포트폴리오

2025.06.29

1. 소개글

안녕하세요. 프론트엔드 개발자 취업을 준비하는 허동영입니다.

저는 다양한 라이브러리와 설계들을 직접 구현하며 검증하고 학습하는 개발자입니다.

우선, 제가 지금까지 진행한 프로젝트들을 간단하게 소개하겠습니다.


1.1. NextJS 블로그 서비스 (TS)

NextJS 15 App Router + MongoDB + Authjs 등을 활용하여 유저의 회원가입부터 백오피스를 통한 유저 블로그 관리, 시나리오와 비즈니스 로직의 무결성과 안전성을 고려하며 진행되고 있는 프로젝트입니다.

실 서비스를 수준을 목표로 진행되고 있습니다.


1.2. 마크다운 -> AST 파서 (TS)

기존 Commommark와 깃헙 마크다운과 다른, Obsidian 스타일의 문법을 추구하는 md -> ast 파서를 만들고 npm에 테스트 버전으로 배포 중입니다.

헤딩, 코드 블럭, 인용문, 리스트(ul, ol), 구분줄, 인라인 스타일, 이미지, 영상 등을 지원하고 있습니다.

또한 내부적으로 인라인 파싱에 LRU 캐시를 적용하여 성능 최적화 또한 일부 되었습니다.

내부적으로, 블럭은 이전 라인의 상태를 기준으로 현재 라인을 읽어나가며 상태 전이를 통해 결정하며, 인라인 스타일은 정규식을 이용한 상태 머신으로 토큰화 후 ast트리로 변환합니다.


1.3. 바닐라 JS로 만든 React (JS)

Softear 부트캠프에서 바닐라 JS로 Taskify 웹을 제작하는 과제에서, 바닐라 JS로 직접 React를 만들어 보았고, 그것을 바탕으로 Taskify 웹을 제작해보았습니다.

React 의 createRoot 부터 시작해서, 기본적인 hook(useEffect, useRef, useState 등), createPortal, 내부 Node 객체(VDOM), 생명주기, 재조정 단계(VDOM 생성 및 비교) + 커밋 단계(DOM 적용 단계, useEffect 및 클린업 포함) 등을 직접 구현하였습니다.


1.4. 커스텀 마크다운 위지웍 에디터 (TS)

React를 기반으로 한 마크다운 위지웍 에디터 입니다.

Obsidian을 유사하게 따라하는 것을 목표로 했습니다.

지원하는 문법은 헤딩, 인라인 스타일만 지원하고 있으며 키보드 또는 마우스에 따른 Selection과 Range, Caret 설정이 가능합니다. 또한 Obisidan에도 있는 Selection(선택 영역)에 따른 마크다운 문법의 표시 유무 또한 제어됩니다.

브라우저, 모바일, 기타 등등 호환성이 현저히 부족한 프로젝트입니다. 따라서 폐기한 프로젝트입니다.



2. NextJS 블로그 서비스 (TS)

실제 배포 중이고, 이용 중인 프로젝트 이기에 프로젝트에 대한 링크는 비공개로 했습니다.

따라서, 프로젝트 핵심을 위주로 정리했습니다.

[서비스 페이지 이동](https://www.sim-log.com)


2.1. 유저 백오피스 UI/UX

저는 사용자에게 연속적인 작업환경이 제공되었으면 했습니다. 작업 중에 관련 다른 정보를 보기 위해 페이지를 벗어나게 되는 UX는 피하게 하고 싶었기에 웹 안에 다수의 창을 띄울 수 있는 윈도우 시스템을 설계했습니다.

이런 윈도우 시스템은 C++ 언어의 명령형 윈도우 시스템을 React의 컴포넌트에 녹여서, WindowProvider 라는 컴포넌트에 props로 명령 스택을 전달하게 하여 구현되게 했습니다.

export default Background() {
   ...중략...
                                       height: 0
                                })}>
                            <span>통계</span>
                        </button>
                    </li>
                </ul>
            </nav>
            <main>
                <ManagementProvider userInfo={userInfo} folderObj={folderObj} seriesObj={seriesObj} trie={trie} setWindows={setWindows}>
                    <WindowProvider commands={windowCommands}/>
                </ManagementProvider>
                <ClientOnly>
                    <TaskPanel/>
                </ClientOnly>
            </main>
        </div>
    )
}


WindowProvider 컴포넌트는 내부에 윈도우 컴포넌트를 관리하고 있습니다. 명령 스택을 전달 받으면 그거에 맞는 윈도우 id 또는 윈도우 객체를 통해 윈도우를 제어합니다.

아래는 내부에서 명령 처리 로직 입니다.

    useEffect(() => {
        if (!commands) return;

        const newWindowSet = new Set(windowSetRef.current);
        const newWindowMap = new Map(windowMapRef.current);

        if (commands.reset) {
            newWindowSet.clear();
        }

        if (commands.add) {
            commands.add.forEach(w => {
                if (newWindowSet.has(w.id)) {
                    // 이미 존재하는 윈도우
                    // z-index만 갱신
                    newWindowSet.delete(w.id);
                    newWindowSet.add(w.id);
                } else {
                    // 존재하지 않는 윈도우
                    newWindowMap.set(w.id, w);
                    newWindowSet.add(w.id);
                }
            })
        }

        if (commands.rename) {
            for (const id in commands.rename) {
                const w = newWindowMap.get(id)
                if (w) {
                    w.name = commands.rename[id];
                }
            }
        }

        if (commands.remove) {
            commands.remove.forEach(w => {
                newWindowMap.delete(w)
                newWindowSet.delete(w)
            })
        }

        if (commands.update) {
            commands.update.forEach(w => {
                if (newWindowSet.has(w.id)) {
                    // 이미 존재하는 윈도우
                    // z-index 갱신
                    newWindowSet.delete(w.id);
                    newWindowSet.add(w.id);

                    // 덮어쓰기
                    newWindowMap.set(w.id, w);
                } else {
                    // 존재하지 않는 윈도우
                    //newWindowMap.set(w.id, w);
                    //newWindowSet.add(w.id);
                }
            })
        }

        setWindowSet(newWindowSet);
        setWindowMap(newWindowMap);
    }, [commands]);


윈도우 시스템에서는 윈도우를 렌더링 하는 것 또한 최적화 되어야합니다.

이를 위해 WindowProvider는 내부에 애니메이션(DOM 조작)콜백 함수 큐를 가지고 있고, 그것을 단 1개의 RequestAnimation을 통해 프레임마다 처리하게 했습니다.


이런 애니메이션을 처리하는 클래스(또는 객체)는 ESM의 모듈 특징을 활용하여 유사 싱글톤을 사용하여 여러 컴포넌트에서 접근하게 했습니다

/**
 * 작업을 수행할 함수 타입
 */
type Task = () => void;

/**
 * 애니메이션 프레임 ID 타입
 */
type Id = number | null;

/**
 * 프레임 단위로 작업을 실행하는 스케줄러 클래스
 */
class FrameScheduler {
    /** 실행할 작업들을 저장하는 큐 */
    #taskQueue = new Set<Task>();

    /** 현재 실행 중인 애니메이션 프레임의 ID */
    #id: Id = null;

    constructor() {
    }

    /**
     * 큐에 있는 모든 작업을 실행하는 메서드
     */
    runTask = () => {
        const taskQueue = this.#taskQueue;
        taskQueue.forEach((task) => task());
        this.startFrameLoop()
    }

    /**
     * 프레임 루프를 시작하는 메서드
     */
    startFrameLoop() {
        this.#id = requestAnimationFrame(this.runTask);
    }

    /**
     * 프레임 루프를 종료하는 메서드
     */
    endFrameLoop() {
        if (this.#id) {
            cancelAnimationFrame(this.#id);
        }
    }

    /**
     * 새로운 작업을 큐에 추가하는 메서드
     * @param task 추가할 작업
     */
    addTask(task: Task) {
        this.#taskQueue.add(task);
    }

    /**
     * 큐에서 작업을 제거하는 메서드
     * @param task 제거할 작업
     */
    removeTask(task: Task) {
        this.#taskQueue.delete(task);
    }
}

const frameScheduler = new FrameScheduler();
export default frameScheduler;


그리고 WindowLayout에는 Pointer 이벤트를 활용하여 윈도우의 리사이징과 위치 조절, z-index, 포그라운드 여부(포커스 여부)를 제어합니다.


    useEffect(() => {
        const el = elementRef.current!;
        el.style.transform = `translate(${currentLayout.current.x}px, ${currentLayout.current.y}px)`;
        el.style.width = `${currentLayout.current.width}px`;
        el.style.height = `${currentLayout.current.height}px`;

        const updateStyle = () => {
            /*
            const currentX = currentLayout.current.x;
            const currentY = currentLayout.current.y;
            const currentWidth = currentLayout.current.width;
            const currentHeight = currentLayout.current.height;

             */

            const nextX = nextLayout.current.x;
            const nextY = nextLayout.current.y;
            const nextWidth = nextLayout.current.width;
            const nextHeight = nextLayout.current.height

            const isEqual = (Object.keys(currentLayout.current) as (keyof Layout)[]).every(key => currentLayout.current[key] === nextLayout.current[key]);
            if (isEqual) return;

            // 이동 거리 계산
            //const dx = nextX - currentX;
            //const dy = nextY - currentY;
            //const distance = Math.sqrt(dx * dx + dy * dy);

            // 거리 비례 시간 (최소 0.05s, 최대 0.5s)
            //const pixelsPerSecond = 1000;
            //const duration = Math.max(0.05, Math.min(0.5, distance / pixelsPerSecond));

            const el = elementRef.current!;

            // 트랜지션 설정
            //el.style.transition = `transform ${duration}s ease`;

            // 위치 적용 (애니메이션 시작)
            el.style.transform = `translate(${nextX}px, ${nextY}px)`;
            el.style.width = `${nextWidth}px`;
            el.style.height = `${nextHeight}px`;

            // 현재 값 갱신
            currentLayout.current = {
                x: nextX,
                y: nextY,
                width: nextWidth,
                height: nextHeight,
            }
        };

        frameScheduler.addTask(updateStyle)

        return () => {
            frameScheduler.removeTask(updateStyle)
        }
    }, [elementRef]);


2.2. 비동기 직렬화

백오피스에서 중요한 것은 버전 관리라고 생각합니다. 사용자가 동시에 여러 작업을 요청할 수 있고, 앞선 작업의 성공 유무에 따라 이후 작업이 영향을 받을 수 밖에 없습니다.

하지만 fetch는 요청할때마다 날라가는 비동기 작업이기에, 이것을 따로 제어해 줄 클래스 또는 객체가 필요 했습니다.

이를 위해, 비동기작업을 관리하는 AsyncManager 클래스를 만들었습니다.


2.2.1. 핵심 동작 코드

클래스는 add, remove, reset, error, notify 등등 여러 메서드가 있고, 그것을 제어하는 플래그인 isIdel, isError 가 있습니다.


아래는 큐에 저장된 비동기 콜백을 순차적으로 실행하고 그것을 구독자에게 알려주는 메서드 입니다.

    private async runAsyncTask() {
        for (const asyncTaskUnit of this.asyncTaskQueue) {
            this.isIdle = false;
            this.notify();

            const {asyncTask, errorCallback, successCallback} = asyncTaskUnit;

            const result = await asyncTask(this.prevValue);

            if (result.status === "success") {
                this.prevValue = result.nextValue
                this.asyncTaskQueue.delete(asyncTaskUnit);
                this.addRecentCompleted(asyncTaskUnit);
                this.completedCount += 1;
                successCallback(result.data);
                this.notify();
            } else {
                this.isError = true;
                this.isIdle = true;
                this.errorMessage = errorCallback(result.data);
                this.notify();
                // 에러 나면 멈추기
                break;
            }
        }
        this.isIdle = true;
        this.notify();
    }


2.2.2. TS 타입 추론 활용

앞서 말했듯이 버전 관리가 비동기 관리의 필수 기능 중 하나 입니다.

앞선 비동기 요청이 성공하면 서버는 새 버전 정보를 반환하고, 클라이언트는 새로 받은 버전 정보를 다음 요청의 버전 정보에 넣어서 fetch를 보내게 됩니다.

여기서, 버전 정보는 고정적인 타입이기에 string으로 처리가 가능하지만, 응답으로 오는 data의 타입은 유동적이기에 제네릭을 활용해야 했습니다.

이를 가장 잘 보여줄 수 있는 코드입니다.

/**
 * @description 비동기 작업 단위의 타입
 * @template T 성공 데이터의 타입
 * @template E 에러 데이터의 타입
 */
export type AsyncTaskUnit<T = any, E = any> = {
    name: string;
    content: string;
    time: Date;
    asyncTask: AsyncTask<T, E>;
    errorCallback: ErrorCallback<E>;
    successCallback: SuccessCallback<T>;
};

성공 데이터 타입 = T, 실패 데이터 타입 = E 를 활용하여, 하나의 비동기 작업에 대한 성공, 실패의 타입을, 비동기 작업 추가 시점에 정적 추론이 가능하게 했습니다.

그렇게 한 이유는, 아래처럼 비동기 작업 추가 로직을 외부 로직으로 추상화 하기 위해서 입니다.

import patchBySession from "@/fetch/client/about/patchBySession";
import {AsyncTaskUnit, typedAsyncTaskUnit} from "@/utils/AsyncTaskManager";
import {UserInfoResponse} from "@/lib/mongoDB/types/documents/userInfo.type";
import {AboutInfoResponse} from "@/lib/mongoDB/types/documents/aboutInfo.type";


type Params = {
    userId: AboutInfoResponse['user_id'];
    content: AboutInfoResponse['content'];
    lastModified: UserInfoResponse['last_modified'];
};

type CreateUpdateAboutAsyncTask = (params: Params) => AsyncTaskUnit;
export const createUpdateAboutAsyncTask: CreateUpdateAboutAsyncTask = (params) => {
    return typedAsyncTaskUnit({
        name: "소개글 업데이트",
        content: ``,
        time: new Date(),
        asyncTask: async (prevValue) => {
            // 1. fetch 요청 보내기
            const promise = patchBySession({
                lastModified: prevValue ?? params.lastModified,
                userId: params.userId,
                content: params.content
            })

            // 4. fetch 응답 처리
            const result = await promise;

            if (result.status === 200) {
                return {
                    status: "success",
                    data: result,
                    nextValue: result.data.lastModified
                }
            } else {
                return {
                    status: "error",
                    data: result
                }
            }
        },
        errorCallback(err) {
            console.error(err);
            switch (err.status) {
                case 400:
                    break;
                case 401:
                    break;
                case 404:
                    break;
                case 408:
                    break
                case 409:
                    break;
                case 500:
                    break;
            }
            return err.message
        },
        successCallback() {

        },
    })
}

위 코드는 비동기 작업 객체를 만드는 함수입니다. 여기서 단순히 객체를 만들어서 넘기게 하면 비동기 작업을 추가할 때, 콜백 함수 끼리 타입 추론이 안되어서 에러가 발생합니다. 이를 막기 위해, 제네릭을 활용한 타입 가드 함수를 따로 만들었습니다.

/**
 * @description AsyncTaskUnit의 타입을 보장하는 유틸리티 함수
 * @template T 성공 데이터의 타입
 * @template E 에러 데이터의 타입
 */
export function typedAsyncTaskUnit<T, E>(params: AsyncTaskUnit<T, E>): AsyncTaskUnit<T, E> {
    return params
}

이렇게해서, 모든 비동기 작업 객체 생성 함수를 모두 컴포넌트 밖 handlers 폴더에 추상화 할 수 있었습니다.


2.3. 백오피스 영상




\[3333](https://youtu.be/SwSVdyQCm_E)

포트폴리오 - sim-log