asyouplz_SpeechNote/guidelines/implementation-guide.md
asyouplz 7f776af999 Initial commit: Obsidian Speech-to-Text Plugin
- Set up project structure and architecture
- Implement core transcription services with OpenAI Whisper API
- Add comprehensive UI components and settings
- Create documentation in Korean and English
- Establish testing framework and examples

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 20:00:43 +09:00

81 KiB

Phase 2 핵심 기능 구현 가이드

목차

  1. 파일 처리 패턴 가이드
  2. API 통합 패턴 및 예제 코드
  3. 오류 처리 전략
  4. 코드 리뷰 체크리스트

1. 파일 처리 패턴 가이드

1.1 옵시디언 Vault 내 파일 접근 방법

파일 시스템 접근 패턴

import { TFile, Vault, App, TAbstractFile } from 'obsidian';

export class VaultFileManager {
    constructor(
        private app: App,
        private vault: Vault
    ) {}

    /**
     * Vault 내 오디오 파일 목록 가져오기
     */
    async getAudioFiles(): Promise<TFile[]> {
        const supportedExtensions = ['m4a', 'mp3', 'wav', 'mp4'];
        
        return this.vault.getFiles().filter(file => {
            const extension = file.extension.toLowerCase();
            return supportedExtensions.includes(extension);
        });
    }

    /**
     * 특정 경로의 파일 가져오기
     */
    async getFileByPath(path: string): Promise<TFile | null> {
        const file = this.vault.getAbstractFileByPath(path);
        
        if (!file || !(file instanceof TFile)) {
            return null;
        }
        
        return file;
    }

    /**
     * 파일의 바이너리 데이터 읽기
     */
    async readBinaryFile(file: TFile): Promise<ArrayBuffer> {
        try {
            return await this.vault.readBinary(file);
        } catch (error) {
            throw new Error(`Failed to read file ${file.path}: ${error.message}`);
        }
    }

    /**
     * 파일의 메타데이터 가져오기
     */
    getFileMetadata(file: TFile): FileMetadata {
        return {
            path: file.path,
            name: file.name,
            extension: file.extension,
            size: file.stat.size,
            created: new Date(file.stat.ctime),
            modified: new Date(file.stat.mtime)
        };
    }
}

interface FileMetadata {
    path: string;
    name: string;
    extension: string;
    size: number;
    created: Date;
    modified: Date;
}

파일 선택 모달 구현

import { App, Modal, TFile, Setting } from 'obsidian';

export class AudioFilePickerModal extends Modal {
    private selectedFile: TFile | null = null;
    private onChoose: (file: TFile) => void;
    private onCancel: () => void;

    constructor(
        app: App,
        audioFiles: TFile[],
        onChoose: (file: TFile) => void,
        onCancel: () => void
    ) {
        super(app);
        this.onChoose = onChoose;
        this.onCancel = onCancel;
        this.modalEl.addClass('audio-file-picker-modal');
    }

    onOpen() {
        const { contentEl } = this;
        
        contentEl.createEl('h2', { text: 'Select Audio File' });
        
        // 파일 목록 표시
        const fileList = contentEl.createDiv('file-list');
        
        this.app.vault.getFiles()
            .filter(file => this.isAudioFile(file))
            .forEach(file => {
                const fileItem = fileList.createDiv('file-item');
                fileItem.setText(`${file.path} (${this.formatFileSize(file.stat.size)})`);
                fileItem.onclick = () => {
                    this.selectedFile = file;
                    this.onChoose(file);
                    this.close();
                };
            });

        // 버튼 영역
        const buttonContainer = contentEl.createDiv('button-container');
        
        new Setting(buttonContainer)
            .addButton(btn => btn
                .setButtonText('Cancel')
                .onClick(() => {
                    this.onCancel();
                    this.close();
                }));
    }

    private isAudioFile(file: TFile): boolean {
        const audioExtensions = ['m4a', 'mp3', 'wav', 'mp4'];
        return audioExtensions.includes(file.extension.toLowerCase());
    }

    private formatFileSize(bytes: number): string {
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
    }

    onClose() {
        const { contentEl } = this;
        contentEl.empty();
    }
}

1.2 M4A 파일 처리 및 검증 로직

고급 파일 검증 시스템

export class AudioFileValidator {
    private readonly MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
    private readonly MIN_FILE_SIZE = 100; // 100 bytes
    private readonly SUPPORTED_FORMATS = {
        'm4a': { mimeTypes: ['audio/mp4', 'audio/x-m4a'], magicBytes: [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70] },
        'mp3': { mimeTypes: ['audio/mpeg'], magicBytes: [0xFF, 0xFB] },
        'wav': { mimeTypes: ['audio/wav'], magicBytes: [0x52, 0x49, 0x46, 0x46] },
        'mp4': { mimeTypes: ['video/mp4', 'audio/mp4'], magicBytes: [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70] }
    };

    /**
     * 종합적인 파일 검증
     */
    async validateAudioFile(file: TFile, buffer?: ArrayBuffer): Promise<ValidationResult> {
        const errors: ValidationError[] = [];
        const warnings: ValidationWarning[] = [];

        // 1. 확장자 검증
        const extensionValid = this.validateExtension(file);
        if (!extensionValid.valid) {
            errors.push({
                code: 'INVALID_EXTENSION',
                message: extensionValid.error!,
                field: 'extension'
            });
        }

        // 2. 파일 크기 검증
        const sizeValid = this.validateFileSize(file.stat.size);
        if (!sizeValid.valid) {
            errors.push({
                code: 'INVALID_SIZE',
                message: sizeValid.error!,
                field: 'size'
            });
        } else if (sizeValid.warning) {
            warnings.push({
                code: 'LARGE_FILE',
                message: sizeValid.warning
            });
        }

        // 3. 매직 바이트 검증 (버퍼가 제공된 경우)
        if (buffer) {
            const magicValid = this.validateMagicBytes(file.extension, buffer);
            if (!magicValid.valid) {
                errors.push({
                    code: 'INVALID_FORMAT',
                    message: magicValid.error!,
                    field: 'format'
                });
            }
        }

        // 4. 파일명 검증
        const nameValid = this.validateFileName(file.name);
        if (!nameValid.valid) {
            warnings.push({
                code: 'PROBLEMATIC_NAME',
                message: nameValid.error!
            });
        }

        return {
            valid: errors.length === 0,
            errors: errors.length > 0 ? errors : undefined,
            warnings: warnings.length > 0 ? warnings : undefined,
            metadata: {
                extension: file.extension,
                size: file.stat.size,
                sizeFormatted: this.formatFileSize(file.stat.size),
                path: file.path
            }
        };
    }

    /**
     * 확장자 검증
     */
    private validateExtension(file: TFile): { valid: boolean; error?: string } {
        const extension = file.extension.toLowerCase();
        
        if (!Object.keys(this.SUPPORTED_FORMATS).includes(extension)) {
            return {
                valid: false,
                error: `Unsupported format: .${extension}. Supported: ${Object.keys(this.SUPPORTED_FORMATS).map(ext => `.${ext}`).join(', ')}`
            };
        }
        
        return { valid: true };
    }

    /**
     * 파일 크기 검증
     */
    private validateFileSize(size: number): { valid: boolean; error?: string; warning?: string } {
        if (size < this.MIN_FILE_SIZE) {
            return {
                valid: false,
                error: `File is too small (${size} bytes). Minimum size is ${this.MIN_FILE_SIZE} bytes.`
            };
        }
        
        if (size > this.MAX_FILE_SIZE) {
            return {
                valid: false,
                error: `File size (${this.formatFileSize(size)}) exceeds maximum allowed size (25MB)`
            };
        }
        
        // 경고: 10MB 이상
        if (size > 10 * 1024 * 1024) {
            return {
                valid: true,
                warning: `Large file (${this.formatFileSize(size)}) may take longer to process`
            };
        }
        
        return { valid: true };
    }

    /**
     * 매직 바이트 검증 (파일 포맷 실제 검증)
     */
    private validateMagicBytes(extension: string, buffer: ArrayBuffer): { valid: boolean; error?: string } {
        const format = this.SUPPORTED_FORMATS[extension.toLowerCase() as keyof typeof this.SUPPORTED_FORMATS];
        if (!format) {
            return { valid: true }; // 포맷 정보가 없으면 통과
        }

        const bytes = new Uint8Array(buffer.slice(0, format.magicBytes.length));
        const magicMatch = format.magicBytes.every((byte, index) => bytes[index] === byte);

        if (!magicMatch) {
            return {
                valid: false,
                error: `File content does not match expected ${extension.toUpperCase()} format`
            };
        }

        return { valid: true };
    }

    /**
     * 파일명 검증
     */
    private validateFileName(name: string): { valid: boolean; error?: string } {
        // 문제가 될 수 있는 문자 체크
        const problematicChars = /[<>:"/\\|?*]/g;
        if (problematicChars.test(name)) {
            return {
                valid: false,
                error: 'File name contains problematic characters'
            };
        }
        
        return { valid: true };
    }

    /**
     * 파일 크기 포맷팅
     */
    private formatFileSize(bytes: number): string {
        if (bytes < 1024) return `${bytes} B`;
        if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
        return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
    }
}

interface ValidationResult {
    valid: boolean;
    errors?: ValidationError[];
    warnings?: ValidationWarning[];
    metadata?: {
        extension: string;
        size: number;
        sizeFormatted: string;
        path: string;
    };
}

interface ValidationError {
    code: string;
    message: string;
    field?: string;
}

interface ValidationWarning {
    code: string;
    message: string;
}

1.3 파일 크기 제한 처리 (25MB)

청크 기반 파일 처리

export class LargeFileProcessor {
    private readonly CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
    private readonly MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB

    /**
     * 대용량 파일 처리 (압축 포함)
     */
    async processLargeFile(file: TFile, vault: Vault): Promise<ProcessedFile> {
        const stats = file.stat;
        
        if (stats.size > this.MAX_FILE_SIZE) {
            // 파일이 너무 큰 경우 압축 시도
            return await this.compressAndProcess(file, vault);
        }
        
        // 정상 크기 파일 처리
        const buffer = await vault.readBinary(file);
        return {
            buffer,
            compressed: false,
            originalSize: stats.size,
            processedSize: stats.size
        };
    }

    /**
     * 파일 압축 처리
     */
    private async compressAndProcess(file: TFile, vault: Vault): Promise<ProcessedFile> {
        const buffer = await vault.readBinary(file);
        
        // 오디오 품질 조정을 통한 크기 감소
        // 실제 구현에서는 Web Audio API 또는 외부 라이브러리 사용
        const compressed = await this.compressAudio(buffer);
        
        if (compressed.byteLength > this.MAX_FILE_SIZE) {
            throw new Error(
                `File is still too large after compression. ` +
                `Original: ${this.formatSize(buffer.byteLength)}, ` +
                `Compressed: ${this.formatSize(compressed.byteLength)}`
            );
        }
        
        return {
            buffer: compressed,
            compressed: true,
            originalSize: buffer.byteLength,
            processedSize: compressed.byteLength
        };
    }

    /**
     * 오디오 압축 (샘플링 레이트 감소)
     */
    private async compressAudio(buffer: ArrayBuffer): Promise<ArrayBuffer> {
        // 실제 구현 시 Web Audio API 사용
        // 여기서는 시뮬레이션만
        
        // 예: 샘플링 레이트를 절반으로 줄이기
        const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
        
        try {
            const audioBuffer = await audioContext.decodeAudioData(buffer.slice(0));
            
            // 낮은 샘플링 레이트로 새 버퍼 생성
            const targetSampleRate = Math.min(audioBuffer.sampleRate, 16000);
            const offlineContext = new OfflineAudioContext(
                audioBuffer.numberOfChannels,
                audioBuffer.duration * targetSampleRate,
                targetSampleRate
            );
            
            const source = offlineContext.createBufferSource();
            source.buffer = audioBuffer;
            source.connect(offlineContext.destination);
            source.start();
            
            const compressedBuffer = await offlineContext.startRendering();
            
            // ArrayBuffer로 변환
            return this.audioBufferToArrayBuffer(compressedBuffer);
        } finally {
            audioContext.close();
        }
    }

    /**
     * AudioBuffer를 ArrayBuffer로 변환
     */
    private audioBufferToArrayBuffer(audioBuffer: AudioBuffer): ArrayBuffer {
        const channels = [];
        for (let i = 0; i < audioBuffer.numberOfChannels; i++) {
            channels.push(audioBuffer.getChannelData(i));
        }
        
        const length = audioBuffer.length * audioBuffer.numberOfChannels * 2;
        const buffer = new ArrayBuffer(length);
        const view = new DataView(buffer);
        
        let offset = 0;
        for (let i = 0; i < audioBuffer.length; i++) {
            for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
                const sample = channels[channel][i];
                const value = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
                view.setInt16(offset, value, true);
                offset += 2;
            }
        }
        
        return buffer;
    }

    private formatSize(bytes: number): string {
        return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
    }
}

interface ProcessedFile {
    buffer: ArrayBuffer;
    compressed: boolean;
    originalSize: number;
    processedSize: number;
}

1.4 Base64 인코딩 최적화

효율적인 Base64 처리

export class Base64Encoder {
    private readonly CHUNK_SIZE = 1024 * 1024; // 1MB chunks for encoding

    /**
     * ArrayBuffer를 Base64로 효율적으로 인코딩
     */
    async encodeArrayBuffer(buffer: ArrayBuffer): Promise<string> {
        if (buffer.byteLength < this.CHUNK_SIZE) {
            // 작은 파일은 직접 인코딩
            return this.directEncode(buffer);
        }
        
        // 큰 파일은 청크로 나누어 인코딩
        return await this.chunkedEncode(buffer);
    }

    /**
     * 직접 인코딩 (작은 파일)
     */
    private directEncode(buffer: ArrayBuffer): string {
        const bytes = new Uint8Array(buffer);
        let binary = '';
        
        for (let i = 0; i < bytes.length; i++) {
            binary += String.fromCharCode(bytes[i]);
        }
        
        return btoa(binary);
    }

    /**
     * 청크 기반 인코딩 (대용량 파일)
     */
    private async chunkedEncode(buffer: ArrayBuffer): Promise<string> {
        const chunks: string[] = [];
        const uint8Array = new Uint8Array(buffer);
        
        for (let i = 0; i < uint8Array.length; i += this.CHUNK_SIZE) {
            const chunk = uint8Array.slice(i, Math.min(i + this.CHUNK_SIZE, uint8Array.length));
            
            // 각 청크를 비동기적으로 처리
            await new Promise(resolve => setTimeout(resolve, 0));
            
            let binary = '';
            for (let j = 0; j < chunk.length; j++) {
                binary += String.fromCharCode(chunk[j]);
            }
            
            chunks.push(btoa(binary));
        }
        
        return chunks.join('');
    }

    /**
     * Base64를 ArrayBuffer로 디코딩
     */
    async decodeToArrayBuffer(base64: string): Promise<ArrayBuffer> {
        const binary = atob(base64);
        const buffer = new ArrayBuffer(binary.length);
        const bytes = new Uint8Array(buffer);
        
        for (let i = 0; i < binary.length; i++) {
            bytes[i] = binary.charCodeAt(i);
        }
        
        return buffer;
    }

    /**
     * 스트리밍 인코딩 (메모리 효율적)
     */
    async* encodeStream(buffer: ArrayBuffer): AsyncGenerator<string, void, unknown> {
        const uint8Array = new Uint8Array(buffer);
        
        for (let i = 0; i < uint8Array.length; i += this.CHUNK_SIZE) {
            const chunk = uint8Array.slice(i, Math.min(i + this.CHUNK_SIZE, uint8Array.length));
            
            let binary = '';
            for (let j = 0; j < chunk.length; j++) {
                binary += String.fromCharCode(chunk[j]);
            }
            
            yield btoa(binary);
            
            // CPU에 여유 시간 제공
            await new Promise(resolve => setTimeout(resolve, 0));
        }
    }
}

// 사용 예제
export class OptimizedFileUploader {
    private encoder = new Base64Encoder();

    async uploadFile(file: TFile, vault: Vault): Promise<void> {
        const buffer = await vault.readBinary(file);
        
        // 스트리밍 인코딩 사용
        const chunks: string[] = [];
        for await (const chunk of this.encoder.encodeStream(buffer)) {
            chunks.push(chunk);
            // 진행 상황 업데이트
            this.updateProgress(chunks.length, Math.ceil(buffer.byteLength / (1024 * 1024)));
        }
        
        const base64 = chunks.join('');
        // 업로드 처리...
    }

    private updateProgress(current: number, total: number): void {
        const percentage = (current / total) * 100;
        console.log(`Encoding progress: ${percentage.toFixed(1)}%`);
    }
}

2. API 통합 패턴 및 예제 코드

2.1 Whisper API 호출 구현 패턴

완전한 Whisper API 클라이언트

import { requestUrl, RequestUrlParam } from 'obsidian';

export class WhisperAPIClient {
    private readonly API_BASE_URL = 'https://api.openai.com/v1';
    private readonly TRANSCRIPTION_ENDPOINT = '/audio/transcriptions';
    private readonly TRANSLATION_ENDPOINT = '/audio/translations';
    
    private abortController?: AbortController;
    private requestQueue: Promise<any> = Promise.resolve();

    constructor(
        private apiKey: string,
        private logger: ILogger
    ) {}

    /**
     * 음성을 텍스트로 변환
     */
    async transcribe(
        audioBuffer: ArrayBuffer,
        options: TranscriptionOptions = {}
    ): Promise<TranscriptionResponse> {
        return this.queueRequest(() => this.executeTranscription(audioBuffer, options));
    }

    /**
     * 실제 변환 실행
     */
    private async executeTranscription(
        audioBuffer: ArrayBuffer,
        options: TranscriptionOptions
    ): Promise<TranscriptionResponse> {
        this.abortController = new AbortController();

        const formData = this.buildFormData(audioBuffer, options);
        
        const requestConfig: RequestUrlParam = {
            url: `${this.API_BASE_URL}${this.TRANSCRIPTION_ENDPOINT}`,
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${this.apiKey}`,
                // FormData는 자동으로 Content-Type 설정
            },
            body: formData,
            throw: false,
            // 타임아웃 설정 (30초)
            timeout: 30000
        };

        try {
            this.logger.debug('Starting transcription request', { 
                fileSize: audioBuffer.byteLength,
                options 
            });

            const startTime = Date.now();
            const response = await requestUrl(requestConfig);
            const duration = Date.now() - startTime;

            this.logger.debug('Transcription completed', { 
                duration,
                status: response.status 
            });

            if (response.status === 200) {
                return this.parseResponse(response.json, duration);
            } else {
                throw await this.handleAPIError(response);
            }
        } catch (error) {
            if ((error as Error).name === 'AbortError') {
                throw new TranscriptionCancelledError();
            }
            throw error;
        } finally {
            this.abortController = undefined;
        }
    }

    /**
     * FormData 구성
     */
    private buildFormData(audioBuffer: ArrayBuffer, options: TranscriptionOptions): FormData {
        const formData = new FormData();
        
        // 오디오 파일 추가
        const audioBlob = new Blob([audioBuffer], { 
            type: options.mimeType || 'audio/m4a' 
        });
        formData.append('file', audioBlob, options.filename || 'audio.m4a');
        
        // 모델 설정
        formData.append('model', options.model || 'whisper-1');
        
        // 선택적 파라미터들
        if (options.language && options.language !== 'auto') {
            formData.append('language', options.language);
        }
        
        if (options.prompt) {
            formData.append('prompt', options.prompt);
        }
        
        if (options.temperature !== undefined) {
            formData.append('temperature', options.temperature.toString());
        }
        
        if (options.responseFormat) {
            formData.append('response_format', options.responseFormat);
        }
        
        // 타임스탬프 옵션 (verbose_json 포맷에서만)
        if (options.responseFormat === 'verbose_json' && options.timestampGranularities) {
            options.timestampGranularities.forEach(granularity => {
                formData.append('timestamp_granularities[]', granularity);
            });
        }
        
        return formData;
    }

    /**
     * 응답 파싱
     */
    private parseResponse(json: any, duration: number): TranscriptionResponse {
        if (typeof json === 'string') {
            // response_format이 'text'인 경우
            return {
                text: json,
                duration,
                type: 'simple'
            };
        }
        
        // verbose_json 또는 json 포맷
        return {
            text: json.text,
            language: json.language,
            duration,
            segments: json.segments,
            words: json.words,
            type: json.segments ? 'verbose' : 'simple'
        };
    }

    /**
     * API 에러 처리
     */
    private async handleAPIError(response: any): Promise<never> {
        const errorBody = response.json;
        
        switch (response.status) {
            case 400:
                throw new BadRequestError(errorBody?.error?.message || 'Invalid request');
            case 401:
                throw new AuthenticationError('Invalid API key');
            case 429:
                const retryAfter = response.headers?.['retry-after'];
                throw new RateLimitError(retryAfter);
            case 413:
                throw new FileTooLargeError();
            case 500:
            case 502:
            case 503:
                throw new ServerError(`Server error: ${response.status}`);
            default:
                throw new UnknownAPIError(`API error: ${response.status}`, response.status);
        }
    }

    /**
     * 요청 큐잉 (동시 요청 제한)
     */
    private async queueRequest<T>(request: () => Promise<T>): Promise<T> {
        const promise = this.requestQueue.then(request, request);
        this.requestQueue = promise.catch(() => {});
        return promise;
    }

    /**
     * 진행 중인 요청 취소
     */
    cancel(): void {
        this.abortController?.abort();
    }

    /**
     * API 키 검증
     */
    async validateApiKey(): Promise<boolean> {
        try {
            // 최소한의 테스트 요청
            const testAudio = new ArrayBuffer(100);
            await this.transcribe(testAudio, { 
                responseFormat: 'text' 
            });
            return true;
        } catch (error) {
            if (error instanceof AuthenticationError) {
                return false;
            }
            // 다른 에러는 API 키와 관련 없음
            return true;
        }
    }
}

// 타입 정의
interface TranscriptionOptions {
    model?: 'whisper-1';
    language?: string;
    prompt?: string;
    temperature?: number;
    responseFormat?: 'json' | 'text' | 'verbose_json';
    timestampGranularities?: ('word' | 'segment')[];
    mimeType?: string;
    filename?: string;
}

interface TranscriptionResponse {
    text: string;
    language?: string;
    duration: number;
    segments?: TranscriptionSegment[];
    words?: TranscriptionWord[];
    type: 'simple' | 'verbose';
}

interface TranscriptionSegment {
    id: number;
    start: number;
    end: number;
    text: string;
    temperature: number;
    avgLogprob?: number;
    compressionRatio?: number;
    noSpeechProb?: number;
}

interface TranscriptionWord {
    word: string;
    start: number;
    end: number;
    confidence?: number;
}

// 커스텀 에러 클래스들
class TranscriptionError extends Error {
    constructor(message: string, public code: string) {
        super(message);
        this.name = 'TranscriptionError';
    }
}

class AuthenticationError extends TranscriptionError {
    constructor(message: string) {
        super(message, 'AUTH_ERROR');
    }
}

class RateLimitError extends TranscriptionError {
    constructor(public retryAfter?: string) {
        super('Rate limit exceeded', 'RATE_LIMIT');
    }
}

class FileTooLargeError extends TranscriptionError {
    constructor() {
        super('File size exceeds API limit (25MB)', 'FILE_TOO_LARGE');
    }
}

class ServerError extends TranscriptionError {
    constructor(message: string) {
        super(message, 'SERVER_ERROR');
    }
}

class BadRequestError extends TranscriptionError {
    constructor(message: string) {
        super(message, 'BAD_REQUEST');
    }
}

class UnknownAPIError extends TranscriptionError {
    constructor(message: string, public status: number) {
        super(message, 'UNKNOWN_ERROR');
    }
}

class TranscriptionCancelledError extends TranscriptionError {
    constructor() {
        super('Transcription was cancelled', 'CANCELLED');
    }
}

2.2 FormData 구성 방법

고급 FormData 처리

export class FormDataBuilder {
    private formData: FormData;

    constructor() {
        this.formData = new FormData();
    }

    /**
     * 오디오 파일 추가 (다양한 포맷 지원)
     */
    addAudioFile(buffer: ArrayBuffer, format: AudioFormat): this {
        const mimeType = this.getMimeType(format);
        const filename = `audio.${format}`;
        
        const blob = new Blob([buffer], { type: mimeType });
        this.formData.append('file', blob, filename);
        
        return this;
    }

    /**
     * Whisper API 파라미터 추가
     */
    addWhisperParams(params: WhisperParams): this {
        // 필수 파라미터
        this.formData.append('model', params.model || 'whisper-1');
        
        // 선택적 파라미터
        if (params.language) {
            this.formData.append('language', params.language);
        }
        
        if (params.prompt) {
            // 프롬프트 길이 제한 (최대 224 토큰)
            const truncatedPrompt = this.truncatePrompt(params.prompt);
            this.formData.append('prompt', truncatedPrompt);
        }
        
        if (params.temperature !== undefined) {
            // 온도 범위 검증 (0.0 ~ 1.0)
            const temp = Math.max(0, Math.min(1, params.temperature));
            this.formData.append('temperature', temp.toString());
        }
        
        if (params.responseFormat) {
            this.formData.append('response_format', params.responseFormat);
        }
        
        return this;
    }

    /**
     * 메타데이터 추가 (커스텀 처리용)
     */
    addMetadata(metadata: Record<string, any>): this {
        Object.entries(metadata).forEach(([key, value]) => {
            if (value !== undefined && value !== null) {
                if (Array.isArray(value)) {
                    value.forEach(item => {
                        this.formData.append(`${key}[]`, String(item));
                    });
                } else {
                    this.formData.append(key, String(value));
                }
            }
        });
        
        return this;
    }

    /**
     * FormData 빌드
     */
    build(): FormData {
        return this.formData;
    }

    /**
     * MIME 타입 매핑
     */
    private getMimeType(format: AudioFormat): string {
        const mimeTypes: Record<AudioFormat, string> = {
            'm4a': 'audio/mp4',
            'mp3': 'audio/mpeg',
            'wav': 'audio/wav',
            'mp4': 'audio/mp4',
            'mpeg': 'audio/mpeg',
            'mpga': 'audio/mpeg',
            'webm': 'audio/webm'
        };
        
        return mimeTypes[format] || 'audio/mpeg';
    }

    /**
     * 프롬프트 길이 제한 (간단한 구현)
     */
    private truncatePrompt(prompt: string, maxTokens: number = 224): string {
        // 대략적으로 1 토큰 = 4 문자로 계산
        const maxChars = maxTokens * 4;
        
        if (prompt.length <= maxChars) {
            return prompt;
        }
        
        return prompt.substring(0, maxChars - 3) + '...';
    }
}

type AudioFormat = 'm4a' | 'mp3' | 'wav' | 'mp4' | 'mpeg' | 'mpga' | 'webm';

interface WhisperParams {
    model?: 'whisper-1';
    language?: string;
    prompt?: string;
    temperature?: number;
    responseFormat?: 'json' | 'text' | 'verbose_json';
}

2.3 비동기 처리 및 프로미스 체이닝

고급 비동기 처리 패턴

export class AsyncTranscriptionManager {
    private currentOperation?: Promise<any>;
    private operationQueue: Array<() => Promise<any>> = [];
    private isProcessing = false;

    /**
     * 순차적 처리 (큐 기반)
     */
    async processSequentially<T>(operation: () => Promise<T>): Promise<T> {
        return new Promise((resolve, reject) => {
            this.operationQueue.push(async () => {
                try {
                    const result = await operation();
                    resolve(result);
                } catch (error) {
                    reject(error);
                }
            });
            
            this.processQueue();
        });
    }

    /**
     * 큐 처리
     */
    private async processQueue(): Promise<void> {
        if (this.isProcessing || this.operationQueue.length === 0) {
            return;
        }
        
        this.isProcessing = true;
        
        while (this.operationQueue.length > 0) {
            const operation = this.operationQueue.shift()!;
            await operation();
        }
        
        this.isProcessing = false;
    }

    /**
     * 병렬 처리 (배치)
     */
    async processBatch<T>(
        items: T[],
        processor: (item: T) => Promise<any>,
        batchSize: number = 3
    ): Promise<any[]> {
        const results: any[] = [];
        
        for (let i = 0; i < items.length; i += batchSize) {
            const batch = items.slice(i, i + batchSize);
            const batchResults = await Promise.all(
                batch.map(item => processor(item))
            );
            results.push(...batchResults);
        }
        
        return results;
    }

    /**
     * 타임아웃이 있는 비동기 작업
     */
    async withTimeout<T>(
        operation: Promise<T>,
        timeoutMs: number,
        timeoutError?: Error
    ): Promise<T> {
        const timeout = new Promise<never>((_, reject) => {
            setTimeout(() => {
                reject(timeoutError || new Error(`Operation timed out after ${timeoutMs}ms`));
            }, timeoutMs);
        });
        
        return Promise.race([operation, timeout]);
    }

    /**
     * 재시도가 있는 비동기 작업
     */
    async withRetry<T>(
        operation: () => Promise<T>,
        options: RetryOptions = {}
    ): Promise<T> {
        const {
            maxAttempts = 3,
            delay = 1000,
            backoff = 2,
            shouldRetry = () => true
        } = options;
        
        let lastError: Error;
        
        for (let attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return await operation();
            } catch (error) {
                lastError = error as Error;
                
                if (attempt === maxAttempts || !shouldRetry(error)) {
                    throw error;
                }
                
                const waitTime = delay * Math.pow(backoff, attempt - 1);
                await this.sleep(waitTime);
            }
        }
        
        throw lastError!;
    }

    /**
     * 프로미스 체이닝 예제
     */
    async transcribeWithFullPipeline(
        file: TFile,
        vault: Vault,
        whisperClient: WhisperAPIClient
    ): Promise<TranscriptionResult> {
        return this.validateFile(file)
            .then(() => this.readFile(file, vault))
            .then(buffer => this.preprocessAudio(buffer))
            .then(processed => this.transcribeAudio(processed, whisperClient))
            .then(response => this.postprocessText(response))
            .then(result => this.saveResult(result))
            .catch(error => this.handleError(error));
    }

    /**
     * async/await 버전
     */
    async transcribeWithAsyncAwait(
        file: TFile,
        vault: Vault,
        whisperClient: WhisperAPIClient
    ): Promise<TranscriptionResult> {
        try {
            // 검증
            await this.validateFile(file);
            
            // 파일 읽기
            const buffer = await this.readFile(file, vault);
            
            // 전처리
            const processed = await this.preprocessAudio(buffer);
            
            // 변환
            const response = await this.transcribeAudio(processed, whisperClient);
            
            // 후처리
            const result = await this.postprocessText(response);
            
            // 저장
            return await this.saveResult(result);
        } catch (error) {
            return this.handleError(error);
        }
    }

    /**
     * 파이프라인 빌더 패턴
     */
    createPipeline<T>(): Pipeline<T> {
        return new Pipeline<T>();
    }

    private sleep(ms: number): Promise<void> {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // 헬퍼 메서드들 (실제 구현 필요)
    private async validateFile(file: TFile): Promise<void> {
        // 구현...
    }

    private async readFile(file: TFile, vault: Vault): Promise<ArrayBuffer> {
        return vault.readBinary(file);
    }

    private async preprocessAudio(buffer: ArrayBuffer): Promise<ArrayBuffer> {
        return buffer; // 실제로는 처리 필요
    }

    private async transcribeAudio(buffer: ArrayBuffer, client: WhisperAPIClient): Promise<any> {
        return client.transcribe(buffer);
    }

    private async postprocessText(response: any): Promise<TranscriptionResult> {
        return {
            text: response.text,
            timestamp: Date.now()
        };
    }

    private async saveResult(result: TranscriptionResult): Promise<TranscriptionResult> {
        // 저장 로직
        return result;
    }

    private async handleError(error: any): Promise<never> {
        throw error;
    }
}

// 파이프라인 빌더
class Pipeline<T> {
    private steps: Array<(input: any) => Promise<any>> = [];

    pipe<U>(step: (input: T) => Promise<U>): Pipeline<U> {
        this.steps.push(step);
        return this as any;
    }

    async execute(input: T): Promise<any> {
        let result = input;
        
        for (const step of this.steps) {
            result = await step(result);
        }
        
        return result;
    }
}

interface RetryOptions {
    maxAttempts?: number;
    delay?: number;
    backoff?: number;
    shouldRetry?: (error: any) => boolean;
}

interface TranscriptionResult {
    text: string;
    timestamp: number;
}

2.4 스트리밍 응답 처리

스트리밍 API 처리

export class StreamingTranscriptionHandler {
    private reader?: ReadableStreamDefaultReader<Uint8Array>;
    private decoder = new TextDecoder();

    /**
     * 스트리밍 응답 처리
     */
    async handleStreamingResponse(
        response: Response,
        onChunk: (text: string) => void,
        onComplete: (fullText: string) => void,
        onError: (error: Error) => void
    ): Promise<void> {
        if (!response.body) {
            throw new Error('Response body is empty');
        }

        this.reader = response.body.getReader();
        const chunks: string[] = [];

        try {
            while (true) {
                const { done, value } = await this.reader.read();
                
                if (done) {
                    break;
                }
                
                const chunk = this.decoder.decode(value, { stream: true });
                chunks.push(chunk);
                
                // 청크별 처리
                const parsedChunk = this.parseStreamChunk(chunk);
                if (parsedChunk) {
                    onChunk(parsedChunk);
                }
            }
            
            // 완료 처리
            const fullText = chunks.join('');
            onComplete(fullText);
        } catch (error) {
            onError(error as Error);
        } finally {
            this.reader?.releaseLock();
        }
    }

    /**
     * Server-Sent Events (SSE) 처리
     */
    async handleSSEStream(
        url: string,
        headers: HeadersInit,
        onMessage: (data: any) => void,
        onError: (error: Error) => void
    ): Promise<EventSource> {
        const eventSource = new EventSource(url);
        
        eventSource.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);
                onMessage(data);
            } catch (error) {
                console.error('Failed to parse SSE message:', error);
            }
        };
        
        eventSource.onerror = (error) => {
            onError(new Error('SSE connection error'));
            eventSource.close();
        };
        
        return eventSource;
    }

    /**
     * 청크 파싱
     */
    private parseStreamChunk(chunk: string): string | null {
        // SSE 포맷 파싱 예제
        if (chunk.startsWith('data: ')) {
            const data = chunk.slice(6).trim();
            
            if (data === '[DONE]') {
                return null;
            }
            
            try {
                const parsed = JSON.parse(data);
                return parsed.text || parsed.content || null;
            } catch {
                return null;
            }
        }
        
        return chunk;
    }

    /**
     * 진행 상황이 있는 스트리밍
     */
    async streamWithProgress(
        response: Response,
        onProgress: (progress: StreamProgress) => void
    ): Promise<string> {
        const contentLength = response.headers.get('content-length');
        const total = contentLength ? parseInt(contentLength, 10) : 0;
        
        if (!response.body) {
            throw new Error('Response body is empty');
        }
        
        const reader = response.body.getReader();
        const chunks: Uint8Array[] = [];
        let received = 0;
        
        while (true) {
            const { done, value } = await reader.read();
            
            if (done) {
                break;
            }
            
            chunks.push(value);
            received += value.length;
            
            // 진행 상황 업데이트
            onProgress({
                received,
                total,
                percentage: total > 0 ? (received / total) * 100 : 0
            });
        }
        
        // 전체 데이터 조합
        const allChunks = new Uint8Array(received);
        let position = 0;
        
        for (const chunk of chunks) {
            allChunks.set(chunk, position);
            position += chunk.length;
        }
        
        return this.decoder.decode(allChunks);
    }

    /**
     * 취소 가능한 스트림
     */
    createCancellableStream(): CancellableStream {
        const abortController = new AbortController();
        
        return {
            signal: abortController.signal,
            cancel: () => abortController.abort(),
            
            async process(
                response: Response,
                processor: (chunk: string) => void
            ): Promise<void> {
                if (!response.body) {
                    throw new Error('Response body is empty');
                }
                
                const reader = response.body.getReader();
                
                try {
                    while (!abortController.signal.aborted) {
                        const { done, value } = await reader.read();
                        
                        if (done) {
                            break;
                        }
                        
                        const text = new TextDecoder().decode(value);
                        processor(text);
                    }
                } finally {
                    reader.releaseLock();
                }
            }
        };
    }
}

interface StreamProgress {
    received: number;
    total: number;
    percentage: number;
}

interface CancellableStream {
    signal: AbortSignal;
    cancel: () => void;
    process: (response: Response, processor: (chunk: string) => void) => Promise<void>;
}

3. 오류 처리 전략

3.1 에러 타입별 처리 방법

종합적인 에러 처리 시스템

export class ComprehensiveErrorHandler {
    private readonly errorStrategies = new Map<string, ErrorStrategy>();
    private readonly logger: ILogger;
    private readonly notifier: INotificationService;

    constructor(logger: ILogger, notifier: INotificationService) {
        this.logger = logger;
        this.notifier = notifier;
        this.registerErrorStrategies();
    }

    /**
     * 에러 전략 등록
     */
    private registerErrorStrategies(): void {
        // 네트워크 에러
        this.errorStrategies.set('NetworkError', {
            handle: (error) => this.handleNetworkError(error),
            canRetry: true,
            userMessage: 'Network connection issue. Please check your internet connection.'
        });

        // API 에러
        this.errorStrategies.set('APIError', {
            handle: (error) => this.handleAPIError(error),
            canRetry: (error) => error.status >= 500 || error.status === 429,
            userMessage: (error) => this.getAPIErrorMessage(error)
        });

        // 검증 에러
        this.errorStrategies.set('ValidationError', {
            handle: (error) => this.handleValidationError(error),
            canRetry: false,
            userMessage: (error) => error.message
        });

        // 파일 에러
        this.errorStrategies.set('FileError', {
            handle: (error) => this.handleFileError(error),
            canRetry: false,
            userMessage: 'File operation failed. Please check file permissions.'
        });

        // 권한 에러
        this.errorStrategies.set('PermissionError', {
            handle: (error) => this.handlePermissionError(error),
            canRetry: false,
            userMessage: 'Permission denied. Please check your settings.'
        });
    }

    /**
     * 메인 에러 처리
     */
    async handle(
        error: Error,
        context?: ErrorContext
    ): Promise<ErrorHandlingResult> {
        // 에러 분류
        const errorType = this.classifyError(error);
        const strategy = this.errorStrategies.get(errorType);

        // 로깅
        this.logError(error, errorType, context);

        // 전략에 따른 처리
        if (strategy) {
            await strategy.handle(error);
            
            // 사용자 알림
            const userMessage = typeof strategy.userMessage === 'function'
                ? strategy.userMessage(error)
                : strategy.userMessage;
            
            this.notifier.error(userMessage);

            return {
                handled: true,
                canRetry: typeof strategy.canRetry === 'function'
                    ? strategy.canRetry(error)
                    : strategy.canRetry,
                errorType
            };
        }

        // 알 수 없는 에러
        return this.handleUnknownError(error);
    }

    /**
     * 에러 분류
     */
    private classifyError(error: Error): string {
        if (error.name === 'NetworkError' || error.message.includes('network')) {
            return 'NetworkError';
        }
        
        if (error.name === 'APIError' || error.message.includes('API')) {
            return 'APIError';
        }
        
        if (error.name === 'ValidationError') {
            return 'ValidationError';
        }
        
        if (error.message.includes('file') || error.message.includes('File')) {
            return 'FileError';
        }
        
        if (error.message.includes('permission') || error.message.includes('denied')) {
            return 'PermissionError';
        }
        
        return 'UnknownError';
    }

    /**
     * 네트워크 에러 처리
     */
    private async handleNetworkError(error: Error): Promise<void> {
        // 네트워크 상태 확인
        const isOnline = await this.checkNetworkStatus();
        
        if (!isOnline) {
            this.notifier.warn('You appear to be offline. Please check your connection.');
        } else {
            this.notifier.error('Network request failed. The server may be unavailable.');
        }
    }

    /**
     * API 에러 처리
     */
    private handleAPIError(error: any): void {
        const status = error.status || error.statusCode;
        
        switch (status) {
            case 401:
                this.notifier.error('Authentication failed. Please check your API key.');
                // API 키 재설정 UI 표시
                this.showAPIKeySettings();
                break;
            
            case 429:
                const retryAfter = error.retryAfter || 60;
                this.notifier.warn(`Rate limit exceeded. Please try again in ${retryAfter} seconds.`);
                break;
            
            case 403:
                this.notifier.error('Access forbidden. You may not have permission for this operation.');
                break;
            
            case 500:
            case 502:
            case 503:
                this.notifier.error('Server error. Please try again later.');
                break;
            
            default:
                this.notifier.error(`API error: ${error.message}`);
        }
    }

    /**
     * 검증 에러 처리
     */
    private handleValidationError(error: any): void {
        const fields = error.fields || [];
        
        if (fields.length > 0) {
            const fieldList = fields.join(', ');
            this.notifier.error(`Validation failed for: ${fieldList}`);
        } else {
            this.notifier.error(error.message);
        }
    }

    /**
     * 파일 에러 처리
     */
    private handleFileError(error: Error): void {
        if (error.message.includes('size')) {
            this.notifier.error('File is too large. Maximum size is 25MB.');
        } else if (error.message.includes('format')) {
            this.notifier.error('Unsupported file format. Please use M4A, MP3, WAV, or MP4.');
        } else if (error.message.includes('not found')) {
            this.notifier.error('File not found. It may have been moved or deleted.');
        } else {
            this.notifier.error('File operation failed. Please check file permissions.');
        }
    }

    /**
     * 권한 에러 처리
     */
    private handlePermissionError(error: Error): void {
        this.notifier.error('Permission denied. Please check your settings and try again.');
        // 설정 페이지로 이동 제안
    }

    /**
     * 알 수 없는 에러 처리
     */
    private handleUnknownError(error: Error): ErrorHandlingResult {
        this.logger.error('Unknown error occurred', error);
        this.notifier.error(`An unexpected error occurred: ${error.message}`);
        
        return {
            handled: true,
            canRetry: false,
            errorType: 'UnknownError'
        };
    }

    /**
     * API 에러 메시지 생성
     */
    private getAPIErrorMessage(error: any): string {
        const status = error.status || error.statusCode;
        
        const messages: Record<number, string> = {
            400: 'Invalid request. Please check your input.',
            401: 'Authentication failed. Please check your API key.',
            403: 'Access forbidden.',
            404: 'Resource not found.',
            429: 'Too many requests. Please slow down.',
            500: 'Server error. Please try again later.',
            502: 'Bad gateway. Please try again.',
            503: 'Service unavailable. Please try again later.'
        };
        
        return messages[status] || `API error: ${error.message}`;
    }

    /**
     * 에러 로깅
     */
    private logError(error: Error, errorType: string, context?: ErrorContext): void {
        this.logger.error(`[${errorType}] ${error.message}`, error, {
            ...context,
            stack: error.stack,
            timestamp: new Date().toISOString()
        });
    }

    /**
     * 네트워크 상태 확인
     */
    private async checkNetworkStatus(): Promise<boolean> {
        try {
            const response = await fetch('https://api.openai.com', { 
                method: 'HEAD',
                mode: 'no-cors'
            });
            return true;
        } catch {
            return false;
        }
    }

    /**
     * API 키 설정 UI 표시
     */
    private showAPIKeySettings(): void {
        // 구현 필요
    }
}

interface ErrorStrategy {
    handle: (error: any) => void | Promise<void>;
    canRetry: boolean | ((error: any) => boolean);
    userMessage: string | ((error: any) => string);
}

interface ErrorContext {
    operation?: string;
    file?: string;
    userId?: string;
    [key: string]: any;
}

interface ErrorHandlingResult {
    handled: boolean;
    canRetry: boolean;
    errorType: string;
}

3.2 재시도 로직 구현 (지수 백오프)

고급 재시도 메커니즘

export class AdvancedRetryManager {
    private readonly defaultConfig: RetryConfig = {
        maxAttempts: 3,
        initialDelay: 1000,
        maxDelay: 30000,
        backoffMultiplier: 2,
        jitterFactor: 0.1,
        retryableErrors: ['NetworkError', 'TimeoutError', 'ServerError']
    };

    /**
     * 지수 백오프를 사용한 재시도
     */
    async executeWithRetry<T>(
        operation: () => Promise<T>,
        config?: Partial<RetryConfig>
    ): Promise<T> {
        const finalConfig = { ...this.defaultConfig, ...config };
        let lastError: Error;
        
        for (let attempt = 1; attempt <= finalConfig.maxAttempts; attempt++) {
            try {
                // 작업 실행
                const result = await operation();
                
                // 성공 시 메트릭 기록
                this.recordSuccess(attempt);
                
                return result;
            } catch (error) {
                lastError = error as Error;
                
                // 재시도 가능 여부 확인
                if (!this.isRetryable(error, finalConfig)) {
                    throw error;
                }
                
                // 마지막 시도였다면 에러 throw
                if (attempt === finalConfig.maxAttempts) {
                    throw new MaxRetriesExceededError(lastError, finalConfig.maxAttempts);
                }
                
                // 대기 시간 계산
                const delay = this.calculateDelay(attempt, finalConfig);
                
                // 재시도 전 로깅
                this.logRetryAttempt(attempt, finalConfig.maxAttempts, delay, error);
                
                // 대기
                await this.sleep(delay);
            }
        }
        
        throw lastError!;
    }

    /**
     * 적응형 재시도 (성공률 기반)
     */
    async executeWithAdaptiveRetry<T>(
        operation: () => Promise<T>,
        adaptiveConfig?: AdaptiveRetryConfig
    ): Promise<T> {
        const config = {
            ...this.defaultConfig,
            ...adaptiveConfig,
            successRateThreshold: adaptiveConfig?.successRateThreshold || 0.5
        };
        
        // 현재 성공률 확인
        const successRate = this.getRecentSuccessRate();
        
        // 성공률이 낮으면 재시도 횟수 증가
        if (successRate < config.successRateThreshold) {
            config.maxAttempts = Math.min(config.maxAttempts * 2, 10);
            config.initialDelay = config.initialDelay * 1.5;
        }
        
        return this.executeWithRetry(operation, config);
    }

    /**
     * 지수 백오프 지연 시간 계산
     */
    private calculateDelay(attempt: number, config: RetryConfig): number {
        // 기본 지수 백오프
        let delay = Math.min(
            config.initialDelay * Math.pow(config.backoffMultiplier, attempt - 1),
            config.maxDelay
        );
        
        // Jitter 추가 (충돌 방지)
        if (config.jitterFactor > 0) {
            const jitter = delay * config.jitterFactor * Math.random();
            delay = delay + jitter;
        }
        
        return Math.floor(delay);
    }

    /**
     * 재시도 가능 여부 판단
     */
    private isRetryable(error: any, config: RetryConfig): boolean {
        // 에러 타입 확인
        if (config.retryableErrors) {
            const errorName = error.name || error.constructor.name;
            if (!config.retryableErrors.includes(errorName)) {
                return false;
            }
        }
        
        // HTTP 상태 코드 확인
        if (error.status !== undefined) {
            // 5xx 에러와 429는 재시도 가능
            if (error.status >= 500 || error.status === 429) {
                return true;
            }
            
            // 4xx 에러는 대부분 재시도 불가
            if (error.status >= 400 && error.status < 500) {
                return false;
            }
        }
        
        // 특정 에러 메시지 확인
        const message = error.message?.toLowerCase() || '';
        const retryableMessages = ['timeout', 'network', 'econnrefused', 'enotfound'];
        
        return retryableMessages.some(msg => message.includes(msg));
    }

    /**
     * Circuit Breaker와 통합된 재시도
     */
    async executeWithCircuitBreaker<T>(
        operation: () => Promise<T>,
        circuitBreaker: CircuitBreaker
    ): Promise<T> {
        // Circuit이 열려있으면 빠른 실패
        if (circuitBreaker.isOpen()) {
            throw new CircuitOpenError('Circuit breaker is open');
        }
        
        try {
            const result = await this.executeWithRetry(operation);
            circuitBreaker.recordSuccess();
            return result;
        } catch (error) {
            circuitBreaker.recordFailure();
            throw error;
        }
    }

    /**
     * 재시도 메트릭 기록
     */
    private recordSuccess(attempts: number): void {
        // 메트릭 수집 구현
        console.log(`Operation succeeded after ${attempts} attempt(s)`);
    }

    private logRetryAttempt(
        attempt: number,
        maxAttempts: number,
        delay: number,
        error: any
    ): void {
        console.log(
            `Retry attempt ${attempt}/${maxAttempts} after ${delay}ms delay. ` +
            `Error: ${error.message}`
        );
    }

    private getRecentSuccessRate(): number {
        // 최근 성공률 계산 로직
        return 0.8; // 예시
    }

    private sleep(ms: number): Promise<void> {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}

interface RetryConfig {
    maxAttempts: number;
    initialDelay: number;
    maxDelay: number;
    backoffMultiplier: number;
    jitterFactor: number;
    retryableErrors?: string[];
}

interface AdaptiveRetryConfig extends Partial<RetryConfig> {
    successRateThreshold?: number;
}

class MaxRetriesExceededError extends Error {
    constructor(public originalError: Error, public attempts: number) {
        super(`Operation failed after ${attempts} attempts: ${originalError.message}`);
        this.name = 'MaxRetriesExceededError';
    }
}

class CircuitOpenError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'CircuitOpenError';
    }
}

3.3 Circuit Breaker 패턴 적용

Circuit Breaker 구현

export class CircuitBreaker {
    private state: CircuitState = 'CLOSED';
    private failureCount = 0;
    private successCount = 0;
    private lastFailureTime?: number;
    private nextAttemptTime?: number;

    constructor(private config: CircuitBreakerConfig) {}

    /**
     * Circuit Breaker를 통한 작업 실행
     */
    async execute<T>(operation: () => Promise<T>): Promise<T> {
        // 상태 확인
        if (this.isOpen()) {
            throw new CircuitOpenError(
                `Circuit breaker is open. Next attempt at ${new Date(this.nextAttemptTime!)}`
            );
        }

        try {
            const result = await operation();
            this.onSuccess();
            return result;
        } catch (error) {
            this.onFailure();
            throw error;
        }
    }

    /**
     * Circuit이 열려있는지 확인
     */
    isOpen(): boolean {
        if (this.state === 'OPEN') {
            // 타임아웃 확인
            if (Date.now() >= this.nextAttemptTime!) {
                this.state = 'HALF_OPEN';
                return false;
            }
            return true;
        }
        return false;
    }

    /**
     * 성공 처리
     */
    recordSuccess(): void {
        this.onSuccess();
    }

    /**
     * 실패 처리
     */
    recordFailure(): void {
        this.onFailure();
    }

    /**
     * 성공 시 상태 업데이트
     */
    private onSuccess(): void {
        this.failureCount = 0;
        
        if (this.state === 'HALF_OPEN') {
            this.successCount++;
            
            if (this.successCount >= this.config.successThreshold) {
                this.close();
            }
        }
    }

    /**
     * 실패 시 상태 업데이트
     */
    private onFailure(): void {
        this.failureCount++;
        this.lastFailureTime = Date.now();
        
        if (this.state === 'HALF_OPEN') {
            this.open();
        } else if (this.failureCount >= this.config.failureThreshold) {
            this.open();
        }
    }

    /**
     * Circuit 열기
     */
    private open(): void {
        this.state = 'OPEN';
        this.nextAttemptTime = Date.now() + this.config.timeout;
        console.log(`Circuit breaker opened. Will retry at ${new Date(this.nextAttemptTime)}`);
    }

    /**
     * Circuit 닫기
     */
    private close(): void {
        this.state = 'CLOSED';
        this.failureCount = 0;
        this.successCount = 0;
        console.log('Circuit breaker closed');
    }

    /**
     * 상태 가져오기
     */
    getState(): CircuitBreakerState {
        return {
            state: this.state,
            failureCount: this.failureCount,
            successCount: this.successCount,
            lastFailureTime: this.lastFailureTime,
            nextAttemptTime: this.nextAttemptTime
        };
    }

    /**
     * 수동 리셋
     */
    reset(): void {
        this.close();
    }
}

type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

interface CircuitBreakerConfig {
    failureThreshold: number;  // 실패 횟수 임계값
    successThreshold: number;  // 성공 횟수 임계값 (HALF_OPEN 상태에서)
    timeout: number;           // OPEN 상태 유지 시간 (ms)
}

interface CircuitBreakerState {
    state: CircuitState;
    failureCount: number;
    successCount: number;
    lastFailureTime?: number;
    nextAttemptTime?: number;
}

// Circuit Breaker 매니저
export class CircuitBreakerManager {
    private breakers = new Map<string, CircuitBreaker>();

    /**
     * Circuit Breaker 생성 또는 가져오기
     */
    getBreaker(name: string, config?: CircuitBreakerConfig): CircuitBreaker {
        if (!this.breakers.has(name)) {
            const defaultConfig: CircuitBreakerConfig = {
                failureThreshold: 5,
                successThreshold: 2,
                timeout: 60000, // 1분
                ...config
            };
            
            this.breakers.set(name, new CircuitBreaker(defaultConfig));
        }
        
        return this.breakers.get(name)!;
    }

    /**
     * 모든 Circuit Breaker 상태
     */
    getAllStates(): Record<string, CircuitBreakerState> {
        const states: Record<string, CircuitBreakerState> = {};
        
        this.breakers.forEach((breaker, name) => {
            states[name] = breaker.getState();
        });
        
        return states;
    }

    /**
     * 모든 Circuit Breaker 리셋
     */
    resetAll(): void {
        this.breakers.forEach(breaker => breaker.reset());
    }
}

3.4 사용자 친화적 에러 메시지

에러 메시지 매핑 시스템

export class UserFriendlyErrorMessages {
    private readonly errorMessages = new Map<string, ErrorMessageConfig>();

    constructor() {
        this.initializeMessages();
    }

    /**
     * 에러 메시지 초기화
     */
    private initializeMessages(): void {
        // 네트워크 에러
        this.errorMessages.set('NETWORK_ERROR', {
            title: 'Connection Problem',
            message: 'Unable to connect to the service. Please check your internet connection.',
            icon: '🌐',
            actions: [
                { label: 'Retry', action: 'retry' },
                { label: 'Check Settings', action: 'settings' }
            ]
        });

        // API 키 에러
        this.errorMessages.set('INVALID_API_KEY', {
            title: 'Authentication Failed',
            message: 'Your API key appears to be invalid. Please check your settings.',
            icon: '🔑',
            actions: [
                { label: 'Update API Key', action: 'updateApiKey' },
                { label: 'Get API Key', action: 'openApiDocs' }
            ]
        });

        // 파일 크기 에러
        this.errorMessages.set('FILE_TOO_LARGE', {
            title: 'File Too Large',
            message: 'The selected file exceeds the 25MB limit. Please choose a smaller file.',
            icon: '📁',
            actions: [
                { label: 'Choose Another File', action: 'selectFile' },
                { label: 'Learn More', action: 'help' }
            ]
        });

        // 할당량 초과
        this.errorMessages.set('QUOTA_EXCEEDED', {
            title: 'Quota Exceeded',
            message: 'You have reached your API usage limit. Please try again later or upgrade your plan.',
            icon: '⚠️',
            actions: [
                { label: 'View Usage', action: 'viewUsage' },
                { label: 'Upgrade Plan', action: 'upgrade' }
            ]
        });

        // 서버 에러
        this.errorMessages.set('SERVER_ERROR', {
            title: 'Service Unavailable',
            message: 'The transcription service is temporarily unavailable. Please try again in a few moments.',
            icon: '🔧',
            actions: [
                { label: 'Retry', action: 'retry' },
                { label: 'Check Status', action: 'checkStatus' }
            ]
        });
    }

    /**
     * 에러 코드로 사용자 메시지 가져오기
     */
    getUserMessage(errorCode: string, context?: any): UserErrorMessage {
        const config = this.errorMessages.get(errorCode);
        
        if (!config) {
            return this.getDefaultMessage(errorCode);
        }

        // 컨텍스트를 사용한 메시지 커스터마이징
        let message = config.message;
        if (context) {
            message = this.interpolateMessage(message, context);
        }

        return {
            title: config.title,
            message,
            icon: config.icon,
            actions: config.actions,
            severity: this.getSeverity(errorCode)
        };
    }

    /**
     * 기본 에러 메시지
     */
    private getDefaultMessage(errorCode: string): UserErrorMessage {
        return {
            title: 'An Error Occurred',
            message: `Something went wrong (${errorCode}). Please try again or contact support if the problem persists.`,
            icon: '❌',
            actions: [
                { label: 'Retry', action: 'retry' },
                { label: 'Get Help', action: 'help' }
            ],
            severity: 'error'
        };
    }

    /**
     * 메시지 보간
     */
    private interpolateMessage(message: string, context: any): string {
        return message.replace(/\{(\w+)\}/g, (match, key) => {
            return context[key] || match;
        });
    }

    /**
     * 에러 심각도 결정
     */
    private getSeverity(errorCode: string): ErrorSeverity {
        const criticalErrors = ['SERVER_ERROR', 'INVALID_API_KEY'];
        const warningErrors = ['QUOTA_EXCEEDED', 'RATE_LIMIT'];
        
        if (criticalErrors.includes(errorCode)) {
            return 'error';
        }
        
        if (warningErrors.includes(errorCode)) {
            return 'warning';
        }
        
        return 'info';
    }
}

interface ErrorMessageConfig {
    title: string;
    message: string;
    icon: string;
    actions: ErrorAction[];
}

interface UserErrorMessage extends ErrorMessageConfig {
    severity: ErrorSeverity;
}

interface ErrorAction {
    label: string;
    action: string;
}

type ErrorSeverity = 'info' | 'warning' | 'error' | 'critical';

// 에러 표시 컴포넌트
export class ErrorNotificationComponent {
    constructor(private messageService: UserFriendlyErrorMessages) {}

    /**
     * 에러 알림 표시
     */
    showError(errorCode: string, context?: any): void {
        const message = this.messageService.getUserMessage(errorCode, context);
        
        // Obsidian Notice 사용
        const notice = new Notice('', 0);
        
        // 커스텀 HTML 생성
        const container = notice.noticeEl;
        container.empty();
        container.addClass(`error-notice-${message.severity}`);
        
        // 아이콘과 제목
        const header = container.createDiv('error-notice-header');
        header.createSpan({ text: message.icon, cls: 'error-icon' });
        header.createSpan({ text: message.title, cls: 'error-title' });
        
        // 메시지
        container.createDiv({
            text: message.message,
            cls: 'error-message'
        });
        
        // 액션 버튼들
        if (message.actions.length > 0) {
            const actions = container.createDiv('error-actions');
            
            message.actions.forEach(action => {
                const button = actions.createEl('button', {
                    text: action.label,
                    cls: 'error-action-button'
                });
                
                button.onclick = () => {
                    this.handleAction(action.action);
                    notice.hide();
                };
            });
        }
        
        // 자동 숨김 설정
        if (message.severity !== 'critical') {
            setTimeout(() => notice.hide(), 10000);
        }
    }

    /**
     * 액션 처리
     */
    private handleAction(action: string): void {
        switch (action) {
            case 'retry':
                // 재시도 로직
                break;
            case 'settings':
                // 설정 열기
                break;
            case 'updateApiKey':
                // API 키 업데이트 모달
                break;
            // ... 기타 액션들
        }
    }
}

4. 코드 리뷰 체크리스트

4.1 성능 최적화 포인트

성능 체크리스트

/**
 * 성능 최적화 체크리스트
 * 
 * ✅ 메모리 관리
 * - [ ] 대용량 ArrayBuffer 처리 시 메모리 누수 방지
 * - [ ] 사용 완료된 객체 참조 해제
 * - [ ] WeakMap/WeakSet 활용 검토
 * - [ ] 이벤트 리스너 적절한 해제
 * 
 * ✅ 비동기 처리
 * - [ ] Promise 체이닝 최적화
 * - [ ] 동시성 제어 (최대 동시 요청 수 제한)
 * - [ ] 불필요한 await 제거
 * - [ ] Promise.all() 활용하여 병렬 처리
 * 
 * ✅ 파일 처리
 * - [ ] 청크 단위 처리로 메모리 사용 최소화
 * - [ ] 스트리밍 처리 가능 여부 검토
 * - [ ] Base64 인코딩 최적화
 * - [ ] 파일 캐싱 전략 구현
 * 
 * ✅ API 호출
 * - [ ] 요청 디바운싱/쓰로틀링 적용
 * - [ ] 응답 캐싱 구현
 * - [ ] 불필요한 API 호출 제거
 * - [ ] 배치 처리 가능 여부 검토
 * 
 * ✅ UI 렌더링
 * - [ ] Virtual scrolling for long lists
 * - [ ] 레이지 로딩 적용
 * - [ ] 리렌더링 최소화
 * - [ ] CSS 애니메이션 최적화
 */

// 성능 모니터링 유틸리티
export class PerformanceMonitor {
    private metrics: Map<string, PerformanceMetric> = new Map();

    /**
     * 작업 시간 측정
     */
    async measure<T>(name: string, operation: () => Promise<T>): Promise<T> {
        const start = performance.now();
        
        try {
            const result = await operation();
            const duration = performance.now() - start;
            
            this.recordMetric(name, duration, true);
            return result;
        } catch (error) {
            const duration = performance.now() - start;
            this.recordMetric(name, duration, false);
            throw error;
        }
    }

    /**
     * 메트릭 기록
     */
    private recordMetric(name: string, duration: number, success: boolean): void {
        if (!this.metrics.has(name)) {
            this.metrics.set(name, {
                name,
                count: 0,
                totalDuration: 0,
                avgDuration: 0,
                minDuration: Infinity,
                maxDuration: 0,
                successCount: 0,
                failureCount: 0
            });
        }
        
        const metric = this.metrics.get(name)!;
        metric.count++;
        metric.totalDuration += duration;
        metric.avgDuration = metric.totalDuration / metric.count;
        metric.minDuration = Math.min(metric.minDuration, duration);
        metric.maxDuration = Math.max(metric.maxDuration, duration);
        
        if (success) {
            metric.successCount++;
        } else {
            metric.failureCount++;
        }
    }

    /**
     * 성능 보고서 생성
     */
    generateReport(): PerformanceReport {
        const report: PerformanceReport = {
            timestamp: new Date().toISOString(),
            metrics: Array.from(this.metrics.values()),
            summary: {
                totalOperations: 0,
                avgDuration: 0,
                successRate: 0
            }
        };
        
        // 요약 계산
        let totalOps = 0;
        let totalDuration = 0;
        let totalSuccess = 0;
        
        report.metrics.forEach(metric => {
            totalOps += metric.count;
            totalDuration += metric.totalDuration;
            totalSuccess += metric.successCount;
        });
        
        report.summary.totalOperations = totalOps;
        report.summary.avgDuration = totalOps > 0 ? totalDuration / totalOps : 0;
        report.summary.successRate = totalOps > 0 ? (totalSuccess / totalOps) * 100 : 0;
        
        return report;
    }
}

interface PerformanceMetric {
    name: string;
    count: number;
    totalDuration: number;
    avgDuration: number;
    minDuration: number;
    maxDuration: number;
    successCount: number;
    failureCount: number;
}

interface PerformanceReport {
    timestamp: string;
    metrics: PerformanceMetric[];
    summary: {
        totalOperations: number;
        avgDuration: number;
        successRate: number;
    };
}

4.2 보안 검토 사항

보안 체크리스트

/**
 * 보안 검토 체크리스트
 * 
 * ✅ API 키 보안
 * - [ ] API 키 암호화 저장
 * - [ ] 메모리에서 API 키 보호
 * - [ ] 로그에 API 키 노출 방지
 * - [ ] API 키 검증 로직 구현
 * 
 * ✅ 입력 검증
 * - [ ] 파일 형식 검증
 * - [ ] 파일 크기 제한
 * - [ ] 경로 트래버설 방지
 * - [ ] 인젝션 공격 방지
 * 
 * ✅ 데이터 보호
 * - [ ] 민감한 데이터 암호화
 * - [ ] 안전한 통신 (HTTPS)
 * - [ ] 캐시된 데이터 보호
 * - [ ] 임시 파일 안전한 삭제
 * 
 * ✅ 권한 관리
 * - [ ] 최소 권한 원칙 적용
 * - [ ] 파일 시스템 접근 제한
 * - [ ] API 엔드포인트 검증
 */

// 보안 유틸리티
export class SecurityUtilities {
    /**
     * API 키 마스킹
     */
    maskApiKey(key: string): string {
        if (!key || key.length < 8) {
            return '***';
        }
        
        const visibleStart = 4;
        const visibleEnd = 4;
        const masked = '*'.repeat(key.length - visibleStart - visibleEnd);
        
        return key.substring(0, visibleStart) + masked + key.substring(key.length - visibleEnd);
    }

    /**
     * 안전한 경로 검증
     */
    validatePath(path: string): boolean {
        // 경로 트래버설 방지
        const dangerous = ['..', '~', '\\\\', '//'];
        
        for (const pattern of dangerous) {
            if (path.includes(pattern)) {
                return false;
            }
        }
        
        // 절대 경로 차단
        if (path.startsWith('/') || path.match(/^[A-Z]:/)) {
            return false;
        }
        
        return true;
    }

    /**
     * 입력 살균
     */
    sanitizeInput(input: string): string {
        return input
            .replace(/[<>]/g, '')  // HTML 태그 제거
            .replace(/javascript:/gi, '')  // JavaScript 프로토콜 제거
            .replace(/on\w+=/gi, '')  // 이벤트 핸들러 제거
            .trim();
    }

    /**
     * 안전한 JSON 파싱
     */
    safeJsonParse<T>(json: string): T | null {
        try {
            return JSON.parse(json);
        } catch {
            return null;
        }
    }
}

4.3 테스트 커버리지 요구사항

테스트 체크리스트

/**
 * 테스트 커버리지 체크리스트
 * 
 * ✅ 단위 테스트 (목표: 80% 이상)
 * - [ ] 모든 public 메서드 테스트
 * - [ ] 경계값 테스트
 * - [ ] 에러 케이스 테스트
 * - [ ] 모킹/스터빙 적절히 사용
 * 
 * ✅ 통합 테스트
 * - [ ] API 통합 테스트
 * - [ ] 파일 시스템 통합 테스트
 * - [ ] 옵시디언 API 통합 테스트
 * 
 * ✅ E2E 테스트
 * - [ ] 주요 사용자 시나리오 테스트
 * - [ ] 에러 복구 시나리오 테스트
 * - [ ] 성능 테스트
 */

// 테스트 예제
describe('WhisperService', () => {
    let service: WhisperService;
    let mockLogger: jest.Mocked<ILogger>;

    beforeEach(() => {
        mockLogger = {
            debug: jest.fn(),
            info: jest.fn(),
            warn: jest.fn(),
            error: jest.fn()
        };
        
        service = new WhisperService('test-api-key', mockLogger);
    });

    describe('transcribe', () => {
        it('should successfully transcribe audio', async () => {
            // Arrange
            const audioBuffer = new ArrayBuffer(100);
            const expectedResponse = { text: 'Test transcription' };
            
            // Mock requestUrl
            jest.spyOn(obsidian, 'requestUrl').mockResolvedValue({
                status: 200,
                json: expectedResponse
            });

            // Act
            const result = await service.transcribe(audioBuffer);

            // Assert
            expect(result).toEqual(expectedResponse);
            expect(mockLogger.debug).toHaveBeenCalledWith(
                expect.stringContaining('Starting transcription'),
                expect.any(Object)
            );
        });

        it('should handle API errors properly', async () => {
            // Arrange
            const audioBuffer = new ArrayBuffer(100);
            
            jest.spyOn(obsidian, 'requestUrl').mockResolvedValue({
                status: 401,
                json: { error: 'Unauthorized' }
            });

            // Act & Assert
            await expect(service.transcribe(audioBuffer))
                .rejects
                .toThrow('API returned status 401');
        });
    });
});

4.4 접근성 고려사항

접근성 체크리스트

/**
 * 접근성 체크리스트
 * 
 * ✅ 키보드 접근성
 * - [ ] 모든 기능 키보드로 접근 가능
 * - [ ] 탭 순서 논리적 구성
 * - [ ] 키보드 단축키 제공
 * - [ ] 포커스 표시 명확
 * 
 * ✅ 스크린 리더 지원
 * - [ ] ARIA 레이블 적절히 사용
 * - [ ] 의미있는 대체 텍스트
 * - [ ] 동적 콘텐츠 변경 알림
 * 
 * ✅ 시각적 접근성
 * - [ ] 충분한 색상 대비
 * - [ ] 색상만으로 정보 전달 금지
 * - [ ] 텍스트 크기 조절 가능
 * - [ ] 애니메이션 비활성화 옵션
 * 
 * ✅ 사용성
 * - [ ] 명확한 에러 메시지
 * - [ ] 진행 상황 표시
 * - [ ] 작업 취소 가능
 * - [ ] 도움말 제공
 */

// 접근성 유틸리티
export class AccessibilityUtils {
    /**
     * ARIA 속성 설정
     */
    setAriaAttributes(element: HTMLElement, attributes: AriaAttributes): void {
        Object.entries(attributes).forEach(([key, value]) => {
            if (value !== undefined) {
                element.setAttribute(`aria-${key}`, String(value));
            }
        });
    }

    /**
     * 키보드 내비게이션 설정
     */
    setupKeyboardNavigation(container: HTMLElement): void {
        const focusableElements = container.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        
        const firstElement = focusableElements[0] as HTMLElement;
        const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
        
        container.addEventListener('keydown', (e) => {
            if (e.key === 'Tab') {
                if (e.shiftKey) {
                    if (document.activeElement === firstElement) {
                        lastElement.focus();
                        e.preventDefault();
                    }
                } else {
                    if (document.activeElement === lastElement) {
                        firstElement.focus();
                        e.preventDefault();
                    }
                }
            }
            
            if (e.key === 'Escape') {
                // 닫기 동작
                container.dispatchEvent(new CustomEvent('close'));
            }
        });
    }

    /**
     * 스크린 리더 알림
     */
    announceToScreenReader(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
        const announcement = document.createElement('div');
        announcement.setAttribute('role', 'status');
        announcement.setAttribute('aria-live', priority);
        announcement.style.position = 'absolute';
        announcement.style.left = '-10000px';
        announcement.textContent = message;
        
        document.body.appendChild(announcement);
        
        setTimeout(() => {
            document.body.removeChild(announcement);
        }, 1000);
    }
}

interface AriaAttributes {
    label?: string;
    describedby?: string;
    labelledby?: string;
    hidden?: boolean;
    expanded?: boolean;
    pressed?: boolean;
    checked?: boolean;
    disabled?: boolean;
    required?: boolean;
    invalid?: boolean;
    live?: 'polite' | 'assertive' | 'off';
    busy?: boolean;
}

결론

이 구현 가이드는 Phase 2 핵심 기능 개발을 위한 상세한 패턴과 예제 코드를 제공합니다. 각 섹션의 코드는 실제 프로젝트에 바로 적용 가능하도록 작성되었으며, 다음과 같은 원칙을 따릅니다:

  1. 모듈화: 각 기능이 독립적으로 동작하고 테스트 가능
  2. 확장성: 새로운 기능 추가가 용이한 구조
  3. 안정성: 포괄적인 에러 처리와 복구 메커니즘
  4. 성능: 메모리와 CPU 사용 최적화
  5. 보안: API 키 보호와 입력 검증
  6. 접근성: 모든 사용자가 이용 가능한 인터페이스

개발 시 이 가이드를 참조하여 일관된 코드 품질과 아키텍처를 유지하시기 바랍니다.


최종 업데이트: 2025-08-22 버전: 1.0.0