프로젝트를 진행하면서 더미데이터를 몇백만건씩 넣어본적도 없고 넣기도 빡세고 해서 count 쿼리에 대한 고민은 사실 해본적이 없었다.

 

매번 그냥 count(*) 로 처리했었는데 이번에 한번 대량의 데이터를 넣어서 해보고자 200만건의 데이터를 넣어봤다.

그리고 서버를 돌리고 해당 페이지에 접근했더니 한~참있다가 페이지가 뜬다..

그래서 테스트를 좀 해봤다.

 

테스트 환경

  • MySQL
  • 데이터 200만건.
  • MySQL Workbench에서 테스트

결과는 count(*)로 조회 했을 때 200만건을 처리하는데 16초가 걸렸다.

 

이래서 느렸구나 싶어 방법을 찾아봤고, 마땅한 해결책을 찾지 못해 처음에는 테이블을 새로 만들었다.

테이블에 각 게시판명을 기본키로 잡고 다른 컬럼에는 데이터의 총 개수를 넣어주었다.

그리고 trigger를 통해 insert, delete 발생시에 수정하도록 했다.

 

당연히 속도는 엄청 빨랐다.

하지만 이 문제를 좀 해결했으면 했다.

 

그래서 프로젝트가 다 마무리되고 나서 다시 방법을 좀 찾아봤다.

찾은 방법으로는 count(*)로 처리할것이 아니라 count(distinct(pk)) 형태로 처리하면 더 빠르게 조회할 수 있다는 것이었다.

그래서 테스트를 바로 해봤다.

 

count(distinct(boardNo))

결과는 0.6초가 걸렸다.

그냥 count(*) 쿼리가 16초가 걸린 반면 엄청난 시간차이였다.

 

여기서 만족할 수 없었는데, 이유는 게시판 특성상 검색 기능이 존재해야 하고 그럼 검색한 게시글의 개수가 많다면?

또 같은 문제가 발생할 수 있기 때문에 테스트가 필요했다.

 

제목으로 검색했고, 조회된 데이터 수는 130만건이었다.

count(*) = 1.093

count(distinct(boardNo)) = 1.297

WHERE 조건이 걸리지 않았을 때는 count(distinct(pk))가 훨씬 더 빠른 처리를 보여줬던 반면

조건이 걸려있을때는 count(*)가 더 빨랐다.

 

테스트를 하나 더 해보고 싶었다.

인덱스를 통해 처리하면 좀 더 빠르게 처리할 수 있다는 말은 데이터베이스에 대해 처음 공부할 때부터 계속 듣던 말이다.

그럼 여기에 인덱스까지 설정해주면????

그래서 boardNo, title 두가지를 묶어서 인덱스를 생성하고 다시 테스트를 해봤다.

count(*) = 0.641

count(distinct(boardNo)) = 0.953

결과는 count(*)가 더 빨랐다.

 

 

이렇게 몇가지 테스트를 해본 결과 count 쿼리를 처리하는데 있어서 전체 데이터를 조회해야 한다면 count(*)보다는 count(distinct(pk))가 훨씬 빠른 처리시간을 보여주지만,

WHERE 조건이 붙는 경우에는 count(*)가 더 빠르며, 인덱스까지 생성해준다면 더 빠르게 처리할 수 있다는 결과를 볼 수 있었다.

 

더 많은 양의 데이터를 처리하면 또 다른 결과를 볼 수 있을지도 모르겠지만...

아직 쿼리 효율에 대해 고민하고 공부해야 할 점이 너무 많다는 생각이 들었다.

 

프로젝트 리펙토링 중에 게시판 댓글 처리에 대해 좀 고민을 하게 되었다.

계층형 구조인데 댓글을 삭제했을 때 어떻게 처리할것인가에 대한 고민이었다.

 

처음에는 그냥 해당 댓글을 삭제하도록 구현했었고, 고민하다 보니 그것도 구조가 좀 이상하지 않나 싶어 하위 모든 댓글을 삭제 하는것으로 할까 하다가 게시판은 그렇게 처리했으니 다르게 처리하고 싶었다.

 

그래서 결정한 방법이 많은 사이트에서 볼 수 있는 '삭제된 댓글입니다'를 출력하는 것이었다.

 

이렇게 처리하기 위해 댓글 상태를 표현하는 필드가 필요했고 그거 추가하는거야 어렵지 않으니 추가해줬다.

default '0', 삭제 요청이 들어오면 데이터를 delete처리하는것이 아닌 1로 update 처리를 하도록 했다.

 

그럼 이제 여기서 고민이 있었다.

그냥 select * from comment 이렇게 조회해온다면 삭제된 댓글임에도 내용이 그대로 조회될 것이다.

 

여기서 고민끝에 생각난 방법이 세가지.

 

1. 프론트로 응답하기 전 서버에서 데이터 리스트를 하나하나 검증해 status가 1이라면 내용을 수정한다.

   이 방법은 떠오르자마자 아니다 싶었다.

   리스트를 하나하나 보면서 체크하고 해당되는 데이터를 수정한다? 그건 좀 아니다 싶었다.

 

2. 데이터를 응답받은 프론트에서 파싱하는 과정 중 status가 1인 데이터는 내용을 수정하도록 한다.

    괜찮은 방법이라고 생각했다.

    어차피 파싱 과정에서 데이터를 차례대로 파싱해 나가야 하고, 그 과정에서 status값 하나를 체크하는 것 뿐이니

    처리 시간에 지장을 주지 않고 되지 않겠나 싶었다.

    하지만 문득 이 방법으로 처리하면 내용이 노출될 가능성이 있다는 생각이 들었다.

    그래서 이 방법은 보류.

 

3. 쿼리로 조회할 때 부터 수정된 상태로 받으면 이 문제가 다 해결되지 않을까?

    그래서 쿼리문에서 조건문을 사용할 수 있는지를 찾아봤다.

    그리고 동적쿼리로 if문을 사용할 수 있다는 것을 알아냈다.

 

 

문제해결

if 조건문을 통해 처리하는 방법(MySQL)

SELECT commentNo
    , IF(commentStatus > 0, '삭제된 댓글입니다', commentContent) AS commentContent
    , ...
FROM comment

IF( 조건, true, false) AS 컬럼 표시명

이런 구조로 작성해주면 된다.

그럼 commentStatus는 0, 1두가지로만 구성되어있기 때문에 > 0 을 만족한다는 것은 삭제된 댓글이라는 의미이므로 

'삭제된 댓글입니다'를 content로 가져오게 되고 false의 경우 0이므로 삭제된 댓글이 아니기 때문에 원래 내용을 가져오게 된다.

 

하지만 여기서 문제가 있었다.

JPQL에서는 nativeQuery로 처리하지 않으면 Syntax 오류가 발생한다.

물론 nativeQuery로 처리하면 된다.

하지만 그렇게 되면 클래스 타입의 DTO로 받을 수 없었고, 프로젝트에서는 이 데이터를 또 파싱해야 했어서 무조건 List 타입의 엔티티로 받아야 했기 때문에 다른 방법을 찾아야 했다.

 

 

CASE WHEN

SELECT commentNo
    , CASE
        WHEN (commentStatus > 0) THEN '삭제된 댓글입니다'
        ELSE commentContent
      END AS commentContent
    , ...
FROM comment

다르게 조건문을 사용할 수 있는 CASE WHEN이다.

IF의 경우 단일 조건에 대해서만 처리가 가능하지만 CASE WHEN은 자바에서 switch case 처럼 다양한 조건에 대해 처리가 가능하다.

예를들어 

SELECT commentNo
    , CASE
        WHEN (commentStatus == 0) THEN null
        WHEN (commentStatus == 1) THEN '삭제된 댓글입니다'
        ELSE commentContent
      END AS commentContent
    , ...
FROM comment

이런식으로도 사용이 가능하다.

JPQL에 이걸 그대로 적용하게 되면 END뒤의 AS와 매핑할 DTO의 괄호 부분에 빨간 밑줄이 생기긴 한데 처리하는데 문제가 발생하지는 않았다.

아직까지는 JPQL에서 이 동적쿼리를 사용하는데 있어서 문제가 발생하지는 않았지만 빨간 밑줄이 생기는건 좀 찝찝하긴 해서 원인을 좀 찾아보긴 해야할듯 하다.

 

그리고 이 CASE WHEN의 경우는 Oracle에서도 사용이 가능하다!

'DB' 카테고리의 다른 글

Redis 정리  (1) 2024.12.07
다량의 데이터 count 처리  (0) 2023.09.28
Oracle Scheduler, MySQL Event Scheduler  (0) 2022.04.21
mysql 계층형 쿼리(함수, 재귀)  (0) 2022.04.04
EC2에 설치한 MySQL 데스크탑 workbench에서 접근  (0) 2021.11.09

개발 공부를 시작하고 프로젝트를 진행하면서 보통 VO 하나만으로 처리를 많이 해왔었다.

처음에는 MyBatis로 처리했었고 처음 공부하던 당시에는 VO나 DTO나 같은거 아닌가 정도로만 이해했었다.

둘다 데이터베이스 테이블과 동일한 구조로 만들어주고 요청한 데이터를 받거나 데이터를 담아 데이터베이스에 요청을 하는 정도만 생각했고 그렇게만 사용했기 때문이었다.

 

그러나 JPA를 공부하게 되면서 Entity라는 것에 대해 알게되면서 조회 요청에 대해 DTO로 받아야 하는 경우를 보게 되었고, 그때부터 VO, DTO, Entity는 다른건가? 라는 생각을 하게 되었다.

 

그리고 그에 대해 알아보면서 개념을 좀 정리하게 되어 이렇게 기록을 남기게 되었다.

 

일단, 보통 DTO와 VO에 대해 검색해보면 데이터를 전달하는 객체로 크~게 본 개념에서는 동일한 개념이지만 차이가 있었다.

 

 

DTO

DTO(Data Transfer Object)는 Transfer의 의미 그대로 데이터를 전송하는 객체를 말한다.

그래서 데이터를 계층간 전달하는 역할을 한다.

역할을 확실히 분리하기 위해 DTO에서는 데이터를 담고 빼는것. 즉, getter/setter만 처리하고 비즈니스 로직이 포함되어서는 안된다.

첫줄에서의 의미 그대로 '데이터를 담아 다른 계층에 전송'하는 용도 딱 그만큼만 사용하는 것이다.

 

VO

VO(Value Object)는 의미대로 값 자체를 표현하는 객체이다.

값 자체를 표현하기 때문에 불변의 객체로서 역할을 수행해야 하며, 그렇기에 getter는 갖지만 setter는 가질 수 없다.

DTO와는 다르게 데이터를 담기만 하는것이 아니라 값 자체를 표현하기 때문에 비즈니스 로직을 포함할 수 있다.

 

 

Entity

Entity는 데이터베이스와 직접적으로 매핑되는 클래스이다.

직접적으로 매핑되는 클래스이다 보니 테이블과 구조가 동일하다.

Entity는 데이터베이스와 직접적으로 매핑이 되다보니 계층간 전달 목적으로 사용해서는 안된다.

또한, 비즈니스 로직을 포함할 수 있다.

 

 

 

여기저기 알아보며 정리한 내용은 이정도 이다.

그래서 현재 사용하는 방법은 아래처럼 사용하는중.

 

Entity

  매핑되는 테이블과 동일한 구조로 생성. Entity 데이터를 insert, update 해야 하는 경우에는 Entity를 통해 데이터베이스에 요청을 보내 처리하지만 그 외에는 사용하지 않는다.

 

DTO

  데이터 조회시 Entity와 동일한 구조이더라도 왠만하면 DTO를 통해 데이터를 받도록 하고 결과를 DTO로 리턴한다.

 

VO

  막상 이렇게 분리해야 한다고 알고 나니까 아직은 사용할 프로젝트가 없어서 사용해보질 못했다...

JWT 처음에 공부하면서 프로젝트에 적용한 경험이 있었지만

Application 서버와 API 서버를 나눠서 구현을 해보게 되면서 좀 다르게 쓰게 되지 않을까 싶어서 사용해 보게 되었다.

 

이 포스팅의 중점은 기록정도.

 

프로젝트는 간단한 이미지 게시판, 텍스트만 작성이 가능한 게시판으로 구성되어 있고,

Application 서버와 API 서버로 구분해서 처리를 한다.

Application 서버에서는 API 서버에 데이터 처리 요청을 보내고,

API서버에서는 사실상 거의 모든 처리를 한 뒤 Application 서버에 결과를 리턴해주는 형태로 구현했다.

 

프로젝트 환경은 아래와 같다.

  • Spring boot 2.7.6
  • Spring Data JPA
  • Spring Security
  • MySQL
  • JWT 4.2.1

 

기존 프로젝트를 그대로 가져다가 리펙토링 하자는 생각으로 처리했고 기존에는 SpringSecurity로 처리하고 있었다.

사실 그대로 Security만 써서 쓰려다가 세션 리턴도 좀 애매하고 'RESTAPI는 Stateless 해야 한다' 라는 점에서도 좀 그렇지 않나.... 하는 생각해 JWT 써볼까??? 해서 쓰게 되었다..ㅎㅎ

 

그럼에도 SpringSecurity를 살려두고 같이 사용한 이유는 권한관리 때문이었다.

그리고 그 외에도 SpringSecurity에 유용한 기능이 많아 이렇게 복합적으로 사용하는 경우가 많다고 한다.

 

 

JWT 토큰 구성

토큰은 AccessToken과 RefreshToken 두가지를 사용해 처리했다.

AccessToken은 사용자 아이디를 담아 생성하고

RefreshToken은 난수로 생성하는 rIndex를 담아 생성한다.

이 두 토큰은 모두 클라이언트에서 보관하게 되고 RefreshToken의 경우는 DB에 저장을 했다.

그래서 DB에 refreshToken이라는 테이블을 생성해 저장했고 구조는 아래와 같다.

  • rtIndex = PK. 아무 의미없는 난수
  • userId = 사용자 아이디
  • tokanVal = refreshToken 값
  • expires = refreshTokne 만료 시기.

token값을 PK로 잡지 않고 굳이 난수로 만든 rtIndex를 PK로 잡은데는 이유가 있다.

현재 프로젝트처럼 하나의 서비스를 여러 환경에서 제공하기 위한 분리된 서버에서는 여러 환경에서 로그인을 하는 경우가 발생할 수 있을것이라고 생각했다.

물론 이 경우 보안을 위해 로그인 시 해당 사용자의 기존 refreshToken값을 새로운 값으로 변경해 다른 환경에서 재 로그인을 해야 하도록 만들 수도 있겠지만,

데스크탑 웹에서 로그인을 해두고 모바일로 또 로그인을 하게 되면 한쪽을 사용하지 못하게 하기 보다는 둘다 유지할 수 있도록 하는것이 좋지 않겠나 싶어서였다.

 

물론 현재 상태라면 부작용이 있다. refreshToken 만료기간이 되기 전까지는 사실상 Remember me와 같은 형태가 되기 때문이다.

이 문제에 대해서는 프론트에서 브라우저 종료 이벤트를 통해 클라이언트가 갖고 있는 토큰값을 삭제하도록 하는 방법으로 해결할 수 있을것으로 생각한다.

 

토큰이 발급 되어 클라이언트에서 토큰을 받으면 클라이언트는 두 토큰 모두 쿠키에 저장하도록 했다.

몇일을 여기저기 찾아보면서 고민을 해보며 결정한 것이긴 한데 아무래도 장단점이 좀 있다.

보통 JWT 토큰의 경우 클라이언트 localStorage에 저장하거나 아니면 쿠키에 저장을 한다.

또는, RefreshToken은 쿠키에 저장, AccessToken은 JavaScript의 private Variable에 저장해 매 요청시마다 AccessToken을 재발급 받는 방법이 있다.

 

그럼 첫번째,

스토리지에 저장하는 경우.

사용자가 요청하는 경우 알아서 담기는것이 아닌 코드에 의해 header에 담겨 전송되기 때문에 CSRF공격에 안전하다.

하지만 localStorage에 접근하는 코드 한줄이면 바로 확인이 가능하기 때문에 XSS 공격에 취약하다.

 

두번째,

쿠키에 저장하는 경우.

httpOnly 설정으로 자바스크립트에서 쿠키에 접근이 불가능하다. 그래서 XSS 공격에 안전하다.

하지만 쿠키는 자동으로 request에 실려 전송되기 때문에 100% 안전하다고 할 수 없다.

그리고 자동으로 전송된다는 특징때문에 사용자가 관련 링크를 누르도록 유도하면 위조하기가 쉬워진다.

그렇기 때문에 csrf 공격에 취약하다.

 

세번째,

RefreshToken은 쿠키에 저장하고 AccessToken은 자바스크립트 private variable에 저장한다.

매 요청마다 AccessToken이 재발급 되기 때문에 refreshToken이 탈취되더라도 공격자가 AccessToken을 알 수 없다.

CSRF 공격은 요청 위조로 사용자가 의도하지 않은 요청을 처리하도록 하는 공격방법이지 컴퓨터 자체를 제어할 수 없기 때문에 응답으로 오는 AccessToken은 알 수 없다.

또한 RefreshToken이 쿠키에 저장되어 있기 때문에 XSS 공격을 막을 수 있다.

 

 

이 세가지 방법중에서 가장 좋아보이는 방법은 세번째 방법이다.

근데 두번째 방법을 사용한 이유는

저 세번재 방법을 프로젝트를 모두 마무리한 다음에 발견했고, private variable을 아직 한번도 못써봤기 때문...

 

그래서 여기에 정리는 쿠키에 저장하는 방법으로 정리를 하고 세번째 방법으로 구현을 해본 뒤에

다시 정리 할 예정.

 

쿠키 방식은 csrf 공격에 취약하다고 했다.

그럼 그 csrf 공격을 어느정도 방어하기 위해서 필요한것은 same-site 설정이다.

동일 도메인 요청에 대해서만 해당 쿠키를 전송하도록 하는 설정이다.

로컬에서 테스트했으니 그걸 토대로 예시를 들자면

localhost:8080/ 이 도메인에서 발생하는 요청에 대해서만 쿠키가 전달되는 것이다.

localhost:9090/ 이 도메인에서 발생하는 요청에서는 쿠키가 전달되지 않는다.

 

또 csrf 방지에 대해 알아보면 가장 많이 나오는 방법이 referer 체크다.

요청이 온 페이지를 확인해 처리하는 방법이다.

보통이라면 host와 referer가 일치하기 때문에 이 둘을 비교하는 방법으로 체크할 수 있다.

public class RefererInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request
                             , HttpServletResponse response
                             , Object Handler) throws Exception {
        
        String referer = request.getHeader("Referer");
        
        if(referer == null || !referer.contains(request.getHeader("host"))
            new Exception();
        
    }
}

 

host는 포트 포함 서버의 도메인 네임이 나타나는 부분이고, referer는 이전 페이지의 주소가 담겨있기 때문에 확인할 수 있다.

 

그래서 same-site 설정과 referer 두가지 모두 사용하기로 했다.

사실 둘다 비슷한 조건의 설정이기 때문에 둘다 사용하는것이 의미가 있긴 한가 싶었지만 너무 딥하게 생각을 했는지 인터셉터에서 검증을 좀 더 심화해서 하는 방법으로 구현했다.

 

referer 체크를 할 때 애매하게 localhost:8080/** 형태가 아닌 정확한 url을 체크하는 방법이다.

 

예를들어 board의 상세 페이지 주소는 localhost:8080/board/boardDetail/3

이런 형태로 구성되어있다. 맨끝은 boardNo.

그럼 상세페이지에서는 글 수정, 삭제 기능이 이루어질 수 있다.

그럼 3번글을 삭제 요청을 하게 되면

localhost:8080/board/boardDelete/3

이 주소로 요청이 오게 될것이다.

 

이 요청에 대한 정상적인 referer는 localhost:8080/board/boardDetail/3 이어야 할것이고.

그래서 이걸 체크하기로 했다.

만약 게시글 삭제 요청이 들어왔다면 host인 8080/까지 유효한지 체크가 될것이고,

유효하다면 requestURL와 referer를 비교한다.

requestURL이 /board/boardDelete/3 으로 들어왔다면 referer가 /board/boardDeatil/3인지 확인하는것이다.

만약 referer가 /board/boardDetail/2라면 false를 리턴해 요청이 처리되지 않게 했다.

 

솔직히 하다가 중간에 너무 과한가 싶었지만 확실하게 하기에는 낫지 않나 싶은 생각으로 했다...

 

보안은 배울수록 신경써야 할게 많기 때문에 일단은 여기까지만 하고...

 

다시 JWT로 돌아가 정리.

 

그럼 토큰들을 쿠키에 저장하는데 있어서 보안 문제는 어느정도 해결을 했다.

 

쿠키 설정에서는 자바스크립트에서 접근하지 못하도록 httpOnly 설정을 걸어두고,

https로만 전송되도록 secure 설정과 동일 도메인에 대해서만 전송되도록 same-site 속성을 설정했다.

same-site의 경우는 Strict로 어차피 동일 도메인에서만 요청이 오는게 정상인 프로젝트이므로 가장 강한 속성으로 걸었다.

 

그럼 이제부터 코드정리.

 

 

코드 정리

//API Server - JwtProperties

public interface JwtProperties {
    
    //token secretKey
    String SECRET = "cocos";
    
    //AccessToken Expires. 1시간
    long ACCESS_EXPIRATION_TIME = 60000 * 60;
    
    //token prefix
    String TOKEN_PREFIX = "Bearer";
    
    //AccessToken Header
    String ACCESS_TOKEN_HEADER = "Authorization";
    
    //RefreshToken Header
    String REFRESH_TOKEN_HEADER = "Authorization_Refresh";
    
    //RefreshToken Expires. 2주
    long REFRESH_EXPIRATION_TIME = 60000 * 60 * 24 * 14;
}
//API Server - JwtDTO

@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
@Getter
public class JwtDTO {
    
    private String accessTokenHeader;
    
    private String accessTokenValue;
    
    private String refreshTokenHeader;
    
    private String refreshTokenValue;
}


//API Server - RefreshToken(Entity)

@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
@EqualsAndHashCode
@Getter
public class RefreshToken {
    
    @Id
    private String rtIndex;
    
    private String userId;
    
    private String tokenVal;
    
    private Date expires;
}


//API Server - RefreshDTO

@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToSTring
public class RefreshDTO {
    
    private String refreshIndex;
    
    private String tokenVal;
    
    private Date tokenExpires;
    
    private String originIndex;
}
//API Server - RefreshTokenRepository

public interface RefreshTokenRepository extends JapRepository<RefreshToken, String> {
    
    //Refresh재발급 후 DB 수정
    @Modifying
    @Transactional
    @Query(value = "UPDATE refreshToken " +
            "SET tokenVal = ?1" +
            ", expires = ?2" +
            ", rtIndex = ?3 " +
            "WHERE rtIndex = ?4"
    , nativeQuery = true)
    void patchToken(String refreshToken, Date refreshExpires, String rIndex, String originIndex);
    
    //tokenVal + rtIndex로 데이터가 존재한다면 userId를 반환
    @Query(value = "SELECT userId " +
            "FROM refreshToken " +
            "WHERE rtIndex = ?1 " +
            "AND tokenVal = ?2"
    , nativeQuery = true)
    String existsByRtIndexAndUserId(String rtIndex, String refreshTokenVal);
}
//API Server - TokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    
    private final RefreshTokenRepository refreshTokenRepository;
    
    //AccessToken 발급
    public String issuedAccessToken(String userId) {
        
        String accessToken = JWT.create()
                    .withSubject("cocoToken")
                    .withExpiresAt(new Date(System.currentTimeMillis() + 
                                    JwtProperties.ACCESS_EXPIRES_TIME))
                    .withClaim("userId", userId)
                    .sign(Algorithm.HMAC512(JwtProperties.SECRET));
        
        return accessToken;
    }
    
    //RefreshToken 발급 요청 후 DB에 저장
    public String issuedRefreshToken(String userId) {
        
        RefreshDTO dto = createRefreshToken();
        
        refreshTokenRepository.save(
                    RefreshToken.builder()
                        .rtIndex(dto.getRefreshIndex())
                        .userId(userId)
                        .expires(dto.getTokenExpires)
                        .tokenVal(dto.getTokenVal())
                        .build()
        );
        
        return dto.getTokenVal();
    }
    
    //RefreshToken 발급
    public RefreshDTO createRefreshToken() {
        
        StringBuilder sb = new StringBuilder();
        
        String rIndex = sb.append(new SimpleDateFormat("yyyyMMddHHmmss")
                                    .format(System.currentTimeMillis()))
                          .append(UUID.randomUUID()).toString();
        
        Date refreshExpires = new Date(System.currentTimeMillis() + 
                                        JwtProperties.REFRESH_EXPIRATION_TIME);
        
        String refreshToken = Jwt.create()
                    .withExpiresAt(refreshExpires)
                    .withClaim("refresh", rIndex)
                    .sign(Algorithm.HMAC512(JwtProperties.SECRET));
                    
        RefreshDTO refreshDTO = RefreshDTO.builder()
                    .refreshIndex(rIndex)
                    .tokenVal(refreshToken)
                    .tokenExpires(refreshExpires)
                    .build();
                    
        return refreshDTO;
    }
    
    //RefreshToken 재발급 요청 후 DB 수정
    public String reIssuedRefreshToken(String originIndex) {
        
        RefreshDTO dto = createRefreshToken();
        
        refreshTokenRepository.patchToken(dto.getTokenVal()
                                        , dto.getTokenExpires()
                                        , dto.getRefreshIndex()
                                        , originIndex);
        
        return dto.getTokenVal();
    }
    
    //AccessToken 검증 후 userId 리턴
    public String verifyAccessToken(Cookie accessToken) {
        
        String tokenVal = accessToken.getValue().replace(JwtProperties.TOKEN_PREFIX, "");
        
        String claimByUserId = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET))
                                .build()
                                .verify(tokenVal)
                                .getClaim("userId")
                                .asString();
                                
        return claimByUserId;
    }
    
    //RefreshToken 검증. rIndex와 tokenValue 리턴
    public Map<String, String> verifyRefreshToken(HttpServletRequest request) {
        
        Cookie refreshToken = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
        
        if(refreshToken == null || !refreshToken.getValue().startsWith(JwtProperties.TOKEN_PREFIX))
            return null;
            
        String refreshTokenVal = refreshToken.getValue().replace(JwtProperties.TOKEN_PREFIX, "");
        
        String rIndex = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET))
                            .build()
                            .verify(refreshTokenVal)
                            .getClaim("refresh")
                            .asString();
                            
        if(refreshTokenVal != null && rIndex != null) {
            Map<String, String> tokenMap = new HashMap<>();
            
            tokenMap.put("rIndex", rIndex);
            tokenMap.put("refreshTokenValue", refreshTokenVal);
            
            return tokenMap;
        }
        
        return null; 
    }
    
    //AccessToken, RefreshToken 전체 재발급
    public JwtDTO reIssuanceAllToken(Map<String, String> reIssuedData) {
        
        String userId = refreshTokenRepository.existsByRtIndexAndUserId(
                                reIssuedData.get("rIndex")
                                , reIssuedData.get("refreshTokenValue")
                        );
        
        if(userId != null) {
            JwtDTO dto = JwtDTO.builder()
                .accessTokenHeader(JwtProperties.ACCESS_HEADER_STRING)
                .accessTokenValue(JwtProperties.TOKEN_PREFIX 
                                + issuedAccessToken(userId))
                .refreshTokenHeader(JwtProperties.REFRESH_HEADER_STRING)
                .refreshTokenValue(JwtProperties.TOKEN_PREFIX 
                                + reIssuedRefreshToken(reIssuedData.get("rIndex")))
                .build();
                
            return dto;
        }
        
        return null;
    }
}

토큰 정보에 대해 하나하나 다 작성하다보면 오타도 생기고 찾기도 힘들어지니 JwtProperties를 만들어서 가져다 사용하는 방법을 택했다.

JwtDTO는 애플리케이션 서버에 두 토큰을 반환해주기 위한 DTO이고, RefreshToken은 DB에 저장이 되기 때문에 Entity를 생성했다.

RefreshToken이 재발급 되면 DB 데이터를 수정해줘야 하기 때문에 RefreshDTO를 만들어 담아서 처리하도록 했고,

Repository에서는 재발급 후 DB 데이터를 수정하는것과 토큰 검증 후 해당 값들이 DB에도 존재하는지 체크해주는 두가지를 만들어두었다.

 

TokenProvider는 토큰의 생성 및 재발급, 검증을 담당한다.

AccessToken은 재발급과 첫 발급의 차이가 존재하지 않기 때문에 하나만 존재하지만

RefreshToken은 첫 발급시 DB에 저장해야 하고, 재발급시에는 수정만 해주면 되기 때문에 따로 분리했다.

AccessToken의 만료시간은 1시간, RefreshToken의 만료시간은 2주이며 AccessToken을 재발급 해야 할 때 RefreshToken 역시 재발급 된다.

 

AccessToken이 만료되었을 때 두 토큰 모두 클라이언트와 서버에서 삭제를 하고 다시 로그인을 하도록 하는 방법이 있었고, 구현한것 처럼 두 토큰 모두 재발급을 받도록 하는 방법이 있었다.

전자의 경우 사용자가 로그인을 너무 자주하게 해야 한다거나 AccessToken 만료시간을 늘려 보안 위험이 생길 가능성이 있다고 생각해 후자로 선택하게 되었다.

 

 

 

그럼 애플리케이션 서버에서 요청을 보낼때 토큰을 어떻게 보내고 어떻게 재발급을 받아 처리하는지 정리한다.

간단하게 먼저 정리하면 애플리케이션 서버에서 사용자에게 요청이 들어오면 가장 먼저 토큰 여부를 체크한다.

Remember me를 구현하지 않은 이 프로젝트에서 RefreshToken이 없다면 무조건 비 로그인상태라고 볼 수 있기 때문에 이 경우는 로그인 페이지로 유도한다.

두 토큰 모두 정상적으로 존재한다면 당연히 사용자의 요청을 처리하게 될것이고.

그래서 AccessToken이 없다면 RefreshToken이 있어야 하고 이 조건을 만족한다면 API 서버에 RefreshToken을 보내 재발급 요청을 한다.

재발급 받은 토큰이 리턴되어 들어오면 토큰을 쿠키로 저장하고 이 재발급 받은 토큰들로 사용자의 요청을 처리하게 된다.

//Application server - JwtProperties

public interface JwtProperties {
    
    String ACCESS_HEADER_STRING = "Authorization";
    
    String REFRESH_HEADER_STRING = "Authorization_Refresh";
    
    //refreshToken cookie 만료시간 2주
    int REFRESH_MAX_AGE = 60 * 60 * 24 * 14;
    
    //accessToken cookie 만료시간 59분
    int ACCESS_MAX_AGE = 60 * 59;
    
    String LSC_HEADER_STRING = "lsc";
}


//Application server - JwtDTO

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class JwtDTO {
    
    private String accessTokenHeader;
    
    private String accessTokenValue;
    
    private String refreshTokenHeader;
    
    private String refreshTokenValue;
}
//Application Server - TokenServiceImpl

@Service
@RequiredArgsConstructor
public class TokenServiceImpl implements TokenService {
    
    private final WebClientConfig webClientConfig;
    
    //토큰 체크
    @Override
    public JwtDTO checkExistsToken(HttpServletRequest request, HttpServletResponse response) {
        
        //AccessToken
        Cookie at = WebUtils.getCookie(request, JwtProperties.ACCESS_HEADER_STRING);
        //RefreshToken
        Cookie rt = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
        
        if(at == null && rt != null) //RefreshToken은 존재하고 AccessToken만 만료된 상태라면
            return reIssuedToken(request, response); //두 토큰의 재발급을 요청
        else if(at != null && rt != null) //두 토큰 모두 존재한다면
            return JwtDTO.builder()                  //토큰 정보를 JwtDTO에 담아 리턴
                    .accessTokenHeader(at.getName())
                    .accessTokenValue(at.getValue())
                    .refreshTokenHeader(rt.getName())
                    .refreshTokenValue(rt.getValue())
                    .build();
                    
        return null;
    }
    
    //토큰 재발급 요청
    @Override
    public JwtDTO reIssuedToken(HttpServletRequest request, HttpServletResponse response) {
        
        WebClient client = webClientConfig.useWebClient();
        
        Cookie rt = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
        
        JwtDTO dto = client.post()
                    .uri(uriBuilder -> uriBuilder.path("/token/reissued").build())
                    .cookie(rt.getName(), rt.getValue()) //쿠키에 refreshToken만 담아 요청
                    .retrieve()
                    .bodyToMono(JwtDTO.class)
                    .block();
                    
        saveToken(dto, response); //토큰 저장 요청
        
        return dto;
    }
    
    //토큰을 쿠키에 저장
    @Override
    public void saveToken(JwtDTO jwtDTO, HttpServletResponse response) {
        
        //AccessToken Cookie
        ResponseCookie at = ResponseCookie.from(jwtDTO.getAccessTokenHeader()
                                               , jwtDTO.getAccessTokenValue())
                                          .path("/")
                                          .maxAge(JwtProperties.ACCESS_MAX_AGE)
                                          .httpOnly(true)
                                          .secure(true)
                                          .sameSite("Strict")
                                          .build();
        
        //RefreshToken Cookie
        ResponseCookie rt = ResponseCookie.from(jwtDTO.getRefreshTokenHeader()
                                               , jwtDTO.getRefreshTokenValue())
                                          .path("/")
                                          .maxAge(JwtProperties.REFRESH_MAX_AGE)
                                          .httpOnly(true)
                                          .secure(true)
                                          .sameSite("Strict")
                                          .build();
                                          
        //login check Cookie.
        ResponseCookie lsc = ResponseCookie.from(JwtProperties.LSC_HEADER_STRING
                                               , UUDI.randomUUID().toString())
                                          .path("/")
                                          .maxAge(JwtProperties.REFRESH_MAX_AGE)
                                          .build();
                                          
        response.addHeader("Set-Cookie", at.toString());
        response.addHeader("Set-Cookie", at.toString());
        response.addHeader("Set-Cookie", at.toString());
    }
    
    //모든 쿠키 삭제(로그아웃 시)
    @Override
    public void deleteCookie(HttpServletRequest request, HttpServletResponse response) {
        
        Cookie at = WebUtils.getCookie(request, JwtProperties.ACCESS_HEADER_STRING);
        Cookie rt = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
        Cookie lsc = WebUtils.getCookie(request, JwtProperties.LSC_HEADER_STRING);
        
        deleteCookieProc(at, response);
        deleteCookieProc(rt, response);
        deleteCookieProc(lsc, response);
    }
    
    //쿠키 삭제 처리
    public void deleteCookieProc(Cookie cookie, HttpServletResponse response) {
        
        cookie.setMaxAge(0);
        cookie.setPath("/");
        response.addCookie(cookie);
    }
}

애플리케이션 서버에서는 모든 요청이 들어오면 무조건 checkExistsToken을 통해 토큰 체크를 먼저 한다.

토큰이 필요없는 권한이 없어도 되는 페이지라도 로그인 여부를 확인하기 위해서 체크를 하게 된다.

로그인을 해 토큰을 발급받는 경우에도 여기서 saveToken을 통해 쿠키를 생성하게 된다.

재발급을 받은 후에나 토큰이 둘다 존재하는 경우 모두 JwtDTO에 담아 리턴하도록 했는데,

재발급을 받은 뒤 사용자 요청을 처리할 때 재발급 받은 토큰이 필요하기 때문에 DTO에 담아 리턴하도록 했다.

둘다 존재하는 경우에는 DTO에 굳이 담지 않아도 되지만 토큰이 있는것과 없는것의 차이가 발생할 수 있는 요청에 대해 구분해 처리하도록 하기 위해서 DTO에 담아 리턴하도록 했다.

예를들어 게시글 상세페이지에서 토큰여부에 따라 사용자 검증을 하고 게시글 수정, 삭제 등의 버튼을 출력한다거나 하는 차이가 발생할 수 있는데 토큰이 없다면 기능 버튼이 출력이 안되고 있다면 출력이 가능하도록 처리해야 한다.

그럼 여기서 재발급을 받은 경우에는 DTO에 담겨있을 것이고, 둘다 존재해서 DTO에 담지 않았다면 쿠키에 있을것이고, 로그인하지 않았다면 쿠키가 존재하지 않을것이다.

그럼 요청 전 토큰 여부를 확인할 때 쿠키를 체크하고 하나만 있다면 DTO를 다시 체크하고 하는 형태로 여러번 거쳐야 하기 때문에 아예 DTO에 담아서 DTO에 값이 있느냐 없느냐에 따라 처리하도록 하기 위함이었다.

 

saveToken에서 보면 두 토큰 말고 lsc라는 쿠키를 하나 더 생성하도록 하고 있는데 이건 로그인 여부를 확인하기 위한 임시 쿠키로 생성하게 되었다.

프로젝트 상단 네비게이션바에서 로그인 or 로그아웃을 출력하도록 하는데 lsc 쿠키가 존재하면 로그인을 한 상태로 로그아웃을 출력하도록 하기 위해 만들었다.

SpringSecurity로 프로젝트를 진행할때는 세션을 통해 확인할 수 있었지만 JWT를 사용하며 체크를 하려다보니 세션을 통해 처리할 수가 없었다.

CSRF 토큰까지 사용할겸 애플리케이션 서버에다가 security 적용해서 세션 통해 처리할까 하다가 그럼 굳이 JWT 사용해야할 이유도 없는것 같고... 해서 다른 방법을 좀 알아봤다.

근데 딱히 방법을 찾지 못해 고민한 내용들 중에서만 골라야 했다.

 

첫번째,

매 요청마다 리턴되는 데이터에 사용자 상태를 리턴해준다.(정보 x)

사용자에 대한 정보말고 현재 로그인한 사용자라는 임의의 데이터를 리턴해 그 데이터가 조건에 따라 로그인 여부를 출력해주도록 하는 방법.

이 방법을 사용하게 되면 모든 요청에서 해당 데이터를 처리해 리턴해줘야 한다.

 

두번째,

RefreshToken을 localStorage에 저장하고 페이지 로딩시마다 이걸 체크해 처리한다.

근데 그럼 RefreshToken이 탈취되기 쉽다.

 

세번째,

localStorage에 담으면 탈취되기 쉬우니까 그냥 임의의 쿠키를 하나 만들어 해당 쿠키가 존재하면 로그인을 했다고 판단하여 처리.

쿠키값은 난수이기 때문에 탈취되더라도 문제가 없고 페이지 로딩 시 이 쿠키가 존재하는지 체크해주면 된다.

하지만 요청에 자동으로 실려가는 쿠키의 특성 상 요청마다 불필요하게 같이 전송되어야 하긴 하다.

또한, 괜한 쿠키를 하나 더 생성하는 꼴이 된다.

 

이렇게 세가지 중에서 고민을 많이 했다.

두번째는 사실 떠오르기만 했지 사용은 못하겠다 싶었었고 첫번째와 세번째를 계속 고민하다가 세번째 방법을 택하게 되었다.

쿠키를 더 늘리면 안된다는 조건이 붙었다거나 사용해서는 안된다는 조건이 붙지 않는다면

첫번째 방법보다는 세번째 방법이 좀 더 빠르고 편하게 처리할 수 있지 않을까 싶어서 였다.

첫번째 방법을 만족하기 위해서는 모든 요청에 대해 API서버에서 토큰을 받아야 한다.

하지만 게시글 목록 같은데에서는 굳이 API서버에 토큰을 보내 검증처리를 하도록 해야 할 필요가 없다.

그래서 불필요한 처리가 늘어나겠거니 싶어서 세번째 방법을 택하게 되었다.

 

lsc 쿠키의 만료시간은 refreshToken과 같은 시간을 갖게 되고 토큰 재발급시에는 refreshToken 쿠키의 만료시간이 수정되기 때문에 lsc 쿠키 역시 만료시간을 수정하도록 했다.

또한 로그아웃시 이 쿠키 역시 삭제되도록 처리했다.

 

 

그럼 이제 로그인처리를 정리.

 

처음에는 SpringSecurity로 만들어진 프로젝트이기도 하고 이전에 JWT 적용해서 만들때는 단일 서버로 처리했으니 필터만 추가해놓으면 security에서 알아서 요청 가로채 처리하니 문제가 없었다.

그리고 이 프로젝트를 수정하면서도 거기에 대해서 아무런 생각도 없이 그냥 그렇게 만들었다.

그랬더니??

당연히 클라이언트에 리턴을 안한다..

요청을 가로채서 추가한 필터 다 처리하고 로그인 처리 한 다음에 그냥 끝내버리니까..

 

그래서 요청을 가로채도록 하는게 아니라 컨트롤러에서 요청을 받도록 하고 서비스단에서 처리를 한 뒤 토큰을 리턴해주도록 했다.

// API server - member(Entity)

@Entity
@Getter
@ToString
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
    
    @Id
    private String userId;
    
    private String userpw;
    
    private String userName;
    
    @OneToMany(mappedBy = "userId", fetch = FetchType.EAGER, cascade = CascadeType.REMOVE)
    @ToString.Exclude
    private List<Auth> auths;
    
    public void setUserId(String userId) {
        this.userId = userId;
    }
}

//API server - Auth(Entity)

@Entity
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConsructor
@Builder
public class Auth {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long authNo;
    
    private String userId;
    
    private String auth;
}
//API server - CorsConfig

@Configuration
public class CorsConfig {
    
    @Bean
    public CorsFilter corsFilter() {
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        
        CorsConfiguration config = new CorsConfiguration();
        
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        
        source.registerCorsConfigurtaion("/**", config);
        
        return new CorsFilter(source);
    }
}



//API server - JwtAuthorizationFilter
//서버로 들어오는 모든 요청을 가로채서 토큰 검증

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    
    private final MemberRepository memberRepository;
    
    private final JwtTokenProvider jwtTokneProvider;
    
    public JwtAuthorizationFilter(AuthenticationManager authenticationManger
                                  , MemberRepository memberRepository
                                  , JwtTokenProvider jwtTokenProvider) {
        
        super(authenticationManager);
        this.memberRepository = memberRepository;
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request
                                    , HttpServletResponse response
                                    , FilterChain chain)
                                    throws IOException, ServletException {
        
        Cookie jwtCookie = WebUtils.getCookie(request, JwtProperties.ACCESS_HEADER_STRING);
        
        //cookie가 존재하지 않거나 cookie값의 시작이 Bearer로 시작하지 않으면 검증을 하지 않고 넘김.
        if(jwtCookie == null || !jwtCookie.getValue().startsWith(JwtProperties.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        
        String username = jwtTokenProvider.verifyAccessToken(jwtCookie);
        
        if(username != null) {
            Member memberEntity = memberRepository.findByUserId(username);
            
            CustomUser customUser = new CustomUser(memberEntity);
            
            Authentication authentication = 
                new UsernamePasswordAuthenticationToken(customUser, null, customUser.getAuthorities());
            
            SecurityContextHolder.getContext().setAuthentication(authentication);
            
            chain.doFilter(request, response);
        }
    }
}



//API server - SecurityConfig

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final CorsFilter corsFilter;
    
    private final MemberRepository memberRepository;
    
    private final JwtTokenProvider jwtTokenProvider;
    
    private AuthenticationManager authenticationManager;
    
    private final AuthenticationManagerBuilder localConfigureAuthenticationBldr;
    
    private boolean authenticationManagerInitialized;
    
    private boolean disableLocalConfigureAuthenticationBldr;
    
    private final AuthenticationConfiguration authenticationConfiguration;
    
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .antMatchers("/token/reissued");
    }
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                    .addFilter(corsFilter)
                    .formLogin().disable()
                    .httpBasic().disable()
                    .logout().logoutSuccessHandler(logoutSuccessHandler())
                .and()
                    .addFilter(new JwtAuthorizationFilter(authenticationManager()
                                                          , memberRepository
                                                          , jwtTokenProvider))
                    .authorizeRequests()
                    .antMatchers("/", "/resources/**")
                    .permitAll();
                    
        return http.build();
    }
    
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        
        if(!this.authenticationManagerInitialized) {
            this.configure(this.localConfigureAuthenticationBldr);
            
            if(this.disableLocalConfigureAuthenticationBldr)
                this.authenticationManager = this.authenticationConfiguration.getAuthenticationManager();
            else
                this.authenticationManager = (AuthenticationManager) this.localConfigureAuthenticationBldr.build();
            
            this.authenticationMangerInitialized = true;
        }
        
        return this.authenticationManager;
    }
    
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        this.disableLocalConfigureAuthenticationBldr = true;
    }
}

이렇게 이전 JWT 포스팅에서 정리했듯이 SecurityConfig를 작성하고 CorsFilter와 JwtAuthorizationFilter를 추가해줬다.

이 필터들은 JWT 포스팅에서 정리하기도 했고 로그인에는 굳이 간섭되지 않기 때문에 여기서는 따로 정리 안함.

 

SecurityConfig에서 설정한것으로 로그인요청이 가로채지지 않고 직접 처리된다 정도.

 

그럼 이제 로그인에 진짜 필요한 코드들 정리.

//API server - CustomUser

@Getter
public class CustomUser extends User {
    
    private Member member;
    
    public CustomUser(String username
                      , String password
                      , Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
    }
    
    public CustomUser(Member member) {
        super(member.getUserId(), member.getUserPw(), member.getAuths().stream().map(auth ->
                new SimpleGrantedAuthority(auth.getAuth())).collect(Collectors.toList()));
                
        this.member = member;
    }
}



//API server - CustomUserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService {
    
     @Setter(onMethod_ = {@Autowired})
     private MemberRepository repository;
     
     @Override
     @Transactional
     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
         Member member = repository.findByUserId(username);
         
         return member == null ? null : new CustomUser(member);
     }
}



//API server - MemberController

@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
    
    private final MemberService memberService;
    
    @PostMapping("/login")
    public ResponseEntity<JwtDTO> loginProc(@RequestBody Member member
                                            , HttpServletRequest request)
                                            throws Exception {
    
        return new ResponseEntity<>(memberService.memberLogin(member), HttpStatus.OK);
    }
}



//API server - MemberServiceImpl

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
    
    private final PasswordEncoder passwordEncoder;
    
    private final MemberRepository memberRepository;
    
    private AuthenticationManager authenticationManager;
    
    private final JwtTokenProvider tokenProvider;
    
    
    @Override
    public JwtDTO memberLogin(Member member) {
        
        UsernamePasswordAuthenticationToken authenticationToken = 
                new UsernamePasswordAuthenticationToken(member.getUserId(), member.getUserPw());
        
        Authentication authentication = 
                authenticationManager.authenticate(authenticationToken);
        
        CustomUser customUser = (CustomUser) authenticateion.getPrincipal();
        
        String uid = customUser.getMember().getUserId();
        
        String accessToken = tokenProvider.issuedAccessToken(uid);
        
        String refreshToken = tokenProvider.issuedRefreshToken(uid);
        
        JwtDTO dto = JwtDTO.builder()
                        .accessTokenHeader(JwtProperties.ACCESS_HEADER_STRING)
                        .accessTokenValue(JwtProperties.TOKEN_PREFIX + accessToken);
                        .refreshTokenHeader(JwtProperties.REFRESH_HEADER_STRING)
                        .refreshTokenValue(JwtProperties.TOKEN_PREFIX + refreshToken)
                        .build();
                        
        return dto;
    }   
}

애플리케이션 서버에서 로그인 요청을 보내면 AuthorizationFilter에서 일단 요청을 가로채 토큰 여부를 검사한다.

하지만 토큰이 없으니 토큰 검증을 하지 않고 리턴을 하지만 로그인 요청은 권한이 필요한 요청이 아니기 때문에 계속 처리를 진행하게 된다.

컨트롤러에서는 memberLogin을 호출해 로그인 처리를 진행한다.

서비스단에서는 UsernamePasswordAuthenticationToken을 생성하고 이걸 authencationManager.authenticate()에 담아 요청 정보를 체크하게 된다.

authenticate에서는 넘겨받은 데이터인 요청이 들어온 아이디와 비밀번호를 갖고 UserDetailsService를 호출해 로그인을 시도한다.

정상적으로 처리가 되었다면 다시 서비스단으로 빠져나오게 되고 authentication에서 사용자 정보를 꺼내 CustomUser타입으로 담아둔다.

AccessToken에서는 사용자 아이디가 필요하기 때문이다.

그럼 CustomUser 타입으로 담아둔 데이터에서 아이디를 꺼내서 그걸로 토큰을 생성하고 

생성된 토큰은 JwtDTO에 담아 리턴하게 된다.

그럼 컨트롤러에서도 이걸 그대로 애플리케이션 서버로 리턴해준다.

 

//Application server - MemberWebClient

@Service
@RequiredArgsConstructor
public class MemberWebClient {
    
    private final WebClientConfig webClientConfig;
    
    private final TokenService tokenService;
    
    public int loginProc(Map<String, String> loginData
                         , HttpServletRequest request
                         , HttpServletResponse response)
                         throws JsonProcessingException {
        
        WebClient client = webClientConfig.useWebClient();
        
        Member member = Member.builder()
                    .userId(loginData.get("userId"))
                    .userPw(loginData.get("userpw"))
                    .build();
                    
        JwtDTO responseVal = client.post()
                    .uri(uriBuilder -> uriBuilder.path("/member/login").build())
                    .bodyValue(member)
                    .retrieve()
                    .onStatus(
                        HttpStatus::is4xxClientError, clientResponse ->
                            Mono.error(
                                new CustomNotFoundException(ErrorCode.USER_NOT_FOUND)
                            )
                    )
                    .bodyToMono(JwtDTO.class)
                    .block();
                    
        if(responseVal != null) {
            tokenService.saveToken(responseVal, response);
            return 1;
        }else{
            return 0;
        }
    }
}

 

JwtDTO 타입으로 리턴을 받아온 애플리케이션 서버는 saveToken을 통해 리턴받은 토큰을 클라이언트에 쿠키로 저장하도록 해서 정상적으로 로그인 처리를 마무리한다.

 

 

 

REST 방식의 프로젝트에서 JWT를 적용하기가 생각보다 어려웠다.

REST도 공부하는 중이라 아직 어렵다는것도 한몫 했겠지만 토큰 관리를 고민하는데에서 시간을 엄청 소모했다.

단일 서버에서 JWT를 적용해 구현하는것과 차이도 컸고 그로인해 발생하는 또 다른 문제점들을 해결하는데도 생각나는대로 적용해보기에는 보안과 처리과정에 따른 성능 이슈를 고려해야 하는 부분이 너무 많았다.

아주 작은 미니프로젝트였지만 그래도 RESTAPI와 JWT를 활용하는데 있어서 조금은 더 배울 수 있었지 않았나 싶다.

RESTAPI를 만들어보려고 기존 프로젝트를 리펙토링하면서 api 서버에 접근하기 위해 WebClient를 사용해보게 되었다.

선택지는 HttpURLConnection, WebClient, RestTemplate 이 세가지가 있었다.

 

검색했을 때 가장 많이 볼 수 있었던것은 RestTemplate이었지만 Spring 5.0부터는 WebClient를 사용하도록 권장하고 있기 때문에 WebClient로 결정하게 되었다.

'RestTemplate이 Deprecated되었다' 라고 많이 볼 수 있었는데 아예 Deprecated 된 것은 아니었다.

Spring 깃에서 20년 2월 11일자 이슈를 보면

이런 내용을 확인할 수 있다.

'RestTemplate 향후 사용 중단 가능성에 대해 언급하는것 보다는 유지관리 모드에 있음을 설명하는것이 더 유용하고 정확하다.' 라는 내용이다.

 

즉, Deprecated 된 것이 아니라 유지보수만 하겠다는 것이다.

직접 찾아보진 못했지만 스프링에서 직접적으로 Deprecated를 언급한 적이 있다고 한다.

그 날짜가 20년 2월 12일인데

'As of 5.0, the non-blocking, 

reactive org.springframework.web.reactive.client.WebClient offers a modern alternative to the RestTemplate with efficient support for both sync and async, as well as streaming scenarios.
The RestTemplate will be deprecated in a future version and will not have major new features added going forward.
See the WebClient section of the Spring Framework reference documentation for more details and example code.'

이런 내용이었다.

아마 이 내용으로 인해 Deprecated 된다는 소문이 퍼지면서 저 이슈를 다시 작성하지 않았을까 싶다.

 

중요한건 지금도 RestTemplate은 Deprecated되지 않았고 아직 언급이 없다는 점이다.

그럼에도 WebClient로 해본 이유는 '스프링에서 권장하고 있으니 먼저 써보자 '라는 생각이었다.

물론 RestTemplate도 Deprecated가 아직은 될 예정이 없으니 써볼 예정.

 

HttpURLConnection은 결과값을 받아올 때 Stream으로 직접 하나하나 처리해야 하고 이것저것 설정해야 하는 게 많아 보여 일단은 가장 마지막에.....

 

 

WebClient란

웹으로 API를 호출하기 위해 사용되는 Http Client 모듈 중 하나이다.

 

WebClient는 Non-blocking 방식이다.

Non-blocking이라고 해서 비동기(Asynchronous)를 의미하는 것은 아니다.

크게 보자면 Non-blocking은 요청하고 딴일 하다가 응답이 오면 결과를 처리하는 방식이고

비동기 역시 요청 후 딴일 하다가 응답이 오면 처리하는 방식이다.

 

하지만 조금만 깊게 들어가면 비동기방식은 conneciton이 끊어지고 서로간에 이벤트를 통해 통신하는 방식으로 요청자와 제공자 사이에서 Message Broker라는 서비스가 중계해주게 된다.

 

참고했던 블로그 포스팅에서는 동기(Synchronous)와 비동기(Asynchronous)를 이렇게 정리해주셨다.

  • 호출'된' 함수의 수행결과 및 종료를 호출'한' 함수가(호출된 함수뿐 아니라 호출한 함수도 함께) 신경을 쓰고 있다면 Synchronous
  • 호출'된' 함수의 수행결과 및 종료를 호출'된' 함수 혼자서만 직접 신경쓰고 처리한다면 Asynchronous이다.

그럼 blocking과 Non-blocking은?

  • 호출'된' 함수가 자신이 할 일을 모두 마칠때까지 제어권을 갖고 호출'한' 함수에게 돌려주지 않는다면 block
  • 호출'된' 함수가 자신이 할 일을 마치지 않았지만 바로 제어권을 건네주어(return) 호출'한' 함수가 다른 일을 진행하도록 해주면 Non-block이다.

처음 이 포스팅을 보면서는 뭔가 말장난인가... 싶은 느낌도 있었다.

좀 정리를 다시 해보면 blocking과 Non-blocking은 제어권을 어디에서 갖고 있느냐에 따른 구분이기 때문에 요청 후 다른 처리를 하느냐 마느냐의 차이이고,

synchronous과 Asynchronous는 요청자가 처리를 계속해서 신경을 쓰고 있느냐 안쓰고 있느냐 라는 개념으로 이해하면 좀 편하다.

 

좀 더 상세하게 정리를 해주셨으니 참고.

https://happycloud-lee.tistory.com/220

 

Spring WebClient 쉽게 이해하기

1. Spring WebClient 이해 이 글을 읽기 전에 먼저 일하는 방식 변화를 이끌고 있는 애자일, 마이크로서비스, 데브옵스, 클라우드에 대해 기본적인 이해를 하실것을 권장 합니다. https://happycloud-lee.tisto

happycloud-lee.tistory.com

 

 

WebClient 사용 코드

일단 WebClient를 적용해보면서 가장 많이 보게 된 사이트는 baeldung이었다.

 

https://www.baeldung.com 

 

Baeldung

In-depth, to-the-point tutorials on Java, Spring, Spring Boot, Security, and REST.

www.baeldung.com

 

WebClient뿐만 아니라 REST, Security, OAuth 등등 여러가지가 정리되어있어 참고하기 좋았다.

이번에는 사용예제가 정리된 블로그 포스팅보다는 아무래도 여기가 더 도움이 많이 되었다.

 

프로젝트는 기본 게시판 형태로 이미지파일을 업로드할 수 있는 게시판과 일반 게시판 두가지를 갖고 있는 작은 프로젝트다.

 

환경은 아래와 같다.

  • Spring boot
  • Gradle
  • JDK 1.8
  • JWT
  • Spring Data JPA

API서버와 Client 서버를 분리해서 프로젝트를 진행했고 한대의 데스크탑에서 처리했기 때문에

API서버의 경우 localhost:9095, Client 서버의 경우 localhost:8080으로 포트만 다르게 해서 진행했다.

 

WebClient 사용 예제는 아래처럼 정리한다.

  1. WebClient 사용법(인스턴스 생성)
  2. get(), post()요청 및 Query String, PathVariable, Multipart

 

일단 WebClient를 사용하기 위해서는 Dependency를 추가해야 한다.

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-webflux 

    implementation 'org.springframework.boot::spring-boot-starter-webflux:2.7.6'

 

WebClient의 가장 기본적인 사용법

import org.springframework.web.reactive.function.client.WebClient;

....
	String response = WebClient.builder()
            .baseUrl("http://localhost:9095")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .build()
                .get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                        .queryParam("pageNum", 1)
                        .build())
                .retrieve()
                .bodyToMono(String.class)
                .block();

이 코드는 API서버에 board-list를 요청한 경우인데 WebClient. ~~ .build()까지가 WebClient를 설정하는 부분이다.

그리고 build다음으로 .get()이 요청 형태 그 뒤로 uri와 응답받는 타입 등의 요청에 대한 설정을 하고 block()으로 마무리해준다.

분리해서 아래처럼 처리할 수도 있다.

 

import org.springframework.web.reactive.function.client.WebClient;

...
    
    WebClient client = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .build();
                    
    String response = client.get()
                        .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                                .queryParam("pageNum", 1)
                                .build())
                        .retrieve()
                        .bodyToMono(String.class)
                        .block();

 

이런식으로 WebClient 인스턴스를 생성하고 그걸 가져다 사용하는 형태로 사용할 수 있다.

또한 아무런 설정도 없이 인스턴스만 생성하는 것이 가능하다.

 

WebClient client1 = WebClient.create();
WebClient client2 = WebClient.create("http://localhost:9095");

 

이렇게 아주 간단하게만 생성해두고 나머지는 이 인스턴스를 사용할 때 속성을 추가해서 상황에 맞게 사용할 수도 있다.

이번 프로젝트는 굳이 이렇게 막 여러 상황에 대한 처리를 할만한게 없었어서 이렇게 간단하게 생성하는 방법으로는 하지 않았고 서비스별로 나눠서 작성했기 때문에 WebClientConfig라는 컴포넌트를 만들어 분리해서 사용했다.

 

//WebClientConfig
import org.springframework.stereotype.Component;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class WebClientConfig {
    
    public WebClient useWebClient() {
        
        WebClient webClient = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .build();
        
        return webClient;
    }
}


//service

...

@Service
@RequiredArgsConstructor
public class HierarchicalBoardWebClient {
    
    private final WebClientConfig webClientConfig;
    
    public HierarchicalBoardListDTO getHierarchicalBoardList(Criteria cri) 
                                            throws JsonProcessingException {
        
        WebClient client = webClientConfig.useWebClient();
        
        String response = client.get()
                            .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                                    .queryParam("pageNum", cri.getPageNum())
                                    .queryParam("amount", cri.getBoardAmount())
                                    .build())
                            .retrieve()
                            .bodyToMono(String.class)
                            .block();
        
        ...
    }
    
    ...
}

 

하나의 API서버에만 요청하고 받고 처리하기 때문에 기본적인 baseUrl은 9095까지 적어주어 서비스단에서 사용할때는 필요한 위치의 요청만 작성하면 되도록 했다.

이렇게 하면 모든 서비스단에서 WebClient 인스턴스를 생성할때 매번 설정하지 않아도 되니 코드 중복도 해결되고 간단하게 처리할 수 있다.

 

 

get(), post() 요청 및 Query String, PathVariable, Multipart

각 요청을 어떻게 하는지에 대해 정리한다.

API서버에 요청을 하는 방식은 get, post, patch, delete 이렇게 구분할 수 있다.

get, post, put, delete 이렇게 처음에는 배우긴 했는데 put은 리소스 전체를 업데이트할때, patch는 일부를 업데이트할때 사용하는것으로 현재 프로젝트의 경우 update처리에 대해서 전체를 업데이트하는 경우는 전혀 없기 때문에 patch만 사용했다.

근데 put이나 patch나 요청하는 방법에 대해서는 별반 차이가 없어서 큰 문제는 안될것으로 보인다.

복잡하게 요청하는 경우라면 좀 다를수도 있겠지만...

 

get 요청에서 많이 사용하는 방법이 Query String과 PathVariable 이 두가지가 있다.

아무런 매개변수가 없이 get 요청만 보내서 받는 경우도 있지만 그렇지 않은 경우도 있다.

프로젝트에서 게시판은 페이징 기능을 처리하기 때문에 쿼리스트링이 필요한 경우가 있었고,

게시판 내용을 보는 상세페이지에서는 글번호를 PathVariable로 받아 처리했기 때문에 필요했다.

 

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
...

@Service
@RequiredArgsConstrucotr
public class HierarchicalBoardWebClient {

    private final WebClientConfig webClientConfig;
    
    public HierarchicalBoardListDTO getHierarchicalBoardList(Criteria cri)
                                    throws JsonProcessingException {
        WebClient client = webClientConfig.useWebClient();
        
        // QueryString & header
        String response1 = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list")
                        .queryParam("pageNum", cri.getPageNum())
                        .queryParam("amount", cri.getBoardAmount())
                        .build())
                .header("headerName", "headerValue")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        //아무것도 없이 그냥 get요청 하는 경우
        String response2 = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-list").build())
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        
        ObjectMapper om = new ObjectMapper();
        
        om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        
        HierarchicalBoardListDTO dto;
        
        dto = om.readValue(response1, HierarchicalBoardListDTO.class);
        
        dto.setPageDTO(new PageDTO(cri, dto.getTotalPages()));
        
        return dto;
    }
    
    public HierarchicalBoardDetailDTO getHierarchicalBoardDetail(long boardNo)
                                                     throws JsonProcessingException {
        WebClient client = webClientConfig.useWebClient();
        
        //PathVariable & cookie
        String response = client.get()
                .uri(uriBuilder -> uriBuilder.path("/board/board-detail/{boardNo}")
                        .build(boardNo))
                .cookie("cookieName", "cookieValue")
                .retrieve()
                .bodyToMono(String.class)
                .block();
        
        ObjectMapper om = new ObjectMapper();
        
        HierarchicalBoardDetailDTO dto = om.readValue(responseVal, HierarhicalBoardDetailDTO.class);
        
        return dto;
    }
}

위 메소드는 게시판 리스트 요청, 아래 메소드는 상세페이지 데이터 요청이다.

게시판 리스트 요청에서 사실 pageNum과 amout없이 넘기는 일은 없긴한데 쿼리스트링을 쓸때랑 안쓸때를 비교하기 위해 나눠두었다.

쿼리스트링을 사용하는 경우 path() 뒤로 queryParam()으로 추가를 해주면되고 하나하나 추가하기 귀찮다면

MultiValueMap 타입으로 정리해 queryParams()로 보내는 방법도 있다.

위 코드에 보이는대로 queryParam의 경우 (name, value)로 작성해 요청을 보내면 된다.

 

아래 메소드에서는 PathVariable로 받는 경우인데 요청 uri의 경우 그대로 작성을 해주고 build()에 값을 넣어 넘겨주는 형태로 처리한다.

 

더 다양하고 복잡한 속성들도 존재하지만 기본적인 요청에 대해서는 이렇게 처리하는것으로 응답을 받을 수 있다.

 

이 프로젝트에서는 JWT로 인증, 인가 처리를 하도록 했는데 그것때문에 header, cookie 옵션 역시 기록한다.

JWT의 경우 요청헤더에 담아 받거나 쿠키에 담아 받는데 클라이언트 서버에서 API 서버로 요청할때 위 처럼 보내줄 수 있다.

굳이 JWT가 아니더라도 cookie나 RequestHeader에 담아 보내야 하는 데이터가 있다면 위 처럼 보내주면 된다.

보이는 그대로 보내주면 되고 JWT라고 가정한다면

.header("Authorization", "tokenValue")  ||  .cookie("Authorization", "tokenValue")

이런 형태로 담아서 보내주면 된다.

header와 cookie 속성 역시 하나만 보낼 수 있는것이 아니기 때문에 아래에 더 추가해서 보낼 수 있고

역시 headers와 cookies로 MultiValueMap으로 처리해 보낼 수 있다.

map으로 보내게 된다면 queryParam과는 조금 다르게 처리해야 하는데 consumer를 통해 보내야 한다.

consumer는 이번에 처음 봤는데 함수적 인터페이스로 단지 매개값을 소비하는 역할만을 한다고 한다.

 

 

 

다른 요청을 정리하기 전에 남은 속성들과 코드를 정리.

 

retrieve()는 ResponseEntity를 받아 디코딩하는 경우에 사용한다.

이 위치에 retrieve() 대신 exchange()를 사용할 수 있는데 exchange는 retrieve보다 더 많은 기능을 제공하고 모든 시나리오에서 application이 직접 ResponseBody를 consume해야 한다.

요약하면 retrieve() 보다 exchange()를 사용하는게 더 복잡하다.

그렇기때문에 exchange()를 사용해서 응답코드나 응답헤더를 봐야한다거나, 아니면 직접 응답을 consume해야 한다거나 하는 특별한 이유가 없다면 retrieve()를 사용해서 처리하면 된다.

 

 

bodyToMono()에서는 응답을 받는 타입을 설정해준다.

bodyToMono()말고 bodyToFlux()도 있는데 Mono의 경우 0 ~ 1개의 결과를 처리하는 경우에 사용하고

Flux는 0 ~ N개의 결과를 처리하는 경우에 사용하면 된다.

 

프로젝트를 진행하면서는 JSON으로 다 정리된 상태로 받았기 때문에 Mono만 사용해 처리해봤고 대부분 String으로 받아와서 처리했다.

받는 타입은 크게 상관이 없는데 응답으로 넘어오는 데이터와 동일한 DTO가 존재한다면 해당 DTO.class로 받을 수도 있다.

또한 리턴 결과를 Long 타입으로 처리하는 경우도 있었어서 Long으로 받아서 처리한 경우도 있다.

 

마지막 block()은 blocking을 하겠다 라고 보면된다. 즉, 완전한 비동기 방식이 아니다.

처음에 Non-blocking과 Asynchronous의 차이를 설명한 이유이다.

block()을 설정하게 되면 결국 결과가 반환될때까지 기다렸다가 다음으로 넘어가게 된다.

근데 이렇게 사용한 이유는 프로젝트에서는 그냥 결과값을 매핑해 응답을 반환하는 형태라서 크게 문제가 되지 않기 때문이다.

하지만 만약 결과값을 다시 어떠한 알고리즘에 처리하는 과정이 추가가 된다면 비동기라고 부를수가 없다.

block()말고도 toStream()이라는 속성도 있는데 이 속성 역시 마찬가지로 대기하게 되므로 문제 해결이 되지 않는다.

이걸 완전한 비동기 방식으로 사용하려면 subscribe() 속성을 사용하면 되는데 이걸 사용하게 되면 요청 후 응답이 오지 않았더라도 비동기 처리로 다음 요청을 수행하게 되고 응답이 도착하면 그에 대한 처리를 수행하게 된다.

subscribe()의 경우 사용해보지 않아서 잘 정리해주신 블로그를 아래에..

 

https://tecoble.techcourse.co.kr/post/2021-10-20-synchronous-asynchronous/

 

동기와 비동기 with webClient

‘여기서 만나’ 프로젝트를 진행하면서 초반에는 RestTemplate을 통하여 외부 API와 데이터를 주고받았다. 이후 WebClient가 비동기 방식으로 쓰여 더 좋다고 하여 WebClient로 변경했다. 그리고 이전 글

tecoble.techcourse.co.kr

 

남은건 요청이 아닌 응답 데이터를 처리해주는 ObjectMapper이다.

응답받은 json을 dto로 파싱해주는 방법은 다양한데 가장 많이 보이기도 하고 편하게 파싱이 되는 ObjectMapper를 사용했다.

ObjectMapper의 경우 .readValue(응답데이터, 원하는DTO.class) 이렇게 작성하는것으로 간단하게 파싱이 가능하다.

그러나 만약 리턴되는 데이터가 상황에따라 굳이 DTO에는 안들어가도 되는 경우가 있을 수 있다.

이 프로젝트의 경우는 그게 페이징 데이터였다.

API서버에서 게시판 리스트를 가져올때 Page 타입으로 데이터를 꺼내 그걸 리턴해주도록 했는데 그러다보니 불필요한 페이징 데이터가 많았다. 중복되는 데이터도 많았고.

그래서 클라이언트 DTO에는 그런 데이터들을 아예 작성하지 않았다. 쓸일도 없었기 때문에.

물론 API 서버에서 미리 걸러내서 응답해준다면 더욱 좋겠지만 아쉽게도 JPA는 아직 한창 공부중이라서 방법을 찾을 수 없었고 해결하지 못해 클라이언트서버에서 해결해야 했다.

겸사겸사 상황에 따라 다른 DTO에 받는다면 이런 경우도 있겠지 하면서 사용하게 되었다.

 

그 설정은 .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 이다.

파싱하고자 하는 json 데이터에는 존재하지만 이 데이터를 정리할 dto에는 해당 필드가 존재하지 않는다면 그것을 무시하고 존재하는 필드에만 파싱을 하도록 하는 설정이다.

이 설정을 해주지 않고 그냥 처리한다면 Exception이 발생한다.

사용해보진 않았지만 비슷한 옵션으로 null 값을 무시하는 옵션도 있다.

.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false); 이다.

이 옵션들 둘다 true로 설정하면 json데이터가 모두 dto에 들어갈 수 있어야 하며 null값도 있으면 안된다.

당연히 옵션 설정 따로 안하면 default가 true이다.

 

 

그럼 다음은 post요청이다.

post 요청의 경우 DTO에 데이터를 담아 요청하는 방법과 Multipart를 담아 요청하는 두가지 방법이 있다.

다른 방식으로 요청을 보내는 케이스는 따로 찾아볼 수 없어서 이 두가지 방법만 사용해봤다.

 

일단은 기본적인 post 요청을 먼저 정리.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
...

@Service
@RequiredArgsConstrucotr
public class HierarchicalBoardWebClient {

    private final WebClientConfig webClientConfig;
    
    public long boardInsert(HttpServletRequest request, HttpServletResponse response) {
        
        WebClient client = webClientConfig.useWebClient();
        
        HierarchicalBoardDTO dto = HierarchicalBoardDTO.builder()
                    .boardTitle(request.getParameter("boardTitle"))
                    .boardContent(reqeuest.getParameter("boardContent"))
                    .build();
                    
        /*
            Mono<HierarchicalBoardDTO> dto2 = HierarchicalBoardDTO.builder()
                        .boardTitle(request.getParameter("boardTitle"))
                        .boardContent(request.getParameter("boardContent"))
                        .build();
        */
        
        return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .body(Mono.just(dto), HierarchicalBoardDTO.class)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        /*
            return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .body(dto2, HierarchicalBoardDTO.class)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        */
        
        /*
            return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/board/board-insert").build())
                    .accept()
                    .bodyValue(dto)
                    .retrieve()
                    .onStatus(
                            HttpStatus::is5xxServerError, clientResponse ->
                                    Mono.error(
                                            new NullPointerException()
                                    )
                            )
                    )
                    .bodyToMono(Long.class)
                    .block();
        */
    }
}

post 요청의 경우 이런 형태로 요청할 수 있다.

return에서 보면 주석포함 총 3가지 방식으로 리턴하고 있는데, 모두 사용할 수 있는 방법이다.

차이를 보자면 body에 어떻게 담아서 처리하는지, 아니면 bodyValue를 사용해 처리하는지의 차이다.

body를 활용하는 경우는 비동기 타입으로 인코딩해야 하는 경우이다.

dto만 먼저 보면 HierarchicalBoardDTO 타입으로 생성했다.

이걸 Mono<HIerarchialBoardDTO>로 만들어주는것이 Mono.just(dto) 부분이다.

그 뒤에는 elementClass를 넣어주어 body를 작성하게 된다.

그래서 애초에 Mono<HierarchicalBoardDTO> 로 만들어준 dto2는 Mono.just로 처리해줄 필요가 없어진다.

이걸 이해하기 위해서는 WebFlux를 이해해야 하고 Reactive Programming을 이해해야 한다.

근데 이게 좀 어려워서... 아직 정리가 안된상태...

 

bodyValue()의 경우는 비동기타입이 아닌 실제 값인 객체를 갖고 있는 경우 사용한다.

사실상 dto의 경우는 body로 인코딩을 해서 보낼 필요없이 bodyValue()로 보내주면 된다.

 

깊은 내용없이 간단하게 정리하자면 비동기타입이 아닌 실제값(객체)를 보낼것이라면 bodyValue(),

그렇지 않고 비동기타입(Mono, Flux 등)을 보낼것이라면 body()를 사용하면 된다.

그리고 이걸 이해하고 제대로 사용하기 위해서는 WebFlux를 이해해야 한다.

 

patch(), delete(), put() 요청은 post()와 거의 동일하기 때문에 따로 정리하지 않는다.

 

 

그럼 마지막으로 Multipart.

 

WebClient에서는 메모리문제를 피하기 위해 기본 in-memory buffer 값이 256KB로 설정이 되어있다.

256KB 이하의 파일만 처리를 할것이라면 굳이 손댈 필요 없겠지만 그렇지 않다면 따로 설정을 해줘야 한다.

그래서 WebClientConfig에 Bean을 추가했다.

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;

@Component
public class WebClientConfig {
    
    public WebClient useWebClient() {
        ....
    }
    
    public WebClient useImageWebClient() {
        
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                        .codecs(clientCodecConfigurer -> clientCodecConfigurer.defaultCodecs()
                                .maxInmemorySize(20 * 1024 * 1024)).build();
        
        WebClient webClient = WebClient.builder()
                    .baseUrl("http://localhost:9095")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .exchangeStrategies(exchangeStrategies)
                    .build();
        
        return webClient;
    }
}

이렇게 ExchangeStrategies를 통해 maxInmemorySize를 늘려주고 WebClient를 생성할때 속성을 넣어 설정해주면 문제가 해결된다.

 

요청은 아래처럼 처리한다.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
....

@Service
@RequiredArgsConstructor
public class ImageBoardWebClient {
    
    private final WebClientConfig webClientConfig;
    
    public long imageBoardInsert(String imageTitle
                                 , String imageContent
                                 , List<MultipartFile> files) {
        
        WebClient client = webClientConfig.useImageWebClient();
        
        //요청 데이터를 담아줄 bodyBuilder
        MultipartBodyBuilder mbBuilder = new MultipartBodyBuilder();
        
        for(int i = 0; i < files.size(); i++)
            mbBuilder.part("files", files.get(i).getResource()); //이미지 파일
        
        mbBuilder.part("imageTitle", imageTitle);     //게시글 제목
        mbBuilder.part("imageContent", imageContent); //게시글 내용
        
        return client.post()
                    .uri(uriBuilder -> uriBuilder.path("/image-board/image-insert").build())
                    .contentType(MediaType.MULTIPART_FORM_DATA)
                    .body(BodyInserters.fromMultipartData(mbBuilder.build()))
                    .retrieve()
                    .bodyToMono(Long.class)
                    .block();
    }
}

 

기존 post 요청에서 dto에 담아 보냈던것 처럼 Multipart처리시에는 MultipartBodyBuilder에 데이터를 담아 보내주면 된다.

MultipartBodyBuilder는 <String, HttpEntity> 형태로 담아줄 수 있다.

게시글에서 기본적인 파일, 제목, 내용을 담아준 뒤, 요청 body에서는 BodyInserters.fromMultipartData()를 사용해

MultipartBodyBuilder를 빌드해 body에 담아주면 된다.

MultipartBodyBuilder를 빌드하게 되면 MultiValueMap 타입이 된다.

BodyInserters.fromMultipartData()에는 이 MultiValueMap 타입만 받을 수 있고 받은 MultipartValueMap을 MultipartData로 쓰기위해 MultipartInserter를 return한다.

이때 받는 MultiValueMap의 값은 Obejct 또는 HttpEntity이다.

 

다시 좀 정리하면 MultiPartBodyBuilder에 데이터를 담아준다.

그걸 build() 하게 되면 MultiValueMap으로 리턴된다.

BodyInserters.fromMultipartData()는 MultiValueMap을 받으며 해당 map의 값으로는 Object 또는 HttpEntity여야 한다.

받은 MultiValueMap을 MultipartData로 쓰기위해 MultipartInserters를 반환해 body에 담아준다.

 

//api server insert
@PostMapping("/image-insert")
public long imageBoardInsert(@RequestParam List<MultipartFile> files
                             , @RequestParam String imageTitle
                             , @RequestParam String imageContent) {
    
    ....
}

api 서버에서 받을때는 이렇게 받을 수 있다.

 

 

Reference

 

JPA를 사용할 때 Id 값을 Long이나 int 타입으로 설정 했을 때 자동으로 증가되도록 설정하는 경우가 있다.

뭐 Oracle에서는 sequence를 이용해 Nextval, currval로 알아낼 수 있는 방법도 있지만 MySQL에서 설정하는 auto_increment는 그런게 없다.

 

그래서 이전에 MyBatis로 프로젝트를 할 때 방금 저장한 아이디값이 필요한 경우 SELECT로 가장 큰 값을 가져오도록 서브쿼리를 사용한다거나 해서 알아내 넣어줬었다.

 

근데 JPA에서는 간단하게 이 ID값을 알아낼 수 있었다.

 

long id = boardRepository.save(Board).getId();

 

JPA에서는 save로 저장했을 때 return되는 객체가 Entity 자체라고 한다.

 

예를들어 Board라는 Entity에 id, boardTitle, boardContent, userId 이렇게 구성되어있다고 할 때

save()를 하게 되면 이 4개의 데이터가 담긴 객체가 리턴 된다는 것이다.

그래서 get을 통해 id값을 리턴받게 되면 save로 저장해준 데이터의 id값을 받을 수 있게 되는 것이다.

 

 

개발환경

Springboot 2.7.0

SpringDataJPA

Lombok

MySQL

 

 

문제사항

JPA 공부하고 기존 진행했던 간단한 게시판 프로젝트에 JPA를 적용 중 이미지 파일 처리에서 문제가 발생.

//imageBoard Entity(사진 게시판)

@Entity
@Data
@Builder
public class ImageBoard {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long imageNo;
    
    @NonNull
    private String imageTitle;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "userId")
    @ToString.Exclude
    private Member member;
    
    private Date imageDate;
    
    private String imageContent;
    
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY)
    @ToString.Exclude
    private final Set<comment> comments = new HashSet<>();
    
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY)
    @ToString.Exclude
    private final Set<ImageData> imageDataSet = new HashSet<>();
}
// ImageData Entity(이미지 파일)

@Entity
@Data
@Builder
public class ImageData {
    
    @Id
    private String imageName;
    
    @ManyToOne
    @JoinColumn(name = "imageNo")
    @ToString.Exclude
    private ImageBoard imageBoard;
    
    @NonNull
    private String oldName;
    
    private int imageStep;
}

이렇게 두가지 엔티티가 이미지 게시판을 구성하게 된다.

그럼 insert를 한다고 했을 때 ImageBoard 엔티티를 먼저 저장해주고 ImageData 엔티티를 저장해줘야 한다.

ImageBoard 테이블에 imageNo가 존재해야 ImageData에 해당 게시글의 파일 정보를 저장할 수 있으니까.

 

최근에 'Entity는 Setter를 최대한 사용하지 않는것이 좋다. 꼭 Entity에 set을 해줘야 한다면 builder를 사용하는것이 좋다.' 라는 포스팅을 봤다.

봤으면 적용해야되니 builder를 통해 처리하고자 했다.

 

//처리코드

// imageBoardInsert 메소드
....
ImageBoard imageBoard = ImageBoard.builder()
                            .member(principalService.checkPrincipal(principal)
                            .imageTitle(request.getParameter("imageTitle"))
                            .imageContent(request.getParameter("imageContent"))
                            .imageDate(Date.valueOf(LocalDate.now()))
                            .build();
imageBoardRepository.save(imageBoard);
...

//파일 처리 메소드(imageBoardInsert 메소드에서 호출)
ImageBoard imageBoard = new ImageBoard();
imageBoard.setImageNo(imageNo);

ImageData imageData = ImageData.builder()
                            .imageboard(imageBoard)
                            .imageName(saveName)
                            .oldName(originalName)
                            .imageStep(step)
                            .build();
                            
imageDataRepository.save(imageData);
....

이렇게 작성했더니 바로 오류가 발생했다.

오류는 imageTitle은 NonNull이기 때문에 null이 들어가면 안된다는것.

처음한 생각으로는 연관관계 매핑을 해놨고 imageNo만 있으면 되겠지? 해서 setImageNo를 했고

안되길래 builder로 imageNo만 넣어서 빌드를 한 다음에 넣어봤는데 그것도 안되고...

@NonNull 어노테이션만 지우면 바로 해결되는 문제이긴 했지만 만약 이걸 사용해야 하는 환경에서 이런 문제에 또 부딪히게 되면 그때가서 또 시간 써야 될것 같아서 방법을 찾아봄.

 

간단하게 설명하자면 연관관계 매핑이 되어있으면 One 에 해당하는 Entity에 Many 에 해당하는 Entity를 담아 한번에 save가 가능하다.

 

처리는 아래처럼 하면 된다.

// ImageBoard Entity

@Entity
@Data
@Builder
public class ImageBoard {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long imageNo;
    
    @NonNull
    private String imageTitle;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "userId")
    @ToString.Exclude
    private Member member;
    
    private Date imageDate;
    
    private String imageContent;
    
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY)
    @ToString.Exclude
    private final Set<Comment> comments = new HashSet<>();
    
    // cascade = CascadeType.ALL 추가
    @OneToMany(mappedBy = "imageBoard", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @ToString.Exclude
    private final Set<ImageData> imageDataSet = new HashSet<>();
    
    //코드 추가
    public void addImageData(ImageData imageData) {
        
        //imageDataSet에 객체 추가
        imageDataSet.add(imageData);
        //imageData에도 imageBoard 객체를 추가
        imageData.setImageBoard(this);
    }
}
// 처리 코드

void imageInsertProc(.......){
    ....
    
    ImageBoard imageBoard = ImageBoard.builder()
                                .member(principalService.checkPrincipal(principal))
                                .imageTitle(request.getParameter("imageTitle"))
                                .imageContent(request.getParameter("imageContent"))
                                .imageDate(Date.valueOf(LocalDate.now())
                                .build();
                                
    imageInsert(...... , imageBoard);
    
    imageBoardRepository.save(imageBoard);
    ....
}
//파일 처리 메소드 호출

void imageInsert(..... , ImageBoard imageBoard) {
    ...
    ImageData imageData = ImageData.builder()
                            .imageName(saveName)
                            .oldName(originalName)
                            .imageStep(step)
                            .build();
                            
    imageBoard.addImageData(imageData);
    ....
}

이렇게 처리했다.

imageBoard를 먼저 빌드한 뒤 파일 처리 메소드를 호출할 때 imageBoard를 같이 넘겨준다.

그럼 파일 처리과정에서 imageData를 빌드할때는 ImageBoard를 제외한 나머지 값들로 빌드해준다.

그리고 Imageboard 엔티티에서 만들어놓은 addImageData 메소드를 통해 두 엔티티에 객체들을 저장할 수 있도록 해준다.

 

그리고나서 파일처리가 끝나면 Repository를 통해 save를 해주게 되면 연관관계에 있는 두 엔티티의 데이터가 한번에 저장되게 된다.

그럼 파일 처리 도중에 ImageBoard 엔티티 데이터에 대해 굳이 신경 쓸 필요도 없고 저장도 간단하게 해줄 수 있다.

 

Reference

https://data-make.tistory.com/730

 

[JPA] 양방향 관계 Entity 저장하기

JPA 양방향 관계 Entity 저장하기 인간은 습관의 동물이다. 습관에는 대부분 좋은 습관이 많지만 그중에 나쁜 습관도 있다. 그것은 바로.. 원리를 모르고 개발하는 습관이다. 😯 . '요로케할 때 이

data-make.tistory.com

 

SpringSecurity를 사용하던 중 Security 설정에서 상속받아 사용하던 WebSecurityConfigurerAdapter가 Deprecated되었다.

 

처음 Spring Security를 공부했을 때 WebSecurityConfigurerAdapter를 상속받아 설정하는걸 배웠었고 프로젝트에 구현할때도 그렇게 했었는데 어느날 갑자기 선이 쭉 생기면서 Deprecated 되었다는 것을 발견했다.

 

그래서 찾아보니 Spring Security 5.7.0부터 사용을 권장하지 않는다는 공식 문서를 찾게 되었다.

 

공식문서에는 더이상 사용을 하지 않는 다는 말과 함께 어떻게 수정해 사용하면 되는지에 대해 설명해뒀다.

 

https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter

 

Spring Security without the WebSecurityConfigurerAdapter

<p>In Spring Security 5.7.0-M2 we <a href="https://github.com/spring-projects/spring-security/issues/10822">deprecated</a> the <code>WebSecurityConfigurerAdapter</code>, as we encourage users to move towards a component-based security configuration.</p> <p

spring.io

 

 

WebSecurityConfigurerAdapter를 사용할때의 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/resources/**", "/js/**", "/css/**");
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/user/**")
                .access("hasRole('ROLE_USER')")
            .and()
                .formLogin()
                .successHandler(loginSuccessHandler())
            .and()
                .logout()
                .logoutSuccessUrl("/")
            .and()
                .exceptionHandling().accessDeniedPage("/");
    }
}

 

WebSecurityConfigurerAdapter를 사용하지 않을때의 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().antMatchers("/resources/**", "/js/**", "/css/**");
    }
    
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/user/**")
                .access("hasRole('ROLE_USER')")
            .and()
                .formLogin()
                .successHandler(loginSuccessHandler())
            .and()
                .logout()
                .logoutSuccessHandler(logoutSuccessHandler())
            .and()
                .exceptionHandling().accessDeniedpage("/");
        
        
        return http.build();
    }
}

 

 

코드만 봤을때는 크게 차이는 없다. 코드상으로는 상속받는게 없으니 override하지 않고 Bean으로 등록해준다는 정도의 차이점만 발생한다고 볼 수 있다.

이 외 설정에 대해서도 공식문서에 잘 나와있기 때문에 공식문서를 잘 확인한다면 큰 문제는 없을것 같다.

 

 

몇번 테스트를 해보면서 그런적은 없는데 이 설정 방식에 대해 알아보던 중 이렇게 bean으로 등록했을 때 오류가 발생하는 경우도 있는것 같다.

 

found webSecurityConfigurerAdapter as well as securityFilterChain. please select just one.

이런 오류가 발생하는 경우가 있다는데 그럴때는 securityConfig 클래스에

@ConditionalOnDefaultWebSecurity

@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)

이 두 어노테이션을 추가해주고

securityFilterChain 메소드에 

@Order(SecurityProperties.BASIC_AUTH_ORDER)

어노테이션을 추가해주면 해결이 된다고 한다.

 

+ Recent posts