주니어 개발자의 CI/CD 무중단 배포 환경 구축 후기

ashesglow·2020년 2월 9일
2
post-thumbnail

카페24 호스팅 경험

학부 시절에 프로젝트를 서비스할 때에는 카페 24를 활용해서 호스팅을 했었다.
그때 당시만 하더라도 AWS가 이렇게 많은 사랑을 받지는 않았었던 것 같다.(내가 몰랐던 건가..)

기억이 가물가물 해서 예전 블로그를 찾아보니 카페24에서 SSL 적용하던 글이 있어서 링크해본다.
https://jaeuk2274.tistory.com/39

참고로 Amazon에서 발급해주는 SSL 인증서는 무료라고 한다. (대신 해당 도메인의 DNS 서버로 Route53을 사용)

이때는 내가 직접 war로 묶고 배포하고 밤에 서버 재시작하고.. 이랬던 것 같다.
당시의 나에게는 CI/CD 이런 것에 대한 개념 자체가 없었고, 인프라 지식들도 거의 없다시피 한 수준이었다..

당연히 불편하다고 느꼈으나,
원래 이렇게 해야만 하고,
이런 방법밖에는 존재하지 않는 줄 알았다.

그러다가 어느 순간, 데브옵스라는 키워드가 핫해지기도 했고,
CI, CD의 개념이라던가..
젠킨스 같은 CI(Continuous Integration) 툴에 대해서도 알게 되었다.

아무튼 다시 돌아와서,

  • 너도나도 사용하는 AWS (국내 점유율 압도적)
  • 왠만한 IT 회사면 전부 구축되어 있다는 CI/CD 환경
  • 무중단 배포 (실서비스에는 필수)

환경에 대해 직접 구축하면서 경험해 보고 싶었다.
첫 글에도 배포때문에 아주 고생한다는 글이 있다. (사실 이건 소스가...)
제대로 이해하고, 구축도 해 봐야 뭘 해볼수도 있지 않을까?

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

그래서 블로그 참조하고, 구글링 해 나가면서 구현을 한번 해 보자.
생각 속에 어떤 책을 알게 되었다.

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

구성과 내용이 좋은 책이다.
AWS에 대해서 아무것도 모르던 사람이 따라가고, 이해하면서 직접 혼자서 모든 것을 구현해볼 수 있다.

또 AWS가 아니더라도
개인적으로 몇 가지의 좋은 베스트 프렉티스를 얻은 것 같다. (이 내용들은 밑에서 정리할 예정이다.)

최종적으로 이 책을 선택한 이유는
1. 최근 한창 공부했던 Boot + JPA 조합
2. 공부하려고 생각했던 Security + OAuth2 에 대한 구현 및 기초 지식
3. 최종적으로 AWS를 활용한 CI/CD 무중단 배포 환경 구축에 대한 내용이 잘 정리

무튼 프로젝트는 이런 스텍으로 진행하게 되었다.

  • Spring Boot + JPA
  • Spring Security + OAuth2 (Spring security) - google, naver login
  • AWS (EC2, RDS)
  • CI (Travis CI)
  • CD (AWS S3, CodeDeploy)
  • 무중단 배포 (Nginx)

기억하고 싶은 내용

AWS에 대해 공부하고 싶으면 내용이 좋으니 책을 구입해서 직접 실습해보는 것을 추천한다.

전체 내용들 그 중에서도 내가 인상깊게 보았거나,
내가 앞으로도 기억하고 싶은 내용들만 정리를 해 본다.

  1. @LoginUser 직접 생성해서 활용
  2. BaseTimeEntity 클래스 활용
  3. .ignore / 제대로 동작하지 않을 때 대처법
  4. 똑같은 이름으로 만들지 않으면서 생기는 삽질
  5. Entity 클래스와 기본 Entity Repository는 같은 도메인 디렉토리에서 관리
  6. zip, tar, tar.gz ...(삽질기)
  7. 각종 스크립트(.sh) 구성

참고로 직접 구현한 소스는 모드 깃허브에 업로드가 되어 있다.
궁금한 부분이 있으면, 책의 깃허브 소스보다 더 자세하게 주석을 달면서 공부했던 것 같다.
https://github.com/jaeuk2274/bootJpaAwsPractice

@LoginUser 직접 생성해서 활용

지금까지의 모든 개발에서 세션에 있는 유저 정보를 활용할 때 그냥 세션 세팅하고 꺼내서만 사용했었는데.

이렇게 어노테이션을 만들고,

@Target(ElementType.PARAMETER) // 이 어노테이션이 생성될 수 있는 위치 지정, 파라미터로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용 가능
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

세션 세팅하고

public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
	...
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		...
        User user = saveOrUpdate(attributes);
        httpSession.setAttribute("user", new SessionUser(user));
        // 왜 User 클래스 안쓰고 새로 SessionUser 클래스를 만드는지에 대한 이유
        // 직렬화 때문. 자식 엔티티 등 직렬화 대상에 자식들까지 포함되게 되는데.. 
        // 성능 이슈, 부수 효과가 발생할 확룔 올라감
        // 직렬화 기능을 가진 세션 DTO 하나를 만들어 사용하는 것이 운영 및 유지보수성이 좋다.
		...
    }

세션 DTO 하나를 만들어 사용한다.

@Getter // SessionUser 에는 인증된 사용자 정보. 만 필요
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();
    }
}

마지막으로 저렇게 파라미터로 받아서 사용한다..!

   @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        // @LoginUser 활용, 설정 참고(LoginUserArgumentResolver)
        // SessionUser user = (SessionUser) httpSession.getAttribute("user");
      
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
		...
    }

물론 저렇게 받기 위해서 몇 가지의 설정이 필요하다.
HandlerMethodArgumentResolver를 구현하고

// HandlerMethodArgumentResolver 는 한가지 기능 지원.
// 조건에 맞는 경우 메소드가 있다면 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있다.
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    //컨트롤러 메서드의 특정 파라미터를 지원하는지 판단.
    // @LoginUser 이 붙어 있고
    // 파라미터 클래스 타입이 SessionUser 인 경우
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        return isLoginUserAnnotation && isUserClass;
    }

    // 파라미터에 전달할 객체 생성 (여기서는 세션에서 객체 가져옴)z
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}

다음은 config 설정에서 만들었던 HandlerMethodArgumentResolver를 넣어줘야 한다.

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

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

정말 단순하게만 생각해도,

  • 파라미터만 봐도 딱 보인다. (세션 유저를 사용하는게)
  • 세션 DTO를 만들어 사용하기 때문에, 어떤 내용들이 들어가고, 들어있는지 명확하게 보인다.
  • 반복적인 코드가 없어진다.
    • SessionUser user = (SessionUser) httpSession.getAttribute("user");

BaseTimeEntity 클래스 활용

@Getter
@MappedSuperclass // Jpa 엔티티 클래스들이 BaseTimeEntity 상속할 경우 필드들도 칼럼으로 인식
@EntityListeners(AuditingEntityListener.class) // auditing 기능 포함
public abstract class BaseTimeEntity {

    @CreatedDate // 생성되어 저장될 때 시간 자동 저장
    private LocalDateTime createdDate;

    @LastModifiedDate // 조회한 엔티티의 값을 변경할 때 시간 자동 저장
    private LocalDateTime modifiedDate;

}

사실 이 내용은 이전 우아한형제들의 기술블로그를 보면서 알고 있었던 내용이었고,
코드리뷰 적응기(feat. 파일럿 프로젝트)
이렇게 적용해야지 생각하던 부분이었는데 역시나 책에서도 다루고 있던 내용이었다.

.ignore / 제대로 동작하지 않을 때 대처법

사실 회사에서 깃허브를 쓰지 않아서, (SVN 사용..)
.ignore에 대해서 몰랐다.

혹시나 나 처럼 모르신 분들이 있을 것 같아서 잠깐 정리하자면
Ignore 말 그대로 무시한다는 뜻만 알고 있으면 이해가 편하다. (깃에 올라가는걸 무시하게 한다)

gitignore란?

  • 깃에서 특정 파일 혹은 디렉토리를 관리 대상에서 제외할 때 사용하는 파일.
  • 이 파일 안에 기입된 내용들은 모두 깃에서 관리하지 않겠다는 것을 의미
  • 예를 들어 자동으로 생성되는 로그파일, 프로젝트 설정 파일 등을 관리 대상에서 제외할 수 있다.

해당 프로젝트의 .ignore 파일을 보면 이해가 빠를 것 같다.

.idea
.gradle

# 공개키 관련 깃에 올라가는 것을 막아야 한다. AWS 서버 내부 세팅에 파일 올려놓음
application-oauth.properties

#제대로 작동하지 않을 때 대처법
#git rm -r --cached .
#git add .
#git commit -m "fixed untracked files"

해당 파일을 보면
.idea
.gradle 디렉토리에 해당하는 파일들을 깃에서 관리하지 않겠다.
커밋할 때에도 해당 디렉토리들은 보이지 않는다.

또한 이 ignore 파일이 제대로 동작하지 않을 때에는 캐시를 지워주면 된다.
(위의 주석된 내용 참조)

Entity 클래스와 기본 Entity Repository는 같은 도메인 디렉토리에서 관리

이전의 나는 controller, repository, domain 이런 방식으로 디렉토리 관리를 했었는데,
(Entity는 domain, Repository는 전부 repository 이렇게..)

Entity 클래스와 기본 Entity Repository는 같은 도메인 디렉토리에서 관리를 한다고 한다.

image.png
이유는

  • 서비스가 커지면서, 도메인이 분리가 될 수 있다.
  • Entity 클래스는 기본 Repository 없이 제 약할을 할 수 없다.(의존적이다)
  • 둘이 함께 움직여야 한다.

똑같은 이름으로 만들지 않으면서 생기는 삽질

난 눈으로 직접 보는 것을 좋아한다.
물론 정확하게 알고 난 다음에는 관리의 용이성을 위해 같은 이름을 설정하고 세팅하지만,
정확하게 이게 어떤 것과 연관이 되고,
여기서는 이 이름을 사용하고 저기서는 저 이름을 사용한다는 걸 정확하게 알고 사용해야 한다고 생각한다.

처음 스프링을 좀 알게되고 공부할때도 (부트가 아닌 xml 설정..)
다 똑같은 이름으로 해서 그냥 설정, 세팅 빨리하는 것 보다, 이건 이렇게 여기서 사용 되는구나.
혼자 삽질하면서 배웠던(깨달았던) 것 같다.

무튼 그래서
책과는 다르게 (책에서는 대부분 같은 이름을 사용)
내 프로젝트 명부터 해서, AWS의 각종 서비스들에 대한 네임들을 전부 다르게 주었다.
한번씩 뭔가 안맞는 경우가 생기면,
이름 바꿔가면서 아 이 서비스는 이걸 이렇게 사용하고, 이 서비스에서는 이 이름을 사용하네? 를 배웠던 것 같다.

zip, tar, tar.gz ...(삽질기)

아무래도 책이 현 시점에서 최신의 책은 아니니까,
그 당시의 환경과 지금이 다른 경우가 있었다.

Travis CI 에서 zip 명령어만 사용하면 무한정 대기하게 되는 이슈가 발생하였다.
처음에 접근은..
1. 권한이 문제인가?

  • Tracis 에서 권한을 설정하는 뭔가 있나?
    아무리 찾아봐도 권한 관련된 설정은 없다.
    이미 내 깃허브랑 연동이 되어 있고, zip 명령어만 주석처리하면 다 잘 된다. 다른 명령어는 다 잘된다.
    그냥 zip 명령어만 무한정 대기를 한다.
  1. 내가 놓친 설정이 있나?
  • Travis 가 아닌 AWS에서 내가 놓친 설정이 있나?
  • 페어 키 설정도 다 정확하게 했다.
  • 그리고 before-deploy 단계
    즉, 배포도 전에 Travis에서 압축하는 과정에서 발생하는 것이라 AWS와는 상관이 없을 것 같았다.
  • 하지만 그건 내 추측이라 다시 한번 S3 버킷부터, IAM 권한까지 다시 세팅해 보았다.
  1. 역시 마찬가지로 무한정 대기를 하다 10분이 지나 타임아웃이 된다.
  • 설마 그럴 일은 없겠지만, 10분동안에 압축이 안된다..? (프로젝트가 얼마나 크다고 10분동안 압축이 안되나..?)
  • 그래서 타임 아웃 시간을 늘려도 보았다.
  1. 그래서 다른 접근. zip이 안되면 tar나 tar.gz로 압축하면 되잖아? (왜 진작에 이 생각을 안했을까..)
  • tar도 되고 tar.gz도 된다. (하하하..)
  • 이 방식으로 접근하고 찾아보니까, 스택오버플로에도 대부분 tar를 사용하더라..
  • 근데 tar보다는 그냥 tar.gz을 사용하고 싶었다. (tar는 실질적인 압축이 아니니까)
  • 여기서 bundle_type 에러가 있었는데..
  • zip -> zip / tar -> tar / tar.gz -> tgz 형식으로 받고 있었다.
    (이것도 모르고 tar.gz 으로 번들 타입으로 보내려고 했었다는.. 이건 tarvis log 보면서 파악했다..)

각종 스크립트(.sh) 구성

사실 스크립트 파일들을 직접 작성해 볼 기회가 많지 않았고,
솔직히 잘 몰랐다.

비록 간단한 스크립트 파일들이지만, 실습해 보면서 많이 배웠던 것 같다.

다른 스크립트를 Import 해서 해당 function을 사용한다던지

# 현재 stop.sh 가 속해 있는 경로 찾기
ABSPATH=$(readlink -f $0)
ABSDIR=$(dirname $ABSPATH)
# 자바로 보면 일종의 import 구문 / profile.sh의 function 사용
source ${ABSDIR}/profile.sh
...

echo 로 출력해 그 값을 사용해야 하는 방법이라던지..

#!/usr/bin/env bash

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

# 쉬고 있는 profile 찾기: real1이 사용중이면 real2가 쉬고 있고, 반대면 real1이 쉬고 있음
function find_idle_profile()
{
    # 현재 nginx가 바라보고 있는 스프링 부트가 정상적으로 수행 중인지 체크 정상 200.
    RESPONSE_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/profile)

    if [ ${RESPONSE_CODE} -ge 400 ] # 400 보다 크면 (즉, 40x/50x 에러 모두 포함)
    then
    ...
    echo "${IDLE_PROFILE}"
}

# 쉬고 있는 profile의 port 찾기
function find_idle_port()
{
    ...
}

후기

정리한 내용 이 외에도 유익한 내용들이 많았다.

  • 주니어 개발자가 미처 신경쓰지 못할 보안에 관련한 내용

  • 서버에 직접 배포하고 서버에서만 해당 파일들을 적용하게 하는 방법

  • AWS 서비스들, Nginx 등 각종 로그 파일, 설정파일 확인은 물론 신규 설정들을 적용하는 방법

    ex. codedeploy-agent-deployments.log
    스크린샷 2020-02-08 오전 10.38.30.png

몰랐으면 그냥 계속 몰랐을 내용들을 깨닫게 되었던 것 같다.

결론은
CI / CD / 무중단 배포 환경을 직접 구축해보면서
부족한 지식들에 대해 많이 깨닫게 되었고 (특히, 인프라적인 지식)
좋은 정제된 글/책들의 지식들을 습득하는 것에 대해서도 재미를 느꼈다.

또 단순히 잘나간다고, 핫하다고 사용하는 것이 아니라

  • 이 기술을 선택하는 이유.
  • 현재의 상황에서 왜 이런 기술을 추천하는지.
  • 기술들에 대한 비교. 장/단점

다양한 관점에서의 비교, 그 과정에 대해서 고민할 수 있는 시작이 된 것 같다.

0개의 댓글