저는 항상 역할을 바꿀때마다 PATCH로 했는데, PUT을 쓰는 블로그, api명세서가 더 보이는 것 같아서 이에 왜 PATCH가 아닌 PUT을 쓰는지 이해하고자 작성하게 되었습니다.
PATCH: 리소스의 일부 속성만 변경할 때 사용
PUT: 리소스를 전체 교체(Replace) 또는 업데이트(Update) 할 때 사용
PATCH /admin/users/1/role
PATCH는 리소스의 일부를 변경할 때 사용하는 것이 적절한데, "유저 역할"이 단일 필드라서 PATCH도 가능할 것처럼 생각되었었습니다.
PUT /admin/users/1/role
하지만, 일반적으로 권한(role) 변경은 전체 속성을 변경하는 의미로 해석될 수 있다는 것을 확인했습니다. (출처)
해당 api 권한 변경을 요청할 때, 보안적인 이유로 PUT을 사용하는 것이 더 명확하다고 판단했습니다.
예를 들어, 변경 시점에서 기존 권한이 무엇인지와 함께 명확한 상태를 지정해야 하는 경우가 많습니다.
(중요)
PATCH는 특정 필드만 수정할 수 있지만, 권한 변경 같은 중요한 작업에서는 기존 값을 덮어쓰는 방식(Replace)이 더 적절할 수 있습니다.
이를 고려해보고 적절히 사용해야합니다. 특정 필드만 변경하는 것인지.
보안적인 이유: 역할 변경은 민감한 작업이므로, 전체 상태를 갱신하는 것이 더 안전
RESTful 관점: PUT은 자원의 전체적인 업데이트를 의미하고, 역할 변경이 이에 가깝다고 판단
일관성 유지: 다른 API와의 통일성을 유지하기 위해 사용
PUT과 PATCH 메서드는 리소스 업데이트 시 약간의 차이가 있습니다.
PUT 은 리소스의 전체를 대체하는 데 사용되며, 제공되지 않은 필드는 기본값이나 null로 설정될 수 있습니다.
반면 PATCH 는 리소스의 일부만 수정하는 데 사용되며, 제공되지 않은 필드는 기존 값을 유지합니다
따라서, 해당 api 요청시에는 리소스의 일부만 수정하려는 경우 PATCH 메서드를 사용하는 것이 적절합니다.
그러나 서버 구현에 따라 PATCH 메서드가 지원되지 않을 수 있으므로, 서버가 PATCH 메서드를 지원하는지 확인하는 것이 중요합니다.
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() 메서드가 실행되었다는 것입니다.
이는 보통
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로 설정하는 것이 더 적절하기 때문입니다.
근데 왜 해당 어노테이션과 옵션을 써야하는 것인가 궁금하지 않나요?
readOnly = true는 트랜잭션 내에서 엔티티의 변경이 발생하지 않도록 보장합니다.
JPA의 변경 감지(dirty checking)를 비활성화하여 성능을 최적화합니다.
데이터 변경이 없는 조회성 메서드에는 readOnly 설정을 하는 것이 좋음.
이러한 것들 때문에 해당 어노테이션과 옵션을 써야합니다. (옵션은 선택사항)
다시 돌아와서, 해당 코드에서 왜 해당 어노테이션이 필요한 것일까?
즉,
로그인 과정에서 DB 변경이 없으므로 readOnly = true를 붙이는 것이 좋으며,
조회 작업을 안전하게 하기 위해 @Transactional을 유지한다.
그리고 지금 AuthException을 사용하고 있습니다.
(합당한 이유를 작성해뒀습니다)
User user = userRepository.findByEmail(signinRequest.getEmail())
.orElseThrow(() -> new InvalidRequestException("가입되지 않은 유저입니다."));
AuthException을 안 쓰는 이유
AuthException을 사용하면 보통 JWT 인증 과정에서 발생하는 예외를 처리하는 용도로 사용됩니다.
signin()은 인증 이전 단계인 로그인 과정이므로 잘못된 요청에 대한 처리가 필요합니다.
InvalidRequestException이 적절한 이유
if (!jwtProvider.validateToken(token)) {
throw new AuthException("유효하지않은 JWT 토큰");
}
일단, 에러는 다시 말하자면
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());
}
}
ㅋㅋ 잡았다 요놈!!!
회원가입 요청에서 비밀번호가 정상적으로 입력되고, 암호화까지 완료되었는데도,
DB에 저장될 때 password 값이 null이 되는 문제가 발생했음.
도대체 왜!!!!!!? ㅋㅋㅋㅋ
재밌다. 아니 요즘 정말 개발하는게 재밌다 왜지? 드디어 미쳐가는 것인가..
로그를 분석해보니,
로그 분석 결과
$2a$04$AR9BaB9ZOytxIG8ORRy19urGcxD.vsxWl3Lfs1GZfUyDFzY7EesnC
-> 정상적으로 수행됨 즉, User 객체에 password가 할당되지 않아서 null로 저장되고 있던 거임!!!
그렇다면 ? 엔티티를 확인해보면 끝~~
깔삼하게 엔티티에 어노테이션까지 추가~
깔삼뽕삼쓰~~~ 이제 에러 발생하면 ? -> 그럴 일 1도 x.
하하하
당연했쥬~ NICE 하쥬~ 기분 좋쥬~ (요즘은 주말에도 계속 공부해서인지 점점 미쳐 가는 중인 것 같습니다 .. ㅋㅋ) 왜 재밌는거냐고~~
TMI를 말하자면, 제가 아프면서 7~8개월 가량 누워 지낼때 통증 때문에 진통제 먹으면서 겜을 하게 되면 통증이 조금 없어져서 인지? 피파4랑 LOL를 정말 좋아했는데, 이젠 게임도 안하고 다시 예전처럼 공부하게 되네여 (real TMI)
그렇지면 일단 해당 프로젝트의 코드에선 바꿔도 된다는 말이 없기도 없고, 튜터님께 확실하게 뭐로 해야하는지 여쭤보고 선택하고 싶어서 해당 건은 일단 보류.
-> patchMapping으로 되어있어서 patch로 테스트 진행.