πŸ”Ž ν‚€μ›Œλ“œ 기반 검색과 μΆ”μ²œ 검색어

박상은·2022λ…„ 4μ›” 16일
0

πŸ›’ blemarket πŸ›’

λͺ©λ‘ 보기
4/7

ν•΄λ‹Ή ν¬μŠ€νŠΈλŠ” Next.js와 Prisma 기반으둜 μž‘μ„±ν–ˆμŠ΅λ‹ˆλ‹€.

πŸ”— μƒν’ˆ κ²Œμ‹œκΈ€κ³Ό ν‚€μ›Œλ“œ 관계

κΈ°λ³Έμ μœΌλ‘œλŠ” κ²Œμ‹œκΈ€κ³Ό ν‚€μ›Œλ“œλŠ” 각각이 μ—¬λŸ¬ 개λ₯Ό μ†Œμœ ν•  수 μžˆμ–΄μ„œ N:M κ΄€κ³„μž…λ‹ˆλ‹€.

μ²˜μŒμ—λŠ” N:M을 μƒκ°ν•˜κ³  κ΅¬ν˜„ν•˜κΈ°κ°€ μ–΄λ €μ›Œμ„œ 각 κ²Œμ‹œκΈ€μ— ν‚€μ›Œλ“œλ“€μ„ 곡백 κΈ°μ€€μœΌλ‘œ λΆ„λ¦¬ν•œ λ¬Έμžμ—΄λ‘œ λ„£μ–΄μ„œ κ΅¬ν˜„ν–ˆμŠ΅λ‹ˆλ‹€.
μ •μƒμ μœΌλ‘œ λ™μž‘μ€ ν•˜μ§€λ§Œ μƒν’ˆμ΄ λŠ˜μ–΄λ‚ μˆ˜λ‘ DB에 뢀담이 μƒκΈ°κ²Œ 될거라고 μƒκ°ν•΄μ„œ 이후에 N:M κ΄€κ³„λ‘œ μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€.

πŸ“ƒ λͺ¨λΈ μ •μ˜

N:M 관계이기 λ•Œλ¬Έμ— ν•„μ—°μ μœΌλ‘œ 쀑간 ν…Œμ΄λΈ”μ΄ μƒκΈ°κ²Œ λ©λ‹ˆλ‹€.
쀑간 ν…Œμ΄λΈ”μ€ λͺ…μ‹œμ μœΌλ‘œ 이름을 μ§€μ •ν•˜κ³  μ»¬λŸΌμ„ μΆ”κ°€ν•΄ 쀄 수 μžˆμ§€λ§Œ ꡳ이 ν•„μš”ν•˜μ§€μ•ŠκΈ° λ•Œλ¬Έμ— μ•”μ‹œμ μœΌλ‘œ μƒμ„±ν•˜λ„λ‘ λ†”λ’€μŠ΅λ‹ˆλ‹€.

μ•„λ§ˆλ„ productId, keywordId 두 개의 μ»¬λŸΌμ„ κ°–κ³  μžˆλŠ” ν…Œμ΄λΈ”μ΄ μƒκΈΈκ²λ‹ˆλ‹€.

model Product {
  id          Int      @id @default(autoincrement())
  name        String   @db.VarChar(30)
  price       Int
  description String   @db.MediumText
  image       String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  keywords Keyword[]
  # ... μƒλž΅
}

model Keyword {
  id        Int      @id @default(autoincrement())
  keyword   String   @unique @db.VarChar(20)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  products Product[]
}

πŸ›« λ°±μ—”λ“œ

1. μƒν’ˆ 생성 μ‹œ ν‚€μ›Œλ“œ 등둝

ν‚€μ›Œλ“œλ“€μ€ 곡백을 κΈ°μ€€μœΌλ‘œ λΆ„λ¦¬ν•΄μ„œ μž…λ ₯λ°›μŠ΅λ‹ˆλ‹€.

// async ν•¨μˆ˜ 내뢀에 μžˆλ‹€κ³  κ°€μ •
// ν‚€μ›Œλ“œ 생성 or μ°Ύκ³  μƒν’ˆκ³Ό μ—°κ²°
const keywordsPromise = (keywords as string).split(" ").map((keyword) =>
  prisma.keyword.upsert({
    create: {
      keyword,
      products: {
        connect: {
          id: createdProduct.id,
        },
      },
    },
    update: {
      products: {
        connect: {
          id: createdProduct.id,
        },
      },
    },
    where: {
      keyword,
    },
  })
);

await Promise.allSettled(keywordsPromise);

2. ν‚€μ›Œλ“œ κΈ°μ€€ κ²Œμ‹œκΈ€ 검색

// async ν•¨μˆ˜ 내뢀에 μžˆλ‹€κ³  κ°€μ •
// "records"λŠ” μƒν’ˆμ˜ μƒνƒœ κ΄€λ ¨ν•œ 것이라 λ¬΄μ‹œν•΄λ„ 됨
const keyword = req.query.keyword + "";
const findKeywordProducts = await prisma.product.findMany({
  take: offset,
  skip: page * offset,
  where: {
    keywords: {
      some: {
        keyword,
      },
    },
    records: {
      some: {
        OR: condition,
      },
    },
  },
  include: {
    _count: {
      select: {
        answers: true,
        records: true,
      },
    },
    records: {
      select: {
        kinds: true,
      },
    },
  },
  orderBy: [
    {
      createdAt: "desc",
    },
  ],
});

3. μΆ”μ²œ 검색어 κ°€μ Έμ˜€κΈ°

ν•˜λ‚˜ μ΄μƒμ˜ κΈ€μžλ₯Ό ν¬ν•¨ν•˜λŠ” ν‚€μ›Œλ“œκ°€ μžˆλŠ” κ²½μš°μ— λͺ¨λ‘ κ°€μ Έμ˜€λŠ” λ‘œμ§μž…λ‹ˆλ‹€.

// async ν•¨μˆ˜ 내뢀에 μžˆλ‹€κ³  κ°€μ •
const keywords = await prisma.keyword.findMany({
  where: {
    keyword: {
      contains: keyword,
    },
  },
  select: {
    keyword: true,
  },
});

πŸ›¬ ν”„λ‘ νŠΈ

1. λ””λ°”μš΄μŠ€ 적용

λ””λ°”μš΄μŠ€λž€ μ—°μ†ν•΄μ„œ 같은 μš”μ²­μ΄ λ“€μ–΄μ˜¬ 경우 제일 λ§ˆμ§€λ§‰ μš”μ²­λ§Œ μœ νš¨ν•˜κ²Œ μ²˜λ¦¬ν•˜λŠ” 방법

μ‚¬μš©μžκ°€ μ—°μ†μ μœΌλ‘œ ν‚€μ›Œλ“œλ₯Ό μž…λ ₯ν•  λ•ŒλŠ” μš”μ²­μ„ 보내지 μ•Šκ³  0.3초 정도 μž…λ ₯이 λ©ˆμΆ”λ©΄ ν˜„μž¬κΉŒμ§€ μž…λ ₯ν•œ λ¬Έμžμ—΄μ„ λ°±μ—”λ“œλ‘œ μ „μ†‘ν•΄μ„œ μž…λ ₯ν•œ λ¬Έμžμ—΄μ„ ν¬ν•¨ν•˜λŠ” λͺ¨λ“  ν‚€μ›Œλ“œλ“€μ„ λ°˜ν™˜ν•΄μ€λ‹ˆλ‹€.

// debounceκ°€ true일 κ²½μš°μ— ν‚€μ›Œλ“œ 검색 μš”μ²­μ„ 보내도둝 μ²˜λ¦¬ν•¨

// 2022/04/16 - ν‚€μ›Œλ“œ 검색 μ‹œ λ””λ°”μš΄μŠ€ μ μš©ν•  λ•Œ μ‚¬μš©ν•˜λŠ” λ³€μˆ˜ - by 1-blue
const [debounce, setDebounce] = useState(false);
// 2022/04/16 - ν‚€μ›Œλ“œ 검색 μ‹œ λ””λ°”μš΄μŠ€ μ μš©ν•  λ•Œ μ‚¬μš©ν•˜λŠ” ν•¨μˆ˜ - by 1-blue
const debounceKeyword = useCallback(() => setDebounce(true), [setDebounce]);
// 2022/04/16 - ν‚€μ›Œλ“œ 검색 μ‹œ λ””λ°”μš΄μŠ€ 적용 - by 1-blue
useEffect(() => {
  const timerId = setTimeout(debounceKeyword, 300);

  return () => {
    clearTimeout(timerId);
    setDebounce(false);
  };
}, [debounceKeyword, keyword, setDebounce]);

2. 포컀싱 문제 ν•΄κ²°

<input />에 포컀슀λ₯Ό μ£Όλ©΄ μΆ”μ²œ 검색어가 λžœλ”λ§λ˜κ³ , 포컀슀λ₯Ό λ– λ‚˜λ©΄ onFocus와 onBlurλ₯Ό μ΄μš©ν•΄μ„œ μΆ”μ²œ 검색어듀이 사라지도둝 λ§Œλ“€μ—ˆμŠ΅λ‹ˆλ‹€.
ν•˜μ§€λ§Œ μΆ”μ²œ 검색어λ₯Ό ν΄λ¦­ν•˜λŠ” μˆœκ°„μ— μΆ”μ²œ κ²€μƒ‰μ–΄μ˜ <input />μ—μ„œ ν¬μ»€μŠ€κ°€ μ—†μ–΄μ Έμ„œ μΆ”μ²œ 검색어가 μ‚¬λΌμ Έμ„œ 검색이 μ•ˆλ˜λŠ” λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.

문제λ₯Ό ν•΄κ²°ν•˜κΈ° μœ„ν•΄μ„œ 이전에 μ‚¬μš©ν•΄λ΄€λ˜ λͺ¨λ‹¬ μ˜μ—­μ™Έ 클릭 μ‹œ λ‹«λŠ” κΈ°λŠ₯을 κ°€μ Έμ™€μ„œ μ μš©ν–ˆμŠ΅λ‹ˆλ‹€.
<input />κ³Ό μΆ”μ²œ ν‚€μ›Œλ“œλ₯Ό κ°μ‹ΈλŠ” wrapper에 넣어두고 wrapperλ₯Ό μ œμ™Έν•œ μ˜μ—­μ„ ν΄λ¦­ν•˜λ©΄ μΆ”μ²œ 검색어가 사라지도둝 κ΅¬ν˜„ν•΄μ„œ 문제λ₯Ό ν•΄κ²°ν–ˆμŠ΅λ‹ˆλ‹€.

// 2022/04/16 - ν‚€μ›Œλ“œ κ°’ - by 1-blue ( react-hook-form의 useForm() )
const keyword = watch("keyword");
// 2022/04/16 - ν‚€μ›Œλ“œ 포컀슀 μ—¬λΆ€ 및 κ΄€λ ¨ ν‚€μ›Œλ“œ 보여쀄지 κ²°μ •ν•  λ³€μˆ˜ - by 1-blue
const [isFocus, setIsFocus] = useState(false);
// 검색창과 μΆ”μ²œ 검색어λ₯Ό μžμ‹μœΌλ‘œ κ°€μ§€λŠ” element
const wrapperRef = useRef<HTMLDivElement>(null);
// 2022/04/16 - μ˜μ—­μ™Έ 클릭 μ‹œ μΆ”μ²œ ν‚€μ›Œλ“œ μ°½ λ‹«κΈ° - by 1-blue
const handleCloseModal = useCallback(
  (e: any) => {
    if (
      isFocus &&
      (!wrapperRef.current || !wrapperRef.current.contains(e.target))
    )
      setIsFocus(false);
  },
  [isFocus, setIsFocus, wrapperRef]
);
// 2022/04/16 - μΆ”μ²œ ν‚€μ›Œλ“œ μ°½ λ‹«κΈ° 이벀트 등둝 - by 1-blue
useEffect(() => {
  setTimeout(() => window.addEventListener("click", handleCloseModal), 0);
  return () => window.removeEventListener("click", handleCloseModal);
}, [handleCloseModal]);

// ... λ‚˜λ¨Έμ§€ μƒλž΅

0개의 λŒ“κΈ€