본문 바로가기
Back-end/java spring

[Spring MVC] 기본 기능

by kkkdh 2023. 5. 20.
728x90

Logging

이제 System.out.println() method 같은 시스템 콘솔을 사용해서 필요한 정보를 출력하지 않고, 로거를 사용하자.

 

SLF4J

 

SLF4J

Simple Logging Facade for Java (SLF4J) The Simple Logging Facade for Java (SLF4J) serves as a simple facade or abstraction for various logging frameworks (e.g. java.util.logging, logback, log4j) allowing the end user to plug in the desired logging framewor

www.slf4j.org

Logback

 

Logback Home

Logback Project Logback is intended as a successor to the popular log4j project, picking up where log4j 1.x leaves off. Logback's architecture is quite generic so as to apply under different circumstances. At present time, logback is divided into three mod

logback.qos.ch

SLF4J는 수 많은 logging library들의 추상화를 제공하는 interface이다. Logback은 로그 라이브러리 구현체 중 하나로 스프링 부트가 대표적으로 제공하는 logging library.

 

현재 참여중인 프로젝트에서도 SLF4J + Logback를 이용해서 log를 남기고 있다.

코드 예시

  • 로그 레벨을 설정할 수 있다.
    • TRACE > DEBUG > INFO > WARN > ERROR
    • 개발 서버는 보통 DEBUG level로 로그 출력
    • 운영(상용) 서버는 INFO level로 로그 출력
#전체 로그 레벨 설정 방식 (default: INFO)
logging.level.root=info
#hello.springmvc package와 그 하위 log level 설정
logging.level.hello.springmvc=debug

올바른 로그 사용법

  • log.debug("data="+data)
    • 위와 같이 사용하는 것도 물론 가능하고, 정상적으로 로그가 출력된다.
    • 하지만, 자바의 특성과 맞물리는 문제로 인해 위 방식은 절대 사용하면 안된다.
    • Java에 의해 "data="+data, 즉 string concatenation 연산이 실행되고, 이 결과를 메모리 일부 공간에 저장하게 된다.
    • 물론 이 과정에서 CPU를 사용한다.
    • 따라서 log level에 따라서 출력하지도 않을 logging message를 위해 불필요한 자원을 소모하게 된다.
  • log.debug("data={}", data)
    • 다음과 같이 설정하면 DEBUG level이 출력되지 않는 로그 레벨에서 아무런 일도 발생하지 않는다.
    • 따라서 불필요한 자원 소모가 발생하지 않는다.

로그 사용시 장점

  • thread 정보, class 이름과 같은 부가 정보를 같이 출력해줌
  • log level에 따라 보여지는 log를 다르게 설정할 수 있다.
  • system out console 뿐만 아니라 설정에 따라서 다양한 형태로 로그를 남길 수 있다. (특히, 파일로 남길때는 일별, 특정 용량에 따라 로그 분할도 가능하다고 함..)
  • 성능 또한 System.out 보다 좋다고 함 (내부 버퍼링, 멀티 쓰레드 기능등을 제공하기 때문)
  • 이러한 장점으로 인해 실무에서는 꼭 logger를 사용하자!
@SLF4J 어노테이션을 클래스 레벨에 작성하면, Logger logger = LoggerFactory.getLogger(getClass()); 작업을 자동으로 수행해준다.

Request Mapping

MappingController

@RestController

  • Controller annotation은 반환 값이 String이면, 논리적 뷰 이름으로 판단. 그래서 뷰를 찾고, 뷰를 rendering
  • RestController annotation은 반환 값을 그대로 HTTP message body에 입력한다.

@RequestMapping("/hello-basic")

  • /hello-basic URL 호출이 오면, 해당 어노테이션을 붙인 메서드가 실행되도록 mapping
  • 대부분의 속성을 배열[]로 제공하므로, 다중 설정이 가능 (여러 개의 url pattern mapping 가능함을 의미)
참고: 스프링 부트 3.0 이전에는 "/hello-basic", "/hello-basic/" 두 가지 url pattern을 "/hello-basic"이라고만 mapping 해도 같은 url로 인식하고 처리했다.
스프링 부트 3.0 이후부터는 "/hello-basic", "/hello-basic/" 서로 다른 URL 요청을 사용해야 한다. 기존에는 /(slash)를 제거해주는 방식으로 동작했으나, 스프링 부트 3.0 이후 버전부터는 그렇지 않다고 한다.

HTTP method mapping

@RequestMapping annotation을 사용하는 경우 HTTP method와 무관하게, url pattern 일치 여부만을 판단해서 method가 호출되었다. (method 속성을 따로 지정하지 않는 경우)

GetMapping example

HTTP method에 따라서 GetMapping, PostMapping, PutMapping, DeleteMapping, PatchMapping 등 다양한 어노테이션을 사용해 원하는 HTTP method에 맞는 request mapping이 가능하다.

 

PathVariable(경로 변수) 사용

path variable mapping 방식

위와 같이 @PathVariable annotation을 이용해서 url pattern 내에 포함되어 있는 경로 변수를 mapping 할 수 있다.

  • RequestMapping은 URL 경로를 template화 할 수 있고, @PathVariable annotation을 이용해 매칭되는 부분을 편리하게 조회할 수 있다.
  • path variable과 이름을 갖게 인수의 변수명을 mapping 한 경우에는 value를 생략해도 된다.

특정 파라미터, 헤더, 미디어 타입 조건 매핑

mode=debug라는 HTTP header key-value 쌍이 입력되어야 매핑된다.
application/json 타입의 request data type만 mapping 하겠음을 의미 (consume, 소비)

Content-Type header를 기반으로 미디어 타입으로 mapping. (요청 데이터의 content-type을 기반으로 mapping)

맞지 않는 경우 HTTP 415 status code (unsupported media type)을 반환한다.

제공할 수 있는 data type을 기반으로 mapping 하는 작업을 수행한다. (produce, 생산)

Accept header를 기반으로 미디어 타입으로 mapping. (response 할 수 있는 데이터의 content-type을 기반으로 mapping)

맞지 않는 경우 HTTP 406 status code (not acceptable)을 반환한다.


HTTP 요청 - 기본, 헤더 조회

이번에 작성한 예시 코드는 다음과 같다.

request header mapping 테스트를 위한 예시 코드

  • HttpServletRequest, HttpServletResponse 등 서블릿을 사용할 때, 인수로 받아들이던 객체를 모두 가져다 사용할 수 있다.
  • HttpMethod: HTTP method를 조회 org.springframework.http.HttpMethod
  • Locale: Locale 정보를 조회 

위치 정보

  • @RequestHeader MultiValueMap<T, V> mm: 모든 header를 MultiValueMap 형식으로 조회
    • 동일한 key에 대해서 여러 개의 value를 저장할 수 있는 자료구조
  • @RequestHeader("host") String host: 특정 HTTP header를 조회하기 위한 방식
    • required, defaultValue 등의 속성을 부여할 수 있다.
  • @CookieValue(value = "myCookie", required = false) String cookie
    • 특정 쿠키를 조회한다.
    • required, defaultValue 등의 속성을 부여할 수 있다.

 

 

Method Arguments :: Spring Framework

JDK 8’s java.util.Optional is supported as a method argument in combination with annotations that have a required attribute (for example, @RequestParam, @RequestHeader, and others) and is equivalent to required=false.

docs.spring.io

 

 

Return Values :: Spring Framework

A single value type, e.g. Mono, is comparable to returning DeferredResult. A multi-value type, e.g. Flux, may be treated as a stream depending on the requested media type, e.g. "text/event-stream", "application/json+stream", or otherwise is collected to a

docs.spring.io

HTTP request, response에 대해서 controller의 request mapping method에서 사용할 수 있는 인수에 대한 정리 문서


@RequestParam 사용 방식

가장 처음으로 위와 같이 version 1 → version 2로의 갱신이 가능하다.

 

기존에 request, response 객체를 이용해서 parameter를 조회하던 방식을 @RequestParam annotation을 method parameter에 작성함으로써 대체 가능하다.

또한 @RequestParam annotation에 작성한 query parameter key 값은 매핑하고자 하는 인수와 이름이 같은 경우 생략이 가능하다.

심지어 단순 타입(String, int, Integer와 같은 타입)의 경우에는 @RequestParam 어노테이션 자체를 생략하는 방식도 가능하다고 한다.

 

다만, 어노테이션을 완전히 생략함으로써 모두가 알아보기에는 쉽지 않은 코드가 된다는 것에 주의하자

required 옵션을 부여해서 필수 여부를 부여할 수 있다. 기본 값은 true로 false로 설정하는 경우 해당 query parameter가 없어도 동작이 가능하다.

 

required = false인 경우에는 method parameter 변수에 null이 주입되기 때문에, null을 넣어줄 수 있는 형태로 변수 형식을 변경해야 함에 주의하자.

defaultValue 옵션으로 기본값을 부여할 수 있다. 이런 경우 required 옵션은 무의미해지기 때문에, 생략했다.

Map을 이용해서 query parameter를 한 번에 매핑할 수도 있다.

 

MultiValueMap을 이용해서 동일한 key에 대해 여러 개의 value를 갖는 속성들 또한 매핑이 가능하다.

 

parameter의 값이 1개가 확실한 경우에는 Map을 사용해도 좋지만, 웬만하면 MultiValueMap 자료구조의 사용을 권장한다고 한다.


@ModelAttribute

실제 개발을 하는 상황에서는 요청 파라미터를 받아 필요한 객체를 만들고, 그 객체에 값을 넣어주어야 한다.

@RequestParam String username;
@Requestparam int age;

HelloData data = new HelloData();
data.setUsername(username);
data.setAge(age);

이런 반복 작업을 @ModelAttribute annotation을 활용해서 대체할 수 있다.

 

먼저 request parameter를 binding 할 객체를 다음과 같이 설계한다.

위에서 사용한 @Data annotation은 lombok에서 제공하는 annotation으로 Getter, Setter 생성, ToString, EqualsAndHashCode, RequiredArgsConstructor 어노테이션이 하던 작업을 자동으로 수행해준다.

 

HelloData 객체가 생성되어 값이 자동으로 바인딩되어 값이 들어가는 결과를 확인할 수 있다.

 

심지어 v2에서 볼 수 있듯이 annotation 생략도 가능하다.

  • HelloData 객체 생성
  • 요청 파라미터의 이름으로 HelloData 객체의 property를 찾는다.
  • 해당 property의 setter를 호출해서 값을 binding
  • parameter name: username → setUsername() method 호출해서 값을 세팅

프로퍼티

객체에 getUsername(), setUsername() method가 있으면, 이 객체는 username property를 갖고 있다.

username property의 값을 변경하면 setUsername method가 호출되고, 조회하면 getUsername method가 호출된다.

 

@Data annotation에 의해 getter, setter가 모두 생성되었기 때문에 가능한 작업

 

바인딩 오류

age=abc처럼 숫자가 들어가야하는 상황에 문자를 넣으면 BindException이 발생. 이런 binding 오류를 처리하는 방법은 이후 검증 부분에서 다룬다고 한다.

 

스프링은 어노테이션 생략시 다음 규칙에 따라 지정

  • String, int, Integer와 같은 단순 타입 = @RequestParam
  • 나머지 = @ModelAttribute (argument resolver로 지정한 타입은 제외, HttpServletRequest(Response), InputStream 등..)

HTTP 요청 메시지 - 단순 텍스트

HTTP message body에 데이터를 직접 담아서 request를 보내는 방식에서 request message body를 binding 하는 방식을 알아보자.

 

query parameter 형식으로 넘어오는 데이터와는 다르게 HTTP message body를 통해 데이터가 직접 넘어오는 경우 @RequestParam, @ModelAttribute annotation을 사용한 binding이 불가능하다.

 

HTTP message body에 데이터를 직접 담아서 전달하는 방식

  • HTTP API에서 주로 사용하는 방식, JSON, XML, TEXT
  • 데이터 형식은 주로 JSON 사용
  • POST, PUT, PATCH

servlet에서도 사용했던 방식

위 코드를 다음과 같이 개선할 수 있다.

  • Spring MVC가 InputStream, Writer를 인자로 받아들일 수 있도록 지원
  • 따라서 HttpServletRequest(Response) 객체를 직접 받아올 필요가 없어짐

InputStream은 HTTP request message body의 내용을 직접 조회하기 위해 사용할 수 있고, OutputStream은 response message body에 직접 결과 출력 가능

하지만, StreamUtils를 이용한 inputStream에서 데이터를 읽어오는 방식 또한 귀찮아서 HttpEntity라는 것을 제공한다.

  • HttpEntity: HTTP header, body 정보를 편리하게 조회 가능
    • 메시지 바디 정보 직접 조회
    • request parameter(query parameter or form data)를 조회하는 기능과 관계 없다. (@RequestParam, @ModelAttribute X)
  • HttpEntity는 응답에도 사용이 가능
    • 메시지 바디 정보 직접 반환 가능
    • 헤더 정보 포함 가능
    • view 조회는 X
  • RequestEntity
    • HttpMethod, url 정보 추가, request에 사용
  • ResponseEntity
    • HTTP 상태 코드 설정 가능, response에 사용
    • return new ResponseEntity<String>("Hello World", responseHeaders, HttpStatus.CREATED)

간단하게 HTTP message를 spec화 해놓은 것을 그대로 가져올 수 있는 객체라고 볼 수 있을 것 같다.

실무에서 가장 많이 사용하는 방식

@RequestBody를 사용하면, HTTP message body 정보를 편리하게 사용 가능하다.

 

header 정보가 필요하다면, HttpEntity 또는 @RequestHeader annotation을 사용하면 된다고 한다. (당연하게도 @RequestParam, @ModelAttribtue 와는 전혀 관계 없음)

 

@ResponseBody를 사용하면, 응답 결과를 HTTP message body에 직접 담아서 전달할 수 있다. (view 사용 X)


HTTP 요청 메시지 - JSON type

가장 초기의 귀찮은 방식이다.

 

앞서 정리한 바와 같이 @ResponseBody annotation을 이용해 @Controller annotation을 class level에서 사용했어도 message body에 실릴 데이터를 직접 반환 가능 (view 사용 X)

@RequestBody annotation을 활용해서 request message body를 직접 가져올 수 있고, ObjectMapper를 이용해 json data 형식을 가져와서 직접 parsing 하는 형태이다.

ObjectMapper를 사용한 parsing까지 대신 해줄 수 없을까?? → 가능하다!!

@RequestBody를 객체에 직접 기입하면, HTTP message converter가 HTTP message body의 내용을 우리가 원하는 문자나 객체로 변환해주기 때문에, 위와 같은 방식이 가능한 것이다.

 

물론, HttpEntity를 사용한 방식 또한 가능하고, 마찬가지로 HTTP message converter가 개입해서 우리가 원하는대로 parsing 해서 객체나 문자 값을 변수에 binding 해준다고 한다.

 

@RequestBody annotation은 @RequestParam, @ModelAttribute와 달리 생략이 불가능

  • 앞서 정리했듯 단순 타입은 @RequestParam이 적용
  • 그 외의 타입은 @ModelAttribute가 적용됨을 확인했다.
  • 따라서 annotation 생략 시에 json data parsing에 @ModelAttribute가 적용되어, 동작하지 않는 결과를 확인하게 된다.
주의!: content-type이 application/json으로 request message가 전송되어야 HTTP message converter에 의한 올바른 parsing이 가능함에 주의하자.

다음의 개선 방안은 @ResponseBody에 의해 response message body 또한 json type으로 객체를 변환해주는 결과를 확인할 수 있는 예시이다.

postman으로 테스트 성공!

  • @RequestBody 요청
    • JSON 요청 → HTTP message converter → 객체
  • @ResponseBody 응답 
    • 객체 → HTTP message converter JSON 응답

HTTP 응답 - HTTP API, message body에 직접 입력

HTTP API 혹은 REST API를 제공하는 경우 html page가 아닌 data를 전달하는 개발을 하게 된다.

 

이에 따라 HTTP message body에 JSON 형식과 같은 유형으로 데이터를 실어 보내게 된다. 데이터를 보내는 방식을 정리해 보자.

먼저 그냥 문자열을 response message body에 실어 보내는 방식은 위 코드와 같이 구현 가능하다.

 

class level에 @Controller annotation을 사용했기 때문에, view가 아닌 데이터를 response로 보내고 싶은 경우에는 @ResponseBody annotation을 method level에서 기입해줘야 한다.

 

혹은 @RestController annotation을 사용해야 한다. (@RestController = @Controller + @ResponseBody)

 

ResponseEntity 객체는 위에서 설명했던 바와 같이 HttpEntity 클래스를 상속받아 구현되었고, response message body에 data를 쉽게 실어보낼 수 있도록 하는 기능 등을 제공한다.

 

이번에는 JSON type으로 data를 전송하는 방식을 알아보자.

JSON type data 전달

ResponseEntity를 그대로 전달해서 HTTP message converter를 거쳐 json type으로 data를 반환할 수 있다. 

 

또한, 객체를 그대로 반환해서 JSON type으로 data 전달 또한 가능한데, 이런 경우 HTTP status code를 설정할 수 없다는 단점이 있다.

 

이를 보완하기 위해 spring mvc에서는 @ResponseStatus annotation을 제공하고, 해당 어노테이션을 이용해 HTTP 상태 코드와 상태 메시지 설정이 가능

 

다만, @ResponseStatus annotation을 사용해서 상태 코드를 관리하는 방법은 컴파일 타임에 결정되는 constant를 이용해 annotation의 attribute 값을 setting 해줘야 한다.

 

이에 따라 동적으로 런타임 환경에서 다른 status code setting이 불가능하다는 점에 주의하자.


HTTP message converter

View를 전송하는 방식이 아닌, HTTP API(or REST API)와 같은 방식으로 구현해서 JSON type data를 HTTP message body에서 읽어오거나 직접 JSON type으로 작성하려 하는 경우 HTTP message converter를 사용하면 편리하다고 한다.

 

@ResponseBody annotation 사용에 따른 동작 방식

  • controller의 method에서 HTTP response message body에 문자 혹은 객체를 직접 반환할 수 있도록 돕는다.
  • viewResolver 대신 HttpMessageConverter가 동작
  • 기본 문자 처리는 StringHttpMessageConverter가 동작
  • 기본 객체 처리는 MapingJackson2HttpMessageConverter가 동작 (JSON 처리)

 

다음 두 개의 header 정보를 이용해서 client, server간 교환할 데이터 타입에 대해 확인이 가능하다.

  • Content-Type header는 request message에서 전송하는 data type
  • Accept header는 client가 전달받고 싶은 data type을 의미

따라서 위 헤더의 정보를 이용해 알맞은 HttpMessageConverter가 동작해 JSON ↔ 객체 매핑이 가능한 구조이다.

 

스프링 MVC가 HttpMessageConverter를 적용하는 경우

  • HTTP 요청: @RequestBody, HttpEntity(RequestEntity) 파라미터 사용
  • HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity) 반환

HttpMessageConvter interface code

HttpMessageConverter는 interface

HttpMessageConveter는 interface로 구현되어 있고, 원하는 형태에 따라 알맞은 interface의 구현체를 사용하는 방식으로 동작한다.

 

HttpMessageConverter가 보유한 추상 메서드

  • canRead, canWrite
  • read, write

canRead, canWriter는 HttpMessageConverter 구현체가 해당 메시지 type을 읽거나 쓸 수 있는지 여부를 판별하는 메서드이다.

 

read, writer는 읽거나 쓸 수 있는 상황에서 실제 읽거나 쓰는 작업을 위해 사용하는 메서드

 

SpringBoot의 기본 message converter

0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter 
2 = MappingJackson2HttpMessageConverter
(일부 생략)

스프링 부트는 다양한 message converter를 제공

 

대상 클래스의 타입 + 미디어 타입(Content-type) (+ 그 이상의 정보) 둘을 체크해서 사용 여부를 결정(canRead, canWrite 이용)

 

사용 여부를 충족하지 못한다면, 우선 순위에 따라 다음 message converter의 사용 여부를 확인

 

주요 HttpMessageConverter 구현체

  • ByteArrayHttpMessageConverter: byte[] 데이터를 처리
    • 클래스 타입: byte[], 미디어 타입: */*
    • 요청 예) @RequestBody byte[] data
    • 응답 예) @ResponseBody return byte[] → 쓰기 미디어 타입 application/octet-stream
  • StringHttpMessageConverter: String 문자로 데이터를 처리
    • 클래스 타입: String , 미디어타입: */*
    • 요청 예) @RequestBody String data
    • 응답 예) @ResponseBody return "ok" → 쓰기 미디어타입 text/plain
  • MappingJackson2HttpMessageConverter: application/json 처리
    • 클래스 타입: 객체 또는 HashMap , 미디어타입 application/json 관련
    • 요청 예) @RequestBody HelloData data
    • 응답 예) @ResponseBody return helloData → 쓰기 미디어타입 application/json 관련

 

HTTP 요청 데이터를 읽거나 응답 데이터를 생성하는 과정에서 HttpMessageConverter 동작 과정

  1. @RequestBody(or @ResponesBody), HttpEntity(RequestEntity, ResponseEntity)를 사용한다.
  2. HttpMessageConverter를 우선 순위에 따라 탐색하며 canRead(or canWriter)를 이용해 호환성 검사
  3. 대상 클래스 타입 지원 여부 + Content-Type(Accept) header를 통한 media type 지원 여부 검사
  4. canRead(or canWriter) 통과하면, read(or write) method 호출해서 객체를 생성(or data 생성)해서 반환

여기서 대상 클래스 타입을 먼저 지원하는지 확인하고 media type을 확인하는 이유는, 1순위 2순위 HttpMessageConverter가 미디어 타입을 모두 지원하기 때문이라고 생각한다.


요청 매핑 핸들러 어뎁터의 구조

HTTP message converter가 Spring MVC의 구조에서 사용되는 부분은 바로 RequestMappingHandlerAdapter를 살펴봐야 한다. (annotation driven controller를 처리한는 handler adapter)

(출처: 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술)

Dispatcher Servlet이 RequestMappingHandlerAdapter를 선택해 handler(controller)를 실행하는 과정에서 http message converter에 의해 다양한 type의 request body data를 controller의 method level에서 mapping해 사용할 수 있게 되는 것이다.

 

위 구조도를 보면 알 수 있듯이 ArgumentResolver, ReturnValueHandler에 의해서 http message converter가 사용되어 handler에서 원하는 양식으로 parameter를 전달 받고, 원하는대로 반환할 수 있게 된다.

 

  • (HandlerMethod)ArgumentResolver
    • HttpServletRequest, Model부터 @RequestParam, @ModelAttribute같은 어노테이션, @RequestBody, HttpEntity에 이르기까지 다양한 형태로 요청 메시지를 원하는 타입으로 바인딩이 가능하게 만드는 객체
    • RequestMappingHandlerAdapter(annotation 기반 handler 처리 담당) handler adapter가 ArgumentResolver를 호출해서 controller(handler)가 원하는 형태의 파라미터 값(또는 객체)를 생성한다.
    • 스프링은 30가지가 넘는 ArgumentResolver를 기본으로 제공
    • supportsParameter() method를 호출해서 해당 파라미터를 지원하는지 체크
    • resolveArgument() method를 호출해서 실제 객체를 생성
  • (HandlerMethod)ReturnValueHandler
    • response message를 처리하는 과정인 것을 제외하고는 ArgumentResolver와 유사한 동작 방식을 갖는다.
    • controller에서 String value만 반환해도 논리적 이름과 일치하는 view를 연결하는 작업도 ReturnValueHandler 덕분이다.
    • ReturnValueHandler 또한 http message converter를 필요한 경우 호출해서 사용한다.

ArgumentResolver, ReturnValueHandler는 http message converter를 이용해 data를 객체로 만드는 작업을 수행한다. 즉, 변환에 알맞은 http message converter를 찾는 작업을 수행한다.

 

ArgumentResolver, ReturnValueHandler 그리고 HttpMessageConverter는 Spring MVC에서 interface로 제공하고 있으며 이에 따라 당연히 원한다면 custom implements를 구현해서 사용할 수 있다. (물론 대부분의 구현체가 이미 다 만들어져 있다고 한다.)

@Bean
public WebMvcConfigurer webMvcConfigurer() {
     return new WebMvcConfigurer() {
         @Override
         public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
             //...
         }
         @Override
         public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
             //...
             }
         };
}

이미 구현된 기능을 확장해서 사용하기 위해서는 WebMvcConfigurer를 상속 받아 스프링 빈으로 등록하면 된다고 하니 나중에 필요할 때, 이 글을 읽는 다면 WebMvcConfigurer를 찾아보도록 하자.

 

728x90

댓글