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 아키텍처(웹/앱 서버와 데이터베이스 서버 분리) 구조**를 채택하고 있습니다. + +Relink 배포 아키텍처 + +* **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) 필터링 보안망이 통째로 무력화됩니다. + +Cloudflare Security Shield Bypass Scenario + +### 1.2 가짜 프로토콜 헤더 조작 (Header Spoofing) - 2단계 +* **보안 위협:** 공격자가 **1.1(우회 접속)에 성공한 상태**에서 HTTP 요청 헤더에 `X-Forwarded-Proto: https`를 직접 조작 및 주입해 전송할 경우, 백엔드(Spring Boot)가 실제 안전하지 않은 HTTP 요청임에도 불구하고 보안 HTTPS 연결로 완전히 속아 넘어가게 되는 취약점입니다. (정상적으로 Cloudflare를 거쳐 접속할 경우, Cloudflare가 헤더 변조를 강제로 차단 및 정화하므로 반드시 우회가 선행되어야 함) + +Header Spoofing Attack Scenario + +--- + +## 2. 해결 방안 (Solution) + +Cloudflare IP Whitelisting Security Architecture + +오라클 클라우드(OCI) 네트워크 방화벽 레벨에서 **오직 Cloudflare 공식 IP 대역들만 서버의 HTTP 80 포트에 접근할 수 있도록 강제 격리**합니다. 이외의 모든 IP 대역(공격자 직접 접속 등)은 원천 차단(Drop)됩니다. + +### 📊 네트워크 격리 구조 (Network Security Topology) + +
+ +```mermaid +graph TD + User(("User (사용자)")) + Hacker(("Hacker (공격자)")) + CF["Cloudflare Proxy"] + + subgraph VM1 ["VM 1 (Application Server)"] + FW{"OCI Security List
(Port 80)"} + Nginx["Nginx Proxy
Next.js Static Web"] + end + + %% 연결 관계 + User -- "HTTPS (443)" --> CF + CF -- "HTTP (80) [CF IP]" --> FW + Hacker -- "HTTP (80) Direct" --> FW + + FW -- "정상 통과 (Allow)" --> Nginx + FW -.->|"차단 (Drop)"| X["연결 실패 (Timeout)"] + + %% 스타일링 + style CF fill:#f39c12,stroke:#d35400,color:#fff + style Nginx fill:#27ae60,stroke:#1e8449,color:#fff + style FW fill:#e74c3c,stroke:#c0392b,color:#fff + style Hacker fill:#c0392b,stroke:#962d22,color:#fff + style X fill:#7f8c8d,stroke:#34495e,color:#fff +``` + +
+ +--- + +## 3. 오라클 클라우드(OCI) 수신 규칙 설정 절차 + +1. **OCI 콘솔** 로그인 후 **Networking ➡️ Virtual Cloud Networks**로 이동합니다. +2. VM 1 인스턴스가 속한 **VCN**과 **보안 목록(Security List)**을 차례로 클릭합니다. +3. 기존에 모든 IP 대역을 허용하던 규칙(**소스:** `0.0.0.0/0`, **대상 포트:** `80`, **프로토콜:** `TCP`)을 찾아 **삭제(Remove)**합니다. +4. **수신 규칙 추가(Add Ingress Rules)**를 클릭하여 아래 **15개 Cloudflare 공식 IPv4 대역**을 각각 등록합니다. + * **소스 유형 (Source Type):** CIDR + * **소스 CIDR (Source CIDR):** `[Cloudflare IP 대역]` (예: `103.21.244.0/22`) + * **IP 프로토콜 (IP Protocol):** TCP + * **대상 포트 범위 (Destination Port Range):** `80` + * **설명 (Description):** `Cloudflare Ingress (80)` + +### 📋 허용할 Cloudflare IPv4 대역 목록 +*(최신 목록은 [Cloudflare IP Ranges](https://www.cloudflare.com/ips/)에서 실시간으로 조회가 가능합니다.)* +```text +103.21.244.0/22 +103.22.200.0/22 +103.31.4.0/22 +104.16.0.0/13 +104.24.0.0/14 +108.162.192.0/18 +131.0.72.0/22 +141.101.64.0/18 +162.158.0.0/15 +172.64.0.0/13 +173.245.48.0/20 +188.114.96.0/20 +190.93.240.0/20 +197.234.240.0/22 +198.41.128.0/17 +``` + +--- + +## 4. 검증 및 결과 확인 (Verification) + +* **도메인 접속 (`https://relink.ai.kr`):** Cloudflare 서버를 거쳐서 진입하므로 **정상 접속 성공**. +* **실제 IP 다이렉트 접속 (`http://140.238.xxx.xxx`):** OCI 방화벽 레벨에서 즉시 차단되므로 Nginx에 연결조차 되지 않고 **접속 제한 및 타임아웃 실패**. + +--- + +## 5. ⚡ 추가 트러블슈팅: IP 화이트리스팅 적용 후 사이트가 너무 느려지는 현상 + +OCI 방화벽에 Cloudflare IP 화이트리스팅을 적용한 직후, 사이트 로딩 속도가 비정상적으로 느려지는 현상이 발생할 수 있습니다. 이는 OCI 방화벽 자체의 문제가 아니라 **네트워크/애플리케이션 계층의 병목 현상**이며, 원인과 해결법은 다음과 같습니다. + +### 5.1 원인 분석 +1. **가짜 IP로 인한 WAS(Tomcat) DNS 역조회 병목 (99% 확률):** + * 방화벽 화이트리스팅 적용으로 모든 트래픽이 Cloudflare IP 대역을 거쳐 들어옵니다. + * Nginx에 Cloudflare의 진짜 IP 식별 설정(`set_real_ip_from`)이 없거나 IP 대역 중 일부가 누락된 경우, WAS(Spring Boot Tomcat)는 모든 사용자의 IP를 Cloudflare 대표 IP(예: `162.158.x.x`)로 오인합니다. + * Tomcat은 접속 로그를 기록하거나 보안 필터를 처리할 때 이 대표 IP들의 영문 호스트명을 알아내기 위해 **DNS 역방향 조회(Reverse DNS Lookup)**를 수행하며, 이때 네트워크 대기 지연(Timeout)이 발생해 화면 로딩이 2~5초 이상 지연됩니다. +2. **Cloudflare의 IPv6 연결 차단 대기 (Fallback 지연):** + * Cloudflare는 도메인 접속 시 기본적으로 **IPv6**로 우선 접속을 시도합니다. + * OCI 방화벽(수신 규칙)에 IPv4 대역만 열어놓고 IPv6 대역을 누락하면, Cloudflare가 OCI 서버로 IPv6 패킷을 쏘았을 때 OCI가 이를 응답 없이 차단(Drop)합니다. + * Cloudflare는 타임아웃 대기 시간(1~2초)이 지난 후에야 IPv4로 우회(Fallback) 연결을 재시도하므로 페이지 내부의 수많은 리소스를 가져올 때마다 극심한 대기렉이 누적됩니다. + +### 5.2 해결 방안 + +#### ✔️ 해결책 1: Nginx Real IP 복원 설정 적용 (WAS 병목 제거) +Nginx가 Cloudflare IP 뒤에 숨겨진 실제 사용자 접속 IP(`CF-Connecting-IP` 헤더)를 원본 IP로 정상 복원하여 WAS로 넘기도록 유도합니다. +* **적용 파일:** `nginx-prod.conf` +* **설정 코드:** + ```nginx + # Cloudflare 대역을 신뢰할 수 있는 프록시로 지정 (실제 사용중인 15개 대역 완벽 매칭) + 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; + + # 진짜 IP가 담겨오는 Cloudflare 고유 헤더 지정 + real_ip_header CF-Connecting-IP; + ``` + +#### ✔️ 해결책 2: Cloudflare IPv6 비활성화 또는 OCI 수신 규칙 추가 (IPv6 Fallback 제거) +* **방법 A (권장):** Cloudflare 웹 대시보드 ➡️ **Network** 메뉴로 접속하여 **IPv6 Compatibility (IPv6 호환성)** 설정을 **OFF**로 비활성화합니다. (가장 간단하고 확실하게 타임아웃 지연 제거) +* **방법 B:** OCI 보안 목록에 아래 **Cloudflare 공식 IPv6 대역 (7개)**을 TCP 80 포트 수신 허용 규칙으로 추가 등록해 줍니다. + * `2400:cb00::/32` + * `2606:4700::/32` + * `2803:f800::/32` + * `2405:b500::/32` + * `2405:8100::/32` + * `2a06:98c0::/29` + * `2c0f:f248::/32` + diff --git a/docs/Troubleshooting/02. nginx-unprivileged-and-healthcheck.md b/docs/Troubleshooting/02. nginx-unprivileged-and-healthcheck.md new file mode 100644 index 0000000..0587106 --- /dev/null +++ b/docs/Troubleshooting/02. nginx-unprivileged-and-healthcheck.md @@ -0,0 +1,158 @@ +# Nginx 일반 계정 권한 적용 및 헬스체크 도입 트러블슈팅 + +보안 강화(Security Hardening)를 위한 Nginx 일반 계정(Non-root) 권한 전환 및 시스템 복원력 확보를 위한 `HEALTHCHECK` 지시어 추가 과정에서 발생한 기술적 제약 사항과 해결 방안을 기록합니다. + +--- + +## 1. 개요 및 문제 정의 (Issue) + +### 1.1. 🚨 보안 취약점 +* **기존 환경**: 기본 `nginx:alpine` 이미지는 컨테이너 내부에서 `root` 권한으로 프로세스를 구동합니다. +* **위험 요인**: **컨테이너 내부가 뚫릴 경우, 실제 서버(호스트 VM)의 최고 권한(root)까지 통째로 빼앗길 수 있는 잠재적 위험이 있습니다.** + * **세부 기술적 원리 (컨테이너 탈옥)**: + * **커널 공유 구조**: 도커 컨테이너는 가상머신(VM)과 달리 호스트 OS의 리눅스 커널(Kernel)을 공유하여 작동합니다. 컨테이너 내부의 `root` 사용자(UID 0)는 네임스페이스(Namespace)로 분리되어 있을 뿐, 본질적으로 호스트의 실제 `root` 사용자(UID 0)와 직접 대응됩니다. + * **권한 전이 위험**: 만약 Nginx 취약점(예: RCE, 원격 코드 실행)을 통해 외부 공격자가 컨테이너 제어권을 획득하는 데 성공하고, 이어서 알려진 커널 취약점이나 볼륨 마운트 빈틈을 이용해 격리 장벽을 깨고 나오는 **컨테이너 탈옥(Container Escape)**을 성공시킨다면 상황이 치명적입니다. + * **호스트 즉시 장악**: 컨테이너가 `root` 권한으로 실행 중이었다면 탈옥하여 호스트로 빠져나간 프로세스 역시 **호스트(실제 VM)의 최고 관리자(`root`) 권한을 즉시 보유**하게 되므로, 물리 서버 전체 장악 및 데이터 파괴 등 2차 피해를 막을 방어선이 완전히 붕괴됩니다. + * **일반 계정 적용 시의 메리트**: 반면 Nginx가 일반 계정(UID 101 등)으로 작동 중이었다면, 공격자가 격리를 뚫고 호스트로 탈옥하더라도 호스트 내에서 아무런 권한이 없는 '무명 일반 사용자' 상태에 머물게 되므로 피해 범위를 극도로 좁힐 수 있습니다. + +### 1.2. 🔌 복원력(Resilience) 누락 +* **기존 환경**: 컨테이너의 단순 프로세스 생존 여부만 판단하며 내부 서비스의 정상 응답 여부를 확인하는 장치가 없었습니다. +* **위험 요인**: Nginx 프로세스가 데드락이나 행(Hang) 상태에 빠져 먹통이 되어도 Docker 엔진이 비정상 컨테이너로 인지하지 못해 사용자 요청이 지속적으로 인입되어 장애를 일으킵니다. + +--- + +## 2. 해결 방안 (Solution) + +1. **일반 계정(Non-root) 베이스 이미지 사용**: `nginxinc/nginx-unprivileged:alpine` +2. **안전성 진단 장치 도입**: 디스크 무부하 방식의 로컬 `HEALTHCHECK` 추가 + +--- + +## 3. 기술적 제약 및 트러블슈팅 (Troubleshooting) + +### ⚠️ 일반 계정 포트 바인딩 제한 (Permission Denied) +리눅스 커널 정책상 **일반 계정 권한(Non-root) 사용자 계정은 1024번 미만의 웰노운 포트(Well-known Port)인 80, 443 포트를 바인딩할 수 없습니다.** +따라서 Nginx 베이스 이미지만 바꾸고 기존처럼 `listen 80;` 설정을 사용하면, 컨테이너 기동 시 권한 에러와 함께 컨테이너가 즉시 강제 종료됩니다. + +### 💡 해결 방안: 내부 포트 변경 및 도커 포트 포워딩 활용 +외부 사용자는 기존과 동일하게 `80` 포트로 접속하도록 유지하면서, 인프라 내부에서만 안전하게 포트를 변환하여 중개합니다. + +* **Nginx 내부 리슨 포트**: `80` ➡️ `8080` +* **Docker Compose 포트 매핑**: `80:80` ➡️ `80:8080` (호스트의 80번 포트 요청을 안전하게 컨테이너의 일반 계정 8080 포트로 전달) + +```text +[사용자 브라우저] (http://relink.ai.kr) + │ + ▼ (80 포트로 요청 진입) +[서버 (Oracle Cloud VM)] ── Docker 엔진이 요청을 전달 + │ + ▼ (포트 포워딩: 80 ➡️ 8080) +[Nginx 컨테이너 내부] (8080 포트에서 안전하게 대기 중인 일반 계정 Nginx가 처리) +``` + +--- + +## 4. 구체적인 코드 변경 내역 (Changes) + +### ① `frontend/Dockerfile` +Nginx 베이스 이미지를 변경하고 일반 계정 포트인 `8080`을 노출한 뒤, 주기적으로 상태를 감시하는 헬스체크 지시어를 추가했습니다. + +```dockerfile +# ... (Builder 단계 생략) ... + +# 1. 일반 계정 Nginx Alpine 이미지로 전환 +FROM nginxinc/nginx-unprivileged:alpine + +COPY --from=builder /app/out /usr/share/nginx/html + +# 2. 일반 계정 포트 8080 노출 +EXPOSE 8080 + +# 3. 30초 주기, 시스템 무부하 spider 방식의 헬스체크 추가 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1 +``` + +### ② `deploy/prod/web/nginx-prod.conf` +Nginx가 일반 계정 권한으로 바인딩할 수 있도록 리슨 포트를 `8080`으로 수정했습니다. + +```nginx +server { + listen 8080; # 80 포트에서 8080 포트로 변경 + server_name relink.ai.kr; + + # ... (생략) ... +} +``` + +### ③ `deploy/prod/web/docker-compose-prod-web.yml` +호스트의 80 포트로 진입한 트래픽이 일반 계정 권한 컨테이너 내부의 8080 포트로 가도록 포트 맵핑을 수정했습니다. + +```yaml + nginx: + image: ${DOCKERHUB_USERNAME}/relink-frontend:latest + container_name: relink-nginx + ports: + - "80:8080" # 외부 호스트 80 -> 내부 컨테이너 8080 연결 + - "443:443" + # ... (생략) ... +``` + +--- + +## 5. 성능 및 영향도 분석 + +* **성능 부담 Zero**: + * 헬스체크 명령어에 `--spider` 옵션을 적용하여 웹 문서 다운로드를 완전 차단하고 오직 응답 헤더 정보만 확인합니다. 디스크 I/O가 전혀 발생하지 않습니다. + * 인터넷망을 타지 않는 로컬 루프백(`localhost`) 네트워크 통신으로 실행 시간은 0.001초 미만이며, CPU/메모리 리소스 사용량은 `0.0001%` 이하로 사실상 측정이 무의미한 수준입니다. +* **사용자 영향도 Zero**: + * 도커의 가상 포트 매핑 기술 덕분에 외부에서는 기존 주소 그대로 사용 가능하며 포트를 명시할 필요가 없습니다. + +--- + +## 🛠️ [보너스 팁] 주기적 헬스체크 로그 필터링하기 + +30초 주기인 헬스체크로 인해 Nginx Access 로그(`access.log`)가 지저분해지는 것을 방지하려면, `nginx-prod.conf`에 아래와 같이 User-Agent 필터링 처리를 해주면 깔끔하게 로그 기록을 차단할 수 있습니다. + +```nginx +server { + listen 8080; + server_name relink.ai.kr; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri.html $uri/ /index.html; + + # 헬스체크(wget) 요청에 대해서는 로그를 남기지 않음 + if ($http_user_agent ~* "Wget") { + access_log off; + } + } +} +``` + +--- + +## 🚀 [향후 보완 과제] Liveness & Self-Healing 고도화 (Next Steps) + +현재 헬스체크(`HEALTHCHECK`) 지시어 추가를 통해 컨테이너의 비정상 상태(`unhealthy`)를 스스로 감지할 수 있는 인프라 기반을 다졌습니다. 하지만 Docker 엔진은 기본 사양상 컨테이너가 `unhealthy` 상태가 되더라도 자동으로 컨테이너를 강제 재시작해주지는 않습니다. + +안전한 서비스 무중단 운영과 자동화를 위해 향후 검토해볼 만한 고도화 방향성입니다. + +### 1. 🔄 자가 치유 (Self-Healing) 시스템 도입 +* **목표**: 서비스 먹통 감지 즉시 사람이 개입하지 않고 컨테이너를 자동 재시작 +* **구현 방향**: + * **Autoheal 컨테이너 연동**: 호스트의 Docker 데몬 소켓(`/var/run/docker.sock`)을 실시간 모니터링하면서, 특정 컨테이너가 `unhealthy`로 바뀌는 순간 즉시 `docker restart ` 명령을 자동 수행해 주는 초경량 헬퍼 이미지(예: `willfarrell/autoheal`)를 `docker-compose`에 연동해 주는 것만으로 간편하게 구현이 가능합니다. + +### 2. 🚨 실시간 장애 알림 (Alerting) 구축 +* **목표**: 컨테이너가 정상적으로 동작하지 않거나 비정상 재시작을 반복할 때 개발자에게 즉각 경고 알림 전송 +* **구현 방향**: + * **경량 알림 솔루션**: 외부에서 타겟 서버 상태를 체크해 주는 경량 웹 대시보드 도구인 `Uptime Kuma`를 띄워두고 메인 도메인을 지속적으로 헬스체크하여, 에러 시 즉시 **Slack / Discord 웹훅**으로 전송하도록 구성합니다. + * **프로페셔널 메트릭 시스템**: `Prometheus` + `Grafana` + `Alertmanager` 조합을 이용해 컨테이너의 CPU/메모리/헬스체크 실패 상태를 통합 모니터링 대시보드로 시각화하고 정교한 알람 조건을 구축합니다. + +### 3. 🌐 오케스트레이터 및 로드 밸런서 연동 +* **목표**: 인프라 복수 분산(Scale-out) 환경에서 장애 컨테이너를 자동으로 제외 +* **구현 방향**: + * 향후 쿠버네티스(Kubernetes)나 AWS ECS 등의 컨테이너 오케스트레이션 인프라로 전환 및 마이그레이션할 때, 본 파일에 추가된 헬스체크 설정을 로드 밸런서의 **Liveness/Readiness Probe**와 동기화시킵니다. 이를 통해 장애 컨테이너로 유입되는 모든 트래픽을 즉각 우회시키고 신속하게 정상 파드(Pod)/태스크(Task)로 대체되는 클라우드 베스트 아키텍처를 완성할 수 있습니다. + diff --git a/docs/Troubleshooting/images/1.0 cloudflare_security.png b/docs/Troubleshooting/images/1.0 cloudflare_security.png new file mode 100644 index 0000000..07b9fa7 Binary files /dev/null and b/docs/Troubleshooting/images/1.0 cloudflare_security.png differ diff --git a/docs/Troubleshooting/images/1.1 cloudflare_bypass_vulnerability.png b/docs/Troubleshooting/images/1.1 cloudflare_bypass_vulnerability.png new file mode 100644 index 0000000..74db082 Binary files /dev/null and b/docs/Troubleshooting/images/1.1 cloudflare_bypass_vulnerability.png differ diff --git a/docs/Troubleshooting/images/1.2 header_spoofing_vulnerability.png b/docs/Troubleshooting/images/1.2 header_spoofing_vulnerability.png new file mode 100644 index 0000000..3c94248 Binary files /dev/null and b/docs/Troubleshooting/images/1.2 header_spoofing_vulnerability.png differ diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..2f19b8c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +out +.git +.env +.env.* +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5c087c1 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@10.30.1 --activate + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +ENV NEXT_OUTPUT_EXPORT=true +RUN pnpm build + +FROM nginxinc/nginx-unprivileged:alpine + +COPY --from=builder /app/out /usr/share/nginx/html + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1 diff --git a/frontend/README.md b/frontend/README.md index 78f3d91..9fba186 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,4 +1,4 @@ -# SearchWeb Frontend +# ReLink Frontend This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 2e6f87e..53c7a93 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,10 +1,13 @@ import type { NextConfig } from "next"; const isDev = process.env.NODE_ENV === 'development'; +const isStaticExport = process.env.NEXT_OUTPUT_EXPORT === 'true'; const backendOrigin = (process.env.NEXT_PUBLIC_BACKEND_ORIGIN || (isDev ? 'http://localhost:8080' : '')).replace(/\/+$/, ''); const nextConfig: NextConfig = { + ...(isStaticExport ? { output: 'export' as const } : {}), images: { + unoptimized: isStaticExport, remotePatterns: [ { protocol: 'https', @@ -13,12 +16,13 @@ const nextConfig: NextConfig = { }, ], }, - // [BACKEND_CONNECT] 백엔드 API 프록시 설정 - // 인증 관련 엔드포인트(/api/auth/*)는 프론트엔드에서 백엔드로 직접 호출한다. - // (buildBackendUrl 참고) Set-Cookie 가 프록시를 거치며 누락되는 문제를 방지하기 위함. - // 아래 rewrite 는 인증 외 일반 API 용이며, Phase 3(Nginx 단일 origin) 도입 시 제거된다. - async rewrites() { - // 운영 환경에서 백엔드 주소가 없는 경우(예: Nginx 단일 오리진 사용) rewrite 생략 +}; + +// [BACKEND_CONNECT] 백엔드 API 프록시 설정 +// 인증 관련 엔드포인트(/api/auth/*)는 프론트엔드에서 백엔드로 직접 호출한다. +// 정적 배포 빌드에서는 Next.js rewrites가 지원되지 않으므로 Nginx가 이 역할을 수행한다. +if (!isStaticExport) { + nextConfig.rewrites = async () => { if (!backendOrigin) return []; return [ @@ -35,8 +39,7 @@ const nextConfig: NextConfig = { destination: `${backendOrigin}/login/oauth2/:path*`, }, ]; - }, - // [/BACKEND_CONNECT] -}; + }; +} export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index 348bc0c..6722f84 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev --port 4000", "build": "next build", + "build:static": "cross-env NEXT_OUTPUT_EXPORT=true next build", "start": "next start", "lint": "eslint" }, @@ -32,6 +33,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "cross-env": "^7.0.3", "eslint": "^9", "eslint-config-next": "16.1.6", "shadcn": "^3.8.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5b44d1f..f85e6a2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.91.3 version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.3))(react@19.2.3) + agentation: + specifier: ^3.0.2 + version: 3.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -69,9 +72,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) - agentation: - specifier: ^3.0.2 - version: 3.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 eslint: specifier: ^9 version: 9.39.3(jiti@2.6.1) @@ -1932,6 +1935,11 @@ packages: typescript: optional: true + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5716,6 +5724,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/frontend/public/relink_logo.png b/frontend/public/relink_logo.png new file mode 100644 index 0000000..d013fdd Binary files /dev/null and b/frontend/public/relink_logo.png differ diff --git a/frontend/public/relink_logo_black.png b/frontend/public/relink_logo_black.png new file mode 100644 index 0000000..21c9f75 Binary files /dev/null and b/frontend/public/relink_logo_black.png differ diff --git a/frontend/public/relink_logo_white.png b/frontend/public/relink_logo_white.png new file mode 100644 index 0000000..594b7c7 Binary files /dev/null and b/frontend/public/relink_logo_white.png differ diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 718d6fe..d013fdd 100644 Binary files a/frontend/src/app/favicon.ico and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5f3e631..9a3ebb1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -40,7 +40,7 @@ --color-card-foreground: var(--card-foreground); --color-card: var(--card); - /* SearchWeb specific custom colors */ + /* ReLink specific custom colors */ --color-primary-content: var(--primary-content); --color-primary-dark: var(--primary-dark); --color-primary-light: var(--primary-light); @@ -134,7 +134,7 @@ --input: rgba(255, 255, 255, 0.08); --ring: rgba(124, 58, 237, 0.5); /* Purple Transparent */ - /* SearchWeb specific custom colors for dark mode */ + /* ReLink specific custom colors for dark mode */ --text-main: #f8fafc; /* slate-50 */ --text-sub: #94a3b8; /* slate-400 */ } @@ -166,10 +166,13 @@ @apply border-border outline-ring/50; } button, [role="button"] { - @apply cursor-pointer; + @apply cursor-pointer select-none; } body { - /* bg, text is handled via layout.tsx classes directly */ + @apply select-none; + } + input, textarea, [contenteditable="true"] { + @apply select-text cursor-text; } } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 9fd9fbb..4dd83c7 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -17,8 +17,8 @@ const inter = Inter({ }); export const metadata: Metadata = { - title: "SearchWeb - 북마크의 진화, AI 지능형 관리", - description: "AI를 통해 북마크를 지능적으로 관리하세요. SearchWeb.", + title: "ReLink - 북마크의 진화, AI 지능형 관리", + description: "AI를 통해 북마크를 지능적으로 관리하세요. ReLink.", }; export default function RootLayout({ @@ -29,8 +29,10 @@ export default function RootLayout({ return ( - {/* Material Symbols Outlined Font */} - + + + +
- SearchWeb Logo
-

Hello SearchWeb!

+

Hello ReLink!

당신의 링크, 이제 AI와 함께
스마트하게 관리하세요

@@ -291,7 +291,7 @@ export default function LoginPage() { {/* Benefits Section - Sophisticated Icon Cards */}
-

Why SearchWeb?

+

Why ReLink?

{/* AI Feature */}
@@ -320,7 +320,7 @@ export default function LoginPage() {

- 로그인함으로써 SearchWeb의
+ 로그인함으로써 ReLink의
이용약관개인정보처리방침에 동의하게 됩니다.

diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx index 2fa9482..b552cbf 100644 --- a/frontend/src/app/my-links/page.tsx +++ b/frontend/src/app/my-links/page.tsx @@ -218,6 +218,10 @@ export default function MyLinksPage() { 'bg-gradient-to-br from-amber-500 to-orange-600' ]; + + + + return (
{/* Main Content Area */} @@ -367,10 +371,10 @@ export default function MyLinksPage() { role="button" tabIndex={0} onClick={() => handleFolderBadgeClick(folder.memberFolderId)} - className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer outline-none hover:shadow-md ${ + className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer outline-none ${ selectedFolderId === folder.memberFolderId - ? 'ring-2 ring-purple-400 ring-offset-2' - : '' + ? 'ring-2 ring-inset ring-white/60 dark:ring-white/30 shadow-md scale-[1.02]' + : 'shadow-sm hover:shadow-md' }`} >
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2eb65bc..0063d02 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -67,7 +67,7 @@ export default function Home() { if (isAuthenticated) { router.push("/my-links"); } else { - window.location.href = buildBackendUrl("/oauth2/authorization/google"); + router.push("/login"); } }; @@ -92,7 +92,7 @@ export default function Home() { - 지금 가장 스마트한 북마크, SearchWeb 베타서비스 + 지금 가장 스마트한 북마크, ReLink 베타서비스

흩어진 링크에서,
@@ -100,14 +100,14 @@ export default function Home() {

저장하고, 태그하고, 다시 활용하세요.
- SearchWeb이 당신의 지식 관리를 돕습니다.
+ ReLink가 당신의 지식 관리를 돕습니다.
단순한 저장을 넘어선 지식 베이스를 구축하세요.

@@ -228,9 +228,9 @@ export default function Home() {
- {/* Tiny floating elements for dynamic feel */} + {/* 마지막 남은 배경 요소들 주석 처리
- {/* Floating AI Magic Icon - Enhanced Background Element (Bottom Left) - Optimized */} + + */} - {/* Floating Folder Icon - Enhanced Background Element (Adjusted for overlap) - Optimized */} + {/* Floating Folder Icon 주석 처리 + */} - {/* Floating Rocket Icon - Enhanced Background Element - Optimized */} + {/* Floating Rocket Icon 주석 처리 + */} @@ -265,7 +268,7 @@ export default function Home() {
-

왜 SearchWeb인가요?

+

왜 ReLink인가요?

단순한 북마크를 넘어선 지능형 지식 관리 시스템을 경험하세요.

@@ -412,7 +415,7 @@ export default function Home() {
language - SearchWeb + ReLink
이용약관 @@ -420,7 +423,7 @@ export default function Home() { 문의하기
- © 2026 SearchWeb Inc. All rights reserved. + © 2026 ReLink Inc. All rights reserved.
diff --git a/frontend/src/components/dialogs/SaveLinkDialog.tsx b/frontend/src/components/dialogs/SaveLinkDialog.tsx index 5ab5f8b..8fc9c89 100644 --- a/frontend/src/components/dialogs/SaveLinkDialog.tsx +++ b/frontend/src/components/dialogs/SaveLinkDialog.tsx @@ -13,6 +13,8 @@ import { useAnalyzeLink } from '@/lib/api/linkAnalysisApi'; import { useAuthStore } from '@/lib/store/authStore'; import type { LinkAnalysisResponse } from '@/lib/types/linkAnalysis'; import { FOLDER_TYPE } from '@/lib/types/folder'; +import { Spinner } from '@/components/ui/spinner'; +import { buildGoogleFaviconUrl, getUrlBaseDomain, buildDirectFaviconUrl } from '@/lib/utils/favicon'; // 디자인 시안에서 추출한 커스텀 테마 매핑 const theme = { @@ -104,13 +106,27 @@ export function SaveLinkDialog() { }, [url]); useEffect(() => { - setFaviconLoadFailed(false); + setFaviconFallbackStep('basedomain'); + setFaviconVisible(false); }, [url]); - // --- UI 전용 상태 추가 (클립보드 추천) --- + // --- UI 전용 상태 추가 (클립보드 추천 및 파비콘 폴백) --- const [clipboardUrl, setClipboardUrl] = useState(null); - const [faviconLoadFailed, setFaviconLoadFailed] = useState(false); + const [faviconFallbackStep, setFaviconFallbackStep] = useState<'basedomain' | 'direct' | 'failed'>('basedomain'); + const [faviconVisible, setFaviconVisible] = useState(false); + + const previewFaviconUrl = (() => { + if (!url) return null; + if (faviconFallbackStep === 'basedomain') { + const baseDomain = getUrlBaseDomain(url); + return buildGoogleFaviconUrl(baseDomain); + } + if (faviconFallbackStep === 'direct') { + return buildDirectFaviconUrl(url); + } + return null; + })(); // --- 팝업이 열고 닫힐 때마다 모든 입력 상태 초기화 및 클립보드 감지 --- useEffect(() => { @@ -383,19 +399,42 @@ export function SaveLinkDialog() {
-
- {url && !faviconLoadFailed ? ( +
+ {/* 기본 체인 링크 아이콘 (파비콘 로딩 전/실패 시의 디폴트 플레이스홀더. 파비콘 로드 성공 시 크로스페이드 아웃) */} + link + + {previewFaviconUrl && faviconFallbackStep !== 'failed' && ( setFaviconLoadFailed(true)} + className={`w-5 h-5 object-contain transition-opacity duration-200 relative z-10 ${faviconVisible ? 'opacity-100' : 'opacity-0'}`} + onLoad={(e) => { + const img = e.currentTarget; + // 구글 파비콘 API는 파비콘이 없을 경우 16x16 크기의 기본 지구본 이미지를 200 OK로 반환. + // 이를 감지하여 실패로 간주하고 폴백(직접 호출)을 실행. + if (img.naturalWidth === 16 && img.naturalHeight === 16) { + setFaviconVisible(false); + if (faviconFallbackStep === 'basedomain') { + setFaviconFallbackStep('direct'); + } else { + setFaviconFallbackStep('failed'); + } + } else { + setFaviconVisible(true); // 정상 파비콘일 때만 서서히 표시 + } + }} + onError={() => { + setFaviconVisible(false); + if (faviconFallbackStep === 'basedomain') { + setFaviconFallbackStep('direct'); + } else { + setFaviconFallbackStep('failed'); + } + }} /> - ) : ( - link )}
@@ -460,7 +499,7 @@ export function SaveLinkDialog() {
{analyzeUrlMutation.isPending && ( - progress_activity + Fetching title... )} @@ -485,7 +524,7 @@ export function SaveLinkDialog() { > {analyzeLinkMutation.isPending ? ( <> - progress_activity + Analyzing... ) : ( diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index a2219be..a003531 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -21,7 +21,7 @@ export function Header({ title = 'My Links' }: HeaderProps) {
{/* Left: Content Area Header */}
-
+
home / {title} @@ -35,9 +35,11 @@ export function Header({ title = 'My Links' }: HeaderProps) { : '' }`}>
+ {/* 향후 기능 개발 예정이라 주석 + */}
); @@ -57,9 +58,9 @@ export function LandingHeader({ activeSection, isLoginPage = false }: LandingHea <>
{/* Logo */} - - language - SearchWeb + + ReLink + ReLink
@@ -132,8 +133,8 @@ export function LandingHeader({ activeSection, isLoginPage = false }: LandingHea
- language - SearchWeb + ReLink + ReLink
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index d769740..804f608 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -3,22 +3,22 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { cn } from '@/lib/utils'; -import { useUIStore } from '@/lib/store/uiStore'; -import { useFolders } from '@/lib/api/folderApi'; -import { useFolderStore } from '@/lib/store/folderStore'; +// import { useUIStore } from '@/lib/store/uiStore'; +// import { useFolders } from '@/lib/api/folderApi'; +// import { useFolderStore } from '@/lib/store/folderStore'; import { useAuthStore } from '@/lib/store/authStore'; import { useRouter } from 'next/navigation'; -const PINNED_DOT_COLORS = ['bg-blue-500', 'bg-purple-500', 'bg-teal-500', 'bg-amber-500', 'bg-emerald-500']; +// const PINNED_DOT_COLORS = ['bg-blue-500', 'bg-purple-500', 'bg-teal-500', 'bg-amber-500', 'bg-emerald-500']; export function Sidebar() { const pathname = usePathname(); const router = useRouter(); const logout = useAuthStore((s) => s.logout); const member = useAuthStore((s) => s.member); - const { data: folders } = useFolders(member?.memberId); - const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId); - const pinnedFolders = folders?.slice(0, 5) ?? []; + // const { data: folders } = useFolders(member?.memberId); + // const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId); + // const pinnedFolders = folders?.slice(0, 5) ?? []; const navItems = [ { name: 'Overview', href: '/', icon: 'visibility', activeClass: 'bg-primary/20 text-violet-300' }, @@ -32,17 +32,17 @@ export function Sidebar() { diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx index 2dc6ab7..fed3dc6 100644 --- a/frontend/src/components/my-links/FolderCard.tsx +++ b/frontend/src/components/my-links/FolderCard.tsx @@ -126,10 +126,10 @@ export function FolderCard({ folder, color }: FolderCardProps) { handleCardClick(); } }} - className={`bg-white dark:bg-card-dark rounded-lg p-2.5 border transition-all duration-300 group cursor-pointer h-[90px] flex flex-col justify-between outline-none hover:bg-purple-50/30 dark:hover:bg-white/[0.03] relative overflow-visible ${ + className={`bg-white dark:bg-card-dark rounded-lg p-2.5 border transition-all duration-300 group cursor-pointer h-[90px] flex flex-col justify-between outline-none relative overflow-visible ${ isSelected - ? 'border-purple-500 ring-1 ring-purple-500/20 shadow-md' - : 'border-gray-200/70 dark:border-white/5 shadow-sm hover:shadow-md hover:border-purple-300 dark:hover:border-white/10' + ? 'border-purple-500 bg-purple-50/40 dark:border-purple-400 dark:bg-purple-500/10 shadow-sm hover:bg-purple-50/40 dark:hover:bg-purple-500/10' + : 'border-gray-200/70 dark:border-white/5 shadow-sm hover:shadow-md hover:border-purple-300 dark:hover:border-white/10 hover:bg-purple-50/30 dark:hover:bg-white/[0.03]' } ${color || ''}`} >
diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx index 3af3c50..9b12d76 100644 --- a/frontend/src/components/my-links/RightPanel.tsx +++ b/frontend/src/components/my-links/RightPanel.tsx @@ -10,6 +10,7 @@ import { SortDropdown, SortOption } from '@/components/ui/SortDropdown'; import type { BookmarkResponse } from '@/lib/types/bookmark'; import { FOLDER_TYPE } from '@/lib/types/folder'; import { compareFolders } from '@/lib/utils/folderUtils'; +import { buildGoogleFaviconUrl, getUrlHostname, getUrlBaseDomain, buildDirectFaviconUrl } from '@/lib/utils/favicon'; /** * 날짜 문자열을 받아 현재 시간 기준 상대적인 시간(예: Just now, 5m ago)으로 변환합니다. @@ -67,6 +68,12 @@ function LinkItem({ const [tagInput, setTagInput] = useState(data.tags?.join(', ') ?? ''); // 태그 입력값 const [isDropdownOpen, setIsDropdownOpen] = useState(false); // 더보기 메뉴 오픈 여부 const [isFolderDropdownOpen, setIsFolderDropdownOpen] = useState(false); // 폴더 이동 드롭다운 여부 + const [faviconFallbackStep, setFaviconFallbackStep] = useState<'basedomain' | 'direct' | 'failed'>('basedomain'); + + // 북마크 데이터가 바뀌면 파비콘 재시도 플래그를 리셋합니다. + useEffect(() => { + setFaviconFallbackStep('basedomain'); + }, [data.bookmarkId]); const noteInputRef = useRef(null); // 메모 입력창 참조 const titleInputRef = useRef(null); // 제목 입력창 참조 @@ -89,6 +96,27 @@ function LinkItem({ const iconDarkClass = 'dark:bg-gray-800 dark:border-gray-700 dark:group-hover:border-violet-400/30 dark:group-hover:bg-[linear-gradient(135deg,rgba(109,40,217,0.3)_0%,rgba(139,92,246,0.18)_100%)] dark:group-hover:text-violet-200 dark:group-hover:shadow-[0_12px_24px_-16px_rgba(124,58,237,0.8)]'; const tagDarkClass = 'dark:border-white/8 dark:bg-slate-800/85 dark:text-slate-300 dark:group-hover:border-violet-400/30 dark:group-hover:bg-[linear-gradient(135deg,rgba(76,29,149,0.32)_0%,rgba(109,40,217,0.22)_100%)] dark:group-hover:text-violet-100'; + // 대안 A 파비콘 폴백 주소 계산 + const fallbackFaviconUrl = (() => { + const originalUrl = data.link?.originalUrl ?? data.link?.canonicalUrl ?? data.link?.domain; + if (!originalUrl) return null; + if (faviconFallbackStep === 'basedomain') { + return buildGoogleFaviconUrl(getUrlBaseDomain(originalUrl)); + } + if (faviconFallbackStep === 'direct') { + return buildDirectFaviconUrl(originalUrl); + } + return null; + })(); + + const fallbackDomain = + getUrlHostname(data.link?.originalUrl) ?? + getUrlHostname(data.link?.canonicalUrl) ?? + getUrlHostname(data.link?.domain) ?? + data.link?.domain ?? + data.displayTitle; + const fallbackInitial = fallbackDomain?.[0]?.toUpperCase() ?? 'L'; + // Handle outside clicks for dropdowns and editing modes | 드롭다운 및 편집 모드 외부 클릭 시 닫기/저장 처리 useEffect(() => { @@ -223,7 +251,7 @@ function LinkItem({ return (
{data.link?.faviconUrl ? ( - - ) : data.link?.domain ? ( { + const img = e.currentTarget; + if (img.naturalWidth === 16 && img.naturalHeight === 16) { + if (!img.dataset.fallbackStep) { + img.dataset.fallbackStep = 'basedomain'; + img.src = buildGoogleFaviconUrl(getUrlBaseDomain(data.link?.originalUrl)) || ''; + } else if (img.dataset.fallbackStep === 'basedomain') { + img.dataset.fallbackStep = 'direct'; + img.src = buildDirectFaviconUrl(data.link?.originalUrl) || ''; + } else { + img.style.display = 'none'; + if (img.parentElement) { + img.parentElement.textContent = fallbackInitial; + } + } + } + }} + onError={(e) => { + const img = e.currentTarget; + if (!img.dataset.fallbackStep) { + img.dataset.fallbackStep = 'basedomain'; + img.src = buildGoogleFaviconUrl(getUrlBaseDomain(data.link?.originalUrl)) || ''; + } else if (img.dataset.fallbackStep === 'basedomain') { + img.dataset.fallbackStep = 'direct'; + img.src = buildDirectFaviconUrl(data.link?.originalUrl) || ''; + } else { + img.style.display = 'none'; + if (img.parentElement) { + img.parentElement.textContent = fallbackInitial; + } + } + }} + /> + ) : fallbackFaviconUrl ? ( + { + const img = e.currentTarget; + if (img.naturalWidth === 16 && img.naturalHeight === 16) { + if (faviconFallbackStep === 'basedomain') { + setFaviconFallbackStep('direct'); + } else { + img.style.display = 'none'; + if (img.parentElement) { + img.parentElement.textContent = fallbackInitial; + } + } + } + }} onError={(e) => { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).parentElement!.innerHTML = `${data.link?.domain?.[0] ?? 'L'}`; + const img = e.currentTarget; + if (faviconFallbackStep === 'basedomain') { + setFaviconFallbackStep('direct'); + } else { + img.style.display = 'none'; + if (img.parentElement) { + img.parentElement.textContent = fallbackInitial; + } + } }} /> ) : ( - {(data.link?.domain ?? data.displayTitle)?.[0] ?? 'L'} + {fallbackInitial} )}
@@ -873,26 +957,36 @@ export function RightPanel() { {/* Dropdown Menu */} {isTagDropdownOpen && ( -
- {AVAILABLE_TAGS.map(tag => { - const isSelected = selectedTags.includes(tag); - return ( - - ); - })} +
+ {AVAILABLE_TAGS.length > 0 ? ( + AVAILABLE_TAGS.map(tag => { + const isSelected = selectedTags.includes(tag); + return ( + + ); + }) + ) : ( +
+ sell + No tags found +
+ )} {selectedTags.length > 0 && (