SpringPlus- 개인과제(8)

ChoRong0824·2025년 3월 15일
0

Web

목록 보기
43/51
post-thumbnail

저는 항상 역할을 바꿀때마다 PATCH로 했는데, PUT을 쓰는 블로그, api명세서가 더 보이는 것 같아서 이에 왜 PATCH가 아닌 PUT을 쓰는지 이해하고자 작성하게 되었습니다.


PATCH가 아닌 PUT을 사용한 이유

1. PATCH와 PUT의 차이

PATCH: 리소스의 일부 속성만 변경할 때 사용
PUT: 리소스를 전체 교체(Replace) 또는 업데이트(Update) 할 때 사용

2. 사용자 역할 변경의 경우

PATCH /admin/users/1/role
PATCH는 리소스의 일부를 변경할 때 사용하는 것이 적절한데, "유저 역할"이 단일 필드라서 PATCH도 가능할 것처럼 생각되었었습니다.

PUT /admin/users/1/role
하지만, 일반적으로 권한(role) 변경은 전체 속성을 변경하는 의미로 해석될 수 있다는 것을 확인했습니다. (출처)

해당 api 권한 변경을 요청할 때, 보안적인 이유로 PUT을 사용하는 것이 더 명확하다고 판단했습니다.
예를 들어, 변경 시점에서 기존 권한이 무엇인지와 함께 명확한 상태를 지정해야 하는 경우가 많습니다.

(중요)
PATCH는 특정 필드만 수정할 수 있지만, 권한 변경 같은 중요한 작업에서는 기존 값을 덮어쓰는 방식(Replace)이 더 적절할 수 있습니다.
이를 고려해보고 적절히 사용해야합니다. 특정 필드만 변경하는 것인지.

3. PATCH 대신 PUT을 선택한 이유

  1. 보안적인 이유: 역할 변경은 민감한 작업이므로, 전체 상태를 갱신하는 것이 더 안전

  2. RESTful 관점: PUT은 자원의 전체적인 업데이트를 의미하고, 역할 변경이 이에 가깝다고 판단

  3. 일관성 유지: 다른 API와의 통일성을 유지하기 위해 사용


PUT과 PATCH 메서드는 리소스 업데이트 시 약간의 차이가 있습니다.
PUT 은 리소스의 전체를 대체하는 데 사용되며, 제공되지 않은 필드는 기본값이나 null로 설정될 수 있습니다.
반면 PATCH 는 리소스의 일부만 수정하는 데 사용되며, 제공되지 않은 필드는 기존 값을 유지합니다

따라서, 해당 api 요청시에는 리소스의 일부만 수정하려는 경우 PATCH 메서드를 사용하는 것이 적절합니다.
그러나 서버 구현에 따라 PATCH 메서드가 지원되지 않을 수 있으므로, 서버가 PATCH 메서드를 지원하는지 확인하는 것이 중요합니다.


참고 블로그 1, 2, 3


로그인 테스트시 ERR 발견

java.lang.NullPointerException: Cannot invoke "java.lang.CharSequence.length()" because "charSequence" is null
	at at.favre.lib.crypto.bcrypt.BCrypt$Verifyer.toCharArray(BCrypt.java:513) ~[bcrypt-0.10.2.jar:na]
	at at.favre.lib.crypto.bcrypt.BCrypt$Verifyer.verify(BCrypt.java:483) ~[bcrypt-0.10.2.jar:na]
	at org.example.expert.config.PasswordEncoder.matches(PasswordEncoder.java:14) ~[classes/:na]
	at org.example.expert.domain.auth.service.AuthService.signin(AuthService.java:59) ~[classes/:na]

signin() 메서드에서 PasswordEncoder.matches()를 호출하는 과정에서 NullPointerException이 발생했다는 말.
즉, password가 null인 상태에서 matches() 메서드가 실행되었다는 것입니다.

이는 보통

  1. 로그인 요청에서 password 필드를 보내지 않았거나 null 값을 전달했을 가능성이 높습니다.
  2. 데이터베이스에서 조회한 사용자 객체(User 엔티티)의 password 값이 null일 가능성이 있습니다.
  3. 회원가입 시 비밀번호가 정상적으로 저장되지 않았을 가능성도 있습니다.

로그인 요청을 보낼 때 password 값을 포함했는지 확인하는 방법

postman에서 요청해주고, AuthService.signin() 코드 확인

User user = userRepository.findByEmail(signinRequest.getEmail())
        .orElseThrow(() -> new InvalidRequestException("가입되지 않은 유저입니다."));

if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
    throw new InvalidRequestException("유효x password");
}

지금 로그인에 보면 어노테이션이 있는데, 원래 코드에는 어노테이션이 붙지 않았습니다.
왜 @Transactional을 붙여야 하는 것인가 ?
해당 코드를 보면 단순 read(조회)작업 이기 때문에 @Transactional를 readOnly=true로 설정하는 것이 더 적절하기 때문입니다.

근데 왜 해당 어노테이션과 옵션을 써야하는 것인가 궁금하지 않나요?

  1. readOnly = true는 트랜잭션 내에서 엔티티의 변경이 발생하지 않도록 보장합니다.

  2. JPA의 변경 감지(dirty checking)를 비활성화하여 성능을 최적화합니다.

  3. 데이터 변경이 없는 조회성 메서드에는 readOnly 설정을 하는 것이 좋음.

이러한 것들 때문에 해당 어노테이션과 옵션을 써야합니다. (옵션은 선택사항)

다시 돌아와서, 해당 코드에서 왜 해당 어노테이션이 필요한 것일까?

  • findByEmail() 같은 메서드는 JPA 리포지토리를 통해 호출되는데, Lazy Loading이 걸린 엔티티를 안전하게 다루려면 트랜잭션이 필요할 수 있는데, 이때 만약 @Transactional이 없다면, user.getRoles() 같은 Lazy Loading 필드를 사용할 때 LazyInitializationException이 발생할 수 있기 때문입니다.

즉,
로그인 과정에서 DB 변경이 없으므로 readOnly = true를 붙이는 것이 좋으며,
조회 작업을 안전하게 하기 위해 @Transactional을 유지한다.


그리고 지금 AuthException을 사용하고 있습니다.

이를 InvalidRequestExceptiond으로 수정하는 이유는 ?

(합당한 이유를 작성해뒀습니다)

User user = userRepository.findByEmail(signinRequest.getEmail())
        .orElseThrow(() -> new InvalidRequestException("가입되지 않은 유저입니다."));
  1. AuthException을 안 쓰는 이유
    AuthException을 사용하면 보통 JWT 인증 과정에서 발생하는 예외를 처리하는 용도로 사용됩니다.
    signin()은 인증 이전 단계인 로그인 과정이므로 잘못된 요청에 대한 처리가 필요합니다.

  2. InvalidRequestException이 적절한 이유

  • 로그인 과정에서 사용자가 잘못된 요청(잘못된 이메일, 패스워드 등)을 입력한 것이므로 "잘못된 요청"을 의미하는 예외가 적절함.
  • InvalidRequestException은 사용자의 입력이 유효하지 않은 경우에 대한 예외를 나타낼 수 있음.
  1. 만약, AuthException을 사용한다면 언제 사용해야 하는 것인가?
    AuthException은 보통 JWT 토큰이 만료되었거나, 유효하지 않거나, 권한이 없는 경우에 사용됩니다.
if (!jwtProvider.validateToken(token)) {
    throw new AuthException("유효하지않은 JWT 토큰");
}

따라서

  • 로그인 단계에서는 InvalidRequestException이 적절함 (잘못된 이메일, 패스워드 입력 등의 경우)
  • JWT 관련 문제에서는 AuthException이 적절함 (토큰 검증 실패, 만료 등)

일단, 에러는 다시 말하자면
BCrypt$Verifyer.verify() 호출에서 password가 null이라는 문제 발생!
PasswordEncoder.matches() 내부에서 password가 null이 되어 발생한 문제!
에 관한 것입니다.

db 확인해보니 password가 안담겨서 문제였네요.
왜 안담기는지 이해가 안돼서 다 찍어보기로 결심.

package org.example.expert.domain.auth.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.expert.config.JwtUtil;
import org.example.expert.config.PasswordEncoder;
import org.example.expert.domain.auth.dto.request.SigninRequest;
import org.example.expert.domain.auth.dto.request.SignupRequest;
import org.example.expert.domain.auth.dto.response.SigninResponse;
import org.example.expert.domain.auth.dto.response.SignupResponse;
import org.example.expert.domain.auth.exception.AuthException;
import org.example.expert.domain.common.exception.InvalidRequestException;
import org.example.expert.domain.user.entity.User;
import org.example.expert.domain.user.enums.UserRole;
import org.example.expert.domain.user.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {
        log.info("회원가입 요청, Email: {}, Nickname: {}, Role: {}",
                signupRequest.getEmail(), signupRequest.getNickname(), signupRequest.getUserRole());

        if (signupRequest.getPassword() == null || signupRequest.getPassword().isBlank()) {
            throw new InvalidRequestException("비밀번호를 입력해주세요.");
        }

        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }

        log.info("입력된 원본 비밀번호: {}", signupRequest.getPassword());

        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
        log.info("암호화된 비번: {}", encodedPassword);

        UserRole userRole = UserRole.of(signupRequest.getUserRole());

        User newUser = new User(
                signupRequest.getEmail(),
                encodedPassword,
                signupRequest.getNickname(),
                userRole
        );

        log.info("생성된 유저 객체: {}", newUser);
        User savedUser = userRepository.save(newUser);

        log.info("저장된 유저 객체 정보 - Id: {}, Email:{}, Password: {}, Nickname: {}",
                savedUser.getId(),savedUser.getEmail(),savedUser.getPassword(),savedUser.getNickname());

        String bearerToken = jwtUtil.createToken(savedUser.getId(),
                savedUser.getEmail(),
                savedUser.getNickname(),
                userRole);

        log.info("jwt 토큰: {}", bearerToken);

        return new SignupResponse(bearerToken, savedUser.getNickname());
    }

    @Transactional
    public SigninResponse signin(SigninRequest signinRequest) {
        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
                () -> new InvalidRequestException("가입되지 않은 유저입니다."));

        // 로그인 시 이메일과 비밀번호가 일치하지 않을 경우 401을 반환합니다.
        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
            throw new AuthException("잘못된 비밀번호입니다.");
        }

        String bearerToken = jwtUtil.createToken(user.getId(),
                user.getEmail(),
                user.getNickname(),
                user.getUserRole());

        return new SigninResponse(bearerToken, user.getNickname());
    }
}

ㅋㅋ 잡았다 요놈!!!

문제 분석 시, 비밀번호가 null로 저장되는 이유

회원가입 요청에서 비밀번호가 정상적으로 입력되고, 암호화까지 완료되었는데도,
DB에 저장될 때 password 값이 null이 되는 문제가 발생했음.
도대체 왜!!!!!!? ㅋㅋㅋㅋ
재밌다. 아니 요즘 정말 개발하는게 재밌다 왜지? 드디어 미쳐가는 것인가..

로그를 분석해보니,
로그 분석 결과

  1. passwordEncoder.encode(signupRequest.getPassword()) -> 정상적으로 수행됨
  2. 암호화된 비번: $2a$04$AR9BaB9ZOytxIG8ORRy19urGcxD.vsxWl3Lfs1GZfUyDFzY7EesnC -> 정상적으로 수행됨
  3. 하지만, 저장된 유저 객체 정보 - Password: null

즉, User 객체에 password가 할당되지 않아서 null로 저장되고 있던 거임!!!
그렇다면 ? 엔티티를 확인해보면 끝~~

깔삼하게 엔티티에 어노테이션까지 추가~

깔삼뽕삼쓰~~~ 이제 에러 발생하면 ? -> 그럴 일 1도 x.
하하하

당연했쥬~ NICE 하쥬~ 기분 좋쥬~ (요즘은 주말에도 계속 공부해서인지 점점 미쳐 가는 중인 것 같습니다 .. ㅋㅋ) 왜 재밌는거냐고~~

TMI를 말하자면, 제가 아프면서 7~8개월 가량 누워 지낼때 통증 때문에 진통제 먹으면서 겜을 하게 되면 통증이 조금 없어져서 인지? 피파4랑 LOL를 정말 좋아했는데, 이젠 게임도 안하고 다시 예전처럼 공부하게 되네여 (real TMI)



그렇지면 일단 해당 프로젝트의 코드에선 바꿔도 된다는 말이 없기도 없고, 튜터님께 확실하게 뭐로 해야하는지 여쭤보고 선택하고 싶어서 해당 건은 일단 보류.
-> patchMapping으로 되어있어서 patch로 테스트 진행.

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

0개의 댓글