diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..c2065bc2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,37 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..c35b6693
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM openjdk:17-jdk
+LABEL maintainer="Yosongsong"
+
+ARG JAR_FILE=build/libs/springboot-url-shortener-0.0.1-SNAPSHOT.jar
+ADD ${JAR_FILE} url-shortener-springboot.jar
+
+ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/url-shortener-springboot.jar"]
diff --git a/README.md b/README.md
index a557279f..df00d01c 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,79 @@
-# springboot-url-shortener
-SprintBoot URL Shortener 구현 미션 Repository 입니다.
-
-## 요구사항
-각 요구사항을 모두 충족할 수 있도록 노력해봅시다.
-- [ ] URL 입력폼 제공 및 결과 출력
-- [ ] URL Shortening Key는 8 Character 이내로 생성
-- [ ] 단축된 URL 요청시 원래 URL로 리다이렉트
-- [ ] 단축된 URL에 대한 요청 수 정보저장 (optional)
-- [ ] Shortening Key를 생성하는 알고리즘 2개 이상 제공하며 애플리케이션 실행중 동적으로 변경 가능 (optional)
-
-
-## Short URL Service
-### 읽으면 좋은 레퍼런스
-- [Naver 단축 URL API](https://developers.naver.com/docs/utils/shortenurl/)
-- [짧게 줄인 URL의 실제 URL 확인 원리 및 방법](https://metalkin.tistory.com/50)
-- [짧게 줄인 URL 알고리즘 고찰](https://metalkin.tistory.com/53)
-- [단축 URL 원리 및 개발](https://blog.siyeol.com/26)
-
-### Short URL의 동작 과정
-예시로 bitly를 봅시다
-
-
-1. 원본 URL을 입력하고 Shorten 버튼을 클릭합니다.
-2. Unique Key를 7문자 생성합니다.
-3. Unique Key와 원본 URL을 DB에 저장합니다.
-4. bitly.com/{Unique Key} 로 접근하면, DB를 조회하여 원본 URL로 redirect합니다.
-
-### Short URL의 특징
-단축 URL서비스는 간편하지만, 단점(위험성)이 있습니다.
-링크를 클릭하는 사용자는 단축된 URL만 보고 클릭하기 때문에 어떤 곳으로 이동할지 알 수 없습니다.
-
-- Short URL 서비스는 주로 요청을 Redirect 시킵니다. (Redirect와 Forward의 차이점에 대해 검색해보세요.)
-- 긴 URL을 짧은 URL로 압축할 수 있다.
-- short url만으로는 어디에 연결되어있는 지 알 수 없다. 때문에 피싱 사이트 등의 보안에 취약하다.
-- 광고를 본 뒤에 원본url로 넘겨주기도 한다. 이 과정에서 악성 광고가 나올 수 있다.
-- 당연하지만 이미 존재하는 키를 입력하여 들어오는 사람이 존재할 수 있다.
-- 기존의 원본 URL 변경되었더라도 단축 URL을 유지하여, 혼란을 방지할 수 있다.
-
-### 예시 사이트
-[https://url.kr/](https://url.kr/)
+## 📌 설명
+>시연 동영상입니다. https://vimeo.com/manage/videos/873085666
+
+- URL Shortner 서비스를 구현했습니다.
+- 프론트엔드와 백엔드 모두 구현했습니다.
+- Shortening key 알고리즘은 Base62, Short UUID, Adler Hashing을 사용했습니다.
+- 추가적인 팀 미션으로 배포를 진행했습니다.
+- 개인 미션으로 Redis를 활용한 캐싱을 구현했습니다.
+- 개인 미션으로 Docker를 이용한 배포를 진행했습니다.(도커 네트워크 활용)
+
+~~[Url Shortener 서비스 링크](http://ec2-3-35-240-254.ap-northeast-2.compute.amazonaws.com:3000)~~
+
+## 👩💻 요구 사항과 구현 내용
+
+- [x] URL 입력폼 제공 및 결과 출력
+ > 리액트로 구현했습니다. [(링크)](https://github.com/Dev-Yesung/react-url-shortener)
+- [x] URL Shortening Key는 8 Character 이내로 생성
+ > base62 방식으로 인코딩 완료
+- [x] 단축된 URL 요청시 원래 URL로 리다이렉트
+ > 상태코드 301(MOVE_PERMANENTLY)
+- [x] 단축된 URL에 대한 요청 수 정보저장
+ > MySQL 이외에 Redis를 활용하여 캐싱했습니다.
+- [x] Shortening Key를 생성하는 알고리즘 2개 이상 제공하며 애플리케이션 실행중 동적으로 변경 가능
+ > 추가적으로 8글자 이내의 UUID, Adler 알고리즘을 사용하였습니다.
+
+## 📝 Redis를 활용한 캐싱과 MySQL과의 데이터 일치전략
+
+Redis는 서버가 다운될 것에 대비해 어느 정도 데이터를 백업해두는 기능을 갖고 있습니다.
+하지만 완벽한 백업이 아니기 때문에 Redis에서 사용하는 캐싱 데이터는
+다음의 조건을 만족하는 데이터에 사용하면 좋다고 생각합니다.
+
+1) 캐싱 했을 때의 성능(속도) 향상
+2) 손실되어도 괜찮은 데이터
+
+URL Shortener서버는 Redis를 두 가지 용도로 사용 중 입니다.
+1) 리다이렉션으로 보낼 원본 url을 빠르게 찾기 위해
+2) 인코딩된 url의 총 click 수를 빠르게 저장하고 조회하기 위해
+
+-----
+1️⃣
+인코딩된 shortening key에 매핑되는 원본 URL은 자주 변경되지 않습니다.
+그래서 RDB 저장소까지 가서 읽기를 수행할 필요가 없고 캐시 저장소를 통해
+빠르게 요청을 처리하면 좋을 거 같다고 생각했습니다.
+
+-----
+2️⃣
+클릭 수(API요청 횟수)에 관한 업데이트는 1차적으로 Redis에만 진행되도록 했습니다.
+그 이유는 클릭 수는 손실되어도 타격이 큰 데이터가 아니라는 생각을 했습니다.
+물론 선착순 당첨 이벤트와 같이 특수한 상황에서 클릭수의 경우 정확도가 중요하겠고
+마케팅 데이터로 활용할 클릭수는 어느 정도 의미가 있겠지만,
+현재는 그런 특수한 상황이 아니라 배제했습니다.
+
+-----
+3️⃣
+속도나 동시성을 어느 정도 고려해주는게 좋다는 생각을 해서 Redis를 활용했습니다.
+Redis는 싱글 스레드 방식으로 작동하기 때문에 동시에 여러 스레드가 접근할 경우
+순차적으로 요청을 처리하게 되어 데이터 정합성을 보장하고
+인메모리 데이터베이스라 속도 또한 보장하기 때문입니다.
+
+-----
+4️⃣
+1차적으로 Redis에서 업데이트된 클릭수는
+매일 새벽 3시(트래픽이 가장 적게 몰릴것 같읕 시간)에
+MySQL로 데이터를 업데이트 합니다. 이때 처리하는 방법은
+@Scheduled(스프링 스케줄러)를 사용하였습니다.
+
+-----
+5️⃣
+클릭수에 관한 데이터를 다루는 방법으로,
+Redis 서버가 다운 될 것을 고려해 MySQL에도 클릭수를 저장할까 생각했지만,
+클릭수가 크게 중요한 데이터가 아니고 서비스의 본질은
+긴 URL을 줄이는 것과 빠르게 원본 URL을 찾아주는 거라 생각해 배제했습니다.
+
+-----
+6️⃣
+Redis에 계속해서 캐시 데이터를 두게 되면 메모리 낭비가 심할거라 생각해
+최근에 클릭한 데이터들에는 만료시간을 연장하는 알고리즘을 적용할까 생각했지만,
+구현할 시간이 없어 패스했습니다!
+
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 00000000..9e67466e
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,35 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.1.4'
+ id 'io.spring.dependency-management' version '1.1.3'
+}
+
+group = 'kr.co.programmers'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = '17'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+
+ compileOnly 'org.projectlombok:lombok:1.18.30'
+
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+
+ annotationProcessor 'org.projectlombok:lombok:1.18.30'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..033e24c4
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..9f4197d5
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 00000000..e5ddb406
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/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/subprojects/plugins/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##*/}
+APP_HOME=$(cd "${APP_HOME:-./}" && 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=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=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, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+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/gradlew.bat b/gradlew.bat
new file mode 100644
index 00000000..93e3f59f
--- /dev/null
+++ b/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.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+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/settings.gradle b/settings.gradle
new file mode 100644
index 00000000..6be9ab47
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'springboot-url-shortener'
diff --git a/src/main/java/shortener/SpringbootUrlShortenerApplication.java b/src/main/java/shortener/SpringbootUrlShortenerApplication.java
new file mode 100644
index 00000000..8c21f3d2
--- /dev/null
+++ b/src/main/java/shortener/SpringbootUrlShortenerApplication.java
@@ -0,0 +1,13 @@
+package shortener;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SpringbootUrlShortenerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SpringbootUrlShortenerApplication.class, args);
+ }
+
+}
diff --git a/src/main/java/shortener/application/CacheMigrationScheduler.java b/src/main/java/shortener/application/CacheMigrationScheduler.java
new file mode 100644
index 00000000..9bc0ceca
--- /dev/null
+++ b/src/main/java/shortener/application/CacheMigrationScheduler.java
@@ -0,0 +1,44 @@
+package shortener.application;
+
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.extern.slf4j.Slf4j;
+import shortener.domain.ClicksCacheRepository;
+import shortener.domain.ShortUrl;
+import shortener.infrastructure.ShortUrlJpaRepository;
+
+@Slf4j
+@Transactional
+@Service
+public class CacheMigrationScheduler {
+
+ private final ShortUrlJpaRepository shortUrlRepository;
+ private final ClicksCacheRepository clicksCacheRepository;
+
+ public CacheMigrationScheduler(
+ ShortUrlJpaRepository shortUrlRepository,
+ ClicksCacheRepository clicksCacheRepository
+ ) {
+ this.shortUrlRepository = shortUrlRepository;
+ this.clicksCacheRepository = clicksCacheRepository;
+ }
+
+ @Scheduled(cron = "0 0 3 * * *")
+ public void migrateClicksCacheDataToMasterDatabase() {
+ log.info("Run scheduler to migrate clicks from cache to master database...");
+ log.info("Find all shortUrl from master database...");
+ List savedShortUrls = shortUrlRepository.findAll();
+ log.info("Success to find all shortUrl.");
+ Map clicksForMigration = clicksCacheRepository.findAll(savedShortUrls);
+ clicksForMigration.forEach((id, clicks) -> {
+ log.info("Trying to update id({}) clicks({})...", id, clicks);
+ shortUrlRepository.updateClicks(id, clicks);
+ log.info("Success to update");
+ });
+ }
+}
diff --git a/src/main/java/shortener/application/ShortenerService.java b/src/main/java/shortener/application/ShortenerService.java
new file mode 100644
index 00000000..c0a86fcb
--- /dev/null
+++ b/src/main/java/shortener/application/ShortenerService.java
@@ -0,0 +1,133 @@
+package shortener.application;
+
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.extern.slf4j.Slf4j;
+import shortener.application.dto.response.ClicksResponse;
+import shortener.application.dto.response.ShortUrlCreateResponse;
+import shortener.domain.ClicksCacheRepository;
+import shortener.domain.OriginalUrlCacheRepository;
+import shortener.domain.ShortUrl;
+import shortener.global.error.ErrorCode;
+import shortener.global.error.exception.EntityNotFoundException;
+import shortener.infrastructure.ShortUrlJpaRepository;
+import shortener.domain.urlencoder.UrlEncoder;
+
+@Slf4j
+@Transactional
+@Service
+public class ShortenerService {
+
+ private final ShortUrlJpaRepository shortUrlRepository;
+ private final OriginalUrlCacheRepository originalUrlCacheRepository;
+ private final ClicksCacheRepository clicksCacheRepository;
+
+ public ShortenerService(
+ ShortUrlJpaRepository shortUrlRepository,
+ @Qualifier("originalUrlRedisCacheRepository")
+ OriginalUrlCacheRepository originalUrlCacheRepository,
+ @Qualifier("clicksRedisCacheRepository")
+ ClicksCacheRepository clicksCacheRepository
+ ) {
+ this.shortUrlRepository = shortUrlRepository;
+ this.originalUrlCacheRepository = originalUrlCacheRepository;
+ this.clicksCacheRepository = clicksCacheRepository;
+ }
+
+ public ShortUrlCreateResponse saveNewShortUrl(String originalUrl, int algorithmId) {
+ ShortUrl newShortUrl = saveShortUrlInMaster(originalUrl, algorithmId);
+ saveOriginalUrlAndClicksInCache(newShortUrl);
+
+ return ShortUrlCreateResponse.of(newShortUrl);
+ }
+
+ public String findOriginalUrl(String encodedUrl) {
+ log.info("Trying to find originalUrl from cache...");
+ Optional cachedOriginalUrl = originalUrlCacheRepository.findOriginalUrlByEncodedUrl(encodedUrl);
+ if (cachedOriginalUrl.isPresent()) {
+ String originalUrl = cachedOriginalUrl.get();
+ log.info("Success to find originalUrl({}) in cache.", originalUrl);
+ updateClicksInCache(encodedUrl);
+
+ return originalUrl;
+ }
+ log.warn("Fail to find originalUrl from cache!!!");
+
+ log.info("Switch to master database system");
+ String originalUrl = findOriginalUrlByNoCache(encodedUrl);
+
+ log.info("Trying to save shortUrl(key({}), value({})) in cache...", encodedUrl, originalUrl);
+ ShortUrl shortUrl = shortUrlRepository.findShortUrlByEncodedUrl(encodedUrl)
+ .orElseThrow(() -> new EntityNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL));
+ saveOriginalUrlAndClicksInCache(shortUrl);
+ updateClicksInCache(encodedUrl);
+
+ return originalUrl;
+ }
+
+ public ClicksResponse findClicks(String encodedUrl) {
+ log.info("Trying to find clicks for encodedUrl({}) from cache...", encodedUrl);
+ Optional clicksFromCache = clicksCacheRepository.findClicksByEncodedUrl(encodedUrl);
+ if (clicksFromCache.isPresent()) {
+ Long clicks = clicksFromCache.get();
+ log.info("Success to find clicks(encodedUrl({}), clicks({})) from cache.", encodedUrl, clicks);
+
+ return ClicksResponse.of(encodedUrl, clicks);
+ }
+ log.warn("Fail to find clicks from cache!!!");
+
+ log.info("Switch to master database system");
+ log.info("Trying to find clicks for encodedUrl({}) from master database...", encodedUrl);
+ ShortUrl shortUrl = shortUrlRepository.findShortUrlByEncodedUrl(encodedUrl)
+ .orElseThrow(() -> new EntityNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL));
+ long clicks = shortUrl.getClicks();
+ log.info("Success to find clicks(encodedUrl({}), clicks({})) from master database.", encodedUrl, clicks);
+
+ saveOriginalUrlAndClicksInCache(shortUrl);
+
+ return ClicksResponse.of(encodedUrl, clicks);
+ }
+
+ private ShortUrl saveShortUrlInMaster(String originalUrl, int algorithmId) {
+ log.info("Start creating new shortUrl Entity...");
+ ShortUrl newShortUrl = new ShortUrl(originalUrl);
+ ShortUrl savedShortUrl = shortUrlRepository.save(newShortUrl);
+ log.info("Success to create new Entity(id({}))", savedShortUrl.getId());
+
+ log.info("Start encoding new shortUrl...");
+ String encodedUrl = UrlEncoder.getShortUrl(savedShortUrl, algorithmId);
+
+ log.info("Start saving new shortUrl({}) in master database...", encodedUrl);
+ savedShortUrl.updateEncodedUrl(encodedUrl);
+ log.info("Success to save new shortUrl");
+
+ return savedShortUrl;
+ }
+
+ private void saveOriginalUrlAndClicksInCache(ShortUrl shortUrl) {
+ log.info("Trying to save shortUrl in cache...");
+ originalUrlCacheRepository.save(shortUrl);
+ clicksCacheRepository.save(shortUrl);
+ log.info("Success to save shortUrl in cache.");
+ }
+
+ private String findOriginalUrlByNoCache(String encodedUrl) {
+ log.info("Trying to find originalUrl by encodedUrl({})...", encodedUrl);
+ ShortUrl foundShortUrl = shortUrlRepository.findShortUrlByEncodedUrl(encodedUrl)
+ .orElseThrow(() -> new EntityNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL));
+ String originalUrl = foundShortUrl.getOriginalUrl();
+ log.info("Success to find originalUrl({})", originalUrl);
+
+ return originalUrl;
+ }
+
+ private void updateClicksInCache(String encodedUrl) {
+ log.info("Update clicks in cache...");
+ clicksCacheRepository.updateClicks(encodedUrl);
+ log.info("Success to update clicks in cache.");
+ }
+}
diff --git a/src/main/java/shortener/application/dto/response/ClicksResponse.java b/src/main/java/shortener/application/dto/response/ClicksResponse.java
new file mode 100644
index 00000000..8e0ce980
--- /dev/null
+++ b/src/main/java/shortener/application/dto/response/ClicksResponse.java
@@ -0,0 +1,11 @@
+package shortener.application.dto.response;
+
+public record ClicksResponse(
+ String encodedUrl,
+ Long clicks
+) {
+
+ public static ClicksResponse of(String encodedUrl, Long clicks) {
+ return new ClicksResponse(encodedUrl, clicks);
+ }
+}
diff --git a/src/main/java/shortener/application/dto/response/ShortUrlCreateResponse.java b/src/main/java/shortener/application/dto/response/ShortUrlCreateResponse.java
new file mode 100644
index 00000000..039738a7
--- /dev/null
+++ b/src/main/java/shortener/application/dto/response/ShortUrlCreateResponse.java
@@ -0,0 +1,18 @@
+package shortener.application.dto.response;
+
+import shortener.domain.ShortUrl;
+
+public record ShortUrlCreateResponse(
+ Long id,
+ String shortUrl,
+ String originalUrl
+) {
+
+ public static ShortUrlCreateResponse of(ShortUrl newShortUrl) {
+ Long shortUrlId = newShortUrl.getId();
+ String shortUrl = newShortUrl.getEncodedUrl();
+ String originalUrl = newShortUrl.getOriginalUrl();
+
+ return new ShortUrlCreateResponse(shortUrlId, shortUrl, originalUrl);
+ }
+}
diff --git a/src/main/java/shortener/config/ClicksCacheRedisConfig.java b/src/main/java/shortener/config/ClicksCacheRedisConfig.java
new file mode 100644
index 00000000..c2da708d
--- /dev/null
+++ b/src/main/java/shortener/config/ClicksCacheRedisConfig.java
@@ -0,0 +1,40 @@
+package shortener.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+@EnableRedisRepositories(redisTemplateRef = "redisTemplateForClicks")
+public class ClicksCacheRedisConfig {
+
+ @Value("${spring.data.redis.host}")
+ private String redisHost;
+
+ @Value("${spring.data.redis.port}")
+ private int redisPort;
+
+ @Bean
+ public RedisConnectionFactory clicksCacheRedisConnectionFactory() {
+ LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisHost, redisPort);
+ lettuceConnectionFactory.setDatabase(1);
+
+ return lettuceConnectionFactory;
+ }
+
+ @Bean
+ public RedisTemplate, ?> redisTemplateForClicks() {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(clicksCacheRedisConnectionFactory());
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class));
+
+ return redisTemplate;
+ }
+}
diff --git a/src/main/java/shortener/config/OriginalUrlCacheRedisConfig.java b/src/main/java/shortener/config/OriginalUrlCacheRedisConfig.java
new file mode 100644
index 00000000..092562b1
--- /dev/null
+++ b/src/main/java/shortener/config/OriginalUrlCacheRedisConfig.java
@@ -0,0 +1,39 @@
+package shortener.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+@EnableRedisRepositories(redisTemplateRef = "redisTemplateForOriginalUrl")
+public class OriginalUrlCacheRedisConfig {
+
+ @Value("${spring.data.redis.host}")
+ private String redisHost;
+
+ @Value("${spring.data.redis.port}")
+ private int redisPort;
+
+ @Bean
+ public RedisConnectionFactory originalUrlCacheRedisConnectionFactory() {
+ LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisHost, redisPort);
+ lettuceConnectionFactory.setDatabase(0);
+
+ return lettuceConnectionFactory;
+ }
+
+ @Bean
+ public RedisTemplate, ?> redisTemplateForOriginalUrl() {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(originalUrlCacheRedisConnectionFactory());
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setValueSerializer(new StringRedisSerializer());
+
+ return redisTemplate;
+ }
+}
diff --git a/src/main/java/shortener/config/SchedulerConfig.java b/src/main/java/shortener/config/SchedulerConfig.java
new file mode 100644
index 00000000..feae8001
--- /dev/null
+++ b/src/main/java/shortener/config/SchedulerConfig.java
@@ -0,0 +1,9 @@
+package shortener.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@Configuration
+@EnableScheduling
+public class SchedulerConfig {
+}
diff --git a/src/main/java/shortener/config/WebConfig.java b/src/main/java/shortener/config/WebConfig.java
new file mode 100644
index 00000000..3b0628b2
--- /dev/null
+++ b/src/main/java/shortener/config/WebConfig.java
@@ -0,0 +1,20 @@
+package shortener.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@EnableWebMvc
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOrigins("http://url-shortener-react:3000", "http://localhost:3000",
+ "http://ec2-3-35-240-254.ap-northeast-2.compute.amazonaws.com:3000")
+ .allowedMethods("GET", "POST", "PUT", "DELETE")
+ .allowedHeaders("*");
+ }
+}
diff --git a/src/main/java/shortener/domain/ClicksCacheRepository.java b/src/main/java/shortener/domain/ClicksCacheRepository.java
new file mode 100644
index 00000000..66008301
--- /dev/null
+++ b/src/main/java/shortener/domain/ClicksCacheRepository.java
@@ -0,0 +1,15 @@
+package shortener.domain;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public interface ClicksCacheRepository {
+ ShortUrl save(ShortUrl shortUrl);
+
+ void updateClicks(String encodedUrl);
+
+ Optional findClicksByEncodedUrl(String encodedUrl);
+
+ Map findAll(List shortUrls);
+}
diff --git a/src/main/java/shortener/domain/OriginalUrlCacheRepository.java b/src/main/java/shortener/domain/OriginalUrlCacheRepository.java
new file mode 100644
index 00000000..e9a16011
--- /dev/null
+++ b/src/main/java/shortener/domain/OriginalUrlCacheRepository.java
@@ -0,0 +1,9 @@
+package shortener.domain;
+
+import java.util.Optional;
+
+public interface OriginalUrlCacheRepository {
+ ShortUrl save(ShortUrl shortUrl);
+
+ Optional findOriginalUrlByEncodedUrl(String encodedUrl);
+}
diff --git a/src/main/java/shortener/domain/ShortUrl.java b/src/main/java/shortener/domain/ShortUrl.java
new file mode 100644
index 00000000..e3709bb7
--- /dev/null
+++ b/src/main/java/shortener/domain/ShortUrl.java
@@ -0,0 +1,59 @@
+package shortener.domain;
+
+import org.springframework.data.redis.core.index.Indexed;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.extern.slf4j.Slf4j;
+import shortener.global.error.ErrorCode;
+import shortener.global.error.exception.BusinessException;
+
+@Slf4j
+@Entity
+@Table(name = "short_urls")
+public class ShortUrl {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ Long id;
+ @Indexed
+ @Column(name = "encoded_url", unique = true)
+ String encodedUrl;
+ @Column(name = "original_url", nullable = false, length = 2000)
+ String originalUrl;
+ @Column(name = "clicks", nullable = false)
+ long clicks = 0L;
+
+ protected ShortUrl() {
+ }
+
+ public ShortUrl(String originalUrl) {
+ log.info("Create ShortUrl Entity...");
+ this.originalUrl = originalUrl;
+ }
+
+ public void updateEncodedUrl(String encodedUrl) {
+ log.info("Update shortUrl({}) to Entity(id({}))", encodedUrl, this.id);
+ this.encodedUrl = encodedUrl;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public String getEncodedUrl() {
+ return encodedUrl;
+ }
+
+ public String getOriginalUrl() {
+ return originalUrl;
+ }
+
+ public long getClicks() {
+ return clicks;
+ }
+}
diff --git a/src/main/java/shortener/domain/urlencoder/UrlEncoder.java b/src/main/java/shortener/domain/urlencoder/UrlEncoder.java
new file mode 100644
index 00000000..e2744378
--- /dev/null
+++ b/src/main/java/shortener/domain/urlencoder/UrlEncoder.java
@@ -0,0 +1,32 @@
+package shortener.domain.urlencoder;
+
+import shortener.domain.ShortUrl;
+import shortener.domain.urlencoder.algorithm.AdlerHash;
+import shortener.global.error.ErrorCode;
+import shortener.global.error.exception.BusinessException;
+import shortener.domain.urlencoder.algorithm.AlgorithmType;
+import shortener.domain.urlencoder.algorithm.Base62Hash;
+import shortener.domain.urlencoder.algorithm.ShortUuidHash;
+
+public class UrlEncoder {
+
+ private static final Base62Hash base62Hash = new Base62Hash();
+ private static final ShortUuidHash shortUuidHash = new ShortUuidHash();
+ private static final AdlerHash adlerHash = new AdlerHash();
+
+ public static String getShortUrl(ShortUrl shortUrl, int algorithmId) {
+ if (AlgorithmType.BASE62.getId() == algorithmId) {
+ Long id = shortUrl.getId();
+
+ return base62Hash.encode(id);
+ } else if (AlgorithmType.SHORT_UUID.getId() == algorithmId) {
+ return shortUuidHash.encode();
+ } else if (AlgorithmType.ADLER.getId() == algorithmId) {
+ String originalUrl = shortUrl.getOriginalUrl();
+
+ return adlerHash.encode(originalUrl);
+ } else {
+ throw new BusinessException(ErrorCode.INVALID_ENCODING_ALGORITHM);
+ }
+ }
+}
diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/AdlerHash.java b/src/main/java/shortener/domain/urlencoder/algorithm/AdlerHash.java
new file mode 100644
index 00000000..fe7e76c9
--- /dev/null
+++ b/src/main/java/shortener/domain/urlencoder/algorithm/AdlerHash.java
@@ -0,0 +1,18 @@
+package shortener.domain.urlencoder.algorithm;
+
+public class AdlerHash {
+
+ private static final int MOD_ADLER = 65521;
+ private static int dataSum = 1;
+ private static int rollingSum = 0;
+
+ public String encode(String originalUrl) {
+ for (char urlElement : originalUrl.toCharArray()) {
+ dataSum = (dataSum + urlElement) % MOD_ADLER;
+ rollingSum = (rollingSum + dataSum) % MOD_ADLER;
+ }
+ int key = (rollingSum << 16) | dataSum;
+
+ return Integer.toHexString(key);
+ }
+}
diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/AlgorithmType.java b/src/main/java/shortener/domain/urlencoder/algorithm/AlgorithmType.java
new file mode 100644
index 00000000..0b17a28d
--- /dev/null
+++ b/src/main/java/shortener/domain/urlencoder/algorithm/AlgorithmType.java
@@ -0,0 +1,23 @@
+package shortener.domain.urlencoder.algorithm;
+
+public enum AlgorithmType {
+ BASE62(1, "Base62"),
+ SHORT_UUID(2, "ShortUuid"),
+ ADLER(3, "Adler");
+
+ private final int id;
+ private final String name;
+
+ AlgorithmType(int id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+}
diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/Base62Hash.java b/src/main/java/shortener/domain/urlencoder/algorithm/Base62Hash.java
new file mode 100644
index 00000000..8b627362
--- /dev/null
+++ b/src/main/java/shortener/domain/urlencoder/algorithm/Base62Hash.java
@@ -0,0 +1,30 @@
+package shortener.domain.urlencoder.algorithm;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class Base62Hash {
+
+ private static final char[] base62CryptoWords = {
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
+ 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',
+ 'U', 'V', 'W', 'X', 'Y', 'Z',
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
+ 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
+ 'u', 'v', 'w', 'x', 'y', 'z'
+ };
+
+ public String encode(long index) {
+ StringBuilder encodedUrlBuilder = new StringBuilder();
+ log.info("Start encoding shortUrl by id({})", index);
+ do {
+ int cryptoIndex = (int)(index % 62);
+ encodedUrlBuilder.append(base62CryptoWords[cryptoIndex]);
+ index /= 62;
+ } while (index % 62 > 0);
+ log.info("Success to encode to shortUrl({})", encodedUrlBuilder);
+
+ return encodedUrlBuilder.toString();
+ }
+}
diff --git a/src/main/java/shortener/domain/urlencoder/algorithm/ShortUuidHash.java b/src/main/java/shortener/domain/urlencoder/algorithm/ShortUuidHash.java
new file mode 100644
index 00000000..6b18a940
--- /dev/null
+++ b/src/main/java/shortener/domain/urlencoder/algorithm/ShortUuidHash.java
@@ -0,0 +1,12 @@
+package shortener.domain.urlencoder.algorithm;
+
+import java.util.UUID;
+
+public class ShortUuidHash {
+
+ public String encode() {
+ return UUID.randomUUID()
+ .toString()
+ .substring(0, 7);
+ }
+}
diff --git a/src/main/java/shortener/global/error/ErrorCode.java b/src/main/java/shortener/global/error/ErrorCode.java
new file mode 100644
index 00000000..107e3306
--- /dev/null
+++ b/src/main/java/shortener/global/error/ErrorCode.java
@@ -0,0 +1,24 @@
+package shortener.global.error;
+
+public enum ErrorCode {
+ INVALID_REQUEST_NUMBERS("U001", "API의 총 요청 횟수가 잘 못 되었습니다."),
+ NOT_FOUND_MAPPED_URL("U002", "요청받은 URL을 찾을 수 없습니다."),
+ INVALID_ENCODING_ALGORITHM("U003", "올바르지 않은 인코딩 요청입니다."),
+ INVALID_INPUT_VALUE("U004", "올바르지 않은 입력입니다.");
+
+ private final String code;
+ private final String message;
+
+ ErrorCode(String code, String message) {
+ this.code = code;
+ this.message = message;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/src/main/java/shortener/global/error/ErrorResponse.java b/src/main/java/shortener/global/error/ErrorResponse.java
new file mode 100644
index 00000000..6a35ab92
--- /dev/null
+++ b/src/main/java/shortener/global/error/ErrorResponse.java
@@ -0,0 +1,80 @@
+package shortener.global.error;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.springframework.validation.BindingResult;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+public record ErrorResponse(
+ @JsonSerialize(using = LocalDateTimeSerializer.class)
+ @JsonDeserialize(using = LocalDateTimeDeserializer.class)
+ @JsonFormat(pattern = "yyyy-MM-dd kk:mm:ss")
+ LocalDateTime timestamp,
+ String code,
+ List errors,
+ String message
+) {
+
+ private ErrorResponse(ErrorCode code, List errors) {
+ this(LocalDateTime.now(), code.getCode(), errors, code.getMessage());
+ }
+
+ private ErrorResponse(ErrorCode code) {
+ this(LocalDateTime.now(), code.getCode(), new ArrayList<>(), code.getMessage());
+ }
+
+ public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult) {
+ return new ErrorResponse(errorCode, FieldError.of(bindingResult));
+ }
+
+ public static ErrorResponse of(ErrorCode errorCode) {
+ return new ErrorResponse(errorCode);
+ }
+
+ public static ErrorResponse of(final ErrorCode code, final List errors) {
+ return new ErrorResponse(code, errors);
+ }
+
+ @Getter
+ @NoArgsConstructor(access = AccessLevel.PROTECTED)
+ public static class FieldError {
+
+ private String field;
+ private String value;
+ private String reason;
+
+ private FieldError(final String field, final String value, final String reason) {
+ this.field = field;
+ this.value = value;
+ this.reason = reason;
+ }
+
+ public static List of(final String field, final String value, final String reason) {
+ List fieldErrors = new ArrayList<>();
+ fieldErrors.add(new FieldError(field, value, reason));
+ return fieldErrors;
+ }
+
+ private static List of(final BindingResult bindingResult) {
+ final List fieldErrors = bindingResult.getFieldErrors();
+ return fieldErrors.stream()
+ .map(error -> new FieldError(
+ error.getField(),
+ error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(),
+ error.getDefaultMessage()))
+ .collect(Collectors.toList());
+ }
+ }
+}
diff --git a/src/main/java/shortener/global/error/GlobalExceptionHandler.java b/src/main/java/shortener/global/error/GlobalExceptionHandler.java
new file mode 100644
index 00000000..7a165198
--- /dev/null
+++ b/src/main/java/shortener/global/error/GlobalExceptionHandler.java
@@ -0,0 +1,35 @@
+package shortener.global.error;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+
+import shortener.global.error.exception.BusinessException;
+import shortener.global.error.exception.EntityNotFoundException;
+
+@ControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(EntityNotFoundException.class)
+ public ResponseEntity handleEntityNotFoundException(EntityNotFoundException e) {
+ ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode());
+
+ return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
+ }
+
+ @ExceptionHandler(BusinessException.class)
+ public ResponseEntity handleBusinessException(BusinessException e) {
+ ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode());
+
+ return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
+
+ return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
+ }
+}
diff --git a/src/main/java/shortener/global/error/exception/BusinessException.java b/src/main/java/shortener/global/error/exception/BusinessException.java
new file mode 100644
index 00000000..cfce3f53
--- /dev/null
+++ b/src/main/java/shortener/global/error/exception/BusinessException.java
@@ -0,0 +1,15 @@
+package shortener.global.error.exception;
+
+import shortener.global.error.ErrorCode;
+
+public class BusinessException extends RuntimeException {
+ ErrorCode errorCode;
+
+ public BusinessException(ErrorCode errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public ErrorCode getErrorCode() {
+ return errorCode;
+ }
+}
diff --git a/src/main/java/shortener/global/error/exception/CacheNotFoundException.java b/src/main/java/shortener/global/error/exception/CacheNotFoundException.java
new file mode 100644
index 00000000..ef54ad09
--- /dev/null
+++ b/src/main/java/shortener/global/error/exception/CacheNotFoundException.java
@@ -0,0 +1,16 @@
+package shortener.global.error.exception;
+
+import shortener.global.error.ErrorCode;
+
+public class CacheNotFoundException extends RuntimeException {
+
+ ErrorCode errorCode;
+
+ public CacheNotFoundException(ErrorCode errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public ErrorCode getErrorCode() {
+ return errorCode;
+ }
+}
diff --git a/src/main/java/shortener/global/error/exception/EntityNotFoundException.java b/src/main/java/shortener/global/error/exception/EntityNotFoundException.java
new file mode 100644
index 00000000..311bac45
--- /dev/null
+++ b/src/main/java/shortener/global/error/exception/EntityNotFoundException.java
@@ -0,0 +1,15 @@
+package shortener.global.error.exception;
+
+import shortener.global.error.ErrorCode;
+
+public class EntityNotFoundException extends RuntimeException {
+ ErrorCode errorCode;
+
+ public EntityNotFoundException(ErrorCode errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public ErrorCode getErrorCode() {
+ return errorCode;
+ }
+}
diff --git a/src/main/java/shortener/infrastructure/ClicksRedisCacheRepository.java b/src/main/java/shortener/infrastructure/ClicksRedisCacheRepository.java
new file mode 100644
index 00000000..76838fc3
--- /dev/null
+++ b/src/main/java/shortener/infrastructure/ClicksRedisCacheRepository.java
@@ -0,0 +1,81 @@
+package shortener.infrastructure;
+
+import java.text.MessageFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.stereotype.Repository;
+
+import lombok.extern.slf4j.Slf4j;
+import shortener.domain.ClicksCacheRepository;
+import shortener.domain.ShortUrl;
+import shortener.global.error.ErrorCode;
+import shortener.global.error.exception.CacheNotFoundException;
+
+@Slf4j
+@Repository
+public class ClicksRedisCacheRepository implements ClicksCacheRepository {
+
+ private final ValueOperations valueOperations;
+
+ public ClicksRedisCacheRepository(RedisTemplate redisTemplateForClicks) {
+ this.valueOperations = redisTemplateForClicks.opsForValue();
+ }
+
+ @Override
+ public ShortUrl save(ShortUrl shortUrl) {
+ String encodedUrl = shortUrl.getEncodedUrl();
+ long clicks = shortUrl.getClicks();
+
+ log.info("Trying to save clicks(key({}), value({}) into cache repository...", encodedUrl, clicks);
+ valueOperations.set(encodedUrl, clicks);
+ log.info("Success to save into cache.");
+
+ return shortUrl;
+ }
+
+ @Override
+ public void updateClicks(String encodedUrl) {
+ log.info("Trying to find clicks in cache...");
+ Long clicks = Optional.ofNullable(valueOperations.get(encodedUrl))
+ .orElseThrow(() -> new CacheNotFoundException(ErrorCode.NOT_FOUND_MAPPED_URL));
+ log.info("Success to find clicks(value({})) from cache.", clicks);
+
+ log.info("Trying to update clicks({}) in cache...", clicks);
+ valueOperations.set(encodedUrl, clicks + 1);
+ log.info("Success to update clicks(value({})) in cache.", clicks + 1);
+ }
+
+ @Override
+ public Optional findClicksByEncodedUrl(String encodedUrl) {
+ log.info("Get clicks from cache...");
+ Long clicks = valueOperations.get(encodedUrl);
+
+ return Optional.ofNullable(clicks);
+ }
+
+ @Override
+ public Map findAll(List shortUrls) {
+ log.info("Trying to map shortUrl id to clicks from cache...");
+ return shortUrls.stream()
+ .collect(Collectors.toConcurrentMap(shortUrl -> {
+ Long id = shortUrl.getId();
+ log.info("key(id): {}", id);
+
+ return id;
+ }, shortUrl -> {
+ String encodedUrl = shortUrl.getEncodedUrl();
+ Long clicks = Optional.ofNullable(valueOperations.get(encodedUrl))
+ .orElseThrow(() -> new RuntimeException(
+ MessageFormat.format("Can not find clicks for key({0})", encodedUrl)
+ ));
+ log.info("value(clicks): {}", clicks);
+
+ return clicks;
+ }));
+ }
+}
diff --git a/src/main/java/shortener/infrastructure/OriginalUrlRedisCacheRepository.java b/src/main/java/shortener/infrastructure/OriginalUrlRedisCacheRepository.java
new file mode 100644
index 00000000..b965daab
--- /dev/null
+++ b/src/main/java/shortener/infrastructure/OriginalUrlRedisCacheRepository.java
@@ -0,0 +1,42 @@
+package shortener.infrastructure;
+
+import java.util.Optional;
+
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.stereotype.Repository;
+
+import lombok.extern.slf4j.Slf4j;
+import shortener.domain.OriginalUrlCacheRepository;
+import shortener.domain.ShortUrl;
+
+@Slf4j
+@Repository
+public class OriginalUrlRedisCacheRepository implements OriginalUrlCacheRepository {
+
+ private final ValueOperations valueOperations;
+
+ public OriginalUrlRedisCacheRepository(RedisTemplate redisTemplateForOriginalUrl) {
+ this.valueOperations = redisTemplateForOriginalUrl.opsForValue();
+ }
+
+ @Override
+ public ShortUrl save(ShortUrl shortUrl) {
+ String encodedUrl = shortUrl.getEncodedUrl();
+ String originalUrl = shortUrl.getOriginalUrl();
+
+ log.info("Trying to save originalUrl(key({}), value({}) into cache...", encodedUrl, originalUrl);
+ valueOperations.set(encodedUrl, originalUrl);
+ log.info("Success to save into cache.");
+
+ return shortUrl;
+ }
+
+ @Override
+ public Optional findOriginalUrlByEncodedUrl(String encodedUrl) {
+ log.info("Get original url from cache...");
+ String originalUrl = valueOperations.get(encodedUrl);
+
+ return Optional.ofNullable(originalUrl);
+ }
+}
diff --git a/src/main/java/shortener/infrastructure/ShortUrlJpaRepository.java b/src/main/java/shortener/infrastructure/ShortUrlJpaRepository.java
new file mode 100644
index 00000000..85063436
--- /dev/null
+++ b/src/main/java/shortener/infrastructure/ShortUrlJpaRepository.java
@@ -0,0 +1,19 @@
+package shortener.infrastructure;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import shortener.domain.ShortUrl;
+
+public interface ShortUrlJpaRepository extends JpaRepository {
+
+ Optional findShortUrlByEncodedUrl(String encodedUrl);
+
+ @Modifying
+ @Query("UPDATE ShortUrl su SET su.clicks = :clicks WHERE su.id = :id")
+ void updateClicks(@Param("id") Long id, @Param("clicks") Long clicks);
+}
diff --git a/src/main/java/shortener/presentation/ShortenerController.java b/src/main/java/shortener/presentation/ShortenerController.java
new file mode 100644
index 00000000..46cae68d
--- /dev/null
+++ b/src/main/java/shortener/presentation/ShortenerController.java
@@ -0,0 +1,91 @@
+package shortener.presentation;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import lombok.extern.slf4j.Slf4j;
+import shortener.application.ShortenerService;
+import shortener.application.dto.response.ClicksResponse;
+import shortener.application.dto.response.ShortUrlCreateResponse;
+
+@Slf4j
+@RestController
+public class ShortenerController {
+
+ private final ShortenerService shortenerService;
+
+ public ShortenerController(ShortenerService shortenerService) {
+ this.shortenerService = shortenerService;
+ }
+
+ @PostMapping("/v1/util/short-url/{algorithmId}")
+ public ResponseEntity createShortUrl(
+ String originalUrl,
+ @PathVariable int algorithmId
+ ) {
+ log.info("Receive request to create originalUrl({}) to shortURL...", originalUrl);
+ ShortUrlCreateResponse response = shortenerService.saveNewShortUrl(originalUrl, algorithmId);
+ log.info("Success to create shortURL : {}", response.shortUrl());
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(response);
+ }
+
+ @GetMapping("/{encodedUrl}")
+ public ResponseEntity getOriginalUrl(@PathVariable String encodedUrl) {
+ String originalUrl = shortenerService.findOriginalUrl(encodedUrl);
+ HttpHeaders headers = createRedirectionHeader(originalUrl);
+
+ return ResponseEntity
+ .status(HttpStatus.MOVED_PERMANENTLY)
+ .headers(headers)
+ .build();
+ }
+
+ @GetMapping("/{encodedUrl}/clicks")
+ public ResponseEntity getClicks(@PathVariable String encodedUrl) {
+ log.info("Receive request to get clicks for encodedUrl({})...", encodedUrl);
+ ClicksResponse response = shortenerService.findClicks(encodedUrl);
+ log.info("Success to get clicks({}) for encodedUrl({})",
+ response.clicks(), response.encodedUrl());
+
+ return ResponseEntity.ok(response);
+ }
+
+ private HttpHeaders createRedirectionHeader(String originalUrl) {
+ String httpAppendedOriginalUrl = appendHttpToUrlIfAbsent(originalUrl);
+
+ log.info("Create redirection Http headers...");
+ HttpHeaders headers = new HttpHeaders();
+ headers.add(HttpHeaders.HOST, "http://yosongsong.shortener.co.kr");
+ headers.add(HttpHeaders.SERVER, "Tomcat");
+ headers.add(HttpHeaders.LOCATION, httpAppendedOriginalUrl);
+ headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0");
+ log.info("Success to create headers");
+
+ return headers;
+ }
+
+ private String appendHttpToUrlIfAbsent(String originalUrl) {
+ log.info("Trying to append \"http://\" to url if absent...");
+ boolean hasHttp = originalUrl.startsWith("http://");
+ boolean hasHttps = originalUrl.startsWith("https://");
+
+ if (hasHttp || hasHttps) {
+ log.info("Already exist \"http://\" or \"https://\" => {}", originalUrl);
+
+ return originalUrl;
+ }
+
+ String httpAppended = "http://" + originalUrl;
+ log.info("append \"http://\" => {}", httpAppended);
+
+ return httpAppended;
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 00000000..3296a2ec
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,31 @@
+server:
+ port: 8081
+
+spring:
+
+ datasource:
+ url: jdbc:mysql://url-shortener-mysql:3306/url_shortener
+ username: root
+ password: 12345
+ driver-class-name: com.mysql.cj.jdbc.Driver
+
+ h2:
+ console:
+ enabled: true
+
+ jpa:
+ open-in-view: true
+ hibernate:
+ ddl-auto: create
+ naming:
+ implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
+ show-sql: false
+ properties:
+ hibernate:
+ format_sql: true
+ dialect: org.hibernate.dialect.MySQL8Dialect
+
+ data:
+ redis:
+ host: url-shortener-redis
+ port: 6379