개발노트

25.02.20 JWT 리팩토링 과 헥사고날 아키텍처 도입 본문

Spring Boot

25.02.20 JWT 리팩토링 과 헥사고날 아키텍처 도입

ddong-kka 2025. 2. 20. 23:37

개요

AccessToken 과 RefreshToken을 발급해 사용할 것이다.

로그인에 성공 시 AccessToken 은 헤더에 Authorization 의 키 값으로 포함하고 RefreshToken은 쿠키에 포함해 응답한다.

login과 logout을 userService 에서 처리하면서 user 도메인과 token 도메인을 함께 다루다 보니 userService에서 token 
관련 기능의 의존성이 너무 커지는게 신경이 쓰였다. 이 부분을 생각하면서 개발을 진행하지않아 이미 문제를 의식했을 때는 변경해야할것이 너무 많아졌다. 조언을 구하고자 튜터님을 찾아가 헥사요

AccessToken 과 RefreshToken을 발급해 사용할 것이다.

 

로그인에 성공 시 AccessToken 은 헤더에 Authorization 의 키 값으로 포함하고 RefreshToken은 쿠키에 포함해 응답한다.

 

login과 logout을 userService 에서 처리하면서 user 도메인과 token 도메인을 함께 다루다 보니 userService에서 token 

관련 기능의 의존성이 너무 커지는게 신경이 쓰였다. 이 부분을 생각하면서 개발을 진행하지않아 이미 문제를 의식했을 때는 변경해야할것이 너무 많아졌다. 조언을 구하고자 튜터님을 찾아가 의견을 듣던 중 헥사고날 아키텍처에 대해 알게되었다. 현재 구조에서 완벽한 헥사고날 구조로 변경하기엔 변경할것이 너무 많아 어느정도 헥사고날의 느슨간 결합의 개념과 비슷하게 디렉토리 구조와 코드를 변경하고 서로의 의존성을 줄였다.

 

헥사고날 아키텍처에 대해 간단하게 정리하고 변경한 코드를 기록하려한다.

헥사고날 아키텍처란?

소프트웨어 설계 원칙 중 하나로, 애플리케이션의 핵심 비즈니스 로직을 외부 의존성(DB, UI, 외부 API 등) 으로부터

분리하여 유지보수성과 확장성을 높이는 아키텍처 패턴이다.

 

헥사고날이라는 명칭은 비즈니스로직을 둘러싼 여러 개의 포트를 나타내기 위해 육각형 모양을 비유적으로 사용한 것이라고한다.

애플리케이션의 내부와 외부 시스템 간의 상호작용을 명확하게 분리해 애플리케이션의 외부 의존성으로부터 독립적으로 만들고, 내부 도메인 로직에 집중할 수 있도록 하는 것이 중요 요소

 

https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture

 

  1. 도메인
    • 비즈니스 로직이 위치하는 영역으로, 어떤 외부 환경에도 의존하지 않는 독립적인 모듈
    • 도메인 로직을 유지하기 위해 Application Service 또는 Use Case 레이어를 둠.
  2. 포트 (port)
    • 도메인(Core)과 외부 시스템(Database, UI, API 등) 사이를 연결하는 인터페이스.
    • Inbound Port (들어오는 요청)과 Outbound Port (외부 시스템과 연결)로 나뉨.
  3. 어댑터(Adapters)
    • 포트를 구현하여 실제로 외부 시스템과 연결하는 부분
    • Repository, API 클라이언트, UI 등의 요소가 포함됨.

 

장점

  1. 유연성
    • 내부 도메인 로직은 외부 시스템이나 기술과 독립적이다.
      외부 시스템의 변경이 내부 애플리케이션에 영향을 미치지 않도록 할 수 있다.
  2. 테스트 용이성
    • 외부 시스템과 분리되어있어 핵심 도메인만을 독립적으로 테스트할 수있음
  3. 기술 변화에 강함
    • 외부 기슬 스택이 변경되어도 핵심 로직에는 영향을 미치지 않으며, 어댑터만 변경하면된다.
  4. 각각의 역할 분리
    • 포트와 어댑터를 통해 명확하게 책임을 분리하고, 시스템을 더 쉽게 이해할 수 있게만든다.

실제 예시 (Spring Boot에서의 활용)

  1. 핵심 도메인(서비스): 도메인 로직이 포함된 서비스 클래스를 작성합니다.
  2. 포트(인터페이스): 예를 들어, UserRepository와 같은 인터페이스를 정의합니다.
  3. 어댑터: Spring의 JpaRepository를 사용하여 DB와 연결하는 어댑터를 구현하거나, REST API를 처리하는 컨트롤러를 작성할 수 있습니다.

 

 


적용

비슷하게나마 port와 adapter로 분리해 userService에서 token 과 관련된 기능을 사용해 보겠다.

기존의 프로젝트 구조

 

 

크게는 config 과 doamin 안에서 각자의 맡은 역할의 도메인과 관련된 코드를 작성하고있다.

jwt 와 관련된 코드는 config 안에서 다루고있었다. 그러나 refresh 토큰을 구현하면서 refreshToken을 저장할 엔티티가

필요해져서 token 과 관련된 기능을 token 이라는 도메인 안에서 다루게 구조를 변경한다.

 

token 도메인

 

filter 는 config/filter 경로에 생성

 

Port 구현

package com.sparta.delivery.domain.token.interfaces;

import com.sparta.delivery.domain.user.entity.User;

public interface JwtService {

    String createAccessToken(User user);

    String createRefreshToken(User user);

    boolean isTokenExpired(String token);

    String getCategory(String token);
}

 

package com.sparta.delivery.domain.token.interfaces;

import com.sparta.delivery.domain.user.entity.User;

public interface RefreshTokenService {

    void addRefreshTokenEntity(User user , String refreshToken);

    void removeRefreshToken(String refreshToken);

    String reissueAccessToken(String refreshToken);
}

 

 

Adapter 구현

package com.sparta.delivery.domain.token.service;

import com.sparta.delivery.domain.token.interfaces.JwtService;
import com.sparta.delivery.domain.user.entity.User;
import io.jsonwebtoken.ExpiredJwtException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class JwtServiceImpl implements JwtService {

    private final JwtUtil jwtUtil;

    private final Long accessExpiredMs;
    private final Long refreshExpiredMs;

    public JwtServiceImpl(JwtUtil jwtUtil,
                          @Value("${spring.jwt.accessTokenValidityInMilliseconds}") Long accessExpiredMs,
                          @Value("${spring.jwt.refreshTokenValidityInMilliseconds}") Long refreshExpiredMs) {
        this.jwtUtil = jwtUtil;
        this.accessExpiredMs = accessExpiredMs;
        this.refreshExpiredMs = refreshExpiredMs;
    }

    @Override
    public String createAccessToken(User user) {
        return jwtUtil.createJwt("access",user.getUsername(), user.getEmail(), user.getRole(),accessExpiredMs);
    }

    @Override
    public String createRefreshToken(User user) {
        return jwtUtil.createJwt("refresh",user.getUsername(), user.getEmail(), user.getRole(),refreshExpiredMs);
    }

    @Override
    public boolean isTokenExpired(String token) {

        try{
            jwtUtil.isExpired(token);
            return false;
        }catch (ExpiredJwtException e){
            return true;
        }
    }

    @Override
    public String getCategory(String token) {
        return jwtUtil.getCategory(token);
    }
}

 

package com.sparta.delivery.domain.token.service;

import com.sparta.delivery.config.global.exception.custom.InvalidRefreshTokenException;
import com.sparta.delivery.config.global.exception.custom.RefreshTokenAlreadyExistsException;
import com.sparta.delivery.domain.token.entity.RefreshToken;
import com.sparta.delivery.domain.token.interfaces.RefreshTokenService;
import com.sparta.delivery.domain.token.repository.RefreshTokenRepository;
import com.sparta.delivery.domain.user.entity.User;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.http.Cookie;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.Date;

@Service
public class RefreshTokenServiceImpl implements RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;

    private final JwtUtil jwtUtil;
    private final Long accessExpiredMs;
    private final Long refreshExpiredMs;

    public RefreshTokenServiceImpl(RefreshTokenRepository refreshTokenRepository,
                                   JwtUtil jwtUtil,
                                   @Value("${spring.jwt.accessTokenValidityInMilliseconds}") Long accessExpiredMs,
                                   @Value("${spring.jwt.refreshTokenValidityInMilliseconds}") Long refreshExpiredMs) {
        this.refreshTokenRepository = refreshTokenRepository;
        this.jwtUtil = jwtUtil;
        this.accessExpiredMs = accessExpiredMs;
        this.refreshExpiredMs = refreshExpiredMs;
    }


    @Override
    public void addRefreshTokenEntity(User user, String refresh) {
        if (refreshTokenRepository.findByUser(user).isPresent()){
            throw new RefreshTokenAlreadyExistsException("이미 로그인되었거나 비정상 로그아웃되었습니다.");
        }

        Date date = new Date(System.currentTimeMillis() + refreshExpiredMs);

        RefreshToken refreshToken = RefreshToken.builder()
                .user(user)
                .refresh(refresh)
                .expiration(date.toString())
                .build();

        refreshTokenRepository.save(refreshToken);
    }


    @Override
    public void removeRefreshToken(String refreshToken) {
        if(!refreshTokenRepository.existsByRefresh(refreshToken)){
            throw new InvalidRefreshTokenException("등록된 토큰이 아닙니다.");
        }

        refreshTokenRepository.deleteByRefresh(refreshToken);

        Cookie cookie = new Cookie("refresh", null);
        cookie.setMaxAge(0);
        cookie.setPath("/");
    }
    // 1. 재발급 요청은 프론트에서 보낸다

    @Override
    public String reissueAccessToken(String refreshToken) {

        if (jwtUtil.isExpired(refreshToken)){
            throw new ExpiredJwtException(null, null, "Refresh token is still valid, no need to reissue access token");
        }

        if (!jwtUtil.getCategory(refreshToken).equals("refresh")){
            throw new InvalidRefreshTokenException("Provided token is not a refresh token");
        }

        RefreshToken token = refreshTokenRepository.findByRefresh(refreshToken)
                .orElseThrow(() -> new InvalidRefreshTokenException("Invalid or non-existent refresh token"));

        User user = token.getUser();

        return jwtUtil.createJwt("access",user.getUsername(),user.getEmail(),user.getRole(),accessExpiredMs);
    }
}

 

 

token 과 관련된 repository 기능을 분리해서 구현했다.

 

 


UserSerivce 에서  Adapter를 가져와 Token 기능을 사용

public JwtResponseDto authenticateUser(LoginRequestDto loginRequestDto) {

    User user = userRepository.findByUsernameAndDeletedAtIsNull(loginRequestDto.getUsername())
            .orElseThrow(()-> new IllegalArgumentException("Invalid username : " + loginRequestDto.getUsername()));

    if (!passwordEncoder.matches(loginRequestDto.getPassword(),user.getPassword() )){
        throw new IllegalArgumentException("Invalid password : " + loginRequestDto.getPassword());
    }

    String accessToken = jwtService.createAccessToken(user);
    String refreshToken = jwtService.createRefreshToken(user);

    refreshTokenService.addRefreshTokenEntity(user,refreshToken);

    return new JwtResponseDto(accessToken,refreshToken);
}

public void removeRefreshToken(String refreshToken) {

    if (jwtService.isTokenExpired(refreshToken)){
        return;
    }

    if (!jwtService.getCategory(refreshToken).equals("refresh")){
        throw new InvalidRefreshTokenException("잘못된 토큰이 들어왔습니다.");
    }

    refreshTokenService.removeRefreshToken(refreshToken);
}