CI (Continuous Integration)

간단히 요약하자면 빌드/테스트 자동화 과정이며, CI/CD 파이프라인을 구현하기 위한 첫 번째 단계이기도 하다.

개발자를 위한 자동화 프로세스인 지속적인 통합(Continuous Integration)을 의미한다.

CI를 성공적으로 구현할 경우 새로운 코드 변경 사항이 정기적으로 빌드/테스트되어 공유 리포지토리에 통합된다.

이를 통해 여러 명의 개발자가 동시에 애플리케이션 개발과 관련된 코드 작업을 할 경우 서로 충돌할 수 있는 문제를 해결할 수 있다!

 

지속적 통합의 실행은 소스/버전 관리 시스템에 대한 변경 사항을 정기적으로 커밋하여 모든 사람에게 동일 작업 기반을 제공하는 것으로 시작한다. 커밋할 때마다 빌드와 일련의 자동 테스트가 이루어져 동작을 확인하고 변경으로 인해 문제가 생기는 부분이 없도록 보장한다

 

CI는 2가지 방법이 있는데 이에 대해 알아보자.

 

1. 코드 변경사항을 주기적으로 빈번하게 merge

가능한 작은 단위로 나누어서 주기적으로 빈번하게 개발하고 계속해서 통합하여 나간다.

 

merge 과정 이후의 흐름

1. 개발자들은 형상관리 툴(github 등)에 작업한 코드를 통합한다
2. 통합한 코드가 제대로 동작하는지 빌드 및 테스트를 진행한다.
3. 버그가 생기면 다음날 해야 할 목록에 정리해두고 다음 날에 버그를 해결한다.

 

2. 통합 단계의 자동화

github에 코드만 올리고 나머지 작업인 테스트와 빌드는 프로그램이 자동으로 해주어 똑같이 반복되는 귀찮은 작업을 하지 않을 수 있고, 문제도 더 적어진다!

 

자동화를 사용한 이후의 흐름

1. 개발자들은 형상관리 툴(github 등)에 작업한 코드를 통합한다
2. 빌드 및 테스트는 자동으로 진행되므로, 버그가 생기면 다음날 온 버그를 확인해서 버그를 해결한다.

 

CI의 장점

  • 코드의 검증에 들어가는 시간이 줄어든다.
  • 개발 편의성이 증가한다.
  • 항상 테스트 코드를 통과한 코드만이 repository에 올라가기에 좋은 코드 퀄리티를 유지할 수 있다.

 

CD (Continuous Deployment)

배포 자동화 과정이다. CD는 지속적인 서비스 제공(Continuous Delivery) 또는 지속적인 배포(Continuous Deployment)를 의미하며 이 두 용어는 상호 교환적으로 사용된다.

모두 파이프라인의 추가 단계에 대한 자동화를 뜻하지만, 얼마나 많은 자동화가 이루어지고 있는지를 설명하기 위해 별도로 사용되기도 한다.

 

CI에서 Build되고 Test된 후에 배포 단계에서 release할 준비 단계를 거치고 문제가 없는지 수정할만한 것들이 없는지 검증한다.

사용자들에게 서비스를 제공해도 되겠다고 판단되면 배포를 수동적으로 진행하는 것이 지속적인 서비스 제공이고, 자동화를 통하여 배포를 진행하는 것을 지속적인 배포라 한다.

 

지속적 배포는 빌드, 테스트 및 배포 단계를 자동화하는 DevOps 방식을 논리적 극한까지 끌어 올린다. 

코드 변경이 파이프라인의 이전 단계를 모두 성공적으로 통과하면 해당 변경 사항이 프로덕션에 자동으로 배포된다. 

지속적 배포를 채택하면 품질 저하 없이 최대한 빨리 사용자에게 새로운 기능을 제공할 수 있다.

 

또한 성숙하고 입증된 지속적 통합 및 전달 단계를 기반으로 하기에 간단한 코드 변경이 정기적으로 마스터에 커밋되고, 자동화된 빌드 및 테스트 프로세스를 거치며 다양한 사전 프로덕션 환경으로 승격되며, 문제가 발견되지 않으면 최종적으로 배포된다.

 

CD를 적용한 후 흐름

1. CI를 적용하여 코드를 검증한다.
2. 배포 환경과 비슷한 곳에서 검증을 진행한다.
3. 검증된 소프트웨어를 실제 프로덕션 환경으로 배포한다.

 

CD의 장점

  • 개발자는 배포보다 개발에 더 신경쓸 수 있도록 도와준다.
  • 개발자가 원클릭으로 수작업 없이 빌드, 테스트, 배포까지의 자동화를 할 수 있다.

 

CI/CD 종류

  • Jenkins
  • CircleCI
  • TravisCI
  • Github Actions
  • etc

 

CI/CD  적용 전후 비교

CI/CD를 적용하기 전

  1. 개발자들이 개발하여 코드를 수정 후 각자의 feature 브랜치에 코드를 push한다. (어느 한 부분에서 에러가 났지만 개발자들이 눈치 못챔을 가정)
  2. 각자의 코드를 git에 올리고 통합(Intergration)한다.
  3. 에러가 발생했지만 어느 부분에서 에러가 났는지 모르므로 디버깅 후 코드 수정한다.
  4. (1) ~ (4)의 과정을 반복한다.
  5. 에러가 해결되었으면 배포를 시작한다. 하지만 배포과정 또한, 개발자가 직접 배포과정을 거치므로 많은 시간을 소요된다.

 

코드의 양이 적다면 조금만 시간 투자해도 에러를 찾아낼 수 있지만,

코드의 양이 많다면 곧바로 에러 추적이 안되므로 어마어마한 양의 디버깅 과정을 마주하게 될 수도 있다.

 

CI/CD를 적용 후

  1. 개발자들이 개발하여 feature브랜치에 코드를 push한다.
  2. git push를 통해 Trigger되어 CI서버에서 알아서 Build, Test, Lint를 실행하고 결과를 전송한다.
  3. 개발자들은 결과를 전송받고 에러가 난 부분이 있다면 수정하고 코드를 master 브랜치에 merge한다.
  4. master 브랜치에 코드를 merge하고 Build, Test가 정상적으로 수행이 되었다면 CI서버에서 알아서 Deploy 과정을 수행한다.

 

일일히 빌드와 테스트, 배포과정을 개발자가 직접한다는 것은 리소스낭비이고 심한 경우에는 업무의 대부분을 빌드와 테스트, 배포에 투자해야 할 수도 있다.

 

 

'DevOps' 카테고리의 다른 글

Jenkins, Docker를 활용한 SpringBoot CI/CD  (1) 2023.08.18

JPA로 findBy~를 사용하던 중 발생한 에러이다.

query did not return a unique result : 5

이는 repository에서 조회한 결과는 5개인데 이를 class로 받아 담을 수 없기에 발생한다.

List<Class>로 받게 되면 오류 해결 할 수 있다!

해당 repository 구문으로 데이터를 조회해야하는데 제목과 같은 에러가 발생했다.

구글링 결과 파라미터의 타입이 맞지 않아 생기는 오류라고..!

entity에서 선언된 타입과 repository에서 입력한 타입과 맞지 않아 에러가 발생되는 케이스이다.

둘 타입을 맞게 설정해주면 에러 해결 완료!

이어달리기 프로젝트에서 로그인 부분 얼추 마무리 단계에 접어들면서 회원 탈퇴 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 연관관계에 있어 공부해볼 수 있어 좋았다!

repository단에 동적 쿼리가 담긴 메소드를 작성한 후 잘 돌아가는지 테스트하려고 하는데 위와 같은 에러가 떴다.

 

알고보니 내가 동적으로 쿼리를 날리는 경우라면 NativeQuery를 True로 설정해야하는 것이다.

@Query(value = "select * from member_status where user_profile_idx = :userProfileIdx limit 1", nativeQuery = True)

다음과 같이 NativeQuery를 True로 선언해주니 바로 해결했다!

프로젝트를 진행하며 Interface를 활용해 메서드를 만들고 호출하였다.

Interface는 객체지향 개발 5대 원칙을 만족시킬 수 있는 도구라고 하는데, 구체적으로 무엇이고 어떻게 구현하는지 알아보자!

 

1. 인터페이스란? (Interface)

Interface는 추상 메서드들을 나열한 형태이다. 쉽게 말하자면 객체의 사용방법을 가이드라인 하는 것이다.

구현도 되지 않는 메서드들의 나열을 왜 사용할까?

1. 추상 클래스를 통해 객체들 간 네이밍을 통일할 수 있고, 이로 인해 소스의 가독성과 유지보수가 향상된다.
2. 확장에는 열려있고 변경에는 닫혀있는 객체 간 결합도 (코드 종속성)를 낮춘 유연한 방식의 개발이 가능하다.

인터페이스의 특징으로는,

  • 다중 상속 가능
    • 껍데기만 존재하여 클래스 상속시 발생했던 모호함이 없기에 다중 상속이 가능하다.
  • 추상 메서드와 상수만 사용 가능
    • 인터페이스는 구현 소스를 생성할 수 없다.
  • 생성자 사용 불가
    • 인터페이스 객체가 아니므로 생성자를 사용할 수 없다.
  • 메서드 오버라이딩 필수
    • 자식 클래스는 부모 인터페이스의 추상 메서드를 모두 오버라이딩해야 한다.
코드 종속성을 낮춘다 
코드 종속성은 각각의 메서드 간의 결합도를 의미하며 인터페이스를 활용하면 한 메서드를 수정하였을 때, 다른 메서드도 수정해야 하는 상황을 줄여준다는 의미이다.
 인터페이스로 추상 메서드를 지정하면 메서드의 input값과 output값이 고정되어 있다. 예를 들자면 
 public abstract String myName(String name)이라는 메서드는 input값, output값 모두 String으로 고정되어 있다. 이 메서드를 구현하는 객체에서 아무리 수정하더라도 input값과 output 값은 String으로 고정되어 있어 변경에 대한 영향도가 작다. 그래서 인터페이스는 "변경에 강하다", "확장에는 열려있고 변경에는 닫혀있다" 라고 말하는 것입니다.

 

2. 인터페이스 선언

 인터페이스 클래스 작성방식은 아래와 같다. 

인터페이스에서 포함될수 있는 것들은 상수와 메서드 원형뿐이다. 그리고 두가지 멤버 변수는 public static final  public abstract 이어야 한다. (보통 생략한다)  

interface 인터페이스이름 {     
    public static final 타입 상수이름 = 값;     
    public abstract 메서드이름(매개변수목록);
}

 

3. 인터페이스 구현

  • 인터페이스는 구현 코드가 없고 클래스가 이 인터페이스를 구현한다라고 한다.
  • 코드에서 인터페이스의 메서드를 호출하면 인터페이스는 구현 객체의 메서드를 찾아서 호출한다.
  • 객체는 추상 메서드를 구현한 메서드를 갖고 있어야 한다. 이 객체를 구현 객체라고 한다.
  • 구현 객체를 생성하는 클래스는 구현 클래스라고 하다.

자바 클래스는 부모클래스 하나만 상속할수 있다.  extends 를 이용한 클래스 상속은 하나만 되고 두개 이상은 인터페이스만 된다. 인터페이스를 implements 하는 것은 상속이 아니라 구현한다고 해야 한다. 이렇게 두개 이상의 구현 가능한 인터페이스를 콤마(,) 로 구분해서 추가한다.

 

단일 인터페이스 구현

단일 인터페이스는 아래와 같이 구현한다.

public class ClassName impements InterfaceName{
	// 인터페이스의 추상 메서드 구현
}

 

 

다중 인터페이스 구현

 자바 클래스는 하나의 부모 클래스를 상속할수 있지만 인터페이스는 다중 상속이 가능하다. extends 키워드와 콤마를 이용해서 여러 개 인터페이스를 지정하면 된다.

public interface Animals extends IBird, IFlay {
	public void go();
	public void run();
}

 

 

4. 인터페이스 사용하는 이유

 인터페이스 클래스는 DB 연결에 많이 사용한다.

프로젝트에서 전체 프로그램을 완성해 가는 시점에 MySql 에서 Oracle 로 바뀔 경우를 생각해보자.

인터페이스로 구현하지 않았다면 모든 참조 소스는 다 바꿔야 한다. 그러나 인터페이스를 참조해서 구현했다면 Oracle 연결 클래스만 바꾸면 끝난다. 

 

 

5. 인터페이스끼리의 상속

인터페이스끼리도 상속을 통해서 확장을 시켜나갈 수 있고 하위 인터페이스에서 네이밍을 강제하여 객체 간의 통일성을 추구할 수 있다. 인터페이스로 객체를 구현할 때는 implements라는 키워드를 쓰지만, 인터페이스끼리 상속을 할 때는 클래스와 마찬가지로 extneds 키워드를 사용한다.

 

이렇게 자바에서의 인터페이스에 대하여 알아보았다.

다음 포스팅에서는 스프링에서의 인터페이스 특징, 역할 등에 대해 알아볼 예정이다!

 

 

이어달리기 프로젝트 중 이메일 인증번호 발급 관련하여 properties에 담긴 보안 정보를 읽어오려고 @Value 어노테이션을 지정하여 변수를 생성하였다.

 

    @Value("${spring.mail.username}")
    private String email;
    @Value("${spring.mail.password}")
    private String password;

 

그런데 계속 Value값이 인식이 안돼서 보니,

import lombok.Value 를 사용해서였다..!

 

import org.springframework.beans.factory.annotation.Value;

해당 import 문으로 변경하였더니 정상 작동하였다!

 

[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를 사용하면 위와 같은 클러스터링 환경에서 쉽게 인증 로직을 사용할 수 있을 것 같다.

 

 

이어달리기 프로젝트 중, Club Entity의 몇 컬럼에 default값 수정 요청이 들어왔다.

level과 goal_type에 해당하는 default값을 설정해주고 실행했는데

 

이와 같은 에러가 떴다.

 

구글링 해본 결과 보통 PK값에 이런 오류가 많이 생겨서 mysql을 재부팅하면 해결된다 하지만,

나는 PK값이 아닌 다른 컬럼에 이런 오류가 발생했다.

 

결론은 엔티티를 수정하고 그 수정사항이 DB에 반영이 안되어서 나타난 에러였다.

 

defalut값을 추가해준 goal_type과 level 컬럼에 DB에서도 마찬가지로 추가해주니 해결 완료!

+ Recent posts