작성자 | 서버 팀원 박서진
사용 기술 |
JPA |
Spring boot(3.3.1) |
Database : PostgreSQL |
한끼족보 서버는 이번에 데이터베이스에 접근하는 프레임워크로 JPA를 채택하였는데, 요구사항 변화에 따라 JPA의 한계에 대응하며 쿼리 최적화를 해보았다. 특히 짧은 개발 기간 동안 고민과 변화가 가장 많았던 API를 통해 어떤 과정으로 쿼리 최적화를 했는지에 대한 과정을 기록하려한다.
JPA 채택 이유
우선 서버 팀에서 왜 DB 접근 프레임워크로 JPA를 채택했는지부터 간단하게 설명하고자 한다.
JPA란 Java Persistence API의 약자로, 자바 진영 ORM(객체와 관계형 DB 테이블을 매핑해주는 기술) 표준이다. 기존의 MyBatis와 같은 SQLMapper는 개발자가 직접 SQL을 작성해야하므로 데이터베이스에 종속적이다. 예를 들어 나중에 도메인이 변경되면 연관된 SQL문을 모두 찾아 수정해야하고, 중간에 DB가 바뀌면 해당 DB의 문법에 맞는 SQL문도 모두 수정해주어야한다.
반면 JPA는 사용자가 자체적으로 제공하는 인터페이스를 통해 기본적인 CRUD의 경우 메소드 호출만으로 쿼리 생성이 가능하고, 직접 쿼리문을 작성할 경우 JPQL(객체 지향 쿼리 언어)을 사용해 객체를 기반으로 쿼리를 작성할 수 있다. 또한 JPA는 특정 DB에 종속되지 않기 때문에 만약 추후 서버 DB가 변경될 경우 변경될 DB에 맞춰 SQL문법을 수정할 필요가 없고, 설정 파일만 변경하면 된다.
그리고 가장 큰 장점은 객체와 데이터베이스 간의 매핑을 자동으로 처리해준다는 것이다. 개발자들이 자바 객체 모델을 만들면 JPA에서 자동으로 이를 DB와 매핑해준다. 이로 인해 개발자들은 객체 지향적으로 데이터를 처리할 수 있다.
이러한 장점을 이유로 우리는 특정 데이터베이스에 종속되지 않고 객체 지향적인 프로그래밍에 집중할 수 있는 JPA를 선택했다.
화면 구성
위의 화면은 [족보 상세보기] API가 호출되는 화면이다. API 이름대로 사용자가 특정 족보의 화면을 볼 수 있는 화면인데, 여기서 해당 족보에 소속된 식당들의 목록을 조회할 수 있다(유저의 닉네임, 프로필 사진은 별도의 API가 존재한다).
그리고 아래는 그 데이터들이 저장되어 있는 DB의 ERD 구조이다. 관계는 요구사항에 따라 매핑되어 있다.
테이블 간의 관계를 간단히 설명하자면 아래와 같다.
- 하나의 족보(favorite)는 여러 개의 가게(store)를 가질 수 있고, 하나의 가게는 여러 족보에 소속될 수 있다. → N : M 관계이므로 중간 테이블인 favorite_store 생성
- 가게는 여러 개의 가게 사진(store_image)을 가질 수 있다.
클라이언트로부터 족보의 id(식별값)를 전달받아 id에 해당하는 특정 족보를 찾고, favorite_store(족보에 소속된 가게)를 통해 가게들을 찾은 뒤 해당 가게에 소속되는 가게 이미지들을 response로 보내주어야했다.
첫 번째로 가장 단순하게 모든 데이터들을 쿼리 한 방으로 가져오자!라는 생각을 할 수 있을 것이다. 그런데 여기서 JPA 기술 관련 문제가 생긴다. 바로 fetch join의 한계이다.
🤔첫번째 고민 - fetch join의 한계
여기서 문제가 되는 부분은 favorite과 favorite_store 테이블의 관계가 1 : N이고, store와 store_image의 관계가 1:N 관계라는 것이다.
우리는 JPA의 가장 강력한 기술인 fetch join을 사용해서 아래와 같은 JPQL을 생각할 수 있다.
@Query("select f from Favorite f join fetch f.favoriteStores fs join fetch fs.store s join fetch s.images where f.id = :id and s.isDeleted = false")
Favorite findByIdWithFavoriteStoreAndStoresAndImages(Long id);
하지만 해당 쿼리는 아래와 같은 에러를 발생시킨다.
handleException() in GlobalExceptionHandler throw Exception
[class org.springframework.dao.InvalidDataAccessApiUsageException] : org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags: [org.hankki.hankkiserver.domain.store.model.Store.images, org.hankki.hankkiserver.domain.favorite.model.Favorite.favoriteStores]
“cannot simultaneously fetch multiple bags” 메시지에서 확인할 수 있듯이 @OneToMany 관계에서는 fetch join을 2번 사용할 수 없다. @OneToMany의 경우 join의 특성상 결과가 카타시안 곱으로 생성되어 부모 엔티티가 중복으로(Hibernate 6부터는 내부에서 자동으로 중복을 제거해주긴 한다)조회되는데, 이러한 중복 데이터는 그 수가 많아질 수록 객체 그래프로 관리하기 어려워진다. 그래서 JPA는 이러한 문제를 방지하고자 @OneToMany 관계인 컬렉션은 2번 fetch join 될 수 없도록 제한하고 있다.
그렇다고 이를 우회하기 위해 일반 join을 사용하면 대상 엔티티가 아닌 데이터들은 영속성 컨텍스트에 올라가지 않으므로 N + 1 문제가 생길 것이다. 영속성에 없는 데이터를 다시 조회하기 위해 추가적인 select 쿼리가 발생하므로 결국 join을 사용하는 의미가 없어지는 것이다. 이 문제는 데이터를 영속성 컨텍스트에서 캐싱해서 관리하는 JPA의 특징으로 비롯된다.
위와 같은 이유로 한 번의 쿼리로 모든 데이터들을 한번에 가져오는 것은 불가능했다. 이를 해결하기 위해 다른 쿼리 전략을 고민했다.
😀첫번째 해결
처음으로 생각한 방법은 아래와 같이 데이터를 쪼개서 가져오는 것이었다. 1번과 2번에서 각각 Favorite과 Store을 대상 엔티티로 하고, 연관된 엔티티는 left fetch join을 통해 가져오고자 했다.
Repository
JPQL을 작성하여 위의 사진처럼 favorite과 favorite_store을 함께 가져오고, 그 후 store와 store_image를 함께 가져온다. 쿼리는 아래와 같다.
// 1번 쿼리
@Query("select f from Favorite f left join fetch f.favoriteStores where f.id = :id")
Optional<Favorite> findByIdWithFavoriteStore(@Param("id") Long id);
// 2번 쿼리
@Query("select s from Store s left join fetch s.images where s.id in :ids and s.isDeleted = false")
List<Store> findAllByIdsWithStoreImages(@Param("ids") List<Long> ids);
컬렉션 fetch join인데 데이터 중복 제거를 위해 distinct 키워드 안 써도 되는 건가? 라는 의문이 있는 사람도 있을 텐데, Hibernate 6부턴 distinct를 자동 적용해주어서 생략해도 된다. 자세한 내용은 공식 6.0 Migration Guide 문서 Query - Distinct 목차를 참고하자.
https://docs.jboss.org/hibernate/orm/6.0/migration-guide/migration-guide.html
이렇게 하면 쿼리 2번으로 연관된 엔티티들을 영속성 컨텍스트에 올려서 N + 1문제를 해결할 수 있다. 그런데 여기서 문제가 생긴다………
😕두번째 고민 - 페이징
두번째 문제는 2차 스프린트 때 적용될 페이징이었다.
모두들 한번쯤은 어플리케이션에서 게시글 목록의 맨 아래 [게시글 목록 더보기] 버튼을 누르면 더 많은 게시글이 나오는 것을 본 적이 있을 것이다. 이러한 것이 가능하게 하는 기능이 페이징이다.
페이징 없이 대량의 데이터가 한 번에 전달되어야한다면 서버와 클라이언트 개발자 모두에게 성능 측면에서 좋지않다. 또한 무엇보다도 사용자에게도 불편함을 줄 수 있다. 예를 들어 위의 화면에서 누군가 족보에 100개의 가게를 가지고 있다면 때 100개의 데이터만큼의 스크롤이 생길 것이다. 이러한 현상을 막고자 추후 2차 스프린트 때 게시글 목록들에서 페이징을 도입하자는 이야기가 나왔다.
문제는 JPA에서 @OneToMany 관계의 컬렉션을 fetch join한 데이터는 페이징이 불가능하다는 것이다.
추후 Store(위의 사진에서 표시된 부분)를 페이징하는 기능을 추가해야하는데, 현재 Store은 2번 쿼리에서 @OneToMany 관계인 StoreImage와 fetch join 되어 조회되고 있는 상태였다.
// 2번 쿼리
@Query("select s from Store s left join fetch s.images where s.id in :ids and s.isDeleted = false")
List<Store> findAllByIdsWithStoreImages(@Param("ids") List<Long> ids);
페이징은 2차 스프린트로 들어갈 예정이었지만, 나중에 바꾸기 귀찮고 미리 추후의 요구사항들을 고려한 쿼리를 작성해보고자 했다.
😀두번째 해결 - @BatchSize
@OneToMany에서 페이징이 불가능한 문제의 해결방법으로 BatchSize가 있다. BatchSize란 부모 엔티티와 연관된 자식 엔티티들에 접근할 때 자동으로 한 번의 쿼리로 조회할 수 있게하는 설정이다.
BatchSize를 yml, properties 파일 등에 설정하여 전역적으로 적용하는 방법도 있지만, 우리는 필요한 엔티티의 필드에만 선언하여 코드에서 명시적으로 사용하는 방식을 채택했다. 어노테이션을 선언한 과정은 아래와 같다.
Favorite
- favorite이 가지고 있는 favoriteStores에 @BatchSize(size = 100) 선언
public class Favorite extends BaseTimeEntity {
... 생략
@OneToMany(mappedBy = "favorite")
@BatchSize(size = 100)
private List<FavoriteStore> favoriteStores = new ArrayList<>();
... 생략
Store
- FavoriteStore와 Store은 N : 1 관계인데 @ManyToOne에는 @BatchSize 선언 불가
- 따라서 Store은 Entity 자체에 걸어줌
- store의 storeImages에 @BatchSize 선언
@BatchSize(size = 100)
public class Store extends BaseTimeEntity {
... 생략
@OneToMany(mappedBy = "store")
@BatchSize(size = 100)
private List<StoreImage> storeImages = new ArrayList<>();
... 생략
이렇게 BatchSize 설정을 하면 Repository에서 Favorite만 가져와도 연관된 엔티티를 한 번에 조회하는 쿼리들을 자동 생성할 수 있게된다. 여기서 application에서 직접 접근하는 부분은 1번 뿐이고, 나머지는 모두 조회 쿼리가 자동 생성된다.
Repository
아래는 해당 로직의 service에서 호출되는 메소드이다. JPARepository에 직접 접근하는 Bean이다. 아래서 확인할 수 있듯이 현재 repository에서 접근하는 엔티티는 favorite 하나 뿐이다.
public class FavoriteFinder {
private final FavoriteRepository favoriteRepository;
public Favorite findById(final long id) {
return favoriteRepository.findById(id).orElseThrow(() -> new NotFoundException(FavoriteErrorCode.FAVORITE_NOT_FOUND));
}
...생략
그리고 service와 DTO에서 Favorite과 연관된 FavoriteStore, Store에 접근하는데, 이 때가 각각 favorite_store, store에 대한 일괄 조회 쿼리가 발생하는 시점이다. 위의 어노테이션을 선언한 후 API 호출을 통해 생성되는 쿼리문은 아래와 같다.
Hibernate:
select
f1_0.favorite_id,
f1_0.created_at,
f1_0.detail,
f1_0.name,
f1_0.updated_at,
f1_0.user_id
from
favorite f1_0
where
f1_0.favorite_id=?
Hibernate:
select
fs1_0.favorite_id,
fs1_0.favorite_store_id,
fs1_0.store_id
from
favorite_store fs1_0
where
fs1_0.favorite_id=?
Hibernate:
select
s1_0.store_id,
s1_0.category,
s1_0.created_at,
s1_0.heart_count,
s1_0.is_deleted,
s1_0.lowest_price,
s1_0.name,
s1_0.latitude,
s1_0.longitude,
s1_0.updated_at
from
store s1_0
where
s1_0.store_id = any (?)
Hibernate:
select
si1_0.store_id,
si1_0.store_image_id,
si1_0.created_at,
si1_0.image_url
from
store_image si1_0
where
si1_0.store_id = any (?)
@BatchSize 어노테이션을 선언함으로써, Repository에서 Favorite에만 접근하고도 자동으로 나머지 엔티티를 일괄적으로 가져오는 쿼리를 발생시킬 수 있었다. 또한 Store과 StoreImage를 fetch join 해서 가져오지 않으므로, 추후 Store에 대한 페이징 요구사항이 추가되어도 문제가 생기지 않을 것이다.
여기서 요구사항이 추가되었다.
🤨세번째 고민 - [가게의 생성일 순으로 내림차순 정렬] 요구사항 추가
Store을 생성일 내림차순으로 정렬해달라는 요구사항이 추가되었다.
위에서 보다시피 현재 Store은 내가 직접 Repository에 접근해서 가져오는 것이 아니라 BatchSize 설정을 통해 자동으로 select 쿼리가 나가고 있다. JPA의 BatchSize 설정으로 자동 실행되는 쿼리에 OrderBy 조건을 거는 것은 아예 불가능하다.
🤗세번째 고민 해결 - 직접 일괄 조회 쿼리 작성
결국 요구사항을 위해 Store를 조회할 때는 자동 생성되는 BatchSize 설정은 포기하고, Store를 일괄적으로 가져오는 JPQL 쿼리를 직접 작성하게 되었다.
그런데 여기서 좀 더 고려하고 싶은게 생겼다.
- 가게가 없는 족보도 있는데(favorite_store에 데이터 존재x), 이 경우 굳이 조회 쿼리가 나가게 해야하는 건지?
- 족보는 어차피 페이징이 필요없는데, 그럼 FavoriteStore(족보에 속한 가게)과는 left fetch join해서 두 개의 엔티티를 한 번의 쿼리로 미리 영속성 컨텍스트에 올려도 상관없을 것 같음
위의 두 가지 고려사항을 반영하여 구상한 데이터 조회 순서는 아래와 같다. 1번은 첫 번째 단계에서 했던 것처럼 left fetch join 한 뒤, favorite_store의 데이터 여부로 2, 3번 쿼리는 실행될 수도, 안 될 수도 있다. 2번은 정렬을 위해 직접 쿼리 작성, 그리고 3번은 기존처럼 BatchSize 설정을 통해 자동으로 실행되는 것을 구상했다.
Repository
족보에 가게가 없을 수 있으므로 JPQL로 Favorite과 FavoriteStore를 left fetch join했다. 그리고 별도의 로직을 통해 FavoriteStore이 empty면 2번, 3번 쿼리가 나가지 않도록 처리해주었고, 2번 쿼리의 경우 BatchSize의 자동 쿼리를 사용하지 않고 쿼리를 직접 작성하여 OrderBy를 통해 정렬해주었다.
// 1번 쿼리
@Query("select f from Favorite f left join fetch f.favoriteStores where f.id = :favoriteId")
Optional<Favorite> findByIdWithFavoriteStore(@Param("favoriteId") Long favoriteId);
// 2번 쿼리
@Query("select s from Store s where s.id in :ids and s.isDeleted = false order by s.createdAt desc")
List<Store> findAllByIdsAndIsDeletedIsFalseOrderByCreatedAtDesc(@Param("ids") List<Long> ids);
// 3번 쿼리는 BatchSize 설정을 통해 자동 실행
족보에 속한 가게가 있는 지에 대한 여부에 따라 발생하는 쿼리문은 각각 아래와 같다.
족보에 가게 없을 경우
Hibernate:
select
f1_0.favorite_id,
f1_0.created_at,
f1_0.detail,
fs1_0.favorite_id,
fs1_0.favorite_store_id,
fs1_0.store_id,
f1_0.image_type,
f1_0.name,
f1_0.updated_at,
f1_0.user_id
from
favorite f1_0
left join
favorite_store fs1_0
on f1_0.favorite_id=fs1_0.favorite_id
where
f1_0.favorite_id=?
족보에 가게 있을 경우
Hibernate:
select
f1_0.favorite_id,
f1_0.created_at,
f1_0.detail,
fs1_0.favorite_id,
fs1_0.favorite_store_id,
fs1_0.store_id,
f1_0.image_type,
f1_0.name,
f1_0.updated_at,
f1_0.user_id
from
favorite f1_0
left join
favorite_store fs1_0
on f1_0.favorite_id=fs1_0.favorite_id
where
f1_0.favorite_id=?
Hibernate:
select
s1_0.store_id,
s1_0.address,
s1_0.category,
s1_0.created_at,
s1_0.heart_count,
s1_0.is_deleted,
s1_0.lowest_price,
s1_0.name,
s1_0.latitude,
s1_0.longitude,
s1_0.updated_at
from
store s1_0
where
s1_0.store_id in (?, ?, ?)
and s1_0.is_deleted=false
order by
s1_0.created_at desc
Hibernate:
select
i1_0.store_id,
i1_0.store_image_id,
i1_0.created_at,
i1_0.image_url
from
store_image i1_0
where
i1_0.store_id = any (?)
이렇게 정렬과 추후 추가될 페이징 요구사항을 고려하여 경우에 따라 최대 3번, 최소 1번의 쿼리문으로 데이터를 가져올 수 있게 되었다.
🫠네번째 고민 - [족보에 가장 최근에 추가된 가게가 상단에 위치하도록 정렬] 요구사항 추가
정렬 요구사항이 바뀌었다. 이번엔 최근에 추가된 가게가 목록의 가장 상단으로 오게 바뀌었다.
이 때 큰 고민에 빠졌다. 왜냐하면… 가게가 최근에 추가되었는지에 대한 정보는 favorite_store 테이블에 있기 때문이다. 당연한 소리지만 store만으로는 족보에 언제 추가되었는지 알 수 없다.
근데 Store와 FavoriteStore는 그 문제의 @OneToMany 관계라 페이징을 위해 fetch join 할 수 없다. 그래서 FavoriteStore를 batch로 가져오면 application에서 코드로 직접 Store과 FavoriteStore의 store_id를 매칭시켜 favorite_store_id로 정렬해야한다…?
아무리 생각해도 각각 가져온 뒤 직접 코드로 매칭시켜 정렬하는 것은 마음에 들지 않았다…
🫠네번째 고민 해결
그래서 ERD를 쳐다보면서 어떻게 해야 favorite_store의 정보로 DB에서 정렬하면서 페이징 가능하도록 store를 가져올 수 있을지 고민하기 시작했다. 문제의 @OneToMany를 어떻게 해결하는게 좋을까?
우선 태초마을로 돌아가 fetch join을 왜 사용하는가부터 생각해보았다.
- JPA는 데이터를 영속성 컨텍스트에 캐싱해서 사용한다. 영속성에 데이터가 없을 경우에만 DB에서 조회한다. 이는 DB에 대한 접근을 줄여 성능을 최적화하기 위한 기능이다.
- 연관 관계인 엔티티를 지연 로딩으로 설정해놓아도, 그 엔티티의 필드에 접근하는 시점에서 영속성 컨텍스트에 올리기 위해 추가적인 쿼리가 나간다. 이것이 바로 N + 1 문제이다.
- JPA에는 N + 1 문제를 해결하기 위한 방법으로 fetch join을 제공한다. fetch join은 join된 엔티티를 [영속성 컨텍스트에 미리 올리기 위해] 사용하는 기능이다.
3번까지 생각하니 불현듯 그럼 FavoriteStore과 Store은 일반 join을 해도 상관없는 것 아닌가?하는 생각이 들었다.
왜냐하면 FavoriteStore은 이미 Favorite과 fetch join(기존의 1번 쿼리)을 통해 영속성 컨텍스트에 올라갔기 때문이다. 영속성 컨텍스트에 올린 엔티티는 굳이 fetch join 할 필요가 없다. 그냥 Store과 FavoriteStore를 일반 join을 하고, Store를 FavoriteStore의 id에 의해 정렬하면 된다. 이번에는 아래와 같은 형태로 데이터를 가져오도록 구상했다.
Repository
[세번째 고민 해결]에서 변경된 건 위의 사진에서 2번으로, 일반 join으로 Store와 FavoriteStore를 일반 join하고 FavoriteStore의 id로 Store를 내림차순 정렬한다.
// 2번 쿼리
@Query("select s from Store s join FavoriteStore fs on s.id = fs.store.id where fs in :favoriteStores and s.isDeleted = false order by fs.id desc ")
List<Store> findAllByFavoriteStoresAndIsDeletedIsFalseOrderByFavoriteStoreId(@Param("favoriteStores") List<FavoriteStore> favoriteStores);
최종적으로 API에 요청을 보냈을 때 쿼리는 아래와 같이 발생한다(족보에 가게 있는 경우).
Hibernate:
select
f1_0.favorite_id,
f1_0.created_at,
f1_0.detail,
fs1_0.favorite_id,
fs1_0.favorite_store_id,
fs1_0.store_id,
f1_0.image_type,
f1_0.name,
f1_0.updated_at,
f1_0.user_id
from
favorite f1_0
left join
favorite_store fs1_0
on f1_0.favorite_id=fs1_0.favorite_id
where
f1_0.favorite_id=?
Hibernate:
select
s1_0.store_id,
s1_0.address,
s1_0.category,
s1_0.created_at,
s1_0.heart_count,
s1_0.is_deleted,
s1_0.lowest_price,
s1_0.name,
s1_0.latitude,
s1_0.longitude,
s1_0.updated_at
from
store s1_0
join
favorite_store fs1_0
on s1_0.store_id=fs1_0.store_id
where
fs1_0.favorite_store_id in (?, ?, ?, ?, ?)
and s1_0.is_deleted=false
order by
fs1_0.favorite_store_id desc
Hibernate:
select
i1_0.store_id,
i1_0.store_image_id,
i1_0.created_at,
i1_0.image_url
from
store_image i1_0
where
i1_0.store_id = any (?)
이렇게 join을 통해 최근에 추가된(id 생성전략이 IDENTITY 라 순차적으로 생성)로 내림차순 정렬할 수 있게 되었다. 또한 fetch join이 아닌 단순히 일반 join이라 추후 store 페이징도 가능하다.
마치며
JPA가 굉장히 편한 ORM 기술임은 자명하지만 실제로 사용하는 단계에선 그 한계에 부딪히는 일이 잦은 것 같다. 특히 이번 게시글에서 다룬 @OneToMany 관계에서 fetch join 시 생기는 문제점과 N + 1 문제가 그렇다. 정말 JPA의 내부 동작 방식과 그 한계에 대해 자세하고 꼼꼼하게 알고 있어야 성능을 최적화한 코드를 작성할 수 있는 것 같다.
그리고 4번 고민같은 경우엔 fetch join이 join의 기본이 아닌데도 fetch join만을 고려하여 쿼리를 짜려한 것이 시간을 잡아먹은 가장 큰 이유같다. fetch join의 존재 목적은 잊고 무의식적으로 [JPA에서 join을 할 때 무조건 fetch join을 해야한다]라고 생각하고 있었다. 사실 단순 join을 바로 생각하면 바로 해결될 문제였는데, 너무 JPA 프레임워크에 종속된 사고를 하고 있는게 아닌가하는 생각이 든다. 프레임워크의 기능을 사용할 땐 무지성으로 사용하기보다는 왜 그 기능이 존재하는건지에 대한 것을 먼저 고려해야겠다.
지금 쿼리도 많은 과정과 고민을 거쳐서 나름 최적화한 코드이지만 좀 더 나은 방법이 없을지 계속 고민을 하고있다. 현재 고려하고 있는 건 Native Query를 이용해 필요한 필드만 DTO로 가져오는 방법을 고려하고 있는데, Repository에서 바로 DTO로 데이터를 가져오는 방식은 아직 우리 프로젝트에서 채택하지 않은 방식이고 사용해본 적이 없어서 명확한 사용 근거와 방식에 대해 더 공부해보려한다.
또한 다른 복잡한 API를 개발하면서 서브쿼리가 필요한 상황도 있었는데 JPQL나 Querydsl에서는 서브쿼리를 지원하지 않기 때문에 단순히 한 번의 쿼리로 해결할 수 있는 기능도 어쩔 수 없이 쿼리를 쪼개서 사용해야하는 경우도 있었다. 이러한 상황에선 추후 Native Query를 사용하거나 아니면 아예 MyBatis를 적용해서 직접 SQL문을 작성하는 것도 고민해봐야할 듯 하다.
'Server' 카테고리의 다른 글
우당탕탕 서브모듈 도입기 (1) | 2024.08.07 |
---|