배포하는 동안 애플리케이션이 종료된다는 문제가 있었습니다. 긴 기간은 아니지만, 새로운 Jar가 실행되기 전까진 기존 Jar를 종료시켜 놓기때문에 서비스가 중단됩니다.

반면 24시간 서비스하는 네이버나 카카오톡 같은 경우 배포하는 동안 서비스가 정지되지는 않습니다. 그럼, 어떻게 서비스 중단 없이 배포를 계속할 수 있는지 배워봅시다.

 

 

 

무중단 배포 소개

예전에는 배포는 팀의 아주 큰 이벤트이기 때문에 다 같이 코드를 합치는 날과 배포를 하는 날을 정하고 진행했습니다. 특히 배포일에는 사용자가 적은 새벽 시간에 개발자들이 모두 남아 배포 준비를 해야만 했고 배포가 잦아질 때는 새벽마다 남아야만 했습니다.

더군다나 배포를 하고 나서 치명적인 문제가 발견되면 어떻게 해야 할까요? 새벽 시간에 부랴부랴 문제를 해결하다가, 사용자 유입이 많아지는 아침이 되면 긴급점검 공지를 올리고 수정해야만 했습니다. 실제로 게임을 할 때 이런공지를 많이 보곤했었습니다.

이런 수고스러움을 덜기 위해 기존 서비스를 정지하지 않고, 배포할 수 있는 방법들을 찾기 시작 했고 이를 무중단 배포라고 합니다.

 

무중단 배포 방식에는 몇 가지가 있습니다.

  • AWS에서 블루 그린(Blue-Green) 무중단 배포
  • 도커를 이용한 웹서비스 무중단 배포

이외에도 L4 스위치를 이용한 무중단 배포 방법도 있지만, L4가 워낙 고가의 장비이다 보니 대형 인터넷 기업 외에는 쓸 일이 거의 없다고합니다.

 

이 책에서는 NGINX를 이용한 무중단 배포를 진행합니다. NGINX는 웹 서버, 리버스 프록시, 캐싱, 로드 밸런싱, 미디어 스트리밍 등을 위한 오픈소스 소프트웨어입니다. 이전에 아파치가 대세였던 자리를 완전히 빼앗은 가장 유명한 웹 서버이자 오픈소스입니다. 고성능 웹 서버이기때문에 대부분 서비스들이 현재는 NGINX를 사용하고 있습니다.

 

NGINX가 가지고 있는 여러 기능 중 리버스 프록시가 있습니다. 리버스 프록시란 NGINX가 외부의 요청을 받아 백엔드 서버로 요청을 전달하는 행위를 이야기합니다. 리버스 프록시 서버(NGINX)는 요청을 전달하고, 실제 요청에 대한 처리는 뒷단의 웹 애플리케이션 서버들이 처리합니다. 

 

우리는 이 리버스 프록시를 통해 무중단 배포 환경을 구축할 예정입니다. NGINX를 이용한 무중단 배포를 하는 이유는 가장 저렴하고 쉽기 때문입니다. 기존에 쓰던 EC2에 그대로 적용하면 되므로 배포를 위해 AWS EC2 인스턴스가 하나 더 필요하지 않습니다. 추가로 이 방식은 꼭 AWS와 같은 클라우드 인프라가 구축되어 있지 않아도 사용할 수 있는 범용ㅇ적인 방법입니다. 즉, 개인 서버 혹은 사내 서버에서도 동일한 방식으로 구축할 수 있으므로 사용처가 많습니다.

 

구조는 간단합니다. 하나의 EC2 혹은 리눅스 서버에 NGINX 1대와 스프링 부트 Jar를 2대 사용하는 것입니다.

  • NGINX는 80(http), 443(https) 포트를 할당합니다.
  • 스프링 부트1은 8081포트로 실행합니다.
  • 스프링 부트2는 8082포트로 실행합니다.

NGINX 무중단 배포 1은 아래와 같은 구조가 됩니다.

NGINX 무중단 배포 1

운영 과정을 아래와 같습니다.

  1. 사용자는 서비스 주소로 접속합니다. (80 or 443 port)
  2. NGINX는 사용자의 요청을 받아 현재 연결된 스프링 부트로 요청을 전달합니다. 
  3. 스프링 부트2는 NGINX와 연결된 상태가 아니니 요청받지 못합니다.

1.1 버전으로 신규 배포가 필요하면, NGINX와 연결되지 않은 스프링 부트2(8082 port)로 배포합니다.

 

NGINX 무중단 배포 2

  1. 배포하는 동안에도 서비스는 중단되지 않습니다. (NGINX는 스프링 부트1을 바라보기 때문)
  2. 배포가 끝나고 정상적으로 스프링 부트2가 구동 중인지 확인합니다.
  3. 스프링 부트2가 정상 구동 중이면 nginx reload 명령어를 통해 8081대신에 8082를 바라보도록합니다.
  4. nginx reload는 0.1초 이내에 완료됩니다.

이후 1.2 버전 배포가 필요하면 이번에는 스프링 부트1로 배포합니다.

 

이렇게 구성하게 되면 전체 시스템 구조는 아래와 같습니다.

 

무중단 배포 전체 구조

기존 구조에서 EC2 내부의 구조만 변경된 것입니다.

 

 

 

NGINX 설치와 스프링 부트 연동하기

먼저 EC2에 NGINX를 설치합시다.

 

  • NGINX 설치

EC2에 접속해서 아래 명령어로 NGINX를 설치합니다.

sudo amazon-linux-extras install nginx1

설치가 완료되었으면 아래 명령어로 nginx를 실행합니다.

sudo service nginx start

아래 로그가 나오면 정상 실행하는 것 입니다.

Redirecting to /bin/systemctl start nginx.service

 

  • 보안 그룹 추가

먼저 nginx의 포트번호를 보안 그룹에 추가하겠습니다. nginx의 포트번호는 기본적으로 80입니다. 해당 포트 번호가 보안 그룹에 없으니 ec2->보안 그룹-> ec2 보안 그룹 선택 -> 인바운트 편집으로 차례로 이동해서 변경해줍니다.

 

  • 리다이렉션 주소 추가

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

 

 

추가한 후에는 EC2의 도메인으로 접근하되, 8080 포트를 제거하고 접근해 봅니다. 그럼 NGINX 웹 페이지를 볼 수 있습니다. 이제 스프링 부트와 연동해봅시다.

 

 

  • NGINX와 스프링 부트 연동

NGINX가 현재 실행 중인 스프링 부트 프로젝트를 바라볼 수 있도록 프록시 설정을 하겠습니다. NGINX 설정파일을 아래 코드를 통해 열어봅시다.

sudo vim /etc/nginx/nginx.conf

설정 내용 중 server 아래의 location / 부분을 찾아서 아래와 같이 추가합니다.

   location / {
        proxy_pass http://localhost:8080;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
    }
  • proxy_pass
    • nginx로 요청이오면 http://locahost:8080으로 전달합니다.
  • proxy_set_header XXX
    • 실제 요청 데이터를 header의 각 항목에 할당합니다.
    • 예) proxy_set_header X-Real-IP $remote_addr: Request Header의 X-Real-IP에 요청자의 IP를 저장합니다.

수정이 끝났으면 :wq 명령어로 저장하고 종료한 뒤, 아래 코드로 nginx를 재시작 하겠습니다.

sudo service nginx restart

다시 브라우저로 포트번호 없이 접속하면 스프링 부트 프로젝트가 나오는 것을 볼 수 있습니다.

 

 

 

무중단 배포 스크립트 만들기

무중단 배포 스크립트 작업 전에 API를 하나 추가하겠습니다. 이 API는 이후 배포 시에 8081을 쓸지, 8082를 쓸지 판단하는 기준이 됩니다.

 

profile API 추가

profileController를 만들어 아래와 같이 간단한 API 코드를 추가합니다. web 디렉토리에 생성합니다.

ProfileController 클래스 생성

@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);
    }
}
  • evn.getActiveProfiles()
    • 현재 실행 중인 ActiveProfile을 모두 가져옵니다.
    • 즉, real, oauth, real-db 등이 활성화되어 있다면 3개가 모두 담겨있습니다.
    • 여기서 real, real1, real2는 모두 배포에 사용될 profile이라 이 중 하나라도 있으면 그 값을 반환하도록합니다.
    • 실제 이번 무중단 배포에서는 real1과 real2만 사용되지만, step2를 다시 사용해볼 수도있으니 real도 남겨둡니다.

해당 코드가 잘 작동하는지 테스트 코드를 작성해보겠습니다. 해당 컨트롤러는 특별히 스프링 환경이 필요하지는 않습니다. 그래서 @SpringBootTest없이 테스트 코드를 작성합니다. test에 web 디렉토리에 테스트 클래스를 생성해줍시다.

ProfileControllerUnitTest 클래스 생성

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.security.core.parameters.P;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

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

 

Security.config에 인증없이 호출될 수 있게 andMatcher에 /profile 추가

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

그리고 SecurityConfig 설정이 잘 되었는지도 테스트 코드로 검증합니다. 이 검증은 스프링 시큐리티 설정을 불러와야 하니 @SpringBootTest를 사용하는 테스트 클래스를 하나 더 추가합니다.

ProfileControllerTest 클래스 생성

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProfileControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void profile은_인증없이_호출된다() throws Exception {
        String expected = "default";

        ResponseEntity<String> response = restTemplate.getForEntity("/profile", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo(expected);
    }
}

여기까지 모든 테스트가 성공했다면 깃허브로 푸시하여 배포합니다. url/profile로 접속해서 profile이 잘 나오는지 확인합니다.

 

 

real1, real2 profile 생성

현재 EC2 환경에서 실행되는 profile은 real밖에 없습니다. 해당 profile은 Travis CI 배포 자동화를 위한 profile이니 무중단 배포를 위한 profile 2개를 src/main/resources 아래에 추가합시다.

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

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로 되어있습니다. 이제 깃에 푸시해줍시다.

 

 

NGINX 설정 수정

무중단 배포의 핵심은 NGINX 설정입니다. 배포 때마다 nginx의 프록시 설정(스프링 부트로 요청을 흘려보내는)이 순식간에 교체됩니다. 여기서 프록시 설정이 교체될 수 있도록 설정을 추가하겠습니다.

 

nginx 설정이 모여있는 /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;

저장하고 종료한 뒤, 해당 파일은 nginx가 사용할 수 있게 설정합니다. 아래와 같이 nginx.conf 파일을 열어줍니다.

sudo vim /etc/nginx/nginx.conf

location / 부분을 찾아 아래와 같이 변경해줍니다.

저장하고 종료한 뒤 재시작합니다.

sudo service nginx restart

다시 브라우저에서 정상적으로 호출되는지 확인합니다. 확인되었다면 nginx 설정까지 잘 된 것 입니다.

 

 

배포 스크립트들 작성

먼저 step2와 중복되지 않기 위해 EC2에 step3 디렉토리를 생성합니다.

mkdir ~/app/step3 && mkdir ~/app/step3/zip

무중단 배포는 앞으로 step3를 사용하겠습니다. 그래서 appspec.yml 역시 step3로 배포되도록 수정합니다.

version: 0.0 # CodeDeploy 버전을 명시한다.
os: linux
files:
  - source:  / # 전체 파일을 나타낸다.
    destination: /home/ec2-user/app/step3/zip/ # source로 지정된 전체 파일이 받을 위치이다.
    overwrite: yes # 기존 파일들이 있으면 덮어 쓸지를 결정한다.

무중단 배포를 진행할 스크립트들은 총 5개 입니다.

  • stop.sh : 기존 nginx에 연결되어 있진 않지만, 실행 중이던 스프링 부트 종료
  • start.sh : 배포할 신규 버전 스프링 부트 프로젝트를 stop.sh로 종료한 'profile'로 실행
  • health.sh : 'start.sh'로 실행시킨 프로젝트가 정상적으로 실행됐는지 체크
  • switch.sh : nginx가 바라보는 스프링 부트를 최신 버전으로 변경
  • 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 디렉토리에 추가합니다.

health.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
  1. 엔진엑스와 연결되지 않은 포트로 스프링 부트가 잘 수행되었는지 체크
  2. 정상 확인 후 엔진엑스 프록시 설정 변경(switch_proxy)
  3. 엔진엑스 프록시 설정 변경은 switch.sh에서 수행

 

profile.sh 생성

#!/usr/bin/env bash

# bash는 return value가 안되니 *제일 마지막줄에 echo로 해서 결과 출력*후, 클라이언트에서 값을 사용한다

# 쉬고 있는 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를 사용해선 안됩니다.

 

start.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 &

prfile.sh를 통해 IDLE_PROFILE를 가져온다.

 

stop.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가 속해 있는 경로 찾기
  • source ${ABSDIR}/profile.sh
    • 자바로 보면 일종의 import 구문
    • 해당 코드로 인해 stop.sh에서도 profile.sh의 여러 function을 사용할 수 있게 됩니다.

 

switch.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를 사용
    • 엔진엑스가 변경할 프록시 주소를 생성
  • sudo tee /etc/nginx/conf.d/service-url.inc
    • 앞에서 넘겨준 문장을 service-url.inc에 덮어 씁니다.
  • sudo service nginx reload
    • 엔진엑스 설정을 다시 불러온다.
    • restart는 잠시 끊기는 현상이 있지만, reload는 끊김 없이 다시 불러온다.

이제 실제로 무중단 배포를 진행해 봅시다.

 

 

 

무중단 배포 테스트

build.gradle

version '1.0.1-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")
  1. build.gradle은 Groovy 기반의 빌드 툴입니다.
  2. 당연히 Groovy 언어의 여러 문법을 사용할 수 있는데, 여기서는 new Date()로 빌드할 때마다 그 시간이 버전에 추가되도록 구성하였습니다.

여기까지 구성한 뒤 최종 코드를 깃허브로 푸시합니다. 배포가 자동으로 진행되면 CodeDeploy 로그로 잘 진행되는지 확인해 봅시다.

tail -f /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log

위와 같은 메시지가 차례대로 출력되면 성공입니다.

한 번 더 배포하면 그때는 real2로 배포됩니다. 이 과정에서 브라우저 새로고침을 해보면 전혀 중단 없는 것을 확인할 수 있습니다. 2번 배포를 진행한 뒤에 아래와 같이 자바 애플리케이션 실행 여부를 확인해봅시다.

ps -ef | grep java

아래와 같이 2개의 애플리케이션이 실행되고 있음을 알 수 있습니다.

이제 해당 시스템은 마스터 브랜치에 푸시가 발생하면 자동으로 서버 배포가 진행되고 서버 중단 역시 전혀 없는 시스템이 되었습니다. 

 

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

24시간 365일 운영되는 서비스에 배포 환경 구축은 필수 과제 중 하나입니다. 여러 개발자의 코드가 실시간으로 병합되고, 테스트가 수행되는 환경, master 브랜치가 푸시되면 배포가 자동으로 이루어지는 환경을 구축하지 않으면 실수할 여지가 너무나 많습니다. 이번엔 이런 배포 환경을 구성해 보겠습니다!

 

 

CI & CD 소개

저는 개인적으로 CI(Continuous Integration 지속적인 통합)와 CD(Continuous Deployment(지속적인 배포)가 되게 생소합니다. 그래서 간략하게 정리해보았습니다. CI란 코드 버전 관리를 하는 VCS 시스템(Git 등)에 PUSH가 되면 자동으로 테스트와 빌드가 수행되어 안정적인 배포 파일을 만드는 과정을 말합니다. CD란 CI의 빌드 결과를 자동으로 운영 서버에 무중단 배포까지 진행되는 과정을 말합니다. 

 

현대의 웹 서비스 개발에서는 하나의 프로젝트를 여러 개발자가 함께 개발을 진행합니다. 그러다 보니 각자가 개발한 코드가 합쳐야 할 때마다 큰 일이었습니다. 그래서 매주 병합일(코드 Merge만 하는 날)을 정하여 이날은 각자가 개발한 코드를 합치는 일만 진행했다고 합니다.

 

이런 수작업 때문에 생산성이 좋을 수가 없었으며 개발자들은 지속해서 코드가 통합되는 환경인 CI를 구축하게 되었다고 합니다. 개발자 각자가 원격 저장소(Git)로 푸시가 될 때마다 코드를 병합하고, 테스트 코드와 빌드를 수행하면서 자동으로 코드가 통합되어 더는 수동으로 코드를 통합할 필요가 없어지면서 자연스럽게 개발자들 역시 개발에만 집중할 수 있게 되었습니다.

 

CD 역시 마찬가지입니다. 한 두 대의 서버에 개발자가 수동으로 배포를 할 수 있지만, 수십 대 수백 대의 서버에 배포를 해야 하거나 긴박하게 당장 배포를 해야 하는 상황이 오면 더는 수동으로 배포할 수가

없습니다. 그래서 이 역시 자동화하게 되었고, 개발자들이 개발에만 집중할 수 있게 되었습니다.

 

CI와 CI 모두 등장 배경은 개발자들이 개발에만 집중할 수 있도록 하기위해 등장했다고 보면 좋을 것 같습니다.

 

여기서 주의할 점은 단순히 CI 도구를 도입했다고 해서 CI를 하고 있는 것은 아닙니다. 마틴 파울러의 블로그(http://bit.ly/2Yv0vFp)를 참고해보면 CI에 대해 아래와 같은 4가지 규칙을 이야기합니다.

  • 모든 소스 코드가 살아있고(현재 실행되고) 누구든 현재의 소스에 접근할 수 있는 단일 지점을 유지할 것
  • 빌드 프로세스를 자동화해서 누구든 소스로부터 시스템을 빌드하는 단일 명령어를 사용할 수 있게 할 것
  • 테스팅을 자동화해서 단일 명령어로 언제든지 시스템에 대한 건전한 테스트 수트를 실행할 수 있게 할 것
  • 누구나 현재 실행 파일을 얻으면 지금까지 가장 완전한 실행 파일을 얻었다는 확신을 하게 할 것

 특히 중요한 것은 테스팅 자동화입니다. 지속적으로 통합하기 위해서는 무엇보다 프로젝트가 완전한 상태임을 보장하기 위해 테스트 코드가 구현되어 있어야만 합니다.

 

 

 

Travis CI 연동하기

Travis CI는 Github에서 제공하는 무료 CI 서비스입니다. 젠킨스와 같은 CI 도구도 있지만, 젠킨스는 설치형이기 때문에 이를 위한 EC2 인스턴스가 하나 더 필요합니다. 이제 시작하는 서비스에서 배포를 위한 EC2 인스턴스는 부담스럽기 때문에 오픈소스 웹 서비스인 Travis CI를 사용하겠습니다.

 

 

Travis CI 웹 서비스 설정

https://travis-ci.com/에서 깃허브 계정으로 로그인을 한 뒤, 오른쪽 위에 계정명->setting을 클릭합니다. 설정 페이지 아래쪽에 깃허브 저장소 검색창에 저장소 이름을 입력해서 찾은 다음 클릭해줍시다. 그 후 build history 페이지를 눌러줍시다. Travis CI 웹사이트에서 설정은 이것이 끝입니다. 상세한 설정을 프로젝트의 yml 파일로 해야합니다.

 

 

프로젝트 설정

Travis CI의 상세한 설정은 프로젝트에 존재하는 .travis.yml 파일로 할 수 있습니다. yml 확장자는 YAML으로 JSON에서 괄호를 제거한 것 이라고 보면 편합니다. Travis CI 설정을 YAML을 통해서 하고 있습니다. 

 

프로젝트의 build.gradle과 같은 위치에서 .travis.yml을 생성한 후 아래의 코드를 추가해줍니다.

language: java
jdk:
  - openjdk8

branches:
  only:
    - main

# Travis CI 서버의 Home
cache:
  directories:
    - '$HOME/.m2/repository'
    - '$HOME/.gradle'

before_install:
  - chmod +x gradlew

script: "./gradlew clean build"

# CI 실행 완료 시 메일로 알람
notifications:
  email:
    recipients:
      - 본인 메일 주소
  • branches
    • Travis CI를 어느 브랜치가 푸시될 때 수행할지 지정합니다.
    • 현재 옵션은 오직 master 브랜치에 push될 때만 수행합니다.
  • cache
    • 그레이들을 통해 의존성을 받게되면 이를 해당 디렉토리에 캐시하여, 같은 의존성은 다음 배포 때부터 다시 받지 않도록 설정합니다.
  • script
    • master 브랜치에 푸시되었을 때 수행하는 명령어입니다.
    • 여기서는 프로젝트 내부에 둔 gradlew을 통해 clean & build를 수행합니다.
  • notifications
    • Travis CI 실행 완료 시 자동으로 알림이 가도록 설정합니다.

여기까지 마친 뒤, master 브랜치에 커밋과 푸시를 하고, Travis CI 저장소 페이지를 확인해봅시다. 여기서 바로 확인이 되지않는데, 시간을 두고 다시 보시면 바뀐다고 합니다. 하지만 저는 아래와 같은 에러가 발생했습니다.

찾아보니 비자카드를 등록하고 무료 플랜을 가입해야했습니다. setting에 Plan에서 Trial Plan이라는 무료 플랜을 등록했습니다. 이후에도 잘 되지않아서 찾아보니 branch를 master -> main으로 바꿔주어야 했습니다. (git branch 기본이 master -> main 으로 바뀜)

정상 작동

적어놓은 메일로도 정상작동 된다는 메일이 온 것을 볼 수 있을 것 입니다.

 

 

 

Travis CI와 AWS S3 연동하기

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

 

Travis CI 연동시 구조

Travis CI와 S3를 연동해봅시다. 실제 배포는 AWS CodeDeploy라는 서비스를 이용합니다. S3 연동이 먼저 필요한 이유는 Jar 파일을 전달하기 위해서 입니다.

CodeDeploy는 저장 기능이 없습니다. 그래서 Travis CI가 빌드한 결과물을 받아서 CodeDeploy가 가져갈 수 있도록 보관할 수 있는 공간이 필요합니다. 보통 이럴 때 S3를 사용합니다.

 

 

AWS Key 발급

일반적으로 AWS 서비스에 외부 서비스가 접근할 수 없습니다. 그러므로 접근 가능한 권한을 가진 Key를 생성해서 사용해야 합니다. AWS에서는 이러한 인증과 관련된 기능을 제공하는 서비스로 IAM(Identity And AccessManagement)이 있습니다.

 

IAM은 AWS에서 제공하는 서비스의 접근 방식과 권한을 관리합니다. IAM을 통해 Travis CI가 AWS S3와 CodeDeploy에 접근할 수 있도록 하겠습니다. AWS 웹 콘솔에서 IAM을 검색하여 이동합니다.

  1. IAM 페이지 왼쪽 사이드바에서 사용자->사용자 추가 버튼을 차례로 클릭해줍시다.
  2. 사용자 이름과 엑세스 유형은 프로그래밍 방식을 선택한 뒤 다음버튼을 눌러줍시다. 기존 정책 직접 연결을 선택합니다. 아래 정책 검색 창에서 s3full과 codedeployfull을 검색한 뒤 둘다 체크해줍니다.
  3. 태그는 Name 값을 지정하는데, 본인이 인지 가능한 정도의 이름으로 만들어줍시다.
  4. 마지막으로 본인이 생성한 권한 설정 항목을 확인합니다.

최종 생성 완료되면 엑세스 키와 비밀 엑세스 키가 생성 됩니다. 이 두 값이 Travis CI에서 사용될 키 입니다. 이제 이 키를 Travis CI에 등록해줍시다.

 

 

Travis CI에 키 등록

Travis CI 설정 화면으로 이동합니다. 프로젝트 좌측 More Option에서 Setting을 눌러주면됩니다. 아래 Environment Variables 항목이 있습니다. 아래처럼 변수명으로 하여 엑세스 키값과 시크릿 키 값을 추가해줍니다.

이렇게 등록된 값은 .travis.yml에서 $AWS_ACCESS_KEY, $AWS_SECRET_KEY란 이름으로 이용할 수 있습니다. 이제 해당 키를 사용해서 Jar를 관리할 S3 버킷을 생성해줍시다.

 

 

S3 버킷 생성

파일 서버의 역할을 하기 때문에, Travis CI에서 생성된 Build 파일을 저장하도록 구성하겠습니다. S3에 저장된 Build 파일은 이후 AWS의 CodeDeploy에서 배포할 파일로 가져가도록 구성할 예정입니다. AWS 웹에서 S3를 검색하여 이동해줍니다.

  1. 버킷 만들기를 눌러줍니다. 
  2. 원하는 버킷명을 작성합니다. 이 버킷에 배포할 Zip 파일이 모여있는 장소임을 의미하도록 짓는 것을 추천합니다.
  3. 다음으로 버전관리를 설정하는데, 별다른 설정을 할 것이 없어서 바로 넘어갑니다.

S3가 생성되었으니 이제 해당 S3로 배포 파일을 전달해봅시다.

 

 

.travis.yml 추가

travis CI에 빌드하여 만든 Jar 파일을 S3에 올릴 수 있도록 .travis.yml에 아래 코드를 추가해줍시다.

...
before_deploy:
  - zip -r SpringBootWebService ./*
  - mkdir -p deploy
  - mv SpringBootWebService.zip deploy/SpringBootWebService.zip

deploy:
  - provider: s3
    access_key_id: $AWS_ACCESS_KEY
    secret_access_key: $AWS_SECRET_KEY

    bucket: qazyj-springboot-build #S3 버킷 이름
    region: ap-northeast-2
    skip_cleanup: true
    acl: private #zip 파일 접근 private으로 
    local_dir: deploy #before_deploy에서 생성한 디렉토리
    wait_until_deployed : true    
...

전체 코드는 아래와 같습니다.

language: java
jdk:
  - openjdk8
branches:
  only:
    - main

# Travis CI Server's Home
cache:
  - directories:
      - '$HOME/.m2/repository'
      - '$HOME/.gradle'

script: "./gradlew clean build"

before_deploy:
  - zip -r SpringBootWebService ./*
  - mkdir -p deploy
  - mv SpringBootWebService.zip deploy/SpringBootWebService.zip

deploy:
  - provider: s3
    access_key_id: $AWS_ACCESS_KEY
    secret_access_key: $AWS_SECRET_KEY

    bucket: qazyj-springboot-build #S3 버킷 이름
    region: ap-northeast-2
    skip_cleanup: true
    acl: private #zip 파일 접근 private으로 
    local_dir: deploy #before_deploy에서 생성한 디렉토리
    wait_until_deployed : true

# CI 실행 완료 시 메일로 알람
notifications:
  email:
    recipients:
      - qazyj@naver.com
  • before_deploy
    • deploy 명령어가 실행되기 전에 수행됩니다.
    • CodeDeploy는 Jar 파일을 인식하지 못하므로 Jar+기타 설정 파일들을 모아 압축(zip)합니다.
  • zip -r SpringBootWebService
    • 현재 위치의 모든 파일을 springboot2-webservice 이름으로 압축합니다.
    • 명령어의 마지막 위치는 본인의 프로젝트 이름이어야합니다.
  • mkdir -p deploy
    • deploy라는 디렉토리를 Travis CI가 실행중인 위치에서 생성합니다.
  • mv SpringBootWebService.zip deploy/SpringBootWebService.zip
    • SpringBootWebService.zip 파일을 deploy/SpringBootWebService.zip 으로 이동시킵니다.
  • deploy
    • S3로 파일 업로드 혹은 CodeDeploy로 배포 등 외부 서비스와 연동될 행위들을 선언합니다.
  • local_dir: deploy
    • 앞에 생성한 deploy 디렉토리를 지정합니다.
    • 해당 위치의 파일들만 S3로 전송합니다.

설정이 다 되었으면 푸쉬합니다. 그리고 S3 버킷을 가보면 업로드가 성공한 것을 확인할 수 있습니다.

이제 CodeDeploy로 배포까지 완료해봅시다.

 

 

 

Travis CI와 AWS S3, CodeDeploy 연동하기

AWS의 배포 시스템인 CodeDeploy를 이용하기 전에 배포 대상인 EC2가 CodeDeploy를 연동 받을 수 있게 IAM 역할을 하나 생성해주겠습니다.

 

 

EC2에 IAM 역할 추가하기

S3와 마찬가지로 IAM을 검색해줍시다.

  1. 역할 탭을 클릭해서 이동합니다.
  2. 역할 만들기 버튼을 클릭합니다.
  3. EC2에서 사용할 것이기 때문에 AWS 서비스를 누른뒤, EC2를 눌러줍니다. 다음 버튼을 누릅시다.
  4. 정책에선 EC2RoleForA를 검색해서 AmazonEC2RoleforAWSCodeDeploy를 선택합니다. 다음 버튼을 누릅시다.
  5. 태그틑 본인이 원하는 이름으로 짓습니다. 다음 버튼을 누릅시다.
  6. 최종적으로 확인한 뒤, 만들어줍니다.

이렇게 만든 역할을 EC2 서비스에 등록하겠습니다. EC2 인스턴스 목록으로 이동한 뒤, 본인의 인스턴스를 마우스 오른쪽 버튼으로 눌러 보안 -> IAM 역할 수정을 선택해줍니다. IAM 역할에 방금 만든 역할을 추가해줍니다. 그 후 EC2 인스턴스를 재부팅 합니다. 재부팅을 해야만 역할이 정상적으로 적용되니 재부팅 해줍시다!

 

재부팅이 완료되었으면 CodeDeploy의 요청을 받을 수 있도록 에이전트를 하나 설치해줍시다.

 

 

CodeDeploy 에이전트 설치

ssh로 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

위의 코드가 안되면 아래의 코드처럼 루비를 설치한 뒤 위의 코드를 적어줍시다.

sudo yum install ruby

설치가 끝났으면 Agent가 정상적으로 실행되고 있는지 상태 검사를 해줍시다.

sudo service codedeploy-agent status

아래와 같이 running 메시지가 출력된다면 정상적인 상태입니다.

The AWS CodeDeploy agent is running as PID 4573

 

 

CodeDeploy를 위한 권한 생성

CodeDeploy에서 EC2에 접근하려면 마찬가지로 권한이 필요합니다. AWS의 서비스이니 IAM 역할을 생성합니다. EC2에 IAM 역할을 만들어 준 것처럼 CodeDeploy를 선택해줍니다. CodeDeploy는 역할이 하나 뿐이라서 선택 없이 바로 다음으로 넘어가면 됩니다. 

 

 

CodeDeploy 생성

CodeDeploy는 AWS의 배포 삼형제 중 하나입니다. 배포 삼형제에 대해 간단하게 소개하자면 아래와 같습니다.

  • Code Commit
    • 깃허브와 같은 코드 저장소의 역할을 합니다.
    • 프라이빗 기능을 지원한다는 강점이 있지만, 현재 깃허브에서 무료로 프라이빗 지원을 하고 있어서 거의 사용되지 않습니다.
  • Code Build
    • Travis CI와 마찬가지로 빌드용 서비스입니다.
    • 멀티 모듈을 배포해야 하는 경우 사용해 불편하지만, 규모가 있는 서비스에서는 대부분 젠킨스/팀시티 등을 이용하니 이것 역시 사용할 일이 거의 없습니다.
  • CodeDeploy
    • AWS의 배포 서비스입니다.
    • 앞에서 언급한 다른 서비들은 대체재가 있고, 딱히 대체재보다 나은 점이 없지만, CodeDeploy는 대체재가 없습니다.
    • 오토 스케일링 그룹 배포, 블루 그린 배포, 롤링 배포, EC2 단독 배포 등 많은 기능을 지원합니다.

이 중 현재 진행 중인 프로젝트에서는 Code Commit의 역할은 깃 허브가, Code Build의 역할은 Travis CI가 하고 있습니다. CodeDeploy 서비스로 이동해서 화면 중앙에 있는 애플리케이션 생성 버튼을 클릭해줍시다. 이름은 원하시는 이름으로, 컴퓨팅은 아래와 같이 EC2/온프레미스를 선택해줍시다.

생성이 완료되면 배포 그룹을 생성하라는 메시지를 볼 수 있습니다. 화면 중앙의 배포 그룹 생성 버튼을 클릭합니다. 이름은 원하시는 이름으로 해주시고, 서비스 역할 선택에서 이전에 만들어 주었던 codedeploy IAM 역할을 선택해 줍시다. 배포 유형은 현재 위치를 선택합니다. 환경 구성에서는 Amazon EC2 인스턴스를 체크합니다. 태그 그룹은 아래와 같이 해줍니다.

마지막으로 아래와 같이 배포 구성을 선택하고 로드밸런싱은 체크해제합니다.

이제 배포 그룹까지 생성되었다면 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으로 진행합니다. 코드는 아래와 같습니다.

version: 0.0 # CodeDeploy 버전을 명시한다.
os: linux 
files:
  - source:  / # 전체 파일을 나타낸다.
    destination: /home/ec2-user/app/step2/zip/ # source로 지정된 전체 파일이 받을 위치이다.
    overwrite: yes # 기존 파일들이 있으면 덮어 쓸지를 결정한다.
  • version: 0.0
    • CodeDeploy 버전을 이야기합니다.
    • 프로젝트 버전이 아니므로 0.0 외에 다른 버전을 사용하면 오류가 발생합니다.
  • source
    • CodeDeploy에서 전달해 준 파일 중 destination으로 이동시킬 대상을 지정합니다.
    • 루트 경로(/)를 지정하면 전체 파일을 이야기합니다.
  • destination
    • source에서 지정된 파일을 받을 위치입니다.
    • 이후 Jar를 실행하는 등은 destination에서 옮긴 파일들로 진행됩니다.
  • overwrite
    • 기존에 파일들이 있으면 덮어쓸지를 결정합니다.
    • yes라고 했으니 파일들을 덮어쓰게 됩니다.

.travis.yml에도 CodeDeploy 내용을 추가합니다. deploy 항목에 다음 코드를 추가합니다.

language: java
jdk:
  - openjdk8
branches:
  only:
    - main

# Travis CI Server's Home
cache:
  - directories:
      - '$HOME/.m2/repository'
      - '$HOME/.gradle'

script: "./gradlew clean build"

before_deploy:
  - zip -r SpringBootWebService *
  - mkdir -p deploy
  - mv SpringBootWebService.zip deploy/SpringBootWebService.zip

deploy:
  - provider: s3
    access_key_id: $AWS_ACCESS_KEY
    secret_access_key: $AWS_SECRET_KEY
    bucket: qazyj-springboot-build #S3 버킷 이름
    region: ap-northeast-2
    skip_cleanup: true
    acl: private #zip 파일 접근 private으로
    local_dir: deploy #before_deploy에서 생성한 디렉토리
    wait_until_deployed : true
    on:
      branch: main

## 새롭게 추가된 부분
  - provider: codedeploy
    access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
    secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
    bucket: qazyj-springboot-build # S3 버킷
    key: SpringBootWebService.zip # 빌드 파일을 압축해서 전달
    bundle_type: zip # 압축 확장자
    application: springboot2-webservice # 웹 콘솔에서 등록한 CodeDeploy 어플리케이션
    deployment_group: springboot2-webservice-group # 웹 콘솔에서 등록한 CodeDeploy 배포 그룹
    region: ap-northeast-2
    wait-until-deployed: true
    on:
      branch: main
## 새롭게 추가된 부분

# CI 실행 완료 시 메일로 알람
notifications:
  email:
    recipients:
      - qazyj@naver.com

모든 내용을 작성했다면 프로젝트를 커밋하고 푸쉬합니다. Travis CI가 끝나면 CodeDeploy 화면 아래에서 배포가 수행되는 것을 확인할 수 있습니다. on: branch:main을 추가안해주면 

위의 에러가 뜨며 정상적으로 작업이 진행되지 않습니다.

여러번의 실패 끝에 on: branch:main을 추가해서 성공적으로 배포가 되었습니다.

 

이제 Travis CI, S3, CodeDeploy가 연동되었습니다.

 

 

 

배포 자동화 구성

이제 실제로 Jar를 배포하여 실행까지 해봅시다.

 

deploy.sh 파일 추가

먼저 step 환경에 실행될 deploy.sh를 생성하겠습니다. scripts 디렉토리를 생성해서 여기에 deploy.sh 스크립트를 생성합니다. 코드는 아래와 같습니다.

#!/bin/bash

REPOSITORY=/home/ec2-user/app/step2
PROJECT_NAME=SpringBootWebService

echo "> Build 파일 복사"

cp $REPOSITORY/zip/*.jar $REPOSITORY/

echo "> 현재 구동중인 애플리케이션 pid 확인"

CURRENT_PID=$(pgrep -fl SpringBootWebService | grep java | 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: $JAR_NAME"

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 &

다음은 .travis.yml 파일을 수정해줍시다.

 

 

.travis.yml 파일 수정

현재는 프로젝트의 모든 파일을 zip 파일로 만드는데, 실제로 필요한 파일은 Jar, appspec.yml, 배포를 위한 스크립트들 입니다. 이 외 나머지는 배포에 필요하지 않으니 포함하지 않도록 before_deploy를 아래와 같이 수정합니다. 

language: java
jdk:
  - openjdk8
branches:
  only:
    - main

# Travis CI Server's Home
cache:
  - directories:
      - '$HOME/.m2/repository'
      - '$HOME/.gradle'

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/SpringBootWebService.zip # deploy로 zip파일 이동

deploy:
  - provider: s3
    access_key_id: $AWS_ACCESS_KEY
    secret_access_key: $AWS_SECRET_KEY
    bucket: qazyj-springboot-build #S3 버킷 이름
    region: ap-northeast-2
    skip_cleanup: true
    acl: private #zip 파일 접근 private으로
    local_dir: deploy #before_deploy에서 생성한 디렉토리
    wait_until_deployed : true
    on:
      branch: main

## 새롭게 추가된 부분
  - provider: codedeploy
    access_key_id: $AWS_ACCESS_KEY # Travis repo settings에 설정된 값
    secret_access_key: $AWS_SECRET_KEY # Travis repo settings에 설정된 값
    bucket: qazyj-springboot-build # S3 버킷
    key: SpringBootWebService.zip # 빌드 파일을 압축해서 전달
    bundle_type: zip # 압축 확장자
    application: springboot2-webservice # 웹 콘솔에서 등록한 CodeDeploy 어플리케이션
    deployment_group: springboot2-webservice-group # 웹 콘솔에서 등록한 CodeDeploy 배포 그룹
    region: ap-northeast-2
    wait-until-deployed: true
    on:
      branch: main
## 새롭게 추가된 부분

# CI 실행 완료 시 메일로 알람
notifications:
  email:
    recipients:
      - qazyj@naver.com
  • Travis CI는 S3로 특정 파일만 업로드가 안됩니다.
    • 디렉토리 단위로만 업로드할 수 있기 때문에 before-deploy 디렉토리는 항상 생성합ㄴ디ㅏ.
  • before-deploy에는 zip 파일에 포함시킬 파일들을 저장합니다.
  • zip- r 명령어를 통해 before-deploy 디렉토리 전체 파일을 압축합니다.

마지막으로 CodeDeploy의 명령을 담당할 appspec.yml 파일을 수정합니다.

 

 

appspec.yml 파일 수정

appspec.yml 파일에 아래 코드를 추가합니다. location, timeout, runas의 들여쓰기를 주의해야 합니다. 잘못될 경우 배포가 실패합니다.

version: 0.0 # CodeDeploy 버전을 명시한다.
os: linux
files:
  - source:  / # 전체 파일을 나타낸다.
    destination: /home/ec2-user/app/step2/zip/ # source로 지정된 전체 파일이 받을 위치이다.
    overwrite: yes # 기존 파일들이 있으면 덮어 쓸지를 결정한다.
    
permissions:
  - object: /
    pattern: "**"
    owner: ec2-user
    group: ec2-user

hooks:
  ApplicationStart:
    - location: deploy.sh # 엔진엑스와 연결되어 있지 않은 Port로 새 버전의 스프링 부트를 시작합니다.
      timeout: 60
      runas: ec2-user
  • permissions
    • CodeDeploy에서 EC2 서버로 넘겨준 파일들을 모두 ec2-user 권한을 갖도록합니다.
  • hooks
    • CodeDeploy 배포 단계에서 실행할 명령어를 지정합니다.
    • ApplicationStart라는 단계에서 deploy.sh를 ec2-user권한으로 실행하게 합니다.
    • timeout: 60으로 스크립트 실행 60초 이상 수행되면 실패가 됩니다.(무한정 기다릴 수 없으니 시간 제한을 둬야만 합니다.)

모든 설정이 완료되었으니 커밋과 푸시를 합니다. 

 

CodeDeploy에 배포가 성공하면 웹브라우저에서 EC2 도메인을 이용해서 확인해봅시다. 마지막으로 실제 배포하듯이 진행해 보겠습니다.

 

 

실제 배포 과정 체험

build.gradle에서 프로젝트 버전을 아래와 같이 변경합니다.

version '1.0.1-SNAPSHOT'

간단하게나마 변경된 내용을 알 수 있게 src/main/resources/templates/index.mustache 내용에 아래와 같이 Ver.3 텍스트를 추가합니다.

<h1>스프링부트로 시작하는 웹 서비스 Ver.3</h1>

그리고 깃허브로 커밋과 푸시를 합니다. 변경된 코드가 배포된 것을 확인할 수 있습니다.

 

 

 

CodeDeploy 로그 확인

CodeDeploy와 같이 AWS가 지원하는 서비스에서는 오류가 발생했을 때 로그 찾는 방법을 모르면 오류를 해결하기가 어렵습니다. 그래서 배포가 실패할 경우 어느 로그를 봐야할지 간단하게 배워봅시다.

CodeDeploy에 관한 대부분 내용은 /opt/codedeploy-agent/deployment-root에 있습니다. 해당 디렉터리로 이동(cd /opt/codedeploy-agent/deployment-root) 한 뒤 ll 명령어를 입력하면 아래와 같은 내용을 확인할 수 있습니다.

drwxr-xr-x 7 root root 101  1월  7 00:41 18e713ab-302e-4750-ac92-3fa4f156e15b
drwxr-xr-x 2 root root 247  1월  7 00:41 deployment-instructions
drwxr-xr-x 2 root root  46  1월  7 00:36 deployment-logs
drwxr-xr-x 2 root root   6  1월  7 00:41 ongoing-deployment
  1. 최상단의 영문과 대시(-)가 있는 디렉토리명은 CodeDeploy ID입니다.
    • 사용자마다 고유한 ID가 생성되어 각자 다른 ID가 발급되니 본인의 서버에는 다른 코드로 되어있습니다.
    • 해당 디렉토리로 들어가보면 배포한 단위별로 배포 파일들이 있습니다.
    • 본인의 배포 파일이 정상적으로 왔는지 확인해 볼 수 있습니다.
  2. /opt/codedeploy-agent/deployment-root/deployment-logs/codedeploy-agent-deployments.log
    • CodeDeploy 로그 파일입니다.
    • CodeDeploy로 이루어지는 배포 내용 중 표준 입/출력 내용은 모두 여기에 담겨 있습니다.
    • 작성한 echo 내용도 모두 표기됩니다.

테스트, 빌드, 배포까지 전부 자동화되었습니다. 이제는 작업이 끝난 내용을 Master 브랜치에 푸시만하면 자동으로 EC2에 배포가 됩니다. 

하지만, 문제가 한 가지 남았습니다. 배포하는 동안 스프링 부트 프로젝트는 종료 상태가 되어 서비스를 이용할 수 없다는 것 입니다. 어떻게 하면 배포하는 동안에도 서비스는 계속 유지될 수 있을까요??

 

다음 장에서는 서비스 중단 없는 배포 방법에 대해서 배워봅시다.

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

이제 완성된 프로젝트와 배포 환경을 구축했습니다. 이들을 조합해 실제로 서비스를 한번 배포해봅시다.

 

 

 

EC2에 프로젝트 Clone 받기

EC2에 깃 설치

sudo yum install git

깃 설치 상태 확인

git --version

git clone으로 프로젝트를 저장할 디렉토리를 생성해줍시다.

mkdir ~/app && mkdir ~/app/step1

생성한 디렉토리로 이동합시다.

cd ~/app/step1

본인의 https 주소를 복사합니다. git clone을 해줍시다.

git clone (복사한 주소)

파일이 잘 복사 되었는지 확인합시다.

cd (프로젝트명)
ls

코드들이 잘 수행되는지 테스트로 검증해봅시다.

./gradlew test

5장까지 잘 적용했다면 정상적으로 테스트를 통과합니다.

만약 테스트가 실패해서 수정하고 깃허브에 푸시를 했다면 프로젝트 폴더 안에서 다음 명령어를 사용하면 됩니다.

git pull

만약 아래와 같이 gradlew 실행 권한이 없다는 메시지가 뜬다면

-bash: ./gradlew: Permission denied

아래 명령어로 실행 권한을 추가한 뒤 다시 테스트를 수행해주면 됩니다.

chmod +x ./gradlew

 

 

 

배포 스크립트 만들기

작성한 코드를 실제 서버에 반영하는 것을 배포라고 합니다. 이 책에서 배포라 하면 아래의 과정을 모두 포괄하는 의미라고 보면 됩니다.

  • git clone 혹은 git pull을 통해 새 버전의 프로젝트 받음
  • Gradle이나 Maven을 통해 프로젝트 테스트와 빌드
  • EC2 서버에서 해당 프로젝트 실행 및 재실행

앞선 과정을 배포할 때마다 개발자가 하나하나 명령어를 실행하는 것은 불편함이 많습니다. 그래서 이를 쉘 스크립트로 작성해 스크립트만 실행하면 앞의 과정이 차례로 진행되도록 하겠습니다.

~/app/step1에 deploy.sh 파일을 하나 생성합니다.

vim ~/app/step1/deploy.sh

빔은 기타 에디터와 다르게 빔만의 사용법이 있다고 합니다. 아래의 코드를 추가해줍시다. (PHP코드와 비슷합니다.)

#!/bin/bash 

REPOSITORY=/home/ec2-user/app/step1 
PROJECT_NAME=spring_web_service

cd $REPOSITORY/$PROJECT_NAME 

echo "> Git pull" 

git pull 

echo "> 프로젝트 Build 시작" 

./gradlew build 

echo "> step1 디렉토리로 이동" 

cd $REPOSITORY 

echo "> Build 파일복사" 

cp $REPOSITORY/$PROJECT_NAME/build/libs/*.jar $REPOSITORY/ 

echo "> 현재 구동중인 애플리케이션 pid 확인" 

CURRENT_PID=$(pgrep -f ${PROJECT_NAME}.*.jar) 

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/ | grep jar | tail -n 1) 

echo "> JAR Name: $JAR_NAME" 

nohup java -jar $REPOSITORY/$JAR_NAME 2>&1 &
  • REPOSITORY=/home/ec2-user/app/step1
    • 프로젝트 디렉토리 주소는 스크립트 내에서 자주 사용하는 값이기 때문에 이를 변수로 저장
    • PROJECT_NAME도 마찬가지입니다.
    • $변수명으로 사용가능합니다.
  • cd $REPOSITORY/$PROJECT_NAME/
    • 제일 처음 git clone 받았던 디렉토리로 이동합니다.
  • git pull
    • 디렉토리 이동 후, Master 브랜치의 최신 내용을 받습니다.
  • ./gradlew build
    • 프로젝트 내부의 gradlew로 build를 수행합니다.
  • cp ./build/libs/*.jar %REPOSITORY/
    • build의 결과물인 jar 파일을 복사해 jar 파일을 모아둔 위치로 복사합니다.
  • CURRENT_PID=$(pgrep -f springboot-webservice)
    • 기존에 수행 중이던 스프링 부트 애플리케이션을 종료합니다.
    • pgrep은 process id만 추출하는 명령어입니다.
    • -f 옵션은 프로세스 이름으로 찾습니다.
  • if ~ else ~ fi
    • 현재 구동 중인 프로세스가 있는지 없는지를 판단해서 기능을 수행합니다.
  • JAR_NAME=$(ls -tr $REPOSITORY/|grep jar |tail -n 1)
    • 새로 실행할 jar 파일명을 찾습니다
    • 여러 jar 파일이 생기기 때문에 tail -n으로 가장 나중에 jar 파일을 변수에 저장합니다.
  • nohup java -jar $REPOSITORY/$JAR_NAME 2>&1 &
    • 찾은 jar 파일명으로 해당 jar 파일을 nohup으로 실행합니다.
    • 스프링 부트의 장점으로 특별히 외장 톰캣을 설치할 필요가 없습니다.

이렇게 생성한 스크립트에 실행 권한을 추가합니다.

chmod +x ./deploy.sh

아래 명령어로 확인해보면 x 권한이 추가된 것을 확인할 수 있습니다.

ll

이제 이 스크립트를 다음 명령어로 실행합니다.

./deploy.sh

잘 실행되었으면 nohup.out 파일으르 열어 로그를 보겠습니다.

vim nohup.out

로그를 보니, ClientRegistrationRepository를 찾을 수 없다는 에러가 발생하면서 애플리케이션 실행에 실패하는 것을 볼 수 있습니다.

***************************

APPLICATION FAILED TO START

***************************



Description:



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.



The following candidates were found but could not be injected:

        - Bean method 'clientRegistrationRepository' in 'OAuth2ClientRegistrationRepositoryConfiguration' not loaded because OAuth2 Clients Configured Condition registered clients is not available





Action:



Consider revisiting the entries above or defining a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' in your configuration.

이유는 ClientRegistrationRepository를 생성하려면 clientId와 clientSecret가 필수입니다. 로컬 PC에서는 application-oauth.properties가 있어 문제가 되지 않았지만, 해당 파일은 .gitignore로 제외 대상이기 때문에 깃허브에 올라가지 않습니다.

 

 

 

외부 Security 파일 등록하기

애플리케이션을 실행하기 위해 공개된 저장소에 clientId와 clientSecret을 올릴 수는 없습니다. 서버에서 직접 이 설정들을 갖고 있게 해줍시다.

 

먼저 step1이 아닌 app 디렉토리에 properties파일을 생성합니다.

vim /home/ec2-user/app/application-oauth.properties

로컬에 있는 application-oauth.properties 파일 내용을 그대로 붙여넣기 합시다. 붙여넣기 한 뒤, :wq를 누르면 저장됩니다.

방금 생성한 application-oauth.properties를 쓰도록 deploy.sh 파일을 수정합니다.

vim deploy.sh
nohup java -jar \
   -Dspring.config.location=classpath:/application.properties,/home/ec2-user/app/application-oauth.properties \
   $REPOSITORY/$JAR_NAME 2>&1 &
  • -Dspring.config.location
    • 스프링 설정 파일 위치를 지정합니다.
    • 기본 옵션들을 담고 있는 application.properties과 OAuth 설정들을 담고 있는 application-oauth.properties의 위치를 지정합니다.
    • classpath가 붙으면 jar 안에 있는 resources 디렉토리를 기준으로 경로가 생성됩니다.
    • application-oauth.properties는 절대경로를 사용합니다. 외부 파일이 있기 때문입니다.

이제 다시 deploy.sh를 실행해봅시다.

./deploy.sh

 

 

 

스프링 부트 프로젝트로 RDS 접근하기

MariaDB에서 스프링부트 프로젝트를 실행하기 위해선 몇 가지 작업이 필요합니다.

  • 테이블 생성 : H2에서 자동 생성해주던 테이블들을 MariaDB에선 직접 쿼리를 이용해 생성합니다.
  • 프로젝트 설정 : 자바 프로젝트가 MariaDB에 접근하려면 데이터베이스 드라이버가 필요합니다.
  • EC2 설정 : 데이터베이스의 접속 정보는 중요하게 보호해야 할 정보입니다. 공개되면 외부에서 데이터를 모두 가져갈 수 있기 때문입니다. 프로젝트 안에 접속 정보를 갖고 있다면 깃허브와 같이 오픈된 공간에선 누구나 해킹할 위험이 있습니다.

 

 

RDS 테이블 생성

먼저 RDS 테이블을 생성해줍시다. 여기선 JPA가 사용될 엔티티 테이블과 스프링 세션이 사용될 테이블 2가지 종류를 생성합니다. JPA가 사용할 테이블은 테스트 코드 수행 시 로그로 생성되는 쿼리를 사용하면 됩니다.

 

복사하여 RDS에 반영해줍시다.

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

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;

 

 

프로젝트 설정

MariaDB 드라이버를 build.gradle에 등록합시다.

implementation("org.mariadb.jdbc:mariadb-java-client")

그리고 서버에 구동될 환경을 하나 구성합니다.

src/main/resources/에 application-real.properties파일을 추가합니다.

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

이렇게 하면 profile=real인 환경이 구성된다고 보면됩니다. 실제 운영 될 환경이기 때문에 보안/로그상 이슈가 될만한 설정들을 모두 제거하며 RDS 환경 profile 설정이 추가됩니다.

github에 푸시합니다.

 

 

EC2 설정

OAuth와 마찬가지로 RDS 접속 정보도 보호해야할 정보이니 EC2 서버에 직접 설정 파일으 둡니다. app디렉토리에 application-real-db.properties 파일을 생성합니다. 아래의 코드를 추가한 뒤 :wq로 저장해줍니다.

spring.jpa.hibernate.ddl-auto=none
spring.datasource.url=jdbc:mariadb://(rds주소):(포트명)/(database명)
spring.datasource.username=db계정
spring.datasource.password=db계정 비밀번호
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
  • spring.jpa.hibernate.ddl-auto=none
    • JPA로 테이블이 자동 생성되는 옵션을 None(생성하지 않음)으로 지정합니다.
    • RDS에는 실제 운영으로 사용될 테이블이니 절대 스프링 부트에서 새로 만들지 않도록 해야 합니다.
    • 이 옵션을 하지 않으면 자칫 테이블이 모두 새로 생성될 수 있습니다.

마지막으로 deploy.sh가 real profile을 쓸 수 있도록 수정해줍니다.

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 &

구동해봅니다.

./deploy.sh

아래의 명령어로 html 코드가 정상적으로 보인다면 성공입니다.

curl localhost:8080

저는 잘 되지 않았습니다. 

해결 방법은 일단 책 저자분의 블로그를 참고했습니다.

 

(2020.12.16) 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 최신 코드로 변경하기

작년 11월 말에 스프링 부트와 AWS로 혼자 구현하는 웹 서비스를 출판 하였습니다. Spring Boot가 2.1 -> 2.4로, IntelliJ IDEA가 2019 -> 2020으로 오면서 너무 많은 변화가 있다보니, 집필할 때와 비교해 실습

jojoldu.tistory.com

여기서 읽어 보시면 맨아래

이렇게 바꾸라고 되어있는데 이렇게 바꾸면

***************************

APPLICATION FAILED TO START

***************************



Description:



Failed to bind properties under 'spring.datasource.hikari' to com.zaxxer.hikari.HikariDataSource:



    Property: spring.datasource.hikari.driver-class-name

    Value: org.mariadb.jdbc.Driver

    Origin: URL [file:/home/ec2-user/app/application-real-db.properties]:7:44

    Reason: Failed to load driver class org.mariadb.jdbc.Driver in either of HikariConfig class loader or Thread context classloader



Action:



Update your application's configurat

위의 에러가 나오게 됩니다. application-real-db.properties 파일을  spring.datasource.hikari를 com.zaxxer.hikari.HikariDataSource로 전부 고쳐주니 정상적으로 돌아갔습니다.

spring.jpa.hibernate.ddl-auto=none
spring.jpa.show_sql=false

com.zaxxer.hikari.HikariDataSource.jdbc-url=jdbc:mariadb://(rds주소):(포트명)/(database명)
com.zaxxer.hikari.HikariDataSource.username=db 계정
com.zaxxer.hikari.HikariDataSource.password=db 비밀번호
com.zaxxer.hikari.HikariDataSource.driver-class-name=org.mariadb.jdbc.Driver

돌려 보니 SQL 테이블이 제대로 생성되지 않는 에러가 계속 발생해서 찾아보니 springboot 2.1.9가 되면서 기존에 사용중이던 MySQL5InnoDBDialect 이 Deprecated가 된것으로 보입니다. application.properties의 코드인 아래 코드를

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

2.1.10부터는 아래의 코드로 추가해줘야합니다.

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect spring.jpa.properties.hibernate.dialect.storage_engine=innodb spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
  • MySQL5Dialect 로 하게되면 auto innodb가 적용이 안된채로 작동되는것을 확인하였습니다.
    • 스프링부트 2.2.2가 되어도 MySQL57Dialect 를 사용해야될것 같습니다.
  • 2.1.10 이후에는 직접 jdbc-url을 선언하여 ;MODE=MYSQL가 h2주소 뒤에 붙어야만 mysql 테이블 쿼리가 정상 작동됩니다.

제가 잘 되지 않았던 부분은 application.properties에서 application-real.properties에 접근을 잘 못했던 것 같습니다. 아래의 코드로 테이블이 생성되면 안되는데

spring.jpa.hibernate.ddl-auto=none

계속 테이블이 생성되며 에러가 발생했던 것이였고, application.properties에서 application-real-db.properties로 직접 접근을 시켜주니 해결이 됐습니다.. (이것 때문에 EC2, RDS 두번 설치했습니다..ㅠㅠ)

application.properties를 아래와 같이 변경해주시면 됩니다!!

spring.profiles.include=oauth,real-db

만약 아래와 같이 입력했을 때,

curl localhost:8080

아래와 같이 나온다면

curl: (7) Failed to connect to localhost port 8080: Connection refused

아래 명령어를 입력해줍시다!

netstat -ln | grep 8080

그 후 다시 curl localhost:8080을 해보시면  아래와 같은 html 코드를 볼 수 있습니다!!

추가적으로 저는 두번째에 할때는 DB Navigator가 잘 동작하지 않아 IntelliJ 우측 탭에있는 Database를 사용해서 쿼리문을 작성하였습니다.

 

 

 

 

EC2에 소셜 로그인하기

잘 배포된 것은 확인했습니다. 이제 브라우저를 확인해 볼텐데, 그 전에 몇 가지 작업을 해보겠습니다.

  • AWS 보안 그룹 변경
    • 먼저 EC2에 스프링 부트 프로젝트가 8080 포트로 배포되었으니 8080 포트가 보안 그룹에 열려 있는지 확인합니다.

저는 열려 있었습니다.

 

  • AWS EC2 도메인으로 접속

왼쪽 사이드 바의 [인스턴스] 메뉴를 클릭합니다. 본인이 생성한 EC2 인스턴스를 선택하면 상세 정보에서 퍼블릭 DNS를 확인할 수 있습니다. 해당 주소가 EC2에 자동으로 할당된 도메인 입니다. 

그럼 도메인 주소에 8080 포트를 붙여 브라우저에 입력합니다. 아래와 같이 localhost:8080을 입력할 때와 같은 화면을 볼 수 있습니다.

이제 도메인을 가진 서비스가 되었습니다. 

 

아직 현재 상태에서는 해당 서비스에 EC2의 도메인을 등록하지 않았기 때문에 구글과 네이버 로그인이 작동하지 않습니다. 그렇기 때문에 구글먼저 등록하도록 하겠습니다.

 

 

  • 구글에 EC2 주소 등록

구글 console에 들어가서 우측에 있는 API 및 서비스 -> 사용자 인증 정보를 눌러줍니다. OAuth 2.0 클라이언트 ID에 만들어져있는 ID를 눌러줍니다. 승인된 리디렉션 URI에 (퍼블릭DNS주소:8080/login/oauth2/code/google)를 추가해준 뒤 저장해줍니다.

 

  • 네이버에 EC2 주소 등록

네이버 개발자 센터로 접속해서 본인의 프로젝트로 이동해줍니다. API 설정에서 PC웹 항목에 있는 서비스 URL과 Callback URL 2개를 수정합니다.  서비스 URL은 포트 번호를 제외한 실제 도메인 주소, Callback URL에는 구글에서 등록한 주소와 비슷한 전체 주소인 (퍼블릭DNS주소:8080/login/oauth2/code/naver)를 입력한 뒤 수정버튼을 눌러줍니다.

 

구글, 네이버 둘 다 도메인 주소로 로그인을 테스트 해보면 정상적으로 로그인이 될 것 입니다.

 

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

 

AWS에서는 모니터링, 알람, 백업, HA 구성 등을 모두 지원하는 관리형 서비스인 RDS를 제공합니다. RDS는 AWS에서 지원하는 클라우드 기반 관계형 데이터베이스입니다. 하드웨어 프로비저닝, 데이터베이스 설정, 패치 및 백업과 같이 잦은 운영 작업을 자동화하여 개발자가 개발에 집중할 수 있게 지원하는 서비스입니다. 

 

 

RDS 인스턴스 생성하기

먼저 RDS 인스턴스를 생성합시다. EC2 인스턴스를 만들 때와 똑같이 검색창에 RDS를 검색한 뒤 선택하고, RDS 대시보드에서 데이터베이스 생성 버튼을 클립합시다.

 

RDS 생성 과정이 진행됩니다. DB 엔진 선택화면에서 MariaDB를 선택해줍시다. MariaDB를 선택하는 이유는 아래와 같습니다.

  • 비용
  • Amazon Aurora(오로라) 교체 용이성

RDS의 가격은 라이센스 비용 영향을 받습니다. 상용 데이터베이스인 오라클,MSSQL이 오픈소스인 MySQL, MariaDB, PostgreSQL보다는 동일한 사양 대비 더 가격이 높습니다. 

두 번째로 Amazon Aurora 교체 용이성 입니다. Amazon Aurora는 AWS에서 MySQL과 PostgreSQL을 클라우드 기반에 맞게 재구성한 데이터베이스입니다. 공식 자료에 의하면 RDS MySQL 대비 5배, RDS PostgreSQL 보다 3배의 성능을 제공합니다. 더군다나 AWS에서 직접 엔지니어링하고 있기 때문에 계속해서 발전하고 있습니다. 그렇기 때문에 MariaDB를 사용하도록 하겠습니다.

 

MariaDB는 MySQL을 기반으로 만들어졌습니다. 쿼리를 비롯한 전반적인 사용법은 MySQL과 유사하니 사용 방법에 대해서는 크게 걱정하지 않아도 됩니다. MariaDB는 MySQL 대비 아래의 장점이 있습니다.

  • 동일 하드웨어 사양으로 MySQL보다 향상된 성능
  • 좀 더 활성화된 커뮤니티
  • 다양한 기능
  • 다양한 스토리지 엔진

MySQL을 써왔다면 이번 기회에 MariaDB를 선택해서 사용해보길 추천한다고 합니다.

 

 

 

 

 

RDS 운영 환경에 맞는 파라미터 설정하기

RDS를 처음 생성하면 몇가지 설정을 필수로 해야 합니다. 우선 아래 3개의 설정을 차례로 진행해 보겠습니다.

  • 타임존
  • Character Set
  • Max Connection

왼쪽 파라미터 그룹 탭을 클릭해서 이동합니다. 파라미터 그룹 생성 버튼을 눌러줍니다. 세부 정보 위쪽에 DB 엔진을 선택하는 항복이 있습니다. 여기서 방금 생성한 MariaDB와 같은 버전을 맞춰야 합니다. 그룹을 생성했으면 해당 파라미터 그룹을 클릭합니다. 파라미터 편집 버튼을 클릭해 편집 모드로 전환합니다.

  1. time_zone의 값을 Asia/Seoul로 바꿔줍니다. 
  2. character 검색해서 나온 값은 utf8mb4(이모지 저장가능), collation 검색해서 나온 값은 utf8mb4_general_ci로 바꿔줍니다.
  3. max_connections 150으로 바꿔줍니다.
  4. 변경사항 저장 버튼 클릭

이제 파라미터 그룹을 데이터베이스에 연결합시다. 왼쪽 메뉴탭에 데이터베이스를 클릭한 뒤 데이터베이스 체크 후 수정버튼을 눌러줍니다.

 

 

위와 같이 수정해줍시다.

 

 

 

내 PC에서 RDS에 접속해보기

내 PC에서 RDS로 접근하기 위해서 RDS의 보안 그룹에 본인 PC의 IP를 추가하겠습니다. 

 

RDS의 보안 그룹 정보를 그대로 두고, EC2에 사용된 보안 그룹의 그룹 ID를 복사한 뒤 복사된 그룹 ID와 본인의 IP를 RDS 보안 그룹의 인바운드로 추가합니다.

 

 

Database 플러그인 설치

로컬에서 원격 데이터베이스로 붙을 때 GUT 클라이언트를 많이 사용합니다. MySQL의 대표적인 클라이언트로 Weorkbench 등이 있습니다. 각각의 도구마다 큰 차이가 없으니 본인이 가장 좋아하는 틀을 사용하면 됩니다. 이 책에서는 인텔리제이에 Database 플러그인을 설치해서 진행했습니다.

 

RDS 정보 페이지에서 엔드 포인트를 확인합니다. 이 엔드 포인트가 접근 가능한 URL이므로 메모장 같은 곳에 복사해둡니다.

 

이제 Action검색(커맨드+시프트+a)으로 database browser를 실행합니다. 왼쪽 + 버튼을 누른 뒤, MySQL을 눌러줍니다. MariaDB는 MySQL 기반이므로 MySQL을 사용하면 됩니다. 본인이 생성한 RDS의 정보를 차례로 등록합니다.

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

 

이번엔 AWS라는 클라우드 서비스를 이용해 본격적으로 서버 배포를 진행해 보겠습니다. 

 

클라우드 서비스를 이용하는 이유 중 하나는 만든 서비스를 24시간 아무때나 외부에서 접근하려면 24시간동안 서버가 켜져있어야합니다. 

 

24시간 작동하는 서버에는 3가지의 선택지가 있습니다.

  • 집에 PC를 24시간 구동시킨다. 
  • 호스팅 서비스(Cafe 24, 코리아호스팅 등)을 이용한다.
  • 클라우드 서비스(AWS, AZURE, GCP. NAVER CLOUD PLATFORM 등)을 이용한다.

일반적으로 비용은 호스팅 서비스나 집 PC를 이용하는 것이 저렴합니다. 만약 특정 시간에만 트래픽이 몰린다면 유동적으로 사양을 늘릴 수 있는 클라우드가 유리합니다. 

 

클라우드에 대해서 잠깐 이야기하자면, 클라우드 서비스는 쉽게 말하면 인터넷(클라우드)를 통해 서버, 스토리지(파일 저장소), 데이터베이스, 네트워크, 소프트웨어, 모니터링 등의 컴퓨팅 서비스를 제공하는 것 입니다. 

 

AWS의 EC2는 서버 장비를 대여하는 것이지만, 실제로는 그 안의 로그 관리, 모니터링, 하드웨어 교체, 네트워크 관리 등을 기본적으로 지원하고 있습니다. 개발자가 직접 해야 할 일을 AWS가 전부 지원하는 것 입니다.

 

이런 클라우드에는 몇 가지의 형태가 있습니다.

  1. Infrastructure as a Service (IaaS, 아이아스, 이에스)
    • 기존 물리 장비를 미들웨어와 함께 묶어둔 추상화 서비스입니다.
    • 가상머신, 스토리지, 네트워크, 운영체제 등의 IT 인프라를 대여해 주는 서비스라고 보면 됩니다.
    • AWS의 EC2, S3 등
  2. Platform as a Service (PaaS, 파스)
    • 앞에서 언급한 IaaS에서 한 번 더 추상화한 서비스입니다.
    • 한 번 더 추상화 했기 때문에 많은 기능이 자동화되어 있습니다.
    • AWS의 Beanstalk(빈스톡), Heroku(헤로쿠) 등
  3. Software as a Service (SaaS, 사스)
    • 소프트웨어 서비스를 이야기합니다.
    • 구글 드라이브, 드랍박스, 네이버 클라우드 등

여러 클라우드 서비스 중 AWS를 선택합니다. 이유는 아래와 같습니다.

  • 첫 가입 시 1년간 대부분 서비스가 무료입니다. 단, 서비스마다 제한이 있는데 이는 각 서비스를 설정할 때 설명하겠습니다.
  • 클라우드에서는 기본적으로 지원하는 기능(모니터링, 로그관리, 백업, 복구, 클러스터링 등등)이 많아 개인이나 소규모일 때 개발에 좀 더 집중할 수 있습니다.
  • 많은 기업이 AWS로 이전 중이기 때문에 이직할 때 AWS 사용 경험이 도움이 됩니다. 국내에서는 쿠팡, 우아한형제들, 리멤버 등 클라우드를 사용할 수 있는 회사에서는 대부분 AWS를 사용한다고 합니다.
  • 사용자가 많아 국내 자료와 커뮤니티가 활성화되어 있습니다.

이 책에서 진행하는 모든 AWS 서비스는 IaaS를 사용합니다. AWS의 PaaS 서비스인 빈스톡을 사용하면 대부분 작업이 간소화되지만, 프리티어로 무중단 배포가 불가능합니다.

배포할 때마다 서버가 다운되면 제대로된 서비스를 만들 수 없으니 무중단 배포는 필수이고 빈스톡은 사용할 수 없습니다. 그리고 AWS 초보자인 저로서는 직접 하나씩 다 다뤄보는 것이 공부하는 데 도움이 될거라고 합니다.

 

 

 

AWS 회원가입

AWS 고식 사이트(https://aws.amazon.com/ko/)로 이동한 뒤 무료 계정을 만들어 줍시다. 무료로 개정을 만들고, 영문 주소와 신용 카드 정보를 입력한 뒤 가입하면 됩니다. 선택사항은 개인, 무료를 골라주시면 됩니다. 

 

 

 

EC2 인스턴스 생성하기 

EC2는 AWS에서 생성하는 성능, 용량 등을 유동적으로 사용할 수 있는 서버입니다. 

 

AWS에서 무료로 제공하는 프리티어 플랜에서는 EC2 사용에 다음과 같은 제한이 있습니다.

  • 사양이 t2.micro만 가능합니다.
    • vCPU(가상 CPU) 1Core, 메로리 1GB 사양입니다.
    • 보통 vCPU는 물리 CPU 사양의 절반 정도의 성능을 가집니다.
  • 월 750 시간의 제한이 있습니다. 초과하면 비용이 부과됩니다.
    • 24시간 * 31일 = 744시간 입니다.
    • 즉, 1대의 t2.micro만 사용한다면 24시간 사용할 수 있습니다.

앞의 제한 사항을 주의하면서 AWS를 사용하면 1년간 재미나게 써볼 수 있습니다. 자 그럼 EC2를 만들기 전에, 본인의 리전을 확인해 봅시다. 리전은 우측상단에 있습니다. 서울로 되어있지 않다면 서울로 바꿔줍시다.

 

그 후 검색창에 EC2를 검색해줍시다. AWS 서비스에 있는 EC2를 선택해 줍시다. EC2 대스보드가 나오는데, 중앙에 있는 인스턴스 시작 버튼을 클릭합니다. 인스턴스란 EC2 서비스에 생성된 가상 머신을 이야기합니다. 

 

인스턴스를 생성하는 첫 단계는 AMI(Amazon Machine Image)를 선택하는 것 입니다. AMI는 EC2 인스턴스를 시작하는데 필요한 정보를 이미지로 만들어 둔 것을 이야기합니다. 인스턴스라는 가상 머신에 운영체제 등을 설치할 수 있게 구워 넣은 이미지로 생각하면 됩니다.

예를들어 아마존 리눅스 2 AMI를 사용한다면 Amazon Linux 2 OS가 인스턴스에 설치되어 개발자가 사용할 수 있음을 의미합니다. 여기서는 Amazon Linux AMI를 선택합니다. 책에서는 Amazon Linux AMI를 선택했지만 저는 없어서 Amazon Linux 2 AMI를 선택했습니다.

 

Amazon Linux 2는 센토스(Centos) 7버전 자료들을 그대로 사용할 수 있다고 합니다. 그럼 굳이 센토스 AMI를 사용하지 않고 아마존 리눅스 AMI를 사용한 이유가 무엇일까요?? 이유는 아래와 같습니다.

  • 아마존이 개발하고 있기 때문에 지원받기가 쉽다.
  • 레드햇 베이스이므로 레드햇 계열의 배포판을 많이 다뤄본 사람일수록 문제없이 사용할 수 있다.
  • AWS의 각종 서비스와의 상성이 좋다
  • Amazon 독자적인 개발 리포지터리를 사용하고 있어 yum이 매우 빠르다.

Amazon Linux 2 AMI를 선택 한 뒤 프리티어로 표기된 t2.micro를 선택하고 다음버튼을 누릅니다. 다음단계는 세부정보 구성입니다. 혼자서 1대의 서버만 사용하니 별다른 설정없이 다음을 눌러 넘어가줍시다. 

 

다음 단계는 스토리지 선택입니다. 스토리지는 흔히 하드디스크라고 불리는 서버의 디스크를 이야기하며 서버의 용량을 얼마나 정할지 선택하는 단계입니다. 기본값은 8GB이지만, 30GB까지 프리티어로 가능하니 30GB으로 수정한 후, 다음버튼을 눌러줍시다.

 

다음 단계는 태그입니다. 웹 콘솔에서 표기될 태그인 Name 태그를 등록합시다. 여러 인스턴스가 있을 경우 이를 태그별로 구분하면 검색이나 그룹 짓기 편하므로 여기서 본인의 서비스의 인스턴스를 나타낼 수 있는 값으로 등록해준 뒤, 다음버튼을 눌러줍시다.

 

다음은 보안 그룹입니다. 보안 그룹은 방화벽을 이야기합니다. '서버로 80 포트 외에는 허용하지 않는다'는 역할을 하는 방화벽이 AWS에서는 보안 그룹으로 사용됩니다. 기존에 생성된 보안 그룹이 없으므로 보안 그룹 이름엔 유의미한 이름으로 변경합니다. 아래와 같이 변경해주시면 됩니다.

이 보안 그룹이 굉장히 중요한 부분입니다. 유형 항목에서 SSH이면서 포트 항목에서 22인 경우는 AWS EC2에 터미널로 접속할 때를 이야기합니다. pem 키가 없으면 접속이 안 되니 전체 오픈(0.0.0.0/0,::/0)하는 경우를 종종 발견합니다. 이렇게 되면 이후 파일 공유 디렉토리나 깃허브 등에 실수로 pem키가 노출되는 순간 서버에서 가상화폐가 채굴되는 것을 볼 수 있습니다.

보안은 언제나 높을수록 좋으니 pem 키 관리와 지정된 IP에서만 ssh 접속이 가능하도록 구성하는 것이 안전합니다. 그래서 본인의 집 IP를 기본적으로 추가하고 카페와 같이 집 외에 다른 장소에서 접속할 때는 해당 장소의 IP를 다시 SSH 규칙에 추가하는 것이 안전합니다.  그 후 검토 및 시작 버튼을 눌러줍시다. 그렇게 되면 검토 화면에서 보안 그룹을 경고하는데, 이는 8080포트가 전체 오픈이 되어서 발생합니다. 8080을 열어 놓는 것은 위험한 일이 아니니 바로 시작해줍시다.

 

인스턴스로 접근하기 위해서는 pem 키(비밀키)가 필요합니다. 그래서 인스턴스 마지막 단계는 할당할 pem키를 선택하는 것 입니다. 인스턴스는 지정된 pem 키(비밀키)와 매칭되는 공개키를 가지고 있어, 해당 pem 키 외에는 접근을 허용하지 않습니다. 일종의 마스터키이기 때문에 절대 유출되면 안 됩니다. pem 키는 이후 EC2 서버로 접속할 때 필수 파일이니 잘 관리할 수 있는 디렉토리로 저장합니다. 그 후 인스턴스를 시작해줍시다.

 

생성이 다 되었다면 IP와 도메인이 할당된 것을 확인할 수 있습니다. 인스턴스도 결국 하나의 서버이기 때문에 IP가 존재합니다. 인스턴스 생성 시에 항상 새 IP를 할당하는데, 같은 인스턴스를 중지하고 다시 시작할 때도 새 IP가 할당됩니다. 

즉, 요금을 아끼기 위해 잠깐 인스턴스를 중지하고 다시 시작하면 IP가 변경되는 것 입니다. 이렇게되면 매번 접속해야 하는 IP가 변경되서 PC에서 접근할 때마다 IP주소를 확인해야 합니다. 굉장히 번거로우므로 인스턴스의 IP가 매번 변경되지 않고 고정 IP를 가지게 해야합니다.

 


EIP 할당

AWS의 고정 IP를 Elastic IP(EIP, 탄력적 IP)라고 합니다. EC2 인스턴스 페이지 왼쪽 카테고리에서 탄력적 IP를 눌러 선택하고 주소가 없으므로 탄력적 IP주소 할당을 클릭해서할당 해준 뒤, 인스턴스와 연결해줍니다.

 

여기까지 진행했으면 EC2 인스턴스 생성 과정은 끝났지만 주의할 점이 있습니다. 방금 생성한 탄력적 IP는 생성하고 EC2 서버에 연결하지 않으면 비용이 발생합니다. 즉, 생성한 탈력적 IP는 무조건 EC2에 바론 연결해야 하며 만약 더는 사용할 인스턴스가 없을 때도 탄력적 IP를 삭제해야 합니다. 

 

 

 

EC2 서버에 접속하기

여기서는 Mac과 Window가 다른데, 저는 Mac을 사용하므로 Mac으로의 방법만 설명하겠습니다. Mac & Linux는 터미널에서 작업합니다.

 

AWS와 같은 외부 서버로 SSH 접속을 하려면 터미널에 매번 아래와 같이 긴 명령어를 입력해야 합니다.

ssh -i (pem 키 위치) (EC2의 탄력적 IP 주소)

이렇게 하라고 하는데 전 잘 되지않아서 검색해보니

ssh -i (pem 키 위치 & 확장자를 포함한 파일명) ec-user@(EC2의 탄력적 IP 주소)

로 하니까 연결이 되었습니다.

 

연결할 때마다 위의 코드를 적기는 힘듭니다. 아래와 같은 작업을 해줍시다.

cp (pem 키 위치 & 확장자를 포함한 파일명) ~/.ssh/
cd ~/.ssh/
ls
chmod 600 ~/.ssh/(pem 키 이름 & 확장자를 포함한 파일명)
vim ~/.ssh/config

이렇게 입력하면 입력할 수 있는 공간이 생깁니다. i 버튼을 눌러 수정할 수 있게 해준 후 아래의 코드를 입력합니다.

# 주석
Host (본인이 원하는 서비스명)
    HostName (ec2의 탄력적 IP 주소)
    User ec2-user
    IdentityFile ~/.ssh/(pem 키 이름 확장자 명 포함)

를 입력한 뒤 esc 버튼을 누르고 :wq를 입력하면 저장됩니다. 후 실행 권한이 필요하므로 아래의 권할 설정을 해줍시다.

chmod 700 ~/.ssh/config

이렇게 하면, 아래의 코드를 입력하면 ec2 서버와 연결이 가능해집니다.

ssh (config에 등록한 서비스명)

 

 

 

아마존 리눅스 서버 생성 시 꼭 해야할 설정들

java 8 설치

sudo yum install -y java-1.8.0-openjdk-devel.x86_64

타임존 변경

sudo rm /etc/localtime
sudo ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime
date

Hostname 변경

sudo hostnamectl set-hostname freelec-springboot2-webservice
sudo reboot
sudo vim /etc/hosts

에러 뜨는지 test(에러 뜨면 성공)

curl freelec-springboot2-webservice

 

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

스프링 시큐리티는 막강한 인증(Authentication)과 인가(Authorization) 기능을 가진 프레임워크입니다.  사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준이라고 보시면 됩니다. 

스프링의 대부분 프로젝트들(Mvc, Data, Batch 등등)처럼 확장성을 고려한 프레임워크다 보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있습니다. 

 

 

 

스프링 시큐리티와 스프링 시큐리티 oauth2 클라이언트

많은 서비스에서 로그인 방식보다는 구글, 페이스북, 네이버 로그인과 같은 소셜 로그인 기능을 사용합니다.

 

많은 서비스에서 소셜 로그인을 사용하는 이유는 뭘까요?? 로그인 기능을 직접 구현할 경우 배보다 배꼽이 커지는 경우가 많기 때문입니다. 직접 구현하면 다음을 전부 구현해야 합니다. OAuth를 써도 구현해야 하는것은 제외했습니다.

  • 로그인 시 보안
  • 비밀번호 찾기
  • 회원가입 시 이메일 혹은 전화번호 인증
  • 비밀변호 변경
  • 회원정보 변경

OAuth 로그인 구현 시 앞선 목록의 것들을 모두 구글, 페이스북, 네이버 등에 맡기면 되니 서비스 개발에 집중할 수 있씁니다.

 

  • 스프링 부트 1.5 vs 스프링 부트 2.0

스프링 부트 1.5에서의 OAuth2 연동 방법이 2.0에서는 크게 변경되었습니다. 하지만, 인터넷 자료들을 보면 설정 방법에 크게 차이가 없는 경우를 자주 봅니다. 이는 spring-security-oauth2-autoconfigure 라이브러리 덕분입니다.

 

spring-security-oauth2-autoconfigure

spring-security-oauth2-autoconfigure 라이브러리를 사용할 경우 스프링 부트2에서도 1.5에서 쓰던 설정을 그대로 사용할 수 있습니다. 새로운 방법을 쓰기보다는 기존에 안전하게 작동하던 코드를 사용하는 것이 아무래도 더 확실하므로 많은 개발자가 이 방식을 사용해 왔습니다. 

 

하지만 이 책에서는 스프링 부트 2 방식인 Spring Security Oauth2 Client 라이브러리를 사용해서 진행합니다. 이유는 아래와 같습니다.

  • 스프링 팀에서 기존 1.5에서 사용되던 spring-security-oauth 프로젝트는 유지 상태로 결정했으며 더는 신규 기능은 추가하지 않고 버그 수정 정도의 기능만 추가될 예정, 신규 기능은 새 oauth2 라이브러리에서만 지원하겠다고 선언
  • 스프링 부트용 라이브러리(starter) 출시
  • 기존에 사용되던 방식은 확장 포인트가 적절하게 오픈되어 있지 않아 직접 상속하거나 오버라이딩 해야 하고 신규 라이브러리의 경우 확장 포인트를 고려해서 설계된 상태   

그리고 한가지 더 이야기하자면, 이 책 이외에 스프링 부트 2 방식의 자료를 찾고 싶은 경우 인터넷 자료들 사이에서 다음 두 가지만 확인하면 됩니다. 먼저 spring-security-oauth2-autoconfigure 라이브러리를 썻는지를 확인하고 application.properties 혹은 application.yml 정보가 다음과 같이 차이가 있는지 비교해야합니다. 

스프링 부트 1.5와 2.0 설정 차이

스프링 부트 1.5방식에서는 url 주소를 모두 명시해야하지만, 2.0방식에서는 client 인증 정보만 입력하면 됩니다. 1.5 버전에서 직접 입력했던 값들은 2.0버전으로 오면서 모두 enum으로 대체되었습니다.

CommonOAuth2Provider라는 enum이 새롭게 추가되어 구글, 깃허브, 페이스북, 옥타의 기본 설정값을 모두 여기서 제공합니다. 이외의 다른 소셜 로그인(네이버, 카카오 등)을 추가한다면 직접 다 추가해주어야 합니다.

 

 

 

구글 서비스 등록

먼저 구글 서비스에 신규 서비스를 생성합니다. 여기서 발급된 인증 정보(clientId와 clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있으니 무조건 발급받고 시작해야 합니다.

 

 

 

appication-oauth 등록

src/main/resources에 appication-oauth.properties 파일을 생성한 뒤 아래의 코드를 추가합니다. 

spring.security.oauth2.client.registration.google.client-id=구글클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=구글클라이언트시크릿
spring.security.oauth2.client.registration.google.scope=profile,email
  • scpoe=profile,email
    • 기본 값은 openid, profile, email 입니다.
    • 강제로 profile, email을 등록한 이유는 Openid라는 scope가 있으면 Open Id Provider로 인식하기 때문입니다.
    • 이렇게되면 openid Provider인 서비스(구글)와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 Oauth2Service를 만들어야합니다.

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 Profiler이 생성되어 이를 통해 관리할 수 있습니다. 즉, profile=xxx라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있습니다. 호출하는 방식을 여러개지만 이 책에서는 스프링 부트의 기본 설정파일인 application.properties에서 application-oauth.properties를 포함하도록 구성합니다. 

 

application.properties에 아래와 같이 코드를 추가합니다.

spring.profiles.include=oauth

 

 

.gitignore 등록

구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들입니다. 외부에 노출하면 안되는데 github에 오픈소스를 올려놓으면 노출하게됩니다. 보안을 위해 깃허브에 application-oauth.properties 파일이 올라가는 것을 방지하겠습니다.

 

plugins에 .gitignore을 설치 후 gitignore 파일을 만들어줍니다. 해당 파일에 아래의 코드를 추가해주면 됩니다.

application-oauth.properties

.gradle
.idea

 

커밋 내역의 해당 파일이 없으면 성공입니다.

 

 

 

구글 로그인 연동하기

구글의 로그인 인증정보를 발급 받았으니 프로젝트 구현을 진행해봅시다. 머넞 사용자 정보를 담당할 도메인인 User 클래스를 생성합니다. 패키지는 domain 아래에 user 패키지를 생성합니다.

User 클래스 생성

@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();
    }
}
  • @Enumerated (EnumType.STRING)
    • JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정합니다.
    • 기본적으로는 int로 된 숫자가 저장됩니다.
    • 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 가 없습니다.
    • 그래서 문자열(EnumType.STRING)로 저장될 수 있도록 선언합니다.

각 사용자의 권한을 관리할 Enum 클래스 Role을 생성합니다.

Role Enum 클래스 생성

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;

}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 합니다.

 

마지막으로 User의 CRUD를 책임질 UserRepository도 생성합니다.

UserRepository 인터페이스 생성

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}
  • findByEmail
    • 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드입니다.

 

User 엔티티 관련 코드를 모두 작성했으니 본격적으로 시큐리티 설정을 진행하겠습니다.

 

 

스프링 시큐리티 설정

먼저 build.gradle에 스프링 시큐리티 관련 의존성 하나를 추가합니다. 코드는 아래와 같습니다.

    // 스프링 시큐리티
    implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
  • spring-boot-starter-oauth2-client
    • 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성입니다.
    • spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해줍니다.

build.gradle 설정이 끝났으면 OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성합니다.

config.auth 패키지를 앞으로 시큐리티 관련 클래스는 모두 이곳에 담는다고 보면 됩니다.

 

SecurityConfig 클래스를 생성하고 아래와 같이 코드를 작성합니다. CustomOAuth2UserService 클래스를 아직 만들지 않아서 아직 코드를 돌리면 안됩니다.

SecurityConfig 클래스 생성

@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);
    }
}
  • @EnableWebSecurity
    • Spring Security 설정들을 활성화시켜 줍니다.
  • csrf().disable().headers().frameOptions().disable
    • h2-console 화면을 사용하기 위해 해당 옵션들을 disalbe 합니다.
  • authorizeRequests
    • URL 별 권한 관리를 설정하는 옵션의 시작점입니다.
    • authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있습니다.
  • antMatchers
    • 권한 관리 대상을 지정하는 옵션입니다.
    • URL, HTTP 메소드별로 관리가 가능합니다.
    • "/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 주었습니다.
    • "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했습니다.
  • anyRequest
    • 설정된 값들 이외 나머지 URL들을 나타냅니다.
    • 여기서는 authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 합니다.
    • 인증된 사용자 즉, 로그인한 사용자들을 이야기합니다.
  • logout().logoutSuccessURL("/")
    • 로그아웃 기능에 대한 여러 설정의 진입점입니다.
    • 로그아웃 성공 시 "/" 주소로 이동합니다.
  • oauth2Login
    • OAuth2 로그인 기능에 대한 여러 설정의 진입점입니다.
  • userInfoEndpoint
    • OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 의 설정들을 담당합니다.
  • userService
    • 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록합니다.
    • 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있습니다.

설정 코드 작성이 끝났다면 CustomOAuth2UserService 클래스를 생성합니다. 이 클래스는 구글 로그인 이후 가져온 사용자의 정보(email, name, picture)들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원합니다.

CustomOAuth2UserService 클래스 생성

@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);
    }
}
  • registrationId
    • 현재 로그인 진행 중인 서비스를 구분하는 코드입니다.
    • 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용합니다.
  • userNameAttributeName
    • OAuth2 로그인 진행 시 키가 되는 필드값을 이야기합니다. Primary Key와 같은 의미입니다.
    • 구글의 경우 기본적으로 코드를 지원하지만, 네이버 카카오 등은 기본 지원하지 않습니다. 구글의 기본 코드는 "sub"입니다.
    • 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용됩니다.
  • OAuthAttributes
    • OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스입니다.
    • 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용합니다.
    • 바로 아래에서 이 클래스의 코드가 나오니 차례로 생성하시면 됩니다.
  • SessionUser
    • 세션에 사용자 정보를 저장하기 위한 Dto 클래스입니다.
    • User 클래스를 쓰지 않고 새로 만들어서 쓰는지 뒤이어서 상세하게 설명하겠습니다.

구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현되었습니다. 사용자의 이름이나 프로필 사진이 변경되면 User 엔티티에도 반영됩니다.

이제 OAuthAttributes 클래스를 생서해줍시다. Dto이기 때문에 auth패키지안에 dto 패키지를 만든뒤 안에 생성해줍시다.

OAuthAttributes 클래스 생성

@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) {
        return ofGoogle(userNameAttributeName, attributes);
    }

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

 

  • of()
    • OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 합니다.
  • toEntity()
    • User 엔티티를 생성합니다.
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때입니다.
    • 가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용합니다.

 

SessionUser 클래스 생성

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

SeesionUser에는 인증된 사용자 정보만 필요합니다. 

 

 

로그인 테스트

스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼을 추가해보겠습니다.

index.mustache 코드 추가

{{>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>
                {{/userName}}
            </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}}
  • {{#userName}}
    • 머스테치는 다른 언어와 같은 if문을 제공하고 있습니다.
    • true/false 여부만 판단할 뿐입니다.
    • 그래서 머스테치에서는 항상 최종값을 넘겨줘야합니다.
    • 여기서도 역시 userName이 있다면 userName을 노출시키도록 구성했습니다.
  • a href="/logout"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL입니다.
    • 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없습니다.
    • securityConfig 클래스에서 URL을 변경할 순 있지만 기본 URL을 사용해도 충분하니 여기서는 그대로 사용합니다.
  • {{^userName}}
    • 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용합니다.
    • 여기서는 userName이 없다면 로그인 버튼을 노출시키도록 구성했습니다.
  • a href="/oauth2/authorization/google"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL입니다.
    • 로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없습니다.

 

index.mustache에서 userName을 사용할 수 있게 IndexController에서 userName을 Model에 저장하는 코드를 추가합니다.

IndexController 코드 추가

public class IndexController {
    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user");

        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
    ...
}
  • (SessionUser) httpSession.getAttribute("user")
    • 앞서 작성된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했습니다.
    • 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있습니다.
  • if(user != null)
    • 세션에 저장된 값이 있을 때만 model에 userName으로 등록됩니다.

 

그럼 한번 테스트해 봅시다.

로그인 된 정보는 세션에 저장되어있어 껐다가 다시 들어와도 로그인 상태가 유지됩니다. 로그아웃을 하면 세션 정보가 사라져서 더이상 로그인 정보를 갖고있지 않는 것을 테스트 할 수 있습니다.

 

h2-console을 통해 유저정보가 user 테이블에 저장되있는 모습도 볼 수 있습니다.

 

하지만, 글등록을 하려고하면 글등록을 할 수 없도록 에러가 나옵니다.

이유는 현재 로그인된 사용자의 권한은 GUEST입니다. 이 상태에서는 posts 기능을 전혀 쓸 수 없습니다. 위의 에러를 읽어보면 status가 403으로 권한이 거부된 것을 볼 수 있습니다.

 

그렇다면 권한을 변경해서 다시 해봅시다. h2-console로 가서 role을 USER로 변경해봅시다.

update user set role = 'USER';

 

세션에는 이미 GUEST인 정보로 저장되어있으니 로그아웃한 후 다시 로그인해서 글 등록을 해보겠습니다. 

권한을 부여해준 뒤 글 등록이 되는 모습을 볼 수 있습니다.

 

 

 

어노테이션 기반으로 개선하기

일반적인 프로그래밍에서 개선이 필요한 나쁜 코드 중 대표적인 것은 같은 코드가 반복되는 부분입니다. 같은 코드를 계속 복사 & 붙여넣기 하다보면 수정이 필요할 때 해당 부분들을 모두 수정해야되기 때문에 유지보수에 좋지 못하기 때문입니다. 

 

그럼 앞서 만든 코드에서 개선할만한 것은 무엇이 있을까요? 이동욱님은 IndexController에서 세션값을 가져오는 부분이라고 생각하고 있습니다.

SessionUser user = (SessionUser) httpSession.getAttribute("user");

index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하면 그 때마다 직접 세션에서 값을 가져와야합니다. 그래서 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경해보겠습니다.

config.auth 패키지에 @LoginUser 어노테이션을 생성합니다.

@LoginUser 어노테이션 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
  • @Target(ElementType.PARAMETER)
    • 이 어노테이션이 생성될 수 있는 위치를 지정합니다.
    • PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있습니다.
    • 이 외에도 클래스 선언문에 쓸 수 있는 TYPE 등이 있습니다.
  • @interface
    • 이 파일을 어노테이션 클래스로 지정합니다.
    • LoginUser라는 이름을 가진 어노테이션으로 생성되었다고 보면 됩니다.

그리고 같은 위치에 LoginUserArgumentResolver를 생성합니다. HandlerMethodArgumentResolver라는

인터페이스를 구현한 클래스입니다. HandlerMethodArgumentResolver는 한가지 기능을 지원합니다. 바로 조건에 맞는 경우 메소드가 있다면 HandlerMethodArgumentResolver의 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있습니다.

 

LoginUserArgumentReslover 클래스 생성

@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");
    }
}
  • supportsParameter()
    • 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단합니다.
    • 여기서는 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환합니다.
  • resolveArgument()
    • 파라미터에 전달할 객체를 생성합니다.
    • 여기서는 세션에서 객체를 가져옵니다.

@LoginUser를 사용하기 위한 환경은 구성되었습니다. 

 

이제 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가해줍시다. config 패키지에 WebConfig클래스를 생성합시다.

WebConfig 클래스 생성

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

 

HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 합니다. 

 

모든 설정이 끝났으니 IndexController의 코드에서 반복되는 부분들을 모두 @LoginUser로 개선합시다.

IndexController 메서드 변경

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
  • @LoginUser SessionUser user
    • 기존에 httpSession.getAttribute("user")로 가져오던 세션 정보 값이 개선되었습니다.
    • 이제는 어느 컨트롤러든지 @LoginUser만 사용하면 세션 정보를 가져올 수 있게 되었습니다.

 

 

 

세션 저장소로 데이터베이스 사용하기

현재 만든 서비스는 애플리케이션을 재실행하면 로그인이 풀립니다. 이는 세션이 내장 톰캣의 메모리에 저장되기 때문입니다. 기본적으로 세션은 실행되는 WAS의 메모리에서 저장되고 호출됩니다. 메모리에 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화가 됩니다. 즉, 배포할 때마다 톰캣이 재시작되는 것 입니다. 

 

이 외에도 한 가지 더 문제가 있는데, 2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야만 합니다. 그래서 실제 현업에서는 세션 저장소에 대해 다음의 3가지 중 한 가지를 선택합니다.

  1. 톰캣 세션을 사용한다.
    • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식입니다.
    • 이렇게 될 경우 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요합니다.
  2. MySQL과 같은 데이터베이스를 세션 저장소로 사용합니다.
    • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법입니다.
    • 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있습니다.
    • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용합니다.
  3. Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용합니다.
    • B2C 서비스에서 가장 많이 사용하는 방식입니다.
    • 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요합니다.

여기서는 두 번째 방식인 데이터베이스를 세션 저장소로 사용하는 방식을 선택하여 진행하겠습니다. 선택한 이유는 설정이 간단하고 사용자가 많은 서비스가 아니며 비용 절감을 위해서입니다. 

 

이후 AWS에서 이 서비스를 배포하고 운영할 때를 생각하면 레디스와 같은 메모리 DB를 사용하기는 부담스럽습니다. 왜냐하면, 레디스와 같은 서비스(엘라스틱 캐시)에 별도로 사용료를 지불해야 하기 때문입니다. 

 

 

spring-session-jdbc 등록

build.gradle에 아래와 같이 의존성을 등록합니다. 

implementation('org.springframework.session:spring-session-jdbc')

그리고 application.properties에 세션 저장소를 Jdbc로 선택하도록 코드를 추가합시다.

spring.session.store-type=jdbc

모두 변경하였으니 다시 애플리케이션을 실행해서 로그인을 테스트한 뒤, h2-console로 접속해봅시다.

 

h2-console을 보면 세션을 위한 테이블 2개(SPRING_SESSION, SPRING_SESSION_ATTRIBUTES)가 생성된 것을 볼 수 있습니다. JPA로 인해 세션 테이블이 자동 생성되었기 때문에 별도로 해야 할 일은 없습니다. 

로그인을 해주니 세션 테이블에 값이 추가된 것을 볼 수 있습니다.

 

물론 지금은 기존과 동일하게 스프링을 재시작하면 세션이 풀립니다. 이유는 H2 기반으로 스프링이 재실행될 때 H2도 재시작되기 때문입니다. 이후 AWS로 배포하게 되면 AWS의 데이터베이스 서비스인 RDS(Relation Database Service)를 사용하게 되니 읻대부터는 세션이 풀리지 않습니다. 그 기반이 되는 코드를 작성한 것이니 걱정하지 마시길 바랍니다.

 

 

 

네이버 로그인

구글과 같이 네이버로도 로그인할 수 있게 해줍시다!

 

 

네이버 API 등록

먼저 네이버 오픈 API로 이동합니다.

https://developers.naver.com/apps/#/register?api=nvlogin

 

위처럼 만든 후, application-oauth.properties에 등록합시다. 네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력해야 합니다.
application-oauth.properties 코드 추가

# registration
spring.security.oauth2.client.registration.naver.client-id=네이버클라이언트ID
spring.security.oauth2.client.registration.naver.client-secret=네이버클라이언트시크릿
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
  • user_name_attribute=response
    • 기준이 되는 user_name의 이름을 네이버에서는 response로 해야합니다.
    • 이유는 네이버의 회원 조회 시 반환되는 JSON 형태 때문입니다.

네이버 오픈 API의 로그인 회원 결과는 아래와 같습니다.

{
    "resultcode" : "00",
    "message: : "success",
    "response" : {
        "email" : "openapi@naver.com",
        ...
    }
}

스프링 시큐리티에선 하위필드를 명시할 수 없습니다. 최상위 필드들만 user_name으로 지정 가능합니다. 하지만 네이버의 응답값 최상위필드는 resultcode, message, response입니다. 이러한 이유로 3개 중에 골라야합니다. 본문에서 담고있는 response를 user_name으로 지정하고 이후 자바 코드로 response의 id를 user_name으로 지정하겠습니다. 

 

 

스프링 시큐리티 설정 등록

구글 로그인을 등록하면서 대부분 코드를 확장성 있게 작성했다보니 네이버는 쉽게 등록 가능합니다. OAuthAttributes에 아래와 같이 네이버인지 판단하는 코드와 네이버 생성자만 추가해주면됩니다.

OAuthAttributes 메소드 수정, 추가

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

 

마지막으로 index.mustache에 네이버 로그인 버튼을 추가합니다. 

<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
  • /oauth2/authorization/naver
    • 네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri 값에 맞춰 자동으로 등록됩니다.
    • /oauth2/authorization/ 까지는 고정이고 마지막 path만 각 소셜 로그인 코드를 사용하면 됩니다.

 

 

네이버 로그인까지 성공했습니다.

 

 

 

기존 테스트에 시큐리티 적용하기

마지막으로 기존 테스트에 시큐리티 적용으로 문제가 되는 부분들을 해결해보겠습니다. 문제가 되는 부분들은 대표적으로 아래와 같은 이유 때문입니다.

기존에 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성하였습니다. 하지만, 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있습니다. 기존의 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정해봅시다.

 

문제 1. CustomOAuth2UserService 찾을 수 없음

CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없기 때문에 발생합니다. 난 분명히 application-oauth.properties를 추가했는데 왜 이런 설정값들이 없다고 할까요???

이는 src/main 환경과 src/test 환경의 차이 때문이라고 합니다.  둘은 본인만의 환경 구성을 가집니다. 다만, application.properties가 테스트 코드 수행할 때도 적용되는 이유는 test에 application.properties가 없으면 main의 설정을 그대로 가져오기 때문입니다. 다만, 자동으로 가져오는 옵션의 범위는 application.properties 파일 까지입니다. 

 

이 문제를 해결하기 위해 테스트 환경을 위한 application.properties를 만들어 봅시다. 실제 구글 연동까지 하는 테스트코드는 없기 때문에 가짜 설정값을 등록합니다.

spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
spring.session.store-type=jdbc

# Test OAuth

spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=profile,email

해당 application.properties를 추가하면 7개이던 error가 4개로 줄어들 것 입니다.

 

 

문제 2. 302 Status Code

Posts_등록된다 테스트 로그를 확인해보면 302(리다이렉션 응답) Status Code가 옵니다. 이는 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문입니다. 그래서 이런 API 요청은 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있게 합시다.

 

스프링 시큐리티 테스트를 위한 여러 도구를 지원하는 spring-security-test를 build.gradle에 추가합니다. 

testImplementation("org.springframework.security:spring-security-test")

 

그리고 PostsApiControllerTest의 2개의 테스트 메소드에 아래와 같이 임의 사용자 인증을 추가합니다.

    @Test
    @WithMockUser(roles="USER")
    public void Posts_등록된다() throws Exception { }
    
    @Test
    @WithMockUser(roles="USER")
    public void Posts_수정된다() throws Exception {}
  • @WithMockUser(roles="USER")
    • 인증된 모의 사용자를 만들어서 사용합니다.
    • roles에 권한을 추가할 수 있습니다.
    • 즉, 이 어노테이션으로 인해 ROLE_USER권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가집니다.

이정도만하면 될 것 같지만, 실제로 작동하지 않습니다. @WithMockUser가 MockMvc에서만 작동하기 때문입니다. 현재 PostsApiControllerTest는 @SpringBootTest로만 되어있으며 MockMvc를 전혀 사용하지 않습니다. 그래서 @SpringBootTest에서 MockMvc를 사용하는 방법을 알아봅시다. 코드를 아래와 같이 변경합니다.

package com.qazyj.book.springboot.web;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.qazyj.book.springboot.domain.posts.Posts;
import com.qazyj.book.springboot.domain.posts.PostsRepository;
import com.qazyj.book.springboot.web.dto.PostsSaveRequestDto;
import com.qazyj.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Before;
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.*;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    // 추가 MockMvc
    @Autowired
    private PostsRepository postsRepository;

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    @WithMockUser(roles="USER")
    public void Posts_등록된다() throws Exception {
        //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
        // mvc로 수정 
        mvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    @Test
    @WithMockUser(roles="USER")
    public void Posts_수정된다() throws Exception {
        //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;

        //when
        mvc.perform(put(url)
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(new ObjectMapper().writeValueAsString(requestDto)))
                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}
  • @Before
    • 매번 테스트가 시작되기전에 MockMvc 인스턴스를 생성합니다.
  • mvc.perform
    • 생성된 MockMvc를 통해 API를 테스트 합니다.
    • 본문(Body) 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환합니다.

그리고 전체 테스트를 해봅시다. 2개의 에러만 남게 됩니다.

 

 

문제 3 @WebMvcTest에서 CustomOAuth2UserService를 찾을 수 없음

@WebMvcTest는 CustomOAuth2UserService를 스캔하지 않기 때문에 에러가 발생합니다. @WebMvcTest는 WebSecurityConfigurerAdapter, WebMvcConfigurer를 비롯한 @ControllerAdvice, @Controller를 읽습니다. 즉, @Repository, @Service, @Component는 스캔 대상이 아닙니다. 그렇기 때문에 SecurityConfig를 읽었지만, SecurityConfig를 생성하기 위해 필요한 CustomOAuth2UserService는 읽을 수가 없어서 에러가 발생한 것 입니다. 

 

아래와 같이 스캔 대상에서 SecurityConfig를 제거해줍시다.

@WebMvcTest(controllers = HelloController.class,
        excludeFilters = {
        @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
        }
)

그리고 마찬가지로 @WithMockUser를 사용해서 가짜로 인증된 사용자를 생성해줍시다.

    @WithMockUser(roles="USER")
    @Test
    public void hello가_리턴된다() throws Exception {}
    
    @WithMockUser(roles="USER")
    @Test
    public void helloDto가_리턴된다() throws Exception {}

이렇게 한 뒤 테스트를 돌려보면 추가에러가 발생합니다.

 

이 에러는 @EnableJpaAuditing으로 인해 발생합니다. @EnableJpaAuditing를 사용하기 위해선 최소 하나의 @Entity 클래스가 필요합니다. @WebMvcTest이다 보니 당연히 없습니다.

 

@EnableJpaAuditing가 @SpringBootApplication과 함께 있다보니 @WebMvcTest에서도 스캔하게 되었습니다. 그래서 @EnableJpaAuditing과 @SpringBootApplication 둘을 분리하겠습니다. Application.java에서 @EnableJpaAuditing를 제거해줍시다.

 

그리고 config 패키지에 JpaConfig를 생성하여 @EnableJpaAuditing을 추가합니다.

 

그런뒤 돌려보면 모든 테스트가 정상적으로 완료됩니다.

 

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

머스테치를 통해 화면 영역을 개발해보겠습니다. 이번 기회에 서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이는 무엇인지, 이 책에서는 왜 JSP가 아닌 머스테치를 선택했는지, 머스테치를 통해 기본적인 CRUD 화면 개발 방법 등을 차례로 진행하며 배워봅시다.

 

 

서버 템플릿 엔진과 머스테치 소개

템플릿 엔진이란, 지정된 템플릿 양식과 데이터가 합쳐져 HTML 문서를 출력하는 소프트웨어를 이야기합니다. 템플릿 엔진에는 서버 템플릿 엔진과 클라이언트 템플릿 엔진이 있습니다.

 

서버 템플릿 엔진은 서버에서 Java 코드로 문자열을 만든 뒤 이 문자열을 HTML로 변환하여 브라우저로 전달합니다.

클라이언트 템플릿 엔진은 서버에선 브라우저로 데이터만 직렬화해서 넘겨주고, 브라우저에서 HTML을 생성합니다. 따라서 소스 코드가 브라우저에서 실행됩니다.

 

 

 

머스테치란

머스테치(http://mustache.github.io/)는 수 많은 언어를 지원하는 가장 심플한 템플릿 엔진입니다. 대부분의 언어를 지원하다 보니 자바에서 사용될 때는 서버 템플릿 엔진, 자바스크립트에서 사용될 때는 클라이언트 템플릿 엔진으로 모두 사용할 수 있습니다.

 

이동욱님이 사용하는 템플릿 엔진들의 단점은 아래와 같습니다.

  • JSP, Velocity : 스프링 부트에서는 권장하지 않는 템플릿 엔진입니다.
  • Freemarker : 템플릿 엔진으로는 너무 과하게 많은 기능을 지원합니다. 높은 자유도로 인해 숙련도가 낮을수록 Freemarker 안에 비즈니스 로직이 추가될 확률이 높습니다.
  • Thymeleaf : 스프링 진영에서 적극적으로 밀고 있지만 문법이 어렵습니다. HTML 태그에 속성으로 템플릿 기능을 사용하는 방식이 기존 개발자분들께 높은 허들로 느껴지는 경우가 많습니다. 

머스테치의 장점은 아래와 같습니다.

  • 문법이 다른 템플릿 엔진보다 심플합니다.
  • 로직 코드를 사용할 수 없어 View의 역할과 서버의 역할이 명확하게 분리됩니다.
  • mustache.js와 mustache.java 2가지가 다 있기 때문에 하나의 문법으로 클라이언트/서버 템플릿 모두 사용 가능합니다.

개인적으로 템플릿 엔진은 화면 역할에만 충실해야 한다고 생각합니다. 너무 많은 기능을 제공하면 API와 템플릿 엔진, 자바스크립트가 서로 로직을 나눠 갖게 되어 유지보수하기가 굉장히 어려워 질 것 입니다.

 

 

머스테치 플러그인 설치

아래와 같이 Preferences의 Marketplace에서 설치해주시면 됩니다. 

설치 한 뒤, build.gradle에 의존성을 주입해주면 됩니다. 코드는 아래와 같습니다.

    // 머스테치
    implementation('org.springframework.boot:spring-boot-starter-mustache')

위의 의존성을 보시면 머스테치는 스프링 부트에서 공식 지원하는 템플릿 엔진임을 알 수 있습니다.

 

 

기본 페이지 만들기

머스테치의 파일 위치는 기본적으로 src/main/resources/templates입니다. 해당 위치에 index.mustache를 생성한 뒤, 아래의 코드를 추가해주시면 됩니다.

<!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를 생성합니다.

@Controller
public class IndexController {

    @GetMapping("/")
    public String index() {
        return "index";
    }

}

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정됩니다. 즉, 여기선 "index"를 반환하므로, index.mustache로 전환되어 View Resolver가 처리하게 됩니다.

 

테스트 코드로 검증해 봅시다. test 패키지에 web패키지 안에 IndexControllerTest 클래스를 만든 뒤 아래의 코드를 추가해줍시다.

@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("스프링 부트로 시작하는 웹 서비스");
    }
}

정상적으로 돌아가는 것을 볼 수 있습니다.

 

 

 

게시글 등록 화면 만들기

오픈소스인 부트스트랩을 이용하여 화면을 만들어 봅시다. 부트스트랩, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 2가지가 있습니다. 하나는 외부 CDN을 사용하는 것, 다른 하나는 직접 라이브러리를 받아서 사용하는 방법입니다.

 

여기선 전자은 외부 CDN을 사용합니다. 사용 방법도 HTML/JSP/Mustache에 코드만 한줄 추가하면 되어 굉장히 간단합니다.

2개의 라이브러리 부트스트랩과 제이쿼리를 index.mustache에 추가해야 합니다. 하지만, 여기서는 바로 추가하지 않고 레이아웃 방식으로 추가해보겠습니다. 레이아웃 방식이란 공통 영역을 별도의 파일로 분리하여 필요한 곳에서 가져다 쓰는 방식을 이야기합니다.

 

src/main/resources/templates 디렉토리에 layout 디렉토리를 생성한 뒤, footer.mustach, header.mustache 파일을 생성합니다.

footer.mustach

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

header.mustach

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

 

코드를 보면 css와 js의 위치가 서로 다릅니다. 이는 페이징 로딩속도를 높이기 위해 css는 header에, js는 footer에 두었습니다. HTML은 위에서부터 코드가 실행되기 때문에 head가 다 실행되고서야 body가 실행됩니다.

 

즉, head가 다 불러지지 않으면 사용자 쪽에선 백지화면만 노출됩니다. 특히 js의 용량이 크면 클수록 body 부분의 실행이 늦어지기 때문에 js는 body 하단에 두어 화면이 다 그려진 뒤에 호출하는 것이 좋습니다.

 

반면 css는 화면을 그리는 역할이므로 head에서 불러오는 것이 좋습니다. 그렇지 않으면 css가 적용되지 않은 깨진 화면을 사용자가 볼 수 있기 때문입니다. 추가로, bootstrap.js의 경우 제이쿼리가 꼭 있어야만 하기 때문에 부트스트랩보다 먼저 호출되도록 코드를 작성했습니다. 보통 앞선 상황을 bootstrap.js가 제이쿼리에 의존한다고 합니다.

 

라이브러리를 비롯해 기타 HTML 태그들이 모두 레이아웃에 추가되니 이제 index.mustache에는 필요한 코드만 남게 됩니다. index.mustache 코드는 아래와 같이 변경됩니다.

{{>layout/header}}

    <h1>스프링부트로 시작하는 웹 서비스</h1>

{{>layout/footer}}
  • {{>layout/header}}
    • {{>}}는 현재 머스테치 파일을 기준으로 다른 파일을 가져옵니다.

 

레이아웃으로 파일을 분리했으니 index.mustache에 글 등록 버튼을 하나 추가해봅시다.

{{>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>
    </div>

{{>layout/footer}}

여기서는 <a> 태그를 이용해 글 등록 페이지로 이동하는 글 등록 버튼이 생성되었습니다. 이동할 페이지의 주소는 /posts/save 입니다.

 

해당 주소에 해당하는 컨트롤러를 생성해줍시다. 페이지에 관련된 컨트롤러는 모두 IndexController를 사용합니다.

IndexController 메소드 추가

    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }

 

index.mustache와 마찬가지로 /posts/save를 호출하면 연결되는 posts-save.mustache를 만들어 줍시다. templates에 생성해주면됩니다. 코드는 아래와 같습니다.

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

 

돌려보면, 글 등록 페이지까지 나오지만 아직 글 등록해주는 기능은 없습니다. API를 호출하는 JS가 전혀 없기 때문입니다. 그래서 src/main/resources에 static/js/app 디렉토리를 생성해줍시다. 해당 디렉토리에 index.js 파일을 생성해주고 아래의 코드를 추가해줍시다.

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 = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    }

};

main.init();
  • window.location.href = '/'
    • 글 등록이 성고하면 메인페이지(/)로 이동합니다.
  • type : 'POST'
    • http 메소드 중 하나인 POST를 선택합니다.
    • REST에서 CRUD는 다음과 같이 HTTP 메소드에 매핑됩니다.
      • 생성 (Create) : POST
      • 읽기 (Read) : GET
      • 수정 (Update) : PUT
      • 삭제 (Delete) : DELETE

자 그럼 생성된 Index.js를 머스테치 파일이 쓸 수 있게 footer.mustache에 추가해줍시다.

<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에서 /로 설정됩니다.

 

실제 프로그램을 돌려 글 등록을 해보시면 글 등록이 되었다는 문구와 함께 h2-console을 통해 테이블에 데이터가 추가된 것을 확인할 수 있을 것 입니다.

 

 

 

전체 조회 화면 만들기

전체 조회를 위해 Index.mustache의 UI를 변경하겠습니다. 코드는 아래와 같습니다.

{{>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}}
                <tr>
                    <td>{{id}}</td>
                    <td>{{title}}</td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
            </tbody>
        </table>
    </div>

{{>layout/footer}}
  • {{#posts}}
    • posts라는 List를 순회합니다.
    • java의 for문과 동일하게 생각하면 됩니다.
  • {{id}} 등의 {{변수명}}
    • List에서 뽑아낸 객체의 필드를 사용합니다.

 

그럼 Controller, Service, Repository 코드를 작성합시다. 먼저 Repository부터 시작합니다. 

기존에 있던 PostsRepository 인터페이스에 쿼리가 추가

public interface PostsRepository extends JpaRepository<Posts, Long> {

    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

SpringDataJap에서 제공하지 않는 메소드는 위처럼 쿼리로 작성해도 되는 것을 보여드리고자 @Query를 사용했습니다. 실제로 앞의 코드는 SpringDataJpa에서 제공하는 기본 메소드만으로 해결할 수 있습니다. 다만 @Query가 훨씬 가독성이 좋으니 선택해서 사용합시다.

 

PostsService 코드 추가

    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc() {
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
    }

(readOnly = true)를 주면 트랜잭션 범위는 유지하되, 조회 기능만 남겨두어 조회 속도가 개선되기 때문에 CUD 기능이 없을 때 사용하면 좋습니다.

메소드 내부의 코드에서 람다식을 모르시면 조금 생소한 코드가 있을 수 있습니다.

.map(PostsListResponseDto::new)  -> .map(posts -> new PostsListResponseDto(posts))와 같습니다. postsRepository 결과로 넘어온 Posts의 Stream을 map을 통해 PostsListResponseDto 반환 -> List로 변환하는 메소드입니다.

 

PostsListResponseDto 생성

@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를 수정해줍시다.

IndexController 수정

@RequiredArgsConstructor
@Controller
public class IndexController {
    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        
        return "index";
    }
}
  • Model
    • 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있습니다.
    • 여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달합니다.

 

Controller까지 모두 완성이 됐습니다. 실행한 뒤 localhosts:8080에 접속한 뒤 등록화면을 이용해 데이터를 등록해보면 홈화면에 데이터가 추가된 것을 확인할 수 있을 것 입니다.

 

 

 

게시글 수정, 삭제 화면 만들기

게시글 수정 API는 이미 만들었습니다. 해당 API로 요청하는 화면을 개발해봅시다.

 

게시글 수정

게시글 수정 화면 머스테치 파일을 만들어 줍니다.

posts-update.mustache 추가

{{>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="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}}
  • {{post.id}}
    • 머스테치는 객체의 필드 접근시 점(Dot)으로 구분합니다.
  • readonly
    • Input 태그에 읽기 가능만 허용하는 속성입니다.
    • id와 author는 수정할 수 없도록 읽기만 허용하도록 추가합니다. (실제 웹에서 수정이 불가합니다.)

 

그리고 btn-update 버튼을 클릭하면 update 기능을 호출할 수 있게 index.js파일에도 update function을 추가해줍니다.

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });

        $('#btn-update').on('click', function () {
            _this.update();
        });
    },
    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));
        });
    }

};

main.init();

 

마지막으로 수정 페이지로 이동할 수 있게 페이지 이동 기능을 추가해줍시다. index.mustache 코드를 아래와 같이 살짝만 수정해줍시다.

            {{#posts}}
                <tr>
                    <td>{{id}}</td>
                    <td><a href="/posts/update/{{id}}">{{title}}</a></td>
                    <td>{{author}}</td>
                    <td>{{modifiedDate}}</td>
                </tr>
            {{/posts}}
  • <a href="/posts/update/{{id}}"></a>
    • 타이틀(title)에 a tag를 추가합니다.
    • 타이틀을 클릭하면 해당 게시글의 수정 화면으로 이동합니다.

 

수정 화면을 연결할 Controller 작업을 해줍시다.

IndexController 메소드 추가

    @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에 삭제 버튼도 추가했습니다. 기능을 구현해봅시다. 삭제 이벤트를 진행할 js코드를 추가합시다.

 

index.js 추가

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

 

이제 삭제 API를 만들어 봅시다. 먼저 서비스 메소드입니다.

 

PostsService 메소드 추가

    @Transactional
    public void delete (Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

        postsRepository.delete(posts);
    }
  • postsRepository.delete(posts)
    • JpaRepository에서 이미 delete 메소드를 지원하고 있으니 이를 활용합니다.
    • 엔티티를 파라미터로 삭제할 수도 있고, deleteById 메소드를 이용하면 id로 삭제할 수도 있습니다.
    • 존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제합니다.

 

서비스에서 만든 delete 메소드를 컨트롤러가 사용하도록 코드를 추가합시다.

 

PostsApiController 메소드 추가

    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }

 

컨트롤러까지 생성되었으니 한번 테스트 해봅시다. 실제로 테스트해보면 정상적으로 돌아가는 것을 확인할 수 있습니다.

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

웹 서비스를 개발하고 운영하다 보면 피할 수 없는 문제가 데이터베이스를 다루는 일 입니다. 이동욱님이 스프링을 배울 때는 MyBatis와 같은 SQL 매퍼를 이용해서 데이터베이스의 쿼리를 작성했다고 합니다. 그러다 보니 실제로 개발하는 시간보다 SQL을 다루는 시간이 더 많았습니다. 그러던 중 RDBMS를 이용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있는 JPA라는 자바 표준 ORM기술을 만나게 되었다고 합니다.

 

 


JPA 소개

현대의 웹 애플리케이션에서 RDB는 빠질 수 없는 요소입니다. 그러다 보니 객체를 관계형 데이터베이스에서 관리하는 것이 무엇보다중요합니다. 관계형 데이터베이스가 SQL만 인식할 수 있기 때문에 현업 프로젝트 대부분이 애플리케이션 코드보다 SQL로 가득하게 됬습니다. SQL로만 가능하다 보니 각 테이블마다 기본적인 CRUD(Create, Read, Update, Delete) SQL을 매번 생성해야 합니다. 

 이러한 반복적인 SQL 코드는 실제 현업에서 수십, 수백 개의 테이블이 존재하고, 이런 테이블의 몇 배의 SQL을 만들고 유지보수해야합니다. 이러한 단순 반복 작업 문제 외에도 문제가 한 가지 더 있습니다. 바로 패러다임 불일치 문제입니다. RDB는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술입니다.

 객체지향 프로그래밍의 특징인 추상화, 캡슐화, 다형성, 상속화 등이 있습니다. RDB로 객체지향을 표현할 수 있을까요?? 결론부터 말하자면 쉽지 않습니다. 이유는 사상부터 다른 시작점에서 출발했기 때문입니다. RDB와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생하는 것입니다. 이를 패러다임 불일치라고 합니다.

 

예를 들어 봅시다. 객체지향 프로그래밍이 부모가 되는 객체를 가져오려면 어떻게 해야될까요?

User user = findUser();
Group group = user.getGroup();

코드를 보고 누구나 명확하게 User와 Group은 부모-자식 관계임을 알 수 있습니다.  하지만 여기에 데이터베이스가 추가되면 아래와 같이 변경됩니다.

User user = userDao.findUser();
Group group = groupDao.findGroup(user.getGroupId());

User 따로 Group 따로 조회하게 됩니다. User와 Group이 어떤 관계인지 알 수 있을까요?? 상속, 1:N 등 다양한 객체 모델링을 데이터베이스로는 구현할 수 없습니다. 그러다 보니 웹 애플리케이션 개발은 점점 데이터베이스 모델링에만 집중하게 됩니다. JPA는 이런 문제점을 해결하기 위해 등장하게 됩니다.

 

서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술입니다. 즉, 개발자는 객체지향적으로 프로그래밍하고, JPA가 이를 RDB에 맞게 SQL을 대신 생성해서 실행합니다. 개발자는 항상 객체지향적으로 코드를 표현할 수 있으니 더는 SQL에 종속적인 개발을 하지 않아도 됩니다.

 

 

Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서입니다. 따라서 JPA를 사용하기 위해서는 구현체가 필요합니다. 대표적으로 Hibernate, Eclipse Link 등이 있습니다. 하지만 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를 쓰는 중이라면 쉽게 교체할 수 있습니다. 실제로 자바의 Redis 클라이언트가 Jedis에서 Lettuce로 대세가 넘어갈 때 Spring Data Redis를 사용한 사람들은 아주 쉽게 교체를 했다고 합니다.

다음으로 저장소 교체의 용이성이란 RDB 외에 다른 저장소로 쉽게 교체하기 위함입니다. 서비스 초기에는 RDB로 모든 기능을 처리했지만, 점점 트래픽이 많아져 RDB로는 감당이 안 될 때가 올 수도 있습니다. 이때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됩니다.

 

이는 Spring Data 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문입니다. 즉, Spring Data 하위 프로젝트들은 save(), findAll(), findOne() 등을 인터페이스로 갖고 있습니다. 그러다보니 저장소가 교체되어도 기본적인 기능은 변결할 것이 없습니다. 이러한 장점들로 인해 Hibernate를 직접 쓰기보다는 Spring 팀에서 계속해서 Spring Data 프로젝트를 권하고 있습니다.

 

 

실무에서 JPA

실무에서 JPA를 사용하지 못하는 가장 큰 이유로 높은 러닝 커브를 이야기합니다. 이점은 이동욱님도 동의한다고 합니다. JPA를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스를 둘다 이해해야 합니다.

하지만 그만큼 JPA를 사용해서 얻는 보상은 큽니다. 가장 먼저 CRUD 쿼리를 직접 작성할 필요가 없습니다. 또한, 부모-자식 관계 표현, 1:N관계 표현, 상태와 행위를 한 곳에서 관리하는 등 객체지향 프로그래밍을 쉽게 할 수 있습니다.

속도 이슈에는 없을까 하는 걱정이 있을거라 생각합니다. 이동욱님은 포털 서비스와 이커머스에서 모두 JPA 기술들을 사용해보면서 높은 트래픽과 대용량 데이터 처리를 경험해보았지만 JPA에서는 여러 성능 이슈 해결 책들을 이미 준비해놓은 상태이기 때문에 이를 잘 활용하면 네이티브 쿼리만큼의 퍼포먼스를 낼 수 있다고 합니다. 

 

 

요구사항 분석

게시판의 요구사항은 아래와 같습니다.

  • 게시판 기능
    • 게시글 조회
    • 게시글 등록
    • 게시글 수정
    • 게시글 삭제
  • 회원 기능
    • 구글 / 네이버로 로그인
    • 로그인한 사용자 글 작성 권한
    • 본인 작성 글에 대한 권한 관리

어떤 웹 애플리케이션을 만들더라도 기반이 될 수 있게 보편적이지만 필수 기능들은 모두 구현하게 됩니다.

 

 

 

프로젝트에 Spring Data JPA 적용하기

 

먼저 build.gradle에 아래와 같이 의존성들을 등록합니다.

    // jpa
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    implementation('com.h2database:h2')
  • spring-boot-starter-data-jpa
    • 스프링 부트용 Spring Data JPA 추상화 라이브러리입니다.
    • 스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해줍니다.
  • h2
    • 인메모리 관계형 데이터베이스입니다.
    • 별도의 설치가 필요없이 프로젝트 의존성만으로 관리할 수 있습니다.
    • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됩니다.
    • JPA의 테스트, 로컬 환경에서의 구동에서 사용할 예정입니다.

의존성이 등록되었다면, JPA 기능을 사용해봅시다. main패키지 하위 패키지로 domain이란 패키지를 만듭시다. 해당 패키지는 도메인을 담을 패키지입니다. 여기서 도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하시면 됩니다.

 

기존에 MyBatis와 같은 쿼리 매퍼를 사용했다면 dao 패키지를 떠올리겠지만, dao 패키지와는 조금 결이 다르다고 생각하면 됩니다. 도메인이란 용어가 조금 어색하겠지만, 과정이 진행될 때마다 어떤 이야기인지 몸으로 느낄 수 있으니 조금만 참아달라고 합니다. (도메인에 대하여 공부하고 싶다면 최범균님이 집필하신 "DDD Start"를 추천합니다.)

 

domain 패키지에 posts 패키지와 Posts클래스를 만듭니다. Posts 클래스 코드는 아래와 같습니다.

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    @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;
    }
}
  • @Entity
    • 테이블과 링크될 클래스임을 나타냅니다.
    • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭합니다.
      Ex) SalesManager.java -> sales_manager table
  • @Id
    • 해당 테이블의 PK 필드를 나타냅니다.
  • @GeneratedValue
    • PK의 생성 규칙을 나타냅니다.
    • 스프링 부트 2.0 에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 됩니다.
    • 스프링 부트 2.0과 1.5 버전의 차이는 https://jojoldu.tistory.com/295에 정리되어있으니 참고하시길 바랍니다.
  • @Column
    • 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이됩니다.
    • 사용하는 이유는 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용합니다.
      Ex) 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하고싶거나 등의 경우에 사용됩니다.
  • @NoArgsConstructor
    • 롬복의 기능으로 기본 생성자를 자동 추가해주는 어노테이션입니다.
    • 위의 코드에서는 public Posts() {}가 자동생성된다고 보면됩니다.
  • @Builder
    • 롬복의 기능으로 해당 클래스의 빌더 패턴 클래스를 생성합니다.
    • 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함됩니다.

Posts 클래스는 실제 DB의 테이블과 매칭될 클래스이며 보통 Entity 클래스라고 불리기도 합니다. JPA를 사용하시면 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정을 통해 작업합니다. 서비스 초기 구축단계 에선 테이블 설계(여기선 Entity 설계)가 빈번하게 변경되는데, 이 때 롬복의 어노테이션들은 코드 변경량을 최소화시켜 주기 때문에 적극적으로 사용합니다.

 

@Setter를 두지 않는 이유는 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수 없기 때문입니다. 따라서, 필드 값 변경이 필요한 경우 파라미터로 set을 해주는 것이 아닌 목적과 의도를 나타낼 수 있는 메소드를 추가하여 사용합니다. 

예를들어, 취소 메소드를 만든다고 가정해봅시다.

잘못된 코드

public class Order {
    public void setStatus(boolean status){
        this.status = status
    }
}

public void 주문서비스의_취소이벤트() {
    order.setStatus(false);
}

올바른 코드

public class Order {
    public void setStatus(){
        this.status = false;
    }
}

public void 주문서비스의_취소이벤트() {
    order.setStatus();
}

 

그렇다면 의문점이 생깁니다. Setter가 없는 상황에서 어떻게 값을 채워 DB에 삽입해야 할까요? 

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로합니다.

이 책에서는 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스를 사용합니다. 생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같습니다. 다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수가 없습니다.

예를들어,  new Example(a, b)를 new Example(b, a)처럼 a와 b의 위치를 변경해도 코드를 싱행하기 전까지는 문제를 찾을 수가 없습니다. 하지만, 빌더를 사용하게 되면 아래와 같이 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있습니다.

Example.builder()
    .a(a)
    .b(b)
    .build();

 

Posts 클래스 생성이 끝났다면, Posts 클래스로 Database를 접근하게 해줄 JpaRepository를 생성합니다. 패키지는 Posts 클래스와 같습니다.

public interface PostsRepository extends JpaRepository<Posts, Long> {

    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

보통 Dao라고 불리는 DB Layer 접근자 입니다. JPA에선 Repository라고 부르며 인터페이스로 생성합니다. 단순히 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>을 상속하면 기본적인 CURD 메소드가 자동으로 생성됩니다.

@Repository를 추가할 필요도 없습니다. 여기서 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 하는 점 입니다. 둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없습니다. 

 

모두 작성되었다면 간단하게 테스트 코드로 기능을 검증해 보겠습니다.

 

 

 

Spring Data JPA 테스트 코드 작성하기

test디렉토리에 domain.posts 패키지를 생성하고, 테스트 클래스는 PostsRepositoryTest란 이름으로 생성합니다. 해당 클래스에서 save, findAll과 같은 기능을 테스트합니다. 코드는 아래와 같습니다.

@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("qazyj@naver.com")
               .build());

        //when
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}
  • @After
    • Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
    • 보통은 배포 전 전체 테스트를 수행할 때 테스트 간 침범을 막기위해 사용합니다.
    • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아있어 다음 테스트 실행 시 테스트가 실패할 수 있습니다.
  • postsRepository.save
    • 테이블 posts에 insert/update 쿼리를 실행합니다.
    • id 값이 있다면 update, 없다면 Insert쿼리가 실행됩니다.
  • postsRepository.findAll
    • 테이블에 posts에 있는 모든 데이터를 조회해오는 메소드입니다.

별다른 설정 없이 @SpringBootTest를 사용할 경우 H2 데이터베이스를 자동으로 실행해 줍니다. 이 테스트 역시 실행할 경우 H2가 자동으로 실행됩니다. 테스트를 실행해보면 정상적으로 돌아가는 것을 확인할 수 있습니다.

 

여기서 한 가지 궁금한 것이 있습니다. 실제로 실행된 쿼리는 어떤 형태일까?입니다.

application.properties에 아래 한줄의 코드를 적으면 확인할 수 있습니다.

spring.jpa.show_sql = true

저는 application.properties가 생성되어있지 않아서 생성해주었습니다. 위치는 main의 resources 폴더 안에 file로 만들면됩니다.

그 후 위의 코드를 추가해준 뒤 실행시켜보았습니다.

Hibernate: drop table posts if exists
Hibernate: create table posts (id bigint generated by default as identity, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id))

Hibernate: insert into posts (id, author, content, title) values (null, ?, ?, ?)
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: delete from posts where id=?

그 후 출력된 것을 자세히보면 위처럼 쿼리 로그가 출력이 되어 나옵니다. 

 

출력되는 로그를 MySQL 버전으로 바꿀 수도 있습니다. application.properties에 아래의 코드를 추가하면 됩니다.

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

그 후 출력하면 아래와 같이 나옵니다.

Hibernate: create table posts (id bigint not null auto_increment, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB

 

 

 

등록/수정/조회 API 만들기

 

API를 만들기 위해 총 3개의 클래스가 필요합니다.

  • Request 데이터를 받을 Dto
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

Service에서는 비즈니스 로직을 처리하는 것이 아닌 트랜잭션, 도메인 간 순서 보장의 역할만 합니다. 그렇다면 비즈니스 로직은 누가 처리할까요?? 그림을 봅시다.

spring 웹 계층

간단하게 각 영역을 소개하자면 아래와 같습니다.

  • Web Layer
    • 흔히 사용하는 컨트롤러(@Controller), JSP/Freemarker 등의 뷰 템플릿 영역입니다.
    • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기합니다.
  • Service Layer
    • @Service에 사용되는 서비스 영역입니다.
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용됩니다.
    • @Transactional이 사용되어야하는 영역이기도 합니다.
  • Repository Layer
    • Database와 같이 데이터 저장소에 접근하는 영역입니다.
    • 기존에 개발하셨던 분들이라면 Dao(Database Access Object) 영역으로 이해하시면 쉬울 것입니다.
  • Dtos
    • Dto(Data tranfer Ojbect)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 얘기합니다.
    • 예를들어, 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.
  • Domain Model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
    • 이를테면 택시 앱이라고 하면 배차 ,탑승, 요금 등이 모두 도메인이 될 수 있습니다.
    • @Entity를 사용해보신 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해해주시면 됩니다.
    • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아닙니다.
    • VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.

Web, Service, Repository, Dto, Domain 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 어디일까요? 바로 Domain입니다. 기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 합니다. 주문 취소 로직을 작성한다면 아래와 같습니다.

@Transactional
public Order cancelOrder(int orderId){
    1) 데이터베이스로부터 주문정보 (Orders), 결제정보(Biling), 배송정보(Delivery) 조회
    2) 배송 취소를 해야하는지 확인
    3) if(배송중이라면) {배송 취소로 변경}
    4) 각 테이블에 취소 상태 Update
}

모든 로직이 서비스 클래스 내부에서 처리됩니다. 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 됩니다.

 

 

order, biling, delivery가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 모데인 간의 순서만 보장해 줍니다. 이 책에서는 계속 이렇게 도메인 모델을 다루고 코드를 작성합니다.

 

그렇다면 등록, 수정, 삭제 기능을 만들어 보겠습니다. PostsApiController를 web 패키지에, PostsSaveRequestDto를 web.dto 패키지에, PostsService를 service.posts 패키지에 생성합니다. 코드는 아래와 같습니다.

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }

}

 

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

스프링을 어느 정도 써보셨던 분들이라면 Controller와 Service에서 @Autowired가 없는 것이 어색하게 느껴집니다. 스프링에선 Bean을 주입받는 방식들이 아래와 같습니다.

  • @Autowired
  • setter
  • 생성자

이 중 가장 권장하는 방식이 생성자로 주입받는 방식입니다.(@Autowired는 권장하지 않습니다.) 즉, 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다는 것 입니다. 위의 코드에서 생성자는 @RequiredArgsConstructor에서 해줍니다. 

생성자를 직접 안쓰고 롬복 어노테이션을 사용한 이유는 간단합니다. 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함입니다.

 

이제 Controller와 Service에서 사용할 Dto 클래스를 생성합니다.

@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 패키지에 PostsApiControllerTest를 생성합니다. 코드는 아래와 같습니다.

@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() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록된다() throws Exception {
        //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를 사용하면 됩니다.

 

수정/조회 기능도 빠르게 만들어 보겠습니다.

@RequiredArgsConstructor
@RestController
public class PostsApiController {

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

}
@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();
    }
}
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
    private String title;
    private String content;

    @Builder
    public PostsUpdateRequestDto(String title, String content) {
        this.title = title;
        this.content = content;
    }
}
@Getter
@NoArgsConstructor
@Entity
public class Posts {

    ...
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}
@RequiredArgsConstructor
@Service
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;
    }

    @Transactional(readOnly = true)
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));

        return new PostsResponseDto(entity);
    }
}

여기서 신기한 것이 있습니다. update 기능에서 데이터베이스 쿼리를 날리는 부분이 없습니다. 이게 가능한 이유는 JPA의 영속성 컨텍스트 때문입니다. 

영속성 컨텍스트란, 엔티티를 영구 저장하는 환경입니다. JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈립니다.

JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다. 

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. 즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없는 것입니다. 이 개념을 더티 체킹이라고 합니다. (더티 체킹에 대한 자세한 내용 - https://jojoldu.tistory.com/415) 

 

그럼 테스트 코드를 작성하여 업데이트한 코드가 정상작동 하는지 테스트해보겠습니다.

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    ...  

    @Test
    public void Posts_수정된다() throws Exception {
        //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);
    }
}

 

돌려보면 정상 작동하는 것을 확인할수 있습니다.

 

JPA와 테스트 코드에 대해 진행해보았으니, 조회 기능은 실제로 톰캣을 실행해서 확인해보겠습니다.

직접 접근하려면 웹 콘솔을 사용해야만 합니다. application.properties에 아래의 코드를 추가해줍시다.

spring.h2.console.enabled=true

추가 한 뒤 Application 클래스의 main 메소드를 실행합니다. 정상적으로 실행됐다면 톰캣이 8080 포트로 실행됐습니다. http://localhost:8080/h2-console로 접속하면 아래와 같이 웹 콘솔 화면이 등장합니다.

여기서 JDBC URL의 값이 jdbc:h2:mem:testdb로 되어있지 않다면 작성해줍니다. 그 후 connect 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동합니다. POSTS 테이블이 있어야 정상적으로 동작한 것 입니다.

간단한 쿼리를 실행해봅시다.

SELECT * FROM POSTS

위의 POSTS 테이블을 조회하면 아래와 같이 빈 테이블이 뜹니다.

테이블에 값을 추가한 뒤, 실행해 봅시다.

insert into posts (author, content, title) values ('author', 'content', 'title');

정상적으로 값이 잘 들어가는 것을 볼 수 있습니다.

 

등록된 데이터를 확인 후 API를 요청해 보겠습니다. http://localhosts:8080/api/v1/posts/1 을 입력해 API 조회 기능을 테스트해봅니다.

방금 입력한 데이터가 정상적으로 보이는 것을 볼 수 있습니다. 기본적인 등록/수정/조회 기능을 모두 만들고 테스트해 보았습니다. 

 

 

 

JPA Auditing으로 생성시간/수정시간 자동화하기

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함합니다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어서 굉장히 중요한 정보이기 때문입니다. 그렇다 보니 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 됩니다.

이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다고 생각하면 어마어마하게 귀찮고 코드가 지저분해집니다. 그래서 이 문제를 해결하고자 JPA Auditing을 사용하겠습니다.

 

 

LocalDate 사용

Java8 부터 LocalDate와 LocalDateTime이 등장했습니다. 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이라 Java8일 경우 무조건 써야 한다고 생각하면 됩니다.

 

domain 패키지에 BaseTimeEntity를 생성합니다. 코드는 아래와 같습니다.

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;

}
  • @MappedSuperclass
    • JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 합니다.
  • @EntityListeners(AuditingEntityListener.class)
    • BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.
  • @CreatedDate
    • Entity가 생성되어 저장될 때 시간이 자동 저장됩니다.
  • @LastModifiedDate
    • 조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.

BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할입니다.

 

그다음 Posts 클래스가 BaseTimeEntity를 상속받도록 변경합니다.

public class Posts extends BaseTimeEntity {
    ...
}

 

마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션 하나를 추가해줍니다.

@EnableJpaAuditing          // JPA Auditing 활성화
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

 

 

JPA Auditing 테스트 코드 작성하기

PostsRepositoryTest 클래스에 테스트 메소드를 하나 더 추가해줍니다.

    @Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2019, 6, 4, 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);
    }

테스트 코드를 수행해보면 실제 시간이 잘 저장된 것을 확인할 수 있습니다.

 

 

 

 

 

출처

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

+ Recent posts