Skip to content

Feature/#168 사람인 데이터 update#169

Open
choichangyeon wants to merge 13 commits intodevfrom
Feature/#168-사람인-데이터-Update

Hidden character warning

The head ref may contain hidden characters: "Feature/#168-\uc0ac\ub78c\uc778-\ub370\uc774\ud130-Update"
Open

Feature/#168 사람인 데이터 update#169
choichangyeon wants to merge 13 commits intodevfrom
Feature/#168-사람인-데이터-Update

Conversation

@choichangyeon
Copy link
Copy Markdown
Collaborator

@choichangyeon choichangyeon commented Apr 19, 2025

💡 관련이슈

🍀 작업 요약

  • 어드민 페이지 대비 API 구축

💬 리뷰 요구 사항

  • 타입이나 코드적으로 매우 취약해보이는 부분을 지적해주세요
  • 추후 관리자 페이지에서 사용될 로직이므로 중요하게 봐주시면 감사하겠습니다!

✔️ 이슈 닫기

Closes #168

Summary by CodeRabbit

  • 신규 기능
    • Saramin 채용 공고를 외부 API에서 가져와 데이터베이스에 저장하는 관리자용 API가 추가되었습니다.
    • 관리자 페이지에 "DB PATCH" 버튼이 추가되어, 클릭 시 채용 데이터가 데이터베이스에 갱신됩니다.
  • 개선 및 기타
    • 채용 직군, 지역, 학력 코드에 대한 매핑 정보가 추가되었습니다.
    • 관리자용 API 경로와 관련 상수, 성공 메시지가 추가되었습니다.
    • 채용 공고 모델에 산업명과 키워드 필드가 추가되었습니다.
    • 관리자 페이지에 경고 메시지와 애니메이션 효과가 포함된 UI가 도입되었습니다.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2025

"""

Walkthrough

이 변경사항은 Saramin(사람인) 외부 채용 공고 API로부터 데이터를 받아와 로컬 데이터베이스에 저장하는 새로운 어드민 API 엔드포인트와 관련 프론트엔드, 상수, 클라이언트 서비스 코드를 추가합니다. 인증 토큰 검증, 직군/지역별 반복 요청, 데이터 정규화 및 일괄 삽입 로직이 포함되어 있습니다. 또한, 어드민이 버튼 클릭으로 해당 작업을 트리거할 수 있는 UI와 관련 상수, 메시지, 경로 정의가 함께 도입되었습니다.

Changes

파일/경로 요약 변경 요약
src/app/api/admin/route.ts Saramin API에서 채용공고를 받아와 DB에 저장하는 PATCH API 라우트 추가, 인증 및 데이터 정규화 포함
src/features/admin/admin-button.tsx
src/features/admin/api/client-services.ts
어드민이 DB에 채용공고를 저장하는 버튼 컴포넌트 및 API 호출 클라이언트 서비스 함수 추가
src/features/admin/data/saramin-constants.ts 직군, 지역, 학력 코드 맵핑 상수 정의 파일 신규 추가
src/constants/env-constants.ts Saramin API Key 환경변수 상수 추가
src/constants/message-constants.ts DB 작업 성공 메시지 상수 추가
src/constants/path-constant.ts 어드민 API 경로 상수 추가
prisma/schema.prisma JobPosting 모델에 industryName, keyword 필드 추가
src/app/(with-nav)/(with-header)/admin/page.tsx 어드민 페이지 React 컴포넌트 추가, AdminButton 및 인터랙티브 UI 포함

Sequence Diagram(s)

sequenceDiagram
    participant AdminUser as Admin User
    participant AdminButton as AdminButton(Component)
    participant ClientService as postJobPostingDataToDatabase
    participant API as /api/admin (Route)
    participant Saramin as Saramin API
    participant DB as Database

    AdminUser->>AdminButton: Click "DB CREATE"
    AdminButton->>ClientService: postJobPostingDataToDatabase()
    ClientService->>API: PATCH /api/admin
    API->>API: 인증 토큰 검증
    loop 각 직군/지역 조합
        API->>Saramin: 채용공고 요청
        Saramin-->>API: 채용공고 데이터 반환
        API->>API: 데이터 정규화 및 수집
    end
    API->>DB: createMany(채용공고 데이터)
    DB-->>API: 저장 결과 반환
    API-->>ClientService: 성공 메시지 반환
    ClientService-->>AdminUser: alert(성공 메시지)
Loading

Assessment against linked issues

Objective Addressed Explanation
사람인 API에 요청 보내기, API 요청 함수 구현 (#168)

Possibly related PRs

  • Refactor/#158 user-meta-data type #163: JobPosting 모델 필드 업데이트와 연동된 채용공고 수집/저장 API 라우트 추가로, 데이터 구조 및 저장 방식에서 직접적인 연관성이 있습니다.

Suggested labels

📬 API

Suggested reviewers

  • choichangyeon
  • parkminjo

Poem

🐇
버튼을 누르면 바람이 분다,
사람인에서 소식이 온다.
직군과 지역을 샅샅이 훑어,
토끼는 채용공고를 담는다.
알림은 띠링, 데이터는 쏙!
어드민의 손끝에 DB가 춤춘다.
🥕✨
"""

✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (6)
src/constants/message-constants.ts (1)

90-92: 성공 메시지 추가가 적절합니다.

DB 관련 성공 메시지가 기존 구조에 맞게 잘 추가되었습니다. 다만, 좀 더 구체적인 메시지를 사용하는 것이 사용자 경험에 도움이 될 수 있습니다.

- CREATE_SUCCESS: 'DB CREATE SUCCESS',
+ CREATE_SUCCESS: '데이터베이스에 성공적으로 저장되었습니다.',
src/features/admin/data/saramin-constants.ts (2)

1-23: 직업 코드 매핑에 타입 정의와 주석 추가 필요

직업 코드 매핑은 잘 구현되었으나, 타입 정의와 설명 주석이 있으면 코드의 가독성과 이해도가 향상될 것입니다.

+/**
+ * 사람인 API의 직업 분류 코드와 이름을 매핑한 객체
+ * 중분류 코드(jobMidCd)를 키로 사용
+ */
-export const jobMidCdMap: Record<number, string> = {
+export const jobMidCdMap: Record<JobMidCode, JobName> = {
};

+type JobMidCode = number;
+type JobName = string;

25-32: 지역 코드 매핑에 타입 정의와 주석 추가 필요

지역 코드 매핑은 잘 구현되었으나, 타입 정의와 설명 주석이 있으면 코드의 가독성과 이해도가 향상될 것입니다.

+/**
+ * 사람인 API의 지역 이름과 위치 코드를 매핑한 객체
+ * 지역명을 키로 사용하고, 쉼표로 구분된 위치 코드 문자열을 값으로 사용
+ */
-export const locMcdMap: Record<string, string> = {
+export const locMcdMap: Record<RegionName, LocationCodes> = {
  '수도권': '101000,102000,108000',
  '경상도': '110000,111000,104000,106000,107000',
  '전라도': '112000,113000,103000',
  '충청도': '115000,114000,105000,118000',
  '강원도': '109000',
  '제주도': '116000',
};

+type RegionName = string;
+type LocationCodes = string;
src/features/admin/api/client-services.ts (1)

17-18: alert 대신 더 나은 UI 피드백 메커니즘으로 교체하세요.

현재 TODO 코멘트로 표시되어 있지만, alert()는 사용자 경험 측면에서 좋지 않습니다. 향후 개발에서는 토스트 메시지나 모달 등 더 현대적인 알림 방식을 도입하는 것이 좋겠습니다.

src/app/api/admin/route.ts (2)

78-78: Promise 생성자 매개변수 개선 필요

사용하지 않는 매개변수 _는 혼란을 줄 수 있습니다. 대신 resolve를 사용하여 의도를 명확히 하세요.

- await new Promise((_) => setTimeout(_, 300));
+ await new Promise((resolve) => setTimeout(resolve, 300));

101-105: 응답 구조 단순화 필요

현재 응답이 필요 이상으로 중첩되어 있습니다. 클라이언트 사용 패턴과 일관된 API 응답 구조를 유지하는 것이 좋습니다.

- const response = {
-   message: CREATE_SUCCESS,
- };
- 
- return NextResponse.json({ response }, { status: 200 });

+ return NextResponse.json({ message: CREATE_SUCCESS }, { status: 200 });
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 09d2a6c and 39cd810.

📒 Files selected for processing (7)
  • src/app/api/admin/route.ts (1 hunks)
  • src/constants/env-constants.ts (1 hunks)
  • src/constants/message-constants.ts (1 hunks)
  • src/constants/path-constant.ts (2 hunks)
  • src/features/admin/admin-button.tsx (1 hunks)
  • src/features/admin/api/client-services.ts (1 hunks)
  • src/features/admin/data/saramin-constants.ts (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
src/features/admin/admin-button.tsx (1)
src/features/admin/api/client-services.ts (1)
  • postJobPostingDataToDatabase (12-19)
🔇 Additional comments (3)
src/constants/env-constants.ts (1)

21-22: 환경 변수 추가가 적절하게 되어 있습니다.

사람인 API를 사용하기 위한 API 키 환경 변수가 기존 패턴에 맞게 잘 추가되었습니다.

src/constants/path-constant.ts (2)

32-32: 포맷팅 수정이 잘 되었습니다.

공백 추가를 통한 일관성 있는 포맷팅이 적용되었습니다.


57-59: 어드민 경로 상수가 잘 추가되었습니다.

어드민 API 경로 상수가 기존 패턴에 맞게 잘 정의되었습니다.

'제주도': '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.

Comment on lines +6 to +21
const AdminButton = () => {
const handleOnClick = async () => {
await postJobPostingDataToDatabase();
};
return (
<div>
<Button onClick={handleOnClick}>DB CREATE</Button>
</div>
);
};
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

AdminButton 컴포넌트에 로딩 상태 및 오류 처리 기능 추가 필요

현재 구현은 기본적인 기능은 동작하지만, API 호출 중 로딩 상태를 표시하거나 오류를 처리하는 기능이 없습니다. 또한 "DB CREATE" 버튼 레이블은 일반 사용자에게 명확하지 않을 수 있습니다.

'use client';

import Button from '@/components/ui/button';
import { postJobPostingDataToDatabase } from '@/features/admin/api/client-services';
+import { useState } from 'react';

const AdminButton = () => {
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);

  const handleOnClick = async () => {
+    // 실행 전 사용자 확인
+    if (!confirm('사람인 채용 공고 데이터를 가져와 데이터베이스에 저장하시겠습니까?')) {
+      return;
+    }
+    
+    setIsLoading(true);
+    setError(null);
+    
+    try {
      await postJobPostingDataToDatabase();
+    } catch (e) {
+      setError('데이터 가져오기에 실패했습니다. 다시 시도해 주세요.');
+      console.error(e);
+    } finally {
+      setIsLoading(false);
+    }
  };
  return (
    <div>
-      <Button onClick={handleOnClick}>DB CREATE</Button>
+      <Button 
+        onClick={handleOnClick} 
+        disabled={isLoading}
+        variant={error ? "destructive" : "default"}
+      >
+        {isLoading ? '데이터 가져오는 중...' : '채용공고 데이터 가져오기'}
+      </Button>
+      {error && <p className="text-red-500 mt-2 text-sm">{error}</p>}
    </div>
  );
};

export default AdminButton;
📝 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
const AdminButton = () => {
const handleOnClick = async () => {
await postJobPostingDataToDatabase();
};
return (
<div>
<Button onClick={handleOnClick}>DB CREATE</Button>
</div>
);
};
'use client';
import Button from '@/components/ui/button';
import { postJobPostingDataToDatabase } from '@/features/admin/api/client-services';
import { useState } from 'react';
const AdminButton = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOnClick = async () => {
// 실행 전 사용자 확인
if (!confirm('사람인 채용 공고 데이터를 가져와 데이터베이스에 저장하시겠습니까?')) {
return;
}
setIsLoading(true);
setError(null);
try {
await postJobPostingDataToDatabase();
} catch (e) {
setError('데이터 가져오기에 실패했습니다. 다시 시도해 주세요.');
console.error(e);
} finally {
setIsLoading(false);
}
};
return (
<div>
<Button
onClick={handleOnClick}
disabled={isLoading}
variant={error ? 'destructive' : 'default'}
>
{isLoading ? '데이터 가져오는 중...' : '채용공고 데이터 가져오기'}
</Button>
{error && <p className="text-red-500 mt-2 text-sm">{error}</p>}
</div>
);
};
export default AdminButton;

Comment on lines +13 to +16
const { response } = await fetchWithSentry(ROOT, {
method: POST,
});
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

응답 데이터 구조 검증 필요

response 객체에서 직접 message 속성에 접근하기 전에 응답 구조가 예상대로인지 확인하는 검증 로직을 추가하는 것이 좋습니다.

export const postJobPostingDataToDatabase = async (): Promise<void> => {
  const { response } = await fetchWithSentry(ROOT, {
    method: POST,
  });

+  if (!response || typeof response.message !== 'string') {
+    alert('서버로부터 예상치 못한 응답을 받았습니다.');
+    return;
+  }

  // TODO: 관리자에게 알리는 alert로 변경
  alert(response.message);
};
📝 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
const { response } = await fetchWithSentry(ROOT, {
method: POST,
});
export const postJobPostingDataToDatabase = async (): Promise<void> => {
const { response } = await fetchWithSentry(ROOT, {
method: POST,
});
if (!response || typeof response.message !== 'string') {
alert('서버로부터 예상치 못한 응답을 받았습니다.');
return;
}
// TODO: 관리자에게 알리는 alert로 변경
alert(response.message);
};

Comment thread src/features/admin/api/client-services.ts
Comment on lines +54 to +74
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,
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']),
});
});
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 응답 처리 코드 개선 필요

현재 코드는 any 타입을 사용하고 있으며, 옵셔널 체이닝에 많이 의존하고 있습니다. 이 부분을 개선하여 타입 안전성을 높이고 불필요한 중첩을 줄일 수 있습니다.

// 사람인 API 응답을 위한 타입을 정의하고, 각 필드에 대한 접근자 함수를 생성하는 것이 좋습니다.
// 예시:

function getSafeString(value: any): string {
  return typeof value === 'string' ? value : '';
}

function getSafeNumber(value: any): number {
  const num = Number(value);
  return isNaN(num) ? 0 : num;
}

jobs.forEach((job: any) => {
  const pos = job.position || {};
  const requiredEduLevel = pos['required-education-level'] || {};
  const expLevel = pos['experience-level'] || {};
  const jobMidCode = pos['job-mid-code'] || {};
  const location = pos.location || {};
  const company = job.company?.detail || {};
  
  // 교육 코드 처리 로직 개선
  const rawEd = requiredEduLevel.code;
  let eduCode = DEFAULT_EDU_CODE;
  
  if (rawEd !== undefined) {
    const i = getSafeNumber(rawEd);
    eduCode = i <= LIMIT_EDU_LEVEL ? i : (eduMap[i] || DEFAULT_EDU_CODE);
  }
  
  allRecords.push({
    url: getSafeString(job.url),
    companyName: getSafeString(company.name),
    positionTitle: getSafeString(pos.title),
    locationName: getSafeString(location.name),
    jobMidCodeName: getSafeString(jobMidCode.name),
    experienceCode: getSafeNumber(expLevel.code),
    experienceName: getSafeString(expLevel.name),
    requiredEducationCode: eduCode,
    requiredEducationName: getSafeString(requiredEduLevel.name),
    openingTimestamp: getSafeNumber(job['opening-timestamp']),
    expirationTimestamp: getSafeNumber(job['expiration-timestamp']),
  });
});

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.

image

이부분 제 로컬에서도 오류가 발생하고 있습니다!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

@hye410 스크린샷에서 오류가 발생한 것을 확인했어요! 아마도 사람인 API에서 받아온 데이터를 처리하는 과정에서 문제가 발생한 것 같습니다.

코드를 살펴보니 다음과 같은 문제가 있을 수 있어요:

  1. eduMap[i] 접근 시 해당 키가 없는 경우 undefined가 되어 오류가 발생할 수 있습니다.
  2. API에서 예상하지 못한 구조의 데이터가 올 경우 타입 캐스팅과 옵셔널 체이닝만으로는 방어가 충분하지 않습니다.

다음과 같이 코드를 수정해보세요:

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);
    // eduMap에 해당 키가 없을 경우 DEFAULT_EDU_CODE 사용
    eduCode = i <= LIMIT_EDU_LEVEL ? i : (eduMap[i] || DEFAULT_EDU_CODE);
  }

  // 모든 필드에 대해 안전한 기본값 처리
  allRecords.push({
    url: job.url || '',
    companyName: job.company?.detail?.name || '',
    positionTitle: pos.title || '',
    locationName: pos.location?.name || '',
    jobMidCodeName: pos['job-mid-code']?.name || '',
    experienceCode: Number(pos['experience-level']?.code) || 0,
    experienceName: pos['experience-level']?.name || '',
    requiredEducationCode: eduCode,
    requiredEducationName: pos['required-education-level']?.name || '',
    openingTimestamp: Number(job['opening-timestamp']) || 0,
    expirationTimestamp: Number(job['expiration-timestamp']) || 0,
  });
});

혹시 전체 오류 메시지를 공유해주실 수 있나요? 그러면 더 정확한 원인을 파악하고 해결책을 제안할 수 있을 것 같습니다!

🐰✏️ (이 문제를 함께 해결해봐요!)


🏁 Script executed:

#!/bin/bash
# Check the structure of the eduMap, jobMidCdMap, and locMcdMap constants
echo "Checking saramin-constants.ts file:"
cat src/features/admin/data/saramin-constants.ts

# Check the JobPosting schema to verify field types
echo -e "\nChecking Prisma schema for JobPosting:"
rg "model JobPosting" -A 20 prisma/schema.prisma

Length of output: 1845


@hye410 Prisma 스키마와 코드를 비교해보니 오류 원인을 찾았습니다!

문제점:

  1. JobPosting 모델에는 active 필드가 필수이지만, DB 저장 시 이 필드를 제공하지 않고 있습니다.
  2. JobRecord 타입은 Omit<JobPosting, 'id' | 'active' | 'createdAt'>로 정의되어 있지만, 실제 데이터 삽입 시에 active 값을 설정하지 않고 있습니다.

해결책:

await prisma.jobPosting.createMany({
  data: allRecords.map((record) => ({
    url: record.url,
    companyName: record.companyName,
    positionTitle: record.positionTitle,
    locationName: record.locationName,
    jobMidCodeName: record.jobMidCodeName,
    experienceCode: record.experienceCode,
    experienceName: record.experienceName,
    requiredEducationCode: record.requiredEducationCode,
    requiredEducationName: record.requiredEducationName,
    openingTimestamp: Number(record.openingTimestamp),
    expirationTimestamp: Number(record.expirationTimestamp),
    active: 1,  // 필수 필드 추가
  })),
  skipDuplicates: true,
});

또한 eduMap 사용 시 안전하게 처리하는 것도 중요합니다:

if (rawEd !== undefined) {
  const i = Number(rawEd);
  eduCode = i <= LIMIT_EDU_LEVEL ? i : (eduMap[i] || DEFAULT_EDU_CODE);
}

이렇게 수정하면 오류가 해결될 것입니다!

🐰💡 (문제를 찾았어요!)


🧬 Code Graph Analysis Results

Relevant snippets from src/features/admin/data/saramin-constants.ts (lines 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 };
  • These constants provide mappings for job middle codes, location codes, and education level mappings used in the API route to build requests and interpret data.

Relevant snippet from src/constants/env-constants.ts (lines 1-23)

export const ENV = {
  DATABASE_URL: process.env.DATABASE_URL,
  BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,

  GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,

  NAVER_CLIENT_ID: process.env.NAVER_CLIENT_ID,
  NAVER_CLIENT_SECRET: process.env.NAVER_CLIENT_SECRET,

  KAKAO_CLIENT_ID: process.env.KAKAO_CLIENT_ID,
  KAKAO_CLIENT_SECRET: process.env.KAKAO_CLIENT_SECRET,

  NEXTAUTH_URL: process.env.NEXTAUTH_URL,
  NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,

  OPENAI_API_KEY: process.env.OPENAI_API_KEY,

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

  SARAMIN_API_KEY: process.env.SARAMIN_API_KEY,
};
  • Environment variables used in the API route, including the Saramin API key and NextAuth secret for token verification.

Relevant snippet from src/constants/message-constants.ts (lines 3-28 and 80-93)

export const AUTH_MESSAGE = {
  VALIDATION: {
    NAME_LENGTH: '이름은 2~8자여야 합니다.',
    NAME_SPECIAL_CHAR: '이름에는 특수 문자를 포함할 수 없습니다.',
    EMAIL_INVALID: '올바른 이메일 형식이어야 합니다.',
    PASSWORD_LENGTH: '비밀번호는 6자 이상이어야 합니다.',
    PASSWORD_SPECIAL_CHAR: '비밀번호는 최소 하나의 특수 문자를 포함해야 합니다.',
    PASSWORD_CHECK: '비밀번호가 일치하지 않습니다.',
    EMAIL_EMPTY_FIELD: '이메일을 입력해주세요.',
    PASSWORD_EMPTY_FIELD: '비밀번호를 입력해주세요.',
  },
  RESULT: {
    SIGN_UP_SUCCESS: '회원 가입에 성공했습니다.',
    SIGN_UP_FAILED: '회원 가입에 실패했습니다.',
    SIGN_UP_EXIST_ERROR: '이미 존재하는 이메일입니다.',
    SIGN_UP_EMPTY_FIELD: '모든 값을 입력해주세요.',
    SIGN_IN_FAILED: '이메일 혹은 비밀번호를 확인해주세요.',
    SOCIAL_SIGN_IN_EXIST_ERROR: '다른 로그인 방식으로 이미 가입된 계정입니다.',
    SOCIAL_SIGN_IN_FAILED: '소셜 로그인에 실패했어요. 다시 시도해 주세요.',
    AUTH_REQUIRED: '사용자 인증이 필요합니다.',
  },
  ERROR: {
    EXPIRED_TOKEN: '토큰이 만료되었습니다. 다시 로그인해 주세요.',
    SESSION_NO_USER: '세션에 유저 정보가 존재하지 않습니다.',
  },
};

export const DB_MESSAGE = {
  ERROR: {
    DB_REQUEST_ERROR: 'DB 서버 요청 에러',
    DB_SERVER_ERROR: 'DB 서버 에러',
    DB_URL_ERROR: 'DB URL 에러',
  },
  VALIDATION: {
    USER_ID_VALIDATION: '유저 아이디가 유효하지 않습니다.',
    JOB_POSTING_ID_VALIDATION: '채용 공고 아이디가 유효하지 않습니다.',
  },
  SUCCESS: {
    CREATE_SUCCESS: 'DB CREATE SUCCESS',
  },
};
  • These constants provide standardized messages for authentication errors, success, and database errors used in the API response.

Possibly relevant snippet from src/types/job-posting.ts (lines 3-16)

export type JobPosting = {
  experienceType: string;
  educationLevel: string;
  jobType: string;
  id: number;
  title: string;
  company: string;
  location: JsonValue;
  url: string;
  employmentType: string;
  postedAt: Date;
  expiredAt: Date;
  createdAt: Date;
};
  • Type definition for JobPosting, which is related to the database model used in the API route. The API route uses a type JobRecord which is Omit<JobPosting, 'id' | 'createdAt'>.

These snippets provide context on constants, environment variables, messages, and types used in the src/app/api/admin/route.ts file, which will assist in understanding and troubleshooting the reported error.

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.

이 부분이 active 관련된 DB가 합쳐지질 않았기 때문에 발생하는 Lint 에러 입니다.
해당 부분은 참고 부탁드리겠습니다!

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!

Comment on lines +41 to +78
for (const [jobCdStr, _] of Object.entries(jobMidCdMap)) {
for (const [_n, locCodes] of Object.entries(locMcdMap)) {
const params = new URLSearchParams({
'access-key': SARAMIN_API_KEY,
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,
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));
}
}
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

API 호출 개선 및 타입 안전성 향상이 필요합니다.

이 코드에는 몇 가지 중요한 문제가 있습니다:

  1. 여러 번의 순차적 API 호출로 인한 타임아웃 위험
  2. any 타입 사용으로 인한 타입 안전성 저하
  3. 중첩 속성 접근 시 발생할 수 있는 null/undefined 참조 문제

다음과 같은 개선을 권장합니다:

  1. API 호출을 배치 처리하거나 작업을 더 작은 단위로 분할하세요:
// 별도의 도우미 함수로 API 호출 로직 분리
async function fetchJobsFromSaramin(jobCdStr: string, locCodes: string): Promise<JobRecord[]> {
  const params = new URLSearchParams({
    'access-key': SARAMIN_API_KEY,
    loc_mcd: locCodes,
    job_mid_cd: jobCdStr,
    start: PAGE,
    count: COUNT.toString(),
  });
  
  const res = await fetch(`${URL}?${params}`);
  const data = await res.json();
  
  if (!data.jobs?.job) {
    return [];
  }
  
  return data.jobs.job.map((job: any) => mapJobToRecord(job));
}

// 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];
  }
  
  return {
    url: job.url || '',
    companyName: job.company?.detail?.name || '',
    positionTitle: pos.title || '',
    locationName: pos.location?.name || '',
    jobMidCodeName: pos['job-mid-code']?.name || '',
    experienceCode: Number(pos['experience-level']?.code || 0),
    experienceName: pos['experience-level']?.name || '',
    requiredEducationCode: eduCode,
    requiredEducationName: pos['required-education-level']?.name || '',
    openingTimestamp: Number(job['opening-timestamp'] || 0),
    expirationTimestamp: Number(job['expiration-timestamp'] || 0),
  };
}
  1. 사람인 API 응답 타입 정의:
interface SaraminJob {
  url: string;
  company?: {
    detail?: {
      name?: string;
    };
  };
  position?: {
    title?: string;
    location?: {
      name?: string;
    };
    'job-mid-code'?: {
      name?: string;
    };
    'experience-level'?: {
      code?: string;
      name?: string;
    };
    'required-education-level'?: {
      code?: string;
      name?: string;
    };
  };
  'opening-timestamp'?: string;
  'expiration-timestamp'?: string;
}

interface SaraminResponse {
  jobs: {
    job: SaraminJob[];
  };
}

Comment thread src/app/api/admin/route.ts Outdated
Comment on lines +106 to +110
} catch (error) {
return NextResponse.json({ message: DB_SERVER_ERROR }, { status: 500 });
}
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 });
}

Comment thread src/app/api/admin/route.ts Outdated
export const POST = async (request: NextRequest) => {
try {
const token = await getToken({ req: request, secret: NEXTAUTH_SECRET });
if (!token) return NextResponse.json({ message: EXPIRED_TOKEN }, { status: 401 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

관리자 권한 검증 로직이 필요합니다.

현재 코드는 토큰의 존재 여부만 확인하고 있지만, 이 API는 관리자 전용 기능이므로 토큰에서 사용자 역할(role)을 확인하여 관리자인지 검증하는 로직이 필요합니다.

export const POST = async (request: NextRequest) => {
  try {
    const token = await getToken({ req: request, secret: NEXTAUTH_SECRET });
    if (!token) return NextResponse.json({ message: EXPIRED_TOKEN }, { status: 401 });
+    
+    // 관리자 권한 확인
+    if (token.role !== 'ADMIN') {
+      return NextResponse.json({ message: '관리자만 접근할 수 있습니다.' }, { status: 403 });
+    }

    const allRecords: JobRecord[] = [];
📝 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 POST = async (request: NextRequest) => {
try {
const token = await getToken({ req: request, secret: NEXTAUTH_SECRET });
if (!token) return NextResponse.json({ message: EXPIRED_TOKEN }, { status: 401 });
export const POST = async (request: NextRequest) => {
try {
const token = await getToken({ req: request, secret: NEXTAUTH_SECRET });
if (!token) return NextResponse.json({ message: EXPIRED_TOKEN }, { status: 401 });
// 관리자 권한 확인
if (token.role !== 'ADMIN') {
return NextResponse.json({ message: '관리자만 접근할 수 있습니다.' }, { status: 403 });
}
const allRecords: JobRecord[] = [];
// ...

Comment on lines +82 to +101
await prisma.jobPosting.createMany({
data: allRecords.map((record) => ({
url: record.url,
companyName: record.companyName,
positionTitle: record.positionTitle,
locationName: record.locationName,
jobMidCodeName: record.jobMidCodeName,
experienceCode: record.experienceCode,
experienceName: record.experienceName,
requiredEducationCode: record.requiredEducationCode,
requiredEducationName: record.requiredEducationName,
openingTimestamp: Number(record.openingTimestamp),
expirationTimestamp: Number(record.expirationTimestamp),
// TODO: 타입변경 적용 후 삭제 예정
active: 0,
})),
skipDuplicates: true,
});
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

데이터 삽입 최적화 및 검증 필요

현재 코드는 데이터를 다시 매핑하여 삽입하고 있으며, 데이터 유효성 검증이 부족합니다. 또한 active: 0에 TODO 주석이 있습니다.

- await prisma.jobPosting.createMany({
-   data: allRecords.map((record) => ({
-     url: record.url,
-     companyName: record.companyName,
-     positionTitle: record.positionTitle,
-     locationName: record.locationName,
-     jobMidCodeName: record.jobMidCodeName,
-     experienceCode: record.experienceCode,
-     experienceName: record.experienceName,
-     requiredEducationCode: record.requiredEducationCode,
-     requiredEducationName: record.requiredEducationName,
-     openingTimestamp: Number(record.openingTimestamp),
-     expirationTimestamp: Number(record.expirationTimestamp),
-     // TODO: 타입변경 적용 후 삭제 예정
-     active: 0,
-   })),
-   skipDuplicates: true,
- });

+ // 데이터 유효성 검증
+ const validRecords = allRecords.filter(record => 
+   record.url && 
+   record.companyName && 
+   record.positionTitle
+ );
+ 
+ if (validRecords.length === 0) {
+   return NextResponse.json({ message: '유효한 직무 데이터가 없습니다.' }, { status: 400 });
+ }
+ 
+ // 직접 레코드 사용 (중복 매핑 제거)
+ await prisma.jobPosting.createMany({
+   data: validRecords.map(record => ({
+     ...record,
+     active: 0, // TODO: 타입변경 적용 후 삭제 예정
+   })),
+   skipDuplicates: true,
+ });

Committable suggestion skipped: line range outside the PR's diff.

Copy link
Copy Markdown
Collaborator

@ImJaeOne ImJaeOne left a comment

Choose a reason for hiding this comment

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

확인했습니다!

Copy link
Copy Markdown
Collaborator

@parkminjo parkminjo left a comment

Choose a reason for hiding this comment

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

고생하셨습니다 코맨트 확인 부탁드립니다.

skipDuplicates: true,
});

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로 바꾸지 않고 바로 보내주고 있으니, 밑쪽에 바로 적용하셔도 될 것 같습니다.

'제주도': '116000',
};

export const eduMap: Record<number, number> = { 6: 1, 7: 2, 8: 3, 9: 4 };
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에서 각각의 숫자들이 무엇을 의미하는지 변수로 지정하거나 주석을 달아주시면 좋을 것 같습니다!

@parkminjo
Copy link
Copy Markdown
Collaborator

스크린샷 2025-04-20 오전 10 36 35 통신 url을 확인해보니 값들이 undefined로 보내지고 있어서 정보가 안 뜨는 것 같습니다!

Copy link
Copy Markdown
Collaborator

@hye410 hye410 left a comment

Choose a reason for hiding this comment

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

창연님 코멘트 확인 부탁드립니다!
그리고 아직 admin 계정이 DB에 없고
admin권한으로 접근할 수 있는 페이지, 권한 등이 명확하게 정해지지 않아서
로컬에서 테스트를 진행할 수가 없네요🥲
이 내용은 일단 보류해 두었다가 그 부분이 확실해 지고 DB에 admin 권한이 추가되면
그때 로컬에서 각자 테스트 돌려본 이후에 dev로 merge하는 것이 낫지 않을까용?

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 방식 때문에..)

Comment on lines +54 to +74
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,
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']),
});
});
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.

image

이부분 제 로컬에서도 오류가 발생하고 있습니다!

@ImJaeOne ImJaeOne force-pushed the Feature/#168-사람인-데이터-Update branch from 407ad67 to b0f4092 Compare April 25, 2025 08:54
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (3)
src/features/admin/admin-button.tsx (1)

1-23: 🛠️ Refactor suggestion

컴포넌트에 로딩 상태 및 오류 처리 기능 추가 필요

현재 구현은 기능은 동작하지만, API 호출 중 로딩 상태를 표시하거나 오류를 처리하는 기능이 없습니다. 또한 확인 메시지가 다소 비공식적입니다.

아래와 같이 개선하는 것이 좋겠습니다:

'use client';

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

const AdminButton = () => {
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);

  const handleOnClick = () => {
    showNotiflixConfirm({
-      message: '이번 업데이트 차례가 당신이 맞습니까? 진짜로??',
+      message: '사람인 채용 공고 데이터를 업데이트하시겠습니까? 이 작업은 시간이 소요될 수 있습니다.',
      okFunction: async () => {
+        setIsLoading(true);
+        setError(null);
+        try {
          await postJobPostingDataToDatabase();
+        } catch (e) {
+          setError('데이터 업데이트 중 오류가 발생했습니다.');
+          console.error(e);
+        } finally {
+          setIsLoading(false);
+        }
      },
    });
  };
  return (
    <div>
-      <Button onClick={handleOnClick}>DB PATCH</Button>
+      <Button 
+        onClick={handleOnClick} 
+        disabled={isLoading}
+      >
+        {isLoading ? '업데이트 중...' : '채용 공고 업데이트'}
+      </Button>
+      {error && <p className="text-red-500 mt-2 text-sm">{error}</p>}
    </div>
  );
};

export default AdminButton;
src/app/api/admin/route.ts (2)

30-34: 🛠️ Refactor suggestion

관리자 권한 검증 로직 추가 필요

현재 코드는 토큰의 존재 여부만 확인하지만, 관리자 권한이 있는 사용자만 이 API에 접근할 수 있도록 추가 검증이 필요합니다.

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 });
+    
+    // 관리자 권한 확인
+    if (token.role !== 'ADMIN') {
+      return NextResponse.json({ message: '관리자만 접근할 수 있습니다.' }, { status: 403 });
+    }

108-110: 🛠️ 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 });
  }
🧹 Nitpick comments (2)
src/app/(with-nav)/(with-header)/admin/page.tsx (1)

7-43: AdminPage 컴포넌트의 스타일링 방식 혼합

현재 코드는 Tailwind CSS와 인라인 스타일을 혼합하여 사용하고 있습니다. 일관된 스타일링 방식을 사용하는 것이 유지보수에 더 좋습니다.

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%)',
-      }}
+      className='flex h-full items-center justify-center bg-gradient-radial from-[#E55A27] from-30% to-white to-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',
-        }}
+        className='group relative flex items-center gap-10 rounded-3xl border border-white/20 p-10 text-white shadow-2xl backdrop-blur-md bg-[#C2410C]'
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
      >

다만, 복잡한 box-shadow 스타일의 경우 인라인 스타일로 유지하는 것이 더 명확할 수 있습니다.

src/app/api/admin/route.ts (1)

103-107: 응답 메시지에 상세 정보 추가

현재 응답은 단순한 성공 메시지만 반환하고 있습니다. 작업 결과에 대한 더 상세한 정보를 제공하면 좋겠습니다.

-    const response = {
-      message: CREATE_SUCCESS,
-    };
-
-    return NextResponse.json({ response }, { status: 200 });

+    return NextResponse.json({
+      response: {
+        message: CREATE_SUCCESS,
+        stats: {
+          totalRecordsProcessed: allRecords.length,
+          recordsInserted: result.insertedCount,
+          previousRecords: result.beforeCount,
+          processedAt: new Date().toISOString()
+        }
+      }
+    }, { status: 200 });
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 407ad67 and b0f4092.

⛔ Files ignored due to path filters (1)
  • public/assets/character/card/admin-character.png is excluded by !**/*.png
📒 Files selected for processing (9)
  • prisma/schema.prisma (2 hunks)
  • src/app/(with-nav)/(with-header)/admin/page.tsx (1 hunks)
  • src/app/api/admin/route.ts (1 hunks)
  • src/constants/env-constants.ts (1 hunks)
  • src/constants/message-constants.ts (1 hunks)
  • src/constants/path-constant.ts (1 hunks)
  • src/features/admin/admin-button.tsx (1 hunks)
  • src/features/admin/api/client-services.ts (1 hunks)
  • src/features/admin/data/saramin-constants.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/constants/env-constants.ts
  • src/features/admin/api/client-services.ts
  • src/constants/path-constant.ts
  • src/constants/message-constants.ts
  • src/features/admin/data/saramin-constants.ts
🧰 Additional context used
🧬 Code Graph Analysis (1)
src/features/admin/admin-button.tsx (2)
src/utils/show-notiflix-confirm.ts (1)
  • showNotiflixConfirm (24-48)
src/features/admin/api/client-services.ts (1)
  • postJobPostingDataToDatabase (13-19)
🔇 Additional comments (3)
prisma/schema.prisma (2)

150-151: 스키마에 새 필드 추가 확인됨

JobPosting 모델에 두 개의 새로운 선택적 필드(industryName, keyword)가 추가되었네요. 사람인 API에서 가져온 추가 정보를 저장하기 위한 목적으로 보입니다. DB 컬럼명 매핑도 프로젝트 컨벤션에 맞게 잘 되어 있습니다.


162-162: userSelectedJobs 필드 포맷팅 변경

단순한 포맷팅 변경으로 보입니다. 기능적인 변화는 없습니다.

src/app/api/admin/route.ts (1)

24-24:

Details

✅ Verification successful

타입 정의 개선

Omit<JobPosting, 'id' | 'createdAt'> 타입 정의가 있지만, 이전의 코드 리뷰와 PR 코멘트에서 active 필드 관련 이슈가 있었습니다. 타입과 실제 구현이 일치하는지 확인해주세요.


🏁 Script executed:

#!/bin/bash
# JobPosting 모델 구조 확인

# Prisma 스키마에서 JobPosting 모델 구조 확인
echo "Prisma 스키마의 JobPosting 모델:"
grep -A 25 "model JobPosting" prisma/schema.prisma

# 현재 타입 정의와 실제 사용 비교
echo "\n타입 정의와 실제 사용 비교:"
echo "타입 정의: Omit<JobPosting, 'id' | 'createdAt'>"
echo "실제 필드들:"
grep -o "record\.[a-zA-Z]*" src/app/api/admin/route.ts | sort | uniq

Length of output: 1765


타입 정의 검증 완료

Omit<JobPosting, 'id' | 'createdAt'>로 정의된 JobRecord 타입이 Prisma 스키마에 명시된 필드와 일치함을 확인했습니다. 코드(src/app/api/admin/route.ts 라인 24) 내에서 active 필드를 사용하고 있지 않아, 추가 수정은 필요하지 않습니다.

Comment on lines +45 to +54
<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>
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!

Comment on lines +80 to +101
// 기존 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,
});
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
};
});

Comment on lines +37 to +78
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));
}
}
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,
};
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 데이터 요청 및 생성

4 participants