1

주제: 플러그인의 작동원리 설명좀 부탁드려요.

이제 php개발자 2년차에 접어들고 있습니다.

이제 점점 많은 오픈소스들 접하면서 실력을 키우려고 하는데요.

태터툴즈의 플러그인이 어떤 원리로 작동하는지 궁금해서

이렇게 글 올립니다.

여기 게시물도 뒤져보고

여기저기 찾아봤는데 이해갈만한게 없네요.

플러그인 제작 API도 읽어봤는데 통 무슨얘긴지 ㅜ.ㅜ

그리고 API라기보다는 플러그인이 작동하는 원리를 좀 알고싶어서요.

플러그인 작동원리 알자고 처음부터 태터툴즈 소스분석할려니 워낙 양도 많아서 덜컥 하네요.

태터툴즈 에서는 플러그인이 어떤 원리로  작동되는지 설명좀 부탁드릴께요.

o242 (2006-08-09 17:04:52)에 의해 마지막으로 수정

2

답글: 플러그인의 작동원리 설명좀 부탁드려요.

일단 윈도우/GUI 프로그래밍을 해보셨는지요?
사용자가 버튼을 클릭한다거나, 무언가 입력을 한다거나 할 때 이벤트라는 것이 발생해서 해당 프로그램에게 그 사실과 관련 정보를 알려주지요.

태터툴즈 플러그인도 비슷한 방식입니다.
사용자가 글을 쓴다든가, 누군가 코멘트를 쓰거나 트랙백을 보냈을 때, 본문이 출력될 때 등의 이벤트들이 존재합니다.
플러그인 제작자는 자기가 원하는 동작을 어디서 해야 할지 결정하고, 원하는 이벤트를 처리하는 핸들러 함수를 php로 작성하여 플러그인의 xml 파일에 이벤트와 함수 이름을 넣어주면 태터툴즈가 이를 인식하게 됩니다.
해당 이벤트가 발생하면 태터툴즈는 그 이벤트를 사용하는 플러그인들이 등록한 함수를 하나씩 실행시켜주고, $target 인자를 통해 내용을 조작하거나 뭔가 출력하는 등의 행동을 취할 수 있습니다.

문제의 답은 우리 안에 있다.
내면에 귀를 기울여 보자.

3

답글: 플러그인의 작동원리 설명좀 부탁드려요.

환영합니다. smile

플러그인의 작동원리는 생각보다 어렵지는 않습니다.
곧 나올 1.1도 크게 차이나지는 않지만 1.0.6.1 기준으로 설명드리겠습니다.

blog/index.php 파일을 열어보시면 1024 line ~ 1093 line이 플러그인과 관련된 부분입니다.

$activePlugins=array();
$eventMappings=array();
$tagMappings=array();
if(!empty($owner)){
    $activePlugins=fetchQueryColumn("SELECT name FROM {$database['prefix']}Plugins WHERE owner = $owner");
    $xmls=new XMLStruct();
    foreach($activePlugins as $plugin){
        $manifest=@file_get_contents("../plugins/$plugin/index.xml");
        if($manifest&&$xmls->open($manifest)){
            if($xmls->doesExist('/plugin/binding/listener')){
                foreach($xmls->selectNodes('/plugin/binding/listener') as $listener){
                    if(!empty($listener['.attributes']['event'])&&!empty($listener['.value'])){
                        if(!isset($eventMappings[$listener['.attributes']['event']]))
                            $eventMappings[$listener['.attributes']['event']]=array();
                        array_push($eventMappings[$listener['.attributes']['event']],array('plugin'=>$plugin,'listener'=>$listener['.value']));
                    }
                }
                unset($listener);
            }
            if($xmls->doesExist('/plugin/binding/tag')){
                foreach($xmls->selectNodes('/plugin/binding/tag') as $tag){
                    if(!empty($tag['.attributes']['name'])&&!empty($tag['.attributes']['handler'])){
                        if(!isset($tagMappings[$tag['.attributes']['name']]))
                            $tagMappings[$tag['.attributes']['name']]=array();
                        array_push($tagMappings[$tag['.attributes']['name']],array('plugin'=>$plugin,'handler'=>$tag['.attributes']['handler']));
                    }
                }
                unset($tag);
            }
        }else{
            $plugin=mysql_escape_string($plugin);
            mysql_query("DELETE FROM {$database['prefix']}Plugins WHERE owner = $owner AND name = '$plugin'");
        }
    }
    unset($xmls);
    unset($plugin);
}
function fireEvent($event,$target=null,$mother=null,$condition=true){
    global $service,$eventMappings,$pluginURL;
    if(!$condition)
        return $target;
    if(!isset($eventMappings[$event]))
        return $target;
    foreach($eventMappings[$event] as $mapping){
        include_once ("../plugins/{$mapping['plugin']}/index.php");
        if(function_exists($mapping['listener'])){
            $pluginURL="{$service['path']}/plugins/{$mapping['plugin']}";
            $target=call_user_func($mapping['listener'],$target,$mother);
        }
    }
    return $target;
}
function handleTags(&$content){
    global $service,$tagMappings,$pluginURL;
    if(preg_match_all('/\[##_(\w+)_##\]/',$content,$matches)){
        foreach($matches[1] as $tag){
            if(!isset($tagMappings[$tag]))
                continue;
            $target='';
            foreach($tagMappings[$tag] as $mapping){
                include_once ("../plugins/{$mapping['plugin']}/index.php");
                if(function_exists($mapping['handler'])){
                    $pluginURL="{$service['path']}/plugins/{$mapping['plugin']}";
                    $target=call_user_func($mapping['handler'],$target);
                }
            }
            dress($tag,$target,$content);
        }
    }
}

위의 부분인데요. 보시면 아시겠지만 간단히 설명드리겠습니다. smile

foreach($activePlugins as $plugin){
...
}

{$database['prefix']}Plugins 테이블에서 현재 사용중인 플러그인의 목록을 가져와서 루프를 돌게되죠.

설명을 위해 BlogIcon 플러그인과 TattertoolsBirthday 플러그인을 짬뽕시킨 예제;
...
<plugin version="1.0">
...
<binding>
    <listener event="ViewCommenter">BlogIcon_main</listener>
    <tag name="TattertoolsBirthday" handler="TattertoolsBirthday_TattertoolsBirthday" />
</binding>
...
</plugin>

$plugin에는 해당 플러그인의 디렉토리 이름이 있고 그 플러그인의 index.xml을 파싱을 하여 /plugin/binding/listener element와 /plugin/binding/tag element에 대하여 처리를 합니다.

listener쪽은 이벤트를 담당하는 부분으로 $eventMappings 배열에 이벤트명을 배열 index로 해서 플러그인 디렉토리명과 해당 이벤트를 처리해줄 함수명을 담습니다.
(위의 예제에서 ViewCommenter가 이벤트명이고 BlogIcon_main이 이벤트를 처리해줄 함수명입니다.)

tag쪽은 일반 치환자를 담당하는 부분으로 $tagMappings 배열에 tag element의 name attribute를 index로 해서 플러그인 디렉토리명과 해당 치환자를 처리해줄 함수명을 담습니다.
(위의 예제에서 TattertoolsBirthday가 배열 index가 되고 TattertoolsBirthday_TattertoolsBirthday가 치환자를 처리해줄 함수명입니다.)


이렇게 $eventMappings과 $tagMappings을 만들어두고 이벤트와 치환자를 각각 처리합니다.

fireEvent(($isComment?'ViewCommenter':'ViewGuestCommenter'),htmlspecialchars($commentSubItem['name']),$commentSubItem)

이벤트의 경우는 위의 예처럼 각 이벤트가 발생할 때마다 $eventMappings['ViewCommenter']에 있는 플러그인들을 include하여 call_user_func로 담아둔 함수를 호출하며 $target에 htmlspecialchars($commentSubItem['name']), $mother에 $commentSubItem를 넘겨줍니다.
따라서 이벤트로 발생하는 함수는 $target과 $mother를 둘다 받으셔야 합니다.

index.php
function BlogIcon_main($target, $mother) {
    ...
    return $target;
}

치환자의 경우는 skin을 파싱하는 부분에서 처리를 하게되는데..

    $sval=replaceSkinTag($sval,'html');
    $sval=replaceSkinTag($sval,'head');
    $sval=replaceSkinTag($sval,'body');
    handleTags($sval);

이런 부분이 있는데 위의 세줄은 [##_SKIN_html_start_##], [##_SKIN_html_end_##], [##_SKIN_head_start_##], [##_SKIN_head_end_##], [##_SKIN_body_start_##], [##_SKIN_body_end_##]의 총 6개의 자동 생성 치환자를 만드는 부분이고..
handleTags에서 위와 같은 치환자들을 처리해주는데 방식은 이벤트와 큰 차이가 없지만 $mother가 없다는 것이 다릅니다.

index.php
function TattertoolsBirthday_TattertoolsBirthday($target) {
    ...
    return $target;
}

또, 두가지의 경우 모두 $target을 받아서 반드시 return을 해주셔야 합니다.
이 것은 같은 이벤트나 치환자를 사용하는 다른 플러그인들에서도 충돌이 없이 작동을 하기 위해서인데 위의 코드를 보시면 이해가 가실겁니다. smile


ps. 힘들다;;; 근데 이해를 하실려나 OTL

4

답글: 플러그인의 작동원리 설명좀 부탁드려요.

아 그리고 어떤 이벤트가 있는지는 http://forum.tattertools.com/ko/viewtopic.php?id=380 를 참고해주세요. smile

5

답글: 플러그인의 작동원리 설명좀 부탁드려요.

왠지 Peris 님 설명 끝에는 명대사, "참 쉽죠?" 를 붙여줘야 할 것만 같은..

6

답글: 플러그인의 작동원리 설명좀 부탁드려요.

아주 짤막하게 개념만 설명하려고 했던 제 설명과 함수 만들 때의 주의사항까지 알려주시는 Peris님의 설명...
너무나 대조적이군요....;;;

문제의 답은 우리 안에 있다.
내면에 귀를 기울여 보자.

7

답글: 플러그인의 작동원리 설명좀 부탁드려요.

daybreaker 작성:

아주 짤막하게 개념만 설명하려고 했던 제 설명과 함수 만들 때의 주의사항까지 알려주시는 Peris님의 설명...
너무나 대조적이군요....;;;

;; 근데.. 저렇게 장황하게 써놓아봤자 오히려 더 헷갈리기만 할지도 모른다는 생각이 드네요. OTL;;

8

답글: 플러그인의 작동원리 설명좀 부탁드려요.

1,000번 보는 것보다는 한번 태터블로그 사용해보고 플러그인도 만들어보고 그러면 파악이 더 쉽게 될지도...
개발자분이시라니 플러그인 한부분만 알려고 하는것 보다는 태터 소스 전체를 살포시 열어보심이 더 유익할지도...

당신의 삶속에 매화꽃 향기처럼 늘 아름다운 향기로 가득하길...
# J.Parker

9

답글: 플러그인의 작동원리 설명좀 부탁드려요.

J. Parker 작성:

1,000번 보는 것보다는 한번 태터블로그 사용해보고 플러그인도 만들어보고 그러면 파악이 더 쉽게 될지도...
개발자분이시라니 플러그인 한부분만 알려고 하는것 보다는 태터 소스 전체를 살포시 열어보심이 더 유익할지도...

그렇죠 그러면서 plugin도 하나 짜고, 버그 리포팅도 하고 버그 수정도 하다보면 빠져드는겁니다..

( -_-)>

잡담 전문 인생

10

답글: 플러그인의 작동원리 설명좀 부탁드려요.

gofeel 작성:
J. Parker 작성:

1,000번 보는 것보다는 한번 태터블로그 사용해보고 플러그인도 만들어보고 그러면 파악이 더 쉽게 될지도...
개발자분이시라니 플러그인 한부분만 알려고 하는것 보다는 태터 소스 전체를 살포시 열어보심이 더 유익할지도...

그렇죠 그러면서 plugin도 하나 짜고, 버그 리포팅도 하고 버그 수정도 하다보면 빠져드는겁니다..

( -_-)>

저를 보세요. 맨땅에 헤딩하는 것부터 시작했잖아요. 이상적인 본보기죠? (...)

11

답글: 플러그인의 작동원리 설명좀 부탁드려요.

graphittie님은 정말...;;;
태터툴즈 만지면서 php를 배우셨다고 들은 것 같은데 어느새 관리자 화면 스킨 기능을 만드신다거나....

문제의 답은 우리 안에 있다.
내면에 귀를 기울여 보자.

12

답글: 플러그인의 작동원리 설명좀 부탁드려요.

그렇다면요, 제가 임의로 치환자를 만들어 넣고 난 뒤에 그걸 플러그인의 index.xml 파일과 index.php에 적용을 시키면 되짆아요?

예를 들어서, skin.html의 헤더 부분에 [##_tablink_##] 라는 치환자를 넣고난 뒤,

index.xml

    <binding>
        <tag name="tablink" handler="setTabMenu" />
    </binding>
index.php

function setTabMenu($target)
{
    global $blog;
    echo '<script>alert("'.$blog['name'].'");</script>';
    ...

    return $target;
}

이렇게 하면 일단 알럿창 하나 뜨고 시작해야 하는 것 맞지 않나요?
그런데, 안되는 이유는 뭘까요? 1.0.6.1 버전입니다.

Nothing is Impossible
http://www.justinchronicles.net

13

답글: 플러그인의 작동원리 설명좀 부탁드려요.

Justin 작성:

이렇게 하면 일단 알럿창 하나 뜨고 시작해야 하는 것 맞지 않나요?
그런데, 안되는 이유는 뭘까요? 1.0.6.1 버전입니다.

이상 없습니다. 1.0.6.1은 아니지만 정상 동작합니다. 어떤 플러그인을 만드시려고 하는지
그리고, 이왕이면 전체 코드를 공개해주시면 정확하게 진단이 가능할것 같습니다.

당신의 삶속에 매화꽃 향기처럼 늘 아름다운 향기로 가득하길...
# J.Parker

14

답글: 플러그인의 작동원리 설명좀 부탁드려요.

J. Parker 작성:
Justin 작성:

이렇게 하면 일단 알럿창 하나 뜨고 시작해야 하는 것 맞지 않나요?
그런데, 안되는 이유는 뭘까요? 1.0.6.1 버전입니다.

이상 없습니다. 1.0.6.1은 아니지만 정상 동작합니다. 어떤 플러그인을 만드시려고 하는지
그리고, 이왕이면 전체 코드를 공개해주시면 정확하게 진단이 가능할것 같습니다.

그렇죠? 저도 이상없다고 생각하는데, 실제로는 그렇지 않으니... ㅡㅡ;
전체 코드와 skin.html 부분은 아래와 같습니다.

index.xml

...
    <binding>
        <tag name="tab_link_menu" handler="getTabLinkMenu" />
    </binding>
index.php

function getTabLinkMenu($target)
{
    global $blog;

    $arrLinks    = getLinks($blog['name']);
    $strLink    = '';
    foreach($arrLinks as $arrLink)
    {
        $strLink    .= '<li><a href="'.$arrLink('url').'" target="_self">'.$arrLink('value').'</a></li>'."\n";
    }
    return $strLink;
}

function getLinks($strVal)
{
    $arrLinks    = array();
    switch ($strVal)
    {
        case 'test.blog':
        case 'chronicles':
        case 'tools':
            $arrLinks[]    = array('value' => 'Home',            'url' => 'http://www.justinchronicles.net/index.php');
            $arrLinks[]    = array('value' => 'Chronicles',    'url' => 'http://www.justinchronicles.net/chronicles');
            $arrLinks[]    = array('value' => 'Daniel',        'url' => 'http://danielsdiary.tistory.com');
            $arrLinks[]    = array('value' => 'Tools',            'url' => 'http://www.letmeshow.info/tools');
            $arrLinks[]    = array('value' => 'Photo',            'url' => 'http://justinphoto.tistory.com');
            break;
        default:
            break;
    }
    return $arrLinks;
}
skin.html

        <!-- header -->
        <div id="header">
...
            <!-- navigation menu -->
            <ul>
                [##_tab_link_menu_##]
                <li><a href="[##_taglog_link_##]">Tags</a></li>
                <li><a href="[##_guestbook_link_##]">Guestbook</a></li>
            </ul>
            <!-- navigation menu -->
        </div>
        <!-- header -->

무엇이 문제일까요? 플러그인 설정이 잘못됐을 때 나타나는 백지화면 뿐이라서요. ㅡㅜ

Nothing is Impossible
http://www.justinchronicles.net

15

답글: 플러그인의 작동원리 설명좀 부탁드려요.

좀 무식한 방법이지만 config.php에 보면 ini_set('display_errors', 'off'); 이 부분이 있는데 ini_set('display_errors', 'on'); 이렇게 바꿔보세요.
문제가 있다면 php 문법 오류에 대해 나옵니다. (블로그도 정상적으로 열리고요.)

문제 해결되면 재빨리 도로 off로 돌려놓는 쎈쓰!! (써보면 아시겠지만 notice 알림에 대한 것도 나오다보니 보기가 안좋습니다;; )

나니 (2006-12-01 07:36:17)에 의해 마지막으로 수정

하늘은 스스로 삽질하는 자를 삽으로 팬다

16

답글: 플러그인의 작동원리 설명좀 부탁드려요.

나니 작성:

좀 무식한 방법이지만 config.php에 보면 ini_set('display_errors', 'off'); 이 부분이 있는데 ini_set('display_errors', 'on'); 이렇게 바꿔보세요.
문제가 있다면 php 문법 오류에 대해 나옵니다. (블로그도 정상적으로 열리고요.)

문제 해결되면 재빨리 도로 off로 돌려놓는 쎈쓰!! (써보면 아시겠지만 notice 알림에 대한 것도 나오다보니 보기가 안좋습니다;; )

오... 그것이 디버깅 모드였군뇽. 저 그런거 좋아라 합니다. 어차피 랩탑에서 개발서버 돌려놓으면서 작업하니까 시간 두고 천천히 해봐야겠네요. ^^ 다행입니다. 뭐가 보여야 디버깅도 할텐데, 그게 안되니 어찌나 답답하던지 말이죵. ^^;

성공하면 다시 올려보겠습니다. ^^;

Nothing is Impossible
http://www.justinchronicles.net

17

답글: 플러그인의 작동원리 설명좀 부탁드려요.

Justin 작성:
index.php

function getTabLinkMenu($target)
{
    global $blog;

    $arrLinks    = getLinks($blog['name']);
    $strLink    = '';
    foreach($arrLinks as $arrLink)
    {
        $strLink    .= '<li><a href="'.$arrLink('url').'" target="_self">'.$arrLink('value').'</a></li>'."\n";
    }
    return $strLink;
}

위에도 써놨지만 $target을 받으셔서 반드시 리턴을 해주셔야 합니다.

뭐 백지로 나오는 이유는 $arrLink('url') 때문인거 같습니다만..;;

$arrLink('url') 이 아니라 $arrLink['url'] 이겠죠. smile

18

답글: 플러그인의 작동원리 설명좀 부탁드려요.

나니 작성:

좀 무식한 방법이지만 config.php에 보면 ini_set('display_errors', 'off'); 이 부분이 있는데 ini_set('display_errors', 'on'); 이렇게 바꿔보세요.
문제가 있다면 php 문법 오류에 대해 나옵니다. (블로그도 정상적으로 열리고요.)

문제 해결되면 재빨리 도로 off로 돌려놓는 쎈쓰!! (써보면 아시겠지만 notice 알림에 대한 것도 나오다보니 보기가 안좋습니다;; )

허걱, 그런데 이걸 on으로 돌려놓고 나니까 관리자 화면으로 들어가지질 않네요. ㅡㅜ
이번엔 관리자 로그인 화면이 백지... ㅡㅡ;

세션 때문인지 브라우저를 다 끄고 새로 접속해도 마찬가지... ㅡㅡ;
어쩌면 좋으려나...

Nothing is Impossible
http://www.justinchronicles.net

19

답글: 플러그인의 작동원리 설명좀 부탁드려요.

Justin 작성:

무엇이 문제일까요? 플러그인 설정이 잘못됐을 때 나타나는 백지화면 뿐이라서요. ㅡㅜ

잘못하셨네요.

index.php

첫번째
배열값 전달 잘못하셨습니다.
- 수정전

$strLink    .= '<li><a href="'.$arrLink('url').'" target="_self">'.$arrLink('value').'</a></li>'."\n";

- 수정후

$strLink    .= '<li><a href="'.$arrLink['url'].'" target="_self">'.$arrLink['value'].'</a></li>'."\n";

수정전에 보시면 배열인자값을 ()괄호로 받는게 아니라 []으로 받아야 합니다.

두번째
getLinks() 함수를 쓰셨는데 태터툴즈에 이미 정의되어있는 함수명입니다. 다른 함수명으로 변경하셔야 합니다.
getTablinks()와 같이 구별되게 태터에 이미 정의된 함수와 중복되면 에러납니다.

헉, 쓰고나니 답변들이 쭉쭉...

당신의 삶속에 매화꽃 향기처럼 늘 아름다운 향기로 가득하길...
# J.Parker

20

답글: 플러그인의 작동원리 설명좀 부탁드려요.

다들 도움 주셔서 고맙습니다. ^^ 플러그인 문제 해결했구요,

다만, 나니님께서 가르쳐주신 그 display_error, off -> on 문제가 개발서버에서 한번 바꿔봤더니 아예 관리자 로그인 화면으로 접속이 불가능해진지라... 백지화면이네요. 다시 off로 돌려놔도 그렇네요.

이거는 어떻게 해결하죠? ㅡㅡ?

Nothing is Impossible
http://www.justinchronicles.net

21

답글: 플러그인의 작동원리 설명좀 부탁드려요.

그걸 수정했다고해서 그런 문제가 생길 이유가 없습니다.;;
뭔가 오타가 있으신게 아닐까라고 추측해봅니다.

22

답글: 플러그인의 작동원리 설명좀 부탁드려요.

Peris 작성:

그걸 수정했다고해서 그런 문제가 생길 이유가 없습니다.;;
뭔가 오타가 있으신게 아닐까라고 추측해봅니다.

그렇죠? 저건 php.ini 파일에 규정되어 있는걸 잠깐 돌려놓는 것 뿐이니...
그런데, 다시 off 로 했다고 해서 안되는건 사실인걸요.

다른건 건디린 적이 없으니까용. ㅡ0ㅡ;
첫화면은 정상적으로 뜨는데 관리자 로그인화면에서 저런답니다. ㅡㅜ

Nothing is Impossible
http://www.justinchronicles.net

23

답글: 플러그인의 작동원리 설명좀 부탁드려요.

Justin 작성:
Peris 작성:

그걸 수정했다고해서 그런 문제가 생길 이유가 없습니다.;;
뭔가 오타가 있으신게 아닐까라고 추측해봅니다.

그렇죠? 저건 php.ini 파일에 규정되어 있는걸 잠깐 돌려놓는 것 뿐이니...
그런데, 다시 off 로 했다고 해서 안되는건 사실인걸요.

다른건 건디린 적이 없으니까용. ㅡ0ㅡ;
첫화면은 정상적으로 뜨는데 관리자 로그인화면에서 저런답니다. ㅡㅜ

정 안되시면 config.php 파일을 삭제하신 후에 setup.php를 통해서 재설정 한번 해보세요.

24

답글: 플러그인의 작동원리 설명좀 부탁드려요.

휘유우... 결국 config.php 파일 지우고 새로 세팅해서 테스트환경 만들어놨네요. ^^;
남들은 1.1로 잘만 올라가는데, 아직까지 1.0.6.1도 새로와서 말이지요. ㅎㅎ

Nothing is Impossible
http://www.justinchronicles.net

25

답글: 플러그인의 작동원리 설명좀 부탁드려요.

너무 많이 적었다가.
다 지웠습니다. orz

플러그인 셈플은 없나요 ???
요 몇일전에, 플러그인 하나 만들어보려고 하다가.
몇번 헤딩하고, 만들었다가, 올리구.
잘못된거 확인하고, 수정해서 올릴 엄두가 나지 않아서.
걍 지워버렸었습니다.

물론 플러그인 예제란게 완벽할 수는 없다고 생각합니다.
기존의 플러그인과 테더 셈플로 소화하는 방법이 가장 좋은 방법이란것도 알구요.
하지만, 테더 개발자가 아닌이상, 그리고 테더의 문제를 해결하려 하는게 아닌이상.
그리고, 학생때와같이 시간이 남거나 하는게 아닌이상,
위와같이, 테더소스보고 이해하고, 하기가 쉽지 않습니다.

잘 만들어진 셈플코드,
100개의 메뉴얼보다 훨씬 효과적으로 내용을 전달하며,
100배의 시간을 들여 작동원리를 이해하는것보다 훨씬 효율적입니다.

물론, 제가 시간이 많다면,
테더개발에 참여할 수 있다면, 얘기는 다르겠지만...
제가 다른 시간낼 수 없는 작업을 하고 있는 중이라서.. orz

htna (2006-12-01 16:16:23)에 의해 마지막으로 수정

Yesterday is history, tomorrow is a mystery, and today is a gift; that's why we call it - present