diff --git a/.github/workflows/backend-dev.yml b/.github/workflows/backend-dev.yml
index a3257f028..d41e8468f 100644
--- a/.github/workflows/backend-dev.yml
+++ b/.github/workflows/backend-dev.yml
@@ -70,3 +70,41 @@ jobs:
- name: Docker run
run: sudo docker run -d -p 8080:8080 -e SPRING_PROFILES_ACTIVE=dev -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }}
+
+ deploy-prod-1:
+ needs: build
+ runs-on: [ self-hosted, backend-prod-1 ]
+ steps:
+ - name: Docker remove
+ run: |
+ CONTAINER_IDS=$(sudo docker ps -qa)
+ if [ -n "$CONTAINER_IDS" ]; then
+ sudo docker rm -f $CONTAINER_IDS
+ else
+ echo "No running containers found."
+ fi
+
+ - name: Docker Image pull
+ run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }}
+
+ - name: Docker run
+ run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }}
+
+ deploy-prod-2:
+ needs: build
+ runs-on: [ self-hosted, backend-prod-2 ]
+ steps:
+ - name: Docker remove
+ run: |
+ CONTAINER_IDS=$(sudo docker ps -qa)
+ if [ -n "$CONTAINER_IDS" ]; then
+ sudo docker rm -f $CONTAINER_IDS
+ else
+ echo "No running containers found."
+ fi
+
+ - name: Docker Image pull
+ run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }}
+
+ - name: Docker run
+ run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }}
diff --git a/.github/workflows/backend-prod.yml b/.github/workflows/backend-prod.yml
index f7daacd33..0c7a5073b 100644
--- a/.github/workflows/backend-prod.yml
+++ b/.github/workflows/backend-prod.yml
@@ -54,9 +54,9 @@ jobs:
docker buildx build --platform linux/arm64 -t \
${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }} --push .
- deploy:
+ deploy-prod-1:
needs: build
- runs-on: [ self-hosted, backend-prod ]
+ runs-on: [ self-hosted, backend-prod-1 ]
steps:
- name: Docker remove
run: |
@@ -71,4 +71,25 @@ jobs:
run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }}
- name: Docker run
- run: sudo docker run -d -p 8080:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }}
+ run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }}
+
+ deploy-prod-2:
+ needs: build
+ runs-on: [ self-hosted, backend-prod-2 ]
+ steps:
+ - name: Docker remove
+ run: |
+ CONTAINER_IDS=$(sudo docker ps -qa)
+ if [ -n "$CONTAINER_IDS" ]; then
+ sudo docker rm -f $CONTAINER_IDS
+ else
+ echo "No running containers found."
+ fi
+
+ - name: Docker Image pull
+ run: sudo docker pull ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_PROD }}
+
+ - name: Docker run
+ run: sudo docker run -d -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod -v log-volume:/app/logs --name haengdong-backend ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_IMAGE_BE_DEV }}
+
+
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 000000000..671ee930b
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,244 @@
+# Created by https://www.toptal.com/developers/gitignore/api/java,intellij,gradle,macos,windows,linux
+# Edit at https://www.toptal.com/developers/gitignore?templates=java,intellij,gradle,macos,windows,linux
+
+### Intellij ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### Intellij Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+# Azure Toolkit for IntelliJ plugin
+# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
+.idea/**/azureSettings.xml
+
+### Java ###
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+replay_pid*
+
+### Linux ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+### Gradle ###
+.gradle
+**/build/
+!src/**/build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
+
+# Avoid ignore Gradle wrappper properties
+!gradle-wrapper.properties
+
+# Cache of project
+.gradletasknamecache
+
+# Eclipse Gradle plugin generated files
+# Eclipse Core
+.project
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+
+### Gradle Patch ###
+# Java heap dump
+*.hprof
+
+# End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle,macos,windows,linux
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 000000000..df2cf44e0
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,9 @@
+FROM openjdk:17-jdk-slim
+
+WORKDIR /app
+
+COPY /build/libs/*.jar /app/haengdong-0.0.1-SNAPSHOT.jar
+
+EXPOSE 8080
+ENTRYPOINT ["java"]
+CMD ["-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-Duser.timezone=Asia/Seoul", "-jar", "haengdong-0.0.1-SNAPSHOT.jar"]
diff --git a/server/build.gradle b/server/build.gradle
new file mode 100644
index 000000000..c2936590a
--- /dev/null
+++ b/server/build.gradle
@@ -0,0 +1,87 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.3.1'
+ id 'io.spring.dependency-management' version '1.1.5'
+ id 'org.asciidoctor.jvm.convert' version '3.3.2'
+}
+
+group = 'server'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+configurations {
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+ asciidoctorExt
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ 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'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+
+ implementation 'io.jsonwebtoken:jjwt:0.9.1'
+ implementation 'javax.xml.bind:jaxb-api:2.3.1'
+
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+ annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
+ testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
+}
+
+ext {
+ snippetsDir = file('build/generated-snippets')
+}
+
+test {
+ useJUnitPlatform()
+ outputs.dir snippetsDir
+}
+
+asciidoctor {
+ inputs.dir snippetsDir
+ configurations 'asciidoctorExt'
+ baseDirFollowsSourceFile()
+ dependsOn test
+}
+
+tasks.resolveMainClassName {
+ dependsOn 'copyApiDocuments'
+}
+
+tasks.register('copyApiDocuments', Copy) {
+ dependsOn asciidoctor
+ from file("build/docs/asciidoc")
+ into file("build/resources/main/static/docs")
+}
+
+bootJar {
+ dependsOn copyApiDocuments
+}
+
+jar {
+ enabled = false
+}
+
+build {
+ dependsOn copyApiDocuments
+}
diff --git a/server/docs/24-08-04-erd.sql b/server/docs/24-08-04-erd.sql
new file mode 100644
index 000000000..ae2fbc4a5
--- /dev/null
+++ b/server/docs/24-08-04-erd.sql
@@ -0,0 +1,65 @@
+-- Create tables
+CREATE TABLE action
+(
+ event_id BIGINT,
+ id BIGINT AUTO_INCREMENT,
+ sequence BIGINT,
+ PRIMARY KEY (id)
+);
+
+CREATE TABLE bill_action
+(
+ action_id BIGINT UNIQUE,
+ id BIGINT AUTO_INCREMENT,
+ price BIGINT,
+ title VARCHAR(30),
+ PRIMARY KEY (id)
+);
+
+CREATE TABLE event
+(
+ id BIGINT AUTO_INCREMENT,
+ name VARCHAR(255),
+ token VARCHAR(255),
+ PRIMARY KEY (id)
+);
+
+CREATE TABLE event_step
+(
+ event_id BIGINT,
+ id BIGINT AUTO_INCREMENT,
+ sequence BIGINT,
+ name VARCHAR(255),
+ PRIMARY KEY (id)
+);
+
+CREATE TABLE member_action
+(
+ action_id BIGINT UNIQUE,
+ id BIGINT AUTO_INCREMENT,
+ member_group_id BIGINT,
+ member_name VARCHAR(255),
+ status ENUM('IN', 'OUT'),
+ PRIMARY KEY (id)
+);
+
+-- Add foreign key constraints
+ALTER TABLE action
+ ADD CONSTRAINT FKgf0qmub9va1xbe44nehny31yw
+ FOREIGN KEY (event_id)
+ REFERENCES event (id);
+
+ALTER TABLE bill_action
+ ADD CONSTRAINT FK54tx517tp0ry6453olkply4us
+ FOREIGN KEY (action_id)
+ REFERENCES action (id);
+
+ALTER TABLE event_step
+ ADD CONSTRAINT FKe3rkib91cvl0x5w9wqkshmn81
+ FOREIGN KEY (event_id)
+ REFERENCES event (id);
+
+ALTER TABLE member_action
+ ADD CONSTRAINT FK5jna51dn8fs2ir52l4uwn517u
+ FOREIGN KEY (action_id)
+ REFERENCES action (id);
diff --git a/server/docs/24-08-04-erd.svg b/server/docs/24-08-04-erd.svg
new file mode 100644
index 000000000..5a4bac225
--- /dev/null
+++ b/server/docs/24-08-04-erd.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..e6441136f
Binary files /dev/null and b/server/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..a4413138c
--- /dev/null
+++ b/server/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/server/gradlew b/server/gradlew
new file mode 100644
index 000000000..b740cf133
--- /dev/null
+++ b/server/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 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.
+#
+
+##############################################################################
+#
+# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ 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, 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" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# 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/server/gradlew.bat b/server/gradlew.bat
new file mode 100644
index 000000000..25da30dbd
--- /dev/null
+++ b/server/gradlew.bat
@@ -0,0 +1,92 @@
+@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
+
+@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
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+: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/server/settings.gradle b/server/settings.gradle
new file mode 100644
index 000000000..dbbee46fb
--- /dev/null
+++ b/server/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'haengdong'
diff --git a/server/src/docs/asciidoc/bill.adoc b/server/src/docs/asciidoc/bill.adoc
new file mode 100644
index 000000000..64b366594
--- /dev/null
+++ b/server/src/docs/asciidoc/bill.adoc
@@ -0,0 +1,121 @@
+== 지출
+
+=== 지출 생성
+
+operation::createBills[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"지출 금액은 비어 있으면 안됩니다."
+ },
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"지출 내역은 비어 있으면 안됩니다."
+ },
+ {
+ "code":"BILL_TITLE_INVALID",
+ "message":"앞뒤 공백을 제거한 지출 내역 제목은 %d ~ %d자여야 합니다."
+ },
+ {
+ "code":"BILL_PRICE_INVALID",
+ "message":"지출 금액은 10,000,000 이하의 자연수여야 합니다."
+ },
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ }
+]
+----
+
+=== 지출 수정
+
+operation::updateBill[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"지출 내역 제목은 공백일 수 없습니다."
+ },
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"지출 금액은 공백일 수 없습니다."
+ },
+ {
+ "code":"BILL_TITLE_INVALID",
+ "message":"앞뒤 공백을 제거한 지출 내역 제목은 %d ~ %d자여야 합니다."
+ },
+ {
+ "code":"BILL_PRICE_INVALID",
+ "message":"지출 금액은 %,d 이하의 자연수여야 합니다."
+ },
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"BILL_NOT_FOUND",
+ "message":"존재하지 않는 지출입니다."
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ }
+]
+----
+
+=== 지출 삭제
+
+operation::deleteBill[snippets="path-parameters,http-request,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ }
+]
+----
diff --git a/server/src/docs/asciidoc/billDetail.adoc b/server/src/docs/asciidoc/billDetail.adoc
new file mode 100644
index 000000000..349fb309c
--- /dev/null
+++ b/server/src/docs/asciidoc/billDetail.adoc
@@ -0,0 +1,93 @@
+== 지출 상세
+
+=== 지출 상세 조회
+
+operation::findBillDetails[snippets="path-parameters,http-request,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code": "EVENT_NOT_FOUND",
+ "message": "존재하지 않는 행사입니다."
+ },
+ {
+ "code": "BILL_NOT_FOUND",
+ "message": "존재하지 않는 지출입니다."
+ },
+ {
+ "code": "BILL_DETAIL_NOT_FOUND",
+ "message": "존재하지 않는 참여자 지출입니다."
+ },
+ {
+ "code": "TOKEN_NOT_FOUND",
+ "message": "토큰이 존재하지 않습니다."
+ },
+ {
+ "code": "TOKEN_EXPIRED",
+ "message": "만료된 토큰입니다."
+ },
+ {
+ "code": "TOKEN_INVALID",
+ "message": "유효하지 않은 토큰입니다."
+ },
+ {
+ "code": "FORBIDDEN",
+ "message": "접근할 수 없는 행사입니다."
+ }
+]
+----
+
+=== 지출 상세 수정
+
+operation::updateBillDetails[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code": "REQUEST_EMPTY",
+ "message": "멤버 이름은 공백일 수 없습니다."
+ },
+ {
+ "code": "REQUEST_EMPTY",
+ "message": "지출 금액은 공백일 수 없습니다."
+ },
+ {
+ "code": "EVENT_NOT_FOUND",
+ "message": "존재하지 않는 행사입니다."
+ },
+ {
+ "code": "BILL_NOT_FOUND",
+ "message": "존재하지 않는 지출 입니다."
+ },
+ {
+ "code": "BILL_DETAIL_NOT_FOUND",
+ "message": "존재하지 않는 참여자 지출입니다."
+ },
+ {
+ "code": "BILL_PRICE_NOT_MATCHED",
+ "message": "지출 총액이 일치하지 않습니다."
+ },
+ {
+ "code": "TOKEN_NOT_FOUND",
+ "message": "토큰이 존재하지 않습니다."
+ },
+ {
+ "code": "TOKEN_EXPIRED",
+ "message": "만료된 토큰입니다."
+ },
+ {
+ "code": "TOKEN_INVALID",
+ "message": "유효하지 않은 토큰입니다."
+ },
+ {
+ "code": "FORBIDDEN",
+ "message": "접근할 수 없는 행사입니다."
+ }
+]
+----
diff --git a/server/src/docs/asciidoc/event.adoc b/server/src/docs/asciidoc/event.adoc
new file mode 100644
index 000000000..10b165282
--- /dev/null
+++ b/server/src/docs/asciidoc/event.adoc
@@ -0,0 +1,205 @@
+== 행사
+
+=== 행사 생성
+
+operation::createEvent[snippets="http-request,request-body,request-fields,response-body,response-fields,http-response,response-cookies"]
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"행사 이름은 공백일 수 없습니다."
+ },
+ {
+ "code":"EVENT_NAME_LENGTH_INVALID",
+ "message":"행사 이름은 2자 이상 30자 이하만 입력 가능합니다. 입력한 이름 길이 : 21"
+ },
+ {
+ "code":"EVENT_NAME_MULTIPLE_BLANK",
+ "message":"행사 이름에는 공백 문자가 연속될 수 없습니다. 입력한 이름 : 공백 문자"
+ },
+ {
+ "code":"EVENT_PASSWORD_INVALID",
+ "message":"비밀번호는 4자리 숫자만 가능합니다."
+ }
+]
+----
+
+=== 행사 관리자 로그인
+
+operation::eventLogin[snippets="path-parameters,http-request,request-body,request-fields,http-response,response-cookies"]
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"비밀번호는 공백일 수 없습니다."
+ },
+ {
+ "code":"PASSWORD_INVALID",
+ "message":"비밀번호가 일치하지 않습니다."
+ }
+]
+----
+
+=== 행사 정보 조회
+
+operation::getEvent[snippets="path-parameters,http-request,response-body,response-fields,http-response"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ }
+]
+----
+
+=== 행사 전체 참여자 목록 조회
+
+operation::findAllMembers[snippets="path-parameters,http-request,response-body,response-fields,http-response,response-fields"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ }
+]
+----
+
+=== 행사 전체 지출 이력 조회
+
+operation::findBills[snippets="path-parameters,http-request,http-response,response-fields"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ }
+]
+----
+
+=== 행사 참여자 정보 변경
+
+operation::updateMembers[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"]
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+
+ {
+ "code":"MEMBER_NAME_CHANGE_DUPLICATE",
+ "message":"중복된 참여 인원 이름 변경 요청이 존재합니다."
+ },
+ {
+ "code":"MEMBER_NOT_EXIST",
+ "message":"현재 참여하고 있지 않는 인원이 존재합니다."
+ },
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"멤버 이름은 공백일 수 없습니다."
+ },
+ {
+ "code":"MEMBER_NAME_LENGTH_INVALID",
+ "message":"멤버 이름은 1자 이상 4자 이하만 입력 가능합니다."
+ },
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"MEMBER_NAME_DUPLICATE",
+ "message":"중복된 행사 참여 인원 이름이 존재합니다."
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"만료된 토큰입니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ }
+]
+----
+
+=== 행사 참여자 삭제
+
+operation::deleteMember[snippets="path-parameters,http-request,http-response,request-cookies"]
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ }
+]
+----
+
+=== 행사 어드민 권한 확인
+
+operation::authenticateEvent[snippets="http-request,http-response,request-cookies"]
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code": "EVENT_NOT_FOUND",
+ "message": "존재하지 않는 행사입니다."
+ },
+ {
+ "code": "TOKEN_NOT_FOUND",
+ "message": "토큰이 존재하지 않습니다."
+ },
+ {
+ "code": "TOKEN_EXPIRED",
+ "message": "만료된 토큰입니다."
+ },
+ {
+ "code": "TOKEN_INVALID",
+ "message": "유효하지 않은 토큰입니다."
+ },
+ {
+ "code": "FORBIDDEN",
+ "message": "접근할 수 없는 행사입니다."
+ }
+]
+----
diff --git a/server/src/docs/asciidoc/index.adoc b/server/src/docs/asciidoc/index.adoc
new file mode 100644
index 000000000..4dcc2dc26
--- /dev/null
+++ b/server/src/docs/asciidoc/index.adoc
@@ -0,0 +1,15 @@
+ifndef::snippets[]
+:snippets: ../../build/generated-snippets
+endif::[]
+= 행동대장
+:source-highlighter: highlightjs :hardbreaks:
+:toc: left :doctype: book :icons: font :toc-title: 전체 API 목록 :toclevels: 2 :sectlinks:
+:sectnums:
+:sectnumlevels: 2
+
+
+include::{docdir}/event.adoc[]
+include::{docdir}/memberBillReport.adoc[]
+include::{docdir}/member.adoc[]
+include::{docdir}/bill.adoc[]
+include::{docdir}/billDetail.adoc[]
diff --git a/server/src/docs/asciidoc/member.adoc b/server/src/docs/asciidoc/member.adoc
new file mode 100644
index 000000000..e37a57d57
--- /dev/null
+++ b/server/src/docs/asciidoc/member.adoc
@@ -0,0 +1,152 @@
+== 참여자
+
+=== 행사 참여자 추가(멤버 추가)
+
+operation::saveMembers[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"REQUEST_EMPTY",
+ "message":"멤버 목록은 공백일 수 없습니다."
+ },
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"MEMBER_ALREADY_EXIST",
+ "message":"현재 참여하고 있는 인원이 존재합니다."
+ },
+ {
+ "code":"MEMBER_NAME_DUPLICATE",
+ "message":"중복된 이름이 존재합니다. 입력된 이름: [이상, 이상, 감자, 백호]"
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"만료된 토큰입니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ },
+ {
+ "code":"FORBIDDEN",
+ "message":"접근할 수 없는 행사입니다."
+ }
+]
+----
+
+=== 행사 참여 인원에서 삭제(멤버 삭제)
+
+operation::deleteMember[snippets="path-parameters,http-request,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"만료된 토큰입니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ },
+ {
+ "code":"FORBIDDEN",
+ "message":"접근할 수 없는 행사입니다."
+ }
+]
+----
+
+=== 멤버 정보 수정
+
+operation::updateMembers[snippets="path-parameters,http-request,request-body,request-fields,http-response,request-cookies"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code": "REQUEST_EMPTY",
+ "message": "멤버 ID는 공백일 수 없습니다."
+ },
+ {
+ "code": "REQUEST_EMPTY",
+ "message": "입금 여부는 공백일 수 없습니다."
+ },
+ {
+ "code": "REQUEST_EMPTY",
+ "message": "멤버 이름은 공백일 수 없습니다."
+ },
+ {
+ "code": "MEMBER_NAME_LENGTH_INVALID",
+ "message": "멤버 이름은 1자 이상 4자 이하만 입력 가능합니다."
+ },
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ },
+ {
+ "code": "MEMBER_UPDATE_MISMATCH",
+ "message": "업데이트 요청된 참여자 정보와 기존 행사 참여자 정보가 일치하지 않습니다."
+ },
+ {
+ "code": "MEMBER_NAME_DUPLICATE",
+ "message": "중복된 행사 참여 인원 이름이 존재합니다."
+ },
+ {
+ "code": "MEMBER_NAME_CHANGE_DUPLICATE",
+ "message": "중복된 참여 인원 이름 변경 요청이 존재합니다."
+ },
+ {
+ "code":"TOKEN_NOT_FOUND",
+ "message":"토큰이 존재하지 않습니다."
+ },
+ {
+ "code":"TOKEN_EXPIRED",
+ "message":"만료된 토큰입니다."
+ },
+ {
+ "code":"TOKEN_INVALID",
+ "message":"유효하지 않은 토큰입니다."
+ },
+ {
+ "code":"FORBIDDEN",
+ "message":"접근할 수 없는 행사입니다."
+ }
+]
+----
+
+=== 현재 행사에 참여 중인 (탈주 가능한) 참여자 목록 조회
+
+operation::getCurrentMembers[snippets="path-parameters,http-request,http-response,response-fields"]
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ }
+]
+----
diff --git a/server/src/docs/asciidoc/memberBillReport.adoc b/server/src/docs/asciidoc/memberBillReport.adoc
new file mode 100644
index 000000000..18bd7d3ef
--- /dev/null
+++ b/server/src/docs/asciidoc/memberBillReport.adoc
@@ -0,0 +1,17 @@
+== 정산
+
+=== 참여자별 정산 결과 조회
+
+operation::getMemberBillReports[snippets="path-parameters,http-request,response-body,response-fields,http-response,http-request"]
+
+==== [.red]#Exceptions#
+
+[source,json,options="nowrap"]
+----
+[
+ {
+ "code":"EVENT_NOT_FOUND",
+ "message":"존재하지 않는 행사입니다."
+ }
+]
+----
diff --git a/server/src/main/java/server/haengdong/HaengdongApplication.java b/server/src/main/java/server/haengdong/HaengdongApplication.java
new file mode 100644
index 000000000..4a84c6120
--- /dev/null
+++ b/server/src/main/java/server/haengdong/HaengdongApplication.java
@@ -0,0 +1,15 @@
+package server.haengdong;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@Slf4j
+@SpringBootApplication
+public class HaengdongApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(HaengdongApplication.class, args);
+ }
+
+}
diff --git a/server/src/main/java/server/haengdong/application/AuthService.java b/server/src/main/java/server/haengdong/application/AuthService.java
new file mode 100644
index 000000000..d9352ad53
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/AuthService.java
@@ -0,0 +1,41 @@
+package server.haengdong.application;
+
+
+import java.util.Map;
+import server.haengdong.domain.TokenProvider;
+import server.haengdong.exception.AuthenticationException;
+import server.haengdong.exception.HaengdongErrorCode;
+
+public class AuthService {
+
+ private static final String TOKEN_NAME = "eventToken";
+ private static final String CLAIM_SUB = "sub";
+
+ private final TokenProvider tokenProvider;
+
+ public AuthService(TokenProvider tokenProvider) {
+ this.tokenProvider = tokenProvider;
+ }
+
+ public String createToken(String eventId) {
+ Map payload = Map.of(CLAIM_SUB, eventId);
+
+ return tokenProvider.createToken(payload);
+ }
+
+ public String findEventIdByToken(String token) {
+ validateToken(token);
+ Map payload = tokenProvider.getPayload(token);
+ return (String) payload.get(CLAIM_SUB);
+ }
+
+ private void validateToken(String token) {
+ if (!tokenProvider.validateToken(token)) {
+ throw new AuthenticationException(HaengdongErrorCode.TOKEN_INVALID);
+ }
+ }
+
+ public String getTokenName() {
+ return TOKEN_NAME;
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/BillService.java b/server/src/main/java/server/haengdong/application/BillService.java
new file mode 100644
index 000000000..407914d29
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/BillService.java
@@ -0,0 +1,155 @@
+package server.haengdong.application;
+
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import server.haengdong.application.request.BillAppRequest;
+import server.haengdong.application.request.BillDetailUpdateAppRequest;
+import server.haengdong.application.request.BillDetailsUpdateAppRequest;
+import server.haengdong.application.request.BillUpdateAppRequest;
+import server.haengdong.application.response.BillDetailsAppResponse;
+import server.haengdong.application.response.StepAppResponse;
+import server.haengdong.domain.bill.Bill;
+import server.haengdong.domain.bill.BillDetail;
+import server.haengdong.domain.bill.BillRepository;
+import server.haengdong.domain.event.Event;
+import server.haengdong.domain.event.EventRepository;
+import server.haengdong.domain.member.Member;
+import server.haengdong.domain.member.MemberRepository;
+import server.haengdong.domain.step.Steps;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+@Service
+public class BillService {
+
+ private final BillRepository billRepository;
+ private final EventRepository eventRepository;
+ private final MemberRepository memberRepository;
+
+ @Transactional
+ public void saveBill(String eventToken, BillAppRequest request) {
+ Event event = getEvent(eventToken);
+ List memberIds = request.memberIds();
+ List members = memberIds.stream()
+ .map(this::findMember)
+ .toList();
+
+ Bill bill = request.toBill(event, members);
+ billRepository.save(bill);
+ }
+
+ private Member findMember(Long memberId) {
+ return memberRepository.findById(memberId)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.MEMBER_NOT_FOUND));
+ }
+
+ public List findSteps(String token) {
+ Event event = getEvent(token);
+ List bills = billRepository.findAllByEvent(event);
+
+ return createStepAppResponses(bills);
+ }
+
+ private List createStepAppResponses(List bills) {
+ Steps steps = Steps.of(bills);
+ return steps.getSteps().stream()
+ .map(StepAppResponse::of)
+ .toList();
+ }
+
+ @Transactional
+ public void updateBill(String token, Long billId, BillUpdateAppRequest request) {
+ Bill bill = getBill(billId);
+
+ validateToken(token, bill);
+
+ bill.update(request.title(), request.price());
+ }
+
+ private void validateToken(String token, Bill bill) {
+ Event event = bill.getEvent();
+ if (event.isTokenMismatch(token)) {
+ throw new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND);
+ }
+ }
+
+ @Transactional
+ public void deleteBill(String token, Long billId) {
+ Bill bill = getBill(billId);
+ validateToken(token, bill);
+ billRepository.deleteById(billId);
+ }
+
+ public BillDetailsAppResponse findBillDetails(String token, Long billId) {
+ Bill bill = billRepository.findById(billId)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND));
+ validateToken(token, bill);
+
+ List billDetails = bill.getBillDetails();
+ return BillDetailsAppResponse.of(billDetails);
+ }
+
+ @Transactional
+ public void updateBillDetails(String token, Long billId, BillDetailsUpdateAppRequest request) {
+ Bill bill = billRepository.findById(billId)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND));
+
+ List billDetailUpdateAppRequests = request.billDetailUpdateAppRequests();
+
+ validateToken(token, bill);
+ validateBillDetailSize(billDetailUpdateAppRequests, bill);
+ validateTotalPrice(billDetailUpdateAppRequests, bill);
+
+ List billDetails = bill.getBillDetails();
+
+ for (BillDetailUpdateAppRequest updateRequest : billDetailUpdateAppRequests) {
+ BillDetail detailToUpdate = billDetails.stream()
+ .filter(detail -> detail.isSameId(updateRequest.id()))
+ .findFirst()
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_DETAIL_NOT_FOUND));
+
+ detailToUpdate.updatePrice(updateRequest.price());
+ detailToUpdate.updateIsFixed(updateRequest.isFixed());
+ }
+ }
+
+ private void validateBillDetailSize(List requests, Bill bill) {
+ List ids = requests.stream()
+ .map(BillDetailUpdateAppRequest::id)
+ .distinct()
+ .toList();
+ if (bill.getBillDetails().size() != ids.size()) {
+ throw new HaengdongException(HaengdongErrorCode.BILL_DETAIL_NOT_FOUND);
+ }
+ }
+
+ private void validateTotalPrice(
+ List billDetailUpdateAppRequests,
+ Bill bill
+ ) {
+ Long requestsPriceSum = calculateUpdatePriceSum(billDetailUpdateAppRequests);
+ if (!bill.isSamePrice(requestsPriceSum)) {
+ throw new HaengdongException(HaengdongErrorCode.BILL_PRICE_NOT_MATCHED);
+ }
+ }
+
+ private Long calculateUpdatePriceSum(List billDetailUpdateAppRequests) {
+ return billDetailUpdateAppRequests.stream()
+ .map(BillDetailUpdateAppRequest::price)
+ .reduce(0L, Long::sum);
+ }
+
+ private Event getEvent(String eventToken) {
+ return eventRepository.findByToken(eventToken)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND));
+ }
+
+ private Bill getBill(Long billId) {
+ return billRepository.findById(billId)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BILL_NOT_FOUND));
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/EventService.java b/server/src/main/java/server/haengdong/application/EventService.java
new file mode 100644
index 000000000..af49ebb5a
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/EventService.java
@@ -0,0 +1,95 @@
+package server.haengdong.application;
+
+import java.util.List;
+import java.util.Map.Entry;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import server.haengdong.application.request.EventAppRequest;
+import server.haengdong.application.request.EventLoginAppRequest;
+import server.haengdong.application.request.EventUpdateAppRequest;
+import server.haengdong.application.response.EventAppResponse;
+import server.haengdong.application.response.EventDetailAppResponse;
+import server.haengdong.application.response.MemberBillReportAppResponse;
+import server.haengdong.domain.bill.Bill;
+import server.haengdong.domain.bill.BillRepository;
+import server.haengdong.domain.member.Member;
+import server.haengdong.domain.bill.MemberBillReport;
+import server.haengdong.domain.event.Event;
+import server.haengdong.domain.event.EventRepository;
+import server.haengdong.domain.event.EventTokenProvider;
+import server.haengdong.exception.AuthenticationException;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+@Service
+public class EventService {
+
+ private final EventRepository eventRepository;
+ private final EventTokenProvider eventTokenProvider;
+ private final BillRepository billRepository;
+
+ @Transactional
+ public EventAppResponse saveEvent(EventAppRequest request) {
+ String token = eventTokenProvider.createToken();
+ Event event = request.toEvent(token);
+ eventRepository.save(event);
+
+ return EventAppResponse.of(event);
+ }
+
+ public EventDetailAppResponse findEvent(String token) {
+ Event event = getEvent(token);
+
+ return EventDetailAppResponse.of(event);
+ }
+
+ public void validatePassword(EventLoginAppRequest request) throws HaengdongException {
+ Event event = getEvent(request.token());
+ if (event.isPasswordMismatch(request.password())) {
+ throw new AuthenticationException(HaengdongErrorCode.PASSWORD_INVALID);
+ }
+ }
+
+ private Event getEvent(String token) {
+ return eventRepository.findByToken(token)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND));
+ }
+
+ public List getMemberBillReports(String token) {
+ Event event = eventRepository.findByToken(token)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND));
+ List bills = billRepository.findAllByEvent(event);
+
+ MemberBillReport memberBillReport = MemberBillReport.createByBills(bills);
+
+ return memberBillReport.getReports().entrySet().stream()
+ .map(this::createMemberBillReportResponse)
+ .toList();
+ }
+
+ private MemberBillReportAppResponse createMemberBillReportResponse(Entry entry) {
+ Member member = entry.getKey();
+ Long price = entry.getValue();
+
+ return new MemberBillReportAppResponse(
+ member.getId(),
+ member.getName(),
+ member.isDeposited(),
+ price
+ );
+ }
+
+ @Transactional
+ public void updateEvent(String token, EventUpdateAppRequest request) {
+ Event event = getEvent(token);
+ if (request.isEventNameExist()) {
+ event.rename(request.eventName());
+ }
+ if (request.isAccountExist()) {
+ event.changeAccount(request.bankName(), request.accountNumber());
+ }
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/MemberService.java b/server/src/main/java/server/haengdong/application/MemberService.java
new file mode 100644
index 000000000..840c4f769
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/MemberService.java
@@ -0,0 +1,118 @@
+package server.haengdong.application;
+
+
+import java.util.List;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import server.haengdong.application.request.MemberSaveAppRequest;
+import server.haengdong.application.request.MembersSaveAppRequest;
+import server.haengdong.application.request.MembersUpdateAppRequest;
+import server.haengdong.application.response.MemberAppResponse;
+import server.haengdong.application.response.MembersDepositAppResponse;
+import server.haengdong.application.response.MembersSaveAppResponse;
+import server.haengdong.domain.bill.Bill;
+import server.haengdong.domain.bill.BillRepository;
+import server.haengdong.domain.event.Event;
+import server.haengdong.domain.event.EventRepository;
+import server.haengdong.domain.member.Member;
+import server.haengdong.domain.member.MemberRepository;
+import server.haengdong.domain.member.UpdatedMembers;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+@Service
+public class MemberService {
+
+ private final MemberRepository memberRepository;
+ private final EventRepository eventRepository;
+ private final BillRepository billRepository;
+
+ @Transactional
+ public MembersSaveAppResponse saveMembers(String token, MembersSaveAppRequest request) {
+ Event event = getEvent(token);
+ List memberNames = request.members().stream()
+ .map(MemberSaveAppRequest::name)
+ .toList();
+
+ validateMemberSave(memberNames, event);
+
+ List members = memberNames.stream()
+ .map(name -> new Member(event, name))
+ .toList();
+
+ List savedMembers = memberRepository.saveAll(members);
+ return MembersSaveAppResponse.of(savedMembers);
+ }
+
+ private void validateMemberSave(List memberNames, Event event) {
+ Set uniqueMemberNames = Set.copyOf(memberNames);
+ if (memberNames.size() != uniqueMemberNames.size()) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_DUPLICATE, memberNames);
+ }
+ if (isDuplicatedMemberNames(uniqueMemberNames, event)) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_ALREADY_EXIST);
+ }
+ }
+
+ private boolean isDuplicatedMemberNames(Set uniqueMemberNames, Event event) {
+ return memberRepository.findAllByEvent(event).stream()
+ .anyMatch(member -> uniqueMemberNames.contains(member.getName()));
+ }
+
+ public List getCurrentMembers(String token) {
+ Event event = getEvent(token);
+
+ return billRepository.findFirstByEventOrderByIdDesc(event)
+ .map(Bill::getMembers)
+ .orElseGet(() -> memberRepository.findAllByEvent(event))
+ .stream()
+ .map(MemberAppResponse::of)
+ .toList();
+ }
+
+ public MembersDepositAppResponse findAllMembers(String token) {
+ Event event = getEvent(token);
+
+ List members = memberRepository.findAllByEvent(event);
+
+ return MembersDepositAppResponse.of(members);
+ }
+
+ @Transactional
+ public void updateMembers(String token, MembersUpdateAppRequest request) {
+ Event event = getEvent(token);
+ UpdatedMembers updatedMembers = new UpdatedMembers(request.toMembers(event));
+ List originMembers = memberRepository.findAllByEvent(event);
+
+ updatedMembers.validateUpdatable(originMembers);
+ memberRepository.saveAll(updatedMembers.getMembers());
+ }
+
+ @Transactional
+ public void deleteMember(String token, Long memberId) {
+ memberRepository.findById(memberId)
+ .ifPresent(member -> deleteMember(token, member));
+ }
+
+ private void deleteMember(String token, Member member) {
+ Event event = member.getEvent();
+ if (event.isTokenMismatch(token)) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_NOT_FOUND);
+ }
+
+ billRepository.findAllByEvent(event).stream()
+ .filter(bill -> bill.containMember(member))
+ .forEach(bill -> bill.removeMemberBillDetail(member));
+ billRepository.flush();
+ memberRepository.delete(member);
+ }
+
+ private Event getEvent(String token) {
+ return eventRepository.findByToken(token)
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.EVENT_NOT_FOUND));
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/request/BillAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillAppRequest.java
new file mode 100644
index 000000000..8eb945263
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/BillAppRequest.java
@@ -0,0 +1,17 @@
+package server.haengdong.application.request;
+
+import java.util.List;
+import server.haengdong.domain.bill.Bill;
+import server.haengdong.domain.member.Member;
+import server.haengdong.domain.event.Event;
+
+public record BillAppRequest(
+ String title,
+ Long price,
+ List memberIds
+) {
+
+ public Bill toBill(Event event, List members) {
+ return Bill.create(event, title, price, members);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/request/BillDetailUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillDetailUpdateAppRequest.java
new file mode 100644
index 000000000..4e66b9215
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/BillDetailUpdateAppRequest.java
@@ -0,0 +1,8 @@
+package server.haengdong.application.request;
+
+public record BillDetailUpdateAppRequest(
+ Long id,
+ Long price,
+ boolean isFixed
+) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/BillDetailsUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillDetailsUpdateAppRequest.java
new file mode 100644
index 000000000..7a6477e08
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/BillDetailsUpdateAppRequest.java
@@ -0,0 +1,8 @@
+package server.haengdong.application.request;
+
+import java.util.List;
+
+public record BillDetailsUpdateAppRequest(
+ List billDetailUpdateAppRequests
+) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/BillUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/BillUpdateAppRequest.java
new file mode 100644
index 000000000..aa09f2351
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/BillUpdateAppRequest.java
@@ -0,0 +1,7 @@
+package server.haengdong.application.request;
+
+public record BillUpdateAppRequest(
+ String title,
+ Long price
+) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/EventAppRequest.java b/server/src/main/java/server/haengdong/application/request/EventAppRequest.java
new file mode 100644
index 000000000..20ec16d88
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/EventAppRequest.java
@@ -0,0 +1,10 @@
+package server.haengdong.application.request;
+
+import server.haengdong.domain.event.Event;
+
+public record EventAppRequest(String name, String password) {
+
+ public Event toEvent(String token) {
+ return new Event(name, password, token);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/request/EventLoginAppRequest.java b/server/src/main/java/server/haengdong/application/request/EventLoginAppRequest.java
new file mode 100644
index 000000000..947b5ef39
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/EventLoginAppRequest.java
@@ -0,0 +1,4 @@
+package server.haengdong.application.request;
+
+public record EventLoginAppRequest(String token, String password) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/EventUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/EventUpdateAppRequest.java
new file mode 100644
index 000000000..13bffccc1
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/EventUpdateAppRequest.java
@@ -0,0 +1,17 @@
+package server.haengdong.application.request;
+
+public record EventUpdateAppRequest(
+ String eventName,
+ String bankName,
+ String accountNumber
+) {
+
+ public boolean isEventNameExist() {
+ return eventName != null && !eventName.trim().isEmpty();
+ }
+
+ public boolean isAccountExist() {
+ return bankName != null && !bankName.trim().isEmpty()
+ && accountNumber != null && !accountNumber.trim().isEmpty();
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/request/MemberNameUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberNameUpdateAppRequest.java
new file mode 100644
index 000000000..b36d71cc1
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/MemberNameUpdateAppRequest.java
@@ -0,0 +1,7 @@
+package server.haengdong.application.request;
+
+public record MemberNameUpdateAppRequest(
+ Long id,
+ String name
+) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/MemberNamesUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberNamesUpdateAppRequest.java
new file mode 100644
index 000000000..cd0c00544
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/MemberNamesUpdateAppRequest.java
@@ -0,0 +1,8 @@
+package server.haengdong.application.request;
+
+import java.util.List;
+
+public record MemberNamesUpdateAppRequest(
+ List members
+) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/MemberSaveAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberSaveAppRequest.java
new file mode 100644
index 000000000..45dc234e3
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/MemberSaveAppRequest.java
@@ -0,0 +1,4 @@
+package server.haengdong.application.request;
+
+public record MemberSaveAppRequest(String name) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/MemberUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MemberUpdateAppRequest.java
new file mode 100644
index 000000000..77df0625b
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/MemberUpdateAppRequest.java
@@ -0,0 +1,16 @@
+package server.haengdong.application.request;
+
+
+import server.haengdong.domain.member.Member;
+import server.haengdong.domain.event.Event;
+
+public record MemberUpdateAppRequest(
+ Long id,
+ String name,
+ boolean isDeposited
+) {
+
+ public Member toMember(Event event) {
+ return new Member(id, event, name, isDeposited);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/request/MembersSaveAppRequest.java b/server/src/main/java/server/haengdong/application/request/MembersSaveAppRequest.java
new file mode 100644
index 000000000..ae0c9fc62
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/MembersSaveAppRequest.java
@@ -0,0 +1,8 @@
+package server.haengdong.application.request;
+
+import java.util.List;
+
+public record MembersSaveAppRequest(
+ List members
+) {
+}
diff --git a/server/src/main/java/server/haengdong/application/request/MembersUpdateAppRequest.java b/server/src/main/java/server/haengdong/application/request/MembersUpdateAppRequest.java
new file mode 100644
index 000000000..aa3253504
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/request/MembersUpdateAppRequest.java
@@ -0,0 +1,14 @@
+package server.haengdong.application.request;
+
+import java.util.List;
+import server.haengdong.domain.event.Event;
+import server.haengdong.domain.member.Member;
+
+public record MembersUpdateAppRequest(List members) {
+
+ public List toMembers(Event event) {
+ return members.stream()
+ .map(memberRequest -> memberRequest.toMember(event))
+ .toList();
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/BillAppResponse.java b/server/src/main/java/server/haengdong/application/response/BillAppResponse.java
new file mode 100644
index 000000000..8a5a5b2bc
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/BillAppResponse.java
@@ -0,0 +1,14 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.bill.Bill;
+
+public record BillAppResponse(
+ Long id,
+ String title,
+ Long price,
+ boolean isFixed
+) {
+ public static BillAppResponse of(Bill bill) {
+ return new BillAppResponse(bill.getId(), bill.getTitle(), bill.getPrice(), bill.isFixed());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/BillDetailAppResponse.java b/server/src/main/java/server/haengdong/application/response/BillDetailAppResponse.java
new file mode 100644
index 000000000..6618135cb
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/BillDetailAppResponse.java
@@ -0,0 +1,20 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.bill.BillDetail;
+
+public record BillDetailAppResponse(
+ Long id,
+ String memberName,
+ Long price,
+ boolean isFixed
+) {
+
+ public static BillDetailAppResponse of(BillDetail billDetail) {
+ return new BillDetailAppResponse(
+ billDetail.getId(),
+ billDetail.getMember().getName(),
+ billDetail.getPrice(),
+ billDetail.isFixed()
+ );
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/BillDetailsAppResponse.java b/server/src/main/java/server/haengdong/application/response/BillDetailsAppResponse.java
new file mode 100644
index 000000000..3618e0d08
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/BillDetailsAppResponse.java
@@ -0,0 +1,14 @@
+package server.haengdong.application.response;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import server.haengdong.domain.bill.BillDetail;
+
+public record BillDetailsAppResponse(List billDetails) {
+
+ public static BillDetailsAppResponse of(List billDetails) {
+ return billDetails.stream()
+ .map(BillDetailAppResponse::of)
+ .collect(Collectors.collectingAndThen(Collectors.toList(), BillDetailsAppResponse::new));
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/EventAppResponse.java b/server/src/main/java/server/haengdong/application/response/EventAppResponse.java
new file mode 100644
index 000000000..f331d0011
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/EventAppResponse.java
@@ -0,0 +1,10 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.event.Event;
+
+public record EventAppResponse(String token) {
+
+ public static EventAppResponse of(Event event) {
+ return new EventAppResponse(event.getToken());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/EventDetailAppResponse.java b/server/src/main/java/server/haengdong/application/response/EventDetailAppResponse.java
new file mode 100644
index 000000000..899ea76ec
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/EventDetailAppResponse.java
@@ -0,0 +1,14 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.event.Event;
+
+public record EventDetailAppResponse(
+ String eventName,
+ String bankName,
+ String accountNumber
+) {
+
+ public static EventDetailAppResponse of(Event event) {
+ return new EventDetailAppResponse(event.getName(), event.getBankName(), event.getAccountNumber());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/LastBillMemberAppResponse.java b/server/src/main/java/server/haengdong/application/response/LastBillMemberAppResponse.java
new file mode 100644
index 000000000..abefe009a
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/LastBillMemberAppResponse.java
@@ -0,0 +1,10 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.member.Member;
+
+public record LastBillMemberAppResponse(Long id, String name) {
+
+ public static LastBillMemberAppResponse of(Member member) {
+ return new LastBillMemberAppResponse(member.getId(), member.getName());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java
new file mode 100644
index 000000000..b253ea697
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/MemberAppResponse.java
@@ -0,0 +1,12 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.member.Member;
+
+public record MemberAppResponse(
+ Long id,
+ String name
+) {
+ public static MemberAppResponse of(Member member) {
+ return new MemberAppResponse(member.getId(), member.getName());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/MemberBillReportAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberBillReportAppResponse.java
new file mode 100644
index 000000000..875578fbc
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/MemberBillReportAppResponse.java
@@ -0,0 +1,9 @@
+package server.haengdong.application.response;
+
+public record MemberBillReportAppResponse(
+ Long memberId,
+ String name,
+ boolean isDeposited,
+ Long price
+) {
+}
diff --git a/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java
new file mode 100644
index 000000000..94dd77117
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/MemberDepositAppResponse.java
@@ -0,0 +1,13 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.member.Member;
+
+public record MemberDepositAppResponse(
+ Long id,
+ String name,
+ boolean isDeposited
+) {
+ public static MemberDepositAppResponse of(Member member) {
+ return new MemberDepositAppResponse(member.getId(), member.getName(), member.isDeposited());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java b/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java
new file mode 100644
index 000000000..1d08536ba
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/MemberSaveAppResponse.java
@@ -0,0 +1,12 @@
+package server.haengdong.application.response;
+
+import server.haengdong.domain.member.Member;
+
+public record MemberSaveAppResponse(
+ Long id,
+ String name
+) {
+ public static MemberSaveAppResponse of(Member member) {
+ return new MemberSaveAppResponse(member.getId(), member.getName());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/MembersDepositAppResponse.java b/server/src/main/java/server/haengdong/application/response/MembersDepositAppResponse.java
new file mode 100644
index 000000000..007904d25
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/MembersDepositAppResponse.java
@@ -0,0 +1,15 @@
+package server.haengdong.application.response;
+
+import java.util.List;
+import server.haengdong.domain.member.Member;
+
+public record MembersDepositAppResponse(
+ List members
+) {
+
+ public static MembersDepositAppResponse of(List members) {
+ return new MembersDepositAppResponse(members.stream()
+ .map(MemberDepositAppResponse::of)
+ .toList());
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java b/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java
new file mode 100644
index 000000000..e171f9950
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/MembersSaveAppResponse.java
@@ -0,0 +1,16 @@
+package server.haengdong.application.response;
+
+import java.util.List;
+import server.haengdong.domain.member.Member;
+
+public record MembersSaveAppResponse(
+ List members
+) {
+ public static MembersSaveAppResponse of(List members) {
+ return new MembersSaveAppResponse(
+ members.stream()
+ .map(MemberSaveAppResponse::of)
+ .toList()
+ );
+ }
+}
diff --git a/server/src/main/java/server/haengdong/application/response/StepAppResponse.java b/server/src/main/java/server/haengdong/application/response/StepAppResponse.java
new file mode 100644
index 000000000..2537d1db1
--- /dev/null
+++ b/server/src/main/java/server/haengdong/application/response/StepAppResponse.java
@@ -0,0 +1,21 @@
+package server.haengdong.application.response;
+
+import java.util.List;
+import server.haengdong.domain.step.Step;
+
+public record StepAppResponse(
+ List bills,
+ List members
+) {
+ public static StepAppResponse of(Step step) {
+ List billAppResponses = step.getBills().stream()
+ .map(BillAppResponse::of)
+ .toList();
+
+ List memberAppResponses = step.getMembers().stream()
+ .map(MemberAppResponse::of)
+ .toList();
+
+ return new StepAppResponse(billAppResponses, memberAppResponses);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/config/AdminInterceptor.java b/server/src/main/java/server/haengdong/config/AdminInterceptor.java
new file mode 100644
index 000000000..acfe99789
--- /dev/null
+++ b/server/src/main/java/server/haengdong/config/AdminInterceptor.java
@@ -0,0 +1,56 @@
+package server.haengdong.config;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+import server.haengdong.application.AuthService;
+import server.haengdong.exception.AuthenticationException;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.infrastructure.auth.AuthenticationExtractor;
+
+@Slf4j
+public class AdminInterceptor implements HandlerInterceptor {
+
+ private static final String ADMIN_URI_REGEX = "/api/admin/events/([^/]+)";
+ private static final Pattern ADMIN_URI_PATTERN = Pattern.compile(ADMIN_URI_REGEX);
+ private static final int EVENT_TOKEN_MATCHER_INDEX = 1;
+
+ private final AuthService authService;
+ private final AuthenticationExtractor authenticationExtractor;
+
+ public AdminInterceptor(AuthService authService, AuthenticationExtractor authenticationExtractor) {
+ this.authService = authService;
+ this.authenticationExtractor = authenticationExtractor;
+ }
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ HttpMethod method = HttpMethod.valueOf(request.getMethod());
+ if (HttpMethod.OPTIONS.equals(method)) {
+ return true;
+ }
+ validateToken(request);
+ return true;
+ }
+
+ private void validateToken(HttpServletRequest request) {
+ String token = authenticationExtractor.extract(request, authService.getTokenName());
+ String tokenEventId = authService.findEventIdByToken(token);
+ String uri = request.getRequestURI();
+
+ Matcher matcher = ADMIN_URI_PATTERN.matcher(uri);
+ if (!matcher.find()) {
+ throw new AuthenticationException(HaengdongErrorCode.FORBIDDEN);
+ }
+
+ String eventToken = matcher.group(EVENT_TOKEN_MATCHER_INDEX);
+ if (!tokenEventId.equals(eventToken)) {
+ log.warn("[행사 접근 불가] Cookie EventId = {}, URL EventId = {}", tokenEventId, eventToken);
+ throw new AuthenticationException(HaengdongErrorCode.FORBIDDEN);
+ }
+ }
+}
diff --git a/server/src/main/java/server/haengdong/config/RequestServletFilter.java b/server/src/main/java/server/haengdong/config/RequestServletFilter.java
new file mode 100644
index 000000000..b1afdb6f8
--- /dev/null
+++ b/server/src/main/java/server/haengdong/config/RequestServletFilter.java
@@ -0,0 +1,23 @@
+package server.haengdong.config;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import org.springframework.stereotype.Component;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+
+@Component
+public class RequestServletFilter implements Filter {
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper((HttpServletRequest) request);
+
+ chain.doFilter(wrappedRequest, response);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/config/WebMvcConfig.java b/server/src/main/java/server/haengdong/config/WebMvcConfig.java
new file mode 100644
index 000000000..43f858db3
--- /dev/null
+++ b/server/src/main/java/server/haengdong/config/WebMvcConfig.java
@@ -0,0 +1,65 @@
+package server.haengdong.config;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import server.haengdong.application.AuthService;
+import server.haengdong.domain.TokenProvider;
+import server.haengdong.infrastructure.auth.AuthenticationExtractor;
+import server.haengdong.infrastructure.auth.JwtTokenProvider;
+import server.haengdong.infrastructure.auth.TokenProperties;
+
+@RequiredArgsConstructor
+@EnableConfigurationProperties(TokenProperties.class)
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+ private final TokenProperties tokenProperties;
+
+ @Value("${cors.max-age}")
+ private Long maxAge;
+
+ @Value("${cors.allowed-origins}")
+ private String[] allowedOrigins;
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOrigins(allowedOrigins)
+ .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
+ .allowedHeaders("*")
+ .allowCredentials(true)
+ .maxAge(maxAge);
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(adminInterceptor())
+ .addPathPatterns("/api/admin/**");
+ }
+
+ @Bean
+ public AdminInterceptor adminInterceptor() {
+ return new AdminInterceptor(authService(), authenticationExtractor());
+ }
+
+ @Bean
+ public AuthService authService() {
+ return new AuthService(tokenProvider());
+ }
+
+ @Bean
+ public TokenProvider tokenProvider() {
+ return new JwtTokenProvider(tokenProperties);
+ }
+
+ @Bean
+ public AuthenticationExtractor authenticationExtractor() {
+ return new AuthenticationExtractor();
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/TokenProvider.java b/server/src/main/java/server/haengdong/domain/TokenProvider.java
new file mode 100644
index 000000000..28e7956c3
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/TokenProvider.java
@@ -0,0 +1,12 @@
+package server.haengdong.domain;
+
+import java.util.Map;
+
+public interface TokenProvider {
+
+ String createToken(Map payload);
+
+ Map getPayload(String token);
+
+ boolean validateToken(String token);
+}
diff --git a/server/src/main/java/server/haengdong/domain/bill/Bill.java b/server/src/main/java/server/haengdong/domain/bill/Bill.java
new file mode 100644
index 000000000..6face8274
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/bill/Bill.java
@@ -0,0 +1,158 @@
+package server.haengdong.domain.bill;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import server.haengdong.domain.member.Member;
+import server.haengdong.domain.event.Event;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+public class Bill {
+
+ private static final int MIN_TITLE_LENGTH = 1;
+ private static final int MAX_TITLE_LENGTH = 30;
+ private static final long MIN_PRICE = 1L;
+ private static final long MAX_PRICE = 10_000_000L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @JoinColumn(name = "event_id", nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Event event;
+
+ @Column(nullable = false, length = MAX_TITLE_LENGTH)
+ private String title;
+
+ @Column(nullable = false)
+ private Long price;
+
+ @OneToMany(mappedBy = "bill", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
+ private List billDetails = new ArrayList<>();
+
+ public Bill(Event event, String title, Long price) {
+ validateTitle(title);
+ validatePrice(price);
+ this.event = event;
+ this.title = title.trim();
+ this.price = price;
+ }
+
+ private void validateTitle(String title) {
+ int titleLength = title.trim().length();
+ if (titleLength < MIN_TITLE_LENGTH || titleLength > MAX_TITLE_LENGTH) {
+ throw new HaengdongException(HaengdongErrorCode.BILL_TITLE_INVALID, MIN_TITLE_LENGTH, MAX_TITLE_LENGTH);
+ }
+ }
+
+ private void validatePrice(Long price) {
+ if (price < MIN_PRICE || price > MAX_PRICE) {
+ throw new HaengdongException(HaengdongErrorCode.BILL_PRICE_INVALID, MAX_PRICE);
+ }
+ }
+
+ public static Bill create(Event event, String title, Long price, List members) {
+ Bill bill = new Bill(event, title, price);
+ bill.resetBillDetails(members);
+ return bill;
+ }
+
+ public void resetBillDetails(List members) {
+ this.billDetails.clear();
+ Iterator priceIterator = distributePrice(members.size()).iterator();
+
+ for (Member member : members) {
+ BillDetail billDetail = new BillDetail(this, member, priceIterator.next(), false);
+ this.billDetails.add(billDetail);
+ }
+ }
+
+ private void resetBillDetails() {
+ Iterator priceIterator = distributePrice(billDetails.size()).iterator();
+
+ billDetails.forEach(billDetail -> {
+ billDetail.updatePrice(priceIterator.next());
+ billDetail.updateIsFixed(false);
+ });
+ }
+
+ private List distributePrice(int memberCount) {
+ if (memberCount == 0) {
+ return new ArrayList<>();
+ }
+ long eachPrice = price / memberCount;
+ long remainder = price % memberCount;
+
+ List results = Stream.generate(() -> eachPrice)
+ .limit(memberCount - 1)
+ .collect(Collectors.toList());
+ results.add(eachPrice + remainder);
+ return results;
+ }
+
+ public void removeMemberBillDetail(Member member) {
+ BillDetail foundBillDetail = billDetails.stream()
+ .filter(billDetail -> billDetail.isMember(member))
+ .findFirst()
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.MEMBER_NOT_FOUND));
+
+ billDetails.remove(foundBillDetail);
+ resetBillDetails();
+ }
+
+ public void update(String title, Long price) {
+ validateTitle(title);
+ validatePrice(price);
+ this.title = title.trim();
+ this.price = price;
+ resetBillDetails();
+ }
+
+ public boolean containMember(Member member) {
+ return billDetails.stream()
+ .anyMatch(billDetail -> billDetail.isMember(member));
+ }
+
+ public boolean isSameMembers(Bill other) {
+ Set members = Set.copyOf(this.getMembers());
+ Set otherMembers = Set.copyOf(other.getMembers());
+
+ return members.equals(otherMembers);
+ }
+
+ public boolean isSamePrice(Long price) {
+ return this.price.equals(price);
+ }
+
+ public boolean isFixed() {
+ return billDetails.stream()
+ .anyMatch(BillDetail::isFixed);
+ }
+
+ public List getMembers() {
+ return billDetails.stream()
+ .map(BillDetail::getMember)
+ .toList();
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/bill/BillDetail.java b/server/src/main/java/server/haengdong/domain/bill/BillDetail.java
new file mode 100644
index 000000000..6f3bcf973
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/bill/BillDetail.java
@@ -0,0 +1,61 @@
+package server.haengdong.domain.bill;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import server.haengdong.domain.member.Member;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+public class BillDetail {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @JoinColumn(nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Bill bill;
+
+ @JoinColumn(name = "member_id", nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Member member;
+
+ @Column(nullable = false)
+ private Long price;
+
+ @Column(nullable = false)
+ private boolean isFixed;
+
+ public BillDetail(Bill bill, Member member, Long price, boolean isFixed) {
+ this.bill = bill;
+ this.member = member;
+ this.price = price;
+ this.isFixed = isFixed;
+ }
+
+ public void updatePrice(Long price) {
+ this.price = price;
+ }
+
+ public void updateIsFixed(boolean isFixed) {
+ this.isFixed = isFixed;
+ }
+
+ public boolean isSameId(Long id) {
+ return this.id.equals(id);
+ }
+
+ public boolean isMember(Member member) {
+ return this.member.equals(member);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/bill/BillRepository.java b/server/src/main/java/server/haengdong/domain/bill/BillRepository.java
new file mode 100644
index 000000000..23efe487c
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/bill/BillRepository.java
@@ -0,0 +1,23 @@
+package server.haengdong.domain.bill;
+
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+import server.haengdong.domain.event.Event;
+
+@Repository
+public interface BillRepository extends JpaRepository {
+
+ @Query("""
+ select b
+ from Bill b
+ join fetch b.billDetails bd
+ join fetch bd.member
+ where b.event = :event
+ """)
+ List findAllByEvent(Event event);
+
+ Optional findFirstByEventOrderByIdDesc(Event event);
+}
diff --git a/server/src/main/java/server/haengdong/domain/bill/MemberBillReport.java b/server/src/main/java/server/haengdong/domain/bill/MemberBillReport.java
new file mode 100644
index 000000000..ee16824d0
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/bill/MemberBillReport.java
@@ -0,0 +1,30 @@
+package server.haengdong.domain.bill;
+
+import static java.util.stream.Collectors.toMap;
+
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
+import server.haengdong.domain.member.Member;
+
+@Getter
+public class MemberBillReport {
+
+ private final Map reports;
+
+ private MemberBillReport(Map reports) {
+ this.reports = reports;
+ }
+
+ public static MemberBillReport createByBills(List bills) {
+ Map reports = bills.stream()
+ .flatMap(bill -> bill.getBillDetails().stream())
+ .collect(toMap(
+ BillDetail::getMember,
+ BillDetail::getPrice,
+ Long::sum
+ ));
+
+ return new MemberBillReport(reports);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/event/Bank.java b/server/src/main/java/server/haengdong/domain/event/Bank.java
new file mode 100644
index 000000000..6e449ae7e
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/event/Bank.java
@@ -0,0 +1,56 @@
+package server.haengdong.domain.event;
+
+import java.util.Arrays;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+public enum Bank {
+ WOORI_BANK("우리은행"),
+ JEIL_BANK("제일은행"),
+ SHINHAN_BANK("신한은행"),
+ KB_BANK("KB국민은행"),
+ HANA_BANK("하나은행"),
+ CITI_BANK("시티은행"),
+ IM_BANK("IM뱅크"),
+ BUSAN_BANK("부산은행"),
+ GYEONGNAM_BANK("경남은행"),
+ GWANGJU_BANK("광주은행"),
+ JEONBUK_BANK("전북은행"),
+ JEJU_BANK("제주은행"),
+ IBK_BANK("기업은행"),
+ KDB_BANK("산업은행"),
+ SUHYUP_BANK("수협은행"),
+ NH_BANK("농협은행"),
+ SAEMAUL_BANK("새마을금고"),
+ POST_BANK("우체국은행"),
+ SHINHYEOP_BANK("신협은행"),
+ SBI_SAVINGS_BANK("SBI저축"),
+ KAKAO_BANK("카카오뱅크"),
+ TOSS_BANK("토스뱅크"),
+ K_BANK("케이뱅크"),
+ ;
+
+ private final String name;
+
+ Bank(String name) {
+ this.name = name;
+ }
+
+ public static void isExists(String bankName) {
+ Arrays.stream(Bank.values())
+ .filter(bank -> bank.name.equals(bankName))
+ .findFirst()
+ .orElseThrow(() -> new HaengdongException(HaengdongErrorCode.BANK_NAME_INVALID, getSupportedBanks()));
+ }
+
+ private static String getSupportedBanks() {
+ return Arrays.stream(Bank.values())
+ .map(Bank::getName)
+ .reduce((bank1, bank2) -> bank1 + ", " + bank2)
+ .orElse("지원하는 은행이 없습니다.");
+ }
+
+ public String getName() {
+ return name;
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/event/Event.java b/server/src/main/java/server/haengdong/domain/event/Event.java
new file mode 100644
index 000000000..58d080a43
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/event/Event.java
@@ -0,0 +1,115 @@
+package server.haengdong.domain.event;
+
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import java.util.Arrays;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+public class Event {
+
+ private static final int MIN_NAME_LENGTH = 1;
+ private static final int MAX_NAME_LENGTH = 20;
+ private static final int MIN_ACCOUNT_NUMBER_LENGTH = 8;
+ private static final int MAX_ACCOUNT_NUMBER_LENGTH = 30;
+ private static final String SPACES = " ";
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = MAX_NAME_LENGTH)
+ private String name;
+
+ @Embedded
+ @AttributeOverride(name = "value", column = @Column(name = "password", nullable = false))
+ private Password password;
+
+ @Column(nullable = false, unique = true)
+ private String token;
+
+ @Column(length = MAX_ACCOUNT_NUMBER_LENGTH)
+ private String account;
+
+
+ public Event(String name, String password, String token) {
+ validateName(name);
+ this.name = name;
+ this.password = new Password(password);
+ this.token = token;
+ this.account = "";
+ }
+
+ private void validateName(String name) {
+ int nameLength = name.trim().length();
+ if (nameLength < MIN_NAME_LENGTH || MAX_NAME_LENGTH < nameLength) {
+ throw new HaengdongException(
+ HaengdongErrorCode.EVENT_NAME_LENGTH_INVALID, MIN_NAME_LENGTH, MAX_NAME_LENGTH);
+ }
+ if (isBlankContinuous(name)) {
+ throw new HaengdongException(HaengdongErrorCode.EVENT_NAME_CONSECUTIVE_SPACES);
+ }
+ }
+
+ private boolean isBlankContinuous(String name) {
+ return name.contains(SPACES);
+ }
+
+ public boolean isTokenMismatch(String token) {
+ return !this.token.equals(token);
+ }
+
+ public boolean isPasswordMismatch(String rawPassword) {
+ return !password.matches(rawPassword);
+ }
+
+ public void rename(String name) {
+ validateName(name);
+ this.name = name;
+ }
+
+ public void changeAccount(String bankName, String accountNumber) {
+ validateBankName(bankName);
+ validateAccountNumber(accountNumber);
+ this.account = bankName + " " + accountNumber;
+ }
+
+ private void validateBankName(String bankName) {
+ Bank.isExists(bankName);
+ }
+
+ private void validateAccountNumber(String accountNumber) {
+ int accountLength = accountNumber.trim().length();
+ if (accountLength < MIN_ACCOUNT_NUMBER_LENGTH || MAX_ACCOUNT_NUMBER_LENGTH < accountLength) {
+ throw new HaengdongException(
+ HaengdongErrorCode.ACCOUNT_LENGTH_INVALID, MIN_ACCOUNT_NUMBER_LENGTH, MAX_ACCOUNT_NUMBER_LENGTH);
+ }
+ }
+
+ public String getBankName() {
+ String[] bankNameAndAccountNumber = account.split(" ");
+ if (bankNameAndAccountNumber.length > 0) {
+ return bankNameAndAccountNumber[0];
+ }
+ return "";
+ }
+
+ public String getAccountNumber() {
+ String[] bankNameAndAccountNumber = account.split(" ");
+ if (bankNameAndAccountNumber.length > 1) {
+ return String.join(" ", Arrays.copyOfRange(bankNameAndAccountNumber, 1, bankNameAndAccountNumber.length));
+ }
+ return "";
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/event/EventImage.java b/server/src/main/java/server/haengdong/domain/event/EventImage.java
new file mode 100644
index 000000000..cc5aad0dd
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/event/EventImage.java
@@ -0,0 +1,30 @@
+package server.haengdong.domain.event;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+public class EventImage {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @JoinColumn(name = "event_id", nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Event event;
+
+ @Column(nullable = false)
+ private String url;
+}
diff --git a/server/src/main/java/server/haengdong/domain/event/EventRepository.java b/server/src/main/java/server/haengdong/domain/event/EventRepository.java
new file mode 100644
index 000000000..09526125e
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/event/EventRepository.java
@@ -0,0 +1,11 @@
+package server.haengdong.domain.event;
+
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface EventRepository extends JpaRepository {
+
+ Optional findByToken(String token);
+}
diff --git a/server/src/main/java/server/haengdong/domain/event/EventTokenProvider.java b/server/src/main/java/server/haengdong/domain/event/EventTokenProvider.java
new file mode 100644
index 000000000..6450f0dcf
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/event/EventTokenProvider.java
@@ -0,0 +1,12 @@
+package server.haengdong.domain.event;
+
+import java.util.UUID;
+import org.springframework.stereotype.Component;
+
+@Component
+public class EventTokenProvider {
+
+ public String createToken() {
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/event/Password.java b/server/src/main/java/server/haengdong/domain/event/Password.java
new file mode 100644
index 000000000..7c195b5d5
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/event/Password.java
@@ -0,0 +1,52 @@
+package server.haengdong.domain.event;
+
+import jakarta.persistence.Embeddable;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Embeddable
+public class Password {
+
+ private static final int PASSWORD_LENGTH = 4;
+ private static final Pattern PASSWORD_PATTERN = Pattern.compile(String.format("^\\d{%d}$", PASSWORD_LENGTH));
+ private static final String HASH_ALGORITHM = "SHA-256";
+
+ private String value;
+
+ public Password(String password) {
+ validatePassword(password);
+ this.value = encode(password);
+ }
+
+ private void validatePassword(String password) {
+ Matcher matcher = PASSWORD_PATTERN.matcher(password);
+ if (!matcher.matches()) {
+ throw new HaengdongException(HaengdongErrorCode.EVENT_PASSWORD_FORMAT_INVALID, PASSWORD_LENGTH);
+ }
+ }
+
+ private String encode(String rawPassword) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance(HASH_ALGORITHM);
+ byte[] hashedPassword = digest.digest(rawPassword.getBytes());
+ return Base64.getEncoder().encodeToString(hashedPassword);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalArgumentException("해시 알고리즘이 존재하지 않습니다.");
+ }
+ }
+
+ public boolean matches(String rawPassword) {
+ String hashedPassword = encode(rawPassword);
+ return value.equals(hashedPassword);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/member/Member.java b/server/src/main/java/server/haengdong/domain/member/Member.java
new file mode 100644
index 000000000..25a19ccb0
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/member/Member.java
@@ -0,0 +1,84 @@
+package server.haengdong.domain.member;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import java.util.Objects;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import server.haengdong.domain.event.Event;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"event_id", "name"})})
+@Entity
+public class Member {
+
+ private static final int MIN_NAME_LENGTH = 1;
+ private static final int MAX_NAME_LENGTH = 8;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @JoinColumn(name = "event_id", nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ private Event event;
+
+ @Column(nullable = false, length = MAX_NAME_LENGTH)
+ private String name;
+
+ @Column(nullable = false)
+ private boolean isDeposited;
+
+ public Member(Event event, String name) {
+ this(null, event, name, false);
+ }
+
+ public Member(Long id, Event event, String name, boolean isDeposited) {
+ validateName(name);
+ this.id = id;
+ this.event = event;
+ this.name = name;
+ this.isDeposited = isDeposited;
+ }
+
+ private void validateName(String name) {
+ int nameLength = name.length();
+ if (nameLength < MIN_NAME_LENGTH || nameLength > MAX_NAME_LENGTH) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_LENGTH_INVALID, MIN_NAME_LENGTH,
+ MAX_NAME_LENGTH);
+ }
+ }
+
+ public boolean hasName(String name) {
+ return this.name.equals(name);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Member member = (Member) o;
+ return Objects.equals(id, member.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/member/MemberRepository.java b/server/src/main/java/server/haengdong/domain/member/MemberRepository.java
new file mode 100644
index 000000000..15d57f4ba
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/member/MemberRepository.java
@@ -0,0 +1,10 @@
+package server.haengdong.domain.member;
+
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+import server.haengdong.domain.event.Event;
+
+public interface MemberRepository extends JpaRepository {
+
+ List findAllByEvent(Event event);
+}
diff --git a/server/src/main/java/server/haengdong/domain/member/UpdatedMembers.java b/server/src/main/java/server/haengdong/domain/member/UpdatedMembers.java
new file mode 100644
index 000000000..3a588c077
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/member/UpdatedMembers.java
@@ -0,0 +1,67 @@
+package server.haengdong.domain.member;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+public class UpdatedMembers {
+
+ private final Set members;
+
+ public UpdatedMembers(List members) {
+ validateMemberUnique(members);
+ validateNameUnique(members);
+ this.members = new HashSet<>(members);
+ }
+
+ private void validateMemberUnique(List members) {
+ if (members.size() != Set.copyOf(members).size()) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_CHANGE_DUPLICATE);
+ }
+ }
+
+ private void validateNameUnique(List members) {
+ Set uniqueNames = members.stream()
+ .map(Member::getName)
+ .collect(Collectors.toSet());
+ if (members.size() != uniqueNames.size()) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_CHANGE_DUPLICATE);
+ }
+ }
+
+ public void validateUpdatable(List originMembers) {
+ Set uniqueMembers = Set.copyOf(originMembers);
+ validateUpdatedMembersExist(uniqueMembers);
+ validateUpdatedNamesUnique(uniqueMembers);
+ }
+
+ private void validateUpdatedMembersExist(Set originMembers) {
+ if (!this.members.equals(originMembers)) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_UPDATE_MISMATCH);
+ }
+ }
+
+ private void validateUpdatedNamesUnique(Set originMembers) {
+ boolean duplicated = originMembers.stream()
+ .anyMatch(this::isMemberNameUpdated);
+
+ if (duplicated) {
+ throw new HaengdongException(HaengdongErrorCode.MEMBER_NAME_DUPLICATE);
+ }
+ }
+
+ private boolean isMemberNameUpdated(Member originMembers) {
+ return this.members.stream()
+ .filter(member -> !member.getId().equals(originMembers.getId()))
+ .anyMatch(member -> member.hasName(originMembers.getName()));
+ }
+
+ public List getMembers() {
+ return members.stream().toList();
+ }
+}
+
+
diff --git a/server/src/main/java/server/haengdong/domain/step/Step.java b/server/src/main/java/server/haengdong/domain/step/Step.java
new file mode 100644
index 000000000..834d3e715
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/step/Step.java
@@ -0,0 +1,50 @@
+package server.haengdong.domain.step;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import server.haengdong.domain.bill.Bill;
+import server.haengdong.domain.member.Member;
+import server.haengdong.exception.HaengdongErrorCode;
+import server.haengdong.exception.HaengdongException;
+
+public class Step {
+
+ private final List bills;
+ private final Set members;
+
+ private Step(List bills, Set members) {
+ this.bills = bills;
+ this.members = members;
+ }
+
+ public static Step of(Bill bill) {
+ List bills = new ArrayList<>();
+ bills.add(bill);
+ Set members = new HashSet<>(bill.getMembers());
+
+ return new Step(bills, members);
+ }
+
+ public void add(Bill bill) {
+ if (isNotSameMember(bill)) {
+ throw new HaengdongException(HaengdongErrorCode.DIFFERENT_STEP_MEMBERS);
+ }
+
+ bills.add(bill);
+ }
+
+ public boolean isNotSameMember(Bill bill) {
+ Set otherMembers = Set.copyOf(bill.getMembers());
+ return !members.equals(otherMembers);
+ }
+
+ public List getBills() {
+ return bills;
+ }
+
+ public Set getMembers() {
+ return members;
+ }
+}
diff --git a/server/src/main/java/server/haengdong/domain/step/Steps.java b/server/src/main/java/server/haengdong/domain/step/Steps.java
new file mode 100644
index 000000000..f1ad8c746
--- /dev/null
+++ b/server/src/main/java/server/haengdong/domain/step/Steps.java
@@ -0,0 +1,33 @@
+package server.haengdong.domain.step;
+
+import java.util.ArrayList;
+import java.util.List;
+import server.haengdong.domain.bill.Bill;
+
+public class Steps {
+
+ private final List steps;
+
+ private Steps(List steps) {
+ this.steps = steps;
+ }
+
+ public static Steps of(List bills) {
+ List steps = new ArrayList<>();
+ Step currentStep = null;
+
+ for (Bill bill : bills) {
+ if (currentStep == null || currentStep.isNotSameMember(bill)) {
+ currentStep = Step.of(bill);
+ steps.add(currentStep);
+ continue;
+ }
+ currentStep.add(bill);
+ }
+ return new Steps(steps);
+ }
+
+ public List getSteps() {
+ return steps;
+ }
+}
diff --git a/server/src/main/java/server/haengdong/exception/AuthenticationException.java b/server/src/main/java/server/haengdong/exception/AuthenticationException.java
new file mode 100644
index 000000000..2efcb16e7
--- /dev/null
+++ b/server/src/main/java/server/haengdong/exception/AuthenticationException.java
@@ -0,0 +1,19 @@
+package server.haengdong.exception;
+
+import lombok.Getter;
+
+@Getter
+public class AuthenticationException extends RuntimeException {
+
+ private final HaengdongErrorCode errorCode;
+ private final String message;
+
+ public AuthenticationException(HaengdongErrorCode errorCode) {
+ this(errorCode, errorCode.getMessage());
+ }
+
+ public AuthenticationException(HaengdongErrorCode errorCode, String message) {
+ this.errorCode = errorCode;
+ this.message = message;
+ }
+}
diff --git a/server/src/main/java/server/haengdong/exception/ErrorResponse.java b/server/src/main/java/server/haengdong/exception/ErrorResponse.java
new file mode 100644
index 000000000..3937f4322
--- /dev/null
+++ b/server/src/main/java/server/haengdong/exception/ErrorResponse.java
@@ -0,0 +1,15 @@
+package server.haengdong.exception;
+
+public record ErrorResponse(
+ String errorCode,
+ String message
+) {
+
+ public static ErrorResponse of(HaengdongErrorCode errorCode) {
+ return new ErrorResponse(errorCode.name(), errorCode.getMessage());
+ }
+
+ public static ErrorResponse of(HaengdongErrorCode errorCode, String message) {
+ return new ErrorResponse(errorCode.name(), message);
+ }
+}
diff --git a/server/src/main/java/server/haengdong/exception/GlobalExceptionHandler.java b/server/src/main/java/server/haengdong/exception/GlobalExceptionHandler.java
new file mode 100644
index 000000000..4d2c96c73
--- /dev/null
+++ b/server/src/main/java/server/haengdong/exception/GlobalExceptionHandler.java
@@ -0,0 +1,90 @@
+package server.haengdong.exception;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.util.stream.Collectors;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final String LOG_FORMAT = """
+ \n\t{
+ "RequestURI": "{} {}",
+ "RequestBody": {},
+ "ErrorMessage": "{}"
+ \t}
+ """;
+
+ @ExceptionHandler(AuthenticationException.class)
+ public ResponseEntity authenticationException(HttpServletRequest req, AuthenticationException e) {
+ log.warn(LOG_FORMAT, req.getMethod(), req.getRequestURI(), getRequestBody(req), e.getMessage(), e);
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .body(ErrorResponse.of(e.getErrorCode()));
+ }
+
+ @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+ public ResponseEntity noResourceException(HttpRequestMethodNotSupportedException e) {
+ log.warn(e.getMessage(), e);
+ return ResponseEntity.badRequest()
+ .body(ErrorResponse.of(HaengdongErrorCode.REQUEST_METHOD_NOT_SUPPORTED));
+ }
+
+ @ExceptionHandler(NoResourceFoundException.class)
+ public ResponseEntity noResourceException(NoResourceFoundException e) {
+ log.warn(e.getMessage(), e);
+ return ResponseEntity.badRequest()
+ .body(ErrorResponse.of(HaengdongErrorCode.NO_RESOURCE_REQUEST));
+ }
+
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ public ResponseEntity httpMessageNotReadableException(HttpMessageNotReadableException e) {
+ log.warn(e.getMessage(), e);
+ return ResponseEntity.badRequest()
+ .body(ErrorResponse.of(HaengdongErrorCode.MESSAGE_NOT_READABLE));
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ log.warn(e.getMessage(), e);
+ String errorMessage = e.getFieldErrors().stream()
+ .map(error -> error.getField() + " " + error.getDefaultMessage())
+ .collect(Collectors.joining(", "));
+
+ return ResponseEntity.badRequest()
+ .body(ErrorResponse.of(HaengdongErrorCode.REQUEST_EMPTY, errorMessage));
+ }
+
+ @ExceptionHandler(HaengdongException.class)
+ public ResponseEntity haengdongException(HttpServletRequest req, HaengdongException e) {
+ log.warn(LOG_FORMAT, req.getMethod(), req.getRequestURI(), getRequestBody(req), e.getMessage(), e);
+ return ResponseEntity.badRequest()
+ .body(ErrorResponse.of(e.getErrorCode(), e.getMessage()));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleException(HttpServletRequest req, Exception e) {
+ log.error(LOG_FORMAT, req.getMethod(), req.getRequestURI(), getRequestBody(req), e.getMessage(), e);
+ return ResponseEntity.internalServerError()
+ .body(ErrorResponse.of(HaengdongErrorCode.INTERNAL_SERVER_ERROR));
+ }
+
+ private String getRequestBody(HttpServletRequest req) {
+ try (BufferedReader reader = req.getReader()) {
+ return reader.lines().collect(Collectors.joining(System.lineSeparator() + "\t"));
+ } catch (IOException e) {
+ log.error("Failed to read request body", e);
+ return "";
+ }
+ }
+}
diff --git a/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java
new file mode 100644
index 000000000..a59288f4b
--- /dev/null
+++ b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java
@@ -0,0 +1,58 @@
+package server.haengdong.exception;
+
+import lombok.Getter;
+
+@Getter
+public enum HaengdongErrorCode {
+
+ /* Domain */
+
+ EVENT_NOT_FOUND("존재하지 않는 행사입니다."),
+ EVENT_NAME_LENGTH_INVALID("행사 이름은 %d자 이상 %d자 이하만 입력 가능합니다."),
+ EVENT_NAME_CONSECUTIVE_SPACES("행사 이름에는 공백 문자가 연속될 수 없습니다."),
+ EVENT_PASSWORD_FORMAT_INVALID("비밀번호는 %d자리 숫자만 가능합니다."),
+ BANK_NAME_INVALID("지원하지 않는 은행입니다. 지원하는 은행 목록: %s"),
+ ACCOUNT_LENGTH_INVALID("계좌번호는 %d자 이상 %d자 이하만 입력 가능합니다."),
+
+ MEMBER_NAME_LENGTH_INVALID("참여자 이름은 %d자 이상 %d자 이하만 입력 가능합니다."),
+ MEMBER_NAME_DUPLICATE("행사에 중복된 참여자 이름이 존재합니다."),
+ MEMBER_NOT_FOUND("존재하지 않는 참여자입니다."),
+ MEMBER_ALREADY_EXIST("현재 참여하고 있는 인원이 존재합니다."),
+ MEMBER_NAME_CHANGE_DUPLICATE("중복된 참여 인원 이름 변경 요청이 존재합니다."),
+ MEMBER_UPDATE_MISMATCH("업데이트 요청된 참여자 ID 목록과 기존 행사 참여자 ID 목록이 일치하지 않습니다."),
+
+ BILL_NOT_FOUND("존재하지 않는 지출입니다."),
+ BILL_TITLE_INVALID("앞뒤 공백을 제거한 지출 내역 제목은 %d ~ %d자여야 합니다."),
+ BILL_PRICE_INVALID("지출 금액은 %,d 이하의 자연수여야 합니다."),
+ BILL_DETAIL_NOT_FOUND("존재하지 않는 참여자 지출입니다."),
+ BILL_PRICE_NOT_MATCHED("지출 총액이 일치하지 않습니다."),
+
+ DIFFERENT_STEP_MEMBERS("참여자 목록이 일치하지 않습니다."),
+
+ /* Authentication */
+
+ PASSWORD_INVALID("비밀번호가 일치하지 않습니다."),
+
+ TOKEN_NOT_FOUND("토큰이 존재하지 않습니다."),
+ TOKEN_INVALID("유효하지 않은 토큰입니다."),
+
+ FORBIDDEN("접근할 수 없는 행사입니다."),
+
+ /* Request Validation */
+
+ REQUEST_EMPTY("입력 값은 공백일 수 없습니다.")
+
+ /* System */,
+
+ MESSAGE_NOT_READABLE("읽을 수 없는 요청입니다."),
+ REQUEST_METHOD_NOT_SUPPORTED("지원하지 않는 요청 메서드입니다."),
+ NO_RESOURCE_REQUEST("존재하지 않는 자원입니다."),
+ INTERNAL_SERVER_ERROR("서버 내부에서 에러가 발생했습니다."),
+ ;
+
+ private final String message;
+
+ HaengdongErrorCode(String message) {
+ this.message = message;
+ }
+}
diff --git a/server/src/main/java/server/haengdong/exception/HaengdongException.java b/server/src/main/java/server/haengdong/exception/HaengdongException.java
new file mode 100644
index 000000000..4454fe7bf
--- /dev/null
+++ b/server/src/main/java/server/haengdong/exception/HaengdongException.java
@@ -0,0 +1,19 @@
+package server.haengdong.exception;
+
+import lombok.Getter;
+
+@Getter
+public class HaengdongException extends RuntimeException {
+
+ private final HaengdongErrorCode errorCode;
+
+ public HaengdongException(HaengdongErrorCode errorCode) {
+ super(errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+
+ public HaengdongException(HaengdongErrorCode errorCode, Object... args) {
+ super(String.format(errorCode.getMessage(), args));
+ this.errorCode = errorCode;
+ }
+}
diff --git a/server/src/main/java/server/haengdong/infrastructure/DataSourceConfig.java b/server/src/main/java/server/haengdong/infrastructure/DataSourceConfig.java
new file mode 100644
index 000000000..c859217b4
--- /dev/null
+++ b/server/src/main/java/server/haengdong/infrastructure/DataSourceConfig.java
@@ -0,0 +1,70 @@
+package server.haengdong.infrastructure;
+
+import com.zaxxer.hikari.HikariDataSource;
+import jakarta.persistence.EntityManagerFactory;
+import java.util.HashMap;
+import java.util.Map;
+import javax.sql.DataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.Profile;
+import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
+import org.springframework.orm.jpa.JpaTransactionManager;
+import org.springframework.transaction.PlatformTransactionManager;
+
+@Profile("prod")
+@Configuration
+public class DataSourceConfig {
+
+ private static final String PRIMARY = "primary";
+ private static final String SECONDARY = "secondary";
+
+ @ConfigurationProperties(prefix = "spring.datasource.hikari.primary")
+ @Bean
+ public DataSource primaryDataSource() {
+ return DataSourceBuilder.create().type(HikariDataSource.class).build();
+ }
+
+ @ConfigurationProperties(prefix = "spring.datasource.hikari.secondary")
+ @Bean
+ public DataSource secondaryDataSource() {
+ return DataSourceBuilder.create().type(HikariDataSource.class).build();
+ }
+
+ @DependsOn({"primaryDataSource", "secondaryDataSource"})
+ @Bean
+ public DataSource routingDataSource(
+ @Qualifier("primaryDataSource") DataSource primary,
+ @Qualifier("secondaryDataSource") DataSource secondary) {
+ DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource();
+
+ Map