개발 공부를 시작하고 프로젝트를 진행하면서 보통 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)

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

 

프로젝트 환경

  • Springboot 2.7.5
  • Maven
  • jwt 4.2.1
  • Spring Data JPA
  • MySQL
  • SpringSecurity

프로젝트 예제

인프런강의 -  스프링부트 시큐리티 & JWT(최주호강사님)

예제의 대부분 코드는 강의내에 있는 코드이고 그것을 기반으로 조금씩 수정한 코드입니다.

강의에서는 ResponseHeader에 담아 응답하고 RequestHeader에 담아 요청하는 코드이지만 페이지 이동 시 RequestHeader에 토큰을 담는 방법을 해결하지 못해 Cookie에 담아 응답하고 요청하는 코드로 수정했습니다.

JWT Token을 생성하는것은 라이브러리를 사용하고 SpringSecurity와 같이 JWT Token을 생성하고 처리하는 예제입니다.

 

필요한 포인트가 있는게 아니라 jwt를 공부하시는 것이라면 강의를 보시는것을 추천드립니다.

 

 

예제

1. Maven Dependency

  • SpringDataJPA
  • thymeleaf
  • Springboot DevTools
  • Lombok
  • Spring Web
  • SpringSecurity
  • MySQL Driver
  • jwt 4.2.1

2. 코드

// CorsConfig

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        
        CorsConfiguration config = new CorsConfiguration();
        
        config.setAllowCredentials(true);// 서버가 응답할 때 json을 자바스크립트에서 처리할 수 있도록 설정
        config.addAllowedOrigin("*");    // 모든 ip에 응답을 허용
        config.addAllowedHeader("*");    // 모든 header에 응답을 허용
        config.addAllowedMethod("*");    // 모든 post, get, put, delete, patch 요청을 허용
        
        // /api/**로 들어오는 모든 요청은 이 설정을 따라야 한다.
        source.registerCorsConfiguration("/api/**", config);
        
        return new CorsFilter(source);
    }
}

CORS는 Cross-Origin Resource Sharing의 약자로 특정 헤더를 통해 브라우저에게 Origin에서 실행되고 있는 웹 애플리케이션이 Cross-Origin에 리소스에 접근할 수 있는 권한이 있는지 없는지에 대한 것을 확인하는 필터다.

설정한 코드를 보면 setAllowedCredentials로 서버가 응답할 때 json을 자바스크립트에서 처리할 수 있도록 설정을 해주고

모든 ip, header, 요청 타입에 대한 것을 허용해서 요청이 들어오게 되면 corsFilter를 타도록 설정을 해주는 것이다.

그리고 registerCorsConfiguration으로 path, config를 담아 등록을 해줘야 하는데 /api/** 라는 주소로 들어오는 모든 요청은 이 설정을 따르도록 해준 뒤 이 설정을 리턴해주면 된다.

 

이 필터는 securityConfig에 등록해 처리할 수 있도록 한다.

필터 추가는 좀 더 하단에 securityConfig 코드에서.

 

// JwtProperties

public interface JwtProperties {

    String SECRET = "cos";                   // 토큰 생성 시 사용할 고유값. sign에 사용
    int EXPIRATION_TIME = 60000*10;          // 토큰 유효시간
    String TOKEN_PREFIX = "Bearer";          // 토큰값에 앞에 들어갈 인증 방법 값
    String HEADER_STRING = "Authorization";  // 헤더명
}

 

JwtProperties는 토큰 생성시에 작성해야 하는 값들을 정리해놓은 인터페이스일 뿐이다.

이렇게 사용하면 추후 수정이 필요할 때 좀 더 편하게 수정하고 여러 부분에서 사용하기 때문에 오타로 인한 실수를 방지할 수 있다.

 

// User(Entity)

import lombok.Data;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Entity
@Data
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    
    private String username;
    
    private String password;
    
    // 권한은 ROLE_USER, ROLE_MANAGER 이런 형태로 저장할것이기 때문에
    // Stirng 타입으로 생성
    private String roles;
    
    public List<String> getRoleList() {
        if(this.roles.length() > 0)
            return Arrays.asList(this.roles.split(","));
        
        return new ArrayList<>();
    }
}

 

//UserRepository

import com.example.jwt_testproject.model.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    
    public User findByUsername(String username);

}
// PrincipalDetails(UserDetails)

import com.example.jwt_testproject.model.User;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Data
public class PrincipalDetails implements UserDetails {

    private User user;
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        
        //User의 role을 String 타입으로 만들기 때문에 Collection 타입으로 변환해야 한다.
        
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        
        user.getRoleList().forEach(r -> {
            authorities.add(() -> r);
        });
        
        return authorities;
    }
    
    @Override
    public String getPassword() {
        return user.getPassword();
    }
    
    @Override
    public String getUsername() {
        return user.getUsername();
    }
    
    @Override
    public boolean isAccountNonExpried() {
        // 계정 만료 여부에 대한 처리 메소드다
        // 메소드명 그대로 계정이 만료되지 않았는가에 대한 리턴이기 때문에
        // true는 만료되지 않았다는 의미이고 false는 만료되었다는 의미다.
        return true;
    }
    
    @Override
    public boolean isAccountNonLocked() {
        // 계정이 잠겨있는지에 대한 여부를 리턴.
        // true면 계정이 잠겨있지 않다는 것이고 false면 잠겨있다는 의미다.
        return true;
    }
    
    @Override
    public boolean isCredentialsNonExpired() {
        // 비밀번호 유지 기간에 대한 처리 메소드
        // 비밀번호를 지정한 기간 이상으로 사용했다면 false를 리턴하고
        // 지정 기간을 넘기지 않았다면 true를 리턴한다
        return true;
    }
    
    @Override
    public boolean isEnabled() {
        // 계정활성화에 대한 메소드
        // 휴면계정이라면 false
        // 휴면계정이 아니라면 true를 리턴
        return true;
    }
}

 

 

UserDetails를 만들어야 하는 이유는 SecurityContextHolder에 사용자 정보를 담아줄 때 Authentication객체로 담아줘야 하는데 이 Authentication 객체에 담아주는 User 오브젝트의 타입이 UserDetails 타입이어야 하기 때문이다.

 

// PrincipalDetailsService(UserDetailsService)

import com.example.jwt_testproject.model.User;
import com.example.jwt_testproject.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
    
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        
        System.out.println("loadUserByUsername");
        
        User userEntity = userRepository.findByUsername(username);
        
        return new PrincipalDetails(userEntity);
    }
}

PrincipalDetailsService에서는 로그인 요청을 처리한다.

 

// JwtAuthenticatoinFilter

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.jwt_testproject.config.auth.PrincipalDetails;
import com.example.jwt_testproject.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    
    private final AuthenticationManager authenticationManager;
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request
                                     , HttpServletResponse response)
                                     throws AuthenticationException {
        System.out.println("JwtAuthenticationFilter's attemptAuthentication");
        
        try {
            //1. username과 password를 받아서 parsing
            ObjectMapper om = new ObjectMapper();
            User user = om.readValue(request.getInputStream(), User.class);
            
            System.out.println("attemptAuthentication user : " + user);
            
            //2. 로그인 시도를 위한 UsernamePasswordAuthenticationToken 생성
            UsernamePasswordAuthenticationToken authenticationToken = 
                      new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
            
            //3. authenticationManager.authenticate()를 통해 로그인 시도
            //로그인 시도로 인해 PrincipalDetailsService가 호출되어 loadUserByUsername이 실행
            Authentication authentication =
                    authenticationManager.authenticate(authenticationToken);
            
            //로그인이 정상적으로 처리되었다면 getUsername이 정상적으로 출력
            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            System.out.println("login Success : " + principalDetails.getUsername());
            
            return authenticaiton;
        }catch (IOException e) {
            e.printStackTrace();
        }
        
        return null;
    }

    // attemptAuthentication 처리 후 호출되는 메소드
    // 여기서 토큰을 생성
    @Override
    protected void successfulAuthentication(HttpServletReqeust
                                  , HttpServletResponse response
                                  , FilterChain chain
                                  , Authentication authResult)
                                  throws IOExceptoin, ServletException {

        System.out.println("JwtAuthenticationFilter's successfulAuthentication");
        
        // 토큰 생성을 위한 정보 get
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
        
        // 가장 많이 사용하는 알고리즘인 HMAC512로 암호화해 토큰 생성
        // 토큰 생성은 jwt 라이브러리로 생성
        String jwtToken = JWT.create()
                    .withSubject("cocoToken") //토큰이름. 크게 의미없다.
                    .withExpriresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME)) // 토큰 만료시간
                    .withClaim("id", principalDetails.getUser().getId())
                    .withClaim("username", principalDetails.getUser().getUsername()) //토큰에 저장하는 데이터. 여기서는 id와 username만 저장한다.
                    .sign(Algorithm.HMAC512(JwtProperties.SECRET)); //토큰 서명. 알고리즘과 secret을 담아주면 된다.
        
        System.out.println("AuthenticationFilter token : " + jwtToken);
        
        //응답 헤더(Response Header)에 토큰 담기
        //(헤더명, 헤더값(토큰값))
        response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + jwtToken);
        
        //토큰을 localStorage에 담지 않고 쿠키에 담는 경우
        Cookie cookie = new Cookie(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + jwtToken);
        
        cookie.setPath("/");
        cookie.setMaxAge(60 * 60);// 토큰 유효시간과 동일하게 설정해 토큰이 만료될때 쿠키도 삭제되도록
        cookie.isHttpOnly();// HttpOnly로 설정해 클라이언트에서 쿠키값을 알 수 없도록 설정(보안설정)
        
        response.addCookie(cookie);
    }
}

 

SpringSecurity를 사용하면서 username과 password를 post로 /login에 요청을 보내게 되면 UsernamePasswordAuthenticationFilter가 동작하게 된다.

따로 설정하지 않는다면 알아서 동작하는 필터인데 예제에서는 formLogin을 막아둘것이기 때문에 로그인 요청이 오더라도 이 필터를 타지 않는다.

그래서 JwtAuthenticationFilter에서 이 필터를 상속받아 구현해두고 securityConfig에서 필터를 등록해 로그인 요청시에 처리할 수 있도록 구현해야 한다.

 

이 필터를 securityConfig에 등록할때는 AuthenticationManager를 꼭 같이 넘겨줘야 하기 때문에 JwtAuthenticationFilter에서는 생성자를 만들어 AuthenticationManager를 받을 수 있도록 처리하거나 위와 같이 @RequiredArgsConstructor 어노테이션을 걸어주고 final 로 받아주면 된다.

 

필터를 securityConfig에 등록하게 되면 로그인 요청이 왔을 때 JwtAuthenticationFilter의 attemptAuthentication이 동작하게 되며 처리과정은 아래와 같다.

 

  1. username과 password를 받아 User Object에 담아준다
  2. 로그인 시도를 하기 위해 UsernamePasswordAuthenticationToken을 생성한다.
  3. AuthenticatonManger를 통해 로그인을 시도한다. 이때 UserDetailsService를 구현한 PrincipalDetailsService가 호출되어 loadUserByUsername을 실행한다.
  4. 로그인이 정상적으로 처리되면 authentication 객체에 로그인의 정보가 담긴다. 즉, 사용자 인증이 완료된다.
  5. return authentication으로 세션에 authentication 객체를 저장한다.

 

ObjectMapper는 json을 파싱해주는데 readValue를 통해 request의 데이터를 User 오브젝트에 담아준다.

그 다음 UsernamePasswordAuthenticationToken을 생성하는데 로그인 시도를 위해 만드는 토큰이다.

이 토큰은 formLogin을 사용하게 되면 알아서 생성되어 처리해주는 토큰인데 예제에서는 formLogin을 사용하지 않도록 막아두었기 때문에 직접 생성해줘야 한다.

이 토큰을 생성할때 넣어줘야 하는 값으로는 principal, credentials, roles가 존재한다.

principal은 username을 넣어주면 되고 credentails는 password, roles는 권한을 넣어주면 되는데 principal과 password와는 다르게 Collection 타입으로 받는다.

여기서는 로그인에 권한까지는 필요하지 않기 때문에 roles는 굳이 넘겨줄 필요가 없이 username과 password만 넘겨주면 된다.

 

다음으로 authenticationManger를 통해 UserDetailsService를 구현한 PrincipalDetailsService를 호출하게 되고 그 안에 있는 loadUserByUsername을 실행해 처리한다.

정상적으로 처리가되어 리턴된 정보는 Authentication 객체에 담기게 되는데 로그인의 정보가 그대로 담기는 것이고 사용자 인증이 되었다는 의미이다.

마지막으로 return authentication으로 세션에 authentication 객체를 저장해 세션에 로그인 정보를 담아준다.

 

jwt 토큰에 대해 찾아보다보면 세션을 사용하지 않는 stateless로 처리한다는 것을 볼 수 있는데 굳이 세션에 담는 이유는 권한처리를 SpringSecurity가 하도록 하기 위해서다.

SpringSecurity는 세션에 값이 있어야 권한관리를 할 수 있기 때문에 처리된 데이터를 세션에 담아줘야 한다.

그렇기 때문에 권한관리가 필요없는 프로젝트라면 굳이 세션에 정보를 담아줄 필요는 없다.

 

attemptAuthentication에서는 username과 password를 받는데 위 코드는 ObjectMapper를 통해 json을 받아 파싱한 케이스이고 좀 원시적인 방법으로 BufferedReader를 통해 받는 방법도 있다.

// username과 password를 BufferedReader로 받는 방법

@Override
public Authentication attemptAuthentication(HttpServletRequest request
                             , HttpServletResponse response)
                             throws AuthenticationException {

    System.out.println("JwtAuthenticationFilter's attemptAuthentication");
    
    try {
        BufferedReader br = request.getReader();
        String input = null
        while((input = br.readLine()) != null) {
            System.out.println(input);
        }
        ....
    }catch (IOException e) {
        e.printStackTrace();
    }
    
    return null;
}

프론트단에서 데이터를 보내는 방식에 따라 달라질 수 있겠지만 보통 웹에서는 x-www-form-urlencoded 방식으로 보내기 때문에 이렇게 받아 처리가 가능하고 이걸 파싱해 처리해주면 된다.

이렇게 받게 되면 "username=coco&password=1234" 이런 형태로 받게 된다.

 

 

attemptAuthentication 메소드 수행이 모두 끝나면 그 다음 호출되는 메소드는 successfulAuthentication이다.

jwt 토큰은 여기서 생성한다.

attemptAuthentication에서 생성해도 되지만 끝나고 successfulAuthentication이 호출되기 때문에 굳이 attemptAuthentication에서 생성할 필요는 없다.

 

successfulAuthentication에서는 토큰 생성을 위한 정보를 먼저 받아온다.

id와 username(사용자 아이디)가 필요하기 때문이다.

그 외에 토큰에 더 담고자 하는 정보가 있다면 그 정보역시 토큰 생성전에 받아와야 한다.

 

예제에서 토큰은 가장 흔히 사용한다는 HMAC512 알고리즘을 사용하고 jwt 라이브러리를 통해 생성한다.

토큰에 담아줘야 하는 데이터는 withClaim으로 이름과 데이터를 넣어주면 된다.

들어가는 데이터로는 List, String, long, double, int, map 등등 다양하다.

그리고 마지막으로 sign()을 통해 서명을 해준다.

여기에 사용할 알고리즘과 secretKey를 넣어주면 된다.

 

이제 그 다음은 선택사항이다.

강의에서는 responseHeader에 담아 넘겨주고 클라이언트는 그 토큰값을 요청할때 다시 requestHeader에 담아 요청하는 방식으로 진행되었는데 postman을 통해 테스트할때는 문제가 없지만 html파일까지 만들어서 하다보니 권한이 필요한 페이지로 이동시에 requestHeader에 토큰을 담아야할 방법을 찾지 못해 쿠키에 담는 방법을 택했다.

 

토큰 저장방식에 있어서 보통 localStorage에 담는 방법과 cookie에 담는 방법이 많이 보이는데 각각 장단점이 있다.

의견이 분분해 정답은 없는것으로 보인다.

 

헤더에 담아 요청, 응답하는 방법을 택한다면 response.addHeader를 통해 responseHeader에 토큰을 담아주면 되고 쿠키에 담아 요청, 응답하는 방법을 택한다면 아래 쿠키 생성 코드를 사용하면 된다.

 

토큰이 만료되면 쿠키역시 굳이 살아있을 필요가 없다고 생각해 쿠키 만료시간 역시 토큰 생성시간과 동일하게 처리했다.

그리고 isHttpOnly를 설정해주었는데 이걸 설정해줘서 프론트에서 쿠키값을 볼 수 없도록 보안설정을 해줘야 한다.

그래야 XSS 공격에 대한 보안이 가능하다.

그리고 addCookie로 토큰을 담은 쿠키를 돌려주면 된다.

 

둘다 알고있긴 해야된다고 생각하는게 아직 공부하지 못했지만 accessToken과 refreshToken을 만들어 보안을 더 강화해야 하는데 이때 accessToken은 localStorage에 담고 refreshToken은 쿠키에 담아 처리한다는 포스팅을 본 적이 있어 추후 두 방법 모두 사용하게 될 것 같기 때문이다.

 

그리고 쿠키를 생성할때의 문제점이 하나 있다.

원래 토큰값을 생성할때 Bearer tokenvalue 이렇게 만들어줘야 한다고 강의에서 설명해주셨는데

쿠키에는 공백이 포함될 수 없다. 그래서 쿠키로만 구현을 하다보니 저 공백을 없애버렸는데 헤더에 담아 처리하게 되었을때 문제점이 발생하지 않을런지 공백이 꼭 필요하다면 쿠키에 담을때는 어떻게 처리를 할지에 대해서도 학습이 좀 필요하다.

 

 

토큰을 생성한뒤에 응답까지 완료했따면 인증이 필요한 경우에 처리할 필터가 필요한데 그 역할을 하는 필터가 JwtAuthorizationFilter다.

 

 

// JwtAuthorizationFilter

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.jwt_testproject.config.auth.PrincipalDetails;
import com.example.jwt_testproject.model.User;
import com.example.jwt_testproject.repository.UserRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.web.util.WebUtils;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private UserRepository userRepository;
    
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager
                            , UserRepository userRepository) {

        super(authenticationManager);
        this.userRepository = userRepository;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request
                           , HttpServletResponse response
                           , FilterChain chain)
                           throws IOException, ServletException {
        
        System.out.println("Authorities request");
        
        // 헤더에서 Authorization의 값을 가져온다.
        // 강의 예제에서는 헤더에서 가져오는 방법을 사용했으나
        // 쿠키로 변경했기 때문에 주석처리.
        // String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
        // System.out.println("jwtHeader : " + jwtHeader);
        
        //쿠키에서 Authorization 값을 가져온다.
        Cookie jwtCookie = WebUtils.getCookie(request, JwtProperties.HEADER_STRING);
        System.out.println("jwtCookie : " + jwtCookie);
        
        // Authorization이라는 쿠키(혹은 헤더)가 존재하는지,
        // value가 Bearer로 시작하는지를 검증.
        // Authorization이라는 쿠키(헤더)가 존재하지 않거나 Bearer로 시작하지 않는다면
        // 서버에서 만든 토큰이 아니기 때문에 토큰 검증을 할 필요가 없어 다음 필터로 넘긴다.
        if(jwtCookie == null || !jwtCookie.getValue().startsWith(JwtProperties.TOKEN_PREFIX)) {
            System.out.println("token is null");
            chain.doFilter(request, response);
            return;
        }
        
        System.out.println("token is not null");
        
        // 토큰 검증을 하기 위해 value에서 Bearer 뒤에 있는 값만 빼준다.
        // 헤더방식
        // String jwtToken = request
        //            .getHeader(JwtProperties.HEADER_STRING)
        //            .replace(JwtProperties.TOKEN_PREFIX, "");
        
        //쿠키 방식
        String jwtToken = jwtCookie
                    .getValue()
                    .replace(JwtProperties.TOKEN_PREFIX, "");
        System.out.println("AuthorizationFilter jwtToken : " + jwtToken);
        
        // verify로 토큰을 서명한다.
        // 서명이 정상적으로 처리된다면 getClaim을 통해 username 정보를 가져오고
        // String 으로 캐스팅해준다.
        // username이 정상적으로 들어온다는 것은 서명이 정상적으로 처리 되었다는것을 의미한다.
        String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET))
                    .build()
                    .verify(jwtToken)
                    .getClaim("username")
                    .asString();
        
        System.out.println("AuthorizationFilter username : " + username);
        
        if(username != null) { //서명이 정상적으로 처리되어 username을 받았다면
            System.out.println("username is nomal state");
            User userEntity = userRepository.findByUsername(username);
            
            PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
            
            Authentication authentication = 
                    new UsernamePasswordAuthenticationToken(principalDetails
                                                   , null
                                                   , principalDetails.getAuthorities());
            
            // securityContextHolder는 security를 저장할 수 있는 세션공간.
            // 강제로 security 세션에 접근해 authentication 객체를 저장.
            SecurityContextHolder.getContext().setAuthentication(authentication);
            
            chain.doFilter(request, response);
        }
    }
}

이 필터는 SpringSecurity가 갖고 있는 필터 중 BasicAuthenticationFilter를 extends 했다.

BasicAuthenticationFilter는 권한이나 인증이 필요한 특정 주소를 요청했을 때 동작하는 필터다.

그렇기 때문에 권한이나 인증이 필요하지 않은 주소를 접근하는 경우에는 동작하지 않는 필터이기 때문에 여기서 토큰을 검증하도록 구현한다.

 

JwtAuthorizationFilter를 securityConfig에 추가해 권한이나 인증이 필요한 주소로 접근 시 이 필터를 타도록 해주면 된다.

 

이 필터에서는 쿠키(혹은 헤더)에서 Authorization의 값을 찾고 존재한다면 그 토큰 값을 검증하고 세션에 정보를 넘겨주는 역할을 한다.

 

넘어온 쿠키나 헤더의 값을 확인할때는 그 값이 존재하는지, Bearer로 시작을 하는 값인지를 먼저 체크해줘야 한다.

값이 존재하지 않는다면 생성해준 토큰을 클라이언트가 넘겨주지 않았거나 갖고 있지 않다는 의미이기 떄문에 더이상 토큰을 검증할 필요가 없기 때문에 거기서 바로 넘겨버리면 되기 때문이다.

 

정상적인 토큰값을 받았다면 토큰을 검증하게되고 그 검증을 위해 Bearer를 제외한 나머지 값들을 뽑아 검증한다.

verify로 토큰을 서명하고 이 서명이 정상적으로 처리가 된다면 토큰 생성시에 넣어준 정보인 username을 getClaim으로 가져올 수 있다.

 

그럼 username값이 있다는것은 정상적인 토큰이라는 것이 검증이 되었다는 것이다.

if문 내부를 보면 username을 갖고 사용자 정보를 가져온 뒤 그 정보를 PrincipalDetails(UserDetails)에 담아준다.

그리고 authentication 객체를 만들고 그 객체를 SecurityContextHolder라는 security의 세션 공간에 저장을 해준다.

 

JwtAuthenticationFilter에서도 authentcation 객체를 만들었는데 이때와 다른점은 AuthenticatoinFilter에서는 로그인 요청을 해서 만드는것 이었다면 여기서는 로그인 요청을 할 것이 아니라 세션에 저장하기 위한 객체를 만드는 것이라는 차이가 있다.

그래서 객체 생성시에 UsernamePasswordAuthenticationToken으로 생성을 해주면 되고 credentials 부분을 보면 null로 되어있는데 로그인 요청을 하는것이 아니기 때문에 비밀번호는 굳이 넣어줄 필요가 없어서 null로 처리하는 것이다.

그리고 roles를 getAuthorities로 넣어주는데 이전에는 로그인 요청이기 때문에 권한까지 담아서 요청할 필요가 없이 아이디, 비밀번호만 있으면 되지만 SpringSecurity가 권한 관리를 하도록 해야 하기 때문에 권한을 같이 담아줘야 한다.

 

마지막 SecurityContextHolder.getContext().setAuthentication()은 SpringSecurity를 저장할 수 있는 Security의 세션 공간인 SecurityContextHolder에 authentication을 저장해주는 코드인데

이렇게 세션에 담아주지 않는다면 Security에서는 권한 관리를 할 수 없기 때문에 담아줘야 한다.

 

 

그럼 이제 필터 작성은 모두 끝났고 SecurityConfig를 작성하기 전에 하나만 더 만들어주면 된다.

강의에서는 postman으로만 테스트했기 때문에 이정도만 하고 SecurityConfig를 작성해 테스트해봐도 되지만

html파일을 만들고 처리하다보니 로그아웃되었을때도 쿠키가 남아있어 로그아웃 시 쿠키를 삭제해주도록 LogoutSuccessHandler를 따로 만들었다.

 

import com.example.jwt_testproject.config.jwt.JwtProperties;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.util.WebUtils;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
    
    @Override
    public void onLogoutSuccess(HttpServletRequest request
                            , HttpServletResponse response
                            , Authenticatoin authentication)
                            throws IOException, ServletException {
        
        Cookie cookie = WebUtils.getCookie(request, JwtProperties.HEADER_STRING);
        
        cookie.setValue(null);
        cookie.setMaxAge(0);
        cookie.setPath("/");
        
        response.addCookie(cookie);
        response.sendRedirect("/home");
    }
}

로그아웃이 정상적으로 처리되었다면 쿠키의 값을 null로 만들어주고 maxAge를 0으로 세팅해 삭제되도록 설정했다.

 

그럼 마지막 설정인 SecurityConfig다.

강의 예제와는 좀 다르게 되어있는데 이 강의 뿐만 아니라 많은 시큐리티 강의에서는 WebSecurityConfigurerAdapter를 extends해준다.

근데 현재 시점으로 Deprecated 상태이기 때문에 강의 종료 후 이 부분을 수정했다.

 

// SecurityConfig

import com.example.jwt_testproject.config.auth.CustomLogoutSuccessHandler;
import com.example.jwt_testproject.config.jwt.JwtAuthenticationFilter;
import com.example.jwt_testproject.config.jwt.JwtAuthorizationFilter;
import com.example.jwt_testproject.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;

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

    private final CorsFilter corsFilter;
    
    private final UserRepository userRepository;
    
    private AuthenticationManager authenticationManager;
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public CustomLogoutSuccessHandler logoutSuccessHandler() {
        return new CustomLogoutSuccessHandler();
    }
    
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        
        http.csrf().disable();
        
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)// 세션을 사용하지 않는다(STATELESS)
                .and()
                .addFilter(corsFilter) // CorsFilter 추가
                .formLogin().disable() // formLogin 사용안함.
                .httpBasic().disable()
                .logout().logoutSuccessHandler(logoutSuccessHandler()) // CustomLogoutSuccessHandler 등록
                .and()
                .addFilter(new JwtAuthenticationFilter(authenticationManager())// JwtAuthenticationFilter 추가
                .addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository))// JwtAuthorizationFilter 추가
                .authorizeRequests()
                .antMatchers("/api/v1/user/**")
                .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/manager/**")
                .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/admin/**")
                .access("hasRole('ROLE_ADMIN')")
                .anyRequest()
                .permitAll();
                
        return http().build();
    }
    
    private final AuthenticationManagerBuilder localConfigureAuthenticationBldr;
    
    private boolean authenticationManagerInitialized;
    
    private boolean disableLocalConfigureAuthenticationBldr;
    
    private fianl AuthenticationConfiguration authenticationConfiguration;
    
    protected AuthenticationManager authenticationManager() throws Exception {
        
        if(!this.authenticationManagerInitialized) {
            this.configure(this.localConfigureAuthenticationBldr);
            
            if(this.disableLocalConfigureAuthenticationBldr) {
                this.authenticationManager = this.authenticationConfiguration.getAuthenticationManger();
            } else {
                this.authenticationManager = (AuthenticationManager) this.localConfigureAuthenticationBldr.build();
            }
            
            this.authenticationManagerInitialized = true;
        }
        
        return this.authenticationManager;
    }
    
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        this.disableLocalConfigureAuthenticatoinBldr = true;
    }
}

 

authenticatoinManger는 WebSecurityConfigurerAdapter 안에 들어가있기 때문에 extends해서 사용한다면 아래 코드가 필요없이 바로 사용이 가능하다.

하지만 Deprecated 상태라 제거하다보니 바로 갖다가 사용하는게 불가능해졌다.

그래서 생각한 방법이 security Config에 만들어주는 방법을 택했다.

 

WebSecurityConfigurerAdapter에서 그대로 긁어온 뒤에 새로 가져와야 하는 코드들을 더 찾아 추가해주는 형태로 수정했다.

정상적으로 잘 동작하긴하지만 WebSecurityConfigurerAdapter를 Deprecated 상태로 만들었으면 다른 방법으로 authenticationManger를 사용하도록 만들어뒀을텐데 아니면 이거 말고 다른걸 사용하도록 만들어뒀을텐데 아직 못찾았다.

이게 완전 맞는 방법인지는 좀 더 확인이 필요하다.

일단은 문제는 발생하지 않으니 jwt를 테스트하는데 있어서는 문제가 없다.

 

테스트

 

테스트는 postman으로 테스트하는 경우 헤더에 담아테스트하는게 가장 편하다고 생각하니 주석처리 해놨던 Header에서 토큰값을 꺼내오는 코드들을 활성화 시켜주고 쿠키에서 꺼내오는 코드들은 주석처리.

 

쿠키 방식으로 테스트 하는 경우는 로그인하면 서버에서 쿠키를 보내주니 따로 손대는것 없이 권한이 필요한 /api/user/** 페이지 등에 접근하면서 테스트를 해볼 수 있다.

 

 

JQuery만 써봤기 때문에 JQuery 기준으로 requet header에 토큰 값을 담는 방법을 정리.

토큰을 헤더로 넘겨받았을 때 어떻게 관리할것인가에 대한 방법도 여러가지가 있었다.

localStorage에 담아 필요할때마다 꺼낸다거나 정적변수에 담아 사용한다는 내용이 대부분이었다.

localStorage에 매번 접근하면 과도한 오버헤드가 발생하니 정적변수에 담아 처리하는게 효율적이다 라는 의견이었다.

아쉽게도 이에 대한 언급만 있을 뿐이라 근본적인 문제를 해결하지는 못했다.

 

일단 JQuery만 사용해봤기 때문에 아래처럼 처리는 가능했다.

// responseHeader에서 토큰 값 꺼내오기
var token = request.getResponseHeader("Authorization");

// 로컬 스토리지에 토큰 저장
localStorage.setItem("Authorization", token);

// 로컬 스토리지에서 토큰 꺼내오기
var token = localStorage.getItem("Authorization");


// RequestHeader에 토큰을 담아 요청하기
var hReq = new XMLHttpRequest();

hReq.open("get", "/api/v1/user", false);
hReq.setRequestHeader("Authorization", token);
hReq.send();


// ajax로 토큰을 담아 요청하기
$.ajax({
	type: "post",
    url: "/api/v1/user",
    data: data,
    beforeSend: function(xhr) {
        xhr.setRequestHeader("Authorization", token);
    },
    success: function(data) {
    
    }
});

 

 

 

여기서 이 두가지 요청 케이스들은 정상적으로 처리가 가능하지만 문제점은 페이지 이동에 있어서 문제가 있다는 것이었다.

예를들어 ajax로 처리하면서 requestHeader에 토큰을 담아 보내서 처리한 뒤에 페이지 이동을 해야 하는 상황인데

페이지 이동시에는 토큰이 담기지 않아 권한이 필요한 페이지는 접근이 불가능하다는 문제다.

이 내용에 대해 여러 포스팅을 찾아봤지만 물어보는 분들은 있는데 명확한 답은 찾지 못했다.

 

jwt토큰을 공부하면서 아직 남은부분이 accessToken과 refreshToken에 대한 학습인데 accessToken은 localStorage에 refreshToken은 쿠키에 담는다 라는 포스팅을 봤다.

아마 여기에 힌트가 있지 않을까 생각한다...

 

'Web' 카테고리의 다른 글

JWT 1. JWT란?  (0) 2022.11.25
CI/CD란?  (0) 2021.12.28
Jenkins github webhook 연동  (0) 2021.12.28
Jenkins로 Spring Boot 프로젝트 빌드&배포하기  (0) 2021.12.24

JWT는 Json Web Token으로 말 그대로 웹 토큰을 의미한다.

Json format을 이용해 사용자에 대한 속성을 저장하는 Claim 기반의 웹 토큰이다.

JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달한다.

그래서 주로 회원 인증이나 정보 전달에 사용된다.

 

JWT의 구조

JWT는 header, payload, signature 이렇게 세가지 부분으로 이루어진다.

각 부분은 Base64Url로 인코딩되어 표현되고 '.' 으로 구분한다.

 

jwt.io

 

HEADER

header는 typ와 alg 두가지 정보로 구성되어 있다.

typ는 토큰의 타입을 지정하고 alg는 토큰의 알고리즘 방식을 지정하게 된다.

 

 

PAYLOAD

payload는 토큰에서 사용할 정보의 조각들인 클레임(Claim)이 담겨있다.

클레임은 등록된 클레임(Registered Claim), 공개 클레임(Public Claim), 비공개 클레임(Private Claim)으로 나눠지며

json형태로 다수의 정보를 넣을 수 있다.

 

등록된 클레임은 토큰 정보를 표현하기 위해 이미 정해진 종류의 데이터들로, 모두 선택적으로 작성이 가능하며 사용할 것을 권장한다.

  • iss : 토큰 발급자(issuer)
  • sub : 토큰 제목(subject)
  • aud : 토큰 대상자(audience)
  • exp : 토큰 만료 시간(expriation), NumericDate 형식으로 되어 있어야 한다.
  • nbf : 토큰 활성 날짜(not before), 이 날이 지나기 전의 토큰은 활성화가 되지 않는다.
  • iat : 토큰 발급 시간(issued at), 토큰 발급 이후의 경과 시간을 알 수 있다.
  • jti : JWT 토큰 식별자(JWT ID), 중복 방지를 위해 사용하며, 일회용 토큰(Access Token)등에 사용한다.

공개 클레임은 사용자 정의 클레임으로 공개용 정보를 위해 사용되며 충돌 방지를 위해 URI 포맷을 이용한다.

 

비공개 클레임은 사용자 정의 클레임으로 서버와 클라이언트 사이에 임의로 지정한 정보를 저장한다.

 

 

SIGNATURE

signature(서명)은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다.

signature는 토큰의 header, payload를 각각 Base64Url로 인코딩 하고 이를 이용해 header의 alg에서 정의한 알고리즘으로 암호화 한 뒤 다시 Base64Url로 인코딩해 생성한다.

 

 

Signature 암호화 방식

signature를 생성할 때 사용하는 암호화 방식으로는 보통 두가지를 사용한다고 한다.

hs256방식과 RSA 방식인데 이 중에 더 많이 사용하는 방법은 hs256 방식이라고 한다.

 

 

HS256 방식

hs256 알고리즘은 두가지 알고리즘을 합친것이다.

h는 HMAC 알고리즘을 의미하는데 HMAC은 SecretKey를 이용한 암호화 방식이다.

s256은 SHA256 알고리즘을 의미하는데 해시함수 암호화 방식이다.

이 두가지를 합쳐서 h(HMAC) + s256(SHA256) = hs256 알고리즘이라고 부른다.

 

이 hs256 알고리즘은 서버의 secretKey를 이용해 암호화를 하는 방식이다.

이 암호화를 사용할 경우 처리 과정은 아래와 같다.

  1. header와 payload를 각각 Base64Url로 인코딩한다.
  2. signature는 인코딩된 header + payload + secretKey를 hs256으로 암호화한다.
  3. hs256으로 암호화된 signature를 Base64Url로 다시 인코딩해준다.

서버가 토큰을 받게 되면 서버는 토큰의 header와 payload + 자신의 secretkey를 hs256으로 암호화한 뒤 그 값을 토큰의 signature값과 비교를 해 토큰을 검증하게 된다.

 

 

RSA 방식

RSA 알고리즘은 public key(공개키)와 private key(개인키)를 사용하는 방식이다.

공개키는 누구나 알고 있어도 상관없는 말 그대로 공개되어 있는 키이고,

개인키는 소유자만이 알고 비밀이에 갖고 있어야 하는 키다.

RSA 방식의 특징으로는 개인키로 암호화를 하면 공개키로 열어볼 수 있고 공개키로 암호화를 하면 개인키로 열어볼 수 있다.

개인키로 잠궈놓는 것은 소유자만이 할 수 있기 때문에 전자 서명에 많이 사용되고,

공개키로 잠궈놓는 것은 암호화를 의미한다.

 

JWT에서 RSA 방식으로 signature를 생성하게 되면 아래와 같이 처리하게 된다.

  1. header와 payload를 각각 Base64Url로 인코딩한다.
  2. signature는 인코딩된 header와 payload를 서버의 개인키로 암호화 한다.
  3. 개인키로 암호화한 signature를 Base64Url로 인코딩한다.

서버는 토큰을 받게 되면 자신의 공개키로 signature를 복호화 해 자신이 만든 토큰이라는 것을 확인한 뒤 처리하게 된다.

 

처음 이 내용을 들었을때는 개인키로 암호화를 하는 경우 누구나 가질 수 있는 공개키로 열어볼 수 있는데 왜 개인키로 암호화를 하지? 라는 생각을 했는데

조금만 더 생각해보면 RSA 방식에서의 signature는 header와 payload의 조합이다.

이 토큰을 탈취해서 signature를 복호화 한다는 것은 header와 payload의 값을 볼 수 있다는 것인데 그건 굳이 signature를 복호화 하지 않아도 확인할 수 있는 정보고 Base64Url로 인코딩되어 있기 때문에 값을 확인하기도 쉽다.

그래서 payload에 중요정보를 넣으면 안된다고 하는것이기 때문에 문제의 포인트가 되지 않는다.

 

그리고 개인키로 암호화를 해서 공개키로 열어야 서버가 암호화를 했다는 것이 인증되는 것이나 마찬가지이기 때문이다.

공개키로 암호화를 하면 누구나 암호화를 할 수 있게 되고 서버가 암호화를 했다는 인증이 되지 않기 때문이다.

그래서 개인키를 전자서명에 사용하는 것이다.

 

 

JWT의 단점

  1. JWT는 토큰 자체에 정보를 담고 있으므로 양날의 검이 될 수 있다. 편하게 정보에 접근할 수 있지만 탈취당했을 때 유출에 대한 위험도 있다.
  2. 토큰의 payload에 3종류의 클레임을 저장하기 때문에 정보가 많아질수록 토큰의 길이가 늘어나고 이는 네트워크에 부하를 줄 수 있다.
  3. payload는 암호화된 것이 아니라 Base64Url로 인코딩 된 것이기 때문에 탈취하면 쉽게 데이터를 볼 수 있다. 그래서 JWE로 암호화를 하거나 중요데이터를 넣지 않아야 한다.
  4. JWT는 상태를 저장하지 않기 때문에 임의로 삭제하는것이 불가능하므로 토큰 만료 시간을 꼭 정의해야 한다.
  5. 토큰은 클라이언트측에서 관리해야 하기 때문에, 토큰을 저장해야 한다.

 

 

Reference

'Web' 카테고리의 다른 글

JWT 2. JWT 구현  (0) 2022.12.01
CI/CD란?  (0) 2021.12.28
Jenkins github webhook 연동  (0) 2021.12.28
Jenkins로 Spring Boot 프로젝트 빌드&배포하기  (0) 2021.12.24

+ Recent posts