'.....'에 해당되는 글 47건

  1. 2008.06.09 Tcl/Tk 문법 - 2부 Tk
  2. 2008.06.09 Tcl/Tk 문법 - 1부 Tcl
  3. 2008.05.23 Erlang 관련 URL
  4. 2008.03.25 Name mangling in C++
  5. 2008.02.05 BFD 라이브러리
  6. 2008.01.07 Firefox Beta2 Release
  7. 2007.04.28 Error LNK2005 : _DllMain@12 already defined
  8. 2007.04.24 The Function Pointer Tutorials
  9. 2007.02.13 애플리케이션 개발시의 메모리 디버깅 : 메모리 누수 발견 기법
  10. 2007.01.02 TCP/IP를 기반으로 한 온라인 게임 제작 : 크리티컬섹션과 세마포어 , 뮤텍스


Tcl/Tk 문법 - 2부 Tk

Development 2008. 6. 9. 09:39
출처 : http://terzeron.net/wp/?p=245

1999/07/16

1장 소개

1) X Window System과 Tk의 소개

X 윈도우 시스템은 MIT와 DEC가 공동으로 개발한, 하드웨어와 OS에 독립적인 윈도우 시스템이다. 하드웨어와 OS에 독립적일 수 있는 이유는, X 클라이언트와 X 서버로 각각의 기능이 분리되어 있기 때문이다. X 서버는 장치구동기(device driver)를 통해 하드웨어와 OS를 제어하고, X 클라이언트는 X 프로토콜을 이용해 X 서버에게 윈도우 작업을 요청하게 되는 것이다. X 프로토콜은 사람이 이해하기 어렵기 때문에, Xlib을 이용하여 C로 X 윈도우 프로그래밍을 할 수 있도록 지원하고 있다.

그러나 Xlib조차 프로그래밍하기가 쉽지 않다. 아주 간단한 윈도우를 하나 만드려고 해도 프로그래머가 지정해야 할 것들이 너무 많기 때문이다. 그래서 이미 Xlib 수준에서 구현된 위짓(widget)을 재사용하게 된다. 이것을 바로 툴킷(Toolkit)이라고 한다. 툴킷에도 여러 단계가 있어서 가장 하위 레벨은 X Toolkit Intrinsics이다. 이것은 여러 X 응용프로그램에서 자주 사용되는, 아주 핵심적인 위짓을 미리 만들어놓은-정의해놓은-라이브러리라고 할 수 있다. Xt라고 불리우는 X Toolkit Intrinsics 이외에, 좀 더 세련된 위짓들의 집합을 제공하는 것으로는 Motif와 Qt, Gtk 등이 있다.

Tk는 일반적인 뜻으로는 툴킷(Toolkit)을 의미한다. 그러나 여기서 말하는 Tcl/Tk는 Tcl에서 사용하는 툴킷으로 그 의미가 한정된다. 우리가 이제부터 살펴볼 것은 Tcl에서 윈도우를 어떻게 생성하고 다룰 것인가에 관한 것이다. Tk를 중심으로 Tcl/Tk를 익혀보기 위해서는 wish(windowing shell)가 필요하다. 1부에서 밝힌대로 Tcl/Tk를 설치했다면 wish가 존재할 것이다. /usr/X11R6/bin이나 /usr/openwin/bin 등의 X 응용프로그램 디렉토리에서 찾아서 실행해보자. 제대로 설치가 되어 있다면 다음 명령을 수행했을 때, 버튼 위에 쓰여진 환영 인사를 만날 수 있을 것이다.


button .b -text "Hello, world!"
pack .b

pack은 사용자가 만든 위짓 인스턴스를 화면에 디스플레이하는 명령이다.

2) X resource

X 윈도우 시스템에서 폰트나 색상, 사이즈와 같이 위짓의 속성을 바꿀 수 있는 변수들을 X resource라고 한다. 리소스(resource)는 X 클라이언트가 요청하면 X 서버가 할당해주는 형태로 되어 있다. X 윈도우 시스템에는 여러가지 위짓이 존재하고, 각각의 위짓은 또 다양한 리소스를 필요로 하게 되므로 상당히 많은 종류의 리소스가 존재한다. 그러나 여기서는 Tcl/Tk에서 중요하게 다루는 몇 가지 리소스에 대해 짧게 살펴보고 지나갈 것이다. X 윈도우 프로그래밍에 관심있는 독자라면 Xlib 프로그래밍이나 X 툴킷 프로그래밍에 관련된 책을 읽어보는 것이 좋겠다.

우선 가장 많이 사용되는 것은 색상이다. 아주 간단한 RGB방식으로 빨간색, 파란색, 녹색을 1 바이트(0에서 255까지)씩 빛을 혼합하는 것이다. 예를 들면 흰색은 #ffffff이고, 검정색은 #000000이다. 빨간색은 #ff0000이고, 녹색은 #0000ff이다. 보라색은 #ffff00이 되겠다. 그러나 프로그래머가 매번 광원의 색을 혼합하여 색상을 만들어내는 것은 번거로운 일이 될 것이다. X 윈도우 시스템에서는 showrgb라는 명령으로 이미 지정되어 있는 색상의 이름을 확인할 수 있다. k에서는 이렇게 정의되어 있는 색상의 이름을 그대로 사용할 수 있다.

다음으로 많이 사용하는 리소스는 폰트이다. 폰트는 XLFD(X Logical Font Description)을 따라서 그 이름을 지정하면 되는데, 복잡한 설명보다는 xlsfonts를 이용하여 폰트 리스트에서 마음에 드는 폰트명을 골라서 사용하면 된다. 그러나 폰트명만 가지고는 폰트의 생김새를 알 수 없으므로, xfontsel이라는 프로그램으로 각각의 폰트가 어떻게 생겼는지 확인하는 것이 좋겠다. 어떻게 사용하는지는 차차 살펴보기로 하자.

2장 Tk Widget

Tk 위짓은 Motif의 스타일을 제공하는 위짓이다. 각각의 위짓은 클래스(class)와 인스턴스(instance)로 그 개념을 나누어 볼 수 있다. 클래스는 특정 위짓의 종류를 지칭하고, 인스턴스는 그 위짓 클래스에 속하는 위짓을 하나 만들어 이름을 붙여준 것을 가리킨다. 위짓 인스턴스는 계층을 이루어서 하나의 윈도우를 구성하게 된다. Xterm을 예로 보면, Xterm은 왼쪽이나 오른쪽에 스크롤바(scrollbar)를 가지고 있고, 나머지 부분에 텍스트입력창이 존재한다. 이런 경우에는 최상위의 윈도우 아래에 스크롤바와 텍스트입력창이 존재하는 계층구조라고 할 수 있을 것이다. 메뉴(menu)를 가지고 있는 X 응용프로그램이라면 메뉴 안에 메뉴 아이템이 존재하고, 그 아이템이 다시 메시지창을 가진다든가 해서 아주 복잡한 계층구조를 형성할 것이다.

Tk에서 제공하는 위짓에는 프레임(frame), 레이블(label), 버튼(button), 체크버튼(checkbutton), 라디오버튼(radiobutton), 메시지(message), 리스트박스(listbox), 스크롤바(scrollbar), 스케일(scale), 엔트리(entry), 메뉴(menu), 메뉴버튼(menubutton) 등이 있다.

특별한 위짓으로는 top-level 위짓이 있는데, 이것은 “.”(dot)으로 표현된다. “.”을 쓰면 최상위의 위짓이고, “.a”라 하면 top-level 위짓 바로 아래 레벨에 위치하는 위짓 인스턴스 a가 되는 것이다.

1) 프레임(frame)

프레임은 위짓을 그룹으로 묶을 때 사용하는 위짓이다. 나중에 살펴 볼 pack 명령만으로는 위짓들을 관리하기가 어려워서 프레임을 이용하여 위짓을 그룹단위로 사용하게 된다. 프레임은 -relief 옵션을 주어 5가지의 형태로 만들 수 있는데, 다음의 예는 각각의 형태를 한 윈도우에 모아서 보여주는 것이다.


foreach re { raised sunken flat groove ridge } {
frame .$re -width 15m -height 10m -relief $re -bg gray40 -borderwidth 4
pack .$re -side left -padx 2m -pady 2m
}

[그림 1]

2) 버튼(button)

버튼은 마우스 버튼을 누르면 움푹하게 들어가고, 손가락을 떼면 다시 올라오는 위짓이다. 가장 흔히 만날 수 있는 위짓이 바로 버튼이다. 버튼도 프레임과 마찬가지로 -relief 옵션을 이용하여 5가지의 형태로 만들 수 있다.

다음은 버튼 2개를 하나의 프레임 안에 넣는 예이다. “.f.b1″ 또는 “.f.b2″는 f라는 프레임 인스턴스 아래에 두 개의 버튼 인스턴스가 존재한다는 의미이다. 물론 위에서 설명한 바와 같이, 프레임 f는 top-level 위짓 바로 아래 레벨에 위치하는 프레엠 위짓 클래스의 f라는 이름을 가진 인스턴스가 되는 것이다.


frame .f
button .f.b1 -text button1
button .f.b2 -text button2
pack .f.b1 .f.b2

아무 것도 나오지 않는다. 이상할 것은 없다. 바로 프레임 f를 pack하지 않았기 때문이다. pack에 대해서는 뒤에서 자세히 다루어 보도록 한다. 다음 명령을 추가하면 버튼 2개가 나타날 것이다.


pack .f

3) 체크버튼(checkbutton)과 라디오버튼(radiobutton), 리스트박스(listbox)

체크버튼은 여러 개의 버튼을 눌러서 동시에 여러가지를 선택할 수 있게 하는 버튼이다. 반면에 라디오버튼은 라디오에서 채널을 맞추듯 한 번에 단 하나만을 선택할 수 있게 하는 버튼이다. 체크버튼과 라디오버튼은 그 버튼이 체크되는 순간에 특정 변수에 특정 값을 대입할 수 있도록 -variable, -value 옵션을 제공한다.(-value 옵션은 라디오버튼에서만 사용된다.) 리스트박스는 체크버튼을 확장해 놓은 것으로 생각할 수 있는 위짓으로 하나 이상의 아이템을 선택할 수 있다.


checkbutton .red -text Red -variable color_red
checkbutton .green -text Green -variable color_green
checkbutton .blue -text Blue -variable color_blue
pack .red .green .blue -side top -fill x

radiobutton .red -text Red -variable color -value red
radiobutton .green -text Green -variable color -value green
radiobutton .blue -text Blue -variable color -value blue
pack .red .green .blue -side top -fill x<
[그림 2]
[그림 3]

위의 두 예제는 체크버튼과 라디오버튼이 어떻게 다른가를 알려주는 예이다. 체크버튼의 경우에, 몇 개를 선택하고서 puts $color_red $color_green $color_blue를 실행하여 각각의 값을 확인해 보자. 체크되어 있는 색상의 변수에는 이, 아닌 경우에는 0이 대입되어 있는 것을 알 수 있다. 라디오버튼의 예제는 색상을 선택한 후에, puts $color 해 보면 값이 설정되어 있는 것을 확인할 수 있다.


listbox .name -selectmode browse
pack .name
set f [open /etc/passwd]
while {[gets $f line] >= 0} {
set item [lindex [split $line :] 0]
.name insert end “$item”
}
close $f

[그림 4]

이 예제를 -selectmode 옵션의 값을 single, multiple, extended로 바꾸어가며 테스트해보자. 그러면 마우스의 바인딩(binding)이 어떻게 바뀌는지 알 수 있다. 예를 들면, extended로 바꾸어 놓고 실행하면 마우스의 첫번째 버튼을 누른 채로 드래그할 때 해당 아이템들 모두를 선택할 수 있다.

4) 메시지(message)

메시지는 길이가 긴 텍스트를 보여주는 메시지 창을 의미한다. 다음의 예는 오른쪽 정렬로 텍스트를 보여준다. 폰트를 지정하고, 배경색과 전경색을 지정하는 방법을 눈여겨 보아두자.


message .m -justify right -font "-*-helvetica-*-*-*--*-120-*" -bg white -fg black -text "Message widgets are similar to labels except that they display multiline strings."
pack .m

[그림 5]

5) 스크롤바(scrollbar) & 스케일(scale)

스크롤바는 대개 수직으로 세워져 있는 위짓인 반면에, 스케일은 수평으로 눕혀져 있는 위짓이다. 물론 각각의 위짓은 옵션을 이용하여 그 모양을 반대로 바꿀 수 있다. 스크롤바는 많은 아이템을 검색할 때 사용되고, 스케일은 슬라이더(slider)를 이용하여 정수값을 변화시킬 때 사용된다.


listbox .li -yscrollcommand ".sc set"
scrollbar .sc -command ".li yview"
pack .li -side left
pack .sc -side right -fill y
foreach it [lsort [glob *]] {
.li insert end $it
}

[그림 6]

스크롤바를 상하로 움직이면 리스트박스 인스턴스인 .li에 yview명령을 수행하게 되어 리스트박스가 상하로 움직이게 되는 것이다.

scale .percent -from 0 -to 100 -orient horizontal -command showvalue
pack .percent
proc showvalue parameter {
puts [.percent get]
}

[그림 7]

이 예는 슬라이더를 움직이면 그 해당 정수값을 출력하는 것이다. horizontal을 vertical로 바꾸면 슬라이더가 수직으로 나타날 것이다. 스케일에 -command 옵션으로 값에 변화가 있을 때마다 프러시져를 호출할 수 있다. 잘 살펴보면 프러시져 안에서 스케일의 현재 값을 얻기 위해 .percent get하고 있음을 알 수 있다.

6) 엔트리(entry)

엔트리는 텍스트 입력창이다. 엔트리에 텍스트를 입력한 후에 puts $var로 변수의 값을 확인해보자.


entry .e -relief groove -textvariable var
pack .e

7) 메뉴(menu)와 메뉴버튼(menubutton)

메뉴에는 체크버튼, 라디오버튼, 메뉴버튼, 명령(command), 구분선(separator) 등을 아이템으로 가질 수 있다. 체크버튼과 라디오버튼은 위에서 설명한 바와 같은 방식으로 작동하고, 명령은 프러시져를 호출하기 위해 사용하며, 구분선은 아이템을 시각적으로 그룹화하기 위해 사용된다.


menubutton .mb -text "Menu" -menu .mb.m
pack .mb
menu .mb.m
.mb.m add radiobutton -label Red -variable red
.mb.m add radiobutton -label Blue -variable blue
.mb.m add separator
.mb.m add command -label "Hello" -command hello
proc hello {} {
puts "Hello, world!"
}

다만 메뉴 자체는 pack 명령을 사용하지 않는다. 메뉴가 속해있는 다른 위짓이 그 작업을 대신 수행해준다. 여기서는 메뉴버튼을 pack해서 화면에 디스플레이하고, menu인 .m을 연결시킨다.

메뉴의 한 항목이 다시 메뉴를 부르는 것을 캐스케이딩 메뉴(cascading menu)라고 하는데, 이것은 add하는 항목에 cascade라고 쓰고 -menu option을 이용하여 새로운 메뉴를 연결시키면 가능하다.


.mb.m add cascade -labe "Another menu" -menu .mb.cm
menu .mb.cm
.mb.cm add radiobutton -label Green -variable green

메뉴바를 만들고 거기서 다시 서브메뉴를 만드는 경우가 있는데, 이런 경우에는 우선 메뉴바를 하나 만들고, 메뉴바에 메뉴버튼을 몇 개 붙인 후, 각각의 버튼에 메뉴를 연결하는 것이다. 메뉴바는 메뉴버튼을 그룹으로 만드는 것이므로 프레임으로 선언한다.


frame .menubar -relief raised
menubutton .menubar.file -text File -menu .menubar.file.menu
menubutton .menubar.edit -text Edit -menu .menubar.edit.menu
pack .menubar -fill x -expand true
pack .menubar.file .menubar.edit -side left

menu .menubar.file.menu
.menubar.file.menu add command -label “Open”
.menubar.file.menu add command -label “Save”
.menubar.file.menu add command -label “Exit”
File과 Edit 메뉴인 .menubar.file.menu와 .menubar.edit.menu는 위에서 설명했던 대로 사용자가 만들면 된다. 중요한 것은 메뉴는 pack하지 않고, 메뉴를 연결하는 위짓(여기서는 메뉴바와 메뉴버튼)은 반드시 pack해야 한다는 것이다. 메뉴의 단축키를 지정하거나 특정 문자에 밑줄을 긋기 위해서는 -accelerator와 -underline 옵션을 사용하면 된다.


.menubar.file.menu add command -label "Open" -underline 0 -accelerator "Ctrl+O" -command open
proc open {} {
puts "open"
}

이 예에서 -underline 옵션의 값이 0인데, 이것은 Open이라는 label의 첫글자 O에 밑줄을 긋겠다는 의미이다. 단축키는 Ctrl+O로 지정했지만, 실제로 Ctrl+O를 누른다고 해서 그 항목이 선택되지는 않는다. 단축키와 그 항목을 연결하기 위해서는 bind 명령을 사용해야 한다. 이것은 뒤에서 다루기로 한다.

3장 Pack

pack 명령을 이용하면 위짓들의 위치를 조정할 수 있다. pack 명령에서 사용되는 옵션에는 -after, -anchor, -before, -expand, -fill, -in, -ipadx, -ipady, -padx, -pady, -side 등이 있는데, 가장 일반적으로 쓰는 옵션은 -side라고 할 수 있다. -side 옵션의 값으로 줄 수 있는 것은 left, right, top, bottom이 있다.


button .a -text a
button .b -text b
button .c -text c

세 개의 버튼을 생성한 후에, 다음의 두 명령을 각각 실행하여 위짓들의 배치를 확인해보자.

pack .a .b .c -side left
pack .a .b .c -side top

[그림 8]
[그림 9]

위짓들을 배치하다보면 가끔 상하나 좌우로 조금씩 공간이 생기는데, 이것을 없애주려면 -fill 옵션을 사용하면 된다. -fill 옵션에는 x, y, both로 각각 값을 줄 수 있다.


button .a -text aaaaaaaa
button .b -text b
button .c -text cccc
pack .a .b .c -fill x

-fill 옵션없이 pack 명령을 수행한 것과 결과가 어떻게 다른지 비교해보자. -fill 옵션말고도 빈 공간을 채우는 방법으로 -expand 옵션을 사용하는 것이 있는데, -expand는 모든 위짓이 함께 공간을 채우는 것이 아니라, 특정 위짓에게 남은 공간을 모두 할당해준다. 그러나 -fill 옵션처럼 그 위짓이 빈 공간을 모두 채우는 것이 아니라, 잠재적으로 그 위짓에게 공간을 사용할 권리가 부여된 것이다. -fill 옵션을 함께 사용하여 빈 공간을 채울 수 있다. 다음의 예제를 실행하고 윈도우 크기를 늘여보자.


button .a -text aaaaaaaa
button .b -text b
button .c -text cccc
pack .a .b .c -side left

[그림 10]

오른쪽에 빈 공간이 생긴다. 이제 다음 각각의 pack 명령을 수행하여 -expand 옵션이 어떤 효과를 내는지 살펴보도록 하자.


pack .a .b .c -expand 1
pack .a .b -side left -expand 0
pack .c -expand 1 -fill x

[그림 11]
[그림 12]
[그림 13]

이제 위짓들을 정렬하는 방법에 대해 알아보자. 정렬을 위해서는 -anchor 옵션을 사용할 수 있는데, 옵션의 값으로 center(중앙), n(북), ne(북동), e(동), se(남동), s(남), sw(남서), w(서), nw(북서)의 방향값을 줄 수 있다.

pack .a -anchor nw

명령을 수행한 후, 윈도우의 크기를 늘여서 위짓이 어떻게 정렬되어 있는지 확인해보자.

-padx, -pady 옵션은 위짓과 다른 위짓(또는 윈도우 경계) 사이의 간격을 조정할 때 사용하고, -ipadx, -ipady 옵션은 위짓 내부의 텍스트나 이미지와 위짓 경계와의 내부 간격을 조정할 때 사용하는 옵션이다.

pack .a .b .c -padx 2m -pady 3m -ipadx 2m -ipady 2m

4장 이벤트(event)와 액션(action)

다음 표는 이벤트와 그 이벤트의 의미이다.

Button 마우스 버튼이 눌러졌음
ButtonPress 마우스 버튼이 눌러졌음
ButtonRelease 마우스 버튼이 눌러졌다가 다시 놓아졌음
Key 키가 눌러졌음
KeyPress 키가 눌러졌음
KeyRelease 키가 눌러졌다가 다시 놓아졌음
Enter 마우스 포인터가 윈도우 안으로 들어왔음
Leave 마우스 포인터가 윈도우 바깥으로 나갔음
Motion 마우스가 윈도우 내에서 움직임
Expose 윈도우가 다시 그려짐
Map 윈도우가 화면에 나타남
Unmap 윈도우가 화면에서 없어짐
FocusIn 키보드 포커스가 윈도우 안으로 들어왔음
FocusOut 키보드 포커스가 윈도우 바깥으로 나갔음
Gravity 윈도우의 gravity가 변화했음
Circulate 윈도우가 쌓여있는 순서가 변했음
Configure 윈도우의 위치가 크기가 변했음
Destroy 윈도우가 제거되었음
Property X 윈도우 속성이 윈도우에 쓰여졌음
Visibility 윈도우의 visibility가 변했음
Reparent 윈도우 매니저에 의해 윈도우의 parent가 바뀌었음
Colormap 윈도우의 컬러맵이 변화했음

일반적인 X 윈도우 시스템의 이벤트는 위와 같고, 마우스의 첫번째 버튼이 눌러진다든가 하는 특정한 이벤트는 ButtonPress-1과 같이 지정할 수 있다. 키보드의 경우, Control-1, Shift-M, Alt-V, Mod5와 같이 특정 키를 가리킬 수 있다.

이벤트에 액션을 연결시키는 명령은 bind 명령인데, 이벤트가 발생하는 범위를 지정할 수 있다.


bind all {puts "Entering %W at (%x, %y)"}

all 대신에 위짓의 이름을 쓰면 그 위짓 내부에서만 이벤트와 액션을 연결하는 바인딩(binding)이 효과를 가지게 된다. %W는 이벤트가 발생한 윈도우의 이름이고, %x, %y는 xy좌표이다. %K는 키 이벤트의 이름이고, %A는 키 이벤트를 ASCII코드로 바꾸어 주는 변수이다. 윈도우의 너비와 높이를 알려주는 것으로 %w, %h가 있고, 이벤트 종류를 알려주는 %T를 사용할 수도 있다.

5장 캔버스(canvas) 위짓

캔버스(canvas)는 그림을 그릴 수 있는 위짓이다. 캔버스에 그릴 수 있는 것으로는, line(선), oval(원과 타원), rectangle(사각형), polygon(다각형), arc(원호), text(텍스트), bitmap(비트맵), image(이미지), window 등이 있다.


canvas .can -width 300 -height 200
pack .can
.can create arc 130 40 50 120

이것은 (130, 40)부터 (50, 120)에 걸쳐 원호를 그리는 것이다. -fiil 옵션으로 내부를 특정 색으로 채울 수도 있다.


.can create bitmap 200 20 -bitmap error
.can create image 100 100 -image earth.gif

이 예제들은 비트맵과 일반 이미지를 그리는 것인데, -bitmap이나 -image 옵션의 값은 비트맵 파일의 이름이나 이미지의 이름이다. error의 경우에는 Tk에서 이미 정의된 비트맵인데, 이밖에도 gray25, gray50, hourglass, info, questhead, question, warning 등이 있다.

이미지를 다루기 위해서는 image를 먼저 만드는 과정이 필요하다. image라는 명령을 사용하는 것인데, 다음의 예를 참고하자.


image create photo x -file earth.gif
label .y -image x
pack .y

[그림 14]

x라는 이름으로 earth.gif를 이미지로 만들고, label에다가 얹는다. 버튼 위에도 얹을 수 있다. 그러면 이미지가 있는 버튼을 만들 수 있는 것이다. Tk에서는 GIF와 PPM/PGM 포맷의 image를 사용할 수 있다.


.can create line 100 120 150 130 -arrow first

다시 캔버스로 돌아가서 선을 그려보자. -arrow 옵션의 값으로는 last와 both를 줄 수도 있는데, 화살표모양을 어디에 달 것인가를 결정하는 것이다. 좌표를 더 많이 주고 -smooth 옵션의 값을 1로 주면 베지어 곡선(Bezier curve)을 그릴 수 있다.


.can create line 230 140 150 60 100 110 120 30 -smooth 1

원이나 타원의 경우 oval 오브젝트를 만들면 되는데, 원이나 타원이 들어갈 사각형의 두 대각점을 지정하면 된다. 사각형을 만드는 방법도 마찬가지이다. 다각형의 경우, 좌표를 나열하면 다각형을 그려준다.


.can create polygon 30 50 60 40 20 100 120 180 -fill orange

위짓을 넣기 위해 윈도우를 만들어 본다. 캔버스내에 윈도우의 위치가 정해지므로 위짓을 pack하지는 않는다.


button .can.b -text hello -relief raised
.can create window 100 100 -window .can.b

캔버스에 그림을 다 그렸으면 출력을 해야 하는 경우가 있다. postscipt 명령을 주면 캔버스의 그림을 PS형식의 파일로 저장한다.


.can postscript -colormode color -file result

그러나 필자가 실험해 본 결과로는, 안타깝게도 버튼 위짓이 출력되지 않았다.

[그림 14.5]

6장 텍스트 위짓(text widget)

텍스트 위짓은 텍스트를 보여주기 위한 위짓이다. 다른 위짓도 크기가 커지면 스크롤바를 달아주어야 하는데, 텍스트 위짓은 텍스트의 분량이 많을 것으로 예상을 하고 미리 스크롤바를 달아주는 것이 좋다.


text .t -yscrollcommand ".sc set"
entry .e -textvariable var
scrollbar .sc -command ".t yview"
pack .sc -side right -fill y
pack .t .e -side top -expand 1 -fill x

이제 텍스트 위짓에 파일의 내용을 올리도록 한다. 이것은 리스트박스에서 했던 것과 비슷한 작업이다. 리스트박스의 예제를 참고하도록 하자.


proc print filename {
set f [open $filename]
while {![eof $f]} {
.t insert end [read $f 100]
}
close $f
}
print /etc/passwd

텍스트와 관련하여 텍스트의 일부를 잘라 내어, 복사하고 붙이는 작업을 할 수 있는데, 이렇게 잘라 내거나 복사하려는 텍스트는 클립보드(clipboard)나 selection 메카니즘을 이용하게 된다. selection은 X 응용프로그램간의 텍스트의 저장을 Tcl에서 지원하게 위해 제공되는 것이고, 클립보드는 Tk에서 사용하는 데이터의 저장공간이다. selection은 화면 상에서 사용자에게 보이지만, 클립보드는 보이지 않는다. 위의 예제를 실행하고 마우스로 텍스트를 긁어서 엔트리 위짓에 복사해보자. 텍스트를 선택하게 되면 selection에 자료가 복사되는데, 다음과 같은 명령으로 확인할 수 있다.


selection get

클립보드에 자료를 복사하고 꺼내는 것은 다음과 같다.


clipboard append "Hello, world!"
selection get -selection CLIPBOARD

selection이나 클립보드에 저장된 자료를 제거하는 것은 get이나 append 대신 clear를 사용하면 가능하다.

텍스트를 입력할 때, 포커스를 지정하는 명령으로 focus가 제공된다. 다음은 frame1 위짓의 entry 위짓에 포커스를 맞추는 예이다.


focus .frame1.entry

포커스를 없애려면 focus none을 수행하면 된다.

7장 Dialog Window

대화 상자(dialog window)를 만드는 것은 tk_dialog를 이용하면 가능하다. 다음은 OK, Cancel, Quit라는 세 개의 버튼을 가지는 대화 상자를 생성한다. 디폴트(default)로 선택되는 버튼은 0번째 OK버튼이다. 비트맵으로 info를 사용하였다.


tk_dialog .dlg "Hello" "This is a hello." info 0 OK Cancel Quit

[그림 15]

사용자가 직접 대화 상자를 만들기 위해서는 toplevel 명령을 이용하여 toplevel 위짓을 구성하고, 그 위짓을 Diaglog 클래스로 선언하는 것이다. toplevel 위짓은 기본 윈도우 바깥에 윈도우를 하나 더 생성하는 역할을 한다.


toplevel .top -class Dialog
label .top.l -relief flat -text "My own dialog box"
button .top.b -relief ridge -text "OK" -command { puts "OK" }
pack .top.l .top.b -side left

[그림 16]

이벤트가 발생하면 그 이벤트는 그 윈도우와 그 하위에 있는 위짓에만 전달이 된다. 이것을 grab이라고 하는데, 이벤트를 특정 윈도우와 연결시키려면 다음과 같이 grab을 설정하면 된다.


grab set .dlg

반대로 이 설정을 풀려면 set 대신 release를 사용한다.

Tk에서는 변수의 값이 변하거나 윈도우의 visibility 상태가 바뀌거나, 또는 윈도우가 사라지게 될 때까지 기다리게 하는 tkwait 명령이 있다.


tkwait variable hello
tkwait visibility .top
tkwait window .dlg

다음은 OK 버튼과 Cancel 버튼을 가진 대화상자를 만들고, 대화상자에서 임의의 버튼을 선택할 때까지 기다리는 예제이다.


toplevel .top
grab set .top
button .top.ok -text OK -comman { destroy .top }
button .top.cancel -text Cancel -comman { destroy .top }
pack .top.ok .top.cancel -side left
tkwait window .top

8장 기타

위짓에 속한 기본 리소스가 아닌 윈도우 매니저가 관리하는 리소스는 wm 명령을 이용하면 된다. 예를 들어, 윈도우의 타이틀을 바꾸려면 위짓의 리소스를 바꾸어 해결할 수 있는 것이 아니라, 윈도우 매니저에게 요청해야 한다.


wm title .top "My dialog window"

이 윈도우를 아이콘으로 만들어(minimize) 보자.


wm iconify .top

윈도우 매니저가 관리하는 리소스를 바꾸거나 그 정보를 알아내기 위해서 wm 명령을 사용할 수 있다. 자세한 내용은 man -s n wm을 참조해야 할 것이다.

tkwait의 예제에서 나왔던 destroy 명령은 윈도우를 제거하는 명령이다. 윈도우를 제거하면서 관련된 명령이나 윈도우 상태 또한 제거한다. 윈도우가 쌓여있는 순서를 바꾸기 위해 raise나 lower 명령을 사용할 수 있다.

예제파일

MP3 연주기로 유명한 Mpg123은 텍스트 기반의 프로그램이다. 이것의 대표적인 X 인터페이스는 Tk3play이다. 다시 말하면, Mpg123은 텍스트 상에서 MP3 파일을 디코딩(decoding)해서 연주해주는 작업을 하고, Tk3play는 버튼이나 레이블을 이용하여 Mpg123을 제어하는 역할을 맡는다. 아래 예제는 필자가 Tk3play를 몰랐을 때, Mpg123을 X 윈도우 상에서 제어하는 인터페이스가 필요해서 작성했던 Tcl/Tk 스크립트이다. Tk3play의 아주 기본적인 몇 가지 기능을 가지고 있는 인터페이스라고 할 수 있다.


#!/usr/local/bin/wish -f
#
# mpgplay
#
# 이 스크립트는 wish를 이용한 mpg플레이어입니다.
# This script run mpeg3 audio player with wish.

# 아규먼트로부터 디렉토리명을 제외한 MP3 파일의 이름을 구한다.
set filenamelist [split [lindex $argv 0] /]
set listend [expr [llength $filenamelist] - 1]
set filename [lindex $filenamelist $listend]

# 타이틀과 MP3 파일명을 위한 레이블을 만들고
# Play, Stop, Quit을 위한 버튼을 만든다.
label .title -text ” Terzeron’s mp3 player ” -foreground black
-background pink -relief ridge
label .name -text $filename -foreground black -background white
-relief ridge
frame .ground -relief sunken
button .ground.play -text Play -foreground blue -background white
button .ground.quit -text Quit -foreground red -background white
button .ground.stop -text Stop -foreground purple -background white

# 레이블과 버튼 등의 위짓을 배치한다.
pack .title -side top -fill x
pack .name -fill x
pack .ground -fill x
pack .ground.play .ground.stop .ground.quit -side left -expand 1 -fill both

# 버튼과 키입력에 대한 이벤트 처리 프러시져를 연결시킨다.
bind .ground.play { playproc }
bind .ground.quit { quitproc }
bind .ground.stop { stopproc }
bind . { playproc }
bind . { stopproc }
bind . { quitproc }

# 기본값 지정
set pidno 0
set playing 0
set stopping 0

# Play 프러시져
proc playproc {} {
global argv pidno playing stopping fileloc
# 이미 연주 중이면 무시
if {$playing == 1} {
return
}
# 버튼의 색상과 relief형태를 바꾼다.
.ground.play configure -background yellow -foreground black -relief sunken
.ground.stop configure -background white -foreground purple -relief raised
if {$stopping == 1} {
# 연주 중에 멈춘 경우에는 CONT 시그널을 보내 mpg123을 동작시킨다.
exec /usr/bin/kill -CONT $pidno
} else {
# 아직 시작하지 않은 경우에는 mpg123을 fork/exec하여 실행시킨다.
set pidno [exec /usr/local/bin/mpg123 [lindex $argv 0] >& /dev/null &]
}
set playing 1
set stopping 0
}

# Stop 프러시져
proc stopproc {} {
global argv pidno stopping playing
# 연주가 멈춰 있는 경우나 아직 시작하지 않은 경우에는 무시한다.
if {$stopping == 1 || $playing == 0} {
return
}
# 버튼의 색상과 relief 형태를 바꾼다.
.ground.stop configure -background yellow -foreground black -relief sunken
.ground.play configure -background white -foreground blue -relief raised
set stopping 1
set playing 0
# STOP 시그널을 mpg123으로 보내 잠시 프로세스를 멈추게 한다.
exec /usr/bin/kill -STOP $pidno
}

# Quit 프러시져
proc quitproc {} {
global argv pidno playing stopping
# KILL 시그널을 보내 프로세스를 종료시킨다.
if [catch {if {$pidno != 0} { exec /usr/bin/kill -9 $pidno } } ] {
;
}
# top-level 위짓을 없앤다.
destroy .
# 끝낸다.
exit 0
}
# 스크립트가 실행하면 바로 Play 프러시져를 호출하여 연주를 시작한다.
playproc
[그림 17]

이 인터페이스는 Tk3play에 비하면 기능도 부족하고 깔끔한 위짓 배치를 보여주지도 못한다. 그러나 독자들이 ‘왜 Tcl/Tk 인가?’하는 의문에 답할 수 있는 한 가지 사례를 보여주는 예제 프로그램이 될 수 있을 것이다. 이 기사를 읽은 독자라면 누구나 Tcl/Tk를 이용하여 손쉽게 X 윈도우 프로그래밍을 할 수 있을 것이다.

Mpg123과 Tk3play는 다음 URL에서 구할 수 있다.
http://www-ti.informatik.uni-tuebingen.de/~hippm/mpg123.html
http://www.msc.cornell.edu/~bef2

서울대학교 전산과학과 데이타베이스 연구실 조영일

:

Tcl/Tk 문법 - 1부 Tcl

Development 2008. 6. 9. 09:34
출처 : http://terzeron.net/wp/?p=244

1999/05/15

소개

Tcl은 Tool Command Language, Tk는 Toolkit의 약자로서, 응용프로그램을 제어하고 확장하는 것을 돕는 프로그래밍 언어의 하나이다. Tcl의 장점은 Tk와 함께 사용되어 응용프로그램이 X Window를 손쉽게 다룰 수 있도록 할 수 있다는 데 있다. 물론 요즘에는 Scheme, Lisp, Perl 등의 강력한 경쟁자들이 생기긴 했지만, 아직도 많은 개발자들이 Tcl/Tk에 대해서 친근감을 느끼고 있다. 게다가 Tcl/Tk는 C source로 embed하여 사용할 수 있다는 강점을 가지고 있다.

Tcl/Tk는 [티클티케이]라고 읽는다고 개발자 J. K. Ousterhout가 말했다. Ousterhout는 Tcl의 장점으로 쉽고, 강력한 스크립트 언어이며, 다른 패키지와 연결하기 쉽다는 점을 밝히고 있다.

필자가 꼽는 여타의 프로그래밍 언어에 대한 Tcl/Tk의 장점은 쉽다는 것이다. 아주 간단하게 문법을 익힐 수 있어서 초보자들이 X Window 상에서 실행되는 프로그램을 개발하는 것이 아주 용이하다. 그러나 이 장점은 역으로 단점이 되기도 한다. Tcl자체만으로는 시스템 프로그래밍에 적합하지 않다는 점이 다. 그러나 프로그래밍 언어는 모든 기능을 가질 수도 없고, 가진다고 해도 널리 쓰일 수 있는 것은 아니기 때문에 Tcl 나름대로 유용하게 쓰일 곳이 있기 마련이다. 그러나 미리부터 실망할 필요는 없다. Tcl을 C로 embed시켜 서 시스템 프로그램을 개발한다면 Tcl의 장점을 살리면서 C의 강력한 기능 을 지원받을 수 있으니 말이다. 자, 이제부터 티클티케이가 당신의 개발도 구가 될 수 있도록 가볍게 시작하도록 하자.

설치 및 실행

Tcl/Tk에 관련된 거의 모든 패키지는 http://www.scriptics.com/software에서 구할 수 있다. 윈도우즈(Windows)와 매킨토시(Macintosh)환경에서 사용할 수 있는 바이너리 배포본도 제공된다. 필자가 권하는 버전은 Tcl/Tk 8.1b3이다.

ftp://ftp.scriptics.com/pub/tcl/tcl8_1/tcl8.1b3.tar.gz

ftp://ftp.scriptics.com/pub/tcl/tcl8_1/tk8.1b3.tar.gz

에서 소스를 직접 내려받을 수도 있다. 컴파일 과정은 아주 간단하다. Tcl과 Tk 소스를 모두 풀어놓고 tcl8.1b3/unix 디렉토리에서 configure;make를 실행하면 컴파일까지 끝난다. 수퍼유저 권한으로 make install을 실행하면 /usr/local/bin에서 tclsh과 wish를 실행할 수 있을 것이다. 이전 Tcl/Tk버전을 이미 설치했다면 tclsh과 wish가 최신 버전(tclsh8.1과 wish8.1)을 링크하고 있지 않을 테니, 확인을 해야 하는 번거로움이 있다. tclsh은 Tcl 명령만을 실행할 수 있는 인터프리터이고, wish은 Tcl/Tk 명령을 실행할 수 있는 인터프리터인데, 1부에서는 tclsh로도 충분히 모든 기능을 확인할 수 있을 것이다.

이 문서는 총 3부로 구성되어 있고, 1부는 Tcl의 문법, 2부는 Tk의 문법, 3부는 Tcl/Tk의 확장에 대해 다루고 있다.

1부 Tcl의 문법


1장 기초 문법과 변수, 표현식, 리스트

1) 기초 문법과 변수

가장 처음에 하고 싶은 일은 출력일 것이다. 모든 프로그래밍 언어가 그렇 게 하듯이 세상에 인사부터 하는 게 예의일 것이다.

puts "Hello, World!"

인사를 여러 번 할 수 있도록 문자열을 변수에 저장하려면,

set hello "Hello, World!"
put $hello

csh과 비슷하게 대입에는 set, 변수의 값 사용(dereference)에는 $를 사용한다. 더욱 재미있는 점은 csh처럼 unset 명령도 제공된다는 점이다.

다소 불편한 점이라면, 표현식을 처리하기 위해서는 반드시 expr을 사용해 야 한다는 것이다. 이것은 Tcl이 변수 타입에 대해서 느슨한 태도를 취하기 때문인데, 만약 다음과 같은 명령을 준다면,

set j 3; set i $j+1

i에는 문자열로 $j+1이 대입될 것이다. 이것은 원하는 바가 아닐 수도 있다. $j+1을 표현식(expression)으로 간주하고 처리하기 위해서는 앞에서 밝힌 바와 같이 expr을 써서,

set i [expr $j+1]

이라고 명령을 주어야만 한다. 다소 번거로운 일이긴 하지만, 거꾸로 생각해보면 표현의 자유로움과 다양성을 보장받을 수 있다는 것을 의미하기도 한다. 이 예에서 우리는 ;이 Tcl문장의 구분자(separator)로 사용된다는 것과 [ ]이 하나의 Tcl명령의 결과값을 반환한다는 것을 덤으로 알게 되었다.

Tcl에서 사용되는 특수문자는 일반적으로 셸(shell)에서 사용하는 a, ,
, f 등을 그대로 사용할 수 있다. 예를 들어, 다음의 실행 결과는 My name is “Young-il Cho”. 가 될 것이다.

set firstname "Young-il"
set lastname "Cho"
set myname "My name is "$firstname $lastname".
";
puts $myname

사실 Tcl의 각 문장 하나하나는 결과값을 매번 반환하기 때문에 마지막 줄의 puts는 필요하지 않을 수도 있다. 그렇기 때문에, puts $myname 대신에 set myname 이라고 입력해도 같은 결과를 얻을 수 있다.

변수는 타입이 정해지지 않았기 때문에, 다른 타입의 값을 대입할 수 있을 뿐만 아니라, 일반 변수를 배열처럼 사용할 수도 있다. 다음은 1월과 2월의 이름을 배열에 넣은 후에, 1월의 이름을 구하는 예제이다. 배열이라고는 하지만, 마치 Perl의 해시(hash) 변수처럼 사용할 수 있다.

set month(1) Jan
set month(2) Feb
set month(1)

다차원 배열도 가능하다. 다음은 4월 3일부터 5일까지의 요일을 다차원 배열에 대입해보는 아주 간단한 예이다.

set mday(4,3) Sat
set mday(4,4) Sun
set mday(4,5) Mon
puts $mday(4,4)

정수값을 가지고 있는 변수는 incr 명령을 이용하여 증감시킬 수 있는데, 그 변수에 들어 있는 값이 정수값으로 읽을 수 있는 값이기만 하면 된다.

set x "1"
incr x

여기에 다시 100배를 해서 3을 빼는 작업을 해보도록 하자. 100를 하기 위해서는 앞에서와 같이 expr $x*100도 가능하겠지만, append 명령을 이용하여 정수값 뒤에 0을 두 개 붙이는 것도 가능하다. 다음 예의 결과값은 2 * 100 - 3이니까 197이 될 것이다.

append x "00"
incr x -3
puts $x

강력하게 타입을 검사하는 컴파일 프로그래밍 언어에서는 상상도 하지 못할 엉뚱한 작업들을 태연하게 해내고 있다. 그만큼 융통성이 있다고 볼 수도 있고 거꾸로 사용자들이 에러를 내기 쉬울 수도 있다고 볼 수도 있다.

변수 중에서 예약되어 있는 변수 두 가지가 있다. 하나는 프로그램으로 넘겨지는 argument 리스트에 관련된 argv0, argv, argc이고, 또 하나는 환경 변수를 해시 형태로 가지고 있는 env이다. argv0은 Tcl로 작성된 스크립트의 이름을 가리키고, argv는 argument 리스트 전체를 배열로 가지고, argc는 argument의 개수를 정수값으로 가지고 있다. 다음의 예를 실행파일로 만들어서 argument로 hello 1 2 world를 사용해 실행해보면 이해가 어렵지 않을 것이다.

#!/usr/local/bin/tclsh
puts "This program $argv0 has $argc arguments : $argv
";
puts "User's ID is $env(USER).
";

2) 표현식

표현식은 수식이나 문자열 처리에 관한 것이다. Tcl에서 제공하는 연산자는 ANSI C의 것과 거의 동일하다. 사칙 연산자(+, -, *, -)와 나머지 연산자(%)를 비롯하여 관계연산자(>, <, >=. <=, !=, ==)와 논리연산자(!, &&, ||), 비트 연산자(!, ^, |, ~, <<, >>), 3항 연산자(? :)가 모두 지원된다.

또한 C에서 제공하는 수학 함수(삼각함수, 지수함수 등)를 거의 모두 사용할 수 있다. 기본적으로 프로그래밍의 경험이 조금이라도 있는 독자라면 여기에서 자세히 다루지는 않고 이름만 나열해도 쉽게 man page를 이용해 쉽게 찾아볼 수 있을 것으로 간주하고 넘어가도록 하겠다.

abs, acos, asin, atan, ceil, cos, cosh, double, exp, floor, fmod, hypot, int, log, log10, pow, round, sin, sinh, sqrt, tan, tanh

tclsh 실행 도중, man abs를 입력해보면 C의 man page를 볼 수 있다. abs, labs, llabs가 C에서 제공되고 있지만, Tcl에서는 long 타입의 변수를 사용할 수 없는 관계로 labs와 llabs는 사용할 수 없다. 물론 Tcl을 C에서 라이브러리로 사용할 때는 long이 사용되어야 하므로 long를 다루는 방법이 제공된다.

리스트나 문자열 형태의 값을 Tcl 명령으로 인식하여 작업을 수행하는 eval이라는 명령이 있다. eval {puts $tcl_version}라고 입력하면 puts $tcl_version이 실행될 것이다. 그러면 굳이 eval 명령을 사용해야 하는 이유에 대한 의구심이 생길 지도 모르겠다. 다음의 예를 보자.

set y 3
foreach f {set unset} {
eval $f y
}
puts $y

우선 변수 y에 3을 대입하고 foreach 문에서 변수 f에 set과 unset을 대입하면서 매번 eval $f y를 실행하기 때문에, set y와 unset y가 실행된다. unset에 의해 y를 변수로 사용할 수 없게 되었으므로 마지막 명령 puts는 에러를 발생하게 되어 있다.

문자열 처리에 관련된 명령들은 뒤에서 다시 설명하도록 하겠다.

3) 리스트

리스트는 [, ] 또는 “, “을 이용해 만들 수 있다.

set x {hello world}
set x "hello world"

는 같은 결과를 가지게 된다. 주의할 것은 set x [hello world]는 hello를 명령으로 인식하여 에러를 발생하게 된다는 점이다.

리스트를 다루기 위해 제공되는 명령에는 리스트를 붙이거나 인덱스를 이용해 특정 아이템을 꺼내거나 쪼개거나 검색하는 명령이 있다. 리스트 안에 리스트를 둘 수도 있는데, 이런 경우 lindex를 두번 사용하여 특정 단어를 지정할 수 있다. 다음은 New York의 York를 가리키는 예를 든 것이다.

set x {California Chicago {New York} Washington Texas}
lindex [lindex $x 2] 1

리스트를 붙이는 방법에는 리스트에 존재하는 모든 아이템을 모아서 붙이는 concat 명령과 각 리스트를 하나의 아이템으로 유지시키면서 붙이는 list 명령이 있는데, 다음을 비교해보자.

set y {Miami {Salt-lake City} Alaska}
concat $x $y
list $x $y

이 명령들의 결과는 다음과 같다.

California Chicago {New York} Washington Texas Miami {Salt-lake City} Alaska
{California Chicago {New York} Washington Texas} {Miami {Salt-lake City} Alaska}

이제는 리스트를 쪼개고 합치고 하는 작업을 하기로 한다. passwd파일의 일부을 변수 a에 담고, :을 구분자(delimiter)로 삼아서 : 단위로 쪼개어 변수 b에 리스트로 저장한다. 그리고는 다시 하나의 아이템으로 합쳐보도록 하자. 결과적으로 변수 c는 a와 같은 내용을 포함하게 될 것이다.

set a root:x:0:1:Super-User:/:/sbin/sh
set b [split $a :]
set c [join $b :]

이번에는 리스트의 일부를 찾거나 치환하거나 리스트를 정렬하는 명령에 대해 알아보도록 하자. 아이템을 찾는 것은 lsearch, 치환하는 것은 lreplce와 lrange, 정렬하는 것은 lsort이다. 리스트가 포함하고 있는 아이템 수를 반환하는 llength명령은 지나가도록 한다. 치환하거나 아이템을 찾을 때 사용되는 인덱스(index 또는 offset)를 설명하는 일은 필자에게나 독자에게나 모두 머리 아픈 일이 될 테니, 간단한 예로 대신하고자 한다. 다음은 리스트 변수 x의 2번째 위치부터 1 빼기 1개(0개)의 아이템과 Detroit라는 새로운 아이템을 바꿔치기 하고 2번째 위치부터 3 빼기 1개(2개)의 아이템을 Detroit와 Boston으로 치환하는 것이다. 그 다음은 3번째 공백 위치에 Illinois과 Detroit를 끼워넣는 것이다. 리스트의 일부를 꺼내는 lrange 명령도 있으나 여기서는 설명을 생략하기로 한다.

lreplace $x 2 1 Detroit
lreplace $x 2 3 Detroit Boston
linsert $x 3 Illinois Detroit
lrange $x 1 2

주의할 것은 이런 명령이 리스트 변수 x에 대해서 물리적인 효과를 가지지는 못한다는 점이다. x에 물리적인 효과를 주려면 set x [linsert $x 3 Illinois Detroit] 처럼 새로 대입해야만 한다. 위 명령의 결과는 다음과 같다.

California Chicago Detroit {New York} Washington Texas
California Chicago Detroit Boston Texas
California Chicago {New York} Illinois Detroit Washington Texas
Chicago {New York}

lappend는 위의 명령들과는 조금 다르다. 변수를 새로운 아이템을 추가한 결과로 바꾸어주기 때문이다.

lappend x Hawaii

이제 정렬해보자. lsort에 사용되는 옵션(option)에는 -ascii, -dictionary, -integer, -real, -command, -increasing, -decreasing, -index가 있는데, 디폴트(default) 값은 -ascii와 -increasing이다. Tcl/Tk가 버전 8로 올라오면서 -dictionary와 -index가 추가되었는데, -dictionary 옵션은 -ascii 옵션과 거의 동일하지만, 대소문자가 구분되지만 비교시에는 무시되고 단어 중간에 나오는 숫자는 문자로서 취급되지 않고 수치로 취급된다는 점이다. man page에 나오는 예를 적어보았다.

lsort -dictionary {bigBoy bigbang bigboy}
lsort -dictionary {x10y x9y x11y}

직접 실행하여 결과를 얻어보길 바란다. -index 옵션은 다음과 같은 예를 보면 쉽게 이해할 수 있을 것이다. 각각의 아이템은 리스트 형태로 되어있고, 그 리스트 중에서 index가 가리키는 아이템이 정렬할 때 키(key)로 사용되는 것이다.

lsort -integer -index 1 {{First 24} {Second 18} {Third 30}}

2장 제어문, 프러시져

1) 제어문

제어문에는 if, while, for, foreach, switch, continue, break 등이 있다. source나 이전에 다루었던 eval등이 제어문에 속하기는 하지만, source의 설명은 다음 장으로 미룬다. 제어문은 대체로 프로그래밍의 기본에 속하기 때문에 프로그래밍의 경험이 조금이라도 있는 독자라면 쉽게 이해할 것으로 간주하여 간단히 설명하고 다음으로 넘어가도록 하겠다. 다만 Tcl에서 주의해야 할 점은 C와는 달리 비교문은 {과 }로 둘러싸야 한다는 것이다.

if {$x < $y} {
puts "X is less than Y."
} else if {$x == $y} {
puts "X is equal to Y."
} else {
puts "X is greater than Y."
}

위의 예제는 x와 y의 수치값을 비교하여 각각의 경우에 대해 설명을 출력한다. 다음의 while, for, foreach는 모두 동일한 작업을 수행한다.

set i 0
while {$i > 5} {
puts $i
if {$i == 3} {
break;
}
incr i
}

for {set i 0} {$i > 5} {incr i} {
puts $i
if {$i == 3} {
break;
}
}

foreach i {0 1 2 3 4 5} {
puts $i
if {$i == 3} {
break;
}
}

2) 프러시져(procedure)

프러시져는 C의 함수(function)과 같다고 할 수 있다. 문법은 “proc 프러시져이름 매개변수리스트 프러시져본체”로 되어 있다. 명시적으로 return을 이용해서 결과를 반환할 수도 있고, return을 사용하지 않으면 마지막으로 사용된 표현식의 결과가 return값으로 사용된다.

proc average parameters {
set sum 0.0
foreach i $parameters {
set sum [expr $sum + $i]
}
return [expr $sum / [llength $parameters]]
}

프러시져의 호출은 다음과 같은 방법으로 할 수 있다.

average {1 2 3 4 5 6 7 8 9 10}

{1 2 3 4 5 6 7 8 9 10}이 parameters라는 리스트형 변수로 넘겨지는 것이다. 그러나 매개변수로 하나만 사용될 수 있는 것은 아니고 여러 개를 지정하거나 기본값을 지정할 수도 있다.

proc sum {x {y 3}} {
return [expr $x + $y]
}

변수 y의 기본값으로 3이 정해져 있기 때문에, sum 3 4 과 같은 형태의 호출이 가능하고, sum 3 과 같은 형태로도 호출을 할 수 있다. 그 결과값은 7과 6일 것이다.

프러시져 내부로 제어가 넘어가면 기본적으로는 외부나 상위의 프러시져의 변수를 사용할 수 없다. 매번 매개변수를 사용할 수는 없는 노릇이므로 global이나 upvar를 사용하여 상위레벨의 변수에 접근하게 된다. global 명령은 외부의 변수들을 전역변수로 지정하여 현재 프러시져 내부에서 지역변수처럼 자연스럽게 사용할 수 있게 한다. upvar는 Tcl의 “참조에 의한 호출”(call-by-reference)을 지원하기 위해 사용되는 명령으로 상위레벨의 프러시져에서 사용되는 변수를 새로운 이름으로 지정해서 지역변수로 사용하게 한다.

set virtual_average 30.0
proc average parameters {
global virtual_average
set sum 0.0
foreach i $parameters {
set sum [expr $sum + $i]
}
return [expr $virtual_average + $sum / [llength $parameters]]
}

이 예제는 평균을 구하는 위의 예제에 가평균을 사용하기 위해 약간 손을 본 프러시져이다. virtual_average가 외부에서 정의되었기 때문에 proc average에서는 사용할 수가 없어서 global 명령으로 virtual_average를 지역변수화한 것이다. 다음의 예는 upvar를 이용해서 새로운 지역변수로 상위레벨의 변수를 참조하는 것을 보이는 것이다.

proc print array_name {
upvar $array_name arr
foreach item [lsort $arr] {
puts $item
}
}

upvar는 정수값을 이용해서 참조의 대상이 되는 변수를 어느 레벨까지 찾을 것인가를 결정할 수 있는데, #0을 쓰면 무한대로 거슬러 상위로 올라갈 수 있다. 다음의 예는 바로 상위의 프러시져의 array_name이나 혹은 그 프러시져를 호출한 더 상위의 프러시져에서 사용된 array_name을 arr로 참조하겠다는 의미이다. 그 다음 줄은 상위로 계속 거슬러 올라가면서 array_name이라는 변수를 찾겠다는 의미이다.

upvar 2 $array_name arr
upvar #0 $array_name arr

upvar와 비슷한 명령으로 uplevel이라는 명령이 제공되는데, 이 명령은 eval과 upvar를 합쳐놓은 기능을 한다. 다음 명령이 프러시져 내부에서 사용되면 이 프러시져를 호출한 프러시져의 x변수를 참조해다가 43을 대입하는 것을 실행할 수 있게 한다.

uplevel 1 {set x 43}

3장 문자열 처리

Tcl은 8 bit-clean하다고 한다. 이 말은 ASCII 코드 이외의 문자를 다룰 수 있다는 의미이다. 그래서 tclsh에서 한글 출력이 가능하다. wish의 경우에는 조금 사정이 달라서, 폰트만 지정하면 출력은 문제가 없지만,아직까지도 입력서버에 관련된 사항이 미비하여 한글입력은 가능하지 않다.

문자열을 다루는 것은 리스트를 다루는 것과 비슷한 형태의 명령을 통해 가능하다. 다만 문자열 처리 명령은 string과 기타 명령을 조합한 형태이다. 예를 들면, string length는 문자열의 길이를 출력하는 명령이고, string index는 문자열의 몇번째 글자를 지정하는 명령이다.

1) 정규 표현식(regular expression)

문자열에 대해서 설명하기 전에 정규 표현식(regular expression)에 대해서 설명을 해야겠다. 다소 안타깝지만, Tcl의 정규 표현식은 Perl의 정규 표현식이 만들어내는 것보다는 표현력이 약하다. 그러나 사용하는데 별 무리는 없을 것이다.

[HTML]

. 임의의 한 글자
* 바로 앞 글자 또는 부분문자열(substring)이 0번 이상 반복될 수 있음
+ 바로 앞 글자 또는 부분문자열이 1번 이상 반복될 수 있음
? 바로 앞 글자 또는 부분문자열이 존재하거나 존재하지 않을 수 있음
^ 문자열의 처음 위치(첫 글자 자체를 가리키는 것은 아님)
$ 문자열의 마지막 위치(마지막 글자 아님)
특수문자 특수문자를 정규 표현식에서의 의미가 아닌 문자 그 자체로 인식
(정규표현식) ()를 이용하여 표현식과 일치하는 문자열을 부분문자열로 간주
[문자열] []안의 문자들 중 임의의 한 글자(예제 참고)
정규표현식|정규표현식 두 정규 표현식 중 임의의 한 표현식

[/HTML]

정규 표현식은 regexp나 regsub와 같은 명령을 통해 사용될 수도 있고, 문자열 관련 명령 중의 패턴(pattern)으로도 사용될 수 있다. 다음에 나올 예는 regexp와 regsub가 각각 패턴의 일치성을 검사하거나 패턴을 치환하는 것이다.

regexp {[^a-z].*} The
regexp {[^a-z].*} them
regexp {[^a-z].*} 123

[] 내부 문자열의 첫 글자로 ^가 나오면 [] 내부의 문자열을 제외한 모든 글자가 해당되도록 되어 있으므로 위의 예는 a부터 z까지의 소문자를 제외한 글자로 시작하는 단어만 참(true)로 간주하여 패턴 매치(pattern match)의 결과를 1이나 0으로 돌려주게 된다. 그러므로 두번째 단어를 제외한 두 단어는 패턴 매치가 성공하여 1이 반환된다. 매치된 부분문자열을 변수에 저장할 수도 있는데, 다음의 예를 보자.

regexp {([A-Z][a-z]+) ([a-z]+)} “King died!” line noun verb

line에는 King died가 noun에는 King이 verb에는 died가 저장될 것이다. 옵션으로 -nocase와 -indices가 지원되는데, -nocase는 대소문자를 구별하지 않으면서 패턴 매치를 수행하는 옵션이고, -indices는 일치하는 부분문자열을 변수에 저장하는 대신, 부분문자열의 첫 글자와 마지막 글자의 인덱스(index 또는 offset)을 대입해준다. 이 옵션을 써서 위의 예를 실행하게 되면 line은 0 8, noun은 0 3, verb는 5 8을 가지게 될 것이다.

다음은 regsub를 이용해 잘못된 스펠링 teh를 the로 바꾸고 그 바뀐 문자열을 x에 저장하는 것이다.

regsub teh "I can't believe teh truth of teh events" the x
puts $x

결과는 예상과는 달리 앞 쪽의 teh만 제대로 고쳐질 것이다. 매번 teh가 나올 때마다 고치려면 -all옵션을 사용하면 된다.

2) format

C의 printf는 너무 유명해서 모르는 프로그래머가 없을만한 함수이다. printf의 최대 장점은 문자열의 format을 정해서 프로그래머가 원하는 형태로 출력이 가능하다는 점인데, Tcl에서는 이런 format을 위해 format명령과 scan명령을 제공한다. format 명령은 printf와 비슷하고 scan은 sscanf과 비슷하다.

puts [format "My name is %s and my age is %d." Terzeron 25]

이런 식으로 해서 ANSI C의 printf가 제공하는 모든 기능을 Tcl에서도 사용 가능하다. 거꾸로 sscanf도 가능한데, 다음의 예를 보자. scan의 결과는 매치되는 부분문자열의 개수이고, 마지막에 사용된 변수에 매치되는 부분문자열이 저장된다.

scan "My name is Terzeron and my age is 25." "My name is %s" name

3) 문자열 명령

드디어 문자열 자체를 다루는 명령들에 대해서 알아볼 시간이다. 앞에서도 언급했다시피 string 명령은 compare(비교), first(첫번째 매치되는 글자의 index), last(마지막으로 매치되는 글자의 index), index(그 인덱스에 위치한 글자), length(문자열의 길이), match(패턴 매치 결과), range(부분문자열), tolower(소문자로 바꾸기), toupper(대문자로 바꾸기), trim, trimleft, trimright(해당 글자를 문자열 앞쪽이나 뒤쪽에서 잘라내기), 등의 명령과 조합하여 사용해야 한다.

string length "hello"
string range "hello" 3 4
string compare "hello" "hello"
string compare "hello" "Hello"
string index "hello" 4
string first l "hello"
string last l "hello"
string match {[a-z]*} “hello”
string trim aaabbcccaaa a
string trimleft aaabbcccaaa a
string trimright aaabbcccaaa a
string toupper hello
string tolower Hello

위 예제를 한 줄씩 설명하기는 시간 낭비일테니까, 각자 직접 실행하여 결과를 얻어보길 바란다. 필자는 string match가 잘 되지 않아서 다소 애를 먹은 경험이 있다. 위 예제의 결과를 차례대로 나열해보면 이렇다. 5, lo, 0, 1, o, 2, 3, 1, bbccc, bbcccaaa, aaabbccc, HELLO, hello

4장 파일 입출력, 프로세스 관리

이전까지는 어떤 시스템에서도 잘 실행되었을 법한 명령들에 대해서 다루어 보았다. 그러나 파일 입출력이나 프로세스 문제는 시스템마다 다르기 때문에, 지금부터는 POSIX를 따르는 유닉스 시스템에서 실행되는 명령들에 대해서 다루어보기로 한다.

1) 시스템

디렉토리를 지정하는 방법으로 C shell에서 사용하는 ~도 사용가능하다. ~terzeron이라라고 하면 terzeron이라는 사용자의 홈 디렉토리를 의미하고, ~라고 하면 지금 프로그램의 실행자의 홈 디렉토리를 의미한다. cd 명령으로 디렉토리를 옮길 수 있고, pwd 명령은 현재 디렉토리를 확인할 수 있고, file 명령으로 디렉토리에 관한 문자열 작업을 가능하게 돕는다. file 명령의 옵션으로는 다음과 같은 것들이 있다.

[HTML]

dirname 파일이 위치한 디렉토리 이름
rootname 파일의 확장자를 제외한 이름 전체
extension 파일의 확장자
tail 파일의 전체 경로에서 디렉토리 부분을 제외한 나머지
atime 최종 접근 시간(access time)
mtime 최종 변경 시간(modified time)
size 파일의 바이트(byte) 크기
type 파일의 타입
readlink 심볼릭 링크(symbolic link)가 가리키는 파일의 이름
exists 파일이 존재하는가?
isdirectory 파일이 디렉토리인가?
isfile 파일이 일반 파일인가?
executable 실행가능한가?
readable 읽기가능한가?
writable 쓰기가능한가?
owned 프로그램 실행자의 소유인가?
stat 파일의 atime, ctime, dev, gid, ino, mode, mtime, nlink, size, uid를 키로 가지는 배열을 반환한다.
lstat stat과 같으나 심볼릭 링크에 대해서는 링크 자체의 정보를 반환한다.

[/HTML]

glob을 이용하면 shell에서처럼 *이나 ?같은 메타 문자를 사용할 수 있다. glob 명령으로 파일이름 리스트를 구하고 file 명령으로 각 파일에 대한 정보를 구하거나 테스트를 수행할 수 있다. 다음의 예는 /usr/include/sys 디렉토리에 존재하는 헤더 파일 중 x로 시작하는 이름을 가진 파일들을 읽어서 그 크기를 조사해보는 것이다.

set filelist [glob /usr/include/sys/x*.h]
foreach f $filelist {
puts “$f [file size $f]”
}

2) 파일 입출력
파일을 열고 닫고 읽고 쓰기 위해 제공되는 명령에는 open, close, gets, puts, read, flush, eof, seek, tell 등이 있다. 파일을 다루기 위해서 가장 먼저 해야 할 일은 파일을 여는(open) 작업이다. 파일을 열면 파일id가 반환되고 이 id를 가지고 읽고 쓰는 작업을 수행할 수 있는 것이다.

set filename /usr/include/limits.h
set f [open $filename r]
while {[gets $f line] >= 0} {
puts $line
}
close $f

이 예제는 가장 간단한 파일 입출력을 보여주고 있다. 한 줄씩 읽어서 line이라는 변수에 저장하고 다시 출력하고 있다. 이제 다른 파일로 복사하는 프로그램으로 고쳐보자. 위의 예에서는 읽기 위해서 모드(mode)를 r(읽기)로 주었는데, 이번에는 강제적으로 덮어쓰기 위해 w+(강제로 덮어쓰기) 모드로 열어야 한다.

set infile /usr/include/limits.h
set outfile ~/limits.h
set inf [open $infile r]
set outf [open $outfile w+]
while {[gets $inf line] >= 0} {
puts $outf $line
}
close $outf
close $inf

flush는 puts가 버퍼링을 하기 때문에 출력이 지연되는 것을 강제적으로 막기 위해 사용되는 명령이다. 위의 예제에서 puts 명령 다음에 flush $outf라고 써주게 되면 puts가 버퍼를 다 채우지 못했더라도 강제적으로 버퍼를 비워서 출력이 빠르게 진행된다. 이 밖에 파일의 변위(offset)를 가지고 특정 위치로 이동할 수 있도록 seek 명령이 제공되고, 현재 파일의 어느 부분을 읽거나 쓰고 있는지 알기 위해 tell 명령이 제공된다. 이렇게 랜덤한 접근을 하는 경우 파일의 끝을 알아야 하는데, 이럴 때 eof 명령으로 파일의 끝인가를 검사할 수 있다.
3) 프로세스 관리

프로세스는 파일 상태로 디스크에 존재하는 프로그램이 실행되어서 메모리에 올라와 있는 것을 말한다. 프로세스를 만들기 위해서는 exc 명령으로 프로그램을 실행시켜야 한다. 다음 예는 stdio.h에서 printf라는 단어를 포함하고 그 중에서 FILE이라는 단어를 포함하지 않는 줄을 찾아주는 프로세스를 만드는 것이다.

exec grep printf /usr/include/stdio.h | grep -v FILE

현재 실행되고 있는 Tcl 스크립트의 출력을 다른 프로그램(필터)으로 넘길 수 있는데, 이것은 파일 입출력에서 다루었던 open 명령을 이용하는 것이다. 다음 예는 stdio.h에서 printf 라는 단어가 나오는 모든 줄을 test라는 파일로 저장하는 것을 보인다.

set in [open /usr/include/stdio.h r]
set out [open {| grep printf > test} w]
while {[gets $in line] >= 0} {
puts $out $line
}
close $out
close $in

open 명령에 사용될 수 있는 파이프(pipe)나 입출력 재지정(redirection)을 위해 사용할 수 있는 기호에는 >>파일명(출력을 기존 파일에 덧붙임), >@파일id(파일id로 출력), >&파일명(표준출력과 표준에러를 파일로 보냄), 2>파일명(파일로 표준에러를 보냄), <파일명(파일로부터 표준입력을 받음), <<값(값으로부터 표준입력을 받음), <@파일id(파일id를 통해 표준입력을 받음) 이 있다. 이 기호들은 exec에서도 사용할 수 있다.

exec find / -name core -exec rm -f "{}" ; 2> /dev/null > result

find를 실행하게 되면 유저 권한으로는 들어갈 수 없는 디렉토리가 종종 있다. 그래서 이럴 때 뜨는 에러 메시지를 모아서 /dev/null로 보내는 방법을 이용한다. find의 실행 결과는 result라는 파일로 모은다.

close하기 전에 pid $out을 실행해보면 grep 프로세스의 프로세스 id가 출력된다. 그리고 pid라고 실행하면 현재 Tcl 스크립트나 tclsh의 프로세스 id가 출력된다.

프로세스를 종료할 때, exit를 통해서 상태값(status)을 반환할 수 있는데, 기본 상태값은 0이다. 물론 exit 명령 자체의 결과값은 없다.

4) 스크립트 실행하기

source 명령을 이용하면 C shell의 source 명령처럼 Tcl 스크립트를 불러다가 실행할 수 있다.

source /usr/local/lib/tcl8.1/history.tcl

5장 에러와 예외사항(exception)

에러(error)라는 표현은 독자들도 많이 알고 있을 것이지만, 예외사항(exception)은 Java가 널리 퍼지기 전에는 프로그래밍 이론 과목에서나 접했을 법한 용어이다. 예외사항은 아주 간단하게 말하면 에러의 상위집합(superset)이다. 다시 말해서 에러는 예외사항의 부분집합이다. 에러라고 하기에는 큰 문제가 발생한 것은 아니고 그렇다고 정상 종료도 아닌 결과가 예외사항이 되는 것이다.

예외사항은 Java에서처럼 발생시키는 것과 그것을 잡아서 처리한다는 개념이 존재하는데, 에러나 예외사항을 발생시키기 위해서는 error 명령와 return 명령을 사용할 수 있다. 반대로 발생한 예외사항을 감지해내기 위해서는 catch 명령을 사용해야 한다.

unset zzz

은 에러가 발생할 것이다. zzz라는 변수를 이전에 선언한 적이 없기 때문이다. 그러나 이렇게 발생한 에러를 잡으려면 다음과 같이 할 수 있다. message 변수를 지정하면 변수에 에러 메시지가 저장된다.

catch {unset x}
catch {unset x} message

에러를 발생시킬 때, 에러 메시지는 반드시 지정해야 하지만, 에러 정보와 에러 코드는 선택하게 되면 Tcl에서 지정해서 사용하는 errorInfo나 errorCode 변수에 저장할 수 있다. error 명령의 문법은 “error 에러메시지 에러정보 에러코드”이다.

error "can't unset the variable: no such variable"

예외사항을 발생시키는 것은 return 명령을 이용하는 것이다. return도 error 명령처럼 에러코드와 에러정보, 에러메시지등을 지정할 수 있는데, 문법은 “return -code 결과값 -errorinfo 에러정보 -errorcode 에러코드 에러메시지”이다. 결과값의 기본값은 0이다. 다음의 예는 전역 변수인 errorInfo와 errorCode를 이용해 return하는 것이다. 프러시져 내에서 실행되는 경우에는 errorInfo와 errorCode를 global 명령을 이용해 지역 변수처럼 사용할 수 있도록 처리해 주어야 한다는 사실을 잊지 않아야 한다.

return -code 4 -errorinfo $errorInfo -errorcode $errorCode

6장 기타

1) 배열

앞에서 Tcl에서의 배열이란 Perl에서의 해시 변수와 비슷하다고 언급한 적이 있다. 배열을 다루는 명령은 문자열 처리 명령처럼 array 명령에 다양한 옵션을 결합시켜서 사용해야 한다. 배열의 키를 모두 찾기 위해서는 names라는 옵션을, 배열의 키의 수를 구하기 위해서는 size 옵션을 사용한다.

array names my_array
array size my_array

배열에 대해서 검색을 할 수도 있는데, 이 때는 다음과 같은 형태로 가능하다. 배열을 만들고, search id를 구해서 배열에 원소가 아직 남아 있는지 검사하고 남아 있으면 매번 키를 구해다가 키를 이용해 배열을 다루게 된다. 검색이 끝나면 search에 관련된 정보를 반환한다.

set my_arr(1) hello
set my_arr(2) world
set my_arr(name) terzeron
set my_arr(age) 25
set search_id [array startsearch my_arr]
while {[array anymore my_arr $search_id]} {
set key [array nextelement my_arr $search_id]
puts $my_arr($key)
}
array donesearch my_arr $search_id

2) 정보(info)와 내부 명령

현재 Tcl 스크립트와 현재 버전의 Tcl/Tk의 정보를 볼 구할 수 있는 명령은 info이다. info 명령의 옵션을 다음과 같이 정리해 보았다.

[HTML]

args 프러시져명 프러시져의 아규먼트(argument)를 리스트로 반환한다.
body 프러시져명 프러시져의 몸체 부분을 반환한다.
cmdcount 현재 인터프리터(interpreter)에서 사용된 명령의 수를 반환한다.
commands 패턴 실행가능한 명령을 리스트로 반환한다. 패턴을 지정하는 경우에는 매치되는 명령만 반환할 수도 있다.
complete 명령 버전 8에서 추가된 옵션으로 명령이 완전한 명령인가를 검사한다.
default 프러시져명 아규먼트명 변수명 프러시져의 해당 아규먼트가 기본값을 가지는지 검사하여 변수에 결과값을 써준다.
exists 변수명 변수가 존재하는지 검사한다.
globals 패턴 전역 변수의 리스트를 반환한다. 패턴 매치도 가능하다.
hostname 현재 프로세스가 실행되고 있는 컴퓨터의 이름을 반환한다.
level 번호 현재 스택(stack)의 레벨를 반환한다.
library 현재 Tcl의 라이브러리의 절대 경로를 반환한다.
locals 패턴 지역 변수의 리스트를 반환한다.
nameofexecutable 현재 실행 프로그램(tclsh)의 절대경로를 반환한다.
patchlevel 패치된 레벨을 반환한다.
procs 패턴 현재 정의되어 있는 프러시져를 패턴 매치하면서 반환한다.
script 스크립트의 이름을 반환한다.
sharedlibextension 공유라이브러리의 확장자명을 반환한다.
tclversion Tcl의 버전을 반환한다.
vars 패턴 현재 사용가능한 변수명의 리스트를 반환한다. 패턴 매치도 가능하다.

[/HTML]

내부명령으로 사용할 수 있는 것은 rename, time, trace 등이 있다. rename은 변수나 프로시져의 이름을 바꿀 수 있는데, 바꾸는 것 뿐만 아니라 {}로 바꾸어서 사용하지 못하게 제거할 수도 있다. time은 스크립트을 반복 수행하여 그 평균 수행시간을 계산해준다. 다음과 같이 사용할 수 있다.

time test.tcl 300

trace 명령은 변수의 값 변화를 추적할 수 있는 명령이다. 다음의 예를 보면서 설명하도록 하자.

trace variable age w p_age1
trace vinfo age
trace vdelete age w p_age1

이 예는 age라는 변수에 대해서 쓰기 작업이 이루어지면 p_age1이라는 프러시져를 매번 호출하고, age에 대한 trace 정보를 구하고, 추적 작업을 종료하는 것이다.

3) 히스토리(history)

C shell에서와 같이 history 명령을 실행하면 여태까지 실행했던 명령들을 볼 수가 있다.

history

history 명령에 keep 옵션을 주어 특정 개수만큼 히스토리를 유지할 수도 있고, nextid 옵션을 주어 다음 명령이 히스토리에서 몇 번째 명령이 될 것인가를 알아볼 수도 있다. 또한 redo 옵션으로 저장된 명령을 다시 실행할 수도 있다. 히스토리는 substitute 옵션을 통해 아이템을 변경할 수 있다.

history redo
history redo 3

set x helo
history substitute lo llo -1

마지막 명령은 바로 이전 명령행에서 lo를 llo로 바꾸게 하는 것이다. 조심해야 할 것이 하나 있다. 버전 8에서는 이 옵션이 없어지고 대신 change 옵션이 추가되었는데, 이 옵션은 명령행 전체를 치환한다. 비슷한 기능을 하는 add 옵션도 추가되었다. 히스토리를 지우는 clear 옵션도 제공된다.

set x helo
history change {set x hello} -1

!!는 history redo의 단축어로 제공된다. !번호 같은 형식으로도 히스토리를 이용할 수가 있다. substitute대신에 ^old^new와 같은 형식을 이용할 수도 있다. 이런 단축어들은 C shell에 기반한 것이다.

4) 매뉴얼(manual)

tclsh에서는 매뉴얼을 볼 수가 있다. 다른 키워드와 겹치지만 않는다면 man 키워드 로 매뉴얼을 볼 수 있다. 그러나 history만 해도 유닉스의 명령과 같기 때문에 man history로는 Tcl명령인 history를 공부할 수가 없다. 이럴 때는

man -s n history

로 문제를 해결할 수 있을 것이다.

서울대학교 전산과학과 데이타베이스 연구실 조영일

:

Erlang 관련 URL

Development 2008. 5. 23. 16:50
erlang 관련 URLs

http://www.erlang.org/doc/ (공식 사이트)
http://groups.google.com/group/erlangstudy/web/erlang-otp-r12b-release-highlight (erlang study 관련 구글그룹)


http://tempe.st/2007/05/erlang-ruby-and-php-battle-it-out/




:

Name mangling in C++

Development/C / C++ 2008. 3. 25. 15:59
C++은 C언어를 지원한다. 그래서 예전의 C소스를 가져다가 사용할 수 있다.

C 언어는 symbol에서 함수의 이름만으로 찾을 수 있다. 이 것은 C언어가 같은 함수 이름을
사용하는 것을 허용하지 않기 때문인데, C++에서는 다형성으로 인해서 같은 이름을 사용할 수 있다. 그래서 함수이름만으로 찾는 것이 불가능하다.

이런 것을 위해서 내부적으로 함수 이름을 변경해주는데 이것이 name mangling 이다.

다음과 같이 된다.

int foo(void)  {return 1;}
int foo(int ) {return 1;}
void goo(void) { int i=foo(), j=f(0); }



int _foo_i (void)  {return 1;}
int _foo_v (int ) {return 1;}
void _goo_i (void) { int i=_foo_i(), j=_foo_v(0); }




참조 : http://en.wikipedia.org/wiki/Name_mangling#Name_mangling_in_C.2B.2B




:

BFD 라이브러리

Development 2008. 2. 5. 13:51
BFD 라이브러리란 오브젝트 파일 형식이 어떤 것이던 동일한 루틴을 사용해 오브젝트 파일에 접근하기 위해 GNU에서 만든 라이브러리 패키지. BFD는 크게 Front-end와 Back-end 두 부분으로 나누어져 있다.

Front-end는 라이브러리 사용자에게 제공되는 Interface이며, Back-end는 특정 오브젝트 파일 형식을 접근할 수 있는 함수와 자료로 이루어져 있다.


아래는 GNU의 BFD Library 의 Document 페이지이다.
http://www.gnu.org/software/binutils/manual/bfd-2.9.1/html_mono/bfd.html






:

Firefox Beta2 Release

카테고리 없음 2008. 1. 7. 11:04
사용자 삽입 이미지

Firefox3 Beta2



Mozilla의 Firefox Beta2 가 릴리즈되었다.

대략적인 변화로는 안정성, 성능, 메모리 사용, 플랫폼 확장성 등에 향상이 있었으며,
코드의 간소화 등이 있다.

릴리즈 노트에서~
http://en-us.www.mozilla.com/en-US/firefox/3.0b2/releasenotes/
:

Error LNK2005 : _DllMain@12 already defined

Development/C / C++ 2007. 4. 28. 09:55
Error LNK2005 : _DllMain@12 already defined

cpp파일 내에서 DllMain 함수를 직접 구현하여 사용할 때
CString 등을 위해서 afx.h 를 include 시키게 되면 발생하는 에러이다.


일반적으로 이 LNK2005 에러는 다른 라이브러리 내에 DllMain이 포함되어 있는 경우에는
http://support.microsoft.com/kb/148652 에서 지시하는 대로 해결을 할 수가 있지만
cpp 파일내에서 DllMain을 직접 구현하여 사용할 경우에는 해당이 되질 않는다.

하지만 CString은 써야겠고...
이럴 때에는 afx.h 를 include시키지 말고

atlstr.h를 include시켜 사용하면 해결 할 수 있다.

// #include <afx.h>
#include <atlstr.h>


// afxstr.h에 정의되어 있는 CString
typedef ATL::CStringT< TCHAR, StrTraitMFC< TCHAR > > CString;




thx to K.H






:

The Function Pointer Tutorials

Development/C / C++ 2007. 4. 24. 15:46

The Function Pointer Tutorials

  Extensive Index
  2.  The Syntax of C and C++ Function Pointers
  3.  How to Implement Callbacks in C and C++ ?
  4.  Functors to encapsulate C and C++ Function Pointers
  5.  Topic Specific Links




-------------------------------------------------------------------------------
Function Pointer에 대한 Tutorial이 있는 곳.
Lars의 홈페이지
출처 : http://www.newty.de/fpt/index.html

:

애플리케이션 개발시의 메모리 디버깅 : 메모리 누수 발견 기법

Development/C / C++ 2007. 2. 13. 16:19
출처 : http://www.ibm.com/developerworks/kr/library/opendw/20061219/
[오픈 디벨로퍼웍스]는 여러분이 직접 필자로 참가하는 코너입니다. 이번에는 김현우 님이 작성한 애플리케이션 개발시의 메모리 부족 에러에 대비하는 메모리 누수 발견 프로그램 개발 방법을 함께 살펴봅니다.

필자는 DVD 레코더와 셋톱박스의 복합 모델을 개발하는 팀에 소속되어 있다. 현재 유럽에서는 아날로그 방송을 디지털로 서서히 대체하고 있기 때문에, 관련 제품의 개발 요청이 쇄도하고 있다.
얼마 전 유럽을 타깃으로 3개의 유사 모델(D197, D198, D199)을 개발하고 있을 때의 일이다. 우여곡절 끝에 기본 모델인 D197 개발을 마치고 양산 시켰으며, D198도 완료하여 QA 그룹에 테스트를 의뢰한 후 결과를 기다리고 있었다. 팀원들 모두, D197 모델이 별 이상 없었으니 부가기능을 조금 추가한 D198 역시 무난히 양상 단계로 넘어갈 것이라고 판단, 모처럼의 한가한 시간을 보내고 있었다.
그런데 그 순간 옆자리에서 통화하는 소리가 들렸다.
"네, 세트가 멎었다고요?"
테스트 그룹으로부터 D198 세트가 오동작 한다는 연락을 받은 것이다. 우리는 즉시 테스트 그룹으로 달려갔다. 노트북을 D198에 연결하고 에러 메시지를 확인했다. 디버그 화면에는 메모리 부족을 나타내는 경고 메시지가 반복해서 출력되고 있었다. 사용할 수 있는 메모리가 바닥나서 더 이상 동작하지 못하고 멈춰버린 것이었다.
D198의 DRAM은 시스템을 동작시키고 남을 넉넉한 크기여서, 정상적인 경우라면 메모리 부족 문제가 발생할 이유가 없었다. D197 모델에서는 없었던 메모리 문제가 왜 발생했을까? 팀원들은 오늘도 일찍 퇴근하기는 틀렸다는 생각에 투덜거리며 D198에서 새로 추가된 코드를 중심으로 살펴보기 시작했다.

메모리 누수

자바나 C# 같은 언어에는 가비지 컬렉터(Garbage Collector)가 있어서, 알아서 메모리 관리를 해주지만 프로그래밍 언어의 대표주자 C/C++ 에서의 메모리 할당과 해제는 프로그래머의 몫이다. 따라서 애플리케이션이 메모리를 할당 받아서 사용했다면, 사용이 끝난 뒤에는 반드시 반납해서 해당 메모리를 재사용할 수 있도록 해야 한다. 그러나 프로그래머도 사람인지라 할당 받은 메모리를 실수로 해제하지 않는 경우가 발생할 수 있다. 이러한 경우 해제되지 않은 메모리는 사용이 끝난 뒤에도 남아있어 공간만 차지하는 상태로 있게 된다. 이것을 메모리 누수라고 한다.
언뜻 생각해보면 메모리를 할당 받고 해제하는 것이 무슨 어렵고 복잡한 일인가? 의문을 가질 수도 있겠지만, 실제로 코딩을 하다 보면 그렇게 간단한 문제가 아니다.
상용코드는 여러 프로그래머들이 개발하고 유지보수하기 때문에 시간이 지나면 일관성을 잃고 난해해지기 쉬울 뿐 아니라 그 양도 방대해진다. 때문에 복잡해진 애플리케이션의 상태나 조건을 후임 개발자가 꿰뚫고 있기는 쉽지 않다.
여기에 새로운 기능을 추가하다 보면 예상치 못한 상태에 빠지기도 하고 복잡해진 조건에 누락되는 부분이 생겨 메모리 누수가 발생하기 쉽다. 특히 문제의 루틴이 단발적으로 사용되는 것이 아니라면 루틴이 실행될 때마다 메모리 누수가 누적되어 언젠가는 메모리 부족 문제가 터질 수밖에 없게 된다.

메모리 감시자

짧은 분량의 간단한 프로그램이라면 차분히 코드를 살펴보면서 메모리 누수를 찾아 나설 수도 있다. 실제로 느긋하게 코드를 읽어 나가는 것은 메모리 누수뿐 아니라 야근하며 작성했던 코드의 결함을 찾아내는 가장 좋은 방법일 수도 있다.
그 러나 방대한 분량의 상용 코드에서 생긴 문제라면 어떻겠는가? 거기에 상용 프로그램 개발에 느긋한 일정을 주는 회사를 본 적이 있는가? 긴박한 일정에 메모리 누수까지 생겼다면 과중한 스트레스로 이력서를 고쳐 쓰고 취업 사이트를 뒤지게 될지도 모르겠다.
방대한 프로그램에 숨겨진 감쪽같은 메모리 누수는 사막에서 잃어 버린 바늘과도 같다. 맨 손으로 사막에서 바늘을 찾으려는 것은 너무도 무모한 짓이다. 사막에서 바늘을 찾으려면 도구가 필요하다. 이를테면 금속탐지기 같은 것이 있으면 큰 도움이 될 것이다. 우리도 메모리 누수를 찾기 위한 금속탐지기부터 구해 보도록 하자.
필자는 도입부의 문제 상황에서 다른 팀원들과 함께 코드를 살펴보는 대신에 메모리 감시 모듈을 작성하여 코드에 추가하는 작업을 했다. 물론 다른 팀원들이 열심히 코드를 분석하는 동안에 다른 코드를 작성하고 있었기 때문에, 자칫 딴짓을 하는 것처럼 보였을 수도 있었겠지만 결국은 필자의 방법으로 문제를 해결할 수 있었다. 두 세시간 정도의 시간이 지나자 코드 분석을 하던 팀원들은 하나 둘씩 지쳐서 포기하기에 이르렀는데, 그 즈음에 필자의 프로그램이 완성되었다.
필자가 개발한 메모리 감시자는 메모리의 할당과 해제 상태를 저장하고 리포트 하는 기능을 하는데, 감시 모듈을 포함시킨 D198을 부팅시켜 기본동작을 수행시켜 본 후 메모리 상태를 확인해 보았다. 예상대로 D198에서 새롭게 추가된 유료방송 처리 부분이 문제였다.
우리는 리포트 받은 부분의 코드를 살펴보고 곧 문제를 찾아내어 메모리 누수를 해결할 수 있었다. 그리고 덤으로 세트에 주는 영향은 미미하지만 감추어져 있던 또 다른 메모리 누수(몇 년 정도 동작 시키면 세트를 멈추게 할 수도 있는 문제)도 찾아낼 수 있었다.

메모리 감시자의 구현

메모리 감시자가 하는 일은 거창해 보이지만 구현은 생각보다 간단하다. C언어에서는 malloc이나 calloc을 이용하여 메모리를 할당하고, free를 이용하여 메모리를 해제한다. 마찬가지로 C++에서는 new와 delete가 같은 동작을 한다. 메모리 감시자는 링크드 리스트로 구현되며, malloc이 호출 되면 할당한 메모리의 주소를 리스트에 저장해 두었다가 free로 해당주소를 해제하면 저장된 리스트에서 해제된 주소 값을 찾아 삭제한다.
이러한 동작으로 메모리 감시자는 현재 할당되어 있는 메모리의 주소만을 보관하고 있게 된다. 따라서 메모리 감시자를 포함한 어플리케이션을 실행시킨 후 리포트를 확인하면, 비정상적으로 여러 번의 메모리 할당을 받은 부분을 어렵지 않게 발견할 수 있다. 메모리 감시자는 헤더 파일 하나(TraceMem.h)와 소스 파일 하나(TraceMem.c)로 구성된다. 각각의 구현 방법에 대해 설명하도록 하겠다.


TraceMem.h

메모리 감시자가 애플리케이션이 할당 받은 동적 메모리의 주소 값을 저장하는 역할을 하지만, 메모리 감시자 자체도 링크드 리스트로 구현되기 때문에 스스로가 동작하는 동안에도 동적 메모리의 할당을 하게 된다. 즉, 애플리케이션에서 할당한 메모리 주소를 메모리 감시자의 링크드 리스트에 저장하기 위해 다른 메모리를 할당해야 하는 것이다.
그러면 메모리 감시자는 주소 값을 저장하기 위해 할당 받은 메모리의 주소를 또 다시 저장하려 들것이다. 이 때문에 순환참조가 발생되며, 메모리 누수 잡기를 시작도 하기 전에 시스템이 다운되는 상황이 벌어지게 된다. 이러한 문제를 피하기 위해 메모리 감시자가 스스로의 목적에 의해 사용하는 동적 메모리의 할당과 해제는 메모리 감시자의 감시를 받지 않도록 할 필요가 있다.
아래 코드를 살펴보면 매크로를 이용해서 _MALLOC와 _FREE를 정의하였다. _MALLOC와 _FREE는 realloc을 이용하였는데, realloc은 malloc과 free의 기능도 모두 할 수 있는 범용적인 메모리 함수다. 본래 realloc은 malloc이나 calloc으로 할당한 메모리의 크기를 변경시킬 때 사용하는 함수이다. 기존에 할당 받은 주소를 NULL로 주면 메모리를 새로 할당하는 malloc의 역할을 하고, 새로 할당할 메모리의 크기를 0으로 하면 기존의 메모리만 해제시켜 버리기 때문에 free와 같은 역할을 한다.
메모리 감시자는 malloc과 free가 호출될 때 할당되거나 해제된 주소를 관리하므로, 메모리 감시자 코드를 구현하는 데는 malloc이나 calloc을 사용하지 않고 realloc을 사용한 _MALLOC과 _FREE를 사용할 것이다.
TraceMem은 메모리 감시자의 몸체가 되고 MemItem은 메모리 주소 값을 저장하는 리스트의 노드이다. STL을 사용할 경우는 더욱 편리하게 리스트를 구현할 수 있지만, C에서도 사용할 수 있도록 링크드 리스트를 직접 구현해 보겠다.

#define _MALLOC(p)	realloc(NULL, p)
#define _FREE(p) realloc(p, 0)

void* dbgMalloc(int size, char* file, int line);
#define malloc(n) dbgMalloc(n, __FILE__, __LINE__)

void dbgFree(void* ptr);
#define free(p) dbgFree(p)

typedef struct _MemItem
{
void* ptr;
char* file;
int line;
unsigned long size;
int num;
struct _MemItem *next;
} MemItem;

typedef struct _TraceMem
{
MemItem* head;
MemItem* tail;
int num;
} TraceMem;

TraceMem* TraceMemCreate();
void TraceMemDelete(TraceMem*);
void TraceMemPrint(TraceMem* self);
TraceMem* TraceMemGetSummary(TraceMem* self);

extern TraceMem* traceMem;

TraceMem.c

dbgMalloc과 dbgFree는 malloc과 free를 대신하여 수행되며 메모리 감시자에 메모리 주소를 추가하고 삭제하는 역할을 하며 아래 코드는 메모리 감시자의 몸체 부분으로 임베디드 환경에서도 사용할 수 있도록 C를 객체지향 스타일로 작성한 것이다.

void* dbgMalloc(int size, char* file, int line)
{
void* ptr = _MALLOC(size);

TraceMemAdd(traceMem, ptr, file, line, size);

return ptr;
}

void dbgFree(void* ptr)
{
TraceMemRemove(traceMem, ptr);

_FREE(ptr);
}

TraceMem* traceMem = NULL;

/* 링크드 리스트 노드 생성자 */
MemItem* MemItemCreate(void* ptr, char* file, int line, unsigned long size)
{
MemItem* self = (MemItem*) _MALLOC(sizeof(MemItem));

self->file = (char*)_MALLOC(strlen(file) + 1);
strcpy(self->file, file);
self->line = line;
self->size = size;
self->num = 1;
self->ptr = ptr;

return self;
}

/* 소멸자 */
void MemItemDelete(MemItem* self)
{
if (self == NULL)
{
return;
}

_FREE(self->file);
_FREE(self);
}

void MemItemPrint(MemItem* self)
{
printf(" ++ [%s:%d:%p] : %d/%d\n", self->file, self->line, self->ptr, self->num, self->size );
}

/* 메모리 감시자 생성자 */
TraceMem* TraceMemCreate()
{
TraceMem* self = (TraceMem*) _MALLOC(sizeof(TraceMem));

self->head = MemItemCreate(0, "Head", 0, 0);
self->tail = MemItemCreate(0, "Tail", 0, 0);

self->head->next = self->tail;
self->tail->next = NULL;

self->num = 0;

return self;
}

/* 소멸자 */
void TraceMemDelete(TraceMem* self)
{
while ( self->head->next != self->tail )
{
MemItemDelete( TraceMemPop(self, self->head->next) );
}

if (self->num != 0)
{
printf(" ++++ ERROR : TraceMem has Items %d", self->num);
}

_FREE(self->head);
_FREE(self->tail);
_FREE(self);
}

/* 주소값을 이용한 노드 검색 */
MemItem* TraceMemFindPtr(TraceMem* self, void* ptr)
{
MemItem* iter;

for ( iter = self->head->next; iter != self->tail; iter = iter->next )
{
if (ptr == iter->ptr)
return iter;
}

return NULL;
}

int TraceMemPush(TraceMem* self, MemItem* item)
{
MemItem* next = self->head->next;

self->head->next = item;
item->next = next;

return (self->num++);
}

MemItem* TraceMemPop(TraceMem* self, MemItem* item)
{
MemItem *iter;

for ( iter = self->head; iter != self->tail; iter = iter->next )
{
if (iter->next == item)
{
iter->next = iter->next->next;
item->next = NULL;
self->num--;

return item;
}
}

return NULL;
}

/* 메모리 정보를 리스트에 추가 */
int TraceMemAdd(TraceMem* self, void* ptr, char* file, int line, unsigned long size)
{
MemItem *tar;

if ( (tar = TraceMemFindPtr(self, ptr)) == NULL)
{
MemItem* item = MemItemCreate(ptr, file, line, size);
TraceMemPush(self, item);
}
else
{
TraceMemPrint(self);
}

return 0;
}

/* 메모리 정보를 리스트에서 제거 */
int TraceMemRemove(TraceMem* self, void* ptr)
{
MemItem *tar;

if ( (tar = TraceMemFindPtr(self, ptr)) != NULL)
{
MemItemDelete( TraceMemPop(self, tar) );
}
else
{
TraceMemPrint(self);
}

return 0;
}

/* 메모리 감시자 내용 출력 */
void TraceMemPrint(TraceMem* self)
{
MemItem *iter;

printf("\n ++ TraceMemPrint\n");

for(iter = self->head->next; iter != self->tail; iter = iter->next)
{
MemItemPrint(iter);
}
}

/* 파일명과 라인수로 노드를 찾는 함수 */
MemItem* TraceMemFindFileLine(TraceMem* self, char* file, int line)
{
MemItem* iter;

for ( iter = self->head->next; iter != self->tail; iter = iter->next )
{
if (line == iter->line && strcmp(file, iter->file) == 0)
return iter;
}

return NULL;
}

/* 정리된 메모리 상태를 주는 함수 */
TraceMem* TraceMemGetSummary(TraceMem* self)
{
MemItem *iter, *tar;
TraceMem* sum = TraceMemCreate();

for (iter = self->head->next; iter != self->tail; iter = iter->next)
{
if ( (tar = TraceMemFindFileLine(sum, iter->file, iter->line)) == NULL)
{
MemItem *item = MemItemCreate(0, iter->file, iter->line, iter->size);
TraceMemPush(sum, item);
}
else
{
tar->num ++;
tar->size += iter->size;
}
}

return sum;


Test 수행하기


#include <iostream>
#include "TraceMem.h"
using namespace std;
int main()
{
traceMem = TraceMemCreate();
int *ptr = (int*) malloc(100); // Line 11
int *ptr2 = (int*) malloc(200); // Line 12
for (int i=1; i&lt;=100; i++)
{
malloc(i); // Line 16
if (i%4 == 0)
{
malloc(i); // Line 19
}
}

free(ptr);
free(ptr2);
TraceMem* summary = TraceMemGetSummary(traceMem);
TraceMemPrint(summary);

TraceMemDelete(summary);
TraceMemDelete(traceMem);

return 0;
}
> Report
++ TraceMemPrint
++ [D:\Works\VCxx\MemTest\Main.cpp:16:00000000] : 100/ 5050
++ [D:\Works\VCxx\MemTest\Main.cpp:19:00000000] : 25/ 1300


위의 예는 Main.cpp의 11, 12 번째 줄에서 할당한 메모리는 정상적으로 해제한 반면 16, 19번째 줄에서 할당한 메모리는 해제하지 않았다. Report는 해제되지 않은 메모리를 보여주는데, Main.cpp의 16번째 줄의 두 숫자 100은 해제되지 않은 메모리의 개수이고 5050은 메모리 사이즈의 합이다. 테스트 코드에서는 1부터 100까지의 크기로 메모리를 할당했었기 때문에, 할당된 횟수는 100회이고 사이즈는 1부터 100의 합인 5050이 된다.

결론

이상으로 메모리 감시자를 이용하여 메모리 누수를 손쉽게 찾을 수 있는 방법에 대해 알아봤다. 소개한 코드에서는 malloc과 free를 사용한 경우를 예로 들었지만 calloc에 대해서도 적용할 수 있으며, 약간의 매크로 트릭과 연산자 오버로딩을 이용하면 C++의 new와 delete에도 적용할 수 있다. 물론 소개한 알고리즘을 응용하여 가비지 컬렉터가 없는 여타의 언어에 대해서도 모두 적용할 수 있다.
메모리 감시자는 간단하지만 부담스러운 가격의 상용 디버깅 툴을 사용하지 않더라도 메모리 누수를 찾아내는 강력한 기능을 제공한다. 더구나 대부분 C를 이용하여 작성되는 임베디드 환경에서라면, 그나마도 변변한 디버깅 툴도 없는 실정이어서 특히 유용한 툴이 될 수 있을 것이다.
:

TCP/IP를 기반으로 한 온라인 게임 제작 : 크리티컬섹션과 세마포어 , 뮤텍스

Development/C / C++ 2007. 1. 2. 16:45

프로그램의 세계 1999년 12월에 특집 기사

글쓴이 : 배재현(goldbat@ncsoft.co.kr) ( NC소프트 )

HTML Formated & Rearranged : Daeyeon, Jeong




             
TCP/IP 를 기반으로 한 온라인 게임 제작




최근들어 온라인 게임들이 점점 대중화되며 인기를 끌고 있다. 특히 수천 명이 하나의 서버에서 게임 내 가상스페이스를 공유하며 플레이하는 그래픽 머드의 개발과 동작원리는 게임 개발을 시작하려는 사람들에게 많은 관심의 대상이 되고 있다. 3부에서는 간단한 그래픽 머드의 서버와 클라이언트 프로그램을 구현하고 이를 통해 그 구조를 살펴본다.

 온라인 게임과 싱글유저 게임은 사실 별다른 차이점이 없다. 이를 RPG로 한정하고 생각한다고 해도 유일하게 다른 점은 한가지뿐이다. 현재 내가 플레이하고 있는 게임의 여러 자원들, 즉 게임 내의 나의 분신인 게임 캐릭터와 캐릭터가 싸우고 있는 게임 속의 몬스터, 캐릭터가 가지고 있는 아이템, 옆을 걸어 지나가는 마을주민 등이 게임이 플레이 중인 자기 PC에 있는냐 아니면 랜이나 전화선으로 연결된 온라인 상의 어느 곳에 존재하느냐 일뿐이다.

 그러나 이러한 멀티유저 게임과 싱글유저 게임의 차이점은 게임제작에서 실제 개발뿐만이 아니라 기획단계부터 많은 제약을 받게 된다. 싱글유저라면 간단하게 만들 수 있는 RPG의 퀘스트도 멀티유저 온라인 RPG라면 엄청난 일이 된다.

 예를 들어 뒷산에 있는 어떤 보스급 몬스터를 죽이면 꽤 좋은 아이템을 주는 이벤트를 만든다고 할 때 싱글유저 게임이라면 별 문제가 아닐 수도 있지만 멀티유저 게임이라면 문제가 틀리다. 같은 게임을 플레이중인 플레이어가 한 명이 아니라 수백 또는 수천 명이 될 수도 있기 때문에 몬스터가 주는 아이템이 좋을 경우 한 명의 용감한 용사가 아니라 수백 명이 말 그대로 인해전술로 몬스터 한마리를 잡기 위해 몰려들 수도 있다. 이럴 경우 그 몬스터는 당연히 몇 분만에 제대로 싸워보지도 못하고 죽게 될 것이고, 플레이어의 숨막히는 모험을 기대한 기획자의 기획은 실패하게 된다. 멀티유저게임은 이런 기획상의 문제를 극복한다고 해도 실제 개발에서 해결해야할 문제가 많이 남아있다.


동기화 (Synchronization)

 멀리 떨어져 있는 많은 게임 플레이어들이 하나의 가상 공간에서 같은 게임을 즐긴다는 것은 분명히 매력적이기는 하지만, 개발자에게는 많은 골치거리를 제공한다. 게이머들이 모두 동일한 환경에서 빠른 네트워크를 통해 게임에 접속해서 게임을 플레이한다면 좋겠지만 불행히 현실은 그렇지 않다. 어떤 사람은 T3 이상의 고속회선을 통해서 게임을 할 수 있고 또 다른 사람은 1400bps의 느린 모뎀에 낮은 클럭의 486PC에서 게임을 할 수도 있다. 이런 상이한 조건의 클라이언트들에게 ‘거의’ 동일한 서비스를 제공하기 위해서는 많은 테크닉이 필요하다.


해킹

 그래픽 머드의 특성상 게임 내의 실제 데이터는 로컬 PC에 존재하게 된다. 로컬 PC에 있는 데이터를 분석하고 조작해서 게임을 편하게 즐기는 단순한 해킹에서부터 TCP/IP의 하위 레이어에 침투해서 패킷을 가로채 분석한 다음 가짜 패킷을 서버에 보내는 전문 해커까지 서버를 공격하는 방법은 다양하다. 개발자는 패킷을 암호화하거나 게임 데이터를 압축해서 이러한 해킹에 대항해야 한다.


서버의 안정성

 훌륭한 기획에 그래픽을 만드는 것까지는 순조롭게 진행이 되다 결국의 서버의 안정성이 확보되지 않아 개발이 실패하는 일도 발생할 수 있다.

싱글유저 게임이라면 이런 문제에 대해 걱정할 필요가 없겠지만, 메가 플레이어가 접속하는 게임이라면 네트워크 문제(이것은 일단 돈으로 해결할 수도 있다)와 수십 개의 스레드를 사용할 때 발생하는 데드락, 시스템 정지, 메모리 참조에러 등 오래 살아있는 서버를 만드는 것이 게임 자체를 만드는 것보다 오히려 어려울 수도 있다.


양질의 회선

 네트워크 게임은 대부분 리얼타임으로 게임이 진행되고 서버/클라이언트 사이에 주고받는 데이터의 양은 다른 서비스와 비교할 수 없을 정도로 많다. 온라인 게임 서버의 네트워크 트래픽은 사용자가 증가할 때마다 산술증가가 아니라 기하급수로 증가한다. 좋은 게임 서버의 개발도 중요하지만 서비스가 시작되면 회선에도 많은 투자를 해야한다.


 

리스트 1 : 시스템을 정지시키는 간단한 프로그램

#include "process.h"
#include "stdio.h"

unsigned __stdcall thread(void* arg)
{
   while (1) {
   }

   return 0;
}

int main()
{
   int i;

   for (i = 0; i < 3000; i++) {
     _beginthreadex(NULL, 0, thread, 0, 0, NULL);
   }
   getch();
   return 0;
}
 


멀티스레드 프로그래밍

 서버 프로그래밍에서 가장 중요한 요소 중의 하나가 스레드(thread)다(스레드의 정의와 스레드 관련 API 함수들에 대해서는 지면관계상 자세히 다루기 힘들기 때문에 생략하겠다. 스레드는 프로세스 내의 작은 프로세스들이라고 이해하고 넘어가도 내용을 이해하는 데는 별다른 문제는 없을 것이다).

 멀티태스킹이 지원되는 OS에서는 동시에 여러 개의 프로세스가 실행되는 것이 가능하다. 윈도우 95나 NT같이 완전한 선점형 멀티태스킹 OS라면 백그라운드로 프린트나 파일 복사 같은 작업을 하고 있더라도 포그라운드로 실행중인 프로그램에 별로 영향을 미치지 않고 여러 개의 작업을 동시에 수행할 수 있다. 멀티태스킹 뿐만 아니라 멀티스레드가 지원되는 OS라면 여러 개의 프로그램을 동시 실행시키는 것뿐만 아니라 한 프로그램 또는 프로세스 안에 여러 개의 자식 프로세스(스레드)들을 만들 수 있다. 한 프로그램 내에서도 현재 작업을 중단하지 않고 여러 개의 일을 수행하는 것도 가능하다. 예를 들어 파일을 읽으면서 읽은 양을 다이얼로그 박스에 표시한다던지, 워드프로세스에서 사용자의 입력과 동시에 맞춤법을 맞추는 등 여러 가지 면에서 편리하게 사용이 가능하다.

편해 보이기는 하지만 이 스레드 또한 양날의 칼이다. 잘 사용하면 문제가 없지만 잘못 사용하면 작업을 빨리 끝내는 것이 아니라 오히려 시스템의 속도를 떨어뜨릴 수도 있다.

아무리 멀티태스킹, 멀티스레드를 지원하는 OS라고 해도 결국은 한정된 자원인 CPU를 나눠서 사용하는 것일 뿐이다. 어떤 컴퓨터에 5개의 프로그램과 이 프로그램들에서 만든 100여 개의 스레드가 실행중이라고 할 때 그냥 보기에는 모두 동시에 실행이 되고 있는 것처럼 보이지만 실제로는 그 컴퓨터에 CPU가 하나가 설치되어 있던 2개 또는 8개 이상의 CPU가 달려있던지 결국은 제한된 CPU의 파워를 타임슬라이스(time slice)로 쪼개서 사용하고 있는 것뿐이다. 아무리 잘 만들어진 멀티태스킹 OS라도 CPU라는 한정된 자원을 나눠 사용하는 프로세스와 스레드들이 서로 긴밀하게 협조하면서 실행되게 만들어지지 않는다면 제대로 작동하지 않게 된다.

리스트 1은 간단한 예제지만 OS의 작동을 거의 멈추게 할 수 있다. beginthreadex() 함수는 이름에서 알 수 있듯이 새로운 스레드를 시작하게 하는 함수다. _beginthread의 세 번째 파라미터는 새로운 스레드로 실행될 루틴의 시작 주소이고 네 번째 파라미터는 이 루틴의 변수값이다. 리스트 1은 아무 것도 하지 않고 무한루프를 도는 스레드 3,000개를 만드는 프로그램이다. 비주얼 C++가 깔려 있다면 컴파일하고 실행하자(이 기사의 모든 예제는 비주얼 C++ 6.0을 기준으로 만들어졌다).


    C:\>cl /MDd test.c
    (/MDd은 멀티스레드 라이브러리를 사용한다는 옵션)
    C:\>test


이 프로그램을 실행시키면 실행환경의 OS가 윈도우 NT 4.0이건 윈도우 2000이건 바로 다운된다. 물론 OS가 완전히 다운되는 것은 아니고 CPU의 대부분을 3,000개의 아무 일도 하지 않는 무한루프가 차지하기 때문에 태스크 스위칭이나 사용자의 입력을 전혀 받지 못하는 상태가 된다. 즉, 사용자의 입장에서는 어떤 입력에도 반응하지 않고 test.exe를 죽이기 위해 태스크 매니저를 띄우려고 해도 아무런 반응이 없는, 사실상 시스템이 죽은 상태가 된다. 아무리 멀티태스킹 OS라고 해도 프로세스가 아무 일도 하지 않는 무한루프 while (1) { }을 실행시키면 전체 시스템의 속도가 많이 떨어질텐데 이런 루틴 3,000개가 동시에 돌아간다고 생각하면 당연한 결과이다.

다음은 약간 다른 버전의 thread() 함수다.

    unsigned __stdcall thread(void* arg)
    {
       while (1) {
             _sleep(50);
       }

       return 0;
    }

 _sleep() 함수는 현재 실행중인 프로세스를 잠시 대기상태가 되게 하는 함수다. 파라미터는 대기상태로 있는 시간이다(단위는 밀리초). 파라미터로 1,000을 주면 1초동안, 60,000을 주면 1분동안 대기상태가 된다(대기상태에 있는 스레드나 프로세스는 CPU를 거의 사용하지 않는다). 그래서 위의 루틴은 0.05초마다 한번씩 루프를 돌게 된다. 다시 컴파일해서 실행시켜보면 사용하는 시스템마다 약간씩 다르겠지만 느려지는 느낌이 들기는 해도 별다른 무리없이 PC를 사용할 수 있다.

멀티 플레이어 온라인 게임의 서버뿐만이 아니라 다른 범용적인 목적의 멀티유저용 프로그램의 서버라도 위와 같은 과다한 스레드의 사용문제에 부딪히게 된다. 동시에 여러 명의 사용자가 접속했을 때 이들의 요구에 동시에 응답하기 위해서는 스레드의 사용이 필수겠지만 수십 개의 스레드를 만들고 무한정 사용자의 입력을 기다릴 수는 없다. 동시 유저가 1,000명이 될 것이라고 가정하고 1,000개의 스레드를 만든다면 이론적으로는 맞지만 놀고 있는 스레드들이 CPU를 대부분 사용하므로 서비스의 전체 속도는 떨어질 것이 확실하다. 이러한 문제를 막기 위해 사용자가 접속할 때마다 스레드를 만든다고 해도, 만약 이 스레드가 필요하지 않은 경우에도 실행이 되고 있다면 같은 문제에 봉착하게 된다.

따라서 충분한 수의 스레드를 만들어 스레드 풀에 넣은 후에 이 스레드들을 사용하기 전까지는 대기상태에 있게 하는 방법이 필요하다. _sleep()은 특정 시간동안 대기상태에 있게 하지만 프로그래머가 정한 특정한 때에만 스레드나 프로세스가 실행되게 하고 싶다면 Win32의 이벤트 오브젝트(event object)를 사용하면 된다. 이벤트 오브젝트를 사용하면 필요하지 않을 때는 대기상태에 두었다가 필요할 때 이벤트를 발생시켜 프로세스를 깨울 수 있다(리스트 2).

CreateEvent()는 이벤트 오브젝트를 만드는 Win32 함수이며, SetEvent()는 이벤트를 발생시키는 Win32 API 함수다. WaitForSingleObject() 함수는 하나의 특정 이벤트를 기다리는 함수로 첫 번째 파라미터는 기다릴 이벤트 오브젝트의 핸들, 두 번째 파라미터는 기다릴 시간이다(단위는 밀리초). 두 번째 파라미터를 1,000으로 주면 WaitSingleObject() 함수는 1초동안 이벤트가 일어나기를 기다린다. INFINITE는 winbase.h에 미리 선언되어 있는 상수로 이벤트를 무한히 기다리게 된다. WaitForSingleObject()는 정해진 시간에 기다리는 이벤트가 발생하지 않으면 WAIT_TIMEOUT을 리턴한다.

CreateEvent() 함수의 두 번째 파라미터는 이벤트가 발생한 후에 이벤트의 신호(signal)를 리셋할 것인지 아니면 자동으로 리셋될 것인지를 TRUE(1)/FAL SE(0) 값으로 결정한다.

이벤트 신호(signal)는 교통신호등의 신호와 같은 거의 같은 의미다. 횡단보도에서 파란불을 기다리는 자동차처럼 WaitForSingle Object()에 있는 프로세스나 스레드는 신호(signal)가 들어오기를 기다리고 있다. 이 값을 TRUE(1)로 주면 한번 신호가 들어간 후에도 계속 신호등은 파란불인 상태로 남아있게 된다. 그래서 뒤에 대기 중이던 차들도 계속 통과하는 것이다. 이 신호를 리셋하기 위해서는 프로그래머가 ResetEvent() 함수를 이용해서 이벤트의 신호(signal)를 리셋해야 한다. FALSE(0)로 주면 자동으로 리셋이 된다. 즉, 한대의 자동차가 통과하고 나면 신호등은 다시 즉시 빨간불이 되어 한번에 한대의 자동차만 지나가게 된다. 이벤트 오브젝트의 모든 사용이 끝나면 CloseHandle() 함수로 오브젝트를 다시 커널에 반환한다. 더 자세한 것은 Win32 레퍼런스 가이드나 비주얼 C++ 헬프를 참고하기 바란다.

 

리스트 2 : 이벤트 오브젝트

#include <windows.h>
#include <process.h>
#include <stdio.h>

HANDLE hEvent;

unsigned __stdcall thread(void* arg)
{
   
while (1) {
   
WaitForSingleObject(hEvent, INFINITE);
   
printf(“Hello world!\n”);
}

return 0;
}

int main()
{

    char c;
    hEvent=CreateEvent(NULL, 0, 0, “testevent”);
    _beginthreadex(NULL, 0, thread, 0, 0, NULL);

    while (1) {
     
    c = toupper(getch());
      if (c == ‘H’)
      {
       
    SetEvent(hEvent);
     
    }
    else if (c == ‘C’)
    {
       
    break;
    }

}

CloseHandle(hEvent);

return 0;

}


리스트 2를 컴파일해서 실행하고 ‘h’키를 누르면 ‘Hello world!’를 도스창에 출력하고 ‘c’키를 누르면 프로그램이 종료된다. 프로그램이 실행되면 처음에는 thread() 루틴은 WaitSingleObject() 함수에서 hEvent 이벤트를 무한히 기다리게 된다. 사용자가 ‘h’키를 누르면 SetEvent() 함수로 hEvent 이벤트를 발생하고 thread()는 대기상태에서 빠져나와 printf(“Hello world!”)를 실행하고 다시 WaitSingleObject() 함수에서 무한히 hEvent 함수를 기다리게 된다. ‘c’값을 눌러 루프를 벗어나면 프로그램은 끝난다. 이때 따로 스레드를 닫지 않아도 thread()를 실행중인 스레드는 메인 프로세스의 자식 프로세스이기 때문에 OS에 의해 자동으로 없어진다. 하지만 thread() 스레드가 끝나고 데이터의 초기화나 다른 작업이 필요할 때는 어떻게 할까? 리스트 3을 보자.

리스트 2에서 한 개의 이벤트 오브젝트를 사용한 것에 반해 리스트 3은 3개의 이벤트 오브젝트를 사용하고 있다.

한번에 여러 개의 이벤트 오브젝트를 기다리기 위해 이벤트 오브젝트 Win32 API 함수 중에서 WaitFor MultipleObjects() 함수를 사용하고 있다. WaitFor MultipleObjects()는 WaitForSingleObject()와 달리 하나의 이벤트 오브젝트가 아니라 한 개 이상의 여러 개의 이벤트를 기다릴 수 있다.

WaitForMultipleObjects() 함수의 첫 번째 파라미터는 기다릴 이벤트의 개수, 두 번째 파라미터는 기다릴 오브젝트들, 세 번째 파라미터는 여러 개의 오브젝트 중 하나를 기다릴 것인지 아니면 여러 개의 이벤트 오브젝트를 모두 기다릴 것인지를 정한다. 이 값을 TRUE(1) 값으로 설정하면 기다리고 있는 여러 이벤트가 모두 발생해야 대기상태를 빠져나가고, FALSE(0) 값을 주면 기다리고 있는 이벤트들 중에서 하나의 이벤트만 발생해도 대기상태를 벗어나게 된다.

WaitForMultipleObjects() 함수의 리턴값은 발생한 이벤트의 인덱스값이나 에러가 발생할 경우의 에러코드다. 리스트 3에서는 만약 hHelloEvent 이벤트가 발생하면 0을, hByeEvent 이벤트가 발생하면 1을 리턴한다.

 

리스트 3 : 복수의 오브젝트 이벤트


#include <windows.h>
#include <process.h>
#include <stdio.h>

HANDLE hHelloEvent;
HANDLE hByeEvent;
HANDLE hCloseEvent;

unsigned __stdcall thread(void* arg)
{
     
HANDLE events[] = { hHelloEvent, hByeEvent };
      DWORD dwReturn;


     
while (1) {
      dwReturn = WaitForMultipleObjects
(2, events, FALSE, INFINITE);

      if (dwReturn == 0) {
      printf(“Hello world!\n”);
     
}

      else if (dwReturn == 1) {
     
break;
      }

      else {

      // 에러
     
}
      } // End of while

      printf(“Bye\n”);
      SetEvent(hCloseEvent);
     
return 0;
}
 

int main()
{

   char c;
   
hHelloEvent=CreateEvent(NULL,0,0,“helloevent”);
   
hByeEvent = CreateEvent(NULL, 0, 0, “byeevent”);    hCloseEvent=CreateEvent(NULL,0,0,“closeevent”);

   _beginthreadex(NULL, 0, thread, 0, 0, NULL);

   while (1) {

    c = toupper(getch());

   
if (c == ‘H’) {
    SetEvent(hHelloEvent);
   
}
    else if (c == ‘C’) {
    SetEvent(hByeEvent);
   
break;
    }

    } // End of While

   WaitForSingleObject(hCloseEvent, INFINITE);

   printf(“Closed”);
   CloseHandle(hHelloEvent);
   CloseHandle(hByeEvent);
   CloseHandle(hCloseEvent);

   return 0;

}



리스트 3을 컴파일하고 실행시킨 후 ‘h’키를 누르면 SetEvent(hHelloEvent)가 hHelloEvent를 발생해서 도스창에 ‘Hello world!’를 출력하는 것은 리스트 2와 같지만 ‘c’키를 누르면 hByeEvent 이벤트가 발생된다. WaitForMultipleObjects()는 hHelloEvent 이벤트 뿐만 아니라 hByeEvent 이벤트 역시 기다리고 있으므로 루프를 벗어나 ‘Bye’출력을 하고 hCloseEvent 이벤트를 발생시킨다. 이때 main()에서는 ‘c’키를 눌렀기 때문에 루프를 벗어나서 다음 라인에 있는 WaitFor SingleObject(hCloseEvent, INFINITE); 에서 hClos eEvent 이벤트를 기다리고 있기 때문에 대기상태에서 벗어나게 된다. 프로그램은 ‘Cloed’를 출력하고 종료된다.

멀티스레드 프로그래밍에서는 이와 같이 이벤트 오브젝트를 이용하거나 전역변수를 사용하면 스레드 간 통신 문제를 해결할 수 있다. 하지만 이외에도 여러 개의 스레드가 같은 데이터를 사용할 때 문제가 발생할 수 있다.

예를 들어 A, B, C 3개의 스레드가

int data = 0;

의 값을 모두 공통으로 사용한다고 할 때, 동시에 2개의 스레드가 data에 1을 더하는 오퍼레이션을 실행한다면 data의 값이 1이 될 것인지 아니면 2가 될 것인지는 아무도 모른다. 이렇게 스레드들이 사용하는 데이터가 숫자값이라면 그냥 틀리는 문제로 넘어가겠지만 만약 링크드 리스트나 트리같은 데이터 구조일 때는 운이 좋으면 데이터의 구조가 깨질 것이고, 운이 나쁘다면 프로그램 자체가 access violation 에러를 발생시키고 다운될 것이다.

이러한 문제를 피하기 위해 Win32 API에는 동기화 함수들(Synchronization Functions)이 준비되어 있다. 이 글에서는 모든 함수들과 오브젝트들을 다룰 수는 없기 때문에 CRITICAL_SECTION과 Interlocked 함수들만 다루도록 하겠다(Win32 동기화 함수들에 대해 더 알고 싶은 사람들은 Win32 API 레퍼런스 매뉴얼을 찾아보거나 MSDN 유저는 동기화(Synchronization)항목을 찾아보기 바란다). 크리티컬 섹션(Critical section)은 중대, 치명적이라는 Critical의 의미대로 프로그램 내에 한번에 한 개의 스레드만 진입이 필요한 영역을 의미한다. 크리티컬 섹션으로 정의된 영역은 어떤 스레드가 크리티컬 섹션에 들어가려고 해도 이미 다른 스레드가 이 영역에 들어가 있는 상태라면 뒤에 진입하려는 시도를 했던 스레드는 대기상태로 들어간다. 그리고 먼저 이 영역에 들어갔던 스레드가 이 영역을 벗어나면 대기상태에 있던 스레드는 크리티컬 섹션으로 들어가게 된다. Win32에서 크리티컬 섹션은 코드에서 크리티컬 섹션이라고 정의하는 것이 아니라(C나 C++에는 불행히 이런 문법이 없다) CRITICAL_SECTION이라는 스트럭처를 이용해서 가상으로 정의해서 사용한다. 즉, 같은 CRITICAL_SECTION 스트럭처를 사용하는 스레드는 같은 크리티컬 섹션으로 진입하는 스레드로 간주된다. 크리티컬 섹션으로 들어가는 API는 Enter Critical Section(), 벗어났다고 알리는 API는 Leave CriticalSec tion()이다. 자세한 내용은 다음 예제를 통해 살펴보자. Interlocked 함수는 이름이 Interlocked로 시작되는 함수들로 특정 32비트 변수에 대해 한 개 이상의 스레드가 동시에 접근하는 것을 막는 함수들이다. Interlocked 함수들은 다음과 같은 것들이 있다.

InterlockedCompareExchange
InterlockedCompareExchangePointer
InterlockedDecrement
InterlockedExchange
InterlockedExchangeAdd
InterlockedExchangePointer
InterlockedIncrement

이 중 예제에서 사용할 InterlokcedIncrement를 보자. 함수의 스펙은 아래와 같다. 이 함수는 32비트 변수값을 1 증가시키는 기능을 한다.

LONG InterlockedIncrement(LPLONG lpAddend);

파라미터는 32비트 변수의 주소이고 리턴값은 증가된값이다. 즉

int nNumber = 1;
int nResult;
nResult = InterlockedIncrement((long*) &nNumber);

의 결과는 nNumber값은 2가 되고 nResult에도 nNumber의 증가값인 2가 저장된다. 아래 예제는 Critical section과 Interlocked 함수를 사용한 한 개의 데이터값을 여러 개의 스레드가 동시에 사용하는 프로그램이다.

리스트 4는 10개의 스레드가 하나의 변수 int data을 랜덤한 시간 간격(0~1000밀리초)으로 1씩 증가시키고 이를 화면에 출력하는 프로그램이다. 이때 크리티컬 섹션을 사용하지 않는다면 int data의 값이 어떻게 될까? data++ 오퍼레이션은 실행시간이 짧기 때문에 data=1, data=2, data=3... 순으로 화면에 그려질 것이다. 낮은 확률이지만 동시에 2개 이상의 스레드가 int data값을 바꿔 data=1022, data=1022, data=1023과 같은 결과가 나올지도 모른다.

Win32 Critical section API를 사용하는 방법은 InitializeCriticalSection()으로 CRITICAL_SECT ION 스트럭처를 초기화하고 크리티컬 섹션에 들어갈 때는 EnterCriticalSection() 함수를 사용하고, 나올 때는 LeaveCriticalSection() 함수를 사용하면 된다. 사용이 끝났다면 DeleteCriticalSection()으로 크리티컬 섹션 오브젝트를 제거한다.

리스트 4를 컴파일하고 실행시키면 data=1 data=2 data=3... 이 화면에 계속 출력되고 아무키나 누르면 실행이 종료될 것이다. main()에서는 0.5초 간격으로 종료가 끝난 스레드의 숫자를 체크하며 루프를 돌다 모든 스레드 종료가 확인되면 프로그램을 끝내게 된다. 종료된 스레드의 숫자는 InterlockedIncrement()를 사용해서 증가시킨다. 동시에 여러 개의 스레드가 int closethread의 값을 증가시키려 해도 한번에 하나의 스레드만 int closedthread의 값을 증가시키기 때문에 데이터의 무결성은 보장된다.

 

리스트 4 : Critical section과 interlocked 함수


#include <windows.h>
#include <stdio.h>
#include <process.h>

CRITICAL_SECTION cs;

HANDLE hCloseEvent;
int data = 0;
int end = 0;
int closethread = 0;

unsigned __stdcall thread(void* arg)
{

   srand((int) arg);

   while (end == 0) {

   _sleep(rand() % 1000); // 0~1초간 기다린다    EnterCriticalSection(&cs);

   // critical section에 진입

   data++;
   printf(“data = %d\n”, data);
   LeaveCriticalSection(&cs); // critical section을 벗어났다
   }

   InterlockedIncrement((long*) &closethread);

   return 0;
}
 

int main()
{
   int i;
   
int seed;

   hCloseEvent=CreateEvent(NULL, 0, 0, “closeevent”);
   InitializeCriticalSection(&cs);

   for (i = 0, seed = 1033; i < 10; i++, seed += 2321)
   {
     
_beginthreadex(NULL, 0, thread, (void*) seed, 0, NULL);
   }

   getch();
   
end = 1;

   while (closethread < 10)
   {
   _sleep(500);
   
}

   DeleteCriticalSection(&cs);

   return 0;
}



리스트 4에서 사용한 크리티컬 섹션이나 다루지 않은 세마포어(semaphore), 뮤텍스(mutex) 등의 동기화(Synchronization) 방법을 사용하면 멀티스레드 프로그램에서 다수의 스레드가 하나의 공용 데이터를 사용한 것에 대한 무결성을 보장할 수 있다. 하지만 크리티컬 섹션, 뮤텍스, 세마포어 등을 이용해서 특정 데이터 사용 전에 락(lock)을 걸고 데이터의 사용이 끝난 후에 락(lock)을 푸는 것으로 데이터의 무결성을 보장할 수 있을지 모르지만, 여러 개의 락을 사용할 때 잘못된 순서/방법으로 사용할 경우 쉽게 데드락(dead lock)을 초래할 수도 있다. 리스트 5를 보자.

 

리스트 5: 데드락 (dead lock)

.....

EnterCriticalSection(&cs1);
db.delete_user(“user1”);

EnterCriticalSection(&cs2); // -> A
db.delete_item(“user1”);

LeaveCriticalSection(&cs2);
LeaveCriticalSection(&cs1);

.....

EnterCriticalSection(&cs2);
item = db.find_item(“user2”); // -> B

EnterCriticalSection(&cs1);
user = db.find_target_user(item->target);
user->damage(10);

LeaveCriticalSection(&cs1);
EnterCriticalSection(&cs2);

.....


리스트 5의 두 코드는 가상의 머그 서버에서 돌아가는 코드로 게임 내의 데이터베이스에 액세스하는 코드들이다. 이 가상의 서버는 동시에 수백 명의 사용자를 감당하기 위해 수십 개의 스레드를 사용하고 있고, 게임 내의 캐릭터가 죽거나 이동/로그인/로그아웃하고 아이템의 사용/이동이 빈번하기 때문에 2개의 CRITICAL_SECTION cs1과 cs2를 사용해서 유저 데이터를 관리한다. cs1은 유저의 데이터를, cs2는 아이템의 데이터를 액세스할 때 사용하는 크리티컬 섹션 오브젝트다.

언뜻 보기에는 맞는 것 같다. 하지만 만약 X라는 스레드가 리스트 5의 A 부분에서 cs2를 사용해서 cs2의 크리티컬 섹션으로 들어가려고 시도중이고, Y라는 스레드는 B 부분에서 cs1의 크리티컬 섹션으로 진입을 시도하고 있다면 X 스레드가 cs2를 벗어나야 Y 스레드가 cs1에 진입할 수 있고, X 스레드는 Y 스레드가 cs1을 벗어나야 cs2로 들어갈 수 있다. X 스레드와 Y 스레드가 서로 돌려줄 수 없는 것을 기다리고 있으므로 영원히 기다려야 한다. 명백한 데드락(dead lock)이다.

위의 데드락은 쉬운 편이라 누구나 쉽게 찾을 수 있는 것이지만 수천 명의 유저가 동시에 플레이 가능한 머그 게임의 서버일 경우, 전체 서버에 사용되는 락(lock) 오브젝트의 숫자가 동시 접속중인 사용자의 2~3배가 되는 경우가 빈번히 일어날 수 있다. 이러한 경우에 데드락을 막는 것은 매우 어렵다. 한 개의 스레드가 데드락이 될 경우 얼마 후에 데드락이 걸린 스레드에 사용된 락에 접근하는 모든 스레드가 같이 데드락에 빠지게 된다. 이런 데드락 문제는 실행환경의 복잡도가 올라갈수록 발생할 확률이 높아지기 때문에 동시 접속자의 숫자가 많을 때 발생하기 쉽고 같은 서버 프로그램이라도 사용자의 숫자가 적다면 발생확률이 낮다. 이런 버그는 개발기간에는 발생 자체가 힘들다. 서비스와 동일한 조건을 맞추기 위해서는 테스트 요원을 수천 명씩 뽑아야 하기 때문에 찾는 것뿐만 아니라 디버깅도 매우 힘들다. 따라서 프로그래머는 이러한 락(lock) 오브젝트들의 사용에 자신만의 규칙을 세우고 코딩 때는 반드시 이 원칙을 지켜서 개발을 해야할 필요가 있다.

리스트 5의 데드락 문제는 두 개의 크리티컬 섹션을 사용할 때 반드시 cs1를 통과한 다음 cs2의 크리티컬 섹션으로 진입하는 것으로 진입 순서를 정하면 해결이 가능하다. 즉, 아래와 같이 사용하면 데드락은 피할 수 있다.

    EnterCriticalSection(&cs1);

    ...

    EnterCriticalSection(&cs2);

    ...

    LeaveCriticalSection(&cs2);

    ...

    LeaveCriticalSection(&cs1);


클라이언트 & 서버 프로그래밍

그래픽 머드는 텍스트 머드에서 발전한 것이고 텍스트 머드는 채팅에서 나왔다. 이런 진화과정을 보면 그래픽 머드의 기본원리가 사실은 채팅과 그렇게 다르지 않다고 생각할 수도 있다. 최근에 실제 상용 서비스 중인 여러 채팅 서비스들이 채팅의 원래 기능인 텍스트의 전송 이외에 여러 가지 부가 서비스를 지원하고 있지만, 채팅 서비스의 기본 서비스는 ‘같은 채팅방에 있는 사람들에게 한사람이 전송하는 문장을 전파한다(broadcast)’일 것이다. 간단한 형태의 채팅서버를 만들고 싶다면 아래의 조건을 만족하는 서버 프로그램을 만들면 된다.

① 소켓을 열고 새로운 사용자가 접속하길 기다린다.
② 사용자가 접속하면 접속자 리스트에 추가한다.
③ 접속자가 문장을 서버로 보내면 모든 접속자에게 문장을 전송한다.
④ 접속이 끊기면 접속자 리스트에서 삭제한다.


클라이언트 쪽은

① 소켓을 열고 서버에 접속한다.
② 사용자가 문자를 입력하고 엔터키를 치면 서버에 문자열을 전송한다.
③ 서버에서 문자열을 보내면 화면에 출력한다.

클라이언트 쪽은 소켓프로그래밍에 대한 약간의 지식이 있다면 그리 어렵지 않게 구현할 수 있을 것이다. 서버쪽 기능들은 앞에서 다룬 멀티스레드를 이용, 2개의 스레드 루틴을 만드는 것으로 간단히 구현이 가능하다. 하나는 사용자의 접속을 처리하는 스레드이고 두 번째는 접속한 사용자를 처리하는 스레드이다. 사용자의 접속을 처리하는 스레드를 ServerThread라 하고 접속한 클라이언트의 패킷처리와 소켓관리를 처리하는 스레드를 UserThread라 부르기로 하자. 이 두 개의 스레드를 간단한 슈도(suedo) 코드로 정의하면 리스트 6과 같다.

 

리스트 6: ServerThread와 UserThread의 슈도코드


ServerThread()
{
   
init_socket(); // 소켓초기화

   
while (true) {
   
socket = accept(); // 새 접속을 기다린다
   
user = new user(socket);

   // 새로운 유저 오브젝트 생성
   
userlist.add(user); // 사용자 추가
   createUserThread(user);
// 새 UserThread()를 만든다
   
}
}

UserThread(user)
{
   while (true) {
   
event = wait_event(user.socket) // 소켓의 이벤트를 기다린다

   if (event == event_close) { // 소켓이 닫혔다는 이벤트 발생

   userlist.delete(user); // 사용자 삭제
   
break; // 루프를 벗어난다
   }

   else if (event == event_read) { // 읽기 이벤트 발생
   
string = read_socket(); // 소켓을 읽는다
   // 접속한 모든 사용자에 string을 보낸다
   userlist.send_string_to_all_user(string);
   
}

   }
}
 


ServerThread()는 처음에 서버 프로그램이 시작할 때 하나가 만들어진다. ServerThread()는 처음에 소켓을 초기화하고 초기화가 끝나면 새 사용자가 접속하기를 무한히 기다린다. 접속을 기다리다 새로운 사용자가 접속할 때마다 ServerThread()는 새로운 User 오브젝트를 만들고 새롭게 열린 소켓을 처리할 UserThread()를 만든다.

UserThread()는 wait_event(user.socket) 소켓에서 발생하는 사건(소켓이 닫혔다거나 아니면 소켓에 읽기 준비가 되었다 등의 이벤트)을 기다린다. 이때 소켓이 끊기거나 읽을 데이터가 소켓에 들어오면 발생한 이벤트를 처리한다. 소켓이 닫히는 이벤트가 발생하면 루프를 벗어나 UserThread()를 끝내고, 읽기 이벤트라면 소켓을 읽고난 후 읽은 문자 데이터를 현재 접속해 있는 접속자들(userlist가 보관하고 있는)에게 보낸다.

알고리즘을 설명하는 슈도코드에서는 이런 편리한 문법이 가능하지만 실제 코딩 때는 이러한 문법이 없으니 이렇게 쉽게 구현하는 것이 어렵다고 생각할 것이다. 하지만 WSA(Windows Socket API)를 사용하면 소켓 핸들에 특정 이벤트를 설정하는 것이 가능하다. 이것은 다음 예제에서 설명하도록 하겠다.

userlist 오브젝트는 사용자의 리스트를 관리하는 오브젝트로 여러 개의 스레드가 사용되기 때문에 내부적으로는 CRITICAL_SECTION이나 뮤텍스(mutex) 또는 세마포어(semaphore)같은 락(lock)을 사용해서 여러 개의 스레드가 동시에 userlist에 접근하더라도 데이터의 무결성을 보장해야 한다. 위의 예는 채팅서버를 만들기 위한 알고리즘이지만 몇 가지 추가 사항을 제외하고는 그래픽 머드 서버를 만드는 것과 별로 다르지 않다. 채팅 서비스에서는 채팅 서버와 클라이언트는 누가 무슨 말을 했는지에 대한 문자열에 관한 정보만 주고받는다. 채팅 클라이언트는 사용자가 타이핑한 문자열을 채팅 서버에 보내고 채팅 서버는 전달받은 문자열을 접속해 있는 사용자들에게 전해준다.

그래픽 머드의 서버/클라이언트도 별반 다르지 않다. 전달되고 주고받는 데이터가 단지 문자열이 아니라 여러 가지 타입의 패킷이라는 것이 다를 뿐이다. 리스트 7은 UserThread()의 그래픽 머드용 버전이다.

 

리스트 7 : 그래픽머드용 UserThread() 슈도코드


UserThread(user)
{
    while (true) {
         event = wait_event(user.socket)
         // 소켓의 이벤트를 기다린다
         if (event == event_close) { // 소켓이 닫히면...
             userlist.delete(user) // 사용자 삭제
             break // 루프를 벗어난다
         }
         else if (event == event_read) {
             // 읽기 이벤트 발생
             packet = read_socket() // 소켓을 읽는다
            user.packet(packet)
         }
    }
}

user::packet(packet)
{
    switch (packet) {
    case move :
           move(packet); // user를 이동한다
           userlist.seemove(user);
           // 접속자들에게 움직임을 알린다
           break;

    case say :
           userlist.seesay(packet); // 채팅을 처리한다
           break;
    }
}

 


그럼 실제 구현된 간략화된 형태의 머그 클라이언트와 서버를 보자(그림 1). 클라이언트는 게임화면과 채팅내용, 메시지가 나타나는 로그(log)창, 그리고 채팅 내용을 입력하는 입력창의 구성으로 되어있다. client.exe를 실행한 후 file 메뉴에서 login을 선택하면 그림 2의 대화상자가 열린다. Address에 서버의 주소를 입력하고 Name에 원하는 게임 내의 캐릭터 이름을 입력하고 확인 버튼을 누르면 본 게임에 접속하게 된다. 테스트 서버/클라이언트가 지원하는 기능은 채팅과 캐릭터의 이동뿐으로 공격같은 것은 되지 않는다. 캐릭터와 배경도 그래픽 이미지가 아니라 아스키 코드로 이루어져 있다. 방향키를 누르면 누른 방향으로 캐릭터(U자)가 이동할 것이다. 하지만 만약 이동할 위치에 다른 유저가 있거나 벽이 있다면 캐릭터는 움직이지 않는다.

서버와 클라이언트 중 클라이언트 쪽은 오직 서버와 통신만 하는 일반적인 윈속(Winsock) 프로그램이기 때문에 별다른 테크닉이 사용되지 않았다. 따라서 이 글에서는 서버 쪽을 중점적으로 설명하겠다.

 

리스트 8 : server.cpp의 일부


World g_world;
int g_nPort = 1001;
SOCKET g_hListener = NULL;
LONG APIENTRY WndProc(HWND hWnd, UINT message,

WPARAM wParam, LPARAM lParam)
{
   switch (message) {

   case WM_CREATE :

   // 서버 윈도우가 생길 때 ServerThread 스레드를 만든다.
   _beginthreadex(NULL, 0, ServerThread, 0, 0, NULL);
   ....
 
}

  unsigned __stdcall ServerThread(void* pArg)
 
{

  char szBuffer[1024];
 
WSADATA wd;

  // winsock 초기화
 
If (WSAStartup(0x0202, &wd) != 0) {

  sprintf(szBuffer, “WSAStartup error code=%x”, WSAGetLastError());
 
SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
  return 0;
  }

  g_hListener = socket(AF_INET, SOCK_STREAM, 0);
 
 
if (g_hListener == INVALID_SOCKET)
  {
     
sprintf(szBuffer, “socket error code=%x”,
     
WSAGetLastError());
     SendMessage(g_hwndLog, WM_USER_LOG, 0,
(LPARAM) szBuffer);
     
return 0;
 
}

  struct sockaddr_in sa;
 
memset(&sa, 0, sizeof(sa));
 
sa.sin_family = AF_INET;
  sa.sin_addr.s_addr = htonl(INADDR_ANY);
  sa.sin_port = htons(g_nPort);

  if (bind(g_hListener, (struct sockaddr*) &sa, sizeof(sa)) != 0)
  {

    sprintf(szBuffer, “bind error code=%x”, WSAGetLastError());
    SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
    return 0;
  }

  if (listen(g_hListener, 5) != 0)
  {

   
sprintf(szBuffer, “listen error code=%x”, WSAGetLastError());
    SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
    return 0;
  }

  while (true)
  {
 
     struct sockaddr_in ca;
     
int clientAddressLength = sizeof(ca);
      int nLength = sizeof(sa);
      SOCKET hSocket = accept(g_hListener,
(struct sockaddr*) &ca, &nLength);

      if (hSocket == INVALID_SOCKET) {
     
continue;
      }

      sprintf(szBuffer, “new connection  %d.%d.%d.%d”,
      sa.sin_addr.S_un.S_un_b.s_b1,
sa.sin_addr.S_un.S_un_b.s_b2,      
sa.sin_addr.S_un.S_un_b.s_b3, sa.sin_addr.S_un.S_un_b.s_b4);

     
Log(szBuffer);

      User* pUser = new User(hSocket);
      g_world.AddUser(pUser);             // 새로운 사용자 등록

      _beginthreadex(NULL, 0, UserThread, pUser,
0, NULL);

      // 새로운 UserThread 스레드를 만든다.
     
pUser->Send(S_NAME);   // 클라이언트에 사용자의 이름을 요구한다

  }  // End of while

  if (g_hListener != 0)
  {
 
    if (closesocket(g_hListener)==SOCKET_ERROR)
     
{
         sprintf(szBuffer, “closesocket error
code=%x”, WSAGetLastError());          SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
     
}
 
}

  WSACleanup();
 
return 0;
}
 


리스트 8은 ServerThread()를 실제로 구현한 코드로 클라이언트/서버 중 서버의 코드로 server.cpp에 있는 함수다. 알고리즘은 리스트 6의 슈도코드와 거의 비슷하므로 알고리즘 자체의 이해에는 어려움이 없을 것이다. ServerThread()는 서버의 메인 윈도우가 만들어진 후에 스레드로 실행되는 함수로 우리가 만들 머그 서버의 메인 루틴이다. Winsock 부분은 전형적인 Winsock 서버의 코드들이다. WSAStartup()으로 Winsock을 초기화시키고, 소켓(socket() 테스트 서버가 사용하는 1001번이다)을 열고, 이름을 정하고(bind()) 접속을 기다리고(listen()), 접속이 신청되면 허락한다(accept()).

새로운 접속이 생기면 new User(hSocket)로 새 유저 오브젝트를 만들고 g_world에 등록한다. 그리고 등록한 클라이언트에 접속한 유저의 이름을 묻는 패킷 S_NAME을 보낸다. g_world는 전체 게임세계에 있는 모든 오브젝트를 관리하는 클래스의 인스턴스로 접속한 사람들의 이름/위치/id를 가지고 있고, 전체 게임 내의 맵(map) 데이터를 가지고 있다. 이 맵 데이터를 이용해서 사용자의 캐릭터 이동시 벽이나 장애물 또는 접속해 있는 다른 사용자의 캐릭터들과의 충돌체크를 한다. ServerThread()는 새 접속자의 등록이 끝나면 접속한 사람별로 UserThread() 스레드를 실행한다.

이 테스트용 클라이언트/서버 환경에서 사용되는 패킷은 아래와 같은 구조로 되어 있다. 패킷의 처음 2바이트는 전체 패킷의 길이가 저장되고, 세 번째 바이트에는 패킷의 번호가 저장된다. 네 번째 바이트부터 패킷의 끝까지는 패킷의 몸체(body)가 저장된다.

User::Send(char)는 패킷몸체가 없는 패킷을 보내는 함수다. 패킷몸체가 없고 패킷번호만 보내기 때문에 이 함수로 보내지는 패킷은 항상 3바이트의 크기만 가진다는 것을 알 수 있다. 서버에서 클라이언트로 전달되는 패킷 중 S_MOVE라는 패킷이 있다. 이 패킷은 움직인 유저의 id(4바이트)와 x(4바이트), y(4바이트)로 구성되어 있다.

이 패킷을 인코딩하는 함수는 아래와 같다(SetChar(), SetInteger(), SetShort(), GetChar(), GetInteger(), GetShort()는 서버와 클라이언트가 공통으로 사용하는 util.cpp에 있다).

    char szPacket[512];
    char* pPacket = szPacket + 2;

    pPacket = SetChar(pPacket, S_MOVE);
    // 3번째 바이트에 패킷번호
    pPacket = SetInteger(pPacket, pUser->Id());
    // 4번째 바이트부터 id
    pPacket = SetInteger(pPacket, pUser->X());
    // 8번째 바이트부터 x
    pPacket = SetInteger(pPacket, pUser->Y());
    // 12번째 바이트부터 y
    SetShort(szPacket, pPacket - szPacket);
    // 0번째에 2바이트의 패킷 길이

    클라이언트 측에서 S_MOVE 패킷의 디코딩은 아래와 같다.

    Int nId;
    int nX;
    int nY;

    pPacket = GetInteger(pPacket, nId);
    pPacket = GetInteger(pPacket, nX);
    pPacket = GetInteger(pPacket, nY);

    WSAEVENT hEvents[] = { hEvent1, hEvent2, hEvent3 };

    ...

    WSAWaitForMultipleEvents(3, hEvents, FALSE, WSA_INFINITE, FALSE);

    ...

     

리스트 9, 10 : Server.cpp의 UserThread()와 user.cpp


UserThread()

unsigned __stdcall UserThread(void* pArg)
{
    User* pUser = (User*) pArg;
    enum MODE { PACKET_LENGTH, PACKET_BODY };
    MODE mode = PACKET_LENGTH;

    char szBuffer[512];
    short sPacketLen;
    int nPI = 0;
    int nRI = 0;

    WSAEVENT hRecvEvent = WSACreateEvent();
    int nRead;

    WSANETWORKEVENTS event;

    // 소켓의 읽기와 닫기를 이벤트로 정의
    WSAEventSelect(pUser->Socket(), hRecvEvent, FD_READ | FD_CLOSE);
    while (g_bShutDown == false)
    {

    // 이벤트를 기다림
    DWORD dwReturn =     WSAWaitForMultipleEvents(1,&hRecvEvent,FALSE,WSA_INFINITE, FALSE);

    if (dwReturn == WSA_WAIT_FAILED) { // 에러발생
    int nError = WSAGetLastError();
    }
    else if (dwReturn == 0) { // 이벤트 발생
    // 이벤트 신호를 리셋
    WSAResetEvent(hRecvEvent);
    }

    while ((nRead=recv(pUser->Socket(),(char*)  szBuffer + nPI, 512 - nPI, 0)) > 0)
    {
          nRI += nRead;

    while (nPI < nRI)
    {
        if (mode == PACKET_LENGTH)
        {
            if (nPI + 2 <= nRI)
            {
                // 패킷의 길이
                sPacketLen = szBuffer[0] + (szBuffer[1] << 8) - 2;
                nPI += 2;
                mode = PACKET_BODY;
            }
            else
            {
                break;
            }
        }
        else if (mode == PACKET_BODY)
        {
            if (nPI + sPacketLen <= nRI)
            {
                    // 패킷번호가 맞는지 확인
                    if (szBuffer[nPI] >= C_MAX)
                    {
                    }
                    else
                    {

                        // 받은 패킷 처리
                        pUser->Packet(szBuffer + nPI);
                    }
                   nPI += sPacketLen;
                mode = PACKET_LENGTH;
            }
        }
    }
    if (nPI == nRI)
    {
        nPI = nRI = 0;
    }
    else if (nPI > 0)
    {
        memmove(szBuffer, szBuffer + nPI, nRI - nPI);
        nRI -= nPI;
    }
}

// 발생한 이벤트의 종류 확인
WSAEnumNetworkEvents (pUser->Socket(), hRecvEvent, &event);

// FD_CLOSE 이벤트면 스레드 종료
if ((event.lNetworkEvents & FD_CLOSE) == FD_CLOSE)
{
        g_world.Remove(pUser); // 등록 사용자 삭제
        break;
}

} // End of While

WSACloseEvent(hRecvEvent); // 이벤트 오브젝트 닫기
return 0;

}
 

user.cpp

void User::Packet(char* pPacket)
{

    switch (*pPacket++)
    {
        case C_MOVE :
        if (IsAlive() == true)
        {
            g_world.Move(this, (char) *pPacket);
        }
        break;

        case C_SAY :
        {
                char szBuffer[1024];
                GetString(pPacket, szBuffer,
                sizeof(szBuffer));
                g_world.Say(this, szBuffer);
        }
        break;

        case C_ATTACK :
        {
                if (IsAlive() == true)
                {
                    unsigned int nVictim;
                    GetInteger(pPacket, (int&) nVictim);
                    g_world.Attack(this, nVictim);
                }
        }
        break;

        case C_NAME :
        {
                char szBuffer[1024];
                GetString(pPacket, szBuffer,
                sizeof(szBuffer));
                SetName(szBuffer);
                g_world.Enter(this);
        }
   }
}

void User::Send(char cPacket)
{
       char szPacket[4];
       SetShort(szPacket, 3);
       SetShort(szPacket + 2, cPacket);
       send(m_hSocket, (const char*) szPacket, 3, 0);
}


WSA로 시작하는 함수들은 Win32에서 지원하는 윈속 함수들이다. WSAEventSelect() 함수는 소켓핸들에 네트워크 이벤트가 정의된 이벤트 오브젝트를 설정한다.

설정한 소켓에 지정한 네트워크 이벤트가 발생하면 이벤트 오브젝트에 신호(signal)가 세팅된다. 따라서 만약 이 이벤트 오브젝트를 기다리고 있는 프로세스가 있다면 그 프로세스는 대기상태에서 벗어나게 된다.

WSAEVENT hRecvEvent = WSACreateEvent();

...

WSAEventSelect(pUser->Socket(), hRecvEvent, FD_READ | FD_CLOSE);

pUser->Socket()은 소켓핸들을 리턴하는 User 클래스의 멤버 함수다. 위의 코드는 hRecvEvent 이벤트 오브젝트에 FD_READ와 FD_CLOSE 네트워크 이벤트를 설정하고 pUser->Socket() 소켓에 hRecvEvent 오브젝트를 연결한다. 이렇게 하면 pUser->Socket()에 읽기 준비가 되었거나 소켓이 닫히면 hRecEvent 이벤트가 발생한다. 이때 주의할 것은 하나의 소켓에는 하나의 이벤트 오브젝트만 연결이 가능하다. 즉, 아래와 같은 코드는 잘못된 코드다.

WSAEventSelect(hSocket, hEvent1, FD_READ);
WSAEventSelect(hSocket, hEvent2, FD_CLOSE);

두 번째 줄이 실행될 때 첫줄에서 정의한 FD_READ 이벤트는 취소되고 hEvent2에 정의된 FD_CLOSE 네트워크 이벤트만 정상적으로 작동한다. 소켓에 정의된 이벤트를 모두 취소하고 싶다면 아래와 같이 하면 된다.

WSAEventSelect(hSocket, hEvent, 0);

네트워크 이벤트를 기다리는 함수는 앞에서 다루었던 WaitForMultipleObject()와 비슷한 기능을 하며 한 개 이상의 복수 이벤트를 기다릴 수 있다. 네트워크 이벤트를 기다리는 함수에는 WaitForSingleObject()와 같이 한 개의 이벤트만 기다리는 함수는 없다.

WSAWaitForMultipleEvents(1, &hRecvEvent, FALSE, WSA_INFINITE, FALSE);

WSAWaitForMultipleEvents()에서 사용되는 파라미터 역시 WaitForMultipleObject()와 비슷하다.

첫 번째 파라미터는 기다릴 이벤트 오브젝트의 갯수, 두 번째 파라미터는 기다릴 이벤트 오브젝트들의 시작주소, 세 번째 파라미터는 모든 오브젝트를 기다릴 것인지 아니면 오브젝트들 중의 하나만 기다릴지를 결정한다. 네 번째 파라미터는 기다릴 시간이다. WSA_INFINITE값을 주면 영원히 기다리게 된다. 리턴값은 DWORD값으로 발생한 이벤트의 순서를 의미한다. 리스트 10에서는 한 개의 오브젝트를 기다리고 있으므로 항상 0을 리턴할 것이다. 앞에서와 같이 3개의 이벤트를 기다리고 있을 경우 두 번째 이벤트인 hEvent2가 발생하면 리턴값은 1이 될 것이다.

WSAEnumNetworkEvents(pUser->Socket(), hRecvEvent, &event);

한 개의 이벤트 오브젝트에 여러 개의 네트워크 이벤트를 지정하기 때문에 실제 발생한 네트워크 이벤트를 알아내기 위해서는 WSAEnumNetworkEvents()을 이용해서 발생한 이벤트의 상세 정보를 알아내면 된다. 위의 코드는 event structure에 발생한 이벤트의 상세정보를 채운다. event.lNetworkEvents에 실제 발생한 이벤트의 값이 저장된다. FD_CLOSE 이벤트 처리 때 중요한 것은 소켓이 닫혀 FD_CLOSE 이벤트가 발생하더라도 아직 소켓의 버퍼에 읽지 않은 패킷이 남아 있을 수 있다. 그러므로 FD_CLOSE 이벤트가 발생해서 소켓이 끊어진 것이 확인되더라도 recv()에서 -1이 리턴될 때까지 recv()를 콜할 필요가 있다.
 

리스트 11은 world.cpp와 world.h의 일부이다. World 클래스는 접속한 사용자들의 데이터와 전체 월드의 맵 데이터를 관리한다. 맵 데이터는 캐릭터의 이동시 다른 캐릭터와 벽들 간의 충돌체크에 사용된다. m_mapUser는 C++의 standard library를 사용해서 만든 오브젝트로 접속한 유저의 ID값을 키값으로 User 데이터를 이진트리에 넣어 관리한다(standard library에 관한 자세한 내용은 C++ 레퍼런스 가이드를 참고하기 바란다). m_mapUser 오브젝트와 맵 데이터인 g_pszMap은 동시에 여러 개의 스레드를 사용하기 때문에 크리티컬 섹션으로 관리하고 있다. 만약 m_mapUser 오브젝트를 크리티컬 섹션을 이용해서 한번에 한 스레드가 접근하게 보호하지 않으면 한 스레드가 데이터를 삽입하고 있는 동시에 또 다른 스레드가 데이터를 삭제하려는 시도를 할 수도 있다. 이렇게 되면 m_mapUser의 이진트리 구조는 깨지게 되고 잘못된 메모리 액세스로 프로그램이 다운된다.  

World::AddUser()에서는 새로운 유저가 추가될 때 새롭게 추가되는 유저에 ID값을 부여하게 되는데 ID값은 m_nId 값을 InterlockedIncrement()을 이용해서 하나씩 증가시키면서 얻는다. World::Remove() 멤버 함수는 UserThread()에서 소켓이 끊어졌을 경우에 불려진다. m_mapUser에서 소켓이 끊어진 User 오브젝트를 삭제하고 현재 접속해 있는 모든 유저들에게 이 사실을 알려 클라이언트들의 화면에서 접속이 끊긴 유저의 캐릭터가 사라지게 한다. World::Enter()는 반대로 새로운 유저가 접속했을 경우 전체 유저들에게 새 유저의 접속을 알린다. 네트워크 게임에서 패킷의 양이 많아지고 패킷의 크기가 커지면 커질수록 필연적으로 네트워크 트래픽 증가가 따르게 된다. 테스트 서버의 경우 사용되고 있는 캐릭터의 정보는 이름밖에 없기 때문에 별 문제가 없지만, 실제 게임에서는 캐릭터의 스프라이트 정보는 물론이고 현재의 상태, HP, MP 등 보내야할 데이터가 많을 뿐만 아니라 한 화면에 수십명의 캐릭터가 있을 수 있다. 그렇기 때문에 캐릭터의 정보를 계속 보내는 것이 아니라 처음 화면에 나타날 때 캐릭터의 정보를 보내고 다음에 그 캐릭터의 상태가 변할 때(움직이거나 화면을 벗어나거나 그래픽 데이터가 변경되거나)는 바뀐 캐릭터의 ID 값과 새로운 상태만을 보내준다. 이 테스트 서버에서도 새로운 유저가 접속할 때 S_ENTER 패킷에서 캐릭터의 이름과 위치를 보낸 후 다음 패킷부터는 캐릭터와 ID 값만 가지고 통신을 하게 된다.

 

리스트 11 : world 클래스

#ifndef _WORLD_
#define _WORLD_

class User;

class World {
     protected:
     CRITICAL_SECTION m_csUser;
     CRITICAL_SECTION m_csWorld;
     std::map<unsigned int,User*> m_mapUser;
     unsigned int m_nId;

     public:
     World();
     ~World();


     bool MoveUser(User* pUser,char cDirection);
     void Move(User* pUser,char cDirection);
     void Say(User* pUser,char* pszSay);
     void Attack(User* pAttacker,unsigned int nVictim);
     void Send(char* pPacket,int nPacket);

     // 패킷을 전체 유저에 전송
     User* FindUser(unsigned int nId);
     void Enter(User* pUser);
     void AddUser(User* pUser);
     void Remove(User* pUser);
};
#endif


char g_pszMap[][1024] = {
...............
};

World::World()
{
     InitializeCriticalSection(&m_csUser);
     InitializeCriticalSection(&m_csWorld);
     m_nId = 0;
}

World::~World()
{
     DeleteCriticalSection(&m_csUser);
     DeleteCriticalSection(&m_csWorld);
}

void World::AddUser(User* pUser)
{
     int nId = InterlockedIncrement((long*) &m_nId);
     EnterCriticalSection(&m_csUser);
     pUser->SetId(nId);
     m_mapUser.insert(std::map<unsigned int,User*>::value_type(nId, pUser));
     LeaveCriticalSection(&m_csUser);
}

User* World::FindUser(unsigned int nId)
{
     User* pUser = NULL;
     std::map<unsigned int,User*>::iterator i;
     EnterCriticalSection(&m_csUser);
     i = m_mapUser.find(nId);
     if (i != m_mapUser.end())
     {
         pUser = i->second;
     }

     LeaveCriticalSection(&m_csUser);
     return pUser;
}

void World::Remove(User* pUser)
{
     std::map<unsigned int,User*>::iterator i;
     char szPacket[512];
     char* pPacket = szPacket + 2;
     pPacket = SetChar(pPacket, S_REMOVE);
     pPacket = SetInteger(pPacket, pUser->Id());
     SetShort(szPacket, pPacket - szPacket);
     EnterCriticalSection(&m_csWorld);
     g_pszMap[pUser->Y()][pUser->X()] = ‘ ‘;
     LeaveCriticalSection(&m_csWorld);
     EnterCriticalSection(&m_csUser);
     i = m_mapUser.find(pUser->Id());
     if (i != m_mapUser.end())
     {
        delete i->second;
        m_mapUser.erase(i);
     }
     LeaveCriticalSection(&m_csUser);
     Send(szPacket, pPacket - szPacket);
}


void World::Enter(User* pUser)
{
     char szPacket[512];
     char* pPacket = szPacket + 2;
     bool bPut = false;

     // 비어있는 자리를 찾는다.
     EnterCriticalSection(&m_csWorld);
     for(int nY=10; nY<25 && bPut == false; nY++)
     {
        for (int nX = 11; nX < 65 && bPut == false; nX++)
        {
                if (g_pszMap[nY][nX] == ‘ ‘)
                {
                   pUser->SetX(nX);
                   pUser->SetY(nY);
                   int nNum = strlen(g_pszMap[nY]);
                   g_pszMap[nY][nX] = ‘U’; // [nX] = ‘U’;
                   bPut = true;
                }
        }
     }
     LeaveCriticalSection(&m_csWorld);


     // 새로 접속한 클라이언트에 패킷을 보낸다.
     pPacket = SetChar(pPacket, S_ENTER);
     pPacket = SetInteger(pPacket, pUser->Id());
     pPacket = SetString(pPacket, pUser->Name());
     pPacket = SetInteger(pPacket, pUser->X());
     pPacket = SetInteger(pPacket, pUser->Y());
     SetShort(szPacket, pPacket - szPacket);
     Send(szPacket, pPacket - szPacket);

     std::map<unsigned int,User*>::iterator i;
     User* pTarget;

     // 새로 접속한 pUser를 제외한 전체 유저에 패킷을 보낸다.
     EnterCriticalSection(&m_csUser);
     for (i = m_mapUser.begin(); i != m_mapUser.end(); i++)
     {
        pTarget = i->second;
        if (pTarget != pUser) {
        pPacket=szPacket + 2;
        pPacket= SetChar(pPacket, S_ENTER);
        pPacket=SetInteger(pPacket,pTarget->Id());
        pPacket=SetString(pPacket,pTarget->Name());
        pPacket=SetInteger(pPacket, pTarget->X());
        pPacket=SetInteger(pPacket, pTarget->Y());
        SetShort(szPacket, pPacket - szPacket);
        pUser->Send(szPacket, pPacket - szPacket);
        }
     }
     LeaveCriticalSection(&m_csUser);
}


마치며

이 글에서 사용된 테스트용 서버와 클라이언트는 사실 미완성 버전이다. 전투나 HP 관리, 사용자 데이터의 저장, NPC 처리 등 구현되지 않은 것 투성이다. 처음 이 글을 시작할 때 구현하고 싶었던 것은 네트핵(Nethack)의 멀티유저 버전이었다. 이 글을 읽는 독자들 중 멀티유저 게임의 개발에 관심이 있는 사람이라면 이 미완성 프로그램을 발전시켜 완전한 형태의 네트핵 서버/클라이언트를 만드는 것에 도전해보길 기대하며 글을 마치겠다.

 

프로세스간 동기화 (Interprocess Synchronization)


멀티쓰레드 환경에서 세마포어, 뮤텍스와 크리티컬 섹션 오브젝트는 어떻게 다른가 살펴보자. 아래 코드는 전형적인 뮤텍스 오브젝트의 사용방법이다.

hMutex = CreateMutex (NULL, FALSE, “MyMutexObject”);

// 뮤텍스 오브젝트를 만든다.

...

unsigned __stdcall thread1()
{
   ...

   WaitForSingleObject(hMutex, INFINITE); // 크리티컬 섹션으로 진입
   ...

   ReleaseMutex(hMutex); // 크리티컬 섹션을 빠져나온다.
   ...
}

...

CloseMutex(hMutex); // 사용이 끝난 뮤텍스 오브젝트를 없앤다.

뮤텍스 오브젝트는 만들어질 때 이미 신호(signal)가 셋트되어 있는 상태다. 따라서 처음 WaitiForSingleObject(hMutex)를 콜(call)하는 쓰레드는 기다림없이 바로 크리티컬 섹션으로 들어갈 수 있다. 그리고 ReleaseMutex(hMutex)를 콜하기 전까지 신호(signal)는 리셋되어 있는 상태이기 때문에 다른 쓰레드들은 이 크리티컬 섹션으로 진입할수 없다. 뮤텍스 오브젝트와 크리티컬 섹션 오브젝트 모두 크리티컬 섹션의 기능을 수행하고 사용법도 비슷해 보인다.

둘 간의 차이는 뮤텍스 오브젝트는 interprocess synchronization (프로세스간 동기화)가 가능한 오브젝트이고, 크리티컬 섹션 오브젝트는 그렇지 않다는 것이다. CreateMutex()의 3번째 파라미터는 오브젝트의 이름을 스트링으로 지정하는 항목이다(NULL값으로 만들면 다른 프로세스에서 이 오브젝트를 찾을수 없다). 이렇게 만들어진 오브젝트는 지정한 이름으로 OS의 커널에 등록되어 여러 프로세스가 이 오브젝트를 사용하는 것이 가능하다. 만약 프린트를 하려는 프로그램이 2개 이상이 있다면? 여러 프로그램이 각자의 윈도우에 뭔가를 그리려고 동시에 시도한다면? 모두다 흔히 발생하는 일이다. 이때 프로세스간 동기화가 필요하다. OpenMutex(),OpenSemaphore(),OpenEvent()와 같은 API들을 사용하면 오브젝트의 이름으로 다른 프로세스에서 만든 오브젝트의 핸들을 얻을수 있다.

unsigned __stdcall thread2()
{
   hMutex = OpenMutex(NULL, FALSE, “MyMutexObject);
   WaitForSingleObject(hMutex, INFINITE); // 크리티컬 섹션으로 진입
   ...
   ReleaseMutex(hMutex); // 크리티컬 섹션을 빠져나온다.
}

뮤텍 스 오브젝트는 커널에서 만들어지고 크리티컬 섹션 오브젝트는 프로세서에서 만들어지기 때문에 프로세스간 동기화가 필요하지 않을 경우 크리티컬 섹션 오브젝트를 사용하는 것이 속도면에서 약간 빠르다.



       
소스 제공 : 프로그램의 세계 자료실 Multi.zip

: