JWT + OAuth2.0을 활용하여 로그인, 회원가입 구현하기 (React, Spring) (Redis + Login)

Lord·2024년 7월 23일
post-thumbnail

두번째 포스팅에 이어서 3번째 포스팅이다. 이번에는 Login 관련 코드와 redis에 대한 설명을 담을 것 같다. 먼저 왜 Redis를 사용하였는지에 대해 이야기해보자.

왜 Redis 인가?

1. Redis란?

Redis는 오픈 소스 인메모리 데이터 구조 저장소로, 높은 성능과 유연성을 제공한다. 주로 캐싱, 세션 관리, 실시간 분석 등에 사용되며, 다양한 데이터 구조(문자열, 해시, 리스트, 세트, 정렬된 세트 등)를 지원한다.

2. Redis를 사용한 이유

2.1. 높은 성능

Redis는 인메모리 데이터베이스로, 데이터 조회와 저장 속도가 매우 빠르다. 이는 사용자의 로그인 상태를 빠르게 검증하고 새로운 액세스 토큰을 발급받는 데 유리하다. 리프레시 토큰은 빈번하게 조회될 수 있으므로, Redis의 빠른 성능은 큰 장점이 된다.

2.2. 확장성

Redis는 클러스터링을 통해 수평 확장이 가능하다. 이는 사용자가 증가할수록 더 많은 데이터를 저장하고 빠르게 접근할 수 있음을 의미한다. 대규모 사용자 기반을 가진 애플리케이션에서도 Redis는 안정적인 성능을 제공할 수 있다.

2.3. 유연한 데이터 구조

Redis는 다양한 데이터 구조를 지원하여, 리프레시 토큰과 같은 복잡한 데이터를 효율적으로 저장하고 관리할 수 있다. 예를 들어, 해시(Hash) 자료구조를 사용하면 사용자별로 리프레시 토큰과 관련된 여러 메타데이터를 함께 저장할 수 있다.

2.4. TTL(Time-to-Live) 지원

Redis는 각 키에 대해 TTL(Time-to-Live)을 설정할 수 있어, 자동으로 만료되는 데이터를 관리하기에 적합하다. 리프레시 토큰은 일정 시간이 지나면 만료되어야 하므로, TTL 기능을 사용하여 토큰의 수명을 쉽게 관리할 수 있다.

2.5. 복제 및 내구성

Redis는 데이터를 복제하고 지속적으로 백업할 수 있는 기능을 제공한다. 이는 리프레시 토큰과 같은 중요한 데이터를 안전하게 보호할 수 있음을 의미한다. Redis의 복제 기능을 사용하면 데이터 유실의 위험을 줄이고, 시스템 장애 시에도 데이터를 복구할 수 있다.

Redis 관련 주요 소스코드

Redis 서비스 (RedisService.java)

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisService {
    private final RedisTemplate<String, Object> redisTemplate;

    public void setValues(String key, String data) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data);
    }

    public void setValuesWithDuration(String key, String data, Duration duration) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    @Transactional(readOnly = true)
    public String getValues(String key) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        if (values.get(key) == null) {
            return null;
        }
        return (String) values.get(key);
    }

    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }
}

RedisService 클래스는 Redis 데이터베이스와 상호작용하는 서비스 클래스이다. Redis에 값을 저장하고, 조회하고, 삭제하는 기능을 제공한다.

  1. setValues

    • 설명: 주어진 키와 데이터를 Redis에 저장한다.
    • 입력: key (저장할 키), data (저장할 데이터)
    • 로직:
      • redisTemplate.opsForValue()를 사용하여 ValueOperations 객체를 가져온다.
      • values.set(key, data)를 호출하여 주어진 키와 데이터를 Redis에 저장한다.
  2. setValuesWithDuration

    • 설명: 주어진 키와 데이터를 Redis에 저장하며, 저장 기간을 설정한다.
    • 입력: key (저장할 키), data (저장할 데이터), duration (저장 기간)
    • 로직:
      • redisTemplate.opsForValue()를 사용하여 ValueOperations 객체를 가져온다.
      • values.set(key, data, duration)를 호출하여 주어진 키와 데이터를 저장 기간과 함께 Redis에 저장한다.
  3. getValues

    • 설명: 주어진 키에 해당하는 데이터를 Redis에서 조회한다.
    • 입력: key (조회할 키)
    • 출력: data (조회된 데이터)
    • 로직:
      • redisTemplate.opsForValue()를 사용하여 ValueOperations 객체를 가져온다.
      • values.get(key)를 호출하여 주어진 키에 해당하는 데이터를 조회한다.
      • 조회된 데이터가 없으면 null을 반환한다.
    • 트랜잭션: @Transactional(readOnly = true) 어노테이션을 사용하여 읽기 전용 트랜잭션으로 설정한다.
  4. deleteValues

    • 설명: 주어진 키에 해당하는 데이터를 Redis에서 삭제한다.
    • 입력: key (삭제할 키)
    • 로직:
      • redisTemplate.delete(key)를 호출하여 주어진 키에 해당하는 데이터를 삭제한다.

Redis를 사용하여 애플리케이션의 데이터를 캐싱하거나, 세션 관리, 임시 데이터 저장 등의 용도로 사용할 수 있다. RedisTemplate을 주입받아 Redis와 상호작용하며, ValueOperations 객체를 사용하여 키-값 쌍을 저장, 조회, 삭제하는 기능을 제공한다.

Login 관련 주요 소스코드

JSON 인증 필터 (JsonAuthenticationFilter.java)

public class JsonAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private static final String DEFAULT_LOGIN_URL = "/members/login";
    private static final String HTTP_METHOD = "POST";
    private static final String CONTENT_TYPE = "application/json";
    private static final String USERNAME_KEY = "email";
    private static final String PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_LOGIN_REQUEST_MATCHER =
            new AntPathRequestMatcher(DEFAULT_LOGIN_URL, HTTP_METHOD);

    private final ObjectMapper objectMapper;

    public JsonAuthenticationFilter(ObjectMapper objectMapper) {
        super(DEFAULT_LOGIN_REQUEST_MATCHER);
        this.objectMapper = objectMapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
            throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
        }

        String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
        Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);

        String email = usernamePasswordMap.get(USERNAME_KEY);
        String password = usernamePasswordMap.get(PASSWORD_KEY);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);

        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

JsonAuthenticationFilter 클래스는 JSON 요청 본문을 통해 인증을 처리하는 필터 클래스이다. AbstractAuthenticationProcessingFilter를 상속받아 JSON 형식의 로그인 요청을 처리한다.

  1. 필드

    • DEFAULT_LOGIN_URL: 기본 로그인 URL로, "/members/login"으로 설정된다.
    • HTTP_METHOD: HTTP 메소드로, "POST"로 설정된다.
    • CONTENT_TYPE: 요청 콘텐츠 타입으로, "application/json"으로 설정된다.
    • USERNAME_KEY: JSON 요청 본문에서 사용자명(이메일)을 추출하기 위한 키.
    • PASSWORD_KEY: JSON 요청 본문에서 비밀번호를 추출하기 위한 키.
    • DEFAULT_LOGIN_REQUEST_MATCHER: 기본 로그인 요청 매처로, 지정된 URL과 HTTP 메소드에 매칭된다.
    • objectMapper: JSON 파싱을 위한 Jackson의 ObjectMapper 객체.
  2. 생성자

    • 설명: 기본 로그인 요청 매처와 ObjectMapper를 초기화한다.
    • 입력: ObjectMapper 객체
    • 로직: super(DEFAULT_LOGIN_REQUEST_MATCHER)를 호출하여 기본 로그인 요청 매처를 설정하고, objectMapper를 초기화한다.
  3. attemptAuthentication

    • 설명: 인증 시도를 처리한다.
    • 입력: HttpServletRequest, HttpServletResponse
    • 출력: Authentication
    • 예외: AuthenticationException, IOException, ServletException
    • 로직:
      • 요청의 콘텐츠 타입이 "application/json"인지 확인한다. 그렇지 않은 경우 AuthenticationServiceException 예외를 발생시킨다.
      • 요청 본문을 읽어 String 형식으로 변환한다.
      • objectMapper를 사용하여 요청 본문을 Map<String, String>으로 변환한다.
      • 사용자명(이메일)과 비밀번호를 추출한다.
      • UsernamePasswordAuthenticationToken 객체를 생성하여 인증 요청을 처리한다.
      • this.getAuthenticationManager().authenticate(authRequest)를 호출하여 인증을 시도한다.

JSON 형식의 로그인 요청을 처리하여 사용자명(이메일)과 비밀번호를 추출하고, 이를 기반으로 인증을 시도한다. 이를 통해 클라이언트는 JSON 형식으로 로그인 요청을 보낼 수 있으며, 서버는 이를 처리하여 인증을 수행할 수 있다.

로그인 성공 핸들러 (LoginSuccessHandler.java)

@Slf4j
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtService jwtService;
    private final MemberRepository memberRepository;
    private final RedisService redisService;

    @Value("${jwt.access.expiration}")
    private String accessTokenExpiration;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        ObjectMapper objectMapper = new ObjectMapper();
        String email = extractUsername(authentication);
        List<String> roleList = new ArrayList<>();
        authentication.getAuthorities().forEach(authority -> {
            roleList.add(authority.getAuthority());
        });
        String role = roleList.get(0);
        String accessToken = jwtService.createAccessToken(email, role);
        String refreshToken = jwtService.createRefreshToken(email);

        jwtService.sendAccessTokenAndRefreshToken(response, accessToken, refreshToken);

        memberRepository.findByEmail(email)
                .ifPresent(member -> {
                    Map<String, Object> map = new HashMap<>();
                    map.put("nickname", member.getNickname());
                    map.put("email", member.getEmail());
                    response.setStatus(HttpStatus.OK.value());
                    response.setContentType("application/json");
                    try {
                        objectMapper.writeValue(response.getWriter(), map);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                    redisService.setValues("RefreshToken" + member.getEmail(), refreshToken);
                });
        log.info("로그인에 성공하였습니다. 이메일 : {}", email);
        log.info("로그인에 성공하였습니다. 액세스 토큰 : {}", accessToken);
        log.info("액세스 토큰 만료 기간 : {}", accessTokenExpiration);
    }

    private String extractUsername(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return userDetails.getUsername();
    }
}

LoginSuccessHandler 클래스는 인증이 성공적으로 완료되었을 때 호출되는 핸들러로, JWT 토큰을 생성하고, 응답에 추가하여 클라이언트에게 전달하는 역할을 한다. SimpleUrlAuthenticationSuccessHandler를 상속받아 인증 성공 후의 추가 작업을 처리한다.

  1. 필드

    • jwtService: JWT 토큰을 생성하고 검증하는 서비스.
    • memberRepository: 회원 정보를 조회하는 리포지토리.
    • redisService: Redis와 상호작용하는 서비스.
    • accessTokenExpiration: JWT 액세스 토큰 만료 기간.
  2. onAuthenticationSuccess

    • 설명: 인증이 성공적으로 완료되었을 때 호출된다.
    • 로직:
      • ObjectMapper를 사용하여 JSON 응답을 작성한다.
      • extractUsername 메소드를 호출하여 인증된 사용자의 이메일을 추출한다.
      • 인증된 사용자의 역할 리스트를 추출하고, 첫 번째 역할을 가져온다.
      • jwtService를 사용하여 액세스 토큰과 리프레시 토큰을 생성한다.
      • 생성된 액세스 토큰과 리프레시 토큰을 응답 헤더에 추가한다.
      • memberRepository를 사용하여 회원 정보를 조회하고, 회원 정보를 JSON 형식으로 응답에 작성한다.
      • 생성된 리프레시 토큰을 Redis에 저장한다.
      • 로그를 통해 인증 성공 메시지와 액세스 토큰 정보를 기록한다.
  3. extractUsername

    • 설명: 인증된 사용자로부터 이메일을 추출한다.
    • 입력: Authentication 객체
    • 출력: 이메일 (String)
    • 로직:
      • UserDetails 객체를 가져와서 사용자명을 반환한다.

인증이 성공적으로 완료된 후 JWT 토큰을 생성하고, 클라이언트에게 응답으로 전달하여 이후의 요청에서 인증 정보를 사용할 수 있도록 한다. 이를 통해 클라이언트는 액세스 토큰을 사용하여 인증된 상태를 유지할 수 있다.

로그인 실패 핸들러 (LoginFailureHandler.java)

@Slf4j
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/plain;charset=UTF-8");
        if (authenticationException instanceof LockedException) {
            response.setStatus(606);
            response.getWriter().write("비활성화된 계정입니다. 관리자에 문의해주세요.");
        } else {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().write("이메일이나 비밀번호를 확인해주세요.");
        }
        log.info("로그인에 실패하였습니다. {}", authenticationException.getMessage());
    }
}

LoginFailureHandler 클래스는 인증이 실패했을 때 호출되는 핸들러로, 실패 원인에 따라 적절한 응답을 반환하는 역할을 한다.SimpleUrlAuthenticationFailureHandler를 상속받아 인증 실패 시 추가 작업을 처리한다.

  1. onAuthenticationFailure
    • 설명: 인증이 실패했을 때 호출된다.
    • 입력: HttpServletRequest, HttpServletResponse, AuthenticationException
    • 출력: 없음
    • 로직:
      • 응답의 문자 인코딩을 "UTF-8"로 설정하고, 콘텐츠 타입을 "text/plain;charset=UTF-8"로 설정한다.
      • authenticationException의 타입에 따라 다른 응답을 반환한다.
        • LockedException: 계정이 비활성화된 경우
          • HTTP 상태 코드를 606으로 설정한다.
          • 응답 본문에 "비활성화된 계정입니다. 관리자에 문의해주세요."라는 메시지를 작성한다.
        • 그 외의 예외: 일반적인 인증 실패 (이메일이나 비밀번호 오류 등)
          • HTTP 상태 코드를 400(BAD_REQUEST)으로 설정한다.
          • 응답 본문에 "이메일이나 비밀번호를 확인해주세요."라는 메시지를 작성한다.
      • 로그를 통해 인증 실패 메시지를 기록한다.

이렇게 모든 주요 백엔드 코드를 살펴보았다. 코드에 대한 설명을 하나하나 작성하느라 시간이 꽤 걸렸지만 회고하는 시간을 가진 것 같아 뿌듯하다. 다음은 프론트엔드관련 포스팅을 이어갈 예정이다.

profile
다재다능한 Backend 개발자에 도전하는 개발자

0개의 댓글