Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ model JobPosting {
positionTitle String @map("position_title")
companyName String @map("company_name")
locationName String @map("location_name")
industryName String? @map("industry_name")
keyword String? @map("keyword")
experienceCode Int @map("experience_code")
experienceName String @map("experience_name")
requiredEducationCode Int @map("required_education_code")
Expand All @@ -157,7 +159,7 @@ model JobPosting {
expirationTimestamp Int @map("expiration_timestamp")
createdAt DateTime @default(now()) @map("created_at")

userSelectedJobs UserSelectedJob[]
userSelectedJobs UserSelectedJob[]

@@map("job_posting")
}
Expand Down
Binary file added public/assets/character/card/admin-character.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions src/app/(with-nav)/(with-header)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import AdminButton from '@/features/admin/admin-button';
import Image from 'next/image';
import { useState } from 'react';

const AdminPage = () => {
const [hovered, setHovered] = useState(false);

return (
<div
className='flex h-full items-center justify-center'
style={{
background: 'radial-gradient(circle, #E55A27 30%, #ffffff 100%)',
}}
>
<section
className='group relative flex items-center gap-10 rounded-3xl border border-white/20 p-10 text-white shadow-2xl backdrop-blur-md'
style={{
backgroundColor: '#C2410C',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<AdminButton />
<div className='group relative'>
<Image
src='/assets/character/card/admin-character.png'
width={200}
height={200}
alt='레옹병아리'
className='rounded-full border-4 border-white transition-transform duration-500 group-hover:scale-105'
style={{
boxShadow: `
0 0 10px rgba(255, 255, 255, 0.3),
0 0 20px rgba(226, 86, 39, 0.5),
0 0 40px rgba(194, 65, 12, 0.5)
`,
}}
/>
<div className='absolute inset-0 animate-ping rounded-full border-4 border-yellow-300 opacity-30 group-hover:opacity-70'></div>
</div>
</section>

<div
className='absolute left-1/2 top-40 mb-12 -translate-x-1/2 transform animate-pulse rounded-xl bg-black/70 p-6 text-2xl text-yellow-400 shadow-xl'
style={{
width: 'max-content',
transition: 'opacity 0.3s ease',
}}
>
<p>일주일에 한번만 눌러라 마!</p>
<p>안 그럼 총 맞는다!</p>
</div>
Comment on lines +45 to +54
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

경고 메시지의 톤과 표현 개선 필요

현재 경고 메시지가 비공식적이고 위협적인 톤으로 작성되어 있습니다. 관리자 페이지에서는 더 전문적이고 정보를 제공하는 메시지가 적절합니다.

<div
  className='absolute left-1/2 top-40 mb-12 -translate-x-1/2 transform animate-pulse rounded-xl bg-black/70 p-6 text-2xl text-yellow-400 shadow-xl'
  style={{
    width: 'max-content',
    transition: 'opacity 0.3s ease',
  }}
>
-  <p>일주일에 한번만 눌러라 마!</p>
-  <p>안 그럼 총 맞는다!</p>
+  <p>주의: 데이터 업데이트는 일주일에 한 번만 실행해주세요.</p>
+  <p>불필요한 API 호출을 방지하기 위한 권장사항입니다.</p>
</div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className='absolute left-1/2 top-40 mb-12 -translate-x-1/2 transform animate-pulse rounded-xl bg-black/70 p-6 text-2xl text-yellow-400 shadow-xl'
style={{
width: 'max-content',
transition: 'opacity 0.3s ease',
}}
>
<p>일주일에 한번만 눌러라 마!</p>
<p>안 그럼 총 맞는다!</p>
</div>
<div
className='absolute left-1/2 top-40 mb-12 -translate-x-1/2 transform animate-pulse rounded-xl bg-black/70 p-6 text-2xl text-yellow-400 shadow-xl'
style={{
width: 'max-content',
transition: 'opacity 0.3s ease',
}}
>
<p>주의: 데이터 업데이트는 일주일에 한 번만 실행해주세요.</p>
<p>불필요한 API 호출을 방지하기 위한 권장사항입니다.</p>
</div>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉,,,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ImJaeOne 놀라셨군요! 제안이 너무 형식적이었을 수 있습니다. 만약 이 관리자 페이지가 내부용이고 팀 내에서 재미있는 말투를 선호하신다면, 원래 메시지를 유지하셔도 괜찮습니다. 제 제안은 일반적인 사용자 인터페이스 가이드라인에 기반한 것이었습니다만, 내부 도구라면 팀 문화에 맞는 재미있는 메시지가 더 적합할 수 있습니다. 어떤 스타일을 유지하고 싶으신지 알려주시면 도움드리겠습니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅋㅋㅋ

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

</div>
);
};

export default AdminPage;
111 changes: 111 additions & 0 deletions src/app/api/admin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ENV } from '@/constants/env-constants';
import { AUTH_MESSAGE, DB_MESSAGE } from '@/constants/message-constants';
import { getToken } from 'next-auth/jwt';
import { NextRequest, NextResponse } from 'next/server';
import { eduMap, jobMidCdMap, locMcdMap } from '@/features/admin/data/saramin-constants';
import { prisma } from '@/lib/prisma';
import { JobPosting } from '@prisma/client';

const {
ERROR: { DB_SERVER_ERROR },
SUCCESS: { CREATE_SUCCESS },
} = DB_MESSAGE;
const {
ERROR: { EXPIRED_TOKEN },
} = AUTH_MESSAGE;
const { NEXTAUTH_SECRET, SARAMIN_API_KEY } = ENV;

const URL = 'https://oapi.saramin.co.kr/job-search';
const COUNT = 100;
const PAGE = '0';
const DEFAULT_EDU_CODE = 6;
const LIMIT_EDU_LEVEL = 5;

type JobRecord = Omit<JobPosting, 'id' | 'createdAt'>;

/**
* PATCH 요청 함수
*/

export const PATCH = async (request: NextRequest) => {
try {
const token = await getToken({ req: request, secret: NEXTAUTH_SECRET });
if (!token) return NextResponse.json({ message: EXPIRED_TOKEN }, { status: 401 });

const allRecords: JobRecord[] = [];

for (const [jobCdStr, _] of Object.entries(jobMidCdMap)) {
for (const [_n, locCodes] of Object.entries(locMcdMap)) {
const params = new URLSearchParams({
'access-key': SARAMIN_API_KEY as string,
loc_mcd: locCodes,
job_mid_cd: jobCdStr,
start: PAGE,
count: COUNT.toString(),
});
const res = await fetch(`${URL}?${params}`);
const data = await res.json();
const jobs = data.jobs.job;

jobs.forEach((job: any) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 보면 for문 안에 또 for문이 있고 그 안에 또 forEach가 있어서 지금 3중으로 돌고있는데
이거 데이터 요청이 그럼 몇 번 일어나는 건가용..???
테스트를 직접 해보고 싶은데 테스트 할 수 있는 페이지가 없더라구여...
그래서 다른 곳에서 adminbutton import해서 실행해봤는데 오류가 나서..
혹시 창연님은 테스트하셨을 때 문제 없으셨나요???

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 단계에서 테스트를 실행하면 안됩니다!
요청이 전달됐는지 확인만 진행한 상황입니다.
현재 모든 경우의 수에 대한 정보를 받아와서 한꺼번에 처리하는 내용입니다.

대략 12,000개의 데이터입니다.
실제로 요청하는 횟수는 사람인 요청 126회 + 저희 db에 push 요청 1회로 총 127회 통신될 예정입니다.

그러나 주석에 말씀드린 것처럼 이후에 로직을 수정할 예정입니다.
(PATCH 방식 때문에..)

const pos = job.position || {};
const rawEd = pos['required-education-level']?.code;
let eduCode: number = DEFAULT_EDU_CODE;
if (rawEd !== undefined) {
const i = Number(rawEd);
eduCode = i <= LIMIT_EDU_LEVEL ? i : eduMap[i];
}

allRecords.push({
url: job.url as string,
companyName: job.company?.detail?.name as string,
positionTitle: pos.title as string,
locationName: pos.location?.name as string,
industryName: pos.industry?.name as string,
keyword: job.keyword as string,
jobMidCodeName: pos['job-mid-code']?.name as string,
experienceCode: Number(pos['experience-level']?.code),
experienceName: pos['experience-level']?.name as string,
requiredEducationCode: eduCode,
requiredEducationName: pos['required-education-level']?.name as string,
openingTimestamp: Number(job['opening-timestamp']),
expirationTimestamp: Number(job['expiration-timestamp']),
});
});

await new Promise((_) => setTimeout(_, 300));
}
}
Comment on lines +37 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

API 호출 최적화 및 에러 처리 개선 필요

현재 코드는 3중 중첩 루프(for-for-forEach)로 API를 호출하고 있어 요청 수가 많고 효율성이 떨어집니다. 또한 API 응답 유효성 검사나 에러 처리가 부족합니다.

-    for (const [jobCdStr, _] of Object.entries(jobMidCdMap)) {
-      for (const [_n, locCodes] of Object.entries(locMcdMap)) {
-        const params = new URLSearchParams({
-          'access-key': SARAMIN_API_KEY as string,
-          loc_mcd: locCodes,
-          job_mid_cd: jobCdStr,
-          start: PAGE,
-          count: COUNT.toString(),
-        });
-        const res = await fetch(`${URL}?${params}`);
-        const data = await res.json();
-        const jobs = data.jobs.job;
-
-        jobs.forEach((job: any) => {
+    // API 요청을 병렬로 처리하기 위한 프로미스 배열
+    const fetchPromises = [];
+    
+    for (const [jobCdStr, _] of Object.entries(jobMidCdMap)) {
+      for (const [_n, locCodes] of Object.entries(locMcdMap)) {
+        // 각 조합에 대한 API 요청 함수 생성
+        fetchPromises.push(
+          fetchJobsFromSaramin(jobCdStr, locCodes)
+            .then(jobs => {
+              if (jobs && jobs.length > 0) {
+                const mappedJobs = jobs.map(job => mapJobToRecord(job));
+                allRecords.push(...mappedJobs);
+              }
+            })
+            .catch(error => {
+              console.error(`API 요청 실패 (job: ${jobCdStr}, loc: ${locCodes}):`, error);
+            })
+        );
+      }
+    }
+    
+    // 모든 API 요청을 병렬로 처리 (적절한 타임아웃 설정)
+    await Promise.all(
+      fetchPromises.map(promise => 
+        Promise.race([
+          promise,
+          new Promise((_, reject) => 
+            setTimeout(() => reject(new Error('API 요청 타임아웃')), 10000)
+          )
+        ])
+      )
+    );

+ // fetchJobsFromSaramin 함수 정의
+ async function fetchJobsFromSaramin(jobCdStr: string, locCodes: string) {
+   // 요청 간 적절한 딜레이
+   await new Promise(resolve => setTimeout(resolve, 300));
+   
+   const params = new URLSearchParams({
+     'access-key': SARAMIN_API_KEY as string,
+     loc_mcd: locCodes,
+     job_mid_cd: jobCdStr,
+     start: PAGE,
+     count: COUNT.toString(),
+   });
+   
+   try {
+     const res = await fetch(`${URL}?${params}`);
+     
+     if (!res.ok) {
+       throw new Error(`API 응답 오류: ${res.status}`);
+     }
+     
+     const data = await res.json();
+     
+     if (!data.jobs?.job) {
+       return [];
+     }
+     
+     return data.jobs.job;
+   } catch (error) {
+     console.error(`Saramin API 요청 실패:`, error);
+     return [];
+   }
+ }
+ 
+ // mapJobToRecord 함수 정의
+ function mapJobToRecord(job: any): JobRecord {
+   const pos = job.position || {};
+   const rawEd = pos['required-education-level']?.code;
+   let eduCode: number = DEFAULT_EDU_CODE;
+   
+   if (rawEd !== undefined) {
+     const i = Number(rawEd);
+     eduCode = i <= LIMIT_EDU_LEVEL ? i : (eduMap[i] || DEFAULT_EDU_CODE);
+   }
+   
+   return {
+     url: job.url as string || '',
+     companyName: job.company?.detail?.name as string || '',
+     positionTitle: pos.title as string || '',
+     locationName: pos.location?.name as string || '',
+     industryName: pos.industry?.name as string || '',
+     keyword: job.keyword as string || '',
+     jobMidCodeName: pos['job-mid-code']?.name as string || '',
+     experienceCode: Number(pos['experience-level']?.code) || 0,
+     experienceName: pos['experience-level']?.name as string || '',
+     requiredEducationCode: eduCode,
+     requiredEducationName: pos['required-education-level']?.name as string || '',
+     openingTimestamp: Number(job['opening-timestamp']) || 0,
+     expirationTimestamp: Number(job['expiration-timestamp']) || 0,
+   };
+ }

각 API 요청을 병렬로 처리하고 타임아웃을 적용하여 효율성을 높입니다. 또한, 에러 처리와 기본값 설정을 강화하여 데이터 일관성을 유지합니다.

다음 스크립트로 API 요청의 정확한 형태를 확인할 수 있습니다:


🏁 Script executed:

#!/bin/bash
# API 호출 최적화를 위한 정보 확인

# 사람인 상수 파일 확인
echo "사람인 API 관련 상수:"
cat src/features/admin/data/saramin-constants.ts

# 총 API 호출 횟수 계산
echo "\n총 API 호출 횟수 계산:"
JOB_COUNT=$(grep -o "{" src/features/admin/data/saramin-constants.ts | wc -l | xargs)
LOC_COUNT=$(grep -o ":" src/features/admin/data/saramin-constants.ts | wc -l | xargs)
echo "직군 수: $JOB_COUNT, 지역 수: $LOC_COUNT, 총 API 호출 수: $((JOB_COUNT * LOC_COUNT))"

Length of output: 1103


API 호출 병렬 처리 및 에러 핸들링 강화 필요

현재 src/app/api/admin/route.ts에서는

  • jobMidCdMap (20개) × locMcdMap (6개) = 총 120회 API를 순차 호출
  • 각 호출마다 300ms 고정 딜레이를 적용하므로, 실제 응답시간을 제외해도 최소 36초 이상의 지연 발생

이를 개선하기 위해 다음 조치를 권장합니다:

  • 비즈니스 로직 분리

    • fetchJobsFromSaramin(jobMidCd, locCodes) → API 요청 + 기본 유효성 검사 + 에러 핸들링
    • mapJobToRecord(job) → 한 건의 원시 데이터를 JobRecord 타입으로 매핑
  • 병렬 요청 및 동시성 제어

    • Promise.all로 완전 병렬 처리 대신 p-limit 같은 라이브러리로 동시 요청 수(예: 5~10개)를 제한
    • 각 요청에 대해 Promise.race로 타임아웃(예: 10초) 추가
  • 에러 처리 및 재시도 전략

    • HTTP 상태 코드 검사(res.ok)
    • 네트워크 오류나 비정상 응답 시 최대 N회 재시도 또는 상세 로깅
  • 타입 안정성 및 기본값 강화

    • JobRecord 인터페이스 정의
    • || '', || 0 등으로 누락 필드 방어

수정 대상 위치

  • src/app/api/admin/route.ts (37–78행)
  • 필요 시 src/app/api/admin/utils/ 등에 유틸 함수 분리

위 개선을 통해 전체 처리 시간을 크게 단축하고, 코드 가독성·유지보수성을 확보할 수 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const [jobCdStr, _] of Object.entries(jobMidCdMap)) {
for (const [_n, locCodes] of Object.entries(locMcdMap)) {
const params = new URLSearchParams({
'access-key': SARAMIN_API_KEY as string,
loc_mcd: locCodes,
job_mid_cd: jobCdStr,
start: PAGE,
count: COUNT.toString(),
});
const res = await fetch(`${URL}?${params}`);
const data = await res.json();
const jobs = data.jobs.job;
jobs.forEach((job: any) => {
const pos = job.position || {};
const rawEd = pos['required-education-level']?.code;
let eduCode: number = DEFAULT_EDU_CODE;
if (rawEd !== undefined) {
const i = Number(rawEd);
eduCode = i <= LIMIT_EDU_LEVEL ? i : eduMap[i];
}
allRecords.push({
url: job.url as string,
companyName: job.company?.detail?.name as string,
positionTitle: pos.title as string,
locationName: pos.location?.name as string,
industryName: pos.industry?.name as string,
keyword: job.keyword as string,
jobMidCodeName: pos['job-mid-code']?.name as string,
experienceCode: Number(pos['experience-level']?.code),
experienceName: pos['experience-level']?.name as string,
requiredEducationCode: eduCode,
requiredEducationName: pos['required-education-level']?.name as string,
openingTimestamp: Number(job['opening-timestamp']),
expirationTimestamp: Number(job['expiration-timestamp']),
});
});
await new Promise((_) => setTimeout(_, 300));
}
}
// API 요청을 병렬로 처리하기 위한 프로미스 배열
const fetchPromises: Promise<void>[] = [];
for (const [jobCdStr] of Object.entries(jobMidCdMap)) {
for (const [, locCodes] of Object.entries(locMcdMap)) {
// 각 조합에 대한 API 요청 함수 생성
fetchPromises.push(
fetchJobsFromSaramin(jobCdStr, locCodes)
.then(jobs => {
if (jobs.length > 0) {
const mapped = jobs.map(job => mapJobToRecord(job));
allRecords.push(...mapped);
}
})
.catch(error => {
console.error(`API 요청 실패 (job: ${jobCdStr}, loc: ${locCodes}):`, error);
})
);
}
}
// 모든 API 요청을 병렬로 처리 (타임아웃 10초 적용)
await Promise.all(
fetchPromises.map(p =>
Promise.race([
p,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('API 요청 타임아웃')), 10000)
)
])
)
);
// --------- Helper Functions ---------
// 사람인 API 호출 + 응답 검사 + 에러 핸들링
async function fetchJobsFromSaramin(jobCdStr: string, locCodes: string): Promise<any[]> {
// 요청 간 딜레이
await new Promise(resolve => setTimeout(resolve, 300));
const params = new URLSearchParams({
'access-key': SARAMIN_API_KEY as string,
loc_mcd: locCodes,
job_mid_cd: jobCdStr,
start: PAGE,
count: COUNT.toString(),
});
try {
const res = await fetch(`${URL}?${params}`);
if (!res.ok) {
throw new Error(`API 응답 오류: ${res.status}`);
}
const data = await res.json();
return data.jobs?.job ?? [];
} catch (e) {
console.error('Saramin API 요청 실패:', e);
return [];
}
}
// 단일 job 객체를 JobRecord로 매핑
function mapJobToRecord(job: any): JobRecord {
const pos = job.position || {};
const rawEd = pos['required-education-level']?.code;
let eduCode: number = DEFAULT_EDU_CODE;
if (rawEd !== undefined) {
const i = Number(rawEd);
eduCode = i <= LIMIT_EDU_LEVEL ? i : (eduMap[i] || DEFAULT_EDU_CODE);
}
return {
url: (job.url as string) || '',
companyName: (job.company?.detail?.name as string) || '',
positionTitle: (pos.title as string) || '',
locationName: (pos.location?.name as string) || '',
industryName: (pos.industry?.name as string) || '',
keyword: (job.keyword as string) || '',
jobMidCodeName: (pos['job-mid-code']?.name as string) || '',
experienceCode: Number(pos['experience-level']?.code) || 0,
experienceName: (pos['experience-level']?.name as string) || '',
requiredEducationCode: eduCode,
requiredEducationName: (pos['required-education-level']?.name as string) || '',
openingTimestamp: Number(job['opening-timestamp']) || 0,
expirationTimestamp: Number(job['expiration-timestamp']) || 0,
};
}


// 기존 jobPosting 데이터 전부 삭제
await prisma.jobPosting.deleteMany({});

// 새 데이터 삽입
await prisma.jobPosting.createMany({
data: allRecords.map((record) => ({
url: record.url,
companyName: record.companyName,
positionTitle: record.positionTitle,
locationName: record.locationName,
industryName: record.industryName,
keyword: record.keyword,
jobMidCodeName: record.jobMidCodeName,
experienceCode: record.experienceCode,
experienceName: record.experienceName,
requiredEducationCode: record.requiredEducationCode,
requiredEducationName: record.requiredEducationName,
openingTimestamp: Number(record.openingTimestamp),
expirationTimestamp: Number(record.expirationTimestamp),
})),
skipDuplicates: true,
});
Comment on lines +80 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

트랜잭션 처리 및 데이터 유효성 검증 추가 필요

현재 코드는 모든 기존 데이터를 삭제한 후 새 데이터를 삽입하는데, 이 과정에서 오류가 발생하면 데이터가 모두 손실될 수 있습니다. 트랜잭션 처리와 데이터 유효성 검증이 필요합니다.

-    // 기존 jobPosting 데이터 전부 삭제
-    await prisma.jobPosting.deleteMany({});
-
-    // 새 데이터 삽입
-    await prisma.jobPosting.createMany({
-      data: allRecords.map((record) => ({
-        url: record.url,
-        companyName: record.companyName,
-        positionTitle: record.positionTitle,
-        locationName: record.locationName,
-        industryName: record.industryName,
-        keyword: record.keyword,
-        jobMidCodeName: record.jobMidCodeName,
-        experienceCode: record.experienceCode,
-        experienceName: record.experienceName,
-        requiredEducationCode: record.requiredEducationCode,
-        requiredEducationName: record.requiredEducationName,
-        openingTimestamp: Number(record.openingTimestamp),
-        expirationTimestamp: Number(record.expirationTimestamp),
-      })),
-      skipDuplicates: true,
-    });

+    // 데이터 유효성 검증
+    if (allRecords.length === 0) {
+      return NextResponse.json({ 
+        message: '가져온 채용 공고 데이터가 없습니다. 사람인 API 응답을 확인해주세요.' 
+      }, { status: 400 });
+    }
+
+    // 트랜잭션으로 데이터 처리
+    const result = await prisma.$transaction(async (tx) => {
+      // 기존 jobPosting 데이터 개수 확인
+      const existingCount = await tx.jobPosting.count();
+      
+      // 기존 데이터 삭제
+      await tx.jobPosting.deleteMany({});
+      
+      // 유효한 레코드만 필터링
+      const validRecords = allRecords.filter(record => 
+        record.url && record.companyName && record.positionTitle
+      );
+      
+      // 새 데이터 삽입
+      const insertResult = await tx.jobPosting.createMany({
+        data: validRecords,
+        skipDuplicates: true,
+      });
+      
+      return {
+        beforeCount: existingCount,
+        insertedCount: insertResult.count,
+        totalRecords: allRecords.length,
+        validRecords: validRecords.length
+      };
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 기존 jobPosting 데이터 전부 삭제
await prisma.jobPosting.deleteMany({});
// 새 데이터 삽입
await prisma.jobPosting.createMany({
data: allRecords.map((record) => ({
url: record.url,
companyName: record.companyName,
positionTitle: record.positionTitle,
locationName: record.locationName,
industryName: record.industryName,
keyword: record.keyword,
jobMidCodeName: record.jobMidCodeName,
experienceCode: record.experienceCode,
experienceName: record.experienceName,
requiredEducationCode: record.requiredEducationCode,
requiredEducationName: record.requiredEducationName,
openingTimestamp: Number(record.openingTimestamp),
expirationTimestamp: Number(record.expirationTimestamp),
})),
skipDuplicates: true,
});
// 데이터 유효성 검증
if (allRecords.length === 0) {
return NextResponse.json({
message: '가져온 채용 공고 데이터가 없습니다. 사람인 API 응답을 확인해주세요.'
}, { status: 400 });
}
// 트랜잭션으로 데이터 처리
const result = await prisma.$transaction(async (tx) => {
// 기존 jobPosting 데이터 개수 확인
const existingCount = await tx.jobPosting.count();
// 기존 데이터 삭제
await tx.jobPosting.deleteMany({});
// 유효한 레코드만 필터링
const validRecords = allRecords.filter(record =>
record.url && record.companyName && record.positionTitle
);
// 새 데이터 삽입
const insertResult = await tx.jobPosting.createMany({
data: validRecords,
skipDuplicates: true,
});
return {
beforeCount: existingCount,
insertedCount: insertResult.count,
totalRecords: allRecords.length,
validRecords: validRecords.length
};
});


const response = {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

message 같은 경우 response로 바꾸지 않고 바로 보내주고 있으니, 밑쪽에 바로 적용하셔도 될 것 같습니다.

message: CREATE_SUCCESS,
};

return NextResponse.json({ response }, { status: 200 });
} catch (error) {
return NextResponse.json({ message: DB_SERVER_ERROR }, { status: 500 });
}
Comment on lines +108 to +110
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

에러 처리 개선 필요

현재 에러 처리가 최소한으로만 되어 있습니다. 에러 로깅과 더 자세한 에러 메시지를 제공하는 것이 도움이 됩니다.

  try {
    // 기존 코드...
  } catch (error) {
+   console.error('Admin API 에러:', error);
+   
+   // 에러 타입에 따른 대응
+   if (error instanceof Error) {
+     return NextResponse.json({ 
+       message: `${DB_SERVER_ERROR}: ${error.message}` 
+     }, { status: 500 });
+   }
    
    return NextResponse.json({ message: DB_SERVER_ERROR }, { status: 500 });
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
return NextResponse.json({ message: DB_SERVER_ERROR }, { status: 500 });
}
} catch (error) {
console.error('Admin API 에러:', error);
// 에러 타입에 따른 대응
if (error instanceof Error) {
return NextResponse.json({
message: `${DB_SERVER_ERROR}: ${error.message}`
}, { status: 500 });
}
return NextResponse.json({ message: DB_SERVER_ERROR }, { status: 500 });
}

};
2 changes: 2 additions & 0 deletions src/constants/env-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ export const ENV = {

SENTRY_DSN: process.env.SENTRY_DSN,
SENTRY_CLIENT_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,

SARAMIN_API_KEY: process.env.SARAMIN_API_KEY,
};
3 changes: 3 additions & 0 deletions src/constants/message-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export const DB_MESSAGE = {
USER_ID_VALIDATION: '유저 아이디가 유효하지 않습니다.',
JOB_POSTING_ID_VALIDATION: '채용 공고 아이디가 유효하지 않습니다.',
},
SUCCESS: {
CREATE_SUCCESS: 'DB CREATE SUCCESS',
},
};

export const CHARACTER_MESSAGE = {
Expand Down
3 changes: 3 additions & 0 deletions src/constants/path-constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export const ROUTE_HANDLER_PATH = {
EXPERIENCE: '/api/character/experience',
HISTORY: (id: number) => `/api/character/history/${id}`,
},
ADMIN: {
ROOT: '/api/admin',
},
};

export const QUERY_PARAMS = {
Expand Down
23 changes: 23 additions & 0 deletions src/features/admin/admin-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import Button from '@/components/ui/button';
import { postJobPostingDataToDatabase } from '@/features/admin/api/client-services';
import { showNotiflixConfirm } from '@/utils/show-notiflix-confirm';

const AdminButton = () => {
const handleOnClick = () => {
showNotiflixConfirm({
message: '이번 업데이트 차례가 당신이 맞습니까? 진짜로??',
okFunction: async () => {
await postJobPostingDataToDatabase();
},
});
};
return (
<div>
<Button onClick={handleOnClick}>DB PATCH</Button>
</div>
);
};

export default AdminButton;
19 changes: 19 additions & 0 deletions src/features/admin/api/client-services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { API_METHOD } from '@/constants/api-method-constants';
import { ROUTE_HANDLER_PATH } from '@/constants/path-constant';
import { fetchWithSentry } from '@/utils/fetch-with-sentry';
import { Notify } from 'notiflix';

const { ROOT } = ROUTE_HANDLER_PATH.ADMIN;
const { PATCH } = API_METHOD;

/**
*
* @description 사람인 데이터를 정제하여 DB로 보내는 로직
*/
export const postJobPostingDataToDatabase = async (): Promise<void> => {
const { response } = await fetchWithSentry(ROOT, {
method: PATCH,
});

Notify.success(response.message);
};
34 changes: 34 additions & 0 deletions src/features/admin/data/saramin-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const jobMidCdMap: Record<number, string> = {
16: '기획·전략',
14: '마케팅·홍보·조사',
3: '회계·세무·재무',
5: '인사·노무·HRD',
4: '총무·법무·사무',
2: 'IT개발·데이터',
15: '디자인',
8: '영업·판매·무역',
21: '고객상담·TM',
18: '구매·자재·물류',
12: '상품기획·MD',
7: '운전·운송·배송',
10: '서비스',
11: '생산',
22: '건설·건축',
6: '의료',
9: '연구·R&D',
19: '교육',
13: '미디어·문화·스포츠',
17: '금융·보험',
20: '공공·복지',
};

export const locMcdMap: Record<string, string> = {
'수도권': '101000,102000,108000',
'경상도': '110000,111000,104000,106000,107000',
'전라도': '112000,113000,103000',
'충청도': '115000,114000,105000,118000',
'강원도': '109000',
'제주도': '116000',
};

export const eduMap: Record<number, number> = { 6: 1, 7: 2, 8: 3, 9: 4 };
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

교육 코드 매핑에 타입 정의와 주석 추가 필요

교육 코드 매핑은 간결하게 구현되었으나, 타입 정의와 설명 주석이 없어 코드의 의미를 이해하기 어렵습니다. 각 코드가 어떤 교육 수준을 나타내는지 주석으로 설명하면 좋을 것 같습니다.

+/**
+ * 사람인 API의 교육 코드를 내부 교육 코드로 매핑한 객체
+ * 6: 고졸 -> 1: 고졸
+ * 7: 초대졸 -> 2: 초대졸
+ * 8: 대졸 -> 3: 대졸
+ * 9: 석박사 -> 4: 석박사
+ */
-export const eduMap: Record<number, number> = { 6: 1, 7: 2, 8: 3, 9: 4 };
+export const eduMap: Record<SaraminEduCode, InternalEduCode> = { 6: 1, 7: 2, 8: 3, 9: 4 };
+
+type SaraminEduCode = number;
+type InternalEduCode = number;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const eduMap: Record<number, number> = { 6: 1, 7: 2, 8: 3, 9: 4 };
/**
* 사람인 API의 교육 코드를 내부 교육 코드로 매핑한 객체
* 6: 고졸 -> 1: 고졸
* 7: 초대졸 -> 2: 초대졸
* 8: 대졸 -> 3: 대졸
* 9: 석박사 -> 4: 석박사
*/
export const eduMap: Record<SaraminEduCode, InternalEduCode> = {
6: 1,
7: 2,
8: 3,
9: 4,
};
type SaraminEduCode = number;
type InternalEduCode = number;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 코드래빗이 이미 잘 달어주었네요, eduMap에서 각각의 숫자들이 무엇을 의미하는지 변수로 지정하거나 주석을 달아주시면 좋을 것 같습니다!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@parkminjo 네, eduMap의 각 숫자들의 의미를 더 명확하게 표현하기 위해 enum을 사용하는 방법을 제안드립니다. 이렇게 하면 숫자의 의미가 코드 자체에서 명확해지고 타입 안전성도 높아집니다:

/**
 * 사람인 API 및 내부 시스템에서 사용하는 교육 수준 코드
 */
enum SaraminEduCode {
  HIGH_SCHOOL = 6,     // 고졸
  COLLEGE = 7,         // 초대졸
  UNIVERSITY = 8,      // 대졸
  GRADUATE = 9         // 석박사
}

enum InternalEduCode {
  HIGH_SCHOOL = 1,     // 고졸
  COLLEGE = 2,         // 초대졸
  UNIVERSITY = 3,      // 대졸
  GRADUATE = 4         // 석박사
}

/**
 * 사람인 API의 교육 코드를 내부 교육 코드로 매핑하는 객체
 */
export const eduMap: Record<SaraminEduCode, InternalEduCode> = {
  [SaraminEduCode.HIGH_SCHOOL]: InternalEduCode.HIGH_SCHOOL,
  [SaraminEduCode.COLLEGE]: InternalEduCode.COLLEGE,
  [SaraminEduCode.UNIVERSITY]: InternalEduCode.UNIVERSITY,
  [SaraminEduCode.GRADUATE]: InternalEduCode.GRADUATE
};

이렇게 구현하면 숫자 값이 무엇을 의미하는지 명확하게 표현되고, 나중에 코드를 읽는 사람이 바로 이해할 수 있습니다. 또한 타입 체크도 더 엄격하게 이루어집니다.

🐰✏️


✏️ Learnings added
Learnt from: parkminjo
PR: BEST-L2CP/ChickMate#169
File: src/features/admin/data/saramin-constants.ts:34-34
Timestamp: 2025-04-20T01:31:49.434Z
Learning: 사람인 API와 관련된 상수에는 의미를 명확히 하기 위해 enum이나 상수 변수를 사용하고, 주석을 추가하는 것이 좋습니다. 특히 eduMap과 같은 코드 매핑에서는 숫자 값의 의미를 명확히 표현해야 합니다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.