각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 3 회고 (190일차)

제이온 (Jayon) 2021. 8. 9.

안녕하세요? 제이온입니다.

 

그동안 구현한 내용이 많았는데 바쁘다는 핑계로 기록할 내용을 미뤄버렸습니다. 노션에 학습 부채 기록장이 미루라고 만든 건 아닌데, 이만큼이나 쌓이게 됐네요..

 

 

 

 

시간 잘 쪼개서 채워나가야겠습니다.

 

 

프로젝트 내의 댓글 조회하는 기능

우리 다라쓰 서비스를 이용하는 유저는 관리자 페이지를 이용할 수 있습니다. 특정 프로젝트 내의 댓글을 조회하며 관리할 수 있고, 댓글에 관한 통계 기능을 제공하자는 계획을 세웠습니다. 먼저, 저번 주 평일 동안은 프로젝트 내의 댓글을 조회하는 기능을 만들기 위해 노력했습니다.

 

더 디테일하게 말하자면, 특정 프로젝트 안에 있고 시작 날짜와 종료 날짜 사이의 댓글들을 조회할 수 있어야 하는 기능과 댓글 검색 기능을 제공해야 합니다. 우선 전자부터 이야기해 보겠습니다.

 

 

    Page<Comment> findByProjectSecretKeyAndCreatedDateBetween(String projectSecretKey, LocalDateTime startDate,
        LocalDateTime endDate, Pageable pageable);

 

 

꽤 심플합니다. 위는 CommentRepository 객체인데 메소드 이름으로 충분히 제가 원하는 기능을 구현할 수 있었습니다. 특히 특정 날짜 사이 댓글을 알기 위해서 "Between" 키워드를 사용했습니다.

 

여기서 사용된 요청 모델은 아래와 같습니다.

 

 

@Getter
@AllArgsConstructor
public class CommentReadRequestInProject {

    private String sortOption;

    private String projectKey;

    @DateTimeFormat(iso = ISO.DATE)
    private LocalDate startDate;

    @DateTimeFormat(iso = ISO.DATE)
    private LocalDate endDate;

    private Integer page;

    private Integer size;
}

 

 

해당 기능을 구현하기 위해 가장 많이 시간을 쓴 곳은 다름아닌 날짜 처리였습니다. 프론트에서 시작 날짜와 종료 날짜를 문자열로 보내는데, 문자열을 그대로 LocalDate로 매핑할 수가 없었습니다. 그래서 구글링을 하다 보니 @DateTimeFormat()을 알게 되었고, "yyyy-MM-dd" 형태로 문자열을 LocalDate 형태로 매핑해 줄 수 있었습니다. 다만, 6월 31일 같은 유효하지 않은 날짜를 입력하면 기다란 에러가 발생하는데 이것을 에러 핸들링을 하는 방법을 도무지 알 수가 없었습니다.

 

 

 

 

이게 다 에러 메시지입니다.. 하하 물론 중반부에 있는 "default message" 키워드로 보아 그 부분만 추출해 낼 수 있을 것 같긴합니다. 그래도 메시지 자체가 커스텀으로 바꿀 수가 없었습니다. messages.properties라던지 여러 갖가지 방안을 강구했는데 끝내 실패해서 일단 프론트 측에서 에러 처리를 잘 해달라고만 말해두었습니다.

 

 

후자는 검색 기능입니다. 정확히는 프로젝트 내의 존재하고 시작 날짜와 종료 날짜 사이에 있고, 특정 키워드를 내용으로 갖고 있는 댓글을 검색하는 것입니다. 예상했듯이 JPA 메소드에 조건 하나만 넣어주면 끝납니다.

 

 

    Page<Comment> findByProjectSecretKeyAndContentContainingAndCreatedDateBetween(String projectSecretKey,
        String keyword, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable);

 

 

'XXXContaining'를 사용하면 MySQL에서 like과 동일한 역할을 수행합니다. 요청 모델에서 동일하게 @DateTimeFormat을 사용하여 날짜 매핑을 해 주었습니다.

 

 

프로젝트 내의 댓글 통계 기능

금, 토, 일은 여기에 시간을 갈아 넣었습니다. 우리 서비스의 관리자 페이지는 댓글의 통계 기능도 제공합니다. 예를 들어 2021년 8월 1일부터 2021년 8월 9일 까지의 댓글 일별 통계를 알고 싶다고 하면, 각 일자 별로 달린 댓글의 개수가 나옵니다. 일별 통계 외에 시간별과 월별 통계를 제공합니다.

 

MySQL 기준으로는 date() 함수를 통해 날짜 데이터를 연월일로 만들 수 있으며, month() 함수를 통해 날짜 데이터를 연월, 그리고 hour() 함수를 통해 연월일 시간으로 만들 수 있습니다. 하지만, 이상하게도 JPA에서는 해당 날짜 함수를 인식하지 못했습니다. 구글링을 해 보았지만, 저와 비슷한 현상을 겪는 사람들이 많았고 결국 substring()으로 해결하기로 했습니다.

 

 

    @Query("select substring(c.createdDate, :beginIndex, :length) as date, count(c) as count from Comment c "
        + "where c.project.secretKey=:projectSecretKey and c.createdDate between :startDate and :endDate group by date")
    List<Object[]> findDateCount(@Param("projectSecretKey") String projectSecretKey,
        @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate,
        @Param("beginIndex") Integer beginIndex, @Param("length") Integer length);

 

 

group by를 사용하여 특정 프로젝트 내의 특정 기간 동안 생긴 댓글을 시간별, 일별, 월별 통계로 나타냈습니다. 주의할 점은 JPA에서 substring 함수는 beginIndex부터 endIndex까지 데이터를 자르는 것이 아니라 beginIndex부터 특정 length만큼 데이터를 자른다는 것입니다.

 

이 방법은 통계를 손쉽게 가져오지만, 한 가지 문제점이 있습니다. 바로 데이터가 존재하는 날짜만 표시한다는 것이죠. 예를 들어, 사용자가 2021년 4월부터 2021년 8월까지의 댓글 월별 통계를 알고 싶습니다. 그런데, 데이터가 8월에만 존재한다면 4월부터 7월까지는 아예 표시가 되지 않고, {"2021-08": 10}과 같이 데이터가 날아 옵니다. 이것은 그래프로 통계를 내야하는 입장으로서 적합하지 않습니다.

 

그렇다고 10년치 날짜 더미 데이터 테이블을 만들어서 위 테이블과 join하는 것은 쓸데없는 데이터를 DB에 저장해야해서 마음에 들지 않았습니다. 그래서 어느 정도 애플리케이션에서 로직을 짜기로 했습니다.

 

우선, 해당 메소드는 추상화되어 있습니다. beginIndex와 length 값에 따라 시간별인지 일별인지 월별인지 정해지기 때문이죠. 그래서 다음과 같이 Repository 단에서 전략 패턴을 사용했습니다.

 

 

public interface CommentCountStrategy {

    boolean isCountable(String period);

    List<Stat> calculateCount(String projectKey, LocalDateTime startDate, LocalDateTime endDate);
}

@Component
@RequiredArgsConstructor
public class CommentCountStrategyByDaily implements CommentCountStrategy {

    private static final String DATE = "DAILY";
    private static final Integer BEGIN_INDEX = 1;
    private static final Integer LENGTH = 10;

    private final CommentRepository commentRepository;

    @Override
    public boolean isCountable(String periodicity) {
        return DATE.equals(periodicity.toUpperCase(Locale.ROOT));
    }

    @Override
    public List<Stat> calculateCount(String projectKey, LocalDateTime startDate, LocalDateTime endDate) {
        List<Stat> stats = commentRepository.findDateCount(projectKey, startDate, endDate, BEGIN_INDEX, LENGTH).stream()
            .map(objects -> new Stat((String) objects[0], (Long) objects[1]))
            .collect(Collectors.toList());

        List<Stat> noneDailyStats = getStatByNoneDaily(startDate, endDate, stats);
        stats.addAll(noneDailyStats);
        stats.sort(Comparator.comparing(Stat::getDate));
        return stats;
    }

    private List<Stat> getStatByNoneDaily(LocalDateTime startDate, LocalDateTime endDate, List<Stat> stats) {
        List<Stat> noneMonthStats = new ArrayList<>();
        LocalDate start = LocalDate.from(startDate);
        LocalDate end = LocalDate.from(endDate);

        outer:
        for (LocalDate localDate = start; !localDate.equals(end); localDate = localDate.plusDays(1L)) {
            for (Stat stat : stats) {
                if (localDate.toString().equals(stat.getDate())) {
                    continue outer;
                }
            }
            noneMonthStats.add(new Stat(localDate.toString(), 0L));
        }
        return noneMonthStats;
    }
}

@Component
@RequiredArgsConstructor
public class CommentCountStrategyByHourly implements CommentCountStrategy {

    private static final String DATE = "HOURLY";
    private static final Integer BEGIN_INDEX = 12;
    private static final Integer LENGTH = 2;

    private final CommentRepository commentRepository;

    @Override
    public boolean isCountable(String periodicity) {
        return DATE.equals(periodicity.toUpperCase(Locale.ROOT));
    }

    @Override
    public List<Stat> calculateCount(String projectKey, LocalDateTime startDate, LocalDateTime endDate) {
        List<Stat> stats = commentRepository.findDateCount(projectKey, startDate, endDate, BEGIN_INDEX, LENGTH).stream()
            .map(objects -> new Stat(String.valueOf(Integer.parseInt(String.valueOf(objects[0]))), (Long) objects[1]))
            .collect(Collectors.toList());

        List<Stat> noneHourlyStats = getStatByNoneHourly(startDate, endDate, stats);
        stats.addAll(noneHourlyStats);
        stats.sort(Comparator.comparingInt(s -> Integer.parseInt(s.getDate())));
        return stats;
    }

    private List<Stat> getStatByNoneHourly(LocalDateTime startDate, LocalDateTime endDate, List<Stat> stats) {
        List<Stat> noneHourlyStats = new ArrayList<>();

        outer:
        for (int localTime = LocalTime.MIN.getHour(); localTime <= LocalTime.MAX.getHour(); localTime++) {
            for (Stat stat : stats) {
                if (localTime == Integer.parseInt(stat.getDate())) {
                    continue outer;
                }
            }
            noneHourlyStats.add(new Stat(String.valueOf(localTime), 0L));
        }
        return noneHourlyStats;
    }
}

@Component
@RequiredArgsConstructor
public class CommentCountStrategyByMonthly implements CommentCountStrategy {

    private static final String DATE = "MONTHLY";
    private static final Integer BEGIN_INDEX = 1;
    private static final Integer LENGTH = 7;

    private final CommentRepository commentRepository;

    @Override
    public boolean isCountable(String periodicity) {
        return DATE.equals(periodicity.toUpperCase(Locale.ROOT));
    }

    @Override
    public List<Stat> calculateCount(String projectKey, LocalDateTime startDate, LocalDateTime endDate) {
        List<Stat> stats = commentRepository.findDateCount(projectKey, startDate, endDate, BEGIN_INDEX, LENGTH).stream()
            .map(objects -> new Stat((String) objects[0], (Long) objects[1]))
            .collect(Collectors.toList());

        List<Stat> noneMonthStats = getStatByNoneMonth(startDate, endDate, stats);
        stats.addAll(noneMonthStats);
        stats.sort(Comparator.comparing(Stat::getDate));
        return stats;
    }

    private List<Stat> getStatByNoneMonth(LocalDateTime startDate, LocalDateTime endDate, List<Stat> stats) {
        List<Stat> noneMonthStats = new ArrayList<>();
        YearMonth startYearMonth = YearMonth.from(startDate);
        YearMonth endYearMonth = YearMonth.from(endDate);

        outer:
        for (YearMonth yearMonth = startYearMonth; !yearMonth.equals(endYearMonth); yearMonth = yearMonth.plusMonths(1L)) {
            for (Stat stat : stats) {
                if (yearMonth.toString().equals(stat.getDate())) {
                    continue outer;
                }
            }
            noneMonthStats.add(new Stat(yearMonth.toString(), 0L));
        }
        return noneMonthStats;
    }
}

 

 

코드가 길긴 한데 중요한 것은 하나입니다. JPA에서 얻어지는 날짜 데이터 외에 데이터가 없는 달은 0개로 만들어서 리스트에 추가한다는 것입니다. 여기서 Stat 객체는 날짜 필드와 카운트 필드를 갖고 있습니다.

 

 

정리

기능 하나를 구현하기 위해서 새로 알게 된 개념이 참 많은 것 같습니다. 지금은 바빠서 더 깊은 학습은 하지 못했으나, 이번 주는 기능 구현보다는 유지 보수에 초점을 맞추기로 해서 차근 차근 정리해 나가야겠습니다.

 

댓글

추천 글