Experience
개발연구원
·(주) 웰비아닷컴·2021.12 – 2023.02게임의 안티치트 모듈 및 프로그램인 Xigncode3를 개발하는 회사입니다. · C++로 개발된 보안 모듈을 Unity, 안드로이드 플랫폼에 적용 · JNI 프레임워크를 활용한 C++, Java 간 크로스플랫폼 연동 구현 · 언리얼 엔진(UE4) 소스 코드 분석을 통한 글로벌 오프셋 취약점 발견 및 대응방안 제시 · ACTk Obscured Variable 분석 및 메모리 변조 방지 메커니즘 연구 · Windows Powershell을 이용한 개발환경 자동화 스크립트 구축
부회장 및 리버싱 멘토
·국립금오공과대학교 정보 보안 동아리 BOSS·2023.03 – 2024.08회사를 다니며 습득한 게임 보안 지식을 바탕으로 후배들에게 멘토링을 제공했습니다. · 리버싱 교육 커리큘럼 설계 및 실습 자료 제작 · 디버깅 툴(IDA)을 활용한 정적 분석 멘토링 · 메모리 변조 툴(Cheat Engine 등)을 이용한 동적 분석 멘토링 · 비전공자도 이해할 수 있는 보안 기초 개념 교육 진행 · 내부 CTF 개최 및 동적 분석 관련 문제 제작
학부연구생
·신호처리 및 지능형네트워크 연구실·2023.07 – 2025.02학부연구생으로서 연구실에서 진행한 다양한 프로젝트에 참여하였습니다. · 프로젝트 내 프론트엔드 개발 · 프로젝트 내 UI/UX 디자인 · 마크다운 에디터 및 수식 에디터 결합 연구
Projects





+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를 설계 및 구현하여 사용자가 이용하기에 어려움이 없는 서비스를 만들고자 하였습니다. 브루트포스와 인터벌 병합 알고리즘의 두 알고리즘을 상황에 맞게 사용하여, 최대한 빠르게 빈 일정을 찾도록 구현하였습니다.
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
}
}
}
}





운영 중인 웹 앱 위에 AI 기반 실시간 편집 오버레이를 얹는 에이전트 비종속 사이드카입니다. 한 줄 스크립트 태그로 Shadow DOM 오버레이를 주입하여 사용자가 UI 요소를 클릭하고 자연어로 수정을 요청하면, 선택한 AI 에이전트 CLI 가 실제 소스를 수정하고 빌드한 뒤 rsync 배포 또는 PR 생성까지 수행하도록 설계하였습니다. OpenClaw, Claude Code, Codex, Aider, Gemini 다섯 개 에이전트 어댑터와 Next.js, Vite, Astro, SvelteKit 예제를 포함하여, HMAC actor 서명과 systemd 템플릿 등의 자체 호스팅 구조로 npm 에 공개 배포하였습니다. 2026-05 pollinations.ai 공식 OSS 앱 쇼케이스에 Flower Tier 로 등재되었습니다.
1인 개발
Express 사이드카 및 Shadow DOM 오버레이 설계 AI 에이전트 어댑터 6종 구현 HMAC 서명 및 systemd 통합 등 운영 보안 구현 테스트 및 CI 구축 후 npm 공개 배포
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 코딩 에이전트와 함께 알고리즘 문제를 푸는 웹 기반 학습 플랫폼입니다. 에이전트가 코드 작성·디버깅·힌트 제공을 실시간으로 보조하며, 실행 흐름 전체를 Langfuse 스타일 Trace 뷰어로 세션 단위로 추적할 수 있도록 설계하였습니다. 사용자별 하네스 시스템으로 에이전트의 동작 방침과 프롬프트를 코드를 건드리지 않고 커스터마이징할 수 있도록 구현하였습니다.
6인 팀
프론트엔드 전담: IDE·Trace·하네스·리포트 전체 설계 및 구현 (전체 커밋의 65%), IDE 워크벤치: Monaco Editor 멀티탭·패널 리사이즈·diff 뷰 구현, LangGraph 에이전트 Trace 뷰어(TraceWorkbench) 단독 설계 및 구현, 에이전트 SSE 스트리밍 채팅 패널 및 worktree 카드 UI 구현, 하네스 페이지: 빌드/활성화 흐름 및 BYOK(사용자 API 키) 설정 구현, 제출 채점 결과 패널: QUEUED·RUNNING·COMPLETED 상태 및 큐 대기 순서 표시, 리포트 페이지: 5축 레이더 차트·마크다운 렌더링 구현, 백엔드 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.