Skip to content
Merged
61 changes: 61 additions & 0 deletions docs/local-backend-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 로컬 BE 연동 테스트 (OAuth 우회 로그인)

로컬 BE(`debate-timer-be`, `local` 프로필)와 FE를 함께 띄워 테스트할 때 쓰는 방법입니다.
로컬에서는 Google OAuth 로그인이 동작하지 않으므로, 테스트 회원을 직접 넣고 local JWT secret으로 서명한 액세스 토큰을 `localStorage`에 넣어 로그인한 상태로 만듭니다.

## 1. 로컬 BE 실행

`local` 프로필은 H2 메모리 DB(`jdbc:h2:mem:database`)를 사용하므로 **BE를 재시작하면 회원·테이블 데이터가 모두 사라집니다.** 재시작 후에는 2단계부터 다시 진행하세요.

`application-local.yml`의 CORS 허용 origin은 `http://localhost:8080`뿐이라 FE 개발 서버(`3000`)의 요청과 SockJS 연결이 막힙니다. 실행 인자로 `3000`을 추가합니다.

```bash
cd ../debate_timer/debate-timer-be
sh ./gradlew bootRun --args='--cors.origin.cors-origin=http://localhost:3000,http://localhost:8080'
```

- `gradlew`에 실행 권한이 없으면 `./gradlew` 대신 `sh ./gradlew`로 실행합니다.
- `Started DebateTimerApplication` 로그가 보이면 준비된 것입니다.

## 2. FE 실행

`.env.local`의 `VITE_API_BASE_URL`이 `http://localhost:8080`인지 확인한 뒤 실행합니다.

```bash
npm run dev # http://localhost:3000
```

## 3. 테스트 회원 생성 + 액세스 토큰 발급

```bash
node scripts/local-login-token.mjs # 기본 이메일: local-tester@debate.local
node scripts/local-login-token.mjs me@test.dev # 이메일 지정
```

스크립트가 하는 일:

1. H2 콘솔(`/h2-console`)로 `MEMBER` 테이블에 회원을 넣습니다. 같은 이메일이 있으면 그대로 둡니다.
BE는 토큰의 이메일(`sub`)로 회원을 조회하므로 회원 행이 반드시 있어야 합니다.
2. local secret(`jwt.secret_key`)으로 `type: ACCESS_TOKEN` JWT(HS256)를 서명합니다.
3. `GET /api/table`로 토큰이 통과하는지 확인합니다.
4. 브라우저 콘솔에 붙여넣을 코드를 출력합니다.

환경 변수로 기본값을 바꿀 수 있습니다: `LOCAL_API_BASE_URL`, `LOCAL_JWT_SECRET`, `LOCAL_TOKEN_TTL_SECONDS`(기본 86400초).

## 4. 브라우저에 토큰 넣기

`http://localhost:3000`을 열고 개발자 도구 콘솔에 스크립트가 출력한 코드를 붙여넣습니다.

```js
localStorage.setItem('accessToken', '<accessToken>');
localStorage.setItem('memberId', '<memberId>');
location.reload();
```

FE는 `localStorage.accessToken`을 `Authorization` 헤더에 그대로 붙여 보냅니다(`src/util/accessToken.ts`, `src/apis/axiosInstance.ts`).

## 참고

- refresh token 쿠키는 없으므로 토큰이 만료되면 3~4단계를 다시 하세요.
- 청중 화면(`/live/{tableId}`)은 로그인이 필요 없으므로 시크릿 창이나 다른 브라우저로 열면 사회자와 청중을 동시에 테스트할 수 있습니다.
- 사회자 공유는 타이머 페이지(`/table/customize/{tableId}`)에서 공유 모달을 열 때 소켓이 연결됩니다.
2 changes: 2 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@
"서버에서 토론 진행 오류를 전달했어요.": "The server reported a debate progress error.",
"알 수 없는 실시간 오류가 발생했어요.": "An unknown live error occurred.",
"토론 시작을 대기 중입니다.": "Waiting for the debate to start.",
"사회자 연결을 기다리는 중이에요.": "Waiting for the moderator to connect.",
"사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.": "The moderator has disconnected. Waiting for them to reconnect.",
"토론이 종료되었습니다.": "The debate has ended.",
"페이지 닫기": "Close page",
"참여해 주셔서 감사합니다.": "Thank you for joining.",
Expand Down
2 changes: 2 additions & 0 deletions public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@
"서버에서 토론 진행 오류를 전달했어요.": "서버에서 토론 진행 오류를 전달했어요.",
"알 수 없는 실시간 오류가 발생했어요.": "알 수 없는 실시간 오류가 발생했어요.",
"토론 시작을 대기 중입니다.": "토론 시작을 대기 중입니다.",
"사회자 연결을 기다리는 중이에요.": "사회자 연결을 기다리는 중이에요.",
"사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.": "사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.",
"토론이 종료되었습니다.": "토론이 종료되었습니다.",
"페이지 닫기": "페이지 닫기",
"참여해 주셔서 감사합니다.": "참여해 주셔서 감사합니다.",
Expand Down
105 changes: 105 additions & 0 deletions scripts/local-login-token.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env node
// 로컬 BE(local 프로필, H2 메모리 DB)에서 Google OAuth 없이 로그인하기 위한 도구입니다.
// 1. H2 콘솔로 테스트 회원을 넣고 (이미 있으면 건너뜀)
// 2. local 프로필의 JWT secret으로 ACCESS_TOKEN을 서명해 출력합니다.
//
// 사용: node scripts/local-login-token.mjs [email]
// 환경 변수:
// LOCAL_API_BASE_URL (기본 http://localhost:8080)
// LOCAL_JWT_SECRET (기본값은 BE application-local.yml의 jwt.secret_key)
// LOCAL_TOKEN_TTL_SECONDS (기본 86400)
import { createHmac } from 'node:crypto';

const email = process.argv[2] ?? 'local-tester@debate.local';
const baseUrl = process.env.LOCAL_API_BASE_URL ?? 'http://localhost:8080';
const secret =
process.env.LOCAL_JWT_SECRET ?? 'lnklbdsxjfhruyfidjgjgrjfjfjhfhhh';
const ttlSeconds = Number(process.env.LOCAL_TOKEN_TTL_SECONDS ?? 86400);

function base64Url(value) {
return Buffer.from(value).toString('base64url');
}

function signAccessToken() {
const now = Math.floor(Date.now() / 1000);
const header = base64Url(JSON.stringify({ alg: 'HS256' }));
const payload = base64Url(
JSON.stringify({
sub: email,
iat: now,
exp: now + ttlSeconds,
type: 'ACCESS_TOKEN',
}),
);
const signature = createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest('base64url');
return `${header}.${payload}.${signature}`;
}

async function runH2Query(sessionId, sql) {
const response = await fetch(
`${baseUrl}/h2-console/query.do?jsessionid=${sessionId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ sql }),
},
);
return response.text();
}

async function ensureMember() {
const entry = await fetch(`${baseUrl}/h2-console/`).then((res) => res.text());
const sessionId = entry.match(/jsessionid=([a-z0-9]+)/)?.[1];
if (!sessionId) {
throw new Error(
'H2 콘솔 세션을 만들지 못했습니다. BE가 local 프로필로 실행 중인지 확인하세요.',
);
}

await fetch(`${baseUrl}/h2-console/login.do?jsessionid=${sessionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
driver: 'org.h2.Driver',
url: 'jdbc:h2:mem:database',
user: 'sa',
password: '',
}),
});

const escapedEmail = email.replaceAll("'", "''");
await runH2Query(
sessionId,
`MERGE INTO MEMBER (EMAIL, CREATED_AT, MODIFIED_AT) KEY (EMAIL) VALUES ('${escapedEmail}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
);
const result = await runH2Query(
sessionId,
`SELECT ID FROM MEMBER WHERE EMAIL = '${escapedEmail}'`,
);
const memberId = result.replace(/<[^>]*>/g, ' ').match(/\bID\s+(\d+)/)?.[1];
if (!memberId) {
throw new Error('회원 ID를 조회하지 못했습니다.');
}
return memberId;
}

const memberId = await ensureMember();
const accessToken = signAccessToken();

const check = await fetch(`${baseUrl}/api/table`, {
headers: { Authorization: accessToken },
});
if (!check.ok) {
throw new Error(`발급한 토큰 검증 실패: GET /api/table → ${check.status}`);
}

console.log(`email: ${email}`);
console.log(`memberId: ${memberId}`);
console.log(`accessToken: ${accessToken}`);
console.log('');
console.log('브라우저(http://localhost:3000) 개발자 도구 콘솔에 붙여넣기:');
console.log(
`localStorage.setItem('accessToken', '${accessToken}'); localStorage.setItem('memberId', '${memberId}'); location.reload();`,
);
90 changes: 90 additions & 0 deletions src/hooks/sockets/useAudienceSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,4 +447,94 @@ describe('useAudienceSocket', () => {
expect(result.current.latestMessage?.eventType).toBe('PLAY');
});
});

describe('마지막 수신 시각', () => {
const createMessage = (version: number): SocketMessage => ({
eventType: 'SYNC',
data: {
timerType: 'NORMAL',
sequence: 0,
remainingTime: 30,
isRunning: true,
},
version,
});

const setup = () => {
let handleMessage: (message: IMessage) => void = () => undefined;
let handleConnection: () => void = () => undefined;
subscribe.mockImplementation(
(_destination: string, callback: (message: IMessage) => void) => {
handleMessage = callback;
},
);
addConnectionListener.mockImplementation((listener: () => void) => {
handleConnection = listener;
return vi.fn();
});

const hook = renderHook(() => useAudienceSocket(123));
const receive = (body: string) =>
act(() => {
handleMessage({ body } as IMessage);
});

return { ...hook, receive, reconnect: () => act(handleConnection) };
};

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-22T00:00:00Z'));
});

afterEach(() => {
vi.useRealTimers();
});

it('메시지를 받기 전에는 lastReceivedAt이 null이어야 한다', () => {
const { result } = setup();

expect(result.current.lastReceivedAt).toBeNull();
});

it('유효한 메시지를 받으면 수신 시각으로 lastReceivedAt을 갱신해야 한다', () => {
const { result, receive } = setup();

receive(JSON.stringify(createMessage(10)));
expect(result.current.lastReceivedAt).toBe(Date.now());

vi.advanceTimersByTime(5000);
receive(JSON.stringify(createMessage(11)));
expect(result.current.lastReceivedAt).toBe(Date.now());
});

it('version이 오래되어 무시된 메시지도 수신 시각은 갱신해야 한다', () => {
const { result, receive } = setup();

receive(JSON.stringify(createMessage(10)));
vi.advanceTimersByTime(5000);
receive(JSON.stringify(createMessage(9)));

expect(result.current.latestMessage).toEqual(createMessage(10));
expect(result.current.lastReceivedAt).toBe(Date.now());
});

it('형식이 잘못된 메시지는 수신 시각을 갱신하지 않아야 한다', () => {
const { result, receive } = setup();

receive('invalid-json');
receive(JSON.stringify({ eventType: 'UNKNOWN', data: null }));

expect(result.current.lastReceivedAt).toBeNull();
});

it('재연결되면 lastReceivedAt을 초기화해야 한다', () => {
const { result, receive, reconnect } = setup();

receive(JSON.stringify(createMessage(10)));
reconnect();

expect(result.current.lastReceivedAt).toBeNull();
});
});
});
9 changes: 9 additions & 0 deletions src/hooks/sockets/useAudienceSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isSocketMessage } from '../../apis/sockets/util';
* @param {UseAudienceSocketOptions} options - 소켓 채널 구독 활성화 옵션
* @returns {Object} 청중 소켓 상태와 제어 함수를 반환합니다.
* @returns {SocketMessage | null} returns.latestMessage - 검증된 가장 최근의 수신 메시지입니다.
* @returns {number | null} returns.lastReceivedAt - 형식이 올바른 메시지를 마지막으로 수신한 시각입니다. version이 오래되어 반영하지 않은 메시지도 포함합니다.
* @returns {boolean} returns.isConnected - 소켓 연결 상태입니다.
* @returns {Function} returns.connect - `useSocket.connect`에 위임하기 전에 현재 메시지를 초기화합니다.
* @returns {Function} returns.disconnect - `useSocket.disconnect`에 위임하기 전에 현재 메시지를 초기화합니다.
Expand Down Expand Up @@ -44,6 +45,9 @@ export default function useAudienceSocket(
null,
);

// 형식이 올바른 메시지를 마지막으로 수신한 시각 (사회자 연결 여부 판단에 사용)
const [lastReceivedAt, setLastReceivedAt] = useState<number | null>(null);

// 마지막으로 반영한 메시지의 version (이보다 작거나 같은 메시지는 오래된 것으로 간주)
const lastVersionRef = useRef<number | null>(null);

Expand All @@ -54,6 +58,7 @@ export default function useAudienceSocket(
*/
const resetMessage = useCallback(() => {
setLatestMessage(null);
setLastReceivedAt(null);
lastVersionRef.current = null;
}, []);

Expand Down Expand Up @@ -99,6 +104,9 @@ export default function useAudienceSocket(
try {
const parsedData = JSON.parse(message.body);
if (isSocketMessage(parsedData)) {
// 오래된 version이라 반영하지 않더라도 사회자가 메시지를 보내고 있다는 신호로 본다
setLastReceivedAt(Date.now());

const { version } = parsedData;
const lastVersion = lastVersionRef.current;
const hasVersion = version !== undefined && version !== null;
Expand Down Expand Up @@ -133,6 +141,7 @@ export default function useAudienceSocket(

return {
latestMessage,
lastReceivedAt,
isConnected,
error,
connect: connectAudienceSocket,
Expand Down
Loading
Loading