ASP.net에서 실행되는 웹 참조 클라이언트에서 RAW Soap 데이터 가져 오기


95

현재 프로젝트에서 웹 서비스 클라이언트 문제를 해결하려고합니다. 서비스 서버 (대부분 LAMP)의 플랫폼을 잘 모르겠습니다. 나는 내 고객과의 잠재적 인 문제를 제거했기 때문에 울타리의 측면에 결함이 있다고 생각합니다. 클라이언트는 서비스 WSDL에서 자동 생성 된 표준 ASMX 유형 웹 참조 프록시입니다.

필요한 것은 RAW SOAP 메시지 (요청 및 응답)입니다.

이것에 대해 가장 좋은 방법은 무엇입니까?

답변:


135

web.configSOAP (Request / Response) Envelope를 얻기 위해 다음과 같이 변경했습니다 . 이렇게하면 모든 원시 SOAP 정보가 파일에 출력됩니다 trace.log.

<system.diagnostics>
  <trace autoflush="true"/>
  <sources>
    <source name="System.Net" maxdatasize="1024">
      <listeners>
        <add name="TraceFile"/>
      </listeners>
    </source>
    <source name="System.Net.Sockets" maxdatasize="1024">
      <listeners>
        <add name="TraceFile"/>
      </listeners>
    </source>
  </sources>
  <sharedListeners>
    <add name="TraceFile" type="System.Diagnostics.TextWriterTraceListener"
      initializeData="trace.log"/>
  </sharedListeners>
  <switches>
    <add name="System.Net" value="Verbose"/>
    <add name="System.Net.Sockets" value="Verbose"/>
  </switches>
</system.diagnostics>

2
이것은 VS 2010을 사용하여 2012 년 2 월에 작동하지 않습니다. 누구든지 더 최신 솔루션을 알고 있습니까?
qxotk

2
이것은 도움이됩니다. 그러나 제 경우에는 응답이 Gzip으로 압축되고 결합 된 출력에서 ​​ASCII 또는 16 진수 코드를 가져 오는 것이 고통 스럽습니다. 바이너리 또는 텍스트 전용 대체 출력 방법입니까?

2
@Dediqated-위의 예에서 initializeData 속성 값을 조정하여 trace.log 파일에 대한 경로를 설정할 수 있습니다.
DougCouto

3
더 이상 쓸모가 없으며 wireshark를 사용하여 원시 SOAP 데이터를 확인했습니다. 어쨌든 감사합니다!
2013 년

7
좋은. 하지만 나를위한 XML은 다음과 같습니다. System.Net Verbose : 0 : [14088] 00000000 : 3C 3F 78 6D 6C 20 76 65-72 73 69 6F 6E 3D 22 31 : <? xml version = "1 System.Net Verbose : 0 : [14088] 00000010 : 2E 30 22 20 65 6E 63 6F-64 69 6E 67 3D 22 75 74 : .0 "encoding ="ut ....... 형식 지정 방법 또는 xml 데이터 만 포함 .?
Habeeb 2015

35

전체 요청 및 응답을 로그 파일에 기록하는 SoapExtension을 구현할 수 있습니다. 그런 다음 web.config에서 SoapExtension을 활성화하여 디버깅 목적으로 쉽게 켜고 끌 수 있습니다. 다음은 내가 사용하기 위해 찾아서 수정 한 예입니다. 제 경우에는 로깅이 log4net에 의해 수행되었지만 로그 메서드를 자신의 것으로 바꿀 수 있습니다.

public class SoapLoggerExtension : SoapExtension
{
    private static readonly ILog log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
    private Stream oldStream;
    private Stream newStream;

    public override object GetInitializer(LogicalMethodInfo methodInfo, SoapExtensionAttribute attribute)
    {
        return null;
    }

    public override object GetInitializer(Type serviceType)
    {
        return null;
    }

    public override void Initialize(object initializer)
    {

    }

    public override System.IO.Stream ChainStream(System.IO.Stream stream)
    {
        oldStream = stream;
        newStream = new MemoryStream();
        return newStream;
    }

    public override void ProcessMessage(SoapMessage message)
    {

        switch (message.Stage)
        {
            case SoapMessageStage.BeforeSerialize:
                break;
            case SoapMessageStage.AfterSerialize:
                Log(message, "AfterSerialize");
                    CopyStream(newStream, oldStream);
                    newStream.Position = 0;
                break;
                case SoapMessageStage.BeforeDeserialize:
                    CopyStream(oldStream, newStream);
                    Log(message, "BeforeDeserialize");
                break;
            case SoapMessageStage.AfterDeserialize:
                break;
        }
    }

    public void Log(SoapMessage message, string stage)
    {

        newStream.Position = 0;
        string contents = (message is SoapServerMessage) ? "SoapRequest " : "SoapResponse ";
        contents += stage + ";";

        StreamReader reader = new StreamReader(newStream);

        contents += reader.ReadToEnd();

        newStream.Position = 0;

        log.Debug(contents);
    }

    void ReturnStream()
    {
        CopyAndReverse(newStream, oldStream);
    }

    void ReceiveStream()
    {
        CopyAndReverse(newStream, oldStream);
    }

    public void ReverseIncomingStream()
    {
        ReverseStream(newStream);
    }

    public void ReverseOutgoingStream()
    {
        ReverseStream(newStream);
    }

    public void ReverseStream(Stream stream)
    {
        TextReader tr = new StreamReader(stream);
        string str = tr.ReadToEnd();
        char[] data = str.ToCharArray();
        Array.Reverse(data);
        string strReversed = new string(data);

        TextWriter tw = new StreamWriter(stream);
        stream.Position = 0;
        tw.Write(strReversed);
        tw.Flush();
    }
    void CopyAndReverse(Stream from, Stream to)
    {
        TextReader tr = new StreamReader(from);
        TextWriter tw = new StreamWriter(to);

        string str = tr.ReadToEnd();
        char[] data = str.ToCharArray();
        Array.Reverse(data);
        string strReversed = new string(data);
        tw.Write(strReversed);
        tw.Flush();
    }

    private void CopyStream(Stream fromStream, Stream toStream)
    {
        try
        {
            StreamReader sr = new StreamReader(fromStream);
            StreamWriter sw = new StreamWriter(toStream);
            sw.WriteLine(sr.ReadToEnd());
            sw.Flush();
        }
        catch (Exception ex)
        {
            string message = String.Format("CopyStream failed because: {0}", ex.Message);
            log.Error(message, ex);
        }
    }
}

[AttributeUsage(AttributeTargets.Method)]
public class SoapLoggerExtensionAttribute : SoapExtensionAttribute
{
    private int priority = 1; 

    public override int Priority
    {
        get { return priority; }
        set { priority = value; }
    }

    public override System.Type ExtensionType
    {
        get { return typeof (SoapLoggerExtension); }
    }
}

그런 다음 YourNamespace 및 YourAssembly가 SoapExtension의 클래스 및 어셈블리를 가리키는 web.config에 다음 섹션을 추가합니다.

<webServices>
  <soapExtensionTypes>
    <add type="YourNamespace.SoapLoggerExtension, YourAssembly" 
       priority="1" group="0" />
  </soapExtensionTypes>
</webServices>

나는 그것을 줄 것이다. 나는 비누 확장명을 조사했지만 서비스를 호스팅하는 데 더 많은 것처럼 보였습니다. 작동한다면 진드기를 줄 것입니다 :)
Andrew Harry

예, 이것은 생성 된 프록시를 통해 웹 애플리케이션에서 이루어진 호출을 기록하는 데 작동합니다.
John Lemp

이것이 제가 함께 갔던 경로이고, 정말 잘 작동하고 있으며, 로깅하기 전에 xpath를 사용하여 민감한 데이터를 마스킹 할 수 있습니다.
Dave Baghdanov 2014-06-04

클라이언트의 SOAP 요청에 헤더를 추가해야했습니다. 이것은 나를 도왔다. rhyous.com/2015/04/29/…
Rhyous 2015-04-29

나는 또한 C #을 처음 접했고 어셈블리 이름에 무엇을 입력 해야할지 모르겠습니다. 확장 프로그램이 실행되지 않는 것 같습니다.
Collin Anderson

28

web.config 또는 serializer 클래스가 왜 모든 소란 스러운지 잘 모르겠습니다. 아래 코드가 저에게 효과적이었습니다.

XmlSerializer xmlSerializer = new XmlSerializer(myEnvelope.GetType());

using (StringWriter textWriter = new StringWriter())
{
    xmlSerializer.Serialize(textWriter, myEnvelope);
    return textWriter.ToString();
}

17
어디 myEnvelope에서 왔습니까?
ProfK 2015 년

6
나는이 답변에 더 많은 찬성표가없는 이유를 이해하지 못합니다. 잘 했어!
Batista

1
myEnvalope는 웹 서비스를 통해 보내는 객체입니다. 설명 된대로 (특히 textWriter.tostring 함수) 제대로 작동하도록 할 수는 없었지만 serialize를 호출 한 후 원시 xml을 볼 수 있다는 점에서 디버그 모드에서 제 목적에 맞게 충분히 작동했습니다. 다른 일부는 혼합 된 결과로 작업했기 때문에이 답변을 매우 감사하게 생각합니다 (예를 들어 web.config를 사용하는 로깅은 xml의 일부만 반환했으며 구문 분석도 그리 쉽지 않음).
user2366842

@Bimmerbound : 암호화 된 VPN을 통해 전송 된 보안 SOAP 메시지와 함께 작동합니까?
바나나에있는 우리의 남자는

6
이 답변은 비누 봉투를 생성하지 않으며 xml로 직렬화됩니다. 비누 봉투 요청을받는 방법?
Ali Karaca

21

시도 Fiddler2를 당신이 요청 및 응답을 검사하게됩니다. Fiddler가 http 및 https 트래픽 모두에서 작동한다는 점은 주목할 가치가 있습니다.


https 암호화 서비스입니다
Andrew Harry

8
Fiddler는 https 트래픽을 암호화 해제 할 수 있습니다.
Aaron Fischer

1
이 솔루션이 web / app.config에 추적을 추가하는 것보다 낫다는 것을 알았습니다. 감사!
andyuk

1
하지만 구성 파일에서 system.diagnostics를 사용하는 것은 타사 앱을 설치할 필요가 없습니다
Azat

1
@AaronFischer : Fiddler는 암호화 된 VPN을 통해 전송 된 SOAP 메시지와 함께 작동합니까?
바나나에있는 우리의 남자는

6

웹 참조 호출에서 예외가 발생하면 Tim Carter의 솔루션이 작동하지 않는 것 같습니다. 예외가 발생하면 오류 처리기에서 코드로 검사 할 수 있도록 원시 웹 공명을 얻으려고 노력했습니다. 그러나 호출이 예외를 throw 할 때 Tim의 메서드에 의해 작성된 응답 로그가 비어 있음을 발견했습니다. 나는 코드를 완전히 이해하지 못하지만 Tim의 방법은 .Net이 이미 웹 응답을 무효화하고 폐기 한 지점 이후에 프로세스를 중단하는 것으로 보입니다.

저수준 코딩으로 웹 서비스를 수동으로 개발하는 클라이언트와 함께 일하고 있습니다. 이 시점에서 그들은 자체 내부 프로세스 오류 메시지를 HTML 형식의 메시지로 SOAP 형식의 응답 전에 응답에 추가합니다. 물론, automagic .Net 웹 참조는 이것에 대해 폭발합니다. 예외가 발생한 후 원시 HTTP 응답을 얻을 수 있다면 혼합 반환 HTTP 응답 내에서 SOAP 응답을 찾아 구문 분석하고 내 데이터를 수신했는지 여부를 알 수 있습니다.

나중 ...

다음은 실행 후에도 작동하는 솔루션입니다 (응답 후에 만 ​​수행됩니다. 요청도받을 수 있음).

namespace ChuckBevitt
{
    class GetRawResponseSoapExtension : SoapExtension
    {
        //must override these three methods
        public override object GetInitializer(LogicalMethodInfo methodInfo, SoapExtensionAttribute attribute)
        {
            return null;
        }
        public override object GetInitializer(Type serviceType)
        {
            return null;
        }
        public override void Initialize(object initializer)
        {
        }

        private bool IsResponse = false;

        public override void ProcessMessage(SoapMessage message)
        {
            //Note that ProcessMessage gets called AFTER ChainStream.
            //That's why I'm looking for AfterSerialize, rather than BeforeDeserialize
            if (message.Stage == SoapMessageStage.AfterSerialize)
                IsResponse = true;
            else
                IsResponse = false;
        }

        public override Stream ChainStream(Stream stream)
        {
            if (IsResponse)
            {
                StreamReader sr = new StreamReader(stream);
                string response = sr.ReadToEnd();
                sr.Close();
                sr.Dispose();

                File.WriteAllText(@"C:\test.txt", response);

                byte[] ResponseBytes = Encoding.ASCII.GetBytes(response);
                MemoryStream ms = new MemoryStream(ResponseBytes);
                return ms;

            }
            else
                return stream;
        }
    }
}

구성 파일에서 구성하는 방법은 다음과 같습니다.

<configuration>
     ...
  <system.web>
    <webServices>
      <soapExtensionTypes>
        <add type="ChuckBevitt.GetRawResponseSoapExtension, TestCallWebService"
           priority="1" group="0" />
      </soapExtensionTypes>
    </webServices>
  </system.web>
</configuration>

"TestCallWebService"는 라이브러리의 이름 (내가 작업하던 테스트 콘솔 앱의 이름)으로 대체됩니다.

정말로 ChainStream에 갈 필요는 없습니다. ProcessMessage에서 다음과 같이 더 간단하게 수행 할 수 있어야합니다.

public override void ProcessMessage(SoapMessage message)
{
    if (message.Stage == SoapMessageStage.BeforeDeserialize)
    {
        StreamReader sr = new StreamReader(message.Stream);
        File.WriteAllText(@"C:\test.txt", sr.ReadToEnd());
        message.Stream.Position = 0; //Will blow up 'cause type of stream ("ConnectStream") doesn't alow seek so can't reset position
    }
}

SoapMessage.Stream을 조회하면이 시점에서 데이터를 검사하는 데 사용할 수있는 읽기 전용 스트림이어야합니다. 이것은 당신이 스트림을 읽으면 오류를 발견하지 않고 후속 처리 폭탄을 처리하고 (스트림이 끝났음) 위치를 처음으로 재설정 할 수 없기 때문에 엉망입니다.

흥미롭게도 ChainStream 및 ProcessMessage 방법을 모두 수행하면 ChainStream에서 스트림 유형을 ConnectStream에서 MemoryStream으로 변경하고 MemoryStream에서 검색 작업을 허용하기 때문에 ProcessMessage 메서드가 작동합니다. (ConnectStream을 MemoryStream으로 캐스팅하려고 시도했지만 허용되지 않았습니다.)

그래서 ..... Microsoft는 ChainStream 유형에 대한 검색 작업을 허용하거나 SoapMessage.Stream이 예상대로 읽기 전용 복사본으로 만들어야합니다. (의원 등을 쓰십시오.)

한 가지 더. 예외가 발생한 후 원시 HTTP 응답을 검색하는 방법을 만든 후에도 여전히 전체 응답을 얻지 못했습니다 (HTTP 스니퍼에 의해 결정됨). 이는 개발 웹 서비스가 응답 시작 부분에 HTML 오류 메시지를 추가했을 때 Content-Length 헤더를 조정하지 않았기 때문에 Content-Length 값이 실제 응답 본문의 크기보다 작기 때문입니다. 내가 얻은 것은 Content-Length 값 문자 수뿐이었습니다. 나머지는 누락되었습니다. 분명히 .Net이 응답 스트림을 읽을 때 Content-Length 문자 수를 읽고 Content-Length 값이 잘못 될 가능성을 허용하지 않습니다. 이것은 당연한 것입니다. 그러나 Content-Length 헤더 값이 잘못된 경우 전체 응답 본문을 얻을 수있는 유일한 방법은 HTTP 스니퍼를 사용하는 것입니다 (저는http://www.ieinspector.com ).


2

프레임 워크가 기본 스트림을 처리 할 때 로깅하는 로깅 스트림에 연결하여 프레임 워크가 로깅을 수행하도록하는 것이 좋습니다. 다음은 ChainStream 메서드에서 요청과 응답 사이를 결정할 수 없기 때문에 내가 원하는만큼 깨끗하지 않습니다. 다음은 내가 처리하는 방법입니다. 스트림 아이디어를 재정의 한 Jon Hanna에게 감사드립니다.

public class LoggerSoapExtension : SoapExtension
{
    private static readonly string LOG_DIRECTORY = ConfigurationManager.AppSettings["LOG_DIRECTORY"];
    private LogStream _logger;

    public override object GetInitializer(LogicalMethodInfo methodInfo, SoapExtensionAttribute attribute)
    {
        return null;
    }
    public override object GetInitializer(Type serviceType)
    {
        return null;
    }
    public override void Initialize(object initializer)
    {
    }
    public override System.IO.Stream ChainStream(System.IO.Stream stream)
    {
        _logger = new LogStream(stream);
        return _logger;
    }
    public override void ProcessMessage(SoapMessage message)
    {
        if (LOG_DIRECTORY != null)
        {
            switch (message.Stage)
            {
                case SoapMessageStage.BeforeSerialize:
                    _logger.Type = "request";
                    break;
                case SoapMessageStage.AfterSerialize:
                    break;
                case SoapMessageStage.BeforeDeserialize:
                    _logger.Type = "response";
                    break;
                case SoapMessageStage.AfterDeserialize:
                    break;
            }
        }
    }
    internal class LogStream : Stream
    {
        private Stream _source;
        private Stream _log;
        private bool _logSetup;
        private string _type;

        public LogStream(Stream source)
        {
            _source = source;
        }
        internal string Type
        {
            set { _type = value; }
        }
        private Stream Logger
        {
            get
            {
                if (!_logSetup)
                {
                    if (LOG_DIRECTORY != null)
                    {
                        try
                        {
                            DateTime now = DateTime.Now;
                            string folder = LOG_DIRECTORY + now.ToString("yyyyMMdd");
                            string subfolder = folder + "\\" + now.ToString("HH");
                            string client = System.Web.HttpContext.Current != null && System.Web.HttpContext.Current.Request != null && System.Web.HttpContext.Current.Request.UserHostAddress != null ? System.Web.HttpContext.Current.Request.UserHostAddress : string.Empty;
                            string ticks = now.ToString("yyyyMMdd'T'HHmmss.fffffff");
                            if (!Directory.Exists(folder))
                                Directory.CreateDirectory(folder);
                            if (!Directory.Exists(subfolder))
                                Directory.CreateDirectory(subfolder);
                            _log = new FileStream(new System.Text.StringBuilder(subfolder).Append('\\').Append(client).Append('_').Append(ticks).Append('_').Append(_type).Append(".xml").ToString(), FileMode.Create);
                        }
                        catch
                        {
                            _log = null;
                        }
                    }
                    _logSetup = true;
                }
                return _log;
            }
        }
        public override bool CanRead
        {
            get
            {
                return _source.CanRead;
            }
        }
        public override bool CanSeek
        {
            get
            {
                return _source.CanSeek;
            }
        }

        public override bool CanWrite
        {
            get
            {
                return _source.CanWrite;
            }
        }

        public override long Length
        {
            get
            {
                return _source.Length;
            }
        }

        public override long Position
        {
            get
            {
                return _source.Position;
            }
            set
            {
                _source.Position = value;
            }
        }

        public override void Flush()
        {
            _source.Flush();
            if (Logger != null)
                Logger.Flush();
        }

        public override long Seek(long offset, SeekOrigin origin)
        {
            return _source.Seek(offset, origin);
        }

        public override void SetLength(long value)
        {
            _source.SetLength(value);
        }

        public override int Read(byte[] buffer, int offset, int count)
        {
            count = _source.Read(buffer, offset, count);
            if (Logger != null)
                Logger.Write(buffer, offset, count);
            return count;
        }

        public override void Write(byte[] buffer, int offset, int count)
        {
            _source.Write(buffer, offset, count);
            if (Logger != null)
                Logger.Write(buffer, offset, count);
        }
        public override int ReadByte()
        {
            int ret = _source.ReadByte();
            if (ret != -1 && Logger != null)
                Logger.WriteByte((byte)ret);
            return ret;
        }
        public override void Close()
        {
            _source.Close();
            if (Logger != null)
                Logger.Close();
            base.Close();
        }
        public override int ReadTimeout
        {
            get { return _source.ReadTimeout; }
            set { _source.ReadTimeout = value; }
        }
        public override int WriteTimeout
        {
            get { return _source.WriteTimeout; }
            set { _source.WriteTimeout = value; }
        }
    }
}
[AttributeUsage(AttributeTargets.Method)]
public class LoggerSoapExtensionAttribute : SoapExtensionAttribute
{
    private int priority = 1;
    public override int Priority
    {
        get
        {
            return priority;
        }
        set
        {
            priority = value;
        }
    }
    public override System.Type ExtensionType
    {
        get
        {
            return typeof(LoggerSoapExtension);
        }
    }
}

1
@TimCarter 그것은 나에게 일했습니다. 내가 삭제 한 유일한 것은 콜론이있는 이름을 제공하여 예외를 일으키는 클라이언트 부분입니다. 따라서이 줄을 삭제하면 모든 요청 / 응답에 대해 하나의 파일을 원하는 사람들에게 잘 작동합니다. 추가해야 할 유일한 것은 다른 답변을 읽으면 분명한 것이며 soapExtension을 표시하기 위해 web.config 노드를 추가해야한다는 것입니다. 고마워!
netadictos

1

다음은 상위 답변의 단순화 된 버전입니다. 이것을 또는 파일 의 <configuration>요소에 추가 하십시오. 프로젝트 폴더에 파일 이 생성됩니다 . 또는 속성을 사용하여 로그 파일의 절대 경로를 지정할 수 있습니다 .web.configApp.configtrace.logbin/DebuginitializeData

  <system.diagnostics>
    <trace autoflush="true"/>
    <sources>
      <source name="System.Net" maxdatasize="9999" tracemode="protocolonly">
        <listeners>
          <add name="TraceFile" type="System.Diagnostics.TextWriterTraceListener" initializeData="trace.log"/>
        </listeners>
      </source>
    </sources>
    <switches>
      <add name="System.Net" value="Verbose"/>
    </switches>
  </system.diagnostics>

maxdatasizetracemode속성은 허용되지 않는다고 경고 하지만 기록 할 수있는 데이터의 양을 늘리고 모든 것을 16 진수로 기록하지 않도록합니다.


0

사용중인 언어를 지정하지 않았지만 C # /. NET을 가정하면 SOAP 확장을 사용할 수 있습니다. .

그렇지 않으면 Wireshark 와 같은 스니퍼를 사용하십시오.


1
예, wireshark를 사용해 보았습니다. 헤더 정보 만 표시 할 수 있으며 SOAP 콘텐츠는 암호화되어 있습니다.
Andrew Harry

https를 사용하고 있기 때문일 수 있습니다. 내가 기억할 수있는 한 SOAP 확장은 더 높은 수준에서 작업하므로 해독 된 데이터를 볼 수 있어야합니다.
rbrayb

2
Wireshark 대신 Fiddler를 사용하면 기본적으로 https를 해독합니다.
Rodrigo Strauss

-1

나는 파티에 꽤 늦었다는 것을 알고 있으며 언어가 실제로 지정되지 않았기 때문에 Bimmerbound의 대답을 기반으로 한 VB.NET 솔루션이 있습니다. 참고 : 아직없는 경우 프로젝트의 stringbuilder 클래스에 대한 참조가 있어야합니다.

 Shared Function returnSerializedXML(ByVal obj As Object) As String
    Dim xmlSerializer As New System.Xml.Serialization.XmlSerializer(obj.GetType())
    Dim xmlSb As New StringBuilder
    Using textWriter As New IO.StringWriter(xmlSb)
        xmlSerializer.Serialize(textWriter, obj)
    End Using


    returnSerializedXML = xmlSb.ToString().Replace(vbCrLf, "")

End Function

함수를 호출하기 만하면 웹 서비스에 전달하려는 객체의 직렬화 된 xml이 포함 된 문자열이 반환됩니다 (실제로는 던지려는 모든 객체에 대해서도 작동합니다).

참고로 xml을 반환하기 전에 함수의 replace 호출은 출력에서 ​​vbCrLf 문자를 제거하는 것입니다. 내 것은 생성 된 xml 내에 여러 가지가 있었지만 직렬화하려는 대상에 따라 분명히 다르며 웹 서비스로 개체를 보내는 동안 제거 될 수 있다고 생각합니다.


비추천으로 나를 때린 사람은 내가 놓친 부분 / 개선 할 수있는 부분을 알려주세요.
user2366842
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.