로고

JSChoIog!

최준서 프로필

최준서Frontend Developer

사용자가 화면과 주고받는 상호작용에서 즐거움을 느껴 프론트엔드를 선택했고, 눌렀을 때의 반응과 흐름의 자연스러움까지 UI/UX의 일부로 여기고 설계합니다.

기획부터 스토어 출시·운영까지, 실제 사용자가 쓰는 제품을 끝까지 만들어 본 프론트엔드 개발자입니다.

인증·유효성 검증·CI/CD 파이프라인처럼 화면 뒤의 안정성도 프론트엔드의 책임으로 여기고 직접 설계합니다.

루게더

Rougether

루틴이 방을 키우고, 방이 모여 집이 되는 소셜 루틴 앱. iOS·Android 출시, 같은 코드로 웹(PWA)·데스크톱 버전 운영 중.

역할
프론트엔드·모바일 리드
기간
2026.04 ~ 현재 · SW마에스트로 17기
2인 — 프론트엔드·모바일 1 (본인), 백엔드 1
  • React
  • React Native
  • React Native Web
  • Expo Router
  • TypeScript
  • Reanimated
  • Gesture Handler
  • TanStack Query
  • Jest
  • GitHub Actions
  • EAS
  • PWA
  • GA4
루게더 나의 방 화면루게더 집 화면루게더 루틴 목록

프론트엔드·모바일

  • 제스처 우선순위 · 페이저 · iOS 엣지 백 설계
  • 웹·데스크톱(PWA) 버전, 2단 레이아웃
  • 컴포넌트 개발 하네스(Dev 탭) 구축
  • 프론트·백 공유 계약(spec) 저장소 운영
  • 스토어 출시 · GA4 계측 · 랜딩(Astro)

빌드·배포

  • GitHub Actions 워크플로 12종
  • EAS 빌드 · OTA 배포 · 릴리스 스모크 테스트

문제 해결 기록

손가락 하나에 뜻이 다섯이던 화면, 제스처의 우선순위를 설계하자

배경

한 화면 위에 세로 당김 새로고침, 하단 탭 가로 페이저, 방↔달력 서브탭 플링, 집 화면 핀치 확대·자리 드래그가 겹쳐 있었고, 아이폰 사용자 습관에 맞춰 직접 만든 가장자리 뒤로가기까지 같은 터치를 두고 경합했습니다.

집을 확대해 두면 옆 탭으로 못 넘어갔고, 같은 가로 스와이프가 위치에 따라 '달력으로'와 '집으로'로 갈렸으며, 시뮬레이터에선 되던 것이 실기기·다른 OS에서 달랐습니다. JS 스레드에서 처리하니 손을 놓은 뒤에야 화면이 따라와 '손맛'도 죽었습니다.

시도한 방법

  1. 판정 문법을 하나로 — 축과 임계

    가로 제스처는 activeOffsetX ±24 / failOffsetY ±36, 세로 당김은 activeOffsetY ±12 / failOffsetX ±12. 한쪽의 실패 조건이 다른 쪽의 활성 조건이 되게 맞췄습니다.

  2. 누가 먼저 판정하는지 그래프로

    더 깊은 자식 디텍터가 동률에서 이기고, iOS는 자식 ScrollView가 requireExternalGestureToFail로 페이저의 방향 판정을 기다립니다. 새로고침 팬은 스크롤과 동시 인식.

  3. UI 스레드로 옮기고 상태 커밋만 JS로

    worklet에서 판정·이동을 처리하고 runOnJS는 탭 선택·저장 같은 커밋에만 씁니다. 제스처 객체는 마운트 시 1회만 만들어 드래그 중 리렌더에도 살아남게 했습니다.

관련 코드

src/utils/gesture.ts
// 가로 우세 + 최소 이동일 때만 활성 — 세로 스크롤·탭은 살려 둔다
export const SWIPE_CLAIM_DX = 24;
export const SWIPE_FAIL_DY = SWIPE_CLAIM_DX * 1.5;

export const horizontalFlingGesture = (id, onFling) =>
  Gesture.Pan().withTestId(id).runOnJS(true)
    .activeOffsetX([-SWIPE_CLAIM_DX, SWIPE_CLAIM_DX])
    .failOffsetY([-SWIPE_FAIL_DY, SWIPE_FAIL_DY]);
src/components/ui/pager-scroll-view.tsx
// iOS: 자식 ScrollView는 페이저의 방향 판정을 기다린다
export function usePagerNativeGesture(pager) {
  const native = Gesture.Native();
  return pager ? native.requireExternalGestureToFail(pager) : native;
}
// pager && Platform.OS === 'ios'
//   ? <GestureDetector gesture={native}>{scroll}</GestureDetector>
//   : scroll
src/components/screens/house/camera.ts
// 집 화면: 카메라가 이동을 가져가는 조건 (UI 스레드)
export function cameraClaimsMove(touchCount, zoomed, draggingSeat, dx, dy) {
  'worklet';
  if (draggingSeat) return false;
  if (touchCount >= 2) return true;
  return zoomed && Math.hypot(dx, dy) > CAM_PAN_SLOP;
}
임계는 상수로, 한 곳에
가로 우세 판정(24px)과 세로 실패 임계(×1.5)를 utils/gesture.ts에 모아, 집 전환·친구 방 순회·온보딩 슬라이드가 같은 문법을 씁니다.
iOS만 스크롤 중재
iOS는 ScrollView가 페이저보다 먼저 터치를 잡아 가로 스와이프가 죽었습니다. 자식 스크롤을 Gesture.Native로 감싸 페이저가 '아니다'라고 할 때까지 기다리게 했고, Android는 그대로 둡니다.
집 화면 잠금
두 손가락이거나 확대 상태의 이동은 카메라가 가져가고(worklet), 그동안 페이저를 잠급니다. 단, 집 페이지가 활성일 때만.

결과

세로 당김·가로 페이저·핀치 확대가 한 화면에서 서로를 방해하지 않고, iOS와 Android가 같은 손동작에 같은 결과를 냅니다.

판정과 이동이 UI 스레드에서 돌아 손가락을 따라오는 반응이 즉시 나옵니다.

만났던 이슈 — 다 맞춰도, 같은 손동작에 뜻이 셋

임계와 우선순위를 다 맞춰도 '나의 방'에서는 가로 스와이프가 손가락 위치에 따라 다른 뜻이었습니다. 달력 위에서는 '월 이동', 방 영역에서는 '방↔달력 서브탭 전환', 그 밖에서는 '하단 탭 이동'.

세 제스처가 모두 정상 동작해도 사용자는 무엇이 일어날지 예측할 수 없었습니다. 기술적으로는 풀 수 있는 충돌이었지만, 풀수록 규칙이 늘어 사용자에게 설명이 안 되는 화면이 됐습니다.

달력 위월 이동 (#562)
방 영역방 ↔ 달력 전환 (#561)
그 밖하단 탭 이동 (#563)

해결 — 충돌을 푸는 대신 뜻을 하나로 줄였습니다

방↔달력 서브탭 플링과 달력 월 스와이프를 제거하고(monthSwipe=false), 가로 스와이프는 어디서 시작하든 하단 탭 이동 하나만 뜻하게 했습니다. 서브탭은 탭 버튼으로만 바꿉니다. (#825)

제거한 코드는 남겨 두지 않고 주석에 이유를 적어, 나중에 누가 다시 붙이려 할 때 왜 뺐는지 알 수 있게 했습니다.

src/components/screens/my-room-screen.tsx
// monthSwipe=false 유지 (#825) — 달력 위 가로 스와이프가
// '월 이동'이라는 또 다른 뜻을 갖지 않게 한다.
<Calendar monthSwipe={false} ... />

// 스와이프는 전부 셸 탭 페이저 몫 — 나의 방 안에서 같은 손동작이
// 손가락 위치에 따라 '달력으로'와 '집으로'로 갈리던 것을 하나로 통일.

만났던 이슈 — iOS 실기기에서만, 스와이프가 갑자기 멈춤

iOS 실기기에서만, 탭 사이를 스와이프하다 갑자기 멈추고 그 뒤로 스와이프가 전혀 먹지 않았습니다. 시뮬레이터에선 재현이 안 됐습니다.

원인은 뒤로가기였습니다. 앱이 네이티브 스택이 아니라 셸 상태로 화면을 바꾸는 구조라 아이폰 사용자 습관에 맞춰 엣지 백을 직접 만들었는데(#564), 이 팬이 페이저와 같은 터치를 두고 경합했습니다.

형제 디텍터가 리렌더로 재부착되며 활성 팬이 취소되면 onFinalize가 JS에 닿지 않아 swiping 래치가 true로 굳고, 이후 활성화가 영영 건너뛰어졌습니다. (2026-09-08 분석)

해결 — 인식 단계에서 끄고, 래치는 매 터치마다 리셋

탭 루트에선 엣지 백 인식 자체를 끔 — JS 가드가 아니라 enabled로, 페이저의 터치를 먼저 잡을 수 없게 (#1143)

래치를 매 터치 시작·외부 탭 전환에서 리셋 — onFinalize가 누락돼도 다음 스와이프가 살아남게

엣지 백은 탭/서브화면 경계를 넘을 때만 재구성 — 데이터 리렌더가 활성 제스처를 갈아끼우지 않게. 서브화면은 전폭 스와이프 백(#1135), 가로 제스처를 쓰는 화면은 왼쪽 28px만

src/components/app/use-app-navigation.ts
// 탭 루트에선 인식 자체를 끈다 (#1143)
const edgeBackEnabled =
  Platform.OS === 'ios' && TAB_FOR_SCREEN[screen] == null;

const edgeBackPan = useMemo(() => Gesture.Pan()
  .enabled(edgeBackEnabled).runOnJS(true).maxPointers(1)
  .activeOffsetX(24).failOffsetY([-16, 16])
  .onTouchesDown((e, mgr) => {
    // 서브화면은 전폭(#1135), 가로 제스처 화면은 왼쪽 28px만
    const ok = fullSwipeRef.current || e.allTouches[0].x <= EDGE_BACK_WIDTH;
    if (!ok) mgr.fail();
  })
  .onEnd((e) => {
    if (e.translationX > 64 || e.velocityX > 700) goBackRef.current();
  }),
  [edgeBackEnabled]); // 경계를 넘을 때만 재구성
src/components/app/tab-pager.tsx
.onTouchesDown((e, mgr) => {
  // iOS: 활성 팬이 형제 디텍터의 재부착·취소로 드롭되면
  // onFinalize가 JS에 닿지 않아 true로 굳는다 (2026-09-08 분석)
  swiping.value = false;
  ...

배운 것

제스처 충돌은 대부분 기술로 풀 수 있지만, 풀었다고 사용자가 편해지는 건 아닙니다. 손동작 하나에 뜻이 여럿이면 규칙을 아무리 잘 세워도 사용자는 그 규칙을 모릅니다.

실기기에서 iOS·Android를 번갈아 눌러 보는 것을 검증 절차에 넣었습니다. 시뮬레이터가 통과시키는 제스처 버그가 생각보다 많았습니다.

그 밖에 만든 것

같은 코드로 웹·데스크톱까지

expo export --platform web으로 PWA 빌드, app.rougether.com에 배포. .web.ts 모듈 6개로 네이티브 SDK를 웹 번들에서 제외하고, 카카오 로그인은 REST 인가 코드 리다이렉트를 직접 구현.

데스크톱은 480px 폰 컬럼, 960px 이상은 [캔버스 | 할 일] 2단으로 최대 1200px.

정적 export의 서버 렌더는 창 폭을 몰라 첫 렌더 마크업이 어긋나면 React가 className 차이를 고치지 않아 프레임이 영영 안 붙었습니다. 첫 렌더는 서버와 같게 두고 마운트 뒤에 프레임을 적용하고, 루트 타입이 Fragment↔View로 바뀌어 리마운트되지 않도록 항상 같은 트리를 그립니다.

곰 발바닥 당겨서 새로고침

RN RefreshControl은 인디케이터 커스텀이 안 돼, RNGH 팬 + Reanimated로 직접 구현.

맨 위에서 아래로 끌면 콘텐츠가 절반 저항으로 따라오고 발바닥이 자라남 — iOS 바운스는 이중 동작이라 끔.

하단바 드래그 선택

알약 안에서 8px 넘게 끌면 선택 표시가 손가락을 따라오고, 놓을 때 한 번만 전환.

실제 탭 너비·중심을 측정해 글꼴·화면 폭이 달라도 좌표를 가정하지 않음.

배포가 사용자에게 닿았는지까지 검증

GitHub Actions 워크플로 12종 — CI, EAS 빌드, dev·production OTA, 핫픽스 OTA, iOS 릴리스 스모크 테스트.

배포 후 번들 지문을 대조해 OTA 도달 여부를 확인하고 Sentry 소스맵을 함께 올려, 릴리스 성공 여부를 사람이 확인하지 않아도 되게.

SEED+

시드플러스

AI·공공데이터로 상권 분석·수익률·생존율 예측을 제공하는 점포형 창업 의사결정 플랫폼. seedplusai.com 운영 중.

역할
프론트엔드 리드 개발자
기간
2026.02 ~ 현재
팀 SEED+ — FE 1 (본인), BE, AI
  • React
  • TypeScript
  • Vite
  • Tailwind CSS
  • Zustand
  • framer-motion
  • Vitest
  • husky
  • Docker
  • nginx
  • GitHub Actions
  • GA4
SEED+ 홈 대시보드 — 상권날씨·주간 브리핑·실시간 채팅SEED+ 내 상가 만들기 — 업종·지역 필터와 수익률 카드

프론트엔드 리드

  • 수익률·생존율 계산기, 내 상가 만들기(필터·북마크), 마이페이지, 피드 구현 및 실 API 연동
  • 세션 기반 인증·회원가입 폼 흐름, 유효성 규칙 V-01~V-07 구현
  • 랜딩 · SEO(OG/robots/sitemap) · GA 웹 스트림
  • Vitest 테스트 CI · husky 훅 · Docker + nginx 배포 · PR/이슈 컨벤션 문서화

문제 해결 기록

기획서의 규칙 번호가 그대로 코드와 테스트가 되게 하자

배경

회원가입·로그인 기능명세서 v2.0의 유효성·처리규칙 시트에는 V-01(이메일)부터 V-07까지 번호가 붙은 규칙이 있었습니다. 기획·백엔드·프론트가 같은 번호로 대화하는데, 코드에서는 규칙이 컴포넌트 안에 흩어져 "V-02가 어디 있지?"를 찾기 어려웠습니다.

게다가 인증 계약이 휴대폰 → loginId / 이메일 기반으로 바뀌는 중이라 기존 검증을 깨지 않으면서 새 규칙을 준비해야 했습니다.

시도한 방법

  1. 규칙 = 함수 = 테스트

    authValidation.ts에 규칙마다 함수를 두고 주석에 V-번호를 적었으며, Vitest describe 이름도 'V-01 이메일'처럼 같은 번호를 씁니다. 규칙이 바뀌면 테스트가 불일치를 먼저 잡습니다.

  2. 서버와 같은 정규화

    서버가 이메일을 소문자로 저장하므로 normalizeEmail로 동일하게 맞췄습니다. V-08(로그인 실패 잠금)은 서버 책임이라 제외했습니다.

  3. 병행 이행

    기존 휴대폰 기반 formValidation.ts는 그대로 두고 신규 모듈을 분리해, 백엔드 이메일 API가 준비된 뒤 화면을 연결하도록 했습니다.

관련 코드

src/utils/authValidation.ts
// 기능명세서 v2.0 ③유효성·처리규칙 시트(V-01~V-07) 구현.
/** V-01 이메일 — RFC 5322 단순화 패턴 */
const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

/** 서버가 소문자로 정규화해 저장하므로 클라이언트도 동일하게 맞춘다 */
export const normalizeEmail = (v: string) => v.trim().toLowerCase();

/** V-01 */
export const validateEmail = (value: string) => {
  const email = value.trim();
  if (!email) return '이메일을 입력해주세요';
  if (!EMAIL_PATTERN.test(email)) return '올바른 이메일 형식이 아닙니다';
  return '';
};

/** V-02 보조 — 강도바·체크리스트 표시에 사용 */
export const getPasswordChecklist = (v: string) => ({
  hasLetter: /[A-Za-z]/.test(v),
  hasNumber: /\d/.test(v),
  hasSpecial: /[!@#$%^&*]/.test(v),
  hasMinLength: v.length >= 8 && v.length <= 64,
});
src/utils/authValidation.test.ts
// 규칙이 바뀌면 이 테스트가 명세와의 불일치를 잡는다
describe('V-01 이메일', () => {
  it('빈 값과 잘못된 형식을 구분해 안내한다', () => {
    expect(validateEmail('')).toBe('이메일을 입력해주세요');
    expect(validateEmail('seedplus@')).toBe('올바른 이메일 형식이 아닙니다');
  });
  it('앞뒤 공백은 무시한다', () => {
    expect(validateEmail('  seedplus@email.com  ')).toBe('');
  });
});
규칙 하나 = 함수 하나
패턴 상수 위에 V-번호 주석을 달아 명세서와 1:1로 대응시켰습니다. 빈 값과 형식 오류를 다른 문구로 안내합니다.
체크리스트 · 강도 표시
V-02 보조 함수로 강도바와 체크리스트 UI가 같은 규칙을 공유합니다.
테스트가 명세를 지킨다
describe('V-01 이메일')처럼 번호를 그대로 써서 리뷰어가 기획서와 테스트를 나란히 읽을 수 있습니다.

결과

기획서 → 코드 → 테스트가 같은 번호를 공유해, 규칙 변경 요청이 오면 어느 함수와 테스트를 고칠지 바로 찾습니다.

휴대폰 기반 검증을 유지한 채 이메일 기반 규칙을 미리 준비해, 백엔드 계약 전환 시 화면 연결만으로 이행했습니다.

그 밖에 만든 것

사람이 잊는 규칙은 훅과 CI가 대신 기억하게

PR CI 7단계 — secrets 스캔 → lint → tsc -b → Vitest → build → Docker build → 이미지 취약점 스캔. 하나라도 실패하면 머지 불가. 테스트가 명세 역할을 하므로 CI가 곧 기획서 검증.

Git 훅(husky) — 브랜치명·커밋 메시지 규칙 검사, 스테이징된 추가 라인만 훑어 하드코딩 IPv4 차단 (127.0.0.1 등 허용, lock 파일 제외).

CI/CD — develop push → Docker 빌드 → ECR push, CI 성공 시 Dev CD가 이어서 배포(workflow_run), prod는 별도 AWS CD. nginx 단일 컨테이너, 환경별 값은 window.__ENV__로 런타임 주입.

활동 · 학력

  • SW마에스트로 17기팀 tripleS · 프론트엔드/모바일2026.04 ~ 2026.12
  • KUIT 5·6·7기웹 FE 파트원 → 파트 리드2025.03 ~ 2026.08
  • GDGoC Konkuk 25-26스터디원2025.09 ~ 2026.07
  • 건국대학교컴퓨터공학부2022.03 ~ 2026.08 (졸업 예정)