Search results for '쿠키'

CookieHandler를 이용한 쿠키 관리



자바 플랫폼의 경우, URL을 통한 오브젝트 액세스는 일련의 프로토콜 핸들러에 의해 관리된다. URL의 첫 부분은 사용되는 프로토콜을 알려주는데, 예를 들어 URL이 file:로 시작되면 로컬 파일 시스템 상에서 리소스를 액세스할 수 있다. 또, URL이 http:로 시작되면 인터넷을 통해 리소스 액세스가 이루어진다. 한편, J2SE 5.0은 시스템 내에 반드시 존재해야 하는 프로토콜 핸들러(http, https, file, ftp, jar 등)를 정의한다.

J2SE 5.0은 http 프로토콜 핸들러 구현의 일부로 CookieHandler를 추가하는데, 이 클래스는 쿠키를 통해 시스템 내에서 상태(state)가 어떻게 관리될 수 있는지를 보여준다. 쿠키는 브라우저의 캐시에 저장된 데이터의 단편이며, 한번 방문한 웹 사이트를 다시 방문할 경우 쿠키 데이터를 이용하여 재방문자임을 식별한다. 쿠키는 가령 온라인 쇼핑 카트 같은 상태 정보를 기억할 수 있게 해준다. 쿠키에는 브라우저를 종료할 때까지 단일 웹 세션 동안 데이터를 보유하는 단기 쿠키와 1주 또는 1년 동안 데이터를 보유하는 장기 쿠키가 있다.

J2SE 5.0에서 기본값으로 설치되는 핸들러는 없으나, 핸들러를 등록하여 애플리케이션이 쿠키를 기억했다가 http 접속 시에 이를 반송하도록 할 수는 있다.

CookieHandler 클래스는 두 쌍의 관련 메소드를 가지는 추상 클래스이다. 첫 번째 쌍의 메소드는 현재 설치된 핸들러를 찾아내고 각자의 핸들러를 설치할 수 있게 한다.

  • getDefault()
  • setDefault(CookieHandler)

보안 매니저가 설치된 애플리케이션의 경우, 핸들러를 얻고 이를 설정하려면 특별 허가를 받아야 한다. 현재의 핸들러를 제거하려면 핸들러로 null을 입력한다. 또한 앞서 얘기했듯이 기본값으로 설정되어 있는 핸들러는 없다.

두 번째 쌍의 메소드는 각자가 관리하는 쿠키 캐시로부터 쿠키를 얻고 이를 설정할 수 있게 한다.

  • get(URI uri, Map<String, List<String>> requestHeaders)
  • put(URI uri, Map<String, List<String>> responseHeaders)

get() 메소드는 캐시에서 저장된 쿠기를 검색하여 requestHeaders를 추가하고, put() 메소드는 응답 헤더에서 쿠키를 찾아내어 캐시에 저장한다.

여기서 보듯이 핸들러를 작성하는 일은 실제로는 간단하다. 그러나 캐시를 정의하는 데는 약간의 추가 작업이 더 필요하다. 일례로, 커스텀 CookieHandler, 쿠키 캐시, 테스트 프로그램을 사용해 보기로 하자. 테스트 프로그램은 아래와 같은 형태를 띠고 있다.


  1.    import java.io.*;
  2.    import java.net.*;
  3.    import java.util.*;
  4.  
  5.    public class Fetch {
  6.      public static void main(String args[]) throws Exception {
  7.        if (args.length == 0) {
  8.          System.err.println("URL missing");
  9.          System.exit(-1);
  10.        }
  11.        String urlString = args[0];
  12.        CookieHandler.setDefault(new ListCookieHandler());
  13.        URL url = new URL(urlString);
  14.        URLConnection connection = url.openConnection();
  15.        Object obj = connection.getContent();
  16.        url = new URL(urlString);
  17.        connection = url.openConnection();
  18.        obj = connection.getContent();
  19.      }
  20.    }


먼저 이 프로그램은 간략하게 정의될 ListCookieHandler를 작성하고 설치한다. 그런 다음 URL(명령어 라인에서 입력)의 접속을 열어 내용을 읽는다. 이어서 프로그램은 또 다른 URL의 접속을 열고 동일한 내용을 읽는다. 첫 번째 내용을 읽을 때 응답에는 저장될 쿠키가, 두 번째 요청에는 앞서 저장된 쿠키가 포함된다.

이제 이것을 관리하는 방법에 대해 알아보기로 하자. 처음에는 URLConnection 클래스를 이용한다. 웹 상의 리소스는 URL을 통해 액세스할 수 있으며, URL 작성 후에는 URLConnection 클래스의 도움을 받아 사이트와의 통신을 위한 인풋 또는 아웃풋 스트림을 얻을 수 있다.


  1.    String urlString = ...;
  2.    URL url = new URL(urlString);
  3.    URLConnection connection = url.openConnection();
  4.    InputStream is = connection.getInputStream();
  5.    // .. read content from stream


접속으로부터 이용 가능한 정보에는 일련의 헤더들이 포함될 수 있는데, 이는 사용중인 프로토콜에 의해 결정된다. 헤더를 찾으려면 URLConnection 클래스를 사용하면 된다. 한편, 클래스는 헤더 정보 검색을 위한 다양한 메소드를 가지는데, 여기에는 다음 사항들이 포함된다.

  • getHeaderFields() - 가용한 필드의 Map을 얻는다.
  • getHeaderField(String name) - 이름 별로 헤더 필드를 얻는다.
  • getHeaderFieldDate(String name, long default) - 날짜로 된 헤더 필드를 얻는다.
  • getHeaderFieldInt(String name, int default) - 숫자로 된 헤더 필드를 얻는다.
  • getHeaderFieldKey(int n) or getHeaderField(int n) - 위치 별로 헤더 필드를 얻는다.

일례로, 다음 프로그램은 주어진 URL의 모든 헤더를 열거한다


  1.    import java.net.*;
  2.    import java.util.*;
  3.  
  4.    public class ListHeaders {
  5.      public static void main(String args[]) throws Exception {
  6.        if (args.length == 0) {
  7.          System.err.println("URL missing");
  8.        }
  9.        String urlString = args[0];
  10.        URL url = new URL(urlString);
  11.        URLConnection connection = url.openConnection();
  12.        Map<String,List<String>> headerFields =
  13.          connection.getHeaderFields();
  14.        Set<String> set = headerFields.keySet();
  15.        Iterator<String> itor = set.iterator();
  16.        while (itor.hasNext()) {
  17.          String key = itor.next();
  18.          System.out.println("Key: " + key + " / " +
  19.            headerFields.get(key));
  20.        }
  21.      }
  22.    }


ListHeaders 프로그램은 가령 http://java.sun.com 같은 URL을 아규먼트로 취하고 사이트로부터 수신한 모든 헤더를 표시한다. 각 헤더는 아래의 형태로 표시된다.

   Key: <key> / [<value>]

따라서 다음을 입력하면,

  >> java ListHeaders http://java.sun.com

다음과 유사한 내용이 표시되어야 한다.

   Key: Set-Cookie / [SUN_ID=192.168.0.1:269421125489956; 
   EXPIRES=Wednesday, 31- Dec-2025 23:59:59 GMT; 
   DOMAIN=.sun.com; PATH=/]
   Key: Set-cookie / 
   [JSESSIONID=688047FA45065E07D8792CF650B8F0EA;Path=/]
   Key: null / [HTTP/1.1 200 OK]
   Key: Transfer-encoding / [chunked]
   Key: Date / [Wed, 31 Aug 2005 12:05:56 GMT]
   Key: Server / [Sun-ONE-Web-Server/6.1]
   Key: Content-type / [text/html;charset=ISO-8859-1]   

(위에 표시된 결과에서 긴 행은 수동으로 줄바꿈한 것임)

이는 해당 URL에 대한 헤더들만을 표시하며, 그곳에 위치한 HTML 페이지는 표시하지 않는다. 표시되는 정보에는 사이트에서 사용하는 웹 서버와 로컬 시스템의 날짜 및 시간이 포함되는 사실에 유의할 것. 아울러 2개의 ‘Set-Cookie’ 행에도 유의해야 한다. 이들은 쿠키와 관련된 헤더들이며, 쿠키는 헤더로부터 저장된 뒤 다음의 요청과 함께 전송될 수 있다.

이제 CookieHandler를 작성해 보자. 이를 위해서는 두 추상 메소드 CookieHandler: get() 과ㅓ put()을 구현해야 한다.

  •   public void put(
        URI uri,
        Map<String, List<String>> responseHeaders)
          throws IOException
    
  •   public Map<String, List<String>> get(
        URI uri,
        Map<String, List<String>> requestHeaders)
          throws IOException
    

우선 put() 메소드로 시작한다. 이 경우 응답 헤더에 포함된 모든 쿠키가 캐시에 저장된다.put()을 구현하기 위해서는 먼저 ‘Set-Cookie’ 헤더의 List를 얻어야한다. 이는 Set-cookieSet-Cookie2 같은 다른 해당 헤더로 확장될 수 있다.

   List<String> setCookieList =
     responseHeaders.get("Set-Cookie");


쿠키의 리스트를 확보한 후 각 쿠키를 반복(loop)하고 저장한다. 쿠키가 이미 존재할 경우에는 기존의 것을 교체하도록 한다.


  1.     if (setCookieList != null) {
  2.       for (String item : setCookieList) {
  3.         Cookie cookie = new Cookie(uri, item);
  4.         // Remove cookie if it already exists in cache
  5.         // New one will replace it
  6.         for (Cookie existingCookie : cache) {
  7.           ...
  8.         }
  9.         System.out.println("Adding to cache: " + cookie);
  10.         cache.add(cookie);
  11.       }
  12.     }


여기서 ‘캐시’는 데이터베이스에서 Collections Framework에서 List에 이르기까지 어떤 것이든 될 수 있다. Cookie 클래스는 나중에 정의되는데, 이는 사전 정의되는 클래스에 속하지 않는다.

본질적으로, 그것이 put() 메소드에 대해 주어진 전부이며, 응답 헤더 내의 각 쿠키에 대해 메소드는 쿠키를 캐시에 저장한다.

get() 메소드는 정반대로 작동한다. URI에 해당되는 캐시 내의 각 쿠키에 대해, get() 메소드는 이를 요청 헤더에 추가한다. 복수의 쿠키에 대해서는 콤마로 구분된(comma-delimited) 리스트를 작성한다. get() 메소드는 맵을 반환하며, 따라서 메소드는 기존의 헤더 세트로 Map 아규먼트를 취하게 된다. 그 아규먼트에 캐시 내의 해당 쿠키를 추가해야 하지만 아규먼트는 불변의 맵이며, 또 다른 불변의 맵을 반환해야만 한다. 따라서 기존의 맵을 유효한 카피에 복사한 다음 추가를 마친 후 불변의 맵을 반환해야 한다.

get() 메소드를 구현하기 위해서는 먼저 캐시를 살펴보고 일치하는 쿠키를 얻은 다음 만료된 쿠키를 모두 제거하도록 한다.


  1.     // Retrieve all the cookies for matching URI
  2.     // Put in comma-separated list
  3.     StringBuilder cookies = new StringBuilder();
  4.     for (Cookie cookie : cache) {
  5.       // Remove cookies that have expired
  6.       if (cookie.hasExpired()) {
  7.         cache.remove(cookie);
  8.       } else if (cookie.matches(uri)) {
  9.         if (cookies.length() > 0) {
  10.           cookies.append(", ");
  11.         }
  12.         cookies.append(cookie.toString());
  13.       }
  14.     }


이 경우에도 Cookie 클래스는 간략하게 정의되는데, 여기에는 hasExpired()matches() 등 2개의 요청된 메소드가 표시되어 있다. hasExpired() 메소드는 특정 쿠키의 만료 여부를 보고하고, matches() 메소드는 쿠키가 메소드에 패스된 URI에 적합한지 여부를 보고한다.

get() 메소드의 다음 부분은 작성된 StringBuilder 오브젝트를 취하고 그 스트링필드 버전을 수정 불가능한 Map에 put한다(이 경우에는 해당 키 ‘Cookie’를 이용).


  1.     // Map to return
  2.     Map<String, List<String>> cookieMap =
  3.       new HashMap<String, List<String>>(requestHeaders);
  4.  
  5.     // Convert StringBuilder to List, store in map
  6.     if (cookies.length() > 0) {
  7.       List<String> list =
  8.         Collections.singletonList(cookies.toString());
  9.       cookieMap.put("Cookie", list);
  10.     }
  11.     return Collections.unmodifiableMap(cookieMap);



다음은 런타임의 정보 표시를 위해 println이 일부 추가되어 완성된 CookieHandler 정의이다.


  1.    import java.io.*;
  2.    import java.net.*;
  3.    import java.util.*;
  4.  
  5.    public class ListCookieHandler extends CookieHandler {
  6.  
  7.      // "Long" term storage for cookies, not serialized so only
  8.      // for current JVM instance
  9.      private List<Cookie> cache = new LinkedList<Cookie>();
  10.  
  11.      /**
  12.       * Saves all applicable cookies present in the response
  13.       * headers into cache.
  14.       * @param uri URI source of cookies
  15.       * @param responseHeaders Immutable map from field names to
  16.       * lists of field
  17.       *   values representing the response header fields returned
  18.       */
  19.  
  20.      public void put(
  21.          URI uri,
  22.          Map<String, List<String>> responseHeaders)
  23.            throws IOException {
  24.  
  25.        System.out.println("Cache: " + cache);
  26.        List<String> setCookieList =
  27.          responseHeaders.get("Set-Cookie");
  28.        if (setCookieList != null) {
  29.          for (String item : setCookieList) {
  30.            Cookie cookie = new Cookie(uri, item);
  31.            // Remove cookie if it already exists
  32.            // New one will replace
  33.            for (Cookie existingCookie : cache) {
  34.              if((cookie.getURI().equals(
  35.                existingCookie.getURI())) &&
  36.                 (cookie.getName().equals(
  37.                   existingCookie.getName()))) {
  38.               cache.remove(existingCookie);
  39.               break;
  40.             }
  41.           }
  42.           System.out.println("Adding to cache: " + cookie);
  43.           cache.add(cookie);
  44.         }
  45.       }
  46.     }
  47.  
  48.     /**
  49.      * Gets all the applicable cookies from a cookie cache for
  50.      * the specified uri in the request header.
  51.      *
  52.      * @param uri URI to send cookies to in a request
  53.      * @param requestHeaders Map from request header field names
  54.      * to lists of field values representing the current request
  55.      * headers
  56.      * @return Immutable map, with field name "Cookie" to a list
  57.      * of cookies
  58.      */
  59.  
  60.     public Map<String, List<String>> get(
  61.         URI uri,
  62.         Map<String, List<String>> requestHeaders)
  63.           throws IOException {
  64.  
  65.       // Retrieve all the cookies for matching URI
  66.       // Put in comma-separated list
  67.       StringBuilder cookies = new StringBuilder();
  68.       for (Cookie cookie : cache) {
  69.         // Remove cookies that have expired
  70.         if (cookie.hasExpired()) {
  71.           cache.remove(cookie);
  72.         } else if (cookie.matches(uri)) {
  73.           if (cookies.length() > 0) {
  74.             cookies.append(", ");
  75.           }
  76.           cookies.append(cookie.toString());
  77.         }
  78.       }
  79.  
  80.       // Map to return
  81.       Map<String, List<String>> cookieMap =
  82.         new HashMap<String, List<String>>(requestHeaders);
  83.  
  84.       // Convert StringBuilder to List, store in map
  85.       if (cookies.length() > 0) {
  86.         List<String> list =
  87.           Collections.singletonList(cookies.toString());
  88.         cookieMap.put("Cookie", list);
  89.       }
  90.         System.out.println("Cookies: " + cookieMap);
  91.     return Collections.unmodifiableMap(cookieMap);
  92.     }
  93.   }



퍼즐의 마지막 조각은 Cookie 클래스 그 자체이며, 대부분의 정보는 생성자(constructor) 내에 존재한다. 생성자 내의 정보 조각(비트)들을 uri 및 헤더 필드로부터 파싱해야 한다. 만료일에는 하나의 포맷이 사용되어야 하지만 인기 있는 웹 사이트에서는 복수의 포맷이 사용되는 경우를 볼 수 있다. 여기서는 그다지 까다로운 점은 없고, 쿠키 경로, 만료일, 도메인 등과 같은 다양한 정보 조각을 저장하기만 하면 된다.


  1.    public Cookie(URI uri, String header) {
  2.      String attributes[] = header.split(";");
  3.      String nameValue = attributes[0].trim();
  4.      this.uri = uri;
  5.      this.name = nameValue.substring(0, nameValue.indexOf('='));
  6.      this.value = nameValue.substring(nameValue.indexOf('=')+1);
  7.      this.path = "/";
  8.      this.domain = uri.getHost();
  9.  
  10.      for (int i=1; i < attributes.length; i++) {
  11.        nameValue = attributes[i].trim();
  12.        int equals = nameValue.indexOf('=');
  13.        if (equals == -1) {
  14.          continue;
  15.        }
  16.        String name = nameValue.substring(0, equals);
  17.        String value = nameValue.substring(equals+1);
  18.        if (name.equalsIgnoreCase("domain")) {
  19.          String uriDomain = uri.getHost();
  20.          if (uriDomain.equals(value)) {
  21.            this.domain = value;
  22.          } else {
  23.            if (!value.startsWith(".")) {
  24.              value = "." + value;
  25.            }
  26.            uriDomain =
  27.              uriDomain.substring(uriDomain.indexOf('.'));
  28.            if (!uriDomain.equals(value)) {
  29.              throw new IllegalArgumentException(
  30.                "Trying to set foreign cookie");
  31.            }
  32.            this.domain = value;
  33.          }
  34.        } else if (name.equalsIgnoreCase("path")) {
  35.          this.path = value;
  36.        } else if (name.equalsIgnoreCase("expires")) {
  37.          try {
  38.            this.expires = expiresFormat1.parse(value);
  39.          } catch (ParseException e) {
  40.            try {
  41.              this.expires = expiresFormat2.parse(value);
  42.            } catch (ParseException e2) {
  43.              throw new IllegalArgumentException(
  44.                "Bad date format in header: " + value);
  45.            }
  46.          }
  47.        }
  48.      }
  49.   }



클래스 내의 다른 메소드들은 단지 저장된 데이터를 반환하거나 만료 여부를 확인한다.

  1.    public boolean hasExpired() {
  2.      if (expires == null) {
  3.        return false;
  4.      }
  5.      Date now = new Date();
  6.      return now.after(expires);
  7.    }
  8.  
  9.    public String toString() {
  10.      StringBuilder result = new StringBuilder(name);
  11.      result.append("=");
  12.      result.append(value);
  13.      return result.toString();
  14.    }



쿠키가 만료된 경우에는 ‘match’가 표시되면 안 된다.

  1.    public boolean matches(URI uri) {
  2.  
  3.      if (hasExpired()) {
  4.        return false;
  5.      }
  6.  
  7.      String path = uri.getPath();
  8.      if (path == null) {
  9.        path = "/";
  10.      }
  11.  
  12.      return path.startsWith(this.path);
  13.    }



Cookie
스펙이 도메인과 경로 양쪽에 대해 매치를 수행할 것을 요구한다는 점에 유의해야 한다. 단순성을 위해 여기서는 경로 매치만을 확인한다.

아래는 전체 Cookie 클래스의 정의이다.

  1.    import java.net.*;
  2.    import java.text.*;
  3.    import java.util.*;
  4.  
  5.    public class Cookie {
  6.  
  7.      String name;
  8.      String value;
  9.      URI uri;
  10.      String domain;
  11.      Date expires;
  12.      String path;
  13.  
  14.      private static DateFormat expiresFormat1
  15.          = new SimpleDateFormat("E, dd MMM yyyy k:m:s 'GMT'", Locale.US);
  16.  
  17.      private static DateFormat expiresFormat2
  18.         = new SimpleDateFormat("E, dd-MMM-yyyy k:m:s 'GMT'", Local.US);
  19.        
  20.  
  21.      /**
  22.       * Construct a cookie from the URI and header fields
  23.       *
  24.       * @param uri URI for cookie
  25.       * @param header Set of attributes in header
  26.       */
  27.      public Cookie(URI uri, String header) {
  28.        String attributes[] = header.split(";");
  29.        String nameValue = attributes[0].trim();
  30.        this.uri = uri;
  31.        this.name =
  32.          nameValue.substring(0, nameValue.indexOf('='));
  33.        this.value =
  34.          nameValue.substring(nameValue.indexOf('=')+1);
  35.        this.path = "/";
  36.        this.domain = uri.getHost();
  37.  
  38.        for (int i=1; i < attributes.length; i++) {
  39.          nameValue = attributes[i].trim();
  40.          int equals = nameValue.indexOf('=');
  41.          if (equals == -1) {
  42.            continue;
  43.          }
  44.          String name = nameValue.substring(0, equals);
  45.          String value = nameValue.substring(equals+1);
  46.          if (name.equalsIgnoreCase("domain")) {
  47.            String uriDomain = uri.getHost();
  48.            if (uriDomain.equals(value)) {
  49.              this.domain = value;
  50.            } else {
  51.              if (!value.startsWith(".")) {
  52.                value = "." + value;
  53.              }
  54.              uriDomain = uriDomain.substring(
  55.                uriDomain.indexOf('.'));
  56.              if (!uriDomain.equals(value)) {
  57.                throw new IllegalArgumentException(
  58.                  "Trying to set foreign cookie");
  59.              }
  60.              this.domain = value;
  61.            }
  62.          } else if (name.equalsIgnoreCase("path")) {
  63.            this.path = value;
  64.          } else if (name.equalsIgnoreCase("expires")) {
  65.            try {
  66.              this.expires = expiresFormat1.parse(value);
  67.            } catch (ParseException e) {
  68.              try {
  69.                this.expires = expiresFormat2.parse(value);
  70.              } catch (ParseException e2) {
  71.                throw new IllegalArgumentException(
  72.                  "Bad date format in header: " + value);
  73.              }
  74.            }
  75.          }
  76.        }
  77.      }
  78.  
  79.      public boolean hasExpired() {
  80.        if (expires == null) {
  81.          return false;
  82.        }
  83.        Date now = new Date();
  84.        return now.after(expires);
  85.      }
  86.  
  87.      public String getName() {
  88.        return name;
  89.      }
  90.  
  91.      public URI getURI() {
  92.        return uri;
  93.      }
  94.  
  95.      /**
  96.       * Check if cookie isn't expired and if URI matches,
  97.       * should cookie be included in response.
  98.       *
  99.       * @param uri URI to check against
  100.       * @return true if match, false otherwise
  101.       */
  102.      public boolean matches(URI uri) {
  103.  
  104.        if (hasExpired()) {
  105.          return false;
  106.        }
  107.  
  108.       String path = uri.getPath();
  109.        if (path == null) {
  110.          path = "/";
  111.        }
  112.  
  113.        return path.startsWith(this.path);
  114.      }
  115.  
  116.      public String toString() {
  117.        StringBuilder result = new StringBuilder(name);
  118.        result.append("=");
  119.        result.append(value);
  120.        return result.toString();
  121.      }
  122.    }



이제 조각들이 모두 확보되었으므로 앞의 Fetch 예제를 실행할 수 있다.

   >> java Fetch http://java.sun.com

   Cookies: {Connection=[keep-alive], Host=[java.sun.com], 
    User-Agent=[Java/1.5.0_04], GET / HTTP/1.1=[null], 
    Content-type=[application/x-www-form-urlencoded], 
    Accept=[text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2]}
   Cache: []
   Adding to cache: SUN_ID=192.168.0.1:235411125667328
   Cookies: {Connection=[keep-alive], Host=[java.sun.com], 
    User-Agent=[Java/1.5.0_04], GET / HTTP/1.1=[null], 
    Cookie=[SUN_ID=192.168.0.1:235411125667328], 
    Content-type=[application/x-www-form-urlencoded], 
    Accept=[text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2]}
   Cache: [SUN_ID=192.168.0.1:235411125667328]

(위에 표시된 결과에서 긴 행은 수동으로 줄바꿈한 것임)

‘Cache’로 시작되는 행은 저장된 캐시를 나타낸다. 저장된 쿠키가 즉시 반환되지 않도록 put() 메소드 전에 get() 메소드가 어떻게 호출되는지에 대해 유의하도록 할 것.

쿠키와 URL 접속을 이용한 작업에 관해 자세히 알고 싶으면 자바 튜토리얼의 Custom Networking trail(영문)을 참조할 것. 이는 J2SE 1.4에 기반을 두고 있으므로 튜토리얼에는 아직 여기서 설명한 CookieHandler에 관한 정보가 실려 있지 않다. Java SE 6 ("Mustang")(영문) 릴리즈에서도 기본 CookieHandler 구현에 관한 내용을 찾아볼 수 있다.

2007/07/03 18:41 2007/07/03 18:41
Trackback Address:이 글에는 트랙백을 보낼 수 없습니다