diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md
new file mode 100644
index 00000000..9f83fb85
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue-template.md
@@ -0,0 +1,20 @@
+---
+name: Issue Template
+about: 이슈 템플릿입니다.
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**📌 상세 설명**
+
+[comment]: <> (이슈에 대한 설명을 적어주세요)
+
+**📝 체크리스트**
+
+[comment]: <> (해야 할 일들을 상세히 나눠 적어주시면 좋아요)
+
+- [ ]
+
+- [ ]
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..971ff5e1
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,8 @@
+## ✨ Related Issue
+- close #이슈번호
+
+
+## 📝 기능 구현 명세
+- 이곳에는 postman 테스트 결과를 넣어주세요
+
+## 🐥 추가적인 언급 사항
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
new file mode 100644
index 00000000..c1a02871
--- /dev/null
+++ b/.github/workflows/CI.yml
@@ -0,0 +1,40 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [ "develop" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: checkout
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: '17'
+
+ - name: create application-secret.yml
+ run: |
+ # create application-secret.yml
+ cd ./src/main/resources
+
+ # application-secret.yml 파일 생성
+ touch ./application-secret.yml
+
+ # GitHub-Actions 에서 설정한 값을 application-secret.yml 파일에 쓰기..git
+ echo "${{ secrets.CI_APPLICATION_SECRET }}" >> ./application-secret.yml
+
+ # application.yml 파일 확인
+ cat ./application-secret.yml
+ shell: bash
+
+ - name: build
+ run: |
+ chmod +x gradlew
+ ./gradlew build -x test
+ shell: bash
diff --git a/.github/workflows/DOCKER-CD.yml b/.github/workflows/DOCKER-CD.yml
new file mode 100644
index 00000000..9678e1f5
--- /dev/null
+++ b/.github/workflows/DOCKER-CD.yml
@@ -0,0 +1,69 @@
+name: DOCKER-CD
+on:
+ push:
+ branches: [ "develop" ]
+
+jobs:
+ ci:
+ runs-on: ubuntu-22.04
+ env:
+ working-directory: .
+
+ steps:
+ - name: 체크아웃
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: '17'
+
+ - name: application-secret.yml 생성
+ run: |
+ cd ./src/main/resources
+ touch ./application-secret.yml
+ echo "${{ secrets.CD_APPLICATION_SECRET}}" > ./application-secret.yml
+ cat ./application-secret.yml
+ cat ./application-dev.yml
+ working-directory: ${{ env.working-directory }}
+
+ - name: 빌드
+ run: |
+ chmod +x gradlew
+ ./gradlew build -x test
+ working-directory: ${{ env.working-directory }}
+ shell: bash
+
+
+ - name: docker build 환경 설정
+ uses: docker/setup-buildx-action@v2.9.1
+
+ - name: docker hub 로그인
+ uses: docker/login-action@v2.2.0
+ with:
+ username: ${{ secrets.DOCKER_LOGIN_USERNAME }}
+ password: ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN }}
+
+ - name: docker image 빌드 및 푸시
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./Dockerfile-Dev
+ push: true
+ tags: ${{ secrets.DOCKER_LOGIN_USERNAME }}/dev
+
+ cd:
+ needs: ci
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: docker 컨테이너 실행
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.SERVER_IP }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SERVER_KEY }}
+ script: |
+ cd ~
+ ./deploy.sh
diff --git a/.github/workflows/PROD-CD.yml b/.github/workflows/PROD-CD.yml
new file mode 100644
index 00000000..7db63e9c
--- /dev/null
+++ b/.github/workflows/PROD-CD.yml
@@ -0,0 +1,68 @@
+name: PROD-CD
+on:
+ push:
+ branches: [ "main" ]
+
+jobs:
+ ci:
+ runs-on: ubuntu-22.04
+ env:
+ working-directory: .
+
+ steps:
+ - name: 체크아웃
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: '17'
+
+ - name: application-secret.yml 생성
+ run: |
+ cd ./src/main/resources
+ touch ./application-secret.yml
+ echo "${{ secrets.CD_APPLICATION_SECRET}}" > ./application-secret.yml
+ cat ./application-secret.yml
+ cat ./application-dev.yml
+ working-directory: ${{ env.working-directory }}
+
+ - name: 빌드
+ run: |
+ chmod +x gradlew
+ ./gradlew build -x test
+ working-directory: ${{ env.working-directory }}
+ shell: bash
+
+ - name: docker build 환경 설정
+ uses: docker/setup-buildx-action@v2.9.1
+
+ - name: docker hub 로그인
+ uses: docker/login-action@v2.2.0
+ with:
+ username: ${{ secrets.DOCKER_LOGIN_USERNAME_PROD }}
+ password: ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN_PROD }}
+
+ - name: docker image 빌드 및 푸시
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./Dockerfile-Prod
+ push: true
+ tags: ${{ secrets.DOCKER_LOGIN_USERNAME_PROD }}/prod
+
+ cd:
+ needs: ci
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: docker 컨테이너 실행
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.SERVER_IP_PROD }}
+ username: ${{ secrets.SERVER_USER_PROD }}
+ key: ${{ secrets.SERVER_KEY_PROD }}
+ script: |
+ cd ~
+ ./deploy.sh
diff --git a/.github/workflows/PROD-CI.yml b/.github/workflows/PROD-CI.yml
new file mode 100644
index 00000000..63a44225
--- /dev/null
+++ b/.github/workflows/PROD-CI.yml
@@ -0,0 +1,40 @@
+name: PROD-CI
+
+on:
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-22.04
+
+ steps:
+ - name: checkout
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: '17'
+
+ - name: create application-secret.yml
+ run: |
+ # create application-secret.yml
+ cd ./src/main/resources
+
+ # application-secret.yml 파일 생성
+ touch ./application-secret.yml
+
+ # GitHub-Actions 에서 설정한 값을 application-secret.yml 파일에 쓰기..git
+ echo "${{ secrets.CI_APPLICATION_SECRET }}" >> ./application-secret.yml
+
+ # application.yml 파일 확인
+ cat ./application-secret.yml
+ shell: bash
+
+ - name: build
+ run: |
+ chmod +x gradlew
+ ./gradlew build -x test
+ shell: bash
diff --git a/.gitignore b/.gitignore
index 1fc1e4d1..416d9087 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ out/
.vscode/
application-secret.yml
+application-local.yml
diff --git a/Dockerfile-Dev b/Dockerfile-Dev
new file mode 100644
index 00000000..0fcbb90e
--- /dev/null
+++ b/Dockerfile-Dev
@@ -0,0 +1,4 @@
+FROM amd64/amazoncorretto:17
+WORKDIR /app
+COPY ./build/libs/Tiki-server-0.0.1-SNAPSHOT.jar /app/Tiki.jar
+CMD ["java", "-Duser.timezone=Asia/Seoul" ,"-jar", "-Dspring.profiles.active=dev","Tiki.jar"]
\ No newline at end of file
diff --git a/Dockerfile-Prod b/Dockerfile-Prod
new file mode 100644
index 00000000..72de4f07
--- /dev/null
+++ b/Dockerfile-Prod
@@ -0,0 +1,4 @@
+FROM amd64/amazoncorretto:17
+WORKDIR /app
+COPY ./build/libs/Tiki-server-0.0.1-SNAPSHOT.jar /app/Tiki.jar
+CMD ["java", "-Duser.timezone=Asia/Seoul" ,"-jar", "-Dspring.profiles.active=prod","Tiki.jar"]
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..70696fdf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,127 @@
+### 🏢 Architecture
+
+
+
+### 📖 Directory
+```
+📁 TIKI_SERVER
+├── .github
+├── .gradle
+├── .idea
+├── build
+├── gradle
+├── src.main
+│ ├──java.com.tiki.server
+│ ├── auth
+│ ├── common
+│ ├── document
+│ ├── member
+│ ├── controller
+│ ├── dto
+│ ├── entity
+│ ├── message
+│ ├── repository
+│ ├── service
+│ ├── memberteammanager
+│ ├── team
+│ ├── timeblock
+```
+
+### ✉️ Commit Messge Rules
+
+**서버** 들의 **Git Commit Message Rules**
+
+- 반영사항을 바로 확인할 수 있도록 작은 기능 하나라도 구현되면 커밋을 권장합니다.
+- 기능 구현이 완벽하지 않을 땐, 각자 브랜치에 커밋을 해주세요.
+
+### 📌 Commit Convention
+
+**[태그] 제목의 형태**
+
+| 태그 이름 | 설명 |
+| :-------: | :-----------------------------------------------: |
+| FEAT | 새로운 기능을 추가할 경우 |
+| FIX | 버그를 고친 경우 |
+| CHORE | 짜잘한 수정 |
+| DOCS | 문서 수정 |
+| INIT | 초기 설정 |
+| TEST | 테스트 코드, 리펙토링 테스트 코드 추가 |
+| RENAME | 파일 혹은 폴더명을 수정하거나 옮기는 작업인 경우 |
+| STYLE | 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 |
+| REFACTOR | 코드 리팩토링 |
+
+### **커밋 타입**
+
+- `[태그] 설명` 형식으로 커밋 메시지를 작성합니다.
+- 태그는 영어를 쓰고 대문자로 작성합니다.
+
+예시 >
+
+```
+ [FEAT] 검색 api 추가
+```
+
+### **💻 Github mangement**
+
+**티키** 들의 WorkFlow : **Gitflow Workflow**
+
+- Develop, Feature, Hotfix 브랜치
+
+- 개발(develop): 기능들의 통합 브랜치
+
+- 기능 단위 개발(feature): 기능 단위 브랜치
+
+- 버그 수정 및 갑작스런 수정(hotfix): 수정 사항 발생 시 브랜치
+
+- 개발 브랜치 아래 기능별 브랜치를 만들어 작성합니다.
+
+### ✍🏻 Code Convention
+
+[에어비앤비 코드 컨벤션](https://github.com/airbnb/javascript)
+
+### 📍 Gitflow 규칙
+
+- Develop에 직접적인 commit, push는 금지합니다.
+- 커밋 메세지는 다른 사람들이 봐도 이해할 수 있게 써주세요.
+- 작업 이전에 issue 작성 후 pullrequest 와 issue를 연동해 주세요.
+- 풀리퀘스트를 통해 코드 리뷰를 전원이 코드리뷰를 진행합니다.
+- 기능 개발 시 개발 브랜치에서 feature/기능 으로 브랜치를 파서 관리합니다.
+- feature 자세한 기능 한 가지를 담당하며, 기능 개발이 완료되면 각자의 브랜치로 Pull Request를 보냅니다.
+- 각자가 기간 동안 맡은 역할을 전부 수행하면, 각자 브랜치에서 develop브랜치로 Pull Request를 보냅니다.
+ **develop 브랜치로의 Pull Request는 상대방의 코드리뷰 후에 merge할 수 있습니다.**
+
+### ❗️ branch naming convention
+
+- develop
+- feature/issue_number-도메인-http Method-api
+- fix/issue_number-도메인-http Method-api
+- release/version_number
+- hotfix/issue_number - Short Description
+
+예시 >
+
+```
+ feature/#3-user-post-api
+```
+
+### 📋 Code Review Convention
+
+- P1: 꼭 반영해주세요 (Request changes)
+- P2: 적극적으로 고려해주세요 (Request changes)
+- P3: 웬만하면 반영해 주세요 (Comment)
+- P4: 반영해도 좋고 넘어가도 좋습니다 (Approve)
+- P5: 그냥 사소한 의견입니다 (Approve)
+
+### 🚀 Test Code Convention
+
+1. given, when, then을 사용한다.
+2. 테스트 메서드명은 다음과 같이 작성한다. -> 메서드명_테스트하고자하는상태_예상되는결과 (ex. giveCotton_CottonCountIs0_NotEnoughCotton)
+3. 설마 이런 거까지 생각해야하나싶은 거까지 작성한다. (ex. 솜뭉치를 여러 개 줄 수 있다.)
+4. 다수의 값을 다룰 때는 @ParameterizedTest를 활용한다.
+
+### 👩👧👧 Our Team
+
+| **🍀 [남궁찬](https://github.com/Chan531)** |**🍀 [신민규](https://github.com/paragon0107)** |
+ |:-----------------------------------:|:-----------------------------------:|
+| Server Developer | Server Developer |
+| 프로젝트 세팅
| 프로젝트 세팅
|
diff --git a/build.gradle b/build.gradle
index ad2653f2..fb35b3de 100644
--- a/build.gradle
+++ b/build.gradle
@@ -24,15 +24,49 @@ repositories {
}
dependencies {
+
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ //jwt
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+
+ // slack logback
+ implementation 'com.github.maricn:logback-slack-appender:1.4.0'
+
+ // s3
+ implementation("software.amazon.awssdk:bom:2.21.0")
+ implementation("software.amazon.awssdk:s3:2.21.0")
+
+ // s3
+ implementation("software.amazon.awssdk:bom:2.21.0")
+ implementation("software.amazon.awssdk:s3:2.21.0")
+
+ // swagger
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
+
+ // mail
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+
+ //Redis
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'io.lettuce:lettuce-core:6.2.1.RELEASE'
+
+ //thymeleaf
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+
+ //Validation
+ implementation 'commons-validator:commons-validator:1.7'
}
tasks.named('test') {
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..da20a6d5
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,13 @@
+services:
+ redis:
+ container_name: redis
+ image: redis:alpine
+ hostname: redis
+ ports:
+ - "6379:6379"
+ networks:
+ - tiki-network
+
+networks:
+ tiki-network:
+ external: true
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/TikiServerApplication.java b/src/main/java/com/tiki/server/TikiServerApplication.java
index 7dae9dea..eddec38e 100644
--- a/src/main/java/com/tiki/server/TikiServerApplication.java
+++ b/src/main/java/com/tiki/server/TikiServerApplication.java
@@ -2,12 +2,14 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+@EnableScheduling
@SpringBootApplication
public class TikiServerApplication {
- public static void main(String[] args) {
- SpringApplication.run(TikiServerApplication.class, args);
- }
+ public static void main(String[] args) {
+ SpringApplication.run(TikiServerApplication.class, args);
+ }
}
diff --git a/src/main/java/com/tiki/server/auth/config/EncoderConfig.java b/src/main/java/com/tiki/server/auth/config/EncoderConfig.java
new file mode 100644
index 00000000..5a70b43d
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/config/EncoderConfig.java
@@ -0,0 +1,15 @@
+package com.tiki.server.auth.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+@Configuration
+public class EncoderConfig {
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/config/SecurityConfig.java b/src/main/java/com/tiki/server/auth/config/SecurityConfig.java
new file mode 100644
index 00000000..c858e05a
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/config/SecurityConfig.java
@@ -0,0 +1,89 @@
+package com.tiki.server.auth.config;
+
+import com.tiki.server.auth.exception.handler.CustomAuthenticationEntryPointHandler;
+import com.tiki.server.auth.filter.ExceptionHandlerFilter;
+import com.tiki.server.auth.filter.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private static final String[] AUTH_WHITE_LIST = {
+ "/api/v1/auth/sign-in",
+ "/api/v1/auth/reissue",
+ "/api/v1/members/password",
+ "/api/v1/members",
+ "/api/v1/email/verification/**",
+ "/actuator/health"
+ };
+
+ private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler;
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+ private final ExceptionHandlerFilter exceptionHandlerFilter;
+
+ @Bean
+ @Profile("local")
+ public SecurityFilterChain filterChainLocal(HttpSecurity http) throws Exception {
+ permitSwaggerUri(http);
+ setHttp(http);
+ return http.build();
+ }
+
+ @Bean
+ @Profile("dev")
+ public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception {
+ permitSwaggerUri(http);
+ setHttp(http);
+ return http.build();
+ }
+
+ @Bean
+ @Profile("prod")
+ public SecurityFilterChain filterChainProd(HttpSecurity http) throws Exception {
+ setHttp(http);
+ return http.build();
+ }
+
+ private void setHttp(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .sessionManagement(sessionManagementConfigurer ->
+ sessionManagementConfigurer
+ .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .exceptionHandling(exceptionHandlingConfigurer ->
+ exceptionHandlingConfigurer
+ .authenticationEntryPoint(customAuthenticationEntryPointHandler))
+ .authorizeHttpRequests(request ->
+ request
+ .requestMatchers(AUTH_WHITE_LIST).permitAll()
+ .anyRequest()
+ .authenticated())
+ .addFilterBefore(
+ jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class
+ )
+ .addFilterBefore(
+ exceptionHandlerFilter, JwtAuthenticationFilter.class
+ );
+ }
+
+ private void permitSwaggerUri(HttpSecurity http) throws Exception {
+ http.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
+ .requestMatchers(new AntPathRequestMatcher("/v3/api-docs/**")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/swagger-ui/**")).permitAll()
+ .requestMatchers(new AntPathRequestMatcher("/docs/**")).permitAll());
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/controller/AuthController.java b/src/main/java/com/tiki/server/auth/controller/AuthController.java
new file mode 100644
index 00000000..2175d28e
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/controller/AuthController.java
@@ -0,0 +1,46 @@
+package com.tiki.server.auth.controller;
+
+import com.tiki.server.auth.controller.docs.AuthControllerDocs;
+import com.tiki.server.auth.dto.request.SignInRequest;
+import com.tiki.server.auth.dto.response.ReissueGetResponse;
+import com.tiki.server.auth.dto.response.SignInGetResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.tiki.server.auth.service.AuthService;
+
+import lombok.RequiredArgsConstructor;
+
+import static com.tiki.server.auth.message.SuccessMessage.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1/auth")
+public class AuthController implements AuthControllerDocs {
+
+ private final AuthService authService;
+
+ @Override
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/sign-in")
+ public SuccessResponse signIn(@RequestBody final SignInRequest request) {
+ SignInGetResponse response = authService.signIn(request);
+ return SuccessResponse.success(SUCCESS_SIGN_IN.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.CREATED)
+ @GetMapping("/reissue")
+ public SuccessResponse reissue(final HttpServletRequest httpServletRequest) {
+ ReissueGetResponse response = authService.reissueToken(httpServletRequest);
+ return SuccessResponse.success(SUCCESS_REISSUE_ACCESS_TOKEN.getMessage(), response);
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/tiki/server/auth/controller/docs/AuthControllerDocs.java
new file mode 100644
index 00000000..a92693c1
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/controller/docs/AuthControllerDocs.java
@@ -0,0 +1,68 @@
+package com.tiki.server.auth.controller.docs;
+
+import com.tiki.server.auth.dto.request.SignInRequest;
+import com.tiki.server.auth.dto.response.SignInGetResponse;
+import com.tiki.server.auth.dto.response.ReissueGetResponse;
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.web.bind.annotation.RequestBody;
+
+@Tag(name = "auth", description = "인증 API")
+public interface AuthControllerDocs {
+
+ @Operation(
+ summary = "로그인",
+ description = "로그인을 진행한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "일치하지 않은 비밀번호",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "유효하지 않은 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse signIn(@RequestBody final SignInRequest request);
+
+ @Operation(
+ summary = "엑세스 토큰 재발급",
+ description = "엑세스 토큰 재발급 메서드입니다.",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "성공"),
+ @ApiResponse(
+ responseCode = "401",
+ description = "유효하지 않은 키, 인증되지 않은 사용자",
+ content = @Content(schema = @Schema(implementation = SuccessResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "유효하지 않은 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse reissue(final HttpServletRequest request);
+}
diff --git a/src/main/java/com/tiki/server/auth/dto/request/SignInRequest.java b/src/main/java/com/tiki/server/auth/dto/request/SignInRequest.java
new file mode 100644
index 00000000..1a72920e
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/dto/request/SignInRequest.java
@@ -0,0 +1,9 @@
+package com.tiki.server.auth.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record SignInRequest(
+ @NotNull String email,
+ @NotNull String password
+) {
+}
diff --git a/src/main/java/com/tiki/server/auth/dto/response/ReissueGetResponse.java b/src/main/java/com/tiki/server/auth/dto/response/ReissueGetResponse.java
new file mode 100644
index 00000000..8971daf2
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/dto/response/ReissueGetResponse.java
@@ -0,0 +1,16 @@
+package com.tiki.server.auth.dto.response;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+import static lombok.AccessLevel.PRIVATE;
+
+@Builder(access = PRIVATE)
+public record ReissueGetResponse(
+ @NotNull String accessToken
+) {
+
+ public static ReissueGetResponse from(final String accessToken) {
+ return ReissueGetResponse.builder().accessToken(accessToken).build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/dto/response/SignInGetResponse.java b/src/main/java/com/tiki/server/auth/dto/response/SignInGetResponse.java
new file mode 100644
index 00000000..7c47539b
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/dto/response/SignInGetResponse.java
@@ -0,0 +1,17 @@
+package com.tiki.server.auth.dto.response;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+import static lombok.AccessLevel.PRIVATE;
+
+@Builder(access = PRIVATE)
+public record SignInGetResponse(
+ @NotNull String accessToken,
+ @NotNull String refreshToken
+) {
+
+ public static SignInGetResponse from(final String accessToken, final String refreshToken) {
+ return SignInGetResponse.builder().accessToken(accessToken).refreshToken(refreshToken).build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/exception/AuthException.java b/src/main/java/com/tiki/server/auth/exception/AuthException.java
new file mode 100644
index 00000000..5e608465
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/exception/AuthException.java
@@ -0,0 +1,16 @@
+package com.tiki.server.auth.exception;
+
+import com.tiki.server.auth.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class AuthException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public AuthException(final ErrorCode errorCode) {
+ super("[AuthException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java b/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java
new file mode 100644
index 00000000..87e632ba
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/exception/handler/CustomAuthenticationEntryPointHandler.java
@@ -0,0 +1,45 @@
+package com.tiki.server.auth.exception.handler;
+
+import static com.tiki.server.auth.message.ErrorCode.*;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.tiki.server.common.dto.ErrorCodeResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
+
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public void commence(
+ final HttpServletRequest request,
+ final HttpServletResponse response,
+ final AuthenticationException authException
+ ) throws IOException {
+ log.info("[AuthenticationEntryPoint] " + authException.getMessage());
+ setResponse(response);
+ }
+
+ private void setResponse(final HttpServletResponse response) throws IOException {
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("UTF-8");
+ response.setStatus(HttpStatus.UNAUTHORIZED.value());
+ PrintWriter writer = response.getWriter();
+ writer.write(objectMapper.writeValueAsString(
+ ErrorCodeResponse.of(UNAUTHENTICATED.getCode(), UNAUTHENTICATED.getMessage())));
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java b/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java
new file mode 100644
index 00000000..4465e9aa
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/filter/ExceptionHandlerFilter.java
@@ -0,0 +1,54 @@
+package com.tiki.server.auth.filter;
+
+import static com.tiki.server.auth.message.ErrorCode.*;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.tiki.server.auth.exception.AuthException;
+import com.tiki.server.auth.message.ErrorCode;
+import com.tiki.server.common.dto.ErrorCodeResponse;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExceptionHandlerFilter extends OncePerRequestFilter {
+
+ private final ObjectMapper objectMapper;
+
+ @Override
+ protected void doFilterInternal(
+ @NonNull final HttpServletRequest request,
+ @NonNull final HttpServletResponse response,
+ @NonNull final FilterChain filterChain
+ ) throws IOException {
+ try {
+ filterChain.doFilter(request, response);
+ } catch (AuthException e) {
+ log.info("[ExceptionHandlerFilter] - AuthException : " + e);
+ setResponse(response, e.getErrorCode());
+ } catch (Exception e) {
+ log.info("[ExceptionHandlerFilter] - UncaughtException : " + e);
+ setResponse(response, UNCAUGHT_EXCEPTION);
+ }
+ }
+
+ private void setResponse(final HttpServletResponse response, final ErrorCode errorCode)
+ throws IOException {
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("UTF-8");
+ response.setStatus(errorCode.getHttpStatus().value());
+ PrintWriter writer = response.getWriter();
+ writer.write(objectMapper.writeValueAsString(ErrorCodeResponse.of(errorCode.getCode(), errorCode.getMessage())));
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java
new file mode 100644
index 00000000..59c797b6
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/filter/JwtAuthenticationFilter.java
@@ -0,0 +1,48 @@
+package com.tiki.server.auth.filter;
+
+import com.tiki.server.auth.jwt.JwtProvider;
+import com.tiki.server.auth.jwt.JwtValidator;
+import com.tiki.server.auth.jwt.UserAuthentication;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtProvider jwtProvider;
+ private final JwtValidator jwtValidator;
+
+ @Override
+ protected void doFilterInternal(
+ @NonNull final HttpServletRequest request,
+ @NonNull final HttpServletResponse response,
+ @NonNull final FilterChain filterChain
+ ) throws IOException, ServletException {
+ String token = jwtProvider.getTokenFromRequest(request);
+ if (StringUtils.hasText(token)) {
+ jwtValidator.validateToken(token);
+ setAuthenticationContextHolder(jwtProvider.getUserFromJwt((token)), request);
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ private void setAuthenticationContextHolder(final long memberId, final HttpServletRequest request) {
+ UserAuthentication authentication = new UserAuthentication(memberId, null, null);
+ authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java b/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java
new file mode 100644
index 00000000..6551c75d
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/jwt/JwtGenerator.java
@@ -0,0 +1,60 @@
+package com.tiki.server.auth.jwt;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+
+import static io.jsonwebtoken.Header.JWT_TYPE;
+import static io.jsonwebtoken.Header.TYPE;
+import static io.jsonwebtoken.security.Keys.hmacShaKeyFor;
+import static java.util.Base64.getEncoder;
+
+import java.util.Date;
+
+@RequiredArgsConstructor
+@Component
+public class JwtGenerator {
+
+ @Value("${jwt.secret}")
+ private String secretKey;
+
+ @Value("${jwt.access-token-expire-time}")
+ private long ACCESS_TOKEN_EXPIRE_TIME;
+
+ @Value("${jwt.refresh-token-expire-time}")
+ public long REFRESH_TOKEN_EXPIRE_TIME;
+
+ public String generateToken(final Authentication authentication, final long expiration) {
+ return Jwts.builder()
+ .setHeaderParam(TYPE, JWT_TYPE)
+ .setClaims(generateClaims(authentication))
+ .setIssuedAt(new Date(System.currentTimeMillis()))
+ .setExpiration(new Date(System.currentTimeMillis() + expiration))
+ .signWith(getSigningKey())
+ .compact();
+ }
+
+ public String generateAccessToken(final Authentication authentication) {
+ return generateToken(authentication, ACCESS_TOKEN_EXPIRE_TIME);
+ }
+
+ public String generateRefreshToken(final Authentication authentication) {
+ return generateToken(authentication, REFRESH_TOKEN_EXPIRE_TIME);
+ }
+
+ private Claims generateClaims(final Authentication authentication) {
+ Claims claims = Jwts.claims();
+ claims.put("memberId", authentication.getPrincipal());
+ return claims;
+ }
+
+ private SecretKey getSigningKey() {
+ String encodedKey = getEncoder().encodeToString(secretKey.getBytes());
+ return hmacShaKeyFor(encodedKey.getBytes());
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java b/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java
new file mode 100644
index 00000000..146bbc83
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/jwt/JwtProvider.java
@@ -0,0 +1,49 @@
+package com.tiki.server.auth.jwt;
+
+import com.tiki.server.common.constants.Constants;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import javax.crypto.SecretKey;
+
+import static io.jsonwebtoken.security.Keys.hmacShaKeyFor;
+import static java.util.Base64.getEncoder;
+
+@RequiredArgsConstructor
+@Component
+public class JwtProvider {
+
+ @Value("${jwt.secret}")
+ private String secretKey;
+
+ public String getTokenFromRequest(final HttpServletRequest request) {
+ String accessToken = request.getHeader(Constants.AUTHORIZATION);
+ if (!StringUtils.hasText(accessToken) || !accessToken.startsWith(Constants.BEARER)) {
+ return null;
+ }
+ return accessToken.substring(Constants.BEARER.length());
+ }
+
+ public long getUserFromJwt(final String token) {
+ Claims claims = getBodyFromJwt(token);
+ return Long.parseLong(claims.get("memberId").toString());
+ }
+
+ public Claims getBodyFromJwt(final String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(getSigningKey())
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+
+ private SecretKey getSigningKey() {
+ String encodedKey = getEncoder().encodeToString(secretKey.getBytes());
+ return hmacShaKeyFor(encodedKey.getBytes());
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java b/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java
new file mode 100644
index 00000000..0f0ceeff
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/jwt/JwtValidator.java
@@ -0,0 +1,33 @@
+package com.tiki.server.auth.jwt;
+
+import com.tiki.server.auth.exception.AuthException;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.JwtException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import static com.tiki.server.auth.message.ErrorCode.*;
+
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class JwtValidator {
+
+ private final JwtProvider jwtProvider;
+
+ public void validateToken(final String token) {
+ try {
+ jwtProvider.getBodyFromJwt(token);
+ } catch (ExpiredJwtException exception) {
+ log.info(exception.getMessage());
+ throw new AuthException(EXPIRED_JWT_TOKEN);
+ } catch (JwtException exception) {
+ log.info(exception.getMessage());
+ throw new AuthException(INVALID_JWT_TOKEN);
+ } catch (Exception exception) {
+ log.info("예상치 못한 에러: " + exception);
+ throw new AuthException(UNCAUGHT_EXCEPTION);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java b/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java
new file mode 100644
index 00000000..5161d124
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/jwt/UserAuthentication.java
@@ -0,0 +1,16 @@
+package com.tiki.server.auth.jwt;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+
+public class UserAuthentication extends UsernamePasswordAuthenticationToken {
+ public UserAuthentication(
+ final Object principal,
+ final Object credentials,
+ final Collection extends GrantedAuthority> authorities
+ ) {
+ super(principal, credentials, authorities);
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/message/ErrorCode.java b/src/main/java/com/tiki/server/auth/message/ErrorCode.java
new file mode 100644
index 00000000..02a99f11
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/message/ErrorCode.java
@@ -0,0 +1,33 @@
+package com.tiki.server.auth.message;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import static org.springframework.http.HttpStatus.*;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ /* 400 BAD REQUEST : 잘못된 요청 */
+ UNCAUGHT_EXCEPTION(BAD_REQUEST, 40001, "예상치 못한 오류가 발생했습니다."),
+
+ /* 401 UNAUTHORIZED : 인증 없음 */
+ UNAUTHENTICATED(UNAUTHORIZED, 40101, "인증 과정 중 오류가 발생했습니다"),
+ UNMATCHED_TOKEN(UNAUTHORIZED, 40102, "토큰이 일치하지 않습니다."),
+ INVALID_JWT_TOKEN(UNAUTHORIZED, 40103, "잘못된 토큰 형식입니다."),
+ EXPIRED_JWT_TOKEN(UNAUTHORIZED, 40104, "만료된 토큰입니다."),
+ EMPTY_JWT(UNAUTHORIZED, 40105, "빈 토큰입니다."),
+
+ /* 403 FORBIDDEN : 권한 없음 */
+ UNAUTHORIZED_USER(FORBIDDEN, 40301, "권한이 없는 사용자입니다."),
+
+ /* 500 INTERNAL_SERVER_ERROR : 서버 내부 오류 발생 */
+ UNCAUGHT_SERVER_EXCEPTION(INTERNAL_SERVER_ERROR, 500, "서버 내부에서 오류가 발생했습니다.");
+
+ private final HttpStatus httpStatus;
+ private final int code;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/auth/message/SuccessMessage.java b/src/main/java/com/tiki/server/auth/message/SuccessMessage.java
new file mode 100644
index 00000000..99e40192
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/message/SuccessMessage.java
@@ -0,0 +1,15 @@
+package com.tiki.server.auth.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ SUCCESS_SIGN_UP("회원가입 성공"),
+ SUCCESS_SIGN_IN("로그인 성공"),
+ SUCCESS_REISSUE_ACCESS_TOKEN("엑세스 토큰 재발급 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/auth/service/AuthService.java b/src/main/java/com/tiki/server/auth/service/AuthService.java
new file mode 100644
index 00000000..8356da3d
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/service/AuthService.java
@@ -0,0 +1,94 @@
+package com.tiki.server.auth.service;
+
+import com.tiki.server.auth.dto.request.SignInRequest;
+import com.tiki.server.auth.dto.response.SignInGetResponse;
+import com.tiki.server.auth.dto.response.ReissueGetResponse;
+import com.tiki.server.auth.exception.AuthException;
+import com.tiki.server.auth.jwt.JwtProvider;
+import com.tiki.server.auth.token.adapter.TokenFinder;
+import com.tiki.server.auth.token.adapter.TokenSaver;
+import com.tiki.server.auth.token.entity.Token;
+import com.tiki.server.email.Email;
+import com.tiki.server.member.adapter.MemberFinder;
+import com.tiki.server.member.entity.Member;
+import com.tiki.server.member.exception.MemberException;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.tiki.server.auth.jwt.JwtGenerator;
+import com.tiki.server.auth.jwt.UserAuthentication;
+
+import lombok.RequiredArgsConstructor;
+import org.thymeleaf.util.StringUtils;
+
+import static com.tiki.server.auth.message.ErrorCode.EMPTY_JWT;
+import static com.tiki.server.auth.message.ErrorCode.UNMATCHED_TOKEN;
+import static com.tiki.server.member.message.ErrorCode.INVALID_MEMBER;
+import static com.tiki.server.member.message.ErrorCode.UNMATCHED_PASSWORD;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AuthService {
+
+ private final JwtGenerator jwtGenerator;
+ private final JwtProvider jwtProvider;
+ private final MemberFinder memberFinder;
+ private final TokenSaver tokenSaver;
+ private final TokenFinder tokenFinder;
+ private final PasswordEncoder passwordEncoder;
+
+ public SignInGetResponse signIn(final SignInRequest request) {
+ Member member = checkMemberEmpty(request);
+ checkPasswordMatching(member, request.password());
+ Authentication authentication = createAuthentication(member.getId());
+ String accessToken = jwtGenerator.generateAccessToken(authentication);
+ String refreshToken = jwtGenerator.generateRefreshToken(authentication);
+ tokenSaver.save(Token.of(member.getId(), refreshToken));
+ return SignInGetResponse.from(accessToken, refreshToken);
+ }
+
+ public ReissueGetResponse reissueToken(final HttpServletRequest request) {
+ String refreshToken = jwtProvider.getTokenFromRequest(request);
+ checkTokenEmpty(refreshToken);
+ long memberId = jwtProvider.getUserFromJwt(refreshToken);
+ Token token = tokenFinder.findById(memberId);
+ checkRefreshToken(refreshToken, token);
+ Authentication authentication = createAuthentication(memberId);
+ String accessToken = jwtGenerator.generateAccessToken(authentication);
+ return ReissueGetResponse.from(accessToken);
+ }
+
+ private Member checkMemberEmpty(final SignInRequest request) {
+ return memberFinder.findByEmail(Email.from(request.email())).orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ private void checkTokenEmpty(final String token) {
+ if (StringUtils.isEmpty(token)) {
+ throw new AuthException(EMPTY_JWT);
+ }
+ }
+
+ private void checkRefreshToken(final String getRefreshToken, final Token token) {
+ log.info("받은 토큰 : " + getRefreshToken);
+ log.info("저장 토큰 : " + token.refreshToken());
+ if (!token.refreshToken().equals(getRefreshToken)) {
+ throw new AuthException(UNMATCHED_TOKEN);
+ }
+ }
+
+ private void checkPasswordMatching(final Member member, final String password) {
+ if (!passwordEncoder.matches(password, member.getPassword())) {
+ throw new MemberException(UNMATCHED_PASSWORD);
+ }
+ }
+
+ private Authentication createAuthentication(final long memberId) {
+ return new UserAuthentication(memberId, null, null);
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/token/adapter/TokenFinder.java b/src/main/java/com/tiki/server/auth/token/adapter/TokenFinder.java
new file mode 100644
index 00000000..91ffca25
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/token/adapter/TokenFinder.java
@@ -0,0 +1,22 @@
+package com.tiki.server.auth.token.adapter;
+
+import com.tiki.server.auth.exception.AuthException;
+import com.tiki.server.auth.token.entity.Token;
+import com.tiki.server.auth.token.repository.TokenRepository;
+import com.tiki.server.common.support.RepositoryAdapter;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Optional;
+
+import static com.tiki.server.auth.message.ErrorCode.UNAUTHORIZED_USER;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class TokenFinder {
+
+ private final TokenRepository tokenRepository;
+
+ public Token findById(final long id) {
+ return tokenRepository.findById(id).orElseThrow(() -> new AuthException(UNAUTHORIZED_USER));
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/token/adapter/TokenSaver.java b/src/main/java/com/tiki/server/auth/token/adapter/TokenSaver.java
new file mode 100644
index 00000000..c98273d6
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/token/adapter/TokenSaver.java
@@ -0,0 +1,17 @@
+package com.tiki.server.auth.token.adapter;
+
+import com.tiki.server.auth.token.entity.Token;
+import com.tiki.server.auth.token.repository.TokenRepository;
+import com.tiki.server.common.support.RepositoryAdapter;
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class TokenSaver {
+
+ private final TokenRepository tokenRepository;
+
+ public void save(final Token token) {
+ tokenRepository.save(token);
+ }
+}
diff --git a/src/main/java/com/tiki/server/auth/token/constants/TokenConstant.java b/src/main/java/com/tiki/server/auth/token/constants/TokenConstant.java
new file mode 100644
index 00000000..f3d6f2b5
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/token/constants/TokenConstant.java
@@ -0,0 +1,9 @@
+package com.tiki.server.auth.token.constants;
+
+import org.springframework.beans.factory.annotation.Value;
+
+public class TokenConstant {
+
+ @Value("${jwt.refresh-token-expire-time}")
+ public static int REFRESH_TOKEN_EXPIRED_TIME;
+}
diff --git a/src/main/java/com/tiki/server/auth/token/entity/Token.java b/src/main/java/com/tiki/server/auth/token/entity/Token.java
new file mode 100644
index 00000000..0d00bd15
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/token/entity/Token.java
@@ -0,0 +1,22 @@
+package com.tiki.server.auth.token.entity;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+import org.springframework.data.redis.core.RedisHash;
+
+import static lombok.AccessLevel.PRIVATE;
+
+@Builder(access = PRIVATE)
+@RedisHash(value = "refreshToken", timeToLive = 1209600000L)
+public record Token(
+ @NotNull long id,
+ @NotNull String refreshToken
+) {
+ public static Token of(final long id, final String refreshToken) {
+ return Token.builder()
+ .id(id)
+ .refreshToken(refreshToken)
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/auth/token/repository/TokenRepository.java b/src/main/java/com/tiki/server/auth/token/repository/TokenRepository.java
new file mode 100644
index 00000000..048413ff
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/token/repository/TokenRepository.java
@@ -0,0 +1,13 @@
+package com.tiki.server.auth.token.repository;
+
+import com.tiki.server.auth.token.entity.Token;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface TokenRepository extends CrudRepository {
+
+ Optional findById(final long id);
+}
diff --git a/src/main/java/com/tiki/server/auth/utils/CookieUtil.java b/src/main/java/com/tiki/server/auth/utils/CookieUtil.java
new file mode 100644
index 00000000..d0637682
--- /dev/null
+++ b/src/main/java/com/tiki/server/auth/utils/CookieUtil.java
@@ -0,0 +1,22 @@
+package com.tiki.server.auth.utils;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.ResponseCookie;
+
+import static com.tiki.server.auth.token.constants.TokenConstant.REFRESH_TOKEN_EXPIRED_TIME;
+
+public class CookieUtil {
+
+ public static void addRefreshToken(HttpServletResponse response, String value) {
+
+ ResponseCookie cookie = ResponseCookie.from("refreshToken", value)
+ .path("/")
+ .secure(true)
+ .sameSite("None")
+ .httpOnly(true)
+ .maxAge(REFRESH_TOKEN_EXPIRED_TIME)
+ .build();
+ response.setHeader("Set-Cookie", cookie.toString());
+ }
+
+}
diff --git a/src/main/java/com/tiki/server/common/config/CorsConfig.java b/src/main/java/com/tiki/server/common/config/CorsConfig.java
new file mode 100644
index 00000000..4a544436
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/config/CorsConfig.java
@@ -0,0 +1,38 @@
+package com.tiki.server.common.config;
+
+import java.util.List;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+
+@Configuration
+public class CorsConfig {
+
+ @Bean
+ public CorsFilter corsFilter() {
+ UrlBasedCorsConfigurationSource source = setUrlBasedCorsConfigurationSource();
+ return new CorsFilter(source);
+ }
+
+ private UrlBasedCorsConfigurationSource setUrlBasedCorsConfigurationSource() {
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ CorsConfiguration config = setCorsConfiguration();
+ source.registerCorsConfiguration("/**", config);
+ return source;
+ }
+
+ private CorsConfiguration setCorsConfiguration() {
+ CorsConfiguration config = new CorsConfiguration();
+ config.setAllowCredentials(true);
+ config.addAllowedOrigin(("https://ti-kii.com"));
+ config.addAllowedOrigin("http://localhost:5173");
+ config.addAllowedOrigin("https://www.tiki-sopt.p-e.kr");
+ config.addAllowedOrigin("https://tiki-client.vercel.app");
+ config.addAllowedHeader("*");
+ config.setAllowedMethods(List.of("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
+ return config;
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/config/JpaAuditingConfig.java b/src/main/java/com/tiki/server/common/config/JpaAuditingConfig.java
new file mode 100644
index 00000000..b3df4190
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/config/JpaAuditingConfig.java
@@ -0,0 +1,9 @@
+package com.tiki.server.common.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@Configuration
+@EnableJpaAuditing
+public class JpaAuditingConfig {
+}
diff --git a/src/main/java/com/tiki/server/common/config/RedisConfig.java b/src/main/java/com/tiki/server/common/config/RedisConfig.java
new file mode 100644
index 00000000..ccde4c63
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/config/RedisConfig.java
@@ -0,0 +1,37 @@
+package com.tiki.server.common.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.util.Map;
+
+@Configuration
+@EnableRedisRepositories
+public class RedisConfig {
+
+ @Value("${REDIS.host}")
+ private String host;
+
+ @Value("${spring.data.redis.port}")
+ private int port;
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ return new LettuceConnectionFactory(host, port);
+ }
+
+ @Bean
+ public RedisTemplate> redisEmailAuthenticationTemplate() {
+ RedisTemplate> redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(redisConnectionFactory());
+ redisTemplate.setHashKeySerializer(new StringRedisSerializer());
+ redisTemplate.setHashValueSerializer(new StringRedisSerializer());
+ return redisTemplate;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/common/config/SwaggerConfig.java b/src/main/java/com/tiki/server/common/config/SwaggerConfig.java
new file mode 100644
index 00000000..09d24168
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/config/SwaggerConfig.java
@@ -0,0 +1,49 @@
+package com.tiki.server.common.config;
+
+import static io.swagger.v3.oas.models.security.SecurityScheme.Type.HTTP;
+
+import java.util.List;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import io.swagger.v3.oas.models.servers.Server;
+
+@Configuration
+public class SwaggerConfig {
+
+ @Bean
+ public OpenAPI openApi() {
+ SecurityScheme securityScheme = new SecurityScheme();
+ securityScheme.setType(HTTP);
+ securityScheme.setScheme("bearer");
+ securityScheme.setBearerFormat("JWT");
+
+ Components components = new Components();
+ components.addSecuritySchemes("BearerAuthentication", securityScheme);
+
+ SecurityRequirement securityRequirement = new SecurityRequirement();
+ securityRequirement.addList("BearerAuthentication");
+
+ Info info = new Info();
+ info.setTitle("TIKI API Document");
+ info.setDescription("티키 API 명세서");
+ info.setVersion("1.0.0");
+
+ Server localServer = new Server();
+ Server devServer = new Server();
+ devServer.setUrl("https://www.tiki-sopt.p-e.kr");
+ localServer.setUrl("http://localhost:8080");
+
+ return new OpenAPI()
+ .components(components)
+ .security(List.of(securityRequirement))
+ .servers(List.of(localServer, devServer))
+ .info(info);
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/config/TimezoneConfig.java b/src/main/java/com/tiki/server/common/config/TimezoneConfig.java
new file mode 100644
index 00000000..d0fd47f2
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/config/TimezoneConfig.java
@@ -0,0 +1,16 @@
+package com.tiki.server.common.config;
+
+import java.util.TimeZone;
+
+import org.springframework.context.annotation.Configuration;
+
+import jakarta.annotation.PostConstruct;
+
+@Configuration
+public class TimezoneConfig {
+
+ @PostConstruct
+ public void init() {
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/constants/Constants.java b/src/main/java/com/tiki/server/common/constants/Constants.java
new file mode 100644
index 00000000..252b9518
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/constants/Constants.java
@@ -0,0 +1,8 @@
+package com.tiki.server.common.constants;
+
+public class Constants {
+ public static final String AUTHORIZATION = "Authorization";
+ public static final String BEARER = "Bearer ";
+ public static final String WRONG_INPUT = "잘못된 JSON 형식입니다.";
+ public static final int INIT_NUM = 0;
+}
diff --git a/src/main/java/com/tiki/server/common/dto/BaseResponse.java b/src/main/java/com/tiki/server/common/dto/BaseResponse.java
new file mode 100644
index 00000000..d75969a3
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/dto/BaseResponse.java
@@ -0,0 +1,8 @@
+package com.tiki.server.common.dto;
+
+import jakarta.validation.constraints.NotNull;
+
+public interface BaseResponse {
+ @NotNull boolean success();
+ @NotNull String message();
+}
diff --git a/src/main/java/com/tiki/server/common/dto/ErrorCodeResponse.java b/src/main/java/com/tiki/server/common/dto/ErrorCodeResponse.java
new file mode 100644
index 00000000..a9162ebe
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/dto/ErrorCodeResponse.java
@@ -0,0 +1,22 @@
+package com.tiki.server.common.dto;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record ErrorCodeResponse(
+ @NotNull boolean success,
+ @NotNull int code,
+ @NotNull String message
+) implements BaseResponse {
+
+ public static ErrorCodeResponse of(final int code, final String message) {
+ return ErrorCodeResponse.builder()
+ .success(false)
+ .code(code)
+ .message(message)
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/dto/ErrorResponse.java b/src/main/java/com/tiki/server/common/dto/ErrorResponse.java
new file mode 100644
index 00000000..147d8706
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/dto/ErrorResponse.java
@@ -0,0 +1,20 @@
+package com.tiki.server.common.dto;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record ErrorResponse(
+ @NotNull boolean success,
+ @NotNull String message
+) implements BaseResponse {
+
+ public static ErrorResponse of(final String message) {
+ return ErrorResponse.builder()
+ .success(false)
+ .message(message)
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/dto/SuccessResponse.java b/src/main/java/com/tiki/server/common/dto/SuccessResponse.java
new file mode 100644
index 00000000..93a94d33
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/dto/SuccessResponse.java
@@ -0,0 +1,25 @@
+package com.tiki.server.common.dto;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static lombok.AccessLevel.PRIVATE;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record SuccessResponse(
+ @NotNull boolean success,
+ @NotNull String message,
+ @JsonInclude(value = NON_NULL) T data
+) implements BaseResponse {
+
+ public static SuccessResponse success(final String message, final T data) {
+ return SuccessResponse.builder().success(true).message(message).data(data).build();
+ }
+
+ public static SuccessResponse> success(final String message) {
+ return SuccessResponse.builder().success(true).message(message).build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/entity/BaseTime.java b/src/main/java/com/tiki/server/common/entity/BaseTime.java
new file mode 100644
index 00000000..080e7bb0
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/entity/BaseTime.java
@@ -0,0 +1,23 @@
+package com.tiki.server.common.entity;
+
+import java.time.LocalDateTime;
+
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public class BaseTime {
+
+ @CreatedDate
+ protected LocalDateTime createdAt;
+
+ @LastModifiedDate
+ protected LocalDateTime updatedAt;
+}
diff --git a/src/main/java/com/tiki/server/common/entity/Position.java b/src/main/java/com/tiki/server/common/entity/Position.java
new file mode 100644
index 00000000..7db100b8
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/entity/Position.java
@@ -0,0 +1,26 @@
+package com.tiki.server.common.entity;
+
+import static com.tiki.server.timeblock.message.ErrorCode.INVALID_TYPE;
+
+import com.tiki.server.timeblock.exception.TimeBlockException;
+
+import lombok.Getter;
+
+@Getter
+public enum Position {
+ ADMIN(1), EXECUTIVE(2), MEMBER(3);
+
+ private final int authorization;
+
+ Position(final int authorization) {
+ this.authorization = authorization;
+ }
+
+ public static Position getAccessiblePosition(final String type) {
+ return switch (type) {
+ case "executive" -> EXECUTIVE;
+ case "member" -> MEMBER;
+ default -> throw new TimeBlockException(INVALID_TYPE);
+ };
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/entity/SortOrder.java b/src/main/java/com/tiki/server/common/entity/SortOrder.java
new file mode 100644
index 00000000..00b1f0f4
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/entity/SortOrder.java
@@ -0,0 +1,5 @@
+package com.tiki.server.common.entity;
+
+public enum SortOrder {
+ ASC, DESC
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/common/entity/University.java b/src/main/java/com/tiki/server/common/entity/University.java
new file mode 100644
index 00000000..d066f345
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/entity/University.java
@@ -0,0 +1,8 @@
+package com.tiki.server.common.entity;
+
+import lombok.Getter;
+
+@Getter
+public enum University {
+ 건국대학교,
+}
diff --git a/src/main/java/com/tiki/server/common/handler/ErrorHandler.java b/src/main/java/com/tiki/server/common/handler/ErrorHandler.java
new file mode 100644
index 00000000..e0d0b82c
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/handler/ErrorHandler.java
@@ -0,0 +1,133 @@
+package com.tiki.server.common.handler;
+
+import com.tiki.server.auth.exception.AuthException;
+import com.tiki.server.common.dto.ErrorCodeResponse;
+import com.tiki.server.email.emailsender.exception.EmailSenderException;
+import com.tiki.server.email.teaminvitation.exception.TeamInvitationException;
+import com.tiki.server.email.verification.exception.EmailVerificationException;
+import com.tiki.server.folder.exception.FolderException;
+import com.tiki.server.note.exception.NoteException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import com.tiki.server.common.dto.BaseResponse;
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.document.exception.DocumentException;
+import com.tiki.server.external.exception.ExternalException;
+import com.tiki.server.member.exception.MemberException;
+import com.tiki.server.memberteammanager.exception.MemberTeamManagerException;
+import com.tiki.server.team.exception.TeamException;
+import com.tiki.server.timeblock.exception.TimeBlockException;
+
+import lombok.extern.slf4j.Slf4j;
+import lombok.val;
+
+import static com.tiki.server.auth.message.ErrorCode.UNCAUGHT_SERVER_EXCEPTION;
+import static com.tiki.server.common.constants.Constants.WRONG_INPUT;
+
+@Slf4j
+@RestControllerAdvice
+public class ErrorHandler {
+
+ @ExceptionHandler(MemberException.class)
+ public ResponseEntity memberException(MemberException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(TeamException.class)
+ public ResponseEntity teamException(TeamException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(MemberTeamManagerException.class)
+ public ResponseEntity memberTeamManagerException(MemberTeamManagerException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(TimeBlockException.class)
+ public ResponseEntity timeBlockException(TimeBlockException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(DocumentException.class)
+ public ResponseEntity documentException(DocumentException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(NoteException.class)
+ public ResponseEntity noteException(NoteException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(ExternalException.class)
+ public ResponseEntity externalException(ExternalException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(EmailVerificationException.class)
+ public ResponseEntity mailVerificationException(EmailVerificationException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(EmailSenderException.class)
+ public ResponseEntity mailSenderException(EmailSenderException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(TeamInvitationException.class)
+ public ResponseEntity teamInvitationException(TeamInvitationException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(FolderException.class)
+ public ResponseEntity folderException(FolderException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(AuthException.class)
+ public ResponseEntity authException(AuthException exception) {
+ log.error(exception.getMessage());
+ val errorCode = exception.getErrorCode();
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(
+ ErrorCodeResponse.of(errorCode.getCode(), errorCode.getMessage()));
+ }
+
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ public ResponseEntity httpMessageNotReadableException(HttpMessageNotReadableException exception) {
+ log.error(exception.getMessage());
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(
+ ErrorResponse.of(WRONG_INPUT));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity exception(Exception exception) {
+ log.error(exception.getMessage());
+ val errorCode = UNCAUGHT_SERVER_EXCEPTION;
+ return ResponseEntity.status(errorCode.getHttpStatus()).body(ErrorResponse.of(errorCode.getMessage()));
+ }
+}
diff --git a/src/main/java/com/tiki/server/common/support/RepositoryAdapter.java b/src/main/java/com/tiki/server/common/support/RepositoryAdapter.java
new file mode 100644
index 00000000..423dc88c
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/support/RepositoryAdapter.java
@@ -0,0 +1,14 @@
+package com.tiki.server.common.support;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.stereotype.Component;
+
+@Component
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface RepositoryAdapter {
+}
diff --git a/src/main/java/com/tiki/server/common/util/ContentDecoder.java b/src/main/java/com/tiki/server/common/util/ContentDecoder.java
new file mode 100644
index 00000000..721a4962
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/util/ContentDecoder.java
@@ -0,0 +1,21 @@
+package com.tiki.server.common.util;
+
+import java.util.Base64;
+import java.util.List;
+
+public class ContentDecoder {
+
+ public static List decodeNoteTemplate(final String encodedData) {
+ String[] parts = encodedData.split("\\|");
+ String decodedActivity = parts[0].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[0]));
+ String decodedPrepare = parts[1].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[1]));
+ String decodedDisappointing = parts[2].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[2]));
+ String decodedComplement = parts[3].isBlank() ? "" : new String(Base64.getDecoder().decode(parts[3]));
+ return List.of(decodedActivity, decodedPrepare, decodedDisappointing, decodedComplement);
+ }
+
+ public static String decodeNoteFree(final String encodeDate) {
+ byte[] decodedBytes = Base64.getDecoder().decode(encodeDate);
+ return new String(decodedBytes);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/common/util/ContentEncoder.java b/src/main/java/com/tiki/server/common/util/ContentEncoder.java
new file mode 100644
index 00000000..f24d7809
--- /dev/null
+++ b/src/main/java/com/tiki/server/common/util/ContentEncoder.java
@@ -0,0 +1,21 @@
+package com.tiki.server.common.util;
+
+import java.util.Base64;
+
+public class ContentEncoder {
+
+ public static String encodeNoteTemplate(final String activity, final String prepare, final String disappointing,
+ final String complement) {
+ String encodedActivity = activity.isBlank() ? " " : Base64.getEncoder().encodeToString(activity.getBytes());
+ String encodedPrepare = prepare.isBlank() ? " " : Base64.getEncoder().encodeToString(prepare.getBytes());
+ String encodedDisappointing =
+ disappointing.isBlank() ? " " : Base64.getEncoder().encodeToString(disappointing.getBytes());
+ String encodedComplement =
+ complement.isBlank() ? " " : Base64.getEncoder().encodeToString(complement.getBytes());
+ return String.join("|", encodedActivity, encodedPrepare, encodedDisappointing, encodedComplement);
+ }
+
+ public static String encodeNoteFree(final String contents) {
+ return Base64.getEncoder().encodeToString(contents.getBytes());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/document/adapter/DeletedDocumentAdapter.java b/src/main/java/com/tiki/server/document/adapter/DeletedDocumentAdapter.java
new file mode 100644
index 00000000..6b17e8a6
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/adapter/DeletedDocumentAdapter.java
@@ -0,0 +1,47 @@
+package com.tiki.server.document.adapter;
+
+import static com.tiki.server.document.message.ErrorCode.INVALID_DOCUMENT;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.document.entity.DeletedDocument;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.document.exception.DocumentException;
+import com.tiki.server.document.repository.DeletedDocumentRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class DeletedDocumentAdapter {
+
+ private final DeletedDocumentRepository deletedDocumentRepository;
+
+ public List get(final long teamId) {
+ return deletedDocumentRepository.findAllByTeamId(teamId);
+ }
+
+ public void save(final List documents) {
+ documents.forEach(document -> deletedDocumentRepository.save(create(document)));
+ }
+
+ public List get(final List deletedDocumentIds, final long teamId) {
+ return deletedDocumentIds.stream()
+ .map(id -> find(id, teamId))
+ .toList();
+ }
+
+ public void deleteAll(final List deletedDocuments) {
+ deletedDocumentRepository.deleteAll(deletedDocuments);
+ }
+
+ private DeletedDocument create(final Document document) {
+ return DeletedDocument.of(document);
+ }
+
+ private DeletedDocument find(final long id, final long teamId) {
+ return deletedDocumentRepository.findByIdAndTeamId(id, teamId)
+ .orElseThrow(() -> new DocumentException(INVALID_DOCUMENT));
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/adapter/DocumentDeleter.java b/src/main/java/com/tiki/server/document/adapter/DocumentDeleter.java
new file mode 100644
index 00000000..9e7f81bd
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/adapter/DocumentDeleter.java
@@ -0,0 +1,20 @@
+package com.tiki.server.document.adapter;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.document.repository.DocumentRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class DocumentDeleter {
+
+ private final DocumentRepository documentRepository;
+
+ public void deleteAll(final List documents) {
+ documentRepository.deleteAll(documents);
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/adapter/DocumentFinder.java b/src/main/java/com/tiki/server/document/adapter/DocumentFinder.java
new file mode 100644
index 00000000..b1813315
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/adapter/DocumentFinder.java
@@ -0,0 +1,51 @@
+package com.tiki.server.document.adapter;
+
+import static com.tiki.server.document.message.ErrorCode.INVALID_DOCUMENT;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.document.exception.DocumentException;
+import com.tiki.server.document.repository.DocumentRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class DocumentFinder {
+
+ private final DocumentRepository documentRepository;
+
+ public List findAllByIdAndTeamId(final List documentIds, final long teamId) {
+ return documentIds.stream()
+ .map(id -> findByIdAndTeamId(id, teamId))
+ .toList();
+ }
+
+ public Document findById(final long documentId) {
+ return documentRepository.findById(documentId)
+ .orElseThrow(() -> new DocumentException(INVALID_DOCUMENT));
+ }
+
+ public List findAllByTeamId(long teamId) {
+ return documentRepository.findAllByTeamId(teamId);
+ }
+
+ public boolean existsById(final Long timeBlockId) {
+ return documentRepository.existsById(timeBlockId);
+ }
+
+ public List findByTeamIdAndFolderId(final long teamId, final Long folderId) {
+ return documentRepository.findAllByTeamIdAndFolderIdOrderByCreatedAtDesc(teamId, folderId);
+ }
+
+ public List findAllByFolderId(final long folderId) {
+ return documentRepository.findAllByFolderId(folderId);
+ }
+
+ private Document findByIdAndTeamId(final long documentId, final long teamId) {
+ return documentRepository.findByIdAndTeamId(documentId, teamId)
+ .orElseThrow(() -> new DocumentException(INVALID_DOCUMENT));
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/adapter/DocumentSaver.java b/src/main/java/com/tiki/server/document/adapter/DocumentSaver.java
new file mode 100644
index 00000000..d86f99b7
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/adapter/DocumentSaver.java
@@ -0,0 +1,29 @@
+package com.tiki.server.document.adapter;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.document.entity.DeletedDocument;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.document.repository.DocumentRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class DocumentSaver {
+
+ private final DocumentRepository documentRepository;
+
+ public Document save(final Document document) {
+ return documentRepository.save(document);
+ }
+
+ public void restore(final List deletedDocuments) {
+ deletedDocuments.forEach(document -> documentRepository.save(create(document)));
+ }
+
+ private Document create(final DeletedDocument deletedDocument) {
+ return Document.restore(deletedDocument);
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/controller/DocumentController.java b/src/main/java/com/tiki/server/document/controller/DocumentController.java
new file mode 100644
index 00000000..72ffc841
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/controller/DocumentController.java
@@ -0,0 +1,125 @@
+package com.tiki.server.document.controller;
+
+import static com.tiki.server.document.message.SuccessMessage.SUCCESS_CREATE_DOCUMENTS;
+import static com.tiki.server.document.message.SuccessMessage.SUCCESS_GET_DOCUMENTS;
+import static com.tiki.server.document.message.SuccessMessage.SUCCESS_GET_TRASH;
+
+import java.security.Principal;
+import java.util.List;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.document.controller.docs.DocumentControllerDocs;
+import com.tiki.server.document.dto.request.DocumentsCreateRequest;
+import com.tiki.server.document.dto.response.DeletedDocumentsGetResponse;
+import com.tiki.server.document.dto.response.DocumentsCreateResponse;
+import com.tiki.server.document.dto.response.DocumentsGetResponse;
+import com.tiki.server.document.service.DocumentService;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1")
+public class DocumentController implements DocumentControllerDocs {
+
+ private final DocumentService documentService;
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/documents/team/{teamId}/timeline")
+ public SuccessResponse getAllDocuments(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam final String type
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ DocumentsGetResponse response = documentService.getAllDocuments(memberId, teamId, type);
+ return SuccessResponse.success(SUCCESS_GET_DOCUMENTS.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/teams/{teamId}/documents")
+ public SuccessResponse createDocuments(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam(required = false) final Long folderId,
+ @RequestBody final DocumentsCreateRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ DocumentsCreateResponse response = documentService.createDocuments(memberId, teamId, folderId, request);
+ return SuccessResponse.success(SUCCESS_CREATE_DOCUMENTS.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/teams/{teamId}/documents")
+ public SuccessResponse getDocuments(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam(required = false) final Long folderId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ DocumentsGetResponse response = documentService.get(memberId, teamId, folderId);
+ return SuccessResponse.success(SUCCESS_GET_DOCUMENTS.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @DeleteMapping("/teams/{teamId}/documents")
+ public void delete(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam("documentId") final List documentIds
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ documentService.delete(memberId, teamId, documentIds);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @DeleteMapping("/teams/{teamId}/trash")
+ public void deleteTrash(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam("documentId") final List deletedDocumentIds
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ documentService.deleteTrash(memberId, teamId, deletedDocumentIds);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @PostMapping("/teams/{teamId}/trash")
+ public void restore(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam("documentId") final List deletedDocumentIds
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ documentService.restore(memberId, teamId, deletedDocumentIds);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/teams/{teamId}/trash")
+ public SuccessResponse getTrash(
+ final Principal principal,
+ @PathVariable final long teamId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ DeletedDocumentsGetResponse response = documentService.getTrash(memberId, teamId);
+ return SuccessResponse.success(SUCCESS_GET_TRASH.getMessage(), response);
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/controller/docs/DocumentControllerDocs.java b/src/main/java/com/tiki/server/document/controller/docs/DocumentControllerDocs.java
new file mode 100644
index 00000000..5ca95791
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/controller/docs/DocumentControllerDocs.java
@@ -0,0 +1,244 @@
+package com.tiki.server.document.controller.docs;
+
+import java.security.Principal;
+import java.util.List;
+
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.document.dto.request.DocumentsCreateRequest;
+import com.tiki.server.document.dto.response.DeletedDocumentsGetResponse;
+import com.tiki.server.document.dto.response.DocumentsCreateResponse;
+import com.tiki.server.document.dto.response.DocumentsGetResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "documents", description = "파일 API")
+public interface DocumentControllerDocs {
+
+ @Operation(
+ summary = "전체 문서 조회",
+ description = "전체 문서를 조회한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse getAllDocuments(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "type",
+ description = "타임라인 타입",
+ in = ParameterIn.QUERY,
+ required = true,
+ example = "executive, member"
+ ) @RequestParam final String type
+ );
+
+ @Operation(
+ summary = "문서 생성",
+ description = "문서를 여러 개 생성한다.",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse createDocuments(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "folderId",
+ description = "생성할 파일이 속할 폴더 id",
+ in = ParameterIn.QUERY,
+ example = "1"
+ ) @RequestParam final Long folderId,
+ @RequestBody final DocumentsCreateRequest request
+ );
+
+ @Operation(
+ summary = "문서 조회",
+ description = "문서를 조회한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse getDocuments(
+ @Parameter(hidden = true) Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "folderId",
+ description = "조회할 폴더 id (최상단은 비워두기)",
+ in = ParameterIn.QUERY,
+ example = "1"
+ ) @RequestParam final Long folderId
+ );
+
+ @Operation(
+ summary = "문서 삭제",
+ description = "문서를 여러 개 삭제한다.",
+ responses = {
+ @ApiResponse(responseCode = "204", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ void delete(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "documentId",
+ description = "삭제할 파일 id 리스트",
+ in = ParameterIn.QUERY,
+ required = true,
+ example = "[1, 2]"
+ ) @RequestParam("documentId") final List documentIds
+ );
+
+ @Operation(
+ summary = "휴지통 문서 삭제",
+ description = "휴지통 속 문서를 여러 개 삭제한다.",
+ responses = {
+ @ApiResponse(responseCode = "204", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ void deleteTrash(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "documentId",
+ description = "삭제할 파일 id 리스트",
+ in = ParameterIn.QUERY,
+ required = true,
+ example = "[1, 2]"
+ ) @RequestParam("documentId") final List deletedDocumentIds
+ );
+
+ @Operation(
+ summary = "휴지통 문서 복구",
+ description = "휴지통 속 문서를 여러 개 복구한다.",
+ responses = {
+ @ApiResponse(responseCode = "204", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ void restore(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "documentId",
+ description = "복구할 파일 id 리스트",
+ in = ParameterIn.QUERY,
+ required = true,
+ example = "[1, 2]"
+ ) @RequestParam("documentId") final List deletedDocumentIds
+ );
+
+ @Operation(
+ summary = "휴지통 조회",
+ description = "휴지통을 조회한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse getTrash(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId
+ );
+}
diff --git a/src/main/java/com/tiki/server/document/dto/request/DocumentCreateRequest.java b/src/main/java/com/tiki/server/document/dto/request/DocumentCreateRequest.java
new file mode 100644
index 00000000..53a7f24a
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/dto/request/DocumentCreateRequest.java
@@ -0,0 +1,16 @@
+package com.tiki.server.document.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+
+public record DocumentCreateRequest(
+ @Schema(description = "파일 이름", example = "tiki.jpg")
+ @NotNull String fileName,
+ @Schema(description = "파일 url", example = "https://.../tiki.jpg")
+ @NotNull String fileUrl,
+ @Schema(description = "파일 key", example = "....jpg")
+ @NotNull String fileKey,
+ @Schema(description = "파일 용량 (단위 : byte)", example = "123")
+ @NotNull long capacity
+) {
+}
diff --git a/src/main/java/com/tiki/server/document/dto/request/DocumentsCreateRequest.java b/src/main/java/com/tiki/server/document/dto/request/DocumentsCreateRequest.java
new file mode 100644
index 00000000..0d7957d1
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/dto/request/DocumentsCreateRequest.java
@@ -0,0 +1,10 @@
+package com.tiki.server.document.dto.request;
+
+import java.util.List;
+
+import jakarta.validation.constraints.NotNull;
+
+public record DocumentsCreateRequest(
+ @NotNull List documents
+) {
+}
diff --git a/src/main/java/com/tiki/server/document/dto/response/DeletedDocumentsGetResponse.java b/src/main/java/com/tiki/server/document/dto/response/DeletedDocumentsGetResponse.java
new file mode 100644
index 00000000..0ae4d9c1
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/dto/response/DeletedDocumentsGetResponse.java
@@ -0,0 +1,40 @@
+package com.tiki.server.document.dto.response;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import java.util.List;
+
+import com.tiki.server.document.entity.DeletedDocument;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record DeletedDocumentsGetResponse(
+ @NotNull List deletedDocuments
+) {
+
+ public static DeletedDocumentsGetResponse from(final List deletedDocuments) {
+ return DeletedDocumentsGetResponse.builder()
+ .deletedDocuments(deletedDocuments.stream().map(DeletedDocumentGetResponse::from).toList())
+ .build();
+ }
+
+ @Builder(access = PRIVATE)
+ private record DeletedDocumentGetResponse(
+ @NotNull long documentId,
+ @NotNull String name,
+ @NotNull String url,
+ @NotNull long capacity
+ ) {
+
+ private static DeletedDocumentGetResponse from(final DeletedDocument deletedDocument) {
+ return DeletedDocumentGetResponse.builder()
+ .documentId(deletedDocument.getId())
+ .name(deletedDocument.getFileName())
+ .url(deletedDocument.getFileUrl())
+ .capacity(deletedDocument.getCapacity())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/dto/response/DocumentsCreateResponse.java b/src/main/java/com/tiki/server/document/dto/response/DocumentsCreateResponse.java
new file mode 100644
index 00000000..95464888
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/dto/response/DocumentsCreateResponse.java
@@ -0,0 +1,32 @@
+package com.tiki.server.document.dto.response;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import java.util.List;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record DocumentsCreateResponse(
+ @NotNull List response
+) {
+
+ public static DocumentsCreateResponse from(final List documentIds) {
+ return DocumentsCreateResponse.builder()
+ .response(documentIds.stream().map(DocumentCreateResponse::from).toList())
+ .build();
+ }
+
+ @Builder(access = PRIVATE)
+ private record DocumentCreateResponse(
+ @NotNull long documentId
+ ) {
+
+ public static DocumentCreateResponse from(final long documentId) {
+ return DocumentCreateResponse.builder()
+ .documentId(documentId)
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/dto/response/DocumentsGetResponse.java b/src/main/java/com/tiki/server/document/dto/response/DocumentsGetResponse.java
new file mode 100644
index 00000000..3ba22bc3
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/dto/response/DocumentsGetResponse.java
@@ -0,0 +1,43 @@
+package com.tiki.server.document.dto.response;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import com.tiki.server.document.entity.Document;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record DocumentsGetResponse(
+ @NotNull List documents
+) {
+
+ public static DocumentsGetResponse from(final List documents) {
+ return DocumentsGetResponse.builder()
+ .documents(documents.stream().map(DocumentInfoGetResponse::from).toList())
+ .build();
+ }
+
+ @Builder(access = PRIVATE)
+ private record DocumentInfoGetResponse(
+ @NotNull long documentId,
+ @NotNull String name,
+ @NotNull String url,
+ @NotNull long capacity,
+ @NotNull LocalDateTime createdTime
+ ) {
+
+ public static DocumentInfoGetResponse from(final Document document) {
+ return DocumentInfoGetResponse.builder()
+ .documentId(document.getId())
+ .name(document.getFileName())
+ .url(document.getFileUrl())
+ .capacity(document.getCapacity())
+ .createdTime(document.getCreatedAt())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/entity/DeletedDocument.java b/src/main/java/com/tiki/server/document/entity/DeletedDocument.java
new file mode 100644
index 00000000..d50d5a38
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/entity/DeletedDocument.java
@@ -0,0 +1,54 @@
+package com.tiki.server.document.entity;
+
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+import com.tiki.server.common.entity.BaseTime;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = PROTECTED)
+@Builder(access = PRIVATE)
+@AllArgsConstructor(access = PRIVATE)
+public class DeletedDocument extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ @Column(name = "deleted_document_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String fileName;
+
+ @Column(nullable = false)
+ private String fileUrl;
+
+ @Column(nullable = false)
+ private String fileKey;
+
+ @Column(nullable = false)
+ private long teamId;
+
+ @Column(nullable = false)
+ private long capacity;
+
+ public static DeletedDocument of(final Document document) {
+ return DeletedDocument.builder()
+ .fileName(document.getFileName())
+ .fileUrl(document.getFileUrl())
+ .fileKey(document.getFileKey())
+ .teamId(document.getTeamId())
+ .capacity(document.getCapacity())
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/entity/Document.java b/src/main/java/com/tiki/server/document/entity/Document.java
new file mode 100644
index 00000000..dd5389f8
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/entity/Document.java
@@ -0,0 +1,69 @@
+package com.tiki.server.document.entity;
+
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+import com.tiki.server.common.entity.BaseTime;
+import com.tiki.server.document.dto.request.DocumentCreateRequest;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@Builder(access = PRIVATE)
+@AllArgsConstructor(access = PRIVATE)
+@NoArgsConstructor(access = PROTECTED)
+public class Document extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ @Column(name = "document_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String fileName;
+
+ @Column(nullable = false)
+ private String fileUrl;
+
+ @Column(nullable = false)
+ private String fileKey;
+
+ @Column(nullable = false)
+ private long capacity;
+
+ @Column(nullable = false)
+ private long teamId;
+
+ private Long folderId;
+
+ public static Document of(final DocumentCreateRequest request, final long teamId, final Long folderId) {
+ return Document.builder()
+ .fileName(request.fileName())
+ .fileUrl(request.fileUrl())
+ .capacity(request.capacity())
+ .fileKey(request.fileKey())
+ .teamId(teamId)
+ .folderId(folderId)
+ .build();
+ }
+
+ public static Document restore(final DeletedDocument deletedDocument) {
+ return Document.builder()
+ .fileName(deletedDocument.getFileName())
+ .fileUrl(deletedDocument.getFileUrl())
+ .capacity(deletedDocument.getCapacity())
+ .fileKey(deletedDocument.getFileKey())
+ .teamId(deletedDocument.getTeamId())
+ .folderId(null)
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/exception/DocumentException.java b/src/main/java/com/tiki/server/document/exception/DocumentException.java
new file mode 100644
index 00000000..142050fe
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/exception/DocumentException.java
@@ -0,0 +1,16 @@
+package com.tiki.server.document.exception;
+
+import com.tiki.server.document.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class DocumentException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public DocumentException(final ErrorCode errorCode) {
+ super("[DocumentException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/message/ErrorCode.java b/src/main/java/com/tiki/server/document/message/ErrorCode.java
new file mode 100644
index 00000000..3a310735
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/message/ErrorCode.java
@@ -0,0 +1,31 @@
+package com.tiki.server.document.message;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.CONFLICT;
+import static org.springframework.http.HttpStatus.FORBIDDEN;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ /* 400 BAD_REQUEST : 잘못된 요청 */
+ INVALID_TYPE(BAD_REQUEST, "유효한 타입이 아닙니다."),
+
+ /* 403 FORBIDDEN : 권한 없음 */
+ INVALID_AUTHORIZATION(FORBIDDEN, "문서에 대한 권한이 없습니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_DOCUMENT(NOT_FOUND, "유효하지 않은 문서입니다."),
+
+ /* 409 CONFLICT : 중복된 자원 */
+ DOCUMENT_NAME_DUPLICATE(CONFLICT, "중복된 파일 이름입니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/document/message/SuccessMessage.java b/src/main/java/com/tiki/server/document/message/SuccessMessage.java
new file mode 100644
index 00000000..315fceee
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/message/SuccessMessage.java
@@ -0,0 +1,15 @@
+package com.tiki.server.document.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ SUCCESS_CREATE_DOCUMENTS("파일 생성 성공"),
+ SUCCESS_GET_DOCUMENTS("전체 문서 조회 성공"),
+ SUCCESS_GET_TRASH("휴지통 조회 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/document/repository/DeletedDocumentRepository.java b/src/main/java/com/tiki/server/document/repository/DeletedDocumentRepository.java
new file mode 100644
index 00000000..52dff8bd
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/repository/DeletedDocumentRepository.java
@@ -0,0 +1,15 @@
+package com.tiki.server.document.repository;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.tiki.server.document.entity.DeletedDocument;
+
+public interface DeletedDocumentRepository extends JpaRepository {
+
+ Optional findByIdAndTeamId(final long id, final long teamId);
+
+ List findAllByTeamId(final long teamId);
+}
diff --git a/src/main/java/com/tiki/server/document/repository/DocumentRepository.java b/src/main/java/com/tiki/server/document/repository/DocumentRepository.java
new file mode 100644
index 00000000..5e1e6058
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/repository/DocumentRepository.java
@@ -0,0 +1,18 @@
+package com.tiki.server.document.repository;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.tiki.server.document.entity.Document;
+
+public interface DocumentRepository extends JpaRepository {
+ List findAllByFolderId(final long folderId);
+
+ List findAllByTeamId(final long teamId);
+
+ List findAllByTeamIdAndFolderIdOrderByCreatedAtDesc(final long teamId, final Long folderId);
+
+ Optional findByIdAndTeamId(final long id, final long teamId);
+}
diff --git a/src/main/java/com/tiki/server/document/service/DocumentService.java b/src/main/java/com/tiki/server/document/service/DocumentService.java
new file mode 100644
index 00000000..4f05c92b
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/service/DocumentService.java
@@ -0,0 +1,141 @@
+package com.tiki.server.document.service;
+
+import static com.tiki.server.document.message.ErrorCode.DOCUMENT_NAME_DUPLICATE;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.tiki.server.common.entity.Position;
+import com.tiki.server.document.adapter.DeletedDocumentAdapter;
+import com.tiki.server.document.adapter.DocumentDeleter;
+import com.tiki.server.document.adapter.DocumentFinder;
+import com.tiki.server.document.adapter.DocumentSaver;
+import com.tiki.server.document.dto.request.DocumentCreateRequest;
+import com.tiki.server.document.dto.request.DocumentsCreateRequest;
+import com.tiki.server.document.dto.response.DeletedDocumentsGetResponse;
+import com.tiki.server.document.dto.response.DocumentsCreateResponse;
+import com.tiki.server.document.dto.response.DocumentsGetResponse;
+import com.tiki.server.document.entity.DeletedDocument;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.document.exception.DocumentException;
+import com.tiki.server.documenttimeblockmanager.adapter.DTBAdapter;
+import com.tiki.server.external.util.AwsHandler;
+import com.tiki.server.folder.adapter.FolderFinder;
+import com.tiki.server.folder.entity.Folder;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.team.adapter.TeamFinder;
+import com.tiki.server.team.entity.Team;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class DocumentService {
+
+ private final DocumentSaver documentSaver;
+ private final DocumentFinder documentFinder;
+ private final DocumentDeleter documentDeleter;
+ private final FolderFinder folderFinder;
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+ private final DeletedDocumentAdapter deletedDocumentAdapter;
+ private final TeamFinder teamFinder;
+ private final DTBAdapter dtbAdapter;
+ private final AwsHandler awsHandler;
+
+ public DocumentsGetResponse getAllDocuments(final long memberId, final long teamId, final String type) {
+ MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ Position accessiblePosition = Position.getAccessiblePosition(type);
+ memberTeamManager.checkMemberAccessible(accessiblePosition);
+ List documents = documentFinder.findAllByTeamId(teamId);
+ return DocumentsGetResponse.from(documents);
+ }
+
+ @Transactional
+ public DocumentsCreateResponse createDocuments(final long memberId, final long teamId,
+ final Long folderId, final DocumentsCreateRequest request) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ validateFolder(folderId, teamId);
+ validateFileName(folderId, teamId, request);
+ List documentIds = saveDocuments(teamId, folderId, request);
+ return DocumentsCreateResponse.from(documentIds);
+ }
+
+ public DocumentsGetResponse get(final long memberId, final long teamId, final Long folderId) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List documents = documentFinder.findByTeamIdAndFolderId(teamId, folderId);
+ return DocumentsGetResponse.from(documents);
+ }
+
+ @Transactional
+ public void delete(final long memberId, final long teamId, final List documentIds) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List documents = documentFinder.findAllByIdAndTeamId(documentIds, teamId);
+ dtbAdapter.deleteAllByDocuments(documentIds);
+ deletedDocumentAdapter.save(documents);
+ documentDeleter.deleteAll(documents);
+ }
+
+ @Transactional
+ public void deleteTrash(final long memberId, final long teamId, final List documentIds) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List deletedDocuments = deletedDocumentAdapter.get(documentIds, teamId);
+ restoreTeamUsage(teamId, deletedDocuments);
+ deletedDocuments.forEach(deletedDocument -> awsHandler.deleteFile(deletedDocument.getFileKey()));
+ deletedDocumentAdapter.deleteAll(deletedDocuments);
+ }
+
+ @Transactional
+ public void restore(final long memberId, final long teamId, final List documentIds) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List deletedDocuments = deletedDocumentAdapter.get(documentIds, teamId);
+ documentSaver.restore(deletedDocuments);
+ deletedDocumentAdapter.deleteAll(deletedDocuments);
+ }
+
+ public DeletedDocumentsGetResponse getTrash(final long memberId, final long teamId) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List deletedDocuments = deletedDocumentAdapter.get(teamId);
+ return DeletedDocumentsGetResponse.from(deletedDocuments);
+ }
+
+ private void validateFolder(final Long folderId, final long teamId) {
+ if (folderId == null) {
+ return;
+ }
+ Folder folder = folderFinder.findById(folderId);
+ folder.validateTeamId(teamId);
+ }
+
+ private void validateFileName(final Long folderId, final long teamId, final DocumentsCreateRequest request) {
+ List documents = documentFinder.findByTeamIdAndFolderId(teamId, folderId);
+ documents.forEach(document -> checkFileNameIsDuplicated(document.getFileName(), request));
+ }
+
+ private void checkFileNameIsDuplicated(final String fileName, final DocumentsCreateRequest request) {
+ if (request.documents().stream().anyMatch(document -> document.fileName().equals(fileName))) {
+ throw new DocumentException(DOCUMENT_NAME_DUPLICATE);
+ }
+ }
+
+ private List saveDocuments(final long teamId, final Long folderId, final DocumentsCreateRequest request) {
+ Team team = teamFinder.findById(teamId);
+ return request.documents().stream()
+ .map(document -> saveDocument(team, folderId, document).getId())
+ .toList();
+ }
+
+ private Document saveDocument(final Team team, final Long folderId, final DocumentCreateRequest request) {
+ team.addUsage(request.capacity());
+ Document document = Document.of(request, team.getId(), folderId);
+ return documentSaver.save(document);
+ }
+
+ private void restoreTeamUsage(final long teamId, final List deletedDocuments) {
+ Team team = teamFinder.findById(teamId);
+ team.restoreUsage(deletedDocuments.stream().mapToLong(DeletedDocument::getCapacity).sum());
+ }
+}
diff --git a/src/main/java/com/tiki/server/document/service/dto/response/DocumentTagGetServiceResponse.java b/src/main/java/com/tiki/server/document/service/dto/response/DocumentTagGetServiceResponse.java
new file mode 100644
index 00000000..bbbe4332
--- /dev/null
+++ b/src/main/java/com/tiki/server/document/service/dto/response/DocumentTagGetServiceResponse.java
@@ -0,0 +1,21 @@
+package com.tiki.server.document.service.dto.response;
+
+import com.tiki.server.document.entity.Document;
+
+import jakarta.validation.constraints.NotNull;
+
+public record DocumentTagGetServiceResponse(
+ @NotNull long id,
+ @NotNull String fileName,
+ @NotNull String fileUrl,
+ @NotNull long capacity
+) {
+
+ public static DocumentTagGetServiceResponse from(final Document document) {
+ return new DocumentTagGetServiceResponse(
+ document.getId(),
+ document.getFileName(),
+ document.getFileUrl(),
+ document.getCapacity());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/documenttimeblockmanager/adapter/DTBAdapter.java b/src/main/java/com/tiki/server/documenttimeblockmanager/adapter/DTBAdapter.java
new file mode 100644
index 00000000..0866f7f7
--- /dev/null
+++ b/src/main/java/com/tiki/server/documenttimeblockmanager/adapter/DTBAdapter.java
@@ -0,0 +1,41 @@
+package com.tiki.server.documenttimeblockmanager.adapter;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.documenttimeblockmanager.entity.DTBManager;
+import com.tiki.server.documenttimeblockmanager.repository.DTBRepository;
+import com.tiki.server.timeblock.entity.TimeBlock;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class DTBAdapter {
+
+ private final DTBRepository dtbRepository;
+
+ public void saveAll(final TimeBlock timeBlock, final List documentIds) {
+ documentIds.forEach(documentId -> dtbRepository.save(DTBManager.of(timeBlock, documentId)));
+ }
+
+ public List getAllByTimeBlock(final TimeBlock timeBlock) {
+ return dtbRepository.findAllByTimeBlockId(timeBlock.getId());
+ }
+
+ public List getAllByIds(final List ids) {
+ return dtbRepository.findAllByIdIn(ids);
+ }
+
+ public void deleteAll(final List dtbManagers) {
+ dtbRepository.deleteAll(dtbManagers);
+ }
+
+ public void deleteAllByTimeBlock(final TimeBlock timeBlock) {
+ dtbRepository.deleteAllByTimeBlockId(timeBlock.getId());
+ }
+
+ public void deleteAllByDocuments(final List documentIds) {
+ dtbRepository.deleteAllByDocumentIdIn(documentIds);
+ }
+}
diff --git a/src/main/java/com/tiki/server/documenttimeblockmanager/entity/DTBManager.java b/src/main/java/com/tiki/server/documenttimeblockmanager/entity/DTBManager.java
new file mode 100644
index 00000000..83881706
--- /dev/null
+++ b/src/main/java/com/tiki/server/documenttimeblockmanager/entity/DTBManager.java
@@ -0,0 +1,52 @@
+package com.tiki.server.documenttimeblockmanager.entity;
+
+import static com.tiki.server.timeblock.message.ErrorCode.INVALID_DOCUMENT_TAG;
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+import com.tiki.server.common.entity.BaseTime;
+import com.tiki.server.timeblock.entity.TimeBlock;
+import com.tiki.server.timeblock.exception.TimeBlockException;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@Builder(access = PRIVATE)
+@AllArgsConstructor(access = PRIVATE)
+@NoArgsConstructor(access = PROTECTED)
+@Table(name = "document_time_block_manager")
+public class DTBManager extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private long documentId;
+
+ @Column(nullable = false)
+ private long timeBlockId;
+
+ public static DTBManager of(final TimeBlock timeBlock, final long documentId) {
+ return DTBManager.builder()
+ .documentId(documentId)
+ .timeBlockId(timeBlock.getId())
+ .build();
+ }
+
+ public void validateTimeBlock(final TimeBlock timeBlock) {
+ if (this.timeBlockId != timeBlock.getId()) {
+ throw new TimeBlockException(INVALID_DOCUMENT_TAG);
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/documenttimeblockmanager/repository/DTBRepository.java b/src/main/java/com/tiki/server/documenttimeblockmanager/repository/DTBRepository.java
new file mode 100644
index 00000000..c5b7ec87
--- /dev/null
+++ b/src/main/java/com/tiki/server/documenttimeblockmanager/repository/DTBRepository.java
@@ -0,0 +1,17 @@
+package com.tiki.server.documenttimeblockmanager.repository;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.tiki.server.documenttimeblockmanager.entity.DTBManager;
+
+public interface DTBRepository extends JpaRepository {
+ List findAllByTimeBlockId(final long timeBlockId);
+
+ List findAllByIdIn(final List ids);
+
+ void deleteAllByTimeBlockId(final long timeBlockId);
+
+ void deleteAllByDocumentIdIn(final List documentIds);
+}
diff --git a/src/main/java/com/tiki/server/drive/controller/DriveController.java b/src/main/java/com/tiki/server/drive/controller/DriveController.java
new file mode 100644
index 00000000..f594dc6e
--- /dev/null
+++ b/src/main/java/com/tiki/server/drive/controller/DriveController.java
@@ -0,0 +1,41 @@
+package com.tiki.server.drive.controller;
+
+import static com.tiki.server.common.dto.SuccessResponse.success;
+import static com.tiki.server.drive.message.SuccessMessage.SUCCESS_GET_DRIVE;
+
+import java.security.Principal;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.drive.controller.docs.DriveControllerDocs;
+import com.tiki.server.drive.dto.DriveGetResponse;
+import com.tiki.server.drive.service.DriveService;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1")
+public class DriveController implements DriveControllerDocs {
+
+ private final DriveService driveService;
+
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/teams/{teamId}/drive")
+ public SuccessResponse getDrive(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam(required = false) final Long folderId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ DriveGetResponse response = driveService.getDrive(memberId, teamId, folderId);
+ return success(SUCCESS_GET_DRIVE.getMessage(), response);
+ }
+}
diff --git a/src/main/java/com/tiki/server/drive/controller/docs/DriveControllerDocs.java b/src/main/java/com/tiki/server/drive/controller/docs/DriveControllerDocs.java
new file mode 100644
index 00000000..32cd82bd
--- /dev/null
+++ b/src/main/java/com/tiki/server/drive/controller/docs/DriveControllerDocs.java
@@ -0,0 +1,53 @@
+package com.tiki.server.drive.controller.docs;
+
+import java.security.Principal;
+
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.drive.dto.DriveGetResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "drive", description = "드라이브 API")
+public interface DriveControllerDocs {
+
+ @Operation(
+ summary = "드라이브 조회",
+ description = "드라이브 뷰를 조회한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse getDrive(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ required = true,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "folderId",
+ description = "조회할 폴더 id (최상단은 비워두기)",
+ in = ParameterIn.QUERY,
+ example = "1"
+ ) @RequestParam final Long folderId
+ );
+}
diff --git a/src/main/java/com/tiki/server/drive/dto/DriveGetResponse.java b/src/main/java/com/tiki/server/drive/dto/DriveGetResponse.java
new file mode 100644
index 00000000..dc31f3c0
--- /dev/null
+++ b/src/main/java/com/tiki/server/drive/dto/DriveGetResponse.java
@@ -0,0 +1,68 @@
+package com.tiki.server.drive.dto;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.folder.entity.Folder;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record DriveGetResponse(
+ @NotNull List documents,
+ @NotNull List folders
+) {
+
+ public static DriveGetResponse of(final List documents, final List folders) {
+ return DriveGetResponse.builder()
+ .documents(documents.stream().map(DocumentGetResponse::from).toList())
+ .folders(folders.stream().map(FolderGetResponse::from).toList())
+ .build();
+ }
+
+ @Builder(access = PRIVATE)
+ private record DocumentGetResponse(
+ @NotNull long documentId,
+ @NotNull String name,
+ @NotNull String url,
+ @NotNull long capacity,
+ @NotNull LocalDateTime createdTime,
+ @NotNull String type
+ ) {
+
+ public static DocumentGetResponse from(final Document document) {
+ return DocumentGetResponse.builder()
+ .documentId(document.getId())
+ .name(document.getFileName())
+ .url(document.getFileUrl())
+ .capacity(document.getCapacity())
+ .createdTime(document.getCreatedAt())
+ .type("document")
+ .build();
+ }
+ }
+
+ @Builder(access = PRIVATE)
+ private record FolderGetResponse(
+ @NotNull long folderId,
+ @NotNull String name,
+ @NotNull LocalDateTime createdTime,
+ @NotNull String path,
+ @NotNull String type
+ ) {
+
+ private static FolderGetResponse from(final Folder folder) {
+ return FolderGetResponse.builder()
+ .folderId(folder.getId())
+ .name(folder.getName())
+ .createdTime(folder.getCreatedAt())
+ .path(folder.getPath())
+ .type("folder")
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/drive/message/SuccessMessage.java b/src/main/java/com/tiki/server/drive/message/SuccessMessage.java
new file mode 100644
index 00000000..1d5a50fe
--- /dev/null
+++ b/src/main/java/com/tiki/server/drive/message/SuccessMessage.java
@@ -0,0 +1,13 @@
+package com.tiki.server.drive.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ SUCCESS_GET_DRIVE("드라이브 조회 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/drive/service/DriveService.java b/src/main/java/com/tiki/server/drive/service/DriveService.java
new file mode 100644
index 00000000..c195375b
--- /dev/null
+++ b/src/main/java/com/tiki/server/drive/service/DriveService.java
@@ -0,0 +1,52 @@
+package com.tiki.server.drive.service;
+
+import static com.tiki.server.folder.constant.Constant.ROOT_PATH;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.tiki.server.document.adapter.DocumentFinder;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.drive.dto.DriveGetResponse;
+import com.tiki.server.folder.adapter.FolderFinder;
+import com.tiki.server.folder.entity.Folder;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class DriveService {
+
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+ private final DocumentFinder documentFinder;
+ private final FolderFinder folderFinder;
+
+ public DriveGetResponse getDrive(final long memberId, final long teamId, final Long folderId) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List documents = documentFinder.findByTeamIdAndFolderId(teamId, folderId);
+ Folder folder = getFolder(teamId, folderId);
+ String path = getChildFolderPath(folder);
+ List folders = folderFinder.findByTeamIdAndPath(teamId, path);
+ return DriveGetResponse.of(documents, folders);
+ }
+
+ private Folder getFolder(final long teamId, final Long folderId) {
+ if (folderId == null) {
+ return null;
+ }
+ Folder folder = folderFinder.findById(folderId);
+ folder.validateTeamId(teamId);
+ return folder;
+ }
+
+ private String getChildFolderPath(final Folder folder) {
+ if (folder == null) {
+ return ROOT_PATH;
+ }
+ return folder.getChildPath();
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/Constants.java b/src/main/java/com/tiki/server/email/Constants.java
new file mode 100644
index 00000000..f9c1c1b5
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/Constants.java
@@ -0,0 +1,6 @@
+package com.tiki.server.email;
+
+public class Constants {
+ public static final String MAIL_FORMAT_EDU = ".edu";
+ public static final String MAIL_FORMAT_AC_KR = ".ac.kr";
+}
diff --git a/src/main/java/com/tiki/server/email/Email.java b/src/main/java/com/tiki/server/email/Email.java
new file mode 100644
index 00000000..ba10c7f0
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/Email.java
@@ -0,0 +1,42 @@
+package com.tiki.server.email;
+
+import com.tiki.server.member.exception.MemberException;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import lombok.*;
+import org.apache.commons.validator.routines.EmailValidator;
+
+import static com.tiki.server.email.Constants.MAIL_FORMAT_AC_KR;
+import static com.tiki.server.email.Constants.MAIL_FORMAT_EDU;
+import static com.tiki.server.member.message.ErrorCode.INVALID_EMAIL;
+
+@Getter
+@Embeddable
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Email {
+
+ @Column(nullable = false)
+ private String email;
+
+ public static Email from(final String email){
+ checkMailFormat(email);
+ return new Email(email);
+ }
+
+ private static void checkMailFormat(final String email) {
+ if (!EmailValidator.getInstance().isValid(email) || !(email.endsWith(MAIL_FORMAT_EDU) ||
+ email.endsWith(MAIL_FORMAT_AC_KR))) {
+ throw new MemberException(INVALID_EMAIL);
+ }
+ }
+
+ @Override
+ public boolean equals(Object target) {
+ if (this == target) return true;
+ if (target == null || getClass() != target.getClass()) return false;
+ Email targetEmail = (Email) target;
+ return email.equals(targetEmail.email);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/config/EmailConfig.java b/src/main/java/com/tiki/server/email/emailsender/config/EmailConfig.java
new file mode 100644
index 00000000..398620c0
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/config/EmailConfig.java
@@ -0,0 +1,45 @@
+package com.tiki.server.email.emailsender.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+
+import java.util.Properties;
+
+import static com.tiki.server.email.emailsender.constants.Constants.TIKI_EMAIL;
+
+@Configuration
+public class EmailConfig {
+
+ @Value("${spring.mail.password}")
+ private String emailPassword;
+
+ @Bean
+ public JavaMailSender setProperties() {
+ JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
+ mailSender.setHost("smtp.gmail.com");
+ mailSender.setPort(587);
+ mailSender.setUsername(TIKI_EMAIL);
+ mailSender.setPassword(emailPassword);
+
+ Properties javaMailProperties = getProperties();
+
+ mailSender.setJavaMailProperties(javaMailProperties);
+
+ return mailSender;
+ }
+
+ private static Properties getProperties() {
+ Properties javaMailProperties = new Properties();
+ javaMailProperties.put("mail.transport.protocol", "smtp");
+ javaMailProperties.put("mail.smtp.auth", "true");
+ javaMailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
+ javaMailProperties.put("mail.smtp.starttls.enable", "true");
+ javaMailProperties.put("mail.debug", "true");
+ javaMailProperties.put("mail.smtp.ssl.trust", "smtp.naver.com");
+ javaMailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2");
+ return javaMailProperties;
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/constants/Constants.java b/src/main/java/com/tiki/server/email/emailsender/constants/Constants.java
new file mode 100644
index 00000000..a0b2a9a8
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/constants/Constants.java
@@ -0,0 +1,19 @@
+package com.tiki.server.email.emailsender.constants;
+
+public class Constants {
+
+ /* 공통 상수 */
+ public static final String TIKI_EMAIL = "hello.wer.tiki@gmail.com";
+ public static final String IMG_PATH = "images/mail_logo.png";
+
+ /* 이메일 인증 관련 상수 */
+ public static final String MAIL_VERIFICATION_CODE = "[Ti.Ki] 회원가입: 이메일 인증번호 안내";
+ public static final String MAIL_PASSWORD_CHANGING_CODE = "[Ti.Ki] 비밀번호 재설정: 이메일 인증번호 안내";
+ public static final String MAIL_INVITE_TEAM_MEMBER = "[Ti.Ki] 워크스페이스 초대 안내";
+ public static final String VERIFICATION_TEMPLATE_NAME = "signup";
+ public static final String CHANGE_PASSWORD_TEMPLATE_NAME = "changePassword";
+ public static final String INVITE_TEAM_MEMBER_TEMPLATE_NAME = "invitation";
+ public static final String PAGE_LOGO_IMAGE_VAR = "image";
+
+ /* 이메일 초대 관련 상수 */
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/controller/EmailSenderController.java b/src/main/java/com/tiki/server/email/emailsender/controller/EmailSenderController.java
new file mode 100644
index 00000000..325d3564
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/controller/EmailSenderController.java
@@ -0,0 +1,49 @@
+package com.tiki.server.email.emailsender.controller;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.email.emailsender.controller.docs.EmailSenderControllerDocs;
+import com.tiki.server.email.emailsender.controller.dto.request.EmailRequest;
+import com.tiki.server.email.emailsender.message.SuccessMessage;
+import com.tiki.server.email.emailsender.service.EmailSenderService;
+import com.tiki.server.email.emailsender.service.dto.EmailServiceRequest;
+import com.tiki.server.email.emailsender.service.dto.TeamInvitationCreateServiceRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.security.Principal;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/email")
+public class EmailSenderController implements EmailSenderControllerDocs {
+
+ private final EmailSenderService emailSenderService;
+
+ @ResponseStatus(HttpStatus.OK)
+ @PostMapping("/verification/signup")
+ public SuccessResponse> sendSignUpMail(@RequestBody final EmailRequest mailRequest) {
+ emailSenderService.sendSignUp(EmailServiceRequest.from(mailRequest.email()));
+ return SuccessResponse.success(SuccessMessage.SUCCESS_SEND_EMAIL.getMessage());
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @PostMapping("/verification/password")
+ public SuccessResponse> sendChangingPasswordMail(@RequestBody final EmailRequest mailRequest) {
+ emailSenderService.sendPasswordChanging(EmailServiceRequest.from(mailRequest.email()));
+ return SuccessResponse.success(SuccessMessage.SUCCESS_SEND_EMAIL.getMessage());
+ }
+
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/invitation/team/{teamId}")
+ public SuccessResponse> sendInvitationMail(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestBody final EmailRequest targetEmailRequest
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ emailSenderService.createTeamInvitation(
+ TeamInvitationCreateServiceRequest.of(targetEmailRequest.email(), teamId, memberId));
+ return SuccessResponse.success(SuccessMessage.SUCCESS_SEND_EMAIL.getMessage());
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/controller/docs/EmailSenderControllerDocs.java b/src/main/java/com/tiki/server/email/emailsender/controller/docs/EmailSenderControllerDocs.java
new file mode 100644
index 00000000..a7d78fc6
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/controller/docs/EmailSenderControllerDocs.java
@@ -0,0 +1,111 @@
+package com.tiki.server.email.emailsender.controller.docs;
+
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.email.emailsender.controller.dto.request.EmailRequest;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import java.security.Principal;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+
+@Tag(name = "EmailVerification", description = "메일 인증 API")
+public interface EmailSenderControllerDocs {
+
+ @Operation(
+ summary = "회원가입 메일 전송",
+ description = "회원 가입을 진행한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "이메일 형식 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "409",
+ description = "이미 가입된 아이디",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse> sendSignUpMail(@RequestBody EmailRequest mailRequest);
+
+ @Operation(
+ summary = "비밀번호 재설정 메일 전송",
+ description = "비밀번호 재설정을 위한 이메일을 보낸다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "이메일 형식 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "가입되지 않은 이메일",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse> sendChangingPasswordMail(@RequestBody EmailRequest mailRequest);
+
+ @Operation(
+ summary = "팀원 초대 메일 전송",
+ description = "팀원 초대를 위한 이메일을 전송한다.",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "이메일 형식 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "400",
+ description = "이미 존재하는 팀원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하는 회원이 아님",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀이 존재하지 않음",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "메일을 설정할 수 없음",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+
+ }
+ )
+ public SuccessResponse> sendInvitationMail(
+ final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @RequestBody final EmailRequest emailRequest);
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/controller/dto/request/EmailRequest.java b/src/main/java/com/tiki/server/email/emailsender/controller/dto/request/EmailRequest.java
new file mode 100644
index 00000000..81f34b95
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/controller/dto/request/EmailRequest.java
@@ -0,0 +1,8 @@
+package com.tiki.server.email.emailsender.controller.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record EmailRequest(
+ @NotNull String email
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/email/emailsender/entity/MailSender.java b/src/main/java/com/tiki/server/email/emailsender/entity/MailSender.java
new file mode 100644
index 00000000..4f61d02e
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/entity/MailSender.java
@@ -0,0 +1,83 @@
+package com.tiki.server.email.emailsender.entity;
+
+import com.tiki.server.email.emailsender.exception.EmailSenderException;
+import com.tiki.server.email.emailsender.message.ErrorCode;
+import jakarta.mail.internet.MimeMessage;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Component;
+import org.thymeleaf.context.Context;
+import org.thymeleaf.spring6.SpringTemplateEngine;
+
+import static com.tiki.server.email.emailsender.constants.Constants.*;
+
+@Component
+@RequiredArgsConstructor
+public class MailSender {
+
+ private final SpringTemplateEngine templateEngine;
+ private final JavaMailSender javaMailSender;
+
+ public void sendVerificationMail(final String email, final String code) {
+ Map map = new HashMap<>();
+ map.put("code", code);
+ MimeMessage message = makeMessage(email, MAIL_VERIFICATION_CODE, VERIFICATION_TEMPLATE_NAME, map);
+ javaMailSender.send(message);
+ }
+
+ public void sendPasswordChangingMail(final String email, final String code, final String name) {
+ Map map = new HashMap<>();
+ map.put("code", code);
+ map.put("name", name);
+ MimeMessage message = makeMessage(email, MAIL_PASSWORD_CHANGING_CODE, CHANGE_PASSWORD_TEMPLATE_NAME, map);
+ javaMailSender.send(message);
+ }
+
+ public void sendTeamInvitationMail(
+ final String email,
+ final String senderName,
+ final String teamName,
+ final long teamId,
+ final long invitationId
+ ) {
+ Map map = new HashMap<>();
+ map.put("teamId", String.format("%d", teamId));
+ map.put("invitationId", String.format("%d", invitationId));
+ map.put("teamName", teamName);
+ map.put("senderName", senderName);
+ MimeMessage message = makeMessage(email, MAIL_INVITE_TEAM_MEMBER, INVITE_TEAM_MEMBER_TEMPLATE_NAME, map);
+ javaMailSender.send(message);
+ }
+
+ private MimeMessage makeMessage(
+ String email,
+ String subject,
+ String templateName,
+ Map map
+ ) {
+ MimeMessage message = javaMailSender.createMimeMessage();
+ try {
+ MimeMessageHelper helper = new MimeMessageHelper(message, true, "utf-8");
+ helper.setFrom(TIKI_EMAIL);
+ helper.setTo(email);
+ helper.setSubject(subject);
+ helper.setText(setContext(map, templateName), true);
+ helper.addInline(PAGE_LOGO_IMAGE_VAR, new ClassPathResource(IMG_PATH));
+ return message;
+ } catch (Exception e) {
+ throw new EmailSenderException(ErrorCode.MESSAGE_HELPER_ERROR);
+ }
+ }
+
+ private String setContext(Map map, String templateName) {
+ Context context = new Context();
+ map.forEach(context::setVariable);
+ return templateEngine.process(templateName, context);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/exception/EmailSenderException.java b/src/main/java/com/tiki/server/email/emailsender/exception/EmailSenderException.java
new file mode 100644
index 00000000..7b338751
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/exception/EmailSenderException.java
@@ -0,0 +1,17 @@
+package com.tiki.server.email.emailsender.exception;
+
+
+import com.tiki.server.email.emailsender.message.ErrorCode;
+import lombok.Getter;
+
+@Getter
+public class EmailSenderException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public EmailSenderException(ErrorCode errorCode) {
+ super("[EmailSenderException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/message/ErrorCode.java b/src/main/java/com/tiki/server/email/emailsender/message/ErrorCode.java
new file mode 100644
index 00000000..4f2d6f3d
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/message/ErrorCode.java
@@ -0,0 +1,17 @@
+package com.tiki.server.email.emailsender.message;
+
+import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+ /* 500 INTERNAL_SERVER_ERROR : 서버 내부 오류 발생 */
+ MESSAGE_HELPER_ERROR(INTERNAL_SERVER_ERROR,"메세지를 설정할 수 없습니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/message/SuccessMessage.java b/src/main/java/com/tiki/server/email/emailsender/message/SuccessMessage.java
new file mode 100644
index 00000000..b0d180a5
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/message/SuccessMessage.java
@@ -0,0 +1,13 @@
+package com.tiki.server.email.emailsender.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ SUCCESS_SEND_EMAIL("이메일 전송 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/service/EmailSenderService.java b/src/main/java/com/tiki/server/email/emailsender/service/EmailSenderService.java
new file mode 100644
index 00000000..c29133c0
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/service/EmailSenderService.java
@@ -0,0 +1,83 @@
+package com.tiki.server.email.emailsender.service;
+
+import static com.tiki.server.memberteammanager.message.ErrorCode.CONFLICT_TEAM_MEMBER;
+
+import com.tiki.server.common.entity.Position;
+import com.tiki.server.email.emailsender.entity.MailSender;
+import com.tiki.server.email.emailsender.service.dto.EmailServiceRequest;
+import com.tiki.server.email.emailsender.service.dto.TeamInvitationCreateServiceRequest;
+import com.tiki.server.email.teaminvitation.adapter.TeamInvitationFinder;
+import com.tiki.server.email.teaminvitation.adapter.TeamInvitationSaver;
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import com.tiki.server.email.verification.adapter.EmailVerificationSaver;
+import com.tiki.server.email.verification.domain.EmailVerification;
+import com.tiki.server.member.adapter.MemberFinder;
+import com.tiki.server.member.entity.Member;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.memberteammanager.exception.MemberTeamManagerException;
+import com.tiki.server.team.adapter.TeamFinder;
+import com.tiki.server.team.entity.Team;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class EmailSenderService {
+
+ private final MemberFinder memberFinder;
+ private final TeamFinder teamFinder;
+ private final MailSender mailSender;
+ private final EmailVerificationSaver emailVerificationSaver;
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+ private final TeamInvitationSaver teamInvitationSaver;
+ private final TeamInvitationFinder teamInvitationFinder;
+
+ public void sendSignUp(final EmailServiceRequest emailServiceRequest) {
+ memberFinder.checkPresent(emailServiceRequest.email());
+ EmailVerification emailVerification = EmailVerification.of(emailServiceRequest.email());
+ mailSender.sendVerificationMail(
+ emailVerification.getId(),
+ emailVerification.getVerificationCode().getCode()
+ );
+ emailVerificationSaver.save(emailVerification);
+ }
+
+ public void sendPasswordChanging(final EmailServiceRequest emailServiceRequest) {
+ Member member = memberFinder.checkEmpty(emailServiceRequest.email());
+ EmailVerification emailVerification = EmailVerification.of(emailServiceRequest.email());
+ mailSender.sendPasswordChangingMail(
+ emailVerification.getId(),
+ emailVerification.getVerificationCode().getCode(),
+ member.getName()
+ );
+ emailVerificationSaver.save(emailVerification);
+ }
+
+ @Transactional
+ public void createTeamInvitation(final TeamInvitationCreateServiceRequest request) {
+ MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(request.senderId(),
+ request.teamId());
+ memberTeamManager.checkMemberAccessible(Position.ADMIN);
+ Team team = teamFinder.findById(request.teamId());
+ checkIsPresentTeamMember(request);
+ TeamInvitation teamInvitation = teamInvitationSaver.createTeamInvitation(
+ TeamInvitation.of(memberTeamManager.getName(), request.teamId(), request.targetEmail()));
+ mailSender.sendTeamInvitationMail(
+ request.targetEmail().getEmail(),
+ memberTeamManager.getName(),
+ team.getName(),
+ request.teamId(),
+ teamInvitation.getId()
+ );
+ }
+
+ private void checkIsPresentTeamMember(final TeamInvitationCreateServiceRequest request) {
+ if (memberTeamManagerFinder.existsByTeamIdAndMemberEmail(request.teamId(), request.targetEmail())) {
+ throw new MemberTeamManagerException(CONFLICT_TEAM_MEMBER);
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/emailsender/service/dto/EmailServiceRequest.java b/src/main/java/com/tiki/server/email/emailsender/service/dto/EmailServiceRequest.java
new file mode 100644
index 00000000..e6936b3f
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/service/dto/EmailServiceRequest.java
@@ -0,0 +1,12 @@
+package com.tiki.server.email.emailsender.service.dto;
+
+import com.tiki.server.email.Email;
+import jakarta.validation.constraints.NotNull;
+
+public record EmailServiceRequest(
+ @NotNull Email email
+) {
+ public static EmailServiceRequest from(final String email) {
+ return new EmailServiceRequest(Email.from(email));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/email/emailsender/service/dto/TeamInvitationCreateServiceRequest.java b/src/main/java/com/tiki/server/email/emailsender/service/dto/TeamInvitationCreateServiceRequest.java
new file mode 100644
index 00000000..c7474879
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/emailsender/service/dto/TeamInvitationCreateServiceRequest.java
@@ -0,0 +1,16 @@
+package com.tiki.server.email.emailsender.service.dto;
+
+import com.tiki.server.email.Email;
+import jakarta.validation.constraints.NotNull;
+
+public record TeamInvitationCreateServiceRequest(
+ @NotNull Email targetEmail,
+ @NotNull long teamId,
+ @NotNull long senderId
+) {
+
+ public static TeamInvitationCreateServiceRequest of(final String targetEmail, final long teamId,
+ final long senderId) {
+ return new TeamInvitationCreateServiceRequest(Email.from(targetEmail), teamId, senderId);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationDeleter.java b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationDeleter.java
new file mode 100644
index 00000000..a35f779c
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationDeleter.java
@@ -0,0 +1,23 @@
+package com.tiki.server.email.teaminvitation.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import com.tiki.server.email.teaminvitation.repository.TeamInvitationRepository;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class TeamInvitationDeleter {
+
+ private final TeamInvitationRepository teamInvitationRepository;
+
+ public void deleteTeamInvitation(final TeamInvitation teamInvitation) {
+ teamInvitationRepository.delete(teamInvitation);
+ }
+
+ public void deleteAll(final List expiredInvitation) {
+ teamInvitationRepository.deleteAll(expiredInvitation);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationFinder.java b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationFinder.java
new file mode 100644
index 00000000..f3edb080
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationFinder.java
@@ -0,0 +1,32 @@
+package com.tiki.server.email.teaminvitation.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.email.teaminvitation.exception.TeamInvitationException;
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import com.tiki.server.email.teaminvitation.repository.TeamInvitationRepository;
+import lombok.RequiredArgsConstructor;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static com.tiki.server.email.teaminvitation.messages.ErrorCode.INVALID_TEAM_INVITATION;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class TeamInvitationFinder {
+
+ private final TeamInvitationRepository teamInvitationRepository;
+
+ public TeamInvitation findByInvitationId(final long invitationId) {
+ return teamInvitationRepository.findById(invitationId)
+ .orElseThrow(() -> new TeamInvitationException(INVALID_TEAM_INVITATION));
+ }
+
+ public List findByExpiredDate(final LocalDate expiredDate) {
+ return teamInvitationRepository.findByExpiredDateBefore(expiredDate);
+ }
+
+ public List findAllByTeamId(final long teamId) {
+ return teamInvitationRepository.findAllByTeamId(teamId);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationSaver.java b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationSaver.java
new file mode 100644
index 00000000..714cd13c
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/adapter/TeamInvitationSaver.java
@@ -0,0 +1,18 @@
+package com.tiki.server.email.teaminvitation.adapter;
+
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import com.tiki.server.email.teaminvitation.repository.TeamInvitationRepository;
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class TeamInvitationSaver {
+
+ private final TeamInvitationRepository teamInvitationRepository;
+
+ public TeamInvitation createTeamInvitation(final TeamInvitation teamInvitation){
+ return teamInvitationRepository.save(teamInvitation);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/controller/TeamInvitationController.java b/src/main/java/com/tiki/server/email/teaminvitation/controller/TeamInvitationController.java
new file mode 100644
index 00000000..0eba6296
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/controller/TeamInvitationController.java
@@ -0,0 +1,76 @@
+package com.tiki.server.email.teaminvitation.controller;
+
+import static com.tiki.server.email.teaminvitation.messages.SuccessMessage.*;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.email.teaminvitation.service.TeamInvitationService;
+import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationEmailsGetResponse;
+import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationInformGetResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.security.Principal;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/team-invitation")
+public class TeamInvitationController {
+
+ private final TeamInvitationService teamInvitationService;
+
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/team/{teamId}")
+ public SuccessResponse getTeamInvitation(
+ final Principal principal,
+ @PathVariable final long teamId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ TeamInvitationEmailsGetResponse response = teamInvitationService.getInvitations(memberId, teamId);
+ return SuccessResponse.success(GET_TEAM_INVITATIONS.getMessage(), response);
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @DeleteMapping("/team/{teamId}")
+ public SuccessResponse> deleteTeamInvitationFromAdmin(
+ final Principal principal,
+ @RequestParam final long invitationId,
+ @PathVariable final long teamId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ teamInvitationService.deleteTeamInvitationFromAdmin(memberId, teamId, invitationId);
+ return SuccessResponse.success(DELETE_TEAM_INVITATION_FROM_ADMIN.getMessage());
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping
+ public SuccessResponse getInvitationInform(
+ @RequestParam final long invitationId
+ ) {
+ TeamInvitationInformGetResponse response = teamInvitationService.getInvitationInform(invitationId);
+ return SuccessResponse.success(GET_TEAM_INVITATION_INFORM.getMessage(), response);
+ }
+
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/team-member")
+ public SuccessResponse> createTeamMemberFromInvitation(
+ Principal principal,
+ @RequestParam final long teamId,
+ @RequestParam final long teamInvitationId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ teamInvitationService.createTeamMemberFromInvitation(memberId, teamId, teamInvitationId);
+ return SuccessResponse.success(CREATE_TEAM_MEMBER_FROM_INVITATION.getMessage());
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @DeleteMapping
+ public SuccessResponse> deleteTeamInvitationFromUser(
+ Principal principal,
+ @RequestParam final long invitationId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ teamInvitationService.deleteTeamInvitation(memberId, invitationId);
+ return SuccessResponse.success(DELETE_TEAM_INVITATION_FROM_USER.getMessage());
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/entity/TeamInvitation.java b/src/main/java/com/tiki/server/email/teaminvitation/entity/TeamInvitation.java
new file mode 100644
index 00000000..68187b59
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/entity/TeamInvitation.java
@@ -0,0 +1,47 @@
+package com.tiki.server.email.teaminvitation.entity;
+
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+import com.tiki.server.common.entity.BaseTime;
+import com.tiki.server.email.Email;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+
+@Getter
+@Entity
+@Builder
+@NoArgsConstructor(access = PROTECTED)
+@AllArgsConstructor(access = PRIVATE)
+public class TeamInvitation extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "invitation_id")
+ private Long id;
+
+ private String sender;
+
+ private long teamId;
+
+ private Email email;
+
+ private LocalDate expiredDate;
+
+ public static TeamInvitation of(final String sender, final long teamId, final Email email) {
+ return TeamInvitation.builder().sender(sender).teamId(teamId).email(email).expiredDate(LocalDate.now()).build();
+ }
+
+ public String getEmailToString(){
+ return email.getEmail();
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/exception/TeamInvitationException.java b/src/main/java/com/tiki/server/email/teaminvitation/exception/TeamInvitationException.java
new file mode 100644
index 00000000..25117f71
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/exception/TeamInvitationException.java
@@ -0,0 +1,15 @@
+package com.tiki.server.email.teaminvitation.exception;
+
+import com.tiki.server.email.teaminvitation.messages.ErrorCode;
+import lombok.Getter;
+
+@Getter
+public class TeamInvitationException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public TeamInvitationException(ErrorCode errorCode) {
+ super("[TeamInvitationException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/messages/ErrorCode.java b/src/main/java/com/tiki/server/email/teaminvitation/messages/ErrorCode.java
new file mode 100644
index 00000000..fbc641fc
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/messages/ErrorCode.java
@@ -0,0 +1,20 @@
+package com.tiki.server.email.teaminvitation.messages;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ ALREADY_INVITED_MEMBER(BAD_REQUEST, "이미 존재하는 팀원입니다."),
+ NOT_MATCHED_MEMBER_INFORM(BAD_REQUEST, "일치하지 않은 초대정보 입니다."),
+ INVALID_TEAM_INVITATION(NOT_FOUND, "존재하지 않거나 만료된 초대정보입니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/messages/SuccessMessage.java b/src/main/java/com/tiki/server/email/teaminvitation/messages/SuccessMessage.java
new file mode 100644
index 00000000..6e08b6f0
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/messages/SuccessMessage.java
@@ -0,0 +1,17 @@
+package com.tiki.server.email.teaminvitation.messages;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ CREATE_TEAM_MEMBER_FROM_INVITATION("팀 가입 성공"),
+ DELETE_TEAM_INVITATION_FROM_ADMIN("초대 취소 성공"),
+ DELETE_TEAM_INVITATION_FROM_USER("초대 거부 성공"),
+ GET_TEAM_INVITATIONS("팀 초대 목록 불러오기 성공"),
+ GET_TEAM_INVITATION_INFORM("초대정보를 불러오기 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/repository/TeamInvitationRepository.java b/src/main/java/com/tiki/server/email/teaminvitation/repository/TeamInvitationRepository.java
new file mode 100644
index 00000000..e49ac1fb
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/repository/TeamInvitationRepository.java
@@ -0,0 +1,14 @@
+package com.tiki.server.email.teaminvitation.repository;
+
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public interface TeamInvitationRepository extends JpaRepository {
+
+ List findByExpiredDateBefore(final LocalDate expiredDate);
+
+ List findAllByTeamId(final long teamId);
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/scheduler/TeamInvitationScheduler.java b/src/main/java/com/tiki/server/email/teaminvitation/scheduler/TeamInvitationScheduler.java
new file mode 100644
index 00000000..18bce060
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/scheduler/TeamInvitationScheduler.java
@@ -0,0 +1,26 @@
+package com.tiki.server.email.teaminvitation.scheduler;
+
+import com.tiki.server.email.teaminvitation.adapter.TeamInvitationDeleter;
+import com.tiki.server.email.teaminvitation.adapter.TeamInvitationFinder;
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@RequiredArgsConstructor
+public class TeamInvitationScheduler {
+
+ private final TeamInvitationDeleter teamInvitationDeleter;
+ private final TeamInvitationFinder teamInvitationFinder;
+
+ @Scheduled(cron = "0 0 0 * * ?")
+ public void cleanUpExpiredInvites() {
+ LocalDate now = LocalDate.now();
+ List expiredInvites = teamInvitationFinder.findByExpiredDate(now);
+ if (!expiredInvites.isEmpty()) {
+ teamInvitationDeleter.deleteAll(expiredInvites);
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/TeamInvitationService.java b/src/main/java/com/tiki/server/email/teaminvitation/service/TeamInvitationService.java
new file mode 100644
index 00000000..1255b74f
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/service/TeamInvitationService.java
@@ -0,0 +1,87 @@
+package com.tiki.server.email.teaminvitation.service;
+
+import com.tiki.server.common.entity.Position;
+import com.tiki.server.email.teaminvitation.exception.TeamInvitationException;
+import com.tiki.server.email.teaminvitation.adapter.TeamInvitationDeleter;
+import com.tiki.server.email.teaminvitation.adapter.TeamInvitationFinder;
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationEmailsGetResponse;
+import com.tiki.server.email.teaminvitation.service.dto.TeamInvitationInformGetResponse;
+import com.tiki.server.member.adapter.MemberFinder;
+import com.tiki.server.member.entity.Member;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerSaver;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.team.adapter.TeamFinder;
+import com.tiki.server.team.entity.Team;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import org.springframework.transaction.annotation.Transactional;
+
+import static com.tiki.server.email.teaminvitation.messages.ErrorCode.ALREADY_INVITED_MEMBER;
+import static com.tiki.server.email.teaminvitation.messages.ErrorCode.NOT_MATCHED_MEMBER_INFORM;
+
+@Service
+@RequiredArgsConstructor
+public class TeamInvitationService {
+
+ private final TeamInvitationDeleter teamInvitationDeleter;
+ private final TeamInvitationFinder teamInvitationFinder;
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+ private final MemberTeamManagerSaver memberTeamManagerSaver;
+ private final TeamFinder teamFinder;
+ private final MemberFinder memberFinder;
+
+ public TeamInvitationInformGetResponse getInvitationInform(final long invitationId) {
+ TeamInvitation invitation = teamInvitationFinder.findByInvitationId(invitationId);
+ Team team = teamFinder.findById(invitation.getTeamId());
+ return TeamInvitationInformGetResponse.of(invitation, team);
+ }
+
+ @Transactional
+ public void createTeamMemberFromInvitation(final long memberId, final long teamId, final long invitationId) {
+ checkIsPresentTeamMember(memberId, teamId);
+ Member member = memberFinder.findById(memberId);
+ Team team = teamFinder.findById(teamId);
+ TeamInvitation invitation = teamInvitationFinder.findByInvitationId(invitationId);
+ checkMemberMatched(invitation, member);
+ memberTeamManagerSaver.save(MemberTeamManager.of(member, team, Position.EXECUTIVE));
+ teamInvitationDeleter.deleteTeamInvitation(invitation);
+ }
+
+ public void deleteTeamInvitationFromAdmin(final long memberId, final long teamId, final long invitationId) {
+ MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ memberTeamManager.checkMemberAccessible(Position.ADMIN);
+ TeamInvitation teamInvitation = teamInvitationFinder.findByInvitationId(invitationId);
+ teamInvitationDeleter.deleteTeamInvitation(teamInvitation);
+ }
+
+ public void deleteTeamInvitation(final long memberId, final long invitationId) {
+ TeamInvitation invitation = teamInvitationFinder.findByInvitationId(invitationId);
+ Member member = memberFinder.findById(memberId);
+ checkMemberMatched(invitation, member);
+ teamInvitationDeleter.deleteTeamInvitation(invitation);
+ }
+
+ @Transactional(readOnly = true)
+ public TeamInvitationEmailsGetResponse getInvitations(final long memberId, final long teamId) {
+ MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ memberTeamManager.checkMemberAccessible(Position.ADMIN);
+ List teamInvitations = teamInvitationFinder.findAllByTeamId(teamId);
+ return TeamInvitationEmailsGetResponse.from(teamInvitations);
+ }
+
+ private void checkMemberMatched(TeamInvitation teamInvitation, Member member) {
+ if (!teamInvitation.getEmail().equals(member.getEmail())) {
+ throw new TeamInvitationException(NOT_MATCHED_MEMBER_INFORM);
+ }
+ }
+
+ private void checkIsPresentTeamMember(long memberId, long teamId) {
+ if (memberTeamManagerFinder.checkIsPresent(memberId, teamId)) {
+ throw new TeamInvitationException(ALREADY_INVITED_MEMBER);
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailGetResponse.java b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailGetResponse.java
new file mode 100644
index 00000000..10a9eb45
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailGetResponse.java
@@ -0,0 +1,13 @@
+package com.tiki.server.email.teaminvitation.service.dto;
+
+import com.tiki.server.email.Email;
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import jakarta.validation.constraints.NotNull;
+
+public record TeamInvitationEmailGetResponse(
+ @NotNull String email
+) {
+ public static TeamInvitationEmailGetResponse from(final TeamInvitation teamInvitation) {
+ return new TeamInvitationEmailGetResponse(teamInvitation.getEmailToString());
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailsGetResponse.java b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailsGetResponse.java
new file mode 100644
index 00000000..7852449d
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationEmailsGetResponse.java
@@ -0,0 +1,17 @@
+package com.tiki.server.email.teaminvitation.service.dto;
+
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+
+public record TeamInvitationEmailsGetResponse(
+ @NotNull List teamInvitationEmailGetResponses
+) {
+ public static TeamInvitationEmailsGetResponse from(final List teamInvitations) {
+ return new TeamInvitationEmailsGetResponse(
+ teamInvitations.stream()
+ .map(TeamInvitationEmailGetResponse::from)
+ .toList());
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationInformGetResponse.java b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationInformGetResponse.java
new file mode 100644
index 00000000..efb38bc3
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/teaminvitation/service/dto/TeamInvitationInformGetResponse.java
@@ -0,0 +1,25 @@
+package com.tiki.server.email.teaminvitation.service.dto;
+
+import com.tiki.server.email.teaminvitation.entity.TeamInvitation;
+import com.tiki.server.team.entity.Team;
+import jakarta.validation.constraints.NotNull;
+
+public record TeamInvitationInformGetResponse(
+ @NotNull String sender,
+ @NotNull String teamName,
+ @NotNull String teamIconUrl,
+ @NotNull long teamId
+) {
+
+ public static TeamInvitationInformGetResponse of(
+ final TeamInvitation invitation,
+ final Team team
+ ) {
+ return new TeamInvitationInformGetResponse(
+ invitation.getSender(),
+ team.getName(),
+ team.getIconImageUrl(),
+ team.getId()
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationFinder.java b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationFinder.java
new file mode 100644
index 00000000..19443e30
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationFinder.java
@@ -0,0 +1,20 @@
+package com.tiki.server.email.verification.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.email.verification.domain.EmailVerification;
+import com.tiki.server.email.verification.exception.EmailVerificationException;
+import com.tiki.server.email.verification.repository.EmailVerificationRepository;
+import lombok.RequiredArgsConstructor;
+
+import static com.tiki.server.email.verification.message.ErrorCode.INVALID_REQUEST;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class EmailVerificationFinder {
+
+ private final EmailVerificationRepository mailRepository;
+
+ public EmailVerification findById(final String email) {
+ return mailRepository.findById(email).orElseThrow(() -> new EmailVerificationException(INVALID_REQUEST));
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationSaver.java b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationSaver.java
new file mode 100644
index 00000000..185297e1
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/adapter/EmailVerificationSaver.java
@@ -0,0 +1,17 @@
+package com.tiki.server.email.verification.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.email.verification.domain.EmailVerification;
+import com.tiki.server.email.verification.repository.EmailVerificationRepository;
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class EmailVerificationSaver {
+
+ private final EmailVerificationRepository mailRepository;
+
+ public void save(final EmailVerification mail) {
+ mailRepository.save(mail);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/verification/constants/Constants.java b/src/main/java/com/tiki/server/email/verification/constants/Constants.java
new file mode 100644
index 00000000..c22e3ca8
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/constants/Constants.java
@@ -0,0 +1,7 @@
+package com.tiki.server.email.verification.constants;
+
+public class Constants {
+
+ public static final int CODE_LENGTH = 6;
+ public static final int CODE_NUM_MAX_VALUE_PER_WORD = 10;
+}
diff --git a/src/main/java/com/tiki/server/email/verification/controller/EmailVerificationController.java b/src/main/java/com/tiki/server/email/verification/controller/EmailVerificationController.java
new file mode 100644
index 00000000..86712204
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/controller/EmailVerificationController.java
@@ -0,0 +1,29 @@
+package com.tiki.server.email.verification.controller;
+
+import static com.tiki.server.email.verification.message.SuccessMessage.SUCCESS_VALIDATION;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.email.verification.dto.request.CodeVerificationRequest;
+import com.tiki.server.email.verification.service.EmailVerificationService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/email/verification")
+public class EmailVerificationController {
+
+ private final EmailVerificationService emailVerificationService;
+
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/checking")
+ public SuccessResponse> checkCode(@RequestBody CodeVerificationRequest codeVerificationRequest) {
+ emailVerificationService.checkCode(codeVerificationRequest);
+ return SuccessResponse.success(SUCCESS_VALIDATION.getMessage());
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/verification/controller/docs/EmailSenderControllerDocs.java b/src/main/java/com/tiki/server/email/verification/controller/docs/EmailSenderControllerDocs.java
new file mode 100644
index 00000000..a4b14b4e
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/controller/docs/EmailSenderControllerDocs.java
@@ -0,0 +1,44 @@
+package com.tiki.server.email.verification.controller.docs;
+
+import com.tiki.server.common.dto.BaseResponse;
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.email.verification.dto.request.CodeVerificationRequest;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestBody;
+
+@Tag(name = "EmailVerification", description = "메일 인증 API")
+public interface EmailSenderControllerDocs {
+
+ @Operation(
+ summary = "메일 인증",
+ description = "인증번호 확인",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "이메일 형식 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "403",
+ description = "인증 값 불일치",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "인증 정보가 존재하지 않음",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ ResponseEntity checkCode(@RequestBody CodeVerificationRequest verificationCodeRequest);
+}
diff --git a/src/main/java/com/tiki/server/email/verification/domain/EmailVerification.java b/src/main/java/com/tiki/server/email/verification/domain/EmailVerification.java
new file mode 100644
index 00000000..b448b9d5
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/domain/EmailVerification.java
@@ -0,0 +1,36 @@
+package com.tiki.server.email.verification.domain;
+
+import com.tiki.server.email.Email;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.data.redis.core.RedisHash;
+
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+
+@Getter
+@AllArgsConstructor(access = PRIVATE)
+@Builder
+@RedisHash(value = "mailVerification", timeToLive = 180)
+public class EmailVerification {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ private String id;
+
+ private VerificationCode verificationCode;
+
+ public static EmailVerification of(Email email) {
+ return EmailVerification.builder()
+ .id(email.getEmail())
+ .verificationCode(VerificationCode.from())
+ .build();
+ }
+
+ public void verify(String code) {
+ this.verificationCode.verify(code);
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/verification/domain/VerificationCode.java b/src/main/java/com/tiki/server/email/verification/domain/VerificationCode.java
new file mode 100644
index 00000000..a1cb8891
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/domain/VerificationCode.java
@@ -0,0 +1,44 @@
+package com.tiki.server.email.verification.domain;
+
+import static com.tiki.server.common.constants.Constants.INIT_NUM;
+import static com.tiki.server.email.verification.constants.Constants.CODE_LENGTH;
+import static com.tiki.server.email.verification.constants.Constants.CODE_NUM_MAX_VALUE_PER_WORD;
+
+import com.tiki.server.email.verification.exception.EmailVerificationException;
+import com.tiki.server.email.verification.message.ErrorCode;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import java.util.Random;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Embeddable
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class VerificationCode {
+
+ @Column(nullable = false)
+ private String code;
+
+ public static VerificationCode from() {
+ return new VerificationCode(generateRandomValue());
+ }
+
+ protected void verify(String code) {
+ if (!this.code.equals(code)) {
+ throw new EmailVerificationException(ErrorCode.INVALID_MATCHED);
+ }
+ }
+
+ private static String generateRandomValue() {
+ Random random = new Random();
+ return IntStream.range(INIT_NUM, CODE_LENGTH)
+ .mapToObj(i -> String.valueOf(random.nextInt(CODE_NUM_MAX_VALUE_PER_WORD))).collect(
+ Collectors.joining());
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/verification/dto/request/CodeVerificationRequest.java b/src/main/java/com/tiki/server/email/verification/dto/request/CodeVerificationRequest.java
new file mode 100644
index 00000000..a09b2050
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/dto/request/CodeVerificationRequest.java
@@ -0,0 +1,9 @@
+package com.tiki.server.email.verification.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record CodeVerificationRequest(
+ @NotNull String email,
+ @NotNull String code
+) {
+}
diff --git a/src/main/java/com/tiki/server/email/verification/exception/EmailVerificationException.java b/src/main/java/com/tiki/server/email/verification/exception/EmailVerificationException.java
new file mode 100644
index 00000000..b84c49a2
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/exception/EmailVerificationException.java
@@ -0,0 +1,15 @@
+package com.tiki.server.email.verification.exception;
+
+import com.tiki.server.email.verification.message.ErrorCode;
+import lombok.Getter;
+
+@Getter
+public class EmailVerificationException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public EmailVerificationException(final ErrorCode errorCode) {
+ super("[EmailVerificationException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/email/verification/message/ErrorCode.java b/src/main/java/com/tiki/server/email/verification/message/ErrorCode.java
new file mode 100644
index 00000000..7a720cfb
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/message/ErrorCode.java
@@ -0,0 +1,21 @@
+package com.tiki.server.email.verification.message;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+import static org.springframework.http.HttpStatus.*;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ /* 403 FORBIDDEN : 권한 없음 */
+ INVALID_MATCHED(FORBIDDEN, "인증 정보가 일치하지 않습니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_REQUEST(NOT_FOUND, "인증 정보가 존재하지 않습니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/email/verification/message/SuccessMessage.java b/src/main/java/com/tiki/server/email/verification/message/SuccessMessage.java
new file mode 100644
index 00000000..eddfa8d5
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/message/SuccessMessage.java
@@ -0,0 +1,13 @@
+package com.tiki.server.email.verification.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ SUCCESS_VALIDATION("인증 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/email/verification/repository/EmailVerificationRepository.java b/src/main/java/com/tiki/server/email/verification/repository/EmailVerificationRepository.java
new file mode 100644
index 00000000..8d3cae9c
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/repository/EmailVerificationRepository.java
@@ -0,0 +1,13 @@
+package com.tiki.server.email.verification.repository;
+
+import com.tiki.server.email.verification.domain.EmailVerification;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface EmailVerificationRepository extends CrudRepository {
+
+ Optional findById(final String email);
+}
diff --git a/src/main/java/com/tiki/server/email/verification/service/EmailVerificationService.java b/src/main/java/com/tiki/server/email/verification/service/EmailVerificationService.java
new file mode 100644
index 00000000..ed89bffa
--- /dev/null
+++ b/src/main/java/com/tiki/server/email/verification/service/EmailVerificationService.java
@@ -0,0 +1,25 @@
+package com.tiki.server.email.verification.service;
+
+import com.tiki.server.email.verification.adapter.EmailVerificationFinder;
+import com.tiki.server.email.verification.domain.EmailVerification;
+import com.tiki.server.email.verification.dto.request.CodeVerificationRequest;
+import com.tiki.server.email.Email;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class EmailVerificationService {
+
+ private final EmailVerificationFinder emailVerificationFinder;
+
+ public void checkCode(CodeVerificationRequest codeVerificationRequest) {
+ Email email = Email.from(codeVerificationRequest.email());
+ EmailVerification emailVerification = emailVerificationFinder.findById(email.getEmail());
+ emailVerification.verify(codeVerificationRequest.code());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/external/config/AWSConfig.java b/src/main/java/com/tiki/server/external/config/AWSConfig.java
new file mode 100644
index 00000000..c68382da
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/config/AWSConfig.java
@@ -0,0 +1,55 @@
+package com.tiki.server.external.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import lombok.RequiredArgsConstructor;
+import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+
+@Configuration
+@RequiredArgsConstructor
+public class AWSConfig {
+
+ private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId";
+ private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey";
+
+ @Value("${aws-property.access-key}")
+ private String accessKey;
+
+ @Value("${aws-property.secret-key}")
+ private String secretKey;
+
+ @Value("${aws-property.aws-region}")
+ private String region;
+
+ @Bean
+ public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() {
+ System.setProperty(AWS_ACCESS_KEY_ID, accessKey);
+ System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey);
+ return SystemPropertyCredentialsProvider.create();
+ }
+
+ @Bean
+ public S3Client getS3Client() {
+ return S3Client.builder()
+ .region(getRegion())
+ .credentialsProvider(systemPropertyCredentialsProvider())
+ .build();
+ }
+
+ @Bean
+ public S3Presigner getS3PreSigner() {
+ return S3Presigner.builder()
+ .region(getRegion())
+ .credentialsProvider(systemPropertyCredentialsProvider())
+ .build();
+ }
+
+ private Region getRegion() {
+ return Region.of(region);
+ }
+}
diff --git a/src/main/java/com/tiki/server/external/constant/ExternalConstant.java b/src/main/java/com/tiki/server/external/constant/ExternalConstant.java
new file mode 100644
index 00000000..b8a74733
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/constant/ExternalConstant.java
@@ -0,0 +1,8 @@
+package com.tiki.server.external.constant;
+
+public class ExternalConstant {
+
+ public static final Long PRE_SIGNED_URL_EXPIRE_MINUTE = 10L;
+ public static final String FILE_SAVE_PREFIX = "file/";
+ public static final String FILE_DELIMITER = ".";
+}
diff --git a/src/main/java/com/tiki/server/external/controller/FileHandlerController.java b/src/main/java/com/tiki/server/external/controller/FileHandlerController.java
new file mode 100644
index 00000000..78281252
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/controller/FileHandlerController.java
@@ -0,0 +1,47 @@
+package com.tiki.server.external.controller;
+
+import static com.tiki.server.external.message.SuccessMessage.PRESIGNED_URL_GET_SUCCESS;
+import static com.tiki.server.external.message.SuccessMessage.S3_FILE_DELETE_SUCCESS;
+
+import com.tiki.server.external.service.FileHandlerService;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.external.controller.docs.FileHandlerControllerDocs;
+import com.tiki.server.external.dto.request.S3DeleteRequest;
+import com.tiki.server.external.dto.response.PreSignedUrlResponse;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1/file")
+public class FileHandlerController implements FileHandlerControllerDocs {
+
+ private final FileHandlerService fileHandlerService;
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/upload")
+ public SuccessResponse getPreSignedUrl(
+ @RequestParam final String fileFormat) {
+ PreSignedUrlResponse response = fileHandlerService.getUploadPreSignedUrl(fileFormat);
+ return SuccessResponse.success(PRESIGNED_URL_GET_SUCCESS.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @PostMapping
+ public SuccessResponse> deleteFile(@RequestBody final S3DeleteRequest request) {
+ fileHandlerService.deleteFile(request);
+ return SuccessResponse.success(S3_FILE_DELETE_SUCCESS.getMessage());
+ }
+}
diff --git a/src/main/java/com/tiki/server/external/controller/docs/FileHandlerControllerDocs.java b/src/main/java/com/tiki/server/external/controller/docs/FileHandlerControllerDocs.java
new file mode 100644
index 00000000..c6c1e387
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/controller/docs/FileHandlerControllerDocs.java
@@ -0,0 +1,62 @@
+package com.tiki.server.external.controller.docs;
+
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.external.dto.request.S3DeleteRequest;
+import com.tiki.server.external.dto.response.PreSignedUrlResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "S3", description = "AWS S3 API")
+public interface FileHandlerControllerDocs {
+
+ @Operation(
+ summary = "Presigned Url 생성",
+ description = "s3로부터 Presigned Url을 생성한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "S3 PRESIGNED URL 불러오기 실패",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse getPreSignedUrl(
+ @Parameter(
+ name = "fileFormat",
+ description = "파일 형식",
+ in = ParameterIn.QUERY,
+ example = "hwp, pdf, ..."
+ ) @RequestParam final String fileFormat
+ );
+
+ @Operation(
+ summary = "s3 파일 삭제",
+ description = "s3의 파일 삭제한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "S3 버킷의 파일 삭제 실패",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse> deleteFile(
+ @RequestBody final S3DeleteRequest request
+ );
+}
diff --git a/src/main/java/com/tiki/server/external/dto/request/PreSignedUrlRequest.java b/src/main/java/com/tiki/server/external/dto/request/PreSignedUrlRequest.java
new file mode 100644
index 00000000..cde7684c
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/dto/request/PreSignedUrlRequest.java
@@ -0,0 +1,8 @@
+package com.tiki.server.external.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record PreSignedUrlRequest(
+ @NotNull String fileFormat
+) {
+}
diff --git a/src/main/java/com/tiki/server/external/dto/request/S3DeleteRequest.java b/src/main/java/com/tiki/server/external/dto/request/S3DeleteRequest.java
new file mode 100644
index 00000000..72fcf0bc
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/dto/request/S3DeleteRequest.java
@@ -0,0 +1,8 @@
+package com.tiki.server.external.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record S3DeleteRequest(
+ @NotNull String fileKey
+) {
+}
diff --git a/src/main/java/com/tiki/server/external/dto/response/PreSignedUrlResponse.java b/src/main/java/com/tiki/server/external/dto/response/PreSignedUrlResponse.java
new file mode 100644
index 00000000..8cc6c9ef
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/dto/response/PreSignedUrlResponse.java
@@ -0,0 +1,20 @@
+package com.tiki.server.external.dto.response;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record PreSignedUrlResponse(
+ @NotNull String fileName,
+ @NotNull String url
+) {
+
+ public static PreSignedUrlResponse of(final String fileName, final String url) {
+ return PreSignedUrlResponse.builder()
+ .fileName(fileName)
+ .url(url)
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/external/exception/ExternalException.java b/src/main/java/com/tiki/server/external/exception/ExternalException.java
new file mode 100644
index 00000000..72fb7fcc
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/exception/ExternalException.java
@@ -0,0 +1,16 @@
+package com.tiki.server.external.exception;
+
+import com.tiki.server.external.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class ExternalException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public ExternalException(final ErrorCode errorCode) {
+ super("[ExternalException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/external/message/ErrorCode.java b/src/main/java/com/tiki/server/external/message/ErrorCode.java
new file mode 100644
index 00000000..6dd0199f
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/message/ErrorCode.java
@@ -0,0 +1,20 @@
+package com.tiki.server.external.message;
+
+import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ /* 500 INTERNAL_SERVER_ERROR : 서버 내부 오류 발생 */
+ PRESIGNED_URL_GET_ERROR(INTERNAL_SERVER_ERROR, "S3 PRESIGNED URL 불러오기에 실패했습니다."),
+ FILE_DELETE_ERROR(INTERNAL_SERVER_ERROR, "S3 버킷의 파일 삭제에 실패했습니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/external/message/SuccessMessage.java b/src/main/java/com/tiki/server/external/message/SuccessMessage.java
new file mode 100644
index 00000000..42828b46
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/message/SuccessMessage.java
@@ -0,0 +1,14 @@
+package com.tiki.server.external.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ PRESIGNED_URL_GET_SUCCESS("S3 PRESIGNED URL 불러오기 성공"),
+ S3_FILE_DELETE_SUCCESS("S3 파일 삭제 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/external/service/FileHandlerService.java b/src/main/java/com/tiki/server/external/service/FileHandlerService.java
new file mode 100644
index 00000000..ab227dbc
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/service/FileHandlerService.java
@@ -0,0 +1,22 @@
+package com.tiki.server.external.service;
+
+import com.tiki.server.external.dto.request.S3DeleteRequest;
+import com.tiki.server.external.dto.response.PreSignedUrlResponse;
+import com.tiki.server.external.util.AwsHandler;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class FileHandlerService {
+
+ private final AwsHandler awsHandler;
+
+ public PreSignedUrlResponse getUploadPreSignedUrl(final String fileFormat) {
+ return awsHandler.getUploadPreSignedUrl(fileFormat);
+ }
+
+ public void deleteFile(final S3DeleteRequest request) {
+ awsHandler.deleteFile(request.fileKey());
+ }
+}
diff --git a/src/main/java/com/tiki/server/external/util/AwsHandler.java b/src/main/java/com/tiki/server/external/util/AwsHandler.java
new file mode 100644
index 00000000..8033fc30
--- /dev/null
+++ b/src/main/java/com/tiki/server/external/util/AwsHandler.java
@@ -0,0 +1,78 @@
+package com.tiki.server.external.util;
+
+import static com.tiki.server.external.constant.ExternalConstant.FILE_SAVE_PREFIX;
+import static com.tiki.server.external.constant.ExternalConstant.PRE_SIGNED_URL_EXPIRE_MINUTE;
+import static com.tiki.server.external.message.ErrorCode.*;
+import static com.tiki.server.external.constant.ExternalConstant.FILE_DELIMITER;
+
+import java.time.Duration;
+import java.util.UUID;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import com.tiki.server.external.config.AWSConfig;
+import com.tiki.server.external.dto.response.PreSignedUrlResponse;
+import com.tiki.server.external.exception.ExternalException;
+
+import lombok.RequiredArgsConstructor;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
+
+@Component
+@RequiredArgsConstructor
+public class AwsHandler {
+
+ private final AWSConfig awsConfig;
+
+ @Value("${aws-property.bucket}")
+ private String bucket;
+
+ public PreSignedUrlResponse getUploadPreSignedUrl(final String fileFormat) {
+ try {
+ String fileName = generateFileName(fileFormat);
+ String key = FILE_SAVE_PREFIX + fileName;
+ S3Presigner preSigner = awsConfig.getS3PreSigner();
+ PutObjectRequest putObjectRequest = createPutObjectRequest(key);
+ PutObjectPresignRequest putObjectPresignRequest = createPutObjectPresignRequest(putObjectRequest);
+ String url = preSigner.presignPutObject(putObjectPresignRequest).url().toString();
+ return PreSignedUrlResponse.of(fileName, url);
+ } catch (RuntimeException e) {
+ throw new ExternalException(PRESIGNED_URL_GET_ERROR);
+ }
+ }
+
+ public void deleteFile(final String request) {
+ try {
+ S3Client s3Client = awsConfig.getS3Client();
+ s3Client.deleteObject((DeleteObjectRequest.Builder builder) ->
+ builder.bucket(bucket)
+ .key(request)
+ .build()
+ );
+ } catch (RuntimeException e) {
+ throw new ExternalException(FILE_DELETE_ERROR);
+ }
+ }
+
+ private PutObjectRequest createPutObjectRequest(final String key) {
+ return PutObjectRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .build();
+ }
+
+ private PutObjectPresignRequest createPutObjectPresignRequest(final PutObjectRequest putObjectRequest) {
+ return PutObjectPresignRequest.builder()
+ .signatureDuration(Duration.ofMinutes(PRE_SIGNED_URL_EXPIRE_MINUTE))
+ .putObjectRequest(putObjectRequest)
+ .build();
+ }
+
+ private String generateFileName(final String fileFormat) {
+ return UUID.randomUUID() + FILE_DELIMITER + fileFormat;
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/adapter/FolderDeleter.java b/src/main/java/com/tiki/server/folder/adapter/FolderDeleter.java
new file mode 100644
index 00000000..aecb7c68
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/adapter/FolderDeleter.java
@@ -0,0 +1,20 @@
+package com.tiki.server.folder.adapter;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.folder.entity.Folder;
+import com.tiki.server.folder.repository.FolderRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class FolderDeleter {
+
+ private final FolderRepository folderRepository;
+
+ public void deleteAll(final List folders) {
+ folderRepository.deleteAll(folders);
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/adapter/FolderFinder.java b/src/main/java/com/tiki/server/folder/adapter/FolderFinder.java
new file mode 100644
index 00000000..5322af1c
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/adapter/FolderFinder.java
@@ -0,0 +1,47 @@
+package com.tiki.server.folder.adapter;
+
+import static com.tiki.server.folder.constant.Constant.ROOT_PATH;
+import static com.tiki.server.folder.message.ErrorCode.INVALID_FOLDER;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.folder.entity.Folder;
+import com.tiki.server.folder.exception.FolderException;
+import com.tiki.server.folder.repository.FolderRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class FolderFinder {
+
+ private final FolderRepository folderRepository;
+
+ public Folder findById(final long id) {
+ return folderRepository.findById(id)
+ .orElseThrow(() -> new FolderException(INVALID_FOLDER));
+ }
+
+ public List findByTeamIdAndPath(final long teamId, final String path) {
+ if (path.equals(ROOT_PATH)) {
+ return folderRepository.findAllByTeamIdAndPathOrderByCreatedAtDesc(teamId, path);
+ }
+ return folderRepository.findAllByPathOrderByCreatedAtDesc(path);
+ }
+
+ public List findAllById(final List folderIds, final long teamId) {
+ return folderIds.stream()
+ .map(id -> findByIdAndTeamId(id, teamId))
+ .toList();
+ }
+
+ public List findAllStartWithPath(final String path) {
+ return folderRepository.findAllByPathStartsWith(path);
+ }
+
+ private Folder findByIdAndTeamId(final long id, final long teamId) {
+ return folderRepository.findByIdAndTeamId(id, teamId)
+ .orElseThrow(() -> new FolderException(INVALID_FOLDER));
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/adapter/FolderSaver.java b/src/main/java/com/tiki/server/folder/adapter/FolderSaver.java
new file mode 100644
index 00000000..f2a6b28b
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/adapter/FolderSaver.java
@@ -0,0 +1,18 @@
+package com.tiki.server.folder.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.folder.entity.Folder;
+import com.tiki.server.folder.repository.FolderRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class FolderSaver {
+
+ private final FolderRepository folderRepository;
+
+ public Folder save(final Folder folder) {
+ return folderRepository.save(folder);
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/constant/Constant.java b/src/main/java/com/tiki/server/folder/constant/Constant.java
new file mode 100644
index 00000000..e3f536e5
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/constant/Constant.java
@@ -0,0 +1,7 @@
+package com.tiki.server.folder.constant;
+
+public class Constant {
+
+ public static final String ROOT_PATH = "";
+ public static final String SEPARATOR = "/";
+}
diff --git a/src/main/java/com/tiki/server/folder/controller/FolderController.java b/src/main/java/com/tiki/server/folder/controller/FolderController.java
new file mode 100644
index 00000000..1de9f8f9
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/controller/FolderController.java
@@ -0,0 +1,91 @@
+package com.tiki.server.folder.controller;
+
+import static com.tiki.server.folder.message.SuccessMessage.SUCCESS_CREATE_FOLDER;
+import static com.tiki.server.folder.message.SuccessMessage.SUCCESS_GET_FOLDERS;
+import static com.tiki.server.folder.message.SuccessMessage.SUCCESS_UPDATE_FOLDER_NAME;
+
+import java.security.Principal;
+import java.util.List;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.folder.controller.docs.FolderControllerDocs;
+import com.tiki.server.folder.dto.request.FolderCreateRequest;
+import com.tiki.server.folder.dto.request.FolderNameUpdateRequest;
+import com.tiki.server.folder.dto.response.FolderCreateResponse;
+import com.tiki.server.folder.dto.response.FoldersGetResponse;
+import com.tiki.server.folder.service.FolderService;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1")
+public class FolderController implements FolderControllerDocs {
+
+ private final FolderService folderService;
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/teams/{teamId}/folders")
+ public SuccessResponse getFolders(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam(required = false) final Long folderId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ FoldersGetResponse response = folderService.get(memberId, teamId, folderId);
+ return SuccessResponse.success(SUCCESS_GET_FOLDERS.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/teams/{teamId}/folders")
+ public SuccessResponse createFolder(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam(required = false) final Long folderId,
+ @RequestBody final FolderCreateRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ FolderCreateResponse response = folderService.create(memberId, teamId, folderId, request);
+ return SuccessResponse.success(SUCCESS_CREATE_FOLDER.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @PatchMapping("/teams/{teamId}/folders/{folderId}")
+ public SuccessResponse> updateFolderName(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @PathVariable final long folderId,
+ @RequestBody final FolderNameUpdateRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ folderService.updateFolderName(memberId, teamId, folderId, request);
+ return SuccessResponse.success(SUCCESS_UPDATE_FOLDER_NAME.getMessage());
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @DeleteMapping("/teams/{teamId}/folders")
+ public void delete(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam("folderId") final List folderIds
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ folderService.delete(memberId, teamId, folderIds);
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/controller/docs/FolderControllerDocs.java b/src/main/java/com/tiki/server/folder/controller/docs/FolderControllerDocs.java
new file mode 100644
index 00000000..35441cee
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/controller/docs/FolderControllerDocs.java
@@ -0,0 +1,154 @@
+package com.tiki.server.folder.controller.docs;
+
+import java.security.Principal;
+import java.util.List;
+
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.folder.dto.request.FolderCreateRequest;
+import com.tiki.server.folder.dto.request.FolderNameUpdateRequest;
+import com.tiki.server.folder.dto.response.FolderCreateResponse;
+import com.tiki.server.folder.dto.response.FoldersGetResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "folders", description = "폴더 API")
+public interface FolderControllerDocs {
+
+ @Operation(
+ summary = "폴더 조회",
+ description = "폴더를 여러 개 조회한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse getFolders(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ required = true,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "folderId",
+ description = "조회할 폴더 id (최상단은 비워두기)",
+ in = ParameterIn.QUERY,
+ example = "1"
+ ) @RequestParam final Long folderId
+ );
+
+ @Operation(
+ summary = "폴더 생성",
+ description = "폴더를 생성한다.",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse createFolder(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ required = true,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "folderId",
+ description = "생성할 폴더가 속할 폴더 id (최상단은 비워두기)",
+ in = ParameterIn.QUERY,
+ example = "1"
+ ) @RequestParam final Long folderId,
+ @RequestBody final FolderCreateRequest request
+ );
+
+ @Operation(
+ summary = "폴더 이름 수정",
+ description = "폴더 이름을 수정한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse> updateFolderName(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ required = true,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "folderId",
+ description = "수정할 폴더 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long folderId,
+ @RequestBody final FolderNameUpdateRequest request
+ );
+
+ @Operation(
+ summary = "폴더 삭제",
+ description = "폴더를 여러 개 삭제한다.",
+ responses = {
+ @ApiResponse(responseCode = "204", description = "성공"),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ void delete(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ required = true,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "folderId",
+ description = "삭제할 폴더 id 리스트",
+ in = ParameterIn.QUERY,
+ required = true,
+ example = "[1, 2]"
+ ) @RequestParam("folderId") final List folderIds
+ );
+}
diff --git a/src/main/java/com/tiki/server/folder/dto/request/FolderCreateRequest.java b/src/main/java/com/tiki/server/folder/dto/request/FolderCreateRequest.java
new file mode 100644
index 00000000..fcc85eb4
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/dto/request/FolderCreateRequest.java
@@ -0,0 +1,10 @@
+package com.tiki.server.folder.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+
+public record FolderCreateRequest(
+ @Schema(description = "폴더 이름", example = "폴더 1")
+ @NotNull String name
+) {
+}
diff --git a/src/main/java/com/tiki/server/folder/dto/request/FolderNameUpdateRequest.java b/src/main/java/com/tiki/server/folder/dto/request/FolderNameUpdateRequest.java
new file mode 100644
index 00000000..d4f2575e
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/dto/request/FolderNameUpdateRequest.java
@@ -0,0 +1,10 @@
+package com.tiki.server.folder.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+
+public record FolderNameUpdateRequest(
+ @Schema(description = "수정할 폴더 이름", example = "수정할 폴더 1")
+ @NotNull String name
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/folder/dto/response/FolderCreateResponse.java b/src/main/java/com/tiki/server/folder/dto/response/FolderCreateResponse.java
new file mode 100644
index 00000000..ea6dcaee
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/dto/response/FolderCreateResponse.java
@@ -0,0 +1,18 @@
+package com.tiki.server.folder.dto.response;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record FolderCreateResponse(
+ @NotNull long folderId
+) {
+
+ public static FolderCreateResponse from(final long folderId) {
+ return FolderCreateResponse.builder()
+ .folderId(folderId)
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/dto/response/FoldersGetResponse.java b/src/main/java/com/tiki/server/folder/dto/response/FoldersGetResponse.java
new file mode 100644
index 00000000..30211bcd
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/dto/response/FoldersGetResponse.java
@@ -0,0 +1,41 @@
+package com.tiki.server.folder.dto.response;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import com.tiki.server.folder.entity.Folder;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+@Builder(access = PRIVATE)
+public record FoldersGetResponse(
+ @NotNull List folders
+) {
+
+ public static FoldersGetResponse from(final List folders) {
+ return FoldersGetResponse.builder()
+ .folders(folders.stream().map(FolderInfoGetResponse::from).toList())
+ .build();
+ }
+
+ @Builder(access = PRIVATE)
+ private record FolderInfoGetResponse(
+ @NotNull long id,
+ @NotNull String name,
+ @NotNull LocalDateTime createdTime,
+ @NotNull String path
+ ) {
+
+ private static FolderInfoGetResponse from(final Folder folder) {
+ return FolderInfoGetResponse.builder()
+ .id(folder.getId())
+ .name(folder.getName())
+ .createdTime(folder.getCreatedAt())
+ .path(folder.getPath())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/entity/Folder.java b/src/main/java/com/tiki/server/folder/entity/Folder.java
new file mode 100644
index 00000000..68daded7
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/entity/Folder.java
@@ -0,0 +1,62 @@
+package com.tiki.server.folder.entity;
+
+import static com.tiki.server.document.message.ErrorCode.INVALID_AUTHORIZATION;
+import static com.tiki.server.folder.constant.Constant.ROOT_PATH;
+import static com.tiki.server.folder.constant.Constant.SEPARATOR;
+import static jakarta.persistence.GenerationType.IDENTITY;
+
+import com.tiki.server.common.entity.BaseTime;
+import com.tiki.server.document.exception.DocumentException;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor
+public class Folder extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ private String path;
+
+ @Column(nullable = false)
+ private long teamId;
+
+ public Folder(final String name, final Folder parentFolder, final long teamId) {
+ this.name = name;
+ this.path = generatePath(parentFolder);
+ this.teamId = teamId;
+ }
+
+ public void validateTeamId(final long teamId) {
+ if (this.teamId != teamId) {
+ throw new DocumentException(INVALID_AUTHORIZATION);
+ }
+ }
+
+ public String getChildPath() {
+ return path + SEPARATOR + id;
+ }
+
+ public void updateName(final String name) {
+ this.name = name;
+ }
+
+ private String generatePath(final Folder parentFolder) {
+ if (parentFolder == null) {
+ return ROOT_PATH;
+ }
+ return parentFolder.getPath() + SEPARATOR + parentFolder.getId();
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/exception/FolderException.java b/src/main/java/com/tiki/server/folder/exception/FolderException.java
new file mode 100644
index 00000000..908a40e0
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/exception/FolderException.java
@@ -0,0 +1,16 @@
+package com.tiki.server.folder.exception;
+
+import com.tiki.server.folder.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class FolderException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public FolderException(final ErrorCode errorCode) {
+ super("[FolderException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/folder/message/ErrorCode.java b/src/main/java/com/tiki/server/folder/message/ErrorCode.java
new file mode 100644
index 00000000..57bd9afe
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/message/ErrorCode.java
@@ -0,0 +1,23 @@
+package com.tiki.server.folder.message;
+
+import static org.springframework.http.HttpStatus.CONFLICT;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_FOLDER(NOT_FOUND, "유효하지 않은 폴더입니다."),
+
+ /* 409 CONFLICT : 중복된 자원 */
+ FOLDER_NAME_DUPLICATE(CONFLICT, "중복된 폴더 이름입니다.");;
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/folder/message/SuccessMessage.java b/src/main/java/com/tiki/server/folder/message/SuccessMessage.java
new file mode 100644
index 00000000..5697f97c
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/message/SuccessMessage.java
@@ -0,0 +1,15 @@
+package com.tiki.server.folder.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ SUCCESS_GET_FOLDERS("폴더 목록 조회 성공"),
+ SUCCESS_CREATE_FOLDER("폴더 생성 성공"),
+ SUCCESS_UPDATE_FOLDER_NAME("폴더 이름 수정 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/folder/repository/FolderRepository.java b/src/main/java/com/tiki/server/folder/repository/FolderRepository.java
new file mode 100644
index 00000000..8b184542
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/repository/FolderRepository.java
@@ -0,0 +1,19 @@
+package com.tiki.server.folder.repository;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.tiki.server.folder.entity.Folder;
+
+public interface FolderRepository extends JpaRepository {
+
+ List findAllByTeamIdAndPathOrderByCreatedAtDesc(final long teamId, final String path);
+
+ List findAllByPathOrderByCreatedAtDesc(final String path);
+
+ Optional findByIdAndTeamId(final long id, final long teamId);
+
+ List findAllByPathStartsWith(final String path);
+}
diff --git a/src/main/java/com/tiki/server/folder/service/FolderService.java b/src/main/java/com/tiki/server/folder/service/FolderService.java
new file mode 100644
index 00000000..bdb1efb2
--- /dev/null
+++ b/src/main/java/com/tiki/server/folder/service/FolderService.java
@@ -0,0 +1,118 @@
+package com.tiki.server.folder.service;
+
+import static com.tiki.server.folder.constant.Constant.ROOT_PATH;
+import static com.tiki.server.folder.message.ErrorCode.FOLDER_NAME_DUPLICATE;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.tiki.server.document.adapter.DeletedDocumentAdapter;
+import com.tiki.server.document.adapter.DocumentDeleter;
+import com.tiki.server.document.adapter.DocumentFinder;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.folder.adapter.FolderDeleter;
+import com.tiki.server.folder.adapter.FolderFinder;
+import com.tiki.server.folder.adapter.FolderSaver;
+import com.tiki.server.folder.dto.request.FolderCreateRequest;
+import com.tiki.server.folder.dto.request.FolderNameUpdateRequest;
+import com.tiki.server.folder.dto.response.FolderCreateResponse;
+import com.tiki.server.folder.dto.response.FoldersGetResponse;
+import com.tiki.server.folder.entity.Folder;
+import com.tiki.server.folder.exception.FolderException;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class FolderService {
+
+ private final FolderFinder folderFinder;
+ private final FolderSaver folderSaver;
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+ private final DocumentFinder documentFinder;
+ private final DocumentDeleter documentDeleter;
+ private final DeletedDocumentAdapter deletedDocumentAdapter;
+ private final FolderDeleter folderDeleter;
+
+ public FoldersGetResponse get(final long memberId, final long teamId,
+ final Long folderId) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ Folder folder = getFolder(teamId, folderId);
+ String path = getChildFolderPath(folder);
+ List folders = folderFinder.findByTeamIdAndPath(teamId, path);
+ return FoldersGetResponse.from(folders);
+ }
+
+ @Transactional
+ public FolderCreateResponse create(final long memberId, final long teamId,
+ final Long folderId, final FolderCreateRequest request) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ Folder parentFolder = getFolder(teamId, folderId);
+ String path = getChildFolderPath(parentFolder);
+ validateFolderName(teamId, path, request.name());
+ Folder folder = folderSaver.save(new Folder(request.name(), parentFolder, teamId));
+ return FolderCreateResponse.from(folder.getId());
+ }
+
+ @Transactional
+ public void updateFolderName(final long memberId, final long teamId,
+ final long folderId, final FolderNameUpdateRequest request) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ Folder folder = folderFinder.findById(folderId);
+ folder.validateTeamId(teamId);
+ validateFolderName(teamId, folder.getPath(), request.name());
+ folder.updateName(request.name());
+ }
+
+ @Transactional
+ public void delete(final long memberId, final long teamId, final List folderIds) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List folders = folderFinder.findAllById(folderIds, teamId);
+ deleteFolders(folders);
+ }
+
+ private Folder getFolder(final long teamId, final Long folderId) {
+ if (folderId == null) {
+ return null;
+ }
+ Folder folder = folderFinder.findById(folderId);
+ folder.validateTeamId(teamId);
+ return folder;
+ }
+
+ private String getChildFolderPath(final Folder folder) {
+ if (folder == null) {
+ return ROOT_PATH;
+ }
+ return folder.getChildPath();
+ }
+
+ private void validateFolderName(final long teamId, final String path, final String name) {
+ List folders = folderFinder.findByTeamIdAndPath(teamId, path);
+ if (folders.stream().anyMatch(folder -> folder.getName().equals(name))) {
+ throw new FolderException(FOLDER_NAME_DUPLICATE);
+ }
+ }
+
+ private void deleteFolders(final List folders) {
+ folders.forEach(this::deleteChildFolders);
+ folders.forEach(this::deleteDocuments);
+ folderDeleter.deleteAll(folders);
+ }
+
+ private void deleteChildFolders(final Folder folder) {
+ List childFolders = folderFinder.findAllStartWithPath(folder.getChildPath());
+ childFolders.forEach(this::deleteDocuments);
+ folderDeleter.deleteAll(childFolders);
+ }
+
+ private void deleteDocuments(final Folder folder) {
+ List documents = documentFinder.findAllByFolderId(folder.getId());
+ deletedDocumentAdapter.save(documents);
+ documentDeleter.deleteAll(documents);
+ }
+}
diff --git a/src/main/java/com/tiki/server/member/Constants.java b/src/main/java/com/tiki/server/member/Constants.java
new file mode 100644
index 00000000..38246c5f
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/Constants.java
@@ -0,0 +1,6 @@
+package com.tiki.server.member;
+
+public class Constants {
+
+ public static final String PASSWORD_PATTERN = "^(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-={}\\[\\]:;\"'<>?,./]).{8,}$";
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/member/adapter/MemberFinder.java b/src/main/java/com/tiki/server/member/adapter/MemberFinder.java
new file mode 100644
index 00000000..4b06285f
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/adapter/MemberFinder.java
@@ -0,0 +1,39 @@
+package com.tiki.server.member.adapter;
+
+import static com.tiki.server.member.message.ErrorCode.CONFLICT_MEMBER;
+import static com.tiki.server.member.message.ErrorCode.INVALID_MEMBER;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.email.Email;
+import com.tiki.server.member.entity.Member;
+import com.tiki.server.member.exception.MemberException;
+import com.tiki.server.member.repository.MemberRepository;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.Optional;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class MemberFinder {
+
+ private final MemberRepository memberRepository;
+
+ public Optional findByEmail(final Email email) {
+ return memberRepository.findByEmail(email);
+ }
+
+ public Member findById(final long memberId) {
+ return memberRepository.findById(memberId).orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ public Member checkEmpty(final Email email) {
+ return memberRepository.findByEmail(email).orElseThrow(() -> new MemberException(INVALID_MEMBER));
+ }
+
+ public void checkPresent(final Email email) {
+ findByEmail(email).ifPresent((member) -> {
+ throw new MemberException(CONFLICT_MEMBER);
+ });
+ }
+}
diff --git a/src/main/java/com/tiki/server/member/adapter/MemberSaver.java b/src/main/java/com/tiki/server/member/adapter/MemberSaver.java
new file mode 100644
index 00000000..9b87ac47
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/adapter/MemberSaver.java
@@ -0,0 +1,17 @@
+package com.tiki.server.member.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.member.entity.Member;
+import com.tiki.server.member.repository.MemberRepository;
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class MemberSaver {
+
+ private final MemberRepository memberRepository;
+
+ public void save(final Member member) {
+ memberRepository.save(member);
+ }
+}
diff --git a/src/main/java/com/tiki/server/member/controller/MemberController.java b/src/main/java/com/tiki/server/member/controller/MemberController.java
new file mode 100644
index 00000000..8190560d
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/controller/MemberController.java
@@ -0,0 +1,57 @@
+package com.tiki.server.member.controller;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.member.controller.docs.MemberControllerDocs;
+import com.tiki.server.member.dto.request.PasswordChangeRequest;
+import com.tiki.server.member.dto.request.MemberProfileCreateRequest;
+import com.tiki.server.member.dto.response.BelongTeamsGetResponse;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import com.tiki.server.member.service.MemberService;
+
+import lombok.RequiredArgsConstructor;
+
+import java.security.Principal;
+
+import static com.tiki.server.member.message.SuccessMessage.SUCCESS_CHANGING_PASSWORD;
+import static com.tiki.server.member.message.SuccessMessage.SUCCESS_CREATE_MEMBER;
+import static com.tiki.server.team.message.SuccessMessage.SUCCESS_GET_JOINED_TEAM;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/v1/members")
+public class MemberController implements MemberControllerDocs {
+
+ private final MemberService memberService;
+
+ @Override
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping
+ public SuccessResponse> signUp(@RequestBody final MemberProfileCreateRequest request) {
+ memberService.signUp(request);
+ return SuccessResponse.success(SUCCESS_CREATE_MEMBER.getMessage());
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/teams")
+ public SuccessResponse getBelongTeam(
+ final Principal principal
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ BelongTeamsGetResponse response = memberService.findBelongTeams(memberId);
+ return SuccessResponse.success(SUCCESS_GET_JOINED_TEAM.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @PatchMapping("/password")
+ public SuccessResponse> changePassword(
+ @RequestBody final PasswordChangeRequest passwordChangeRequest
+ ) {
+ memberService.changePassword(passwordChangeRequest);
+ return SuccessResponse.success(SUCCESS_CHANGING_PASSWORD.getMessage());
+ }
+}
diff --git a/src/main/java/com/tiki/server/member/controller/docs/MemberControllerDocs.java b/src/main/java/com/tiki/server/member/controller/docs/MemberControllerDocs.java
new file mode 100644
index 00000000..ebd23b56
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/controller/docs/MemberControllerDocs.java
@@ -0,0 +1,98 @@
+package com.tiki.server.member.controller.docs;
+
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.member.dto.request.PasswordChangeRequest;
+import com.tiki.server.member.dto.request.MemberProfileCreateRequest;
+import com.tiki.server.member.dto.response.BelongTeamsGetResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import org.springframework.web.bind.annotation.RequestBody;
+
+import java.security.Principal;
+
+@Tag(name = "members", description = "멤버 API")
+public interface MemberControllerDocs {
+
+ @Operation(
+ summary = "회원가입 API",
+ description = "회원가입을 위한 정보를 보낸다.",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "이메일 형식 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "400",
+ description = "비밀번호 불일치",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "409",
+ description = "이미 가입된 아이디",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse> signUp(@RequestBody final MemberProfileCreateRequest request);
+
+ @Operation(
+ summary = "소속 팀 가져오기",
+ description = "왼쪽 사이드바의 소속된 팀 정보를 가져옵니다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "404",
+ description = "유효하지 않은 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse getBelongTeam(
+ @Parameter(hidden = true) final Principal principal
+ );
+
+ @Operation(
+ summary = "비밀번호 변경",
+ description = "비밀번호를 변경합니다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "비밀번호가 일치하지 않습니다.",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "유효하지 않은 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}
+ )
+ SuccessResponse> changePassword(
+ @RequestBody final PasswordChangeRequest passwordChangeRequest
+ );
+}
diff --git a/src/main/java/com/tiki/server/member/dto/request/MemberProfileCreateRequest.java b/src/main/java/com/tiki/server/member/dto/request/MemberProfileCreateRequest.java
new file mode 100644
index 00000000..09b91bf4
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/dto/request/MemberProfileCreateRequest.java
@@ -0,0 +1,17 @@
+package com.tiki.server.member.dto.request;
+
+import java.time.LocalDate;
+
+import com.tiki.server.common.entity.University;
+
+import jakarta.validation.constraints.NotNull;
+
+public record MemberProfileCreateRequest(
+ @NotNull String name,
+ @NotNull LocalDate birth,
+ @NotNull University univ,
+ @NotNull String email,
+ @NotNull String password,
+ @NotNull String passwordChecker
+) {
+}
diff --git a/src/main/java/com/tiki/server/member/dto/request/PasswordChangeRequest.java b/src/main/java/com/tiki/server/member/dto/request/PasswordChangeRequest.java
new file mode 100644
index 00000000..ff85ddb8
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/dto/request/PasswordChangeRequest.java
@@ -0,0 +1,10 @@
+package com.tiki.server.member.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record PasswordChangeRequest(
+ @NotNull String email,
+ @NotNull String password,
+ @NotNull String passwordChecker
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/member/dto/response/BelongTeamsGetResponse.java b/src/main/java/com/tiki/server/member/dto/response/BelongTeamsGetResponse.java
new file mode 100644
index 00000000..76c83efc
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/dto/response/BelongTeamsGetResponse.java
@@ -0,0 +1,37 @@
+package com.tiki.server.member.dto.response;
+
+import com.tiki.server.team.entity.Team;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+import java.util.List;
+
+import static lombok.AccessLevel.PRIVATE;
+
+@Builder(access = PRIVATE)
+public record BelongTeamsGetResponse(
+ @NotNull List belongTeamGetResponses
+) {
+
+ public static BelongTeamsGetResponse from(final List teams) {
+ return BelongTeamsGetResponse.builder()
+ .belongTeamGetResponses(teams.stream().map(BelongTeamGetResponse::from).toList())
+ .build();
+ }
+
+ @Builder(access = PRIVATE)
+ public record BelongTeamGetResponse(
+ @NotNull long id,
+ @NotNull String name,
+ @NotNull String iconImageUrl
+ ) {
+ public static BelongTeamGetResponse from(final Team team) {
+ return BelongTeamGetResponse.builder()
+ .id(team.getId())
+ .name(team.getName())
+ .iconImageUrl(team.getIconImageUrl())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/member/dto/response/SignInResultGetResponse.java b/src/main/java/com/tiki/server/member/dto/response/SignInResultGetResponse.java
new file mode 100644
index 00000000..f95ce5eb
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/dto/response/SignInResultGetResponse.java
@@ -0,0 +1,12 @@
+package com.tiki.server.member.dto.response;
+
+import jakarta.validation.constraints.NotNull;
+
+public record SignInResultGetResponse(
+ @NotNull String accessToken
+) {
+
+ public static SignInResultGetResponse from(final String accessToken) {
+ return new SignInResultGetResponse(accessToken);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/member/entity/Member.java b/src/main/java/com/tiki/server/member/entity/Member.java
new file mode 100644
index 00000000..55623359
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/entity/Member.java
@@ -0,0 +1,64 @@
+package com.tiki.server.member.entity;
+
+import static jakarta.persistence.EnumType.STRING;
+import static jakarta.persistence.GenerationType.IDENTITY;
+
+import java.time.LocalDate;
+
+import com.tiki.server.common.entity.BaseTime;
+import com.tiki.server.email.Email;
+import com.tiki.server.common.entity.University;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import lombok.*;
+
+@Entity
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Member extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ @Column(name = "member_id")
+ private Long id;
+
+ private Email email;
+
+ @Column(nullable = false)
+ private String password;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ private LocalDate birth;
+
+ @Column(nullable = false)
+ @Enumerated(value = STRING)
+ private University univ;
+
+ public static Member of(
+ final String email,
+ final String password,
+ final String name,
+ final LocalDate birth,
+ final University univ) {
+ return Member.builder()
+ .email(Email.from(email))
+ .password(password)
+ .name(name)
+ .birth(birth)
+ .univ(univ)
+ .build();
+ }
+
+ public void resetPassword(final String password) {
+ this.password = password;
+ }
+}
diff --git a/src/main/java/com/tiki/server/member/exception/MemberException.java b/src/main/java/com/tiki/server/member/exception/MemberException.java
new file mode 100644
index 00000000..2df1ef2b
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/exception/MemberException.java
@@ -0,0 +1,16 @@
+package com.tiki.server.member.exception;
+
+import com.tiki.server.member.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class MemberException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public MemberException(final ErrorCode errorCode) {
+ super("[MemberException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/member/message/ErrorCode.java b/src/main/java/com/tiki/server/member/message/ErrorCode.java
new file mode 100644
index 00000000..1d441fb3
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/message/ErrorCode.java
@@ -0,0 +1,27 @@
+package com.tiki.server.member.message;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import static org.springframework.http.HttpStatus.*;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ /* 400 BAD REQUEST : 잘못된 요청 */
+ UNMATCHED_PASSWORD(BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
+ INVALID_PASSWORD(BAD_REQUEST, "잘못된 비밀번호 형식입니다."),
+ INVALID_EMAIL(BAD_REQUEST, "잘못된 이메일 형식입니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_MEMBER(NOT_FOUND, "유효하지 않은 회원입니다."),
+
+ /* 409 CONFLICT : 중복된 데이터 존재 */
+ CONFLICT_MEMBER(CONFLICT, "존재하는 이메일입니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/member/message/SuccessMessage.java b/src/main/java/com/tiki/server/member/message/SuccessMessage.java
new file mode 100644
index 00000000..a99abbe8
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/message/SuccessMessage.java
@@ -0,0 +1,14 @@
+package com.tiki.server.member.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ SUCCESS_CREATE_MEMBER("회원가입 성공"),
+ SUCCESS_CHANGING_PASSWORD("비밀번호 변경 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/member/repository/MemberRepository.java b/src/main/java/com/tiki/server/member/repository/MemberRepository.java
new file mode 100644
index 00000000..0788365b
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/repository/MemberRepository.java
@@ -0,0 +1,12 @@
+package com.tiki.server.member.repository;
+
+import com.tiki.server.email.Email;
+import org.springframework.data.jpa.repository.JpaRepository;
+import com.tiki.server.member.entity.Member;
+
+import java.util.Optional;
+
+public interface MemberRepository extends JpaRepository {
+
+ Optional findByEmail(final Email email);
+}
diff --git a/src/main/java/com/tiki/server/member/service/MemberService.java b/src/main/java/com/tiki/server/member/service/MemberService.java
new file mode 100644
index 00000000..be4fdd4e
--- /dev/null
+++ b/src/main/java/com/tiki/server/member/service/MemberService.java
@@ -0,0 +1,92 @@
+package com.tiki.server.member.service;
+
+import com.tiki.server.member.adapter.MemberFinder;
+import com.tiki.server.member.adapter.MemberSaver;
+import com.tiki.server.member.dto.request.PasswordChangeRequest;
+import com.tiki.server.member.dto.request.MemberProfileCreateRequest;
+import com.tiki.server.member.dto.response.BelongTeamsGetResponse;
+import com.tiki.server.email.Email;
+import com.tiki.server.member.entity.Member;
+import com.tiki.server.member.exception.MemberException;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.team.adapter.TeamFinder;
+import com.tiki.server.team.entity.Team;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import static com.tiki.server.member.Constants.PASSWORD_PATTERN;
+import static com.tiki.server.member.message.ErrorCode.*;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberService {
+
+ private final MemberSaver memberSaver;
+ private final MemberFinder memberFinder;
+ private final PasswordEncoder passwordEncoder;
+ private final TeamFinder teamFinder;
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+
+ @Transactional
+ public void signUp(final MemberProfileCreateRequest request) {
+ memberFinder.checkPresent(Email.from(request.email()));
+ checkPasswordFormat(request.password());
+ checkPasswordMatch(request.password(), request.passwordChecker());
+ Member member = createMember(request);
+ saveMember(member);
+ }
+
+ public BelongTeamsGetResponse findBelongTeams(final long memberId) {
+ List memberTeamManagers = memberTeamManagerFinder.findAllByMemberIdOrderByCreatedAt(memberId);
+ List teams = getTeams(memberTeamManagers);
+ return BelongTeamsGetResponse.from(teams);
+ }
+
+ @Transactional
+ public void changePassword(final PasswordChangeRequest request) {
+ Member member = memberFinder.checkEmpty(Email.from(request.email()));
+ checkPasswordFormat(request.password());
+ checkPasswordMatch(request.password(), request.passwordChecker());
+ member.resetPassword(passwordEncoder.encode(request.password()));
+ }
+
+ private Member createMember(final MemberProfileCreateRequest request) {
+ return Member.of(
+ request.email(),
+ passwordEncoder.encode(request.password()),
+ request.name(),
+ request.birth(),
+ request.univ());
+ }
+
+ private void saveMember(final Member member) {
+ memberSaver.save(member);
+ }
+
+ private List getTeams(final List memberTeamManagers) {
+ return memberTeamManagers.stream()
+ .map(memberTeamManager -> teamFinder.findById(memberTeamManager.getTeamId()))
+ .toList();
+ }
+
+ private void checkPasswordFormat(final String password) {
+ if (!(password != null && !password.contains(" ") && Pattern.matches(PASSWORD_PATTERN, password))) {
+ throw new MemberException(INVALID_PASSWORD);
+ }
+ }
+
+ private void checkPasswordMatch(final String password, final String passwordChecker) {
+ if (!password.equals(passwordChecker)) {
+ throw new MemberException(UNMATCHED_PASSWORD);
+ }
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerDeleter.java b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerDeleter.java
new file mode 100644
index 00000000..c38e3c38
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerDeleter.java
@@ -0,0 +1,24 @@
+package com.tiki.server.memberteammanager.adapter;
+
+import java.util.List;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.memberteammanager.repository.MemberTeamManagerRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class MemberTeamManagerDeleter {
+
+ private final MemberTeamManagerRepository memberTeamManagerRepository;
+
+ public void delete(final MemberTeamManager memberTeamManager) {
+ memberTeamManagerRepository.delete(memberTeamManager);
+ }
+
+ public void deleteAll(final List memberTeamManagers) {
+ memberTeamManagerRepository.deleteAll(memberTeamManagers);
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerFinder.java b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerFinder.java
new file mode 100644
index 00000000..668fb66b
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerFinder.java
@@ -0,0 +1,46 @@
+package com.tiki.server.memberteammanager.adapter;
+
+import static com.tiki.server.memberteammanager.message.ErrorCode.INVALID_MEMBER_TEAM_MANAGER;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.email.Email;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.memberteammanager.exception.MemberTeamManagerException;
+import com.tiki.server.memberteammanager.repository.MemberTeamManagerRepository;
+
+import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class MemberTeamManagerFinder {
+
+ private final MemberTeamManagerRepository memberTeamManagerRepository;
+
+ public MemberTeamManager findByMemberIdAndTeamId(final long memberId, final long teamId) {
+ return memberTeamManagerRepository.findByMemberIdAndTeamId(memberId, teamId)
+ .orElseThrow(() -> new MemberTeamManagerException(INVALID_MEMBER_TEAM_MANAGER));
+ }
+
+ public List findAllByMemberIdOrderByCreatedAt(final long memberId) {
+ return memberTeamManagerRepository.findAllByMemberIdOrderByCreatedAt(memberId);
+ }
+
+ public List findAllByTeamId(final long teamId) {
+ return memberTeamManagerRepository.findAllByTeamId(teamId);
+ }
+
+ public List findNameAndEmailByMemberIdAndTeamId(final long teamId) {
+ return memberTeamManagerRepository.findTeamMembersByTeamId(teamId);
+ }
+
+ public boolean checkIsPresent(final long memberId, final long teamId) {
+ return memberTeamManagerRepository.findByMemberIdAndTeamId(memberId, teamId).isPresent();
+ }
+
+ public boolean existsByTeamIdAndMemberEmail(final long teamId, final Email email){
+ return memberTeamManagerRepository.existsByTeamIdAndMemberEmail(teamId,email);
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerSaver.java b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerSaver.java
new file mode 100644
index 00000000..eff8baca
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/adapter/MemberTeamManagerSaver.java
@@ -0,0 +1,18 @@
+package com.tiki.server.memberteammanager.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.memberteammanager.repository.MemberTeamManagerRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class MemberTeamManagerSaver {
+
+ private final MemberTeamManagerRepository memberTeamManagerRepository;
+
+ public void save(final MemberTeamManager memberTeamManager) {
+ memberTeamManagerRepository.save(memberTeamManager);
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/controller/MemberTeamController.java b/src/main/java/com/tiki/server/memberteammanager/controller/MemberTeamController.java
new file mode 100644
index 00000000..ae6a6bf9
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/controller/MemberTeamController.java
@@ -0,0 +1,84 @@
+package com.tiki.server.memberteammanager.controller;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.memberteammanager.controller.dto.request.UpdateTeamMemberNameRequest;
+import com.tiki.server.memberteammanager.service.MemberTeamManagerService;
+import com.tiki.server.memberteammanager.service.dto.response.MemberTeamInformGetResponse;
+import com.tiki.server.memberteammanager.service.dto.response.TeamMembersGetResponse;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.security.Principal;
+
+import static com.tiki.server.memberteammanager.message.SuccessMessage.GET_TEAM_INFORM;
+import static com.tiki.server.memberteammanager.message.SuccessMessage.GET_TEAM_MEMBERS;
+import static com.tiki.server.memberteammanager.message.SuccessMessage.KICK_TEAM;
+import static com.tiki.server.memberteammanager.message.SuccessMessage.LEAVE_TEAM;
+import static com.tiki.server.memberteammanager.message.SuccessMessage.UPDATE_NAME;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/team-member")
+public class MemberTeamController {
+
+ private final MemberTeamManagerService memberTeamManagerService;
+
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/teams/{teamId}/members")
+ public SuccessResponse getMembers(
+ final Principal principal,
+ @PathVariable final long teamId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ TeamMembersGetResponse response = memberTeamManagerService.getMembers(teamId, memberId);
+ return SuccessResponse.success(GET_TEAM_MEMBERS.getMessage(), response);
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/teams/{teamId}/members/position")
+ public SuccessResponse getMemberTeamInform(
+ final Principal principal,
+ @PathVariable final long teamId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ MemberTeamInformGetResponse response = memberTeamManagerService.getMemberTeamInform(memberId, teamId);
+ return SuccessResponse.success(GET_TEAM_INFORM.getMessage(), response);
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @PatchMapping("/teams/{teamId}/members/name")
+ public SuccessResponse> updateTeamMemberName(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestBody final UpdateTeamMemberNameRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ memberTeamManagerService.updateTeamMemberName(memberId, teamId, request.newName());
+ return SuccessResponse.success(UPDATE_NAME.getMessage());
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @DeleteMapping("/teams/{teamId}/members/{kickOutMemberId}")
+ public SuccessResponse> kickOutMemberFromTeam(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @PathVariable final long kickOutMemberId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ memberTeamManagerService.kickOutMemberFromTeam(memberId, teamId, kickOutMemberId);
+ return SuccessResponse.success(KICK_TEAM.getMessage());
+ }
+
+ @ResponseStatus(HttpStatus.OK)
+ @DeleteMapping("/teams/{teamId}/leave")
+ public SuccessResponse> leaveTeam(
+ final Principal principal,
+ @PathVariable final long teamId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ memberTeamManagerService.leaveTeam(memberId, teamId);
+ return SuccessResponse.success(LEAVE_TEAM.getMessage());
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/controller/dto/request/UpdateTeamMemberNameRequest.java b/src/main/java/com/tiki/server/memberteammanager/controller/dto/request/UpdateTeamMemberNameRequest.java
new file mode 100644
index 00000000..9b6fc655
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/controller/dto/request/UpdateTeamMemberNameRequest.java
@@ -0,0 +1,8 @@
+package com.tiki.server.memberteammanager.controller.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record UpdateTeamMemberNameRequest(
+ @NotNull String newName
+) {
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/entity/MemberTeamManager.java b/src/main/java/com/tiki/server/memberteammanager/entity/MemberTeamManager.java
new file mode 100644
index 00000000..3cd56074
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/entity/MemberTeamManager.java
@@ -0,0 +1,76 @@
+package com.tiki.server.memberteammanager.entity;
+
+import static com.tiki.server.memberteammanager.message.ErrorCode.*;
+import static jakarta.persistence.EnumType.STRING;
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+import com.tiki.server.common.entity.BaseTime;
+import com.tiki.server.common.entity.Position;
+import com.tiki.server.member.entity.Member;
+import com.tiki.server.memberteammanager.exception.MemberTeamManagerException;
+import com.tiki.server.team.entity.Team;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@Builder(access = PRIVATE)
+@AllArgsConstructor(access = PRIVATE)
+@NoArgsConstructor(access = PROTECTED)
+public class MemberTeamManager extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ @Column(name = "manager_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private long memberId;
+
+ @Column(nullable = false)
+ private long teamId;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ @Enumerated(value = STRING)
+ private Position position;
+
+ public static MemberTeamManager of(final Member member, final Team team, final Position position) {
+ return MemberTeamManager.builder()
+ .memberId(member.getId())
+ .teamId(team.getId())
+ .name(member.getName())
+ .position(position)
+ .build();
+ }
+
+ public void checkMemberAccessible(final Position accesiblePosition) {
+ if (this.position.getAuthorization() > accesiblePosition.getAuthorization()) {
+ throw new MemberTeamManagerException(INVALID_AUTHORIZATION);
+ }
+ }
+
+ public void updateName(final String name){
+ this.name = name;
+ }
+
+ public void updatePositionToExecutive(){
+ this.position = Position.EXECUTIVE;
+ }
+
+ public void updatePositionToAdmin(){
+ this.position = Position.ADMIN;
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/exception/MemberTeamManagerException.java b/src/main/java/com/tiki/server/memberteammanager/exception/MemberTeamManagerException.java
new file mode 100644
index 00000000..02bc2939
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/exception/MemberTeamManagerException.java
@@ -0,0 +1,16 @@
+package com.tiki.server.memberteammanager.exception;
+
+import com.tiki.server.memberteammanager.message.ErrorCode;
+
+import lombok.Getter;
+
+@Getter
+public class MemberTeamManagerException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public MemberTeamManagerException(final ErrorCode errorCode) {
+ super("[MemberTeamManagerException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/message/ErrorCode.java b/src/main/java/com/tiki/server/memberteammanager/message/ErrorCode.java
new file mode 100644
index 00000000..0134d070
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/message/ErrorCode.java
@@ -0,0 +1,25 @@
+package com.tiki.server.memberteammanager.message;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.FORBIDDEN;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ CONFLICT_TEAM_MEMBER(BAD_REQUEST,"이미 존재하는 팀원입니다"),
+ /* 403 FORBIDDEN : 권한 없음 */
+ INVALID_AUTHORIZATION(FORBIDDEN, "권한이 없습니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_MEMBER_TEAM_MANAGER(NOT_FOUND, "팀에 존재하지 않는 회원입니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/message/SuccessMessage.java b/src/main/java/com/tiki/server/memberteammanager/message/SuccessMessage.java
new file mode 100644
index 00000000..05038e62
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/message/SuccessMessage.java
@@ -0,0 +1,17 @@
+package com.tiki.server.memberteammanager.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ GET_TEAM_INFORM("팀 개인 정보 불러오기 성공"),
+ GET_TEAM_MEMBERS("팀원 정보 불러오기 성공"),
+ KICK_TEAM("팀 퇴출 성공"),
+ LEAVE_TEAM("팀 탈퇴 성공"),
+ UPDATE_NAME("팀 내 이름 변경 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/repository/MemberTeamManagerRepository.java b/src/main/java/com/tiki/server/memberteammanager/repository/MemberTeamManagerRepository.java
new file mode 100644
index 00000000..6b8e831a
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/repository/MemberTeamManagerRepository.java
@@ -0,0 +1,32 @@
+package com.tiki.server.memberteammanager.repository;
+
+import com.tiki.server.email.Email;
+import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import org.springframework.data.jpa.repository.Query;
+
+public interface MemberTeamManagerRepository extends JpaRepository {
+
+ Optional findByMemberIdAndTeamId(final long memberId, final long teamId);
+
+ List findAllByTeamId(final long teamId);
+
+ List findAllByMemberIdOrderByCreatedAt(final long memberId);
+
+ @Query("SELECT m.name AS memberName, m.email AS memberEmail, mtm.position AS memberPosition " +
+ "FROM MemberTeamManager mtm " +
+ "JOIN Member m ON mtm.memberId = m.id " +
+ "WHERE mtm.teamId = :teamId")
+ List findTeamMembersByTeamId(final long teamId);
+
+ @Query("SELECT CASE WHEN COUNT(mtm) > 0 THEN true ELSE false END " +
+ "FROM MemberTeamManager mtm " +
+ "JOIN Member m ON mtm.memberId = m.id " +
+ "WHERE mtm.teamId = :teamId AND m.email = :email")
+ boolean existsByTeamIdAndMemberEmail(final long teamId, final Email email);
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/memberteammanager/repository/projection/TeamMemberInformGetProjection.java b/src/main/java/com/tiki/server/memberteammanager/repository/projection/TeamMemberInformGetProjection.java
new file mode 100644
index 00000000..548813e5
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/repository/projection/TeamMemberInformGetProjection.java
@@ -0,0 +1,9 @@
+package com.tiki.server.memberteammanager.repository.projection;
+
+import com.tiki.server.common.entity.Position;
+
+public interface TeamMemberInformGetProjection {
+ String getMemberName();
+ String getMemberEmail();
+ Position getMemberPosition();
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/service/MemberTeamManagerService.java b/src/main/java/com/tiki/server/memberteammanager/service/MemberTeamManagerService.java
new file mode 100644
index 00000000..7f5cf1f2
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/service/MemberTeamManagerService.java
@@ -0,0 +1,83 @@
+package com.tiki.server.memberteammanager.service;
+
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerDeleter;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerSaver;
+import com.tiki.server.memberteammanager.entity.MemberTeamManager;
+import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection;
+import com.tiki.server.memberteammanager.service.dto.response.TeamMembersGetResponse;
+import com.tiki.server.note.adapter.NoteFinder;
+import com.tiki.server.note.entity.Note;
+import com.tiki.server.team.exception.TeamException;
+import com.tiki.server.memberteammanager.service.dto.response.MemberTeamInformGetResponse;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+import static com.tiki.server.common.entity.Position.ADMIN;
+import static com.tiki.server.team.message.ErrorCode.INVALID_AUTHORIZATION_DELETE;
+import static com.tiki.server.team.message.ErrorCode.TOO_HIGH_AUTHORIZATION;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class MemberTeamManagerService {
+
+ private final NoteFinder noteFinder;
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+ private final MemberTeamManagerDeleter memberTeamManagerDeleter;
+
+ @Transactional
+ public void kickOutMemberFromTeam(final long memberId, final long teamId, final long kickOutMemberId) {
+ MemberTeamManager accessMember = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ checkIsAdmin(accessMember);
+ MemberTeamManager kickOutMember = memberTeamManagerFinder.findByMemberIdAndTeamId(kickOutMemberId, teamId);
+ deleteNoteDependency(kickOutMemberId, teamId);
+ memberTeamManagerDeleter.delete(kickOutMember);
+ }
+
+ @Transactional
+ public void leaveTeam(final long memberId, final long teamId) {
+ MemberTeamManager accessMember = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ checkIsNotAdmin(accessMember);
+ deleteNoteDependency(memberId, teamId);
+ memberTeamManagerDeleter.delete(accessMember);
+ }
+
+ public MemberTeamInformGetResponse getMemberTeamInform(final long memberId, final long teamId) {
+ MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ return MemberTeamInformGetResponse.of(memberTeamManager.getPosition(), memberTeamManager.getName());
+ }
+
+ @Transactional
+ public void updateTeamMemberName(final long memberId, final long teamId, final String name) {
+ MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ memberTeamManager.updateName(name);
+ }
+
+ public TeamMembersGetResponse getMembers(final long teamId, final long memberId) {
+ MemberTeamManager memberTeamManager = memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ List teamMembers = memberTeamManagerFinder.findNameAndEmailByMemberIdAndTeamId(teamId);
+ return TeamMembersGetResponse.from(teamMembers);
+ }
+
+ private void checkIsAdmin(MemberTeamManager memberTeamManager) {
+ if (!memberTeamManager.getPosition().equals(ADMIN)) {
+ throw new TeamException(INVALID_AUTHORIZATION_DELETE);
+ }
+ }
+
+ private void checkIsNotAdmin(final MemberTeamManager memberTeamManager) {
+ if (memberTeamManager.getPosition().equals(ADMIN)) {
+ throw new TeamException(TOO_HIGH_AUTHORIZATION);
+ }
+ }
+
+ private void deleteNoteDependency(final long memberId, final long teamId) {
+ List notes = noteFinder.findAllByMemberIdAndTeamId(memberId, teamId);
+ notes.forEach(Note::deleteMemberDependency);
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/service/dto/response/MemberTeamInformGetResponse.java b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/MemberTeamInformGetResponse.java
new file mode 100644
index 00000000..7c2ea78a
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/MemberTeamInformGetResponse.java
@@ -0,0 +1,13 @@
+package com.tiki.server.memberteammanager.service.dto.response;
+
+import com.tiki.server.common.entity.Position;
+import jakarta.validation.constraints.NotNull;
+
+public record MemberTeamInformGetResponse(
+ @NotNull Position position,
+ @NotNull String name
+) {
+ public static MemberTeamInformGetResponse of(final Position position, final String name) {
+ return new MemberTeamInformGetResponse(position, name);
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMemberGetResponse.java b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMemberGetResponse.java
new file mode 100644
index 00000000..0912f7a6
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMemberGetResponse.java
@@ -0,0 +1,15 @@
+package com.tiki.server.memberteammanager.service.dto.response;
+
+import com.tiki.server.common.entity.Position;
+import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection;
+import jakarta.validation.constraints.NotNull;
+
+public record TeamMemberGetResponse (
+ @NotNull String name,
+ @NotNull Position position,
+ @NotNull String email
+){
+ public static TeamMemberGetResponse from(TeamMemberInformGetProjection projection){
+ return new TeamMemberGetResponse(projection.getMemberName(),projection.getMemberPosition(), projection.getMemberEmail());
+ }
+}
diff --git a/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMembersGetResponse.java b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMembersGetResponse.java
new file mode 100644
index 00000000..491153d5
--- /dev/null
+++ b/src/main/java/com/tiki/server/memberteammanager/service/dto/response/TeamMembersGetResponse.java
@@ -0,0 +1,17 @@
+package com.tiki.server.memberteammanager.service.dto.response;
+
+import com.tiki.server.memberteammanager.repository.projection.TeamMemberInformGetProjection;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+
+public record TeamMembersGetResponse(
+ @NotNull List teamMemberGetResponses
+) {
+ public TeamMembersGetResponse(List teamMemberGetResponses){
+ this.teamMemberGetResponses = teamMemberGetResponses;
+ }
+
+ public static TeamMembersGetResponse from(List projections){
+ return new TeamMembersGetResponse(projections.stream().map(TeamMemberGetResponse::from).toList());
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/adapter/NoteDeleter.java b/src/main/java/com/tiki/server/note/adapter/NoteDeleter.java
new file mode 100644
index 00000000..1080645c
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/adapter/NoteDeleter.java
@@ -0,0 +1,18 @@
+package com.tiki.server.note.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.note.repository.NoteRepository;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NoteDeleter {
+
+ private final NoteRepository noteRepository;
+
+ public void deleteNoteByIds(final List noteIds){
+ noteIds.forEach(noteRepository::deleteById);
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/adapter/NoteFinder.java b/src/main/java/com/tiki/server/note/adapter/NoteFinder.java
new file mode 100644
index 00000000..fdcf4748
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/adapter/NoteFinder.java
@@ -0,0 +1,45 @@
+package com.tiki.server.note.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.note.entity.Note;
+import com.tiki.server.note.exception.NoteException;
+import com.tiki.server.note.repository.NoteRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static com.tiki.server.note.message.ErrorCode.INVALID_NOTE;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NoteFinder {
+
+ private final NoteRepository noteRepository;
+
+ public List findByCreatedAtBeforeOrderByModifiedAtDesc(
+ final LocalDateTime createdAt,
+ final PageRequest pageRequest,
+ final long teamId
+ ) {
+ return noteRepository.findByTeamIdAndCreatedAtBeforeOrderByCreatedDesc(createdAt, pageRequest, teamId);
+ }
+
+ public List findByCreatedAtAfterOrderByModifiedAtAsc(
+ final LocalDateTime createdAt,
+ final PageRequest pageRequest,
+ final long teamId
+ ) {
+ return noteRepository.findByTeamIdAndCreatedAtAfterOrderByCreatedAtAsc(createdAt, pageRequest, teamId);
+ }
+
+ public List findAllByMemberIdAndTeamId(final long memberId, final long teamId) {
+ return noteRepository.findAllByMemberIdAndTeamId(memberId, teamId);
+ }
+
+ public Note findById(final long noteId) {
+ return noteRepository.findById(noteId)
+ .orElseThrow(() -> new NoteException(INVALID_NOTE));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/adapter/NoteSaver.java b/src/main/java/com/tiki/server/note/adapter/NoteSaver.java
new file mode 100644
index 00000000..112db2ac
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/adapter/NoteSaver.java
@@ -0,0 +1,17 @@
+package com.tiki.server.note.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.note.entity.Note;
+import com.tiki.server.note.repository.NoteRepository;
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NoteSaver {
+
+ private final NoteRepository noteRepository;
+
+ public Note createNote(final Note note) {
+ return noteRepository.save(note);
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/constants/NoteConstants.java b/src/main/java/com/tiki/server/note/constants/NoteConstants.java
new file mode 100644
index 00000000..0b199f08
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/constants/NoteConstants.java
@@ -0,0 +1,6 @@
+package com.tiki.server.note.constants;
+
+public class NoteConstants {
+
+ public static final int PAGE_SIZE = 10;
+}
diff --git a/src/main/java/com/tiki/server/note/controller/NoteController.java b/src/main/java/com/tiki/server/note/controller/NoteController.java
new file mode 100644
index 00000000..7d98fd5d
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/controller/NoteController.java
@@ -0,0 +1,137 @@
+package com.tiki.server.note.controller;
+
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.common.entity.SortOrder;
+import com.tiki.server.note.controller.docs.NoteControllerDocs;
+import com.tiki.server.note.controller.dto.request.NoteFreeCreateRequest;
+import com.tiki.server.note.controller.dto.request.NoteFreeUpdateRequest;
+import com.tiki.server.note.controller.dto.request.NoteTemplateCreateRequest;
+import com.tiki.server.note.controller.dto.request.NoteTemplateUpdateRequest;
+import com.tiki.server.note.controller.dto.response.SuccessNoteDetailResponse;
+import com.tiki.server.note.service.NoteService;
+import com.tiki.server.note.service.dto.request.NoteFreeCreateServiceRequest;
+import com.tiki.server.note.service.dto.request.NoteFreeUpdateServiceRequest;
+import com.tiki.server.note.service.dto.request.NoteTemplateCreateServiceRequest;
+import com.tiki.server.note.service.dto.request.NoteTemplateUpdateServiceRequest;
+import com.tiki.server.note.service.dto.response.NoteCreateServiceResponse;
+import com.tiki.server.note.service.dto.response.NoteDetailGetServiceResponse;
+import com.tiki.server.note.service.dto.response.NoteListGetServiceResponse;
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.security.Principal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static com.tiki.server.note.message.SuccessMessage.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/notes")
+public class NoteController implements NoteControllerDocs {
+
+ private final NoteService noteService;
+
+ @Override
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/free")
+ public SuccessResponse createNoteFree(
+ final Principal principal,
+ @RequestBody final NoteFreeCreateRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ NoteCreateServiceResponse response = noteService.createNoteFree(NoteFreeCreateServiceRequest.of(request, memberId));
+ return SuccessResponse.success(CREATE_NOTE.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.CREATED)
+ @PostMapping("/template")
+ public SuccessResponse createNoteTemplate(
+ final Principal principal,
+ @RequestBody final NoteTemplateCreateRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ NoteCreateServiceResponse response = noteService.createNoteTemplate(NoteTemplateCreateServiceRequest.of(request, memberId));
+ return SuccessResponse.success(CREATE_NOTE.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @PatchMapping("/free/{noteId}")
+ public SuccessResponse> updateNoteFree(
+ final Principal principal,
+ @PathVariable final long noteId,
+ @RequestBody final NoteFreeUpdateRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ noteService.updateNoteFree(NoteFreeUpdateServiceRequest.of(request, noteId, memberId));
+ return SuccessResponse.success(UPDATE_NOTE.getMessage());
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @PatchMapping("/template/{noteId}")
+ public SuccessResponse> updateNoteTemplate(
+ final Principal principal,
+ @PathVariable final long noteId,
+ @RequestBody final NoteTemplateUpdateRequest request
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ noteService.updateNoteTemplate(NoteTemplateUpdateServiceRequest.of(request, noteId, memberId));
+ return SuccessResponse.success(UPDATE_NOTE.getMessage());
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/{teamId}")
+ public SuccessResponse getNote(
+ final Principal principal,
+ @PathVariable long teamId,
+ @RequestParam(required = false) LocalDateTime createdAt,
+ @RequestParam(defaultValue = "DESC") SortOrder sortOrder
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ if (createdAt == null) {
+ createdAt = (sortOrder == SortOrder.DESC) ? LocalDateTime.now() : LocalDateTime.of(1970, 1, 1, 0, 0);
+ }
+ NoteListGetServiceResponse response = noteService.getNote(teamId, memberId, createdAt, sortOrder);
+ return SuccessResponse.success(GET_NOTE.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.OK)
+ @GetMapping("/{teamId}/{noteId}")
+ public SuccessNoteDetailResponse getNoteDetail(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @PathVariable final long noteId
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ NoteDetailGetServiceResponse response = noteService.getNoteDetail(teamId, memberId, noteId);
+ return SuccessNoteDetailResponse.success(GET_NOTE_DETAIL.getMessage(), response);
+ }
+
+ @Override
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @DeleteMapping("/{teamId}")
+ public void deleteNotes(
+ final Principal principal,
+ @PathVariable final long teamId,
+ @RequestParam final List noteIds
+ ) {
+ long memberId = Long.parseLong(principal.getName());
+ noteService.deleteNotes(noteIds, teamId, memberId);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/controller/docs/NoteControllerDocs.java b/src/main/java/com/tiki/server/note/controller/docs/NoteControllerDocs.java
new file mode 100644
index 00000000..5cb6a54d
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/controller/docs/NoteControllerDocs.java
@@ -0,0 +1,332 @@
+package com.tiki.server.note.controller.docs;
+
+import com.tiki.server.common.dto.ErrorResponse;
+import com.tiki.server.common.dto.SuccessResponse;
+import com.tiki.server.common.entity.SortOrder;
+import com.tiki.server.note.controller.dto.request.NoteFreeCreateRequest;
+import com.tiki.server.note.controller.dto.request.NoteFreeUpdateRequest;
+import com.tiki.server.note.controller.dto.request.NoteTemplateCreateRequest;
+import com.tiki.server.note.controller.dto.request.NoteTemplateUpdateRequest;
+import com.tiki.server.note.controller.dto.response.SuccessNoteDetailResponse;
+import com.tiki.server.note.service.dto.response.NoteCreateServiceResponse;
+import com.tiki.server.note.service.dto.response.NoteDetailGetServiceResponse;
+import com.tiki.server.note.service.dto.response.NoteListGetServiceResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.security.Principal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Tag(name = "notes", description = "노트 API")
+public interface NoteControllerDocs {
+
+ @Operation(
+ summary = "자유 형식 노트 생성",
+ description = "새로운 자유 형식 노트를 생성한다.",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "노트 생성 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "요청 데이터 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음(토큰 에러)",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ )
+ }
+ )
+ SuccessResponse createNoteFree(
+ @Parameter(hidden = true) final Principal principal,
+ @RequestBody final NoteFreeCreateRequest request
+ );
+
+ @Operation(
+ summary = "템플릿 노트 생성",
+ description = "새로운 템플릿 형식 노트를 생성한다.",
+ responses = {
+ @ApiResponse(responseCode = "201", description = "노트 생성 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "요청 데이터 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음(토큰 에러)",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ )
+ }
+ )
+ SuccessResponse createNoteTemplate(
+ @Parameter(hidden = true) final Principal principal,
+ @RequestBody final NoteTemplateCreateRequest request
+ );
+
+ @Operation(
+ summary = "자유 형식 노트 수정",
+ description = "기존 자유 형식 노트를 수정한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "노트 수정 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "요청 데이터 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음(토큰 에러)",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 노트",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ )
+ }
+ )
+ SuccessResponse> updateNoteFree(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "noteId",
+ description = "노트 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long noteId,
+ @RequestBody final NoteFreeUpdateRequest request
+ );
+
+ @Operation(
+ summary = "템플릿 노트 수정",
+ description = "기존 템플릿 노트를 수정한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "노트 수정 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "요청 데이터 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음(토큰 에러)",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "존재하지 않는 노트",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ )
+ }
+ )
+ SuccessResponse> updateNoteTemplate(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "noteId",
+ description = "노트 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long noteId,
+ @RequestBody final NoteTemplateUpdateRequest request
+ );
+
+ @Operation(
+ summary = "노트 목록 조회",
+ description = "특정 팀의 노트 목록을 조회한다.",
+ responses = {
+ @ApiResponse(responseCode = "200", description = "조회 성공"),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음(토큰 에러)",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ )
+ }
+ )
+ SuccessResponse getNote(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "createdAt",
+ description = "생성시간",
+ in = ParameterIn.QUERY,
+ required = false,
+ example = "yyyy-MM-dd'T'HH:mm:ss.nnnnnnnnn"
+ ) @RequestParam(required = false) final LocalDateTime createdAt,
+ @Parameter(
+ name = "sortOrder",
+ description = "정렬 순서",
+ in = ParameterIn.QUERY,
+ required = false,
+ example = "ASC, DESC"
+ ) @RequestParam(defaultValue = "DESC") final SortOrder sortOrder
+ );
+
+ @Operation(
+ summary = "노트 상세 조회",
+ description = "특정 노트의 상세 정보를 조회한다.",
+ responses = {
+ @ApiResponse(
+ responseCode = "200",
+ description = "조회 성공",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = SuccessNoteDetailResponse.class))),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음(토큰 에러)",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "노트를 찾을 수 없음",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ )
+ }
+ )
+ SuccessNoteDetailResponse getNoteDetail(
+ @Parameter(hidden = true) final Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "noteId",
+ description = "노트 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long noteId
+ );
+
+ @Operation(
+ summary = "노트 삭제",
+ description = "특정 팀의 노트를 삭제한다.",
+ responses = {
+ @ApiResponse(responseCode = "204", description = "삭제 성공"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "요청 데이터 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ ),
+ @ApiResponse(
+ responseCode = "403",
+ description = "접근 권한 없음(토큰 에러)",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "팀에 존재하지 않는 회원",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "4xx",
+ description = "클라이언트(요청) 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content(schema = @Schema(implementation = ErrorResponse.class))
+ )
+ }
+ )
+ void deleteNotes(
+ @Parameter(hidden = true) Principal principal,
+ @Parameter(
+ name = "teamId",
+ description = "팀 id",
+ in = ParameterIn.PATH,
+ example = "1"
+ ) @PathVariable final long teamId,
+ @Parameter(
+ name = "noteIds",
+ description = "노트 id 리스트",
+ in = ParameterIn.QUERY,
+ example = "[1,2,3,4,5]"
+ ) @RequestParam final List noteIds
+ );
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeCreateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeCreateRequest.java
new file mode 100644
index 00000000..f40bac2a
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeCreateRequest.java
@@ -0,0 +1,18 @@
+package com.tiki.server.note.controller.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteFreeCreateRequest(
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String contents,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long teamId
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeUpdateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeUpdateRequest.java
new file mode 100644
index 00000000..90e412e6
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteFreeUpdateRequest.java
@@ -0,0 +1,19 @@
+package com.tiki.server.note.controller.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.NonNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteFreeUpdateRequest(
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String contents,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long teamId
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateCreateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateCreateRequest.java
new file mode 100644
index 00000000..b8b23c52
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateCreateRequest.java
@@ -0,0 +1,22 @@
+package com.tiki.server.note.controller.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.NonNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteTemplateCreateRequest(
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String answerWhatActivity,
+ @NotNull String answerHowToPrepare,
+ @NotNull String answerWhatIsDisappointedThing,
+ @NotNull String answerHowToFix,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long teamId
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateUpdateRequest.java b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateUpdateRequest.java
new file mode 100644
index 00000000..4c22c26b
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/controller/dto/request/NoteTemplateUpdateRequest.java
@@ -0,0 +1,22 @@
+package com.tiki.server.note.controller.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.NonNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteTemplateUpdateRequest(
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String answerWhatActivity,
+ @NotNull String answerHowToPrepare,
+ @NotNull String answerWhatIsDisappointedThing,
+ @NotNull String answerHowToFix,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long teamId
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/controller/dto/response/SuccessNoteDetailResponse.java b/src/main/java/com/tiki/server/note/controller/dto/response/SuccessNoteDetailResponse.java
new file mode 100644
index 00000000..1902f6d9
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/controller/dto/response/SuccessNoteDetailResponse.java
@@ -0,0 +1,35 @@
+package com.tiki.server.note.controller.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.tiki.server.common.dto.BaseResponse;
+import com.tiki.server.note.service.dto.response.NoteFreeDetailGetServiceResponse;
+import com.tiki.server.note.service.dto.response.NoteTemplateDetailGetServiceResponse;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static lombok.AccessLevel.PRIVATE;
+
+@Builder(access = PRIVATE)
+public record SuccessNoteDetailResponse(
+ @NotNull boolean success,
+ @NotNull String message,
+ @Schema(
+ description = "응답 데이터",
+ oneOf = {
+ NoteFreeDetailGetServiceResponse.class,
+ NoteTemplateDetailGetServiceResponse.class
+ }
+ )
+ @JsonInclude(value = NON_NULL) T data
+) implements BaseResponse {
+
+ public static SuccessNoteDetailResponse success(final String message, final T data) {
+ return SuccessNoteDetailResponse.builder().success(true).message(message).data(data).build();
+ }
+
+ public static SuccessNoteDetailResponse> success(final String message) {
+ return SuccessNoteDetailResponse.builder().success(true).message(message).build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/entity/Note.java b/src/main/java/com/tiki/server/note/entity/Note.java
new file mode 100644
index 00000000..dc1760a1
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/entity/Note.java
@@ -0,0 +1,112 @@
+package com.tiki.server.note.entity;
+
+import com.tiki.server.common.entity.BaseTime;
+import com.tiki.server.note.exception.NoteException;
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+
+import static com.tiki.server.note.message.ErrorCode.UPDATE_ONLY_AUTHOR;
+import static com.tiki.server.note.message.ErrorCode.UPDATE_ONLY_BELONGING_TEAM;
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+@Entity
+@Getter
+@Builder
+@AllArgsConstructor(access = PRIVATE)
+@NoArgsConstructor(access = PROTECTED)
+public class Note extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ @Column(name = "note_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String title;
+
+ @Column(nullable = false)
+ private boolean complete;
+
+ @Column(nullable = false)
+ private LocalDate startDate;
+
+ @Column(nullable = false)
+ private LocalDate endDate;
+
+ private Long memberId;
+
+ @Column(nullable = false)
+ private long teamId;
+
+ @Column(nullable = false)
+ private String contents;
+
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ private NoteType noteType;
+
+ public static Note of(
+ final String title,
+ final boolean complete,
+ final LocalDate startDate,
+ final LocalDate endDate,
+ final String contents,
+ final long memberId,
+ final long teamId,
+ final NoteType noteType
+ ) {
+ return Note.builder()
+ .title(title)
+ .complete(complete)
+ .startDate(startDate)
+ .endDate(endDate)
+ .memberId(memberId)
+ .teamId(teamId)
+ .contents(contents)
+ .noteType(noteType)
+ .build();
+ }
+
+ public void updateValue(
+ final long clientId,
+ final long clientTeamId,
+ final String title,
+ final String contents,
+ final LocalDate startDate,
+ final LocalDate endDate,
+ final boolean complete,
+ final NoteType noteType
+ ) {
+ checkAuthor(clientId);
+ checkTeam(clientTeamId);
+ this.title = title;
+ this.contents = contents;
+ this.startDate = startDate;
+ this.endDate = endDate;
+ this.complete = complete;
+ this.noteType = noteType;
+ }
+
+ public void deleteMemberDependency() {
+ this.memberId = null;
+ }
+
+ private void checkAuthor(final long clientId) {
+ if (this.memberId != clientId) {
+ throw new NoteException(UPDATE_ONLY_AUTHOR);
+ }
+ }
+
+ private void checkTeam(final long clientTeamId) {
+ if (this.teamId != clientTeamId) {
+ throw new NoteException(UPDATE_ONLY_BELONGING_TEAM);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/entity/NoteType.java b/src/main/java/com/tiki/server/note/entity/NoteType.java
new file mode 100644
index 00000000..409ba86f
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/entity/NoteType.java
@@ -0,0 +1,6 @@
+package com.tiki.server.note.entity;
+
+public enum NoteType {
+ FREE,
+ TEMPLATE
+}
diff --git a/src/main/java/com/tiki/server/note/exception/NoteException.java b/src/main/java/com/tiki/server/note/exception/NoteException.java
new file mode 100644
index 00000000..54a9524f
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/exception/NoteException.java
@@ -0,0 +1,15 @@
+package com.tiki.server.note.exception;
+
+import com.tiki.server.note.message.ErrorCode;
+import lombok.Getter;
+
+@Getter
+public class NoteException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public NoteException(final ErrorCode errorCode) {
+ super("[NoteException] : " + errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/message/ErrorCode.java b/src/main/java/com/tiki/server/note/message/ErrorCode.java
new file mode 100644
index 00000000..c5f9e49b
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/message/ErrorCode.java
@@ -0,0 +1,25 @@
+package com.tiki.server.note.message;
+
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorCode {
+
+ /* 400 BAD REQUEST : 잘못된 요청 */
+ TITLE_IS_EMPTY(BAD_REQUEST, "제목은 필수 입력값 입니다."),
+ TITLE_LENGTH_OVER(BAD_REQUEST, "제목은 100자를 넘길 수 없습니다."),
+ UPDATE_ONLY_AUTHOR(BAD_REQUEST, "수정은 작성자만 가능합니다."),
+ UPDATE_ONLY_BELONGING_TEAM(BAD_REQUEST, "해당 팀에 소속된 파일이 아닙니다."),
+
+ /* 404 NOT_FOUND : 자원을 찾을 수 없음 */
+ INVALID_NOTE(NOT_FOUND, "유효하지 않은 노트입니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/note/message/SuccessMessage.java b/src/main/java/com/tiki/server/note/message/SuccessMessage.java
new file mode 100644
index 00000000..0d9a6ff5
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/message/SuccessMessage.java
@@ -0,0 +1,16 @@
+package com.tiki.server.note.message;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum SuccessMessage {
+
+ GET_NOTE("노트 조회 성공"),
+ GET_NOTE_DETAIL("노트 상세 조회 성공"),
+ CREATE_NOTE("노트 생성 성공"),
+ UPDATE_NOTE("노트 수정 성공");
+
+ private final String message;
+}
diff --git a/src/main/java/com/tiki/server/note/repository/NoteRepository.java b/src/main/java/com/tiki/server/note/repository/NoteRepository.java
new file mode 100644
index 00000000..e8e6ab54
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/repository/NoteRepository.java
@@ -0,0 +1,25 @@
+package com.tiki.server.note.repository;
+
+import com.tiki.server.note.entity.Note;
+
+import io.lettuce.core.dynamic.annotation.Param;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface NoteRepository extends JpaRepository {
+
+ @Query("SELECT n FROM Note n WHERE n.teamId = :teamId AND n.createdAt < :createdAt ORDER BY n.createdAt DESC")
+ List findByTeamIdAndCreatedAtBeforeOrderByCreatedDesc(@Param("createdAt") final LocalDateTime createdAt,
+ final Pageable pageable, final long teamId);
+
+ @Query("SELECT n FROM Note n WHERE n.teamId = :teamId AND n.createdAt > :createdAt ORDER BY n.createdAt ASC")
+ List findByTeamIdAndCreatedAtAfterOrderByCreatedAtAsc(@Param("createdAt") final LocalDateTime createdAt,
+ final Pageable pageable, final long teamId);
+
+ List findAllByMemberIdAndTeamId(final long memberId, final long TeamId);
+}
diff --git a/src/main/java/com/tiki/server/note/service/NoteService.java b/src/main/java/com/tiki/server/note/service/NoteService.java
new file mode 100644
index 00000000..8b5c46ce
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/NoteService.java
@@ -0,0 +1,245 @@
+package com.tiki.server.note.service;
+
+import com.tiki.server.common.entity.SortOrder;
+import com.tiki.server.common.util.ContentEncoder;
+import com.tiki.server.document.adapter.DocumentFinder;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.memberteammanager.adapter.MemberTeamManagerFinder;
+import com.tiki.server.note.adapter.NoteDeleter;
+import com.tiki.server.note.adapter.NoteFinder;
+import com.tiki.server.note.adapter.NoteSaver;
+import com.tiki.server.note.entity.Note;
+import com.tiki.server.note.entity.NoteType;
+import com.tiki.server.note.service.dto.request.*;
+import com.tiki.server.note.service.dto.response.*;
+import com.tiki.server.notedocumentmanager.adapter.NDDeleter;
+import com.tiki.server.notedocumentmanager.adapter.NDFinder;
+import com.tiki.server.notedocumentmanager.adapter.NDSaver;
+import com.tiki.server.notedocumentmanager.entity.NDManager;
+import com.tiki.server.notetimeblockmanager.adapter.NTBDeleter;
+import com.tiki.server.notetimeblockmanager.adapter.NTBFinder;
+import com.tiki.server.notetimeblockmanager.adapter.NTBSaver;
+import com.tiki.server.notetimeblockmanager.entity.NTBManager;
+import com.tiki.server.timeblock.adapter.TimeBlockFinder;
+import com.tiki.server.timeblock.entity.TimeBlock;
+
+import lombok.RequiredArgsConstructor;
+
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static com.tiki.server.common.constants.Constants.INIT_NUM;
+import static com.tiki.server.note.constants.NoteConstants.PAGE_SIZE;
+
+@Service
+@RequiredArgsConstructor
+public class NoteService {
+
+ private final MemberTeamManagerFinder memberTeamManagerFinder;
+ private final NoteSaver noteSaver;
+ private final NoteFinder noteFinder;
+ private final NoteDeleter noteDeleter;
+ private final NTBFinder ntbFinder;
+ private final NTBSaver ntbSaver;
+ private final NTBDeleter ntbDeleter;
+ private final NDFinder ndFinder;
+ private final NDSaver ndSaver;
+ private final NDDeleter ndDeleter;
+ private final TimeBlockFinder timeBlockFinder;
+ private final DocumentFinder documentFinder;
+
+ @Transactional
+ public NoteCreateServiceResponse createNoteFree(final NoteFreeCreateServiceRequest request) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId());
+ String encryptedContents = ContentEncoder.encodeNoteFree(request.contents());
+ Note note = createNote(NoteBase.of(request), encryptedContents, NoteType.FREE);
+ createNoteTimeBlockManagers(request.timeBlockIds(), note.getId());
+ createNoteDocumentManagers(request.documentIds(), note.getId());
+ return NoteCreateServiceResponse.from(note.getId());
+ }
+
+ @Transactional
+ public NoteCreateServiceResponse createNoteTemplate(final NoteTemplateCreateServiceRequest request) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId());
+ String encryptedContents = ContentEncoder.encodeNoteTemplate(
+ request.answerWhatActivity(),
+ request.answerHowToPrepare(),
+ request.answerWhatIsDisappointedThing(),
+ request.answerHowToFix()
+ );
+ Note note = createNote(NoteBase.of(request), encryptedContents, NoteType.TEMPLATE);
+ createNoteTimeBlockManagers(request.timeBlockIds(), note.getId());
+ createNoteDocumentManagers(request.documentIds(), note.getId());
+ return NoteCreateServiceResponse.from(note.getId());
+ }
+
+ @Transactional
+ public void updateNoteFree(final NoteFreeUpdateServiceRequest request) {
+ Note note = noteFinder.findById(request.noteId());
+ memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId());
+ String encryptedContents = ContentEncoder.encodeNoteFree(request.contents());
+ note.updateValue(
+ request.memberId(),
+ request.teamId(),
+ request.title(),
+ encryptedContents,
+ request.startDate(),
+ request.endDate(),
+ request.complete(),
+ NoteType.FREE
+ );
+ updateNoteTimeBlockManager(request.timeBlockIds(), note.getId());
+ updateNoteDocumentManager(request.documentIds(), note.getId());
+ }
+
+ @Transactional
+ public void updateNoteTemplate(final NoteTemplateUpdateServiceRequest request) {
+ Note note = noteFinder.findById(request.noteId());
+ memberTeamManagerFinder.findByMemberIdAndTeamId(request.memberId(), request.teamId());
+ String encryptedContents = ContentEncoder.encodeNoteTemplate(
+ request.answerWhatActivity(),
+ request.answerHowToPrepare(),
+ request.answerWhatIsDisappointedThing(),
+ request.answerHowToFix()
+ );
+ note.updateValue(
+ request.memberId(),
+ request.teamId(),
+ request.title(),
+ encryptedContents,
+ request.startDate(),
+ request.endDate(),
+ request.complete(),
+ NoteType.TEMPLATE
+ );
+ updateNoteTimeBlockManager(request.timeBlockIds(), request.noteId());
+ updateNoteDocumentManager(request.documentIds(), request.noteId());
+ }
+
+ @Transactional
+ public void deleteNotes(final List noteIds, final long teamId, final long memberId) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ ndDeleter.deleteByNoteIds(noteIds);
+ ntbDeleter.noteTimeBlockManagerDeleteByIds(noteIds);
+ noteDeleter.deleteNoteByIds(noteIds);
+ }
+
+ public NoteListGetServiceResponse getNote(
+ final long teamId,
+ final long memberId,
+ final LocalDateTime createdAt,
+ final SortOrder sortOrder
+ ) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ PageRequest pageable = PageRequest.of(INIT_NUM, PAGE_SIZE);
+ List noteList = getNotes(createdAt, sortOrder, pageable, teamId);
+ List noteGetResponses = noteList.stream()
+ .map(note -> NoteGetResponse.of(note, getMemberName(note.getMemberId(), teamId)))
+ .toList();
+ return new NoteListGetServiceResponse(noteGetResponses);
+ }
+
+ public NoteDetailGetServiceResponse getNoteDetail(final long teamId, final long memberId, final long noteId) {
+ memberTeamManagerFinder.findByMemberIdAndTeamId(memberId, teamId);
+ Note note = noteFinder.findById(noteId);
+ List documentList = getDocumentListMappedByNote(noteId);
+ List timeBlockList = getTimeBlocksMappedByNote(noteId);
+ String memberName = getMemberName(note.getMemberId(), teamId);
+ return note.getNoteType() == NoteType.FREE
+ ? NoteFreeDetailGetServiceResponse.of(note, memberName, documentList, timeBlockList)
+ : NoteTemplateDetailGetServiceResponse.of(note, memberName, documentList, timeBlockList);
+ }
+
+ private String getMemberName(final Long noteMemberId, final long teamId) {
+ return Optional.ofNullable(noteMemberId)
+ .map(id -> memberTeamManagerFinder.findByMemberIdAndTeamId(id, teamId).getName())
+ .orElse("알 수 없음");
+ }
+
+ private void updateNoteDocumentManager(final List documentIds, final long noteId) {
+ List existingNoteDocumentIds = ndFinder.findAllByNoteId(noteId).stream()
+ .map(NDManager::getDocumentId)
+ .toList();
+ List idsToAdd = documentIds.stream()
+ .filter(id -> !existingNoteDocumentIds.contains(id))
+ .toList();
+ List idsToRemove = existingNoteDocumentIds.stream()
+ .filter(id -> !documentIds.contains(id))
+ .toList();
+ createNoteDocumentManagers(idsToAdd, noteId);
+ ndDeleter.deleteByNoteIdAndDocumentId(noteId, idsToRemove);
+ }
+
+ private void updateNoteTimeBlockManager(final List timeBlockIds, final long noteId) {
+ List existingNoteTimeBlockIds = ntbFinder.findAllByNoteId(noteId).stream()
+ .map(NTBManager::getTimeBlockId)
+ .toList();
+ List idsToAdd = timeBlockIds.stream()
+ .filter(id -> !existingNoteTimeBlockIds.contains(id))
+ .toList();
+ List idsToRemove = existingNoteTimeBlockIds.stream()
+ .filter(id -> !timeBlockIds.contains(id))
+ .toList();
+ createNoteTimeBlockManagers(idsToAdd, noteId);
+ ntbDeleter.deleteByNoteIdAndTimeBlockId(noteId, idsToRemove);
+ }
+
+ private List getNotes(final LocalDateTime createdAt, final SortOrder sortOrder, final PageRequest pageable,
+ final long teamId) {
+ if (sortOrder == SortOrder.DESC) {
+ return noteFinder.findByCreatedAtBeforeOrderByModifiedAtDesc(createdAt, pageable, teamId);
+ }
+ return noteFinder.findByCreatedAtAfterOrderByModifiedAtAsc(createdAt, pageable, teamId);
+ }
+
+ private List getTimeBlocksMappedByNote(final long noteId) {
+ List timblockIdList = ntbFinder.findAllByNoteId(noteId).stream()
+ .map(NTBManager::getTimeBlockId)
+ .toList();
+ return timblockIdList.stream()
+ .map(timeBlockFinder::findById)
+ .toList();
+ }
+
+ private List getDocumentListMappedByNote(final long noteId) {
+ List documentIdList = ndFinder.findAllByNoteId(noteId).stream()
+ .map(NDManager::getDocumentId)
+ .toList();
+ return documentIdList.stream()
+ .map(documentFinder::findById)
+ .toList();
+ }
+
+ private Note createNote(final NoteBase request, final String encryptedContents, final NoteType noteType) {
+ return noteSaver.createNote(
+ Note.of(
+ request.title(),
+ request.complete(),
+ request.startDate(),
+ request.endDate(),
+ encryptedContents,
+ request.memberId(),
+ request.teamId(),
+ noteType
+ ));
+ }
+
+ private void createNoteTimeBlockManagers(final List timeBlockIds, final long noteId) {
+ timeBlockIds.stream()
+ .filter(timeBlockFinder::existsById)
+ .map(timeBlockId -> NTBManager.of(noteId, timeBlockId))
+ .forEach(ntbSaver::save);
+ }
+
+ private void createNoteDocumentManagers(final List documentIds, final long noteId) {
+ documentIds.stream()
+ .filter(documentFinder::existsById)
+ .map(documentId -> NDManager.of(noteId, documentId))
+ .forEach(ndSaver::save);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteBase.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteBase.java
new file mode 100644
index 00000000..40f9fe77
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteBase.java
@@ -0,0 +1,44 @@
+package com.tiki.server.note.service.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteBase(
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull long memberId,
+ @NotNull long teamId,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds
+) {
+
+ public static NoteBase of(final NoteFreeCreateServiceRequest noteFreeCreateServiceRequest) {
+ return new NoteBase(
+ noteFreeCreateServiceRequest.title(),
+ noteFreeCreateServiceRequest.complete(),
+ noteFreeCreateServiceRequest.startDate(),
+ noteFreeCreateServiceRequest.endDate(),
+ noteFreeCreateServiceRequest.memberId(),
+ noteFreeCreateServiceRequest.teamId(),
+ noteFreeCreateServiceRequest.timeBlockIds(),
+ noteFreeCreateServiceRequest.documentIds()
+ );
+ }
+
+ public static NoteBase of(final NoteTemplateCreateServiceRequest noteTemplateCreateServiceRequest) {
+ return new NoteBase(
+ noteTemplateCreateServiceRequest.title(),
+ noteTemplateCreateServiceRequest.complete(),
+ noteTemplateCreateServiceRequest.startDate(),
+ noteTemplateCreateServiceRequest.endDate(),
+ noteTemplateCreateServiceRequest.memberId(),
+ noteTemplateCreateServiceRequest.teamId(),
+ noteTemplateCreateServiceRequest.timeBlockIds(),
+ noteTemplateCreateServiceRequest.documentIds()
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeCreateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeCreateServiceRequest.java
new file mode 100644
index 00000000..a2eda438
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeCreateServiceRequest.java
@@ -0,0 +1,37 @@
+package com.tiki.server.note.service.dto.request;
+
+import com.tiki.server.note.controller.dto.request.NoteFreeCreateRequest;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteFreeCreateServiceRequest(
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String contents,
+ @NotNull long teamId,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long memberId
+) {
+ public static NoteFreeCreateServiceRequest of(
+ final NoteFreeCreateRequest request,
+ final long memberId
+ ) {
+ return new NoteFreeCreateServiceRequest(
+ request.title(),
+ request.complete(),
+ request.startDate(),
+ request.endDate(),
+ request.contents(),
+ request.teamId(),
+ request.timeBlockIds(),
+ request.documentIds(),
+ memberId
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeUpdateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeUpdateServiceRequest.java
new file mode 100644
index 00000000..5c2f544c
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteFreeUpdateServiceRequest.java
@@ -0,0 +1,41 @@
+package com.tiki.server.note.service.dto.request;
+
+import com.tiki.server.note.controller.dto.request.NoteFreeUpdateRequest;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteFreeUpdateServiceRequest(
+ @NotNull long noteId,
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String contents,
+ @NotNull long teamId,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long memberId
+) {
+
+ public static NoteFreeUpdateServiceRequest of(
+ final NoteFreeUpdateRequest request,
+ final long noteId,
+ final long memberId
+ ) {
+ return new NoteFreeUpdateServiceRequest(
+ noteId,
+ request.title(),
+ request.complete(),
+ request.startDate(),
+ request.endDate(),
+ request.contents(),
+ request.teamId(),
+ request.timeBlockIds(),
+ request.documentIds(),
+ memberId
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateCreateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateCreateServiceRequest.java
new file mode 100644
index 00000000..f03c93ce
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateCreateServiceRequest.java
@@ -0,0 +1,43 @@
+package com.tiki.server.note.service.dto.request;
+
+import com.tiki.server.note.controller.dto.request.NoteTemplateCreateRequest;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteTemplateCreateServiceRequest(
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String answerWhatActivity,
+ @NotNull String answerHowToPrepare,
+ @NotNull String answerWhatIsDisappointedThing,
+ @NotNull String answerHowToFix,
+ @NotNull long teamId,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long memberId
+) {
+ public static NoteTemplateCreateServiceRequest of(
+ final NoteTemplateCreateRequest request,
+ final long memberId
+ ) {
+ return new NoteTemplateCreateServiceRequest(
+ request.title(),
+ request.complete(),
+ request.startDate(),
+ request.endDate(),
+ request.answerWhatActivity(),
+ request.answerHowToPrepare(),
+ request.answerWhatIsDisappointedThing(),
+ request.answerHowToFix(),
+ request.teamId(),
+ request.timeBlockIds(),
+ request.documentIds(),
+ memberId
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateUpdateServiceRequest.java b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateUpdateServiceRequest.java
new file mode 100644
index 00000000..414f2c35
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/request/NoteTemplateUpdateServiceRequest.java
@@ -0,0 +1,47 @@
+package com.tiki.server.note.service.dto.request;
+
+import com.tiki.server.note.controller.dto.request.NoteTemplateUpdateRequest;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteTemplateUpdateServiceRequest(
+ @NotNull long noteId,
+ @NotNull String title,
+ @NotNull boolean complete,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String answerWhatActivity,
+ @NotNull String answerHowToPrepare,
+ @NotNull String answerWhatIsDisappointedThing,
+ @NotNull String answerHowToFix,
+ @NotNull long teamId,
+ @NotNull List timeBlockIds,
+ @NotNull List documentIds,
+ @NotNull long memberId
+) {
+
+ public static NoteTemplateUpdateServiceRequest of(
+ final NoteTemplateUpdateRequest request,
+ final long noteId,
+ final long memberId
+ ) {
+ return new NoteTemplateUpdateServiceRequest(
+ noteId,
+ request.title(),
+ request.complete(),
+ request.startDate(),
+ request.endDate(),
+ request.answerWhatActivity(),
+ request.answerHowToPrepare(),
+ request.answerWhatIsDisappointedThing(),
+ request.answerHowToFix(),
+ request.teamId(),
+ request.timeBlockIds(),
+ request.documentIds(),
+ memberId
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteCreateServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteCreateServiceResponse.java
new file mode 100644
index 00000000..5167e271
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteCreateServiceResponse.java
@@ -0,0 +1,11 @@
+package com.tiki.server.note.service.dto.response;
+
+import jakarta.validation.constraints.NotNull;
+
+public record NoteCreateServiceResponse(
+ @NotNull long noteId
+) {
+ public static NoteCreateServiceResponse from(final long noteId) {
+ return new NoteCreateServiceResponse(noteId);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteDetailGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteDetailGetServiceResponse.java
new file mode 100644
index 00000000..1758caea
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteDetailGetServiceResponse.java
@@ -0,0 +1,11 @@
+package com.tiki.server.note.service.dto.response;
+
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(
+ description = "NoteDetail 응답 객체",
+ oneOf = {NoteFreeDetailGetServiceResponse.class, NoteTemplateDetailGetServiceResponse.class}
+)
+public interface NoteDetailGetServiceResponse {
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteFreeDetailGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteFreeDetailGetServiceResponse.java
new file mode 100644
index 00000000..2266832f
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteFreeDetailGetServiceResponse.java
@@ -0,0 +1,48 @@
+package com.tiki.server.note.service.dto.response;
+
+import com.tiki.server.common.util.ContentDecoder;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.document.service.dto.response.DocumentTagGetServiceResponse;
+import com.tiki.server.note.entity.Note;
+import com.tiki.server.note.entity.NoteType;
+import com.tiki.server.timeblock.entity.TimeBlock;
+import com.tiki.server.timeblock.service.dto.response.TimeBlockTagServiceResponse;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteFreeDetailGetServiceResponse(
+ @NotNull long noteId,
+ @NotNull NoteType noteType,
+ @NotNull String title,
+ @NotNull String author,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull boolean complete,
+ @NotNull String contents,
+ @NotNull List documentList,
+ @NotNull List timeBlockList
+) implements NoteDetailGetServiceResponse {
+
+ public static NoteFreeDetailGetServiceResponse of(
+ final Note note,
+ final String author,
+ final List documentList,
+ final List timeBlockList
+ ) {
+ return new NoteFreeDetailGetServiceResponse(
+ note.getId(),
+ NoteType.FREE,
+ note.getTitle(),
+ author,
+ note.getStartDate(),
+ note.getEndDate(),
+ note.isComplete(),
+ ContentDecoder.decodeNoteFree(note.getContents()),
+ documentList.stream().map(DocumentTagGetServiceResponse::from).toList(),
+ timeBlockList.stream().map(TimeBlockTagServiceResponse::from).toList()
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteGetResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteGetResponse.java
new file mode 100644
index 00000000..25b59495
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteGetResponse.java
@@ -0,0 +1,31 @@
+package com.tiki.server.note.service.dto.response;
+
+import com.tiki.server.note.entity.Note;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import jakarta.validation.constraints.NotNull;
+
+public record NoteGetResponse(
+ @NotNull long noteId,
+ @NotNull String title,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull String author,
+ @NotNull boolean complete,
+ @NotNull LocalDateTime lastUpdatedAt
+) {
+
+ public static NoteGetResponse of(final Note note, final String author) {
+ return new NoteGetResponse(
+ note.getId(),
+ note.getTitle(),
+ note.getStartDate(),
+ note.getEndDate(),
+ author,
+ note.isComplete(),
+ note.getUpdatedAt()
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteListGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteListGetServiceResponse.java
new file mode 100644
index 00000000..e0881cee
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteListGetServiceResponse.java
@@ -0,0 +1,14 @@
+package com.tiki.server.note.service.dto.response;
+
+import java.util.List;
+
+import jakarta.validation.constraints.NotNull;
+
+public record NoteListGetServiceResponse(
+ @NotNull List noteGetResponseList
+) {
+
+ public static NoteListGetServiceResponse of(final List noteGetResponseList) {
+ return new NoteListGetServiceResponse(noteGetResponseList);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/note/service/dto/response/NoteTemplateDetailGetServiceResponse.java b/src/main/java/com/tiki/server/note/service/dto/response/NoteTemplateDetailGetServiceResponse.java
new file mode 100644
index 00000000..7f252024
--- /dev/null
+++ b/src/main/java/com/tiki/server/note/service/dto/response/NoteTemplateDetailGetServiceResponse.java
@@ -0,0 +1,55 @@
+package com.tiki.server.note.service.dto.response;
+
+import com.tiki.server.common.util.ContentDecoder;
+import com.tiki.server.document.entity.Document;
+import com.tiki.server.document.service.dto.response.DocumentTagGetServiceResponse;
+import com.tiki.server.note.entity.Note;
+import com.tiki.server.note.entity.NoteType;
+import com.tiki.server.timeblock.entity.TimeBlock;
+import com.tiki.server.timeblock.service.dto.response.TimeBlockTagServiceResponse;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public record NoteTemplateDetailGetServiceResponse(
+ @NotNull long noteId,
+ @NotNull NoteType noteType,
+ @NotNull String title,
+ @NotNull String author,
+ @NotNull LocalDate startDate,
+ @NotNull LocalDate endDate,
+ @NotNull boolean complete,
+ @NotNull String answerWhatActivity,
+ @NotNull String answerHowToPrepare,
+ @NotNull String answerWhatIsDisappointedThing,
+ @NotNull String answerHowToFix,
+ @NotNull List documentList,
+ @NotNull List timeBlockList
+) implements NoteDetailGetServiceResponse {
+
+ public static NoteTemplateDetailGetServiceResponse of(
+ final Note note,
+ final String author,
+ final List documentList,
+ final List timeBlockList
+ ) {
+ List contents = ContentDecoder.decodeNoteTemplate(note.getContents());
+ return new NoteTemplateDetailGetServiceResponse(
+ note.getId(),
+ NoteType.TEMPLATE,
+ note.getTitle(),
+ author,
+ note.getStartDate(),
+ note.getEndDate(),
+ note.isComplete(),
+ contents.get(0),
+ contents.get(1),
+ contents.get(2),
+ contents.get(3),
+ documentList.stream().map(DocumentTagGetServiceResponse::from).toList(),
+ timeBlockList.stream().map(TimeBlockTagServiceResponse::from).toList()
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDDeleter.java b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDDeleter.java
new file mode 100644
index 00000000..ac63ea35
--- /dev/null
+++ b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDDeleter.java
@@ -0,0 +1,24 @@
+package com.tiki.server.notedocumentmanager.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.notedocumentmanager.repository.NDRepository;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NDDeleter {
+
+ private final NDRepository ndRepository;
+
+ public void deleteByNoteIds(final List noteIds) {
+ noteIds.forEach(ndRepository::deleteAllByNoteId);
+ }
+
+ public void deleteByNoteIdAndDocumentId(final long noteId, final List documentIds) {
+ documentIds.forEach(documentId ->
+ ndRepository.deleteByNoteIdAndDocumentId(noteId, documentId)
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDFinder.java b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDFinder.java
new file mode 100644
index 00000000..52c2350f
--- /dev/null
+++ b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDFinder.java
@@ -0,0 +1,19 @@
+package com.tiki.server.notedocumentmanager.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.notedocumentmanager.entity.NDManager;
+import com.tiki.server.notedocumentmanager.repository.NDRepository;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NDFinder {
+
+ private final NDRepository ndRepository;
+
+ public List findAllByNoteId(final long noteId){
+ return ndRepository.findAllByNoteId(noteId);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDSaver.java b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDSaver.java
new file mode 100644
index 00000000..86c47d88
--- /dev/null
+++ b/src/main/java/com/tiki/server/notedocumentmanager/adapter/NDSaver.java
@@ -0,0 +1,17 @@
+package com.tiki.server.notedocumentmanager.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.notedocumentmanager.entity.NDManager;
+import com.tiki.server.notedocumentmanager.repository.NDRepository;
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NDSaver {
+
+ private final NDRepository ndRepository;
+
+ public NDManager save(final NDManager ndManager) {
+ return ndRepository.save(ndManager);
+ }
+}
diff --git a/src/main/java/com/tiki/server/notedocumentmanager/entity/NDManager.java b/src/main/java/com/tiki/server/notedocumentmanager/entity/NDManager.java
new file mode 100644
index 00000000..7125feea
--- /dev/null
+++ b/src/main/java/com/tiki/server/notedocumentmanager/entity/NDManager.java
@@ -0,0 +1,39 @@
+package com.tiki.server.notedocumentmanager.entity;
+
+import com.tiki.server.common.entity.BaseTime;
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+@Entity
+@Getter
+@Builder(access = PRIVATE)
+@AllArgsConstructor(access = PRIVATE)
+@NoArgsConstructor(access = PROTECTED)
+@Table(name = "note_document_manager")
+public class NDManager extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ @Column(name = "manager_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private long noteId;
+
+ @Column(nullable = false)
+ private long documentId;
+
+ public static NDManager of(final long noteId, final long documentId) {
+ return NDManager.builder()
+ .noteId(noteId)
+ .documentId(documentId)
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/notedocumentmanager/repository/NDRepository.java b/src/main/java/com/tiki/server/notedocumentmanager/repository/NDRepository.java
new file mode 100644
index 00000000..f924cc1f
--- /dev/null
+++ b/src/main/java/com/tiki/server/notedocumentmanager/repository/NDRepository.java
@@ -0,0 +1,15 @@
+package com.tiki.server.notedocumentmanager.repository;
+
+import com.tiki.server.notedocumentmanager.entity.NDManager;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface NDRepository extends JpaRepository {
+
+ void deleteAllByNoteId(final long noteId);
+
+ void deleteByNoteIdAndDocumentId(final long noteId, final long documentId);
+
+ List findAllByNoteId(final long noteId);
+}
diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBDeleter.java b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBDeleter.java
new file mode 100644
index 00000000..13269f49
--- /dev/null
+++ b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBDeleter.java
@@ -0,0 +1,24 @@
+package com.tiki.server.notetimeblockmanager.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.notetimeblockmanager.repository.NTBRepository;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NTBDeleter {
+
+ private final NTBRepository ntbRepository;
+
+ public void noteTimeBlockManagerDeleteByIds(final List noteIds) {
+ noteIds.forEach(ntbRepository::deleteAllByNoteId);
+ }
+
+ public void deleteByNoteIdAndTimeBlockId(final long noteId, final List timeBlockIds) {
+ timeBlockIds.forEach(timeBlockId ->
+ ntbRepository.deleteByNoteIdAndTimeBlockId(noteId, timeBlockId)
+ );
+ }
+}
diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBFinder.java b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBFinder.java
new file mode 100644
index 00000000..76f9d469
--- /dev/null
+++ b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBFinder.java
@@ -0,0 +1,23 @@
+package com.tiki.server.notetimeblockmanager.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.notetimeblockmanager.entity.NTBManager;
+import com.tiki.server.notetimeblockmanager.repository.NTBRepository;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NTBFinder {
+
+ private final NTBRepository ntbRepository;
+
+ public List findAllByNoteId(final long noteId) {
+ return ntbRepository.findAllByNoteId(noteId);
+ }
+
+ public List findAllByTimeBlockId(final long timeBlockId) {
+ return ntbRepository.findAllByTimeBlockId(timeBlockId);
+ }
+}
diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBSaver.java b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBSaver.java
new file mode 100644
index 00000000..5dd673bc
--- /dev/null
+++ b/src/main/java/com/tiki/server/notetimeblockmanager/adapter/NTBSaver.java
@@ -0,0 +1,17 @@
+package com.tiki.server.notetimeblockmanager.adapter;
+
+import com.tiki.server.common.support.RepositoryAdapter;
+import com.tiki.server.notetimeblockmanager.entity.NTBManager;
+import com.tiki.server.notetimeblockmanager.repository.NTBRepository;
+import lombok.RequiredArgsConstructor;
+
+@RepositoryAdapter
+@RequiredArgsConstructor
+public class NTBSaver {
+
+ private final NTBRepository ntbRepository;
+
+ public NTBManager save(final NTBManager ntbManager) {
+ return ntbRepository.save(ntbManager);
+ }
+}
diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/entity/NTBManager.java b/src/main/java/com/tiki/server/notetimeblockmanager/entity/NTBManager.java
new file mode 100644
index 00000000..0cf5d45f
--- /dev/null
+++ b/src/main/java/com/tiki/server/notetimeblockmanager/entity/NTBManager.java
@@ -0,0 +1,39 @@
+package com.tiki.server.notetimeblockmanager.entity;
+
+import com.tiki.server.common.entity.BaseTime;
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import static jakarta.persistence.GenerationType.IDENTITY;
+import static lombok.AccessLevel.PRIVATE;
+import static lombok.AccessLevel.PROTECTED;
+
+@Entity
+@Getter
+@Builder(access = PRIVATE)
+@AllArgsConstructor(access = PRIVATE)
+@NoArgsConstructor(access = PROTECTED)
+@Table(name = "note_time_block_manager")
+public class NTBManager extends BaseTime {
+
+ @Id
+ @GeneratedValue(strategy = IDENTITY)
+ @Column(name = "manager_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private long noteId;
+
+ @Column(nullable = false)
+ private long timeBlockId;
+
+ public static NTBManager of(final long noteId, final long timeBlockId) {
+ return NTBManager.builder()
+ .noteId(noteId)
+ .timeBlockId(timeBlockId)
+ .build();
+ }
+}
diff --git a/src/main/java/com/tiki/server/notetimeblockmanager/repository/NTBRepository.java b/src/main/java/com/tiki/server/notetimeblockmanager/repository/NTBRepository.java
new file mode 100644
index 00000000..b6a70e8e
--- /dev/null
+++ b/src/main/java/com/tiki/server/notetimeblockmanager/repository/NTBRepository.java
@@ -0,0 +1,17 @@
+package com.tiki.server.notetimeblockmanager.repository;
+
+import com.tiki.server.notetimeblockmanager.entity.NTBManager;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface NTBRepository extends JpaRepository