refactor : 전체 디렉토리 구조 변경
170
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/.gitignore
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/intellij,java,windows
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=intellij,java,windows
|
||||
|
||||
### 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
### 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*
|
||||
|
||||
### 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
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/intellij,java,windows
|
||||
.idea
|
||||
.gradle/
|
||||
.idea/.gitignore
|
||||
.idea/compiler.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/misc.xml
|
||||
.idea/vcs.xml
|
||||
|
||||
application-oauth.properties
|
||||
|
||||
!gradle/wrapper
|
||||
57
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/.travis.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
language: java
|
||||
|
||||
jdk:
|
||||
- openjdk11
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
# Travis CI 서버의 Home
|
||||
cache:
|
||||
directories:
|
||||
- '$HOME/.m2/repository'
|
||||
- '$HOME/.gradle'
|
||||
|
||||
# Permission Denied 오류 해결을 위해 추가
|
||||
before_install:
|
||||
- chmod +x gradlew
|
||||
|
||||
script: "./gradlew clean build"
|
||||
|
||||
before_deploy:
|
||||
- mkdir -p before-deploy # zip에 포함시킬 파일들을 담을 디렉토리 설정
|
||||
- cp scripts/*.sh before-deploy/
|
||||
- cp appspec.yml before-deploy/
|
||||
- cp build/libs/*.jar before-deploy/
|
||||
- cd before-deploy && zip -r before-deploy * # before-deploy로 이동 후 전체 압축
|
||||
- cd ../ && mkdir -p deploy # 상위 디렉토리로 이동 후 deploy 디렉토리 생성
|
||||
- mv before-deploy/before-deploy.zip deploy/sprinbboot2-webservice.zip # deploy로 zip파일 이동
|
||||
|
||||
deploy:
|
||||
- provider: s3
|
||||
access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
|
||||
secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
|
||||
|
||||
bucket: banjjoknim-springboot2-webservice-build # S3 버킷
|
||||
region: ap-northeast-2
|
||||
skip_cleanup: true
|
||||
acl: private # zip 파일 접근을 private로
|
||||
local_dir: deploy # before_deploy에서 생성한 디렉토리
|
||||
wait-until-deployed: true
|
||||
|
||||
- provider: codedeploy
|
||||
access_key_id: $AWS_ACCESS_KEY
|
||||
secret_access_key: $AWS_SECRET_KEY
|
||||
bucket: banjjoknim-springboot2-webservice-build
|
||||
key: springboot2-webservice.zip # 빌드 파일을 압축해서 전달
|
||||
bundle_type: zip # 압축 확장자
|
||||
application: springboot2-webservice # 웹 콘솔에서 등록한 CodeDeploy 애플리케이션
|
||||
deployment_group: springboot2-webservice-group # 웹 콘솔에서 등록한 CodeDeploy 배포 그룹
|
||||
region: ap-northeast-2
|
||||
wait-until-deployed: true
|
||||
|
||||
# 실행 완료 시 메일로 알람
|
||||
notifications:
|
||||
email:
|
||||
- recipients: qowoghd@gmail.com
|
||||
26
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/appspec.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
version: 0.0
|
||||
os: linux
|
||||
files:
|
||||
- source: /
|
||||
destination: /home/ec2-user/app/step3/zip/
|
||||
overwrite: yes
|
||||
|
||||
permissions:
|
||||
- object: /
|
||||
pattern: "**"
|
||||
owner: ec2-user
|
||||
group: ec2-user
|
||||
|
||||
hooks:
|
||||
AfterInstall:
|
||||
- location: stop.sh # 엔진엑스와 연결되어 있지 않은 스프링 부트를 종료합니다.
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
ApplicationStart:
|
||||
- location: start.sh # 엔진엑스와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작합니다.
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
ValidateService:
|
||||
- location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인합니다.
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
39
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/build.gradle
Normal file
@@ -0,0 +1,39 @@
|
||||
buildscript{
|
||||
ext{
|
||||
springBootVersion = '2.1.7.RELEASE'
|
||||
}
|
||||
repositories {
|
||||
mavenCentral()
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'eclipse'
|
||||
apply plugin: 'org.springframework.boot'
|
||||
apply plugin: 'io.spring.dependency-management'
|
||||
|
||||
group 'com.banjjoknim.book'
|
||||
version '1.0.1-SNAPSHOT-' + new Date().format("yyyyMMddHHmmss")
|
||||
sourceCompatibility = 1.11
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile('org.springframework.boot:spring-boot-starter-web')
|
||||
compile('org.projectlombok:lombok')
|
||||
annotationProcessor('org.projectlombok:lombok')
|
||||
compile('org.springframework.boot:spring-boot-starter-data-jpa')
|
||||
compile('com.h2database:h2')
|
||||
testCompile('org.springframework.boot:spring-boot-starter-test')
|
||||
compile('org.springframework.boot:spring-boot-starter-mustache')
|
||||
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
|
||||
compile('org.springframework.session:spring-session-jdbc')
|
||||
testCompile('org.springframework.security:spring-security-test')
|
||||
compile('org.mariadb.jdbc:mariadb-java-client')
|
||||
}
|
||||
BIN
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
5
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
185
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/gradlew
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or 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 UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# 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"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
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"
|
||||
which java >/dev/null 2>&1 || 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
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
104
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/gradlew.bat
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
@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=.
|
||||
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%" == "0" goto init
|
||||
|
||||
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 init
|
||||
|
||||
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
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
: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 %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="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!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
37
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/scripts/deploy.sh
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
REPOSITORY=/home/ec2-user/app/step2
|
||||
PROJECT_NAME=springboot2-webservice
|
||||
|
||||
echo "> Build 파일 복사"
|
||||
|
||||
cp $REPOSITORY/zip/*.jar $REPOSITORY/
|
||||
|
||||
echo "> 현재 구동 중인 애플리케이션 pid 확인"
|
||||
|
||||
CURRENT_PID=$(pgrep -fl $PROJECT_NAME | grep jar | awk '{print $1}')
|
||||
|
||||
echo "현재 구동 중인 애플리케이션 pid : $CURRENT_PID"
|
||||
|
||||
if [ -z "$CURRENT_PID"]; then
|
||||
echo "> 현재 구동 중인 애플리케이션이 없으므로 종료하지 않습니다."
|
||||
else
|
||||
echo "> kill -15 $CURRENT_PID"
|
||||
kill -15 $CURRENT_PID
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
echo "> 새 애플리케이션 배포"
|
||||
|
||||
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)
|
||||
|
||||
echo "> JAR_NAME 에 실행권한 추가"
|
||||
|
||||
chmod +x $JAR_NAME
|
||||
|
||||
echo "> $JAR_NAME 실행"
|
||||
|
||||
nohup java -jar \
|
||||
-Dspring.config.location=classpath:/application.properties,classpath:/application-real.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties \
|
||||
-Dspring.profiles.active=real \
|
||||
$JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
|
||||
39
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/scripts/health.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH)
|
||||
source ${ABSDIR}/profile.sh
|
||||
source ${ABSDIR}/switch.sh
|
||||
|
||||
IDLE_PORT=$(find_idle_port)
|
||||
|
||||
echo "> Health Check Start!"
|
||||
echo "> IDLE_PORT: $IDLE_PORT"
|
||||
echo "> curl -s http://localhost:$IDLE_PORT/profile "
|
||||
sleep 10
|
||||
|
||||
for RETRY_COUNT in {1..10}
|
||||
do
|
||||
RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
|
||||
UP_COUNT=$(echo ${RESPONSE} | grep 'real' | wc -l)
|
||||
|
||||
if [ ${UP_COUNT} -ge 1 ]
|
||||
then # UP_COUNT >= 1 ("real" 문자열이 있는지 검증)
|
||||
echo "> Health Check 성공"
|
||||
switch_proxy
|
||||
break
|
||||
else
|
||||
echo "> Health Check의 응답을 알 수 없거나 혹은 실행 상태가 아닙니다."
|
||||
echo "> Health Check: ${RESPONSE}"
|
||||
fi
|
||||
|
||||
if [ ${RETRY_COUNT} -eq 10 ]
|
||||
then
|
||||
echo "> Health Check 실패. "
|
||||
echo "> 엔진엑스에 연결하지 않고 배포를 종료합니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "> Health Check 연결 실패. 재시도...."
|
||||
sleep 10
|
||||
done
|
||||
36
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/scripts/profile.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 쉬고 있는 profile 찾기: real1이 사용 중이면 real2가 쉬고 있고, 반대면 real1이 쉬고 있음
|
||||
|
||||
function find_idle_profile() {
|
||||
RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)
|
||||
|
||||
if [ ${RESPONSE_CODE} -ge 400 ] # 400 보다 크면(즉, 40x/50x 에러 모두 포함)
|
||||
then
|
||||
CURRENT_PROFILE=real2
|
||||
else
|
||||
CURRENT_PROFILE=$(curl -s http://localhost/profile)
|
||||
fi
|
||||
|
||||
if [ ${CURRENT_PROFILE} == real1 ]
|
||||
then
|
||||
IDLE_PROFILE=real2
|
||||
else
|
||||
IDLE_PROFILE=real1
|
||||
fi
|
||||
|
||||
echo "${IDLE_PROFILE}"
|
||||
}
|
||||
|
||||
# 쉬고 있는 profile의 port 찾기
|
||||
|
||||
function find_idle_port() {
|
||||
IDLE_PROFILE=$(find_idle_profile)
|
||||
|
||||
if [ ${IDLE_PROFILE} == real1 ]
|
||||
then
|
||||
echo "8081"
|
||||
else
|
||||
echo "8082"
|
||||
fi
|
||||
}
|
||||
33
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/scripts/start.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH)
|
||||
source ${ABSDIR}/profile.sh
|
||||
|
||||
REPOSITORY=/home/ec2-user/app/step3
|
||||
PROJECT_NAME=SpringBootWebService # 실제 실습때 사용한 프로젝트 이름
|
||||
|
||||
echo "> Build 파일 복사"
|
||||
echo "> cp $REPOSITORY/zip/*.jar $REPOSITORY/"
|
||||
|
||||
cp $REPOSITORY/zip/*.jar $REPOSITORY/
|
||||
|
||||
echo "> 새 애플리케이션 배포"
|
||||
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)
|
||||
|
||||
echo "> JAR Name: $JAR_NAME"
|
||||
|
||||
echo "> $JAR_NAME 에 실행권한 추가"
|
||||
|
||||
chmod +x $JAR_NAME
|
||||
|
||||
echo "> $JAR_NAME 실행"
|
||||
|
||||
IDLE_PROFILE=$(find_idle_profile)
|
||||
|
||||
echo "> $JAR_NAME 를 profile=$IDLE_PROFILE 로 실행합니다."
|
||||
|
||||
nohup java -jar \
|
||||
-Dspring.config.location=classpath:/application.properties,classpath:/application-$IDLE_PROFILE.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties \
|
||||
-Dspring.profiles.active=$IDLE_PROFILE \
|
||||
$JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
|
||||
19
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/scripts/stop.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH)
|
||||
source ${ABSDIR}/profile.sh
|
||||
|
||||
IDLE_PORT=$(find_idle_port)
|
||||
|
||||
echo "> $IDLE_PORT 에서 구동중인 애플리케이션 pid 확인"
|
||||
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT})
|
||||
|
||||
if [ -z ${IDLE_PID} ]
|
||||
then
|
||||
echo "> 현재 구동 중인 애플리케이션이 없으므로 종료하지 않습니다."
|
||||
else
|
||||
echo "> kill -15 $IDLE_PID"
|
||||
kill -15 ${IDLE_PID}
|
||||
sleep 5
|
||||
fi
|
||||
16
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/scripts/switch.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH)
|
||||
source ${ABSDIR}/profile.sh
|
||||
|
||||
function switch_proxy() {
|
||||
IDLE_PORT=$(find_idle_port)
|
||||
|
||||
echo "> 전환할 Port: $IDLE_PORT"
|
||||
echo "> Port 전환"
|
||||
echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
|
||||
|
||||
echo "> 엔진엑스 Reload"
|
||||
sudo service nginx reload # 실습 때는 오류로 인해 "sudo systemctl reload nginx" 사용하였음.
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Chapter1. 인텔리제이로 스프링 부트 시작하기
|
||||
@@ -0,0 +1,681 @@
|
||||
# Chapter10. 24시간 365일 중단 없는 서비스를 만들자
|
||||
`Travis CI`를 활용하여 배포 자동화 환경을 구축해 보았습니다. 하지만 배포하는 동안 애플리케이션이 종료된다는 문제가 남았습니다. 긴 기간은 아니지만, **새로운 Jar가 실행되기 전까진 기존 Jar를 종료시켜 놓기 때문에** 서비스가 중단됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 10.1 무중단 배포 소개
|
||||
서비스를 정지하지 않고, 배포할 수 있는 방법을 **무중단 배포**라고 합니다.
|
||||
|
||||
무중단 배포 방식에는 몇 가지가 있습니다.
|
||||
|
||||
- `AWS`에서 블루 그린(`Blue-Green`) 무중단 배포
|
||||
- 도커를 이용한 웹서비스 무중단 배포
|
||||
|
||||
이 외에도 `L4 스위치`를 이용한 무중단 배포 방법도 있지만, `L4`가 워낙 고가의 장비이다 보니 대형 인터넷 기업 외에는 쓸 일이 거의 없습니다.
|
||||
|
||||
여기서 진행할 방법은 **엔진엑스(Nginx)** 를 이용한 무중단 배포입니다. 엔진엑스는 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어입니다.
|
||||
|
||||
엔진엑스가 가지고 있는 여러 기능 중 `리버스 프록시`가 있습니다. `리버스 프록시`란 엔진엑스가 **외부의 요청을 받아 백엔드 서버로 요청을 전달**하는 행위를 이야기합니다. 리버스 프록시 서버(엔진엑스)는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 웹 애플리케이션 서버들이 처리합니다.
|
||||
|
||||
엔진엑스를 이용한 무중단 배포를 하는 이유는 간단합니다. **가장 저렴하고 쉽기 때문**입니다.
|
||||
|
||||
기존에 쓰던 `EC2`에 그대로 적용하면 되므로 배포를 위해 `AWS EC2 인스턴스`가 하나 더 필요하지 않습니다. 추가로 이 방식은 꼭 `AWS`와 같은 클라우드 인프라가 구축되어 있지 않아도 사용할 수 있는 범용적인 방법입니다. 즉, 개인 서버 혹은 사내 서버에서도 동일한 방식으로 구축할 수 있으므로 사용처가 많습니다.
|
||||
|
||||
구조는 간단합니다. 하나의 `EC2` 혹은 리눅스 서버에 엔진엑스 1대와 **스프링 부트 Jar를 2대** 사용하는 것입니다.
|
||||
|
||||
- 엔진엑스는 `80(http)`, `443(https)` 포트를 할당합니다.
|
||||
- `스프링 부트1`은 `8081`포트로 실행합니다.
|
||||
- `스프링 부트2`는 `8082`포트로 실행합니다.
|
||||
|
||||
**엔진엑스 무중단 배포 1**은 다음과 같은 구조가 됩니다.
|
||||
|
||||

|
||||
|
||||
운영 과정은 다음과 같습니다.
|
||||
|
||||
- ① 사용자는 서비스 주소로 접속합니다(`80` 혹은 `443` 포트).
|
||||
- ② 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청을 전달합니다.
|
||||
- 스프링 부트1 즉, 8081 포트로 요청을 전달한다고 가정합니다.
|
||||
- ③ 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청받지 못합니다.
|
||||
|
||||
1.1 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링 부트2(8082 포트)로 배포합니다(아래 사진).
|
||||
|
||||

|
||||
|
||||
- ① 배포하는 동안에도 서비스는 중단되지 않습니다.
|
||||
- 엔진엑스는 스프링 부트1을 바라보기 때문입니다.
|
||||
- ② 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인합니다.
|
||||
- ③ 스프링 부트2가 정상 구동 중이면 `nginx reload` 명령어를 통해 `8081` 대신에 `8082`를 바라보도록 합니다.
|
||||
- ④ `nginx reload`는 0.1초 이내에 완료됩니다.
|
||||
|
||||
이후 1.2 버전 배포가 필요하면 이번에는 스프링 부트1로 배포합니다(아래 사진).
|
||||
|
||||

|
||||
|
||||
- ① 현재는 엔진엑스와 연결된 것이 스프링 부트2입니다.
|
||||
- ② 스프링 부트1의 배포가 끝났다면 엔진엑스가 스프링 부트1을 바라보도록 변경하고 `nginx reload`를 실행합니다.
|
||||
- ③ 이후 요청부터는 엔진엑스가 스프링 부트 1로 요청을 전달합니다.
|
||||
|
||||
이렇게 구성하게 되면 전체 시스템 구조는 다음과 같습니다.
|
||||
|
||||

|
||||
|
||||
기존 구조에서 `EC2` 내부의 구조만 변경된 것이니 크게 걱정하지 않아도 됩니다.
|
||||
|
||||
사진 출처 : [기억보단 기록을](https://jojoldu.tistory.com/267)
|
||||
|
||||
---
|
||||
|
||||
## 10.2 엔진엑스 설치와 스프링 부트 연동하기
|
||||
가장 먼저 `EC2`에 엔진엑스를 설치하겠습니다.
|
||||
|
||||
#### 엔진엑스 설치
|
||||
`EC2`에 접속해서 다음 명령어로 엔진엑스를 설치합니다.
|
||||
|
||||
>sudo yum install nginx
|
||||
|
||||
설치가 완료되었으면 다음 명령어로 엔진엑스를 실행합니다.
|
||||
|
||||
>sudo service nginx start
|
||||
|
||||
엔진엑스가 잘 실행되었다면 다음과 같은 메시지를 볼 수 있습니다.
|
||||
|
||||
>Starting nginx: [ OK ]
|
||||
|
||||
>###### 학습중 발생 오류 추가
|
||||
>
|
||||
>명령어로 `sudo service nginx start` 대신 `sudo systemctl start nginx` 를 사용
|
||||
>멈추고 싶다면 `sudo systemctl stop nginx` 명령어를 이용하면 된다.
|
||||
>상태를 확인하고 싶다면 `sudo systemctl status nginx` 명령어를 이용한다.
|
||||
>
|
||||
>참고 링크 : [AWS EC2에 NGINX 설치 및 사용하기](https://msyu1207.tistory.com/entry/AWS-EC2%EC%97%90-NGINX-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0)
|
||||
|
||||
외부에서 잘 노출되는지 확인해 보겠습니다.
|
||||
|
||||
#### 보안 그룹 추가
|
||||
먼저 엔진엑스의 포트번호를 보안 그룹에 추가하겠습니다. 엔진엑스의 포트번호는 기본적으로 `80`입니다. 해당 포트 번호가 보안 그룹에 없으니 `[EC2 -> 보안 그룹 -> EC2 보안 그룹 선택 -> 인바운드 편집]`으로 차례로 이동해서 변경합니다.
|
||||
|
||||
![80번 포트를 보안 그룹에 추가]
|
||||
|
||||

|
||||
|
||||
#### 리다이렉션 주소 추가
|
||||
`8080`이 아닌 `80`포트로 주소가 변경되니 구글과 네이버 로그인에도 변경된 주소를 등록해야만 합니다. 기존에 등록된 리디렉션 주소에서 `8080` 부분을 제거하여 추가 등록합니다. 앞서 진행된 `Chapter8`을 참고하여 구글과 네이버에 차례로 등록합니다.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
추가한 후에는 `EC2`의 도메인으로 접근하되, **8080 포트를 제거하고** 접근해 봅니다. 즉, 포트번호 없이 도메인만 입력해서 브라우저에서 접속합니다.
|
||||
|
||||
>`80번 포트는 기본적으로 도메인에서 포트번호가 제거된 상태입니다.`
|
||||
|
||||
그럼 다음과 같이 엔진엑스 웹페이지를 볼 수 있습니다.
|
||||
|
||||

|
||||
|
||||
이제 스프링 부트와 연동해 보겠습니다.
|
||||
|
||||
#### 엔진엑스와 스프링 부트 연동
|
||||
엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 하겠습니다. 엔진엑스 설정 파일을 열어봅니다.
|
||||
|
||||
>sudo vim /etc/nginx/nginx.conf
|
||||
|
||||
설정 내용 중 `server` 아래의 `location /` 부분을 찾아서 다음과 같이 추가합니다.
|
||||
|
||||

|
||||
|
||||
>proxy_pass http://localhost:8080; ①
|
||||
>proxy_set_header X-Real-IP \$remote_addr;
|
||||
>proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; ②
|
||||
>proxy_set_header Host $http_host;
|
||||
|
||||
##### -----코드설명-----
|
||||
**① proxy_pass**
|
||||
- 엔진엑스로 요청이 오면 `http://localhost:8080`로 전달합니다.
|
||||
|
||||
**② proxy_set_header XXX**
|
||||
- 실제 요청 데이터를 `header`의 각 항목에 할당합니다.
|
||||
- 예) `proxy_set_header X-Real-IP $remote_addr` : `Request Header`의 `X-Real-IP`에 요청자의 `IP`를 저장합니다.
|
||||
|
||||
##### -----------------------
|
||||
|
||||
수정이 끝났으면 `:wq` 명령어로 저장하고 종료해서, 엔진엑스를 재시작 하겠습니다.
|
||||
|
||||
>sudo service nginx restart
|
||||
|
||||
>###### 학습중 발생 오류 추가
|
||||
>
|
||||
>위의 동작, 정지와 같은 오류로 인해 `sudo systemctl restart nginx` 사용하여 해결.
|
||||
|
||||
|
||||
다시 브라우저로 접속해서 엔진엑스 시작 페이지가 보이면 화면을 새로고침합니다.
|
||||
|
||||
엔진엑스가 스프링 부트 프로젝트를 프록시하는 것이 확인됩니다(기본 페이지가 보입니다). 본격적으로 무중단 배포 작업을 진행해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 10.3 무중단 배포 스크립트 만들기
|
||||
무중단 배포 스크립트 작업 전에 API를 하나 추가하겠습니다. 이 API는 이후 배포 시에 `8081`을 쓸지, `8082`를 쓸지 판단하는 기준이 됩니다.
|
||||
|
||||
### profile API 추가
|
||||
`ProfileController`를 만들어 다음과 같이 간단한 API 코드를 추가합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class ProfileController {
|
||||
private final Environment env;
|
||||
|
||||
@GetMapping("/profile")
|
||||
public String profile() {
|
||||
List<String> profiles = Arrays.asList(env.getActiveProfiles()); // ①
|
||||
|
||||
List<String> realProfiles = Arrays.asList("real", "real1", "real2");
|
||||
|
||||
String defaultProfile = profiles.isEmpty() ? "default" : profiles.get(0);
|
||||
|
||||
return profiles.stream()
|
||||
.filter(realProfiles::contains)
|
||||
.findAny()
|
||||
.orElse(defaultProfile);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### -----코드설명-----
|
||||
**① env.getActiveProfiles()**
|
||||
- 현재 실행 중인 `ActiveProfile`을 모두 가져옵니다.
|
||||
- 즉, `real`, `real1`, `real2`는 모두 배포에 사용될 `profile`이라 이 중 하나라도 있으면 그 값을 반환하도록 합니다.
|
||||
- 실제로 이번 무중단 배포에서는 `real1`과 `real2`만 사용되지만, `step2`를 다시 사용해볼 수도 있으니 `real`도 남겨둡니다.
|
||||
##### -----------------------
|
||||
|
||||
이 코드가 잘 작동하는지 테스트 코드를 작성해 보겠습니다. 해당 컨트롤러는 특별히 **스프링 환경이 필요하지는 않습니다.** 그래서 `@SpringBootTest` 없이 테스트 코드를 작성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class ProfileControllerUnitTest {
|
||||
|
||||
@Test
|
||||
public void real_profile이_조회된다() {
|
||||
//given
|
||||
String expectedProfile = "real";
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.addActiveProfile(expectedProfile);
|
||||
env.addActiveProfile("oauth");
|
||||
env.addActiveProfile("real-db");
|
||||
|
||||
ProfileController controller = new ProfileController(env);
|
||||
|
||||
//when
|
||||
String profile = controller.profile();
|
||||
|
||||
//then
|
||||
assertThat(profile).isEqualTo(expectedProfile);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void real_profile이_없으면_첫_번째가_조회된다() {
|
||||
//given
|
||||
String expectedProfile = "oauth";
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.addActiveProfile(expectedProfile);
|
||||
env.addActiveProfile("real-db");
|
||||
|
||||
ProfileController controller = new ProfileController(env);
|
||||
|
||||
//when
|
||||
String profile = controller.profile();
|
||||
|
||||
//then
|
||||
assertThat(profile).isEqualTo(expectedProfile);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void active_profile이_없으면_default가_조회된다() {
|
||||
//given
|
||||
String expectedProfile = "default";
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
|
||||
ProfileController controller = new ProfileController(env);
|
||||
|
||||
//when
|
||||
String profile = controller.profile();
|
||||
|
||||
//then
|
||||
assertThat(profile).isEqualTo(expectedProfile);
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
`ProfileController`나 `Environment` 모두 **자바 클래스(인터페이스)**이기 때문에 쉽게 테스트할 수 있습니다. `Environment`는 인터페이스라 가짜 구현체인 `MockEnvironment`(스프링에서 제공)를 사용해서 테스트하면 됩니다.
|
||||
|
||||
이렇게 해보면 **생성자 DI가 얼마나 유용한지** 알 수 있습니다. 만약 `Environment`를 `@Autowired`로 `DI` 받았다면 **이런 테스트 코드를 작성하지 못 했습니다.** 항상 스프링 테스트를 해야했을 것입니다. 앞의 테스트가 다 통과했다면 컨트롤러 로직에 대한 이슈는 없습니다.
|
||||
|
||||
그리고 이 `/profile`이 **인증 없이도 호출될 수 있게** `SecurityConfig` 클래스에 제외 코드를 추가합니다.
|
||||
|
||||
```java
|
||||
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll();
|
||||
```
|
||||
##### -----코드설명-----
|
||||
**① permitAll 마지막에 "/profile"이 추가됩니다.**
|
||||
##### ----------------------
|
||||
|
||||
그리고 `SecurityConfig` 설정이 잘 되었는지도 테스트 코드로 검증합니다. 이 검증은 스프링 시큐리티 설정을 불러와야 하니 `@SpringBootTest`를 사용하는 테스트 클래스(`ProfileControllerTest`)를 하나 더 추가합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
public class ProfileControllerTest {
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Test
|
||||
public void profile은_인증없이_호출된다() {
|
||||
String expected = "default";
|
||||
|
||||
ResponseEntity<String> response = restTemplate.getForEntity("/profile", String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
여기까지 모든 테스트가 성공했다면 깃허브로 푸시하여 배포 합니다. 배포가 끝나면 브라우저에서 `/profile`로 접속해서 `profile`이 잘 나오는지 확인합니다.
|
||||
|
||||
여기까지 잘 되었으면 잘 구성된 것이니 다음으로 넘어갑니다.
|
||||
|
||||
### real1, real2 profile 생성
|
||||
현재 `EC2` 환경에서 실행되는 `profile`은 `real`밖에 없습니다. 해당 `profile`은 **Travis CI 배포 자동화를 위한** `profile`이니 무중단 배포를 위한 `profile` 2개(`real1`, `real2`)를 `src/main/resources` 아래에 추가합니다.
|
||||
|
||||
```java
|
||||
//application-real1.properties
|
||||
|
||||
server.port=8081
|
||||
spring.profiles.include=oauth,real-db
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
spring.session.store-type=jdbc
|
||||
```
|
||||
|
||||
```java
|
||||
//application-real2.properties
|
||||
|
||||
server.port=8082
|
||||
spring.profiles.include=oauth,real-db
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
spring.session.store-type=jdbc
|
||||
```
|
||||
|
||||
2개의 `profile`은 `real profile`과 크게 다른 점은 없지만, 한 가지가 다릅니다.
|
||||
|
||||
`server.port`가 `8080`이 아닌 `8081/8082`로 되어 있습니다. 이 부분만 주의해서 생성하고 생성된 후에는 깃허브로 푸시하면서 마무리합니다.
|
||||
|
||||
### 엔진엑스 설정 수정
|
||||
무중단 배포의 핵심은 **엔진엑스 설정**입니다. 배포 때마다 엔진엑스의 프록시 설정(스프링 부트로 요청을 흘려보내는)이 순식간에 교체됩니다. 여기서 프록시 설정이 교체될 수 있도록 설정을 추가하겠습니다.
|
||||
|
||||
엔진엑스 설정이 모여있는 `/etc/nginx/conf.d/ `에 `service-url.inc`라는 파일을 하나 생성합니다.
|
||||
|
||||
>sudo vim /etc/nginx/conf.d/service-url.inc
|
||||
|
||||
그리고 다음 코드를 입력합니다.
|
||||
|
||||
>set \$service_url http://127.0.0.1:8080;
|
||||
|
||||
저장하고 종료한 뒤(`:wq`) 해당 파일은 엔진엑스가 사용할 수 있게 설정합니다. 다음과 같이 `nginx.conf` 파일을 열겠습니다.
|
||||
|
||||
>sudo vim /etc/nginx/nginx.conf
|
||||
|
||||
`location / `부분을 찾아 다음과 같이 변경합니다.
|
||||
|
||||
>include /etc/nginx/conf.d/service-url.inc;
|
||||
>
|
||||
>location / {
|
||||
> proxy_pass \$service_url;
|
||||
>}
|
||||
|
||||
저장하고 종료한 뒤(`:wq`) **재시작**합니다.
|
||||
|
||||
> sudo service nginx restart
|
||||
|
||||
다시 브라우저에서 정상적으로 호출되는지 확인합니다. 확인되었다면 엔진엑스 설정까지 잘 된 것입니다.
|
||||
|
||||
### 배포 스크립트들 작성
|
||||
먼저 `step2`와 중복되지 않기 위해 `EC2`에 `step3` 디렉토리를 생성합니다.
|
||||
|
||||
>mkdir ~/app/step3 && mkdir ~/app/step3/zip
|
||||
|
||||
무중단 배포는 앞으로 `step3`를 사용하겠습니다. 그래서 `appspec.yml` 역시 `step3`로 배포되도록 수정합니다.
|
||||
|
||||
```sh
|
||||
version: 0.0
|
||||
os: linux
|
||||
files:
|
||||
- source: /
|
||||
destination: /home/ec2-user/app/step3/zip/
|
||||
overwrite: yes
|
||||
```
|
||||
무중단 배포를 진행할 스크립트들은 총 5개입니다.
|
||||
|
||||
- `stop.sh` : 기존 엔진엑스에 연결되어 있진 않지만, 실행 중이던 스프링 부트 종료
|
||||
- `start.sh` : 배포할 신규 버전 스프링 부트 프로젝트를 `stop.sh`로 종료한 `profile`로 실행
|
||||
- `health.sh` : `start.sh`로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
|
||||
- `switch.sh` : 엔진엑스가 바라보는 스프링 부트를 최신 버전으로 변경
|
||||
- `profile.sh` : 앞선 4개 스크립트 파일에서 공용으로 사용할 `profile` 과 포트 체크 로직
|
||||
|
||||
`appspec.yml`에 앞선 스크립트를 사용하도록 설정합니다.
|
||||
|
||||
```
|
||||
hooks:
|
||||
AfterInstall:
|
||||
- location: stop.sh # 엔진엑스와 연결되어 있지 않은 스프링 부트를 종료합니다.
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
ApplicationStart:
|
||||
- location: start.sh # 엔진엑스와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작합니다.
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
ValidateService:
|
||||
- location: health.sh # 새 스프링 부트가 정상적으로 실행됐는지 확인합니다.
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
```
|
||||
`Jar` 파을이 복사된 이후부터 차례로 앞선 스크립트들이 실행된다고 보면 됩니다. 다음은 각 스크립트입니다. 이 스크립트들 역시 `scripts` 디렉토리에 추가합니다.
|
||||
|
||||

|
||||
|
||||
#### `profile.sh`
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 쉬고 있는 profile 찾기: real1이 사용 중이면 real2가 쉬고 있고, 반대면 real1이 쉬고 있음
|
||||
|
||||
function find_idle_profile() {
|
||||
RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile) ①
|
||||
|
||||
if [ ${RESPONSE_CODE} -ge 400 ] # 400 보다 크면(즉, 40x/50x 에러 모두 포함)
|
||||
then
|
||||
CURRENT_PROFILE=real2
|
||||
else
|
||||
CURRENT_PROFILE=$(curl -s http://localhost/profile)
|
||||
fi
|
||||
|
||||
if [ ${CURRENT_PROFILE} == real1 ]
|
||||
then
|
||||
IDLE_PROFILE=real2 ②
|
||||
else
|
||||
IDLE_PROFILE=real1
|
||||
fi
|
||||
|
||||
echo "${IDLE_PROFILE}" ③
|
||||
}
|
||||
|
||||
# 쉬고 있는 profile의 port 찾기
|
||||
|
||||
function find_idle_port() {
|
||||
IDLE_PROFILE=$(find_idle_profile)
|
||||
|
||||
if [ ${IDLE_PROFILE} == real1 ]
|
||||
then
|
||||
echo "8081"
|
||||
else
|
||||
echo "8082"
|
||||
fi
|
||||
}
|
||||
```
|
||||
##### -----코드설명-----
|
||||
**① `$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)`**
|
||||
- 현재 엔진엑스가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 확인합니다.
|
||||
- 응답값을 `HttpStatus`로 받습니다.
|
||||
- 정상이면 `200`, 오류가 발생한다면 `400 ~ 503` 사이로 발생하니 `400` 이상은 모두 예외로 보고 `real2`를 **현재 profile로 사용**합니다.
|
||||
|
||||
**② `IDLE_PROFILE`**
|
||||
- 엔진엑스와 연결되지 않은 `profile`입니다.
|
||||
- 스프링 부트 프로젝트를 이 `profile`로 연결하기 위해 반환합니다.
|
||||
|
||||
**③ `echo "${IDLE_PROFILE}"`**
|
||||
- `bash`라는 스크립트는 **값을 반환하는 기능이 없습니다.**
|
||||
- 그래서 **제일 마지막 줄에 echo로 결과를 출력** 후, 클라이언트에서 그 값을 잡아서 (`$(find_idle_profile)`) 사용합니다.
|
||||
- 중간에 `echo`를 사용해선 안 됩니다.
|
||||
##### ----------------------
|
||||
|
||||
#### **`stop.sh`**
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH) ①
|
||||
source ${ABSDIR}/profile.sh ②
|
||||
|
||||
IDLE_PORT=$(find_idle_port)
|
||||
|
||||
echo "> $IDLE_PORT 에서 구동중인 애플리케이션 pid 확인"
|
||||
IDLE_PID=$(lsof -ti tcp:${IDLE_PORT})
|
||||
|
||||
if [ -z ${IDLE_PID} ]
|
||||
then
|
||||
echo "> 현재 구동 중인 애플리케이션이 없으므로 종료하지 않습니다."
|
||||
else
|
||||
echo "> kill -15 $IDLE_PID"
|
||||
kill -15 ${IDLE_PID}
|
||||
sleep 5
|
||||
fi
|
||||
```
|
||||
##### -----코드설명-----
|
||||
**① `ABSDIR=$(dirname $ABSPATH)`**
|
||||
- 현재 `stop.sh`가 속해 있는 경로를 찾습니다.
|
||||
- 하단의 코드와 같이 `profile.sh`의 경로를 찾기 위해 사용됩니다.
|
||||
|
||||
**② `source ${ABSDIR}/profile.sh`**
|
||||
- 자바로 보면 일종의 `import` 구문입니다.
|
||||
- 해당 코드로 인해 `stop.sh`에서도 `profile.sh`의 여러 `function`을 사용할 수 있게 됩니다.
|
||||
##### -----------------------
|
||||
|
||||
#### **`start.sh`**
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH)
|
||||
source ${ABSDIR}/profile.sh
|
||||
|
||||
REPOSITORY=/home/ec2-user/app/step3
|
||||
PROJECT_NAME=SpringBootWebService
|
||||
|
||||
echo "> Build 파일 복사"
|
||||
echo "> cp $REPOSITORY/zip/*.jar $REPOSITORY/"
|
||||
|
||||
cp $REPOSITORY/zip/*.jar $REPOSITORY/
|
||||
|
||||
echo "> 새 애플리케이션 배포"
|
||||
JAR_NAME=$(ls -tr $REPOSITORY/*.jar | tail -n 1)
|
||||
|
||||
echo "> JAR Name: $JAR_NAME"
|
||||
|
||||
echo "> $JAR_NAME 에 실행권한 추가"
|
||||
|
||||
chmod +x $JAR_NAME
|
||||
|
||||
echo "> $JAR_NAME 실행"
|
||||
|
||||
IDLE_PROFILE=$(find_idle_profile)
|
||||
|
||||
echo "> $JAR_NAME 를 profile=$IDLE_PROFILE 로 실행합니다."
|
||||
|
||||
nohup java -jar \
|
||||
-Dspring.config.location=classpath:/application.properties,classpath:/application-$IDLE_PROFILE.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties \
|
||||
-Dspring.profiles.active=$IDLE_PROFILE \
|
||||
$JAR_NAME > $REPOSITORY/nohup.out 2>&1 &
|
||||
```
|
||||
|
||||
##### -----코드설명-----
|
||||
**① `기본적인 스크립트는 step2의 deploy.sh와 유사합니다`**
|
||||
**② `다른 점이라면 IDLE_PROFILE을 통해 properties 파일을 가져오고(application-$IDLE_PROFILE.properties), active profile을 지정하는 것(-Dspring.profiles.active=$IDLE_PROFILE) 뿐입니다.`**
|
||||
**③ `여기서도 IDLE_PROFILE을 사용하니 profile.sh을 가져와야 합니다.`**
|
||||
##### ----------------------
|
||||
|
||||
#### **`health.sh`**
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH)
|
||||
source ${ABSDIR}/profile.sh
|
||||
source ${ABSDIR}/switch.sh
|
||||
|
||||
IDLE_PORT=$(find_idle_port)
|
||||
|
||||
echo "> Health Check Start!"
|
||||
echo "> IDLE_PORT: $IDLE_PORT"
|
||||
echo "> curl -s http://localhost:$IDLE_PORT/profile "
|
||||
sleep 10
|
||||
|
||||
for RETRY_COUNT in {1..10}
|
||||
do
|
||||
RESPONSE=$(curl -s http://localhost:${IDLE_PORT}/profile)
|
||||
UP_COUNT=$(echo ${RESPONSE} | grep 'real' | wc -l)
|
||||
|
||||
if [ ${UP_COUNT} -ge 1 ]
|
||||
then # UP_COUNT >= 1 ("real" 문자열이 있는지 검증)
|
||||
echo "> Health Check 성공"
|
||||
switch_proxy
|
||||
break
|
||||
else
|
||||
echo "> Health Check의 응답을 알 수 없거나 혹은 실행 상태가 아닙니다."
|
||||
echo "> Health Check: ${RESPONSE}"
|
||||
fi
|
||||
|
||||
if [ ${RETRY_COUNT} -eq 10 ]
|
||||
then
|
||||
echo "> Health Check 실패. "
|
||||
echo "> 엔진엑스에 연결하지 않고 배포를 종료합니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "> Health Check 연결 실패. 재시도...."
|
||||
sleep 10
|
||||
done
|
||||
```
|
||||
##### -----코드설명-----
|
||||
**① 엔진엑스와 연결되지 않은 포트로 스프링 부트가 잘 수행되었는지 체크합니다.**
|
||||
**② 잘 떴는지 확인되어야 엔진엑스 프록시 설정을 변경(`switch_proxy`)합니다.**
|
||||
**③ 엔진엑스 프록시 설정 변경은 `switch.sh`에서 수행합니다.**
|
||||
##### ----------------------
|
||||
|
||||
#### `switch.sh`
|
||||
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ABSPATH=$(readlink -f $0)
|
||||
ABSDIR=$(dirname $ABSPATH)
|
||||
source ${ABSDIR}/profile.sh
|
||||
|
||||
function switch_proxy() {
|
||||
IDLE_PORT=$(find_idle_port)
|
||||
|
||||
echo "> 전환할 Port: $IDLE_PORT"
|
||||
echo "> Port 전환"
|
||||
echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
|
||||
|
||||
echo "> 엔진엑스 Reload"
|
||||
sudo service nginx reload
|
||||
}
|
||||
```
|
||||
##### -----코드설명-----
|
||||
**① `echo "set \$service_url http://127.0.0.1:${IDLE_PORT};"`**
|
||||
- 하나의 문장을 만들어 파이프라인(`|`)으로 넘겨주기 위해 `echo`를 사용합니다.
|
||||
- 엔진엑스가 변경할 프록시 주소를 생성합니다.
|
||||
- 쌍따옴표(`"`)를 사용해야 합니다.
|
||||
- 사용하지 않으면 `$service_url`을 그대로 인식하지 못하고 변수를 찾게 됩니다.
|
||||
|
||||
**② `| sudo tee /etc/nginx/conf.d/service-url.inc`**
|
||||
- 앞에서 넘겨준 문장을 `service-url.inc`에 덮어씌웁니다.
|
||||
|
||||
**③ `sudo service nginx reload`**
|
||||
- 엔진엑스 설정을 다시 불러옵니다.
|
||||
- **`restart`와는 다릅니다.**
|
||||
- `restart`는 잠시 끊기는 현상이 있지만, `reload`는 끊김 없이 다시 불러옵니다.
|
||||
- 다만, 중요한 설정들은 반영되지 않으므로 `restart`를 다시 사용해야 합니다.
|
||||
- 여기선 **외부의 설정 파일**인 `service-url`을 다시 불러오는 거라 `reload`로 가능합니다.
|
||||
|
||||
스크립트까지 모두 완성했습니다. 그럼 실제로 무중단 배포를 진행해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 10.4 무중단 배포 테스트
|
||||
배포 테스트를 하기 전, 한 가지 추가 작업을 진행하도록 하겠습니다. 잦은 배포로 `Jar` 파일명이 겹칠 수 있습니다. 매번 버전을 올리는 것이 귀찮으므로 자동으로 버전값이 변경될 수 있도록 조치하겟습니다.
|
||||
|
||||
#### `build.gradle`
|
||||
```java
|
||||
version '1.0.1-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")
|
||||
```
|
||||
##### -----코드설명-----
|
||||
**① `build.gradle`은 `Groovy` 기반의 빌드툴입니다.**
|
||||
**② 당연히 `Groovy` 언어의 여러 문법을 사용할 수 있는데, 여기서는 `new Date()`로 빌드할 때마다 그 시간이 버전에 추가되도록 구성하였습니다.**
|
||||
##### ----------------------
|
||||
|
||||
여기까지 구성한 뒤 최종 코드를 깃허브로 푸시합니다. 배포가 자동으로 진행되면 `CodeDeploy` 로그로 잘 진행되는지 확인해 봅니다.
|
||||
|
||||
>`tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log`
|
||||
|
||||
그럼 다음과 같은 메시지가 차례로 출력됩니다.
|
||||
|
||||

|
||||
|
||||
스프링 부트 로그도 보고 싶다면 다음 명령어로 확인할 수 있습니다.
|
||||
|
||||
>`vim ~/app/step3/nohup.out`
|
||||
|
||||
그럼 스프링 부트 실행 로그를 직접 볼 수 있습니다. 한 번 더 배포하면 그때는 `real2`로 배포됩니다. 이 과정에서 브라우저 새로고침을 해보면 전혀 중단 없는 것을 확인할 수 있습니다. 2번 배포를 진행한 뒤에 다음과 같이 자바 애플리케이션 실행 여부를 확인합니다.
|
||||
|
||||
>`ps -ef | grep java`
|
||||
|
||||
다음과 같이 2개의 애플리케이션(`real1`, `real2`)이 실행되고 있음을 알 수 있습니다.
|
||||
|
||||

|
||||
|
||||
이제 이 시스템은 마스터 브랜치에 푸시가 발생하면 자동으로 서버 배포가 진행되고, 서버 중단 역시 전혀 없는 시스템이 되었습니다.
|
||||
|
||||
---
|
||||
|
||||
#### 추가사항
|
||||
실습 중간중간에 스크립트에 문제가 있는 것인지 새롭게 배포를 할 때마다 기존에 실행되어있던 프로젝트가 종료되지 않았고, 그로 인해 `java.net.BindException: Address already in use (Bind failed)` 에러가 발생하여 제대로 되지 않았다. 이 에러는 8080포트로 이미 실행되어 있는 `PID`를 찾아서 종료해준 뒤 배포하면 제대로 배포가 되는 것을 볼 수 있다.
|
||||
|
||||
참고 링크
|
||||
- [Address already in use 혹은 Bind failed 에러 해결하기](https://fishpoint.tistory.com/3746)
|
||||
- [Address already in use (Bind failed) 에러 해결하기](https://philip1994.tistory.com/6)
|
||||
- [netstat 명령어를 통한 네트워크 상태 확인 방법](http://blog.naver.com/PostView.nhn?blogId=ncloud24&logNo=221388026417&parentCategoryNo=&categoryNo=79&viewDate=&isShowPopularPosts=false&from=postView)
|
||||
265
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter2.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Chapter2. 스프링 부트에서 테스트 코드를 작성하자
|
||||
|
||||
---
|
||||
|
||||
## 2.1 테스트 코드 소개
|
||||
|
||||
`TDD`와 단위 테스트는 다른 이야기이다. `TDD`는 **테스트가 주도하는 개발**을 이야기하는데, **테스트 코드를 먼저 작성**하는 것부터 시작합니다.
|
||||
|
||||
>**레드 그린 사이클**
|
||||
>- 항상 실패하는 테스트를 먼저 작성하고(`Red`)
|
||||
>- 테스트가 통과하는 프로덕션 코드를 작성하고(`Green`)
|
||||
>- 테스트가 통과하면 프로덕션 코드를 리팩토링 합니다(`Refactor`).
|
||||
|
||||
반면 단위 테스트는 `TDD`의 첫 번째 단계인 **기능 단위의 테스트 코드를 작성**하는 것을 이야기한다. `TDD`와 달리 테스트 코드를 꼭 먼저 작성해야 하는 것도 아니고, 리팩토링도 포함되지 않는다. 순수하게 테스크 코드만 작성하는 것을 말한다.
|
||||
|
||||
>**단위 테스트 코드를 작성함으로써 얻는 이점**
|
||||
>- 단위 테스트는 개발단계 초기에 문제를 발견하게 도와줍니다.
|
||||
>- 단위 테스트는 개발자가 나중에 코드를 리팩토링하거나 라이브러리 업그레이드 등에서 기존 기능이 올바르게 작동하는지 확인할 수 있습니다(예, 회귀테스트).
|
||||
>- 단위 테스트는 기능에 대한 불확실성을 감소시킬 수 있습니다.
|
||||
>- 단위 테스트는 시스템에 대한 실제 문서를 제공합니다. 즉, 단위 테스트 자체가 문서로 사용할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Hello Controller 테스트 코드 작성하기
|
||||
|
||||
일반적으로 패키지 명은 **웹 사이트 주소의 역순**으로 합니다. 예를 들어 `admin.jojoldu.com`이라는 사이트라면 패키지명은 `com.jojoldu.admin`으로 하면 됩니다.
|
||||
|
||||
>#### Application
|
||||
>```java
|
||||
>package com.banjjoknim.book.springboot;
|
||||
>
|
||||
>import org.springframework.boot.SpringApplication;
|
||||
>import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
>
|
||||
>@SpringBootApplication
|
||||
>public class Application {
|
||||
> public static void main(String[] args) {
|
||||
> SpringApplication.run(Application.class, args);
|
||||
> }
|
||||
>}
|
||||
>```
|
||||
>
|
||||
>- `@SpringBootApplication` 어노테이션으로 인해 스프링 부트의 자동 설정, 스프링 `Bean` 읽기와 생성이 모두 자동으로 설정됩니다.
|
||||
>- 특히, `@SpringBootApplication`이 있는 위치부터 설정을 읽어가기 때문에 이 어노테이션이 선언된 클래스는 항상 **프로젝트의 최상단에 위치**해야만 합니다.
|
||||
>- 내장 WAS(`Web Application Server`)란 별도로 외부에 WAS를 두지 않고 애플리케이션을 실행할 때 내부에서 WAS를 실행하는 것을 이야기합니다. `SpringApplication.run`으로 인해 내장 WAS를 실행합니다. 이렇게 되면 항상 서버에 **톰캣을 설치할 필요가 없게 되고,** 스프링 부트로 만들어진 `Jar` 파일(실행 가능한 Java 패키징 파일)로 실행하면 됩니다.
|
||||
>- 스프링 부트에서는 **내장 WAS를 사용하는 것을 권장**한다. **언제 어디서나 같은 환경에서 스프링 부트를 배포**할 수 있기 때문이다.
|
||||
|
||||
>#### HelloController
|
||||
>```java
|
||||
>package com.banjjoknim.book.springboot.web;
|
||||
>
|
||||
>import org.springframework.web.bind.annotation.GetMapping;
|
||||
>import org.springframework.web.bind.annotation.RestController;
|
||||
>
|
||||
>@RestController // 1.
|
||||
>public class HelloController {
|
||||
>
|
||||
> @GetMapping("/hello") // 2.
|
||||
> public String hello() {
|
||||
> return "hello";
|
||||
> }
|
||||
>}
|
||||
>```
|
||||
>
|
||||
>**1. @RestController**
|
||||
>- 컨트롤러를 `JSON`을 반환하는 컨트롤러로 만들어 줍니다.
|
||||
>- 예전에는 `@ResponseBody`를 각 메소드마다 선언했던 것을 한번에 사용할 수 있게 해준다고 생각하면 됩니다.
|
||||
>
|
||||
>**2. @GetMapping**
|
||||
>- `HTTP Method`인 `Get`의 요청을 받을 수 있는 API를 만들어 줍니다.
|
||||
>- 예전에는 `@RequestMapping(method = RequestMethod.GET)`으로 사용되었습니다.
|
||||
|
||||
>#### HelloControllerTest
|
||||
>```java
|
||||
>package com.banjjoknim.book.springboot.web;
|
||||
>
|
||||
>import org.junit.Test;
|
||||
>import org.junit.runner.RunWith;
|
||||
>import org.springframework.beans.factory.annotation.Autowired;
|
||||
>import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
>import org.springframework.test.context.junit4.SpringRunner;
|
||||
>import org.springframework.test.web.servlet.MockMvc;
|
||||
>
|
||||
>import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
>import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
>import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
>
|
||||
>@RunWith(SpringRunner.class) // 1.
|
||||
>@WebMvcTest(controllers = HelloController.class) // 2.
|
||||
>public class HelloControllerTest {
|
||||
>
|
||||
> @Autowired // 3.
|
||||
> private MockMvc mvc; // 4.
|
||||
>
|
||||
> @Test
|
||||
> public void hello가_리턴된다() throws Exception {
|
||||
> String hello = "hello";
|
||||
>
|
||||
> mvc.perform(get("/hello")) // 5.
|
||||
> .andExpect(status().isOk()) // 6.
|
||||
> .andExpect(content().string(hello)); // 7.
|
||||
> }
|
||||
>}
|
||||
>```
|
||||
>
|
||||
>**1. @RunWith(SpringRunner.class)**
|
||||
>- 테스트를 진행할 때 `JUnit`에 내장된 실행자 외에 다른 실행자를 실행시킵니다.
|
||||
>- 여기서는 `SpringRunner`라는 스프링 실행자를 사용합니다.
|
||||
>- 즉, 스프링 부트 테스트와 `JUnit` 사이에 연결자 역할을 합니다.
|
||||
>
|
||||
>**2. @WebMvcTest**
|
||||
>- 여러 스프링 테스트 어노테이션 중, `Web(Spring MVC)`에 집중할 수 있는 어노테이션입니다.
|
||||
>- 선언할 경우 `@Controller`, `@ControllerAdvice` 등을 사용할 수 있습니다.
|
||||
>- 단, `@Service`, `@Component`, `@Repository` 등은 사용할 수 없습니다.
|
||||
>- 여기서는 컨트롤러만 사용하기 때문에 선언합니다.
|
||||
>
|
||||
>**3. @Autowired**
|
||||
>- 스프링이 관리하는 빈(`Bean`)을 주입 받습니다.
|
||||
>
|
||||
>**4. private MockMvc mvc**
|
||||
>- 웹 API를 테스트할 때 사용합니다.
|
||||
>- 스프링 `MVC` 테스트의 시작점입니다.
|
||||
>- 이 클래스를 통해 `HTTP GET`, `POST` 등에 대한 API 테스트를 할 수 있습니다.
|
||||
>
|
||||
>**5. mvc.perform(get("/hello"))**
|
||||
>- `MockMvc`를 통해 `/hello` 주소로 `HTTP GET` 요청을 합니다.
|
||||
>- 체이닝이 지원되어 아래와 같이 여러 검증 기능을 이어서 선언할 수 있습니다.
|
||||
>
|
||||
>**6. .andExpect(status().isOk())**
|
||||
>- `mvc.perform`의 결과를 검증합니다.
|
||||
>- `HTTP Header`의 `Status`를 검증합니다.
|
||||
>- 우리가 흔히 알고 있는 `200`, `404`, `500` 등의 상태를 검증합니다.
|
||||
>- 여기선 `OK` 즉, `200`인지 아닌지를 검증합니다.
|
||||
>
|
||||
>**7. .andExpect(content().string(hello))**
|
||||
>- `mvc.perform`의 결과를 검증합니다.
|
||||
>- 응답 본문의 내용을 검증합니다.
|
||||
>- `Controller`에서 `"hello"`를 리턴하기 때문에 이 값이 맞는지 검증합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2.3 롬복 소개 및 설치하기
|
||||
|
||||
**자바 개발자들의 필수 라이브러리 롬복**
|
||||
- 롬복은 자바 개발할 때 자주 사용하는 코드 `Getter`, `Setter`, 기본생성자, `toString` 등을 어노테이션으로 자동 생성해 줍니다.
|
||||
|
||||
|
||||
- `build.gradle`에 다음의 코드를 추가하여 의존성(라이브러리)을 추가합니다.
|
||||
```java
|
||||
compile('org.projectlombok:lombok')
|
||||
```
|
||||
|
||||
- `plugins`에서 `lombok` 플러그인을 검색하여 설치합니다.
|
||||
|
||||
- 롬복은 프로젝트마다 설정해야 합니다. 플러그인 설치는 한 번만 하면 되지만, `build.gradle`에 라이브러리를 추가하는 것과 `Enable annotation processing`를 체크하는 것은 프로젝트마다 진행해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Hello Controller 코드를 롬복으로 전환하기
|
||||
|
||||
>#### HelloResponseDto
|
||||
>```java
|
||||
>package com.banjjoknim.book.springboot.web.dto;
|
||||
>
|
||||
>import lombok.Getter;
|
||||
>import lombok.RequiredArgsConstructor;
|
||||
>
|
||||
>@Getter // 1.
|
||||
>@RequiredArgsConstructor // 2.
|
||||
>public class HelloResponseDto {
|
||||
>
|
||||
> private final String name;
|
||||
> private final int amount;
|
||||
>}
|
||||
>```
|
||||
>
|
||||
>**1. @Getter**
|
||||
>- 선언된 모든 필드의 `get` 메소드를 생성해줍니다.
|
||||
>
|
||||
>**2. @RequiredArgsConstructor**
|
||||
>- 선언된 모든 `final` 필드가 포함된 생성자를 생성해 줍니다.
|
||||
>- `final`이 없는 필드는 생성자에 포함되지 않습니다.
|
||||
|
||||
>#### HelloResponseDtoTest
|
||||
>```java
|
||||
>package com.banjjoknim.book.springboot.web.dto;
|
||||
>
|
||||
>import org.junit.Test;
|
||||
>
|
||||
>import static org.assertj.core.api.Assertions.assertThat;
|
||||
>
|
||||
>public class HelloResponseDtoTest {
|
||||
>
|
||||
> @Test
|
||||
> public void 롬복_기능_테스트() {
|
||||
> // given
|
||||
> String name = "test";
|
||||
> int amount = 1000;
|
||||
>
|
||||
> // when
|
||||
> HelloResponseDto dto = new HelloResponseDto(name, amount);
|
||||
>
|
||||
> // then
|
||||
> assertThat(dto.getName()).isEqualTo(name); // 1. , 2.
|
||||
> assertThat(dto.getAmount()).isEqualTo(amount);
|
||||
> }
|
||||
>}
|
||||
>```
|
||||
>
|
||||
>**1. assertThat**
|
||||
>- `assertj`라는 테스트 검증 라이브러리의 검증 메소드입니다.
|
||||
>- 검증하고 싶은 대상을 메소드 인자로 받습니다.
|
||||
>- 메소드 체이닝이 지원되어 `isEqualTo`와 같이 메소드를 이어서 사용할 수 있습니다.
|
||||
>
|
||||
>**2. isEqualTo**
|
||||
>- `assertj`의 동등 비교 메소드입니다.
|
||||
>- `assertThat`에 있는 값과 `isEqualTo`의 값을 비교해서 같을 때만 성공입니다.
|
||||
>
|
||||
>**Junit과 비교하여 assertj의 장점은 다음과 같습니다.**
|
||||
>- `CoreMatchers`와 달리 추가적으로 라이브러리가 필요하지 않습니다.
|
||||
> - `JUnit`의 `assertThat`을 쓰게 되면 `is()`와 같이 `CoreMatchers` 라이브러리가 필요합니다.
|
||||
>- 자동완성이 좀 더 확실하게 지원됩니다.
|
||||
> - IDE에서는 `CoreMatchers`와 같은 `Matcher` 라이브러리의 자동완성 지원이 약합니다.
|
||||
|
||||
>#### HelloResponseDto
|
||||
>```java
|
||||
>@GetMapping("/hello/dto")
|
||||
> public HelloResponseDto helloDto(@RequestParam("name") String name, @RequestParam("amount") int amount) { // 1.
|
||||
> return new HelloResponseDto(name, amount);
|
||||
> }
|
||||
>```
|
||||
>
|
||||
>**1. @RequestParam**
|
||||
>- 외부에서 API로 넘긴 파라미터를 가져오는 어노테이션입니다.
|
||||
>- 여기서는 외부에서 `name (@RequestParam("name"))`이란 이름으로 넘긴 파라미터를 메소드 파라미터 `name (String name)`에 저장하게 됩니다.
|
||||
|
||||
`name`과 `amount`는 API를 호출하는 곳에서 넘겨준 값들입니다. 추가된 API를 테스트하는 코드를 `HelloControllerTest`에 추가합니다.
|
||||
|
||||
>#### HelloResponseDtoTest
|
||||
>```java
|
||||
>@Test
|
||||
> public void helloDto가_리턴된다() throws Exception {
|
||||
> String name = "hello";
|
||||
> int amount = 1000;
|
||||
>
|
||||
> mvc.perform(get("/hello/dto")
|
||||
> .param("name", name) // 1.
|
||||
> .param("amount", String.valueOf(amount)))
|
||||
> .andExpect(status().isOk())
|
||||
> .andExpect(jsonPath("$.name", is(name))) // 2.
|
||||
> .andExpect(jsonPath("$.amount", is(amount)));
|
||||
> }
|
||||
>```
|
||||
>
|
||||
>**1. param**
|
||||
>- API 테스트할 때 사용될 요청 파라미터를 설정합니다.
|
||||
>- 단, 값은 `String`만 허용됩니다.
|
||||
>- 그래서 숫자/날짜 등의 데이터도 등록할 때는 문자열로 변경해야만 가능합니다.
|
||||
>
|
||||
>**2. jsonPath**
|
||||
>- `JSON` 응답값을 필드별로 검증할 수 있는 메소드입니다.
|
||||
>- `$`를 기준으로 필드명을 명시합니다.
|
||||
>- 여기서는 `name`과 `amount`를 검증하니 `$.name`, `$.amount`로 검증합니다.
|
||||
|
||||
---
|
||||
841
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter3.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# Chapter3. 스프링 부트에서 JPA로 데이터베이스 다뤄보자
|
||||
|
||||
---
|
||||
|
||||
## 3.1 JPA 소개
|
||||
|
||||
현대의 웹 애플리케이션에서 관계형 데이터베이스(`RDB`, `Relational Database`)는 빠질 수 없는 요소입니다. `Oracle`, `MySQL`, `MSSQL` 등을 쓰지 않는 웹 애플리케이션은 거의 없습니다. 그러다 보니 **객체를 관계형 데이터베이스에서 관리**하는 것이 무엇보다 중요합니다. 그러나 개발자가 아무리 자바 클래스를 아릅답게 설계해도, SQL을 통해야만 데이터베이스에 저장하고 조회할 수 있습니다. 결국, 관계형 데이터베이스를 사용해야만 하는 상황에서 **SQL은 피할 수 없습니다.**
|
||||
|
||||
이 반복적인 SQL을 단순하게 반복해야만 하는 문제 외에도 **패러다임 불일치** 문제도 있습니다. 관계형 데이터베이스는 **어떻게 데이터를 저장**할지에 초점이 맞춰진 기술입니다. 반대로 객체지향 프로그래밍 언어는 메시지를 기반으로 **기능과 속성을 한 곳에서 관리**하는 기술입니다. 이 둘은 이미 사상부터 다른 시작점에서 출발했습니다. 관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생합니다. 이를 **패러다임 불일치**라고 합니다.
|
||||
|
||||
`상속`, `1 : N` 등 다양한 객체 모델링을 데이터베이스로는 구현할 수 없습니다. 그러다 보니 웹 어플리케이션 개발은 점점 **데이터베이스 모델링**에만 집중하게 됩니다. `JPA`는 이런 문제점을 해결하기 위해 등장하게 됩니다.
|
||||
|
||||
서로 지향하는 바가 다른 2개 영역(객체지향 프로그래밍 언어와 관계형 데이터베이스)을 **중간에서 패러다임 일치**를 시켜주기 위한 기술입니다. 즉, 개발자는 **객체지향적으로 프로그래밍을 하고**, `JAP`가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행합니다. 개발자는 항상 객체 지향적으로 코드를 표현할 수 있으니 더는 **SQL에 종속적인 개발을 하지 않아도 됩니다.**
|
||||
|
||||
#### Spring Data JPA
|
||||
|
||||
`JPA`는 인터페이스로서 자바 표준명세서입니다.인터페이스인 `JPA`를 사용하기 위해서는 구현체가 필요합니다. 대표적으로 `Hibernate`, `EclipseLink` 등이 있습니다. 하지만 `Spring`에서 `JPA`를 사용할 때는 이 구현체들을 직접 다루진 않습니다. 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 `Spring Data JPA`라는 모듈을 이용하여 `JPA` 기술을 다룹니다. 이들의 관계를 보면 다음과 같습니다.
|
||||
|
||||
- `JPA <- Hibernate <- Spring Data JPA`
|
||||
|
||||
`Hibernate`를 쓰는 것과 `Spring Data JPA`를 쓰는 것 사이에는 큰 차이가 없습니다. 그럼에도 스프링 진영에서는 `Spring Data JPA`를 개발했고, 이를 권장하고 있습니다. 이렇게 한 단계 더 감싸놓은 `Spring Data JPA`가 등장한 이유는 크게 두 가지가 있습니다.
|
||||
|
||||
- **구현체 교체의 용이성**
|
||||
- **저장소 교체의 용이성**
|
||||
|
||||
먼저 '구현체 교체의 용이성'이란 **`Hibernate` 외에 다른 구현체로 쉽게 교체하기 위함**입니다. `Hibernate`가 언젠간 수명을 다해서 새로운 `JPA` 구현체가 대세로 떠오를 때, `Spring Data JPA`를 쓰는 중이라면 아주 쉽게 교체할 수 있습니다. `Spring Data JPA` 내부에서 구현체 매핑을 지원해주기 때문입니다.
|
||||
다음으로 '저장소 교체의 용이성'이란 **관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함**입니다. `Spring Data`의 하위 프로젝트들은 기본적인 **CRUD의 인터페이스가 같기**때문에, 간단하게 **의존성만 교체**함으로써 저장소의 교체가 가능합니다. 즉, `Spring Data JPA`, `Spring Data Redis`, `Spring Data MongoDB` 등등 `Spring Data`의 하위 프로젝트들은 `save()`, `findAll()`, `findOne()` 등을 인터페이스로 갖고 있습니다. 그러다 보니 저장소가 교체되어도 기본적인 기능은 변경할 것이 없습니다. 이런 장점들로 인해 `Spring Data` 프로젝트를 권장하고 있습니다.
|
||||
|
||||
#### 실무에서 JPA
|
||||
|
||||
실무에서 `JPA`를 사용하지 못하는 가장 큰 이유로 **높은 러닝 커브**를 이야기합니다. `JPA`를 잘 쓰려면 **객체지향 프로그래밍과 관계형 데이터베이스**를 둘 다 이해해야 합니다. 하지만 그만큼 `JPA`를 사용해서 얻는 보상은 큽니다. 가장 먼저 `CRUD` 쿼리를 직접 작성할 필요가 없습니다. 또한, 부모-자식 관계 표현, `1 : N` 관계 표현, 상태와 행위를 한 곳에서 관리하는 등 객체지향 프로그래밍을 쉽게 할 수 있습니다.
|
||||
|
||||
#### 요구사항 분석
|
||||
앞으로 만들 게시판의 요구사항입니다.
|
||||
|
||||
- 게시판 기능
|
||||
- 게시글 조회
|
||||
- 게시글 등록
|
||||
- 게시글 수정
|
||||
- 게시글 삭제
|
||||
|
||||
- 회원기능
|
||||
- 구글 / 네이버 로그인
|
||||
- 로그인한 사용자 글 작성 권한
|
||||
- 본인 작성 글에 대한 권한 관리
|
||||
|
||||
---
|
||||
|
||||
## 3.2 프로젝트에 Spring Data JPA 적용하기
|
||||
먼저 `build.gradle`에 다음과 같이 `org.springframework.boot:spring-boot-starter-data-jpa`와 `com.h2database:h2` 의존성들을 등록합니다.
|
||||
|
||||
```java
|
||||
dependencies {
|
||||
compile('org.springframework.boot:spring-boot-starter-web')
|
||||
compile('org.projectlombok:lombok')
|
||||
compile('org.springframework.boot:spring-boot-starter-data-jpa') // 1.
|
||||
compile('com.h2database:h2') // 2.
|
||||
testCompile('org.springframework.boot:spring-boot-starter-test')
|
||||
}
|
||||
```
|
||||
**1. spring-boot-starter-data-jpa**
|
||||
- 스프링 부트용 `Spring Data JPA` 추상화 라이브러리입니다.
|
||||
- 스프링 부트 버전에 맞춰 자동으로 `JPA`관련 라이브러리들의 버전을 관리해 줍니다.
|
||||
|
||||
**2. h2**
|
||||
- 인메모리 관계형 데이터베이스입니다.
|
||||
- 별도의 설치가 필요 없이 프로젝트 의존성만으로 관리할 수 있습니다.
|
||||
- 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됩니다.
|
||||
|
||||
도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역이라고 생각하면 됩니다. 그간 `xml`에 쿼리를 담고, 클래스는 오로지 쿼리의 결과만 담던 일들이 모두 도메인 클래스라고 불리는 곳에서 해결됩니다.
|
||||
|
||||
`domain` 패키지에 **posts 패키지와 Posts 클래스**를 만듭니다.
|
||||
|
||||
`Posts` 클래스의 코드는 다음과 같습니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.domain.posts;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
|
||||
@Getter // 6.
|
||||
@NoArgsConstructor // 5.
|
||||
@Entity // 1.
|
||||
public class Posts {
|
||||
|
||||
@Id // 2.
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY) // 3.
|
||||
private Long id;
|
||||
|
||||
@Column(length = 500, nullable = false) // 4.
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String content;
|
||||
|
||||
private String author;
|
||||
|
||||
@Builder // 7.
|
||||
public Posts(String title, String content, String author) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.author = author;
|
||||
}
|
||||
}
|
||||
```
|
||||
여기서 `Posts` 클래스는 실제 `DB`의 테이블과 매칭될 클래스이며 보통 `Entity` 클래스라고도 합니다.`JPA`를 사용하시면 `DB` 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 `Entity` 클래스의 수정을 통해 작업합니다.
|
||||
|
||||
**`Posts` 클래스에는 `JPA`에서 제공하는 어노테이션들이 몇 개 있습니다.**
|
||||
|
||||
**1. @Entity**
|
||||
- 테이블과 링크될 클래스임을 나타냅니다.
|
||||
- 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 칭합니다.
|
||||
- ex)SalesManager.java -> sales_manager table
|
||||
|
||||
**2. @Id**
|
||||
- 해당 테이블의 `PK` 필드를 나타냅니다.
|
||||
|
||||
**3. @GeneratedValue**
|
||||
- `PK`의 생성 규칙을 나타냅니다.
|
||||
- 스프링 부트 2.0 에서는 `GenerationType.IDENTITY` 옵션을 추가해야만 `auto_increment`가 됩니다.
|
||||
|
||||
**4. @Column**
|
||||
- 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 럼이 됩니다.
|
||||
- 사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용합니다.
|
||||
- 문자열의 경우 `VARCHAR(255)`가 기본값인데, 사이즈를 500으로 늘리고 싶거나(`ex: title`), 타입을 `TEXT`로 변경하고 싶거나(`ex: content`) 등의 경우에 사용됩니다.
|
||||
|
||||
**5. @NoArgsConstructor**
|
||||
- 기본 생성자 자동 추가
|
||||
- `public Posts(){}`와 같은 효과
|
||||
|
||||
**6. @Getter**
|
||||
- 클래스 내 모든 필드의 `Getter` 메소드를 자동생성
|
||||
|
||||
**7. @Builder**
|
||||
- 해당 클래스의 빌더 패턴 클래스를 생성
|
||||
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
|
||||
|
||||
이 `Posts` 클래스에는 **Setter 메소드가 없습니다.** 자바빈 규약을 생각하면서 **`getter/setter`를 무작정 생성**하는 경우가 있습니다. 이렇게 되면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수가 없어, 차후 기능 변경 시 정말 복잡해집니다. 그래서 **`Entity` 클래스에서는 절대 `Setter` 메소드를 만들지 않습니다.** 대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 합니다. 예를 들어 주문 취소 메소드를 만든다고 가정하면 다음 코드로 비교해보면 됩니다.
|
||||
|
||||
**잘못된 사용 예**
|
||||
```java
|
||||
public class Order{
|
||||
public void setStatus(boolean status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
public void 주문서비스의_취소이벤트() {
|
||||
order.setStatus(false);
|
||||
}
|
||||
```
|
||||
**올바른 사용 예**
|
||||
```java
|
||||
public class Order{
|
||||
public void cancelOrder() {
|
||||
this.status = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void 주문서비스의_취소이벤트() {
|
||||
order.cancelOrder();
|
||||
}
|
||||
```
|
||||
|
||||
그렇다면 **Setter가 없는 이 상황에서 어떻게 값을 채워 DB에 삽입**^insert^해야 할까요?
|
||||
|
||||
기본적인 구조는 **생성자를 통해** 최종값을 채운 후 `DB`에 삽입^insert^하는 것이며, 값 변경이 필요한 경우 **해당 이벤트에 맞는 public 메소드를 호출**하여 변경하는 것을 전제로 합니다.
|
||||
여기서는 생성자 대신에 **@Builder를 통해 제공되는 빌더 클래스**를 사용합니다. 생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같습니다. 다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수가 없습니다. 예를 들어 다음과 같은 생성자가 있다면 개발자가 `new Example(b, a)`처럼 **a와 b의 위치를 변경해도 코드를 실행하기 전까지는 문제를 찾을 수가 없습니다.**
|
||||
|
||||
```java
|
||||
public Example(String a, String b) {
|
||||
this.a = a;
|
||||
this.b = b;
|
||||
}
|
||||
```
|
||||
하지만 빌더를 사용하게 되면 다음과 같이 **어느 필드에 어떤 값을 채워야 할지** 명확하게 인지할 수 있습니다.
|
||||
|
||||
```java
|
||||
Example.builder()
|
||||
.a(a)
|
||||
.b(b)
|
||||
.build();
|
||||
```
|
||||
|
||||
`Posts` 클래스 생성이 끝났다면, `Posts` 클래스로 `Database`를 접근하게 해줄 `JpaRepository`를 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.domain.posts;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PostsRepository extends JpaRepository<Posts, Long> {
|
||||
|
||||
}
|
||||
```
|
||||
보통 `ibatis`나 `Mybatis` 등에서 `Dao`라고 불리는 `DB Layer` 접근자입니다. `JPA`에선 `Repository`라고 부르며 **인터페이스**로 생성합니다. 단순히 인터페이스를 생성 후, `JpaRepository<Entity 클래스, PK 타입>`를 상속하면 기본적인 `CRUD` 메소드가 자동으로 생성됩니다.
|
||||
**@Repository를 추가할 필요도 없습니다.** 여기서 주의하실 점은 **Entity 클래스와 기본 Entity Repository는 함께 위치**해야 한다는 점입니다. 둘은 아주 밀접한 관계이고, `Entity` 클래스는 **기본 Repository 없이는 제대로 역할을 할 수가 없습니다.**
|
||||
|
||||
나중에 프로젝트 규모가 커져 도메인별로 프로젝트를 분리해야 한다면 이때 `Entity` 클래스와 기본 `Repository`는 함께 움직여야 하므로 **도메인 패키지에서 함께 관리**합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3.3 Spring Data JPA 테스트 코드 작성하기
|
||||
|
||||
`test` 디렉토리에 `domain.posts` 패키지를 생성하고, 테스트 클래스는 `PostsRepositoryTest`란 이름으로 생성합니다. `PostsRepositoryTest`에서는 다음과 같이 `save`, `findAll` 기능을 테스트합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.domain.posts;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest
|
||||
public class PostsRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
PostsRepository postsRepository;
|
||||
|
||||
@After // 1.
|
||||
public void cleanup() {
|
||||
postsRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void 게시글저장_불러오기() {
|
||||
// given
|
||||
String title = "테스트 게시글";
|
||||
String content = "테스트 본문";
|
||||
|
||||
postsRepository.save(Posts.builder() // 2.
|
||||
.title(title)
|
||||
.content(content)
|
||||
.author("banjjoknim")
|
||||
.build());
|
||||
|
||||
// when
|
||||
List<Posts> postsList = postsRepository.findAll(); // 3.
|
||||
|
||||
// then
|
||||
Posts posts = postsList.get(0);
|
||||
assertThat(posts.getTitle()).isEqualTo(title);
|
||||
assertThat(posts.getContent()).isEqualTo(content);
|
||||
}
|
||||
}
|
||||
```
|
||||
**1. @After**
|
||||
- `JUnit`에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
|
||||
- 보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용합니다.
|
||||
- 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 `H2`에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트가 실패할 수 있습니다.
|
||||
|
||||
**2. postsRepository.save**
|
||||
- 테이블 `posts`에 `insert/update` 쿼리를 실행합니다.
|
||||
- `id` 값이 있다면 `update`가, 없다면 `insert` 쿼리가 실행됩니다.
|
||||
|
||||
**3. postsRepository.findAll**
|
||||
- 테이블 `posts`에 있는 모든 데이터를 조회해오는 메소드입니다.
|
||||
|
||||
별다른 설정 없이 `@SpringBootTest`를 사용할 경우 **H2 데이터베이스**를 자동으로 실행해 줍니다. 이 테스트 역시 실행할 경우 `H2`가 자동으로 실행됩니다.
|
||||
|
||||
여기서 **실제로 실행된 쿼리는 어떤 형태일까?** 라는 의문이 생길 수 있습니다. 쿼리 로그를 `ON/OFF`할 수 있는 설정이 있습니다. 다만, 이런 설정들을 `Java`클래스로 구현할 수 있으나 스프링 부트에서는 `application.properties`, `application.yml` 등의 파일로 **한 줄의 코드로 설정**할 수 있도록 지원하고 권장하니 이를 사용합니다.
|
||||
|
||||
`src/main/resources` 디렉토리 아래에 `application.properties` 파일을 생성합니다.
|
||||
|
||||
옵션은 다음과 같습니다. 옵션이 추가되었다면 다시 테스트를 수행해봅니다.
|
||||
|
||||
`spring.jpa.show_sql=true`
|
||||
|
||||
로그를 살펴보면 `create table` 쿼리를 보면 `id bigint generated by default as identity`라는 옵션으로 생성됩니다. 이는 `H2` 쿼리 문법이 적용되었기 때문입니다. `H2`는`MySQL`의 쿼리를 수행해도 정상적으로 작동하기 때문에 이후 디버깅을 위해서 **출력되는 쿼리 로그를 MySQL 버전**으로 변경해 보겠습니다.
|
||||
|
||||
이 옵션 역시 `application.properties`에서 설정이 가능합니다. 다음 코드를 추가합니다.
|
||||
|
||||
`spring.jap.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect`
|
||||
|
||||
추가했다면 다시 테스트 코드를 수행해 봅니다.
|
||||
|
||||
---
|
||||
|
||||
## 3.4 등록/수정/조회 API 만들기
|
||||
|
||||
API를 만들기 위해 총 3개의 클래스가 필요합니다.
|
||||
- **`Request` 데이터를 받을 `Dto`**
|
||||
- **API 요청을 받을 `Controller`**
|
||||
- **트랜잭션, 도메인 기능 간의 순서를 보장하는 `Service`**
|
||||
|
||||
여기서 많은 분들이 오해하고 계신 것이, **`Service`에서 비지니스 로직을 처리**해야 한다는 것입니다. 하지만, 전혀 그렇지 않습니다. `Service`는 **트랜잭션, 도메인 간 순서 보장**의 역할만 합니다.
|
||||
|
||||
**Spring 웹 계층**
|
||||
|
||||

|
||||
|
||||
- **Web Layer**
|
||||
- 흔히 사용하는 컨트롤러(`@Controller`)와 `JSP/Freemarker` 등의 뷰 템플릿 영역입니다.
|
||||
- 이외에도 필터(`@Filter`), 인터셉터, 컨트롤러 어드바이스(`@ControllerAdvice`) 등 **외부 요청과 응답**에 대한 전반적인 영역을 이야기합니다.
|
||||
|
||||
- **Service Layer**
|
||||
- `@Service`에 사용되는 서비스 영역입니다.
|
||||
- 일반적으로 `Controller`와 `Dao`의 중간 영역에서 사용됩니다.
|
||||
- `@Transactional`이 사용되어야 하는 영역이기도 합니다.
|
||||
|
||||
- **Repository Layer**
|
||||
- **Database**와 같이 데이터 저장소에 접근하는 영역입니다.
|
||||
- 기존에 개발하셨던 분들이라면 `Dao(Data Access Object)` 영역으로 이해하시면 쉬울 것입니다.
|
||||
|
||||
- **Dtos**
|
||||
- `Dto(Data Transfer Object)`는 **계층 간에 데이터 교환을 위한 객체**를 이야기하며 `Dtos`는 이들의 영역을 이야기합니다.
|
||||
- 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 `Repository Layer`에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.
|
||||
|
||||
- **Domain Model**
|
||||
- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
|
||||
- 이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있습니다.
|
||||
- `@Entity`를 사용해보신 분들은 `@Entity`가 사용된 영역 역시 도메인 모델이라고 이해해주시면 됩니다.
|
||||
- 다만, 무조건 데이터베이스의 테이블과 관계가 있어야 하는 것은 아닙니다.
|
||||
- `VO`처럼 값 객체들도 이 영역에 해당하기 때문입니다.
|
||||
|
||||
`Web`^Controller^, `Service`, `Repository`, `Dto`, `Domain` 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 바로 **Domain** 입니다. 기존에 서비스로 처리하던 방식을 **트랜잭션 스크립트**라고 합니다. 주문 취소 로직을 작성한다면 다음과 같습니다.
|
||||
|
||||
**슈도 코드**
|
||||
```java
|
||||
@Transactional
|
||||
public Order cancelOrder(int orderId) {
|
||||
|
||||
1) 데이터베이스로부터 주문정보 (Orders), 결제정보 (Billing), 배송정보 (Delivery) 조회
|
||||
|
||||
2) 배송 취소를 해야 하는지 확인
|
||||
|
||||
3) if(배송 중이라면) {
|
||||
배송 취소로 변경
|
||||
}
|
||||
|
||||
4) 각 테이블에 취소 상태 Update
|
||||
}
|
||||
```
|
||||
**실제 코드**
|
||||
```java
|
||||
@Transactional
|
||||
public Order cancelOrder(int orderId) {
|
||||
|
||||
// 1)
|
||||
OrdersDto order = ordersDao.selectOrders(orderId);
|
||||
BillingDto billing = billingDao.selectBilling(orderId);
|
||||
DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
|
||||
|
||||
// 2)
|
||||
String deliveryStatus = delivery.getStatus();
|
||||
|
||||
// 3)
|
||||
if("IN_PROGRESS".equals(deliveryStatus)) {
|
||||
delivery.setStatus("CANCEL");
|
||||
deliveryDao.update(delivery);
|
||||
}
|
||||
|
||||
// 4)
|
||||
order.setStatus("CANCEL");
|
||||
ordersDao.update(order);
|
||||
|
||||
billing.setStatus("CANCEL");
|
||||
deliveryDao.update(billing);
|
||||
|
||||
return order;
|
||||
}
|
||||
```
|
||||
모든 로직이 **서비스 클래스 내부에서 처리됩니다.** 그러다 보니 **서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리** 역할만 하게 됩니다. 반면 도메인 모델에서 처리할 경우 다음과 같은 코드가 될 수 있습니다.
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public Order cancelOrder(int orderId) {
|
||||
|
||||
// 1)
|
||||
Orders order = ordersRepository.findById(orderId);
|
||||
Billing billing = billingRepository.findByOrderId(orderId);
|
||||
Delivery delivery = deliveryRepository.findByOrderId(orderId);
|
||||
|
||||
// 2 - 3)
|
||||
delivery.cancel();
|
||||
|
||||
// 4)
|
||||
order.cancel();
|
||||
billing.cancel();
|
||||
|
||||
return order;
|
||||
}
|
||||
```
|
||||
`order`, `billing`, `delivery`가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 **트랜잭션과 도메인 간의 순서만 보장**해 줍니다. 여기서는 계속 이렇게 **도메인 모델을** 다루고 코드를 작성합니다.
|
||||
|
||||
그럼 등록, 수정, 삭제 기능을 만들어 보겠습니다. `PostsApiController`를 `web` 패키지에, `PostsSaveRequestDto`를 `web.dto` 패키지에, `PostsService`를 `service.posts` 패키지에 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import com.banjjoknim.book.springboot.service.PostsService;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsSaveRequestDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class PostsApiController {
|
||||
|
||||
private final PostsService postsService;
|
||||
|
||||
@PostMapping("/api/v1/posts")
|
||||
public Long save(@RequestBody PostsSaveRequestDto requestDto){
|
||||
return postsService.save(requestDto);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.service;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.PostsRepository;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsSaveRequestDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class PostsService {
|
||||
private final PostsRepository postsRepository;
|
||||
|
||||
@Transactional
|
||||
public Long save(PostsSaveRequestDto requestDto) {
|
||||
return postsRepository.save(requestDto.toEntity()).getId();
|
||||
}
|
||||
}
|
||||
```
|
||||
스프링에선 `Bean`을 주입받는 방식들이 다음과 같습니다.
|
||||
- **@Autowired**
|
||||
- **setter**
|
||||
- **생성자**
|
||||
|
||||
이 중 가장 권장하는 방식이 **생성자로 주입**받는 방식입니다(**`@Autowired`는 권장하지 않습니다**). 즉 **생성자로** `Bean` 객체를 받도록 하면 `@Autowired`와 동일한 효과를 볼 수 있다는 것입니다. 여기서 생성자는 `@RequiredArgsConstructor`에서 해결해 줍니다. **final이 선언된 모든 필드**를 인자값으로 하는 생성자를 롬복의 `@RequiredArgsConstructor`가 대신 생성해 준 것입니다. 생성자를 직접 안 쓰고 롬복 어노테이션을 사용한 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함입니다.
|
||||
|
||||
`Controller`와 `Service`에서 사용할 `Dto` 클래스를 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class PostsSaveRequestDto {
|
||||
private String title;
|
||||
private String content;
|
||||
private String author;
|
||||
|
||||
@Builder
|
||||
public PostsSaveRequestDto(String title, String content, String author) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public Posts toEntity() {
|
||||
return Posts.builder()
|
||||
.title(title)
|
||||
.content(content)
|
||||
.author(author)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
여기서 `Entity` 클래스와 거의 유사한 형태임에도 `Dto` 클래스를 추가로 생성했습니다. 하지만, 절대로 **Entity 클래스를 Request/Response 클래스로 사용해서는 안 됩니다.** `Entity` 클래스는 **떼이터베이스와 맞닿은 핵심 클래스**입니다. `Entity` 클래스를 기준으로 테이블이 생성되고, 스키마가 변경됩니다. 화면 변경은 아주 사소한 기능 변경인데, 이를 위해 테이블과 연결된 `Entity` 클래스를 변경하는 것은 너무 큰 변경입니다.
|
||||
|
||||
수 많은 서비스 클래스나 비즈니스 로직들이 `Entity` 클래스를 기준으로 동작합니다. `Entity` 클래스가 변경되면 여러 클래스에 영향을 끼치지만, `Request`와 `Response`용 `Dto`는 `View`를 위한 클래스라 정말 자주 변경이 필요합니다.
|
||||
|
||||
`View Layer`와 `DB Layer`의 역할 분리를 철저하게 하는 게 좋습니다. 실제로 `Controller`에서 **결괏값으로 여러 테이블을 조인해서 줘야 할 경우**가 빈번하므로 `Entity` 클래스만으로 표현하기 어려운 경우가 많습니다.
|
||||
|
||||
꼭 `Entity` 클래스와 `Controller`에서 쓸 `Dto`는 분리해서 사용해야 합니다. 등록 기능의 코드가 완성되었으니, 테스트 코드로 검증해 보겠습니다. 테스트 패키지 중 `web` 패키지에 `PostApiControllerTest`를 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import com.banjjoknim.book.springboot.domain.posts.PostsRepository;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsSaveRequestDto;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
public class PostsApiControllerTest {
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private PostsRepository postsRepository;
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
postsRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void Posts_등록된다() {
|
||||
// given
|
||||
String title = "title";
|
||||
String content = "content";
|
||||
PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
|
||||
.title(title)
|
||||
.content(content)
|
||||
.author("author")
|
||||
.build();
|
||||
|
||||
String url = "http://localhost:" + port + "/api/v1/posts";
|
||||
|
||||
// when
|
||||
ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);
|
||||
|
||||
// then
|
||||
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(responseEntity.getBody()).isGreaterThan(0L);
|
||||
|
||||
List<Posts> all = postsRepository.findAll();
|
||||
assertThat(all.get(0).getTitle()).isEqualTo(title);
|
||||
assertThat(all.get(0).getContent()).isEqualTo(content);
|
||||
}
|
||||
}
|
||||
```
|
||||
`Api Controller`를 테스트하는데 `HelloController`와 달리 `@WebMvcTest`를 사용하지 않았습니다. **@WebMvcTest의 경우 JPA 기능이 작동하지 않기** 때문인데, `Controller`와 `ControllerAdvice` 등 **외부 연동과 관련된 부분만** 활성화되니 지금 같이 `JPA` 기능까지 한번에 테스트할 때는 `@SpringBootTest`와 `TestRestTemplate`을 사용하면 됩니다. 테스트를 수행하보면 `WebEnvironment.RANDOM_PORT`로 인한 랜덤 포트 실행과 `insert` 쿼리가 실행된 것 모두 확인할 수 있습니다. 등록 기능을 완성했으니 수정/조회 기능도 만들어 보겠습니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import com.banjjoknim.book.springboot.service.PostsService;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsSaveRequestDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class PostsApiController {
|
||||
|
||||
private final PostsService postsService;
|
||||
|
||||
@PostMapping("/api/v1/posts")
|
||||
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
|
||||
return postsService.save(requestDto);
|
||||
}
|
||||
|
||||
@PutMapping("/api/v1/posts/{id}")
|
||||
public Long update(@PathVariable Long id, @RequestBody PostsSaveRequestDto requestDto) {
|
||||
return postsService.update(id, requestDto);
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/posts/{id}")
|
||||
public PostsResponseDto findById(@PathVariable Long id) {
|
||||
return postsService.findById(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class PostsResponseDto {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private String author;
|
||||
|
||||
public PostsResponseDto(Posts entity) {
|
||||
this.id = entity.getId();
|
||||
this.title = entity.getTitle();
|
||||
this.content = entity.getContent();
|
||||
this.author = entity.getAuthor();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
`PostsResponseDto`는 **Entity의 필드 중 일부만 사용**하므로 생성자로 `Entity`를 받아 필드에 값을 넣습니다. 굳이 모든 필드를 가진 생성자가 필요하진 않으므로 `Dto`는 `Entity`를 받아 처리합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class PostsUpdateRequestDto {
|
||||
private String title;
|
||||
private String content;
|
||||
|
||||
@Builder
|
||||
public PostsUpdateRequestDto(String title, String content) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public class Posts {
|
||||
...
|
||||
|
||||
public void update(String title, String content) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public class PostsService {
|
||||
...
|
||||
|
||||
@Transactional
|
||||
public Long update(Long id, PostsUpdateRequestDto requestDto) {
|
||||
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
|
||||
|
||||
posts.update(requestDto.getTitle(), requestDto.getContent());
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public PostsResponseDto findById(Long id) {
|
||||
Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
|
||||
|
||||
return new PostsResponseDto(entity);
|
||||
}
|
||||
}
|
||||
```
|
||||
여기서 `update` 기능에서 데이터베이스에 **쿼리를 날리는 부분이 없습니다.** 이게 가능한 이유는 `JPA`의 **영속성 컨텍스트** 때문입니다.
|
||||
|
||||
영속성 컨텍스트란, **앤티티를 영구 저장하는 환경**입니다. 일종의 논리적 개념이라고 보시면 되며, `JPA`의 핵심 내용은 **앤티티가 영속성 컨텐스트에 포함되어 있냐 아니냐**로 갈립니다. `JPA`의 앤티티 매니저가 활성화된 상태로(`Spring Data Jpa`를 쓴다면 기본 옵션) **트랜잭션 안에서 데이터베이스에서 데이터를 가져오면** 이 데이터는 영속성 컨텍스트가 유지된 상태입니다. 이 상태에서 해당 데이터의 값을 변경하면 **트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영**합니다. 즉, `Entity` 객체의 값만 변경하면 별도로 **Update 쿼리를 날릴 필요가 없다**는 것입니다. 이 개념을 **더티 체킹**이라고 합니다.
|
||||
|
||||
정상적으로 `Update` 쿼리를 수행하는지 테스트 코드로 확인해 보겠습니다. 등록기능과 마찬가지로 `PostApiControllerTest`에 추가하겠습니다.
|
||||
|
||||
```java
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
public class PostsApiControllerTest {
|
||||
@Test
|
||||
public void Posts_수정된다() {
|
||||
|
||||
// given
|
||||
Posts savedPosts = postsRepository.save(Posts.builder()
|
||||
.title("title")
|
||||
.content("content")
|
||||
.author("author")
|
||||
.build());
|
||||
|
||||
Long updateId = savedPosts.getId();
|
||||
String expectedTitle = "title2";
|
||||
String expectedContent = "content2";
|
||||
|
||||
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
|
||||
.title(expectedTitle)
|
||||
.content(expectedContent)
|
||||
.build();
|
||||
|
||||
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
|
||||
|
||||
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
|
||||
|
||||
// when
|
||||
ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
|
||||
|
||||
// then
|
||||
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(responseEntity.getBody()).isGreaterThan(0L);
|
||||
|
||||
List<Posts> all = postsRepository.findAll();
|
||||
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
|
||||
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
테스트를 수행하보면 `update` 쿼리가 수행되는 것을 확인할 수 있습니다.
|
||||
|
||||
**조회 기능은 실제로 톰캣을 실행**해서 확인해 보겠습니다. 앞서 언급한 대로 로컬 환경에선 데이터베이스로 `H2`를 사용합니다. 메모리에서 실행하기 때문에 **직접 접근하려면 웹 콘솔**을 사용해야만 합니다. 먼저 웹 콘솔 옵션을 활성화합니다. `application.properties`에 다음과 같이 옵션을 추가합니다.
|
||||
|
||||
`spring.h2.console.enabled=true`
|
||||
|
||||
추가한 뒤 `Application` 클래스의 `main` 메소드를 실행합니다. 정상적으로 실행됐다면 톰캣이 8080 포트로 실행됩니다. 여기서 웹 브라우저에 `http://localhost:8080/h2-console` 로 접속하면 웹 콘솔 화면이 등장합니다. 이때 `JDBC URL`이 `jdbc:h2:mem:testdb`로 되어 있지 않다면 똑같이 작성한 뒤 `connect` 버튼을 클릭하면 현재 프로젝트의 `H2`를 관리할 수 있는 관리 페이지로 이동합니다. 이동 후에 직접 쿼리를 실행하여 매핑된 `URL`을 통해 조회할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 JPA Auditing으로 생성시간/수정시간 자동화하기
|
||||
|
||||
보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함합니다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문입니다.
|
||||
|
||||
```java
|
||||
// 성성일 추가 코드 예제
|
||||
public void savePosts() {
|
||||
...
|
||||
posts.setCreateDate(new LocalDate());
|
||||
postsRepository.save(posts);
|
||||
...
|
||||
}
|
||||
```
|
||||
이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다고 생각하면 어마어마하게 귀찮고 코드가 지저분해집니다. 그래서 이 문제를 해결하고자 `JPA Auditing`을 사용합니다.
|
||||
|
||||
#### LocalDate 사용
|
||||
`Java8`부터 `LocalDate`와 `LocalDateTime`이 등장했습니다. `Java`의 기본 날짜 타입인 `Date`의 문제점을 제대로 고친 타입이라 `Java8`일 경우 무조건 써야 한다고 생각하면 됩니다.
|
||||
|
||||
또한 `LocalDate`와 `LocalDateTime`이 데이터베이스에 제대로 매핑되지 않는 이슈가 `Hibernate 5.2.10` 버전에서 해결되었기 때문에, 스프링 부트 `2.x` 버전을 사용하면 기본적으로 해당 버전을 사용중이라 별다른 설정 없이 바로 적용하면 됩니다.
|
||||
|
||||
`domain` 패키지에 `BaseTimeEntity` 클래스를 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.domain;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.MappedSuperclass;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@MappedSuperclass // 1.
|
||||
@EntityListeners(AuditingEntityListener.class) // 2.
|
||||
public class BaseTimeEntity {
|
||||
|
||||
@CreatedDate // 3.
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
@LastModifiedDate // 4.
|
||||
private LocalDateTime modifiedDate;
|
||||
}
|
||||
```
|
||||
`BaseTimeEntity` 클래스는 모든 `Entity`의 상위 클래스가 되어 **Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할**입니다.
|
||||
|
||||
**1. @MappedSuperclass**
|
||||
- `JPA Entity` 클래스들이 `BaseTimeEntity`을 상속할 경우 필드들(`createdDate`, `modifiedDate`)도 칼럼으로 인식하도록 합니다.
|
||||
|
||||
**2. @EntityListeners(AuditingEntityListener.class)**
|
||||
- `BaseTimeEntity` 클래스에 `Auditing` 기능을 포함시킵니다.
|
||||
|
||||
**3. @CreatedDate**
|
||||
- `Entity`가 생성되어 저장될 때 시간이 자동 저장됩니다.
|
||||
|
||||
**4. @LastModifiedDate**
|
||||
- 조회한 `Entity`의 값을 변경할 때 시간이 자동 저장됩니다.
|
||||
|
||||
그리고 `Posts` 클래스가 `BaseTimeEntity`를 상속받도록 변경합니다.
|
||||
|
||||
```java
|
||||
public class Posts extends BaseTimeEntity {
|
||||
}
|
||||
```
|
||||
|
||||
마지막으로 `JPA Auditing` 어노테이션들을 모두 활성화할 수 있도록 `Application` 클래스에 활성화 어노테이션 하나를 추가합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
@EnableJpaAuditing // JPA Auditing 활성화
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### JPA Auditing 테스트 코드 작성하기
|
||||
`PostsRepositoryTest` 클래스에 테스트 메소드를 하나 더 추가합니다.
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void BaseTimeEntity_등록() {
|
||||
|
||||
// given
|
||||
LocalDateTime now = LocalDateTime.of(2020, 11, 19, 0, 0, 0);
|
||||
postsRepository.save(Posts.builder()
|
||||
.title("title")
|
||||
.content("content")
|
||||
.author("author")
|
||||
.build());
|
||||
|
||||
// when
|
||||
List<Posts> postsList = postsRepository.findAll();
|
||||
|
||||
// then
|
||||
Posts posts = postsList.get(0);
|
||||
|
||||
System.out.println(">>>>>>>>>> createDate=" + posts.getCreatedDate() + ", modifiedDate=" + posts.getModifiedDate());
|
||||
|
||||
assertThat(posts.getCreatedDate()).isAfter(now);
|
||||
assertThat(posts.getModifiedDate()).isAfter(now);
|
||||
}
|
||||
```
|
||||
테스트 코드를 수행해 보면 실제 시간이 잘 저장된 것을 확인할 수 있습니다.
|
||||
|
||||
앞으로 추가될 엔티티들은 더이상 등록일/수정일로 고민할 필요가 없습니다. `BaseTimeEntity`만 상속받으면 자동으로 해결되기 때문입니다.
|
||||
|
||||
---
|
||||
713
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter4.md
Normal file
@@ -0,0 +1,713 @@
|
||||
# Chapter4. 머스테치로 화면 구성하기
|
||||
|
||||
---
|
||||
|
||||
## 4.1 서버 템플릿 엔진과 머스테치 소개
|
||||
|
||||
일반적으로 웹 개발에 있어 템플릿 엔진이란, **지정된 템플릿 양식과 데이터**가 합쳐져 HTML 문서를 출력하는 소프트웨어를 이야기합니다.
|
||||
|
||||
- 서버 템플릿 엔진 : `JSP`, `Freemarker` ...
|
||||
- 클라이언트 템플릿 엔진 : `리액트(React)`, `뷰(Vue)`의 `View` 파일 ...
|
||||
|
||||
```java
|
||||
<script type="text/javascript">
|
||||
|
||||
$(document).ready(function(){
|
||||
if(a == "1"){
|
||||
<% System.out.println("test"); %>
|
||||
}
|
||||
});
|
||||
```
|
||||
실제로 위 코드는 **if문과 관계없이 무조건 test를 콘솔에 출력합니다.** 이유는 프론트엔드의 자바스크립트가 작동하는 영역과 JSP가 작동하는 영역이 다르기 때문인데 JSP를 비롯한 서버 템플릿 엔진은 **서버에서 구동**됩니다.
|
||||
|
||||
서버 템플릿 엔진을 이용한 화면 생성은 **서버에서 Java 코드로 문자열**을 만든 뒤 이 문자열을 HTML로 변환하여 **브라우저로 전달**합니다. 앞선 코드는 HTML을 만드는 과정에서 `System.out.println("test");`를 실행할 뿐이며, 이때의 자바스크립트 코드는 **단순한 문자열일 뿐입니다.**
|
||||
|
||||
반면에 자바스크립트는 **브라우저 위에서 작동**합니다. 앞에서 작성된 자바스크립트 코드가 실행되는 장소는 서버가 아닌 **브라우저**입니다. 즉, 브라우저에서 작동될 때는 서버 템플릿 엔진의 손을 벗어나 제어할 수가 없습니다. 흔히 이야기하는 `Vue.js`나 `React.js`를 이용한 SPA는 **브라우저에서 화면을 생성**합니다. 즉, **서버에서 이미 코드가 벗어난 경우**입니다. 그래서 서버에는 `Json` 혹은 `Xml` 형식의 데이터만 전달하고 클라이언트에서 조립합니다.
|
||||
|
||||
**머스테치란**
|
||||
|
||||
머스테치는 **수많은 언어를 지원하는 가장 심플한 템플릿 엔진**입니다. 루비, 자바스크립트, 파이썬, PHP, 자바, 펄, Go, ASP 등 현존하는 대부분 언어를 지원하고 있습니다. 그러다 보니 자바에서 사용될 때는 서버 템플릿 엔진으로, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있습니다.
|
||||
|
||||
자바 진영에서는 `JSP`, `Velocity`, `Freemarker`, `Thymeleaf` 등 다양한 서버 템플릿 엔진이 존재합니다.
|
||||
|
||||
템플릿 엔진들의 단점은 다음과 같습니다.
|
||||
- **JSP, Velocity** : **스프링 부트에서는 권장하지 않는 템플릿 엔진입니다.**
|
||||
- **Freemarker : 템플릿 엔진으로는 너무 과하게 많은 기능을 지원합니다. 높은 자유도로 인해 숙련도가 낮을수록 Freemarker 안에 비즈니스 로직이 추가될 확률이 높습니다.**
|
||||
- **Thymeleaf : 스프링 진영에서 적극적으로 밀고 있지만 문법이 어렵습니다. HTML 태그에 속성으로 템플릿 기능을 사용하는 방식이 기존 개발자분들께 높은 허들로 느껴지는 경우가 많습니다. 실제로 사용해 본 분들은 자바스크립트 프레임워크를 배우는 기분이라고 후기를 이야기하기도 합니다. 물론 Vue.js를 사용해 본 경험이 있어 태그 속성 방식이 익숙한 분이라면 Thymeleaf를 선택해도 됩니다.**
|
||||
|
||||
반면 머스테치의 장점은 다음과 같습니다.
|
||||
- **문법이 다른 템플릿 엔진보다 심플합니다.**
|
||||
- **로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리됩니다.**
|
||||
- **Mustache.js와 Mustache.java 2가지가 다 있어, 하나의 문법으로 클라이언트/서버 템플릿을 모두 사용 가능합니다.**
|
||||
|
||||
**템플릿 엔진은 화면 역할에만 충실한 것이 좋습니다.** 너무 많은 기능을 제공하면 API와 템플릿 엔진, 자바스크립트가 서로 로직을 나눠 갖게 되어 유지보수하기가 굉장히 어려워집니다.
|
||||
|
||||
**머스테치 플러그인 설치**
|
||||
|
||||
`IntelliJ` 플러그인에서 `mustache`를 검색해서 해당 플러그인을 설치한 후 인텔리제이를 재시작하여 플러그인이 작동하는 것을 확인하면 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 4.2 기본 페이지 만들기
|
||||
|
||||
가장 먼저 스프링 부트 프로젝트에서 머스테치를 편하게 사용할 수 있도록 머스테치 스타터 의존성을 `build.gradle`에 등록합니다.
|
||||
|
||||
`compile('org.springframework.boot:spring-boot-starter-mustache')`
|
||||
|
||||
머스테치는 **스프링 부트에서 공직 지원하는 템플릿 엔진**입니다. 의존성 하나만 추가하면 다른 스타터 패키지와 마찬가지로 추가 설정 없이 설치가 끝나며, 별도로 스프링 부트 버전을 신경 쓰지 않아도 되는 장점도 있습니다.
|
||||
|
||||
머스테치의 파일 위치는 기본적으로 `src/main/resources/templates`입니다. 이 위치에 머스테치 파일을 두면 스프링 부트에서 자동으로 로딩합니다.
|
||||
|
||||
첫 페이지를 담당할 `index.mustache`를 `src/main/resources/templates`에 생성합니다.
|
||||
|
||||
**index.mustache**
|
||||
```java
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<title>스프링 부트 웹서비스</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
</head>
|
||||
<body>
|
||||
<h1>스프링 부트로 시작하는 웹 서비스</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
생덩된 머스테치에 URL을 매핑합니다. URL 매핑은 당연하게 `Controller`에서 진행합니다. `web` 패키지 안에 `IndexController`를 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class IndexController {
|
||||
|
||||
@GetMapping("/")
|
||||
public String index() {
|
||||
return "index";
|
||||
}
|
||||
}
|
||||
```
|
||||
머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 **앞의 경로와 뒤의 파일 확장자는 자동으로 지정**됩니다. 앞의 경로는 `src/main/resources/templates`로, 뒤의 파일 확장자는 `.mustache`가 붙는 것입니다. 즉 여기선 `index`를 반환하므로, `src/main/resources/templates/index.mustache`로 전환되어 `View Resolver`가 처리하게 됩니다.
|
||||
|
||||
>`ViewResolver`는 URL 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격으로 볼 수 있습니다.
|
||||
|
||||
테스트 코드로 검증해 보겠습니다. `test` 패키지에 `IndexControllerTest` 클래스를 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = RANDOM_PORT)
|
||||
public class IndexControllerTest {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Test
|
||||
public void 메인페이지_로딩() {
|
||||
// when
|
||||
String body = this.restTemplate.getForObject("/", String.class);
|
||||
|
||||
// then
|
||||
assertThat(body).contains("스프링 부트로 시작하는 웹 서비스");
|
||||
}
|
||||
}
|
||||
```
|
||||
이번 테스트는 실제로 URL 호출 시 페이지의 내용이 제대로 호출되는지에 대한 테스트입니다. HTML도 결국은 **규칙이 있는 문자열**입니다. `TestRestTemplate`를 통해 `"/"`로 호출했을 때 `index.mustache`에 포함된 코드들이 있는지 확인하면 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 4.3 게시글 등록 화면 만들기
|
||||
|
||||
오픈 소스인 부트스트랩을 이용하여 화면을 만들어 봅니다. 부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지가 있습니다. 하나는 **외부 CDN**을 사용하는 것이고, 다른 하나는 **직접 라이브러리를 받아서 사용**하는 방법입니다.
|
||||
|
||||
**레이아웃 방식 : 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식**
|
||||
|
||||
`src/main/resources/templates` 디렉토리에 `layout` 디렉토리를 추가로 생성합니다. 그리고 `footer.mustache`, `header.mustache` 파일을 생성합니다.
|
||||
|
||||
레이아웃 파일들에 각각 공통 코드를 추가합니다.
|
||||
|
||||
**header.mustache**
|
||||
```java
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<title>스프링 부트 웹 서비스</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
```
|
||||
|
||||
**footer.mustache**
|
||||
```java
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**페이지 로딩속도를 높이기 위해** `css`는 `header`에, `js`는 `footer`에 두었습니다. HTML은 위에서부터 코드가 실행되기 때문에 **head가 다 실행되고서야 body가 실행**됩니다. 즉, `head`가 다 불러지지 않으면 사용자 쪽에선 백지 화면만 노출됩니다. 특히 `js`의 용량이 크면 클수록 `body` 부분의 실행이 늦어지기 때문에 `js`는 `body` 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋습니다.
|
||||
|
||||
반면 `css`는 화면을 그리는 역할이므로 `head`에서 불러오는 것이 좋습니다. 그렇지 않으면 `css`가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문입니다. 추가로, `bootstrap.js`의 경우 **제이쿼리가 꼭 있어야**만 하기 때문에 부트스트랩보다 먼저 호출되도록 코드를 작성해야 합니다. 보통 이런 경우를 `bootstrap.js`가 **제이쿼리에 의존**한다고 합니다.
|
||||
|
||||
라이브러리를 비롯해 기타 HTML 태그들이 모두 레이아웃에 추가되었으므로 `index.mustache`의 코드는 다음과 같이 변경됩니다.
|
||||
|
||||
```java
|
||||
{{>layout/header}} // 1.
|
||||
|
||||
<h1>스프링 부트로 시작하는 웹 서비스</h1>
|
||||
|
||||
{{>layout/footer}}
|
||||
```
|
||||
|
||||
**1. {{>layout>header}}**
|
||||
- `{{>}}`는 현재 머스테치 파일(`index.mustache`)을 기준으로 다른 파일을 가져옵니다.
|
||||
|
||||
레이아웃으로 파일을 분리했으니 `index.mustache`에 글 등록 버튼을 추가합니다.
|
||||
|
||||
```java
|
||||
{{>layout/header}}
|
||||
|
||||
<h1>스프링 부트로 시작하는 웹 서비스</h1>
|
||||
<div class="col-md-12">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{>layout/footer}}
|
||||
```
|
||||
여기서는 `<a>` 태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼이 생성되었습니다. 이동할 페이지의 주소는 `/posts/save`입니다.
|
||||
|
||||
이 주소에 해당하는 컨트롤러를 생성합니다.
|
||||
|
||||
```java
|
||||
@RequiredArgsConstructor
|
||||
@Controller
|
||||
public class IndexController {
|
||||
...
|
||||
|
||||
@GetMapping("/posts/save")
|
||||
public String postsSave() {
|
||||
return "posts-sava";
|
||||
}
|
||||
}
|
||||
```
|
||||
`index.mustache`와 마찬가지로 `/posts/save`를 호출하면 `posts-save.mustache`를 호출하는 메소드가 추가되었습니다. `posts-save.mustache` 파일을 생성합니다. 파일의 위치는 `index.mustache`와 같습니다.
|
||||
|
||||
**posts-save.mustache**
|
||||
```java
|
||||
{{>layout/header}}
|
||||
|
||||
<h1>게시글 등록</h1>
|
||||
|
||||
<div class="col-md-12">
|
||||
<div class="col-md-4">
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label for="title">제목</label>
|
||||
<input type="text" class="form-control" id="title" placeholder="제목을 입력하세요">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">내용</label>
|
||||
<textarea class="form-control" id="content" placeholder="내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<a href="/" role="button" class="btn btn-secondary">취소</a>
|
||||
<button type="button" class="btn btn-primary" id="btn-save">등록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{>layout/footer}}
|
||||
```
|
||||
아직 게시글 등록 화면에 **등록 버튼은 기능이 없습니다.** API를 호출하는 `JS`가 전혀 없기 때문입니다. 그래서 `src/main/resources`에 `static/js/app` 디렉토리를 생성합니다.
|
||||
|
||||
**index.js**
|
||||
```java
|
||||
var main = {
|
||||
init : function () {
|
||||
var _this = this;
|
||||
$('#btn-save').on('click', function () {
|
||||
_this.save();
|
||||
});
|
||||
},
|
||||
save : function () {
|
||||
var data = {
|
||||
title: $('#title').val(),
|
||||
author: $('#author').val(),
|
||||
content: $('#content').val()
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/api/v1/posts',
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
data: JSON.stringify(data)
|
||||
}).done(function () {
|
||||
alert('글이 등록되었습니다.');
|
||||
window.location.href = '/'; // 1.
|
||||
}).fail(function (error) {
|
||||
alert(JSON.stringify(error));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
main.init();
|
||||
```
|
||||
**1. window.location.href = '/'**
|
||||
- 글 등록이 성공하면 메인페이지(`/`)로 이동합니다.
|
||||
|
||||
`index.js`의 첫 문장에 `var main = { ... }`라는 코드를 선언했습니다. 굳이 `main`라는 변수의 속성으로 `function`을 추가한 이유는, 예를 들면 `index.js`가 다음과 같이 `function`을 작성한 상황이라고 가정하겠습니다.
|
||||
|
||||
```java
|
||||
var init = function() {
|
||||
...
|
||||
};
|
||||
|
||||
var save = function() {
|
||||
...
|
||||
};
|
||||
|
||||
init();
|
||||
```
|
||||
`index.mustache`에서 `a.js`가 추가되어 `a.js`도 **a.js만의 init과 save function이 있다**면?
|
||||
브라우저의 스코프는 **공용 공간**으로 쓰이기 때문에 나중에 로딩된 `js`의 `init`, `save`가 먼저 로딩된 `js`의 `function`을 **덮어쓰게 됩니다.**
|
||||
여러 사람이 참여하는 프로젝트에서는 **중복된 함수 이름**은 자주 발생할 수 있습니다. 모든 `function` 이름을 확인하면서 만들 수는 없습니다. 그래서 이런 문제를 피하려고 `index.js`만의 유효범위를 만들어 사용합니다.
|
||||
|
||||
방법은 `var index`이란 객체를 만들어 해당 객체에서 필요한 모든 `function`을 선언하는 것입니다. 이렇게 하면 **index 객체 안에서만 function이 유효**하기 때문에 다른 `JS`와 겹칠 위험이 사라집니다.
|
||||
|
||||
생성된 `index.js`를 머스테치 파일이 쓸 수 있게 `footer.mustache`에 추가합니다.
|
||||
|
||||
```java
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
|
||||
|
||||
<!--index.js 추가-->
|
||||
<script src="/js/app/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
`index.js` 호출 코드를 보면 **절대 경로**(`/`)로 바로 시작합니다. 스프링 부트는 기본적으로 `src/main/resources/static`에 위치한 자바스크립트, CSS, 이미지 등 정적 파일들은 URL에서 `/`로 설정됩니다.
|
||||
|
||||
그래서 다음과 같이 파일이 위치하면 위치에 맞게 호출이 가능합니다.
|
||||
|
||||
- `src/main/resources/static/js/...(http://도메인/js/...)`
|
||||
- `src/main/resources/static/css/...(http://도메인/css/...)`
|
||||
- `src/main/resources/static/image/...(http://도메인/image/...)`
|
||||
|
||||
등록 기능이 완성되었으므로 직접 브라우저에서 테스트하고 `h2` DB에 데이터가 등록되었는지도 확인해봅니다.
|
||||
|
||||
---
|
||||
|
||||
## 4.4 전체 조회 화면 만들기
|
||||
전체 조회를 위해 `index.mustache`의 UI를 변경합니다.
|
||||
|
||||
**index.mustache**
|
||||
```java
|
||||
{{>layout/header}}
|
||||
|
||||
<h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
|
||||
<div class="col-md-12">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<!--목록 출력 영역-->
|
||||
<table class="table table-horizontal table-bordered">
|
||||
<thead class="thead-strong">
|
||||
<tr>
|
||||
<th>게시글번호</th>
|
||||
<th>제목</th>
|
||||
<th>작성자</th>
|
||||
<th>최종수정일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody">
|
||||
{{#posts}} // 1.
|
||||
<tr>
|
||||
<td>{{id}}</td> // 2.
|
||||
<td>{{title}}</td>
|
||||
<td>{{author}}</td>
|
||||
<td>{{modifiedDate}}</td>
|
||||
</tr>
|
||||
{{/posts}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{>layout/footer}}
|
||||
```
|
||||
**1. {{#posts}}**
|
||||
- `posts`라는 `List`를 순회합니다.
|
||||
- `Java`의 `for`문과 동일하게 생각하면 됩니다.
|
||||
|
||||
**2. {{id}} 등의 {{변수명}}**
|
||||
- `List`에서 뽑아낸 객체의 필드를 사용합니다.
|
||||
|
||||
다음으로 `Controller`, `Service`, `Repository` 코드를 작성합니다.
|
||||
기존에 있던 `PostsRepository` 인터페이스에 쿼리가 추가됩니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.domain.posts;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PostsRepository extends JpaRepository<Posts, Long> {
|
||||
|
||||
@Query("SELECT p FROM Posts p ORDER BY p.id DESC ")
|
||||
List<Posts> findAllDesc();
|
||||
}
|
||||
```
|
||||
`SpringDataJpa`에서 제공하지 않는 메소드는 위처럼 `@Query`를 사용하여 쿼리로 작성해도 됩니다.
|
||||
|
||||
`Repository` 다음으로 `PostsService`에 코드를 추가합니다.
|
||||
|
||||
```java
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class PostsService {
|
||||
private final PostsRepository postsRepository;
|
||||
|
||||
...
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PostsListResponseDto> findAllDesc() {
|
||||
return postsRepository.findAllDesc().stream()
|
||||
.map(PostsListResponseDto::new)
|
||||
.collect(toList());
|
||||
}
|
||||
}
|
||||
```
|
||||
`findAllDesc` 메소드의 트랙잭션 어노테이션(`@Transaction`)에 옵션이 하나 추가되었습니다. `(readOnly = true)`를 주면 **트랜잭션 범위는 유지**하되, 조회 기능만 남겨두어 **조회 속도가 개선**되기 때문에 등록, 수정, 삭제 기능이 전혀 없는 서비스 메소드에서 사용하는 것을 추천합니다.
|
||||
|
||||
아직 `PostsListResponseDto` 클래스가 없기 때문에 이 클래스 역시 생성합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
public class PostsListResponseDto {
|
||||
private Long id;
|
||||
private String title;
|
||||
private String author;
|
||||
private LocalDateTime modifiedDate;
|
||||
|
||||
public PostsListResponseDto(Posts entity) {
|
||||
this.id = entity.getId();
|
||||
this.title = entity.getTitle();
|
||||
this.author = entity.getAuthor();
|
||||
this.modifiedDate = entity.getModifiedDate();
|
||||
}
|
||||
}
|
||||
```
|
||||
마지막으로 `Controller`를 변경합니다.
|
||||
|
||||
```java
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
|
||||
import org.springframework.ui.Model;
|
||||
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Controller
|
||||
public class IndexController {
|
||||
|
||||
private final PostsService postsService;
|
||||
|
||||
@GetMapping("/")
|
||||
public String index(Model model) { // 1.
|
||||
model.addAttribute("posts", postsService.findAllDesc());
|
||||
return "index";
|
||||
}
|
||||
|
||||
@GetMapping("/posts/save")
|
||||
public String postsSave() {
|
||||
return "posts-save";
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
**1. Model**
|
||||
- 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있습니다.
|
||||
- 여기서는 `postsService.findAllDesc()`로 가져온 결과를 `posts`로 `index.mustache`에 전달합니다.
|
||||
|
||||
`Controller`까지 모두 완성되었으므로, `http://localhost:8080/`로 접속한 뒤 등록 화면을 이용해 정상적으로 기능이 동작하는지 확인합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4.5 게시글 수정, 삭제 화면 만들기
|
||||
|
||||
게시글 수정 API는 이미 만들어둔 `PostsApiController`의 `update` 메소드를 이용합니다.
|
||||
|
||||
#### 게시글 수정
|
||||
|
||||
게시글 수정 화면 머스테치 파일을 생성합니다.
|
||||
|
||||
```java
|
||||
{{>layout/header}}
|
||||
|
||||
<h1>게시글 수정</h1>
|
||||
|
||||
<div class="col-md-12">
|
||||
<div class="col-md-4">
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label for="id">글 번호</label>
|
||||
<input type="text" class="form-control" id="id" value="{{post.id}}" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="title">제목</label>
|
||||
<input type="text" class="form-control" id="title" value="{{post.title}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="author">작성자</label>
|
||||
<input type="text" class="form-control" id="author" value="{{post.author}}" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">내용</label>
|
||||
<textarea class="form-control" id="content">{{post.content}}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
<a href="/" role="button" class="btn btn-secondary">취소</a>
|
||||
<button type="button" class="btn btn-primary" id="btn-update">수정 완료</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{>layout/footer}}
|
||||
```
|
||||
**1. {{post.id}}**
|
||||
- 머스테치는 객체의 필드 접근 시 점(`Dot`)으로 구분합니다.
|
||||
- 즉, `Post` 클래스의 `id`에 대한 접근은 `post.id`로 사용할 수 있습니다.
|
||||
|
||||
**2. readonly**
|
||||
- `input` 태그에 읽기 기능만 허용하는 속성입니다.
|
||||
- `id`와 `author`는 수정할 수 없도록 읽기만 허용하도록 추가합니다.
|
||||
|
||||
그리고 `btn-update` 버튼을 클릭하면 `update` 기능을 호출할 수 있게 `index.js` 파일에도 `update function`을 하나 추가합니다.
|
||||
|
||||
```java
|
||||
var main = {
|
||||
init: function () {
|
||||
var _this = this;
|
||||
...
|
||||
|
||||
$('#btn-update').on('click', function () { // 1.
|
||||
_this.update();
|
||||
})
|
||||
},
|
||||
save: function () {
|
||||
...
|
||||
},
|
||||
update: function () { // 2.
|
||||
var data = {
|
||||
title: $('#title').val(),
|
||||
content: $('#content').val()
|
||||
};
|
||||
|
||||
var id = $('#id').val();
|
||||
|
||||
$.ajax({
|
||||
type: 'PUT', // 3.
|
||||
url: '/api/v1/posts/' + id, // 4.
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
data: JSON.stringify(data)
|
||||
}).done(function () {
|
||||
alert('글이 수정되었습니다.');
|
||||
window.location.href = '/';
|
||||
}).fail(function (error) {
|
||||
alert(JSON.stringify(error));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
main.init();
|
||||
```
|
||||
**1. $('#btn-update').on('click')**
|
||||
- `btn-update`란 `id`를 가진 HTML 엘리먼트에 `click` 이벤트가 발생할 때 `update function`을 실행하도록 이벤트를 등록합니다.
|
||||
|
||||
**2. update: function ()**
|
||||
- 신규로 추가될 `update function`입니다.
|
||||
|
||||
**3. type: 'PUT'**
|
||||
- 여러 `HTTP Method` 중 PUT 메소드를 선택합니다.
|
||||
- `PostApiController`에 있는 API에서 이미 `@PutMapping`으로 선언했기 때문에 `PUT`을 사용해야 합니다. 참고로 이는 `REST` 규약에 맞게 설정된 것입니다.
|
||||
- `REST`에서 `CRUD`는 다음과 같이 `HTTP Method`에 매핑됩니다.
|
||||
- 생성 (Create) - POST
|
||||
- 읽기 (Read) - GET
|
||||
- 수정 (Update) - PUT
|
||||
- 삭제 (Delete) - DELETE
|
||||
|
||||
**4. url: '/api/v1/posts/' + id**
|
||||
- 어느 게시글을 수정할지 `URL Path`로 구분하기 위해 `Path`에 `id`를 추가합니다.
|
||||
|
||||
마지막으로 전체 목록에서 **수정 페이지로 이동할 수 있게** 페이지 이동 기능을 추가해 보겠습니다. `index.mustache` 코드를 '살짝' 수정합니다.
|
||||
|
||||
**index.mustache**
|
||||
```java
|
||||
<tbody id="tbody">
|
||||
{{#posts}}
|
||||
<tr>
|
||||
<td>{{id}}</td>
|
||||
<td><a href="/posts/update/{{id}}">{{title}}</a></td> // 1.
|
||||
<td>{{author}}</td>
|
||||
<td>{{modifiedDate}}</td>
|
||||
</tr>
|
||||
{{/posts}}
|
||||
</tbody>
|
||||
```
|
||||
**1. < a href="/posts/update/{{id}}"></a>**
|
||||
- 타이틀(`title`)에 `a tag`를 추가합니다.
|
||||
- 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동합니다.
|
||||
|
||||
`IndexController`에 다음과 같이 메소드를 추가합니다.
|
||||
|
||||
```java
|
||||
@RequiredArgsConstructor
|
||||
@Controller
|
||||
public class IndexController {
|
||||
|
||||
private final PostsService postsService;
|
||||
|
||||
...
|
||||
|
||||
@GetMapping("/posts/update/{id}")
|
||||
public String postsUpdate(@PathVariable Long id, Model model) {
|
||||
PostsResponseDto dto = postsService.findById(id);
|
||||
model.addAttribute("post", dto);
|
||||
|
||||
return "posts-update";
|
||||
}
|
||||
}
|
||||
```
|
||||
브라우저에서 수정 기능이 제대로 동작하는지 확인합니다.
|
||||
|
||||
#### 게시글 삭제
|
||||
삭제 버튼은 본문을 확인하고 진행해야 하므로, 수정 화면에 추가합니다.
|
||||
|
||||
**posts-update.mustache**
|
||||
```java
|
||||
<div class="col-md-12">
|
||||
<div class="col-md-4">
|
||||
...
|
||||
<a href="/" role="button" class="btn btn-secondary">취소</a>
|
||||
<button type="button" class="btn btn-primary" id="btn-update">수정 완료</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
**1. btn-delete**
|
||||
- 삭제 버튼을 수정 완료 버튼 옆에 추가합니다.
|
||||
- 해당 버튼 클릭시 `JS`에서 이벤트를 수신할 예정입니다.
|
||||
|
||||
삭제 이벤트를 진행할 `JS` 코드도 추가합니다.
|
||||
|
||||
**index.js**
|
||||
```java
|
||||
var main = {
|
||||
init: function () {
|
||||
var _this = this;
|
||||
...
|
||||
|
||||
$('#btn-delete').on('click', function () {
|
||||
_this.delete();
|
||||
});
|
||||
},
|
||||
...
|
||||
delete: function () {
|
||||
var id = $('#id').val();
|
||||
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: '/api/v1/posts/' + id,
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8'
|
||||
}).done(function () {
|
||||
alert('글이 삭제되었습니다.');
|
||||
window.location.href = '/';
|
||||
}).fail(function (error) {
|
||||
alert(JSON.stringify(error));
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
main.init();
|
||||
```
|
||||
`type`은 `DELETE`를 제외하고는 `update function`과 크게 차이 나진 않습니다. 다음으로는 삭제 API를 만듭니다. 먼저 서비스 메소드입니다.
|
||||
|
||||
**PostsService**
|
||||
```java
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class PostsService {
|
||||
private final PostsRepository postsRepository;
|
||||
|
||||
...
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
|
||||
|
||||
postsRepository.delete(posts); // 1.
|
||||
}
|
||||
}
|
||||
```
|
||||
**1. postsRepository.delete(posts);**
|
||||
- `JpaRepository`에서 이미 `delete` 메소드를 지원하고 있으니 이를 활용합니다.
|
||||
- 엔티티를 파라미터로 삭제할 수도 있고, `deleteById` 메소드를 이용하면 `id`로 삭제할 수도 있습니다.
|
||||
- 존재하는 `Posts`인지 확인을 위해 엔티티 조회 후 그대로 삭제합니다.
|
||||
|
||||
서비스에서 만든 `delete` 메소드를 컨트롤러가 사용하도록 코드를 추가합니다.
|
||||
|
||||
**PostApiController**
|
||||
```java
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class PostsApiController {
|
||||
|
||||
private final PostsService postsService;
|
||||
|
||||
...
|
||||
|
||||
@DeleteMapping("/api/v1/posts/{id}")
|
||||
public Long delete(@PathVariable Long id) {
|
||||
postsService.delete(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
```
|
||||
컨트롤러까지 생성되었으니 브라우저에서 기능이 잘 동작하는지 테스트를 해봅니다.
|
||||
|
||||
---
|
||||
1018
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter5.md
Normal file
234
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter6.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Chapter6. AWS 서버 환경을 만들어보자 - AWS EC2
|
||||
|
||||
AWS(Amazon Web Service)라는 클라우드 서비스를 이용해 본격적으로 서버 배포를 진행해 보겠습니다. 외부에서 본인이 만든 서비스에 접근하려면 **24시간 작동하는 서버**가 필수입니다. 24시간 작동하는 서버에는 3가지 선택지가 있습니다.
|
||||
|
||||
- 집에 PC를 24시간 구동시킨다.
|
||||
- 호스팅 서비스(`Cafe 24`, `코리아 호스팅` 등)를 이용한다.
|
||||
- 클라우드 서비스(`AWS`, `AZURE`, `GCP` 등)를 이용한다.
|
||||
|
||||
일반적으로 비용은 호스팅 서비스나 집 PC를 이용하는 것이 저렴합니다. 만약 특정 시간에만 트래픽이 몰린다면 **유동적으로 사양을 늘릴 수 있는 클라우드가 유리합니다.**
|
||||
|
||||
##### 클라우드
|
||||
클라우드 서비스는 인터넷(클라우드)을 통해 서버, 스토리지(파일 저장소), 데이터베이스, 네트워크, 소프트웨어, 모니터링 등의 컴퓨팅 서비스를 제공하는 것입니다. 단순히 물리 장비를 대여하는 것으로 생각하지만, 그렇지는 않습니다.
|
||||
|
||||
예를 들어 `AWS`의 `EC2`는 서버 장비를 대여하는 것이지만, 실제로는 그 안의 로그 관리, 모니터링, 하드웨어 교체, 네트워크 관리 등을 기본적으로 지원하고 있습니다. 개발자가 직접 해야 할 일을 `AWS`가 전부 지원을 하는 것입니다.
|
||||
|
||||
이런 클라우드에는 몇 가지 형태가 있습니다.
|
||||
|
||||
(1) `Infrastructure as a Service` (IaaS, 아이아스, 이에스)
|
||||
- 기존 물리 장비를 미들웨어와 함께 묶어둔 추상화 서비스입니다.
|
||||
- 가상머신, 스토리지, 네트워크, 운영체제 등의 IT 인프라를 대여해 주는 서비스라고 보면 됩니다.
|
||||
- `AWS`의 `EC2`, `S3` 등
|
||||
|
||||
(2) `Platform as a Service` (SaaS, 사스)
|
||||
- 소프트웨어 서비스를 이야기합니다.
|
||||
- 구글 드라이브, 드랍박스, 와탭 등
|
||||
|
||||
여기서는 여러 클라우드 서비스(`AWS`, `Azure`, `GCP` 등) 중 `AWS`를 선택합니다. 이유는 다음과 같습니다.
|
||||
|
||||
- 첫 가입 시 1년간 대부분 서비스가 무료입니다. 단, 서비스마다 제한이 있습니다.
|
||||
- 클라우드에서는 기본적으로 지원하는 기능(모니터링, 로그관리, 백업, 복구, 클러스터링 등등)이 많아 개인이나 소규모일 때 개발에 좀 더 집중할 수 있습니다.
|
||||
- 많은 기업이 `AWS`로 이전 중이기 때문에 이직할 때 `AWS` 사용 경험은 도움이 됩니다. 국내에서는 `AWS` 점유율이 압도적입니다. 쿠팡, 우아한형제들, 리멤버 등 클라우드를 사용할 수 있는 회사에서는 대부분 `AWS`를 사용합니다.
|
||||
- 사용자가 많아 국내 자료와 커뮤니티가 활성화되어 있습니다.
|
||||
|
||||
이 책에서 진행하는 모든 `AWS` 서비스는 `IaaS`를 사용합니다. `AWS`의 `PaaS` 서비스인 빈스톡을 사용하면 대부분 작업이 간소화되지만, **프리티어로 무중단 배포가 불가능**합니다(~~돈을 내고 2대 사용하면 가능합니다~~).
|
||||
|
||||
---
|
||||
|
||||
## 6.1 AWS 회원 가입
|
||||
**Master 혹은 Visa 카드가 필요합니다.** 본인의 카드 중 `Master` 혹은 `Visa` 카드를 준비한 뒤 진행합니다.
|
||||
|
||||
`AWS` 공식 사이트(https://aws.amazon.com/ko/)로 이동한 뒤 **무료 계정 만들기**를 선택합니다.
|
||||
|
||||
참고링크 : [AWS 회원가입 따라하기](https://goddaehee.tistory.com/175)
|
||||
|
||||
차례대로 진행한 뒤 콘솔 로그인 버튼을 클릭해서 로그인을 진행합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6.2 EC2 인스턴스 생성하기
|
||||
`EC2`는 `AWS`에서 제공하는 성능, 용량 등을 유동적으로 사용할 수 있는 서버입니다. 보통 `AWS`에서 리눅스 서버 혹은 윈도우 서버를 사용합니다. 라고 하면 이 `EC2`를 이야기하는 것입니다.
|
||||
|
||||
>`EC2`의 이름은 `Elastic Compute Cloud`에서 `C`가 2개가 있어 `C2`라는 이름이 붙었습니다. `AWS`에서는 대부분 첫 글자가 중복되면 숫자로 표기합니다. 비슷한 예로 `AWS`의 `S3`는 `Simple Storage Service`를 줄여가 `S`가 3개라고 하여 `S3`입니다.
|
||||
|
||||
`AWS`에서 무료로 제공하는 프리티어 플랜에서는 `EC2` 사용에 다음과 같은 제한이 있습니다.
|
||||
|
||||
- 사양이 `t2.micro`만 가능합니다.
|
||||
- `vCPU(가상 CPU) 1 Core`, 메모리 `1GB` 사양입니다.
|
||||
- 보통 `vCPU` 는 물리 CPU 사양의 절반 정도의 성능을 가집니다.
|
||||
- 월 750시간의 제한이 있습니다. 초과하면 비용이 부과됩니다.
|
||||
- 24시간 * 31일 = 744시간입니다.
|
||||
- 즉, **1대의 t2.micro만 사용한다면 24시간** 사용할 수 있습니다.
|
||||
|
||||
자 그럼 `EC2`를 만들기 전에, 본인의 리전을 확인해 봅니다.
|
||||
|
||||
>리전이란 `AWS`의 서비스가 구동될 지역을 이야기합니다. `AWS`는 도시별로 클라우드 센터를 지어 해당 센터에서 구축된 가상머신들을 사용할 수 있습니다. 이걸 리전이라고 합니다.
|
||||
|
||||
>서울 리전이 생기기 전까지는 국내 서비스들은 도쿄 리전을 사용했습니다. 한국과 가장 가까운 지역이라 가장 네트워크가 빠르기 때문입니다. 현재는 서울 리전이 있어 국내에서 서비스한다면 무조건 서울 리전을 선택하면 됩니다.
|
||||
|
||||
보통은 처음 리전(웹에서 오른쪽 위의 메뉴에서 확인 가능)이 오아이주로 선택되어 있습니다. 이를 **서울로 변경**합니다.
|
||||
|
||||
서울로 리전을 변경했다면 화면 중앙에 있는 검색창에서 `ec2` 를 입력하여 `EC2` 서비스를 클릭합니다.
|
||||
|
||||
`EC2` 대시보드가 나오는데, 여기서 중앙에 있는 `[인스턴스 시작]` 버튼을 클릭합니다. 인스턴스란 `EC2` 서비스에 생성된 가상머신을 이야기합니다.
|
||||
|
||||
인스턴스를 생성하는 첫 단계는 `AMI(Amazon Machine Image, 아마존 머신 이미지)`를 선택하는 것입니다. 먼저 `AMI`에 대해 설명하면, `AMI`는 `EC2` 인스턴스를 시작하는 데 필요한 정보를 **이미지로 만들어 둔 것**을 이야기합니다. 인스턴스라는 가상 머신에 운영체제 등을 설치할 수 있게 구워 넣은 이미지로 생각하면 됩니다.
|
||||
|
||||
여기서는 `Amazon Linux AMI`를 선택합니다. 아마존 리눅스 2 대신에 아마존 리눅스 1을 선택한 이유는 **아직 국내 자료가 리눅스 1이 더 많기 때문**입니다. 보통 센토스 6 버전으로 진행되는 자료들은 아마존 리눅스 1에서 모두 사용할 수 있습니다. 아마존 리눅스 2는 센토스 7 버전 자료들을 그대로 사용할 수 있습니다. 하지만 굳이 센토스 AMI를 사용하지 않고 아마존 리눅스 AMI를 사용하는 이유는 다음과 같습니다.
|
||||
|
||||
- 아마존이 개발하고 있기 때문에 지원받기가 쉽다.
|
||||
- 레드햇 베이스이므로 레드햇 계열의 배포판을 많이 다뤄본 사람일수록 문제없이 사용할 수 있다.
|
||||
- `AWS`의 각종 서비스와의 상성이 좋다.
|
||||
- `Amazon` 독자적인 개발 리포지터리를 사용하고 있어 `yum`이 매우 빠르다.
|
||||
|
||||
다음은 인스턴스 유형을 선택하는 단계입니다. 인스턴스 유형에서는 프리티어로 표기된 `t2.micro`를 선택합니다.
|
||||
|
||||
다음 단계는 세부정보 구성입니다. 기업에서 사용할 경우 화면상에 표기된 VPC, 서브넷 등을 세세하게 다루지만, 여기서는 혼자서 1대의 서버만 사용하니 별다른 설정을 하지 않고 넘어갑니다.
|
||||
|
||||
>VPC와 서브넷 등은 AWS 서비스들의 네트워크 환경을 구성하는 정도로만 이해하면 됩니다. 1인 개발 시 혹은 대량의 서버를 사용하지 않는다면 굳이 별도로 구성할 필요가 없으므로 여기서는 기본 생성되는 값을 사용합니다.
|
||||
|
||||
다음 단계는 스토리지 선택입니다. 스토리지는 흔히 **하드디스크**라고 부르는 서버의 디스크(SSD도 포함)를 이야기하며 **서버의 용량**을 얼마나 정할지 선택하는 단계입니다. 여기서 설정의 기본값은 8GB입니다. **30GB까지 프리티어로 가능**하므로 최대치인 30GB로 변경합니다.
|
||||
|
||||
태그에는 웹 콘솔에서 표기될 태그인 `Name` 태그를 등록합니다. 태그는 해당 인스턴스를 표현하는 여러 이름으로 사용될 수 있습니다. `EC2`의 이름을 붙인다고 생각하고 넣으면 됩니다. 여러 인스턴스가 있을 경우 이를 태그별로 구분하면 검색이나 그룹 짓기 편하므로 여기서 본인 서비스의 인스턴스를 나타낼 수 있는 값으로 등록합니다.
|
||||
|
||||
다음으로 보안 그룹입니다. 보안 그룹은 **방화벽**을 이야기합니다. **서버로 80 포트 외에는 허용하지 않는다**는 역할을 하는 방화벽이 `AWS`에서는 보안 그룹으로 사용됩니다.
|
||||
|
||||
이 보안그룹 부분이 굉장히 중요한 부분입니다. 유형 항목에서 `SSH`이면서 포트 항목에서 22인 경우는 **AWS EC2에 터미널로 접속**할 때를 이야기합니다. `pem` 키가 없으면 접속이 안 되니 전체 오픈(0.0.0.0/0, ::/0)하는 경우를 종종 발견합니다. 이렇게 되면 이후 파일 공유 디렉토리나 깃허브등에 실수로 `pem` 키가 노출되는 순간 서버에서 가상화폐가 채굴되는 것을 볼 수 있습니다.
|
||||
|
||||
보안은 언제나 높을수록 좋으니 `pem` 키 관리와 **지정된 IP에서만 SSH 접속이 가능**하도록 구성하는 것이 안전합니다. 그래서 본인 집의 IP를 기본적으로 추가하고(**내 IP를 선택하면 현재 접속한 장소의 IP**가 자동 지정됩니다) 카페와 같이 집 외에 다른 장소에서 접속할 때는 **해당 장소의 IP를 다시 SSH 규칙에 추가**하는 것이 안전합니다.
|
||||
|
||||
현재 프로젝트의 기본 포트인 8080을 추가하고 `[검토 및 시작]` 버튼을 클릭합니다. 검토 화면에서 보안 그룹 경고를 하는데, 이는 8080이 전체 오픈이 되어서 발생합니다. 8080을 열어 놓는 것은 위험한 일이 아니니 바로 `[시작하기]` 버튼을 클릭합니다.
|
||||
|
||||
인스턴스로 접근하기 위해서는 `pem` 키(비밀키)가 필요합니다. 그래서 인스턴스 마지막 단계는 할당할 `pem` 키를 선택하는 것입니다. 인스턴스는 지정된 `pem` 키(비밀키)와 매칭되는 공개키를 가지고 있어 해당 `pem` 키 외에는 접근을 허용하지 않습니다. 일종의 **마스터키**이기 때문에 절대 유출되면 안 됩니다. `pem` 키는 이후 `EC2` 서버로 접속할 때 필수 파일이니 **잘 관리할 수 있는 디렉토리로 저장**합니다. 기존에 생성된 `pem` 키가 있다면 선택하고 없다면 신규로 생성합니다.
|
||||
|
||||
`pem` 키까지 내려받았다면 인스턴스 생성 시작 페이지로 이동한 뒤 인스턴스 `id`를 클릭하여 `EC2` 목록으로 이동합니다. 생성이 다 되었다면 IP와 도메인이 할당된 것을 확인할 수 있습니다.
|
||||
|
||||
인스턴스도 결국 하나의 서버이기 때문에 IP가 존재합니다. 인스턴스 생성 시에 항상 새 IP를 할당하는데, 한 가지 조건이 더 있습니다. 같은 인스턴스를 중지하고 **다시 시작할 때도 새 IP가 할당**됩니다. 즉, 잠깐 인스턴스를 중지하고 다시 시작하면 IP가 변경되는 것입니다. 이렇게 되면 매번 접속해야 하는 IP가 변경돼서 PC에서 접근할 때마다 IP 주소를 확인해야 합니다. 굉장히 번거로우므로 인스턴스의 IP가 **매번 변경되지 않고 고정 IP를 가지게** 해야 합니다. 그래서 **고정 IP**를 할당합니다.
|
||||
|
||||
#### EIP 할당
|
||||
`AWS`의 고정 IP를 `Elastic IP(EIP, 탄력적 IP)`라고 합니다. `EC2` 인스턴스 페이지의 왼쪽 카테고리에서 **탄력적 IP를 눌러 선택**하고 주소가 없으므로 `[내 주소 할당]` 버튼을 클릭해서 바로 `[할당]` 버튼을 클릭합니다.
|
||||
|
||||
새 주소 할당이 완료되면 **탄력적 IP가 발급**됩니다.
|
||||
|
||||
방금 생성한 탄력적 IP와 방금 생성한 `EC2` 주소를 연결합니다. **방금 생성한 탄력적 IP를 확인**하고, 페이지 위에 있는 `[작업]` 버튼 -> `[주소 연결]` 메뉴를 선택합니다. 주소 연결을 위해 생성한 `EC2` 이름과 IP를 선택하고 `[연결]` 버튼을 클릭합니다. 연결이 완료되면 왼쪽 카테고리에 있는 `[인스턴스]` 탭을 클릭해서 다시 **인스턴스 목록** 페이지로 이동한 뒤 해당 인스턴스의 **퍼블릭, 탄력적 IP**가 모두 잘 연결되었는지 확인합니다.
|
||||
|
||||
여기까지 진행했으면 `EC2` 인스턴스 생성 과정은 끝났습니다. 하지만, 주의할 점이 있습니다. 방금 생성한 탄력적 IP는 **생성하고 EC2 서버에 연결하지 않으면** 비용이 발생합니다. 즉, **생성한 탄력적 IP는 무조건 EC2에 바로 연결해야 하며** 만약 더는 사용할 인스턴스가 없을 때도 탄력적 IP를 삭제해야 합니다. 마찬가지로 비용 청구가 되므로 꼭 잊지 않고 삭제해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6.3 EC2 서버에 접속하기
|
||||
방금 생성한 `EC2`로 접속을 해보겠습니다.
|
||||
|
||||
### Mac & Linux
|
||||
자세한 내용은 책을 참조할 것
|
||||
|
||||
### Windows
|
||||
윈도우에서는 `Mac`과 같이 `SSH` 접속하기엔 불편한 점이 많아 별도의 클라이언트(`putty`)를 설치합니다. putty 사이트(`https://www.putty.org/`)에 접속하여 실행 파일을 내려받습니다.
|
||||
|
||||
실행 파일은 2가지입니다.
|
||||
|
||||
- putty.exe
|
||||
- puttygen.exe
|
||||
|
||||
두 파일을 모두 내려받은 뒤, `puttygen.exe` 파일을 실행합니다.
|
||||
|
||||
`putty`는 `pem` 키로 실행이 안 되며 `pem` 키를 `ppk` 파일로 변환을 해야만 합니다. `puttygen`은 이 과정을 진행해 주는 클라이언트입니다. `puttygen` 화면에서 상단 `[Conversions -> Import Key]`를 선택해서 내려받은 `pem` 키를 선택합니다.
|
||||
|
||||
그럼 자동으로 변환이 진행됩니다. `[save private key]` 버튼을 클릭하여 `ppk` 파일을 생성합니다. 경고 메시지가 뜨면 `[예]`를 클릭하고 넘어갑니다.
|
||||
|
||||
`ppk` 파일이 잘 생성되었으면 `putty.exe` 파일을 실행하여 다음과 같이 각 항목을 등록합니다.
|
||||
|
||||
- HostName : `username@public_ip` 를 등록합니다. 우리가 생성한 `Amazon Linux`는 `ec2-user`가 `username`이라서 `ec2-user`@탄력적 IP 주소를 등록하면 됩니다.
|
||||
- Port : `ssh` 접속 포트인 22를 등록합니다.
|
||||
- Connection type : `SSH`를 선택합니다.
|
||||
|
||||
항목들을 모두 채웠다면 왼쪽 사이드바에 있는 `[Connection -> SSH -> Auth]`를 차례대로 클릭해서 `ppk` 파일을 로드할 수 있는 화면으로 이동합니다. `[Browse...]` 버튼을 클릭해서 조금 전에 생성한 `ppk` 파일을 불러옵니다. 정상적으로 불러왔다면 다시 `[Session]` 탭으로 이동하여 `[Saved Sessions]`에 **현재 설정들을 저장할 이름을 등록**하고 `[save]` 버튼을 클릭합니다.
|
||||
|
||||
저장한 뒤 `[open]` 버튼을 클릭하면 다음과 같이 `SSH` 접속 알림이 등장합니다. `[예]`를 클릭합니다. 그럼 `SSH` 접속이 성공한 것을 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 6.4 아마존 리눅스 1 서버 생성 시 꼭 해야 할 설정들
|
||||
아마존 리눅스 1 서버를 처음 받았다면 몇 가지 설정들이 필요합니다. 이 설정들은 모두 자바 기반의 웹 애플리케이션(톰캣과 스플링부트)이 작동해야 하는 서버들에선 필수로 해야 하는 설정들입니다.
|
||||
- **Java 8 설치** : 현재 이 프로젝트의 버전은 `Java 8`입니다.
|
||||
- **타임존 변경** : 기본 서버의 시간은 미국 시간대입니다. 한국 시간대가 되어야만 우리가 사용하는 시간이 모두 한국 시간으로 등록되고 사용됩니다.
|
||||
- **호스트네임 변경** : 현재 접속한 서버의 별명을 등록합니다. 실무에서는 한 대의 서버가 아닌 수십 대의 서버가 작동되는데, IP만으로 어떤 서버가 어떤 역할을 하는지 알 수 없습니다. 이를 구분하기 위해 보통 호스트 테임을 필수로 등록합니다.
|
||||
|
||||
방금 진행한 `EC2` 접속 과정을 통해서 `EC2`에 접속한 뒤에 다음 과정을 진행하면 됩니다.
|
||||
|
||||
#### Java 8 설치
|
||||
아마존 리눅스 1의 경우 기본 자바 버전이 7입니다. 이 책에서는 자바 8을 기본으로 사용하므로 자바 8을 `EC2`에 설치합니다. `EC2`에서 다음의 명령어를 실행합니다.
|
||||
|
||||
```java
|
||||
sudo yum install -y java-1.8.0-openjdk-devel.x86_64
|
||||
```
|
||||
설치가 완료되었다면 인스턴스의 `Java` 버전을 8로 변경합니다.
|
||||
|
||||
```java
|
||||
sudo /usr/sbin/alternatives --config java
|
||||
```
|
||||
그리고 선택 화면에서는 `Java8`을 선택합니다(2 입력).
|
||||
|
||||
버전이 변경되었으면 사용하지 않는 `Java7`을 삭제합니다.
|
||||
```java
|
||||
sudo yum remove java-1.7.0-openjdk
|
||||
```
|
||||
현재 버전이 `Java8`이 되었는지 확인합니다.
|
||||
```java
|
||||
java -version
|
||||
```
|
||||
|
||||
#### 타임존 변경
|
||||
`EC2` 서버의 기본 타임존은 `UTC`입니다. 이는 세계 표준 시간으로 한국의 시간대가 아닙니다. 즉, **한국의 시간과는 9시간 차이**가 발생합니다. 이렇게 되면 서버에서 수행되는 `Java` 애플리케이션에서 생성되는 시간도 모두 9시간씩 차이가 나기 때문에 꼭 수정해야 할 설정입니다. 서버의 타임존을 **한국 시간(KST)**으로 변경합니다.
|
||||
|
||||
다음 명령어를 차례로 수행합니다.
|
||||
```java
|
||||
sudo rm /etc/localtime
|
||||
sudo ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime
|
||||
```
|
||||
정상적으로 수행되었다면 `date` 명령어로 타임존이 `KST`로 변경된 것을 확인할 수 있습니다.
|
||||
|
||||
#### Hostname 변경
|
||||
여러 서버를 관리 중일 경우 **IP만으로 어떤 서비스의 서버인지** 확인이 어렵습니다. 그래서 각 서버가 **어느 서비스인지 표현**하기 위해 HOSTNAME을 변경합니다. 다음 명령어로 편집 파일을 열어봅니다.
|
||||
|
||||
```java
|
||||
sudo vim /etc/sysconfig/network
|
||||
```
|
||||
참고 링크 : [Linux 기반의 vi(vim) 에디터 사용법](https://jhnyang.tistory.com/54)
|
||||
|
||||
참고 링크 : [Amazon Linux 인스턴스의 호스트 이름 변경](https://docs.aws.amazon.com/ko_kr/AWSEC2/latest/UserGuide/set-hostname.html)
|
||||
|
||||
변경한 후 다음 명령어로 서버를 재부팅 합니다.
|
||||
```java
|
||||
sudo reboot
|
||||
```
|
||||
재부팅이 끝나고 나서 다시 접속해 보면 HOSTNAME이 잘 변경된 것을 확인할 수 있습니다.
|
||||
|
||||
`Hostname`이 등록되었다면 한 가지 작업을 더 해야 합니다.
|
||||
호스트 주소를 찾을 때 가장 먼저 검색해 보는 `/etc/hosts`에 변경한 `hostname`을 등록합니다.
|
||||
|
||||
다음 명령어로 `/etc/hosts` 파일을 열어 봅니다.
|
||||
```java
|
||||
sudo vim /etc/hosts
|
||||
```
|
||||
방금 등록한 HOSTNAME을 등록합니다.
|
||||
```java
|
||||
127.0.0.1 등록한 HOSTNAME
|
||||
```
|
||||
`:wq` 명령어로 저장하고 종료한 뒤 정상적으로 등록되었는지 확인해 봅니다. 확인 방법은 다음 명령어로 합니다.
|
||||
```java
|
||||
curl 등록한 호스트 이름
|
||||
```
|
||||
만약 잘못 등록되었다면 찾을 수 없는 주소라는 에러가 발생합니다.
|
||||
|
||||
`curl: (6) Could not resolve host: .....`
|
||||
|
||||
잘 등록하였다면 80 포트로 접근이 안된다는 에러가 발생합니다.
|
||||
|
||||
`curl: (7) Failed to connect to .....`
|
||||
|
||||
이는 아직 80포트로 실행된 서비스가 없음을 의미합니다. 즉, curl 호스트 이름으로 실행은 잘 되었음을 의미합니다.
|
||||
|
||||
`EC2` 설정이 완료되었으니 이제 `AWS`의 데이터베이스 서비스인 `RDS`를 생성하고 설정해 보겠습니다.
|
||||
|
||||
---
|
||||
224
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter7.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Chapter7. AWS에 데이터베이스 환경을 만들어보자 - AWS RDS
|
||||
웹 서비스의 백엔드를 다룬다고 했을 때 **애플리케이션 코드를 작성하는 것 만큼 중요한 것이 데이터베이스를 다루는 일**입니다. 따라서 어느 정도의 데이터베이스 구축, 쿼리 튜닝에 대해서 기본적인 지식이 필요합니다. 이번 장에서는 데이터베이스를 구축하고 앞 장에서 만든 `EC2` 서버와 연동을 해보겠습니다. 다만, **직접 데이터베이스를 설치하지 않습니다.** 직접 데이터베이스를 설치해서 다루게 되면 모니터링, 알람, 백업, `HA` 구성 등을 모두 직접 해야만 합니다. 처음 구축할 때 며칠이 걸릴 수 있는 일입니다.
|
||||
|
||||
`AWS`에서는 앞에서 언급한 작업을 모두 지원하는 **관리형 서비스**인 `RDS(Relational Database Service)`를 제공합니다. `RDS`는 `AWS`에서 지원하는 클라우드 기반 관계형 데이터베이스입니다. 하드웨어 프로비저닝, 데이터베이스 설정, 패치 및 백업과 같이 잦은 운영 작업을 자동화하여 개발자가 개발에 집중할 수 있게 지원해주는 서비스입니다. 추가로 **조정 가능한 용량**을 지원하여 예상치 못한 양의 데이터가 쌓여도 비용만 추가로 내면 정상적으로 서비스가 가능한 장점도 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7.1 RDS 인스턴스 생성하기
|
||||
먼저 `RDS` 인스턴스를 생성하겠습니다. 검색창에 `rds`를 입력해서 선택하고, `RDS` 대시보드에서 `[데이터베이스 생성]` 버튼을 클릭합니다.
|
||||
|
||||
`RDS` 생성 과정이 진행됩니다. `DB` 엔진 선택 화면에서 `MariaDB`를 선택하도록 하겠습니다. `RDS`에는 오라클, `MSSQL`, `PostgreSQL` 등이 있으며 당연히 **본인이 가장 잘 사용하는 데이터베이스**를 고르면 되지만, 꼭 다른 데이터베이스를 선택해야 할 이유가 있는 것이 아니라면 `MySQL`, `MariaDB`, `PostgreSQL` 중에 고르길 추천합니다. 필자는 그중에서도 `MariaDB`를 추천하며 이유는 다음과 같습니다.
|
||||
|
||||
- 가격
|
||||
- `Amazon Aurora(오로라)` 교체 용이성
|
||||
|
||||
`RDS`의 가격은 라이센스 비용 영향을 받습니다. 상용 데이터베이스인 오라클, `MSSQL`이 오픈소스인 `MySQL`, `MariaDB`, `PostgreSQL` 보다는 **동일한 사양 대비 가격이 더 높습니다.**
|
||||
|
||||
두 번째로 **Amazon Aurora 교체 용이성**입니다. `Amazon Aurora`는 `AWS`에서 **MySQL과 PostgreSQL을 클라우드 기반에 맞게 재구성한 데이터베이스** 입니다. 공식 자료에 의하면 `RDS MySQL` 대비 5배, `RDS PostgreSQL` 보다 3배의 성능을 제공합니다. 더군다나 **AWS에서 직접 엔지니어링**하고 있기 때문에 계속해서 발전하고 있습니다. 현재도 다른 데이터베이스와 비교해 다양한 기능을 제공하고 있습니다.
|
||||
|
||||
클라우드 서비스에 가장 적합한 데이터베이스이기 때문에 많은 회사가 `Amazon Aurora`를 선택합니다. 그러다 보니 호환 대상이 아닌 오라클, `MSSQL`을 굳이 선택할 필요가 없습니다. 이렇게 보면 `Aurora`를 선택하면 가장 좋을 것 같지만 시작하는 단계에서 `Aurora`를 선택하기 어렵습니다. **프리티어 대상이 아니며**, 최저 비용이 **월 10만원 이상**이기 때문에 부담스럽습니다. 그래서 일단은 `MariaDB`로 시작합니다.
|
||||
|
||||
**MySQL을 기반**으로 만들어졌기 때문에 쿼리를 비롯한 전반적인 사용법은 `MySQL`과 유사하니 사용 방법에 대해서는 크게 걱정하지 않아도 됩니다. 비슷한 사용법 외에도 `MariaDB`는 `MySQL` 대비 다음의 장점이 있습니다.
|
||||
|
||||
- 동일 하드웨어 사양으로 `MySQL`보다 향상된 성능
|
||||
- 좀 더 활성화된 커뮤니티
|
||||
- 다양한 기능
|
||||
- 다양한 스토리지 엔진
|
||||
|
||||
참고 자료 : ["MySQL에서 MariaDB로 마이그레이션 해야 할 10가지 이유"](https://xdhyix.wordpress.com/2016/03/24/mysql-%EC%97%90%EC%84%9C-mariadb-%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98-%ED%95%B4%EC%95%BC%ED%95%A0-10%EA%B0%80%EC%A7%80-%EC%9D%B4%EC%9C%A0/)
|
||||
|
||||
다음으로 넘어가면 사용 사례 항목이 나옵니다. `[프리티어]`를 선택합니다.
|
||||
|
||||
상세 설정에서는 다음과 같이 설정합니다.
|
||||
|
||||
>DM 인스턴스 클래스 : db.t2.micro - 1 vCPU, 1GiB RAM / 할당된 스토리지 : 20
|
||||
스토리지 자동 조정 체크해제
|
||||
DB 인스턴스 식별자 : 원하는 이름
|
||||
마스터 사용자 이름 : 원하는 이름
|
||||
마스터 암호 : 원하는 암호
|
||||
|
||||
본인만의 `DB` 인스턴스 이름과 사용자 정보를 등록합니다. 여기서 사용된 사용자 정보로 실제 데이터베이스에 접근하게 됩니다. 네트워크에선 퍼블릭 엑세스를 `[예]`로 변경합니다. 이후 보안 그룹에서 지정된 IP만 접근하도록 막을 예정입니다.
|
||||
|
||||
데이터베이스 옵션에서는 **이름을 제외한 나머지를** 일단 기본값으로 둡니다.
|
||||
|
||||
모든 설정이 끝나서 `[완료]` 버튼을 클릭하면 생성 과정이 진행되고, 데이터베이스가 다 생성되었다면 이제 본격적으로 설정을 해보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7.2 RDS 운영환경에 맞는 파라미터 설정하기
|
||||
`RDS`를 처음 생성하면 몇 가지 설정을 필수로 해야합니다. 우선 다음 3개의 설정을 차례로 진행합니다.
|
||||
|
||||
- 타임존
|
||||
- Character Set
|
||||
- Max Connection
|
||||
|
||||
왼쪽 카테고리에서 `[파라미터 그룹]` 탭을 클릭해서 이동합니다.
|
||||
|
||||
화면 오른쪽 위의 `[파라미터 그룹 생성]` 버튼을 클릭합니다.
|
||||
|
||||
세부 정보 위쪽에 `DB` 엔진을 선택하는 항목이 있습니다. 여기서 **방금 생성한 MariaDB와 같은 버전**을 맞춰야 합니다. 앞에서 생성된 버전과 같은 버전대를 선택해야 합니다. (ex. 10.2.xx 버전으로 생성되었다면 10.2 버전 선택)
|
||||
|
||||
생성이 완료되면 파라미터 그룹 목록 창에 새로 생성된 그룹을 볼 수 있는데, 해당 파라미터 그룹을 클릭한 뒤, `[파라미터 편집]` 버튼을 통해 **편집모드로 전환**합니다.
|
||||
|
||||
편집 모드로 되었다면 하나씩 설정값들을 변경합니다. 먼저 `time_zone`을 검색하여 `[Asia/Seoul]`을 선택합니다.
|
||||
|
||||
다음으로 `Character Set`을 변경합니다. `Character Set`은 항목이 많습니다. 아래 8개 항목 중 `character` 항목들은 모두 `utf8mb4`로, `collation` 항목들은 `utf8mb4_general_ci`로 변경합니다.
|
||||
|
||||
- character_set_client
|
||||
- character_set_connection
|
||||
- character_set_database
|
||||
- character_set_filesystem
|
||||
- character_set_results
|
||||
- character_set_server
|
||||
- collation_connection
|
||||
- collation_server
|
||||
|
||||
`utf8`은 이모지를 저장할 수 없지만, `utf8mb4`는 이모지를 저장할 수 있으므로 보편적으로 `utf8mb4`를 많이 사용합니다.
|
||||
|
||||
마지막으로 `Max Connection`을 수정합니다. `RDS`의 `Max Connection`은 **인스턴스 사양에 따라 자동으로** 정해집니다. 현재 프리티어 사양으로는 약 60개의 커넥션만 가능해서 좀 더 넉넉한 값(150)으로 지정합니다.
|
||||
|
||||
이후에 `RDS` 사양을 높이게 된다면 기본값으로 다시 돌려놓으면 됩니다. 설정이 다 되었다면 위의 `[변경 사항 저장]` 버튼을 클릭해 최종 저장합니다.
|
||||
|
||||
이렇게 생성된 파라미터 그룹을 다음과 같은 순서로 데이터베이스에 연결합니다.
|
||||
|
||||
>1. 좌측의 데이터베이스 버튼 클릭
|
||||
>2. 데이터베이스 선택
|
||||
>3. 수정 버튼 클릭
|
||||
>
|
||||
>4. 이후 추가 구성의 데이터베이스 옵션 항목에서 `DB` 파라미터 그룹은 `default`로 되어있습니다. `DB` 파라미터 그룹을 방금 생성한 신규 파라미터 그룹으로 변경합니다.
|
||||
|
||||
계속을 누르면 수정 사항 요약을 볼 수 있습니다. 여기서 반영 시점을 `[즉시 적용]`으로 합니다. 예약된 다음 유지 시간으로 하면 지금 하지 않고, 새벽 시간대에 진행하게 됩니다. 이 수정사항이 반영되는 동안 데이터베이스가 작동하지 않을 수 있으므로 예약 시간을 걸어두라는 의미지만, 지금은 서비스가 오픈되지 않았기 때문에 즉시 적용합니다. 그럼 `수정 중` 상태가 나옵니다.
|
||||
|
||||
간혹 파라미터 그룹이 제대로 반영되지 않을 때가 있습니다. 정상 적용을 위해 한 번 더 재부팅을 진행합니다.(`작업 - 재부팅 버튼`)
|
||||
|
||||
재부팅까지 성공했다면 이제 로컬 PC에서 RDS에 한 번 접속해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7.3 내 PC에서 RDS에 접속해 보기
|
||||
로컬 PC에서 `RDS`로 접근하기 위해서 **RDS의 보안 그룹에 본인 PC의 IP를 추가**합니다. `RDS`의 세부정보 페이지중 `연결 & 보안`탭에서 `VPC 보안 그룹`중 활성 항목을 클릭합니다.
|
||||
|
||||
`RDS`의 보안 그룹 정보를 그대로 두고, 브라우저를 새로 열어 봅니다. 새로 연 브라우저 창에서는 보안 그룹 규칙 탭에서 **EC2에 사용된 보안 그룹의 그룹 ID**를 복사합니다.
|
||||
|
||||
복사된 보안 그룹 ID와 본인의 IP를 아래 쪽의 인바운드 규칙 탭의 `인바운드 규칙 편집` 버튼을 통해 **RDS 보안 그룹의 인바운드**로 추가합니다.
|
||||
|
||||
인바운드 규칙 유형에서는 `MYSQL/Aurora`를 선택면 자동으로 3306 포트가 선택됩니다.
|
||||
|
||||
- 보안 그룹 첫 번째 줄 : 현재 내 PC의 IP를 등록합니다.
|
||||
- 보안 그룹 두 번째 줄 : `EC2`의 보안 그룹을 추가합니다.
|
||||
- 이렇게 하면 **EC2와 RDS 간에 접근이 가능**합니다.
|
||||
- `EC2`의 경우 이후에 2대 3대가 될 수도 있는데, 매번 IP를 등록할 수는 없으니 보편적으로 이렇게 보안 그룹 간에 연동을 진행합니다.
|
||||
|
||||
`RDS`와 개인 PC, `EC2` 간의 연동 설정은 모두 되었습니다. 로컬에서 한번 테스트해 보겠습니다.
|
||||
|
||||
### Database 플러그인 설치
|
||||
로컬에서 원격 데이터베이스로 붙을 때 GUI 클라이언트를 많이 사용합니다. `MySQL`의 대표적인 클라이언트로 `Workbench`, `SQLyog`(유료), `Sequel Pro`(맥 전용), `DataGrip`(유료) 등이 있습니다.
|
||||
|
||||
각각의 도구마다 큰 차이가 없으니 **본인이 가장 좋아하는 툴을** 사용하면 됩니다. 이 책에서는 **인텔리제이에 Database 플러그인**을 설치해서 진행하겠습니다.
|
||||
|
||||
`RDS` 정보 페이지의 `연결 & 보안` 탭에서 **엔드 포인트**를 확인합니다. 이 엔드 포인트가 접근 가능한 URL이므로 메모장 같은 곳에 복사해 둡니다.
|
||||
|
||||
이 책에서는 `Database Navigator` 플러그인을 사용하고 있으나 인텔리제이의 공식 플러그인이 아닙니다.
|
||||
|
||||
~~자세한 내용은 책을 참조할 것. 인텔리제이 얼티메이트 버전에는 해당되지 않는 것 같습니다.~~
|
||||
|
||||
#### IntelliJ Ultimate 기준
|
||||
|
||||

|
||||
|
||||
`Database` -> `Data Source` -> `MySQL` 선택(**MariaDB는 MySQL 기반이므로**) 후 본인이 생성한 `RDS`의 정보를 차례로 등록합니다.
|
||||
|
||||

|
||||
|
||||
- Host : 방금 전 복사한 RDS의 엔드 포인트를 등록합니다.
|
||||
- User : 데이터베이스를 만들때 입력한 마스터 사용자 이름을 입력합니다.
|
||||
- Password : 마스터 암호를 입력합니다.
|
||||
|
||||
마스터 계정명과 비밀번호를 등록한 뒤, 화면 아래의 `[Test Connection]`을 클릭해 연결 테스트를 해봅니다.
|
||||
|
||||
`Connection Successful` 메시지를 보았다면 `[Apply -> OK]` 버튼을 차례로 눌러 최종 저장을 합니다.
|
||||
|
||||

|
||||
|
||||
그럼 인텔리제이에 `RDS`의 스키마가 노출됩니다. 위쪽에 있는 `[Open SQL Console]` 버튼을 클릭하고 `[New SQL Console..]` 항목을 선택해서 SQL을 실행할 콘솔창을 열어보겠습니다.
|
||||
|
||||

|
||||
|
||||
사진에 보이는 `console`의 이름을 적당히 바꿔준 뒤, 생성된 콘솔창에서 SQL을 실행해 보겠습니다.
|
||||
|
||||
쿼리가 수행될 `database`를 선택하는 쿼리입니다.
|
||||
|
||||
>use AWS `RDS 웹 콘솔에서 지정한 데이터베이스명`;
|
||||
|
||||
만약 본인이 `RDS` 생성 시 지정한 `database` 명을 잊었다면 인텔리제이의 `Database`탭에 생성된 `DB`의 `Schema` 항목을 보면 **MySQL에서 기본으로 생성하는 스키마 외에 다른 스키마**가 1개 추가되어 있으니 이를 확인하면 됩니다.
|
||||
|
||||
데이터베이스가 선택된 상태에서 **현재의 character_set, collation** 설정을 확인합니다.
|
||||
|
||||
> show variables like 'c%';
|
||||
|
||||
쿼리 결과를 보면 다른 필드들은 모두 `utf8mb4`가 잘 적용되었는데 `character_set_database`, `collation_connection` 2가지 항목이 `latin1`로 되어있습니다.
|
||||
|
||||
이 2개의 항목이 `MariaDB`에서만 `RDS` 파라미터 그룹으로는 변경이 안됩니다. 그래서 직접 변경하기 위해 다음 쿼리를 실행합니다.
|
||||
|
||||
>ALTER DATABASE 데이터베이스명
|
||||
>CHARACTER SET = 'utf8mb4'
|
||||
>COLLATE = 'utf8mb4_general_ci';
|
||||
|
||||
쿼리를 수행하였다면 다시 한번 `character set`을 확인해 봅니다.
|
||||
|
||||
성공적으로 모든 항목이 `utf8mb4`로 변경된 것을 확인했다면 **타임존**까지 아래 쿼리로 확인해 봅니다.
|
||||
|
||||
>select @@time_zone, now();
|
||||
|
||||
`RDS` 파라미터 그룹이 잘 적용되어 한국 시간으로 된 것을 확인할 수 있습니다.
|
||||
|
||||
마지막으로 한글명이 잘 들어가는지 간단한 테이블 생성과 `insert` 쿼리를 실행해 봅니다.
|
||||
|
||||
>테이블 생성은 인코딩 설정 변경 전에 생성되면 안 됩니다. 만들어질 당시의 설정값을 그대로 유지하고 있어, 자동 변경이 되지 않고 강제로 변경해야만 합니다. 웬만하면 테이블은 모든 설정이 끝난 후 생성하시는 것이 좋습니다.
|
||||
|
||||
다음 쿼리를 차례로 실행해 봅니다.
|
||||
|
||||
>CREATE TABLE test (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
content varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB;
|
||||
>
|
||||
>insert into test(content) values ('테스트');
|
||||
>
|
||||
>select * from test;
|
||||
|
||||
한글 데이터도 잘 등록되는 것을 확인할 수 있습니다.
|
||||
|
||||
`RDS`에 대한 모든 설정이 끝났습니다. 이제 이렇게 설정된 `RDS`가 `EC2`와 잘 연동되는지 확인해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7.4 EC2에서 RDS에서 접근 확인
|
||||
`EC2`에 `ssh` 접속을 진행합니다.
|
||||
|
||||
- 맥에선 `ssh` 서비스명
|
||||
- 윈도우에선 `putty`
|
||||
|
||||
접속되었다면 `MySQL` 접근 테스트를 위해 `MySQL CLI`를 설치하겠습니다.
|
||||
|
||||
> sudo yum install mysql
|
||||
|
||||
설치가 다 되었으면 로컬에서 접근하듯이 계정, 비밀번호, 호스트 주소를 사용해 `RDS`에 접속합니다.
|
||||
|
||||
> mysql -u 계정 -p -h Host주소
|
||||
>
|
||||
>ex) mysql -u banjjoknim -p -h springboot2-webservice.cdlzwe9h6r65.ap-northeast-2.rds.amazonaws.com
|
||||
|
||||
참고 링크 : [AWS : RDS 인스턴스 연결 오류](https://bravesuccess.tistory.com/196)
|
||||
|
||||
패스워드를 입력하라는 메시지가 나오면 패스워드까지 입력합니다. `EC2`에서 `RDS`로 접속되는 것을 확인할 수 있습니다.
|
||||
|
||||
`RDS`에 접속되었으면 실제로 생성한 `RDS`가 맞는지 간단한 쿼리를 한번 실행해 봅니다.
|
||||
|
||||
> show databases;
|
||||
|
||||
우리가 생성했던 데이터베이스(`springboot2-webservice`)가 있음을 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
433
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter8.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# Chapter8. EC2 서버에 프로젝트를 배포해 보자
|
||||
이제 실제로 서버에 서비스를 한번 배포해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 8.1 EC2에 프로젝트 Clone 받기
|
||||
먼저 깃허브에서 코드를 받아올 수 있게 `EC2`에 깃을 설치합니다. `EC2`로 접속해서 다음과 같이 명령어를 입력합니다.
|
||||
|
||||
>sudo yum install git
|
||||
|
||||
설치가 완료되면 다음 명령어로 설치 상태를 확인합니다.
|
||||
|
||||
>git --version
|
||||
|
||||
깃이 성공적으로 설치되면 `git clone`으로 프로젝트를 저장할 디렉토리를 생성합니다.
|
||||
|
||||
>mkdir ~/app && mkdir ~/app/step1
|
||||
|
||||
생성된 디렉토리로 이동합니다.
|
||||
|
||||
>cd ~/app/step1
|
||||
|
||||
본인의 깃허브 웹페이지에서 `https` 주소를 복사한 뒤, 복사한 `https` 주소를 통해 `git clone`을 진행합니다.
|
||||
|
||||
>git clone `복사한 주소`
|
||||
|
||||
그러면 클론이 진행되는 것을 볼 수 있습니다.
|
||||
|
||||
`git clone`이 끝났으면 클론된 프로젝트로 이동해서 파일들이 잘 복사되었는지 확인합니다.
|
||||
|
||||
>cd `프로젝트명`
|
||||
>ll(영어 LL의 소문자 - 현재 디렉토리 내의 파일 리스트를 보여준다)
|
||||
|
||||
프로젝트의 코드들이 모두 있으면 됩니다. 그리고 코드들이 잘 수행되는지 테스트로 검증하겠습니다.
|
||||
|
||||
>./gradlew test
|
||||
|
||||
`Chapter5`의 **기존 테스트에 Security 적용하기**까지 잘 적용했다면 정상적으로 테스트를 통과합니다.
|
||||
|
||||
테스트가 실패해서 수정하고 깃허브에 푸시를 했다면 프로젝트 폴더안에서 다음 명령어를 사용하면 됩니다.
|
||||
|
||||
>git pull
|
||||
|
||||
만약 다음과 같이 `gradlew` 실행 권한이 없다는 메시지가 뜬다면
|
||||
|
||||
>-bash: ./gradlew: Permission denied
|
||||
|
||||
다음 명령어로 실행 권한을 추가한 뒤 다시 테스트를 수행하면 됩니다.
|
||||
|
||||
>chmod +x ./gradlew
|
||||
|
||||
깃을 통해 프로젝트의 클론과 풀까지 잘 진행했으니 이제 프로젝트의 테스트, 빌드, 실행까지 진행합니다.
|
||||
|
||||
>현재 EC2엔 그레이들(Gradle)을 설치하지 않았습니다. 하지만, Gradle Task(ex: test)를 수행할 수 있습니다. 이는 프로젝트 내부에 포함된 `gradlew` 파일 때문입니다. 그레이들이 설치되지 않은 환경 혹은 버전이 다른 상황에서도 해당 프로젝트에 한해서 그레이들을 쓸 수 있도록 지원하는 `Wrapper` 파일입니다. 해당 파일을 직접 이용하기 때문에 별도로 설치할 필요가 없습니다.
|
||||
|
||||
> ###### 학습중 발생 오류 추가
|
||||
>Could not find or load main class org.gradle.wrapper.GradleWrapperMain 에러가 발생한다면?
|
||||
>
|
||||
>- 원인 : 현재 프로젝트에 `gradle/wrapper/gradle-wrapper.jar`이 존재하지 않아서 그런 것이다.
|
||||
>- 참고 링크 : [Could not find or load main class org.gradle.wrapper.GradleWrapperMain](https://stackoverflow.com/questions/29805622/could-not-find-or-load-main-class-org-gradle-wrapper-gradlewrappermain)
|
||||
>
|
||||
>나같은 경우는 `.gitignore`에 `gradle` 디렉토리까지 포함해서 발생한 문제였다. 그래서 `.gitignore`에 `!gradle/**`을 추가해서 해결했다.
|
||||
|
||||
> ###### 학습중 발생 오류 추가
|
||||
> Starting a Gradle Daemon (subsequent builds will be faster)
|
||||
> Task :compileJava FAILED
|
||||
>
|
||||
>FAILURE: Build failed with an exception.
|
||||
>
|
||||
>* What went wrong:
|
||||
>Execution failed for task ':compileJava'.
|
||||
> Could not target platform: 'Java SE 11' using tool chain: 'JDK 8 (1.8)'.
|
||||
>
|
||||
>즉, EC2의 자바 버전과 프로젝트의 자바 버전이 달라서 컴파일이 불가능하다는 뜻이다.
|
||||
>자바 버전을 맞춰서 설치하면 된다.
|
||||
>- 참고 링크 : [AWS EC2에 JDK 11 설치하기](https://pompitzz.github.io/blog/java/awsEc2InstallJDK11.html)
|
||||
|
||||
> ###### 학습중 발생 오류 추가
|
||||
>JVM crash log found: file:///home/ec2-user/app/step1/TIL/WebServiceBySpringBootAndAWS/hs_err_pid1785.log
|
||||
>
|
||||
>FAILURE: Build failed with an exception.
|
||||
>
|
||||
> Gradle build daemon disappeared unexpectedly (it may have been killed or may have crashed)
|
||||
>
|
||||
>Native memory allocation (mmap) failed to map 262144
|
||||
>INFO: os::commit_memory(0x00007f1000196000, 262144, 0) failed; error='Not enough space' (errno=12)
|
||||
>라는 문구를 발견했다. 메모리가 부족해서 생긴 문제인듯한데.. 어떻게 해결해야 할지 모르겠다...
|
||||
|
||||
---
|
||||
|
||||
## 8.2 배포 스크립트 만들기
|
||||
작성한 코드를 실제 서버에 반영하는 것을 배포라고 합니다. 이 책에서 배포라 하면 다음의 과정을 모두 포괄하는 의미라고 보면 됩니다.
|
||||
|
||||
- `git clone` 혹은 `git pull`을 통해 새 버전의 프로젝트 받음
|
||||
- `Gradle`이나 `Maven`을 통해 프로젝트 테스트와 빌드
|
||||
- `EC2` 서버에서 해당 프로젝트 실행 및 재실행
|
||||
|
||||
앞선 과정을 **배포할 때마다 개발자가 하나하나 명령어를 실행**하는 것은 불편함이 많습니다. 그래서 이를 쉘 스크립트로 작성해 스크립트만 실행하면 앞의 과정이 차례로 진행되도록 하겠습니다. 참고로 쉘 스크립트와 빔(`vim`)은 서로 다른 역할을 합니다. 쉘 스크립트는 `.sh`라는 파일 확장자를 가진 파일입니다. `node.js`가 `.js`라는 파일을 통해 서버에서 작동하는 것처럼 쉘 스크립트 역시 리눅스에서 기본적으로 사용할 수 있는 스크립트 파일의 한종류입니다.
|
||||
|
||||
빔은 리눅스 환경과 같이 `GUI`가 아닌 환경에서 사용할 수 있는 편집 도구입니다. 리눅스에선 빔 외에도 이맥스(`Emacs`), 나노(`nano`)등의 도구를 지원하지만 가장 대중적인 도구가 빔이다보니 이 책에서도 빔으로 리눅스 환경에서의 편집을 진행하겠습니다.
|
||||
|
||||
`~/app/step1/`에 `deploy.sh` 파일을 하나 생성합니다.
|
||||
|
||||
>vim ~/app/step1/deploy.sh
|
||||
|
||||
참고 링크 : [초심자를 위한 최소한의 vim 가이드](http://bit.ly/2Q3BpvZ)
|
||||
|
||||
다음의 코드를 추가합니다.
|
||||
|
||||
|
||||
>#!/bin/bash
|
||||
>
|
||||
>REPOSITORY=/home/ec2-user/app/step1 // 1.
|
||||
>PROJECT_NAME=springboot2-webservicee
|
||||
>
|
||||
>cd \$REPOSITORY/$PROJECT_NAME/ // 2.
|
||||
>
|
||||
>echo "> Git Pull" // 3.
|
||||
>
|
||||
>./gradlew build // 4.
|
||||
>
|
||||
>echo "> step1 디렉토리로 이동"
|
||||
>
|
||||
>cd $REPOSITORY
|
||||
>
|
||||
>echo "> Build 파일 복사"
|
||||
>
|
||||
>cp \$REPOSITORY/\$PROJECT_NAME/build/libs/*.jar $REPOSITORY/ // 5.
|
||||
>
|
||||
> echo "> 현재 구동중인 애플리케이션 pid 확인"
|
||||
>
|
||||
>CURRENT_PID=\$(pgrep -f ${PROJECT_NAME}.*.jar) // 6.
|
||||
>
|
||||
>echo "현재 구동 중인 애플리케이션 pid: \$CURRENT_PID"
|
||||
>
|
||||
>if [ -z "$CURRENT_PID" ]; then // 7.
|
||||
> echo "> 현재 구동 중인 애플리케이션이 없으므로 종료하지 않습니다."
|
||||
>else
|
||||
> echo "> kill -15 \$CURRENT_PID"
|
||||
> kill -15 \$CURRENT_PID
|
||||
> sleep 5
|
||||
>fi
|
||||
>
|
||||
>echo "> 새 애플리케이션 배포"
|
||||
>
|
||||
>JAR_NAME=\$(ls -tr $REPOSITORY/ | grep jar | tail -n 1) // 8.
|
||||
>
|
||||
>echo "> JAR Name : $JAR_NAME"
|
||||
>
|
||||
>nohup java -jar \$REPOSITORY/$JAR_NAME 2>&1 & // 9.
|
||||
|
||||
#### 코드설명
|
||||
|
||||
**1. REPOSITORY=/home/ec2-user/app/step1**
|
||||
- 프로젝트 디렉토리 주소는 스크립트 내에서 자주 사용하는 값이기 때문에 이를 변수로 저장합니다.
|
||||
- 마찬가지로 `PROJECT_NAME=springboot2-webservicee`도 동일하게 변수로 저장합니다.
|
||||
- 쉘에서는 **타입 없이** 선언하여 저장합니다.
|
||||
- 쉘에서는 `$ 변수명`으로 변수를 사용할 수 있습니다.
|
||||
|
||||
**2. cd \$REPOSITORY/$PROJECT_NAME/**
|
||||
- 제일 처음 `git clone` 받았던 디렉토리로 이동합니다.
|
||||
- 바로 위의 쉘 변수 설명을 따라 `/home/ec2-user/app/step1/springboot2-webservicee` 주소로 이동합니다.
|
||||
|
||||
**3. git pull**
|
||||
- 디렉토리 이동 후, `master` 브랜치의 최신 내용을 받습니다.
|
||||
|
||||
**4. ./gradlew build**
|
||||
- 프로젝트 내부의 `gradlew`로 `build`를 수행합니다.
|
||||
|
||||
**5. cp \$REPOSITORY/\$PROJECT_NAME/build/libs/*.jar $REPOSITORY/**
|
||||
- `build`의 결과물인 `jar` 파일을 복사해 `jar` 파일을 모아둔 위치로 복사합니다.
|
||||
|
||||
**6. CURRENT_PID=\$(pgrep -f ${PROJECT_NAME}.*.jar)**
|
||||
- 기존에 수행 중이던 스프링 부트 애플리케이션을 종료합니다.
|
||||
- `pgrep`은 `process id`만 추출하는 명령어입니다.
|
||||
- `-f` 옵션은 프로세스 이름으로 찾습니다.
|
||||
|
||||
**7. if ~ else ~ fi**
|
||||
- 현재 구동 중인 프로세스가 있는지 없는지를 판단해서 기능을 수행합니다.
|
||||
- `process id` 값을 보고 프로세스가 있으면 해당 프로세스를 종료합니다.
|
||||
|
||||
**8. JAR_NAME=\$(ls -tr $REPOSITORY/ | grep jar | tail -n 1)**
|
||||
- 새로 실행할 `jar` 파일명을 찾습니다.
|
||||
- 여러 `jar` 파일이 생기기 때문에 `tail -n`로 가장 나중의 `jar` 파일(최신 파일)을 변수에 저장합니다.
|
||||
|
||||
**9. nohup java -jar \$REPOSITORY/$JAR_NAME 2>&1 &**
|
||||
- 찾은 `jar` 파일명으로 해당 `jar` 파일을 `nohup`으로 실행합니다.
|
||||
- 스프링 부트의 장점으로 특별히 외장 톰캣을 설치할 필요가 없습니다.
|
||||
- 내장 톰캣을 사용해서 `jar` 파일만 있으면 바로 웹 애플리케이션 서버를 실행할 수 있습니다.
|
||||
- 일반적으로 자바를 실행할 때는 `java -jar`라는 명령어를 사용하지만, 이렇게 하면 사용자가 터미널 접속을 끊을 때 애플리케이션도 같이 종료됩니다.
|
||||
- 애플리케이션 실행자가 터미널을 종료해도 애플리케이션은 계속 구동될 수 있도록 `nohup` 명령어를 사용합니다.
|
||||
|
||||
이렇게 생성한 스크립트에 실행 권한을 추가합니다.
|
||||
|
||||
>chmod +x ./deploy.sh
|
||||
|
||||
그리고 다시 확인해 보면 `x` 권한이 추가된 것을 확인할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
이제 이 스크립트를 다음 명령어로 실행합니다.
|
||||
|
||||
>./deploy.sh
|
||||
|
||||
그러면 다음과 같이 로그가 출력되며 애플리케이션이 실행됩니다.
|
||||
|
||||

|
||||
|
||||
>###### 학습중 오류 발생 추가
|
||||
>
|
||||
>
|
||||
>
|
||||
>위 사진와 같은 오류가 발생해서 디렉토리 계층을 전부 파악해야 했다.
|
||||
>
|
||||
>
|
||||
>
|
||||
>내 경우에는 디렉토리 구조가
|
||||
/home/ec2-user/app/step1/TIL/WebServiceBySpringBootAndAWS 였다.
|
||||
따라서 이에 맞게 `deploy.sh`의 변수 값을 수정해서 해결했다.
|
||||
>
|
||||
>
|
||||
|
||||
잘 실행되었으니 `nohup.out` 파일을 열어 로그를 보겠습니다. `nohup.out`은 실행되는 애플리케이션에서 출력되는 모든 내용을 갖고 있습니다.
|
||||
|
||||
> vim nohup.out
|
||||
|
||||
`nohup.out` 제일 아래로 가면 `ClientRegistrationRepository`를 찾을 수 없다(`that could not be found.`)는 에러가 발생하면서 애플리케이션 실행에 실패했다는 것을 알 수 있습니다.
|
||||
|
||||
>오류 로그
|
||||
>
|
||||
>Method springSecurityFilterChain in org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration required a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' that could not be found.
|
||||
|
||||
|
||||
왜 이렇게 되었을까요?
|
||||
|
||||
---
|
||||
|
||||
## 8.3 외부 Security 파일 등록하기
|
||||
이유는 다음과 같습니다. `ClientRegistrationRepository`를 생성하려면 `clientId`와 `clientSecret`가 필수입니다. 로컬 PC에서 실행할 때는 `application-oauth.properties`가 있어서 문제가 없었습니다.
|
||||
|
||||
하지만 이 파일은 **.gitignore로 git에서 제외 대상**이라 깃허브에는 올라가있지 않습니다. 애플리케이션을 실행하기 위해 공개된 저장소에 `ClinetId`와 `ClientSecret`을 올릴 수는 없으니 **서버에서 직접 이 설정들을 가지고 있게** 하겠습니다.
|
||||
|
||||
먼저 `step1`이 아닌 `app` 디렉토리에 `properties` 파일을 생성합니다.
|
||||
|
||||
>vim /home/ec2-user/app/application-oauth.properties
|
||||
|
||||
그리고 로컬에 있는 `application-oauth.properties` 파일 내용을 그대로 붙여넣기를 한 뒤, 해당 파일을 저장하고 종료합니다(`:wq`). 그리고 방금 생성한 `application-oauth.properties`을 쓰도록 `deploy.sh` 파일을 수정합니다.
|
||||
|
||||
**`deploy.sh`**
|
||||
>...
|
||||
>nohup java -jar \ -Dspring.config.location=classpath:/application.properties,/home/ec2-user/app/application-oauth.properties \ \$REPOSITORY/$JAR_NAME 2>&1 &
|
||||
|
||||
##### 코드 설명
|
||||
**1. -Dspring.config.location**
|
||||
- 스프링 설정 파일 위치를 지정합니다.
|
||||
- 기본 옵션들을 담고 있는 `application-properties`과 `OAuth` 설정들을 담고 있는 `application-oauth.properties`의 위치를 지정합니다.
|
||||
- `classpath`가 붙으면 `jar` 안에 있는 `resources` 디렉토리를 기준으로 경로가 생성됩니다.
|
||||
- `application-oauth.properties`은 절대경로를 사용합니다. 외부에 파일이 있기 때문입니다.
|
||||
|
||||
수정이 다 되었다면 다시 `deploy.sh`를 실행해 봅니다.
|
||||
|
||||
>###### 학습중 발생 오류 추가
|
||||
>Error: Unable to access jarfile -Dspring.config.location=classpath:/application.properties,/home/ec2-user/app/application-oauth.properties
|
||||
Error: Unable to access jarfile
|
||||
>
|
||||
>띄어쓰기가 잘못이었다!!!!!!! `\`의 의미는 `이어쓰기`라고 한다...
|
||||
|
||||
그럼 다음과 같이 정상적으로 실행된 것을 확인할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
마지막으로 `RDS`에 접근하는 설정도 추가해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 8.4 스프링 부트 프로젝트로 RDS 접근하기
|
||||
`RDS`는 `MariaDB`를 사용 중입니다. 이 `MariaDB`에서 스프링부트 프로젝트를 실행하기 위해선 몇 가지 작업이 필요합니다. 진행할 작업은 다음과 같습니다.
|
||||
|
||||
- **테이블 생성** : `H2`에서 자동 생성해주던 테이블들을 `MariaDB`에선 직접 쿼리를 이용해 생성합니다.
|
||||
- **프로젝트 생성** : 자바 프로젝트가 `MariaDB`에 접근하려면 데이터베이스 드라이버가 필요합니다. `MariaDB`에서 사용 가능한 드라이버를 프로젝트에 추가합니다.
|
||||
- **EC2 (리눅스 서버) 설정** : 데이터베이스의 접속 정보는 중요하게 보호해야 할 정보입니다. 공개되면 외부에서 데이터를 모두 가져갈 수 있기 때문입니다. 프로젝트 안에 접속 정보를 갖고 있다면 깃허브와 같이 오픈된 공간에선 누구나 해킹할 위험이 있습니다. `EC2` 서버 내부에서 접속 정보를 관리하도록 설정합니다.
|
||||
|
||||
### RDS 테이블 생성
|
||||
먼저 `RDS` 테이블을 생성합니다. 여기선 `JPA`가 사용될 엔티티 테이블과 스프링 세션이 사용될 테이블 2가지 종류를 생성합니다. `JPA`가 사용할 테이블은 **테스트 코드 수행 시 로그로 생성되는 쿼리를 사용하면 됩니다.** 테스트 코드를 수행하면 다음과 같이 로그가 발생하니 `create table`부터 복사하여 `RDS`에 반영합니다.
|
||||
|
||||
>Hibernate: create table posts (id bigint not null auto_increment, created_date datetime, modified_date datetime, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB
|
||||
Hibernate: create table user (id bigint not null auto_increment, created_date datetime, modified_date datetime, email varchar(255) not null, name varchar(255) not null, picture varchar(255), role varchar(255) not null, primary key (id)) engine=InnoDB
|
||||
|
||||
스프링 세션 테이블은 `schema-mysql.sql` 파일에서 확인할 수 있습니다. `File` 검색으로 찾습니다.
|
||||
|
||||
>CREATE TABLE SPRING_SESSION(
|
||||
PRIMARY_ID CHAR(36) NOT NULL,
|
||||
SESSION_ID CHAR(36) NOT NULL,
|
||||
CREATION_TIME BIGINT NOT NULL,
|
||||
LAST_ACCESS_TIME BIGINT NOT NULL,
|
||||
MAX_INACTIVE_INTERVAL INT NOT NULL,
|
||||
EXPIRY_TIME BIGINT NOT NULL,
|
||||
PRINCIPAL_NAME VARCHAR(100),
|
||||
CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID)
|
||||
) ENGINE = InnoDB ROW_FORMAT = DYNAMIC;
|
||||
>
|
||||
>CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID);
|
||||
CREATE INDEX SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME);
|
||||
CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME);
|
||||
>
|
||||
>CREATE TABLE SPRING_SESSION_ATTRIBUTES(
|
||||
SESSION_PRIMARY_ID CHAR(36) NOT NULL,
|
||||
ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
|
||||
ATTRIBUTE_BYTES BLOB NOT NULL,
|
||||
CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
|
||||
CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) REFERENCES SPRING_SESSION (PRIMARY_ID) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB ROW_FORMAT = DYNAMIC;
|
||||
|
||||
이것 역시 복사하여 `RDS`에 반영합니다. `RDS`에 필요한 테이블은 모두 생성하였으니 프로젝트 설정으로 넘어갑니다.
|
||||
|
||||
### 프로젝트 설정
|
||||
먼저 `MariaDB` 드라이버를 `build.gradle`에 등록합니다(현재는 `H2` 드라이버만 있는 상태).
|
||||
|
||||
>compile('org.mariadb.jdbc:mariadb-java-client')
|
||||
|
||||
그리고 서버에서 구동될 환경을 하나 구성합니다(여기서 환경이란 스프링의 `profile`을 이야기합니다).
|
||||
`src/main/resources/`에 `application-real.properties` 파일을 추가합니다. 앞에서 이야기한 대로 `application-real.properties` 로 파일을 만들면 `profile=real`인 환경이 구성된다고 보면 됩니다. 실제 운영될 환경이기 때문에 보안/로그상 이슈가 될 만한 설정들을 모두 제거하며 **RDS 환경 profile** 설정이 추가됩니다.
|
||||
|
||||
>spring.profiles.include=oauth,real-db
|
||||
>spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
>spring.session.store-type=jdbc
|
||||
|
||||
모든 설정이 완료되었다면 깃허브로 푸시합니다.
|
||||
|
||||
### EC2 설정
|
||||
`OAuth`와 마찬가지로 `RDS` 접속 정보도 보호해야 할 정보이니 `EC2` 서버에 직접 설정 파일을 둡니다.
|
||||
`app` 디렉토리에 `application-real-db.properties` 파일을 생성합니다.
|
||||
|
||||
>vim ~/app/application-real-db.properties
|
||||
|
||||
그리고 다음과 같은 내용을 추가합니다.
|
||||
|
||||
>spring.jpa.hibernate.ddl-auto=none
|
||||
>spring.datasource.url=jdbc:mariadb://rds주소:포트명(기본은 3306)/database이름
|
||||
>spring.datasource.username=db계정
|
||||
>spring.datasource.password=db계정 비밀번호
|
||||
>spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
|
||||
|
||||
##### 코드설명
|
||||
**1. spring.jpa.hibernate.ddl-auto=none**
|
||||
- `JPA`로 테이블이 자동 생성되는 옵션을 `None`(생성하지 않음)으로 지정합니다.
|
||||
- `RDS`에는 실제 운영으로 사용될 테이블이니 절대 스프링 부트에서 새로 만들지 않도록 해야 합니다.
|
||||
- 이 옵션을 하지 않으면 자칫 테이블이 모두 새로 생성될 수 있습니다.
|
||||
- 주의해야 하는 옵션입니다.
|
||||
|
||||
마지막으로 `deploy.sh`가 `real profile`을 쓸 수 있도록 다음과 같이 개선합니다.
|
||||
|
||||
>...
|
||||
nohup java -jar \
|
||||
-Dspring.config.location=classpath:/application.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties,classpath:/application-real.properties \
|
||||
-Dspring.profiles.active-real \
|
||||
\$REPOSITORY/$JAR_NAME 2>&1 &
|
||||
|
||||
##### 코드설명
|
||||
**1. -Dspring.profiles.active=real**
|
||||
- `application-real.properties`를 활성화시킵니다.
|
||||
- `application-real.properties`의 `spring.profiles.include=oauth,real-db` 옵션 때문에 `real-db` 역시 함께 활성화 대상에 포함됩니다.
|
||||
|
||||
이렇게 설정한 후 다시 `deploy.sh`를 실행해 봅니다. `nohup.out` 파일을 열어 다음과 같이 로그가 보인다면 성공적으로 수행된 것입니다.
|
||||
|
||||
> Tomcat started on port(s): 8080 (http) with context path ''
|
||||
Started Application in 13.29 seconds (JVM running for ~~~)
|
||||
|
||||
`curl` 명령어로 `html` 코드가 정상적으로 보인다면 성공입니다.
|
||||
|
||||
>curl localhost:8080
|
||||
|
||||
마지막으로 실제 브라우저에서 로그인을 시도해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 8.5 EC2에서 소셜 로그인하기
|
||||
`curl` 명령어를 통해 `EC2`에 서비스가 잘 배포된 것을 확인하였으니 이제 브라우저에서 확인해볼 텐데, 그 전에 다음과 같은 몇 가지 작업을 해보겠습니다.
|
||||
|
||||
#### AWS 보안 그룹 변경
|
||||
먼저 `EC2`에 스프링 부트 프로젝트가 8080 포트로 배포되었으니, 8080포트가 보안 그룹에 열려 있는지 확인한 뒤, 해당 그룹의 인바운드 규칙에 8080 포트가 열려있다면 OK, 안 되어있다면 추가해줍니다.
|
||||
|
||||
#### AWS EC2 도메인으로 접속
|
||||
왼쪽 사이드바의 `[인스턴스]` 메뉴를 클릭해서 본인이 생성한 `EC2` 인스턴스를 선택하면 다음과 같이 상세 정보에서 **퍼블릭 DNS**를 확인할 수 있습니다.
|
||||
|
||||
이 주소가 `EC2`에 자동으로 할당된 **도메인**입니다. 인터넷이 되는 장소 어디나 이 주소를 입력하면 우리의 `EC2` 서버에 접근할 수 있습니다. 이 도메인 주소에 8080 포트를 붙여 브라우저에 입력하면 확인할 수 있습니다.
|
||||
|
||||
하지만 현재 상태에서는 해당 서비스에 **EC2의 도메인을 등록하지 않았기 때문에** 구글과 네이버 로그인이 작동하지 않습니다.
|
||||
|
||||
먼저 구글에 등록합니다.
|
||||
|
||||
#### 구글에 EC2 주소 등록
|
||||
[구글 웹 콘솔](https://console.cloud.google.com/home/dashboard)로 접속하여 본인의 프로젝트로 이동한 다음 `[API 및 서비스 -> 사용자 인증 정보]`로 이동합니다.
|
||||
|
||||
해당하는 `OAuth 2.0 클라이언트 ID`를 선택한 뒤, **승인된 리디렉션 URI**에 `EC2`의 퍼블릭 `DNS`를 등록합니다. 그리고 퍼블릭 `DNS` 주소에 `:8080/login/oauth2/code/google` 주소를 추가하여 승인된 리디렉션 URI에 등록합니다.
|
||||
|
||||
이제 `EC2 DNS` 주소로 이동해서 다시 구글 로그인을 시도해 보면 같이 로그인이 정상적으로 수행되는 것을 확인할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
### ~~해냈다!!!!!!!~~
|
||||
|
||||
#### 네이버에 EC2 주소 등록
|
||||
[네이버 개발자 센터](https://developers.naver.com/apps/#/myapps)로 접속해서 본인의 프로젝트로 이동합니다.
|
||||
|
||||
메뉴중에서 API설정 탭에 들어간 뒤, 아래로 내려가 보면 PC 웹 항목이 있는데 여기서 **서비스 URL과 Callback URL** 2개를 수정합니다.
|
||||
|
||||
(1) 서비스 URL
|
||||
- 로그인을 시도하는 서비스가 네이버에 등록된 서비스인지 판단하기 위한 항목입니다.
|
||||
- 8080 포트는 제외하고 실제 도메인 주소만 입력합니다.
|
||||
- 네이버에서 아직 지원되지 않아 하나만 등록 가능합니다.
|
||||
- 즉, `EC2`의 주소를 등록하면 `localhost`가 안됩니다.
|
||||
- 개발 단계에서는 등록하지 않는 것을 추천합니다.
|
||||
- `localhost`도 테스트하고 싶으면 네이버 서비스를 하나 더 생성해서 키를 발급받으면 됩니다.
|
||||
|
||||
(2) Callback URL
|
||||
- 전체 주소를 등록합니다(`EC2 퍼블릭 DNS:8080/login/oauth2/code/naver`)
|
||||
|
||||
2개 항목을 모두 수정/추가하였다면 구글과 마찬가지로 네이버 로그인도 정상적으로 수행되는 것을 확인할 수 있습니다.
|
||||
|
||||
간단하게나마 스프링 부트 프로젝트를 `EC2`에 배포해 보았습니다. 스크립트를 작성해서 간편하게 빌드와 배포를 진행한 것 같지만 현재 방식은 몇 가지 문제가 있습니다.
|
||||
|
||||
- 수동 실행되는 `Test`
|
||||
- 본인이 짠 코드가 다른 개발자의 코드에 영향을 미치지 않는지 확인하기 위해 전체 테스트를 수행해야만 합니다.
|
||||
- 현재 상태에선 항상 개발자가 작업을 진행할 때마다 수동으로 전체 테스트를 수행해야만 합니다.
|
||||
|
||||
- 수동 `Build`
|
||||
- 다른 사람이 작성한 브랜치와 본인이 작성한 브랜치가 합쳐졌을 때(`Merge`) 이상이 없는지는 `Build`를 수행해야만 알 수 있습니다.
|
||||
- 이를 매번 개발자가 직접 실행해봐야만 합니다.
|
||||
|
||||
다음 작업은 이런 수동 `Test & Build`를 자동화시켜서 **깃허브에 푸시를 하면 자동으로 Test & Build & Deploy**가 진행되도록 하는 것입니다.
|
||||
|
||||
---
|
||||
739
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/ChapterDescription/Chapter9.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# Chapter9. 코드가 푸시되면 자동으로 배포해 보자 - Travis CI 배포 자동화
|
||||
24시간 365일 운영되는 서비스에서 배포 환경 구축은 필수 과제 중 하나입니다. 여러 개발자의 코드가 **실시간으로** 병합되고, 테스트가 수행되는 환경, `master` 브랜치가 푸시되면 배포가 자동으로 이루어지는 환경을 구착하지 않으면 실수할 여지가 너무나 많습니다. 이번에는 이러한 배포 환경을 구성해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 9.1 CI & CD 소개
|
||||
앞에서 스크립트를 개발자가 직접 실행함으로써 발생하는 불편을 경험했습니다. 그래서 `CI`, `CD` 환경을 구축하여 이 과정을 개선하려고 합니다.
|
||||
|
||||
`CI`와 `CD`란 무엇일까? 코드 버전 관리를 하는 `VCS 시스템(Git, SVN 등)`에 `PUSH`가 되면 자동으로 테스트와 빌드가 수행되어 **안정적인 배포 파일을 만드는 과정**을 `CI(Continuous Integration - 지속적 통합)`라고 하며, 이 빌드 결과를 자동으로 운영 서버에 무중단 배포까지 진행되는 과정을 `CD(Continuous Deployment - 지속적인 배포)`라고 합니다.
|
||||
|
||||
여기서 주의할 점은 단순히 **CI 도구를 도입했다고 해서 CI를 하고 있는 것은 아닙니다.** [마틴 파울러의 블로그](http://bit.ly/2Yv0vFp)를 참고해 보면 `CI`에 대해 다음과 같은 4가지 규칙을 이야기합니다.
|
||||
|
||||
- 모든 소스 코드가 살아 있고(현재 실행되고) 누구든 현재의 소스에 접근할 수 있는 단일 지점을 유지할 것
|
||||
- 빌드 프로세스를 자동화해서 누구든 소스로부터 시스템을 빌드하는 단일 명령어를 사용할 수 있게 할 것
|
||||
- 테스팅을 자동화해서 단일 명령어로 언제든지 시스템에 대한 건전한 테스트 수트를 실핼할 수 있게 할 것
|
||||
- 누구나 현재 실행 파일을 얻으면 지금까지 가장 완전한 실행 파일을 얻었다는 확신을 하게 할 것
|
||||
|
||||
여기서 특히나 중요한 것은 **테스팅 자동화**입니다. 지속적으로 통합하기 위해서는 무엇보다 이 프로젝트가 **완전한 상태임을 보장**하기 위해 테스트 코드가 구현되어 있어야만 합니다.
|
||||
|
||||
- 추천 강의 : [백명석님의 클린코더스 - TDD편](http://bit.ly/2xtKinX)
|
||||
|
||||
---
|
||||
|
||||
## 9.2 Travis CI 연동하기
|
||||
`Travis CI`는 깃허브에서 제공하는 무료 `CI` 서비스입니다. 젠킨스와 같은 `CI` 도구도 있지만, 젠킨스는 설치형이기 때문에 이를 위한 `EC2` 인스턴스가 하나 더 필요합니다. 이제 시작하는 서비스에서 배포를 위한 `EC2` 인스턴스는 부담스럽기 때문에 오픈소스 웹 서비스인 `Travis CI`를 사용하겠습니다.
|
||||
|
||||
>`AWS`에서 `Travis CI`와 같은 `CI` 도구로 `CodeBuild`를 제공합니다. 하지만 빌드 시간만큼 요금이 부과되는 구조라 초기에 사용하기는 부담스럽습니다. 실제 서비스되는 `EC2`, `RDS`, `S3` 외에는 비용 부분을 최소화하는 것이 좋습니다.
|
||||
|
||||
### Travis CI 웹 서비스 설정
|
||||
`https://travis-ci.org/`에서 깃허브 계정으로 로그인을 한 뒤, 오른쪽 위에 `[계정명 -> Settings]`를 클릭합니다.
|
||||
|
||||
설정 페이지 아래쪽을 보면 깃허브 저장소 검색창이 있습니다. 여기서 저장소 이름을 입력해서 찾은 다음, 오른쪽의 상태바를 활성화시킵니다(`Legacy Services integration 메뉴`).
|
||||
|
||||
활성화한 저장소를 클릭하면 다음과 같이 저장소 빌드 히스토리 페이지로 이동합니다.
|
||||
|
||||

|
||||
|
||||
`Travis CI` 웹 사이트에서 설정은 이것이 끝입니다. 상세한 설정은 **프로젝트의 yml 파일로** 진행해야 합니다.
|
||||
|
||||
### 프로젝트 설정
|
||||
`Travis CI`의 상세한 설정은 프로젝트에 존재하는 `.travis.yml` 파일로 할 수 있습니다. `yml` 파일 확장자를 `YAML(야믈)`이라고 합니다. `YAML`은 쉽게 말해서 **JSON에서 괄호를 제거한** 것입니다.
|
||||
|
||||
프로젝트의 `build.gradle`과 같은 위치에서 `.travis.yml`을 생성한 후 다음의 코드를 추가합니다.
|
||||
|
||||
**.travis.yml**
|
||||
```java
|
||||
language: java
|
||||
jdk:
|
||||
- openjdk8
|
||||
|
||||
branches: // 1.
|
||||
only:
|
||||
- master
|
||||
|
||||
# Travis CI 서버의 Home
|
||||
cache: // 2.
|
||||
directories:
|
||||
- '$HOME/.m2/repository'
|
||||
- '$HOME/.gradle'
|
||||
|
||||
script: "./gradlew clean build" // 3.
|
||||
|
||||
# 실행 완료 시 메일로 알람
|
||||
notifications: // 4.
|
||||
email:
|
||||
- recipients: 본인 메일 주소
|
||||
```
|
||||
##### 코드설명
|
||||
**1. branches**
|
||||
- `Travis CI`를 어느 브랜치가 푸시될 때 수행할지 지정합니다.
|
||||
- 현재 옵션은 오직 **master 브랜치에 push될 때만** 수행합니다.
|
||||
|
||||
**2. cache**
|
||||
- 그레이들을 통해 의존성을 받게 되면 이를 해당 디렉토리에 캐시하여, **같은 의존성은 다음 배포 때부터 다시 받지 않도록** 설정합니다.
|
||||
|
||||
**3. script**
|
||||
- `master` 브랜치에 푸시되었을 때 수행하는 명령어입니다.
|
||||
- 여기서는 프로젝트 내부에 둔 `gradlew`을 통해 `clean & build`를 수행합니다.
|
||||
|
||||
**4. notifications**
|
||||
- `Travis CI` 실행 완료 시 자동으로 알람이 가도록 설정합니다.
|
||||
|
||||
자 그럼 여기까지 마친 뒤, `master` 브랜치에 커밋과 푸시를 하고, 좀 전의 `Travis CI` 저장소 페이지를 확인합니다.
|
||||
|
||||
>###### 학습중 발생 오류 추가
|
||||
>계속해서 아무 변화가 없길래 여기저기 다 찾아봤지만 해결책을 찾을 수 없었다.
|
||||
>그러다가 [Travis](https://travis-ci.org/getting_started)의 2번 항목에서 해결책을 찾을 수 있었다.
|
||||
>
|
||||
>**Add a .travis.yml file to your repository**
|
||||
>
|
||||
>In order for Travis CI to build your project, you'll need to add a .travis.yml
|
||||
> configuration file to the root directory of your repository.
|
||||
>If a .travis.yml is not in your repository, or is not valid YAML, Travis CI will ignore it.
|
||||
>Here you can find some of our basic language examples.
|
||||
>
|
||||
>그러니까 쉽게 말하면, `repository`의 `root` 디렉토리에 `.travis.yml` 파일을 만들어야만 한다는 것이다. 나의 경우에는 프로젝트의 상위에 `TIL`이라는 `root` 디렉토리가 있었기 때문에 계속해서 안된 것이었다...
|
||||
|
||||
>###### 학습중 발생 오류 추가
|
||||
>
|
||||
>빌드가 되는줄 알았더니... Queued에서 멈춰버렸다.
|
||||
>[Jobs stuck on "Queued"](https://travis-ci.community/t/jobs-stuck-on-queued/5768)를 참고해보니, 존재하지 않는 환경을 요청하는 경우 발생한다고 한다...
|
||||
>따라서 환경에 대한 설정을 입력해주어야 한다.
|
||||
|
||||
>###### 학습중 발생 오류 추가
|
||||
>
|
||||
>`gradlew`는 실행 파일이다. 그리고 리눅스 환경에서 실행 파일은 **실행 권한이 있어야만 실행 가능**한데, 실행권한이 없기 때문에 발생한 에러라고 한다(접근권한이 아니라).
|
||||
>일반적으로 `gradlew` 파일에는 실행권한이 프로젝트 생성시점에 부여되며, **그 파일이 깃허브에 올라가기 때문에** 별도로 `x권한(실행)`을 주지 않아도 된다.
|
||||
>
|
||||
>결론적으로, `gradlew`에 실행권한이 자동으로 부여되지 않았기 때문에 발생한 오류인 것 같다. 따라서 해결책으로 `.travis.yml`에
|
||||
>`before_install: `
|
||||
>`- chmod +x gradlew`
|
||||
>를 추가해서 해결했다.
|
||||
>[참고 링크](https://github.com/jojoldu/freelec-springboot2-webservice/issues/75)
|
||||
|
||||
빌드가 성공한 것이 확인되면 `.travis.yml`에 등록한 이메일을 확인합니다.
|
||||
|
||||

|
||||
|
||||
#### 성공!!
|
||||
|
||||
---
|
||||
|
||||
## 9.3 Travis CI와 AWS S3 연동하기
|
||||
`S3`란 `AWS`에서 제공하는 **일종의 파일 서버**입니다. 이미지 파일을 비롯한 정적 파일들을 관리하거나 지금 진행하는 것처럼 배포 파일들을 관리하는 등의 기능을 지원합니다. 보통 이미지 업로드를 구현한다면 이 `S3`를 이용하여 구현하는 경우가 많습니다. `S3`를 비롯한 `AWS` 서비스와 `Travis CI`를 연동하게 되면 전체 구조는 다음과 같습니다.
|
||||
|
||||

|
||||
|
||||
첫 번째 단계로 `Travis CI`와 `S3`를 연동하겠습니다. 실제 배포는 `AWS CodeDeploy`라는 서비스를 이용합니다. 하지만, `S3` 연동이 먼저 필요한 이유는 **jar 파일을 전달하기 위해서**입니다.
|
||||
|
||||
`CodeDeploy`는 저장 기능이 없습니다. 그래서 `Travis CI`가 빌드한 결과물을 받아서 `CodeDeploy`가 가져갈 수 있도록 보관할 수 있는 공간이 필요합니다. 보통은 이럴 때 `AWS S3`를 이용합니다.
|
||||
|
||||
>`CodeDeploy`가 빌드도 하고 배포도 할 수 있습니다. `CodeDeploy`에서는 깃허브 코드를 가져오는 기능을 지원하기 때문입니다. 하지만 이렇게 할 때 빌드 없이 배포만 필요할 때 대응하기 어렵습니다.
|
||||
>
|
||||
>빌드와 배포가 분리되어 있으면 예전에 빌드되어 만들어진 `Jar`를 재사용하면 되지만, `CodeDeploy`가 모든 것을 하게 될 땐 항상 빌드를 하게 되니 확장성이 많이 떨어집니다. 그래서 웬만하면 빌드와 배포는 분리하는 것을 추천합니다.
|
||||
|
||||
`Travis CI`와 `AWS S3` 연동을 진행해 보겠습니다.
|
||||
|
||||
### AWS Key 발급
|
||||
일반적으로 `AWS` 서비스에 **외부 서비스가 접근할 수 없습니다.** 그러므로 **접근 가능한 권한을 가진 Key**를 생성해서 사용해야 합니다. `AWS`에서는 이러한 인증과 관련된 기능을 제공하는 서비스로 `IAM(Identity and Access Management)`이 있습니다.
|
||||
|
||||
`IAM`은 `AWS`에서 제공하는 서비스의 접근 방식과 권한을 관리합니다. 이 `IAM`을 통해 `Travis CI`가 `AWS`의 `S3`와 `CodeDeploy`에 접근할 수 있도록 해보겠습니다. `AWS` 웹 콘솔에서 `IAM`을 검색하여 이동합니다. `IAM` 페이지 왼쪽 사이드바에서 `[사용자 -> 사용자 추가]` 버튼을 차례로 클릭합니다.
|
||||
|
||||
생성할 사용자의 이름과 액세스 유형을 선택합니다. 액세스 유형은 **프로그래밍 방식 엑세스**입니다.
|
||||
|
||||
권한 설정 방식은 3개중 `[기존 정책 직접 연결]`을 선택합니다.
|
||||
|
||||
화면 아래 정책 검색 화면에서 `s3full`로 검색하여 체크하고 다음 권한으로 `CodeDeployFull`을 검색하여 체크합니다.
|
||||
|
||||
실제 서비스 회사에서는 권한도 **S3와 CodeDeploy를 분리해서 관리**하기도 합니다만, 여기서는 간단하게 둘을 합쳐서 관리하겠습니다. 2개의 권한이 설정되었으면 다음으로 넘어갑니다.
|
||||
|
||||
태그는 `Name` 값을 지정하는데, 본인이 인지 가능한 정도의 이름으로 만듭니다.
|
||||
|
||||
마지막으로 본인이 생성한 권한 설정 항목을 확인한 뒤, 최종 생성 완료되면 다음과 같이 액세스 키와 비밀 액세스 키가 생성됩니다. 이 두 값이 **Travis CI에서 사용될 키**입니다.
|
||||
|
||||
이제 이 키를 `Travis CI`에 등록하겠습니다.
|
||||
|
||||
### Travis CI에 키 등록
|
||||
먼저 `Travis CI`의 설정 화면으로 이동합니다(오른쪽의 `More options`의 `Settings` 버튼으로 진입합니다).
|
||||
|
||||

|
||||
|
||||
설정 화면을 아래로 조금 내려보면 `Environment Variables` 항목이 있습니다.
|
||||
|
||||
여기에 `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`를 변수로 해서 `IAM` 사용자에서 발급받은 키 값들을 등록합니다.
|
||||
|
||||
|`NAME`에 입력 | `VALUE`에 입력|
|
||||
|--|--|
|
||||
|AWS_ACCESS_KEY | 액세스 키 ID |
|
||||
|AWS_SECRET_KEY | 비밀 액세스 키|
|
||||
|
||||
각각 입력한 후 `Add` 버튼을 통해 추가하면 됩니다.
|
||||
|
||||
여기에 등록된 값들은 이제 `.travis.yml` 에서 `$AWS_ACCESS_KEY`, `$AWS_SECRET_KEY`란 이름으로 사용할 수 있습니다.
|
||||
|
||||
그럼 이제 이 키를 사용해서 `Jar`를 관리할 `S3` 버킷을 생성하겠습니다.
|
||||
|
||||
### S3 버킷 생성
|
||||
다음으로 `S3(Simple Storage Service)`에 관해 설정을 진행하겠습니다. `AWS`의 `S3` 서비스는 일종의 **파일 서버**입니다. 순수하게 파일들을 저장하고 접근 권한을 관리, 검색 등을 지원하는 파일 서버의 역할을 합니다.
|
||||
|
||||
`S3`는 보통 게시글을 쓸 때 나오는 첨부파일 등록을 구현할 때 많이 이용합니다. 파일 서버의 역할을 하기 때문인데, `Travis CI`에서 생성된 **Build 파일을 저장**하도록 구성하겠습니다. `S3`에 저장된 `Build` 파일은 이후 `AWS`의 `CodeDeploy`에서 배포할 파일로 가져가도록 구성할 예정입니다. `AWS` 서비스에서 `S3`를 검색하여 이동하고 버킷을 생성합니다(버킷 만들기 버튼 이용).
|
||||
|
||||
원하는 버킷명을 작성합니다. 이 버킷에 **배포할 Zip 파일이 모여있는 장소**임을 의미하도록 짓는 것을 추천합니다.
|
||||
|
||||
그리고 버전관리를 설정합니다. 별다른 설정을 할 것이 없으니 바로 넘어갑니다.
|
||||
|
||||
다음으로는 버킷의 보안과 권한 설정 부분입니다. 퍼블릭 액세스를 열어두지 말고 **모든 차단**을 해야 합니다. 현재 프로젝트는 깃허브에 오픈소스로 풀려있으니 문제없지만, 실제 서비스에서 할 때는 `Jar` 파일이 퍼블릭일 경우 누구나 내려받을 수 있어 코드나 설정값, 주요 키값들이 다 탈취될 수 있습니다.
|
||||
|
||||
퍼블릭이 아니더라도 우리는 `IAM` 사용자로 발급받은 키를 사용하니 접근 가능합니다. 그러므로 모든 액세스를 차단하는 설정에 체크합니다.
|
||||
|
||||
그리고 버킷을 생성하면 버킷 목록에서 확인할 수 있습니다.
|
||||
|
||||
`S3`가 생성되었으니 이제 이 `S3`로 배포 파일을 전달해 보겠습니다.
|
||||
|
||||
### .travis.yml 추가
|
||||
`Travis CI`에서 빌드하여 만든 `Jar` 파일을 `S3`에 올릴 수 있도록 `.travis.yml`에 다음 코드를 추가합니다.
|
||||
|
||||
```java
|
||||
...
|
||||
before_deploy:
|
||||
- zip -r springboot2-webservice *
|
||||
- mkdir -p deploy
|
||||
- mv springboot2-webservice.zip deploy/springboot2-webservice.zip
|
||||
|
||||
deploy:
|
||||
provider: s3
|
||||
access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
|
||||
secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
|
||||
|
||||
bucket: banjjoknim-springboot2-webservice-build # S3 버킷
|
||||
region: ap-northeast-2
|
||||
skip_cleanup: true
|
||||
acl: private # zip 파일 접근을 private로
|
||||
local_dir: deploy # before_deploy에서 생성한 디렉토리
|
||||
wait-until-deployed: true
|
||||
...
|
||||
```
|
||||
전체 코드는 다음과 같습니다. `Travis CI Settings` 항목에서 등록한 `$AWS_ACCESS_KEY`와 `$AWS_SECRET_KEY`가 변수로 사용됩니다.
|
||||
|
||||
```java
|
||||
language: java
|
||||
|
||||
jdk:
|
||||
- openjdk11 # 저는 프로젝트 자바 버전이 11입니다.
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
# Travis CI 서버의 Home
|
||||
cache:
|
||||
directories:
|
||||
- '$HOME/.m2/repository'
|
||||
- '$HOME/.gradle'
|
||||
|
||||
# Permission Denied 오류 해결을 위해 추가
|
||||
before_install:
|
||||
- chmod +x gradlew
|
||||
|
||||
script: "./gradlew clean build"
|
||||
|
||||
before_deploy: // 1.
|
||||
- zip -r springboot2-webservice * // 2.
|
||||
- mkdir -p deploy // 3.
|
||||
- mv springboot2-webservice.zip deploy/springboot2-webservice.zip // 4.
|
||||
|
||||
deploy: // 5.
|
||||
provider: s3
|
||||
access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
|
||||
secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
|
||||
|
||||
bucket: banjjoknim-springboot2-webservice-build # S3 버킷
|
||||
region: ap-northeast-2
|
||||
skip_cleanup: true
|
||||
acl: private # zip 파일 접근을 private로
|
||||
local_dir: deploy # before_deploy에서 생성한 디렉토리 // 6.
|
||||
wait-until-deployed: true
|
||||
|
||||
# 실행 완료 시 메일로 알람
|
||||
notifications:
|
||||
email:
|
||||
- recipients: 본인 메일 주소
|
||||
```
|
||||
|
||||
##### -----코드설명-----
|
||||
**1. before_deploy**
|
||||
- `deploy` 명령어가 실행되기 전에 수행됩니다.
|
||||
- `CodeDeploy`는 **Jar 파일은 인식하지 못하므로** `Jar+기타 설정 파일들`을 모아 압축(`zip`) 합니다.
|
||||
|
||||
**2. zip -r springboot2-webservice**
|
||||
- 현재 위치의 모든 파일을 `springboot2-webservice` 이름으로 압축(`zip`) 합니다.
|
||||
- 명령어의 마지막 위치는 본인의 프로젝트 이름이어야 합니다.
|
||||
|
||||
**3. mkdir -p deploy**
|
||||
- `deploy`라는 디렉토리를 `Travis CI`가 실행 중인 위치에서 생성합니다.
|
||||
|
||||
**4. mv springboot2-webservice.zip deploy/springboot2-webservice.zip**
|
||||
- `springboot2-webservice.zip` 파일을 `deploy/springboot2-webservice.zip`으로 이동시킵니다.
|
||||
|
||||
**5. deploy**
|
||||
- `S3`로 파일 업로드 혹은 `CodeDeploy`로 배포 등 **외부 서비스와 연동될 행위들을 선언**합니다.
|
||||
|
||||
**6. local_dir: deploy**
|
||||
- 앞에서 생성한 `deploy` 디렉토리를 지정합니다.
|
||||
- **해당 위치의 파일들만** `S3`로 전송합니다.
|
||||
|
||||
##### ----------------------
|
||||
|
||||
설정이 다 되었으면 **깃허브로 푸시**합니다. `Travis CI`에서 자동으로 빌드가 진행되는 것을 확인하고, 모든 빌드가 성공하는지 확인합니다. 다음 로그가 나온다면 `Travis CI`의 빌드가 성공한 것입니다.
|
||||
|
||||

|
||||
|
||||
그리고 `S3` 버킷을 가보면 업로드가 성공한 것을 확인할 수 있습니다.
|
||||
|
||||
`Travis CI`를 통해 자동으로 파일이 올려진 것을 확인할 수 있습니다.
|
||||
`Travis CI`와 `S3` 연동이 완료되었습니다. 이제 `CodeDeploy`로 배포까지 완료해 보겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 9.4 Travis CI와 AWS S3, CodeDeploy 연동하기
|
||||
`AWS`의 배포 시스템인 `CodeDeploy`를 이용하기 전에 배포 대상인 **EC2가 CodeDeploy를 연동 받을 수 있게** `IAM` 역할을 하나 생성하겠습니다.
|
||||
|
||||
### EC2에 IAM 역할 추가하기
|
||||
`S3`와 마찬가지로 `IAM`을 검색하고, 이번에는 `[역할]` 탭을 클릭해서 이동합니다. `[역할 -> 역할 만들기]` 버튼을 차례로 클릭합니다.
|
||||
|
||||
##### -----`IAM`의 사용자와 역할의 차이점-------
|
||||
- 역할
|
||||
- `AWS` 서비스에만 할당할 수 있는 권한
|
||||
- `EC2`, `CodeDeploy`, `SQS` 등
|
||||
- 사용자
|
||||
- **AWS 서비스 외**에 사용할 수 있는 권한
|
||||
- `로컬 PC`, `IDC 서버` 등
|
||||
##### ------------------------------------------------------
|
||||
|
||||
지금 만들 권한은 **EC2에서 사용할 것**이기 때문에 사용자가 아닌 역할로 처리합니다. 서비스 선택에서는 `[AWS 서비스 -> EC2]`를 차례로 선택합니다.
|
||||
|
||||
정책에선 `EC2RoleForA`를 검색하여 `AmazonEC2RoleforAWSCodeDeploy`를 선택한 뒤, 태그는 본인이 원하는 이름으로 짓습니다.
|
||||
|
||||
마지막으로 역할의 이름을 등록하고 나머지 등록 정보를 최종적으로 확인한 뒤 역할을 생성합니다.
|
||||
|
||||
이렇게 만든 역할을 `EC2` 서비스에 등록하겠습니다. `EC2` 인스턴스 목록으로 이동한 뒤, 본인의 인스턴스를 마우스 오른쪽 버튼으로 눌러 `[인스턴스 설정 -> IAM 역할 연결/바꾸기]`를 차례로 선택합니다(현재는 `[인스턴스 선택후 -> 작업 -> 보안 -> IAM 역할 수정]`을 이용하면 되는듯하다).
|
||||
|
||||
방금 생성한 역할을 선택한 뒤, 해당 `EC2` 인스턴스를 재부팅 합니다. 재부팅을 해야만 역할이 정상적으로 적용되니 꼭 한 번은 재부팅해 주세요.
|
||||
|
||||
재부팅이 완료되었으면 `CodeDeploy`의 요청을 받을 수 있게 에이전트를 하나 설치하겠습니다.
|
||||
|
||||
### CodeDeploy 에이전트 설치
|
||||
`EC2`에 접속해서 다음 명령어를 입력합니다.
|
||||
|
||||
>aws s3 cp s3://aws-codedeploy-ap-northeast-2/latest/install . --region ap-northeast-2
|
||||
|
||||
내려받기가 성공했다면 다음과 같은 메시지가 콘솔에 출력됩니다.
|
||||
|
||||
>download: s3://aws-codedeploy-ap-northeast-2/latest/install to ./install
|
||||
|
||||
`install` 파일에 실행 권한이 없으니 실행 권한을 추가합니다.
|
||||
|
||||
>chmod +x ./install
|
||||
|
||||
`install` 파일로 설치를 진행합니다(아래 명령어 입력).
|
||||
|
||||
>sudo ./install auto
|
||||
|
||||
>###### 학습중 오류 발생 추가
|
||||
>
|
||||
>**루비라는 언어가 설치되지 않은 상태**여서 발생하는 에러이다.
|
||||
>`Linux AMI`에서는 `sudo yum install ruby` 명령어를 실행해서 루비를 설치하면 해결된다.
|
||||
>
|
||||
>아래 사진은 `Ubuntu` 기준으로 루비를 설치하는 방법이다.
|
||||
>
|
||||
|
||||
설치가 끝났으면 `Agent`가 정상적으로 실행되고 있는지 상태 검사를 합니다.
|
||||
|
||||
>sudo service codedeploy-agent status
|
||||
|
||||
다음과 같이 `running` 메시지가 출력되면 정상입니다.
|
||||
|
||||
>The AWS CodeDeploy agent is running as PID xxx
|
||||
|
||||
### CodeDeploy를 위한 권한 생성
|
||||
`CodeDeploy`에서 `EC2`에 접근하려면 마찬가지로 권한이 필요합니다. `AWS`의 서비스이니 `IAM` 역할을 생성합니다. 서비스는 `[AWS 서비스 -> CodeDeploy]`를 차례로 선택합니다.
|
||||
|
||||
`CodeDeploy`는 권한이 하나뿐이라서 선택 없이 바로 다음으로 넘어갑니다.
|
||||
|
||||
태그 역시 본인이 원하는 이름으로 짓습니다.
|
||||
|
||||
`CodeDeploy`를 위한 역할 이름과 선택 항목들을 확인한 뒤 생성 완료를 합니다.
|
||||
|
||||
이제 `CodeDeploy`를 생성해 보겠습니다.
|
||||
|
||||
### CodeDeploy 생성
|
||||
`CodeDeploy`는 `AWS`의 배포 삼형제 중 하나입니다. 배포 삼형제에 대해 간단히 소개하자면 다음과 같습니다.
|
||||
|
||||
- `Code Commit`
|
||||
- 깃허브와 같은 코드 저장소의 역할을 합니다.
|
||||
- 프라이빗 기능을 지원한다는 강점이 있지만, 현재 **깃허브에서 무료로 프라이빗 지원**을 하고 있어서 거의 사용되지 않습니다.
|
||||
|
||||
- `Code Bulid`
|
||||
- `Travis CI`와 마찬가지로 **빌드용 서비스**입니다.
|
||||
- 멀티 모듈을 배포해야 하는 경우 사용해 볼만하지만, 규모가 있는 서비스에서는 대부분 **젠킨스/팀시티 등을 이용**하니 이것 역시 사용할 일이 없습니다.
|
||||
|
||||
- `CodeDeploy`
|
||||
- `AWS`의 배포 서비스입니다.
|
||||
- 앞에서 언급한 다른 서비스들은 대체재가 있고, 딱히 대체재보다 나은 점이 없지만, `CodeDeploy`는 대체재가 없습니다.
|
||||
- 오토 스케일링 그룹 배포, 블루 그린 배포, `EC2` 단독 배포 등 많은 기능을 지원합니다.
|
||||
|
||||
이 중에서 현재 진행 중인 프로젝트에서는 `Code Commit`의 역할은 깃허브가, `Code Build`의 역할은 `Travis CI`가 하고 있습니다. 그래서 우리가 추가로 사용할 서비스는 `CodeDeploy`입니다.
|
||||
|
||||
`CodeDeploy` 서비스로 이동해서 화면 중앙에 있는 `[애플리케이션 생성]` 버튼을 클릭한 뒤, 생성할 `CodeDeploy`의 이름과 컴퓨팅 플랫폼을 선택합니다. 컴퓨팅 플랫폼은 `[EC2/온프레미스]`를 선택하면 됩니다.
|
||||
|
||||
생성이 완료되면 배포 그룹을 생성합니다. 화면 중앙의 `[배포 그룹 생성]` 버튼을 클릭합니다.
|
||||
|
||||
배포 그룹과 서비스 역할을 등록합니다. 서비스 역할은 좀 전에 생성한 `CodeDeploy`용 `IAM` 역할을 선택하면 됩니다.
|
||||
|
||||
배포 유형에서는 **현재 위치**를 선택합니다. 만약 본인이 배포할 서비스가 2대 이상이라면 `블루/그린`을 선택하면 됩니다. 여기선 1대의 `EC2`에만 배포하므로 선택하지 않습니다.
|
||||
|
||||
환경 구성에서는 `[Amazon EC2 인스턴스]`에 체크한 뒤, 해당하는 인스턴스의 키와 값을 선택해줍니다.
|
||||
|
||||
마지막으로 배포 구성 중에서 `CodeDeployDefault.AllAtOnce`를 선택하고 `로드밸런싱 활성화` 항목은 체크 해제합니다.
|
||||
|
||||
배포 구성이란 한번 배포할 때 몇 대의 서버에 배포할지를 결정합니다. 2대 이상이라면 1대씩 배포할지, 30% 혹은 50%로 나눠서 배포할지 등등 여러 옵션을 선택하겠지만, 1대 서버다 보니 전체 배포하는 옵션으로 선택하면 됩니다.
|
||||
|
||||
배포 그룹까지 생성되었다면 `CodeDeploy` 설정은 끝입니다. 이제 `Travis CI`와 `CodeDeploy`를 연동해 보겠습니다.
|
||||
|
||||
### Travis CI, S3, CodeDeploy 연동
|
||||
먼저 `S3`에서 넘겨줄 `zip` 파일을 저장할 디렉토리를 하나 생성하겠습니다. `EC2` 서버에 접속해서 다음과 같이 디렉토리를 생성합니다.
|
||||
|
||||
>mkdir ~/app/step2 && mkdir ~/app/step2/zip
|
||||
|
||||
`Travis CI`의 `Build`가 끝나면 `S3`에 `zip` 파일이 전송되고, 이 `zip` 파일은 `/home/ec2-user/app/step2/zip`로 복사되어 압축을 풀 예정입니다.
|
||||
|
||||
`Travis CI`의 설정은 **.travis.yml로 진행**했습니다.
|
||||
|
||||
`AWS CodeDeploy`의 설정은 **appspec.yml로 진행**합니다(`.travis.yml`과 동일 위치에 생성).
|
||||
|
||||
코드는 다음과 같습니다.
|
||||
|
||||
```java
|
||||
version: 0.0 // 1.
|
||||
os: linux
|
||||
files:
|
||||
- source: / // 2.
|
||||
destination: /home/ec2-user/app/step2/zip/ // 3.
|
||||
overwrite: yes // 4.
|
||||
```
|
||||
##### -----코드설명-----
|
||||
**1. version: 0.0**
|
||||
- `CodeDeploy 버전`을 이야기합니다.
|
||||
- 프로젝트 버전이 아니므로 `0.0` 외에 다른 버전을 사용하면 오류가 발생합니다.
|
||||
|
||||
**2. source**
|
||||
- `CodeDeploy`에서 전달해 준 파일 중 `destination`으로 이동시킬 대상을 지정합니다.
|
||||
- 루트 경로(`/`)를 지정하면 전체 파일을 이야기합니다.
|
||||
|
||||
**3. destination**
|
||||
- `source`에서 지정된 파일을 받을 위치입니다.
|
||||
- 이후 `Jar`를 실행하는 등의 작업은 `destination`에서 옮긴 파일들로 진행됩니다.
|
||||
|
||||
**4. overwirte**
|
||||
- 기존에 파일들이 있으면 덮어쓸지를 결정합니다.
|
||||
- 현재 `yes`라고 했으니 파일들을 덮어쓰게 됩니다.
|
||||
|
||||
##### ------------------------
|
||||
|
||||
`.travis.yml`에도 `CodeDeploy` 내용을 추가합니다. `deploy` 항목에 다음 코드를 추가합니다.
|
||||
|
||||
```java
|
||||
...
|
||||
- provider: codedeploy
|
||||
access_key_id: $AWS_ACCESS_KEY
|
||||
secret_access_key: $AWS_SECRET_KEY
|
||||
bucket: banjjoknim-springboot2-webservice-build
|
||||
key: springboot2-webservice.zip # 빌드 파일을 압축해서 전달
|
||||
bundle_type: zip # 압축 확장자
|
||||
application: springboot2-webservice # 웹 콘솔에서 등록한 CodeDeploy 애플리케이션
|
||||
deployment_group: springboot2-webservice-group # 웹 콘솔에서 등록한 CodeDeploy 배포 그룹
|
||||
region: ap-northeast-2
|
||||
wait-until-deployed: true
|
||||
```
|
||||
`S3` 옵션과 유사합니다. 다른 부분은 `CodeDeploy`의 애플리케이션 이름과 배포 그룹명을 지정하는 것입니다.
|
||||
|
||||
전체 코드는 다음과 같습니다.
|
||||
|
||||
```java
|
||||
language: java
|
||||
|
||||
jdk:
|
||||
- openjdk11
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
# Travis CI 서버의 Home
|
||||
cache:
|
||||
directories:
|
||||
- '$HOME/.m2/repository'
|
||||
- '$HOME/.gradle'
|
||||
|
||||
# Permission Denied 오류 해결을 위해 추가
|
||||
before_install:
|
||||
- chmod +x gradlew
|
||||
|
||||
script: "./gradlew clean build"
|
||||
|
||||
before_deploy:
|
||||
- zip -r springboot2-webservice *
|
||||
- mkdir -p deploy
|
||||
- mv springboot2-webservice.zip deploy/springboot2-webservice.zip
|
||||
|
||||
deploy:
|
||||
- provider: s3
|
||||
access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
|
||||
secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
|
||||
|
||||
bucket: banjjoknim-springboot2-webservice-build # S3 버킷
|
||||
region: ap-northeast-2
|
||||
skip_cleanup: true
|
||||
acl: private # zip 파일 접근을 private로
|
||||
local_dir: deploy # before_deploy에서 생성한 디렉토리
|
||||
wait-until-deployed: true
|
||||
|
||||
- provider: codedeploy
|
||||
access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
|
||||
secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
|
||||
bucket: banjjoknim-springboot2-webservice-build # S3 버킷
|
||||
key: springboot2-webservice.zip # 빌드 파일을 압축해서 전달
|
||||
bundle_type: zip # 압축 확장자
|
||||
application: springboot2-webservice # 웹 콘솔에서 등록한 CodeDeploy 애플리케이션
|
||||
deployment_group: springboot2-webservice-group # 웹 콘솔에서 등록한 CodeDeploy 배포 그룹
|
||||
region: ap-northeast-2
|
||||
wait-until-deployed: true
|
||||
|
||||
# 실행 완료 시 메일로 알람
|
||||
notifications:
|
||||
email:
|
||||
- recipients: 본인 메일 주소
|
||||
```
|
||||
모든 내용을 작성했다면 프로젝트를 커밋하고 푸시합니다. 깃허브로 푸시가 되면 `Travis CI`가 자동으로 시작됩니다.
|
||||
`Travis CI`가 끝나면 `CodeDeploy` 화면 아래에서 배포가 수행되는 것을 확인할 수 있습니다(그룹 배포 내역).
|
||||
|
||||
>###### 학습중 발생 오류 추가
|
||||
>
|
||||
>CodeDeploy 환경구성에 Amazon EC2 인스턴스 설정이 잘못되어서 생긴 문제라고 한다
|
||||
(태그가 잘못되어서 생긴 문제).
|
||||
>또는 `travis.yml`에 오타가 있어서 트레비스 트리거가 작동하지 않아 발생한 오류라고 한다.
|
||||
>나의 경우에는 아래 사진에 표시된 부분이 달라서 트리거가 작동하지 않은 것 같다.
|
||||
>
|
||||
>참고 링크 : [travis ci , s3, codeDeploy 연동 실패](https://github.com/jojoldu/freelec-springboot2-webservice/issues/474), [Travis CI, AWS S3, AWS CodeDeploy 배포 오류](https://jhhj424.tistory.com/16)
|
||||
|
||||

|
||||
|
||||
배포가 끝났다면 다음 명령어로 파일들이 잘 도착했는지 확인해 봅니다.
|
||||
|
||||
>cd /home/ec2-user/app/step2/zip
|
||||
|
||||
파일 목록을 확인해 봅니다(`ls -al`)
|
||||
|
||||
프로젝트 파일들이 잘 도착한게 확인된다면 `Travis CI`와 `S3`, `CodeDeploy`의 연동이 완료된 것입니다.
|
||||
|
||||
---
|
||||
|
||||
## 9.5 배포 자동화 구성
|
||||
`Travis CI`, `S3`, `CodeDeploy` 연동까지 구현되었습니다. 이제 이것을 기반으로 실제로 **Jar를 배포하여 실행까지 해보겠습니다.**
|
||||
|
||||
### `deploy.sh` 파일 추가
|
||||
먼저 `step2` 환경에서 실행될 `deploy.sh`를 생성하겠습니다. `scripts` 디렉토리를 생성해서 여기에 스크립트를 생성합니다(`scripts`의 위치는 `build.gradle`과 동일한 위치).
|
||||
|
||||

|
||||
|
||||
>#!/bin/bash
|
||||
>
|
||||
>REPOSITORY=/home/ec2-user/app/step2
|
||||
>PROJECT_NAME=springboot2-webservice
|
||||
>
|
||||
>echo "> Build 파일 복사"
|
||||
>
|
||||
>cp \$REPOSITORY/zip/*.jar $REPOSITORY/
|
||||
>
|
||||
>echo "> 현재 구동 중인 애플리케이션 pid 확인"
|
||||
>
|
||||
>CURRENT_PID=\$(pgrep -fl \$PROJECT_NAME | grep jar | awk '{print $1}') // ①
|
||||
>
|
||||
>echo "현재 구동 중인 애플리케이션 pid : $CURRENT_PID"
|
||||
>
|
||||
>if [ -z "\$CURRENT_PID"]; then
|
||||
> echo "> 현재 구동 중인 애플리케이션이 없으므로 종료하지 않습니다."
|
||||
>else
|
||||
> echo "> kill -15 $CURRENT_PID"
|
||||
> kill -15 \$CURRENT_PID
|
||||
> sleep 5
|
||||
>fi
|
||||
>
|
||||
>echo "> 새 애플리케이션 배포"
|
||||
>
|
||||
>JAR_NAME=$(ls -tr \$REPOSITORY/*.jar | tail -n 1)
|
||||
>
|
||||
>echo "> JAR_NAME 에 실행권한 추가"
|
||||
>
|
||||
>chmod +x $JAR_NAME // ②
|
||||
>
|
||||
>echo "> $JAR_NAME 실행"
|
||||
>
|
||||
>nohup java -jar \\
|
||||
>-Dspring.config.location=classpath:/application.properties,classpath:/application-real.properties,/home/ec2-user/app/application-oauth.properties,/home/ec2-user/app/application-real-db.properties \\
|
||||
>-Dspring.profiles.active=real \\
|
||||
>\$JAR_NAME > $REPOSITORY/nohup.out 2>&1 & // ③
|
||||
|
||||
##### -----코드설명-----
|
||||
**① CURRENT_PID**
|
||||
- 현재 수행 중인 스프링 부트 애플리케이션의 프로세스 ID를 찾습니다.
|
||||
- 실쟁 중이면 종료하기 위해서입니다.
|
||||
- 스프링 부트 애플리케이션 이름(`springboot2-webservice`)으로 된 다른 프로그램들이 있을 수 있어 `springboot2-webservice`로 된 `jar(pgrep -fl springboot2-webservice | grep jar)` 프로세스를 찾은 뒤 ID를 찾습니다(`| awk '{print $1}`)
|
||||
|
||||
**② chmod +x $JAR_NAME**
|
||||
- `Jar` 파일은 실행 권한이 없는 상태입니다.
|
||||
- `nohup`으로 실행할 수 있게 실행 권한을 부여합니다.
|
||||
|
||||
**③ \$JAR_NAME > $REPOSITORY/nohup.out 2>&1 &**
|
||||
- `nohup` 실행 시 `CodeDeploy`는 **무한 대기**합니다.
|
||||
- 이 이슈를 해결하기 위해 `nohup.out` 파일을 표준 입출력용으로 별도로 사용합니다.
|
||||
- 이렇게 하지 않으면 `nohup.out` 파일이 생기지 않고, **CodeDeploy 로그에 표준 입출력이 출력됩니다.**
|
||||
- `nohup`이 끝나기 전까지 `CodeDeploy`도 끝나지 않으니 꼭 저렇게 해야합니다.
|
||||
|
||||
##### ------------------------
|
||||
|
||||
`step1`에서 작성된 `deploy.sh`와 크게 다르지 않습니다. 우선 `git pull`을 통해 **직접 빌드했던 부분을 제거**했습니다. 그리고 `Jar`를 실행하는 단계에서 몇 가지 코드가 추가되었습니다.
|
||||
|
||||
>플러그인 중 `BashSupport`를 설치하면 `.sh` 파일 편집 시 도움을 받을 수 있습니다.
|
||||
|
||||
`deploy.sh` 파일은 여기에서 끝입니다.
|
||||
다음으로 `.travis.yml` 파일을 수정하겠습니다.
|
||||
|
||||
### .travis.yml 파일 수정
|
||||
현재는 프로젝트의 모든 파일을 `zip` 파일로 만드는데, 실제로 필요한 파일들은 **Jar, appspec.yml, 배포를 위한 스크립트**들입니다. 이 외 나머지는 배포에 필요하지 않으니 포함하지 않겠습니다. 그래서 `.travis.yml` 파일의 `before_deploy`를 수정합니다.
|
||||
|
||||
>`.travis.yml` 파일은 `Travis CI`에서만 필요하지 `CodeDeploy`에서는 필요하지 않습니다.
|
||||
|
||||
>before_deploy:
|
||||
> - mkdir -p before-deploy # zip에 포함시킬 파일들을 담을 디렉토리 설정 // ①
|
||||
> - cp scripts/*.sh before-deploy/ // ②
|
||||
> - cp appspec.yml before-deploy/
|
||||
> - cp build/libs/*.jar before-deploy/
|
||||
> - cd before-deploy && zip -r before-deploy * # before-deploy로 이동 후 전체 압축 // ③
|
||||
> - cd ../ && mkdir -p deploy # 상위 디렉토리로 이동 후 deploy 디렉토리 생성
|
||||
> - mv before-deploy/before-deploy.zip deploy/sprinbboot2-webservice.zip # deploy로 zip파일 이동
|
||||
|
||||
##### -----코드설명-----
|
||||
**① Travis CI는 S3로 특정 파일만 업로드가 안됩니다.**
|
||||
- 디렉토리 단위로만 업로드할 수 있기 때문에 `deploy` 디렉토리는 항상 생성합니다.
|
||||
|
||||
**② before-deploy에는 zip 파일에 포함시킬 파일들을 저장합니다.**
|
||||
|
||||
**③ zip -r 명령어를 통해 before-deploy 디렉토리 전체 파일을 압축합니다.**
|
||||
|
||||
##### ----------------------
|
||||
|
||||
이 외 나머지 코드는 수정할 것이 없습니다.
|
||||
|
||||
마지막으로 `CodeDeploy`의 명령을 담당할 `appspec.yml` 파일을 수정합니다.
|
||||
|
||||
### appspec.yml 파일 수정
|
||||
`appspec.yml` 파일에 다음 코드를 추가합니다. `location`, `timeout`, `runas`의 들여쓰기를 주의해야 합니다. 들여쓰기가 잘못될 경우 배포가 실패합니다.
|
||||
|
||||
```java
|
||||
permissions:
|
||||
- object: /
|
||||
pattern: "**"
|
||||
owner: ec2-user
|
||||
group: ec2-user
|
||||
|
||||
hooks:
|
||||
ApplicationStart:
|
||||
- location: deploy.sh
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
```
|
||||
|
||||
##### -----코드설명-----
|
||||
**① permissions**
|
||||
- `CodeDeploy`에서 `EC2` 서버로 넘겨준 파일들을 모두 `ec2-user` 권한을 갖도록 합니다.
|
||||
|
||||
**② hooks**
|
||||
- `CodeDeploy` 배포 단계에서 실행할 명령어를 지정합니다.
|
||||
- `ApplicationStart`라는 단계에서 `deploy.sh`를 `ec2-user` 권한으로 실행하게 합니다.
|
||||
- `timeout: 60`으로 스크립트 실행 60초 이상 수행되면 실패가 됩니다(무한정 기다릴 수 없으니 시간 제한을 둬야만 합니다).
|
||||
|
||||
##### -----------------------
|
||||
|
||||
그래서 전체 코드는 다음과 같습니다.
|
||||
```java
|
||||
version: 0.0
|
||||
os: linux
|
||||
files:
|
||||
- source: /
|
||||
destination: /home/ec2-user/app/step2/zip/
|
||||
overwrite: yes
|
||||
|
||||
permissions:
|
||||
- object: /
|
||||
pattern: "**"
|
||||
owner: ec2-user
|
||||
group: ec2-user
|
||||
|
||||
hooks:
|
||||
ApplicationStart:
|
||||
- location: deploy.sh
|
||||
timeout: 60
|
||||
runas: ec2-user
|
||||
```
|
||||
모든 설정이 완료되었으니 깃허브로 커밋과 푸시를 합니다. `Travis CI`에서 다음과 같이 성공 메시지를 확인하고 `CodeDeploy`에서도 배포가 성공한 것을 확인합니다.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
웹 브라우저에서 `EC2` 도메인을 입력해서 확인해 봅니다.
|
||||
|
||||
마지막으로 실제 배포하듯이 진행해 보겠습니다.
|
||||
|
||||
### 실제 배포 과정 체험
|
||||
`build.gradle`에서 프로젝트 버전을 변경합니다.
|
||||
|
||||
>version '1.0.1-SNAPSHOT'
|
||||
|
||||
간단하게나마 변경된 내용을 알 수 있게 `src/main/resources/templates/index.mustache` 내용에 다음과 같이 `Ver.2` 텍스트를 추가합니다.
|
||||
|
||||
>...
|
||||
>`<h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>`
|
||||
>...
|
||||
|
||||
그리고 깃허브로 커밋과 푸시를 합니다. 그럼 **변경된 코드가 배포**된 것을 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 9.6 CodeDeploy 로그 확인
|
||||
`CodeDeploy`와 같이 `AWS`가 지원하는 서비스에서는 오류가 발생했을 때 로그 찾는 방법을 모르면 오류를 해결하기가 어렵습니다. 그래서 배포가 실패하면 어느 로그를 봐야 할지 간단하게 소개하려고 합니다.
|
||||
|
||||
`CodeDeploy`에 관한 대부분 내용은 `/opt/codedeploy-agent/deployment-root`에 있습니다. 해당 디렉토리로 이동(`cd /opot/codedeploy-agent/deployment-root`)한 뒤 목록을 확인해보면 다음과 같은 내용을 확인할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
##### 코드설명
|
||||
**① 최상단의 영문과 대시(-)가 있는 디렉토리명은 CodeDeploy ID입니다.**
|
||||
- 사용자마다 고유한 ID가 생성되어 각자 다른 ID가 발급되니 본인의 서버에는 다른 코드로 되어있습니다.
|
||||
- 해당 디렉토리로 들어가 보면 **배포한 단위별로 배포 파일들이** 있습니다.
|
||||
- 본인의 배포 파일이 정상적으로 왔는지 확인해 볼 수 있습니다.
|
||||
|
||||
**② /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log**
|
||||
- `CodeDeploy` 로그 파일입니다.
|
||||
- `CodeDeploy`로 이루어지는 배포 내용 중 표준 입/출력 내용은 모두 여기에 담겨 있습니다.
|
||||
- 작성한 `echo` 내용도 모두 표기됩니다.
|
||||
|
||||
테스트, 빌드, 배포까지 전부 자동화되었습니다. 이제는 작업이 끝난 내용을 **Master 브랜치에 푸시만 하면 자동으로 EC2에 배포**가 됩니다.
|
||||
|
||||
하지만 문제가 한 가지 남았습니다. **배포하는 동안** 스프링 부트 프로젝트는 종료 상태가 되어 **서비스를 이용할 수 없다**는 것입니다.
|
||||
|
||||
다음으로는 **서비스 중단 없는 배포** 방법을 소개하려고 합니다. 흔히 말하는 무중단 배포라고 생각하면 됩니다.
|
||||
|
||||
---
|
||||
|
After Width: | Height: | Size: 63 KiB |
BIN
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/images/Chapter10_grep_java.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 182 KiB |
BIN
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/images/Chapter10_scripts.PNG
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
BIN
도서/스프링 부트와 AWS로 혼자 구현하는 웹 서비스/src/images/Chapter8_deploy.PNG
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,13 @@
|
||||
package com.banjjoknim.book.springboot;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
// @EnableJpaAuditing 가 삭제됨
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.banjjoknim.book.springboot.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
@Configuration
|
||||
@EnableJpaAuditing // JPA Auditing 활성화
|
||||
public class JpaConfig {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.banjjoknim.book.springboot.config;
|
||||
|
||||
import com.banjjoknim.book.springboot.config.auth.LoginUserArgumentResolver;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
private final LoginUserArgumentResolver loginUserArgumentResolver;
|
||||
|
||||
@Override
|
||||
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
|
||||
argumentResolvers.add(loginUserArgumentResolver);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.banjjoknim.book.springboot.config.auth;
|
||||
|
||||
import com.banjjoknim.book.springboot.config.auth.dto.OAuthAttributes;
|
||||
import com.banjjoknim.book.springboot.config.auth.dto.SessionUser;
|
||||
import com.banjjoknim.book.springboot.domain.user.User;
|
||||
import com.banjjoknim.book.springboot.domain.user.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Collections;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
|
||||
private final UserRepository userRepository;
|
||||
private final HttpSession httpSession;
|
||||
|
||||
@Override
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
OAuth2UserService delegate = new DefaultOAuth2UserService();
|
||||
OAuth2User oAuth2User = delegate.loadUser(userRequest);
|
||||
|
||||
String registrationId = userRequest.getClientRegistration()
|
||||
.getRegistrationId();
|
||||
String userNameAttributeName = userRequest.getClientRegistration()
|
||||
.getProviderDetails()
|
||||
.getUserInfoEndpoint()
|
||||
.getUserNameAttributeName();
|
||||
|
||||
OAuthAttributes attributes = OAuthAttributes.
|
||||
of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
|
||||
|
||||
User user = saveOrUpdate(attributes);
|
||||
httpSession.setAttribute("user", new SessionUser(user));
|
||||
|
||||
return new DefaultOAuth2User(
|
||||
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
|
||||
attributes.getAttributes(),
|
||||
attributes.getNameAttributeKey());
|
||||
}
|
||||
|
||||
private User saveOrUpdate(OAuthAttributes attributes) {
|
||||
User user = userRepository.findByEmail(attributes.getEmail())
|
||||
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
|
||||
.orElse(attributes.toEntity());
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.banjjoknim.book.springboot.config.auth;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface LoginUser {
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.banjjoknim.book.springboot.config.auth;
|
||||
|
||||
import com.banjjoknim.book.springboot.config.auth.dto.SessionUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
|
||||
|
||||
private final HttpSession httpSession;
|
||||
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
|
||||
boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
|
||||
|
||||
return isLoginUserAnnotation && isUserClass;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
|
||||
return httpSession.getAttribute("user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.banjjoknim.book.springboot.config.auth;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.user.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
|
||||
private final CustomOAuth2UserService customOAuth2UserService;
|
||||
|
||||
@Override
|
||||
protected void configure(HttpSecurity http) throws Exception {
|
||||
http.csrf().disable().headers().frameOptions().disable()
|
||||
.and()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
|
||||
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.logout().logoutSuccessUrl("/")
|
||||
.and()
|
||||
.oauth2Login()
|
||||
.userInfoEndpoint()
|
||||
.userService(customOAuth2UserService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.banjjoknim.book.springboot.config.auth.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.user.Role;
|
||||
import com.banjjoknim.book.springboot.domain.user.User;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Getter
|
||||
public class OAuthAttributes {
|
||||
private Map<String, Object> attributes;
|
||||
private String nameAttributeKey;
|
||||
private String name;
|
||||
private String email;
|
||||
private String picture;
|
||||
|
||||
@Builder
|
||||
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture) {
|
||||
this.attributes = attributes;
|
||||
this.nameAttributeKey = nameAttributeKey;
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.picture = picture;
|
||||
}
|
||||
|
||||
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
|
||||
if ("naver".equals(registrationId)) {
|
||||
return ofNaver("id", attributes);
|
||||
}
|
||||
|
||||
return ofGoogle(userNameAttributeName, attributes);
|
||||
}
|
||||
|
||||
private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
|
||||
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
|
||||
|
||||
return OAuthAttributes.builder()
|
||||
.name((String) response.get("name"))
|
||||
.email((String) response.get("email"))
|
||||
.picture((String) response.get("profile_image"))
|
||||
.attributes(response)
|
||||
.nameAttributeKey(userNameAttributeName)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
|
||||
return OAuthAttributes.builder()
|
||||
.name((String) attributes.get("name"))
|
||||
.email((String) attributes.get("email"))
|
||||
.picture((String) attributes.get("picture"))
|
||||
.attributes(attributes)
|
||||
.nameAttributeKey(userNameAttributeName)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
public User toEntity() {
|
||||
return User.builder()
|
||||
.name(name)
|
||||
.email(email)
|
||||
.picture(picture)
|
||||
.role(Role.GUEST)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.banjjoknim.book.springboot.config.auth.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.user.User;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
@Getter
|
||||
public class SessionUser implements Serializable {
|
||||
private String name;
|
||||
private String email;
|
||||
private String picture;
|
||||
|
||||
public SessionUser(User user) {
|
||||
this.name = user.getName();
|
||||
this.email = user.getEmail();
|
||||
this.picture = user.getPicture();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.banjjoknim.book.springboot.domain;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.MappedSuperclass;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class BaseTimeEntity {
|
||||
|
||||
@CreatedDate
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
@LastModifiedDate
|
||||
private LocalDateTime modifiedDate;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.banjjoknim.book.springboot.domain.posts;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.BaseTimeEntity;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
public class Posts extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(length = 500, nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String content;
|
||||
|
||||
private String author;
|
||||
|
||||
@Builder
|
||||
public Posts(String title, String content, String author) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public void update(String title, String content) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.banjjoknim.book.springboot.domain.posts;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PostsRepository extends JpaRepository<Posts, Long> {
|
||||
|
||||
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
|
||||
List<Posts> findAllDesc();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.banjjoknim.book.springboot.domain.user;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum Role {
|
||||
|
||||
GUEST("ROLE_GUEST", "손님"),
|
||||
USER("ROLE_USER", "일반 사용자");
|
||||
|
||||
private final String key;
|
||||
private final String title;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.banjjoknim.book.springboot.domain.user;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.BaseTimeEntity;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
public class User extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String email;
|
||||
|
||||
@Column
|
||||
private String picture;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Role role;
|
||||
|
||||
@Builder
|
||||
public User(String name, String email, String picture, Role role) {
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.picture = picture;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public User update(String name, String picture) {
|
||||
this.name = name;
|
||||
this.picture = picture;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getRoleKey() {
|
||||
return this.role.getKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.banjjoknim.book.springboot.domain.user;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.banjjoknim.book.springboot.service;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import com.banjjoknim.book.springboot.domain.posts.PostsRepository;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsListResponseDto;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsResponseDto;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsSaveRequestDto;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsUpdateRequestDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class PostsService {
|
||||
private final PostsRepository postsRepository;
|
||||
|
||||
@Transactional
|
||||
public Long save(PostsSaveRequestDto requestDto) {
|
||||
return postsRepository.save(requestDto.toEntity()).getId();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Long update(Long id, PostsUpdateRequestDto requestDto) {
|
||||
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
|
||||
|
||||
posts.update(requestDto.getTitle(), requestDto.getContent());
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public PostsResponseDto findById(Long id) {
|
||||
Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
|
||||
|
||||
return new PostsResponseDto(entity);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PostsListResponseDto> findAllDesc() {
|
||||
return postsRepository.findAllDesc().stream()
|
||||
.map(PostsListResponseDto::new)
|
||||
.collect(toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
|
||||
|
||||
postsRepository.delete(posts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import com.banjjoknim.book.springboot.web.dto.HelloResponseDto;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class HelloController {
|
||||
|
||||
@GetMapping("/hello")
|
||||
public String hello() {
|
||||
return "hello";
|
||||
}
|
||||
|
||||
@GetMapping("/hello/dto")
|
||||
public HelloResponseDto helloDto(@RequestParam("name") String name, @RequestParam("amount") int amount) {
|
||||
return new HelloResponseDto(name, amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import com.banjjoknim.book.springboot.config.auth.LoginUser;
|
||||
import com.banjjoknim.book.springboot.config.auth.dto.SessionUser;
|
||||
import com.banjjoknim.book.springboot.service.PostsService;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsResponseDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Controller
|
||||
public class IndexController {
|
||||
|
||||
private final PostsService postsService;
|
||||
private final HttpSession httpSession;
|
||||
|
||||
@GetMapping("/")
|
||||
public String index(Model model, @LoginUser SessionUser user) {
|
||||
model.addAttribute("posts", postsService.findAllDesc());
|
||||
|
||||
if (user != null) {
|
||||
model.addAttribute("userName", user.getName());
|
||||
}
|
||||
return "index";
|
||||
}
|
||||
|
||||
@GetMapping("/posts/save")
|
||||
public String postsSave() {
|
||||
return "posts-save";
|
||||
}
|
||||
|
||||
@GetMapping("/posts/update/{id}")
|
||||
public String postsUpdate(@PathVariable Long id, Model model) {
|
||||
PostsResponseDto dto = postsService.findById(id);
|
||||
model.addAttribute("post", dto);
|
||||
|
||||
return "posts-update";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import com.banjjoknim.book.springboot.service.PostsService;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsResponseDto;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsSaveRequestDto;
|
||||
import com.banjjoknim.book.springboot.web.dto.PostsUpdateRequestDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class PostsApiController {
|
||||
|
||||
private final PostsService postsService;
|
||||
|
||||
@PostMapping("/api/v1/posts")
|
||||
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
|
||||
return postsService.save(requestDto);
|
||||
}
|
||||
|
||||
@PutMapping("/api/v1/posts/{id}")
|
||||
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
|
||||
return postsService.update(id, requestDto);
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/posts/{id}")
|
||||
public PostsResponseDto findById(@PathVariable Long id) {
|
||||
return postsService.findById(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/v1/posts/{id}")
|
||||
public Long delete(@PathVariable Long id) {
|
||||
postsService.delete(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class ProfileController {
|
||||
private final Environment env;
|
||||
|
||||
@GetMapping("/profile")
|
||||
public String profile() {
|
||||
List<String> profiles = Arrays.asList(env.getActiveProfiles());
|
||||
|
||||
List<String> realProfiles = Arrays.asList("real", "real1", "real2");
|
||||
|
||||
String defaultProfile = profiles.isEmpty() ? "default" : profiles.get(0);
|
||||
|
||||
return profiles.stream()
|
||||
.filter(realProfiles::contains)
|
||||
.findAny()
|
||||
.orElse(defaultProfile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class HelloResponseDto {
|
||||
|
||||
private final String name;
|
||||
private final int amount;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
public class PostsListResponseDto {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String author;
|
||||
private LocalDateTime modifiedDate;
|
||||
|
||||
public PostsListResponseDto(Posts entity) {
|
||||
this.id = entity.getId();
|
||||
this.title = entity.getTitle();
|
||||
this.author = entity.getAuthor();
|
||||
this.modifiedDate = entity.getModifiedDate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class PostsResponseDto {
|
||||
|
||||
private Long id;
|
||||
private String title;
|
||||
private String content;
|
||||
private String author;
|
||||
|
||||
public PostsResponseDto(Posts entity) {
|
||||
this.id = entity.getId();
|
||||
this.title = entity.getTitle();
|
||||
this.content = entity.getContent();
|
||||
this.author = entity.getAuthor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import com.banjjoknim.book.springboot.domain.posts.Posts;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class PostsSaveRequestDto {
|
||||
private String title;
|
||||
private String content;
|
||||
private String author;
|
||||
|
||||
@Builder
|
||||
public PostsSaveRequestDto(String title, String content, String author) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public Posts toEntity() {
|
||||
return Posts.builder()
|
||||
.title(title)
|
||||
.content(content)
|
||||
.author(author)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.banjjoknim.book.springboot.web.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class PostsUpdateRequestDto {
|
||||
private String title;
|
||||
private String content;
|
||||
|
||||
@Builder
|
||||
public PostsUpdateRequestDto(String title, String content) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
spring.profiles.include=oauth,real-db
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
spring.session.store-type=jdbc
|
||||
@@ -0,0 +1,4 @@
|
||||
server.port=8081
|
||||
spring.profiles.include=oauth,real-db
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
spring.session.store-type=jdbc
|
||||
@@ -0,0 +1,4 @@
|
||||
server.port=8082
|
||||
spring.profiles.include=oauth,real-db
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
spring.session.store-type=jdbc
|
||||
@@ -0,0 +1,5 @@
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
spring.h2.console.enabled=true
|
||||
spring.profiles.include=oauth
|
||||
spring.session.store-type=jdbc
|
||||
@@ -0,0 +1,74 @@
|
||||
var main = {
|
||||
init: function () {
|
||||
var _this = this;
|
||||
$('#btn-save').on('click', function () {
|
||||
_this.save();
|
||||
});
|
||||
|
||||
$('#btn-update').on('click', function () {
|
||||
_this.update();
|
||||
});
|
||||
|
||||
$('#btn-delete').on('click', function () {
|
||||
_this.delete();
|
||||
});
|
||||
},
|
||||
save: function () {
|
||||
var data = {
|
||||
title: $('#title').val(),
|
||||
author: $('#author').val(),
|
||||
content: $('#content').val()
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/api/v1/posts',
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
data: JSON.stringify(data)
|
||||
}).done(function () {
|
||||
alert('글이 등록되었습니다.');
|
||||
window.location.href = '/';
|
||||
}).fail(function (error) {
|
||||
alert(JSON.stringify(error));
|
||||
});
|
||||
},
|
||||
update: function () {
|
||||
var data = {
|
||||
title: $('#title').val(),
|
||||
content: $('#content').val()
|
||||
};
|
||||
|
||||
var id = $('#id').val();
|
||||
|
||||
$.ajax({
|
||||
type: 'PUT',
|
||||
url: '/api/v1/posts/' + id,
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
data: JSON.stringify(data)
|
||||
}).done(function () {
|
||||
alert('글이 수정되었습니다.');
|
||||
window.location.href = '/';
|
||||
}).fail(function (error) {
|
||||
alert(JSON.stringify(error));
|
||||
});
|
||||
},
|
||||
delete: function () {
|
||||
var id = $('#id').val();
|
||||
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: '/api/v1/posts/' + id,
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8'
|
||||
}).done(function () {
|
||||
alert('글이 삭제되었습니다.');
|
||||
window.location.href = '/';
|
||||
}).fail(function (error) {
|
||||
alert(JSON.stringify(error));
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
main.init();
|
||||
@@ -0,0 +1,44 @@
|
||||
{{>layout/header}}
|
||||
|
||||
<h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
|
||||
<div class="col-md-12">
|
||||
<!--로그인 기능 영역-->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
|
||||
{{#userName}}
|
||||
Logged in as : <span id="user">{{userName}}</span>
|
||||
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
|
||||
{{/userName}}
|
||||
{{^userName}}
|
||||
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
|
||||
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
|
||||
{{/userName}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<!-- 목록 출력 영역 -->
|
||||
<table class="table table-horizontal table-bordered">
|
||||
<thead class="thead-strong">
|
||||
<tr>
|
||||
<th>게시글번호</th>
|
||||
<th>제목</th>
|
||||
<th>작성자</th>
|
||||
<th>최종수정일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody">
|
||||
{{#posts}}
|
||||
<tr>
|
||||
<td>{{id}}</td>
|
||||
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
|
||||
<td>{{author}}</td>
|
||||
<td>{{modifiedDate}}</td>
|
||||
</tr>
|
||||
{{/posts}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{>layout/footer}}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
|
||||
|
||||
<!--index.js 추가-->
|
||||
<script src="/js/app/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<title>스프링 부트 웹 서비스</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -0,0 +1,26 @@
|
||||
{{>layout/header}}
|
||||
|
||||
<h1>게시글 등록</h1>
|
||||
|
||||
<div class="col-md-12">
|
||||
<div class="col-md-4">
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label for="title">제목</label>
|
||||
<input type="text" class="form-control" id="title" placeholder="제목을 입력하세요">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="author">작성자</label>
|
||||
<input type="text" class="form-control" id="author" placeholder="작성자를 입력하세요">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">내용</label>
|
||||
<textarea class="form-control" id="content" placeholder="내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<a href="/" role="button" class="btn btn-secondary">취소</a>
|
||||
<button type="button" class="btn btn-primary" id="btn-save">등록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{>layout/footer}}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{>layout/header}}
|
||||
|
||||
<h1>게시글 수정</h1>
|
||||
|
||||
<div class="col-md-12">
|
||||
<div class="col-md-4">
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label for="id">글 번호</label>
|
||||
<input type="text" class="form-control" id="id" value="{{post.id}}" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="title">제목</label>
|
||||
<input type="text" class="form-control" id="title" value="{{post.title}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="author">작성자</label>
|
||||
<input type="text" class="form-control" id="author" value="{{post.author}}" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">내용</label>
|
||||
<textarea class="form-control" id="content">{{post.content}}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
<a href="/" role="button" class="btn btn-secondary">취소</a>
|
||||
<button type="button" class="btn btn-primary" id="btn-update">수정 완료</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-delete">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{>layout/footer}}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.banjjoknim.book.springboot.domain.posts;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest
|
||||
public class PostsRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
PostsRepository postsRepository;
|
||||
|
||||
@After
|
||||
public void cleanup() {
|
||||
postsRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void 게시글저장_불러오기() {
|
||||
// given
|
||||
String title = "테스트 게시글";
|
||||
String content = "테스트 본문";
|
||||
|
||||
postsRepository.save(Posts.builder()
|
||||
.title(title)
|
||||
.content(content)
|
||||
.author("banjjoknim")
|
||||
.build());
|
||||
|
||||
// when
|
||||
List<Posts> postsList = postsRepository.findAll();
|
||||
|
||||
// then
|
||||
Posts posts = postsList.get(0);
|
||||
assertThat(posts.getTitle()).isEqualTo(title);
|
||||
assertThat(posts.getContent()).isEqualTo(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void BaseTimeEntity_등록() {
|
||||
|
||||
// given
|
||||
LocalDateTime now = LocalDateTime.of(2020, 11, 19, 0, 0, 0);
|
||||
postsRepository.save(Posts.builder()
|
||||
.title("title")
|
||||
.content("content")
|
||||
.author("author")
|
||||
.build());
|
||||
|
||||
// when
|
||||
List<Posts> postsList = postsRepository.findAll();
|
||||
|
||||
// then
|
||||
Posts posts = postsList.get(0);
|
||||
|
||||
System.out.println(">>>>>>>>>> createDate=" + posts.getCreatedDate() + ", modifiedDate=" + posts.getModifiedDate());
|
||||
|
||||
assertThat(posts.getCreatedDate()).isAfter(now);
|
||||
assertThat(posts.getModifiedDate()).isAfter(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.banjjoknim.book.springboot.web;
|
||||
|
||||
import com.banjjoknim.book.springboot.config.auth.SecurityConfig;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.FilterType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@WebMvcTest(controllers = HelloController.class, excludeFilters = {
|
||||
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
|
||||
})
|
||||
public class HelloControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
public void hello가_리턴된다() throws Exception {
|
||||
String hello = "hello";
|
||||
|
||||
mvc.perform(get("/hello"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(hello));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
public void helloDto가_리턴된다() throws Exception {
|
||||
String name = "hello";
|
||||
int amount = 1000;
|
||||
|
||||
mvc.perform(get("/hello/dto")
|
||||
.param("name", name)
|
||||
.param("amount", String.valueOf(amount)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name", is(name)))
|
||||
.andExpect(jsonPath("$.amount", is(amount)));
|
||||
}
|
||||
}
|
||||