Published on

2026년 3분기 회고

Authors
  • avatar
    Name
    최영준 (Youngjun Choi)
    Twitter

정신없이 지내다 보니 어느새 10월이 되었고, 3분기가 다 가버렸다. 기존 코어 쪽에 있던 세금홈을 인컴 자체 서비스로 재구축하기 위해 인프라, 모노레포 세팅 등 다양한 기술적인 문제들을 해결했고, 그 사이 유저들을 모으기 위한 주민세, 추석복권 같은 인플로우 제품과 세금홈을 채우는 서비스들도 만들었다. 그간 고민했던 내용과 4분기에 준비하고 있는 기술적 과제들도 함께 정리해보자.

새로운 서비스, 세금홈

세금홈은 인컴이 계열사로 분리되기 전부터 코어에서 서빙하던 서비스였다. 이번 작업은 계열사로서 인컴만의 홈 서비스를 만들어, 자체적으로 가치를 주고 유저들이 늘 머무를 수 있는 서비스를 만드는 데 의의가 있었다.

할 일, 이번 달 소득, 혜택, 세금 소식으로 구성된 세금홈 메인 화면

할 일, 이번 달 소득, 혜택, 세금 소식을 한곳에서 보여주는 세금홈 메인 화면

개인적으로는 토스의 프론트 개발자로서 처음으로 메인 운영 서비스의 서비스 코드와 인프라를 처음부터 세팅해볼 수 있는 기회였다.

토스에 입사하고 2년 정도 세금 제품을 만들어 왔지만, 지금까지 맡은 서비스는 대부분 기존 토대 위에서 작업하거나 운영 기간이 정해진 서비스였다. 코어에서 처음 담당했던 숨은 환급액 찾기는 다른 두 분의 프론트 개발자가 만들어 주신 토대 위에서 작업했고, 인플로우 서비스들은 상대적으로 규모가 작고 운영 기간도 정해져 있었다. 반면 인컴만의 세금홈은 장기적으로 운영해야 하는 서비스였기 때문에, 서비스와 인프라 모두를 고려해 처음부터 설계해야 했다.

세금홈의 런칭부터 현재까지 고민했던 부분들을 정리해보면 아래와 같다.

기술적 고민 1. App Router

인컴 내에서 App Router를 사용한 첫 서비스는 아니지만, 모바일 대고객 서비스로는 세금홈이 처음으로 App Router를 도입했다. 서버 컴포넌트에서는 window에 접근하거나 상태를 가질 수 없기 때문에, 서버 컴포넌트와 클라이언트 컴포넌트의 차이를 이해하고 경계를 잘 나누는 것이 중요했다.

예를 들어 안내 UI를 유저에게 두 번까지만 보여줘야 하는 경우, 보통은 로컬 스토리지로 구현했겠지만 하이드레이션 이슈를 피하기 위해 서버와 클라이언트 모두에서 조회할 수 있는 쿠키를 활용했다.

// app/page.tsx (Server Component)
import { cookies } from 'next/headers'

export default async function Page() {
  const seenCount = Number((await cookies()).get('guide_seen')?.value ?? 0)
  // 서버에서 이미 노출 여부를 알기 때문에 첫 렌더부터 깜빡임이 없다
  return seenCount < 2 ? <GuideBanner seenCount={seenCount} /> : null
}

클라이언트 컴포넌트 하위는 모두 클라이언트 컴포넌트가 되기 때문에, layout과 컴포넌트 설계에서도 많은 고민을 했다. 특히 공통 로깅 패키지는 클라이언트 컴포넌트가 필수였기 때문에, 데이터 패칭과 로그 컴포넌트의 경계를 나누거나 children으로 주입하는 방식으로 서버 컴포넌트를 유지했다.

export default async function Page() {
  const summary = await fetchTaxSummary()
  return (
    <ImpressionLog name="home_summary">   {/* 'use client' */}
      <TaxSummary data={summary} />       {/* Server Component 유지 */}
    </ImpressionLog>
  )
}

또한 Pages Router와 달리, UA를 읽어 설정해야 하는 폰트 크기, 다크모드, 다국어, polyfill, Web Vitals 같은 공통 요소들을 App Router 방식으로 새로 정리해야 했다. 이를 묶어 App Router 전용 라이브러리를 구축했고, 이후 생긴 신규 서비스들도 가져다 쓰기만 하면 되도록 만들었다.

서버/클라 경계를 먼저 그리고 시작하니 로딩·에러·데이터 패칭이 라우트 단위로 응집되었고, 이것이 Next가 App Router로 가려는 이유라는 걸 체감했다.

기술적 고민 2. 인컴 서비스 간의 인증 체계

세금홈을 개발하면서 가장 고민했던 부분이다. 세금홈은 인컴 내 모든 서비스로 진입하는 허브 서비스이기 때문에, 세금홈 > 다른 서비스로 이동할 때 인증으로 인한 로딩이 발생하지 않게 하고 싶었다.

인컴은 토스 코어와 별개의 계열사라 자체 인증 체계를 가지고 있어 별도 인증이 필요하다. 문제는 인컴 서비스끼리는 같은 인증 체계를 쓰는데도 서비스마다 인증을 따로 관리하고 있었다는 점이다. 그래서 서비스에 진입할 때마다 인증을 다시 진행해야 했다. 이를 해결하기 위해 서비스별로 다른 키 값과 방식으로 관리하던 인증 로직을 별도 모노레포 workspace로 분리하고, 모든 서비스가 공통 키 값을 바라보도록 했다.

여기에 더해 서버 사이드 로직을 좀 더 적극적으로 활용하기 위해 토큰 관리 방식도 개편했다. 기존에는 앱 스토리지와 세션/로컬 스토리지만 사용해 클라이언트에 의존할 수밖에 없었다. 이제는 인증에 성공하면 토큰을 쿠키에 동기화하고, 서버에서 먼저 로직을 시도한 뒤 실패하면 클라이언트 로직으로 fallback하도록 해 더 매끄러운 유저 경험을 줄 수 있게 되었다.

이 두 가지 개선 덕분에 세금홈에서 한 번 인증한 유저는 다른 서비스에 별도 인증 없이 바로 진입할 수 있게 되었고, 서버 사이드 리다이렉션 같은 로딩 개선의 토대도 마련할 수 있었다. 특히 회원의 경우, 기존에는 토큰이 유효한지와 상관없이 서비스에 진입할 때마다 새로 토큰을 발급받아야 했지만, 이제는 토큰이 유효한 시간 동안은 재발급 없이 바로 진입할 수 있게 되었다.

기술적 고민 3. 신규 서비스 생성, 운영 자동화를 위한 CLI

세금홈이 대고객 App Router 첫 서비스였던 만큼, 챕터 내에서는 앞으로 인컴의 대고객 신규 서비스를 모두 App Router 기반으로 만들기로 기조를 잡았다. 이에 따라 늘어날 서비스들에 대비해 원스톱 서비스 생성 템플릿 스크립트를 고민했다. 서비스를 만들 때마다 반복하던 고민들을 CLI 하나로 해결해 빠르게 서비스를 생성하고, 최대한 서비스 코드에만 집중할 수 있게 하고 싶었다.

서비스 생성이 매일 일어나는 일은 아니지만, 막상 하려고 하면 어디서부터 어디까지 해야 하는지 알기 어렵겠다는 생각에서 시작한 작업이었다.

고민한 포인트는 서비스와 인프라 두 가지였다.

서비스 코드는 tds나 로그 같은 공통 패키지 버전을 관리하기 위해 catalog를 도입해, 템플릿이 항상 최신 상태를 유지하도록 했다. 또한 앞서 준비한 App Router 전용 라이브러리와 인컴 서비스 공통 인증 패키지를 템플릿에 기본으로 적용해, CLI 실행 후 개발 서버를 띄우면 바로 서비스 코드만 작성하면 되도록 했다.

인프라는 에러 모니터링을 위한 Sentry 프로젝트 세팅부터 alert rule까지 자동으로 추가했다. Sentry 자체 wizard CLI를 그대로 쓸지도 고민했지만, instrument와 client/server 설정 과정에서 우리만의 config가 필요해 직접 구현했다. 서비스 코드와 마찬가지로 CLI가 끝나면 바로 테스트 페이지에서 에러를 발생시켜 확인할 수 있게 구성했다. 성능 측정도 바로 가능하도록 Web Vitals 측정 컴포넌트를 추가해, 서비스별 트래픽과 성능을 대시보드에서 바로 볼 수 있게 했다.

CLI 한 번으로 자동 세팅되는 항목을 정리하면 아래와 같다.

영역항목내용
서비스템플릿App Router 기반 서비스 코드 구조, App Router 전용 라이브러리 적용
서비스catalogtds, 로그 등 공통 패키지 버전 최신화
서비스인증인컴 서비스 공통 인증 패키지 적용
인프라Sentry프로젝트 생성, instrument, client/server config, 에러 테스트 페이지
인프라alert rule에러 알림 규칙 자동 등록
인프라Web Vitals성능 측정 컴포넌트, 트래픽/성능 대시보드 연동

현재 세금홈 이후 3개의 추가 서비스에서 잘 사용되면서, 챕터 내 자산으로 계속해서 고도화 및 운영되고 있다.

아직 하지 못한 부분은 gocd나 aws cdn 세팅 같은 인프라 레벨의 IaC로, devops 팀과 이야기하며 점차 고도화해볼 예정이다. 개발 당시에는 불가능했던 알파 개인 클러스터 같은 기능도 이제 포함할 수 있게 되어, 앞으로 더 좋은 개발 환경이 자동으로 추가될 예정이다.

기술적 고민 4. 웹뷰 네비게이션 리다이렉션

세금홈은 원래 코어에서 서빙하던 서비스였기 때문에, 기존 진입점을 인컴의 신규 세금홈으로 리다이렉션하는 작업이 필요했다. 이미 배포된 스킴들은 회수할 수 없어서, 같은 스킴으로 진입하더라도 인컴의 세금홈으로 랜딩되도록 했는데 이 과정이 생각보다 어려웠다.

스킴 중에는 세금홈만 여는 것 외에도, 세금홈을 아래에 깔고 타겟 서비스를 띄우는 스킴도 있었다. 이 경우 웹뷰가 2개 뜨는데, 아래에 깔린 구 세금홈 화면을 신 세금홈 화면으로 바꿔야 했다.

처음에는 location.replace로 화면을 바꿨다. 화면은 잘 바뀌었지만, 웹뷰가 켜질 때 연결된 앱브릿지가 도메인이 달라지면서 동작하지 않아 인증이 실패했다. 이를 피하려고 구 세금홈 웹뷰를 닫고 신 세금홈 웹뷰를 새로 띄우면, 이번에는 타겟 서비스 웹뷰와 순서 경쟁이 생겨 신 세금홈이 제일 위로 올라오는 문제가 있었다.

결국 네이티브 팀과 논의한 끝에, 타겟 서비스로 가는 스킴이더라도 타겟 서비스는 띄우지 않고 신 세금홈만 별도 웹뷰로 바로 띄우는 방식으로 정리했다. 타겟 서비스로 바로 진입하지 못한다는 한계는 남지만, 해당 스킴들의 트래픽이 적어 복잡도를 높이기보다는 이 트레이드오프를 받아들이기로 했다. 단순한 리다이렉션이었지만 코어와 인컴의 인프라가 다르다 보니 꽤 복잡하고 어려운 과정이었다.

이렇게 다양한 고민과 함께 앞으로의 서비스 전반을 위한 기반 작업까지 진행할 수 있어서, 어렵기도 했지만 개발자로서 보람을 느낀 작업이었다. 세금홈이 허브 역할을 하다 보니 다른 팀과의 연결 관계를 더 고민하게 되었고, 이전과는 다른 시야로 일하고 있다.

무엇보다 애착이 가는 서비스가 생겼다. 소득과 소비 등 유저의 목소리를 더 잘 반영해, 세금홈이 사랑받는 서비스가 되도록 계속 고민하고 개선해나가고 싶다.

인플로우와 세금홈 서비스 구현

3분기의 메인은 세금홈 구축이었지만, 그 사이 유저들이 세금 제품에 계속 관심을 갖게 하기 위한 이벤트성 서비스와 세금홈을 채우는 서비스들도 함께 만들었다. 대표적으로 납부 시기에 맞춰 주민세 서비스를 구현해, 유저가 잊지 않고 편하게 납부할 수 있게 했다.

추석복권: iOS에서만 실패하던 카카오톡 공유

인플로우 이벤트 중 가장 기억에 남는 건 추석복권 서비스다. Kakao SDK를 이용한 공유로 유저가 한 번 더 들어오게 만드는 게 주요 목표였다. OS 기본 공유 시트보다 Kakao SDK로 카카오톡에 바로 공유하는 구조일 때 공유 성공률이 훨씬 높았기 때문에 핵심 기능이었다. 그런데 이상하게 iOS에서만, 카카오톡이 설치되어 있는데도 카카오톡 설치 안내 화면이 노출되어 공유가 불가능한 경우가 있었다.

카카오톡이 설치되어 있는데도 노출된 카카오톡 설치 안내 화면

iOS에서 카카오톡이 설치되어 있어도 노출되던 설치 안내 화면

여기서 동료들과 정말 많은 삽질을 했다. 재현이 쉽지 않았고, 이번 이벤트를 위해 서버 쪽에서도 Kakao SDK webhook 연결에 변경점이 있다 보니 변경 사항을 하나하나 되돌려가며 테스트해야 했다.

결국 찾은 원인은 비동기 처리 때문에 페이지 이동이 사용자의 클릭으로 일어난 이동으로 인식되지 않은 것이었다. CTA를 클릭하면 webhook용 공유 링크를 서버 API로 받아온 뒤 Kakao.Share.sendDefault를 호출하고 있었다.

// Before: 클릭 → API 응답 대기 → 공유
const handleShare = async () => {
  const { shareUrl } = await fetchShareLink() // await 이후의 이동은 사용자 클릭으로 인식되지 않는다
  Kakao.Share.sendDefault({
    objectType: 'feed',
    content: {
      title: '추석 복권',
      imageUrl: SHARE_IMAGE_URL,
      link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
    },
  })
}

카카오 JS SDK는 iOS에서 카카오톡을 커스텀 스킴으로 직접 여는 대신, talk-apps.kakao.com 도메인의 Universal Link로 페이지를 이동(location.href)시켜 카카오톡을 실행한다. iOS는 Universal Link를 유저가 직접 일으킨 이동일 때만 앱으로 연결하고, 그렇지 않으면 그냥 웹페이지로 연다. 이때 열리는 페이지가 바로 위의 "카카오톡이 실행되지 않나요?" 화면이다. 카카오 데브톡에서도 공유는 팝업과 같은 방식으로 동작하기 때문에 클릭 이벤트 밖에서 호출하면 브라우저 레벨에서 막힌다고 안내하고 있다.

클릭 이벤트 안에서 실제 API를 호출할 때는 이슈가 간헐적으로만 발생해서, 처음에는 API 응답이 느릴 때만 생기는 타이밍 이슈라고 생각했다. 그런데 다양한 테스트를 하던 중, 공유 주소는 고정하고 비동기만 끼워 넣어도 바로 재현되는 걸 발견했다. API 호출 없이 await Promise.resolve()나 setTimeout(fn, 0)처럼 사실상 지연이 없는 비동기로 이벤트를 처리하기만 해도 똑같이 설치 안내 화면이 떴다. 즉, 응답 속도가 아니라 클릭 핸들러의 동기 실행 흐름에서 벗어났는지가 문제였다. iOS에서는 Safari뿐 아니라 앱 내 웹뷰도 Safari와 같은 브라우저 엔진인 WebKit을 사용하기 때문에, 토스 앱 안에서도 이 정책이 그대로 적용되어 iOS에서만 문제가 나타났다.

해결은 단순했다. 공유 링크를 클릭할 때 가져오는 게 아니라 화면 진입 시 미리 받아두고, CTA 클릭 핸들러 안에서는 SDK만 동기적으로 호출하도록 바꿨다.

// After: 진입 시 링크를 미리 발급, 클릭 시에는 SDK만 호출
function ShareButton() {
  const [shareUrl, setShareUrl] = useState<string>()

  useEffect(() => {
    fetchShareLink().then(({ shareUrl }) => setShareUrl(shareUrl))
  }, [])

  const handleShare = () => {
    if (!shareUrl) return
    Kakao.Share.sendDefault({
      objectType: 'feed',
      content: {
        title: '추석 복권',
        imageUrl: SHARE_IMAGE_URL,
        link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
      },
    })
  }

  return <Button onClick={handleShare} disabled={!shareUrl}>카카오톡으로 공유하기</Button>
}

이렇게 바꾼 뒤로는 iOS에서도 100% 공유에 성공했다. 기존에 없던 이슈라 많이 당황하고 힘들었지만, 끝내 해결해서 무사히 배포했고 이벤트도 성공적으로 마무리되었다. 앱 실행이나 페이지 이동처럼 유저의 클릭이 필요한 동작은, 아무리 짧은 비동기라도 거치지 않고 클릭 핸들러에서 바로 호출해야 한다는 걸 확실히 배운 경험이었다.

세금홈을 위한 서비스 구현

세금홈을 인컴에서 재구축하면서, 기존에 코어가 외부 업체와 연계해 제공하던 사업자 경정청구와 부동산 관련 세금 환급 서비스도 인컴에서 직접 구현했다. 또한 세금홈 자체를 활성화하기 위해 머니리포트와 세금퀴즈를 만들었고, 지금은 하반기(2학기) OKR 달성을 위해 계속 개선하고 있다.

이 과정에서 기능 구현을 넘어 제품적인 고민도 이어가며 다양한 의견을 내려고 하고 있다. PO분과 이야기했을 때, 비즈니스와 기술 양쪽에서 더 많이 고민하고 기여해주길 바란다는 이야기를 들었다. 그 뒤로는 기능을 구현하는 데서 멈추지 않고, 구현 이후의 다음 스텝과 제품, 그리고 팀의 목표를 함께 고민하는 연습을 계속하고 있다.

4분기에 준비하고 있는 작업

4분기에는 개발 생산성과 성능 개선, 두 가지 축으로 작업을 준비하고 있다.

개발 생산성

인컴 내 팀이 커지고 제품이 성장하면서 신규 서비스가 빠르게 늘고 있다. 대고객 서비스만 해도 별도 서비스로 분리되거나 새로 생긴 서비스가 3개월 사이 4개나 되고, 앞으로 더 많은 팀이 생길 예정이라 서비스를 더 빠르게 세팅할 필요가 커지고 있다. 그래서 세금홈을 구축하며 만들었던 신규 서비스 생성, 운영 자동화를 위한 CLI를 고도화해 반복 작업을 줄이고, 혼란 없이 서비스와 인프라 세팅까지 마칠 수 있도록 지원해볼 예정이다.

템플릿의 기반이 되는 공통 서버 스펙 같은 다양한 공통 요구사항을 위한 패키지 관리도 고도화해서, 내 문제뿐 아니라 챕터 전체의 문제를 푸는 쪽으로 시야를 넓혀보려 한다.

성능 개선

이전 회사에서부터 다양한 JS 환경에서 성능 개선 작업을 해왔고, 가장 관심이 많은 분야다. 유저 경험뿐 아니라 이탈률 개선처럼 비즈니스적으로도 중요하고, 프론트엔드 개발자가 할 수 있는 기술적 고민의 집약체라고 느껴 정말 해보고 싶었던 주제였다. 리더분과 이야기하며 인컴 프론트 서비스들의 성능 문제를 원페이지로 정리했고, 구체적인 액션 아이템을 월별로 세웠다. 이를 바탕으로 하나씩 개선해나갈 예정이다.

코어와 다른 환경이지만 토스다운 경험을 만들고, 이 과정에서 성능 전문가로 성장할 수 있기를 기대해본다.

마무리

3분기는 세금홈이라는 새로운 서비스를 처음부터 만들고, 그 위에서 여러 서비스와 이벤트를 운영하며 정신없이 보낸 시간이었다. 처음으로 서비스의 시작부터 운영까지 책임지면서, 기술적인 고민만큼이나 제품과 팀을 함께 고민하는 시야를 얻을 수 있었다. 4분기에는 이렇게 쌓은 기반 위에서 개발 생산성과 성능이라는 두 가지 숙제를 잘 풀어내고, 그 과정을 다음 회고에서 다시 정리해보려 한다.