본문 바로가기
For me

빵그리 리팩토링

by 순원이 2024. 10. 3.

https://devroach.tistory.com/57

 

1. 한 함수 내에 추상화 수준 같게 해야 한다. 

1-1 

 

Tell, Don't Ask" 원칙

[Review 관련]

package com.bbangle.bbangle.review.service;

import com.bbangle.bbangle.board.domain.Board;
import com.bbangle.bbangle.board.repository.BoardRepository;
import com.bbangle.bbangle.boardstatistic.service.BoardStatisticService;
import com.bbangle.bbangle.exception.BbangleErrorCode;
import com.bbangle.bbangle.exception.BbangleException;
import com.bbangle.bbangle.image.domain.Image;
import com.bbangle.bbangle.image.dto.ImageDto;
import com.bbangle.bbangle.image.repository.ImageRepository;
import com.bbangle.bbangle.image.service.ImageService;
import com.bbangle.bbangle.member.domain.Member;
import com.bbangle.bbangle.member.repository.MemberRepository;
import com.bbangle.bbangle.page.ImageCustomPage;
import com.bbangle.bbangle.page.ReviewCustomPage;
import com.bbangle.bbangle.review.domain.*;
import com.bbangle.bbangle.review.dto.*;
import com.bbangle.bbangle.review.repository.ReviewLikeRepository;
import com.bbangle.bbangle.review.repository.ReviewRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;

import java.math.BigDecimal;
import java.util.*;

import static com.bbangle.bbangle.exception.BbangleErrorCode.IMAGE_NOT_FOUND;
import static com.bbangle.bbangle.exception.BbangleErrorCode.REVIEW_MEMBER_NOT_PROPER;
import static com.bbangle.bbangle.exception.BbangleErrorCode.REVIEW_NOT_FOUND;
import static com.bbangle.bbangle.image.domain.ImageCategory.REVIEW;
import static java.util.Locale.ROOT;

@Service
@RequiredArgsConstructor
public class ReviewService {

    private static final Boolean WRITE = true;
    private static final Boolean DELETE = false;
    private static final Long PAGE_SIZE = 10L;
    private static final Long NON_MEMBER = 0L;

    @Value("${cdn.domain}")
    private String cdnDomain;
    private final BoardStatisticService boardStatisticService;
    private final ReviewRepository reviewRepository;
    private final BoardRepository boardRepository;
    private final MemberRepository memberRepository;
    private final ReviewLikeRepository reviewLikeRepository;
    private final ReviewManager reviewManager;
    private final ReviewStatistics reviewStatistics;
    private final ImageService imageService;
    private final ImageRepository imageRepository;

    /**
     *
     */
    @Transactional
    public void makeReview(ReviewRequest reviewRequest, Long memberId) {
        memberRepository.findMemberById(memberId);
        Review review = Review.of(reviewRequest, memberId);
        List<Badge> badges = reviewRequest.badges();
        badges.forEach(review::insertBadge);
        Review savedReview = reviewRepository.save(review);

        Long reviewId = savedReview.getId();

        boardStatisticService.updateReview(reviewRequest.boardId());

        List<String> urls = reviewRequest.urls();
        if(Objects.isNull(urls)){
            return;
        }
        moveImages(urls, reviewId);
    }

    @Transactional
    public ReviewImageUploadResponse uploadReviewImage(ReviewImageUploadRequest reviewImageUploadRequest, Long memberId) {
        memberRepository.findMemberById(memberId);
        List<String> urls = imageService.saveAll(reviewImageUploadRequest.category(), reviewImageUploadRequest.images());
        return new ReviewImageUploadResponse(urls);
    }

    @Transactional(readOnly = true)
    public ReviewRateResponse getReviewRate(Long boardId) {
        List<ReviewDto> reviews = reviewRepository.findByBoardId(boardId);
        return ReviewRateResponse.from(reviews);
    }

    /**
     *  책임:
     *          1. 리뷰 찾음
     *          2. 리스트가 0이면 빈페이지 반환   -> 공통 CustomPage의 역할
     *          3. 커서관리 및 DTO -1           -> 공통 CustomPage의 역할
     *          4. 리뷰 - 리뷰이미지 조회 및 매핑  -> 이미지 서비스에게 책임 넘기기 or 쿼리로 한 번에 가져오기
     *          5. 리뷰 - 리뷰태크매핑  -> 데이터 베이스에 저장할 때 ENUM이 아니라 ENUM의 속성(action)으로 저장했으면 어떘을까요?
     *                                           or 현행을 유지한더라도 ReviewManeger가 아니라 ReviewTag가 해야 할 것 같습니다
     *          6. 리뷰 - 리뷰좋아요 조회 및 매핑  -> 쿼리로 한 번에 가져오기 
     *          7. 리뷰,리뷰이미지,리뷰태크,리뷰좋아요를 합쳐서 DTO로 변환 
     *
     */
    @Transactional(readOnly = true)
    public ReviewCustomPage<List<ReviewInfoResponse>> getReviews(Long boardId,
                                                                 Long cursorId,
                                                                 Long memberId) {
        //1
        memberId = memberId != null ? memberId : NON_MEMBER;
        Board board = boardRepository.findById(boardId)
                .orElseThrow(() -> new BbangleException(BbangleErrorCode.BOARD_NOT_FOUND));
        List<ReviewSingleDto> reviewSingleList = reviewRepository.getReviewSingleList(board.getId(), cursorId);

        //2
        if(ObjectUtils.isEmpty(reviewSingleList)){
            return new ReviewCustomPage<>(Collections.emptyList(), 0L, false);
        }

        //3
        int reviewSingListSize = reviewSingleList.size();
        Long nextCursor = reviewSingleList.get(reviewSingListSize -1).id();
        Long lastCursor = reviewSingleList.stream()
                .findFirst()
                .get()
                .id();
        ReviewCursor reviewCursor = ReviewCursor.builder()
                .nextCursor(nextCursor)
                .lastCursor(lastCursor)
                .build();
        boolean hasNext = checkHasNext(reviewSingListSize);
        if (hasNext) {
            reviewSingleList.remove(reviewSingleList.get(reviewSingListSize - 1));
        }

        //4
        Map<Long, List<ImageDto>> imageMap = reviewRepository.getImageMap(reviewCursor);
        //5
        Map<Long, List<String>> tagMap = new HashMap<>();
        for(ReviewSingleDto reviewSingleDto : reviewSingleList){
            reviewManager.getTagMap(reviewSingleDto, tagMap);    //TODO return값 활용 안 하고 있습니다
        }
        //6
        Map<Long, List<Long>> likeMap = makeLikeMap(reviewCursor);

        //7
        List<ReviewInfoResponse> reviewInfoResponseList =
                ReviewInfoResponse.createList(reviewSingleList, imageMap, tagMap, likeMap, memberId);
        return ReviewCustomPage.from(reviewInfoResponseList, nextCursor, hasNext);
    }

    @Transactional
    public void insertLike(Long reviewId, Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new BbangleException(BbangleErrorCode.NOTFOUND_MEMBER));

        reviewLikeRepository.save(ReviewLike.builder()
                .memberId(member.getId())
                .reviewId(reviewId)
                .build());
    }

    @Transactional
    public void removeLike(Long reviewId, Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new BbangleException(BbangleErrorCode.NOTFOUND_MEMBER));
        reviewLikeRepository.findByMemberIdAndReviewId(member.getId(), reviewId)
                .ifPresent(reviewLikeRepository::delete);
    }

    @Transactional(readOnly = true)
    public ReviewInfoResponse getReviewDetail(Long reviewId, Long memberId) {
        memberId = memberId == null ? NON_MEMBER : memberId;
        ReviewSingleDto reviewDetail = reviewRepository.getReviewDetail(reviewId);
        ReviewCursor reviewCursor = ReviewCursor.builder()
                .reviewId(reviewId)
                .build();
        Map<Long, List<ImageDto>> imageMap = reviewRepository.getImageMap(reviewCursor);
        Map<Long, List<String>> tagMap = new HashMap<>();
        reviewManager.getTagMap(reviewDetail, tagMap);
        Map<Long, List<Long>> likeMap = makeLikeMap(reviewCursor);
        return ReviewInfoResponse.create(reviewDetail, imageMap, tagMap, likeMap, memberId);
    }

    @Transactional(readOnly = true)
    public ReviewImagesResponse getReviewImages(Long reviewId) {
        return new ReviewImagesResponse(imageService.findImagePathById(REVIEW, reviewId));
    }


    /**
     * getReviews함수와 마찬 가지로
     */
    @Transactional(readOnly = true)
    public ReviewCustomPage<List<ReviewInfoResponse>> getMyReviews(Long memberId, Long cursorId) {
        List<ReviewSingleDto> myReviewList = reviewRepository.getMyReviews(memberId, cursorId);
        if(ObjectUtils.isEmpty(myReviewList)){
            return new ReviewCustomPage<>(Collections.emptyList(), 0L, false);
        }
        int myReviewListSize = myReviewList.size();
        Long nextCursor = myReviewList.get(myReviewListSize -1).id();
        Long lastCursor = myReviewList.stream().findFirst().get().id();

        ReviewCursor reviewCursor = ReviewCursor.builder()
                .nextCursor(nextCursor)
                .lastCursor(lastCursor)
                .build();
        boolean hasNext = checkHasNext(myReviewListSize);
        if (hasNext) {
            myReviewList.remove(myReviewList.get(myReviewListSize - 1));
        }
        Map<Long, List<ImageDto>> imageMap = reviewRepository.getImageMap(reviewCursor);
        Map<Long, List<String>> tagMap = new HashMap<>();
        for(ReviewSingleDto reviewSingleDto : myReviewList){
            reviewManager.getTagMap(reviewSingleDto, tagMap);
        }
        Map<Long, List<Long>> likeMap = makeLikeMap(reviewCursor);
        List<ReviewInfoResponse> reviewInfoResponseList =
                ReviewInfoResponse.createList(myReviewList, imageMap, tagMap, likeMap, memberId);
        return ReviewCustomPage.from(reviewInfoResponseList, nextCursor, hasNext);
    }

    @Transactional(readOnly = true)
    public ImageCustomPage<List<ImageDto>> getAllImagesByBoardId(Long boardId, Long cursorId) {
        List<ImageDto> allImagesByBoardId = reviewRepository.getAllImagesByBoardId(boardId, cursorId);
        if(ObjectUtils.isEmpty(allImagesByBoardId)){
            return new ImageCustomPage<>(Collections.emptyList(), 0L, false);
        }
        int allImagesSize = allImagesByBoardId.size();
        boolean hasNext = checkHasNext(allImagesSize);
        Long nextCursor = allImagesByBoardId.get(allImagesSize -1).getId();
        if (hasNext) {
            allImagesByBoardId.remove(allImagesByBoardId.get(allImagesSize - 1));
        }
        return ImageCustomPage.from(allImagesByBoardId, nextCursor, hasNext);
    }

    @Transactional(readOnly = true)
    public ImageDto getImage(Long imageId) {
        Image image = imageRepository.findById(imageId)
                .orElseThrow(() -> new BbangleException(IMAGE_NOT_FOUND));
        return ImageDto.builder()
                .url(image.getPath())
                .build();
    }
    @Transactional
    public void updateReview(ReviewRequest reviewRequest, Long reviewId, Long memberId) {
        memberRepository.findMemberById(memberId);
        Review review = reviewRepository.findById(reviewId)
                .orElseThrow(() -> new BbangleException(REVIEW_NOT_FOUND));
        if(!review.getMemberId().equals(memberId)){
            throw new BbangleException(REVIEW_MEMBER_NOT_PROPER);
        }
        review.update(reviewRequest);
        List<String> urls = reviewRequest.urls();
        List<Image> images = imageRepository.findByDomainId(reviewId);
        List<String> removedUrls = new ArrayList<>();
        if(!ObjectUtils.isEmpty(images)){
            List<String> imagePaths = new ArrayList<>(images.stream()
                    .map(Image::getPath)
                    .toList());
            for(String url : urls) {
                if (imagePaths.contains(url)) {
                    Image image = imageRepository.findByPath(url);
                    image.update(reviewId, url);
                    removedUrls.add(url);
                    imagePaths.remove(url);
                }
            }
            List<String> changedPaths = removeCdnDomain(imagePaths);
            imageService.deleteImages(changedPaths);
            imageRepository.deleteAllByPathIn(imagePaths);
        }
        urls = urls.stream()
                .filter(url -> !removedUrls.contains(url))
                .toList();
        if(urls.isEmpty()){
            return;
        }
        moveImages(urls, reviewId);
    }

    private List<String> removeCdnDomain(List<String> paths) {
        return paths.stream()
                .map(path -> path.replace(cdnDomain, ""))
                .toList();
    }

    @Transactional
    public void deleteReview(Long reviewId, Long memberId) {
        memberRepository.findMemberById(memberId);
        Review review = reviewRepository.findById(reviewId)
                .orElseThrow(() -> new BbangleException(REVIEW_NOT_FOUND));
        if(!review.getMemberId().equals(memberId)){
            throw new BbangleException(REVIEW_MEMBER_NOT_PROPER);
        }
        List<Image> reviewImages = imageRepository.findByDomainId(reviewId);
        List<ReviewLike> reviewLikes = reviewLikeRepository.findByReviewId(reviewId);
        if (!ObjectUtils.isEmpty(reviewImages)) {
            imageRepository.deleteAllByDomainId(reviewId);
        }
        if(!ObjectUtils.isEmpty(reviewLikes)) {
            reviewLikeRepository.deleteAllByReviewId(reviewId);
        }
        review.delete();
        boardStatisticService.updateReview(review.getBoardId());
    }

    @Transactional
    public void deleteImage(Long imageId) {
        Image reviewImg = imageRepository.findById(imageId)
                .orElseThrow(() -> new BbangleException(IMAGE_NOT_FOUND));
        imageRepository.delete(reviewImg);
    }

    private boolean checkHasNext(int size) {
        return size >= PAGE_SIZE + 1;
    }

    @Transactional
    public SummarizedReviewResponse getSummarizedReview(Long boardId) {
        List<ReviewDto> reviews = reviewRepository.findByBoardId(boardId);

        if (reviews.isEmpty()) {
            return SummarizedReviewResponse.getEmpty();
        }

        BigDecimal averageRatingScore = reviewStatistics.getAverageRatingScore(reviews);
        int reviewCount = reviewStatistics.count(reviews);
        List<String> popularBadgeList = reviewStatistics.getPopularBadgeList(reviews);

        return SummarizedReviewResponse.of(averageRatingScore, reviewCount, popularBadgeList);
    }




    /**
     * 여기서 부터 아래까지 이미지, 경로에 관한 함수입니다.
     * Review의 책임과 역할은 아니라 생각하여 Image와  Image.path를 VO로 만들어 주어 아래 역할을 부여해주면 될 것 같습니다
     */
    private String createNewStoragePath(String extractedFileName, Long reviewId){
        return REVIEW.name().toLowerCase(ROOT)+"/"+reviewId+extractedFileName;
    }

    private void moveImages(List<String> urls, Long reviewId){
        List<Image> images = imageRepository.findAllByPathIn(urls);
        List<String> fromPaths = makeTempStoragePath(images);
        List<String> toPaths = makeFinalStoragePath(reviewId, fromPaths);
        updateImagePath(reviewId, fromPaths, images, toPaths);
    }

    private void updateImagePath(Long reviewId, List<String> fromPaths, List<Image> images, List<String> toPaths) {
        List<String> deletedPath = new ArrayList<>();
        for(int i = 0; i < fromPaths.size(); i++){
            images.get(i).update(reviewId, cdnDomain + toPaths.get(i));
            imageService.move(fromPaths.get(i), toPaths.get(i));
            deletedPath.add(fromPaths.get(i));
        }
        imageService.deleteImages(deletedPath);
    }

    private List<String> makeFinalStoragePath(Long reviewId, List<String> fromPaths) {
        return fromPaths.stream()
                .map(path -> path.substring(path.lastIndexOf("/")))
                .map(extractedFileName -> createNewStoragePath(extractedFileName, reviewId))
                .toList();
    }

    private List<String> makeTempStoragePath(List<Image> images) {
        return images.stream()
                .map(Image::getPath)
                .map(path -> path.replace(cdnDomain, ""))
                .toList();
    }

    private Map<Long, List<Long>> makeLikeMap(ReviewCursor reviewCursor) {
        List<ReviewLike> likeList = reviewRepository.getLikeList(reviewCursor);
        return reviewManager.getLikeMap(likeList);
    }

}

 

 

[Board 관련]

package com.bbangle.bbangle.board.repository.util;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class BoardPageGenerator {

    private static final Long NO_NEXT_CURSOR = -1L;
    private static final Long HAS_NEXT_PAGE_SIZE = BOARD_PAGE_SIZE + 1L;

    /**
     *
     * 책임:
     *          1. list가 비었다면 빈 페이지를 반환함     -> 공통 CustomPage에서 커버가능
     *          2. Dao -> DTO 변환
     *          3. 커서 조절 및 DTO리스트 하나 삭제       -> 공통 CustomPage에서 커버가능
     *
     * 거슬리는 점:
     *              1. 플래그를 전달받음
     *              2. 함수가 20줄을 넘는다(아주 사소)
     *              3. 나머지는 쿼리로 한 번에 받지 않았기에 함수가 복잡하다(중요)
     */
    public static BoardCustomPage<List<BoardResponseDto>> getBoardPage(
        List<BoardResponseDao> boardDaos, Boolean isInFolder
    ) {
        if (boardDaos.isEmpty()) {
            return BoardCustomPage.emptyPage();
        }

        List<BoardResponseDto> boardResponseDtos = convertToBoardResponse(boardDaos, isInFolder);

        Long nextCursor = NO_NEXT_CURSOR;
        boolean hasNext = false;
        if (boardResponseDtos.size() == HAS_NEXT_PAGE_SIZE) {
            hasNext = true;
            nextCursor = boardResponseDtos.get(boardResponseDtos.size() - 1)
                .getBoardId();
        }
        boardResponseDtos = boardResponseDtos.stream()
            .limit(BOARD_PAGE_SIZE)
            .toList();

        return BoardCustomPage.from(boardResponseDtos, nextCursor, hasNext);
    }

    private static List<BoardResponseDto> convertToBoardResponse(
        List<BoardResponseDao> boardResponseDaoList,
        Boolean isInFolder
    ) {
        Map<Long, List<String>> tagMapByBoardId = getTagListFromBoardResponseDao(
            boardResponseDaoList);

        Map<Long, Boolean> isBundled = getIsBundled(boardResponseDaoList);
        Map<Long, Boolean> isSoldOut = getIsSoldOut(boardResponseDaoList);
        Map<Long, Boolean> isBbangcketing = getIsBbangcketing(boardResponseDaoList);

        boardResponseDaoList = removeDuplicatesByBoardId(boardResponseDaoList);

        return getBoardResponseDtos(boardResponseDaoList, isInFolder, isBundled, tagMapByBoardId,
            isSoldOut, isBbangcketing);
    }

    private static Map<Long, Boolean> getIsBbangcketing(List<BoardResponseDao> boardResponseDaoList) {
        return boardResponseDaoList.stream()
            .collect(Collectors.toMap(
                BoardResponseDao::boardId,
                board -> new ArrayList<>(Collections.singletonList(board.orderStartDate())),
                (existingList, newList) -> {
                    existingList.addAll(newList);
                    return existingList;
                }))
            .entrySet()
            .stream()
            .collect(Collectors.toMap(
                Entry::getKey,
                entry -> {
                    for (LocalDateTime endDate : entry.getValue()) {
                        if (endDate != null && endDate.isAfter(LocalDateTime.now())) {
                            return true;
                        }
                    }
                    return false;
                }
            ));
    }

    private static Map<Long, Boolean> getIsSoldOut(List<BoardResponseDao> boardResponseDaoList) {
        return boardResponseDaoList.stream()
            .collect(Collectors.toMap(
                BoardResponseDao::boardId,
                board -> new ArrayList<>(Collections.singletonList(board.isSoldOut())),
                (existingList, newList) -> {
                    existingList.addAll(newList);
                    return existingList;
                }))
            .entrySet()
            .stream()
            .collect(Collectors.toMap(
                Entry::getKey,
                entry -> {
                    for (Boolean isSoldOut : entry.getValue()) {
                        if (isSoldOut != null && !isSoldOut) {
                            return false;
                        }
                    }

                    return true;
                }
            ));
    }

    private static List<BoardResponseDto> getBoardResponseDtos(
        List<BoardResponseDao> boardResponseDaoList,
        Boolean isInFolder,
        Map<Long, Boolean> isBundled,
        Map<Long, List<String>> tagMapByBoardId,
        Map<Long, Boolean> isSoldOut, Map<Long, Boolean> isBbangcketing
    ) {
        if (Boolean.TRUE.equals(isInFolder)) {
            return boardResponseDaoList.stream()
                .map(boardDao -> BoardResponseDto.inFolder(
                    boardDao,
                    isBundled.get(boardDao.boardId()),
                    tagMapByBoardId.get(boardDao.boardId()),
                    isBbangcketing.get(boardDao.boardId()),
                    isSoldOut.get(boardDao.boardId()))
                )
                .toList();
        }

        return boardResponseDaoList.stream()
            .map(boardDao -> BoardResponseDto.from(
                boardDao,
                isBundled.get(boardDao.boardId()),
                tagMapByBoardId.get(boardDao.boardId()),
                isBbangcketing.get(boardDao.boardId()),
                isSoldOut.get(boardDao.boardId()))
            )
            .toList();
    }

    private static Map<Long, List<String>> getTagListFromBoardResponseDao(
        List<BoardResponseDao> boardResponseDaoList
    ) {
        return boardResponseDaoList.stream()
            .collect(Collectors.toMap(
                BoardResponseDao::boardId,
                board -> new ArrayList<>(Collections.singletonList(board.tagsDao())),
                (existingList, newList) -> {
                    existingList.addAll(newList);
                    return existingList;
                }))
            .entrySet()
            .stream()
            .collect(Collectors.toMap(
                Entry::getKey,
                entry -> extractTags(entry.getValue())
                    .stream()
                    .toList()
            ));
    }

    private static Map<Long, Boolean> getIsBundled(List<BoardResponseDao> boardResponseDaoList) {
        return boardResponseDaoList
            .stream()
            .collect(Collectors.toMap(
                BoardResponseDao::boardId,
                board -> new HashSet<>(Collections.singleton(board.category())),
                (existingSet, newSet) -> {
                    existingSet.addAll(newSet);
                    return existingSet;
                }
            ))
            .entrySet()
            .stream()
            .collect(Collectors.toMap(
                Entry::getKey,
                entry -> entry.getValue()
                    .size() > 1)
            );
    }

    private static List<BoardResponseDao> removeDuplicatesByBoardId(
        List<BoardResponseDao> boardResponseDaos
    ) {
        Map<Long, BoardResponseDao> uniqueBoardMap = boardResponseDaos.stream()
            .collect(Collectors.toMap(
                BoardResponseDao::boardId,
                boardResponseDao -> boardResponseDao,
                (existing, replacement) -> existing,
                LinkedHashMap::new
            ));

        return uniqueBoardMap.values()
            .stream()
            .toList();
    }

    private static List<String> extractTags(List<TagsDao> tagsDaoList) {
        if (Objects.isNull(tagsDaoList) || tagsDaoList.isEmpty()) {
            return Collections.emptyList();
        }

        HashSet<String> tags = new HashSet<>();
        for (TagsDao dto : tagsDaoList) {
            addTagIfTrue(tags, dto.glutenFreeTag(), TagEnum.GLUTEN_FREE.label());
            addTagIfTrue(tags, dto.highProteinTag(), TagEnum.HIGH_PROTEIN.label());
            addTagIfTrue(tags, dto.sugarFreeTag(), TagEnum.SUGAR_FREE.label());
            addTagIfTrue(tags, dto.veganTag(), TagEnum.VEGAN.label());
            addTagIfTrue(tags, dto.ketogenicTag(), TagEnum.KETOGENIC.label());
        }
        return new ArrayList<>(tags);
    }

    private static void addTagIfTrue(Set<String> tags, boolean condition, String tag) {
        if (condition) {
            tags.add(tag);
        }
    }

}

 

[슬랙 관련]

Before

package com.bbangle.bbangle.common.adaptor.slack;

import static org.springframework.http.MediaType.APPLICATION_JSON;

import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Slf4j
@Profile({"production"})
@Component
@RequiredArgsConstructor
public class RealSlackAdaptor implements SlackAdaptor {

    @Value("${slack.webhook-url}")
    private String WEB_HOOK_URL;
    private final RestTemplate restTemplate = new RestTemplate();

    public void sendAlert(HttpServletRequest httpServletRequest, Throwable t) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(APPLICATION_JSON);

        SlackMessage slackMessage = buildMessage(httpServletRequest, t);
        HttpEntity<SlackMessage> request = new HttpEntity<>(slackMessage, headers);

        try {
            restTemplate.postForEntity(WEB_HOOK_URL, request, String.class);
        } catch (Exception e) {
            log.error("슬랙 전송 실패!! ", e);
        }
    }

    private SlackMessage buildMessage(HttpServletRequest request, Throwable throwable) {
        String title = throwable.getMessage();
        String message = String.format(
            "- url: %s \n - 위치: %s \n - message: %s ",
            request.getRequestURI(),
            extractMethodPosition(throwable),
            throwable.getMessage()
        );

        return new SlackMessage(
            List.of(
                createMessageTitle(title),
                createMessageBody(message)
            )
        );
    }

    private String extractMethodPosition(Throwable t) {
        Optional<StackTraceElement> optional = Arrays.stream(t.getStackTrace())
            .filter(it -> it.getClassName().contains("bbangle"))
            .findFirst();

        StackTraceElement targetElement = optional.orElseGet(() -> t.getStackTrace()[0]);
        return String.format("%s, %s", targetElement.getClassName(), targetElement.getMethodName());
    }

    private SlackBlock createMessageTitle(String title) {
        return new SlackBlock(
            "header",
            new SlackText(title)
        );
    }

    private SlackBlock createMessageBody(String message) {
        return new SlackBlock(
            "section",
            new SlackText(message)
        );
    }

    public record SlackMessage(List<SlackBlock> blocks) {

    }

    public record SlackBlock(String type, SlackText text) {

    }

    @Data
    public static class SlackText {

        private final String type = "plain_text";
        private final String text;
    }


After

package com.bbangle.bbangle.common.adaptor.slack;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import static org.springframework.http.MediaType.APPLICATION_JSON;

@Slf4j
@Profile({"production"})
@Component
@RequiredArgsConstructor
public class RealSlackAdaptor implements SlackAdaptor {

    private final RestTemplate restTemplate = new RestTemplate();
    @Value("${slack.webhook-url}")
    private String WEB_HOOK_URL;

    public void sendAlert(HttpServletRequest httpServletRequest, Throwable t) {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(APPLICATION_JSON);

            SlackMessage slackMessage = SlackMessage.fromException(httpServletRequest, t);
            HttpEntity<SlackMessage> request = new HttpEntity<>(slackMessage, headers);

            restTemplate.postForEntity(WEB_HOOK_URL, request, String.class);
        } catch (Exception e) {
            log.error("슬랙 전송 실패!! ", e);
            // 추가적인 예외 처리가 필요하다면 여기에 작성
        }
    }

}
package com.bbangle.bbangle.common.adaptor.slack;

import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

@Data
@AllArgsConstructor
public class SlackMessage {

    private List<SlackBlock> blocks;

    public static SlackMessage fromException(HttpServletRequest request, Throwable t) {
        return new SlackMessage(List.of(
                SlackBlock.createBlock("header", "plain_text", SlackBlock.SlackText.createHeader(t.getMessage())),
                SlackBlock.createBlock("section", "plain_text", SlackBlock.SlackText.createSection(request, t))
        ));
    }


    @Data
    @AllArgsConstructor
    public static class SlackBlock {
        private String type; // ex) header, section
        private SlackText text;


        public static SlackBlock createBlock(String part, String type, String title) {
            return new SlackBlock(part, new SlackText(type, title));
        }


        @Data
        @AllArgsConstructor
        public static class SlackText {
            private String type; // ex) plain_text
            private String text;

            public static String createHeader(String text) {
                return truncateText(text, 150);
            }

            public static String createSection(HttpServletRequest request, Throwable t) {
                return String.format(
                        "- url: %s \n - 위치: %s \n - message: %s ",
                        request.getRequestURI(),
                        extractMethodPosition(t),
                        truncateText(t.getMessage(), 3000)
                );
            }

            /**
             * 에러 발생한 메소드 위치 반환
             */
            private static String extractMethodPosition(Throwable t) {
                Optional<StackTraceElement> optional = Arrays.stream(t.getStackTrace())
                        .filter(it -> it.getClassName().contains("bbangle"))
                        .findFirst();

                StackTraceElement targetElement = optional.orElseGet(() -> t.getStackTrace()[0]);
                return String.format("%s, %s", targetElement.getClassName(), targetElement.getMethodName());
            }

            /**
             * 문자열 자르기
             */
            private static String truncateText(String text, int maxLength) {
                if (text.length() <= maxLength) {
                    return text;
                }
                return text.substring(0, maxLength - 3) + "...";
            }
        }
    }
}

'For me' 카테고리의 다른 글

알고리즘 수업  (0) 2024.09.26