기술 회고

맞춤 추천 게시글 시스템 구축 - 구현 1편

Stitchhhh 2025. 4. 17. 20:15

이 전에 작성했던 맞춤 추천 게시글 구축 - 이론편을 기반으로 구현편을 이어서 진행하도록 해보겠습니다.

기술 스택 버전

  • Spring Boot : 3.4.4
  • Kotlin : 1.9
  • JDK : 21
  • MySQL : 9.2.0
  • Spring Data JPA : 3.4.4
  • Kotlin JDSL : 3.5.5
  • Coroutine : 1.8.1

테이블 설계

가장 먼저 테이블이 필요하므로 최대한 간단하게 설계를 진행해보겠습니다. 필요한 테이블로는 '사용자', '게시글', '사용자 게시글 활동 이력' 테이블이 있습니다.

User(사용자) 테이블

@Entity
class User(
    @Id
    @GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY)
    var id: Long? = null,

    /** 이름 **/
    @Column(unique = true, nullable = false)
    val username: String
)

 

사용자 테이블은 위처럼 설계하였습니다. 'ID' 필드만 있어도 문제없지만 너무 초라해보이니 '이름'까지 넣어줬습니다.

Post(게시글) 테이블

@Entity
class Post(
    @Id
    @GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY)
    var id: Long? = null,

    /** 제목 **/
    @Column(nullable = false)
    val title: String,

    /** 내용 **/
    @Column(nullable = false)
    val content: String,

    /** 작성자 **/
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    val user: User
)

 

게시글 테이블은 'ID', '제목', '내용', '작성자'를 가지고 있습니다.

UserPostAction(사용자 게시글 활동 이력) 테이블

@Entity
/** post_id, user_id 조합이 유일해야 하므로 unique constraint를 설정합니다. **/
@Table(
    uniqueConstraints = [
        UniqueConstraint(name = "UniquePostIdAndUserId", columnNames = ["post_id", "user_id"])
    ])
class UserActionRecord(
    @Id
    @GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY)
    var id: Long? = null,

    /** 사용자 **/
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    val user: User,

    /** 게시글 **/
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    val post: Post,

    /** 조회 횟수 **/
    @Column(nullable = false)
    var viewCount: Long = 0L,

    /** 좋아요 여부 **/
    @Column(nullable = false)
    var favorite: Boolean = false,

    /** 마지막 활동 시간 **/
    @Column(nullable = false)
    val timestamp: OffsetDateTime = OffsetDateTime.now()
)

 

마지막으로 사용자 게시글 활동 이력 테이블입니다. 'ID', '사용자', '게시글', '조회 횟수', '좋아요 여부', '마지막 활동 시간'이 있습니다. ('마지막 활동 시간'을 넣은 이유는 추후에 최근 이력을 바탕으로 추천 글을 가져오기 위하여 미리 넣어두었으므로 없어도 전혀 문제가 없습니다.)

 

또한 다른 테이블과 다른점이 존재하는데 이유는 다음과 같습니다.

  • 복합 유니크 키 추가 : 사용자 활동 이력 테이블에는 사용자와 게시글 조합이 중복으로 저장되면 안 됩니다. 실수로 넣지는 않겠지만 예방차원으로 복합 유니크 키로 지정을 해주었습니다.

이렇게 테이블 설계는 간단히 마무리하였습니다. 이 후에는 바로 데이터를 조회 및 가공하는 작업에 대해서 설명을 할 예정입니다. 저의 경우에는 사용자,게시글 테이블에 데이터를 10,000개, 사용자 게시글 활동 이력 테이블에는 데이터를 100,000개를 추가하였습니다.

사용자 활동 이력 커스텀 조회

가장 처음으로 진행해야 하는 파트는 '사용자 활동 이력 커스텀 조회'입니다. 여기서 '커스텀'이라는 키워드가 포함되어 있는데 '사용자 게시글 활동 이력' 테이블에 저장된 데이터를 가공하는 작업이 필요하기 때문입니다. 해당 작업을 어플리케이션에서 처리하는 것 보다는 쿼리에서 가공된 데이터를 한 번에 가져오기 위해 '커스텀' 키워드를 추가 하였습니다.

 

커스텀 조회의 목표는 다음과 같습니다.

  • 현재 사용자 게시글 활동 이력에는 불필요한 데이터가 일부 존재하며, 데이터를 집계하는 작업이 필요합니다.
  • 데이터 집계는 좋아요 여부와 조회 횟수에 일정한 가중치를 부여하여 가중치의 합을 구해야합니다.
  • 최종적으로는 '사용자', '게시글', '가중치' 데이터가 필요합니다.

해당 데이터를 가져오는 쿼리를 Spring Data JPA의 nativeQuery을 활용하여 조회할 수 있지만 nativeQuery는 문자열로 작성되기 때문에 대체로 지양하고 있습니다. 프로젝트에서는 Kotlin JDSL을 사용하고 있기 때문에 이를 활용하여 진행하겠습니다.

interface UserPostActionRepository : JpaRepository<UserPostAction, Long>, UserPostScoreRepository

interface UserPostScoreRepository {
    suspend fun findAllWeights(): List<UserPostScore>
}

class UserPostScoreRepositoryImpl(
    private val entityManager: EntityManager,
    private val jpqlRenderContext: RenderContext
) : UserPostScoreRepository {
    override suspend fun findAllWeights(): List<UserPostScore> {
        val query = jpql {
            val viewScore =
                caseWhen(path(UserPostAction::viewCount).gt(5L))
                    .then(5L)
                    .`else`(path(UserPostAction::viewCount))

            val favoriteScore =
                caseWhen(path(UserPostAction::favorite).eq(true))
                    .then(10L)
                    .`else`(0L)

            val weight = sum(favoriteScore.plus(viewScore))

            selectNew<UserPostScore>(
                path(UserPostAction::user)(User::id).alias(expression("userId")),
                path(UserPostAction::post)(Post::id).alias(expression("postId")),
                weight.alias(expression("weight"))
            )
                .from(
                    entity(UserPostAction::class)
                )
                .groupBy(
                    path(UserPostAction::post)(Post::id),
                    path(UserPostAction::user)(User::id)
                )
        }

        return entityManager.createQuery(query, jpqlRenderContext).resultList
    }
}

/**
 * 사용자 게시글 가중치 계산 결과
 *
 * @property userId 사용자 ID
 * @property postId 게시글 ID
 * @property weight 가중치 점수
 */
data class UserPostScore(
    val userId: Long,
    val postId: Long,
    val weight: Double
)

 

위 쿼리는 다음과 같이 실행됩니다.

  1. 게시글, 사용자를 기준으로 그룹화합니다.
  2. 게시글, 사용자 별로 조회수 가중치와, 좋아요 가중치를 계산합니다.
    1. 조회수 가중치는 1회당 1점으로 최대 5점까지 가능합니다.
    2. 좋아요 가중치는 여부에 따라 0점 또는 10점 입니다.
  3. 게시글, 사용자, 조회수 가중치 + 좋아요 가중치를 List<UserPostScore>로 가져옵니다.

맞춤 추천 게시글을 생성하기 위한 데이터

이제 맞춤 추천 게시글을 생성하기 위한 기초 데이터를 가지고 앞으로 자주 사용할 데이터들을 미리 할당해주는 작업을 진행하겠습니다.

 

자주 사용할 데이터로는 '사용자 별 이미 상호작용한 게시글 집합', '사용자별 게시글 가중치 벡터', '사용자별 벡터의 노름' 입니다. 조금 전에 가져왔던 List<UserPostScore>를 기반으로 할당을 해보겠습니다.

/**
 * 공통 데이터 준비
 * @property posts 사용자별 이미 상호작용한 게시글 집합
 * @property vectors 사용자별 게시글 가중치 벡터
 * @property norms 사용자별 벡터의 노름
 */
private data class CalculatorData(
    val posts: Map<Long, List<Long>>,
    val vectors: Map<Long, Map<Long, Double>>,
    val norms: Map<Long, Double>
)

/**
 * 공통 데이터 준비:
 * - 사용자별 이미 상호작용한 게시글 집합 (userPosts)
 *     - UserId -> Set<PostId>
 * - 사용자별 게시글 가중치 벡터 (userVectors)
 *     - UserId -> Map<PostId, Weight>
 * - 사용자별 벡터의 노름 (userNorms)
 *     - UserId -> Norm
 */
private suspend fun prepareUserData(weights: List<UserPostScore>): CalculatorData {
    val userPosts = weights
        .groupBy { it.userId }
        .mapValues { it.value.map { postScore -> postScore.postId }.toSet() }

    val userVectors = weights
        .groupBy { it.userId }
        .mapValues { it.value.associate { postScore -> postScore.postId to postScore.weight } }

    val userNorms = userVectors
        .mapValues { sqrt(it.value.values.sumOf { score -> score * score }) }
        
    return CalculatorData(posts = userPosts, vectors = userVectors, norms = userNorms)
}

 

해당 과정까지 진행했다면 대부분 필요한 데이터를 조회하고 변수에 할당까지 한 상태입니다. 앞으로 남은 과정은 사용자 간의 코사인 유사도를 계산하고 이를 바탕으로 추천 게시글을 생성하는 작업만 남았습니다.

 

위 작업은 절차가 복잡하고 내용이 많기 때문에 '맞춤 추천 게시글 구축 - 구현 2편'에서 진행하도록 하겠습니다!