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





+1
Discord上でメイプルストーリーに関する情報をユーザーに提供するチャットボットです。 Nexon Open APIを活用してリアルタイムのゲーム情報、キャラクター検索、ギルド情報などを提供しています。 約3年間サービス中で、現在約4,700サーバーで累計18万人以上が利用しています。
1人開発
チャットボットおよびウェブサイト開発 運用および保守全般
// 스레드풀 크기별 채널 분할하여 병렬 처리
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();
}
);
}
});
}// 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";
}
}// 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();
}// 슬래시 커맨드 처리 - 스타포스 시뮬레이터
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);
}// 스타포스 시뮬레이션 결과 임베드 생성
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();
}



韓国労総大邱支部の公式ウェブサイトです。 ウェブサイト内の受講申込機能を主として、全体的なウェブサイト開発外注を進めました。 開発主要機能としては、受講申込システムと管理者システムのフロントエンドとバックエンド全体を開発しました。
1人開発
プロジェクト設計 フロントエンド開発 バックエンド開発
// 숫자만 입력 가능하도록 하는 함수 (생년월일, 전화번호)
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;
};// 상태 우선순위 매핑으로 정렬 최적화
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万件以上の記事データをクロール
@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))




+1
グループおよび個人のスケジュール管理を支援し、グループ内メンバーの空き時間を見つけるコミュニティ形式の共有協働カレンダーです。 直感的なUIを設計・実装し、ユーザーが利用しやすいサービスを作ることを目指しました。 ブルートフォースとインターバルマージアルゴリズムの2つのアルゴリズムを状況に応じて使用し、可能な限り速く空き時間を見つけるように実装しました。
4人チーム
フロントエンドUI実装 フロントエンドAxios通信、データレンダリング、セッション管理機能の実装 ブルートフォースと区間マージアルゴリズムを活用した空き時間検索アルゴリズムの実装 バックエンドCORS管理およびコントローラ一部実装
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;
}// 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 [];
}
};// 페이지 이동 시 상태 유지
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]);


バーチャルグループRE:REVOLUTIONのファンゲームです。 グループメンバーの誕生日のためのファンゲーム外注を受けて開発しました。 Unityを使用して開発し、WebGLでビルドしてウェブ上でゲームをプレイできるように制作しました。 PCだけでなくモバイル環境のためのボタンUIを追加し、様々な環境でも問題なくプレイできるように制作しました。
1人開発
ゲーム開発全般
// 테트로미노 이동 및 충돌 검사
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人開発
ゲーム開発全般 レベルデザイン ゲームバランシング
// 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; }
}// 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;
}
}// 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); // 위치 리셋으로 무한 스크롤
}
}
}



AIベースのスマート会議プラットフォームです。 LiveKitを利用したビデオ会議の上に、リアルタイムSTT字幕、会議要約、アジェンダ管理、発言権管理を組み合わせ、ひとつの会議室ですべての流れを処理できるよう設計しました。 faster-whisperをCUDA環境で動作させ、韓国語のリアルタイム文字起こし、話者分離、ハルシネーション遮断を実装し、TURNサーバを自前で構築してネットワーク環境を問わず安定した接続を提供しました。
7人チーム
フロントエンド会議室UIおよびチャット機能の実装 STOMP WebSocket 再接続およびタイマーイベント体系の設計 AI STTパイプラインの安定性改善 TURNサーバの自前構築およびデプロイパイプライン構成
@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 위에 회의 내 채팅을 얹은 코드
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 정상/비정상 종료를 구분해 재연결 정책을 제어
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();
}
}
},// 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
'''
}
}
}
}




+2
ソーシャルログイン、口座連携、AIによる予算管理、目標達成の視覚化動画までを統合したフィンテック家計管理アプリです。 月収を週ごとに自動で振り分け、目標を延長すると残り期間に合わせて再計算されるよう設計し、LangGraphベースのAIチャットボットが加盟店分類や予算アドバイスを担当するように実装しました。 目標達成の視覚化動画は、別途設計し現在も自ら運用している自前の動画生成パイプラインサービス (sketch.pyan.kr) と連携させ、MP4・WebM・GIFをCDN経由で提供するよう構成しました。
7人チーム
Flutter ベースのフロントエンド開発 画像→動画変換パイプラインサービスの単独設計および運用 Jenkins CI/CD および Nginx HTTPS デプロイ構成 バックエンドの sketch 連携および FCM 通知の分離実装
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"]
# ... 슬롯 확보 후 헤드리스 브라우저 자동화 + 후처리
@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
@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) {}
}
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
}
}
}
}





稼働中の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 への公開リリース
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 };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;
}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 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 });
}
}
}




+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件
// 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 목록 갱신
},
});// 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}초`;// 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
Languages
Tools & Platforms
Achievements & Licenses
受賞
KIT Engineering Fair 銅賞
·国立金烏工科大学校 LINC3.0 事業団活動
CO-UP CAMPUS CHATBOT HACKATHON 参加
·教育部ほか6機関資格
情報処理技士
·韓国産業人力公団JLPT N2
·国際交流基金OPIc IH
·ACTFLEducation
SSAFY
14期 · モバイルトラック
国立金烏工科大学校
工学士 · コンピュータソフトウェア工学科
大邱シジ高等学校
高等学校卒業 · 理系
一緒に働きませんか
プロジェクトのご相談・協業のご提案・採用のお話、どうぞお気軽にご連絡ください。
© 2026 イ・サンホン. All rights reserved.
Built with Next.js, FastAPI & Claude Code.