본문 바로가기

vscode-with-tistory

vscode extension 개발일기7: 테스트 코드 이식

앞의 과정에서 기능은 구현되었다. 그러나 기능을 테스트 유닛에서 구현되었으므로 실제 extension에 적용된 것은 아니다. 그래서 extension에 구현된 기능을 삽입해야 한다. 이 과정에서 어떤 과정을 거쳐 코드를 최적화시켰고 구현중 빠진 기능이 무엇이며 이를 어떻게 구현하였는지를 서술한다.

테스트 코드 이식

1. 카테고리 ID불러오기

테스트 코드작성중 빠트린 내용이다. 사용자 측면에선 카테고리가 이름으로 보이지만 티스토리 입장에선 카테고리 ID라는 정수로 표현된다. 사용자는 카테고리ID를 알 수 없기 때문에 사용자가 카테고리명을 입력하면 이를 통해 티스토리에 카테고리 리스트를 받아 탐색을 수행하여 카테고리 ID를 대신 매핑시켜준다.

2. Class 작성

전처리를 수행해야 하는 옵션들이 많아 api에서 if문으로 처리하기엔 코드가 길어질 것 같아서 타입스크립트의 getter, setter기능로 전처리, 후처리를 수행하기로 하였다.

date 정보 전처리 및 후처리

date정보는 yyyy-mm-dd hh:mm:ss 형식으로 작성된다. 사용자에 따라 굳이 시, 분, 초까지 입력하여 예약 발생을 수행하는 사용자는 많지 않을 것으로 판단하여 년,월,일만 작성하여 예약발행을 수행할 수 있도록 한다.

setter: 해당 정보를 Date객체로 변환을 수행한다. 변환을 수행하는 이유는 파싱된 date의 정보가 과거 시간대를 표시하는지를 getter에서 수행하기 위함이다.

getter은 저장된 날짜가 미래의 시간을 나타내는지 확인하고 미래시간일 경우 timestamp형태로 반환하고 아니면 공백을 반환한다.

post 정보 전처리 및 후처리

post정보는 public, protect, private가 string으로 입력되지만 tistory에 전송할 때는 매칭되는 숫자로 변환해서 보내주어야 한다. 이를 위해 setter와 getter을 다음과같이 작성하였다.

setter: public, protect, private여부를 확인하고 이에 매칭되는 Enum객체 값으로 저장한다. 이때 여러 옵션이 존재할 수 있기 때문에 파싱된 정보가 public, private, protect중 1개만 존재하는지를 정규식으로 확인한다.

getter: 저장된 Enum값을 확인하고 이를 반환한다.


기능 분리

테스트 코드에서 작성된 코드를 다음과 같이 분리하였다

  1. 파일을 한줄 씩 읽는 비동기함수
  2. 정보를 파싱하는 함수
  3. 태그 정보를 파싱하는 함수
  4. 네트워크 통신을 수행하는 함수
  5. 전송 완료 후 데이터에 postId 정보를 추가해주는 함수(test수행하지 않은 함수)

기능마다 함수로 분리를 수행하였다.


extension작성

사용자가 사용할 수 있는 extension에 포스팅을 수행하는 pushOne함수를 다음과 같이 작성하였다.

const pushOne = vscode.commands.registerCommand(
        "vscode-with-tistory.pushOrUpdatePost",
        () => {
            vscode.window.showInformationMessage("포스팅 수행중...");
            postBlog()
                .then((url) => {
                    vscode.window.showInformationMessage(`포스팅 완료: ${url}`);
                })
                .catch((error) => {
                    showErrorMessage(error);
                });
        }
    );

여기서 catch의 부분의 showErrorMessage는 커스텀 함수이며 코드는 다음과 같다.

const showErrorMessage = (error: any): void => {
    function isError(candidate: any): candidate is Error | TypeError {
        return candidate.isError === true;
    }
    if (isError(error)) {
        let hasValue = false;
        for (let enumValue in ERROR_MESSAGES) {
            if (error.message === enumValue) {
                showErrorMessage(error.message);
                hasValue = true;
                break;
            }
        }
        if (!hasValue) {
            vscode.window.showErrorMessage(ERROR_MESSAGES.NotDesignateError);
        } else {
            vscode.window.showErrorMessage(error.message);
        }
    } else {
        vscode.window.showErrorMessage(error.message);
    }
};

필자가 예상한 오류가 반환될 시 오류를 메세지박스로 출력해주고 예상치 못한 오류가 발생할 시 기본 오류를 반환한다.


개발 시 겪은 문제점 및 해결

테스트 코드를 이식하면서 다음과같은 문제점을 겪었고 해결한 방법에 대해 서술한다.

1. 파일을 못읽는다

테스트 코드 부분엔 다음의 로직이 들어있다.

for await (const line of readLineInterface) {
    if (lineNumber === 0) {
        if (line !== "---") {
            new Error("Fail Parsing option");
        }
    } else if (
        !endParsing &&
        lineNumber > 0 &&
        line === "---"
    ) {
        endParsing = true;
    } else if (!endParsing) {
        const groups = line.match(regexOption)?.groups;
        if (groups?.key && groups?.value) {
            const { key, value } = groups;
            parsedOption[key] = value.trim();
            parsingTag = false;
        } else if (groups?.key === "tag") {
            parsedOption.tag = [];
            parsingTag = true;
        } else if (parsingTag) {
            const tagOption = line.match(tagOptionRegex);
            if (tagOption) {
                parsedOption.tag.push(tagOption[0]);
            } else {
                throw new Error("Error");
            }
        } else {
            throw new Error("Error");
        }
    } else {
        markdownData += line;
    }
    lineNumber++;
}

테스트 코드에서 막 쓴 코드지만 로직은 다음과같이 작동한다.

  1. 파일의 시작이 ---로 시작하는지 확인한다.
  2. 파일을 한줄씩 읽으면서 옵션 이름(key)와 옵션 값(value)를 정규식으로 파싱한다.
  3. 만약 key값이 tag이고 value값이 존재하지 않으면 tag옵션 파싱을 수행한다.
  4. tag 파싱을 수행중 key,value값이 둘 다 존재하면 tag파싱을 완료하였다고 판단하고 2번과정으로 돌아가 파싱을 수행한다.
  5. 읽는 부분이 ---이면 옵션 파싱을 모두 완료하였다고 판단하고 하위의 내용들은 모두 게시글 내용으로 판단하여 게시글 내용으로 추가한다.

아직 테스트 코드를 분리를 하지 않았는데 저 코드를 그대로 삽입하니 작동이 안되었다. 잘못이식했으면 에러라도 보여주어야 하는데 에러조차 반환해주지 않는다. 에러를 반환해주지 않으니 어느부분에서 문제가 발생하였는지 몰라서 일단 해볼 수 있는건 다 해보았다.

  • for await of의 성능문제 extension1개가 너무 많은 시간을 소모해서 강제로 종료시킬 수 있다는 생각을 하였다. 파일을 한 줄 씩 읽는 방법은 아무래도 시간이 오래걸리기 때문이다. 그래서 현재 로직을 거의 수정하지 않고 파일을 1줄씩 읽는 방법 중 빠른 방법이 잇는지를 찾아보았다. 찾아보니 이벤트 리스너를 사용하는 방법이 있었고 nodejs에서 해당 방법을 권장한다. 혹시 이 방법으로 되나 싶어서 수행해보았다.
const readFile = async (
    uri: vscode.Uri,
    blogName: string
): Promise<[ParsedOptions, string]> => {
    const { createReadStream } = await import("fs");
    const { createInterface } = await import("readline");
    const { once } = await import("events");
    const readLineInterface = createInterface({
        input: createReadStream(uri.fsPath),
        crlfDelay: Infinity,
    });
    const categoryList = await getCategories(blogName);
    const options = new ParsedOptions(categoryList);
    let lineNumber = 0;
    let endParsing = false;
    let parsingTag = false;
    let buffer = [""];
    readLineInterface.on("line", (line: string) => {
        if (!endParsing) {
            if (lineNumber === 0 && line !== "---") {
                throw new Error(ERROR_MESSAGES.FailParsing);
            } else if (lineNumber > 0 && line !== "---") {
                const parsedArray = parsingOption(line);
                const isParsingSuccess = options.setOption(parsedArray);
                if (isParsingSuccess) {
                    parsingTag = false;
                } else if (!isParsingSuccess) {
                    parsingTag = true;
                } else if (parsingTag) {
                    options.tag = parsingTagOption(line);
                } else {
                    throw new Error(ERROR_MESSAGES.FailParsing);
                }
            } else {
                endParsing = true;
            }
        } else {
            buffer.push(line);
        }
        lineNumber++;
    });
    await once(readLineInterface, "close");
    return [options, buffer.join("")];
};

놀랍게도 읽지 않는다.

  • 파일을 파싱하는 로직의 문제인가? 단순히 파일을 파싱하는 로직을 잘못 옮겨서 발생한 문제인가 고민해보았다. 그래서 로직을 잠시 주석처리하고 console.log로 파일을 1줄씩 읽어보는 것을 시도하였다. 물론 실행이 안되었다.
  • vscode.uri문제인가? vscode의 uri가 잘못되어서 읽을 파일의 경로를 못찾을 수 있다는 생각을 하였다.그래서 파일을 절대경로로 삽입하여 파일 읽기를 수행하였다. 디버깅을 통해 파일을 읽기 전에 정확한 경로가 들어가였는지를 검증하였다. 물론 이 방법도 실패하였다.
  • vscode에서 파일 읽기를 엄격하게 제어하는가? vscode프로그램에서 파일을 제어하는 것을 잡고 있어서 nodejs에 내장된 모듈을 이용한 파일 읽기가 안되는 가능성을 생각해보았다. 그러나 동일한 vscode환경에서 수행되는 테스트코드에는 통과가 되는데 동일한 환경에서 수행이 안된다는 것은 말도 안된다라는 생각을 하여 테스트조차 수행하지 않았다.
  • 외부함수와 내부함수 원본 코드와 다른점을 생각해보았다. 원본 코드는 함수를 내부에서 생성하고 호출한다. 반면 옮긴 코드는 그렇지 않다. 이를 통해 파일을 1줄씩 읽는 함수를 내부에 선언해서 써보기로 하였다.
const readFile = async (
    uri: vscode.Uri,
    blogName: string
): Promise<[ParsedOptions, string]> => {
    const { createReadStream } = await import("fs");
    const { createInterface } = await import("readline");
    const { once } = await import("events");
    async function readOneLine(): Promise<[ParsedOptions, string]> {
        const readLineInterface = createInterface({
            input: createReadStream(uri.fsPath),
            crlfDelay: Infinity,
        });
        const categoryList = await getCategories(blogName);
        const options = new ParsedOptions(categoryList);
        let lineNumber = 0;
        let endParsing = false;
        let parsingTag = false;
        let buffer = [""];
        readLineInterface.on("line", (line: string) => {
            if (!endParsing) {
                if (lineNumber === 0 && line !== "---") {
                    throw new Error(ERROR_MESSAGES.FailParsing);
                } else if (lineNumber > 0 && line !== "---") {
                    const parsedArray = parsingOption(line);
                    const isParsingSuccess = options.setOption(parsedArray);
                    if (isParsingSuccess) {
                        parsingTag = false;
                    } else if (!isParsingSuccess) {
                        parsingTag = true;
                    } else if (parsingTag) {
                        options.tag = parsingTagOption(line);
                    } else {
                        throw new Error(ERROR_MESSAGES.FailParsing);
                    }
                } else {
                    endParsing = true;
                }
            } else {
                buffer.push(line);
            }
            lineNumber++;
        });
        await once(readLineInterface, "close");
        return [options, buffer.join("")];
    }
    return await readOneLine();
};

쓰고보니 뭔 의미인가 싶긴 하다. 실행결과는 당연히 실패하였다.

  • 마지막에 멈춘 부분이 어디인가? 코드를 step by step으로 따라가다보면 await once(readLineInterface, "close");부분에서 인터페이스의 익명함수로 들어가지 않고 멈추었다. 이 부분이 문제가 있나 싶어서 step into로 깊이 들어가보았다.
    function once(emitter, name) {
      return new Promise((resolve, reject) => {
        const errorListener = (err) => {
          emitter.removeListener(name, resolver);
          reject(err);
        };
        const resolver = (...args) => {
          if (typeof emitter.removeListener === 'function') {
            emitter.removeListener('error', errorListener);
          }
          resolve(args);
        };
        eventTargetAgnosticAddListener(emitter, name, resolver, { once: true });
        if (name !== 'error') {
          addErrorHandlerIfEventEmitter(emitter, errorListener, { once: true });
        }
      });
    }

이벤트 모듈의 once는 이벤트를 1회만 연결하고 제거를 수행한다. 해당 함수는 promise를 반환해주므로 then과 catch를 통해 내부에서 오류가 난 것인지를 파악해보았다.

const readFile = async (
    uri: vscode.Uri,
    blogName: string
): Promise<[ParsedOptions, string]> => {
    const { createReadStream } = await import("fs");
    const { createInterface } = await import("readline");
    const { once } = await import("events");

    const readLineInterface = createInterface({
        input: createReadStream(uri.fsPath),
        crlfDelay: Infinity,
    });
    const categoryList = await getCategories(blogName);
    const options = new ParsedOptions(categoryList);
    let lineNumber = 0;
    let endParsing = false;
    let parsingTag = false;
    let buffer = [""];
    readLineInterface.on("line", (line: string) => {
        console.log(line);
    });
    once(readLineInterface, "close").then(console.log).catch(console.error);
    return [options, buffer.join("")];
};

실행결과 어떤 로그도 작성되지 않고 종료한다.

  • error catch시도 promise부분이 아니면 다른 부분에서 에러가 발생하였나 생각하여 파일을 읽는 로직에 try-catch문을 추가해서 에러가 발생하는지 확인해보았다.
const readFile = async (
    uri: vscode.Uri,
    blogName: string
): Promise<[ParsedOptions, string]> => {
    const { createReadStream } = await import("fs");
    const { createInterface } = await import("readline");
    const { once } = await import("events");

    const categoryList = await getCategories(blogName);
    const options = new ParsedOptions(categoryList);
    let lineNumber = 0;
    let endParsing = false;
    let parsingTag = false;
    let buffer = [""];
    try {
        const rl = createInterface({
            input: createReadStream(uri.fsPath),
            crlfDelay: Infinity,
        });
        rl.on("line", (line) => {
            if (!endParsing) {
                if (lineNumber === 0 && line !== "---") {
                    throw new Error(ERROR_MESSAGES.FailParsing);
                } else if (lineNumber > 0 && line !== "---") {
                    const parsedArray = parsingOption(line);
                    const isParsingSuccess = options.setOption(parsedArray);
                    if (isParsingSuccess) {
                        parsingTag = false;
                    } else if (!isParsingSuccess) {
                        parsingTag = true;
                    } else if (parsingTag) {
                        options.tag = parsingTagOption(line);
                    } else {
                        throw new Error(ERROR_MESSAGES.FailParsing);
                    }
                } else {
                    endParsing = true;
                }
            } else {
                buffer.push(line);
            }
            lineNumber++;
        });
        await once(rl, "close");
    } catch (error) {
        console.error(error);
    }
    return [options, buffer.join("")];
};

에러를 발생하지 않고 정상적으로 파일을 읽고 파싱은 실패한다.

...왜 되는지 모르겠다. 의문이 들어서 테스트 workspace에서 디버거를 키니 오류 내용을 볼 수 있었따 결국 vscode가 테스트중인 workspace에서 발생한 에러를 받아오지 못해서 발생하는 헤프닝이었다...(젠장, 내 시간...)

notdebug


리스너 내부에서 발생한 에러 핸들링 불가

위에서 발생한 문제점을 한줄로 줄이자면 에러 핸들링을 수행할 수 없다는 것이다. 파싱을 수정 중에 커스텀 에러를 던져주면 extension이 이를 캐치하지 못하고 콘솔창에 뿌려준다.

이 함수를 호출한 부모함수는 postBlog이고 이는 extension.ts의 pushOne함수에서 호출하는 함수다. pushOne에서 이벤트 핸들링을 수행하나 이를 받지 못한다.

그래서 다음의 경우를 생각해보았다.

  1. 다른 부분에서 핸들링을 수행하는가 ⇒ console.error이 아닌 console.log형식으로 반환을 해주는 것을 보니 유력해 보였다. 그래서 필자가 작성한 부분중에 console.log를 사용하는 곳을 찾을려 했으나 없었다.

답은 의외의 곳에 있었다. 아직 nodejs가 정식으로 지원해주진 않는 기능이다.

nodejs의 16.10.0 문서에는 error handling을 다음과 같이 작성해두었다.

등록된 이벤트 리스너에서 에러가 발생할 때 기본적으로 process.nextTick()로 잡히지 않은 에러인 것 처럼 대한다 ⇒ 에러이지만 에러가 아닌 것 처럼 보여주겠다. ⇒ 아 그래서 console.log로 보여줬구나

nodejs의 이벤트 타입인 EventTarget는 기본적으로 nodejs를 종료한다. ⇒ 아 이래서 IDE가 에러를 못잡았구나?

EventTarget에서 발생한 에러는 EventEmiiter의 error이벤트 리스너같은 어떤 특별한 디폴트 핸들링으로 대체되지 않은다. ⇒ Promise의 reject로만 잡아라

captureReject라는 옵션을 true로 설정하면 이벤트 리스터 콜백함수에서 발생하는 에러를 error리스너로 캐치해서 에러를 이용할 수 있도록 하였다. 그러나 이 기능을 써도 error리스너 내부에선 무한루프를 방지하기 위해 반복문을 사용할 수 없다고 명시되어 있다(예?). 원문은 다음과 같다.

The 'error' events that are generated by the captureRejections behavior do not have a catch handler to avoid infinite error loops: the recommendation is to not use async functions as 'error' event handlers.

심지어 이 기능도 실험적으로 제공되는 기능이다. 즉 사용해서 배포하기엔 위험성이 존재한다.

실험적인 기능을 사용할 순 없으니 그럴 순 없으니 성능을 포기하더라도 for await of문법으로 변경하기로 하였다.

catch error

좋아, 에러 캐치에 성공하였다.

결국 처음으로 돌아온건데 앞에서 왜 에러가 캐치가 안되었는지 궁금하였다. 필자 생각으론 위의 과정에서 이미 오류를 고쳤으나 리스너 자체가 에러 캐치를 못한 것 같다.

Events | Node.js v16.10.0 Documentation


잘못된 axios api사용

정상 응답은 json으로 반환하지만 비정상적인 응답은 xml로 반환되어서 에러 내용을 알지 못하는 에러가 발견되었다.

이러한 에러이유가 발견된 이유를 찾아보니 axios를 잘못쓰고 있었다.

찾아보니까 응답을 처리할 때는 axios가 기본값으로 데이터를 json으로 처리해서 반환해준다는 것을 알았다.

빡대가린가...

axios.get을 사용할 때는 인자들이 url파라미터 부분에 붙어야 하나 body에 붙어서 발생된 오류인 것으로 추측된다.

그래서 axios.get을 사용하는 부분에서 data를 params로 고쳤다.

api문서에 나오는 내용을 이제야 알다니, 사람인가 싶다


Maximum call stack size exceeded오류

콜스택이 초과되었다는 오류가 발생하였다. 보통 재귀함수를 사용할 때 발생되는 문제다.

근데 문제가 있다, 내가 짠 로직중에 재귀함수를 쓴 코드가 1개도 없다(...?)

그래서 1줄씩 디버깅하면서 조사해보니 title데이터를 불러오는데 문제가 있었다.

let postedData: PostInfo = {
    access_token: accessToken,
    output: "json",
    blogName: selectedBlog.name,
    title: options.title,
    visibility: options.post,
    published: options.date,
    password: options.password,
    tag: options.tag,
    category: options.category,
    slogan: options.url,
    acceptComment: options.comments,
    content,
};

maximum call stack

title코드를 보았다.

public set title(v: string) {
    this._title = v;
}

public get title(): string {
    return this._title ? this.title : "";
}

와, 오타가 있었구나, 사람인가?


카테고리 파싱 문제

카테고리가 제대로 적용이 안되서 분석해보았다.

분석해보니 getter와 setter에 문제점이 있었다.

public set category(categoryName: string) {
    for (let category of this._categoryList) {
        if (category.name === categoryName) {
            this._category = categoryName;
            return;
        }
    }
    throw new TypeError(ERROR_MESSAGES.FailParsing);
}
public get category(): string {
    return this._category ? this._category : "0";
}

포스팅을 수행하기 위해서는 카테고리 id를 불러와야 하는데 카테고리 이름을 불러와서 문제가 발생하였다.

이를 setter,getter로직을 수정하고 추후 유지보수 시 같은 문제가 발생하지 않기 위해 interface를 작성하였다.

export interface CategoryInfo {
    id: string;
    name: string;
    parent: string;
    label: string;
    entries: string;
}
const getCategories = async (
    blogName: string
): Promise<Array<CategoryInfo>> => {
    const {
        data: { tistory },
    } = await axios.get(API_URI.CATEGORY_LIST, {
        params: {
            access_token: getConfigProperty(PROPERTIES.Token),
            output: "json",
            blogName,
        },
        validateStatus: (status: number) => status >= 200 && status < 500,
    });

    if (tistory.status === "200") {
        return tistory.item.categories;
    } else {
        throw new Error(
            `${ERROR_MESSAGES.TistoryAPIError}: ${tistory.error_message}`
        );
    }
};
export class ParsedOptions {
    private _title?: string;
    private _postId?: number;
    private _url?: string;
    private _date?: Date;
    private _tag?: string[];
    private _comments?: boolean;
    public password?: string;
    private _post?: "public" | "protect" | "private";
    private _category?: CategoryInfo;
    private _categoryList: Array<CategoryInfo>;

    constructor(categoryList: Array<CategoryInfo>) {
        this._tag = new Array<string>();
        this._categoryList = categoryList;
    }
    /* 생략.. */
    public set category(categoryName: string) {
        for (let category of this._categoryList) {
            if (category.name === categoryName) {
                this._category = category;
                return;
            }
        }
        throw new TypeError(ERROR_MESSAGES.FailParsing);
    }
    public get category(): string {
        return this._category ? this._category.id : "0";
    }
    /* 생략.. */

코드에서 필요한 부분은 id뿐이므로 getter에서 id만 반환시켜준다.

category

잘 잡힌다.


코드 분리

블로그 로그인과 포스팅 기능을 1개의 파일에 삽입하였떠니 생각보다 너무 크다

이를 분리해야할 필요가 생겼다.

3가지의 분류로 수행하였다.

  1. login에서만 사용하는 기능(loginTistory.ts)
  2. 포스팅에서만 사용하는 기능(pushPost.ts)
  3. login과 포스팅에서 공통으로 사용하는 기능(commons.ts)