티쳐포보스 회원가입 API를 맡아 개발했었다.

MVP 기능을 대략적으로 완수하고 현재 리팩토링하며 개선지점을 찾고 있는 중, 회원가입 API의 동시성 문제에 대해 고민해보게 되었다.

 

현재 회원가입 서비스 로직은 아래와 같이,

if (memberRepository.existsByEmailAndStatus(request.getEmail(), Status.ACTIVE))
    throw new AuthHandler(ErrorStatus.MEMBER_EMAIL_DUPLICATE);
if (memberRepository.existsByPhoneAndStatus(request.getPhone(), Status.ACTIVE))
    throw new AuthHandler(ErrorStatus.MEMBER_PHONE_DUPLICATE);
if (!request.getPassword().equals(request.getRePassword()))
    throw new AuthHandler(ErrorStatus.PASSWORD_NOT_CORRECT);
if (!(request.getAgreementUsage().equals("T") && request.getAgreementInfo().equals("T") && request.getAgreementAge().equals("T")))
    throw new AuthHandler(ErrorStatus.INVALID_AGREEMENT_TERM);

 

1. 이메일 중복 여부

2. 전화번호 중복 여부

3. 비밀번호 재입력 일치 여부

4. 약관동의 체크 여부

와 같은 검증 조건이 적용되며 로직이 이어진다.

 

이때 회원가입에서 동시성 문제를 피할 수가 없는데... 아래과 같은 케이스가 있을 것이다.

거의 동시에 사용자1과 사용자2가 "test@naver.com"이라는 같은 이메일을 가지고 회원가입을 진행한다고 가정하자.

이메일이 존재하면 에러 처리를 진행하지만, DB에 없는 이메일이기에 사용자1, 사용자2 둘 다 모두 에러 없이 비밀번호 암호화까지 완료한다.

간발의 차로 사용자1이 계정 생성에 성공하고, 뒤이어 사용자2도 성공하여 DB에 같은 이메일로 계정이 두 개 생긴다.

 

이와 같은 동시성 문제는 어떻게 처리하면 좋을까?

Jmeter, JUnit을 활용하여 테스트 코드를 직접 작성해 동시 읽기 문제를 해결해보고자 한다.

사실 실제 서비스에서 같은 이메일 계정으로 회원가입을 할 가능성은 거의 희박하지만 ,,,  DB 락, 유니크 키 등 개념을 활용하여 서비스 진행 중 발생할 수 있는 케이스를 해결해보고자 한다!

 

 

 

우선 Jmeter를 사용해 스레드 100개를 2번 호출하여 중복으로 계정이 얼마나 생성되는지 확인해보았다.

수많은 호출 중 보이는 초록 불빛..

그러고 DB를 확인해본 결과

 

10개의 중복 계정이 생성되었다..!

실제 서비스에서 같은 이메일로 계정 생성하는 경우는 드물 거 같지만,, 10개나..

그러나 이러한 문제는 매우 크리티컬하게 작용할 것이기에, 자바의 ExecutorService와 CountDownLatch를 활용하여 회원가입 테스트를 진행해보았다.

 

 

우선, 여러 요청이 들어왔을 때 애플리케이션, 데이터베이스 단계 관점에서 해결 방법을 생각해볼 수 있다.

JVM의 동기화 기술은 여러 개의 JVM이 동시에 실행되는 멀티 스레드 환경에서 문제가 되기에,

데이터의 무결성과 일관성을 위해 트랜잭션을 지원하는 DB에서 처리하는게 좋다고 판단했다.

 

단 긴 시간 락을 점유하는 트랜잭션 비용이 크거나 잘못될 경우, 격리 수준, 전파 수준과 같은 데드락이 발생하는 락을 사용하기 보다는 데이터베이스 키 무결성을 이용하는 게 좋다고 한다.

JPA와 Database를 활용하여 동시성 문제를 해결할 수 있는 방법들로 하나씩 풀어나가 보겠다!

 

 

1.Service 코드에 @Synchronized 사용

가장 간단한 방법이다.

 

해당 예약어를 사용하는 경우 동시에 오는 스레드 요청을 커밋하기 전 트랜잭션 전 단계에 Synchronized를 설정해야한다.

멀티 스레드 환경에서 하나의 자원을 사용하고자 할 때, 먼저 접근하여 사용하고 있는 스레드를 제외하고 나머지 스레드는 접근할 수 없도록 막는다.

 

그래서 사용하게 된다면, 자바 내부에서 동기화를 위해 block, unblock 처리를 하게 되는데 요청이 너무 많아지면 프로그램 성능 저하를 일으킬 수 있다..!

그래서 이 방법은 테스트만 진행해보고 패스하기로 했다.

아래 링크 답변을 통하여 멀티 스레드, 그리고 동기화 이슈에 대해 잘 이해할 수 있었다!

https://stackoverflow.com/questions/41767860/spring-transactional-with-synchronized-keyword-doesnt-work

 

Spring @Transactional with synchronized keyword doesn't work

Let's say I have a java class with a method like this (just an example) @Transactional public synchronized void onRequest(Request request) { if (request.shouldAddBook()) { if (database.

stackoverflow.com

 

 

2. 낙관적 락 적용

현재 사용하고 있는 DBMS는 MySQL로 기존 격리수준은 repeatable read이다.

기존 격리수준을 변경하지 않고 격리성을 증진할 수 있는 방법으로 락을 사용할 수 있다.

트랜잭션을 순차적으로 처리하도록 도와주고, 데이터의 일관성을 유지할 수 있도록 도와준다.

현재 이슈의 경우 트랜잭션 충돌이 심하게 나지 않을 것이라 가정하고, 낙관적 락으로 충분히 해결할 수 있다 생각했다.

 

낙관적 락을 적용하기 위해 @Version에 해당하는 컬럼을 추가한다.

그러면서 버전을 비교해가며 트랜잭션 충돌을 줄일 수 있는데,

회원가입 API의 경우 우선 이메일 중복으로 version에 해당하는 컬럼을 추가하기까진 동시성 이슈가 발생할 가능성이 적기도 하고

좋아요 기능이나 예약 기능과 같이 동시다발적으로 같은 데이터를 참조할 경우가 낮다고 판단하여 해당 방법 또한 패스하였다 ㅎㅎ..

 

 

3. 테이블에 Unique key 조건 추가

중복 검증하고 싶은 컬럼에 unique=true 조건을 추가하여 insert가 두 건 이상 되지 않도록 한다.

현재 내가 중복 체크하고 싶은 컬럼은 email과 phone 컬럼이다.

엔티티 @Table에 UniqueConstraint 조건을 설정하여 DuplicateKeyException을 감지할 수 있는데,

이와 같은 경우 (email, phone) 함께 엮어서 unique key를 생성한다.

테이블에 추가하는 경우 email과 phone중에 하나라도 중복되는게 있다면 Exception을 발생시키기에, 컬럼마다 추가하는 식으로 진행하였다.

 

해당 과정을 트랜잭션 관점으로 살펴보자면,

두 사용자가 같은 이메일로 회원가입 요청을 보내도 아직 데이터베이스에 해당 이메일에 해당되는 레코드가 존재하지 않는다. 그렇기에 두 트랜잭션 모두 read 이후 각각 write(member_1), write(member_2) 쓰기 작업을 수행한다.

 

그러나 unique 제약 조건을 설정함으로써 한 트랜잭션에서 쓰기 작업을 진행하고 commit이 된다면

다른 트랜잭션에서는 commit되는 시점에 unique 조건을 만족하지 않아 DataIntegrityViolationException 예외가 발생한다. 따라서 이를 통해 회원가입 동시성을 해결할 수 있다!

 

직접 회원가입 API에 적용하여, 테스트 코드와 함께 검증도 진행해보겠다.

우선 unique 키를 적용하여 부하 테스트를 먼저 진행해보겠다!

현재 데이터베이스에서 Member 테이블에 email = 'test@gmail.com'이라는 조건의 쿼리를 조회하면 2.75 cost가 소요된다. 

 

Member 엔티티 email과 phone 컬럼에 unique key 조건을 추가해준다.

 

쿼리는 위와 같다!

 

그리고 회원가입 API 부하 테스트를 맨 처음 동일한 조건으로 100개의 스레드를 두 번 호출해주면..!

 

 

위의 기존에 중복으로 추가되었던 계정은 unique 키 설정으로 부득이하게 변경

계정 하나만 새로 추가되는 것을 확인할 수 있다!

그러나 저장이 되지 않은 것일 뿐, API 중복 요청이 안오는 것은 아니다.

즉 데이터베이스 커밋 요청은 들어오니.. 테스트 코드를 작성함으로써 동시에 저장되는지 검증 또한 진행해보겠다.

 

우선 Java에서 비동기 로직을 수행하는 ExecutorServiceCountDownLatch를 활용해주었다.

회원가입 메서드를 수행하는 스레드는 총 5개로 설정하였고, 이미 회원가입된 이메일이 있다면 List에 담아주지 않는 로직으로 진행했다. 

 

@Test
@DisplayName("회원 가입 - 동시성 테스트")
void joinMemberWithConcurrent() throws InterruptedException {
    // given
    Member expected = authTestUtil.generateMemberDummy("email@gmail.com");
    AuthRequestDTO.JoinDTO request = request("백채연", "email@gmail.com", "asdf1234", "asdf1234", 2, "01012341234", "T"); // request로 입력한 Member data

    doReturn(expected).when(memberRepository)
            .save(any(Member.class));

    ExecutorService executorService = Executors.newFixedThreadPool(5);
    CountDownLatch latch = new CountDownLatch(5);

    // when
    log.info("회원가입 동시성 테스트 진행");
    ArrayList<Member> memberList = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
        executorService.execute(() -> {
            Member member = memberRepository.save(authCommandService.joinMember(request));
            if (memberList.isEmpty()){
                memberList.add(member);
            }
            else{
                for (Member m : memberList) {
                    if (!m.getEmail().equals(member.getEmail())) {
                        memberList.add(member);
                    }
                }
            }
            latch.countDown();
        });
    }
    latch.await();

    // then
    assertEquals(memberList.size(), 1);
}

 

CountDownLatch를 활용하여 설정한 count가 0이 될 때까지 타 스레드에서 대기해준다.

ExecutorService로 설정한 5개 스레드가 모두 작업을 마칠때까지 대기하여 결과를 테스트한 결과, 정상적으로 하나의 계정만 리스트에 들어온 것을 확인할 수 있었다.

 

실행 계획을 살펴본 결과 cost가 0.35로 줄어들었다!

unique key 적용 전에는 테이블 full scan을 진행했었는데, 인덱스로 조회하다니..!

unique key 생성함과 동시에 mysql 자체에서 인덱스를 생성해준 것 같다. 

 

테이블의 인덱스를 확인해본 결과 email과 phone에 해당되는 unique index가 생성된 것을 확인할 수 있었다.

 

 

이렇게 최종적으로 unique constraint를 추가하여 수정한 부분은 DB에서 직접 예외를 발생시키기에 애플리케이션 예외로 다시 넘겨주어야 한다.

위에 언급하였듯 회원가입 API의 경우 동시성 이슈가 발생할 가능성이 적기도 하고, DB 단계에서 예외 처리해도 충분하다고 생각하는 트랜잭션 작업이라 생각했기에 이와 같이 진행했다.

추후 동일한 자원을 공유하며 조회하고, 수정하는 작업의 API를 진행할 때 락 관련 작업을 진행해 성능 개선과 동시에 공부를 더 진행해보고 싶다!

현재 지금까지 시험 치룬 횟수 조회하는 API를 리팩토링 중이다.

토큰을 통해 로그인한 유저를 식별하고, 해당 유저가 치룬 시험 내역들을 조회하여 5회 이상인지, 미만인지에 따라 응답값을 다르게 반환한다.

이때 유저가 치룬 시험 내역을 조회하는 쿼리는 아래와 같다.

select distinct e.*
from exam e, member_exam me
where e.id = me.exam_id
  and me.member_id = :memberId
  and me.status = 'ACTIVE'

 

여기서 개선이 필요하다고 생각이 든 부분은,

1. distinct로 추가적인 중복 제거 연산

2. e.*로 불필요한 컬럼까지 조회

3. exam e, member_exam me와 같이 크로스 조인

 

해당 쿼리와 함께 리팩토링이 필요한 부분을 수정하여 개선 작업을 진행해보겠다.

 

 

현재 비즈니스 로직은 아래와 같다.

로그인한 유저의 Id로 현재 치룬 시험 내역 횟수를 조회한다.

getTakenExams에서 List<Exam>을 반환한 후 ExamConverter를 통해 exams.size()를 최종적으로 반환한다.

같은 팀원분께서 getTakenExams() 메서드를 추후 마이페이지나 시험 뷰에서 재사용할 가능성이 있기에 Exam List로 반환하는 서비스 함수를 제작했다고 하셨다.

 

 

API 테스트시 2469ms 소요되는 것을 확인할 수 있다. 

 

위 사진은 기존 쿼리의 실행 계획이다.

exam, member_exam을 join한 후 distinct를 사용했다. 

distinct는 키워드 하나만으로 간단하게 중복을 제거할 수 있다. 그러나 temp tablespace에 임시로 저장하고 작업하기에 시스템에 부하를 줄 수 있다.

그리고 추후 관리시 데이터를 확실히 알 수 없는 상황이기에 빼지도 못하고, 그대로 두면 효율이 떨어진다. 

 

 

 

우선 쿼리를 실행했을 때 517ms가 소요된다.

해당 쿼리에서 DISTINCT 대신 서브쿼리로 exists (select 1 ~ ..)와 같은 semi join을 사용할 것이다.

 

SELECT e.*
FROM exam e
WHERE EXISTS (
    SELECT 1
    FROM member_exam me
    WHERE e.id = me.exam_id
      AND me.member_id = :memberId
      AND me.status = 'ACTIVE'
);

 

수정한 쿼리 실행 결과 346ms가 소요되었다.

약 200ms가 줄어들었다!

 

 

티쳐포보스 프로젝트 개발 중 '같은 업종 사장님 평균 점수 조회' API를 맡게 되었다.

1. 시험을 치룬 유저의 내역 중 특정 기간에 해당되는 유저의 시험 점수 평균을 낸다.

2. 시험을 치룬 유저가 아닌 다른 유저들의 특정 기간에 해당되는 시험 점수 평균을 낸다.

 

현재 1번의 조회 쿼리문은 아래와 같다.

네이티브 쿼리를 활용하여 유저의 시험 내역 (member_exam) 중 점수값만 추려 평균값을 반환한다.

@Query(value = "select round(avg(me.score)) from member_exam me " +
        "where me.member_id = :memberId " +
        "and month(me.created_at) between :first and :last " +
        "and me.status = 'ACTIVE'", nativeQuery = true)
Optional<Integer> getAverageByMemberId(@Param("memberId") Long memberId, @Param("first") int first, @Param("last") int last);

 

2번의 조회 쿼리문은 아래와 같다.

1번 쿼리문과의 차이점은 member_id가 아닌 유저의 id값들의 점수값만 추려 평균값을 반환한다.

@Query(value = "select round(avg(me.score)) from member_exam me " +
        "where me.member_id <> :memberId " +
        "and month(me.created_at) between :first and :last " +
        "and me.status = 'ACTIVE'", nativeQuery = true)
Optional<Integer> getAverageByMemberIdNot(@Param("memberId") Long memberId, @Param("first") int first, @Param("last") int last);

 

 

쿼리를 짜면서 드는 생각이,

네이티브 쿼리로 바로 평균값을 도출해서 반환하는 게 성능상 괜찮을까?

혹은 바로 점수값을 계산하지 않고, member_exam 리스트만 반환하여 비즈니스 로직에서 평균값을 계산하는게 더 빠를까?

혹은 저 쿼리문보다 더 개선된 쿼리문으로 리팩토링할 수 없을까?

하여 Jmeter를 활용해 성능 부하를 테스트해보기로 하였다!

 

우선 하단 링크에 접속하여 Jmeter를 설치해주고,

https://jmeter.apache.org/download_jmeter.cgi

 

Apache JMeter - Download Apache JMeter

Download Apache JMeter We recommend you use a mirror to download our release builds, but you must verify the integrity of the downloaded files using signatures downloaded from our main distribution directories. Recent releases (48 hours) may not yet be ava

jmeter.apache.org

 

 

 윈도우 기준 apache-jmeter-5.6.3.tgz 를 설치해준 뒤, bin 폴더의 jmeter.bat 파일을 실행해준다.

 

프로그램을 실행시킨 뒤, test Plan에서 add > Threads > Thread Group으로 부하를 만들 스레드 그룹을 생성해준다.

 

총 100명의 유저가 1초안에 반복적으로 5번 API 요청을 보내도록 조건을 수정했다!

 

부하를 일으킬 HTTP Request를 작성해주고 테스트해본 결과 평균 3363ms이 소요된다.

차례대로 조건과 쿼리문을 수정해가며 성능을 개선해보자!

 

 

+) SQL 프로시저를 활용하여 더미데이터 약 5천건을 생성했다.

 

더미데이터로 부하 테스트를 한 결과, 2288ms가 소요되었다.

 

0. 쿼리 실행 계획

우선 성능을 개선하고자 하는 쿼리 실행 계획을 살펴보자.

상단의 실행 계획은 사용자 평균 점수 조회 쿼리문이다.

 

상단의 실행 계획은 사용자 외 같은 업종 사장님들의 평균 점수 조회 쿼리문이다.

 

1. 객체만 조회하여 비즈니스 로직에서 점수 계산하기

이 방법을 먼저 고민한 이유는, JPQL을 통해 '점수'만, 혹은 조건에 해당하는 MemberExam 객체 리스트들만 조회하는 메서드를 생성하여 추후 재활용성을 위해 고려하였다.

우선 Repository 메서드를 아래와 같이 생성한다.

(기존에 네이티브 쿼리로 작성하였으므로, 동일하게 네이티브 쿼리로 작성한다!)  

@Query(value = "select score from member_exam me " +
        "where me.member_id = :memberId " +
        "and month(me.created_at) between :first and :last " +
        "and me.status = 'ACTIVE'", nativeQuery = true)
Optional<List<Integer>> findMemberExamsByMemberId(@Param("memberId") Long memberId, @Param("first") int first, @Param("last") int last);
@Query(value = "select score from member_exam me " +
        "where me.member_id <> :memberId " +
        "and month(me.created_at) between :first and :last " +
        "and me.status = 'ACTIVE'", nativeQuery = true)
Optional<List<Integer>> findMemberExamsByMemberIdNot(@Param("memberId") Long memberId, @Param("first") int first, @Param("last") int last);

해당 쿼리로 생성하는경우 MemberExam 객체 (score) 리스트를 반환한다.

 

비즈니스 로직도 다음과 같이 변경했다.

 

Jmeter로 성능 부하 테스트를 한 결과, 3485ms가 소요되었다.

MemberExam 객체 리스트, 혹은 MemberExam (score) 객체 리스트만 반환하는 repository 메서드를 생성하여 재사용이 가능하게끔 나중에 리팩토링하면 좋을 거 같다~ 라고 팀원들과 얘기했었는데, 테스트를 통해 결과를 확인하니 그러지 않아도 될 거 같다 ㅎㅎ..

데이터가 많이 쌓이지 않아 엄청난 차이는 나지 않았지만, 누적된 데이터에 따라 해당 쿼리를 호출한다면 크게 영향을 미칠 것이다.

해당 메서드가 필요하다면 따로 나중에 추가하기로 ..!

 

 

2. @QueryHints 추가하기

Repository 메서드에 읽기 전용 쿼리 힌트를 적용하는 것이다.

JPA는 데이터베이스 SQL 힌트 기능을 제공하지 않고, 하이버네이트가 제공한다.

그렇기에 읽기 전용 쿼리 힌트를 추가해서 사용한다면 스냅샷을 보관하지 않고 메모리상의 이점을 얻을 수 있다.

기존 메서드에 아래 힌트를 추가로 달아주었다.

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))

 

그렇게 테스트를 해본 결과..

 

기존 쿼리보다 200ms가 줄어들었다!

 

+) 더미데이터 생성 결과 2197ms로, 기존 쿼리보다 약 100ms 줄어들었다.

 

3. 날짜 조건 비교 Between에서 부등호 비교로

2번에 이어서 진행하면, 기존 쿼리에서 month(me.created_at) between :first and :last 조건으로 날짜별 시험 분기처리를 진행한다.

이때 Between이 아닌 부등호 비교를 하면, 부등호 조건으로 스캔 시작점이 달라지기에 스캔 범위가 줄어들 수 있다.

CPU Cycle을 적게 소모할 수 있기에, 쿼리문을 수정하고 다시 부하 테스트를 진행했다.

 

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
@Query(value = "select round(avg(me.score)) from member_exam me " +
        "where me.member_id = :memberId " +
        "and month(me.created_at) >= :first and month(me.created_at) <= :last " +
        "and me.status = 'ACTIVE'", nativeQuery = true)
Optional<Integer> getAverageByMemberId(@Param("memberId") Long memberId, @Param("first") int first, @Param("last") int last);

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
@Query(value = "select round(avg(me.score)) from member_exam me " +
        "where me.member_id <> :memberId " +
        "and month(me.created_at) >= :first and month(me.created_at) <= :last " +
        "and me.status = 'ACTIVE'", nativeQuery = true)
Optional<Integer> getAverageByMemberIdNot(@Param("memberId") Long memberId, @Param("first") int first, @Param("last") int last);

   

쿼리힌트를 적용한 쿼리문 호출했을 때보다 약 100ms 가량 줄어들었다!

현재 DB에 많은 양의 데이터가 들어 있지 않아 엄청난 큰 차이는 아니지만, 이는 추후 더미 데이터가 쌓일 때 유의미하게 작용할 거라 생각한다.

 

+) 더미데이터 생성 결과, 2005ms가 소요되었다.

기존 쿼리보다 약 200ms 가량 감소되었다!

 

4. NOT, != 와 같은 부정 조건 대체

3번 쿼리 중 사용자 외 같은 업종 종사하는 사장님들의 평균 점수를 조회하는 쿼리에서, where me.member_id <> :memberId 와 같이 부정연산자 <>를 쓴 걸 확인할 수 있다.

부정 연산자의 경우 해당 조건 외 나머지 인덱스에 해당하는 것을 찾는 것이기에, 지정된 대상 외 나머지라는 것 자체를 알기 위해서는 전체를 읽어야만 알 수 있다.

그렇기에 부정 조건을 대체하고 left outer join을 통해 인덱스를 추가하여, 새로 추가된 memberId 컬럼이 null인 경우만 조회하는 쿼리로 변경해보았다.

 

select round(avg(me.score)) from member_exam me
left outer join (select member_id from member_exam where member_id = :memberId) m on me.member_id = m.member_id
where m.member_id is null
and month(me.created_at) >= :first and month(me.created_at) <= :last
and me.status = 'ACTIVE'

select 서브 쿼리를 추가했고, left outer join을 통해 m.member_id is null 인 조건만 조회하기!

 

성능 부하 테스트를 해본 결과, 3번에 이어 500ms 가 줄어들었다.

리팩토링 전 시간에 비하면 약 1.1초가 줄어든 결과이다.

적은 데이터이지만 확실히 속도를 단축시킬 수 있어 뿌듯하다 ㅎㅅㅎ

추후 DB에 프로시저로 더미데이터를 생성해서, 더 확실한 성능 개선 결과값을 측정해 업데이트해야겠다!

 

+) 더미 데이터 테스트 결과

최종 리팩토링한 쿼리 결과 14104ms가 소요되었다.

이전에 갖고 있던 데이터로 테스트했을 때는 <>보다 left outer join으로 속도를 빠르게 개선시킬 수 있을 줄 알았는데, 더미데이터 바탕으로 테스트해보니 이전보다 몇 배는 더 오래 걸렸다...!

부정 조건으로 나머지 조건들을 다 탐색해야해서 시간이 더 걸릴 줄 알았는데, 오히려 추가적인 join 과정이 불필요하게 시간을 더 늦춘 거 같다.

 

실제 데이터를 활용해 결과를 확인함으로써 쿼리 로직 한 줄의 소중함을 몸소 깨달을 수 있었고,

이론이 맞다고 해서 실제 탐색 결과가 항상 동일하지 않다는 걸 알 수 있었다.

쿼리를 이리 저리 수정하고 테스트해보며 더 나은 성능을 도출할 수 있도록 노력해야겠다 ~!

 

오류 내용

프로젝트 실행시 다음과 같은 에러가 발생했다.

이는 한 필드에 하나의 값이 할당되어야 하는데, 해당 타입이 여러개라 의존성 주입이 제대로 되지 않아 발생한 에러이다.

 

 

발생 원인

에러가 난 부분을 보면,

 

현재 Controller 내에서 Service 빈이 제대로 주입되지 않는다.

바보 같이 구현체(TempQueryServiceImpl)를 가지고 와야하는데 인터페이스(TempQueryService)를 적어버렸다 .. ^^

TempQueryService를 상속한 TempQueryServiceImpl를 적지 않고 TempQueryService를 적으니, 빨간 줄이 그어졌는데 인텔리제이에서 @Qualifier 어노테이션을 적용해서 수정하라고 띄워준다.

 

해결방법

나처럼 오탈자로 간단하게 해결하는 경우 외에, 인터페이스 및 구현체로 인하여 제대로 된 의존성 주입이 되지 않을 때의 해결법이다. 대표적으로 @Qualifier, @Primary 어노테이션을 통해 클래스를 지정할 수 있다. 

 

@Qualifier 어노테이션의 경우 인터페이스를 상속한 클래스가 여러 개일 때 해당 어노테이션에 클래스명을 기입하여 멤버변수에 의존성을 주입해준다.

@Qualifier("클래스명")
private MyInterface myInterface;

 

 

혹은 생성자 인자에 의존성을 주입할 수도 있다.

private MyInterface myInterface;

@Autowired
public 생성자(@Qualifier("클래스명") MyInterface myInterface) {
    this.myInterface = myInterface;
}

 

정리내용

오타로 발생한 이슈,, 였지만 인터페이스 & 구현체를 바탕으로 MVC 모델 구축하는 것이 아직 어색하다. 많이 활용하면서 객체지향의 참맛을 익혀야쥐

 

build.gradle 파일에 queryDsl을 세팅하기 위해 의존성을 추가 후 compileQuerydsl을 실행했더니 다음과 같은 에러가 발생했다.

 

영한님의 Querydsl 강의를 다시 찾아보니 강의 자료에 "스프링 부트 2.6 이상, Querydsl 5.0 지원 방법"에 대한 내용이 추가 되어있었다.

 

buildscript {
   ext {
      queryDslVersion = "5.0.0"
   }
}

plugins {
   id 'org.springframework.boot' version '2.6.8'
   id 'io.spring.dependency-management' version '1.0.11.RELEASE'
   // querydsl 추가
   id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
   id 'java'
}

group = 'com.wangtak'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
   compileOnly {
      extendsFrom annotationProcessor
   }
}

repositories {
   mavenCentral()
}

dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
   implementation 'org.springframework.boot:spring-boot-starter-web'

   //querydsl 추가
   implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
   annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}"

   compileOnly 'org.projectlombok:lombok'
   developmentOnly 'org.springframework.boot:spring-boot-devtools'
   annotationProcessor 'org.projectlombok:lombok'
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
   useJUnitPlatform()
}

// querydsl 세팅 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
   jpa = true
   querydslSourcesDir = querydslDir
}
sourceSets {
   main.java.srcDir querydslDir
}
configurations {
   querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
   options.annotationProcessorPath = configurations.querydsl
}
// querydsl 세팅 끝

위 처럼 querydsl의 버전 명시와 querydsl-jpa, querydsl-apt를 추가해준다.

오류 내용

Quizeloper 좋아요 기능을 만드는 중, 제목과 같은 에러가 발생했다.

엔티티매니저가 없어 삭제 요청을 수행하지 못한다니..!

 

에러가 발생한 Service 코드는 다음과 같다.

 

public void postQuizLikes(Long userId, Long quizId){
        User user = userRepository.findByIdAndStatus(userId, ACTIVE).orElseThrow(() -> new BaseException(BaseResponseStatus.USER_NOT_FOUND));
        Quiz quiz = quizRepository.findByIdAndStatus(quizId, ACTIVE).orElseThrow(() -> new BaseException(BaseResponseStatus.QUIZ_NOT_FOUND));
        if (quizLikeRepository.existsIdByUserIdAndQuizId(user.getId(), quizId)) {
            quizLikeRepository.deleteByUserIdAndQuizId(userId, quizId);
        }
        else {
            QuizLike quizLike = QuizLike.builder().user(user).quiz(quiz).build();
            quizLikeRepository.save(quizLike);
        }

    }

유저와 퀴즈를 조회한 후, 좋아요 DB에 해당 유저와 퀴즈가 존재하는지에 따라 좋아요, 또는 좋아요 취소를 할 수 있다.

quizLikeRepository.deleteByUserIdAndQuizId(userId, quizId); 부분에서 에러가 발생했다.

 

 

발생 원인

확인해보니 삭제 메서드 수행시 삭제가 제대로 되지 않은 것이다.

 

DeleteBy_Id 동작 방식

1. EntityManager opened
2. SELECT 쿼리 생성
3. EntityManager closed
4. DELETE 쿼리 예외 발생

 

기본적으로 JPA는 트랜잭션을 기반으로 작동하게 되어 있는데, 트랜잭션 단위에 따라 1차 캐시 영역에 있는 객체들이 db에 flush되어 영속화 되기 때문이다.

 

영속작업을 하는 persist() 메소드에 객체가 들어갔으나 가능한 트랜잭션이 존재하지 않았기에 본 에러가 발생한 것이다.

 

 

해결 방법

서비스 혹은 클래스 위에 @Transactional을 선언해둠으로써 해결할 수 있었다.

 

그럼 지금까지의 Service 메서드들 중 @Transactional을 선언하지 않고도 어떻게 외부 호출이 가능했을까?

바로 JpaRepository의 구현체인 SimpleJpaRepository에서 이미 메서드에 @Transactional을 붙여 놓아서이다.

 

그래서 JpaRepository에 미리 선언된 메서드를 호출할 경우에, 서비스 클래스에서 @Transactional을 붙이지 않아도 문제가 없었던 것이다. 따라서 레포지토리 클래스에 추가로 정의한 메서드에 @Transactional을 붙여줘도 문제를 해결할 수 있다.

 

정리 내용

  • @Transactional 어노테이션을 통해 스프링에서 트랜잭션 관리를 단순화할 수 있다.
    • 데이터 일관성과 에러 처리가 간편하게 관리된다.
  • 트랜잭션은 어노테이션 기반 AOP를 통해 구현되어 @Transactional이 선언되면 해당 클래스에 트랜잭션이 적용된 프록시 객체가 생성된다.
  • 프록시 객체는 @Transactional이 포함된 메서드가 호출될 경우 트랜잭션을 시작하고 Commit이나 Rollback을 수행한다.
  • CheckedException이나 예외가 없을 때 Commit, UncheckedException이 발생하면 Rollback된다.
    • 디버깅을 통해 Commit, Rollback 과정을 바탕으로 예외 처리를 진행해야겠다.
  • JPA에서 단일 작업에 대해서는 @Transactional을 직접 선언할 필요가 없다.
    • 여러 작업을 하나의 단위로 묶어 Commit이나 Rollback 처리가 필요할 때 직접 선언한다!

 

오류 내용

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain

 

이 에러는 Jackson 라이브러리가 객체를 JSON으로 직렬화하는 과정에서 발생하였다.

직렬화란 Object를 연속된 String 데이터나 연속된 Bytes 데이터로 바꾸는 것을 의미한다.

에러 메시지에서 나오는 내용을 해석하자면, Jackson이 org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor 클래스를 직렬화할 수 있는 방법을 찾지 못했다는 것이다.

 

발생 원인

문제 원인 코드 QuizService

public GetPagedQuizRes getQuizList(int size, int page) {
        List<Quiz> quizList= quizRepository.findAllByStatus(ACTIVE);
        List<Long> quizLikes = quizLikeRepository.findAllByUserAndStatus(1L, ACTIVE);

        if(page > quizList.size()/size){
            throw new BaseException(PAGE_COUNT_OVER);
        }

        PageRequest pageRequest = PageRequest.of(page, size);
        int start = (int) pageRequest.getOffset();
        int end = Math.min((start + pageRequest.getPageSize()), quizList.size());

        Page<Quiz> pagingQuiz = new PageImpl<>(quizList.subList(start, end), pageRequest, quizList.size());

        int total = quizList.size();

        List<GetQuizRes> quizResList = pagingQuiz.getContent().stream()
                .map(quiz -> new GetQuizRes(quiz.getTitle(), quiz.getType(), quiz.getStackUnit(), quiz.getQuizUnit(),
                        checkUserLikesQuiz(quizLikes, quiz)))
                .collect(Collectors.toList());

        return new GetPagedQuizRes(quizResList, total, page);
    }

이 문제는 일반적으로 Hibernate의 지연 로딩(Lazy Loading) 기능 때문에 발생한다. Hibernate는 프록시(proxy)를 사용하여 지연 로딩을 구현하는데, 이로 인해 직렬화할 때 문제가 발생할 수 있다. 

 

 

나의 경우 해당 코드에서 에러가 발생했는데, 두번째 line에서 quiz.getQuizUnit() 부분에서 에러가 났다.

Quiz 엔티티와 관계된 QuizUnit(ManyToOne)이 FetchType.LAZY로 설정되어 있다.

Jackson으로 Quiz 엔티티를 Serialize를 할때, LAZY 설정으로 비어있는 객체(QuizUnit)를 Serialize 하려고 해서 발생되는 문제이다.

 

 

해결 방법

1. DTO로 바꾸어 사용할 데이터만 반환해 사용

DTO에 엔티티를 직접 담기보다 사용할 데이터만 조회할 수 있도록 한다.

디버깅을 돌려봤을 때 QuizUnit의 프록시 객체를 조회하는 것을 확인할 수 있다.

 

중간객체를 설정하여 중간객체의 구체적인 값을 불러오고 DTO에 담아주는 식으로 로직을 수정하니 해결되었다.

 

 

2. 오류가 발생하는 컬럼에 @JsonIgnore 설정

해당 필드를 Json으로 변경시 제외된다.

Json의 응답결과에 @JsonIgnore이 선언되어 있는 객체가 빠지게 되어 오류를 해결할 수 있다.

그러나 선언한 엔티티의 값도 포함하고 싶은 경우 Json 응답 결과가 빠져버리게 되어 알 수 없다.

이런 경우 서비스 내 응답 결과로 해당 엔티티가 필요 없는 경우의 확장성까지 생각해 선언하는 것이 좋아보인다.

 

3. LAZY를 EAGER로 변경

하지만 이 방법은 N+1 문제를 발생시킬 수 있어 좋은 방법은 아닌 거 같다.

 

4. application 파일에 spring.jackson.serialization.fail-on-empty-beans=false 설정

오류만 안나오도록 하는거라 어떤 문제가 있을지는 모른다.

 

5. 각 Entity쪽에 @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) 설정

 

정리 내용

  • 가급적 LAZY 로딩 전략을 사용하고, 엔티티를 엮어서 함께 사용해야한다면 JPQL의 fetch join을 통해 한방 쿼리로 가져와 사용하자.
  • 스프링부트는 @ResponseBody를 선언할 때 Object를 json으로 변환하기 위해 Jackson 라이브러리를 사용한다.
  • Jackson 순환 참조 에러는 @OneToMany, @ManyToOne에서 반복 참조로 인하여 직렬화를 이용해 json 형태로 객체를 변환시킬 때 발생한다.

Quizeloper 엔티티 추가 수정사항이 있어 수정하고 다시 빌드했더니 다음과 같은 에러가 발생했다.

 

오류 내용

Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Collection 'com.cs.quizeloper.quiz.entity.Quiz.quizUnitList' is 'mappedBy' a property named 'quiz_id' which does not exist in the target entity 'com.cs.quizeloper.quiz.entity.QuizUnitList'

 

프로젝트 실행할 때 자동으로 해당 class의 이름을 가진 DB 테이블을 생성하기 위해 JPA(Hibernate)를 사용했다.

이 에러는 entityManagerFactory가 빈 생성 중에 오류가 발생한 것으로, 어떤 컬렉션 필드가 문제가 있는 것으로 보인다.
해당 컬렉션은 'mappedBy' 속성이 사용되었는데, 이 속성의 값인 'id'라는 이름의 속성이 연관된 엔티티 클래스에 존재하지 않는다는 뜻이다.

 

 

발생 원인

문제의 엔티티는 Quiz, QuizUnit으로 Quiz-[One-Many]-QuizUnitList의 관계이다.

Quiz 엔티티는 퀴즈 테이블, QuizUnitList 엔티티는 퀴즈 문제 유형들을 담는 엔티티이다.

테이블 연관관계 매핑시 @OneToMany 어노테이션에 mappedBy 속성을 추가하였다.

 

QuizUnitList 엔티티
Quiz 엔티티

 

QuizUnitList 엔티티에서 Quiz 엔티티를 참조할 때 작성한 필드명은 quiz인데 mappedBy 속성에 잘못하고 quiz_id로 잘못입력해 오류가 발생하였다.

 

 

해결 방법

수정 후 Quiz 엔티티

quiz_id에서 quiz로 고쳐주니 오류없이 잘 실행되었다! 

 

 

 

정리 내용

  • DB에서는 FK를 통해 맺는 연관관계를 객체로 표현할 때는 연관관계 주인과 mappedBy로 나타내야 한다.
  • 다대일 관계에서 다가 되는 쪽이 연관관계 주인이 된다.
  • 연관관계 주인이 되는 부분은 Join할 컬럼을 나타내야 한다.
    • @ManyToOne 어노테이션을 통해 연관관계를 나타내고 @JoinColumn(name = "quiz_id")을 통해 어떤 컬럼(필드명)과 조인을 하는지 나타내면 된다.
  • mappedBy는 연관관계를 맺으며 주인이 되는 객체의 필드명을 적어준다.

이번 포스팅에서는 GoF 디자인 패턴 중 하나인 옵저버 패턴에 대해 정리할 것이다.

예제를 직접 작성해보고 패턴의 로직을 익혀볼 것이다!

 

옵저버 패턴이란?

출처 : https://velog.io/@weekbelt/%EC%98%B5%EC%A0%80%EB%B2%84-Observer-%ED%8C%A8%ED%84%B4

소프트웨어 디자인 패턴 중 하나로, 객체 간의 일대다(one-to-many) 의존 관계를 정의하는 패턴이다.

이 패턴은 어떤 객체의 상태 변화가 발생했을 때 그 객체에 의존하는 다른 객체들에게 자동으로 알림을 보내고 상태 변화에 대한 처리를 할 수 있도록 해준다. 이는 객체 간의 결합도를 낮추고 유연한 구조를 만들어준다.

주로 분산 이벤트 핸들링 시스템을 구현하는 데 사용되어, Pub/Sub (발행/구독) 모델로도 알려져 있다.

ex) 발행자(Subject): 유튜브 채널, 관찰자(Observer): 구독자들

 

주요 요소

1. Subject (주체) 

  • 상태 변화를 감지하고, 옵저버들을 관리하는 객체이다.
  • 상태 변화가 발생하면 등록된 옵저버들에게 알림을 보낸다.

2. Observer (옵저버)

  • Subject의 상태 변화를 감지하고, 상태 변화에 대한 처리를 수행하는 인터페이스이다.

3. ConcreteSubject (구체적인 주체)

  • 실제 상태 변화가 일어나는 객체로서 Subject를 구현한 클래스이다.

4. ConcreteObserver (구체적인 옵저버)

  • 실제로 상태 변화를 감지하고 처리하는 객체로서 Observer를 구현한 클래스이다.

 

  • 옵저버 패턴에서는 한개의 관찰 대상자(Subject)와 여러개의 관찰자(Observer A, B, C)로 일 대 다 관계로 구성되어 있다.
  • 관찰 대상자(Subject)의 상태가 바뀌면 변경사항을 옵저버 한태 통보해준다. (notify)
  • 대상자로부터 통보를 받은 관찰자(Observer)는 값을 바꾸거나 삭제하는 등 적절히 대응한다. (update)
  • 관찰자(Observer)들은 언제든 관찰 대상자(Subject)의 그룹에서 추가/삭제 될 수 있다.

 

 

옵저버 패턴 예제

이번 옵저버 패턴 예시로 쓸 소재는 온라인 주문 알림 시스템이다. 온라인 쇼핑몰에서 주문이 들어왔을 때, 옵저버 패턴을 활용해 알림 시스템을 만들어볼 것이다.

 

 

관찰 대상자(Subject): 주문 클래스

import java.util.ArrayList;
import java.util.List;

public class Order {
    private List<Observer> observers = new ArrayList<>();
    private String status;

    public void attach(Observer observer) {
        observers.add(observer);
    }

    public void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(this);
        }
    }

    public void setStatus(String status) {
        this.status = status;
        notifyObservers();
    }

    public String getStatus() {
        return status;
    }
}

 

관찰자(Observer) 인터페이스

public interface Observer {
    void update(Order order);
}

 

구체적인 관찰 대상자(ConcreteSubect): 주문 상태 변경

public class OrderNotification implements Observer {
    @Override
    public void update(Order order) {
        System.out.println("주문 상태 업데이트: " + order.getStatus());
    }
}

 

구체적인 관찰자(ConcreteObserver): 옵저버 등록 및 주문 상태 변화에 대한 처리

public class Main {
    public static void main(String[] args) {
        Order order = new Order();
        OrderNotification notification = new OrderNotification();

        order.attach(notification);

        order.setStatus("배송 중");
        order.setStatus("배송 완료");
    }
}

Order 객체와 OrderNotification 객체를 각각 생성한 후, 주문 상태의 변화에 따라 주문 알림이 옵저버에게 전달되고 출력된다.

order.attach(notification)과 같이 Order 클래스의 attach 메서드를 통하여 여러개의 관찰자 리스트에 추가한다.

관찰자 리스트에 포함된 객체들에 상태 변경 알림 setStatus를 통해 상태 변화를 전달하고 출력할 수 있다.

 

이로써 개념과 예제를 통해 옵저버 패턴에 대해 알아보았다.

객체의 느슨한 결합을 유지하면서 상태 변화를 처리할 수 있다는 점에서 확장 가능한 코드를 작성할 수 있었다.

주로 이벤트 핸들링이나 MVC 아키텍쳐에서 사용된다고 하니, 패턴 활용해서 나중에 코드 작성해보려고 한다!

'java > Design Pattern' 카테고리의 다른 글

전략 패턴 (Strategy Pattern)  (0) 2023.08.01

이번 Quizeloper 프로젝트를 진행하면서 CICD는 내가 담당하게 되었다!

구축하면서 진행한 과정 및 공부한 내용을 기록해보려고 한다.

yaml 보안파일 함께 올리고 ec2 용량 늘리기 등 ,, 고군분투 끝에 성공했다 허허 

 

구현 방식

출처 :&nbsp;https://velog.io/@haeny01/AWS-Jenkins%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-Docker-x-SpringBoot-CICD-%EA%B5%AC%EC%B6%95

구성 요소

  • Jenkins Server : AWS EC2 Ubuntu 20.04
  • Spring Boot Server : AWS EC2 Ubuntu 20.04
  • Github Repository
  • Docker Hub Repository
  • SpringBoot : 3.1.2
  • Java : OpenJDK 17

 

진행 순서

  1. Jenkins Server에 Docker 설치
  2. Jenkins Server에 Docker를 이용하여 Jenkins 실행
  3. Jenkins 접속
  4. Jenkins와 Github 연동
  5. Jenkins와 Docker Hub 연결
  6. Jenkins Server와 Spring Boot Server SSH 연결 설정
  7. Jenkins Pipeline 구성
    • Spring Boot Project Github Repository Clone
    • application-{secret}.yaml 파일 추가
    • Gradle Build
    • Docker Build
    • Docker Push
    • Spring Boot Server SSH 연결
      • Docker Pull
      • Docker Run

 

 

EC2 Jenkins Server 구축

 

🪴EC2 초기 설정

sudo apt-get update && sudo apt-get upgrade
sudo apt install build-essential

 

🪴Docker 설치

1. 기본 설정 & 사전 설치

sudo apt update
sudo apt install apt-transport-https ca-certificates curl software-properties-common

 

2. 자동 설치 스크립트 활용

리눅스 배포판 종류를 자동으로 인식하여 Docker 패키지를 설치해주는 스크립트를 제공

sudo wget -q0- https://get.docker.com/ | sh

 

3. Docker 서비스 실행하기 및 부팅 시 자동 실행 설정

sudo systemctl start docker
sudo systemctl enable docker

 

4. Docker 그룹에 현재 계정 추가

sudo usermod -aG docker ${USER}
sudo systemctl restart docker
  • docker 그룹은 root 권한과 동일하므로 꼭 필요한 계정만 포함
  • 현재 실행 중인 계정에서 로그아웃한 뒤 다시 로그인하면 적용
  • Docker 명령어 작성할 때 sudo 없이 작성할 수 있어서 매우 편리하므로 사용하자!

 

5. Docker 설치 확인

docker -v

 

위와 같이 도커 설치된 것을 확인할 수 있다!

 

 

🪴Docker로 Jenkins 설치하기 

1.  Jenkins 이미지 파일 내려받기 (JDK 17)

docker pull jenkins/jenkins:jdk17

보통 lts 버전으로 내려받는데, 나는 jdk17버전을 사용했다.

스프링부트 버전이랑 자바 버전이 높은 편이라 lts를 사용하는 경우 젠킨스 gradle로 빌드하는 과정에서 오류가 생겼다.

자바 버전이 높은 경우 (17 이상), 혹은 빌드 단계에서 버전 관련 에러가 생기는 경우 jenkins 버전을 직접 명시에 다운 받길 권한다!

 

lts로 다운받았을 적 스크린샷 ㅎ

다운받으면 이런식으로 출력이 된다.

 

2.  내려받아진 이미지 확인

docker images

 

3. Jenkins 이미지를 Container로 실행

docker run -d -p 8080:8081 -p 50000:50000 -v /jenkins:/var/jenkins -v /home/ubuntu/.ssh:/root/.ssh -v /var/run/docker.sock:/var/run/docker.sock --name jenkins -u root jenkins/jenkins:jdk17
  • docker run -d : Docker 컨테이너를 백그라운드에서 실행하는 명령어
  • -p 8080:8081 : 호스트의 8080 포트와 컨테이너의 8081 포트를 연결하여 Jenkins 웹 인터페이스에 접근
  • -p 50000:50000: 호스트의 50000 포트와 컨테이너의 50000 포트를 연결하여 Jenkins 에이전트 노드와의 통신을 위해 사용
  • -v /jenkins:/var/jenkins: 호스트의 /jenkins 경로와 컨테이너의 /var/jenkins 경로를 볼륨으로 연결
    Jenkins 설정 및 데이터를 호스트에 저장
  • -v /home/ubuntu/.ssh:/root/.ssh: 호스트의 /home/ubuntu/.ssh 경로와 컨테이너의 /root/.ssh 경로를 볼륨으로 연결
    SSH 키와 관련된 데이터를 호스트와 공유
  • -v /var/run/docker.sock:/var/run/docker.sock: 호스트의 Docker 소켓과 컨테이너의 Docker 소켓을 연결하여 컨테이너 내부에서 호스트의 Docker 데몬 제어
  • --name jenkins: 컨테이너의 이름을 "jenkins"로 지정
  • jenkins/jenkins:jdk17: 실행할 Docker 이미지를 지정

나는 호스트랑 컨테이너 포트를 분리하기 위해 8080, 8081로 나누었고, OpenJDK17버전으로 이미지를 지정하였다! 

 

 

4. 돌아가고 있는 Container 확인

docker ps

 

🪴EC2 프리티어 용량 확장

프리티어 기준 EC2를 사용해 Jenkins를 빌드하는 경우 중간에 멈추거나 팅긴다 ..

CPU 사용량만 봐도 널뛰기마냥 날뛰기에, swap 메모리를 할당해서 이러한 문제를 방지하려고 한다!

프리티어 EC2 기본 RAM이 1GB이니, 2GB(128MB * 16) swap 파일을 만들어 할당한다.

 

sudo dd if=/dev/zero of=/swapfile bs=128M count=16
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
sudo swapon -s
sudo vi /etc/fstab
/swapfile swap swap defaults 0 0

 

이렇게 해도 용량이 부족한 경우, 이 블로그 글을 참고하길 바란다! 

빌드가 계속 안되길래 용량이 많이 부족한 줄 알고 참고해서 인스턴스 볼륨 크기를 16GiB까지 늘렸으나, 지금 보니 파이프라인 도커 실행 단계에서 명령어를 잘못 친 것 때문인 듯하다. 허허 그래도 마음 편하게 늘리면 좋으니 ..

참고로 EBS 용량은 30GiB가 최대라고 한다.

 

 

 

Jenkins 설정

 

🪴Jenkins 접속

1.  브라우저에서 [EC2 인스턴스 URL]:8081로 접속

참고로 나는 도커 실행할 때 호스트 포트를 8081로 열어주었다!

 

2. 암호 입력

접속하면 다음과 같은 화면이 뜬다.

초기 젠킨스 비밀번호를 확인해야하는데 Jenkins Container에 접속하해서 얻어오거나 docker logs jenkins 명령어를 사용하면 된다.

 

// 컨테이너에 접속하지 않고 확인
docker logs jenkins
// Container 접속해서 암호 파일 확인
docker ezezc -it jenkins bash
sudo cat /var/lib/jenkins/secrets/initialAdminPassword

 

3. Install suggested Plugins로 플러그인 설치

웬만하면 다 성공하지만 간혹가다 다 설치 실패로 뜨는 경우가 있는데, 이런 경우 젠킨스 서버와의 버전이 맞지 않아 생기는 오류일 가능성이 크다. 서버 버전과 맞추고 난 후 다시 다운받길 바란다!

 

 

4. Jenkins 계정 생성

계정명, 암호는 젠킨스 서버에 접속할 때 필요한 Id, Password이다.

 

로그인한 뒤 상단과 같이 Jenkins 메인 대시보드 페이지가 나오면 성공이다!

 

 

🪴Jenkins Plugin 설치

Docker, Jenkins로 스프링부트 프로젝트를 빌드하기 위해 ssh 관련 데이터 및 docker 정보를 추가해야한다.

또한 플러그인을 설치해서 연결하는 과정이 필요하다.

우선, 사용에 필요한 플러그인 리스트를 보고 다운받자.

 

 

1. Plugin 설치

Jenkins 대시보드 > Jenkins 관리 > Plugins > Available Plugins 접속

 

2. 설치 Plugin 리스트

Docker, Docker Pipeline, SSH Agent, Publish Over SSH, Github Integration을 차례로 다운받는다.

다운 받고 바로 시작할 수 있으면 그대로 진행하고, 재부팅이 필요할시 젠킨스 컨테이너 중지하고 재시작을 한다.

 

 

🪴Jenkins & Github 연동

Jenkins Container를 생성할 때 "/home/ubuntu/.ssh:/root/.ssh"로 .ssh 디텍도리를 마운트 해놓았기 때문에 Container 밖에서 ssh 키를 생성하면 Jenkins Container와 연결된다.

 

1. SSH 키 생성

ssh-keygen

그냥 전부 enter를 입력해 default로 만든다.

 

다음과 같은 Key print 이미지가 생성되면 성공이다.

 

2. Github Deploy Key 등록

연동할 Github Repository의 Settings > Deploy Keys로 접속한다.

 

제목은 자유롭게 적어주고 Key 부분에 id_rsa.pub에 들어있는 public key 값을 넣어준다.

하단의 명령어로 id_rsa.pub을 확인할 수 있다.

 

cd /home/ubuntu/.ssh
cat id_rsa.pub

 

 

3. Jenkins Credentials 등록

Jenkins 대시보드 > Jenkins 관리 > Credentials에 접속한다.

Stores scoped to Jenkins의 Domains가 (global)이라 적혀있는 부분을 누른다.

 

Store Jenkins에 Domain이 (global)인 화살표를 눌러 Global credentials (unrestricted)로 이동한다.
왼쪽 메뉴의 Add credentials를 눌러 credentials를 추가한다.

 

 

  • Kind
    SSH Username with private Key
  • ID
    github → 마음대로 지어도 된다. 그러나 Pipeline Script 작성시 기입해야하기에 식별할 수 있는 ID로 작성하자.
  • Username
    root (Default)
  • Private Key
    Enter directly 체크 → private key 입력
    private key는 Jenkins Server에서 생성된 id_rsa로, 아래 명령어로 확인할 수 있다.
cd /home/ubuntu/.ssh
cat id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
...
...
...
-----END OPENSSH PRIVATE KEY-----

 

 

🪴Jenkins & Docker Hub 연동

1. Docker Hub Credentials 등록

첫 부분에서 Docker 관련 Plugin을 설치했다.

Github ssh key 등록한 것과 마찬가지로 Docker Hub Credentials를 등록한다.

Jenkins 대시보드 > Jenkins 관리 > Credentials에 접속한 뒤 Add Credentials를 눌러 credentials를 추가한다.

 

  • Kind
    Username with password
  • Username
    본인의 Docker Hub ID
  • Password
    본인의 Docker Hub Password
  • ID
    docker-hub → 마음대로 지어도 된다. 그러나 Pipeline Script 작성시 기입해야하기에 식별할 수 있는 ID로 작성하자.

 

2. Jenkins Container 내부에 Docker 설치

Jenkins Pipline에서 Docker 명령어를 사용할 수 있도록 Jenkins Container 내부에 Docker를 설치해야 한다.

EC2에서 Jenkins Container에 접속한다.

 docker exec -it jenkins /bin/bash
apt-get update
apt-get install docker.io

 

 

🪴Jenkins & SSH  연동

지금까지는 Jenkins Server에서 jenkins 컨테이너에 접속하지 않고 key들을 접근하여 Jenkins Server의 key 경로를 모른다. 즉 /home/ubuntu/.ssh/id_rsa를 입력하면 안된다.

그러나 Jenkins Server에서 컨테이너를 실행할 때 /home/ubuntu/.ssh를 /root/.ssh와 연결해 놓았기 때문에  Jenkins 서버 내 /root/.ssh/id_rsa를 입력하면 된다.

이를 위해 Jenkins 상에서 Publish Over SSH 플러그인을 미리 설치해둔 상태이다.

 

Publish Over SSH 플러그인 설정

Jenkins 대시보드 > Jenkins 관리 > 시스템 설정에서 Publish Over SSH 영역의 고급 버튼을 눌러 설정

  • Path to Key
    Private key의 경로를 입력한다. → /root/.ssh/id_rsa
  • Key
    id_rsa 파일 내용 복사해서 붙여넣기
  • Name
    접속할 ssh 서버의 이름 입력
  • Hostname
    접속할 서버의 주소 입력

    Spring Boot Server EC2 인스턴스 URL을 넣어준다.
  • Username
    접속할 유저명으로,
    기본 유저인 ubuntu를 입력한다
  • Test Configuration 버튼을 눌러 정상적으로 연결 되는지 확인한다!

 

 

🪴Application-{secret}.yaml 추가

보통 스프링부트 프로젝트를 작업할 때 보안 관련 파일 (application-{secret}.yaml)을 ignore로 처리하는 경우가 많을 것이다.

이런 경우 젠킨스에서 빌드할 때 직접 파일을 추가해주지 않으면 인식하지 못해 build에 실패한다.

application-secret.yaml 파일을 build할 때 넣어줄 수 있도록 pipeline script에 쓰일 식별자를 만들 것이다.

 

Jenkins 관리 > Credentials > System > Global Credentials에 add Credentials 버튼을 누른다.

  • Kind
    Secret file
  • File
    application-{secret}.yaml에 해당하는 파일을 올려준다.
  • ID
    pipeline script에서 식별될 식별자

생성해주면 끝!

참고로 나는 Build할 때 Test 관련 DB를 따로 설정해주지 않아 Test 단계에서 Build가 실패했었다.

아직 프로젝트 개발 시작 전이고 Test 작업을 시작하지 않아서 pipeline script에 추가로 (프로젝트명)ApplicationTests.java 파일을 지우고, @SpringBootTest 어노테이션을 지운 상태의 (프로젝트명)ApplicationTests.java 파일이 copy되는 명령어를 추가로 기입하였다.

어노테이션을 지운 상태의 파일을 넣기 위해 application-{secret}.yaml 파일을 추가로 넣어준 것처럼 Credentials를 만들었다.

이와 관련하여 트러블슈팅 글을 작성할 예정이다.

 

 

Jenkins Pipeline

 

이제 본격적으로 pipeline을 구성할 차례이다.

Jenkins 대시보드 > 새로운 item을 누르고 적절한 item 이름을 입력한 뒤 Pipeline을 선택한다.

 

 

🪴Jenkins & Github Webhook 연동

깃허브에 푸시할 때마다 자동으로 Build 될 수 있도록 Pipeline과 Github Webhook을 연동해야 한다.

초반에 이에 필요한 Plugin인 Github Integration Plugin을 설치한 상태이다.

 

1. Github Webhook 추가

Github Repository에서 Settings > Webhooks > Add Webhook을 눌러 Webhook을 추가한다.

 

  • Payload Url
    [Jenkins Server URL]:[Jenkins Server 포트]/github-webhook/
  • Content type
    application/json

나머지는 모두 default 설정을 유지하고, Add webhook 버튼을 눌러 Wehhook을 추가한다.

github-webhook 뒤의 /를 꼭 넣어줘야 한다!

이때 꼭 보안그룹의 인바운드 규칙에 젠킨스 포트를 0.0.0.0으로 퍼블릭으로 열어두어야 깃허브가 보낸 Webhook 요청을 받을 수 있다.

 

 

2. Jenkins Pipeline 설정

아까 생성한 Pipeline 창에 다시 가서 General 칸에 Github Project를 선택한 후 자신의 Github Repository 링크를 입력한다.

이때 Repository Url은 Clone시 사용하는 HTTPS Url(.git으로 끝남)으로 입력한다.

 

 

빌드 유발에는 깃허브 webhook 설정을 위해 GitHub hook trigger for GITScm polling을 체크해준다.

 

 

Pipeline Script에 다음과 같이 작성한다.

pipeline {
    agent any

    environment {
        imagename = "Docker 유저 아이디/이미지 이름"
        registryCredential = 'Docker hub Credential Id'
        dockerImage = ''
    }

    stages {
        stage('Prepare') {
          steps {
            echo 'Clonning Repository'
            git url: 'Github Repository SSH Url(git@github.com로 시작)',
              branch: 'Clone 받아올 브랜치 이름',
              credentialsId: 'Github Credential Id'
            }
            post {
             success { 
               echo 'Successfully Cloned Repository'
             }
               failure {
               error 'This pipeline stops here...'
             }
          }
        }
        
        // 보안 정보 다운로드
        stage('secret.yml download') {
          steps {
              withCredentials([file(credentialsId: 'application-secret Credential Id', variable: 'dbConfigFile')]) {
                  script {
                  	  // Secret file 입력할 주소
                      sh 'cp $dbConfigFile /var/jenkins_home/workspace/(프로젝트 이름)/src/main/resources/application-secret.yaml'
                    }
              }
          }
        }
        
        // SpringBootTest 어노테이션 삭제
        stage('spring test rm') {
          steps {
              withCredentials([file(credentialsId: 'spring test Credential Id', variable: 'testConfigFile')]) {
                  script {
                      sh 'rm /var/jenkins_home/workspace/(프로젝트명)/.../ApplicationTests.java' // 기존 파일 삭제
                      sh 'cp $testConfigFile /var/jenkins_home/workspace/(프로젝트명)/.../ApplicationTests.java' // 새로 파일 업로드
                    }
              }
          }
        }

        stage('Bulid Gradle') {
          steps {
            echo 'Bulid Gradle'
            dir('.'){
                sh './gradlew clean build --no-daemon' // 빠른 빌드를 위해 daemon 조건 없애기
            }
          }
          post {
            failure {
              error 'This pipeline stops here...'
            }
          }
        }
        
        stage('Bulid Docker') {
          steps {
            echo 'Bulid Docker'
            script {
                dockerImage = docker.build imagename
            }
          }
          post {
            failure {
              error 'This pipeline stops here...'
            }
          }
        }

        stage('Push Docker') {
          steps {
            echo 'Push Docker'
            script {
                docker.withRegistry( '', registryCredential) {
                    dockerImage.push() 
                }
            }
          }
          post {
            failure {
              error 'This pipeline stops here...'
            }
          }
        }
        
        stage('Docker Run') {
            steps {
                echo 'Pull Docker Image & Docker Image Run'
                sshagent (credentials: ['ssh credential Id']) {
                    sh "ssh -o StrictHostKeyChecking=no [스프링부트 Server username]@[스프링부트 Server IP 주소] 'docker pull (위에서 설정했던 도커 이미지 이름)'" 
                    sh "ssh -o StrictHostKeyChecking=no [스프링부트 Server username]@[스프링부트 Server IP 주소] 'docker ps -q --filter name=(도커 컨테이너 이름) | grep -q . && docker stop (도커 컨테이너 이름) && docker rm (도커 컨테이너 이름) | true'"
                    sh "ssh -o StrictHostKeyChecking=no [스프링부트 Server username]@[스프링부트 Server IP 주소] 'docker run -d --name quiz -p 8080:8081 --name=(도커 컨테이너 이름) (도커 이미지 이름) -f /dev/null'" // 종료하지 않고 계속 실행
                }
            }
        }
    }
}

하단에 도커 이미지 이름은 (docker hub ID)/(프로젝트명)으로 구성하고, 컨테이너 이름은 (docker hub ID)-(프로젝트명)이다.

이미지 이름 형식 저렇게 안지어서 계속 오류 났었다 ..ㅎ

 

sh "ssh -o StrictHostKeyChecking=no [스프링부트 Server username]@[스프링부트 Server IP 주소] 'docker pull (위에서 설정했던 도커 이미지 이름)'" 
sh "ssh -o StrictHostKeyChecking=no [스프링부트 Server username]@[스프링부트 Server IP 주소] 'docker ps -q --filter name=(도커 컨테이너 이름) | grep -q . && docker stop (도커 컨테이너 이름) && docker rm (도커 컨테이너 이름) | true'"
sh "ssh -o StrictHostKeyChecking=no [스프링부트 Server username]@[스프링부트 Server IP 주소] 'docker run -d --name quiz -p 8080:8081 --name=(도커 컨테이너 이름) (도커 이미지 이름) -f /dev/null'" // 종료하지 않고 계속 실행

첫번째 line은 올렸던 도커 이미지를 받아오는 명령어이다.

두번째 line은 현재 실행중인 도커 컨테이너를 찾고 종료하는 명령어다.

처음 Build할 때는 두번째 line을 주석 처리하고, 두번째 Build부터 사용하면 에러가 나지 않는다.

마지막 line은 도커 컨테이너의 8081 포트를 EC2 인스턴스의 8080 포트와 연결하여 접근할 수 있도록 하는 명령어이다.

 

이렇게 해서 빌드하면!

docker push 에러가 발생한다.

젠킨스 서버의 도커 컨테이너에 로그인을 안한 상태로, 다음과 같은 명령어로 로그인하면 된다.

 

docker login
// user : 도커 허브 아이디
// password : 도커 허브 비밀번호

계속해서 실행하고 있는 거 확인!

13번 (사실 그 이상) 끝에 성공!

중간에 용량 및 버전 이슈가 있었지만 ..

묵히고 묵히던 CI/CD 진행 과정을 정리할 수 있어서 뿌듯하다 ㅎㅎ

앞으로의 프로젝트 개발도 잘 할 수 있길 바라며 이상 포스팅을 마친다!

 

 

 

References

[Devops] Jenkins, Docker로 Spring Boot CI/CD 구축하기 (tistory.com)

[AWS] Jenkins를 활용한 Docker x SpringBoot CI/CD 구축 (velog.io)

'DevOps' 카테고리의 다른 글

[CI/CD] CI/CD란  (0) 2023.01.31

+ Recent posts