PYAN
  • Home
  • Skills
  • Projects
  • Experience
  • Achievements
  • Education
  • Summary
  • Contact

이상헌

Full Stack Developer & Game Programmer

Experience

개발연구원
·(주) 웰비아닷컴·2021.12 – 2023.02

게임의 안티치트 모듈 및 프로그램인 Xigncode3를 개발하는 회사입니다. · C++로 개발된 보안 모듈을 Unity, 안드로이드 플랫폼에 적용 · JNI 프레임워크를 활용한 C++, Java 간 크로스플랫폼 연동 구현 · 언리얼 엔진(UE4) 소스 코드 분석을 통한 글로벌 오프셋 취약점 발견 및 대응방안 제시 · ACTk Obscured Variable 분석 및 메모리 변조 방지 메커니즘 연구 · Windows Powershell을 이용한 개발환경 자동화 스크립트 구축

부회장 및 리버싱 멘토
·국립금오공과대학교 정보 보안 동아리 BOSS·2023.03 – 2024.08

회사를 다니며 습득한 게임 보안 지식을 바탕으로 후배들에게 멘토링을 제공했습니다. · 리버싱 교육 커리큘럼 설계 및 실습 자료 제작 · 디버깅 툴(IDA)을 활용한 정적 분석 멘토링 · 메모리 변조 툴(Cheat Engine 등)을 이용한 동적 분석 멘토링 · 비전공자도 이해할 수 있는 보안 기초 개념 교육 진행 · 내부 CTF 개최 및 동적 분석 관련 문제 제작

학부연구생
·신호처리 및 지능형네트워크 연구실·2023.07 – 2025.02

학부연구생으로서 연구실에서 진행한 다양한 프로젝트에 참여하였습니다. · 프로젝트 내 프론트엔드 개발 · 프로젝트 내 UI/UX 디자인 · 마크다운 에디터 및 수식 에디터 결합 연구


Projects

엔버봇
2022.04 - 현재 서비스 중🔗

+1

Discord 내에서 메이플스토리에 관련된 정보를 사용자에게 제공하는 챗봇입니다. Nexon Open API를 활용하여 실시간 게임 정보, 캐릭터 검색, 길드 정보 등을 제공하고 있습니다. 약 3년 간 서비스 중이며, 현재 약 4,700개의 서버에서 누적 18만 명 이상이 이용하고 있습니다.

팀 구성

1인 개발

담당 역할

챗봇 및 웹 사이트 개발 운영 및 유지보수 일체

Java
JDA
Maven
Nexon Open API
jsoup
REST API
Gson
Linux
JavaScript
FastAPI
React
MUI
axios
도전과제
문제대규모 동시 요청 처리로 인한 응답 지연
해결샤딩을 통한 서버별 스레드 분산, 비동기적 처리 과정을 도입해 각 명령어에 대한 수행 과정이 따로 진행될 수 있도록 처리함. 이 방안을 통해 응답 시간을 1~2초 가량으로 단축시킬 수 있었음.
결과메이플스토리 파트너 선정 및 게임 유저수 급증으로 인한 봇 이용자 급증. 이에 따라 5초 이상의 응답 지연이 생겨 Discord 정책인 3초 이내 응답 요건 충족 실패. 사용자가 명령어를 입력했지만 응답을 받지 못하는 일이 발생함.
문제제한된 서버 리소스에서의 안정성 확보
해결재사용이 가능한 리소스는 캐시로 저장하도록 변경. 크론탭 기반 실시간 메모리 모니터링 시스템 및 자동 재가동 시스템을 구축하여 해결함. 이 방안을 통해 가동률 98% 이상을 달성할 수 있었음. 추가로, 현재는 서버 업그레이드를 진행하여 데이터 마이그레이션을 완료함.
결과1코어 CPU, 1GB 메모리 환경의 서버에서 OOM 에러가 빈발함. 이에 봇 서버가 종료되어 가동률이 낮아지는 문제가 발생함.
문제직관적이지 않은 명령어 입력 방식 개선
해결사용자가 놓치는 부분이 없도록 데이터 입력부 미작성에 대한 피드백을 강화함. 또한 수많은 예외 처리 분기점을 만들어 최대한 많은 상황에서의 에러를 처리할 수 있도록 하여 해결함. 이 방안을 통해 관련 문의사항이 기존 대비 80% 이상 줄어들었음.
결과사용자들이 명령어 사용법을 어려워 함. 이에 따라 관련 문의가 자주 들어오는 문제가 발생함.
문제제한된 UI 내에서의 사용자 경험의 향상
해결최대한 많은 시행착오를 겪어가며 직접 QA 테스트를 진행하고, 어떤 방식으로 데이터를 표현하는 것이 가장 직관적인지 연구함. 표현 방식의 케이스를 3가지 정도 선별해 실 사용자들에게 의견을 구함. 이 방안을 통해 필수적인 정보는 다 담으면서도 UX를 저하시키지 않을 수 있었음.
결과Discord 메시지로만 정보를 표현할 수 있다는 조건 때문에, 너무 많은 정보는 사용자 경험을 해치는 문제가 되었음.
코드 보기 (5)
공지사항 알리미 코드 일부(java)샤딩을 통해 대규모 서버 분산 처리를 진행했습니다.
// 스레드풀 크기별 채널 분할하여 병렬 처리
int threadPoolSize = MESSAGE_THREAD_POOL_SIZE;
int channelsPerThread = (int) Math.ceil((double) activeChannelInfos.size() / threadPoolSize);

// 각 채널별 완료 추적을 위한 카운터
CountDownLatch messageCompletionLatch = new CountDownLatch(activeChannelInfos.size());

// 채널을 스레드별로 분할하여 병렬 처리
for (int i = 0; i < activeChannelInfos.size(); i += channelsPerThread) {
    final List<ChannelInfo> channelShard = activeChannelInfos.subList(startIndex, endIndex);
    
    messageExecutor.submit(() -> {
        // 이 샤드의 채널들에 순차 전송
        for (ChannelInfo channelInfo : channelShard) {
            channel.sendMessageEmbeds(embed).queue(
                success -> messageCompletionLatch.countDown(),
                failure -> {
                    String failureReason = getFailureReason(failure);
                    // 에러 타입별 채널 상태 관리
                    messageCompletionLatch.countDown();
                }
            );
        }
    });
}
캐릭터 정보 명령어 코드 일부(java)Nexon Open API 연동 및 데이터를 파싱하는 코드입니다.
// GetCharacterBasicAPI.java - API 호출 및 JSON 파싱
public static String[] getCharacterInfo(String name) {
    // OCID 획득
    String ocid = GetOcidAPI.getOcid(name);
    if(ocid.equals("error")) return null;
    
    // API 호출 및 데이터 파싱
    String url = NexonOpenAPIConfig.base_url + "character/basic?ocid=" + ocid;
    String jsonStr = fetchDataFromUrl(url);
    
    if(jsonStr.equals("error")) {
        return new String[] { "API is in Error" };
    }
    
    Gson gson = new Gson();
    Map<String, Object> map = gson.fromJson(jsonStr, Map.class);
    
    // 데이터 가공 및 반환
    String worldImageUrl = worldImageUrlTable(map.get("world_name").toString());
    String level = "Lv." + (int)(Double.parseDouble(map.get("character_level").toString())) 
                  + " (" + map.get("character_exp_rate").toString() + "%)";
    String imageUrl = map.get("character_image").toString().replaceAll("https://", "http://");
    
    return new String[] {worldImageUrl, level, job, popular, guild, updated, userName, imageUrl, ocid};
}

private static String fetchDataFromUrl(String url) {
    try {
        Document doc = Jsoup.connect(url)
                .header("x-nxopen-api-key", NexonOpenAPIConfig.API_KEY)
                .ignoreContentType(true).get();
        
        return doc.select("body").text();
    } catch (Exception e) {
        System.out.println("ERROR :: " + url);
        e.printStackTrace();
        return "error";
    }
}
경험치 히스토리 명령어 코드 일부(java)경험치 상승량 제공을 위한 경험치 계산 로직입니다.
// BigDecimal을 사용한 정밀 경험치 계산
private static String upExpCalc(int sLevel, int eLevel, double sPercent, double ePercent) {
    BigDecimal exp = new BigDecimal(0);
    
    for(int i = sLevel; i <= eLevel; i++) {
        if(i == sLevel) {
            BigDecimal tmp = ExpTables.calcExpPercentToDecimal(sLevel, 
                          (Math.round((100.0 - sPercent) * 1000) / 1000.0));
            exp = exp.add(tmp);
        } else if(i == eLevel) {
            BigDecimal tmp = ExpTables.calcExpPercentToDecimal(eLevel, 
                          (Math.round(ePercent * 1000) / 1000.0));
            exp = exp.add(tmp);
        } else {
            exp = exp.add(ExpTables.expTable()[i - 200]);
        }
    }
    
    return exp.setScale(0, RoundingMode.HALF_UP).toString();
}
스타포스 명령어 코드 일부(java)명령어 수신 후 비동기 작업 처리 및 사용자 피드백을 진행하는 코드입니다.
// 슬래시 커맨드 처리 - 스타포스 시뮬레이터
if(e.getName().equals("스타포스")) {
    e.deferReply().queue();
    
    // 파라미터 검증
    if(option1 == null || option2 == null || /* ... 기타 옵션들 */) {
        e.getHook().sendMessageEmbeds(mapleUserTypingFail()).queue();
        return;
    }
    
    // 즉시 대기 메시지 전송 후 백그라운드 처리
    e.getHook().sendMessageEmbeds(mapleStarForceSimulatorWaiting()).queue(hook -> {
        mapleStarForceSimulatorThreading(level, start, end, starCatch, retry, 
                                       preventDestroy, mvpGrade, isPCRoom, eventType, e, hook);
        System.out.println("Waiting Message Send Success :: " + Main.getNowTimeOnly());
    });
}

// 비동기 시뮬레이션 처리
void mapleStarForceSimulatorThreading(@params) {
    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            try {
                e.getHook().editMessageEmbedsById(hook.getId(), 
                    mapleNewStarForceSimulator(level, start, end, starCatch, retry, 
                                             preventDestroy, eventType, mvpGrade, isPCRoom)).queue();
            } catch (Exception ex) {
                e.getHook().editMessageEmbedsById(hook.getId(), 
                    mapleStarForceSimulatorFailed()).queue();
                ex.printStackTrace();
            }
        }
    }, 0);
}
스타포스 명령어 임베드 생성 명령어 일부(java)시뮬레이션 이후 유저에게 보내는 메시지를 생성하는 코드 일부입니다.
// 스타포스 시뮬레이션 결과 임베드 생성
MessageEmbed mapleNewStarForceSimulator(@params) {
    MapleNewStarForceSimulator mscs = new MapleNewStarForceSimulator(level, start, end, 
                                                                   starCatch, retry, preventDestroy, eventType, mvpGrade, isPCRoom);
    maple.MapleNewStarForceSimulator.ReturnNode n = mscs.calculate();
    
    if (n.getCount() == -1) {
        return mapleStarForceSimulatorErrorMessage();
    }
    
    EmbedBuilder eb = new EmbedBuilder();
    eb.setAuthor("스타포스 시뮬레이터");
    eb.setColor(new Color(255, 192, 203));
    
    // 이벤트별 상세 정보 처리
    String eventTypeText = getEventTypeText(eventType);
    String mvpGradeText = getMvpGradeText(mvpGrade);
    
    if ((n.getDestroyed() == 1) && !retry) {
        eb.setDescription("아쉽지만 아이템이 파괴되었어.");
        eb.addField("파괴 전 마지막 스타포스", n.getEnd() + " 성", false);
    } else {
        eb.setDescription("강화에 성공했어!");
        eb.addField("결과", n.getEnd() + " 성", false);
    }
    
    // 상세 통계 정보 추가
    eb.addField("소모 메소", mesoFormatWithUnit(n.getMeso()), false);
    eb.addField("강화 횟수", mesoFormat(n.getCount()) + " 회", true);
    eb.addField("성공 횟수", mesoFormat(n.getSuccess()) + " 회", true);
    eb.addField("실패 횟수", mesoFormat(n.getFail()) + " 회", true);
    eb.addField("파괴 횟수", mesoFormat(n.getDestroyed()) + " 회", true);
    
    return eb.build();
}
한국노총대구지역본부 웹 사이트
2025.03 - 2025.06🔗

한국노총 대구지부의 공식 웹 사이트입니다. 웹 사이트 내 수강 신청 기능을 주로 하여, 전체적인 웹 사이트 개발 외주를 진행했습니다. 개발 주요 기능으로는 수강 신청 시스템과 관리자 시스템의 프론트엔드와 백엔드 전체를 개발하였습니다.

팀 구성

1인 개발

담당 역할

프로젝트 설계 프론트엔드 개발 백엔드 개발

React
JavaScript
Python
FastAPI
MongoDB
PHP
Linux
도전과제
문제급변하는 클라이언트 요구사항 대응
해결클라이언트에게 상황을 설명하고, React / FastAPI / MongoDB 기반의 가벼운 풀스택 프로젝트로 변경을 요청함. 프로젝트 설계 내역과 함께 정보를 공유하며 클라이언트를 설득해 개발에 착수할 수 있었음.
결과초기 계약은 Cafe24 기반 단순 수강신청이었으나, 실제 내용은 Cafe24 시스템으로 구현이 불가능했음. 이에 따라 기능을 완성하기 위해 풀스택 개발로 전환해야하는 문제가 발생함.
문제개발 중 지속적인 기능 확장 요청
해결기능별 개발 소요시간과 개발 우선순위를 클라이언트와 공유하며 전체 일정에 끼치는 영향을 투명하게 공개해 현실적인 일정을 조율함. 추가가 확정된 기능은 AI와 Figma를 활용해 목업을 제작하고, 이를 공유하여 기능 개발을 조율할 수 있었음.
결과수강신청 시스템에서 시작하여, 이를 관리할 관리자 페이지의 추가, 관리자 페이지 내 회원 관리 기능 추가, 팝업 관리 기능 추가, 통계 조회 기능 추가 등 점진적으로 기능 확장을 요청하는 문제가 있었음.
문제기존 PHP 시스템과의 연동 복잡성
해결iframe 통신 코드를 PHP 및 React 프로젝트에 구현하였고, 이를 통해 세션을 공유하는 매커니즘을 구축하여 해결함.
결과Cafe24라는 특성 상 PHP 코드 기반의 시스템과 새로 개발한 React 앱 간 데이터 연동이 필요한 문제가 발생.
문제실무 환경 구축 및 배포 최적화
해결Ubuntu 서버 환경에서 Nginx를 통해 프론트와 api 서버를 분리하고, certbot을 이용해 SSL 인증서를 적용함.
결과HTTPS 설정, 자동 인증 갱신, Nginx 트래픽 라우팅, 보안 설정 등 실제 서비스 환경을 구성하는 데에 어려움이 있었음.
문제짧은 개발 기간 내 고품질 결과물 요구
해결기능별 우선순위를 정리해 클라이언트와 공유하고, 각 컴포넌트 및 기능을 모두 분리하여 개발이 완료되는 즉시 클라이언트에게 요청해 QA를 진행함. 빠른 피드백 반영과 개발을 동시에 진행해 3개월이라는 시간 내에 결과물을 완성할 수 있었음.
결과지속적인 요구사항 변경에도 불구하고 클라이언트의 서비스 오픈 일정으로 인해 개발 기간이 약 2주 단축되어야 하는 상황이 발생함.
코드 보기 (2)
수강신청 로직 내 일부(javascript)동적으로 폼 내용을 검증하여 사용자 경험을 최적화하였습니다.
// 숫자만 입력 가능하도록 하는 함수 (생년월일, 전화번호)
const handleNumericInput = (e, fieldName, maxLength, nextFieldRef) => {
    const { value } = e.target;
    const numericValue = value.replace(/[^0-9]/g, "");

    setFormData((prevState) => ({
        ...prevState,
        [fieldName]: numericValue,
    }));

    // 최대 길이에 도달하면 다음 필드로 포커스 이동
    if (numericValue.length === maxLength && nextFieldRef && nextFieldRef.current) {
        nextFieldRef.current.focus();
    }
};

// 폼 유효성 검사
const validateForm = () => {
    const newErrors = {};
    
    // 차량번호 검증 (특별 처리)
    if (formData.carNumber && formData.carNumber.trim() === "없음") {
        // "없음"은 유효한 값이므로 에러에서 제거
        if (newErrors.carNumber) delete newErrors.carNumber;
    } else if (formData.carNumber && formData.carNumber.trim() !== "") {
        const carNumberRegex = /^[\d]{2,3}[가-힣]{1}[\d]{4}$/;
        if (!carNumberRegex.test(formData.carNumber.replace(/\s+/g, ""))) {
            newErrors.carNumber = "올바른 차량번호 형식이 아닙니다. (예: 12가1234)";
        }
    }
    
    return newErrors;
};
수강신청 승인 페이지 코드 일부(javascript)수강신청 폼과 같은 대용량 데이터 테이블을 최적화하여 UX를 개선하였습니다.
// 상태 우선순위 매핑으로 정렬 최적화
const STATUS_PRIORITY = {
    enrolled: 1,    // 승인대기
    approved: 2,    // 승인완료  
    canceled: 3,    // 승인취소
    complete: 4     // 과정수료
};

// 필터링된 데이터 정렬
const filteredEnrollments = enrollments
    .filter((item) => {
        const nameMatch = enrollmentRequest.applicant_name
            .toLowerCase().includes(nameFilter.toLowerCase());
        const statusMatch = statusFilter === "all" || 
            enrollment.status === getStatusCodeFromFilter(statusFilter);
        const targetAudienceMatch = targetAudienceFilter === "all" || 
            courseSummary.target_audience === targetAudienceFilter;
        
        return nameMatch && statusMatch && targetAudienceMatch;
    })
    .sort((a, b) => {
        // 상태 우선순위에 따라 정렬
        const statusPriorityA = STATUS_PRIORITY[a.enrollment.status] || 999;
        const statusPriorityB = STATUS_PRIORITY[b.enrollment.status] || 999;
        
        if (statusPriorityA !== statusPriorityB) {
            return statusPriorityA - statusPriorityB;
        }
        
        // 같은 상태면 강의 시작일 기준 정렬
        return new Date(a.course_summary.start_date) - new Date(b.course_summary.start_date);
    });
주식Talk
2023.09 - 2023.12

1시간 단위로 뉴스 기사와 주가 등락률을 활용하여, 전일 종가에 비해 당일 종가의 등락률을 예측하는 인공지능 카카오톡 챗봇입니다. Python Flask로 구현한 서버를 Kakao i OpenBuilder에 연결하여 사용자 발화 의도를 분석하여 보다 정밀한 동작이 가능하게끔 설계하였습니다. AI가 생성한 결과를 바탕으로 동적으로 그래프 이미지를 생성하고, 이를 직접 디자인한 카카오톡 메시지에 첨부함으로써 사용자가 정보를 읽는 데에 불편함이 없도록 구현하였습니다.

팀 구성

4인 팀

담당 역할

Kakao i OpenBuilder 내 시나리오 및 스킬 제작 챗봇 통신을 위한 Flask 기반 챗봇 서버 및 콘텐츠 다운로드 서버 구현 주식 분야와 종목의 개체명 인식 및 유사도 분석 기능 구현 동적 그래프 생성 및 카카오톡 내 메시지 디자인 설계 멀티스레딩을 활용해 국내 주식 전 종목에 대한 8개월 분량의 기사 데이터 약 20만 개 이상 크롤링

Kakao i OpenBuilder
Python
Flask
Linux
Java
jsoup
Konlpy
도전과제
문제대용량 데이터 크롤링 및 처리 최적화
해결멀티스레딩을 활용한 병렬 크롤링, 메모이제이션을 통한 중복 크롤링 방지, 효율적인 데이터 파싱 구조를 설계하여 약 2일에 걸쳐 데이터를 수집함.
결과국내 주식 전 종목 대상 8개월 분량 20만 개 이상 기사 데이터를 최대한 빠르게 수집해야 하는 문제가 발생함.
문제제한된 UI에서의 복합 정보 표현
해결단순 수치 및 글로만 표현했을 때보다 이미지가 있을 때 사용자가 쉽게 정보를 받아들임을 인지함. 이에 따라 동적으로 긍/부정도 그래프를 생성하는 기능을 구현함. 관련 기사 데이터는 메시지 외부 버튼으로 분리해 가독성을 향상할 수 있었음.
결과카카오톡 메시지 인터페이스 내에서 여러 정보를 직관적으로 표현하는 데 어려움이 있었음.
문제자연어 처리를 통한 의도 파악 정확도 향상
해결Konlpy를 활용해 형태소를 분석하고 발화 의도를 파악하는 모델에 연결함. 주식 및 종목명 또한 유사도 계산을 통해 모호한 입력을 처리함. 추가로 개체명 인식 기능을 구현해 삼성전자(삼전), KT(케이티)와 같은 줄임말 및 외국어에도 대응할 수 있도록 제작함.
결과사용자가 입력한 종목명이나 분야, 발화 의도를 정확히 인식하지 못하는 경우가 발생함.
문제팀 내 개발 방식 및 우선순위 조율
해결Figma를 통한 시각적 설명, 개발 소요시간 객관적 분석 자료 제시로 합의점을 도출함.
결과AI 기능 향상 우선 vs 병렬 개발에 대한 팀장과의 의견 차이가 발생함.
코드 보기 (1)
메시지 수신부 코드 일부(python)의도 파악 및 발화의 모호함을 해결하기 위해 여러 예외 처리부를 구현하였습니다.
@app.route('/api/message', methods=['POST'])
def message():
    content = request.get_json()
    user_input = content['userRequest']['utterance'].replace("\n", "")
    
    # 의도 파악
    intention_predict = intention_understanding_instance.intentionPrediction(user_input)
    intention_correct = intention_predict in [3, 4, 6]
    
    if not intention_correct:
        return jsonify(makeMessage(data=None, type=4))
    
    # 단어 대체 및 예외 처리
    user_input = alterWords(user_input.replace(" ", "").strip())
    exception_type, exception_word = findException(user_input.replace(" ", "").upper().strip())
    
    # 종목명 및 분야 체크
    if exception_type == -1:
        found_word = checker.check_words(user_input.replace(" ","").upper())
        if found_word is not None:
            try:
                # 분야 데이터 처리
                messageData, stock_count = findSectorData(found_word)
                return jsonify(makeMessage(data=messageData, type=2, count=stock_count))
            except:
                # 종목 데이터 처리
                messageData, news_count = findStockData(found_word)
                return jsonify(makeMessage(data=messageData, type=1, count=news_count))
COCA : 그룹 및 개인 일정 관리 공유 서비스
2024.02 - 2024.06

+1

그룹 및 개인 일정 관리에 도움을 주고, 그룹 내 인원의 빈 일정을 찾아주는 커뮤니티 형식의 공유 협업 캘린더입니다. 직관적인 UI를 설계 및 구현하여 사용자가 이용하기에 어려움이 없는 서비스를 만들고자 하였습니다. 브루트포스와 인터벌 병합 알고리즘의 두 알고리즘을 상황에 맞게 사용하여, 최대한 빠르게 빈 일정을 찾도록 구현하였습니다.

팀 구성

4인 팀

담당 역할

프론트엔드 UI 구현 프론트엔드 Axios 통신 및 데이터 렌더링과 세션 관리 기능 구현 브루트포스와 인터벌 병합 알고리즘을 활용한 빈 일정 찾기 알고리즘 구현 백엔드 CORS 관리 및 컨트롤러 일부 구현

JavaScript
React
Axios
Java
Spring
MySQL
도전과제
문제복잡한 빈 일정 찾기 알고리즘 최적화
해결브루트포스, 인터벌 병합 알고리즘, 이분탐색, 해시 테이블 등 여러 알고리즘을 활용해 프로토타입을 개발하여 속도를 측정함. 상황에 따라 가장 적합한 알고리즘을 택하여 구현함. 이에 단순 날짜 계산은 브루트포스로, 분 단위의 시간 계산에서는 인터벌 병합 알고리즘을 채택해 연산 시간을 10초 이내로 단축함.
결과단순 브루트포스 알고리즘을 활용하는 경우, 여러 사용자의 일정을 종합하여 공통적으로 빈 시간을 찾는 연산이 10분 이상 소요되는 문제가 발생.
문제인증 방식 급변에 따른 소스코드 전면 수정
해결Axios 인터셉터를 제안해 이를 적용, 토큰 자동 관리 시스템을 구현하여 기존 코드를 최대한 보존할 수 있었음.
결과마감 일주일 전 세션 기반의 인증 방식에서 JWT 토큰 인증으로 갑작스러운 변경 요구가 있었음. 충분히 고민해보고 적용해야 할 사항이라는 의견을 제시했지만 팀원의 독단적인 변경으로 인한 문제가 발생함.
문제프론트엔드 - 백엔드 간 데이터 통신 최적화
해결React 내 상태 관리를 useState만 사용하는 것이 아니라 useMemo 또한 적절히 사용하여 정보를 캐시할 수 있도록 하였고, 백엔드 코드 및 서버 정책을 수정해 CORS 에러를 해결할 수 있었음.
결과이미지 서버 및 기타 컨트롤러 내 CORS 이슈, 실시간 일정 업데이트에 대한 렌더링 성능 이슈가 발생함.
문제페이지 이동 시 상태 유지 문제
해결로컬스토리지 및 Redux를 활용하여 상태 관리 시스템을 구축했고, 사용자 경험을 개선할 수 있었음.
결과사용자가 페이지를 이동할 때마다 이전의 작업 내용이 초기화되고 기본값으로 돌아가는 문제가 발생함.
문제팀 내 풀스택 개발자로서의 가교 역할
해결팀 내 유일한 풀스택 개발자로서 소통 창구를 담당해 프론트엔드와 백엔드 각각에서 요구하는 조건이 무엇인지를 취합해 회의를 주도함. 프론트엔드만 구현하는 것이 아니라 필요하다면 Spring 백엔드 컨트롤러 또한 동시에 직접 구현하며 팀 통합을 조율함.
결과개발 진행 중 프론트엔드 - 백엔드 간 소통이 되지 않는 문제가 있었음.
코드 보기 (3)
빈 일정 찾기 알고리즘 함수 일부(java)인터벌 병합 알고리즘을 사용해 성능을 최적화하였습니다.
private List<CommonSchedule> interval(LocalDateTime startTime, LocalDateTime endTime, 
                                    int duration, List<String> members) {
    List<Interval> combined = new ArrayList<>();
    final int timeSlot = 10;
    
    // 모든 멤버의 일정을 수집
    for (String memberId : members) {
        List<PersonalSchedule> personalSchedules = 
            personalScheduleRepository.findPersonalScheduleByDateRange(memberId, startTime, endTime);
        
        if (personalSchedules != null && personalSchedules.size() > 0) {
            for (PersonalSchedule schedule : personalSchedules) {
                combined.add(new Interval(schedule.getStartTime(), schedule.getEndTime()));
            }
        }
    }
    
    // 인터벌 병합으로 중복 제거 및 최적화
    List<Interval> mergeSchedule = IntervalMerge.intervalMerge(combined);
    Collections.sort(mergeSchedule, Comparator.comparing(Interval::getStart));
    
    // 빈 시간 계산
    List<CommonSchedule> resultSchedule = new ArrayList<>();
    LocalDateTime current = startTime;
    
    for (Interval interval : mergeSchedule) {
        while (current.plusMinutes(duration).isBefore(interval.getStart()) || 
               current.plusMinutes(duration).isEqual(interval.getStart())) {
            resultSchedule.add(new CommonSchedule(current, current.plusMinutes(duration)));
            current = current.plusMinutes(timeSlot);
        }
        
        if (current.isBefore(interval.getEnd())) {
            current = interval.getEnd();
        }
    }
    
    return resultSchedule;
}
GroupList 통신 코드 일부(javascript)Axios 인터셉터를 활용한 JWT 토큰 자동 관리 시스템을 구현하였습니다.
// JWT 토큰 자동 갱신 처리
const getGroupList = async (id, navigate) => {
  try {
    const config = {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    };
    const res = await axios.get(
      process.env.REACT_APP_SERVER_URL + `/api/calendar/member/${id}`,
      config
    );
    
    if (res.data.code === 200) {
      return res.data.data;
    } else if (res.data.code === 401 || res.data.data === null) {
      // 토큰 만료 시 자동 갱신 후 재시도
      await refreshAccessToken(navigate);
      return getGroupList(id, navigate);
    } else {
      throw new Error("unknown Error");
    }
  } catch (error) {
    console.error(error);
    Swal.fire({
      position: "center",
      icon: "error", 
      title: "에러!",
      text: "서버와의 통신에 문제가 생겼어요!",
      showConfirmButton: false,
      timer: 1500,
    });
    return [];
  }
};
메인페이지 코드 일부(javascript)로컬스토리지를 활용하여 상태를 관리, UX를 개선할 수 있었습니다.
// 페이지 이동 시 상태 유지
useEffect(() => {
  // 로컬스토리지에서 이전 상태 복원
  const savedState = localStorage.getItem('calendarState');
  if (savedState) {
    const parsedState = JSON.parse(savedState);
    setSelectedDate(parsedState.selectedDate);
    setViewMode(parsedState.viewMode);
  }
}, []);

// 상태 변경 시 자동 저장
useEffect(() => {
  const stateToSave = {
    selectedDate,
    viewMode,
    currentGroup
  };
  localStorage.setItem('calendarState', JSON.stringify(stateToSave));
}, [selectedDate, viewMode, currentGroup]);
제미니 테트리스
2022.09🔗

버츄얼 그룹 RE:REVOLUTION의 팬 게임입니다. 그룹 멤버의 생일을 위한 팬 게임 외주를 맡아 개발했습니다. Unity를 이용해 개발하고, WebGL로 빌드해 웹 상에서 게임을 플레이 할 수 있도록 제작하였습니다. PC뿐 아니라 모바일 환경을 위한 버튼 UI를 추가하여 여러 환경에서도 문제 없이 플레이 할 수 있도록 제작하였습니다.

팀 구성

1인 개발

담당 역할

게임 개발 일체

Unity
C#
WebGL
도전과제
문제크로스 플랫폼 호환성 확보
해결WebGL 빌드를 활용하여 브라우저 기반 플레이 환경을 구축하고, 빌드된 html 파일을 수정하여 반응형 UI를 설계함.
결과PC와 모바일 기기에서 동일한 게임 경험을 제공해야 하는 문제가 발생함.
문제모바일 터치 인터페이스 최적화
해결터치 버튼 UI를 추가로 구현하여 기존 키보드로 조작할 수 있는 기능에 대응함.
결과키보드가 없는 모바일 환경에서도 직관적으로 게임을 조작할 수 있어야 했음.
문제다양한 화면 비율에 대한 블록 좌표 정렬
해결WebGL 컨테이너 크기를 화면 비율에 맞게 동적으로 조정하여 상대적 좌표 시스템을 적용하여 해결함.
결과모바일의 경우, 기종별로 화면 비율이 달라 테트리스 블록 위치가 어긋나는 현상이 있었음.
문제외주 프로젝트 품질 관리
해결클라이언트 피드백을 최대한 빠르게 반영해 반복적으로 개선해나감. 직접 QA 테스트를 진행하며 버그를 최소화 함.
결과1인 개발 및 QA가 존재하지 않는 상황에서의 외주였지만 브랜드 이미지에 맞는 퀄리티가 요구됨.
문제짧은 개발 기간 내 완성도 확보
해결Unity의 기존 에셋을 최대한 활용하고, 핵심 기능 및 메인 게임 로직을 우선 개발한 후 부가 기능을 순차적으로 업데이트 함. 이에 처음엔 노멀 모드 뿐이었지만, 웹이라는 특성 상 배포 후 수정이 가능하여 이지 모드를 추후 추가함.
결과1개월이라는 제한된 시간 내에 완성된 게임을 제작해야 했음.
코드 보기 (1)
게임 로직 코드 일부(csharp)게임 로직 일부 및 레벨 시스템을 구현하였습니다.
// 테트로미노 이동 및 충돌 검사
bool MoveTetromino(Vector3 moveDir, bool isRotate)
{
    Vector3 oldPos = tetrominoNode.transform.position;
    Quaternion oldRot = tetrominoNode.transform.rotation;

    tetrominoNode.transform.position += moveDir;
    if (isRotate) {
        tetrominoNode.transform.rotation *= Quaternion.Euler(0, 0, 90);
    }

    if (!CanMoveTo(tetrominoNode)) {
        // 이동 불가능하면 원래 위치로 복구
        tetrominoNode.transform.position = oldPos;
        tetrominoNode.transform.rotation = oldRot;

        // 아래로 떨어지다가 막힌 경우 보드에 추가
        if ((int)moveDir.y == -1 && (int)moveDir.x == 0 && isRotate == false) {
            AddToBoard(tetrominoNode);
            CheckBoardColumn();
            CreateTetromino();

            // 게임 오버 체크
            if (!CanMoveTo(tetrominoNode)) {
                gameOverPanel.SetActive(true);
            }
        }
        return false;
    }
    return true;
}

// 동적 난이도 조절 시스템
float FallCycleTable()
{
    switch (levelInt) {
        case 1: return isEasyMode ? 2.0f : 1.0f;
        case 2: return isEasyMode ? 1.8f : 0.9f;
        // ... 레벨별 속도 조절
        default: return 0.001f;
    }
}
오뉴런
2023.01 - 2023.08

버츄얼 그룹 RE:REVOLUTION의 팬 게임입니다. 그룹 멤버의 생일을 위한 팬 게임 외주를 맡아 개발했습니다. Unity를 이용해 개발한 쿠키런 형식의 2D 횡 스크롤 달리기 게임입니다.

팀 구성

1인 개발

담당 역할

게임 개발 일체 레벨 디자인 게임 밸런싱

Unity
C#
도전과제
문제Unity 2D 횡스크롤 게임 핵심 매커니즘 구현
해결Unity Animator를 활용한 자연스러운 움직임 및 플로우를 구현하고, 물리 엔진 기반의 점프/슬라이드 시스템을 구현함.
결과쿠키런 스타일의 무한 러닝 게임에서 자연스러운 캐릭터 움직임이 요구됨.
문제게임 밸런스 및 난이도 조절
해결거리별 속도 증가 시스템을 구현하고, 직접 레벨 디자인 및 플레이 테스트를 진행하며 아이템 및 장애물 배치 패턴을 다양화 함. 또한 거리 및 속도, 스테이지에 따른 점수 시스템을 조절해 게임의 밸런스를 맞춤.
결과플레이어가 지루하지 않으면서도 적절한 도전감을 느낄 수 있는 난이도 설계가 요구됨.
코드 보기 (3)
게임매니저 코드 내 일부(csharp)싱글톤 패턴을 활용하여 게임 매니저를 구현하였습니다.
// GameManager.cs - 게임 상태 관리
public class GameManager : MonoBehaviour
{
    private static GameManager _instance;
    private bool gameOver = false;
    private int stage = 0;
    private bool isStageChanged = false;
    
    public static GameManager Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = FindObjectOfType(typeof(GameManager)) as GameManager;
            }
            return _instance;
        }
    }
    
    // 게임 상태 프로퍼티들
    public bool GameOver { get; set; }
    public int Stage { get; set; }
    public bool IsStageChanged { get; set; }
}
장애물 생성 시스템 내 코드 일부(csharp)랜덤으로 패턴을 생성하되, 오브젝트 풀링을 통해 성능을 최적화하였습니다.
// PatternManager.cs - 패턴 기반 장애물 생성
void Update()
{
    switch (GameManager.Instance.Stage)
    {
        case 0: min = 0; max = 4; break;
        case 1: min = 5; max = 9; break;
        case 2: min = 10; max = 13; break;
    }
    
    if (GameManager.Instance.IsLandDestroyed)
    {
        if (GameManager.Instance.IsStageChanged)
        {
            // 스테이지별 시작 패턴 생성
            switch (GameManager.Instance.Stage)
            {
                case 0:
                    GameObject newPattern1 = Instantiate(startPattern1, 
                        new Vector3(18f, 0.1f, 0), Quaternion.identity);
                    break;
                // ... 다른 스테이지들
            }
        }
        else
        {
            // 랜덤 패턴 생성
            GameObject newPattern = Instantiate(
                patternList[Random.Range(min, max)], 
                new Vector3(18f, 0.1f, 0), Quaternion.identity);
        }
        GameManager.Instance.IsLandDestroyed = false;
    }
}
스크롤 컨트롤러 코드 내 일부(csharp)플레이어 캐릭터의 위치 이동 및 스크롤 초과를 방지하기 위해 무한 스크롤 시스템을 구현하였습니다.
// ScrollController.cs - 백그라운드 무한 스크롤
void Update()
{
    if (!GameManager.Instance.GameOver)
    {
        transform.Translate(-mapSpeed * Time.deltaTime, 0, 0);
        
        if (transform.position.x < ifposX)
        {
            transform.Translate(posX, 0, 0); // 위치 리셋으로 무한 스크롤
        }
    }
}
로피시즈 (Rawfish's)
2026.01 - 2026.02

AI 기반 스마트 회의 플랫폼입니다. LiveKit 기반 화상회의 위에 실시간 STT 자막, 회의 요약, 안건 관리, 발언권 관리를 결합해 단일 회의실 안에서 모든 흐름을 처리하도록 설계하였습니다. faster-whisper를 CUDA 환경에서 구동하여 한국어 실시간 전사와 화자 분리, 할루시네이션 차단을 구현하였고, TURN 서버를 자체 구축하여 네트워크 환경에 관계없이 안정적인 연결을 제공하였습니다.

팀 구성

7인 팀

담당 역할

프론트엔드 회의실 UI 및 채팅 기능 구현 STOMP WebSocket 재연결 및 타이머 이벤트 체계 설계 AI STT 파이프라인 안정성 개선 TURN 서버 자체 구축 및 배포 파이프라인 구성

React
Vite
TypeScript
Tailwind
LiveKit
STOMP
Spring Boot
Java
MySQL
Redis
FastAPI
Whisper
Docker
Nginx
Jenkins
OpenVidu
coturn
도전과제
문제OpenVidu 기반 구조의 안정성 한계로 인한 LiveKit 전면 전환
해결초기 OpenVidu 로 구현했던 비디오/오디오 트랙 처리, 화면 공유, 발언권 큐 등 회의실 핵심 로직을 LiveKit 으로 전면 마이그레이션함. LiveKit Service 클래스를 새로 설계하여 트랙 구독 / 발행 / 데이터 채널을 한 곳에서 관리하도록 정리함. 이 방안을 통해 LiveKit Data Channel 위에 회의 내 채팅 기능까지 별도 WebSocket 없이 통합함.
결과OpenVidu 의 라이브러리 한계와 잦은 연결 불안정으로 발표용 시연이 위태로운 상태였음. 비디오 트랙 토글 / 화면공유 / 디바이스 전환 등 복합 동작에서 누수 버그가 반복적으로 발생함.
문제발표 직전 STT 파이프라인 14건 동시 버그 일괄 수정
해결Whisper 의 할루시네이션 패턴을 보수적으로 차단하는 _is_hallucination 휴리스틱을 추가함. 불완전 UTF-8 한국어 토큰 복구 로직, finalize 타이밍 3 초 조정, pending 토큰 4 개 이상 시 강제 flush, per-speaker 텍스트 delta 추출과 같은 안정화를 한 번에 진행함. 이를 통해 STT 의 중복 / 시간역전 / 화자 뒤바뀜 / 시간 밀림 / FFFD 노이즈를 단일 릴리즈에서 종합 정리함.
결과발표 직전 마지막 주말, STT 결과에서 중복 출력, 시간 역전, 화자 뒤바뀜, finalize 누락 등이 동시에 발생함. 할루시네이션도 같이 끼어들어 회의록의 신뢰도가 무너진 상태였음.
문제외부 TURN 서비스 의존을 제거하기 위한 자체 coturn 서버 구축
해결OCI 춘천 리전에 coturn 을 직접 띄우고 turn.pyan.kr 도메인으로 정리함. 3478 (UDP/TCP), 5349 (TLS) 포트를 열고 LiveKit 클라이언트 / 서버 양측 설정을 자체 TURN 으로 전환함. 이 방안을 통해 외부 비용 발생을 끊고, NAT 환경 / 회사망에서도 안정적으로 P2P 우회 연결이 되도록 만듦.
결과metered.ca 외부 TURN 서비스에 의존하던 시기에는 무료 한도 / 트래픽 제한이 시연 안정성에 직접적인 변수였음. 장시간 회의 시 갑작스러운 연결 끊김도 외부 서비스 측 문제로 진단되어, 자체 운영의 필요성이 있었음.
문제새로고침 / 재참여 시 회의 상태 복원의 연쇄 버그
해결회의 시간 동기화에 relativeTime 기반 보정값을 도입하고, recoveredTimeOffset 초기화 순서를 다시 잡음. cleanupMeetingData 함수 선언 순서와 방어 코드를 정리해 새로고침 후 빈 세션 / Ghost 참가자 / 토큰 재발급 흐름이 꼬이지 않도록 처리함. 장치 설정과 카메라 / 마이크 상태도 새로고침 직후 자동 복원되도록 보강함.
결과새로고침 시 회의 시간이 0 으로 초기화되거나, 토큰 없이 회의 URL 에 직접 접근한 사용자가 Ghost 참가자로 남는 문제가 반복적으로 발생함. 게스트 사용자의 회의 재참여 경로에서도 변수 초기화 순서 문제로 STT 가 끊기는 케이스가 있었음.
문제STOMP WebSocket 의 재연결 안정성과 회의 내 채팅 통합
해결@stomp/stompjs 의 reconnectDelay, fatalCloseCodes, onWebSocketClose 훅을 활용해 정상 종료(1000)와 비정상 종료를 구분하도록 정리함. 프로토콜 / 정책 에러는 자동 재연결 루프를 끊고, 일시적 끊김은 1 초 간격으로 최대 3 회 재시도하도록 구성함. 채팅은 별도 채널을 띄우는 대신 LiveKit Data Channel 위에 sendChatMessage 형태로 얹어 한 연결 안에서 처리함.
결과기존에는 새로고침 / 네트워크 변동 시 STOMP 가 무한 재시도 루프에 빠지거나, 정책 에러에서도 끊임없이 재연결을 시도해 서버 부하가 증가했음. 회의 내 채팅을 별도 WebSocket 으로 띄우면 인증 / 재연결 처리가 두 배가 되어 부담이었음.
코드 보기 (4)
STT 할루시네이션 차단 코드 일부(python)Whisper STT 결과에서 명백한 반복 패턴만 보수적으로 차단하는 휴리스틱입니다.
@staticmethod
def _is_hallucination(text: str) -> bool:
    """Whisper 할루시네이션 감지 (보수적): 명백한 반복만 차단"""
    if not text or len(text) < 30:
        return False
    words = text.split()
    if len(words) < 6:
        return False
    counts = Counter(words)
    most_common_word, freq = counts.most_common(1)[0]
    # 2글자 이상 단어가 전체의 70% 이상 → 확실한 할루시네이션
    if len(most_common_word) >= 2 and freq / len(words) >= 0.7:
        return True
    # 연속 4회 이상 같은 단어
    consecutive = 1
    for i in range(1, len(words)):
        if words[i] == words[i - 1] and len(words[i]) >= 2:
            consecutive += 1
            if consecutive >= 4:
                return True
        else:
            consecutive = 1
    return False
LiveKit Data Channel 채팅 코드 일부(javascript)회의 내 채팅을 별도 WebSocket 없이 LiveKit Data Channel 위에 얹어 처리한 코드입니다.
// LiveKit Data Channel 위에 회의 내 채팅을 얹은 코드
async sendChatMessage(message, senderName) {
  if (!this.room || !this.room.localParticipant) {
    throw new Error('Room not connected');
  }

  const payload = {
    type: 'chat',
    message,
    sender: senderName,
    timestamp: Date.now(),
  };

  const data = new TextEncoder().encode(JSON.stringify(payload));
  await this.room.localParticipant.publishData(data, { reliable: true });
}
STOMP WebSocket 재연결 핸들러 코드 일부(javascript)정상 / 비정상 종료를 구분해 재연결 무한루프를 막는 핸들러입니다.
// STOMP 정상/비정상 종료를 구분해 재연결 정책을 제어
onWebSocketClose: (evt) => {
  this.isConnected = false;
  this.subscriptions.clear();
  onDisconnected?.();

  const code = evt?.code;
  if (code && code !== 1000) {
    this._emitError(
      new Error(`WebSocket closed (code=${code}, reason=${evt?.reason || ''})`),
      { step: 'connect:onWebSocketClose', code, reason: evt?.reason },
    );
  }

  // Protocol/policy errors -> stop auto-reconnect to avoid endless loops
  if (code && this.fatalCloseCodes.has(code)) {
    if (this.client) {
      this.client.reconnectDelay = 0;
      this.client.deactivate();
    }
  }
},
Jenkins 배포 파이프라인 코드 일부(javascript)프론트엔드 빌드 / 환경변수 / 도커 배포까지 자동화한 Jenkins 파이프라인 정의입니다.
// Jenkinsfile 프론트엔드 배포 파이프라인
pipeline {
    agent any
    environment {
        DOCKER_IMAGE = "frontend-app"
        DOCKER_TAG = "${BUILD_NUMBER}"
        GITLAB_CREDENTIAL_ID = 'gitlab-credentials'
    }
    stages {
        stage('Checkout') {
            steps {
                git branch: 'FE_dev', credentialsId: "${GITLAB_CREDENTIAL_ID}",
                    url: 'https://lab.ssafy.com/.../S14P11D207.git'
            }
        }
        stage('Create Env') {
            steps {
                dir('frontend') {
                    sh '''
                        echo "VITE_API_BASE_URL=https://i14d207.p.ssafy.io/api/v1" > .env
                        echo "VITE_OPENVIDU_SERVER_URL=wss://i14d207.p.ssafy.io/livekit" >> .env
                        echo "VITE_WS_BASE_URL=wss://i14d207.p.ssafy.io" >> .env
                        echo "VITE_AI_SERVER_URL=wss://ai.flipped.cloud/asr" >> .env
                    '''
                }
            }
        }
        stage('Build & Deploy') {
            steps {
                sh '''
                    docker build -t ${DOCKER_IMAGE}:${DOCKER_TAG} .
                    docker tag ${DOCKER_IMAGE}:${DOCKER_TAG} ${DOCKER_IMAGE}:latest
                    docker stop frontend-app || true
                    docker rm frontend-app || true
                    docker run -d --name frontend-app -p 3000:80 ${DOCKER_IMAGE}:latest
                '''
            }
        }
    }
}
모아 (MOA) + Sketch 영상 파이프라인
2026.03 - 2026.04

+2

소셜 로그인, 계좌 연동, AI 예산 관리, 목표 달성 시각화 영상까지 통합한 핀테크 재무관리 앱입니다. 월 소득을 주차별로 자동 배분하고 목표를 연장하면 남은 기간에 맞춰 재계산되도록 설계하였으며, LangGraph 기반 AI 챗봇이 가맹점 분류와 예산 조언을 담당하도록 구현하였습니다. 목표 달성 시각화 영상은 별도로 설계하고 현재도 직접 운영 중인 자체 영상 생성 파이프라인 서비스 (sketch.pyan.kr) 와 연동하여 MP4, WebM, GIF 형식을 CDN 으로 제공하도록 구성하였습니다.

팀 구성

7인 팀

담당 역할

Flutter 기반 프론트엔드 개발 이미지 → 영상 변환 파이프라인 서비스 단독 설계 및 운영 Jenkins CI/CD 및 Nginx HTTPS 배포 구성 백엔드 sketch 연동 및 FCM 알림 분리 구현

Flutter
Dart
Spring Boot
Java
PostgreSQL
Redis
FastAPI
LangGraph
Flask
Docker
Nginx
Jenkins
SSAFY 금융 API
도전과제
문제자체 영상 생성 파이프라인 별도 서비스화
해결Flask 와 멀티스레드 워커 큐, SQLite 기반 `goal_id → task_id → 파일 경로` 매핑, FFmpeg 후처리 (원본 mp4 / 압축 mp4 / WEBM / GIF, output_autocrop) 를 단일 서비스로 묶음. `/api/*` 엔드포인트는 API 키로 보호하고 `/cdn/*` 만 공개 다운로드로 노출함. `gunicorn -w 1 --threads 8` 단일 워커 + 멀티스레드 구성으로 인메모리 큐 일관성을 보장함. Nginx + Let's Encrypt + `sketch.pyan.kr` 도메인까지 단독으로 구성함.
결과모아 백엔드가 영상 바이트를 다시 수신해 재전송하면 응답 지연이 커지고, 외부 렌더링 단계가 단일 백엔드 트랜잭션 안에서 실패할 가능성도 컸음. 영상 처리는 별도 서비스로 분리해야 백엔드의 다른 흐름이 받는 영향을 줄일 수 있는 상태였음.
문제외부 렌더링 장애에 대비한 demo fallback API 분리
해결기본 `/api/generate` 와 별도로 `/api/demo/generate` 엔드포인트를 추가함. 조건이 맞으면 사전 준비된 canned mp4 를 복사해 정상 `ready` 작업처럼 반환하도록 처리함. `user_id`, `plan_id`, `plan_title` 을 분기 키로 받아 데모용 영상을 매핑함. 모아 백엔드의 `task_id` 저장과 `/api/status/{task_id}` 폴링 흐름을 손대지 않고 그대로 사용 가능하도록 인터페이스를 동일하게 유지함.
결과외부 렌더링은 발표 일정과 무관하게 장애가 발생할 수 있었고, 그 경우 모아의 목표 생성 흐름이 `ready` 상태에 도달하지 못해 시연이 무너질 위험이 있었음. 백엔드 polling 과 FCM 알림 코드를 손대지 않고도 정상 흐름으로 끝맺을 수 있는 구조가 필요했음.
문제챗봇 SSE 스트리밍 raw relay
해결FastAPI 측 LangGraph 에이전트 실행 결과를 `token`, `tool_call`, `complete` 등 이벤트로 분리해 SSE 로 발행함. Spring 백엔드는 응답을 변환하지 않고 그대로 raw relay 하도록 컨트롤러를 정리함. Nginx 에 `proxy_buffering off` 와 `X-Accel-Buffering: no` 를 적용하고 read/send timeout 을 1200 초로 늘려 중간 버퍼링과 끊김을 차단함. Flutter 측은 EventSource 클라이언트로 이벤트 종류별 분기 처리 함.
결과초기 구성에서는 Spring ResponseBody 변환이 SSE 를 한꺼번에 모아서 내려보내 첫 토큰까지 5~10 초씩 지연되는 문제가 있었음. Nginx 기본 버퍼링도 같은 영향이 있어 사용자가 응답이 멈춘 듯한 경험을 했음.
문제목표 주차 allocation 정합성 통합 정리
해결목표 생성 시 allocation 기간이 주 단위로 정확히 떨어지도록 보정함. 목표 연장에서는 이미 지난 주의 allocation 은 보존하고 남은 기간을 새 종료일까지 재배분하는 방식으로 통일함. read sync 단계의 allocation 범위도 동일 기준으로 수정함. demo plan 시드는 allocation 보호 분기를 별도로 두어 데모 데이터가 사용자 입력으로 덮이지 않도록 처리함.
결과같은 목표가 어느 흐름에서 갱신됐는지에 따라 주별 저축 금액이 다르게 계산되는 회귀가 반복적으로 발생함. 홈 화면 / 메뉴 / 목표 상세 차트가 같은 목표에 대해 서로 다른 숫자를 보여주는 케이스가 있었음.
문제다중 환경 Google 로그인 흐름 분리
해결debug / release 빌드 각각에 Google Cloud 의 별도 OAuth 클라이언트 ID 를 매핑하고 환경별 검증 흐름을 분리함. release 서명 fallback 을 추가해 서명 미구성 빌드도 디버그 인증 경로로 동작하도록 처리함. 웹 빌드는 deprecated 된 gapi 흐름 대신 GIS (Google Identity Services) 정식 경로로 전환하고 클라이언트 ID 를 별도로 관리함. 토큰 검증 응답 구조도 단일 모델로 통일함.
결과초반에는 debug 빌드가 release OAuth 클라이언트로 인증을 시도해 invalid_client 에러가 반복적으로 발생했고, 웹 빌드는 deprecated 된 gapi 흐름을 쓰다가 첫 로그인에서 popup 이 막히는 문제가 있었음. 환경별 디버깅 시간이 누적되어 다른 작업이 지연되는 상태였음.
코드 보기 (4)
Sketch 워커 큐 등록 코드 일부(python)이미지 → 영상 변환 작업을 인메모리 큐에 등록하고 백그라운드 워커가 순차 처리하도록 한 코드입니다.
def enqueue_generation_task(
    task_id: str,
    image_path: Path,
    source_name: str,
    goal_id: str | None,
    pen_mode: str,
    postprocess_enabled: bool,
    input_crop_applied: bool,
    output_autocrop_enabled: bool,
    crop_reference_path: Path | None = None,
    cleanup_paths: list[str] | None = None,
) -> Event:
    start_task(
        task_id, source_name, goal_id, pen_mode,
        postprocess_enabled, input_crop_applied, output_autocrop_enabled,
    )
    ensure_worker_threads_started()

    completion_event = Event()
    with TASKS_LOCK:
        TASK_EVENTS[task_id] = completion_event
        PENDING_TASK_IDS.append(task_id)
        refresh_queue_positions_locked()

    record_admin_event(
        "INFO", "task.queued", "작업이 대기열에 등록되었습니다.",
        task_id=task_id, goal_id=goal_id,
    )

    WORK_QUEUE.put({
        "task_id": task_id,
        "image_path": str(image_path),
        "pen_mode": pen_mode,
        "postprocess_enabled": postprocess_enabled,
        "output_autocrop_enabled": output_autocrop_enabled,
        "crop_reference_path": str(crop_reference_path) if crop_reference_path else None,
        "cleanup_paths": cleanup_paths or [],
    })
    return completion_event


def generation_worker(worker_index: int) -> None:
    while True:
        job = WORK_QUEUE.get()
        task_id = job["task_id"]
        # ... 슬롯 확보 후 헤드리스 브라우저 자동화 + 후처리
Sketch demo fallback API 코드 일부(python)외부 렌더링 장애 시에도 사전 준비된 canned mp4 를 ready 작업으로 매핑해 정상 흐름을 끝맺도록 한 엔드포인트입니다.
@app.route("/api/demo/generate", methods=["POST"])
def api_demo_generate():
    auth_error = require_api_key()
    if auth_error:
        return auth_error

    demo_metadata, demo_error = extract_demo_request_metadata()
    if demo_error:
        return demo_error

    image_file = extract_uploaded_file()
    task_id, _, _, _, _, _, goal_id, idempotency_key, reused_existing, error_response = (
        enqueue_request_file(image_file, enable_idempotency=True)
    )
    if error_response:
        return error_response

    if demo_metadata:
        # 외부 렌더링 실패 시 canned mp4 를 ready 상태로 즉시 매핑
        apply_demo_request_metadata(task_id, demo_metadata)

    payload, _ = build_api_task_payload(task_id, goal_id=goal_id)
    payload = attach_idempotency_metadata(payload, idempotency_key, reused_existing)
    payload["mode"] = "demo"
    status_code = 200 if payload.get("status") == "ready" else 202
    return jsonify(payload), status_code
목표 주차 allocation 계산 코드 일부(java)월요일 기준 주를 잘라 목표 금액을 균등 분배하고 잔여를 마지막 주에 합산하는 계산 로직입니다.
@Component
public class PlanWeekAllocationCalculator {

    public List<WeekAmount> calculate(LocalDate startDate, LocalDate endDate, Long targetAmount) {
        List<WeekAmount> weeks = new ArrayList<>();
        LocalDate weekStart = startDate.with(DayOfWeek.MONDAY);
        while (!weekStart.isAfter(endDate)) {
            LocalDate weekEnd = weekStart.plusDays(6);
            if (!weekEnd.isBefore(startDate)) {
                weeks.add(new WeekAmount(weekStart, weekEnd, 0L));
            }
            weekStart = weekStart.plusWeeks(1);
        }

        long baseAmount = targetAmount / weeks.size();
        long remainder = targetAmount % weeks.size();
        List<WeekAmount> result = new ArrayList<>(weeks.size());
        for (int i = 0; i < weeks.size(); i++) {
            long amount = baseAmount;
            if (i == weeks.size() - 1) {
                amount += remainder;  // 잔여는 마지막 주에 합산
            }
            WeekAmount week = weeks.get(i);
            result.add(new WeekAmount(week.weekStartDate(), week.weekEndDate(), amount));
        }
        return result;
    }

    public record WeekAmount(LocalDate weekStartDate, LocalDate weekEndDate, Long amount) {}
}
Flutter SSE 챗봇 스트림 수신 코드 일부(dart)dio 의 stream 응답을 텍스트 디코딩하고 SSE 빈 줄 기준으로 프레임을 잘라 token / tool_call / complete 이벤트로 분기하는 코드입니다.
Stream<ChatStreamEvent> postChatStream({
    required String message,
    String? conversationId,
}) async* {
    final body = <String, dynamic>{'message': message};
    if (conversationId != null) body['conversation_id'] = conversationId;

    final response = await _dio.post<ResponseBody>(
        'v1/chatbot/messages/stream',
        data: body,
        options: Options(
            responseType: ResponseType.stream,
            headers: {'Accept': 'text/event-stream'},
            receiveTimeout: const Duration(minutes: 2),
        ),
    );

    final stream = response.data!.stream.map(
        (chunk) => utf8.decode(chunk, allowMalformed: true).replaceAll('\r\n', '\n'),
    );
    yield* _parseResponseChunks(stream);
}

Stream<ChatStreamEvent> _parseResponseChunks(Stream<String> stream) async* {
    String buffer = '';

    await for (final chunk in stream) {
        buffer += chunk;

        // SSE 프레임 단위 처리: 빈 줄 기준으로 분리
        while (buffer.contains('\n\n')) {
            final frameEnd = buffer.indexOf('\n\n');
            final frame = buffer.substring(0, frameEnd);
            buffer = buffer.substring(frameEnd + 2);

            for (final event in _parseFrameEvents(frame)) {
                yield event;  // token / tool_call / complete
            }
        }
    }
}
Pyanchor
2026.04 - 현재 운영 중🔗

운영 중인 웹 앱 위에 AI 기반 실시간 편집 오버레이를 얹는 에이전트 비종속 사이드카입니다. 한 줄 스크립트 태그로 Shadow DOM 오버레이를 주입하여 사용자가 UI 요소를 클릭하고 자연어로 수정을 요청하면, 선택한 AI 에이전트 CLI 가 실제 소스를 수정하고 빌드한 뒤 rsync 배포 또는 PR 생성까지 수행하도록 설계하였습니다. OpenClaw, Claude Code, Codex, Aider, Gemini 다섯 개 에이전트 어댑터와 Next.js, Vite, Astro, SvelteKit 예제를 포함하여, HMAC actor 서명과 systemd 템플릿 등의 자체 호스팅 구조로 npm 에 공개 배포하였습니다. 2026-05 pollinations.ai 공식 OSS 앱 쇼케이스에 Flower Tier 로 등재되었습니다.

팀 구성

1인 개발

담당 역할

Express 사이드카 및 Shadow DOM 오버레이 설계 AI 에이전트 어댑터 6종 구현 HMAC 서명 및 systemd 통합 등 운영 보안 구현 테스트 및 CI 구축 후 npm 공개 배포

TypeScript
Node.js
Express
Next.js
Vite
Astro
SvelteKit
Claude Agent SDK
systemd
HMAC
GitHub Actions
도전과제
문제다양한 코딩 에이전트의 단일 인터페이스 추상화
해결70 줄 안팎의 AgentRunner TypeScript 인터페이스를 정의해 각 백엔드를 같은 컨트랙트의 어댑터로 분리함. PYANCHOR_AGENT 환경변수 하나만 바꾸면 OpenClaw, Claude Code, Codex, Aider, Gemini, Pollinations 사이를 즉시 전환할 수 있음. 새 어댑터는 보통 100~200 줄로 추가됨. 이를 통해 후속으로 추가한 Pollinations 어댑터도 CLI 설치 없이 HTTP 만으로 동작하는 첫 백엔드로 손쉽게 합류시킴.
결과코딩 에이전트 SDK 생태계가 빠르게 변하는 상황에서, 특정 LLM 에 종속되지 않는 사이드카 구조가 필요했음. 초기에는 OpenClaw 한 백엔드만을 가정한 채로 구현되어 있어, 신규 에이전트 도입마다 대규모 수정이 필요한 상태였음.
문제첫 init 직후 사이드카 기동 실패로 인한 onboarding 단절
해결실행 디렉터리의 .env 자동 로드, 사용 가능한 빈 포트 자동 탐지, 복사해서 그대로 붙여넣을 수 있는 bootstrap 스니펫 출력 기능을 도입함. 모든 boot-time 검증을 한 번에 보여주는 pyanchor doctor 커맨드를 추가하여, 실패 항목별 fix 힌트로 사용자가 자가 진단할 수 있도록 정리함. 이 방안을 통해 첫 init 부터 첫 edit 까지 30 초 안에 도달하도록 안정화함.
결과npx 직후 사이드카가 떠도 .env 가 cwd 에서 안 읽히고 prod 호스트네임 trust 가 빠져 첫 edit 이 조용히 실패하는 일이 잦음. 초보 사용자가 디버깅하다 포기하는 경로가 반복되어 retention 이 떨어졌음.
문제어댑터 간 default 모델 식별자 leak 으로 인한 첫 edit 실패
해결어댑터별로 default 모델 식별자 매핑을 분리하고, 환경변수가 비어있을 때만 어댑터 기본값을 적용하도록 fall-through 우선순위를 다시 잡음. 어댑터 인터페이스에 detectModel() 추상화를 추가하여 신규 어댑터도 같은 함정을 피할 수 있도록 가드를 둠. 이 방안을 통해 v0.32.3 hotfix 로 첫 edit 성공률을 0% 에서 정상 수치로 회복시킴.
결과PYANCHOR_AGENT_MODEL 의 기본값에 OpenClaw 라우팅 prefix 가 들어있어 codex / aider / claude-code 어댑터로 전달됨. 이로 인해 v0.32.x 초기 사용자의 첫 edit 이 100% 실패하는 회귀 이슈가 발생함.
문제systemd 환경에서의 사이드카 자가 종료 race
해결GC 가 listen handle 을 일찍 회수하는 race 임을 확인하고, handle 에 명시적 reference 를 보존해 수명을 보장하도록 처리함. bootstrap 스니펫에는 prod 호스트네임을 trust list 에 자동 포함시키는 가드를 추가함. GC 수정으로 가려졌던 vitest 케이스 두 건은 별도 표시하여 추적 가능하게 둠. 이 방안을 통해 v0.32.4 이후 systemd 환경에서 안정 운영하도록 만듦.
결과server.cjs 가 systemd 안에서 listen 시작 후 약 1초 안에 self-exit 되는 문제가 발생함. 로컬 개발 환경에선 재현되지 않아 진단에 시간이 오래 걸렸음.
문제토큰 노출 시 위조 가능한 게이트 쿠키의 보안 강화
해결쿠키 값 자체를 actor 정보가 담긴 HMAC-signed JWT 로 교체함. PYANCHOR_GATE_COOKIE_HMAC_SECRET 으로 모든 요청의 서명을 검증하고, /_pyanchor/unlock?secret=X 엔드포인트가 magic word 진입과 actor 식별을 한 번에 처리하도록 정리함. v0.17 까지의 `=1` 평문 쿠키는 unsigned 로 명시적으로 reject 함. 이 방안을 통해 actor 별 audit log 가 가능해졌고, 토큰만 알아낸 외부 사용자는 unsigned 쿠키로 차단됨.
결과self-hosted 환경이라도 토큰만 노출되면 쿠키를 devtools 콘솔에서 한 줄로 위조 가능했음. 또 한 인스턴스를 여러 사람이 공유할 때 audit log 가 모두 같은 익명 토큰으로만 남아 행위 추적이 불가능했음.
코드 보기 (4)
AgentRunner 인터페이스 코드 일부(typescript)각 AI 에이전트 백엔드가 구현해야 하는 안정 인터페이스를 정의한 코드입니다.
export interface AgentRunner {
  /** Identifier used in logs and the PYANCHOR_AGENT env var. */
  readonly name: string;

  /** Optional one-time setup before run() is called. */
  prepare?(context: AgentRunContext): Promise<void>;

  /**
   * Drive the agent for a single user request. Yield events as the agent
   * thinks/acts. Return naturally when the agent is done.
   */
  run(input: AgentRunInput, context: AgentRunContext): AsyncIterable<AgentEvent>;
}

export interface AgentRunInput {
  prompt: string;
  targetPath: string;       // route/file hint
  mode: AiEditMode;          // "edit" mutates files; "chat" must answer without writing
  recentMessages: ReadonlyArray<AiEditMessage>;
  jobId: string;
}

export interface AgentRunContext {
  workspaceDir: string;      // scratch dir the agent should mutate
  timeoutMs: number;
  model: string;             // optional hint from PYANCHOR_AGENT_MODEL
  thinking: string;          // optional reasoning level
  signal: AbortSignal;       // adapters MUST observe this for responsive cancel
}

export type AgentEvent =
  | { type: "log"; text: string }
  | { type: "thinking"; text: string }
  | { type: "step"; label: string; description?: string }
  | { type: "result"; summary: string; thinking?: string | null };
Gate JWT 발행 / 검증 코드 일부(typescript)node:crypto 만으로 HS256 JWT 를 발행하고 서명을 검증하는 코드입니다.
import { createHmac, timingSafeEqual } from "node:crypto";

const HEADER_B64URL = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";

export interface GateJwtPayload {
  iat: number;          // issued-at, unix seconds
  exp: number;          // expires-at, unix seconds
  v: 1;                  // payload version
  sub?: string;         // optional subject (display only, never trusted for auth)
}

export function issueGateJwt(payload: GateJwtPayload, secret: string): string {
  if (!secret) throw new GateJwtError("missing-secret", "HMAC secret unset");
  const body = base64url(JSON.stringify(payload));
  const sig = base64url(
    createHmac("sha256", secret).update(`${HEADER_B64URL}.${body}`).digest()
  );
  return `${HEADER_B64URL}.${body}.${sig}`;
}

export function verifyGateJwt(token: string, secret: string): GateJwtPayload {
  const [h, b, s] = token.split(".");
  if (h !== HEADER_B64URL) throw new GateJwtError("wrong-alg", "non-HS256 header");
  const expected = base64url(
    createHmac("sha256", secret).update(`${h}.${b}`).digest()
  );
  if (!timingSafeEqual(Buffer.from(s), Buffer.from(expected))) {
    throw new GateJwtError("bad-signature", "signature mismatch");
  }
  const payload = JSON.parse(Buffer.from(b, "base64url").toString()) as GateJwtPayload;
  if (Math.floor(Date.now() / 1000) >= payload.exp) {
    throw new GateJwtError("expired", "token expired");
  }
  return payload;
}
Codex 어댑터 brief 빌더 코드 일부(typescript)외부 CLI 에 일관된 형식의 컨텍스트를 전달하기 위한 prompt 빌더입니다.
function formatRecent(messages: AgentRunInput["recentMessages"]): string {
  return messages
    .slice(-6)
    .map((m) => {
      const role = m.role === "assistant" ? "Assistant" : m.role === "system" ? "System" : "User";
      return `- ${role} [${m.mode}]${m.status ? ` (${m.status})` : ""}: ${m.text}`;
    })
    .join("\n");
}

export function buildBrief(input: AgentRunInput): string {
  const sections: string[] = [];

  if (input.targetPath) sections.push(`Target route: ${input.targetPath}`);
  sections.push(`Mode: ${input.mode}`);

  if (input.recentMessages.length > 0) {
    sections.push(`Recent conversation:\n${formatRecent(input.recentMessages)}`);
  }

  sections.push("");
  sections.push("User request:");
  sections.push(input.prompt);

  if (input.mode === "edit") {
    const framework = selectFramework(pyanchorConfig.framework);
    sections.push("");
    sections.push(
      "Apply the change to the appropriate files in the working directory. " +
      `${framework.briefBuildHint} ` +
      "Do not refactor unrelated areas. Respond in 2-3 lines summarizing the changes."
    );
  }

  return sections.join("\n");
}
Pollinations HTTP-only 어댑터 코드 일부(typescript)CLI 없이 HTTP 만으로 동작하는 OpenAI 호환 tool 루프 어댑터입니다.
// Pollinations adapter HTTP-only.
// Configuration (all optional):
//   PYANCHOR_AGENT=pollinations
//   PYANCHOR_POLLINATIONS_TOKEN=sk_...     // backend bearer token (recommended)
//   PYANCHOR_POLLINATIONS_REFERRER=...     // attribution / tier
//   PYANCHOR_POLLINATIONS_MODEL=nova-fast              // default since v0.38.0
//   PYANCHOR_POLLINATIONS_BASE_URL=https://gen.pollinations.ai
//   PYANCHOR_POLLINATIONS_PATH=/v1/chat/completions
//   PYANCHOR_POLLINATIONS_MAX_TURNS=12

async function runToolLoop(
  input: AgentRunInput,
  context: AgentRunContext,
): AsyncIterable<AgentEvent> {
  const messages = [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: buildBrief(input) },
  ];

  for (let turn = 0; turn < pyanchorConfig.pollinationsMaxTurns; turn++) {
    const response = await fetch(endpoint, {
      method: "POST",
      headers: bearer(),
      body: JSON.stringify({ model, messages, tools: TOOLS, tool_choice: "auto" }),
      signal: context.signal,
    });
    const choice = (await response.json()).choices[0];
    const calls = choice.message.tool_calls ?? [];

    if (calls.length === 0) {
      yield { type: "result", summary: choice.message.content };
      return;
    }

    for (const call of calls) {
      const result = await dispatch(call, context.workspaceDir);
      yield { type: "step", label: call.function.name, description: result.snippet };
      messages.push(choice.message, { role: "tool", tool_call_id: call.id, content: result.text });
    }
  }
}
AIG (AI-integrated Ground)
2026.04 - 2026.05

+2

LangGraph 기반 AI 코딩 에이전트와 함께 알고리즘 문제를 푸는 웹 기반 학습 플랫폼입니다. 에이전트가 코드 작성·디버깅·힌트 제공을 실시간으로 보조하며, 실행 흐름 전체를 Langfuse 스타일 Trace 뷰어로 세션 단위로 추적할 수 있도록 설계하였습니다. 사용자별 하네스 시스템으로 에이전트의 동작 방침과 프롬프트를 코드를 건드리지 않고 커스터마이징할 수 있도록 구현하였습니다.

팀 구성

6인 팀

담당 역할

프론트엔드 전담: IDE·Trace·하네스·리포트 전체 설계 및 구현 (전체 커밋의 65%), IDE 워크벤치: Monaco Editor 멀티탭·패널 리사이즈·diff 뷰 구현, LangGraph 에이전트 Trace 뷰어(TraceWorkbench) 단독 설계 및 구현, 에이전트 SSE 스트리밍 채팅 패널 및 worktree 카드 UI 구현, 하네스 페이지: 빌드/활성화 흐름 및 BYOK(사용자 API 키) 설정 구현, 제출 채점 결과 패널: QUEUED·RUNNING·COMPLETED 상태 및 큐 대기 순서 표시, 리포트 페이지: 5축 레이더 차트·마크다운 렌더링 구현, 백엔드 API 통합 및 응답 스펙 호환 수정 16건

Next.js
TypeScript
React
Zustand
TanStack Query
Monaco Editor
Spring Boot
Java
PostgreSQL
Redis
RabbitMQ
LangGraph
Python
FastAPI
Docker
Nginx
Jenkins
도전과제
문제Monaco Editor WebWorker 라이프사이클 경쟁 조건 6건 일괄 수정
해결loader.config를 명시하여 worker URL을 고정하고, auxiliary input 정규화, 탭 닫을 때 editor.dispose() 보장, getWorker 오버라이드 제거 등 라이프사이클 버그 6건을 단일 릴리즈에서 처리했습니다. React StrictMode의 이중 마운트 환경에서 worker가 중복 생성되지 않도록 mount/unmount 순서를 정리하고 방어 코드를 추가했습니다.
결과IDE 장시간 사용 또는 탭 전환 시 Monaco Editor가 멈추거나 이전 파일 내용이 잔존하는 현상이 반복 발생했습니다. getWorker 미정의로 인한 콘솔 에러와 미해제 Web Worker로 인한 메모리 누수가 브라우저에 누적되고 있던 상태였습니다.
문제AI 에이전트 SSE 스트리밍 cleanup 누락 4건 수정
해결SSE EventSource의 cleanup 함수를 useEffect의 반환값으로 통일하여, 컴포넌트 unmount 시 연결을 강제 종료하는 것을 보장하도록 정리했습니다. 스트리밍 완료 전 사용자가 이탈한 케이스에서 부분 토큰이 화면에 고착되는 문제는, pending 상태 초기화를 cleanup 순서 내로 이동하여 처리했습니다.
결과에이전트 채팅 중 다른 탭으로 이동하면 SSE 연결이 끊기지 않고 메모리를 점유하는 누수가 있었습니다. 스트리밍이 도중에 끊긴 경우 마지막 부분 토큰이 화면에 남아 메시지가 잘린 것처럼 보이는 케이스가 반복 발생하던 상태였습니다.
문제백엔드 RabbitMQ 큐 도입 후 신규 QUEUED 상태 UI 대응
해결SubmissionResultPanel에 QUEUED 분기를 추가하고, 큐 내 자신의 위치 (1시작의 queuePosition)를 실시간 표시하도록 처리했습니다. elapsed 타이머는 QUEUED 상태에서도 0.5초 간격으로 계속 업데이트되어 사용자가 대기 시간을 직관적으로 파악할 수 있도록 개선했습니다.
결과백엔드의 채점 큐 도입 직후, 프론트가 QUEUED 상태를 인식하지 못해 채점 중 표시 없이 공백 화면이 되거나, 폴링이 조기 종료되는 회귀가 발생했습니다.
문제하네스 buildHarness의 경쟁 조건: 초회 전송 전 빌드 미보장 버그
해결에이전트의 초회 메시지 전송 직전에 buildHarness 호출을 강제 삽입하고, 완료 후 관련 쿼리 캐시를 즉시 revalidate하도록 처리했습니다. 하네스 적용 직후 AGENTS.md 탭이 자동으로 열리도록 개선하여, 변경의 반영을 사용자가 즉시 확인할 수 있도록 UX를 강화했습니다.
결과하네스 설정을 변경하고 바로 에이전트를 실행하면, 이전 버전의 runtime_config로 동작하는 케이스가 있었습니다. runtime_config의 컴파일 완료 전에 에이전트가 실행을 시작하여, 빈 하네스 상태로 응답하는 케이스도 있었습니다.
코드 보기 (3)
Trace 뷰어: 진행 중인 run이 있는 동안만 폴링(typescript)RUNNING 상태의 트레이스가 없어지면 자동으로 폴링을 중단하고, 선택 중인 run이 진행 중일 경우 2초마다 스팬 목록을 업데이트하여 실시간 트레이스를 표시한다.
// TraceWorkbench.tsx TanStack Query refetchInterval 조건부 설정
// 완료/실패/취소 상태만 남으면 폴링을 멈춰 불필요한 요청을 차단한다.
const { data: traceList } = useQuery({
  queryKey: ["agentTraces", sessionId, page],
  queryFn: () => sessionApi.getAgentTraceList(sessionId, page, TRACE_PAGE_SIZE),
  staleTime: 30_000,
  refetchInterval: (query) => {
    const TERMINAL = ["COMPLETED", "FAILED", "CANCELLED"];
    const hasActive = (query.state.data?.runs ?? [])
      .some((r) => !TERMINAL.includes(r.status));
    return hasActive ? 5000 : false; // 진행 중 있으면 5초, 없으면 중단
  },
});

// 선택된 run 상세도 동일 패턴 진행 중이면 2초마다 spans 갱신
const { data: selectedRunDetail } = useQuery<AgentRunTrace>({
  queryKey: ["agentTraceDetail", sessionId, selectedRunId],
  queryFn: () => sessionApi.getAgentTraceDetail(sessionId, selectedRunId ?? ""),
  enabled: !!selectedRunId,
  staleTime: 30_000,
  refetchInterval: (query) => {
    const TERMINAL = ["COMPLETED", "FAILED", "CANCELLED"];
    const status = query.state.data?.status;
    if (!status || TERMINAL.includes(status)) return false;
    return 2000; // 진행 중이면 2초마다 span 목록 갱신
  },
});
RabbitMQ QUEUED 상태: 채점 큐 대기 UI(typescript)QUEUED를 RUNNING과 동일하게 취급하여 기존 폴링·타이머 로직을 재사용하면서, 큐 내 위치를 실시간 표시하여 사용자가 대기 상황을 직관적으로 파악할 수 있도록 한다.
// SubmissionResultPanel.tsx RabbitMQ 큐 도입 후 QUEUED 상태 신규 대응
// QUEUED = 큐 대기 중 / RUNNING = 채점 중 둘 다 "진행 중"으로 표시

const isQueued  = result.rawStatus === "QUEUED";
const isRunning = result.rawStatus === "RUNNING" || isQueued; // QUEUED도 진행 중 취급

// 0.5초마다 now 갱신 → QUEUED 상태에서도 elapsed 타이머 계속 올라감
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
  if (!loading) return;
  const t = setInterval(() => setNow(Date.now()), 500);
  return () => clearInterval(t);
}, [loading]);

// 상태별 안내 문구
const statusLabel = isQueued
  ? result.queuePosition != null
    ? `${result.queuePosition}번째 대기 중 · ${elapsedSec}초 경과`
    : `큐 대기 중 · ${elapsedSec}초 경과`
  : isRunning
    ? `채점 중 · ${elapsedSec}초 경과`
    : `소요 ${elapsedSec}초`;
하네스 적용: buildHarness 강제 호출과 즉시 캐시 무효화(typescript)AGENTS.md 저장 직후 buildHarness를 강제 호출하여 runtime_config_json의 컴파일을 보장하고, 관련 쿼리 캐시를 즉시 무효화하여 하네스 변경이 다음 에이전트 실행에 즉시 반영되도록 한다.
// IdeShell.tsx 하네스 AGENTS.md 저장 후 즉시 빌드 보장
// ⚠️ 저장만 하고 buildHarness 를 호출하지 않으면 runtime_config_json 이
//    갱신되지 않아 다음 agent run 이 이전 설정으로 동작한다.

// 3) 하네스 빌드 AGENTS.md 는 이 시점에 runtime_config_json 으로 컴파일됨
const buildResult = await sessionApi.buildHarness(sessionId, modelId);
const buildSucceeded = isHarnessBuildSucceeded(
  buildResult.compileStatus,
  buildResult.validErrors?.length ?? 0
);

// 4) store 파일 content 즉시 반영 + 관련 쿼리 캐시 무효화
//    (invalidateQueries 만으론 store .files 가 stale 로 남아 새로고침 필요했던 문제 해결)
hydrateFileContent(targetDisplayPath, markdown, "markdown");
await queryClient.invalidateQueries({ queryKey: ["workspace",    sessionId] });
await queryClient.invalidateQueries({ queryKey: ["session",      sessionId] });
await queryClient.invalidateQueries({ queryKey: ["agentTraces",  sessionId] });

// 5) 적용 결과를 사용자가 즉시 확인할 수 있도록 AGENTS.md 탭 자동 포커스
openTabInEditorGroup(targetDisplayPath);
setActivePath(targetDisplayPath);

Skills & Technologies

Frameworks & Libraries

React
Vue.js
Discord API
FastAPI
Axios
MUI
Node.js
Flask
Express
Spring

Languages

Java
Kotlin
JavaScript
TypeScript
HTML/CSS
C#
C++
Python
PHP
SQL

Tools & Platforms

Git
Unity
Linux
Claude Code
MongoDB
Slack
Discord
Mattermost
Teams
Figma
MySQL

Achievements & Licenses

수상

2023-11

KIT Engineering Fair 동상

·국립금오공과대학교 LINC3.0 사업단

활동

2024-01

CO-UP CAMPUS CHATBOT HACKATHON 참가

·교육부 외 6개 기관

자격증

2024-09

정보처리기사

·한국산업인력공단
2025-01

JLPT N2

·일본국제교류기금
2026-03

OPIc IH

·ACTFL

Education

2025.07현재

SSAFY

14기 · 모바일트랙

2019.032025.02

국립금오공과대학교

공학사 · 컴퓨터소프트웨어공학과

2016.032019.02

대구시지고등학교

고등학교 졸업 · 이과

함께 일하고 싶어요!

프로젝트 문의 및 협업 제안, 입사 제안을 기다립니다.

© 2026 이상헌. All rights reserved.

Built with Next.js, FastAPI & Claude Code.