그래픽 사용자 인터페이스(GUI) 프로그래밍 소개

실행중인 프로그램과 사용자 사이의 교감이 지금까지는 터미널 콘솔창을 통해서만 가능하였다. 즉, 사용자가 프로그램에 값을 입력할 때, 또는 프로그램이 결과를 사용자에게 보여줄 때 모두 터미널 콘솔창을 이용하였다.

하지만 요즘 사용되는 애플리케이션은 사용자와 다양한 방식으로 교감한다. 사용자는 키보드 입력 이외에 마우스, 터치 등의 수단으로 애플리케이션에게 정보를 전달하고, 애플리케이션은 팝업 윈도우 창, 소리, 진동 등 다양한 방식으로 반응한다.

여기서는 티비 질문/답변 게임 진행자가 윈도우 창, 소리, 마우스 클릭 등의 다양한 방식으로 교감하는 GUI(graphic user interface, 그래픽 사용자 인터페이스) 애플리케이션을 구현하고자 한다.

최종 목표

아래 내용을 만족하는 GUI 애프리케이션을 생성하고자 한다.

  • 게임 참여자가 진행자의 질문에 답을 하면 정답/오답 여부에 따라 진행자는 해당 버튼을 누른다.
  • 정답 버튼을 누를 때마다 특정 소리가 나고 정답 버튼 옆 숫자가 1씩 증가한다.
  • 오답 버튼을 누를 때마다 특정 소리가 나고 오답 버튼 옆 숫자가 1씩 증가한다.
  • 정답/오답 버튼을 누를 때마다 "총 문제 개수"가 1씩 증가한다.

애플리케이션의 시작 모습은 다음과 같다.

게임 시작 후에 참여자가 5개의 정답과 2개의 오답 버튼을 말했다면 애플리케이션 모습이 아래 모습으로 변하였어야만 한다.

GUI 애플리케이션 구성 요소 확인

"파이썬 퀴즈 게임" GUI 애플리케이션 구성요소는 다음과 같다.

  1. 윈도우 창(screen)
    • 정답/오답 버튼, 문장, 숫자 등이 표시되는 윈도우 창을 가리킨다.
  2. 정답/오답 버튼
  3. 두 개의 문장
    • "정답 또는 오답 버튼을 누르세요."
    • 총 문제 개수:
  4. 세 개의 변하는 숫자
    • 정답 수/오답 수/전체 문제 수

창(screen), 버튼 등 애플리케이션을 구성하는 요소들을 위젯(widget)이라 부른다. GUI 프로그래밍을 위해 다양한 위젯이 제공되며, 여기서는 가장 기본적인 위젯만을 사용하여 퀴즈 게임 애플리케이션을 구현한다.

GUI 애플리케이션 요소 구현 과정

앞서 언급한 구성 요소 네 개를 하나씩 구현해 보자.

윈도우 창

윈도우 창은 tkinter 모듈에 포함된 Tk 클래스의 인스턴스로 구할 수 있다.

In [1]:
from tkinter import *

# 윈도우 팝업창 인스턴스 생성
app = Tk()
# 윈도우 팝업창 타이틀 지정
app.title("파이썬 퀴즈 게임")
# 윈도우 팝업창 크기와 위치 지정
app.geometry('300x120+200+100')

# 윈도우 팝업창 유지
# X 버튼을 누를 때가지 유지됨.
# 사용하지 않을 경우 생성된 후 바로 사라짐.
app.mainloop()

위 프로그램을 실행하면 지정된 타이틀과 크기를 갖는, 하지만 다른 요소를 전혀 포함하지 않는 윈도우 창이 아래와 같이 생성된다.

주의사항

mainloop 메소드는 생성된 윈도우 창이 사용자가 닫기 버튼(X)을 누를 때까지 유지되도록 하는 기능을 갖는다.

정답/오답 버튼

버튼은 tkinter 모듈의 Button 클래스의 인스턴스로 생성할 수 있다.

In [2]:
from tkinter import *

app = Tk()
app.title("파이썬 퀴즈 게임")
app.geometry('300x120+200+100')

# 정답 버튼 생성
b1 = Button(app, text = "정답", width = 7)
b1.pack(side = 'left',  padx = 10, pady = 10)

# 오답 버튼 생성
b2 = Button(app, text = "오답", width = 7)
b2.pack(side = 'right', padx = 10, pady = 10)

app.mainloop()

위 프로그램을 실행하면 앞서 생성한 app 윈도우 창에 정답/오답 두 개의 버튼이 생성된다.

주의사항

  • Button 생성자의 첫째 인자는 생성된 버튼을 위치시킬 창을 가리키는 값이어야 한다. 여기서는 app이 사용되었다.
  • Button 생성자의 textwidth 키워드 인자의 역할은 상식적으로 이해될 수 있다.
  • Button 클래스의 pack 메소드는 생성된 버튼을 지정된 윈도우 창에 위치시키는 기능을 갖는다.
  • pack 메소드의 side 키워드 변수의 역할은 버튼 위치를 지정하기 위해 사용된다.
  • pack 메소드의 padxpady의 역할은 버튼 주의에 여백(패딩)을 주기 위해 사용된다.

두 개의 문장

윈도우 창에 문장을 입력하기 위해서는 원하는 문장을 Label 클래스의 인스턴스로 생성한 후에 지정된 윈도우 창에 위치시켜야 한다.

In [3]:
from tkinter import *

app = Tk()
app.title("파이썬 퀴즈 게임")
app.geometry('300x120+200+100')

# 첫째 문장
lab1 = Label(app, text = "정답 또는 오답 버튼을 누르세요!", fg='blue', height=3)
lab1.pack()

# 둘째 문장
lab2 = Label(app, text='총 문제 개수:')
lab2.pack()

b1 = Button(app, text = "정답", width = 7)
b1.pack(side = 'left',  padx = 10, pady = 10)

b2 = Button(app, text = "오답", width = 7)
b2.pack(side = 'right', padx = 10, pady = 10)

app.mainloop()

위 프로그램을 실행하면 앞서 생성한 app 윈도우 창에 지정한 두 개의 문장이 차례대로 표시된다.

주의사항

  • Label 생성자에 사용된 인자들의 역할은 자연스럽게 이해될 수 있다.
  • 두 개의 문장이 정답/오답 위 쪽에 자리 잡았다. Label, Button 클래스 모두 pack 메소드를 갖고 있으며 pack 메소드가 실행되는 순서대로 각 요소가 자리를 잡는다.

세 개의 변하는 숫자

윈도우 창에 변하는 숫자를 입력하기 위해서도 Label 클래스의 인스턴스를 사용한다. 다만 정답/오답 버튼을 누를 때마다 변하는 값을 바로바로 애플리케이션 창에 보여주기 위해서는 숫자를 담는 특별한 자료형이 필요하다.

In [4]:
from tkinter import *

app = Tk()
app.title("파이선 퀴즈 게임")
app.geometry('300x120+200+100')

# 저장된 정수를 앱에 자동으로 전달하는 자료형 활용: IntVar 클래스
num_good = IntVar()     # 정답 개수 저장
num_good.set(0)         # 정답 개수 0으로 초기화

num_bad = IntVar()      # 오답 개수 저장
num_bad.set(0)          # 오답 개수 0으로 초기화

num_total = IntVar()    # 총 문제 개수 정장
num_total.set(0)        # 총 문제 개수 0으로 초기화


lab1 = Label(app, text = "정답 또는 오답 버튼을 누르세요!", fg='blue', height=3)
lab1.pack()

lab2 = Label(app, text='총 문제 개수:')
lab2.pack()

# IntVar 인스턴스에 저장된 값을 Label 클래스의 인스턴스로 전달하여 보여주기
lab3 = Label(app, textvariable = num_total)
lab3.pack()

lab4 = Label(app, textvariable = num_good)
lab4.pack(side = 'left')

lab5 = Label(app, textvariable = num_bad)
lab5.pack(side = 'right')

b1 = Button(app, text = "정답", width = 7)
b1.pack(side = 'left',  padx = 10, pady = 10)

b2 = Button(app, text = "오답", width = 7)
b2.pack(side = 'right', padx = 10, pady = 10)

app.mainloop()

위 프로그램을 실행하면 앞서 생성한 num_total, num_good, num_bad 변수에 저장된 값이 지정된 위치에 표시된다.

원래 아래 모양을 기대했지만

아래 모양으로 만들어진다. 이유는 app을 생성할 때 윈도우 창의 크기를 지정했기 때문이다.

주의사항

  • IntVar 클래스의 인스턴스는 정수를 담는 그릇 역할을 수행한다.
  • IntVar 클래스의 set 메소드는 생성된 인스턴스에 담길 정수값을 지정한다.
  • Label 생성자에 사용된 textvariable 키워드 매개변수가 text 대신에 사용되었다.

요소들을 하나로 묶어서 처리하기

총 문제 개수가 "총 문제 개수:" 문장 옆에 위치시켜서 다음 모양을 만들었으면 한다. 그러기 위해서는 lab2lab3를 하나로 묶어야 하며 이를 위해 Frame 클래스를 활용한다.

In [5]:
from tkinter import *

app = Tk()
app.title("파이선 퀴즈 게임")
app.geometry('300x120+200+100')

num_good = IntVar()
num_good.set(0)    

num_bad = IntVar() 
num_bad.set(0)     

num_total = IntVar()
num_total.set(0)    

lab1 = Label(app, text = "정답 또는 오답 버튼을 누르세요!", fg='blue', height=3)
lab1.pack()

# 프레임 인스턴스 생성: 여러 요소를 하나로 묶는 기능 수행
# 윈도우 창을 여러 개의 프레임으로 나누는 기능 가잠
# 프레임을 먼저 윈도우 창에 위치시킴
frame = Frame(app)
frame.pack()

# lab2를 app이 아니라 frame 내부의 왼편에 위치시킴
lab2 = Label(frame, text='총 문제 개수:')
lab2.pack(side='left')

# lab3를 app이 아니라 frame 내부의 오른편에 위치시킴
lab3 = Label(frame, textvariable = num_total)
lab3.pack(side='right')

lab4 = Label(app, textvariable = num_good)
lab4.pack(side = 'left')

lab5 = Label(app, textvariable = num_bad)
lab5.pack(side = 'right')

b1 = Button(app, text = "정답", width = 7)
b1.pack(side = 'left',  padx = 10, pady = 10)

b2 = Button(app, text = "오답", width = 7)
b2.pack(side = 'right', padx = 10, pady = 10)

app.mainloop()

위 프로그램을 실행하면 lab2lab3가 하나의 프레임으로 묶여서 각각 한 줄의 좌우편에 위치한다.

주의사항

  • Frame 클래스의 pack 메소드 역시 호출 순서대로 윈도우 창에 위친한다.

Frame 활용하기

lab2lab3를 하나로 묶은 것처럼 다른 줄도 Frame으로 묶어서 처리하면 보다 편하다. 따라서 각각의 줄을 하나의 프레임으로 묶기 위해 Frame 인스턴스를 세 개 사용한다. 이를 위해 Frame 클래스를 활용한다.

In [6]:
from tkinter import *

app = Tk()
app.title("파이선 퀴즈 게임")
app.geometry('300x120+200+100')

num_good = IntVar()
num_good.set(0)    

num_bad = IntVar() 
num_bad.set(0)     

num_total = IntVar()
num_total.set(0)    

# 세 개의 프레임 생성 후 차례대로 app에 위치 시키기
frame1 = Frame(app)
frame1.pack()
frame2 = Frame(app)
frame2.pack()
frame3 = Frame(app)
frame3.pack()

# lab1을 frame1에 넣기
lab1 = Label(frame1, text = "정답 또는 오답 버튼을 누르세요!", fg='blue', height=3)
lab1.pack()

# lab2와 lab3를 frame2에 넣기
lab2 = Label(frame2, text='총 문제 개수:')
lab2.pack(side='left')

lab3 = Label(frame2, textvariable = num_total)
lab3.pack(side='right')

# lab4, lab5, b1, b2를 frame3에 넣기
lab4 = Label(frame3, textvariable = num_good)
lab4.pack(side = 'left')

lab5 = Label(frame3, textvariable = num_bad)
lab5.pack(side = 'right')

b1 = Button(frame3, text = "정답", width = 7)
b1.pack(side = 'left',  padx = 10, pady = 10)

b2 = Button(frame3, text = "오답", width = 7)
b2.pack(side = 'right', padx = 10, pady = 10)

app.mainloop()

위 프로그램을 실행하면 애초에 원했던 모양이 완성되어 보인다.

주의사항

  • 정답/오답 버튼을 눌러도 아직은 아무런 반응이 없다.
  • 소리도 나지 않고 숫자도 변하지 않는다.
  • 이유: 버튼을 눌렀을 때 실행되는 기능을 지정하지 않았기 때문이다.

버튼과 함수 연결하기

버튼을 누를 때 소리가 나거나 숫자가 변해야 하는 일이 발생하도록 하려면 원하는 기능을 수행하는 함수와 버튼을 서로 연결해야 한다.

예를 들어, 정답 버튼을 누를 때마다 정답 수가 1씩 증가해야 한다면 아래 함수를 호출하도록 하면 된다.

def play_correct_sound():
    num_good.set(num_good.get() + 1)
    num_total.set(num_total.get() + 1)

또한 오답 버튼을 누를 때마다 오답 수가 1씩 증가해야 한다면 아래 함수를 호출하도록 하면 된다.

def play_wrong_sound():
    num_bad.set(num_bad.get() + 1)
    num_total.set(num_total.get() + 1)

주의사항

  • IntVarget 메소드는 현재 저장된 정수값을 리턴한다.
  • 따라서 예를 들어 아래 코드는 현재 정답수에 1을 더해 다시 num_good에 저장하라는 명령문이다.

    num_good.set(num_good.get() + 1)
    
  • num_badnum_total 도 동일하게 작동한다.

이제 버튼을 누를 때 숫자가 변하도록 만들 수 있으며, Button 클래스 생성자의 command 키워드 매개변수를 활용하면 된다.

In [7]:
from tkinter import *

app = Tk()
app.title("파이선 퀴즈 게임")
app.geometry('300x120+200+100')

num_good = IntVar()
num_good.set(0)    

num_bad = IntVar() 
num_bad.set(0)     

num_total = IntVar()
num_total.set(0)    

# 정답/오답 버튼을 누를 때 실행되는 함수 선언하기
def play_correct_sound():
    num_good.set(num_good.get() + 1)
    num_total.set(num_total.get() + 1)

def play_wrong_sound():
    num_bad.set(num_bad.get() + 1)
    num_total.set(num_total.get() + 1)
    
frame1 = Frame(app)
frame1.pack()
frame2 = Frame(app)
frame2.pack()
frame3 = Frame(app)
frame3.pack()

lab1 = Label(frame1, text = "정답 또는 오답 버튼을 누르세요!", fg='blue', height=3)
lab1.pack()

lab2 = Label(frame2, text='총 문제 개수:')
lab2.pack(side='left')

lab3 = Label(frame2, textvariable = num_total)
lab3.pack(side='right')

lab4 = Label(frame3, textvariable = num_good)
lab4.pack(side = 'left')

lab5 = Label(frame3, textvariable = num_bad)
lab5.pack(side = 'right')

# command 키워드 매개 변수에 특정 기능 추가하기
b1 = Button(frame3, text = "정답", width = 7, command = play_correct_sound)
b1.pack(side = 'left',  padx = 10, pady = 10)

b2 = Button(frame3, text = "오답", width = 7, command = play_wrong_sound)
b2.pack(side = 'right', padx = 10, pady = 10)

app.mainloop()

위 프로그램을 실행하면 정답/오답 버튼을 누를 때 마다 세 개의 숫자가 적절하게 변한는 것을 확인할 수 있다.

주의사항

  • 아직은 소리가 나지는 않는다.
  • play_correct_soundplay_wrong_sound 본문에 소리내는 부분은 포함되어 있지 않다.

파이썬 애플리케이션에서 소리 내기

애플리케이션에서 소리를 내는 일은 간단하지 않다. 무엇보다도 윈도우, 맥, 리눅스 등 사용하는 운영체제마다 소리를 관리하는 방식이 다르기 때문이다. 다른 프로그래밍언어들도 비슷한 문제를 갖고 있으며, 파이썬의 경우에는 pygame 이라는 패키지를 추가로 설치해서 활용하여 소리를 낼 수 있는 방법이 가장 많이 활용된다.

주의: 패키지란?

  • 파이썬 파일(모듈)을 디렉토리 단위로 정리한 것을 가리킨다.
  • 따라서 pygame 이라는 디렉토리 안에 많은 파이썬 코드 파일이 저장되어 있다고 생각하면 된다.

pygame 패키지 설치

pygame은 파이썬에서 기본적으로 제공하지 않는다. 따라서 파이썬 설치 이후에 추가로 설치해야 하며 pip 이라는 파이썬 패키지 관리자를 이용하면 설치가 매우 쉽다. 기본적으로 아래 과정을 따르면 된다.

  1. 파이썬 정식 버전을 설치할 때 파이썬 경로를 시스템에 등록하지 않은 경우 경로설정을 해주어야 한다. 경로설정에 대한 설명은 여기서는 하지 않는다.
  2. 터미널 창에서 아래 명령어를 실행시킨다.
    pip install pygame
    

pygame.mixer 모듈 활용하기

pygame 패키지(디렉토리) 안에 mixer 라는 모듈이 포함되어 있는데, 바로 이 모듈이 소리를 내는 다양한 기능을 담고 있다.

mixer 모듈을 활용하기 위해서는 아래 과정을 기본적으로 따르면 된다. 여기서는 굳이 다른 설명을 첨부하지 않는다.

import pygame.mixer as sounds
sounds.init()

그런 다음에 정답/오답 버튼을 누를 때 사용할 음악을 아래와 같이 지정한다.

correct_s = sounds.Sound("codes/hfprog/sounds/correct.wav")
wrong_s   = sounds.Sound("codes/hfprog/sounds/wrong.wav")

주의:

  • correct.wavwrong.wav 두 음원파일이 현재 작업 디렉토리(cwd)의 하위 디렉토리인 codes/hfprog/sounds에 저장되어 있다고 가정한다. 다른 곳에 저장되어 있을 경우 경로를 적절하게 수정해야 한다.

이제 play_correct_soundplay_wrong_sound 함수에 소리내는 기능도 추가할 수 있다.

def play_correct_sound():
    num_good.set(num_good.get() + 1)
    num_total.set(num_total.get() + 1)
    correct_s.play()

def play_wrong_sound():
    num_bad.set(num_bad.get() + 1)
    num_total.set(num_total.get() + 1)
    wrong_s.play()

파이선 퀴즈 게임 애플리케이션 최종 코드

이제 최종 코드를 살펴보자.

In [8]:
from tkinter import *
# pygame.mixer를 sounds라는 애칭으로 불러오기
# 애칭은 임의로 지정 가능
import pygame.mixer as sounds

# 정답/오답 소리 지정하기
sounds.init()
correct_s = sounds.Sound("codes/sounds/correct.wav")
wrong_s   = sounds.Sound("codes/sounds/wrong.wav")

app = Tk()
app.title("파이선 퀴즈 게임")
app.geometry('300x120+200+100')

num_good = IntVar()
num_good.set(0)    

num_bad = IntVar() 
num_bad.set(0)     

num_total = IntVar()
num_total.set(0)    

# 정답/오답 버튼을 누를 때 실행되는 함수에 소리기능 추가
def play_correct_sound():
    num_good.set(num_good.get() + 1)
    num_total.set(num_total.get() + 1)
    correct_s.play()

def play_wrong_sound():
    num_bad.set(num_bad.get() + 1)
    num_total.set(num_total.get() + 1)
    wrong_s.play()

frame1 = Frame(app)
frame1.pack()
frame2 = Frame(app)
frame2.pack()
frame3 = Frame(app)
frame3.pack()

lab1 = Label(frame1, text = "정답 또는 오답 버튼을 누르세요!", fg='blue', height=3)
lab1.pack()

lab2 = Label(frame2, text='총 문제 개수:')
lab2.pack(side='left')

lab3 = Label(frame2, textvariable = num_total)
lab3.pack(side='right')

lab4 = Label(frame3, textvariable = num_good)
lab4.pack(side = 'left')

lab5 = Label(frame3, textvariable = num_bad)
lab5.pack(side = 'right')

# command 키워드 매개 변수에 특정 기능 추가하기
b1 = Button(frame3, text = "정답", width = 7, command = play_correct_sound)
b1.pack(side = 'left',  padx = 10, pady = 10)

b2 = Button(frame3, text = "오답", width = 7, command = play_wrong_sound)
b2.pack(side = 'right', padx = 10, pady = 10)

app.mainloop()