๋ฐ˜์‘ํ˜•

ํšŒ์›๊ฐ€์ž…, ๊ธ€์“ฐ๊ธฐ ๋“ฑ ์ž…๋ ฅ Form ํŽ˜์ด์ง€์—์„œ ์‹ค์ˆ˜๋กœ ๋‹ค๋ฅธ ๋งํฌ๋ฅผ ํด๋ฆญํ•˜๊ฑฐ๋‚˜, ์ €์žฅํ•˜๋Š” ๊ฒƒ์„ ๊นœ๋นกํ•˜๊ณ  ๋‹ค๋ฅธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋ฉด ์œ ์ € ์ž…์žฅ์—์„œ ๋ฌด์ฒ™ ์งœ์ฆ๋‚˜๋Š” ์ƒํ™ฉ์ด ๋œ๋‹ค. ์ฒ˜์Œ๋ถ€ํ„ฐ ํผ์„ ๋‹ค์‹œ ์ž‘์„ฑํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. ์ž„์‹œ ์ €์žฅ ๊ธฐ๋Šฅ์ด ์žˆ๋‹ค๋ฉด ๊ดœ์ฐฎ์ง€๋งŒ, ๊ทธ๋ ‡์ง€ ์•Š๋‹ค๋ฉด ํŽ˜์ด์ง€ ์ดํƒˆ์— ๋Œ€ํ•œ Confirm ๋‹จ๊ณ„๋ฅผ ์ถ”๊ฐ€ํ•ด์„œ ์‚ฌ์šฉ์„ฑ์„ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ๋‹ค. ์‹ค์ œ๋กœ ์—ฌ๋Ÿฌ ์›น ์„œ๋น„์Šค์—์„œ Form ํŽ˜์ด์ง€ ์ดํƒˆ์‹œ ‘์ €์žฅํ•˜์ง€ ์•Š์€ ๋‚ด์šฉ์€ ์‚ญ์ œ๋œ๋‹ค’๋Š” ์•ˆ๋‚ด ๋ฌธ๊ตฌ๋ฅผ ๋„์šด๋‹ค.

 

๊ตฌํ˜„ ๋ฐฉ๋ฒ•


NextJS ์ž์ฒด์ ์œผ๋กœ ์—ฌ๋Ÿฌ ๋ผ์šฐํŠธ ์ด๋ฒคํŠธ๋ฅผ ์ œ๊ณตํ•˜๋Š”๋ฐ routeChangeStart๋Š” ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์„ ์‹œ์ž‘ํ•  ๋•Œ ํŠธ๋ฆฌ๊ฑฐ๋˜๋Š” ์ด๋ฒคํŠธ๋‹ค. ํŽ˜์ด์ง€๋ฅผ ์–ธ๋กœ๋“œ(์ƒˆ๋กœ๊ณ ์นจ)ํ•  ๋• window ๊ฐ์ฒด์—์„œ ๋ฐœ์ƒํ•˜๋Š” beforeunload ์ด๋ฒคํŠธ๋ฅผ ์ด์šฉํ•˜๋ฉด ๋œ๋‹ค. ๋Œ€๋žต ์•„๋ž˜ 4๊ฐ€์ง€ ๋‹จ๊ณ„๋ฅผ ํ†ตํ•ด ๊ตฌํ˜„ํ•œ๋‹ค.

 

๋‹จ๊ณ„๋ณ„ ๊ณผ์ •

  1. ์œ ์ €๊ฐ€ Form์„ ์ €์žฅํ•˜์ง€ ์•Š์€ ์ƒํƒœ์—์„œ ๋‹ค๋ฅธ ํŽ˜์ด์ง€ ์ด๋™(๋ผ์šฐํŠธ ๋ณ€๊ฒฝ) ์‹œ๋„
    a. โš ๏ธ Prev/Next ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅธ ์‹œ์ ์— ์ด๋™ํ•  ํŽ˜์ด์ง€ ์ฃผ์†Œ๋กœ ์ฃผ์†Œ์ฐฝ ๊ฒฝ๋กœ๊ฐ€ ๋ฐ”๋€๋‹ค
    b. ๐Ÿ—„ ๋•Œ๋ฌธ์— ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์ทจ์†Œ๋ฅผ ๋Œ€๋น„ํ•ด ํ˜„์žฌ ๊ฒฝ๋กœ๋ฅผ ์ž„์‹œ ์ €์žฅํ•ด๋‘”๋‹ค
  2. routeChangeStart ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰
  3. ์œ ์ €์˜ ์„ ํƒ์— ๋”ฐ๋ผ ์•„๋ž˜ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๋Š” callback ์‹คํ–‰
    a. ํ™•์ธ true : ๋‹ค๋ฅธ ํŽ˜์ด์ง€๋กœ ์ด๋™(ํŽ˜์ด์ง€ ์ดํƒˆ)
    b. ์ทจ์†Œ false : ํŽ˜์ด์ง€ ์ด๋™ ์ทจ์†Œ
  4. ์œ ์ €๊ฐ€ ์ทจ์†Œ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํ˜„์žฌ ํŽ˜์ด์ง€์— ๋จธ๋ฌผ๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ๋‹ค๋ฉด…
    a. 1-b ์—์„œ ์ž„์‹œ ์ €์žฅํ•œ ๊ฒฝ๋กœ๋กœ ๋ณต๊ตฌ — router.replace(...)
    b. routeChangeError ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ์„œ ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์ทจ์†Œ

 

์ฃผ์˜ํ•  ์ 

์ž„์‹œ ์ €์žฅํ•œ ๊ฒฝ๋กœ๋กœ router.replace๋ฅผ ์‹คํ–‰ํ•˜๋ฉด(4-a) ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์„ ์‹œ๋„ํ•˜๋ฏ€๋กœ routeChangeStart ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. ๊ทธ๋Ÿผ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์‹คํ–‰๋ผ์„œ ์œ„ ๊ณผ์ •์„ ๋˜ ๋‹ค์‹œ ๋ฐ˜๋ณตํ•˜๋ฉฐ ๋ฌดํ•œ ๋ Œ๋”๋ง๋œ๋‹ค. ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๋ ค๋ฉด ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์„ ์ทจ์†Œํ–ˆ์„ ๋•Œ๋งŒ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์‹คํ–‰ํ•˜๋„๋ก ํ•ด์•ผ ํ•œ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด isKilledRouter ๊ฐ™์€ ๋‚ด๋ถ€ ์ƒํƒœ๋กœ ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์ทจ์†Œ ์—ฌ๋ถ€๋ฅผ ๊ด€๋ฆฌํ•œ๋‹ค.

isKilledRouter ๊ฐ’ (๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์ทจ์†Œ ์—ฌ๋ถ€) routeChangeStart ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰ ์—ฌ๋ถ€
true โŒ
false โœ…

์œ ์ €๊ฐ€ ํŽ˜์ด์ง€ ์ด๋™์„ ์ทจ์†Œํ•œ ํ›„ ๋‹ค์‹œ ํŽ˜์ด์ง€ ์ด๋™์„ ์‹œ๋„ํ•˜๋Š” ์ƒํ™ฉ๋„ ๊ณ ๋ คํ•ด์•ผ ํ•œ๋‹ค. ์ฆ‰, 1~4๋ฒˆ ๋‹จ๊ณ„๊นŒ์ง€ ํ•œ ์‚ฌ์ดํด ์‹คํ–‰์„ ๋งˆ์นœ ๋’ค์—๋„ ๋‹ค์Œ ์‚ฌ์ดํด์—์„œ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์‹คํ–‰๋  ์ˆ˜ ์žˆ๋„๋ก isKilledRouter ์ƒํƒœ๋ฅผ false๋กœ ๋ฐ”๊ฟ”์ฃผ๋Š” ์ž‘์—…์ด ํ•„์š”ํ•˜๋‹ค.

 

push, replace ์ฐจ์ด์  ๋น„๊ต

๐Ÿ’ก router.replace๋Š” history stack์— ์žˆ๋Š” ๊ฐ€์žฅ ๋งˆ์ง€๋ง‰ ์š”์†Œ๋ฅผ ์ด๋™ํ•  ๊ฒฝ๋กœ๋กœ ๋Œ€์ฒดํ•œ๋‹ค. ์ฆ‰ ํ˜„์žฌ ํŽ˜์ด์ง€๋ฅผ ์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๋กœ ๋ฎ์–ด์“ด๋‹ค. ๊ทธ ์™ธ์—” router.push์™€ ๋™์ผํ•˜๋‹ค. — ์ฐธ๊ณ  ํฌ์ŠคํŒ…

  • push ์ด์šฉ์‹œ
    • ์ด๋™ : '/''/profiles''/profiles/smith'
    • ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์—์„œ ๋’ค๋กœ๊ฐ€๊ธฐ ํ›„ : 'profiles'
    • history stack: ['/', 'profiles', 'profiles/smith']
  • replace ์ด์šฉ์‹œ
    • ์ด๋™ : '/' (push) → '/profiles' (replace) → '/profiles/smith'
    • ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์—์„œ ๋’ค๋กœ๊ฐ€๊ธฐ ํ›„ : '/'
    • history stack: ['/', 'profiles/smith']

 

๊ตฌํ˜„ ํ•˜๊ธฐ


์ฐธ๊ณ  ๋‚ด์šฉ

  • router.events๋Š” Router ์ƒ์„ฑ์ž ๋ฐ useRouter ํ›…์ด ๋ฐ˜ํ™˜ํ•˜๋Š” ์ธ์Šคํ„ด์Šค๋กœ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋‹ค.
  • then ํ›„์† ๋ฉ”์„œ๋“œ์˜ ์ฝœ๋ฐฑ์€ ๋งˆ์ดํฌ๋กœํƒœ์Šคํฌํ์— ๋“ค์–ด๊ฐ”๋‹ค๊ฐ€ ์ฝœ์Šคํƒ์ด ๋น„์—ˆ์„ ๋•Œ ์‹คํ–‰๋œ๋‹ค
  • NextJS์—์„œ ๋ผ์šฐํŒ…์ด ๋ฐœ์ƒํ•˜๋ฉด ๋ฐ์ดํ„ฐ ํŒจ์นญ ๋ฉ”์„œ๋“œ(get...Props)๊ฐ€ ์‹คํ–‰๋œ๋‹ค. ๋ฐ์ดํ„ฐ ํŒจ์นญ ๋ฉ”์„œ๋“œ๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๊ณ  URL์„ ๋ณ€๊ฒฝํ•˜๊ณ  ์‹ถ์„ ๋• shallow: true ์˜ต์…˜์„ ๋„˜๊ธด๋‹ค. — ์ฐธ๊ณ ๊ธ€
  • next/router๋Š” Context API๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. router.* ๋ฉ”์„œ๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋ฉด ๋‚ด๋ถ€ ์ƒํƒœ ๊ฐ’์„ ๋ณ€๊ฒฝํ•˜๋ฏ€๋กœ ๋ฆฌ๋ Œ๋”๋ง์ด ๋ฐœ์ƒํ•œ๋‹ค. window.history.replaceState๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ฆฌ์•กํŠธ์™€ ์ƒ๊ด€์—†๋Š” ํžˆ์Šคํ† ๋ฆฌ ๊ฐ์ฒด๋ฅผ ํ•ธ๋“ค๋งํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ฆฌ๋ Œ๋”๋ง์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. — ์ฐธ๊ณ ๊ธ€

 

router.replace ์‚ฌ์šฉ

์ปค์Šคํ…€ํ›…์ด ๋ฐ›๋Š” shouldStopNavigation ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ true๋ผ๋ฉด routeChangeStart ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋“ฑ๋ก๋œ๋‹ค. ๊ทธ ํ›„ ์œ ์ €๊ฐ€ ํŽ˜์ด์ง€ ์ด๋™(๋ผ์šฐํŠธ ๋ณ€๊ฒฝ)์„ ์‹œ๋„ํ•˜๋ฉด onRouteChange ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์‹คํ–‰ํ•œ๋‹ค.

 

โถ onRouteChange() ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰ — ํŽ˜์ด์ง€ ์ด๋™(์œ ์ € ์•ก์…˜์œผ๋กœ ์ธํ•ด)

  • ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์„ ์ทจ์†Œํ•œ ์ ์ด ์—†์œผ๋ฏ€๋กœ if ์กฐ๊ฑด ํ†ต๊ณผ ํ›„ callback ํ•จ์ˆ˜ ์‹คํ–‰
  • ์œ ์ €๊ฐ€ ์ทจ์†Œ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŽ˜์ด์ง€์— ๋จธ๋ฌด๋ฅด๊ธฐ๋กœ ํ–ˆ๋‹ค๋ฉด if(!confirmed) ์กฐ๊ฑด๋ฌธ ์‹คํ–‰(2๋ฒˆ~)

 

โท recoverToCurrentPath() ํ•จ์ˆ˜ ์‹คํ–‰

  • router.replace(...) ์‹คํ–‰
  • then ๋ฉ”์„œ๋“œ ํ•ธ๋“ค๋Ÿฌ๋Š” ๋งˆ์ดํฌ๋กœํƒœ์Šคํฌ ํ์—์„œ ๋Œ€๊ธฐ

 

โธ killRouterEvent() ํ•จ์ˆ˜ ์‹คํ–‰

  • isKilledRouter ์ƒํƒœ true๋กœ ๋ณ€๊ฒฝ
  • routeChangeError ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ ์‹œํ‚ด

 

โน onRouteChange() ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰ — currentPath ๋ณต๊ตฌ

  • 2๋ฒˆ์—์„œ ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ(router.replace)์„ ์‹œ๋„ํ–ˆ์œผ๋ฏ€๋กœ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์‹คํ–‰๋จ
  • 3๋ฒˆ์—์„œ isKilledRouter ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ–ˆ์œผ๋ฏ€๋กœ if ์กฐ๊ฑด ํ†ต๊ณผ ๋ชปํ•˜๊ณ  ์ข…๋ฃŒ

 

๐Ÿ” โ‘ท currentPath ๋ณต๊ตฌ์— ๋Œ€ํ•œ ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์™„๋ฃŒ

๐Ÿ” โ‘ด ํŽ˜์ด์ง€ ์ด๋™์— ๋Œ€ํ•œ โ‘ถ ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์—๋Ÿฌ ๋ฐœ์ƒ ๋ฐ clean-up & effect ์‹คํ–‰

 

โบ then() ๋ฉ”์„œ๋“œ ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰

  • isKilledRouter ์ƒํƒœ false๋กœ ๋ณ€๊ฒฝ — ๋‹ค์Œ ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์‹œ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ์‹คํ–‰์„ ์œ„ํ•ด
  • then ํ•ธ๋“ค๋Ÿฌ์˜ res ์ธ์ž๋Š” replace ์„ฑ๊ณต ์—ฌ๋ถ€๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” boolean ๊ฐ’ ๋ฐ˜ํ™˜

 

๐Ÿ’ก /profile/edit ํŽ˜์ด์ง€์—์„œ ๋’ค๋กœ๊ฐ€๊ธฐ(/profile) ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ณ  ์ฝ˜์†” ์ถœ๋ ฅ ๋‚ด์šฉ ์ž‘์„ฑ

// ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜
const getWindow = () => (typeof window !== 'undefined' ? window : null);
// useNavigationInterceptor.ts

const useNavigationInterceptor = (
  shouldStopNavigation: boolean, // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ๊ด€๋ จ ์ด๋ฒคํŠธ ๊ฐ์ง€ ์—ฌ๋ถ€(Hook ํ™œ์„ฑ ์—ฌ๋ถ€)
  callback: () => boolean, // *ex) () => confirm('...')*
) => {
  const router = useRouter();
  const currentPath = useRef(router.asPath); // ํ˜„์žฌ ๊ฒฝ๋กœ ์ €์žฅ
  const isKilledRouter = useRef(false); // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์ทจ์†Œ ์—ฌ๋ถ€

  const killRouterEvent = useCallback(() => {
    console.log(`killRouterEvent | path: ${location.pathname}`); // โ‘ถ /profile
    isKilledRouter.current = true;
    Router.events.emit('routeChangeError'); // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์—๋Ÿฌ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ด
    throw 'Abort route change. You can ignore this error'; // ์ฝ˜์†”์— ์—๋Ÿฌ ์ถœ๋ ฅ
  }, []);

  const recoverToCurrentPath = useCallback(() => {
    console.log(`beforeReplace | path: ${location.pathname}`); // โ‘ต /profile
    router
      .replace(currentPath.current, undefined, { shallow: true })
      .then((res: boolean) => {
        // replace ์ž‘์—… ์„ฑ๊ณต ์—ฌ๋ถ€
        isKilledRouter.current = false;
        console.log(`afterReplace | path: ${location.pathname}`); // โ‘ธ /profile/edit (๊ฒฝ๋กœ ๋ณต๊ตฌ๋จ)
      });
  }, [router]);

  // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์‹œ ์‹คํ–‰๋  ํ•ธ๋“ค๋Ÿฌ
  const onRouteChangeStart = useCallback(
    (toUrl: string) => {
      // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์„ ์‹œ์ž‘ํ•˜๋Š” ์‹œ์ ์— ์ด๋™ํ•  ๊ฒฝ๋กœ๊ฐ€ ์ฃผ์†Œ์ฐฝ์— ๋ฏธ๋ฆฌ ๋ฐ˜์˜๋จ
      console.log(`onRouteChange | path: ${location.pathname}`); // โ‘ด /profile, โ‘ท /profile
      if (isKilledRouter.current) return;

      const confirmed = callback(); // Confirm ์ฐฝ ์„ ํƒ ๊ฒฐ๊ณผ

      if (!confirmed) {
        recoverToCurrentPath();
        killRouterEvent();
      }
    },
    [callback, killRouterEvent, recoverToCurrentPath],
  );

  // ์ƒˆ๋กœ๊ณ ์นจ์‹œ ์‹คํ–‰๋  ํ•ธ๋“ค๋Ÿฌ
  const onBeforeUnload = useCallback((e: BeforeUnloadEvent) => {
    e.preventDefault(); // HTML ํ‘œ์ค€ ๋ช…์„ธ ์ค€์ˆ˜(unload ์ด๋ฒคํŠธ ์ทจ์†Œ)
    e.returnValue = ''; // for Chrome
  }, []);

  useEffect(() => {
    if (shouldStopNavigation) {
      console.log(`effect | path: ${location.pathname}`); // (4-3) /profile/edit
      getWindow()?.addEventListener('beforeunload', onBeforeUnload); // ์ƒˆ๋กœ๊ณ ์นจ
      Router.events.on('routeChangeStart', onRouteChangeStart); // ํŽ˜์ด์ง€ ์ด๋™

      return () => {
        console.log(`cleanup | path: ${location.pathname}`); // (4-2) /profile/edit
        getWindow()?.removeEventListener('beforeunload', onBeforeUnload);
        Router.events.off('routeChangeStart', onRouteChangeStart);
      };
    }
  }, [onBeforeUnload, onRouteChangeStart, shouldStopNavigation]);
};
// useNavigationInterceptor.ts ํ›… ์‚ฌ์šฉ

// isDirty๋Š” react-hook-form์˜ formState๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ƒํƒœ๋‹ค.
// ํ•„๋“œ๋ฅผ ์ˆ˜์ •ํ–ˆ๋‹ค๋ฉด isDirty ๊ฐ’์€ true๊ฐ€ ๋œ๋‹ค
useNavigationInterceptor(isDirty, () => {
  return confirm('Warning! You have unsaved changes');
});

 

๋ฆฌํŒฉํ† ๋ง — history.replaceState ์‚ฌ์šฉ โญ๏ธ

currentPath๋ฅผ ๋ณต๊ตฌํ•  ๋•Œ router.replace() ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๋ฆฌ๋ Œ๋”๋ง์„ ํŠธ๋ฆฌ๊ฑฐํ•˜๋ฏ€๋กœ effect ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋œ๋‹ค. ๋˜ํ•œ onRouteChange ์ด๋ฒคํŠธ ๋ฐœ์ƒ์œผ๋กœ ํ•ธ๋“ค๋Ÿฌ ์ค‘๋ณต ํ˜ธ์ถœ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด isKilledRouter ๊ฐ™์€ ์ถ”๊ฐ€์ ์ธ ๋‚ด๋ถ€ ์ƒํƒœ๋„ ํ•„์š”ํ•˜๋‹ค.

 

History API์˜ replaceState() ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์กฐ๊ธˆ ๋” ์ตœ์ ํ™”ํ•  ์ˆ˜ ์žˆ๋‹ค. replaceState() ๋ฉ”์„œ๋“œ๋Š” ๋ฆฌ์•กํŠธ์™€ ์ƒ๊ด€์—†๋Š” ํžˆ์Šคํ† ๋ฆฌ ๊ฐ์ฒด๋ฅผ ํ•ธ๋“ค๋งํ•˜๋ฏ€๋กœ ๋ฆฌ๋ Œ๋”๋ง์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋˜ํ•œ popstate ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค๊ธฐ ๋•Œ๋ฌธ์— onRouteChange ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋Š” ํ˜ธ์ถœํ•˜์ง€ ์•Š๋Š”๋‹ค. ๊ทธ๋Ÿผ isKilledRouter ์ƒํƒœ๋„ ํ•„์š” ์—†์–ด์ง„๋‹ค.

addEventListener('popstate', (event) => {
  /* replaceState() ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ์‹œ ์‹คํ–‰ */
});

 

ํ•œํŽธ currentPath์—๋Š” router.asPath ๊ฐ’์„ ์ €์žฅํ•˜๊ณ  ์žˆ๋‹ค. asPath๋Š” locale๊ณผ basePath๋ฅผ ํฌํ•จํ•˜์ง€ ์•Š์ง€๋งŒ, router.replace()๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด locale์„ ๊ทธ๋Œ€๋กœ ์œ ์ง€ํ•˜๋ฏ€๋กœ ํฐ ๋ฌธ์ œ๊ฐ€ ์—†๋‹ค.

 

๋ฐ˜๋ฉด replaceState()๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด asPath ๊ฐ’์ด ๊ทธ๋Œ€๋กœ ์ฃผ์†Œ์ฐฝ์— ๋“ค์–ด๊ฐ€๋ฏ€๋กœ locale์„ ํฌํ•จํ•˜์ง€ ์•Š๊ฒŒ ๋œ๋‹ค. asPath ๋Œ€์‹  location.pathname์„ ์‚ฌ์šฉํ•˜๋ฉด locale์„ ํฌํ•จํ•œ ์ฃผ์†Œ์ฐฝ์œผ๋กœ ๋ณ€๊ฒฝ๋˜๋„๋ก ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

// useNavigationInterceptor.ts ๊ฐœ์„  ์ฝ”๋“œ

// (์ œ๊ฑฐ) const isKilledRouter = useRef(false);
const currentPath = useRef(router.asPath); // router.asPath ๊ฐ’์œผ๋กœ ์ž„์‹œ ํ• ๋‹น

const killRouterEvent = useCallback(() => {
  // (์ œ๊ฑฐ) isKilledRouter.current = true;
  Router.events.emit('routeChangeError');
  throw 'Abort route change. You can ignore this error';
}, []);

const recoverToCurrentPath = useCallback(() => {
  // (๊ต์ฒด) router.replace(...)
  getWindow()?.history.replaceState(null, '', currentPath.current);
}, []);

const onRouteChangeStart = useCallback(
  (toUrl: string) => {
    // (์ œ๊ฑฐ) if (isKilledRouter.current) return;
    const confirmed = callback();

    if (!confirmed) {
      recoverToCurrentPath();
      killRouterEvent();
    }
  },
  [callback, killRouterEvent, recoverToCurrentPath],
);

// (์ถ”๊ฐ€) useEffect๋Š” ํ™”๋ฉด์— ํŽ˜์ธํŒ…์„ ๋งˆ์นœ ํ›„ ์‹คํ–‰๋˜๋ฏ€๋กœ window ๊ฐ์ฒด์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” ์‹œ์ 
useEffect(() => {
  const window = getWindow();
  if (window) currentPath.current = window.location.pathname;
}, []);
๋”๋ณด๊ธฐ
// useNavigationInterceptor.ts

// ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜
const getWindow = () => (typeof window !== 'undefined' ? window : null);

const useNavigationInterceptor = (
  shouldStopNavigation: boolean, // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ๊ด€๋ จ ์ด๋ฒคํŠธ ๊ฐ์ง€ ์—ฌ๋ถ€(Hook ํ™œ์„ฑ ์—ฌ๋ถ€)
  callback: () => boolean, // ex) () => confirm('...')
) => {
  const router = useRouter();
  const currentPath = useRef(router.asPath); // router.asPath ๊ฐ’์œผ๋กœ ์ž„์‹œ ํ• ๋‹น

  const killRouterEvent = useCallback(() => {
    console.log(`killRouterEvent | path: ${location.pathname}`); // โ‘ถ /profile/edit
    Router.events.emit('routeChangeError'); // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์—๋Ÿฌ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ด
    throw 'Abort route change. You can ignore this error'; // ์ฝ˜์†”์— ์—๋Ÿฌ ์ถœ๋ ฅ
  }, []);

  const recoverToCurrentPath = useCallback(() => {
    getWindow()?.history.replaceState(null, '', currentPath.current);
    console.log(`beforeReplace | path: ${location.pathname}`); // โ‘ต /profile/edit
  }, []);

  // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์‹œ ์‹คํ–‰๋  ํ•ธ๋“ค๋Ÿฌ
  const onRouteChangeStart = useCallback(
    (toUrl: string) => {
      // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์„ ์‹œ์ž‘ํ•˜๋Š” ์‹œ์ ์— ์ด๋™ํ•  ๊ฒฝ๋กœ๊ฐ€ ์ฃผ์†Œ์ฐฝ์— ๋ฏธ๋ฆฌ ๋ฐ˜์˜๋จ
      console.log(`onRouteChange | path: ${location.pathname}`); // โ‘ด /profile
      const confirmed = callback(); // Confirm ์ฐฝ ์„ ํƒ ๊ฒฐ๊ณผ

      if (!confirmed) {
        recoverToCurrentPath();
        killRouterEvent();
      }
    },
    [callback, killRouterEvent, recoverToCurrentPath],
  );

  // ์ƒˆ๋กœ๊ณ ์นจ์‹œ ์‹คํ–‰๋  ํ•ธ๋“ค๋Ÿฌ
  const onBeforeUnload = useCallback((e: BeforeUnloadEvent) => {
    e.preventDefault(); // HTML ํ‘œ์ค€ ๋ช…์„ธ ์ค€์ˆ˜(unload ์ด๋ฒคํŠธ ์ทจ์†Œ)
    e.returnValue = ''; // for Chrome
  }, []);

  useEffect(() => {
    if (shouldStopNavigation) {
      getWindow()?.addEventListener('beforeunload', onBeforeUnload); // ์ƒˆ๋กœ๊ณ ์นจ
      Router.events.on('routeChangeStart', onRouteChangeStart); // ํŽ˜์ด์ง€ ์ด๋™

      return () => {
        getWindow()?.removeEventListener('beforeunload', onBeforeUnload);
        Router.events.off('routeChangeStart', onRouteChangeStart);
      };
    }
  }, [onBeforeUnload, onRouteChangeStart, shouldStopNavigation]);

  useEffect(() => {
    const window = getWindow();
    if (window) currentPath.current = window.location.pathname;
  }, []);
};

 

์œ„์ฒ˜๋Ÿผ ์ฝ”๋“œ๋ฅผ ๊ฐœ์„ ํ•˜๊ณ  ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ / ์ƒˆ๋กœ๊ณ ์นจ์„ ํ•ด๋ณด๋ฉด ์•„๋ž˜ 3๊ฐœ ๋ฉ”์„œ๋“œ๋งŒ ์‹คํ–‰๋œ๋‹ค. (/profile/edit ํŽ˜์ด์ง€์—์„œ ํผ ์ˆ˜์ • ํ›„ ์ €์žฅํ•˜์ง€ ์•Š๊ณ  ๋‹ค๋ฅธ ํŽ˜์ด์ง€ ์ด๋™์„ ์‹œ๋„ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •)

  1. onRouteChangeStart/profile/edit/profile
  2. recoverToCurrentPath/profile/profile/edit
  3. killRouterEvent/profile/edit

 

Confirm ํ™•์ธ์„ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์ฃผ์†Œ์ฐฝ ๋ณ€๊ฒฝ

๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์„ ์‹œ์ž‘ํ•˜๋ฉด ์ด๋™ํ•  ๊ฒฝ๋กœ๊ฐ€ ์ฃผ์†Œ์ฐฝ์— ๋ฏธ๋ฆฌ ๋ฐ˜์˜๋œ๋‹ค. ํ˜„์žฌ๋Š” Confirm ์ฐฝ ์ทจ์†Œ๋ฅผ ๋ˆ„๋ฅด๋ฉด ํ˜„์žฌ ํŽ˜์ด์ง€ ๊ฒฝ๋กœ๋กœ ๋˜๋Œ๋ฆฌ๊ณ (์ด๋•Œ ์ฃผ์†Œ์ฐฝ์ด ๋ฐ”๋€Œ๋Š”๊ฑธ ์œ ์ €๊ฐ€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค), ํ™•์ธ์„ ๋ˆ„๋ฅด๋ฉด ๋ฏธ๋ฆฌ ๋ฐ˜์˜๋œ ๊ฒฝ๋กœ๊ฐ€ ์œ ์ง€๋œ๋‹ค.

 

๋งŒ์•ฝ Confirm ์ฐฝ ์ทจ์†Œ ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„๋• ์ฃผ์†Œ์ฐฝ์— ์•„๋ฌด ๋ณ€ํ™”๊ฐ€ ์—†๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ด๊ณ , ํ™•์ธ์„ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์ฃผ์†Œ์ฐฝ์ด ์ด๋™ํ•  ๊ฒฝ๋กœ๋กœ ๋ฐ”๋€Œ๋„๋ก ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด, replaceState ์‹คํ–‰ ์ˆœ์„œ๋ฅผ ์‚ด์ง ๋ฐ”๊ฟ”์ฃผ๋ฉด ๋œ๋‹ค.

 

๐Ÿ’ก routeChangeStart ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋Š” โถurl โท{ shallow }๋ฅผ ์ธ์ž๋กœ ๋ฐ›๋Š”๋‹ค. url์€ ์ด๋™ํ•  ๊ฒฝ๋กœ๋ฅผ ๋‚˜ํƒ€๋‚ธ๋‹ค — ์ •ํ™•ํžˆ ๋งํ•˜๋ฉด ํ˜„์žฌ ๋ธŒ๋ผ์šฐ์ €์— ํ‘œ์‹œ๋˜๊ณ  ์žˆ๋Š” baseUrl์„ ํฌํ•จํ•œ ์ฃผ์†Œ.

// useNavigationInterceptor.ts

// recoverToCurrentPath -> changePath๋กœ ์ด๋ฆ„ ๋ณ€๊ฒฝ
const changePath = useCallback((url: string) => {
  getWindow()?.history.replaceState(null, '', url);
}, []);

const onRouteChangeStart = useCallback(
  (toUrl: string) => {
    // ์ด๋™ํ•  ๊ฒฝ๋กœ๊ฐ€ ์ฃผ์†Œ์ฐฝ์— ๋ฏธ๋ฆฌ ๋ฐ˜์˜๋จ (์ด๋™ํ•  ๊ฒฝ๋กœ === toUrl)
    changePath(currentPath.current); // ํ˜„์žฌ ํŽ˜์ด์ง€ ๊ฒฝ๋กœ๋กœ ๋ณ€๊ฒฝ
    const confirmed = callback(); // Confirm ์ฐฝ ์„ ํƒ ๊ฒฐ๊ณผ

    if (!confirmed) killRouterEvent();
    else changePath(toUrl); // ํ™•์ธ ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์ด๋™ํ•  ๊ฒฝ๋กœ๋กœ ๋ณ€๊ฒฝ
  },
  [callback, changePath, killRouterEvent],
);

 

์ตœ์ข… ์ฝ”๋“œ โšก๏ธ

๋”๋ณด๊ธฐ
// useNavigationInterceptor.ts

// ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜
const getWindow = () => (typeof window !== 'undefined' ? window : null);

const useNavigationInterceptor = (
  shouldStopNavigation: boolean, // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ๊ด€๋ จ ์ด๋ฒคํŠธ ๊ฐ์ง€ ์—ฌ๋ถ€(Hook ํ™œ์„ฑ ์—ฌ๋ถ€)
  callback: () => boolean, // ex) () => confirm('...')
) => {
  const router = useRouter();
  const currentPath = useRef(router.asPath); // router.asPath ๊ฐ’์œผ๋กœ ์ž„์‹œ ํ• ๋‹น

  const killRouterEvent = useCallback(() => {
    Router.events.emit('routeChangeError'); // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ ์—๋Ÿฌ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ด
    throw 'Abort route change. You can ignore this error'; // ์ฝ˜์†”์— ์—๋Ÿฌ ์ถœ๋ ฅ
  }, []);

  const changePath = useCallback((url: string) => {
    getWindow()?.history.replaceState(null, '', url);
  }, []);

  // ๋ผ์šฐํŠธ ๋ณ€๊ฒฝ์‹œ ์‹คํ–‰๋  ํ•ธ๋“ค๋Ÿฌ
  const onRouteChangeStart = useCallback(
    (toUrl: string) => {
      // ์ด๋™ํ•  ๊ฒฝ๋กœ๊ฐ€ ์ฃผ์†Œ์ฐฝ์— ๋ฏธ๋ฆฌ ๋ฐ˜์˜๋จ (์ด๋™ํ•  ๊ฒฝ๋กœ === toUrl)
      changePath(currentPath.current); // ํ˜„์žฌ ํŽ˜์ด์ง€ ๊ฒฝ๋กœ๋กœ ๋ณ€๊ฒฝ
      const confirmed = callback(); // Confirm ์ฐฝ ์„ ํƒ ๊ฒฐ๊ณผ

      if (!confirmed) killRouterEvent();
      else changePath(toUrl); // ํ™•์ธ ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์ด๋™ํ•  ๊ฒฝ๋กœ๋กœ ๋ณ€๊ฒฝ
    },
    [callback, changePath, killRouterEvent],
  );

  // ์ƒˆ๋กœ๊ณ ์นจ์‹œ ์‹คํ–‰๋  ํ•ธ๋“ค๋Ÿฌ
  const onBeforeUnload = useCallback((e: BeforeUnloadEvent) => {
    e.preventDefault(); // HTML ํ‘œ์ค€ ๋ช…์„ธ ์ค€์ˆ˜(unload ์ด๋ฒคํŠธ ์ทจ์†Œ)
    e.returnValue = ''; // for Chrome
  }, []);

  useEffect(() => {
    if (shouldStopNavigation) {
      getWindow()?.addEventListener('beforeunload', onBeforeUnload); // ์ƒˆ๋กœ๊ณ ์นจ
      Router.events.on('routeChangeStart', onRouteChangeStart); // ํŽ˜์ด์ง€ ์ด๋™

      return () => {
        getWindow()?.removeEventListener('beforeunload', onBeforeUnload);
        Router.events.off('routeChangeStart', onRouteChangeStart);
      };
    }
  }, [onBeforeUnload, onRouteChangeStart, shouldStopNavigation]);

  useEffect(() => {
    const window = getWindow();
    if (window) currentPath.current = window.location.pathname;
  }, []);
};

 

๋ ˆํผ๋Ÿฐ์Šค


 


๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”
๋ฐ˜์‘ํ˜•