인기글 기능 설계 및 구현

김소희·2025년 12월 23일

메인페이지 주간 인기글 기능 구현

메인페이지에 자유게시판과 코드게시판의 주간 인기글을 슬라이드 형태로 보여주는 기능을 구현했다. 사용자가 직접 버튼을 클릭하거나 5초마다 자동으로 전환되며, 각 게시판의 상위 3개 글을 확인할 수 있다.

실시간 인기글 기능 구현 배경

메인페이지는 서비스에서 가장 많이 호출되는 페이지다. 사용자가 서비스에 접속하면 가장 먼저 마주하는 화면이기 때문에 빠른 응답 속도가 필수적이다. 이러한 메인페이지에 자유게시판과 코드게시판의 인기글을 노출하기로 결정하면서, 어떤 방식으로 인기글을 선정하고 제공할 것인지에 대한 고민이 시작되었다.

가장 단순한 방법은 메인페이지 조회 시마다 게시글들을 조회수, 좋아요, 댓글 수 등으로 정렬하고 집계하여 상위 게시글을 가져오는 것이다. 하지만 이 방식은 매 요청마다 복잡한 계산 쿼리를 실행해야 하므로 데이터베이스에 상당한 부담을 준다. 특히 게시글이 수천, 수만 개로 늘어날수록 정렬과 집계 연산의 비용은 기하급수적으로 증가한다. 메인페이지의 높은 호출 빈도를 고려하면 이는 전체 서비스 성능에 치명적인 영향을 미칠 수 있다.

인기글의 본질에 대해서도 생각해보았다. 인기글이란 특정 기간 동안 사용자들의 반응이 누적된 글이라고 판단했다. 조회수가 높고, 좋아요를 많이 받고, 댓글이 활발하게 달린 글이 인기글이다. 여기서 중요한 점은 인기글이 "지금 이 순간"의 스냅샷보다는 "최근 분위기"를 반영하는 것이 더 의미 있다는 것이다. 1분 전과 지금의 인기글이 달라질 필요는 없다. 오히려 일정 기간 동안의 트렌드를 보여주는 것이 사용자에게 더 가치 있는 정보다.

이러한 판단을 바탕으로 주간 인기글이라는 개념을 도입했다. 주간 인기글은 최근 7일 동안 가장 많은 반응을 얻은 게시글들을 의미한다. 이렇게 기간을 명확히 정의하면 인기글이 매번 실시간으로 계산될 필요가 없다. 대신 정해진 주기마다 한 번씩 계산하여 결과를 저장해두고, 메인페이지에서는 저장된 결과만 조회하면 된다.

구현 방식 선택: 배치 vs 실시간 캐싱

인기글 제공 방식으로 두 가지 접근을 검토했다. 첫 번째는 배치와 스케줄러를 활용하여 주기적으로 인기글을 계산하고 별도 테이블에 저장하는 방식이고, 두 번째는 메인페이지 조회 시 실시간으로 계산하되 Redis 캐싱으로 성능을 보완하는 방식이다.

실시간 캐싱 방식은 언뜻 보기에 항상 최신 데이터를 제공할 수 있어 매력적으로 보인다. 하지만 이 방식에는 여러 숨겨진 위험이 존재한다. 가장 큰 문제는 Redis 장애 시 모든 요청이 데이터베이스로 직행한다는 점이다. 캐시가 없는 상태에서 복잡한 집계 쿼리가 메인페이지 요청마다 실행되면 데이터베이스가 감당할 수 없는 부하를 받게 되고, 이는 곧 서비스 전체의 마비로 이어질 수 있다. 또한 서비스 시작 시 캐시 워밍업이 필요하며, 캐시가 만료되는 순간 다수의 요청이 동시에 데이터베이스를 조회하는 Cache Stampede 문제를 방지하기 위한 Lock 로직 등 추가적인 복잡도가 발생한다.

반면 배치 방식은 여러 명확한 장점을 가진다. 배치 작업이 실패하더라도 이전에 저장된 인기글 데이터가 그대로 제공되므로 서비스 중단 없이 안정적으로 운영할 수 있다. 성능이 예측 가능하다는 점도 중요하다. 메인페이지 조회는 항상 단순한 SELECT 쿼리만 실행하므로 응답 시간이 일정하고, 트래픽이 급증하더라도 데이터베이스 부하가 안정적으로 유지된다. Redis 같은 외부 캐시 시스템에 의존하지 않아 아키텍처가 단순하고 장애 포인트가 줄어든다. 디버깅과 모니터링도 용이하다. 배치 작업의 성공 여부와 실행 시간을 명확히 추적할 수 있으며, 문제 발생 시 원인을 파악하고 대응하기 쉽다.

메인페이지 조회 시 복잡한 계산을 하지 않아 성능이 좋다는 점, 점수 기반 랭킹으로 공정하고 조정 가능한 선정 기준을 마련할 수 있다는 점을 고려하여 배치 방식을 선택했다. 인기글이라는 기능의 특성상 실시간성보다는 안정성과 성능이 더 중요하다고 판단했다.

구현 설계

매주 수요일 새벽 3시에 스케줄러가 배치 작업을 실행하여 최근 7일간의 게시글 데이터를 집계한다. 점수 계산 방식은 조회수, 좋아요 수, 댓글 수에 각각 가중치를 부여하여 합산하는 방식을 채택했다. 기본적으로 조회수 1점, 좋아요 5점, 댓글 3점으로 설정했다. 단순 조회보다는 좋아요라는 명시적인 호감 표현에, 그리고 댓글이라는 참여에 더 높은 가중치를 부여한 것이다. 이 가중치는 서비스 운영 과정에서 조정할 수 있도록 설계했다.

계산된 인기글은 별도의 테이블에 저장된다. 배치 실행 시마다 해당 주차의 데이터를 삭제하고 새로 계산하여 저장하는 방식으로, 항상 최신 7일간의 인기글을 유지한다. 메인페이지는 7일 전 날짜를 기준으로 인기글을 조회하면 되므로 응답 속도가 매우 빠르다.

실시간성을 포기하는 대신 안정성과 성능을 선택한 것이다. 인기글이 일주일 단위로 갱신되어도 사용자 경험에 큰 영향이 없다고 판단했고, 이는 서비스의 전반적인 품질 향상에 더 도움이 된다는 결론에 도달했다.

테이블 설계

프론트엔드에서 게시판별로 개별 호출할 예정이기 때문에 테이블을 분리하는 것이 더 효율적이라고 판단했다.

테이블 분리 이유:

  • 쿼리 성능: board_type 조건 없이 바로 조회 가능
  • 명확성: 자유게시판과 코드게시판 인기글 테이블로 목적이 명확
  • 확장성: 나중에 각 게시판마다 다른 인기글 정책 적용 가능
  • 인덱스 효율: 테이블이 작아서 인덱스 효율이 좋음
CREATE TABLE WEEKLY_POPULAR_FREEBOARD (
    WEEKLY_POPULAR_FREEBOARD_ID BIGINT AUTO_INCREMENT PRIMARY KEY,
    FREEBOARD_ID BIGINT NOT NULL,
    WEEK_START_DATE DATE NOT NULL,
    WEEK_END_DATE DATE NOT NULL,
    VIEW_COUNT INT DEFAULT 0,
    LIKE_COUNT INT DEFAULT 0,
    COMMENT_COUNT INT DEFAULT 0,
    POPULARITY_SCORE INT DEFAULT 0,
    RANKING INT NOT NULL,
    CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX IDX_WEEKLY_POPULAR_FREEBOARD_WEEK (WEEK_START_DATE),
    INDEX IDX_WEEKLY_POPULAR_FREEBOARD_RANKING (WEEK_START_DATE, RANKING),
    CONSTRAINT FK_WEEKLY_POPULAR_FREEBOARD
        FOREIGN KEY (FREEBOARD_ID) REFERENCES FREEBOARD(FREEBOARD_ID) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE WEEKLY_POPULAR_CODEBOARD (
    WEEKLY_POPULAR_CODEBOARD_ID BIGINT AUTO_INCREMENT PRIMARY KEY,
    CODEBOARD_ID BIGINT NOT NULL,
    WEEK_START_DATE DATE NOT NULL,
    WEEK_END_DATE DATE NOT NULL,
    VIEW_COUNT INT DEFAULT 0,
    LIKE_COUNT INT DEFAULT 0,
    COMMENT_COUNT INT DEFAULT 0,
    POPULARITY_SCORE INT DEFAULT 0,
    RANKING INT NOT NULL,
    CREATED_AT TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX IDX_WEEKLY_POPULAR_CODEBOARD_WEEK (WEEK_START_DATE),
    INDEX IDX_WEEKLY_POPULAR_CODEBOARD_RANKING (WEEK_START_DATE, RANKING),
    CONSTRAINT FK_WEEKLY_POPULAR_CODEBOARD
        FOREIGN KEY (CODEBOARD_ID) REFERENCES CODEBOARD(CODEBOARD_ID) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

인기도 점수 계산 로직

주간 인기글은 다음과 같은 가중치로 점수를 계산한다.

인기도 점수 = (조회수 × 1) + (좋아요 × 5) + (댓글 × 3)

백엔드 구현

Domain & DTO

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PopularFreeboard {
    private Long weeklyPopularFreeboardId;
    private Long freeboardId;
    private LocalDate weekStartDate;
    private LocalDate weekEndDate;
    private Integer viewCount;
    private Integer likeCount;
    private Integer commentCount;
    private Integer popularityScore;
    private Integer ranking;
    private LocalDateTime createdAt;
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PopularFreeboardResponseDto {
    private Long freeboardId;
    private Long userId;
    private String userNickname;
    private String userImage;
    private String freeboardTitle;
    private String freeboardPlainText;
    private Long freeboardClick;
    private String freeboardRepresentImage;
    private LocalDateTime freeboardCreatedAt;
    private Integer likeCount;
    private Integer commentCount;
    private Integer popularityScore;
    private Integer ranking;
}

코드게시판도 동일한 구조로 PopularCodeboardPopularCodeboardResponseDto를 작성했다.

Mapper

@Mapper
public interface PopularFreeboardMapper {
    List<PopularFreeboardResponseDto> findWeeklyPopularPosts(@Param("weekStartDate") LocalDate weekStartDate);
    
    void insertWeeklyPopularPosts(@Param("posts") List<PopularFreeboard> posts);
    
    void deleteWeeklyPopularPosts(@Param("weekStartDate") LocalDate weekStartDate);
    
    List<PopularFreeboard> calculateWeeklyPopularPosts(
        @Param("startDate") LocalDate startDate,
        @Param("endDate") LocalDate endDate,
        @Param("limit") int limit
    );
}

MyBatis XML

프로젝트에서는 통합 좋아요 테이블인 LIKE와 통합 댓글 테이블인 COMMENT를 사용하고 있어, REFERENCE_TYPEBOARD_TYPE으로 게시판을 구분한다.

PopularFreeboardMapper.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="kr.or.kosa.backend.freeboard.mapper.PopularFreeboardMapper">

    <select id="findWeeklyPopularPosts" resultType="kr.or.kosa.backend.freeboard.dto.PopularFreeboardResponseDto">
        SELECT
        f.FREEBOARD_ID as freeboardId,
        f.USER_ID as userId,
        u.USER_NICKNAME as userNickname,
        u.USER_IMAGE as userImage,
        f.FREEBOARD_TITLE as freeboardTitle,
        f.FREEBOARD_PLAIN_TEXT as freeboardPlainText,
        f.FREEBOARD_CLICK as freeboardClick,
        f.FREEBOARD_CREATED_AT as freeboardCreatedAt,
        f.FREEBOARD_REPRESENT_IMAGE as freeboardRepresentImage,
        p.LIKE_COUNT as likeCount,
        p.COMMENT_COUNT as commentCount,
        p.POPULARITY_SCORE as popularityScore,
        p.RANKING as ranking
        FROM WEEKLY_POPULAR_FREEBOARD p
        INNER JOIN FREEBOARD f ON p.FREEBOARD_ID = f.FREEBOARD_ID
        INNER JOIN USERS u ON f.USER_ID = u.USER_ID
        WHERE p.WEEK_START_DATE = #{weekStartDate}
        AND f.FREEBOARD_DELETED_YN = 'N'
        ORDER BY p.RANKING ASC
        LIMIT 10
    </select>

    <select id="calculateWeeklyPopularPosts" resultType="kr.or.kosa.backend.freeboard.domain.PopularFreeboard">
        SELECT
            f.FREEBOARD_ID as freeboardId,
            f.FREEBOARD_CLICK as viewCount,
            COALESCE(like_counts.like_count, 0) as likeCount,
            COALESCE(comment_counts.comment_count, 0) as commentCount,
            (f.FREEBOARD_CLICK * 1) +
            (COALESCE(like_counts.like_count, 0) * 5) +
            (COALESCE(comment_counts.comment_count, 0) * 3) as popularityScore
        FROM FREEBOARD f
        LEFT JOIN (
            SELECT REFERENCE_ID, COUNT(*) as like_count
            FROM `LIKE`
            WHERE REFERENCE_TYPE = 'POST_FREEBOARD'
            GROUP BY REFERENCE_ID
        ) like_counts ON f.FREEBOARD_ID = like_counts.REFERENCE_ID
        LEFT JOIN (
            SELECT BOARD_ID, COUNT(*) as comment_count
            FROM `COMMENT`
            WHERE BOARD_TYPE = 'FREEBOARD'
            AND IS_DELETED = FALSE
            GROUP BY BOARD_ID
        ) comment_counts ON f.FREEBOARD_ID = comment_counts.BOARD_ID
        WHERE f.FREEBOARD_DELETED_YN = 'N'
        AND f.FREEBOARD_CREATED_AT BETWEEN #{startDate} AND #{endDate}
        HAVING popularityScore > 0
        ORDER BY popularityScore DESC
        LIMIT #{limit}
    </select>

    <insert id="insertWeeklyPopularPosts">
        INSERT INTO WEEKLY_POPULAR_FREEBOARD (
            FREEBOARD_ID,
            WEEK_START_DATE,
            WEEK_END_DATE,
            VIEW_COUNT,
            LIKE_COUNT,
            COMMENT_COUNT,
            POPULARITY_SCORE,
            RANKING
        )
        VALUES
        <foreach collection="posts" item="post" separator=",">
            (
                #{post.freeboardId},
                #{post.weekStartDate},
                #{post.weekEndDate},
                #{post.viewCount},
                #{post.likeCount},
                #{post.commentCount},
                #{post.popularityScore},
                #{post.ranking}
            )
        </foreach>
    </insert>

    <delete id="deleteWeeklyPopularPosts">
        DELETE FROM WEEKLY_POPULAR_FREEBOARD
        WHERE WEEK_START_DATE = #{weekStartDate}
    </delete>

</mapper>

PopularCodeboardMapper.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="kr.or.kosa.backend.codeboard.mapper.PopularCodeboardMapper">

    <select id="findWeeklyPopularPosts" resultType="kr.or.kosa.backend.codeboard.dto.PopularCodeboardResponseDto">
        SELECT
        c.CODEBOARD_ID as codeboardId,
        c.USER_ID as userId,
        u.USER_NICKNAME as userNickname,
        u.USER_IMAGE as userImage,
        c.CODEBOARD_TITLE as codeboardTitle,
        c.CODEBOARD_PLAIN_TEXT as codeboardPlainText,
        c.CODEBOARD_CLICK as codeboardClick,
        c.CODEBOARD_CREATED_AT as codeboardCreatedAt,
        c.AI_SCORE as aiScore,
        p.LIKE_COUNT as likeCount,
        p.COMMENT_COUNT as commentCount,
        p.POPULARITY_SCORE as popularityScore,
        p.RANKING as ranking
        FROM WEEKLY_POPULAR_CODEBOARD p
        INNER JOIN CODEBOARD c ON p.CODEBOARD_ID = c.CODEBOARD_ID
        INNER JOIN USERS u ON c.USER_ID = u.USER_ID
        WHERE p.WEEK_START_DATE = #{weekStartDate}
        AND c.CODEBOARD_DELETED_YN = 'N'
        ORDER BY p.RANKING ASC
        LIMIT 10
    </select>

    <select id="calculateWeeklyPopularPosts" resultType="kr.or.kosa.backend.codeboard.domain.PopularCodeboard">
        SELECT
            c.CODEBOARD_ID as codeboardId,
            c.CODEBOARD_CLICK as viewCount,
            COALESCE(like_counts.like_count, 0) as likeCount,
            COALESCE(comment_counts.comment_count, 0) as commentCount,
            (c.CODEBOARD_CLICK * 1) +
            (COALESCE(like_counts.like_count, 0) * 5) +
            (COALESCE(comment_counts.comment_count, 0) * 3) as popularityScore
        FROM CODEBOARD c
        LEFT JOIN (
            SELECT REFERENCE_ID, COUNT(*) as like_count
            FROM `LIKE`
            WHERE REFERENCE_TYPE = 'POST_CODEBOARD'
            GROUP BY REFERENCE_ID
        ) like_counts ON c.CODEBOARD_ID = like_counts.REFERENCE_ID
        LEFT JOIN (
            SELECT BOARD_ID, COUNT(*) as comment_count
            FROM `COMMENT`
            WHERE BOARD_TYPE = 'CODEBOARD'
            AND IS_DELETED = FALSE
            GROUP BY BOARD_ID
        ) comment_counts ON c.CODEBOARD_ID = comment_counts.BOARD_ID
        WHERE c.CODEBOARD_DELETED_YN = 'N'
        AND c.CODEBOARD_CREATED_AT BETWEEN #{startDate} AND #{endDate}
        HAVING popularityScore > 0
        ORDER BY popularityScore DESC
        LIMIT #{limit}
    </select>

    <insert id="insertWeeklyPopularPosts">
        INSERT INTO WEEKLY_POPULAR_CODEBOARD (
            CODEBOARD_ID,
            WEEK_START_DATE,
            WEEK_END_DATE,
            VIEW_COUNT,
            LIKE_COUNT,
            COMMENT_COUNT,
            POPULARITY_SCORE,
            RANKING
        )
        VALUES
        <foreach collection="posts" item="post" separator=",">
            (
                #{post.codeboardId},
                #{post.weekStartDate},
                #{post.weekEndDate},
                #{post.viewCount},
                #{post.likeCount},
                #{post.commentCount},
                #{post.popularityScore},
                #{post.ranking}
            )
        </foreach>
    </insert>

    <delete id="deleteWeeklyPopularPosts">
        DELETE FROM WEEKLY_POPULAR_CODEBOARD
        WHERE WEEK_START_DATE = #{weekStartDate}
    </delete>

</mapper>

Service

배치 실행 시 7일 전부터 어제까지의 데이터를 계산하고, 조회 시에는 7일 전 날짜를 기준으로 인기글을 가져온다.

@Slf4j
@Service
@RequiredArgsConstructor
public class PopularFreeboardService {

    private final PopularFreeboardMapper popularFreeboardMapper;

    public List<PopularFreeboardResponseDto> getWeeklyPopularPosts() {
        LocalDate weekAgo = LocalDate.now().minusDays(7);
        log.info("자유게시판 인기글 조회 - 기준 날짜: {}", weekAgo);
        return popularFreeboardMapper.findWeeklyPopularPosts(weekAgo);
    }

    public boolean isPopularPostsEmpty() {
        LocalDate weekAgo = LocalDate.now().minusDays(7);
        List<PopularFreeboardResponseDto> posts = popularFreeboardMapper.findWeeklyPopularPosts(weekAgo);
        return posts == null || posts.isEmpty();
    }

    @Transactional
    public void processWeeklyPopularPosts() {
        LocalDate today = LocalDate.now();
        LocalDate weekAgo = today.minusDays(7);

        log.info("자유게시판 주간 인기글 배치 시작 - 기간: {} ~ {}", weekAgo, today.minusDays(1));

        popularFreeboardMapper.deleteWeeklyPopularPosts(weekAgo);

        List<PopularFreeboard> calculatedPosts = popularFreeboardMapper.calculateWeeklyPopularPosts(
                weekAgo,
                today.minusDays(1),
                3
        );

        log.info("계산된 자유게시판 인기글 수: {}", calculatedPosts.size());

        if (calculatedPosts.isEmpty()) {
            log.warn("자유게시판 주간 인기글이 없습니다. 기간: {} ~ {}", weekAgo, today.minusDays(1));
            return;
        }

        List<PopularFreeboard> postsWithRanking = IntStream.range(0, calculatedPosts.size())
                .mapToObj(i -> {
                    PopularFreeboard post = calculatedPosts.get(i);
                    return PopularFreeboard.builder()
                            .freeboardId(post.getFreeboardId())
                            .weekStartDate(weekAgo)
                            .weekEndDate(today.minusDays(1))
                            .viewCount(post.getViewCount())
                            .likeCount(post.getLikeCount())
                            .commentCount(post.getCommentCount())
                            .popularityScore(post.getPopularityScore())
                            .ranking(i + 1)
                            .build();
                })
                .toList();

        popularFreeboardMapper.insertWeeklyPopularPosts(postsWithRanking);

        log.info("자유게시판 주간 인기글 배치 완료 - {} 건 저장", postsWithRanking.size());
    }
}

코드게시판도 동일한 구조로 PopularCodeboardService를 작성했다.

스케줄러

매주 수요일 새벽 3시에 자동으로 인기글을 계산한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class PopularFreeboardScheduler {

    private final PopularFreeboardService popularFreeboardService;

    @Scheduled(cron = "0 0 3 * * WED")
    public void scheduleWeeklyPopularPosts() {
        log.info("자유게시판 주간 인기글 배치 스케줄러 시작");
        try {
            popularFreeboardService.processWeeklyPopularPosts();
        } catch (Exception e) {
            log.error("자유게시판 주간 인기글 배치 실패", e);
        }
    }
}
@Slf4j
@Component
@RequiredArgsConstructor
public class PopularCodeboardScheduler {

    private final PopularCodeboardService popularCodeboardService;

    @Scheduled(cron = "0 0 3 * * WED")
    public void scheduleWeeklyPopularPosts() {
        log.info("코드게시판 주간 인기글 배치 스케줄러 시작");
        try {
            popularCodeboardService.processWeeklyPopularPosts();
        } catch (Exception e) {
            log.error("코드게시판 주간 인기글 배치 실패", e);
        }
    }
}

배포 시 자동 초기화

애플리케이션이 시작될 때 인기글 테이블이 비어있으면 자동으로 초기 데이터를 생성한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class PopularPostInitializer implements ApplicationRunner {
    
    private final PopularFreeboardService popularFreeboardService;
    private final PopularCodeboardService popularCodeboardService;

    @Override
    public void run(ApplicationArguments args) {
        log.info("=== 인기글 초기화 시작 ===");
        
        try {
            if (popularFreeboardService.isPopularPostsEmpty()) {
                log.info("자유게시판 인기글이 비어있음 - 배치 실행");
                popularFreeboardService.processWeeklyPopularPosts();
            }
            
            if (popularCodeboardService.isPopularPostsEmpty()) {
                log.info("코드게시판 인기글이 비어있음 - 배치 실행");
                popularCodeboardService.processWeeklyPopularPosts();
            }
            
            log.info("=== 인기글 초기화 완료 ===");
        } catch (Exception e) {
            log.error("인기글 초기화 실패", e);
        }
    }
}

Controller

@RestController
@RequestMapping("/popular/freeboard")
@RequiredArgsConstructor
public class PopularFreeboardController {

    private final PopularFreeboardService popularFreeboardService;

    @GetMapping
    public ResponseEntity<List<PopularFreeboardResponseDto>> getWeeklyPopularPosts() {
        List<PopularFreeboardResponseDto> posts = popularFreeboardService.getWeeklyPopularPosts();
        return ResponseEntity.ok(posts);
    }
}
@RestController
@RequestMapping("/popular/codeboard")
@RequiredArgsConstructor
public class PopularCodeboardController {

    private final PopularCodeboardService popularCodeboardService;

    @GetMapping
    public ResponseEntity<List<PopularCodeboardResponseDto>> getWeeklyPopularPosts() {
        List<PopularCodeboardResponseDto> posts = popularCodeboardService.getWeeklyPopularPosts();
        return ResponseEntity.ok(posts);
    }
}

프론트엔드 구현

TipTap JSON 데이터 처리

자유게시판과 코드게시판의 PLAIN_TEXT 컬럼에 TipTap 에디터의 JSON 배열이 저장되어 있어서, 이를 파싱해서 실제 텍스트만 추출하는 로직이 필요했다.

const extractPlainText = (jsonText) => {
  if (!jsonText) return '내용 없음';
  
  try {
    const parsed = JSON.parse(jsonText);
    if (Array.isArray(parsed) && parsed.length > 0) {
      const content = parsed[0].content || '';
      return content
        .replace(/<[^>]+>/g, '')
        .replace(/&nbsp;/g, ' ')
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&amp;/g, '&')
        .trim();
    }
  } catch (e) {
    return jsonText;
  }
  
  return '내용 없음';
};

데이터 통합 및 상태 관리

export default function Main() {
  const { user } = useLogin();
  const navigate = useNavigate();

  const [popularPosts, setPopularPosts] = useState([]);
  const [userStats, setUserStats] = useState({ level: null, exp: 0, totalSolved: 0 });
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (user) {
      fetchAllData();
    }
  }, [user]);

  const fetchAllData = async () => {
    try {
      setLoading(true);
      
      if (user.userId) {
        const [levelRes, usageRes] = await Promise.all([
          getUserLevel(user.userId),
          getUsageInfo(user.userId)
        ]);
        
        setUserStats({
          level: levelRes.data || null,
          exp: levelRes.data?.exp || 0,
          totalSolved: usageRes.data?.totalSolved || 0
        });
      }

      const [freeboardRes, codeboardRes] = await Promise.all([
        axiosInstance.get("/popular/freeboard"),
        axiosInstance.get("/popular/codeboard")
      ]);

      const freeSchema = (freeboardRes.data || []).map(p => {
        const plainText = extractPlainText(p.freeboardPlainText);
        
        return { 
          ...p, 
          type: 'free', 
          title: p.freeboardTitle, 
          id: p.freeboardId, 
          author: p.userNickname || 'Unknown',
          profileImage: p.userImage,
          views: p.freeboardClick, 
          date: p.freeboardCreatedAt,
          likes: p.likeCount,
          comments: p.commentCount,
          score: p.popularityScore,
          ranking: p.ranking,
          plainText: plainText,
          image: p.freeboardRepresentImage
        };
      });

      const codeSchema = (codeboardRes.data || []).map(p => {
        const plainText = extractPlainText(p.codeboardPlainText);
        
        return { 
          ...p, 
          type: 'code', 
          title: p.codeboardTitle, 
          id: p.codeboardId, 
          author: p.userNickname || 'Unknown',
          profileImage: p.userImage,
          views: p.codeboardClick, 
          date: p.codeboardCreatedAt,
          likes: p.likeCount,
          comments: p.commentCount,
          score: p.popularityScore,
          ranking: p.ranking,
          plainText: plainText, 
          aiScore: p.aiScore,
          image: p.codeboardRepresentImage
        };
      });
      
      const combined = [...freeSchema, ...codeSchema];
      setPopularPosts(combined);

    } catch (error) {
      console.error("데이터 로딩 실패:", error);
      setPopularPosts([]);
    } finally {
      setLoading(false);
    }
  };

  const handlePostClick = (post) => {
    if (post.type === 'free') navigate(`/freeboard/${post.id}`);
    else navigate(`/codeboard/${post.id}`);
  };

  return (
    <main className="min-h-screen bg-white dark:bg-[#0a0a0a]">
      <div className="w-full h-full flex flex-col items-center">
        {user ? (
          <LoggedInMain
            user={user}
            userStats={userStats}
            popularPosts={popularPosts}
            loading={loading}
            onPostClick={handlePostClick}
          />
        ) : (
          <GuestLandingPage />
        )}
      </div>
    </main>
  );
}

슬라이드 UI 구현

자유게시판과 코드게시판을 5초마다 자동으로 전환하며, 각 게시판당 3개씩 표시한다.

const [currentBoardType, setCurrentBoardType] = useState('free');

useEffect(() => {
    if (popularPosts.length === 0) return;
    
    const timer = setInterval(() => {
        setCurrentBoardType(prev => prev === 'free' ? 'code' : 'free');
    }, 5000);
    
    return () => clearInterval(timer);
}, [popularPosts.length]);

const currentBoardPosts = popularPosts.filter(
    post => post.type === currentBoardType
);

각 게시글은 순위, 제목, 내용 미리보기, 작성자 프로필, 조회수와 좋아요와 댓글 수, 작성일, 대표 이미지를 표시하며, 클릭 시 해당 게시글 상세 페이지로 이동한다.

최종 결과

메인 페이지에서 주간 인기글을 슬라이드 형태로 확인할 수 있으며, 매주 수요일 새벽 3시마다 자동으로 업데이트된다. 배포 시에는 자동으로 초기 데이터가 생성되어 빈 화면이 노출되지 않는다. 최근 7일간의 게시글을 조회수, 좋아요, 댓글 수를 기반으로 한 점수로 순위를 매겨 상위 3개를 보여준다.

profile
개발자 소희의 노트

0개의 댓글