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/.gitignore b/.gitignore
new file mode 100644
index 0000000..4c75e42
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,39 @@
+HELP.md
+.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/
+
+.env
\ No newline at end of file
diff --git a/README.md b/README.md
index 7f5d396..5c2c1e6 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,37 @@
# spring-vote-22nd
ceos back-end 22nd voting service project
+
+[CEOS 22기 파트장 & 팀 투표 하러 가기](https://next-vote-22nd-inky.vercel.app/)
+
+## Swagger
+https://ceos-22nd-catchup.github.io/swagger-ui/
+
+## ERD
+
+
+## 기능
+### 로그인
+- JWT
+- AccessToken 기반
+- 일회성 서비스임을 고려하여 RefreshToken 및 로그아웃 미구현
+
+### 회원가입
+- 아이디, 비밀번호, 이메일, 파트, 이름, 팀
+- 아이디와 이메일 중복 여부는 회원가입 요청 시점에서 일괄 검사
+- 파트와 팀은 각각 PartType과 TeamType으로 정의해서 일관성 확보
+
+### 투표
+- 레포지토리 단에서 득표 순으로 내림차순 정렬하여 반환.
+- 파트장 투표와 팀 투표는 반드시 로그인한 사용자만 가능.
+- 한 아이디 당 한 번만 투표 가능 (1인 1표).
+- 투표 페이지에 접근할 수는 있지만, 투표에 참여할 수는 없음.
+- 파트장 투표 시 본인의 파트에 해당하는 파트장 투표만 가능함.
+ - 프론트엔드에서 로그인한 사용자의 파트 소속을 확인해서 애초에 백엔드 투표 페이지에 접근할 수 없도록 조치함.
+ - 다만 백엔드에서도 이를 검증하는 로직을 추가하였음.
+- 데모데이 투표 시 본인이 속한 팀에는 투표를 할 수 없음.
+
+## 서버 배포 전략
+- OCI 인스턴스
+- 수동 배포
+ - Spring과 MySQL 서버만 필요한 간단한 구조였음
+ - 이미 OCI 환경에 기존에 구축한 환경으로 서버를 구동하기 충분했기 때문에 Github Action을 통한 Docker 컨테이너 기반 배포 자동화를 도입하지는 않았음
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..55654c5
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,53 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.5.7'
+ id 'io.spring.dependency-management' version '1.1.7'
+}
+
+group = 'com.ceos22nd'
+version = '0.0.1-SNAPSHOT'
+description = 'ceos 22nd voting system'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
+
+
+ compileOnly 'org.projectlombok:lombok'
+
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ annotationProcessor 'org.projectlombok:lombok'
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
+ runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
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/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..b6edb01
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'voting-system'
diff --git a/src/main/java/com/ceos22nd/voting_system/VotingSystemApplication.java b/src/main/java/com/ceos22nd/voting_system/VotingSystemApplication.java
new file mode 100644
index 0000000..4976305
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/VotingSystemApplication.java
@@ -0,0 +1,13 @@
+package com.ceos22nd.voting_system;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class VotingSystemApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(VotingSystemApplication.class, args);
+ }
+
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/config/SecurityConfig.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/config/SecurityConfig.java
new file mode 100644
index 0000000..911a115
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/config/SecurityConfig.java
@@ -0,0 +1,70 @@
+package com.ceos22nd.voting_system.domain.auth.config;
+
+import com.ceos22nd.voting_system.domain.auth.jwt.JwtAuthenticationFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.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
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ return http
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .csrf(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .sessionManagement(session ->
+ session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ )
+ .authorizeHttpRequests((auth) -> auth
+ .requestMatchers("/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs",
+ "/api-docs/**", "/v3/api-docs/**").permitAll()
+ .requestMatchers("/api/auth/signup", "/api/auth/login",
+ "/api/candidates/teams", "/api/candidates/parts").permitAll()
+ .anyRequest().authenticated())
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .build();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+
+ configuration.setAllowedOrigins(List.of("http://localhost:3000", "https://next-vote-22nd-inky.vercel.app/"));
+
+ configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
+
+ configuration.setAllowedHeaders(List.of("*"));
+
+ configuration.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+
+ return source;
+ }
+
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/controller/AuthController.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/controller/AuthController.java
new file mode 100644
index 0000000..40f2f4b
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/controller/AuthController.java
@@ -0,0 +1,47 @@
+package com.ceos22nd.voting_system.domain.auth.controller;
+
+import com.ceos22nd.voting_system.domain.auth.dto.LogInRequest;
+import com.ceos22nd.voting_system.domain.auth.dto.LogInResponse;
+import com.ceos22nd.voting_system.domain.auth.dto.SignUpRequest;
+import com.ceos22nd.voting_system.domain.auth.dto.SignUpResponse;
+import com.ceos22nd.voting_system.domain.auth.service.AuthService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class AuthController {
+
+ private final AuthService authService;
+
+ /**
+ * 회원가입 API
+ *
+ * @param request SignUpRequest (회원가입 요청)
+ * @return SignUpResponse (회원가입 응답)
+ */
+ @Valid
+ @PostMapping("/api/auth/signup")
+ public ResponseEntity signUp(@RequestBody SignUpRequest request){
+ SignUpResponse response = authService.signUp(request);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ /**
+ * 로그인 API
+ *
+ * @param request LogInRequest (로그인 요청)
+ * @return JwtTokenResponse (JWT 토큰)
+ */
+ @Valid
+ @PostMapping("/api/auth/login")
+ public ResponseEntity logIn(@RequestBody LogInRequest request){
+ LogInResponse response = authService.logIn(request);
+ return ResponseEntity.status(HttpStatus.OK).body(response);
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/LogInRequest.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/LogInRequest.java
new file mode 100644
index 0000000..d084ace
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/LogInRequest.java
@@ -0,0 +1,9 @@
+ package com.ceos22nd.voting_system.domain.auth.dto;
+
+ import jakarta.validation.constraints.NotBlank;
+
+ public record LogInRequest(
+ @NotBlank String loginId,
+ @NotBlank String password
+ ) {
+ }
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/LogInResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/LogInResponse.java
new file mode 100644
index 0000000..02492a0
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/LogInResponse.java
@@ -0,0 +1,6 @@
+package com.ceos22nd.voting_system.domain.auth.dto;
+
+public record LogInResponse(
+ String accessToken
+) {
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/SignUpRequest.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/SignUpRequest.java
new file mode 100644
index 0000000..51aca41
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/SignUpRequest.java
@@ -0,0 +1,32 @@
+package com.ceos22nd.voting_system.domain.auth.dto;
+
+import com.ceos22nd.voting_system.domain.member.enums.PartType;
+import com.ceos22nd.voting_system.domain.member.enums.TeamType;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record SignUpRequest(
+ @NotBlank(message = "아이디를 입력해주세요.")
+ String loginId,
+
+ @NotBlank(message = "비밀번호를 입력해주세요.")
+ String password,
+
+ @NotBlank(message = "이메일을 입력해주세요.")
+ @Email(message = "이메일 형식이 올바르지 않습니다.")
+ String email,
+
+ @NotBlank(message = "이름을 입력해주세요.")
+ String realName,
+
+ @NotNull(message = "파트를 입력해주세요.")
+ PartType part,
+
+ @NotNull(message = "소속 팀을 선택해주세요.")
+ TeamType team,
+
+ @NotNull(message = "파트장 후보 여부를 체크해주세요.")
+ Boolean isPartLeadCandidate
+) {
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/SignUpResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/SignUpResponse.java
new file mode 100644
index 0000000..8abd639
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/dto/SignUpResponse.java
@@ -0,0 +1,19 @@
+package com.ceos22nd.voting_system.domain.auth.dto;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import lombok.Builder;
+
+@Builder
+public record SignUpResponse(
+ String email,
+ String partType,
+ boolean isPartLeadCandidate
+) {
+ public static SignUpResponse from(Member member){
+ return SignUpResponse.builder()
+ .email(member.getEmail())
+ .partType(member.getPart().name().toLowerCase())
+ .isPartLeadCandidate(member.isPartLeadCandidate())
+ .build();
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/jwt/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..a7b3096
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/jwt/JwtAuthenticationFilter.java
@@ -0,0 +1,57 @@
+package com.ceos22nd.voting_system.domain.auth.jwt;
+
+import com.ceos22nd.voting_system.domain.auth.service.CustomUserDetailsService;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+ private final TokenProvider tokenProvider;
+ private final CustomUserDetailsService customUserDetailsService;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ String token = resolveToken(request);
+
+ if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
+ Long memberId = tokenProvider.getMemberId(token);
+
+ UserDetails userDetails = customUserDetailsService.loadUserByUsername(memberId.toString());
+
+ if (userDetails != null) {
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
+ authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+
+ private String resolveToken(HttpServletRequest request) {
+ String bearerToken = request.getHeader("Authorization");
+ if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
+ return bearerToken.substring(7);
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/jwt/TokenProvider.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/jwt/TokenProvider.java
new file mode 100644
index 0000000..77d2485
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/jwt/TokenProvider.java
@@ -0,0 +1,77 @@
+package com.ceos22nd.voting_system.domain.auth.jwt;
+
+import io.jsonwebtoken.*;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import io.jsonwebtoken.security.SecurityException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.util.Date;
+
+@Slf4j
+@Component
+public class TokenProvider {
+ private final SecretKey key;
+ private final long accessTokenExpiration;
+
+ public TokenProvider(
+ @Value("${jwt.secret}") String secret,
+ @Value("${jwt.access_expiration}") long accessTokenExpiration
+ ) {
+ byte[] keyBytes = Decoders.BASE64.decode(secret);
+ this.key = Keys.hmacShaKeyFor(keyBytes);
+ this.accessTokenExpiration = accessTokenExpiration;
+ }
+
+ public String createAccessToken(Long memberId) {
+ Date now = new Date();
+ Date validity = new Date(now.getTime() + accessTokenExpiration);
+
+ return Jwts.builder()
+ .setSubject(memberId.toString())
+ .setIssuedAt(now)
+ .setExpiration(validity)
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ public boolean validateToken(String token) {
+ try {
+ Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token);
+ return true;
+ } catch (SecurityException | MalformedJwtException e) {
+ log.info("잘못된 JWT 서명입니다.");
+ } catch (ExpiredJwtException e) {
+ log.info("만료된 JWT 토큰입니다.");
+ } catch (UnsupportedJwtException e) {
+ log.info("지원되지 않는 JWT 토큰입니다.");
+ } catch (IllegalArgumentException e){
+ log.info("잘못된 JWT 토큰입니다.");
+ }
+ return false;
+ }
+
+ public Long getMemberId(String token) {
+ return Long.parseLong(
+ parseClaims(token).getSubject()
+ );
+ }
+
+ public Claims parseClaims(String token){
+ try {
+ return Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ } catch (ExpiredJwtException e) {
+ return e.getClaims();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/security/CustomUserDetails.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/security/CustomUserDetails.java
new file mode 100644
index 0000000..96f25ec
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/security/CustomUserDetails.java
@@ -0,0 +1,54 @@
+package com.ceos22nd.voting_system.domain.auth.security;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import lombok.RequiredArgsConstructor;
+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.Collections;
+
+@RequiredArgsConstructor
+public class CustomUserDetails implements UserDetails {
+ private final Member member;
+
+ public Long getId() {
+ return member.getId();
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities(){
+ return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
+ }
+
+ @Override
+ public String getPassword() {
+ return member.getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return member.getId().toString();
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return UserDetails.super.isEnabled();
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return UserDetails.super.isCredentialsNonExpired();
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return UserDetails.super.isAccountNonLocked();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return UserDetails.super.isAccountNonExpired();
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/service/AuthService.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/service/AuthService.java
new file mode 100644
index 0000000..07abe89
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/service/AuthService.java
@@ -0,0 +1,95 @@
+package com.ceos22nd.voting_system.domain.auth.service;
+
+import com.ceos22nd.voting_system.domain.auth.dto.LogInRequest;
+import com.ceos22nd.voting_system.domain.auth.dto.LogInResponse;
+import com.ceos22nd.voting_system.domain.auth.dto.SignUpRequest;
+import com.ceos22nd.voting_system.domain.auth.dto.SignUpResponse;
+import com.ceos22nd.voting_system.domain.auth.jwt.TokenProvider;
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import com.ceos22nd.voting_system.domain.member.entity.Team;
+import com.ceos22nd.voting_system.domain.member.repository.MemberRepository;
+import com.ceos22nd.voting_system.domain.member.repository.TeamRepository;
+import com.ceos22nd.voting_system.global.dto.ErrorCode;
+import com.ceos22nd.voting_system.global.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+ private final MemberRepository memberRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final TokenProvider tokenProvider;
+ private final TeamRepository teamRepository;
+
+ @Transactional
+ public SignUpResponse signUp(SignUpRequest request){
+ log.info("회원가입 시도: loginId = {}, email = {}, name = {}",
+ request.loginId(), request.email(), request.realName()
+ );
+ // 아이디 유효성 검사
+ validateLoginId(request.loginId());
+
+ // 이메일 유효성 검사
+ validateEmail(request.email());
+
+ // 비밀번호 해싱
+ String encryptedPassword = passwordEncoder.encode(request.password());
+
+ Team team = teamRepository.findByTeamName(request.team().getInputValue())
+ .orElseThrow(() -> new BusinessException(ErrorCode.TEAM_NOT_FOUND));
+
+ // Member 객체 생성
+ Member newMember = Member.create(
+ request.loginId(),
+ encryptedPassword,
+ request.email(),
+ request.part(),
+ team,
+ request.realName(),
+ request.isPartLeadCandidate()
+ );
+
+ // DB 저장
+ Member savedMember = memberRepository.save(newMember);
+
+ log.info("회원가입 성공: memberId = {}", savedMember.getId());
+ return SignUpResponse.from(savedMember);
+ }
+
+ private void validateLoginId(String loginId){
+ boolean exist = memberRepository.existsByLoginId(loginId);
+ if (exist){
+ throw new BusinessException(ErrorCode.DUPLICATE_LOGIN_ID);
+ }
+ }
+
+ private void validateEmail(String email){
+ boolean exist = memberRepository.existsByEmail(email);
+ if (exist){
+ throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public LogInResponse logIn(LogInRequest request){
+ log.info("로그인 시도: loginId = {}", request.loginId());
+ Member member = memberRepository.findByLoginId(request.loginId())
+ .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED));
+
+ if (!passwordEncoder.matches(request.password(), member.getPassword())){
+ throw new BusinessException(ErrorCode.LOGIN_FAILED);
+ }
+
+ String accessToken = tokenProvider.createAccessToken(member.getId());
+
+ log.info("로그인 성공: memberId = {}, loginId = {}", member.getId(), member.getLoginId());
+ return new LogInResponse(accessToken);
+ }
+
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/auth/service/CustomUserDetailsService.java b/src/main/java/com/ceos22nd/voting_system/domain/auth/service/CustomUserDetailsService.java
new file mode 100644
index 0000000..cb7dfb8
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/auth/service/CustomUserDetailsService.java
@@ -0,0 +1,23 @@
+package com.ceos22nd.voting_system.domain.auth.service;
+
+import com.ceos22nd.voting_system.domain.auth.security.CustomUserDetails;
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import com.ceos22nd.voting_system.domain.member.repository.MemberRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class CustomUserDetailsService implements UserDetailsService {
+ private final MemberRepository memberRepository;
+
+ @Override
+ public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
+ Member member = memberRepository.findById(Long.parseLong(memberId))
+ .orElseThrow(() -> new UsernameNotFoundException("해당 유저가 존재하지 않습니다. id= " + memberId));
+ return new CustomUserDetails(member);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/controller/MemberController.java b/src/main/java/com/ceos22nd/voting_system/domain/member/controller/MemberController.java
new file mode 100644
index 0000000..4b5669a
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/controller/MemberController.java
@@ -0,0 +1,44 @@
+package com.ceos22nd.voting_system.domain.member.controller;
+
+import com.ceos22nd.voting_system.domain.auth.security.CustomUserDetails;
+import com.ceos22nd.voting_system.domain.member.dto.MemberStatusResponse;
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import com.ceos22nd.voting_system.domain.member.service.MemberService;
+import com.ceos22nd.voting_system.domain.vote.service.VoteService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("api/members")
+public class MemberController {
+ private final MemberService memberService;
+ private final VoteService voteService;
+
+ @GetMapping("/me")
+ public ResponseEntity getMemberStatus(
+ @AuthenticationPrincipal CustomUserDetails userDetails
+ ) {
+ Long memberId = userDetails.getId();
+
+ Member member = memberService.findByMemberId(memberId);
+
+ boolean hasVotedForTeam = voteService.hasVotedForTeam(memberId);
+ boolean hasVotedForPart = voteService.hasVotedForPartLead(memberId);
+
+ return ResponseEntity.ok(
+ MemberStatusResponse.of(
+ member.getId(),
+ member.getRealName(),
+ member.getTeam().getTeamName(),
+ member.getPart(),
+ hasVotedForTeam,
+ hasVotedForPart
+ )
+ );
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/dto/MemberStatusResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/member/dto/MemberStatusResponse.java
new file mode 100644
index 0000000..672554e
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/dto/MemberStatusResponse.java
@@ -0,0 +1,26 @@
+package com.ceos22nd.voting_system.domain.member.dto;
+
+import com.ceos22nd.voting_system.domain.member.enums.PartType;
+import lombok.Builder;
+
+@Builder
+public record MemberStatusResponse(
+ Long id,
+ String name,
+ String team,
+ PartType part,
+ boolean hasVotedForTeam,
+ boolean hasVotedForPartLead
+) {
+ public static MemberStatusResponse of(Long id, String name, String team, PartType part,
+ boolean teamVote, boolean partVote) {
+ return MemberStatusResponse.builder()
+ .id(id)
+ .name(name)
+ .team(team)
+ .part(part)
+ .hasVotedForTeam(teamVote)
+ .hasVotedForPartLead(partVote)
+ .build();
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/entity/Member.java b/src/main/java/com/ceos22nd/voting_system/domain/member/entity/Member.java
new file mode 100644
index 0000000..f73066d
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/entity/Member.java
@@ -0,0 +1,84 @@
+package com.ceos22nd.voting_system.domain.member.entity;
+
+import com.ceos22nd.voting_system.domain.member.enums.PartType;
+import com.ceos22nd.voting_system.global.dto.ErrorCode;
+import com.ceos22nd.voting_system.global.exception.BusinessException;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Member {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "login_id", unique = true, nullable = false)
+ private String loginId;
+
+ @Column(name = "password", nullable = false)
+ private String password;
+
+ @Column(name = "email", unique = true, nullable = false)
+ private String email;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "part", nullable = false)
+ private PartType part;
+
+ @Column(name ="real_name", nullable = false)
+ private String realName;
+
+ @Column(name = "is_part_lead_candidate", nullable = false)
+ private boolean isPartLeadCandidate;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "team_id")
+ private Team team;
+
+ @Builder(access = AccessLevel.PRIVATE)
+ private Member(String loginId, String password, String email, PartType part,
+ Team team, String realName, boolean isPartLeadCandidate) {
+ this.loginId = loginId;
+ this.password = password; // DB 컬럼명과 매핑
+ this.email = email;
+ this.part = part;
+ this.team = team;
+ this.realName = realName;
+ this.isPartLeadCandidate = isPartLeadCandidate;
+ }
+
+ public static Member create(
+ String loginId,
+ String encryptedPassword,
+ String email,
+ PartType part,
+ Team team,
+ String realName,
+ boolean isPartLeadCandidate
+ ){
+ return Member.builder()
+ .loginId(loginId)
+ .password(encryptedPassword)
+ .email(email)
+ .part(part)
+ .team(team)
+ .realName(realName)
+ .isPartLeadCandidate(isPartLeadCandidate)
+ .build();
+ }
+
+ public void validateTeamVote(Long voteTeam) {
+ if (this.team.getId().equals(voteTeam)) {
+ throw new BusinessException(ErrorCode.SELF_VOTING_NOT_ALLOWED);
+ }
+ }
+
+ public void validateCandidate() {
+ if(!this.isPartLeadCandidate) {
+ throw new BusinessException(ErrorCode.CANDIDATE_NOT_FOUND);
+ }
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/entity/Team.java b/src/main/java/com/ceos22nd/voting_system/domain/member/entity/Team.java
new file mode 100644
index 0000000..89a0042
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/entity/Team.java
@@ -0,0 +1,24 @@
+package com.ceos22nd.voting_system.domain.member.entity;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table
+@Getter
+public class Team {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "team_name")
+ private String teamName;
+
+ @Column(name = "team_member")
+ @OneToMany(mappedBy = "team")
+ private List members = new ArrayList<>();
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/enums/PartType.java b/src/main/java/com/ceos22nd/voting_system/domain/member/enums/PartType.java
new file mode 100644
index 0000000..0433720
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/enums/PartType.java
@@ -0,0 +1,9 @@
+package com.ceos22nd.voting_system.domain.member.enums;
+
+public enum PartType {
+ FRONTEND, BACKEND;
+
+ public static PartType from(String value) {
+ return PartType.valueOf(value.toUpperCase());
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/enums/TeamType.java b/src/main/java/com/ceos22nd/voting_system/domain/member/enums/TeamType.java
new file mode 100644
index 0000000..8946a5f
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/enums/TeamType.java
@@ -0,0 +1,34 @@
+package com.ceos22nd.voting_system.domain.member.enums;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.util.stream.Stream;
+
+@Getter
+@RequiredArgsConstructor
+public enum TeamType {
+ // 괄호 안의 문자열이 프론트에서 보내주는 value와 정확히 일치해야 합니다.
+ STORIX("STORIX"),
+ MODELLY("Modelly"),
+ CATCHUP("CatchUp"),
+ MENUAL("Menual"),
+ DIGGINDIE("DiggIndie");
+
+ private final String inputValue; // 프론트에서 넘어오는 값
+
+ @JsonCreator
+ public static TeamType from(String value) {
+ return Stream.of(TeamType.values())
+ .filter(team -> team.getInputValue().equals(value))
+ .findFirst()
+ .orElse(null);
+ }
+
+ @JsonValue
+ public String getValue() {
+ return name(); // CATCHUP
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/repository/MemberRepository.java b/src/main/java/com/ceos22nd/voting_system/domain/member/repository/MemberRepository.java
new file mode 100644
index 0000000..9acc649
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/repository/MemberRepository.java
@@ -0,0 +1,19 @@
+package com.ceos22nd.voting_system.domain.member.repository;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface MemberRepository extends JpaRepository {
+
+ List findByIsPartLeadCandidateIsTrue();
+
+ Optional findByLoginId(String loginId);
+
+ boolean existsByLoginId(String loginId);
+ boolean existsByEmail(String email);
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/repository/TeamRepository.java b/src/main/java/com/ceos22nd/voting_system/domain/member/repository/TeamRepository.java
new file mode 100644
index 0000000..feae5c5
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/repository/TeamRepository.java
@@ -0,0 +1,12 @@
+package com.ceos22nd.voting_system.domain.member.repository;
+
+import com.ceos22nd.voting_system.domain.member.entity.Team;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface TeamRepository extends JpaRepository {
+ Optional findByTeamName(String teamName);
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/member/service/MemberService.java b/src/main/java/com/ceos22nd/voting_system/domain/member/service/MemberService.java
new file mode 100644
index 0000000..55317d4
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/member/service/MemberService.java
@@ -0,0 +1,19 @@
+package com.ceos22nd.voting_system.domain.member.service;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import com.ceos22nd.voting_system.domain.member.repository.MemberRepository;
+import com.ceos22nd.voting_system.global.dto.ErrorCode;
+import com.ceos22nd.voting_system.global.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class MemberService {
+ private final MemberRepository memberRepository;
+
+ public Member findByMemberId(Long memberId) {
+ return memberRepository.findById(memberId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/controller/CandidateController.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/controller/CandidateController.java
new file mode 100644
index 0000000..3429d75
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/controller/CandidateController.java
@@ -0,0 +1,44 @@
+package com.ceos22nd.voting_system.domain.vote.controller;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import com.ceos22nd.voting_system.domain.member.entity.Team;
+import com.ceos22nd.voting_system.domain.vote.dto.PartLeadCandidateResponse;
+import com.ceos22nd.voting_system.domain.vote.dto.TeamCandidateResponse;
+import com.ceos22nd.voting_system.domain.vote.service.VoteService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/candidates")
+@RequiredArgsConstructor
+public class CandidateController {
+
+ private final VoteService voteService;
+
+ @GetMapping("/teams")
+ public ResponseEntity> getTeamCandidates() {
+ List candidates = voteService.getAllTeams();
+
+ List response = candidates.stream()
+ .map(TeamCandidateResponse::from)
+ .toList();
+
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("/parts")
+ public ResponseEntity> getPartLeadCandidates() {
+ List candidates = voteService.getCandidates();
+
+ List response = candidates.stream()
+ .map(PartLeadCandidateResponse::from)
+ .toList();
+
+ return ResponseEntity.ok(response);
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/controller/VoteController.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/controller/VoteController.java
new file mode 100644
index 0000000..d503c09
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/controller/VoteController.java
@@ -0,0 +1,74 @@
+package com.ceos22nd.voting_system.domain.vote.controller;
+
+import com.ceos22nd.voting_system.domain.auth.security.CustomUserDetails;
+import com.ceos22nd.voting_system.domain.member.enums.PartType;
+import com.ceos22nd.voting_system.domain.vote.dto.VoteRequest;
+import com.ceos22nd.voting_system.domain.vote.dto.VoteResultResponse;
+import com.ceos22nd.voting_system.domain.vote.dto.VoteStatusResponse;
+import com.ceos22nd.voting_system.domain.vote.service.VoteService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/votes")
+@RequiredArgsConstructor
+public class VoteController {
+
+ private final VoteService voteService;
+
+ @PostMapping("/teams")
+ public ResponseEntity voteForTeam(
+ @AuthenticationPrincipal CustomUserDetails userDetails,
+ @RequestBody VoteRequest request) {
+
+ voteService.voteForTeam(userDetails.getId(), request.getTargetId());
+
+ return ResponseEntity.ok().body("팀 투표가 완료되었습니다.");
+ }
+
+ @PostMapping("/parts")
+ public ResponseEntity voteForPartLead(
+ @AuthenticationPrincipal CustomUserDetails userDetails,
+ @RequestBody VoteRequest request) {
+
+ voteService.voteForPartLead(userDetails.getId(), request.getTargetId());
+
+ return ResponseEntity.ok().body("파트장 투표가 완료되었습니다.");
+ }
+
+ @GetMapping("/status")
+ public ResponseEntity getVoteStatus(
+ @AuthenticationPrincipal CustomUserDetails userDetails) {
+
+ Long voterId = userDetails.getId();
+
+ boolean hasVotedForTeam = voteService.hasVotedForTeam(voterId);
+ boolean hasVotedForPartLead = voteService.hasVotedForPartLead(voterId);
+
+ return ResponseEntity.ok(VoteStatusResponse.of(hasVotedForTeam, hasVotedForPartLead));
+ }
+
+ @GetMapping("/results/teams")
+ public ResponseEntity> getTeamVoteResult(
+ @AuthenticationPrincipal CustomUserDetails userDetails) {
+
+ List response = voteService.getTeamVoteResult();
+
+ return ResponseEntity.ok(response);
+ }
+
+ @GetMapping("/results/parts/{part}")
+ public ResponseEntity> getPartVoteResult(
+ @AuthenticationPrincipal CustomUserDetails userDetails,
+ @PathVariable("part") String part
+ ) {
+ PartType partType = PartType.from(part);
+ List response = voteService.getPartVoteResult(partType);
+
+ return ResponseEntity.ok(response);
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/PartLeadCandidateResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/PartLeadCandidateResponse.java
new file mode 100644
index 0000000..f2273c0
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/PartLeadCandidateResponse.java
@@ -0,0 +1,19 @@
+package com.ceos22nd.voting_system.domain.vote.dto;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import lombok.Builder;
+
+@Builder
+public record PartLeadCandidateResponse(
+ Long id,
+ String name,
+ String part
+) {
+ public static PartLeadCandidateResponse from(Member member) {
+ return PartLeadCandidateResponse.builder()
+ .id(member.getId())
+ .name(member.getRealName())
+ .part(member.getPart().name())
+ .build();
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/TeamCandidateResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/TeamCandidateResponse.java
new file mode 100644
index 0000000..3ccfa17
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/TeamCandidateResponse.java
@@ -0,0 +1,17 @@
+package com.ceos22nd.voting_system.domain.vote.dto;
+
+import com.ceos22nd.voting_system.domain.member.entity.Team;
+import lombok.Builder;
+
+@Builder
+public record TeamCandidateResponse(
+ Long id,
+ String name
+) {
+ public static TeamCandidateResponse from(Team team) {
+ return TeamCandidateResponse.builder()
+ .id(team.getId())
+ .name(team.getTeamName())
+ .build();
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/TotalVoteResultResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/TotalVoteResultResponse.java
new file mode 100644
index 0000000..5609e7e
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/TotalVoteResultResponse.java
@@ -0,0 +1,21 @@
+package com.ceos22nd.voting_system.domain.vote.dto;
+
+import lombok.Builder;
+
+import java.util.List;
+
+@Builder
+public record TotalVoteResultResponse(
+ List teamVoteResults,
+ List partLeadVoteResults
+) {
+ public static TotalVoteResultResponse of(
+ List teamVoteResults,
+ List partLeadResults
+ ){
+ return TotalVoteResultResponse.builder()
+ .teamVoteResults(teamVoteResults)
+ .partLeadVoteResults(partLeadResults)
+ .build();
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteRequest.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteRequest.java
new file mode 100644
index 0000000..26226b3
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteRequest.java
@@ -0,0 +1,14 @@
+package com.ceos22nd.voting_system.domain.vote.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class VoteRequest {
+ private Long targetId; // teamId 또는 candidateId
+
+ public VoteRequest(Long targetId) {
+ this.targetId = targetId;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteResultResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteResultResponse.java
new file mode 100644
index 0000000..48475a3
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteResultResponse.java
@@ -0,0 +1,17 @@
+package com.ceos22nd.voting_system.domain.vote.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class VoteResultResponse {
+
+ private Long targetId; // teamId 또는 candidateId
+ private String targetName; // team name 또는 member name
+ private Long voteCount;
+
+ public static VoteResultResponse of(Long targetId, String targetName, Long voteCount) {
+ return new VoteResultResponse(targetId, targetName, voteCount);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteStatusResponse.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteStatusResponse.java
new file mode 100644
index 0000000..fc30969
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/dto/VoteStatusResponse.java
@@ -0,0 +1,15 @@
+package com.ceos22nd.voting_system.domain.vote.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class VoteStatusResponse {
+ private boolean teamVote;
+ private boolean partLeadVote;
+
+ public static VoteStatusResponse of(boolean teamVote, boolean partLeadVote) {
+ return new VoteStatusResponse(teamVote, partLeadVote);
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/entity/PartLeadVote.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/entity/PartLeadVote.java
new file mode 100644
index 0000000..73f2aa6
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/entity/PartLeadVote.java
@@ -0,0 +1,36 @@
+package com.ceos22nd.voting_system.domain.vote.entity;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(uniqueConstraints = {
+ @UniqueConstraint(name = "uk_part_lead_vote_voter", columnNames = "voter_id")})
+public class PartLeadVote {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @JoinColumn(name = "candidate_id", nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Member candidate;
+
+ @JoinColumn(name = "voter_id", nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Member voter;
+
+ private PartLeadVote(Member candidate, Member voter) {
+ this.candidate = candidate;
+ this.voter = voter;
+ }
+
+ public static PartLeadVote createVote(Member candidate, Member voter) {
+ return new PartLeadVote(candidate, voter);
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/entity/TeamVote.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/entity/TeamVote.java
new file mode 100644
index 0000000..1a63dc0
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/entity/TeamVote.java
@@ -0,0 +1,39 @@
+package com.ceos22nd.voting_system.domain.vote.entity;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import com.ceos22nd.voting_system.domain.member.entity.Team;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(uniqueConstraints = {
+ @UniqueConstraint(name = "uk_team_vote_voter", columnNames = "voter_id")
+})
+public class TeamVote {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "team_id", nullable = false)
+ private Team team;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "voter_id", nullable = false)
+ private Member voter;
+
+ private TeamVote(Member voter, Team team) {
+ this.voter = voter;
+ this.team = team;
+ }
+
+ public static TeamVote createVote(Member voter, Team team) {
+ return new TeamVote(voter, team);
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/repository/PartLeadVoteRepository.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/repository/PartLeadVoteRepository.java
new file mode 100644
index 0000000..06d6db8
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/repository/PartLeadVoteRepository.java
@@ -0,0 +1,26 @@
+package com.ceos22nd.voting_system.domain.vote.repository;
+
+import com.ceos22nd.voting_system.domain.member.enums.PartType;
+import com.ceos22nd.voting_system.domain.vote.dto.VoteResultResponse;
+import com.ceos22nd.voting_system.domain.vote.entity.PartLeadVote;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface PartLeadVoteRepository extends JpaRepository {
+
+ @Query("SELECT new com.ceos22nd.voting_system.domain.vote.dto.VoteResultResponse(" +
+ " m.id, m.realName, COUNT(v)) " +
+ "FROM PartLeadVote v " +
+ "RIGHT JOIN v.candidate m " +
+ "WHERE m.isPartLeadCandidate = true AND m.part = :part " +
+ "GROUP BY m.id, m.realName " +
+ "ORDER BY COUNT(v) DESC")
+ List findAllPartLeadVoteResultsByPart(@Param("part")PartType part);
+
+ Long countByCandidateId(Long teamId);
+
+ boolean existsByVoterId(Long voterId);
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/repository/TeamVoteRepository.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/repository/TeamVoteRepository.java
new file mode 100644
index 0000000..05fe0f0
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/repository/TeamVoteRepository.java
@@ -0,0 +1,23 @@
+package com.ceos22nd.voting_system.domain.vote.repository;
+
+import com.ceos22nd.voting_system.domain.vote.dto.VoteResultResponse;
+import com.ceos22nd.voting_system.domain.vote.entity.TeamVote;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+public interface TeamVoteRepository extends JpaRepository {
+
+ @Query("SELECT new com.ceos22nd.voting_system.domain.vote.dto.VoteResultResponse(" +
+ " t.id, t.teamName, COUNT(v)) " +
+ "FROM TeamVote v " +
+ "RIGHT JOIN v.team t " +
+ "GROUP BY t.id, t.teamName " +
+ "ORDER BY COUNT(v) DESC")
+ List findAllTeamVoteResults();
+
+ Long countByTeamId(Long teamId);
+
+ boolean existsByVoterId(Long voterId);
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/domain/vote/service/VoteService.java b/src/main/java/com/ceos22nd/voting_system/domain/vote/service/VoteService.java
new file mode 100644
index 0000000..17ee85d
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/domain/vote/service/VoteService.java
@@ -0,0 +1,147 @@
+package com.ceos22nd.voting_system.domain.vote.service;
+
+import com.ceos22nd.voting_system.domain.member.entity.Member;
+import com.ceos22nd.voting_system.domain.member.entity.Team;
+import com.ceos22nd.voting_system.domain.member.enums.PartType;
+import com.ceos22nd.voting_system.domain.member.repository.MemberRepository;
+import com.ceos22nd.voting_system.domain.member.repository.TeamRepository;
+import com.ceos22nd.voting_system.domain.vote.dto.VoteResultResponse;
+import com.ceos22nd.voting_system.domain.vote.entity.PartLeadVote;
+import com.ceos22nd.voting_system.domain.vote.entity.TeamVote;
+import com.ceos22nd.voting_system.domain.vote.repository.PartLeadVoteRepository;
+import com.ceos22nd.voting_system.domain.vote.repository.TeamVoteRepository;
+import com.ceos22nd.voting_system.global.dto.ErrorCode;
+import com.ceos22nd.voting_system.global.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.dao.DataAccessException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class VoteService {
+
+ private final TeamVoteRepository teamVoteRepository;
+ private final PartLeadVoteRepository partLeadRepository;
+ private final MemberRepository memberRepository;
+ private final TeamRepository teamRepository;
+ private final PartLeadVoteRepository partLeadVoteRepository;
+
+ /**
+ * 팀에 투표하기
+ */
+ @Transactional
+ public void voteForTeam(Long voterId, Long teamId) {
+ Member voter = getMemberOrThrow(voterId);
+ Team team = getTeamOrThrow(teamId);
+
+ validateNotVotedForTeam(voter.getId());
+ voter.validateTeamVote(teamId);
+
+ TeamVote teamVote = TeamVote.createVote(voter, team);
+ teamVoteRepository.save(teamVote);
+ }
+
+ /**
+ * 파트장 후보에게 투표하기
+ */
+ @Transactional
+ public void voteForPartLead(Long voterId, Long candidateId) {
+ Member voter = getMemberOrThrow(voterId);
+ Member candidate = getMemberOrThrow(candidateId);
+
+ validateNotVotedForPartLead(candidate.getId());
+ candidate.validateCandidate();
+
+ PartLeadVote partLeadVote = PartLeadVote.createVote(voter, candidate);
+ partLeadRepository.save(partLeadVote);
+ }
+
+ @Transactional(readOnly = true)
+ public List getTeamVoteResult(){
+ try {
+ return teamVoteRepository.findAllTeamVoteResults();
+ } catch (DataAccessException e){
+ throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public List getPartVoteResult(PartType partType){
+ try {
+ return partLeadVoteRepository.findAllPartLeadVoteResultsByPart(partType);
+ } catch (DataAccessException e){
+ throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
+ }
+ }
+
+ /**
+ * 팀별 투표 결과 조회
+ */
+ public long getTeamVoteCount(Long teamId) {
+ return teamVoteRepository.countByTeamId(teamId);
+ }
+
+ /**
+ * 후보자별 투표 결과 조회
+ */
+ public long getPartLeadVoteCount(Long candidateId) {
+ return partLeadRepository.countByCandidateId(candidateId);
+ }
+
+ /**
+ * 후보자 목록 조회
+ */
+ public List getCandidates() {
+ return memberRepository.findByIsPartLeadCandidateIsTrue();
+ }
+
+ /**
+ * 모든 팀 조회
+ */
+ public List getAllTeams() {
+ return teamRepository.findAll();
+ }
+
+ /**
+ * 특정 회원의 팀 투표 여부 확인
+ */
+ public boolean hasVotedForTeam(Long voterId) {
+ return teamVoteRepository.existsByVoterId(voterId);
+ }
+
+ /**
+ * 특정 회원의 파트장 투표 여부 확인
+ */
+ public boolean hasVotedForPartLead(Long voterId) {
+ return partLeadRepository.existsByVoterId(voterId);
+ }
+
+ // =================================================================================================================
+ private Member getMemberOrThrow(Long memberId) {
+ return memberRepository.findById(memberId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ private Team getTeamOrThrow(Long teamId) {
+ return teamRepository.findById(teamId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.TEAM_NOT_FOUND));
+ }
+
+ private void validateNotVotedForTeam(Long voterId) {
+ if(teamVoteRepository.existsByVoterId(voterId)) {
+ throw new BusinessException(ErrorCode.ALREADY_VOTED);
+ }
+ }
+
+ private void validateNotVotedForPartLead(Long voterId) {
+ if(partLeadRepository.existsByVoterId(voterId)) {
+ throw new BusinessException(ErrorCode.ALREADY_VOTED);
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/ceos22nd/voting_system/global/dto/ErrorCode.java b/src/main/java/com/ceos22nd/voting_system/global/dto/ErrorCode.java
new file mode 100644
index 0000000..178786e
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/global/dto/ErrorCode.java
@@ -0,0 +1,41 @@
+package com.ceos22nd.voting_system.global.dto;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@RequiredArgsConstructor
+public enum ErrorCode {
+
+ /**
+ * 회원가입
+ */
+ DUPLICATE_LOGIN_ID(HttpStatus.BAD_REQUEST, "이미 존재하는 아이디입니다."),
+ DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일입니다."),
+
+ /**
+ * 로그인
+ */
+ LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "아이디 또는 비빌번호가 일치하지 않습니다."),
+ USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."),
+
+
+ /**
+ * Common
+ */
+ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생헀습니다."),
+ INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."),
+
+ /**
+ * Vote
+ */
+ ALREADY_VOTED(HttpStatus.BAD_REQUEST, "이미 투표를 완료했습니다."),
+ SELF_VOTING_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "본인 팀에 투표할 수 없습니다."),
+ TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 팀입니다."),
+ MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."),
+ CANDIDATE_NOT_FOUND(HttpStatus.NOT_FOUND, "후보자가 아닙니다.");
+
+ private final HttpStatus httpStatus;
+ private final String message;
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/global/dto/ErrorResponse.java b/src/main/java/com/ceos22nd/voting_system/global/dto/ErrorResponse.java
new file mode 100644
index 0000000..0f7e106
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/global/dto/ErrorResponse.java
@@ -0,0 +1,24 @@
+package com.ceos22nd.voting_system.global.dto;
+
+import lombok.Builder;
+import org.springframework.http.ResponseEntity;
+
+import java.time.LocalDateTime;
+
+@Builder
+public record ErrorResponse(
+ String code,
+ String message,
+ LocalDateTime timestamp
+) {
+ public static ResponseEntity toResponseEntity(ErrorCode errorCode) {
+ return ResponseEntity
+ .status(errorCode.getHttpStatus())
+ .body(ErrorResponse.builder()
+ .code(errorCode.name())
+ .message(errorCode.getMessage())
+ .timestamp(LocalDateTime.now())
+ .build()
+ );
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/global/exception/BusinessException.java b/src/main/java/com/ceos22nd/voting_system/global/exception/BusinessException.java
new file mode 100644
index 0000000..0140fdb
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/global/exception/BusinessException.java
@@ -0,0 +1,14 @@
+package com.ceos22nd.voting_system.global.exception;
+
+import com.ceos22nd.voting_system.global.dto.ErrorCode;
+import lombok.Getter;
+
+@Getter
+public class BusinessException extends RuntimeException {
+ private final ErrorCode errorCode;
+
+ public BusinessException(ErrorCode errorCode) {
+ super(errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/com/ceos22nd/voting_system/global/exception/GlobalExceptionHandler.java b/src/main/java/com/ceos22nd/voting_system/global/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..5c70caa
--- /dev/null
+++ b/src/main/java/com/ceos22nd/voting_system/global/exception/GlobalExceptionHandler.java
@@ -0,0 +1,50 @@
+package com.ceos22nd.voting_system.global.exception;
+
+import com.ceos22nd.voting_system.global.dto.ErrorCode;
+import com.ceos22nd.voting_system.global.dto.ErrorResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(BusinessException.class)
+ public ResponseEntity handleBusinessException(BusinessException e) {
+ log.warn("Business Exception: {}", e.getMessage());
+ return ErrorResponse.toResponseEntity(e.getErrorCode());
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationException(MethodArgumentNotValidException e) {
+
+ String errorMessage = Optional.ofNullable(e.getBindingResult().getFieldError())
+ .map(DefaultMessageSourceResolvable::getDefaultMessage)
+ .orElse("입력값이 올바르지 않습니다.");
+
+ ErrorResponse response = ErrorResponse.builder()
+ .code("INVALID_INPUT")
+ .message(errorMessage)
+ .timestamp(LocalDateTime.now())
+ .build();
+
+ log.warn("Validation Exception: {}", errorMessage);
+
+ return ResponseEntity
+ .status(ErrorCode.INVALID_INPUT_VALUE.getHttpStatus())
+ .body(response);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleException(Exception e) {
+ log.error("Unhandled Exception: ", e);
+ return ErrorResponse.toResponseEntity(ErrorCode.INTERNAL_SERVER_ERROR);
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..f279718
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,19 @@
+spring:
+ datasource:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: ${SPRING_DATASOURCE_URL}
+ username: ${SPRING_DATASOURCE_USERNAME}
+ password: ${SPRING_DATASOURCE_PASSWORD}
+
+ jpa:
+ hibernate:
+ ddl-auto: ${JPA_HIBERNATE_DDL_AUTO}
+ database-platform: org.hibernate.dialect.MySQLDialect
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+
+jwt:
+ secret: ${JWT_SECRET}
+ access_expiration: ${JWT_ACCESS_EXPIRATION}
\ No newline at end of file
diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql
new file mode 100644
index 0000000..4fd1013
--- /dev/null
+++ b/src/main/resources/import.sql
@@ -0,0 +1,5 @@
+INSERT INTO team (team_name) VALUES ('STORIX');
+INSERT INTO team (team_name) VALUES ('Modelly');
+INSERT INTO team (team_name) VALUES ('CatchUp');
+INSERT INTO team (team_name) VALUES ('Menual');
+INSERT INTO team (team_name) VALUES ('DiggIndie');
\ No newline at end of file
diff --git a/src/test/java/com/ceos22nd/voting_system/VotingSystemApplicationTests.java b/src/test/java/com/ceos22nd/voting_system/VotingSystemApplicationTests.java
new file mode 100644
index 0000000..e474963
--- /dev/null
+++ b/src/test/java/com/ceos22nd/voting_system/VotingSystemApplicationTests.java
@@ -0,0 +1,13 @@
+package com.ceos22nd.voting_system;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class VotingSystemApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}