반응형

[PART6.배열과 문자열 기본(1/14)] 1차원 배열 — 가장 단순하지만 가장 깊은 데이터 구조

선언·초기화 4가지 / System.Array 위에 올라간 메모리 구조 / 인덱스 0 시작이 빨라지는 이유 / Length 고정과 IL 명령어(newarr·ldelem·stelem·ldlen) / Unity 핫패스에서 캐싱 배열로 GC alloc 없애기


1. 왜 1차원 배열을 다시 봐야 하는가

신입 시절 Unity 코드에서 자주 마주치는 한 줄이 있습니다.

C#
RaycastHit[] hits = Physics.RaycastAll(origin, dir);

문법은 익숙합니다. 배열을 받았고, hits.Length만큼 for로 돌면 됩니다. 그런데 모바일 빌드에서 프로파일러를 돌리면 이 한 줄이 매 프레임 GC(Garbage Collector — 더 이상 쓰지 않는 객체를 자동으로 회수해 메모리를 돌려주는 런타임 구성요소) Alloc을 만들고 있고, 60FPS 게임에서 GC가 한 번 돌 때마다 5~10밀리초씩 프레임이 끊긴다는 것을 알게 됩니다.

같은 문제를 푸는 두 줄짜리 차이는 이렇게 생겼습니다.

C#
// ❌ 매 호출 새 배열 할당
RaycastHit[] hits = Physics.RaycastAll(origin, dir);

// ✅ 미리 할당해 둔 배열을 재사용
private static readonly RaycastHit[] _hits = new RaycastHit[16];
int n = Physics.RaycastNonAlloc(origin, dir, _hits);

두 코드의 차이를 설명할 수 있으려면 1차원 배열이 메모리에서 어떤 모양으로 살아 있는지, 왜 매 호출 새 배열을 만드는 게 비싸고 한 번 만들어 둔 것을 다시 쓰는 게 싼지를 알아야 합니다. "배열은 그냥 데이터 통이다"로는 위 두 줄의 차이를 설명할 수 없습니다.

이 글에서 풀고자 하는 질문은 세 가지입니다.

  1. 1차원 배열은 메모리에 어떻게 놓여 있는가?
  2. 인덱스가 0부터 시작하고, forforeach 중 무엇을 쓰는 게 빠른가?
  3. Unity 핫패스에서 배열을 어떻게 다뤄야 GC가 한가해지는가?

2. 1차원 배열이란 무엇인가

비유 — 우편함이 일렬로 붙은 한 동짜리 아파트

1차원 배열은 길이가 정해진 우편함 한 줄이라고 보면 직관적입니다. 같은 크기의 칸이 0번부터 차례로 붙어 있고, 한 칸의 위치만 알면 다른 어떤 칸도 한 번의 계산으로 찾아갈 수 있습니다. 칸 수는 입주 시점에 박힌 채 절대 늘지 않습니다 — 더 많은 우편함이 필요하면 새 동(새 배열)을 짓고 기존 내용을 옮겨야 합니다.

메모리 레이아웃 — 객체 헤더 + 길이 + 요소 영역

배열 변수는 스택 위의 작은 슬롯(보통 8바이트)일 뿐이고, 실제 데이터는 모두 힙(heap — 가비지 컬렉터가 관리하는 동적 메모리 영역)에 살고 있습니다. 변수는 그 힙 객체를 가리키는 참조입니다.

스택 (변수 슬롯)

배열 객체는 어떤 요소 타입이든 항상 힙에 자리 잡습니다. 머리에는 다른 .NET 객체와 똑같은 객체 헤더(타입 정보 + 동기화 블록)가 있고, 그다음에 변하지 않는 Length가 박혀 있고, 이어서 실제 요소들이 한 줄로 누워 있습니다. 이 "한 줄로 누워 있다"는 것이 배열의 모든 성능 특성을 결정합니다 — CPU 캐시가 잘 듣고, 인덱스 접근이 한 번의 곱셈+덧셈으로 끝나고, 길이가 박혀 있어 매 접근마다 범위 검사를 할 수 있다는 것이 모두 이 레이아웃에서 나옵니다.

네 가지 선언 방식

C#
// (a) 크기만 지정 — 모든 칸이 기본값(int=0, bool=false, 참조타입=null)
int[] a = new int[3];

// (b) 길이 + 값 동시
int[] b = new int[] { 10, 20, 30 };

// (c) 변수 선언 한정 단축형 — (b)와 동일
int[] c = { 10, 20, 30 };

// (d) 컴파일러가 요소 타입 추론
int[] d = new[] { 10, 20, 30 };

(b)·(c)·(d)는 어디까지나 표기법 차이일 뿐 컴파일 결과가 같습니다. (c)는 변수를 처음 선언하는 자리에서만 쓸 수 있고(메서드 인자나 return 자리에서는 불가), (d)는 요소 타입을 적지 않아도 되니 var와 함께 자주 등장합니다.

이 셋이 정말 같은지 확인하려면 IL(Intermediate Language — C# 컴파일러가 만드는 중간 코드, JIT가 실행 직전에 기계어로 다시 번역한다)을 들여다보면 됩니다.

IL
// FixedSize() — new int[3]
IL_0000: ldc.i4.3                                                                    // 길이 3을 스택에 푸시
IL_0001: newarr [System.Runtime]System.Int32                                         // int 배열을 힙에 새로 할당 (모든 칸 0으로 초기화)
IL_0006: ret

// WithValues() — new int[] { 10, 20, 30 }
IL_0000: ldc.i4.3                                                                    // 길이 3
IL_0001: newarr [System.Runtime]System.Int32                                         // 빈 배열 할당
IL_0006: dup                                                                         // 스택의 배열 참조 복제
IL_0007: ldtoken field ... '__StaticArrayInitTypeSize=12' ... '97CA15...'            // 사전 정의된 12바이트 데이터 블록 핸들
IL_000c: call void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers
                  ::InitializeArray(...)                                             // 데이터 블록을 배열에 통째로 복사
IL_0011: ret

newarr 한 줄이 곧 GC 부담입니다. 길이만 적은 (a) 형태는 그 자리에서 0으로 채운 새 객체를 힙에 던지고, 초기값이 있는 (b)·(c)·(d) 형태는 빈 배열을 만든 뒤 컴파일러가 어셈블리 어딘가에 박아 둔 정적 바이트 블록(__StaticArrayInitTypeSize=12)을 RuntimeHelpers.InitializeArray로 한 번에 복사합니다. 한 칸씩 stelem.i4로 채우는 것보다 빠르지만 — 새 객체를 만든다는 사실은 변하지 않습니다. 핫패스에서 새 배열을 만드는 비용이 비싼 이유는 바로 이 newarr가 매번 호출되기 때문입니다.


3. 내부 동작 — 인덱스 접근, Length, 그리고 IL이 보여주는 진실

인덱스 0부터 시작하는 진짜 이유

"왜 1이 아니고 0인가?"

답은 메모리 주소 계산식입니다. CPU가 arr[i]를 만났을 때 실행하는 식은 단순합니다.

요소 주소 = 배열 시작 주소 + i × 요소 크기

i = 0이면 곱셈 결과가 0이라 그냥 시작 주소가 첫 요소 주소가 됩니다. 만약 1부터 셌다면 매 접근마다 (i - 1) 빼기 한 번이 추가로 들어갑니다. 한 번의 뺄셈이라 별것 아니라고 느낄 수 있지만, 1프레임에 수만 번 인덱싱이 일어나는 게임 루프에서는 의미 있는 차이가 됩니다. 그리고 무엇보다 — 0 시작은 C·C++부터 이어진 관례라 다른 언어와 데이터를 주고받을 때 인덱스 변환을 하지 않아도 됩니다.

for vs foreach — 의외로 IL이 거의 같다

신입 때 자주 듣는 말 중 하나가 "foreachIEnumerator를 만들기 때문에 느리다"입니다. 일반 컬렉션에서는 사실이지만, 배열에서는 컴파일러가 foreach를 사실상 for로 다시 써 줍니다.

C#
public static int SumFor(int[] xs)
{
    int sum = 0;
    for (int i = 0; i < xs.Length; i++) sum += xs[i];
    return sum;
}

public static int SumForeach(int[] xs)
{
    int sum = 0;
    foreach (int x in xs) sum += x;
    return sum;
}
IL
// SumFor 핵심 부분
IL_0007: ldloc.0           // sum
IL_0008: ldarg.0           // 배열 xs
IL_0009: ldloc.1           // 인덱스 i
IL_000a: ldelem.i4         // xs[i] (int) 로드 — 내부적으로 범위 검사 포함
IL_000b: add               // sum + xs[i]
IL_000c: stloc.0           // sum 갱신
...
IL_0011: ldloc.1           // i
IL_0012: ldarg.0           // xs
IL_0013: ldlen             // xs.Length (메서드 호출 아님 — 배열 전용 한 명령)
IL_0014: conv.i4
IL_0015: clt               // i < Length 비교

// SumForeach 핵심 부분
IL_000a: ldloc.1           // 임시 변수에 담긴 배열
IL_000b: ldloc.2           // 인덱스
IL_000c: ldelem.i4         // xs[i]
...
IL_0017: ldloc.1
IL_0018: ldlen             // ← foreach인데도 ldlen 사용!
IL_0019: conv.i4
IL_001a: blt.s IL_000a     // i < Length이면 점프

두 메서드 모두 IEnumerator를 만들지 않고, ldlen(Length 가져오기)·ldelem.i4(int 요소 로드)·인덱스 증가의 같은 패턴을 돕니다. 차이가 있다면 foreach가 배열을 임시 변수에 한 번 담는다는 점뿐입니다. 배열 한정으로는 어느 쪽을 써도 성능이 같다고 봐도 됩니다 — 그러니 가독성이 더 좋은 쪽을 쓰면 됩니다.

ldlen이 메서드가 아니라 단일 IL 명령어라는 점도 짚어 둘 만합니다. 그래서 arr.Length는 호출 비용이 사실상 0이고, "루프 조건에서 arr.Length를 매번 평가하면 느리다"는 통념도 배열에는 해당하지 않습니다.

범위 검사와 IndexOutOfRangeException

배열 접근(ldelem·stelem)은 IL 한 명령어처럼 보이지만, JIT(Just-In-Time — 실행 직전에 IL을 기계어로 변환하는 컴파일러)는 그 자리에 보이지 않는 범위 검사를 박아 둡니다. 인덱스가 0 <= i < Length를 벗어나면 그 자리에서 IndexOutOfRangeException이 던져집니다. 이 검사 덕분에 C/C++ 같은 버퍼 오버런(범위를 넘어선 메모리에 침범) 사고를 막을 수 있지만, 매 접근마다 비교가 들어가면 성능이 깎입니다.

좋은 소식은 JIT가 for (int i = 0; i < arr.Length; i++) 같은 정형 패턴을 알아보고 범위 검사를 통째로 들어내는 BCE(Bounds Check Elimination — 범위 검사 제거) 최적화를 한다는 점입니다. JIT가 "이 루프 안에서 i는 절대 Length 이상이 될 수 없다"는 사실을 증명할 수 있으면, 매 반복의 검사를 빼 버립니다. 그래서 위 정형 패턴이 가장 빠릅니다.

반면 arr[someComputedIndex]처럼 인덱스 증명이 어려운 경우엔 검사가 그대로 남습니다. 신입이 자주 만드는 안티패턴 — 인덱스를 따로 계산해서 넘기는 헬퍼 함수, for (int i = 0; i <= arr.Length; i++) 같은 한 칸 넘는 루프 — 는 BCE 혜택을 못 받거나 런타임 예외로 떨어집니다.

<<= — 한 글자 차이지만 결과가 다르다 길이 N인 배열의 유효 인덱스는 0 ~ N-1이다. for (int i = 0; i < arr.Length; i++)은 마지막 인덱스 N-1에서 끝나지만, <= 로 적으면 N까지 가서 즉시 IndexOutOfRangeException이 발생한다.

4. 실전 적용 — Unity 핫패스에서 GC를 굶기는 법

Before/After: 매 호출 할당 vs 캐시 배열 재사용

C#
// ❌ Before — 매 호출 새 배열을 힙에 던짐
public static int[] FindAlloc(int n)
{
    int[] result = new int[n];
    for (int i = 0; i < n; i++) result[i] = i;
    return result;
}

// ✅ After — 미리 할당해 둔 캐시 배열에 결과를 채워 길이만 반환
private static readonly int[] _cache = new int[64];

public static int FindNonAlloc(int n)
{
    int count = Math.Min(n, _cache.Length);
    for (int i = 0; i < count; i++) _cache[i] = i;
    return count;
}
IL
// FindAlloc — 호출마다 newarr가 본문에 박혀 있음
IL_0001: ldarg.0
IL_0002: newarr [System.Runtime]System.Int32                  // ← 매번 힙 할당
IL_0007: stloc.0
...
IL_000f: stelem.i4                                            // result[i] = i

// FindNonAlloc — 본문에는 newarr가 없다
IL_0002: ldsfld int32[] ArrayDemo.HotPath::_cache             // ← 정적 캐시 로드
IL_0007: ldlen
...
IL_001a: stelem.i4                                            // _cache[i] = i

// .cctor — _cache는 클래스 처음 쓰일 때 단 한 번 만들어진다
IL_0000: ldc.i4.s 64
IL_0002: newarr [System.Runtime]System.Int32                  // ← 평생 한 번만 newarr
IL_0007: stsfld int32[] ArrayDemo.HotPath::_cache

핵심은 두 메서드의 본문 IL을 비교하는 것입니다. FindAlloc은 본문 안에 newarr 한 줄이 박혀 있어 호출할 때마다 힙에 새 배열을 만듭니다. FindNonAlloc은 본문에 newarr가 없습니다 — ldsfld로 정적 필드를 가져올 뿐입니다. _cache 자체는 클래스 정적 생성자(.cctor)에서 한 번 만들어지고, 그 뒤로는 같은 객체를 모든 호출이 공유합니다. 호출 N번에 대한 힙 할당이 N번에서 1번으로 줄어듭니다.

Unity API에는 이 패턴을 그대로 따라가는 짝이 많습니다.

매번 새 배열 (피해야 할 쪽) 캐시 배열 재사용 (선호)
Physics.RaycastAll Physics.RaycastNonAlloc
Physics.OverlapSphere Physics.OverlapSphereNonAlloc
GetComponentsInChildren<T>() (배열 반환) GetComponentsInChildren<T>(List<T>) (오버로드)
string.Split(...) 미리 할당한 Span<char> 사용

핫패스(Update·FixedUpdate·OnTriggerStay 같이 매 프레임 호출되는 경로)에서는 거의 항상 NonAlloc 또는 캐시 패턴을 골라야 합니다. 60FPS로 10초 동안 돌리면 매 프레임 16개짜리 배열이 9600번 만들어지고, 그게 GC 입장에서는 정리해야 할 9600개의 단명 객체입니다.

특히 Unity 모바일 빌드는 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환한 뒤 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터로, .NET CoreCLR이 쓰는 세대별 GC와 달리 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라, 같은 코드라도 데스크톱·서버 .NET보다 GC 한 번에 끊기는 시간이 더 두드러집니다. Unity Profiler의 GC.Alloc 컬럼이 매 프레임 0이 되도록 핫패스를 설계하는 것이 모바일 60FPS의 출발점입니다.

빈 배열은 Array.Empty<T>()

길이가 0인 배열을 반환할 일이 자주 있다면(예: "결과가 없을 때 빈 배열을 돌려줌") new int[0] 대신 Array.Empty<int>()를 씁니다. 호출할 때마다 새 빈 배열 객체를 만드는 것이 아니라 타입별로 미리 만들어진 단 하나의 빈 배열 인스턴스를 돌려주기 때문에 GC alloc이 0입니다.

C#
// ❌ 호출마다 작은 빈 배열 객체가 생긴다
public int[] EmptyResult() => new int[0];

// ✅ 어떤 호출이든 같은 객체 — 할당 0
public int[] EmptyResult() => Array.Empty<int>();

List<T>보다 미세하게 빠른 이유

List<T>[i]는 메서드 호출이라(내부적으로 _items[index]로 위임), 배열 직접 접근(arr[i] → IL ldelem.i4)보다 한 단계 더 거칩니다. 보통은 차이가 무의미하지만, 수만 번 반복되는 매 프레임 루프에서는 누적이 됩니다. 길이가 변하지 않고 인덱스 접근만 하는 자료라면 배열을 그대로 쓰는 편이 단순하고 약간 빠릅니다 — List<T>는 "런타임에 길이가 변하는 컨테이너"라는 정체성을 위해 도입하는 추가 비용이 있다는 사실만 기억하면 됩니다.


5. 함정과 주의사항

함정 1 — 배열 변수 대입은 복사가 아니라 별명

배열은 참조 타입입니다. 변수에 대입하면 새 배열이 생기는 게 아니라 같은 객체를 가리키는 참조가 하나 더 생길 뿐입니다. 신입이 가장 자주 깨지는 지점입니다.

int[] original
C#
public static (int orig, int alias, int clone) ShallowVsClone()
{
    int[] original = { 1, 2, 3 };
    int[] aliased  = original;                 // 참조 복사 — 같은 객체
    int[] cloned   = (int[])original.Clone();  // 새 객체

    aliased[0] = 99;
    cloned[0]  = 7;
    return (original[0], aliased[0], cloned[0]);
    // → (99, 99, 7)
}
IL
// 핵심 부분
IL_0012: stloc.0                                              // original 변수에 새 배열 저장
IL_0013: ldloc.0
IL_0014: stloc.1                                              // aliased = original (같은 참조)
IL_0015: ldloc.0
IL_0016: callvirt instance object [System.Runtime]System.Array::Clone()   // 새 배열 객체 생성
IL_001b: castclass int32[]
IL_0020: stloc.2                                              // cloned에 새 객체 참조
IL_0021: ldloc.1                                              // aliased
IL_0022: ldc.i4.0
IL_0023: ldc.i4.s 99
IL_0025: stelem.i4                                            // aliased[0] = 99
                                                              // (aliased와 original이 같은 객체이므로
                                                              //  original[0]도 99로 바뀐다)

aliased = original은 한 줄짜리 참조 복사이고, Clone()callvirt로 호출되어 진짜 새 배열을 만듭니다. 요소 값을 독립적으로 두고 싶으면 반드시 Clone()이나 Array.Copy로 복사해야 합니다. 메서드 인자로 배열을 넘길 때도 마찬가지로 — 받은 메서드가 그 배열을 수정하면 호출자의 배열도 같이 바뀝니다.

(int[])original.Clone() — 왜 캐스팅이 필요한가 Array.Clone()object를 반환한다. 배열이라는 사실은 런타임에는 알지만 컴파일러 입장에서는 일단 object이므로, 다시 int[]로 형변환해 줘야 인덱싱이 가능하다.

함정 2 — Length는 프로퍼티지 메서드가 아니다

C#
arr.Length    // ✅ 프로퍼티 — 단일 IL 명령(ldlen)으로 처리
arr.Length()  // ❌ 컴파일 오류

List<T>Count(역시 프로퍼티), 문자열은 Length(프로퍼티), IEnumerable<T>에는 Count()(메서드, 전수 카운트)가 있어 헷갈리기 쉽습니다. 배열에서는 항상 괄호 없는 arr.Length입니다.

함정 3 — 공변성과 ArrayTypeMismatchException

C# 배열은 공변성(covariance — 서브타입 배열을 상위타입 배열로 다룰 수 있음)을 지원해, string[]object[] 자리에 넘길 수 있습니다. 컴파일은 통과하지만 런타임에 깨질 수 있다는 것이 함정입니다.

C#
string[] strings = { "a", "b" };
object[] objs    = strings;      // 컴파일 OK (공변성)

objs[0] = 123;                   // 런타임: ArrayTypeMismatchException
//      string 자리에 int를 넣으려 하므로 CLR이 막아 준다

CLR(Common Language Runtime — .NET 코드를 실행하는 가상머신)이 매 stelem 때마다 실제 요소 타입과 저장하려는 값의 타입을 비교해 막습니다. 이 비교 자체도 작은 비용이라, 성능이 중요한 자리에서는 IList<T>IReadOnlyList<T>처럼 제네릭 인터페이스로 전달하는 편이 안전하고 빠릅니다.

함정 4 — 참조 타입 배열은 모든 칸이 null

new string[5]string 다섯 개를 만들지 않습니다. 다섯 개의 빈 슬롯을 만들고 모두 null로 채울 뿐입니다.

C#
string[] names = new string[3];
Console.WriteLine(names[0].Length);   // NullReferenceException

값 타입 배열은 자동으로 0/false로 초기화되지만, 참조 타입 배열은 각 칸을 직접 new로 채워 넣기 전까지는 모두 비어 있다고 기억해야 합니다.


6. C# 버전별 변화

배열 자체는 C# 1.0부터 거의 그대로지만, 주변 문법은 꾸준히 발전했습니다.

버전 변화 비고
1.0 int[] x = new int[] { 1, 2, 3 } 기본
3.0 int[] x = new[] { 1, 2, 3 } 요소 타입 추론
12 int[] x = [1, 2, 3] 컬렉션 식List<T>·Span<T> 등에도 같은 문법

C# 12에서 등장한 컬렉션 식은 형태가 새롭지만, 단순 배열에 한해서는 컴파일 결과가 기존과 똑같습니다.

C#
public static int[] OldStyle() => new int[] { 1, 2, 3 };
public static int[] NewStyle() => [1, 2, 3];
IL
// OldStyle()
IL_0000: ldc.i4.3
IL_0001: newarr [System.Runtime]System.Int32
IL_0006: dup
IL_0007: ldtoken field ... '4636993D...'                      // 사전 정의된 12바이트 데이터 블록
IL_000c: call void ... RuntimeHelpers::InitializeArray(...)
IL_0011: ret

// NewStyle() — 완전히 동일!
IL_0000: ldc.i4.3
IL_0001: newarr [System.Runtime]System.Int32
IL_0006: dup
IL_0007: ldtoken field ... '4636993D...'                      // 같은 hash, 같은 데이터 블록
IL_000c: call void ... RuntimeHelpers::InitializeArray(...)
IL_0011: ret

두 메서드의 IL이 한 글자도 다르지 않습니다. 컬렉션 식이 진가를 발휘하는 곳은 List<T>·Span<T>·HashSet<T> 같은 다른 컬렉션 타입과 짝을 이룰 때입니다 — [1, 2, 3] 한 표기로 어떤 타입의 컬렉션이든 만들 수 있게 된 것이 핵심이고, 1차원 배열에서는 "더 짧게 쓸 수 있다" 정도로 받아들이면 충분합니다.

C# 12 컬렉션 식의 다른 활용(전개 연산자 .., Span<T>와의 조합 등)은 PART 6의 5번 주제 "컬렉션 식"에서 별도로 다룹니다.


7. 정리

이 글에서 다룬 내용을 한 손에 잡히는 체크리스트로 압축합니다.

  • [ ] 배열은 참조 타입이고, 변수는 힙 객체를 가리키는 포인터다.
  • [ ] 메모리 레이아웃: 객체 헤더 + Length(읽기 전용) + 요소 영역(연속).
  • [ ] 인덱스 0 시작요소 주소 = 시작 + i × 크기 식을 단순하게 유지하기 위해서다.
  • [ ] Length는 프로퍼티(괄호 없음). IL에서는 ldlen 단일 명령으로 처리되어 호출 비용이 사실상 0이다.
  • [ ] 배열에 한해 forforeach는 IL 패턴이 거의 같다 — 가독성으로 고른다.
  • [ ] ldelem·stelem 마다 JIT가 범위 검사를 박는다. for (int i = 0; i < arr.Length; i++) 같은 정형 패턴은 BCE(Bounds Check Elimination)로 검사가 사라진다.
  • [ ] new int[N]은 본문에 newarr를 박는다 → 매 호출 GC alloc. 핫패스에서는 캐시 배열·*NonAlloc API를 써서 본문에서 newarr를 없앤다.
  • [ ] 빈 배열을 자주 돌려준다면 Array.Empty<T>()(타입당 인스턴스 1개).
  • [ ] int[] b = a;는 별명이지 복사가 아니다. 독립이 필요하면 (int[])a.Clone() 또는 Array.Copy.
  • [ ] 참조 타입 배열의 모든 칸은 null로 초기화된다. 값을 직접 넣어 줘야 안전하게 접근할 수 있다.
  • [ ] C# 12 컬렉션 식 [1, 2, 3]은 단순 배열에서 기존 표기와 IL이 같다. 진가는 다른 컬렉션 타입과 함께 쓸 때 드러난다 — 이는 PART 6-5에서 다룬다.
반응형

+ Recent posts