개인 프로젝트/Toy & Side

[TOY] 개발 - 수정

kwang2134 2025. 1. 4. 19:45
728x90
728x90
반응형

이전 진행 상황

  • 통합 테스트 개발
  • PostApiController 추가
  • 기타 수정

프론트엔드 개발로 인한 수정된 부분을 위한 문서입니다. 개발된 프론트엔드 관련 코드는 따로 업로드될 예정이며 서버 측 코드의 수정을 주 내용으로 다루고 있습니다.

  • user
  • post
  • comment
  • global

User

프론트엔드 개발이 이루어지면서 Api 컨트롤러가 추가되고 시큐리티 관련과 서버 측 코드 수정도 많이 이루어졌습니다. admin 관련 기능이 개발되었습니다. 


변경 사항

  • UserController 수정
  • UserFormController 수정
  • WebSecurityConfig 수정
  • LoginSuccessHandler  수정
  • UserDTO 수정
  • UserService 수정
  • User 수정
  • UserRepository 수정

추가 사항

  • UserApiController 추가
  • CustomAuthenticationFailureHandler 추가
  • Admin 클래스 추가

변경 내용

class UserController - 수정

회원 가입에 실패하는 경우 에러 정보를 가지고 다시 회원 가입 페이지로 리다이렉트 되게 수정하였습니다. 

    //회원 가입
    @PostMapping("/signup")
    public String signupUser(@Validated(ValidationGroups.SignUp.class) @ModelAttribute("userRequest") UserDTO.Request userRequest,
                             BindingResult bindingResult) {

        if (bindingResult.hasErrors()) {
            return "user/signup-form";
        }

        User user = userService.signupUser(userMapper.toEntity(userRequest));
        return "redirect:/";
    }

유저 정보 업데이트 실패의 경우에도 에러 정보를 가지고 다시 수행할 수 있게 수정하였습니다.

    //정보 수정
    @PostMapping("/mypage")
    public String updateUser(
            @AuthenticationPrincipal CustomUserDetails userDetails,
            @Validated(ValidationGroups.Update.class) @ModelAttribute UserDTO.Request userRequest,
            BindingResult bindingResult,
            Model model) {

        if (bindingResult.hasErrors()) {
            // 검증 실패 시 필요한 데이터를 다시 모델에 추가
            User user = userService.viewUserInfo(userDetails.getId());
            model.addAttribute("user", userMapper.toDTO(user));
            model.addAttribute("userRequest", userRequest);

            // edit 탭이 활성화되도록 상태 전달
            model.addAttribute("activeTab", "edit");

            return "user/mypage";
        }

        userService.updateUser(userDetails.getId(), userMapper.toUpdateDTO(userRequest));
        return "redirect:/user/mypage";
    }

class UserFormController - 수정

html 파일이 개발되면서 페이지 이름이 달라지며 경로에 맞춰 수정했습니다. 

@Controller
@RequestMapping("/user")
@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 "user/signup-form";  // templates/users/signupForm.html
    }

    //로그인 -> SpringSecurity 처리
    @GetMapping("/login")
    public String loginForm(@RequestParam(required = false) String error,
                            @RequestParam(required = false) String message,
                            @RequestParam(required = false) String username,
                            Model model, HttpServletRequest request) {
        if (error != null) {
            model.addAttribute("errorMessage", message);
            model.addAttribute("username", username);
        }

        String uri = request.getHeader("Referer");
        if (uri != null && !uri.contains("/login")) {
            request.getSession().setAttribute("prevPage", uri);
        }
        return "user/login-form";
    }

    //마이페이지
    @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 "user/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 "user/mypage :: #posts-fragment";  // posts 탭의 프래그먼트만 반환
    }
}

class WebSecurityConfig - 수정 

url 허용 방식을 기존 특정 url을 인증 없이 허용하던 방식에서 특정 url만 인증이 필요하도록 변경하였습니다. admin 페이지 개발을 admin 관련 필터 설정과 기존 회원 설정을 분리하였습니다. 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final CustomAuthenticationFailureHandler failureHandler;
    private final LoginSuccessHandler loginSuccessHandler;

    private final AdminAuthenticationSuccessHandler adminSuccessHandler;
    private final AdminAuthenticationFailureHandler adminFailureHandler;

    @Bean
    public SecurityFilterChain  userFilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher(request -> !request.getRequestURI().startsWith("/admin/"))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/user/mypage/**").authenticated()
                        .anyRequest().permitAll()
                )
                .formLogin(form -> form
                        .loginPage("/user/login")
                        .loginProcessingUrl("/user/login-proc")
                        .successHandler(loginSuccessHandler)
                        .failureHandler(failureHandler)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/user/logout")
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
                        .clearAuthentication(true)
                        .permitAll()
                )
                .csrf(csrf -> csrf
                        .ignoringRequestMatchers("/api/**")  // API 요청은 CSRF 제외
                );

        return http.build();
    }

    @Bean
    public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher("/admin/**")  // admin 관련 URL만 처리
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/admin/login").permitAll()
                        .anyRequest().hasRole("ADMIN")
                )
                .formLogin(form -> form
                        .loginPage("/admin/login")
                        .loginProcessingUrl("/admin/login-proc")
                        .successHandler(adminSuccessHandler)
                        .failureHandler(adminFailureHandler)
                        .permitAll()
                )
                .csrf(csrf -> csrf
                        .ignoringRequestMatchers("/api/**")
                );
        return http.build();
    }

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

class LoginSuccessHandler - 수정

로그인 성공 시 이전 페이지로 리다이렉트 되게 수정하였습니다. 기존 로직의 경우 인증이 필요한 페이지에 접근하여 로그인 화면으로 넘어가고 로그인이 성공했을 경우에만 Spring Security가 가로챘던 이전 페이지로 리다이렉트 되도록 되어있었습니다. 이제 모든 구간에서 이전 url의 정보를 가지게 하여 헤더의 로그인 버튼으로 로그인 한 뒤 다시 이전 페이지로 돌아가게 수정하였습니다. handler에 user 패키지가 추가되고 위치가 변경되었습니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {

        HttpSession session = request.getSession();
        session.setAttribute("user", authentication.getPrincipal());
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        String prevPage = (String) session.getAttribute("prevPage");

        if (savedRequest != null) {
            log.info("savedRequest = {}", savedRequest.getRedirectUrl());
            String targetUrl = savedRequest.getRedirectUrl();
            getRedirectStrategy().sendRedirect(request, response, targetUrl);
        } else if(prevPage != null) {
            log.info("prevPage = {}", prevPage);
            session.removeAttribute("prevPage");
            getRedirectStrategy().sendRedirect(request, response, prevPage);
        } else {
            getRedirectStrategy().sendRedirect(request, response, "/");
        }
    }
}

class UserDTO - 수정

요청을 위한 DTO에 validation을 위한 제약을 추가하였습니다. 패키지 구조가 기존 dto에서 dto.user로 user 패키지가 추가되고 위치가 변경되었습니다. 

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Request {
        private Long id;

        @NotBlank(message = "아이디는 필수 입력 사항입니다", groups = {ValidationGroups.SignUp.class})
        private String loginId;

        @NotBlank(message = "비밀번호는 필수 입력 사항입니다", groups = {ValidationGroups.SignUp.class, ValidationGroups.Update.class})
        private String password;

        @NotBlank(message = "이름은 필수 입력 사항입니다", groups = {ValidationGroups.SignUp.class, ValidationGroups.Update.class})
        private String username;

        @Email(message = "올바른 이메일 형식이 아닙니다")
        private String email;
    }

class UserService - 수정

로직 수행에 필요한 메서드가 추가로 생성되었습니다. 회원가입 수행 시 로그인에 사용할 아이디가 사용 가능한지 체크하는 메서드와 admin 페이지에서 사용될 권한으로 검색하는 메서드와 유저 차단, 매니저 승급, 일반 회원으로 강등에 사용할 메서드가 추가되었습니다. 

    @Override
    @Transactional(readOnly = true)
    public boolean isLoginIdAvailable(String loginId) {
        return repository.findByLoginId(loginId).isEmpty();
    }

    @Transactional(readOnly = true)
    public List<User> getNormalUsers() {
        return repository.findUsersByRole(Role.USER);
    }

    @Transactional(readOnly = true)
    public List<User> getManageUsers() {
        return repository.findUsersByRole(Role.MANAGER);
    }

    @Transactional
    public void banUser(Long id) {
        repository.deleteById(id);
    }

    @Transactional
    public void appointUser(Long id) {
        User user = repository.findById(id).orElseThrow(() -> new UserNotFoundException(id));
        user.changeManager();
    }

    @Transactional
    public void demoteUser(Long id) {
        User user = repository.findById(id).orElseThrow(() -> new UserNotFoundException(id));
        user.changeUser();
    }

class User - 수정

회원을 매니저로 승급하는 메서드와 일반 회원으로 변경하는 메서드가 추가되었습니다. 

    public void changeManager() {
        if (this.role == Role.USER) {
            this.role = Role.MANAGER;
        }
    }

    public void changeUser() {
        this.role = Role.USER;
    }

class UserRepository - 수정

회원 권한으로 검색하는 메서드가 생성되었습니다.

List<User> findUsersByRole(Role role);

추가 사항

class UserApiController - 추가

회원 가입 수행 시 로그인 아이디의 유효성 체크를 위해 생성되었습니다. RestController로 입력받은 아이디에 대해 유효성 검사를 실시하고 결과를 반환합니다. 유효성 체크의 경우 중복된 로그인 아이디 사용을 막기 위해 데이터베이스 내 입력받은 로그인 아이디가 존재하는지 검사하고 결과를 반환합니다. 

@Slf4j
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserApiController {

    private final UserService userService;


    @GetMapping("/check-loginId")
    public ResponseEntity<?> checkLoginId(@RequestParam String loginId) {
        log.info("check-loginId = {}", loginId);
        Map<String, Object> response = new HashMap<>();

        if (loginId == null || loginId.trim().isEmpty()) {
            response.put("available", false);
            response.put("message", "아이디를 입력해주세요.");
            return ResponseEntity.badRequest().body(response);
        }

        boolean isAvailable = userService.isLoginIdAvailable(loginId);

        response.put("available", isAvailable);
        response.put("message", isAvailable ? "사용 가능한 아이디입니다." : "이미 사용중인 아이디입니다.");

        return ResponseEntity.ok(response);
    }
}

class CustomAuthenticationFailureHandler - 추가

유저 로그인 실패 시 처리를 위한 핸들러입니다. 로그인 실패 처리를 수행하게 됩니다. 

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {

        String errorMessage = "아이디 또는 비밀번호가 일치하지 않습니다.";
        if (exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 일치하지 않습니다.";
        } else if (exception instanceof InsufficientAuthenticationException) {
            errorMessage = "필수 입력 사항입니다.";
        }

        String username = request.getParameter("username");
        response.sendRedirect("/user/login?error=true&message=" +
                URLEncoder.encode(errorMessage, StandardCharsets.UTF_8) +
                "&username=" + URLEncoder.encode(username, StandardCharsets.UTF_8));
    }
}

class AdminFormController - 추가

관리자 페이지를 위한 form 컨트롤러입니다. 관리자 로그인 메서드와 유저 관리 창을 위한 메서드가 있습니다.

@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/admin")
public class AdminFormController {

    private final UserService userService;

    private final AdminMapper adminMapper;

    @GetMapping("/login")
    public String adminLoginForm(@RequestParam(required = false) String error,
                                 Model model) {
        if (error != null) {
            model.addAttribute("errorMessage", "관리자 계정 정보가 올바르지 않습니다.");
        }
        return "admin/login-form";
    }

    @GetMapping("/manage")
    public String manageUser(Model model) {
        List<User> normalUsers = userService.getNormalUsers();
        List<User> manageUsers = userService.getManageUsers();

        model.addAttribute("normalUsers", adminMapper.toUserList(normalUsers));
        model.addAttribute("managerUsers", adminMapper.toUserList(manageUsers));

        log.info("manage 페이지 호출 성공");

        return "admin/manage-form";
    }
}

class AdminApiController - 추가

관리자의 유저 관리 화면에서 사용할 Api 컨트롤러입니다. 회원 차단, 승급, 강등을 위한 메서드가 존재합니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/manage")
public class AdminApiController {

    private final UserService userService;

    @PostMapping("/ban")
    public ResponseEntity<?> banUser(@RequestParam Long id) {
        try {
            userService.banUser(id);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    @PostMapping("/appoint")
    public ResponseEntity<?> appointUser(@RequestParam Long id) {
        try {
            userService.appointUser(id);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

    @PostMapping("/demote")
    public ResponseEntity<?> demoteUser(@RequestParam Long id) {
        try {
            userService.demoteUser(id);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

}

class AdminDTO - 추가

admin 화면의 유저 관리를 위한 dto입니다. 유저 엔티티와 동일한 필드를 가지고 있는데 관리자가 회원의 정보를 다 열람할 수 있기 때문입니다.

@Getter
@AllArgsConstructor
public class AdminDTO {

    private final Long id;
    private final String loginId;
    private final String password;
    private final String username;
    private final String email;
    private final String role;
}

class AdminMapper - 추가

관리 화면을 위한 회원 정보를 dto로 변환하는 mapper입니다.

@Component
@RequiredArgsConstructor
public class AdminMapper {

    public List<AdminDTO> toUserList(List<User> users) {
        return users.stream().map(user -> new AdminDTO(
                user.getId(),
                user.getLoginId(),
                user.getPassword(),
                user.getUsername(),
                user.getEmail(),
                user.getRole().toString())).toList();
    }
}

class AdminAuthenticationSuccessHandler  - 추가

관리자 화면 로그인 성공 로직을 처리하는 핸들러입니다.

@Component
public class AdminAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        if (authentication.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
            response.sendRedirect("/admin/manage");
        } else {
            response.sendRedirect("/admin/login?error=true");
        }
    }
}

class AdminAuthenticationFailureHandler - 추가

관리자 로그인 실패 시 처리하는 핸들러입니다. 

@Component
public class AdminAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException {
        response.sendRedirect("/admin/login?error=true");
    }
}

Post

페이징 관련과 프론트 렌더링에 필요한 추가적인 정보를 넘기기 위해 수정된 부분들이 존재합니다.


변경 사항

  • PostFormController 수정
  • PostController 수정
  • PostMapper 수정
  • PostQueryRepository 수정
  • PostService 수정
  • PostRepository 수정
  • Post 수정
  • PostType 수정

class PostFormController - 수정

게시글 페이징 관련 요청에 페이지 그룹이 null으로 문제가 발생해 페이지 그룹에 대한 처리와 화살표 버튼으로 10 페이지 단위 넘기기를 수행할 경우 다음 페이지 그룹의 존재 여부를 model에 넣어 넘기게 처리하였습니다. 

	// pageGroup이 null이면 현재 페이지로 계산
        if (pageGroup == null) {
            pageGroup = (currentPage - 1) / 9;
        }
        
        boolean hasNextGroup = endPage < totalPages;
        model.addAttribute("hasNextGroup", hasNextGroup);

위와 같은 코드들이 관련된 모든 요청 메서드에 추가되었습니다.

	if (request.getType() == null) {
            request.setType("NORMAL");
        }

게시글 수정 및 작성 메서드에선 기본 게시글 종류를 일반으로 지정해 주는 코드가 추가되었습니다. 게시글 수정폼 요청 메서드에 게시글 id를 model에 담아 넘기는 부분이 추가되었습니다.

class PostController - 수정

게시글 삭제의 경우 삭제를 요청한 회원의 권한이 매니저 이상이라면 삭제되게 수정하였습니다. 매니저 이상의 권한을 가진 유저는 다른 회원의 게시글을 삭제할 권한을 부여하였습니다.

    private boolean checkPermission(CustomUserDetails userDetails, Post post) {
        // 사용자가 없거나 게시글 작성자가 아닌 경우
        if (userDetails == null || !post.getUser().getId().equals(userDetails.getId())) {
            // 매니저 이상 권한인 경우 삭제 허용
            return userDetails == null ||
                    !(userDetails.getRole() == Role.MANAGER || userDetails.getRole() == Role.ADMIN);
        }
        return false;  // 게시글 작성자인 경우 삭제 허용
    }

class PostMapper - 수정

게시글 조회 시 웹에서 이미지를 불러오는 부분에 문제가 있어 응답 dto로 변환 시 웹에서 읽을 수 있는 경로로 매핑해 주는 부분과 게시글 작성 날짜를 yyyy:MM:dd 형식으로 출력하기 위해 포맷하는 과정이 추가되었습니다.

    private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd");

    //이미지 경로 변환 및 날짜 형식 포맷
    public PostDTO.Response toResponseDTO(Post post) {
        String content = post.getContent();
        content = content.replaceAll("!\\[image\\]\\(([^)]*)\\)", "<img src='$1' alt='Post Image'>");

        return new PostDTO.Response(
                post.getId(),
                post.getDisplayName(),
                post.getTitle(),
                content,
                post.getCreatedAt().format(formatter),
                post.getUpdatedAt().format(formatter),
                post.getRecomCount(),
                post.getNotRecomCount()
        );
    }

그리고 요청 객체를 엔티티로 변환하는 메서드가 추가되었습니다.

    public Post toEntity(PostDTO.Request dto) {
        return Post.builder()
                .title(dto.getTitle())
                .content(dto.getContent())
                .displayName(dto.getDisplayName())
                .password(dto.getPassword())
                .postType(PostType.valueOf(dto.getType()))
                .build();
    }

게시글 작성 시 요청 dto에 담긴 정보로 게시글 저장을 위해 사용됩니다. 

class PostQueryRepository - 수정 

검색 조건 검사에서 기존 contains 사용으로 대소문자를 구별한 채 검색이 되어 대소문자 구분 없이 검색하기 위해 기존 contains 대신 containsIgnoreCase를 사용하여 대소문자를 구분하지 않게 해 주었습니다.

    private BooleanExpression titleContains(String title) {
        return title != null ? post.title.containsIgnoreCase(title) : null;
    }

    private BooleanExpression contentContains(String content) {
        return content != null ? post.content.containsIgnoreCase(content) : null;
    }

    private BooleanExpression authorContains(String author) {
        return author != null ? post.displayName.containsIgnoreCase(author) : null;
    }

class PostService - 수정

게시글 리스트를 위한 일반, 공지, 인기 게시글 리스트를 최신순으로 출력하기 위해 게시글 id 기준 내림차순으로 정렬하여 받아올 수 있게 수정하였습니다.

    @Override
    @Transactional(readOnly = true)
    public Page<Post> viewPopularPosts(Pageable pageable) {
        return postRepository.findByPostTypeOrderByIdDesc(PostType.POPULAR, pageable);
    }

    @Override
    @Transactional(readOnly = true)
    public Page<Post> viewNoticePosts(Pageable pageable) {
        return postRepository.findByPostTypeOrderByIdDesc(PostType.NOTICE, pageable);
    }

    @Override
    @Transactional(readOnly = true)
    public Page<Post> viewNormalPosts(Pageable pageable) {
        return postRepository.findByPostTypeOrderByIdDesc(PostType.NORMAL, pageable);
    }

interface PostRepository - 수정

위의 PostService에서 사용되는 내림차순 정렬을 위한 메서드를 추가했습니다.

Page<Post> findByPostTypeOrderByIdDesc(PostType postType, Pageable pageable);

class Post - 수정

게시글과 유저를 연결하는 과정에서 displayName 필드에 회원의 username으로 채우는 로직을 추가했습니다. 기존 회원의 경우 username을 기준으로 출력하고 비회원인 경우 작성 시 입력한 displayName 필드의 값으로 출력되게 되는데 코드의 단일화를 위해 웹에선 무조건 displayName을 기준으로 출력하고 회원인 경우 displayName에 회원의 username을 넣어주게 수정했습니다.

    public void connectUser(User user) {
        this.displayName = user.getUsername();
        this.user = user;
    }

Enum PostType - 수정

기존 value 필드를 제거했습니다. 

@Getter
@RequiredArgsConstructor
public enum PostType {
    NORMAL,
    NOTICE,
    POPULAR
}

Comment

Post와 거의 동일한 부분들이 수정되었습니다. 페이징 처리를 위한 페이지 그룹과 화살표 버튼에 대한 처리와 mapper의 dto 변환 시 날짜 포맷 그리고 회원 연결 시 displayName 필드에 username을 넣는 등 거의 동일한 부분이 수정되었고 댓글 출력 시 댓글과 대댓글의 계층형 구조를 위해 추가적인 수정이 존재합니다. 

 

post와 동일한 수정 내용은 생략하고 comment만의 수정 내용에 대해서만 작성하였습니다.


  • CommentFormController 수정
  • CommentController 수정
  • CommentDTO 수정
  • CommentMapper 수정
  • CommentService 수정
  • Comment 수정
  • CommentRepository 수정

class CommentFormController - 수정

댓글 페이징 요청 시 post의 정보를 model에 같이 담아 보내게 처리했습니다.

	Post post = postService.viewPost(postId);
        model.addAttribute("post", postMapper.toResponseDTO(post));

class CommentController - 수정

게시글과 동일하게 매니저 이상의 권한을 가진 회원이 다른 회원의 댓글을 삭제할 수 있게 하였습니다.

    private boolean checkPermission(CustomUserDetails userDetails, Comment comment) {
        // 사용자가 없거나 댓글 작성자가 아닌 경우
        if (userDetails == null || !comment.getUser().getId().equals(userDetails.getId())) {
            // 매니저 이상 권한인 경우 삭제 허용
            return userDetails == null ||
                    !(userDetails.getRole() == Role.MANAGER || userDetails.getRole() == Role.ADMIN);
        }
        return false;  // 댓글 작성자인 경우 삭제 허용
    }

class CommentDTO - 수정

댓글 출력 시 계층 구조를 위해 응답 객체에 댓글 깊이를 나타내는 필드를 추가했습니다. 0인 경우 원본 댓글, 1인 경우 대댓글을 의미합니다.

private final int depth;

class CommentMapper - 수정

날짜 형식 포맷과 응답 객체 변환 시 parentComment의 여부로 깊이를 매핑하는 코드를 추가하였습니다.

    public List<CommentDTO.Response> toResponseListDTO(List<Comment> comments) {
        return comments.stream().map(comment -> new CommentDTO.Response(
                comment.getId(),
                comment.getUser() != null ? comment.getUser().getId() : null,
                comment.getParentComment() != null ? comment.getParentComment().getId() : null,
                comment.getDisplayName(),
                comment.getContent(),
                comment.isDeleted(),
                comment.getCreatedAt().format(formatter),
                comment.getUpdatedAt().format(formatter))
                comment.getUpdatedAt().format(formatter),
                comment.getParentComment() != null ? 1 : 0)
        ).toList();
    }

class CommentService - 수정

댓글 삭제 처리에 자식이 있는 댓글 삭제 시 원본 댓글의 값을 변경하는 부분이 추가되었습니다.

    @Override
    @Transactional
    public void deleteComt(Long commentId) {
        Comment comment = repository.findById(commentId)
                .orElseThrow(() -> new CommentNotFoundException(commentId));

        List<Comment> childComments = repository.findByParentCommentId(commentId);

        if (!childComments.isEmpty()) {
            // 자식 댓글이 있는 경우 상태만 변경
            comment.changeStateToDelete();
            comment.changeContentToDeleteMessage();
            // 자식 댓글들의 상태도 변경
            childComments.forEach(Comment::changeStateToDelete);
        } else {
            // 자식 댓글이 없는 경우 실제 삭제
            repository.deleteById(commentId);
        }
    }

class Comment - 수정

위의 CommentService에서 사용된 changeContentToDeleteMessage() 메서드가 추가되었습니다. 삭제를 수행하는 원본 댓글의 작성자 필드를 비우고 내용을 "삭제된 댓글입니다."로 변경합니다. 해당 원본 댓글에 대해서 더 이상 대댓글을 작성할 수 없습니다.

    public void changeContentToDeleteMessage() {
        this.displayName = null;
        this.content = "삭제된 댓글입니다.";
    }

interface CommentRepository - 수정

댓글 출력 시 내림차순 정렬을 통해 최신순으로 출력하게 하고 부모 댓글이 존재하는 경우 부모 댓글을 기준으로 정렬하여 대댓글은 부모 댓글 아래에 출력할 수 있도록 처리하는 메서드입니다. 이전 댓글 출력에선 댓글을 최신순으로만 출력하여 원본 댓글 아래에 대댓글이 위치하지 못하던 상태로 대댓글의 기능을 제대로 처리하지 못했는데 해당 부분을 수정하였습니다.

    @Query("SELECT c FROM Comment c "+
    "WHERE c.post.id = :postId " +
    "ORDER BY COALESCE(c.parentComment.id, c.id) DESC, c.id ASC")
    Page<Comment> findAllWithPaging(@Param("postId") Long postId, Pageable pageable);

Global

예외와 웹에 관한 부분 설정 수정과 docker 의존성 추가, ts 코드 컴파일을 위한 추가 설정 파일들이 추가되었습니다.


변경 사항

  • WebConfig 수정

추가 사항

  • GlobalControllerAdvice 추가
  • ValidationGroups 추가
  • CustomErrorController 추가
  • application.properties 프로필 분리
  • 도커 관련 파일 추가
  • ts 관련 파일 추가

변경 내용

class WebConfig - 수정

웹에서 이미지 처리를 위해 설정을 보완했습니다.

	registry.addResourceHandler("/images/**")
                .addResourceLocations("file:///D:/project/images/")
                .setCachePeriod(3600)
                .resourceChain(true)
                .addResolver(new PathResourceResolver());

 


추가 내용

class GlobalControllerAdvice - 추가

Thymeleaf 템플릿에서 uri 접근을 위한 Controller입니다. Thymeleaf 3.1 이후로 보안상 #session에 직접 접근할 수 없어 추가하게 되었습니다.

@ControllerAdvice
public class GlobalControllerAdvice {

    @ModelAttribute("currentUri")
    public String currentUri(HttpServletRequest request) {
        return request.getRequestURI();
    }
}

final class ValidationGroups - 추가

회원 가입과 회원 정보 수정 시 스프링의 validation 사용으로 필드에 대한 검증을 나누기 위해 추가하였습니다.

public final class ValidationGroups {
    public interface SignUp {}
    public interface Update {}

    private ValidationGroups() {}
}

class CustomErrorController - 추가

처리 되지 못한 예외가 발생할 경우 에러 페이지로 처리하기 위한 클래스입니다. 404, 400, 500 오류에 대한 처리를 수행합니다.

@Controller
public class CustomErrorController implements ErrorController {
    @RequestMapping("/error")
    public String handleError(HttpServletRequest request, Model model) {
        Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);

        if (status != null) {
            int statusCode = Integer.parseInt(status.toString());

            if (statusCode == HttpStatus.NOT_FOUND.value()) {
                model.addAttribute("errorMessage", "요청하신 페이지를 찾을 수 없습니다.");
                return "error/not-found";
            } else if (statusCode == HttpStatus.INTERNAL_SERVER_ERROR.value()) {
                model.addAttribute("errorMessage", "서버 오류가 발생했습니다.");
                return "error/server-error";
            } else {
                model.addAttribute("errorMessage", "요청을 처리할 수 없습니다.");
                return "error/client-error";  // 4xx 에러를 위한 공통 페이지
            }
        }

        model.addAttribute("errorMessage", "서버 오류가 발생했습니다.");
        return "error/server-error";
    }
}

application.properties

도커 의존성을 도입하며 개발 환경에서 로컬 실행을 위한 프로필과 도커용 프로필을 분리하였습니다.

# -- 공용 application.properties --
spring.application.name=board
spring.output.ansi.enabled=always

#이미지 크기 제한 4MB
spring.servlet.multipart.max-file-size=4MB
spring.servlet.multipart.max-request-size=4MB

#요청 로깅
logging.level.org.springframework.web=DEBUG
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace

#BatchSize 설정
spring.jpa.properties.hibernate.default_batch_fetch_size=15

spring.thymeleaf.servlet.produce-partial-output-while-processing=false

# -- application-local.properties 개발 환경에서 사용으로 메모리 모드 h2 사용 --
#Redis 설정
spring.redis.host=localhost
spring.redis.port=6379

#H2 설정 (개발용)
spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create

# -- application-docker.properties 서비스 환경으로 mysql 사용 --
#Redis 설정
spring.redis.host=redis
spring.redis.port=6379

#MySQL 설정
spring.datasource.url=jdbc:mysql://mysql:3306/board?useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=kwang
spring.datasource.password=6036
spring.jpa.hibernate.ddl-auto=none

docker

도커 의존성 추가로 인한 파일입니다. env 파일은 깃에 연동되지 않고 env.example을 연동하여 예시를 제공합니다. 

//Dockerfile
FROM eclipse-temurin:17-jdk-jammy

WORKDIR /app
COPY build/libs/board-0.0.1-SNAPSHOT.jar app.jar

LABEL authors="kwang"

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

//docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
          condition: service_started
    environment:
      SPRING_PROFILES_ACTIVE: docker
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/board?useSSL=false&allowPublicKeyRetrieval=true
      SPRING_DATASOURCE_USERNAME: kwang
      SPRING_DATASOURCE_PASSWORD: 6036

  mysql:
     image: mysql:8.0.39
     ports:
       - "3306:3306"
     environment:
       MYSQL_DATABASE: board
       MYSQL_USER: kwang
       MYSQL_PASSWORD: 6036
       MYSQL_ROOT_PASSWORD: root
     volumes:
       - mysql-data:/var/lib/mysql
     command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
     healthcheck:
       test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "kwang", "--password=6036" ]
       interval: 10s
       timeout: 5s
       retries: 5

  redis:
    image: redis:7.4.1
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  mysql-data:
  redis-data:


//.env
MYSQL_USER=kwang
MYSQL_PASSWORD=6036
MYSQL_DATABASE=board
MYSQL_ROOT_PASSWORD=root

//.env.example
MYSQL_USER=username
MYSQL_PASSWORD=password
MYSQL_DATABASE=dbname
MYSQL_ROOT_PASSWORD=root_password

TypeScript

타입스크립트를 사용하며 프로젝트 빌드 시 ts 코드를 js 코드로 변환하는 처리에 대한 코드가 추가되었습니다. tsconfig.json 코드만 깃에 연동되어 관리됩니다. 

//tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "es6",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./src/main/resources/static/js",
    "rootDir": "./src/main/typescript"
  }
}

//package.json
{
  "name": "board",
  "version": "1.0.0",
  "description": "게시판 프로젝트",
  "main": "index.js",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^22.10.1",
    "typescript": "^5.7.2"
  }
}
728x90