개인 프로젝트/Toy & Side

[TOY] 개발 - Adapters(Post)

kwang2134 2024. 12. 4. 14:36
728x90
반응형
728x90

이전 진행 상황

  • UserDTO 수정
  • UserMapper 추가
  • UserFormController 추가
  • UserController 추가
  • Spring Security 관련 클래스 추가
  • Post 페이징 관련 메서드 추가

  • Post
  • Photo

Post

Post의 adapters 개발로 인해 많은 것들이 수정되었습니다. 페이징 처리를 위해 존재하던 게시글 목록과 관련된 메서드의 반환 값이 Page로 변경되고 각 타입별 게시글 조회 메서드가 추가되었습니다. Querydsl을 통해 PostQueryRepositoryImpl이 추가되었습니다. 또한 Service의 로직과 Controller 추가로 인해 DTO의 구조 변경 등 많은 사항이 수정되었습니다.


변경 내용

  • 게시글 목록 조회 관련 메서드 페이징 처리 -> 반환 타입 List <Post>에서 Page <Post>로 변경
  • PostRepository 메서드 추가 -> 게시글 조회 시 User 정보를 함께 가져오는 fetch join 메서드 추가
  • PostService 메서드 추가 -> 타입별 게시글 목록 조회 일반, 공지, 인기
  • PostService 메서드 추가 -> 비회원 게시글 수정 시 권한 확인 메서드 checkNonUserPost 추가
  • PostService 메서드 수정 -> viewPost 메서드의 post 조회 시 fetch join 메서드 사용 변경
  • PostDTO 구조 통합 및 수정 -> 요청 DTO 회원/비회원 통합 및 불필요한 필드 제거
  • PostDTO 이너 클래스 추가 -> 게시글 목록 요청 DTO 추가 ListResponse
  • PostMapper 메서드 추가 -> 게시글 목록용 DTO 변환 로직 추가

추가 내용

  • PostQueryRepositoryImpl 추가 
  • QuerydslConfig 추가
  • PostFormController 추가
  • PostController 추가
  • UnauthorizedAccessException 추가
  • GlobalExceptionHandler 추가

변경 내용

interface PostRepository - 수정

페이징 처리를 위해 게시글 타입을 통한 목록 조회의 반환 타입을 Page로 변경하였습니다. 게시글과 유저의 정보를 함께 들고 오는 fetch join 메서드가 추가되었습니다. 

 Page<Post> findByPostType(PostType postType, Pageable pageable);
 
 @Query("SELECT p FROM Post p JOIN FETCH p.user WHERE p.id = :postId")
 Optional<Post> findByIdWithUser(@Param("postId") Long postId);

class Postservice - 수정

게시글 작성 메서드가 수정되었습니다. userId를 파라미터로 넘겨 회원이 작성한 경우 게시글과 회원을 외래키로 연결하는 로직이 추가되었습니다.

    @Override
    @Transactional
    public Post createPost(Post post, Long userId) {
        if (userId != null) {
            User user = userRepository.findById(userId)
                    .orElseThrow(() -> new UserNotFoundException(userId));
            post.connectUser(user);
        }
        return postRepository.save(post);
    }

searchPosts가 페이징처리를 위해 Page 타입을 반환하게 변경하였습니다.

    @Override
    @Transactional(readOnly = true)
    public Page<Post> searchPosts(PostSearchCond searchCond, Pageable pageable) {
        return queryRepository.searchPosts(searchCond, pageable);
    }

일반, 공지, 인기 게시글을 Page로 반환하는 메서드가 추가되었습니다.

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

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

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

viewPost로 게시글 정보를 조회할 때 User의 정보를 같이 들고 오는 fetch join 메서드를 사용하게 변경하였습니다.

    @Override
    @Transactional(readOnly = true)
    public Post viewPost(Long postId) {
        return postRepository.findById(postId)
        return postRepository.findByIdWithUser(postId)
                .orElseThrow(() -> new PostNotFoundException(postId));
    }

수정 버튼 클릭 시 비회원 용 권한 처리를 위한 메서드가 추가되었습니다.

    @Override
    public boolean checkNonUserPost(Long postId, String password) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new PostNotFoundException(postId));

        return post.getPassword().equals(password);
    }

class PostDTO - 수정

기존 회원 용 요청과 비회원 용 요청이 나눠져 있는 상태에서 하나의 요청으로 합쳐졌습니다. Spring Security를 사용하며 로그인 시 보관된 회원의 인증 정보를 통해 회원/비회원을 결정하게 됩니다. 요청에 불필요했던 필드들도 제거되었습니다. 

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Request {
        private String title;
        private String content;
        private String type;
        private String displayName;  // 비회원인 경우 작성자명 회원인 경우 username
        private String password;  // 비회원인 경우 비밀번호
    }

게시글 목록 페이지에서 응답을 위한 DTO가 추가되었습니다. Mapper를 통해 List 형태로 반환하게 됩니다.

    @Getter
    @AllArgsConstructor
    public static class ListResponse {
        private final Long id;
        private final String username;
        private final String title;
        private final String createdAt;
        private final int recomCount;
    }

class PostMapper - 수정

DTO의 구조가 변경됨에 따라 mapper 또한 수정되었습니다. 통합된 게시글 작성 요청을 엔티티로 변환하는 메서드입니다. 

    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 리스트를 만드는 메서드입니다.

    public List<PostDTO.ListResponse> toDTOList(List<Post> posts) {
        return posts.stream().map(post -> new PostDTO.ListResponse(
                post.getId(),
                post.getDisplayName(),
                post.getTitle(),
                post.getCreatedAt().toString(),
                post.getRecomCount()
                )).toList();
    }

게시글 수정 요청 시 넘길 DTO 변환을 위한 메서드입니다. 수정이 가능한 게시글의 이전 값들을 담아 model에 넣어 보내게 됩니다. 

    public PostDTO.Request toRequestDTO(Post post) {
        return new PostDTO.Request(post.getTitle(),
                post.getContent(),
                post.getPostType().toString(),
                null,
                null);
    }

추가 내용

class PostQueryRepositoryImpl - 추가

게시글 검색을 위한 메서드를 구현한 Repository입니다. Querydsl을 사용한 검색이 수행됩니다. 게시글 검색의 경우 제목, 내용, 작성자, 제목 + 내용 총 4가지 조건으로 검색이 가능합니다. 기존 검색 조건이 없을 경우 전체 게시글 조회로 사용할 예정이었으나 각 게시글 tab에 맞는 목록을 반환하기 위해 각 게시글 목록 조회 메서드를 추가하고 searchPosts 메서드는 검색을 위해서만 사용됩니다.  

import static com.kwang.board.post.domain.model.QPost.post;

@Repository
@RequiredArgsConstructor
public class PostQueryRepositoryImpl implements PostQueryRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Post> searchPosts(PostSearchCond searchCond, Pageable pageable) {
        // 전체 검색 쿼리
        List<Post> posts = queryFactory
                .selectFrom(post)
                .where(
                        searchCondition(searchCond)
                )
                .orderBy(post.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // 전체 카운트 쿼리
        Long total = queryFactory
                .select(post.count())
                .from(post)
                .where(
                        searchCondition(searchCond)
                )
                .fetchOne();

        return new PageImpl<>(posts, pageable, total);
    }

    private BooleanExpression searchCondition(PostSearchCond searchCond) {
        if (searchCond.getTitle() != null && searchCond.getContent() != null) {
            return titleContains(searchCond.getTitle())
                    .or(contentContains(searchCond.getContent()));
        } else if (searchCond.getTitle() != null) {
            return titleContains(searchCond.getTitle());
        } else if (searchCond.getContent() != null) {
            return contentContains(searchCond.getContent());
        } else if (searchCond.getAuthor() != null) {
            return authorContains(searchCond.getAuthor());
        }

        return null;
    }

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

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

    private BooleanExpression authorContains(String author) {
        return author != null ? post.user.username.contains(author) : null;
    }
}

class QuerydslConfig - 추가

global 패키지의 config 아래 생성된 클래스로 JpaQueryFactory를 빈으로 등록하여 사용하기 위한 코드가 존재합니다.

@Configuration
public class QuerydslConfig {

    @Bean
    JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}

class PostFormController - 추가

게시글 화면 렌더링을 위한 Controller로 get 요청을 처리합니다. RequestMapping은 "/"로 게시글 목록 페이지가 index 페이지가 됩니다. 게시글은 10개 단위로 페이징 처리가 되며 페이지는 9개 단위로 처리됩니다.

@Controller
@RequestMapping("/")
@RequiredArgsConstructor
public class PostFormController {

    private final PostService postService;
    private final PostMapper postMapper;

    // 인덱스 페이지 (일반 게시글 목록)
    @GetMapping
    public String index(Model model, @PageableDefault(size = 10) Pageable pageable) {
        Page<Post> posts = postService.viewNormalPosts(pageable);

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

        // 현재 페이지가 속한 페이지 그룹의 시작과 끝
        int pageGroup = (currentPage - 1) / 9;
        int startPage = pageGroup * 9 + 1;
        int endPage = Math.min(startPage + 8, totalPages);

        model.addAttribute("posts", postMapper.toDTOList(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());
        model.addAttribute("searchCond", new PostSearchCond());

        return "posts/list";
    }

게시글의 경우 일반, 공지, 인기 게시글이 존재해 tab을 통한 프래그먼트 형식으로 구현됩니다. tab 전환 시 해당 타입의 게시글 목록 프래그먼트를 반환하는 메서드입니다. 

    // 탭 전환 시 게시글 목록 프래그먼트 반환
    @GetMapping("/posts/tab")
    public String getPostsByType(@RequestParam PostType type,
                                 @PageableDefault(size = 10) Pageable pageable, Model model) {
        Page<Post> posts = switch (type) {
            case NORMAL -> postService.viewNormalPosts(pageable);
            case NOTICE -> postService.viewNoticePosts(pageable);
            case POPULAR -> postService.viewPopularPosts(pageable);
        };

        int currentPage = posts.getNumber() + 1;
        int totalPages = posts.getTotalPages();
        int pageGroup = (currentPage - 1) / 9;
        int startPage = pageGroup * 9 + 1;
        int endPage = Math.min(startPage + 8, totalPages);

        model.addAttribute("posts", postMapper.toDTOList(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());
        model.addAttribute("searchCond", new PostSearchCond());

        return "posts/list :: #postsFragment";
    }

페이지 전환 시 사용되는 메서드로 파라미터로 넘어온 페이지를 통해 출력할 페이지를 계산하게 됩니다.

    // 페이지 전환 시 게시글 목록 프래그먼트 반환
    @GetMapping("/posts/page")
    public String getPostsByPage(@RequestParam PostType type,
                                 @RequestParam int pageGroup,
                                 @PageableDefault(size = 10) Pageable pageable, Model model) {
        Page<Post> posts = switch (type) {
            case NORMAL -> postService.viewNormalPosts(pageable);
            case NOTICE -> postService.viewNoticePosts(pageable);
            case POPULAR -> postService.viewPopularPosts(pageable);
        };

        int currentPage = posts.getNumber() + 1;
        int totalPages = posts.getTotalPages();
        int startPage = pageGroup * 9 + 1;
        int endPage = Math.min(startPage + 8, totalPages);

        model.addAttribute("posts", postMapper.toDTOList(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());
        model.addAttribute("searchCond", new PostSearchCond());

        return "posts/list :: #postsFragment";
    }

다음은 게시글 검색 시 검색된 게시글을 반환하는 프래그먼트입니다. 검색 조건을 통해 검색할 내용을 searchCond에 넣은 뒤 검색을 수행하게 됩니다.

    // 게시글 검색 시 검색된 게시글 프래그먼트로 반환
    @GetMapping("/posts/search")
    public String searchPosts(@RequestParam String searchType,
                              @RequestParam String keyword,
                              @PageableDefault(size = 10) Pageable pageable,
                              Model model) {
        PostSearchCond searchCond = createSearchCond(searchType, keyword);
        Page<Post> posts = postService.searchPosts(searchCond, pageable);

        int currentPage = posts.getNumber() + 1;
        int totalPages = posts.getTotalPages();
        int pageGroup = (currentPage - 1) / 9;
        int startPage = pageGroup * 9 + 1;
        int endPage = Math.min(startPage + 8, totalPages);

        model.addAttribute("posts", postMapper.toDTOList(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());

        // 검색 조건 유지를 위한 정보 추가
        model.addAttribute("searchType", searchType);
        model.addAttribute("keyword", keyword);
        model.addAttribute("searchCond", searchCond);

        return "posts/list :: #postsFragment";
    }
    
    private PostSearchCond createSearchCond(String searchType, String keyword) {
        PostSearchCond searchCond = new PostSearchCond();
        switch (searchType) {
            case "TITLE":
                searchCond.setTitle(keyword);
                break;
            case "CONTENT":
                searchCond.setContent(keyword);
                break;
            case "TITLE_CONTENT":
                searchCond.setTitle(keyword);
                searchCond.setContent(keyword);
                break;
            case "AUTHOR":
                searchCond.setAuthor(keyword);
                break;
        }
        return searchCond;
    }

검색된 게시글의 페이지 전환 메서드입니다.

    // 검색 페이지 전환
    @GetMapping("/posts/search/page")
    public String getSearchPostsByPage(@RequestParam String searchType,
                                       @RequestParam String keyword,
                                       @RequestParam int pageGroup,
                                       @PageableDefault(size = 10) Pageable pageable,
                                       Model model) {
        // 검색 조건 생성
        PostSearchCond searchCond = createSearchCond(searchType, keyword);
        Page<Post> posts = postService.searchPosts(searchCond, pageable);

        // 페이지네이션 정보 계산
        int currentPage = posts.getNumber() + 1;
        int totalPages = posts.getTotalPages();
        int startPage = pageGroup * 9 + 1;
        int endPage = Math.min(startPage + 8, totalPages);

        // 모델에 데이터 추가
        model.addAttribute("posts", postMapper.toDTOList(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());

        // 검색 조건 유지를 위한 데이터
        model.addAttribute("searchType", searchType);
        model.addAttribute("keyword", keyword);
        model.addAttribute("searchCond", searchCond);

        return "posts/list :: #postsFragment";
    }

게시글 작성을 위한 작성 화면을 렌더링 하는 메서드입니다. 기존 회원이 작성한 게시글이라면 비회원용 displayName 필드가 비어있던 구조에서 회원이 작성한 경우 displayName 필드에 회원의 username이 들어간 상태로 저장되어 응답 반환 시 게시글 작성자 값으로 displayName 필드를 반환하여 구조를 단순화하였습니다. 해당 구조를 위해 회원이 게시글 작성 페이지 요청을 한 경우 미리 displayName 필드에 회원의 username을 담은 dto를 model에 담아 넘기게 됩니다. username 보관을 위해 CustomUserDetails의 필드에 username을 보관하는 부분이 추가되었습니다. 현재 사용되는 username은 UserDetails에서 사용되는 username과 전혀 다른 필드로 사용자의 별명 즉 게시글 작성 시 표시될 이름을 나타내는 필드입니다. 

    @GetMapping("/post/write")
    public String createPost(@AuthenticationPrincipal CustomUserDetails userDetails, Model model) {
        PostDTO.Request request = new PostDTO.Request();
        if (userDetails != null) {
            // 로그인 상태면 작성자명 미리 설정
            request.setDisplayName(userDetails.getDisplayName());
        }
        model.addAttribute("request", request);
        return "post/write";
    }

게시글 수정 화면을 렌더링 하는 메서드입니다. 회원이 수정 버튼을 누른 경우 해당 게시글을 작성한 회원이 맞는지 체크한 뒤 인증에 성공하게 되면 게시글 수정 화면이 열리게 됩니다. 비회원의 경우 게시글 작성 시 입력했던 비밀번호가 일치하면 수정 화면이 열리게 되고 비밀번호 필드의 경우 비회원이 작성한 게시글에만 표시될 예정입니다.

    @GetMapping("/post/{id}/edit")
    public String updatePost(@AuthenticationPrincipal CustomUserDetails userDetails,
                             @PathVariable("id") Long postId,
                             @RequestParam(required = false) String password,
                             Model model) {

        Post post = postService.viewPost(postId);

        // 회원 게시글인 경우
        if (post.getUser() != null) {
            if (checkPermission(userDetails, post)) {
                throw new UnauthorizedAccessException("게시글에 대한 수정 권한이 없습니다.");
            }
        }

        // 비회원 게시글인 경우
        else {
            if (checkPassword(postId, password)) {
                throw new UnauthorizedAccessException("비밀번호가 일치하지 않습니다.");
            }
        }

        model.addAttribute("request", postMapper.toRequestDTO(post));
        return "post/edit";

    }

    private boolean checkPassword(Long postId, String password) {
        return password == null || !postService.checkNonUserPost(postId, password);
    }

    private boolean checkPermission(CustomUserDetails userDetails, Post post) {
        return userDetails == null || !post.getUser().getId().equals(userDetails.getId());
    }

게시글 조회에 대한 메서드입니다. 게시글 조회를 위한 게시글의 정보와 댓글 정보입니다. 댓글은 프래그먼트로 사용될 예정이고 15개 단위로 댓글이 페이징 처리 되었습니다. 댓글 작성을 위한 객체를 함께 보내게 되고 댓글 수정과 댓글 페이지 처리에 대한 부분은 CommentController에서 별도의 메서드를 통해 처리될 예정입니다. 

    // 게시글 조회
    @GetMapping("/post/{id}")
    public String viewPost(@AuthenticationPrincipal CustomUserDetails userDetails,
                           @PathVariable("id") Long postId,
                           @PageableDefault(size = 15) Pageable pageable, Model model) {
        // 게시글 조회
        Post post = postService.viewPost(postId);

        // 댓글 작성용 객체
        CommentDTO.Request commentRequest = new CommentDTO.Request();
        if (userDetails != null) {
            // 로그인 상태면 작성자명 미리 설정
            commentRequest.setDisplayName(userDetails.getDisplayName());
        }

        // 해당 게시글의 댓글 조회
        Page<Comment> comments = commentService.viewComts(postId, pageable);
        // 페이지네이션 계산
        int currentPage = comments.getNumber() + 1;
        int totalPages = comments.getTotalPages();
        int pageGroup = (currentPage - 1) / 9;
        int startPage = pageGroup * 9 + 1;
        int endPage = Math.min(startPage + 8, totalPages);

        // 게시글 정보
        model.addAttribute("post", postMapper.toResponseDTO(post));

        // 댓글 관련 데이터
        model.addAttribute("comments", commentMapper.toResponseListDTO(comments.getContent()));
        model.addAttribute("commentRequest", commentRequest);  // 작성용

        // 페이지네이션 정보
        model.addAttribute("currentPage", currentPage);
        model.addAttribute("startPage", startPage);
        model.addAttribute("endPage", endPage);
        model.addAttribute("totalPages", totalPages);
        model.addAttribute("hasNext", comments.hasNext());
        model.addAttribute("hasPrev", comments.hasPrevious());

        return "post/view";
    }

class PostController - 추가

게시글에 대한 요청은 get 요청이 대부분으로 작성, 수정과 같은 post 요청은 별로 존재하지 않았습니다. 게시글 작성 요청으로 회원이 작성한 게시글인 경우 회원과 게시글을 외래키로 연결해 주고 비회원 게시글인 경우 외래키가 null으로 저장됩니다. 게시글이 저장된 이후 uploadPhoto를 통해 게시글에 추가되었던 이미지를 영구 저장과 동시에 게시글 본문 내용의 경로를 변경해 주고 성공 시 게시글 목록 페이지로 리다이렉트 하게 됩니다. 

@Controller
@RequestMapping("/manage/post")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;
    private final PhotoService photoService;

    private final PostMapper postMapper;

    @PostMapping("/write")
    public String createPost(@AuthenticationPrincipal CustomUserDetails userDetails,
                             @Valid @ModelAttribute PostDTO.Request request,
                             HttpSession session) {

        Long userId = userDetails != null ? userDetails.getId() : null;
        Post createdPost = postService.createPost(postMapper.toEntity(request), userId);

        photoService.uploadPhoto(createdPost.getId(), session.getId());
        return "redirect:/";
    }

게시글 수정 요청입니다. 수정 가능한 내용을 수정한 뒤 새로 추가된 updatePhoto 메서드를 통해 처리해 줍니다. 이미 수정 페이지를 열기 위해 권한 체크를 수행하였기 때문에 추가 인증 없이 로직이 수행되고 있습니다. 

    @PostMapping("/{id}/edit")
    public String updatePost(@Valid @ModelAttribute PostDTO.Request request,
                             @PathVariable("id") Long postId, HttpSession session) {

        postService.updatePost(postId, postMapper.toUpdateDTO(request));
        photoService.updatePhoto(postId, session.getId());

        return "redirect:/post/" + postId;
    }

게시글 삭제에 대한 부분입니다. 게시글 삭제에 대한 권한을 체크한 뒤 권한이 존재한다면 삭제됩니다. 게시글 삭제 시 연결된 댓글과 이미지에 대한 데이터가 연동되어 삭제되지만 실제 저장된 이미지 파일은 삭제가 되지 않기 때문에 PhotoService의 deletePhoto를 통해 저장된 파일을 삭제한 뒤 게시글 삭제를 수행하게 됩니다. 

    @PostMapping("/{id}/delete")
    public String deletePost(@AuthenticationPrincipal CustomUserDetails userDetails,
                             @PathVariable("id") Long postId,
                             @RequestParam(required = false) String password){
        Post post = postService.viewPost(postId);

        // 회원 게시글인 경우
        if (post.getUser() != null) {
            if (checkPermission(userDetails, post)) {
                throw new UnauthorizedAccessException("게시글에 대한 삭제 권한이 없습니다.");
            }
        }

        // 비회원 게시글인 경우
        else {
            if (checkPassword(postId, password)) {
                throw new UnauthorizedAccessException("비밀번호가 일치하지 않습니다.");
            }
        }

        photoService.deletePhoto(postId);
        postService.deletePost(postId);
        return "redirect:/";
    }

    private boolean checkPassword(Long postId, String password) {
        return password == null || !postService.checkNonUserPost(postId, password);
    }

    private boolean checkPermission(CustomUserDetails userDetails, Post post) {
        return userDetails == null || !post.getUser().getId().equals(userDetails.getId());
    }

class UnauthorizedAccessException - 추가

게시글 수정 권한 확인 실패 시 발생하는 예외입니다. Controller에서 발생하는 예외로 발생 시 401 Unauthorized 상태 코드와 함께 "Unauthorized access" 메시지가 포함됩니다. 

@ResponseStatus(HttpStatus.UNAUTHORIZED)
public class UnauthorizedAccessException extends BaseException {
    public UnauthorizedAccessException(String message) {
        super(message, "G001");
    }
}

 

class GlobalExceptionHandler - 추가

Controller 전역의 예외 처리를 위한 핸들러 클래스입니다. 모든 컨트롤러에서 UnauthorizedAccessException이 발생할 경우 에러 상태를 넘기고 error 페이지로 이동하게 됩니다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UnauthorizedAccessException.class)
    public String handleUnauthorizedAccess(UnauthorizedAccessException e, Model model) {
        model.addAttribute("errorMessage", e.getMessage());
        return "error/unauthorized";
    }
}

Photo

Photo 클래스의 DTO가 삭제되었습니다. Photo의 경우 MultipartFile을 통해 넘겨받고 응답의 경우 본문 내용에 경로가 삽입되어 전달되기 때문에 DTO 사용이 필요하지 않아 제거되었습니다. 그리고 PhotoService에 게시글 수정 시 이미지 수정에 대한 메서드가 추가되었습니다.


변경 내용

  • PhotoDTO 삭제
  • PhotoService 메서드 추가 -> updatePhoto 추가
  • PhotoService 메서드 수정 -> deletePhoto 수정

class PhotoService

기존 이미지 파일 업로드에 대한 방식을 본문 내 경로 삽입 형태로 변경하며 게시글 수정 시 이미지 수정에 대한 메서드가 필요하게 되었습니다. 수정 메서드 없이도 로직 자체는 정상 수행이 가능하나 본문 내에서 삭제된 이미지 값이 데이터베이스와 pc 저장소에 남아 저장 공간을 차지하게 되므로 수정 시 사라진 이미지에 대한 처리가 필요해졌습니다. 해당 메서드는 기존 게시글에 존재하던 이미지를 가져와 본문 내용에 존재하지 않게 된다면 데이터베이스와 파일 시스템에서 이미지를 삭제하는 로직이 추가된 upload 메서드라고 생각하면 됩니다.

    @Override
    @Transactional
    public List<Photo> updatePhoto(Long postId, String sessionId) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new PostNotFoundException(postId));

        List<Photo> savedPhotos = new ArrayList<>();
        Map<String, String> pathMap = new HashMap<>();
        String content = post.getContent();

        // 1. 기존 사진들 중 본문에서 제거된 사진 삭제
        List<Photo> existingPhotos = repository.findByPostId(postId);
        for (Photo photo : existingPhotos) {
            if (!content.contains(photo.getPhotoPath())) {
                // 파일 시스템에서 삭제
                File fileToDelete = new File(UPLOAD_PATH + photo.getSavedPhotoName());
                if (fileToDelete.exists()) {
                    fileToDelete.delete();
                }
                // DB에서 삭제
                repository.delete(photo);
            }
        }

        // 2. 새로 추가된 임시 파일 처리
        Set<String> tempFileNames = tempFileMap.get(sessionId);
        if (tempFileNames != null) {
            for (String tempFileName : tempFileNames) {
                if (content.contains(tempFileName)) {
                    String newFileName = String.format("%d_%s%s",
                            postId, UUID.randomUUID(), getExtension(tempFileName));

                    try {
                        // 임시 파일을 정식 파일로 이동
                        File tempFile = new File(UPLOAD_PATH + tempFileName);
                        File newFile = new File(UPLOAD_PATH + newFileName);
                        if (tempFile.exists()) {
                            tempFile.renameTo(newFile);
                        }

                        // Photo 엔티티 생성 및 저장
                        Photo photo = Photo.builder()
                                .post(post)
                                .originalPhotoName(tempFileName)
                                .savedPhotoName(newFileName)
                                .photoPath("/images/" + newFileName)
                                .build();

                        savedPhotos.add(repository.save(photo));
                        pathMap.put("/images/" + tempFileName, "/images/" + newFileName);

                    } catch (Exception e) {
                        throw new FileUploadException("Failed to process file: " + tempFileName);
                    }
                }
            }
        }

        // 3. 본문의 이미지 경로 업데이트
        for (Map.Entry<String, String> entry : pathMap.entrySet()) {
            content = content.replace(entry.getKey(), entry.getValue());
        }
        post.updateContent(content);

        // 4. 처리된 임시 파일 정보 삭제
        tempFileMap.remove(sessionId);

        return savedPhotos;
    }

해당 메서드가 추가되며 테스트를 위한 코드를 추가했습니다. 성공 케이스의 테스트를 구성했고 3개의 이미지를 통해 시나리오를 구성했습니다.

  1. 초기 업로드 이미지 파일 1, 2, SessionId1
  2. 1 번째 수정 이미지 파일 3 추가, SessionId2
  3. 2 번째 수정 이미지 파일 1, 3 삭제, SessionId3
@Test
    void updatePhoto_Success() throws IOException {
        // given
        // 초기 파일 설정
        when(multipartFile.getOriginalFilename()).thenReturn(ORIGINAL_FILE_NAME);
        when(multipartFile2.getOriginalFilename()).thenReturn(ORIGINAL_FILE_NAME2);
        when(multipartFile3.getOriginalFilename()).thenReturn(ORIGINAL_FILE_NAME3);

        // 파일 전송 모킹
        doAnswer(invocation -> {
            File file = invocation.getArgument(0);
            new FileOutputStream(file).close();
            return null;
        }).when(multipartFile).transferTo(any(File.class));

        doAnswer(invocation -> {
            File file = invocation.getArgument(0);
            new FileOutputStream(file).close();
            return null;
        }).when(multipartFile2).transferTo(any(File.class));

        doAnswer(invocation -> {
            File file = invocation.getArgument(0);
            new FileOutputStream(file).close();
            return null;
        }).when(multipartFile3).transferTo(any(File.class));

        // 초기 게시글 생성
        String tempFilePath1 = service.tempUploadPhoto(multipartFile, SESSION_ID);
        String tempFilePath2 = service.tempUploadPhoto(multipartFile2, SESSION_ID);

        Long postId = 1L;
        String content = "게시글 본문 내용<img src='" + tempFilePath1 + "'>" +
                "중간 내용<img src='" + tempFilePath2 + "'>끝";

        Post post = Post.builder()
                .id(postId)
                .content(content)
                .build();

        when(postRepository.findById(postId)).thenReturn(Optional.of(post));
        when(photoRepository.save(any(Photo.class))).thenAnswer(i -> i.getArgument(0));

        // 초기 업로드
        List<Photo> initialPhotos = service.uploadPhoto(postId, SESSION_ID);
        String firstResult = post.getContent();
        when(photoRepository.findByPostId(postId)).thenReturn(initialPhotos);

        // 새로운 이미지 추가 수정
        String tempFilePath3 = service.tempUploadPhoto(multipartFile3, SESSION_ID2);
        String updatedContent = post.getContent() + "<img src='" + tempFilePath3 + "'>";
        post.updateContent(updatedContent);

        // when - 첫 번째 수정
        List<Photo> updatedPhotos = service.updatePhoto(postId, SESSION_ID2);
        String secondResult = post.getContent();

        // then - 첫 번째 수정 결과 확인
        assertThat(updatedPhotos).hasSize(1);
        assertThat(initialPhotos).hasSize(2);
        assertThat(post.getContent()).contains(updatedPhotos.get(0).getSavedPhotoName());
        assertThat(post.getContent()).contains(initialPhotos.get(0).getSavedPhotoName());
        assertThat(post.getContent()).contains(initialPhotos.get(1).getSavedPhotoName());

        // when - 두 번째 수정 (이미지 2개 삭제)
        String contentWithRemovedImages = post.getContent()
                .replace("<img src='/images/" + updatedPhotos.get(0).getSavedPhotoName() + "'>", "")
                .replace("<img src='/images/" + initialPhotos.get(0).getSavedPhotoName() + "'>", "");
        post.updateContent(contentWithRemovedImages);

        List<Photo> updatedList = new ArrayList<>(initialPhotos);
        updatedList.add(updatedPhotos.get(0));
        when(photoRepository.findByPostId(postId)).thenReturn(updatedList);
        List<Photo> finalPhotos = service.updatePhoto(postId, SESSION_ID3);
        String lastResult = post.getContent();

        // then - 최종 결과 확인
        assertThat(finalPhotos).hasSize(0);
        assertThat(post.getContent()).doesNotContain(updatedPhotos.get(0).getSavedPhotoName());
        assertThat(post.getContent()).doesNotContain(initialPhotos.get(0).getSavedPhotoName());
        assertThat(post.getContent()).contains(initialPhotos.get(1).getSavedPhotoName());

        // tempFileMap 확인
        Map<String, Set<String>> tempFileMap = (Map<String, Set<String>>)
                ReflectionTestUtils.getField(service, "tempFileMap");
        assertThat(tempFileMap).doesNotContainKey(SESSION_ID);
        assertThat(tempFileMap).doesNotContainKey(SESSION_ID2);
        assertThat(tempFileMap).doesNotContainKey(SESSION_ID3);

        // 직접 확인
        log.info("Initial content = {}", firstResult);
        log.info("Updated content = {}", secondResult);
        log.info("Final content = {}", lastResult);
    }

deletePhoto 메서드가 수정되었습니다. 기존의 단순히 데이터베이스에서 해당 칼럼 삭제를 수행하던 메서드에서 실제 저장소에 존재하는 이미지 파일을 제거하는 로직으로 변경되었습니다. 삭제할 post에 존재하는 이미지 파일들을 제거하는 로직을 수행하게 됩니다. 데이터베이스 접근이 사라졌기 때문에 트랜잭션 어노테이션이 삭제되었습니다. 

    @Override
    public void deletePhoto(Long postId) {
        List<Photo> photos = repository.findByPostId(postId);
        for (Photo photo : photos) {
            File file = new File(UPLOAD_PATH + photo.getSavedPhotoName());
            if (file.exists()) {
                file.delete();
            }
        }
    }

 

 

728x90