inquirer를 활용한 대화형 인터페이스(CLI) 만들기

허민(허브)·2024년 2월 18일
0
post-custom-banner

서론

최근에 "모던 자바스크립트 Deep Dive" 스터디를 진행하면서 면접을 대비하기 위해 문제은행에서 문제를 뽑아주는 코드를 작성해야 했습니다. 이 과정에서 참여자 수, 참여자 이름, 그리고 사용할 문제 파일을 선택하는 기능이 필요했습니다. 이러한 기능을 간편하게 구현하기 위해 inquirer 라이브러리를 활용하여 CLI를 만들었습니다.

CLI : Command-Line-Interface의 준말입니다. 키보드를 이용해 운영체제와 상호작용하는 소프트웨어 메커니즘입니다.

이 방법을 통해서 스터디가 더욱 원할하게 진행되었고, 대화형 인터페이스를 만드는 방법을 배우게 되어 inquirer에 대해 적어보고자 합니다.

본론

Inquirer란?

https://github.com/SBoudrias/Inquirer.js

프로젝트를 처음 만들 때 위와 같이 템플릿 옵션을 선택하는 화면을 많이 보았을 겁니다. 이와 같이 대화형 CLI를 제공해주는 라이브러리로 대표적인 것이 inquirer 입니다.

inquirer는 Node.js 환경에서 사용되는 명령줄 인터페이스 도구로, 다양한 유형의 질문을 사용하여 사용자와의 상호작용을 쉽게 처리할 수 있습니다. 이 라이브러리를 활용하면 CLI에서의 사용자 입력을 효과적으로 다룰 수 있으며, 프롬프트를 통해 동적인 상호작용을 제공할 수 있습니다.

Inquirer 를 선택한 이유

inquirer 외에도 프롬프트를 수행하는 Commander.js 와 같은 라이브러리도 존재합니다. Commander.js와 같은 다른 라이브러리는 주로 명령행 인터페이스를 다루는 데에 특화되어 있어서 대화형 기능을 inquirer가 더 효과적으로 제공하고 있어서 사용했습니다.

Inquirer를 통해 입력을 받는 예시 코드

import { input } from '@inquirer/prompts';

const answer = await input({ message: '이름을 입력해주세요.' });

console.log(`제 이름은 ${answer}입니다.`);

inquirer는 input, checkbox, select 등과 같은 다양한 기능을 제공하여 명령형 인터페이스를 간편하게 개발할 수 있습니다. 이를 통해 사용자와의 상호작용을 통해 입력값을 수집하거나 여러 선택지 중에서 선택을 받는 등의 작업을 손쉽게 처리할 수 있습니다.

문제은행

문제은행 코드는 저희 스터디 레포를 확인해주시면 감사하겠습니다. 이 코드는 저 혼자 만든 코드는 아니고 함께 만든 코드입니다!
https://github.com/Better-Front-End-Study/ModernJsDeepDive

스터디는 참여자 수, 참여자 이름, 그리고 질문 파일을 선택하면 차례대로 각 참여자가 3개의 질문을 받고 이에 대한 답변을 하는 형식으로 진행되었습니다.

다음과 같은 요구사항에 기반하여 문제은행 프롬프트를 개발했습니다.

✅ 1. 스터디마다 면접자 수와 참여자가 변경될 수 있기 때문에, CLI에서 해당 정보를 입력받는다.
✅ 2. 질문은 JSON 형식으로 저장하고 있어 원하는 JSON 파일을 선택할 수 있다.
✅ 3. 답변자의 진행 순서는 무작위로 정해지며, 이 순서가 계속해서 반복된다. 예를 들어, 참여자1, 참여자2, 참여자3이 있다면 3, 1, 2와 같이 렌덤으로 순서가 정해지면, 해당 순서대로 모든 질문이 완료될 때까지 이를 반복한다.
✅ 4. 각 참여자당 3개의 질문이 제공되며, 중복된 질문은 발생하지 않도록한다. 마지막 질문이 3개로 채워지지 않은 경우 남은 질문을 보여준다.

1. 면접자 수와 참여자 이름 입력 받기

 // 면접자 수 입력받기
  const { numInterviewers } = await inquirer.prompt([
    {
      type: "input",
      name: "numInterviewers",
      message: "참여한 면접자 수를 입력해주세요:",
      validate: (input) =>
        input !== "" && !isNaN(input) && parseInt(input) > 0
          ? true
          : "유효한 숫자를 입력해주세요!",
    },
  ]);

// 면접자 이름 입력받기
await askForInterviewerNames(parseInt(numInterviewers));

inquirer의 장점은 Promise를 지원하여 비동기적으로 코드를 작성할 수 있어서 좋았습니다. 또한, validate를 제공해주기 때문에 면접자 수를 입력할 때 잘못된 값을 입력한 경우 에러 메시지를 출력할 수 있어서 좋았습니다.

입력받은 면접자 수를 가지고 참석자의 이름을 받도록 코드를 작성해주었습니다.

/**
 * 인터뷰어 입력받아 저장
 * @param {*} num {number}
 */
const askForInterviewerNames = async (num) => {
  const questions = [];
  for (let i = 0; i < num; i++) {
    questions.push({
      type: "input",
      name: `interviewer${i}`,
      message: `멋쟁이 참석자 #${i + 1}의 이름은?`,
    });
  }

  const answers = await inquirer.prompt(questions);
  interviewers = Object.values(answers);
  console.log(`${interviewers} 의 면접을 시작합니다 ( ˙◞˙ )`);

  // 인터뷰어 순서를 섞어요
  interviewers = shuffleArray(interviewers);
};

2. 문제은행 파일 선택하기

// 현재 파일의 URL을 파일 경로로 변환
const __filename = fileURLToPath(import.meta.url);

// __filename에서 디렉토리 경로를 얻음
const __dirname = path.dirname(__filename);

path.dirname() 함수는 Node.js의 경로 모듈(path)에 속한 함수로, 파일 경로에서 디렉토리 경로를 추출하는 역할을 합니다. __filename은 현재 파일의 경로를 나타내는 변수이며, 이를 path.dirname() 함수에 전달하여 해당 파일이 위치한 디렉토리의 경로를 얻을 수 있습니다. 예를 들어, 만약 __filename이 "/path/to/file.js"라면, path.dirname(__filename)은 "/path/to"를 반환합니다. 따라서 __dirname 변수에는 현재 파일이 위치한 디렉토리의 경로가 할당됩니다. 이러한 구성을 통해 현재 모듈이 위치한 디렉토리를 동적으로 알아낼 수 있으며, 파일의 상대 경로를 이용하여 다양한 작업을 수행할 때 유용하게 활용됩니다.

/**
 * 파일명 리스트를 반환합니다.
 * @returns {string[]}
 */
const getFilesFromDirectory = () => {
  // __dirname을 기반으로 상위 디렉토리로 이동하고, 그 위치에 QUESTIONS_DIR_PATH라는 변수에 저장된 디렉토리 경로를 붙여서 최종적인 디렉토리 경로를 얻습니다.
  const directoryPath = path.resolve(__dirname, `../${QUESTIONS_DIR_PATH}`);
  // 지정된 디렉토리에서 동기적으로 파일 및 하위 디렉토리 목록을 읽어옵니다. 반환되는 배열에는 디렉토리와 파일의 이름이 포함됩니다.
  return fs.readdirSync(directoryPath); 
};
/**
 * 지정된 파일에서 질문 배열을 읽어온다.
 * @param {string} fileName - 파일 이름
 * @returns {string[]}
 */
const readQuestionsFromFile = (fileName) => {
  try {
    const filePath = path.resolve(
      __dirname,
      `../${QUESTIONS_DIR_PATH}/${fileName}`
    );
    const fileContents = fs.readFileSync(filePath, "utf8");
    const questionsJson = JSON.parse(fileContents);
    return questionsJson.questions;
  } catch (error) {
    console.error("파일을 읽는 도중 에러가 발생하였습니다.", error);
    return [];
  }
};

...

 if (!allQuestions.length) {
    const files = getFilesFromDirectory(); 
    const answers = await inquirer.prompt([
      {
        type: "list",
        name: "selectedFile",
        message: "질문 파일을 선택해주세요.",
        choices: files,
      },
    ]);
    allQuestions = readQuestionsFromFile(answers.selectedFile);
    remainingQuestions = [...allQuestions];
  }

지정된 디렉토리에서 파일 목록을 가져와 리스트로 표시하고, 선택된 파일에서 질문을 추출할 수 있도록 합니다.

3. 문제를 섞어서 면접자에게 질문하기

면접 질문은 한 사람당 3개씩 받고 대답하는 것을 반복합니다. 따라서 답변자와 3개의 질문을 보여주고 대답을 마치면 엔터를 누르면 다음 질문자로 넘어갈 수 있도록 하였습니다.

  // 인터뷰어의 현재 위치를 추적하는 변수
  let currentInterviewerIndex = 0;
	
  ...
  
   /**
   * 사용자가 엔터키를 누를 때까지 기다린다.
   * @returns {Promise<void>}
   **/
  const waitForEnter = () => {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });

    return new Promise((resolve) =>
      rl.question("계속하려면 엔터키를 눌러주세요...👍", (ans) => {
        rl.close();
        resolve();
      })
    );
  };

readline 모듈을 사용하여 사용자와의 상호작용을 담당하는 rl 인터페이스를 생성합니다. process.stdin은 표준 입력(사용자 입력), process.stdout은 표준 출력(콘솔에 출력)을 나타냅니다.

Promise를 반환하는데, 사용자에게 메시지를 표시하고, 사용자가 엔터 키를 누를 때까지 기다리는 rl.question 함수를 호출합니다. 이때, 사용자가 엔터를 누르면 콜백 함수가 실행되며, resolve()를 호출하여 Promise가 이행됩니다.


  ...

  while (remainingQuestions.length) {
    // 여기서 문제 수를 조절해요 (현재 3문제)
    const numElements = Math.min(3, remainingQuestions.length);

    // 문제 배열 무작위로 섞어서 문제 3개 뽑기
    const selectedQuestions = selectRandomElements(
      remainingQuestions,
      numElements
    );

    // 현재 인터뷰어 선택
    const selectedInterviewer = interviewers[currentInterviewerIndex];

    console.log("답변자: ", selectedInterviewer);

    console.log("❤️질문 입니다.❤️\n ", selectedQuestions);

    // 선택된 질문을 남은 질문에서 제거해줍니다. (중복을 방지)
    remainingQuestions = remainingQuestions.filter(
      (question) => !selectedQuestions.includes(question)
    );

    // 다음 인터뷰어로 이동
    currentInterviewerIndex =
      (currentInterviewerIndex + 1) % interviewers.length;

    if (remainingQuestions.length) {
      await waitForEnter();
    }
    
   console.log("모든 질문이 선택되었습니다. 다음에 또 만나요!😎");

  }

while문을 사용하여 문제가 다 소진될 때까지 반복되도록 코드를 작성하였습니다.

결론

inquirer를 활용하여 대화형 인터페이스를 만들어보면서 Node.js를 통해 더 흥미로운 개발을 경험할 수 있었습니다. 다음에는 스크립트와 CLI를 활용하여 배포 환경을 구축하고 템플릿 코드 개발을 편리하게 할 수 있도록 개발 환경을 개선하고 싶습니다.

profile
Adventure, Challenge, Consistency
post-custom-banner

0개의 댓글