[졸프] Spring Boot 답변 리스트 조회 API – 사용자별 블록 정렬 (최신순 & 인기순) 구현기

ssun·2025년 8월 19일

졸업프로젝트

목록 보기
18/21

이번에는 질문 상세 조회 API에서 답변 리스트를 어떻게 정렬할지에 대한 로직을 정리했다.
우리 서비스는 사용자가 독서 전 → 독서 중 → 독서 후로 생각이 변화하는 과정을 보여주는 게 핵심이다.
따라서 답변을 단순히 한 줄 리스트로 보여주는 게 아니라, 사용자별 블록 단위로 묶어서 정렬하는 방식을 택했다.

1. 문제 상황


질문 상세 조회 API를 만들고, 답변 리스트를 내려줄 때 정렬 방식이 문제였다.

처음 구현은 단순히 createdAt DESC로 정렬해서 보여주고 있었다.

하지만 우리 서비스는 독서 전 → 독서 중 → 독서 후로 변화하는 사용자 생각의 흐름을 강조해야 한다.
→ 따라서 사용자별로 답변을 블록 단위로 묶어 보여주는 방식이 필요했다.

또, 프론트에서 정렬 기준을 바꿀 수 있도록 sort 파라미터(latest, popular)를 지원하고 싶었다.

발생한 문제들

인기순 정렬 불가

  • 기존에는 상태 → 최신순만 고려해서 정렬했기 때문에, 좋아요 수가 많은 답변이 상단에 노출되지 않았다.

사용자 블록 깨짐

  • 인기순 정렬을 단순히 answers.stream().sorted(...likeCount...)로 했더니,
    → 답변이 전부 흩어져서 사용자별 흐름(전→중→후)이 보이지 않게 됐다.

동점 처리 불안정

  • 좋아요 수가 같은 사용자들끼리는 매번 순서가 바뀌거나, 스트림 처리 순서에 따라 결과가 달라졌다.
    → 서비스 UX 입장에서 “정렬 결과가 바뀌는 것”은 굉장히 불안정한 경험이었다.

2. 해결 과정

사용자 블록 단위 그룹핑

  • 답변 전체를 먼저 userId 기준으로 groupingBy
  • 이후 entrySet().stream().sorted(...) 방식으로 블록 자체를 정렬

정렬 기준 분리

  • latest: 블록 간 정렬은 가장 최신 답변 작성일 내림차순
  • popular: 블록 간 정렬은 최대 좋아요 수 내림차순
  • 동점일 때는 최신 답변 작성일 → userId 순으로 안정화

블록 내부 정렬 고정

  • 내부는 무조건 상태 순서(BEFORE → READING → AFTER)
  • 같은 상태끼리는 순서를 강제하지 않고, 흐름만 보이도록 유지

3. 요구사항 정리

각 정렬기준에 따라 요구사항을 정리해보자면...

기본(latest)

  • 사용자 블록 단위로 답변을 묶는다.
  • 블록 내부 정렬: BEFORE → READING → AFTER (독서 상태 순서 유지)
  • 블록 간 정렬: 각 사용자의 가장 최신 답변 작성일(createdAt) 기준으로 내림차순
    → 최신 답변이 더 최근인 사용자가 위에 오도록

인기순(popular)

  • 사용자 블록 단위로 답변을 묶는다.
  • 블록 내부 정렬: BEFORE → READING → AFTER (독서 상태 순서 유지)
  • 블록 간 정렬: 각 사용자의 답변 중 최대 좋아요 수(likeCount) 기준으로 내림차순
    → 좋아요가 가장 많은 답변을 가진 사용자가 위에 오도록
  • 동점일 경우: 최신 답변이 더 최근인 사용자 → 그래도 같으면 userId 오름차순

4. 최종 코드

import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

public QuestionDetailResponseDto getQuestionDetail(Long questionId, String sort) {
    Question question = questionRepository.findById(Math.toIntExact(questionId))
            .orElseThrow(QuestionNotFoundException::new);

    // 1) 모든 답변 조회
    List<Answer> answers = answerRepository.findByQuestion_QuestionId(questionId);

    // 2) 사용자별 그룹핑
    Map<Long, List<Answer>> grouped = answers.stream()
            .filter(a -> a.getUser() != null)
            .collect(Collectors.groupingBy(a -> a.getUser().getUserId()));

    // 3) 상태 우선순위
    Map<String, Integer> stateOrder = Map.of("BEFORE", 0, "READING", 1, "AFTER", 2);

    // 4) 블록 내부 정렬 (상태 순서만 유지)
    Comparator<Answer> innerComparator = Comparator.comparing(
            (Answer a) -> stateOrder.getOrDefault(
                    Optional.ofNullable(a.getAnswerState()).orElse("").toUpperCase(), 99)
    );

    // 5) 유틸 함수
    Function<List<Answer>, Integer> maxLikes = list ->
            list.stream().map(Answer::getLikeCount).filter(Objects::nonNull).max(Integer::compareTo).orElse(0);

    Function<List<Answer>, LocalDateTime> latestCreated = list ->
            list.stream().map(Answer::getCreatedAt).filter(Objects::nonNull).max(LocalDateTime::compareTo).orElse(LocalDateTime.MIN);

    // 6) 블록 간 정렬
    Comparator<Map.Entry<Long, List<Answer>>> blockComparator;
    if ("popular".equalsIgnoreCase(sort)) {
        blockComparator = Comparator
                .comparing((Map.Entry<Long, List<Answer>> e) -> maxLikes.apply(e.getValue()))
                .reversed()
                .thenComparing(e -> latestCreated.apply(e.getValue()), Comparator.reverseOrder())
                .thenComparing(Map.Entry::getKey);
    } else {
        blockComparator = Comparator
                .comparing((Map.Entry<Long, List<Answer>> e) -> latestCreated.apply(e.getValue()), Comparator.reverseOrder())
                .thenComparing(e -> maxLikes.apply(e.getValue()), Comparator.reverseOrder())
                .thenComparing(Map.Entry::getKey);
    }

    // 7) 사용자 블록 정렬 + DTO 변환
    List<AnswersByUserDto> answersByUser = grouped.entrySet().stream()
            .sorted(blockComparator)
            .map(entry -> {
                User user = userRepository.findById(entry.getKey())
                        .orElseThrow(UserNotFoundException::new);

                List<AnswerResponseDto> answerDtos = entry.getValue().stream()
                        .sorted(innerComparator)
                        .map(a -> AnswerResponseDto.from(
                                a,
                                user.getUserId().toString(),
                                Optional.ofNullable(user.getUserNickname()).orElse("알 수 없음"),
                                Optional.ofNullable(user.getProfileUrl()).orElse("")
                        ))
                        .collect(Collectors.toList());

                return new AnswersByUserDto(user, answerDtos);
            })
            .collect(Collectors.toList());

    QuestionResponseDto questionDto = QuestionResponseDto.from(question, answers.size());
    return new QuestionDetailResponseDto(questionDto, answersByUser);
}

5. 배운 점

간단한 리팩토링이었지만 여러가지 배운 점이 있다.

  • 단순 리스트 정렬이 아니라 사용자 단위 블록을 유지하면서 정렬해야 할 때는
    groupingBy → entrySet().stream().sorted(...) → DTO 변환
    순서로 풀어가는 게 가장 깔끔하다.
  • 인기순 같은 경우 전역 정렬보다 사용자 블록 단위 정렬이 UX적으로 더 자연스럽다.
  • 동점 처리를 넣어야 결과가 항상 안정적이고 테스트 환경/실서비스에서 일관되게 동작한다.

답변 리스트 조회 리팩토링 끝!

profile
안녕하세요! 백다현입니다

0개의 댓글