Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ SPRING_PROFILES_ACTIVE=local
# 로컬 Hibernate SQL 진단이 필요할 때만 주석 해제합니다.
# JPA_SHOW_SQL=true

# MinIO (docker-compose 기본값)
# 파일 저장소 - 기본 애플리케이션 값은 local이지만 현재 Docker 개발환경은 MinIO를 명시적으로 사용합니다.
STORAGE_TYPE=minio
STORAGE_BUCKET=docgrid
# STORAGE_TYPE=local일 때만 사용하며 실제 파일은 Git에 포함되지 않습니다.
STORAGE_LOCAL_ROOT=./data/docgrid

# MinIO Adapter (docker-compose 기본값)
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin1234
MINIO_BUCKET=docgrid

# 문서 Chunk 정책 - 기본값을 쓰면 설정하지 않아도 됩니다.
# 같은 배포군의 Worker는 결정성을 위해 반드시 같은 값을 사용해야 합니다.
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,8 @@ __pycache__/
.env
.env.properties

# Local Filesystem Adapter가 저장하는 문서 원본
/data/

# 공급사 설치 파일과 라이선스는 Repository 외부에 보관한다.
/.local-vendor/
70 changes: 64 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,42 @@ cd docgrid

이후 명령은 별도 안내가 없는 한 저장소 루트에서 실행합니다.

## 파일 저장소 선택

DocGrid의 API와 인덱싱 Worker는 특정 Cloud SDK가 아니라 공통 파일 저장소 Port를 사용합니다. 현재
Local Filesystem과 MinIO Adapter를 지원하며 `STORAGE_TYPE`으로 하나만 선택합니다.

| `STORAGE_TYPE` | 용도 | 추가 설정 |
|---|---|---|
| `local` | 별도 Object Storage 없이 실행하는 기본값 | `STORAGE_LOCAL_ROOT`, `STORAGE_BUCKET` |
| `minio` | Docker 또는 외부 MinIO 사용 | `STORAGE_BUCKET`, `MINIO_ENDPOINT`, Credential |

Local Filesystem은 `STORAGE_TYPE`을 설정하지 않았을 때의 애플리케이션 기본값입니다. 실제 절대 경로는
DB에 저장하지 않고, DB에는 `LOCAL` Provider와 논리 Bucket·Object Key만 저장합니다.

```dotenv
STORAGE_TYPE=local
STORAGE_BUCKET=docgrid
STORAGE_LOCAL_ROOT=./data/docgrid
```

현재 `.env.example`은 팀의 Docker MinIO 개발 방식을 바로 실행할 수 있도록 `minio`를 명시합니다.

```dotenv
STORAGE_TYPE=minio
STORAGE_BUCKET=docgrid
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin1234
```

API와 Worker는 반드시 동일한 `STORAGE_TYPE`과 저장소 Endpoint·Bucket을 사용해야 합니다. 같은 DB
Schema를 사용하면서 서로 다른 Local Directory나 개발자별 MinIO를 바라보면 DB의 Object Key는
존재하지만 실제 파일을 찾지 못합니다.

`STORAGE_TYPE` 변경은 기존 파일을 자동으로 옮기지 않습니다. 파일이 들어 있는 DB Schema의 Provider를
바꾸려면 Object와 DB Metadata를 함께 이전하는 별도 Migration이 필요합니다.

## EC2 OpenSQL을 사용하는 로컬 실행

프론트엔드와 Spring Boot는 로컬에서 실행하고, DB만 SSH Tunnel을 통해 EC2 OpenSQL에 연결합니다.
Expand All @@ -55,10 +91,17 @@ DB_PORT=55433
DB_NAME=<development-database>
DB_USER=<development-user>
DB_PASSWORD=<development-password>
DB_SCHEMA=public
DB_SCHEMA=<development-schema>
DB_SSLMODE=disable
SPRING_PROFILES_ACTIVE=local

# EC2 OpenSQL의 개발자별 Schema와 한 환경으로 묶을 Local Docker MinIO입니다.
STORAGE_TYPE=minio
STORAGE_BUCKET=docgrid-<developer>
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin1234

# 자동 인덱싱 Worker를 로컬에서 실행합니다.
INDEXING_WORKER_ENABLED=true
INDEXING_WORKER_MAX_CONCURRENCY=1
Expand All @@ -70,8 +113,9 @@ OLLAMA_MODEL=qwen2.5:7b
`DB_SSLMODE=disable`은 DB 자체 TLS가 비활성화되어 있고 아래 SSH Tunnel로 전송 구간을 암호화하는
환경의 설정입니다. OpenSQL 서버가 TLS를 제공하면 서버 정책에 맞는 SSL Mode를 사용합니다.

MinIO 설정은 `.env.example`의 로컬 기본값을 그대로 사용할 수 있습니다. `.env`는 Git에 추가하지
않습니다.
EC2 OpenSQL과 개발자별 Local MinIO를 함께 사용할 때는 개발자마다 DB Schema와 Bucket을 분리해야
합니다. 팀 공용 Schema를 사용하려면 모든 API와 Worker가 접근할 수 있는 공용 저장소가 필요합니다.
`.env`는 Git에 추가하지 않습니다.

### 2. OpenSQL SSH Tunnel 열기

Expand Down Expand Up @@ -104,6 +148,12 @@ Docker Desktop을 실행한 뒤 로컬 인프라를 기동합니다. 이 구성
docker compose up -d --build minio embedding-server
```

`STORAGE_TYPE=local`을 선택했다면 MinIO는 실행하지 않고 BGE-M3만 기동합니다.

```bash
docker compose up -d --build embedding-server
```

BGE-M3는 첫 실행 시 약 3GB 모델을 내려받으므로 준비까지 10~15분 정도 걸릴 수 있습니다. 모델이
준비되기 전에 Spring Boot의 인덱싱 Worker를 실행하지 마세요.

Expand All @@ -116,6 +166,7 @@ docker compose logs -f embedding-server

```bash
curl -f http://localhost:8000/health
# STORAGE_TYPE=minio인 경우에만 확인합니다.
curl -f http://localhost:9000/minio/health/live
```

Expand Down Expand Up @@ -188,7 +239,7 @@ npm --prefix frontend run dev
4. 문서 내용으로 검색
5. RAG 질문에 Ollama 답변과 인용 근거가 표시되는지 확인

인덱싱은 BGE-M3와 MinIO가 필요하고, 최종 RAG 답변 생성은 Ollama가 필요합니다.
인덱싱은 BGE-M3와 선택한 파일 저장소가 필요하고, 최종 RAG 답변 생성은 Ollama가 필요합니다.

## 사용 포트

Expand All @@ -199,8 +250,8 @@ npm --prefix frontend run dev
| `55433` | 로컬 | EC2 OpenSQL로 연결되는 SSH Tunnel |
| `5432` | EC2 | OpenSQL 실제 포트 |
| `8000` | 로컬 Docker | BGE-M3 임베딩 서버 |
| `9000` | 로컬 Docker | MinIO API |
| `9001` | 로컬 Docker | MinIO Console |
| `9000` | 로컬 Docker | MinIO API(`STORAGE_TYPE=minio`) |
| `9001` | 로컬 Docker | MinIO Console(`STORAGE_TYPE=minio`) |
| `11434` | 로컬 (네이티브) | Ollama RAG LLM 서버 |

## 종료
Expand All @@ -212,6 +263,13 @@ Docker Service는 다음 명령으로 중지합니다.
docker compose stop minio embedding-server
```

Local Filesystem을 사용했다면 `embedding-server`만 중지합니다. `STORAGE_LOCAL_ROOT`의 원본 파일은
애플리케이션 종료 후에도 유지되며 Git에 포함되지 않습니다.

```bash
docker compose stop embedding-server
```

로컬 PostgreSQL 구성까지 실행했다면 `postgres`도 함께 중지합니다.

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.opensource.docgrid.domain.document.config;

import java.nio.file.Path;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import com.opensource.docgrid.domain.document.enums.StorageProvider;

import lombok.Getter;
import lombok.Setter;

/**
* 파일 저장소 Adapter 선택과 모든 구현이 공유하는 논리적 저장 위치 설정을 제공한다.
* 실제 Provider Client 생성과 I/O는 각 Adapter가 담당하며 이 설정은 도메인 분기를 만들지 않는다.
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "storage")
public class FileStorageProperties {

private StorageProvider type = StorageProvider.LOCAL;
private String bucket = "docgrid";
private Local local = new Local();
Comment thread
Gimini-3 marked this conversation as resolved.
Outdated

/**
* Local Filesystem Adapter가 Object Key를 해석할 기준 Root를 제공한다.
*/
@Getter
@Setter
public static class Local {

private Path root = Path.of("./data/docgrid");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
* 파일 오브젝트(실제 바이너리 위치) 테이블.
*
* <p>역할: 업로드된 파일 바이너리의 저장 위치와 해시를 기록한다.
* 이유: 실제 파일 바이너리는 DB에 저장하지 않고 MinIO/S3/local오브젝트 스토리지에 저장하므로,
* 그 위치(bucket/objectKey)와 무결성 검증용 해시만 이 테이블에 보관한다.
* 이유: 실제 파일 바이너리는 DB에 저장하지 않고 Local/MinIO/S3 등 외부 파일 저장소에 저장하므로,
* 그 논리 위치(bucket/objectKey)와 무결성 검증용 해시만 이 테이블에 보관한다.
* 관계: document_versions.file_object_id가 이 테이블을 참조한다(하나의 파일이 여러 버전에서 재사용될 수 있음).
* unique 제약: 동일 storage_provider/bucket/objectKey 조합과 file_hash/file_size 조합은 각각 유일해야 한다.
* index: uploaded_by에 대한 조회 인덱스를 둔다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ INSERT INTO file_objects (
created_at, updated_at
) VALUES (
:bucketName, :objectKey, :originalFilename, :contentType,
:fileSize, :fileHash, 'MINIO', :uploadedBy, CURRENT_TIMESTAMP,
:fileSize, :fileHash, :storageProvider, :uploadedBy, CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (file_hash, file_size) DO NOTHING
Expand All @@ -33,6 +33,7 @@ int insertIfAbsent(
@Param("contentType") String contentType,
@Param("fileSize") Long fileSize,
@Param("fileHash") String fileHash,
@Param("storageProvider") String storageProvider,
@Param("uploadedBy") Long uploadedBy
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
/**
* 두 개의 짧은 DB Transaction 사이에서 원본 읽기, 형식별 파싱과 Chunk 계산을 조정한다.
*
* <p>이 Service 자체에는 Transaction을 적용하지 않아 MinIO I/O와 CPU 계산 중 DB 행 잠금이
* <p>이 Service 자체에는 Transaction을 적용하지 않아 파일 저장소 I/O와 CPU 계산 중 DB 행 잠금이
* 유지되지 않게 한다. 외부 구간에는 불변 Snapshot과 Draft만 전달하고 JPA Entity는 전달하지 않는다.
*/
@Service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ private StoredFile storeCandidate(DocumentUploadRequest request, ValidatedFile v
objectKey
);
} catch (IOException e) {
log.error("MinIO 업로드용 파일 스트림을 열지 못했습니다.", e);
log.error("파일 저장소 업로드용 원본 Stream을 열지 못했습니다.", e);
throw new DocGridException(ErrorCode.FILE_STORAGE_FAILED, e);
}
}
Expand Down Expand Up @@ -101,7 +101,7 @@ private void cleanupCandidate(StoredFile candidate) {
try {
fileStorageService.delete(candidate);
} catch (RuntimeException cleanupException) {
log.error("사용되지 않은 MinIO Object 정리에 실패했습니다. bucket={}, objectKey={}",
log.error("사용되지 않은 저장소 Object 정리에 실패했습니다. bucket={}, objectKey={}",
candidate.bucketName(), candidate.objectKey(), cleanupException);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ private StoredFile storeCandidate(DocumentVersionUploadRequest request, Validate
inputStream, validatedFile.fileSize(), validatedFile.contentType(), objectKey
);
} catch (IOException exception) {
log.error("MinIO 업로드용 파일 스트림을 열지 못했습니다.", exception);
log.error("파일 저장소 업로드용 원본 Stream을 열지 못했습니다.", exception);
throw new DocGridException(ErrorCode.FILE_STORAGE_FAILED, exception);
}
}
Expand All @@ -94,7 +94,7 @@ private void cleanupCandidate(StoredFile candidate) {
try {
fileStorageService.delete(candidate);
} catch (RuntimeException cleanupException) {
log.error("사용되지 않은 MinIO Object 정리에 실패했습니다. bucket={}, objectKey={}",
log.error("사용되지 않은 저장소 Object 정리에 실패했습니다. bucket={}, objectKey={}",
candidate.bucketName(), candidate.objectKey(), cleanupException);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ public PreparationResult prepare(
return PreparationResult.work(new FileSnapshot(
documentVersion.getId(),
documentVersion.getDocument().getDocumentType(),
new StoredFile(fileObject.getBucketName(), fileObject.getObjectKey())
new StoredFile(
fileObject.getStorageProvider(),
fileObject.getBucketName(),
fileObject.getObjectKey()
)
));
}

Expand Down Expand Up @@ -231,6 +235,7 @@ private void validateSupportedFile(DocumentVersion documentVersion) {

FileObject fileObject = documentVersion.getFileObject();
if (fileObject == null
|| fileObject.getStorageProvider() == null
|| !StringUtils.hasText(fileObject.getBucketName())
|| !StringUtils.hasText(fileObject.getObjectKey())) {
throw new DocGridException(ErrorCode.DOCUMENT_FILE_REFERENCE_MISSING);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ public Resolution resolve(

int inserted = fileObjectRepository.insertIfAbsent(
storedFile.bucketName(), storedFile.objectKey(), validatedFile.originalFilename(),
validatedFile.contentType(), validatedFile.fileSize(), fileHash, userId
validatedFile.contentType(), validatedFile.fileSize(), fileHash,
storedFile.storageProvider().name(), userId
);
FileObject fileObject = fileObjectRepository.findByFileHashAndFileSize(fileHash, validatedFile.fileSize())
.orElseThrow(() -> new DocGridException(ErrorCode.FILE_OBJECT_RESOLUTION_FAILED));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,11 @@ public DocumentFileSnapshot getDocumentFileSnapshot(Long userId, Long documentId

// 2. JPA Entity 대신 외부 저장소 조회에 필요한 불변 값만 Transaction 밖으로 전달한다.
return new DocumentFileSnapshot(
new StoredFile(fileObject.getBucketName(), fileObject.getObjectKey()),
new StoredFile(
fileObject.getStorageProvider(),
fileObject.getBucketName(),
fileObject.getObjectKey()
),
currentVersion.getOriginalFilename() != null
? currentVersion.getOriginalFilename()
: fileObject.getOriginalFilename(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import java.io.InputStream;

/**
* 문서 도메인에 파일 저장·조회·삭제 기능을 제공하는 저장소 Port다.
* Provider SDK와 경로 규칙은 Adapter 내부에 한정하고 호출자는 불변 저장 위치만 전달한다.
*/
public interface FileStorageService {

StoredFile store(InputStream inputStream, long fileSize, String contentType, String objectKey);
Expand Down
Loading