Skip to content

[OT-296][FEAT]: workflow 작성 및 서버 간 통신 연결 설정#166

Merged
marulog merged 7 commits intodevelopfrom
OT-296-feature/ai-service
Mar 14, 2026
Merged

[OT-296][FEAT]: workflow 작성 및 서버 간 통신 연결 설정#166
marulog merged 7 commits intodevelopfrom
OT-296-feature/ai-service

Conversation

@marulog
Copy link
Copy Markdown
Collaborator

@marulog marulog commented Mar 13, 2026

📝 작업 내용

이번 PR에서 작업한 내용을 적어주세요

  • AI 서버 <-> 스프링 간 통신 비동기 처리
    트랜잭션이 걸려있는 상태에서 AI서버로 비동기 호출을 보내고 있기 때문에 정합성을 맞추기위해
    Spring에서 제공하는 EventListner를 사용했습니다.
  • AI서버 환경 변수 및 런타임 도커 설정 했습니다.
  • 깃 워크 플로우 작성 했습니다.

📷 스크린샷

image

☑️ 체크 리스트

체크 리스트를 확인해주세요

  • 테스트는 잘 통과했나요?
  • 충돌을 해결했나요?
  • 이슈는 등록했나요?
  • 라벨은 등록했나요?

#️⃣ 연관된 이슈

close #163

💬 리뷰 요구사항

env 변경점 있습니다. 해당 pr 머지 후 수정하겠습니다.
배포환경에서는 CI 진행 중 S3에서 모델을 다운로드 받아서 도커 내부 이미지로 저장되며
로컬환경에서는 모델을 직접 넣으셔야됩니다.
오쁠티 구글 드라이브 공유함에 모델 넣었으니 사용하실 때 참고 바랍니다 :)

추가로 AI서버 수동 배포 금지입니다.
아직 EC2 및 AWS 변수 설정 안해서 터질겁니다.

Summary by CodeRabbit

  • 새로운 기능

    • EC2로 AI 서비스를 자동 배포하는 CI 워크플로우 추가
    • 이벤트 기반 비동기 AI 태깅 처리 도입 (이벤트 발행 및 비동기 리스너)
  • 개선사항

    • AI 호출 타임아웃을 설정으로 구성 가능하도록 변경
    • 태깅 모델을 초기 로드하고 로드 실패를 로깅해 예측 신뢰성 향상
    • 비동기 처리 활성화로 백그라운드 태깅 지원
  • 구성 변경

    • 모델 경로 및 AI 관련 환경변수 추가/재배치, 기본값 필수화
    • 런타임 이미지에 모델 파일 포함 및 실행 설정 추가
  • 기타

    • 모델 아티팩트 무시 규칙을 디렉토리 단위로 조정

@marulog marulog self-assigned this Mar 13, 2026
@marulog marulog added deploy 프로젝트 배포 관련 feat 새로운 기능 구현 labels Mar 13, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 13, 2026

Walkthrough

새 GitHub Actions 배포 워크플로우를 추가하고, Spring에서 이벤트 기반 비동기 AI 태깅 흐름을 도입했으며, AI 타임아웃 및 머신 컨테이너의 모델 복사·로딩 경로를 명시화했습니다.

Changes

Cohort / File(s) Summary
CI/CD 배포 자동화
​.github/workflows/deploy-ai.yml
새 워크플로우 추가: ECR 로그인·리포지토리 보장, S3에서 모델 다운로드, Docker 빌드·푸시, AWS SSM으로 EC2에 배포·Promtail 구성 및 상태 폴링/에러 처리.
프로젝트 구성 / 컨테이너
​.gitignore, docker-compose.yml, apps/machine/Dockerfile
모델 경로 무시 범위를 디렉토리로 확장, docker-compose에 AI 환경변수 추가, Dockerfile에 모델 디렉토리 복사 및 컨테이너 실행 설정 추가.
Spring 애플리케이션 설정
apps/api-admin/src/main/java/com/ott/api_admin/ApiAdminApplication.java, apps/api-admin/src/main/resources/application.yml
@EnableAsync 추가로 비동기 처리 활성화, ai.timeout-ms 설정(30000) 추가.
AI 클라이언트 및 WebClient 구성
apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java, apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java, apps/api-admin/src/main/java/com/ott/api_admin/ai/dto/TaggingRequest.java
AiClient에 타임아웃 주입 적용; WebClientConfig에서 ai.base-url·ai.timeout-ms의 기본값 제거(필수화); DTO의 인라인 주석 제거.
이벤트 기반 태깅 도입
apps/api-admin/src/main/java/com/ott/api_admin/tagging/event/AiTaggingRequestedEvent.java, apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java, apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java, apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java
AiTaggingRequestedEvent 레코드 추가, BackOfficeContentsService에서 이벤트 발행 추가, AITaggingAsyncService를 @Async + @TransactionalEventListener(AFTER_COMMIT)으로 변경해 이벤트 기반 비동기 처리로 전환. MediaMoodTagAppend 서비스로 태그 교체 트랜잭션 분리.
머신 서비스 설정 및 모델 로딩
apps/machine/app/config.py, apps/machine/app/services/tagging.py
model_path 제거하고 AI_TAGGING_MODEL_PATH 필수화, MoodTagger가 초기화 시 모델을 로드하도록 변경(명시적 로드·오류 로깅·predict 시 로드 검증).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant BackOfficeContentsService as BackOffice
    participant EventPublisher as Publisher
    participant AITaggingAsyncService as TaggingService
    participant AiClient
    participant AIServer as AI

    Client->>BackOffice: createContentsUpload(request)
    BackOffice->>BackOffice: linkTags()
    BackOffice->>Publisher: publish(AiTaggingRequestedEvent)
    Publisher->>TaggingService: handleAiTagging(event) (AFTER_COMMIT, `@Async`)
    TaggingService->>AiClient: requestTags(description)
    AiClient->>AI: POST /tagging (configured timeout)
    AI-->>AiClient: tags response
    AiClient-->>TaggingService: return tags
    TaggingService->>TaggingService: map -> dedupe -> persist via MediaMoodTagAppend
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • yubin012
  • phonil
  • arlen02-01
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 "[OT-296][FEAT]: workflow 작성 및 서버 간 통신 연결 설정"이며, 워크플로우 추가 및 AI 서버와 Spring 간 통신 설정이라는 주요 변경사항을 명확하게 설명합니다.
Linked Issues check ✅ Passed PR은 #163에서 요구하는 학습된 ML/AI 서버와 Spring 간 연동 구현을 완전히 충족합니다. EventListener를 이용한 비동기 통신, AI 서버 환경 변수 설정, Docker 런타임 구성, CI 워크플로우 추가를 포함합니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 AI 서버 연동 구현 범위 내에 있습니다. GitHub 워크플로우, 환경 변수 설정, Docker 설정, 비동기 이벤트 리스너 구현, 트랜잭션 범위 분리 등 모두 #163의 목표와 직접 연관되어 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch OT-296-feature/ai-service
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/machine/app/services/tagging.py (1)

28-30: ⚠️ Potential issue | 🟠 Major

모델 로딩 실패를 삼키면 비정상 상태로 서비스가 기동됩니다.

지금은 예외를 로그만 남기고 넘겨서, 서버는 정상처럼 뜨지만 요청 시점에만 실패합니다. 로딩 실패 시 기동 자체를 실패시키는 편이 안전합니다.

💡 제안 수정안 (fail-fast)
         except Exception as e:
-            logger.error(f"모델 로딩 실패. 경로에 모델 파일이 있는지 확인하세요: {e}")
+            logger.exception(f"모델 로딩 실패. 경로에 모델 파일이 있는지 확인하세요: {e}")
+            raise RuntimeError(f"태깅 모델 초기화 실패: {self.model_path}") from e
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/machine/app/services/tagging.py` around lines 28 - 30, The current
except block around model loading that only calls logger.error("모델 로딩 실패...
{e}") swallows startup failures; change it to fail-fast by either re-raising the
caught exception or terminating the process after logging so the service does
not start in a broken state. Locate the except Exception as e block (the model
loading code in apps/machine/app/services/tagging.py that calls logger.error)
and replace the silent swallow with either: 1) logger.error(...); raise or 2)
logger.error(...); import sys; sys.exit(1). Optionally wrap the original
exception in a RuntimeError with contextual message before raising to preserve
the stack trace and context.
🧹 Nitpick comments (8)
apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java (1)

14-26: 타임아웃이 두 곳에서 설정되고 있습니다.

WebClientConfigresponseTimeoutAiClientMono.timeout() 모두 타임아웃을 설정합니다. 두 설정 모두 동일한 ai.timeout-ms 값을 사용하므로 기능적으로 문제는 없지만, 중복 설정입니다.

  • HttpClient.responseTimeout: 연결 수준 응답 타임아웃
  • Mono.timeout(): 리액티브 스트림 레벨 타임아웃

하나로 통일하는 것을 권장합니다. 일반적으로 HttpClient 레벨 설정만으로 충분합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java`
around lines 14 - 26, The code sets timeouts in two
places—HttpClient.responseTimeout in WebClientConfig.aiWebClient and
Mono.timeout in AiClient—causing redundant timeout behavior; remove the
reactive-level timeout by deleting or disabling the Mono.timeout call in
AiClient so only the HttpClient-level responseTimeout (configured in
WebClientConfig.aiWebClient using ai.timeout-ms) controls request timing, or if
you prefer the reactive timeout, remove the responseTimeout configuration and
rely on Mono.timeout; update AiClient (the method invoking Mono.timeout) or
WebClientConfig.aiWebClient accordingly so only one timeout mechanism remains.
.github/workflows/deploy-ai.yml (2)

149-156: Docker 컨테이너에 헬스체크가 설정되지 않았습니다.

--restart unless-stopped 옵션이 있지만, 컨테이너 헬스체크가 없어 애플리케이션이 비정상 상태에서도 재시작되지 않을 수 있습니다.

💡 헬스체크 추가 제안
-            "sudo docker run -d --name ${CONTAINER_NAME} --restart unless-stopped -p ${PORT}:${PORT} --env-file ${ENV_FILE} ${IMAGE_URI}"
+            "sudo docker run -d --name ${CONTAINER_NAME} --restart unless-stopped --health-cmd='curl -f http://localhost:${PORT}/health || exit 1' --health-interval=30s --health-timeout=10s --health-retries=3 -p ${PORT}:${PORT} --env-file ${ENV_FILE} ${IMAGE_URI}"

또는 Dockerfile에서 HEALTHCHECK 지시어를 추가하는 방법도 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/deploy-ai.yml around lines 149 - 156, Add a container
healthcheck so the deployed service can be detected and restarted when
unhealthy: modify the docker run command that uses ${IMAGE_URI} and
${CONTAINER_NAME} (the line starting with "sudo docker run -d --name
${CONTAINER_NAME} --restart unless-stopped -p ${PORT}:${PORT} --env-file
${ENV_FILE} ${IMAGE_URI}") to include appropriate --health-cmd,
--health-interval and --health-retries flags (or alternatively implement a
HEALTHCHECK in the Dockerfile used to build ${IMAGE_URI}); ensure the health
command probes the app endpoint or port used by ${PORT} and choose sensible
interval/retry values so docker can mark and restart unhealthy containers.

148-151: 오래된 Docker 이미지가 누적될 수 있습니다.

배포 시 이전 이미지를 정리하지 않아 디스크 공간이 점차 부족해질 수 있습니다.

🧹 이미지 정리 명령어 추가 제안

컨테이너 실행 후 사용하지 않는 이미지를 정리하는 명령어를 추가하세요:

             "sudo docker run -d --name ${CONTAINER_NAME} --restart unless-stopped -p ${PORT}:${PORT} --env-file ${ENV_FILE} ${IMAGE_URI}"
+            "sudo docker image prune -af --filter 'until=24h'"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/deploy-ai.yml around lines 148 - 151, The workflow
currently pulls and runs ${IMAGE_URI} but never cleans up old images, which can
fill disk over time; after the "sudo docker run -d --name ${CONTAINER_NAME} ..."
step add a cleanup command such as running a non-interactive image prune (e.g.,
docker image prune -a -f or docker system prune -af) to remove unused
images/containers/volumes, ensuring the command references the same environment
(use sudo if other docker commands use sudo) and is placed immediately after the
run to reclaim space.
apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java (1)

23-24: @RequiredArgsConstructor와 함께 필드 주입을 사용하고 있습니다.

aiWebClient은 생성자 주입을 사용하지만 timeoutMs는 필드 주입을 사용합니다. 일관성과 테스트 용이성을 위해 생성자 주입으로 통일하는 것이 좋습니다.

♻️ 생성자 주입으로 통일 제안
 `@Slf4j`
 `@Component`
-@RequiredArgsConstructor
 public class AiClient {

     private final WebClient aiWebClient;
+    private final long timeoutMs;

-    `@Value`("${ai.timeout-ms}")
-    private Long timeoutMs;
+    public AiClient(
+            WebClient aiWebClient,
+            `@Value`("${ai.timeout-ms}") long timeoutMs
+    ) {
+        this.aiWebClient = aiWebClient;
+        this.timeoutMs = timeoutMs;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java`
around lines 23 - 24, The AiClient class mixes field injection for timeoutMs
with constructor injection for aiWebClient; change timeoutMs to constructor
injection by making it a final field and include it in the generated constructor
(or explicitly add a constructor) so both aiWebClient and timeoutMs are injected
via constructor (update the field declaration for timeoutMs to be final and
remove the `@Value` field injection), ensuring `@RequiredArgsConstructor` covers it
or adding a constructor that accepts Long timeoutMs and the existing aiWebClient
dependency.
apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java (2)

97-101: 불일치 검증용 missingTags 계산 결과가 사용되지 않습니다.

주석 의도대로라면 로그에 남기거나, 사용하지 않을 거면 계산 자체를 제거하는 편이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`
around lines 97 - 101, The computed missingTags list in AITaggingAsyncService
(derived from aiTags.stream().filter(...).distinct().toList()) is not used;
either remove this unused computation or log it for DB<->AI mismatch
verification—update the code in AITaggingAsyncService to (1) if keeping, call
the logger (e.g., processLogger or class logger) to emit a clear message
including missingTags and context (aiTags and moodTagByName keys), or (2) if not
needed, delete the missingTags variable and its stream pipeline to avoid dead
code.

47-101: 컬렉션 변수명 규칙(List suffix) 일관성을 맞춰주세요.

aiTags, foundMoodTags, newMediaMoodTags, missingTags는 프로젝트 규칙상 List 접미사 형태로 맞추는 것이 좋습니다.

As per coding guidelines, "Collection variable names with List suffix".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`
around lines 47 - 101, Rename the collection variables to follow the project's
"List" suffix convention: update aiTags -> aiTagsList, foundMoodTags ->
foundMoodTagList, newMediaMoodTags -> newMediaMoodTagList, and missingTags ->
missingTagList across AITaggingAsyncService (ensure you update all references:
the for-loop index/reads, moodTagByName construction/filtering logic, new
MediaMoodTag builder usage, and all log messages that reference these variables)
so names remain consistent and compile after refactor.
apps/machine/app/config.py (1)

13-14: 주석 처리된 레거시 설정 필드는 제거해도 됩니다.

사용하지 않는 설정 흔적이 남아 있으면 실제 유효 설정 범위를 읽기 어렵습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/machine/app/config.py` around lines 13 - 14, Remove the commented legacy
config fields by deleting the two commented lines referring to
recommend_model_path and model_path in apps/machine/app/config.py; search for
any remaining references to recommend_model_path or model_path elsewhere in the
codebase (e.g., usages in code or docs) and remove or update them if unused,
then run linters/tests to ensure no unresolved references remain.
docker-compose.yml (1)

80-81: AI_TIMEOUT_MS를 환경변수 기반으로 열어두는 편이 운영에 안전합니다.

현재 고정값이라 스테이징/운영별 튜닝이 어렵습니다. 기본값을 두되 env override 가능하게 바꾸는 걸 권장합니다.

💡 제안 수정안
-      AI_TIMEOUT_MS: 30000
+      AI_TIMEOUT_MS: ${AI_TIMEOUT_MS:-30000}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 80 - 81, The AI_TIMEOUT_MS value is
hard-coded which prevents per-environment tuning; update the docker-compose
service environment so AI_TIMEOUT_MS uses Docker Compose variable substitution
with a default (so it remains 30000 if unset) and can be overridden by an
environment variable at deploy time (modify the AI_TIMEOUT_MS entry in
docker-compose.yml where AI_BASE_URL is defined to use the substitution/default
pattern).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api-admin/src/main/java/com/ott/api_admin/ApiAdminApplication.java`:
- Around line 9-16: The async handler AITaggingAsyncService.handleAiTagging
performs multiple repository operations that must be atomic; add the
`@Transactional` annotation to the handleAiTagging method (which already has
`@TransactionalEventListener` and `@Async`) so findById, findByNameInAndStatus,
deleteByMedia_Id and saveAll execute within a single transaction to prevent
partial commits on failure.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 36-38: The delete-then-insert sequence in
AITaggingAsyncService.handleAiTagging (calls to deleteByMedia_Id and saveAll) is
not executed atomically, risking tag loss if saveAll fails; extract that
delete+save logic into a dedicated transactional method (e.g.,
replaceTagsTransactional(mediaId, newTags)) annotated with `@Transactional` (or
`@Transactional`(propagation = Propagation.REQUIRES_NEW)) inside the service and
invoke that method from handleAiTagging so the delete and save run in one DB
transaction; ensure the handler only calls this transactional helper and does
not itself rely on the event listener transaction semantics.

In `@apps/api-admin/src/main/resources/application.yml`:
- Around line 61-63: The ai.timeout-ms value is hardcoded so environment
overrides (AI_TIMEOUT_MS) in docker-compose don't apply; change the YAML entry
to use a Spring env placeholder like ai.timeout-ms: ${AI_TIMEOUT_MS:30000} so
the timeout can be overridden at runtime, and verify any code that reads this
property (e.g., the component or `@ConfigurationProperties` class that reads
ai.base-url / ai.timeout-ms) expects an integer and will parse the injected
value correctly.

---

Outside diff comments:
In `@apps/machine/app/services/tagging.py`:
- Around line 28-30: The current except block around model loading that only
calls logger.error("모델 로딩 실패... {e}") swallows startup failures; change it to
fail-fast by either re-raising the caught exception or terminating the process
after logging so the service does not start in a broken state. Locate the except
Exception as e block (the model loading code in
apps/machine/app/services/tagging.py that calls logger.error) and replace the
silent swallow with either: 1) logger.error(...); raise or 2) logger.error(...);
import sys; sys.exit(1). Optionally wrap the original exception in a
RuntimeError with contextual message before raising to preserve the stack trace
and context.

---

Nitpick comments:
In @.github/workflows/deploy-ai.yml:
- Around line 149-156: Add a container healthcheck so the deployed service can
be detected and restarted when unhealthy: modify the docker run command that
uses ${IMAGE_URI} and ${CONTAINER_NAME} (the line starting with "sudo docker run
-d --name ${CONTAINER_NAME} --restart unless-stopped -p ${PORT}:${PORT}
--env-file ${ENV_FILE} ${IMAGE_URI}") to include appropriate --health-cmd,
--health-interval and --health-retries flags (or alternatively implement a
HEALTHCHECK in the Dockerfile used to build ${IMAGE_URI}); ensure the health
command probes the app endpoint or port used by ${PORT} and choose sensible
interval/retry values so docker can mark and restart unhealthy containers.
- Around line 148-151: The workflow currently pulls and runs ${IMAGE_URI} but
never cleans up old images, which can fill disk over time; after the "sudo
docker run -d --name ${CONTAINER_NAME} ..." step add a cleanup command such as
running a non-interactive image prune (e.g., docker image prune -a -f or docker
system prune -af) to remove unused images/containers/volumes, ensuring the
command references the same environment (use sudo if other docker commands use
sudo) and is placed immediately after the run to reclaim space.

In `@apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java`:
- Around line 23-24: The AiClient class mixes field injection for timeoutMs with
constructor injection for aiWebClient; change timeoutMs to constructor injection
by making it a final field and include it in the generated constructor (or
explicitly add a constructor) so both aiWebClient and timeoutMs are injected via
constructor (update the field declaration for timeoutMs to be final and remove
the `@Value` field injection), ensuring `@RequiredArgsConstructor` covers it or
adding a constructor that accepts Long timeoutMs and the existing aiWebClient
dependency.

In `@apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java`:
- Around line 14-26: The code sets timeouts in two
places—HttpClient.responseTimeout in WebClientConfig.aiWebClient and
Mono.timeout in AiClient—causing redundant timeout behavior; remove the
reactive-level timeout by deleting or disabling the Mono.timeout call in
AiClient so only the HttpClient-level responseTimeout (configured in
WebClientConfig.aiWebClient using ai.timeout-ms) controls request timing, or if
you prefer the reactive timeout, remove the responseTimeout configuration and
rely on Mono.timeout; update AiClient (the method invoking Mono.timeout) or
WebClientConfig.aiWebClient accordingly so only one timeout mechanism remains.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 97-101: The computed missingTags list in AITaggingAsyncService
(derived from aiTags.stream().filter(...).distinct().toList()) is not used;
either remove this unused computation or log it for DB<->AI mismatch
verification—update the code in AITaggingAsyncService to (1) if keeping, call
the logger (e.g., processLogger or class logger) to emit a clear message
including missingTags and context (aiTags and moodTagByName keys), or (2) if not
needed, delete the missingTags variable and its stream pipeline to avoid dead
code.
- Around line 47-101: Rename the collection variables to follow the project's
"List" suffix convention: update aiTags -> aiTagsList, foundMoodTags ->
foundMoodTagList, newMediaMoodTags -> newMediaMoodTagList, and missingTags ->
missingTagList across AITaggingAsyncService (ensure you update all references:
the for-loop index/reads, moodTagByName construction/filtering logic, new
MediaMoodTag builder usage, and all log messages that reference these variables)
so names remain consistent and compile after refactor.

In `@apps/machine/app/config.py`:
- Around line 13-14: Remove the commented legacy config fields by deleting the
two commented lines referring to recommend_model_path and model_path in
apps/machine/app/config.py; search for any remaining references to
recommend_model_path or model_path elsewhere in the codebase (e.g., usages in
code or docs) and remove or update them if unused, then run linters/tests to
ensure no unresolved references remain.

In `@docker-compose.yml`:
- Around line 80-81: The AI_TIMEOUT_MS value is hard-coded which prevents
per-environment tuning; update the docker-compose service environment so
AI_TIMEOUT_MS uses Docker Compose variable substitution with a default (so it
remains 30000 if unset) and can be overridden by an environment variable at
deploy time (modify the AI_TIMEOUT_MS entry in docker-compose.yml where
AI_BASE_URL is defined to use the substitution/default pattern).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d537002b-aa24-4eec-aac4-138d9336769b

📥 Commits

Reviewing files that changed from the base of the PR and between f49c92c and bf5657a.

📒 Files selected for processing (15)
  • .github/workflows/deploy-ai.yml
  • .gitignore
  • apps/api-admin/src/main/java/com/ott/api_admin/ApiAdminApplication.java
  • apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java
  • apps/api-admin/src/main/java/com/ott/api_admin/ai/dto/TaggingRequest.java
  • apps/api-admin/src/main/java/com/ott/api_admin/config/WebClientConfig.java
  • apps/api-admin/src/main/java/com/ott/api_admin/content/service/BackOfficeContentsService.java
  • apps/api-admin/src/main/java/com/ott/api_admin/tagging/event/AiTaggingRequestedEvent.java
  • apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java
  • apps/api-admin/src/main/resources/application.yml
  • apps/machine/Dockerfile
  • apps/machine/app/config.py
  • apps/machine/app/services/tagging.py
  • apps/machine/models/recommend/.gitkeep
  • docker-compose.yml

Comment thread apps/api-admin/src/main/resources/application.yml
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java (2)

49-103: 컬렉션 변수명에 List 접미사 누락

코딩 가이드라인에 따르면 컬렉션 변수명에 List 접미사를 사용해야 합니다. 현재 aiTags, foundMoodTags, newMediaMoodTags, missingTags 등이 해당됩니다.

예: aiTagList, foundMoodTagList, newMediaMoodTagList, missingTagList

As per coding guidelines: "Collection variable names with List suffix".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`
around lines 49 - 103, Rename the collection variables to include the List
suffix: change aiTags -> aiTagList, foundMoodTags -> foundMoodTagList,
newMediaMoodTags -> newMediaMoodTagList, and missingTags -> missingTagList;
update every usage (loop index accesses, streams, collectors, log messages,
method arguments like aiClient.getEmotionTags result assignment, moodTagByName
lookups, and the final .toList() assignment) to the new names and ensure any
related local variables or warnings (e.g., seen set usage) remain consistent so
the code compiles and behavior is unchanged.

110-114: 비동기 컨텍스트에서 BusinessException 재throw가 의미가 없습니다.

@Async 메서드에서 예외를 throw해도 호출자에게 전파되지 않습니다. AsyncUncaughtExceptionHandler가 설정되어 있지 않다면, BusinessException도 다른 예외와 동일하게 로그만 남기는 것이 일관성 있습니다.

♻️ 일관된 예외 처리 제안
-        } catch (BusinessException e) {
-            throw e;
-        } catch (Exception e) {
+        } catch (Exception e) {
             log.error("[AI Tagging] 미디어 ID: {} - 태깅 저장 중 예외 발생", mediaId, e);
         }

또는 AsyncUncaughtExceptionHandler를 구성하여 BusinessException을 별도로 처리하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`
around lines 110 - 114, In the `@Async` method inside AITaggingAsyncService,
remove the ineffective catch(BusinessException) that simply rethrows the
exception and instead handle BusinessException the same way as other exceptions
(log the error with mediaId and the exception) or configure an
AsyncUncaughtExceptionHandler to handle BusinessException centrally;
specifically, update the method that currently catches BusinessException to
either (a) delete the catch and let the generic Exception catch log the error,
or (b) consolidate both catches so BusinessException is logged via
log.error("[AI Tagging] 미디어 ID: {} - 태깅 저장 중 예외 발생", mediaId, e), or
alternatively implement/configure AsyncUncaughtExceptionHandler to process
BusinessException from async methods.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 99-103: The computed missingTags list (from aiTags filtered
against moodTagByName) is never used; add a log statement in
AITaggingAsyncService after the missingTags computation that logs the
missingTags (and context like the input id or media identifier if available)
using the existing logger (e.g., logger.warn or logger.info) so the "DB <-> AI
태깅 불일치" can be observed; ensure you reference aiTags, moodTagByName and
missingTags in the message and only log when missingTags is non-empty to avoid
noisy logs.

---

Nitpick comments:
In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 49-103: Rename the collection variables to include the List
suffix: change aiTags -> aiTagList, foundMoodTags -> foundMoodTagList,
newMediaMoodTags -> newMediaMoodTagList, and missingTags -> missingTagList;
update every usage (loop index accesses, streams, collectors, log messages,
method arguments like aiClient.getEmotionTags result assignment, moodTagByName
lookups, and the final .toList() assignment) to the new names and ensure any
related local variables or warnings (e.g., seen set usage) remain consistent so
the code compiles and behavior is unchanged.
- Around line 110-114: In the `@Async` method inside AITaggingAsyncService, remove
the ineffective catch(BusinessException) that simply rethrows the exception and
instead handle BusinessException the same way as other exceptions (log the error
with mediaId and the exception) or configure an AsyncUncaughtExceptionHandler to
handle BusinessException centrally; specifically, update the method that
currently catches BusinessException to either (a) delete the catch and let the
generic Exception catch log the error, or (b) consolidate both catches so
BusinessException is logged via log.error("[AI Tagging] 미디어 ID: {} - 태깅 저장 중 예외
발생", mediaId, e), or alternatively implement/configure
AsyncUncaughtExceptionHandler to process BusinessException from async methods.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 127e837f-60a3-46b6-847c-9029ffe0c042

📥 Commits

Reviewing files that changed from the base of the PR and between bf5657a and 286a78f.

📒 Files selected for processing (1)
  • apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java (1)

118-122: ⚠️ Potential issue | 🟡 Minor

missingTags는 아직도 계산만 하고 버려집니다.

Line 119-122에서 만든 리스트를 사용하지 않아, 주석에 적은 DB/AI 불일치 정보가 실제로 남지 않습니다. non-empty일 때 warn 로그나 metric으로 소비해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`
around lines 118 - 122, The computed List<String> missingTags in
AITaggingAsyncService is never used; after creating missingTags from aiTags and
moodTagByName, add a non-empty check and consume it: if missingTags is not
empty, emit a warn log (e.g., logger.warn("DB <-> AI tag mismatch, missing tags:
{}", missingTags)) and/or increment a metric or counter (e.g.,
metricRegistry.counter("ai.tagging.missing").increment(missingTags.size())) so
the DB/AI inconsistency is recorded and observable.
🧹 Nitpick comments (1)
apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java (1)

90-91: 리스트 타입 로컬 변수명은 List suffix로 맞춰주세요.

변경분 기준으로 newMediaMoodTags, missingTags는 둘 다 List<?> 타입이라 규칙과 어긋납니다. newMediaMoodTagList, missingTagList처럼 맞춰 두면 일관성이 유지됩니다. As per coding guidelines, 'Collection variable names with List suffix'.

Also applies to: 118-122

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`
around lines 90 - 91, The local List-typed variables do not follow the naming
guideline requiring a 'List' suffix; rename newMediaMoodTags to
newMediaMoodTagList and missingTags to missingTagList (and any other List-typed
locals in the same method/class that follow the old pattern) and update all
references/usages accordingly in AITaggingAsyncService (e.g., variable
declarations, iterations, additions, and returns) so names consistently end with
'List'.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 56-57: The event handler in AITaggingAsyncService (the method
annotated with `@TransactionalEventListener`(phase =
TransactionPhase.AFTER_COMMIT) and `@Async`) performs a blind delete+saveAll and
can be overwritten by a later/earlier concurrent event for the same mediaId;
change it to enforce per-mediaId latest-write semantics by either (A) checking
entity version/modifiedDate before applying changes: fetch current tagging
metadata (version or modifiedDate) and only perform delete+saveAll if the
event's timestamp/version is newer, or (B) serialize processing per mediaId
using a lock/queue (in-memory ConcurrentHashMap lock or a distributed lock like
Redisson or ShedLock) so only one event for a given mediaId runs at a time;
apply the same guard to the other handler occurrences mentioned (lines around
61-62 and 124-126) so stale events cannot overwrite newer results.
- Around line 56-59: The current handleAiTagging method mixes external AI calls
and DB delete/insert inside one transactional block causing potential atomicity
violations; move the aiClient.getEmotionTags call out of the `@Transactional`
handleAiTagging method so it runs before any DB transaction, then perform the
deleteByMedia_Id and saveAll DB operations inside a dedicated transactional
boundary (either use TransactionTemplate or delegate to a new `@Transactional`
method such as persistMediaTagsTransaction(mediaId, tags)) so failures roll back
the delete, or simply do not swallow non-business exceptions (rethrow them) in
the catch block so the transaction is marked for rollback; also rename
collection variables missingTags -> missingTagsList and newMediaMoodTags ->
newMediaMoodTagsList and consider adding ordering/locking or single-threaded
processing for events with the same mediaId to avoid race conditions.

---

Duplicate comments:
In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 118-122: The computed List<String> missingTags in
AITaggingAsyncService is never used; after creating missingTags from aiTags and
moodTagByName, add a non-empty check and consume it: if missingTags is not
empty, emit a warn log (e.g., logger.warn("DB <-> AI tag mismatch, missing tags:
{}", missingTags)) and/or increment a metric or counter (e.g.,
metricRegistry.counter("ai.tagging.missing").increment(missingTags.size())) so
the DB/AI inconsistency is recorded and observable.

---

Nitpick comments:
In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 90-91: The local List-typed variables do not follow the naming
guideline requiring a 'List' suffix; rename newMediaMoodTags to
newMediaMoodTagList and missingTags to missingTagList (and any other List-typed
locals in the same method/class that follow the old pattern) and update all
references/usages accordingly in AITaggingAsyncService (e.g., variable
declarations, iterations, additions, and returns) so names consistently end with
'List'.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b1e33207-6e63-4691-a246-c750aea871be

📥 Commits

Reviewing files that changed from the base of the PR and between 286a78f and 417d620.

📒 Files selected for processing (2)
  • apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java
  • apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api-admin/src/main/java/com/ott/api_admin/ai/client/AiClient.java

Copy link
Copy Markdown
Contributor

@phonil phonil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로컬에서 모델 직접 넣기? 이거 어떻게 하는지 간단하게 알려주세요

Comment thread apps/api-admin/src/main/resources/application.yml
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java (1)

59-59: 컬렉션 변수명에 List 접미사 누락

코딩 가이드라인에 따르면 컬렉션 변수명에는 List 접미사를 사용해야 합니다.

  • aiTagsaiTagList
  • foundMoodTagsfoundMoodTagList
  • newMediaMoodTagsnewMediaMoodTagList
  • missingTagsmissingTagList

As per coding guidelines: "Collection variable names with List suffix"

Also applies to: 79-79, 91-91, 119-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`
at line 59, Rename the collection variables in AITaggingAsyncService to follow
the "List" suffix guideline: change aiTags to aiTagList, foundMoodTags to
foundMoodTagList, newMediaMoodTags to newMediaMoodTagList, and missingTags to
missingTagList; update all declarations and every usage/reference within methods
(e.g., where aiTags is built/iterated or returned) so identifiers match, and
adjust any method parameters, local variables, and related log/messages to use
the new names (ensure imports/types remain List<String> and run tests/compile to
catch any missed references).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Around line 70-73: The if-block in AITaggingAsyncService (the conditional
checking aiTags == null || aiTags.isEmpty()) has over-indented statements;
adjust the indentation of the inner statements (the log.info("[AI Tagging] 미디어
ID: {} - AI가 반환한 태그가 없습니다.", event.mediaId()) and the return;) to match the
standard block indentation level for the method containing that if, ensuring
they align directly under the if and not indented further.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java`:
- Around line 17-21: The replaceMediaMoodTags method risks UNIQUE constraint
violations because mediaMoodTagRepository.deleteByMedia_Id(mediaId) may be
delayed by JPA; after the delete call either (A) make the repository delete
method explicitly `@Modifying` `@Query`(...) so it executes immediately or (B)
invoke an EntityManager.flush() (e.g., entityManager.flush()) right after delete
and before mediaMoodTagRepository.saveAll(...); keep the `@Transactional` on
replaceMediaMoodTags and then call saveAll(newMediaMoodTagsList); also rename
the parameter newMediaMoodTags to newMediaMoodTagsList (and update all
references) to follow the List suffix naming convention.

---

Nitpick comments:
In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java`:
- Line 59: Rename the collection variables in AITaggingAsyncService to follow
the "List" suffix guideline: change aiTags to aiTagList, foundMoodTags to
foundMoodTagList, newMediaMoodTags to newMediaMoodTagList, and missingTags to
missingTagList; update all declarations and every usage/reference within methods
(e.g., where aiTags is built/iterated or returned) so identifiers match, and
adjust any method parameters, local variables, and related log/messages to use
the new names (ensure imports/types remain List<String> and run tests/compile to
catch any missed references).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9ed5ec65-e2a9-4b5a-9097-e788b657e6b6

📥 Commits

Reviewing files that changed from the base of the PR and between 417d620 and 34092c7.

📒 Files selected for processing (2)
  • apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/AITaggingAsyncService.java
  • apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java

Comment on lines +17 to +21
@Transactional
public void replaceMediaMoodTags(Long mediaId, List<MediaMoodTag> newMediaMoodTags) {
mediaMoodTagRepository.deleteByMedia_Id(mediaId);
mediaMoodTagRepository.saveAll(newMediaMoodTags);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# media_mood_tag 테이블의 UNIQUE 제약 조건 확인
fd -e sql | xargs rg -i "media_mood_tag" -A 10 | rg -i "unique|constraint|index"

Repository: OpenTheTaste/backend

Length of output: 700


🏁 Script executed:

fd -e java | xargs rg "class MediaMoodTagRepository" -A 20

Repository: OpenTheTaste/backend

Length of output: 48


🏁 Script executed:

fd -e java | xargs rg "class MediaMoodTag" -A 30 | head -100

Repository: OpenTheTaste/backend

Length of output: 3156


🏁 Script executed:

cat -n apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java

Repository: OpenTheTaste/backend

Length of output: 935


🏁 Script executed:

fd -e java | xargs rg "MediaMoodTagRepository" -l

Repository: OpenTheTaste/backend

Length of output: 248


🏁 Script executed:

fd -e java | xargs rg "deleteByMedia_Id|class MediaMoodTagRepository" -B 5 -A 10

Repository: OpenTheTaste/backend

Length of output: 2084


JPA 플러시 순서 문제로 인한 제약 조건 위반 위험

deleteByMedia_Id()@Modifying 어노테이션이 없는 파생 쿼리로, JPA가 SELECT 후 DELETE를 실행합니다. 명시적 flush() 호출 없이 즉시 saveAll()을 호출하면 DELETE가 지연되어 INSERT가 먼저 실행될 수 있습니다. DB 스키마의 (media_id, mood_tag_id) UNIQUE 제약 조건으로 인해 제약 조건 위반이 발생할 수 있습니다.

🔧 해결 방법
 `@Transactional`
-public void replaceMediaMoodTags(Long mediaId, List<MediaMoodTag> newMediaMoodTags) {
+public void replaceMediaMoodTags(Long mediaId, List<MediaMoodTag> newMediaMoodTagsList) {
     mediaMoodTagRepository.deleteByMedia_Id(mediaId);
+    mediaMoodTagRepository.flush();
-    mediaMoodTagRepository.saveAll(newMediaMoodTags);
+    mediaMoodTagRepository.saveAll(newMediaMoodTagsList);
 }

또한 컬렉션 변수명은 List 접미사를 사용하세요: newMediaMoodTagsnewMediaMoodTagsList

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Transactional
public void replaceMediaMoodTags(Long mediaId, List<MediaMoodTag> newMediaMoodTags) {
mediaMoodTagRepository.deleteByMedia_Id(mediaId);
mediaMoodTagRepository.saveAll(newMediaMoodTags);
}
`@Transactional`
public void replaceMediaMoodTags(Long mediaId, List<MediaMoodTag> newMediaMoodTagsList) {
mediaMoodTagRepository.deleteByMedia_Id(mediaId);
mediaMoodTagRepository.flush();
mediaMoodTagRepository.saveAll(newMediaMoodTagsList);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-admin/src/main/java/com/ott/api_admin/tagging/service/MediaMoodTagAppend.java`
around lines 17 - 21, The replaceMediaMoodTags method risks UNIQUE constraint
violations because mediaMoodTagRepository.deleteByMedia_Id(mediaId) may be
delayed by JPA; after the delete call either (A) make the repository delete
method explicitly `@Modifying` `@Query`(...) so it executes immediately or (B)
invoke an EntityManager.flush() (e.g., entityManager.flush()) right after delete
and before mediaMoodTagRepository.saveAll(...); keep the `@Transactional` on
replaceMediaMoodTags and then call saveAll(newMediaMoodTagsList); also rename
the parameter newMediaMoodTags to newMediaMoodTagsList (and update all
references) to follow the List suffix naming convention.

Copy link
Copy Markdown
Contributor

@phonil phonil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿굿
고생하셨어요~~

@marulog marulog merged commit 8aabc55 into develop Mar 14, 2026
1 check passed
@phonil phonil deleted the OT-296-feature/ai-service branch April 4, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy 프로젝트 배포 관련 feat 새로운 기능 구현

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OT-296] [FEAT]: AI서버, Spring 통신 구현

2 participants