2024. 11. 12. 13:05ㆍ내일배움캠프/Spring Plus
'Level 1-2' 의 요구사항을 반영한 내용을 기록한 포스팅이다. 어떠한 생각과 과정을 통해 요구사항을 반영했는지 알 수 있도록 작성해 보았다.
0. 요구사항
기획자의 요청을 현재 프로젝트에 반영해야 한다. 전달 받은 요청은 JWT 에 사용자 닉네임 정보를 담아 프론트 쪽에서 이를 꺼내 화면에 보여주길 원한다는 것이다.
1. 사용자 닉네임 추가
1-1. User 클래스 필드 추가
일단 JWT 에 사용자의 닉네임 정보를 담고 싶다면 User 클래스와 매핑된 테이블에 닉네임 정보를 가질 필드 및 컬럼을 아래와 같이 추가할 필요가 있다 생각했다.
@Getter
@Entity
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {
...
private String nickname; // 사용자 닉네임 정보를 저장하기 위해 필드 추가
...
}
위 이미지와 같이 User 클래스(엔티티)와 매핑된 USERS 테이블에 'nickname' 컬럼이 추가된 것을 확인할 수 있었다.
1-2. User 클래스 생성자 수정
User 클래스에 필드를 추가했으니 이제 User 클래스를 생성할 때 사용하는 생성자 또는 메서드를 수정할 필요가 있다. 프로젝트를 좀 더 살펴보니 해당 프로젝트에서는 User 객체를 생성할 때 기본적으로 '생성자' 를 통해 생성하고 있었다. nickname 필드가 추가 되었으므로 사용되는 생성자에 해당 필드에 대한 값을 파라미터로 전달받아 User 객체를 생성할 수 있도록 아래와 같이 수정하였다.
@Getter
@Entity
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {
...
// User 객체 생성시 nickname 을 추가로 전달받아 객체를 생성하도록 수정
public User(String email, String password, UserRole userRole, String nickname) {
this.email = email;
this.password = password;
this.userRole = userRole;
this.nickname = nickname;
}
...
}
1-3. AuthService 수정
User 클래스의 생성자를 수정하니 당연하게도 에러를 나타내는 빨간 밑줄이 등장했다. 먼저 User 객체를 확실하게 생성하는 것은 '회원 가입' 이라 생각해 해당 기능 수행과 관련된 코드를 차근차근 수정해 보았다.
그 중 가장 먼저한 것은 '회원 가입' 요청시 전달 받는 RequestBody 정보를 담을 DTO 를 수정하는 것이다. 사용자 닉네임 정보를 회원 가입시 전달받을 필요가 있기 때문이다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class SignupRequest {
@NotBlank @Email
private String email;
@NotBlank
private String password;
@NotBlank
private String userRole;
@NotBlank
private String nickname; // 회원 가입시 회원 정보를 전달 받을 수 있도록 필드 추가
}
프로젝트에서는 '회원 가입' 요청시 전달받은 RequestBody 를 'SignupRequest' DTO 로 매핑해 전달 받고 있었다. 그래서 해당 클래스를 위와 같이 수정하였다. 또한 이제는 '회원 가입' 요청시 nickname 정보를 반드시 RequestBody 에 담아 요청해야 한다.
요청에 사용되는 DTO 를 수정했으니 요청을 수행하는 비즈니스 로직을 가진 'AuthService.signup()' 을 수정해 보았다. 'signup()' 의 경우 요청 정보와 부가 로직을 통해 USERS 테이블에 저장할 User 엔티티를 생성해 DB 에 반영(저장)하는 비즈니스 로직을 가지고 있다.
// AuthService
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
if (userRepository.existsByEmail(signupRequest.getEmail())) {
throw new InvalidRequestException("이미 존재하는 이메일입니다.");
}
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
UserRole userRole = UserRole.of(signupRequest.getUserRole());
User newUser = new User(
signupRequest.getEmail(),
encodedPassword,
userRole,
signupRequest.getNickname() // SignupRequest 클래스의 nickname 을 생성자 파라미터로 전달
);
...
}
이전에 SignupRequest DTO 에 nickname 필드를 추가했고 RequestBody 에도 nickname 정보다 담겨 전달될 것이기에 User 객체 생성시 생성자 파라미터에 SignupReuqest 의 nickname 정보를 전달하도록 코드를 수정했다.
1-4. UserService 수정
UserService 에서는 회원의 정보를 조회(단건)하는 'getUser()' 와 비밀번호를 변경하는 'changePassword()', 2개의 메서드가 존재(public 기준)한다. 그 중 'getUser()' 의 경우 DB 에서 조회한 사용자 정보를 DTO 에 담아 반환하기에 수정할 필요가 있다 생각 되었다.
먼저 'getUser()' 의 반환 타입에 사용되는 UserResponse DTO 를 아래와 같이 수정했다.
@Getter
public class UserResponse {
private final Long id;
private final String email;
private final String nickname; // 반환할 사용자 정보 추가
public UserResponse(Long id, String email, String nickname) {
this.id = id;
this.email = email;
this.nickname = nickname;
}
}
또한 해당 DTO 를 생성하는 'getUser()' 도 수정했다.
// UserService
public UserResponse getUser(long userId) {
User user = userRepository.findById(userId).orElseThrow(() -> new InvalidRequestException("User not found"));
return new UserResponse(user.getId(), user.getEmail(), user.getNickname());
}
1-5. CommentService, ManagerService, TodoService 수정
1-1 ~ 1-4 내용대로 수정 후 애플리케이션을 실행해보니 CommentService, ManagerServce, TodoService 클래스에서 UserResponse DTO 를 생성하기에 컴파일 에러를 마주할 수 있었고 해당 클래스들의 DTO 생성 로직 또한 아래와 같이 수정해 주었다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommentService {
...
@Transactional
public CommentSaveResponse saveComment(AuthUser authUser, long todoId, CommentSaveRequest commentSaveRequest) {
...
return new CommentSaveResponse(
savedComment.getId(),
savedComment.getContents(),
new UserResponse(user.getId(), user.getEmail(), user.getNickname())
);
}
public List<CommentResponse> getComments(long todoId) {
...
for (Comment comment : commentList) {
User user = comment.getUser();
CommentResponse dto = new CommentResponse(
comment.getId(),
comment.getContents(),
new UserResponse(user.getId(), user.getEmail(), user.getNickname())
);
dtoList.add(dto);
}
return dtoList;
}
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ManagerService {
...
@Transactional
public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSaveRequest managerSaveRequest) {
...
return new ManagerSaveResponse(
savedManagerUser.getId(),
new UserResponse(managerUser.getId(), managerUser.getEmail(), managerUser.getNickname())
);
}
public List<ManagerResponse> getManagers(long todoId) {
...
List<ManagerResponse> dtoList = new ArrayList<>();
for (Manager manager : managerList) {
User user = manager.getUser();
dtoList.add(new ManagerResponse(
manager.getId(),
new UserResponse(user.getId(), user.getEmail(), user.getNickname())
));
}
return dtoList;
}
...
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TodoService {
...
@Transactional
public TodoSaveResponse saveTodo(AuthUser authUser, TodoSaveRequest todoSaveRequest) {
...
return new TodoSaveResponse(
savedTodo.getId(),
savedTodo.getTitle(),
savedTodo.getContents(),
weather,
new UserResponse(user.getId(), user.getEmail(), user.getNickname())
);
}
public Page<TodoResponse> getTodos(int page, int size) {
...
return todos.map(todo -> new TodoResponse(
todo.getId(),
todo.getTitle(),
todo.getContents(),
todo.getWeather(),
new UserResponse(todo.getUser().getId(), todo.getUser().getEmail(), todo.getUser().getNickname()),
todo.getCreatedAt(),
todo.getModifiedAt()
));
}
public TodoResponse getTodo(long todoId) {
...
return new TodoResponse(
todo.getId(),
todo.getTitle(),
todo.getContents(),
todo.getWeather(),
new UserResponse(user.getId(), user.getEmail(), user.getNickname()),
todo.getCreatedAt(),
todo.getModifiedAt()
);
}
}
UserResponse DTO 를 수정하게 되며 상당히 많은 부분을 수정하게 되었다. 여기서든 생각은 "각 Service 의 메서드에서 UserResponse 를 생성할 것이 아니라 UserResponse 클래스 내에서 객체를 생성하는 메서드를 구현하고 Service 메서드에서 이를 호출하는 방식으로 사용하면 유지보수가 편하지 않을까?" 였다.
하지만 이전 게시물에서 말했듯 이 프로젝트를 작성한 개발자는 당사자 나름의 의도가 있어 이런 방식으로 구현을 하였을 것이고 개인과제 특성상 해당 프로젝트를 구현한 개발자와 소통은 어렵기에 결국 현재 프로젝트를 최대한 유지하되 요구사항이 반영될 수 있도록 위와 같은 방식의 수정을 택하게 되었다. 만약 개발자와 소통이 된다고 하면 앞서 말한 생각에 대해 의견을 제시할 것 같다.
2. JWT 에 사용자 닉네임 정보 담기
2-1. JwtUtils 수정
JWT 에 사용자의 닉네임 정보를 담기 위해서 JWT 에 관련한 다양한 기능을 제공하는 JwtUtils 를 먼저 수정하기로 하였고, 그 중에 'JWT 생성' 기능을 수행하는 'createToken()' 을 아래와 같이 수정했다.
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
...
public String createToken(Long userId, String email, UserRole userRole, String nickname) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("userRole", userRole)
.claim("nickname", nickname) // 사용자 닉네임 정보
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
...
}
JWT 생성시 사용자 닉네임 정보를 담아 토큰을 생성하도록 생성 메서드를 수정했으니 다음은 해당 메서드를 호출하는 곳의 로직을 수정해야 했다. 프로젝트에서 JWT 를 생성해 반환하는 경우는 2가지가 있는데 첫 번째는 '회원 가입' 시, 두 번째는 '회원 로그인' 시 이다. 해당 기능들을 수행하는 비즈니스 로직은 AuthService 에 있어 해당 클래스를 수정했다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
...
@Transactional
public SignupResponse signup(SignupRequest signupRequest) {
...
String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole, savedUser.getNickname());
return new SignupResponse(bearerToken);
}
public SigninResponse signin(SigninRequest signinRequest) {
...
String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole(), user.getNickname());
return new SigninResponse(bearerToken);
}
}
'JwtUtils.createToken()' 호출시 사용자 닉네임 정보를 파라미터로 추가적으로 전달하도록 했다.
2-2. JwtFilter 수정
프로젝트를 확인해보니 JwtFilter 를 통해 요청 헤더의 토큰에 담긴 사용자 정보를 HttpServletRequest 에 추가해 Controller 로 전달하는 것을 확인할 수 있었다. JWT 에 사용자 닉네임 정보가 추가된 만큼 추가 정보를 HttpServletRequest 에 추가할 필요가 있다 생각 아래와 같이 수정하였다.
@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {
...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
...
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
httpRequest.setAttribute("userNickname", claims.get("nickname")); // 사용자 닉네임 정보 추가
...
}
...
}
또한 Controller 에서는 사용자 인증에 대한 정보를 어떻게 사용하는지 확인해 보니 직접 작성한 어노테이션인 '@Auth' 와 AuthUserArgumentResolver 를 통해 HttpServletRequest 의 인증된 사용자 정보를 AuthUser 객체로 매핑해 Service 에 전달하고 있었다. 그래서 AuthUser DTO 와 AuthUserArgumentResolver 도 아래와 같이 수정해 주었다.
@Getter
public class AuthUser {
private final Long id;
private final String email;
private final UserRole userRole;
private final String nickname;
public AuthUser(Long id, String email, UserRole userRole, String nickname) {
this.id = id;
this.email = email;
this.userRole = userRole;
this.nickname = nickname;
}
}
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {
...
@Override
public Object resolveArgument(
@Nullable MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory
) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
// JwtFilter 에서 set 한 userId, email, userRole, nickname 값을 가져옴
Long userId = (Long) request.getAttribute("userId");
String email = (String) request.getAttribute("email");
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
String nickName = (String) request.getAttribute("nickName");
return new AuthUser(userId, email, userRole, nickName);
}
}
물론 요구사항에서 "프론트엔드쪽에서 인증 사용자의 닉네임 정보가 필요하다" 라고 했기에 "해당 내용을 수정해야 할까?" 같은 고민이 있었지만 백엔드 쪽에서도 충분히 인증 사용자의 닉네임 정보가 필요할 수 있다는 생각에 위와 같이 수정하게 되었다.
3. 테스트
요구사항을 반영했으니 수정한 기능에 대한 API 테스트를 Postman 을 통해 진행하였다.
3-1. 회원
3-2. 일정
3-3. 댓글
3-4. 일정 매니저
4. 마무리
요구사항 반영을 위해 프로젝트를 수정후 API 테스트가 정상적으로 동작하는 것을 확인했으나 아직 걸리는 것이 있다. 바로 User 클래스 필드에 nickname 을 추가하기 전 이미 한 번 회원가입 요청 테스트를 통해 USERS 테이블에는 사용자 정보가 존재하는 상태였다. 그래서 현재 nickname 컬럼 추가전에 가입한 사용자의 nickname 컬럼에는 null 이 저장되어 있다.
그렇다보니 API 테스트 결과 이미지를 보면 'id = 1' 인 사용자의 경우 nickname 은 null 이 반환되고 있다. 이 문제를 해결하려면 어떤 방법을 사용할까 고민하다가 "Users 테이블에 nickname 컬럼에 null 을 갖는 모든 레코드의 nickname 컬럼을 '기본 값' 을 가지도록 하고 이후 nickname 이 여전히 기본 값인 사용자에 대한 API 요청을 추가로 만들면 어떨까?" 라는 생각을 하게 되었다. 가령 nickname 이 기본 값이 사용자에게는 닉네임을 설정해달라는 알림이나 메시지를 노출해 닉네임을 설정하도록 유도하는 것이다.
물론 이 방식을 과제에 100% 반영하기에는 무리가 있다고 생각, 우선 아래의 쿼리를 사용해 null 을 기본 값으로 대체해 보았다. 기본 값은 알기 쉽도록 'default' 를 사용했다. 쿼리는 MySQL Command Line Client 를 사용해 DB 에 날렸다.
UPDATE users SET nickname = 'default' WHERE nickname IS NULL;
USERS 테이블을 다시 살펴보니 첫 번째 레코드의 nickname 이 null 에서 default 로 잘 변경된 것을 확인할 수 있었다. 그리고 기존에 null 이 뜨던 API 테스트 중 하나를 다시 시도해 보니 아래와 같이 null 대신 default 가 반환되는 것 또한 확인하였다.
'내일배움캠프 > Spring Plus' 카테고리의 다른 글
[Spring Plus] Level 1-5 요구사항 반영 (0) | 2024.11.13 |
---|---|
[Spring Plus] Level 1-4 요구사항 반영 (0) | 2024.11.13 |
[Spring Plus] Level 1-3 요구사항 반영 (0) | 2024.11.12 |
[Spring Plus] Level 1-1 요구사항 반영 (0) | 2024.11.11 |
[Spring Plus] 5분 기록 테이블 (0) | 2024.11.11 |