Chat Conversation
Message 1 from User
ecmascript generator를 이용해서 ui agnostic 하게 도메인 업데이트 로직과 이벤트 모델링을 하고싶은데, 이런 패턴에 대해서 소프트웨어 엔지니어링 guru 관점에서 평가해봐
- frontend 애플리케이션의 주요 관심사와 복잡도는 프론트 도메인 모델의 업데이트와 해당 모델의 상태관리
- 상태 관리 자체는 reducer로 관리할 수 있으나, 상태를 업데이트 하는도중의 event들을 실제로 ui 상태로 업데이트 해야하는 경우가 생김
- 이때 event나 callback기반으로 action 모델링을 하게 되면 테스트도 번거롭고 이벤트 흐름을 추적하기가 어려움
- generator로 action을 pure하게 모델링 해두고 사용처(ui)에서 적절한 iterator runtime으로 이벤트를 필터링하거나 ui 상태에 바인딩 하게 하면 유연하지 않을까 하는 아이디어
- saga는 사소한 ui 로컬 상태 변경도 store에 저장해야하기때문에 비슷하지만 차이가 있음
Message 2 from QueryPie
이 패턴은 매우 흥미로운 아키텍처 접근법으로, 특히 복잡한 프론트엔드 상태 관리에서 발생하는 근본적인 문제들을 해결하려는 시도입니다. 소프트웨어 엔지니어링 관점에서 분석해보겠습니다.
패턴의 핵심 가치
관심사의 분리와 순수성 Generator를 통한 액션 모델링은 비즈니스 로직을 부수효과로부터 완전히 분리할 수 있습니다. 이는 함수형 프로그래밍의 핵심 원칙을 따르면서도 명령형 스타일의 가독성을 유지합니다.
테스트 용이성
Generator 함수는 본질적으로 순수 함수이므로 mock이나 복잡한 설정 없이 단위 테스트가 가능합니다. 각 yield된 이벤트를 순차적으로 검증할 수 있어 테스트 시나리오 작성이 직관적입니다.
아키텍처적 장점
이벤트 흐름의 투명성 Generator의 lazy evaluation과 iterator protocol을 활용하면 이벤트 스트림을 명시적으로 제어할 수 있습니다. 이는 Redux-Saga의 복잡성 없이도 비동기 흐름을 동기적으로 추론 가능하게 만듭니다.
function* userUpdateFlow(userId, updates) {
yield { type: 'VALIDATION_START' };
const validationResult = yield* validateUser(updates);
if (!validationResult.isValid) {
yield { type: 'VALIDATION_ERROR', errors: validationResult.errors };
return;
}
yield { type: 'UPDATE_START', userId };
const result = yield* apiCall(updateUser, { userId, updates });
yield { type: 'UPDATE_COMPLETE', result };
}
런타임 유연성 UI 계층에서 iterator를 통해 필요한 이벤트만 선택적으로 구독할 수 있어, 컴포넌트별로 다른 관심사를 가질 수 있습니다.
잠재적 한계점
복잡성의 이동 Generator 자체는 단순하지만, 이를 UI와 연결하는 런타임 계층의 복잡성이 증가할 수 있습니다. 특히 에러 처리와 예외 상황에서의 상태 동기화가 까다로울 수 있습니다.
생태계 통합 기존 React/Vue 생태계의 도구들과의 호환성 문제가 발생할 수 있습니다. DevTools 지원이나 hot reloading 등의 DX 측면에서 추가 작업이 필요할 것입니다.
메모리 관리 Long-running generator의 경우 메모리 누수 가능성과 가비지 컬렉션 타이밍을 주의깊게 관리해야 합니다.
Redux-Saga와의 차별점
Redux-Saga와 달리 이 패턴은:
- 글로벌 스토어 의존성 없이 로컬 상태 관리 가능
- UI 컴포넌트 레벨에서 직접적인 이벤트 구독
- 더 경량화된 런타임 오버헤드
구현 고려사항
타입 안정성 TypeScript 환경에서 generator의 yield 타입을 정확하게 추론하도록 타입 설계가 중요합니다.
에러 경계 Generator 내부에서 발생하는 에러가 전체 애플리케이션에 전파되지 않도록 적절한 에러 바운더리 설계가 필요합니다.
성능 최적화 이벤트 필터링과 바인딩 과정에서 불필요한 렌더링을 방지하는 최적화 전략이 필요합니다.
전반적 평가
이 패턴은 혁신적이고 이론적으로 견고한 접근법입니다. 특히 복잡한 도메인 로직을 가진 엔터프라이즈 애플리케이션에서 큰 가치를 발휘할 수 있습니다.
다만 팀의 JavaScript generator 숙련도와 유지보수 관점에서의 트레이드오프를 신중히 고려해야 합니다. 프로토타입을 통해 실제 사용 시나리오에서의 DX와 성능을 검증하는 것을 권장합니다.
이 패턴이 성공적으로 적용된다면, 프론트엔드 아키텍처에서 새로운 패러다임을 제시할 수 있을 것으로 보입니다.
Message 3 from User
react를 주로 사용하고 react runtime binding을 사용한다면 어떤모습이 될지 그려줘봐
Message 4 from QueryPie
React에서 generator 기반 도메인 업데이트 로직을 사용하는 구체적인 모습을 보여드리겠습니다.
핵심 아키텍처 구조
React 환경에서 generator 기반 도메인 모델링을 구현한다면 다음과 같은 모습이 될 것입니다.
핵심 아키텍처
도메인 Generator 정의
// domain/userActions.js
function* updateUserProfile(userId, updates) {
yield { type: 'VALIDATION_START', timestamp: Date.now() };
const validation = yield* validateUserData(updates);
if (!validation.isValid) {
yield { type: 'VALIDATION_ERROR', errors: validation.errors };
return { success: false, errors: validation.errors };
}
yield { type: 'UPLOAD_AVATAR_START' };
if (updates.avatar) {
const uploadResult = yield* uploadAvatar(updates.avatar);
updates.avatarUrl = uploadResult.url;
}
yield { type: 'API_CALL_START', endpoint: 'updateUser' };
const result = yield* apiCall(() => updateUser(userId, updates));
if (result.success) {
yield { type: 'UPDATE_SUCCESS', user: result.data };
yield { type: 'SHOW_TOAST', message: '프로필이 업데이트되었습니다' };
} else {
yield { type: 'UPDATE_ERROR', error: result.error };
}
return result;
}
React Runtime Binding - useGenerator Hook
// hooks/useGenerator.js
import { useState, useCallback, useRef, useEffect } from 'react';
function useGenerator(generatorFn, deps = []) {
const [state, setState] = useState({
isRunning: false,
events: [],
result: null,
error: null
});
const generatorRef = useRef(null);
const eventHandlersRef = useRef(new Map());
const execute = useCallback(async (...args) => {
setState(prev => ({ ...prev, isRunning: true, events: [] }));
try {
const generator = generatorFn(...args);
generatorRef.current = generator;
let result = generator.next();
while (!result.done) {
const event = result.value;
// 이벤트 스트림에 추가
setState(prev => ({
...prev,
events: [...prev.events, event]
}));
// 등록된 이벤트 핸들러 실행
const handler = eventHandlersRef.current.get(event.type);
if (handler) {
await handler(event);
}
// 비동기 작업 처리를 위한 로직
if (event.type.includes('API_CALL') || event.type.includes('UPLOAD')) {
// 실제 비동기 작업 수행 후 결과를 generator에 전달
const asyncResult = await performAsyncOperation(event);
result = generator.next(asyncResult);
} else {
result = generator.next();
}
}
setState(prev => ({
...prev,
isRunning: false,
result: result.value
}));
} catch (error) {
setState(prev => ({
...prev,
isRunning: false,
error
}));
}
}, deps);
const on = useCallback((eventType, handler) => {
eventHandlersRef.current.set(eventType, handler);
}, []);
const off = useCallback((eventType) => {
eventHandlersRef.current.delete(eventType);
}, []);
return {
execute,
on,
off,
...state
};
}
컴포넌트 사용 예시
// components/UserProfileForm.jsx
import { useGenerator } from '../hooks/useGenerator';
import { updateUserProfile } from '../domain/userActions';
function UserProfileForm({ userId }) {
const [formData, setFormData] = useState({});
const [toast, setToast] = useState(null);
const [validationErrors, setValidationErrors] = useState({});
const [uploadProgress, setUploadProgress] = useState(0);
const userUpdate = useGenerator(updateUserProfile, [userId]);
// 이벤트 핸들러 등록
useEffect(() => {
userUpdate.on('VALIDATION_ERROR', (event) => {
setValidationErrors(event.errors);
});
userUpdate.on('UPLOAD_AVATAR_START', () => {
setUploadProgress(0);
});
userUpdate.on('SHOW_TOAST', (event) => {
setToast(event.message);
});
userUpdate.on('UPDATE_SUCCESS', (event) => {
setFormData(event.user);
setValidationErrors({});
});
return () => {
userUpdate.off('VALIDATION_ERROR');
userUpdate.off('UPLOAD_AVATAR_START');
userUpdate.off('SHOW_TOAST');
userUpdate.off('UPDATE_SUCCESS');
};
}, [userUpdate]);
const handleSubmit = async (e) => {
e.preventDefault();
await userUpdate.execute(userId, formData);
};
return (
<form onSubmit={handleSubmit}>
{/* 폼 필드들 */}
{validationErrors.name && (
<ErrorMessage>{validationErrors.name}</ErrorMessage>
)}
{userUpdate.isRunning && <LoadingSpinner />}
{uploadProgress > 0 && (
<ProgressBar progress={uploadProgress} />
)}
<button type="submit" disabled={userUpdate.isRunning}>
업데이트
</button>
{toast && <Toast message={toast} onClose={() => setToast(null)} />}
</form>
);
}
고급 패턴들
이벤트 필터링과 조합
// hooks/useEventStream.js
function useEventStream(generator, eventFilter) {
const [filteredEvents, setFilteredEvents] = useState([]);
useEffect(() => {
const filtered = generator.events.filter(eventFilter);
setFilteredEvents(filtered);
}, [generator.events, eventFilter]);
return filteredEvents;
}
// 사용 예시
function ProgressIndicator({ generator }) {
const progressEvents = useEventStream(
generator,
event => event.type.endsWith('_START') || event.type.endsWith('_COMPLETE')
);
return <StepProgress steps={progressEvents} />;
}
에러 경계와 복구
// components/GeneratorErrorBoundary.jsx
class GeneratorErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Generator 실행 중 발생한 에러 로깅
console.error('Generator execution error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <ErrorFallback error={this.state.error} retry={this.props.retry} />;
}
return this.props.children;
}
}
테스트 친화적 구조
// __tests__/userActions.test.js
import { updateUserProfile } from '../domain/userActions';
describe('updateUserProfile', () => {
it('should emit validation events before API call', () => {
const generator = updateUserProfile('user123', { name: 'John' });
const step1 = generator.next();
expect(step1.value).toEqual({
type: 'VALIDATION_START',
timestamp: expect.any(Number)
});
const step2 = generator.next({ isValid: true });
expect(step2.value.type).toBe('API_CALL_START');
});
});
주요 이점
명확한 관심사 분리
- 도메인 로직은 generator에서 순수하게 관리
- UI는 이벤트 구독을 통해 반응적으로 업데이트
- 테스트는 각 계층에서 독립적으로 수행
향상된 개발자 경험
- 이벤트 흐름이 코드로 명시적으로 표현
- Hot reloading과 개발 도구 지원 가능
- 타입스크립트와 자연스럽게 통합
유연한 UI 바인딩
- 컴포넌트별로 필요한 이벤트만 구독
- 동일한 도메인 로직을 다양한 UI에서 재사용
- 런타임에 이벤트 핸들러 동적 변경 가능
이러한 구조는 복잡한 비즈니스 로직과 다양한 UI 요구사항을 효과적으로 분리하면서도, React의 선언적 패러다임과 자연스럽게 어우러집니다.
Message 5 from User
복잡한 인증 전략과 플로우를 표현하는 예시도 보여줘
Message 6 from QueryPie
복잡한 인증 시나리오를 generator로 모델링하면 다단계 인증 플로우를 매우 직관적으로 표현할 수 있습니다. 실제 엔터프라이즈급 인증 시스템의 예시를 보여드리겠습니다. 복잡한 인증 전략을 generator로 표현하면 다음과 같이 구현할 수 있습니다.
도메인 Generator - 인증 플로우
// domain/authFlow.js
function* authenticateUser(credentials, context = {}) {
yield {
type: 'AUTH_START',
timestamp: Date.now(),
context: { deviceId: context.deviceId, userAgent: context.userAgent }
};
// 1. 기본 자격 증명 검증
yield { type: 'CREDENTIAL_VALIDATION_START' };
const credentialResult = yield* validateCredentials(credentials);
if (!credentialResult.isValid) {
yield {
type: 'CREDENTIAL_VALIDATION_FAILED',
attempts: credentialResult.attempts,
isLocked: credentialResult.attempts >= 5
};
if (credentialResult.attempts >= 5) {
yield* handleAccountLockout(credentials.email);
return { success: false, reason: 'account_locked' };
}
return { success: false, reason: 'invalid_credentials' };
}
const user = credentialResult.user;
yield { type: 'CREDENTIAL_VALIDATION_SUCCESS', user: { id: user.id, email: user.email } };
// 2. 리스크 분석
yield { type: 'RISK_ANALYSIS_START' };
const riskScore = yield* analyzeLoginRisk(user, context);
yield { type: 'RISK_ANALYSIS_COMPLETE', riskScore };
// 3. MFA 요구사항 결정
const mfaRequired = user.mfaEnabled || riskScore.isHigh;
if (mfaRequired) {
const mfaResult = yield* handleMFAFlow(user, riskScore);
if (!mfaResult.success) {
return mfaResult;
}
}
// 4. 디바이스 신뢰성 검증
yield { type: 'DEVICE_TRUST_CHECK_START' };
const deviceTrust = yield* checkDeviceTrust(context.deviceId, user.id);
if (!deviceTrust.isTrusted) {
const deviceResult = yield* handleDeviceVerification(user, context);
if (!deviceResult.success) {
return deviceResult;
}
}
// 5. 세션 토큰 발급
yield { type: 'TOKEN_GENERATION_START' };
const tokenResult = yield* generateSessionToken(user, {
deviceId: context.deviceId,
riskScore: riskScore.score,
mfaCompleted: mfaRequired
});
yield {
type: 'AUTH_SUCCESS',
user: user,
token: tokenResult.token,
expiresAt: tokenResult.expiresAt,
sessionId: tokenResult.sessionId
};
return {
success: true,
user,
token: tokenResult.token,
sessionId: tokenResult.sessionId
};
}
// MFA 플로우 서브 제너레이터
function* handleMFAFlow(user, riskScore) {
yield { type: 'MFA_REQUIRED', availableMethods: user.mfaMethods };
// 리스크 레벨에 따른 MFA 방법 제한
const allowedMethods = riskScore.isHigh
? user.mfaMethods.filter(m => m.security === 'high')
: user.mfaMethods;
yield { type: 'MFA_METHODS_FILTERED', allowedMethods };
// 사용자 방법 선택 대기 (UI에서 선택할 때까지)
const selectedMethod = yield {
type: 'MFA_METHOD_SELECTION_REQUIRED',
methods: allowedMethods
};
return yield* executeMFAMethod(selectedMethod, user);
}
function* executeMFAMethod(method, user) {
switch (method.type) {
case 'sms':
return yield* handleSMSMFA(method, user);
case 'email':
return yield* handleEmailMFA(method, user);
case 'totp':
return yield* handleTOTPMFA(method, user);
case 'biometric':
return yield* handleBiometricMFA(method, user);
default:
throw new Error(`Unsupported MFA method: ${method.type}`);
}
}
function* handleSMSMFA(method, user) {
yield { type: 'SMS_MFA_START', phoneNumber: method.phoneNumber };
const code = Math.random().toString().substr(2, 6);
const sendResult = yield* sendSMS(method.phoneNumber, code);
if (!sendResult.success) {
yield { type: 'SMS_SEND_FAILED', error: sendResult.error };
return { success: false, reason: 'sms_send_failed' };
}
yield { type: 'SMS_SENT', messageId: sendResult.messageId };
// 코드 입력 대기 (최대 5분)
const userCode = yield {
type: 'SMS_CODE_INPUT_REQUIRED',
expiresAt: Date.now() + 5 * 60 * 1000
};
const isValid = yield* verifySMSCode(code, userCode);
if (isValid) {
yield { type: 'SMS_MFA_SUCCESS' };
return { success: true };
} else {
yield { type: 'SMS_MFA_FAILED' };
return { success: false, reason: 'invalid_sms_code' };
}
}
function* handleTOTPMFA(method, user) {
yield { type: 'TOTP_MFA_START' };
const userCode = yield {
type: 'TOTP_CODE_INPUT_REQUIRED',
instruction: '인증앱의 6자리 코드를 입력하세요'
};
const isValid = yield* verifyTOTPCode(method.secret, userCode);
if (isValid) {
yield { type: 'TOTP_MFA_SUCCESS' };
return { success: true };
} else {
yield { type: 'TOTP_MFA_FAILED' };
return { success: false, reason: 'invalid_totp_code' };
}
}
function* handleDeviceVerification(user, context) {
yield { type: 'DEVICE_VERIFICATION_START' };
const verificationToken = yield* generateDeviceVerificationToken(user.id, context.deviceId);
yield {
type: 'DEVICE_VERIFICATION_EMAIL_SENT',
email: user.email,
token: verificationToken
};
// 이메일 링크 클릭 대기
const verification = yield {
type: 'DEVICE_VERIFICATION_PENDING',
expiresAt: Date.now() + 30 * 60 * 1000 // 30분
};
if (verification.success) {
yield { type: 'DEVICE_TRUSTED', deviceId: context.deviceId };
return { success: true };
} else {
yield { type: 'DEVICE_VERIFICATION_FAILED' };
return { success: false, reason: 'device_verification_failed' };
}
}
React 컴포넌트 - 인증 UI
// components/AuthenticationFlow.jsx
import { useGenerator } from '../hooks/useGenerator';
import { authenticateUser } from '../domain/authFlow';
function AuthenticationFlow() {
const [currentStep, setCurrentStep] = useState('login');
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [mfaCode, setMfaCode] = useState('');
const [selectedMFAMethod, setSelectedMFAMethod] = useState(null);
const [availableMethods, setAvailableMethods] = useState([]);
const [error, setError] = useState(null);
const [riskLevel, setRiskLevel] = useState(null);
const auth = useGenerator(authenticateUser, []);
// 인증 플로우 이벤트 핸들러
useEffect(() => {
auth.on('CREDENTIAL_VALIDATION_START', () => {
setCurrentStep('validating');
setError(null);
});
auth.on('CREDENTIAL_VALIDATION_FAILED', (event) => {
setError(`로그인 실패 (${event.attempts}/5 시도)`);
if (event.isLocked) {
setCurrentStep('locked');
} else {
setCurrentStep('login');
}
});
auth.on('RISK_ANALYSIS_COMPLETE', (event) => {
setRiskLevel(event.riskScore);
});
auth.on('MFA_METHOD_SELECTION_REQUIRED', (event) => {
setAvailableMethods(event.methods);
setCurrentStep('mfa-selection');
});
auth.on('SMS_SENT', () => {
setCurrentStep('sms-verification');
});
auth.on('TOTP_CODE_INPUT_REQUIRED', () => {
setCurrentStep('totp-verification');
});
auth.on('DEVICE_VERIFICATION_PENDING', () => {
setCurrentStep('device-verification');
});
auth.on('AUTH_SUCCESS', (event) => {
setCurrentStep('success');
// 토큰 저장 및 리다이렉트
localStorage.setItem('authToken', event.token);
window.location.href = '/dashboard';
});
return () => {
// 이벤트 핸들러 정리
auth.off('CREDENTIAL_VALIDATION_START');
auth.off('CREDENTIAL_VALIDATION_FAILED');
// ... 기타 핸들러들
};
}, [auth]);
const handleLogin = async (e) => {
e.preventDefault();
const context = {
deviceId: getDeviceId(),
userAgent: navigator.userAgent,
ipAddress: await getClientIP()
};
await auth.execute(credentials, context);
};
const handleMFAMethodSelect = (method) => {
setSelectedMFAMethod(method);
// Generator에게 선택된 방법 전달
auth.continue(method);
};
const handleMFACodeSubmit = (code) => {
auth.continue(code);
};
const renderCurrentStep = () => {
switch (currentStep) {
case 'login':
return (
<LoginForm
credentials={credentials}
onChange={setCredentials}
onSubmit={handleLogin}
error={error}
isLoading={auth.isRunning}
/>
);
case 'validating':
return <LoadingSpinner message="인증 정보를 확인하고 있습니다..." />;
case 'mfa-selection':
return (
<MFAMethodSelector
methods={availableMethods}
riskLevel={riskLevel}
onSelect={handleMFAMethodSelect}
/>
);
case 'sms-verification':
return (
<SMSVerification
onCodeSubmit={handleMFACodeSubmit}
onResend={() => auth.continue('resend')}
/>
);
case 'totp-verification':
return (
<TOTPVerification
onCodeSubmit={handleMFACodeSubmit}
/>
);
case 'device-verification':
return (
<DeviceVerificationPending
onCheckStatus={() => auth.continue('check')}
/>
);
case 'locked':
return <AccountLocked />;
case 'success':
return <AuthSuccess />;
default:
return null;
}
};
return (
<div className="auth-container">
<ProgressIndicator
currentStep={currentStep}
events={auth.events}
riskLevel={riskLevel}
/>
{renderCurrentStep()}
<SecurityInfo riskLevel={riskLevel} />
</div>
);
}
고급 기능 - 적응형 보안
// domain/adaptiveSecurity.js
function* analyzeLoginRisk(user, context) {
yield { type: 'RISK_FACTORS_ANALYSIS_START' };
const factors = [];
// 지역적 요인
const locationRisk = yield* checkLocationRisk(user.id, context.ipAddress);
if (locationRisk.isUnusual) {
factors.push({ type: 'unusual_location', weight: 0.3 });
}
// 디바이스 요인
const deviceRisk = yield* checkDeviceRisk(context.deviceId, context.userAgent);
if (deviceRisk.isNew) {
factors.push({ type: 'new_device', weight: 0.4 });
}
// 시간적 요인
const timeRisk = yield* checkTimeRisk(user.loginPatterns);
if (timeRisk.isUnusual) {
factors.push({ type: 'unusual_time', weight: 0.2 });
}
// 행동적 요인
const behaviorRisk = yield* checkBehaviorRisk(user.id);
if (behaviorRisk.isAnomalous) {
factors.push({ type: 'anomalous_behavior', weight: 0.5 });
}
const totalScore = factors.reduce((sum, factor) => sum + factor.weight, 0);
const isHigh = totalScore > 0.7;
yield {
type: 'RISK_SCORE_CALCULATED',
factors,
score: totalScore,
isHigh,
recommendedActions: isHigh ? ['require_mfa', 'device_verification'] : []
};
return { score: totalScore, isHigh, factors };
}
테스트 예시
// __tests__/authFlow.test.js
describe('Authentication Flow', () => {
it('should require MFA for high-risk login', async () => {
const mockCredentials = { email: 'test@example.com', password: 'password' };
const mockContext = { deviceId: 'new-device', ipAddress: '1.2.3.4' };
const generator = authenticateUser(mockCredentials, mockContext);
// 기본 인증 성공
let step = generator.next();
expect(step.value.type).toBe('AUTH_START');
step = generator.next();
expect(step.value.type).toBe('CREDENTIAL_VALIDATION_START');
// 유효한 자격증명 시뮬레이션
step = generator.next({ isValid: true, user: mockUser });
expect(step.value.type).toBe('CREDENTIAL_VALIDATION_SUCCESS');
// 높은 리스크 점수 시뮬레이션
step = generator.next();
expect(step.value.type).toBe('RISK_ANALYSIS_START');
step = generator.next({ score: 0.8, isHigh: true });
expect(step.value.type).toBe('MFA_REQUIRED');
});
});
이러한 구조의 핵심 이점은:
복잡한 플로우의 명확한 표현
- 각 인증 단계가 명시적으로 드러남
- 조건부 로직과 분기가 자연스럽게 표현됨
유연한 UI 적응
- 동일한 도메인 로직으로 다양한 UI 시나리오 지원
- 실시간 보안 요구사항 변경에 UI가 반응적으로 대응
테스트 가능한 보안 로직
- 복잡한 인증 시나리오를 단위 테스트로 검증
- Mock 없이도 각 단계별 테스트 가능
확장 가능한 아키텍처
- 새로운 MFA 방법이나 보안 정책 쉽게 추가
- 기존 플로우를 깨뜨리지 않고 기능 확장
Message 7 from User
인증은 중간에 취소 플로우도 필요한데, 취소는 어떻게 표현할수있을까?
Message 8 from QueryPie
인증 취소 플로우를 generator에서 표현하는 것은 매우 중요한 요구사항입니다. 몇 가지 패턴으로 구현할 수 있습니다. 인증 플로우에서 취소 기능을 구현하는 방법은 여러 가지가 있습니다. Generator의 특성을 활용해서 우아하게 처리할 수 있습니다.
1. 취소 가능한 Generator 패턴
핵심 아이디어: Generator에 취소 신호를 전달하는 방식
// hooks/useCancellableGenerator.js
import { useState, useCallback, useRef } from 'react';
function useCancellableGenerator(generatorFn, deps = []) {
const [state, setState] = useState({
isRunning: false,
events: [],
result: null,
error: null,
isCancelled: false
});
const generatorRef = useRef(null);
const cancelTokenRef = useRef({ isCancelled: false });
const eventHandlersRef = useRef(new Map());
const execute = useCallback(async (...args) => {
setState(prev => ({
...prev,
isRunning: true,
events: [],
isCancelled: false,
error: null
}));
// 새로운 취소 토큰 생성
cancelTokenRef.current = { isCancelled: false };
try {
const generator = generatorFn(...args, cancelTokenRef.current);
generatorRef.current = generator;
let result = generator.next();
while (!result.done && !cancelTokenRef.current.isCancelled) {
const event = result.value;
setState(prev => ({
...prev,
events: [...prev.events, event]
}));
// 취소 체크 이벤트 특별 처리
if (event.type === 'CANCELLATION_POINT') {
// UI에서 취소 가능한 지점임을 알림
const handler = eventHandlersRef.current.get('CANCELLATION_POINT');
if (handler) {
await handler(event);
}
}
// 비동기 작업 중 취소 체크
if (event.requiresAsync) {
const asyncResult = await performCancellableAsyncOperation(
event,
cancelTokenRef.current
);
if (cancelTokenRef.current.isCancelled) {
break;
}
result = generator.next(asyncResult);
} else {
result = generator.next();
}
}
if (cancelTokenRef.current.isCancelled) {
setState(prev => ({
...prev,
isRunning: false,
isCancelled: true
}));
} else {
setState(prev => ({
...prev,
isRunning: false,
result: result.value
}));
}
} catch (error) {
setState(prev => ({
...prev,
isRunning: false,
error
}));
}
}, deps);
const cancel = useCallback(() => {
if (cancelTokenRef.current) {
cancelTokenRef.current.isCancelled = true;
// Generator에 취소 신호 전송
if (generatorRef.current) {
try {
generatorRef.current.throw(new CancellationError('User cancelled'));
} catch (e) {
// Generator가 이미 종료된 경우 무시
}
}
}
}, []);
const on = useCallback((eventType, handler) => {
eventHandlersRef.current.set(eventType, handler);
}, []);
return {
execute,
cancel,
on,
...state
};
}
// 취소 에러 클래스
class CancellationError extends Error {
constructor(message) {
super(message);
this.name = 'CancellationError';
this.isCancellation = true;
}
}
2. 취소 가능한 인증 플로우
// domain/cancellableAuthFlow.js
function* authenticateUser(credentials, context, cancelToken) {
try {
yield { type: 'AUTH_START', timestamp: Date.now() };
// 취소 가능한 자격 증명 검증
yield { type: 'CREDENTIAL_VALIDATION_START' };
yield { type: 'CANCELLATION_POINT', step: 'credential_validation' };
const credentialResult = yield* validateCredentials(credentials, cancelToken);
if (!credentialResult.isValid) {
yield { type: 'CREDENTIAL_VALIDATION_FAILED' };
return { success: false, reason: 'invalid_credentials' };
}
const user = credentialResult.user;
yield { type: 'CREDENTIAL_VALIDATION_SUCCESS', user };
// 리스크 분석 (취소 가능)
yield { type: 'RISK_ANALYSIS_START' };
yield { type: 'CANCELLATION_POINT', step: 'risk_analysis' };
const riskScore = yield* analyzeLoginRisk(user, context, cancelToken);
yield { type: 'RISK_ANALYSIS_COMPLETE', riskScore };
// MFA 플로우 (취소 가능)
if (user.mfaEnabled || riskScore.isHigh) {
const mfaResult = yield* handleCancellableMFAFlow(user, riskScore, cancelToken);
if (!mfaResult.success) {
return mfaResult;
}
}
// 토큰 생성
yield { type: 'TOKEN_GENERATION_START' };
const tokenResult = yield* generateSessionToken(user, context, cancelToken);
yield { type: 'AUTH_SUCCESS', user, token: tokenResult.token };
return { success: true, user, token: tokenResult.token };
} catch (error) {
if (error.isCancellation) {
yield { type: 'AUTH_CANCELLED', step: getCurrentStep(), timestamp: Date.now() };
yield* cleanup(credentials, context);
return { success: false, reason: 'cancelled' };
}
throw error;
}
}
function* handleCancellableMFAFlow(user, riskScore, cancelToken) {
try {
yield { type: 'MFA_REQUIRED', availableMethods: user.mfaMethods };
yield { type: 'CANCELLATION_POINT', step: 'mfa_method_selection' };
const selectedMethod = yield {
type: 'MFA_METHOD_SELECTION_REQUIRED',
methods: user.mfaMethods,
cancellable: true
};
// 사용자가 취소를 선택한 경우
if (selectedMethod === 'CANCEL') {
yield { type: 'MFA_CANCELLED_BY_USER' };
return { success: false, reason: 'mfa_cancelled' };
}
return yield* executeCancellableMFAMethod(selectedMethod, user, cancelToken);
} catch (error) {
if (error.isCancellation) {
yield { type: 'MFA_CANCELLED', timestamp: Date.now() };
yield* cleanupMFAResources(user);
throw error; // 상위로 전파
}
throw error;
}
}
function* executeCancellableMFAMethod(method, user, cancelToken) {
switch (method.type) {
case 'sms':
return yield* handleCancellableSMSMFA(method, user, cancelToken);
case 'email':
return yield* handleCancellableEmailMFA(method, user, cancelToken);
default:
throw new Error(`Unsupported MFA method: ${method.type}`);
}
}
function* handleCancellableSMSMFA(method, user, cancelToken) {
try {
yield { type: 'SMS_MFA_START', phoneNumber: method.phoneNumber };
// SMS 전송 중 취소 가능
yield { type: 'CANCELLATION_POINT', step: 'sms_sending' };
const code = Math.random().toString().substr(2, 6);
const sendResult = yield* sendSMS(method.phoneNumber, code, cancelToken);
if (!sendResult.success) {
return { success: false, reason: 'sms_send_failed' };
}
yield { type: 'SMS_SENT', messageId: sendResult.messageId };
// 코드 입력 대기 중 취소 가능
yield { type: 'CANCELLATION_POINT', step: 'sms_code_input' };
const userCode = yield {
type: 'SMS_CODE_INPUT_REQUIRED',
expiresAt: Date.now() + 5 * 60 * 1000,
cancellable: true
};
// 사용자가 취소를 선택한 경우
if (userCode === 'CANCEL') {
yield { type: 'SMS_MFA_CANCELLED_BY_USER' };
return { success: false, reason: 'sms_mfa_cancelled' };
}
const isValid = yield* verifySMSCode(code, userCode, cancelToken);
if (isValid) {
yield { type: 'SMS_MFA_SUCCESS' };
return { success: true };
} else {
yield { type: 'SMS_MFA_FAILED' };
return { success: false, reason: 'invalid_sms_code' };
}
} catch (error) {
if (error.isCancellation) {
yield { type: 'SMS_MFA_CANCELLED' };
yield* cleanupSMSResources(method.phoneNumber);
throw error;
}
throw error;
}
}
// 리소스 정리 함수
function* cleanup(credentials, context) {
yield { type: 'CLEANUP_START' };
// 진행 중인 API 호출 취소
yield* cancelPendingApiCalls(context.sessionId);
// 임시 토큰 무효화
yield* invalidateTemporaryTokens(credentials.email);
// 감사 로그 기록
yield* logAuthCancellation(credentials.email, context);
yield { type: 'CLEANUP_COMPLETE' };
}
3. React 컴포넌트 - 취소 기능 포함
// components/CancellableAuthFlow.jsx
function CancellableAuthFlow() {
const [currentStep, setCurrentStep] = useState('login');
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [cancellationReason, setCancellationReason] = useState(null);
const auth = useCancellableGenerator(authenticateUser, []);
useEffect(() => {
auth.on('CANCELLATION_POINT', (event) => {
// UI에 취소 버튼 활성화 신호
setCurrentStep(prev => ({ ...prev, cancellable: true, step: event.step }));
});
auth.on('AUTH_CANCELLED', (event) => {
setCancellationReason(`인증이 ${event.step} 단계에서 취소되었습니다`);
setCurrentStep('cancelled');
});
auth.on('MFA_METHOD_SELECTION_REQUIRED', (event) => {
setCurrentStep('mfa-selection');
});
auth.on('SMS_CODE_INPUT_REQUIRED', (event) => {
setCurrentStep('sms-input');
});
// ... 기타 이벤트 핸들러
}, [auth]);
const handleCancel = () => {
if (auth.isRunning) {
setShowCancelConfirm(true);
}
};
const confirmCancel = () => {
auth.cancel();
setShowCancelConfirm(false);
};
const handleMFACancel = () => {
// MFA 단계에서 직접 취소
auth.continue('CANCEL');
};
const renderCurrentStep = () => {
switch (currentStep) {
case 'login':
return (
<LoginForm
credentials={credentials}
onChange={setCredentials}
onSubmit={handleLogin}
onCancel={null} // 로그인 단계에서는 취소 불가
isLoading={auth.isRunning}
/>
);
case 'mfa-selection':
return (
<MFAMethodSelector
methods={availableMethods}
onSelect={handleMFAMethodSelect}
onCancel={handleMFACancel} // MFA 선택 취소
showCancel={true}
/>
);
case 'sms-input':
return (
<SMSVerification
onCodeSubmit={handleMFACodeSubmit}
onCancel={handleMFACancel} // SMS 입력 취소
onResend={() => auth.continue('resend')}
showCancel={true}
/>
);
case 'cancelled':
return (
<CancelledState
reason={cancellationReason}
onRestart={() => window.location.reload()}
/>
);
default:
return null;
}
};
return (
<div className="auth-container">
{/* 글로벌 취소 버튼 */}
{auth.isRunning && (
<button
className="cancel-button global"
onClick={handleCancel}
disabled={!auth.isRunning}
>
인증 취소
</button>
)}
{renderCurrentStep()}
{/* 취소 확인 모달 */}
{showCancelConfirm && (
<ConfirmationModal
title="인증 취소"
message="정말로 인증을 취소하시겠습니까? 진행된 내용이 모두 삭제됩니다."
onConfirm={confirmCancel}
onCancel={() => setShowCancelConfirm(false)}
/>
)}
</div>
);
}
4. 고급 취소 패턴들
시간 기반 자동 취소
function* withTimeout(generator, timeoutMs) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new CancellationError('Timeout')), timeoutMs);
});
try {
yield { type: 'TIMEOUT_SET', timeout: timeoutMs };
return yield* generator;
} catch (error) {
if (error.isCancellation) {
yield { type: 'TIMEOUT_CANCELLED' };
}
throw error;
}
}
// 사용 예시
function* authenticateWithTimeout(credentials, context, cancelToken) {
return yield* withTimeout(
authenticateUser(credentials, context, cancelToken),
30000 // 30초 타임아웃
);
}
단계별 취소 정책
const CANCELLATION_POLICIES = {
credential_validation: { allowCancel: false, reason: '보안상 중단 불가' },
risk_analysis: { allowCancel: true, cleanup: true },
mfa_method_selection: { allowCancel: true, cleanup: false },
sms_sending: { allowCancel: true, cleanup: true },
sms_code_input: { allowCancel: true, cleanup: false },
token_generation: { allowCancel: false, reason: '토큰 생성 중 중단 불가' }
};
function* checkCancellationPolicy(step) {
const policy = CANCELLATION_POLICIES[step];
if (!policy.allowCancel) {
yield {
type: 'CANCELLATION_BLOCKED',
step,
reason: policy.reason
};
return false;
}
return true;
}
주요 이점
명확한 취소 지점
- Generator의
yield지점에서 자연스럽게 취소 체크 - 각 단계별로 취소 가능 여부를 명시적으로 제어
리소스 정리 보장
try-catch-finally패턴으로 안전한 리소스 정리- 취소 시점에 관계없이 일관된 정리 로직 수행
사용자 경험 향상
- 단계별 취소 옵션 제공
- 취소 확인 및 피드백 제공
- 진행 상황에 따른 적절한 취소 정책 적용
이러한 패턴을 통해 복잡한 인증 플로우에서도 사용자가 언제든지 안전하게 취소할 수 있으면서, 시스템의 일관성을 유지할 수 있습니다.
Message 9 from User
이러한 아이디어와 일치하거나 비슷한 이미 널리 알려진 패턴이나 라이브러리들이 있을까? 없다면 어떤 문제 때문에 없을까?
Message 10 from QueryPie
아주 흥미로운 분석 결과가 나왔습니다. 당신의 아이디어는 기존 패턴들과 놀랍도록 유사한 철학을 공유하면서도, 실제 구현에서는 독특한 접근법을 제시하고 있습니다.
비슷한 기존 패턴들
Redux-Saga 가장 직접적으로 유사한 패턴입니다. Generator 기반으로 비동기 플로우를 관리하며, 테스트 용이성과 명확한 제어 흐름을 제공합니다. 하지만 Redux 생태계에 강하게 결합되어 있고, 모든 상태를 글로벌 스토어에서 관리해야 합니다.
CSP (Communicating Sequential Processes) in JavaScript
js-csp 라이브러리가 이를 구현했습니다. Generator를 사용해 채널 기반 통신을 통해 프로세스 간 메시지를 주고받는 패턴입니다. 당신의 아이디어와 철학적으로 매우 유사하지만, 채널과 메시지 패싱에 중점을 둡니다.
Algebraic Effects (이론적)
React Hooks는 실제로 algebraic effects의 약한 형태입니다. useState나 useEffect가 본질적으로 effect handler 패턴을 모방합니다. Dan Abramov가 언급했듯이, React의 설계 철학에 깊이 영향을 미쳤습니다.
없는 이유들
1. JavaScript 언어의 한계
// 이상적인 algebraic effects (존재하지 않음)
function* idealFlow() {
const user = perform GetUser(id); // 언어 레벨 지원
const validation = perform Validate(user);
return validation;
}
JavaScript는 delimited continuations이나 native algebraic effects를 지원하지 않습니다. Generator는 one-shot continuation이라 multiple resumption이 불가능합니다.
2. 생태계 관성 기존 Redux/MobX 생태계가 강력하게 자리잡고 있어서, 새로운 패러다임 도입에 저항이 있습니다. 특히 대규모 팀에서는 익숙한 패턴을 선호합니다.
3. 복잡성 인식 Generator는 많은 개발자들이 "어려운" 기능으로 인식합니다. async/await가 더 직관적이라고 여겨져서 Generator 기반 패턴이 외면받았습니다.
4. 프레임워크 결합도 React, Vue, Angular 각각의 생태계가 다른 상태 관리 패턴을 선호하므로, 범용적인 generator 기반 패턴이 자리잡기 어려웠습니다.
5. 성능 우려 Generator 기반 패턴은 함수 호출 오버헤드와 메모리 사용량이 더 높을 수 있다는 우려가 있었습니다.
당신 아이디어의 독창성
UI Agnostic한 접근 기존 패턴들은 특정 프레임워크에 결합되어 있는데, 당신의 접근법은 순수한 도메인 로직과 UI 바인딩을 완전히 분리합니다.
Event Modeling의 자연스러운 표현 Redux-Saga는 여전히 action-reducer 패턴을 따르지만, 당신의 방법은 이벤트 자체를 first-class citizen으로 다룹니다.
Fine-grained Reactivity 컴포넌트 레벨에서 필요한 이벤트만 선택적으로 구독하는 패턴은 기존 방법론에서 찾기 어렵습니다.
현재 시점에서의 기회
2024-2025년 트렌드
- Signals (Angular, Solid.js)가 fine-grained reactivity를 주목받게 함
- React Server Components로 인한 클라이언트 상태 관리 재검토
- AI 기반 개발에서 명시적인 플로우가 중요해짐
기술적 성숙도
- ES2022+ features로 Generator 사용성 개선
- TypeScript 고도화로 타입 안전성 확보 가능
- Web Workers, Concurrent Features로 성능 이슈 해결 가능
결론
당신의 아이디어는 이론적으로는 잘 알려진 개념들의 조합이지만, 실용적 구현에서는 상당히 독창적입니다. 기존 패턴들이 해결하지 못한 문제들 - 특히 UI framework 독립성과 세밀한 이벤트 제어 - 을 우아하게 해결할 수 있는 잠재력이 있습니다.
지금이 이런 패턴을 시도해볼 좋은 시점일 수 있습니다. 프론트엔드 생태계가 상태 관리의 복잡성에 대해 다시 고민하고 있고, 새로운 패러다임에 대한 개방성이 높아지고 있기 때문입니다.