ITN Dev 07-22
fix: 채널 생성 화면의 재로드 안전성, 중복 클릭 방지, 오류 처리를 개선
- OrgDetail의 초기화 effect를 org 객체 identity 대신 org.id에 키잉해, 채널 생성
  성공 후 App.reload()가 만드는 새 객체가 방금 받은 provision 결과/에러/입력 중이던
  담당자 정보를 지워버리지 않게 함(다른 기관으로 전환할 때는 그대로 초기화).
- 양쪽 채널이 모두 실패했을 때 "문정원 채널은 살아있다"는 잘못된 안내를 보여주던
  문제를 3분기(둘 다 실패/법률검토만 실패/문정원만 실패)로 재구성해 해결.
- 정보 저장/채널 생성/시드 업로드에 busy 상태를 추가해 처리 중 중복 클릭으로 두
  요청이 동시에 나가는 것을 방지. ConfirmDialog도 busy 중엔 두 버튼을 비활성화.
- api/client에 상태코드를 담는 ApiError를 도입해 401만 로그아웃으로 취급하고,
  그 외 오류는 재시도 버튼이 있는 오류 화면으로 안내(App.tsx).
- 필수 담당자 정보(부서명/담당자명/연락처/이메일)가 비어 있으면 정보 저장 버튼을
  비활성화(직급/직함은 선택 유지).
- 로그인/기관검색 입력에 aria-label과 autoComplete 추가, StatusBadge READY 케이스
  테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) 
@6fc9ca73271050cab33fe955ddb2cdd6685b5112
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useState } from 'react'
-import { getOrgs, type Org } from './api/client'
+import { ApiError, getOrgs, type Org } from './api/client'
 import LoginPage from './components/LoginPage'
 import OrgDetail from './components/OrgDetail'
 import OrgList from './components/OrgList'
@@ -7,19 +7,30 @@
 
 // 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도
 // 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다.
-type AuthState = 'checking' | 'authenticated' | 'anonymous'
+//
+// 'error'는 401이 아닌 실패(서버 500, 네트워크 단절 등)를 위한 상태다. 이런 오류까지
+// '로그아웃'으로 취급해 로그인 화면으로 보내면, 로그인해도 같은 오류가 반복되고
+// 원인을 알 수 없게 된다 - 세션은 멀쩡한데 서버/네트워크만 문제인 경우이므로
+// 오류 메시지와 재시도 버튼을 보여주는 편이 맞다.
+type AuthState = 'checking' | 'authenticated' | 'anonymous' | 'error'
 
 export default function App() {
   const [orgs, setOrgs] = useState<Org[]>([])
   const [selectedId, setSelectedId] = useState<number | null>(null)
   const [auth, setAuth] = useState<AuthState>('checking')
+  const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null)
 
   const reload = useCallback(async () => {
     try {
       setOrgs(await getOrgs())
       setAuth('authenticated')
-    } catch {
-      setAuth('anonymous')
+    } catch (e) {
+      if (e instanceof ApiError && e.status === 401) {
+        setAuth('anonymous')
+      } else {
+        setAuthErrorMessage((e as Error).message)
+        setAuth('error')
+      }
     }
   }, [])
 
@@ -35,6 +46,21 @@
     return <LoginPage onSuccess={() => void reload()} />
   }
 
+  if (auth === 'error') {
+    return (
+      <div className="flex min-h-screen flex-col items-center justify-center gap-3 p-8 text-sm">
+        <p className="text-red-600">{authErrorMessage}</p>
+        <button
+          type="button"
+          onClick={() => void reload()}
+          className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
+        >
+          다시 시도
+        </button>
+      </div>
+    )
+  }
+
   const selected = orgs.find((org) => org.id === selectedId) ?? null
 
   return (
frontend/src/api/client.test.ts
--- frontend/src/api/client.test.ts
+++ frontend/src/api/client.test.ts
@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { getOrgs, provisionChannels, updateContact } from './client'
+import { ApiError, getOrgs, provisionChannels, updateContact } from './client'
 
 afterEach(() => {
   vi.unstubAllGlobals()
@@ -35,15 +35,19 @@
     expect(init.headers['X-XSRF-TOKEN']).toBe('token-value')
   })
 
-  it('응답이 실패면 예외를 던진다', async () => {
+  it('응답이 실패면 status를 담은 ApiError를 던진다', async () => {
     vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
       ok: false,
       status: 400,
       text: async () => 'bad request',
     }))
 
-    await expect(updateContact(1, {
+    const promise = updateContact(1, {
       deptName: '', managerName: '', managerTitle: '', managerPhone: '', managerEmail: '',
-    })).rejects.toThrow()
+    })
+
+    await expect(promise).rejects.toThrow()
+    await expect(promise).rejects.toBeInstanceOf(ApiError)
+    await expect(promise).rejects.toMatchObject({ status: 400 })
   })
 })
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -36,6 +36,22 @@
   total: number
 }
 
+/**
+ * 서버가 4xx/5xx로 응답했을 때 던지는 예외. status를 함께 들고 있어야 호출자가
+ * "인증이 끊긴 것(401)"과 "그 밖의 모든 오류"를 구분해서 다르게 처리할 수 있다 -
+ * 그렇지 않으면(예: 평범한 Error) 서버 오류나 네트워크 문제까지 전부 로그아웃으로
+ * 오인해 사용자를 로그인 화면으로 쫓아내게 된다.
+ */
+export class ApiError extends Error {
+  constructor(
+    public status: number,
+    message: string,
+  ) {
+    super(message)
+    this.name = 'ApiError'
+  }
+}
+
 /** Spring Security가 내려주는 CSRF 쿠키를 읽어 헤더로 되돌려 보낸다. */
 function csrfToken(): string {
   const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)
@@ -54,7 +70,7 @@
 
   if (!response.ok) {
     const body = await response.text()
-    throw new Error(`요청 실패 (${response.status}): ${body}`)
+    throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`)
   }
   return response.json() as Promise<T>
 }
@@ -93,6 +109,6 @@
     body,
   })
   if (!response.ok) {
-    throw new Error('아이디 또는 비밀번호가 올바르지 않습니다.')
+    throw new ApiError(response.status, '아이디 또는 비밀번호가 올바르지 않습니다.')
   }
 }
frontend/src/components/ConfirmDialog.tsx
--- frontend/src/components/ConfirmDialog.tsx
+++ frontend/src/components/ConfirmDialog.tsx
@@ -4,9 +4,18 @@
   confirmLabel: string
   onConfirm: () => void
   onCancel: () => void
+  /** 확인 액션이 진행 중일 때 true. 두 버튼을 모두 비활성화해 중복 클릭을 막는다. */
+  busy?: boolean
 }
 
-export default function ConfirmDialog({ title, lines, confirmLabel, onConfirm, onCancel }: Props) {
+export default function ConfirmDialog({
+  title,
+  lines,
+  confirmLabel,
+  onConfirm,
+  onCancel,
+  busy = false,
+}: Props) {
   return (
     <div className="fixed inset-0 flex items-center justify-center bg-black/30">
       <div className="w-96 rounded-lg bg-white p-5">
@@ -21,15 +30,17 @@
         <div className="mt-5 flex justify-end gap-2">
           <button
             type="button"
+            disabled={busy}
             onClick={onCancel}
-            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
           >
             취소
           </button>
           <button
             type="button"
+            disabled={busy}
             onClick={onConfirm}
-            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white"
+            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
           >
             {confirmLabel}
           </button>
frontend/src/components/LoginPage.tsx
--- frontend/src/components/LoginPage.tsx
+++ frontend/src/components/LoginPage.tsx
@@ -26,6 +26,8 @@
         <input
           className="mt-6 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
           placeholder="아이디"
+          aria-label="아이디"
+          autoComplete="username"
           value={username}
           onChange={(e) => setUsername(e.target.value)}
         />
@@ -33,6 +35,8 @@
           className="mt-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
           type="password"
           placeholder="비밀번호"
+          aria-label="비밀번호"
+          autoComplete="current-password"
           value={password}
           onChange={(e) => setPassword(e.target.value)}
         />
frontend/src/components/OrgDetail.test.tsx
--- frontend/src/components/OrgDetail.test.tsx
+++ frontend/src/components/OrgDetail.test.tsx
@@ -112,4 +112,130 @@
       expect(screen.getByText(/다시 눌러도 안전/)).toBeTruthy()
     })
   })
+
+  it('양쪽 다 실패하면 문정원 채널이 살아있다는 잘못된 안내를 보여주지 않는다', async () => {
+    mocks.provisionChannels.mockResolvedValue({
+      mj: 'FAILED',
+      law: 'FAILED',
+      message: '[문정원] 서버 오류\n[법률검토] 서버 오류',
+    })
+
+    render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+    fireEvent.click(screen.getByRole('button', { name: '생성' }))
+
+    await waitFor(() => {
+      expect(screen.getByText(/문정원 채널.*법률검토 채널.*모두 생성에 실패/)).toBeTruthy()
+    })
+    expect(screen.queryByText(/문정원 채널은 그대로 남아 있으니/)).toBeNull()
+  })
+
+  it('문정원만 실패하면 법률검토 채널이 살아있다는 안내를 보여준다', async () => {
+    mocks.provisionChannels.mockResolvedValue({
+      mj: 'FAILED',
+      law: 'CREATED',
+      message: '[문정원] 서버 오류',
+    })
+
+    render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+    fireEvent.click(screen.getByRole('button', { name: '생성' }))
+
+    await waitFor(() => {
+      expect(screen.getByText(/문정원 채널 생성에 실패/)).toBeTruthy()
+      expect(screen.getByText(/법률검토 채널은 그대로 남아 있으니/)).toBeTruthy()
+    })
+  })
+
+  it('채널 생성이 진행 중이면 생성 버튼과 채널 생성 버튼이 비활성화된다', async () => {
+    let resolvePromise: (value: { mj: string; law: string; message: string }) => void = () => {}
+    mocks.provisionChannels.mockReturnValue(
+      new Promise((resolve) => {
+        resolvePromise = resolve
+      }),
+    )
+
+    render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+    fireEvent.click(screen.getByRole('button', { name: '생성' }))
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: '생성' })).toBeDisabled()
+    })
+    expect(screen.getByRole('button', { name: '채널 생성' })).toBeDisabled()
+
+    resolvePromise({ mj: 'CREATED', law: 'CREATED', message: '' })
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: '채널 생성' })).toBeEnabled()
+    })
+  })
+
+  it('필수 항목이 비어있으면 정보 저장 버튼이 비활성화된다', () => {
+    render(<OrgDetail org={org()} onChanged={() => {}} />)
+
+    expect(screen.getByRole('button', { name: '정보 저장' })).toBeDisabled()
+  })
+
+  it('직급/직함이 비어있어도 나머지 필수 항목만 채우면 정보 저장 버튼이 활성화된다', () => {
+    render(<OrgDetail org={org()} onChanged={() => {}} />)
+
+    fireEvent.change(screen.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } })
+    fireEvent.change(screen.getByLabelText('담당자명'), { target: { value: '송민지' } })
+    fireEvent.change(screen.getByLabelText('연락처'), { target: { value: '02-3475-5434' } })
+    fireEvent.change(screen.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } })
+
+    expect(screen.getByRole('button', { name: '정보 저장' })).toBeEnabled()
+  })
+
+  it('reload로 org 객체가 새로 만들어져도 같은 기관이면 결과가 지워지지 않는다', async () => {
+    mocks.provisionChannels.mockResolvedValue({
+      mj: 'CREATED',
+      law: 'FAILED',
+      message: '[법률검토] 서버 오류',
+    })
+
+    const { rerender } = render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+    fireEvent.click(screen.getByRole('button', { name: '생성' }))
+
+    await waitFor(() => {
+      expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy()
+    })
+
+    // App.reload()가 만드는, id는 같지만 identity가 다른 새 객체를 흉내낸다.
+    rerender(
+      <OrgDetail
+        org={org({ status: 'PARTIAL', channelIdMj: 'chan-mj' })}
+        onChanged={() => {}}
+      />,
+    )
+
+    expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy()
+  })
+
+  it('다른 기관으로 전환하면 이전 결과와 입력값이 초기화된다', async () => {
+    mocks.provisionChannels.mockResolvedValue({
+      mj: 'CREATED',
+      law: 'FAILED',
+      message: '[법률검토] 서버 오류',
+    })
+
+    const { rerender } = render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+    fireEvent.click(screen.getByRole('button', { name: '생성' }))
+
+    await waitFor(() => {
+      expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy()
+    })
+
+    rerender(<OrgDetail org={org({ id: 2, status: 'READY' })} onChanged={() => {}} />)
+
+    expect(screen.queryByText(/법률검토 채널 생성에 실패/)).toBeNull()
+  })
 })
frontend/src/components/OrgDetail.tsx
--- frontend/src/components/OrgDetail.tsx
+++ frontend/src/components/OrgDetail.tsx
@@ -27,40 +27,61 @@
   }
 }
 
+const REQUIRED_FIELDS: (keyof Contact)[] = [
+  'deptName',
+  'managerName',
+  'managerPhone',
+  'managerEmail',
+]
+
 export default function OrgDetail({ org, onChanged }: { org: Org; onChanged: () => void }) {
   const [contact, setContact] = useState<Contact>(emptyContact(org))
   const [confirming, setConfirming] = useState(false)
   const [result, setResult] = useState<ProvisionResult | null>(null)
   const [error, setError] = useState<string | null>(null)
+  const [busy, setBusy] = useState(false)
 
+  // org.id로만 키를 잡는다(org 객체 전체가 아니라) - App.reload()가 채널 생성 뒤에
+  // 새 배열/새 객체를 만들어 넘기더라도, 같은 기관을 계속 보고 있는 한 방금 받은
+  // result/error를 이 effect가 지워버리면 안 되기 때문이다(같은 이유로 입력 중이던
+  // contact 값도 다른 기관으로 전환할 때만 초기화한다).
   useEffect(() => {
     setContact(emptyContact(org))
     setResult(null)
     setError(null)
-  }, [org])
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [org.id])
 
   const displayMj = `${org.orgNo}_${org.orgName} (문정원)`
   const displayLaw = `${org.orgNo}_${org.orgName} (법률검토)`
   const canProvision = org.status !== 'INFO_PENDING'
+  const requiredFilled = REQUIRED_FIELDS.every((key) => contact[key].trim() !== '')
 
   async function save() {
     setError(null)
+    setBusy(true)
     try {
       await updateContact(org.id, contact)
       onChanged()
     } catch (e) {
       setError((e as Error).message)
+    } finally {
+      setBusy(false)
     }
   }
 
   async function provision() {
-    setConfirming(false)
     setError(null)
+    setBusy(true)
     try {
-      setResult(await provisionChannels(org.id))
+      const provisionResult = await provisionChannels(org.id)
+      setResult(provisionResult)
       onChanged()
     } catch (e) {
       setError((e as Error).message)
+    } finally {
+      setBusy(false)
+      setConfirming(false)
     }
   }
 
@@ -95,14 +116,15 @@
         <div className="mt-5 flex gap-2">
           <button
             type="button"
+            disabled={busy || !requiredFilled}
             onClick={() => void save()}
-            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
           >
             정보 저장
           </button>
           <button
             type="button"
-            disabled={!canProvision}
+            disabled={busy || !canProvision}
             onClick={() => setConfirming(true)}
             className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
           >
@@ -118,16 +140,25 @@
           <p>문정원 채널: {outcomeLabel(result.mj)}</p>
           <p>법률검토 채널: {outcomeLabel(result.law)}</p>
 
-          {result.law === 'FAILED' && (
+          {result.mj === 'FAILED' && result.law === 'FAILED' ? (
             <p className="mt-2 text-amber-700">
-              법률검토 채널 생성에 실패했습니다. 문정원 채널은 그대로 남아 있으니
-              [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다.
+              문정원 채널과 법률검토 채널 모두 생성에 실패했습니다. 아직 만들어진 채널이 없으니 [채널 생성]을 다시 눌러도 안전합니다.
             </p>
-          )}
-          {result.mj === 'FAILED' && (
-            <p className="mt-2 text-amber-700">
-              문정원 채널 생성에 실패했습니다. [채널 생성]을 다시 눌러도 안전합니다.
-            </p>
+          ) : (
+            <>
+              {result.law === 'FAILED' && (
+                <p className="mt-2 text-amber-700">
+                  법률검토 채널 생성에 실패했습니다. 문정원 채널은 그대로 남아 있으니
+                  [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다.
+                </p>
+              )}
+              {result.mj === 'FAILED' && (
+                <p className="mt-2 text-amber-700">
+                  문정원 채널 생성에 실패했습니다. 법률검토 채널은 그대로 남아 있으니
+                  [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다.
+                </p>
+              )}
+            </>
           )}
           {result.message && (
             <pre className="mt-2 whitespace-pre-wrap text-xs text-gray-500">{result.message}</pre>
@@ -140,6 +171,7 @@
           title="아래 채널 2개를 Mattermost에 생성합니다."
           lines={[displayMj, displayLaw]}
           confirmLabel="생성"
+          busy={busy}
           onConfirm={() => void provision()}
           onCancel={() => setConfirming(false)}
         />
frontend/src/components/OrgList.tsx
--- frontend/src/components/OrgList.tsx
+++ frontend/src/components/OrgList.tsx
@@ -19,6 +19,7 @@
         <input
           className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
           placeholder="기관명 검색"
+          aria-label="기관명 검색"
           value={keyword}
           onChange={(e) => setKeyword(e.target.value)}
         />
 
frontend/src/components/SeedUpload.test.tsx (added)
+++ frontend/src/components/SeedUpload.test.tsx
@@ -0,0 +1,55 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import SeedUpload from './SeedUpload'
+
+const mocks = vi.hoisted(() => ({
+  uploadSeed: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  uploadSeed: mocks.uploadSeed,
+}))
+
+beforeEach(() => {
+  mocks.uploadSeed.mockReset()
+})
+
+function selectFile() {
+  const file = new File(['dummy'], 'seed.xlsx', {
+    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  })
+  const input = document.querySelector('input[type="file"]') as HTMLInputElement
+  fireEvent.change(input, { target: { files: [file] } })
+}
+
+describe('SeedUpload', () => {
+  /**
+   * Finding I3 회귀 테스트: 업로드가 진행 중일 때 버튼을 다시 누르면 두 번째 업로드
+   * 요청이 겹쳐서 나갈 수 있다. 업로드 promise가 아직 풀리지 않은 동안 버튼이
+   * 비활성화되는지 검증한다.
+   */
+  it('업로드가 진행 중이면 버튼이 비활성화된다', async () => {
+    let resolvePromise: (value: { created: number; updated: number; total: number }) => void =
+      () => {}
+    mocks.uploadSeed.mockReturnValue(
+      new Promise((resolve) => {
+        resolvePromise = resolve
+      }),
+    )
+
+    render(<SeedUpload onUploaded={() => {}} />)
+
+    selectFile()
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: '신청목록 시트 올리기' })).toBeDisabled()
+    })
+
+    resolvePromise({ created: 1, updated: 0, total: 1 })
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: '신청목록 시트 올리기' })).toBeEnabled()
+    })
+  })
+})
frontend/src/components/SeedUpload.tsx
--- frontend/src/components/SeedUpload.tsx
+++ frontend/src/components/SeedUpload.tsx
@@ -5,14 +5,18 @@
   const inputRef = useRef<HTMLInputElement>(null)
   const [report, setReport] = useState<SeedReport | null>(null)
   const [error, setError] = useState<string | null>(null)
+  const [busy, setBusy] = useState(false)
 
   async function handle(file: File) {
     setError(null)
+    setBusy(true)
     try {
       setReport(await uploadSeed(file))
       onUploaded()
     } catch (e) {
       setError((e as Error).message)
+    } finally {
+      setBusy(false)
     }
   }
 
@@ -31,8 +35,9 @@
       />
       <button
         type="button"
+        disabled={busy}
         onClick={() => inputRef.current?.click()}
-        className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
+        className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm disabled:opacity-50"
       >
         신청목록 시트 올리기
       </button>
frontend/src/components/StatusBadge.test.tsx
--- frontend/src/components/StatusBadge.test.tsx
+++ frontend/src/components/StatusBadge.test.tsx
@@ -8,6 +8,11 @@
     expect(screen.getByText('정보 대기')).toBeTruthy()
   })
 
+  it('생성 가능 상태를 보여준다', () => {
+    render(<StatusBadge status="READY" />)
+    expect(screen.getByText('생성 가능')).toBeTruthy()
+  })
+
   it('부분 생성 상태를 구분해서 보여준다', () => {
     render(<StatusBadge status="PARTIAL" />)
     expect(screen.getByText('부분 생성')).toBeTruthy()
Add a comment
List