๐Ÿ“• ์˜ค๋Š˜ ๋ฐฐ์šด ๋‚ด์šฉ!

  • API ๋ฌธ์„œํ™”
  • Swagger vs Spring Rest Docs
  • Spring Rest Docs
  • Asciidoc

โœ๏ธ API ๋ฌธ์„œํ™” (Documentation)

  • ํด๋ผ์ด์–ธํŠธ๊ฐ€ REST API ๋ฐฑ์—”๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์š”์ฒญ์„ ์ „์†กํ•˜๊ธฐ ์œ„ํ•ด์„œ ์•Œ์•„์•ผ ๋˜๋Š” ์š”์ฒญ ์ •๋ณด๋ฅผ ๋ฌธ์„œ๋กœ ์ž˜ ์ •๋ฆฌํ•˜๋Š” ๊ฒƒ
    ( ํด๋ผ์ด์–ธํŠธ ์ชฝ์—์„œ ์ด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‚ฌ์šฉ์„ ์œ„ํ•ด ์š”์ฒญ URL / request body / query parameter ๋“ฑ์ด ํ•„์š” )

  • ๊ฐœ๋ฐœ์ž๊ฐ€ ์ง์ ‘ ์ˆ˜๊ธฐ๋กœ ์ž‘์„ฑํ•  ์ˆ˜๋„ ์žˆ๊ณ , ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋นŒ๋“œ๋ฅผ ํ†ตํ•ด API ๋ฌธ์„œ๋ฅผ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜๋„ ์žˆ์Œ
    ( But, ์ˆ˜๊ธฐ๋กœ ์ž‘์„ฑํ•˜๋Š” ๊ฒƒ์€ ์•„์ฃผ ๋น„ํšจ์œจ์  โžœ API ๋ฌธ์„œ ์ž๋™ํ™” ์‚ฌ์šฉ)

โœ”๏ธ REST API

โœ” API ๋ฌธ์„œ ์ƒ์„ฑ์˜ ์ž๋™ํ™”๊ฐ€ ํ•„์š”ํ•œ ์ด์œ 

โžœ ์ˆ˜๊ธฐ๋กœ ์ž‘์„ฑํ–ˆ์„ ๋•Œ ์‹œ๊ฐ„๋„ ๋งŽ์ด ๋“ค๊ณ  ์—๋Ÿฌ ๋ฐœ์ƒ ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์•„ ๋น„ํšจ์œจ์ ์ด๊ธฐ ๋•Œ๋ฌธ์— ์ž๋™ํ™” ์‚ฌ์šฉ


โœ๏ธ Swagger vs Spring Rest Docs

โœ” Swagger์˜ API ๋ฌธ์„œํ™” ๋ฐฉ์‹

( Swagger๋ผ๋Š” API ๋ฌธ์„œ ์ž๋™ํ™” ์˜คํ”ˆ ์†Œ์Šค ์‚ฌ์šฉํ•œ ๋ฐฉ์‹ )

  • ์• ํ„ฐ๋„ค์ด์…˜ ๊ธฐ๋ฐ˜์˜ API ๋ฌธ์„œํ™” ๋ฐฉ์‹
    ( ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ์— ๋ฌธ์„œํ™”๋ฅผ ์œ„ํ•œ ๋งŽ์€ ์• ๋„ˆํ…Œ์ด์…˜๋“ค์ด ํฌํ•จ๋จ )

  • ๊ฐ€๋…์„ฑ ๋ฐ ์œ ์ง€ ๋ณด์ˆ˜์„ฑ์ด ๋–จ์–ด์ง

  • API ๋ฌธ์„œ์™€ API ์ฝ”๋“œ ๊ฐ„์˜ ์ •๋ณด ๋ถˆ์ผ์น˜ ๋ฌธ์ œ ๋ฐœ์ƒ ๊ฐ€๋Šฅ
    ( ์• ๋„ˆํ…Œ์ด์…˜ ๋‚ด์— API ์ŠคํŽ™ ์ •๋ณด๋ฅผ ๋ฌธ์ž์—ด๋กœ ์ž…๋ ฅํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ๊ธฐ ๋•Œ๋ฌธ )

  • API ํˆด๋กœ์จ์˜ ๊ธฐ๋Šฅ ํ™œ์šฉ ๊ฐ€๋Šฅ
    ( API ๋ฌธ์„œ ๋‚ด์—์„œ Execute ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ Controller์— ์š”์ฒญ ๊ฐ€๋Šฅ )

[์ฐธ๊ณ ] https://swagger.io/docs/specification/about/

โœ” Spring Rest Docs์˜ API ๋ฌธ์„œํ™” ๋ฐฉ์‹

  • ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ๊ธฐ๋ฐ˜์˜ API ๋ฌธ์„œํ™” ๋ฐฉ์‹

  • ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ์— ๋ฌธ์„œํ™”๋ฅผ ์œ„ํ•œ ์ •๋ณด๋“ค์ด ํฌํ•จ๋˜์ง€ ์•Š์Œ

  • ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค์˜ ์‹คํ–‰์ด ๊ผญ passed์—ฌ์•ผ API ๋ฌธ์„œ๊ฐ€ ์ƒ์„ฑ๋จ
    โžœ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์ •์˜๋˜์–ด ์žˆ๋Š” API ์ŠคํŽ™ ์ •๋ณด์™€ API ๋ฌธ์„œ ์ •๋ณด์˜ ๋ถˆ์ผ์น˜๋กœ ์ธํ•ด ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋ฅผ ๋ฐฉ์ง€ ๊ฐ€๋Šฅ

  • ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋ฅผ ๋ฐ˜๋“œ์‹œ ์ž‘์„ฑํ•ด์•ผํ•จ

  • API ํˆด๋กœ์จ์˜ ๊ธฐ๋Šฅ์€ ์ œ๊ณตํ•˜์ง€ ์•Š์Œ


โœ๏ธ Spring Rest Docs

  • REST API ๋ฌธ์„œ๋ฅผ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•ด ์ฃผ๋Š” Spring ํ•˜์œ„ ํ”„๋กœ์ ํŠธ

  • Controller์˜ ์Šฌ๋ผ์ด์Šค ํ…Œ์ŠคํŠธ๋ฅผ ํ†ตํ•ด ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผ ๋˜์–ด์•ผ์ง€๋งŒ API ๋ฌธ์„œ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ๋งŒ๋“ค์–ด ์ง

[์ฐธ๊ณ ]
https://intellij-asciidoc-plugin.ahus1.de/docs/users-guide/features/advanced/spring-rest-docs.html
https://docs.spring.io/spring-restdocs/docs/current/reference/html5/#introduction

Spring Rest Docs์˜ API ๋ฌธ์„œ ์ƒ์„ฑ ํ๋ฆ„

  1. ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ

    • ์Šฌ๋ผ์ด์Šค ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ
    • API ์ŠคํŽ™ ์ •๋ณด ์ฝ”๋“œ ์ž‘์„ฑ
  2. ํ…Œ์ŠคํŠธ ํƒœ์Šคํฌ (test task) ์‹คํ–‰

    • ์ž‘์„ฑ๋œ ์Šฌ๋ผ์ด์Šค ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์‹คํ–‰
      โžœ ์ผ๋ฐ˜์ ์œผ๋กœ Gradle์˜ ๋นŒ๋“œ ํƒœ์Šคํฌ(task)์ค‘ ํ•˜๋‚˜์ธ test task๋ฅผ ์‹คํ–‰ ์‹œ์ผœ์„œ API ๋ฌธ์„œ ์Šค๋‹ˆํ•(snippet)์„ ์ผ๊ด„ ์ƒ์„ฑ

    • ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ๊ฐ€ passed๋ฉด ๋‹ค์Œ ์ž‘์—… / failed๋ฉด ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ˆ˜์ • ํ›„ ๋‹ค์‹œ ์‹คํ–‰

  3. API ๋ฌธ์„œ ์Šค๋‹ˆํ•(.adoc ํŒŒ์ผ) ์ƒ์„ฑ

    • ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ๊ฐ€ passed๋ฉด ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์˜ API ์ŠคํŽ™ ์ •๋ณด ์ฝ”๋“œ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ API ๋ฌธ์„œ ์Šค๋‹ˆํ•์ด .adoc ํ™•์žฅ์ž๋ฅผ ๊ฐ€์ง„ ํŒŒ์ผ๋กœ ์ƒ์„ฑ๋จ
      โ €

      โœ”๏ธ ์Šค๋‹ˆํ• (snippet)

      • ๋ฌธ์„œ์˜ ์ผ๋ถ€ ์กฐ๊ฐ์„ ์˜๋ฏธ
      • ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ํ•˜๋‚˜ ๋‹น ํ•˜๋‚˜์˜ ์Šค๋‹ˆํ•์ด ์ƒ์„ฑ
      • ์—ฌ๋Ÿฌ๊ฐœ์˜ ์Šค๋‹ˆํ•์„ ๋ชจ์•„์„œ ํ•˜๋‚˜์˜ API ๋ฌธ์„œ ์ƒ์„ฑ ๊ฐ€๋Šฅ
  4. API ๋ฌธ์„œ ์ƒ์„ฑ

    • ์ƒ์„ฑ๋œ API ๋ฌธ์„œ ์Šค๋‹ˆํ•์„ ๋ชจ์•„ ํ•˜๋‚˜์˜ API ๋ฌธ์„œ๋กœ ์ƒ์„ฑ
  5. API ๋ฌธ์„œ๋ฅผ HTML๋กœ ๋ณ€ํ™˜

    • ์ƒ์„ฑ๋œ API ๋ฌธ์„œ๋ฅผ HTML ํŒŒ์ผ๋กœ ๋ณ€ํ™˜

    • HTML๋กœ ๋ณ€ํ™˜๋œ ๋ฌธ์„œ๋Š” HTML ํŒŒ์ผ ์ž์ฒด๋ฅผ ๊ณต์œ ํ•  ์ˆ˜๋„ ์žˆ๊ณ , URL์„ ํ†ตํ•ด ํ•ด๋‹น HTML์— ์ ‘์†ํ•ด์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Œ

โœ” Spring Rest Docs ์„ค์ •

1. build.gradle์— ์•„๋ž˜์™€๊ฐ™์ด ์„ค์ •ํ•ด์ฃผ์–ด์•ผ Spring Rest Docs๊ฐ€ API ๋ฌธ์„œ ์ƒ์„ฑ ์ž‘์—…์„ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜ํ–‰ ๊ฐ€๋Šฅ

plugins {
	id 'org.springframework.boot' version '2.7.1'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id "org.asciidoctor.jvm.convert" version "3.3.2"
    // (1) .adoc ํŒŒ์ผ ํ™•์žฅ์ž๋ฅผ ๊ฐ€์ง€๋Š” AsciiDoc ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑํ•ด์ฃผ๋Š” Asciidoctor ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ํ”Œ๋Ÿฌ๊ทธ์ธ ์ถ”๊ฐ€
	id 'java'
}

group = 'com.codestates'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
	mavenCentral()
}

// (2) ext ๋ณ€์ˆ˜์˜ set() ๋ฉ”์„œ๋“œ๋ฅผ ์ด์šฉํ•ด์„œ, API ๋ฌธ์„œ ์Šค๋‹ˆํ•์ด ์ƒ์„ฑ๋  ๊ฒฝ๋กœ ์ง€์ •
ext {
	set('snippetsDir', file("build/generated-snippets"))
}

// (3) AsciiDoctor์—์„œ ์‚ฌ์šฉ๋˜๋Š” ์˜์กด ๊ทธ๋ฃน ์ง€์ •
// (:asciidoctor task๊ฐ€ ์‹คํ–‰๋˜๋ฉด ๋‚ด๋ถ€์ ์œผ๋กœ ์•„๋ž˜์—์„œ ์ง€์ •ํ•œ โ€˜asciidoctorExtensionsโ€™๋ผ๋Š” ๊ทธ๋ฃน์„ ์ง€์ •)
configurations {
	asciidoctorExtensions
}

dependencies {
       // (4) spring-restdocs-core / spring-restdocs-mockmvc ์˜์กด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ์ถ”๊ฐ€
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
  
  // (5) spring-restdocs-asciidoctor ์˜์กด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ถ”๊ฐ€
  // ((3)์—์„œ ์ง€์ •ํ•œ asciidoctorExtensions ๊ทธ๋ฃน์— ์˜์กด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ํฌํ•จ๋ฉ)
	asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.mapstruct:mapstruct:1.5.1.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'
	implementation 'org.springframework.boot:spring-boot-starter-mail'

	implementation 'com.google.code.gson:gson'
}

// (6) test task ์‹คํ–‰ ์‹œ, API ๋ฌธ์„œ ์ƒ์„ฑ ์Šค๋‹ˆํ• ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ ์„ค์ •
tasks.named('test') {
	outputs.dir snippetsDir
	useJUnitPlatform()
}

// (7)  :asciidoctor task ์‹คํ–‰ ์‹œ, Asciidoctor ๊ธฐ๋Šฅ ์‚ฌ์šฉ์„ ์œ„ํ•ด :asciidoctor task์— asciidoctorExtensions ์„ค์ •
tasks.named('asciidoctor') {
	configurations "asciidoctorExtensions"
	inputs.dir snippetsDir
	dependsOn test
}

// (8) :build task ์‹คํ–‰ ์ „์— ์‹คํ–‰๋˜๋Š” task
// :copyDocument task๊ฐ€ ์ˆ˜ํ–‰๋˜๋ฉด index.html ํŒŒ์ผ์ด "src/main/resources/static/docs" ์— copy๋จ
// copy๋œ index.html ํŒŒ์ผ์€ API ๋ฌธ์„œ๋ฅผ ํŒŒ์ผ ํ˜•ํƒœ๋กœ **์™ธ๋ถ€์— ์ œ๊ณตํ•˜๊ธฐ ์œ„ํ•œ ์šฉ๋„**๋กœ ์‚ฌ์šฉ
task copyDocument(type: Copy) {
	dependsOn asciidoctor
    // (8-1) :asciidoctor task๊ฐ€ ์‹คํ–‰๋œ ํ›„์— task๊ฐ€ ์‹คํ–‰ ๋˜๋„๋ก ์˜์กด์„ฑ ์„ค์ •
	from file("${asciidoctor.outputDir}")
    // (8-2) "build/docs/asciidoc/" ๊ฒฝ๋กœ์— ์ƒ์„ฑ๋˜๋Š” index.html์„ copy
	into file("src/main/resources/static/docs")
    // (8-3) "src/main/resources/static/docs" ๊ฒฝ๋กœ๋กœ index.html์„ ์ถ”๊ฐ€
}

build {
	dependsOn copyDocument
    // (9) :build task๊ฐ€ ์‹คํ–‰๋˜๊ธฐ ์ „์— :copyDocument task๊ฐ€ ๋จผ์ € ์ˆ˜ํ–‰ ๋˜๋„๋ก ํ•จ
}

// (10) ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ ํŒŒ์ผ์ด ์ƒ์„ฑํ•˜๋Š” :bootJar task ์„ค์ •
// ( jar ํŒŒ์ผ์— ํฌํ•จํ•ด์„œ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ API ๋ฌธ์„œ๋ฅผ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•œ ์šฉ๋„ )
bootJar {
	dependsOn copyDocument
    // (10-1) :bootJar task ์‹คํ–‰ ์ „์— :copyDocument task๊ฐ€ ์‹คํ–‰ ๋˜๋„๋ก ์˜์กด์„ฑ ์„ค์ •
	from ("${asciidoctor.outputDir}") {
    // (10-2) Asciidoctor ์‹คํ–‰์œผ๋กœ ์ƒ์„ฑ๋˜๋Š” index.html ํŒŒ์ผ์„ jar ํŒŒ์ผ ์•ˆ์— ์ถ”๊ฐ€
		into 'static/docs'
        // (10-3) jar ํŒŒ์ผ์— index.html์„ ์ถ”๊ฐ€ํ•ด ์คŒ์œผ๋กœ์จ ์›น ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ ‘์†(http://localhost:8080/docs/index.html) ํ›„, API ๋ฌธ์„œ๋ฅผ ํ™•์ธ ๊ฐ€๋Šฅ
	}
}

[์ฐธ๊ณ ] https://docs.gradle.org/current/dsl/org.gradle.api.plugins.ExtraPropertiesExtension.html

2. API ๋ฌธ์„œ ์Šค๋‹ˆํ•์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ํ…œํ”Œ๋ฆฟ(๋˜๋Š” source ํŒŒ์ผ) ์ƒ์„ฑ
โžœ API ๋ฌธ์„œ ์Šค๋‹ˆํ•์ด ์ƒ์„ฑ ๋˜์—ˆ์„ ๋•Œ ์ด ์Šค๋‹ˆํ•์„ ์‚ฌ์šฉํ•ด์„œ ์ตœ์ข… API ๋ฌธ์„œ๋กœ ๋งŒ๋“ค์–ด ์ฃผ๋Š” ํ…œํ”Œ๋ฆฟ ๋ฌธ์„œ(index.adoc)๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๊ฒƒ

  • โœ”๏ธ Gradle ๊ธฐ๋ฐ˜ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” src/docs/asciidoc/ ๊ฒฝ๋กœ์— ํ•ด๋‹นํ•˜๋Š” ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑํ•˜๊ธฐ

  • โœ”๏ธ src/docs/asciidoc/ ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด์— ๋น„์–ด์žˆ๋Š” ํ…œํ”Œ๋ฆฟ ๋ฌธ์„œ(index.adoc) ์ƒ์„ฑํ•˜๊ธฐ
    ( ๋‚˜์ค‘์— ๋งŒ๋“ค์–ด์ง„ ์Šค๋‹ˆํ•๋“ค์„ ํ•œ๋ฒˆ์— ๋ชจ์•„ html๋กœ ๋ณ€ํ™˜ํ•  ์ตœ์ข… API ๋ฌธ์„œ๋ฅผ ์ž‘์„ฑํ•  ์šฉ๋„ !)

โ— ์œ„์™€ ๊ฐ™์ด ๋‘๊ฐ€์ง€ ์„ค์ •์„ ๋งˆ์น˜๋ฉด Controller๋ฅผ ํ…Œ์ŠคํŠธ ํ•  ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ํ•ด๋‹น Controller์— ๋Œ€ํ•œ API ์ŠคํŽ™ ์ •๋ณด๋ฅผ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค์— ์ถ”๊ฐ€ํ•ด ์ฃผ๋ฉด API ๋ฌธ์„œ ์Šค๋‹ˆํ•์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค!

โœ” API ๋ฌธ์„œ ์ƒ์„ฑ์„ ์œ„ํ•œ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ๊ธฐ๋ณธ ๊ตฌ์กฐ

@WebMvcTest(MemberController.class) // (1)
@MockBean(JpaMetamodelMappingContext.class) // (2)
@AutoConfigureRestDocs // (3)
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc; // (4)

    @MockBean
	  // (5) ํ…Œ์ŠคํŠธ ๋Œ€์ƒ Controller ํด๋ž˜์Šค๊ฐ€ ์˜์กดํ•˜๋Š” ๊ฐ์ฒด๋ฅผ Mock Bean ๊ฐ์ฒด๋กœ ์ฃผ์ž… ๋ฐ›๊ธฐ

    @Test
    public void postMemberTest() throws Exception {
        // given
        // (6) ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ 

        // (7) Mock ๊ฐ์ฒด๋ฅผ ์ด์šฉํ•œ Stubbing

        // when
        ResultActions actions =
                mockMvc.perform(
                     // (8) request ์ „์†ก
                );

        // then
        actions
                .andExpect( // (9) response์— ๋Œ€ํ•œ ๊ธฐ๋Œ€ ๊ฐ’ ๊ฒ€์ฆ
                .andDo(document(
                            // (10) API ๋ฌธ์„œ ์ŠคํŽ™ ์ •๋ณด ์ถ”๊ฐ€
                 ));
    }
}

( ์œ„์—์„œ ๋‚˜๋จธ์ง€๋Š” Mockito๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Cotroller ์Šฌ๋ผ์ด์Šค ํ…Œ์ŠคํŠธ๋ฅผ ํ•œ ์ฝ”๋“œ์™€ ๊ฐ™์Œ )

( ํด๋ž˜์Šค ๋ ˆ๋ฒจ์— ์• ๋„ˆํ…Œ์ด์…˜๊ณผ then ๋ถ€๋ถ„์—์„œ .andDo(document(...)); ์ดํ›„๊ฐ€ API ๋ฌธ์„œ์— ๋Œ€ํ•œ ์ •๋ณด์ž„ ! )

  • (1) @WebMvcTest(MemberController.class)

    • Controller ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ์ „์šฉ ์• ๋„ˆํ…Œ์ด์…˜
      ( @SpringBootTest ์• ๋„ˆํ…Œ์ด์…˜ ์‚ฌ์šฉ X )

    • ๊ด„ํ˜ธ ์•ˆ์—๋Š” ํ…Œ์ŠคํŠธ ๋Œ€์ƒ Controller ํด๋ž˜์Šค ์ง€์ •

  • (2) @MockBean(JpaMetamodelMappingContext.class)

    • JPA์—์„œ ์‚ฌ์šฉํ•˜๋Š” Bean ๋“ค์„ Mock ๊ฐ์ฒด๋กœ ์ฃผ์ž…ํ•ด์ฃผ๋Š” ์„ค์ •

      Spring Boot ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๋Š” ํ•ญ์ƒ ์ตœ์ƒ์œ„ ํŒจํ‚ค์ง€ ๊ฒฝ๋กœ์˜ ~~~Application ํด๋ž˜์Šค๋ฅผ ์ฐพ์•„์„œ ์‹คํ–‰ํ•˜๋Š”๋ฐ, ์ด ํด๋ž˜์Šค์—๋Š” @EnableJpaAuditing ์• ๋„ˆํ…Œ์ด์…˜์ด ์ถ”๊ฐ€๋˜์–ด์žˆ์Œ

      @EnableJpaAuditing // ์—ฌ๊ธฐ
      @SpringBootApplication
      public class Section3Week3RestDocsApplication {
      	public static void main(String[] args) {
      		SpringApplication.run(Section3Week3RestDocsApplication.class, args);
      	}
      }

      โžœ ๊ทธ๋Ÿฌ๋ฉด JPA์™€ ๊ด€๋ จ๋œ Bean๋“ค์„ ํ•„์š”๋กœ ํ•˜๊ธฐ ๋•Œ๋ฌธ์—
      @WebMvcTest ์• ๋„ˆํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ์ง„ํ–‰ํ•  ๊ฒฝ์šฐ,
      ๊ด„ํ˜ธ์— JpaMetamodelMappingContext๋ฅผ Mock ๊ฐ์ฒด๋กœ ์ฃผ์ž…ํ•ด ์ฃผ์–ด์•ผ ํ•จ

  • (3) @AutoConfigureRestDocs

    • Spring Rest Docs์— ๋Œ€ํ•œ ์ž๋™ ๊ตฌ์„ฑ์„ ์œ„ํ•œ ์• ๋„ˆํ…Œ์ด์…˜
  • (10) API ๋ฌธ์„œ๋ฅผ ์ž๋™ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•œ ํ•ด๋‹น Controller ํ•ธ๋“ค๋Ÿฌ ๋ฉ”์„œ๋“œ์˜ API ์ŠคํŽ™ ์ •๋ณด๋ฅผ document(โ€ฆ)์— ์ถ”๊ฐ€

    • .andDo(โ€ฆ) ๋ฉ”์„œ๋“œ
      โžœ API ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑ ํ•˜๊ธฐ ์œ„ํ•ด Spring Rest Docs์—์„œ ์ง€์›ํ•˜๋Š” ๋ฉ”์„œ๋“œ

    • document(โ€ฆ) ๋ฉ”์„œ๋“œ
      โžœ ์ผ๋ฐ˜์ ์ธ ๋™์ž‘์„ ์ •์˜ํ•˜๊ณ ์ž ํ•  ๋•Œ ์‚ฌ์šฉ
      ( andExpect()์ฒ˜๋Ÿผ ์–ด๋–ค ๊ฒ€์ฆ ์ž‘์—…์„ ํ•˜๋Š” ๊ฒƒ X )

      โžœ API ์ŠคํŽ™ ์ •๋ณด๋ฅผ ์ „๋‹ฌ ๋ฐ›์•„์„œ ์‹ค์งˆ์ ์ธ ๋ฌธ์„œํ™” ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋Š” RestDocumentationResultHandler ํด๋ž˜์Šค์—์„œ ๊ฐ€์žฅ ํ•ต์‹ฌ ๊ธฐ๋Šฅ์„ ํ•˜๋Š” ๋ฉ”์„œ๋“œ

      [document() ๋ฉ”์„œ๋“œ ์•ˆ์— ์“ธ ์ˆ˜ ์žˆ๋Š” ํŒŒ๋ผ๋ฏธํ„ฐ๋“ค ์ฐธ๊ณ ]
      https://docs.spring.io/spring-restdocs/docs/current/reference/html5/#documenting-your-api
      โ €
      [jsonFieldType ์ฐธ๊ณ ]
      https://docs.spring.io/spring-restdocs/docs/current/reference/html5/#documenting-your-api-request-response-payloads-fields-json-field-types

โ— ์œ„ ๊ธฐ๋ณธ ๊ตฌ์กฐ๋ฅผ ๋”ฐ๋ผ API ์ŠคํŽ™ ์ •๋ณด๋ฅผ ์ž‘์„ฑํ•˜๋ฉด๋จ !
( ์ž์„ธํ•œ ๋‚ด์šฉ์€ ์‹ค์Šต ํŒŒ์ผ ์ฐธ๊ณ  )

โœ”๏ธ @SpringBootTest vs @WebMvcTest
โ €

  • @SpringBootTest ์• ๋„ˆํ…Œ์ด์…˜
    • @AutoConfigureMockMvc์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ
    • ํ”„๋กœ์ ํŠธ์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์ „์ฒด Bean์„ ApplicationContext์— ๋“ฑ๋กํ•˜์—ฌ ์‚ฌ์šฉ
      โžœ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑํ•˜๋Š” ๊ฒƒ์€ ํŽธ๋ฆฌ
      โžœ But, ์‹คํ–‰ ์†๋„๊ฐ€ ์ƒ๋Œ€์ ์œผ๋กœ ๋А๋ฆผ
      โ €
      ๐Ÿ‘‰ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๊นŒ์ง€ ์š”์ฒญ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ด์–ด์ง€๋Š” ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ์— ์ฃผ๋กœ ์‚ฌ์šฉ
      โ €
  • @WebMvcTest ์• ๋„ˆํ…Œ์ด์…˜
    • Controller ํ…Œ์ŠคํŠธ์— ํ•„์š”ํ•œ Bean๋งŒ ApplicationContext์— ๋“ฑ๋กํ•˜์—ฌ ์‚ฌ์šฉ
      โžœ ์‹คํ–‰ ์†๋„ ์ƒ๋Œ€์ ์œผ๋กœ ๋น ๋ฆ„
      But, Controller์—์„œ ์˜์กดํ•˜๊ณ  ์žˆ๋Š” ๊ฐ์ฒด๊ฐ€ ์žˆ๋‹ค๋ฉด ํ•ด๋‹น ๊ฐ์ฒด์— ๋Œ€ํ•ด์„œ Mock ๊ฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์˜์กด์„ฑ์„ ์ผ์ผ์ด ์ œ๊ฑฐํ•ด ์ฃผ์–ด์•ผํ•จ
      โ €
      ๐Ÿ‘‰ Controller๋ฅผ ์œ„ํ•œ ์Šฌ๋ผ์ด์Šค ํ…Œ์ŠคํŠธ์— ์ฃผ๋กœ ์‚ฌ์šฉ

โœ๏ธ Asciidoc

  • Spring Rest Docs๋ฅผ ํ†ตํ•ด ์ƒ์„ฑ๋˜๋Š” ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ๋ฌธ์„œ ํฌ๋งท
    โžœ Spring Rest Docs๋ฅผ ํ†ตํ•ด ๋งŒ๋“ค์–ด์ง€๋Š” ๋ฌธ์„œ ์Šค๋‹ˆํ•๊ณผ ์ด ๋ฌธ์„œ ์Šค๋‹ˆํ•์„ ์‚ฌ์šฉํ•˜๋Š” ํ…œํ”Œ๋ฆฟ ๋ฌธ์„œ๋Š” Asciidoc ํฌ๋งท์˜ ๋ฌธ์„œ๋กœ ์ด๋ฃจ์–ด์ ธ ์žˆ์Œ

  • ๊ธฐ์ˆ  ๋ฌธ์„œ ์ž‘์„ฑ์„ ์œ„ํ•ด ์„ค๊ณ„๋œ ๊ฐ€๋ฒผ์šด ๋งˆํฌ์—… ์–ธ์–ด

  • ์ด๋ฅผ ์ด์šฉํ•ด ์ข€ ๋” ์„ธ๋ จ๋˜๊ณ  ๊ฐ€๋…์„ฑ ์ข‹์€ ๋ฌธ์„œ ๋งŒ๋“ค๊ธฐ ๊ฐ€๋Šฅ !

โœ” Asciidoc ๊ธฐ๋ณธ ๋ฌธ๋ฒ•

Ex.

= ์ปคํ”ผ ์ฃผ๋ฌธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ // (1) ๋ฌธ์„œ์˜ ์ œ๋ชฉ
:sectnums: // (2)
:toc: left // (3)
:toclevels: 4 // (4)
:toc-title: Table of Contents // (5)
:source-highlighter: prettify // (6)
โ €
Joo Hyun Ju <57wnguswn57@gmail.com> // (7) ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑํ•œ ์ด์˜ ์ •๋ณด
โ €
v1.0.0, 2022.11.15 // (8) ์ƒ์„ฑ ๋‚ ์งœ
โ €
// (9) API ๋ฌธ์„œ ์Šค๋‹ˆํ•์„ ์ด์šฉํ•˜๋Š” ๋ถ€๋ถ„
*** // (10)
== MemberController
=== ํšŒ์› ๋“ฑ๋ก
.curl-request // (9-1)
include::{snippets}/post-member/curl-request.adoc[] // (9-2)
โ €
.http-request
include::{snippets}/post-member/http-request.adoc[]
โ €
.request-fields
include::{snippets}/post-member/request-fields.adoc[]
โ €
.http-response
include::{snippets}/post-member/http-response.adoc[]
โ €
.response-fields
include::{snippets}/post-member/response-fields.adoc[]
  • (1) ๋ฌธ์„œ์˜ ์ œ๋ชฉ

    • =๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์ œ๋ชฉ ์ž‘์„ฑ
      ( =์˜ ๊ฐœ์ˆ˜๊ฐ€ ๋Š˜์–ด๋‚  ์ˆ˜๋ก ๊ธ€์ž๋Š” ์ž‘์•„์ง )
  • (2) :sectnums:

    • ๋ชฉ์ฐจ์—์„œ ๊ฐ ์„น์…˜์— ๋„˜๋ฒ„๋ง
  • (3) :toc:

    • ๋ชฉ์ฐจ๋ฅผ ๋ฌธ์„œ์˜ ์–ด๋А ์œ„์น˜์— ๊ตฌ์„ฑํ•  ๊ฒƒ์ธ์ง€ ์„ค์ •
      ( ์œ„์˜ ์˜ˆ์‹œ์—์„œ๋Š” left๋กœ ์„ค์ • )
  • (4) :toclevels:

    • ๋ชฉ์ฐจ์— ํ‘œ์‹œํ•  ์ œ๋ชฉ์˜ level ์ง€์ •
      ( ์œ„์˜ ์˜ˆ์‹œ์—์„œ๋Š” 4๋กœ ์ง€์ • โžœ ==== ๊นŒ์ง€์˜ ์ œ๋ชฉ๋งŒ ๋ชฉ์ฐจ์— ํ‘œ์‹œ๋จ )
  • (5) :toc-title:

    • ๋ชฉ์ฐจ์˜ ์ œ๋ชฉ ์ง€์ •
  • (6) :source-highlighter:

    • ๋ฌธ์„œ์— ํ‘œ์‹œ๋˜๋Š” ์†Œ์Šค ์ฝ”๋“œ ํ•˜์ผ๋ผ์ดํ„ฐ๋ฅผ ์ง€์ •
      ( ์œ„์˜ ์˜ˆ์‹œ์—์„œ๋Š” prettify ์ง€์ • )
  • (9) API ๋ฌธ์„œ ์Šค๋‹ˆํ•์„ ์ด์šฉํ•˜๋Š” ๋ถ€๋ถ„

    • (9-1) .

      • ํ•˜๋‚˜์˜ ์Šค๋‹ˆํ• ์„น์…˜ ์ œ๋ชฉ์„ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ
        ( ์œ„์˜ ์˜ˆ์‹œ์—์„œ๋Š” curl-request๋ฅผ ์„น์…˜ ์ œ๋ชฉ์œผ๋กœ ํ•จ )
    • (9-2) ํ…œํ”Œ๋ฆฟ ๋ฌธ์„œ์—์„œ ์Šค๋‹ˆํ•์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•
      โžœ include::{snippets}/์Šค๋‹ˆํ• ๋ฌธ์„œ๊ฐ€ ์œ„์น˜ํ•œ ๋””๋ ‰ํ† ๋ฆฌ/์Šค๋‹ˆํ• ๋ฌธ์„œํŒŒ์ผ๋ช….adoc[]

      • include
        โžœ Asciidoctor์—์„œ ์‚ฌ์šฉํ•œ๋А ๋งคํฌ๋กœ(macro) ์ค‘ ํ•˜๋‚˜
        โžœ ์Šค๋‹ˆํ•์„ ํ…œํ”Œ๋ฆฟ ๋ฌธ์„œ์— ํฌํ•จํ•  ๋•Œ ์‚ฌ์šฉ

      • ::
        โžœ ๋งคํฌ๋กœ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ํ‘œ๊ธฐ๋ฒ•

        โœ”๏ธ ๋งคํฌ๋กœ (macro)
        โžœ ์–ด๋–ค ๋ฐ˜๋ณต๋˜๋Š” ์ž‘์—…์„ ์ž๋™ํ™”ํ•œ๋‹ค๋Š” ์˜๋ฏธ

      • {snippets}
        โžœ ํ•ด๋‹น ์Šค๋‹ˆํ•์ด ์ƒ์„ฑ๋˜๋Š” ๋””ํดํŠธ ๊ฒฝ๋กœ
        โžœ build.gradle ํŒŒ์ผ์— ์„ค์ •ํ•œ snippetsDir ๋ณ€์ˆ˜๋ฅผ ์ฐธ์กฐํ•˜๋Š” ๋ฐ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Œ

  • (10) ***

    • ๋‹จ๋ฝ์„ ๊ตฌ๋ถ„ ์ง€์„ ์ˆ˜ ์žˆ๋Š” ์ˆ˜ํ‰์„  ์ถ”๊ฐ€
  • ๋ฐ•์Šค ๋ฌธ๋‹จ

    • ์ œ๋ชฉ ๋‹ค์Œ์— ํ•œ ๋ผ์ธ์„ ๋„์šฐ๊ณ  ํ•œ ์นธ ๋“ค์—ฌ์“ฐ๊ธฐ์˜ ๋ฌธ๋‹จ์„ ์ž‘์„ฑํ•˜๋ฉด ํ•ด๋‹น ๋ฌธ๋‹จ์„ ๋ฐ•์Šค๋กœ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Œ
  • CAUTION: / NOTE: / TIP: / IMPORTANT: / WARNING: ๋“ฑ

    • ๊ฒฝ๊ณ  ๋ฌธ๊ตฌ ์ถ”๊ฐ€
  • URL Scheme ์ž๋™ ์ธ์‹

    • http / https / ftp / irc / mailto / hgd@gmail.com๊ณผ ๊ฐ™์€ URL Scheme๋Š” Asciidoc ์—์„œ ์ž๋™์œผ๋กœ ์ธ์‹ํ•˜์—ฌ ๋งํฌ ์„ค์ •๋จ
  • image::

    • ์ด๋ฏธ์ง€ ์ถ”๊ฐ€
      Ex. image::https://spring.io/images/spring-logo-9146a4d3298760c2e7e49595184e1975.svg[spring]

[Asciidoctor ์‚ฌ์šฉ๋ฒ• ์ฐธ๊ณ ]

โœ” Asciidoctor

  • AsciiDoc ํฌ๋งท์˜ ๋ฌธ์„œ๋ฅผ ํŒŒ์‹ฑํ•ด์„œ HTML 5, ๋งค๋‰ด์–ผ ํŽ˜์ด์ง€, PDF ๋ฐ EPUB 3 ๋“ฑ์˜ ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํˆด

  • Spring Rest Docs์—์„œ๋Š” Asciidoc ํฌ๋งท์˜ ๋ฌธ์„œ๋ฅผ HTML ํŒŒ์ผ๋กœ ๋ณ€ํ™˜ํ•˜๊ธฐ ์œ„ํ•ด ๋‚ด๋ถ€์ ์œผ๋กœ Asciidoctor๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Œ


๐Ÿ˜œ ์‹ค์Šต

  • projects - be-template-api-documentation
  • git - be-homework-api-documentation

์œ„์˜ ๊ธฐ๋ณธ ๊ตฌ์กฐ์— ๋”ฐ๋ผ API ์ŠคํŽ™ ์ •๋ณด๋ฅผ ์ž‘์„ฑํ•œ ํ›„ ํ…Œ์ŠคํŠธ๊ฐ€ passed๊ฐ€ ๋˜๋ฉด,
์•„๋ž˜์™€ ๊ฐ™์ด build > generated-snippets > ์Šค๋‹ˆํ•๋ช… ํด๋”์— ํ•ด๋‹น ์Šค๋‹ˆํ•๋“ค์ด ์ƒ์„ฑ๋จ !

โœ”๏ธ snippet ์ข…๋ฅ˜

  • curl-request.adoc
    โžœ ํ˜ธ์ถœ์— ๋Œ€ํ•œ curl ๋ช…๋ น์„ ํฌํ•จ ํ•˜๋Š” ๋ฌธ์„œ
  • httpie-request.adoc
    โžœ ํ˜ธ์ถœ์— ๋Œ€ํ•œ http ๋ช…๋ น์„ ํฌํ•จ ํ•˜๋Š” ๋ฌธ์„œ
  • http-request.adoc
    โžœ http ์š”์ฒญ ์ •๋ณด ๋ฌธ์„œ
  • http-response.adoc
    โžœ http ์‘๋‹ต ์ •๋ณด ๋ฌธ์„œ
  • request-body.adoc
    โžœ ์ „์†ก๋œ http ์š”์ฒญ ๋ณธ๋ฌธ ๋ฌธ์„œ
  • response-body.adoc
    โžœ ๋ฐ˜ํ™˜๋œ http ์‘๋‹ต ๋ณธ๋ฌธ ๋ฌธ์„œ
  • request-parameters.adoc
    โžœ ํ˜ธ์ถœ์— parameter ์— ๋Œ€ํ•œ ๋ฌธ์„œ
  • path-parameters.adoc
    โžœ http ์š”์ฒญ์‹œ url ์— ํฌํ•จ๋˜๋Š” path parameter ์— ๋Œ€ํ•œ ๋ฌธ์„œ
  • request-fields.adoc
    โžœ http ์š”์ฒญ object ์— ๋Œ€ํ•œ ๋ฌธ์„œ
  • response-fields.adoc
    โžœ http ์‘๋‹ต object ์— ๋Œ€ํ•œ ๋ฌธ์„œ

๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ๋งˆ์น˜๊ณ  ์Šค๋‹ˆํ•๋“ค์ด ๋ชจ๋‘ ์ƒ์„ฑ๋˜๋ฉด,
Spring Rest Docs ์„ค์ •์—์„œ ๋งŒ๋“ค์–ด ๋‘์—ˆ๋˜ ํ…œํ”Œ๋ฆฟ ๋ฌธ์„œ๋ฅผ ์ž‘์„ฑํ•  src/docs/asciidoc/index.adoc ํŒŒ์ผ์—
์•„๋ž˜์™€ ๊ฐ™์ด ์Šค๋‹ˆํ•๋“ค์„ ๋ชจ๋‘ ํ•ฉ์ณ HTML๋กœ ๋ณ€ํ™˜์„ ์œ„ํ•œ ์ตœ์ข… API ๋ฌธ์„œ๋ฅผ ๋งŒ๋“ค์–ด์ฃผ๋ฉด ๋จ!

์ด์ œ ๋‹ค ์ž‘์„ฑ์„ ํ–ˆ๋‹ค๋ฉด,

์œ„ ์‚ฌ์ง„์ฒ˜๋Ÿผ Gradle์˜ :build or :bootJar task ๋ช…๋ น์„ ์‹คํ–‰ํ•˜์—ฌ index.adoc ํŒŒ์ผ์„ index.html ํŒŒ์ผ๋กœ ๋ณ€ํ™˜ํ•˜๊ธฐ !

๊ทธ๋Ÿฌ๋ฉด ์œ„์™€ ๊ฐ™์ด src/main/resources/static.docs/ ๊ฒฝ๋กœ์— index.html์ด ์ƒ๊น€ !!

์ด๋ฅผ ์ธํ„ฐ๋„ท์—์„œ ํ™•์ธํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด,

intellij ์—์„œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ ํ›„, http://localhost:8080/docs/index.html ์ด URL์„ ์›น ๋ธŒ๋ผ์šฐ์ €์— ์ž…๋ ฅํ•œ๋‹ค๋ฉด

์•„๋ž˜์™€ ๊ฐ™์ด API ๋ฌธ์„œ๊ฐ€ htmlํ™” ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Œ ~

[์ฐธ๊ณ ] https://jogeum.net/16


๐ŸŒˆ ๋А๋‚€์ 

์˜ค๋Š˜ ํ•™์Šต์€ ์ „์— ํ•™์Šตํ–ˆ๋˜ Controller ์Šฌ๋ผ์ด์Šค ํ…Œ์ŠคํŠธ์— ์ข€ ๋” ์ถ”๊ฐ€๋งŒ ํ•˜๋ฉด ๋˜๋Š” ๋ถ€๋ถ„์ด์–ด์„œ ์‰ฌ์› ๊ณ  ์ž๋™์œผ๋กœ ๋งŒ๋“ค์–ด์ง„ API ๋ฌธ์„œ๋ฅผ ์ง์ ‘ ๋ณผ ์ˆ˜ ์žˆ์–ด์„œ ๋” ์žฌ๋ฏธ์žˆ๋Š” ์ฑ•ํ„ฐ์˜€๋‹ค !

0๊ฐœ์˜ ๋Œ“๊ธ€

๊ด€๋ จ ์ฑ„์šฉ ์ •๋ณด

Powered by GraphCDN, the GraphQL CDN