์๋ ํ์ธ์ ์ฌ๋ฌ๋ถ, Quarkus ์๋ฆฌ์ฆ์ ์ธ ๋ฒ์งธ ํฌ์คํ ์ ๋๋ค!
Java ๋ง์ดํฌ๋ก์๋น์ค๋ฅผ ๊ฐ๋ฐํ ๋ ํํ ๋ค์๊ณผ ๊ฐ์ด ์๊ฐ๋ฉ๋๋ค.
์ข ๋ ์์ธํ ์ดํด๋ณด๋ฉด ๋จผ์ Quarkus๊ฐ Spring API๋ฅผ ์ง์ํ์ฌ Spring ๊ฐ๋ฐ์์๊ฒ ์ผ์ ์์
์์ MicroProfile API๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ๋ณด์ฌ์ฃผ๋ ๋ฒ์์ ์ธ๋ถ ์ฌํญ์ ์ดํด๋ณด๊ฒ ์ต๋๋ค. ๊ทธ๋ฐ ๋ค์ ๋ง์ดํฌ๋ก์๋น์ค๋ฅผ ์์ฑํ ๋ Spring ๊ฐ๋ฐ์์๊ฒ ์ ์ฉํ MicroProfile API๋ฅผ ๋ค๋ฃฐ ๊ฒ์
๋๋ค.
์ Quarkus์ธ๊ฐ? ์ฒซ์งธ, ์ด๊ฒ์ ๋ผ์ด๋ธ ์ฝ๋ฉ์
๋๋ค. ์ฆ, MicroProfile API, Spring API ๋ฐ ๊ธฐํ Java API์ ๋ณ๊ฒฝ ์ฌํญ์ ์๋์ผ๋ก ๋ค์ ๋ก๋ํ๋ ๊ฒ์
๋๋ค. ์ด๋ ๋จ ํ๋์ ๋ช
๋ น(mvn quarkus:dev)์ผ๋ก ์ํ๋ฉ๋๋ค. ๋ ๋ฒ์งธ๋ก,
Spring ๊ฐ๋ฐ์๊ฐ Quarkus์์ MicroProfile API์ ํจ๊ป Spring API๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ์ดํดํ๋๋ก ๋๋ ๊ฒ ์ธ์๋ MicroProfile์ ๋ํด ์์ธํ ์ค๋ช ํ์ง ์์ต๋๋ค.
์ปจํ ์ด๋์ ์ฟ ๋ฒ๋คํฐ์ค
์ด ๋ฌธ์๋ฅผ ๋จ์ํ๊ฒ ์ ์งํ๊ธฐ ์ํด ์ฌ๊ธฐ์๋ ์ง์์ ๋์ ์์ค์ ์ธก๋ฉด๋ง ๋ค๋ฃจ๊ฒ ์ต๋๋ค.
์ฟผ์ปค์ค๋
๋ง์ง๋ง์ผ๋ก, Quarkus๋ Kubernetes๋ฅผ ๋์ ๋ฐฐํฌ ํ๊ฒฝ์ผ๋ก ์ง์คํจ์ผ๋ก์จ Kubernetes ํ๋ซํผ ์์ฒด ์์ค์์ ์ ์ฌํ ๊ธฐ๋ฅ์ด ๊ตฌํ๋๋ ๊ฒฝ์ฐ Java ํ๋ ์์ํฌ๋ฅผ ์ฌ์ฉํ์ง ์์ต๋๋ค. ํ 1์ Kubernetes์ Spring ๊ฐ๋ฐ์๊ฐ ์ฌ์ฉํ๋ ์ผ๋ฐ์ ์ธ Java ํ๋ ์์ํฌ ๊ฐ์ ๊ธฐ๋ฅ์ ๋์ ๋งต์ ์ ๊ณตํฉ๋๋ค.
ํ 1. Java ํ๋ ์์ํฌ์ Kubernetes ๊ฐ์ ๊ธฐ๋ฅ์ ๋์ ๋งต
๊ธฐ๋ฅ
์ ํต์ ์ธ ์คํ๋ง ๋ถ์ธ
Kubernetes
์๋น์ค ๋ฐ๊ฒฌ
์ ๋ ์นด
DNS
๊ตฌ์ฑ
์คํ๋ง ํด๋ผ์ฐ๋ ๊ตฌ์ฑ
๊ตฌ์ฑ ๋งต/๋น๋ฐ๋ฒํธ
๋ก๋ ๊ท ํ ์กฐ์
๋ฆฌ๋ณธ(ํด๋ผ์ด์ธํธ ์ธก)
์๋น์ค, โโ๋ณต์ ์ปจํธ๋กค๋ฌ(์๋ฒ ์ธก)
์์ ์ ์ฝ๋ ์ปดํ์ผ ๋ฐ ์คํ
์ด ๊ธฐ์ฌ์์๋ ๋ค์์ ์ฐธ์กฐํฉ๋๋ค.
์คํ๋ง ํ๋ ์์ํฌ API
์์กด์ฑ ์ฃผ์
Quarkus๋ ๋ค์ํ ๊ธฐ๋ฅ์ ์ง์ํฉ๋๋ค.
ะ
ํ 2. ์ง์๋๋ Spring DI API ์ฌ์ฉ ์
์ง์๋๋ Spring DI ๊ธฐ๋ฅ
์
์์ฑ์ ์ฃผ์
public PersonSpringController(
PersonSpringRepository personRepository, // injected
PersonSpringMPService personService) { // injected
this.personRepository = personRepository;
this.personService = personService;
}
ํ์ฅ ์ฃผ์
@Autowired
@RestClient
SalutationRestClient salutationRestClient;
@Value("${fallbackSalutation}")
String fallbackSalutation;
@ ๊ตฌ์ฑ
@Configuration
public class AppConfiguration {
@Bean(name = "capitalizeFunction")
public StringFunction capitalizer() {
return String::toUpperCase;
}
}
@Component("noopFunction")
public class NoOpSingleStringFunction implements StringFunction {
@Override
public String apply(String s) {
return s;
}
}
@Service
public class MessageProducer {
@Value("${greeting.message}")
String message;
public String getPrefix() {
return message;
}
}
์น ํ๋ ์์ํฌ
MicroProfile ์ฌ์ฉ์๋ Quarkus๊ฐ ๊ธฐ๋ณธ ์น ํ๋ก๊ทธ๋๋ฐ ๋ชจ๋ธ๋ก JAX-RS, MicroProfile Rest Client, JSON-P ๋ฐ JSON-B๋ฅผ ์ง์ํ๋ค๋ ์ ์ ์ข์ํ ๊ฒ์
๋๋ค. Spring ๊ฐ๋ฐ์๋ Spring Web API, ํนํ REST ์ธํฐํ์ด์ค์ ๋ํ Quarkus์ ์ต๊ทผ ์ง์์ ๋ง์กฑํ ๊ฒ์
๋๋ค. Spring DI์ ๋ง์ฐฌ๊ฐ์ง๋ก Spring Web API ์ง์์ ์ฃผ์ ๋ชฉํ๋ Spring ๊ฐ๋ฐ์๊ฐ MicroProfile API์ ํจ๊ป Spring Web API๋ฅผ ์ฌ์ฉํ ์ ์๋๋ก ํ๋ ๊ฒ์
๋๋ค. ์ง์๋๋ Spring Web API๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ์๋ ํ 3์ ์ ๊ณต๋์ด ์์ผ๋ฉฐ, ์ด ์ฃผ์ ์ ๋ํ ์ถ๊ฐ ์ ๋ณด์ ์๋ Quarkus ํํ ๋ฆฌ์ผ์์ ์ฐพ์ ์ ์์ต๋๋ค.
ํ 3. ์ง์๋๋ Spring Web API ์ฌ์ฉ ์
์ง์๋๋ Spring ์น ๊ธฐ๋ฅ
์
@RestController
@์์ฒญ ๋งคํ
@RestController
@RequestMapping("/person")
public class PersonSpringController {
...
...
...
}
@GetMapping
@PostMapping
@PutMapping
@DeleteMapping
@PatchMapping
@RequestParam
@์์ฒญํค๋
@MatrixVariable
@๊ฒฝ๋ก๋ณ์
@CookieValue
@์์ฒญ ๋ณธ๋ฌธ
@์๋ต์ํ
@ExceptionHandler
@RestControllerAdvice(์ผ๋ถ)
@GetMapping(path = "/greet/{id}",
produces = "text/plain")
public String greetPerson(
@PathVariable(name = "id") long id) {
...
...
...
}
์คํ๋ง ๋ฐ์ดํฐ JPA
MicroProfile ์ฌ์ฉ์๋ Quarkus๊ฐ Hibernate ORM์ ์ฌ์ฉํ์ฌ JPA๋ฅผ ์ง์ํ๋ค๋ ์ ๋ ๋์ด ํ๊ฐํ ๊ฒ์
๋๋ค. Spring ๊ฐ๋ฐ์๋ฅผ ์ํ ์ข์ ์์๋ ์์ต๋๋ค. Quarkus๋ ์ผ๋ฐ์ ์ธ Spring Data JPA ์ฃผ์ ๋ฐ ์ ํ์ ์ง์ํฉ๋๋ค. ์ง์๋๋ Spring Data JPA API ์ฌ์ฉ ์๋ ํ 4์ ๋์ ์์ต๋๋ค.
ะ
ํ 4. ์ง์๋๋ Spring Data JPA API ์ฌ์ฉ ์
์ง์๋๋ Spring ๋ฐ์ดํฐ JPA ๊ธฐ๋ฅ
์
ํฌ๋ฌ๋ ์ ์ฅ์
public interface PersonRepository
extends JpaRepository,
PersonFragment {
...
}
์ ์ฅ์
Jpa๋ฆฌํฌ์งํ ๋ฆฌ
ํ์ด์ง ๋ฐ ์ ๋ ฌ ์ ์ฅ์
public class PersonRepository extends
Repository {
Person save(Person entity);
Optional findById(Person entity);
}
์ ์ฅ์ ์กฐ๊ฐ
public interface PersonRepository
extends JpaRepository,
PersonFragment {
...
}
ํ์๋ ์ฟผ๋ฆฌ ๋ฉ์๋
public interface PersonRepository extends CrudRepository {
List findByName(String name);
Person findByNameBySsn(String ssn);
Optional
findByNameBySsnIgnoreCase(String ssn);
Boolean existsBookByYearOfBirthBetween(
Integer start, Integer end);
}
์ฌ์ฉ์ ์ ์ ์ฟผ๋ฆฌ
public interface MovieRepository
extends CrudRepository {
Movie findFirstByOrderByDurationDesc();
@Query("select m from Movie m where m.rating = ?1")
Iterator findByRating(String rating);
@Query("from Movie where title = ?1")
Movie findByTitle(String title);
}
๋ง์ดํฌ๋กํ๋กํ์ผ API
๊ฒฐํจ ํ์ฉ
๋ด๊ฒฐํจ์ฑ ๊ตฌ์ฑ์ ์ฐ์ ์ค๋ฅ๋ฅผ ๋ฐฉ์งํ๊ณ ์์ ์ ์ธ ๋ง์ดํฌ๋ก์๋น์ค ์ํคํ
์ฒ๋ฅผ ๋ง๋๋ ๋ฐ ๋งค์ฐ ์ค์ํฉ๋๋ค. Spring ๊ฐ๋ฐ์๋ ์๋
๋์ ๋ด๊ฒฐํจ์ฑ์ ์ํด ํ๋ก ์ฐจ๋จ๊ธฐ๋ฅผ ์ฌ์ฉํด ์์ต๋๋ค.
ํ 5. ์ง์๋๋ MicroProfile Fault Tolerance API ์ฌ์ฉ ์.
MicroProfile ๊ฒฐํจ ํ์ฉ ๊ธฐ๋ฅ
๊ธฐ์
์
@๋น๋๊ธฐ
๋ณ๋์ ์ค๋ ๋์์ ๋ก์ง ์คํ
@Asynchronous
@Retry
public Future<String> getSalutation() {
...
return future;
}
@์นธ๋ง์ด
๋์ ์์ฒญ ์ ์ ํ
@Bulkhead(5)
public void fiveConcurrent() {
makeRemoteCall(); //...
}
@์ํท๋ธ๋ ์ด์ปค
์ค๋งํธํ ์ฅ์ ์ฒ๋ฆฌ ๋ฐ ์ฅ์ ๋ณต๊ตฌ
@CircuitBreaker(delay=500 // milliseconds
failureRatio = .75,
requestVolumeThreshold = 20,
successThreshold = 5)
@Fallback(fallbackMethod = "fallback")
public String getSalutation() {
makeRemoteCall(); //...
}
@๋์ฒด
์คํจ ์ ๋์ฒด ๋ก์ง ํธ์ถ
@Timeout(500) // milliseconds
@Fallback(fallbackMethod = "fallback")
public String getSalutation() {
makeRemoteCall(); //...
}
public String fallback() {
return "hello";
}
์์ฒญ ์คํจ ์ ์ฌ์๋
@Retry(maxRetries=3)
public String getSalutation() {
makeRemoteCall(); //...
}
์คํจ ์ ์ด ์๊ฐ ์ด๊ณผ
@Timeout(value = 500 ) // milliseconds
@Fallback(fallbackMethod = "fallback")
public String getSalutation() {
makeRemoteCall(); //...
}
์๋น์ค ํ์ธ(์๋น์ค ์ํ)
Kubernetes ํ๋ซํผ์ ํน์ ์๋น์ค๋ฅผ ์ฌ์ฉํ์ฌ ์ปจํ
์ด๋์ ์ํ๋ฅผ ๋ชจ๋ํฐ๋งํฉ๋๋ค. ๊ธฐ๋ณธ ํ๋ซํผ์ด ์๋น์ค๋ฅผ ๋ชจ๋ํฐ๋งํ ์ ์๋๋ก ํ๊ธฐ ์ํด Spring ๊ฐ๋ฐ์๋ ์ผ๋ฐ์ ์ผ๋ก ์ฌ์ฉ์ ์ ์ HealthIndicator ๋ฐ Spring Boot Actuator๋ฅผ ์ฌ์ฉํฉ๋๋ค. Quarkus์์๋ ๊ธฐ๋ณธ์ ์ผ๋ก ํ์ฑ ์ํ ํ์ธ์ ์ํํ์ง๋ง ํ์ฑ ์ํ์ ์ค๋น ์ํ๋ฅผ ๋์์ ํ์ธํ๋๋ก ๊ตฌ์ฑํ ์ ์๋ MicroProfile Health๋ฅผ ์ฌ์ฉํ์ฌ ์ด ์์
์ ์ํํ ์ ์์ต๋๋ค. ์ง์๋๋ MicroProfile Health API๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ์๋ ํ 6์ ์ ๊ณต๋์ด ์์ผ๋ฉฐ ์ถ๊ฐ ์ ๋ณด๋ Quarkus ๋งค๋ด์ผ์ ์ ๊ณต๋์ด ์์ต๋๋ค.
ํ 6: ์ง์๋๋ MicroProfile Health API์ ์ฌ์ฉ ์.
MicroProfile ์ํ ๊ธฐ๋ฅ
๊ธฐ์
์
@ํ์ฑํ
ํ๋ซํผ์ ์คํจํ ์ปจํ
์ด๋ํ๋ ์ ํ๋ฆฌ์ผ์ด์
์ ์ฌ๋ถํ
ํฉ๋๋ค.
์ข
์ :
ํธ์คํธ:8080/๊ฑด๊ฐ/๋ผ์ด๋ธ
@Liveness
public class MyHC implements HealthCheck {
public HealthCheckResponse call() {
...
return HealthCheckResponse
.named("myHCProbe")
.status(ready ? true:false)
.withData("mydata", data)
.build();
}
@์ค๋น
ํ๋ซํผ์ ์ค๋น๊ฐ ๋์ง ์์ ๊ฒฝ์ฐ ์ปจํ
์ด๋ํ๋ ์ ํ๋ฆฌ์ผ์ด์
์ ํธ๋ํฝ์ ๋ณด๋ด์ง ์์ต๋๋ค.
์ข
์ :
ํธ์คํธ:8080/๊ฑด๊ฐ/์ค๋น
@Readiness
public class MyHC implements HealthCheck {
public HealthCheckResponse call() {
...
return HealthCheckResponse
.named("myHCProbe")
.status(live ? true:false)
.withData("mydata", data)
.build();
}
์ธก์ ํญ๋ชฉ
์ ํ๋ฆฌ์ผ์ด์ ์ ์ด์ ๋ชฉ์ (์ฑ๋ฅ SLA ๋ชจ๋ํฐ๋ง) ๋๋ ๋น์ด์ ๋ชฉ์ (๋น์ฆ๋์ค SLA)์ ๋ํ ์งํ๋ฅผ ์ ๊ณตํฉ๋๋ค. Spring ๊ฐ๋ฐ์๋ Spring Boot Actuator ๋ฐ Micrometer๋ฅผ ์ฌ์ฉํ์ฌ ๋ฉํธ๋ฆญ์ ์ ๊ณตํฉ๋๋ค. ๊ฒฐ๊ณผ์ ์ผ๋ก Quarkus๋ MicroProfile Metrics๋ฅผ ์ฌ์ฉํ์ฌ ๊ธฐ๋ณธ ๋ฉํธ๋ฆญ(JVM ๋ฐ ์ด์ ์ฒด์ ), ๋ฒค๋ ๋ฉํธ๋ฆญ(Quarkus) ๋ฐ ์ ํ๋ฆฌ์ผ์ด์ ๋ฉํธ๋ฆญ์ ์ ๊ณตํฉ๋๋ค. MicroProfile Metrics์์๋ ๊ตฌํ ์ JSON ๋ฐ OpenMetrics(Prometheus) ์ถ๋ ฅ ํ์์ ์ง์ํด์ผ ํฉ๋๋ค. MicroProfile Metrics API ์ฌ์ฉ ์๋ ํ 7์ ๋์ ์์ต๋๋ค.
ะ
ํ 7. MicroProfile Metrics API ์ฌ์ฉ ์.
MicroProfile ์งํ ๊ธฐ๋ฅ
๊ธฐ์
์
@๊ณ์ฐ๋จ
์ฃผ์์ด ๋ฌ๋ฆฐ ๊ฐ์ฒด๊ฐ ํธ์ถ๋ ํ์๋ฅผ ๊ณ์ฐํ๋ ์นด์ดํฐ ์นด์ดํฐ๋ฅผ ๋ํ๋ ๋๋ค.
@Counted(name = "fallbackCounter",
displayName = "Fallback Counter",
description = "Fallback Counter")
public String salutationFallback() {
return fallbackSalutation;
}
@ConcurrentGauge
์ฃผ์์ด ๋ฌ๋ฆฐ ๊ฐ์ฒด์ ๋ํ ๋์ ํธ์ถ ์๋ฅผ ๊ณ์ฐํ๋ ๊ฒ์ด์ง๋ฅผ ๋ํ๋ ๋๋ค.
@ConcurrentGuage(
name = "fallbackConcurrentGauge",
displayName="Fallback Concurrent",
description="Fallback Concurrent")
public String salutationFallback() {
return fallbackSalutation;
}
@๊ณ๋๊ธฐ
์ฃผ์์ด ๋ฌ๋ฆฐ ๊ฐ์ฒด์ ๊ฐ์ ์ธก์ ํ๋ ๊ฒ์ด์ง ์ผ์๋ฅผ ๋ํ๋ ๋๋ค.
@Metered(name = "FallbackGauge",
displayName="Fallback Gauge",
description="Fallback frequency")
public String salutationFallback() {
return fallbackSalutation;
}
@์ธก์ ๋จ
์ฃผ์์ด ๋ฌ๋ฆฐ ๊ฐ์ฒด์ ํธ์ถ ๋น๋๋ฅผ ๋ชจ๋ํฐ๋งํ๋ ๋ฏธํฐ ์ผ์๋ฅผ ๋ํ๋ ๋๋ค.
@Metered(name = "MeteredFallback",
displayName="Metered Fallback",
description="Fallback frequency")
public String salutationFallback() {
return fallbackSalutation;
}
๋ฉํธ๋ฆญ์ ์ ๋ ฅํ๊ฑฐ๋ ์์ฑํ๋ผ๋ ์์ฒญ์ด ์์ ๋ ๋ ๋ฉํ๋ฐ์ดํฐ์ ๋ํ ์ ๋ณด๊ฐ ํฌํจ๋ ์ฃผ์
@Metric
@Metered(name = "MeteredFallback",
displayName="Metered Fallback",
description="Fallback frequency")
public String salutationFallback() {
return fallbackSalutation;
}
์ฃผ์์ด ๋ฌ๋ฆฐ ๊ฐ์ฒด์ ์ง์ ์๊ฐ์ ์ถ์ ํ๋ ํ์ด๋จธ๋ฅผ ๋ํ๋ ๋๋ค.
@Timed(name = "TimedFallback",
displayName="Timed Fallback",
description="Fallback delay")
public String salutationFallback() {
return fallbackSalutation;
}
์ธก์ ํญ๋ชฉ ๋์
์ ํ๋ฆฌ์ผ์ด์
์ธก์ ํญ๋ชฉ
๊ธฐ๋ณธ ์ธก์ ํญ๋ชฉ
๊ณต๊ธ์
์ฒด ์งํ
๋ชจ๋ ์งํ
MicroProfile Rest ํด๋ผ์ด์ธํธ
๋ง์ดํฌ๋ก์๋น์ค๋ ์ข ์ข ํด๋น ํด๋ผ์ด์ธํธ API๊ฐ ์๋ํด์ผ ํ๋ RESTful ์๋ํฌ์ธํธ๋ฅผ ์ ๊ณตํฉ๋๋ค. RESTful ๋์ ์ ์ฌ์ฉํ๊ธฐ ์ํด Spring ๊ฐ๋ฐ์๋ ์ผ๋ฐ์ ์ผ๋ก RestTemplate์ ์ฌ์ฉํฉ๋๋ค. Quarkus๋ ์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด MicroProfile Rest Client API๋ฅผ ์ ๊ณตํ๋ฉฐ, ์ฌ์ฉ ์๋ ํ 8์ ๋์ ์์ต๋๋ค.
ะ
ํ 8. MicroProfile Rest ํด๋ผ์ด์ธํธ API ์ฌ์ฉ ์
MicroProfile Rest ํด๋ผ์ด์ธํธ ๊ธฐ๋ฅ
๊ธฐ์
์
@RegisterRestClient
ํ์ํ๋ Java ์ธํฐํ์ด์ค๋ฅผ REST ํด๋ผ์ด์ธํธ๋ก ๋ฑ๋กํฉ๋๋ค.
@RegisterRestClient
@Path("/")
public interface MyRestClient {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String getSalutation();
}
@RestClient
ํ์ํ๋ REST ํด๋ผ์ด์ธํธ ์ธํฐํ์ด์ค์ ์ธ์คํด์ค ๊ตฌํ์ ํ์ํฉ๋๋ค.
@Autowired // or @Inject
@RestClient
MyRestClient restClient;
๊ธฐ๋
REST ์๋ํฌ์ธํธ๋ฅผ ํธ์ถํฉ๋๋ค.
System.out.println(
restClient.getSalutation());
mp-ํด์/URL
REST ์๋ํฌ์ธํธ๋ฅผ ์ง์ ํฉ๋๋ค.
application.properties:
org.example.MyRestClient/mp-rest/url=
http://localhost:8081/myendpoint
๊ฒฐ๊ณผ
์ฃผ๋ก Spring ๊ฐ๋ฐ์๋ฅผ ๋์์ผ๋ก ํ ์ด ๋ธ๋ก๊ทธ์์๋ Quarkus์ MicroProfile API์ ํจ๊ป Spring API๋ฅผ ์ฌ์ฉํ์ฌ Java ๋ง์ดํฌ๋ก์๋น์ค๋ฅผ ๊ฐ๋ฐํ ๋ค์ ์ด๋ฅผ ์๋ฐฑ ๋ฉ๊ฐ๋ฐ์ดํธ์ RAM์ ์ ์ฝํ๊ณ ๋ช ๋ฐ๋ฆฌ์ด์ ๋ฌธ์ ์ ๋๋ค.
์ด๋ฏธ ์ดํดํ์
จ๋ฏ์ด Spring ๋ฐ MicroProfile API ์ง์์ ๋ํ ์ถ๊ฐ ์ ๋ณด์ ๊ธฐํ ์ ์ฉํ ์ ๋ณด๋ ๋ค์์์ ์ฐพ์ ์ ์์ต๋๋ค.
์ถ์ฒ : habr.com