개인 프로젝트/Toy & Side

[TOY] 개발 - Adapters(User)

kwang2134 2024. 12. 3. 18:26
728x90
반응형
728x90

이전 진행 상황

  • 단위 테스트 개발

  • User
  • Post

User

user의 adapters 패키지 개발입니다. Controller가 개발과 함께 Mapper 클래스와 Spring Security가 사용되며 로그인 기능 및 설정 클래스가 생성되었습니다.


 

변경 사항

  • UserDTO.Response 수정 -> List <PostDTO.UserInfoResponse> 제거

추가 사항

  • UserMapper 클래스 추가
  • Controller 추가 -> 렌더링을 위한 UserFormController, 로직 처리를 위한 UserController
  • Spring Security 설정 클래스 추가 -> WebSecurityConfig
  • security 로그인을 위한 클래스 추가 -> LoginSuccessHandler, CutomUserDetails, CustomUserDetailsService

 

변경 내용

UserDTO.Response - 수정

마이페이지의 user 정보를 위한 응답용 DTO로 게시글의 제목을 가진 List를 가지고 있었지만 게시글 페이징 처리로 인해 별도로 분리하였습니다.

    @Getter
    @AllArgsConstructor
    public static class Response {
        private final Long id;
        private final String loginId;
        private final String username;
        private final String email;
        private final String role;
    }

추가 내용

class UserMapper - 추가

엔티티와 DTO 변환을 위한 Mapper 클래스입니다. PasswordEncoder를 사용하여 비밀번호를 암호화 한 뒤 엔티티로 변환하게 됩니다. UserMapper의 경우 PasswordEncoder 주입을 위해 빈으로 등록하였고 adapters 레이어에서 mapper가 자주 사용될 것이라 생각해 싱글톤으로 생성하여 생성 비용을 조금이라도 줄이는 용도로 모든 Mapper를 빈으로 등록할 예정입니다.

@Component
@RequiredArgsConstructor
public class UserMapper {

    private final PasswordEncoder passwordEncoder;

    public UserDTO.Response toDTO(User user) {
        return new UserDTO.Response(
                user.getId(),
                user.getLoginId(),
                user.getUsername(),
                user.getEmail(),
                user.getRole().getValue()
                );
    }

    public UserUpdateDTO toUpdateDTO(UserDTO.Request dto) {
        return new UserUpdateDTO(
                dto.getUsername(),
                dto.getEmail(),
                passwordEncoder.encode(dto.getPassword())
        );
    }

    public User toEntity(UserDTO.Request dto) {
        return User.builder()
                .loginId(dto.getLoginId())
                .username(dto.getUsername())
                .password(passwordEncoder.encode(dto.getPassword()))
                .email(dto.getEmail())
                .build();
    }
    
    public UserDTO.Request toRequestDTO(User user) {
        return new UserDTO.Request(null, null, user.getPassword(), 
        	user.getUsername(), 
        	user.getEmail());
    }
}

class UserFormController - 추가

Thymeleaf 파일을 불러와 화면을 렌더링 하게 될 UserFormController입니다. 회원 가입, 로그인, 마이페이지에 대한 처리가 있습니다. 회원 가입 페이지 요청 시 요청 값을 담을 DTO를 model에 담아 넘기고 페이지를 불러옵니다. 로그인의 경우 Spring Security가 로그인 과정을 처리하기 때문에 페이지를 렌더링 호출만 존재합니다.

@Controller
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserFormController {

    private final UserService userService;
    private final PostService postService;

    private final UserMapper userMapper;
    private final PostMapper postMapper;

    //회원 가입
    @GetMapping("/signup")
    public String signupForm(Model model) {
        model.addAttribute("userRequest", new UserDTO.Request());
        return "users/signupForm";  // templates/users/signupForm.html
    }

    //로그인 -> SpringSecurity 처리
    @GetMapping("/login")
    public String loginForm() {
        return "users/loginForm";  // templates/users/loginForm.html
    }

마이페이지에 대한 요청으로 마이페이지는 프래그먼트 형식으로 구현됩니다. 유저 정보를 반환하는 프래그먼트와 유저가 작성한 게시글 목록을 출력하는 프래그먼트 그리고 유저 정보 수정 페이지를 나타내는 프래그먼트로 세 가지가 존재합니다. 기본적으로 초기 mypage 요청 시 유저 정보를 반환하는 프래그먼트가 보이게 되고 각 탭 클릭 시 프래그먼트 교체 형식으로 비동기 처리를 진행할 예정입니다. 유저가 작성한 게시글은 8개 단위로 페이징 되어 페이지 숫자나 이전, 다음 버튼 클릭 시 UserController로 요청이 넘어가 해당 페이지에 맞는 게시글 프래그먼트를 반환하게 됩니다.

    //마이페이지
    @GetMapping("/mypage")
    public String myPage(Model model, @AuthenticationPrincipal CustomUserDetails userDetails,
                         @PageableDefault(size = 8) Pageable pageable) {

        User user = userService.viewUserInfo(userDetails.getId());
        UserDTO.Request requestDTO = userMapper.toRequestDTO(user);
        Page<Post> posts = postService.viewUserPosts(userDetails.getId(), pageable);

        // 현재 페이지 (0부터 시작하므로 1을 더함)
        int currentPage = posts.getNumber() + 1;
        // 전체 페이지 수
        int totalPages = posts.getTotalPages();

        // 시작 페이지와 끝 페이지 계산
        int startPage = Math.max(1, currentPage - 4);
        int endPage = Math.min(totalPages, startPage + 8);

        // 시작 페이지 재조정 (끝 페이지가 최대값보다 작은 경우)
        startPage = Math.max(1, endPage - 8);

        model.addAttribute("user", userMapper.toDTO(user));         //유저 정보를 반환할 Response DTO
        model.addAttribute("userRequest", requestDTO);   //유저 정보 수정을 담을 Request DTO
        model.addAttribute("posts", postMapper.toUserInfoResponse(posts.getContent()));
        model.addAttribute("currentPage", currentPage);
        model.addAttribute("startPage", startPage);
        model.addAttribute("endPage", endPage);
        model.addAttribute("totalPages", totalPages);
        model.addAttribute("hasNext", posts.hasNext());
        model.addAttribute("hasPrev", posts.hasPrevious());
        return "users/myPage";
    }
    
    // posts 탭 페이징 처리
    @GetMapping("/mypage/posts")
    public String getUserPosts(@AuthenticationPrincipal CustomUserDetails userDetails,
                               @PageableDefault(size = 8) Pageable pageable,
                               Model model) {
        Page<Post> posts = postService.viewUserPosts(userDetails.getId(), pageable);

        int currentPage = posts.getNumber() + 1;
        int totalPages = posts.getTotalPages();

        int startPage = Math.max(1, currentPage - 4);
        int endPage = Math.min(totalPages, startPage + 8);
        startPage = Math.max(1, endPage - 8);

        model.addAttribute("posts", postMapper.toUserInfoResponse(posts.getContent()));
        model.addAttribute("currentPage", currentPage);
        model.addAttribute("startPage", startPage);
        model.addAttribute("endPage", endPage);
        model.addAttribute("totalPages", totalPages);
        model.addAttribute("hasNext", posts.hasNext());
        model.addAttribute("hasPrev", posts.hasPrevious());

        return "users/myPage :: #postsFragment";  // posts 탭의 프래그먼트만 반환
    }

class UserController - 추가

실제 로직이 처리되는 Controller로 Post 요청을 담당하고 있습니다. 회원 가입과 정보 수정에 대한 요청 처리가 있습니다.

@Controller
@RequestMapping("/users/manage")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    private final PostService postService;

    private final UserMapper userMapper;
    private final PostMapper postMapper;

    //회원 가입
    @PostMapping("/signup")
    public String signupUser(@Valid @ModelAttribute UserDTO.Request userRequest) {
        User user = userService.signupUser(userMapper.toEntity(userRequest));
        return "redirect:/";
    }


    //정보 수정
    @PostMapping("/mypage")
    public String updateUser(
            @AuthenticationPrincipal CustomUserDetails userDetails,
            @Valid @ModelAttribute UserDTO.Request userRequest) {
        userService.updateUser(userDetails.getId(), userMapper.toUpdateDTO(userRequest));
        return "redirect:/users/mypage";  // 마이페이지로 리다이렉트
    }
}

class WebSecurityConfig - 추가

Spring Security가 적용되는 범위를 정의한 설정 클래스입니다. 인증 없이 즉 로그인 없이 접근이 가능한 포인트들을 정의하고 추후 개발될 admin 관련 페이지는 역할이 ADMIN으로 설정된 유저만 접근이 가능하게 하였습니다. 로그인 기능이 적용될 포인트로 로그인 페이지를 지정하였고 성공 시 인덱스 페이지인 게시글 목록으로 리다이렉트 됩니다. 실패 시 에러 페이지로 넘어가게 되고 로그아웃 성공 시 인덱스 페이지로 넘어갑니다. 그리고 UserMapper에서 사용했던 PasswordEncoder를 빈으로 등록하는 코드가 존재합니다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/users/signup", "/users/login").permitAll()
                        .requestMatchers("/posts", "/posts/{id}").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/users/login")
                        .defaultSuccessUrl("/")
                        .failureUrl("/users/login?error")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutSuccessUrl("/")
                        .permitAll()
                );

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

class LoginSuccessHandler - 추가

로그인 성공 시 세션에 인증된 유저의 정보를 보관하는 내용이 정의된 핸들러입니다.

@Component
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {

        HttpSession session = request.getSession();
        session.setAttribute("user", authentication.getPrincipal());

        response.sendRedirect("/");
    }
}

class CustomUserDetails - 추가

security에서 인증된 사용자 정보를 보관하는 객체로 security의 userdetails의 User 객체를 상속받아 UserDetails를 구현한 클래스입니다. 로그인에 사용하는 로그인 아이디, 비밀번호, 역할 필드와 유저의 아이디를 같이 보관하기 위해 구현하였습니다.

@Getter
public class CustomUserDetails extends org.springframework.security.core.userdetails.User implements UserDetails {
    private final Long id;

    public CustomUserDetails(User user) {
        super(user.getLoginId(), user.getPassword(), Collections.singleton(new SimpleGrantedAuthority("ROLE_" + user.getRole())));
        this.id = user.getId();
    }
}

class CustomUserDetailsService - 추가

security에서 로그인을 수행하기 위한 클래스입니다. html 파일 내에서 security가 username과 password 필드를 찾은 뒤 로그인을 수행하게 됩니다. 찾은 username 필드의 값이 오버라이드한 loadUserByUsername 메서드의 파라미터로 넘어오게 되고 내부 로직으로 데이터베이스에 로그인 아이디가 일치하는 유저가 존재한다면 해당 유저를 반환하고 존재하지 않는다면 예외를 발생시키게 됩니다. 또한 비밀번호가 틀리게 된다면 Incorrect Password 예외가 발생하고 위에 Config 클래스에서 설정했던 내용으로 login 페이지로 리다이렉트 됨과 함께 파라미터로 error 정보가 넘어오게 됩니다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository repository;

    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        User user = repository.findByLoginId(loginId)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        return new CustomUserDetails(user);
    }
}

Post

userInfo인 마이페이지에서 유저가 작성한 게시글 목록을 반환을 위해 페이징 처리를 수행하며 수정된 부분이 존재합니다. 


변경 사항

  • PostCrudUseCase 메서드 추가 -> 유저 Id를 통한 페이징 조회
  • PostService 메서드 추가 -> 유저Id를 통한 페이징 조회

interface PostRepository

userId를 통한 Post 검색으로 postId를 기준으로 내림차순 정렬을 수행합니다. 페이징 처리를 위해 pageable 객체를 넘기게 되고 Page <Post> 형식을 반환받습니다.

Page<Post> findByUserIdOrderByIdDesc(Long userId, Pageable pageable);

class PostService

UseCase와 Service는 같은 메서드이므로 구현된 Service 부분입니다. 유저 Id로 검색하여 페이징 처리된 Post를 반환합니다.

    @Override
    @Transactional(readOnly = true)
    public Page<Post> viewUserPosts(Long userId, Pageable pageable) {
        return repository.findByUserIdOrderByIdDesc(userId, pageable);
    }

 

728x90