본문으로 건너뛰기

https 인증은 서버만 필요합니다.

· 약 13분
arch-spatula

tl;dr https 설정에서 중요한 것은 서버에서 설장되어 있는 것이 중요합니다. 클라이언트 개발환경의 http로 되어 있어도 상관 없습니다.

에러로그: Access-Control-Allow-Origin

The 'Access-Control-Allow-Origin' header contains the invalid value 'false'

REGEX_ORIGIN=^.+http:\/\/localhost:(1234)|https:\/\/foo\.bar\.com$
import { Application } from 'https://deno.land/x/oak@v12.4.0/mod.ts';
import router from './routes/index.ts';
import { oakCors } from 'https://deno.land/x/cors@v1.2.2/mod.ts';
import { config } from 'https://deno.land/x/dotenv@v3.2.2/mod.ts';

const app = new Application();

const REGEX_ORIGIN = Deno.env.get('REGEX_ORIGIN') || config()['REGEX_ORIGIN']; // <-- 문자열을 가져옵니다.

app.use(
oakCors({
origin: new RegExp(REGEX_ORIGIN),
})
);
app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

http//도 앞에 붙여줘야 합니다. 어디 문서를 자세히 안 보고 도메인하고 포트번호(localhost:(1234))만 입력했는데 프로토콜을 명시했어야 합니다.

이결과 에러의 종류가 바뀌었습니다.

문제: ERR_SSL_PROTOCOL_ERROR

이제 다음 산입니다.

ERR_SSL_PROTOCOL_ERROR

input을 password, email로 지정하고 http 프로토콜로 통신했기 때문에 발생합니다.

예전 위 영상을 보면서 면접공부하기를 잘 했습니다. 일단 단순한 해결방법이 있는지 찾아봤습니다.

시도

Vite https on localhost

이었습니다.

yarn add -D vite-plugin-mkcert

하지만 중국에서 만든 라이브러리라 신뢰하기 어려웠습니다. 물론 stack overflow에서 최다 추천을 받기는 했습니다. 나중에 docker 사용할 줄 알 때 그때 사용하면 적당할 것 같습니다.

로컬에서 https를 만드는 것은 생각보다 어렵습니다.

어쩌면 다른 접근이 필요할지도 모릅니다.

해결

https://localhost:8000/api/auth/signin

개발 서버는 https가 아니었습니다.

http://localhost:8000/api/auth/signin

그냥 http였습니다. ㅂㄷㅂㄷ

학습

https는 서버만 필요합니다. 클라이언트 origin도 https 프로토콜로의 주소를 갖고 요청할 필요는 없습니다.

클라이언트 origin은 http://localhost:1234/이런 주소가 주체로 요청해도 괜찮습니다. 응답의 주체가 되는 origin이 https여야 합니다.

vite 서버 설정

vite.config.ts에서 서버와 관련된 설정과 플러그인을 설치할 수 있습니다. 이리 리액트 플러그인을 사용하고 있습니다. 포트번호 프로토콜 설정도 가능합니다.

/// <reference types="vitest" />
/// <reference types="vite/client" />

import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/setup.ts'],
},
server: {
https: true,
port: 1234,
},
});

vite 플러그인을 모은 레포도 있습니다.

vitejs / awesome-vite

테스트 설정이 실패했습니다.

테스트 환경에서는 리액트 Virtual DOM 트리에서 Context에 없습니다. 그래서 모두 공통적으로 한번에 provider로 감싸줘야 합니다.

import { describe, vi } from 'vitest';
import { Button } from '.';
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import { ThemeProvider } from '@emotion/react';
import theme from '../../../styles/theme';

describe('Button', () => {
it('should invoke the function when the button is clicked', async () => {
user.setup();
const btnText = 'Button';
const mock = vi.fn(() => 0);

render(
<ThemeProvider theme={theme}>
<Button onClick={mock}>{btnText}</Button>
</ThemeProvider>
);
const btnElement = screen.getByRole('button');
await user.click(btnElement);

expect(btnElement).toBeInTheDocument();
expect(mock).toHaveBeenCalledTimes(1);
});

it('should not invoke the function when the button is disabled', async () => {
user.setup();
const btnText = 'Button';
const mock = vi.fn(() => 0);

render(
<ThemeProvider theme={theme}>
<Button onClick={mock} disabled>
{btnText}
</Button>
</ThemeProvider>
);
const btnElement = screen.getByRole('button');
await user.click(btnElement);

expect(btnElement).toBeDisabled();
expect(mock).toHaveBeenCalledTimes(0);
});
});

provider가 너무 많습니다. 일단 현재 테스트 케이스는 정상적으로 동작합니다. provider 설정만 독립적으로 적용이 안 되어 있습니다.

RTL을 그냥 import 하면 오버라이드가 될 것이라고 착각하고 있었습니다. 아니였습니다.

import { render, screen } from '../../../libs/test-utils'; // <- 여기 수정
import { describe, vi } from 'vitest';
import { Button } from '.';
import user from '@testing-library/user-event';

describe('Button', () => {
it('should invoke the function when the button is clicked', async () => {
user.setup();
const btnText = 'Button';
const mock = vi.fn(() => 0);

render(<Button onClick={mock}>{btnText}</Button>);
const btnElement = screen.getByRole('button');
await user.click(btnElement);

expect(btnElement).toBeInTheDocument();
expect(mock).toHaveBeenCalledTimes(1);
});

it('should not invoke the function when the button is disabled', async () => {
user.setup();
const btnText = 'Button';
const mock = vi.fn(() => 0);

render(
<Button onClick={mock} disabled>
{btnText}
</Button>
);
const btnElement = screen.getByRole('button');
await user.click(btnElement);

expect(btnElement).toBeDisabled();
expect(mock).toHaveBeenCalledTimes(0);
});
});

레이아웃 중앙 정렬

import { Global } from '@emotion/react';
import GlobalStyle from '../styles/Reset';
import { Navbar } from '../Components';
import { Outlet } from 'react-router-dom';
import styled from '@emotion/styled';

/**
* @see https://github.com/WANTED-TEAM03/pre-onboarding-10th-1-3/blob/main/src/routes/_globalLayout.tsx
*/
function Layout() {
return (
<>
<Global styles={GlobalStyle} />
<Navbar />
<MainContainer>
<Outlet />
</MainContainer>
</>
);
}

const MainContainer = styled.main`
width: 82.5rem;
margin: auto;
min-height: calc(100vh - 4rem);
`;

export default Layout;

응답별 상태관리

클라이언트가 서버에게 요청을 보내고 응답을 받으면 각각 로직에 맞게 처리해줘야 합니다.

로그인에 케이스는 크게 3가지입니다. 없는 이메일, 비밀번호 불일치, 로그인 성공 각각 피드백 메시지를 제공하면 현실적으로 대응가능합니다. 이메일 저장은 나중에 구현하겠습니다.

import { Button, Input } from '../../Components';
import { signInAPI } from '../../api/authClient';
import { useInput } from '../../hooks';
import { useNavigate } from 'react-router-dom';
import { ROUTE_PATHS } from '../../constant/config';
import { useState } from 'react';

function SignIn() {
const { inputVal: emailValue, changeInputVal: changeEmail } = useInput();
const { inputVal: passwordValue, changeInputVal: changePassword } =
useInput();
const navigate = useNavigate();
const [emailError, setEmailError] = useState(false);
const [passwordError, setPasswordError] = useState(false);

const signIn = async () => {
try {
setEmailError(false);
setPasswordError(false);
const res = await signInAPI(emailValue, passwordValue);
if (res?.msg === 'Error: 비밀번호가 일치하지 않습니다.')
throw new Error('비밀번호가 일치하지 않습니다.');
if (res?.msg === 'Error: 이메일이 없습니다.')
throw new Error('이메일이 없습니다.');

if (res?.success) navigate(ROUTE_PATHS.CARDS);
} catch (error) {
const err = error as Error;
if (err.message === '이메일이 없습니다.') {
setEmailError(true);
}
if (err.message === '비밀번호가 일치하지 않습니다.')
setPasswordError(true);
console.log(err.message);
}
};

return (
<div>
<h1>Sign In</h1>
<Input type="email" onChange={changeEmail} value={emailValue} />
{emailError && <p>이메일이 없습니다.</p>}
<Input type="password" onChange={changePassword} value={passwordValue} />
{passwordError && <p>비밀번호가 일치하지 않습니다.</p>}
<Button onClick={signIn}>Sign In</Button>
</div>
);
}

export { SignIn };

일단 브루트 포스처럼 구현했습니다. 조금더 우아하게 처리하고 싶습니다. Input에 helperText에 대한 상태관리는 일반적입니다. 이부분을 리팩토링하고 싶습니다.

또 다른 문제는 높이 고정문제입니다. hideHelper를 props로 받는 것이 적합해보입니다. 기본은 false인데 true 설정하면 helperText가 있어도 랜더링이 안 되도록 처리해야 하겠습니다.

helperText의 랜더링 제어는 텍스 자체가 존재하고 말고를 기준으로 활용하면 될 것 같습니다.

로그인 저장

import { Button, Input } from '../../Components';
import { signInAPI } from '../../api/authClient';
import { useInput } from '../../hooks';
import { useNavigate } from 'react-router-dom';
import { ROUTE_PATHS } from '../../constant/config';
import { useState } from 'react';
import { MainContainer } from './SignIn.style';

function SignIn() {
const { inputVal: emailValue, changeInputVal: changeEmail } = useInput();
const { inputVal: passwordValue, changeInputVal: changePassword } =
useInput();
const navigate = useNavigate();
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');

const signIn = async () => {
try {
setEmailError('');
setPasswordError('');
const res = await signInAPI(emailValue, passwordValue);

if (res?.msg === 'Error: 비밀번호가 일치하지 않습니다.')
throw new Error('비밀번호가 일치하지 않습니다.');
if (res?.msg === 'Error: 이메일이 없습니다.')
throw new Error('이메일이 없습니다.');

if (res?.success) {
const { access_token } = res;
if (access_token) localStorage.setItem('token', `${access_token}`);
navigate(ROUTE_PATHS.CARDS);
}
} catch (error) {
const err = error as Error;
if (err.message === '이메일이 없습니다.') {
setEmailError('이메일이 없습니다.');
}
if (err.message === '비밀번호가 일치하지 않습니다.') {
setPasswordError('비밀번호가 일치하지 않습니다.');
}
}
};

return (
<MainContainer>
<h1>Sign In</h1>
<Input
type="email"
onChange={changeEmail}
value={emailValue}
helperText={emailError}
/>
<Input
type="password"
onChange={changePassword}
value={passwordValue}
helperText={passwordError}
/>
<Button onClick={signIn}>Sign In</Button>
</MainContainer>
);
}

export { SignIn };

단순하게 기능 구현은 성공했습니다. 다음은 리액트 쿼리 활용입니다. 지금은 통신할 때 모두 직접 axios로 통신합니다. 상태관리가 불필요하게 복잡해질 가능성이 큽니다.

또 useInput에 focus와 HelperText를 제어할 수 있으면 좋을 것 같습니다.

focus제어도 추가하면 될 것 같습니다.

HelperText는 state machine 패턴을 활용하면 될 것 같습니다.

라우팅 테스트 딜레마

import { ThemeProvider } from '@emotion/react';
import theme from '../../styles/theme';
import queryClient from '../queryClient';
import { QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';

type AllProvidersProps = { children: React.ReactNode };

export default function AllProviders({ children }: AllProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<BrowserRouter>{children}</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
);
}
import { render } from '@testing-library/react';
import AllProviders from './AllTheProviders';

const customRender = (ui: React.ReactElement, options = {}) =>
render(ui, {
wrapper: AllProviders,
...options,
});

// eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';

// override render export
export { customRender as render };
import { SignIn } from '.';
import { render, screen } from '../../libs/test-utils';
import { describe, it } from 'vitest';

describe('SignIn', () => {
it('should render Sign In as Heading', () => {
render(<SignIn />);

const headingElement = screen.getByRole('heading', { level: 1 });
expect(headingElement).toBeInTheDocument();
expect(headingElement.textContent).toBe('Sign In');
});
});

이렇게 하면 페이지 단위에서 테스트 코드를 provider 없이 작성할 수 있습니다.

import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import Layout from './GlobalLayout';
import {
Cards,
Deck,
Landing,
NotFound,
Setting,
SignIn,
SignUp,
} from '../pages';
import { ROUTE_PATHS } from '../constant/config';

/**
* 참고 자료
* @see https://github.com/WANTED-TEAM03/pre-onboarding-10th-1-3/blob/main/src/routes/Routes.tsx
* @see https://github.com/wanted-frontedend-team5/pre-onboarding-10th-1-5/blob/main/src/router/Router.jsx
*/

const routes = createBrowserRouter([
{
path: ROUTE_PATHS.WELCOME,
element: <Layout />,
children: [
{ index: true, element: <Landing /> },
{ path: ROUTE_PATHS.SIGN_IN, element: <SignIn /> },
{ path: ROUTE_PATHS.SIGN_UP, element: <SignUp /> },
{ path: ROUTE_PATHS.CARDS, element: <Cards /> },
{ path: ROUTE_PATHS.DECK, element: <Deck /> },
{ path: ROUTE_PATHS.SETTING, element: <Setting /> },
],
errorElement: <NotFound />,
},
]);

function Router() {
return <RouterProvider router={routes} />;
}

export default Router;
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ThemeProvider } from '@emotion/react';
import theme from './styles/theme.ts';
import queryClient from './libs/queryClient.ts';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
<ReactQueryDevtools />
</QueryClientProvider>
</React.StrictMode>
);

문제는 App에서 또 BrowserRouter를 적용하면 중복 적용이 됩니다. App에서 DOM 트리가 독립적으로 동작하는 것이 아닙니다.

이럴 때 접근할 수 있는 다른 전략이 있습니다.

일단 App 컴포넌트에서 진행해야 하는 E2E 성격에 가까운 결합테스트는 나중에 진행하겠습니다.