닷넷 5 환경의 Web API에 OpenAPI 적용을 위한 NSwag 또는 Swashbuckle 패키지 사용
이번 글은 다음의 문서에 대한 간략 정리입니다. ^^
Swagger/OpenAPI를 사용한 ASP.NET Core 웹 API 설명서
; https://learn.microsoft.com/ko-kr/aspnet/core/tutorials/web-api-help-pages-using-swagger
좋군요, 한글화도 잘 되어 있고. ^^
닷넷 세계에서의 Web API는 대표적으로
Route-to-code 방식과
Controller 방식으로 구현할 수 있습니다. 물론, 현실적인 기준으로 봤을 때 일반적으로는 Controller 방식으로 구현하게 됩니다.
이렇게 Web API를 구현하는데 "OpenAPI" 스펙을 적용하는 것은 어떤 의미일까요?
만약 여러분이 기존 SOAP이나 WCF를 접했다면 자연스럽게 WSDL(Web Service Definition Language)을 사용해봤을 텐데요, OpenAPI 스펙을 REST API 세계에서의 WSDL이라고 생각하시면 이해가 더 빠를 것입니다. 그리고
보통 OpenAPI 스펙의 다른 이름으로 "Swagger Specification"이라고 혼용해서 불리기도 하지만,
OpenAPI Specification (formerly Swagger Specification) is an API description format for REST APIs.
엄밀하게 "Swagger" 단독으로는
OpenAPI 스펙용 도구들의 집합을 의미합니다.
Swagger is a set of open-source tools built around the OpenAPI Specification that can help you design, build, document and consume REST APIs.
이에 대해서는 SmartBear 사의 블로그에도 자세하게 소개하고 있습니다.
What Is the Difference Between Swagger and OpenAPI?
; https://swagger.io/blog/api-strategy/difference-between-swagger-and-openapi/
OpenAPI = Specification
Swagger = Tools for implementing the specification
잠시 이력을 살펴보면, 원래 SmartBear 사는 Swagger 명세와 그에 대한 도구들을 소유하고 있었는데, 자신들의 명세를 OpenAPI 이니셔티브에 기부하면서 기존에 "Swagger Specification"라고 알려졌던 것이 "OpenAPI Specification"으로 개명된 것입니다. 개명 시점의 버전이 OpenAPI 2.0이기 때문에 엄밀히 따지면 OpenAPI 3.0 스펙을 Swagger 스펙이라고 부르는 것은 맞지 않게 됩니다. 그리고, SmartBear 사의 기존 도구들은 여전히 Swagger라는 제품명을 유지하면서 오늘날의 이러한 혼란이 오게 된 것입니다.
어쨌든 기존의 WSDL 명세를 구현하기 위해 asmx나 WCF 프레임워크가 나온 것처럼, 언어 중립적인 OpenAPI 스펙을 제공하기 위해 닷넷 세계에서 제공하는 도구들이 바로
NSwag 또는
Swashbuckle 같은 패키지입니다.
사실, ASMX/WCF 때와 마찬가지로 사용법도 매우 쉽습니다. 여러분이 (아주 특별한 목적을 제외하고는) asmx/wcf를 만들 때
WSDL 생성을 직접 제어한 적은 없을 것입니다. (WSDL이 뭔지도 모르고 사용하던 분들도 많았을 것입니다.) 마찬가지로 OpenAPI 스펙 명세 구현도, 여러분은 그냥 기존 Web API를 만들기만 하고 NSwag/Swashbuckle 같은 패키지를 사용하면 그것들이 알아서 사용자가 구현한 Web API를 설명하는 OpenAPI 명세 및 UI를 제공하는 식으로 동작합니다.
또한 이렇게 API에 대한 명세가 있다면 (
비주얼 스튜디오의 "웹/서비스 참조"가 그랬듯이) 클라이언트 측에서의 호출 코드 또한 자동 생성하는 것이 가능합니다.
실제로 간단하게 실습을 해볼까요?
ASP.NET Core Web API 유형의 프로젝트를 선택하면 다음과 같이 "Enable OpenAPI support" 옵션을 볼 수 있습니다.
이것을 켜고 프로젝트를 생성하면 자동으로 Swashbuckle 패키지를 참조해 미들웨어 설정까지 모두 완료한 예제 프로젝트를 생성해 줍니다. (참고로 "
Swashbuckle 및 ASP.NET Core 시작" 문서의 경우 "Enable OpenAPI support" 옵션을 켜지 않은 상태에서 Swashbuckle을 구성하는 방법을 설명합니다.)
using Microsoft.AspNetCore.Builder;
// ...[생략]...
namespace WebApplication1
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebApplication1", Version = "v1" });
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebApplication1 v1"));
}
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}
또한, 기본 생성된 Web API 코드는 Swagger로 인한 어떠한 영향도 없이 일반적인 코드 구성을 보여주는데요,
using Microsoft.AspNetCore.Mvc;
// ...[생략]...
namespace WebApplication1.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
var rng = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
}
}
}
이를 실행하면, OpenAPI 스펙에 따라 Request/Response에 대응하는 API 목록을 보여주는 UI(/swagger/index.html)가 나옵니다.
위의 화면에 왼쪽 상단을 보면 '/swagger/v1/swagger.json" 경로가 있는데요, 이것을 누르고 들어가면 OpenAPI 스펙에 따라 여러분이 작성한 Web API의 명세서를 자동으로 Swashbuckle 미들웨어에서 다음과 같이 생성해 줍니다.
{
"openapi": "3.0.1",
"info": {
"title": "WebApplication1",
"version": "v1"
},
"paths": {
"/WeatherForecast": {
"get": {
"tags": [
"WeatherForecast"
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WeatherForecast"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WeatherForecast"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WeatherForecast"
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"WeatherForecast": {
"type": "object",
"properties": {
"date": {
"type": "string",
"format": "date-time"
},
"temperatureC": {
"type": "integer",
"format": "int32"
},
"temperatureF": {
"type": "integer",
"format": "int32",
"readOnly": true
},
"summary": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
}
}
}
}
그러니까, XML로 만들어진 WSDL 명세의 JSON 버전이라고 보면 됩니다. 그럼, 이 스펙에 따라 Web API를 호출하는 클라이언트 측 코드도 (ASXM/WCF 시절처럼) 자동 생성을 해볼까요? ^^
간단하게 콘솔 프로젝트를 하나 만들고, 프로젝트 노드의 하위에 있는 "Dependencies"를 우클릭해 "Add Connected Service"를 선택합니다.
그럼 다음과 같은 화면이 뜨고, "Service References (OpenAPI, gRPC)"에 있는 "Add" 버튼을 눌러 "Add new OpenAPI service reference" 창을 띄운 후, 소스 코드 자동 생성을 하려는 대상의 OpenAPI 명세를 가리키는 파일 또는 URL을 선택하면 됩니다.
이 글에서는 예제로 작성한 "WebApplication1" 프로젝트를 실행해 두면 OpenAPI 명세를 "http://localhost:30753/swagger/v1/swagger.json" 경로에서 구할 수 있어 위의 화면에 "URL" 조건으로 입력을 했고, (당연히 WebApplication1 웹 프로젝트를 실행해 둔 상태에서) "Finish" 버튼을 누르면 콘솔 프로젝트에 Web API를 호출할 수 있는 클라이언트 코드가 자동 생성됩니다.
해당 코드는 /obj/swaggerClient.cs 파일로 찾을 수 있는데 대충 다음과 같은 식의 C# 코드를 볼 수 있습니다.
//----------------------
// <auto-generated>
// Generated using the NSwag toolchain v13.0.5.0 (NJsonSchema v10.0.22.0 (Newtonsoft.Json v11.0.0.0)) (http://NSwag.org)
// </auto-generated>
//----------------------
#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended."
#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword."
#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?'
#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ...
#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..."
namespace ConsoleApp1
{
[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.0.5.0 (NJsonSchema v10.0.22.0 (Newtonsoft.Json v11.0.0.0))")]
public partial class swaggerClient
{
private string _baseUrl = "";
private System.Net.Http.HttpClient _httpClient;
private System.Lazy<Newtonsoft.Json.JsonSerializerSettings> _settings;
public swaggerClient(string baseUrl, System.Net.Http.HttpClient httpClient)
{
BaseUrl = baseUrl;
_httpClient = httpClient;
_settings = new System.Lazy<Newtonsoft.Json.JsonSerializerSettings>(() =>
{
var settings = new Newtonsoft.Json.JsonSerializerSettings();
UpdateJsonSerializerSettings(settings);
return settings;
});
}
public string BaseUrl
{
get { return _baseUrl; }
set { _baseUrl = value; }
}
protected Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings { get { return _settings.Value; } }
partial void UpdateJsonSerializerSettings(Newtonsoft.Json.JsonSerializerSettings settings);
partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url);
partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder);
partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response);
/// <returns>Success</returns>
/// <exception cref="ApiException">A server side error occurred.</exception>
public System.Threading.Tasks.Task<System.Collections.Generic.ICollection<WeatherForecast>> WeatherForecastAsync()
{
return WeatherForecastAsync(System.Threading.CancellationToken.None);
}
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>Success</returns>
/// <exception cref="ApiException">A server side error occurred.</exception>
public async System.Threading.Tasks.Task<System.Collections.Generic.ICollection<WeatherForecast>> WeatherForecastAsync(System.Threading.CancellationToken cancellationToken)
{
var urlBuilder_ = new System.Text.StringBuilder();
urlBuilder_.Append(BaseUrl != null ? BaseUrl.TrimEnd('/') : "").Append("/WeatherForecast");
var client_ = _httpClient;
try
{
using (var request_ = new System.Net.Http.HttpRequestMessage())
{
request_.Method = new System.Net.Http.HttpMethod("GET");
request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("text/plain"));
PrepareRequest(client_, request_, urlBuilder_);
var url_ = urlBuilder_.ToString();
request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
PrepareRequest(client_, request_, url_);
var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
try
{
var headers_ = System.Linq.Enumerable.ToDictionary(response_.Headers, h_ => h_.Key, h_ => h_.Value);
if (response_.Content != null && response_.Content.Headers != null)
{
foreach (var item_ in response_.Content.Headers)
headers_[item_.Key] = item_.Value;
}
ProcessResponse(client_, response_);
var status_ = ((int)response_.StatusCode).ToString();
if (status_ == "200")
{
var objectResponse_ = await ReadObjectResponseAsync<System.Collections.Generic.ICollection<WeatherForecast>>(response_, headers_).ConfigureAwait(false);
return objectResponse_.Object;
}
else
if (status_ != "200" && status_ != "204")
{
var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
throw new ApiException("The HTTP status code of the response was not expected (" + (int)response_.StatusCode + ").", (int)response_.StatusCode, responseData_, headers_, null);
}
return default(System.Collections.Generic.ICollection<WeatherForecast>);
}
finally
{
if (response_ != null)
response_.Dispose();
}
}
}
finally
{
}
}
protected struct ObjectResponseResult<T>
{
public ObjectResponseResult(T responseObject, string responseText)
{
this.Object = responseObject;
this.Text = responseText;
}
public T Object { get; }
public string Text { get; }
}
public bool ReadResponseAsString { get; set; }
protected virtual async System.Threading.Tasks.Task<ObjectResponseResult<T>> ReadObjectResponseAsync<T>(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> headers)
{
if (response == null || response.Content == null)
{
return new ObjectResponseResult<T>(default(T), string.Empty);
}
if (ReadResponseAsString)
{
var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
try
{
var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject<T>(responseText, JsonSerializerSettings);
return new ObjectResponseResult<T>(typedBody, responseText);
}
catch (Newtonsoft.Json.JsonException exception)
{
var message = "Could not deserialize the response body string as " + typeof(T).FullName + ".";
throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception);
}
}
else
{
try
{
using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
using (var streamReader = new System.IO.StreamReader(responseStream))
using (var jsonTextReader = new Newtonsoft.Json.JsonTextReader(streamReader))
{
var serializer = Newtonsoft.Json.JsonSerializer.Create(JsonSerializerSettings);
var typedBody = serializer.Deserialize<T>(jsonTextReader);
return new ObjectResponseResult<T>(typedBody, string.Empty);
}
}
catch (Newtonsoft.Json.JsonException exception)
{
var message = "Could not deserialize the response body stream as " + typeof(T).FullName + ".";
throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception);
}
}
}
private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo)
{
if (value is System.Enum)
{
string name = System.Enum.GetName(value.GetType(), value);
if (name != null)
{
var field = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name);
if (field != null)
{
var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field, typeof(System.Runtime.Serialization.EnumMemberAttribute))
as System.Runtime.Serialization.EnumMemberAttribute;
if (attribute != null)
{
return attribute.Value != null ? attribute.Value : name;
}
}
}
}
else if (value is bool) {
return System.Convert.ToString(value, cultureInfo).ToLowerInvariant();
}
else if (value is byte[])
{
return System.Convert.ToBase64String((byte[]) value);
}
else if (value != null && value.GetType().IsArray)
{
var array = System.Linq.Enumerable.OfType<object>((System.Array) value);
return string.Join(",", System.Linq.Enumerable.Select(array, o => ConvertToString(o, cultureInfo)));
}
return System.Convert.ToString(value, cultureInfo);
}
}
[System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "10.0.22.0 (Newtonsoft.Json v11.0.0.0)")]
public partial class WeatherForecast
{
[Newtonsoft.Json.JsonProperty("date", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public System.DateTimeOffset Date { get; set; }
[Newtonsoft.Json.JsonProperty("temperatureC", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public int TemperatureC { get; set; }
[Newtonsoft.Json.JsonProperty("temperatureF", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public int TemperatureF { get; set; }
[Newtonsoft.Json.JsonProperty("summary", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public string Summary { get; set; }
}
[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.0.5.0 (NJsonSchema v10.0.22.0 (Newtonsoft.Json v11.0.0.0))")]
public partial class ApiException : System.Exception
{
public int StatusCode { get; private set; }
public string Response { get; private set; }
public System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> Headers { get; private set; }
public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> headers, System.Exception innerException)
: base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + response.Substring(0, response.Length >= 512 ? 512 : response.Length), innerException)
{
StatusCode = statusCode;
Response = response;
Headers = headers;
}
public override string ToString()
{
return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString());
}
}
[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.0.5.0 (NJsonSchema v10.0.22.0 (Newtonsoft.Json v11.0.0.0))")]
public partial class ApiException<TResult> : ApiException
{
public TResult Result { get; private set; }
public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> headers, TResult result, System.Exception innerException)
: base(message, statusCode, response, headers, innerException)
{
Result = result;
}
}
}
#pragma warning restore 1591
#pragma warning restore 1573
#pragma warning restore 472
#pragma warning restore 114
#pragma warning restore 108
기존의 귀찮은 REST API 호출 작업을, 이제는 strong-type 형식으로 다음과 같이 SDK에서 제공하는 라이브러리 사용하듯이 REST API를 사용하면 됩니다.
using System;
using System.Net.Http;
using System.Threading.Tasks;
namespace ConsoleApp1
{
class Program
{
static async Task Main(string[] args)
{
using HttpClient httpClient = new();
swaggerClient client = new swaggerClient("http://localhost:30753", httpClient);
var list = await client.WeatherForecastAsync();
foreach (var info in list)
{
Console.WriteLine(info.Summary);
}
}
}
}
/* 출력 결과
Chilly
Cool
Sweltering
Scorching
Mild
*/
위의 예제에서는 .NET 5의 Web API 프로젝트가 기본 지원하는 Swashbuckle 패키지를 사용한 예인데요, 만약 NSwag을 사용하려면 어떻게 해야 할까요?
이를 위해서는 (
콘솔 프로젝트로 바닥부터 Web API 호스팅 프로젝트를 만들거나) ASP.NET Core Web API 프로젝트 생성 당시 "Enable OpenAPI support" 옵션을 해제하고 만드는 것으로 시작할 수 있습니다. (물론, "Enable OpenAPI support" 옵션으로 생성한 프로젝트의 경우라면 Swashbuckle 패키지 참조를 제거하고 Startup.cs 파일에 구성된 Swashbuckle 관련 미들웨어 코드를 제거하는 식으로 처리해도 무방합니다.)
그다음, NSwag 패키지를 nuget으로부터 참조 추가하고,
Install-Package NSwag.AspNetCore
Startup.cs 파일에 미들웨어 관련 설정을 다음과 같이 구성합니다.
using Microsoft.AspNetCore.Builder;
// ...[생략]...
namespace WebApplication2
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSwaggerDocument();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseAuthorization();
app.UseOpenApi();
app.UseSwaggerUi3();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}
끝입니다. 이제 예제 코드를 실행하면 (Swashbuckle를 사용했을 때와는 다르게) Swagger 관련 UI 페이지가 먼저 뜨지 않고 그냥 /weatherforecast 예제를 구동한다는 차이가 있어, Swagger UI와 명세를 보려면 명시적으로 다음의 URL로 방문해야 합니다.
Swagger UI - http://localhost:<port>/swagger
Web API 명세서 - http://localhost:<port>/swagger/v1/swagger.json
당연히 OpenAPI 스펙은 동일하기 때문에 기존에 Swashbuckle 웹을 대상으로 생성해 두었던 swaggerClient를 그대로 사용할 수 있습니다.
이렇게 대표적으로 2가지의 선택 사항이 있긴 하지만, 현실적으로 Visual Studio의 "Enable OpenAPI support" 옵션에서 Swashbuckle 패키지를 기본으로 제공하기 때문에,
What's new in ASP.NET Core 5.0
- OpenAPI Specification on by default
; https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-5.0#openapi-specification-on-by-default
향후 NSwag은 그다지 사용하지 않을 가능성이 높아졌습니다.
이쯤에서 정리해 보면, Web API 프로젝트에 Swashbuckle 패키지를 사용하지 않을 이유가 없습니다. 일단 적용만 해두면, 클라이언트 측은 각기 언어에 맞게 자동 소스 코드 생성을 할 수 있고, 만약 그런 도구가 없다 해도 기존 방식 그대로 Web API 호출을 할 수 있기 때문입니다.
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]