diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8f7f199 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: ✨ Feature Request +about: 새로운 기능을 제안하세요. +title: "[FEAT] <기능 요약>" +labels: enhancement +assignees: '' +--- + + + + + + + +## 🚀 기능 설명 + + + +## 🏆 작업 목록 + +- + +## 🔗 참고 자료 + diff --git a/.github/ISSUE_TEMPLATE/troubleshooting.md b/.github/ISSUE_TEMPLATE/troubleshooting.md new file mode 100644 index 0000000..7e142e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/troubleshooting.md @@ -0,0 +1,40 @@ +--- +name: ✨ Trouble Shooting +about: 문제를 해결했던 기록을 남겨봐요. +title: "[Trouble Shooting] <오류 요약>" +labels: bug +assignees: '' +--- + + + + + + + +## 📌 이슈 설명 + + + +## 🚀 Description +- [ ] +- [ ] +- [ ] + +## ⏰ 문제 해결을 위해 시도한 점 + + +## ❄️ 주의할 점 + + + +## 🔗 참고 자료 + + + +## ✅ TODO +- [ ] label 확인 +- [ ] assigness 확인 \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8358624 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ + + + + +## 🛰️ Issue Number + +close # + + +## 🪐 작업 내용 + + + + +## ⚠️ PR 특이 사항 + + + + +## 📚 Reference + + + + +### ✅ Check List +- [ ] 코드가 정상적으로 컴파일되나요? +- [ ] 포스트맨에서 결과값을 제대로 확인했나요? +- [ ] 리뷰어 설정을 지정했나요? +- [ ] merge할 브랜치의 위치를 확인했나요? +- [ ] Label을 지정했나요? diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9ebc07c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,92 @@ +name: Deploy to AWS + +on: + push: + branches: [master] + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: spring-vote-dev + EC2_HOST: ${{ secrets.EC2_HOST }} + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Build with Gradle + run: chmod +x ./gradlew && ./gradlew build -x test + + - name: Build and push Docker image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ec2-user + key: ${{ secrets.EC2_SSH_KEY }} + script: | + mkdir -p /home/ec2-user/app + cd /home/ec2-user/app + + cat > docker-compose.yml << 'EOF' + version: '3.8' + services: + app: + image: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/spring-vote-dev:latest + ports: + - "8080:8080" + environment: + - DB_HOST=${{ secrets.DB_HOST }} + - DB_PORT=5432 + - DB_NAME=${{ secrets.DB_NAME }} + - DB_USERNAME=${{ secrets.DB_USERNAME }} + - DB_PASSWORD=${{ secrets.DB_PASSWORD }} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:alpine + ports: + - "6379:6379" + restart: unless-stopped + EOF + + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com + + docker-compose down || true + docker-compose pull + docker-compose up -d \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d607a29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +HELP.md +.env +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +# Terraform +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# 민감 정보 +*.tfvars +*.tfvars.json +*.auto.tfvars +*.auto.tfvars.json + +# 로컬 백엔드 +terraform.tfstate.d/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9988f54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /app +COPY . . +RUN chmod +x ./gradlew && ./gradlew build -x test + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 7f5d396..74a0691 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # spring-vote-22nd ceos back-end 22nd voting service project + +[🚀 중간과제 발표 자료](https://www.figma.com/proto/3dN8Wnp3DyfXGTRm0ULMRF/CEOS-%EB%94%94%EA%B8%B4%EB%94%94?page-id=1813%3A15434&node-id=1962-2108&viewport=805%2C211%2C0.11&t=6ZI3P9hnxkRAE4bh-1&scaling=contain&content-scaling=fixed&starting-point-node-id=1962%3A2108) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d26e2fc --- /dev/null +++ b/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.0' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.diggindie' +version = '0.0.1-SNAPSHOT' +description = 'voting service for ceos 22nd' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + + // Spring Boot Starter + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Database + runtimeOnly 'org.postgresql:postgresql' + + // Lombok + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.postgresql:postgresql' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + annotationProcessor 'org.projectlombok:lombok' + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + + // reddison + implementation 'org.redisson:redisson:3.27.0' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e7b68fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' +services: + app: + build: . + ports: + - "8080:8080" + environment: + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT} + - DB_NAME=${DB_NAME} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + depends_on: + - redis + + redis: + image: redis:alpine + ports: + - "6379:6379" \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/infra/blue/main.tf b/infra/blue/main.tf new file mode 100644 index 0000000..f5ef25f --- /dev/null +++ b/infra/blue/main.tf @@ -0,0 +1,144 @@ +<<<<<<< HEAD +# blue/main.tf - Blue 환경 배포 설정 +======= +# blue/main.tf - Blue EC2 환경 +>>>>>>> dev + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" + + default_tags { + tags = { + Project = "spring-vote" + Environment = "blue" + ManagedBy = "Terraform" + } + } +} + +# 기존 인프라 참조 +data "aws_vpc" "main" { + tags = { + Name = "spring-vote-dev-vpc" + } +} + +<<<<<<< HEAD +data "aws_subnets" "public" { + filter { + name = "vpc-id" + values = [data.aws_vpc.main.id] + } + + filter { + name = "tag:Type" + values = ["Public"] +======= +data "aws_subnet" "public" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "tag:Name" + values = ["spring-vote-dev-public-1"] +>>>>>>> dev + } +} + +data "aws_security_group" "web" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "group-name" + values = ["spring-vote-dev-web-sg"] + } +} + +<<<<<<< HEAD +data "aws_ecs_cluster" "main" { + cluster_name = "spring-vote-dev-cluster" +} + +# Blue ECS Service +module "ecs_service_blue" { + source = "../modules/ecs_service" + + project_name = "spring-vote" + environment = "blue" + cluster_id = data.aws_ecs_cluster.main.id + subnet_ids = data.aws_subnets.public.ids + security_group_id = data.aws_security_group.web.id + container_image = "your-ecr-repo:blue" # TODO: 실제 이미지로 변경 + container_port = 8080 + desired_count = 1 +} + +output "blue_service_name" { + value = module.ecs_service_blue.service_name +} +======= +# Amazon Linux 2023 AMI +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# Blue EC2 인스턴스 +resource "aws_instance" "blue" { + ami = data.aws_ami.amazon_linux.id + instance_type = "t2.nano" + key_name = "terraform" + subnet_id = data.aws_subnet.public.id + vpc_security_group_ids = [data.aws_security_group.web.id] + + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = 30 + delete_on_termination = true + encrypted = true + } + + user_data = <<-EOF + #!/bin/bash + yum update -y + yum install -y docker + systemctl start docker + systemctl enable docker + usermod -a -G docker ec2-user + EOF + + tags = { + Name = "spring-vote-blue" + } +} + +output "blue_instance_id" { + value = aws_instance.blue.id +} + +output "blue_public_ip" { + value = aws_instance.blue.public_ip +} +>>>>>>> dev diff --git a/infra/green/main.tf b/infra/green/main.tf new file mode 100644 index 0000000..647c862 --- /dev/null +++ b/infra/green/main.tf @@ -0,0 +1,147 @@ +<<<<<<< HEAD +# green/main.tf - Green 환경 배포 설정 +======= +# green/main.tf - Green EC2 환경 +>>>>>>> dev + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" + + default_tags { + tags = { + Project = "spring-vote" + Environment = "green" + ManagedBy = "Terraform" + } + } +} + +<<<<<<< HEAD +# 기존 인프라 참조 (Remote State 또는 Data Source 사용) +======= +# 기존 인프라 참조 +>>>>>>> dev +data "aws_vpc" "main" { + tags = { + Name = "spring-vote-dev-vpc" + } +} + +<<<<<<< HEAD +data "aws_subnets" "public" { + filter { + name = "vpc-id" + values = [data.aws_vpc.main.id] + } + + filter { + name = "tag:Type" + values = ["Public"] +======= +data "aws_subnet" "public" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "tag:Name" + values = ["spring-vote-dev-public-1"] +>>>>>>> dev + } +} + +data "aws_security_group" "web" { + vpc_id = data.aws_vpc.main.id + + filter { + name = "group-name" + values = ["spring-vote-dev-web-sg"] + } +} + +<<<<<<< HEAD +data "aws_ecs_cluster" "main" { + cluster_name = "spring-vote-dev-cluster" +} + +# Green ECS Service +module "ecs_service_green" { + source = "../modules/ecs_service" + + project_name = "spring-vote" + environment = "green" + cluster_id = data.aws_ecs_cluster.main.id + subnet_ids = data.aws_subnets.public.ids + security_group_id = data.aws_security_group.web.id + container_image = "your-ecr-repo:green" # TODO: 실제 이미지로 변경 + container_port = 8080 + desired_count = 1 +} + +output "green_service_name" { + value = module.ecs_service_green.service_name +======= +# Amazon Linux 2023 AMI +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# Green EC2 인스턴스 +resource "aws_instance" "green" { + ami = data.aws_ami.amazon_linux.id + instance_type = "t2.nano" + key_name = "terraform" + subnet_id = data.aws_subnet.public.id + vpc_security_group_ids = [data.aws_security_group.web.id] + + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = 30 + delete_on_termination = true + encrypted = true + } + + user_data = <<-EOF + #!/bin/bash + yum update -y + yum install -y docker + systemctl start docker + systemctl enable docker + usermod -a -G docker ec2-user + EOF + + tags = { + Name = "spring-vote-green" + } +} + +output "green_instance_id" { + value = aws_instance.green.id +} + +output "green_public_ip" { + value = aws_instance.green.public_ip +>>>>>>> dev +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..4701b8a --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,97 @@ +# main.tf - 메인 Terraform 설정 + +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +# AWS Provider 설정 +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "Terraform" + } + } +} + +# VPC 모듈 +module "vpc" { + source = "./modules/vpc" + + project_name = var.project_name + environment = var.environment + vpc_cidr = var.vpc_cidr +} + +# RDS 모듈 +module "rds" { + source = "./modules/rds" + + project_name = var.project_name + environment = var.environment + vpc_id = module.vpc.vpc_id + public_subnet_ids = module.vpc.public_subnet_ids + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_instance_class = var.db_instance_class + db_security_group_id = module.vpc.db_security_group_id +} + +# EC2 인스턴스 모듈 +module "ec2" { + source = "./modules/ec2_instance" + + project_name = var.project_name + environment = var.environment + instance_type = var.instance_type + key_name = var.key_name + subnet_id = module.vpc.public_subnet_ids[0] + security_group_id = module.vpc.web_security_group_id +} + +# ECR 리포지토리 +resource "aws_ecr_repository" "main" { + name = "${var.project_name}-${var.environment}" + image_tag_mutability = "MUTABLE" + force_delete = true + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + Name = "${var.project_name}-${var.environment}-ecr" + } +} + +# Outputs +output "vpc_id" { + description = "VPC ID" + value = module.vpc.vpc_id +} + +output "ec2_public_ip" { + description = "EC2 퍼블릭 IP" + value = module.ec2.public_ip +} + +output "rds_endpoint" { + description = "RDS 엔드포인트" + value = module.rds.endpoint +} + +output "ecr_repository_url" { + description = "ECR 리포지토리 URL" + value = aws_ecr_repository.main.repository_url +} \ No newline at end of file diff --git a/infra/modules/ec2_instance/main.tf b/infra/modules/ec2_instance/main.tf new file mode 100644 index 0000000..fd3707e --- /dev/null +++ b/infra/modules/ec2_instance/main.tf @@ -0,0 +1,69 @@ +# modules/ec2_instance/main.tf - EC2 인스턴스 설정 + +# Amazon Linux 2023 AMI +data "aws_ami" "amazon_linux" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# EC2 인스턴스 +resource "aws_instance" "main" { + ami = data.aws_ami.amazon_linux.id + instance_type = var.instance_type + key_name = var.key_name + subnet_id = var.subnet_id + vpc_security_group_ids = [var.security_group_id] + + associate_public_ip_address = true + + root_block_device { + volume_type = "gp3" + volume_size = 30 + delete_on_termination = true + encrypted = true + } + + user_data = <<-EOF + #!/bin/bash + yum update -y + yum install -y docker + systemctl start docker + systemctl enable docker + usermod -a -G docker ec2-user + + # Docker Compose 설치 + curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + + # Java 17 설치 + yum install -y java-17-amazon-corretto-headless + EOF + + tags = { + Name = "${var.project_name}-${var.environment}-instance" + } + + lifecycle { + create_before_destroy = true + } +} + +# Elastic IP (선택사항) +resource "aws_eip" "main" { + instance = aws_instance.main.id + domain = "vpc" + + tags = { + Name = "${var.project_name}-${var.environment}-eip" + } +} diff --git a/infra/modules/ec2_instance/outputs.tf b/infra/modules/ec2_instance/outputs.tf new file mode 100644 index 0000000..da74a93 --- /dev/null +++ b/infra/modules/ec2_instance/outputs.tf @@ -0,0 +1,21 @@ +# modules/ec2_instance/outputs.tf + +output "instance_id" { + description = "EC2 인스턴스 ID" + value = aws_instance.main.id +} + +output "public_ip" { + description = "퍼블릭 IP" + value = aws_eip.main.public_ip +} + +output "private_ip" { + description = "프라이빗 IP" + value = aws_instance.main.private_ip +} + +output "public_dns" { + description = "퍼블릭 DNS" + value = aws_instance.main.public_dns +} diff --git a/infra/modules/ec2_instance/variables.tf b/infra/modules/ec2_instance/variables.tf new file mode 100644 index 0000000..0fdba6d --- /dev/null +++ b/infra/modules/ec2_instance/variables.tf @@ -0,0 +1,32 @@ +# modules/ec2_instance/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t2.nano" +} + +variable "key_name" { + description = "SSH 키 페어 이름" + type = string +} + +variable "subnet_id" { + description = "서브넷 ID" + type = string +} + +variable "security_group_id" { + description = "보안 그룹 ID" + type = string +} diff --git a/infra/modules/ecs_cluster/main.tf b/infra/modules/ecs_cluster/main.tf new file mode 100644 index 0000000..9f001bd --- /dev/null +++ b/infra/modules/ecs_cluster/main.tf @@ -0,0 +1,28 @@ +# modules/ecs_cluster/main.tf - ECS 클러스터 설정 + +# ECS 클러스터 +resource "aws_ecs_cluster" "main" { + name = "${var.project_name}-${var.environment}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = { + Name = "${var.project_name}-${var.environment}-cluster" + } +} + +# ECS 클러스터 용량 공급자 +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} diff --git a/infra/modules/ecs_cluster/outputs.tf b/infra/modules/ecs_cluster/outputs.tf new file mode 100644 index 0000000..6095e71 --- /dev/null +++ b/infra/modules/ecs_cluster/outputs.tf @@ -0,0 +1,16 @@ +# modules/ecs_cluster/outputs.tf + +output "cluster_id" { + description = "ECS 클러스터 ID" + value = aws_ecs_cluster.main.id +} + +output "cluster_arn" { + description = "ECS 클러스터 ARN" + value = aws_ecs_cluster.main.arn +} + +output "cluster_name" { + description = "ECS 클러스터 이름" + value = aws_ecs_cluster.main.name +} diff --git a/infra/modules/ecs_cluster/variables.tf b/infra/modules/ecs_cluster/variables.tf new file mode 100644 index 0000000..1b64da7 --- /dev/null +++ b/infra/modules/ecs_cluster/variables.tf @@ -0,0 +1,11 @@ +# modules/ecs_cluster/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} diff --git a/infra/modules/ecs_service/main.tf b/infra/modules/ecs_service/main.tf new file mode 100644 index 0000000..9372d9f --- /dev/null +++ b/infra/modules/ecs_service/main.tf @@ -0,0 +1,137 @@ +# modules/ecs_service/main.tf - ECS 서비스 설정 + +# CloudWatch 로그 그룹 +resource "aws_cloudwatch_log_group" "main" { + name = "/ecs/${var.project_name}-${var.environment}" + retention_in_days = 7 + + tags = { + Name = "${var.project_name}-${var.environment}-logs" + } +} + +# ECS Task Execution Role +resource "aws_iam_role" "ecs_task_execution" { + name = "${var.project_name}-${var.environment}-ecs-task-execution" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.project_name}-${var.environment}-ecs-task-execution" + } +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# ECS Task Role +resource "aws_iam_role" "ecs_task" { + name = "${var.project_name}-${var.environment}-ecs-task" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = { + Name = "${var.project_name}-${var.environment}-ecs-task" + } +} + +# ECS Task Definition +resource "aws_ecs_task_definition" "main" { + family = "${var.project_name}-${var.environment}" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.task_cpu + memory = var.task_memory + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([ + { + name = "${var.project_name}-${var.environment}" + image = var.container_image + + portMappings = [ + { + containerPort = var.container_port + hostPort = var.container_port + protocol = "tcp" + } + ] + + environment = [ + { + name = "SPRING_PROFILES_ACTIVE" + value = var.environment + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.main.name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = "ecs" + } + } + + essential = true + } + ]) + + tags = { + Name = "${var.project_name}-${var.environment}-task" + } +} + +# 현재 리전 +data "aws_region" "current" {} + +# ECS Service +resource "aws_ecs_service" "main" { + name = "${var.project_name}-${var.environment}-service" + cluster = var.cluster_id + task_definition = aws_ecs_task_definition.main.arn + desired_count = var.desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = var.subnet_ids + security_groups = [var.security_group_id] + assign_public_ip = true + } + + deployment_maximum_percent = 200 + deployment_minimum_healthy_percent = 100 + + lifecycle { + ignore_changes = [desired_count] + } + + tags = { + Name = "${var.project_name}-${var.environment}-service" + } +} \ No newline at end of file diff --git a/infra/modules/ecs_service/outputs.tf b/infra/modules/ecs_service/outputs.tf new file mode 100644 index 0000000..92a932f --- /dev/null +++ b/infra/modules/ecs_service/outputs.tf @@ -0,0 +1,21 @@ +# modules/ecs_service/outputs.tf + +output "service_id" { + description = "ECS 서비스 ID" + value = aws_ecs_service.main.id +} + +output "service_name" { + description = "ECS 서비스 이름" + value = aws_ecs_service.main.name +} + +output "task_definition_arn" { + description = "Task Definition ARN" + value = aws_ecs_task_definition.main.arn +} + +output "log_group_name" { + description = "CloudWatch 로그 그룹 이름" + value = aws_cloudwatch_log_group.main.name +} diff --git a/infra/modules/ecs_service/variables.tf b/infra/modules/ecs_service/variables.tf new file mode 100644 index 0000000..b628e8e --- /dev/null +++ b/infra/modules/ecs_service/variables.tf @@ -0,0 +1,56 @@ +# modules/ecs_service/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "cluster_id" { + description = "ECS 클러스터 ID" + type = string +} + +variable "subnet_ids" { + description = "서브넷 ID 목록" + type = list(string) +} + +variable "security_group_id" { + description = "보안 그룹 ID" + type = string +} + +variable "container_image" { + description = "컨테이너 이미지" + type = string + default = "nginx:latest" # TODO: 실제 이미지로 변경 +} + +variable "container_port" { + description = "컨테이너 포트" + type = number + default = 8080 +} + +variable "task_cpu" { + description = "Task CPU" + type = string + default = "256" +} + +variable "task_memory" { + description = "Task 메모리" + type = string + default = "512" +} + +variable "desired_count" { + description = "원하는 태스크 수" + type = number + default = 1 +} diff --git a/infra/modules/rds/main.tf b/infra/modules/rds/main.tf new file mode 100644 index 0000000..b30b513 --- /dev/null +++ b/infra/modules/rds/main.tf @@ -0,0 +1,78 @@ +# modules/rds/main.tf - PostgreSQL RDS 설정 + +# DB 서브넷 그룹 +resource "aws_db_subnet_group" "main" { + name = "${var.project_name}-${var.environment}-db-subnet-group" + description = "Database subnet group" + subnet_ids = var.public_subnet_ids + + tags = { + Name = "${var.project_name}-${var.environment}-db-subnet-group" + } +} + +# RDS PostgreSQL 인스턴스 +resource "aws_db_instance" "main" { + identifier = "${var.project_name}-${var.environment}-db" + + # 엔진 설정 + engine = "postgres" + engine_version = "15.12" + instance_class = var.db_instance_class + allocated_storage = 20 + max_allocated_storage = 100 + storage_type = "gp2" + storage_encrypted = true + + # 데이터베이스 설정 + db_name = var.db_name + username = var.db_username + password = var.db_password + port = 5432 + + # 네트워크 설정 + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [var.db_security_group_id] + + apply_immediately = true + publicly_accessible = true + multi_az = false # 비용 절감을 위해 단일 AZ + + # 백업 설정 + backup_retention_period = 7 + backup_window = "03:00-04:00" + maintenance_window = "Mon:04:00-Mon:05:00" + + # 기타 설정 + skip_final_snapshot = true # 개발환경용 + final_snapshot_identifier = "${var.project_name}-${var.environment}-final-snapshot" + deletion_protection = false # 개발환경용 + auto_minor_version_upgrade = true + + # 파라미터 그룹 + parameter_group_name = aws_db_parameter_group.main.name + + tags = { + Name = "${var.project_name}-${var.environment}-db" + } +} + +# DB 파라미터 그룹 +resource "aws_db_parameter_group" "main" { + name = "${var.project_name}-${var.environment}-pg15" + family = "postgres15" + + parameter { + name = "log_connections" + value = "1" + } + + parameter { + name = "log_disconnections" + value = "1" + } + + tags = { + Name = "${var.project_name}-${var.environment}-pg15" + } +} diff --git a/infra/modules/rds/outputs.tf b/infra/modules/rds/outputs.tf new file mode 100644 index 0000000..af9787b --- /dev/null +++ b/infra/modules/rds/outputs.tf @@ -0,0 +1,26 @@ +# modules/rds/outputs.tf + +output "endpoint" { + description = "RDS 엔드포인트" + value = aws_db_instance.main.endpoint +} + +output "address" { + description = "RDS 주소 (포트 제외)" + value = aws_db_instance.main.address +} + +output "port" { + description = "RDS 포트" + value = aws_db_instance.main.port +} + +output "db_name" { + description = "데이터베이스 이름" + value = aws_db_instance.main.db_name +} + +output "db_instance_id" { + description = "RDS 인스턴스 ID" + value = aws_db_instance.main.id +} diff --git a/infra/modules/rds/variables.tf b/infra/modules/rds/variables.tf new file mode 100644 index 0000000..37b9409 --- /dev/null +++ b/infra/modules/rds/variables.tf @@ -0,0 +1,48 @@ +# modules/rds/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "public_subnet_ids" { + description = "퍼블릭 서브넷 ID 목록" + type = list(string) +} + +variable "db_security_group_id" { + description = "DB 보안 그룹 ID" + type = string +} + +variable "db_name" { + description = "데이터베이스 이름" + type = string +} + +variable "db_username" { + description = "데이터베이스 사용자명" + type = string +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} diff --git a/infra/modules/vpc/main.tf b/infra/modules/vpc/main.tf new file mode 100644 index 0000000..c36aaa5 --- /dev/null +++ b/infra/modules/vpc/main.tf @@ -0,0 +1,164 @@ +# modules/vpc/main.tf - VPC 및 네트워크 설정 + +# 가용 영역 데이터 +data "aws_availability_zones" "available" { + state = "available" +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.project_name}-${var.environment}-vpc" + } +} + +# 인터넷 게이트웨이 +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-${var.environment}-igw" + } +} + +# 퍼블릭 서브넷 (2개 AZ) +resource "aws_subnet" "public" { + count = 2 + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) + availability_zone = data.aws_availability_zones.available.names[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${var.project_name}-${var.environment}-public-${count.index + 1}" + Type = "Public" + } +} + +# 프라이빗 서브넷 (RDS용, 2개 AZ) +resource "aws_subnet" "private" { + count = 2 + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10) + availability_zone = data.aws_availability_zones.available.names[count.index] + + tags = { + Name = "${var.project_name}-${var.environment}-private-${count.index + 1}" + Type = "Private" + } +} + +# 퍼블릭 라우트 테이블 +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "${var.project_name}-${var.environment}-public-rt" + } +} + +# 퍼블릭 서브넷 라우트 테이블 연결 +resource "aws_route_table_association" "public" { + count = 2 + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +# 웹 서버용 보안 그룹 (HTTP, HTTPS, SSH) +resource "aws_security_group" "web" { + name = "${var.project_name}-${var.environment}-web-sg" + description = "Security group for web servers" + vpc_id = aws_vpc.main.id + + # SSH (22) + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTP (80) + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTPS (443) + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Spring Boot" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "PostgreSQL" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # 모든 아웃바운드 허용 + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-${var.environment}-web-sg" + } +} + +# RDS용 보안 그룹 (PostgreSQL) +resource "aws_security_group" "db" { + name = "${var.project_name}-${var.environment}-db-sg" + description = "Security group for RDS PostgreSQL" + vpc_id = aws_vpc.main.id + + # PostgreSQL (5432) - 웹 서버에서만 접근 + ingress { + description = "PostgreSQL from web servers" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.web.id] + } + + # 모든 아웃바운드 허용 + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-${var.environment}-db-sg" + } +} diff --git a/infra/modules/vpc/outputs.tf b/infra/modules/vpc/outputs.tf new file mode 100644 index 0000000..b996b52 --- /dev/null +++ b/infra/modules/vpc/outputs.tf @@ -0,0 +1,31 @@ +# modules/vpc/outputs.tf + +output "vpc_id" { + description = "VPC ID" + value = aws_vpc.main.id +} + +output "public_subnet_ids" { + description = "퍼블릭 서브넷 ID 목록" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "프라이빗 서브넷 ID 목록" + value = aws_subnet.private[*].id +} + +output "web_security_group_id" { + description = "웹 서버 보안 그룹 ID" + value = aws_security_group.web.id +} + +output "db_security_group_id" { + description = "DB 보안 그룹 ID" + value = aws_security_group.db.id +} + +output "internet_gateway_id" { + description = "인터넷 게이트웨이 ID" + value = aws_internet_gateway.main.id +} diff --git a/infra/modules/vpc/variables.tf b/infra/modules/vpc/variables.tf new file mode 100644 index 0000000..3b0e024 --- /dev/null +++ b/infra/modules/vpc/variables.tf @@ -0,0 +1,17 @@ +# modules/vpc/variables.tf + +variable "project_name" { + description = "프로젝트 이름" + type = string +} + +variable "environment" { + description = "환경" + type = string +} + +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.0.0.0/16" +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..01886d3 --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,18 @@ +# terraform.tfvars - 변수 값 설정 + +aws_region = "ap-northeast-2" +project_name = "spring-vote" +environment = "dev" + +# VPC +vpc_cidr = "10.0.0.0/16" + +# EC2 +instance_type = "t2.nano" +key_name = "your-key-name" # TODO: 실제 키 페어 이름으로 변경 + +# RDS +db_name = "springvote" +db_username = "admin" +db_password = "YourSecurePassword123!" # TODO: 실제 비밀번호로 변경 +db_instance_class = "db.t3.micro" diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..f27fbe2 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,63 @@ +# variables.tf - 전역 변수 정의 + +variable "aws_region" { + description = "AWS 리전" + type = string + default = "ap-northeast-2" +} + +variable "project_name" { + description = "프로젝트 이름" + type = string + default = "spring-vote" +} + +variable "environment" { + description = "환경 (dev, staging, prod)" + type = string + default = "dev" +} + +# VPC 관련 +variable "vpc_cidr" { + description = "VPC CIDR 블록" + type = string + default = "10.0.0.0/16" +} + +# EC2 관련 +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t2.nano" +} + +variable "key_name" { + description = "SSH 키 페어 이름" + type = string +} + +# RDS 관련 +variable "db_name" { + description = "데이터베이스 이름" + type = string + default = "springvote" +} + +variable "db_username" { + description = "데이터베이스 사용자명" + type = string + default = "admin" +} + +variable "db_password" { + description = "데이터베이스 비밀번호" + type = string + sensitive = true +} + +variable "db_instance_class" { + description = "RDS 인스턴스 클래스" + type = string + default = "db.t3.micro" +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..550be7a --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'spring-vote-22nd' \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/VoteApplication.java b/src/main/java/com/diggindie/vote/VoteApplication.java new file mode 100644 index 0000000..0907b16 --- /dev/null +++ b/src/main/java/com/diggindie/vote/VoteApplication.java @@ -0,0 +1,13 @@ +package com.diggindie.vote; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class VoteApplication { + + public static void main(String[] args) { + SpringApplication.run(VoteApplication.class, args); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/code/Code.java b/src/main/java/com/diggindie/vote/common/code/Code.java new file mode 100644 index 0000000..9ec06a6 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/Code.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.common.code; + +public interface Code { + int getStatusCode(); + String getMessage(); +} diff --git a/src/main/java/com/diggindie/vote/common/code/ErrorCode.java b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java new file mode 100644 index 0000000..9ededba --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/ErrorCode.java @@ -0,0 +1,23 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements Code{ + + // 인증 실패 + UNAUTHORIZED_ERROR(401, "Unauthorized Exception"), + + // 권한 없음 + FORBIDDEN_ERROR(403, "Forbidden Exception"), + + // JWT / Token errors + EXPIRED_TOKEN(401, "Expired JWT token"), + INVALID_TOKEN(401, "Invalid JWT token"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/code/SuccessCode.java b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java new file mode 100644 index 0000000..8d7868a --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/code/SuccessCode.java @@ -0,0 +1,19 @@ +package com.diggindie.vote.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessCode implements Code { + + GET_SUCCESS(200, "GET_SUCCESS"), + LOGIN_SUCCESS(200, "LOGIN_SUCCESS"), + DELETE_SUCCESS(200, "DELETE_SUCCESS"), + INSERT_SUCCESS(201, "INSERT_SUCCESS"), + UPDATE_SUCCESS(204, "UPDATE_SUCCESS"); + + private final int statusCode; + private final String message; + +} diff --git a/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java b/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java new file mode 100644 index 0000000..b36c8b6 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/reddison/RedissonConfig.java @@ -0,0 +1,28 @@ +package com.diggindie.vote.common.config.reddison; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + redisHost + ":" + redisPort) + .setConnectionMinimumIdleSize(1) + .setConnectionPoolSize(2); + return Redisson.create(config); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java new file mode 100644 index 0000000..6fc2666 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetailService.java @@ -0,0 +1,33 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public CustomUserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + + Member member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getExternalId(), member.getRole()); + } + + + public CustomUserDetails loadByExternalId(String externalId) { + + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + + return new CustomUserDetails(member.getId(), member.getExternalId(), member.getRole()); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java new file mode 100644 index 0000000..ebf1740 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/CustomUserDetails.java @@ -0,0 +1,60 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.common.enums.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long memberId; + private final String externalId; + private final Role role; + + public CustomUserDetails(Long memberId, String externalId, Role role) { + this.memberId = memberId; + this.externalId = externalId; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.name())); + } + + @Override + public String getUsername() { + return externalId; + } + + public String getExternalId() { return externalId; } + + public Long getMemberId() { + return memberId; + } + + @Override public String getPassword() { + return null; + } + + @Override public boolean isAccountNonExpired() { + return true; + } + + @Override public boolean isAccountNonLocked() { + return true; + } + + @Override public boolean isCredentialsNonExpired() { + return true; + } + + @Override public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java new file mode 100644 index 0000000..f93a739 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.diggindie.vote.common.config.security; + +import com.diggindie.vote.common.config.security.jwt.JwtAccessDeniedHandler; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationEntryPoint; +import com.diggindie.vote.common.config.security.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint authenticationEntryPoint; + private final JwtAccessDeniedHandler accessDeniedHandler; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http + ) throws Exception { + + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of( + "https://api.diggindie.com", + "https://diggindie.com", + "https://www.diggindie.com", + "http://localhost:3000", + "http://localhost:5173", + "https://next-vote-22nd-eomg.vercel.app/" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..b545cd4 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.FORBIDDEN_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..4fbfd2e --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.response.Response; +import tools.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + Response body = Response.of(ErrorCode.UNAUTHORIZED_ERROR, false, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + response.getWriter().flush(); + + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3a0db64 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.common.config.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = jwtTokenProvider.getAccessToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..fda5ad3 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/security/jwt/JwtTokenProvider.java @@ -0,0 +1,161 @@ +package com.diggindie.vote.common.config.security.jwt; + + +import com.diggindie.vote.common.config.security.CustomUserDetailService; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.code.ErrorCode; +import com.diggindie.vote.common.exception.CustomException; +import com.diggindie.vote.common.enums.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.security.Key; +import java.time.Duration; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements InitializingBean { + + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token-validity}") + private Duration accessTokenValidity; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + private Key key; + + private final CustomUserDetailService customUserDetailService; + + @Override + public void afterPropertiesSet() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(String externalId, Role role) { + return generateToken(externalId, role, accessTokenValidity); + } + + public String generateRefreshToken(String externalId, Role role) { + return generateToken(externalId, role, refreshTokenValidity); + } + + public String generateToken(String externalId, Role role, Duration expiration) { + + Date now = new Date(); + Date expiry = new Date(now.getTime() + expiration.toMillis()); + + return Jwts.builder() + .setSubject(externalId) + .claim("role", role.name()) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String getAccessToken(HttpServletRequest request) { + + // cookie 기반 토큰 추출 + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + + // header 기반 토큰 추출 + String bearerToken = request.getHeader("Authorization"); + + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + public Claims parseClaims(String token) { + + try { + return Jwts.parser() + .verifyWith((SecretKey)key) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + // 만료된 토큰 + log.warn("만료된 JWT 토큰이 사용되었습니다.", e); + throw new CustomException(ErrorCode.EXPIRED_TOKEN); + } catch (SecurityException | MalformedJwtException e) { + // 서명 불일치 또는 형식 문제 + log.warn("JWT 토큰 형식이 잘못되었습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } catch (UnsupportedJwtException e) { + log.warn("지원하지 않는 JWT 토큰이 사용되었습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰의 값이 비어있습니다.", e); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } + + public String getExternalId(String token) { + return parseClaims(token).getSubject(); + } + + public Role getRole(String token) { + String roleName = parseClaims(token).get("role", String.class); + return Role.valueOf(roleName); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); // 파싱과 동시에 검증 수행 + return true; + } catch (SecurityException | MalformedJwtException e) { + // 잘못된 서명 또는 JWT 형식 + log.warn("JWT 토큰 형식이 잘못되었습니다.", e); + } catch (ExpiredJwtException e) { + // 만료된 JWT + log.warn("만료된 JWT 토큰이 사용되었습니다.", e); + } catch (UnsupportedJwtException e) { + // 지원하지 않는 JWT + log.warn("지원하지 않는 JWT 토큰이 사용되었습니다.", e); + } catch (IllegalArgumentException e) { + // 빈 JWT 또는 기타 문제 + log.warn("JWT 토큰의 값이 비어있습니다.", e); + } + return false; + } + + public Authentication getAuthentication(String token) { + + String externalId = getExternalId(token); + Role role = getRole(token); + + CustomUserDetails userDetails = customUserDetailService.loadByExternalId(externalId); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } + +} diff --git a/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..6040944 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/config/swagger/SwaggerConfig.java @@ -0,0 +1,31 @@ +package com.diggindie.vote.common.config.swagger; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "DiggIndie Vote API", + version = "v1", + description = "Voting service for CEOS 22nd" + ), + servers = { + @Server(url = "http://localhost:8080", description = "local"), + @Server(url = "https://api.diggindie.com", description = "prod") + }, + security = @SecurityRequirement(name = "bearerAuth") +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" +) +public class SwaggerConfig { +} diff --git a/src/main/java/com/diggindie/vote/common/enums/Part.java b/src/main/java/com/diggindie/vote/common/enums/Part.java new file mode 100644 index 0000000..56f7ac0 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/enums/Part.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.common.enums; + +public enum Part { + BACKEND, + FRONTEND +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/enums/Role.java b/src/main/java/com/diggindie/vote/common/enums/Role.java new file mode 100644 index 0000000..bc38c24 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/enums/Role.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.common.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + ROLE_USER("일반 사용자"), + ROLE_ADMIN("관리자"); + + private final String description; +} diff --git a/src/main/java/com/diggindie/vote/common/exception/CustomException.java b/src/main/java/com/diggindie/vote/common/exception/CustomException.java new file mode 100644 index 0000000..ae99e3b --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/exception/CustomException.java @@ -0,0 +1,22 @@ +package com.diggindie.vote.common.exception; + +import com.diggindie.vote.common.code.ErrorCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + +} + diff --git a/src/main/java/com/diggindie/vote/common/response/PageInfo.java b/src/main/java/com/diggindie/vote/common/response/PageInfo.java new file mode 100644 index 0000000..a90a8e5 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/PageInfo.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.common.response; + +public record PageInfo( + int page, + int size, + boolean hasNext, + long totalElements, + int totalPages +) {} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/common/response/Response.java b/src/main/java/com/diggindie/vote/common/response/Response.java new file mode 100644 index 0000000..bf00e47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/common/response/Response.java @@ -0,0 +1,71 @@ +package com.diggindie.vote.common.response; + + +import com.diggindie.vote.common.code.Code; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Response { + + private int statusCode; + + @Getter(AccessLevel.NONE) + private boolean isSuccess; + + private String message; + + private PageInfo pageInfo; + + private T payload; + + @JsonProperty("isSuccess") + public boolean isSuccess() { + return isSuccess; + } + + // Non-paginated + public static Response of(Code code, boolean isSuccess, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .payload(payload) + .build(); + } + + // Paginated + public static Response of(Code code, boolean isSuccess, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(code.getMessage()) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } + + public static Response of(Code code, boolean isSuccess, String message, T payload, PageInfo pageInfo) { + return Response.builder() + .statusCode(code.getStatusCode()) + .isSuccess(isSuccess) + .message(message) + .pageInfo(pageInfo) + .payload(payload) + .build(); + } +} diff --git a/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java new file mode 100644 index 0000000..fd6f15c --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/controller/CandidateController.java @@ -0,0 +1,85 @@ +package com.diggindie.vote.domain.candidate.controller; + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.candidate.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.candidate.service.CandidateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Candidate", description = "파트장 후보 관련 API") +@Slf4j +@RestController +@RequiredArgsConstructor +public class CandidateController { + + private final CandidateService candidateService; + + @Operation(summary = "파트장 후보 목록 조회", description = "특정 파트의 파트장 후보 목록을 조회합니다.") + @PreAuthorize("isAuthenticated()") + @GetMapping("/candidates") + public ResponseEntity> getCandidates( + @Parameter(description = "파트 (FRONTEND 또는 BACKEND)") @RequestParam("part") Part part + ) { + return ResponseEntity.ok().body(Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 후보 반환 API", + candidateService.getCandidatesByPart(part) + )); + } + + @Operation(summary = "파트장 투표 결과 조회", description = "특정 파트의 파트장 후보별 득표수를 조회합니다.") + @PreAuthorize("isAuthenticated()") + @GetMapping("/votes/leaders/results") + public ResponseEntity> getCandidatesVote( + @Parameter(description = "파트 (FRONTEND 또는 BACKEND)") @RequestParam("part") Part part + ) { + return ResponseEntity.ok().body(Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 투표 결과 반환 API", + candidateService.getCandidateVoteByPart(part) + )); + } + + @Operation(summary = "파트장 후보 등록", description = "로그인한 사용자를 자신의 파트에 맞는 파트장 후보로 등록합니다.") + @PreAuthorize("isAuthenticated()") + @PostMapping("/candidates/apply") + public ResponseEntity> applyCandidate( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok().body(Response.of( + SuccessCode.GET_SUCCESS, + true, + "파트장 후보 등록 API", + candidateService.applyCandidate(userDetails.getMemberId()) + )); + } + + @Operation(summary = "파트장 투표", description = "특정 파트장 후보에게 투표합니다.") + @PostMapping("/votes/leaders") + public ResponseEntity> voteCandidate( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody PartVoteRequestDto request + ) { + candidateService.vote(userDetails.getExternalId(), request); + return ResponseEntity.ok().body(Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "파트장 투표 완료", + (Void) null + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java b/src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java new file mode 100644 index 0000000..bcb0150 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/domain/Candidate.java @@ -0,0 +1,37 @@ +package com.diggindie.vote.domain.candidate.domain; + +import com.diggindie.vote.domain.member.domain.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "candidate") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Candidate { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "candidate_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false, unique = true) + private Member member; + + @Column(name = "vote_count", nullable = false) + private Integer voteCount = 0; + + public Candidate(Member member) { + this.member = member; + this.voteCount = 0; + } + + public void increaseVoteCount() { + this.voteCount++; + } + + public void decreaseVoteCount() { + if (this.voteCount > 0) this.voteCount--; + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java new file mode 100644 index 0000000..116d0f2 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateApplyResponse.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.candidate.dto; + +public record CandidateApplyResponse( + Long candidateId, + String candidateName, + String candidatePart +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java new file mode 100644 index 0000000..20b9c5f --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateDto.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.candidate.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CandidateDto( + Long candidateId, + String candidateName, + String candidatePart, + Long currentVote +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java new file mode 100644 index 0000000..8c3b9cf --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/CandidateListResponse.java @@ -0,0 +1,10 @@ +package com.diggindie.vote.domain.candidate.dto; + +import java.util.List; + +public record CandidateListResponse( + String part, + List candidates +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java b/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java new file mode 100644 index 0000000..aba126a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/dto/PartVoteRequestDto.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.candidate.dto; + +import jakarta.validation.constraints.NotNull; + +public record PartVoteRequestDto( + @NotNull(message = "후보자 ID는 필수입니다") + Long candidateId +) {} diff --git a/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java b/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java new file mode 100644 index 0000000..fd4d381 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/repository/CandidateRepository.java @@ -0,0 +1,18 @@ +package com.diggindie.vote.domain.candidate.repository; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.domain.candidate.domain.Candidate; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CandidateRepository extends JpaRepository { + + @Query("SELECT c FROM Candidate c JOIN FETCH c.member m WHERE m.part = :part") + List findAllByPart(@Param("part") Part part); +} + diff --git a/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java b/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java new file mode 100644 index 0000000..6f6aac2 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/service/CandidateService.java @@ -0,0 +1,106 @@ +package com.diggindie.vote.domain.candidate.service; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.domain.candidate.domain.Candidate; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.candidate.dto.CandidateApplyResponse; +import com.diggindie.vote.domain.candidate.dto.CandidateDto; +import com.diggindie.vote.domain.candidate.dto.CandidateListResponse; +import com.diggindie.vote.domain.candidate.repository.CandidateRepository; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CandidateService { + + private final CandidateRepository candidateRepository; + private final MemberRepository memberRepository; + private final RedissonClient redissonClient; + private final PartVoteExecutor partVoteExecutor; + + private static final String PART_VOTE_LOCK_PREFIX = "vote:part:lock:"; + + public CandidateListResponse getCandidatesByPart(Part part) { + List candidates = candidateRepository.findAllByPart(part); + + List candidateDtos = candidates.stream() + .map(candidate -> new CandidateDto( + candidate.getId(), + candidate.getMember().getMemberName(), + candidate.getMember().getPart().toString(), + null // 투표 전에는 득표수 숨김 + )) + .toList(); + + return new CandidateListResponse(part.toString(), candidateDtos); + } + + public CandidateListResponse getCandidateVoteByPart(Part part) { + List candidates = candidateRepository.findAllByPart(part); + + List candidateDtos = candidates.stream() + .map(candidate -> new CandidateDto( + candidate.getId(), + candidate.getMember().getMemberName(), + candidate.getMember().getPart().toString(), + (long) candidate.getVoteCount() // 엔티티에서 직접 조회 + )) + .toList(); + + return new CandidateListResponse(part.toString(), candidateDtos); + } + + @Transactional(readOnly = false) + public CandidateApplyResponse applyCandidate(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.getCandidate() != null) { + throw new IllegalArgumentException("이미 파트장 후보로 등록되어 있습니다."); + } + + Candidate candidate = new Candidate(member); + Candidate savedCandidate = candidateRepository.save(candidate); + + candidateRepository.flush(); + + return new CandidateApplyResponse( + savedCandidate.getId(), + member.getMemberName(), + member.getPart().toString() + ); + } + + public void vote(String externalId, PartVoteRequestDto request) { + String lockKey = PART_VOTE_LOCK_PREFIX + request.candidateId(); + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean acquired = lock.tryLock(3, 15, TimeUnit.SECONDS); + if (!acquired) { + throw new IllegalStateException("요청이 많습니다. 잠시 후 다시 시도해주세요."); + } + + partVoteExecutor.execute(externalId, request); + log.info("파트장 투표 완료 - externalId: {}, candidateId: {}", externalId, request.candidateId()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("투표 처리 중 오류가 발생했습니다."); + } finally { + if (lock.isHeldByCurrentThread()) lock.unlock(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java b/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java new file mode 100644 index 0000000..3a65d65 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/candidate/service/PartVoteExecutor.java @@ -0,0 +1,43 @@ +package com.diggindie.vote.domain.candidate.service; + +import com.diggindie.vote.domain.candidate.domain.Candidate; +import com.diggindie.vote.domain.candidate.dto.PartVoteRequestDto; +import com.diggindie.vote.domain.candidate.repository.CandidateRepository; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PartVoteExecutor { + + private final MemberRepository memberRepository; + private final CandidateRepository candidateRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(String externalId, PartVoteRequestDto request) { + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.isHasVotedCandidate()) { + throw new IllegalStateException("이미 파트장 투표를 완료하였습니다."); + } + + Candidate candidate = candidateRepository.findById(request.candidateId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 후보자입니다.")); + + if (candidate.getMember().getId().equals(member.getId())) { + throw new IllegalStateException("자기 자신에게는 투표할 수 없습니다."); + } + + if (member.getPart() != candidate.getMember().getPart()) { + throw new IllegalStateException("같은 파트의 후보자에게만 투표할 수 있습니다."); + } + + candidate.increaseVoteCount(); + member.markCandidateVoted(); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java new file mode 100644 index 0000000..a5492b1 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/controller/AuthController.java @@ -0,0 +1,96 @@ +package com.diggindie.vote.domain.member.controller; + + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.member.dto.LoginRequest; +import com.diggindie.vote.domain.member.dto.LoginResponse; +import com.diggindie.vote.domain.member.dto.LogoutResponse; +import com.diggindie.vote.domain.member.dto.SignupRequest; +import com.diggindie.vote.domain.member.dto.SignupResponse; +import com.diggindie.vote.domain.member.dto.TokenReissueResponse; +import com.diggindie.vote.domain.member.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Auth", description = "인증 관련 API") +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다. 가입 성공 시 자동 로그인되어 토큰이 발급됩니다.") + @PostMapping("/auth/signup") + public ResponseEntity> signup( + @RequestBody SignupRequest signupRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "회원 가입 API", + authService.signup(signupRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "로그인", description = "아이디와 비밀번호로 로그인합니다. Access Token은 응답 바디에, Refresh Token은 쿠키에 설정됩니다.") + @PostMapping("/auth/login") + public ResponseEntity> login( + @RequestBody LoginRequest loginRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "일반 로그인 API", + authService.login(loginRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "로그아웃", description = "로그아웃합니다. Refresh Token 쿠키가 제거됩니다. 인증된 사용자만 접근 가능합니다.") + @PreAuthorize("isAuthenticated()") + @PostMapping("/auth/logout") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "로그아웃 API", + authService.logout(httpResponse, userDetails.getExternalId()) + ); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "토큰 재발급", description = "Refresh Token을 이용해 새로운 Access Token과 Refresh Token을 발급받습니다.") + @PostMapping("/auth/reissue") + public ResponseEntity> reissue( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "토큰 재발급 API", + authService.reissue(httpRequest, httpResponse) + ); + return ResponseEntity.ok().body(response); + } + +} diff --git a/src/main/java/com/diggindie/vote/domain/member/domain/Member.java b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java new file mode 100644 index 0000000..29f581a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/domain/Member.java @@ -0,0 +1,79 @@ +package com.diggindie.vote.domain.member.domain; + +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.enums.Role; +import com.diggindie.vote.domain.candidate.domain.Candidate; +import com.diggindie.vote.domain.team.domain.Team; +import jakarta.persistence.*; +import lombok.*; + +import java.util.UUID; + +@Entity +@Table(name = "member") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Column(name = "external_id", nullable = false, length = 36, unique = true) + private String externalId; + + @Enumerated(EnumType.STRING) + private Role role; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + + @Enumerated(EnumType.STRING) + @Column(name = "part", nullable = false, length = 20) + private Part part; + + @Column(name = "login_id", nullable = false, unique = true, length = 20) + private String loginId; + + @Column(name = "email", nullable = false, unique = true, length = 50) + private String email; + + @Column(name = "password", nullable = false, length = 100) + private String password; + + @Column(name = "membername", nullable = false, length = 10) + private String memberName; + + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private Candidate candidate; + + @Column(name = "has_voted_team", nullable = false) + private boolean hasVotedTeam = false; + + @Column(name = "has_voted_candidate", nullable = false) + private boolean hasVotedCandidate = false; + + @Builder + public Member(Role role, Team team, Part part, String loginId, String email, String password, String memberName) { + this.externalId = UUID.randomUUID().toString(); + this.role = Role.ROLE_USER; + this.team = team; + this.part = part; + this.loginId = loginId; + this.email = email; + this.password = password; + this.memberName = memberName; + this.hasVotedTeam = false; + this.hasVotedCandidate = false; + } + + public void markTeamVoted() { + this.hasVotedTeam = true; + } + + public void markCandidateVoted() { + this.hasVotedCandidate = true; + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java b/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java new file mode 100644 index 0000000..e7e40d9 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LoginRequest.java @@ -0,0 +1,7 @@ +package com.diggindie.vote.domain.member.dto; + +public record LoginRequest( + String loginId, + String password +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java new file mode 100644 index 0000000..10f9741 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LoginResponse.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record LoginResponse( + String memberId, + String name, + Part part, + String team, + String accessToken, + long expiresIn +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java new file mode 100644 index 0000000..0382734 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/LogoutResponse.java @@ -0,0 +1,6 @@ +package com.diggindie.vote.domain.member.dto; + +public record LogoutResponse( + String memberId +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java b/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java new file mode 100644 index 0000000..5899460 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/SignupRequest.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record SignupRequest( + String loginId, + String password, + String email, + Part part, + String name, + String team +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java new file mode 100644 index 0000000..2156f79 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/SignupResponse.java @@ -0,0 +1,13 @@ +package com.diggindie.vote.domain.member.dto; + +import com.diggindie.vote.common.enums.Part; + +public record SignupResponse( + String memberId, + String name, + Part part, + String team, + String accessToken, + long expiresIn +) { +} diff --git a/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java b/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java new file mode 100644 index 0000000..8dacab7 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/dto/TokenReissueResponse.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.member.dto; + +public record TokenReissueResponse( + String accessToken, + Long expiresIn +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..1ac3b47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/repository/MemberRepository.java @@ -0,0 +1,18 @@ +package com.diggindie.vote.domain.member.repository; + +import com.diggindie.vote.domain.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + Optional findByExternalId(String externalId); + + boolean existsByLoginId(String loginId); + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java new file mode 100644 index 0000000..33b0840 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/service/AuthService.java @@ -0,0 +1,170 @@ +package com.diggindie.vote.domain.member.service; + +import com.diggindie.vote.common.config.security.jwt.JwtTokenProvider; +import com.diggindie.vote.common.enums.Part; +import com.diggindie.vote.common.enums.Role; +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.dto.*; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final TeamRepository teamRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + @Value("${jwt.access-token-validity}") + private Duration accessTokenValidity; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + @Transactional(readOnly = true) + public LoginResponse login(LoginRequest request, HttpServletResponse response) { + + Member member = memberRepository.findByLoginId(request.loginId()) + .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다.")); + + if (!passwordEncoder.matches(request.password(), member.getPassword())) { + throw new IllegalArgumentException("아이디 또는 비밀번호가 일치하지 않습니다."); + } + + String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); + setRefreshToken(response, member.getExternalId(), member.getRole()); + + + return new LoginResponse( + member.getExternalId(), + member.getMemberName(), + member.getPart(), + member.getTeam().getTeamName(), + accessToken, + accessTokenValidity.getSeconds() + ); + } + + @Transactional + public SignupResponse signup(SignupRequest request, HttpServletResponse response) { + + if (memberRepository.existsByLoginId(request.loginId())) { + throw new IllegalArgumentException("이미 사용 중인 아이디입니다."); + } + + String encodedPassword = passwordEncoder.encode(request.password()); + + Member member = Member.builder() + .loginId(request.loginId()) + .password(encodedPassword) + .email(request.email()) + .part(request.part()) + .memberName(request.name()) + .team(teamRepository.findByTeamName(request.team()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 팀입니다."))) + .build(); + + Member savedMember = memberRepository.save(member); + + String accessToken = jwtTokenProvider.generateAccessToken(member.getExternalId(), member.getRole()); + setRefreshToken(response, savedMember.getExternalId(), savedMember.getRole()); + + return new SignupResponse( + savedMember.getExternalId(), + savedMember.getMemberName(), + savedMember.getPart(), + savedMember.getTeam().getTeamName(), + accessToken, + accessTokenValidity.getSeconds() + ); + } + + @Transactional(readOnly = true) + public LogoutResponse logout(HttpServletResponse response, String externalId) { + refreshTokenService.delete(externalId); + removeRefreshTokenCookie(response); + return new LogoutResponse(externalId); + } + + @Transactional(readOnly = true) + public TokenReissueResponse reissue(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = extractRefreshTokenFromCookie(request); + + if (refreshToken == null) { + throw new IllegalArgumentException("Refresh token이 존재하지 않습니다."); + } + + String externalId = jwtTokenProvider.parseClaims(refreshToken).getSubject(); + + if (!refreshTokenService.validate(externalId, refreshToken)) { + refreshTokenService.delete(externalId); + removeRefreshTokenCookie(response); + throw new IllegalArgumentException("재로그인이 필요합니다."); + } + + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + String newAccessToken = jwtTokenProvider.generateAccessToken(externalId, member.getRole()); + setRefreshToken(response, externalId, member.getRole()); + + return new TokenReissueResponse(newAccessToken, accessTokenValidity.getSeconds()); + } + + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + private void removeRefreshTokenCookie(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path("/") + .maxAge(0) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + private void setRefreshToken(HttpServletResponse response, String externalId, Role role) { + String refreshToken = jwtTokenProvider.generateRefreshToken(externalId, role); + refreshTokenService.save(externalId, refreshToken); + addTokenCookie(response, "refreshToken", refreshToken, refreshTokenValidity); + } + + private void addTokenCookie(HttpServletResponse response, String name, String value, Duration maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path("/") + .maxAge(maxAge) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + +} diff --git a/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java new file mode 100644 index 0000000..c1dee18 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/member/service/RefreshTokenService.java @@ -0,0 +1,43 @@ +package com.diggindie.vote.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RedissonClient redissonClient; + + private static final String KEY_PREFIX = "refresh_token:"; + + @Value("${jwt.refresh-token-validity}") + private Duration refreshTokenValidity; + + public void save(String externalId, String token) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + bucket.set(token, refreshTokenValidity.toMillis(), TimeUnit.MILLISECONDS); + } + + public String get(String externalId) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + return bucket.get(); + } + + public void delete(String externalId) { + RBucket bucket = redissonClient.getBucket(KEY_PREFIX + externalId); + bucket.delete(); + } + + public boolean validate(String externalId, String token) { + String storedToken = get(externalId); + return storedToken != null && storedToken.equals(token); + } +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java new file mode 100644 index 0000000..ff6272b --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/controller/TeamController.java @@ -0,0 +1,78 @@ +package com.diggindie.vote.domain.team.controller; + +import com.diggindie.vote.common.code.SuccessCode; +import com.diggindie.vote.common.config.security.CustomUserDetails; +import com.diggindie.vote.common.response.Response; +import com.diggindie.vote.domain.team.dto.TeamListResponse; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.dto.TeamVoteResultResponse; +import com.diggindie.vote.domain.team.service.TeamService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import com.diggindie.vote.domain.team.service.TeamVoteExecutor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RestController; + +import java.util.concurrent.TimeUnit; + +@Tag(name = "Team", description = "팀 관련 API") +@RestController +@Slf4j +@RequiredArgsConstructor +public class TeamController { + + private final TeamService teamService; + + @Operation(summary = "팀 목록 조회", description = "모든 팀의 목록을 조회합니다.") + @PreAuthorize("isAuthenticated()") + @GetMapping("/teams") + public ResponseEntity> getTeamList() { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "팀 목록 반환 API", + teamService.getTeamList() + ); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "팀 투표", description = "특정 팀에 투표합니다. 자신이 소속된 팀에는 투표할 수 없습니다.") + @PreAuthorize("isAuthenticated()") + @PostMapping("/votes/teams") + public ResponseEntity> voteTeam( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody TeamVoteRequestDto request + ) { + teamService.vote(userDetails.getExternalId(), request); // getUserId() → getExternalId() + return ResponseEntity.ok().body(Response.of( + SuccessCode.INSERT_SUCCESS, + true, + "팀 투표 완료", + (Void) null + )); + } + + @Operation(summary = "팀 투표 결과 조회", description = "팀 투표 결과 조회") + @PreAuthorize("isAuthenticated()") + @GetMapping("/votes/teams/results") + public ResponseEntity> getTeamVoteResults() { + + Response response = Response.of( + SuccessCode.GET_SUCCESS, + true, + "팀 투표 결과 반환 API", + teamService.getTeamVoteResults() + ); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/domain/Team.java b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java new file mode 100644 index 0000000..456c181 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/domain/Team.java @@ -0,0 +1,39 @@ +package com.diggindie.vote.domain.team.domain; + +import com.diggindie.vote.domain.member.domain.Member; +import jakarta.persistence.*; +import lombok.*; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "team") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Team { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id") + private Long id; + + @Column(name = "teamname", nullable = false, length = 20) + private String teamName; + + @Column(name = "proposal", length = 200) + private String proposal; + + @Column(name = "vote_count", nullable = false) + private Integer voteCount = 0; + + @OneToMany(mappedBy = "team") + private List members = new ArrayList<>(); + + public void increaseVoteCount() { + this.voteCount++; + } + + public void decreaseVoteCount() { + if (this.voteCount > 0) this.voteCount--; + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java new file mode 100644 index 0000000..c405a2c --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamDto.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +public record TeamDto( + Long teamId, + String teamName, + String teamProposal +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java new file mode 100644 index 0000000..94d6cad --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamListResponse.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +import java.util.List; + +public record TeamListResponse( + List teams +) { +} + diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java new file mode 100644 index 0000000..ce2fe8a --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteRequestDto.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.team.dto; + +import jakarta.validation.constraints.NotNull; + +public record TeamVoteRequestDto( + @NotNull(message = "팀 ID는 필수입니다") + Long teamId +) {} diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java new file mode 100644 index 0000000..533ca47 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultDto.java @@ -0,0 +1,9 @@ +package com.diggindie.vote.domain.team.dto; + +public record TeamVoteResultDto( + Long teamId, + String teamName, + String teamProposal, + Long currentVote +) { +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java new file mode 100644 index 0000000..a15555b --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/dto/TeamVoteResultResponse.java @@ -0,0 +1,8 @@ +package com.diggindie.vote.domain.team.dto; + +import com.diggindie.vote.domain.team.dto.TeamVoteResultDto; +import java.util.List; + +public record TeamVoteResultResponse( + List teamVoteResults +) {} diff --git a/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java new file mode 100644 index 0000000..7231300 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/repository/TeamRepository.java @@ -0,0 +1,12 @@ +package com.diggindie.vote.domain.team.repository; + +import com.diggindie.vote.domain.team.domain.Team; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TeamRepository extends JpaRepository { + + Optional findByTeamName(String teamName); + +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java new file mode 100644 index 0000000..0d7ad55 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamService.java @@ -0,0 +1,77 @@ +package com.diggindie.vote.domain.team.service; + +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.team.dto.*; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TeamService { + + private final TeamRepository teamRepository; + private final RedissonClient redissonClient; + private final TeamVoteExecutor teamVoteExecutor; + + private static final String TEAM_VOTE_LOCK_PREFIX = "vote:team:lock:"; + + public TeamListResponse getTeamList() { + List teams = teamRepository.findAll(); + + List teamDtos = teams.stream() + .map(team -> new TeamDto( + team.getId(), + team.getTeamName(), + team.getProposal() + )) + .toList(); + + return new TeamListResponse(teamDtos); + } + + public void vote(String externalId, TeamVoteRequestDto request) { + String lockKey = TEAM_VOTE_LOCK_PREFIX + request.teamId(); + RLock lock = redissonClient.getLock(lockKey); + + try { + boolean acquired = lock.tryLock(3, 15, TimeUnit.SECONDS); + if (!acquired) { + throw new IllegalStateException("요청이 많습니다. 잠시 후 다시 시도해주세요."); + } + + teamVoteExecutor.execute(externalId, request); + log.info("팀 투표 완료 - externalId: {}, teamId: {}", externalId, request.teamId()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("투표 처리 중 오류가 발생했습니다."); + } finally { + if (lock.isHeldByCurrentThread()) lock.unlock(); + } + } + + public TeamVoteResultResponse getTeamVoteResults() { + List results = teamRepository.findAll().stream() + .map(team -> new TeamVoteResultDto( + team.getId(), + team.getTeamName(), + team.getProposal(), + team.getVoteCount() == null ? 0L : team.getVoteCount().longValue() + )) + .toList(); + + return new TeamVoteResultResponse(results); + } +} \ No newline at end of file diff --git a/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java b/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java new file mode 100644 index 0000000..55ddd44 --- /dev/null +++ b/src/main/java/com/diggindie/vote/domain/team/service/TeamVoteExecutor.java @@ -0,0 +1,39 @@ +package com.diggindie.vote.domain.team.service; + +import com.diggindie.vote.domain.member.domain.Member; +import com.diggindie.vote.domain.member.repository.MemberRepository; +import com.diggindie.vote.domain.team.domain.Team; +import com.diggindie.vote.domain.team.dto.TeamVoteRequestDto; +import com.diggindie.vote.domain.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TeamVoteExecutor { + + private final MemberRepository memberRepository; + private final TeamRepository teamRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(String externalId, TeamVoteRequestDto request) { + Member member = memberRepository.findByExternalId(externalId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.isHasVotedTeam()) { + throw new IllegalStateException("이미 팀 투표를 완료하셨습니다."); + } + + Team team = teamRepository.findById(request.teamId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 팀입니다.")); + + if (member.getTeam().getId().equals(team.getId())) { + throw new IllegalStateException("자신이 소속한 팀에는 투표할 수 없습니다."); + } + + team.increaseVoteCount(); + member.markTeamVoted(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..bffd8f1 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,37 @@ +spring: + application: + name: vote + + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:vote} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:password} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:validate} + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql: trace + +jwt: + secret-key: ${JWT_SECRET_KEY:diggindievotingserviceeeeeeeeeeeee1234567890} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} diff --git a/src/test/java/com/diggindie/vote/VoteApplicationTests.java b/src/test/java/com/diggindie/vote/VoteApplicationTests.java new file mode 100644 index 0000000..82b8211 --- /dev/null +++ b/src/test/java/com/diggindie/vote/VoteApplicationTests.java @@ -0,0 +1,13 @@ +package com.diggindie.vote; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class VoteApplicationTests { + + @Test + void contextLoads() { + } + +}