Eighty-age๋ ์ค์๊ฐ ๊ฒ์๊ณผ ์ฟ ํฐ ๊ธฐ๋ฅ์ ๊ฐ์ถ
ํ์ฅํ ์ด์ปค๋จธ์ค ์น ์๋น์ค์ ๋๋ค.
Redis ๊ธฐ๋ฐ ์บ์ฑ๊ณผ ๋์์ฑ ์ ์ด๋ฅผ ์ ์ฉํ์ต๋๋ค.
-
์ฑ๋ฅ ์ต์ ํ
- Redis ๊ธฐ๋ฐ ์บ์ฑ ๋ฐ ์ธ๋ฑ์ค ํ๋์ ํตํด ์ค์๊ฐ ์ํ ์กฐํ ๋ฐ ์ธ๊ธฐ ๊ฒ์์ด ์๋ต ์๋ ๊ฐ์
-
๋์์ฑ ์ ์ด
- Redisson ๊ธฐ๋ฐ ๋ถ์ฐ ๋ฝ์ผ๋ก ์ฟ ํฐ ๋ฐ๊ธ ์ ์ค๋ณต ๋ฐฉ์ง ๋ฐ ํธ๋์ญ์ ์์ ์ฑ ํ๋ณด
-
์ด์ ๋ฐ ๋ฐฐํฌ ํจ์จํ
- GitHub Actions์ Docker๋ฅผ ํ์ฉํ CI/CD ๊ตฌ์ถ
- AWS ์ธํ๋ผ(ECR, EC2, RDS, S3, ElastiCache) ๊ธฐ๋ฐ์ ์๋ํ ๋ฐฐํฌ ํ๊ฒฝ ๊ตฌ์ฑ
| ๋๋ฉ์ธ | ๊ธฐ๋ฅ | ์ค๋ช |
|---|---|---|
| ๐ค ํ์ | ํ์๊ฐ์ | ์ฌ์ฉ์(User/Admin) ๊ตฌ๋ถ ๋ฑ๋ก, ๋น๋ฐ๋ฒํธ ์ ์ฑ ์ ์ฉ |
| ๐ค ํ์ | ๋ก๊ทธ์ธ | JWT ๋ฐ๊ธ ๋ฐ ์ธ์ฆ ์ฒ๋ฆฌ |
| ๐ค ํ์ | ํ์์ ๋ณด ์์ | ๋น๋ฐ๋ฒํธ ํ์ธ ํ ๋๋ค์ ๋ฑ ์ ๋ณด ์์ |
| ๐ค ํ์ | ํ์ํํด | ๋น๋ฐ๋ฒํธ ์ฌํ์ธ ํ Soft Delete ์ฒ๋ฆฌ |
| ๐ผ ์ด๋ฏธ์ง | ์ด๋ฏธ์ง ์ ๋ก๋ | S3์ ์ ์ฅ ํ URL ๋ฐํ |
| ๐งด ์ ํ | ์ ํ ์์ฑ | ๊ด๋ฆฌ์ ๊ถํ์ผ๋ก ์ ํ ๋ฑ๋ก |
| ๐งด ์ ํ | ์ ํ ์์ / ์ญ์ | ์ ํ ์ ๋ณด ๋ฐ ์ด๋ฏธ์ง ์์ /์ญ์ |
| ๐งด ์ ํ | ์ ํ ๋จ๊ฑด ์กฐํ | ID ๊ธฐ๋ฐ ์์ธ ์ ๋ณด ์กฐํ |
| ๐งด ์ ํ | ์ ํ ๋ฆฌ์คํธ ์กฐํ | ์ ์ฒด ๋๋ ํํฐ ์กฐ๊ฑด์ ๋ฐ๋ฅธ ๋ฆฌ์คํธ ๋ฐํ |
| ๐งด ์ ํ | ์ ํ ์ด๋ฏธ์ง ์ถ๊ฐ/์ญ์ | ์ ํ์ ๋ค์ค ์ด๋ฏธ์ง ์ถ๊ฐ ๋ฐ ์ญ์ ๊ธฐ๋ฅ |
| ๐ ๊ฒ์ | ์ ํ ๊ฒ์ | ํค์๋, ์นดํ ๊ณ ๋ฆฌ, ์ ๋ ฌ ์กฐ๊ฑด ๊ธฐ๋ฐ ๊ฒ์ |
| ๐ ๊ฒ์ | ์ธ๊ธฐ ๊ฒ์์ด | ์ผ์ ๊ธฐ๊ฐ ๋ด ๊ฐ์ฅ ๋ง์ด ๊ฒ์๋ ํค์๋ Top-N ์ ๊ณต |
| ๐ ๊ฒ์ | ๊ธ์์น ๊ฒ์์ด | ์ต๊ทผ ๊ฒ์๋ ๊ธ์ฆํ ํค์๋ ์๋ ์ง๊ณ |
| ๐ ๋ฆฌ๋ทฐ | ๋ฆฌ๋ทฐ ์์ฑ | ๊ตฌ๋งคํ ์ ์ ์ ํํด ํ์ ๋ฐ ์ฝ๋ฉํธ ๋ฑ๋ก |
| ๐ ๋ฆฌ๋ทฐ | ๋ฆฌ๋ทฐ ์์ / ์ญ์ | ๋ณธ์ธ์ด ์์ฑํ ๋ฆฌ๋ทฐ ์์ ๋ฐ ์ญ์ ๊ฐ๋ฅ |
| ๐ ๋ฆฌ๋ทฐ | ์ ํ๋ณ ๋ฆฌ๋ทฐ ์กฐํ | ์ ํ ์์ธ ํ์ด์ง ๋ด ๋ฆฌ๋ทฐ ๋ฆฌ์คํธ ์ ๊ณต |
| ๐ ์ฟ ํฐ | ์ฟ ํฐ ์์ฑ / ์์ | ๊ด๋ฆฌ์๋ง ๋ฑ๋ก ๊ฐ๋ฅ, ์๋ ๋ฐ ์ ํจ๊ธฐ๊ฐ ํฌํจ |
| ๐ ์ฟ ํฐ | ์ฟ ํฐ ์กฐํ | ์ ์ฒด ์ฟ ํฐ ๋๋ ์ํ๋ณ ํํฐ๋ง ์ง์ |
| ๐ ์ฟ ํฐ | ์ฟ ํฐ ๋จ๊ฑด ์กฐํ | ์ฌ์ฉ ๊ฐ๋ฅํ ์ฟ ํฐ ๋จ๊ฑด ์ ๋ณด ๋ฐํ |
| ๐ ์ฟ ํฐ | ์ฟ ํฐ ๋ด๊ธฐ | ์ฌ์ฉ์ ์์ฒญ ์ ์ฌ์ฉ ์กฐ๊ฑด ๋ง์กฑํ๋ฉด ๋ฐ๊ธ |
- Java 17
- Spring Boot 3.4.4
- MySQL
- H2
- Redis (ElastiCache)
- JWT
- Bcrypt
- Spring Security
- JPA
- JUnit 5
- GitHub Actions
- Docker
- AWS (EC2, S3, RDS, ElastiCache, ECR)
- ๋์์ฑ ์ ์ด๊ฐ ํ์ํ ์ฟ ํฐ ๋ฐ๊ธ ๊ธฐ๋ฅ์ Redisson ๊ธฐ๋ฐ ๋ถ์ฐ ๋ฝ ๋์
- ๋จ์ผ ์๋ฒ ํ๊ฒฝ์ ๊ณ ๋ คํด Redisson Single Server Mode ์ฌ์ฉ
- Redis์ ์ ์ฅ๋ ์ฟ ํฐ ์๋์ ๋ฉ๋ชจ๋ฆฌ์์ ๋จผ์ ์กฐํํ๊ณ ๊ฐ์ โ ์ฑ๋ฅ ๊ฐ์
- ๋ถ์ฐ ๋ฝ์ ํตํด ๋์์ ์ฌ๋ฌ ์ฌ์ฉ์๊ฐ ์ฟ ํฐ์ ๋ฐ๊ธ๋ฐ๋ ์ํฉ์์๋ ์ค๋ณต ๋ฐ๊ธ, ์๋ ์ด๊ณผ ๋ฐ๊ธ, ์์ธ ์ฒ๋ฆฌ ๋ฏธํก ๋ฌธ์ ๋ฅผ ๋ฐฉ์ง
| -ํญ๋ชฉ- | -๋์ ์ - | -๋์ ํ- |
|---|---|---|
| ์ฟ ํฐ ์ค๋ณต ๋ฐ๊ธ | ๋ฐ์ ๊ฐ๋ฅ | ์์ ๋ฐฉ์ง |
| ์ด๊ณผ ๋ฐ๊ธ | ๋น๋ฒ | ๋ฐฉ์ง |
| ์ฑ๋ฅ | DB ๋ณ๋ชฉ ์์ | Redis๋ก ๊ฐ์ |
| ์์ ์ฑ | ์์ธ ์ฒ๋ฆฌ ๋ถ์์ | ๋ฝ๊ณผ ์์ธ ์ฒ๋ฆฌ๋ก ์์ ํ |
-
๊ธฐ์กด ๋ฐฉ์์ ๊ฒ์ ๋ก๊ทธ๋ฅผ ์ค์๊ฐ ์ง๊ณ โ ์๋ต ์ง์ฐ ๋ฐ์
-
Redis Sorted Set์ผ๋ก ์ ํํ์ฌ 25ms ๋ด ์ธ๊ธฐ ๊ฒ์์ด ์กฐํ ๊ฐ๋ฅ
-
DB ์ ๊ทผ ์์ด ์ค์๊ฐ ์์ ์กฐํ์ ์ ํฉํ ๊ตฌ์กฐ๋ก ๊ฐ์
๐ ๊ฒ์ API์ ์บ์๋ฅผ ์ ์ฉํ ์ด์
์ฌ๋ฌ ์ฌ์ฉ์๊ฐ ํค์๋๋ฅผ ์ ๋ ฅํ์ฌ ์ ํ์ ๊ฒ์ํ๋ฉด, ํด๋น ๊ฒ์์ด๋ SearchLog ํ ์ด๋ธ์ ์ ์ฅ๋๋ค.
์ด ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ฌ์ฉ์๋ ์ธ๊ธฐ ๊ฒ์์ด ๋ชฉ๋ก์ ์กฐํํ ์ ์๋ค.๊ทธ๋ฌ๋ ๋ค์์ ์ฌ์ฉ์๊ฐ ๋์์ ์ธ๊ธฐ ๊ฒ์์ด๋ฅผ ์์ฒญํ ๊ฒฝ์ฐ,
SearchLog ํ ์ด๋ธ์์ ์ค์๊ฐ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์ง๊ณํ๊ฒ ๋๋ฉฐ DB์ ๊ณผ๋ถํ๊ฐ ๋ฐ์ํ๋ค.
์ด๋ ์ ์ฒด ์์คํ ์ฑ๋ฅ ์ ํ๋ก ์ด์ด์ง ์ ์๋ค.์ด๋ฌํ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด ์บ์๋ฅผ ์ ์ฉํ์ฌ,
DB ์ ๊ทผ ์์ด ์ธ๊ธฐ ๊ฒ์์ด๋ฅผ ๋น ๋ฅด๊ฒ ์กฐํํ ์ ์๋๋ก ์ค๊ณํ์๋ค.
- ๋ณตํฉ ์กฐ๊ฑด ๊ฒ์ ์ฟผ๋ฆฌ์ ๋ํด ์ธ๋ฑ์ค ์ต์ ํ ์ ์ฉ โ
idx_sale_state_category_name์ธ๋ฑ์ค ์ฌ์ฉ - Postman ๊ธฐ์ค 580ms โ 70ms (์ฝ 8.3๋ฐฐ ์ฑ๋ฅ ํฅ์)
- ์ฟผ๋ฆฌ ์คํ ๊ณํ ๊ธฐ๋ฐ์ผ๋ก ๋ค์ํ ์ธ๋ฑ์ค๋ฅผ ์คํํ๊ณ ๊ฐ์ฅ ํจ์จ์ ์ธ ์กฐํฉ์ ๋์ถ
Review์กฐํ ์ ์ฐ๊ด๋User,Product๋ก ์ธํด N+1 ๋ฐ์JOIN FETCH์ ์ฉํ์ฌ ํ์ํ ๋ฐ์ดํฐ ํ ๋ฒ์ ๋ก๋ฉ, ์ฑ๋ฅ ๊ฐ์
- PR ์์ฑ ์ ์๋ ๋น๋ & ํ ์คํธ, main ๋ธ๋์น ํธ์ ์ ์๋ ๋ฐฐํฌ
- Slack ์๋ฆผ ์ฐ๋์ผ๋ก PR, Merge, ๋ฐฐํฌ ์ฑ๊ณต/์คํจ ์ํฉ ์ค์๊ฐ ๊ณต์
- AWS ECR์ ํตํ Docker ์ด๋ฏธ์ง ๊ด๋ฆฌ ๋ฐ EC2 ๋ฐฐํฌ
- ElastiCache(Redis)๋ฅผ ์ด์ฉํ ์ธ๊ธฐ ๊ฒ์์ด ๋ฐ ์ฟ ํฐ ์บ์ฑ ์ฒ๋ฆฌ
๐๋ฐฐํฌ๋ ์๋น์ค ์ํ ๋ณด๊ธฐ
์ธ๋ฑ์ค ์ ์ฉ์ผ๋ก ์๋ต์๋ 8.29๋ฐฐ ๊ฐ์
์ด๋ค ์กฐ๊ฑด์ผ๋ก ์ฟผ๋ฆฌ๋ฌธ์ ์์ฑํ๋๋์ ๋ฐ๋ผ DB ์๋ต์๋๊ฐ ๋ฌ๋ผ์ง๊ณ , ์ต์ ํ๋ฅผ ์ ์ฉํ ์ ์๋์ง ํ๋จํ ์ ์๋ค.
- ๊ธฐ๋ณธํค, ์ธ๋ํค, ์ ๋ํฌ ์ปฌ๋ผ์ ์ด๋ฏธ ์ ๋ ฌ๋์ด ์์ด ์ธ๋ฑ์ค ํจ๊ณผ๊ฐ ํฌ์ง ์์
- ๋ฐ๋ฉด, ๋ณตํฉ ์กฐ๊ฑด์ ๊ฐ์ง๋ ๊ฒ์ ์ฟผ๋ฆฌ๋ ์ฑ๋ฅ ์ต์ ํ ํจ๊ณผ๊ฐ ํผ
@Query("SELECT new com.example.eightyage.domain.product.dto.response.ProductSearchResponseDto(p.name, p.category, p.price, AVG(r.score)) " +
"FROM Product p LEFT JOIN p.reviews r " +
"WHERE p.saleState = 'FOR_SALE' " +
"AND (:category IS NULL OR p.category = :category) " +
"AND (:name IS NULL OR p.name LIKE CONCAT('%', :name, '%')) " +
"GROUP BY p.name, p.category, p.price " +
"ORDER BY AVG(r.score)")
์ ์ฟผ๋ฆฌ์ ์ฟผ๋ฆฌ๋ฌธ ์ต์ ํ ๋ฐฉ๋ฒ์ค ์ธ๋ฑ์ค๋ฅผ ์ฌ์ฉํ์ฌ ์ฟผ๋ฆฌ ์ต์ ํ ๊ฒฐ์
์ฌ๋ฌ ์ธ๋ฑ์ค ํ๋ณด๋ฅผ ์ ํํ์ฌ ์ธ๋ฑ์ค ์ ์ฉ ์ ๊ณผ ์ฑ๋ฅ ๋ฐ ์คํ๊ณํ ๋น๊ต
| Index Strategy | Response Time (ms) | Actual DB Time | Rows Scanned | Cost |
|---|---|---|---|---|
| No Index | 580 | - | 1,019,721 | - |
| idx_sale_state_category_name | 70 (8.3x faster) | 39.2ms | 145,046 | 1.36e+6 |
| idx_category_sale_state_name | 120 (4.8x faster) | 51.6ms | 145,846 | 1.36e+6 |
| idx_category_sale_state | 152 (3.8x faster) | 122ms | 109,560 | 619,582 |
idx_sale_state_category_name์ธ๋ฑ์ค ์ ์ฉ์ ์๋ต์๋ ๋ฐ ์ค์ DB ์คํ์๊ฐ์ด ๊ฐ์ฅ ๋น ๋ฅด๋ค.- postman ์๋ต์๋: 580ms โ 70ms (8.29๋ฐฐ ๊ฐ์ )
- ๊ฒ์ ์ฑ๋ฅ์ ๋์ด๊ธฐ ์ํด ์ ๋ฌธ์ธ๋ฑ์ค๋
Elastic Search๋ฐ ๋ ์ ํํ ๊ฒ์์์ง ์ฌ์ฉ
Redis ์บ์๋ก ์ธ๊ธฐ ๊ฒ์์ด ์กฐํ ์๋ต ์๋ 2.13๋ฐฐ ๊ฐ์
| ๋ฒ์ | ๋ฐฉ์ | ์๋ต ์๋ (ms) | ๊ฐ์ ๋ฅ |
|---|---|---|---|
| V1 | DB ์ค์๊ฐ ์ง๊ณ | 49 ms | ๊ธฐ์ค๊ฐ |
| V2 | Redis Sorted Set | 23 ms | ๐ฅ ์ฝ 2.13๋ฐฐ ๊ฐ์ |
- V1์ ๊ฒ์ ๋ก๊ทธ ํ ์ด๋ธ์์ ์ค์๊ฐ์ผ๋ก ์ง๊ณํ์ฌ ์๋ต โ 49ms ์์
- V2๋ ์ธ๊ธฐ ๊ฒ์์ด๋ฅผ Redis Sorted Set์ ์บ์ฑํ์ฌ ์กฐํ โ 23ms ์์
- DB ์ ๊ทผ ์์ด ๋น ๋ฅด๊ฒ ์ธ๊ธฐ ํค์๋๋ฅผ ์กฐํํ ์ ์์ด ์๋ฒ ๋ถํ๋ฅผ ์ค์ด๊ณ ์ฌ์ฉ์ ๊ฒฝํ ํฅ์
N+1 ๋ฌธ์ ํด๊ฒฐ: JOIN FETCH๋ก ์ฑ๋ฅ ๊ฐ์
์ํ ์ญ์ ์ ๊ด๋ จ ๋ฆฌ๋ทฐ๋ค์ soft delete ์ฒ๋ฆฌํ๋ ๊ณผ์ ์์
Review โ User, Review โ Product ์ง์ฐ๋ก๋ฉ์ผ๋ก ์ธํด N+1 ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ค.
// ProductService.java - deleteProduct ๋ฉ์๋
Product findProduct = productRepository.findProductByIdOrElseThrow(productId);
List<Review> findReviewList = reviewRepository.findReviewsByProductId(productId);
for (Review review : findReviewList){
review.setDeletedAt(LocalDateTime.now());
}
// ProductRepository.java
@Query("SELECT r FROM Review r WHERE r.product.id = :productId AND r.deletedAt IS NULL")
List<Review> findReviewsByProductId(@Param("productId") Long productId);
Review์ ์ฐ๊ด๋User,Product๊ฐ ์ง์ฐ๋ก๋ฉ(LAZY) ๋์ด ์กฐํ ์๋ง๋ค ์ถ๊ฐ ์ฟผ๋ฆฌ๊ฐ ๋ฐ์ โ N+1
findReviewList๊ฐ N๊ฐ๋ผ๋ฉด, ๊ฐ๊ฐ์Review๋ง๋คUser,Product์กฐํ ์ฟผ๋ฆฌ 1ํ์ฉ ์ถ๊ฐ ์คํ๋จ- ์ด 1 + (2 ร N) ๊ฐ์ ์ฟผ๋ฆฌ ๋ฐ์ โ ์ฌ๊ฐํ ์ฑ๋ฅ ์ ํ
JOIN FETCHvs.@EntityGraph๋ฅผ ๋น๊ตํ์ฌ ์ฑ๋ฅ ์ฐ์์ธJOIN FETCH์ ํJOIN FETCH์ ๊ฒฝ์ฐ ์ถ๊ฐ์ ์ธ SELECT ์์ด 1๋ฒ์ ์ฆ์ ์กฐ์ธ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ด@EntityGraph๋ณด๋ค ์ฑ๋ฅ์ด ์ข๋ค๊ณ ํ๋จ
@Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.product WHERE r.product.id = :productId AND r.deletedAt IS NULL")
List<Review> findReviewsByProductId(@Param("productId") Long productId);
JOIN FETCH๋ก ํ์ํ ๋ฐ์ดํฐ๋ฅผ ํ๋ฒ์ ๊ฐ์ ธ์์ ํด๊ฒฐํ๋ค.
- ๐ ๏ธ GitHub Actions ๊ธฐ๋ฐ CD ์ค์ ํธ๋ฌ๋ธ์ํ : EC2, ECR, ElastiCache
- ์ฟ ํฐ ๋ฐ๊ธ ์์คํ ์์์ ๋ถ์ฐ๋ฝ ์ฌ์ฉ
- ์ธ๋ฑ์ค๋ฅผ ์ด์ฉํ์ฌ ์ฟผ๋ฆฌ๋ฅผ ์ต์ ํ ํ๊ธฐ
- ํธ๋ฌ๋ธ ์ํ : N + 1 ๋ฌธ์
- ํธ๋ฌ๋ธ ์ํ : ํ ์คํธ
- ์ธ๊ธฐ ๊ฒ์์ด ๊ตฌํ Redis ์ค์ ๋ฐ ๋ฌธ์ ํด๊ฒฐํ๊ธฐ
๋ณธ ์ํคํ
์ฒ๋ Spring Boot ๊ธฐ๋ฐ์ ๋จ์ผ ์ ํ๋ฆฌ์ผ์ด์
๊ตฌ์กฐ๋ก,
Docker ์ปจํ
์ด๋ํ, GitHub Actions ๊ธฐ๋ฐ CI/CD,
๊ทธ๋ฆฌ๊ณ AWS EC2 / RDS / S3 / ElastiCache๋ฅผ ์ฐ๋ํ์ฌ
์๋ํ๋ ๋ฐฐํฌ์ ์์ ์ ์ธ ์๋น์ค๋ฅผ ์ ๊ณตํฉ๋๋ค.
๐ฆ ์ ์ฉ ๊ธฐ์ ์์ธ๋ณด๊ธฐ
-
MySQL (RDS)
โ ์ฌ์ฉ์, ์ ํ, ๋ฆฌ๋ทฐ, ์ฟ ํฐ ๋ฑ์ ์ฃผ์ ๋ฐ์ดํฐ ์ ์ฅ -
Redis (ElastiCache)
โ ์ธ๊ธฐ ๊ฒ์์ด, ์ต์ ๊ฐ ์บ์ฑ์ ์ฌ์ฉ. TTL ๊ธฐ๋ฐ ๋ง๋ฃ ์ฒ๋ฆฌ๋ก ํจ์จ์ ๋ฐ์ดํฐ ์ ์ง
-
AWS S3
โ ์ ํ/ํ๋กํ ์ด๋ฏธ์ง ์ ๋ก๋ ๋ฐ ์ ์ ๋ฆฌ์์ค ์ ์ฅ -
AWS EC2
โ ์ ํ๋ฆฌ์ผ์ด์ ์๋ฒ ํธ์คํ -
AWS RDS / ElastiCache / ECR
โ ๊ฐ๊ฐ DB, ์บ์, ๋์ปค ์ด๋ฏธ์ง ์ ์ฅ์ ์ญํ
-
Docker
โ ๊ฐ ๋ชจ๋/์ ํ๋ฆฌ์ผ์ด์ ์ปจํ ์ด๋ํ -
GitHub Actions
โ CI/CD ํ์ดํ๋ผ์ธ ๊ตฌ์ถ, Slack ์ฐ๋ ์๋ฆผ ํฌํจ
โ main ๋ธ๋์น ํธ์ ๋ฐ PR Merge ์ ์๋ ๋ฐฐํฌ ํธ๋ฆฌ๊ฑฐ
|
๐ ํ์ฅ ์ด์ฑ์ CI/CD GitHub |
โจ ํ์ ๋ด์ํ ์ฟ ํฐ GitHub |
โจ ํ์ ์ด์ง์A ์ธ์ฆ / ์ธ๊ฐ GitHub |
โจ ํ์ ์ด์ง์B ์ ํ / ๋ฆฌ๋ทฐ GitHub |
โจ ํ์ ์ ์์ฐ ์ธ๊ธฐ ๊ฒ์์ด GitHub |




