🥲 ChatGPT를 믿은 죄로 핫픽스를 하다
숫자 하나만 수정하면 되는 간단한 작업을 마친 후, 할 일이 없던 나에게 단순 CRUD 작업의 이관 프로젝트가 왔다. 기존 어드민 API 중 일부를 인터널 서버로 옮기면 되는 일이었다.
처음에는 가벼운 프로젝트로 시작했지만, 예상보다 수정 범위와 과제가 복잡했다. 중간에 새로운 API가 추가되기도 하고 요구사항 변경도 있었다. 결국 API 운영 서버 오픈까지 3개월이라는 긴 시간이 소요되었다. 당시 겪었던 여러 고민과 문제 해결을 기록으로 남기고자 한다.
프로젝트를 진행하며 했던 고민들이 많은데, 한 포스트에 다 쓰기엔 분량이 있을 것 같아 주제를 나눠 작성하려 한다. 이번에는 API 이관 과정 중 DAO/DB 관련 트러블슈팅을 다룬다.
두근두근 신규 API
첫 신규 API를 맡았다. 단순 조회 API였지만 막막했다. 어떤 정보를 어떤 테이블에서 찾아야 하는지 모르는 상황이었기 때문이다. 다행히 팀원분들이 친절하게 도메인을 설명해주셨고, 내 나름대로 테이블 구조에 대해 정리할 수 있었다.
API 요구사항은 다음과 같았다.
- sku(아이템 고유식별자) 코드 목록을 받는다.
- 해당 코드에 대응하는 아이템이 포함된 모든 상품 상세정보 리스트를 내려준다.
- 상품별로 해당 코드에 대응하지 않는 아이템 리스트도 포함해 전달한다.
- 상품별로 해당 상품 이미지를 포함해 전달한다.
- 상품별로 해당 상품의 마감세일 여부를 포함해 전달한다.
요구사항을 분석해 정리한 결과, 관련 DB 테이블은 다음 6개였다.
- 상품
- 아이템
- 상품-아이템 맵
- 아이템-sku 맵
- 이미지-상품 맵
- 마감세일-상품 맵
어떻게 해야 효과적으로 테이블들을 매핑할 수 있는지 고민했다.
어차피 각 상품마다 관련 정보들을 한꺼번에 가져와야 하기 때문에 join을 사용할 수도 있었지만, 그대신 map 형태로 테이블별 값을 따로 가져와 조합했다.
class A {
//(...)
/**
* @param skusRequest SKU 코드 목록
* @return SKU 코드에 대응하는 아이템이 포함된 상품 목록
*/
@Transactional(readOnly = true)
public ProductsResponse getProducts(SkusRequest skusRequest) {
List<Product> products = getProductsMappedBy(Sets.newHashSet(skusRequest.getSkuCodes()));
Set<Long> productIds = getProductIdsMappedBy(products);
// Products 인의 productLineItem은 sku코드와 연결된 아이템만을 포함한다. 헤당 상품 속 아이템들을 모두 반환해야 하므로 재조회가 필요.
Set<Long> itemIds = getItemIdsMappedBy(productIds);
Map<Long, String> skuCodeByItemId = mapSkuCodeByItemId(itemIds);
Map<Long, String> imageUrlByItemId = mapImageUrlByItemId(itemIds);
Map<Long, Boolean> isCloseoutProductByProductId = mapIsCloseoutProductByProductId(productIds);
List<ProductResponse> productElements = products.stream()
.map(product -> getProductResponse(skuCodeByItemId, imageUrlByItemId, isCloseoutProductByProductId, product))
.collect(toList());
return ProductsResponse.builder()
.products(productElements)
.build();
}
/**
* @param itemIds 아이템 id 목록
* @return key : 아이템 id / value : 아이템 id와 연결된 ITEM 타입의 이미지 url 링크
*/
private Map<Long, String> mapImageUrlByItemId(Set<Long> itemIds) {
Map<Long, String> keyByProductId = productLineItemRepository.findAllByIdIn(itemIds).stream()
.collect(toMap(
ProductLineItem::getId,
ProductLineItem::getKey,
(initValue, changedValue) -> {
log.warn("해당 값으로 대체 : {}", changedValue);
return changedValue;
}));
Set<String> refKeys = new HashSet<>(keyByProductId.values());
List<Image> images = imageRepository.findAllByRefKeyInAndImageType(refKeys, Image.ImageType.ITEM);
return keyByProductId.entrySet().stream()
.collect(toMap(
Map.Entry::getKey,
entry -> getItemImageUrl(entry.getValue(), images)));
}
//(...)
}
다음 두 이유가 있었다.
- JOIN을 사용하면 쿼리가 복잡해지고 가독성이 떨어질 수 있다.
- 쿼리가 비즈니스 로직과 강결합한다. 수정 및 유지보수에 문제가 생길 수 있다.
물론 여러 쿼리를 사용하면 데이터베이스와의 통신 횟수가 증가하고, 전체적인 성능이 저하될 수 있다. 각 쿼리를 개별적으로 실행하기 때문에 데이터 일관성에 문제가 생길 수도 있고, 그만큼 비즈니스 로직에서 후처리가 필요해 귀찮다. 즉, 실수가 생길 여지가 생긴다.
왜 id가 null이지?
실제로 했던 실수가 하나 있다. 해시맵에 테이블에서 조회한 아이템-sku 값들을 넣은 후였다. 최종 리턴할 상품정보 속 아이템들에도 sku 코드가 매핑되어 있어야 했는데, 초기 sku로 조회한 아이템 이외의 아이템들의 경우 sku 정보가 포함되지 않고 있었다.
베타 배포 이후 확인했고, 급하게 핫픽스 커밋을 날렸었다. 이에 아이템 레포지토리에 쿼리를 두번(처음에 한번, 이후 매핑용으로 한번) 냘리는 방식으로 해결했다.
class A {
@Transactional(readOnly = true)
public ProductsResponse getProducts(SkusRequest skusRequest) {
List<Product> products = getProductsMappedBy(Sets.newHashSet(skusRequest.getSkuCodes()));
Set<Long> productIds = getProductIdsMappedBy(products); // 1
// Products 안의 productLineItem은 sku코드와 연결된 아이템만을 포함한다. 헤당 상품 속 아이템들을 모두 반환해야 하므로 재조회가 필요.
Set<Long> itemIds = getItemIdsMappedBy(productIds);
Map<Long, String> skuCodeByItemId = mapSkuCodeByItemId(itemIds); // 2
(...)
}
(...)
/**
* @param skuCodes SKU 코드 목록
* @return SKU 코드에 대응하는 아이템이 포함된 상품 목록
*/
private List<Product> getProductsMappedBy(Set<String> skuCodes) {
Set<Long> itemIdsLinkedToSku = itemSkuMapRepository.findAllBySkuCodeIn(skuCodes).stream()
.map(ItemSkuMapEntity::getItemId)
.collect(Collectors.toSet());
return productRepository.findAllByProductLineItemIdIn(itemIdsLinkedToSku);
}
/**
* @param itemIds 아이템 id 목록
* @return key : 아이템 id / value : 아이템 id와 연결된 Sku Code
*/
private Map<Long, String> mapSkuCodeByItemId(Set<Long> itemIds) {
return itemSkuMapRepository.findAllByItemIdIn(itemIds).stream()
.collect(toMap(
ItemSkuMapEntity::getItemId,
ItemSkuMapEntity::getSkuCode,
(initValue, changedValue) -> {
log.warn("해당 값으로 대체 : {}", changedValue);
return changedValue;
}));
}
}
이외에도 파라미터로 들어온 sku에 매핑되는 아이템이 여러 개 포함된 상품의 경우, 포함된 아이템 수만큼 상품이 중복으로 리턴되는 버그도 해결했다. 비즈니스에 대한 명확한 이해 없이 코드를 짰을 때의 위험성을 깨닫는 시간이었다.
꿀팁🍯 : 특정 json 데이터들의 값을 편하게 보고싶다면 크롬 개발자도구 콘솔에
a = {...}
형식으로 입력해보자.손쉽게 데이터를 볼 수 있다.
ChatGPT를 믿은 죄로 핫픽스를 하다
Querydsl을 사용해본 첫 테스크인 만큼 우역곡절이 많았다. 처음 커스텀 쿼리가 필요할 땐 Spring Data Jpa의 @Query
어노테이션을 사용했다. 팀 코드 다수가 사용하고 취업 전 진행한 플젝에서도 자주 사용해 익숙했기 떄문이었다.
그런데 팀장님께 커스텀 쿼리는 Querydsl을 쓰는걸 권장한다는 이야기를 들었다. 다음 이유들 때문이었다.
- 타입 안전 : 쿼리를 작성할 때 컴파일 시점에 타입 검사가 가능하므로 에러를 빠르게 발견할 수 있다.
- 객체지향 : 엔티티 클래스의 변경에 따라 자동으로 쿼리 코드가 업데이트된다.
- 동적 쿼리 생성: 조건에 따라 동적으로 쿼리를 생성할 수 있어, 유연한 쿼리 구성이 가능하다.
- 응집성 : IN절 내 빈값 확인 등, DAO 단에서 필요한 여러 사전/사후 검증들을 묶어서 진행할 수 있다.
우아한테크코스 당시, 팀 프로젝트 내에서 Querydsl은 금지 기술 이었다. 그렇기에 해당 기술에 대해 A부터 Z까지 아는게 아무것도 없었다. 이 당시 막 ChatGPT의 놀라움에 대해 여기저기 얘기가 나오던 시기였다. ChatGPT가 알려준 코드를 그대로 사용했다.
class ProductRepositoryImpl {
@Override
public List<Product> findAllByProductLineItemIdIn(Set<Long> itemIds) {
return from(product)
.join(product.productLineItemMaps, productLineItemMap).fetchJoin()
.where(product.productLineItemMaps.any().productLineItemId.in(itemIds))
.distinct()
.fetch();
}
}
야호, 잘 돌아가잖아?
운영 배포 전날 바로 베타 머지한 후 기쁜 마음으로 잠들었다. 비극의 시작이었다.
다음날 팀원이 any() 의 작동방식을 물어봤다. 어찌어찌 구글링으로 찾아낸 방법이라고 말씀드리니 실행계획을 보자고 했다. 확인 결과 서브쿼리가 날아갔고 있었으며 인덱스를 안탔다. 풀스캔이었다. 아직 사용되지 않는 API였기에 망정이지, 실사용 쿼리에 대한 변경이었으면 큰일날 뻔 했다.
실행계획, 너어는 진짜…
사실 부끄럽게도, 당시 실행계획에 대해 들어만 봤지, 실제로 쿼리 최적화를 위해 직접 확인해본 적은 없었다. 이때 실행계획 보는 법을 처음 공부했고, Querydsl 강의도 신청했다. 아래는 당시 위키에 적어둔 실행계획을 보는 법에 대한 기록이다.
-
key
- 뜻: 사용된 인덱스의 종류
- 예시: ‘PRIMARY’, ‘idx_users_email’, ‘idx_users_created_at’
-
type
-
뜻: 인덱스 타입
-
접근 방식 설명 const 상수값을 이용해 테이블의 단일 행을 조회, 결과는 항상 1행이다. ALL 전체 행 스캔, 테이블의 데이터 전체에 접근한다.(비효율적) index 인덱스 스캔, 테이블의 특정 인덱스의 전체 엔트리에 접근한다. eq_ref 이전 테이블과 조인할 때, 현재 테이블에서 단일 행만 읽어온다. ref 고유 키가아닌 인덱스에 대한 등가비교, 여러 개 행에 접근할 가능성이 있다. range 인덱스 특정 범위의 행에 접근한다. (주의 필요)
-
-
rows
- 뜻: 실행된 쿼리의 결과로 반환된 레코드 수
- 예시: 10, 100, 1000
- (반환된 레코드 수가 적을수록 쿼리의 실행 속도가 더 빠르다.)
-
filtered
- 뜻: 조건에 맞게 필터링된 레코드 비율 (0부터 100까지의 값)
- 예시: 50.00, 70.00, 90.00
- (값이 높을수록, 조건절에 해당하는 레코드 수가 많아서 더 효율적인 쿼리)
-
extra
- 뜻: 추가적인 정보
- 예시: ‘Using where’, ‘Using index’, ‘Using temporary’, ‘Using filesort’, ‘Using join buffer (Block Nested Loop)’
결론 : Querydsl로 쿼리를 짜기 전 DB 콘솔에서 EXPLAIN을 통해 쿼리 실행계획을 확인하자.
- key, type, rows, filtered, extra의 각 값의 뜻을 파악하자.
- 인덱스가 걸려있지 않을 시 테이블 풀 스캔이 발생한다. DB 부하를 주니 조심하자.
- slowQuery 로그에서도 해당 쿼리들을 확인해 수정할 수 있다.
In절의 데이터를 파티셔닝 해야한다고?
그렇게 어찌저찌 위 쿼리에 대한 커밋을 올린 후였다. in절 안에 128개 이상 데이터가 들어올 경우 인덱스를 안탈 수 있다는 코드리뷰가 달렸다.
옵티마이저는 쿼리를 실행하기 전에 다양한 실행 계획 중 최적의 계획을 선택한다. 즉 비용이 가장 낮은 실행 계획을 선택한다. IN 절이 특정 기준치(128 등)를 넘길 경우 in_clause_parameter_padding에 의해 풀 테이블 스캔이 실행될 수 있다. 실제로 기존 쿼리들에 200개 이상의 값을 넣고 조회하니 5~6개의 데이터가 in절에 있을 때와 달리 인덱스를 타지 않는 것을 확인했다.
팀 내에서는 이미 해당 케이스에 대한 컨벤션이 있었다. in절 내에 128개 미만 값만 들어오도록 하는 것이었다. 직접 파티셔닝을 하는 방법과, API 사용처에 리스트 파라미터 크기를 제한하는 방법이 있었다. 우린 미리 유관부서와 협의했기 때문에 후자를 택했다.
class ProductRepositoryImpl {
@Override
public List<Product> findAllByProductLineItemIdIn(Set<Long> itemIds) {
List<Long> productIds = from(product)
.select(product.id)
.join(product.productLineItemMaps, productLineItemMap)
.where(productLineItemMap.productLineItemId.in(itemIds))
.fetch();
if (CollectionUtils.isEmpty(productIds)) {
return Collections.emptyList();
}
return from(product)
.join(product.productLineItemMaps, productLineItemMap).fetchJoin()
.join(productLineItemMap.productLineItem).fetchJoin()
.where(product.id.in(productIds))
.distinct()
.fetch();
}
}
만약 커스텀 레포 내에서 in절 내 파티셔닝까지 진행한다면 다음과 같은 방식일 것이다.
class ProductRepositoryImpl {
@Override
public List<Product> findAllByProductLineItemIdIn(Set<Long> itemIds) {
List<Long> productIds = Lists.partition(new ArrayList<>(itemIds), PARTITION_SIZE).stream()
.map(itemIdPartition ->
from(product)
.select(product.id)
.join(product.productLineItemMaps, productLineItemMap)
.where(productLineItemMap.productLineItemId.in(itemIds))
.fetch())
.flatMap(Collection::stream)
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(productIds)) {
return Collections.emptyList();
}
return Lists.partition(productIds, PARTITION_SIZE).stream()
.map(productIdPartition ->
from(product)
.join(product.productLineItemMaps, productLineItemMap).fetchJoin()
.join(productLineItemMap.productLineItem).fetchJoin()
.where(product.id.in(productIds))
.distinct()
.fetch())
.flatMap(Collection::stream)
.collect(Collectors.toList());
}
}
다음과 같은 파티셔닝을 위해서라도 커스텀 쿼리를 Querydsl로 작성하는 것은 확실한 이점이 있다.
fin
아직 DB에 대해 잘 모른다는 것을 깨달았다.
이전까지 쿼리를 짜온 방식은 단순했다. 쉬운 SQL 문법과 Spring Data JPA에서 메소드 이름으로 단순 쿼리를 생성한 후 비즈니스 로직과 연결하는게 전부였던 것 같다. 반면 현업에서는 in절 내 빈값 처리부터 인덱스 및 성능문제 등 고려해야 할 부분이 많았다.
이제 내가 짜야 할 코드는 단순히 돌아가는 코드가 아닌, 쿼리 최적화를 통해 성능상의 문제가 없어 실시간성의 수백만 데이터들을 정상적으로 처리할 수 있는 코드여야 한다. 이를 위해선 MySQL을 포함한 DB 동작방식 및 여러 이슈들, JPA 등 ORM 매핑방식, Querydsl등의 동적쿼리를 위한 도구들에 대한 이해가 필수적이다. 앞으로도 계속 공부해나가려 한다.