ANA Online Judge 우상단 아바타 버그 고치기
ANA 동아리 자체 온라인 저지(AOJ) 헤더의 아바타가 유저네임 이니셜만 찍히는 버그를 잡고 PR 올리기까지
제가 있는 학교 알고리즘 동아리 ANA가 자체 온라인 저지 AOJ를 만들었어요. BOJ 서비스 종료 소식 이후 PS 학습자들이 갈 곳이 애매해진 상황에서, 회장(황현석)이 “큰 커뮤니티보단 동아리 내에서 서로 성과를 공유하는 환경이 성장 동력이었다”며 동아리 차원에서 OJ를 운영하기로 했고, ANA 역대 대회 문제와 USACO 문제를 수록해서 aoj.anacnu.kr로 열었어요.
동아리에 도움이 되고 싶어서 로그인부터 해봤는데, 우상단 아바타가 프로필 사진을 넣어도 username(닉네임도 아닌 아이디)의 첫 글자만 찍혀 있더라고요. fork 떠서 고치고 PR(#14)까지 올렸어요.
환경
| 항목 | 값 |
|---|---|
| HW / OS | Apple Silicon (arm64) / macOS 26.3.1 |
| 런타임 | Node.js v22.14.0, pnpm 10.14.0 |
| 프레임워크 | Next.js 16.2.2 (Turbopack), NextAuth v5, drizzle-kit |
| 컨테이너 | OrbStack 2.1.1, postgres:18-alpine, redis:7-alpine, minio:latest |
| 대상 리포 | csh1668/ana-online-judge @ upstream/main |
| 작업 브랜치 | fix/header-avatar-fallback (fork: Sunkist18/ana-online-judge) |
뭐가 문제였나
세션이 내려주는 값을 먼저 까봤어요.
1
fetch('/api/auth/session').then(r => r.json()).then(console.log)
avatarUrl이 응답에 아예 없고, name에는 닉네임이 아니라 username이 들어가 있어요. 코드를 따라가 보니 세 군데가 물려 있었어요.
src/auth.ts의authorize()가name: user[0].username을 반환해서 DB의users.name이 세션에 닿을 길이 없었어요.jwt콜백이name/avatarUrl을 토큰에 올리지 않고,session콜백도session.user.avatarUrl을 안 세팅해서 클라이언트는 영원히undefined만 봤어요.- UI 쪽은
components/auth/user-menu.tsx에서<Avatar>만 렌더하고<AvatarImage>가 없어서 이니셜 fallback만 찍히고 있었고,ProfileHeader.handleSave는updateProfile서버 액션만 부르고 세션 갱신을 안 해서 저장 직후 새로고침 전까진 옛 값이 남았어요.
코드 수정
네 파일, 커밋 둘로 쪼갰어요.
src/auth.ts — authorize()가 실제 name을 내려주고, 콜백이 avatarUrl을 통과시키게 고쳤어요.
1
2
3
4
5
6
7
8
9
// authorize() 반환
{
id: user[0].id,
email: user[0].email,
username: user[0].username,
name: user[0].name, // was: user[0].username
avatarUrl: user[0].avatarUrl ?? null, // 추가
mustChangePassword: user[0].mustChangePassword,
}
클라이언트에서 프로필을 저장했을 때 세션을 바로 갱신할 수 있게 trigger === "update" 분기도 넓혔어요.
1
2
3
4
5
6
7
8
9
if (trigger === "update" && session) {
if (typeof session.mustChangePassword === "boolean") {
token.mustChangePassword = session.mustChangePassword;
}
if (typeof session.name === "string") token.name = session.name;
if (typeof session.avatarUrl === "string" || session.avatarUrl === null) {
token.avatarUrl = session.avatarUrl;
}
}
user-menu.tsx는 <AvatarImage>를 넣고 우선순위를 avatarUrl → image → undefined로 뒀어요. 이니셜 fallback 로직(name.split(" ").slice(0,2))은 그대로라 name이 avatarUrl과 같이 제대로 내려오기만 하면 자동으로 닉네임 이니셜이 찍혀요.
1
2
3
4
5
6
const avatarSrc = currentUser.avatarUrl ?? currentUser.image ?? undefined;
<Avatar>
<AvatarImage src={avatarSrc} alt={currentUser.name ?? currentUser.username} />
<AvatarFallback>{/* 기존 이니셜 로직 */}</AvatarFallback>
</Avatar>
프로필 저장 직후 헤더 반영은 useSession().update()로요. 본인 프로필일 때만이에요.
1
2
3
4
5
const { update: updateSession } = useSession();
if (isOwner) {
await updateSession({ name: trimmedName, avatarUrl: nextAvatarUrl });
}
타입 선언(src/types/next-auth.d.ts)에 Session.user.avatarUrl: string | null, User.avatarUrl?, JWT.avatarUrl?을 추가했어요.
결과
프로필 이미지를 안 넣은 유저는 이제 username이 아니라 name(닉네임)의 이니셜로 fallback이 찍혀요.
우상단과 프로필 페이지 모두 닉네임 이니셜로 대체되는 모습
프로필 이미지를 설정한 유저는 프로필 페이지뿐 아니라 우상단에도 제대로 이미지가 뜨고요.
저장 직후 useSession().update()로 헤더까지 바로 반영되는 모습
NextAuth v5에서
useSession().update(payload)를 부르면jwt콜백이trigger === "update"로 재호출돼요. 서버 액션이 DB만 갱신하고 세션을 건드리지 않으면 클라이언트useSession()은 다음 새로고침 전까진 옛 값을 들고 있어요.
로컬 띄우면서 만난 것들
고친 뒤 검증하려고 dev를 띄우는 데 몇 번 걸렸어요.
- README는
cp web/.env.example web/.env라고 적혀 있는데web/.env.example자체가 리포에 없어요.docker-compose.yml이랑web/src/lib/env/serverEnv.ts의 zod 스키마를 보면서 손으로 채웠어요. make dev-up의 judge 이미지 빌드가"/usr/local/rustup/toolchains/1.91.1-x86_64-unknown-linux-gnu/bin": not found로 깨지더라고요.judge/Dockerfile이x86_64-unknown-linux-gnu를 하드코딩해 둬서 arm64 호스트에선 경로가 없는 거예요. 아바타 UI 수정에 judge가 필요 없으니docker compose up -d postgres redis minio로 인프라 3개만 올렸어요.make dev-db-migrate는relation "playground_files" does not exist로 깨졌어요.drizzle/0001_playground_minio_migration.sql이TRUNCATE TABLE playground_files CASCADE로 시작하는데 정작CREATE TABLE은0002_mute_husk.sql에 있어서요.pnpm db:push로schema.ts에서 바로 DB에 밀어 넣는 쪽으로 우회했어요.auth.ts에 박은console.log가 HMR로 안 반영돼서 한참 헤맸어요. NextAuth 설정 파일은 서버 청크·미들웨어에 묶여서 Turbopack이 재로드하지 않는 것 같아요.lsof -ti:3000 | xargs kill -9후pnpm dev재기동하니까[auth.jwt update] session payload: {"name":"홍길동","avatarUrl":"..."}가 찍혔어요.git push단계에선 pre-push 훅이judge/에서cargo check를 돌리는데 제 rustc는 Homebrew 1.88.0이고aws-config@1.8.15가 1.91.1을 요구해서 막혔어요.rustup이 없어서 이번엔--no-verify로 우회했는데, 원래는 rustup 깔고 1.91.1로 올리는 게 맞아요.
web/.env.example 부재, 마이그레이션 순서 꼬임, judge/Dockerfile의 아키텍처 하드코딩은 이번 PR 스코프 밖이라 별도 이슈 감이에요.
한계
- Google OAuth 브랜치도 같이 고쳤는데 로컬에 Client ID/Secret을 안 넣어서 실제 구글 로그인으론 검증 못 했어요. PR에는 미검증으로 남겼고요.
- 관리자 대리 로그인(impersonation) 경로에도
session.user.avatarUrl = targetUser.avatarUrl ?? null을 넣어 뒀는데, 이것도 실행 검증은 못 했어요.