refactor : 전체 디렉토리 구조 변경

This commit is contained in:
WIN10-01\UserK
2021-12-13 21:23:43 +09:00
parent cd773bab0f
commit 326dd3a4bc
524 changed files with 1 additions and 1 deletions

View 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

View 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

View 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

View 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')
}

View 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

View 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" "$@"

View 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

View 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 &

View 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

View 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
}

View 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 &

View 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

View 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" 사용하였음.
}

View File

@@ -0,0 +1 @@
# Chapter1. 인텔리제이로 스프링 부트 시작하기

View File

@@ -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**은 다음과 같은 구조가 됩니다.
![Chapter10_nginx_무중단배포_1](https://user-images.githubusercontent.com/68052095/101275875-79c9a600-37ec-11eb-98e8-8bf136781f9a.png)
운영 과정은 다음과 같습니다.
- ① 사용자는 서비스 주소로 접속합니다(`80` 혹은 `443` 포트).
- ② 엔진엑스는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청을 전달합니다.
- 스프링 부트1 즉, 8081 포트로 요청을 전달한다고 가정합니다.
- ③ 스프링 부트2는 엔진엑스와 연결된 상태가 아니니 요청받지 못합니다.
1.1 버전으로 신규 배포가 필요하면, 엔진엑스와 연결되지 않은 스프링 부트2(8082 포트)로 배포합니다(아래 사진).
![Chapter10_nginx_무중단배포_2](https://user-images.githubusercontent.com/68052095/101275874-79310f80-37ec-11eb-85ce-4684565138c6.png)
- ① 배포하는 동안에도 서비스는 중단되지 않습니다.
- 엔진엑스는 스프링 부트1을 바라보기 때문입니다.
- ② 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인합니다.
- ③ 스프링 부트2가 정상 구동 중이면 `nginx reload` 명령어를 통해 `8081` 대신에 `8082`를 바라보도록 합니다.
-`nginx reload`는 0.1초 이내에 완료됩니다.
이후 1.2 버전 배포가 필요하면 이번에는 스프링 부트1로 배포합니다(아래 사진).
![Chapter10_nginx_무중단배포_3](https://user-images.githubusercontent.com/68052095/101275873-79310f80-37ec-11eb-85e5-f727663f69ca.png)
- ① 현재는 엔진엑스와 연결된 것이 스프링 부트2입니다.
- ② 스프링 부트1의 배포가 끝났다면 엔진엑스가 스프링 부트1을 바라보도록 변경하고 `nginx reload`를 실행합니다.
- ③ 이후 요청부터는 엔진엑스가 스프링 부트 1로 요청을 전달합니다.
이렇게 구성하게 되면 전체 시스템 구조는 다음과 같습니다.
![Chapter10_nginx_무중단배포_전체_구조](https://user-images.githubusercontent.com/68052095/101275872-77ffe280-37ec-11eb-88eb-c1f6e69b8fa6.png)
기존 구조에서 `EC2` 내부의 구조만 변경된 것이니 크게 걱정하지 않아도 됩니다.
사진 출처 : [기억보단 기록을](https://jojoldu.tistory.com/267)
---
## 10.2 엔진엑스 설치와 스프링 부트 연동하기
가장 먼저 `EC2`에 엔진엑스를 설치하겠습니다.
#### 엔진엑스 설치
`EC2`에 접속해서 다음 명령어로 엔진엑스를 설치합니다.
>sudo yum install nginx
설치가 완료되었으면 다음 명령어로 엔진엑스를 실행합니다.
>sudo service nginx start
엔진엑스가 잘 실행되었다면 다음과 같은 메시지를 볼 수 있습니다.
>Starting nginx: [ OK ]
>###### 학습중 발생 오류 추가
>![Chapter10_nginx_service_start_error](https://user-images.githubusercontent.com/68052095/101275172-e346b600-37e6-11eb-94d4-2274484363a6.PNG)
>명령어로 `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번 포트를 보안 그룹에 추가]
![Chapter10_nginx_inbound_add](https://user-images.githubusercontent.com/68052095/101275268-b0e98880-37e7-11eb-8060-22478c0c4da5.PNG)
#### 리다이렉션 주소 추가
`8080`이 아닌 `80`포트로 주소가 변경되니 구글과 네이버 로그인에도 변경된 주소를 등록해야만 합니다. 기존에 등록된 리디렉션 주소에서 `8080` 부분을 제거하여 추가 등록합니다. 앞서 진행된 `Chapter8`을 참고하여 구글과 네이버에 차례로 등록합니다.
![Chapter10_nginx_google_domain_add](https://user-images.githubusercontent.com/68052095/101275320-1ccbf100-37e8-11eb-904a-d92e28c42419.png)
![Chapter10_nginx_naver_domain_add](https://user-images.githubusercontent.com/68052095/101275369-67e60400-37e8-11eb-80b3-b45109d9b84c.png)
추가한 후에는 `EC2`의 도메인으로 접근하되, **8080 포트를 제거하고** 접근해 봅니다. 즉, 포트번호 없이 도메인만 입력해서 브라우저에서 접속합니다.
>`80번 포트는 기본적으로 도메인에서 포트번호가 제거된 상태입니다.`
그럼 다음과 같이 엔진엑스 웹페이지를 볼 수 있습니다.
![Chapter10_nginx_home](https://user-images.githubusercontent.com/68052095/101275799-da0c1800-37eb-11eb-97b5-dc7cd1db5c99.PNG)
이제 스프링 부트와 연동해 보겠습니다.
#### 엔진엑스와 스프링 부트 연동
엔진엑스가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 하겠습니다. 엔진엑스 설정 파일을 열어봅니다.
>sudo vim /etc/nginx/nginx.conf
설정 내용 중 `server` 아래의 `location /` 부분을 찾아서 다음과 같이 추가합니다.
![Chapter10_nginx_location](https://user-images.githubusercontent.com/68052095/101276215-85b66780-37ee-11eb-820a-dc838291c482.png)
>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` 디렉토리에 추가합니다.
![Chapter10_scripts](https://user-images.githubusercontent.com/68052095/101280263-7db8f080-380b-11eb-9ac8-d0e81bb7397c.PNG)
#### `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`
그럼 다음과 같은 메시지가 차례로 출력됩니다.
![Chapter10_deployments_log](https://user-images.githubusercontent.com/68052095/101282400-53216480-3818-11eb-9d7b-c844c5f37f3f.PNG)
스프링 부트 로그도 보고 싶다면 다음 명령어로 확인할 수 있습니다.
>`vim ~/app/step3/nohup.out`
그럼 스프링 부트 실행 로그를 직접 볼 수 있습니다. 한 번 더 배포하면 그때는 `real2`로 배포됩니다. 이 과정에서 브라우저 새로고침을 해보면 전혀 중단 없는 것을 확인할 수 있습니다. 2번 배포를 진행한 뒤에 다음과 같이 자바 애플리케이션 실행 여부를 확인합니다.
>`ps -ef | grep java`
다음과 같이 2개의 애플리케이션(`real1`, `real2`)이 실행되고 있음을 알 수 있습니다.
![Chapter10_grep_java](https://user-images.githubusercontent.com/68052095/101282469-aa273980-3818-11eb-87e5-3fde7e548517.png)
이제 이 시스템은 마스터 브랜치에 푸시가 발생하면 자동으로 서버 배포가 진행되고, 서버 중단 역시 전혀 없는 시스템이 되었습니다.
---
#### 추가사항
실습 중간중간에 스크립트에 문제가 있는 것인지 새롭게 배포를 할 때마다 기존에 실행되어있던 프로젝트가 종료되지 않았고, 그로 인해 `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)

View 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`로 검증합니다.
---

View 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 웹 계층**
![Spring 웹 계층](https://blog.kakaocdn.net/dn/bFruEV/btqAUv4HJLQ/H5TVBjqkKc5KBgD4Vdyvkk/img.png)
- **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`만 상속받으면 자동으로 해결되기 때문입니다.
---

View 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;
}
}
```
컨트롤러까지 생성되었으니 브라우저에서 기능이 잘 동작하는지 테스트를 해봅니다.
---

View 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`를 생성하고 설정해 보겠습니다.
---

View 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 기준
![Chapter7_database_RDS_1](https://user-images.githubusercontent.com/68052095/100730191-15ce6880-340d-11eb-80c4-289adb8bc0ec.PNG)
`Database` -> `Data Source` -> `MySQL` 선택(**MariaDB는 MySQL 기반이므로**) 후 본인이 생성한 `RDS`의 정보를 차례로 등록합니다.
![Chapter7_database_RDS_2](https://user-images.githubusercontent.com/68052095/100730194-16ff9580-340d-11eb-8356-3a9ca9aa08cb.PNG)
- Host : 방금 전 복사한 RDS의 엔드 포인트를 등록합니다.
- User : 데이터베이스를 만들때 입력한 마스터 사용자 이름을 입력합니다.
- Password : 마스터 암호를 입력합니다.
마스터 계정명과 비밀번호를 등록한 뒤, 화면 아래의 `[Test Connection]`을 클릭해 연결 테스트를 해봅니다.
`Connection Successful` 메시지를 보았다면 `[Apply -> OK]` 버튼을 차례로 눌러 최종 저장을 합니다.
![Chapter7_database_RDS_3](https://user-images.githubusercontent.com/68052095/100730198-16ff9580-340d-11eb-89dd-bae3c4a88bb9.PNG)
그럼 인텔리제이에 `RDS`의 스키마가 노출됩니다. 위쪽에 있는 `[Open SQL Console]` 버튼을 클릭하고 `[New SQL Console..]` 항목을 선택해서 SQL을 실행할 콘솔창을 열어보겠습니다.
![Chapter7_database_RDS_4](https://user-images.githubusercontent.com/68052095/100730200-17982c00-340d-11eb-93f7-52b03df8c66c.PNG)
사진에 보이는 `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`)가 있음을 확인할 수 있습니다.
---

View 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` 권한이 추가된 것을 확인할 수 있습니다.
![Chapter8_deploy](https://user-images.githubusercontent.com/68052095/100843245-6d2c1180-34bd-11eb-834d-5e6f9de1185e.PNG)
이제 이 스크립트를 다음 명령어로 실행합니다.
>./deploy.sh
그러면 다음과 같이 로그가 출력되며 애플리케이션이 실행됩니다.
![Chapter8_deploy_seccuess](https://user-images.githubusercontent.com/68052095/100846838-59cf7500-34c2-11eb-9915-e48913c45aa0.PNG)
>###### 학습중 오류 발생 추가
>
>![Chapter8_deploy_fail1](https://user-images.githubusercontent.com/68052095/100846844-5b00a200-34c2-11eb-9500-0a86854c322a.PNG)
>
>위 사진와 같은 오류가 발생해서 디렉토리 계층을 전부 파악해야 했다.
>
>![Chapter8_deploy_fail2](https://user-images.githubusercontent.com/68052095/100846842-5a680b80-34c2-11eb-9099-c14e8a8dbc0c.PNG)
>
>내 경우에는 디렉토리 구조가
/home/ec2-user/app/step1/TIL/WebServiceBySpringBootAndAWS 였다.
따라서 이에 맞게 `deploy.sh`의 변수 값을 수정해서 해결했다.
>
>![Chapter8_deploy_fail3](https://user-images.githubusercontent.com/68052095/100847170-ce0a1880-34c2-11eb-97e3-1dc406ed0dfa.PNG)
잘 실행되었으니 `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
>
>띄어쓰기가 잘못이었다!!!!!!! `\`의 의미는 `이어쓰기`라고 한다...
그럼 다음과 같이 정상적으로 실행된 것을 확인할 수 있습니다.
![Chapter8_deploy_seccuess2](https://user-images.githubusercontent.com/68052095/100858992-09f8aa00-34d2-11eb-9d08-bfb358d3d21e.PNG)
마지막으로 `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` 주소로 이동해서 다시 구글 로그인을 시도해 보면 같이 로그인이 정상적으로 수행되는 것을 확인할 수 있습니다.
![Chapter8_I_got_it!!!!](https://user-images.githubusercontent.com/68052095/100868071-6f529800-34de-11eb-86b4-92c54007e9f5.PNG)
### ~~해냈다!!!!!!!~~
#### 네이버에 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**가 진행되도록 하는 것입니다.
---

View 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 메뉴`).
활성화한 저장소를 클릭하면 다음과 같이 저장소 빌드 히스토리 페이지로 이동합니다.
![Chapter9_Travis_CI1](https://user-images.githubusercontent.com/68052095/100978996-89e04c00-3586-11eb-811a-1bd8f61d38d8.PNG)
`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` 디렉토리가 있었기 때문에 계속해서 안된 것이었다...
>###### 학습중 발생 오류 추가
>![Chapter9_Travis_CI_queued](https://user-images.githubusercontent.com/68052095/100988702-57891b80-3593-11eb-919b-3264e5f39f5e.PNG)
>빌드가 되는줄 알았더니... Queued에서 멈춰버렸다.
>[Jobs stuck on "Queued"](https://travis-ci.community/t/jobs-stuck-on-queued/5768)를 참고해보니, 존재하지 않는 환경을 요청하는 경우 발생한다고 한다...
>따라서 환경에 대한 설정을 입력해주어야 한다.
>###### 학습중 발생 오류 추가
>![Chapter9_Travis_CI_Permission_Denied](https://user-images.githubusercontent.com/68052095/101017595-a2b02800-35ad-11eb-8183-5789e5206f41.PNG)
>`gradlew`는 실행 파일이다. 그리고 리눅스 환경에서 실행 파일은 **실행 권한이 있어야만 실행 가능**한데, 실행권한이 없기 때문에 발생한 에러라고 한다(접근권한이 아니라).
>일반적으로 `gradlew` 파일에는 실행권한이 프로젝트 생성시점에 부여되며, **그 파일이 깃허브에 올라가기 때문에** 별도로 `x권한(실행)`을 주지 않아도 된다.
>
>결론적으로, `gradlew`에 실행권한이 자동으로 부여되지 않았기 때문에 발생한 오류인 것 같다. 따라서 해결책으로 `.travis.yml`에
>`before_install: `
>`- chmod +x gradlew`
>를 추가해서 해결했다.
>[참고 링크](https://github.com/jojoldu/freelec-springboot2-webservice/issues/75)
빌드가 성공한 것이 확인되면 `.travis.yml`에 등록한 이메일을 확인합니다.
![Chapter9_Travis_CI_Success](https://user-images.githubusercontent.com/68052095/101044598-dcd7f480-35c2-11eb-89dd-c2f946e45831.PNG)
#### 성공!!
---
## 9.3 Travis CI와 AWS S3 연동하기
`S3``AWS`에서 제공하는 **일종의 파일 서버**입니다. 이미지 파일을 비롯한 정적 파일들을 관리하거나 지금 진행하는 것처럼 배포 파일들을 관리하는 등의 기능을 지원합니다. 보통 이미지 업로드를 구현한다면 이 `S3`를 이용하여 구현하는 경우가 많습니다. `S3`를 비롯한 `AWS` 서비스와 `Travis CI`를 연동하게 되면 전체 구조는 다음과 같습니다.
![Chapter9_Travis_CI_연동시_구조](https://user-images.githubusercontent.com/68052095/101052929-8a023b00-35ca-11eb-8dac-fa79d321910e.png)
첫 번째 단계로 `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` 버튼으로 진입합니다).
![Chapter9_Travis_CI_Settings](https://user-images.githubusercontent.com/68052095/101055258-19a8e900-35cd-11eb-93ec-9a9ff7db786c.PNG)
설정 화면을 아래로 조금 내려보면 `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`의 빌드가 성공한 것입니다.
![Chapter9_Travis_CI_Success2](https://user-images.githubusercontent.com/68052095/101118381-48a27780-362c-11eb-8058-6778ffb8d28a.PNG)
그리고 `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
>###### 학습중 오류 발생 추가
>![Chapter9_CodeDeploy_install_error](https://user-images.githubusercontent.com/68052095/101120672-97064500-3631-11eb-8cdd-7ac30c2e854b.PNG)
>**루비라는 언어가 설치되지 않은 상태**여서 발생하는 에러이다.
>`Linux AMI`에서는 `sudo yum install ruby` 명령어를 실행해서 루비를 설치하면 해결된다.
>
>아래 사진은 `Ubuntu` 기준으로 루비를 설치하는 방법이다.
>![Chapter9_CodeDeploy_install_error_solution](https://user-images.githubusercontent.com/68052095/101120759-c9b03d80-3631-11eb-99a3-381bd4f9a9cb.PNG)
설치가 끝났으면 `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` 화면 아래에서 배포가 수행되는 것을 확인할 수 있습니다(그룹 배포 내역).
>###### 학습중 발생 오류 추가
>![Chapter9_Travis_deploy_fali_by_ruby_version](https://user-images.githubusercontent.com/68052095/101242974-577a4e80-3740-11eb-8c2e-425da8609207.PNG)
>CodeDeploy 환경구성에 Amazon EC2 인스턴스 설정이 잘못되어서 생긴 문제라고 한다
(태그가 잘못되어서 생긴 문제).
>또는 `travis.yml`에 오타가 있어서 트레비스 트리거가 작동하지 않아 발생한 오류라고 한다.
>나의 경우에는 아래 사진에 표시된 부분이 달라서 트리거가 작동하지 않은 것 같다.
>![Chapter9_Travis_deploy_fali_by_ruby_version2](https://user-images.githubusercontent.com/68052095/101245049-8ea32c80-374d-11eb-8b1b-ad2ffccf6455.png)
>참고 링크 : [travis ci , s3, codeDeploy 연동 실패](https://github.com/jojoldu/freelec-springboot2-webservice/issues/474), [Travis CI, AWS S3, AWS CodeDeploy 배포 오류](https://jhhj424.tistory.com/16)
![Chapter9_CodeDeploy_success!](https://user-images.githubusercontent.com/68052095/101129462-ef930d80-3644-11eb-9fa0-d8b425cd574c.PNG)
배포가 끝났다면 다음 명령어로 파일들이 잘 도착했는지 확인해 봅니다.
>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`과 동일한 위치).
![Chapter9_scripts_directory_create](https://user-images.githubusercontent.com/68052095/101125214-097c2280-363c-11eb-839c-5628c8735983.PNG)
>#!/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`에서도 배포가 성공한 것을 확인합니다.
![Chapter9_final_deploy_success](https://user-images.githubusercontent.com/68052095/101137164-bb721980-3651-11eb-8b09-769732b2e225.PNG)
![Chapter9_final_deploy_success2](https://user-images.githubusercontent.com/68052095/101137299-f70ce380-3651-11eb-8c69-3a67f409ded2.png)
웹 브라우저에서 `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`)한 뒤 목록을 확인해보면 다음과 같은 내용을 확인할 수 있습니다.
![Chapter9_deployment_logs](https://user-images.githubusercontent.com/68052095/101147661-05fa9280-3660-11eb-8e6f-830eea9baf7f.PNG)
##### 코드설명
**① 최상단의 영문과 대시(-)가 있는 디렉토리명은 CodeDeploy ID입니다.**
- 사용자마다 고유한 ID가 생성되어 각자 다른 ID가 발급되니 본인의 서버에는 다른 코드로 되어있습니다.
- 해당 디렉토리로 들어가 보면 **배포한 단위별로 배포 파일들이** 있습니다.
- 본인의 배포 파일이 정상적으로 왔는지 확인해 볼 수 있습니다.
**② /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log**
- `CodeDeploy` 로그 파일입니다.
- `CodeDeploy`로 이루어지는 배포 내용 중 표준 입/출력 내용은 모두 여기에 담겨 있습니다.
- 작성한 `echo` 내용도 모두 표기됩니다.
테스트, 빌드, 배포까지 전부 자동화되었습니다. 이제는 작업이 끝난 내용을 **Master 브랜치에 푸시만 하면 자동으로 EC2에 배포**가 됩니다.
하지만 문제가 한 가지 남았습니다. **배포하는 동안** 스프링 부트 프로젝트는 종료 상태가 되어 **서비스를 이용할 수 없다**는 것입니다.
다음으로는 **서비스 중단 없는 배포** 방법을 소개하려고 합니다. 흔히 말하는 무중단 배포라고 생각하면 됩니다.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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);
}
}

View File

@@ -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 {
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,3 @@
spring.profiles.include=oauth,real-db
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.session.store-type=jdbc

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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}}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}}

View File

@@ -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}}

View File

@@ -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);
}
}

View File

@@ -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)));
}
}

Some files were not shown because too many files have changed in this diff Show More