[MUSINSA ROOKIE AI NATIVE ENGINEER] 2차 코딩 테스트 회고 - 합격

이전 글

https://the-snack-over-flow.tistory.com/7

 

[MUSINSA ROOKIE AI NATIVE ENGINEER] 1차 알고리즘 테스트 회고 - 합격

들어가며무신사에서 MUSINSA ROOKIE AI NATIVE ENGINEER (6개월 전환형) 포지션 공고를 봤다.솔직히 말하면, 지원 동기가 거창하지 않았다. "어...? 이 정도면 나도 지원해볼 수 있겠는데?" 싶었다.큰 기대

the-snack-over-flow.tistory.com

 

 

들어가며



이번 글은 1차 통과 이후 이어지는 글이다.

2차 코딩 테스트는 GitHub와 AI Agent를 활용하여 진행됩니다.
AI Agent와 통합 개발환경(IDE) 등을 자유롭게 활용하실 수 있으니,
익숙한 개발환경을 최대한 활용하여 본인의 개발 역량을 보여주시기 바랍니다.

 

1차가 "AI 없이 네 기초 실력이 얼마나 되냐"를 보는 시험이었다면, 2차는 정반대였다.
AI를 얼마나 잘 쓰느냐. 3시간 안에 AI와 협업해서 실제 동작하는 시스템을 만들어 내는 것.


시험 전에

어떤 문제가 나올지 감이 없었다.

시험 안내 메일에 "GitHub와 AI Agent를 활용"이라고만 나와 있을 뿐, 문제 유형도 도메인도 한마디가 없었다.

1차가 알고리즘이었으니 2차는 프론트엔드 구현 과제겠거니 싶어서 Next.js 위주로 준비했는데,

백엔드가 나오면 어떡하나 하는 불안이 계속 있었다.

그래서 모의고사를 돌렸다. shadcn, context7, sequential thinking MCP를 세팅하고,

무신사 스타일 홈쇼핑 페이지를 AI와 함께 처음부터 만들어보는 연습이었다.

결과물 자체보다는 워크플로우를 내 것으로 만드는 게 목적이었다.

 

연습하면서 정한 순서가 있다.

  1. 플랜 모드로 전체 설계 잡기
  2. 계획 검토하고 피드백 주기
  3. 플랜 수정 후 구현
  4. 구조와 코드 파악
  5. AI 자체 검토
  6. 수정

이 흐름을 한 번 경험해두면 실전에서 어떤 도메인이 나와도 같은 방식으로 접근할 수 있을 거라는 게 판단이었다.

그리고 당일.

 

문제를 열어보니 프론트 없이 백엔드 REST API를 구현하는 과제였다. 처음엔 당황했다.

근데 생각해보면, 설계에서부터 구현까지의 워크플로우는 프론트엔드/백엔드 구분을 하지 않는다.
특정 스택과 도메인에 국한되지 않는다고 할 수 있다.

설계하고, AI와 맥락 공유하고, 구현하고, 검증하는 흐름은 프론트든 백엔드든 동일하다.

얼른 10분만에 멘탈 잡고 어떻게 과제를 구현해야할 지에 대해 설계를 시작했다.


과제 : 수강신청 시스템

과제 전문은 2차 결과 발표 후 무신사 측에서 외부에 공개했다. 직접 확인하고 싶다면 여기서 볼 수 있다.

문제가 엄청 길어서 이 글에서는 문제의 핵심만 요약하려고 한다.

과제 한 줄 요약

대학교 수강신청 백엔드 REST API를 3시간 안에 만들어라.
단, 정원이 1명 남은 강좌에 100명이 동시에 신청해도 정확히 1명만 성공해야 한다.

 

구현할 기능 목록은 단순하다.

기능 설명
학생/교수/강좌 목록 조회 기본 CRUD
수강신청 핵심 비즈니스 로직
수강취소 정원 복구 포함
내 시간표 조회 신청 완료 강좌 목록

비즈니스 규칙은 세 가지다.

  • 정원 초과 방지 (동시성 제어 필수)
  • 학생당 최대 18학점
  • 같은 시간대 강좌 중복 신청 불가

데이터 규모 : 학과 10개, 교수 100명+, 강좌 500개+, 학생 10,000명+

의도적으로 불완전한 기획서

과제 문서에는 중요한 전제가 있다.

담당자에게 추가 질문을 할 수 없는 상황이며, 주어진 정보를 바탕으로 합리적인 판단과 결정을 내려야 합니다.

 

인증 처리, 시간표 형식, 시간 충돌 정의, 에러 응답 형식 같은 것들이 명세에 없다. 의도적으로 비워둔 것이다.

"좋은 개발자는 요구받지 않아도 스스로 필요한 것을 찾아서 한다"


내 결과물 구조

제출한 프로젝트의 폴더 구조는 이렇다.

assignment-2026/
├── src/
│   ├── app.js                    Express 앱 설정 + 미들웨어
│   ├── server.js                 서버 진입점 (포트 바인딩)
│   ├── config/
│   │   └── swagger.js            Swagger UI 설정
│   ├── database/
│   │   ├── connection.js         sql.js DB 연결 + 초기화
│   │   ├── schema.js             테이블 DDL (students/courses/enrollments 등)
│   │   └── seed.js               시드 데이터 생성 (학생 1만명, 강좌 550개)
│   ├── routes/
│   │   ├── students.js           GET /api/students, GET /api/students/:id/schedule
│   │   ├── professors.js         GET /api/professors
│   │   ├── courses.js            GET /api/courses (학과 필터 포함)
│   │   └── enrollments.js        POST /api/enrollments, DELETE /api/enrollments/:id
│   ├── services/
│   │   └── enrollmentService.js  동시성 제어 핵심 로직 (JS 락 + SQL 트랜잭션)
│   └── utils/
│       └── response.js           성공/실패 응답 포맷 헬퍼
├── tests/
│   ├── concurrency.test.js       100명 동시 신청 → 정원만큼만 성공 검증
│   ├── business-rules.test.js    학점 제한, 시간 충돌, 중복 신청 검증
│   └── api.test.js               전체 API 엔드포인트 통합 테스트
├── docs/
│   ├── REQUIREMENTS.md           요구사항 분석 + 설계 결정 (935줄)
│   └── API.md                    API 명세서
├── prompts/                      AI 프롬프트 이력 (28개)
├── CLAUDE.md                     AI 에이전트 지침 파일
├── PROBLEM.md                    원본 과제 문서
└── README.md                     빌드/실행 가이드

 

역할이 명확하게 분리되어 있다. 비즈니스 로직은 services/ 에만 있고, routes/ 는 요청 파싱과 응답 반환만 담당한다.

동시성 제어 전체가 enrollmentService.js 한 파일에 집중되어 있어서 평가자가 핵심 로직을 찾기 쉽게 했다.


과정

시작하자마자 코드를 짜지 않았다

시험이 시작되자마자 코드를 짜지 않았다. 10분을 통째로 분석에 썼다.

먼저 REQUIREMENTS.md를 작성했다. 명시된 요구사항 정리, 누락된 부분 도출, 기술 선택 근거까지 문서로 남겼다.

결국 935줄짜리 설계 문서가 됐다.

 

그리고 CLAUDE.md를 세팅했다. 이게 핵심이었다.

CLAUDE.md는 Claude Code가 이 프로젝트에서 어떻게 동작할지를 정의하는 에이전트 지침 파일이다.

여기에 평가 기준을 직접 역분석해서 넣었다 .

## 평가 기준 (출제 의도 — 모든 Phase에서 의식할 것)

### 1. 동작 여부 (가장 큰 비중)

- 서버 실행 + GET /health → 200 응답이 최소 조건
- 빌드/실행 실패 = 이후 평가 불가 → 항상 실행 가능한 상태 유지

### 2. 핵심 기능 (큰 비중)

- 동시성 제어: 100명 동시 신청 → 정확히 정원만큼만 성공 (직접 검증됨)

### 3. 사고의 깊이

- AI 활용 > 코드 품질 (비중이 더 큼)

 

AI가 매번 컨텍스트를 잃지 않고 평가 기준을 의식하면서 작업하도록 만들었다.

타임라인도 단계별로 세분화해서 넣었다. 언제 무엇을 해야 하는지 전부.

기술 스택 선택과 시행착오

기술 스택은 JavaScript + Express.js + SQLite로 결정했다.

SQLite를 선택한 이유는 명확했다. 별도 DB 서버가 없어도 되니까 평가자가 실행하기 편하고,

동기식 트랜잭션이 동시성 제어에 자연스럽게 도움이 된다.

 

그런데 문제가 생겼다. better-sqlite3가 설치되지 않았다.

better-sqlite3는 네이티브 바이너리를 컴파일해야 하는데, 내 로컬 환경과 배포 환경의 CPU 아키텍처가 달랐다. 크로스플랫폼 이슈였다.

다행스럽게도, CLAUDE.md 비상 대응 섹션에 미리 적어 둔 Plan B가 있었다.

| better-sqlite3 설치 실패 | sql.js (WASM 기반 SQLite) 또는 인메모리 Map 구조 |

 

미리 적어둔 게 여기서 먹혔다. 빠르게 sql.js로 전환했다.
WASM 기반이라 네이티브 컴파일이 필요 없고, 동일한 SQL 문법을 그대로 쓸 수 있다는 장점이 있다.

다만 트레이드오프가 있었다. sql.js는 비동기 트랜잭션을 기본으로 지원하지 않는다.

better-sqlite3처럼 db.transaction(fn) 으로 자동 직렬화되지 않는다. 트랜잭션 처리를 수동으로 해야 했다.

동시성 제어 — 이중 보호 전략

sql.js로 전환하면서 트랜잭션만으로는 동시성을 보장하기 어렵다는 걸 인지했다.

Node.js는 싱글 스레드지만 비동기 처리 중에 여러 요청이 같은 강좌에 동시 진입할 수 있다.

트랜잭션 시작 전에 정원 체크를 하면, 체크 시점과 UPDATE 시점 사이에 레이스 컨디션이 생긴다.

그래서 이중 보호 전략을 썼다.

 

1층: JavaScript 레벨 락 (강좌별 스핀락)

const courseLocks = new Map();

async function acquireCourseLock(courseId) {
  const key = `course_${courseId}`;

  while (courseLocks.has(key)) {
    await new Promise((resolve) => setTimeout(resolve, 10));
  }

  const release = () => courseLocks.delete(key);
  courseLocks.set(key, true);

  return release;
}

 

같은 강좌에 대한 요청이 동시에 들어오면, 먼저 락을 잡은 요청만 진행하고 나머지는 대기한다.

강좌별로 락이 독립적이라 다른 강좌는 영향받지 않는다.

 

2층: SQL 트랜잭션 + 이중 정원 체크

export async function enroll(studentId, courseId) {
  const releaseLock = await acquireCourseLock(courseId);
  const db = getDatabase();

  try {
    db.run("BEGIN TRANSACTION");

    // ... 학생/강좌 존재, 중복 신청, 학점 초과, 시간 충돌 검증 ...

    // 1차 정원 확인
    if (courseEnrolled >= courseCapacity) {
      throw new Error("CAPACITY_FULL:정원이 가득 찼습니다");
    }

    // 정원 증가
    db.run(`UPDATE courses SET enrolled = enrolled + 1 WHERE id = ${courseId}`);

    // 이중 안전장치: 업데이트 후 재확인
    const updatedResult = db.exec(
      `SELECT enrolled FROM courses WHERE id = ${courseId}`,
    );
    const newEnrolled = updatedResult[0].values[0][0];

    if (newEnrolled > courseCapacity) {
      throw new Error("CAPACITY_FULL:정원이 가득 찼습니다");
    }

    db.run(
      `INSERT INTO enrollments (student_id, course_id) VALUES (${studentId}, ${courseId})`,
    );
    db.run("COMMIT");
  } catch (error) {
    db.run("ROLLBACK");
    throw error;
  } finally {
    releaseLock();
  }
}

 

JS 락으로 직렬화하고, 트랜잭션으로 원자성을 보장하고, UPDATE 후에 한 번 더 확인한다. 세 겹이다.

 

테스트로 검증했다.

 

it("정원 30명 강좌에 100명 동시 신청 → 정확히 30명만 성공", async () => {
  const requests = studentIds.map((studentId) =>
    request(app).post("/api/enrollments").send({ studentId, courseId }),
  );

  const responses = await Promise.all(requests);
  const successResponses = responses.filter((res) => res.status === 201);

  expect(successResponses.length).toBe(capacity); // 정확히 30명
});

 

휴;; 다행스럽게도 테스트를 통과했다.

데이터와 문서화

시드 데이터도 신경 써야 했다. "User1", "학생001" 같은 무의미한 데이터는 만들지 않았다.

  • 학생 10,000명: 한국어 성씨 + 이름 조합
  • 강좌 550개: 학과별 과목명 토큰으로 생성
  • 교수 100명+: 학과당 10명

평가자가 데이터를 봤을 때 실제 시스템처럼 보여야 한다고 생각했다.

 

API 문서는 Swagger UI로 만들었다. docs/API.md로 정적 문서를 작성하는 것도 괜찮지만,

/api-docs 로 접근하면 직접 API를 테스트할 수 있는 인터랙티브 문서가 있는 게 훨씬 낫다고 판단했다.

 

Render에도 배포했다. 로컬에서만 동작하는 결과물보다 실제 URL이 있는 게 설득력이 다르다고 생각했는데,

실제 3차 면접에서는 면접관께서 이 부분을 그렇게 중요하게 생각하시지는 않으셨던 것 같다.

오히려 트랜잭션, api 부하에 대한 캐싱 전략 등에 더 신경 썼으면 좋았을 것 같다는 아쉬움이 남았다.

AI 활용 — 28개 프롬프트

3시간 동안 Claude와 주고받은 프롬프트가 28개다. prompts/ 디렉토리에 전부 기록했다.

prompts/
├── 01-initial-setup.md
├── 02-phase1-start.md
├── 03-git-strategy-update.md
├── 04-phase2-db-schema.md
...
├── 27-cors-error.md
├── 28-swagger-server-url.md
└── PROMPTS.md

 

단순히 "코드 짜 줘!"가 아니었다. 각 단계에서 무엇을 해야 하는지 맥락을 주고, 트레이드오프를 설명하고, 의사결정 이유를 물었다. CLAUDE.md에 평가 기준이 적혀 있으니 Claude도 그 맥락에서 응답했다.

프롬프트 이력을 남긴 이유도 있다. AI 활용 과정이 평가 항목에 포함된다는 걸 알고 있었다.

어떻게 질문했는지, 어떤 사고 과정을 거쳤는지가 코드 자체만큼 중요하다.

남은 시간

서버 구현이 2시간 만에 끝났다.

처음엔 잘못 계산한 줄 알았다. 다시 봐도 1시간이 남아 있었다.

Problem.md에 적히지 않은 디테일들을 다시 훑었다. 인증 처리, 시간 충돌 정의, 에러 응답 형식. REQUIREMENTS.md에 설계 결정으로 적어두긴 했는데, 구현에서 실제로 일관되게 반영됐는지 확인했다. 빠진 응답 케이스는 채웠다.

Render 배포를 마무리하고 Swagger UI도 엔드포인트마다 설명이 빠진 게 없는지 점검하고 채웠다.

/api-docs 열면 전체 API를 바로 테스트할 수 있는 상태로.

 

남은 시간이 생겼을 때 어디에 쓸지를 미리 생각해두지 않았으면 그냥 흘려보냈을 것 같다.


결과물

3시간 안에 완성된 것들.

  • REST API (수강신청, 수강취소, 학생/교수/강좌 목록, 시간표 조회)
  • 동시성 테스트 포함 22개 이상 테스트 케이스
  • Swagger UI ( /api-docs )
  • REQUIREMENTS.md 935줄 설계 문서
  • 28개 프롬프트 이력
  • Render 배포

개선 가능했던 점

결과물을 제출하고 나서, 냉정하게 보면 고쳤어야 할 부분들이 보인다.

 

SQL Injection 취약점

enrollmentService.js를 보면 이렇게 되어 있다.

const studentResult = db.exec(
  `SELECT id FROM students WHERE id = ${studentId}`,
);

 

템플릿 리터럴로 직접 값을 넣었다. Parameterized query를 써야 하는 자리다. 시간에 쫓겨 넘어갔는데, 보안 취약점이다.

 

스핀락의 한계

while (courseLocks.has(key)) 방식은 단일 Node.js 프로세스 안에서만 동작한다.

여러 서버 인스턴스로 수평 확장하면 각 인스턴스가 독립적인 메모리를 가지므로 락이 의미 없어진다.

Redis 같은 외부 락 스토어가 필요하다.

과제 범위에서는 문제없지만, 실제 프로덕션이라면 첫 번째로 교체해야 할 부분이다.

 

API 응답 형식 비일관성

성공 응답은 { "success": true, "data": { ... } } ,

실패 응답은 { "success": false, "error": { ... } } 구조를 일관되게 쓰려 했는데,

일부 엔드포인트에서 형식이 흔들렸다.

마지막에 확인할 시간이 부족했다.


느낀 점

시간 관리는 결국 우선순위 판단

3시간이 짧냐고 물으면, 짧지 않다. 계획이 명확하면 충분하다.

CLAUDE.md에 타임라인을 단계별로 적어둔 게 결정적이었다.

동시성 구현이 01:30을 넘기면 강좌 조회와 수강신청만이라도 먼저 완성한다.

검증 단계가 02:20이 되면 테스트 없이 서버 실행에 집중한다.

미리 트레이드오프를 정해두면, 막상 시간이 촉박할 때 그 결정을 빠르게 내릴 수 있다. 시험장에서 고민하면 늦다.

동시성 제어 — 알고 구현하는 것과 모르고 구현하는 것

동시성 문제는 재현하기 어렵다. 로컬에서 curl 한두 번으로는 잘 동작하는 것처럼 보인다.

실제로 100개 요청을 동시에 날려봐야 문제가 보인다.

이중 보호 전략을 쓴 이유는 단순하다. 하나가 뚫릴 수 있으니까 두 개를 사용했다.

JS 락은 Node.js 이벤트 루프의 비동기 특성을 고려한 것이고, SQL 트랜잭션은 데이터 무결성을 위한 것이다. 각각의 역할이 달랐다.

그리고 구현 후에 테스트로 검증했다. 100명 동시 신청 테스트가 통과하는 걸 직접 보기 전까지는 불안했는데,

숫자가 딱 맞게 나왔을 때 그제서야 끝난 느낌이었다.

AI 활용 — Claude는 도구가 아니라 협력자

AI를 잘 쓴다는 게 뭔지 이번에 더 명확해졌다.

코드만 뽑아내는 데 쓰는 게 아니라 생각의 과정에 같이 집어넣는 거였다.

CLAUDE.md에 평가 기준을 역분석해서 넣고, 단계별 타임라인을 정의하고,

비상 대응 시나리오를 미리 준비했다. 거기까지가 내 역할이었다.

내가 지침을 허술하게 만들면 Claude도 허술하게 움직였다. 결국 내가 얼마나 잘 정의했느냐가 전부였다.

28개 프롬프트 이력을 남긴 것도 그 맥락이다. 어떻게 질문했는지가 어떤 코드가 나왔는지만큼 중요하다고 생각한다.

프롬프트 엔지니어링이 개발 역량의 일부가 됐다는 걸 이번에 체감했다.

AI Native 엔지니어는 AI를 잘 활용하기 위한 탄탄한 기초 실력이 있어야 한다.

 

1차 메일에 있던 문장이 이제서야 조금은 이해된다. 기초 없이는 AI에게 정확한 맥락을 줄 수 없다.

내가 뭘 해야 하는지 모르면, AI도 뭘 해야 할지 모른다.


마치며

2차 코딩테스트는 1차와는 전혀 다른 유형의 시험이었다.

알고리즘 풀이가 아니라 실제 시스템 설계와 구현. AI 없이 기초를 보여주는 것과, AI와 함께 결과물을 만드는 것.

3시간이 이렇게 빡빡하게 흘러간 적이 없었다.

 

결과는,,,