Ajax와 XML: 다섯 가지 Ajax 안티 패턴(anti-pattern)
원문출처 : http://www.ibm.com/developerworks/kr/library/x-ajaxxml3/
Ajax 코드의 일반적인 함정 피하기
난이도 : 중급
Jack D Herrington, Senior Software Engineer, Leverage Software Inc.
2007 년 5 월 15 일
어플리케이션들이 어떻게 잘못 되었는지를 이해함으로써 어플리케이션들을 올바르게 수행하는 방법을 배울 수 있습니다. Asynchronous JavaScript™ + XML (Ajax) 애플리케이션들을 작성하는 것에도 올바른 방식과 그릇된 방식이 있습니다.
사람들이 처음부터 모든 것을 잘 했다면 이 세상은 완전히 달라졌을 것이다. Ajax도 마찬가지다. 필자는 Ajax 개발자를 위해서 코딩, 기고, 강연, 지원 등 많은 일들을 해왔다. 이러한 일련의 활동들을 통해서 Ajax를 올바르게 다루는 방법과 잘못 다루는 방법에 대해 배웠다. 지난 기술자료인, 다섯 개의 일반적인 Ajax 패턴: 유용한 Ajax 디자인 패턴들에서, Ajax 애플리케이션을 올바르게 작성하는 다섯 가지 패턴들을 제시했다. 이 글에서는 Ajax 코드에서 자주 볼 수 있는 다섯 가지 안티 패턴(anti-pattern)에 대해 설명하겠다.
안티 패턴이란 무엇인가? 안티 패턴은 문제가 될 가능성이 짙은, 모든 사람들이 주의해야 할 애플리케이션 디자인의 결함이다. 이 글에서는 고급 주제에 대해 이야기 하겠다. 신택스 에러와 링커(linker) 문제는 언급하지 않겠다.
대부분의 개발자들은 안티 패턴의 대표적인 예라 할 수 있는, 웹 사이트에서 "SQL Injection attack" 이라는 결과를 만들어 내는 Structured Query Language (SQL) 라이브러리의 오용에 대해 알고 있을 것이다. 이러한 안티 패턴은 기업의 매출에 타격을 가하고, 고객의 기록을 노출 하기도 하며, 모든 프로그래밍 언어에서 발생할 수 있다. 따라서, 왜 이러한 일이 발생하는지, 어떻게 발생하는지, 이러한 문제를 어떻게 피하는지를 이해하는 것이 중요하다.
이 글에서는 Ajax 안티 패턴에 대해 살펴볼 것이다. 필자는 지금 기업에 수십억 달러 정도의 매출의 손실이 있다는 것을 이야기하려는 것은 아니다. 이러한 안티 패턴들이 서버를 충돌시키거나 형편없는 사용자 경험을 제공하고, 이 두 가지 모두 실망스럽고 비용이 많이 들 수 있다는 것을 이야기 하려는 것이다.
무엇인가 잘못 될 수 있다는 것을 이해한다면 많은 것을 배울 수 있다. 가끔 사람들은 Ajax을 페이지가 로딩된 후에 서버에서 XML을 보내는 유일한 방법으로 생각하고 있다. 이것은 매우 제한된 시각이며, 이것이 잘못 적용된다면 애플리케이션에 성능 문제를 일으킬 수 있다. 이 글에서, 왜 이것이 잘못 되었으며 이를 수정하는 방법을 설명하겠다.
굳이 그럴 필요가 없는데도 타이머에 폴링(poll)하기
필자가 생각하기에 많은 Ajax 문제들은 JavaScript 언어에 있는 타이머 기능의 오용과 관련이 있다. 핵심 메소드는 window.setInterval()
이다. 이 메소드를 볼 때마다 여러분의 마음속에 작은 경각심이 생긴다. 왜 이 사람은 타이머를 사용하고 있을까? 틀림없이, 타이머에는 예를 들어 애니메이션과 같은 타이머의 본래 목적이 있을 것이다.
window.setInterval()
메소드는 특정 간격(매 초(second))마다 특정 함수를 콜백(call back)하도록 페이지에 명령한다. JavaScript 언어가 단일 쓰레드이기 때문에, 대부분의 브라우저들은 이러한 타이머를 거의 실행하지 않는다. 여러분이 1초를 요청하면 1초 또는 1.2초 또는 9초 또는 다른 시간으로 콜백을 얻게 된다.
타이머가 확실히 필요하지 않는 한 부분은 완료 할 Ajax 요청을 찾는 부분이다. Listing 1의 예를 보자.
Listing 1. Antipat1a_polling.html
<html><script> var req = null; function loadUrl( url ) { if(window.XMLHttpRequest) { try { req = new XMLHttpRequest(); } catch(e) { req = false; } } else if(window.ActiveXObject) { try { req = new ActiveXObject('Msxml2.XMLHTTP'); } catch(e) { try { req = new ActiveXObject('Microsoft.XMLHTTP'); } catch(e) { req = false; } } } if(req) { req.open('GET', url, true); req.send(''); } } window.setInterval( function watchReq() { if ( req != null && req.readyState == 4 && req.status == 200 ) { var dobj = document.getElementById( 'htmlDiv' ); dobj.innerHTML = req.responseText; req = null; } }, 1000 ); var url = window.location.toString(); url = url.replace( /antipat1a_polling.html/, 'antipat1_content.html' ); loadUrl( url ); </script><body> Dynamic content is shown between here:<br/> <div id="htmlDiv" style="border:1px solid black;padding:10px;"> </div>And here.</body></html> |
|
setInterval
호출에 도달할 때까지 모든 것이 좋아 보인다. 이 호출은 요청의 상태를 관찰 할 타이머를 설정하고 다운로드 된 자료에서 페이지의 내용을 설정한다.
요청이 짧게 완료될 때를 규명할 수 있는 문제에 대한 솔루션을 설명하겠다. Listing 2는 이 페이지가 요청하는 파일을 보여주고 있다.
Listing 2. Antipat1_content.html
<b>Hello there</b> |
|
그림 1은 필자의 브라우저에 나타나는 페이지 모습이다.
그림 1. HTML 문서
"그런데 이것은 잘 작동하지 않습니까? 깨짐 현상도 없는데 픽스가 왜 필요한가요?"라고 물을 수도 있겠다. 실제로 이것은 깨졌다. 매우 느리기 때문이다. 타이머 설정을 1초 간격으로 하면 요청은 잘 진행된다. 따라서 먼저, 비어있는 박스와 함께 페이지가 나타나고, 1초를 기다리면 내용이 나타난다.
솔루션은 무엇인가? Ajax는 본질적으로 비동기식(asynchronous)이다. 요청이 언제 끝났는지를 보기 위해 폴링 루프가 필요하지 않을까?
그렇게 필요한 것은 아니다. Listing 3에서 보듯, 모든 XMLHTTPRequest
객체가 제공하는 것은 onreadystatechange
라고 하는 콜백 메커니즘이다. (얼마나 아름다운 이름인가, VAX PDP/11s를 연상시키는 이름이다.)
Listing 3. Antipat1a_fixed.html
<html><script> var req = null; function processReqChange() { if (req.readyState == 4 && req.status == 200 ) { var dobj = document.getElementById( 'htmlDiv' ); dobj.innerHTML = req.responseText; } } function loadUrl( url ) { ... if(req) { req.onreadystatechange = processReqChange; req.open('GET', url, true); req.send(''); } } var url = window.location.toString(); url = url.replace( /antipat1a_fixed.html/, 'antipat1_content.html' ); loadUrl( url ); </script> ... |
|
이 새로운 코드는 onreadystatechange
콜백에 대한 응답으로 요청 객체가 변했는지의 여부를 본다. 그런 다음, 이것이 완료되면 페이지를 업데이트 한다.
결과는 매우 빨라진 페이지 로드이다. 페이지가 나타나면, 거의 동시에 새로운 콘텐트로 박스가 채워진다. 왜? 요청이 완료되자마자, 이 코드가 호출되고 필자는 페이지를 채우기 때문이다. 바보 같은 타이머 때문에 혼란스러워 할 필요가 없다.
또 다른 폴링 안티 패턴은 한 페이지가 요청을 서버로 반복해서 바운스(bounce) 할 때이다. 심지어 요청이 변경되지 않았음에도 그렇게 하는 경우이다. Listing 4의 검색 페이지 예제를 보자.
Listing 4. Antipat1b_polling.html
<html><script> var req = null; function processReqChange() { if (req.readyState == 4 && req.status == 200 ) { var dobj = document.getElementById( 'htmlDiv' ); dobj.innerHTML = req.responseText; } } function loadUrl( url ) { ... } window.setInterval( function watchSearch() { var url = window.location.toString(); var searchUrl = 'antipat1_content.html?s='+searchText.value; url = url.replace( /antipat1b_polling.html/, searchUrl ); loadUrl( url ); }, 1000 ); </script><body><form> Search <input id="searchText" type="text">:<br/> <div id="htmlDiv" style="border:1px solid black;padding:10px;"> </div></form></body></html> |
|
그림 2에서는 필자의 브라우저에서 이 페이지가 실행되는 모습이다.
그림 2. 동적인 응답 영역을 가진 검색 영역
얼마나 아름다운가? 이 페이지는 이치에 맞는다. 필자가 검색 텍스트를 바꾸면, 결과 영역은 새로운 기준에 근거하여 바뀐다. (실제로는 그렇지 않지만 막후에 실제 검색 엔진이 있었다면 그렇게 했을 것이다.)
문제는 JavaScript 코드가 window.setInterval
을 사용하여 계속해서 요청을 만든다는 것이다. 심지어 검색 필드의 콘텐트가 바뀌지 않았음에도 말이다. 이는 네트워크 대역폭을 잠식하고 서버 시간을 잠식한다. 대중적인 사이트의 경우 이것은 치명적이다.
솔루션은 검색 박스에 이벤트 콜백을 사용하는 것이다. (Listing 5)
Listing 5. Antipat1b_fixed.html
<html><script> var req = null; function processReqChange() { ... } function loadUrl( url ) { ... } var seachTimer = null; function runSearch() { if ( seachTimer != null ) window.clearTimeout( seachTimer ); seachTimer = window.setTimeout( function watchSearch() { var url = window.location.toString(); var searchUrl = 'antipat1_content.html?s='+searchText.value; url = url.replace( /antipat1b_fixed.html/, searchUrl ); loadUrl( url ); seachTimer = null; }, 1000 ); } </script><body><form> Search <input id="searchText" type="text" onkeyup="runSearch()">:<br/> <div id="htmlDiv" style="border:1px solid black;padding:10px;"> </div></form></body></html> |
|
여기에서 필자는 runSearch()
함수를 검색 박스의 onkeyup()
메소드로 연결했다. 이러한 박식으로 사용자가 검색 박스에 무엇인가 입력하면 콜백을 받도록 한다.
runSearch()
가 수행하는 것은 매우 똑똑하다. 1초 간의 단일 타임아웃을 설정하고 이 시간안에 서버를 호출하고 검색을 실행하도록 한다. 그리고 이것을 설정하기 전에 시간이 경과되지 않았다면 그 타임아웃을 삭제한다. 왜? 이것은 사용자가 많은 텍스트를 입력하도록 허용하기 때문이다. 그런 다음, 사용자가 마지막 키를 누른 후 1초 만에 검색이 실행된다. 이러한 방식으로 사용자는 계속적으로 깜박거리는 디스플레이 때문에 신경 쓰지 않아도 된다.
많은 Ajax 안티 패턴은 XMLHTTPRequest
객체의 메커니즘에 대한 오해에서 기인한다. 필자가 종종 목격하게 되는 것 중 하나는 사용자가 콜백에서 객체의 readyState
또는 status
필드를 검사하지 않을 때이다. Listing 6을 보자.
Listing 6. Antipat2_nocheck.html
<html><script> var req = null; function processReqChange() { var dobj = document.getElementById( 'htmlDiv' ); dobj.innerHTML = req.responseText; } ... |
|
모든 것이 멀쩡한 것처럼 보인다. 작은 요청이지만, 일부 브라우저에서는 이것으로 족하다. 하지만, 대부분의 요청들은 onreadystatechange
핸들러로 여러 호출을 요청해야 할 정도로 크다. 따라서 여러분의 콜백은 불완전한 데이터로 작동하게 된다.
이를 수행하는 올바른 방법은 Listing 7에 나타나 있다.
Listing 7. Antipat2_fixed.html
<html><script> var req = null; function processReqChange() { if (req.readyState == 4 && req.status == 200 ) { var dobj = document.getElementById( 'htmlDiv' ); dobj.innerHTML = req.responseText; } } ... |
|
더 많은 코드가 생긴 것은 아니지만 모든 브라우저에서 작동한다.
Windows® Internet Explorer® 7에서 이러한 문제가 다른 브라우저에서 보다 중요하다는 것을 깨달았다. Internet Explorer 7은 onreadystatechange
로 많은 콜백을 한다. 심지어는 작은 요청에도 정말 많다. 따라서, 핸들러를 올바르게 작성하는 것이 중요하다.
HTML이 더 나은 선택임에도 불구하고 복잡한 XML 실행하기
필자가 일했던 한 회사에서, "intelligence at the edge of the network"라는 말을 자주 사용했다. 서버에서 모든 일을 수행하는 대신 데스크탑에서 브라우저 기능을 사용하라는 의미이다.
하지만, 페이지에 많은 정보를 넣으면 그곳에는 많은 JavaScript 코드가 놓이게 된다. 이것은 큰 단점이 된다. 바로 브라우저 호환성이다. 실제로 모든 대중적인 브라우저에서 JavaScript 코드의 모든 중요한 라인을 테스트 해야 한다. 적어도 고객이 사용할 것 같은 브라우저에서도 그렇게 해야 한다. 그래야 의미가 통한다. 복잡한 Ajax 예를 보자. (Listing 8)
Listing 8. Antipat3_complex.html
<html><head><script> var req = null; function processReqChange() { if (req.readyState == 4 && req.status == 200 && req.responseXML ) { var dtable = document.getElementById( 'dataBody' ); var nl = req.responseXML.getElementsByTagName( 'movie' ); for( var i = 0; i < nl.length; i++ ) { var nli = nl.item( i ); var elYear = nli.getElementsByTagName( 'year' ); var year = elYear.item(0).firstChild.nodeValue; var elTitle = nli.getElementsByTagName( 'title' ); var title = elTitle.item(0).firstChild.nodeValue; var elTr = dtable.insertRow( -1 ); var elYearTd = elTr.insertCell( -1 ); elYearTd.innerHTML = year; var elTitleTd = elTr.insertCell( -1 ); elTitleTd.innerHTML = title; } } } function loadXMLDoc( url ) { if(window.XMLHttpRequest) { try { req = new XMLHttpRequest(); } catch(e) { req = false; } } else if(window.ActiveXObject) { try { req = new ActiveXObject('Msxml2.XMLHTTP'); } catch(e) { try { req = new ActiveXObject('Microsoft.XMLHTTP'); } catch(e) { req = false; } } } if(req) { req.onreadystatechange = processReqChange; req.open('GET', url, true); req.send(''); } } var url = window.location.toString(); url = url.replace( /antipat3_complex.html/, 'antipat3_data.xml' ); loadXMLDoc( url ); </script></head><body> <table cellspacing="0" cellpadding="3" width="100%"><tbody id="dataBody"> <tr> <th width="20%">Year</th> <th width="80%">Title</th> </tr> </tbody></table></body></html> |
|
이 코드는 Listing 9에 나타난 XML에서 데이터를 읽고 이를 테이블로 포맷팅 한다.
Listing 9. Antipat3_data.xml
<movies> <movie> <year>1993</year> <title>Jurassic Park</title> </movie> <movie> <year>1997</year> <title>The Lost World: Jurassic Park</title> </movie> <movie> <year>2001</year> <title>Jurassic Park III</title> </movie> </movies> |
그림 3과 같은 결과를 보게 된다.
그림 3. 복잡한 영화 리스팅 페이지
이것은 절대로 나쁜 코드가 아니다. 실제로는 비교적 단순한 태스크를 수행하는 많은 코드일 뿐이다. 결과 페이지는 전혀 복잡하지 않다. 클라이언트 측에서는 정렬되거나 검색될 수 없다. 사실, XML과 HTML간 이러한 복잡한 변환을 할 이유가 전혀 없다.
서버가 XML 대신 HTML을 리턴 하도록 하는 것이 더 간단하지 않을까? (Listing 10)
Listing 10. Antipat3_fixed.html
<html><script> var req = null; function processReqChange() { if (req.readyState == 4 && req.status == 200 ) { var dobj = document.getElementById( 'tableDiv' ); dobj.innerHTML = req.responseText; } } function loadUrl( url ) { ... } var url = window.location.toString(); url = url.replace( /antipat3_fixed.html/, 'antipat3_content.html' ); loadUrl( url ); </script><body><div id="tableDiv"></div></body></html> |
|
정말로 더 간단해졌다. 모든 복잡한 테이블 행과 셀 생성 코드는 <div>
태그의 innerHTML
로 대체된다. à!
서버에서 리턴된 HTML은 Listing 11을 참조하라.
Listing 11. Antipat3_content.html
<table cellspacing="0" cellpadding="3" width="100%"> <tbody id="dataBody"> <tr> <th width="20%">Year</th> <th width="80%">Title</th> </tr> <tr> <td>1993</td> <td>Jurassic Park</td> </tr> <tr> <td>1997</td> <td>The Lost World: Jurassic Park</td> </tr> <tr> <td>2001</td> <td>Jurassic Park III</td> </tr> </tbody> </table> |
|
모든 것이 그렇듯, 서버와 클라이언트 중 어디에서 처리할 것인지를 선택하는 것은 작업의 특성에 달려있다. 이 경우, 작업은 비교적 단순했다. 영화 테이블을 채우는 것이다. 작업이 더 복잡했다면, 다시 말해서, 정렬, 검색, 추가 또는 삭제, 영화를 클릭하면 더 많은 정보로 가게 되는 동적 인터랙션 같은 기능이 있다면 클라이언트에 보다 복잡한 코드가 실행되는 것을 볼 수 있다. 사실, 이 글 후반에 가서는 클라이언트에서의 정렬(sorting)을 설명할 것이다. 여기에서 서버에 너무 많은 부하를 주는 요소에 대해서 이야기 할 것이다.
이것에 대한 가장 완벽한 예제는 아마도 Google Maps일 것이다. Google Maps는 서버 측에서 리치 클라이언트 측 코드와 지능형 매핑 엔진을 혼합하는 고급스러운 작업을 수행한다. 필자는 이 서비스를 거기에서 어떤 프로세싱을 수행할 것인지를 결정하는 방법 예제로서 사용해 보겠다.
JavaScript 코드를 전달해야 할 때 XML 전달하기
웹 브라우저가 XML 데이터 소스를 읽고 이를 동적으로 실행한다는 모든 가설을 기반으로, 여러분은 이것이 사용할 수 있는 유일한 메소드라고 생각할 것이다. 하지만, 여러분의 생각은 틀리다. 매우 똑똑한 엔지니어들은 Ajax 전송 기술을 사용하여 XML 대신 JavaScript 코드를 보내기 때문이다. Listing 12에서 영화 테이블 예제를 보도록 하자.
Listing 12. Antipat4_fixed.html
<html><head><script> var req = null; function processReqChange() { if (req.readyState == 4 && req.status == 200 ) { var dtable = document.getElementById( 'dataBody' ); var movies = eval( req.responseText ); for( var i = 0; i < movies.length; i++ ) { var elTr = dtable.insertRow( -1 ); var elYearTd = elTr.insertCell( -1 ); elYearTd.innerHTML = movies[i].year; var elTitleTd = elTr.insertCell( -1 ); elTitleTd.innerHTML = movies[i].name; } } } function loadXMLDoc( url ) { ... } var url = window.location.toString(); url = url.replace( /antipat4_fixed.html/, 'antipat4_data.js' ); loadXMLDoc( url ); </script></head><body> <table cellspacing="0" cellpadding="3" width="100%"> <tbody id="dataBody"><tr> <th width="20%">Year</th> <th width="80%">Title</th> </tr></tbody></table></body></html> |
|
서버에서 XML을 읽는 대신, JavaScript 코드를 읽는다. 이 코드는 JavaScript 코드에 있는 eval()
함수를 사용하여 데이터를 얻는데, 이는 테이블 구현에 사용할 수 있다.
JavaScript 데이터는 Listing 13에 나타나 있다.
Listing 13. Antipat4_data.js
[ { year: 1993, name: 'Jurassic Park' }, { year: 1997, name: 'The Lost World: Jurassic Park' }, { year: 2001, name: 'Jurassic Park III' } ] |
이러한 기능은 JavaScript 언어로 서버와 통신하도록 해야 한다. 하지만, 그렇게 큰 일은 아니다. 대부분의 대중적인 웹 언어들은 이미 JavaScript Object Notation (JSON) 아웃풋을 지원한다.
효과는 명확하다. 이 예제에서 JavaScript 언어를 사용함으로써 클라이언트로 다운로드 된 데이터의 크기가 52%나 줄었다. 성능 역시 증가했다. JavaScript 버전을 읽는 것도 9%나 빨라졌다. 9%는 그렇게 큰 비율은 아니지만, 이 예제는 매우 기본적인 예제라는 것을 기억하라. 더 큰 데이터 블록이나 더 복잡한 구조는 더 많은 XML 파싱 코드를 요구할 것이지만, JavaScript 코드는 바뀌지 않는다.
서버에서 적은 일을 수행해야 할 카운터 인자가 서버에서 너무 많은 일을 수행한다. 앞서 언급했지만, 이것은 균형을 맞추기 위한 작동이다. 하지만, 서버에서 작업을 분산하는 방법에 대한 설명으로서 클라이언트에서 영화 테이블을 정렬하는 방법을 설명하겠다.
Listing 14에는 정렬 가능한 영화 테이블이 나타나 있다.
Listing 14. Antipat5_sort.html
<html><head><script> var req = null; var movies = null; function processReqChange() { if (req.readyState == 4 && req.status == 200 ) { movies = eval( req.responseText ); runSort( 'year' ); } } function runSort( key ) { if ( key == 'name' ) movies.sort( function( a, b ) { if ( a.name < b.name ) return -1; if ( a.name > b.name ) return 1; return 0; } ); else movies.sort( function( a, b ) { if ( a.year < b.year ) return -1; if ( a.year > b.year ) return 1; return 0; } ); var dtable = document.getElementById( 'dataBody' ); while( dtable.rows.length > 1 ) dtable.deleteRow( 1 ); for( var i = 0; i < movies.length; i++ ) { var elTr = dtable.insertRow( -1 ); var elYearTd = elTr.insertCell( -1 ); elYearTd.innerHTML = movies[i].year; var elTitleTd = elTr.insertCell( -1 ); elTitleTd.innerHTML = movies[i].name; } } function loadXMLDoc( url ) { ... } var url = window.location.toString(); url = url.replace( /antipat5_sort.html/, 'antipat4_data.js' ); loadXMLDoc( url ); </script></head><body> <table cellspacing="0" cellpadding="3" width="100%"> <tbody id="dataBody"><tr> <th width="20%"><a href="javascript: void runSort('year')">Year</a></th> <th width="80%"><a href="javascript: void runSort('name')">Title</a></th> </tr></tbody></table></body></html> |
|
이것은 매우 간단한 예제이다. 이 페이지에 나타날 것 같은 특별히 긴 영화 리스트에는 작동하지 않는다. 하지만, 페이지 리프레시 없이도, 서버가 많은 일을 수행하지 않아도 빠르게 정렬하는 테이블을 만들 수 있다.
결론
필자는 Ajax에 대한 많은 글을 써왔고 많은 Ajax를 수행했다. 필자는 Ajax forum on IBM developerWorks의 Ajax 포럼을 운영한다. 따라서 Ajax에 대한 한 두 가지의 일쯤은 알고 있고 이것이 어떻게 사용되고 어떻게 오용되는지도 알고 있다. 필자가 자주 목격하게 되는 것 중 하나는 개발자들이 Ajax의 복잡성을 오해하고 있다는 것이다. 개발자들은 Ajax가 XML, JavaScript, HTML 코드를 브라우저로 보내는 것 정도로 생각하고 있다. 필자는 Ajax 플랫폼을 완전한 브라우저로 보고 있다. 사실, 대중적인 브라우저들의 전체라고 볼 수 있다.
결론은 다음과 같이 내릴 수 있겠다. Ajax에 대해 배워야 할 것은 많이 있고 이와 관련한 실수들도 많이 한다. 바라건데, 이 글이 빠지기 쉬운 함정을 피하는데 지침이 되었기 바란다. 성공을 통해 많은 것을 배울 수도 있지만 실수를 통해서도 많은 것을 배우기 마련이다.
필자소개
Jack D. Herrington은 20년 경력을 지닌 소프트웨어 엔지니어이다. Code Generation in Action, Podcasting Hacks, PHP Hacks 의 저자이다. 30개 이상의 기술자료를 기고했다. (jherr@pobox.com)