뉴스피드 PJ 트러블 슈팅

ChoRong0824·2025년 2월 20일
0

Web

목록 보기
32/51
post-thumbnail

https://github.com/newspeed8/newspeed/commit/f3e90739a9f5a1c56c11732f4b29453344bfe4d8
선정 이유 : 세션 기반 로그인 방식의 한계를 극복하기 위해, jwt를 사용함으로써 서버의 상태 관리를 없애 분산 환경에서도 원활한 인증 처리를 통해 stateless 인증 체계로 전환하여 서버 부하를 줄이고 확장성을 개선했습니다

크게 기능을 나누면,
User, Post, Like, comment, profile등이 있습니다.
이에 저는 Post를 담당했습니다.

구현한 기능 정리

1. 게시물(Post) 관리

  • 게시물 작성, 조회, 수정, 삭제 기능
  • 작성: 사용자는 새로운 게시물을 작성할 수 있습니다.
  • 조회: 전체 게시물 조회 및 개별 게시물 조회 기능 제공
  • 수정/삭제:게시물 수정 및 삭제는 반드시 작성자 본인만 수행할 수 있습니다.
    작성자가 아닌 사용자가 수정 또는 삭제를 시도하면 예외를 발생시켜 접근을 차단합니다.
  • 예외 처리
    작성자 본인이 아닌 사용자가 게시물 수정/삭제를 시도할 경우, 적절한 HTTP 에러(예: 403 Forbidden)를 반환합니다.

2. 뉴스피드(post) 조회 기능 상세 설명

  • 전체 일정 조회, 단일 조회
  • 정렬: 기본 정렬은 게시물의 생성일자를 기준으로 내림차순 정렬합니다.
  • 페이지네이션: 한 페이지에 10개의 게시물 데이터가 나오도록 페이지네이션을 적용 및 리팩토링하였습니다.
  • 추가 기능
    업그레이드된 뉴스피드 기능으로, 좋아요 수 기준 정렬이나 기간별 검색 기능을 추가적으로 구현할 수 있습니다.

3. JWT 기반 인증/인가

  • 기존 세션 기반 인증을 JWT 기반 인증으로 전환하여,
  • 로그인: /auth/login 엔드포인트를 통해 사용자가 로그인하면 JWT 토큰이 발급됩니다.
  • 보호된 엔드포인트: 발급받은 JWT 토큰을 Authorization: Bearer <토큰> 헤더에 포함하여 보호된 API에 접근할 수 있습니다.
  • 사용자 정보 활용: Spring Security의 SecurityContextHolder를 통해 현재 인증된 사용자 정보를 가져와, 게시물 수정/삭제 등의 권한을 검증합니다.
  • 예외 처리: JWT 토큰이 유효하지 않거나 만료된 경우, 적절한 HTTP 에러(예: 401 Unauthorized 또는 403 Forbidden)를 반환합니다.
  • 토큰 유효기간 @Value("${jwt.expirationMs}")1일로 설정
  • @Value("${jwt.secret}")를 통해 토큰 및 유효기간을 숨켰으며, properties에 토큰 세팅해뒀습니다.

4. 전반적인 Post 리팩토링

JPQL 검토 및 오타수정, 성능 향상을 위해 User로만 조인 가능하도록 재구성.


트러블 슈팅

트러블 슈팅에 관련해선 정말 많이 작성할 것이 많지만, 제일 큰 jwt 해결에 대해 작성하려합니다.
세션기반으로 돌아가던 프로젝트를 jwt기반로 동작하도록 코드를 전반적으로 수정했습니다.
jwt를 채택하고 개발한 이유 : 세션 기반 로그인 방식의 한계를 극복하기 위해, jwt를 사용함으로써 서버의 상태 관리를 없애 분산 환경에서도 원활한 인증 처리를 통해 stateless 인증 체계로 전환하여 서버 부하를 줄이고 확장성을 개선했습니다.

업데이트와 삭제 API에서 작성자 본인 여부를 체크하지 않고 있던 코드였습니다.
이로 인해 다른 사용자가 임의로 게시물을 수정하거나 삭제할 수 있는 보안 취약점이 발생할 수 있습니다.
개선 방안: 컨트롤러나 서비스 레이어에서 요청한 사용자가 해당 게시물의 작성자인지 확인하는 로직을 추가합니다.
예를 들어, 보안 컨텍스트 또는 JWT 토큰을 활용해 현재 로그인한 사용자 정보를 가져와 게시물의 작성자와 비교하도록 합니다.
성능 및 보안 개선 효과: 불필요한 데이터 수정/삭제를 방지하여, 잘못된 접근 시 DB 작업을 막음으로써 시스템 부하를 줄이고, 보안성이 향상됩니다.

‼️ 제일 중요한 이유 : jwt를 사용해보고 싶었으며, 지금 아니면 기회가 아니라고 생각되어 jwt에 대해 학습하며 코드 구현을 하였으며, 리팩토링까지 했습니다.

테스트 과정
이렇게 jwt를 정상적으로 코드 작성 후에 postman으로 테스트를 하게 되면, 이런 화면이 보일 것입니다.
아래 화면 같은 경우 "jwt.io"라는 사이트에 접속하셔서 본인의 토큰을 작성하면 userId를 받는지 여부를 확인할 수 있습니다.

다시 말해, jwt.io에 토큰을 붙여넣으면 자동으로 디코딩되어 Header, Payload, Signature 세 부분이 표시됩니다.
아무 추가 조작 없이, 단순히 붙여넣은 후 Payload 영역을 확인하면 됩니다. 여기서 "userId" 클레임이 "userId": "1"과 같이 포함되어 있는지 확인하시면 됩니다.
즉, 로그인 후 받은 토큰을 jwt.io의 Encoded 필드에 붙여넣고, 자동으로 표시되는 디코딩 결과에서 Payload에 포함된 클레임을 검토하면 됩니다.

발급 받은 토큰을 인증인가 부분의 헤더에

요러케 넣어주시고 필요한 테스트를 진행해주시면 전부 다 정상 동작하는 것을 확인할 수 있습니다.


트러블 슈팅 - jwt

/auth/login 에서 500Error 발생.
구글링에서 공부한 이전 코드는 아래와 같아서, 혹시라도 최신 버전은 0.11.5라서 다른건가? 라는 생각을 하게 됌 -> 이전에 필터체인이었나? 거기서 최신 버전으로 바뀌면서 문법이 바껴서 코드 적용이 제대로 안되던 기억을 토대로 해당 코드도 그럴까봐 수정.

return Jwts.builder()
        .setSubject(email)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
        .signWith(SignatureAlgorithm.HS512, jwtSecret)
        .compact();

를 jjwt 0.11.5버전 부턴

return Jwts.builder()
        .setSubject(email)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
        .signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS512)
        .compact();

로 수정.
이제 나머지 코드를 주석 처리한 부분에 내용을 추가하면 되는 것이었습니다.
즉, 나머지 메서드에서도 동일하게 서명 키를 Key 객체로 반환하는 것이 안전하며,
getEmailFromJwtToken과 validateJwtToken 메서드에서도 jwtSecret을 바로 사용하기보다는 아래와 같이 수정하는 것이 좋겠다고 생각되어 수정했습니다.

public boolean validateJwtToken(String token) {
    try {
        Jwts.parser().setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))).parseClaimsJws(token);
        return true;
    } catch (SecurityException | MalformedJwtException e) {
        // 잘못된 JWT 서명
    } catch (ExpiredJwtException e) {
        // 만료된 JWT
    } catch (UnsupportedJwtException e) {
        // 지원되지 않는 JWT
    } catch (IllegalArgumentException e) {
        // JWT가 빈 값
    }
    return false;
}

이제 코드 수정.

public boolean validateJwtToken(String token) {
        try {
            Jwts.parser()
                .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)))
                .parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            // 잘못된 JWT 서명
            System.err.println("Invalid JWT signature: " + e.getMessage());
        } catch (ExpiredJwtException e) {
            // 만료된 JWT
            System.err.println("JWT token is expired: " + e.getMessage());
        } catch (UnsupportedJwtException e) {
            // 지원되지 않는 JWT
            System.err.println("JWT token is unsupported: " + e.getMessage());
        } catch (IllegalArgumentException e) {
            // JWT가 빈 값
            System.err.println("JWT claims string is empty: " + e.getMessage());
        }
        return false;
    }

이렇게 하면, JWT 토큰 생성과 검증 시 모두 동일한 방식으로 키를 처리하게 되어, 보안상 더 안전하며 예외 발생도 줄어듭니다.

코드 설명을 덧 붙이자면, generateJwtToken 메서드에서 jwtSecret을 byte 배열로 변환하여 Keys.hmacShaKeyFor()로 처리하고 있으며, getEmail~~토큰 및 valid토큰 메서드도 동일한 방식으로 key객체를 사용합니다.
-> 이는 jjwt 최신 버전 기준으로 서명 과정에서 발생하는 문제를 해결하는 것입니다.

하지만 호락호락하지 않죠.
아직 403 에러가 뜰 것입니다. 좀 더 확인해봐야합니다 어디서 문제가 있는지.

  1. 시큐리티 컨피그 설정 확인
    경로가 permitAll으로 설정되어 있는지 확인하면 됩니다.
    SecurityConfig는 다음과 같이 되어 있어야 합니다.
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/auth/**", "/users/signup", "/users/findall", "/users/find/*").permitAll()
    .anyRequest().authenticated()
)
  1. 기존 LoginFilter 제거 확인
    이전에 세션 기반 인증을 위해 작성했던 LoginFilter가 완전히 주석 처리되거나 제거되었는지 확인해야합니다.
    만약, LoginFilter가 여전히 등록되어 있는 상태라면, /auth/login 요청이 세션 검증 로직에 의해 거부될 수 있기 때문입니다.

  2. Postman 요청 시 헤더 확인
    Authorization 헤더가 포함되어 있지 않은지 확인하면 됩니다.
    /auth/login 엔드포인트는 토큰 없이 접근해야 합니다.
    Postman에서 요청 시 Authorization 헤더가 비어있거나 불필요하게 포함되어 있으면, JwtAuthenticationFilter가 이를 처리하면서 문제가 발생할 수 있습니다.

-> security 컨피그 설정 정상 확인. 로그인 필터 제거화니 동작 잘 됌.


500 Error.
JWT 토큰 서명에 사용하는 jwt.secret 값의 길이 문제일 수도 있어서 확인.
HS512 알고리즘은 최소 512비트(즉, 64바이트)의 비밀키가 필요한데, 저는
properties에 jwt.secret=aXJYwM+Ehr0bPq5Xv4gX3Ov6E9... 로 설정.
-> 너무 짧았어서 이 값이 64바이트보다 짧아, Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)) 호출 시 IllegalArgumentException이 발생하여 500 에러가 발생했습니다.

~~ 정상 동작~~


팀원 분께서 질의하시길, return user를 사용한 이유?

@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional(readOnly = true)
    public User login(UserLoginRequestDto dto) {
        User user = userRepository.findByEmail(dto.getEmail())
                .orElseThrow(() -> new InvalidCredentialException("이메일이 존재하지 않습니다."));

        if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
            throw new InvalidCredentialException("비밀번호가 틀렸습니다.");
        }
        return user;
    }
}

Auth서비스에 이제 유저로그인응답Dto 대신 user 엔티티를 반환하는 이유는 jwt 토큰 생성시 사용자에 대한 추가 정보를 사용하기 위해서라고 생각했으며, 기존에는 단순히 userId만 반환했다면, 이제는 JwtUtil.generate.JwtToken 메서드에서 이메일을 토큰의 subject로 사용합니다.
즉, User 엔티티 전체를 반환해서 컨트롤러에서 이를 받아 email 정보를 추출하고, 이를 바탕으로 jwt 토큰을 생성할 수 있는 것입니다.
또한, 이렇게 함으로써 로그인 성공 후, 추가 정보를 활용할 수 있으며, SecurityContext 설정 등에도 편리하게 활용할 수 있습니다.


팀원 질문, jwt 기반 인증의 동작 흐름을 알려주세요
->
1. 로그인 요청

  • 클라이언트 요청 : 사용자가 Postman 등에서 로그인 엔드포인트(예: POST /auth/login)를 호출하면서, 이메일과 비밀번호를 JSON으로 전송합니다.

  • AuthService 처리
    - 사용자 조회: AuthService는 전달받은 이메일을 기반으로 데이터베이스에서 사용자를 찾습니다.

    • 비밀번호 검증: 입력된 비밀번호와 저장된 암호화된 비밀번호를 PasswordEncoder를 통해 비교합니다.
    • 인증 성공: 비밀번호가 일치하면, 해당 User 엔티티를 반환합니다.
  • JWT 토큰 생성 (AuthController)
    - AuthController는 AuthService에서 반환된 User 엔티티를 받고, 그 중에서 이메일 정보를 사용하여 JWT 토큰을 생성.

    • JwtUtil 클래스의 generateJwtToken(email) 메서드가 호출되어,
      - 현재 시간, 만료 시간, 그리고 사용자의 이메일을 토큰의 subject로 설정.
      - Keys.hmacShaKeyFor 메서드를 이용해, 주입된 비밀키(비밀 문자열을 바이트 배열로 변환한 값)로 서명.
    • 생성된 JWT 토큰과 함께 사용자 ID, 이메일을 포함하는 응답(JSON 형태)을 클라이언트에 반환합니다.

점점 산으로 가는 것 같다. 쉽게 생각하면

로그인

사용자가 이메일과 비밀번호로 /auth/login에 로그인 → AuthService가 사용자 인증 후 User 엔티티 반환 → AuthController가 JwtUtil을 사용해 JWT 토큰 생성 후 클라이언트에 반환.

request

클라이언트는 보호된 API 호출 시, Authorization 헤더에 JWT 토큰을 포함 → JwtAuthenticationFilter가 토큰을 추출, 검증, 그리고 해당 사용자의 인증 정보를 SecurityContext에 설정 → 보호된 엔드포인트는 SecurityContext의 인증 정보를 확인하여 정상 처리.


팀원 질문, session을 쓸떄는 userId를 찾아와서 jwt에 접근을해서 userId를 가져와야 하는데, 어떻게 가져 올 수 있는지 ??
-> JWT 기반 인증에서 세션처럼 사용자 정보를 바로 가져오려면, JWT 토큰 내에 포함된 클레임(claims)을 통해 userId 같은 정보를 추출해야 합니다. 저의 의도는 클라이언트가 임의로 전달하는 값이 아니라, 인증 토큰 자체에서 안전하게 사용자 정보를 획득하자는 것이 취지였습니다.

코드와 함께 설명드리겠습니다.

  1. JWT token에 사용자 정보 포함.
    토큰 생성 시, payload에 userId 추가.
public String createToken(String username, Long userId) {
    Claims claims = Jwts.claims().setSubject(username);
    claims.put("userId", userId); // userId를 클레임에 추가
  1. JWT 토큰에서 사용자 정보 추출
public Long getUserId(String token) {
    return ((Number) Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(token)
            .getBody()
            .get("userId")).longValue();
}
  1. Authentication 객체에 사용자 정보 담기
    JWT 필터에서 검증된 토큰의 정보를 기반으로, 사용자 정보를 포함하는 Authentication 객체를 생성하여 SecurityContextHolder에 설정하면 됩니다. 이때 CustomUserDetails와 같이 userId를 담는 커스텀 객체를 활용하면, 컨트롤러에서 @AuthenticationPrincipal로 쉽게 접근할 수 있게 됩니다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider tokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = tokenProvider.resolveToken(request);
        if (token != null && tokenProvider.validateToken(token)) {
            String username = tokenProvider.getUsername(token);
            Long userId = tokenProvider.getUserId(token);
            
            // CustomUserDetails에 userId 포함해서 생성
            CustomUserDetails userDetails = new CustomUserDetails(username, userId, /* 권한 정보 등 추가 */);
            UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }
}

그리고 JwtUserDetails는 UserDetails를 구현하면서 추가 정보를 보관할 수 있게 합니다.

public class CustomUserDetails implements UserDetails {
    private String username;
    private Long userId;
    private Collection<? extends GrantedAuthority> authorities;
    
    public CustomUserDetails(String username, Long userId, Collection<? extends GrantedAuthority> authorities) {
        this.username = username;
        this.userId = userId;
        this.authorities = authorities;
    }
    
    public Long getUserId() {
        return userId;
    }
    
    // UserDetails의 다른 메서드 구현
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
    @Override
    public String getPassword() {
        return null;
    }
    @Override
    public String getUsername() {
        return username;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  1. 컨트롤러에서 @AuthenticationPrincipal 사용
    이제 컨트롤러 메서드에서는 @AuthenticationPrincipal 어노테이션을 통해 CustomUserDetails를 받아올 수 있으며, 이를 통해 userId에 접근할 수 있습니다.
@GetMapping("/profile")
public ResponseEntity<?> getProfile(@AuthenticationPrincipal CustomUserDetails userDetails) {
    Long userId = userDetails.getUserId(); // JWT에서 추출한 userId
    // userId를 기반으로 필요한 작업 수행
    return ResponseEntity.ok("Hello, userId: " + userId);
}

이는 보편적인 JwtAuthenticationFilterSecurityContextHolder를 통해 JWT를 검증하고 인증 정보를 설정하는 과정입니다.(구글링) 사용자가 작성한 코드대로, JwtTokenProvider 클래스는 JWT를 생성하고 검증하며, JwtAuthenticationFilter 필터가 요청을 처리할 때 JWT 토큰을 검증합니다. SecurityContextHolder에 인증된 사용자 정보를 설정한 뒤, 컨트롤러에서 @AuthenticationPrincipal을 통해 이 정보를 가져오는 방식으로 JWT 인증이 작동합니다. 이는 다시 말해 , 사용자가 요청한 대로 JWT 인증을 통해 안전하게 사용자 정보를 처리할 수 있도록 돕기 위한 것입니다.

제 기억이 맞다면, 3~4번은 패스했습니다. 굳이라는 생각이 들었기 떄문입니다.
원칙이라고 생각되지만,

  1. 필요 최소한의 정보만 추출.
    저의 로직에는 게시글이나 댓글 작성 시, 단순히 인증된 사용자의 userId만 필요합니다. 그래서 JWT 토큰에서 바로 userId를 추출해 비즈니스 로직에 사용할 수 있도록 설계했습니다. 굳이라는 생각은 "이 정도면 충분하다"라는 판단에서 나온 것입니다.
  2. 추가 복잡성 회피
    CustomUserDetails 객체를 만들어 SecurityContext에 담고, 컨트롤러에서 @AuthenticationPrincipal로 주입받는 것은 Spring Security의 전체 기능을 활용하는 완전한 구현 방식입니다. 그러나 우리 요구사항에서는 추가적인 권한 관리나 복잡한 사용자 정보를 필요로 하지 않으므로, 이러한 추가 작업은 오히려 복잡성을 높이는 요소로 판단했습니다.
  3. 안전성 확보
    JWT 토큰 자체가 이미 서명되어 있고, 클레임에 userId가 포함되어 있기 때문에, 클라이언트가 임의의 값을 전달하는 위험 없이 안전하게 인증된 정보를 얻을 수 있습니다. 이로 인해 추가적인 인증 객체 생성이나 세션 처리를 굳이 하지 않아도 된다고 판단했습니다.

해결되지 않은 500 에러

500 에러는 내부에서 발생한 예외 때문에 나타나며, 구체적인 원인을 파악하기 위해서는 서버 로그의 스택 트레이스를 확인해야 합니다.
다만, 아래와같이 몇 가지 일반적인 원인과 점검할 사항들이 있습니다.

1. JWT 서명 키 문제

  • HS512 알고리즘은 최소 64바이트 길이의 비밀키가 필요합니다.
  • jwt.secret에 설정한 값이 충분한 길이인지 확인.
  • 예를 들어, 터미널에서 openssl rand -base64 64를 실행해 생성한 값을 사용하면 좋습니다.

2. JwtUtil 메서드 수정

  • generateJwtToken, getEmailFromJwtToken, validateJwtToken 모두에서 Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))를 사용하도록 수정했는지 확인.
    혹시 일부 메서드에서 여전히 raw 문자열(jwtSecret)을 사용하고 있다면, Key 객체 생성 과정에서 오류가 발생할 수 있습니다.

3. 프로퍼티 주입 문제

application.properties에서 jwt.secret과 jwt.expirationMs가 제대로 설정되고, JwtUtil에 주입되고 있는지 확인.
만약 값이 null이거나 올바르지 않다면, 토큰 생성 시 오류가 발생할 수 있습니다.

4. 의존성 및 버전 문제

사용 중인 jjwt 라이브러리의 버전과 Spring Boot 3.x, jakarta.servlet 관련 의존성이 올바르게 설정되어 있는지 확인하.
Gradle 의존성을 최신 상태로 업데이트한 후, 프로젝트를 클린 빌드해보기.

5. 로그 확인

서버 콘솔 로그에 발생한 구체적인 예외 메시지를 확인하기!!!(개인적으로 제일 중요하다고 생각합니다)
"Invalid key length" 또는 "The signing key's size is not sufficient" 등의 메시지가 보인다면 키 길이가 문제일 가능성이 큽니다.

기타 필터/보안 설정

JwtAuthenticationFilter나 SecurityConfig 설정에서, /auth/login 엔드포인트에 대해 잘못된 접근 제어가 없는지 확인하기.
혹시 다른 보안 필터가 500 에러를 발생시키고 있는지도 점검.


해결하기 위한 나의 추가 노력.

  • 코드의 전반적인 리팩토링.
    JWT 토큰에서 "userId" 클레임을 추출하려고 시도하는 부분에서,
  1. Bearer 접두사 있으면 제거 할 수 있게 로직 추가.
public Long getUserIdFromJwtToken(String token) {
    // Bearer 접두사 있으면 제거
    if (token.startsWith("Bearer ")) {
        token = token.substring(7);
    }
  • 토큰 문자열이 실제로 "Bearer " 접두사와 함께 전달되는지 확인합니다.
    만약 접두사가 없다면, 토큰 전체를 사용해야 합니다.
  1. Claims 추가.
 Claims claims = Jwts.parser()
            .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)))
            .parseClaimsJws(token)
            .getBody();

    String userIdStr = claims.get("userId", String.class);
    System.out.println("추출된 userId 클레임: " + userIdStr);

    if (userIdStr == null) {
        throw new IllegalArgumentException("jwt 토큰에 userId 클레임이 없어요");
    }
    return Long.parseLong(userIdStr);
  • 토큰을 생성할 때, "userId" 클레임이 올바르게 포함되었는지 확인해야 합니다.
    토큰 생성 시 JwtUtil.generateJwtToken(...)에 "userId" 클레임을 추가하는 로직이 있었는지 점검합니다.

지금 로그인은 잘 되고있고, comment부분에서 안되고 있는중...
우선, 로그인은 제대로 동작하는데 comment 관련 부분에서 문제가 발생하면 가장 흔한 원인은 보통
리포지토리와 그 커멘트 부분의 메서드의 코드영역이 문제입니다. (출처 : 구글링) LikeRepository의 countByComment 메서드에서 문제가 발생하는 경우를 확인해보면 됩니다.

여기서 제일 중요한 것은 @Transactional 어노테이션이라고 생각합니다.
기본 조회는 Spring Data JPA가 트랜잭션을 관리하지만, 복잡한 비즈니스 로직에는 명시적으로 @Transactional을 붙이는 것이 좋기 때문에, 이부분도 안되어있어 리팩토링 진행했습니다.

@Transactional 어노테이션 사용에 관하여

문득, "why 붙이지 않을 수 있을까?"라는 의구심이 들었습니다.
일부 Spring Data JPA Repository 메서드는 이미 내부에서 트랜잭션을 관리합니다.
예를 들어, 단순 조회의 경우 기본적으로 read-only 트랜잭션이 적용됩니다.
그러나 비즈니스 로직을 수행하는 서비스 계층에서는 여러 DB 작업이 연관되어 있을 경우,
명시적으로 @Transactional 어노테이션을 붙여 트랜잭션 경계를 지정하는 것이 좋습니다.

그렇다면, 따라야 하는 권장 사항이 있는 것인가 ?
-> 네.
쓰기 작업(등록, 수정, 삭제)이 포함된 서비스 메서드에는 반드시 @Transactional을 붙이는 것이 좋습니다.
단순 조회의 경우, @Transactional(readOnly=true)를 붙이는 것도 좋습니다.
만약 트랜잭션 어노테이션이 누락된 상태에서 여러 DB 작업이 연관되어 있다면, 데이터 일관성 문제가 발생할 수 있으므로 확인이 필요합니다.

따라서, 만약에 서비스 계층 메서드에 @Transactional 어노테이션이 없다면,
읽기 전용 작업에는 @Transactional(readOnly = true),
쓰기 작업에는 @Transactional을 붙이는 것이 좋습니다.


구글링을 해보니 똑같은 사례를 가진 분을 따라
Authorization부분에 eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtdW5AZXhhbXBsZS5jb20iLCJ1c2VySWQiOiIxIiwiaWF0IjoxNzM5OTc2MTM1LCJleHAiOjE3NDAwNjI1MzV9.u5vjmNSaAf5qKoJtCVhZV6drmU78q_aCnWwIgDuaROh0ettdiQXzjMspSMRTw1fkU8fWxHM-2GfSzkK8rB0qMQ
를 헤더에 넣어서 요청하니 200ok 떳습니다. 😱

Postman에서 JWT 토큰을 Authorization 헤더에 넣어서 테스트하는 방식은 표준적이고 올바른 방식이며,
로그인에 토큰을 생성했으니, request 헤더에 추가로 넣고 진행하면 되는 건데...🤯


추가적으로, Bearer 정리

"Bearer " 접두사는 HTTP Authorization 헤더에 토큰을 전달할 때 표준적으로 사용하는 형식입니다. 이 접두사는 다음과 같은 역할과 특징을 갖습니다.

  1. What?
    Bearer 토큰은 클라이언트가 서버에 인증 정보를 제공하기 위해 사용하는 토큰입니다.
    HTTP 요청의 Authorization 헤더에 "Bearer <토큰>" 형식으로 전달됩니다.
    여기서 "Bearer"는 인증 방식의 종류를 나타내며, 뒤따르는 실제 토큰 값은 인증에 사용됩니다.

  2. When?
    OAuth 2.0 표준 및 JWT 기반 인증 시스템에서, 클라이언트가 서버에 보호된 리소스에 접근할 때 토큰을 전달하는 표준 방식입니다.
    예를 들어, 로그인 후 서버에서 발급된 JWT 토큰을 클라이언트가 API 요청 시 포함시켜 인증을 수행할 때 사용됩니다.

  3. How?
    클라이언트(예:Postman)는 API 호출 시 Authorization 헤더에 "Bearer <JWT 토큰>"을 설정합니다.
    서버에서는 이를 받아서, "Bearer " 접두사를 제거한 후 나머지 토큰 값만 추출하여 검증 및 파싱 작업을 수행합니다.
    이를 통해 토큰에 포함된 클레임(예:사용자 ID, 이메일 등)을 확인하고, 인증된 사용자로서 요청을 처리할 수 있게 됩니다.

  4. Why?
    JWT 라이브러리나 파싱 로직은 보통 토큰 자체만 필요로 합니다.
    그래서 Authorization 헤더에 "Bearer "가 포함되어 있다면, 해당 문자열을 제거하여 순수한 토큰 값만 전달합니다.
    예를 들어, 아래와 같은 코드에서

if (token.startsWith("Bearer ")) {
    token = token.substring(7);
}

이는 토큰 문자열이 "Bearer "로 시작할 경우, 앞의 7글자를 잘라내고 실제 토큰만 남기는 역할을 합니다.

  1. Practical Example?
    Postman에서 보호된 엔드포인트를 호출할 때, 헤더에 다음과 같이 작성합니다
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtdW5AZXhhbXBsZS5jb20iLCJpYXQiOjE2NDY2MzI5NzUsImV4cCI6MTY0NjYzNjU3NX0.XXXXXXX

서버에서는 이 헤더에서 "Bearer "를 제거한 후, 남은 eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtdW5AZXhhbXBsZS5jb20iLCJpYXQiOjE2NDY2MzI5NzUsImV4cCI6MTY0NjYzNjU3NX0.XXXXXXX 토큰 값을 사용하여 JWT를 검증합니다.

정리하자면, "Bearer " 접두사는 토큰 인증 방식의 표준 형식으로, 클라이언트가 토큰을 전달할 때 사용되며, 서버에서는 이 접두사를 제거하여 순수한 토큰 값을 추출하고 검증하는 과정에서 활용되는 것입니다.


해당 프로젝트를 마무리 하면서

1. 서비스가 달성하고자 하는 목표

인스타그램, 트위터, 페이스북 등과 같이 sns서비스에서 사용자가 CRUD할 수 있고, 친구의 최신 게시물을 뉴스피드로 자유롭게 볼 수 있도록 하며, 사용자 간의 소통과 정보 공유를 원활하게 하는것이 목표입니다.
이를 통해 사용자들이 서로의 일상을 공유하고, 더 나아가 댓글을 통해 정보와 감정을 교류하며, 나아가 플랫폼 내에서 자연스럽게 커뮤니티가 활성화되어 사용자 경험과 참여도를 극대화하는 시너지를 창출하기 위해 서비스를 개발하게 되었습니다.

2. 핵심 기능 소개 최대 1개

Jwt 적용한 로그인 및 댓글, 게시물 CRUD 기능
헤당 프로젝트의 서비스는 JWT 기반 인증 시스템을 도입하였으며,
클라이언트가 안전하게 로그인하고, 발급받은 토큰을 통해 보호된 API에 접근할 수 있도록 구현했습니다.
이를 기반으로 사용자는 게시물과 댓글에 대해 CURD를 수행할 수 있습니다. 특히, 게시물과 댓글 수정/삭제 기능은 해당 콘텐츠의 작성자만이 접근할 수 있도록 권한 검증 로직을 포함하여 보안을 강화했습니다. 이와 더불어, 좋아요 및 프로필 기능을 통해 사용자 간의 원활한 소통과 커뮤니티 활성화를 지원하여, 서비스의 전반적인 사용자 경험과 참여도를 극대화하였습니다. JWT를 활용한 인증 및 권한 관리는 서버의 상태 관리 부담을 줄이는 동시에 확장성과 보안성을 확보하는 데 큰 역할을 하고 있습니다.

3. 기술적으로 새롭게 배운 것에 대한 설명 1가지

기존 세션 기반 인증 대신 JWT를 사용하여 인증/인가 로직을 전환하는 방법을 학습했습니다.
이를 통해 로그인 시 서버가 상태 정보를 유지하지 않고도, 클라이언트가 발급받은 토큰을 이용해 보호된 API에 접근할 수 있다는 것을 알게되었으며, Spring Security와 JJWT 라이브러리를 활용하여 토큰 생성, 검증, 그리고 SecurityContext에 인증 정보를 설정하는 과정에서 에러를 자주 만나 jwt를 더욱 자세히 익혔습니다.

4. 소감

저는 JWT와 게시물 관리 영역을 구현하면서, 기존 세션 기반 방식에서 벗어나 보다 확장성과 보안성이 뛰어난 토큰 기반 인증 방식을 경험할 수 있었습니다. 특히, Spring Security의 필터 체인과 SecurityContext를 활용하는 방법을 익히면서, 인증과 권한 관리에 대한 이해도가 크게 향상되었습니다. 전반적으로 코드 리팩토링과 JWT 적용 과정은 도전적이었지만, 결과적으로 해당 프로젝트의 서비스 안정성과 확장성을 높이는 데 큰 도움이 되었다고 느낍니다.
실제로 JWT 기반 인증을 구현하는 과정에서 Spring Security의 필터 체인과 SecurityContext가 내부적으로 활용되고 있으며, 제가 작성한 코드는 JwtAuthenticationFilter는 OncePerRequestFilter를 상속받아 모든 HTTP 요청마다 JWT를 추출하고 검증하며, 유효한 토큰이 있을 경우 SecurityContextHolder에 인증 정보를 설정하도록 구현되었습니다. 이 과정이 바로 Spring Security의 핵심 메커니즘을 활용하는 것이며, 이를 통해 Spring Security의 내부 작동 원리를 더욱 깊이 이해할 수 있었습니다.

profile
백엔드를 지향하며, 컴퓨터공학과를 졸업한 취준생입니다. 많이 부족하지만 열심히 노력해서 실력을 갈고 닦겠습니다. 부족하고 틀린 부분이 있을 수도 있지만 이쁘게 봐주시면 감사하겠습니다. 틀린 부분은 댓글 남겨주시면 제가 따로 학습 및 자료를 찾아봐서 제 것으로 만들도록 하겠습니다. 귀중한 시간 방문해주셔서 감사합니다.

0개의 댓글