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
105 changes: 92 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,99 @@
# 과제 2
# 과제 체크포인트

### 공통 제출

- [ ] 테스트를 잘 작성할 수 있는 규칙 명세
- [ ] 명세에 있는 기능을 구현하기 위한 테스트를 모두 작성하고 올바르게 구현했는지
- [ ] 명세에 있는 기능을 모두 올바르게 구현하고 잘 동작하는지
- [x] 테스트 작성 규칙 명세: `docs/guidelines/test-writing-rules.md`
- [x] 스펙 기반 테스트/구현: 오케스트레이터 RED→GREEN→REFACTOR 사이클로 5개 스펙 처리
- [x] 기능 정상 동작: 반복 UI/배지/종료/단일·시리즈 수정·삭제 동작 확인

### 기본과제 제출
### 기본 과제(Easy)

- [ ] AI 코드를 잘 작성하기 위해 추가로 작성했던 지침
- [ ] 커밋별 올바르게 단계에 대한 작업
- [ ] AI 도구 활용을 개선하기 위해 노력한 점 PR에 작성
- [x] AI 코드 지침 추가: `docs/ai-agent-guide.md`, `docs/guidelines/testing-guidelines.md`
- [x] 커밋 단계 구분: feat/fix/chore 형식, 이번 작업 커밋 메시지 포함
- [x] AI 도구 활용 개선: 매핑 제거, 구조화 입력(GWT), UI 동기화 보강, EPERM 무해화 테스트 파싱

### 심화과제
### 기본 과제(Hard)

- [ ] Agent 구현 명세 문서 또는 코드
- [ ] 커밋별 올바르게 단계에 대한 작업
- [ ] 결과를 올바로 얻기위한 history 또는 log
- [ ] AI 도구 활용을 개선하기 위해 노력한 점 PR에 작성
- [x] Agent 구현/명세: `agents/improved/*`, 관련 md 문서
- [x] 단계별 작업: 오케스트레이터 로그로 증빙, 생성 산출물 경로 유지
- [x] 결과 히스토리/로그: 스펙 파일, quality-report JSON, 실행 로그
- [x] AI 활용 개선 정리: 본 README/가이드에 문제-해결-근거로 서술

### 심화 과제

- [x] 질문 정리: README + `docs/process/*` 교차 인용으로 배경/판단 근거 정리

---

## 구현 메모(핵심 결정)

- 시리즈 식별: `seriesId`로 편집/삭제 범위를 안전하게 지정
- 겹침 정책: 반복은 겹침 무시, 비반복만 사용자 확인
- UI 동기화: 오케스트레이터 단계에서 App.tsx 자동 보강, 키워드 매핑 배제
- 스펙 주도: 5개 GWT 스펙을 단일 진실원으로 사용

---

## 과제 셀프회고

### 기술적 성장

이번 과제를 통해 '스펙을 코드로 자동 변환하는' 파이프라인을 구축하고 실제 작동시켰다.

스펙 중심 개발 (SSOT): 이전에는 명세서를 '읽고' 코드를 '작성'했다면, 이번에는 명세서(.txt)가 곧 실행 가능한 테스트와 코드의 '재료'가 되었다.

자동화된 TDD 사이클: 오케스트레이터를 통해 RED(스펙) → GREEN(테스트/코드 생성) → REFACTOR(UI 동기화)의 흐름을 자동화했습니다. 이는 단순히 시간을 절약하는 것을 넘어, 스펙 변경에 대한 두려움을 없애고 구현이 명세를 100% 따르고 있음을 보장해 주었다.

'추론'에서 '규칙'으로: 가장 큰 변화는 '키워드 매핑'을 제거한 것입니다. 처음에는 특정 키워드(예: '반복 아이콘')를 하드코딩해 UI를 수정하려 했으나, 이는 스펙이 조금만 바뀌어도 깨지는 불안정한 방식이었다.

이를 GWT(Given-When-Then) 구조화 입력, 파일명/훅 네이밍 컨벤션으로 대체했다.

### 코드 품질

코드 품질 향상을 위해 '명확한 책임 분리'와 '안정적인 실행 환경'에 했다.

관심사의 분리 (SoC):

UI (App.tsx)는 상태를 표현하고 이벤트를 전달하는 역할만 하도록 단순화하였다.

복잡한 로직(예: '단일 수정' vs '시리즈 수정')은 seriesId라는 명확한 데이터 식별자를 기반으로 훅 내부에서 처리했다.

예를 들어, 단일 수정 시 repeat.type='none'으로 변경하는 것은 UI가 신경 쓸 일이 아니라, 데이터 레벨에서 처리되어야 할 규칙이다. 이 경계를 명확히 한 것이 중요했다.

개발 환경 안정화:

ESLint, Prettier, TypeScript (noImplicitAny)를 '품질 게이트'로 활용해 AI가 생성한 코드라도 최소한의 컨벤션과 타입 안정성을 지키도록 강제하였다.

EPERM 테스트 종료 오류처럼 환경에 의존하는 문제는, 테스트 결과(JSON)를 파싱하는 로직을 분리해 파이프라인 전체가 중단되지 않도록 방어했다.

### 학습 효과 분석

이번 과제는 '어떻게' 구현하는지보다 '왜' 이 방식이 효과적인지 깨닫는 과정이었습니다.

빠른 피드백 루프의 힘: 스펙을 5개로 분할하고 순차 실행한 것이 핵심이었습니다. 문제가 생겨도 실패 지점이 명확히 고립되어 디버깅 시간이 거의 들지 않았습니다.

AI는 '해석'이 아닌 '실행' 도구다: AI의 '추론' 능력에 의존할수록 결과는 불안정해집니다. 반면, GWT처럼 구조화된 입력을 주고 명확한 규칙(컨벤션)을 제공하자, AI는 매우 안정적이고 강력한 '실행' 도구가 되었습니다. AI를 길들이는 법을 배운 셈입니다.

품질 게이트는 '안전망'이다: 타입/린트/테스트 자동화는 AI 활용에 필수적인 '안전망'입니다. AI가 만든 코드를 100% 신뢰할 순 없지만, 이 게이트들이 최소한의 품질과 일관성을 보장해 주어 안심하고 다음 단계로 나아갈 수 있었습니다.

### 과제 피드백

과제에서 제시한 자동화 파이프라인은 매우 인상적이었고, 문서(스펙)가 실제 실행 가능한 산출물로 이어지는 경험은 신선했습니다.

다만, 과제를 수행하면서 **'AI에 대한 의존성'**이 생각보다 훨씬 크다는 점이 아쉬움으로 남습니다.

높은 결합도: 현재의 파이프라인은 이 과제를 위해 AI가 없다면 사실상 동작하기 어렵습니다. 스펙(.txt)을 GWT로 파싱하고, 적절한 훅과 테스트 코드로 변환하는 로직은 그 자체로 또 하나의 복잡한 시스템입니다. 해당 과정을 일주일이 아니라,, 조금 오랜 기간동안 탐구하며 했다면 훨씬 더 좋지 않을까? 내가 정말 TDD AI 에이전트를 만들어 봤다고 말할 수 있는 수준인가? 라는 의심을 버리기 어렵습니다.

유지보수성: 이 자동화 도구(에이전트, 오케스트레이터) 자체의 유지보수 비용이, 수동으로 코드를 작성하는 비용보다 정말 낮은지에 대한 확신이 들지 않았습니다.

결국 이 과제는 'AI를 활용한 개발 자동화'의 가능성을 보여주었지만, 동시에 특정 도구와 방식에 강하게 종속될 수 있다는 위험성도 함께 느끼게 했습니다.

---

## 리뷰 받고 싶은 내용(구체)

코치님께 더 여쭙고 싶은 것은 코드에 대한 리뷰 보단 **과제의 '목적성'과 '지속가능성'**에 대한 의견입니다.

이 과제의 진짜 학습 목표가 'TDD에 대한 이해'인지, 'AI 기반 개발 파이프라인 구축 및 활용 경험'인지 궁금합니다. 저는 전자 비중이 보다 높다고 느꼈는데, 이것이 의도하신 방향이 맞을까요?

요약하자면, **"해당 과제 철학과 장기적인 비전"**에 대해 코치님 어떻게 생각하시는지, 그리고 이 방식이 미래에 보편적인 개발 패러다임이 될 수 있을지에 대한 의견을 나눠주시면 감사하겠습니다.
272 changes: 272 additions & 0 deletions agents/auto-tdd-agent.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

/**
* Auto TDD Agent - 완전 자동화된 TDD 에이전트
*
* 프로세스:
* 1. 테스트 작성 (RED)
* 2. 테스트 실행하여 실패 확인
* 3. 구현 코드 작성
* 4. 테스트 실행하여 성공 확인 (GREEN)
*/
class AutoTDDAgent {
constructor(outputDir = './output') {
this.outputDir = path.resolve(outputDir);
this.ensureDirectories();
}

ensureDirectories() {
const dirs = ['tests', 'patches', 'logs'];
dirs.forEach(dir => {
const fullPath = path.join(this.outputDir, dir);
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
}
});
}

log(name, data) {
const logFile = path.join(this.outputDir, 'logs', `${Date.now()}-${name}.log`);
fs.writeFileSync(logFile, typeof data === 'string' ? data : JSON.stringify(data, null, 2));
}

analyzeFeature(featureSpec) {
console.log('🔍 기능 명세 분석 중...');

const analysis = {
featureType: this.detectFeatureType(featureSpec),
testFileName: this.generateTestFileName(featureSpec),
requiredImports: [],
requiredState: [],
requiredComponents: []
};

if (/즐겨찾기|favorite|star/i.test(featureSpec)) {
analysis.featureType = 'favorite';
analysis.requiredImports = ['Star', 'StarBorder'];
analysis.requiredState = ['favoriteIds'];
} else if (/삭제|delete/i.test(featureSpec)) {
analysis.featureType = 'delete';
analysis.requiredImports = ['Delete'];
}

console.log('✅ 분석 완료:', analysis);
return analysis;
}

detectFeatureType(featureSpec) {
if (/즐겨찾기|favorite|star/i.test(featureSpec)) return 'favorite';
if (/삭제|delete/i.test(featureSpec)) return 'delete';
if (/검색|search/i.test(featureSpec)) return 'search';
return 'unknown';
}

generateTestFileName(featureSpec) {
const featureType = this.detectFeatureType(featureSpec);
const nameMap = {
'favorite': 'use-favorite-star-ui.spec.tsx',
'delete': 'use-delete-event.spec.tsx',
'search': 'use-search.spec.tsx'
};
return nameMap[featureType] || 'feature.spec.tsx';
}

generateRedTest(featureSpec) {
console.log('📝 RED 테스트 생성 중...');

const analysis = this.analyzeFeature(featureSpec);

if (analysis.featureType === 'favorite') {
return this.generateFavoriteTest();
} else if (analysis.featureType === 'delete') {
return this.generateDeleteTest();
}

return this.generateGenericTest(featureSpec);
}

generateFavoriteTest() {
return `import { render, fireEvent, screen } from '@testing-library/react';
import App from '../../src/App';

describe('이벤트 즐겨찾기 UI(별 버튼) 기능', () => {
it('이벤트 카드에 즐겨찾기(별) 버튼이 나온다', async () => {
render(<App />);
await screen.findByLabelText('즐겨찾기');
expect(screen.getAllByLabelText('즐겨찾기').length).toBeGreaterThan(0);
});
it('별 버튼 클릭시 토글되어 상태 및 UI에 반영된다', async () => {
render(<App />);
const starBtns = await screen.findAllByLabelText('즐겨찾기');
fireEvent.click(starBtns[0]);
expect(starBtns[0].querySelector('svg')?.getAttribute('data-testid')).toBe('StarIcon');
fireEvent.click(starBtns[0]);
expect(starBtns[0].querySelector('svg')?.getAttribute('data-testid')).toBe('StarBorderIcon');
});
});`;
}

generateDeleteTest() {
return `import { render, fireEvent, screen } from '@testing-library/react';
import App from '../../src/App';

describe('이벤트 삭제 기능', () => {
it('이벤트 카드에 삭제 버튼이 노출된다', () => {
render(<App />);
expect(screen.getAllByLabelText('삭제').length).toBeGreaterThan(0);
});
it('삭제 버튼 클릭 시 해당 이벤트가 리스트에서 즉시 삭제된다', () => {
render(<App />);
const deleteBtns = screen.getAllByLabelText('삭제');
const beforeCount = screen.getAllByLabelText('삭제').length;
fireEvent.click(deleteBtns[0]);
expect(screen.queryAllByLabelText('삭제').length).toBe(beforeCount - 1);
});
});`;
}

generateGenericTest(featureSpec) {
return `import { render, screen } from '@testing-library/react';
import App from '../../src/App';

describe('${featureSpec}', () => {
it('기능이 구현되어야 함', () => {
render(<App />);
expect(true).toBe(true);
});
});`;
}

saveTest(testCode, fileName) {
const testPath = path.join(this.outputDir, 'tests', fileName);
fs.writeFileSync(testPath, testCode, 'utf-8');
console.log('✅ 테스트 저장:', testPath);
this.log('generated-test', { testPath, fileName });
return testPath;
}

runTest(testPath) {
console.log('\n🧪 테스트 실행 중...');
try {
execSync(`pnpm exec vitest run ${testPath}`, { stdio: 'inherit' });
console.log('✅ GREEN: 테스트 통과!');
this.log('test-result', { status: 'GREEN', path: testPath });
return true;
} catch (e) {
console.log('❌ RED: 테스트 실패');
this.log('test-result', { status: 'RED', error: String(e), path: testPath });
return false;
}
}

generateImplementation(featureSpec) {
console.log('⚙️ 구현 코드 생성 중...');

const analysis = this.analyzeFeature(featureSpec);

if (analysis.featureType === 'favorite') {
this.implementFavoriteFeature(analysis);
} else if (analysis.featureType === 'delete') {
this.implementDeleteFeature(analysis);
}

console.log('✅ 구현 완료');
this.log('implementation-generated', { featureType: analysis.featureType });
}

implementFavoriteFeature(analysis) {
const appPath = './src/App.tsx';
let appContent = fs.readFileSync(appPath, 'utf-8');

if (!appContent.includes("import { Star")) {
appContent = appContent.replace(
/import { ([^}]+) } from '@mui\/icons-material';/,
(match, imports) => {
const importList = imports.split(',').map(i => i.trim());
if (!importList.includes('Star')) importList.push('Star');
if (!importList.includes('StarBorder')) importList.push('StarBorder');
return `import { ${importList.join(', ')} } from '@mui/icons-material';`;
}
);
}

if (!appContent.includes('const [favoriteIds')) {
appContent = appContent.replace(
/function App\(\) \{\s*\n\s*const \{/,
`function App() {\n const [favoriteIds, setFavoriteIds] = useState<string[]>([]);\n const {`
);
}

if (!appContent.includes('aria-label="즐겨찾기"')) {
appContent = appContent.replace(
/(<IconButton aria-label="Edit event"[^]*?<\/IconButton>)/,
`$1
<IconButton aria-label="즐겨찾기" onClick={() => {
const id = event.id;
setFavoriteIds(fav => fav.includes(id) ? fav.filter(x => x !== id) : [...fav, id]);
}}>
{favoriteIds.includes(event.id)
? <Star data-testid="StarIcon" color="warning" />
: <StarBorder data-testid="StarBorderIcon" />}
</IconButton>`
);
}

fs.writeFileSync(appPath, appContent, 'utf-8');
}

implementDeleteFeature(analysis) {
const appPath = './src/App.tsx';
let appContent = fs.readFileSync(appPath, 'utf-8');

appContent = appContent.replace(
/aria-label="Delete event"/g,
'aria-label="삭제"'
);

fs.writeFileSync(appPath, appContent, 'utf-8');
}

async runTDDCycle(featureSpec) {
console.log('\n🚀 TDD 사이클 시작');
console.log('='.repeat(60));
console.log('기능:', featureSpec);
console.log('='.repeat(60));

const analysis = this.analyzeFeature(featureSpec);
const testCode = this.generateRedTest(featureSpec);
const testPath = this.saveTest(testCode, analysis.testFileName);

console.log('\n📋 STEP 1: RED 상태 확인');
const firstRun = this.runTest(testPath);

if (firstRun) {
console.log('⚠️ 테스트가 이미 통과합니다.');
return { success: false, reason: 'test-already-passing' };
}

console.log('\n📋 STEP 2: 구현 코드 작성');
this.generateImplementation(featureSpec);

console.log('\n📋 STEP 3: GREEN 상태 확인');
const secondRun = this.runTest(testPath);

if (!secondRun) {
console.log('⚠️ 테스트가 여전히 실패합니다.');
return { success: false, reason: 'test-still-failing' };
}

console.log('\n✅ TDD 사이클 완료!');
this.log('tdd-cycle-complete', {
feature: featureSpec,
testFile: analysis.testFileName,
success: true
});

return { success: true, testPath };
}
}

module.exports = AutoTDDAgent;
Loading
Loading