[졸프] Spring Boot + ML 서버 연동으로 도서 추천 API 구현하기

ssun·2025년 8월 12일

졸업프로젝트

목록 보기
15/21

졸업 프로젝트에서는 Spring Boot 백엔드ML 서버를 연동하여
사용자 맞춤형 도서 추천 API를 구현한 것이다.

ML 서버가 추천 결과를 생성하면, 백엔드에서 이를 받아 DB에 저장하고
클라이언트 요청에 맞춰 반환하는 구조이다.


1. 구현 목표

  • 사용자 ID, 필터 조건 등을 ML 서버에 전달하는 것이다.
  • ML 서버로부터 추천 도서 목록(JSON) 응답을 수신하는 것이다.
  • 추천 데이터를 DB(user_recommended_book)에 저장하는 것이다.
  • 명세서에 맞춘 응답/에러 코드를 처리하는 것이다.

2. API 명세

Endpoint

POST /api/books/recommend?topK={추천개수}

Request

Headers

  • Authorization: Bearer {AccessToken}
  • Content-Type: application/json

Body

{
  "userId": 1,
  "filters": {
    "excludeRead": true
  },
  "context": {
    "preferredGenres": ["에세이", "한국소설"],
    "recentBookIds": [11, 22, 33]
  }
}

Response (성공)

{
  "userId": 1,
  "generatedAt": "2025-08-12T07:05:00Z",
  "items": [
    {
      "bookId": 501,
      "score": 0.9123,
      "keywords": ["관계", "심리", "자기계발"]
    },
    {
      "bookId": 777,
      "score": 0.8877,
      "keywords": ["컴퓨터공학", "시스템설계"]
    }
  ]
}

3. WebClient 설정 (타임아웃 적용)

@Configuration
public class MlClientConfig {

    @Value("${ml.server.url}")
    private String mlUrl;
    @Value("${ml.timeouts.connect-ms}")
    private int connectMs;
    @Value("${ml.timeouts.read-ms}")
    private int readMs;

    @Bean
    public WebClient mlWebClient() {
        HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectMs)
            .responseTimeout(Duration.ofMillis(readMs));

        return WebClient.builder()
            .baseUrl(mlUrl)
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }
}

이번 구현에서 ML 서버 연동 시 연결 지연이나 응답 지연이 장시간 발생하는 것을 방지하기 위해 connect-ms와 read-ms 두 가지 타임아웃 값을 설정했다.

1. connect-ms (연결 타임아웃)

서버와의 TCP 연결이 맺어질 때까지 기다리는 최대 시간이다.

여기서는 3000ms (3초)로 설정하였다.

예를 들어, ML 서버가 다운되어 있거나 네트워크 상태가 불안정할 경우,
3초 안에 연결이 성립하지 않으면 ConnectTimeoutException이 발생한다.

즉, "서버 문 앞에 도착해서 벨 누르는데 3초 안에 응답이 없으면 돌아온다"는 개념이다.

2. read-ms (응답 타임아웃)

연결이 성립된 이후, 서버가 응답 본문을 보내기 시작할 때까지 기다리는 최대 시간이다.

여기서는 5000ms (5초)로 설정하였다.

예를 들어, ML 서버가 모델 연산을 너무 오래 수행해서 5초 안에 응답이 시작되지 않으면
ReadTimeoutException이 발생한다.

즉, "문은 열었지만, 5초 안에 대답이 시작되지 않으면 자리에서 일어난다"는 개념이다.

4. 서비스 로직

@Service
@RequiredArgsConstructor
public class RecommendationGenerationService {

    private final MlRecommendClient mlClient;
    private final UserRecommendedBookRepository urbRepository;
    private final UserRepository userRepository;
    private final BookRepository bookRepository;

    @Transactional
    public MlRecommendResponse generateAndPersist(String bearerToken, int topK, MlRecommendRequest req) {

        if (bearerToken == null || bearerToken.isBlank()) {
            throw new UnauthorizedAccessException();
        }

        if (req == null || req.getUserId() == null || topK <= 0) {
            throw new InvalidRequestException("요청 형식이 올바르지 않습니다.");
        }

        MlRecommendResponse resp = mlClient.recommendBooks(bearerToken, topK, req);
        urbRepository.deleteByUserId(req.getUserId());

        int rank = 1;
        List<UserRecommendedBook> batch = new ArrayList<>();
        for (MlRecommendResponse.Item item : resp.getItems()) {
            Long bookId = item.getBookId();
            if (bookId == null) continue;
            if (!bookRepository.existsById(bookId.intValue())) continue;

            String keywordsJson = new ObjectMapper().writeValueAsString(item.getKeywords());

            User userEntity = userRepository.findById(req.getUserId())
                    .orElseThrow(UserNotFoundException::new);
            Book bookEntity = bookRepository.findById(bookId.intValue())
                    .orElseThrow(BookNotFoundException::new);

            UserRecommendedBook urb = UserRecommendedBook.builder()
                    .user(userEntity)
                    .book(bookEntity)
                    .keyword(keywordsJson)
                    .recommendedAt(resp.getGeneratedAt() != null
                            ? LocalDateTime.ofInstant(resp.getGeneratedAt(), ZoneId.of("UTC"))
                            : LocalDateTime.now(ZoneId.of("UTC")))
                    .build();

            batch.add(urb);
        }
        urbRepository.saveAll(batch);

        return resp;
    }
}

서비스 코드안에 무결성 방어 로직을 넣었다.

if (!bookRepository.existsById(bookId.intValue())) continue; // 무결성 방어
//이 부분

무결성 방어 로직이란?
: 데이터 저장 전, 데이터의 유효성을 사전에 확인하여 데이터베이스 제약 조건 위반을 방지하는 코드.

  • ML 서버와 서비스 DB의 데이터 불일치 가능성이 있기 때문에 해당 로직 삽입. 이를 통해 서비스 안정성이 올라감.

내 코드에서는 bookId가 내 book 테이블에 있는지 확인하고 존재한다면 user_recommended_book 테이블에 넣을 수 있도록 했다.

그리고 여기에는 폴백 처리를 하지는 않았다.
폴백 처리란 "예상치 못한 오류나 외부 시스템 장애가 발생했을 때, 서비스가 완전히 중단되지 않도록 예비 처리 경로를 사용하는 방법"이다.

ML 서버는 100% 안정적이지 않기 때문에 나중에 폴백 처리까지 해놓으면 좋을듯하다. 이때, 시스템 장애가 발생했을 시에 해당 API 전체가 실패하므로 ML과 연동 마무리 후에 폴백 처리(인기 도서 추천 등)까지 하고 싶다!

5. 예외 처리

GlobalExceptionHandler를 통해 명세서에 맞춘 에러 응답을 제공하였다.

  • 400: InvalidRequestException
  • 401: UnauthorizedAccessException
  • 404: UserNotIndexedException, BookNotFoundException
  • 500: 그 외 서버 오류

이 과정에서 WebClient를 이용한 외부 서버 연동 시 타임아웃 설정이 중요하다는 것을 배웠다.두 가지 설정은 외부 서버 연동에서 필수이며, 특히 ML처럼 연산량이 많은 API에서는 서비스 안정성을 위해 반드시 적용해야 하는 설정이라고 한다!

그리고 DB 저장 전 무결성 방어 로직이 필요하다는 것을 느꼈다. 사실 다른 서비스 내에서는 많이 구현하지는 않았지만 DB 저장과 삭제에서는 이제 무결성 방어 로직을 넣어보려고 한다.

마지막으로 ML 서버는 처음 다뤄보니 폴백로직이라는 것에 대해서 처음 알았다.
실제 서비스에서는 ML 서버가 느리거나 실패하는 경우를 대비한 폴백 로직이 필요하다는 것을 깨달았다. 이 부분은 추후에 진행 예정이다!

profile
안녕하세요! 백다현입니다

0개의 댓글