학생과 선생님을 연결하고, 선생님의 관리를 도와주는 교육 플랫폼입니다.
프론트엔드 개발자로 프론트 서버를 개발/운영하고, 프로젝트의 기획과 일부 디자인도 진행했습니다.
또, 실제로 창업하여 운영도 진행했습니다.
-현재는 운영되고 있지 않습니다
View
새로 인강헬퍼를 만들기 시작하면서, 이전 프로젝트에서 아쉬웠던 점이 있었습니다.
전 프로젝트에서는 CSR으로만 프론트엔드를 구현했습니다. 서버 비용 같은 점을 생각하면, SSR을 사용하는 등의 일이 부담스러웠기 때문입니다.
또, 상태 관리에서 redux-toolkit만 썼었는데 react-query라는 라이브러리를 발견해서 docs를 읽어봤더니 캐싱키를 컨셉으로 멋지게 만들어서 react-query를 사용했었습니다.
그렇게 비동기 데이터를 모두 react-query에 일임하자, redux가 필요한 상황이 많지 않았던 것 같습니다.
외부에서 가져오는 데이터를 제외하면 상태 관리가 필요한 경우가 별로 없는 것이 아닌가하고 생각하던 중, 첫 페이지의 로드 속도에 고민이 생겼습니다.
CSR로만 작동할 때 특정 경로에서 캐싱을 하지 않으면, 구조상 처음 접근에서 손해를 볼 수 밖에 없었기 때문입니다.
그러던 중, Next.js를 알게 되었습니다. remix, gatsby도 있었지만, react 공식 팀과 가장 긴밀하게 협력한다는 점과 page router를 구성하면서 SSR, ISR, CSR, SSG 모두 사용할 수 있다는 점이 매력적이라는 생각이 들었고, 새 프로젝트는 Next 프레임워크를 기반으로 구현하기 결정했습니다.
구현에서 고민했던 점은 다음과 같습니다:
- 자연스러운 비동기 데이터의 로딩 및 오류 처리
- 비동기 데이터와 페이지 경로 캐싱
- 콘텐츠 보안 정책(CSP)
- 모바일 화면에서의 채팅 페이지 동작
- 이미지 최적화
- 한/영 폰트 적용
- LCP
- ...등
프론트에서는 유저에게 최신 외부 데이터를 바로 줄 수 없기 때문에, 외부 데이터의 상태 관리가 중요하다고 생각했습니다.
저장된 데이터가 불변하는 데이터라면 캐싱으로 해결 될 수 있기 때문에 문제가 되지 않지만, 변동될 여지가 있는 데이터는 React Server Component나 Next의 Streaming rendering에서도 변동될 수 있는 데이터를 받아오는데 시간이 걸립니다.
애초에 2022년에 프로젝트를 생성했기 때문에, 서버 컴포넌트는 실험적이었으며 Next도 page router만 안정적이었기 때문에 다른 선택지가 있던 것은 아니었습니다.
또, CSR인 경우는 구조 자체가 처음 가져올 데이터를 미리 받을 수 없기 때문에 애당초 고려해야만 했습니다.
개인적으로 사용자가 이상하다 느낄 수 있는 경험을 주는 것에 대해 굉장히 신경쓰이기 때문에, 부드럽게 데이터의 상태를 관리하는 것은 상용화를 전제하는 프로젝트에서 구현에 가장 신경쓰이는 점이었습니다.
비동기 데이터의 상태는 react-query로 잘 관리할 수 있기 때문에 컴포넌트에서 발생할 수 있는 에러에 대해서 잘 대처하고 싶었습니다.
따라서, 하나의 컴포넌트에서 오류가 생겨도 페이지 전체가 아닌 특정 컴포넌트에서만 문제가 생기도록 구현하려 했습니다.
이를 위해, react-query에서 지원하는 QueryErrorResetBoundary 컴포넌트와 react-error-boundary의 ErrorBoundary 컴포넌트를 사용했습니다.
아래 예시 코드는 비동기 데이터를 사용하는 컴포넌트를 감싸줄 컴포넌트의 return과 ErrorFallback 컴포넌트입니다.
mounted 등 상태값은 중요하지 않으니 QueryErrorResetBoundary로 ErrorBoundary를 감싸고, QueryErrorResetBoundary의 reset 함수를 fallbackRender에 넘겨준 컴포넌트에 주목해주세요.
fallback으로 전달한 ErrorFallback 컴포넌트에서 reset 함수는 props(resetErrorBoundary 함수)로 넘어와 에러가 발생한 요청을 재 요청 할 수 있게 해줍니다.
예시) ErrorBoundary
// 예) 선언적으로 감싸줄 에러 바운더리 컴포넌트의 return
return (
<QueryErrorResetBoundary> // react-query
{({ reset }) => (
<ErrorBoundary fallbackRender={ErrorFallback} onReset={reset}> // react-error-boundary
{mounted ? (
// CSR
<Suspense // react
fallback={
isNoFallback ? (
<></>
) : (
<SuspenseFallback
initialMessagePage={initialMessagePage}
isAnimation={isFallbackAnimation}
/>
)
}
>
{children}
</Suspense>
) : (
// SSR
<>
{isNoFallback ? (
<></>
) : (
<SuspenseFallback
initialMessagePage={initialMessagePage}
isAnimation={false}
/>
)}
</>
)}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);예시) ErrorFallback
// 예) ErrorFallback 컴포넌트
const ErrorFallback = ({ error, resetErrorBoundary }) => {
return (
<div>
<p> Error </p>
<p>데이터 요청에 실패하였습니다</p>
<button onClick={() => resetErrorBoundary()} /> // 재 요청
</div>
);
};이전에는 에러가 발생했을 때, 오류 경로 페이지로 이동하거나 새로고침해야 했습니다.
변경된 후에는 에러 컴포넌트의 내용만 표시되지 않으므로 나머지 컴포넌트의 내용은 볼 수 있게 되었습니다.
localStorage와 같은 웹 스토리지는 제한사항은 있지만 읽는 속도가 빠르기 때문에 관리만 잘하면 되지만, 시간이 걸리는 비동기 데이터나 경로 자체의 캐싱은 웹 콘텐츠를 사용자에게 빠르게 전달하기 위해 중요한 부분이라고 생각합니다.
인강헬퍼의 외부 비동기 데이터의 캐싱은 react-query의 키 컨셉을 통해 잘 해결할 수 있지만, Next(page router)에서 지원하는 SSR, ISR, SSG 등과 함께 적절한 방식으로 관리하는 것이 중요하다고 생각했습니다.
따라서, 페이지 렌더링 방식을 분류했습니다:
- 유저 정보 -> SSR의 queryClient(react-query)에서 prefetch 후, CSR로 관리
- 유저 출석 기록 -> CSR로 요청 후 관리
- 메인 배너 (여러 리스트 노출) -> ISR(Incremental Static Regeneration) 또는 stale-while-revalidate
- 로그인 페이지 등의 정적 정보 -> SSG
XSS(브라우저에 악성 스크립트 실행) 등을 막는 CSP는 허용 출처를 지정할 수 있습니다. Next에서도 CSP를 지원합니다.
Next.js는 보통 Middleware에서 CSP를 적용하기를 권장하지만, next.config.js에서도 보안을 돕는 몇가지 옵션을 가지고 있습니다.
예시) next.config.js (header)
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
// ★ X-Content-Type-Options
// Content-type 헤더의 MIMETYPE의 임의 변조 방지
// => styleSheet의 MIMETYPE이 text/css이지 않으면 차단됩니다.
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
// ★ Permissions-Policy
// 브라우저(DOM 또는 Iframe 내)가 지원하는 카메라, API 등의 기능의 활성화 여부 통제
// 'camera=()': 카메라 비활성화, 'camera=*': 모든 경로에서 카메라 허용
{
key: 'Permissions-Policy',
value:
'camera=(), microphone=(), geolocation=(), browsing-topics=()',
},
// ★ Strict-Transport-Security
// HTTP 대신 HTTPS로만 접근 허용
// => HTTPS로 전송된 요청을 중간에 가로채어 내용을 보는 것을 차단합니다. max-age 기간 동안 유효하고, includeSubDomains 설정을 통해 서브도메인 우회도 차단할 수 있습니다.
// (Vercel 배포 시, 자동 추가)
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
}
],
},
];
},
};예시) Middleware.js with nonce
/** CSP options
* @default-src `하위 CSP가 없을 경우, Default로 적용되는 CSP`
* @(script/style/img/font...)-src `해당 (...)가 명시된 주소에서 왔는지 검사`
* @connect-src `XMLHttpRequest, WebSocket 등 검사`
* @object-src `<object>, <embed>, <applet> 등의 태그가 허용 주소에서 왔는지 검사`
* @media-src `<audio>, <video>가 허용 주소에서 왔는지 검사`
* @form-action `<form>의 소스를 검사`
*/
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
block-all-mixed-content;
upgrade-insecure-requests;
connect-src vitals.vercel-insights.com;
`;
const contentSecurityPolicyHeaderValue = cspHeader
.replace(/\s{2,}/g, ' ')
.trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set(
'Content-Security-Policy',
contentSecurityPolicyHeaderValue
);
// 악성 코드를 request header에 심는 것 방지 (response 헤더를 직접 반환)
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
headers: {
'x-nonce': nonce,
'Content-Security-Policy': contentSecurityPolicyHeaderValue,
},
});
return response;
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};nonce는 인라인 스크립트가 필요한 경우를 위한 확인 절차이지만 필요 없는 경우, next.config.js에서 직접 설정할 수 있습니다.
인강헬퍼는 사용하는 스크립트가 있어 동적으로 nonce를 설정해주었습니다.
참고용) next.config.js without nonce
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
module.exports = {
async headers() {
return [
{
// 전체 경로에 대해, CSP를 정적으로 설정하고 있습니다.
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\n/g, ''),
},
],
},
]
},
}인강헬퍼에는 1:1 채팅 기능이 있습니다. 처음에 PC와 Mobile 화면을 모두 하나의 웹에서 관리했습니다.
모바일에서 뒤로가기 기능을 자연스럽게 만들고 싶어서, 모바일 브라우저의 뒤로가기와 스마트폰 자체에 있는 뒤로가기를 동일하게 동작하기로 했습니다.
Next는 자체 라우터가 존재했기 때문에, next/router의 beforePopState을 이용했습니다. 이를 통해, 뒤로가기 버튼을 눌렀을 때 이전 경로로 이동되는 대신 구분되어 있는 컴포넌트가 닫히는 구조로 작동해야 했습니다.
따라서, 기기 타입, router의 shallow 이동 여부, 컴포넌트의 마운트 여부, 현재 경로 등을 체크했습니다.
예시) next/router (beforePopState)
useEffect(() => {
if (!router.isReady) return;
router.beforePopState(state => {
if (
컴포넌트 마운트 됨 &&
기기 타입 !== PC &&
!shallow 이동
) {
document.title = '채팅 타이틀';
window.history.pushState(null, '', router.asPath);
if (router.asPath !== '첫 채팅 페이지 경로') {
컴포넌트 언 마운트;
router.push(router.asPath, undefined, { shallow: true });
}
return false;
}
return true;
});
}, [컴포넌트 마운트 됨]);위 이미지와 같이, 모바일 채팅은 [채팅방 - 채팅창 - 상대 정보]로 중첩되는 구조로 만들었습니다.
이미지 최적화는 웹 성능 향상에 아주 중요한 부분입니다.
이미지 태그는 srcset, sizes와 같은 속성을 기본적으로 제공하는데, 보통 이미지는 상황에 맞게 가공/저장해서 프론트 서버나 DB에 저장해둡니다.
인강헬퍼에서는 Next 프레임워크를 사용하므로, Next에서 지원하는 next/image의 Image 컴포넌트를 사용하면 최적화됩니다. 내부적으로 sharp lib를 이용하고, 4가지 layout 속성을 통해 이미지를 나눠 그립니다. webp/avif 확장자를 선택할 수도 있기 때문에 원하는 상황에 맞게 고를 수도 있습니다.
저는 프론트에서 이미지를 저장해야 할 때 우선 이미지를 압축합니다. 이미지 용량(보통 100KB~)이 커지기 시작하면 느려질 수 밖에 없기 때문입니다. 압축할 때는 이미지 품질을 너무 타협봐서는 안된다고 생각합니다. 보통 이미지 90% 이하로 품질이 손상되면 '흐리다'는 느낌을 받았습니다.
적절한 압축 방식(jpg, webp...)을 선택하고, size로 적절한 크기를 설정하고, priority 옵션 등으로 우선순위와 같은 요소를 조정하는 것이 좋다고 생각됩니다.
이미지를 빠르게 위해서, HTTP 캐시를 설정하는 것은 중요했습니다. 자주 변경되지 않는 이미지의 경우, Cache-Control을 설정하면 성능에 좋습니다.
// next.config.js
module.exports = {
images: {
minimumCacheTTL: 31536000,
},
}문제는 Cache-Control을 길게 설정한 후, 캐시를 무효화 시키기 어렵다는 것입니다. Next에서는 직접 삭제하거나 경로를 수정해야 합니다. 직접 무효화하는 것은 번거롭기 때문에, 간단히 next/Image의 src를 public/image.png에서 public/image?ver=1과 같이 변경하는 것으로 처리할 수도 있습니다.
CDN을 통해 폰트를 가져올 수 있지만, 로컬 폰트(@font-face)를 선택했습니다. 왜냐하면, 타 서버에서 불러올 수 없는 경우가 없으면 했고 디자인 상으로 한글과 영문/숫자의 폰트를 다르게 적용해야 했기 때문입니다.
적용할 폰트(Roboto, Noto Sans KR)를 프로젝트에 추가하고, CSS 파일에 @font-face를 설정합니다.
폰트 확장자는 각각 다르게 설정했었습니다. 기본적으로 로드가 빠른 woff2를 선호했지만, 모든 브라우저 버전에서 지원되지 않으므로 woff와 otf, ttf도 설정해주었습니다.
- TrueType(ttf): 대부분의 운영 체제에서 지원되는 벡터 글꼴
- OpenType(otf): 대부분의 운영 체제에서 지원, ttf보다 곡선 표현이 좋지만 파일 크기가 큼
- woff: 압축률이 높아 로드가 빠른 웹 폰트
- woff2: woff보다 더 높은 압축률
예제) CSS @font-face
/* @font-face (unicode-range: 범위 설정)
특수문자: U+0020-002F, U+003A-0040, U+005B-0060, U+007B-007E
영문(대): U+0041-005A
영문(소): U+0061-007A
숫자: U+0030-0039
*/
@font-face {
font-family: 'Roboto';
font-display: swap;
src: local('Roboto Regular'),
url('/fonts/Roboto-Regular.ttf') format('truetype');
unicode-range: U+0041-005A, U+0061-007A, U+0030-0039; // 영문(대), 영문(소), 숫자인 경우 적용
font-weight: normal;
}
@font-face {
font-family: 'Roboto';
font-display: swap;
src: local('Roboto Bold'), url('/fonts/Roboto-Bold.ttf') format('truetype');
unicode-range: U+0041-005A, U+0061-007A, U+0030-0039;
font-weight: bold;
}
@font-face {
font-family: 'Noto Sans KR';
font-display: swap;
src: local('Noto Sans KR Regular'),
url('/fonts/NotoSansKR-Regular.woff2') format('woff2'),
url('/fonts/NotoSansKR-Regular.woff') format('woff'),
url('/fonts/NotoSansKR-Regular.otf') format('opentype');
font-weight: normal;
}
@font-face {
font-family: 'Noto Sans KR';
font-display: swap;
src: local('Noto Sans KR Bold'),
url('/fonts/NotoSansKR-Bold.woff2') format('woff2'),
url('/fonts/NotoSansKR-Bold.woff') format('woff'),
url('/fonts/NotoSansKR-Bold.otf') format('opentype');
font-weight: bold;
}후에, Body 등에 글꼴을 선언하여 적용했습니다
body {
font-family: 'Roboto', 'Noto Sans KR', sans-serif;
}인강헬퍼는 현재 운영 중단된 상태이기 때문에, 폰트가 적용된 모습을 보여주기 어려워 네이버에서 이미지를 가져왔습니다.
제대로 적용되었다면, 스타일 옆 [계산됨] 탭 아래 [렌더링된 글꼴]에 적용된 폰트가 보입니다.
그렇지만, 여러 폰트의 사용은 성능에 그리 좋지 않았기 때문에 디자인상 꼭 적용하는 것이 아니면 단일로 사용하는 것이 좋았습니다.
다른 지표도 중요할 수 있지만, 많은 개발자들은 LCP는 웹에서 중요한 지표 중 하나라고 생각합니다. 그리고 이 LCP는 이미지 최적화에 영향을 많이 받는다고 생각했습니다.
보통 유저가 웹 사이트에 진입할 때 이미지와 html, css, js를 다운받기 때문에 시간이 오래걸립니다.
특히, 첫 진입에서 배너나 큰 이미지가 있는 경우 캐시되어 있지 않기 더욱 지연됩니다.
저의 경험에서 DOM 로드되는 시간은 0.5초 이내가 가장 좋고, 보통 1초 이내, 늦어도 1.5 ~ 2초 였습니다.
인강헬퍼의 첫 화면은 다음과 같았습니다.
변동되는 데이터가 있으면, 첫 화면이 그려지는 시간이 길어집니다. 이미지도 크기 때문에 지연되는 부분은 어쩔 수 없었습니다.
그렇지만, 인강헬퍼에서 배너, 선생님 정보 및 이미지는 반드시 최신으로 갱신될 필요가 없었기 때문에 Next의 ISR을 사용했습니다.
Home page (ISR)
// Incremental Static Regeneration (ISR) 예시
export const getStaticProps: GetStaticProps = async () => {
try {
const homeContents: HomePropsType = await axios
.get(`API 경로`)
.then(res => res.data);
if (!homeContents || JSON.stringify(homeContents) === '{}') {
return {
redirect: {
destination: '/500',
permanent: false,
},
};
}
return {
props: {
homeContents,
},
revalidate: 60 * 60, // 1시간 설정
};
} catch {
return {
redirect: {
destination: '/500',
permanent: false,
},
};
}
};이렇게 하면 DB에서 데이터도 가져오지 않고, 이미지만으로 빠르게 화면을 그릴 수 있습니다.
그렇지만, 특수한 경우 페이지의 정보를 갱신해야 할 수도 있었습니다. DB의 배너가 갱신되는 경우에 바로 반영해야 하는 경우가 그렇습니다.
Next에서는 경로의 페이지 재 검증(revalidate)을 유도할 수 있기 때문에 이를 이용했습니다.
예시) API Routes(Next)를 이용한 revalidate
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
message: string;
revalidated: boolean;
path?: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
// something code...
try {
await res.revalidate(route + ''); // 경로 재검증
return res.status(200).json({
message: 'success',
revalidated: true,
});
} catch (err) {
return res
.status(500)
.send({
message: 'error',
revalidated: false
});
}
}이렇게 Api router로 운영자가 사용할 수 있도록 페이지를 초기화 시킬 수 있는 버튼을 만들거나, 배너 이미지를 추가/삭제할 때 자동으로 업데이트하게 할 수 있었습니다.
최종적으로 페이지 로드 속도 100~250ms로 줄었습니다.
규모가 크지 않아서 그런지 운영에 큰 문제는 없었지만, 돌이켜보면 아쉬운 점도 잘한 점도 있었습니다.
그 항목은 다음과 같았습니다:
- react-query의 queryKey 관리
- 테스트
- 기기별 프로젝트 분리
- CSS-in-js vs CSS
- 유저 반응
react-query를 이용하면서, queryKey의 관리에 어려움을 겪었습니다. queryKey가 점점 많아지면서 데이터의 캐시 관리가 힘들어졌기 때문입니다. 잦은 데이터 요청이 예상되는 페이지에서 최대한 요청을 보내지 않기 위해, 몇몇 useQuery에서 제공하는 refetch 옵션을 false로 설정했습니다.
이 때문에, 옵션이 비활성화된 곳에서 사용되는 데이터의 queryKey를 수동으로 조작했고 문제가 생겼습니다.
C사탕이 포함된 데이터가 많다면?:
- ‘사탕’ 목록 > queryKey: ['유저', '사탕 목록']
- ‘C사탕’ 판매 예약 목록 > queryKey: ['유저', '사탕', 'C', '예약 목록']
- ‘C사탕’ 판매 예약일 변경 > queryKey: ['유저', '사탕', 'C', '예약', 'A']
- ......
=> Invalidate할 모든 queryKey를 알아야 합니다
유지/보수할 때에도 세부적인 queryKey를 관리하기 점점 어려워지기 때문에, 휴먼 에러도 빈번해질 가능성이 높았습니다. 당시 시간에 쫒긴 나머지 수동으로 조작하는 것에 대해 가볍게 생각했지만, 지금 생각해보면 react-query의 auto fetching 옵션 중 하나를 비활성화했기 때문에 다른 문제가 생길 여지가 있는 것은 필연이었을지도 모르겠습니다.
특히, 인강헬퍼에서는 수업 시간에 관련한 queryKey 관리가 문제였습니다. 하나의 주제에 관련한 queryKey는 query key factory처럼 한 곳에서 관리했으면, 수업 시간 queryKey를 유지/보수할 때 더 편했을 것 같습니다.
예시) Query Key Factory
// 사탕에 관련된 키 관리
const candyKeys = {
all: ['candy'] as const,
lists: () => [...candyKeys.all, 'list'] as const,
list: (filters: string) => [...candyKeys.lists(), { filters }] as const,
details: () => [...candyKeys.all, 'detail'] as const,
detail: (id: number) => [...candyKeys.details(), id] as const,
}
// 사용할 때
useQuery({ queryKey: candyKeys.all, ...... });
queryClient.invalidateQueries(candyKeys.detail(candyId));인강헬퍼를 진행하면서 Jest나 react-testing-library 테스트 코드를 작성해보지 못 한 것이 아쉬웠습니다. 중요한 부분에서 테스트 코드를 작성해서 무결성을 얻었으면 좋았을 것 같습니다.
기획에 따라 인강헬퍼를 구현하면서, 기기 타입과 함께 반응형으로 진행했습니다. 기기 구별은 userAgent를 사용하였는데 개발 편의로 하나의 프로젝트로 구성했지만, 지금 생각해보면 큰 랩퍼 앱 속에 개별 앱으로 분리 구현하여 만들었으면 좋았을 거라는 생각이 들었습니다.
경로나 어플리케이션을 분리하는 것이 더 관리에도 용이하고 코드 용량도 적어서, 구현에 적합하지 않않나 하는 생각이 듭니다.
인강헬퍼를 시작하기 전, CSS와 CSS-in-js 중 어느 방식을 사용할 지에 대한 고민을 했습니다. 각자 장단점은 있었지만, DX보다 성능이나 생산성을 중요하다고 생각하기 때문에 속도가 중점이었습니다.
전에 사용했던 styled-component(CSS-in-js)와 Sass Modules + classnames(lib)를 사용해보았는데, css-module 쪽이 더 빨랐습니다.
react의 컴포넌트 컨셉에는 CSS-in-js의 방식이 맞다는 생각이 들었지만, 당시에는 css-module이 속도에서 큰 차이가 있었습니다. 결국, sass-module + classnames로 스타일링을 선택했고 결과적으로 좋은 선택이었습니다.
개인적으로는 3D나 2D 인터랙션도 좋아했기 때문에, 느려지기 쉬운 인터랙션의 페인팅 속도를 줄이는 데에도 중요했다고 생각하고 있습니다.
기획에도 관여하면서 생각보다 다양한 유저의 반응에 많은 고민을 했었습니다. 읽는 방향, 이미지와 텍스트 등 많지 않은 유저 중에도 성향이 각기 달라 화면 구성에 대한 고민도 많았습니다. 특정 화면은 모두 구현했지만, 갈아엎고 새로 만든 경우가 많았던 것 같습니다.
여러 경험 가운데 확실한 것은 사용자의 목적 중심의 개발이 중요하다는 점이었습니다. 즉 개발진이 어떤 이유나 문제로 구현을 진행하더라도, 결국 목적을 달성하는 일이 빠르고 편해야한다는 단순함이 중요하다는 것을 느꼈습니다.
생각해보면, KakaoTalk이나 KakaoBank, Naver, Toss 등 많은 테크 기업들은 그렇게 기획/디자인 했던 것 같습니다. 특히, 초창기에 KakaoBank를 만드셨던 분들은 이 점을 잘 이해하고 계셨던 것이 아닐까 하는 생각이 듭니다.
새로운 시도가 성공하면 좋지만, 실패했을 때 아픈 것은 어쩔 수 없는 것 같습니다. 다음 프로젝트에서는 반드시 이러한 점을 뼈에 새겨 명심해야 할 것 같습니다.