Thursday, September 7, 2023

실전 gRPC: 프로토콜 버퍼 설계부터 고급 디버깅까지

마이크로서비스 아키텍처(MSA)가 현대적인 애플리케이션 개발의 표준으로 자리 잡으면서, 서비스 간의 효율적이고 안정적인 통신은 시스템 전체의 성능과 확장성을 결정하는 핵심 요소가 되었습니다. 수많은 통신 프로토콜 중에서 구글이 개발한 gRPC(Google Remote Procedure Call)는 HTTP/2의 강력한 성능과 프로토콜 버퍼(Protocol Buffers)의 엄격한 스키마 정의를 결합하여 MSA 환경에 최적화된 솔루션으로 주목받고 있습니다. 이 글에서는 gRPC의 핵심인 .proto 파일 설계 원칙부터 실제 운영 환경에서 마주할 수 있는 복잡한 문제를 해결하기 위한 체계적인 디버깅 전략과 도구 활용법까지 심도 있게 다룹니다.

1. gRPC와 프로토콜 버퍼: 현대적 API의 근간

gRPC를 이해하기 위해서는 먼저 그것이 해결하고자 했던 문제, 즉 기존의 RESTful API가 가진 한계를 알아야 합니다. JSON을 페이로드로 사용하는 REST API는 인간이 읽기 쉽고 널리 사용된다는 장점이 있지만, 텍스트 기반 직렬화로 인한 성능 저하, 느슨한 계약(loose contract), 그리고 HTTP/1.1의 제약으로 인해 대규모 마이크로서비스 환경에서는 비효율을 초래할 수 있습니다.

gRPC는 이러한 문제들을 다음과 같은 핵심적인 특징으로 해결합니다.

  • HTTP/2 기반 통신: gRPC는 기본적으로 HTTP/2를 전송 계층으로 사용합니다. HTTP/2는 단일 TCP 연결 상에서 여러 요청을 동시에 처리할 수 있는 다중화(Multiplexing)를 지원하여 'Head-of-line blocking' 문제를 해결하고 지연 시간을 크게 줄입니다. 또한, 헤더 압축(HPACK)과 서버 푸시(Server Push) 기능을 통해 네트워크 효율성을 극대화합니다.
  • 프로토콜 버퍼(Protocol Buffers): gRPC의 API 명세는 프로토콜 버퍼를 사용해 .proto 파일에 정의됩니다. 프로토콜 버퍼는 구조화된 데이터를 직렬화하기 위한 언어 및 플랫폼 중립적인 메커니즘입니다. 데이터를 텍스트(JSON, XML)가 아닌 압축된 이진 형식으로 직렬화하므로 데이터의 크기가 작고 파싱 속도가 매우 빠릅니다.
  • 엄격한 API 계약(Strict API Contract): .proto 파일은 서비스, 원격 프로시저(RPC), 그리고 메시지 형식을 명확하게 정의하는 '계약서' 역할을 합니다. 이 파일을 기반으로 각 언어에 맞는 클라이언트 스텁(stub)과 서버 스켈레톤(skeleton) 코드가 자동으로 생성되므로, 타입 불일치와 같은 런타임 오류를 컴파일 시점에 방지할 수 있습니다.
  • 다양한 통신 패턴 지원: 단순한 요청-응답 모델(Unary RPC) 외에도, 서버 스트리밍, 클라이언트 스트리밍, 그리고 양방향 스트리밍(Bi-directional Streaming)을 네이티브하게 지원하여 실시간 데이터 전송, 대용량 파일 업로드 등 복잡한 시나리오를 효율적으로 구현할 수 있습니다.

프로토콜 버퍼 심층 분석

gRPC의 심장이라 할 수 있는 프로토콜 버퍼의 구조를 자세히 살펴보겠습니다. .proto 파일은 gRPC 통신의 청사진입니다.

syntax = "proto3";

package ecommerce.v1;

option go_package = "github.com/my-org/ecommerce/gen/go/v1;ecommercev1";

// 상품 정보 서비스 정의
service ProductService {
  // 특정 ID의 상품 정보 조회 (Unary RPC)
  rpc GetProduct(GetProductRequest) returns (Product);

  // 여러 상품 정보 조회 (Server Streaming RPC)
  rpc ListProducts(ListProductsRequest) returns (stream Product);
}

// 상품 메시지 정의
message Product {
  string id = 1; // 상품 고유 ID (UUID)
  string name = 2; // 상품명
  string description = 3; // 상품 설명
  int64 price = 4; // 가격 (가장 작은 통화 단위, 예: 원, 센트)
  bool is_available = 5; // 재고 유무
}

// GetProduct RPC의 요청 메시지
message GetProductRequest {
  string id = 1; // 조회할 상품의 ID
}

// ListProducts RPC의 요청 메시지
message ListProductsRequest {
  // 페이지네이션을 위한 필터링 조건 (선택 사항)
  int32 page_size = 1;
  string page_token = 2;
}

위 예제 코드는 간단한 전자상거래 상품 서비스를 정의합니다. 각 구성 요소를 자세히 분석해 봅시다.

  • syntax = "proto3";: 이 파일이 프로토콜 버퍼 버전 3 문법을 사용함을 명시합니다. proto2에 비해 문법이 간결해지고 기본값 처리가 단순화되었습니다.
  • package ecommerce.v1;: 메시지 타입의 이름 충돌을 방지하기 위한 네임스페이스를 정의합니다. 일반적으로 `[서비스명].[버전]` 형식을 따르는 것이 좋습니다. 이는 API 버전 관리에 매우 중요합니다.
  • option go_package = "...";: 특정 언어(이 경우 Go)에서 생성될 코드의 패키지 경로와 이름을 지정하는 옵션입니다. 각 언어별로 유사한 옵션(java_package, csharp_namespace 등)이 존재합니다.
  • service ProductService { ... }: API가 제공하는 원격 프로시저(메서드)들의 집합인 '서비스'를 정의합니다. REST의 '리소스'와 유사한 개념입니다.
  • rpc GetProduct(...) returns (...);: 원격 프로시저 호출(RPC)을 정의합니다. GetProduct라는 이름의 함수가 GetProductRequest 메시지를 입력으로 받아 Product 메시지를 반환한다는 의미입니다.
  • rpc ListProducts(...) returns (stream Product);: 서버 스트리밍 RPC를 정의합니다. 클라이언트가 하나의 ListProductsRequest를 보내면, 서버는 조건에 맞는 여러 개의 Product 메시지를 스트림 형태로 연속해서 보낼 수 있습니다. 이는 대규모 데이터셋을 효율적으로 전송하는 데 유용합니다.
  • message Product { ... }: 데이터 구조를 정의하는 '메시지' 타입입니다. 각 필드는 타입, 이름, 그리고 고유한 필드 번호로 구성됩니다.
  • 필드 번호(= 1, = 2, ...): 필드 번호는 프로토콜 버퍼에서 매우 중요합니다. 이 번호는 직렬화된 이진 데이터에서 각 필드를 식별하는 데 사용됩니다. 한번 사용된 필드 번호는 변경해서는 안 되며, 필드를 삭제하더라도 해당 번호는 재사용하지 않는 것이 좋습니다 (reserved 키워드로 명시). 이는 향후 API의 하위 호환성(backward compatibility)과 상위 호환성(forward compatibility)을 보장하는 핵심 메커니즘입니다.

2. 견고한 proto 파일 설계 전략

단순히 .proto 파일을 작성하는 것을 넘어, 유지보수 가능하고 확장성 있는 API를 설계하는 것은 gRPC 기반 시스템의 성패를 좌우합니다. 이는 단순한 문법의 문제가 아니라, API의 구조와 철학에 대한 깊은 고민이 필요한 과정입니다.

파일 및 패키지 구조화

프로젝트가 커지면 수십, 수백 개의 .proto 파일이 생길 수 있습니다. 체계적인 구조가 없다면 관리가 불가능해집니다.

  • 기능별 분리: 관련된 서비스와 메시지는 같은 파일이나 디렉터리에 묶습니다. 예를 들어, `product_service.proto`, `order_service.proto`, `user_service.proto`와 같이 서비스 단위로 파일을 분리합니다.
  • 공통 메시지 분리: 여러 서비스에서 공통으로 사용되는 메시지(예: `UUID`, `Money`, `Pagination`)는 `common/v1/types.proto`와 같은 별도의 파일로 추출하여 `import` 구문을 통해 재사용합니다.
// in product_service.proto
import "common/v1/types.proto";

service ProductService {
  rpc CreateProduct(CreateProductRequest) returns (common.v1.UUID);
}

API 버전 관리

API는 한번 배포되면 쉽게 변경하기 어렵습니다. 호환성이 깨지는 변경(breaking change)은 기존 클라이언트의 오작동을 유발할 수 있습니다. 따라서 체계적인 버전 관리가 필수적입니다.

  • 패키지 이름에 버전 명시: 가장 널리 사용되는 방법은 패키지 이름에 `v1`, `v2`와 같은 버전을 포함하는 것입니다 (예: `package ecommerce.product.v1;`). 호환성이 깨지는 변경이 필요할 경우, 새로운 버전의 패키지(`ecommerce.product.v2`)와 .proto 파일을 만들어 점진적으로 마이그레이션합니다.
  • 필드 추가는 안전: 기존 메시지에 새로운 필드를 추가하는 것은 하위 호환성을 해치지 않습니다. 이전 버전의 클라이언트는 새로 추가된 필드를 무시합니다.
  • 필드 번호와 타입 변경 금지: 절대로 기존 필드의 번호나 타입을 변경해서는 안 됩니다. 이는 직렬화/역직렬화 과정에서 데이터를 완전히 손상시킬 수 있습니다.
  • 필드 삭제 대신 `reserved` 사용: 더 이상 사용하지 않는 필드는 삭제하는 대신 `reserved` 키워드를 사용하여 해당 필드 번호와 이름이 미래에 재사용되지 않도록 막아야 합니다.
message UserProfile {
  reserved 2, 15, 9 to 11;
  reserved "nickname", "age";
  // ... other fields
}

메시지 설계 고급 기법

프로토콜 버퍼는 단순한 데이터 구조 외에도 복잡한 비즈니스 로직을 표현할 수 있는 다양한 기능을 제공합니다.

  • `oneof` 활용: 여러 필드 중 단 하나만 존재할 수 있음을 명시하고 싶을 때 `oneof`을 사용합니다. 예를 들어, 알림 메시지가 이메일, SMS, 푸시 알림 중 하나일 수 있는 경우에 유용합니다. 이는 메모리 사용을 최적화하고 API의 의도를 명확히 합니다.
    message Notification {
      oneof delivery_method {
        EmailNotification email = 1;
        SmsNotification sms = 2;
        PushNotification push = 3;
      }
    }
    
  • `map` 타입 사용: 키-값 쌍의 데이터를 표현할 때는 `map`을 사용합니다. 이는 JSON의 객체와 유사하며, 메타데이터나 동적 속성을 저장하는 데 적합합니다.
    message Product {
          // ...
          map<string, string> attributes = 6; // 예: {"color": "red", "size": "large"}
        }
        
  • 표준 Well-Known Types 활용: 프로토콜 버퍼는 `google/protobuf/` 경로에 자주 사용되는 유용한 타입들을 미리 정의해두었습니다. 예를 들어, `Timestamp` (시간 표현), `Duration` (기간 표현), `Empty` (입력이나 출력이 없을 때), `Wrapper` 타입 (StringValue, `Int32Value` 등 스칼라 타입의 null 표현) 등이 있습니다. 이를 직접 정의하지 않고 `import`하여 사용하면 표준을 따르고 코드의 일관성을 높일 수 있습니다.
    import "google/protobuf/timestamp.proto";
    import "google/protobuf/wrappers.proto";
    
    message Event {
      string id = 1;
      google.protobuf.Timestamp event_time = 2;
      google.protobuf.StringValue optional_description = 3; // null이 가능한 문자열
    }
    

3. gRPC 디버깅을 위한 체계적인 접근법

gRPC는 이진 프로토콜을 사용하고 통신이 암호화(TLS)되는 경우가 많아, 기존의 텍스트 기반 API처럼 간단히 요청/응답을 들여다보기 어렵습니다. 따라서 문제 발생 시 체계적인 디버깅 전략을 갖추는 것이 매우 중요합니다.

1단계: 로깅(Logging) - 모든 것의 시작

가장 기본적이면서도 강력한 디버깅 도구는 로그입니다. 하지만 단순히 `print` 문을 남발하는 것이 아니라, 구조화된 로깅(Structured Logging)을 통해 기계가 분석하기 쉬운 형태로 로그를 남겨야 합니다.

  • 구조화된 로그: 로그를 JSON과 같은 형식으로 출력하여 필드(예: `timestamp`, `level`, `service`, `trace_id`, `message`)를 기준으로 쉽게 검색하고 필터링할 수 있도록 합니다.
  • 인터셉터(Interceptor) 활용: gRPC는 서버와 클라이언트 양단에 인터셉터를 추가하여 모든 RPC 호출의 전후에 공통 로직을 삽입할 수 있습니다. 로깅, 인증, 메트릭 수집 등을 위한 인터셉터를 구현하면 비즈니스 로직과 횡단 관심사(cross-cutting concerns)를 깔끔하게 분리할 수 있습니다.
    // Go 언어의 서버 로깅 인터셉터 예시 (개념)
    func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        startTime := time.Now()
        
        // 실제 RPC 핸들러 호출
        resp, err := handler(ctx, req)
        
        duration := time.Since(startTime)
        
        // 구조화된 로그 출력
        log.WithFields(log.Fields{
            "method":   info.FullMethod,
            "duration": duration.Milliseconds(),
            "status":   status.Code(err),
        }).Info("gRPC request handled")
        
        return resp, err
    }
    
  • 로그 레벨: `DEBUG`, `INFO`, `WARN`, `ERROR` 등 로그 레벨을 적절히 사용하여 운영 환경에서는 `INFO` 레벨 이상만 기록하고, 문제 발생 시 동적으로 `DEBUG` 레벨로 전환하여 상세 정보를 확인할 수 있도록 설정합니다.

2단계: 오류 처리(Error Handling) - 문제의 본질 파악

gRPC는 풍부한 상태 코드(Status Code)와 오류 세부 정보(Error Details)를 통해 클라이언트에게 문제의 원인을 명확하게 전달할 수 있습니다.

  • 표준 상태 코드의 적극적 활용: gRPC는 `OK`, `CANCELLED`, `UNKNOWN`, `INVALID_ARGUMENT`, `NOT_FOUND`, `ALREADY_EXISTS`, `PERMISSION_DENIED`, `UNAVAILABLE` 등 다양한 표준 상태 코드를 정의합니다. 서버는 상황에 맞는 가장 적절한 코드를 반환해야 합니다. 예를 들어, 클라이언트가 보낸 요청의 유효성 검사에 실패했다면 `UNKNOWN` 대신 `INVALID_ARGUMENT`를 반환해야 합니다.
  • 상세 오류 정보 전달: 단순한 상태 코드와 메시지만으로는 부족할 때가 많습니다. `google.rpc.Status`와 `google.protobuf.Any`를 사용하면 표준화된 방식으로 구조화된 오류 세부 정보를 전달할 수 있습니다. 예를 들어, 유효성 검사 실패 시 어떤 필드가 어떤 이유로 잘못되었는지 상세히 알려줄 수 있습니다.
    // in proto file
    import "google/rpc/status.proto";
    import "google/rpc/error_details.proto";
    
    // 서버 측 (Go 예시)
    st := status.New(codes.InvalidArgument, "Request validation failed")
    br := &errdetails.BadRequest{
        FieldViolations: []*errdetails.BadRequest_FieldViolation{
            {Field: "email", Description: "Email address is not valid"},
            {Field: "password", Description: "Password must be at least 8 characters long"},
        },
    }
    st, _ = st.WithDetails(br)
    return st.Err()
    
    // 클라이언트 측 (Go 예시)
    err := client.CreateUser(...)
    if err != nil {
        st := status.Convert(err)
        for _, detail := range st.Details() {
            switch t := detail.(type) {
            case *errdetails.BadRequest:
                for _, violation := range t.GetFieldViolations() {
                    fmt.Printf("Field: %s, Error: %s\n", violation.GetField(), violation.GetDescription())
                }
            }
        }
    }
    

3단계: 분산 추적(Distributed Tracing) - 마이크로서비스 간의 여정 추적

하나의 사용자 요청이 여러 마이크로서비스를 거쳐 처리되는 환경에서는 어떤 서비스에서 병목이나 오류가 발생했는지 파악하기 매우 어렵습니다. 분산 추적은 이러한 요청의 전체 흐름을 시각화하여 보여줍니다.

  • 핵심 개념:
    • Trace: 분산 시스템을 가로지르는 하나의 요청의 전체 경로. 고유한 Trace ID를 가집니다.
    • Span: Trace 내에서 특정 작업 단위를 나타냅니다. 각 서비스에서의 처리 과정이 하나의 Span이 될 수 있으며, 고유한 Span ID와 부모 Span ID를 가집니다.
  • 컨텍스트 전파(Context Propagation): gRPC는 메타데이터(HTTP/2 헤더)를 통해 Trace ID, Span ID와 같은 컨텍스트 정보를 서비스 간에 자동으로 전파하는 메커니즘을 제공합니다.
  • 구현: OpenTelemetry와 같은 표준화된 라이브러리를 사용하고, Jaeger나 Zipkin과 같은 추적 시스템을 백엔드로 연동하면, 로깅과 마찬가지로 인터셉터를 통해 모든 gRPC 호출에 대한 추적 정보를 손쉽게 수집할 수 있습니다. 이를 통해 서비스 의존성 맵, 각 서비스에서의 지연 시간 등을 한눈에 파악할 수 있습니다.

4단계: 헬스 체크(Health Checking)

서비스가 정상적으로 실행되고 있는지 외부에서 확인할 수 있는 표준적인 방법을 제공하는 것은 안정적인 시스템 운영의 기본입니다. gRPC는 이를 위한 표준 헬스 체크 프로토콜을 정의하고 있습니다.

  • `grpc.health.v1.Health` 서비스를 구현하면, 쿠버네티스(Kubernetes)의 Liveness/Readiness Probe나 로드 밸런서 등이 해당 gRPC 서비스의 상태를 주기적으로 확인하고, 문제가 발생한 인스턴스를 서비스에서 자동으로 제외할 수 있습니다.

4. gRPC 디버깅을 위한 필수 도구 활용법

이론적인 접근법과 함께, 실제 문제를 해결하는 데 도움을 주는 강력한 도구들을 익혀두어야 합니다. 여기서는 가장 널리 사용되는 CLI 및 GUI 도구들을 소개합니다.

grpcurl: 커맨드 라인 인터페이스의 강자

grpcurl은 `curl`의 gRPC 버전이라고 할 수 있는 강력한 CLI 도구입니다. 서버 탐색 및 RPC 호출에 매우 유용합니다.

  • 서비스 및 메서드 목록 확인:
    # 서버가 제공하는 모든 서비스 목록 조회
    $ grpcurl -plaintext localhost:50051 list
    
    # 특정 서비스의 모든 RPC 메서드 목록 조회
    $ grpcurl -plaintext localhost:50051 list ecommerce.v1.ProductService
  • 메시지 형식 확인:
    # 특정 RPC의 요청/응답 메시지 형식 확인
    $ grpcurl -plaintext localhost:50051 describe ecommerce.v1.ProductService.GetProduct
    
    # 특정 메시지 타입의 상세 구조 확인
    $ grpcurl -plaintext localhost:50051 describe ecommerce.v1.Product
  • RPC 호출 실행:
    # Unary RPC 호출 (요청 데이터를 JSON 형식으로 전달)
    $ grpcurl -plaintext -d '{"id": "a1b2c3d4"}' localhost:50051 ecommerce.v1.ProductService.GetProduct
    
    # 서버 스트리밍 RPC 호출
    $ grpcurl -plaintext -d '{"page_size": 10}' localhost:50051 ecommerce.v1.ProductService.ListProducts
  • 서버 리플렉션(Server Reflection): 서버에 gRPC 서버 리플렉션 프로토콜이 활성화되어 있다면, -proto 옵션으로 .proto 파일을 직접 지정할 필요 없이 서버의 API 명세를 동적으로 가져올 수 있어 매우 편리합니다.

Postman / BloomRPC / Kreya: GUI 기반의 탐색과 테스트

CLI 환경이 익숙하지 않거나, 더 시각적이고 인터랙티브한 방식으로 gRPC 서비스를 테스트하고 싶다면 GUI 도구가 훌륭한 대안입니다.

BloomRPC 스크린샷

  • Postman: 기존의 REST API 테스트 도구로 유명한 Postman은 이제 gRPC를 완벽하게 지원합니다. .proto 파일을 임포트하면 자동으로 서비스와 메서드 목록을 파싱해주며, GUI를 통해 요청 메시지를 작성하고 응답을 확인할 수 있습니다. 환경 변수, 테스트 스크립트, 팀 협업 기능 등 Postman의 강력한 기능들을 gRPC 테스트에도 그대로 활용할 수 있습니다.
  • BloomRPC / Kreya: gRPC에 특화된 경량 클라이언트 도구들입니다. 직관적인 UI를 제공하며, proto 파일 로딩, 메타데이터 편집, 스트리밍 RPC 테스트 등을 손쉽게 수행할 수 있습니다. 특히 복잡한 요청을 반복적으로 테스트해야 할 때 유용합니다.

이러한 GUI 도구들은 다음과 같은 상황에서 특히 빛을 발합니다.

  • gRPC를 처음 접하는 개발자가 API를 탐색하고 학습할 때
  • 복잡한 중첩 구조를 가진 메시지를 작성하고 테스트할 때
  • 스트리밍 RPC의 동작을 실시간으로 확인하고 싶을 때
  • API 테스트 케이스를 저장하고 팀원들과 공유해야 할 때

Wireshark: 최후의 보루, 네트워크 패킷 분석

위의 방법들로도 해결되지 않는 미스터리한 문제가 발생했을 때, Wireshark와 같은 네트워크 프로토콜 분석기를 사용해 gRPC 통신의 가장 낮은 수준을 들여다볼 수 있습니다. HTTP/2와 gRPC 프로토콜에 대한 디코더(dissector)가 내장되어 있어, 암호화되지 않은(plaintext) 연결에 한해 gRPC 프레임의 내용을 직접 분석할 수 있습니다.

  • HTTP/2의 HEADERS, DATA 프레임 분석
  • gRPC의 메시지 길이 접두사(Length-Prefixed-Message) 구조 확인
  • 프로토콜 수준의 오류나 비효율적인 데이터 전송 패턴 파악

Wireshark를 사용하는 것은 전문적인 지식이 필요하지만, 라이브러리 버그나 네트워크 장비의 문제와 같이 애플리케이션 수준에서는 원인을 찾기 힘든 문제를 진단하는 데 결정적인 단서를 제공할 수 있습니다.

결론: 성공적인 gRPC 도입을 향하여

gRPC는 단순한 RPC 프레임워크를 넘어, 마이크로서비스 아키텍처의 복잡성을 관리하고 서비스 간의 상호작용을 명확하게 정의하는 강력한 도구입니다. 성공적인 gRPC 도입은 잘 설계된 .proto 계약에서 시작하여, 로깅, 오류 처리, 분산 추적을 아우르는 체계적인 디버깅 전략, 그리고 상황에 맞는 적절한 도구의 활용 능력에 달려있습니다.

이 글에서 다룬 내용들은 gRPC 기반 시스템을 개발하고 운영하면서 마주칠 수 있는 다양한 문제들을 해결하는 데 든든한 기반이 될 것입니다. 기억해야 할 가장 중요한 점은, gRPC의 모든 것은 '계약', 즉 .proto 파일에서 시작된다는 사실입니다. 명확하고, 일관성 있으며, 확장 가능한 API 계약을 설계하는 데 시간을 투자하는 것이 장기적으로 가장 효율적인 디버깅 전략입니다.

추가 학습 자료

더 깊이 있는 학습을 위해 다음 공식 문서들을 참고하는 것을 추천합니다.

  • gRPC 공식 문서: gRPC의 모든 개념과 각 언어별 튜토리얼을 제공합니다.
  • 프로토콜 버퍼 공식 문서: .proto3 문법과 스타일 가이드, 고급 기법에 대한 상세한 정보를 얻을 수 있습니다.
  • OpenTelemetry: 분산 추적과 메트릭 수집을 위한 표준 라이브러리입니다.

이론적 지식을 바탕으로 직접 간단한 gRPC 서비스를 만들어보고, 의도적으로 오류를 발생시킨 뒤 오늘 배운 디버깅 도구들을 사용하여 문제를 해결하는 과정을 반복적으로 연습해보시길 바랍니다. 실습을 통해 얻은 경험은 어떠한 문서보다도 값진 자산이 될 것입니다.


0 개의 댓글:

Post a Comment