OpenAI를 활용한 Embedding 기반 친구 추천 시스템 구현

bagt13·2026년 4월 11일

Project

목록 보기
24/24
post-thumbnail

❓ LLM 사용 이유

1. LLM 활용 경험

요즘은 바이브 코딩이 유행하고, AI를 활용하지 않은 서비스를 찾기 힘들 정도로 AI가 발달했다. 이러한 상황에서 AI를 활용한 기능을 구현하기 딱 좋은 기능이 친구 추천 로직이었고, 새로운 경험이 될 것이라고 생각했다.

2. 추천 로직의 허점

LLM을 사용하지 않은 친구 추천 로직은 보통 관심사/언어/지역이 유사한 유저를 조회하여 반환하는 구조이다.

하지만 이 구조는 단순 필터링 수준에 그치기 때문에, 사용자 간의 의미적인 유사성을 반영하기 어렵다.

예를 들어 아래와 같은 두 키워드가 자기소개 또는 관심사 필드에 존재한다고 해보자. 사람이 읽기에는 유사한 문장이지만, 단순 텍스트/키워드 기반 필터링으로는 유사하다고 판단하기 어렵다.

  • "I like music"
  • "I enjoy listening to songs"

따라서 유저의 프로필을 의미 단위로 해석하기 위한 방법으로 LLM Embedding 기반 추천 시스템을 선택하게 되었다.


💫 핵심 로직

핵심 로직은 다음과 같다.

  1. text builder를 정의해 사용자 프로필을 하나의 텍스트로 생성하고, 이를 embedding vector로 변환한다.
  2. 각 유저의 vector 간 유사도를 계산하여, 의미적으로 유사한 유저를 찾는다.
  3. embedding 생성 비용을 줄이기 위해 Redis에 캐싱하고, 비동기 구조를 통해 API 응답과 분리한다.

♻️ 전체 flow

전체적인 친구 추천 flow는 다음과 같다.

유저 프로필 생성/수정 시

  1. 유저가 프로필을 생성 또는 수정한다.
  2. Event Publisher를 통해 Event를 발행한다.
  3. 이때, 시간이 오래 걸리는 Embedding API를 기다리지 않도록 하기 위해 Event Listener는 비동기적으로 실행되도록 한다.

비동기 실행 파트

  1. OpenAI Embedding API 호출
  2. 응답 결과(embedding) Redis 저장
  3. 추천 친구 리스트 미리 계산 후 저장

추천 친구 목록 요청 시

Redis에 미리 저장된 추천 친구 리스트 조회 후 반환

프로필 생성/수정 요청에 비해 추천 친구 목록 요청은 훨씬 빈번하기 때문에, 빠른 응답을 위해 이 구조를 선택했다.


✈️ 해결하기

유저 프로필을 텍스트로 추출

원하는 Embedding 결과를 얻기 위해서는, 입력값(text prompt)을 잘 작성해야하기 때문에, 다양한 정보를 하나의 문맥으로 간결하게 구성했다.

@Component
public class UserProfileTextBuilder {

    public String build(User user) {
        return """
                User Profile:
                Native Languages: %s
                Learning Languages: %s
                Interests: %s
                Location: %s
                Self Introduction: %s
                """
                .formatted(
                        nativeLanguages.isEmpty() ? "None" : String.join(", ", nativeLanguages),
                        learningLanguages.isEmpty() ? "None" : String.join(", ", learningLanguages),
                        interests,
                        normalize(user.getLocation().getName()),
                        sanitize(user.getIntro())
                );
    }
}

OpenAI 호출

유저 프로필을 Text Builder를 통해 텍스트로 생성한 뒤, OpenAI Embedding API 요청을 통해 vector로 변환한다.

@Component
@Slf4j
public class OpenAiClient {

    private final WebClient webClient;

    public OpenAiClient(
            @Value("${openai.api.key}") String apiKey
    ) {
        this.webClient = WebClient.builder()
                .baseUrl("https://api.openai.com/v1")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    public List<Double> embed(String text) {
        OpenAiEmbeddingRequest request = new OpenAiEmbeddingRequest();
        request.setModel("text-embedding-3-large");
        request.setInput(text);

        log.info("### request 생성 완료");
        OpenAiEmbeddingResponse response = webClient.post()
                .uri("/embeddings")
                .bodyValue(request)
                .retrieve()
                .bodyToMono(OpenAiEmbeddingResponse.class)
                .block();

        log.info("### response 응답 완료");
        return response.getData().get(0).getEmbedding();
    }
}
  • openApi key 등 설정 -> openApi embedding 호출
  • 실제 호출 및 결과 저장은 UserProfileEmbeddingService에서 진행

응답 결과 캐싱

@Service
@RequiredArgsConstructor
@Slf4j
public class UserProfileEmbeddingService {

    private final OpenAiClient openAiClient;
    private final UserRepository userRepository;
    private final UserProfileEmbeddingRepository repository;
    private final UserProfileTextBuilder profileTextBuilder;

    //embedding API 호출/생성/저장
    public void rebuild(Long userId) {
        User user = userRepository.findUserProfileById(userId)
                .orElseThrow();

        String profileText = profileTextBuilder.build(user);
        log.info("### profileText:{}", profileText);

        log.info("### OpenAI embedding 요청 호출");
        List<Double> embedding = openAiClient.embed(profileText);

        log.info("### created embedding:{}", embedding);
        repository.save(
                new UserProfileEmbedding(
                        userId,
                        embedding,
                        LocalDateTime.now()
                )
        );
    }
}
  • 응답으로 받은 embedding을 Redis에 캐싱한다.

EventListener에서 OpenAPI 비동기 호출

앞서 언급했듯 외부 API 호출을 기존 API 요청과 묶지 않고, 비동기로 처리한다.

@Component
@RequiredArgsConstructor
@Slf4j
public class UserEmbeddingEventListener {

    private final UserProfileEmbeddingService embeddingService;
    private final UserRecommendPrecomputeService userRecommendPrecomputeService;

    @EventListener
    @Async
    public void handle(UserProfileChangeEvent event) {
        log.info("### eventListener called");

        //embedding 생성
        embeddingService.rebuild(event.userId());
        //추천 친구 리스트 미리 계산
        userRecommendPrecomputeService.precompute(event.userId(), 10);
    }
}

embedding 기반 유사 프로필 추출

@Service
@RequiredArgsConstructor
@Slf4j
public class UserRecommendPrecomputeService {

    //저장된 embedding을 기반으로 추천 친구를 미리 계산해서 저장해놓는다.

    private final UserProfileEmbeddingRepository embeddingRepository;
    private final UserRecommendRepository recommendationRepository;

    public void precompute(Long userId, int topK) {

        UserProfileEmbedding me = embeddingRepository
                .findByUserId(userId)
                .orElseThrow();

        List<UserProfileEmbedding> allEmbeddings =
                embeddingRepository.findAllEmbeddings();

        //todo: 가중치 적용(언어->관심사->지역->자기소개), 언어 적용, 테스트, flow
        List<Long> recommended = allEmbeddings.stream()
                .filter(e -> !e.getUserId().equals(userId))
                .map(e -> new RecommendResult(
                        e.getUserId(),
                        SimilarityUtils.cosineSimilarity(
                                me.getVector(),
                                e.getVector())))
                .sorted((a, b) -> Double.compare(b.score(), a.score()))
                .limit(topK)
                .map(RecommendResult::userId)
                .toList();

        log.info("allEmbeddings size={}", allEmbeddings.size());

        recommendationRepository.save(userId, recommended);
    }

    private record RecommendResult(Long userId, double score) {}
}

✅ 결과 확인

프로필 기반 Text 생성 확인

Event 호출 및 embedding 생성 확인

embedding, recommend(추천 친구 리스트) 생성 확인

순차적으로 user2, user3, user4의 회원가입 시, 각 유저의 embedding과 embedding을 기반으로 추출된 추천 친구 리스트가 성공적으로 저장되었다.


💪 주요 내용 및 성과

  • LLM Embedding 기반 의미 유사도 계산
  • Cosine Similarity를 활용한 추천 알고리즘
  • Redis 캐싱을 통한 성능 최적화
  • 비동기 이벤트 기반 구조로 사용자 경험 고려
profile
백엔드 개발자입니다😄

0개의 댓글