요즘은 바이브 코딩이 유행하고, AI를 활용하지 않은 서비스를 찾기 힘들 정도로 AI가 발달했다. 이러한 상황에서 AI를 활용한 기능을 구현하기 딱 좋은 기능이 친구 추천 로직이었고, 새로운 경험이 될 것이라고 생각했다.
LLM을 사용하지 않은 친구 추천 로직은 보통 관심사/언어/지역이 유사한 유저를 조회하여 반환하는 구조이다.
하지만 이 구조는 단순 필터링 수준에 그치기 때문에, 사용자 간의 의미적인 유사성을 반영하기 어렵다.
예를 들어 아래와 같은 두 키워드가 자기소개 또는 관심사 필드에 존재한다고 해보자. 사람이 읽기에는 유사한 문장이지만, 단순 텍스트/키워드 기반 필터링으로는 유사하다고 판단하기 어렵다.
따라서 유저의 프로필을 의미 단위로 해석하기 위한 방법으로 LLM Embedding 기반 추천 시스템을 선택하게 되었다.
핵심 로직은 다음과 같다.
전체적인 친구 추천 flow는 다음과 같다.
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())
);
}
}
유저 프로필을 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();
}
}
@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()
)
);
}
}
앞서 언급했듯 외부 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);
}
}
@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) {}
}



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