Page ▾
[Electron 데스크톱 앱 개발] #4: UI 디자인 개선하기 (랜덤 룰렛)

✨ 목표

지난 글에서는 Electron을 이용해 간단한 랜덤 룰렛 프로그램을 만들어봤습니다.

이번 글에서는 기존에 만든 랜덤 룰렛의 화면을 조금 더 다듬어 실제로 사용할 수 있는 데스크톱 앱 형태로 개선해보겠습니다.

이번 작업에서는 앱 창 크기부터 화면 구성, 색상, 참가자 관리 기능까지 함께 수정했습니다.


🎨 앱 디자인 변경 후 완성된 화면

디자인 수정 후 이미지


✔️ 1. Electron 창 설정 변경하기

먼저 main.js에서 Electron 창의 기본 설정을 변경합니다.

기존에는 테스트를 위해 단순히 창의 크기만 지정했지만, 이번에는 실제 프로그램처럼 사용할 수 있도록 창의 크기와 배경색 등을 설정했습니다.

const { app, BrowserWindow, Menu } = require('electron');

const createWindow = () => {
  const win = new BrowserWindow({
    width: 420,
    height: 720,
    resizable: true,
    backgroundColor: '#1B1B2F'
  });

  win.loadFile('index.html');
};

app.whenReady().then(() => {
  Menu.setApplicationMenu(null); // 기본 메뉴바 제거 (File, Edit 등)
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

 

🔹 창 크기 변경

width: 420,
height: 720,
resizable: true,

기존의 800 × 600 크기에서 420 × 720 크기로 변경했습니다.

랜덤 룰렛 프로그램의 화면 구성에 맞춰 세로로 긴 형태의 창을 사용하도록 했습니다.

또한 resizable: true를 설정해 사용자가 창 크기를 변경할 수 있도록 했습니다.

 

🔹 배경색 설정

backgroundColor: '#1B1B2F'

 

🔹 기본 메뉴바 제거

Menu.setApplicationMenu(null);

Electron에서 기본적으로 제공되는 File, Edit 등의 메뉴바를 제거합니다.

이번 프로그램에서는 별도의 메뉴바가 필요하지 않기 때문에 제거했습니다.

💡 참고
Menu를 사용하기 위해 require('electron')에서 Menu도 함께 가져와야 합니다.

✔️ 2. HTML 구조 변경하기

다음으로 index.html을 수정해 프로그램의 전체적인 화면 구조를 정리했습니다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>랜덤 룰렛</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

  <div class="app">
    <header class="app-header">
      <h1>랜덤 룰렛</h1>
      <p class="subtitle">참가자를 추가하고 룰렛을 돌려보세요</p>
    </header>

    <section class="stage">
      <div id="result" class="result">룰렛을 돌려보세요</div>
      <button id="spinButton" class="spin-button">🎲 룰렛 돌리기</button>
    </section>

    <section class="participants">
      <div class="participants-head">
        <h2>참가자 <span id="participantCount">0</span></h2>
        <button id="clearButton" class="text-button" type="button">전체 삭제</button>
      </div>

      <ul id="participantList" class="participant-list"></ul>

      <form id="addForm" class="add-form">
        <input
          type="text"
          id="participantInput"
          placeholder="이름을 입력하세요"
          autocomplete="off"
        />
        <button type="submit" class="add-button">추가</button>
      </form>
    </section>
  </div>

  <script src="script.js"></script>
</body>
</html>

기존의 단순한 HTML에서 프로그램을 크게 헤더 / 룰렛 영역 / 참가자 영역으로 나누었습니다.

 

🔹 lang="ko"

<html lang="ko">

문서의 기본 언어가 한국어임을 나타냅니다.

 

🔹 참가자 영역

이번에는 참가자 이름을 직접 입력하고 목록으로 관리할 수 있도록 구성했습니다.

<h2>참가자 <span id="participantCount">0</span></h2>

참가자 수를 표시하고,

<button id="clearButton" ...>전체 삭제</button>

버튼을 통해 참가자를 한 번에 삭제할 수 있도록 했습니다.


✔️ 3. CSS를 이용해 UI 개선하기

이번에는 style.css를 새로 작성해 전체적인 디자인을 변경했습니다.

더보기
:root {
  --bg: #1b1b2f;
  --bg-deep: #15152a;
  --surface: #24243e;
  --surface-alt: #2e2e4d;
  --border: rgba(255, 255, 255, 0.08);
  --text: #f5f3f0;
  --text-muted: #a6a6c1;
  --accent: #f4b942;
  --accent-dim: rgba(244, 185, 66, 0.4);
  --coral: #ff6f59;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(180deg, var(--bg) 0%, var(--bg-deep) 100%);
  font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', -apple-system, BlinkMacSystemFont, sans-serif;
  color: var(--text);
}

.app {
  width: 360px;
  padding: 32px 28px 28px;
}

.app-header h1 {
  margin: 0 0 4px;
  font-size: 26px;
  font-weight: 700;
  letter-spacing: -0.02em;
}

.subtitle {
  margin: 0 0 28px;
  font-size: 13px;
  color: var(--text-muted);
}

.stage {
  background: var(--surface);
  border: 1px dashed var(--accent-dim);
  border-radius: 16px;
  padding: 28px 20px;
  text-align: center;
  margin-bottom: 28px;
}

.result {
  font-size: 22px;
  font-weight: 700;
  min-height: 32px;
  margin-bottom: 20px;
  word-break: keep-all;
  transition: transform 0.2s ease, color 0.2s ease;
}

.result.winner {
  color: var(--accent);
  transform: scale(1.08);
}

.spin-button {
  background: var(--accent);
  color: var(--bg);
  border: none;
  border-radius: 10px;
  padding: 12px 32px;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: opacity 0.15s ease, transform 0.1s ease;
}

.spin-button:hover:not(:disabled) {
  opacity: 0.9;
}

.spin-button:active:not(:disabled) {
  transform: scale(0.97);
}

.spin-button:disabled {
  opacity: 0.5;
  cursor: default;
}

.participants-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 12px;
}

.participants-head h2 {
  font-size: 15px;
  font-weight: 700;
  margin: 0;
}

#participantCount {
  color: var(--text-muted);
  font-weight: 400;
}

.text-button {
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 12px;
  cursor: pointer;
  padding: 0;
}

.text-button:hover {
  color: var(--coral);
}

.participant-list {
  list-style: none;
  margin: 0 0 16px;
  padding: 0;
  max-height: 180px;
  overflow-y: auto;
}

.participant {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 4px;
  border-bottom: 1px solid var(--border);
  font-size: 14px;
}

.participant:last-child {
  border-bottom: none;
}

.remove-button {
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  padding: 0 4px;
}

.remove-button:hover {
  color: var(--coral);
}

.add-form {
  display: flex;
  gap: 8px;
}

#participantInput {
  flex: 1;
  background: var(--surface-alt);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  color: var(--text);
  font-size: 14px;
}

#participantInput::placeholder {
  color: #6e6e92;
}

#participantInput:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.add-button {
  background: var(--surface-alt);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 16px;
  font-size: 14px;
  cursor: pointer;
}

.add-button:hover {
  background: #383860;
}

 

프로그램 전체에 사용할 색상은 CSS 변수를 이용해 관리했습니다.

:root {
  --bg: #1b1b2f;
  --bg-deep: #15152a;
  --surface: #24243e;
  --surface-alt: #2e2e4d;
  --border: rgba(255, 255, 255, 0.08);
  --text: #f5f3f0;
  --text-muted: #a6a6c1;
  --accent: #f4b942;
  --accent-dim: rgba(244, 185, 66, 0.4);
  --coral: #ff6f59;
}

이렇게 색상을 변수로 지정하면 여러 CSS 속성에서 같은 색상을 사용할 때 관리하기 편합니다.

 

예를 들어 주요 배경색은 다음과 같이 사용할 수 있습니다.

body {
  background: linear-gradient(
    180deg,
    var(--bg) 0%,
    var(--bg-deep) 100%
  );
}

이번에는 전체적으로 어두운 배경에 노란색 계열의 포인트 색상을 사용해 프로그램의 분위기를 통일했습니다.


✔️ 4. 참가자 관리 기능 추가하기

기존에는 참가자를 단순히 배열에 추가하기만 했지만, 이번에는 참가자를 삭제하거나 중복으로 추가하지 못하도록 수정했습니다.

더보기
const participants = [];
let spinning = false;

const resultEl = document.getElementById('result');
const spinButton = document.getElementById('spinButton');
const participantList = document.getElementById('participantList');
const participantCount = document.getElementById('participantCount');
const addForm = document.getElementById('addForm');
const participantInput = document.getElementById('participantInput');
const clearButton = document.getElementById('clearButton');

addForm.addEventListener('submit', (e) => {
  e.preventDefault();
  addParticipant();
});

clearButton.addEventListener('click', () => {
  if (participants.length === 0) return;
  participants.length = 0;
  resultEl.textContent = '룰렛을 돌려보세요';
  resultEl.classList.remove('winner');
  render();
});

spinButton.addEventListener('click', () => {
  if (spinning) return;

  if (participants.length === 0) {
    resultEl.textContent = '참가자를 먼저 추가해주세요';
    resultEl.classList.remove('winner');
    return;
  }

  spin();
});

function addParticipant() {
  const name = participantInput.value.trim();

  if (!name) return;

  if (participants.includes(name)) {
    participantInput.value = '';
    participantInput.placeholder = '이미 추가된 이름이에요';
    return;
  }

  participants.push(name);
  participantInput.value = '';
  render();
}

function removeParticipant(index) {
  participants.splice(index, 1);
  render();
}

function render() {
  participantCount.textContent = participants.length;
  participantList.innerHTML = '';

  participants.forEach((name, index) => {
    const li = document.createElement('li');
    li.className = 'participant';

    const nameSpan = document.createElement('span');
    nameSpan.textContent = name;

    const removeBtn = document.createElement('button');
    removeBtn.type = 'button';
    removeBtn.className = 'remove-button';
    removeBtn.textContent = '×';
    removeBtn.setAttribute('aria-label', `${name} 삭제`);
    removeBtn.addEventListener('click', () => removeParticipant(index));

    li.appendChild(nameSpan);
    li.appendChild(removeBtn);
    participantList.appendChild(li);
  });
}

// setInterval은 간격을 고정으로 주기 때문에, setTimeout을 재귀 호출해서
// 점점 느려지는 룰렛 느낌을 냅니다.
function spin() {
  spinning = true;
  spinButton.disabled = true;
  resultEl.classList.remove('winner');

  const winner = participants[Math.floor(Math.random() * participants.length)];
  const totalTicks = 16;
  let tick = 0;

  function tickOnce() {
    const randomName = participants[Math.floor(Math.random() * participants.length)];
    resultEl.textContent = randomName;
    tick += 1;

    if (tick < totalTicks) {
      const delay = 60 + tick * 12; // 점점 느려짐
      setTimeout(tickOnce, delay);
    } else {
      resultEl.textContent = `🎉 ${winner}`;
      resultEl.classList.add('winner');
      spinButton.disabled = false;
      spinning = false;
    }
  }

  tickOnce();
}

 

🔹 중복 참가자 방지

if (participants.includes(name)) {
    participantInput.value = '';
    participantInput.placeholder = '이미 추가된 이름이에요';
return;
}

includes()를 이용해 같은 이름이 이미 참가자 목록에 있는지 확인합니다.

이미 존재하는 이름이라면 배열에 추가하지 않고 안내 문구를 표시합니다.

 

🔹 참가자 삭제

각 참가자 옆에 × 버튼을 추가하고, 버튼을 클릭하면 해당 참가자를 삭제하도록 했습니다.

function removeParticipant(index) {
    participants.splice(index, 1);
    render();
}

splice()를 이용해 배열에서 해당 위치의 참가자를 삭제합니다.


✔️ 5. 참가자 목록 화면에 표시하기

참가자가 추가되거나 삭제될 때마다 화면의 참가자 목록을 다시 그리도록 render() 함수를 만들었습니다.

function render() {
  participantCount.textContent = participants.length;
  participantList.innerHTML = '';

  participants.forEach((name, index) => {
    const li = document.createElement('li');
    li.className = 'participant';

    const nameSpan = document.createElement('span');
    nameSpan.textContent = name;

    const removeBtn = document.createElement('button');
    removeBtn.type = 'button';
    removeBtn.className = 'remove-button';
    removeBtn.textContent = '×';

    removeBtn.addEventListener('click', () => {
      removeParticipant(index);
    });

    li.appendChild(nameSpan);
    li.appendChild(removeBtn);
    participantList.appendChild(li);
  });
}

이제 참가자를 추가하면 목록에 표시되고, 각 참가자 옆의 × 버튼을 클릭하면 해당 참가자를 삭제할 수 있습니다.

또한 참가자 수 역시 자동으로 업데이트됩니다.

참가자 추가 및 삭제 화면 이미지


✔️ 6. 룰렛 동작 개선하기

기존에는 버튼을 클릭하면 바로 당첨자가 표시되는 방식이었습니다.

이번에는 실제 룰렛을 돌리는 것처럼 여러 참가자의 이름이 빠르게 바뀌다가 점점 느려지면서 당첨자를 보여주는 방식으로 변경했습니다.

function spin() {
  spinning = true;
  spinButton.disabled = true;

  resultEl.classList.remove('winner');

  const winner =
    participants[Math.floor(Math.random() * participants.length)];

  const totalTicks = 16;
  let tick = 0;

  function tickOnce() {
    const randomName =
      participants[Math.floor(Math.random() * participants.length)];

    resultEl.textContent = randomName;

    tick += 1;

    if (tick < totalTicks) {
      const delay = 60 + tick * 12;
      setTimeout(tickOnce, delay);
    } else {
      resultEl.textContent = `🎉 ${winner}`;
      resultEl.classList.add('winner');

      spinButton.disabled = false;
      spinning = false;
    }
  }

  tickOnce();
}

여기서 중요한 부분은 setTimeout()입니다.

const delay = 60 + tick * 12;
setTimeout(tickOnce, delay);
💡 참고
setInterval() 대신 setTimeout()을 재귀적으로 호출해 다음 실행 시간을 매번 다르게 지정했습니다.

✔️ 7. 룰렛 실행 중 중복 클릭 방지

룰렛이 실행되는 동안 버튼을 여러 번 클릭하면 여러 개의 룰렛이 동시에 실행될 수 있습니다.

이를 방지하기 위해 spinning 변수를 사용했습니다.

let spinning = false;

룰렛을 실행할 때:

if (spinning) return;

spinning = true;
spinButton.disabled = true;

룰렛이 끝나면 다시:

spinButton.disabled = false;
spinning = false;

로 변경합니다.

이렇게 하면 룰렛이 실행되는 동안에는 추가로 룰렛을 실행할 수 없습니다.


✔️ 8. 실행해보기

이제 VS Code 터미널에서 프로그램을 실행합니다.

npm start

Electron 앱이 실행되면 참가자를 추가하고 🎲 룰렛 돌리기 버튼을 눌러 결과를 확인할 수 있습니다.

이번에는 단순히 Electron 창을 띄우는 수준에서 벗어나 참가자 추가 → 참가자 관리 → 룰렛 실행 → 결과 확인까지 하나의 작은 앱으로 동작하게 되었습니다.