diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 9930fb483..000000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..2047096d6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,31 @@
+/.gradle
+/build
+/target
+
+# IntelliJ IDEA
+*.iml
+.idea
+*.iws
+*.ipr
+out/
+.vscode/
+
+# OS
+.DS_Store
+
+# 로그 파일
+*.log
+
+.gitignore
+.git
+README.md
+Dockerfile
+.dockerignore
+
+# 환경 설정 파일
+.env
+discodeit.env
+
+# 임시 파일
+*.tmp
+*.swp
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..8a5622665
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,137 @@
+name: CD Test
+
+on:
+ push:
+ branches: [ "release" ]
+
+env:
+ AWS_REGION_PUBLIC: us-east-1
+ AWS_REGION: ${{ secrets.AWS_REGION }}
+ ECR_REPOSITORY_URI: ${{ secrets.ECR_REPOSITORY_URI }}
+ ECR_REPOSITORY: discodeit
+ ECS_CLUSTER: ${{ secrets.ECS_CLUSTER }}
+ ECS_SERVICE: ${{ secrets.ECS_SERVICE }}
+ ECS_TASK_DEFINITION: ${{ secrets.ECS_TASK_DEFINITION }}
+ CONTAINER_NAME: discodeit-app
+ IMAGE_TAG: ${{ github.sha }}
+
+jobs:
+ build-and-push:
+ name: Build and Docker image push
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: corretto
+ java-version: 17
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
+ aws-region: ${{ env.AWS_REGION_PUBLIC }}
+
+ - name: Login to Amazon ECR public
+ id: login-ecr-public
+ uses: aws-actions/amazon-ecr-login@v2
+ with:
+ registry-type: public
+
+ - name: Build Docker image
+ id: build-image
+ run: |
+ chmod +x ./gradlew
+ ./gradlew bootJar
+ docker build -t ${{ env.ECR_REPOSITORY_URI }}:latest -t ${{ env.ECR_REPOSITORY_URI}}:${IMAGE_TAG} .
+
+ - name: Delete old images (latest + dangling)
+ run: |
+ echo "Deleting 'latest' tagged image..."
+ aws ecr-public batch-delete-image \
+ --repository-name ${{ env.ECR_REPOSITORY }} \
+ --image-ids imageTag=latest || true
+
+ echo "Finding dangling images (no tags)..."
+ dangling=$(aws ecr-public describe-images \
+ --repository-name ${{ env.ECR_REPOSITORY }} \
+ --query 'imageDetails[?imageTags==`null` || length(imageTags)==`0`].imageDigest' \
+ --output json)
+
+ echo "Dangling images: $dangling"
+
+ if [ "$dangling" != "[]" ]; then
+ echo "$dangling" | jq -r '.[]' | while read digest; do
+ echo "Deleting image digest: $digest"
+ aws ecr-public batch-delete-image \
+ --repository-name ${{ env.ECR_REPOSITORY }} \
+ --image-ids imageDigest=$digest || true
+ done
+ else
+ echo "No dangling images to delete."
+ fi
+
+ - name: Push Docker images
+ run: |
+ docker push $ECR_REPOSITORY_URI:latest
+ docker push $ECR_REPOSITORY_URI:${IMAGE_TAG}
+
+ deploy:
+ name: Update ECS Service
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: Update Task Definition
+ id: task-def
+ run: |
+ NEW_IMAGE="$ECR_REPOSITORY_URI:$IMAGE_TAG"
+
+ # 현재 태스크 정의 JSON을 받아와서 새 이미지로 교체
+ aws ecs describe-task-definition --task-definition ${{ env.ECS_TASK_DEFINITION }} \
+ --query "taskDefinition" > taskdef.json
+
+ # 새 이미지 태그로 교체
+ jq --arg IMAGE "$NEW_IMAGE" '.containerDefinitions[0].image=$IMAGE' \
+ taskdef.json > new-taskdef.json
+
+ # 불필요한 필드 제거
+ jq 'del(.status, .revision, .taskDefinitionArn, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \
+ new-taskdef.json > final-taskdef.json
+
+ # 새 태스크 정의 등록
+ TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json file://final-taskdef.json \
+ --query "taskDefinition.taskDefinitionArn" --output text)
+ echo "TASK_DEF_ARN=$TASK_DEF_ARN" >> $GITHUB_ENV
+
+ - name: Scale down ECS service (프리티어 리소스 고려)
+ run: |
+ echo "Scaling down ECS service to 0 tasks..."
+ aws ecs update-service \
+ --cluster ${{ env.ECS_CLUSTER }} \
+ --service ${{ env.ECS_SERVICE }} \
+ --desired-count 0
+ sleep 10
+
+ - name: Start ECS service
+ run: |
+ echo "Updating ECS service to new task definition..."
+ aws ecs update-service \
+ --cluster ${{ env.ECS_CLUSTER }} \
+ --service ${{ env.ECS_SERVICE }} \
+ --task-definition $TASK_DEF_ARN \
+ --desired-count 1
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..e74d679c2
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,28 @@
+name: CI Test
+
+on:
+ push:
+ branches: [ "한동우-sprint8" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: corretto
+ java-version: 17
+
+ - name: Install dependencies & run tests
+ run: ./gradlew clean test jacocoTestReport
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index d5a12dc8e..7827c7f73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,22 @@ bin/
.vscode/
### Mac OS ###
-.DS_Store
\ No newline at end of file
+.DS_Store
+
+## log 파일
+.logs/
+
+## 로컬 바이너리 파일
+binaries/
+
+## 환경 설정 파일
+discodeit.env
+.env
+
+## AWS 관련 민감 파일들
+*.pem
+*.key
+aws-credentials.txt
+.aws/
+credentials
+config
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b81b..000000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index 8bd18ecfa..000000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 2a65317ef..000000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index fe0b0daba..000000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_25/shelved.patch" "b/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_25/shelved.patch"
deleted file mode 100644
index c9e39bd07..000000000
--- "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_25/shelved.patch"
+++ /dev/null
@@ -1,37 +0,0 @@
-Index: .idea/vcs.xml
-IDEA additional info:
-Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
-<+>\n\n \n \n \n \n
-===================================================================
-diff --git a/.idea/vcs.xml b/.idea/vcs.xml
---- a/.idea/vcs.xml (revision 03248a99442145718487c8661d0f332b6369cf4c)
-+++ b/.idea/vcs.xml (date 1744089833326)
-@@ -2,6 +2,5 @@
-
-
-
--
-
-
-\ No newline at end of file
-Index: .idea/misc.xml
-IDEA additional info:
-Subsystem: com.intellij.openapi.diff.impl.patch.BaseRevisionTextPatchEP
-<+>\n\n \n \n \n \n \n \n \n
-===================================================================
-diff --git a/.idea/misc.xml b/.idea/misc.xml
---- a/.idea/misc.xml (revision 03248a99442145718487c8661d0f332b6369cf4c)
-+++ b/.idea/misc.xml (date 1744089833320)
-@@ -1,10 +1,6 @@
-
-
--
--
--
--
--
-+
-
-
-
-\ No newline at end of file
diff --git "a/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_251/shelved.patch" "b/.idea/shelf/Uncommitted_changes_before_Checkout_at_2025__4__8__\354\230\244\355\233\204_2_251/shelved.patch"
deleted file mode 100644
index e69de29bb..000000000
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddfb..000000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..94e8c5cef
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+# 1. 빌드
+FROM amazoncorretto:17-alpine-jdk AS builder
+
+ENV PROJECT_NAME=3-sprint-mission
+ENV PROJECT_VERSION=1.2-M8
+
+WORKDIR /app
+
+# .dockerignore를 활용해 불필요한 파일 제외하고 복사
+COPY . .
+
+# 애플케이션 빌드
+RUN chmod +x gradlew
+RUN ./gradlew bootJar
+
+# 2. 런타임
+# 환경 변수 설정
+FROM amazoncorretto:17-alpine
+
+ENV PROJECT_NAME=3-sprint-mission
+ENV PROJECT_VERSION=1.2-M8
+ENV JVM_OPTS=""
+
+# 작업 디렉토리 설정
+WORKDIR /app
+
+# 빌드 스테이지에서 JAR 파일만 복사
+COPY --from=builder /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}.jar app.jar
+
+# 80 포트 노출
+EXPOSE 80
+
+# 애플리케이션 실행
+ENTRYPOINT ["sh", "-c", "java $JVM_OPTS -jar app.jar"]
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..834fa03ef
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+[](https://codecov.io/gh/dw-real/3-sprint-mission)
diff --git a/build.gradle b/build.gradle
index 9e54c4cd3..da87879ca 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,10 +2,11 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version '1.1.7'
+ id 'jacoco'
}
group = 'com.sprint.mission'
-version = '0.0.1-SNAPSHOT'
+version = '1.2-M8'
java {
toolchain {
@@ -19,6 +20,18 @@ configurations {
}
}
+test {
+ finalizedBy jacocoTestReport
+}
+
+jacocoTestReport {
+ dependsOn test
+ reports {
+ xml.required = true
+ html.required = true
+ }
+}
+
repositories {
mavenCentral()
}
@@ -27,8 +40,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+ implementation 'org.springframework.boot:spring-boot-starter-aop'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
+ runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.7'
runtimeOnly 'org.postgresql:postgresql'
@@ -36,8 +51,14 @@ dependencies {
// https://mvnrepository.com/artifact/org.mapstruct/mapstruct
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
+ // actuator
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ // S3
+ implementation 'software.amazon.awssdk:s3:2.31.7'
}
tasks.named('test') {
useJUnitPlatform()
+ systemProperty "spring.profiles.active", "test"
+ finalizedBy jacocoTestReport
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..1ce3dad4c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,71 @@
+version: '3.8'
+
+# 서비스 정의
+services:
+ postgres:
+ image: postgres:17-alpine
+ container_name: discodeit-db
+ restart: unless-stopped
+
+ # 환경변수 설정
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
+ TZ: "Asia/Seoul"
+
+ # 포트 매핑
+ ports:
+ - "${POSTGRES_PORT}:5432"
+
+ # 볼륨 마운트 (데이터 영속화)
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
+
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: discodeit-app:local-slim
+ container_name: discodeit_app
+ env_file: .env
+ # 환경 변수 설정
+ environment:
+ # STORAGE 설정
+ STORAGE_TYPE: ${STORAGE_TYPE:-local}
+ STORAGE_LOCAL_ROOT_PATH: ${STORAGE_LOCAL_ROOT_PATH}
+
+ # AWS S3 설정
+ AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY}
+ AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY}
+ AWS_S3_REGION: ${AWS_S3_REGION}
+ AWS_S3_BUCKET: ${AWS_S3_BUCKET}
+ AWS_S3_PRESIGNED_URL_EXPIRATION: ${AWS_S3_PRESIGNED_URL_EXPIRATION:-600}
+
+ SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
+ SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
+ SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
+ DISCODEIT_STORAGE_LOCAL_ROOT_PATH: /app/binary_content_storage
+
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ volumes:
+ - app_data:/app/binary_content_storage
+
+ ports:
+ - "${APP_PORT}:80"
+
+volumes:
+ postgres_data:
+ app_data:
\ No newline at end of file
diff --git a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java
index d628b0d64..2ac54d9f3 100644
--- a/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java
+++ b/src/main/java/com/sprint/mission/discodeit/DiscodeitApplication.java
@@ -2,13 +2,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
-@EnableJpaAuditing
@SpringBootApplication
public class DiscodeitApplication {
- public static void main(String[] args) {
- SpringApplication.run(DiscodeitApplication.class, args);
- }
+ public static void main(String[] args) {
+ SpringApplication.run(DiscodeitApplication.class, args);
+ }
}
diff --git a/src/main/java/com/sprint/mission/discodeit/aspect/LoggingAspect.java b/src/main/java/com/sprint/mission/discodeit/aspect/LoggingAspect.java
new file mode 100644
index 000000000..10f038213
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/aspect/LoggingAspect.java
@@ -0,0 +1,163 @@
+package com.sprint.mission.discodeit.aspect;
+
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.*;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+
+@Aspect
+@Component
+@Slf4j
+public class LoggingAspect {
+
+ @Pointcut("execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.*(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.*(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.*(..))")
+ public void serviceLayer() {
+ }
+
+ @Pointcut("execution(* com.sprint.mission.discodeit.controller.UserController.*(..))" +
+ "|| execution(* com.sprint.mission.discodeit.controller.ChannelController.*.*(..))" +
+ "|| execution(* com.sprint.mission.discodeit.controller.MessageController.*(..))")
+ public void controllerLayer() {
+ }
+
+ /**
+ * 메서드 실행 전에 로그를 기록한다.
+ *
+ * @param joinPoint 조인포인트 정보
+ */
+ @Before("serviceLayer() || controllerLayer()")
+ public void logBefore(JoinPoint joinPoint) {
+ String className = joinPoint.getTarget().getClass().getSimpleName();
+ String methodName = joinPoint.getSignature().getName();
+ Object[] args = joinPoint.getArgs();
+
+ log.info("{}.{} 실행 시작 | 매개변수: {}",
+ className, methodName, Arrays.toString(args));
+ }
+
+ /**
+ * 메서드 정상 실행 후에 로그를 기록한다.
+ *
+ * @param joinPoint 조인포인트 정보
+ * @param result 메서드 반환값
+ */
+ @AfterReturning(pointcut = "serviceLayer() || controllerLayer()", returning = "result")
+ public void logAfterReturning(JoinPoint joinPoint, Object result) {
+ String className = joinPoint.getTarget().getClass().getSimpleName();
+ String methodName = joinPoint.getSignature().getName();
+
+ log.info("{}.{} 실행 완료 | 반환값: {}",
+ className, methodName, result);
+ }
+
+ /**
+ * 메서드 실행 중 예외 발생 시 로그를 기록한다.
+ *
+ * @param joinPoint 조인포인트 정보
+ * @param exception 발생한 예외
+ */
+ @AfterThrowing(pointcut = "serviceLayer() || controllerLayer()", throwing = "exception")
+ public void logAfterThrowing(JoinPoint joinPoint, Exception exception) {
+ String className = joinPoint.getTarget().getClass().getSimpleName();
+ String methodName = joinPoint.getSignature().getName();
+
+ log.error("!!! {}.{} 실행 중 예외 발생 - 예외: {}, 메시지: {}",
+ className, methodName, exception.getClass().getSimpleName(), exception.getMessage());
+ }
+
+ /**
+ * 메서드 실행 시간을 측정하고 로그를 기록한다.
+ *
+ * @param proceedingJoinPoint 프로시딩 조인포인트
+ * @return 메서드 실행 결과
+ * @throws Throwable 메서드 실행 중 발생한 예외
+ */
+ @Around("serviceLayer()")
+ public Object logExecutionTime(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
+ String className = proceedingJoinPoint.getTarget().getClass().getSimpleName();
+ String methodName = proceedingJoinPoint.getSignature().getName();
+
+ long startTime = System.currentTimeMillis();
+
+ try {
+ Object result = proceedingJoinPoint.proceed();
+ long endTime = System.currentTimeMillis();
+ long executionTime = endTime - startTime;
+
+ log.info("{}.{} 실행 시간: {}ms", className, methodName, executionTime);
+
+ // 성능 경고 (1초 이상 소요 시)
+ if (executionTime > 1000) {
+ log.warn("[s2][LoggingAspect] {}.{} 실행 시간이 {}ms로 느립니다. 성능 최적화가 필요합니다.",
+ className, methodName, executionTime);
+ }
+
+ return result;
+
+ } catch (Throwable throwable) {
+ long endTime = System.currentTimeMillis();
+ long executionTime = endTime - startTime;
+
+ log.error("{}.{} 실행 실패 | 실행 시간: {}ms, 예외: {}",
+ className, methodName, executionTime, throwable.getMessage());
+
+ throw throwable;
+ }
+ }
+
+ /**
+ * 비즈니스 이벤트 로깅을 위한 포인트컷
+ * 사용자, 채널, 메시지 생성/수정/삭제, 파일 업로드/다운로드
+ */
+ @Pointcut("execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.create(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.update(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.deleteById(..))" +
+
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.createPublicChannel(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.createPrivateChannel(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.update(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.deleteById(..))" +
+
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.create(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.updateContent(..))" +
+ "|| execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.deleteById(..))" +
+
+ "|| execution(* com.sprint.mission.discodeit.storage.LocalBinaryContentStorage.put(..))" +
+ "|| execution(* com.sprint.mission.discodeit.storage.LocalBinaryContentStorage.download(..))")
+ public void businessEvents() {
+ }
+
+ /**
+ * 중요한 비즈니스 이벤트에 대한 상세 로깅을 수행한다.
+ *
+ * @param proceedingJoinPoint 프로시딩 조인포인트
+ * @return 메서드 실행 결과
+ * @throws Throwable 메서드 실행 중 발생한 예외
+ */
+ @Around("businessEvents()")
+ public Object logBusinessEvent(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
+ String methodName = proceedingJoinPoint.getSignature().getName();
+ Object[] args = proceedingJoinPoint.getArgs();
+
+ log.info("비즈니스 이벤트 시작 - 작업: {}, 매개변수: {}", methodName, Arrays.toString(args));
+
+ try {
+ Object result = proceedingJoinPoint.proceed();
+
+ log.info("비즈니스 이벤트 성공 - 작업: {}, 결과: {}", methodName, result);
+
+ return result;
+
+ } catch (Throwable throwable) {
+ log.error("비즈니스 이벤트 실패 - 작업: {}, 예외: {}, 메시지: {}",
+ methodName, throwable.getClass().getSimpleName(), throwable.getMessage());
+
+ throw throwable;
+ }
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/config/JpaConfig.java b/src/main/java/com/sprint/mission/discodeit/config/JpaConfig.java
new file mode 100644
index 000000000..c2a73287d
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/config/JpaConfig.java
@@ -0,0 +1,9 @@
+package com.sprint.mission.discodeit.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@Configuration
+@EnableJpaAuditing
+public class JpaConfig {
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java
new file mode 100644
index 000000000..c655dcb51
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/config/MDCLoggingInterceptor.java
@@ -0,0 +1,39 @@
+package com.sprint.mission.discodeit.config;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.util.UUID;
+
+@Slf4j
+@Component
+public class MDCLoggingInterceptor implements HandlerInterceptor {
+
+ private static final String REQUEST_ID = "requestId";
+ private static final String METHOD = "method";
+ private static final String URL = "url";
+ private static final String HEADER_NAME = "Discodeit-Request-ID";
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+ String requestId = UUID.randomUUID().toString().substring(0, 8);
+ String method = request.getMethod();
+ String url = request.getRequestURI();
+
+ MDC.put(REQUEST_ID, requestId);
+ MDC.put(METHOD, method);
+ MDC.put(URL, url);
+ response.setHeader(HEADER_NAME, requestId);
+
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
+ MDC.clear();
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java
new file mode 100644
index 000000000..500f8f971
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/config/WebMvcConfig.java
@@ -0,0 +1,18 @@
+package com.sprint.mission.discodeit.config;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+@RequiredArgsConstructor
+public class WebMvcConfig implements WebMvcConfigurer {
+
+ private final MDCLoggingInterceptor mdcLoggingInterceptor;
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(mdcLoggingInterceptor);
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java
index 35f86ebb5..c55433550 100644
--- a/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java
+++ b/src/main/java/com/sprint/mission/discodeit/controller/AuthController.java
@@ -4,6 +4,7 @@
import com.sprint.mission.discodeit.dto.auth.LoginDto;
import com.sprint.mission.discodeit.dto.user.UserResponseDto;
import com.sprint.mission.discodeit.service.AuthService;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -20,7 +21,7 @@ public class AuthController implements AuthApi {
private final AuthService authService;
@PostMapping(path = "/login")
- public ResponseEntity login(@RequestBody LoginDto loginDTO) {
+ public ResponseEntity login(@Valid @RequestBody LoginDto loginDTO) {
UserResponseDto loggedInUser = authService.login(loginDTO);
return ResponseEntity.status(HttpStatus.OK).body(loggedInUser);
diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java
index e007006ec..5913e9d6d 100644
--- a/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java
+++ b/src/main/java/com/sprint/mission/discodeit/controller/ChannelController.java
@@ -10,6 +10,7 @@
import java.util.List;
import java.util.UUID;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -32,7 +33,7 @@ public class ChannelController implements ChannelApi {
@PostMapping(path = "/public")
public ResponseEntity createPublicChannel(
- @RequestBody PublicChannelDto publicChannelDTO) {
+ @Valid @RequestBody PublicChannelDto publicChannelDTO) {
ChannelResponseDto createdChannel = channelService.createPublicChannel(publicChannelDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(createdChannel);
diff --git a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java
index f033e6d93..5aa2dcccc 100644
--- a/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java
+++ b/src/main/java/com/sprint/mission/discodeit/controller/MessageController.java
@@ -8,6 +8,7 @@
import com.sprint.mission.discodeit.dto.response.PageResponse;
import com.sprint.mission.discodeit.service.MessageService;
import com.sprint.mission.discodeit.util.FileConverter;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@@ -31,7 +32,7 @@ public class MessageController implements MessageApi {
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity create(
- @RequestPart("messageCreateRequest") MessageRequestDto messageRequestDTO,
+ @Valid @RequestPart("messageCreateRequest") MessageRequestDto messageRequestDTO,
@RequestPart(value = "attachments", required = false) List attachedFiles) {
List binaryContentDtos = FileConverter.resolveFileRequest(attachedFiles);
diff --git a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java
index 5a344fad4..5a5b58584 100644
--- a/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java
+++ b/src/main/java/com/sprint/mission/discodeit/controller/ReadStatusController.java
@@ -5,6 +5,7 @@
import com.sprint.mission.discodeit.dto.readstatus.ReadStatusResponseDto;
import com.sprint.mission.discodeit.dto.readstatus.ReadStatusUpdateDto;
import com.sprint.mission.discodeit.service.ReadStatusService;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -21,7 +22,7 @@ public class ReadStatusController implements ReadStatusApi {
private final ReadStatusService readStatusService;
@PostMapping
- public ResponseEntity create(@RequestBody ReadStatusRequestDto readStatusRequestDTO) {
+ public ResponseEntity create(@Valid @RequestBody ReadStatusRequestDto readStatusRequestDTO) {
ReadStatusResponseDto createdReadStatus = readStatusService.create(readStatusRequestDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(createdReadStatus);
diff --git a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java
index 17e3e0cb2..4c764a68a 100644
--- a/src/main/java/com/sprint/mission/discodeit/controller/UserController.java
+++ b/src/main/java/com/sprint/mission/discodeit/controller/UserController.java
@@ -10,6 +10,7 @@
import com.sprint.mission.discodeit.service.UserService;
import com.sprint.mission.discodeit.service.UserStatusService;
import com.sprint.mission.discodeit.util.FileConverter;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -43,7 +44,7 @@ public class UserController implements UserApi {
// 신규 유저 생성 요청
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity create(
- @RequestPart("userCreateRequest") UserRequestDto userRequestDTO,
+ @Valid @RequestPart("userCreateRequest") UserRequestDto userRequestDTO,
@RequestPart(value = "profile", required = false) MultipartFile profile) {
BinaryContentDto profileRequest = FileConverter.resolveFileRequest(profile);
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java b/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java
index abe6cde5b..674080bd8 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/auth/LoginDto.java
@@ -1,8 +1,13 @@
package com.sprint.mission.discodeit.dto.auth;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
@Schema(description = "로그인 정보")
-public record LoginDto(String username, String password) {
+public record LoginDto(
+ @NotBlank(message = "사용자 이름은 필수 입력 항목입니다.")
+ String username,
+ @NotBlank(message = "비밀번호는 필수 입력 항목입니다.")
+ String password) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java
index 2782a53a4..5ca5bf088 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelDto.java
@@ -1,8 +1,16 @@
package com.sprint.mission.discodeit.dto.channel;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
@Schema(description = "Public Channel 생성 및 수정 정보")
-public record PublicChannelDto(String name, String description) {
+public record PublicChannelDto(
+ @NotBlank(message = "채널 이름은 필수 입력 항목입니다.")
+ @Size(max = 100, message = "채널 이름은 최대 100자까지 입력 가능합니다.")
+ String name,
+
+ @Size(max = 500, message = "채널에 대한 설명은 최대 500자까지 입력 가능합니다.")
+ String description) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java
index 00ad02193..d04466226 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/channel/PublicChannelUpdateDto.java
@@ -1,5 +1,14 @@
package com.sprint.mission.discodeit.dto.channel;
-public record PublicChannelUpdateDto(String newName, String newDescription) {
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record PublicChannelUpdateDto(
+ @NotBlank(message = "채널 이름은 필수 입력 항목입니다.")
+ @Size(max = 100, message = "채널 이름은 최대 100자까지 입력 가능합니다.")
+ String newName,
+
+ @Size(max = 500, message = "채널에 대한 설명은 최대 500자까지 입력 가능합니다.")
+ String newDescription) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java
index d78c4b66c..5f83915af 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageRequestDto.java
@@ -1,9 +1,20 @@
package com.sprint.mission.discodeit.dto.message;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
import java.util.UUID;
@Schema(description = "Message 생성 및 수정 정보")
-public record MessageRequestDto(String content, UUID channelId, UUID authorId) {
+public record MessageRequestDto(
+ @Size(max = 1000, message = "메시지는 1000자 이내여야 합니다.")
+ String content,
+
+ @NotNull(message = "channelId는 필수입니다.")
+ UUID channelId,
+
+ @NotNull(message = "작성자 ID는 필수입니다.")
+ UUID authorId) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java
index f8812475f..c6566bf97 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/message/MessageUpdateDto.java
@@ -1,5 +1,9 @@
package com.sprint.mission.discodeit.dto.message;
-public record MessageUpdateDto(String newContent) {
+import jakarta.validation.constraints.Size;
+
+public record MessageUpdateDto(
+ @Size(max = 1000, message = "메시지는 1000자 이내여야 합니다.")
+ String newContent) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java
index d38c222b3..7b9788354 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusRequestDto.java
@@ -1,9 +1,19 @@
package com.sprint.mission.discodeit.dto.readstatus;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+
import java.time.Instant;
import java.util.UUID;
@Schema(description = "Message 읽음 상태 생성 및 수정 정보")
-public record ReadStatusRequestDto(UUID userId, UUID channelId, Instant lastReadAt) {
+public record ReadStatusRequestDto(
+ @NotNull(message = "사용자 ID는 필수입니다.")
+ UUID userId,
+
+ @NotNull(message = "채널 ID는 필수입니다.")
+ UUID channelId,
+
+ @NotNull(message = "마지막 읽음 시간은 필수입니다.")
+ Instant lastReadAt) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java
index e980d17b6..611e23118 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/readstatus/ReadStatusUpdateDto.java
@@ -1,7 +1,11 @@
package com.sprint.mission.discodeit.dto.readstatus;
+import jakarta.validation.constraints.NotNull;
+
import java.time.Instant;
-public record ReadStatusUpdateDto(Instant newLastReadAt) {
+public record ReadStatusUpdateDto(
+ @NotNull(message = "마지막 읽음 시간은 필수입니다.")
+ Instant newLastReadAt) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java b/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java
new file mode 100644
index 000000000..e3597b58e
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/dto/response/ErrorResponse.java
@@ -0,0 +1,12 @@
+package com.sprint.mission.discodeit.dto.response;
+
+import java.time.Instant;
+import java.util.Map;
+
+public record ErrorResponse(Instant timestamp,
+ String code,
+ String message,
+ Map details,
+ String exceptionType,
+ int status) {
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java b/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java
index 44d7aad8e..3a949e141 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/user/UserRequestDto.java
@@ -1,8 +1,21 @@
package com.sprint.mission.discodeit.dto.user;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
@Schema(description = "User 생성 및 수정 정보")
-public record UserRequestDto(String username, String email, String password) {
+public record UserRequestDto(
+ @NotBlank(message = "이름은 필수 입력 항목입니다.")
+ @Size(max = 10, message = "이름은 최대 10자까지 입력 가능합니다.")
+ String username,
+
+ @NotBlank(message = "이메일은 필수 입력 항목입니다.")
+ @Email(message = "올바른 이메일 형식이 아닙니다.")
+ String email,
+
+ @Size(min = 4, max = 20, message = "비밀번호는 4자 이상 20자 이하로 입력해주세요")
+ String password) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java b/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java
index 1e731d943..bf9bca3d8 100644
--- a/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java
+++ b/src/main/java/com/sprint/mission/discodeit/dto/user/UserUpdateDto.java
@@ -1,5 +1,19 @@
package com.sprint.mission.discodeit.dto.user;
-public record UserUpdateDto(String newUsername, String newEmail, String newPassword) {
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record UserUpdateDto(
+ @NotBlank(message = "이름은 필수 입력 항목입니다.")
+ @Size(max = 10, message = "이름은 최대 10자까지 입력 가능합니다.")
+ String newUsername,
+
+ @NotBlank(message = "이메일은 필수 입력 항목입니다.")
+ @Email(message = "올바른 이메일 형식이 아닙니다.")
+ String newEmail,
+
+ @Size(min = 4, max = 20, message = "비밀번호는 4자 이상 20자 이하로 입력해주세요")
+ String newPassword) {
}
diff --git a/src/main/java/com/sprint/mission/discodeit/entity/ErrorCode.java b/src/main/java/com/sprint/mission/discodeit/entity/ErrorCode.java
new file mode 100644
index 000000000..88fc400bd
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/entity/ErrorCode.java
@@ -0,0 +1,26 @@
+package com.sprint.mission.discodeit.entity;
+
+import lombok.Getter;
+
+@Getter
+public enum ErrorCode {
+ USER_NOT_FOUND("사용자를 찾을 수 없습니다"),
+ DUPLICATE_USER_NAME("이미 존재하는 Username입니다."),
+ DUPLICATE_USER_EMAIL("이미 존재하는 이메일입니다."),
+ CHANNEL_NOT_FOUND("채널을 찾을 수 없습니다."),
+ PRIVATE_CHANNEL_UPDATE("Private Channel은 수정할 수 없습니다."),
+ MESSAGE_NOT_FOUND("메시지를 찾을 수 없습니다."),
+ LOGIN_FAILED("로그인이 실패했습니다. 비밀번호를 확인해주세요."),
+ BINARY_CONTENT_NOT_FOUND("파일을 찾을 수 없습니다."),
+ READ_STATUS_NOT_FOUND("읽음 상태를 찾을 수 없습니다."),
+ USER_STATUS_NOT_FOUND("사용자 상태를 찾을 수 없습니다."),
+ READ_STATUS_ALREADY_EXIST("이미 존재하는 읽음 상태입니다."),
+ USER_STATUS_ALREADY_EXIST("이미 존재하는 사용자 상태입니다."),
+ INTERNAL_SERVER_ERROR("서버 내부 오류가 발생했습니다.");
+
+ private final String message;
+
+ ErrorCode(String message) {
+ this.message = message;
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java
new file mode 100644
index 000000000..2ea0661fc
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/DiscodeitException.java
@@ -0,0 +1,22 @@
+package com.sprint.mission.discodeit.exception;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import lombok.Getter;
+
+import java.time.Instant;
+import java.util.Map;
+
+@Getter
+public class DiscodeitException extends RuntimeException {
+
+ private final Instant timestamp;
+ private final ErrorCode errorCode;
+ private final Map details;
+
+ public DiscodeitException(String message, ErrorCode errorCode, Map details) {
+ super(message);
+ this.timestamp = Instant.now();
+ this.errorCode = errorCode;
+ this.details = details;
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/LoginFailedException.java b/src/main/java/com/sprint/mission/discodeit/exception/LoginFailedException.java
deleted file mode 100644
index 731c69da4..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/LoginFailedException.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.sprint.mission.discodeit.exception;
-
-public class LoginFailedException extends RuntimeException {
-
- public LoginFailedException() {
- super("비밀번호가 일치하지 않습니다.");
- }
-
- public LoginFailedException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/PrivateChannelModificationException.java b/src/main/java/com/sprint/mission/discodeit/exception/PrivateChannelModificationException.java
deleted file mode 100644
index 55defd07e..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/PrivateChannelModificationException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception;
-
-public class PrivateChannelModificationException extends RuntimeException {
- public PrivateChannelModificationException() {
- super("PRIVATE 채널은 변경이 불가능합니다.");
- }
-
- public PrivateChannelModificationException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/AlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/AlreadyExistsException.java
deleted file mode 100644
index 06fcd75db..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/AlreadyExistsException.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.sprint.mission.discodeit.exception.alreadyexist;
-
-public class AlreadyExistsException extends RuntimeException {
- public AlreadyExistsException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/ReadStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/ReadStatusAlreadyExistsException.java
deleted file mode 100644
index 219912343..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/ReadStatusAlreadyExistsException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception.alreadyexist;
-
-public class ReadStatusAlreadyExistsException extends RuntimeException {
- public ReadStatusAlreadyExistsException() {
- super("해당 User, Channel과 관련된 상태가 이미 존재합니다.");
- }
-
- public ReadStatusAlreadyExistsException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/UserStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/UserStatusAlreadyExistsException.java
deleted file mode 100644
index 17d88e9c2..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/alreadyexist/UserStatusAlreadyExistsException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception.alreadyexist;
-
-public class UserStatusAlreadyExistsException extends AlreadyExistsException {
- public UserStatusAlreadyExistsException() {
- super("해당 User와 관련된 UserStatus가 이미 존재합니다.");
- }
-
- public UserStatusAlreadyExistsException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java
new file mode 100644
index 000000000..2f81b4994
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/BinaryContentException.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.exception.binarycontent;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+
+import java.util.Map;
+
+public class BinaryContentException extends DiscodeitException {
+
+ public BinaryContentException(String message, ErrorCode errorCode, Map details) {
+ super(message, errorCode, details);
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/NotFoundBinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/NotFoundBinaryContentException.java
new file mode 100644
index 000000000..70faab30d
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/binarycontent/NotFoundBinaryContentException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.binarycontent;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class NotFoundBinaryContentException extends BinaryContentException {
+
+ public NotFoundBinaryContentException(UUID id) {
+ super(
+ ErrorCode.BINARY_CONTENT_NOT_FOUND.getMessage() + " 파일 id: " + id,
+ ErrorCode.BINARY_CONTENT_NOT_FOUND,
+ Map.of("binaryContentId", id)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java
new file mode 100644
index 000000000..80566ca3b
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/ChannelException.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.exception.channel;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+
+import java.util.Map;
+
+public class ChannelException extends DiscodeitException {
+
+ public ChannelException(String message, ErrorCode errorCode, Map details) {
+ super(message, errorCode, details);
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/NotFoundChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/NotFoundChannelException.java
new file mode 100644
index 000000000..e15933c61
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/NotFoundChannelException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.channel;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class NotFoundChannelException extends ChannelException {
+
+ public NotFoundChannelException(UUID channelId) {
+ super(
+ ErrorCode.CHANNEL_NOT_FOUND.getMessage() + " channelId: " + channelId,
+ ErrorCode.CHANNEL_NOT_FOUND,
+ Map.of("channelId", channelId)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java
new file mode 100644
index 000000000..f9cd00535
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/channel/PrivateChannelUpdateException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.channel;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class PrivateChannelUpdateException extends ChannelException {
+
+ public PrivateChannelUpdateException(UUID channelId) {
+ super(
+ ErrorCode.PRIVATE_CHANNEL_UPDATE.getMessage(),
+ ErrorCode.PRIVATE_CHANNEL_UPDATE,
+ Map.of("channelId", "channelId")
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateEmailException.java b/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateEmailException.java
deleted file mode 100644
index 685ca532b..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateEmailException.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.sprint.mission.discodeit.exception.duplicate;
-
-public class DuplicateEmailException extends DuplicateException {
- public DuplicateEmailException(String email) {
- super(email + "은 이미 존재하는 email 입니다.");
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateException.java b/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateException.java
deleted file mode 100644
index bb96a25dc..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateException.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.sprint.mission.discodeit.exception.duplicate;
-
-public class DuplicateException extends RuntimeException {
- public DuplicateException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateNameException.java b/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateNameException.java
deleted file mode 100644
index 3ca03c406..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/duplicate/DuplicateNameException.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.sprint.mission.discodeit.exception.duplicate;
-
-public class DuplicateNameException extends DuplicateException {
- public DuplicateNameException(String userName) {
- super(userName + "은 이미 존재합니다.");
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java
index 0061ca904..f11e226f4 100644
--- a/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java
+++ b/src/main/java/com/sprint/mission/discodeit/exception/handler/GlobalExceptionHandler.java
@@ -1,48 +1,115 @@
package com.sprint.mission.discodeit.exception.handler;
-import com.sprint.mission.discodeit.exception.LoginFailedException;
-import com.sprint.mission.discodeit.exception.PrivateChannelModificationException;
-import com.sprint.mission.discodeit.exception.alreadyexist.AlreadyExistsException;
-import com.sprint.mission.discodeit.exception.duplicate.DuplicateException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundException;
+import com.sprint.mission.discodeit.dto.response.ErrorResponse;
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+import com.sprint.mission.discodeit.exception.binarycontent.NotFoundBinaryContentException;
+import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException;
+import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException;
+import com.sprint.mission.discodeit.exception.message.NotFoundMessageException;
+import com.sprint.mission.discodeit.exception.readstatus.NotFoundReadStatusException;
+import com.sprint.mission.discodeit.exception.readstatus.ReadStatusAlreadyExistsException;
+import com.sprint.mission.discodeit.exception.user.DuplicateEmailException;
+import com.sprint.mission.discodeit.exception.user.DuplicateNameException;
+import com.sprint.mission.discodeit.exception.user.LoginFailedException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
+import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException;
+import com.sprint.mission.discodeit.exception.userstatus.UserStatusAlreadyExistsException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
-@ControllerAdvice
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestControllerAdvice
public class GlobalExceptionHandler {
- @ExceptionHandler(LoginFailedException.class)
- public ResponseEntity handleLoginFailedException(Exception e) {
- return ResponseEntity
- .status(HttpStatus.UNAUTHORIZED) // 401
- .body(e.getMessage());
+ @ExceptionHandler({NotFoundUserException.class, NotFoundChannelException.class,
+ NotFoundMessageException.class, NotFoundBinaryContentException.class,
+ NotFoundUserStatusException.class, NotFoundReadStatusException.class})
+ public ResponseEntity handleNotFoundException(DiscodeitException e) {
+ ErrorResponse errorResponse = new ErrorResponse(
+ Instant.now(),
+ e.getErrorCode().toString(),
+ e.getMessage(),
+ e.getDetails(),
+ e.getClass().getTypeName(),
+ HttpStatus.NOT_FOUND.value()
+ );
+
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
- @ExceptionHandler(NotFoundException.class)
- public ResponseEntity handleNotFoundException(Exception e) {
- return ResponseEntity
- .status(HttpStatus.NOT_FOUND)
- .body(e.getMessage());
+ @ExceptionHandler({DuplicateEmailException.class, DuplicateNameException.class,
+ UserStatusAlreadyExistsException.class, ReadStatusAlreadyExistsException.class,
+ PrivateChannelUpdateException.class})
+ public ResponseEntity handleBadRequestException(DiscodeitException e) {
+ ErrorResponse errorResponse = new ErrorResponse(
+ Instant.now(),
+ e.getErrorCode().toString(),
+ e.getMessage(),
+ e.getDetails(),
+ e.getClass().getTypeName(),
+ HttpStatus.BAD_REQUEST.value()
+ );
+
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
-
- @ExceptionHandler({PrivateChannelModificationException.class, DuplicateException.class,
- AlreadyExistsException.class})
- public ResponseEntity handleBadRequestException(Exception e) {
- return ResponseEntity
- .status(HttpStatus.BAD_REQUEST)
- .body(e.getMessage());
+
+ @ExceptionHandler(LoginFailedException.class)
+ public ResponseEntity handleLoginFailedException(DiscodeitException e) {
+ ErrorResponse errorResponse = new ErrorResponse(
+ Instant.now(),
+ e.getErrorCode().toString(),
+ e.getMessage(),
+ e.getDetails(),
+ e.getClass().getTypeName(),
+ HttpStatus.UNAUTHORIZED.value()
+ );
+
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
+ }
+
+ // @Valid 검증 실패 시 예외 처리
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationException(MethodArgumentNotValidException e) {
+ List fieldErrors = e.getBindingResult().getFieldErrors();
+
+ Map details = new HashMap<>();
+ for (FieldError fieldError : fieldErrors) {
+ details.put(fieldError.getField(), fieldError.getDefaultMessage());
+ }
+
+ ErrorResponse errorResponse = new ErrorResponse(
+ Instant.now(),
+ "VALIDATION_FAILED",
+ "유효성 검사 실패",
+ details,
+ e.getClass().getTypeName(),
+ HttpStatus.BAD_REQUEST.value()
+ );
+
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
/* 정의되지 않은 예외 처리 */
@ExceptionHandler(RuntimeException.class)
- public ResponseEntity handleUnhandledException(Exception e) {
-
- e.printStackTrace();
+ public ResponseEntity handleUnhandledException(Exception e) {
+ ErrorResponse errorResponse = new ErrorResponse(
+ Instant.now(),
+ ErrorCode.INTERNAL_SERVER_ERROR.toString(),
+ e.getMessage(),
+ null,
+ e.getClass().getTypeName(),
+ HttpStatus.INTERNAL_SERVER_ERROR.value()
+ );
- return ResponseEntity
- .status(HttpStatus.INTERNAL_SERVER_ERROR)
- .body("서버 내부 오류가 발생했습니다.");
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java
new file mode 100644
index 000000000..0301ece60
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/message/MessageException.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.exception.message;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+
+import java.util.Map;
+
+public class MessageException extends DiscodeitException {
+
+ public MessageException(String message, ErrorCode errorCode, Map details) {
+ super(message, errorCode, details);
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/message/NotFoundMessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/message/NotFoundMessageException.java
new file mode 100644
index 000000000..72d51c188
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/message/NotFoundMessageException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.message;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class NotFoundMessageException extends MessageException {
+
+ public NotFoundMessageException(UUID messageId) {
+ super(
+ ErrorCode.MESSAGE_NOT_FOUND.getMessage() + " messageId: + " + messageId,
+ ErrorCode.MESSAGE_NOT_FOUND,
+ Map.of("messageId", messageId)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundBinaryContentException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundBinaryContentException.java
deleted file mode 100644
index bb7517e85..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundBinaryContentException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception.notfound;
-
-public class NotFoundBinaryContentException extends NotFoundException {
- public NotFoundBinaryContentException() {
- super("존재하지 않는 파일입니다.");
- }
-
- public NotFoundBinaryContentException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundChannelException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundChannelException.java
deleted file mode 100644
index 2e9defb1b..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundChannelException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception.notfound;
-
-public class NotFoundChannelException extends NotFoundException {
- public NotFoundChannelException() {
- super("존재하지 않는 채널입니다.");
- }
-
- public NotFoundChannelException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundException.java
deleted file mode 100644
index 367546797..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundException.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.sprint.mission.discodeit.exception.notfound;
-
-public class NotFoundException extends RuntimeException {
- public NotFoundException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundMessageException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundMessageException.java
deleted file mode 100644
index cd7c741ff..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundMessageException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception.notfound;
-
-public class NotFoundMessageException extends NotFoundException {
- public NotFoundMessageException() {
- super("존재하지 않는 메시지입니다.");
- }
-
- public NotFoundMessageException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundReadStatusException.java
deleted file mode 100644
index 91f679fa9..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundReadStatusException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception.notfound;
-
-public class NotFoundReadStatusException extends NotFoundException {
- public NotFoundReadStatusException() {
- super("존재하지 않는 ReadStatus 입니다.");
- }
-
- public NotFoundReadStatusException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserException.java
deleted file mode 100644
index 2fb24292d..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserException.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.sprint.mission.discodeit.exception.notfound;
-
-public class NotFoundUserException extends NotFoundException {
-
- public NotFoundUserException() {
- super("존재하지 않는 사용자입니다.");
- }
-
- public NotFoundUserException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserStatusException.java
deleted file mode 100644
index 2856847d3..000000000
--- a/src/main/java/com/sprint/mission/discodeit/exception/notfound/NotFoundUserStatusException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.sprint.mission.discodeit.exception.notfound;
-
-public class NotFoundUserStatusException extends NotFoundException {
- public NotFoundUserStatusException() {
- super("해당 UserStatus가 존재하지 않습니다.");
- }
-
- public NotFoundUserStatusException(String message) {
- super(message);
- }
-}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/NotFoundReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/NotFoundReadStatusException.java
new file mode 100644
index 000000000..70521d648
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/NotFoundReadStatusException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.readstatus;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class NotFoundReadStatusException extends ReadStatusException {
+
+ public NotFoundReadStatusException(UUID id) {
+ super(
+ ErrorCode.READ_STATUS_NOT_FOUND.getMessage() + " id: " + id,
+ ErrorCode.READ_STATUS_NOT_FOUND,
+ Map.of("readStatusId", id)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusAlreadyExistsException.java
new file mode 100644
index 000000000..4b2d74479
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusAlreadyExistsException.java
@@ -0,0 +1,19 @@
+package com.sprint.mission.discodeit.exception.readstatus;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class ReadStatusAlreadyExistsException extends ReadStatusException {
+
+ public ReadStatusAlreadyExistsException(UUID userId, UUID channelId) {
+ super(
+ ErrorCode.READ_STATUS_ALREADY_EXIST.getMessage() + " userId: " + userId + " channelId: "
+ + channelId,
+ ErrorCode.READ_STATUS_ALREADY_EXIST,
+ Map.of("channelId", channelId,
+ "userId", userId)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java
new file mode 100644
index 000000000..bdde321f5
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/readstatus/ReadStatusException.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.exception.readstatus;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+
+import java.util.Map;
+
+public class ReadStatusException extends DiscodeitException {
+
+ public ReadStatusException(String message, ErrorCode errorCode, Map details) {
+ super(message, errorCode, details);
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateEmailException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateEmailException.java
new file mode 100644
index 000000000..bf43cf4fe
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateEmailException.java
@@ -0,0 +1,16 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+
+public class DuplicateEmailException extends UserException {
+
+ public DuplicateEmailException(String email) {
+ super(
+ ErrorCode.DUPLICATE_USER_EMAIL.getMessage() + " email: " + email,
+ ErrorCode.DUPLICATE_USER_EMAIL,
+ Map.of("email", email)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateNameException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateNameException.java
new file mode 100644
index 000000000..e8036221d
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/DuplicateNameException.java
@@ -0,0 +1,16 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+
+public class DuplicateNameException extends UserException {
+
+ public DuplicateNameException(String username) {
+ super(
+ ErrorCode.DUPLICATE_USER_NAME.getMessage() + " name: " + username,
+ ErrorCode.DUPLICATE_USER_NAME,
+ Map.of("username", username)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/LoginFailedException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/LoginFailedException.java
new file mode 100644
index 000000000..11632df20
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/LoginFailedException.java
@@ -0,0 +1,16 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+
+public class LoginFailedException extends UserException {
+
+ public LoginFailedException(String username) {
+ super(
+ ErrorCode.LOGIN_FAILED.getMessage() + " username: " + username,
+ ErrorCode.LOGIN_FAILED,
+ Map.of("username", username)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/NotFoundUserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/NotFoundUserException.java
new file mode 100644
index 000000000..50f94e22b
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/NotFoundUserException.java
@@ -0,0 +1,25 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class NotFoundUserException extends UserException {
+
+ public NotFoundUserException(UUID userId) {
+ super(
+ ErrorCode.USER_NOT_FOUND.getMessage() + " userId: " + userId,
+ ErrorCode.USER_NOT_FOUND,
+ Map.of("userId", userId)
+ );
+ }
+
+ public NotFoundUserException(String username) {
+ super(
+ ErrorCode.USER_NOT_FOUND.getMessage() + " username: " + username,
+ ErrorCode.USER_NOT_FOUND,
+ Map.of("username", username)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java
new file mode 100644
index 000000000..4edc0f745
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/user/UserException.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.exception.user;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+
+import java.util.Map;
+
+public class UserException extends DiscodeitException {
+
+ public UserException(String message, ErrorCode errorCode, Map details) {
+ super(message, errorCode, details);
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/NotFoundUserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/NotFoundUserStatusException.java
new file mode 100644
index 000000000..a936fdf6f
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/NotFoundUserStatusException.java
@@ -0,0 +1,25 @@
+package com.sprint.mission.discodeit.exception.userstatus;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class NotFoundUserStatusException extends UserStatusException {
+
+ public NotFoundUserStatusException() {
+ super(
+ ErrorCode.USER_STATUS_NOT_FOUND.getMessage(),
+ ErrorCode.USER_STATUS_NOT_FOUND,
+ null
+ );
+ }
+
+ public NotFoundUserStatusException(UUID userId) {
+ super(
+ ErrorCode.USER_STATUS_NOT_FOUND.getMessage() + " 사용자 id: " + userId,
+ ErrorCode.USER_STATUS_NOT_FOUND,
+ Map.of("userId", userId)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistsException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistsException.java
new file mode 100644
index 000000000..24b8890c4
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusAlreadyExistsException.java
@@ -0,0 +1,17 @@
+package com.sprint.mission.discodeit.exception.userstatus;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+
+import java.util.Map;
+import java.util.UUID;
+
+public class UserStatusAlreadyExistsException extends UserStatusException {
+
+ public UserStatusAlreadyExistsException(UUID userId) {
+ super(
+ ErrorCode.USER_STATUS_ALREADY_EXIST.getMessage() + "사용자 id: " + userId,
+ ErrorCode.USER_STATUS_ALREADY_EXIST,
+ Map.of("userId", userId)
+ );
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java
new file mode 100644
index 000000000..78826b8ec
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/exception/userstatus/UserStatusException.java
@@ -0,0 +1,13 @@
+package com.sprint.mission.discodeit.exception.userstatus;
+
+import com.sprint.mission.discodeit.entity.ErrorCode;
+import com.sprint.mission.discodeit.exception.DiscodeitException;
+
+import java.util.Map;
+
+public class UserStatusException extends DiscodeitException {
+
+ public UserStatusException(String message, ErrorCode errorCode, Map details) {
+ super(message, errorCode, details);
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java
index eba928886..c4925b061 100644
--- a/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java
+++ b/src/main/java/com/sprint/mission/discodeit/mapper/ChannelMapper.java
@@ -54,6 +54,6 @@ private Instant getLastMessageAt(Channel channel) {
.stream()
.map(Message::getCreatedAt)
.findFirst()
- .orElse(Instant.MIN);
+ .orElse(null);
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java
index 302559fbb..535560817 100644
--- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicAuthService.java
@@ -4,9 +4,9 @@
import com.sprint.mission.discodeit.dto.user.UserResponseDto;
import com.sprint.mission.discodeit.entity.User;
import com.sprint.mission.discodeit.entity.UserStatus;
-import com.sprint.mission.discodeit.exception.LoginFailedException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserStatusException;
+import com.sprint.mission.discodeit.exception.user.LoginFailedException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
+import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException;
import com.sprint.mission.discodeit.mapper.UserMapper;
import com.sprint.mission.discodeit.repository.UserRepository;
import com.sprint.mission.discodeit.repository.UserStatusRepository;
@@ -34,10 +34,10 @@ public UserResponseDto login(LoginDto loginDTO) {
String password = loginDTO.password();
User user = userRepository.findByUsername(username)
- .orElseThrow(() -> new NotFoundUserException("username이 " + username + "인 사용자를 찾을 수 없습니다."));
+ .orElseThrow(() -> new NotFoundUserException(username));
if (!user.getPassword().equals(password)) {
- throw new LoginFailedException();
+ throw new LoginFailedException(user.getUsername());
}
// User 로그인 시 User 온라인 상태 정보 변경
@@ -51,6 +51,6 @@ public UserResponseDto login(LoginDto loginDTO) {
private UserStatus findUserStatus(UUID userId) {
return userStatusRepository.findByUserId(userId)
- .orElseThrow(NotFoundUserStatusException::new);
+ .orElseThrow(() -> new NotFoundUserStatusException(userId));
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java
index f785e2c1f..32e22fc1d 100644
--- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicBinaryContentService.java
@@ -2,7 +2,7 @@
import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
import com.sprint.mission.discodeit.entity.BinaryContent;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundBinaryContentException;
+import com.sprint.mission.discodeit.exception.binarycontent.NotFoundBinaryContentException;
import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper;
import com.sprint.mission.discodeit.repository.BinaryContentRepository;
import com.sprint.mission.discodeit.service.BinaryContentService;
@@ -24,7 +24,7 @@ public class BasicBinaryContentService implements BinaryContentService {
@Override
public BinaryContentResponseDto findById(UUID id) {
BinaryContent foundBinaryContent = binaryContentRepository.findById(id)
- .orElseThrow(NotFoundBinaryContentException::new);
+ .orElseThrow(() -> new NotFoundBinaryContentException(id));
return binaryContentMapper.toDto(foundBinaryContent);
}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java
index 4e6e68289..7e20f8d2e 100644
--- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicChannelService.java
@@ -8,22 +8,23 @@
import com.sprint.mission.discodeit.entity.ChannelType;
import com.sprint.mission.discodeit.entity.ReadStatus;
import com.sprint.mission.discodeit.entity.User;
-import com.sprint.mission.discodeit.exception.PrivateChannelModificationException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundChannelException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException;
+import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException;
+import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
import com.sprint.mission.discodeit.mapper.ChannelMapper;
import com.sprint.mission.discodeit.repository.ChannelRepository;
import com.sprint.mission.discodeit.repository.ReadStatusRepository;
import com.sprint.mission.discodeit.repository.UserRepository;
import com.sprint.mission.discodeit.service.ChannelService;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
+@Slf4j
@Service("basicChannelService")
@RequiredArgsConstructor
@Transactional(readOnly = true)
@@ -41,6 +42,9 @@ public ChannelResponseDto createPublicChannel(PublicChannelDto publicChannelDto)
String name = publicChannelDto.name();
String description = publicChannelDto.description();
+ log.info("[BasicChannelService] 공개 채널 생성 요청 - name: {}, description: {}",
+ name, description);
+
Channel channel = Channel.builder()
.name(name)
.description(description)
@@ -49,12 +53,17 @@ public ChannelResponseDto createPublicChannel(PublicChannelDto publicChannelDto)
Channel savedChannel = channelRepository.save(channel);
+ log.info("[BasicChannelService] 공개 채널 생성 성공 - id: {}, name: {}, description: {}",
+ savedChannel.getId(), savedChannel.getName(), savedChannel.getDescription());
+
return channelMapper.toDto(savedChannel);
}
@Override
@Transactional
public ChannelResponseDto createPrivateChannel(PrivateChannelDto privateChannelDto) {
+ log.info("[BasicChannelService] 개인 채널 생성 요청 participants: {}", privateChannelDto.participantIds().size());
+
Channel channel = new Channel();
Channel createdChannel = channelRepository.save(channel);
@@ -75,6 +84,8 @@ public ChannelResponseDto createPrivateChannel(PrivateChannelDto privateChannelD
readStatusRepository.saveAll(readStatuses);
+ log.info("[BasicChannelService] 비공개 채널 생성 성공 id: {}", createdChannel.getId());
+
return channelMapper.toDto(createdChannel);
}
@@ -104,36 +115,51 @@ public List findAllByUserId(UUID userId) {
public ChannelResponseDto update(UUID channelId, PublicChannelUpdateDto publicChannelUpdateDto) {
Channel channel = findChannel(channelId);
+ String newName = publicChannelUpdateDto.newName();
+ String newDescription = publicChannelUpdateDto.newDescription();
+
+ log.info("[BasicChannelService] 공개 채널 수정 요청: id: {}, newName: {}, newDescription: {}",
+ channelId, newName, newDescription);
+
// PRIVATE 채널은 수정 불가
if (channel.getType().equals(ChannelType.PRIVATE)) {
- throw new PrivateChannelModificationException();
+ throw new PrivateChannelUpdateException(channelId);
}
// 변경 사항 적용
- channel.updateName(publicChannelUpdateDto.newName());
- channel.updateDescription(publicChannelUpdateDto.newDescription());
+ channel.updateName(newName);
+ channel.updateDescription(newDescription);
- channelRepository.save(channel);
+ Channel updatedChannel = channelRepository.save(channel);
- return channelMapper.toDto(channel);
+ log.info("[BasicChannelService] 공개 채널 수정 성공: id: {}, newName: {}, newDescription: {}",
+ updatedChannel.getId(), updatedChannel.getName(), updatedChannel.getDescription());
+
+ return channelMapper.toDto(updatedChannel);
}
@Override
@Transactional
public void deleteById(UUID channelId) {
+ log.info("[BasicChannelService] 채널 삭제 요청: id: {}", channelId);
+
Channel channel = findChannel(channelId);
+ log.info("[BasicChannelService] 삭제할 채널 조회 완료: id: {}", channel.getId());
+
channelRepository.deleteById(channelId);
+
+ log.info("[BasicUserService] 채널 삭제 완료 - channelId: {}", channelId);
}
private Channel findChannel(UUID id) {
return channelRepository.findById(id)
- .orElseThrow(NotFoundChannelException::new);
+ .orElseThrow(() -> new NotFoundChannelException(id));
}
private User findUser(UUID id) {
return userRepository.findById(id)
- .orElseThrow(NotFoundUserException::new);
+ .orElseThrow(() -> new NotFoundUserException(id));
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java
index 49e918d1b..d1b619f1c 100644
--- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicMessageService.java
@@ -8,9 +8,9 @@
import com.sprint.mission.discodeit.entity.Channel;
import com.sprint.mission.discodeit.entity.Message;
import com.sprint.mission.discodeit.entity.User;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundChannelException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundMessageException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException;
+import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException;
+import com.sprint.mission.discodeit.exception.message.NotFoundMessageException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
import com.sprint.mission.discodeit.mapper.MessageMapper;
import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper;
import com.sprint.mission.discodeit.repository.BinaryContentRepository;
@@ -20,6 +20,7 @@
import com.sprint.mission.discodeit.service.MessageService;
import com.sprint.mission.discodeit.storage.BinaryContentStorage;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@@ -30,6 +31,7 @@
import java.util.List;
import java.util.UUID;
+@Slf4j
@Service("basicMessageService")
@RequiredArgsConstructor
@Transactional(readOnly = true)
@@ -63,6 +65,9 @@ public MessageResponseDto create(MessageRequestDto messageRequestDto,
String content = messageRequestDto.content();
+ log.info("[BasicMessageService] 메시지 생성 요청- authorId: {}, channelId: {}, content: {}",
+ author.getId(), channel.getId(), messageRequestDto.content());
+
Message message = Message.builder()
.content(content)
.author(author)
@@ -72,7 +77,11 @@ public MessageResponseDto create(MessageRequestDto messageRequestDto,
message.updateAttachments(binaryContents);
- messageRepository.save(message);
+ Message savedMessage = messageRepository.save(message);
+
+ log.info("[BasicMessageService] 메시지 생성 성공- id: {}, authorId: {}, channelId: {}, content: {}",
+ savedMessage.getId(), savedMessage.getAuthor().getId(), savedMessage.getChannel().getId(),
+ savedMessage.getContent());
return messageMapper.toDto(message);
}
@@ -109,7 +118,7 @@ public PageResponse findAllByChannelId(UUID channelId, Insta
Instant nextCursor = hasNext ? contentMessages.get(contentMessages.size() - 1).getCreatedAt() : null;
- List content = messages.stream()
+ List content = contentMessages.stream()
.map(messageMapper::toDto)
.toList();
@@ -119,25 +128,35 @@ public PageResponse findAllByChannelId(UUID channelId, Insta
@Override
@Transactional
public MessageResponseDto updateContent(UUID messageId, String content) {
+ log.info("[BasicMessageService] 메시지 내용 수정 요청- id: {} content: {}", messageId, content);
+
Message message = findMessage(messageId);
message.updateContent(content);
- messageRepository.save(message);
+ Message updatedMessage = messageRepository.save(message);
- return messageMapper.toDto(message);
+ log.info("[BasicMessageService] 메시지 내용 수정 성공- id: {}, newContent: {}", updatedMessage.getId(),
+ updatedMessage.getContent());
+
+ return messageMapper.toDto(updatedMessage);
}
@Override
@Transactional
public void deleteById(UUID messageId) {
+ log.info("[BasicMessageService] 메시지 삭제 요청: id: {}", messageId);
+
Message message = findMessage(messageId);
+ log.info("[BasicMessageService] 삭제할 메시지 조회 완료: id: {}", message.getId());
for (BinaryContent binaryContent : message.getAttachments()) {
binaryContentRepository.deleteById(binaryContent.getId());
}
messageRepository.deleteById(messageId);
+
+ log.info("[BasicMessageService] 메시지 삭제 성공: id: {}", messageId);
}
private List convertBinaryContentDtos(List binaryContentDtos) {
@@ -148,16 +167,16 @@ private List convertBinaryContentDtos(List bina
private Message findMessage(UUID id) {
return messageRepository.findById(id)
- .orElseThrow(NotFoundMessageException::new);
+ .orElseThrow(() -> new NotFoundMessageException(id));
}
private Channel findChannel(UUID id) {
return channelRepository.findById(id)
- .orElseThrow(NotFoundChannelException::new);
+ .orElseThrow(() -> new NotFoundChannelException(id));
}
private User findUser(UUID id) {
return userRepository.findById(id)
- .orElseThrow(NotFoundUserException::new);
+ .orElseThrow(() -> new NotFoundUserException(id));
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java
index baa5582a3..28c5f7741 100644
--- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicReadStatusService.java
@@ -6,10 +6,10 @@
import com.sprint.mission.discodeit.entity.Channel;
import com.sprint.mission.discodeit.entity.ReadStatus;
import com.sprint.mission.discodeit.entity.User;
-import com.sprint.mission.discodeit.exception.alreadyexist.ReadStatusAlreadyExistsException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundChannelException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundReadStatusException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException;
+import com.sprint.mission.discodeit.exception.readstatus.ReadStatusAlreadyExistsException;
+import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException;
+import com.sprint.mission.discodeit.exception.readstatus.NotFoundReadStatusException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
import com.sprint.mission.discodeit.mapper.struct.ReadStatusStructMapper;
import com.sprint.mission.discodeit.repository.ChannelRepository;
import com.sprint.mission.discodeit.repository.ReadStatusRepository;
@@ -40,15 +40,15 @@ public ReadStatusResponseDto create(ReadStatusRequestDto readStatusRequestDTO) {
UUID channelId = readStatusRequestDTO.channelId();
if (!userRepository.existsById(userId)) {
- throw new NotFoundUserException();
+ throw new NotFoundUserException(userId);
}
if (!channelRepository.existsById(channelId)) {
- throw new NotFoundChannelException();
+ throw new NotFoundChannelException(channelId);
}
if (readStatusRepository.existsByUserIdAndChannelId(userId, channelId)) {
- throw new ReadStatusAlreadyExistsException();
+ throw new ReadStatusAlreadyExistsException(userId, channelId);
}
User user = findUser(userId);
@@ -103,16 +103,16 @@ public void deleteById(UUID id) {
private ReadStatus findReadStatus(UUID id) {
return readStatusRepository.findById(id)
- .orElseThrow(NotFoundReadStatusException::new);
+ .orElseThrow(() -> new NotFoundReadStatusException(id));
}
private User findUser(UUID id) {
return userRepository.findById(id)
- .orElseThrow(NotFoundUserException::new);
+ .orElseThrow(() -> new NotFoundUserException(id));
}
private Channel findChannel(UUID id) {
return channelRepository.findById(id)
- .orElseThrow(NotFoundChannelException::new);
+ .orElseThrow(() -> new NotFoundChannelException(id));
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java
index 153b1f882..f9f92bb65 100644
--- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserService.java
@@ -7,10 +7,10 @@
import com.sprint.mission.discodeit.entity.BinaryContent;
import com.sprint.mission.discodeit.entity.User;
import com.sprint.mission.discodeit.entity.UserStatus;
-import com.sprint.mission.discodeit.exception.duplicate.DuplicateEmailException;
-import com.sprint.mission.discodeit.exception.duplicate.DuplicateNameException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserStatusException;
+import com.sprint.mission.discodeit.exception.user.DuplicateEmailException;
+import com.sprint.mission.discodeit.exception.user.DuplicateNameException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
+import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException;
import com.sprint.mission.discodeit.mapper.UserMapper;
import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper;
import com.sprint.mission.discodeit.repository.BinaryContentRepository;
@@ -19,6 +19,7 @@
import com.sprint.mission.discodeit.service.UserService;
import com.sprint.mission.discodeit.storage.BinaryContentStorage;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -27,6 +28,7 @@
import java.util.Optional;
import java.util.UUID;
+@Slf4j
@Service("basicUserService")
@RequiredArgsConstructor
@Transactional(readOnly = true)
@@ -45,6 +47,8 @@ public UserResponseDto create(UserRequestDto userRequestDto, BinaryContentDto bi
String username = userRequestDto.username();
String email = userRequestDto.email();
+ log.info("[BasicUserService] 사용자 등록 요청 - username: {}, email: {}", username, email);
+
if (userRepository.existsByUsername(username)) {
throw new DuplicateNameException(username);
}
@@ -81,10 +85,13 @@ public UserResponseDto create(UserRequestDto userRequestDto, BinaryContentDto bi
user.updateStatus(userStatus);
- userRepository.save(user);
+ User savedUser = userRepository.save(user);
userStatusRepository.save(userStatus);
- return userMapper.toDto(user);
+ log.info("[BasicUserService] 사용자 등록 성공 - id: {}, username: {}, email: {}",
+ savedUser.getId(), username, email);
+
+ return userMapper.toDto(savedUser);
}
@Override
@@ -121,6 +128,9 @@ public UserResponseDto update(UUID id, UserUpdateDto userUpdateDto,
String newUsername = userUpdateDto.newUsername();
String newEmail = userUpdateDto.newEmail();
+ log.info("[BasicUserService] 사용자 수정 요청: id: {}, newUsername: {}, newEmail: {}",
+ id, newUsername, newEmail);
+
if (newUsername != null) {
userRepository.findByUsername(newUsername)
.filter(u -> !u.getId().equals(user.getId()))
@@ -162,31 +172,41 @@ public UserResponseDto update(UUID id, UserUpdateDto userUpdateDto,
Optional.ofNullable(userUpdateDto.newPassword()).ifPresent(user::updatePassword);
- userRepository.save(user);
+ User updatedUser = userRepository.save(user);
- return userMapper.toDto(user);
+ log.info("[BasicUserService] 사용자 수정 성공! id: {}, username: {}, email: {}",
+ updatedUser.getId(), updatedUser.getUsername(), updatedUser.getEmail());
+
+ return userMapper.toDto(updatedUser);
}
@Override
@Transactional
public void deleteById(UUID id) {
+ log.info("[BasicUserService] 사용자 삭제 요청: id: {}", id);
+
User user = findUser(id);
+ log.debug("[BasicUserService] 사용자 조회 완료- id: {}, username: {}", user.getId(), user.getUsername());
userRepository.deleteById(id);
+ log.debug("[BasicUserService] userRepository 삭제 완료 - userId: {}", id);
+
userStatusRepository.deleteByUserId(id);
if (user.getProfile() != null) {
binaryContentRepository.deleteById(user.getProfile().getId());
}
+
+ log.info("[BasicUserService] 사용자 삭제 완료 - userId: {}", id);
}
private User findUser(UUID id) {
return userRepository.findById(id)
- .orElseThrow(NotFoundUserException::new);
+ .orElseThrow(() -> new NotFoundUserException(id));
}
private UserStatus findUserStatus(UUID id) {
return userStatusRepository.findByUserId(id)
- .orElseThrow(NotFoundUserStatusException::new);
+ .orElseThrow(() -> new NotFoundUserStatusException(id));
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java
index ea064b94d..cd44c225d 100644
--- a/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java
+++ b/src/main/java/com/sprint/mission/discodeit/service/basic/BasicUserStatusService.java
@@ -5,9 +5,9 @@
import com.sprint.mission.discodeit.dto.userstatus.UserStatusUpdateDto;
import com.sprint.mission.discodeit.entity.User;
import com.sprint.mission.discodeit.entity.UserStatus;
-import com.sprint.mission.discodeit.exception.alreadyexist.UserStatusAlreadyExistsException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserException;
-import com.sprint.mission.discodeit.exception.notfound.NotFoundUserStatusException;
+import com.sprint.mission.discodeit.exception.userstatus.UserStatusAlreadyExistsException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
+import com.sprint.mission.discodeit.exception.userstatus.NotFoundUserStatusException;
import com.sprint.mission.discodeit.mapper.struct.UserStatusStructMapper;
import com.sprint.mission.discodeit.repository.UserRepository;
import com.sprint.mission.discodeit.repository.UserStatusRepository;
@@ -34,11 +34,11 @@ public class BasicUserStatusService implements UserStatusService {
public UserStatusResponseDto create(UserStatusRequestDto userStatusRequestDTO) {
UUID userId = userStatusRequestDTO.userId();
if (!userRepository.existsById(userId)) {
- throw new NotFoundUserException();
+ throw new NotFoundUserException(userId);
}
if (userStatusRepository.existsByUserId(userId)) {
- throw new UserStatusAlreadyExistsException();
+ throw new UserStatusAlreadyExistsException(userId);
}
User user = findUser(userStatusRequestDTO.userId());
@@ -84,7 +84,7 @@ public UserStatusResponseDto update(UUID id, UserStatusUpdateDto userStatusUpdat
public UserStatusResponseDto updateByUserId(UUID userId,
UserStatusUpdateDto userStatusUpdateDTO) {
UserStatus userStatus = userStatusRepository.findByUserId(userId)
- .orElseThrow(NotFoundUserStatusException::new);
+ .orElseThrow(() -> new NotFoundUserStatusException(userId));
userStatus.updatelastActiveAt(userStatusUpdateDTO.newLastActiveAt());
userStatusRepository.save(userStatus);
@@ -105,6 +105,6 @@ private UserStatus findUserStatus(UUID id) {
private User findUser(UUID id) {
return userRepository.findById(id)
- .orElseThrow(NotFoundUserException::new);
+ .orElseThrow(() -> new NotFoundUserException(id));
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java
index 95d28f26b..020223d44 100644
--- a/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java
+++ b/src/main/java/com/sprint/mission/discodeit/storage/LocalBinaryContentStorage.java
@@ -2,6 +2,7 @@
import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -20,6 +21,7 @@
import java.nio.file.Paths;
import java.util.UUID;
+@Slf4j
@Component
@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "local")
public class LocalBinaryContentStorage implements BinaryContentStorage {
@@ -55,11 +57,17 @@ private Path resolvePath(UUID id) {
public UUID put(UUID id, byte[] bytes) {
Path path = resolvePath(id);
+ log.info("파일 업로드 요청: id = {}, size: {} bytes", id, bytes.length);
+ log.debug("저장 경로: {}", path.toAbsolutePath());
+
try {
Files.createDirectories(path.getParent());
Files.write(path, bytes);
+ log.info("파일 업로드 성공: id={}", id);
+
return id;
} catch (IOException e) {
+ log.error("파일 저장 실패: id={}", id, e);
throw new UncheckedIOException("파일 저장 실패: " + id, e);
}
}
@@ -79,6 +87,9 @@ public InputStream get(UUID id) {
public ResponseEntity> download(BinaryContentResponseDto dto) {
Path path = resolvePath(dto.id());
+ log.info("파일 다운로드 요청: id={}, fileName={}", dto.id(), dto.fileName());
+ log.debug("다운로드 경로: {}", path);
+
try {
// MIME 타입
String contentType = Files.probeContentType(path);
@@ -87,6 +98,7 @@ public ResponseEntity> download(BinaryContentResponseDto dto) {
}
Resource resource = new FileSystemResource(path);
+ log.info("파일 다운로드 성공: id={}, contentType={}", dto.id(), contentType);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
@@ -94,6 +106,7 @@ public ResponseEntity> download(BinaryContentResponseDto dto) {
.contentLength(dto.size())
.body(resource);
} catch (IOException e) {
+ log.error("파일 다운로드 실패: id={}", dto.id(), e);
throw new UncheckedIOException("다운로드 실패: " + dto.id(), e);
}
}
diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java
new file mode 100644
index 000000000..a4239a683
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java
@@ -0,0 +1,90 @@
+package com.sprint.mission.discodeit.storage.s3;
+
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.URL;
+import java.time.Duration;
+import java.util.Properties;
+
+public class AWSS3Test {
+
+ private final S3Client s3Client;
+ private final S3Presigner s3Presigner;
+ private final String bucketName;
+
+ public AWSS3Test() throws IOException {
+ Properties properties = new Properties();
+ properties.load(new FileInputStream(".env"));
+
+ String accessKey = properties.getProperty("AWS_S3_ACCESS_KEY");
+ String secretKey = properties.getProperty("AWS_S3_SECRET_KEY");
+ String region = properties.getProperty("AWS_S3_REGION");
+ this.bucketName = properties.getProperty("AWS_S3_BUCKET");
+
+ AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey);
+
+ this.s3Client = S3Client.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
+ .build();
+
+ this.s3Presigner = S3Presigner.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
+ .build();
+ }
+
+ public void uploadTest(String key, String filePath) {
+ PutObjectRequest putRequest = PutObjectRequest.builder()
+ .bucket(bucketName)
+ .key(key)
+ .build();
+
+ s3Client.putObject(putRequest, new File(filePath).toPath());
+ System.out.println("Upload completed: " + key);
+ }
+
+ public void downloadTest(String key, String downloadPath) {
+ GetObjectRequest getRequest = GetObjectRequest.builder()
+ .bucket(bucketName)
+ .key(key)
+ .build();
+
+ s3Client.getObject(getRequest, new File(downloadPath).toPath());
+ System.out.println("Download completed: " + downloadPath);
+ }
+
+ public void presignedUrlTest(String key) {
+ GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+ .bucket(bucketName)
+ .key(key)
+ .build();
+
+ GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofMinutes(10))
+ .getObjectRequest(getObjectRequest)
+ .build();
+
+ URL presignedUrl = s3Presigner.presignGetObject(presignRequest).url();
+ System.out.println("presignedUrl.toString() = " + presignedUrl.toString());
+ }
+
+ public static void main(String[] args) throws IOException {
+
+ AWSS3Test test = new AWSS3Test();
+
+ test.uploadTest("test.jpg", "/Users/handongwoo/sprint-mission/3-sprint-mission/src/test.jpg");
+ test.downloadTest("test.jpg", "/Users/handongwoo/sprint-mission/3-sprint-mission/src/downloaded.jpg");
+ test.presignedUrlTest("test.jpg");
+ }
+}
diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java
new file mode 100644
index 000000000..6133cf881
--- /dev/null
+++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java
@@ -0,0 +1,119 @@
+package com.sprint.mission.discodeit.storage.s3;
+
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.core.io.UrlResource;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.UUID;
+
+@Component
+@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3")
+public class S3BinaryContentStorage implements BinaryContentStorage {
+
+ private final String accessKey;
+ private final String secretKey;
+ private final String region;
+ private final String bucket;
+
+ public S3BinaryContentStorage(@Value("${discodeit.storage.s3.access-key}") String accessKey,
+ @Value("${discodeit.storage.s3.secret-key}") String secretKey,
+ @Value("${discodeit.storage.s3.region}") String region,
+ @Value("${discodeit.storage.s3.bucket}") String bucket) {
+ this.accessKey = accessKey;
+ this.secretKey = secretKey;
+ this.region = region;
+ this.bucket = bucket;
+ }
+
+ @Override
+ public UUID put(UUID id, byte[] bytes) {
+ S3Client s3Client = getS3Client();
+ String key = id.toString();
+
+ PutObjectRequest putRequest = PutObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .build();
+
+ s3Client.putObject(putRequest, RequestBody.fromBytes(bytes));
+
+ return id;
+ }
+
+ @Override
+ public InputStream get(UUID id) {
+ S3Client s3Client = getS3Client();
+ String key = id.toString();
+
+ return s3Client.getObject(GetObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .build());
+ }
+
+ @Override
+ public ResponseEntity download(BinaryContentResponseDto binaryContentResponseDto) {
+ String key = binaryContentResponseDto.id().toString();
+ String presignedUrl = generatePresignedUrl(key, binaryContentResponseDto.contentType());
+
+ try {
+ UrlResource url = new UrlResource(presignedUrl);
+
+ return ResponseEntity
+ .status(302)
+ .location(url.getURL().toURI())
+ .build();
+
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to create redirect for presigned URL", e);
+ }
+ }
+
+ S3Client getS3Client() {
+ AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey);
+
+ return S3Client.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
+ .build();
+ }
+
+ String generatePresignedUrl(String key, String contentType) {
+ AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey);
+
+ S3Presigner s3Presigner = S3Presigner.builder()
+ .region(Region.of(region))
+ .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
+ .build();
+
+ GetObjectRequest getRequest = GetObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .responseContentType(contentType)
+ .build();
+
+ GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofMinutes(10))
+ .getObjectRequest(getRequest)
+ .build();
+
+ return s3Presigner.presignGetObject(presignRequest)
+ .url()
+ .toString();
+ }
+}
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml
new file mode 100644
index 000000000..65d408f3d
--- /dev/null
+++ b/src/main/resources/application-dev.yaml
@@ -0,0 +1,23 @@
+spring:
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+ datasource:
+ driver-class-name: org.h2.Driver
+ url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
+ username: sa
+ password:
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.H2Dialect
+
+server:
+ port: 8081
+
+logging:
+ level:
+ com.sprint.mission.discodeit: DEBUG
diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml
new file mode 100644
index 000000000..91e0971a5
--- /dev/null
+++ b/src/main/resources/application-prod.yaml
@@ -0,0 +1,14 @@
+spring:
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+ driver-class-name: org.postgresql.Driver
+ # JPA 설정
+ jpa:
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+
+server:
+ port: 80
\ No newline at end of file
diff --git a/src/main/resources/application-test.yaml b/src/main/resources/application-test.yaml
new file mode 100644
index 000000000..021ad62f3
--- /dev/null
+++ b/src/main/resources/application-test.yaml
@@ -0,0 +1,19 @@
+spring:
+ datasource:
+ driver-class-name: org.h2.Driver
+ url: jdbc:h2:mem:testdb;MODE=PostgreSQL
+ username: sa
+ password:
+
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.H2Dialect
+ default_schema: discodeit
+
+logging:
+ level:
+ root: INFO
+ org.springframework: DEBUG
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 0830ad905..2c56bc50e 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -1,43 +1,69 @@
+spring:
+ datasource:
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+ driver-class-name: org.postgresql.Driver
+
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+ use_sql_comments: true
+ open-in-view: false
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health, info, metrics, loggers
+ endpoint:
+ info:
+ enabled: true
+ health:
+ show-details: always
+ info:
+ env:
+ enabled: true
+ java:
+ enabled: true
+ os:
+ enabled: true
+
discodeit:
storage:
- type: local
+ type: ${STORAGE_TYPE:local}
local:
- root-path: ./binaries
+ root-path: ${STORAGE_LOCAL_ROOT_PATH:./binaries}
+ s3:
+ access-key: ${AWS_S3_ACCESS_KEY}
+ secret-key: ${AWS_S3_SECRET_KEY}
+ region: ${AWS_S3_REGION}
+ bucket: ${AWS_S3_BUCKET}
+ presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:600}
springdoc:
api-docs:
- # OpenAPI 명세서 경로(JSON)
path: /v3/api-docs
swagger-ui:
- # OpenAPI 명세서 경로(HTML)
path: /swagger-ui.html
- # 요청 응답 시간 표시
display-request-duration: true
- # HTTP 메서드 기준으로 정렬
operations-sorter: method
-spring:
- # DB 접속 정보
- datasource:
- url: jdbc:postgresql://localhost:5432/discodeit
- username: discodeit_user
- password: discodeit1234
- driver-class-name: org.postgresql.Driver
- # JPA 설정
- jpa:
- hibernate:
- ddl-auto: create
- show-sql: true
- properties:
- hibernate:
- format_sql: true
- highlight_sql: true
- use_sql_comments: true
- dialect: org.hibernate.dialect.PostgreSQLDialect
- open-in-view: false
+info:
+ app:
+ name: Discodeit
+ version: 1.7.0
+ java:
+ version: 17
+ spring-boot:
+ version: 3.4.0
-# logging 설정
logging:
level:
+ com.sprint.mission.discodeit: INFO
org.hibernate.sql: debug
- org.hibernate.orm.jdbc.bind: trace
+ org.hibernate.orm.jdbc.bind: trace
\ No newline at end of file
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
new file mode 100644
index 000000000..99dd18983
--- /dev/null
+++ b/src/main/resources/logback-spring.xml
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${CONSOLE_LOG_PATTERN}
+ UTF-8
+
+
+
+
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-application.log
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-application-%d{yyyy-MM-dd}.%i.log
+ 100MB
+ 30
+ 5GB
+
+
+ ${FILE_LOG_PATTERN}
+ UTF-8
+
+
+
+
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-business.log
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-business-%d{yyyy-MM-dd}.%i.log
+ 100MB
+ 30
+ 5GB
+
+
+ ${FILE_LOG_PATTERN}
+ UTF-8
+
+
+
+
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-error.log
+
+ ERROR
+ ACCEPT
+ DENY
+
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log
+ 100MB
+ 30
+ 5GB
+
+
+ ${FILE_LOG_PATTERN}
+ UTF-8
+
+
+
+
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-performance.log
+
+ ${LOG_PATH}/${LOG_FILE_NAME}-performance-%d{yyyy-MM-dd}.%i.log
+ 100MB
+ 30
+ 5GB
+
+
+ ${FILE_LOG_PATTERN}
+ UTF-8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql
index f0610ebe5..785ff02d6 100644
--- a/src/main/resources/schema.sql
+++ b/src/main/resources/schema.sql
@@ -1,47 +1,24 @@
--- 1. distcodeit 스키마 생성
CREATE SCHEMA IF NOT EXISTS discodeit;
--- 2. 권한 설정
-GRANT ALL PRIVILEGES ON SCHEMA discodeit TO discodeit_user;
-GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA discodeit TO discodeit_user;
--- 3. 권한 설정 확인
-SELECT
- n.nspname AS schema_name,
- pg_get_userbyid(n.nspowner) AS schema_owner
-FROM
- pg_namespace n
-WHERE
- n.nspname = 'discodeit';
--- 4. 검색 경로 설정
-ALTER ROLE discodeit_user SET search_path TO discodeit, public;
--- 5. 검색 경로 확인
-SHOW search_path;
--- 6. 테이블 삭제
-DROP TABLE IF EXISTS users CASCADE;
-DROP TABLE IF EXISTS binary_contents CASCADE;
-DROP TABLE IF EXISTS user_statuses CASCADE;
-DROP TABLE IF EXISTS read_statuses CASCADE;
-DROP TABLE IF EXISTS message_attachments CASCADE;
-DROP TABLE IF EXISTS channels CASCADE;
-DROP TABLE IF EXISTS messages CASCADE;
--- 7. 테이블 생성
+
+-- 테이블 생성
CREATE TABLE IF NOT EXISTS binary_contents
(
- id UUID,
- created_at TIMESTAMPTZ NOT NULL,
- file_name VARCHAR(255) NOT NULL,
- size BIGINT NOT NULL,
- content_type VARCHAR(100) NOT NULL,
+ id UUID,
+ created_at TIMESTAMP with time zone NOT NULL,
+ file_name VARCHAR(255) NOT NULL,
+ size BIGINT NOT NULL,
+ content_type VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS users
(
- id UUID,
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ,
- username VARCHAR(50) NOT NULL UNIQUE,
- email VARCHAR(100) NOT NULL UNIQUE,
- password VARCHAR(60) NOT NULL,
+ id UUID,
+ created_at TIMESTAMP with time zone NOT NULL,
+ updated_at TIMESTAMP with time zone,
+ username VARCHAR(50) NOT NULL UNIQUE,
+ email VARCHAR(100) NOT NULL UNIQUE,
+ password VARCHAR(60) NOT NULL,
profile_id UUID,
PRIMARY KEY (id),
FOREIGN KEY (profile_id)
@@ -51,11 +28,11 @@ CREATE TABLE IF NOT EXISTS users
CREATE TABLE IF NOT EXISTS user_statuses
(
- id UUID,
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ,
- user_id UUID NOT NULL UNIQUE,
- last_active_at TIMESTAMPTZ NOT NULL,
+ id UUID,
+ created_at TIMESTAMP with time zone NOT NULL,
+ updated_at TIMESTAMP with time zone,
+ user_id UUID NOT NULL UNIQUE,
+ last_active_at TIMESTAMP with time zone NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id)
REFERENCES users (id)
@@ -64,24 +41,24 @@ CREATE TABLE IF NOT EXISTS user_statuses
CREATE TABLE IF NOT EXISTS channels
(
- id UUID,
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ,
- name VARCHAR(100),
+ id UUID,
+ created_at TIMESTAMP with time zone NOT NULL,
+ updated_at TIMESTAMP with time zone,
+ name VARCHAR(100),
description VARCHAR(500),
- type VARCHAR(20) NOT NULL,
+ type VARCHAR(20) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT channel_type_check CHECK (type IN ('PUBLIC', 'PRIVATE'))
);
CREATE TABLE IF NOT EXISTS messages
(
- id UUID,
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ,
- content TEXT,
+ id UUID,
+ created_at TIMESTAMP with time zone NOT NULL,
+ updated_at TIMESTAMP with time zone,
+ content TEXT,
channel_id UUID,
- author_id UUID,
+ author_id UUID,
PRIMARY KEY (id),
FOREIGN KEY (channel_id)
REFERENCES channels (id)
@@ -93,12 +70,12 @@ CREATE TABLE IF NOT EXISTS messages
CREATE TABLE IF NOT EXISTS read_statuses
(
- id UUID,
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ,
- user_id UUID,
- channel_id UUID,
- last_read_at TIMESTAMPTZ NOT NULL,
+ id UUID,
+ created_at TIMESTAMP with time zone NOT NULL,
+ updated_at TIMESTAMP with time zone,
+ user_id UUID,
+ channel_id UUID,
+ last_read_at TIMESTAMP with time zone NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id)
REFERENCES users (id)
@@ -111,7 +88,7 @@ CREATE TABLE IF NOT EXISTS read_statuses
CREATE TABLE IF NOT EXISTS message_attachments
(
- message_id UUID,
+ message_id UUID,
attachment_id UUID,
PRIMARY KEY (message_id, attachment_id),
FOREIGN KEY (message_id)
diff --git a/src/test/java/com/sprint/mission/discodeit/ChannelServiceIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/ChannelServiceIntegrationTest.java
new file mode 100644
index 000000000..cbfa6ea9d
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/ChannelServiceIntegrationTest.java
@@ -0,0 +1,151 @@
+package com.sprint.mission.discodeit;
+
+import com.sprint.mission.discodeit.dto.channel.ChannelResponseDto;
+import com.sprint.mission.discodeit.dto.channel.PrivateChannelDto;
+import com.sprint.mission.discodeit.entity.*;
+import com.sprint.mission.discodeit.repository.ChannelRepository;
+import com.sprint.mission.discodeit.repository.ReadStatusRepository;
+import com.sprint.mission.discodeit.repository.UserRepository;
+import com.sprint.mission.discodeit.service.ChannelService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest
+@ActiveProfiles("test")
+@DisplayName("ChannelService 통합 테스트")
+@Transactional
+public class ChannelServiceIntegrationTest {
+
+ @Autowired
+ private ChannelRepository channelRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private ReadStatusRepository readStatusRepository;
+ @Autowired
+ private ChannelService channelService;
+
+ private UUID userId1;
+ private UUID userId2;
+ private UUID userId3;
+
+ @BeforeEach
+ void setUp() {
+ // given
+ User user1 = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ User user2 = User.builder()
+ .username("test2")
+ .email("test2@test.com")
+ .password("pwd12345")
+ .build();
+
+ User user3 = User.builder()
+ .username("test3")
+ .email("test3@test.com")
+ .password("pwd123456")
+ .build();
+
+ UserStatus userStatus1 = UserStatus.builder()
+ .user(user1)
+ .lastActiveAt(Instant.now())
+ .build();
+
+ UserStatus userStatus2 = UserStatus.builder()
+ .user(user2)
+ .lastActiveAt(Instant.now())
+ .build();
+
+ UserStatus userStatus3 = UserStatus.builder()
+ .user(user3)
+ .lastActiveAt(Instant.now())
+ .build();
+
+ user1.updateStatus(userStatus1);
+ user2.updateStatus(userStatus2);
+ user3.updateStatus(userStatus3);
+
+ User savedUser1 = userRepository.save(user1);
+ User savedUser2 = userRepository.save(user2);
+ User savedUser3 = userRepository.save(user3);
+
+ userId1 = savedUser1.getId();
+ userId2 = savedUser2.getId();
+ userId3 = savedUser3.getId();
+ }
+
+ @Test
+ @DisplayName("Private 채널 등록 프로세스가 모든 계층에서 올바르게 동작해야 한다.")
+ void completeCreatePrivateChannelIntegration() {
+
+ // given
+ PrivateChannelDto request = new PrivateChannelDto(List.of(userId1, userId2));
+
+ // when
+ ChannelResponseDto result = channelService.createPrivateChannel(request);
+
+ // then
+ Optional foundChannel = channelRepository.findById(result.id());
+ assertTrue(foundChannel.isPresent(), "채널이 저장되어 있어야 한다.");
+ assertNotNull(result);
+ assertEquals(ChannelType.PRIVATE, result.type());
+ assertEquals(result.participants().get(0).id(), userId1);
+ assertNull(result.lastMessageAt(), "초기에는 메시지가 없어야 하므로 lastMessageAt은 null이어야 한다.");
+ List foundReadStatus1 = readStatusRepository.findAllByUserId(userId1);
+ assertEquals(1, foundReadStatus1.size(), "유저 1명의 ReadStatus가 정확히 하나 생성되어야 한다.");
+ }
+
+ @Test
+ @DisplayName("사용자 ID로 채널 조회 프로세스가 모든 계층에서 올바르게 동작해야 한다.")
+ void completeFindByUserIdIntegration() {
+
+ // given
+ Channel publicChannel = Channel.builder()
+ .name("public")
+ .description("test channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ /*
+ Private 채널 요청
+ */
+ PrivateChannelDto request1 = new PrivateChannelDto(List.of(userId1, userId2));
+ PrivateChannelDto request2 = new PrivateChannelDto(List.of(userId2, userId3));
+
+ Channel savedChannel1 = channelRepository.save(publicChannel);
+ ChannelResponseDto privateChannel1 = channelService.createPrivateChannel(request1);
+ ChannelResponseDto privateChannel2 = channelService.createPrivateChannel(request2);
+
+ UUID channelId1 = savedChannel1.getId();
+ UUID channelId2 = privateChannel1.id();
+ UUID channelId3 = privateChannel2.id();
+
+ // when
+ List channels = channelService.findAllByUserId(userId1);
+
+ // then
+ assertEquals(2, channels.size(), "Public 채널과 참여한 Private 채널이 조회되어야 한다.");
+ List resultIds = channels.stream().map(ChannelResponseDto::id).toList();
+ assertTrue(resultIds.contains(channelId1));
+ assertTrue(resultIds.contains(channelId2));
+ assertFalse(resultIds.contains(channelId3));
+ }
+}
diff --git a/src/test/java/com/sprint/mission/discodeit/MessageServiceIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/MessageServiceIntegrationTest.java
new file mode 100644
index 000000000..0d97529e4
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/MessageServiceIntegrationTest.java
@@ -0,0 +1,224 @@
+package com.sprint.mission.discodeit;
+
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto;
+import com.sprint.mission.discodeit.dto.message.MessageRequestDto;
+import com.sprint.mission.discodeit.dto.message.MessageResponseDto;
+import com.sprint.mission.discodeit.dto.response.PageResponse;
+import com.sprint.mission.discodeit.entity.*;
+import com.sprint.mission.discodeit.repository.BinaryContentRepository;
+import com.sprint.mission.discodeit.repository.ChannelRepository;
+import com.sprint.mission.discodeit.repository.MessageRepository;
+import com.sprint.mission.discodeit.repository.UserRepository;
+import com.sprint.mission.discodeit.service.MessageService;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "discodeit.storage.type=local",
+ "discodeit.storage.local.root-path=./binaryTest"
+})
+@DisplayName("UserService 통합 테스트")
+@Transactional
+public class MessageServiceIntegrationTest {
+
+ @Autowired
+ private MessageService messageService;
+
+ @Autowired
+ private MessageRepository messageRepository;
+
+ @Autowired
+ private ChannelRepository channelRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private BinaryContentRepository binaryContentRepository;
+
+ @Autowired
+ private BinaryContentStorage binaryContentStorage;
+
+ private User savedAuthor;
+ private Channel savedChannel1;
+ private Channel savedChannel2;
+
+ @BeforeEach
+ void setUp() {
+ User author = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ UserStatus userStatus = UserStatus.builder()
+ .user(author)
+ .lastActiveAt(Instant.now())
+ .build();
+
+ author.updateStatus(userStatus);
+
+ savedAuthor = userRepository.save(author);
+
+ Channel channel1 = Channel.builder()
+ .name("public")
+ .description("test channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ savedChannel1 = channelRepository.save(channel1);
+
+ Channel channel2 = Channel.builder()
+ .name("public2")
+ .description("test channel2")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ savedChannel2 = channelRepository.save(channel2);
+ }
+
+ @Test
+ @DisplayName("메시지 등록 프로세스가 모든 계층에서 올바르게 동작해야 한다.")
+ void completeCreateMessageIntegration() throws IOException {
+
+ // given
+ byte[] imageBytes = "test".getBytes(StandardCharsets.UTF_8);
+ BinaryContentDto attachment = new BinaryContentDto("attachment.png", 3L,
+ "image/png", imageBytes);
+
+ MessageRequestDto request = new MessageRequestDto("Hello", savedChannel1.getId(),
+ savedAuthor.getId());
+
+ // when
+ MessageResponseDto result = messageService.create(request, List.of(attachment));
+
+ // then
+ assertNotNull(result);
+ assertEquals("Hello", result.content());
+ assertEquals(savedChannel1.getId(), result.channelId());
+ assertEquals(savedAuthor.getId(), result.author().id());
+ UUID messageId = result.id();
+ Optional foundMessage = messageRepository.findById(messageId);
+ assertTrue(foundMessage.isPresent(), "메시지가 저장되어 있어야 한다.");
+ List attachments = foundMessage.get().getAttachments();
+ assertEquals(1, attachments.size());
+ assertEquals("attachment.png", attachments.get(0).getFileName());
+ byte[] data = binaryContentStorage.get(attachments.get(0).getId()).readAllBytes();
+ assertArrayEquals(imageBytes, data);
+ }
+
+ @Test
+ @DisplayName("채널 ID로 메시지 조회 프로세스가 모든 계층에서 올바르게 동작해야 한다.")
+ void completeFindByChannelIdIntegration() {
+
+ // given
+ Message message1 = Message.builder()
+ .author(savedAuthor)
+ .channel(savedChannel1)
+ .content("Hello")
+ .attachments(List.of())
+ .build();
+
+ Message message2 = Message.builder()
+ .author(savedAuthor)
+ .channel(savedChannel2)
+ .content("Hi")
+ .attachments(List.of())
+ .build();
+
+ Message message3 = Message.builder()
+ .author(savedAuthor)
+ .channel(savedChannel2)
+ .content("Nice")
+ .attachments(List.of())
+ .build();
+
+ messageRepository.save(message1);
+ messageRepository.save(message2);
+ messageRepository.save(message3);
+ Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdAt"));
+
+ // when
+ PageResponse result = messageService.findAllByChannelId(savedChannel2.getId(),
+ null, pageable);
+
+ // then
+ List content = result.content();
+ assertEquals(2, content.size());
+ assertEquals("Nice", content.get(0).content());
+ assertEquals("Hi", content.get(1).content());
+ assertFalse(result.hasNext(), "채널에 있는 메시지를 모두 조회했기 때문에 다음 메시지는 없어야한다.");
+ assertNull(result.nextCursor());
+ }
+
+ @Test
+ @DisplayName("메시지 삭제 프로세스가 모든 계층에서 올바르게 동작해야 한다.")
+ void completeDeleteMessageIntegration() {
+
+ // given
+ BinaryContent attachment = new BinaryContent("attachment.png", 3L, "image/png");
+
+ Message message = Message.builder()
+ .author(savedAuthor)
+ .channel(savedChannel1)
+ .content("Hello")
+ .attachments(List.of(attachment))
+ .build();
+
+ BinaryContent savedAttachment = binaryContentRepository.save(attachment);
+ Message savedMessage = messageRepository.save(message);
+
+ UUID messageId = savedMessage.getId();
+ UUID attachmentId = savedAttachment.getId();
+
+ // when
+ messageService.deleteById(messageId);
+
+ // then
+ Optional foundMessage = messageRepository.findById(messageId);
+ assertTrue(foundMessage.isEmpty());
+ Optional foundAttachment = binaryContentRepository.findById(attachmentId);
+ assertTrue(foundAttachment.isEmpty(), "메시지의 첨부 파일도 삭제되어야 한다.");
+ }
+
+ @AfterEach
+ void cleanUpStorage() throws IOException {
+ Path testRoot = Paths.get("./binaryTest");
+ if (Files.exists(testRoot)) {
+ Files.walk(testRoot)
+ .sorted((a, b) -> b.compareTo(a)) // 파일 먼저, 그 다음 디렉토리 삭제
+ .forEach(path -> {
+ try {
+ Files.delete(path);
+ } catch (IOException e) {
+ // 무시 또는 로깅
+ }
+ });
+ }
+ }
+}
diff --git a/src/test/java/com/sprint/mission/discodeit/UserServiceIntegrationTest.java b/src/test/java/com/sprint/mission/discodeit/UserServiceIntegrationTest.java
new file mode 100644
index 000000000..920f0cb75
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/UserServiceIntegrationTest.java
@@ -0,0 +1,142 @@
+package com.sprint.mission.discodeit;
+
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto;
+import com.sprint.mission.discodeit.dto.user.UserRequestDto;
+import com.sprint.mission.discodeit.dto.user.UserResponseDto;
+import com.sprint.mission.discodeit.entity.BinaryContent;
+import com.sprint.mission.discodeit.entity.User;
+import com.sprint.mission.discodeit.entity.UserStatus;
+import com.sprint.mission.discodeit.repository.BinaryContentRepository;
+import com.sprint.mission.discodeit.repository.UserRepository;
+import com.sprint.mission.discodeit.repository.UserStatusRepository;
+import com.sprint.mission.discodeit.service.UserService;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "discodeit.storage.type=local",
+ "discodeit.storage.local.root-path=./binaryTest"
+})
+@DisplayName("UserService 통합 테스트")
+@Transactional
+public class UserServiceIntegrationTest {
+
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private BinaryContentRepository binaryContentRepository;
+
+ @Autowired
+ private UserStatusRepository userStatusRepository;
+
+ @Autowired
+ private BinaryContentStorage binaryContentStorage;
+
+ @Test
+ @DisplayName("사용자 등록 프로세스가 모든 계층에서 올바르게 동작해야 한다.")
+ void completeCreateUserIntegration() throws IOException {
+
+ // given
+ UserRequestDto request = new UserRequestDto("test", "test@test.com", "pwd1234");
+ byte[] imageBytes = "test".getBytes(StandardCharsets.UTF_8);
+ BinaryContentDto profile = new BinaryContentDto("profile.png", 3L, "image/png", imageBytes);
+
+ // when
+ UserResponseDto result = userService.create(request, profile);
+
+ // then
+ assertNotNull(result);
+ Optional foundUser = userRepository.findById(result.id());
+ assertTrue(foundUser.isPresent(), "유저가 저장되어야 한다.");
+ assertEquals("test", foundUser.get().getUsername());
+ Optional status = userStatusRepository.findByUserId(result.id());
+ assertTrue(status.isPresent(), "유저 1명의 UserStatus가 생성되어야 한다.");
+ BinaryContent userProfile = foundUser.get().getProfile();
+ assertNotNull(userProfile, "프로필 사진이 등록되어야 한다.");
+ assertEquals("profile.png", userProfile.getFileName());
+ byte[] data = binaryContentStorage.get(userProfile.getId()).readAllBytes();
+ assertArrayEquals(imageBytes, data);
+ }
+
+ @Test
+ @DisplayName("사용자 삭제 프로세스가 모든 계층에서 올바르게 동작해야 한다")
+ void completeDeleteUserIntegration() {
+
+ // given
+ BinaryContent profile = new BinaryContent("profile.png", 3L, "image/png");
+ User user = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .profile(profile)
+ .build();
+
+ UserStatus userStatus = UserStatus.builder()
+ .user(user)
+ .lastActiveAt(Instant.now())
+ .build();
+
+ user.updateStatus(userStatus);
+
+ User savedUser = userRepository.save(user);
+ BinaryContent savedProfile = binaryContentRepository.save(profile);
+ userStatusRepository.save(userStatus);
+
+ UUID userId = savedUser.getId();
+ UUID profileId = savedProfile.getId();
+
+ // when
+ userService.deleteById(userId);
+
+ // then
+ Optional foundUser = userRepository.findById(userId);
+ assertTrue(foundUser.isEmpty());
+ Optional foundProfile = binaryContentRepository.findById(profileId);
+ assertTrue(foundProfile.isEmpty(), "삭제된 유저의 프로필 사진도 삭제되어야 한다.");
+ Optional foundUserStatus = userStatusRepository.findByUserId(userId);
+ assertTrue(foundUserStatus.isEmpty(), "삭제된 유저의 UserStatus도 삭제되어야 한다.");
+ }
+
+ /*
+ 테스트 종료 후 binaryStorage 테스트 파일 삭제 처리
+ */
+ @AfterEach
+ void cleanUpStorage() throws IOException {
+ Path testRoot = Paths.get("./binaryTest");
+ if (Files.exists(testRoot)) {
+ Files.walk(testRoot)
+ .sorted((a, b) -> b.compareTo(a)) // 파일 먼저, 그 다음 디렉토리 삭제
+ .forEach(path -> {
+ try {
+ Files.delete(path);
+ } catch (IOException e) {
+ // 무시 또는 로깅
+ }
+ });
+ }
+ }
+}
diff --git a/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java
new file mode 100644
index 000000000..6d958e323
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/AuthControllerTest.java
@@ -0,0 +1,70 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sprint.mission.discodeit.config.JpaConfig;
+import com.sprint.mission.discodeit.dto.auth.LoginDto;
+import com.sprint.mission.discodeit.dto.user.UserResponseDto;
+import com.sprint.mission.discodeit.service.AuthService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(controllers = AuthController.class,
+ excludeAutoConfiguration = {JpaConfig.class})
+@DisplayName("UserController 슬라이스 테스트")
+class AuthControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private AuthService authService;
+
+ @Test
+ @DisplayName("등록된 유저로 로그인 시도를 하면 정상적으로 처리되어야 한다.")
+ void givenValidLoginDto_whenLogin_thenReturnLoggedInUserResponse() throws Exception {
+
+ // given
+ LoginDto request = new LoginDto("test", "pwd12345");
+
+ UUID userId = UUID.randomUUID();
+ UserResponseDto loggedInUser = new UserResponseDto(
+ userId,
+ "test",
+ "test@test.com",
+ null,
+ true
+ );
+
+ given(authService.login(any(LoginDto.class))).willReturn(loggedInUser);
+
+ // when
+ ResultActions result = mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)));
+
+ // then
+ result.andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(userId.toString()))
+ .andExpect(jsonPath("$.username").value("test"))
+ .andExpect(jsonPath("$.email").value("test@test.com"))
+ .andExpect(jsonPath("$.online").value(true));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java
new file mode 100644
index 000000000..aaad0a020
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/ChannelControllerTest.java
@@ -0,0 +1,122 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sprint.mission.discodeit.config.JpaConfig;
+import com.sprint.mission.discodeit.dto.channel.ChannelResponseDto;
+import com.sprint.mission.discodeit.dto.channel.PrivateChannelDto;
+import com.sprint.mission.discodeit.dto.channel.PublicChannelDto;
+import com.sprint.mission.discodeit.entity.ChannelType;
+import com.sprint.mission.discodeit.service.basic.BasicChannelService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(controllers = ChannelController.class,
+ excludeAutoConfiguration = {JpaConfig.class})
+@DisplayName("ChannelController 슬라이스 테스트")
+class ChannelControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private BasicChannelService channelService;
+
+ @Test
+ @DisplayName("Public Channel 요청이 올바르게 처리되어야 한다.")
+ void givenValidPublicChannelDto_whenCreatePublicChannel_thenReturnCreatedChannelResponse()
+ throws Exception {
+
+ // given
+ UUID channelId = UUID.randomUUID();
+ PublicChannelDto request = new PublicChannelDto("public", "test channel");
+ ChannelResponseDto expectedResponse = new ChannelResponseDto(channelId, ChannelType.PUBLIC, "public",
+ "test channel", List.of(), null);
+
+ given(channelService.createPublicChannel(any(PublicChannelDto.class)))
+ .willReturn(expectedResponse);
+
+ // when
+ ResultActions result = mockMvc.perform(post("/api/channels/public")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content(objectMapper.writeValueAsString(request)));
+
+ // then
+ result.andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(channelId.toString()))
+ .andExpect(jsonPath("$.name").value("public"))
+ .andExpect(jsonPath("$.description").value("test channel"));
+ verify(channelService).createPublicChannel(argThat(req ->
+ req.name().equals("public") &&
+ req.description().equals("test channel")
+ ));
+ }
+
+ @Test
+ @DisplayName("Private Channel 요청이 올바르게 처리되어야 한다.")
+ void givenValidPrivateChannelDto_whenCreatePrivateChannel_thenReturnCreatedChannelResponse()
+ throws Exception {
+
+ // given
+ UUID channelId = UUID.randomUUID();
+
+ PrivateChannelDto request = new PrivateChannelDto(List.of(UUID.randomUUID(), UUID.randomUUID()));
+
+ ChannelResponseDto expectedResponse = new ChannelResponseDto(channelId, ChannelType.PRIVATE, null,
+ null, List.of(), null);
+
+ given(channelService.createPrivateChannel(any(PrivateChannelDto.class)))
+ .willReturn(expectedResponse);
+
+ // when
+ ResultActions result = mockMvc.perform(post("/api/channels/private")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content(objectMapper.writeValueAsString(request)));
+
+ // then
+ result.andExpect(status().isCreated())
+ .andExpect(jsonPath("$.type").value(ChannelType.PRIVATE.toString()));
+ verify(channelService).createPrivateChannel(request);
+ }
+
+ @Test
+ @DisplayName("Public Channel 생성 시 채널 이름이 누락되면 400 Bad Request가 발생해야 한다.")
+ void givenMissingRequiredField_whenCreatePublicChannel_thenReturnBadRequestWithValidationError()
+ throws Exception {
+
+ // given
+ PublicChannelDto request = new PublicChannelDto(null, "test channel");
+
+ // when
+ ResultActions result = mockMvc.perform(post("/api/channels/public")
+ .contentType(MediaType.APPLICATION_JSON_VALUE)
+ .content(objectMapper.writeValueAsString(request)));
+
+
+ // then
+ result.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
+ .andExpect(jsonPath("$.message").value("유효성 검사 실패"));
+ verify(channelService, never()).createPublicChannel(request);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java
new file mode 100644
index 000000000..142ae59f4
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/MessageControllerTest.java
@@ -0,0 +1,128 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sprint.mission.discodeit.config.JpaConfig;
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
+import com.sprint.mission.discodeit.dto.message.MessageRequestDto;
+import com.sprint.mission.discodeit.dto.message.MessageResponseDto;
+import com.sprint.mission.discodeit.dto.user.UserResponseDto;
+import com.sprint.mission.discodeit.service.basic.BasicMessageService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(controllers = MessageController.class,
+ excludeAutoConfiguration = {JpaConfig.class})
+@DisplayName("MessageController 슬라이스 테스트")
+class MessageControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private BasicMessageService messageService;
+
+ @Test
+ @DisplayName("메시지 생성 요청이 올바르게 처리되어야 한다.")
+ void givenValidUserRequest_whenCreateUser_thenReturnCreatedUserResponse() throws Exception {
+
+ // given
+ UUID channelId = UUID.randomUUID();
+ UUID authorId = UUID.randomUUID();
+ UUID messageId = UUID.randomUUID();
+
+ UserResponseDto author = new UserResponseDto(authorId, "test", "test@test.com",
+ null, true);
+
+ MessageRequestDto request = new MessageRequestDto("Hello", channelId, authorId);
+ BinaryContentResponseDto file = new BinaryContentResponseDto(UUID.randomUUID(), "image.png", 3L,
+ "image/png");
+ MessageResponseDto expectedResponse = new MessageResponseDto(messageId, Instant.now(), Instant.now(),
+ "Hello", channelId, author, List.of(file));
+
+ MockMultipartFile messageCreateRequest = new MockMultipartFile(
+ "messageCreateRequest",
+ "",
+ "application/json",
+ objectMapper.writeValueAsBytes(request)
+ );
+
+ // 가상의 파일 첨부
+ MockMultipartFile attachment = new MockMultipartFile(
+ "attachments",
+ "image.png",
+ "image/png",
+ new byte[]{1, 2, 3}
+ );
+
+ given(messageService.create(any(MessageRequestDto.class), any()))
+ .willReturn(expectedResponse);
+
+ // when
+ ResultActions result = mockMvc.perform(multipart("/api/messages")
+ .file(messageCreateRequest)
+ .file(attachment)
+ .contentType(MediaType.MULTIPART_FORM_DATA_VALUE));
+
+ // then
+ result.andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(messageId.toString()))
+ .andExpect(jsonPath("$.content").value("Hello"))
+ .andExpect(jsonPath("$.author.id").value(authorId.toString()))
+ .andExpect(jsonPath("$.channelId").value(channelId.toString()))
+ .andExpect(jsonPath("$.attachments[0].fileName").value("image.png"))
+ .andExpect(jsonPath("$.attachments[0].contentType").value("image/png"))
+ .andExpect(jsonPath("$.attachments[0].size").value(3));
+ verify(messageService).create(argThat(req ->
+ req.authorId().equals(authorId) &&
+ req.channelId().equals(channelId)
+ ), any());
+ }
+
+ @Test
+ @DisplayName("필수 항목이 누락되면 400 Bad Request가 발생해야 한다.")
+ void givenMissingRequiredField_whenCreateMessage_thenReturnBadRequestWithValidationError()
+ throws Exception {
+
+ // given - 사용자 ID 누락
+ MessageRequestDto request = new MessageRequestDto("Hello", UUID.randomUUID(), null);
+
+ MockMultipartFile messageCreateRequest = new MockMultipartFile(
+ "messageCreateRequest",
+ "",
+ "application/json",
+ objectMapper.writeValueAsBytes(request)
+ );
+
+ // when
+ ResultActions result = mockMvc.perform(multipart("/api/messages")
+ .file(messageCreateRequest)
+ .contentType(MediaType.MULTIPART_FORM_DATA));
+
+ // then
+ result.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
+ .andExpect(jsonPath("$.message").value("유효성 검사 실패"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java
new file mode 100644
index 000000000..eecadacc8
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/controller/UserControllerTest.java
@@ -0,0 +1,121 @@
+package com.sprint.mission.discodeit.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sprint.mission.discodeit.config.JpaConfig;
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto;
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
+import com.sprint.mission.discodeit.dto.user.UserRequestDto;
+import com.sprint.mission.discodeit.dto.user.UserResponseDto;
+import com.sprint.mission.discodeit.service.basic.BasicUserService;
+import com.sprint.mission.discodeit.service.basic.BasicUserStatusService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+import java.util.UUID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(controllers = UserController.class,
+ excludeAutoConfiguration = {JpaConfig.class})
+@DisplayName("UserController 슬라이스 테스트")
+class UserControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockitoBean
+ private BasicUserService userService;
+
+ @MockitoBean
+ private BasicUserStatusService userStatusService;
+
+ @Test
+ @DisplayName("사용자 생성 요청이 올바르게 처리되어야 한다.")
+ void givenValidUserRequest_whenCreateUser_thenReturnCreatedUserResponse()
+ throws Exception {
+
+ // given
+ UUID userId = UUID.randomUUID();
+ UserRequestDto request = new UserRequestDto("test", "test@test.com", "pwd1234");
+ BinaryContentResponseDto profileImage = new BinaryContentResponseDto(UUID.randomUUID(), "profile.png", 3L,
+ "image/png");
+
+ UserResponseDto expectedResponse = new UserResponseDto(userId, "test", "test@test.com", profileImage, null);
+
+ MockMultipartFile userCreateRequest = new MockMultipartFile(
+ "userCreateRequest",
+ "",
+ "application/json",
+ objectMapper.writeValueAsBytes(request)
+ );
+
+ MockMultipartFile profile = new MockMultipartFile(
+ "profile",
+ "profile.png",
+ "image/png",
+ new byte[]{1, 2, 3}
+ );
+
+ given(userService.create(any(UserRequestDto.class), any(BinaryContentDto.class)))
+ .willReturn(expectedResponse);
+
+ // when
+ ResultActions result = mockMvc.perform(multipart("/api/users")
+ .file(userCreateRequest)
+ .file(profile)
+ .contentType(MediaType.MULTIPART_FORM_DATA));
+
+ // then
+ result.andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(userId.toString()))
+ .andExpect(jsonPath("$.username").value("test"))
+ .andExpect(jsonPath("$.email").value("test@test.com"));
+ verify(userService).create(argThat(req ->
+ req.username().equals("test") &&
+ req.email().equals("test@test.com")
+ ), any());
+ }
+
+ @Test
+ @DisplayName("필수 항목이 누락되면 400 Bad Request가 발생해야 한다.")
+ void givenMissingRequiredField_whenCreateUser_thenReturnBadRequestWithValidationError()
+ throws Exception {
+
+ // given
+ UserRequestDto request = new UserRequestDto(null, "test@test.com", "pwd1234");
+
+ MockMultipartFile userCreateRequest = new MockMultipartFile(
+ "userCreateRequest",
+ "",
+ "application/json",
+ objectMapper.writeValueAsBytes(request)
+ );
+
+ // when
+ ResultActions result = mockMvc.perform(multipart("/api/users")
+ .file(userCreateRequest)
+ .contentType(MediaType.MULTIPART_FORM_DATA));
+
+ // then
+ result.andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
+ .andExpect(jsonPath("$.message").value("유효성 검사 실패"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java
new file mode 100644
index 000000000..40d37b507
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/ChannelRepositoryTest.java
@@ -0,0 +1,44 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.entity.Channel;
+import com.sprint.mission.discodeit.entity.ChannelType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@DataJpaTest
+@ActiveProfiles("test")
+@DisplayName("ChannelRepository 기능 테스트")
+class ChannelRepositoryTest {
+
+ @Autowired
+ private ChannelRepository channelRepository;
+
+ @Test
+ @DisplayName("채널을 등록하고 조회할 수 있어야 한다.")
+ void itShouldSaveAndFindChannel() {
+
+ // given
+ Channel channel = Channel.builder()
+ .name("public")
+ .description("test channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ // when
+ Channel savedChannel = channelRepository.save(channel);
+
+ // then
+ Optional foundChannel = channelRepository.findById(savedChannel.getId());
+ assertTrue(foundChannel.isPresent(), "채널이 등록되어야 한다.");
+ assertEquals("public", foundChannel.get().getName());
+ assertEquals("test channel", foundChannel.get().getDescription());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java
new file mode 100644
index 000000000..ba67ebca1
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/MessageRepositoryTest.java
@@ -0,0 +1,108 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.config.JpaConfig;
+import com.sprint.mission.discodeit.entity.Channel;
+import com.sprint.mission.discodeit.entity.ChannelType;
+import com.sprint.mission.discodeit.entity.Message;
+import com.sprint.mission.discodeit.entity.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@DataJpaTest
+@Import(JpaConfig.class)
+@ActiveProfiles("test")
+@DisplayName("MessageRepository 기능 테스트")
+class MessageRepositoryTest {
+
+ @Autowired
+ private MessageRepository messageRepository;
+
+ @Autowired
+ private ChannelRepository channelRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ private Channel savedChannel;
+
+ @BeforeEach
+ void setUp() {
+ // given
+ Channel channel = Channel.builder()
+ .name("public")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ User user = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ savedChannel = channelRepository.save(channel);
+ User savedUser = userRepository.save(user);
+
+ Message message1 = Message.builder()
+ .channel(channel)
+ .author(user)
+ .content("Hello")
+ .build();
+
+ Message message2 = Message.builder()
+ .channel(channel)
+ .author(user)
+ .content("Hi")
+ .build();
+
+ Message message3 = Message.builder()
+ .channel(channel)
+ .author(user)
+ .content("Nice")
+ .build();
+
+ Message message4 = Message.builder()
+ .channel(channel)
+ .author(user)
+ .content("Wow")
+ .build();
+
+ messageRepository.saveAll(List.of(message1, message2, message3, message4));
+ }
+
+ @Test
+ @DisplayName("특정 채널의 이전 메시지를 시간 기준 내림차순으로 조회한다.")
+ void testFindByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc() {
+
+ // when
+ List result = messageRepository.findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc(
+ savedChannel.getId(), Instant.now(), PageRequest.of(0, 2)
+ );
+
+ // then
+ assertEquals(2, result.size());
+ assertEquals("Wow", result.get(0).getContent());
+ assertEquals("Nice", result.get(1).getContent());
+ }
+
+ @Test
+ @DisplayName("채널의 메시지를 페이지로 조회할 수 있어야 한다.")
+ void testFindPageByChannelId() {
+
+ // when
+ List page1 = messageRepository.findPageByChannelId(savedChannel.getId(), PageRequest.of(0, 2));
+
+ // then
+ assertEquals(2, page1.size());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java
new file mode 100644
index 000000000..6659751a2
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/repository/UserRepositoryTest.java
@@ -0,0 +1,103 @@
+package com.sprint.mission.discodeit.repository;
+
+import com.sprint.mission.discodeit.config.JpaConfig;
+import com.sprint.mission.discodeit.entity.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DataJpaTest
+@Import(JpaConfig.class)
+@ActiveProfiles("test")
+@DisplayName("UserRepository 기능 테스트")
+class UserRepositoryTest {
+
+ @Autowired
+ private UserRepository userRepository;
+
+ // 테스트에 사용할 더미 데이터
+ @BeforeEach
+ void setUp() {
+
+ // given
+ User user1 = User.builder()
+ .username("test1")
+ .email("test1@test.com")
+ .password("pwd1234")
+ .build();
+
+ User user2 = User.builder()
+ .username("test2")
+ .email("test2@test.com")
+ .password("pwd12345")
+ .build();
+
+ userRepository.save(user1);
+ userRepository.save(user2);
+ }
+
+ @Test
+ @DisplayName("사용자명으로 사용자를 조회할 수 있어야 한다.")
+ void givenUsername_whenFindByUsername_thenReturnUser() {
+
+ // when
+ Optional foundUser = userRepository.findByUsername("test1");
+
+ // then
+ assertTrue(foundUser.isPresent());
+ assertEquals("test1", foundUser.get().getUsername());
+ }
+
+ @Test
+ @DisplayName("이메일로 사용자를 조회할 수 있어야 한다.")
+ void givenEmail_whenFindByEmail_thenReturnUser() {
+
+ // when
+ Optional foundUser = userRepository.findByEmail("test2@test.com");
+
+ // then
+ assertTrue(foundUser.isPresent());
+ assertEquals("test2@test.com", foundUser.get().getEmail());
+ }
+
+ @Test
+ @DisplayName("존재하는 사용자명 여부를 확인할 수 있어야 한다.")
+ void givenUsername_whenExistsByUsername_thenReturnTrue() {
+
+ // when
+ boolean exist = userRepository.existsByUsername("test2");
+
+ // then
+ assertTrue(exist);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자명 여부를 확인할 수 있어야 한다.")
+ void givenNonexistentUsername_whenExistsByUsername_thenReturnFalse() {
+
+ // when
+ boolean exist = userRepository.existsByUsername("notExistName");
+
+ // then
+ assertFalse(exist);
+ }
+
+ @Test
+ @DisplayName("존재하는 이메일 여부를 확인할 수 있어야 한다.")
+ void givenUsername_whenExistsByEmail_thenReturnTrue() {
+
+ // when
+ boolean exist = userRepository.existsByEmail("test1@test.com");
+
+ // then
+ assertTrue(exist);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java
new file mode 100644
index 000000000..ba13379aa
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicChannelServiceTest.java
@@ -0,0 +1,409 @@
+package com.sprint.mission.discodeit.service.basic;
+
+import com.sprint.mission.discodeit.dto.channel.ChannelResponseDto;
+import com.sprint.mission.discodeit.dto.channel.PrivateChannelDto;
+import com.sprint.mission.discodeit.dto.channel.PublicChannelDto;
+import com.sprint.mission.discodeit.dto.channel.PublicChannelUpdateDto;
+import com.sprint.mission.discodeit.entity.Channel;
+import com.sprint.mission.discodeit.entity.ChannelType;
+import com.sprint.mission.discodeit.entity.ReadStatus;
+import com.sprint.mission.discodeit.entity.User;
+import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException;
+import com.sprint.mission.discodeit.exception.channel.PrivateChannelUpdateException;
+import com.sprint.mission.discodeit.mapper.ChannelMapper;
+import com.sprint.mission.discodeit.repository.ChannelRepository;
+import com.sprint.mission.discodeit.repository.ReadStatusRepository;
+import com.sprint.mission.discodeit.repository.UserRepository;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Channel 서비스 단위 테스트")
+class BasicChannelServiceTest {
+
+ @InjectMocks
+ private BasicChannelService channelService;
+
+ @Mock
+ private ChannelRepository channelRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private ReadStatusRepository readStatusRepository;
+
+ @Mock
+ private ChannelMapper channelMapper;
+
+ @Test
+ @DisplayName("정상적인 Public 채널 생성 요청 시 올바른 비즈니스 로직이 수행되어야 한다.")
+ void should_create_public_channel_when_valid_request() {
+
+ // given
+ UUID channelId = UUID.randomUUID();
+ String name = "public1";
+ String description = "It's public channel";
+
+ PublicChannelDto request = new PublicChannelDto(name, description);
+
+ Channel channel = Channel.builder()
+ .name(name)
+ .description(description)
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ ChannelResponseDto response = new ChannelResponseDto(channel.getId(), ChannelType.PUBLIC,
+ name, description, null, null);
+
+ given(channelRepository.save(any(Channel.class))).willReturn(channel);
+ given(channelMapper.toDto(any(Channel.class))).willReturn(response);
+
+ // when
+ ChannelResponseDto result = channelService.createPublicChannel(request);
+
+ // then
+ assertNotNull(result);
+ assertEquals(name, result.name());
+ assertEquals(description, result.description());
+ assertEquals(ChannelType.PUBLIC, result.type());
+ }
+
+ @Test
+ @DisplayName("정상적인 Private 채널 생성 요청 시 올바른 비즈니스 로직이 수행되어야 한다.")
+ void should_create_private_channel_when_valid_user_ids() {
+
+ // given
+ UUID userId1 = UUID.randomUUID();
+ UUID userId2 = UUID.randomUUID();
+ UUID channelId = UUID.randomUUID();
+
+ PrivateChannelDto request = new PrivateChannelDto(List.of(userId1, userId2));
+
+ Channel channel = new Channel();
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ User user1 = User.builder()
+ .username("user1")
+ .email("user1@example.com")
+ .password("pwd1")
+ .build();
+
+ User user2 = User.builder()
+ .username("user2")
+ .email("user2@example.com")
+ .password("pwd2")
+ .build();
+
+ ChannelResponseDto response = new ChannelResponseDto(channelId, ChannelType.PRIVATE, null,
+ null, List.of(), null);
+
+ ReflectionTestUtils.setField(user1, "id", userId1);
+ ReflectionTestUtils.setField(user2, "id", userId2);
+
+ given(channelRepository.save(any(Channel.class))).willReturn(channel);
+ given(userRepository.findById(userId1)).willReturn(Optional.of(user1));
+ given(userRepository.findById(userId2)).willReturn(Optional.of(user2));
+ given(channelMapper.toDto(channel)).willReturn(response);
+
+ // when
+ ChannelResponseDto result = channelService.createPrivateChannel(request);
+
+ // then
+ assertNotNull(result);
+ assertEquals(channelId, result.id());
+ assertEquals(ChannelType.PRIVATE, result.type());
+ verify(channelRepository).save(any(Channel.class));
+ verify(readStatusRepository).saveAll(anyList());
+ verify(channelMapper).toDto(channel);
+ }
+
+ @Test
+ @DisplayName("등록된 채널 ID로 조회하면 올바르게 조회되어야 한다.")
+ void should_return_channel_when_channel_exists_by_id() {
+
+ // given
+ String channelName = "channel1";
+ String channelDescription = "test channel";
+ UUID channelId = UUID.randomUUID();
+
+ Channel channel = Channel.builder()
+ .name(channelName)
+ .description(channelDescription)
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ ChannelResponseDto expectedChannel = new ChannelResponseDto(channelId, ChannelType.PUBLIC, channelName,
+ channelDescription, List.of(), null);
+
+ given(channelRepository.findById(channelId)).willReturn(Optional.of(channel));
+ given(channelMapper.toDto(channel)).willReturn(expectedChannel);
+
+ // when
+ ChannelResponseDto result = channelService.findById(channelId);
+
+ // then
+ assertEquals(expectedChannel, result);
+ verify(channelRepository).findById(channelId);
+ verify(channelMapper).toDto(channel);
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 채널 ID로 조회하면 NotFoundChannelException이 발생해야 한다.")
+ void should_throw_not_found_exception_when_channel_does_not_exist_by_id() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+ given(channelRepository.findById(notExistId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> channelService.findById(notExistId));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundChannelException.class)
+ .hasMessageContaining("채널");
+ verify(channelRepository).findById(notExistId);
+ }
+
+ @Test
+ @DisplayName("등록된 사용자 ID로 조회하면 사용자의 참여 채널과 공개 채널이 모두 반환되어야 한다.")
+ void should_return_user_channels_and_public_channels_when_user_exists() {
+
+ // given
+ UUID userId = UUID.randomUUID();
+ UUID publicChannelId = UUID.randomUUID();
+ UUID privateChannelId = UUID.randomUUID();
+ UUID notRelatedChannelId = UUID.randomUUID();
+
+ User user = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("test123")
+ .build();
+
+ Channel publicChannel = Channel.builder()
+ .name("public")
+ .description("test channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ Channel privateChannel = Channel.builder()
+ .type(ChannelType.PRIVATE)
+ .build();
+
+ Channel notRelatedChannel = Channel.builder()
+ .type(ChannelType.PRIVATE)
+ .build();
+
+ ReflectionTestUtils.setField(user, "id", userId);
+ ReflectionTestUtils.setField(publicChannel, "id", publicChannelId);
+ ReflectionTestUtils.setField(privateChannel, "id", privateChannelId);
+ ReflectionTestUtils.setField(notRelatedChannel, "id", notRelatedChannelId);
+
+ ReadStatus readStatus = ReadStatus.builder()
+ .channel(privateChannel)
+ .user(user)
+ .build();
+ ReflectionTestUtils.setField(readStatus, "id", UUID.randomUUID());
+
+ List allChannels = List.of(publicChannel, privateChannel, notRelatedChannel);
+ List readStatuses = List.of(readStatus);
+
+ ChannelResponseDto publicChannelDto = new ChannelResponseDto(publicChannelId, ChannelType.PUBLIC, "public",
+ "test channel", null, null);
+
+ ChannelResponseDto privateChannelDto = new ChannelResponseDto(privateChannelId, ChannelType.PRIVATE, null,
+ null, List.of(), null);
+
+ given(readStatusRepository.findAllByUserId(userId)).willReturn(readStatuses);
+ given(channelRepository.findAll()).willReturn(allChannels);
+ given(channelMapper.toDto(publicChannel)).willReturn(publicChannelDto);
+ given(channelMapper.toDto(privateChannel)).willReturn(privateChannelDto);
+
+ // when
+ List result = channelService.findAllByUserId(userId);
+
+ // then
+ assertEquals(2, result.size());
+ assertTrue(result.contains(publicChannelDto));
+ assertTrue(result.contains(privateChannelDto));
+ assertFalse(result.stream().anyMatch(dto -> dto.id().equals(notRelatedChannelId)));
+ verify(readStatusRepository).findAllByUserId(userId);
+ verify(channelRepository).findAll();
+ verify(channelMapper).toDto(publicChannel);
+ verify(channelMapper).toDto(privateChannel);
+ verifyNoMoreInteractions(channelMapper); // 참여하지 않은 비공개 채널은 변환 X
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 사용자 ID로 조회하면 Public 채널만 조회되어야한다.")
+ void should_return_only_public_channels_when_user_does_not_exist() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+ UUID publicChannelId = UUID.randomUUID();
+ UUID privateChannelId = UUID.randomUUID();
+
+ Channel publicChannel = Channel.builder()
+ .name("public")
+ .description("test channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ Channel privateChannel = Channel.builder()
+ .type(ChannelType.PRIVATE)
+ .build();
+
+ ReflectionTestUtils.setField(publicChannel, "id", publicChannelId);
+ ReflectionTestUtils.setField(privateChannel, "id", privateChannelId);
+
+ List allChannels = List.of(publicChannel, privateChannel);
+
+ ChannelResponseDto publicChannelDto = new ChannelResponseDto(publicChannelId, ChannelType.PUBLIC, "public",
+ "test channel", null, null);
+
+ given(readStatusRepository.findAllByUserId(notExistId)).willReturn(List.of());
+ given(channelRepository.findAll()).willReturn(allChannels);
+ given(channelMapper.toDto(publicChannel)).willReturn(publicChannelDto);
+
+ // when
+ List result = channelService.findAllByUserId(notExistId);
+
+ // then
+ assertEquals(1, result.size());
+ assertTrue(result.contains(publicChannelDto));
+ assertFalse(result.stream().anyMatch(dto -> dto.id().equals(privateChannelId)));
+ verify(readStatusRepository).findAllByUserId(notExistId);
+ verify(channelRepository).findAll();
+ verify(channelMapper).toDto(publicChannel);
+ verifyNoMoreInteractions(channelMapper);
+ }
+
+ @Test
+ @DisplayName("정상적인 요청으로 Public 채널을 업데이트하면 올바른 비즈니스 로직이 수행되어야한다.")
+ void should_update_public_channel_when_valid_update_request() {
+
+ // given
+ UUID channelId = UUID.randomUUID();
+ String newName = "public";
+ String newDescription = "It's public channel";
+
+ Channel existingChannel = Channel.builder()
+ .name("public channel")
+ .description("it's public channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ ReflectionTestUtils.setField(existingChannel, "id", channelId);
+
+ PublicChannelUpdateDto updateRequest = new PublicChannelUpdateDto(newName, newDescription);
+
+ ChannelResponseDto expectedResponse = new ChannelResponseDto(channelId, ChannelType.PUBLIC,
+ newName, newDescription, List.of(), null);
+
+ given(channelRepository.findById(channelId)).willReturn(Optional.of(existingChannel));
+ given(channelRepository.save(any(Channel.class))).willReturn(existingChannel);
+ given(channelMapper.toDto(any(Channel.class))).willReturn(expectedResponse);
+
+ // when
+ ChannelResponseDto result = channelService.update(channelId, updateRequest);
+
+ // then
+ assertNotNull(result);
+ assertEquals(newName, result.name());
+ assertEquals(newDescription, result.description());
+ verify(channelRepository).save(any(Channel.class));
+ }
+
+ @Test
+ @DisplayName("Private 채널을 수정하려하면 PrivateChannelUpdateException이 발생해야한다.")
+ void should_throw_exception_when_try_to_update_private_channel() {
+
+ // given
+ UUID channelId = UUID.randomUUID();
+ Channel channel = Channel.builder()
+ .type(ChannelType.PRIVATE)
+ .build();
+
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ PublicChannelUpdateDto updateRequest = new PublicChannelUpdateDto("new Channel", "It's new Channel");
+
+ given(channelRepository.findById(channelId)).willReturn(Optional.of(channel));
+
+ // when
+ Throwable thrown = catchThrowable(() -> channelService.update(channelId, updateRequest));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(PrivateChannelUpdateException.class)
+ .hasMessageContaining("Private");
+ verify(channelRepository).findById(channelId);
+ verify(userRepository, never()).save(any());
+ }
+
+
+ @Test
+ @DisplayName("등록된 채널 ID로 삭제를 시도하면 정상적으로 삭제되어야한다.")
+ void should_delete_channel_when_channel_exists_by_id() {
+
+ // given
+ UUID channelId = UUID.randomUUID();
+
+ Channel channel = Channel.builder()
+ .name("public channel")
+ .description("It's public channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ given(channelRepository.findById(channelId)).willReturn(Optional.of(channel));
+
+ // when
+ channelService.deleteById(channelId);
+
+ // then
+ verify(channelRepository).deleteById(channelId);
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 채널 ID로 삭제를 시도하면 NotFoundChannelException이 발생해야 한다.")
+ void should_throw_not_found_exception_when_try_to_delete_nonexistent_channel() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+ given(channelRepository.findById(notExistId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> channelService.deleteById(notExistId));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundChannelException.class)
+ .hasMessageContaining("채널");
+ verify(channelRepository).findById(notExistId);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java
new file mode 100644
index 000000000..1941d80dc
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicMessageServiceTest.java
@@ -0,0 +1,469 @@
+package com.sprint.mission.discodeit.service.basic;
+
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto;
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
+import com.sprint.mission.discodeit.dto.message.MessageRequestDto;
+import com.sprint.mission.discodeit.dto.message.MessageResponseDto;
+import com.sprint.mission.discodeit.dto.message.MessageUpdateDto;
+import com.sprint.mission.discodeit.dto.response.PageResponse;
+import com.sprint.mission.discodeit.entity.*;
+import com.sprint.mission.discodeit.exception.channel.NotFoundChannelException;
+import com.sprint.mission.discodeit.exception.message.NotFoundMessageException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
+import com.sprint.mission.discodeit.mapper.MessageMapper;
+import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper;
+import com.sprint.mission.discodeit.repository.BinaryContentRepository;
+import com.sprint.mission.discodeit.repository.ChannelRepository;
+import com.sprint.mission.discodeit.repository.MessageRepository;
+import com.sprint.mission.discodeit.repository.UserRepository;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Message 서비스 단위 테스트")
+class BasicMessageServiceTest {
+
+ @InjectMocks
+ private BasicMessageService messageService;
+
+ @Mock
+ private MessageRepository messageRepository;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private ChannelRepository channelRepository;
+
+ @Mock
+ private BinaryContentRepository binaryContentRepository;
+
+ @Mock
+ private MessageMapper messageMapper;
+
+ @Mock
+ private BinaryContentStorage binaryContentStorage;
+
+ @Mock
+ private BinaryContentStructMapper binaryContentMapper;
+
+ @Test
+ @DisplayName("정상적인 Message 생성 시 올바른 비즈니스 로직이 수행되어야 한다.")
+ void givenValidRequest_whenCreateMessage_thenCreateSuccessfully() {
+
+ // given
+ String content = "Hello";
+ UUID messageId = UUID.randomUUID();
+ UUID authorId = UUID.randomUUID();
+ UUID channelId = UUID.randomUUID();
+
+ byte[] imageBytes = new byte[]{1, 2, 3};
+
+ BinaryContentDto binaryContentDto = new BinaryContentDto("attachment.png", 3L,
+ "image/png", imageBytes);
+
+ BinaryContent binaryContent = new BinaryContent("attachment.png", 3L, "image/png");
+
+ UUID attachmentId = UUID.randomUUID();
+ ReflectionTestUtils.setField(binaryContent, "id", attachmentId);
+
+ MessageRequestDto request = new MessageRequestDto(content, channelId, authorId);
+
+ User user = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ Channel channel = Channel.builder()
+ .name("public")
+ .description("test channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ Message message = Message.builder()
+ .author(user)
+ .channel(channel)
+ .content(content)
+ .attachments(List.of(binaryContent))
+ .build();
+
+ ReflectionTestUtils.setField(user, "id", authorId);
+ ReflectionTestUtils.setField(channel, "id", channelId);
+ ReflectionTestUtils.setField(message, "id", messageId);
+
+ BinaryContentResponseDto attachment = new BinaryContentResponseDto(attachmentId, "attachment.png", 3L,
+ "image/png");
+
+ MessageResponseDto messageResponseDto = new MessageResponseDto(messageId, Instant.now(), Instant.now(),
+ content, channelId, null, List.of(attachment));
+
+ given(userRepository.findById(authorId)).willReturn(Optional.of(user));
+ given(channelRepository.findById(channelId)).willReturn(Optional.of(channel));
+ given(binaryContentMapper.toEntity(binaryContentDto)).willReturn(binaryContent);
+ given(messageRepository.save(any(Message.class))).willReturn(message);
+ given(messageMapper.toDto(any(Message.class))).willReturn(messageResponseDto);
+
+ // when
+ MessageResponseDto result = messageService.create(request, List.of(binaryContentDto));
+
+ // then
+ assertNotNull(result);
+ assertEquals(content, result.content());
+ assertEquals(channelId, result.channelId());
+ assertEquals(List.of(attachment), result.attachments());
+ verify(userRepository).findById(authorId);
+ verify(channelRepository).findById(channelId);
+ verify(binaryContentRepository).saveAll(List.of(binaryContent));
+ verify(binaryContentStorage).put(attachmentId, imageBytes);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 사용자 ID로 메시지를 전송하면 NotFoundUserException이 발생해야 한다.")
+ void givenInvalidUserId_whenCreateMessage_thenThrowNotFoundUserException() {
+
+ // given
+ UUID notExistUserId = UUID.randomUUID();
+ UUID channelId = UUID.randomUUID();
+
+ MessageRequestDto request = new MessageRequestDto("Hello", channelId, notExistUserId);
+
+ given(userRepository.findById(notExistUserId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> messageService.create(request, null));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundUserException.class)
+ .hasMessageContaining("사용자");
+ verify(messageRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 채널 ID로 메시지를 전송하면 NotFoundChannelException이 발생해야 한다.")
+ void givenInvalidChannelId_whenCreateMessage_thenThrowNotFoundChannelException() {
+
+ // given
+ UUID userId = UUID.randomUUID();
+ UUID notExistChannelId = UUID.randomUUID();
+
+ MessageRequestDto request = new MessageRequestDto("Hello", notExistChannelId, userId);
+
+ User user = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ given(userRepository.findById(userId)).willReturn(Optional.of(user));
+ given(channelRepository.findById(notExistChannelId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> messageService.create(request, null));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundChannelException.class)
+ .hasMessageContaining("채널");
+ verify(messageRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("메시지 ID로 메시지를 조회하면 정상적으로 조회되어야 한다.")
+ void givenValidMessageId_whenFindMessage_thenReturnDto() {
+
+ // given
+ String content = "Hello";
+ UUID messageId = UUID.randomUUID();
+
+ User user = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ Channel channel = Channel.builder()
+ .name("public")
+ .description("test channel")
+ .type(ChannelType.PUBLIC)
+ .build();
+
+ Message message = Message.builder()
+ .content(content)
+ .author(user)
+ .channel(channel)
+ .attachments(List.of())
+ .build();
+
+ ReflectionTestUtils.setField(message, "id", messageId);
+ ReflectionTestUtils.setField(user, "id", UUID.randomUUID());
+ ReflectionTestUtils.setField(channel, "id", UUID.randomUUID());
+
+ MessageResponseDto expectedDto = new MessageResponseDto(
+ messageId, Instant.now(), Instant.now(),
+ content, channel.getId(), null, List.of()
+ );
+
+ given(messageRepository.findById(messageId)).willReturn(Optional.of(message));
+ given(messageMapper.toDto(message)).willReturn(expectedDto);
+
+ // when
+ MessageResponseDto result = messageService.findById(messageId);
+
+ // then
+ assertNotNull(result);
+ assertEquals(content, result.content());
+ assertEquals(channel.getId(), result.channelId());
+ verify(messageRepository).findById(messageId);
+ verify(messageMapper).toDto(message);
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 ID로 메시지를 조회하면 NotFoundMessageException이 발생해야 한다.")
+ void givenInvalidMessageId_whenFindMessage_thenThrowNotFoundMessageException() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+
+ given(messageRepository.findById(notExistId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> messageService.findById(notExistId));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundMessageException.class)
+ .hasMessageContaining("메시지");
+ verify(messageRepository).findById(notExistId);
+ verifyNoInteractions(messageMapper);
+ }
+
+ @Test
+ @DisplayName("채널 ID로 메시지를 페이지네이션 조회할 때 cursor가 null이면 최신 메시지를 반환해야 한다.")
+ void givenNullCursor_whenFindMessagesByChannelId_thenReturnFirstPage() {
+ // given
+ UUID channelId = UUID.randomUUID();
+ UUID messageId1 = UUID.randomUUID();
+ UUID messageId2 = UUID.randomUUID();
+
+ int size = 2;
+ Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
+ Pageable extendedPageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "createdAt"));
+
+ Channel channel = Channel.builder().name("channel").type(ChannelType.PUBLIC).build();
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ Instant now = Instant.now();
+ Instant t1 = now.minusSeconds(10);
+ Instant t2 = now.minusSeconds(20);
+
+ Message msg1 = Message.builder().content("Hello").channel(channel).build();
+ Message msg2 = Message.builder().content("World").channel(channel).build();
+ Message msg3 = Message.builder().content("NextPage").channel(channel).build();
+
+ ReflectionTestUtils.setField(msg1, "createdAt", now);
+ ReflectionTestUtils.setField(msg2, "createdAt", t1);
+ ReflectionTestUtils.setField(msg3, "createdAt", t2);
+ ReflectionTestUtils.setField(msg1, "id", messageId1);
+ ReflectionTestUtils.setField(msg2, "id", messageId2);
+
+ List allMessages = List.of(msg1, msg2, msg3); // size + 1개
+
+ MessageResponseDto dto1 = new MessageResponseDto(messageId1, Instant.now(), Instant.now(), "Hello", channelId, null, List.of());
+ MessageResponseDto dto2 = new MessageResponseDto(messageId2, Instant.now(), Instant.now(), "World", channelId, null, List.of());
+
+ given(messageRepository.findPageByChannelId(channelId, extendedPageable)).willReturn(allMessages);
+ given(messageMapper.toDto(msg1)).willReturn(dto1);
+ given(messageMapper.toDto(msg2)).willReturn(dto2);
+
+ // when
+ PageResponse result = messageService.findAllByChannelId(channelId, null, pageable);
+
+ // then
+ assertEquals(size, result.content().size());
+ assertTrue(result.hasNext());
+ assertEquals(dto1, result.content().get(0));
+ assertEquals(dto2, result.content().get(1));
+ verify(messageRepository).findPageByChannelId(channelId, extendedPageable);
+ verify(messageMapper).toDto(msg1);
+ verify(messageMapper).toDto(msg2);
+ }
+
+ @Test
+ @DisplayName("cursor가 주어지면 해당 시간 이전 메시지를 최신순으로 페이지네이션 조회해야 한다.")
+ void givenCursor_whenFindMessagesByChannelId_thenReturnPreviousMessagesPage() {
+ // given
+ UUID channelId = UUID.randomUUID();
+ UUID messageId1 = UUID.randomUUID();
+ UUID messageId2 = UUID.randomUUID();
+
+ int size = 2;
+ Instant cursor = Instant.now(); // 기준 시간
+ Instant t1 = cursor.minusSeconds(10);
+ Instant t2 = cursor.minusSeconds(20);
+ Instant t3 = cursor.minusSeconds(30);
+
+ Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
+ Pageable extendedPageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "createdAt"));
+
+ Channel channel = Channel.builder().name("channel").type(ChannelType.PUBLIC).build();
+ ReflectionTestUtils.setField(channel, "id", channelId);
+
+ Message msg1 = Message.builder().content("Msg1").channel(channel).build();
+ Message msg2 = Message.builder().content("Msg2").channel(channel).build();
+ Message msg3 = Message.builder().content("Msg3").channel(channel).build();
+
+ ReflectionTestUtils.setField(msg1, "createdAt", t1);
+ ReflectionTestUtils.setField(msg2, "createdAt", t2);
+ ReflectionTestUtils.setField(msg3, "createdAt", t3);
+ ReflectionTestUtils.setField(msg1, "id", messageId1);
+ ReflectionTestUtils.setField(msg2, "id", messageId2);
+
+ List messages = List.of(msg1, msg2, msg3); // size + 1개
+
+ MessageResponseDto dto1 = new MessageResponseDto(messageId1, t1, t1, "Msg1", channelId, null, List.of());
+ MessageResponseDto dto2 = new MessageResponseDto(messageId2, t2, t2, "Msg2", channelId, null, List.of());
+
+ given(messageRepository.findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc(channelId, cursor, extendedPageable))
+ .willReturn(messages);
+ given(messageMapper.toDto(msg1)).willReturn(dto1);
+ given(messageMapper.toDto(msg2)).willReturn(dto2);
+
+ // when
+ PageResponse result = messageService.findAllByChannelId(channelId, cursor, pageable);
+
+ // then
+ assertEquals(size, result.content().size());
+ assertTrue(result.hasNext());
+ assertEquals(dto1, result.content().get(0));
+ assertEquals(dto2, result.content().get(1));
+ assertEquals(t2, result.nextCursor()); // 마지막 메시지의 createdAt
+ verify(messageRepository).findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc(channelId, cursor, extendedPageable);
+ verify(messageMapper).toDto(msg1);
+ verify(messageMapper).toDto(msg2);
+ verifyNoMoreInteractions(messageMapper); // msg3는 변환 안됨
+ }
+
+ @Test
+ @DisplayName("등록된 메시지 ID로 메시지를 업데이트하면 올바른 비즈니스 로직이 수행되어야 한다.")
+ void givenValidMessageIdAndContent_whenUpdateMessage_thenUpdateSuccessfully() {
+
+ // given
+ UUID messageId = UUID.randomUUID();
+
+ Message message = Message.builder()
+ .content("Hello")
+ .build();
+
+ ReflectionTestUtils.setField(message, "id", messageId);
+
+ String newContent = "Hi";
+
+ Message updatedMessage = Message.builder()
+ .content(newContent)
+ .build();
+
+ MessageResponseDto expectedMessage = new MessageResponseDto(messageId, Instant.now(), Instant.now(), newContent,
+ null, null, List.of());
+
+ given(messageRepository.findById(messageId)).willReturn(Optional.of(message));
+ given(messageMapper.toDto(any(Message.class))).willReturn(expectedMessage);
+ given(messageRepository.save(any(Message.class))).willReturn(updatedMessage);
+
+ // when
+ MessageResponseDto result = messageService.updateContent(messageId, newContent);
+
+ // then
+ assertNotNull(result);
+ assertEquals(expectedMessage, result);
+ verify(messageRepository).findById(messageId);
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 메시지 ID로 메시지를 업데이트하면 NotFoundMessageException이 발생해야 한다.")
+ void givenInvalidMessageId_whenUpdateMessage_thenThrowNotFoundMessageException() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+
+ given(messageRepository.findById(notExistId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> messageService.updateContent(notExistId, "hello"));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundMessageException.class)
+ .hasMessageContaining("메시지");
+ verify(messageRepository).findById(notExistId);
+ }
+
+ @Test
+ @DisplayName("등록된 메시지 ID로 삭제하면 첨부파일도 삭제되어야 한다.")
+ void givenValidMessageId_whenDeleteMessage_thenDeleteWithAttachments() {
+
+ // given
+ UUID messageId = UUID.randomUUID();
+ BinaryContent attachment = new BinaryContent("attachment.jpg", 3L,
+ "image/jpeg");
+
+ ReflectionTestUtils.setField(attachment, "id", UUID.randomUUID());
+
+ Message message = Message.builder()
+ .attachments(List.of(attachment))
+ .build();
+
+ ReflectionTestUtils.setField(message, "id", messageId);
+
+ given(messageRepository.findById(messageId)).willReturn(Optional.of(message));
+
+ // when
+ messageService.deleteById(messageId);
+
+ // then
+ verify(messageRepository).findById(messageId);
+ verify(binaryContentRepository).deleteById(message.getAttachments().get(0).getId());
+ verify(messageRepository).deleteById(messageId);
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 메시지 ID로 삭제하면 NotFoundMessageException이 발생해야 한다.")
+ void givenInvalidMessageId_whenDeleteMessage_thenThrowNotFoundMessageException() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+ given(messageRepository.findById(notExistId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> messageService.deleteById(notExistId));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundMessageException.class)
+ .hasMessageContaining("메시지");
+ verify(messageRepository).findById(notExistId);
+ verifyNoMoreInteractions(binaryContentRepository);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java
new file mode 100644
index 000000000..6b9bd81ce
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/service/basic/BasicUserServiceTest.java
@@ -0,0 +1,410 @@
+package com.sprint.mission.discodeit.service.basic;
+
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentDto;
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
+import com.sprint.mission.discodeit.dto.user.UserRequestDto;
+import com.sprint.mission.discodeit.dto.user.UserResponseDto;
+import com.sprint.mission.discodeit.dto.user.UserUpdateDto;
+import com.sprint.mission.discodeit.entity.BinaryContent;
+import com.sprint.mission.discodeit.entity.User;
+import com.sprint.mission.discodeit.entity.UserStatus;
+import com.sprint.mission.discodeit.exception.user.DuplicateEmailException;
+import com.sprint.mission.discodeit.exception.user.DuplicateNameException;
+import com.sprint.mission.discodeit.exception.user.NotFoundUserException;
+import com.sprint.mission.discodeit.mapper.UserMapper;
+import com.sprint.mission.discodeit.mapper.struct.BinaryContentStructMapper;
+import com.sprint.mission.discodeit.repository.BinaryContentRepository;
+import com.sprint.mission.discodeit.repository.UserRepository;
+import com.sprint.mission.discodeit.repository.UserStatusRepository;
+import com.sprint.mission.discodeit.storage.BinaryContentStorage;
+import org.junit.jupiter.api.DisplayName;
+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 org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.not;
+import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("User 서비스 단위 테스트")
+class BasicUserServiceTest {
+
+ @InjectMocks
+ private BasicUserService userService;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private UserStatusRepository userStatusRepository;
+
+ @Mock
+ private BinaryContentRepository binaryContentRepository;
+
+ @Mock
+ private UserMapper userMapper;
+
+ @Mock
+ private BinaryContentStorage binaryContentStorage;
+
+ @Mock
+ private BinaryContentStructMapper binaryContentMapper;
+
+ @Test
+ @DisplayName("정상적인 User 생성 시 올바른 비즈니스 로직이 수행되어야 한다.")
+ void givenValidRequestAndProfile_whenCreateUser_thenCreateSuccessfully() {
+
+ // given
+ String username = "test";
+ String email = "test@test.com";
+ String password = "pwd1234";
+ UserRequestDto request = new UserRequestDto(username, email, password);
+ byte[] imageBytes = new byte[]{1, 2, 3};
+
+ BinaryContentDto binaryContentDto = new BinaryContentDto("profile.png", 3L,
+ "image/png", imageBytes);
+
+ BinaryContent binaryContent = new BinaryContent("profile.png", 3L, "image/png");
+
+ UUID profileId = UUID.randomUUID();
+ ReflectionTestUtils.setField(binaryContent, "id", profileId);
+
+ User user = User.builder()
+ .username(username)
+ .email(email)
+ .password(password)
+ .build();
+
+ UserStatus userStatus = UserStatus.builder()
+ .user(user)
+ .lastActiveAt(Instant.now())
+ .build();
+
+ UUID userId = UUID.randomUUID();
+ UUID userStatusId = UUID.randomUUID();
+
+ ReflectionTestUtils.setField(user, "id", userId);
+ ReflectionTestUtils.setField(userStatus, "id", userStatusId);
+
+ BinaryContentResponseDto profile = new BinaryContentResponseDto(profileId, "profile.png", 3L,
+ "image/png");
+
+ UserResponseDto response = new UserResponseDto(userId, username, email, profile, null);
+
+ given(binaryContentMapper.toEntity(binaryContentDto)).willReturn(binaryContent);
+ given(userRepository.save(any(User.class))).willReturn(user);
+ given(userMapper.toDto(user)).willReturn(response);
+ given(userRepository.existsByUsername(username)).willReturn(false);
+ given(userRepository.existsByEmail(email)).willReturn(false);
+
+ // when
+ UserResponseDto result = userService.create(request, binaryContentDto);
+
+ // then
+ assertNotNull(result);
+ assertEquals(username, result.username());
+ assertEquals(email, result.email());
+ assertEquals(profile, result.profile());
+ verify(userStatusRepository).save(any(UserStatus.class));
+ verify(binaryContentRepository).save(binaryContent);
+ verify(binaryContentStorage).put(profileId, imageBytes);
+ }
+
+ @Test
+ @DisplayName("중복된 username이 존재하는 경우 DuplicateNameException이 발생해야 한다.")
+ void givenDuplicateUsername_whenCreateUser_thenThrowDuplicateNameException() {
+
+ // given
+ String username = "test";
+ String email = "test@test.com";
+ String password = "pwd1234";
+
+ UserRequestDto request = new UserRequestDto(username, email, password);
+ given(userRepository.existsByUsername(username)).willReturn(true);
+
+ // when
+ Throwable thrown = catchThrowable(() -> userService.create(request, null));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(DuplicateNameException.class)
+ .hasMessageContaining("존재");
+ verify(userRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("중복된 email이 존재하는 경우 DuplicateEmailException이 발생해야 한다.")
+ void givenDuplicateEmail_whenCreateUser_thenThrowDuplicateEmailException() {
+
+ // given
+ String username = "test";
+ String email = "test@test.com";
+ String password = "pwd1234";
+
+ UserRequestDto request = new UserRequestDto(username, email, password);
+ given(userRepository.existsByEmail(email)).willReturn(true);
+
+ // when
+ Throwable thrown = catchThrowable(() -> userService.create(request, null));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(DuplicateEmailException.class)
+ .hasMessageContaining("존재");
+ verify(userRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("등록된 사용자 ID로 조회하면 올바르게 조회되어야 한다.")
+ void givenRegisteredUserId_whenFindById_thenReturnUserResponseDto() {
+
+ // given
+ String username = "test";
+ String email = "test@test.com";
+ String password = "pwd1234";
+ UUID userId = UUID.randomUUID();
+
+ User user = User.builder()
+ .username(username)
+ .email(email)
+ .password(password)
+ .build();
+
+ ReflectionTestUtils.setField(user, "id", userId);
+
+ UserStatus userStatus = UserStatus.builder()
+ .user(user)
+ .lastActiveAt(Instant.now())
+ .build();
+
+ UserResponseDto expectedUser = new UserResponseDto(userId, username, email,
+ null, null);
+
+ given(userRepository.findById(userId)).willReturn(Optional.of(user));
+ given(userStatusRepository.findByUserId(userId)).willReturn(Optional.of(userStatus));
+ given(userMapper.toDto(user)).willReturn(expectedUser);
+
+ // when
+ UserResponseDto actualUser = userService.findById(userId);
+
+ // then
+ assertEquals(expectedUser, actualUser);
+ verify(userRepository).findById(userId);
+ verify(userStatusRepository).findByUserId(userId);
+ verify(userMapper).toDto(user);
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 ID로 사용자를 조회하면 NotFoundUserException이 발생해야한다.")
+ void givenNonexistentUserId_whenFindById_thenThrowNotFoundUserException() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+ given(userRepository.findById(notExistId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> userService.findById(notExistId));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundUserException.class)
+ .hasMessageContaining("사용자");
+ verify(userRepository).findById(notExistId);
+ verifyNoInteractions(userStatusRepository, userMapper); // 사용자 없으면 이후 로직 없어야 함
+ }
+
+ @Test
+ @DisplayName("정상적인 정보로 사용자를 업데이트하면 올바른 비즈니스 로직이 수행되어야 한다.")
+ void givenValidUpdateRequest_whenUpdateUser_thenUpdateSuccessfully() {
+
+ // given
+ UUID userId = UUID.randomUUID();
+ String newUsername = "test2";
+ String newEmail = "test2@test.com";
+ String newPassword = "pwd12345";
+
+ BinaryContent oldProfile = new BinaryContent("old.png", 2L,
+ "image/png");
+ UUID oldProfileId = UUID.randomUUID();
+ ReflectionTestUtils.setField(oldProfile, "id", oldProfileId);
+
+ UserUpdateDto updateRequest = new UserUpdateDto(newUsername, newEmail, newPassword);
+
+ User existingUser = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .profile(oldProfile)
+ .build();
+
+ ReflectionTestUtils.setField(existingUser, "id", userId);
+
+ User updatedUser = User.builder()
+ .username(newUsername)
+ .email(newEmail)
+ .password(newPassword)
+ .build();
+
+ ReflectionTestUtils.setField(updatedUser, "id", userId);
+
+ UserResponseDto expectedResponse = new UserResponseDto(
+ userId, newUsername, newEmail, null, null);
+
+ given(userRepository.findById(userId)).willReturn(Optional.of(existingUser));
+ given(userRepository.findByUsername(newUsername)).willReturn(Optional.empty());
+ given(userRepository.findByEmail(newEmail)).willReturn(Optional.empty());
+ given(userRepository.save(any(User.class))).willReturn(updatedUser);
+ given(userMapper.toDto(any(User.class))).willReturn(expectedResponse);
+
+ // when
+ UserResponseDto result = userService.update(userId, updateRequest, null);
+
+ // then
+ assertNotNull(result);
+ assertEquals(expectedResponse, result);
+ verify(userRepository).save(any(User.class));
+ verify(binaryContentRepository).deleteById(oldProfileId); // 기존 프로필 사진 정보 삭제
+ }
+
+ @Test
+ @DisplayName("이미 존재하는 username으로 변경을 시도하면 DuplicateNameException이 발생해야한다.")
+ void givenDuplicateUsername_whenUpdateUser_thenThrowDuplicateNameException() {
+
+ // given
+ UUID userId = UUID.randomUUID();
+ String existName = "existName";
+
+ User targetUser = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ User existingUser = User.builder()
+ .username("existName")
+ .email("exist@test.com")
+ .password("pwd12")
+ .build();
+
+ ReflectionTestUtils.setField(targetUser, "id", userId);
+ ReflectionTestUtils.setField(existingUser, "id", UUID.randomUUID());
+
+ UserUpdateDto updateRequest = new UserUpdateDto(existName, "test@test.com", "pwd1234");
+
+ given(userRepository.findById(userId)).willReturn(Optional.of(targetUser));
+ given(userRepository.findByUsername(existName)).willReturn(Optional.of(existingUser));
+
+ // when
+ Throwable thrown = catchThrowable(() -> userService.update(userId, updateRequest, null));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(DuplicateNameException.class)
+ .hasMessageContaining("존재");
+ verify(userRepository).findById(userId);
+ verify(userRepository).findByUsername(existName);
+ verify(userRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("이미 존재하는 email로 변경을 시도하면 DuplicateEmailException이 발생해야한다.")
+ void givenDuplicateEmail_whenUpdateUser_thenThrowDuplicateEmailException() {
+
+ // given
+ UUID userId = UUID.randomUUID();
+ String existEmail = "exist@exist.com";
+
+ User targetUser = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .build();
+
+ User existingUser = User.builder()
+ .username("existName")
+ .email("exist@exist.com")
+ .password("pwd12")
+ .build();
+
+ ReflectionTestUtils.setField(targetUser, "id", userId);
+ ReflectionTestUtils.setField(existingUser, "id", UUID.randomUUID());
+
+ UserUpdateDto updateRequest = new UserUpdateDto("test", existEmail,
+ "pwd1234");
+
+ given(userRepository.findById(userId)).willReturn(Optional.of(targetUser));
+ given(userRepository.findByEmail(existEmail)).willReturn(Optional.of(existingUser));
+
+ // when
+ Throwable thrown = catchThrowable(() -> userService.update(userId, updateRequest, null));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(DuplicateEmailException.class)
+ .hasMessageContaining("존재");
+ verify(userRepository).findById(userId);
+ verify(userRepository).findByEmail(existEmail);
+ verify(userRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("등록된 사용자 ID로 삭제하면 관련 정보가 모두 삭제되어야 한다.")
+ void givenRegisteredUserId_whenDeleteUser_thenDeleteUserAndRelatedInfo() {
+
+ // given
+ UUID userId = UUID.randomUUID();
+ BinaryContent profileImage = new BinaryContent("profile.jpg", 3L,
+ "image/jpeg");
+ ReflectionTestUtils.setField(profileImage, "id", UUID.randomUUID());
+
+ User user = User.builder()
+ .username("test")
+ .email("test@test.com")
+ .password("pwd1234")
+ .profile(profileImage)
+ .build();
+
+ ReflectionTestUtils.setField(user, "id", userId);
+
+ given(userRepository.findById(userId)).willReturn(Optional.of(user));
+
+ // when
+ userService.deleteById(userId);
+
+ // then
+ verify(userRepository).deleteById(userId);
+ verify(userStatusRepository).deleteByUserId(userId);
+ verify(binaryContentRepository).deleteById(user.getProfile().getId());
+ }
+
+ @Test
+ @DisplayName("등록되지 않은 사용자 ID로 삭제하면 NotFoundUserException이 발생해야 한다.")
+ void givenNonexistentUserId_whenDeleteUser_thenThrowNotFoundUserException() {
+
+ // given
+ UUID notExistId = UUID.randomUUID();
+ given(userRepository.findById(notExistId)).willReturn(Optional.empty());
+
+ // when
+ Throwable thrown = catchThrowable(() -> userService.deleteById(notExistId));
+
+ // then
+ assertThat(thrown)
+ .isInstanceOf(NotFoundUserException.class)
+ .hasMessageContaining("사용자");
+ verify(userRepository).findById(notExistId);
+ verifyNoMoreInteractions(userStatusRepository, binaryContentRepository);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java
new file mode 100644
index 000000000..68d761529
--- /dev/null
+++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java
@@ -0,0 +1,76 @@
+package com.sprint.mission.discodeit.storage.s3;
+
+import com.sprint.mission.discodeit.dto.binarycontent.BinaryContentResponseDto;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.core.io.UrlResource;
+import org.springframework.http.ResponseEntity;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.BDDMockito.willReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+@DisplayName("S3BinaryContentStorageTest")
+class S3BinaryContentStorageTest {
+
+ private S3BinaryContentStorage binaryContentStorage;
+
+ @BeforeEach
+ void setUp() {
+ binaryContentStorage = spy(new S3BinaryContentStorage("abcdefghi", "secretkey5oslr",
+ "asia", "test-bucket"));
+ }
+
+ @Test
+ @DisplayName("S3 업로드 시 S3Client.putObject를 호출하는지 테스트")
+ void binaryContent_upload_test() {
+
+ // given
+ UUID id = UUID.randomUUID();
+ byte[] bytes = "hello".getBytes();
+
+ S3Client s3Client = mock(S3Client.class);
+ willReturn(s3Client).given(binaryContentStorage).getS3Client();
+
+ // when
+ binaryContentStorage.put(id, bytes);
+
+ // then
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PutObjectRequest.class);
+ then(s3Client).should().putObject(captor.capture(), any(RequestBody.class));
+ assertEquals("test-bucket", captor.getValue().bucket());
+ assertEquals(id.toString(), captor.getValue().key());
+ }
+
+ @Test
+ @DisplayName("S3 다운로드 테스트")
+ void binaryContent_download_test() throws Exception {
+
+ // given
+ UUID id = UUID.randomUUID();
+ String expectedUrl = "https://dummy-url.com/" + id;
+ willReturn(expectedUrl).given(binaryContentStorage).generatePresignedUrl(any(),
+ any());
+
+ BinaryContentResponseDto binaryContentResponseDto = new BinaryContentResponseDto(
+ id, "test.jpg", 3L, "image/png"
+ );
+
+ // when
+ ResponseEntity result = binaryContentStorage.download(binaryContentResponseDto);
+
+ // then
+ assertEquals(302, result.getStatusCode().value());
+ assertEquals(expectedUrl, result.getHeaders().getLocation().toString());
+ }
+}
\ No newline at end of file