From 50dbf1f73824b6dcd0f270494e4b107f0847a904 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Mon, 10 Nov 2025 18:49:44 +0900
Subject: [PATCH 01/24] =?UTF-8?q?=EB=8F=84=EC=BB=A4=20=ED=8C=8C=EC=9D=BC?=
=?UTF-8?q?=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index ab7a327e..27f77c21 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -66,9 +66,9 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
-# 업로드 디렉토리 생성 (백엔드용)
-RUN mkdir -p /app/backend/uploads && \
- chown -R nodejs:nodejs /app/backend/uploads
+# 업로드 및 로그 디렉토리 생성 (백엔드용)
+RUN mkdir -p /app/backend/uploads /app/backend/logs && \
+ chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs
# 시작 스크립트 생성
RUN echo '#!/bin/sh' > /app/start.sh && \
From f340b1ac050fcbbc4e4772d33de6149eea2fc614 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 10:13:41 +0900
Subject: [PATCH 02/24] =?UTF-8?q?log=20=EB=94=94=EB=A0=89=ED=84=B0?=
=?UTF-8?q?=EB=A6=AC=20=EA=B6=8C=ED=95=9C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 27f77c21..ca0d14a0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -66,9 +66,15 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/package.json ./
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./frontend/public
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
-# 업로드 및 로그 디렉토리 생성 (백엔드용)
-RUN mkdir -p /app/backend/uploads /app/backend/logs && \
- chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs
+# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
+RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data && \
+ chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data && \
+ chmod -R 755 /app/backend
+
+# 프론트엔드 standalone 모드를 위한 디렉토리 생성
+RUN mkdir -p /app/frontend/data && \
+ chown -R nodejs:nodejs /app/frontend && \
+ chmod -R 755 /app/frontend
# 시작 스크립트 생성
RUN echo '#!/bin/sh' > /app/start.sh && \
@@ -83,11 +89,7 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
echo 'cd /app/frontend' >> /app/start.sh && \
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
- echo 'npm start &' >> /app/start.sh && \
- echo 'FRONTEND_PID=$!' >> /app/start.sh && \
- echo '' >> /app/start.sh && \
- echo '# 프로세스 모니터링' >> /app/start.sh && \
- echo 'wait $BACKEND_PID $FRONTEND_PID' >> /app/start.sh && \
+ echo 'exec npm start' >> /app/start.sh && \
chmod +x /app/start.sh && \
chown nodejs:nodejs /app/start.sh
From 70e97aa4a2ed038bce4fd54413d9e9342f6fe2dc Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 11:26:26 +0900
Subject: [PATCH 03/24] =?UTF-8?q?=EB=8F=84=EC=BB=A4=ED=8C=8C=EC=9D=BC?=
=?UTF-8?q?=EC=97=90=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95(=EC=9E=84=EC=8B=9C)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/Dockerfile b/Dockerfile
index ca0d14a0..8dbf67ae 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -93,6 +93,25 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
chmod +x /app/start.sh && \
chown nodejs:nodejs /app/start.sh
+# ============================================================
+# 환경변수 설정 (임시 조치)
+# helm-charts의 values_logistream.yaml 관리자가 설정 완료 시 삭제 예정
+# ============================================================
+ENV NODE_ENV=production \
+ LOG_LEVEL=info \
+ PORT=8080 \
+ HOST=0.0.0.0 \
+ DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
+ JWT_SECRET="ilshin-plm-super-secret-jwt-key-2024" \
+ ENCRYPTION_KEY="ilshin-plm-mail-encryption-key-32characters-2024-secure" \
+ JWT_EXPIRES_IN="24h" \
+ CORS_CREDENTIALS="true" \
+ CORS_ORIGIN="https://logistream.kpslp.kr" \
+ KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \
+ ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \
+ NEXT_TELEMETRY_DISABLED="1" \
+ NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
+
# 비특권 사용자로 전환
USER nodejs
From 802cda7348a4cff32a0630000b2285d1198a9d48 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 11:52:53 +0900
Subject: [PATCH 04/24] =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 8dbf67ae..9018f249 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -67,9 +67,12 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./fronte
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
-RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data && \
- chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data && \
- chmod -R 755 /app/backend
+# /app/uploads 경로는 백엔드 코드에서 사용 (mailTemplateFileService 등)
+RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data \
+ /app/uploads/mail-templates /app/uploads/mail-accounts && \
+ chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data \
+ /app/uploads && \
+ chmod -R 755 /app/backend /app/uploads
# 프론트엔드 standalone 모드를 위한 디렉토리 생성
RUN mkdir -p /app/frontend/data && \
From 12b5c4243a99b3cd0f8fecd012c8dba334b61a2a Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 12:23:21 +0900
Subject: [PATCH 05/24] =?UTF-8?q?uuid=EB=B2=84=EC=A0=84=20=EB=8B=A4?=
=?UTF-8?q?=EC=9A=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend-node/package.json b/backend-node/package.json
index bacd9fb3..871c7212 100644
--- a/backend-node/package.json
+++ b/backend-node/package.json
@@ -48,7 +48,7 @@
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10",
- "uuid": "^13.0.0",
+ "uuid": "^9.0.1",
"winston": "^3.11.0"
},
"devDependencies": {
From 8508e64ab301f5c88e531bc611c27f1494ce7da9 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 12:26:36 +0900
Subject: [PATCH 06/24] Fix: Update package-lock.json for uuid@9.0.1
---
backend-node/package-lock.json | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json
index b9528ee0..b0472294 100644
--- a/backend-node/package-lock.json
+++ b/backend-node/package-lock.json
@@ -34,7 +34,7 @@
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10",
- "uuid": "^13.0.0",
+ "uuid": "^9.0.1",
"winston": "^3.11.0"
},
"devDependencies": {
@@ -10642,16 +10642,16 @@
}
},
"node_modules/uuid": {
- "version": "13.0.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
- "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
- "uuid": "dist-node/bin/uuid"
+ "uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
From b47f34c6165d70361e0b4d0ccd3b0755a890d990 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 12:44:28 +0900
Subject: [PATCH 07/24] =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?=
=?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 9018f249..36aa0263 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -67,12 +67,13 @@ COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/public ./fronte
COPY --from=frontend-builder --chown=nodejs:nodejs /app/frontend/next.config.mjs ./frontend/next.config.mjs
# 백엔드 디렉토리 생성 (업로드, 로그, 데이터)
-# /app/uploads 경로는 백엔드 코드에서 사용 (mailTemplateFileService 등)
+# /app/uploads, /app/data 경로는 백엔드 코드에서 동적으로 하위 디렉토리 생성
+# 상위 디렉토리에 쓰기 권한 부여하여 런타임에 자유롭게 생성 가능하도록 함
RUN mkdir -p /app/backend/uploads /app/backend/logs /app/backend/data \
- /app/uploads/mail-templates /app/uploads/mail-accounts && \
- chown -R nodejs:nodejs /app/backend/uploads /app/backend/logs /app/backend/data \
- /app/uploads && \
- chmod -R 755 /app/backend /app/uploads
+ /app/uploads /app/data && \
+ chown -R nodejs:nodejs /app/backend /app/uploads /app/data && \
+ chmod -R 777 /app/uploads /app/data && \
+ chmod -R 755 /app/backend
# 프론트엔드 standalone 모드를 위한 디렉토리 생성
RUN mkdir -p /app/frontend/data && \
From 198f9a6f2bb7c77d29f4ff19a8c9bab817156dc4 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 13:14:25 +0900
Subject: [PATCH 08/24] =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 36aa0263..73653080 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -87,13 +87,13 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
echo '# 백엔드 시작 (백그라운드)' >> /app/start.sh && \
echo 'cd /app/backend' >> /app/start.sh && \
echo 'echo "Starting backend on port 8080..."' >> /app/start.sh && \
- echo 'node dist/app.js &' >> /app/start.sh && \
+ echo 'PORT=8080 node dist/app.js &' >> /app/start.sh && \
echo 'BACKEND_PID=$!' >> /app/start.sh && \
echo '' >> /app/start.sh && \
echo '# 프론트엔드 시작 (포그라운드)' >> /app/start.sh && \
echo 'cd /app/frontend' >> /app/start.sh && \
echo 'echo "Starting frontend on port 3000..."' >> /app/start.sh && \
- echo 'exec npm start' >> /app/start.sh && \
+ echo 'PORT=3000 exec npm start' >> /app/start.sh && \
chmod +x /app/start.sh && \
chown nodejs:nodejs /app/start.sh
@@ -103,7 +103,6 @@ RUN echo '#!/bin/sh' > /app/start.sh && \
# ============================================================
ENV NODE_ENV=production \
LOG_LEVEL=info \
- PORT=8080 \
HOST=0.0.0.0 \
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
JWT_SECRET="ilshin-plm-super-secret-jwt-key-2024" \
From 3153cf03832ddf2a216a504d9c6434c3ed5e6d81 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 13:49:56 +0900
Subject: [PATCH 09/24] =?UTF-8?q?=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 73653080..ed23aa6f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -121,9 +121,10 @@ USER nodejs
# 포트 노출
EXPOSE 3000 8080
-# 헬스체크
-HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
+# 헬스체크 (백엔드와 프론트엔드 둘 다 확인)
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health && \
+ wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
# 컨테이너 시작
CMD ["/app/start.sh"]
From cf2b5d4e8013d352d46058efd952eceb09d27766 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 16:59:16 +0900
Subject: [PATCH 10/24] =?UTF-8?q?url=EC=9D=84=20=EB=A7=9E=EC=B6=B0=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/lib/api/client.ts | 5 +++++
frontend/lib/utils/apiUrl.ts | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts
index 7dc811c9..7279092e 100644
--- a/frontend/lib/api/client.ts
+++ b/frontend/lib/api/client.ts
@@ -13,6 +13,11 @@ const getApiBaseUrl = (): string => {
const currentPort = window.location.port;
const protocol = window.location.protocol;
+ // 프로덕션 환경: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트
+ if (currentHost === "logistream.kpslp.kr") {
+ return `${protocol}//${currentHost}:8080/api`;
+ }
+
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
if (currentHost === "v1.vexplor.com") {
return "https://api.vexplor.com/api";
diff --git a/frontend/lib/utils/apiUrl.ts b/frontend/lib/utils/apiUrl.ts
index ea334b86..c50dc533 100644
--- a/frontend/lib/utils/apiUrl.ts
+++ b/frontend/lib/utils/apiUrl.ts
@@ -8,6 +8,11 @@ export function getApiUrl(endpoint: string): string {
if (typeof window !== "undefined") {
const hostname = window.location.hostname;
+ // 프로덕션: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트
+ if (hostname === "logistream.kpslp.kr") {
+ return `https://logistream.kpslp.kr:8080${endpoint}`;
+ }
+
// 프로덕션: v1.vexplor.com → https://api.vexplor.com
if (hostname === "v1.vexplor.com") {
return `https://api.vexplor.com${endpoint}`;
From c669374156a1dba45dfc6ce2b40c4461629c7b9f Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 17:20:17 +0900
Subject: [PATCH 11/24] =?UTF-8?q?NEXT=5FPUBLIC=5FAPI=5FURL=EC=97=90=20?=
=?UTF-8?q?=ED=8F=AC=ED=8A=B8=208080=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index ed23aa6f..eba3aeb0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -113,7 +113,7 @@ ENV NODE_ENV=production \
KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \
ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \
NEXT_TELEMETRY_DISABLED="1" \
- NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
+ NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api"
# 비특권 사용자로 전환
USER nodejs
From 1514af23831a0c5e3e0e24da7872d2871024edb1 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 17:49:41 +0900
Subject: [PATCH 12/24] =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=9C=EC=A0=90?=
=?UTF-8?q?=EC=97=90=20NEXT=5FPUBLIC=5FAPI=5FURL=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 2 ++
frontend/hooks/useLogin.ts | 5 ++++-
frontend/lib/api/client.ts | 16 +++++++++++++---
3 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index eba3aeb0..609384d6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -39,8 +39,10 @@ RUN npm ci && \
COPY frontend/ ./
# Next.js 프로덕션 빌드 (린트 비활성화)
+# 빌드 시점에 환경변수 설정 (번들에 포함됨)
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
+ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api"
RUN npm run build:no-lint
# ------------------------------
diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts
index 09c32d5f..02837bc9 100644
--- a/frontend/hooks/useLogin.ts
+++ b/frontend/hooks/useLogin.ts
@@ -64,7 +64,10 @@ export const useLogin = () => {
// 로컬 스토리지에서 토큰 가져오기
const token = localStorage.getItem("authToken");
- const response = await fetch(`${API_BASE_URL}${endpoint}`, {
+ // API URL 동적 계산 (매번 호출 시마다)
+ const apiBaseUrl = API_BASE_URL;
+
+ const response = await fetch(`${apiBaseUrl}${endpoint}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts
index 7279092e..d3afe83b 100644
--- a/frontend/lib/api/client.ts
+++ b/frontend/lib/api/client.ts
@@ -32,11 +32,21 @@ const getApiBaseUrl = (): string => {
}
}
- // 3. 기본값
- return "http://localhost:8080/api";
+ // 3. 기본값 (서버사이드 빌드 시)
+ return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api";
};
-export const API_BASE_URL = getApiBaseUrl();
+// 매번 호출 시 동적으로 계산 (getter 함수)
+export const getAPIBaseURL = getApiBaseUrl;
+
+// 하위 호환성을 위해 유지하되, 동적으로 계산되도록 수정
+let _cachedApiBaseUrl: string | null = null;
+export const API_BASE_URL = (() => {
+ if (_cachedApiBaseUrl === null || typeof window !== "undefined") {
+ _cachedApiBaseUrl = getApiBaseUrl();
+ }
+ return _cachedApiBaseUrl;
+})();
// 이미지 URL을 완전한 URL로 변환하는 함수
export const getFullImageUrl = (imagePath: string): string => {
From ff21a84932b686260cfab5ba8d584b1b52fd5874 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Tue, 11 Nov 2025 18:42:35 +0900
Subject: [PATCH 13/24] =?UTF-8?q?8080=ED=8F=AC=ED=8A=B8=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Dockerfile | 4 ++--
frontend/lib/api/client.ts | 4 ++--
frontend/lib/utils/apiUrl.ts | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 609384d6..09fceefb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -42,7 +42,7 @@ COPY frontend/ ./
# 빌드 시점에 환경변수 설정 (번들에 포함됨)
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
-ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api"
+ENV NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
RUN npm run build:no-lint
# ------------------------------
@@ -115,7 +115,7 @@ ENV NODE_ENV=production \
KMA_API_KEY="ogdXr2e9T4iHV69nvV-IwA" \
ITS_API_KEY="d6b9befec3114d648284674b8fddcc32" \
NEXT_TELEMETRY_DISABLED="1" \
- NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr:8080/api"
+ NEXT_PUBLIC_API_URL="https://logistream.kpslp.kr/api"
# 비특권 사용자로 전환
USER nodejs
diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts
index d3afe83b..97179313 100644
--- a/frontend/lib/api/client.ts
+++ b/frontend/lib/api/client.ts
@@ -13,9 +13,9 @@ const getApiBaseUrl = (): string => {
const currentPort = window.location.port;
const protocol = window.location.protocol;
- // 프로덕션 환경: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트
+ // 프로덕션 환경: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음)
if (currentHost === "logistream.kpslp.kr") {
- return `${protocol}//${currentHost}:8080/api`;
+ return `${protocol}//${currentHost}/api`;
}
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
diff --git a/frontend/lib/utils/apiUrl.ts b/frontend/lib/utils/apiUrl.ts
index c50dc533..6984b1bc 100644
--- a/frontend/lib/utils/apiUrl.ts
+++ b/frontend/lib/utils/apiUrl.ts
@@ -8,9 +8,9 @@ export function getApiUrl(endpoint: string): string {
if (typeof window !== "undefined") {
const hostname = window.location.hostname;
- // 프로덕션: logistream.kpslp.kr → 백엔드는 같은 도메인 8080 포트
+ // 프로덕션: logistream.kpslp.kr → Ingress를 통한 접근 (포트 없음)
if (hostname === "logistream.kpslp.kr") {
- return `https://logistream.kpslp.kr:8080${endpoint}`;
+ return `https://logistream.kpslp.kr${endpoint}`;
}
// 프로덕션: v1.vexplor.com → https://api.vexplor.com
From 22b6404a5b40908ea60be119073881a15793de21 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Thu, 13 Nov 2025 16:06:39 +0900
Subject: [PATCH 14/24] =?UTF-8?q?logistream=20=EA=B3=B5=EC=B0=A8=EC=A4=91?=
=?UTF-8?q?=EA=B3=84=EC=9A=A9=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/controllers/authController.ts | 67 +++++
backend-node/src/routes/authRoutes.ts | 6 +
backend-node/src/services/authService.ts | 73 ++++++
frontend/app/(auth)/signup/page.tsx | 49 ++++
frontend/components/auth/LoginForm.tsx | 14 +
frontend/components/auth/SignupForm.tsx | 244 ++++++++++++++++++
frontend/hooks/useSignup.ts | 235 +++++++++++++++++
frontend/lib/api/auth.ts | 21 ++
frontend/types/auth.ts | 18 ++
9 files changed, 727 insertions(+)
create mode 100644 frontend/app/(auth)/signup/page.tsx
create mode 100644 frontend/components/auth/SignupForm.tsx
create mode 100644 frontend/hooks/useSignup.ts
create mode 100644 frontend/lib/api/auth.ts
diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts
index 374015ee..2d574cc7 100644
--- a/backend-node/src/controllers/authController.ts
+++ b/backend-node/src/controllers/authController.ts
@@ -384,4 +384,71 @@ export class AuthController {
});
}
}
+
+ /**
+ * POST /api/auth/signup
+ * 회원가입 API
+ */
+ static async signup(req: Request, res: Response): Promise {
+ try {
+ const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber } = req.body;
+
+ logger.info(`=== 회원가입 API 호출 ===`);
+ logger.info(`userId: ${userId}`);
+
+ // 입력값 검증
+ if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) {
+ res.status(400).json({
+ success: false,
+ message: "모든 필수 항목을 입력해주세요.",
+ error: {
+ code: "INVALID_INPUT",
+ details: "필수 입력값이 누락되었습니다.",
+ },
+ });
+ return;
+ }
+
+ // 회원가입 처리
+ const signupResult = await AuthService.signupUser({
+ userId,
+ password,
+ userName,
+ phoneNumber,
+ licenseNumber,
+ vehicleNumber,
+ });
+
+ if (signupResult.success) {
+ logger.info(`회원가입 성공: ${userId}`);
+ res.status(201).json({
+ success: true,
+ message: "회원가입이 완료되었습니다.",
+ data: {
+ userId,
+ },
+ });
+ } else {
+ logger.warn(`회원가입 실패: ${userId} - ${signupResult.message}`);
+ res.status(400).json({
+ success: false,
+ message: signupResult.message || "회원가입에 실패했습니다.",
+ error: {
+ code: "SIGNUP_FAILED",
+ details: signupResult.message,
+ },
+ });
+ }
+ } catch (error: any) {
+ logger.error("회원가입 오류:", error);
+ res.status(500).json({
+ success: false,
+ message: "회원가입 처리 중 오류가 발생했습니다.",
+ error: {
+ code: "SIGNUP_ERROR",
+ details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ },
+ });
+ }
+ }
}
diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts
index 29bc7944..357bfc8e 100644
--- a/backend-node/src/routes/authRoutes.ts
+++ b/backend-node/src/routes/authRoutes.ts
@@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout);
*/
router.post("/refresh", AuthController.refreshToken);
+/**
+ * POST /api/auth/signup
+ * 회원가입 API
+ */
+router.post("/signup", AuthController.signup);
+
export default router;
diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts
index 11e34576..1bf4dd40 100644
--- a/backend-node/src/services/authService.ts
+++ b/backend-node/src/services/authService.ts
@@ -342,4 +342,77 @@ export class AuthService {
);
}
}
+
+ /**
+ * 회원가입 처리
+ */
+ static async signupUser(data: {
+ userId: string;
+ password: string;
+ userName: string;
+ phoneNumber: string;
+ licenseNumber: string;
+ vehicleNumber: string;
+ }): Promise<{ success: boolean; message?: string }> {
+ try {
+ const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber } = data;
+
+ // 1. 중복 사용자 확인
+ const existingUser = await query(
+ `SELECT user_id FROM user_info WHERE user_id = $1`,
+ [userId]
+ );
+
+ if (existingUser.length > 0) {
+ return {
+ success: false,
+ message: "이미 존재하는 아이디입니다.",
+ };
+ }
+
+ // 2. 비밀번호 암호화
+ const bcrypt = require("bcryptjs");
+ const hashedPassword = await bcrypt.hash(password, 10);
+
+ // 3. 사용자 정보 저장
+ await query(
+ `INSERT INTO user_info (
+ user_id,
+ user_password,
+ user_name,
+ cell_phone,
+ license_number,
+ vehicle_number,
+ company_code,
+ user_type,
+ status,
+ regdate
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`,
+ [
+ userId,
+ hashedPassword,
+ userName,
+ phoneNumber,
+ licenseNumber,
+ vehicleNumber,
+ "*", // 기본 회사 코드
+ null, // user_type: null
+ "active", // status: active
+ ]
+ );
+
+ logger.info(`회원가입 성공: ${userId}`);
+
+ return {
+ success: true,
+ message: "회원가입이 완료되었습니다.",
+ };
+ } catch (error: any) {
+ logger.error("회원가입 오류:", error);
+ return {
+ success: false,
+ message: error.message || "회원가입 중 오류가 발생했습니다.",
+ };
+ }
+ }
}
diff --git a/frontend/app/(auth)/signup/page.tsx b/frontend/app/(auth)/signup/page.tsx
new file mode 100644
index 00000000..ebfed844
--- /dev/null
+++ b/frontend/app/(auth)/signup/page.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useSignup } from "@/hooks/useSignup";
+import { LoginHeader } from "@/components/auth/LoginHeader";
+import { SignupForm } from "@/components/auth/SignupForm";
+import { LoginFooter } from "@/components/auth/LoginFooter";
+
+/**
+ * 회원가입 페이지 컴포넌트
+ */
+export default function SignupPage() {
+ const {
+ formData,
+ isLoading,
+ error,
+ showPassword,
+ validationErrors,
+ touchedFields,
+ isFormValid,
+ handleInputChange,
+ handleBlur,
+ handleSignup,
+ togglePasswordVisibility,
+ } = useSignup();
+
+ return (
+
+ );
+}
diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx
index dda3736f..9d82895a 100644
--- a/frontend/components/auth/LoginForm.tsx
+++ b/frontend/components/auth/LoginForm.tsx
@@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { LoginFormData } from "@/types/auth";
import { ErrorMessage } from "./ErrorMessage";
+import { useRouter } from "next/navigation";
interface LoginFormProps {
formData: LoginFormData;
@@ -28,6 +29,8 @@ export function LoginForm({
onSubmit,
onTogglePassword,
}: LoginFormProps) {
+ const router = useRouter();
+
return (
@@ -97,6 +100,17 @@ export function LoginForm({
"로그인"
)}
+
+ {/* 회원가입 버튼 */}
+
diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx
new file mode 100644
index 00000000..e7c8f267
--- /dev/null
+++ b/frontend/components/auth/SignupForm.tsx
@@ -0,0 +1,244 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react";
+import { SignupFormData } from "@/types/auth";
+import { ErrorMessage } from "./ErrorMessage";
+import { useRouter } from "next/navigation";
+
+interface SignupFormProps {
+ formData: SignupFormData;
+ isLoading: boolean;
+ error: string;
+ showPassword: boolean;
+ validationErrors: Record;
+ touchedFields: Record;
+ isFormValid: boolean;
+ onInputChange: (e: React.ChangeEvent) => void;
+ onBlur: (e: React.FocusEvent) => void;
+ onSubmit: (e: React.FormEvent) => void;
+ onTogglePassword: () => void;
+}
+
+/**
+ * 회원가입 폼 컴포넌트
+ */
+export function SignupForm({
+ formData,
+ isLoading,
+ error,
+ showPassword,
+ validationErrors,
+ touchedFields,
+ isFormValid,
+ onInputChange,
+ onBlur,
+ onSubmit,
+ onTogglePassword,
+}: SignupFormProps) {
+ const router = useRouter();
+
+ return (
+
+
+ 회원가입
+ 새로운 계정을 만들어보세요
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/hooks/useSignup.ts b/frontend/hooks/useSignup.ts
new file mode 100644
index 00000000..6327320b
--- /dev/null
+++ b/frontend/hooks/useSignup.ts
@@ -0,0 +1,235 @@
+import { useState, useCallback, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { SignupFormData } from "@/types/auth";
+import { signupUser } from "@/lib/api/auth";
+import { useToast } from "@/hooks/use-toast";
+
+/**
+ * 유효성 검사 함수들
+ */
+const validators = {
+ // 연락처: 010-1234-5678 형식
+ phoneNumber: (value: string): string | null => {
+ const phoneRegex = /^01[0-9]-\d{3,4}-\d{4}$/;
+ if (!value) return "연락처를 입력해주세요";
+ if (!phoneRegex.test(value)) return "올바른 연락처 형식이 아닙니다 (예: 010-1234-5678)";
+ return null;
+ },
+
+ // 면허번호: 12-34-567890-12 형식
+ licenseNumber: (value: string): string | null => {
+ const licenseRegex = /^\d{2}-\d{2}-\d{6}-\d{2}$/;
+ if (!value) return "면허번호를 입력해주세요";
+ if (!licenseRegex.test(value)) return "올바른 면허번호 형식이 아닙니다 (예: 12-34-567890-12)";
+ return null;
+ },
+
+ // 차량번호: 12가1234 형식
+ vehicleNumber: (value: string): string | null => {
+ const vehicleRegex = /^\d{2,3}[가-힣]\d{4}$/;
+ if (!value) return "차량번호를 입력해주세요";
+ if (!vehicleRegex.test(value)) return "올바른 차량번호 형식이 아닙니다 (예: 12가1234)";
+ return null;
+ },
+
+ // 아이디: 4자 이상
+ userId: (value: string): string | null => {
+ if (!value) return "아이디를 입력해주세요";
+ if (value.length < 4) return "아이디는 4자 이상이어야 합니다";
+ return null;
+ },
+
+ // 비밀번호: 6자 이상
+ password: (value: string): string | null => {
+ if (!value) return "비밀번호를 입력해주세요";
+ if (value.length < 6) return "비밀번호는 6자 이상이어야 합니다";
+ return null;
+ },
+
+ // 비밀번호 확인: password와 일치해야 함
+ passwordConfirm: (value: string, password?: string): string | null => {
+ if (!value) return "비밀번호 확인을 입력해주세요";
+ if (password && value !== password) return "비밀번호가 일치하지 않습니다";
+ return null;
+ },
+
+ // 이름: 2자 이상
+ userName: (value: string): string | null => {
+ if (!value) return "이름을 입력해주세요";
+ if (value.length < 2) return "이름은 2자 이상이어야 합니다";
+ return null;
+ },
+};
+
+/**
+ * 회원가입 커스텀 훅
+ */
+export function useSignup() {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+
+ const [formData, setFormData] = useState({
+ userId: "",
+ password: "",
+ passwordConfirm: "",
+ userName: "",
+ phoneNumber: "",
+ licenseNumber: "",
+ vehicleNumber: "",
+ });
+
+ const [validationErrors, setValidationErrors] = useState>({});
+ const [touchedFields, setTouchedFields] = useState>({});
+ const [isFormValid, setIsFormValid] = useState(false);
+
+ // 유효성 검사 실행
+ const validateField = useCallback((name: string, value: string, password?: string) => {
+ const validator = validators[name as keyof typeof validators];
+ if (validator) {
+ // passwordConfirm 검증 시 password도 함께 전달
+ if (name === "passwordConfirm") {
+ return validator(value, password);
+ }
+ return validator(value);
+ }
+ return null;
+ }, []);
+
+ // 전체 폼 유효성 검사
+ const validateForm = useCallback(() => {
+ const errors: Record = {};
+ let hasErrors = false;
+
+ Object.keys(formData).forEach((key) => {
+ const error = validateField(key, formData[key as keyof SignupFormData], formData.password);
+ if (error) {
+ errors[key] = error;
+ hasErrors = true;
+ }
+ });
+
+ setValidationErrors(errors);
+ setIsFormValid(!hasErrors && Object.values(formData).every((value) => value !== ""));
+ }, [formData, validateField]);
+
+ // 입력 변경 핸들러
+ const handleInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ const newFormData = { ...formData, [name]: value };
+ setFormData(newFormData);
+
+ // touched된 필드만 실시간 유효성 검사
+ if (touchedFields[name]) {
+ const fieldError = validateField(name, value, newFormData.password);
+ setValidationErrors((prev) => ({
+ ...prev,
+ [name]: fieldError || "",
+ }));
+ }
+
+ // password가 변경되면 passwordConfirm도 다시 검증
+ if (name === "password" && touchedFields.passwordConfirm) {
+ const confirmError = validateField("passwordConfirm", newFormData.passwordConfirm, value);
+ setValidationErrors((prev) => ({
+ ...prev,
+ passwordConfirm: confirmError || "",
+ }));
+ }
+
+ setError("");
+ },
+ [validateField, touchedFields, formData],
+ );
+
+ // 포커스 아웃 핸들러 (Blur)
+ const handleBlur = useCallback(
+ (e: React.FocusEvent) => {
+ const { name, value } = e.target;
+
+ // 필드를 touched로 표시
+ setTouchedFields((prev) => ({ ...prev, [name]: true }));
+
+ // 유효성 검사 실행
+ const fieldError = validateField(name, value, formData.password);
+ setValidationErrors((prev) => ({
+ ...prev,
+ [name]: fieldError || "",
+ }));
+ },
+ [validateField, formData.password],
+ );
+
+ // 회원가입 제출
+ const handleSignup = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+
+ // 모든 필드를 touched로 표시 (제출 시 모든 에러 표시)
+ const allTouched = Object.keys(formData).reduce((acc, key) => ({ ...acc, [key]: true }), {});
+ setTouchedFields(allTouched);
+
+ // 최종 유효성 검사
+ validateForm();
+
+ if (!isFormValid) {
+ setError("입력 정보를 다시 확인해주세요");
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await signupUser(formData);
+
+ if (response.success) {
+ toast({
+ title: "회원가입 성공",
+ description: "로그인 페이지로 이동합니다",
+ });
+
+ // 1초 후 로그인 페이지로 이동
+ setTimeout(() => {
+ router.push("/login");
+ }, 1000);
+ } else {
+ setError(response.message || "회원가입에 실패했습니다");
+ }
+ } catch (err: any) {
+ console.error("회원가입 오류:", err);
+ setError(err.message || "회원가입 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [formData, isFormValid, router, toast, validateForm],
+ );
+
+ // 비밀번호 표시 토글
+ const togglePasswordVisibility = useCallback(() => {
+ setShowPassword((prev) => !prev);
+ }, []);
+
+ // 폼 데이터 변경 시 유효성 검사 실행
+ useEffect(() => {
+ validateForm();
+ }, [formData, validateForm]);
+
+ return {
+ formData,
+ isLoading,
+ error,
+ showPassword,
+ validationErrors,
+ touchedFields,
+ isFormValid,
+ handleInputChange,
+ handleBlur,
+ handleSignup,
+ togglePasswordVisibility,
+ };
+}
diff --git a/frontend/lib/api/auth.ts b/frontend/lib/api/auth.ts
new file mode 100644
index 00000000..c3f701e0
--- /dev/null
+++ b/frontend/lib/api/auth.ts
@@ -0,0 +1,21 @@
+import { apiClient } from "./client";
+import { SignupFormData, SignupResponse } from "@/types/auth";
+
+/**
+ * 회원가입 API
+ */
+export async function signupUser(data: SignupFormData): Promise {
+ try {
+ const response = await apiClient.post("/auth/signup", data);
+ return response.data;
+ } catch (error: any) {
+ if (error.response?.data) {
+ return error.response.data;
+ }
+ return {
+ success: false,
+ message: error.message || "회원가입 중 오류가 발생했습니다",
+ };
+ }
+}
+
diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts
index cd8e65b6..f864df5b 100644
--- a/frontend/types/auth.ts
+++ b/frontend/types/auth.ts
@@ -7,6 +7,16 @@ export interface LoginFormData {
password: string;
}
+export interface SignupFormData {
+ userId: string;
+ password: string;
+ passwordConfirm: string;
+ userName: string;
+ phoneNumber: string;
+ licenseNumber: string;
+ vehicleNumber: string;
+}
+
export interface LoginResponse {
success: boolean;
message?: string;
@@ -18,6 +28,14 @@ export interface LoginResponse {
errorCode?: string;
}
+export interface SignupResponse {
+ success: boolean;
+ message?: string;
+ data?: {
+ userId: string;
+ };
+}
+
export interface AuthStatus {
isLoggedIn: boolean;
isAdmin?: boolean;
From 0b61ef4d1225960ad325fe7eab4015797f6d7f66 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Thu, 13 Nov 2025 18:25:50 +0900
Subject: [PATCH 15/24] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?=
=?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/components/auth/SignupForm.tsx | 17 ++++++++---------
1 file changed, 8 insertions(+), 9 deletions(-)
diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx
index e7c8f267..7ed5e846 100644
--- a/frontend/components/auth/SignupForm.tsx
+++ b/frontend/components/auth/SignupForm.tsx
@@ -65,7 +65,7 @@ export function SignupForm({
required
/>
{touchedFields?.userId && validationErrors.userId && (
- {validationErrors.userId}
+ {validationErrors.userId}
)}
@@ -95,7 +95,7 @@ export function SignupForm({
{touchedFields?.password && validationErrors.password && (
- {validationErrors.password}
+ {validationErrors.password}
)}
@@ -125,7 +125,7 @@ export function SignupForm({
{touchedFields?.passwordConfirm && validationErrors.passwordConfirm && (
- {validationErrors.passwordConfirm}
+ {validationErrors.passwordConfirm}
)}
@@ -145,7 +145,7 @@ export function SignupForm({
required
/>
{touchedFields?.userName && validationErrors.userName && (
- {validationErrors.userName}
+ {validationErrors.userName}
)}
@@ -165,7 +165,7 @@ export function SignupForm({
required
/>
{touchedFields?.phoneNumber && validationErrors.phoneNumber && (
- {validationErrors.phoneNumber}
+ {validationErrors.phoneNumber}
)}
@@ -185,7 +185,7 @@ export function SignupForm({
required
/>
{touchedFields?.licenseNumber && validationErrors.licenseNumber && (
- {validationErrors.licenseNumber}
+ {validationErrors.licenseNumber}
)}
@@ -205,7 +205,7 @@ export function SignupForm({
required
/>
{touchedFields?.vehicleNumber && validationErrors.vehicleNumber && (
- {validationErrors.vehicleNumber}
+ {validationErrors.vehicleNumber}
)}
@@ -221,7 +221,7 @@ export function SignupForm({
가입 중...
>
) : (
- "회원가입"
+ "회원가입(공차중계)"
)}
@@ -241,4 +241,3 @@ export function SignupForm({
);
}
-
From 39d327fb45c402b8913b5559809a4990298c816d Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Fri, 28 Nov 2025 11:35:36 +0900
Subject: [PATCH 16/24] =?UTF-8?q?=EC=99=B8=EB=B6=80=20REST=20API=20?=
=?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=99=95=EC=9E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/controllers/DashboardController.ts | 177 ++++++++++------
.../externalRestApiConnectionService.ts | 199 ++++++++++--------
.../dashboard/data-sources/MultiApiConfig.tsx | 78 ++++++-
frontend/components/admin/dashboard/types.ts | 5 +-
frontend/lib/api/externalDbConnection.ts | 3 +
5 files changed, 308 insertions(+), 154 deletions(-)
diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts
index 521f5250..01ac16c0 100644
--- a/backend-node/src/controllers/DashboardController.ts
+++ b/backend-node/src/controllers/DashboardController.ts
@@ -1,4 +1,7 @@
import { Response } from "express";
+import https from "https";
+import axios, { AxiosRequestConfig } from "axios";
+import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { DashboardService } from "../services/DashboardService";
import {
@@ -7,6 +10,7 @@ import {
DashboardListQuery,
} from "../types/dashboard";
import { PostgreSQLService } from "../database/PostgreSQLService";
+import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
/**
* 대시보드 컨트롤러
@@ -590,7 +594,14 @@ export class DashboardController {
res: Response
): Promise {
try {
- const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
+ const {
+ url,
+ method = "GET",
+ headers = {},
+ queryParams = {},
+ body,
+ externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
+ } = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({
@@ -608,85 +619,131 @@ export class DashboardController {
}
});
- // 외부 API 호출 (타임아웃 30초)
- // @ts-ignore - node-fetch dynamic import
- const fetch = (await import("node-fetch")).default;
-
- // 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
- const controller = new (global as any).AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
-
- let response;
- try {
- response = await fetch(urlObj.toString(), {
- method: method.toUpperCase(),
- headers: {
- "Content-Type": "application/json",
- ...headers,
- },
- signal: controller.signal,
- });
- clearTimeout(timeoutId);
- } catch (err: any) {
- clearTimeout(timeoutId);
- if (err.name === 'AbortError') {
- throw new Error('외부 API 요청 타임아웃 (30초 초과)');
+ // Axios 요청 설정
+ const requestConfig: AxiosRequestConfig = {
+ url: urlObj.toString(),
+ method: method.toUpperCase(),
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ ...headers,
+ },
+ timeout: 60000, // 60초 타임아웃
+ validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
+ };
+
+ // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
+ if (externalConnectionId) {
+ try {
+ // 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
+ let companyCode = req.user?.companyCode;
+
+ if (!companyCode) {
+ companyCode = "*";
+ }
+
+ // 커넥션 로드
+ const connectionResult =
+ await ExternalRestApiConnectionService.getConnectionById(
+ Number(externalConnectionId),
+ companyCode
+ );
+
+ if (connectionResult.success && connectionResult.data) {
+ const connection = connectionResult.data;
+
+ // 인증 헤더 생성 (DB 토큰 등)
+ const authHeaders =
+ await ExternalRestApiConnectionService.getAuthHeaders(
+ connection.auth_type,
+ connection.auth_config,
+ connection.company_code
+ );
+
+ // 기존 헤더에 인증 헤더 병합
+ requestConfig.headers = {
+ ...requestConfig.headers,
+ ...authHeaders,
+ };
+
+ // API Key가 Query Param인 경우 처리
+ if (
+ connection.auth_type === "api-key" &&
+ connection.auth_config?.keyLocation === "query" &&
+ connection.auth_config?.keyName &&
+ connection.auth_config?.keyValue
+ ) {
+ const currentUrl = new URL(requestConfig.url!);
+ currentUrl.searchParams.append(
+ connection.auth_config.keyName,
+ connection.auth_config.keyValue
+ );
+ requestConfig.url = currentUrl.toString();
+ }
+ }
+ } catch (connError) {
+ logger.error(
+ `외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
+ connError
+ );
}
- throw err;
}
- if (!response.ok) {
+ // Body 처리
+ if (body) {
+ requestConfig.data = body;
+ }
+
+ // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
+ // ExternalRestApiConnectionService와 동일한 로직 적용
+ const bypassDomains = ["thiratis.com"];
+ const hostname = urlObj.hostname;
+ const shouldBypassTls = bypassDomains.some((domain) =>
+ hostname.includes(domain)
+ );
+
+ if (shouldBypassTls) {
+ requestConfig.httpsAgent = new https.Agent({
+ rejectUnauthorized: false,
+ });
+ }
+
+ const response = await axios(requestConfig);
+
+ if (response.status >= 400) {
throw new Error(
`외부 API 오류: ${response.status} ${response.statusText}`
);
}
- // Content-Type에 따라 응답 파싱
- const contentType = response.headers.get("content-type");
- let data: any;
+ let data = response.data;
+ const contentType = response.headers["content-type"];
- // 한글 인코딩 처리 (EUC-KR → UTF-8)
- const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
- urlObj.hostname.includes('data.go.kr');
-
- if (isKoreanApi) {
- // 한국 정부 API는 EUC-KR 인코딩 사용
- const buffer = await response.arrayBuffer();
- const decoder = new TextDecoder('euc-kr');
- const text = decoder.decode(buffer);
-
- try {
- data = JSON.parse(text);
- } catch {
- data = { text, contentType };
- }
- } else if (contentType && contentType.includes("application/json")) {
- data = await response.json();
- } else if (contentType && contentType.includes("text/")) {
- // 텍스트 응답 (CSV, 일반 텍스트 등)
- const text = await response.text();
- data = { text, contentType };
- } else {
- // 기타 응답 (JSON으로 시도)
- try {
- data = await response.json();
- } catch {
- const text = await response.text();
- data = { text, contentType };
- }
+ // 텍스트 응답인 경우 포맷팅
+ if (typeof data === "string") {
+ data = { text: data, contentType };
}
res.status(200).json({
success: true,
data,
});
- } catch (error) {
+ } catch (error: any) {
+ const status = error.response?.status || 500;
+ const message = error.response?.statusText || error.message;
+
+ logger.error("외부 API 호출 오류:", {
+ message,
+ status,
+ data: error.response?.data,
+ });
+
res.status(500).json({
success: false,
message: "외부 API 호출 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
- ? (error as Error).message
+ ? message
: "외부 API 호출 오류",
});
}
diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts
index 668c07ae..0599a409 100644
--- a/backend-node/src/services/externalRestApiConnectionService.ts
+++ b/backend-node/src/services/externalRestApiConnectionService.ts
@@ -460,6 +460,105 @@ export class ExternalRestApiConnectionService {
}
}
+ /**
+ * 인증 헤더 생성
+ */
+ static async getAuthHeaders(
+ authType: AuthType,
+ authConfig: any,
+ companyCode?: string
+ ): Promise> {
+ const headers: Record = {};
+
+ if (authType === "db-token") {
+ const cfg = authConfig || {};
+ const {
+ dbTableName,
+ dbValueColumn,
+ dbWhereColumn,
+ dbWhereValue,
+ dbHeaderName,
+ dbHeaderTemplate,
+ } = cfg;
+
+ if (!dbTableName || !dbValueColumn) {
+ throw new Error("DB 토큰 설정이 올바르지 않습니다.");
+ }
+
+ if (!companyCode) {
+ throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
+ }
+
+ const hasWhereColumn = !!dbWhereColumn;
+ const hasWhereValue =
+ dbWhereValue !== undefined &&
+ dbWhereValue !== null &&
+ dbWhereValue !== "";
+
+ // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
+ if (hasWhereColumn !== hasWhereValue) {
+ throw new Error(
+ "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
+ );
+ }
+
+ // 식별자 검증 (간단한 화이트리스트)
+ const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+ if (
+ !identifierRegex.test(dbTableName) ||
+ !identifierRegex.test(dbValueColumn) ||
+ (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
+ ) {
+ throw new Error(
+ "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
+ );
+ }
+
+ let sql = `
+ SELECT ${dbValueColumn} AS token_value
+ FROM ${dbTableName}
+ WHERE company_code = $1
+ `;
+
+ const params: any[] = [companyCode];
+
+ if (hasWhereColumn && hasWhereValue) {
+ sql += ` AND ${dbWhereColumn} = $2`;
+ params.push(dbWhereValue);
+ }
+
+ sql += `
+ ORDER BY updated_date DESC
+ LIMIT 1
+ `;
+
+ const tokenResult: QueryResult = await pool.query(sql, params);
+
+ if (tokenResult.rowCount === 0) {
+ throw new Error("DB에서 토큰을 찾을 수 없습니다.");
+ }
+
+ const tokenValue = tokenResult.rows[0]["token_value"];
+ const headerName = dbHeaderName || "Authorization";
+ const template = dbHeaderTemplate || "Bearer {{value}}";
+
+ headers[headerName] = template.replace("{{value}}", tokenValue);
+ } else if (authType === "bearer" && authConfig?.token) {
+ headers["Authorization"] = `Bearer ${authConfig.token}`;
+ } else if (authType === "basic" && authConfig) {
+ const credentials = Buffer.from(
+ `${authConfig.username}:${authConfig.password}`
+ ).toString("base64");
+ headers["Authorization"] = `Basic ${credentials}`;
+ } else if (authType === "api-key" && authConfig) {
+ if (authConfig.keyLocation === "header") {
+ headers[authConfig.keyName] = authConfig.keyValue;
+ }
+ }
+
+ return headers;
+ }
+
/**
* REST API 연결 테스트 (테스트 요청 데이터 기반)
*/
@@ -471,99 +570,15 @@ export class ExternalRestApiConnectionService {
try {
// 헤더 구성
- const headers = { ...testRequest.headers };
+ let headers = { ...testRequest.headers };
- // 인증 헤더 추가
- if (testRequest.auth_type === "db-token") {
- const cfg = testRequest.auth_config || {};
- const {
- dbTableName,
- dbValueColumn,
- dbWhereColumn,
- dbWhereValue,
- dbHeaderName,
- dbHeaderTemplate,
- } = cfg;
-
- if (!dbTableName || !dbValueColumn) {
- throw new Error("DB 토큰 설정이 올바르지 않습니다.");
- }
-
- if (!userCompanyCode) {
- throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
- }
-
- const hasWhereColumn = !!dbWhereColumn;
- const hasWhereValue =
- dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
-
- // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
- if (hasWhereColumn !== hasWhereValue) {
- throw new Error(
- "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
- );
- }
-
- // 식별자 검증 (간단한 화이트리스트)
- const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
- if (
- !identifierRegex.test(dbTableName) ||
- !identifierRegex.test(dbValueColumn) ||
- (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
- ) {
- throw new Error(
- "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
- );
- }
-
- let sql = `
- SELECT ${dbValueColumn} AS token_value
- FROM ${dbTableName}
- WHERE company_code = $1
- `;
-
- const params: any[] = [userCompanyCode];
-
- if (hasWhereColumn && hasWhereValue) {
- sql += ` AND ${dbWhereColumn} = $2`;
- params.push(dbWhereValue);
- }
-
- sql += `
- ORDER BY updated_date DESC
- LIMIT 1
- `;
-
- const tokenResult: QueryResult = await pool.query(sql, params);
-
- if (tokenResult.rowCount === 0) {
- throw new Error("DB에서 토큰을 찾을 수 없습니다.");
- }
-
- const tokenValue = tokenResult.rows[0]["token_value"];
- const headerName = dbHeaderName || "Authorization";
- const template = dbHeaderTemplate || "Bearer {{value}}";
-
- headers[headerName] = template.replace("{{value}}", tokenValue);
- } else if (
- testRequest.auth_type === "bearer" &&
- testRequest.auth_config?.token
- ) {
- headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
- } else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
- const credentials = Buffer.from(
- `${testRequest.auth_config.username}:${testRequest.auth_config.password}`
- ).toString("base64");
- headers["Authorization"] = `Basic ${credentials}`;
- } else if (
- testRequest.auth_type === "api-key" &&
- testRequest.auth_config
- ) {
- if (testRequest.auth_config.keyLocation === "header") {
- headers[testRequest.auth_config.keyName] =
- testRequest.auth_config.keyValue;
- }
- }
+ // 인증 헤더 생성 및 병합
+ const authHeaders = await this.getAuthHeaders(
+ testRequest.auth_type,
+ testRequest.auth_config,
+ userCompanyCode
+ );
+ headers = { ...headers, ...authHeaders };
// URL 구성
let url = testRequest.base_url;
diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx
index 5c516491..86da8fe7 100644
--- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx
+++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
import { getApiUrl } from "@/lib/utils/apiUrl";
@@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [apiConnections, setApiConnections] = useState([]);
- const [selectedConnectionId, setSelectedConnectionId] = useState("");
+ const [selectedConnectionId, setSelectedConnectionId] = useState(dataSource.externalConnectionId || "");
const [availableColumns, setAvailableColumns] = useState([]); // API 테스트 후 발견된 컬럼 목록
const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보
const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개)
@@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
loadApiConnections();
}, []);
+ // dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
+ useEffect(() => {
+ if (dataSource.externalConnectionId) {
+ setSelectedConnectionId(dataSource.externalConnectionId);
+ }
+ }, [dataSource.externalConnectionId]);
+
// 외부 커넥션 선택 핸들러
const handleConnectionSelect = async (connectionId: string) => {
setSelectedConnectionId(connectionId);
@@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const updates: Partial = {
endpoint: fullEndpoint,
+ externalConnectionId: connectionId, // 외부 연결 ID 저장
};
const headers: KeyValuePair[] = [];
const queryParams: KeyValuePair[] = [];
+ // 기본 메서드/바디가 있으면 적용
+ if (connection.default_method) {
+ updates.method = connection.default_method as ChartDataSource["method"];
+ }
+ if (connection.default_body) {
+ updates.body = connection.default_body;
+ }
+
// 기본 헤더가 있으면 적용
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
Object.entries(connection.default_headers).forEach(([key, value]) => {
@@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
}
});
+ const bodyPayload =
+ dataSource.body && dataSource.body.trim().length > 0
+ ? dataSource.body
+ : undefined;
+
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
method: dataSource.method || "GET",
headers,
queryParams,
+ body: bodyPayload,
+ externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달
}),
});
@@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
+ {/* HTTP 메서드 */}
+
+
+
+
+
+ {/* Request Body (POST/PUT/PATCH 일 때만) */}
+ {(dataSource.method === "POST" ||
+ dataSource.method === "PUT" ||
+ dataSource.method === "PATCH") && (
+
+ )}
+
{/* JSON Path */}
+ {/* 차량 타입 */}
+
+
+
+ {touchedFields?.vehicleType && validationErrors.vehicleType && (
+
{validationErrors.vehicleType}
+ )}
+
+
{/* 회원가입 버튼 */}