From f86c35df0a05ec1b25140b74f135c169c2bcd340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 18:23:25 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20404=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 5 ++++ src/assets/image/not-found-cat.webp | Bin 0 -> 23032 bytes src/main.tsx | 6 +++- src/pages/NotFound/index.tsx | 41 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/assets/image/not-found-cat.webp create mode 100644 src/pages/NotFound/index.tsx diff --git a/src/App.tsx b/src/App.tsx index 200ab5ad..6dc7e6d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,7 +38,9 @@ import ManagedMemberList from './pages/Manager/ManagedMemberList'; import ManagedRecruitment from './pages/Manager/ManagedRecruitment'; import ManagedRecruitmentForm from './pages/Manager/ManagedRecruitmentForm'; import ManagedRecruitmentWrite from './pages/Manager/ManagedRecruitmentWrite'; +import NotFoundPage from './pages/NotFound'; import Schedule from './pages/Schedule'; +import ServerErrorPage from './pages/ServerError'; import Timer from './pages/Timer'; import MyPage from './pages/User/MyPage'; import Profile from './pages/User/Profile'; @@ -119,6 +121,9 @@ function App() { + + } /> + } /> diff --git a/src/assets/image/not-found-cat.webp b/src/assets/image/not-found-cat.webp new file mode 100644 index 0000000000000000000000000000000000000000..48ae82d1ed38753909edc0dbb4692bfc6e6b65a7 GIT binary patch literal 23032 zcmV)qK$^c&Nk&HSSpWc6MM6+kP&il$0000G0002x0RTe*06|PpNHsbD01*jEZX`L9 z1o!X~S?Tfr2S=93oLO;<=>G)ZZ!!Szl?^SDB=Me9EMbxi76UMF5v=Q^Z43>2-_OED z!cXeH@5d@+M7{y`U8}ShKZA6v(OUZi%2S#bpCR*AR6;AOWz#swuZ(wKHNY2V6*}Xv~0u%@|o3!V!+%~{~ zkxn$)Zsay=+cqEp_lB|3?%mF2dzMLj*;#G(S>8U8M~#zClV8+mHs^q+uj`|YUVxd` z^-Z_kX6ZaKMSc*rqmzsokd&F0Ubyxk8FL_Mx9vx+d5~hvg2dfJ_#v1i8S@~KfGbdX z@2oMLRXIi_(K|`otJHF_MC%@|1awG%!uM+dBB^+}l1TvAwja=q+aK}thy7<%2jn)A z97z%Bzt>eQs&`L3BVq#hP3FZBVBrebo1`_WsD}2TsPxvSRI-iwj*?SQctYi>z7e^s zyx=vIIi<~dgEw*=Wl8}OID*1h4SOU~$0*cn5!E>xi?9O}goz;7CsI_v;t)!eZCmQp z<<#O(@Cu}&%m%$f61q&nt#&Tx1as&uWj>%+RBC1ks2(Y8#syT-V#{(a39VR>bzUv! zc_+z<`9RaC`OFeNHN^0-Dt#Vr{-OC)|Nm^;HihU7qtqC}&Hmr_^SYnse$MN@NYCbD zUkeB6>X3ng^eUi2z!)GYphrYTK>GUlS0jLMJ7HlU8DQaLf&-$CNgZAK33yAouLR<& z04HyS06ZWdTFKo-(6Boo8W;n!)?;9F&_xdzz`)znQH=(=Q^jEkL$RYA31qtHAz}h1 zKvT4Bv$pMtf5S2GJa78$o9!6Dz*&WZZZYpcKp6G>fAR`Q#X@4>p;3?#^I%}nDCmyD zKm?pL3ev2?KqOEx@Kt@qoQe_Zp)=6ySacRn0aOeeGyWF?VekOtnE)<$-3S;@K&MYtFD@@Le{}t6|&r4HR)GVSi#CyrMLUj6vtQD z#k)&oZGZ2=+I|lvTpeE(vaXLTjkXFORv~Nq(@2)9(CV-XSzCptn5{zA!TYMv>i&UG z;q6xk)1}(2LauyO$eLo5-d6=HYx|=KS8)~E>i8%|NmjWPx7RmCrF-eDvtQ@=5#LcB2M=dPUhL_pW?Be?6IHbDWj<> zQgPbnbf)J}aY{r#^q{91#>i#f=vkAv{+S-{Ngno*9{q`@YOM9_&*Qw$=9EeD=B}|O zd^wk{Jg+5E40!6teE0``m;qB23flI($TN<}s#rk8SzY=Wge?AvP*W0`8mJe8Lmd3R z@BgG5dvdntxwrF*falhjKraCCb8=dlaOK`=3HCx(FMvVXjOUpC2F^8ZoSk{!zD)sD zBcqXkB|?q*rwUW#7KxBUFa-AA8T$`pJnO;l_nzDN#?t9s++|#71>4y67Rxv_9&LLe z%0*v7lu-=o*aLNMLqtsdL*C`lJ2Y-iwdZABR6azH-8j}8y*K3^qtHZd1Qcv!aWQN# z6yp%H+}T5<8zUVr`wGqu)KHx0p=Vh$i{E=_GUr+}_9D(lR&naZh+@(s?&|JGHy-Nx zUeRKuL@`Qmv&bP=M3mbLO|G5@!7s|*y6Rb=MZ~Oc(K~p6bk2~tyOwSosS-UGY4tf~ z&MUjr;V4Fg1T~gDXiq#CPjLDn$thkPv6A%2ie3UEiisZTF7ECS*(RlRYi(M0DyLCc zJ)~mb%hs$ViBQ!&rJuR>8Ool0eWRbY%hNsiuyfeK)~`?A0I zufOas-sS)KuiL(jNqx-Z-rgQ!l;{KoDjwdrG7RbbySLL(MT&%^InYCc$|{0}bOs=y z0FsZnWyI`jFK!A*ivsr5FRyl~qk8lB^sKUKv7xlZuJw+Vy7(|;L;s^=u_b{BL_^cY zF-5ppgy01XNQ;kV*?=uWIv4gt&MGT!d0|0_TRnzHkdwxF)UiPVIYDMMVaNrrowwGF z+EvFow$3ff02D940-MMY<#CRE*k_VEoAP+gd^M$|mYUUFMc#I6k){ohELR(pR2D%j z2SMZX-txl_Ie%k4-WbOKq29 z>3aEc$z`i!+Z0P-K_vnIg7haXd>Y{vw$ic}zYlv0;%BGo+=7Zt_z z@qD;j*L}@#eb`GZsA>)>lE(IWLN~MLQPdT8#di7QuP^ob`E@S~NC<+EjYA?Rw5zMe zC5Iow)0XX_t3uUrLy%I%a9;23%J)=t#ImBIvV9F-?@q>^BljFtd%bsfCruy+o)U9Y z=fbJgjykr>%dKB;mzU3frQTMl- zTlaSFA+;4zr0nrz?3vtqYZuK0^*c`Okz<_qA1q=c4}B%-i8IZDomWn+*FwG4s)K6>-?f$tqlzxV7}bWc>R z!gOnDYigOMi?@d6F3~oka_Im=Ix>=R-haiLyRTSb7VrWjh_?noRHw`qa+KBu#j+QI zB0$YYwH@BQJ753tdO3?(UG6Ges5hFXesZ^)EugZ?S&0S_%H4ZhgQvT@H+|WJ^n&>G z1uwQt$WgPWAYDXHlsI~c%8z)60Z*#s*tS_msnMh*$&h}^g-D!WM`SVv+&@0t?L*bw zIzuWh9 zUmwmorf4WCXfH%mK()^~h8x{TP@76m3hnN%Rc&KSCoU>0D|&X%r4%jkiL1e2$biUl z_~BthjC1UD#ykJ_R-&|!p48C;RJI8>N=X<<#4Lh|Z@=rGI_@^As_N_Oi)SrGmn458 z^U*fWk;ynQJ7hnM`G@+|dm;&e$e{$4)fgqAM%P5^7LWm~JomTX-v8NGyt$5bobw}1 zGzOddTf;q128%DpoQ=3CXkAY69ys4_p~iUFi!Nfp&5HZ`H`jeWKCJ5)XFfB2R;F7j z^_p}?7e}iRmO!ovBA~s-gc>E7W3%W21Acn7UY|y9`+APZaO7vkF>(0}Wn$;H9mgVs zAYDXr00LxwAk@eZ9Lp?Zi1P!F@xbfXmvh`h5`Nak_Q-Nq*ST%4-&Zvf)M>g9Kw+sc zBhxwoIS7={SfB36`SRE*aGFn=<*Jg~XKmXNp{)eTNfjYbS5gvU_>!P%4=st$@$~8g z)$^U_V^JCzeUb!7D&^`$+rvZD*3d4BpdJPM&wLM(#S86L+5OwsDrLJR$QBoSDStynFU-19mJY1Kfmq|6Bv>8wiRc-sj-J8d; z9eUD)O1z;E!%7mQ3pvNb2VQ-}&sx{>x7;6hyEXQs`S6-&*_>M5yWATAMhQ7YzYqgU za9V`-SJ#Ix7^x4p%FjNclRn+U)yMp{HE#+V> zm({Pn?JKU_tPm7-uc0AE(uF+Nw?FXa%@gP8(s)lKr9`Q|fA>RguaLAz94oVuFeE7$ z&AIyF;fzS?eHpS_?rq%ttT$h9pAfWd5VQ?1qREN6Gd}QU$LD!{T2HFp8%SWy=NaGo z;bGmg8d5ftap8s5CnEPRxW9|U#*vcu2bQX*uV26zkB`44N+Us|6Y4^Xs5YqWpTBX> zHM{MM2tIi#mzwJ~4W^JVQ3Du%vOln`sfygu>Hg%@tE zZzv2cymG7AKX7@@c}BHOyjugY62zPJ&GFvs8-ZZMLkq4LDbI)dFQ^&^Nl#nvTW9y> zLQP+eOK~ooTX=Dz0?zyUhu2#)pg`U)QDh^oMGW5CY89T&;l%@%A@0xp^@SS^^^Ps+ zau*#V{my%{ixMVOhZaE9d3ryeULHNWZ+qVgq^?=#@N&Fwe~X zSzH*si;A1!Rv+)(x6{(LVZ|T(y=n2VKHeAG1f_Wg+oXXEFJ%7>AG?GQe|Jc+r%?^W z+l=yAMicMjkRfD^@Y9`zP3u4UL;N5im(*0v&!d(aq`lR9sRi88j{8@^t;zgV_YV+z zYB>S(#O7p_RP$a^8ADq1`4#sAfg*qY4-fPFK;(&yKJ5Xc-q%V2&iw<|C>b)m{^qb@ zY#RWRc^Qdkr|+y3QL}wMy*U%*4eD1qhYPc{$Vg0FY1R%wCCU3r;Hu!f#=f4z3v{at z6AJHhhzbzbK6@n#&N6fI(yw6jdQhR$}+^VtfZ$At~8$s0>_U^GSbC#sM$I{G}oE+PwkZw3W zKL|`)Zp2c`d&Wtkpx$TWPL|xw5~sO+cXIy#u$y5JvAxSGDl9LxVU-L) z_Q#*$d?#L5M{_E|;xU(rsftj_7#B4-RPr)i?=q*mj6_dH@N{ zj>xoqi{0I28*cZ2^|1*^^;kKp0vF>;(Wl0^HCLq#UmrD$HbVJuziXu?_+MkPw?-MXIa z;hBkd{mdjY({<@yNw`M$f)FC zu#M3UR&%RpjFF_S@>wI%pOS(boMtKlO?al}oIQ&c7LaC38TYzz)FP{!GknUU1>>iq zVkMXN+vNUyKg49`^|hE=HF>wKQ_XPw&eih2A~P@4lWG;&l;fwe$9VeI;76=b9zGKnVAB zM3}NE_JQL z6@@4m554r%L`0E;#lu?r{?oS=u=v5YM_xIhnu^6b<+e<>*F$zHa~U*7w-vBQY3ELb z*Ps|cEypZOcgbz#AV0QWi>L`x^{IQfDK{Q@7Cy+Fn)5Tvc?!NUXYWT&fxf1vFmY zpS#ka9VrH$XkLyv%@I>Tk=l}mh|21&9XB+zq8x~X*x&APi)Q+nx;%td_p@4P{~{NX zixSKbX)~93be0kc0t^=2v?G1aKY0|0qB#$3@+_euKaQqsvET5Kk}f|Ys_Ml6bjYaN zuU&4jJcOd@W~rrNkFR;T-Exc2#3M4TGMP{^htu?(dnek^9+d{ezG~#L=1 zZRn+p+TS&|10pWYpZ_Q1Ay7BNSL_mF$FDohBjk?by&V-PFAH7!%+k`u-19H~dMCQB zQm+7r)!w%fEe5l{8{l7-?vrl>_CjMwp3!=SN#mMYC18}zH_bgABIiYIw-TvpbB~%- zx*osBg$6CGQUI}s_AYSE&BT6oRQl=Ken^FqowKJ+=MC}lUal=D35^cR-_IOXIjnGR zr^g;P^Eql%lPkC1JJ5lyvlQaSI8W0lmG#+A%}z<9p?TXvy-1i|!=|Ut3y#{XElTnR zKQ!0<{mYho4nnyFInppinW;T=F`fC{r~f3y0G#JlcAMr2W=HJwal4u``L<1xY_EB4 ztt$7G^g?NLA4BbOi!Q%a?PPpTiK879kiB?94#~lt_sVykD?ch;jUC<})}?ZpsE^qm z_UN-`H?@gGP8PbG0`LKO6Y-7~%yr*;Sh835{m{GY(|YnYHnPU@`C{L8t{T6-%o0<*4rs4F}*GHv3~diCZo%%oY2S^hvp2u{3pr}L+CS$aCM{lR{F zfq_Hn>+8$Oro2rtPE;LGaussLtxFU-&ZN#B1(A{0M^p=iGCcIrXEghsxNpysjKxu3 zrA^uZAMgq7qqm#O?qNpDHY>Zfza@w74Z@k>I4(N7OXb4&ivMz(lKBw>tctUCaw*e| zsVbl+s;KImiV?P)q!m6I%_#B7S3)k|+nkzhm|kB_WAEuYwKB+I>1%eI3qM7(n&;;C5~}+F_X)rWoppL z34}q_>`t3X(`zLn(>u?PD^GfP8Aj3zjfD9N9AR+vMN zbmW;)?3<|(*n@n6@?17$e2PfV8w4tu6l2re(JP_Iq?t!2sRP}c(i1+`Ir}=yGGo;% zcp%f1Qc_iFs>jmReNc+t4rwXvcE;G(Oy_GP0kO)p3KZqCffcE;(Ypo&byhA;bW?lB zmgH<%n|tQTorI$|Mb1P$S0m1n>5atz>A+>EY?=~t$?BR%fy^=;*30E|>0C2pJn5jW z-tO6TE~|_+xrXKv9DN@m8PKB2&a~a#&EB#NNsz^6bUdR{!OK#mHwb}(OE~$xXHc#(x z@BPtocN|L>N}5cibF}(yRg)DkN_snr`1Ldh3Lc1c(6dx!SGSX9+b(nTJh`4DG)lhA zWiH1P1xHM9F$ zS|yh>OB{<7UeA%1pp1`n{4IZRbMk4uK7Y&HcH+^bTr>YP$CSRz?yX}_u@7r@cPU*+ zO>(hnXUaJudiy4g#5#b&RhGEP-q4L*g@@--zz ztw)VXRs8`{=i|G6$^6T*F*fZP$C)FR?U=fdV{W}HP(~KZq!fEa`J3HwRRGg@=5dq7 z=mU|Eq0Y`JcV$m?)t>gq#5baBl~SLu*Jd6^YU`$(Y1Tcah)tzt6tEeOQ@AaQ8Ebpl zIVO487J%F)0+>BrB|DK4X=>j{F8D0IWRjDtwNK<&(s6D_T(u!rwMW(x9pyA2$2#`e zCMs{JtD2da6pxyj(l<`k!O|&cfJeUf7n?p0#=4IzYn#XOeq1b%aU$q~JVG(H4uf7I z^4pj+>4^Y}F5KEh+Gsk5*tTieIkQ zlK7+zl_G@-q~-ab^V}ncBZ1=Pc55mhcNaxxIqiWxVVraDnFPxgIf%twL>@$t$=)mP zJa;ew84Hmjv25$KDE#67^gI5^)Af~kL;qpxTf!&tVSpAalqzPw9vZz@Drgi>h5(E0 z^)_M;t=aX}F|hBwD?7Qfua|ODsf*0wY01Lw@m-*OK)S+IV;e!rF<4M>YMB6n8{%?a zZI!BOzCC`?&)mm3*}3*|OYg7Y2@`#Eq!_xUx>WJEH)ZC78XBK++%Q1~vX~b!G}0zR zP|H2J*=e06HOQh}?9i;lIsPoPizBC;B0Iz=G766jiG7*4Fa>&{`rKa6q)Pje%q8fM z$fYg*XQWZ#*eq?IUsS$#)%YgINoMRq1G7Q+?e)|m0iU#CHY)bvXc_gTyb|SrQbq6P z=FUeZ*(IpA>Mkd`#vgw_r85s_w;Ylpt#qWaBOa~sX3A17ytqoJ5AOS}METf#!02r} znUO1*FEp{rpSgwO)=X@TO8K=v|6H*i=Nl8f-K`vA*JB$=VyXPZEghD?hDr}DN}!po zBnSxQ(9scW=4L{K;UiDmbnn_G(dZ&4L+Mw##y90?PInq5bSEjYVx;ZJ>&WxIBAUIA zmJm(R>W~T=Xk=YtKzn=Rz=4>OKNM6mtNoi_^KUfTGVj0Ot+y&d2G4L~G}%Y9MiH~P zq;x{T_R13-TN3DsLX0jLdoY`gn6hOpH(P4kcA=SV-9>?(uJL&p=|%T9MRdkgO;bVU zi1gTV6b`QudpO=%Kt(8hZAz)??#G+^W7{g0Xx~8nthE?wSowZ$8JMwmE&NRRaksIj zg8l;$?yys%AaV18f(dg@l(Idkw=TN-wp1d8G}lol&s%q-d$&Tc$=*9@DWI$J1SK}i zQ|%c4fv7~Q_h_4Pna(Vy$sCL^uItEn9Q(P(!~8SlG>#t$(Zc@b+n;gX&23qhZMWXM z+68~{>vvtZ)5FewF9goClckL$8#>a6g2l}%n@{N2WJ6P8RfEtr);&RS@6C^x+<1Bp`&9oAvlm$l$27fTYLMKW}G`-oT~7*ZGYpPw2Um$NfpSL zM4ThXI74;Wrj%^BdF;A6{<@c_2r7G(+oF-xi;hczUU6tCNvg7o9L`$_#$@K{9GUqn z#nxR$Xm!zy9N~xz5tk0L44U#?lnm6`o-*SBO^dY95i)?JlN9WA-h(0AdxIH_$ zbUp$c=c2N*&q6Q%b8!W{+{D!Zarsdz28PC=IUiP)mzuLOgy>2OVte4oU^tTvTkc6 z$w^i|_nqs5#l?gu)@`j$&ap-ubsH-%Oq;%gONDRqIbehfV z;&k>1!ciHfvew=-R*ik3T$@If zn|o+Jn_jBL(zR}26rd`SklIXk99;iYM<|QLt97EGZcO6L#2-@`tVf8rYg`Z^kAn^ zm55Rau`Rn;nss!Fu?Enp`@YMAImfI@RVy7fq=9IIl2JC!tOqi@pR?xJrW@v=EP0dr z69-8t<+xWwYJRI;&U)gPw$`3+YukxqYO^p3ti*{#RE*5bju8=BL=U>8^wM1mYCf?P_BnEwcdqdm z3hw`{yFY##AOFa2Kh319NA8bg(&y#A(eI1Y;9&O{BRsA%2IFN$hbGka<9;%ryRW-`{g?jTkGNIc zW*&K5T~!D*?F$8Dk%*ksvh^-q;{TUq^JM>O{lm z!)N}V1o9)>H{?#0TBxCK$U(D*s61QRid6FAUI0&5mly^cRqnC5jE8n6eu8C)qU!tE z28Zp8^q|v_HPUzgeulvR_?P`WLY3TF-}UWPP#`%@t{Otqj6*yNf$^DG7Fo?ClKg~x z66%B?A-I{!;JrVG0P3FvCP6nyKi$Y}(9Dp=j9m4K0^_S4;u{+T8BRnRDk$~-%qV5r zsVavO{`@bATk^ZMm7Bh;-k2-V=}0G`aEH9g6$34;bc^-<@%+kHdso_BLf#?SIkzc* z_^#tfd7{sr_9dl7=&ZJ!97}Sh3I$^w;yG+#rJcTi)-9nuGlPbFhxmKRVqRq0*i#Qd-$89BA^;-v%;bRCh8-cKlJRMZ#4=}r4G!@q z(FQ_dKkN|4yn{v+D8ahRp~>abAWTcBD?5q6K`he?YMv5@_-`>qcxF$e=`-^j-odgI z3B@1v?UL&!nT=`263WU=ry|X1T{MJ|4)H+>o#&OArynh1=3Nx`NaV3W$-TJ;F?Isf ziI#ra^0cE_$eAjhe24hUZcPw*J)PN8`$=Z+_mGs-Bm#z~B!_hNNLmHTiYC(7u}se} zgc^tV+x~CVF?qVlqx100d&oy=yaWi8q@)Mal*G9#*`Z<$O!^S1SuPx(&xrM{**xTs=jADzlpD79@Nc(J z0@av36;;Pd6_m>E!g&xm>!sSC5xxt8cdTc*)-f`B&ze$6*J|ox&KFu*t(z8OD?PI=0P2NBBuJHnx$R0>}9%s2U<;p&#d|Dw{ zt+N>R=R_+&ka%7>iE8J5?%C9PxMZZpc!{IG-9}16NI@g3T%)$w&A6P!acrNLD~lwc z;L{k4eLsbZNq6DyD%aGeKSK znHg{hmtYt~i{ugB)uV=tGDWJN2h#4S1uC8vI9uV(Ia3tsonpCKn1hZH%VA?%mcGu! z=iwx}RW+a)*Uj0H^jdp{LR94x2iR`xEhAA@MLJxGCCK})+9Z2Ng*B-xSFCArVO%$; zJ+4zk(~~%~?ENW2I>oTh#%a5Ebd6cnC9PK_;qoR|@D$ZL1#?8~71!7^5}Y{6XENlS zmm*2FTCJ`ceP#wGm)>hJ_BP6 z<0|1EX(vj~mR$r4&%CZNc0>%?ny)|3v~vuS>g;Aq-fuaCBz<+{(qONmD~D;{kQ|mO z#Xsa4Y<(;vBuQmt+HB2Zyvwub!O&#?IAN96%29M00=b|UV5e(#jt&p+62un(2U)1{Y) z9X*fvnk3%w^UUj|Nng9RnG-tKz%R&^oT;UkbC7@W*N=gOP8UtmgL#=BXOek`1u^28 zSJ=rWc_s=-TSn0^!adzrQsp zf*fX%6N6!09E3&ZH7uawK{e_eBRt4xJsNp$g~>LweI!rDu4@rxZxprOiyn%?+HujM zKP2g=BxNQeBN?uQ$a^Y|DUwc1BT3+N@$s#RV@{T(1Sp%{N%*Scnu18=skXf4Je?^R zGm|EHM=i$?9_jS{*a(XIB=o{)-14~A3!Y7V4MHY&Z(o&w03FgW6o@#88beNhZlf6rd8)*p18?uOv zcE?dya8R&9LU_zAM{2-1onRh$H-(AkF*^xv&ojTJ+p)yRF@F4dNVd1;06V~}uEK40 zFntb$)|^#S&A^$DJTmb<4n~a0&P+W?JeAqxKD0Lkc~cjIIjWq6o%D*@1({d-JnqcM zO3Eqt}sy{=9$4=WD~2Vpd7g&cg&kxKjr2Eyz12 zIY>sDM_Dx0(ROENKK4VzI&bPC^I!kcXABqCQoCv*uV`_0l&mD9*+ue>sTz{(7->`z zQi*+uc)6pk@BDxAIV)fI^jQd*Y{6VYiH4|(-Wkgz?~YWwCKyRL~G|+{tJf(YW*QKH|w8abcMY zIZWm+&F2k&%rCSjGE>W(MdNfkE57YK^29Rlhcu4kc|563@=7Yt=?c2$xcNLy-gY_I ze#<}TGxwiGR9n!^lU7SO) z>tFlu+1sL@WB%hpptEH%b9!8SHUezwGMI@*^C$(XoZsH7RH7?I~rvW{h3 z%pq6I+k1&CWts#KLaWuAYtGHCGF?w!@k-C0qpwJ-&f z>-``{;6%{Hy!NQF3YA@xk@v7|j&cv^$CLeols1j$_bZU&|pY=sz3>*NEifC~&6sA_MLAkQ7ciD`CIx z%C^3(OBCr(M2hsn$b@HP>Ulm)$4hK8BQj8}Q8{WSc84@UnC5Vdw^k3Q>FRN~x!9py zpp^huGqJYEe=#cV^kdL&5BNZ7#3qgB(+^sv)}3ny)E)gJck=IK`UY_ri& zL8AyoTpiMMKa*iUUx#_}JWpscBYPs9cyts!7#@2X0zwcHx)|~1B#32?0%t4xV7HdN zqUBg>JC~%w5h&>)@?<|YThqPmdu%t%F1BO$5Y(O%(fu^Wo;WsNJ;prw`aHW|pV6nY zlh@q$^Ll2h$wvE=mOLF+gpfrO2&t{jqGt*PPt%dO`@@crJ{rf6%=W~{1Bh}2BEl1Eo+(qa6w4jKt)0N9|I&OcEWH+}fQfd+bhflaSg;QK141pb74T0@zZOmzYOn zWbBHlacA}ru}+0j%M|LZDP~7yC9ePOhbf)JGSC#w=c#53wWT=gl+%{2tbppKWEAB} z1BuhLNmJs^jtP6Hq$1m7m82}Pyg*@PRhFonj=shSCxdhhfe?adR*fOe8;*Ob4Y>c~ z*RNGWT2!H((k$D;)In`;q88;zrcPO$Gth_Z>@H+T$T}gdDb?J0O*YLQJlvv|R@kXR zw6lXoW&w4m9Algc50gagREUceh;?dfy}9h{U;B>;1LI4+O*RTi;ur!Y^_;yxsRc@T zlzU{KVs}4V8m281Q9+tUsO;^jhRLo;ny6Bp>M$-GTRBC!I?7d!ICnFY2{ty_aXEro z7P0K2XUEy!goK~@(Rhn!^Iu%)L8%7Cqp4nTU z?mn(eL6w!FLTa0eHq$xc+-=fMED(*#A%b*4522RHB3J5H{~13R{@!1APj?p7af)zd zp$TGkSJQLLsIY8uHug9&17b!HWKwf?clL@{*aFS#q82@I#-O z38Y8?m_wBma*|S~ad8&Y_+x(64~RvxZUez20HLlXh1>4OpTB4S+MlnI5!ut2C})aO zg4uwwj5BC5s_AAj%9P8dT0?C3coirhe*ABqXU|NeL6bUfLLsTskh7*IO5)>wFX7^d zR|bs?yw9Q{hXmP{yZE?gfAwnBd`#_bT-U1E?bf2ul4K4mXlqhar$w6yK{Y6Ll6gu} zX68As%pOE)q}80HA_vV`EmOq0vRVZ7)0Po79$@+|W&$8Wh9E?$S(MbyYbG<9>`2W~ zDuVQBw-*7J8ksO=vgO>oM-f70*>)Sz>@6KZg!;q?BC!m5sC6}#At>V5B9?*;SeYk$ z^h^E=<`plX3Pq@8Hc^R;*OR>Vn8`9;n*HpTk4&XBnmmnHpq4BtiLAt(qbx;v)ZY2? zJqDI7h+{QGA%|oYSL;|96&HQXu4g2iZ1)XYLc9eKjaYZpiqypYOg#IU|+$rH`uNV2k3iV=&M@Tx3 zm97Lm)G~;YplRBH5-LrjfkGO~qEs3*sEb*oMOURgcmXxg#~<^5{Z8R&{}*}V8WfAX zNTO1qv5pogvO*eC0wssHuC4}=#4*lur8zW#E~3c+FBqujKl;yqj~`b1zxHYds`o~c zL6MRJ2^!}S4J6k6G^fppP=#1mP&rxGGK-3}cu+FG;qU&_{qS12$QySeB34Ko6NvK` zddPu7RI_?xqj^&!3aP6N#CcSuJzVVykJZoeANXOm*AhQICyI?)*7ephhtmi|Tpar_ z+jkg3(38{Vq#^{eiYAEq3@ey!JN$54(07sc89w?9DvO>Zf>= zGS7OKnHZyBnq1{%9c0m5F$;vc+N{!2lqz+a3$eDadGUr~RyO@-4Bdwt_7gp!`8el5m)EaUCg4c*Rj@(xe>+FXZ?spcT70m(4+mbKhP(` zW|%{-)OXppHRvHH>EW2kVm3V7|JrZ)>!1HXs9x8j-|znAzwUQn!#r~#pJ*s~+cF11 zEVH;c{H{OufBkoV@#ItGrE`xqgNElH6+i(jKpV*dP~vh4jY1Vl1r+3sg7~)2{XyUO zId3=Ne#77%Jvw(D-??=O$qV+;(14Uj31KR;DE8~;{@O49+^-+zEyK7|#V$+7_oy4& zX-MS|qY+gxF!WPZ9P2ch@!kL98^7hlch9^sUzKX2Ri&OiR+KN&OB zQ1XTb4e5o#%l&WqSMTzcsxHSre&d^HhY{RMmvca1{{26}Te0)b@@ZS|l0Yt0R)tdQ zU-O4~tF~xFm!SHzMHUmk^uu`zx2iyGT%VPE)!Gk<**Ik&!C_F2L9)?VrS-+#}4#?->^%6I5vh8<@~NWb{B zpP--2Vgqh|)y1*umzVS`%~E~CWP=5`Dciz-4iHl)(Jw0W5a8j%(9GMQvUvQZdl*3s zUZK%6JU0-N!qcfS78 z&&wiT^zhyP>1#jd>(==T*n>M|mtAbS&wMdRS9$Q5%{^fd$EQE=Be^~AKJ>NU{1<=i ztKJHRYEtD+xpULp+$Xa75*UoYn2fLoAxh?#|NcKS^YPoh`$J#yYwzmPl{hQPp18N; zuHAIIXV)>31j!G7VaV7ck6{l@nV1bTVkt(jG)>8naU(ag-G^=0Zn~RW_Xw%O5C37J z@pwkYvqd!t6Yj<>OoL1o5G0J;bIYyi*1EZ$+t$rJlEgA^k+3Zejd?c4V-T|)9vj6i zh$1P7G+Ef)bl1k-b8mEaL*Wy7Lb%xvCQmaS(~|B9^MHH<%c3$$6~QgLZdo*~+ve7- zHY$-%5w@qokcmkV=Z(oYHY&1`B1a1c>E_;gxM}U$YP%_05^>BAdmNbXV4h4WF_}Fv z9(%~J2lhSD?ZzzF8@sk{y6J|x_=6u|#00*2ZV%(R7tuU}TPSzX+)Y{3EwyjlP!2(| zdUJl5GlJQZXBJPgBjzwcVb6wg*Ks$s@4?oshdtYAtn-HYA+CW*!(+DD^GNZKh8Wv& zM_PMv+h)7Ro=xooiul5q60^;I97oK?JnXTbvCW=cd$7A1TNjiK4JwCYK2?5@TgGy7 z8?l%;ofPS|tZmsnY{#aSsqeggm`{-gW;cx7!=YiM`)Sl3TXU~2*O%1LXwYD`*(S9g zn(o1-IW&io`T`qo9yY^vY?M?Q^(8iiG+m^k@z%N;{Sx!$)G;)=N`3iF-yl7RrYTZi zpi^Jg(=TH`_y5oR|3_6Y{WA9R1p@$9P&goz9{>Pwp8%Z!D&_%10X|_alSiZ?KdPiL z`K#~@31e>j4LLN$_uvoE56}%ZoEEB!zJ1KbPsi_b&#yWTJMkNSU+-vK|O|B?NR_m2Nv|0Dg+x_|FK?Y)3Mmj8YK z=j~npOW0fYGuRLL_xev)ANargKid2ke^~!%`%C{vjm{A;TeUZfKJOb_KVV>pw!TI0r?^F^16n1Kq>!9)(zY-oeJi$-}?XN{d%^7CMdBU z-tjT9=KV1&=)GXRq=M7;9~ZIty{h3pg%Utd`aXmg}6w3ig|8neThP{wRn`>PR$m@y;O? zCc2Eua#$fZp$3k9iw&_lrNlx6CAkVbS9pk|gGWCc;)5C=jhuDocr|ZMIjkjD6c9Uy zB`z>g{cPKUCajtqooxHqDL6|yFIXE{+xZTO17^d&d>n$<-aOZF%yh^IY62)r*H(aZ|j=DJYUFG&}ir5oIlvp5NoJWP2L~SCDSR= zOO+J5d-)y`?GYHX>50JSMbBhqm(8t#W>Z?S8&b93{ZXx3w;IfzBy_)N(-5w{V93(& zKd1|<1t$q-Me5Uo)VX3V>CZoFd}Jh#?A!v%0ePn@OlNGPK91tM@ZF zN668|ZO+bxdYD^dcgBqynx%F;@XpX>UgeL>32o7_e6SunHkHLYfK%d&)(h%K!o2>< zz`lDSF4=ua1*1NPLZ4C`_j7>%N1y^@ZQW+e0n)`J8aec=dCHh=8+nwxv0+3K*PtdZ zsUXlzU3H)mY({e3n&l#5Z>XX}pN?@xhfC6x0*wMR>H9{`K777SwkNHw#mULc$ZtN|Go{gn{D2y#L zu7Y5H7*Gzxd1>#&FK{d%VS(3%daPvInZNwALYp=40Dd{e8Z9_WIX&YgX6e@K4SF8d zoAbYz*`&nTJdGdSG&I%43pPuS#^6Sq0L#j0oAk6T_YoJr@%VpekFvDSN$AmO!dcOJ z!F@ggT{bgvgIB&k$(C$k>Knkka(0Afmu;&McH(#4o+p`HAxd(pa4>Gn^7a_LV7{b- zM?W0mjB%aaRp5y^o8d|!TI-BWptPmV3Lb^72eig6fG3P8oVFYSwf>?=%;E?5y{!1O z;VkIAV7{b&juAZr~-P zi2TK!SrK<=ov-ZNsNeGPB5H>`(_?Rdu^E$x_o&syIvan{1!R#bRMasx_VtWjBsw6m z(TMejgw)tFx^mIKa`bB@fWU6&ZfULW&9A#oTE{Ny3GdTR6-nyjeZEYycIMG?7<;bz z-5({b$BKdv0O8GpazPUhzUj{MI9usx4U6Va;6m1*6RG)hWH?^GGW$0j)5|)&n`L1p zW<{u`lx8nfflWp^$y2C4T42r~w!v@R*}4d-?(BZizOj8`;|lO$V;2(4A-*A$HoV!d zcc+!14+M4{%){(n?VUX6vx zk|W?OYLAhFazx~U^Y6_oB>cjOHrhhy)nb-I=p7YtTzm}`=?l-_K47ci2Max)p!uYa zHT(7%SqgpiT&4kHgKq1Nq&|Bos| zy4keXt+iulKMjhLFzf82!vT(UViA*C!}z}1XRC>KZLk~d5c>NDI9P!M_Y7=W-8ziG zI%MY1kfB3`uN!iKg+Y}@tMu+I_Fy{k7D6<3wb^@a_A_ku#a`URp?ToJ?~DlY(R;4y zqSFqB9OQ>5MN}HOF7~DEOI77+h=&`#8fWh%eS69p+77%kxtAz%4>5#ZnV5%{f3l%Y zT9e-Q=1fdmS!;|~^~rW%9T4-hpxEv$EG|=8WRoRg7|}3VKY;FEU}*6o*ua&@-TaOu z7G7$N?=Y`bihrP*<~G7&9N|3IVuIVTGeC5~?dPJR==4=`yzSn}EbAU_p{QBaN6M>Bku$##(d{s&%Dg`C+ zW;272xj$T1v_s|Yqjk6=T7+(+0VhCCrVt8q>^@pJVlBB#Y0)RyDTn|nd)rt{37k=s z4fW2KlD)TtpXbMym8*WO_M^U7Xy0*Se4f=viBM1V;LlUpT(HK71=FFhDahHJKiD$V z7s@nlt3_K|3#PW&_ctY|@6f*1ze9i)$$ziqM;$3`a#4YMk7D{HUx$>r4JtYd~2i*KN2+6@;epTtW%+`uuCtjfV@ zm$_H$nfKRcO2&hpx|N@07`&_du}z+f?D{f@KO=rY-yXV7kKG`PA_AyAlZ)nfrTz1y zUIm%%rZoPk)i(i~*!@%@2JJ+E3-3n1F@OK=dw>7#iGT+nE{@Ttt(Bpp5k>4TRA+c4 zx3ttDw%%fHLcLD#R&CNmJL*_gGP0(Q9FyT$Vsz!rwVWKQB#%9_)rZB8A(#qqSzi;g zEsS!N%2vPjo6sMLr&-l3T?eIqnzVJ9c{}?+BlzN(5rLCx2y>XR!;$dbQ!gZ$Yv@;%Jn5lC@Ab<#8LY$0S;tTXVOSBW08D)|g@ld30^DBzj zjZ+^XNVE#2`_gtIObD!71%u$~wQ=$yf7Z-*ZRp;56Hj7Y8FNC}K%CaL_=bhCW{MQ` z_mnKLL-=2i3;41PlRGZlkTbzt0|~8X2fam>J0GfY0IUq53yTYkzYOwk)K_ngG$aT8 zDpApm1M!F#;f#%;c-wu!Y`dx!VWQMu81cYU!lFtZhvE;7Z)8i14Sl?s+|&^&uZ$S3 z$wI}nbgsJG#=A~M7lzYxO#sa54Mi;fB&M7?W_8xdt@K|OeZ3w15K&mvd5voK9m?a` z)ugi*NQD=Le3jRQSU`|o$gigp6P8R)upddvP-go${tCV*e(;=XawhfJPXI`V?BukH z=FU2i70&SODc?%e3wPsnOBlLn?mbC=xQanLY7Pz;!BwK@-eo9=P36wa2oHzTaI*K? zj=LQhnP5AOPM4&&A7y)?u`wT>*+MmU+sX9OoNx8gK+;6-=}u>zy#y38{w^9ngS$@p zjTJI3|bJQK61p7r=}WQP=k~I!^a5OfH!#P$^Htk?iyqcdIHy#2$JxG=PD% zYlqEwiK?OuUR24xO`%k}EKJX;72mH)*DetfjeR2ZymZzK2RS3p5DiAM?P(t$L45!J ziUA94^j}kn5_cc7hlye7E<(6;IjL~MWTf!g=pX_h0oi+++|B<3T>!>OFVHN6uf?Ym zkXwS3R2Z9Y1TT44iW5rK*LS2FF881Ikb9v|df&}~K+$v+o!9ZYYl7pfbU%Kk(Os`{ zZ^}c(ap{Cd*%B;hgz_Tt5vqV_ULmSr;^PT16u?)R{4+_jMNX&|AJm^_Sis_j@lzpW zvk4OQHDZ#@`z-f1Ox~EfB@-H7$T!>r1*#@xhLEVLFTi7R<&*vQhw+5)FI71eL@ zM&r|bT4>{R(WJ$2C7HEbX}|>qUniv z9FI*9Z03C{Xc^hOR)!>B*3OPBTKbv#Hk@$HtQgBv=ApEOsxmkb&nA)>5`(M_CM;!M5y9MwInFl3NPwUZlcSU!+{k`79O$P(Nf z#;fA(by^5=Y_!EGhNi6(0OnMCaT2r^(?3yyejv)2oEi%I)L)+d`u|JrW4r6T{|6Utl#mUfIxs+-8dJW}2wES7h;!^tU+5O;paH9SyXrF)Fa%Um1;~z2;h?Y3 z0oV+oE=v*!Y{+$YD#ENyZJ0Grqggvc{;|dMRI*2`-KqtjkspzOd{qmcHl%q$$<+0O zzeiTGxSP}Dc>?vZR@N(NjD! zJF07-&(MDnhc9NNT$$kL;jM8;|u#-0^vk4sYk%> z3&$r2}k(fkem|>@wDXhvCF|r)GXDx{oQvNooPbU8J{qc z8y~^G60D$9hN?L%HJyfEP8?cg4KCNzfaU}&>9+Gw6^j;{s~8V>iv(OGa#hFhpP(mX zx`}48=fg}9{}0h<7cu$$;sD8LB!QbDXQY@U8cyt*gZxtGILLCb*Mk&DD)+Q2aTael zc=fLctb>m?N*KiD$PkY=0oo)U=viGbv}bd|bCJ9x!?>mfxk3C;g=_J{L=0SEr0I*P z)`g78rxrqJ)xA@yK|MJii(3Ib%wWZI__!&}0~#0o3HfHFP&u_>#%$8I{va7u;v1=7 zcugE_iMYo7s2=*s8WM=o;3G+O3Hqlfz^f4BDLsq3*=)3HSUfsdgoF;njuXwpD4RM| zB4OB7@G;?|Z6`?Y`RlPkT76Fg0TXftDO2c}x1+EC82)){l=rxh$xk@!@bbS#3p?cv z8dEYt?!1{)P=^M1ouqa`Z-R2r*XG11lgaUDI4&+9&8b9HR)kolRFOO-&9CZy*(4W} z*c-@(rin!>#K&-)?N)Qbt-H3Yn|YmK*=#toj;TArMt&BH^DV95rJnlBN^1KH1omEf zR<1NfvRSrr&jACFI-@9{|4=_0W$b^y0{WefS~Hpr1gE|MurO}RZ^oCR7W?ax0d(5v zy(p@#4x2ku^%=x64!$Fg2jo6TLG4QgGTGdDAkquG)+IA1xEWDjy>ab|(%nh!eWS3A zE`XJeN)n=~!ya`aE^^#sgytc%J!Ny${YpW)#ir^krvm$#l62q$2bj^Hv1gh!NegoK z_hMlTmLvFdaB1}m3yA?jSp{N1rPxEKr6k(ym;^Q$)mvMOFME^MZX6k0Z7=|xXaidU zAC{&MMa-o-!^}{&O+>5K!K(l@`PV+#h$RF|pFDTPDp8kBihQwZ?+X8g ztBxKMRR2NPS8JBPry;-CaXKWv22f@{>Jh2gZ*vf@UDRS&w>xU4hRT_H<|9T&1x&uD z>7YtYE9VL203_-I)=by9?cFj!kUU6s@>{Wh|8-+#8Hyot#Gzc&0gfxs=UBwn3<754 z{%S4)B!{%ZjE8l>P62YzlKPk_3Bp$fuW)_~F)-#Yp!r@uxhos=JE(Oe4%A4~Mm}=% zx|F}a00RL24EpgMO2p|g^Aw1OtpFdtQ*|Gy%v7VFc&K7xVe0sl!=&{Ruu&7m*hcUV zC-BAqOOCeIh#M(Gq7=N?umc@PSAbBHEOZ~Wx?NTQNP1uj`<=)Vd+ZKWM;!gQotUJ>OOl^dqCWuZzG^5l~yl9341n! zZLks7mA%hU;aH{#!5TplQk<$m5dzAZvA<)Z(d?@e0h8HGOSQsR>TND9CRl+7{vl;L zNj~a93Qz!$+5h!9-DIDZRM{%K_hwRz0Rg&Xf9g68>etXBeoeU#=!u<5?AizpyX~Jx zqyu*$lhCvh&8h&r`G6uVxZ+3HtWEn|SS>;&NhZ(4k~ClZ{JYD?vBACHL|V)UI8i5W z%O}ji#ln|hJ@1roKSZgZ<&zP412A=xZvlxOD;R7VzI*di3)m^d?F5r9>GSE#MtJsB zGqz9Pu=oC8uCtajSJ`={Z7=n@5i07H|C_NXj%wJ$!}2LlvDg3>S0cg^%uWhiNME|` zkER4I;TLkK;;^BqUNlJ&F+2!1GgcE193UE<=9~ChVA@Uh%${U!I&|B$E`gtV!$mW< zn8$q{Mdv(@e!mU;Xp$mczUAV#-Sj_@d&x|{L>d7Rc zUrTYcYi>-};SF(u?S^uVdVcN9BB9jFp26{;^Lw970WRYW0=zL3OkI2Pm@LY)-=YO{ zlxb?>Lg}9NwxIdEoDhKzU)NZD8|^eQY*C2m9ezzUu6K4Ou-$m_QkBiJpc##)$gt5b zgQOt_aHp#~@;2NM$z^Tsvl7~R8|?KAwB-JNjrw%L%!$2;DmmG3n!J% z#|pGku?aURTMK~|HrE5{-bcwlBpxuV`#l~~ZCA{zY>ltG&NCSuVdKzzzyQ+1)%H4q z4W0q5v7lBwV&%fXn6Y;0ZvYap-G)KiYNGaRMSIMf-YE#FZx;E&y5}!0xL=9s^(}La zcfK3J-&AUy&gbs(u!}W401LklvIC$-y5#ijNG; zkICVnV~|Ky)6w>RLsg)%*9zmhM6XC6nsCw*Mjp`NGu%m-PE!; z+2u#JUceE#7?w{?Bg)HllN8bD|3}HXA2sOmu;ooMy#;CICSttniDD~F&m9nE4Ma&O zVy@ECSt-d0qb_Oi!#&K?xl)CQxbJXdLmP6>@J!>yvB6~A*&=2cvfVUxm2&8(3_&iX z`X*Gc&h*RLK>Wk8(=h0MG!7(g1vcRnS;M_0Rlt{h)FQyqbiGZ^p_&g84l4i+REJ#HpOZpx@ve@H z30yW4qC4RhMH{)%t!Rc2R8zx`o+2$?E*T= zW6e9pdk3Awe&bnW6JVPt(cy@nW-o&C>_U7Wk3T404Zt7Hrg!j%fdG9caIQ>PefS(}b8USk}p)YgZQf(5qtBvhP9Xan!p(7lSq->X{6Z-d<@9HF$m-UqSy5DEZ zNq5l-xeBS%9KKjs6ZdY{aM-xXEvaRMCW zH&60tDt?R1WFh8S&-)SScVKO_Xm|bXd<*_ACsWmZu>S~=fkaW@GEaH8S#dvh-AFox zbA2;^llLcOOm1}bD-K3k#iYnSgbX5dzW@Lh*pCp_r{zTy9KDM6E9|fX@5|XsUCe%$ z0$mw02ycI!2O>u&!ch5FqXGCLGAv&0TrR5h$UpUXrQzC6<~#VY#ci~|Kur0}kBv%E zWAB1^uE$hS0W|4ho^DhgSqa8_BtBaf$}JQ=ZZ8B!G!;*(E=e%=HlkCthy14n?7z#m zzopXSU_4?`u8k%G%Gb!jOfbS$iw|jHeha|aO_xQ!2%OfUx+`-JSV5t=_9zhlPr)O( zh{~V{(-D?EdmL%TfLS;Z++L;^LM)JXioRk?e8l+^qYvf1%!4GCw+7^Mm#nGvPxH4J z-eZg3A33-noP;{cNNz#P0lPw>U4$+k7D&G7c+I8 z61B?$k>RC2^B;a6`}(Rc1Q;|$(f88zvZ7d z2yI66jrI{h?P)Br@Azd#_vcHJ$|l|07k}aXpr&>YE=q$*ljRX;%Pr1mxtK%)PIRG~ zx1#veE;VJ0cCwc*J`+qskisUXv`mhQh3j1)6_z^Mnlc+8;_B`I(lSo!dyETHugPm^ zmG#Of=J2py!;(c-ZqABP1Rd{{b?s#{@5W8OJM-DdZ{GmTeg248XuXtD7T$)qX=Oa| zw}0ED7VTQ2aQF8`oc?|nt8?0Wom>g8j=}F=cF%9kTsR0>CO!;0;M)O{)lK0i!kHL}IwA@3HEMbY34_m{|g- zLQTFTDk1UMn}Z=FU?{_nJ0TR?zcSlnr&@-)5|>S)&Cv!IU1h*j)p77y5y^mOm?Bm_ z{$}qVK?91M&^CWfLJx2p1xFcTd#+&w`-FSfR##@WGwI*31BWBZ8Ryatf!`^EVvl?~ z4@My|7T`O@!$uW>hZJb(s@lJeq2BEw6K?42aIt}G9EK?1xC0nnX&g~Ry* zo; zUIg8tBGLX9p3iwHk=_f8TmOY803lrm`!r}V%WVrZyYB9q0f|N8{~$=+#%*Ek|6U-!gIq6&_lh L&;S4c00000m9Anx literal 0 HcmV?d00001 diff --git a/src/main.tsx b/src/main.tsx index 884e6b70..e6dcd96b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import { initSentry } from './config/sentry.ts'; import ToastProvider from './contexts/ToastContext'; +import { isApiError } from './interface/error.ts'; import { installViewportVars } from './utils/ts/viewport.ts'; installViewportVars(); @@ -14,7 +15,10 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnReconnect: true, - retry: false, + retry: (failureCount, error) => { + const maxRetries = 2; + return failureCount <= maxRetries && isApiError(error) && error.status === 0; + }, }, }, }); diff --git a/src/pages/NotFound/index.tsx b/src/pages/NotFound/index.tsx new file mode 100644 index 00000000..cd1cf127 --- /dev/null +++ b/src/pages/NotFound/index.tsx @@ -0,0 +1,41 @@ +import { useNavigate } from 'react-router-dom'; +import NotFoundCatImage from '@/assets/image/not-found-cat.webp'; +import { useAuthStore } from '@/stores/authStore'; + +function NotFoundPage() { + const navigate = useNavigate(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + const handleGoHome = () => { + navigate(isAuthenticated ? '/home' : '/', { replace: true }); + }; + + return ( +
+
+ 오류 캐릭터 + +
+
+

오류가 발생했어요

+

+ 주소가 잘못 입력되었거나 +
+ 삭제되어 페이지를 찾을 수 없어요 +

+
+ + +
+
+
+ ); +} + +export default NotFoundPage; From 2afa81d4029b0e59000c43a63a686d583be1302d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 18:23:33 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=2050x=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/auth/index.ts | 5 ++++ src/apis/client.ts | 36 +++++++++++++++++++++++++++ src/components/auth/AuthGuard.tsx | 9 +++++-- src/pages/ServerError/index.tsx | 41 +++++++++++++++++++++++++++++++ src/utils/ts/errorRedirect.ts | 12 +++++++++ 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/pages/ServerError/index.tsx create mode 100644 src/utils/ts/errorRedirect.ts diff --git a/src/apis/auth/index.ts b/src/apis/auth/index.ts index 89d50f24..b5c96375 100644 --- a/src/apis/auth/index.ts +++ b/src/apis/auth/index.ts @@ -1,3 +1,4 @@ +import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; import { apiClient } from '../client'; import type { ModifyMyInfoRequest, MyInfoResponse, RefreshTokenResponse, SignupRequest } from './entity'; @@ -11,6 +12,10 @@ export const refreshAccessToken = async (): Promise => { }); if (!response.ok) { + if (isServerErrorStatus(response.status)) { + redirectToServerErrorPage(); + } + throw new Error('토큰 갱신 실패'); } diff --git a/src/apis/client.ts b/src/apis/client.ts index e43babc2..09c14962 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -1,6 +1,7 @@ import { refreshAccessToken } from '@/apis/auth'; import type { ApiError, ApiErrorResponse } from '@/interface/error'; import { useAuthStore } from '@/stores/authStore'; +import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; const BASE_URL = import.meta.env.VITE_API_PATH; @@ -43,6 +44,27 @@ export const apiClient = { ) => sendRequest(endPoint, { ...options, method: 'PATCH' }), }; +function isFetchNetworkError(error: unknown): error is TypeError { + if (!(error instanceof TypeError)) return false; + + const message = error.message.toLowerCase(); + return ( + message.includes('failed to fetch') || + message.includes('load failed') || + message.includes('networkerror') || + message.includes('network request failed') + ); +} + +function createNetworkApiError(requestUrl: string): ApiError { + const error = new Error('네트워크 연결에 실패했습니다. 잠시 후 다시 시도해 주세요.') as ApiError; + error.name = 'NetworkError'; + error.status = 0; + error.statusText = 'NETWORK_ERROR'; + error.url = requestUrl; + return error; +} + function joinUrl(baseUrl: string, path: string) { const base = baseUrl.replace(/\/+$/, ''); const p = path.replace(/^\/+/, ''); @@ -128,6 +150,10 @@ async function sendRequest { + if (shouldSkipInitialize) return; + initialize(); - }, [initialize]); + }, [initialize, shouldSkipInitialize]); - if (isLoading) { + if (isLoading && !shouldSkipInitialize) { return (
diff --git a/src/pages/ServerError/index.tsx b/src/pages/ServerError/index.tsx new file mode 100644 index 00000000..f4bc24db --- /dev/null +++ b/src/pages/ServerError/index.tsx @@ -0,0 +1,41 @@ +import { useNavigate } from 'react-router-dom'; +import NotFoundCatImage from '@/assets/image/not-found-cat.webp'; +import { useAuthStore } from '@/stores/authStore'; + +function ServerErrorPage() { + const navigate = useNavigate(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + const handleGoHome = () => { + navigate(isAuthenticated ? '/home' : '/', { replace: true }); + }; + + return ( +
+
+ 서버 오류 캐릭터 + +
+
+

오류가 발생했어요

+

+ 서버에 일시적인 문제가 생겼어요 +
+ 잠시 후 다시 시도해 주세요 +

+
+ + +
+
+
+ ); +} + +export default ServerErrorPage; diff --git a/src/utils/ts/errorRedirect.ts b/src/utils/ts/errorRedirect.ts new file mode 100644 index 00000000..b91c8901 --- /dev/null +++ b/src/utils/ts/errorRedirect.ts @@ -0,0 +1,12 @@ +const SERVER_ERROR_PATH = '/server-error'; + +export function isServerErrorStatus(status: number): boolean { + return status >= 500 && status < 600; +} + +export function redirectToServerErrorPage(): void { + if (typeof window === 'undefined') return; + if (window.location.pathname === SERVER_ERROR_PATH) return; + + window.location.replace(SERVER_ERROR_PATH); +} From 4675b5a4e6b52510a78fa41843e39d4b1093ed36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:03:40 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/client.ts | 70 +++++++++++++------------------ src/components/auth/AuthGuard.tsx | 6 ++- 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/apis/client.ts b/src/apis/client.ts index 09c14962..fb093171 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -56,6 +56,32 @@ function isFetchNetworkError(error: unknown): error is TypeError { ); } +async function throwApiError(response: Response): Promise { + if (isServerErrorStatus(response.status)) { + redirectToServerErrorPage(); + } + + const errorData = await parseErrorResponse(response); + + const error = new Error(errorData?.message ?? 'API 요청 실패') as ApiError; + error.status = response.status; + error.statusText = response.statusText; + error.url = response.url; + error.apiError = errorData ?? undefined; + + throw error; +} + +function rethrowFetchError(error: unknown, url: string): never { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('요청 시간이 초과되었습니다.'); + } + if (isFetchNetworkError(error)) { + throw createNetworkApiError(url); + } + throw error as Error; +} + function createNetworkApiError(requestUrl: string): ApiError { const error = new Error('네트워크 연결에 실패했습니다. 잠시 후 다시 시도해 주세요.') as ApiError; error.name = 'NetworkError'; @@ -150,30 +176,12 @@ async function sendRequest(response); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('요청 시간이 초과되었습니다.'); - } - if (isFetchNetworkError(error)) { - throw createNetworkApiError(url); - } - throw error; + rethrowFetchError(error, url); } finally { clearTimeout(timeoutId); } @@ -266,30 +274,12 @@ async function sendRequestWithoutRetry(response); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('요청 시간이 초과되었습니다.'); - } - if (isFetchNetworkError(error)) { - throw createNetworkApiError(url); - } - throw error; + rethrowFetchError(error, url); } finally { clearTimeout(timeoutId); } diff --git a/src/components/auth/AuthGuard.tsx b/src/components/auth/AuthGuard.tsx index 26b83191..95231129 100644 --- a/src/components/auth/AuthGuard.tsx +++ b/src/components/auth/AuthGuard.tsx @@ -1,6 +1,7 @@ import { useEffect, type ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import { useAuthStore } from '@/stores/authStore'; +import { SERVER_ERROR_PATH } from '@/utils/ts/errorRedirect'; interface AuthGuardProps { children: ReactNode; @@ -9,7 +10,7 @@ interface AuthGuardProps { function AuthGuard({ children }: AuthGuardProps) { const { pathname } = useLocation(); const { isLoading, initialize } = useAuthStore(); - const shouldSkipInitialize = pathname === '/server-error'; + const shouldSkipInitialize = pathname === SERVER_ERROR_PATH; useEffect(() => { if (shouldSkipInitialize) return; @@ -19,7 +20,8 @@ function AuthGuard({ children }: AuthGuardProps) { if (isLoading && !shouldSkipInitialize) { return ( -
+
+ Loading…
); From 7ddcb2799a4ca96bd066c2dde464b18edc737e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:04:19 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/ErrorPageLayout.tsx | 37 +++++++++++++++++++++ src/pages/NotFound/index.tsx | 39 +++++++++-------------- src/pages/ServerError/index.tsx | 39 +++++++++-------------- 3 files changed, 67 insertions(+), 48 deletions(-) create mode 100644 src/components/common/ErrorPageLayout.tsx diff --git a/src/components/common/ErrorPageLayout.tsx b/src/components/common/ErrorPageLayout.tsx new file mode 100644 index 00000000..c50a8b9d --- /dev/null +++ b/src/components/common/ErrorPageLayout.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +interface ErrorPageLayoutProps { + imageSrc: string; + imageAlt: string; + title: string; + message: ReactNode; + primaryLabel: string; + onPrimaryClick: () => void; +} + +function ErrorPageLayout({ imageSrc, imageAlt, title, message, primaryLabel, onPrimaryClick }: ErrorPageLayoutProps) { + return ( +
+
+ {imageAlt} + +
+
+

{title}

+

{message}

+
+ + +
+
+
+ ); +} + +export default ErrorPageLayout; diff --git a/src/pages/NotFound/index.tsx b/src/pages/NotFound/index.tsx index cd1cf127..1c6aed40 100644 --- a/src/pages/NotFound/index.tsx +++ b/src/pages/NotFound/index.tsx @@ -1,5 +1,6 @@ import { useNavigate } from 'react-router-dom'; import NotFoundCatImage from '@/assets/image/not-found-cat.webp'; +import ErrorPageLayout from '@/components/common/ErrorPageLayout'; import { useAuthStore } from '@/stores/authStore'; function NotFoundPage() { @@ -11,30 +12,20 @@ function NotFoundPage() { }; return ( -
-
- 오류 캐릭터 - -
-
-

오류가 발생했어요

-

- 주소가 잘못 입력되었거나 -
- 삭제되어 페이지를 찾을 수 없어요 -

-
- - -
-
-
+ + 주소가 잘못 입력되었거나 +
+ 삭제되어 페이지를 찾을 수 없어요 + + } + primaryLabel="홈으로 가기" + onPrimaryClick={handleGoHome} + /> ); } diff --git a/src/pages/ServerError/index.tsx b/src/pages/ServerError/index.tsx index f4bc24db..3f464318 100644 --- a/src/pages/ServerError/index.tsx +++ b/src/pages/ServerError/index.tsx @@ -1,5 +1,6 @@ import { useNavigate } from 'react-router-dom'; import NotFoundCatImage from '@/assets/image/not-found-cat.webp'; +import ErrorPageLayout from '@/components/common/ErrorPageLayout'; import { useAuthStore } from '@/stores/authStore'; function ServerErrorPage() { @@ -11,30 +12,20 @@ function ServerErrorPage() { }; return ( -
-
- 서버 오류 캐릭터 - -
-
-

오류가 발생했어요

-

- 서버에 일시적인 문제가 생겼어요 -
- 잠시 후 다시 시도해 주세요 -

-
- - -
-
-
+ + 서버에 일시적인 문제가 생겼어요 +
+ 잠시 후 다시 시도해 주세요 + + } + primaryLabel="홈으로 가기" + onPrimaryClick={handleGoHome} + /> ); } From 9ee1e8402a62ceffe5891f7112f49ec39f5c70b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:04:57 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=83=81=EC=88=98=ED=99=94=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/auth/index.ts | 7 ++++++- src/utils/ts/errorRedirect.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/apis/auth/index.ts b/src/apis/auth/index.ts index b5c96375..bc215630 100644 --- a/src/apis/auth/index.ts +++ b/src/apis/auth/index.ts @@ -1,3 +1,4 @@ +import type { ApiError } from '@/interface/error'; import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; import { apiClient } from '../client'; import type { ModifyMyInfoRequest, MyInfoResponse, RefreshTokenResponse, SignupRequest } from './entity'; @@ -16,7 +17,11 @@ export const refreshAccessToken = async (): Promise => { redirectToServerErrorPage(); } - throw new Error('토큰 갱신 실패'); + const error = new Error('토큰 갱신 실패') as ApiError; + error.status = response.status; + error.statusText = response.statusText; + error.url = url; + throw error; } const data: RefreshTokenResponse = await response.json(); diff --git a/src/utils/ts/errorRedirect.ts b/src/utils/ts/errorRedirect.ts index b91c8901..ec3acb98 100644 --- a/src/utils/ts/errorRedirect.ts +++ b/src/utils/ts/errorRedirect.ts @@ -1,4 +1,4 @@ -const SERVER_ERROR_PATH = '/server-error'; +export const SERVER_ERROR_PATH = '/server-error'; export function isServerErrorStatus(status: number): boolean { return status >= 500 && status < 600; From acd314ac5ada00b6ec332baeabd44469c94d0202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:15:51 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/auth/index.ts | 23 +++++++++++++++++++---- src/apis/client.ts | 7 ++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/apis/auth/index.ts b/src/apis/auth/index.ts index bc215630..e143dec6 100644 --- a/src/apis/auth/index.ts +++ b/src/apis/auth/index.ts @@ -7,14 +7,29 @@ const BASE_URL = import.meta.env.VITE_API_PATH; export const refreshAccessToken = async (): Promise => { const url = `${BASE_URL.replace(/\/+$/, '')}/users/refresh`; - const response = await fetch(url, { - method: 'POST', - credentials: 'include', - }); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + credentials: 'include', + }); + } catch (err) { + if (err instanceof TypeError) { + const networkError = new Error('네트워크 연결에 실패했습니다. 잠시 후 다시 시도해 주세요.') as ApiError; + networkError.name = 'NetworkError'; + networkError.status = 0; + networkError.statusText = 'NETWORK_ERROR'; + networkError.url = url; + throw networkError; + } + throw err as Error; + } if (!response.ok) { if (isServerErrorStatus(response.status)) { redirectToServerErrorPage(); + return undefined as never; } const error = new Error('토큰 갱신 실패') as ApiError; diff --git a/src/apis/client.ts b/src/apis/client.ts index fb093171..fe5fc4fb 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -74,7 +74,12 @@ async function throwApiError(response: Response): Promise { function rethrowFetchError(error: unknown, url: string): never { if (error instanceof Error && error.name === 'AbortError') { - throw new Error('요청 시간이 초과되었습니다.'); + const timeoutError = new Error('요청 시간이 초과되었습니다.') as ApiError; + timeoutError.name = 'TimeoutError'; + timeoutError.status = 0; + timeoutError.statusText = 'TIMEOUT'; + timeoutError.url = url; + throw timeoutError; } if (isFetchNetworkError(error)) { throw createNetworkApiError(url); From df3054ebc46d308d197521d61aff605934c90179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:16:17 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=9B=85=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/NotFound/index.tsx | 10 ++-------- src/pages/ServerError/index.tsx | 10 ++-------- src/utils/hooks/useErrorPageHomeNavigation.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 16 deletions(-) create mode 100644 src/utils/hooks/useErrorPageHomeNavigation.ts diff --git a/src/pages/NotFound/index.tsx b/src/pages/NotFound/index.tsx index 1c6aed40..b7b65adf 100644 --- a/src/pages/NotFound/index.tsx +++ b/src/pages/NotFound/index.tsx @@ -1,15 +1,9 @@ -import { useNavigate } from 'react-router-dom'; import NotFoundCatImage from '@/assets/image/not-found-cat.webp'; import ErrorPageLayout from '@/components/common/ErrorPageLayout'; -import { useAuthStore } from '@/stores/authStore'; +import { useErrorPageHomeNavigation } from '@/utils/hooks/useErrorPageHomeNavigation'; function NotFoundPage() { - const navigate = useNavigate(); - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - - const handleGoHome = () => { - navigate(isAuthenticated ? '/home' : '/', { replace: true }); - }; + const handleGoHome = useErrorPageHomeNavigation(); return ( state.isAuthenticated); - - const handleGoHome = () => { - navigate(isAuthenticated ? '/home' : '/', { replace: true }); - }; + const handleGoHome = useErrorPageHomeNavigation(); return ( state.isAuthenticated); + + return () => { + navigate(isAuthenticated ? '/home' : '/', { replace: true }); + }; +} From a6ff349cb37c1e6d87628b81e87220ab63848952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:16:31 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=ED=95=9C=EA=B8=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/auth/AuthGuard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/auth/AuthGuard.tsx b/src/components/auth/AuthGuard.tsx index 95231129..2456b8c3 100644 --- a/src/components/auth/AuthGuard.tsx +++ b/src/components/auth/AuthGuard.tsx @@ -21,7 +21,7 @@ function AuthGuard({ children }: AuthGuardProps) { if (isLoading && !shouldSkipInitialize) { return (
- Loading… + 로딩 중…
); From 763094310b7d410a95f64c9b01ab73c0c44cecaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:34:59 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/auth/index.ts | 2 +- src/apis/client.ts | 38 +++++++++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/apis/auth/index.ts b/src/apis/auth/index.ts index e143dec6..ce6193b2 100644 --- a/src/apis/auth/index.ts +++ b/src/apis/auth/index.ts @@ -29,7 +29,7 @@ export const refreshAccessToken = async (): Promise => { if (!response.ok) { if (isServerErrorStatus(response.status)) { redirectToServerErrorPage(); - return undefined as never; + throw new Error('서버 오류가 발생했습니다.'); } const error = new Error('토큰 갱신 실패') as ApiError; diff --git a/src/apis/client.ts b/src/apis/client.ts index fe5fc4fb..6522ce1f 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -72,14 +72,22 @@ async function throwApiError(response: Response): Promise { throw error; } -function rethrowFetchError(error: unknown, url: string): never { +function rethrowFetchError(error: unknown, url: string, isTimeout = false): never { if (error instanceof Error && error.name === 'AbortError') { - const timeoutError = new Error('요청 시간이 초과되었습니다.') as ApiError; - timeoutError.name = 'TimeoutError'; - timeoutError.status = 0; - timeoutError.statusText = 'TIMEOUT'; - timeoutError.url = url; - throw timeoutError; + if (isTimeout) { + const timeoutError = new Error('요청 시간이 초과되었습니다.') as ApiError; + timeoutError.name = 'TimeoutError'; + timeoutError.status = 0; + timeoutError.statusText = 'TIMEOUT'; + timeoutError.url = url; + throw timeoutError; + } + const cancelError = new Error('요청이 취소되었습니다.') as ApiError; + cancelError.name = 'Canceled'; + cancelError.status = 0; + cancelError.statusText = 'CANCELED'; + cancelError.url = url; + throw cancelError; } if (isFetchNetworkError(error)) { throw createNetworkApiError(url); @@ -137,7 +145,11 @@ async function sendRequest abortController.abort(), timeout); + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + abortController.abort(); + }, timeout); const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); @@ -186,7 +198,7 @@ async function sendRequest(response); } catch (error) { - rethrowFetchError(error, url); + rethrowFetchError(error, url, didTimeout); } finally { clearTimeout(timeoutId); } @@ -244,7 +256,11 @@ async function sendRequestWithoutRetry abortController.abort(), timeout); + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + abortController.abort(); + }, timeout); const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData); @@ -284,7 +300,7 @@ async function sendRequestWithoutRetry(response); } catch (error) { - rethrowFetchError(error, url); + rethrowFetchError(error, url, didTimeout); } finally { clearTimeout(timeoutId); } From 6ab06f67d02a79cdece62a4150d1a1024986067e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Tue, 3 Mar 2026 21:39:46 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/auth/index.ts | 1 + src/apis/client.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/apis/auth/index.ts b/src/apis/auth/index.ts index ce6193b2..0787a90f 100644 --- a/src/apis/auth/index.ts +++ b/src/apis/auth/index.ts @@ -33,6 +33,7 @@ export const refreshAccessToken = async (): Promise => { } const error = new Error('토큰 갱신 실패') as ApiError; + error.name = 'TokenRefreshError'; error.status = response.status; error.statusText = response.statusText; error.url = url; diff --git a/src/apis/client.ts b/src/apis/client.ts index 6522ce1f..fcb27348 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -59,6 +59,7 @@ function isFetchNetworkError(error: unknown): error is TypeError { async function throwApiError(response: Response): Promise { if (isServerErrorStatus(response.status)) { redirectToServerErrorPage(); + throw new Error('서버 오류가 발생했습니다.'); } const errorData = await parseErrorResponse(response);