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) + "...";
}
}
}
}