Cursor로 나만의 스톱워치를 만들어야겠다. 플러터 앱 개발을 하면서 과정들을 유튜브로 남기고 싶은데, 나만의 콘셉트? 을 잡으려고 생각해 보니 코딩 영상들 중에 시간이 얼마나 걸리는지 보여주는 사람은 못 봤다. 그럼? 내가 해봐야겠다.

윈도 11에 기본적으로 있는 시계 어플은 스톱워치가 1개뿐이다. 구글 검색으로 나오는 스톱워치도 1개만 보여준다. 멀티스톱워치를 만들어서 프로젝트 시작부터 마칠 때까지 얼마만큼의 시간과 노동이 투입되는지 보여주면 유니크한 영상이 될 듯하다.
그냥이가 만든 나만의 스톱워치 다운받기 (<–클릭하세용.)
코드는 맨 아래에 복사하실 수 있습니다. (근데 굳이??? 직접 Cursor한테 시켜보세요~)
Cursor 야 해줘 – 업무 지시하기
가장 먼저 든 생각은 내가 필요로 하는 기능을 나열해서 만들어달라고 했다. 현재 Deepseek와 O3-mini가 무료이기에 O3-mini로 작업하였다.
스톱워치를 여러대 실행할 수 있는 독립 프로그램을 만들어줘. 내가 필요한 기능들이야.
1. 5개 이상 동시에 실행 할 수 있는 스톱워치.
2. 윈도우 11과 비슷한 ui ux
3. 종료 후 다시 실행 해도 기록되있는 시간
4. 데이터는 로컬 컴퓨터에 저장, 불러오기
5. 프로그램 언어는 파이썬 언어로
Cursor가 처음으로 만들어준 멀티 스톱워치 모습이다. 허전해 보이고 타이머를 일일히 추가해줘야하지만, 내가 필요로한 기능은 확실히 들어있다. 몇가지 더 수정, 추가 해보자.
좋아 기능이 내가 원하는 모습이야. 몇가지 변경, 추가해줘.
1. 처음 실행 했을 때 최소 5개의 타이머가 준비된 상태로 실행하게 해줘.
2. 활성화 된 모든 타이머를 동시에 시작 할 수 있는 버튼을 추가해줘
3. 독립적인 실행파일 (exe)를 만들어서 파이썬 없이도 실행 할 수 있도록 해줘
내가 코드를 리뷰 할 필요도 없다. 채팅으로 해줘~ 하면 알아서 기능을 추가해준다. 물론 나 혼자 쓰려는 프로그램이니 디자인은 무시하고 기능만 잘 되면 된다.
거기에 항상 궁금했던 파이썬으로 코딩한 걸 독립실행파일(.exe) 로 만드는 방법을 Cursor Ai에게 시키니 너무나 친절하게 알려주었다.
exe 파일 만들기 (파이썬 설치 없이 실행)
파이썬 스크립트를 exe 파일로 패키징하려면 PyInstaller 를 추천합니다.아래는 PyInstaller를 이용한 exe 파일 생성 방법입니다.PyInstaller 설치터미널(또는 명령 프롬프트)에서 다음 명령어를 실행합니다.
pip install pyinstaller
exe 파일 생성stopwatch_app.py 파일이 있는 디렉터리에서 다음 명령어를 실행합니다.
pyinstaller –onefile stopwatch_app.py
– –onefile 옵션은 단일 실행 파일로 패키징합니다.GUI 창만 표시하고 콘솔창이 뜨지 않게 하려면 추가로 –noconsole 옵션을 사용할 수 있습니다.
pyinstaller –onefile –noconsole stopwatch_app.py
생성된 exe 파일 실행패키징이 완료되면, dist 폴더 내에 stopwatch_app.exe 파일이 생성됩니다. 이를 실행하면 파이썬이 설치되어 있지 않아도 프로그램을 사용할 수 있습니다.이제 위 수정된 코드를 이용하면 요청하신 모든 기능(최소 5 타이머, 전체 시작 버튼, exe 패키징)을 포함한 프로그램을 실행할 수 있습니다.
이대로 실행하니 동일한 경로에 파일들이 생기고 dist 폴더 안에 exe 파일이 생겼다. 실행해 보니 정말로 이전에 사용하던 다른 프로그램들처럼 파이썬 없이 혼자서 실행되고 기능이 동작하는 모습이다. 이야~ 이게 되네!
이제 독립 실행 프로그램으로 만드는 것 까지 왔으니 마지막 디테일을 수정하고 마무리 하자.
좋아. 외부 파일로 실행이 정상적으로 동작하는 것 확인했어. 추가로 아래 대로 변경해줘.
1. 전체 시작이 있으니 전체 중지 버튼, 전체 리셋 버튼도 만들어줘.
2. 각 타이머 마다 이름을 적을 수 있도록 만들어줘. (이름이 적힌 타이머는 삭제 버튼을 누를 때 한번 더 물어보게 해줘)
3. 종료시 데이터를 따로 stopwatch_data.json 파일에 저장을 하는데, 안 보이게 exe 파일 하나로 구동하게 해줘
완성이다. 더 기능을 추가하고 싶지만, 우선은 이정도로 만족하고 원래 하려던걸 해보자. 혹시 내가 만든 스탑워치가 궁금하다면 아래 링크로 다운을 받아서 실행해 보시기 바랍니다.
그냥이가 만든 나만의 스톱워치 다운받기 (<–클릭하세용.)
다운로드 하면 “이 파일 형식은 위험할 수 있습니다” 라고 경고문이 뜨는데, 아무래도 이런 실행파일에 바이러스를 심어두고 배포할 수 있어서 알고 다운받아라는 소리인가 보다.
물론, 바이러스 같은 거 없습니다. 어떻게 바이러스 만드는지도 몰라요. (바이러스를 만들 정도면 취업했겠지.)
나만의 스톱워치는 아마도 계속해서 업데이트를 할 예정입니다.
- 업데이트 할 내용
- 밀리초 단위는 빼자
- 종료시 백그라운드에서 구동하기
- 에러로 컴퓨터가 다운되면 진행중인 시간 저장 못하고 초기값으로 돌아감
- 위 에러의 대처 방안으로 차라리 html 언어로 만들어서 브라우저에서 구동하게 끔 하자
나만의 스톱워치 코드복사하기
아래 코드블록 우측 상단에 Copy 버튼을 눌러 전체 코드를 복사하실 수 있습니다.
import tkinter as tk # Tkinter 모듈: 파이썬으로 GUI(그래픽 사용자 인터페이스)를 만들 때 사용합니다.
import tkinter.ttk as ttk # ttk 모듈: Tkinter의 테마 위젯 제공, 기본 위젯보다 모던한 디자인을 제공합니다.
import tkinter.messagebox as messagebox # 메시지 박스(대화상자)를 쉽게 만들기 위한 모듈입니다.
import time # 시간 관련 함수를 사용하기 위한 모듈입니다.
import json # 데이터를 JSON 형식으로 저장하거나 불러오기 위한 모듈입니다.
import os # 운영체제 관련 기능(파일 시스템 등)을 사용하기 위한 모듈입니다.
import ctypes # Windows API 함수를 파이썬에서 사용하기 위해 필요한 모듈입니다.
def hide_file(filepath):
"""
Windows 전용: 지정된 파일(filepath)의 속성을 '숨김(hidden)'으로 설정합니다.
- 파일 경로를 인자로 받아 Windows API를 호출합니다.
- 실행 도중 오류가 발생하면 False를 반환합니다.
"""
try:
FILE_ATTRIBUTE_HIDDEN = 0x02 # 숨김 속성 상수
# Windows API를 호출하여 파일 속성을 숨김으로 설정합니다.
ret = ctypes.windll.kernel32.SetFileAttributesW(filepath, FILE_ATTRIBUTE_HIDDEN)
return ret != 0 # 성공 시 ret은 0이 아니므로 True를 반환함
except Exception as e:
print("파일 숨기기 실패:", e)
return False
class StopwatchFrame(ttk.Frame):
"""
하나의 스톱워치(타이머)를 나타내는 프레임 클래스입니다.
이 클래스는 아래와 같은 구성 요소를 갖습니다.
- 타이머 이름을 입력할 수 있는 엔트리 위젯
- 현재 시간을 표시하는 라벨
- 시작, 정지, 리셋, (선택적으로) 삭제 버튼
"""
def __init__(self, master, stopwatch_id, initial_elapsed=0.0, is_running=False, last_start=None, name=None, delete_callback=None, **kwargs):
# 부모(ttk.Frame) 클래스의 초기화를 먼저 수행합니다.
super().__init__(master, **kwargs)
self.stopwatch_id = stopwatch_id # 타이머의 일련번호
self.elapsed = initial_elapsed # 지금까지 경과한 총 시간(초 단위)
self.running = is_running # 타이머가 현재 실행 중인지 여부 (True/False)
self.last_start_time = last_start # 타이머가 마지막으로 시작된 시점 (time.time() 값)
self.delete_callback = delete_callback # 타이머 삭제 시 호출할 콜백 함수
# 타이머 이름 설정 (만약 넘겨받은 이름이 없으면 "타이머 {번호}" 형식의 기본 이름 사용)
if name is None:
name = f"타이머 {stopwatch_id}"
self.name_var = tk.StringVar(value=name) # 이름을 저장하는 StringVar 변수
# ========== 1행: 타이머 이름 입력 위젯 구성 ==========
name_label = ttk.Label(self, text="이름:")
name_label.grid(row=0, column=0, padx=5, pady=5) # "이름:" 레이블 배치
self.name_entry = ttk.Entry(self, textvariable=self.name_var)
self.name_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=4, sticky="we")
# columnspan=4를 사용하여 엔트리 위젯이 4칸을 차지하도록 함.
# ========== 2행: 시간 표시 및 제어 버튼 구성 ==========
# 현재 시간(초)를 HH:MM:SS.xxx 형식으로 변환하여 라벨에 표시
self.time_label = ttk.Label(self, text=self.format_time(self.elapsed), font=("Segoe UI", 18))
self.time_label.grid(row=1, column=0, padx=5, pady=5)
# 시작 버튼: 타이머를 시작하는 기능 연결
self.start_button = ttk.Button(self, text="시작", command=self.start)
self.start_button.grid(row=1, column=1, padx=5)
# 정지 버튼: 타이머를 일시 정지하는 기능 연결
self.stop_button = ttk.Button(self, text="정지", command=self.stop)
self.stop_button.grid(row=1, column=2, padx=5)
# 타이머가 이미 실행 중이면 정지 버튼 활성화, 아니면 비활성화
self.stop_button["state"] = "normal" if self.running else "disabled"
# 리셋 버튼: 타이머의 경과 시간을 0으로 초기화
self.reset_button = ttk.Button(self, text="리셋", command=self.reset)
self.reset_button.grid(row=1, column=3, padx=5)
# 삭제 버튼: 타이머를 삭제할 때 호출 (delete_callback이 설정된 경우만 생성)
if self.delete_callback:
self.delete_button = ttk.Button(self, text="삭제", command=self.delete)
self.delete_button.grid(row=1, column=4, padx=5)
# 레이아웃 조절: 1번 컬럼이 남은 공간을 채울 수 있도록 설정
self.columnconfigure(1, weight=1)
# 타이머 시간이 제대로 갱신되도록 반복 호출하는 함수 시작
self.update_clock()
def format_time(self, t):
"""
초 단위 시간 t를 HH:MM:SS.xxx 형식으로 변환하여 반환합니다.
예) 3661.23초 -> "01:01:01.23"
"""
hours = int(t) // 3600 # 시 계산
minutes = (int(t) % 3600) // 60 # 분 계산
seconds = int(t) % 60 # 초 계산
milliseconds = int((t - int(t)) * 100) # 밀리초 계산 (소수점 이하 2자리)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:02d}"
def update_clock(self):
"""
주기적으로(매 50ms) 시간을 업데이트하여 time_label에 최신 경과 시간을 표시합니다.
만약 타이머가 실행 중이라면 현재 경과 시간을 실시간으로 보여줍니다.
"""
if self.running:
# 실행 중일 경우, 저장된 경과 시간과 현재까지 추가된 시간의 합계를 계산합니다.
current_elapsed = self.elapsed + (time.time() - self.last_start_time)
else:
current_elapsed = self.elapsed
# 라벨 텍스트를 업데이트
self.time_label.config(text=self.format_time(current_elapsed))
# 50ms 후 다시 update_clock 함수를 호출하여 주기적으로 갱신합니다.
self.after(50, self.update_clock)
def start(self):
"""
타이머 시작 함수:
- 타이머가 정지 상태일 때 현재 시점을 시작 시점으로 기록하고 실행 상태로 전환합니다.
- 시작 버튼은 비활성화, 정지 버튼은 활성화합니다.
"""
if not self.running:
self.last_start_time = time.time() # 현재 시간을 시작 시간에 기록
self.running = True # 실행 상태로 변경
self.start_button["state"] = "disabled" # 시작 버튼 비활성화
self.stop_button["state"] = "normal" # 정지 버튼 활성화
def stop(self):
"""
타이머 정지 함수:
- 타이머가 실행 중이라면 현재까지의 경과 시간을 계산하여 저장하고 실행 상태를 해제합니다.
- 시작 버튼은 다시 활성화하고 정지 버튼은 비활성화합니다.
"""
if self.running:
# 현재까지 경과한 시간을 누적하여 저장 (이전에 저장된 elapsed에 더합니다)
self.elapsed += time.time() - self.last_start_time
self.running = False # 정지 상태로 변경
self.last_start_time = None # 시작 시간 초기화
self.start_button["state"] = "normal" # 시작 버튼 활성화
self.stop_button["state"] = "disabled" # 정지 버튼 비활성화
def reset(self):
"""
타이머 리셋 함수:
- 누적된 시간을 0으로 초기화합니다.
- 실행 중인 경우 현재 시간을 다시 시작 시간으로 갱신하여 0부터 계산합니다.
"""
self.elapsed = 0.0
if self.running:
self.last_start_time = time.time()
def delete(self):
"""
타이머 삭제 함수:
- 타이머의 이름이 기본값이 아니면, 삭제 전 사용자에게 확인 대화상자를 보여줍니다.
- 확인 후 delete_callback 함수를 호출하여 상위(메인 앱)에서 삭제 처리되도록 합니다.
"""
current_name = self.name_var.get().strip() # 현재 입력된 이름
default_name = f"타이머 {self.stopwatch_id}" # 기본 이름
if current_name != default_name:
# 이름이 기본값이 아니면 사용자에게 재확인함
answer = messagebox.askyesno("삭제 확인", f"'{current_name}' 타이머를 삭제하시겠습니까?")
if not answer:
return # 사용자가 삭제를 취소한 경우 함수 종료
if self.delete_callback:
self.delete_callback(self)
class StopwatchApp(tk.Tk):
"""
메인 애플리케이션 클래스:
- 여러 개의 스톱워치(타이머)를 관리하며 전체 시작, 정지, 리셋 등의 기능을 제공합니다.
- 데이터는 Windows의 APPDATA 폴더 아래 숨김 파일로 저장/불러오게 되어 있습니다.
- 최소 5개의 타이머가 준비되도록 구성되어 있습니다.
"""
def __init__(self):
super().__init__() # 부모(tk.Tk) 클래스 초기화
self.title("멀티 스톱워치") # 창의 제목 설정
# 창을 닫을 때 on_closing() 함수를 호출하도록 바인딩
self.protocol("WM_DELETE_WINDOW", self.on_closing)
# ========== 메인 프레임 생성 ==========
self.main_frame = ttk.Frame(self)
# 창 전체에 대해 여백을 주면서 메인 프레임을 배치
self.main_frame.pack(expand=True, fill="both", padx=10, pady=10)
# ========== 상단 버튼 프레임 ==========
# 여러 타이머 관리 기능 (새 타이머 추가, 전체 시작/정지/리셋)을 위한 버튼 배치
self.button_frame = ttk.Frame(self.main_frame)
self.button_frame.pack(side="top", pady=5)
self.add_button = ttk.Button(self.button_frame, text="새 스톱워치 추가", command=self.add_stopwatch)
self.add_button.pack(side="left", padx=5)
self.start_all_button = ttk.Button(self.button_frame, text="전체 시작", command=self.start_all_timers)
self.start_all_button.pack(side="left", padx=5)
self.stop_all_button = ttk.Button(self.button_frame, text="전체 중지", command=self.stop_all_timers)
self.stop_all_button.pack(side="left", padx=5)
self.reset_all_button = ttk.Button(self.button_frame, text="전체 리셋", command=self.reset_all_timers)
self.reset_all_button.pack(side="left", padx=5)
# ========== 스크롤 가능한 타이머 컨테이너 ==========
# 타이머들이 배치되는 캔버스 위젯 생성 (윈도우 크기에 따라 스크롤 가능)
self.canvas = tk.Canvas(self.main_frame)
self.canvas.pack(side="left", fill="both", expand=True)
# 수직 스크롤바를 생성하여 캔버스에 연결
self.vscrollbar = ttk.Scrollbar(self.main_frame, orient="vertical", command=self.canvas.yview)
self.vscrollbar.pack(side="right", fill="y")
# 캔버스와 스크롤바를 연동
self.canvas.configure(yscrollcommand=self.vscrollbar.set)
# 캔버스 내에 타이머들을 담을 프레임 생성 및 배치
self.stopwatch_container = ttk.Frame(self.canvas)
self.canvas.create_window((0, 0), window=self.stopwatch_container, anchor="nw")
# container의 크기가 변경될 때마다 캔버스의 scrollregion을 갱신
self.stopwatch_container.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
# 마우스 휠 이벤트를 바인딩하여 스크롤 기능 구현(Windows 환경)
self.canvas.bind_all("<MouseWheel>", self.on_mousewheel)
self.stopwatches = [] # StopwatchFrame 객체들을 담는 리스트
# ========== 데이터 파일 경로 지정 ==========
# Windows의 APPDATA 폴더 내에 "멀티스톱워치" 폴더 생성 후 데이터 파일(stopwatch_data.json) 경로 지정
appdata_dir = os.getenv("APPDATA")
data_dir = os.path.join(appdata_dir, "멀티스톱워치")
if not os.path.exists(data_dir):
os.makedirs(data_dir)
self.data_file = os.path.join(data_dir, "stopwatch_data.json")
# 이전에 저장된 타이머 데이터가 있다면 불러옵니다.
self.load_data()
# 저장된 타이머 개수가 5개 미만이면 최소 5개가 있도록 새 타이머를 추가합니다.
if len(self.stopwatches) < 5:
for _ in range(5 - len(self.stopwatches)):
self.add_stopwatch()
# 위젯들을 모두 구성한 후, 창 크기를 내용에 맞게 자동 조절
self.update_idletasks() # 위젯 배치가 끝나도록 대기
req_width = self.main_frame.winfo_reqwidth() + 200 # 가로 여백을 추가하여 창 크기 설정
req_height = self.main_frame.winfo_reqheight() + 250 # 세로 여백을 추가
self.geometry(f"{req_width}x{req_height}") # 창 크기 설정
def on_mousewheel(self, event):
"""
마우스 휠 이벤트 핸들러:
- 사용자가 마우스 휠을 움직이면 캔버스의 내용을 스크롤합니다.
- Windows에서는 event.delta 값으로 스크롤의 방향 및 단위를 알 수 있습니다.
"""
self.canvas.yview_scroll(-1 * int(event.delta / 120), "units")
def add_stopwatch(self, initial_elapsed=0.0, is_running=False, last_start=None, name=None):
"""
새 스톱워치(타이머) 프레임을 생성하여 stopwatch_container에 추가합니다.
- 각 타이머는 StopwatchFrame 클래스의 인스턴스로 생성됩니다.
- 생성 후, 화면에 배치하고 내부 리스트(stopwatches)에 추가합니다.
"""
sw_frame = StopwatchFrame(
self.stopwatch_container,
stopwatch_id=len(self.stopwatches) + 1, # 타이머 번호는 현재 리스트 길이에 1을 더한 값
initial_elapsed=initial_elapsed,
is_running=is_running,
last_start=last_start,
name=name,
delete_callback=self.delete_stopwatch # 타이머 삭제 시 호출될 콜백 함수 지정
)
sw_frame.pack(pady=5, fill="x") # 타이머 프레임을 수직으로 배치(여백 포함)
self.stopwatches.append(sw_frame)
def delete_stopwatch(self, sw):
"""
특정 스톱워치 프레임을 삭제합니다.
- 프레임을 화면에서 제거하고, 리스트에서 해당 객체를 삭제합니다.
"""
sw.destroy() # 위젯 제거
if sw in self.stopwatches:
self.stopwatches.remove(sw)
def start_all_timers(self):
"""
모든 스톱워치를 동시에 시작합니다.
- 각각의 타이머에 대해 실행 중이 아닌 경우 start() 메서드를 호출합니다.
"""
for sw in self.stopwatches:
if not sw.running:
sw.start()
def stop_all_timers(self):
"""
모든 스톱워치를 동시에 정지합니다.
- 실행 중인 타이머들에 대해 stop() 메서드를 호출합니다.
"""
for sw in self.stopwatches:
if sw.running:
sw.stop()
def reset_all_timers(self):
"""
모든 스톱워치를 동시에 리셋합니다.
- 각 타이머의 경과 시간을 0으로 초기화한 후,
save_data()를 호출하여 현재 상태를 바로 저장합니다.
"""
for sw in self.stopwatches:
sw.reset()
self.save_data() # 즉시 저장하여 다음 실행 시 업데이트된 상태로 로드되도록 함
def load_data(self):
"""
이전에 저장된 타이머 데이터를 APPDATA 내 JSON 파일로부터 불러와 복원합니다.
- 파일이 존재하면 각 타이머 정보를 읽어와 새로운 스톱워치 프레임으로 생성합니다.
"""
if os.path.exists(self.data_file):
try:
with open(self.data_file, "r", encoding="utf-8") as f:
data = json.load(f)
sw_list = data.get("stopwatches", [])
# 저장된 각 타이머 데이터에 대해 스톱워치 프레임을 추가
for sw_data in sw_list:
initial_elapsed = sw_data.get("elapsed", 0.0)
is_running = sw_data.get("is_running", False)
last_start = sw_data.get("last_start", None)
name = sw_data.get("name", None)
self.add_stopwatch(initial_elapsed, is_running, last_start, name)
except Exception as e:
print("데이터 로드 오류:", e)
def save_data(self):
"""
모든 스톱워치의 현재 상태를 APPDATA 내 JSON 파일에 저장합니다.
- 만약 타이머가 실행 중이면, 현재까지의 경과 시간을 업데이트한 후 정지 상태로 처리합니다.
- 저장 후, Windows 전용으로 파일을 숨김 속성으로 변경합니다.
"""
sw_list = []
for sw in self.stopwatches:
if sw.running:
# 실행 중인 타이머라면, 현재까지의 시간 추가 후 정지 상태로 전환
current_elapsed = sw.elapsed + (time.time() - sw.last_start_time)
sw.elapsed = current_elapsed
sw.running = False
sw.last_start_time = None
# 각 타이머의 상태를 딕셔너리 형태로 저장
sw_data = {
"elapsed": sw.elapsed,
"is_running": sw.running,
"last_start": sw.last_start_time,
"name": sw.name_var.get()
}
sw_list.append(sw_data)
data = {"stopwatches": sw_list}
try:
# 파일이 이미 존재하면, 먼저 숨김 속성을 해제하여 쓰기가 가능하도록 합니다.
if os.path.exists(self.data_file):
FILE_ATTRIBUTE_NORMAL = 0x80 # 일반 파일 속성 상수
ctypes.windll.kernel32.SetFileAttributesW(self.data_file, FILE_ATTRIBUTE_NORMAL)
with open(self.data_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
# 파일 저장 후, 파일을 숨김 상태로 변경 (Windows 전용)
hide_file(self.data_file)
except Exception as e:
print("데이터 저장 오류:", e)
def on_closing(self):
"""
창을 닫을 때 호출되는 함수:
- 현재 타이머 상태를 저장한 후, 애플리케이션 창을 종료합니다.
"""
self.save_data() # 종료 전 데이터 저장
self.destroy() # 창 닫기
if __name__ == "__main__":
# 프로그램의 시작점: StopwatchApp 클래스의 인스턴스를 생성하고, Tkinter 이벤트 루프를 시작합니다.
app = StopwatchApp()
app.mainloop() # GUI 창을 띄우고 사용자의 이벤트(버튼 클릭 등)를 처리하는 무한 루프 시작