Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 
(연관된 글이 5개 있습니다.)
(시리즈 글이 4개 있습니다.)
.NET Framework: 412. HttpContext.Current를 통해 이해하는 CallContext와 ExecutionContext
; https://www.sysnet.pe.kr/2/0/1608

.NET Framework: 727. ASP.NET의 HttpContext.Current 구현에 대응하는 ASP.NET Core의 IHttpContextAccessor/HttpContextAccessor 사용법
; https://www.sysnet.pe.kr/2/0/11440

.NET Framework: 989. HttpContextAccessor를 통해 이해하는 AsyncLocal<T>
; https://www.sysnet.pe.kr/2/0/12467

.NET Framework: 1076. C# - AsyncLocal 기능을 CallContext만으로 구현하는 방법
; https://www.sysnet.pe.kr/2/0/12706




HttpContext.Current를 통해 이해하는 CallContext와 ExecutionContext

ASP.NET의 경우 HttpContext.Current를 사용해서 ASP.NET pipeline에서의 동일한 문맥 정보를 제공합니다. 일례로 요청을 처리하는 스레드에서 실행하는 모든 코드에서는 무조건 HttpContext.Current 속성을 이용하면 현재 문맥을 가져올 수 있습니다.

그런데, 도대체 HttpContext에 어떤 마법이 숨겨져 있는 걸까요? ^^ 이번엔 이에 대해서 간단하게(?) 파악해 보겠습니다.




우선, HttpContext.Current 속성의 소스 코드를 살펴볼까요?

// System.Web.dll
public sealed class HttpContext : IServiceProvider
{
    // ...[생략]...
    public static HttpContext Current
    {
        get
        {
            return (ContextBase.Current as HttpContext);
        }
        set
        {
            ContextBase.Current = value;
        }
    }
}

아하... ContextBase.Current를 형변환 한 것에 불과하군요. 이어서 ContextBase를 가보겠습니다.

// System.Web.dll
internal class ContextBase
{
    // ...[생략]...
    internal static object Current
    {
        get
        {
            return CallContext.HostContext;
        }
        set
        {
            CallContext.HostContext = value;
        }
    }
}

ContextBase.Current의 내부 구현은 결국 CallContext.HostContext임을 알 수 있습니다. 그리고 CallContext의 정적 공용 변수인 HostContext는 Thread마다 고유하게 할당된 ExecutionContext가 포함하고 있는 2개의 CallContext 인스턴스에 값을 읽고 쓰는 것에 불과합니다.

여기에서 CallContext.HostContext의 set을 살펴보면 다음과 같습니다.

public static void set_HostContext(object value)

    ExecutionContext mutableExecutionContext = Thread.CurrentThread.GetMutableExecutionContext();
    if (value is ILogicalThreadAffinative)
    {
        mutableExecutionContext.IllogicalCallContext.HostContext = null;
        mutableExecutionContext.LogicalCallContext.HostContext = value;
    }
    else
    {
        mutableExecutionContext.IllogicalCallContext.HostContext = value;
        mutableExecutionContext.LogicalCallContext.HostContext = null;
    }
}

여기서 재미있는 점이 바로, ExecutionContext가 관리하는 2개의 CallContext입니다. 왜 2개일까요? 이는 객체의 Serializable과 관련이 있습니다.

Identifying the differences between CallContext Data Slots 
; http://dotnetmustard.blogspot.kr/2008/08/identifying-differences-between.html

Thread 객체가 소유한 (Hashtable로 데이터를 관리하는) CallContext의 데이터를 스레드 간에 넘길 것이냐 말 것이냐에 따라 어느 CallContext에 데이터가 get/set 되는지가 결정되기 때문입니다. (보다 정확하게는 Thread 간이라고 볼 수는 없습니다. 왜냐하면 CallContext가 정의된 네임스페이스는 System.Runtime.Remoting.Messaging이고 이는 원격 메서드 호출에 관여하기 때문입니다. 물론 원격 메서드 호출은 확실히 스레드가 달라지긴 하지만.)

Thread가 가지고 있는 2개의 CallContext는 각기 2개의 클래스로 나뉘는데요. 하나는 LogicalCallContext이고, 다른 하나는 IllogicalCallContext입니다.

internal class LogicalCallContext : ISerializable, ICloneable
{
    // ...[생략]...
    private Hashtable m_Datastore;
    private object m_HostContext;
}

internal class IllogicalCallContext : ICloneable
{
    // ...[생략]...
    private Hashtable m_Datastore;
    private object m_HostContext;
}

전자가 직렬화 가능한 객체를 담고, 후자는 직렬화가 지원되지 않는 객체를 담습니다. 각각의 CallContext는 Thread 인스턴스에서 직접 접근할 수는 없고, CallContext의 정적 메서드를 사용해야만 가능합니다.

  • public static object GetData(string name);
  • public static void SetData(string name, object data);
  • public static object LogicalGetData(string name);
  • public static void LogicalSetData(string name, object data);

우선, LogicalSetData는 Thread.ExecutionContext.LogicalCallContext에 값을 set 합니다. 반면, SetData는 인자로 주어진 object data 인스턴스가 ILogicalThreadAffinative 인터페이스를 구현하고 있다면 LogicalCallContext로, 그렇지 않으면 IllogicalCallContext로 보관됩니다.




말로만 설명하면 너무 식상하니 ^^ 코딩을 해보겠습니다. 우선, Thread의 LogicalCallContext에 값을 보관하려면 다음과 같이 해주면 됩니다.

public class LTAObject : ILogicalThreadAffinative
{
    public string Value { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        // ILogicalThreadAffinative 인터페이스를 구현하지 않은 string 객체라도
        // 명시적으로 LogicalCallContext에 값을 보관
        CallContext.LogicalSetData("key1", "test");
        Debug.Assert(CallContext.LogicalGetData("key1") as string == "test");

        // ILogicalThreadAffinative 인터페이스를 상속받은 객체를 생성하고,
        LTAObject lta = new LTAObject();
        lta.Value = "test";

        // LogicalCallContext에 값을 보관
        CallContext.SetData("key1", lta);
        Debug.Assert((CallContext.LogicalGetData("key1") as LTAObject).Value == "test");
            
    }
}

반면, Thread의 IllogicalCallContext에 값을 보관하는 코드는 다음과 같습니다.

public class NoLTAObject
{
    public string Value { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        // string 객체는 ILogicalThreadAffinative를 상속받지 않았으므로 IllogicalCallContext에 보관됨.
        CallContext.SetData("key1", "test");
        Debug.Assert(CallContext.GetData("key1") as string == "test");
        Debug.Assert(CallContext.LogicalGetData("key1") == null); // LogicalCallContext에 없음.

        // NoLTAObject 객체도 ILogicalThreadAffinative를 상속받지 않았으므로 IllogicalCallContext에 보관됨.
        NoLTAObject noLta = new NoLTAObject();
        noLta.Value = "test";

        CallContext.SetData("key1", noLta);
        Debug.Assert((CallContext.GetData("key1") as NoLTAObject).Value == "test");
        Debug.Assert(CallContext.LogicalGetData("key1") == null); // LogicalCallContext에 없음.
    }
}




단일 스레드로 특정 작업이 처리되는 상황에서는 LogicalCallContext와 IllogicalCallContext 중에서 어디에 값을 보관하느냐는 문제될 것이 없습니다. 하지만, 그 작업과 관련되어 여러 가지 스레드들이 관여하게 된다면 그 스레드들이 공통으로 공유해야 할 문맥 정보가 필요합니다.

위에서 구현되었던 LTAObject와 NoLTAObject가 스레드를 넘어가는 경우의 차이점을 아래의 코드로 직접 확인해 볼 수 있습니다.

public class LTAObject : ILogicalThreadAffinative
{
    public string Value { get; set; }
}

public class NoLTAObject
{
    public string Value { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        // LogicalCallContext에 값을 보관
        CallContext.LogicalSetData("key1", "test");

        // IllogicalCallContext에 값을 보관
        CallContext.SetData("key2", "test");

        // ILogicalThreadAffinative 인터페이스를 상속받은 객체를 생성하고,
        LTAObject lta = new LTAObject();
        lta.Value = "test";

        // LogicalCallContext에 값을 보관
        CallContext.SetData("key3", lta);

        // NoLTAObject 객체도 ILogicalThreadAffinative를 상속받지 않았으므로 IllogicalCallContext에 보관됨.
        NoLTAObject noLta = new NoLTAObject();
        noLta.Value = "test";

        CallContext.SetData("key4", noLta);

        Thread thread = new Thread(threadFunc);
        thread.Start();
        thread.Join();

        return;
    }

    static void threadFunc()
    {
        // LogicalCallContext에 보관되었던 데이터들은 스레드 경계를 넘어옴
        Debug.Assert(CallContext.LogicalGetData("key1") as string == "test");
        Debug.Assert((CallContext.LogicalGetData("key3") as LTAObject).Value == "test");

        // IllogicalCallContext에 보관되었던 데이터들은 스레드 경계를 못 넘음.
        Debug.Assert(CallContext.GetData("key2") == null);
        Debug.Assert(CallContext.GetData("key4") == null);
    }
}

보시는 것처럼, LogicalCallContext에 보관되었던 항목들은 스레드 간에 자연스럽게 데이터 이관이 되었지만, IllogicalCallContext에 보관되었던 항목들은 안되었습니다.

그런데, 혹시 LogicalCallContext의 데이터도 스레드 간에 넘어가지 못하도록 막고 싶지는 않을까요? 데이터를 스레드 간에 넘긴다는 것은 그런 작업이 이뤄졌기 때문에 가능한 것이고 이것이 복잡한 경우 자칫 성능에 영향을 미칠 수 있습니다. 굳이 CallContext의 데이터들이 필요치 않다면 이를 넘기는데 괜한 오버헤드를 가져갈 이유가 없습니다.

이를 위해 ExecutionContext의 SuppressFlow / RestoreFlow 정적 메서드를 이용하면 됩니다.

ExecutionContext.SuppressFlow();

Thread thread = new Thread(threadFunc);
thread.Start();
thread.Join();

ExecutionContext.RestoreFlow();

static void threadFunc()
{
    // 모든 CallContext의 데이터들이 스레드 경계를 못 넘음
    Debug.Assert(CallContext.LogicalGetData("key1") == null);
    Debug.Assert(CallContext.GetData("key2") == null);

    Debug.Assert(CallContext.LogicalGetData("key3") == null);
    Debug.Assert(CallContext.GetData("key4") == null);
}

LogicalCallContext와 IllogicalCallContext 인스턴스는 ExecutionContext에 포함되어 있고, 다시 ExecutionContext는 Thread에 포함되어 있습니다. 따라서 관리는 ExecutionContext 측에서 이뤄지는데, 기본 동작이 스레드 간에 LogicalCallContext를 자동으로 넘겨주는 역할을 하고 이를 막으려면 개발자가 수작업으로 SuppressFlow / RestoreFlow를 호출해 주어야 합니다.




여기까지 이해하셨으면, ThreadPool 타입의 QueueUserWorkItem과 UnsafeQueueUserWorkItem 메서드에서도 CallContxt에 대한 차이가 있다는 것을 자연스럽게 파악할 수 있습니다.

즉, QueueUserWorkItem은 LogicalCallContext 데이터를 스레드 풀에서 가져올 스레드에 설정해 주는 반면, UnsafeQueueUserWorkItem은 LogicalCallContext 데이터 이관을 명시적으로 하지 않는다는 차이가 있습니다. 위의 예제 코드와 비교해서 설명해 보면 UnsafeQueueUserWorkItem은 SuppressFlow / RestoreFlow를 호출한 스레드처럼 동작하는 것입니다.

static void Main(string[] args)
{
    CallContext.LogicalSetData("key1", "test");

    ThreadPool.QueueUserWorkItem(threadFunc, null);
    ThreadPool.UnsafeQueueUserWorkItem(threadFunc, null);

    Console.ReadLine();
}

static void threadFunc(object objContext)
{
    object objValue = CallContext.GetData("key1");
    Console.WriteLine(objValue ?? "(null)"); // QueueUserWorkItem인 경우 "test" 출력
                                             // UnsafeQueueUserWorkItem인 경우 "(null)"
}

실제로 테스트해 보면 QueueUserWorkItem인 경우일지라도 그 전에 SuppressFlow를 호출해 주면 마찬가지로 LogicalCallContext 데이터가 이관되지 않는 것을 알 수 있습니다.

static void Main(string[] args)
{
    CallContext.LogicalSetData("key1", "test");

    ExecutionContext.SuppressFlow();
    ThreadPool.QueueUserWorkItem(threadFunc, null);
    ExecutionContext.RestoreFlow();

    ThreadPool.UnsafeQueueUserWorkItem(threadFunc, null);

    Console.ReadLine();
}

static void threadFunc(object objContext)
{
    object objValue = CallContext.GetData("key1");
    Console.WriteLine(objValue ?? "(null)"); // QueueUserWorkItem, UnsafeQueueUserWorkItem 모두 "(null)"
}

SuppressFlow 사용 중에 일부러 LogicalCallContext를 전달하고 싶다면 대신 다른 방법을 사용해야 합니다.

static void Main(string[] args)
{
    CallContext.LogicalSetData("key1", "test");
    ExecutionContext threadContext = ExecutionContext.Capture(); // 미리 capture 해두고,

    ExecutionContext.SuppressFlow(); // suppress를 해도,

 // ThreadPool.UnsafeQueueUserWorkItem(threadFunc, null);
    ExecutionContext.Run(threadContext, threadFunc, null); // capture했던 내용으로 직접 실행하는 것도 가능

    ExecutionContext.RestoreFlow();
 
    Console.ReadLine();
}

static void threadFunc(object objContext)
{
    object objValue = CallContext.GetData("key1");
    Console.WriteLine(objValue); // "test" 값을 출력
}




비동기 호출의 경우, LogicalCallContext의 데이터 이관이 어떻게 되는지 살펴보는 것도 흥미로운데요. 아래의 글을 보면,

Nested multithread operations tracing
; http://stackoverflow.com/questions/2651327/nested-multithread-operations-tracing

다음과 같은 코드 테스트가 나옵니다.

static void Main(string[] args)
{
    string key = "aaa";
    EventWaitHandle asyncStarted = new AutoResetEvent(false);
    IAsyncResult r = null;

    CallContext.LogicalSetData(key, "Root - op 0");
    Console.WriteLine("Initial: {0}", CallContext.LogicalGetData(key));

    Action a = () =>
    {
        CallContext.LogicalSetData(key, "Async - op 0");
        asyncStarted.Set();
    };
    r = a.BeginInvoke(null, null);

    asyncStarted.WaitOne();
    Console.WriteLine("AsyncOp started: {0}", CallContext.LogicalGetData(key));

    CallContext.LogicalSetData(key, "Root - op 1");
    Console.WriteLine("Current changed: {0}", CallContext.LogicalGetData(key));

    a.EndInvoke(r);
    Console.WriteLine("Async ended: {0}", CallContext.LogicalGetData(key));
}

// 출력 결과
Initial: Root - op 0
AsyncOp started: Root - op 0
Current changed: Root - op 1
Async ended: Async - op 0

처음 Main 메서드의 시작에서 key == "aaa"로 CallContext.LogicalSetData에 값을 넣었는데요. 비동기 호출되는 delegate 메서드 내에서 "aaa" 키에 해당하는 LogicalCallContext의 값을 변경하지만, 외부에서는 그 시점에 변화를 알 수 없습니다. 실제로 비동기 메서드 내에서의 LogicalCallContext 저장소가 그것의 부모 스레드의 LogicalCallContext에 병합되는 시점은 EndInvoke이라는 것이 특이합니다.




자, 이제 다시 최초의 HttpContext.Current로 돌아가 보겠습니다. 여기까지 살펴봤으면 이제 웹상에서 자주 나타나는 한 가지 질문에 답할 수 있게 됩니다.

새로 만든 스레드에서 HttpContext.Current를 접근하면 null이 나옵니다. 왜 그런 걸까요?

간단합니다. 소스 코드를 살펴보면 HttpContext 개체는 ILogicalThreadAffinative 인터페이스를 구현하지 않았습니다. 또한 CallContext.LogicalSetData를 통해서 값을 설정하지도 않았기 때문에 HttpContext 인스턴스 자체는 Thread의 IllogicalCallContext 영역에 보관되어 있습니다.

따라서, ASP.NET Page 내에서 다음과 같이 별도의 스레드로 넘어가버리면 해당 스레드에서는 HttpContext.Current에 아무런 값이 설정되어 있지 않은 상태입니다.

using System;
using System.Web;
using System.Threading;
using System.Runtime.Remoting.Messaging;

namespace WebApplication1
{
    public partial class _Default : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            ThreadPool.QueueUserWorkItem(threadFunc, null);
        }

        void threadFunc(object objContext)
        {
            object httpContext = HttpContext.Current; // == null
            object hostContxtObj = CallContext.HostContext; // null
        }
    }
}

애당초 이것이 가능하려면 마이크로소프트가 HttpContext를 직렬화 가능한 클래스로 만들었어야 합니다. 단지, 그렇게 만들지 않았던 것에는 몇 가지 타당한 이유가 있어 보입니다. 가령 HttpContext.Current에서는 Server, Request, Response 등의 객체를 노출시켜 주는데 그런 객체들을 모두 마샬링 가능한 타입으로 만드는 것은 그다지 효율적이지 않았을 수 있습니다. 그 외에도, 새롭게 생성된 스레드가 현재 요청이 처리된 후에 - 즉, HttpContext가 소멸된 이후에 - HttpContext.Current를 접근한다면 이미 모든 정보가 유효하지 않은 상태에서 그것을 접근하게 해주는 것도 자칫 복잡한 문제를 야기시킬 수 있습니다. 따라서 제 의견에도 HttpContext가 객체가 스레드 간에 넘나들지 않게 한 것은 옳은 결정으로 보입니다.

어쨌든 다른 스레드에서는 HttpContext.Current를 넘겨줄 수 없으므로 필요한 정보가 있다면 그것만을 별도로 취합해서 HttpContext가 아닌 CallContext를 직접 사용해서 넘겨주는 방법을 택해야 합니다.

using System;
using System.Web;
using System.Threading;
using System.Runtime.Remoting.Messaging;

namespace WebApplication1
{
    public partial class _Default : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            CallContext.LogicalSetData("RequestURL", HttpContext.Current.Request.Url.ToString());

            ThreadPool.QueueUserWorkItem(threadFunc, null);
        }

        void threadFunc(object objContext)
        {
            object obj1 = CallContext.LogicalGetData("RequestURL");
        }
    }
}

이 정도면 HttpContext.Current의 미스테리가 좀 풀렸겠지요. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 3/7/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2014-01-27 12시03분
[ryujh] 안녕하세요.

CallContext, ExecutionContext 를 이해하기 전에 HttpContext 를 이해해야 해서 결론을 보고 생각해본 것이,

페이지 호출 당 페이지클래스 인스턴스가 생성되고 그 인스턴스 코드내에 주스레드에서 HttpContext.Current 로 접근 가능하다는 것이 맞을까요?

그리고 ThreadPool.QueueUserWorkItem(threadFunc, null); 대신
ThreadPool.QueueUserWorkItem(threadFunc, HttpContext.Current); 를 넘기면 threadFunc 내에서 사용가능하지 않을지요?

감사합니다.
[guest]
2014-01-27 01시37분
1. 넵. HttpContext.Current로 접근할 수 있습니다.
2. HttpContext.Current를 넘기면 물론 가능합니다. 사실 그게 가장 쉽겠지요. ^^ 단지, 글의 주제상 CallContext를 언급했고 HttpContext가 CallContext에 보관된다는 고유 사실로부터 얻어질 수 있는 효과를 비교하면 아주 동일하진 않습니다. 가령, CallContext의 경우 AppDomain 경계를 넘을 수도 있습니다.
정성태
2020-10-13 09시48분
How to migrate CallContext to .NETStandard and .NETCore
; https://www.cazzulino.com/callcontext-netstandard-netcore.html
정성태
2020-12-30 07시32분
정성태

... 151  152  153  154  155  156  157  158  159  [160]  161  162  163  164  165  ...
NoWriterDateCnt.TitleFile(s)
1048정성태5/27/201132213개발 환경 구성: 123. Apache 소스를 윈도우 환경에서 빌드하기
1047정성태5/27/201126073.NET Framework: 217. Firebird ALinq Provider - 날짜 필드에 대한 낙관적 동시성 쿼리 오류
1046정성태5/26/201130719.NET Framework: 216. 라이선스까지도 뛰어넘는 .NET Profiler [5]
1045정성태5/24/201131819.NET Framework: 215. 닷넷 System.ComponentModel.LicenseManager를 이용한 라이선스 적용 [1]파일 다운로드1
1044정성태5/24/201132374오류 유형: 122. zlib 빌드 오류 - inflate.obj : error LNK2001: unresolved external symbol _inflate_fast
1043정성태5/24/201131320.NET Framework: 214. 무료 Linq Provider - DbLinq를 이용한 Firebird 접근파일 다운로드1
1042정성태5/23/201137660개발 환경 구성: 122. PHP 소스를 윈도우 환경에서 빌드하기
1041정성태5/22/201128584.NET Framework: 213. Linq To SQL - ALinq Provider를 이용하여 Firebird 사용파일 다운로드1
1040정성태5/21/201138916개발 환경 구성: 121. .NET 개발자가 처음 설치해 본 Apache + PHP [2]
1039정성태5/17/201131607.NET Framework: 212. Firebird 데이터베이스와 ADO.NET [2]파일 다운로드1
1038정성태5/16/201133587개발 환경 구성: 120. .NET 프로그래머에게도 유용한 Firebird 무료 데이터베이스 [2]
1037정성태5/11/201128404개발 환경 구성: 119. Visual Studio Professional 이하 버전에서도 TFS의 정적 코드 분석 정책 연동이 가능할까? [3]
1036정성태5/7/201194236오류 유형: 121. Access DB에 대한 32bit/64bit OLE DB Provider 관련 오류 [11]
1035정성태5/7/201128955오류 유형: 120. File cannot be opened. Ensure it is a valid Data Link file.
1034정성태5/2/201126025.NET Framework: 211. 파일 잠금 없이 .NET 어셈블리의 버전을 구하는 방법 [2]파일 다운로드1
1033정성태5/1/201131735웹: 19. IIS Express - appcmd.exe를 이용한 applicationHost.config 변경 [2]
1032정성태5/1/201128377웹: 18. IIS Express를 NT 서비스로 변경
1031정성태4/30/201129532웹: 17. IIS Express - "IIS Installed Versions Manager Interface"의 IIISExpressProcessUtility 구하는 방법 [1]파일 다운로드1
1030정성태4/30/201151797개발 환경 구성: 118. IIS Express - localhost 이외의 호스트 이름으로 접근하는 방법 [4]파일 다운로드1
1029정성태4/28/201140906개발 환경 구성: 117. XCopy에서 파일/디렉터리 확인 질문 없애기 [2]
1028정성태4/27/201138301오류 유형: 119. Visual Studio 2010 SP1 설치 후 Windows Phone 개발자 도구로 인한 재설치 문제 [3]
1027정성태4/25/201127483디버깅 기술: 40. 상황별 GetFunctionPointer 반환값 정리 - x86파일 다운로드1
1026정성태4/25/201145766디버깅 기술: 39. DebugDiag 1.1을 사용한 덤프 분석 [7]
1025정성태4/24/201127820개발 환경 구성: 116. IIS 7 관리자 - Active Directory Certification Authority로부터 SSL 사이트 인증서 받는 방법 [2]
1024정성태4/22/201129186오류 유형: 118. Windows 2008 서버에서 Event Viewer / PowerShell 실행 시 비정상 종료되는 문제 [1]
1023정성태4/20/201130067.NET Framework: 210. Windbg 환경에서 확인해 본 .NET 메서드 JIT 컴파일 전과 후 [1]
... 151  152  153  154  155  156  157  158  159  [160]  161  162  163  164  165  ...