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

Lee Sang-Heon

Full Stack Developer & Game Programmer

Experience

Research Engineer
·Wellbia.com Co., Ltd.·2021.12 – 2023.02

A company developing Xigncode3, an anti-cheat module and program for games. · Applied C++ security modules to Unity and Android platforms · Implemented cross-platform integration between C++ and Java using JNI framework · Discovered and proposed countermeasures for global offset vulnerabilities through Unreal Engine (UE4) source code analysis · Researched ACTk Obscured Variable analysis and memory tampering prevention mechanisms · Built development environment automation scripts using Windows PowerShell

Vice President & Reverse Engineering Mentor
·Kumoh National Institute of Technology Information Security Club BOSS·2023.03 – 2024.08

Provided mentoring to juniors based on game security knowledge acquired while working. · Designed reverse engineering curriculum and created practical materials · Mentored static analysis using debugging tools (IDA) · Mentored dynamic analysis using memory tampering tools (Cheat Engine, etc.) · Conducted basic security concept education accessible to non-majors · Organized internal CTF and created dynamic analysis-related challenges

Undergraduate Researcher
·Signal Processing and Intelligent Network Lab·2023.07 – 2025.02

Participated in various laboratory projects as an undergraduate researcher. · Frontend development within projects · UI/UX design within projects · Research on integrating markdown editors and formula editors


Projects

AngelicBusterBot(Abbot)
2022.04 - Currently in Service🔗

+1

A Discord chatbot that provides MapleStory-related information to users. Utilizes Nexon Open API to deliver real-time game information, character search, and guild details. Has been in service for approximately 3 years, currently serving over 180,000 users across about 4,700 servers.

Team

1-Person Development

Role

Chatbot and website development Operations and maintenance

Java
JDA
Maven
Nexon Open API
jsoup
REST API
Gson
Linux
JavaScript
FastAPI
React
MUI
axios
Challenges
ProblemResponse delays due to large-scale concurrent request processing
SolutionIntroduced sharding-based thread distribution across servers and asynchronous processing to allow each command to be executed independently. This approach reduced response time to approximately 1-2 seconds.
ResultAs a MapleStory Partner and with the surge in game users, bot usage increased dramatically. This led to response delays of over 5 seconds, failing to meet Discord's 3-second response requirement. Users experienced situations where they entered commands but received no response.
ProblemEnsuring stability with limited server resources
SolutionModified to cache reusable resources. Built cron-based real-time memory monitoring system and automatic restart system to resolve issues. This approach achieved uptime of over 98%. Additionally, we completed data migration after upgrading the server.
ResultOOM errors frequently occurred on a server with 1-core CPU and 1GB memory. This caused the bot server to shut down, resulting in low uptime.
ProblemImproving non-intuitive command input method
SolutionStrengthened feedback for incomplete input fields to prevent users from missing required data. Also created numerous exception handling branches to process errors across as many scenarios as possible. Through this approach, related inquiries decreased by more than 80% compared to before.
ResultUsers struggled with how to use the commands, leading to frequent related inquiries.
ProblemImproving user experience within a constrained UI
SolutionConducted direct QA testing through extensive trial and error, researching the most intuitive way to present data. Selected about 3 presentation method cases and gathered opinions from actual users. Through this approach, all essential information could be included without compromising the UX.
ResultDue to the constraint that information could only be expressed via Discord messages, providing too much information became a problem that harmed user experience.
Show Code (5)
Notice Reminder Code Excerpt(java)Implemented large-scale server distribution processing through sharding.
// 스레드풀 크기별 채널 분할하여 병렬 처리
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();
                }
            );
        }
    });
}
Character Information Command Code Excerpt(java)Code for integrating Nexon Open API and parsing data.
// 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";
    }
}
EXP History Command Code Excerpt(java)This is the experience point calculation logic to provide experience point increase.
// 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();
}
Star Force Command Code Excerpt(java)This is a code that processes asynchronous tasks and provides user feedback after receiving a command.
// 슬래시 커맨드 처리 - 스타포스 시뮬레이터
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);
}
Star Force Embed Generation Command Excerpt(java)This is a part of the code that generates a message to the user after simulation.
// 스타포스 시뮬레이션 결과 임베드 생성
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();
}
Federation of Korean Trade Unions Daegu Regional Headquarters Website
2025.03 - 2025.06🔗

Official website for the Daegu branch of the Federation of Korean Trade Unions. Mainly focused on course registration functionality, handled overall website development as commissioned work. Key development features included full frontend and backend development of the course registration system and admin system.

Team

1-Person Development

Role

Project design Frontend development Backend development

React
JavaScript
Python
FastAPI
MongoDB
PHP
Linux
Challenges
ProblemRespond to rapidly changing client requirements
SolutionWe explained the situation to the client and requested a change to a lightweight full-stack project based on React / FastAPI / MongoDB. We were able to persuade the client and begin development by sharing information along with the project design details.
ResultThe initial contract was a simple course registration based on Cafe24, but the actual content was impossible to implement with the Cafe24 system. As a result, the problem of having to switch to full-stack development to complete the function arose.
ProblemRequest for continuous feature expansion during development
SolutionWe share the development time and development priorities for each function with the client and adjust a realistic schedule by transparently disclosing the impact on the overall schedule. For features confirmed to be added, mockups were created using AI and Figma, and these were shared to coordinate feature development.
ResultStarting with the course registration system, there was a problem with requests for gradual expansion of functions, such as adding an administrator page to manage it, adding a member management function within the administrator page, adding a pop-up management function, and adding a statistics inquiry function.
ProblemComplexity of linking with existing PHP system
SolutionThe iframe communication code was implemented in the PHP and React projects, and a mechanism for sharing sessions was established to solve the problem.
ResultDue to the nature of Cafe24, a problem occurred that required data linking between the PHP code-based system and the newly developed React app.
ProblemOptimization of practical environment construction and distribution
SolutionIn the Ubuntu server environment, separate the front and api servers through Nginx and apply the SSL certificate using certbot.
ResultThere were difficulties in configuring the actual service environment, including HTTPS settings, automatic authentication renewal, Nginx traffic routing, and security settings.
ProblemRequires high quality results within a short development period
SolutionWe organize the priorities for each function and share them with the client, separate each component and function, and request it from the client as soon as development is completed to proceed with QA. By quickly reflecting feedback and conducting development simultaneously, we were able to complete the result within 3 months.
ResultDespite continuous requirements changes, a situation arose where the development period had to be shortened by about two weeks due to the client's service opening schedule.
Show Code (2)
Course Registration Logic Excerpt(javascript)Optimized user experience by dynamically validating form content.
// 숫자만 입력 가능하도록 하는 함수 (생년월일, 전화번호)
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;
};
Course Registration Approval Page Code Excerpt(javascript)UX has been improved by optimizing large data tables such as course registration forms.
// 상태 우선순위 매핑으로 정렬 최적화
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);
    });
StockTalk
2023.09 - 2023.12

An AI-powered KakaoTalk chatbot that predicts daily closing price fluctuations compared to the previous day's closing price using hourly news articles and stock price movements. Implemented with a Python Flask server connected to Kakao i OpenBuilder to analyze user speech intent for more precise operations. Dynamically generates graph images based on AI-generated results and attaches them to custom-designed KakaoTalk messages for seamless user experience.

Team

Team of 4

Role

Scenario and skill creation in Kakao i OpenBuilder Implementation of a Flask-based chatbot server and content download server for chatbot communication Implemented entity recognition and similarity analysis for stock sectors and tickers Dynamic graph generation and KakaoTalk message design Crawled over 200,000 stock news articles covering 8 months across all domestic tickers using multithreading

Kakao i OpenBuilder
Python
Flask
Linux
Java
jsoup
Konlpy
Challenges
ProblemOptimize large data crawling and processing
SolutionData was collected over approximately two days by designing parallel crawling using multithreading, preventing duplicate crawling through memoization, and designing an efficient data parsing structure.
ResultA problem arose in collecting data on more than 200,000 articles covering all domestic stocks as quickly as possible over 8 months.
ProblemComplex information representation in constrained UI
SolutionIt is recognized that users accept information more easily when there are images than when it is expressed only through simple numbers and text. Accordingly, a function to dynamically generate a positive/negative graph is implemented. Related article data could be separated into buttons outside the message to improve readability.
ResultThere was difficulty in intuitively expressing various information within the KakaoTalk message interface.
ProblemImproved accuracy of intent identification through natural language processing
SolutionUsing Konlpy, morphemes are analyzed and connected to a model that understands the intention of speech. Stock and item names are also processed for ambiguous input through similarity calculation. In addition, the entity name recognition function was implemented so that it can respond to abbreviations such as Samsung Electronics (Samjeon) and KT(KT) and foreign languages.
ResultThere may be cases where the item name, field, or utterance intention entered by the user is not accurately recognized.
ProblemCoordination of development methods and priorities within the team
SolutionA consensus was reached through visual explanation through Figma and presentation of objective analysis data on development time.
ResultAI function improvement First, there was a difference of opinion with the team leader regarding parallel development of vs.
Show Code (1)
Message Receiver Code Excerpt(python)We implemented several exception handling units to understand intent and resolve ambiguity in utterances.
@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 : Group and Personal Schedule Management Sharing Service
2024.02 - 2024.06

+1

A community-based collaborative calendar that helps manage group and personal schedules and finds available time slots among group members. Designed and implemented an intuitive UI to create a user-friendly service. Utilized both brute-force and interval merging algorithms appropriately to find available time slots as quickly as possible.

Team

Team of 4

Role

Frontend UI implementation Implemented frontend Axios communication, data rendering, and session management Implemented an available-time search algorithm using brute force and interval merging Backend CORS management and partial controller implementation

JavaScript
React
Axios
Java
Spring
MySQL
Challenges
ProblemOptimizing complex empty schedule finding algorithm
SolutionSpeed ​​was measured by developing a prototype using several algorithms such as brute force, interval merge algorithm, binary search, and hash table. The most appropriate algorithm is selected and implemented depending on the situation. Accordingly, simple date calculations use brute force, and time calculations in minutes use an interval merging algorithm, reducing the calculation time to less than 10 seconds.
ResultWhen using a simple brute force algorithm, a problem arises where the calculation to find a common free time by combining the schedules of multiple users takes more than 10 minutes.
ProblemComplete revision of source code due to rapid change in authentication method
SolutionBy proposing and applying the Axios interceptor, we were able to preserve the existing code as much as possible by implementing an automatic token management system.
ResultA week before the deadline, there was a sudden request to change from session-based authentication to JWT token authentication. Although we expressed our opinion that this was something that needed to be considered and applied, problems arose due to arbitrary changes by team members.
ProblemOptimize data communication between frontend and backend
SolutionReact not only uses useState to manage my state, but also uses useMemo appropriately to cache information, and I was able to resolve the CORS error by modifying the backend code and server policy.
ResultCORS issues within the image server and other controllers, as well as rendering performance issues for real-time schedule updates.
ProblemProblem maintaining state when moving pages
SolutionWe were able to build a status management system using local storage and Redux and improve user experience.
ResultEvery time the user moves between pages, the previous work is reset and returns to the default.
ProblemBridge role as a full-stack developer within the team
SolutionAs the only full-stack developer in the team, I am in charge of the communication channel and lead the meeting by collecting the conditions required from each front-end and back-end. Not only does it implement the front end, but if necessary, it also directly implements the Spring back end controller and coordinates team integration.
ResultDuring development, there was a problem with communication between frontend and backend.
Show Code (3)
Available Time Search Algorithm Function Excerpt(java)Performance was optimized using an interval merging algorithm.
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 Communication Code Excerpt(javascript)We have implemented the JWT token automatic management system using the Axios interceptor.
// 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 [];
  }
};
Main Page Code Excerpt(javascript)We were able to improve UX by managing its status using local storage.
// 페이지 이동 시 상태 유지
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]);
Gemini Tetris
2022.09🔗

A fan game for the virtual group RE:REVOLUTION. Developed as a commissioned birthday fan game for a group member. Developed using Unity and built with WebGL to allow gameplay on the web. Added button UI for mobile environments in addition to PC to ensure playability across various platforms.

Team

1-Person Development

Role

End-to-end game development

Unity
C#
WebGL
Challenges
ProblemEnsure cross-platform compatibility
SolutionWe built a browser-based play environment using the WebGL build, and designed a responsive UI by modifying the built html file.
ResultThe problem of providing the same gaming experience on PC and mobile devices arose.
ProblemOptimized mobile touch interface
SolutionThe touch button UI is additionally implemented to correspond to functions that can be operated with the existing keyboard.
ResultThe game had to be intuitive to operate even in a mobile environment without a keyboard.
ProblemAlign block coordinates for different aspect ratios
SolutionWebGL Solved by applying a relative coordinate system by dynamically adjusting the container size to match the screen ratio.
ResultIn the case of mobile devices, the screen ratio was different for each model, so the positions of Tetris blocks were misaligned.
ProblemOutsourcing project quality management
SolutionWe reflect client feedback as quickly as possible and make iterative improvements. Minimize bugs by directly conducting QA tests.
ResultAlthough it was a one-person development and outsourcing in a situation where QA did not exist, quality that matched the brand image was required.
ProblemEnsure completeness within a short development period
SolutionMake the most of Unity's existing assets, develop core features and main game logic first, and then update additional features sequentially. Accordingly, at first there was only normal mode, but due to the nature of the web, modifications were possible after distribution, so easy mode was added later.
ResultWe had to produce a complete game within a limited time of one month.
Show Code (1)
Game Logic Code Excerpt(csharp)Part of the game logic and level system have been implemented.
// 테트로미노 이동 및 충돌 검사
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;
    }
}
Onyurun
2023.01 - 2023.08

A fan game for the virtual group RE:REVOLUTION. Developed as a commissioned birthday fan game for a group member. A Cookie Run-style 2D side-scrolling runner game developed using Unity.

Team

1-Person Development

Role

End-to-end game development Level design Game balancing

Unity
C#
Challenges
ProblemUnity 2D Implementation of the core mechanism of the side-scrolling game
SolutionRealizes natural movement and flow using Unity Animator and implements a jump/slide system based on a physics engine.
ResultNatural character movement is required in a Cookie Run-style endless running game.
ProblemGame balance and difficulty adjustment
SolutionWe implemented a speed increase system by distance, conducted level design and play testing, and diversified item and obstacle placement patterns. In addition, the score system is adjusted according to distance, speed, and stage to balance the game.
ResultA difficulty design that allows players to feel an appropriate sense of challenge without being boring is required.
Show Code (3)
Game Manager Code Excerpt(csharp)A game manager was implemented using the singleton pattern.
// 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; }
}
Obstacle Generation System Code Excerpt(csharp)Patterns are generated randomly, but performance is optimized through object pooling.
// 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;
    }
}
Scroll Controller Code Excerpt(csharp)An infinite scroll system was implemented to prevent the player character's position movement and scrolling excessively.
// 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

An AI-powered smart meeting platform. Combines a LiveKit-based video conference with real-time STT subtitles, auto-generated meeting summaries, agenda management, and speaker queue control in a single room. Runs faster-whisper on CUDA for real-time Korean transcription with speaker diarization and hallucination filtering, and uses a self-hosted TURN server to keep the connection stable across network conditions.

Team

Team of 7

Role

Frontend meeting room UI and chat feature implementation STOMP WebSocket reconnection and timer event system design AI STT pipeline stability improvements Self-hosted TURN server and deployment pipeline setup

React
Vite
TypeScript
Tailwind
LiveKit
STOMP
Spring Boot
Java
MySQL
Redis
FastAPI
Whisper
Docker
Nginx
Jenkins
OpenVidu
coturn
Challenges
ProblemFull migration to LiveKit due to stability limits of the OpenVidu-based stack
SolutionRewrote the meeting-room core (video/audio tracks, screen share, speaker queue) on top of LiveKit. Designed a single LiveKit Service class to centralise track subscription / publishing / data channels. The LiveKit Data Channel was then reused to add in-meeting chat without spinning up a separate WebSocket.
ResultOpenVidu's library quirks and frequent connection drops made the live demo fragile. Leak bugs kept reappearing across composite actions like video toggle, screen sharing, and device switching.
ProblemBulk fix of 14 STT pipeline bugs the weekend before final demo
SolutionAdded a conservative _is_hallucination heuristic to block Whisper's repetition patterns. Shipped recovery for incomplete UTF-8 Korean tokens, finalize-timing adjusted to 3 seconds, forced flush when 4+ pending tokens accumulate, and per-speaker text delta extraction in one batch. This cleared duplicates, time-reversal, speaker bleed, time drift, and FFFD noise in a single release.
ResultOn the last weekend before the demo, the STT output was producing duplicates, time-reversed segments, speakers bleeding into each other, and missing finalize events all at once. Hallucination also kept slipping in, leaving the meeting log fundamentally untrustworthy.
ProblemStanding up our own coturn server to remove the external TURN dependency
SolutionRan coturn in OCI's Chuncheon region under the turn.pyan.kr domain. Opened ports 3478 (UDP/TCP) and 5349 (TLS), and switched both LiveKit client and server config to point at the self-hosted TURN. This cut the external cost line and gave reliable NAT-traversal even from corporate networks.
ResultWhile we relied on metered.ca for TURN, free-tier and bandwidth limits directly threatened demo stability. Unexplained long-meeting disconnects often traced back to the third-party service, so self-hosting became necessary.
ProblemCascading bugs around restoring meeting state on refresh / re-join
SolutionSynced meeting time using a relativeTime-based offset and reordered the recoveredTimeOffset initialization. Reorganised cleanupMeetingData declaration order and added defensive code so a refresh wouldn't leave empty sessions, ghost participants, or stuck token reissue. Device settings and camera / mic state are now restored automatically right after a refresh.
ResultOn refresh, the meeting clock would reset to zero, and users hitting the meeting URL directly without a token left ghost participants behind. Guests rejoining a meeting also hit STT outages caused by a variable-initialization order issue.
ProblemReconnection stability for STOMP WebSocket plus integrating in-meeting chat
SolutionUsed @stomp/stompjs's reconnectDelay, fatalCloseCodes, and onWebSocketClose hook to separate clean closes (1000) from abnormal ones. Protocol / policy errors stop the auto-reconnect loop, while transient drops retry up to three times at 1-second intervals. Chat rides on top of LiveKit Data Channel via sendChatMessage rather than spinning up a separate WebSocket connection.
ResultPreviously, refreshes or network jitter could send STOMP into an infinite retry loop, and even policy errors kept retrying and loaded the server. Running chat over a second WebSocket would have doubled the auth / reconnect surface for no real benefit.
Show Code (4)
STT hallucination filter (excerpt)(python)A conservative heuristic that blocks only obvious repetition patterns from Whisper STT output.
@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 chat (excerpt)(javascript)In-meeting chat ridden on top of LiveKit Data Channel instead of a separate WebSocket.
// 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 reconnect handler (excerpt)(javascript)Handler that separates clean from abnormal closes to avoid an infinite reconnect loop.
// 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 deployment pipeline (excerpt)(javascript)Jenkins pipeline that automates frontend build, env file generation, and docker deployment.
// 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 Video Pipeline
2026.03 - 2026.04

+2

A fintech personal-finance app that combines social login, bank-account linking, AI-driven budget management, and goal-achievement visualization videos in one flow. Monthly income is automatically allocated across weeks and recalculated against the remaining span when a goal is extended, and a LangGraph-based AI chatbot handles merchant classification and budget advice. Goal visualization videos are produced by a self-built image-to-video pipeline service that I designed and still operate (sketch.pyan.kr), which returns MP4, WebM, and GIF assets over a CDN.

Team

Team of 7

Role

Flutter-based frontend development Image-to-video pipeline service solo design and operation Jenkins CI/CD and Nginx HTTPS deployment setup Backend sketch integration and FCM notification separation

Flutter
Dart
Spring Boot
Java
PostgreSQL
Redis
FastAPI
LangGraph
Flask
Docker
Nginx
Jenkins
SSAFY 금융 API
Challenges
ProblemSplitting the image-to-video render path into a standalone microservice to keep the MOA backend lean
SolutionCombined Flask, a multi-threaded worker queue, a SQLite mapping (`goal_id → task_id → file path`), and FFmpeg post-processing (raw mp4 / compressed mp4 / WEBM / GIF, output_autocrop) into a single service. Protected `/api/*` endpoints with API keys and exposed only `/cdn/*` as public download links. Ran it as `gunicorn -w 1 --threads 8` so the in-memory queue stays consistent across requests. Set up Nginx + Let's Encrypt + the `sketch.pyan.kr` domain solo.
ResultRe-receiving and re-uploading the rendered mp4 from the MOA backend would have inflated response latency, and folding the external render step into a single backend transaction widened the failure surface too far. The render path needed to live in a separate service so the rest of the backend would not ride on it.
ProblemA demo fallback API that keeps the polling/FCM flow intact even when the external renderer is down
SolutionAdded a dedicated `/api/demo/generate` endpoint next to the regular `/api/generate`. When the matching condition holds, it copies a canned mp4 and returns a normal `ready` task as if the render had succeeded. The endpoint takes `user_id`, `plan_id`, and `plan_title` as branching keys to map the demo asset. Kept the interface identical so the MOA backend's `task_id` storage and `/api/status/{task_id}` polling stay untouched.
ResultThe external renderer can go down regardless of the SSAFY demo schedule, in which case MOA's goal-creation flow would never reach `ready` and the live demo would break. A fallback that finishes the flow without touching backend polling or FCM code was required.
ProblemRaw SSE relay layout for streaming chatbot tokens through Spring -> FastAPI(LangGraph) -> Flutter
SolutionOn the FastAPI side, split the LangGraph agent's output into `token`, `tool_call`, and `complete` events and emitted them as SSE. The Spring backend stopped transforming the response body and just relayed it raw at the controller level. On Nginx, set `proxy_buffering off`, added `X-Accel-Buffering: no`, and bumped read/send timeouts to 1200 s to kill mid-stream buffering and disconnects. The Flutter side consumed the stream with an EventSource client, branching per event type.
ResultEarly on, Spring's ResponseBody conversion was batching the SSE stream and delaying the first token by 5–10 s. Nginx default buffering had the same effect, leaving users with the impression that the answer had stalled.
ProblemUnified weekly-allocation correctness across goal create / extend / read-sync
SolutionAdjusted goal creation so the allocation span snaps to whole weeks. Reworked goal extension to preserve already-elapsed weeks and redistribute only the remaining span up to the new end date. Brought the read-sync step's allocation range under the same rule. Demo plan seeding got its own protection branch so demo data is never overwritten by user input.
ResultThe per-week savings figure was drifting depending on which path updated the goal, producing recurring regressions. The home screen, the menu, and the goal-detail chart could each show a different number for the same goal.
ProblemSplitting Google login flows per build environment (debug / release / web)
SolutionMapped distinct Google Cloud OAuth client IDs to debug and release builds and split their verification flows. Added a release-signing fallback so a build without a signing config still lands on the debug auth path instead of breaking. Migrated the web build off the deprecated gapi flow to the official GIS (Google Identity Services) path with its own client ID. Unified the token-verification response into a single model.
ResultInitially, debug builds were trying release OAuth clients and getting `invalid_client` repeatedly, while the web build's deprecated gapi popup was getting blocked at first sign-in. Each environment kept eating debug time and delaying other tracks.
Show Code (4)
Sketch worker queue enqueue (excerpt)(python)Code that enqueues an image-to-video render task into the in-memory queue so background workers can pick it up.
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 (excerpt)(python)Endpoint that maps a pre-baked canned mp4 to a ready task so the flow finishes cleanly even when the external renderer is down.
@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
Plan weekly allocation calculator (excerpt)(java)Splits the goal range into Monday-aligned weeks, divides the target amount evenly, and folds the remainder into the final week.
@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 chatbot stream consumer (excerpt)(dart)Decodes dio's streamed response, splits SSE frames on blank-line boundaries, and dispatches token / tool_call / complete events to the UI.
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 - currently maintained🔗

An agent-agnostic sidecar that drops an AI-powered live-edit overlay on top of a running web app. A single <script> tag injects a Shadow DOM overlay; users click a UI element and describe a change in natural language, and the configured AI agent CLI edits the actual source, runs the build, and either rsyncs the result to the live app or opens a PR. Ships with five agent adapters (OpenClaw, Claude Code, Codex, Aider, Gemini) and Next.js, Vite, Astro, and SvelteKit examples, and is published to npm as a self-hosted package with HMAC actor signing and a systemd template. In 2026-05, the project was accepted into the official pollinations.ai OSS app showcase at Flower Tier.

Team

Solo

Role

Express sidecar and Shadow DOM overlay design Six AI agent adapter implementations Production hardening with HMAC signing and systemd integration Test and CI setup with npm public release

TypeScript
Node.js
Express
Next.js
Vite
Astro
SvelteKit
Claude Agent SDK
systemd
HMAC
GitHub Actions
Challenges
ProblemAbstracting a single interface across many coding agents
SolutionDefined a ~70-line AgentRunner TypeScript interface and split each backend into adapters that implement the same contract. A single PYANCHOR_AGENT env var swaps between OpenClaw, Claude Code, Codex, Aider, Gemini, and Pollinations instantly. A new adapter is usually 100-200 lines. The later Pollinations adapter joined as the first HTTP-only backend (no CLI install) on the same interface.
ResultThe coding-agent SDK landscape was changing rapidly, and the sidecar needed a structure that didn't lock the codebase to one LLM. The initial implementation assumed only OpenClaw, so every new agent required wide-reaching changes.
ProblemOnboarding cliff right after the first init
SolutionAdded auto-loading of cwd's .env, auto-detection of a free port, and a paste-ready bootstrap snippet on init output. Introduced the pyanchor doctor command to surface every boot-time check at once, with a per-item fix hint so the user can self-diagnose. This settled the first-init → first-edit path to under 30 seconds.
ResultEven after npx the sidecar would start but the cwd .env wasn't loaded and the prod hostname wasn't trusted, so the very first edit silently failed. First-time users repeatedly gave up while debugging, hurting retention.
ProblemFirst-edit failure caused by a default-model identifier leak between adapters
SolutionSplit per-adapter default model identifiers and reordered the fall-through so the adapter's default only applies when the env var is unset. Added a detectModel() abstraction on the adapter interface so future backends can't trip on the same trap. The v0.32.3 hotfix restored first-edit success rate from 0% to normal.
ResultThe default value of PYANCHOR_AGENT_MODEL carried OpenClaw's routing prefix into codex / aider / claude-code adapters. For early v0.32.x users this regressed first-edit success to 0%.
ProblemSelf-exit race when running the sidecar under systemd
SolutionIdentified a GC race that reclaimed the listen handle too early; pinned an explicit reference to the handle to preserve its lifetime. Patched the bootstrap snippet so the prod hostname is automatically added to the trust list at startup. Marked two vitest cases that the GC fix unmasked, so they remain traceable. From v0.32.4 onward the sidecar runs cleanly under systemd.
ResultUnder systemd, server.cjs would self-exit roughly one second after starting to listen. The issue never reproduced on a developer laptop, which made diagnosis slow.
ProblemHardening the gate cookie against forgery when the token leaks
SolutionReplaced the cookie value itself with an HMAC-signed JWT that carries actor info. The sidecar verifies the signature on every request using PYANCHOR_GATE_COOKIE_HMAC_SECRET, and a new /_pyanchor/unlock?secret=X endpoint folds the magic-word gate and actor identification into one step. The v0.17 `=1` plain cookie is now explicitly rejected as unsigned. This enabled per-actor audit logs and bounced outsiders who only learned the token at 405 / 403.
ResultEven in a self-hosted setup, leaking the token meant anyone could forge the cookie from a devtools console in one line. When multiple people shared an instance, the audit log only ever showed the same anonymous token, so per-actor tracing was impossible.
Show Code (4)
AgentRunner interface (excerpt)(typescript)The stable interface every AI agent backend must implement.
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 issue / verify (excerpt)(typescript)Issues and verifies HS256 JWTs using only node:crypto.
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 adapter brief builder (excerpt)(typescript)Prompt builder that hands the external CLI a consistent context shape.
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 adapter (excerpt)(typescript)An OpenAI-compatible tool-loop adapter that needs no CLI install.
// 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

A web-based algorithm problem-solving platform powered by a LangGraph AI coding agent. The agent assists in real time with code writing, debugging, and hints, while its full execution flow is traceable per session through a Langfuse-style Trace viewer. A per-user harness system lets you customize the agent's behavior and prompts without touching the codebase.

Team

Team of 6

Role

Frontend sole developer: full design and implementation of IDE, Trace, Harness, and Reports (65% of all commits) IDE workbench: Monaco Editor multi-tab, panel resize, diff view LangGraph agent Trace viewer (TraceWorkbench) designed and built from scratch Agent SSE streaming chat panel and worktree card UI Harness page: build/activate flow and BYOK (user API key) settings Submission result panel: QUEUED / RUNNING / COMPLETED states with live queue position Report page: radar chart and markdown rendering Backend API integration and response spec compatibility fixes (16 cases)

Next.js
TypeScript
React
Zustand
TanStack Query
Monaco Editor
Spring Boot
Java
PostgreSQL
Redis
RabbitMQ
LangGraph
Python
FastAPI
Docker
Nginx
Jenkins
Challenges
ProblemFixing 6 Monaco Editor WebWorker lifecycle race conditions at once
SolutionFixed worker URL by explicitly setting loader.config, normalized auxiliary input, guaranteed editor.dispose() on tab close, and removed the getWorker override: all 6 lifecycle bugs handled in a single release. Reorganized mount/unmount order to prevent duplicate worker creation under React StrictMode double-mount.
ResultRepeated freezes and stale file content in Monaco editor after extended use or tab switching. Missing getWorker definition caused console errors, and uncleared Web Workers led to accumulating memory leaks in the browser.
ProblemFixing 4 missing SSE cleanup handlers in the AI agent streaming panel
SolutionUnified SSE EventSource cleanup functions as useEffect return values, ensuring connections are forcibly closed on component unmount. Moved pending-state resets into the cleanup sequence to fix partial tokens freezing on screen when users navigate away mid-stream.
ResultNavigating away from agent chat left SSE connections open and leaking memory. When streaming was interrupted mid-way, the last partial token would remain on screen, making the message appear cut off: a case that recurred repeatedly.
ProblemUI adaptation for new QUEUED state after backend RabbitMQ queue introduction
SolutionAdded a QUEUED branch to SubmissionResultPanel and displayed the live queue position (1-based queuePosition) in real time. Kept the elapsed timer ticking every 0.5 s while QUEUED so users can see wait time intuitively. Updated the polling logic to treat QUEUED as an in-progress state, blocking the early-exit regression.
ResultRight after the backend introduced the scoring queue, the frontend failed to recognize QUEUED status, leaving a blank screen with no "scoring in progress" indicator, and polling terminated too early.
ProblemHarness buildHarness race condition: build not guaranteed before first send
SolutionInserted a forced buildHarness call immediately before the first agent message send, then immediately revalidated the related query cache after completion. Also auto-opened the AGENTS.md tab right after harness application so users can instantly confirm the change took effect.
ResultChanging harness settings and immediately running the agent would sometimes execute with the previous version's runtime_config. There were also cases where the agent started before runtime_config compilation finished, responding with an empty harness state.
Show Code (3)
Trace viewer: poll only while a run is active(typescript)Stops polling automatically when no RUNNING traces remain, and refreshes span data every 2 s for the selected run while it is still in progress.
// 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 state: scoring queue UI(typescript)Treats QUEUED the same as RUNNING to reuse existing polling and timer logic, and displays the live queue position so users can see their wait status intuitively.
// 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}초`;
Harness apply: forced buildHarness call with immediate cache invalidation(typescript)Forces a buildHarness call right after saving AGENTS.md to guarantee runtime_config_json compilation, then immediately invalidates related query caches so harness changes take effect on the very next agent run.
// 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

Awards

2023-11

KIT Engineering Fair Bronze Prize

·Kumoh National Institute of Technology LINC3.0 Project Group

Activities

2024-01

CO-UP CAMPUS CHATBOT HACKATHON Participation

·Ministry of Education and 6 Other Organizations

Licenses

2024-09

Information Processing Engineer

·Human Resources Development Service of Korea
2025-01

JLPT N2

·Japan Foundation
2026-03

OPIc IH

·ACTFL

Education

2025.07Present

SSAFY

14th Cohort · Mobile Track

2019.032025.02

Kumoh National Institute of Technology

Bachelor of Engineering · Department of Computer Software Engineering

2016.032019.02

Daegu Siji High School

High School Diploma · Science Track

Let's Work Together

Open to project inquiries, collaboration proposals, and job opportunities.

© 2026 Lee Sang-Heon. All rights reserved.

Built with Next.js, FastAPI & Claude Code.