diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 09bc59c..e8a23ec 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -452,12 +452,51 @@ jobs: # 업로드물은 컨테이너 밖에 둔다. 볼륨이 없으면 교체할 때마다 /app/uploads 가 컨테이너와 # 함께 사라진다 — 사용자가 올린 진단서·프로필 이미지가 배포마다 전부 날아간다는 뜻이다. - # (여러 인스턴스로 늘릴 때는 S3 구현체로 바꿔야 한다. 이슈 #49) + # (STORAGE_TYPE=s3 로 MinIO·S3 를 쓰면 이 볼륨은 쓰이지 않지만, 되돌릴 때를 위해 둔다.) UPLOADS_DIR=/opt/carecode/uploads mkdir -p "$UPLOADS_DIR" + # 오브젝트 스토리지(MinIO)를 쓰는 배포라면 그 컨테이너는 앱과 생애주기가 다르다. + # 앱만 교체하고 MinIO 는 건드리지 않는다 — 데이터가 그 안에 있다. + # + # 서버 .env 에 STORAGE_TYPE=s3 와 MINIO_ROOT_USER/PASSWORD 를 두면 아래가 한 번 + # 띄우고, 이미 떠 있으면 그대로 둔다. 실측 메모리는 유휴 54MB, 100MB 업로드 후 66MB 다. + # 엔드포인트가 이 호스트를 가리킬 때만 MinIO 를 띄운다. 외부 S3·원격 MinIO 를 쓰는 + # 배포에서는 띄우지 않는다. (grep -E 는 부정 전방탐색을 지원하지 않아 case 로 쓴다.) + S3_ENDPOINT="$(grep -E '^STORAGE_S3_ENDPOINT=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2-)" + LOCAL_OBJECT_STORE=no + if grep -qE '^STORAGE_TYPE=s3$' "$ENV_FILE" 2>/dev/null; then + case "$S3_ENDPOINT" in + ""|*127.0.0.1*|*localhost*|*host.docker.internal*) LOCAL_OBJECT_STORE=yes ;; + esac + fi + + if [ "$LOCAL_OBJECT_STORE" = yes ]; then + MINIO_MEMORY="$(grep -E '^MINIO_MEMORY=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2)" + MINIO_MEMORY="${MINIO_MEMORY:-192m}" + + if ! docker ps --format '{{.Names}}' | grep -qx carecode-minio; then + echo "===== MinIO 기동 (메모리 $MINIO_MEMORY) =====" + mkdir -p /opt/carecode/minio + # 127.0.0.1 에만 바인딩한다. 오브젝트 스토리지를 외부에 열 이유가 없고, + # 앱은 같은 호스트에서 붙는다. + docker run -d --name carecode-minio --restart unless-stopped --memory="$MINIO_MEMORY" --memory-swap="$MINIO_MEMORY" -p 127.0.0.1:9000:9000 --env-file "$ENV_FILE" -v /opt/carecode/minio:/data minio/minio:RELEASE.2024-08-29T01-40-52Z server /data || { + echo "::error::MinIO 기동 실패. STORAGE_TYPE=s3 인데 저장소가 없으면 업로드가 전부 실패합니다." + exit 1 + } + else + echo "MinIO 가 이미 떠 있습니다 (앱만 교체합니다)." + fi + fi + + # host.docker.internal 을 호스트로 매핑한다. + # + # 앱은 브리지 네트워크에서 돌고, MariaDB·Redis·MinIO 는 호스트의 127.0.0.1 에 묶여 있다. + # 컨테이너 안에서 127.0.0.1 은 "그 컨테이너 자신" 이라 그 주소로는 닿지 않는다. + # 그래서 서버 .env 의 접속 주소는 host.docker.internal 이어야 한다 + # (예: jdbc:mariadb://host.docker.internal:3306/carecode). run_app() { - docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" -v "$UPLOADS_DIR:/app/uploads" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null + docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" --add-host=host.docker.internal:host-gateway -v "$UPLOADS_DIR:/app/uploads" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null } wait_healthy() { diff --git a/build.gradle b/build.gradle index 941cda6..0245b46 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,14 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' implementation 'io.micrometer:micrometer-registry-prometheus' + + // S3 호환 오브젝트 스토리지. 엔드포인트를 설정으로 받으므로 AWS S3·MinIO·R2 에 같은 코드가 붙는다. + // (netty 는 비동기 클라이언트용이다. 동기 클라이언트만 쓰므로 apache-client 하나로 충분하다.) + implementation platform('software.amazon.awssdk:bom:2.28.29') + implementation('software.amazon.awssdk:s3') { + exclude group: 'software.amazon.awssdk', module: 'netty-nio-client' + } + implementation 'software.amazon.awssdk:apache-client' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' @@ -61,6 +69,8 @@ dependencies { testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' testImplementation 'org.testcontainers:junit-jupiter:1.21.3' testImplementation 'org.testcontainers:mariadb:1.21.3' + // MinIO 컨테이너로 S3 경로를 실제로 테스트한다. AWS 자격증명 없이 돈다. + testImplementation 'org.testcontainers:minio:1.21.3' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' // 소셜 로그인(OAuth2) @@ -113,6 +123,12 @@ tasks.withType(Test).configureEach { // API 스펙 파일 갱신 스위치. Gradle 속성으로 받아 테스트 JVM 까지 넘긴다 // (-D 로 주면 Gradle JVM 에만 붙고 테스트 워커에는 전달되지 않는다). systemProperty 'openapi.lock.update', project.findProperty('openapi.lock.update') ?: 'false' + + // MinIO 테스트 이미지. 기본값은 공식 이미지이고, 그것을 받을 수 없는 환경에서는 + // -PminioImage=... 로 S3 호환 대체 이미지를 지정한다. + if (project.hasProperty('minioImage')) { + systemProperty 'minioImage', project.property('minioImage') + } } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index e77ecfe..1800fb2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,6 +102,16 @@ services: REFRESH_COOKIE_SAME_SITE: ${REFRESH_COOKIE_SAME_SITE:-Lax} STORAGE_ROOT: /app/uploads + # 저장소 종류. local 이 기본이고, `STORAGE_TYPE=s3 docker compose --profile s3 up` 으로 + # MinIO 를 함께 띄워 S3 경로를 그대로 써 볼 수 있다. + STORAGE_TYPE: ${STORAGE_TYPE:-local} + STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT:-http://minio:9000} + STORAGE_S3_BUCKET: ${STORAGE_S3_BUCKET:-carecode-uploads} + STORAGE_S3_ACCESS_KEY: ${MINIO_ROOT_USER:-carecode} + STORAGE_S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-carecode-local-dev} + # MinIO 는 버킷을 서브도메인으로 쓰려면 DNS 가 필요해서 켠다. + STORAGE_S3_PATH_STYLE: "true" + STORAGE_S3_REGION: ${STORAGE_S3_REGION:-ap-northeast-2} # `SEED_SAMPLE_DATA=true docker compose up` 처럼 셸에서 넘긴 값이 컨테이너까지 가도록 명시한다. SEED_SAMPLE_DATA: ${SEED_SAMPLE_DATA:-false} @@ -116,6 +126,46 @@ services: # 512m 에서도 뜨지만(측정 464MB) 여유가 없어 640m 로 둔다. mem_limit: ${APP_MEMORY:-640m} + # 선택: S3 호환 오브젝트 스토리지. 로컬에서 S3 경로를 AWS 자격증명 없이 써 보기 위한 것이다. + # + # STORAGE_TYPE=s3 docker compose --profile s3 up + # + # 콘솔은 http://localhost:9001 (기본 계정 carecode / carecode-local-dev). + # 실측 메모리: 유휴 54MB, 100MB 업로드 후 66MB. + minio: + image: minio/minio:RELEASE.2024-08-29T01-40-52Z + profiles: ["s3"] + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-carecode} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-carecode-local-dev} + ports: + - "${MINIO_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio-data:/data + mem_limit: ${MINIO_MEMORY:-256m} + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 3s + retries: 12 + + # 버킷을 한 번 만들어 주고 끝난다. 앱이 버킷을 만들지 않는 이유: 운영에서는 버킷 생성 권한을 + # 주지 않는 편이 안전하고, 그러면 앱이 기동할 때마다 권한 오류를 만난다. + minio-init: + image: minio/minio:RELEASE.2024-08-29T01-40-52Z + profiles: ["s3"] + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-carecode} ${MINIO_ROOT_PASSWORD:-carecode-local-dev} && + mc mb --ignore-existing local/${STORAGE_S3_BUCKET:-carecode-uploads} && + echo '버킷 준비 완료' + " + # 선택: 업로드 악성코드 검사기. 시그니처 DB 를 받느라 첫 기동에 몇 분 걸리고 메모리를 1GB 넘게 쓴다. clamav: image: clamav/clamav:1.4 @@ -126,3 +176,4 @@ services: volumes: mariadb-data: uploads: + minio-data: diff --git a/docs/features/operations.md b/docs/features/operations.md index dbce8eb..6a81723 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -308,6 +308,12 @@ ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다. 기동 단계에서 실패합니다(의도된 fail-fast). 검증 단계에서 걸리므로 **운영은 무사합니다**. 이슈 #90 - 컨테이너 이름은 `carecode` 로 통일합니다. 예전 워크플로가 만들던 `carecode-blue` / `carecode-green` 은 교체 단계에서 함께 정리합니다 +- **오브젝트 스토리지(MinIO)를 쓰려면** 서버 `.env` 에 `STORAGE_TYPE=s3` 와 + `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` 를 둡니다. 배포가 `carecode-minio` 를 한 번 + 띄우고(메모리 기본 192m, 실측 유휴 54MB·100MB 업로드 후 66MB), 이후 배포는 **앱만 + 교체합니다** — 데이터가 그 컨테이너 안에 있습니다. 엔드포인트는 + `STORAGE_S3_ENDPOINT=http://host.docker.internal:9000`, `STORAGE_S3_PATH_STYLE=true` 입니다. + 외부 S3·원격 MinIO 를 쓰면 배포는 MinIO 를 띄우지 않습니다 - **업로드물은 `/opt/carecode/uploads` 에 둡니다.** 배포가 그 디렉터리를 컨테이너의 `/app/uploads` 로 마운트합니다. 볼륨이 없으면 교체할 때마다 업로드물이 컨테이너와 함께 사라집니다 — 사용자가 올린 진단서·프로필 이미지가 **배포마다 전부 날아갑니다.** @@ -354,7 +360,21 @@ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab ### DB·Redis 준비 -배포는 앱만 교체하므로 이 둘은 미리 띄워 둡니다. 앱은 같은 호스트의 `127.0.0.1` 로 붙습니다. +배포는 앱만 교체하므로 이 둘은 미리 띄워 둡니다. + +> **접속 주소는 `host.docker.internal` 입니다.** 아래 명령은 DB·Redis 를 호스트의 +> `127.0.0.1` 에만 묶는데, 앱은 브리지 네트워크에서 돌기 때문에 **컨테이너 안의 +> `127.0.0.1` 은 그 컨테이너 자신**입니다. 그 주소로는 닿지 않습니다. +> 배포가 `--add-host=host.docker.internal:host-gateway` 를 붙이므로 서버 `.env` 에는 +> 이렇게 적습니다. +> +> ``` +> SPRING_DATASOURCE_URL=jdbc:mariadb://host.docker.internal:3306/carecode +> SPRING_DATA_REDIS_HOST=host.docker.internal +> ``` +> +> (`127.0.0.1` 로 적혀 있으면 첫 배포가 기동 단계에서 DB 연결 실패로 멈춥니다. 운영 배포가 +> 아직 한 번도 성공하지 못해 드러나지 않은 항목입니다 — 이슈 #94) ```bash docker run -d --name carecode-mariadb --restart unless-stopped --memory=320m -p 127.0.0.1:3306:3306 -e MARIADB_DATABASE=carecode -e MARIADB_USER=carecode -e MARIADB_PASSWORD=... -e MARIADB_ROOT_PASSWORD=... -e TZ=Asia/Seoul -v carecode-db:/var/lib/mysql mariadb:10.11 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --lower-case-table-names=0 --innodb-buffer-pool-size=96M --performance-schema=OFF --max-connections=30 diff --git a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java index b9a3384..dad1251 100644 --- a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java +++ b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java @@ -1,58 +1,52 @@ package com.carecode.core.storage; import com.carecode.core.exception.BusinessException; -import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.ResourceNotFoundException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.nio.file.*; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Locale; -import java.util.Set; -import java.util.UUID; - -/** 로컬 디스크 기반 파일 저장소. 단일 인스턴스 배포를 전제로 한다. */ +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 로컬 디스크 기반 파일 저장소. 단일 인스턴스 배포를 전제로 한다. + * + *
검사(크기·확장자·Content-Type·시그니처·악성코드)는 {@link UploadPolicy} 가 한다. + * 저장소 구현이 늘어도 그 검사를 건너뛸 수 없게 하기 위해서다. + * + *
여러 인스턴스로 늘리거나 서명 URL 이 필요하면 {@code app.storage.type=s3} 로 바꾼다
+ * ({@link S3FileStorageService} — S3·MinIO 둘 다 붙는다).
+ */
@Slf4j
@Service
+@ConditionalOnProperty(name = "app.storage.type", havingValue = "local", matchIfMissing = true)
public class LocalFileStorageService implements FileStorageService {
- /** 업로드를 허용할 확장자. 실행 가능한 파일이 올라가는 것을 막는다. */
- private static final Set 엔드포인트와 path-style 을 설정으로 받기 때문에 구현 하나로 S3·MinIO·R2 를 모두
+ * 쓴다. 어디에 저장하는지는 배포 환경이 정한다.
+ *
+ * 엔드포인트를 설정으로 받고 path-style 접근을 켜면 MinIO 가 그대로 동작한다. 그래서 구현을
+ * 하나만 두고, 어디에 저장하는지는 환경변수로 정한다 — 로컬·CI 는 MinIO 컨테이너, 운영은 S3 든
+ * 자체 MinIO 든 바꿔 끼운다.
+ *
+ * 검사(크기·확장자·Content-Type·시그니처·악성코드)는 {@link UploadPolicy} 가 한다.
+ * 저장 위치가 바뀌어도 받아들이는 기준은 같아야 한다.
+ *
+ * 왜 공개 URL 을 돌려주지 않는가. 건강기록 첨부는 민감정보라 주소만 알면 열리는 상태로
+ * 둘 수 없다. 그래서 {@link #store} 가 돌려주는 {@code url} 은 외부에서 바로 열 수 있는 주소가
+ * 아니라 저장 키이고, 다운로드는 소유권을 확인하는 애플리케이션 경로를 거친다
+ * ({@code GET /health/records/{recordId}/attachments/{id}/download}). 로컬 구현이
+ * {@code /files/...} 를 돌려주던 것과 같은 자리이며, 그쪽도 정적 서빙을 프로필 이미지로만
+ * 좁혀 둔 상태다.
+ */
+@Slf4j
+@Service
+@ConditionalOnProperty(name = "app.storage.type", havingValue = "s3")
+public class S3FileStorageService implements FileStorageService {
+
+ private final S3Client s3Client;
+ private final String bucket;
+ private final UploadPolicy uploadPolicy;
+
+ public S3FileStorageService(
+ S3Client s3Client,
+ @Value("${app.storage.s3.bucket}") String bucket,
+ UploadPolicy uploadPolicy) {
+ this.s3Client = s3Client;
+ this.bucket = bucket;
+ this.uploadPolicy = uploadPolicy;
+ log.info("파일 저장소 초기화(S3 호환): bucket={}", bucket);
+ }
+
+ @Override
+ public StoredFile store(MultipartFile file, String directory) {
+ UploadPolicy.AcceptedUpload accepted = uploadPolicy.accept(file, directory);
+
+ try {
+ s3Client.putObject(
+ PutObjectRequest.builder()
+ .bucket(bucket)
+ .key(accepted.key())
+ .contentType(accepted.contentType())
+ .build(),
+ // 검사한 바이트를 그대로 올린다. 스트림을 다시 열면 검사한 것과 다른 내용이 저장될 수 있다.
+ RequestBody.fromBytes(accepted.content()));
+ } catch (S3Exception e) {
+ log.error("오브젝트 저장 실패 - key={}", accepted.key(), e);
+ throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "파일 저장에 실패했습니다.");
+ }
+
+ return StoredFile.builder()
+ .key(accepted.key())
+ .url(accepted.key())
+ .originalFilename(accepted.originalFilename())
+ .contentType(accepted.contentType())
+ .size(accepted.size())
+ .build();
+ }
+
+ @Override
+ public Resource load(String key) {
+ if (key == null || key.isBlank()) {
+ throw new ResourceNotFoundException("파일을 찾을 수 없습니다.");
+ }
+
+ try {
+ ResponseBytes 이 구현은 {@link #store} 에서 {@code url} 에 키를 그대로 담으므로 변환할 것이 없다.
+ * 로컬 구현에서 S3 로 바꾸면 예전에 저장된 행은 {@code /files/...} 를 들고 있다.
+ * 그 접두사를 떼어 주지 않으면 이전 첨부를 영영 읽지 못하므로 함께 처리한다.
+ */
+ @Override
+ public String toKey(String publicUrl) {
+ if (publicUrl == null || publicUrl.isBlank()) {
+ return publicUrl;
+ }
+ return publicUrl.startsWith("/files/") ? publicUrl.substring("/files/".length()) : publicUrl;
+ }
+
+ @Override
+ public void delete(String key) {
+ if (key == null || key.isBlank()) {
+ return;
+ }
+ try {
+ s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build());
+ } catch (S3Exception e) {
+ // 삭제 실패로 요청을 깨뜨리지 않는다. 행은 이미 지워졌고, 남은 오브젝트는 가리키는
+ // 것이 없어 접근 경로가 없다. 로컬 구현과 같은 판단이다.
+ log.error("오브젝트 삭제 실패 - key={}", key, e);
+ }
+ }
+}
diff --git a/src/main/java/com/carecode/core/storage/UploadPolicy.java b/src/main/java/com/carecode/core/storage/UploadPolicy.java
new file mode 100644
index 0000000..b4a2196
--- /dev/null
+++ b/src/main/java/com/carecode/core/storage/UploadPolicy.java
@@ -0,0 +1,159 @@
+package com.carecode.core.storage;
+
+import com.carecode.core.exception.BusinessException;
+import com.carecode.core.exception.ErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * 업로드를 받아들일지 판단하고, 받아들인 바이트와 저장 키를 돌려준다.
+ *
+ * 이 검사들은 한때 {@link LocalFileStorageService} 안에 있었다. 저장소 구현이 하나뿐일 때는
+ * 문제가 없었지만, S3·MinIO 구현을 더하면 새 구현이 검사를 건너뛸 수 있다. 크기·확장자·
+ * Content-Type·시그니처·악성코드 검사는 "어디에 저장하는가" 와 무관한 규칙이므로 여기로 모았다.
+ *
+ * 구현체는 {@link #accept}가 돌려준 {@link AcceptedUpload#content()} 를 그대로 저장해야 한다.
+ * 스트림을 다시 열면 검사한 것과 다른 내용이 저장될 여지가 생긴다.
+ */
+@Slf4j
+@Component
+public class UploadPolicy {
+
+ /** 업로드를 허용할 확장자. 실행 가능한 파일이 올라가는 것을 막는다. */
+ private static final Set 목으로 검증하면 "SDK 를 올바르게 불렀는가" 만 보게 된다. path-style 접근, 없는 키의 예외
+ * 종류, 버킷 동작처럼 실제로 틀리는 것들은 서버가 있어야 드러난다.
+ *
+ * 같은 구현이 AWS S3 에도 붙는다 — 엔드포인트와 path-style 만 설정으로 다르다.
+ */
+@Testcontainers
+@DisplayName("S3 호환 저장소 (MinIO)")
+class S3FileStorageServiceTest {
+
+ private static final String BUCKET = "carecode-test";
+
+ /**
+ * MinIO 이미지. {@code minio/minio} 가 기본이지만 받을 수 없는 환경이 있어 대체 이미지를
+ * 쓸 수 있게 열어 둔다({@code -DminioImage=...} 또는 환경변수 {@code MINIO_IMAGE}).
+ * 어느 쪽이든 S3 API 는 같다.
+ */
+ private static final org.testcontainers.utility.DockerImageName MINIO_IMAGE =
+ org.testcontainers.utility.DockerImageName
+ .parse(System.getProperty("minioImage",
+ System.getenv().getOrDefault("MINIO_IMAGE", "minio/minio:RELEASE.2024-08-29T01-40-52Z")))
+ .asCompatibleSubstituteFor("minio/minio");
+
+ /**
+ * 테스트마다 컨테이너를 띄우면 느리다. 클래스 단위로 한 번 띄우고 키를 달리 쓴다.
+ * (저장 키에 UUID 가 들어가므로 테스트 간 충돌이 없다.)
+ */
+ private static MinIOContainer minio;
+ private static S3Client s3Client;
+ private static S3FileStorageService storage;
+
+ /** PNG 시그니처. 시그니처 검사를 통과해야 저장까지 간다. */
+ private static final byte[] PNG_HEADER = {
+ (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A
+ };
+
+ @BeforeAll
+ static void startMinio() {
+ try {
+ minio = new MinIOContainer(MINIO_IMAGE)
+ // 대체 이미지는 비권한 사용자로 돌아 /data 에 쓸 수 없다. 테스트 컨테이너라 root 로 띄운다.
+ .withCreateContainerCmdModifier(cmd -> cmd.withUser("0"));
+ minio.start();
+ } catch (RuntimeException e) {
+ // 이미지를 받을 수 없는 환경(사내 레지스트리 제약 등)에서 스위트 전체를 깨뜨리지 않는다.
+ // CI 는 공식 이미지를 받을 수 있으므로 거기서 실제로 검증된다.
+ minio = null;
+ Assumptions.abort("MinIO 이미지를 준비할 수 없어 건너뜁니다 (" + MINIO_IMAGE + "): " + e.getMessage());
+ }
+
+ s3Client = S3Client.builder()
+ .endpointOverride(URI.create(minio.getS3URL()))
+ .region(Region.of("us-east-1"))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(minio.getUserName(), minio.getPassword())))
+ // MinIO 는 버킷을 서브도메인으로 쓰려면 DNS 가 필요하다. 켜지 않으면 연결이 실패한다.
+ .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
+ .build();
+
+ s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
+
+ // 검사는 UploadPolicy 가 한다. 스캐너는 통과시키는 구현을 쓴다(검사 자체는 그쪽 테스트가 본다).
+ storage = new S3FileStorageService(s3Client, BUCKET,
+ new UploadPolicy(10 * 1024 * 1024, new NoOpFileScanner()));
+ }
+
+ @AfterAll
+ static void stopMinio() {
+ if (s3Client != null) {
+ s3Client.close();
+ }
+ if (minio != null) {
+ minio.stop();
+ }
+ }
+
+ private MockMultipartFile pngFile(String name) {
+ byte[] content = new byte[64];
+ System.arraycopy(PNG_HEADER, 0, content, 0, PNG_HEADER.length);
+ return new MockMultipartFile("file", name, "image/png", content);
+ }
+
+ @Test
+ @DisplayName("올리고 같은 바이트를 다시 읽는다")
+ void storeThenLoad() throws IOException {
+ StoredFile stored = storage.store(pngFile("진단서.png"), "health-records");
+
+ assertThat(stored.getKey()).startsWith("health-records/").endsWith(".png");
+ assertThat(stored.getOriginalFilename()).isEqualTo("진단서.png");
+ assertThat(stored.getSize()).isEqualTo(64);
+
+ byte[] loaded = storage.load(stored.getKey()).getContentAsByteArray();
+ assertThat(loaded).hasSize(64);
+ assertThat(java.util.Arrays.copyOf(loaded, PNG_HEADER.length)).isEqualTo(PNG_HEADER);
+ }
+
+ /**
+ * 저장 키에 원본 파일명이 들어가면 경로 조작과 충돌 위험이 있다. UUID 로 대체하는 규칙이
+ * 저장소 구현을 바꿔도 유지되는지 본다.
+ */
+ @Test
+ @DisplayName("원본 파일명은 키에 들어가지 않는다")
+ void keyDoesNotContainOriginalName() {
+ StoredFile stored = storage.store(pngFile("../../etc/passwd.png"), "health-records");
+
+ assertThat(stored.getKey()).doesNotContain("..").doesNotContain("passwd");
+ }
+
+ @Test
+ @DisplayName("없는 키는 404 로 끝낸다")
+ void missingKeyIsNotFound() {
+ assertThatThrownBy(() -> storage.load("health-records/2026/01/01/nope.png"))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("삭제하면 다시 읽히지 않는다")
+ void deleteRemovesObject() {
+ StoredFile stored = storage.store(pngFile("처방전.png"), "health-records");
+ storage.delete(stored.getKey());
+
+ assertThatThrownBy(() -> storage.load(stored.getKey()))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ /** 없는 키를 지워도 예외를 던지지 않는다 — 인터페이스가 그렇게 약속한다. */
+ @Test
+ @DisplayName("없는 키 삭제는 조용히 넘어간다")
+ void deletingMissingKeyIsSilent() {
+ storage.delete("health-records/2026/01/01/nope.png");
+ }
+
+ /**
+ * 저장 위치가 바뀌어도 받아들이는 기준은 같아야 한다. 검사를 {@link UploadPolicy} 로 뺀
+ * 이유가 이것이다 — 새 구현이 검사를 건너뛸 수 없다.
+ */
+ @Test
+ @DisplayName("확장자와 내용이 다른 파일은 S3 경로에서도 거부한다")
+ void rejectsContentMismatch() {
+ MockMultipartFile html = new MockMultipartFile(
+ "file", "evil.png", "image/png", "script".getBytes(StandardCharsets.UTF_8));
+
+ assertThatThrownBy(() -> storage.store(html, "health-records"))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("허용하지 않는 확장자는 S3 경로에서도 거부한다")
+ void rejectsDisallowedExtension() {
+ MockMultipartFile exe = new MockMultipartFile(
+ "file", "payload.exe", "application/octet-stream", new byte[] {0x4D, 0x5A});
+
+ assertThatThrownBy(() -> storage.store(exe, "health-records"))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ /**
+ * 로컬에서 S3 로 바꾸면 예전에 저장된 행은 {@code /files/...} 를 들고 있다. 그 접두사를
+ * 떼어 주지 않으면 이전 첨부를 영영 읽지 못한다.
+ */
+ @Test
+ @DisplayName("예전 /files/ URL 도 키로 되돌린다")
+ void convertsLegacyPublicUrl() {
+ String key = "health-records/2026/08/29/abc.png";
+
+ assertThat(storage.toKey("/files/" + key)).isEqualTo(key);
+ assertThat(storage.toKey(key)).isEqualTo(key);
+ }
+
+ /** 직접 올린 오브젝트도 읽는다 — 마이그레이션으로 밀어 넣은 파일이 그 경우다. */
+ @Test
+ @DisplayName("애플리케이션을 거치지 않고 올린 오브젝트도 읽는다")
+ void readsExternallyUploadedObject() throws IOException {
+ String key = "profile-images/migrated/avatar.png";
+ s3Client.putObject(
+ PutObjectRequest.builder().bucket(BUCKET).key(key).build(),
+ RequestBody.fromBytes("migrated".getBytes(StandardCharsets.UTF_8)));
+
+ assertThat(storage.load(key).getContentAsByteArray())
+ .isEqualTo("migrated".getBytes(StandardCharsets.UTF_8));
+ }
+}
+ *
+ */
+@Slf4j
+@Configuration
+@ConditionalOnProperty(name = "app.storage.type", havingValue = "s3")
+public class S3ClientConfiguration {
+
+ /**
+ * @param endpoint MinIO 등 S3 호환 서버 주소. 비우면 AWS S3 로 간다
+ * @param region AWS 리전. MinIO 는 아무 값이나 받지만 SDK 가 요구해서 넣는다
+ * @param accessKey 비우면 {@link DefaultCredentialsProvider} 를 쓴다(EC2 IAM 역할·환경변수·프로파일)
+ * @param pathStyle {@code true} 면 {@code http://host/bucket/key}. MinIO 는 가상 호스트
+ * 방식(버킷을 서브도메인으로)을 쓰려면 DNS 가 필요해서 보통 켠다
+ */
+ @Bean
+ public S3Client s3Client(
+ @Value("${app.storage.s3.endpoint:}") String endpoint,
+ @Value("${app.storage.s3.region:ap-northeast-2}") String region,
+ @Value("${app.storage.s3.access-key:}") String accessKey,
+ @Value("${app.storage.s3.secret-key:}") String secretKey,
+ @Value("${app.storage.s3.path-style:false}") boolean pathStyle) {
+
+ var builder = S3Client.builder()
+ .region(Region.of(region))
+ .serviceConfiguration(S3Configuration.builder()
+ .pathStyleAccessEnabled(pathStyle)
+ .build());
+
+ if (!endpoint.isBlank()) {
+ builder.endpointOverride(URI.create(endpoint));
+ }
+
+ // 키를 주지 않으면 SDK 의 기본 탐색을 쓴다. 운영 S3 는 EC2 IAM 역할을 쓰는 쪽이
+ // 안전하다 — 자격증명이 환경변수나 .env 에 남지 않는다.
+ if (!accessKey.isBlank() && !secretKey.isBlank()) {
+ builder.credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKey, secretKey)));
+ } else {
+ builder.credentialsProvider(DefaultCredentialsProvider.create());
+ }
+
+ log.info("S3 호환 클라이언트 초기화: endpoint={}, region={}, pathStyle={}, 정적 자격증명={}",
+ endpoint.isBlank() ? "(AWS 기본)" : endpoint, region, pathStyle, !accessKey.isBlank());
+
+ return builder.build();
+ }
+}
diff --git a/src/main/java/com/carecode/core/storage/S3FileStorageService.java b/src/main/java/com/carecode/core/storage/S3FileStorageService.java
new file mode 100644
index 0000000..5c5f7db
--- /dev/null
+++ b/src/main/java/com/carecode/core/storage/S3FileStorageService.java
@@ -0,0 +1,133 @@
+package com.carecode.core.storage;
+
+import com.carecode.core.exception.BusinessException;
+import com.carecode.core.exception.ErrorCode;
+import com.carecode.core.exception.ResourceNotFoundException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/**
+ * S3 호환 오브젝트 스토리지 저장소. AWS S3·MinIO·R2 에 같은 코드가 붙는다.
+ *
+ *
+ * 용도 설정
+ * AWS S3 엔드포인트 비움 + 리전 + IAM 역할(자격증명 생략)
+ * MinIO 엔드포인트 지정 + path-style 켜기 + 액세스 키/시크릿