[Spring Plus] Level 1-2 요구사항 반영

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;	// 사용자 닉네임 정보를 저장하기 위해 필드 추가

    ...
}

nickname 필드 추가 후 USERS 테이블 상태

 

위 이미지와 같이 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 이 저장되어 있다.

요구사항 반영에 따른 USERS 테이블 상태(위-반영전, 아래-반영후)

 

그렇다보니 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 테이블 상태

 

USERS 테이블을 다시 살펴보니 첫 번째 레코드의 nickname 이 null 에서 default 로 잘 변경된 것을 확인할 수 있었다. 그리고 기존에 null 이 뜨던 API 테스트 중 하나를 다시 시도해 보니 아래와 같이 null 대신 default 가 반환되는 것 또한 확인하였다.

일정 관리자 조회(다건) 요청 - nickname 기본 값 반영 이후