3 API 명세와 구현

seohan·2022년 1월 7일
0

이 내용은 원문 Modern-API-Development-with-Spring-and-Spring-Boot를 발췌하여 정리한 것입니다

전체 코드

여기에서는 이 두 영역을 사용하여 REST API를 구현합니다. 구현을 위해 디자인 우선 접근 방식을 선택했습니다. 먼저 API를 설계하고 나중에 구현하기 위해 OpenAPI 명세를 사용합니다. 여기서는 간단한 전자상거래 앱의 API를 설계하고 구현합니다.

  • OpenAPI 명세로 API 설계
  • OpenAPI 명세를 Spring 코드로 변환
  • OpenAPI 명세 코드 인터페이스 구현
  • 전역 예외 처리기 추가

OAS로 API 설계하기

OpenAPI 명세는 REST API의 명세와 설명을 해결하기 위해 도입되었습니다. 이를 통해 YAML 또는 JSON 마크업 언어로 REST API를 작성할 수 있습니다.

REST API를 구현하기 위해 OAS 버전 3.0을 사용합니다.

OAS는 이전에는 Swagger 명세라고 하지만 OAS 지원 도구는 여전히 Swagger로 알려져 있습니다. Swagger는 REST API의 전체 개발 수명 주기를 돕는 오픈 소스 프로젝트입니다.

이 장에서는 Swagger를 사용할 것입니다.

  • Swagger Editor: REST API를 설계하고 설명
  • Swagger Codegen: Spring 기반 API 인터페이스를 생성
  • REST API 문서 생성을 위한 Swagger UI.

OAS 구조

OAS 구조입니다. 처음 3가지는 메타데이터입니다.

  • openapi (version)
  • info
  • externalDocs
  • servers
  • tags
  • paths
  • components

API 정의는 여러 파일로 나눌 수도 있습니다.

메타 데이터

openapi: 3.0.3
info:
  title: Sample Ecommerce App
  description:
    'This is a ***sample ecommerce app API***.  You can find out more about Swagger at [swagger.io](http://swagger.io).
    Description supports markdown markup.
  termsOfService: https://github.com/LICENSE
  contact:
    email: support@packtpub.com
  license:
    name: MIT
    url: https://github.com/LICENSE
  version: 1.0.0
externalDocs:
  description: Document link
  url: http://swagger.io

openapi.yaml 코드

openapi

OpenAPI는 시맨틱 버전 관리를 사용합니다. 즉, 버전이 Major:minor:patch 형식입니다.

info

info에는 API에 대한 메타데이터가 포함됩니다. 이 정보는 문서 생성에 사용되며 클라이언트가 사용할 수 있습니다. 여기에는 제목과 버전만 필수입니다.

  • title: API의 제목
  • description: API 세부 정보
  • termsOfService: 서비스 약관으로 연결되는 URL
  • contact: 연락처 정보
  • license: 라이선스 정보
  • version: API 버전을 문자열 형식으로 노출합니다.

externalDocs

노출된 API의 확장 문서를 가리키는 필드입니다. description과 URL의 두 가지 속성이 있습니다. description은 외부 문서의 요약을 정의하는 필드이며 Markdown을 사용할 수 있습니다. url 속성은 필수이며 외부 문서에 대한 링크입니다.

servers

severs는 API를 호스팅하는 서버 목록이 포함됩니다(선택적). 호스팅된 API 문서가 대화형인 경우 Swagger UI에서 이를 사용하여 API를 직접 호출하고 응답을 표시할 수 있습니다. 기본은 호스트된 문서 서버의 루트(/)를 가리킵니다.

servers:
  - url: https://ecommerce.swagger.io/v2

tags

tags:
  - name: cart
    description: Everything about cart
    externalDocs:
      description: Find out more (extra document link)
      url: http://swagger.io
  - name: order
    description: Operation about orders
  - name: user
    description: Operations about users

tags는 리소스에서 수행되는 작업을 그룹화하는 데 사용됩니다.

components

components:
  schemas:
    Cart:
      description: Shopping Cart of the user
      type: object
      properties:
        customerId:
          description: Id of the customer 
          type: string
        items:
          description: Collection of items in cart.
          type: array
          items:
            $ref: '#/components/schemas/Item'

여기에서 Cart 모델은 object이며 Id 및 items를 포함합니다.

OBJECT 타입

모델과 필드 모두 object로 정의할 수 있습니다. typeobject로 표시하면 다음 속성은 모든 객체의 필드로 구성된 속성입니다.

예를 들어, 이전 코드의 Cart 모델은 다음 구문을 가집니다.

type: object
properties:
    <field name>:
        type: <data type>

OAS는 다음과 같은 6가지 기본 데이터 유형을 지원합니다.

  • string, number, integer, boolean
  • object, array

Cart 모델에 대해 논의합니다.
date-timefloat 타입 등을 정의하려면 format 속성을 사용합니다.

orderDate:
    type: string
    format: date-time

orderDate는 string으로 정의되지만 format은 문자열 값을 결정합니다.
format은 date-time으로 표시되므로 orderDate 필드는 2020-10-22T19:31:58Z. 와 같은 형식을 포함합니다.

다음은 일반적으로 사용하는 형식들입니다.

  • float
  • double
  • int32
  • int64
  • date: for example, 2020-10-22.
  • byte: Base64-encoded values
  • binary

Cart 모델의 items 필드는 사용자 정의 항목 타입의 배열입니다.

여기서 Items은 $ref를 사용하여 참조됩니다. 사용자 정의 타입은 $ref를 사용하는 참조입니다. Item 모델은 component/schemas 섹션의 일부이기도 합니다.

따라서 $ref 값에는 #/component/schemas/{type}이 있는 사용자 정의 타입에 대한 앵커가 포함됩니다.

$ref는 참조 객체를 나타냅니다. JSON 참조를 기반으로 하며 YAML에서 동일한 의미입니다. 동일한 문서나 외부 문서의 객체를 참조할 수 있습니다.

# Relative Schema Document
$ref: Cart.yaml
# Relative Document with embedded Schema
$ref: definitions.yaml#/Cart

array

type: array
items:
    type: <type of object>
  • object 타입을 배열에 배치하면 중첩 배열을 가질 수 있습니다.
  • $ref를 이용하여 사용자 정의 타입을 참조할 수도 있습니다.
Item:
  description: 장바구니 항목
  type: object
  properties:
    id:
      description: 식별자
      type: string
    quantity:
      description: 수량
      type: integer
      format: int32
    unitPrice:
      description: 단가
      type: number
      format: double

Itemcomponents/schema 섹션의 일부이기도 합니다.

IMPORTANT

components 섹션에서 requestBodies(요청 페이로드) 및 response를 정의할 수도 있습니다. 이것은 일반적인 요청 본문과 응답이 있을 때 유용합니다.

paths

여기서 엔드포인트를 정의합니다.

POST /api/v1/carts/{customerId}/items에 대한 정의입니다. 이 API는 지정된 고객 Id와 연결된 cart에 항목을 추가합니다.

paths:
  /api/v1/carts/{customerId}/items:
    post:
      tags:
        - cart
      summary: 장바구니에 항목 추가
      description: Adds an item to the shopping cart
      operationId: addCartItemsByCustomerId
      parameters:
        - name: customerId
          in: path
          description: Customer Identifier
          required: true
          schema:
            type: string
      requestBody:
        description: Item object
        content:
          application/xml:
            schema:
              $ref: '#/components/schemas/Item'
          application/json:
            schema:
              $ref: '#/components/schemas/Item'
      responses:
        201:
          description: 아이템이 추가되었습니다.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Item'
        404:
          description: 고객 ID가 존재하지 않습니다
          content: {}

tags

tags는 API 그룹핑이나 Swagger Codegen이 코드 생성에서 사용될 수 있습니다.

Figure 3.1 — Cart APIs

summary와 description

summary와 description 섹션은 앞서 OAS 섹션의 메타데이터 섹션에서 논의한 것과 동일합니다. 여기에는 각각 주어진 API의 작업 요약과 자세한 설명이 포함되어 있습니다.

operationId

이것은 Swagger Codegen에서 생성된 API 인터페이스의 메서드 이름으로 사용됩니다.

parameters

자세히 보면 name 필드 앞에 -(하이픈)이 있습니다. 이것은 배열 요소로 선언하는 데 사용됩니다. parameters 필드는 여러 매개변수를 포함할 수 있으며 실제로는 경로 및 쿼리 매개변수의 조합이므로 배열로 선언됩니다.

경로 매개변수의 경우 매개변수 아래의 이름 값이 중괄호 안의 경로에 지정된 값과 동일한지 확인해야 합니다.

매개변수 필드에는 API 쿼리, 경로, 헤더 및 쿠키 매개변수가 포함됩니다. 이전 코드에서는 경로 매개변수(in 필드의 값)를 사용했습니다. 쿼리 매개변수로 선언하려는 경우 값을 쿼리로 변경할 수 있습니다.

responses

responses는 응답 타입을 정의합니다. 기본 필드로 HTTP 상태 코드가 포함되어 있습니다. HTTP 상태 코드가 될 수 있는 응답이 하나 이상 있어야 합니다.

다음은 headers 예입니다

responses:
   200:
      description: operation successful
      headers:
         X-RateLimit-Limit:
            schema:
            type: integer

components 섹션에서 응답을 생성하고 $ref를 사용하여 직접 사용할 수 있습니다.

requestBody

requestBody는 요청 페이로드 객체를 정의하는 데 사용됩니다. content는 response 객체에 대해 정의된 방식과 유사한 방식으로 정의할 수 있습니다. responsecomponents 섹션 아래에 재사용 가능한 requestBody을 만들고 $ref를 사용하여 직접 사용할 수도 있습니다.

openapi.yaml에서 코드를 복사하여 https://editor.swagger.io 편집기 편집기에 붙여넣어 멋진 사용자 인터페이스에서 API를 보고 사용하는 것이 좋습니다.

OAS로부터 Spring 코드 생성하기

지금까지 RESTful 웹 서비스 이론과 개념, Spring 기초에 대해 배웠고 샘플 애플리케이션에 대한 첫 번째 API 명세를 설계했습니다.

Swagger 플러그인을 사용하여 방금 작성한 API 정의로부터 코드를 생성하려고 합니다.

(1) Gradle 플러그인에 Swagger Gradle 추가

plugins {
  …
  …
  id 'org.hidetake.swagger.generator' version '2.18.2'
}

(2) 코드 생성을 위한 config 정의

OpenAPI Generator의 CLI가 사용해야 하는 모델 및 API 패키지 이름이나 REST 인터페이스 또는 날짜/시간 관련 개체를 생성하는 데 사용해야 하는 라이브러리 설정이 필요합니다.

{
  "library": "spring-mvc",
  "dateLibrary": "java8",
  "hideGenerationTimestamp": true,
  "modelPackage": "com.packt.modern.api.model",
  "apiPackage": "com.packt.modern.api",
  "invokerPackage": "com.packt.modern.api",
  "serializableModel": true,
  "useTags": true,
  "useGzipFeature" : true,
  "hateoas": true,
  "withXml": true,
  "importMappings": {
      "Link": "org.springframework.hateoas.Link"
  }
}

config.json 코드

importMapping 객체에 대한 코드가 생성되면 생성된 코드에서 매핑된 클래스를 사용합니다. 모델에서 Link를 사용하는 경우 생성된 모델은 YAML 파일에 정의된 모델 대신 매핑된 'org.springframework.hateoas.Link' 클래스를 사용합니다.

(3) OpenAPI Generator 가 무시할 파일을 정의

API 인터페이스와 모델만 생성하므로 openapi-generator-ignore에 다음 코드를 추가합니다.

**/*controller.java

(4) swaggerSources task 정의하기

build.gradle파일에서 swaggerSources 부분에 task를 추가합니다:

swaggerSources {
  def typeMappings = 'URI=URI'
  def importMappings = 'URI=java.net.URI'
  eStore {
    def apiYaml = "openapi.yaml"
    def configJson = "config.json"
    inputFile = file(apiYaml)
    def ignoreFile = file(".openapi-generator-ignore")
    code {
      language = 'spring'
      configFile = file(configJson)
      rawOptions = ['--ignore-file-override', ignoreFile, '--type-mappings',
          typeMappings, '--import-mappings', importMappings] as List<String>
      components = [models: true, apis: true, supportingFiles: 'ApiUtil.java']
      //depends On validation // Should be uncommented once
      //plugin starts supporting OA 3 validation
    }
}
}

여기에서는 openapi.yaml 파일의 위치를 가리키는 inputFile을 포함하는 eStore(사용자 정의 이름)를 정의했습니다.
입력을 정의한 후 생성기는 코드에서 구성된 출력을 생성해야 합니다.

  • language: 언어를 지정합니다
  • configFile: config.json을 지정합니다.
  • rawOptions(typeMapping 및 importMapping 포함) 및 components 요소를 정의했습니다. language를 제외하고는 모두 선택 사항입니다.

여기에서는 단지 모델과 API를 생성하기를 원합니다.

ApiUtil.java는 생성된 API 인터페이스에 필요합니다. 그렇지 않으면 빌드 시간 동안 컴파일 오류가 발생하므로 구성 요소에 추가됩니다.

(5) compileJava 의존성에 swaggerSources 추가

swaggerSourcescompileJava에 의존성 태스크로 추가해야 합니다. eStore에 정의된 코드 블록을 가리킵니다.

compileJava.dependsOn swaggerSources.eStore.code

(6) 생성된 소스 코드를 Gradle 소스 세트에 추가

이렇게 하면 생성된 소스 코드와 리소스를 개발 및 빌드에 사용할 수 있습니다.

sourceSets.main.java.srcDir "${swaggerSources.eStore.code.outputDir}/src/main/java"
sourceSets.main.resources.srcDir "${swaggerSources.eStore.code.outputDir}/src/main/resources"

소스 코드는 프로젝트의 /build/swagger-code-eStore 디렉토리에 생성됩니다. 그러면 생성된 소스 코드와 리소스가 sourceSets에 추가됩니다.

(7) 코드 생성, 컴파일 및 빌드를 위한 빌드 실행

마지막 단계는 빌드를 실행하는 것입니다. 빌드 경로에 실행 가능한 Java 코드가 있는지 확인하십시오. Java 버전은 build.gradle 속성(sourceCompatibility = '1.14') 또는 IDE 설정에 정의된 버전과 일치해야 합니다.

$ gradlew clean build

OAS로부터 생성한 인터페이스 구현

Model과 API 인터페이스 코드에는 YAML의 모든 주석이 포함되며 모델에는 JSON 및 XML 콘텐츠 타입을 지원하는 데 필요한 모든 매핑이 포함됩니다.

이제 인터페이스를 구현하고 그 안에 비즈니스 로직을 작성하기만 하면 됩니다. Swagger Codegen은 제공된 각 태그에 대한 API 인터페이스를 생성합니다.

예를 들어 cartpayment 태그에 대해 각각 CartApiPaymentAPI 인터페이스를 생성합니다. cart 태그가 있는 모든 API는 CartApi 인터페이스로 함께 묶입니다.

이제 각 인터페이스에 대한 클래스를 구현하면 됩니다.

@RestController
public class CartsController implements CartApi {
  private static final Logger log = LoggerFactory.getLogger(CartsController.class);

  @Override
  public ResponseEntity<List<Item>> addCartItemsByCustomerId (String customerId, @Valid Item item) {
    log.info("Request for customer ID: {}\nItem: {}", customerId, item);
    return ok(Collections.EMPTY_LIST);
  }

  @Override
  public ResponseEntity<List<Cart>> getCartByCustomerId(String customerId) {
    throw new RuntimeException("Manual Exception thrown");
  }
  // Other method implementations (omitted)
}

Global 예외 처리기

여러 메서드로 구성된 여러 컨트롤러가 있습니다. 각 메서드에는 확인된 예외가 있거나 런타임 예외가 발생할 수 있습니다. 클린 코드를 위해 모든 오류를 처리할 수 있는 중앙 집중식 장소가 있어야 합니다.

@ControllerAdvice로 주석이 달린 클래스를 작성하고 각 예외에 대해 @ExceptionHandler를 추가하면 됩니다.

public class Error {
  private static final long serialVersionUID = 1L;
  /**
   * App error code, which is different from HTTP error code.
   */
  private String errorCode;
  /**
   * Short, human-readable summary of the problem.
   */
  private String message;
  /**
   * HTTP status code.
   */
  private Integer status;
  /**
   * Url of request that produced the error.
   */
  private String url = "Not available";
  /**
   * Method of request that produced the error.
   */
  private String reqMethod = "Not available";
  // getters and setters (omitted)
}

필요하면 다른 필드도 추가할 수 있습니다. exception 패키지에는 모든 사용자 정의 예외와 전역적인 예외처리 코드를 넣을 것입니다.

그런 다음 ErrorCode라는 enum을 작성하여 사용자 정의 에러와 에러 코드를 포함한 모든 예외 키를 넣는다.

public enum ErrorCode {
  // Internal Errors: 1 to 0999
  GENERIC_ERROR("PACKT-0001", "The system is unable to complete the request. Contact system support."),
  HTTP_MEDIATYPE_NOT_SUPPORTED("PACKT-0002", "Requested media type is not supported. Please use application/json or   application/xml as 'Content-Type' header value"),
  HTTP_MESSAGE_NOT_WRITABLE("PACKT-0003", "Missing 'Accept' header. Please add 'Accept' header."),
  HTTP_MEDIA_TYPE_NOT_ACCEPTABLE("PACKT-0004", "Requested 'Accept' header value is not supported. Please use   application/json or application/xml as 'Accept' value"),
  JSON_PARSE_ERROR("PACKT-0005", "Make sure request payload should be a valid JSON object."),
  HTTP_MESSAGE_NOT_READABLE("PACKT-0006", "Make sure request payload should be a valid JSON or XML object according to 'Content-Type'.");
  private String errCode;
  private String errMsgKey;
  ErrorCode(final String errCode, final String errMsgKey) {
    this.errCode = errCode;
    this.errMsgKey = errMsgKey;
  }
  /**
   * @return the errCode
   */
  public String getErrCode() {
    return errCode;
  }
  /**
   * @return the errMsgKey
   */
  public String getErrMsgKey() {
    return errMsgKey;
  }
}

여기서는 메시지 키 대신 실제의 에러 메시지를 추가합니다. 다국어를 위해서는 메시지 키를 추가하고 src/main/resources에 있는 리소스 파일에 추가할 수 있습니다.

다음에는 Error 객체를 만드는 유틸리티를 추가합니다.

public class ErrorUtils {
  private ErrorUtils() {}
  /**
   * Creates and return an error object
   *
   * @param errMsgKey
   * @param errorCode
   * @param httpStatusCode
   * @param url
   * @return error
   */
  public static Error createError(final String errMsgKey, final String errorCode, 
          final Integer httpStatusCode) {
    Error error = new Error();
    error.setMessage(errMsgKey);
    error.setErrorCode(errorCode);
    error.setStatus(httpStatusCode);

    return error;
  }
}

끝으로 전역 예외 처리를 구현하는 클래스를 만듭니다.

@ControllerAdvice
public class RestApiErrorHandler {
  private static final Logger log = LoggerFactory.getLogger(RestApiErrorHandler.class);
  private final MessageSource messageSource;

  @Autowired
  public RestApiErrorHandler(MessageSource messageSource) {
    this.messageSource = messageSource;
  }

  @ExceptionHandler(Exception.class)
  public ResponseEntity<Error> handleException(HttpServletRequest request, Exception ex, Locale locale) {
    Error error = ErrorUtils.createError(ErrorCode.GENERIC_ERROR.getErrMsgKey(), 
          ErrorCode.GENERIC_ERROR.getErrCode(), 
          HttpStatus.INTERNAL_SERVER_ERROR.value())
      .setUrl(request.getRequestURL().toString()).setReqMethod(request.getMethod());
    return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
  }

  @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
  public ResponseEntity<Error> handleHttpMediaTypeNotSupportedException(HttpServletRequest request, HttpMediaTypeNotSupportedException ex, Locale locale) {
    Error error = ErrorUtils.createError(ErrorCode.HTTP_MEDIATYPE_NOT_SUPPORTED.getErrMsgKey(),
            ErrorCode.HTTP_MEDIATYPE_NOT_SUPPORTED.getErrCode(),
            HttpStatus.UNSUPPORTED_MEDIA_TYPE.value())
        .setUrl(request.getRequestURL().toString())
        .setReqMethod(request.getMethod());
    log.info("HttpMediaTypeNotSupportedException :: request.getMethod(): " + request.getMethod());
    return new ResponseEntity<>(error, HttpStatus. UNSUPPORTED_MEDIA_TYPE);
  }

클래스에 @ControllerAdvice로 주석 표시하여 이 클래스가 REST 컨트롤러의 모든 요청 및 응답 처리를 추적할 수 있도록 하고 @ExceptionHandler를 사용하여 예외를 처리할 수 있도록 합니다.

코드에서는 일반 내부 서버 오류 예외와 HttpMediaTypeNotSupportException이라는 두 가지 예외를 처리하고 있습니다. 처리 방법은 ErrorCode, HttpServletRequestHttpStatus를 사용하여 Error 객체를 채웁니다.
마지막에 적절한 HTTP 상태로 ResponseEntity 내부에 래핑된 오류를 반환합니다.

여기에서 사용자 정의 예외도 추가할 수 있습니다. 국제화 메시지를 지원하기 위해 Locale 인스턴스(메서드 매개변수)와 messageSource 클래스 멤버를 사용할 수도 있습니다.

테스팅

코드 준비가 되었으면 다음 명령으로 프로젝트 컴파일하고 빌드할 수 있습니다

gradlew clean build

빌드가 성공하면 앱을 실행할 수 있습니다.

java -jar build/libs/Chapter03-0.0.1-SNAPSHOT.jar

이제 curl 명령으로 테스트를 수행할 수 있습니다.

$ curl --request GET 'http://localhost:8080/api/v1/carts/1' --header 'Accept: application/xml'

이 명령은 /cart를 GET으로 요청하였고 Accept 헤더에서 XML 응답을 요구하므로 다음과 같은 응답을 볼 수 있습니다.

<Error>
    <errorCode>PACKT-0001</errorCode>
    <message>The system is unable to complete the request. Contact system support.</message>
    <status>500</status>
    <url>http://localhost:8080/api/v1/carts/1</url>
    <reqMethod>GET</reqMethod>
</Error>

Accept 헤더를 application/xml에서 application/json로 바꾸면 다음과 같은 JSON 응답을 볼 수 있습니다.

$ curl --request GET 'http://localhost:8080/api/v1/carts/1' --header 'Accept: application/json'
{
    "errorCode": "PACKT-0001",
    "message": "The system is unable to complete the request. Contact system support.",
    "status": 500,
    "url": "http://localhost:8080/api/v1/carts/1",
    "reqMethod": "GET"
}

이번에는 cart에 아이템을 추가할 수 있습니다.

$ curl --request POST 'http://localhost:8080/api/v1/carts/1/items' \
 --header 'Content-Type: application/json' \
 --header 'Accept: application/json' \
 --data-raw '{ \
     "id": "1",\
     "quantity": 1,\
     "unitPrice": 2.5\
 }'

구현에서는 단지 빈 컬렉션을 반환하기 때문에 응답으로 [](빈 배열)을 얻습니다. 요청과 함께 페이로드(item 객체)를 보내기 때문에 이 요청에 Content-Type 헤더를 제공해야 합니다.

Accept 헤더 값이 application/xml이면 <List/> 값을 반환합니다. Content-TypeAccept 헤더를 제거/변경하거나 잘못된 형식의 JSON 또는 XML을 사용하여 다른 오류 응답을 테스트할 수 있습니다.

이런 식으로 OpenAPI를 사용하여 API 설명을 생성한 다음 생성된 모델 및 API 인터페이스를 사용하여 API를 구현할 수 있습니다.

요약

RESTful 서비스를 작성하기 위해 디자인 우선 접근 방식을 선택했습니다. OAS를 사용하여 API 설명을 작성하는 방법과 Swagger Codegen 도구(Gradle 플러그인 사용)를 사용하여 모델 및 API 인터페이스를 생성하는 방법을 배웠습니다. 또한 모든 예외 처리를 중앙 집중화하기 위해 전역 예외 처리기를 구현했습니다. API 인터페이스가 있으면 비즈니스 로직에 대한 구현을 작성할 수 있습니다. 이제 RESTful API를 작성하기 위해 OAS 및 Swagger Codegen을 사용하는 방법을 알게 되었습니다. 또한 전역적으로 예외를 처리하는 방법도 배웠습니다.

다음에는 데이터베이스 지속성이 있는 비즈니스 로직으로 완전한 API 인터페이스를 구현합니다.

profile
코드코드

0개의 댓글