티쳐포보스 회원가입 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 과정이 불필요하게 시간을 더 늦춘 거 같다.

 

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

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

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

 

오늘은 JPA를 이용해 개발을 진행하다보면 자주 접하는 Fetch Type에 대해 알아볼 것이다.

Fetch Type 속성은 언제 어떻게 동작하며, 전에 작성한 N+1 문제와 어떤 관련이 있는지, 그리고 어떻게 해결할 수 있는지에 대해 정리한다.

 


Fetch Type이란?

JPA가 하나의 Entity를 조회할 때, 연관관계에 있는 객체를 어떻게 가져올 것이냐를 나타내는 설정값이다.

크게 Eager와 Lazy 두가지 전략이 있다. Fetch Type Issue 상황은 하나의 Entity를 로드할 때 두가지 전략 중 고민하는 상황을 말한다.

  • 연관관계에 있는 Entity들 모두 가져온다 - Eager
  • 연관관계에 있는 Entity 가져오지 않고, Getter로 접근할 때 가져온다 - Lazy
각 연관관계의 default 속성
- @ManyToOne : EAGER
- @OneToOne : EAGER
- @ManyToMany : LAZY
- @OneToMany : LAZY

JPA 기본 페치 전략
- @ManyToOne, @OneToOne : 즉시 로딩 (optional = false : 내부 조인, optional = true : 외부 조인)
- @OneToMany, @ManyToMany : 지연 로딩 (optional = false : 외부 조인, optional = true : 외부 조인)

 

즉시로딩과 지연로딩

1) 즉시로딩 EAGER

  • 특정 엔티티를 조회할 때 연관된 모든 엔티티를 같이 로딩
  • 항상 외부 조인 (OUTER JOIN)을 사용 (외부 조인보다 내부 조인(INNER JOIN)이 성능 최적화에 더 유리)
  • 실무에서 엔티티 간 관계가 복잡해질 수록 조인으로 인한 성능 저하를 피할 수 없고 JPQL에서 N+1문제를 일으킴
  • 지연로딩을 기본으로 사용하고 상황에 맞게 사용하길 권장

2) 지연로딩 LAZY

  • 연관된 엔티티를 프록시로 조회
  • 프록시를 실제 사용시 초기화하면서 데이터베이스를 조회
  • 지연로딩 적용 상태에서 연관관계 상태의 종(Member와 Order 중 Member)에 접근하려고 하면 proxy [~] - no Session과 같은 에러 메세지 
  • 이미 DB와 연결된 Connection에 커밋을 날리고 트랜잭션이 닫힌 상태, 연결된 Connection이 없음
  • @Transactional 어노테이션을 통해 해결 (해당 메소드를 하나의 트랜잭션으로 처리)  -> 필요할 때마다 다시 데이터베이스와의 연결이 생성되어 정상적으로 실행
  • 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용할 이유가 없음. 따라서 영속성 컨텍스트에 이미 로딩되어 있으면 프록시 객체가 아닌 실제 객체(엔티티)를 사용
더보기

즉시로딩에서 외부 조인을 사용하는 이유?

  • 다대일 관계인 회원 테이블과 팀 테이블을 조인할 때 회원 테이블의 외래 키에 not null 제약조건을 걸어두면 모든 회원은 팀에 소속되므로 항상 내부 조인을 사용해도 된다.
  • 반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생한다. 데이터베이스 제약조건으로 이런 상황을 막을 수 없다.
  • 따라서 JPA는 일대다 관계를 즉시 로딩할 때 항상 외부 조인을 사용한다.

 

글로벌 페치 전략에 즉시로딩 사용시 단점

  1. 사용하지 않는 엔티티를 로딩한다
  2. N+1 문제 발생
    • JPA는 JPQL을 분석해서 SQL을 생성할 때 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용
    • 따라서 즉시 로딩이든 지연 로딩이든 구분하지 않고 JPQL 쿼리 자체에 충실하게 SQL을 만듦
    • 만약 order : member = N : 1인 ManyToOne 관계에서 조회한 order 엔티티가 10개이면 member를 조회하는 SQL도 10번 실행 -> 처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 것을 N+1 문제
    • N+1이 발생하면 SQL이 상당히 많이 호출되므로 조회 성능에 치명적 -> JPQL Fetch join으로 해결

 

Fetch Type의 동작 시점

 

JPA Entity Manager 에 의해 관리되는 Persistence Context 에 Entity가 Managed 상태로 올라올 때의 동작이다.

queryDSL 과 같은 쿼리 빌더를 이용해 아무리 Join 문을 짜도 Fetch Join 을 하지 않는 이상 메인 도메인의 엔티티만 Persistence Context 에 올라온다.

연관관계에 대한 Fetch 도 메인 도메인만 일어난다.

 

 

 

프로젝트 제작하면서 발생했던 MultipleBagFetchException 에러를 맞닥뜨리게 되면서,

원인부터 파악하기 위해 우선 N+1 문제에 대해 알아보려고 한다.

 

하단의 블로거 분 글을 보고 공부하고 많이 참고하였다!

 

N+1 문제 - Incheol's TECH BLOG

Query를 실행하도록 지원해주는 다양한 플러그인이 있다. 대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있을 것이다. 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

incheol-jung.gitbook.io


N+1 문제란!

JPA를 사용하면 자주 만나게 되는 것들 중 하나가 N+1 문제다.

N+1 문제란, 연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우 조회된 데이터 갯수 (N) 만큼 연관 관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어온다. 이를 N+1 문제라고 한다.

 

N+1 문제를 마주치게 한 코드들을 먼저 살펴보자.

 

Entity 설정

위 사진은 현재 진행하고 있는 졸프 서버 ERD 설계도이다

ERD 설계대로 제작한 Entity 코드는 아래와 같다.

 

  • 관리자 (Admin)은 회원을 여러명 추가할 수 있다.
  • 회원은 한 명(팀)의 관리자에 종속되어 있다.
  • Cctv도 이하 동문!

 

AdminEntity (테스트 위해 수정)

package com.example.ahpuh.admin.entity;

import com.example.ahpuh.cctv.entity.CctvEntity;
import com.example.ahpuh.user.entity.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "admin")
public class AdminEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long adminIdx;

    @Column(nullable = false, length = 100)
    private String email;

    @Column(nullable = false, length = 100)
    private String pwd;

    @Column(nullable = false, length = 100)
    private String poolName;

    @Column(nullable = false, length = 100)
    private String poolNum;

    @Column(nullable = false, length = 100)
    private String poolAddress;

    @Column(nullable = false, columnDefinition = "varchar(10) default 'active'")
    private String status;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "userIdx")
    private Set<UserEntity> userEntities = new HashSet<>();

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "cctvIdx")
    private List<CctvEntity> cctvEntities = new ArrayList<>();

    @Builder
    public AdminEntity(String email, String pwd, String poolName, String poolNum, String poolAddress, String status){
        this.email = email;
        this.pwd = pwd;
        this.poolName = poolName;
        this.poolNum = poolNum;
        this.poolAddress = poolAddress;
        this.status = status;
    }

    public AdminEntity(String s) {
        this.poolName = s;
    }

    public void setUsers(Set<UserEntity> user) {
        this.userEntities = user;
    }
}

 

UserEntity

package com.example.ahpuh.user.entity;

import com.example.ahpuh.admin.entity.AdminEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "user")
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userIdx;

    @ManyToOne
    @JoinColumn(name = "adminIdx")
    private AdminEntity adminIdx;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, length = 100)
    private String phoneNum;

    @Column(nullable = true)
    private String gender;

    @Column(nullable = true)
    private String age;

    @Column(nullable = true)
    private String address;

    @Column(nullable = false, columnDefinition = "varchar(10) default 'ACTIVE'")
    private String lectureStatus;

    @Column(nullable = false, columnDefinition = "varchar(10) default 'active'")
    private String status;

    @Builder
    public UserEntity(String name, String phoneNum, String gender, String age, String address, String lectureStatus, String status){
        this.name = name;
        this.phoneNum = phoneNum;
        this.gender = gender;
        this.age = age;
        this.address = address;
        this.lectureStatus = lectureStatus;
        this.status = status;
    }
}

 

CctvEntity

package com.example.ahpuh.admin.entity;

import com.example.ahpuh.cctv.entity.CctvEntity;
import com.example.ahpuh.user.entity.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "admin")
public class AdminEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long adminIdx;

    @Column(nullable = false, length = 100)
    private String email;

    @Column(nullable = false, length = 100)
    private String pwd;

    @Column(nullable = false, length = 100)
    private String poolName;

    @Column(nullable = false, length = 100)
    private String poolNum;

    @Column(nullable = false, length = 100)
    private String poolAddress;

    @Column(nullable = false, columnDefinition = "varchar(10) default 'active'")
    private String status;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "userIdx")
    private Set<UserEntity> userEntities = new HashSet<>();

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "cctvIdx")
    private List<CctvEntity> cctvEntities = new ArrayList<>();

    @Builder
    public AdminEntity(String email, String pwd, String poolName, String poolNum, String poolAddress, String status){
        this.email = email;
        this.pwd = pwd;
        this.poolName = poolName;
        this.poolNum = poolNum;
        this.poolAddress = poolAddress;
        this.status = status;
    }

    public void setUsers(Set<UserEntity> user) {
        this.userEntities = user;
    }
}

 

관리자 (Admin)을 조회해보면?

테스트 케이스를 작성하여 조회를 해보자.

테스트 시나리오는 다음과 같다.

 

  • 회원 10명을 추가했다.
  • 관리자 10팀을 생성했다.
  • 관리자는 10명씩 회원을 담당하고 있다.
  • 관리자를 조회해보자.

 

테스트 코드는 다음과 같다.

@Test
	void contextLoads() {
		Set<UserEntity> user = new LinkedHashSet<>();
		for(int i = 0; i < 10; i++){
			user.add(new UserEntity("user" + i));
		}
		userRepository.saveAll(user);

		List<AdminEntity> admins = new ArrayList<>();
		for(int i = 0; i < 10; i++){
			AdminEntity admin = new AdminEntity("admin" + i);
			admin.setUsers(user);
			admins.add(admin);
		}
		adminRepository.saveAll(admins);

		entityManager.clear();

		System.out.println("-------------------------------------------------------------------------------");
		List<AdminEntity> everyAdmins = adminRepository.findAll();
		assertFalse(everyAdmins.isEmpty());
	}

 

결과는?

 

Hibernate SQL Log를 활성화하여 실제로 호출된 결과를 확인했는데, (로그 추가 예정)

 

  • 관리자를 조회하는 쿼리를 호출하였다.
  • 회원을 조회하는 쿼리가 관리자를 조회한 row 만큼 쿼리가 호출하였다.

 

FetchType.EAGER라서 발생하는 것일까?

이와 같은 경우 쿼리가 하나밖에 호출되지 않았지만, 이는 연관관계 데이터를 프록시 객체로 바인딩한다는 것이다.
 
하지만 실제로 우리는 연관관계 엔티티를 프록시만으로는 사용하지 않기에 실제 연관관계의 엔티티를 사용하는 로직을 추가한다면 마찬가지로 N+1 문제가 발생한다.
 
FetchType을 변경하는 것은 단지 N+1 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지, 초기 데이터 로드 시점에 가져올지 차이만 있는 것이다.
 
 

N+1은 왜 발생하는 것일까?

N+1은 쿼리를 1개 날렸는데, 이로 인하여 추가 쿼리가 N개 나간다는 의미이다.

JPARepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 한다.
 
그렇기에 JPQL은 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 호출하기에, findAll()이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 select * from Admin 쿼리만 실행하게 되는것이다. 그렇기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 별도로 호출하게 된다.

 

해결 방법

1. Fetch join 

최적화된 쿼리를 직접 사용하는 것이다.

이는 JPARepository에서 제공하는 것은 아니고 JPQL로 작성해야 한다.

@Query("select a from Admin a join fetch a.users")
List<AdminEntity> findAllJoinFetch();
 
실제로 INNER JOIN으로 호출되며, 이는 연관관계가 있을 경우 하나의 쿼리문으로 표현할 수 있기에 매우 유리하다.
 
 

그러나 Fetch Join을 사용하는 경우 FetchType을 사용할 수 없다.

Fetch Join을 사용시 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오기에 FetchType을 Lazy로 해놓는 것이 무의미하다.

또한 페이징 쿼리를 사용할 수 없다.

하나의 쿼문으로 가져오다 보니 페이징 단위로 데이터를 가져오는 것이 불가능하다.

 

2. EntityGraph

@EntityGraph의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다.

Fetch join과 동일하게 JPQL을 사용하여 query문을 작성하고 필요한 연관관계를 EntityGraph에 설정하면 된다.

그리고 Fetch join과는 다르게 join문이 OUTER JOIN으로 실행되는 것을 확인할 수 있다.

 

@EntityGraph(attributePaths = "users")
@Query("select a from Admin a")
List<AdminEntity> findAllEntityGraph();

 

 

FetchJoin & EntityGraph 주의사항

Fetch Join과 EntityGraph는 JPQL을 사용하여 JOIN문을 호출한다는 공통점이 있다. 또한, 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Admin의 수만큼 user의 중복 데이터가 존재할 수 있다.

그러므로 중복된 데이터가 컬렉션에 존재하지 않도록 주의해야 한다.

 

  • 컬렉션 Set을 사용하게 되면 중복을 허용하지 않는 자료구조이기에 중복된 데이터를 제거할 수 있다.
  • JPQL을 사용하기 때문에 distinct를 사용하여 중복된 데이터를 조회하지 않을 수 있다.

 

해결방법

1. FetchMode.SUBSELECT

한번의 쿼리가 아닌 두번의 쿼리로 해결하는 방법이다.

해당 엔티티를 조회하는 쿼리는 그대로, 연관관계 데이터를 조회할 때는 서브 쿼리로 함께 조회하는 방법이다.

 

즉시로딩으로 설정하면 조회시점에, 지연로딩으로 설정하면 지연로딩된 엔티티를 사용하는 시점에 위의 쿼리가 실행된다. 모두 지연로딩으로 설정하고 성능 최적화가 필요한 곳에는 JPQL fetch join을 사용하는 것이 추천되는 전략이다.

 

 

2. BatchSize

하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 어노테이션을 이용하면, 연관된 엔티티를 조회할 때 지정된 size만큼 SQL의 IN절을 사용하여 조회한다.

 

즉시로딩시 @BatchSize가 있을때 User의 row 갯수만큼 추가 SQL을 날리지 않고, 조회한 Admin의 Id들을 모아서 SQL IN 절을 날린다. size는 IN절에 올수있는 최대 인자 개수를 말한다.

만약 User의 개수가 10개이고 size = 5라면, IN절이 2번 실행될것이다.

지연 로딩이라면 지연 로딩된 엔티티 최초 사용시점에 5건을 미리 로딩해두고, 6번째 엔티티 사용 시점에 다음 SQL을 추가로 실행한다.
 
 
그러나 연관 관계 데이터의 최적화 데이터 사이즈를 알기는 쉽지 않다.

추가로, hibernate.default_batch_fetch_size 속성을 사용하면 애플리케이션 전체에 기본으로 @BatchSize를 적용할 수 있다.

 

 

3. QueryBuilder

Query를 실행하도록 지원해주는 다양한 플러그인 중 대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있다.

이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

// QueryDSL로 구현한 예제
return from(admin).leftJoin(admin.users, user)
                   .fetchJoin()

 

이어달리기 프로젝트에서 로그인 부분 얼추 마무리 단계에 접어들면서 회원 탈퇴 API를 맡게 되었다.

처음에 Repository로 하나씩 조회하려고 했으나, 이번 프로젝트 ERD 설계에서 테이블마다 FK로 얽히고 설켜있기에,,

Entity 설계할 때 cascade 설정을 놓치고 있었음을 깨달았다.

 

이번 포스팅은 양방향 연관관계에서 쓰이는 영속성 전이 cascade에 대해 알아볼 것이다!

 

우선 그 전에 회원 탈퇴 API를 구현하면서 맞닥뜨린 에러문을 보자.

Cannot delete or update a parent row: a foreign key constraint fails ...

 

이런 에러문이 떴다.

해석하자면 해당 데이터 (row)를 삭제하려면 FK 관계에 있는 컬럼 때문에 삭제나 업데이트가 불가능해지는 것이다.

즉, 데이터 테이블 간 종속성 때문에 생기는 에러다.

 

해당 에러를 해결하기 위해서는 

 

1. 자식 테이블의 정보를 먼저 삭제 후, 부모 테이블의 정보를 삭제한다.
2. on delete Cascade 문법을 통해 부모 테이블이 삭제될 때 종속된 자식 테이블도 같이 삭제한다.

 

1번의 경우 테이블 설계가 복잡해질시 골치 아프다.

그리고 코드가 깔끔하지 않다 ..

따라서 2번의 방법으로 선택했다.

 

엔티티를 설계할 때 cascade (영속성 전이) 옵션을 추가하면 된다.

우선 영속성 정의 그리고 고아 객체에 대해 알아보자!

 


Cascade (영속성 전이)

부모 엔티티가 영속화될 때 자식 엔티티도 같이 영속화되고, 부모 엔티티가 삭제될 때 자식 엔티티도 삭제되는 등 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 전이되는 것

 

Cascade의 종류

  • ALL : 모두 적용 
  • PERSIST : 영속 (부모만 영속화하면 설정한 자식 엔티티까지 함께 영속화해서 저장)
  • MERGE : 병합
  • REMOVE : 삭제 (부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제)
  • REFRESH : Refresh
  • DETACH : DETACH

 

Orphan (고아 객체), OrphanRemoval

JPA에서는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이를 고아 객체 제거라 한다.

OrphanRemoval을 이용하면 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다!

이때 주의점은 특정 엔티티가 소유하는 엔티티에만 이 기능을 적용해야한다.

만약 삭제한 엔티티를 다른 곳에서도 참조한다면 문제가 발생할 수 있다.

그러기에 @OneToOne, @OneToMany에만 사용할 수 있다!!

 

 

CascadeType.REMOVE 와 orphanRemoval = true의 차이

이 둘은 관계가 끊어졌을 때 데이터에 대한 동작의 차이이다.

orphanRemoval = true는 연관된 엔티티 간의 참조가 끊어질 때 삭제가 이뤄진다.

cascade = CascadeType.REMOVE 는 부모 엔티티를 삭제하면 자식 엔티티를 삭제하는 것이지 참조가 끊어질 때 삭제가 이뤄지는 것은 아니다.

orphanRemoval = true 는 자식 객체의 데이터까지 제거하는 반면, CascadeType.REMOVE 는 자식 엔티티가 그대로 남아있다. 참조를 변경하여 무결성 오류를 안나게 할 뿐, 데이터는 남겨둔다.

 

따라서 두 엔티티의 관계를 끊을 때, 테이블의 데이터가 계속 남아있기를 바란다면, CascadeType.Remove 만 쓰는 것이고,

테이블의 데이터까지 삭제를 바란다면 orphanRemoval = true 를 사용하는 것이다.

 

 

Cascade + Orphan (영속성 전이 + 고아 객체)

일반적으로 엔티티는 EntityManager.persist()를 통해 영속화하고 remove()를 통해 제거되며 엔티티 스스로 생명주기를

리한다.

하지만 CascadeType.ALL + orphanRemoval = true 를 동시에 사용하면 부모 엔티티를 통해 자식의 생명 주기를 관리할 수 있다!!

 

 

 


이어달리기 프로젝트의 ERD 설계를 살펴보자.

User 엔티티 삭제할 때 => User 테이블 참조하는 UserProfile 엔티티를 삭제하게 되는데, 

이때 UserProfile을 FK로 삼는 Group 부분에서 에러가 발생했다.

 

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long clubIdx;

@Column(nullable = false, length = 20)
private String name;

@Column(nullable = false, length = 50)
private String content;

@Column(columnDefinition = "text")
private String imgURL;

@OneToOne
@JoinColumn(name = "userProfileIdx")
private UserProfileEntity hostIdx;

그룹 엔티티 일부인데, hostIdx(방장)를 UserProfile에서 참조하고 있다.

 

UserProfile - Club (그룹)은 일대일 연관관계으로 엮여있기에

@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "hostIdx", orphanRemoval = true)
private ClubEntity club;

다음과 같이 UserProfile 엔티티(PK 테이블)의 club 컬럼에서 cascade과 orphanRemoval 설정을 추가하였다.

 

User - UserProfile 은 일대다 연관관계로 엮여있기에

@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "userProfileIdx", orphanRemoval = true)
private List<UserProfileEntity> userProfileEntities = new ArrayList<>();

 

부모 테이블에 자식 테이블들 연관 관계를 설정하고, 영속성 전이와 고아 객체 설정을 완료하면

부모 테이블이 삭제됨과 동시에 해당 자식 테이블들도 삭제된다!!

 

 

다만 테이블을 이미 생성한 상태에서 수정하려면

테이블을 create함과 동시에 이 설정들이 적용되기에 drop과 함께 초기화해야 한다..!

 

하지만

-- 삭제하는 SQL

ALTER TABLE 테이블명 DROP FOREIGN KEY 테이블_FK값;

ALTER TABLE feed DROP FOREIGN KEY feed_ibfk_3;

먼저 기존에 있었던 FK들을 제거하고

 

-- ALTER를 이용한 CASCADE 추가
ALTER TABLE 테이블명
ADD CONSTRAINT 제약조건명
  FOREIGN KEY (CHILD_테이블의_FK값) -- 해당 테이블의 FK 설정
  REFERENCES MOTHER_테이블명(MOTHER_테이블의_PK) -- MOTHER PK와 연결
  ON DELETE CASCADE; -- MOTHER TABLE의 값 삭제시 연결된 값 삭제

다음과 같은 sql문으로 Cascade 제약 조건을 추가하면 된다.

이때 FK를 빈 상태로 두면 자동으로 생성된다.

 

아니면 스키마에 직접 cascade 설정을 추가하면 된다!

 

 

이렇게 영속성 전이와 고아 객체를 통해 에러를 해결할 수 있었다.

강의만 듣고 막연했던 부분인데, 이렇게 트러블 슈팅을 통해 고민하며 Entity 연관관계에 있어 공부해볼 수 있어 좋았다!

 

[SpringBoot] JPA Spring Security + refresh token으로 회원가입 구현하기 (1)

이어달리기 프로젝트를 하면서 이번에 유저에 관한 모델 API 제작을 맡게 되었다. 그 중 스타트는 회원가입과 로그인이다. GetIT 프로젝트 때는 내 담당이 아니었어서 JPA로 구현하는 건 처음이다.

seolki-log.tistory.com

1편에 이어서 Gradle을 활용해 SpringBoot 환경에서 회원가입/로그인 환경을 구축해보려고 한다.

 

Spring Security + JWT

Spring Security와 JWT를 어떻게 하면 같이 사용할 수 있을까?

 

 

Gradle

  1. 먼저 Spring Security와 jwt 인증에 필요한 라이브러리들을 gradle에 추가해준다.
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'mysql:mysql-connector-java'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.jsoup:jsoup:1.13.1'

	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
	implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'

	implementation group: 'org.hibernate', name: 'hibernate-spatial', version: '5.6.14.Final'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

프로젝트 내 현 gradle 상황이라 관련 없는 것도 섞여있다.

 

Jwt를 사용하기 위해 구현해야 할 것은 크게 기본적으로 

  • Jwt 토큰 제공을 위한 JwtTokenProvider
  • HTTP Request에서 토큰을 읽어 들여 정상 토큰이면 Security Context에 저장하는JwtTokenFilter

이렇게 2개이다.

 

그리고 Spring Security에 적용하기 위해 구현해야 할 것은 기본적으로

  • Jwt Filter를 Spring Security Filter Chain에 추가하기 위한 JwtSecurityConfig
  • 기본적으로 Spring Security 설정을 위한 SecurityConfig

이다.

 

Authentication Token을 Authentication Manager가 넘겨받아

Authentication 객체를 생성하고, 이를 Provider에게 전달하여 Token을 생성하게 된다.

 

 

TokenProvider

@Slf4j
@Component
public class TokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분 - 1000 * 60 * 30
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일 - 1000 * 60 * 60 * 24 * 7

    private final Key key;

    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

JWT 토큰을 발행하고, Payload에 들어간 클레임을 통해 User 객체를 생성하여 Authentication 객체를 반환한다.

HTTP Request Header로부터 토큰을 가져오고, 토큰을 검증하는 모든 기능을 JwtTokenProvider에 구현하였다.

 

 

JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    // 실제 필터링 로직은 doFilterInternal 에 들어감
    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

OncePerRequestFilter를 상속받아 JwtTokenFilter를 구현함으로써 Request마다 1회만 실행되는 필터를 작성했다.

OncePerRequestFilter를 사용하면 doFIlter 메서드가 아닌 doFilterInternal 메서드를 Override 하여 정의해주어야 한다.

 

JwtTokenFilter사용자의 요청이 들어오면 Servlet Req, Res 객체가 생성되어 넘어오게 되는데, 

Req 객체에서 JWT Token을 추출해서 token을 통해 정상 토큰인지 확인한 후 토큰을 통해 생성한 Authentication 객체를 SecurityContext에 저장해주는 역할을 수행한다.

 

Authentication 객체는 Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체인데,

Spring Security는 사용자의 principalcredential 정보를 Authentication 객체에 담아 생성한 후 보관한다.

(여기서 principal은 유저의 식별자, credential은 암호라고 생각하면 된다.)

 

이때, Authentication 객체를 만드는 과정은 다음과 같다.

jwtTokenProvider.getAuthentication

위 메서드로 token을 넘겨주면, token을 생성할 때 claim으로 넣어줬었던 유저의 이름을  UserDetailsService의 loadUserByUsername 메서드에 넘겨주고, 해당 유저가 존재하는지 확인한다.

 

존재한다면,

UsernamePasswordAuthenticationToken

위 객체를 생성하여 반환해주게 되는데, 이 객체는 Security Context에 저장될 Authentication 객체이다.

 

Security Context Authentication 객체를 보관하는 친구이며, 이 Security Context는 SecurityContextHolder를 통해 접근할 수 있다.

 

정리하자면, 사용자의 요청이 들어오면 헤더에 담아서 보낸 JWT Token을 통해 Authentication 객체를 생성하여

SecurityContextHolder.getContext().setAuthentication(auth);

다음과 같이 Security Context에 저장해둔 것이다.

 

 

JwtSecurityConfig

// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

JwtTokenProvider를 주입받고 JwtTokenFilter를 생성한 후,
UsernamePasswordAuthenticationFilter가 동작하여 아이디, 패스워드를 통해 인증 요청을 진행하기 전에 제작한 Jwt Custom Filter를 추가하여 정상적으로 JWT 토큰을 통한 인증 방식이 작동하도록 해주었다.

 

 

SecurityConfig

@EnableWebSecurity // 기본적인 Web 보안 활성화
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    // 인증이 되지 않은 유저가 요청을 보낼 때 동작하는 인터페이스
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
	// 서버에 요청 시 액세스 불가능한 권한의 유저가 요청할 경우 동작하는 핸들러
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // CSRF 설정 Disable
        http.csrf().disable()
                .cors().disable()
                .headers().frameOptions().disable()
                .and()
                // exception handling 할 때 우리가 만든 클래스를 추가
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // h2-console 을 위한 설정을 추가
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                // 시큐리티는 기본적으로 세션을 사용
                // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
                .and()
                .authorizeRequests()
                .antMatchers("/", "/**").permitAll()
                .antMatchers("/users/logIn").permitAll()
                .antMatchers("/users/sign-in").permitAll()
                .anyRequest().authenticated()  // 나머지 API 는 전부 인증 필요

                // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}

WebSecurityConfigurerAdapter를 상속받은 config 클래스 (Security Config)에 @EnableWebSecurity 어노테이션을 달면 SpringSecurityFilterChain이 자동으로 포함된다.

jwtAccessDeniedHandler : 서버에 요청 시 액세스 불가능한 권한의 유저가 요청했을 경우 동작하는 핸들러

jwtAuthenticationEntryPoint : 인증이 되지 않은 유저가 요청을 보냈을 때 동작하는 인터페이스

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

 

참고한 블로그에서는 Security Config 파일에 Spring security에서 인증을 담당하는 AuthenticationManager를 사용하여 원하는 시점에 인증을 처리하기 위해 추가로 Bean을 등록해주었다.

 

@Bean
public AuthenticationManager authenticationManager(
	AuthenticationConfiguration authenticationConfiguration
) throws Exception {
	return authenticationConfiguration.getAuthenticationManager();
}

 


TokenModel

TokenDto

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {

    @ApiModelProperty(example = "권한 타입")
    private String grantType;
    @ApiModelProperty(example = "엑세스 토큰")
    private String accessToken;
    @ApiModelProperty(example = "리프레쉬 토큰")
    private String refreshToken;
    @ApiModelProperty(example = "엑세스 토큰 만료 시간")
    private Long accessTokenExpiresIn;

    public void setGrantType(String grantType) {
        this.grantType = grantType;
    }
}

 

 

RefreshTokenEntity

@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshTokenEntity {

    @Id
    private String keyId;
    private String value;

    public RefreshTokenEntity updateValue(String token) {
        this.value = token;
        return this;
    }

    @Builder
    public RefreshTokenEntity(String key, String value) {
        this.keyId = key;
        this.value = value;
    }
}

refresh token을 저장할 Entity이다.

 

 

TokenRepository

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
    Optional<RefreshTokenEntity> findByKeyId(String key);
}

 

 

CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException(email + " -> 데이터베이스에서 찾을 수 없습니다."));

    }

    // DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(UserEntity user) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getRole().toString());
        return new User(
                String.valueOf(user.getEmail()),
                user.getPwd(),
                Collections.singleton(grantedAuthority)
        );
    }
}

로그인 인증 등 유저의 인증 정보가 담긴 객체를 증명하는 Service단이다.

 

 


UserModel

UserController

@ResponseBody
@ApiOperation(value = "회원가입", notes ="비밀번호 validation 규칙은 8글자 이상 16글자 이하, 문자 + 숫자 섞어서입니다!")
@PostMapping("/sign-in")
public BaseResponse<TokenDto> signIn(@RequestBody PostUserReq user) {
    try {
        TokenDto token = this.userService.signIn(user);
        return new BaseResponse<>(token);

    } catch (BaseException e) {
        return new BaseResponse<>(e.getStatus());
    }
}

 

 

PostUserReq

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PostUserReq {
    private Long userIdx;
    private String name;
    private String email;
    private String pwd;
    private Role role;

    @Builder
    public PostUserReq(Long userIdx, String name, String email, String pwd){
        this.userIdx = userIdx;
        this.name = name;
        this.email = email;
        this.pwd = pwd;
    }
}

 

 

UserRepository

@Repository
public interface UserRepository extends JpaRepository <UserEntity, Long> {
    UserEntity findAllByUserIdx(Long userIdx);
    boolean existsByEmail(String email);
    Optional<UserEntity> findByEmail(String email);
}

 

 

UserService

회원가입

// 회원가입
public TokenDto signIn(PostUserReq user) throws BaseException {
    if(user.getEmail() == null || user.getPwd() == null){
        throw new BaseException(BaseResponseStatus.POST_USERS_EMPTY);
    }
    if(!isRegexEmail(user.getEmail())){
        throw new BaseException(BaseResponseStatus.POST_USERS_INVALID_EMAIL);
    }
    if(isHaveEmail(user.getEmail())){
        throw new BaseException(BaseResponseStatus.DUPLICATE_EMAIL);
    }
    String password = user.getPwd();
    if(!isRegexPwd(password)){
        throw new BaseException(BaseResponseStatus.POST_USERS_INVALID_PWD);
    }
    try{
        String encodedPwd = passwordEncoder.encode(user.getPwd());
        user.setPwd(encodedPwd);
    }catch (Exception e){
        throw new BaseException(BaseResponseStatus.PASSWORD_ENCRYPTION_ERROR);
    }
    UserEntity userEntity = UserEntity.builder()
            .name(user.getName())
            .email(user.getEmail())
            .pwd(user.getPwd())
            .loginType(LoginType.BASIC)
            .role(Role.ROLE_USER)
            .build();
    user.setPwd(password);

    userEntity = userRepository.save(userEntity);
    UserProfileEntity userProfileEntity = UserProfileEntity.builder()
            .nickName("기본 닉네임")
            .imgURL("기본 이미지")
            .statusMsg("안녕하세요")
            .userIdx(userEntity)
            .build();
    userProfileRepository.save(userProfileEntity);
    return token(user);

}

 

토큰 발급

public TokenDto token(PostUserReq user){
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPwd());
    // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
    //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    // 3. 인증 정보를 기반으로 JWT 토큰 생성
    TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
    // 4. RefreshToken 저장
    RefreshTokenEntity refreshToken = RefreshTokenEntity.builder()
            .key(authentication.getName())
            .value(tokenDto.getRefreshToken())
            .build();
    refreshTokenRepository.save(refreshToken);
    // 5. 토큰 발급
    return tokenDto;
}

 

 

refresh token 재발급

public TokenDto reissue(TokenDto tokenRequestDto) { //재발급
    // 1. Refresh Token 검증
    if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
        throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
    }

    // 2. Access Token 에서 Member ID 가져오기
    Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());

    // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
    RefreshTokenEntity refreshToken = refreshTokenRepository.findByKeyId(authentication.getName())
            .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

    // 4. Refresh Token 일치하는지 검사
    if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
        throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
    }

    // 5. 새로운 토큰 생성
    TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

    // 6. 저장소 정보 업데이트
    RefreshTokenEntity newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
    refreshTokenRepository.save(newRefreshToken);

    // 토큰 발급
    return tokenDto;
}

 

 

Postman을 통하여 body값에 User 생성에 필요한 값들을 담고 발행해주면,

 

정상적으로 회원가입에 성공했다!

다음에는 refresh token을 관리하는 방법 (refresh Token일 경우 필터 조건 처리하기, Redis 활용하기) 나 Spring Security에 관한 내용들에 대해 더 공부해볼 예정이다.

 

 

 

이어달리기 프로젝트를 하면서 이번에 유저에 관한 모델 API 제작을 맡게 되었다. 그 중 스타트는 회원가입과 로그인이다.

GetIT 프로젝트 때는 내 담당이 아니었어서 JPA로 구현하는 건 처음이다.

이번에 직접 Spring Security를 적용해서 어떤 과정으로 적용되며 access, refresh token을 발급할 수 있는지 과정에 대해 알아볼 것이다! 

보안성과 편의성 모두를 잡을 수 있는 Sliding Sessions 전략이 있는데, 이 전략은 세션을 지속적으로 이용하는 유저에게 자동으로 만료 기한을 늘려주는 방법이다. 이 방법으로는 프로젝트 중후순쯤 어느정도 마무리되고 다시 공부해서 적용해볼 예정이다.

이번 포스팅에서는 JWT와 token, 그리고 Spring security에 관한 이론적인 부분에 대해서 알아볼 것이다.

 

 

JWT란?

JWT(Json Web Token)란 Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token이다. JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달한다. 주로 회원 인증이나 정보 전달에 사용되는 JWT는 아래의 로직을 따라서 처리된다.

 

 

JWT 는 어떤 상황에서 사용될까?

  • 회원 인증JWT 를 가장 많이 사용하는 상황이다. 유저가 로그인을 하면, 서버는 유저의 정보에 기반한 토큰을 발급하여 유저에게 전달해준다. 그 후, 유저가 서버에 요청을 할 때 마다 JWT를 포함하여 전달한다. 서버가 클라이언트에게서 요청을 받을때 마다, 해당 토큰이 유효하고 인증됐는지 검증을 하고 유저가 요청한 작업에 권한이 있는지 확인하여 작업을 처리한다.
     서버측에서는 유저의 세션을 유지 할 필요가 없다. 즉 유저가 로그인되어있는지 안되어있는지 신경 쓸 필요가 없고, 유저가 요청을 했을때 토큰만 확인하면 된다. 세션 관리가 필요 없어서 서버 자원을 많이 아낄 수 있다.
  • 정보 교류: JWT는 두 개체 사이에서 안정성있게 정보를 교환하기에 좋은 방법이다. 정보가 sign 이 되어있기 때문에 정보를 보낸이가 바뀌진 않았는지, 또 정보가 도중에 조작되지는 않았는지 검증할 수 있다.

 

Spring Security 인증과정

우선 Sprint security의 인증 과정은 위의 그림과 같다. 

  1. Http Request가 서버로 넘어와서, 가장 먼저 AuthenticationFilter가 요청을 낚아챈다.
  2. AuthenticationFilter에서 Request의 Username, password를 이용하여 UsernamePasswordAuthenticationToken을 생성한다.
  3. AuthenticationManager가 토큰을 받는다.
  4. AuthenticationManager는 토큰을 AuthenticationProvider에게 토큰을 넘겨준다.
  5. AuthenticationProvider는 UserDetailsService로 토큰의 사용자 아이디(username)을 전달하여 DB에 존재하는지 확인한다. 이 때, UserDetailsService는 DB의 회원정보를 UserDetails 라는 객체로 반환한다.
  6. AuthenticationProvider는 반환받은 UserDetails 객체와 실제 사용자의 입력정보를 비교한다.
  7. 비교가 완료되면 사용자 정보를 가진 Authentication 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandle를 실행한다.(실패시 AuthenticationFailureHandler를 실행한다.)

 

Spring Security Filter

스프링 시큐리티는 필터를 기반으로 수행된다.

필터와 인터셉터의 차이는 실행되는 시점의 차이이다.
- 필터는 dispatcher servlet으로 요청이 도착하기 전에 동작한다.
- 인터셉터는 dispatcher servlet을 지나고 controller에 도착하기 전에 동작한다.

 

더보기
  • SecurityContextPersistenceFilter : SecurityContextRepository에서 SecurityContext를 가져오거나 저장하는 역할을 한다.
  • LogoutFilter : 설정된 로그아웃 URL로 오는 요청을 감시하며, 해당 유저를 로그아웃 처리
  • (UsernamePassword)AuthenticationFilter : (아이디와 비밀번호를 사용하는 form 기반 인증) 설정된 로그인 URL로 오는 요청을 감시하며, 유저 인증 처리
    • AuthenticationManager를 통한 인증 실행
    • 인증 성공 시, 얻은 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
    • 인증 실패 시, AuthenticationFailureHandler 실행
  • DefaultLoginPageGeneratingFilter : 인증을 위한 로그인폼 URL을 감시한다.
  • BasicAuthenticationFilter : HTTP 기본 인증 헤더를 감시하여 처리한다.
  • RequestCacheAwareFilter : 로그인 성공 후, 원래 요청 정보를 재구성하기 위해 사용된다.
  • SecurityContextHolderAwareRequestFilter : HttpServletRequestWrapper를 상속한 SecurityContextHolderAwareRequestWapper 클래스로 HttpServletRequest 정보를 감싼다. SecurityContextHolderAwareRequestWrapper 클래스는 필터 체인상의 다음 필터들에게 부가정보를 제공한다.
  • AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 인증토큰에 사용자가 익명 사용자로 나타난다.
  • SessionManagementFilter : 이 필터는 인증된 사용자와 관련된 모든 세션을 추적한다.
  • ExceptionTranslationFilter : 이 필터는 보호된 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달하는 역할을 한다.
  • FilterSecurityInterceptor : 이 필터는 AccessDecisionManager 로 권한부여 처리를 위임함으로써 접근 제어 결정을 쉽게해준다.

 

필터들은 위의 그림과 같이 체인되어 있다. 필요한 부분이 있을 때 해당 필터를 찾아 공부하면 될 듯하다.

 

 

Cookie, Session, JWT는 모두 비연결성인 네트워크 서버 특징을 연결성으로 사용하기 위한 방법이다.

* Cookie & Session은 서버의 어떠한 저장소에 해당 값과 매칭되는 value를 가지고 있어야 한다. 그래서 서버 자원이 많이 사용되는 단점이 있다.
* JWT는 Cookie & Session의 자원 문제를 해결하기 위한 방법이다. JWT는 토큰 자체에 유저 정보를 담아서 암호화한 토큰이라고 생각하면 된다. 암호화된 내용은 디코딩 과정을 통해서 해석이 가능하다.

 

JWT 구조

JWT는 3개의 구역이 있다.

 

  • header : Header, Payload, Verify Signature 를 암호화할 방식(alg), 타입(Type) 등을 포함한다. alg는 해싱 알고리즘을 지정한다. 보통 HMAC SHA256 또는 RSA가 사용되며, 이 알고리즘은 토큰을 검증할 때 사용되는 signature부분에서도 사용된다. 
{
  "typ": "JWT",
  "alg": "HS256"
}

위 예제에서는 HMAC SHA256 해싱 알고리즘을 사용했고, (곧 나올) Spring Security를 활용한 JWT 예제에서 Base64로 인코딩하는 경우 헤더에 담긴 값을 발급할 수 있다.

  • Payload :서버에서 보낼 데이터. 일반적으로 user의 id, 유효기간을 포함한다. 여기에 담는 정보의 한 조각을 클레임(claim)이라 부르고 이는 name/value의 한 쌍으로 이뤄져있다. 클레임의 종류는 크게 세 분류로 나뉘어져있다.
    • 등록된 (registered) 클레임 :  서비스에서 필요한 정보들이 아닌, 토큰에 대한 정보들을 담기위하여 이름이 이미 정해진 클레임들이다. 등록된 클레임의 사용은 모두 선택적 (optional)이며, 이에 포함된 클레임 이름들은 다음과 같다.
      • iss: 토큰 발급자 (issuer)
      • sub: 토큰 제목 (subject)
      • aud: 토큰 대상자 (audience)
      • exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) 언제나 현재 시간보다 이후로 설정되어있어야 한다.
      • nbf: Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념이다. 여기에도 NumericDate 형식으로 날짜를 지정하며, 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.
      • iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있다.
      • jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용된다. 일회용 토큰에 사용하면 유용하다.
    • 공개 (public) 클레임 : 충돌이 방지된 (collision-resistant) 이름을 가지고 있어야 한다. 충돌을 방지하기 위해서는, 클레임 이름을 URI 형식으로 짓는다.
    • 비공개 (private) 클레임 : 등록된 클레임도아니고, 공개된 클레임들도 아니다. 양 측간에 (보통 클라이언트 <->서버) 협의하에 사용되는 클레임 이름들이다. 공개 클레임과는 달리 이름이 중복되어 충돌이 될 수 있으니 사용할때에 유의해야한다.
    • 하단의 payload 예제는 2개의 등록된 클레임, 1개의 등록된 클레임, 2개의 비공개 클레임으로 이뤄져있다.
{
    "iss": "velopert.com",
    "exp": "1485270000000",
    "https://velopert.com/jwt_claims/is_admin": true,
    "userId": "11028373727102",
    "username": "velopert"
}
  • 서명(Verify Signature) : Base64 방식으로 인코딩한 Header, Payload, Secret key 를 더한 값을 주어진 비밀키로 hash하여 생성한다.
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

     서명 부분을 만드는 슈도코드의 구조로 이렇게 만든 해쉬를 base64 형태로 나타내면 된다. 이렇게 각 세 단에서 발급받은 값들 사이 .을 넣어주고 합친 후, 비밀키의 값을 secret으로 해싱한 뒤 base64로 인코딩한다. 이 값을 .를 중간자로 다 합쳐주면 하나의 토큰이 생성된다!

 

 

JWT를 통한 인증절차

  1. 사용자가 로그인을 한다.
  2. 서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유 ID 값을 부여한 후 기타 정보와 함께 Payload 에 집어넣는다.
  3. JWT 토큰의 유효기간을 설정한다.
  4. 암호화할 Secret key 를 이용해 Access Token 을 발급한다.
  5. 사용자는 Access Token 을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.
  6. 서버에서는 해당 토큰의 Verify Signature 를 Secret key 로 복호화한 후, 조작 여부, 유효기간을 확인한다.
  7. 검증이 완료되었을 경우, Payload 를 디코딩 하여 사용자의 ID 에 맞는 데이터를 가져온다.

 

JWT는 보통 Access Token의 유효기간은 보안상 매우 짧다. 그래서 Refresh Token을 따로 발급해주는데, 유효 기간이 지나고 Access Token이 만료되면 새로운 JWT를 발급할 수 있는 토큰이다.

 

 

Access Token & Refresh Token

  1. Access Token의 만료기간을 매우매우 길게 설정해준다. -> 보안 상 불가능
  2. Access Token을 매번 요청마다 새롭게 갱신한다. -> 서버에 너무나 많은 요청을 하게 된다.
  3. Refresh Token을 도입한다. -> 가장 괜찮은 기법

그럼 Refresh Token에 대해서 알아보자.

 

Refresh Token

간단하게, Access Token을 재발급 받기위한 Token이다.

OAuth2.0을 이용하여 타서비스 로그인 기능을 구현한 경험이 있다면 누구나 들어보았을 것이다. refresh token을 활용한 회원가입 과정은 다음과 같다.

 

  1. 클라이언트에서 로그인한다.
  2. 서버는 클라이언트에게 Access Token과 Refresh Token을 발급한다. 동시에 Refresh Token은 서버에 저장된다.
  3. 클라이언트는 local 저장소에 두 Token을 저장한다.
  4. 매 요청마다 Access Token을 헤더에 담아서 요청한다.
  5. 이 때, Access Token이 만료가 되면 서버는 만료되었다는 Response를 하게 된다.
  6. 클라이언트는 해당 Response를 받으면 Refresh Token을 보낸다.
  7. 서버는 Refresh Token 유효성 체크를 하게 되고, 새로운 Access Token을 발급한다.
  8. 클라이언트는 새롭게 받은 Access Token을 기존의 Access Token에 덮어쓰게 된다.

 

Refresh Token을 사용하여 다음과 같은 이득을 얻을 수 있다.

  • Access Token의 유효기간을 짧게 하여 탈취 방지
    • Access Token이 탈취당하더라도 유효기간이 짧아 사용할 수 있는 기간이 줄어들어 탈취 방지 효과가 있음
  • Access 토큰의 유효기간에 짧음에도 불구하고 Refresh Token이 만료될때까지 추가적인 로그인을 하지 않아도 됨
    • 마치 세션이 유지되는 것 같은 효과

 

 

이렇게 Jwt를 활용한 로그인 인증 방식에 대해 알아보았다.

요즘은 서버에 부하가 발생할 경우 서버 자체 스팩을 늘리는 것이 아니라, 서버를 여러대 두고 로드밸런싱을 하는 것이 대세다. 이러한 방법을 사용할 경우 각기 다른 서버에 요청을 보내는 경우가 생기는데, session을 그냥 사용하는 경우에는 로그인 정보가 달라져 로그인이 풀릴 수 있다. 또한 성질이 다른 서버에서 공통으로 사용할 인증 로직으로 사용하기에는 적절하지 않을 수 있다.

JWT를 사용하면 위와 같은 클러스터링 환경에서 쉽게 인증 로직을 사용할 수 있을 것 같다.

 

 

라이징테스트 원티드 클론코딩 프로젝트를 하면서 멘토님께서 트랜잭션 잘 반영했는지 체크하면 좋겠다는 피드백을 받았다.

이번 기회에 스트링부트에서 트랜잭션 정의와 DB의 트랜잭션을 관리하는 방법을 익히며 현재 Dao단의 쿼리문을 검토하며 적용해볼 예정이다!

 

 

Spring Transaction Management

트랜잭션은 완전히 성공하거나 완전히 실패하는 일련의 논리적 작업단위이자, 데이터베이스에서 SQL을 실행하는 작업 단위를 뜻한다.

보통 데이터베이스에 데이터 CRUD 연산을 요청할 때 SQL 쿼리를 작성해서 실행한다. 단순하게 하나의 SQL 쿼리 실행이 실패한 경우 문제가 없다. 그러나 여러 건의 데이터를 처리하는 쿼리가 실행되던 중 오류가 발생하는 경우도 찾아온다. 이런 경우 오류 발생 전 완료된 작업은 DB에 저장할 것인지, 혹은 작업 전체를 오류로 판단하여 작업 내용을 원상 복구할지 처리해야한다. 중간에 오류가 발생하면 트랜잭션의 모든 단계를 이전으로 돌리는 것을 롤백이라고 부른다.

스프링부트에서는 DB의 트랜잭션을 처리할 수 있는 기능을 지원한다.

 

 

트랜잭션의 성질

  • 원자성 (Atomicity) : 한 트랜잭션 내 실행한 작업들은 하나로 간주한다. 모두 성공하거나 모두 실패한다.
  • 일관성 (Consistency) : 트랜잭션은 일관성 있는 데이터베이스 상태를 유지한다. (Data integrity 만족 등)
  • 격리성 (Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않도록 격리해야 한다.
  • 지속성 (Durability) : 트랜잭션을 성공적으로 마치면 결과가 항상 저장되어야 한다.

 

트랜잭션의 종류

1. Global Transactions 

드물지만 서로 다른 데이터베이스 간에 트랜잭션이 발생할 수 있는 응용프로그램이 있을 수 있다. 이를 분산 트랜잭션(distributed transaction) 처리라고 한다.

트랜잭션 관리자는 이를 처리하기 위해서 애플리케이션 내에 있을 수 없고 애플리케이션 서버 수준에 있다. JTA 또는 Java Transaction API는 JNDI의 지원과 함께 다른 데이터베이스를 조회하는데 필요하며 트랜잭션 관리자는 분산 트랜잭션의 커밋 또는 롤백을 결정한다. 이는 복잡한 프로세스이며 애플리케이션 서버 수준의 지식을 필요로 한다.

 

2. Local Transactions

로컬 트랜잭션은 간단한 JDBC 연결과 같은 단일 RDBMS와 애플리케이션 사이에서 발생한다. 로컬 트랜잭션을 사용하면 모든 트랜잭션 코드가 우리 코드 내에 있다.

만약 jdbc를 사용한다면 트랜잭션 관리 API는 JDBC용이다. Hibernate (JPA  API 중 하나)를 사용한다면 애플리케이션 서버의 hibernate 트랜잭션 API와 JTA는 global 트랜잭션을 위한 것이다.

 

 

 

예시를 보자면,

(참고 코드 : https://it-techtree.tistory.com/entry/springboot-manage-database-transactions)

 

@Component
public class BookingService {

    private final static Logger logger = LoggerFactory.getLogger(BookingService.class);

    private final JdbcTemplate jdbcTemplate;

    public BookingService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void book(String... persons) {
        for (String person : persons) {
            logger.info("Booking " + person + " in a seat...");
            jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", person);
        }
    }

    public List<String> findAllBookings() {
        return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
                (rs, rowNum) -> rs.getString("FIRST_NAME"));
    }

}

BookingService클래스에 @Component 어노테이션을 추가하여 스프링 빈 클래스로 추가한다. book() 메서드는 JDBC Template을 이용하여 insert 쿼리를 실행한다.

 

스프링의 트랜잭션은 프로그래밍 방식과 선언적 방식 (어노테이션)의 두가지 방식으로 구분할 수 있다.

프로그래밍 방식의 경우 TransactionTemplate을 활용하거나 직접 PlatformTransacitonManager를 구현한다.

 

book() 메서드 위에 추가된 @Transactional은 데이터베이스의 트랜잭션 관리를 수행하는 어노테이션이다. 클래스, 메서드위에 이 어노테이션이 추각되면 해당 클래스에 트랜잭션 기능이 적용된 프록시 객체가 생성된다.

 

이 프록시 객체는 @Transactional이 포함된 메소드가 호출될 경우 PlatformTransactionManager를 사용하여 트랜잭션을 시작하고 정상 여부에 따라 Commit 또는 Rollback한다.

프록시 객체의 프록시 패턴은 해당 클래스가 다른 클래스에 주입되면 Spring에서 내부적으로 target(본체)를 호출하여 대신 주입하는 것이다.

 

book() 메서드에서 SQL 쿼리 실행 중 실패가 발생하면, 메서드 내에서 실행되었던 쿼리 내용은 모두 Rollback(=원상복귀) 된다. @Transactional 어노테이션 추가만으로 데이터베이스의 트랜잭션을 손쉽게 관리할 수 있다. 

 

 

@Transactional 어노테이션 속성

@Transactional 어노테이션 속성 설정을 변경하여 트랜잭션 설정을 하는 방법을 알아보자.

 

propagation

트랜잭션 전파를 위한 설정이다. (Optional) 이것은 트랜잭션 동작을 설정하는 데 매우 중요한 속성이다.
  • REQUIRED (default) : 현재 트랜잭션 지원, 존재하지 않는 경우 새 트랜잭션 생성
  • REQUIRES_NEW : 새로운 트랜잭션을 생성하고 존재하지 않는 경우 현재 트랜잭션을 일시 중단합니다.
  • MANDATORY : 현재 트랜잭션을 지원하고 존재하지 않는 경우 예외를 던집니다.
  • NESTED : 현재 트랜잭션이 있는 경우 중첩된 트랜잭션 내에서 실행
  • SUPPORTS : 현재 트랜잭션을 지원하지만 존재하지 않는 경우 비트랜잭션으로 실행

 

isolation
트랜잭션 격리 수준. 트랜잭션이 다른 트랜잭션과 격리되어야 하는 수준을 결정한다.
  • DEFAULT : 데이터 소스의 기본 격리 수준
  • READ_UNCOMMITTED : Dirty Read, Non-Repeatable Read 및 Phantom Read가 발생할 수 있음을 나타냅니다. 다른 트랜잭션의 커밋되지 않은 데이터도 읽을 수 있음.
  • READ_COMMITTED : Dirty Read를 방지하고 반복할 수 없으며 Phantom Read가 발생할 수 있음을 나타냅니다. 커밋된 데이터만 읽음. 반복조회시 커밋 시점에 따라 데이터 상이.
  • REPEATABLE_READ : Dirty Read와 Non-Repeatable Read가 방지되지만 Phantom Read가 발생할 수 있음을 나타냅니다. 반복적으로 조회하여도 동일한 데이터를 보장.
  • SERIALIZABLE : Dirty Read와 Non-Repeatable Read, Phantom Read가 방지될 수 있음을 나타냅니다. 데이터 처리의 직렬화를 보장.

readOnly : 트랜잭션이 읽기 전용인지 또는 읽기/쓰기인지 여부
timeout : 트랜잭션 타임아웃(처리 시간초과)
rollbackFor : 트랜잭션의 롤백을 발생시켜야 하는 예외(Exception) 클래스의 배열
rollbackForClassName : 트랜잭션의 롤백을 발생시켜야 하는 예외 클래스 이름의 배열
noRollbackFor : 트랜잭션 롤백을 유발하지 않아야 하는 예외 클래스 개체의 배열
noRollbackForClassName : 트랜잭션 롤백을 유발하지 않아야 하는 예외 클래스 이름의 배열

 

 

다수의 트랜잭션이 경쟁시 발생할 수 있는 문제

다수의 트랜잭션이 동시에 실행되는 상황에선 트랜잭션 처리방식을 좀 더 고려해야 한다.

예를들어 특정 트랜잭션이 처리중이고 아직 커밋되지 않았는데 다른 트랜잭션이 그 레코드에 접근한 경우 다음과 같은 문제가 발생할 수 있다.

 

 

1) Dirty Read

  •  트랜잭션 A가 어떤 값을 1에서 2로 변경하고 아직 커밋하지 않은 상황에서 트랜잭션 B가 같은 값을 읽는 경우 트랜잭션 B는 2가 조회 된다.
  •  트랜잭션 B가 2를 조회 한 후 A가 롤백되면 트랜잭션 B는 잘못된 값을 읽게 된 것이다. 즉, 아직 트랜잭션이 완료되지 않은 상황에서 데이터에 접근을 허용할 경우 발생할 수 있는 데이터 불일치이다.

 

 

2) Non-Repeatable Read

  •  트랜잭션 A가 어떤 값 1을 읽었다. 이후 A는 같은 쿼리를 실행할 예정인데, 그 사이에 트랜잭션 B가 값 1을 2로 바꾸고 커밋해버리면 A가 같은 쿼리 두번을 날리는 사이 두 쿼리의 결과가 다르게 되어 버린다.
  •  즉, 한 트랜잭션에서 같은 쿼리를 두번 실행했을 때 발생할 수 있는 데이터 불일치이다. (Dirty Read에 비해서는 발생 확률이 적다.)

 

3) Phantom Read

  •  트랜잭션 A가 어떤 조건을 사용하여 특정 범위의 값들 [0, 1, 2, 3, 4]을 읽었다.
  • 이후 A는 같은 쿼리를 실행할 예정인데, 그 사이에 트랜잭션 B가 같은 테이블에 값 [5, 6, 7]을 추가해버리면 A가 같은 쿼리 두번을 날리는 사이 두 쿼리의 결과가 다르게 되어 버린다.
  •  즉, 한 트랜잭션에서 일정 범위의 레코드를 두번 이상 읽을 때 발생하는 데이터 불일치이다.

 

 

# 레퍼런스
https://it-techtree.tistory.com/entry/springboot-manage-database-transactions
https://goddaehee.tistory.com/167
https://sas-study.tistory.com/443

저번에 작성한 구글 로그인 API 로직을 바탕으로 공식 문서와 여러 레퍼런스를 참고하여 실제로 구글 로그인 API를 구현해 볼 것이다.

현재 라이징테스트 원티드 클론 코딩 프로젝트 중 구글 로그인을 맡게 되어 명확하게 이해하고 넘어가고 싶어 글을 작성하게 되었다.

 

0. 소셜로그인 로직

출처 : luiseok.com

 

1. 구글 OAuth API 프로젝트 환경 구성

 

1) 우선 사용자에게 보여질 Oauth 동의 화면을 구성한다.

  • 사용자 인증 정보 메뉴 선택
  • 사용자 인증 정보 만들기 클릭
  • OAuth Client ID 만들기 클릭

 

2) 구글의 어떤 사용자 데이터까지 접근할 것인지 범위를 지정한다.

 email, profile까지 지정하였다. 완료되면 사용자 Oauth 동의 화면이 구성된다.

 

 

3) OAuth api에 액세스하기 위해 사용자 인증 정보를 발급해야 한다.

  1. 옆 메뉴에서 사용자 인증 정보 클릭
  2. 사용자 인증 정보 만들기 클릭
  3. OAuth 클라이언트 ID 만들기 클릭
  4. 위 화면에서 애플리케이션 유형 -> 웹 애플리케이션 선택
  5. 승인된 자바스크립트 원본 및 Redirection URI 입력

 

  → 프로젝트에서 redirect할 주소로 localhost:9000/users/login/redirect으로 설정

(로컬서버인 경우 로컬호스트 입력이 가능하고 별도 서버를 둔 경우에는 해당 서버의 Public IP를 입력할 수 있다. 대신, login 요청하는 URL의 Root 주소여야 한다. 포트 번호도 마찬가지이다. 현재 진행 중인 프로젝트의 포트 번호는 9000!)

 

승인된 리디렉션 URI : 구글 로그인 이후 사용자의 AuthCode를 전달받는 URL 입력

 

 

4) 해당 과정을 거치면 아래와 같이 사용자 인증 정보가 발급된다.

대시보드에서 OAuth 클라이언트 ID의 이름을 누르게 되면 우측에 위와 같이 클라이언트 ID, 클라이언트 보안 비밀(Secret ID) 가 존재하는데, 이를 알고 있어야 로그인 API를 사용할 수 있다.

 

구글 로그인 API 사용 설정은 모두 마쳤다.

 

 

2. REST API 구현

  • SNS 소셜 로그인 프로세스
    • 로그인 최초 요청 처리 (”/users/googleLogin”)
      • 첫번째로 사용자가 웹사이트의 로그인 화면에서 특정한 소셜 로그인 버튼을 클릭하게 되는데, 먼저 이 요청을 처리해야 한다.
      • 이 요청은 정해진 형식으로 URL을 갖춰서 소셜 로그인 페이지로 리다이렉트하는 과정으로 처리한다.
    • 소셜 로그인 페이지에서 로그인한 이후 승인된 리디렉션 URI로 리다이렉트(”/users/login/redirect”)
      • 이때 해당 API 서버로부터 1회용 access code를 받게 되는데, 이 코드를 이용해 api 서버로부터 access token과 refresh token을 받게 된다.
    • 이 access token을 이용해서 인가 처리를 실행하며, 소셜 서버로부터 사용자의 추가 정보를 요청할때도 이 access token을 사용하여 정보를 받아올 수 있다.
  • 소셜 로그인을 구현하기 위해서는 해당 서드파티와 반드시 정해진 형식에 맞춰서 response/request를 진행해야 한다. 
    • REST API를 구현할 예정이므로 HTTP/REST 규약 부분을 참고해서 개발하였다.

 

⬇️ 하단 사이트 반드시 참고

https://developers.google.com/identity/protocols/oauth2/web-server#libraries

 

웹 서버 애플리케이션용 OAuth 2.0 사용  |  Authorization  |  Google Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English 의견 보내기 웹 서버 애플리케이션용 OAuth 2.0 사용 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분

developers.google.com

 

전체적인 틀, 과정은 위의 공식 문서에서 확인할 수 있다.

 

 

1단계 : 승인 매개변수 설정하기 (GoogleOauth 클래스 상세구현)

첫번째 단계는 승인 요청을 생성하여 애플리케이션을 식별하는 매개변수를 설정하고 사용자에게 애플리케이션에 부여하라는 요청을 정의합니다.

## 공식문서
Google의 OAuth 2.0 엔드포인트는 https://accounts.google.com/o/oauth2/v2/auth에 있습니다. 이 엔드포인트는 HTTPS를 통해서만 액세스할 수 있습니다. 일반 HTTP 연결은 거부됩니다.
Google 승인 서버는 웹 서버 애플리케이션에 다음과 같은 쿼리 문자열 매개변수를 지원합니다.

# 매개변수
client_id (필수)
애플리케이션의 클라이언트 ID입니다.

redirect_uri (필수)
사용자가 승인 흐름을 완료한 후 API 서버가 사용자를 리디렉션하는 위치를 결정합니다. 이 값은 클라이언트의 API에서 구성한 OAuth 2.0 클라이언트에 대해 승인된 리디렉션 URI 중 하나와 정확히 일치해야 합니다.

response_type (필수)
Google OAuth 2.0 엔드포인트에서 승인 코드를 반환할지 여부를 결정합니다. 웹 서버 애플리케이션의 매개변수 값을 code로 설정합니다.

scope (필수)
애플리케이션이 사용자를 대신하여 액세스할 수 있는 리소스를 식별하는 공백으로 구분된 범위 목록입니다. 이러한 값은 Google이 사용자에게 표시하는 동의 화면에 알립니다. 범위를 사용하면 애플리케이션이 필요한 리소스에 대한 액세스만 요청하는 동시에 사용자가 애플리케이션에 부여하는 액세스 양을 제어할 수 있습니다. 

access_type (권장)
사용자가 브라우저에 없을 때 애플리케이션이 액세스 토큰을 새로고침할 수 있는지 여부를 나타냅니다. 유효한 매개변수 값은 기본값인 online와 offline입니다. 이 값은 애플리케이션이 처음 승인 코드를 토큰으로 교환할 때 갱신 토큰 및 액세스 토큰을 반환하도록 Google 승인 서버에 지시합니다.

state (권장)
애플리케이션이 승인 요청과 승인 서버의 응답 간에 상태를 유지하는 데 사용하는 문자열 값을 지정합니다. 이 매개변수를 사용하여 사용자를 애플리케이션의 올바른 리소스로 안내하거나, nonce를 전송하고, 크로스 사이트 요청 위조를 완화할 수 있습니다. redirect_uri을 추측할 수 있으므로 state 값을 사용하면 수신 연결이 인증 요청의 결과임을 보장할 수 있습니다.

application.yml에 발급받은 client id, client secret key 등을 등록해준다.

노출되면 보안 상 문제가 생기므로 코드에 직접 추가하지 않고 yml 파일에 불러와서 사용해준다.

 

작성한 application.yml은 다음과 같다.

google:
  auth:
    url: 'https://oauth2.googleapis.com'
    scope: 'profile,email,openid'
  login:
    url: 'https://accounts.google.com'
  redirect:
    uri: 'http://localhost:9000/users/login/redirect'
  client:
    id: ' '
  secret: ' '

 

 

2단계 : Google OAuth 2.0 서버로 리디렉션 & 사용자 동의 요청

이 단계에서는 사용자가 애플리케이션에 요청된 액세스 권한을 부여할지 결정한다.

서버 측에서는 구글 소셜 로그인 페이지로 리디렉션하려면 어떻게 URL을 구성해야 하는지 확인한다.

Google의 OAuth 2.0 서버는 사용자를 인증하고 애플리케이션이 요청된 범위에 액세스할 수 있도록 사용자의 동의를 얻는다. 지정한 리디렉션 URL을 사용하여 응답이 애플리케이션에 다시 전송된다.

 

URL을 구성하기 위해 Config 파일에 코드를 추가해주었다.

@Component
    public class ConfigUtils {
        @Value("${google.auth.url}")
        private String googleAuthUrl;

        @Value("${google.login.url}")
        private String googleLoginUrl;

        @Value("${google.redirect.uri}")
        private String googleRedirectUrl;

        @Value("${google.client.id}")
        private String googleClientId;

        @Value("${google.secret}")
        private String googleSecret;

        @Value("${google.auth.scope}")
        private String scopes;

        public String googleInitUrl() {
            Map<String, Object> params = new HashMap<>();
            params.put("client_id", getGoogleClientId());
            params.put("redirect_uri", getGoogleRedirectUri());
            params.put("response_type", "code");
            params.put("scope", getScopeUrl());
			
            // 파라미터를 형식에 맞춰 구성해주는 함수
            String paramStr = params.entrySet().stream()
                    .map(param -> param.getKey() + "=" + param.getValue())
                    .collect(Collectors.joining("&"));

            return getGoogleLoginUrl()
                    + "/o/oauth2/v2/auth"
                    + "?"
                    + paramStr;
        }

        public String getGoogleAuthUrl() {
            return googleAuthUrl;
        }

        public String getGoogleLoginUrl() {
            return googleLoginUrl;
        }

        public String getGoogleClientId() {
            return googleClientId;
        }

        public String getGoogleRedirectUri() {
            return googleRedirectUrl;
        }

        public String getGoogleSecret() {
            return googleSecret;
        }

        // scope의 값을 보내기 위해 띄어쓰기 값을 UTF-8로 변환하는 로직 포함
        public String getScopeUrl() {
            return scopes.replaceAll(",", "%20");
        }
    }

 https://accounts.google.com/o/oauth2/v2/auth?scope=profile&response_type=code&client_id="할당받은id"&redirect_uri="access token 처리" 로 Redirect URL을 생성하는 로직을 구성한다.

application.yml에 @Value에 해당하는 값과 일치하지 않게 적으면 인식을 못한다.

 

 

 

3단계 : 소셜 로그인 이후 요청 처리

이전에 구글에 등록해뒀던 redirect api를 controller에서 개발한다.

  • 소셜 로그인 결과로 받아온 일회성 코드 (상단의 사진)를 보내서 서드파티 (Thrid Party, 제3자 즉 구글)로부터 액세스 토큰을 받아오고, 그 액세스 토큰을 다시 보내 서드파티에 저장된 사용자 정보를 받아오는 일련의 과정을 거칠 것이다.
  • 그 과정의 결과로 다시 서버에 정보를 요청할때 필요한 accessToken, 개발 중인 서버에서 회원 인가처리를 할 jwtToken, 그리고 추후에 조회등에 필요한 userIdx등의 정보를 받아올 것이다.

서버와 통신하기 위한 소셜 로그인 model을 생성한다.

아래부터 나오는 코드들은 메이쁘님의 코드를 공부하며 참고했다. 정말 감사합니다 ,,,

 

[Spring Boot] Google 로그인 REST API 로만 구현해보기!(코드, 스샷)

안녕하세요? Spring Boot와 Java를 이용해서 쉽고 간단하게 구글 로그인하는 API를 구현해봤습니다. 여러 블로그 및 공식 문서를 참고했습니다. https://developers.google.com/identity/protocols/oauth2/web-server 웹

maivve.tistory.com

 

먼저 일회성 토큰을 받은 후 해당 일회성 토큰을 가지고 Access Token을 발급받기 위한 Request 모델이다.

@Getter
@Setter
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class GoogleLoginReq {
    private String clientId;    // 애플리케이션의 클라이언트 ID
    private String redirectUri; // Google 로그인 후 redirect 위치
    private String clientSecret;    // 클라이언트 보안 비밀
    private String responseType;    // Google OAuth 2.0 엔드포인트가 인증 코드를 반환하는지 여부
    private String scope;   // OAuth 동의범위
    private String code;
    private String accessType;  // 사용자가 브라우저에 없을 때 애플리케이션이 액세스 토큰을 새로 고칠 수 있는지 여부
    private String grantType;
    private String state;
    private String includeGrantedScopes;    // 애플리케이션이 컨텍스트에서 추가 범위에 대한 액세스를 요청하기 위해 추가 권한 부여를 사용
    private String loginHint;   // 애플리케이션이 인증하려는 사용자를 알고 있는 경우 이 매개변수를 사용하여 Google 인증 서버에 힌트를 제공
    private String prompt;  // default: 처음으로 액세스를 요청할 때만 사용자에게 메시지가 표시
}

 

다음은 일회성 토큰을 통해 얻은 Response 모델이다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoogleLoginRes {
    private String accessToken; // 애플리케이션이 Google API 요청을 승인하기 위해 보내는 토큰
    private String expiresIn;   // Access Token의 남은 수명
    private String refreshToken;    // 새 액세스 토큰을 얻는 데 사용할 수 있는 토큰
    private String scope;
    private String tokenType;   // 반환된 토큰 유형(Bearer 고정)
    private String idToken;
}

 

다음으로 구글로 액세스 토큰을 활용해 JWT의 Payload 부분인 구글에 등록된 사용자 정보에 관한 model이다.

@Data
@NoArgsConstructor
public class GoogleLoginDto {

    private String iss;
    private String azp;
    private String aud;
    private String sub;
    private String email;
    private String emailVerified;
    private String atHash;
    private String name;
    private String picture;
    private String givenName;
    private String familyName;
    private String locale;
    private String iat;
    private String exp;
    private String alg;
    private String kid;
    private String typ;

}

 

그리고 각각 클라이언트의 정보에 관해 요청할 값들, jwtToken과 accessToken등이 담겨져 반환할 객체이다.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class GetGoogleReq {
    private String nickName;
    private String email;
    private String phoneNum;
    private String pwd;
    private String profileImgUrl;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class GetGoogleRes {

    private String jwtToken;
    private int userIdx;
    private String accessToken;
    private String tokenType;
}

 

1) /users/googleLogin

Service 구현 없이 하나의 Controller function 안에서 로직을 구현하였다.

/**
     * 구글로그인 API
     * [GET] /users/googleLogIn
     * @return BaseResponse<PostLoginRes>
     */
    @ResponseBody
    @GetMapping("/googleLogin")
    public ResponseEntity<Object> moveGoogleInitUrl() {
        String authUrl = configUtils.googleInitUrl();
        URI redirectUri = null;
        try {
            redirectUri = new URI(authUrl);
            HttpHeaders httpHeaders = new HttpHeaders();
            httpHeaders.setLocation(redirectUri);
            return new ResponseEntity<>(httpHeaders, HttpStatus.SEE_OTHER);
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }

        return ResponseEntity.badRequest().build();
    }

구글 로그인 페이지 창으로 인도하는 API이다.

client Id, redirect Url, response type, scope과 같은 파라미터를 형식에 맞춰서 구성을 한뒤, yml에 설정한 redirect Login Url과 함께 담아 반환해준다. 즉 구글 로그인 페이지가 보여지게 한다.

상단의 redirectUri는 http Header에 담아진다. 이를 ResponseEntity 형식으로 감싸주고 반환한다.

 

HttpHeaders

  • Header에 원하는 방식으로 key-value값을 설정해서 보낼 수 있는 객체이다.
  • 클라이언트와 서버가 요청 또는 응답으로 부가적인 정보를 전송할 수 있도록 한다.

ResponseEntity

  • 일반적인 API는 반환하는 리소스에 Value만 있지 않으며, 상태 코드, 응답 메세지 등이 포함될 수 있다.
  • ResponseEntity는 client가 보내는 여러가지 응답 내용을 규격에 맞게 한번 감싸주는 역할을 한다.
  • 같은 역할로는 @ResponseBody 어노테이션이 있다.
  • HttpEntity를 상속받고 있는 클래스이다.

 

Postman으로 테스트해본 결과,

  • localhost:9000/users/googleLogin으로 request하면 일련의 과정을 거쳐 소셜 로그인 페이지가 렌더링된다.

 

브라우저에서 확인해보면,

  • 곧바로 소셜 로그인 페이지로 리다이렉트되어 저장되어있는 프로필 페이지가 렌더링되는 것을 확인할 수 있다.

 

  • 프로필을 선택하고 나면 이전에 남은 로그인 처리를 진행할 redirect_uri로 지정했던 url로 리다이렉트된다.
  • 상단의 코드 파라미터가 추후에 사용할 일회성 코드이다.

 

2) /users/login/redirect

/**
     * Social Login API Server 요청에 의한 callback 을 처리
     * @param  code API Server 로부터 넘어오는 code
     * @return GetGoogleRes
     */
    @ResponseBody
    @GetMapping("/login/redirect")
    public BaseResponse<GetGoogleRes> redirectGoogleLogin(@RequestParam("code") String authCode) {
        // HTTP 통신을 위해 RestTemplate 활용
        RestTemplate restTemplate = new RestTemplate();
        GoogleLoginReq requestParams = GoogleLoginReq.builder()
                .clientId(configUtils.getGoogleClientId())
                .clientSecret(configUtils.getGoogleSecret())
                .code(authCode)
                .redirectUri(configUtils.getGoogleRedirectUri())
                .grantType("authorization_code")
                .build();

        try {
            // Http Header 설정
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            HttpEntity<GoogleLoginReq> httpRequestEntity = new HttpEntity<>(requestParams, headers);
            ResponseEntity<String> apiResponseJson = restTemplate.postForEntity(configUtils.getGoogleAuthUrl() + "/token", httpRequestEntity, String.class);

            // ObjectMapper를 통해 String to Object로 변환
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
            objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // NULL이 아닌 값만 응답받기(NULL인 경우는 생략)
            GoogleLoginRes googleLoginResponse = objectMapper.readValue(apiResponseJson.getBody(), new TypeReference<GoogleLoginRes>() {});

            // 사용자의 정보는 JWT Token으로 저장되어 있고, Id_Token에 값을 저장한다.
            String jwtToken = googleLoginResponse.getIdToken();

            // JWT Token을 전달해 JWT 저장된 사용자 정보 확인
            String requestUrl = UriComponentsBuilder.fromHttpUrl(configUtils.getGoogleAuthUrl() + "/tokeninfo").queryParam("id_token", jwtToken).toUriString();

            String resultJson = restTemplate.getForObject(requestUrl, String.class);

            // 랜덤 문자열
            String alphaNum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
            String Num = "0123456789";
            int alphaNumLength = alphaNum.length();
            int numLength = Num.length();

            Random random = new Random();

            StringBuffer code = new StringBuffer();
            for (int i = 0; i < 8; i++) {
                code.append(alphaNum.charAt(random.nextInt(alphaNumLength)));
            }

            StringBuffer phoneNum = new StringBuffer();
            phoneNum.append("010");
            for (int i = 0; i < 8; i++) {
                phoneNum.append(alphaNum.charAt(random.nextInt(numLength)));
            }

            if(resultJson != null) {

                GoogleLoginDto userInfoDto = objectMapper.readValue(resultJson, new TypeReference<GoogleLoginDto>() {});

                GetGoogleReq getGoogleReq = new GetGoogleReq(userInfoDto.getName(), userInfoDto.getEmail(), phoneNum.toString(), code.toString(), userInfoDto.getPicture());
                GetGoogleRes getGoogleRes = userService.createSocialUser(getGoogleReq);
                    getGoogleRes.setAccessToken(googleLoginResponse.getAccessToken());
                    getGoogleRes.setTokenType(googleLoginResponse.getTokenType());
                return new BaseResponse<>(getGoogleRes);

            }
            else {
                throw new Exception("Google OAuth failed!");
            }

        }
        catch (Exception e) {
            e.printStackTrace();
        }
        return new BaseResponse<>(BAD_REQUEST);
    }

 

전체 코드이다. 부분별로 나눠서 살펴보자.

 

// HTTP 통신을 위해 RestTemplate 활용
        RestTemplate restTemplate = new RestTemplate();
        GoogleLoginReq requestParams = GoogleLoginReq.builder()
                .clientId(configUtils.getGoogleClientId())
                .clientSecret(configUtils.getGoogleSecret())
                .code(authCode)
                .redirectUri(configUtils.getGoogleRedirectUri())
                .grantType("authorization_code")
                .build();

사용자가 로그인하려고 클릭을 하면 구글에서 accessToken을 발급받기 위해 미리 설정한 uri로 리다이렉트된다.

try문 전까지 보면 우선 HTTP 통신을 위해 RestTemplate을 활용하였다. (보통 Config에 함수를 추가해준다.)

그리고 일회성 토큰으로 accessToken을 발급받기 위해 GoogleLoginReq 바디에 알맞게 client Id, clientSecret, auth code, redirect Uri, 권한 코드와 같은 파라미터들을 담아준다.

 

// HTTP 통신을 위해 RestTemplate 활용
        RestTemplate restTemplate = new RestTemplate();
        GoogleLoginReq requestParams = GoogleLoginReq.builder()
                .clientId(configUtils.getGoogleClientId())
                .clientSecret(configUtils.getGoogleSecret())
                .code(authCode)
                .redirectUri(configUtils.getGoogleRedirectUri())
                .grantType("authorization_code")
                .build();

try문부터 살펴보면, Http 헤더를 설정한다. 헤더에 상단에 파라미터를 담은 모델, 즉 일회성 토큰을 통하여 accessToken을 발급받을 준비를 한다.

 

// ObjectMapper를 통해 String to Object로 변환
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
            objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // NULL이 아닌 값만 응답받기(NULL인 경우는 생략)
            GoogleLoginRes googleLoginResponse = objectMapper.readValue(apiResponseJson.getBody(), new TypeReference<GoogleLoginRes>() {});

            // 사용자의 정보는 JWT Token으로 저장되어 있고, Id_Token에 값을 저장한다.
            String jwtToken = googleLoginResponse.getIdToken();

            // JWT Token을 전달해 JWT 저장된 사용자 정보 확인
            String requestUrl = UriComponentsBuilder.fromHttpUrl(configUtils.getGoogleAuthUrl() + "/tokeninfo").queryParam("id_token", jwtToken).toUriString();

            String resultJson = restTemplate.getForObject(requestUrl, String.class);

ObjectMapper를 활용해서 String을 Object로 반환해주고,

일회성 토큰을 통해 얻은 Access Token이 담긴 Response 모델을 읽어온다.

 

이 모델에 담긴 사용자의 Id Token값을 따로 저장한다.

이 Jwt Token을 Uri에 전달하여 저장된 사용자 정보인지 확인을 하고, Object값으로 result값을 받아온다.

 

if(resultJson != null) {

                GoogleLoginDto userInfoDto = objectMapper.readValue(resultJson, new TypeReference<GoogleLoginDto>() {});

                GetGoogleReq getGoogleReq = new GetGoogleReq(userInfoDto.getName(), userInfoDto.getEmail(), phoneNum.toString(), code.toString(), userInfoDto.getPicture());
                GetGoogleRes getGoogleRes = userService.createSocialUser(getGoogleReq);
                    getGoogleRes.setAccessToken(googleLoginResponse.getAccessToken());
                    getGoogleRes.setTokenType(googleLoginResponse.getTokenType());
                return new BaseResponse<>(getGoogleRes);

            }
            else {
                throw new Exception("Google OAuth failed!");
            }

result값이 null이 아니라면 objectMapper를 통해 GoogleLoginDto에 사용자의 정보 (userInfoDto)를 담아온다.

DB에 반영하기 위해 userInfoDto에 있는 값을 활용하여 GetGoogleReq를 생성한다.

이번 프로젝트에서는 유저의 비밀번호와 전화번호는 not null로 설정했기에 랜덤으로 값을 생성해준다.

 

다시 한번 localhost:9000/users/googleLogin에 들어가 로그인을 시도해보면..

다음과 같이 잘 되는 것을 확인할 수 있다!

유저를 생성할 때 사용한 jwtService의 createJwt 메소드를 사용해서 jwtToken도 발급해주었다.

DB에도 정상적으로 잘 반영된 것을 확인할 수 있다!

 

저번 GetIT 프로젝트에서도 구글, 네이버 소셜 로그인을 구현했으나 내 담당 API가 아니었고, 그렇기에 이번 라이징 테스트 때 꼭 구현해보고자 다짐했었다.

차근차근 공식 문서와 멋진 블로그 선배림들 자료들을 통하여 단계별로 짚고 넘어가서 확실히 이해할 수 있었다!

이번 이어달리기 프로젝트 때 맡게 된다면 프로젝트 구조를 명확히 하여 util, Controller, Service단 각각 역할을 구분지어 가독성 있는 API를 짜고 싶다. 

 

# 레퍼런스
https://maivve.tistory.com/336
https://mslilsunshine.tistory.com/171
https://wonit.tistory.com/308

+ Recent posts