About

안녕하세요, 기록하는 개발자 강선후입니다.

금융권 백엔드 개발자를 목표로 Java와 Spring을 파고들고 있습니다. 신한DS금융 SW아카데미에서 배우는 것들, 팀 프로젝트에서 넘어지고 일어난 기록, 그리고 더 나은 개발 습관에 대한 고민을 이 블로그에 남깁니다.

Topics

apDui
글 태그 방명록 RSS GitHub

© 2026 apDui — 기록: apdui

사용자 기반 추천 시스템 구현

2025. 12. 26. 00:27·Web

https://www.upstage.ai/

 

Upstage AI - Building intelligence for the future of work

Upstage builds powerful large language models and document processing engines to transform workflows and empower leading businesses like yours.

www.upstage.ai

후원사인 Upstage AI의 임베딩 모델이 없었다면 난이도가 많이 높았을 터였다.

성능 평가에서 후술하겠지만 아주 만족스러운 성능이었다.

 


문제 정의

이번 프로젝트에서 내가 메인으로 맡았던 역할은

사용자 행동 로그 기반 임베딩 추천 시스템 설계 및 구현이었다.


사용자 행동 데이터를 수집 및 가공하여 임베딩을 만들고,

추천 결과가 자연스럽게 나오도록 데이터 파이프라인과 API를 설계하였다.

 

임베딩이란? 

임베딩(Embedding)은 텍스트, 이미지, 오디오 같은 복잡한 데이터를 컴퓨터가 이해할 수 있는 고차원 숫자 벡터(밀집된 실수 배열)로 변환하는 과정 및 그 결과물로, 데이터의 의미와 관계를 수치화하여 AI 모델이 처리하고 학습할 수 있게 하는 핵심 기술입니다. 이 변환을 통해 유사한 의미의 단어나 객체는 벡터 공간에서 가까운 거리에 위치하게 되어, 의미 기반 검색, 추천 시스템, 문서 분류 등 다양한 AI 작업에 활용됩니다. 

핵심 목표

  • 사용자가 어떤 유기동물 상세 페이지를 얼마나 오래 봤는지
  • 사용자가 어떤 유기동물을 북마크(좋아요)했는지

이 행동 로그를 바탕으로

  • 사용자 선호를 벡터(임베딩) 로 만들고
  • 그 임베딩과 유사한 유기동물을 추천하는 것

 

단순하지만, 다음과 같은 질문들이 뒤따른다.

사용자의 실제 행동 데이터를 어떻게 수치화하고,
이 데이터를 임베딩으로 변환해 추천에 활용할 것인가?

 

임베딩 로직을  기간 내에 직접 구현할 수는 없었기에,

Upstage AI 유료 모델을 사용하여 문제를 해결하였다.

 

따라서 내 책임 범위는 여기까지였다.

  • 행동 로그 설계
  • 가중치 기반 사용자 선호 모델링
  • 임베딩 계산 파이프라인 구현
  • 운영 가능한 구조 설계

 


전체 아키텍처 개요

사용자 행동
→  UserInterest / UserLike 로그 적재
→  행동 가중치 계산
→  Animal Embedding 가중합
→  정규화 진행 
→  User Embedding 저장
→  코사인 유사도 기반 추천

 

핵심 포인트는 사용자 임베딩을 직접 모델로 생성하지 않는다는 점이다.
이미 생성되어 있는 Animal Embedding들을 조합하여 User Embedding을 계산한다.

 

사용자는 데이터 포인트가 아니라 행동 분포다.

사용자 수가 적고, 행동 로그가 불완전한 상태에서
사용자 임베딩을 직접 학습한다는 선택은 과적합과 추천 붕괴로 이어질 것이라 판단했다.

 

flowchart TD

    A[사용자 행동] --> B[UserInterest / UserLike 로그 적재]
    B --> C[행동 가중치 계산]
    C --> D[Animal Embedding 가중합]
    D --> E[정규화 진행]
    E --> F[User Embedding 저장]
    F --> G[코사인 유사도 기반 추천]

 

 


사용자 행동 로그 설계

UserInterest : 체류 시간 기반 관심도

이 API는 사용자가 유기동물 상세 페이지에 들어왔을 때 호출된다.

desertionNo : 유기동물 식별자
dwellTimeSeconds : 체류 시간 (초 단위)

 

이 단계에서는 임베딩을 건드리지 않고,

사용자의 방문 사실만을 기록한다.

 


 

UserLike : 명시적 선호

“북마크”는 체류 시간과 성격이 다르다.

체류 시간 → 암묵적 신호
좋아요 → 명시적 신호


그래서 UserInterest와 UserLike는 테이블을 분리하고, 가중치도 다르게 적용했다.
이를 통해 추천 결과가 "많이 본 것”에만 쏠리거나,

클릭 한 번에 과도하게 유저 선호도가 왜곡되는 것을 방지하려했다.

 

이제 사용자 행동 로그가 쌓이기 시작했다.
하지만 이 상태에서는 아직 추천을 할 수 없다.
로그는 사실의 기록일 뿐이고, 추천은 해석의 결과이기 때문이다.

 


사용자 임베딩(User Embedding) 계산

이 시스템의 사용자 임베딩은 새로운 의미 공간을 학습해서 만드는 대상이 아니다.
이미 생성되어 있는 Animal Embedding들을 기준으로,
사용자가 어떤 동물들을 어떤 강도로 선호했는지를 벡터 연산으로 표현한 결과물이다.

그래서 사용자 임베딩을 생성하는 로직은
추천 서비스가 아닌, 사용자 임베딩 전용 계산 서비스에 위치시켰다.

@Transactional
public void computeUserEmbedding(Long userId) {
    User user = findUserOrThrow(userId);

    Map<String, Double> weightMap = buildWeightMap(user);
    if (weightMap.isEmpty()) return;

    Map<String, float[]> animalVectors = loadAnimalVectors(weightMap.keySet());
    float[] userVector = computeWeightedAverageVector(weightMap, animalVectors);
    if (userVector == null) return;

    l2NormalizeInPlace(userVector);

    upsertUserEmbedding(user, userVector);
}

 

가중 평균 벡터 계산

private float[] computeWeightedAverageVector(
        Map<String, Double> weightMap,
        Map<String, float[]> animalVectors
) {
    float[] sum = null;
    double totalWeight = 0.0;

    for (Map.Entry<String, Double> entry : weightMap.entrySet()) {
        String deserNo = entry.getKey();
        double weight = entry.getValue();

        float[] vector = animalVectors.get(deserNo);
        if (vector == null) continue;

        if (sum == null) sum = new float[vector.length];

        for (int i = 0; i < vector.length; i++) {
            sum[i] += (float) (vector[i] * weight);
        }
        totalWeight += weight;
    }

    if (sum == null || totalWeight == 0.0) return null;

    for (int i = 0; i < sum.length; i++) {
        sum[i] /= (float) totalWeight;
    }
    return sum;
}

 

L2  정규화

private void l2NormalizeInPlace(float[] vector) {
    double norm = 0.0;
    for (float v : vector) norm += v * v;

    norm = Math.sqrt(norm);
    if (norm <= 0.0) return;

    for (int i = 0; i < vector.length; i++) {
        vector[i] /= (float) norm;
    }
}

 

정규화까지 완료되면, 이 벡터는 이제 하나의 명확한 의미를 갖는다.
현재 사용자가 지금까지 보여준 행동들을 임베딩 공간에서 요약한 결과이다.

이 시점에서 사용자 임베딩은 더 이상 계산 대상이 아니다.
추천 요청이 들어왔을 때 즉시 사용할 수 있는 완성된 상태의 데이터가 된다.
그래서 이 벡터는 JSON 형태로 직렬화되어 UserEmbedding 엔티티에 저장된다.

여기서 중요한 점은,
사용자 임베딩을 로그 기반의 파생 데이터로 취급한다는 것이다.
원본 데이터는 언제나 UserInterest와 UserLike 로그이고,
UserEmbedding은 언제든지 다시 계산될 수 있는 캐시 성격의 결과물이다.

이렇게 설계한 이유는 후에 가중치 정책이 바뀌거나, 임베딩 모델이 교체되거나,
심지어 추천 로직이 변경되더라도 사용자 행동 로그 자체는 영향을 받지 않게 하기 위함이다.

 


사용자 추천 진행

추천 요청이 들어오면 오직 두 가지 데이터만을 사용한다.

이미 계산되어 저장된 User Embedding
사전에 생성되어 있는 Animal Embedding

 

recommend

public List<String> recommend(Long userId, int top) {
        Optional<UserEmbedding> optUserEmbedding = userEmbeddingRepository.findByUserId(userId);
        float[] userVector = null;
        if (optUserEmbedding.isPresent()) {
            try {
                userVector = objectMapper.readValue(
                        optUserEmbedding.get().getEmbeddingJson(), new TypeReference<float[]>() {});
            } catch (JsonProcessingException e) {
                log.warn("user {} 임베딩 오류 : 역직렬화 실패", userId, e);
            }
        }

        List<AnimalEmbedding> embeddings = animalEmbeddingRepository.findAll();

        if (userVector == null || userVector.length == 0) {
            return embeddings.stream()
                    .map(AnimalEmbedding::getDesertionNo)
                    .limit(top)
                    .toList();
        }

        List<RecommendationScore> scores = calculateScores(userVector, embeddings);
        scores.sort(Comparator.comparingDouble(RecommendationScore::score).reversed());

        return scores.stream()
                .limit(top)
                .map(RecommendationScore::desertionNo)
                .toList();
    }

 

사용자 임베딩이 존재하는 경우, 모든 Animal Embedding을 순회하며
각 동물 벡터와 사용자 벡터 간의 코사인 유사도를 계산한다.

이 계산은 calculateScores 내부에서 수행되며,
각 동물은 (desertionNo, score) 형태의 점수 객체로 변환된다.

 

calculateScores

private List<RecommendationScore> calculateScores(float[] userVector, List<AnimalEmbedding> embeddings) {
        List<RecommendationScore> scores = new ArrayList<>();

        for (AnimalEmbedding emb : embeddings) {
            try {
                float[] animalVector = objectMapper.readValue(
                        emb.getEmbeddingJson(), new TypeReference<float[]>() {});
                if (animalVector.length != userVector.length) {
                    continue;
                }
                double score = cosineSimilarity(userVector, animalVector);
                scores.add(new RecommendationScore(emb.getDesertionNo(), score));
            } catch (JsonProcessingException e) {
                log.warn("animal {} 임베딩 오류 : Score 계산 실패", emb.getDesertionNo(), e);
            }
        }

        return scores;
    }

cosineSimilarity

private double cosineSimilarity(float[] a, float[] b) {
        double dot = 0.0;
        double normA = 0.0;
        double normB = 0.0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        if (normA == 0.0 || normB == 0.0) {
            return 0.0;
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }



이 점수(score)가 가지는 의미는 단순하다.

이 동물이 가진 의미적 방향이, 현재 사용자의 관심 방향과 얼마나 유사한가

 

모든 점수가 계산되면, 유사도 기준으로 내림차순 정렬을 수행하고
상위 00개의 동물만을 추출한다.

 


'Web' 카테고리의 다른 글
  • 스프링부트 게시판 만들기 #2 : 데이터베이스 구축
  • 스프링부트 게시판 만들기 #1 : CRUD 구현
  • 개발 전 프로젝트 세팅
  • CORS 개념 및 해결 방법
apdui
apdui
뭐든 잘하고 싶은 앞뒤의 풀스택 도전기
apdui apdui 뭐든 잘하고 싶은 앞뒤의 풀스택 도전기
개설일 —
방문자 수
카테고리
  • 분류 전체보기 (79)
    • Data Science (4)
    • Computer Science (2)
      • Network (2)
    • Web (21)
      • Spring (2)
      • Vibe Coding (3)
    • 회고 (18)
      • 월간 (8)
      • 멋쟁이사자처럼 (3)
      • 신한DS 아카데미 7기 (6)
    • 코딩테스트 (19)
      • 백준(BOJ) (19)
    • Github (1)
    • Project (2)
    • Artificial Intelligence (8)
    • Develop (2)
    • Language (1)
      • Java (1)
      • JavaScript (0)
    • Financial Engineering (1)
태그 방명록 RSS GitHub
© apdui · Designed by 강선후
apdui
사용자 기반 추천 시스템 구현
상단으로

티스토리툴바