기술 회고

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

Stitchhhh 2025. 4. 19. 18:28

이 전에 작성했던 맞춤 추천 게시글 시스템 구축 - 구현 1편에 이어서 구현 2편을 진행해보겠습니다.

 

해당 시스템에는 레디스를 적용할 계획입니다. 레디스의 목적은 추천 게시글 목록이 이미 존재한다면 해당 데이터를 리턴하고, 추천 게시글 목록이 존재하지 않으면 레디스에 저장해서 관리하기 위함입니다.

 

레디스 설정파일은 다음과 같습니다. 유저 ID를 Key로 게시글 ID 리스트를 Value로 가진 자료구조로 저장해보겠습니다.

@Configuration
@EnableCaching
class RedisConfig(
    @Value("\${redis.host}")
    private val host: String,
    @Value("\${redis.port}")
    private val port: Int
) {
    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory {
        return LettuceConnectionFactory(host, port)
    }

    @Bean
    fun userPostRedisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<Long, List<Long>> {
        return RedisTemplate<Long, List<Long>>().apply {
            connectionFactory = redisConnectionFactory
            keySerializer = GenericToStringSerializer(Long::class.java)
            valueSerializer = GenericJackson2JsonRedisSerializer()
        }
    }
}

 

추천 게시글 서비스

추천 게시글 서비스에는 getRecommendPosts 함수가 있습니다. 해당 함수에 사용자 ID를 파라미터로 넘기게 되면 해당 사용자의 추천 게시글 ID 리스트를 반환합니다.

 

getRecommendPosts 함수는 다음과 같이 동작합니다.

  1. 레디스에 사용자 ID를 기준으로 데이터가 존재하는지 확인합니다.
  2. 데이터가 존재한다면 해당 데이터를 리턴합니다.
  3. 데이터가 존재하지 않으면 excuteRecommedationJob 함수를 실행하고 결과값을 저장합니다.
  4. 결과값에 데이터가 존재하면 레디스에 사용자 ID를 Key 데이터를 Value로 저장합니다.
  5. 결과값을 리턴합니다.

excuteRecommedationJob 함수는 다음과 같이 동작합니다.

  1. 사용자별 게시글 가중치를 가져옵니다.
  2. 맞춤 추천 게시글 계산을 위한 데이터를 가져옵니다.
  3. 사용자별 유사도를 가져옵니다.
  4. 사용자별 추천 게시글 리스트를 가져와 ID 리스트만 반환합니다.
suspend fun getRecommendPosts(userId: Long): List<Long> {
    val get = recommendPostRepository.get(userId)
    if (get != null) {
        println("Redis 캐시에서 추천 게시글 목록을 가져옴")
        return get
    }

    val result = coroutineScope {
        async(Dispatchers.Default) {
            executeRecommendationJob(userId = userId)
        }.await()
    }

    if (result.isNotEmpty()) {
        println("Redis 캐시에서 추천 게시글 목록을 저장함")
        recommendPostRepository.set(userId, result)
    } else{
        println("추천 게시글 목록이 없음")
    }

    return result
}

private suspend fun executeRecommendationJob(userId: Long): List<Long> {
    val weights = withContext(Dispatchers.IO) {
        userPostWeightRepository.findAllWeights()
    }

    val data = prepareUserData(weights)

    val similarity = calculatorUserSimilarities(userId, data)

    return calculatorRecommendations(similarity, userId, data).map { it.postId }
}

 

사용자별 유사도 계산

다음으로는 사용자별 유사도 계산 함수입니다. 동작 순서에대한 설명은 코드 주석에 상세히 작성하였습니다.

/**
 * ■ 동작 순서
 *   1) 기준 사용자의 벡터/노름을 구한다. (없으면 빈 리스트 즉시 반환)
 *   2) 비교 대상 ID 목록(otherUserIds)을 만든다. (없으면 빈 리스트 즉시 반환)
 *   3) 배치 크기 계산: 전체 유사도 개수 / (코어 수 * 4), 최소 1
 *   4) 각 사용자 쌍마다
 *        ‑ 대상 벡터·노름이 없으면 유사도 0 으로 단축(return@inner)
 *        ‑ 작은 맵만 순회해 dot‑product 계산 (교집합 Set 생성 최소화)
 *        ‑ normA·normB 가 0 이면 유사도 0, 아니면 dot/(normA*normB)
 *        ‑ UserSimilarity DTO 생성
 *   5) awaitAll + flatten 으로 모든 결과를 하나의 리스트로 결합해 반환.
 */
private suspend fun calculatorUserSimilarities(
    userId: Long, data: CalculatorData
): List<UserSimilarity> = coroutineScope {
    // 1) 기준 벡터/노름 준비
    val baseVector = data.vectors[userId] ?: return@coroutineScope emptyList()
    val baseNorm = data.norms[userId] ?: 0.0

    // 2) 비교 대상 ID 목록(otherUserIds)을 만든다.
    val otherUserIds = data.vectors.keys.filter { it != userId }
    if (otherUserIds.isEmpty()) return@coroutineScope emptyList()

    // 3) 배치 크기 선정
    val coreCount = Runtime.getRuntime().availableProcessors()
    val batchSize = maxOf(1, otherUserIds.size / (coreCount * 4))

    // 4) 병렬 유사도 계산
    otherUserIds
        .chunked(batchSize)
        .map { batch ->
            async {
                batch.map inner@{ otherUserId ->
                    // 4-1) 대상 백터/노름 가져오기
                    val targetVector = data.vectors[otherUserId] ?: return@inner UserSimilarity(
                        baseUserId = userId, targetUserId = otherUserId, similarity = 0.0
                    )
                    val targetNorm = data.norms[otherUserId] ?: 0.0
                    // 4-2) 교집합을 구하고 내적 계산
                    val dotProduct = baseVector.keys
                        .intersect(targetVector.keys)
                        .sumOf { postId -> baseVector[postId]!! * targetVector[postId]!! }

                    // 4-3) 코사인 유사도 계산
                    val similarity = if (baseNorm == 0.0 || targetNorm == 0.0) 0.0
                    else dotProduct / (baseNorm * targetNorm)

                    // 4-4) 결과 객체 생성
                    UserSimilarity(baseUserId = userId, targetUserId = otherUserId, similarity = similarity)
                }
            }
        }
        // 5) 모든 async 결과를 합쳐서 반환
        .awaitAll().flatten()
}

 

사용자별 추천 게시글 계산

다음으로는 사용자별 추천 게시글 계산 함수입니다. 동작 순서에대한 설명은 코드 주석에 상세히 작성하였습니다.

/**
 * ■ 동작 순서
 *   1) 유사도 내림차순 정렬 후 상위 K개 선택
 *   2) 이미 상호작용한 게시글 ID 구하기
 *   3) 배치 크기 계산: 전체 유사도 개수 / (코어 수 * 4), 최소 1
 *   4) Recommendation 생성
 *        ‑ 대상 사용자의 벡터 가져오기 (없으면 빈 리스트)
 *        ‑ 이미 상호작용한 게시글 제외
 *        ‑ Recommendation 객체 생성
 *   5) (userId, postId) 기준으로 점수 합산 후 내림차순 정렬 및 topNPosts 개 반환
 */
private suspend fun calculatorRecommendations(
    similarities: List<UserSimilarity>, userId: Long, data: CalculatorData
): List<Recommendation> = coroutineScope {
    // 1) 유사도 내림차순 정렬 후 상위 K개 선택
    val sortSimilarities = similarities
        .sortedByDescending { it.similarity }
        .take(maxSimilarUsers)

    // 2) 이미 상호작용한 게시글 ID 구하기
    val interactedPostIds = data.posts[userId] ?: emptyList()

    // 배치 크기 계산: 전체 유사도 개수 / (코어 수 * 4), 최소 1
    val coreCount = Runtime.getRuntime().availableProcessors()
    val batchSize = maxOf(1, sortSimilarities.size / (coreCount * 4))

    // 3) Recommendation 생성
    val recommendations = sortSimilarities
        .chunked(batchSize)
        .map { batch ->
            async {
                batch.flatMap { similarity ->
                    // 3-1) 대상 사용자의 벡터 가져오기
                    val vector =
                        data.vectors[similarity.targetUserId] ?: return@flatMap emptyList<Recommendation>()

                    // 3-2) 이미 상호작용한 게시글 제외
                    val filtered = vector.filterKeys { it !in interactedPostIds }

                    // 3-3) Recommendation 객체 생성
                    filtered.map { (postId, weight) ->
                        Recommendation(
                            userId = userId, postId = postId, score = similarity.similarity * weight
                        )
                    }
                }
            }
        }
        // 4) 모든 async 결과를 합쳐서 평탄화
        .awaitAll().flatten()

    // 5) (userId, postId) 기준으로 점수 합산 후 내림차순 정렬 및 topNPosts 개 반환
    recommendations
        .groupBy { it.userId to it.postId }
        .map { (key, recs) ->
            Recommendation(userId = key.first, postId = key.second, score = recs.sumOf { it.score })
        }
        .sortedByDescending { it.score }
        .take(maxRecommendedPosts)
}

 

최종 코드는 https://github.com/min-seon-gyu/recommend_system에서 확인할 수 있습니다.