diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b58e6ba
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,23 @@
+.git
+.gradle
+.gradle-user
+.gradle-user-home
+.idea
+.vscode
+.claude
+CLAUDE.md
+build/
+bin/
+out/
+frontend/
+frontend_old/
+docs_tmp/
+status.txt
+status_utf8.txt
+prd.json
+progress.txt
+version.txt
+
+# Environment variables (Secrets)
+.env
+.env.*
diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
new file mode 100644
index 0000000..ebff957
--- /dev/null
+++ b/.github/workflows/deploy-prod.yml
@@ -0,0 +1,79 @@
+name: ReLink Production CI/CD
+
+on:
+ push:
+ branches: [ "main", "dev" ] # main, dev 브랜치로 머지(push) 완료 시에만 실행
+
+jobs:
+ build-backend:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4 # 소스 코드 가져오기
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }} # 도커 허브 로그인
+
+ - name: Build and Push Backend Docker Image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/relink-backend:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/relink-backend:${{ github.sha }}
+
+ build-frontend:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4 # 소스 코드 가져오기
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }} # 도커 허브 로그인
+
+ - name: Build and Push Frontend Docker Image
+ uses: docker/build-push-action@v5
+ with:
+ context: ./frontend
+ file: ./frontend/Dockerfile
+ push: true
+ tags: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/relink-frontend:latest
+ ${{ secrets.DOCKERHUB_USERNAME }}/relink-frontend:${{ github.sha }}
+
+ deploy:
+ needs: [build-backend, build-frontend] # 백엔드와 프론트엔드 빌드가 모두 성공해야 배포 시작
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4 # 설정 파일 복사를 위해 Checkout 추가
+
+ - name: Copy config files via SCP
+ uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
+ with:
+ host: ${{ secrets.PROD_VM1_HOST }}
+ username: ${{ secrets.PROD_VM1_USER }}
+ key: ${{ secrets.PROD_VM1_SSH_KEY }}
+ source: "deploy/prod/web/docker-compose-prod-web.yml,deploy/prod/web/nginx-prod.conf"
+ target: "~/Searchweb/deploy"
+ strip_components: 3
+
+ - name: Deploy to Oracle Cloud VM1
+ uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
+ with:
+ host: ${{ secrets.PROD_VM1_HOST }}
+ username: ${{ secrets.PROD_VM1_USER }}
+ key: ${{ secrets.PROD_VM1_SSH_KEY }}
+ script: |
+ cd ~/Searchweb/deploy
+ # 서버에서 최신 프론트 및 백엔드 이미지로 교체 및 재시작
+ sudo IMAGE_TAG=${{ github.sha }} docker compose -f docker-compose-prod-web.yml pull
+ sudo IMAGE_TAG=${{ github.sha }} docker compose -f docker-compose-prod-web.yml up -d
+ # 오래된 이미지 파일 정리(용량 확보)
+ sudo docker image prune -f
\ No newline at end of file
diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml
deleted file mode 100644
index b8c0ffa..0000000
--- a/.github/workflows/deploy-test.yml
+++ /dev/null
@@ -1,92 +0,0 @@
-name: CI/CD - Searchweb (version.txt 기반)
-
-on:
- push:
- branches: [ "main", "dev" ]
- pull_request:
- branches: [ "main", "dev" ]
-
-jobs:
- build-and-push:
- name: Build & Push Docker Image
- runs-on: ubuntu-latest
-
- outputs:
- image_tag: ${{ steps.meta.outputs.IMAGE_TAG }}
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Read version.txt
- id: meta
- run: |
- VERSION=$(cat version.txt | tr -d ' \n')
- echo "IMAGE_TAG=${VERSION}" >> $GITHUB_OUTPUT
-
- - name: Set up JDK 17
- uses: actions/setup-java@v4
- with:
- distribution: 'temurin'
- java-version: '17'
-
- - name: Grant execute permission
- run: chmod +x ./gradlew
-
- - name: Build JAR
- run: ./gradlew clean bootJar
-
- - name: Docker Login
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Docker Build (version + latest)
- run: |
- docker build \
- -t ${{ secrets.DOCKERHUB_USERNAME }}/searchweb:${{ steps.meta.outputs.IMAGE_TAG }} \
- -t ${{ secrets.DOCKERHUB_USERNAME }}/searchweb:latest \
- .
-
- - name: Docker Push
- run: |
- docker push ${{ secrets.DOCKERHUB_USERNAME }}/searchweb:${{ steps.meta.outputs.IMAGE_TAG }}
- docker push ${{ secrets.DOCKERHUB_USERNAME }}/searchweb:latest
-
- deploy:
- name: Deploy to OCI
- runs-on: ubuntu-latest
- needs: build-and-push
-
- env:
- IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
-
- steps:
- - name: Prepare SSH key
- run: |
- mkdir -p ~/.ssh
- echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
-
- - name: Add Known Hosts
- run: ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
-
- - name: Deploy via SSH
- run: |
- ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
- docker login -u '${{ secrets.DOCKERHUB_USERNAME }}' -p '${{ secrets.DOCKERHUB_TOKEN }}'
- docker pull ${{ secrets.DOCKERHUB_USERNAME }}/searchweb:${IMAGE_TAG}
- docker pull ${{ secrets.DOCKERHUB_USERNAME }}/searchweb:latest
-
- # 기존 컨테이너 강제 삭제 (있으면 지우고, 없으면 에러 무시)
- docker rm -f searchweb 2>/dev/null || true
-
- # 새 컨테이너 실행
- docker run -d \
- --env SPRING_PROFILES_ACTIVE=dev \
- --env-file /home/ubuntu/Searchweb/.env \
- -p 8080:8080 \
- --name searchweb \
- ${{ secrets.DOCKERHUB_USERNAME }}/searchweb:latest
- EOF
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 22faf30..ae5101c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,10 @@ CLAUDE.md
.gradle-user/
status.txt
status_utf8.txt
-docs/Backend/memo/
+docs_tmp/
+
+
+
+
+prd.json
+progress.txt
diff --git a/Dockerfile b/Dockerfile
index dd932ed..eeba46f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,8 @@
+FROM eclipse-temurin:17-jdk-alpine AS builder
+WORKDIR /app
+COPY . .
+RUN chmod +x gradlew && ./gradlew clean bootJar
+
FROM eclipse-temurin:17-jre-alpine
-COPY build/libs/SearchWeb-0.0.1-SNAPSHOT.jar app.jar
+COPY --from=builder /app/build/libs/*-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
\ No newline at end of file
diff --git a/deploy/dev/docker-compose-dev.yml b/deploy/dev/docker-compose-dev.yml
new file mode 100644
index 0000000..dcba2f1
--- /dev/null
+++ b/deploy/dev/docker-compose-dev.yml
@@ -0,0 +1,66 @@
+# dev 환경: 운영(prod)과 동일하게 프론트(Nginx), 백엔드(App), DB를 모두 Docker로 실행.
+services:
+ # (1) dev PostgreSQL 데이터베이스
+ postgres:
+ image: postgres:16
+ container_name: dev-db
+ environment:
+ POSTGRES_DB: ${DEV_POSTGRES_DB}
+ POSTGRES_USER: ${DEV_POSTGRES_USER}
+ POSTGRES_PASSWORD: ${DEV_POSTGRES_PASSWORD}
+ TZ: Asia/Seoul
+ ports:
+ - "7000:5432"
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ - ../../src/main/resources/db/init_postgres.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ networks:
+ - dev-searchweb-network
+
+ # (2) dev Spring Boot 백엔드
+ backend:
+ build:
+ context: ../../
+ dockerfile: Dockerfile
+ container_name: dev-app
+ ports:
+ - "8000:8080"
+ environment:
+ - SPRING_PROFILES_ACTIVE=dev
+ - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/${DEV_POSTGRES_DB}
+ - SPRING_DATASOURCE_USERNAME=${DEV_POSTGRES_USER}
+ - SPRING_DATASOURCE_PASSWORD=${DEV_POSTGRES_PASSWORD}
+ env_file:
+ - ../../.env
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - dev-searchweb-network
+
+ # (3) dev nginx 프론트 정적 서빙 및 백엔드 프록시
+ nginx:
+ image: nginx:alpine
+ container_name: dev-nginx
+ ports:
+ - "4000:80"
+ volumes:
+ - ../../frontend/out:/usr/share/nginx/html:ro
+ - ./nginx-dev.conf:/etc/nginx/conf.d/default.conf:ro
+ depends_on:
+ - backend
+ networks:
+ - dev-searchweb-network
+
+networks:
+ dev-searchweb-network:
+ driver: bridge
+
+volumes:
+ postgres-data:
+ name: dev-postgres-data
diff --git a/deploy/dev/nginx-dev.conf b/deploy/dev/nginx-dev.conf
new file mode 100644
index 0000000..8a5ab89
--- /dev/null
+++ b/deploy/dev/nginx-dev.conf
@@ -0,0 +1,43 @@
+server {
+ listen 80;
+ server_name localhost;
+
+ # Next.js 정적 빌드 결과물을 서빙합니다.
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ try_files $uri $uri.html $uri/ /index.html;
+ }
+
+ # dev Docker 네트워크의 Spring Boot 컨테이너로 API 요청을 전달합니다.
+ location /api/ {
+ proxy_pass http://backend:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # OAuth2 로그인 시작 요청을 Spring Boot 컨테이너로 전달합니다.
+ location /oauth2/ {
+ proxy_pass http://backend:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # OAuth2 provider callback 요청을 Spring Boot 컨테이너로 전달합니다.
+ location /login/oauth2/ {
+ proxy_pass http://backend:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
diff --git a/deploy/local/docker-compose-local.yml b/deploy/local/docker-compose-local.yml
new file mode 100644
index 0000000..7fe074a
--- /dev/null
+++ b/deploy/local/docker-compose-local.yml
@@ -0,0 +1,26 @@
+# local 환경: 프론트(pnpm dev), 백엔드(IDE)는 개별 실행, DB만 Docker로 실행.
+services:
+ postgres:
+ image: postgres:16
+ container_name: local-db
+ environment:
+ POSTGRES_DB: ${DEV_POSTGRES_DB:-searchwebdb}
+ POSTGRES_USER: ${LOCAL_DB_USERNAME}
+ POSTGRES_PASSWORD: ${LOCAL_DB_PASSWORD}
+ TZ: Asia/Seoul
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ - ../../src/main/resources/db/init_postgres.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
+ networks:
+ - local-searchweb-network
+ restart: always
+
+networks:
+ local-searchweb-network:
+ driver: bridge
+
+volumes:
+ postgres-data:
+ name: local-postgres-data
diff --git a/deploy/prod/db/docker-compose-prod-db.yml b/deploy/prod/db/docker-compose-prod-db.yml
new file mode 100644
index 0000000..eabac52
--- /dev/null
+++ b/deploy/prod/db/docker-compose-prod-db.yml
@@ -0,0 +1,28 @@
+version: '3.8'
+
+services:
+ # VM2 전용 PostgreSQL 데이터베이스입니다.
+ postgres:
+ image: postgres:16
+ container_name: relink-db
+ environment:
+ POSTGRES_DB: ${PROD_POSTGRES_DB}
+ POSTGRES_USER: ${PROD_DB_USERNAME}
+ POSTGRES_PASSWORD: ${PROD_DB_PASSWORD}
+ TZ: Asia/Seoul
+ env_file:
+ - ./.env
+ ports:
+ - "${PROD_VM1_INTERNAL_IP}:7000:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./init_postgres.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ restart: always
+
+volumes:
+ postgres_data:
diff --git a/deploy/prod/db/init_postgres.sql b/deploy/prod/db/init_postgres.sql
new file mode 100644
index 0000000..335176a
--- /dev/null
+++ b/deploy/prod/db/init_postgres.sql
@@ -0,0 +1,699 @@
+
+
+BEGIN;
+
+-- =========================
+-- TABLES
+-- =========================
+
+CREATE TABLE IF NOT EXISTS "category_master" (
+ "category_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "parent_category_id" int,
+ "category_name" varchar(80) NOT NULL,
+ "category_level" smallint NOT NULL,
+ "is_active" boolean NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_category_master PRIMARY KEY ("category_id"),
+ CONSTRAINT ck_category_master_level CHECK (category_level in (1,2)),
+ CONSTRAINT fk_category_master_parent_category_id
+ FOREIGN KEY ("parent_category_id") REFERENCES "category_master"("category_id")
+);
+
+CREATE TABLE IF NOT EXISTS "link" (
+ "link_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "canonical_url" text NOT NULL,
+ "original_url" text NOT NULL,
+ "domain" varchar(255),
+ "title" varchar(255),
+ "description" text,
+ "thumbnail_url" text,
+ "favicon_url" text,
+ "content_type" varchar(30) DEFAULT 'link' NOT NULL,
+ "primary_category_id" int NOT NULL,
+ "category_score" numeric(5,4),
+ "classifier_version" varchar(50),
+ "categorized_at" timestamptz,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_link PRIMARY KEY ("link_id"),
+ CONSTRAINT uq_link_canonical_url UNIQUE ("canonical_url"),
+ CONSTRAINT ck_link_content_type CHECK (content_type in ('link','article','video','pdf','etc')),
+ CONSTRAINT ck_link_category_score_range CHECK (category_score is null or (category_score between 0 and 1))
+);
+
+CREATE TABLE IF NOT EXISTS "member" (
+ "member_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "email" varchar(255),
+ "login_id" varchar(50), -- 기존 username 대체
+ "password_hash" varchar(255),
+ "member_name" varchar(20),
+ "nick_name" varchar(50),
+ "job" varchar(20),
+ "major" varchar(20),
+ "summary" varchar(100) DEFAULT NULL,
+ "status" varchar(20) DEFAULT 'active' NOT NULL,
+ "role" varchar(20) DEFAULT 'ROLE_USER' NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_member PRIMARY KEY ("member_id"),
+ CONSTRAINT uq_member_email UNIQUE ("email"),
+ CONSTRAINT uq_member_login_id UNIQUE ("login_id"),
+ CONSTRAINT ck_member_login_pw_pair
+ CHECK ((login_id is null and password_hash is null) OR (login_id is not null and password_hash is not null)),
+ CONSTRAINT ck_member_status CHECK (status in ('active','blocked'))
+);
+
+CREATE TABLE IF NOT EXISTS "refresh_token" (
+ "id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "member_id" bigint NOT NULL,
+ "session_id" varchar(36) NOT NULL,
+ "version" integer NOT NULL DEFAULT 1,
+ "token_hash" varchar(64) NOT NULL,
+ "expires_at" timestamptz NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "rotated_at" timestamptz,
+ "replaced_by_version" integer,
+ "grace_until" timestamptz,
+ CONSTRAINT pk_refresh_token PRIMARY KEY ("id"),
+ CONSTRAINT uq_refresh_token_hash UNIQUE ("token_hash"),
+ CONSTRAINT uq_refresh_token_session_version UNIQUE ("session_id", "version"),
+ CONSTRAINT fk_refresh_token_member_id FOREIGN KEY ("member_id") REFERENCES "member"("member_id") ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS "member_folder" (
+ "member_folder_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "owner_member_id" bigint NOT NULL,
+ "parent_folder_id" int,
+ "folder_name" varchar(80) NOT NULL,
+ "description" text,
+ "folder_type" varchar(20) DEFAULT 'CUSTOM' NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_member_folder PRIMARY KEY ("member_folder_id"),
+ CONSTRAINT ck_member_folder_type CHECK (folder_type IN ('CUSTOM', 'UNORGANIZED')),
+ CONSTRAINT fk_member_folder_parent_folder_id
+ FOREIGN KEY ("parent_folder_id") REFERENCES "member_folder"("member_folder_id"),
+ CONSTRAINT fk_member_folder_owner_member_id
+ FOREIGN KEY ("owner_member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "member_tag" (
+ "member_tag_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "owner_member_id" bigint NOT NULL,
+ "tag_name" varchar(50) NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_member_tag PRIMARY KEY ("member_tag_id"),
+ CONSTRAINT uq_member_tag_owner_tag UNIQUE ("owner_member_id", "tag_name"),
+ CONSTRAINT fk_member_tag_owner_member_id FOREIGN KEY ("owner_member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "team" (
+ "team_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "team_name" varchar(80) NOT NULL,
+ "owner_member_id" int NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_team PRIMARY KEY ("team_id"),
+ CONSTRAINT fk_team_owner_member_id FOREIGN KEY ("owner_member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "team_tag" (
+ "team_tag_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "team_id" int NOT NULL,
+ "tag_name" varchar(50) NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_team_tag PRIMARY KEY ("team_tag_id"),
+ CONSTRAINT uq_team_tag_team_tag UNIQUE ("team_id", "tag_name"),
+ CONSTRAINT fk_team_tag_team_id FOREIGN KEY ("team_id") REFERENCES "team"("team_id")
+);
+
+CREATE TABLE IF NOT EXISTS "team_folder" (
+ "team_folder_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "team_id" int NOT NULL,
+ "parent_folder_id" int,
+ "folder_name" varchar(80) NOT NULL,
+ "description" text,
+ "created_by_member_id" int NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_team_folder PRIMARY KEY ("team_folder_id"),
+ CONSTRAINT fk_team_folder_team_id FOREIGN KEY ("team_id") REFERENCES "team"("team_id"),
+ CONSTRAINT fk_team_folder_parent_folder_id FOREIGN KEY ("parent_folder_id") REFERENCES "team_folder"("team_folder_id")
+);
+
+CREATE TABLE IF NOT EXISTS "team_folder_permission" (
+ "team_folder_permission_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "team_folder_id" int NOT NULL,
+ "member_id" int NOT NULL,
+ "permission" varchar(20) DEFAULT 'viewer' NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_team_folder_permission PRIMARY KEY ("team_folder_permission_id"),
+ CONSTRAINT uq_team_folder_permission_folder_member UNIQUE ("team_folder_id", "member_id"),
+ CONSTRAINT ck_team_folder_permission_permission CHECK (permission in ('viewer','editor')),
+ CONSTRAINT fk_team_folder_permission_folder_id FOREIGN KEY ("team_folder_id") REFERENCES "team_folder"("team_folder_id"),
+ CONSTRAINT fk_team_folder_permission_member_id FOREIGN KEY ("member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "team_member" (
+ "team_member_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "team_id" int NOT NULL,
+ "member_id" int NOT NULL,
+ "role" varchar(20) DEFAULT 'member' NOT NULL,
+ "joined_at" timestamptz DEFAULT now() NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_team_member PRIMARY KEY ("team_member_id"),
+ CONSTRAINT uq_team_member_team_member UNIQUE ("team_id", "member_id"),
+ CONSTRAINT ck_team_member_role CHECK (role in ('owner','admin','member')),
+ CONSTRAINT fk_team_member_team_id FOREIGN KEY ("team_id") REFERENCES "team"("team_id"),
+ CONSTRAINT fk_team_member_member_id FOREIGN KEY ("member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "team_saved_link" (
+ "team_saved_link_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "team_folder_id" int NOT NULL,
+ "link_id" bigint NOT NULL,
+ "created_by_member_id" int NOT NULL,
+ "display_title" varchar(255) NOT NULL,
+ "note" text,
+ "primary_category_id" int,
+ "category_source" varchar(10) DEFAULT 'system' NOT NULL,
+ "category_score" numeric(5,4),
+ "categorized_at" timestamptz,
+ "sort_order" int NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_team_saved_link PRIMARY KEY ("team_saved_link_id"),
+ CONSTRAINT ck_team_saved_link_category_source CHECK (category_source in ('system','member')),
+ CONSTRAINT ck_team_saved_link_category_score CHECK (category_score is null or (category_score between 0 and 1)),
+ CONSTRAINT ck_team_saved_link_sort_nonneg CHECK (sort_order >= 0),
+ CONSTRAINT fk_team_saved_link_folder_id FOREIGN KEY ("team_folder_id") REFERENCES "team_folder"("team_folder_id"),
+ CONSTRAINT fk_team_saved_link_link_id FOREIGN KEY ("link_id") REFERENCES "link"("link_id")
+);
+
+CREATE TABLE IF NOT EXISTS "team_saved_link_tag" (
+ "team_saved_link_tag_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "team_saved_link_id" bigint NOT NULL,
+ "team_tag_id" bigint NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_team_saved_link_tag PRIMARY KEY ("team_saved_link_tag_id"),
+ CONSTRAINT uq_team_saved_link_tag_item_tag UNIQUE ("team_saved_link_id", "team_tag_id"),
+ CONSTRAINT fk_team_saved_link_tag_link_id FOREIGN KEY ("team_saved_link_id") REFERENCES "team_saved_link"("team_saved_link_id"),
+ CONSTRAINT fk_team_saved_link_tag_tag_id FOREIGN KEY ("team_tag_id") REFERENCES "team_tag"("team_tag_id")
+);
+
+
+
+CREATE TABLE IF NOT EXISTS "link_enrichment" (
+ "link_enrichment_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "link_id" bigint NOT NULL,
+ "request_url" text NOT NULL,
+ "final_url" text,
+ "fetch_status" varchar(20) DEFAULT 'pending' NOT NULL,
+ "classify_status" varchar(20) DEFAULT 'pending' NOT NULL,
+ "attempt_count" smallint NOT NULL,
+ "last_attempt_at" timestamptz,
+ "error_code" varchar(50),
+ "error_message" text,
+ "http_status" int,
+ "latency_ms" int,
+ "selected_site_name" varchar(255),
+ "selected_title" text,
+ "selected_description" text,
+ "fetched_at" timestamptz,
+ "predicted_category_id" int,
+ "predicted_score" numeric(5,4),
+ "classifier_version" varchar(50),
+ "classified_at" timestamptz,
+ "keyword_extractor_version" varchar(50),
+ "keyword_source" varchar(30),
+ "keyword_extracted_at" timestamptz,
+ "suggested_member_folder_id" int,
+ "suggested_team_folder_id" int,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_link_enrichment PRIMARY KEY ("link_enrichment_id"),
+ CONSTRAINT ck_link_enrichment_fetch_status CHECK (fetch_status in ('pending','running','success','failed')),
+ CONSTRAINT ck_link_enrichment_classify_status CHECK (classify_status in ('pending','running','success','failed')),
+ CONSTRAINT ck_link_enrichment_attempt_nonneg CHECK (attempt_count >= 0),
+ CONSTRAINT ck_link_enrichment_http_status_range CHECK (http_status is null or (http_status between 100 and 599)),
+ CONSTRAINT ck_link_enrichment_latency_nonneg CHECK (latency_ms is null or latency_ms >= 0),
+ CONSTRAINT ck_link_enrichment_score_range CHECK (predicted_score is null or (predicted_score between 0 and 1)),
+ CONSTRAINT ck_link_enrichment_keyword_source CHECK (keyword_source is null or keyword_source in ('title','description','title_description','other')),
+ CONSTRAINT ck_link_enrichment_suggested_folder_one
+ CHECK ((suggested_member_folder_id is null) OR (suggested_team_folder_id is null)),
+ CONSTRAINT fk_link_enrichment_link_id FOREIGN KEY ("link_id") REFERENCES "link"("link_id")
+);
+
+CREATE TABLE IF NOT EXISTS "oauth_member" (
+ "oauth_member_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "member_id" bigint NOT NULL,
+ "provider" varchar(30) NOT NULL,
+ "provider_member_key" varchar(255) NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_oauth_member PRIMARY KEY ("oauth_member_id"),
+ CONSTRAINT uq_oauth_member_provider_key UNIQUE ("provider", "provider_member_key"),
+ CONSTRAINT fk_oauth_member_member_id FOREIGN KEY ("member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "member_folder_tag" (
+ "member_folder_tag_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "member_folder_id" int NOT NULL,
+ "member_tag_id" bigint NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_member_folder_tag PRIMARY KEY ("member_folder_tag_id"),
+ CONSTRAINT uq_member_folder_tag_item_tag UNIQUE ("member_folder_id", "member_tag_id"),
+ CONSTRAINT fk_member_folder_tag_member_folder_id
+ FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id"),
+ CONSTRAINT fk_member_folder_tag_member_tag_id
+ FOREIGN KEY ("member_tag_id") REFERENCES "member_tag"("member_tag_id")
+);
+
+CREATE TABLE IF NOT EXISTS "folder_suggestion_rule" (
+ "folder_suggestion_rule_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "scope_type" varchar(10) DEFAULT 'member' NOT NULL,
+ "owner_member_id" bigint,
+ "team_id" int,
+ "category_id" int NOT NULL,
+ "member_folder_id" int,
+ "team_folder_id" int,
+ "priority" int NOT NULL,
+ "is_active" boolean NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_folder_suggestion_rule PRIMARY KEY ("folder_suggestion_rule_id"),
+ CONSTRAINT ck_folder_suggestion_rule_scope CHECK (scope_type in ('member','team')),
+ CONSTRAINT ck_folder_suggestion_rule_scope_match CHECK (
+ (scope_type='member' and owner_member_id is not null and team_id is null and member_folder_id is not null and team_folder_id is null)
+ OR
+ (scope_type='team' and team_id is not null and owner_member_id is null and team_folder_id is not null and member_folder_id is null)
+ ),
+ CONSTRAINT ck_folder_suggestion_rule_priority_nonneg CHECK (priority >= 0),
+ CONSTRAINT fk_folder_suggestion_rule_member_folder_id
+ FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id"),
+ CONSTRAINT fk_folder_suggestion_rule_team_folder_id
+ FOREIGN KEY ("team_folder_id") REFERENCES "team_folder"("team_folder_id"),
+ CONSTRAINT fk_folder_suggestion_rule_owner_member_id FOREIGN KEY ("owner_member_id") REFERENCES "member"("member_id"),
+ CONSTRAINT fk_folder_suggestion_rule_team_id FOREIGN KEY ("team_id") REFERENCES "team"("team_id")
+);
+
+CREATE TABLE IF NOT EXISTS "link_enrichment_keyword" (
+ "link_enrichment_keyword_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "link_enrichment_id" int NOT NULL,
+ "keyword" varchar(100) NOT NULL,
+ "score" numeric(5,4),
+ "rank" smallint NOT NULL,
+ "source" varchar(30),
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_link_enrichment_keyword PRIMARY KEY ("link_enrichment_keyword_id"),
+ CONSTRAINT uq_link_enrichment_keyword_enrich_keyword UNIQUE ("link_enrichment_id", "keyword"),
+ CONSTRAINT ck_link_enrichment_keyword_score CHECK (score is null or (score between 0 and 1)),
+ CONSTRAINT ck_link_enrichment_keyword_rank_nonneg CHECK (rank >= 0),
+ CONSTRAINT ck_link_enrichment_keyword_source CHECK (source is null or source in ('title','description','title_description','other')),
+ CONSTRAINT fk_link_enrichment_keyword_link_enrichment_id
+ FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id")
+);
+
+CREATE TABLE IF NOT EXISTS "member_saved_link" (
+ "member_saved_link_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "link_id" bigint NOT NULL,
+ "link_enrichment_id" int,
+ "member_folder_id" int NOT NULL,
+ "display_title" varchar(255) NOT NULL,
+ "note" text,
+ "primary_category_id" int,
+ "category_source" varchar(10) DEFAULT 'system' NOT NULL,
+ "category_score" numeric(5,4),
+ "view_count" integer DEFAULT 0 NOT NULL,
+ "last_viewed_at" timestamptz,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_member_saved_link PRIMARY KEY ("member_saved_link_id"),
+ CONSTRAINT ck_member_saved_link_category_source CHECK (category_source in ('system','member')),
+ CONSTRAINT ck_member_saved_link_category_score CHECK (category_score is null or (category_score between 0 and 1)),
+ CONSTRAINT ck_member_saved_link_view_count_nonneg CHECK (view_count >= 0),
+ CONSTRAINT fk_member_saved_link_link_id FOREIGN KEY ("link_id") REFERENCES "link"("link_id"),
+ CONSTRAINT fk_member_saved_link_link_enrichment_id FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id"),
+ CONSTRAINT fk_member_saved_link_member_folder_id FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id")
+);
+
+-- (idempotent) 기존 DB에 대해 view_count / last_viewed_at 컬럼 추가
+ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL;
+ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "last_viewed_at" timestamptz;
+
+CREATE TABLE IF NOT EXISTS "link_enrichment_feedback" (
+ "link_enrichment_feedback_id" int GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "link_enrichment_id" int NOT NULL,
+ "member_saved_link_id" int,
+ "action" varchar(20) NOT NULL,
+ "suggested_member_folder_id" int,
+ "final_member_folder_id" int,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_link_enrichment_feedback PRIMARY KEY ("link_enrichment_feedback_id"),
+ CONSTRAINT ck_link_enrichment_feedback_action CHECK (action in ('ACCEPT','MOVE','REJECT','IGNORE')),
+ CONSTRAINT fk_link_enrichment_feedback_link_enrichment_id
+ FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id")
+);
+
+CREATE TABLE IF NOT EXISTS "member_saved_link_tag" (
+ "member_saved_link_tag_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "member_saved_link_id" bigint NOT NULL,
+ "member_tag_id" bigint NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_member_saved_link_tag PRIMARY KEY ("member_saved_link_tag_id"),
+ CONSTRAINT uq_member_saved_link_tag_item_tag UNIQUE ("member_saved_link_id", "member_tag_id"),
+ CONSTRAINT fk_member_saved_link_tag_member_saved_link_id
+ FOREIGN KEY ("member_saved_link_id") REFERENCES "member_saved_link"("member_saved_link_id"),
+ CONSTRAINT fk_member_saved_link_tag_member_tag_id
+ FOREIGN KEY ("member_tag_id") REFERENCES "member_tag"("member_tag_id")
+);
+
+-- ==================================================
+-- LEGACY TABLES (Board, Comment, Likes, Website)
+-- ==================================================
+
+CREATE TABLE IF NOT EXISTS "website" (
+ "website_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "name" varchar(100) NOT NULL,
+ "korean_name" varchar(100),
+ "description" text,
+ "url" text NOT NULL,
+ "category" varchar(50),
+ "subcategory" varchar(50),
+ "view_count" bigint DEFAULT 0 NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_website PRIMARY KEY ("website_id")
+);
+
+CREATE TABLE IF NOT EXISTS "board" (
+ "board_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "member_member_id" bigint NOT NULL,
+ "url" text,
+ "title" varchar(255) NOT NULL,
+ "summary" text,
+ "description" text,
+ "hashtags" text,
+ "likes_count" int DEFAULT 0 NOT NULL,
+ "comments_count" int DEFAULT 0 NOT NULL,
+ "bookmarks_count" int DEFAULT 0 NOT NULL,
+ "views_count" int DEFAULT 0 NOT NULL,
+ "created_date" timestamptz DEFAULT now() NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_board PRIMARY KEY ("board_id"),
+ CONSTRAINT fk_board_member_id FOREIGN KEY ("member_member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "comment" (
+ "comment_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "board_board_id" bigint NOT NULL,
+ "member_member_id" bigint NOT NULL,
+ "member_nickname" varchar(50),
+ "member_job" varchar(20),
+ "member_major" varchar(20),
+ "content" text NOT NULL,
+ "created_date" timestamptz DEFAULT now() NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_comment PRIMARY KEY ("comment_id"),
+ CONSTRAINT fk_comment_board_id FOREIGN KEY ("board_board_id") REFERENCES "board"("board_id"),
+ CONSTRAINT fk_comment_member_id FOREIGN KEY ("member_member_id") REFERENCES "member"("member_id")
+);
+
+CREATE TABLE IF NOT EXISTS "likes" (
+ "likes_id" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
+ "board_board_id" bigint NOT NULL,
+ "member_member_id" bigint NOT NULL,
+ "is_liked" boolean DEFAULT false NOT NULL,
+ "created_at" timestamptz DEFAULT now() NOT NULL,
+ "updated_at" timestamptz DEFAULT now() NOT NULL,
+ "deleted_at" timestamptz,
+ "created_by_member_id" bigint,
+ "updated_by_member_id" bigint,
+ "deleted_by_member_id" bigint,
+ CONSTRAINT pk_likes PRIMARY KEY ("likes_id"),
+ CONSTRAINT uq_likes_board_member UNIQUE ("board_board_id", "member_member_id"),
+ CONSTRAINT fk_likes_board_id FOREIGN KEY ("board_board_id") REFERENCES "board"("board_id"),
+ CONSTRAINT fk_likes_member_id FOREIGN KEY ("member_member_id") REFERENCES "member"("member_id")
+);
+
+-- =========================
+-- INDEXES (원본 유지 + 위험한 이름만 개선)
+-- =========================
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_category_child_name
+ ON "category_master" ("parent_category_id", "category_name")
+ WHERE parent_category_id IS NOT NULL AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_category_parent ON "category_master" ("parent_category_id");
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_category_root_name
+ ON "category_master" ("category_name")
+ WHERE parent_category_id IS NULL AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_category_name ON "category_master" ("category_name");
+CREATE INDEX IF NOT EXISTS idx_category_level ON "category_master" ("category_level");
+CREATE INDEX IF NOT EXISTS idx_category_active ON "category_master" ("is_active");
+CREATE INDEX IF NOT EXISTS idx_category_deleted_at ON "category_master" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_link_domain ON "link" ("domain");
+CREATE INDEX IF NOT EXISTS idx_link_original_url ON "link" ("original_url");
+CREATE INDEX IF NOT EXISTS idx_link_title ON "link" ("title");
+CREATE INDEX IF NOT EXISTS idx_link_content_type ON "link" ("content_type");
+CREATE INDEX IF NOT EXISTS idx_link_primary_category ON "link" ("primary_category_id");
+CREATE INDEX IF NOT EXISTS idx_link_categorized_at ON "link" ("categorized_at");
+CREATE INDEX IF NOT EXISTS idx_link_created_at ON "link" ("created_at");
+CREATE INDEX IF NOT EXISTS idx_link_deleted_at ON "link" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_member_status ON "member" ("status");
+CREATE INDEX IF NOT EXISTS idx_member_created_at ON "member" ("created_at");
+CREATE INDEX IF NOT EXISTS idx_member_deleted_at ON "member" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_refresh_token_member ON "refresh_token" ("member_id");
+CREATE INDEX IF NOT EXISTS idx_refresh_token_hash ON "refresh_token" ("token_hash");
+CREATE INDEX IF NOT EXISTS idx_refresh_token_expires ON "refresh_token" ("expires_at");
+
+CREATE INDEX IF NOT EXISTS idx_member_folder_owner ON "member_folder" ("owner_member_id");
+CREATE INDEX IF NOT EXISTS idx_member_folder_parent ON "member_folder" ("parent_folder_id");
+CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_parent_name
+ ON "member_folder" ("owner_member_id", "parent_folder_id", "folder_name")
+ WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_member_folder_name ON "member_folder" ("folder_name");
+CREATE INDEX IF NOT EXISTS idx_member_folder_created ON "member_folder" ("created_at");
+CREATE INDEX IF NOT EXISTS idx_member_folder_deleted ON "member_folder" ("deleted_at");
+-- 한 사용자당 시스템 폴더(UNORGANIZED)는 정확히 1개만 활성 상태로 존재
+CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_owner_unorganized
+ ON "member_folder" ("owner_member_id")
+ WHERE folder_type = 'UNORGANIZED' AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_member_tag_owner ON "member_tag" ("owner_member_id");
+CREATE INDEX IF NOT EXISTS idx_member_tag_deleted_at ON "member_tag" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_team_name ON "team" ("team_name");
+CREATE INDEX IF NOT EXISTS idx_team_owner ON "team" ("owner_member_id");
+CREATE INDEX IF NOT EXISTS idx_team_deleted ON "team" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_team_folder_team ON "team_folder" ("team_id");
+CREATE INDEX IF NOT EXISTS idx_team_folder_parent ON "team_folder" ("parent_folder_id");
+CREATE UNIQUE INDEX IF NOT EXISTS uq_team_folder_parent_name
+ ON "team_folder" ("team_id", "parent_folder_id", "folder_name")
+ WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_team_folder_name ON "team_folder" ("folder_name");
+CREATE INDEX IF NOT EXISTS idx_team_folder_created_by ON "team_folder" ("created_by_member_id");
+CREATE INDEX IF NOT EXISTS idx_team_folder_created ON "team_folder" ("created_at");
+CREATE INDEX IF NOT EXISTS idx_team_folder_deleted ON "team_folder" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_tfp_team_folder ON "team_folder_permission" ("team_folder_id");
+CREATE INDEX IF NOT EXISTS idx_tfp_member ON "team_folder_permission" ("member_id");
+CREATE INDEX IF NOT EXISTS idx_tfp_permission ON "team_folder_permission" ("permission");
+
+CREATE INDEX IF NOT EXISTS idx_team_member_team ON "team_member" ("team_id");
+CREATE INDEX IF NOT EXISTS idx_team_member_member ON "team_member" ("member_id");
+CREATE INDEX IF NOT EXISTS idx_team_member_role ON "team_member" ("role");
+
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_folder ON "team_saved_link" ("team_folder_id");
+CREATE UNIQUE INDEX IF NOT EXISTS uq_team_saved_link_folder_link
+ ON "team_saved_link" ("team_folder_id", "link_id")
+ WHERE deleted_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_link ON "team_saved_link" ("link_id");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_created_by ON "team_saved_link" ("created_by_member_id");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_title ON "team_saved_link" ("display_title");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_primary_category ON "team_saved_link" ("primary_category_id");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_categorized_at ON "team_saved_link" ("categorized_at");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_folder_sort ON "team_saved_link" ("team_folder_id", "sort_order");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_created_at ON "team_saved_link" ("created_at");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_deleted_at ON "team_saved_link" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_tag_item ON "team_saved_link_tag" ("team_saved_link_id");
+CREATE INDEX IF NOT EXISTS idx_team_saved_link_tag_tag ON "team_saved_link_tag" ("team_tag_id");
+
+CREATE INDEX IF NOT EXISTS idx_team_tag_team ON "team_tag" ("team_id");
+
+CREATE INDEX IF NOT EXISTS idx_link_enrichment_deleted_at ON "link_enrichment" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_oauth_member_member_id ON "oauth_member" ("member_id");
+CREATE INDEX IF NOT EXISTS idx_oauth_member_provider ON "oauth_member" ("provider");
+CREATE INDEX IF NOT EXISTS idx_oauth_member_deleted_at ON "oauth_member" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_member_folder_tag_item ON "member_folder_tag" ("member_folder_id");
+CREATE INDEX IF NOT EXISTS idx_member_folder_tag_tag ON "member_folder_tag" ("member_tag_id");
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_fsr_member_owner_category
+ ON "folder_suggestion_rule" ("owner_member_id", "category_id")
+ WHERE scope_type='member' AND is_active=true AND deleted_at IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_fsr_team_category
+ ON "folder_suggestion_rule" ("team_id", "category_id")
+ WHERE scope_type='team' AND is_active=true AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_fsr_scope ON "folder_suggestion_rule" ("scope_type");
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_fsr_scope_owner_team_category_active
+ ON "folder_suggestion_rule" ("scope_type", "owner_member_id", "team_id", "category_id")
+ WHERE is_active = true;
+
+CREATE INDEX IF NOT EXISTS idx_fsr_owner ON "folder_suggestion_rule" ("owner_member_id");
+CREATE INDEX IF NOT EXISTS idx_fsr_team ON "folder_suggestion_rule" ("team_id");
+CREATE INDEX IF NOT EXISTS idx_fsr_category ON "folder_suggestion_rule" ("category_id");
+CREATE INDEX IF NOT EXISTS idx_fsr_member_folder ON "folder_suggestion_rule" ("member_folder_id");
+CREATE INDEX IF NOT EXISTS idx_fsr_team_folder ON "folder_suggestion_rule" ("team_folder_id");
+CREATE INDEX IF NOT EXISTS idx_fsr_priority ON "folder_suggestion_rule" ("priority");
+CREATE INDEX IF NOT EXISTS idx_fsr_active ON "folder_suggestion_rule" ("is_active");
+CREATE INDEX IF NOT EXISTS idx_folder_suggestion_rule_deleted_at ON "folder_suggestion_rule" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_link_enrich_kw_enrichment ON "link_enrichment_keyword" ("link_enrichment_id");
+CREATE INDEX IF NOT EXISTS idx_link_enrich_kw_keyword ON "link_enrichment_keyword" ("keyword");
+CREATE INDEX IF NOT EXISTS idx_link_enrich_kw_created_at ON "link_enrichment_keyword" ("created_at");
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_member_saved_link_folder_link
+ ON "member_saved_link" ("member_folder_id", "link_id")
+ WHERE deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_link ON "member_saved_link" ("link_id");
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_enrichment ON "member_saved_link" ("link_enrichment_id");
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_folder ON "member_saved_link" ("member_folder_id");
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_title ON "member_saved_link" ("display_title");
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_primary_category ON "member_saved_link" ("primary_category_id");
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_created_at ON "member_saved_link" ("created_at");
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_deleted_at ON "member_saved_link" ("deleted_at");
+
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_tag_item ON "member_saved_link_tag" ("member_saved_link_id");
+CREATE INDEX IF NOT EXISTS idx_member_saved_link_tag_tag ON "member_saved_link_tag" ("member_tag_id");
+
+COMMIT;
+
+-- Indexes for Legacy Tables
+CREATE INDEX IF NOT EXISTS idx_website_url ON "website" ("url");
+CREATE INDEX IF NOT EXISTS idx_website_category ON "website" ("category");
+
+CREATE INDEX IF NOT EXISTS idx_board_member ON "board" ("member_member_id");
+CREATE INDEX IF NOT EXISTS idx_board_title ON "board" ("title");
+CREATE INDEX IF NOT EXISTS idx_board_created_date ON "board" ("created_date");
+
+CREATE INDEX IF NOT EXISTS idx_comment_board ON "comment" ("board_board_id");
+CREATE INDEX IF NOT EXISTS idx_comment_member ON "comment" ("member_member_id");
+
+CREATE INDEX IF NOT EXISTS idx_likes_board ON "likes" ("board_board_id");
+CREATE INDEX IF NOT EXISTS idx_likes_member ON "likes" ("member_member_id");
+
diff --git a/deploy/prod/web/docker-compose-prod-web.yml b/deploy/prod/web/docker-compose-prod-web.yml
new file mode 100644
index 0000000..5432c0e
--- /dev/null
+++ b/deploy/prod/web/docker-compose-prod-web.yml
@@ -0,0 +1,34 @@
+version: '3.8'
+
+services:
+ # (1) 백엔드 스프링 부트 앱
+ backend:
+ image: ${DOCKERHUB_USERNAME}/relink-backend:${IMAGE_TAG:-latest}
+ container_name: relink-app
+ environment:
+ - SPRING_PROFILES_ACTIVE=prod
+ - SERVER_FORWARD_HEADERS_STRATEGY=native
+ - SPRING_DATASOURCE_URL=jdbc:postgresql://${PROD_VM2_INTERNAL_IP}:7000/${PROD_POSTGRES_DB}
+ - SPRING_DATASOURCE_USERNAME=${PROD_DB_USERNAME}
+ - SPRING_DATASOURCE_PASSWORD=${PROD_DB_PASSWORD}
+ env_file:
+ - ./.env
+ restart: always
+
+ # (2) Nginx 웹 서버
+ nginx:
+ image: ${DOCKERHUB_USERNAME}/relink-frontend:${IMAGE_TAG:-latest}
+ container_name: relink-nginx
+ ports:
+ - "80:8080"
+ - "443:443"
+ volumes:
+ - ./nginx-prod.conf:/etc/nginx/conf.d/default.conf:ro
+ - ./certs:/etc/nginx/certs:ro
+ depends_on:
+ - backend
+ restart: always
+
+networks:
+ default:
+ name: searchweb-network
diff --git a/deploy/prod/web/nginx-prod.conf b/deploy/prod/web/nginx-prod.conf
new file mode 100644
index 0000000..3cbb219
--- /dev/null
+++ b/deploy/prod/web/nginx-prod.conf
@@ -0,0 +1,81 @@
+server {
+ listen 8080;
+ server_name relink.ai.kr;
+
+ # --- Cloudflare Real IP 복원 설정 ---
+ # 모든 트래픽이 Cloudflare IP로 들어오면서 발생하는 병목 현상 및 IP 인식 오류 방지
+ set_real_ip_from 103.21.244.0/22;
+ set_real_ip_from 103.22.200.0/22;
+ set_real_ip_from 103.31.4.0/22;
+ set_real_ip_from 104.16.0.0/13;
+ set_real_ip_from 104.24.0.0/14;
+ set_real_ip_from 108.162.192.0/18;
+ set_real_ip_from 131.0.72.0/22;
+ set_real_ip_from 141.101.64.0/18;
+ set_real_ip_from 162.158.0.0/15;
+ set_real_ip_from 172.64.0.0/13;
+ set_real_ip_from 173.245.48.0/20;
+ set_real_ip_from 188.114.96.0/20;
+ set_real_ip_from 190.93.240.0/20;
+ set_real_ip_from 197.234.240.0/22;
+ set_real_ip_from 198.41.128.0/17;
+ real_ip_header CF-Connecting-IP;
+ # ------------------------------------
+
+ # 정적 파일과 API 응답 압축을 켭니다.
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
+
+ # 기본 보안 헤더입니다.
+ add_header X-Frame-Options "SAMEORIGIN";
+ add_header X-XSS-Protection "1; mode=block";
+ add_header X-Content-Type-Options "nosniff";
+
+ # Next.js 정적 빌드 결과물을 서빙합니다.
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ try_files $uri $uri.html $uri/ /index.html;
+
+ # Docker 헬스체크(User-Agent가 Wget인 경우) 로그 기록 해제
+ if ($http_user_agent ~* "Wget") {
+ access_log off;
+ }
+
+ expires 1h;
+ add_header Cache-Control "public, no-transform";
+ }
+
+ # 백엔드 API 요청을 Spring Boot 컨테이너로 전달합니다.
+ location /api/ {
+ proxy_pass http://backend:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+ }
+
+ # OAuth2 로그인 시작 요청을 Spring Boot 컨테이너로 전달합니다.
+ location /oauth2/ {
+ proxy_pass http://backend:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+ }
+
+ # OAuth2 provider callback 요청을 Spring Boot 컨테이너로 전달합니다.
+ location /login/oauth2/ {
+ proxy_pass http://backend:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+ }
+
+ # nginx 기본 50x 에러 페이지입니다.
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 88edc98..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-version: '3.8'
-
-services:
- postgres:
- image: postgres:16
- container_name: searchweb-db
- environment:
- POSTGRES_DB: ${POSTGRES_DB}
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- TZ: Asia/Seoul
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- # - ./src/main/resources/db/init.sql:/docker-entrypoint-initdb.d/init.sql
- networks:
- - searchweb-network
- restart: always
-
-networks:
- searchweb-network:
- driver: bridge
-
-volumes:
- postgres_data:
diff --git a/docs/Infrastructure/01. network-and-infra.md b/docs/Infrastructure/01. network-and-infra.md
new file mode 100644
index 0000000..5a36761
--- /dev/null
+++ b/docs/Infrastructure/01. network-and-infra.md
@@ -0,0 +1,103 @@
+# 🌐 Relink 인프라 & 배포 아키텍처 가이드
+
+본 문서는 **Relink** 서비스의 2-Tier 운영 인프라 아키텍처와 네트워크 데이터 흐름을 핵심만 간결하게 설명합니다.
+
+---
+
+## 1. 아키텍처 개요 (High-Level Topology)
+현재 리소스 제약(각 VM 1GB RAM) 하에서 가장 가볍고 안정적인 **2-VM 기반 3-Tier 아키텍처(웹/앱 서버와 데이터베이스 서버 분리) 구조**를 채택하고 있습니다.
+
+
+
+* **VM 1 (Web / Application Server)**: Nginx (Reverse Proxy & Next.js 정적 서빙) + Spring Boot API
+* **VM 2 (Database Server)**: PostgreSQL 16 (인터넷 망 분리)
+
+---
+
+## 2. 네트워크 & 데이터 흐름 (Data Flow)
+
+```mermaid
+graph TD
+ User((사용자))
+ CF[Cloudflare Proxy]
+
+ subgraph VM1 ["VM 1 (Application Server)"]
+ direction TB
+ P80[HTTP 80 Ingress]
+ subgraph Docker ["Docker Stack"]
+ Nginx[Nginx Proxy
Next.js Static Web]
+ Spring[Spring Boot API
Port 8080]
+ end
+ end
+
+ subgraph VM2 ["VM 2 (Database Server)"]
+ DB[(PostgreSQL
Port 7000)]
+ end
+
+ %% 연결 관계
+ User -- "HTTPS (443)" --> CF
+ CF -- "HTTP (80)" --> P80
+ P80 --> Nginx
+ Nginx -- "Static Serving" --> User
+ Nginx -- "Reverse Proxy" --> Spring
+ Spring -- "Private IP" --> DB
+
+ %% 스타일링
+ style CF fill:#f39c12,stroke:#d35400,color:#fff
+ style Nginx fill:#27ae60,stroke:#1e8449,color:#fff
+ style Spring fill:#2980b9,stroke:#2471a3,color:#fff
+ style DB fill:#8e44ad,stroke:#713d8d,color:#fff
+ style P80 fill:#e74c3c,stroke:#c0392b,color:#fff
+```
+
+### 2.1 실제 데이터 흐름 시나리오
+1. **사용자 ➡️ Cloudflare**: 사용자는 안전한 **HTTPS(443)** 포트로 도메인을 통해 Cloudflare Proxy 서버에 접속합니다.
+2. **Cloudflare ➡️ VM 1 (HTTP 80 Ingress)**: Cloudflare는 전달받은 암호화 트래픽을 복호화(Flexible SSL 모드)한 후, 본 서버인 VM 1의 **HTTP(80)** 포트로 요청을 보냅니다.
+3. **VM 1 방화벽 통과**: 이 요청이 VM 1의 가상 네트워크(VCN) 보안 규칙 및 OS 방화벽에 정의된 **HTTP 80 Ingress** 허용 목록을 통과하여 비로소 서버 내부로 진입하게 됩니다.
+4. **VM 1 (Nginx Proxy)**: 유입된 요청을 Nginx가 식별하여 다이렉트 처리 혹은 포워딩을 수행합니다.
+ * **정적 리소스**: Next.js 정적 페이지 파일(HTML/JS/CSS 등)을 즉시 **사용자에게 직접 반환(서빙)**합니다.
+ * **API / OAuth 요청**: `/api`, `/oauth2`, `/login/oauth2` 경로의 요청을 내부 Docker 네트워크망을 통해 **Spring Boot(8080)**로 리버스 프록시(전달)합니다.
+5. **VM 1 ➡️ VM 2 (PostgreSQL)**: Spring Boot가 비즈니스 로직을 처리하는 과정에서, 사설 네트워크 대역을 안전하게 경유하여 VM 2의 **PostgreSQL(7000)**에 쿼리를 수행하고 데이터를 반환받아 최종 응답을 빌드합니다.
+
+
+---
+
+## 3. 구성 요소 핵심 역할 및 포트 설계
+
+### 3.1 VM 1 (Web / Application Server)
+* **역할**: 웹 프론트엔드 정적 파일 서빙, API 요청 라우팅, 백엔드 비즈니스 로직 구동
+* **주요 포트 설정**:
+ * **HTTP (80)**: 외부 오픈 (Cloudflare Proxy 유입 처리)
+ * **HTTPS (443)**: Cloudflare 측에서 HTTPS 종단 처리(Flexible 모드)를 수행하므로 VM에서는 직접 다루지 않고 80 포트로 포워딩받음
+ * **SSH (22)**: 보안상 관리자 지정 IP에서만 접근 허용
+ * **8080 (Spring API)**: Docker 내부망을 통해서만 Nginx와 통신 (외부 직접 노출 차단)
+
+### 3.2 VM 2 (Database Server)
+* **역할**: 회원 데이터, 링크 메타데이터 등 중요 영속성 데이터 저장 및 격리
+* **주요 포트 설정**:
+ * **PostgreSQL (7000)**: VM 1의 **Private IP** 대역에서만 접근이 가능하도록 방화벽 및 OCI 보안 규칙 설정 (인터넷 상의 다이렉트 접근 완전 봉쇄)
+ * **SSH (22)**: 관리자 지정 IP에서만 접근 허용
+
+---
+
+## 4. 인프라 운영 핵심 체크포인트 (521 에러 및 통신 문제 대응)
+
+> 🚨 **[IMPORTANT] 네트워크 통신 불통 및 521(Web Server is Down) 에러 발생 시 최우선 확인 리스트:**
+> 1. **Cloudflare SSL/TLS 설정**: 반드시 **Flexible** 모드로 세팅되어 있어야 합니다. (Full/Strict 모드 사용 시 VM 1 측의 443 포트 및 인증서 부재로 521 에러 유발)
+> 2. **OCI VCN (가상 클라우드 네트워크) 수신 규칙**:
+> * **VM 1**: 80 포트(TCP)가 모든 대역(`0.0.0.0/0`) 혹은 Cloudflare IP 대역에 열려 있는지 확인
+> * **VM 2**: 7000 포트(TCP)가 VM 1의 Private IP 기준으로 열려 있는지 확인
+> 3. **서버 내부 Docker 상태**:
+> * `sudo docker ps` 명령어로 `relink-nginx` 및 `relink-backend` 컨테이너가 정상 기동(Up) 중인지 확인
+
+---
+
+## 5. 🔒 Cloudflare 기반 보안 격리 (IP 화이트리스팅)
+
+공격자가 본 서버(VM 1)의 퍼블릭 IP로 직접 우회 접속(Bypass)하거나 가짜 프로토콜 헤더를 조작하여 전송하는 **헤더 스푸핑(Header Spoofing)** 위협을 방어하기 위한 보안 구성입니다.
+
+* **네트워크 격리:** OCI 수신 규칙에서 `0.0.0.0/0` (전체 허용)을 삭제하고, 오직 Cloudflare 공식 IP 대역만 VM 1의 HTTP(80) 포트에 접근할 수 있도록 수신을 차단/격리했습니다.
+* **Real IP 복원:** Nginx에서 `set_real_ip_from` 설정을 통해 Cloudflare IP가 아닌, 실제 접속자의 원본 IP를 올바르게 식별하도록 복원합니다.
+
+> 💡 **[TIP] 상세 아키텍처 다이어그램, OCI 설정 절차 및 속도 지연(병목) 트러블슈팅 가이드는 아래 전용 문서를 참고하세요.**
+> * 📄 **[Cloudflare Flexible 모드 우회 및 헤더 스푸핑 방지 가이드 (Origin Shielding)](../Troubleshooting/01. cloudflare-origin-shielding.md)**
diff --git a/docs/Infrastructure/documentation_guide.md b/docs/Infrastructure/documentation_guide.md
new file mode 100644
index 0000000..14df2cc
--- /dev/null
+++ b/docs/Infrastructure/documentation_guide.md
@@ -0,0 +1,53 @@
+# 📘 Relink 인프라 문서화 가이드 (Success Formula)
+
+이 문서는 Relink 프로젝트의 인프라 아키텍처를 시각적으로 일관되게 기록하기 위한 가이드라인입니다. 나중에 AI에게 작업을 요청하거나 직접 문서를 수정할 때 이 규칙을 따르면 항상 고퀄리티의 결과물을 얻을 수 있습니다.
+
+---
+
+### 🎨 1. 시각적 이미지 생성 (generate_image 전용)
+
+가장 깔끔하고 전문적인 인프라 이미지를 얻기 위한 프롬프트 공식입니다.
+
+**[Standard Prompt]**
+> "A professional isometric technical diagram of a web application deployment. Clean white background, muted professional colors (blue, slate, soft green). Inside a box representing 'Oracle Cloud VM', there are two distinct modules: one clearly labeled 'Frontend (Nginx / Next.js)' and another 'Backend (Spring Boot)'. A separate icon for 'PostgreSQL' connected by a line. A Cloudflare logo and a User icon. Premium minimalist design, high resolution, with modern typography. The Nginx and Next.js are grouped together in one clean module to show they work together. 3D isometric style with soft shadows."
+
+**[핵심 규칙]**
+* **배경**: 반드시 `Clean white background` (최고의 가독성)
+* **스타일**: `Isometric 3D`, `Minimalist`, `Premium`
+* **색상**: `Muted professional colors` (채도가 너무 높지 않은 차분한 색상)
+
+---
+
+### 📊 2. 상세 다이어그램 (Mermaid 스타일)
+
+Mermaid 다이어그램의 일관성을 위해 다음 색상 코드를 유지합니다.
+
+**[색상 팔레트]**
+* **Cloudflare**: `#f39c12` (주황색 - 보안 및 외부 서비스)
+* **Frontend**: `#27ae60` (초록색 - 웹 서버 및 정적 자산)
+* **Backend**: `#2980b9` (파란색 - API 및 서버 로직)
+* **Database**: `#8e44ad` (보라색 - 데이터 저장소)
+* **Firewall/Port**: `#e74c3c` (빨간색 - 보안 경계 및 입구)
+
+**[표기 규칙]**
+* 한글과 영어를 병기하되, 기술적 용어는 영어를 우선 (예: `Frontend
Next.js / Nginx`)
+* `subgraph`를 활용하여 물리적 서버(VM)와 논리적 스택(Docker)을 구분
+
+---
+
+### 📝 3. 문서 작성 철학 (Content Rules)
+
+1. **텍스트 최소화**: 서술형 문장보다는 표(Table)와 키워드 위주로 정보 전달.
+2. **계층적 구조**:
+ * `High-Level` (전체 그림) -> `Detailed Flow` (상세 흐름) -> `Core Roles` (핵심 역할) 순서로 구성.
+3. **Callout 활용**: `[!TIP]`, `[!IMPORTANT]` 등을 사용하여 사용자가 지금 당장 봐야 할 행동(Action Item)을 강조.
+
+---
+
+### 🚀 AI에게 요청하는 마법의 프롬프트
+
+나중에 인프라 변경 시 AI에게 다음과 같이 채팅하세요:
+
+> "기존 `docs/Infrastructure/architecture.md`를 업데이트해줘. 스타일과 색상 코드는 `documentation_guide.md`의 성공 공식을 100% 따르고, 텍스트는 최소화해서 세련되게 만들어줘."
+
+---
diff --git a/docs/Infrastructure/images/relink_architecture.png b/docs/Infrastructure/images/relink_architecture.png
new file mode 100644
index 0000000..0e0378b
Binary files /dev/null and b/docs/Infrastructure/images/relink_architecture.png differ
diff --git a/docs/Plan/07. link_analysis_context_mode_plan.md b/docs/Plan/07. link_analysis_context_mode_plan.md
new file mode 100644
index 0000000..d92a5ba
--- /dev/null
+++ b/docs/Plan/07. link_analysis_context_mode_plan.md
@@ -0,0 +1,259 @@
+# AI 링크 분석 컨텍스트 모드 개선 계획서
+
+## 1. 배경
+
+현재 AI 링크 분석은 사용자의 기존 폴더와 태그를 최대한 활용하도록 설계되어 있다. 이 방향은 사용자의 저장 이력과 분류 체계가 충분히 쌓인 경우에는 유효하지만, 신규 사용자나 저장 데이터가 적은 사용자에게는 부정확한 추천을 만들 수 있다.
+
+특히 초기 사용자는 기존 폴더와 태그가 아직 취향이나 분류 기준을 충분히 반영하지 못한다. 이 상태에서 AI가 기존 후보를 강하게 재사용하려고 하면, 링크 내용과 더 잘 맞는 신규 폴더/태그를 제안하지 못하고 빈약한 기존 후보에 억지로 맞추는 문제가 생긴다.
+
+## 2. 목표
+
+- 초기 사용자에게는 링크 내용 중심의 자연스러운 신규 폴더/태그 제안을 강화한다.
+- 저장 이력이 충분한 사용자에게는 기존 폴더/태그 재사용을 우선하여 개인화 품질을 유지한다.
+- 프롬프트 파일을 과도하게 분리하지 않고, 하나의 공통 프롬프트 구조 안에서 정책만 동적으로 주입한다.
+- 미분류 폴더나 약한 이름 매칭이 AI 추천의 강한 후보로 작동하지 않도록 한다.
+
+## 3. 핵심 결정
+
+사용자 분류 체계의 성숙도를 2단계로 나눈다.
+
+```java
+LOW_CONTEXT
+HIGH_CONTEXT
+```
+
+3단계(COLD/WARM/MATURE) 대신 2단계를 선택한다. 현재 문제의 본질은 "기존 분류 데이터를 신뢰할 만큼 충분한가"이며, 사용자 만족도 관점에서도 다음 두 정책이면 1차 개선에는 충분하다.
+
+- 데이터가 부족하면 콘텐츠 중심 추천
+- 데이터가 충분하면 사용자 이력 중심 추천
+
+중간 단계를 추가하면 기준 튜닝과 프롬프트 정책이 복잡해지고, 실제 품질 개선 여부를 검증하기 전에 운영 부담이 커질 수 있다. 따라서 1차 구현은 2단계로 단순하게 가져가고, 이후 추천 로그와 사용자 수정 데이터를 기반으로 필요할 때 세분화한다.
+
+## 4. 모드별 정책
+
+### 4.1 LOW_CONTEXT
+
+사용자의 기존 폴더/태그/저장 이력이 부족한 상태다.
+
+추천 정책:
+
+- 링크 내용과 주제를 우선한다.
+- 신규 폴더/태그 제안을 적극 허용한다.
+- 기존 폴더/태그는 강하게 일치할 때만 재사용한다.
+- 미분류 폴더는 분석이 불가능한 경우에만 사용한다.
+- 저장 이력이 없는 폴더도 이름/설명이 강하게 맞으면 후보로 인정한다.
+
+예시:
+
+- 링크: 인프런 Spring 강의
+- 기존 폴더: 뉴스, 자료, 미분류
+- 추천 폴더: Spring 또는 백엔드
+- 추천 태그: 강의, Spring, Java, 백엔드
+
+### 4.2 HIGH_CONTEXT
+
+사용자의 기존 저장 패턴과 분류 체계가 충분히 쌓인 상태다.
+
+추천 정책:
+
+- 기존 폴더/태그 재사용을 우선한다.
+- 폴더 설명, 대표 저장 링크, 자주 쓰인 태그를 근거로 추천한다.
+- 강하게 맞는 기존 후보가 없을 때만 신규 폴더/태그를 제안한다.
+- 기존 태그와 신규 태그를 섞어 제안할 수 있다.
+
+예시:
+
+- 링크: 인프런 Spring 강의
+- 기존 폴더: 강의, 백엔드, 뉴스
+- 사용자가 강의 자료를 강의 폴더에 꾸준히 저장해 왔음
+- 추천 폴더: 강의
+- 추천 태그: 강의, Spring, 백엔드, Java
+
+## 5. 모드 판정 기준
+
+1차 기준은 폴더 구조만 본다. 커스텀 루트 폴더가 충분하면 저장 이력이 적어도 사용자가 분류 체계를 미리 준비한 상태로 본다.
+
+LOW_CONTEXT 판정 조건:
+
+- 커스텀 루트 폴더 수가 7개 미만
+
+HIGH_CONTEXT 판정 조건:
+
+- 커스텀 루트 폴더 수가 7개 이상
+
+미분류 폴더는 커스텀 루트 폴더 수에서 제외한다. 태그 수와 저장 링크 수는 컨텍스트 레벨 판정 조건으로 사용하지 않고, AI가 폴더와 태그 의미를 파악할 보조 맥락으로만 사용한다.
+
+## 6. 프롬프트 설계
+
+프롬프트 파일을 LOW_CONTEXT용, HIGH_CONTEXT용으로 나누지 않는다.
+
+권장 구조:
+
+```text
+공통 system prompt 1개
+공통 user prompt 1개
++ modeInstruction 동적 주입
+```
+
+system prompt는 공통 출력 규칙만 담당한다.
+
+- JSON only
+- 제목 형식
+- 설명 길이와 문체
+- 태그 2~5개
+- 미분류는 최후의 수단
+- 한국어 응답 규칙
+
+user prompt는 요청별 동적 맥락을 담당한다.
+
+- `contextMode`: LOW_CONTEXT 또는 HIGH_CONTEXT
+- `modeInstruction`: 모드별 추천 정책 문장
+- `tags`: 기존 태그 사전
+- `folderContext`: 폴더 설명, 저장 개수, 대표 링크, 자주 쓰인 태그
+- 페이지 정보
+
+LOW_CONTEXT용 정책 문장 예시:
+
+```text
+context: LOW_CONTEXT
+folder: 링크 내용 기반 신규 제안 우선, 강한 기존 일치만 재사용
+```
+
+HIGH_CONTEXT용 정책 문장 예시:
+
+```text
+context: HIGH_CONTEXT
+folder: 기존 폴더 강한 일치 우선, 없으면 신규 제안
+```
+
+## 7. 구현 계획
+
+### 7.1 모드 enum 추가
+
+`linkanalysis` 도메인 또는 서비스 패키지에 컨텍스트 모드 enum을 추가한다.
+
+```java
+public enum LinkAnalysisContextLevel {
+ LOW_CONTEXT,
+ HIGH_CONTEXT
+}
+```
+
+### 7.2 모드 판정 로직 추가
+
+`LinkAnalysisServiceImpl`에서 AI 프롬프트 생성 전에 사용자 컨텍스트 수준을 계산한다.
+
+판정에 사용할 데이터:
+
+- 루트 폴더 목록
+- `UNORGANIZED` 폴더 여부
+
+주의사항:
+
+- 미분류 폴더는 커스텀 폴더 수에서 제외한다.
+- 빈 폴더도 사용자가 준비한 분류 체계로 인정한다.
+- 태그 수, 저장 링크 수, 폴더 컨텍스트 유무는 HIGH_CONTEXT 판정 조건으로 사용하지 않는다.
+
+### 7.3 프롬프트 생성 개선
+
+현재 `buildPrompt()`는 `folderContexts`가 비어 있는지 여부로 basic/enriched 프롬프트를 선택한다. 이를 모드 기반 정책 주입 방식으로 바꾼다.
+
+변경 방향:
+
+- `buildPrompt(page, folders, tags, folderContexts, contextMode)` 형태로 확장한다.
+- 템플릿 변수에 `contextMode`와 `modeInstruction`을 추가한다.
+- 기존 `basic/enriched` 이분법은 점진적으로 축소하거나, 1차에서는 enriched 템플릿에 정책 변수를 추가하는 방식으로 최소 변경한다.
+
+### 7.4 폴더 컨텍스트 렌더링 개선
+
+`buildFolderContextBlock()`에 다음 정보를 포함한다.
+
+- 폴더명
+- 폴더 설명
+- 폴더 타입
+- 저장 링크 수
+- 대표 링크
+- 자주 쓰인 태그
+
+표현 원칙:
+
+- `UNORGANIZED`는 "시스템 폴더 - 분석 불가 시에만 사용"으로 명확히 표시한다.
+- `bookmarkCount`는 추천 우선순위가 아니라 폴더 맥락 근거량으로만 제공한다.
+- 최근 저장일은 활성도 편향을 만들 수 있으므로 프롬프트에 전달하지 않는다.
+
+### 7.5 시스템 프롬프트 조정
+
+현재 시스템 프롬프트의 "기존 태그를 최대한 재사용" 계열 규칙을 완화한다.
+
+변경 방향:
+
+- 폴더 모드 정책은 user prompt의 `modeInstruction`을 따른다.
+- 태그는 링크 내용 기준으로 추천하고, 강하게 일치하는 기존 태그만 재사용한다.
+- 약한 폴더명 매칭과 기존 태그 사전 강제 매칭을 금지한다.
+
+### 7.6 후처리 보강
+
+AI 응답 파싱 후 추천 결과를 모드에 따라 보정한다.
+
+LOW_CONTEXT:
+
+- 추천 폴더가 미분류면 첫 번째 구체 태그 기반 폴더명으로 보정한다.
+- 기존 폴더 정확 일치가 아니면 `isExisting=false`로 둔다.
+
+HIGH_CONTEXT:
+
+- 기존 폴더 정확 일치 시 기존 폴더 ID를 연결한다.
+- 기존 태그 정확 일치 시 기존 태그명을 유지한다.
+- 강하게 맞는 기존 후보가 없으면 신규 제안을 허용한다.
+
+## 8. 검증 계획
+
+단위 테스트 또는 서비스 레벨 테스트를 추가한다.
+
+필수 케이스:
+
+- 미분류 폴더만 있는 사용자 -> LOW_CONTEXT
+- 커스텀 폴더 7개 미만 -> LOW_CONTEXT
+- 미분류 포함 폴더 수만 7개인 사용자 -> LOW_CONTEXT
+- 커스텀 폴더 7개 이상 -> HIGH_CONTEXT
+- LOW_CONTEXT에서 미분류 추천이 나오면 구체 폴더명으로 보정
+- HIGH_CONTEXT에서 기존 폴더명과 정확히 일치하면 `memberFolderId` 매칭
+
+수동 평가 샘플:
+
+- 강의 플랫폼 링크
+- 공식 문서 링크
+- GitHub 저장소 링크
+- 뉴스/블로그 아티클
+- 디자인/마케팅/개발 도구 링크
+
+평가 기준:
+
+- 초기 사용자에게 신규 폴더 제안이 자연스러운가
+- 기존 사용자에게 기존 폴더 재사용이 잘 되는가
+- 미분류 추천 빈도가 줄었는가
+- 사용자가 저장 전에 수정할 가능성이 줄었는가
+
+## 9. 기대 효과
+
+- 초기 사용자에게 더 정확하고 자연스러운 폴더/태그 추천을 제공한다.
+- 사용자 데이터가 충분히 쌓인 뒤에는 기존 분류 체계를 존중하는 개인화 추천으로 전환된다.
+- 프롬프트 파일을 여러 개로 쪼개지 않아 유지보수 비용을 줄인다.
+- 미분류와 약한 이름 매칭이 추천 결과를 왜곡하는 문제를 줄인다.
+
+## 10. 1차 적용 범위
+
+1차에서는 다음까지만 적용한다.
+
+- LOW_CONTEXT / HIGH_CONTEXT 판정
+- 모드별 정책 문장 주입
+- 폴더 컨텍스트에 저장 개수 추가
+- LOW_CONTEXT에서 미분류 추천 보정 강화
+- 관련 테스트 추가
+
+다음 항목은 1차 이후로 미룬다.
+
+- 추천 confidence API 응답 노출
+- 사용자 수정 이력 기반 자동 튜닝
+- 3단계 이상 세분화
+- 벡터 검색 또는 임베딩 기반 폴더 매칭
diff --git a/docs/Troubleshooting/01. cloudflare-origin-shielding.md b/docs/Troubleshooting/01. cloudflare-origin-shielding.md
new file mode 100644
index 0000000..ef68796
--- /dev/null
+++ b/docs/Troubleshooting/01. cloudflare-origin-shielding.md
@@ -0,0 +1,159 @@
+# 🔒 Cloudflare Flexible 모드 우회 및 헤더 스푸핑 방지 가이드
+
+## 📌 개요 (Overview)
+
+본 문서는 **Cloudflare Flexible SSL** 구조에서 발생할 수 있는 보안 취약점(우회 접속 및 헤더 스푸핑)의 발생 원인과 오라클 클라우드(OCI) 방화벽을 활용한 해결 방법을 정리합니다.
+
+---
+
+## 1. 문제 상황 및 보안 위협 (Problem)
+
+두 취약점은 서로 밀접하게 연계되어 있으며, **1.1(우회)이 선행되어야만 1.2(헤더 조작) 공격이 성립하는 연쇄 관계**를 가집니다.
+
+### 1.1 Cloudflare 보안 장벽 우회 (Bypass) - 1단계
+* **보안 위협:** 공격자가 도메인이 아닌 서버의 퍼블릭 IP(`http://140.238.xxx.xxx`)로 직접 포트 80에 요청을 보내면, Cloudflare의 DDoS 방어 및 웹 방화벽(WAF) 필터링 보안망이 통째로 무력화됩니다.
+
+
+
+### 1.2 가짜 프로토콜 헤더 조작 (Header Spoofing) - 2단계
+* **보안 위협:** 공격자가 **1.1(우회 접속)에 성공한 상태**에서 HTTP 요청 헤더에 `X-Forwarded-Proto: https`를 직접 조작 및 주입해 전송할 경우, 백엔드(Spring Boot)가 실제 안전하지 않은 HTTP 요청임에도 불구하고 보안 HTTPS 연결로 완전히 속아 넘어가게 되는 취약점입니다. (정상적으로 Cloudflare를 거쳐 접속할 경우, Cloudflare가 헤더 변조를 강제로 차단 및 정화하므로 반드시 우회가 선행되어야 함)
+
+
+
+---
+
+## 2. 해결 방안 (Solution)
+
+
+
+오라클 클라우드(OCI) 네트워크 방화벽 레벨에서 **오직 Cloudflare 공식 IP 대역들만 서버의 HTTP 80 포트에 접근할 수 있도록 강제 격리**합니다. 이외의 모든 IP 대역(공격자 직접 접속 등)은 원천 차단(Drop)됩니다.
+
+### 📊 네트워크 격리 구조 (Network Security Topology)
+
+
당신의 링크, 이제 AI와 함께
스마트하게 관리하세요
Why SearchWeb?
+Why ReLink?
- 로그인함으로써 SearchWeb의
+ 로그인함으로써 ReLink의
이용약관 및 개인정보처리방침에 동의하게 됩니다.
저장하고, 태그하고, 다시 활용하세요.
- SearchWeb이 당신의 지식 관리를 돕습니다.
+ ReLink가 당신의 지식 관리를 돕습니다.
단순한 저장을 넘어선 지식 베이스를 구축하세요.
단순한 북마크를 넘어선 지능형 지식 관리 시스템을 경험하세요.