Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
707e855
README.md 작성 및 DTO 네이밍 컨벤션에 맞게 리팩토링 (#239)
jihukimme Oct 1, 2025
c6b84f9
다이어그램 파일명 수정 (#240)
jihukimme Oct 1, 2025
ccc7a2c
Spring Quartz에 Cluster를 이용한 schedule, workflow 실시간 반영 (#238)
bwnfo3 Oct 2, 2025
8bc6a27
docs: 다이어그램 및 시연 영상 업로드
jihukimme Oct 2, 2025
bb32fb1
docs: 시연 영상 유튜브 링크 업로드
jihukimme Oct 2, 2025
bc755b5
docs: 시연영상 목차 추가
can019 Oct 8, 2025
1a850e2
목차 및 각 콘텐츠 앵커 링크 도입 (#242)
can019 Oct 8, 2025
29b9b4c
docs: ERD 추가 및 시퀀스 다이어그램 경로 수정 (#244)
jihukimme Oct 9, 2025
283723e
Merge branch 'main' into develop
jihukimme Oct 9, 2025
1c6963d
서버 장애 발생으로 인해 중단된 워크플로우 자동 복구 및 이어하기(Resume) 기능 구현 (#246)
jihukimme Jan 22, 2026
b6af254
refactor: 워크플로우 재개(Resume) 로직 개선 및 README 문서 보완 (#248)
jihukimme Jan 22, 2026
b92b249
Merge branch 'main' into develop
jihukimme Jan 22, 2026
6c1607d
refactor: 통신 기술 스택 변경 (RestTemplate -> RestClient) 및 E2E 테스트 안정화 (#249)
jihukimme Feb 10, 2026
63f11dc
[Refactor] 가상 스레드 도입을 통한 비동기 처리 최적화 (#251)
jihukimme Feb 13, 2026
fbda4bb
Feature/async optimization (#253)
jihukimme Feb 14, 2026
3d5a51e
refactor: update MyBatis type-aliases-package to use domain instead o…
jihukimme Feb 14, 2026
fdce465
chore: 장기실행작업(워크플로우)에 대해서만 가상스레드 설정
jihukimme Feb 14, 2026
d85e420
chore: increase HikariCP pool size and refine semaphore initializatio…
jihukimme Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 194 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
8. [주요 구성 요소 및 역할](#8-주요-구성-요소-및-역할)
9. [프로젝트 디렉토리 구조](#9-프로젝트-디렉토리-구조)
10. [환경 변수 관리 전략](#10-환경-변수-관리-전략)
11. [시연 영상](#11-시연-영상)
11. [실행 방법 (Getting Started)](#11-실행-방법-getting-started)
12. [배포 방법 (Deployment)](#12-배포-방법-deployment)
13. [시연 영상](#13-시연-영상)

---

Expand Down Expand Up @@ -223,14 +225,201 @@ pre-processing-service/

## 10. 환경 변수 관리 전략

추후 작성 예정

* **FastAPI (`pre-processing-service`)**:
* **Spring Boot (`user-service`)**:

각 서비스는 환경별 설정 관리를 위해 다음과 같은 전략을 사용합니다.



### 10.1. FastAPI (`pre-processing-service`)



`.env` 파일을 통해 환경 변수를 로드합니다. `apps/pre-processing-service/app/core/config.py`의 `BaseSettings`를 기반으로 동작합니다.



* **필수 변수**:

* `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`: MariaDB 연결 정보

* `LOKI_HOST`, `LOKI_PORT`: Loki 로깅 서버 정보

* `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`: AWS S3 저장소 설정 (OCR 및 이미지 업로드)

* **선택 변수**:

* `OPENAI_API_KEY`: AI 콘텐츠 생성 기능 사용 시 필요

* `MODE`: `dev` 또는 `prd` (기본값: `dev`)



### 10.2. Spring Boot (`user-service`)



Spring Profiles(`develop`, `production`)를 사용하여 환경을 분리합니다.



* **Local (Develop)**: `application-develop.yml`을 사용하며, 로컬 Docker 인프라(localhost)에 맞춰져 있습니다.

* **Production**: `application-production.yml`을 사용하며, 민감한 정보(DB 비밀번호, API 키 등)는 **배포 시점의 환경 변수** 또는 **Docker Compose의 environment** 설정을 통해 주입받습니다.



---

## 11. 시연 영상


## 11. 실행 방법 (Getting Started)



로컬 개발 환경에서 프로젝트를 실행하는 방법입니다.



### 11.1. 사전 준비 사항 (Prerequisites)



* **Java 21** (Amazon Corretto 21 권장)

* **Python 3.11** (Poetry 패키지 매니저 설치 필요)

* **Docker & Docker Compose**



### 11.2. 1단계: 인프라 실행 (Database & Monitoring)



프로젝트 실행에 필요한 DB(MariaDB)와 모니터링 도구(Loki, Promtail, Grafana)를 Docker로 실행합니다.



```bash

cd docker/local

docker-compose up -d

```



### 11.3. 2단계: Backend (FastAPI - Worker) 실행



Python 기반의 AI/전처리 워커 서비스를 실행합니다.



```bash

cd apps/pre-processing-service



# 1. 의존성 설치

poetry install



# 2. 서비스 실행 (Uvicorn)

poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

```



### 11.4. 3단계: Backend (Spring Boot - Orchestrator) 실행



Java 기반의 메인 오케스트레이터 서비스를 실행합니다.



```bash

cd apps/user-service



# develop 프로파일로 실행

./gradlew bootRun --args='--spring.profiles.active=develop'

```



* **API 문서 (Swagger)**:

* Spring Boot: `http://localhost:8081/swagger-ui/index.html` (설정 필요 시)

* FastAPI: `http://localhost:8000/docs`



---



## 12. 배포 방법 (Deployment)



본 프로젝트는 **GitHub Actions**를 통해 CI/CD 파이프라인이 구축되어 있습니다.



### 12.1. CI/CD 파이프라인



1. **CI (Continuous Integration)**:

* `main` 브랜치에 Push 또는 PR 시 자동으로 Java/Python 테스트 및 빌드가 수행됩니다.

2. **CD (Continuous Deployment)**:

* 릴리즈 태그 생성 시 Docker Image를 빌드하여 Docker Hub에 Push 합니다.

* AWS EC2 인스턴스에 SSH로 접속하여 최신 이미지를 Pull 하고 `docker-compose`를 재실행합니다.



### 12.2. 프로덕션 실행 설정



프로덕션 환경의 Docker 설정은 `docker/production` 디렉토리에 위치합니다.



```bash

cd docker/production

docker-compose up -d

```



---



## 13. 시연 영상

[https://www.youtube.com/watch?v=1vApNttVxVg](https://www.youtube.com/watch?v=1vApNttVxVg)
[![Video Label](http://img.youtube.com/vi/1vApNttVxVg/0.jpg)](https://www.youtube.com/watch?v=1vApNttVxVg)
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package site.icebang.common.health.service;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -14,7 +14,7 @@
@RequiredArgsConstructor
public class HealthCheckService {

private final RestTemplate restTemplate;
private final RestClient restClient;

private final FastApiProperties fastApiProperties;

Expand All @@ -24,7 +24,7 @@ public String ping() {
log.info("Attempting to connect to FastAPI server at: {}", url);

try {
return restTemplate.getForObject(url, String.class);
return restClient.get().uri(url).retrieve().body(String.class);
} catch (RestClientException e) {
log.error("Failed to connect to FastAPI server at {}. Error: {}", url, e.getMessage());
return "ERROR: Cannot connect to FastAPI";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,14 @@ public class RequestContextDto {
public static RequestContextDto forScheduler(String traceId) {
return new RequestContextDto(traceId, "scheduler", "quartz-scheduler");
}

/**
* 시스템 복구 실행용 컨텍스트를 생성하는 정적 팩토리 메서드입니다.
*
* @param traceId 기존 실행에서 사용하던 추적 ID
* @return 복구용 RequestContext 객체
*/
public static RequestContextDto forRecovery(String traceId) {
return new RequestContextDto(traceId, "system-recovery", "workflow-system-recovery");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package site.icebang.domain.workflow.mapper;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import site.icebang.domain.workflow.model.JobRun;

Expand All @@ -9,4 +10,7 @@ public interface JobRunMapper {
void insert(JobRun jobRun);

void update(JobRun jobRun);

JobRun findSuccessfulJobByWorkflowRunId(
@Param("workflowRunId") Long workflowRunId, @Param("jobId") Long jobId);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package site.icebang.domain.workflow.mapper;

import java.util.Optional;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import site.icebang.domain.workflow.model.TaskRun;

Expand All @@ -12,5 +11,6 @@ public interface TaskRunMapper {

void update(TaskRun taskRun);

Optional<TaskRun> findLatestSuccessRunInJob(Long jobRunId, String taskName);
TaskRun findSuccessfulTaskRunByWorkflowRunId(
@Param("workflowRunId") Long workflowRunId, @Param("taskName") String taskName);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package site.icebang.domain.workflow.mapper;

import java.util.List;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import site.icebang.domain.workflow.model.WorkflowRun;

Expand All @@ -9,4 +12,6 @@ public interface WorkflowRunMapper {
void insert(WorkflowRun workflowRun);

void update(WorkflowRun workflowRun);

List<WorkflowRun> findByStatus(@Param("status") String status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import site.icebang.domain.workflow.mapper.TaskIoDataMapper;
import site.icebang.domain.workflow.mapper.TaskRunMapper;
import site.icebang.domain.workflow.model.JobRun;
import site.icebang.domain.workflow.model.TaskIoData;

@Slf4j
@Service
Expand All @@ -24,30 +25,32 @@ public class WorkflowContextService {
private final ObjectMapper objectMapper;

/**
* 특정 Job 실행 내에서, 이전에 성공한 Task의 이름으로 결과(Output)를 조회합니다.
* 전체 워크플로우 실행 범위(WorkflowRun) 내에서, 이전에 성공한 Task의 이름으로 결과(Output)를 조회합니다. Resume(이어하기) 시, 이전 Job이
* 스킵되더라도 DB에서 전체 이력을 조회하여 데이터를 가져옵니다.
*
* @param jobRun 현재 실행중인 JobRun
* @param jobRun 현재 실행중인 JobRun (내부의 workflowRunId를 사용하여 전체 범위 조회)
* @param sourceTaskName 결과를 조회할 이전 Task의 이름
* @return 조회된 결과 데이터 (JsonNode)
*/
public Optional<JsonNode> getPreviousTaskOutput(JobRun jobRun, String sourceTaskName) {
Long workflowRunId = jobRun.getWorkflowRunId();
try {
return taskRunMapper
.findLatestSuccessRunInJob(jobRun.getId(), sourceTaskName)
return Optional.ofNullable(
taskRunMapper.findSuccessfulTaskRunByWorkflowRunId(workflowRunId, sourceTaskName))
.flatMap(taskRun -> taskIoDataMapper.findOutputByTaskRunId(taskRun.getId()))
.map(
ioData -> {
try {
return objectMapper.readTree(ioData.getDataValue());
} catch (Exception e) {
log.error("TaskIoData JSON 파싱 실패: TaskIoDataId={}", ioData.getId(), e);
return null;
}
});
.map(this::parseJson);
} catch (Exception e) {
log.error(
"이전 Task 결과 조회 중 오류 발생: JobRunId={}, TaskName={}", jobRun.getId(), sourceTaskName, e);
log.error("워크플로우 데이터 조회 실패: WorkflowRunId={}, TaskName={}", workflowRunId, sourceTaskName, e);
return Optional.empty();
}
}

private JsonNode parseJson(TaskIoData ioData) {
try {
return objectMapper.readTree(ioData.getDataValue());
} catch (Exception e) {
log.error("TaskIoData JSON 파싱 실패: TaskIoDataId={}", ioData.getId(), e);
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ public void executeWorkflow(Long workflowId, RequestContextDto context) {
for (JobDto jobDto : jobDtos) {
Job job = new Job(jobDto);
mdcManager.setJobContext(job.getId());

// 📌 이미 성공한 Job인지 확인하여 중복 실행 방지 (Resume 기능)
JobRun existingSuccessfulJob =
jobRunMapper.findSuccessfulJobByWorkflowRunId(workflowRun.getId(), job.getId());
if (existingSuccessfulJob != null) {
workflowLogger.info(
"---------- Job 스킵 (이미 성공함): JobId={}, PreviousJobRunId={} ----------",
job.getId(),
existingSuccessfulJob.getId());
continue;
}

JobRun jobRun = JobRun.start(workflowRun.getId(), job.getId());
jobRunMapper.insert(jobRun);
workflowLogger.info(
Expand Down
Loading
Loading