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

イ・サンホン

Full Stack Developer & Game Programmer

Experience

開発研究員
·(株)ウェルビアドットコム·2021.12 – 2023.02

ゲームのアンチチートモジュールおよびプログラムであるXigncode3を開発する会社です。 · C++で開発されたセキュリティモジュールをUnity、Androidプラットフォームに適用 · JNIフレームワークを活用したC++、Java間クロスプラットフォーム連動実装 · Unreal Engine(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を設計・実装し、ユーザーが利用しやすいサービスを作ることを目指しました。 ブルートフォースとインターバルマージアルゴリズムの2つのアルゴリズムを状況に応じて使用し、可能な限り速く空き時間を見つけるように実装しました。

チーム構成

4人チーム

役割

フロントエンドUI実装 フロントエンドAxios通信、データレンダリング、セッション管理機能の実装 ブルートフォースと区間マージアルゴリズムを活用した空き時間検索アルゴリズムの実装 バックエンドCORS管理およびコントローラ一部実装

JavaScript
React
Axios
Java
Spring
MySQL
課題と解決
問題複雑な空のスケジュール検索アルゴリズムの最適化
解決策ブルトフォース、インターバルマージアルゴリズム、二分探索、ハッシュテーブルなど、複数のアルゴリズムを活用してプロトタイプを開発して速度を測定する。 状況に応じて最適なアルゴリズムを選択して実装します。 これに単純日付計算はブルトフォースで、分単位の時間計算ではインターバルマージアルゴリズムを採用して演算時間を10秒以内に短縮する。
結果単純ブルトフォースアルゴリズムを活用する場合、複数のユーザーのスケジュールを総合して共通に空き時間を見つける操作が10分以上かかるという問題が発生。
問題認証方式急変に伴うソースコード前面修正
解決策Axiosインターセプターを提案し、これを適用し、トークン自動管理システムを実装し、既存のコードを最大限保存することができた。
結果締め切り 1週間前、セッションベースの認証方式で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の既存アセットを最大限活用し、コア機能とメインゲームロジックをまず開発した後、アドオンを順次更新する。 これに最初はノーマルモードだけだったが、Webという特性上、配布後修正が可能でイージーモードを今後追加する。
結果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、話者ごとのテキスト delta 抽出といった安定化を一度に進めた。 これにより STT の重複/時間逆転/話者入れ替わり/時間ズレ/FFFDノイズを1リリースで総整理した。
結果発表直前の最後の週末、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 の形で載せて1接続内で処理した。
結果従来はリフレッシュや回線変動で 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)フロントエンドのビルド/環境変数/Docker デプロイまでを自動化した 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) を 1 つのサービスにまとめた。 `/api/*` エンドポイントは API キーで保護し、`/cdn/*` のみを公開ダウンロードとして露出させた。 `gunicorn -w 1 --threads 8` の単一ワーカー + マルチスレッド構成にして、インメモリキューの整合性を保証した。 Nginx + Let's Encrypt + `sketch.pyan.kr` ドメインまで単独で構築した。
結果モアバックエンドが大容量 mp4 を再受信して再送するとレスポンス遅延が増え、外部レンダリング工程を単一のバックエンドトランザクションに含めると失敗の影響範囲も大きくなる状況だった。 動画処理は別サービスに切り出さなければバックエンド全体の安定性を確保できない前提だった。
問題外部レンダリング障害に備える demo fallback API の分離
解決策既定の `/api/generate` とは別に `/api/demo/generate` エンドポイントを追加した。 条件を満たす場合は事前に用意した canned mp4 をコピーして、正常な `ready` 作業のように返すようにした。 `user_id`、`plan_id`、`plan_title` を分岐キーとして受け取り、デモ用動画にマッピングするようにした。 モアバックエンドの `task_id` 保存と `/api/status/{task_id}` ポーリングフローはそのまま使えるよう、インターフェースを完全に同一に保った。
結果外部レンダリングは SSAFY の発表日程と無関係に停止する可能性があり、その場合モアの目標生成フローが `ready` 状態に到達せず、ライブデモが破綻する危険があった。 バックエンドのポーリングと FCM 通知のコードを触らずに正常終了できる構造が必要だった。
問題チャットボット SSE ストリーミングの raw relay
解決策FastAPI 側で LangGraph エージェントの実行結果を `token`、`tool_call`、`complete` などのイベントに分けて SSE として配信するようにした。 Spring バックエンドはレスポンスボディを変換せず、そのまま raw relay する方針に整理した。 Nginx 側では `proxy_buffering off` と `X-Accel-Buffering: no` を適用し、read/send タイムアウトを 1200 秒に引き上げて中間バッファリングと切断を遮断した。 Flutter 側は EventSource クライアントでストリームを購読し、イベント種別ごとに分岐処理した。
結果初期構成では Spring の ResponseBody 変換が SSE をまとめてから返してしまい、最初のトークンまで 5〜10 秒遅延する問題があった。 Nginx のデフォルトバッファリングも同様に作用し、ユーザーには応答が止まっているように見える状態だった。
問題目標の週次 allocation 整合性の統合整理
解決策目標作成時に allocation 期間が週単位できれいに収まるように補正した。 目標延長では、すでに経過した週の allocation は保持し、残りの期間だけ新しい終了日までで再配分する形に統一した。 read sync 段階の allocation 範囲も同じ基準に合わせて修正した。 demo plan の seed には allocation 保護分岐を別途設け、デモデータがユーザー入力で上書きされないようにした。
結果同じ目標がどのフローで更新されたかによって週ごとの貯蓄額が異なる回帰が繰り返し発生していた。 ホーム画面、メニュー、目標詳細チャートが同一目標に対して別の数値を表示するケースもあった。
問題複数環境向け Google ログインフローの分離
解決策debug / release ビルドそれぞれに別個の Google Cloud OAuth クライアント ID を割り当て、検証フローを環境別に分離した。 release 署名 fallback を追加して、署名設定が欠けたビルドでも debug 認証経路で動作するようにした。 Web ビルドは廃止予定の gapi フローから公式の GIS (Google Identity Services) 経路に移行し、専用のクライアント ID を別に管理するようにした。 トークン検証のレスポンス構造も単一モデルに統一した。
結果初期は debug ビルドが release 用 OAuth クライアントで認証を試みて `invalid_client` エラーが繰り返し発生し、Web ビルドは廃止予定の gapi フロー経由でファーストサインイン時にポップアップがブロックされる問題があった。 環境ごとのデバッグ時間が積み重なり、他の作業まで遅れる状態になっていた。
コードを見る (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 のストリームレスポンスをテキストとしてデコードし、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 - 現在運用中🔗

稼働中のWebアプリの上にAIによるリアルタイム編集オーバーレイを載せる、エージェント非依存のサイドカーです。 1行のスクリプトタグでShadow DOMオーバーレイを注入し、ユーザーがUI要素をクリックして自然言語で変更を指示すると、選択したAIエージェントCLIがソースを実際に修正し、ビルドを走らせ、rsyncでの反映またはPR生成までを行うよう設計しました。 OpenClaw・Claude Code・Codex・Aider・Geminiの5種のエージェントアダプタと 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 環境変数を1つ変えるだけで OpenClaw・Claude Code・Codex・Aider・Gemini・Pollinations の間を即座に切り替えられる。 新規アダプタは通常100〜200行で追加できる。 後から追加した Pollinations アダプタも CLI インストール不要、HTTP のみで動作する最初のバックエンドとして同インターフェースに合流させた。
結果コーディングエージェントのSDK事情が急速に変化する中で、特定LLMに依存しないサイドカー構造が必要だった。 初期実装は OpenClaw 1種を前提としており、新規エージェント追加のたびに広範囲の改修を要する状態だった。
問題初回inits直後にサイドカーが起動しないことによるオンボーディングの断絶
解決策実行ディレクトリの .env 自動読み込み、空きポートの自動検出、貼り付けるだけで済むブートストラップスニペットの出力を導入した。 すべての起動時検査を一度に表示する pyanchor doctor コマンドを追加し、失敗項目ごとの修正ヒントで利用者が自力で原因にたどり着けるよう整理した。 この方策により、初回initから初回editまでの所要時間が30秒以内に収まるようになった。
結果npx 直後にサイドカーが立ち上がっても、cwdの .env が読まれず本番ホスト名が信頼リストから外れていて初回編集が静かに失敗する事象が頻発した。 初心者がデバッグ途中で離脱する経路が繰り返され、リテンションが低下していた。
問題アダプタ間でのデフォルトモデル識別子リークによる初回編集の失敗
解決策アダプタごとにデフォルトモデル識別子のマッピングを分離し、環境変数が未設定の場合のみアダプタ既定値を適用するようフォールスルー順を整理した。 アダプタインターフェースに detectModel() 抽象を追加し、新規バックエンドが同じ罠を踏まないようガードを設けた。 この方策により、v0.32.3 のホットフィックスで初回編集成功率が 0% から通常水準に戻った。
結果PYANCHOR_AGENT_MODEL の既定値に OpenClaw のルーティングプレフィックスが含まれていて、codex / aider / claude-code アダプタに伝播していた。 そのため v0.32.x 初期利用者の初回編集が 100% 失敗するリグレッションが発生した。
問題systemd 配下でのサイドカー自己終了レース
解決策GCが listen ハンドルを早期に回収するレースであると特定し、ハンドルへ明示的な参照を保持して寿命を保証するよう修正した。 ブートストラップスニペットでは本番ホスト名を起動時に信頼リストへ自動追加するガードを足した。 GC修正によって表面化した vitest 2件は明示的に印を付けて追跡可能な状態に残した。 この方策により v0.32.4 以降は systemd 配下でも安定稼働するようになった。
結果server.cjs が systemd 配下で listen 開始から約1秒で自己終了する不具合が発生した。 ローカル開発機では再現できず、診断に時間を要した。
問題トークン流出時に偽造可能だったゲートクッキーの強化
解決策クッキーの値自体を、アクター情報を持つ HMAC-signed JWT に置き換えた。 サイドカーは PYANCHOR_GATE_COOKIE_HMAC_SECRET ですべてのリクエストの署名を検証し、新設の /_pyanchor/unlock?secret=X エンドポイントがマジックワード判定とアクター識別を1ステップで完了させる。 v0.17 までの `=1` 平文クッキーは明示的に署名なしとして拒否する。 この方策によりアクター別の監査ログが可能になり、トークンだけを盗み見た外部利用者は署名なしクッキーで弾かれるようになった。
結果セルフホスト構成でもトークンが流出するとクッキーを devtools コンソールから1行で偽造できる状態だった。 また1つのインスタンスを複数人で共有した場合、監査ログが匿名トークンとしてしか残らず、アクター別の追跡ができなかった。
コードを見る (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に統一された形のコンテキストを渡すためのプロンプトビルダです。
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互換のツールループアダプタです。
// 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統合型コーディング学習プラットフォーム)
2026.04 - 2026.05

+2

LangGraphベースのAIコーディングエージェントとともにアルゴリズム問題を解くWebベース学習プラットフォームです。 エージェントがコード作成・デバッグ・ヒント提供をリアルタイムで補助し、実行フロー全体をLangfuseスタイルのTraceビューアでセッション単位に追跡できるよう設計しました。 ユーザーごとのハーネスシステムにより、エージェントの動作方針とプロンプトをコードを触らずにカスタマイズできるよう実装しました。

チーム構成

6人チーム

役割

フロントエンド専任: IDE・Trace・ハーネス・レポート全体の設計・実装(全コミットの65%) IDEワークベンチ: Monaco Editorマルチタブ・パネルリサイズ・diffビュー実装 LangGraphエージェントTraceビューア(TraceWorkbench): ゼロから単独設計・実装 エージェントSSEストリーミングチャットパネルおよびworktreeカードUI実装 ハーネスページ: ビルド/アクティブ化フロー・BYOK設定実装 採点結果パネル: QUEUED・RUNNING・COMPLETED状態とキュー待ち順表示 レポートページ: レーダーチャート・マークダウンレンダリング実装 バックエンド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.