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 처리를 하게 되는데 요청이 너무 많아지면 프로그램 성능 저하를 일으킬 수 있다..!
윈도우 기준 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 과정이 불필요하게 시간을 더 늦춘 거 같다.
실제 데이터를 활용해 결과를 확인함으로써 쿼리 로직 한 줄의 소중함을 몸소 깨달을 수 있었고,
이론이 맞다고 해서 실제 탐색 결과가 항상 동일하지 않다는 걸 알 수 있었다.
쿼리를 이리 저리 수정하고 테스트해보며 더 나은 성능을 도출할 수 있도록 노력해야겠다 ~!
@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 등이 있다.
부모 테이블에 자식 테이블들 연관 관계를 설정하고, 영속성 전이와 고아 객체 설정을 완료하면
부모 테이블이 삭제됨과 동시에 해당 자식 테이블들도 삭제된다!!
다만 테이블을 이미 생성한 상태에서 수정하려면
테이블을 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 연관관계에 있어 공부해볼 수 있어 좋았다!
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;
}
}
// 직접 만든 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을 등록해주었다.
@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;
}
}
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의 인증 과정은 위의 그림과 같다.
Http Request가 서버로 넘어와서, 가장 먼저 AuthenticationFilter가 요청을 낚아챈다.
AuthenticationFilter에서 Request의 Username, password를 이용하여 UsernamePasswordAuthenticationToken을 생성한다.
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, 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개의 비공개 클레임으로 이뤄져있다.
서명 부분을 만드는 슈도코드의 구조로 이렇게 만든 해쉬를 base64 형태로 나타내면 된다. 이렇게 각 세 단에서 발급받은 값들 사이 .을 넣어주고 합친 후, 비밀키의 값을 secret으로 해싱한 뒤 base64로 인코딩한다. 이 값을 .를 중간자로 다 합쳐주면 하나의 토큰이 생성된다!
JWT를 통한 인증절차
사용자가 로그인을 한다.
서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유 ID 값을 부여한 후 기타 정보와 함께 Payload 에 집어넣는다.
JWT 토큰의 유효기간을 설정한다.
암호화할 Secret key 를 이용해 Access Token 을 발급한다.
사용자는 Access Token 을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.
서버에서는 해당 토큰의 Verify Signature 를 Secret key 로 복호화한 후, 조작 여부, 유효기간을 확인한다.
검증이 완료되었을 경우, Payload 를 디코딩 하여 사용자의 ID 에 맞는 데이터를 가져온다.
JWT는 보통 Access Token의 유효기간은 보안상 매우 짧다. 그래서 Refresh Token을 따로 발급해주는데, 유효 기간이 지나고 Access Token이 만료되면 새로운 JWT를 발급할 수 있는 토큰이다.
Access Token & Refresh Token
Access Token의 만료기간을 매우매우 길게 설정해준다. -> 보안 상 불가능
Access Token을 매번 요청마다 새롭게 갱신한다. -> 서버에 너무나 많은 요청을 하게 된다.
Refresh Token을 도입한다. -> 가장 괜찮은 기법
그럼 Refresh Token에 대해서 알아보자.
Refresh Token
간단하게, Access Token을재발급 받기위한 Token이다.
OAuth2.0을 이용하여 타서비스 로그인 기능을 구현한 경험이 있다면 누구나 들어보았을 것이다. refresh token을 활용한 회원가입 과정은 다음과 같다.
이 때, Access Token이 만료가 되면 서버는 만료되었다는 Response를 하게 된다.
클라이언트는 해당 Response를 받으면 Refresh Token을 보낸다.
서버는 Refresh Token 유효성 체크를 하게 되고, 새로운 Access Token을 발급한다.
클라이언트는 새롭게 받은 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 트랜잭션을 위한 것이다.
@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가 같은 쿼리 두번을 날리는 사이 두 쿼리의 결과가 다르게 되어 버린다.
즉, 한 트랜잭션에서 일정 범위의 레코드를 두번 이상 읽을 때 발생하는 데이터 불일치이다.
첫번째 단계는 승인 요청을 생성하여 애플리케이션을 식별하는 매개변수를 설정하고 사용자에게 애플리케이션에 부여하라는 요청을 정의합니다.
## 공식문서 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 파일에 불러와서 사용해준다.
서버 측에서는 구글 소셜 로그인 페이지로 리디렉션하려면 어떻게 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을 생성한다.
아래부터 나오는 코드들은 메이쁘님의 코드를 공부하며 참고했다. 정말 감사합니다 ,,,
먼저 일회성 토큰을 받은 후 해당 일회성 토큰을 가지고 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이다.
@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를 짜고 싶다.