(엄격 근엄 진지 모드에 관하여)
OAuth 관련 로직(구글)을 개발 중이었습니다. 구글 버튼을 클릭하며 지속적으로 테스트를 진행해야 하는 상황이었는데요, 인가 코드를 재사용 했다는 이유로 토큰을 발급받는 과정에서 계속해서 에러가 발생했습니다.
prompt=consent를 추가하기로 했습니다. 구글 OAuth에서 prompt=consent는 사용자에게 매번 동의 화면을 표시하도록 강제하는 파라미터입니다.

사용자가 이미 해당 애플리케이션에 권한을 허용했더라도, 매번 동의 화면이 표시됩니다. 달리 표현하면 매번 새로운 인가 코드(Authorization Code)가 발급된다는 것이지요. 그런데 여전히 인가 코드 재사용에 대한 에러가 계속해서 발생했습니다.
로깅을 해보니, 구글 버튼을 단 한 번 클릭했음에도 불구하고 요청이 동시에 두 번 들어가는 것을 확인할 수 있었습니다. 백엔드에서는 함수를 한 번 실행하는데 요청이 두 번 들어갔다는 것은, 백엔드의 문제가 아니라는 반증이었습니다.

인가 코드를 통해 다시 백엔드로 요청을 보내는 부분은 프론트엔드의 RedirectPage였습니다. RedirectPage의 useEffect 내에서 인가 코드를 백엔드로 전달하는 함수가 두 번 실행되었을 가능성이 높다고 판단했습니다.

위 함수는 useEffect에서, 인가 코드와 provider(=google)가 정확하게 반영되었을 때, 백엔드로 post 요청을 진행하는 함수입니다. 여전히 useEffect와 위 함수에도 문제가 없었습니다. 함수를 생성하고 내부에서 한 번 실행했기 때문이죠.
'무엇이 혹은 어떤 상황이 useEffect를 두 번 반복해서 실행하는가'에 대한 답을 찾아야 했습니다.

답은 StrictMode에 있었습니다. 하나하나 톺아보겠습니다.
<StrictMode> lets you find common bugs in your components early during development.
개발 과정에서 발생할 버그를 미리 발견하기 위해 사용하는 모드임을 공식 문서에서 명시하고 있습니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
컴포넌트 내부의 전체 컴포넌트 트리에 대해 '개발 환경에서만' 동작하는 추가적인 검사 기능을 활성화하는 역할을 합니다. 앱 전체에 StrictMode를 활성화하기 위해서는 위와 같은 방식으로 App 컴포넌트를 감싸주면 됩니다.
import { StrictMode } from 'react';
function App() {
return (
<>
<Header />
<StrictMode>
<main>
<Sidebar />
<Content />
</main>
</StrictMode>
<Footer />
</>
);
}
앱의 특정 부분에만 적용하고 싶다면, 해당 부분을 StrictMode로 감싸주면 됩니다. 이때, Header와 Footer에는 StrictMode가 적용되지 않겠지요.
React는 우리가 작성하는 모든 컴포넌트가 Pure Function이라고 가정합니다. 이러한 가정을 기반으로 Impure 한 함수는 두 번 호출합니다. Pure Function은 sum(2,3)과 같이, 입력값만이 출력을 결정하는 형태의 함수를 의미합니다. 당연히 우리가 개발하는 함수들은 완전하게 Pure 할 수 없습니다. 그래서도 안 되고요.
두 번 호출되는 대상은 다음과 같습니다.

// index.js
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';
const root = createRoot(document.getElementById("root"));
root.render(<App />);
// App.js
import { useState } from 'react';
import StoryTray from './StoryTray.js';
let initialStories = [
{id: 0, label: "Ankit's Story" },
{id: 1, label: "Taylor's Story" },
];
export default function App() {
let [stories, setStories] = useState(initialStories)
return (
<div
style={{
width: '100%',
height: '100%',
textAlign: 'center',
}}
>
<StoryTray stories={stories} />
</div>
);
}
// StoryTray.js
export default function StoryTray({ stories }) {
const items = stories;
items.push({ id: 'create', label: 'Create Story' });
return (
<ul>
{items.map(story => (
<li key={story.id}>
{story.label}
</li>
))}
</ul>
);
}

언뜻 봤을 때는 위 코드에서 문제점을 발견하기 어렵습니다.
리액트는 리스트 항목을 효율적으로 업데이트하고 재사용하기 위해, 각 항목에 '고유한' key 속성을 필요로 합니다.
props로 전달하는 객체의 id는 정수이고, StoryTray.js에서 새롭게 추가하는 객체의 id는 'create'입니다. 만약 props로 전달되는 stories 배열에 이미 id가 'create'인 요소가 존재한다면, StoryTray.js에서 새롭게 추가하는 id: 'create' 항목이 중복되어 리액트가 이를 구별하지 못하고 잘못된 렌더링을 유발할 수 있습니다.
이처럼 StrictMode는, 당장은 문제로 보이지 않을 수 있지만, 개발 과정에서 충분히 발생할 수 있는 버그를 미리 발견하는 역할을 하게 됩니다.
사실 이번 파트가 트러블 슈팅의 핵심이었습니다.
useEffect에는 setup과 cleanup 코드가 있을 수 있습니다. 컴포넌트가 마운트 될 때 setup 코드를 호출하고, 컴포넌트가 언마운트 될 때 cleanup 코드를 호출합니다. 만약 의존성이 등록되어 마지막 렌더링 이후 해당 의존성이 변경되었다면, setup과 cleanup 코드가 다시 호출되겠지요.
StrictMode가 활성화되면, 개발 모드에서 매 렌더링마다 해당 이펙트의 setup과 cleanup 코드를 한 번 더 실행하여, 수동으로 캐치하기 어려운 미세한 버그를 발견하는 데 도움을 줍니다.
// index.js
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';
const root = createRoot(document.getElementById("root"));
root.render(<App />);
// App.js
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>;
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [show, setShow] = useState(false);
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<button onClick={() => setShow(!show)}>
{show ? 'Close chat' : 'Open chat'}
</button>
{show && <hr />}
{show && <ChatRoom roomId={roomId} />}
</>
);
}
// Chat.js
let connections = 0;
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
connections++;
console.log('Active connections: ' + connections);
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
connections--;
console.log('Active connections: ' + connections);
}
};
}
위 코드에도 보이지 않는 문제가 있습니다. disconnect를 수행하는 cleanup 코드가 없기 때문에, 채팅방이 변경되어도 이전 채팅방과의 연결이 백그라운드에서 지속되고 있습니다. connections의 증가를 보면 알 수 있습니다.
기존 채팅방에 대한 연결이 지속되면, 성능 저하나 네트워크 관련 이슈가 발생할 가능성이 높습니다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
cleanup에 대한 로직을 추가하면 기존의 메모리 누수 문제는 해결할 수 있겠으나, 이러한 문제를 조기에 식별하기란 굉장히 어려운 일입니다.
StrictMode를 사용하면, 최초에 useEffect에서 setup 코드를 실행하고, 마치 언마운트를 한 번 거친 것처럼 cleanup 코드를 실행한 뒤 다시 setup 코드를 실행합니다. 위에서 이미 언급했던 "개발 모드에서 매 렌더링마다 해당 이펙트의 setup과 cleanup 코드를 한 번 더 실행하여"에 해당합니다. 마치 수학 시험에서 이미 푼 문제를 검산하는 것과 같습니다.
state와 effect의 사용 과정에서, 개발자가 미처 발견하기 어려운 버그를 '사전에' 발견하기 위해 StrictMode를 사용하는 것이라고 요약할 수 있겠습니다.

이제는 이해할 수 있겠습니다. 프로젝트에 StrictMode가 적용되어 있었고, 그로 인해 setup에 해당하는 authorizationMutate() 함수가 다시 한번 실행되게 됩니다. authorizationMutate()는 3000/auth/google/user 경로로 인가 코드를 post 하는 역할을 수행합니다.
최초에 설정한 prompt=consent 이후에 동일한 인가 코드를 동시에 서버에 전송하니, 백엔드 입장에서는 getToken() 과정에서 에러를 발생시킬 수밖에 없었던 것입니다.
당장은 StrictMode를 사용하지 않는 것으로 문제를 일부 해결했습니다. 하지만 StrictMode가 제공하는 장점이 크기 때문에, 이런 식의 갈무리는 안티 패턴에 가깝다는 생각이 듭니다. 우선 백엔드 로직을 마무리한 뒤에, 해당 문제에 대한 더 높은 수준의 해결책을 찾아야겠습니다.
reference: https://react.dev/reference/react/StrictMode