From 01fcfc6fb31dec89c3c443ff2878ca0e92c6e187 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Fri, 4 Sep 2026 03:26:08 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20Azure=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20legacy-azure=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/{ => legacy-azure}/azure-portal-guide.md | 0 infra/{ => legacy-azure}/azure-setup.sh | 0 infra/{ => legacy-azure}/custom-domain.sh | 0 infra/{ => legacy-azure}/deploy-manual.sh | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename infra/{ => legacy-azure}/azure-portal-guide.md (100%) rename infra/{ => legacy-azure}/azure-setup.sh (100%) rename infra/{ => legacy-azure}/custom-domain.sh (100%) rename infra/{ => legacy-azure}/deploy-manual.sh (100%) diff --git a/infra/azure-portal-guide.md b/infra/legacy-azure/azure-portal-guide.md similarity index 100% rename from infra/azure-portal-guide.md rename to infra/legacy-azure/azure-portal-guide.md diff --git a/infra/azure-setup.sh b/infra/legacy-azure/azure-setup.sh similarity index 100% rename from infra/azure-setup.sh rename to infra/legacy-azure/azure-setup.sh diff --git a/infra/custom-domain.sh b/infra/legacy-azure/custom-domain.sh similarity index 100% rename from infra/custom-domain.sh rename to infra/legacy-azure/custom-domain.sh diff --git a/infra/deploy-manual.sh b/infra/legacy-azure/deploy-manual.sh similarity index 100% rename from infra/deploy-manual.sh rename to infra/legacy-azure/deploy-manual.sh From d90223b18a7c671e32a1ffb8ba6072fef1b6ef2a Mon Sep 17 00:00:00 2001 From: yoonseo Date: Fri, 4 Sep 2026 03:26:35 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=94=A7chore:=20Terraform=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20.gitignore=EC=97=90?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20CI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++++ .gitignore | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 490ed6d..50c67e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,10 @@ name: CI on: pull_request: branches: [dev, main] + paths-ignore: + - "infra/**" + - "docs/**" + - "**.md" # 동일 PR에서 새 푸시 시 이전 실행 취소 concurrency: diff --git a/.gitignore b/.gitignore index 124f493..5d5b0d0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,18 @@ docs/ ### Logs ### logs/ -*.log \ No newline at end of file +*.log + +### Terraform ### +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfvars +*.tfvars.json +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +.terraformrc +terraform.rc \ No newline at end of file From ba02a298a3aa2a06ee94a6fdde72bcc69586f330 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sat, 5 Sep 2026 15:14:33 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=94=A7chore:=20Terraform=20plan=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20.gitignore=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5d5b0d0..dbab077 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,7 @@ override.tf override.tf.json *_override.tf .terraformrc -terraform.rc \ No newline at end of file +terraform.rc +tfplan +*.tfplan +plan.out \ No newline at end of file From 8315a8e7a401ea3933a07e9a1b78e606c7314b7f Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sat, 5 Sep 2026 15:15:45 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=94=A7chore:=20Terraform=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20AWS=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/.terraform.lock.hcl | 46 +++ infra/terraform/compute.tf | 453 ++++++++++++++++++++++++++++ infra/terraform/edge.tf | 135 +++++++++ infra/terraform/main.tf | 55 ++++ infra/terraform/network.tf | 120 ++++++++ infra/terraform/outputs.tf | 52 ++++ infra/terraform/storage.tf | 123 ++++++++ infra/terraform/variables.tf | 67 ++++ 8 files changed, 1051 insertions(+) create mode 100644 infra/terraform/.terraform.lock.hcl create mode 100644 infra/terraform/compute.tf create mode 100644 infra/terraform/edge.tf create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/network.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/storage.tf create mode 100644 infra/terraform/variables.tf diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..4a059b2 --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,46 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.60" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.6" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/infra/terraform/compute.tf b/infra/terraform/compute.tf new file mode 100644 index 0000000..f140da7 --- /dev/null +++ b/infra/terraform/compute.tf @@ -0,0 +1,453 @@ +# ══ IAM ══════════════════════════════════════════════════════ +data "aws_iam_policy_document" "ec2_assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "ecs_tasks_assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "instance" { + name = "${local.name_prefix}-instance-role" + assume_role_policy = data.aws_iam_policy_document.ec2_assume.json +} + +resource "aws_iam_role_policy_attachment" "instance_managed" { + for_each = toset([ + "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role", + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", # SSH 키 없이 접속 + ]) + + role = aws_iam_role.instance.name + policy_arn = each.value +} + +# 부팅 시 자기 자신에게 EIP 를 붙이기 위한 권한 +data "aws_iam_policy_document" "instance_eip" { + statement { + actions = ["ec2:AssociateAddress", "ec2:DescribeAddresses"] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "instance_eip" { + name = "associate-eip" + role = aws_iam_role.instance.id + policy = data.aws_iam_policy_document.instance_eip.json +} + +resource "aws_iam_instance_profile" "instance" { + name = "${local.name_prefix}-instance-profile" + role = aws_iam_role.instance.name +} + +resource "aws_iam_role" "task_execution" { + name = "${local.name_prefix}-task-execution-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json +} + +resource "aws_iam_role_policy_attachment" "task_execution_managed" { + role = aws_iam_role.task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +data "aws_iam_policy_document" "task_execution_ssm" { + statement { + actions = ["ssm:GetParameters"] + resources = concat( + [for p in aws_ssm_parameter.app : p.arn], + [aws_ssm_parameter.db_password_seed.arn], + ) + } +} + +resource "aws_iam_role_policy" "task_execution_ssm" { + name = "read-app-secrets" + role = aws_iam_role.task_execution.id + policy = data.aws_iam_policy_document.task_execution_ssm.json +} + +# 앱 코드가 쓰는 권한. 액세스 키 불필요. +resource "aws_iam_role" "task" { + name = "${local.name_prefix}-task-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json +} + +data "aws_iam_policy_document" "task_s3" { + statement { + actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + resources = ["${aws_s3_bucket.images.arn}/*"] + } + statement { + actions = ["s3:ListBucket"] + resources = [aws_s3_bucket.images.arn] + } +} + +resource "aws_iam_role_policy" "task_s3" { + name = "images-bucket-access" + role = aws_iam_role.task.id + policy = data.aws_iam_policy_document.task_s3.json +} + +# ── GitHub Actions OIDC ────────────────────── +# GitHub OIDC 공급자는 URL 당 계정에 하나만 존재할 수 있다. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "github_assume" { + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:${var.github_repository}:ref:refs/heads/main"] + } + } +} + +resource "aws_iam_role" "github_actions" { + name = "${local.name_prefix}-github-actions" + assume_role_policy = data.aws_iam_policy_document.github_assume.json +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + actions = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:PutImage", + ] + resources = ["*"] + } + + statement { + actions = ["ecs:UpdateService", "ecs:DescribeServices", "ecs:RegisterTaskDefinition"] + resources = ["*"] + } + + statement { + actions = ["iam:PassRole"] + resources = [aws_iam_role.task_execution.arn, aws_iam_role.task.arn] + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = "deploy" + role = aws_iam_role.github_actions.id + policy = data.aws_iam_policy_document.github_deploy.json +} + +# ══ ECR ══════════════════════════════════════════════════════ +resource "aws_ecr_repository" "app" { + for_each = toset(["backend", "ai"]) + + name = "${local.name_prefix}-${each.key}" + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { scan_on_push = true } +} + +resource "aws_ecr_lifecycle_policy" "app" { + for_each = aws_ecr_repository.app + repository = each.value.name + + policy = jsonencode({ + rules = [{ + rulePriority = 1 + description = "최근 10개만 보관" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = 10 + } + action = { type = "expire" } + }] + }) +} + +# ══ ECS 클러스터 + 캐패시티 ═══════════════════════════════════ +data "aws_ssm_parameter" "ecs_ami" { + name = "/aws/service/ecs/optimized-ami/amazon-linux-2023/arm64/recommended/image_id" +} + +resource "aws_ecs_cluster" "main" { + name = "${local.name_prefix}-cluster" + + setting { + name = "containerInsights" + value = "disabled" # 비용 절감 + } +} + +resource "aws_launch_template" "ecs" { + name_prefix = "${local.name_prefix}-lt-" + image_id = data.aws_ssm_parameter.ecs_ami.value + instance_type = var.instance_type + + iam_instance_profile { arn = aws_iam_instance_profile.instance.arn } + + vpc_security_group_ids = [aws_security_group.instance.id] + + block_device_mappings { + device_name = "/dev/xvda" + ebs { + # ECS 최적화 AL2023 ARM64 AMI 의 루트 스냅샷이 30GB 라 그 아래로는 못 줄인다 + volume_size = 30 + volume_type = "gp3" + encrypted = true + } + } + + user_data = base64encode(<<-EOT + #!/bin/bash + echo "ECS_CLUSTER=${aws_ecs_cluster.main.name}" >> /etc/ecs/ecs.config + + TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 60") + IID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ + http://169.254.169.254/latest/meta-data/instance-id) + aws ec2 associate-address --region ${var.region} \ + --instance-id "$IID" \ + --allocation-id ${aws_eip.origin.id} \ + --allow-reassociation + EOT + ) + + lifecycle { create_before_destroy = true } +} + +resource "aws_autoscaling_group" "ecs" { + name = "${local.name_prefix}-asg" + vpc_zone_identifier = [for s in aws_subnet.public : s.id] + + min_size = 1 + max_size = 2 + desired_capacity = 1 + + launch_template { + id = aws_launch_template.ecs.id + version = "$Latest" + } + + protect_from_scale_in = true + + tag { + key = "AmazonECSManaged" + value = "true" + propagate_at_launch = true + } + + lifecycle { + ignore_changes = [desired_capacity] + } +} + +# 이 블록이 "런칭 때 Fargate 전환" 의 접점 +resource "aws_ecs_capacity_provider" "ec2" { + name = "${local.name_prefix}-cp-ec2" + + auto_scaling_group_provider { + auto_scaling_group_arn = aws_autoscaling_group.ecs.arn + managed_termination_protection = "ENABLED" + + managed_scaling { + status = "ENABLED" + target_capacity = 100 + } + } +} + +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + capacity_providers = [aws_ecs_capacity_provider.ec2.name] + + default_capacity_provider_strategy { + capacity_provider = aws_ecs_capacity_provider.ec2.name + weight = 100 + } +} + +# ══ ECS 태스크 + 서비스 ══════════════════════════════════════ +resource "aws_cloudwatch_log_group" "app" { + for_each = toset(["backend", "ai"]) + + name = "/ecs/${local.name_prefix}/${each.key}" + retention_in_days = 7 # 기본값은 무기한 — 반드시 지정 +} + +resource "aws_ecs_task_definition" "app" { + family = "${local.name_prefix}-app" + + # host 모드: 두 컨테이너가 호스트 네트워크 공유 → backend 가 localhost:8000 으로 ai 호출. + # awsvpc 는 태스크 ENI 에 퍼블릭 IP 를 못 붙여 NAT($33/월)가 필요해진다. + # 트레이드오프: 한 인스턴스에 같은 태스크 2개 불가. Fargate 전환 시 awsvpc + NAT 필요. + network_mode = "host" + requires_compatibilities = ["EC2"] + cpu = "1536" + memory = "1408" + + execution_role_arn = aws_iam_role.task_execution.arn + task_role_arn = aws_iam_role.task.arn + + runtime_platform { + cpu_architecture = "ARM64" + operating_system_family = "LINUX" + } + + container_definitions = jsonencode([ + { + name = "ai" + image = "${aws_ecr_repository.app["ai"].repository_url}:latest" + essential = true + memory = local.ai_memory + + environment = [ + { name = "PORT", value = "8000" }, + ] + + secrets = [ + { name = "OPENAI_API_KEY", valueFrom = aws_ssm_parameter.app["openai_api_key"].arn }, + { name = "CLOVA_OCR_SECRET", valueFrom = aws_ssm_parameter.app["clova_ocr_secret"].arn }, + { name = "CLOVA_OCR_URL", valueFrom = aws_ssm_parameter.app["clova_ocr_url"].arn }, + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.app["ai"].name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "ai" + } + } + + # ⚠ 이미지에 curl 이 설치되어 있어야 한다 (Dockerfile 확인) + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + } + }, + { + name = "backend" + image = "${aws_ecr_repository.app["backend"].repository_url}:latest" + essential = true + memory = local.backend_memory + + dependsOn = [{ containerName = "ai", condition = "HEALTHY" }] + + environment = [ + { name = "SPRING_PROFILES_ACTIVE", value = "prod" }, + { name = "SERVER_PORT", value = "8080" }, + { name = "AI_SERVICE_BASE_URL", value = "http://localhost:8000" }, + { name = "S3_BUCKET", value = aws_s3_bucket.images.bucket }, + { name = "AWS_REGION", value = var.region }, + { name = "JAVA_TOOL_OPTIONS", value = "-XX:MaxRAMPercentage=60" }, + { name = "DB_HOST", value = aws_db_instance.main.address }, + { name = "CORS_ALLOWED_ORIGINS", value = "https://${var.domain_name}" }, + ] + + secrets = [ + { name = "DB_PASSWORD", valueFrom = aws_ssm_parameter.app["db_password"].arn }, + { name = "JWT_SECRET", valueFrom = aws_ssm_parameter.app["jwt_secret"].arn }, + { name = "GOOGLE_CLIENT_ID", valueFrom = aws_ssm_parameter.app["google_client_id"].arn }, + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.app["backend"].name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "backend" + } + } + + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } + }, + ]) +} + +resource "aws_ecs_service" "app" { + name = "${local.name_prefix}-app" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = 1 + + # 롤링 업데이트 = 나중에 ALB 를 서비스 재생성 없이 붙일 수 있는 전제 + deployment_controller { type = "ECS" } + + # 인스턴스 1대 + host 모드 → 교체형 배포 + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + capacity_provider_strategy { + capacity_provider = aws_ecs_capacity_provider.ec2.name + weight = 100 + } + + lifecycle { + ignore_changes = [task_definition] + } + + depends_on = [aws_ecs_cluster_capacity_providers.main] +} + +# ══ 예산 알림 ═════════════════════════════════════════════════ +resource "aws_budgets_budget" "monthly" { + name = "${local.name_prefix}-monthly" + budget_type = "COST" + limit_amount = tostring(var.monthly_budget_usd) + limit_unit = "USD" + time_unit = "MONTHLY" + + notification { + comparison_operator = "GREATER_THAN" + threshold = 80 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = [var.alert_email] + } + + notification { + comparison_operator = "GREATER_THAN" + threshold = 100 + threshold_type = "PERCENTAGE" + notification_type = "FORECASTED" + subscriber_email_addresses = [var.alert_email] + } +} \ No newline at end of file diff --git a/infra/terraform/edge.tf b/infra/terraform/edge.tf new file mode 100644 index 0000000..4e6fc53 --- /dev/null +++ b/infra/terraform/edge.tf @@ -0,0 +1,135 @@ +# ── 오리진 고정 IP ─────────────────────────────────────────── +# 인스턴스가 교체돼도 오리진 주소가 안 깨지도록 EIP 를 쓴다. +# 실제 연결은 launch_template 의 user_data 가 부팅 시 수행한다. +resource "aws_eip" "origin" { + domain = "vpc" + tags = { Name = "${local.name_prefix}-eip-origin" } +} + +# ── ACM: CloudFront 용은 반드시 us-east-1 ──────────────────── +# 루트 도메인(han-spoon.site)은 Vercel 프론트엔드가 쓰므로 api 서브도메인만 발급. +resource "aws_acm_certificate" "cdn" { + provider = aws.us_east_1 + domain_name = local.api_fqdn + validation_method = "DNS" + + lifecycle { create_before_destroy = true } +} + +# DNS 권한이 Vercel 에 있어 검증 레코드는 수동 등록한다. +# → terraform output acm_validation_record 로 값을 확인해 Vercel DNS 에 넣을 것. +# 나중에 Route 53 으로 이관하면 aws_route53_record + aws_acm_certificate_validation 을 되살린다. + +# ── CloudFront ─────────────────────────────────────────────── +resource "random_password" "origin_secret" { + length = 40 + special = false +} + +# 하드코딩된 정책 ID 대신 이름으로 조회 +data "aws_cloudfront_cache_policy" "disabled" { + name = "Managed-CachingDisabled" +} + +data "aws_cloudfront_cache_policy" "optimized" { + name = "Managed-CachingOptimized" +} + +data "aws_cloudfront_origin_request_policy" "all_viewer" { + name = "Managed-AllViewer" +} + +resource "aws_cloudfront_origin_access_control" "s3" { + name = "${local.name_prefix}-oac-s3" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_cloudfront_distribution" "main" { + enabled = true + aliases = [local.api_fqdn] + price_class = "PriceClass_200" # 아시아 포함, 남미/아프리카 제외 + + origin { + origin_id = "api" + + # Route 53 레코드 대신 EIP 의 퍼블릭 DNS 를 직접 쓴다. + # EIP 가 고정이므로 인스턴스가 교체돼도 이 이름은 유지된다. + domain_name = aws_eip.origin.public_dns + + custom_origin_config { + http_port = 8080 # backend 컨테이너가 host 모드로 바인딩하는 포트 + https_port = 443 + origin_protocol_policy = "http-only" # Caddy 로 오리진 TLS 붙이면 https-only + origin_ssl_protocols = ["TLSv1.2"] + origin_read_timeout = 60 # OCR 파이프라인이 길다 (기본 30s) + } + + custom_header { + name = "X-Origin-Secret" + value = random_password.origin_secret.result + } + } + + origin { + origin_id = "images" + domain_name = aws_s3_bucket.images.bucket_regional_domain_name + origin_access_control_id = aws_cloudfront_origin_access_control.s3.id + } + + default_cache_behavior { + target_origin_id = "api" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + + cache_policy_id = data.aws_cloudfront_cache_policy.disabled.id + origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id + } + + ordered_cache_behavior { + path_pattern = "/images/*" + target_origin_id = "images" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + + cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id + } + + restrictions { + geo_restriction { restriction_type = "none" } + } + + viewer_certificate { + # 인증서가 ISSUED 상태여야 배포가 생성된다 → 2단계 apply 필요 + acm_certificate_arn = aws_acm_certificate.cdn.arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } +} + +# S3 버킷은 이 CloudFront 배포만 읽을 수 있다 +data "aws_iam_policy_document" "images_cdn" { + statement { + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.images.arn}/*"] + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.main.arn] + } + } +} + +resource "aws_s3_bucket_policy" "images" { + bucket = aws_s3_bucket.images.id + policy = data.aws_iam_policy_document.images_cdn.json +} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..c418109 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,55 @@ +terraform { + required_version = ">= 1.10" + + required_providers { + aws = { source = "hashicorp/aws", version = "~> 5.60" } + random = { source = "hashicorp/random", version = "~> 3.6" } + } + + backend "s3" { + bucket = "hanspoon-tfstate" + key = "prod/terraform.tfstate" + region = "ap-northeast-2" + encrypt = true + use_lockfile = true + } +} + +provider "aws" { + region = var.region + default_tags { tags = local.common_tags } +} + +provider "aws" { + alias = "us_east_1" + region = "us-east-1" + default_tags { tags = local.common_tags } +} + +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + name_prefix = "${var.project}-${var.env}" + api_fqdn = "${var.api_subdomain}.${var.domain_name}" + + common_tags = { + Project = var.project + Env = var.env + ManagedBy = "terraform" + } + + azs = slice(data.aws_availability_zones.available.names, 0, 2) + + public_subnets = { + for idx, az in local.azs : az => { cidr = cidrsubnet(var.vpc_cidr, 8, idx), az = az } + } + + private_subnets = { + for idx, az in local.azs : az => { cidr = cidrsubnet(var.vpc_cidr, 8, idx + 10), az = az } + } + + backend_memory = 640 + ai_memory = 768 +} \ No newline at end of file diff --git a/infra/terraform/network.tf b/infra/terraform/network.tf new file mode 100644 index 0000000..0954ccd --- /dev/null +++ b/infra/terraform/network.tf @@ -0,0 +1,120 @@ +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { Name = "${local.name_prefix}-vpc" } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + tags = { Name = "${local.name_prefix}-igw" } +} + +# 퍼블릭: ECS 컨테이너 인스턴스. +resource "aws_subnet" "public" { + for_each = local.public_subnets + + vpc_id = aws_vpc.main.id + cidr_block = each.value.cidr + availability_zone = each.value.az + map_public_ip_on_launch = true + + tags = { Name = "${local.name_prefix}-public-${each.key}" } +} + +# 프라이빗: RDS 전용. +resource "aws_subnet" "private" { + for_each = local.private_subnets + + vpc_id = aws_vpc.main.id + cidr_block = each.value.cidr + availability_zone = each.value.az + + tags = { Name = "${local.name_prefix}-private-${each.key}" } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { Name = "${local.name_prefix}-rt-public" } +} + +resource "aws_route_table_association" "public" { + for_each = aws_subnet.public + + subnet_id = each.value.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + tags = { Name = "${local.name_prefix}-rt-private" } +} + +resource "aws_route_table_association" "private" { + for_each = aws_subnet.private + + subnet_id = each.value.id + route_table_id = aws_route_table.private.id +} + +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [aws_route_table.public.id, aws_route_table.private.id] + + tags = { Name = "${local.name_prefix}-vpce-s3" } +} + +# ── 보안 그룹 ──────────────────────────────────────────────── +data "aws_ec2_managed_prefix_list" "cloudfront" { + name = "com.amazonaws.global.cloudfront.origin-facing" +} + +resource "aws_security_group" "instance" { + name = "${local.name_prefix}-sg-instance" + description = "ECS container instance" + vpc_id = aws_vpc.main.id + + tags = { Name = "${local.name_prefix}-sg-instance" } +} + +resource "aws_vpc_security_group_ingress_rule" "instance_from_cloudfront" { + security_group_id = aws_security_group.instance.id + description = "HTTP(8080) from CloudFront edges only" + prefix_list_id = data.aws_ec2_managed_prefix_list.cloudfront.id + ip_protocol = "tcp" + from_port = 8080 + to_port = 8080 +} + +# 아웃바운드: CLOVA OCR, LLM API, ECR, SSM 호출용 +resource "aws_vpc_security_group_egress_rule" "instance_all" { + security_group_id = aws_security_group.instance.id + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_security_group" "rds" { + name = "${local.name_prefix}-sg-rds" + description = "PostgreSQL from ECS instances only" + vpc_id = aws_vpc.main.id + + tags = { Name = "${local.name_prefix}-sg-rds" } +} + +# SG 를 참조하게 함. +resource "aws_vpc_security_group_ingress_rule" "rds_from_instance" { + security_group_id = aws_security_group.rds.id + referenced_security_group_id = aws_security_group.instance.id + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 +} \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..2ccf248 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,52 @@ +output "cloudfront_domain" { + description = "CloudFront 배포 도메인" + value = aws_cloudfront_distribution.main.domain_name +} + +output "origin_eip" { + description = "EC2 오리진 고정 IP" + value = aws_eip.origin.public_ip +} + +output "rds_endpoint" { + description = "RDS 엔드포인트 (application-prod.yml 의 DB_HOST)" + value = aws_db_instance.main.address +} + +output "ecr_repository_urls" { + description = "GitHub Actions 가 push 할 대상" + value = { for k, v in aws_ecr_repository.app : k => v.repository_url } +} + +output "github_actions_role_arn" { + description = "워크플로의 role-to-assume 에 넣을 값" + value = aws_iam_role.github_actions.arn +} + +output "s3_bucket_name" { + description = "이미지 버킷" + value = aws_s3_bucket.images.bucket +} + +output "origin_secret" { + description = "오리진이 검증할 X-Origin-Secret 헤더 값" + value = random_password.origin_secret.result + sensitive = true +} +# ── DNS 수동 등록용 (Vercel 대시보드에 입력) ────────────────── +output "acm_validation_record" { + description = "1단계: Vercel DNS 에 등록할 인증서 검증 CNAME" + value = { + for dvo in aws_acm_certificate.cdn.domain_validation_options : + dvo.domain_name => { + type = dvo.resource_record_type + name = dvo.resource_record_name + value = dvo.resource_record_value + } + } +} + +output "api_dns_record" { + description = "2단계: Vercel DNS 에 등록할 API CNAME" + value = "${local.api_fqdn} CNAME ${aws_cloudfront_distribution.main.domain_name}" +} diff --git a/infra/terraform/storage.tf b/infra/terraform/storage.tf new file mode 100644 index 0000000..744b3ba --- /dev/null +++ b/infra/terraform/storage.tf @@ -0,0 +1,123 @@ +# ── S3: 메뉴 이미지 ────────────────────────────────────────── +resource "random_id" "bucket_suffix" { + byte_length = 4 +} + +resource "aws_s3_bucket" "images" { + bucket = "${local.name_prefix}-images-${random_id.bucket_suffix.hex}" + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_versioning" "images" { + bucket = aws_s3_bucket.images.id + versioning_configuration { status = "Enabled" } +} + +resource "aws_s3_bucket_public_access_block" "images" { + bucket = aws_s3_bucket.images.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "images" { + bucket = aws_s3_bucket.images.id + + # 스캔 원본은 90일 후 삭제. + rule { + id = "expire-scan-originals" + status = "Enabled" + filter { prefix = "scans/" } + expiration { days = 90 } + } + + # 대표 메뉴 이미지는 영구 보관, 옛 버전은 정리 + rule { + id = "cleanup-old-versions" + status = "Enabled" + filter { prefix = "menus/" } + noncurrent_version_expiration { noncurrent_days = 30 } + } +} + +# ── RDS: PostgreSQL 16 ─────────────────────────────────────── +resource "aws_db_subnet_group" "main" { + name = "${local.name_prefix}-db-subnet" + subnet_ids = [for s in aws_subnet.private : s.id] +} + +resource "random_password" "db" { + length = 32 + special = false +} + +resource "aws_db_instance" "main" { + identifier = "${local.name_prefix}-db" + engine = "postgres" + engine_version = "16" + instance_class = var.db_instance_class + + allocated_storage = 20 + max_allocated_storage = 50 + storage_type = "gp3" + storage_encrypted = true + + db_name = "hanspoon" + username = "hanspoon_app" + password = random_password.db.result + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + multi_az = false # 런칭 시 true 로 + + backup_retention_period = 7 + backup_window = "17:00-18:00" + maintenance_window = "sun:18:00-sun:19:00" + + deletion_protection = true + skip_final_snapshot = false + final_snapshot_identifier = "${local.name_prefix}-db-final" + + performance_insights_enabled = false + + lifecycle { + prevent_destroy = true + } +} + +# ── SSM ─────────────────────────────────────── +locals { + app_secrets = [ + "db_password", + "jwt_secret", + "google_client_id", + "openai_api_key", + "clova_ocr_secret", + "clova_ocr_url", + ] +} + +resource "aws_ssm_parameter" "app" { + for_each = toset(local.app_secrets) + + name = "/${local.name_prefix}/${each.key}" + type = "SecureString" + value = "PLACEHOLDER" # 실제 값은 CLI 로 주입 + + lifecycle { + ignore_changes = [value] + } +} + +# Terraform 이 생성한 DB 비밀번호를 옮겨 담을 곳 (최초 1회 참고용) +resource "aws_ssm_parameter" "db_password_seed" { + name = "/${local.name_prefix}/db_password_seed" + type = "SecureString" + value = random_password.db.result +} \ No newline at end of file diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..27d76ae --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,67 @@ +variable "project" { + type = string + default = "hanspoon" +} + +variable "env" { + type = string + default = "prod" + validation { + condition = contains(["dev", "prod"], var.env) + error_message = "env 는 dev 또는 prod 여야 함." + } +} + +variable "region" { + type = string + default = "ap-northeast-2" +} + +variable "vpc_cidr" { + type = string + default = "10.0.0.0/16" + validation { + condition = can(cidrhost(var.vpc_cidr, 0)) + error_message = "유효한 CIDR 표기여야 함." + } +} + +variable "domain_name" { + type = string + default = "han-spoon.site" +} + +variable "instance_type" { + type = string + default = "t4g.small" +} + +variable "db_instance_class" { + type = string + default = "db.t4g.micro" +} + +variable "github_repository" { + type = string + default = "Han-Spoon/backend-api" +} + +variable "alert_email" { + type = string + default = "lys8167@gmail.com" +} + +variable "monthly_budget_usd" { + type = number + default = 60 + validation { + condition = var.monthly_budget_usd > 0 + error_message = "예산은 0보다 커야 함." + } +} + +variable "api_subdomain" { + description = "API 호스트. root 도메인은 Vercel 프론트엔드가 쓰고 있음." + type = string + default = "api" +} \ No newline at end of file From f1d42a758c85144405e2fbafc63f824ca1b60393 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 07:32:27 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=E2=9C=A8feat:=20AWS=20S3=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=20=EA=B3=84=EC=B8=B5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/service/S3StorageService.java | 140 ++++++++++++++++++ .../global/config/S3Properties.java | 8 + .../global/config/S3StorageConfig.java | 42 ++++++ .../global/exception/ErrorCode.java | 43 ++++-- 4 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java create mode 100644 src/main/java/com/hanspoon/backend_api/global/config/S3Properties.java create mode 100644 src/main/java/com/hanspoon/backend_api/global/config/S3StorageConfig.java diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java b/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java new file mode 100644 index 0000000..38c86dc --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java @@ -0,0 +1,140 @@ +package com.hanspoon.backend_api.domain.upload.service; + +import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; +import com.hanspoon.backend_api.global.config.S3Properties; +import com.hanspoon.backend_api.global.exception.BusinessException; +import com.hanspoon.backend_api.global.exception.ErrorCode; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +@Service +public class S3StorageService { + + private static final String SCAN_PREFIX = "scans/"; + + // 브라우저가 실제로 보내는 표준 MIME 만 허용. + // ⚠ 값(확장자)은 아래 KEY_TAIL_PATTERN 의 허용 목록과 반드시 일치해야 한다. + // 불일치하면 createUploadUrl 이 만든 키를 resolveKey 가 거부한다. + private static final Map EXTENSIONS = Map.of( + "image/jpeg", "jpg", + "image/png", "png", + "image/webp", "webp"); + + private static final Pattern KEY_TAIL_PATTERN = + Pattern.compile("^[0-9a-fA-F-]{36}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.(jpg|png|webp)$"); + + private final S3Presigner presigner; + private final S3Client s3Client; + private final S3Properties properties; + + public S3StorageService(S3Presigner presigner, S3Client s3Client, S3Properties properties) { + this.presigner = presigner; + this.s3Client = s3Client; + this.properties = properties; + } + + public UploadTicketResponse createUploadUrl(UUID userId, String contentType) { + String normalized = contentType == null ? "" : contentType.trim().toLowerCase(); + String extension = EXTENSIONS.get(normalized); + + if (extension == null) { + throw new BusinessException(ErrorCode.INVALID_CONTENT_TYPE, "Unsupported content type: " + contentType); + } + + // Server에서 Key 생성. + String key = "%s%s/%s.%s".formatted(SCAN_PREFIX, userId, UUID.randomUUID(), extension); + + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .contentType(normalized) + // 같은 URL이 재사용되어 기존 객체가 덮어쓰여지는 것 방지. + .ifNoneMatch("*") + .build(); + + try { + PresignedPutObjectRequest signedRequest = presigner.presignPutObject(builder -> + builder.signatureDuration(properties.uploadUrlTtl()).putObjectRequest(putRequest)); + + return new UploadTicketResponse( + key, signedRequest.url().toString(), Instant.now().plus(properties.uploadUrlTtl())); + } catch (RuntimeException exception) { + throw new BusinessException(ErrorCode.STORAGE_PRESIGN_ERROR, "Failed to presign upload URL.", exception); + } + } + + // 프론트가 보낸 키 검증, 유효하면 그대로 반환. (S3에 접근할 때 사용) + public String resolveKey(UUID userId, String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "storageKey is blank."); + } + + String key = storageKey.trim(); + + if (!key.startsWith(SCAN_PREFIX) || key.contains("..") || key.contains("//")) { + throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "Invalid storage key: " + key); + } + + String tail = key.substring(SCAN_PREFIX.length()); + if (!KEY_TAIL_PATTERN.matcher(tail).matches()) { + throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "Invalid storage key: " + key); + } + + if (!tail.startsWith(userId + "/")) { + throw new BusinessException(ErrorCode.ACCESS_DENIED, "Storage key belongs to another user."); + } + + return key; + } + + // 객체가 실제로 올라왔는지, 크기·타입이 정책에 맞는지 확인. + public HeadObjectResponse verifyUploadObject(String key) { + try { + HeadObjectResponse object = s3Client.headObject( + builder -> builder.bucket(properties.bucket()).key(key)); + + if (object.contentLength() > properties.maxFileSize()) { + throw new BusinessException(ErrorCode.FILE_TOO_LARGE); + } + + if (!EXTENSIONS.containsKey(object.contentType())) { + throw new BusinessException(ErrorCode.INVALID_CONTENT_TYPE); + } + + return object; + } catch (S3Exception exception) { + if (exception.statusCode() == 404) { + throw new BusinessException(ErrorCode.UPLOAD_NOT_FOUND, "Object not found: " + key, exception); + } + throw new BusinessException(ErrorCode.STORAGE_PRESIGN_ERROR, "Failed to read object metadata.", exception); + } + } + + // presigned GET URL 발급 (객체 조회) + public String createReadUrl(String key) { + GetObjectRequest getRequest = + GetObjectRequest.builder().bucket(properties.bucket()).key(key).build(); + + try { + return presigner + .presignGetObject(builder -> + builder.signatureDuration(properties.readUrlTtl()).getObjectRequest(getRequest)) + .url() + .toString(); + } catch (RuntimeException exception) { + throw new BusinessException(ErrorCode.STORAGE_PRESIGN_ERROR, "Failed to presign read URL.", exception); + } + } + + // DB 기록용 객체 좌표. + public String objectUri(String key) { + return "s3://" + properties.bucket() + "/" + key; + } +} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/S3Properties.java b/src/main/java/com/hanspoon/backend_api/global/config/S3Properties.java new file mode 100644 index 0000000..075183d --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/global/config/S3Properties.java @@ -0,0 +1,8 @@ +package com.hanspoon.backend_api.global.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("app.s3") +public record S3Properties( + String bucket, String region, String endpoint, Duration uploadUrlTtl, Duration readUrlTtl, long maxFileSize) {} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/S3StorageConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/S3StorageConfig.java new file mode 100644 index 0000000..bd73c10 --- /dev/null +++ b/src/main/java/com/hanspoon/backend_api/global/config/S3StorageConfig.java @@ -0,0 +1,42 @@ +package com.hanspoon.backend_api.global.config; + +import java.net.URI; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@EnableConfigurationProperties(S3Properties.class) +public class S3StorageConfig { + + @Bean + S3Client s3Client(S3Properties properties) { + software.amazon.awssdk.services.s3.S3ClientBuilder builder = + S3Client.builder().region(Region.of(properties.region())); + applyEndpointOverride(properties, builder::endpointOverride, builder::serviceConfiguration); + return builder.build(); + } + + @Bean + S3Presigner s3Presigner(S3Properties properties) { + S3Presigner.Builder builder = S3Presigner.builder().region(Region.of(properties.region())); + applyEndpointOverride(properties, builder::endpointOverride, builder::serviceConfiguration); + return builder.build(); + } + + private void applyEndpointOverride( + S3Properties properties, + java.util.function.Consumer endpointSetter, + java.util.function.Consumer configSetter) { + if (properties.endpoint() == null || properties.endpoint().isBlank()) { + return; + } + endpointSetter.accept(URI.create(properties.endpoint())); + configSetter.accept( + S3Configuration.builder().pathStyleAccessEnabled(true).build()); + } +} diff --git a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java index 3712de0..97ef2ca 100644 --- a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java +++ b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java @@ -4,26 +4,49 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +/** + * 비즈니스 예외 코드. 도메인 패키지 구조를 따라 묶는다. + * + *

{@code code} 는 응답 바디의 {@code code} 필드로 그대로 나가는 API 계약이다. 값을 바꾸면 클라이언트의 에러 분기가 + * 깨지므로, 이름만 바꿀 때도 문자열은 유지할 것. + */ @Getter @RequiredArgsConstructor public enum ErrorCode { + + // ── 공통 ──────────────────────────────────────────────────────────── + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "Invalid request."), + RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "RATE_LIMIT_EXCEEDED", "Too many requests."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error."), + + // ── 인증 · 인가 ───────────────────────────────────────────────────── + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "Invalid or expired token."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access is denied."), + + // ── 사용자 · 프로필 ───────────────────────────────────────────────── USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "User not found."), PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROFILE_NOT_FOUND", "Profile not found."), PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "PROFILE_ALREADY_EXISTS", "Profile already exists."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "Invalid or expired token."), - ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "Access is denied."), - RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "RATE_LIMIT_EXCEEDED", "Too many requests."), - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "Invalid request."), - OCR_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "OCR_SERVICE_ERROR", "OCR service failed."), - RULE_ENGINE_ERROR(HttpStatus.BAD_GATEWAY, "RULE_ENGINE_ERROR", "Rule engine evaluation failed."), - AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AI_SERVICE_UNAVAILABLE", "AI service is unavailable."), + + // ── 업로드 · 스토리지(S3) ─────────────────────────────────────────── INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "INVALID_CONTENT_TYPE", "Unsupported image content type."), - INVALID_STORAGE_KEY(HttpStatus.BAD_REQUEST, "INVALID_STORAGE_KEY", "Invalid storage key or image URL."), - BLOB_SAS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "BLOB_SAS_ERROR", "Failed to issue blob SAS."), + INVALID_STORAGE_KEY(HttpStatus.BAD_REQUEST, "INVALID_STORAGE_KEY", "Invalid storage key."), + FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "FILE_TOO_LARGE", "Uploaded file exceeds the size limit."), + UPLOAD_NOT_FOUND(HttpStatus.NOT_FOUND, "UPLOAD_NOT_FOUND", "Uploaded object not found."), + STORAGE_PRESIGN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "STORAGE_PRESIGN_ERROR", "Failed to presign storage URL."), + + // ── 스캔 ──────────────────────────────────────────────────────────── SCAN_NOT_FOUND(HttpStatus.NOT_FOUND, "SCAN_NOT_FOUND", "Scan not found."), + + // ── 소통 카드 ─────────────────────────────────────────────────────── CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "CARD_NOT_FOUND", "Saved card not found."), + + // ── AI 서비스 연동 ────────────────────────────────────────────────── + OCR_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "OCR_SERVICE_ERROR", "OCR service failed."), + RULE_ENGINE_ERROR(HttpStatus.BAD_GATEWAY, "RULE_ENGINE_ERROR", "Rule engine evaluation failed."), RESULT_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "RESULT_SERVICE_ERROR", "Result generation failed."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "Unexpected server error."); + AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AI_SERVICE_UNAVAILABLE", "AI service is unavailable."), + ; private final HttpStatus status; private final String code; From 58745aa41cd63695693faf82bce6c53bc48eabc0 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 07:34:52 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C,=20=EC=8A=A4=EC=BA=94=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20S3=20=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20Azure=20Blob=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 21 ++- .../domain/scan/service/ScanProcessor.java | 20 +-- .../domain/scan/service/ScanService.java | 21 +-- .../upload/controller/UploadController.java | 26 ++-- ...sRequest.java => UploadTicketRequest.java} | 9 +- .../upload/dto/UploadTicketResponse.java | 12 +- .../upload/service/BlobStorageService.java | 123 ------------------ .../global/config/BlobStorageConfig.java | 34 ----- .../scan/service/ScanProcessorTest.java | 29 ++--- .../domain/scan/service/ScanServiceTest.java | 11 +- .../controller/UploadControllerTest.java | 52 +++++--- .../service/BlobStorageServiceTest.java | 108 --------------- .../upload/service/S3StorageServiceTest.java | 99 ++++++++++++++ 13 files changed, 201 insertions(+), 364 deletions(-) rename src/main/java/com/hanspoon/backend_api/domain/upload/dto/{UploadSasRequest.java => UploadTicketRequest.java} (55%) delete mode 100644 src/main/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageService.java delete mode 100644 src/main/java/com/hanspoon/backend_api/global/config/BlobStorageConfig.java delete mode 100644 src/test/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageServiceTest.java create mode 100644 src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 4e0eee4..ac5a352 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,11 +18,10 @@ repositories { mavenCentral() } -extra["springCloudAzureVersion"] = "7.1.0" - +extra["awsSdkVersion"] = "2.54.13" dependencyManagement { imports { - mavenBom("com.azure.spring:spring-cloud-azure-dependencies:${property("springCloudAzureVersion")}") + mavenBom("software.amazon.awssdk:bom:${property("awsSdkVersion")}") } } @@ -40,16 +39,17 @@ dependencies { runtimeOnly("org.postgresql:postgresql") implementation("com.pgvector:pgvector:0.1.6") - // DB Migration (Flyway) — Spring Boot 4는 자동설정이 spring-boot-flyway 모듈에 분리됨 + // DB Migration (Flyway) implementation("org.springframework.boot:spring-boot-flyway") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql") - // Azure Configuration (Key Vault secrets) - implementation("com.azure.spring:spring-cloud-azure-starter-keyvault-secrets") - - // Azure Blob Storage (SAS 발급용 — 업로드 프록시 아님) - implementation("com.azure:azure-storage-blob") + // AWS S3 + implementation("software.amazon.awssdk:s3") { + exclude(group = "software.amazon.awssdk", module = "netty-nio-client") + exclude(group = "software.amazon.awssdk", module = "apache-client") + } + implementation("software.amazon.awssdk:url-connection-client") // OpenAPI & Swagger (Spring Boot 4 compatible) implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3") @@ -74,7 +74,7 @@ dependencies { testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - // Test - Testcontainers (통합 테스트용 PostgreSQL). Testcontainers 2.x 아티팩트 명명 사용 + // Test - Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers-junit-jupiter") testImplementation("org.testcontainers:testcontainers-postgresql") @@ -84,7 +84,6 @@ tasks.withType { useJUnitPlatform() } -// Spring Boot 실행 가능 jar만 생성 (plain jar 비활성 → Docker COPY 글롭 모호성 제거) tasks.named("jar") { enabled = false } diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java index cea8a27..537a295 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java @@ -16,7 +16,7 @@ import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.domain.user.entity.UserAllergy; import com.hanspoon.backend_api.domain.user.entity.UserProfile; import com.hanspoon.backend_api.domain.user.repository.UserAllergyRepository; @@ -33,12 +33,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -/** - * 스캔 비동기 파이프라인. read SAS 생성 → OCR → (needs_retake 분기) → 프로필 매핑 → RuleEngine → ai_result → - * OCR↔Final index 머지 → 영속화. {@link ScanService} 와 분리된 빈이라 @Async 프록시가 정상 적용된다. - * - *

실패해도 예외를 호출자에게 던지지 않고 scan_status 를 FAILED 로 남긴다(폴링으로 확인). - */ @Component public class ScanProcessor { @@ -46,7 +40,7 @@ public class ScanProcessor { private static final String NEEDS_RETAKE = "needs_retake"; private final AiClient aiClient; - private final BlobStorageService blobStorageService; + private final S3StorageService s3StorageService; private final UserProfileRepository userProfileRepository; private final UserAllergyRepository userAllergyRepository; private final ScanSessionRepository scanSessionRepository; @@ -55,14 +49,14 @@ public class ScanProcessor { public ScanProcessor( AiClient aiClient, - BlobStorageService blobStorageService, + S3StorageService s3StorageService, UserProfileRepository userProfileRepository, UserAllergyRepository userAllergyRepository, ScanSessionRepository scanSessionRepository, MenuImageRepository menuImageRepository, MenuAnalysisRepository menuAnalysisRepository) { this.aiClient = aiClient; - this.blobStorageService = blobStorageService; + this.s3StorageService = s3StorageService; this.userProfileRepository = userProfileRepository; this.userAllergyRepository = userAllergyRepository; this.scanSessionRepository = scanSessionRepository; @@ -79,8 +73,8 @@ public void process(UUID scanId, UUID userId, String storageKey, String source) return; } try { - // 1) read SAS → OCR - String imageUrl = blobStorageService.createReadSasUrl(storageKey); + // 1) presigned GET URL → OCR + String imageUrl = s3StorageService.createReadUrl(storageKey); OcrResponse ocr = aiClient.requestOcr(new OcrRequest(source, storageKey, imageUrl)); // 2) menu_image 저장 + 세션에 OCR 메타 반영 @@ -135,7 +129,7 @@ private void persistMenuImage(UUID scanId, String source, String storageKey, Ocr fileSize = ocr.menuImage().fileSize(); } menuImageRepository.save(MenuImage.create( - scanId, resolvedSource, storageKey, blobStorageService.blobUrl(storageKey), mimeType, fileSize)); + scanId, resolvedSource, storageKey, s3StorageService.objectUri(storageKey), mimeType, fileSize)); } private boolean isNeedsRetake(OcrResponse ocr) { diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java index 8382733..6c5ac14 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java @@ -11,7 +11,7 @@ import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.global.common.PageResponse; import com.hanspoon.backend_api.global.exception.BusinessException; import com.hanspoon.backend_api.global.exception.ErrorCode; @@ -21,32 +21,33 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** - * 스캔 시작/조회. 시작은 세션을 PROCESSING 으로 저장(즉시 커밋)한 뒤 {@link ScanProcessor} 비동기 처리를 트리거한다. - * - *

startScan 은 의도적으로 비트랜잭션 — 세션 저장이 즉시 커밋돼야 별도 스레드의 비동기 작업이 그 행을 조회할 수 있다. - */ @Service public class ScanService { - private final BlobStorageService blobStorageService; + private final S3StorageService s3StorageService; private final ScanSessionRepository scanSessionRepository; private final MenuAnalysisRepository menuAnalysisRepository; private final ScanProcessor scanProcessor; public ScanService( - BlobStorageService blobStorageService, + S3StorageService s3StorageService, ScanSessionRepository scanSessionRepository, MenuAnalysisRepository menuAnalysisRepository, ScanProcessor scanProcessor) { - this.blobStorageService = blobStorageService; + this.s3StorageService = s3StorageService; this.scanSessionRepository = scanSessionRepository; this.menuAnalysisRepository = menuAnalysisRepository; this.scanProcessor = scanProcessor; } public ScanCreatedResponse startScan(UUID userId, StartScanRequest request) { - String storageKey = blobStorageService.extractStorageKey(request.storageKey()); + // 형식 · 소유권 검증 (외부 입력을 받는 유일한 지점) + String storageKey = s3StorageService.resolveKey(userId, request.storageKey()); + + // presigned PUT 은 서버가 내용을 모르므로 실제 업로드 여부·크기·타입을 여기서 확인한다. + // 비동기로 넘긴 뒤 실패하면 사용자는 폴링만 하다 FAILED 를 받게 된다. + s3StorageService.verifyUploadObject(storageKey); + // title 은 생성 시 null — 조회 때 기본값(스캔 시각)으로 보이고, 수정은 마이페이지 API 담당 ScanSession session = scanSessionRepository.save(ScanSession.create(userId, null, null, null, ScanStatus.PROCESSING, null)); diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java b/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java index 427f9cc..5fd4337 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/controller/UploadController.java @@ -1,34 +1,34 @@ package com.hanspoon.backend_api.domain.upload.controller; -import com.hanspoon.backend_api.domain.upload.dto.UploadSasRequest; +import com.hanspoon.backend_api.domain.upload.dto.UploadTicketRequest; import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; +import com.hanspoon.backend_api.global.security.CurrentUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.UUID; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** - * 1. 업로드 SAS 발급. 인증된 사용자만 호출. - * 2. FE 하드코딩 컨테이너 SAS 에서는 미사용으로 남는다. - */ -@Tag(name = "Upload", description = "이미지 업로드용 Blob SAS 발급 API") +/** 이미지 업로드용 presigned URL 발급. 인증된 사용자만 호출. */ +@Tag(name = "Upload", description = "이미지 업로드용 presigned URL 발급 API") @RestController @RequestMapping("/api/v1/uploads") public class UploadController { - private final BlobStorageService blobStorageService; + private final S3StorageService s3StorageService; - public UploadController(BlobStorageService blobStorageService) { - this.blobStorageService = blobStorageService; + public UploadController(S3StorageService s3StorageService) { + this.s3StorageService = s3StorageService; } - @Operation(summary = "이미지 업로드용 쓰기 SAS 발급. FE 는 uploadUrl 로 PUT 후 스캔 요청에 storageKey 전달") + @Operation(summary = "업로드용 presigned PUT URL 발급. FE 는 uploadUrl 로 PUT 후 스캔 요청에 storageKey 전달") @PostMapping("/sas") - public UploadTicketResponse issueUploadSas(@Valid @RequestBody UploadSasRequest request) { - return blobStorageService.createUploadSas(request.contentType()); + public UploadTicketResponse issueUploadTicket( + @CurrentUser String userId, @Valid @RequestBody UploadTicketRequest request) { + return s3StorageService.createUploadUrl(UUID.fromString(userId), request.contentType()); } } diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadSasRequest.java b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketRequest.java similarity index 55% rename from src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadSasRequest.java rename to src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketRequest.java index 159e94a..e1872c0 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadSasRequest.java +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketRequest.java @@ -3,11 +3,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -/** - * 쓰기 SAS 발급 요청 (Path B). - * - * @param contentType 업로드할 이미지 MIME 타입 (예: image/jpeg) - */ -@Schema(description = "업로드 SAS 발급 요청") -public record UploadSasRequest( +@Schema(description = "업로드 티켓 발급 요청") +public record UploadTicketRequest( @Schema(description = "이미지 MIME 타입", example = "image/jpeg") @NotBlank String contentType) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java index 8547e7b..339720d 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java +++ b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java @@ -3,13 +3,5 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; -/** - * 쓰기 SAS 발급 응답 (Path B). FE 는 {@code uploadUrl} 로 이미지를 PUT 하고, 스캔 요청 시 {@code storageKey} 를 보낸다. - * - * @param storageKey 서버가 생성한 blob 명 (예: menu-xxxx.jpg) - * @param uploadUrl 쓰기 SAS 가 붙은 업로드 URL (PUT 대상) - * @param readUrl SAS 없는 평문 blob URL (참조용) - * @param expiresAt 업로드 SAS 만료 시각 - */ -@Schema(description = "업로드 SAS 발급 응답") -public record UploadTicketResponse(String storageKey, String uploadUrl, String readUrl, Instant expiresAt) {} +@Schema(description = "업로드 티켓 발급 응답") +public record UploadTicketResponse(String storageKey, String uploadUrl, Instant expiresAt) {} diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageService.java b/src/main/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageService.java deleted file mode 100644 index f78e7e4..0000000 --- a/src/main/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.hanspoon.backend_api.domain.upload.service; - -import com.azure.storage.blob.BlobClient; -import com.azure.storage.blob.BlobContainerClient; -import com.azure.storage.blob.sas.BlobSasPermission; -import com.azure.storage.blob.sas.BlobServiceSasSignatureValues; -import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.global.exception.BusinessException; -import com.hanspoon.backend_api.global.exception.ErrorCode; -import java.time.Duration; -import java.time.OffsetDateTime; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.regex.Pattern; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -/** - * Blob 업로드/읽기 SAS 발급. - * - A: FE 가 Blob 에 직접 업로드(백엔드 우회)하므로 백엔드는 업로드 바이트를 다루지 않고 단일 blob 범위의 짧은 SAS 만 발급한다. - * - B: FE 가 백엔드에서 쓰기 SAS 를 받아 업로드. Path A 보다 보안은 약간 향상되나, 여전히 SAS URL 이 노출될 수 있으므로 짧은 TTL 권장. - * - *

    - *
  • {@link #createReadSasUrl} / {@link #extractStorageKey} — 두 업로드 경로(A/B) 공통 코어. - * AI 가 private blob 을 읽도록 읽기 SAS 를 발급한다. - * 오케스트레이션이 OcrRequest.imageUrl 빌드에 사용. - *
  • {@link #createUploadSas} — Path B 전용. FE 가 백엔드에서 쓰기 SAS 를 받아 업로드. - *
- */ -@Service -public class BlobStorageService { - - private static final Pattern STORAGE_KEY_PATTERN = Pattern.compile("^menu-[A-Za-z0-9-]+\\.(jpg|jpeg|png|webp)$"); - - private static final Map CONTENT_TYPE_EXTENSION = - Map.of("image/jpeg", "jpg", "image/png", "png", "image/webp", "webp"); - - private final BlobContainerClient containerClient; - private final Duration uploadSasTtl; - private final Duration readSasTtl; - private final List allowedContentTypes; - - public BlobStorageService( - BlobContainerClient menuImageContainerClient, - @Value("${app.blob.upload-sas-ttl:10m}") Duration uploadSasTtl, - @Value("${app.blob.read-sas-ttl:15m}") Duration readSasTtl, - @Value("${app.blob.allowed-content-types:image/jpeg,image/png,image/webp}") - List allowedContentTypes) { - this.containerClient = menuImageContainerClient; - this.uploadSasTtl = uploadSasTtl; - this.readSasTtl = readSasTtl; - this.allowedContentTypes = allowedContentTypes; - } - - /** B: 단일 blob 쓰기 SAS 발급. */ - public UploadTicketResponse createUploadSas(String contentType) { - String normalized = contentType == null ? "" : contentType.trim().toLowerCase(); - String extension = CONTENT_TYPE_EXTENSION.get(normalized); - if (extension == null || !allowedContentTypes.contains(normalized)) { - throw new BusinessException(ErrorCode.INVALID_CONTENT_TYPE, "Unsupported content type: " + contentType); - } - - String storageKey = "menu-" + UUID.randomUUID() + "." + extension; - BlobClient blob = containerClient.getBlobClient(storageKey); - OffsetDateTime expiry = OffsetDateTime.now().plus(uploadSasTtl); - BlobSasPermission permission = - new BlobSasPermission().setCreatePermission(true).setWritePermission(true); - - String sas = generateSas(blob, expiry, permission); - return new UploadTicketResponse( - storageKey, blob.getBlobUrl() + "?" + sas, blob.getBlobUrl(), expiry.toInstant()); - } - - /** A, B 공유 코어: storageKey 로 읽기 SAS URL 발급 (AI OCR 이 private blob 을 읽도록). */ - public String createReadSasUrl(String storageKey) { - String key = validateKey(storageKey); - BlobClient blob = containerClient.getBlobClient(key); - OffsetDateTime expiry = OffsetDateTime.now().plus(readSasTtl); - BlobSasPermission permission = new BlobSasPermission().setReadPermission(true); - - String sas = generateSas(blob, expiry, permission); - return blob.getBlobUrl() + "?" + sas; - } - - /** SAS 없는 평문 blob URL (DB 저장용 — SAS 는 휘발성이라 미저장). */ - public String blobUrl(String storageKey) { - return containerClient.getBlobClient(validateKey(storageKey)).getBlobUrl(); - } - - /** A, B 공유 코어: storageKey 또는 imageUrl(Path A) 을 받아 blob 명으로 정규화. 우리 컨테이너 외 URL 은 거부(SSRF 가드). */ - public String extractStorageKey(String storageKeyOrUrl) { - if (storageKeyOrUrl == null || storageKeyOrUrl.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "storageKey/imageUrl is blank."); - } - String value = storageKeyOrUrl.trim(); - if (value.startsWith("http://") || value.startsWith("https://")) { - String prefix = containerClient.getBlobContainerUrl() + "/"; - if (!value.startsWith(prefix)) { - throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "URL is outside the managed container."); - } - String afterPrefix = value.substring(prefix.length()); - int query = afterPrefix.indexOf('?'); - value = query >= 0 ? afterPrefix.substring(0, query) : afterPrefix; - } - return validateKey(value); - } - - private String validateKey(String key) { - if (key == null || !STORAGE_KEY_PATTERN.matcher(key).matches()) { - throw new BusinessException(ErrorCode.INVALID_STORAGE_KEY, "Invalid storage key: " + key); - } - return key; - } - - private String generateSas(BlobClient blob, OffsetDateTime expiry, BlobSasPermission permission) { - try { - return blob.generateSas(new BlobServiceSasSignatureValues(expiry, permission)); - } catch (RuntimeException exception) { - throw new BusinessException(ErrorCode.BLOB_SAS_ERROR, "Failed to sign blob SAS.", exception); - } - } -} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/BlobStorageConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/BlobStorageConfig.java deleted file mode 100644 index 7c44e46..0000000 --- a/src/main/java/com/hanspoon/backend_api/global/config/BlobStorageConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.hanspoon.backend_api.global.config; - -import com.azure.storage.blob.BlobContainerClient; -import com.azure.storage.blob.BlobServiceClientBuilder; -import com.azure.storage.common.StorageSharedKeyCredential; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Azure Blob 컨테이너 클라이언트. 계정키 기반 {@link StorageSharedKeyCredential} 로 SAS 를 로컬 서명한다(네트워크 불필요). - * 클라이언트 생성은 지연(lazy)이라 부팅 시 네트워크 호출이 없다 → 로컬에서 키 없이도 기동 가능. - * (하드닝 후속: 계정키 대신 Managed Identity + User Delegation SAS) - */ -@Configuration -public class BlobStorageConfig { - - // 로컬/테스트에서 키 없이도 기동 가능하도록 placeholder(base64). 실제 SAS 발급에는 운영의 BLOB_ACCOUNT_KEY(Key Vault) 필요. - private static final String PLACEHOLDER_KEY = "ZGV2LXBsYWNlaG9sZGVy"; // base64("dev-placeholder") - - @Bean - public BlobContainerClient menuImageContainerClient( - @Value("${app.blob.account-name}") String accountName, - @Value("${app.blob.account-key:}") String accountKey, - @Value("${app.blob.container}") String container) { - - String effectiveKey = (accountKey == null || accountKey.isBlank()) ? PLACEHOLDER_KEY : accountKey; - return new BlobServiceClientBuilder() - .endpoint("https://" + accountName + ".blob.core.windows.net") - .credential(new StorageSharedKeyCredential(accountName, effectiveKey)) - .buildClient() - .getBlobContainerClient(container); - } -} diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java index bf8000f..96821c6 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java @@ -24,7 +24,7 @@ import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.domain.user.entity.ReligionType; import com.hanspoon.backend_api.domain.user.entity.UserProfile; import com.hanspoon.backend_api.domain.user.repository.UserAllergyRepository; @@ -44,11 +44,13 @@ @ExtendWith(MockitoExtension.class) class ScanProcessorTest { + private static final String STORAGE_KEY = "scans/11111111-1111-1111-1111-111111111111/abc.jpg"; + @Mock private AiClient aiClient; @Mock - private BlobStorageService blobStorageService; + private S3StorageService s3StorageService; @Mock private UserProfileRepository userProfileRepository; @@ -79,7 +81,7 @@ private OcrResponse usableOcr() { new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession( "menu.jpg", 2, null, "completed", "2026-06-05T00:00:00Z"), new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage( - "upload", "menu-x.jpg", "https://acct/board-images/menu-x.jpg", "image/jpeg", 123L), + "upload", STORAGE_KEY, "https://s3/presigned", "image/jpeg", 123L), new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality( "usable", 80, 20, 2, 1.0, 1280, 960, null, List.of(), List.of()), List.of(ocrMenu("samgyeopsal", "9000", false, 1), ocrMenu("doenjang", "8000", true, 2)), @@ -94,9 +96,8 @@ void completesScanAndMergesOcrWithRuleEngine() { OcrResponse ocr = usableOcr(); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); - when(blobStorageService.createReadSasUrl("menu-x.jpg")) - .thenReturn("https://acct/board-images/menu-x.jpg?sig=r"); - when(blobStorageService.blobUrl("menu-x.jpg")).thenReturn("https://acct/board-images/menu-x.jpg"); + when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r"); + when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(ocr); UserProfile profile = UserProfile.create(userId, "KR", false, false, null, ReligionType.HALAL, true, true); when(userProfileRepository.findByUserId(userId)).thenReturn(Optional.of(profile)); @@ -145,7 +146,7 @@ void completesScanAndMergesOcrWithRuleEngine() { "doenjang", "has_unclear_broth", new OwnerQuestion("use anchovy?", null, null))))); when(aiClient.result(any())).thenReturn(finalResult); - scanProcessor.process(scanId, userId, "menu-x.jpg", "upload"); + scanProcessor.process(scanId, userId, STORAGE_KEY, "upload"); assertThat(session.getScanStatus()).isEqualTo(ScanStatus.COMPLETED); assertThat(session.getMenuCount()).isEqualTo(2); @@ -176,19 +177,18 @@ void marksNeedsRetakeAndSkipsRuleEngine() { UUID scanId = session.getId(); OcrResponse ocr = new OcrResponse( new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession("menu.jpg", 0, null, "completed", null), - new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage("upload", "menu-x.jpg", "u", "image/jpeg", 1L), + new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage("upload", STORAGE_KEY, "u", "image/jpeg", 1L), new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality( "needs_retake", 20, 1, 0, 0.0, 100, 100, null, List.of(), List.of("too blurry")), List.of(), null); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); - when(blobStorageService.createReadSasUrl("menu-x.jpg")) - .thenReturn("https://acct/board-images/menu-x.jpg?sig=r"); - when(blobStorageService.blobUrl("menu-x.jpg")).thenReturn("https://acct/board-images/menu-x.jpg"); + when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r"); + when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY); when(aiClient.requestOcr(any())).thenReturn(ocr); - scanProcessor.process(scanId, userId, "menu-x.jpg", "upload"); + scanProcessor.process(scanId, userId, STORAGE_KEY, "upload"); assertThat(session.getScanStatus()).isEqualTo(ScanStatus.NEEDS_RETAKE); assertThat(session.getRetakeReasons()).containsExactly("too blurry"); @@ -203,11 +203,10 @@ void marksFailedWhenOcrThrows() { UUID scanId = session.getId(); when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session)); - when(blobStorageService.createReadSasUrl("menu-x.jpg")) - .thenReturn("https://acct/board-images/menu-x.jpg?sig=r"); + when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r"); when(aiClient.requestOcr(any())).thenThrow(new BusinessException(ErrorCode.OCR_SERVICE_ERROR, "boom")); - scanProcessor.process(scanId, userId, "menu-x.jpg", "upload"); + scanProcessor.process(scanId, userId, STORAGE_KEY, "upload"); assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED); verify(menuAnalysisRepository, never()).saveAll(any()); diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java index 8ce1a4b..3b5a430 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java @@ -16,7 +16,7 @@ import com.hanspoon.backend_api.domain.scan.entity.ScanStatus; import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository; import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; import com.hanspoon.backend_api.global.common.PageResponse; import com.hanspoon.backend_api.global.exception.BusinessException; import com.hanspoon.backend_api.global.exception.ErrorCode; @@ -37,7 +37,7 @@ class ScanServiceTest { @Mock - private BlobStorageService blobStorageService; + private S3StorageService s3StorageService; @Mock private ScanSessionRepository scanSessionRepository; @@ -54,14 +54,15 @@ class ScanServiceTest { @Test void startScanSavesSessionTriggersProcessorAndReturnsProcessing() { UUID userId = UUID.randomUUID(); - when(blobStorageService.extractStorageKey("menu-x.jpg")).thenReturn("menu-x.jpg"); + String key = "scans/" + userId + "/2f1c9d3e-0000-4000-8000-000000000001.jpg"; + when(s3StorageService.resolveKey(userId, key)).thenReturn(key); when(scanSessionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest("menu-x.jpg", "upload")); + ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload")); assertThat(response.status()).isEqualTo(ScanStatus.PROCESSING); assertThat(response.scanId()).isNotNull(); - verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq("menu-x.jpg"), eq("upload")); + verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq(key), eq("upload")); } @Test diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java index ae8ecc4..bc1267f 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java @@ -1,6 +1,7 @@ package com.hanspoon.backend_api.domain.upload.controller; import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -8,44 +9,65 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.domain.upload.service.BlobStorageService; +import com.hanspoon.backend_api.domain.upload.service.S3StorageService; +import com.hanspoon.backend_api.global.security.CurrentUser; import java.time.Instant; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; -/** - * 컨트롤러 standalone 슬라이스. 인증 강제는 SecurityConfig(anyRequest authenticated, PUBLIC_ENDPOINTS 미포함)가 - * 담당하므로 여기서는 위임/검증/응답 스키마만 확인한다. - */ class UploadControllerTest { - private final BlobStorageService blobStorageService = mock(BlobStorageService.class); + private static final UUID USER_ID = UUID.randomUUID(); + + private final S3StorageService s3StorageService = mock(S3StorageService.class); private MockMvc mockMvc; @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(new UploadController(blobStorageService)) + HandlerMethodArgumentResolver currentUserResolver = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return USER_ID.toString(); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(new UploadController(s3StorageService)) + .setCustomArgumentResolvers(currentUserResolver) .build(); } @Test - void issuesUploadSas() throws Exception { - when(blobStorageService.createUploadSas("image/jpeg")) + void issuesUploadTicket() throws Exception { + String key = "scans/" + USER_ID + "/abc.jpg"; + when(s3StorageService.createUploadUrl(eq(USER_ID), eq("image/jpeg"))) .thenReturn(new UploadTicketResponse( - "menu-abc.jpg", - "https://testacct.blob.core.windows.net/board-images/menu-abc.jpg?sig=xyz", - "https://testacct.blob.core.windows.net/board-images/menu-abc.jpg", + key, + "https://bucket.s3.ap-northeast-2.amazonaws.com/" + key + "?X-Amz-Signature=xyz", Instant.parse("2026-06-06T00:10:00Z"))); mockMvc.perform(post("/api/v1/uploads/sas") .contentType("application/json") .content("{\"contentType\":\"image/jpeg\"}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.storageKey").value("menu-abc.jpg")) - .andExpect(jsonPath("$.uploadUrl").value(containsString("sig="))) - .andExpect(jsonPath("$.readUrl").value(containsString("board-images/menu-abc.jpg"))); + .andExpect(jsonPath("$.storageKey").value(key)) + .andExpect(jsonPath("$.uploadUrl").value(containsString("X-Amz-Signature="))) + .andExpect(jsonPath("$.expiresAt").exists()); } @Test diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageServiceTest.java deleted file mode 100644 index a9297bf..0000000 --- a/src/test/java/com/hanspoon/backend_api/domain/upload/service/BlobStorageServiceTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.hanspoon.backend_api.domain.upload.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.azure.storage.blob.BlobContainerClient; -import com.azure.storage.blob.BlobServiceClientBuilder; -import com.azure.storage.common.StorageSharedKeyCredential; -import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; -import com.hanspoon.backend_api.global.exception.BusinessException; -import com.hanspoon.backend_api.global.exception.ErrorCode; -import java.time.Duration; -import java.util.List; -import org.junit.jupiter.api.Test; - -/** 더미 base64 계정키로 SAS 를 오프라인 서명 → 네트워크 없이 검증. */ -class BlobStorageServiceTest { - - private static final String ACCOUNT = "testacct"; - private static final String CONTAINER = "board-images"; - private static final String CONTAINER_URL = "https://testacct.blob.core.windows.net/board-images"; - // base64("storage-account-key") - private static final String DUMMY_KEY = "c3RvcmFnZS1hY2NvdW50LWtleQ=="; - - private final BlobStorageService service = new BlobStorageService( - containerClient(), - Duration.ofMinutes(10), - Duration.ofMinutes(15), - List.of("image/jpeg", "image/png", "image/webp")); - - private static BlobContainerClient containerClient() { - return new BlobServiceClientBuilder() - .endpoint("https://" + ACCOUNT + ".blob.core.windows.net") - .credential(new StorageSharedKeyCredential(ACCOUNT, DUMMY_KEY)) - .buildClient() - .getBlobContainerClient(CONTAINER); - } - - @Test - void createUploadSasIssuesWriteSas() { - UploadTicketResponse ticket = service.createUploadSas("image/jpeg"); - - assertThat(ticket.storageKey()).matches("^menu-[A-Za-z0-9-]+\\.jpg$"); - assertThat(ticket.uploadUrl()) - .startsWith(CONTAINER_URL + "/" + ticket.storageKey() + "?") - .contains("sig=") - .contains("se="); - assertThat(ticket.readUrl()).isEqualTo(CONTAINER_URL + "/" + ticket.storageKey()); - assertThat(ticket.readUrl()).doesNotContain("?"); - assertThat(ticket.expiresAt()).isNotNull(); - } - - @Test - void createUploadSasRejectsUnsupportedContentType() { - assertThatThrownBy(() -> service.createUploadSas("image/gif")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_CONTENT_TYPE); - } - - @Test - void createReadSasUrlIssuesReadSas() { - String url = service.createReadSasUrl("menu-abc123.jpg"); - - assertThat(url) - .startsWith(CONTAINER_URL + "/menu-abc123.jpg?") - .contains("sp=r") - .contains("sig="); - } - - @Test - void createReadSasUrlRejectsInvalidKey() { - assertThatThrownBy(() -> service.createReadSasUrl("../secret.jpg")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_STORAGE_KEY); - assertThatThrownBy(() -> service.createReadSasUrl("menu-x.gif")).isInstanceOf(BusinessException.class); - } - - @Test - void extractStorageKeyAcceptsPlainKey() { - assertThat(service.extractStorageKey("menu-abc123.png")).isEqualTo("menu-abc123.png"); - } - - @Test - void extractStorageKeyAcceptsOurContainerUrlWithSas() { - String url = CONTAINER_URL + "/menu-abc123.jpg?sv=2024&sig=xyz"; - - assertThat(service.extractStorageKey(url)).isEqualTo("menu-abc123.jpg"); - } - - @Test - void extractStorageKeyRejectsForeignHost() { - assertThatThrownBy(() -> service.extractStorageKey("https://evil.com/board-images/menu-abc123.jpg")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_STORAGE_KEY); - } - - @Test - void extractStorageKeyRejectsOtherContainer() { - assertThatThrownBy( - () -> service.extractStorageKey("https://testacct.blob.core.windows.net/other/menu-abc123.jpg")) - .isInstanceOf(BusinessException.class) - .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.INVALID_STORAGE_KEY); - } -} diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java new file mode 100644 index 0000000..97a1785 --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java @@ -0,0 +1,99 @@ +package com.hanspoon.backend_api.domain.upload.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse; +import com.hanspoon.backend_api.global.config.S3Properties; +import com.hanspoon.backend_api.global.exception.BusinessException; +import com.hanspoon.backend_api.global.exception.ErrorCode; +import java.time.Duration; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +class S3StorageServiceTest { + + private static final String BUCKET = "hanspoon-test-images"; + private static final UUID USER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID OTHER_USER = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + private final S3Properties properties = new S3Properties( + BUCKET, "ap-northeast-2", null, Duration.ofMinutes(10), Duration.ofMinutes(15), 10 * 1024 * 1024); + + private final S3StorageService service = new S3StorageService(presigner(), mock(S3Client.class), properties); + + private static S3Presigner presigner() { + return S3Presigner.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("test-ak", "test-sk"))) + .build(); + } + + @Test + void createUploadUrlIssuesPresignedPut() { + UploadTicketResponse ticket = service.createUploadUrl(USER, "image/jpeg"); + + assertThat(ticket.storageKey()).matches("^scans/" + USER + "/[0-9a-f-]{36}\\.jpg$"); + assertThat(ticket.uploadUrl()) + .contains(BUCKET) + .contains("X-Amz-Signature=") + .contains("X-Amz-Expires="); + assertThat(ticket.expiresAt()).isNotNull(); + } + + @Test + void createUploadUrlRejectsUnsupportedContentType() { + assertThatThrownBy(() -> service.createUploadUrl(USER, "image/gif")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_CONTENT_TYPE); + } + + @Test + void resolveKeyAcceptsOwnKey() { + String key = service.createUploadUrl(USER, "image/png").storageKey(); + + assertThat(service.resolveKey(USER, key)).isEqualTo(key); + } + + @Test + void resolveKeyRejectsOtherUsersKey() { + String key = service.createUploadUrl(OTHER_USER, "image/png").storageKey(); + + assertThatThrownBy(() -> service.resolveKey(USER, key)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + void resolveKeyRejectsMalformedKeys() { + assertThatThrownBy(() -> service.resolveKey(USER, "menus/foo.jpg")).isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "scans/" + USER + "/../secret.jpg")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "scans/" + USER + "/a.gif")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "https://evil.com/scans/x.jpg")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveKey(USER, "")).isInstanceOf(BusinessException.class); + } + + @Test + void objectUriReturnsS3Scheme() { + assertThat(service.objectUri("scans/" + USER + "/abc.jpg")) + .isEqualTo("s3://" + BUCKET + "/scans/" + USER + "/abc.jpg"); + } + + @Test + void createReadUrlIssuesPresignedGet() { + String key = "scans/" + USER + "/abc.jpg"; + + assertThat(service.createReadUrl(key)).contains(key).contains("X-Amz-Signature="); + } +} From bed313b358f8ad1018a55df76c4ba78b0bc4947a Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 07:35:02 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=94=A7chore:=20CloudFront=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20S3=20?= =?UTF-8?q?=ED=82=A4=20=ED=94=84=EB=A6=AC=ED=94=BD=EC=8A=A4=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/edge.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/terraform/edge.tf b/infra/terraform/edge.tf index 4e6fc53..f5f460a 100644 --- a/infra/terraform/edge.tf +++ b/infra/terraform/edge.tf @@ -89,7 +89,7 @@ resource "aws_cloudfront_distribution" "main" { } ordered_cache_behavior { - path_pattern = "/images/*" + path_pattern = "/menus/*" target_origin_id = "images" viewer_protocol_policy = "redirect-to-https" allowed_methods = ["GET", "HEAD"] From c848c4dcf705984ea55c7f36b21afb4d3d53229d Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 07:35:23 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=90=9Bfix:=20SecurityConfig=20?= =?UTF-8?q?=EC=9D=98=20ObjectMapper=20=EB=A5=BC=20Jackson=203=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hanspoon/backend_api/global/config/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java index 6cd682b..81cccd6 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java @@ -1,6 +1,5 @@ package com.hanspoon.backend_api.global.config; -import com.fasterxml.jackson.databind.ObjectMapper; import com.hanspoon.backend_api.global.exception.ErrorCode; import com.nimbusds.jose.jwk.source.ImmutableSecret; import io.jsonwebtoken.security.Keys; @@ -31,6 +30,7 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; +import tools.jackson.databind.ObjectMapper; @Configuration @EnableMethodSecurity @@ -123,7 +123,7 @@ private void writeProblemDetail( ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, errorCode.getMessage()); problemDetail.setTitle(status.getReasonPhrase()); - problemDetail.setType(URI.create("https://api.han-spoon.com/problems/" + errorCode.getCode())); + problemDetail.setType(URI.create("https://api.han-spoon.site/problems/" + errorCode.getCode())); problemDetail.setProperty("code", errorCode.getCode()); problemDetail.setProperty("timestamp", OffsetDateTime.now()); From c42a917d823be326d7d8b549df9cb483935a2e43 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 07:35:40 +0900 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=94=A7chore:=20prod=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8C=8C=EC=9D=BC=20Azure=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 28 +++++++------------------ src/main/resources/application.yml | 17 +++++++-------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 119a75f..4a07827 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,17 +2,10 @@ spring: config: activate: on-profile: prod - import: optional:azure-keyvault:${AZURE_KEYVAULT_ENDPOINT:https://kv-hanspoon-prod.vault.azure.net/} - cloud: - azure: - keyvault: - secret: - endpoint: ${AZURE_KEYVAULT_ENDPOINT:https://kv-hanspoon-prod.vault.azure.net/} datasource: - url: ${DB_PRIVATE_ENDPOINT_URL:jdbc:postgresql://psql-hanspoon-prod.privatelink.postgres.database.azure.com:5432/hanspoon?sslmode=require} + url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME:hanspoon}?sslmode=require username: ${DB_USERNAME:hanspoon_app} - # Key Vault 우선, 미사용/부재 시 DB_PASSWORD 환경변수로 폴백 - password: ${kv-secret-db-password:${DB_PASSWORD:}} + password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: ${DB_MAX_POOL_SIZE:10} @@ -45,20 +38,15 @@ server: app: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:https://han-spoon.site,https://www.han-spoon.site} - blob: - # SAS 서명용 스토리지 계정 키. Key Vault 우선, 미사용/부재 시 BLOB_ACCOUNT_KEY 환경변수로 폴백. - account-key: ${kv-secret-blob-account-key:${BLOB_ACCOUNT_KEY:}} - azure-ai: - endpoint: ${AZURE_AI_PRIVATE_ENDPOINT:https://aoai-hanspoon-prod.openai.azure.com} - # Key Vault 우선, 미사용/부재 시 AZURE_AI_API_KEY 환경변수로 폴백 - api-key: ${kv-secret-azure-openai-api-key:${AZURE_AI_API_KEY:}} + s3: + bucket: ${S3_BUCKET} + region: ${AWS_REGION:ap-northeast-2} security: google: client-id: ${GOOGLE_CLIENT_ID} jwt: - issuer: ${JWT_ISSUER:https://api.han-spoon.com} - # Key Vault 우선, 미사용/부재 시 JWT_SECRET 환경변수로 폴백 - secret: ${kv-secret-jwt-signing-key:${JWT_SECRET:}} + issuer: ${JWT_ISSUER:https://api.han-spoon.site} + secret: ${JWT_SECRET} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:15m} refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:14d} @@ -75,4 +63,4 @@ springdoc: swagger-ui: enabled: ${SWAGGER_UI_ENABLED:false} api-docs: - enabled: ${OPENAPI_DOCS_ENABLED:false} + enabled: ${OPENAPI_DOCS_ENABLED:false} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3b0473c..3543f90 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,20 +37,17 @@ app: openai: capacity: ${OPENAI_RATE_LIMIT_CAPACITY:30} refill-period: ${OPENAI_RATE_LIMIT_REFILL_PERIOD:1m} - azure-ai: - endpoint: ${AZURE_AI_ENDPOINT:https://han-spoon-openai.openai.azure.com} - api-key: ${AZURE_AI_API_KEY:} ai-service: base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000} connect-timeout: ${AI_SERVICE_CONNECT_TIMEOUT:3s} read-timeout: ${AI_SERVICE_READ_TIMEOUT:30s} - blob: - account-name: ${BLOB_ACCOUNT_NAME:sthanspoonprod} - container: ${BLOB_CONTAINER:board-images} - account-key: ${BLOB_ACCOUNT_KEY:} - upload-sas-ttl: ${BLOB_UPLOAD_SAS_TTL:10m} - read-sas-ttl: ${BLOB_READ_SAS_TTL:15m} - allowed-content-types: ${BLOB_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/webp} + s3: + bucket: ${S3_BUCKET:hanspoon-local-images} + region: ${AWS_REGION:ap-northeast-2} + endpoint: ${S3_ENDPOINT:} + upload-url-ttl: ${S3_UPLOAD_URL_TTL:10m} + read-url-ttl: ${S3_READ_URL_TTL:15m} + max-file-size: ${S3_MAX_FILE_SIZE:10485760} # 10MB security: google: client-id: ${GOOGLE_CLIENT_ID:} From 3d984a24e460e156fac410755d395b80bec1ab56 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 07:35:48 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=94=A7chore:=20Azure=20=EC=9E=94?= =?UTF-8?q?=EC=97=AC=20=EB=8D=B0=EB=93=9C=EC=BD=94=EB=93=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/scan/dto/StartScanRequest.java | 6 ++-- .../global/config/OpenApiConfig.java | 35 ------------------- .../global/config/RestClientConfig.java | 18 +--------- .../global/config/SwaggerConfig.java | 2 +- 4 files changed, 5 insertions(+), 56 deletions(-) delete mode 100644 src/main/java/com/hanspoon/backend_api/global/config/OpenApiConfig.java diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java index cb270b7..b3e8d5c 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java +++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java @@ -4,14 +4,14 @@ import jakarta.validation.constraints.NotBlank; /** - * 스캔 시작 요청. 이미지는 미리 Blob 에 업로드된 상태이고, 그 키(또는 우리 컨테이너 URL)를 넘긴다. + * 스캔 시작 요청. 이미지는 presigned URL 로 S3 에 업로드된 상태이고, 그 객체 키를 넘긴다. * *

표시용 제목(title)은 받지 않는다 — 생성 시엔 기본값(스캔 시각)으로 보이고, 수정은 마이페이지의 별도 API 담당. * - * @param storageKey 업로드된 blob 키(예: menu-xxxx.jpg) 또는 우리 컨테이너의 imageUrl + * @param storageKey 업로드된 S3 객체 키 (예: scans/{userId}/{uuid}.jpg) * @param source 이미지 소스 (camera | upload), 선택 */ @Schema(description = "스캔 시작 요청") public record StartScanRequest( - @Schema(description = "업로드된 blob 키 또는 우리 컨테이너 imageUrl", example = "menu-9f3c.jpg") @NotBlank String storageKey, + @Schema(description = "업로드된 S3 객체 키", example = "scans/3f2a.../9f3c....jpg") @NotBlank String storageKey, @Schema(description = "이미지 소스", example = "upload") String source) {} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/OpenApiConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/OpenApiConfig.java deleted file mode 100644 index 9cff2a8..0000000 --- a/src/main/java/com/hanspoon/backend_api/global/config/OpenApiConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.hanspoon.backend_api.global.config; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * OpenAPI(Swagger UI) 설정. Bearer(JWT) 인증 스킴을 전역으로 등록해 - * Swagger UI 의 "Authorize" 버튼에 access token 을 넣고 보호된 API 를 호출할 수 있게 한다. - * (permitAll 엔드포인트는 토큰 없이도 동작한다.) - */ -@Configuration -public class OpenApiConfig { - - private static final String BEARER_SCHEME = "bearerAuth"; - - @Bean - public OpenAPI openAPI() { - return new OpenAPI() - .info(new Info().title("Han-Spoon API").version("v1").description("Han-Spoon 백엔드 API")) - .addSecurityItem(new SecurityRequirement().addList(BEARER_SCHEME)) - .components(new Components() - .addSecuritySchemes( - BEARER_SCHEME, - new SecurityScheme() - .name(BEARER_SCHEME) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - } -} diff --git a/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java index a6f8b9b..2900d7f 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java @@ -17,25 +17,9 @@ public RestClient.Builder restClientBuilder() { return RestClient.builder(); } - @Bean - public RestClient azureAiRestClient( - RestClient.Builder builder, - @Value("${app.azure-ai.endpoint:https://han-spoon-openai.openai.azure.com}") String azureAiEndpoint, - @Value("${app.azure-ai.api-key:}") String azureAiApiKey, - @Value("${app.rest-client.connect-timeout:3s}") Duration connectTimeout, - @Value("${app.rest-client.read-timeout:10s}") Duration readTimeout) { - - return builder.baseUrl(azureAiEndpoint) - .requestFactory(clientHttpRequestFactory(connectTimeout, readTimeout)) - .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .defaultHeader("api-key", azureAiApiKey) - .build(); - } - /** * AI 서비스(OCR + Rule Engine) 호출용 RestClient. - * OCR 은 Azure Document Intelligence 를 거쳐 느리므로 read-timeout 을 길게 둔다. + * OCR + 룰엔진 + 결과생성 파이프라인이 길어 read-timeout 을 넉넉히 둔다. */ @Bean public RestClient aiServiceRestClient( diff --git a/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java index 349095e..d577b89 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/SwaggerConfig.java @@ -26,7 +26,7 @@ public OpenAPI hanSpoonOpenApi() { private Info apiInfo() { return new Info() .title("Han-Spoon Backend API") - .description("Foreign tourist service API for Han-Spoon running on Azure Container Apps.") + .description("Foreign tourist service API for Han-Spoon.") .version("v1") .contact(new Contact().name("Han-Spoon Backend Team").email("backend@han-spoon.com")) .license(new License().name("Private").url("https://han-spoon.com")); From 028018f2fd09637bda82673f3bd14a9fe82118a5 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 07:42:37 +0900 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=94=A7chore:=20PR=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=95=AD=EB=AA=A9=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 43f513e..d0145f3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,6 +18,7 @@ ## ✅ Self-Check List - [ ] 스스로 코드를 한 번 이상 리뷰했습니다. (Self-review) -- [ ] `ktlint` 포맷팅과 Kotlin 공식 컨벤션을 준수했습니다. -- [ ] 불필요한 `!!` (Not-null assertion) 사용을 배제하고 안전하게 예외 처리(`?: throw`)를 했습니다. -- [ ] 코드에 불필요한 주석이나 `println`, TODO가 남아있지 않습니다. \ No newline at end of file +- [ ] `./gradlew spotlessApply` 로 포맷팅을 적용했습니다. +- [ ] `./gradlew build` 가 통과합니다. +- [ ] 예외를 `BusinessException` + `ErrorCode` 로 처리했습니다. +- [ ] 코드에 불필요한 주석이나 `System.out.println`, TODO 가 남아있지 않습니다. From d3a7457d348382ea7f3b79018204412f805f1500 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sun, 6 Sep 2026 16:53:30 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=94=A7chore:=20AWS=20ECS=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EB=A1=9C=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=84=A4=EC=A0=95=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 + .env.example | 118 ++++++---------------------- .github/workflows/deploy-prod.yml | 123 +++++++++++++++++++++--------- .gitignore | 2 + docker/Dockerfile | 29 +++---- infra/terraform/compute.tf | 28 +++++-- infra/terraform/variables.tf | 7 +- 7 files changed, 151 insertions(+), 158 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0c9ea89..f7263ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ .gradle build **/build +!build/libs +!build/libs/*.jar .idea *.iml logs diff --git a/.env.example b/.env.example index ddaefee..37e8206 100644 --- a/.env.example +++ b/.env.example @@ -1,107 +1,33 @@ -# Han-Spoon Backend — 환경변수 예시 -# 사용법: 이 파일을 복사해 .env 로 만들고 값을 채우세요. (cp .env.example .env) -# .env 는 .gitignore 에 등록되어 커밋되지 않습니다. 민감정보(secret/password/key)는 절대 커밋하지 마세요. -# 아래 키들은 src/main/resources/application*.yml 의 ${VAR:default} 와 1:1 대응합니다. (default 가 있으면 생략 가능) -# -# [Key Vault 미사용 대비] prod 는 원래 일부 시크릿을 Azure Key Vault(kv-secret-*)에서 주입하지만, -# KV 가 없거나 못 쓸 때를 대비해 동일 시크릿을 환경변수로 폴백받도록 구성돼 있습니다. +# Han-Spoon Backend — 로컬 개발용 환경변수 +# set -a && source .env && set +a && ./gradlew bootRun -# ============================================================ -# Google OAuth (소셜 로그인) -# ============================================================ -GOOGLE_CLIENT_ID=43362237352-l6jq3pff1bt43dnak41qaoi3o14useet.apps.googleusercontent.com -GOOGLE_JWK_SET_URI=https://www.googleapis.com/oauth2/v3/certs +# ── 프로파일 ──────────────────────────────────────────────── +# ⚠ prod 를 넣지 말 것. 로컬에서 RDS·SSM 을 찾다가 기동에 실패한다. +SPRING_PROFILES_ACTIVE=local +SERVER_PORT=8080 -# ============================================================ -# JWT (자체 토큰) -# ============================================================ -# JWT_SECRET: HS256 서명 키. 최소 32바이트 이상의 랜덤 문자열을 직접 생성해 채우세요. (예: openssl rand -base64 48) -# prod 에서도 KV(kv-secret-jwt-signing-key) 부재 시 이 값으로 폴백됩니다. -JWT_SECRET= -JWT_ACCESS_TOKEN_EXPIRATION=30m -JWT_REFRESH_TOKEN_EXPIRATION=14d - -# ============================================================ -# Local Database (PostgreSQL) -# ============================================================ +# ── 로컬 DB ───────────────────────────────────────────────── +# docker compose -f docker/docker-compose.yml up -d postgres LOCAL_DB_URL=jdbc:postgresql://localhost:5432/hanspoon LOCAL_DB_USERNAME=hanspoon LOCAL_DB_PASSWORD=hanspoon -POSTGRES_MAJOR_VERSION=16 -POSTGRES_MINOR_VERSION=0 - -# ============================================================ -# CORS (프론트 출처 허용) -# ============================================================ -# 프론트 dev 서버 출처와 일치시킬 것. 여러 개면 콤마로 구분. -CORS_ALLOWED_ORIGINS=http://localhost:5173 -# ============================================================ -# Azure AI (OpenAI / Document Intelligence) -# ============================================================ -AZURE_AI_ENDPOINT=https://team1-gpt-4o-mini.openai.azure.com/ -# AZURE_AI_API_KEY: Azure AI 키 (민감정보 → 비워둠). -AZURE_AI_API_KEY= +# ── 인증 ──────────────────────────────────────────────────── +GOOGLE_CLIENT_ID= +# HS256 서명 키. 32바이트 이상 랜덤 문자열: openssl rand -base64 48 +JWT_SECRET= -# ============================================================ -# Azure Blob Storage (이미지 업로드 SAS 발급) -# ============================================================ -BLOB_ACCOUNT_NAME=sthanspoonprod -BLOB_CONTAINER=board-images -# BLOB_ACCOUNT_KEY: 스토리지 계정 키 (민감정보 → 비워둠). SAS 서명에 사용. -# 비우면 로컬에선 placeholder 로 기동만 되고 실제 SAS 발급은 실패합니다. (KV 대신 이 env 로 주입) -BLOB_ACCOUNT_KEY= -# (선택) SAS 만료/허용 타입 — 기본값 있어 생략 가능 -BLOB_UPLOAD_SAS_TTL=10m -BLOB_READ_SAS_TTL=15m -BLOB_ALLOWED_CONTENT_TYPES=image/jpeg,image/png,image/webp +# ── AWS S3 ────────────────────────────────────────────────── +S3_BUCKET=hanspoon-prod-images-917dedbe +AWS_REGION=ap-northeast-2 +# MinIO 등 S3 호환 스토리지를 쓸 때만 채움. +S3_ENDPOINT= -# ============================================================ -# AI Service (OCR / Rule Engine — FastAPI 래퍼) -# ============================================================ +# ── AI 서비스 ─────────────────────────────────────────────── AI_SERVICE_BASE_URL=http://localhost:8000 -AI_SERVICE_CONNECT_TIMEOUT=3s -AI_SERVICE_READ_TIMEOUT=30s -# ============================================================ -# 선택 — 기본값이 있어 생략 가능 (튜닝 시에만 설정) -# ============================================================ -# Async ThreadPool -ASYNC_CORE_POOL_SIZE=4 -ASYNC_MAX_POOL_SIZE=16 -ASYNC_QUEUE_CAPACITY=100 -# Caffeine Cache -CACHE_MAXIMUM_SIZE=10000 -CACHE_EXPIRE_AFTER_WRITE=10m -# RestClient timeouts (Azure OpenAI 직통) -REST_CLIENT_CONNECT_TIMEOUT=3s -REST_CLIENT_READ_TIMEOUT=10s -# Bucket4j (OpenAI 호출 quota) -OPENAI_RATE_LIMIT_CAPACITY=30 -OPENAI_RATE_LIMIT_REFILL_PERIOD=1m -# 로그 파일 경로(local) -LOG_FILE=logs/han-spoon-api-local.log +# ── CORS ──────────────────────────────────────────────────── +CORS_ALLOWED_ORIGINS="http://localhost:5173,https://han-spoon.site" -# ============================================================ -# Production(Azure) — Key Vault 미사용 대비 -# ------------------------------------------------------------ -# prod 프로파일(SPRING_PROFILES_ACTIVE=prod)을 KV 없이 구동할 때 채울 값들. -# KV import 는 optional 이라 부재 시 아래 환경변수로 폴백됩니다(우선순위: KV > env). -# 로컬(local 프로파일) 구동에는 불필요합니다. -# ============================================================ -# --- KV 시크릿 대체 (민감정보, KV 못 쓸 때 필수) --- -# DB_PASSWORD= # ← kv-secret-db-password 대체 -# JWT_SECRET= # ← kv-secret-jwt-signing-key 대체 (위 JWT 섹션과 동일 키) -# AZURE_AI_API_KEY= # ← kv-secret-azure-openai-api-key 대체 (위 Azure AI 섹션과 동일 키) -# BLOB_ACCOUNT_KEY= # ← Blob 계정 키 (위 Azure Blob 섹션과 동일 키) -# --- prod 인프라/접속 정보 --- -# AZURE_KEYVAULT_ENDPOINT=https://kv-hanspoon-prod.vault.azure.net/ # KV 안 쓰면 무시됨(optional import) -# DB_PRIVATE_ENDPOINT_URL=jdbc:postgresql://psql-hanspoon-prod.privatelink.postgres.database.azure.com:5432/hanspoon?sslmode=require -# DB_USERNAME=hanspoon_app -# DB_MAX_POOL_SIZE=10 -# DB_MIN_IDLE=2 -# AZURE_AI_PRIVATE_ENDPOINT=https://aoai-hanspoon-prod.openai.azure.com -# JWT_ISSUER=https://api.han-spoon.com -# SERVER_PORT=8080 -# SWAGGER_UI_ENABLED=false -# OPENAPI_DOCS_ENABLED=false +# ── 로그 ──────────────────────────────────────────────────── +LOG_FILE=logs/han-spoon-api-local.log diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 8cc0231..46ab8de 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -1,13 +1,21 @@ name: Deploy (prod) -# main 머지(push) → ACR 클라우드 빌드(새 태그) → ACA 새 이미지로 배포. -# 인증은 GitHub OIDC 페더레이션(장기 비밀번호 없음). 수동 실행도 허용. +# main 머지(push) → ECR 푸시(커밋 SHA 태그) → 태스크 정의 새 리비전 등록 → ECS 배포. + +# Checkout → JDK 21 → Gradle → bootJar +# → AWS OIDC → ECR 로그인 → 이미지 빌드·푸시 +# → 현재 태스크 정의 조회 → 이미지 교체 → 새 리비전 등록·배포 +# → 헬스체크 on: push: branches: [main] + paths-ignore: + - "infra/**" + - "docs/**" + - "**.md" workflow_dispatch: -# 동시 배포 방지: 같은 ref의 이전 배포가 끝날 때까지 새 배포는 대기(취소하지 않음). +# 동시 배포 방지. concurrency: group: deploy-prod cancel-in-progress: false @@ -17,55 +25,98 @@ permissions: contents: read env: - RG: smu-team1 - ACR: acrhanspoonprod - APP: ca-hanspoon-api - IMAGE_REPO: hanspoon-api + AWS_REGION: ap-northeast-2 + ECR_REPOSITORY: hanspoon-prod-backend + ECS_CLUSTER: hanspoon-prod-cluster + ECS_SERVICE: hanspoon-prod-app + TASK_FAMILY: hanspoon-prod-app + CONTAINER_NAME: backend + HEALTH_URL: https://api.han-spoon.site/actuator/health jobs: deploy: - runs-on: ubuntu-latest + # ARM64 native runner. + runs-on: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v4 - - name: Azure login (OIDC) - uses: azure/login@v2 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build jar + run: ./gradlew bootJar -x test + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} - # 커밋 SHA를 태그로 → 매 배포가 고유·불변 이미지가 되어 ACA가 반드시 새 리비전 생성. - - name: Set image tag - id: tag - run: echo "tag=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + - name: Login to ECR + id: ecr + uses: aws-actions/amazon-ecr-login@v2 - - name: ACR build (cloud) + # Build and push Docker image to ECR + - name: Build and push image + id: image + env: + REGISTRY: ${{ steps.ecr.outputs.registry }} run: | - az acr build -r "$ACR" \ - -t "$IMAGE_REPO:${{ steps.tag.outputs.tag }}" \ - -t "$IMAGE_REPO:latest" \ - -f docker/Dockerfile . + IMAGE="$REGISTRY/$ECR_REPOSITORY:${GITHUB_SHA::12}" + docker build -f docker/Dockerfile -t "$IMAGE" . + docker push "$IMAGE" + echo "uri=$IMAGE" >> "$GITHUB_OUTPUT" - - name: Deploy to Container Apps + # Deploy to ECS + - name: Fetch current task definition run: | - az containerapp update -g "$RG" -n "$APP" \ - --image "$ACR.azurecr.io/$IMAGE_REPO:${{ steps.tag.outputs.tag }}" \ - -o none + aws ecs describe-task-definition \ + --task-definition "$TASK_FAMILY" \ + --query 'taskDefinition | { + family: family, + taskRoleArn: taskRoleArn, + executionRoleArn: executionRoleArn, + networkMode: networkMode, + containerDefinitions: containerDefinitions, + requiresCompatibilities: requiresCompatibilities, + cpu: cpu, + memory: memory, + runtimePlatform: runtimePlatform + }' > task-definition.json + + - name: Render task definition + id: render + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: ${{ env.CONTAINER_NAME }} + image: ${{ steps.image.outputs.uri }} + + - name: Deploy to ECS + uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + with: + task-definition: ${{ steps.render.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: true + # 인스턴스 1대 + network_mode host: 교체형 배포. 30~60초 다운타임 후 복귀. - name: Health check run: | - FQDN=$(az containerapp show -g "$RG" -n "$APP" \ - --query properties.configuration.ingress.fqdn -o tsv) - echo "App URL: https://$FQDN" - # 새 리비전이 트래픽을 받을 때까지 최대 ~2.5분 폴링 - for i in $(seq 1 15); do - code=$(curl -s -o /dev/null -w '%{http_code}' "https://$FQDN/actuator/health" || true) - echo " attempt $i: HTTP $code" - if [ "$code" = "200" ]; then - echo "✅ Health OK"; exit 0 + for i in $(seq 1 30); do + if curl -fsS --max-time 10 "$HEALTH_URL" > /dev/null; then + echo "healthy" + exit 0 fi + echo "waiting... ($i/30)" sleep 10 done - echo "::warning::Health check가 200을 반환하지 않았습니다. ACA 로그를 확인하세요." + echo "health check failed" + exit 1 diff --git a/.gitignore b/.gitignore index dbab077..1b394b5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ out/ ### .env ### .env +.env.* +!.env.example ### docs ### docs/ diff --git a/docker/Dockerfile b/docker/Dockerfile index 3becbdd..939be2f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,25 +1,18 @@ # syntax=docker/dockerfile:1 -# 멀티스테이지: 소스로부터 bootJar 빌드 → JRE 런타임 이미지 -# 빌드 컨텍스트는 레포 루트. (az acr build / docker build -f docker/Dockerfile .) -# 1) Build stage -FROM eclipse-temurin:21-jdk AS builder -WORKDIR /workspace +# native로 jar 빌드 → arm64 런타임 이미지에 COPY -# 의존성 캐시 레이어 (빌드 스크립트/래퍼 먼저 복사) -COPY gradlew settings.gradle.kts build.gradle.kts ./ -COPY gradle gradle -RUN chmod +x gradlew && ./gradlew --no-daemon dependencies >/dev/null 2>&1 || true +FROM eclipse-temurin:21-jre -# 소스 복사 후 실행 가능한 jar 생성 (테스트/스폿리스는 CI에서 수행 → 이미지 빌드는 스킵) -COPY src src -RUN ./gradlew --no-daemon clean bootJar -x test -x spotlessCheck +# install curl (ECS healthCheck) +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* -# 2) Runtime stage -FROM eclipse-temurin:21-jre AS runtime WORKDIR /app -# bootJar 산출물 (plain jar는 build.gradle.kts에서 비활성) -COPY --from=builder /workspace/build/libs/*-SNAPSHOT.jar app.jar +COPY build/libs/*-SNAPSHOT.jar app.jar + EXPOSE 8080 -ENV JAVA_OPTS="" -ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"] + +# Run the application +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/infra/terraform/compute.tf b/infra/terraform/compute.tf index f140da7..f3ca7d7 100644 --- a/infra/terraform/compute.tf +++ b/infra/terraform/compute.tf @@ -123,10 +123,11 @@ data "aws_iam_policy_document" "github_assume" { values = ["sts.amazonaws.com"] } + # 저장소/브랜치를 반드시 좁힐 것. 빠뜨리면 아무 저장소나 이 역할을 가정할 수 있다. condition { test = "StringLike" variable = "token.actions.githubusercontent.com:sub" - values = ["repo:${var.github_repository}:ref:refs/heads/main"] + values = [for repo in var.github_repositories : "repo:${repo}:ref:refs/heads/main"] } } } @@ -137,20 +138,34 @@ resource "aws_iam_role" "github_actions" { } data "aws_iam_policy_document" "github_deploy" { + # 계정 단위 액션이라 리소스를 지정할 수 없다 + statement { + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + # 나머지 ECR 액션은 우리 저장소로 한정 statement { actions = [ - "ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload", "ecr:PutImage", + "ecr:BatchGetImage", + "ecr:DescribeImages", ] - resources = ["*"] + resources = [for repo in aws_ecr_repository.app : repo.arn] } + # RegisterTaskDefinition 은 리소스 수준 권한을 지원하지 않는다 statement { - actions = ["ecs:UpdateService", "ecs:DescribeServices", "ecs:RegisterTaskDefinition"] + actions = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition", + "ecs:UpdateService", + "ecs:DescribeServices", + ] resources = ["*"] } @@ -325,7 +340,10 @@ resource "aws_ecs_task_definition" "app" { container_definitions = jsonencode([ { - name = "ai" + name = "ai" + # :latest 는 부트스트랩용 플레이스홀더. ECR 이 IMMUTABLE 이라 재푸시가 불가하므로 + # 실제 배포는 GitHub Actions 가 커밋 SHA 태그로 새 리비전을 등록한다. + # aws_ecs_service 의 ignore_changes = [task_definition] 이 이를 보장한다. image = "${aws_ecr_repository.app["ai"].repository_url}:latest" essential = true memory = local.ai_memory diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 27d76ae..06b8cc0 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -41,9 +41,10 @@ variable "db_instance_class" { default = "db.t4g.micro" } -variable "github_repository" { - type = string - default = "Han-Spoon/backend-api" +variable "github_repositories" { + description = "OIDC 로 배포를 허용할 저장소 목록. 백엔드와 AI 서비스가 같은 태스크 정의를 갱신하므로 역할을 공유한다." + type = list(string) + default = ["Han-Spoon/backend-api", "Han-Spoon/ai"] } variable "alert_email" {