본문 바로가기
기타

[내일배움캠프 - Spring 숙련주차 팀 프로젝트] 뉴스피드 프로젝트 KPT 회고

by ㅇ달빛천사ㅇ 2024. 9. 6.
728x90

1. 깃허브 링크

 

GitHub - MinjuKang727/nbcamp_spring_newsfeed_project

Contribute to MinjuKang727/nbcamp_spring_newsfeed_project development by creating an account on GitHub.

github.com


2. 팀 노션 페이지

 

Spring 숙련주차 팀 프로젝트 - 뉴스피드 N along | Notion

4. 와이어프레임

sequoia-carpet-385.notion.site

 


3. 시연 영상

 


4. Only My 트러블 슈팅 

Spring Security 로그아웃 핸들러 구현

Spring Security 구조를 아직 잘 몰라서 로그아웃 처리를 filter 단에서 어떻게 하는지 몰라서 발생한 문제입니다.
인터넷에서 검색한 결과를 바탕으로 LogoutHandler를 상속받은 UserLogoutHandler를 구현하여
WebSecurityConfig에서 로그아웃 핸들러 등록을 하였습니다.
그런데 PostMan에서 POST 메서드로 로그아웃 URL에 요청을 보낸 결과
해당 메서드를 사용할 수 없다는 에러가 났습니다.

다른 HTTP 메서드로도 동일한 결과가 나와서 다시 인터넷으로 알아본 결과
AntiRequestMatcher메서드에 AntPathRequestMatcher객체를 통해
요청 방식을 POST방식으로 설정해 주어 해결하였습니다.

 

UserLogoutHandler.java

package com.sparta.newsfeed_project.auth;

import com.sparta.newsfeed_project.auth.jwt.JwtUtil;
import com.sparta.newsfeed_project.domain.token.TokenBlacklistService;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;

@Slf4j(topic = "UserLogoutHandler")
public class UserLogoutHandler implements LogoutHandler {

    private final TokenBlacklistService tokenBlacklistService;

    public UserLogoutHandler(TokenBlacklistService tokenBlacklistService) {
        this.tokenBlacklistService = tokenBlacklistService;
    }

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        log.info("로그아웃 시도");
        try {
            this.tokenBlacklistService.addTokenToBlackList(request);
            response.setHeader(JwtUtil.AUTHORIZATION_HEADER, null);
        } catch (ServletException e) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        }


    }
}

 

WebSecurityConfig.java

// 코드 생략

@Slf4j(topic = "WebSecurityConfig")
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class WebSecurityConfig {
    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;
    private final TokenBlacklistService tokenBlacklistService;
    private final AuthenticationConfiguration authenticationConfiguration;

    // 코드 생략

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        // 코드 생략

        httpSecurity.addFilterBefore(jwtAuthorizationFilter(), LogoutFilter.class)
                .logout(logout ->
                    logout
                        .logoutRequestMatcher(new AntPathRequestMatcher("/users/logout", "POST"))
                        .addLogoutHandler(new UserLogoutHandler(tokenBlacklistService))
                        .logoutSuccessHandler((request, response, authentication) -> {
                                response.setStatus(HttpServletResponse.SC_OK);
                                response.getWriter().write("Logout Success");
                        })
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
        );

        // 코드 생략
        
        return httpSecurity.build();
    }
 }

JWT 토큰 로그아웃 처리

PostMan에서 테스트 도중 이전 발급된 만료되지 않은 JWT 토큰으로 로그아웃 후에도

사용자 정보 수정이 가능하다는 것을 발견 이를 해결하기 위한 방법을 찾아보았습니다.

  • 토큰 블랙리스트 사용
  • 짧은 만료 시간 설정 및 리프레시 토큰 사용
  • 클라이언트 측에서 토큰 폐기

의 방법을 찾을 수 있었습니다.


리프레시 토큰은 엑세스 토큰과 리프레시 토큰을 구현해야하는데

아직 해당 부분을 접해 본 적이 없는 상태에서 구현하기에 시간이 부족할 것 같아 선택 항목에서 제외,
클라이언트 측에서 토큰 폐기하는 방법은 이미 발급된 토큰을 서버단에서 폐기할 방법이 없어 제외
토큰 블랙리스트 방법을 사용하기로 하였습니다.


토큰 블랙리스트 방법폐기한 토큰을 따로 저장하여 사용자 인증시 해당 토큰이 블랙리스트인지 함께 검증하는 방법인데요.
마지막으로 블랙리스트 방법이 남은 상태에서
이 방법을을 선택하기 전에 강의에서 JWT 토큰의 장점이 STATELESS(무상태) 라고 하셨는데
이것을 따로 저장한다고 한다면 이건 STATELESS가 아니지 않은가? 하고 고민하였습니다.


인터넷에 검색을 해 보니 보안이 중요한 시스템에서는 일부 상태를 저장하는 하이브리드 방식을 고려해야할 수 있다고 해서 블랙리스트 방법을 선택하였습니다.


폐기된 토큰을 저장소로는

  • IN-MEMORY
  • DB
  • Redis와 같은 인메모리 데이터베이스

가 있었는데 각각 장단점이 있었으나 인터넷에 Redis를 사용한 방법이 많았고
토큰 만료 시간을 활용한 TTL 설정으로 성능과 유연성을 모두 얻을 수 있다고 해서
그 방법을 사용하고 싶었으나 Redis는 따로 설치를 하는 과정이 필요해서
DB에 따로 토큰 블랙리스트를 만들었습니다.


로그아웃 핸들러에서 로그아웃 요청이 들어올 때, DB에 토큰 값을 저장하도록 구현하였습니다.
그런데 막상 구현을 끝내고 보니

JWT토큰을 RESPONSE HEADER에 담아서 보내서 따로 블랙리스트 필요없이 그냥 HEADER에서 JWT 토큰을 없애면 되는 것이었습니다.


이번 트러블 슈팅은 아직 제가 서비스 구조와 흐름을 잘 이해하지 못해 간단히 해결방법을 찾지 못했던 것 같습니다.
하지만 블랙리스트를 통해 좀 더 강력한 보안 및 사용자 로그아웃 기록을 남길 수 있다는 점에서는 블랙리스트를 사용할 가치가 있다고 생각되어집니다.

개인적으로는 토큰 관리 방법에 대해 많이 고민하고 배울 수 있었던 시간이었던 것 같습니다.

 

Token.java

package com.sparta.newsfeed_project.domain.token;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Date;

@Entity
@Getter
@NoArgsConstructor
@Table(name = "token_blacklist")
public class Token {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false, unique = true)
    private String token;
    private Date expirationTime;

    public Token(String tokenValue, Date expirationTime) {
        this.token = tokenValue;
        this.expirationTime = expirationTime;
    }
}

 

TokenBlacklist.java

package com.sparta.newsfeed_project.domain.token;

import com.sparta.newsfeed_project.auth.jwt.JwtUtil;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Date;

@Slf4j(topic = "TokenBlacklistService")
@Service
@Transactional(readOnly = true)
public class TokenBlacklistService {
    private final TokenBlacklistRepository tokenBlacklistRepository;
    private final JwtUtil jwtUtil;

    public TokenBlacklistService(TokenBlacklistRepository tokenBlacklistRepository, JwtUtil jwtUtil) {
        this.tokenBlacklistRepository = tokenBlacklistRepository;
        this.jwtUtil = jwtUtil;
    }

    @Transactional
    public void addTokenToBlackList(HttpServletRequest request) throws ServletException {
        log.info("토큰 블랙리스트에 추가");

        String token = jwtUtil.getDecodedToken(request);
        if (token != null) {
            Date expirationTime = jwtUtil.getExpirationTime(token);
            Token expiredToken = new Token(token, expirationTime);
            this.tokenBlacklistRepository.save(expiredToken);
        }
    }

    public boolean isTokenBlackListed(HttpServletRequest request) throws ServletException {
        log.info("토큰 not 블랙리스트 검증");
        String token = jwtUtil.getDecodedToken(request);

        return this.tokenBlacklistRepository.existsByToken(token);
    }

    @Transactional
    public void removeTokenFromBlackList() {
        log.info("만료된 토큰 블랙리스트 정리");
        this.tokenBlacklistRepository.deleteByExpirationTimeBefore(new Date());
    }
}

 

TokenBlacklistRepository.java

package com.sparta.newsfeed_project.domain.token;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Date;

public interface TokenBlacklistRepository extends JpaRepository<Token, Long> {
    boolean existsByToken(String token);
    void deleteByExpirationTimeBefore(Date now);
}

 

로그아웃 핸들러에서 블랙리스트에 로그아웃한 유저의 토큰 추가


사용자 로그아웃과 관련하여 미처 생각하지 못했던 토큰 블랙리스트 테이블 생성

JWT 토큰으로 로그아웃 구현을 처음 하다보니 정확히 어떤 데이터가 필요한지 몰라
처음에는 생각하지 못한 토큰 블랙리스트 테이블을
로그아웃 기능 구현 중 급하게 추가하게되어 ERD를 수정해야 했습니다.
데이터베이스는 한번 만들고 난 이후에는 구조를 수정하기 번거롭고 어렵다고 알고 있는데
비록 연관관계가 없는 독립된 테이블이었지만
제가 아직 모르는 것이 많아 뒤늦게 ERD를 수정했어야 했다는 점이 많이 아쉬웠고
앞으로도 더 열심히 공부해서 이런 일이 발생하지 않도록 해야겠다고 생각하였습니다.

 


5. KPT 회고 

 

Keep - 현재 만족하고 있는 부분

Problem - 불편하게 느끼는 부분

Try - Problem에 대한 해결책, 당장 실행 가능한 것


6. 과제 피드백

1. exception handler에서 http status를 모두 400으로 보내셨는데, 각 상황에 맞는 상태 코드를 사용하는 것이 좋겠습니다. 예를 들어 MethodArgumentNotValidException 의 경우, 클라이언트의 요청 자체가 잘못된 것이므로 400이 맞습니다.

그러나 IOException 의 경우는 서버에서 작업하던 중에 발생한 예외이므로 보통은 500을 사용합니다.

validateToken 이 IOException을 던지기 때문에 토큰 오류로 간주하신 것으로 확인했는데,

IOException의 경우 파일 시스템에 접근하는 등 다양한 상황에서 발생할 수 있는 오류이기 때문에 토큰 검증에 한해서라고 단정짓기가 어렵습니다.

토큰 검증의 경우에는 커스텀 예외나 400번대 상태 코드를 던질 수 있는 오류를 사용하는 것이 좋겠습니다.

 

2. tokenBlacklistService.isTokenBlackListed 를 거의 모든 컨트롤러에서 서비스에 진입하기 직전 사용하고 있는데,

이것을 좀 더 간단히 해결할 수 있는 방법은 어떤 것이 있을까요?

답은 이미 여러분의 코드에 있습니다^_^

 

3. post 엔티티에 연관관계로 걸려있는 user 엔티티, 또는 comment 엔티티에 연관관계로 걸려있는 post는 setter를 사용하는 이유가 있을까요?

여러분이 이미 공부하셨듯이 setter는 지양하는 것이 좋고, 사용할 때에는 명확한 기준을 가지고 사용해야 합니다.

여기서는 그 기준을 알아보기가 좀 어려웠는데, 여기에 대해서 답변해 보시면 좋은 면접 준비가 될 것 같습니다.

 

4. 코드에 일관되지 않은 부분이 보이는데요,

예를 들어 id로 엔티티를 조회했을 때 어느 부분에서는 NullPointerException을 던지고 있고,

어느 부분에서는 IllegalArgumentException을 던지고 있습니다.

또는 정의된 예외에 관해서도, 어느 부분은 RuntimeException를 사용하고

어느 부분은 CommonException을 사용하고 있네요.

이런 부분들은 팀원끼리 협의하여 어떤 방식으로 처리하겠다를 미리 정하시고 사용하시는 것이 좋겠습니다.

 

5. like 컨트롤러 쪽 api 시그니처가 restful하지 않게 느껴집니다.

어떻게 하면 restful한 api를 만들 수 있는지 좀 더 고민해보세요.

여러분!!! 세 분이서 이만큼 만들기 쉽지 않으셨을텐데 정말 고생 많으셨어요.

각자 맡은 기능도 잘 구현해주셨고, 서로 도와가면서 원활하게 협업도 하셨습니다.

짧은 시간 동안 구현하고 발표하시느라 수고 많으셨어요!!!

 

7. Exception 코드 구현부분에 있어 properties라는 것을 활용해 보면 좋을 것 같습니다.

728x90