티스토리 뷰

2024년 05월 18일, 프리온보딩 프론트엔드 챌린지 강의 요약

 

Next.js를 사용하면 이미지 처리에 'next/image'를 무조건 사용해야 할까?

React를 사용할 때, React 답게 사용해야 한다면서 `useState`, `useRef`로만 관리해야 한다든지, Tanstack-Query를 사용할 때는 모든 API 요청을 `useQuery`, `useMutation`으로 처리해야 한다든지 처럼 말이다.

 

Next.js에서는 무조건 `next/image`를 사용해야 할까? 세상에는 그런 건 없다. 알맞은 상황에 맞게 사용하면 된다.

 

next/image

Next.js는 프레임워크로 개발자가 비즈니스 로직 작성에 집중할 수 있도록 여러가지 유용한 기능을 만들었다.

`next/image`는 웹 사이트의 로딩 성능에 많은 영향을 미치는 이미지를 사용하는 것만으로도 큰 개선을 볼 수 있도록 해주는 컴포넌트이다.

 

import Image from 'next/image'

export default function Page() {
    return (
        <Image
            src="/profile.png"
            width={500}
            height={500}
            alt="Picture of the author"
        />
    )
}

 

공식 문서에는 다음과 같은 장점이 있다고 소개한다.

  • 향상된 퍼포먼스
  • 비주얼 안정성
  • 빠른 페이지 로드
  • Assets 유연성

 

이미지 최적화는 웹 사이트의 'FCP(First Contentful Paint)' 성능 개선에 필요한 큰 요소 중 하나이다.

 

Next.js는 'CLS(Cumulative Layout Shift)' 해소 겸 이미지 사이즈 최적화 겸 미리 'width'와 'height' 값을 받아 놓는다.

개발자 모드에서 src를 확인해보면 요청 쿼리스트링을 확인할 수 있다.

 

  • 쿼리스트링 요소
    • 원본 이미지 주소
    • 가로 너비 에 대한 정보
    • 세로 너비에 대한 정보

 

위의 정보들을 참조하여 적절한 크기로 이미지를 최적화하여 돌려준다. 즉, 원본 이미지를 직접 사용하지 않고 Next.js가 한 번 정돈한 뒤 사용하는 것이다.

또한, 'blur', 'placeholder' 속성을 사용하면 이미지가 아직 다 받아와지지 않았을 때 미리 콘텐츠가 표시된 것처럼 보임으로써 '인지된 성능' 지표를 올릴 수 있다.

 

const blurStyle =
    	placeholder === 'blur' && !blurComplete
    ? {
        backgroundSize: objectFit || 'cover',
        backgroundPosition: objectPosition || '0% 0%',
        filter: 'blur(20px)',
        backgroundImage: `url("${blurDataURL}")`,
    }
    : {}

 

그런데, 왜 굳이 API 호출 방식으로 최적화를 할까?

여러 가지 이유 중 '한 번 최적화를 처리한 콘텐츠는 서버 내 캐시에 저장'되는 것을 활용해 성능 상 큰 효과를 보려고 한 것 같다.

 

이러한 처리를 서비스 만들 때마다 구현해야 했다면 많은 시간과 노력이 필요하다.

Next.js의 프로덕션에 필요한 기능들을 간단하게 사용할 수 있도록 미리 구현해둔 프레임워크이다.

 

next/image는 필수인가?

이미지를 최적화하고, 캐싱하여 효율적으로 처리하여 'CLS'는 줄어들고, 최적화된 이미지를 빠르게 받을 수 있다.

그러나, 좋은 물건은 가격이 비싸다. 'next/image'는 공짜가 아니다.

 

'next/image'는 서버 리소스를 사용하며, 이미지 프로세싱에 들어가는 컴퓨팅 리소스는 작지 않다.

 

next/image의 구조적인 리스크

다중 환영 분신술

동일한 이미지가 여러 장 연달아 렌더링 되는 이슈가 보고되는 경우가 있다. 문제 해결을 위해 여러가지 접근을 해봐도 문제가 없는 것처럼 보인다.

 

그러나, 문제는 Next.js 내부에서 이미지를 프로세싱하고 캐시에 저장할 때 동시성 이슈로 인해 한 이미지가 다른 이미지를 덮어써서 발생한 이슈이다.

 

코드의 시점에서는 모든 것이 올바른 것처럼 보이지만, 프로세스 버퍼의 출력을 보면 실제로 잘못됐다는 것을 알 수 있게 된다. `processBuffer`, `Operation` 등을 사용해서 디버깅으로 확인해보면 말이다.

 

예를 들어, 이미지가 두 개가 있을 때, 이미지 1과 2를 순차적으로 프로세싱 했지만, 특정한 경우 결과물이 뒤섞이는 문제가 발생한다. 보통 캐시는 '키-값'으로 이뤄지고, 키는 '요청 URL', 값은 '프로세싱이 완료된 이미지'이다. 서로 다른 URL로 요청하지만 캐시에 담긴 값 일부는 덮어씌어진 상태로 응답되는 것이다.

 

이 케이스는 CPU 갯수에 영향을 받으며, 이 경우에는 디버깅이 매우 어렵다. 이슈 내용으로 보면 '경쟁 상태(Race Condition)'에 의한 버그이다.

 

  • 경쟁 상태: 둘 이상의 입력 또는 조작의 타이밍이나 순서 등 결과값에 영향을 줄 수 있는 상태
    • 입력 변화의 타이밍이나 순서가 예상과 다르게 작동하면 정상적인 결과가 나오지 않게 될 위험을 경쟁 위험이라고 한다.

 

'next/image'를 사용할 때 서버 측에 존재하는 캐시의 존재로 인해 캐시 레이어에서 문제가 발생할 수 있겠다고 판단이 가능해진다.

 

살아남자 개복치

 

Image Optimization causes out of memory error · Issue #24531 · vercel/next.js

What version of Next.js are you using? 10.1.3 What version of Node.js are you using? 14.16.1 What browser are you using? Chrome What operating system are you using? ubuntu How are you deploying you...

github.com

 

이미지 프로세싱은 무거운 연산이다. 왜 무거울까?

정확한 내용은 ChatGPT에게 물어보자. 대략적으로 무거운 이유는 다음과 같다.

 

  • CPU 부하
    • 픽셀 연산의 양
    • 복잡한 연산
  • 메모리 부하
    • 압축 해제
    • 대용량 데이터
    • 다중 버퍼링 및 중간 데이터

 

'CPU(중앙 처리 장치)'는 컴퓨터의 연산 작업을 수행하며, 이미지 프로세싱 작업에서 복잡한 수학적 계산, 반복적인 연산, 데이터 변환 등을 처리한다.

'메모리(RAM)'는 데이터를 일시적으로 저장하고 접근하는 공간을 제공하며, 이미지 프로세싱 작업에서 메모리는 이미지 데이터와 중간 결과를 저장하는 데 사용된다.

 

각 이미지 포맷마다 압축하는 경우 등 상황을 보면 메모리보다 CPU 쪽에서 100% 사용량을 초과하는 상황이 더 빠르게 발생할 것으로 보인다. 버스트를 사용하면 다르겠지만, 동시 요청이 조금만 높아져도 금방 서버가 뻗을 가능성이 존재한다.

 

이처럼 서버 자원은 공짜가 아니다. Next.js는 지속적으로 메모리 이슈를 겪고 있고, 관련 이슈 수정 및 개선도 지속적으로 이뤄지고 있다.

 

주어진 자원 안에서 정상적으로 사용하는 경우에 대한 부분에서는 개선이 이뤄지고 있지만, 정말 문제는 '메모리 누수(Memory Leak)'이다.

 

자바스크립트는 사용되지 않는 메모리 자원을 주기적으로 해제시켜주는 매커니즘인 '가비지 컬렉터'가 있다. 그러나, 이 매커니즘이 정상적으로 동작하지 않을 때가 있다.

메모리 사용률 100%가 되고 새롭게 할당할 메모리가 없어지므로, 서버 작동에 필요한 정상 프로세스를 수행하기 어려운 상태가 되고, 결국 서비스가 다운된다. 메모리를 무겁게 다루는 작업일수록 관리가 잘 이뤄지지 않을 가능성이 높다.

 

이제 신경쓰지 않아도 돼

그렇다면, Next.js에서 Image 컴포넌트를 쓰는 것은 폭탄일까? 사용하지 말아야 할까?

아니다. 프레임워크가 제공하는 도구는 개발 생산성에 도움이 된다. 기본적인 프로그래밍 원칙인 '관심사의 분리'에 의거하여 문제를 해결할 수 있다.

 

우리는 일반적으로 Next.js를 가지고 SSR 렌더링을 기대하지만, 이미지 최적화를 하다가 서버가 죽을 것을 기대하지 않는다. 즉, 지금까지 이미지 프로세싱을 처리하는 주체가 SSR 렌더링 + 이미지 프로세싱을 모두 처리해서 문제가 되었다고 보면 된다.

 

결국 이미지 최적화를 처리할 별도로 분리된 서버 리소스가 필요하다. 그러나, 현대적인 CDN에서는 이러한 니즈에 대해 이미 좋은 해결책을 가지고 있다. 프로바디어 별 로더 코드를 적절히 구현하고 나면 Next.js 서버 자체 컴퓨팅 파워가 아닌 다른 곳의 자원을 사용해 이미지 최적화가 가능하다.

 

// Docs: https://aws.amazon.com/developer/application-security-performance/article
export default function cloudfrontLoader({ src, width, quality }) {
    const url = new URL(`https://example.com${src}`)
    url.searchParams.set('format', 'auto')
    url.searchParams.set('width', width.toString())
    url.searchParams.set('quality', (quality || 75).toString())
    return url.href
}

// Docs: https://developers.cloudflare.com/images/url-format
export default function cloudflareLoader({ src, width, quality }) {
    const params = [`width=${width}`, `quality=${quality || 75}`, 'format=auto']
    return `https://example.com/cdn-cgi/image/${params.join(',')}/${src}`
}

 

위와 같이 로더 함수를 작성하고, `next.config.js`에서 loaderFile의 경로를 등록해주면 Image 컴퓨넌트는 우리가 새롭게 정의한 함수를 사용하여 이미지 최적화를 해준다.

 

module.exports = {
	images: {
        loader: 'custom',
        loaderFile: './my/image/loader.js',
    },
}

 

  • CloudFlare의 경우 CloudFlare Images의 이미지 리사이징, 최적화, 저장 등 서비스를 지원한다.

 

데이터 페칭은 무조건 useQuery를 사용해야 한다?

Tanstack-Query는 선언적인 방식으로 클라이언트 측 상태와 서버 상태를 알아서 동기화해준다.

// Tanstack-Query 사용 예제
function Posts({ setPostId }) {
    const { status, data, error, isFetching } = useQuery({
        queryKey: ["post", postId],
        queryFn: () => getPostById(postId)
    );
    // ...
}

 

React에서 모든 상태들이 `useState`로 관리될 필요 없듯, Tanstack-Query의 모든 데이터 호출을 `useQuery`로 할 필요도 없을 것이다.

 

`useQuery`를 사용한다는 것은 Tanstack-Query의 코어 로직을 React에 묶어 사용하게 되는 것이다. 즉, React에 대한 종속성이 생긴다는 것이다.

 

Framework Agnostic(프레임워크 독립적)

Tanstack-Query는 프레임워크 독립적한 라이브러리이다. 핵심 로직을 중심으로 각 프레임워크 별 바인딩을 추가하는 방식으로 확장이 가능하다는 의미이다.

 

확장 가능성의 핵심은 Tanstack-Query의 Core 로직을 이루는 `QueryClient` 클래스가 있다.

이 클래스는 옵저버 패턴을 기반으로 상태 업데이트를 전파하고, 내부에 자바스크립트로 작성된 캐시를 관리하는 기능을 포함하여 '상태 업데이트를 전파'하는 기능 자체는 순수 자바스크립트로 작성되어 있다.

 

// 자바스크립트에서 Tanstack-Query
export const queryClient = new QueryClient()
const fetchTodos = () => {
    const { data } = await queryClient.ensureQueryData({
        queryKey: ["todos"],
        queryFn: getTodos
    })
    
    return data
}

const renderTodos = async () => {
    const todos = await fetchTodos()

    // todos를 DOM에 반영
    const todoList = document.getElementById('todoList')
    todos.forEach(todo => {
        const todoItem = document.createElement('li');
        todoItem.textContent = todo;
        todoList.appendChild(todoItem);
    });
}

 

Rules of Hooks

프레임워크는 사용하되, 결합하지 말자. 우리가 무의식적으로 받아들이고 있어 잊고 있던 규칙이 있다.

useQuery는 커스텀 훅이다. 그렇기 때문에, Rules of Hooks의 규칙을 따라야 한다.

렌더와 Hooks의 호출 순서는 연관이 깊기 때문에 단순히 호출 조건을 `if - else`로 다룰 수는 없다.

 

  • 최상위 수준에서만 Hooks를 호출하기
    • 루프, 조건, 중첩 함수 또는 `try / catch / finally` 블록 내에서 Hooks 호출하기 않기
    • 대신, 얼리 리턴 전 항상 React 함수 최상위 수준에서 Hooks 사용하기
  • React 함수에서만 Hooks를 호출하기
    • 일반 자바스크립트 함수에서 Hooks 호출하지 않기

 

여러 기능들을 Hooks에 연동하지 않고 직접 함수 호출로 처리할 수 있다면 어떨까?

즉, `useQuery` 대신 React에 의존성이 없는 `queryClient`를 직접 사용하는 것이다. `useQuery`는 단순히 `queryClient`를 React와 연동해주는 컨테이너뿐이니까.

 

async function loadData(userId) {
    try {
        // 사용자 정보를 먼저 확인하고 필요한 경우에만 가져옵니다.
        const { data: user } = await queryClient.ensureQueryData({
            queryKey: ['user', userId],
            queryFn: () => fetchUser(userId)
        });

        // 사용자의 게시물을 확인하고 필요한 경우에만 가져옵니다.
        const { data: posts } = await queryClient.ensureQueryData({
            queryKey: ['posts', user.id],
            queryFn: () => fetchPostsByUser(user.id)
        });

        if (posts.length > 0) {
            // 첫 번째 게시물의 댓글을 확인하고 필요한 경우에만 가져옵니다.
            const { data: comments } = await queryClient.ensureQueryData({
                queryKey: ['comments', posts[0].id],
                queryFn: () => fetchCommentsByPost(posts[0].id)
            });
            return { user, posts, comments };
        }
        
    	return { user, posts };
    } catch (error) {
        console.error("Error loading data:", error);
        throw error;
    }
}

 

위 처럼 코드를 작성하면, 데이터 호출 로직을 컴포넌트와 분리할 수 있다는 장점이 있다. 즉, React에 대한 의존성을 지울 수 있다.

 

  • 선언적 데이터 페칭보다 일반 함수 내 로직의 일부로서 취급할 수 있을 때
    • onClick 내 작성된 로직에서 데이터 의존성을 캐시에서 꺼내올 때
    • 유저 행동에 대한 로거를 React 외부에서 호출할 때 필요한 값을 캐시에서 값을 꺼내올 때
  • 페이지에 필요한 데이터 의존성을 React 외부에서 미리 호출할 때
    • `react-router`의 loader에서 데이터 의존성을 미리 호출한 뒤 캐시에 넣을 때
    • 서버 사이드 렌더링(SSR)이나 정적 사이트 생성(SSG)과 같은 환경에서 데이터를 미리 불러오고 캐시할 때

 

`react-router`의 loader에서 데이터 의존성을 미리 불러온다면?

`react-router`의 loader를 사용하면, 페이지를 진입하는 최초 시점에 필요한 데이터 의존성들을 모두 앞당겨(Flatten) 가져올 수 있다. 꼭 데이터 호출 말고도 여러가지 초기화 작업들을 하기 좋다. loader를 잘 사용하면 불필요한 `useState`와 `useEffect`를 많이 줄일 수 있다.

 

import { useLoaderData } from "@remix-run/react";

export async function loader() {
	return json(await db.user.findMany());
}

export default function Users() {
    const data = useLoaderData<typeof loader>();
    return (
        <ul>
            {data.map((user) => (
            	<li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}

// loader를 사용한 데이터 페칭 예제 코드
// react-router의 loader 함수
async function loader({ params }) {
    const userId = params.userId;
    await queryClient.ensureQueryData({ // 캐시에 있으면 리턴, 없으면 호출
        queryKey: ['user', userId],
        queryFn: () => fetchUser(userId)
    });
    await queryClient.ensureQueryData({
        queryKey: ['posts', userId],
        queryFn: () => fetchPostsByUser(userId)
    });
    // 로더가 데이터를 미리 로드하고,
    // 페이지 컴포넌트에서는 이 데이터를 캐시에서 직접 참조할 수 있습니다.
}

function UserProfile({ params }) {
    // 페이지가 로드되는 시점에서는 캐시 내에 이미 데이터를 가지고 있음
    const user = useQuery(['user', params.userId])?.data
    const posts = useQuery(['posts', params.userId])?.data
    
    return (
        <div>
            <h1>{user?.name}</h1>
            <ul>
            	{posts?.map(post => <li key={post.id}>{post.title}</li>)}
            </ul>
        </div>
    );
}

 

컴포넌트는 보다 그리기에 집중하고, loader는 데이터 의존성을 담당하는 관심사의 분리가 잘 이뤄진 코드이다. 무엇보다 loader에서 불러온 데이터 의존성들은 Tanstack-Query의 캐시에 담아 전역 상태처럼 사용할 수 있다.

// 예시 코드
const getFirstUserId = () =>
	queryClient.getQueryData({ queryKey: ["users"], queryFn: getUsers })[0].id
    
<div>first user id: {getFirstUserId()}</div>

 

`useQuery`만 사용해야 한다고 생각했던 사람들도 있겠지만, 사실 잘못된 건 아니다. 연산에 필요한 값을 내부 저장소로부터 쿼리하여 가져왔을 뿐이니까. 억지로 React와 엮인 부분이 없어 자유롭게 분리하고 활용할 수 있어 가벼워 보인다.

 

우리가 작성하는 코드를 모두 프레임워크와 독립적으로 작성한다는 것은 현실적이지 않는 목표이다.

가장 생각해야 하는 것은 우리의 코드는 무엇과 엮어 있는가? 그 엮임으로 인해 지불한 댓가는 무엇인가? 얼마만큼 프레임워크로부터 독립적일 수 있을까?

 

Critical Rendering Path

웹 페이지가 브라우저에 로드되고 화면에 렌더링되는 과정인 '중요 렌더링 경로(Critical Rendering Path)' 개념은 굉장히 중요하다. 프론트엔드 개발은 이 구조 위에서 일어나는 변화이기 때문이다.

 

핵심은 HTML과 CSS로부터 문서의 구조와 스타일을 읽어온 뒤, 하나의 화면을 그려내는 과정이다. HTML과 CSS 파일 그 자체는 문자열일 뿐. 브라우저는 이 문서들을 '파서(Parser)'로 읽어와 의미 단위로 '토큰화(Tokenization)' 한다. 그리고 각각 'DOM(Document Object Model)''CSSOM(Cascading Style Sheet Object Model)'으로 만든다.

그 다음 이 두 트리를 합쳐 실제 화면을 그리기 위한 정보인 '렌더 트리(Render Tree)'로 변환하고, 렌더 트리에는 시각적으로 보이는 요소들만 포함된다. (예. head, script, display: none 요소들 제외)

그 후, 브라우저는 렌더 트리를 기반으로 각 요소가 위치할 자리를 '계산(Layout)'하고, 화면을 '그리기(Painting)'하며, 중간 렌더 트리에서 변화가 생기면 그 변화의 범위에 따라 요소가 그려질 범위를 다시 계산하거나, 화면에 그린다.

 

이 때, 레이아웃과 페인팅은 여러 레이어로 나뉘어 작업이 이뤄져 ('쌓임 맥락', 'Stracking Context' (ex. z-dinex)) 최종적으로 하나의 결과물로 합치는 과정이 필요하다. 레이어 간 순서를 따져 하나의 화면으로 '합성(Composite)'하면 모든 과정이 마무리 된다.

 

이 과정은 매우 중요하므로 잘 알아두어야 한다.

화면을 다시 그리는 Layout 과정 시각화 영상

좌측 상단에서 하나의 요소를 완성시키고 알맞은 위치로 이동시키는 작업을 반복하는데, 트리 구조에서 하위 노드를 가진 하나의 노드가 렌더링되는 과정을 상상하면 이해하기 쉽다.

 

여기서 굉장히 중요한 사실은 브라우저는 변경이 일어난 범위나 속성에 따라 새로 그리는 범위가 달라진다는 것이다.

 

  • 레이아웃: 엘리먼트가 화면에서 얼마만큼의 공간을 차지하고 어디에 위치해야 하는지에 대한 정보를 계산하는 단계
  • 페인트: 엘리먼트의 색상, 이미지, 테두리, 그림자 등 시각적인 요소를 그리는 단계
  • 합성: 페인트 단계까지 나뉘어 있던 레이어들은 하나의 평면으로 합쳐넣는 작업

 

브라우저가 화면을 렌더링 하는 규칙은 굉장히 복잡하고, '레이아웃'이 일어나는 대상 엘리먼트가 최상위 'Document' 객체인 경우가 여러 발생한다. 어떤 노드가 변경되었을 때, 정말 레이아웃에 변경이 있을지 없을지 확실히 알 수 없기 때문이다. 새로 그릴 수도 있다. 너무 세부적인 것들을 파고들기 보다 전체적인 그림을 봐야 한다.

 

이력서

잘 쓰여진 이력서는 생각보다 적다

이력서에 정보값이 없는 이력서가 많아 어떻게 물어봐야 할 단서를 찾는 일이 더 많다. 즉, 질문할 거리를 찾는 일이 더 많다는 것이다.

 

채용 업무에 있어서 참고한 기준

  • 엔지니어로서 경력에 맞는 경험을 쌓았는가? 즉, 경력의 밀도가 어떠한가?
  • 이력서에 본인의 언어로 소화한 고민의 흔적이 녹아 있는가?

 

실무 경험이 3년 차인데, 무한 스크롤 또는 useMemo와 같은 경험은 큰 기대가 없다. 실무를 1년 정도 경험하면 CRUD 기반의 적당한 서비스를 만들 수 있다. 따라서 2년 차 이상 경력자는 이력서에 단순히 기능 개발한 것 외에 본인이 서비스를 운영하면서 마주쳤던 기술적인 어려움과 그에 대한 해결에 대한 실마리가 담겨져 있어야 한다.

 

예를 들면, 번들 크기 관련 초기 성능 이슈라면 '코드 스플리팅', '트리 셰이킹', '번들러', '모듈 시스템', 'Webpack' 등의 키워드가 들어가 있을 것이다.

혹은, 비즈니스 로직이 얽혀 유지보수가 힘든 코드 때문에 고생했다면 'SOLID', '캡슐화', '디자인 패턴' 등 키워드가 들어가 있을 것이다.

 

중요한 것은, '뽑을 이유'와 뽑을 이유에 대한 가설을 검증하고 싶다는 '궁금증'을 불러일으키지 못한다면 그 이력서는 다음으로 나아가지 못할 확률이 높아진다.

 

본인의 체험을 통한 것이 나임에 언어로 소화되지 못한 것도 낮은 점수를 받는다. 단순히 검색 결과를 긁어 나열한 블로그의 경우에도 없는 것보다 낮지만 좋은 평가를 얻지 못한다.

 

신입 이력서의 경우 본인이 소화하지 못하는 과도하고 거창한 키워드로 채워진 이력서들에 대해서도 낮은 점수를 받는다. 신입은 공부 기간이 짧을 수 밖에 없기 때문에 그 동안 본인이 얼마나 빠르게 성장해왔고, 앞으로도 그럴 것인지 어필하는 것이 가장 좋다.

 

사람을 뽑는 입장에게 기술적인 숙련도를 떠나 이 사람이 옆자리에 앉았을 때 좋은 동료가 되어줄 수 있을지를 가늠할 수 있는, 즉 안심하고 나를 뽑을 수 있도록 안심시켜 주기 위한 정보를 제공할 필요가 있다.

이러한 정보를 잘 전달할 수 있는 매체가 바로 기술 블로그에 정리한 고민들이다. 누군가에게 보여주기 보다 순전히 나의 어떤 문제를 만나 어떤 과정을 거쳐 해결해 나갔는지 보여주기만 하면 충분하다. 그 과정이 결국 업무를 보는 순서와 다르지 않기 때문이다.

 

이력서를 성장시키는 노력들

Localhost 바깥의 세상으로 나아가기

'Localhost'을 벗어나야만 배우는 것들이 있다.

내가 사용하고 싶은 서비스, 도구에 집중하는 것이 경쟁력이다.

 

  • "계속 서버가 죽어서 고민이며, 이 문제를 해결하기 위해 A, B 테스트를 했다."
  • "로그로 서버 용량이 계속 차는 문제가 있는데 어떻게 해결해야 할까?"

 

위와 같은 고민들이 진짜 실무를 하는 사람들이라는 인식을 줄 수 있다.

서비스 경험이라고 해서 많은 사용자가 있는 거창한 서비스, 실무에서 사용하는 MSA, k8s에 각종 프레임워크까지 모두 적용한 환경에서의 경험을 요구하는 것이 아니다.

 

화려한 최신 기술에 휘둘리지 말고, 정말로 회사에서 실제로 진행하게 될 '서비스 개발'과 '제품 출시 후 운영'에 대한 핵심 경험을 쌓아야 한다.

 

기술들에 대한 감식안이 없는 신입 입장에서 키워드 중심의 기술 탐색을 하게 되고, 여러 마케팅적 문구들로 인해 불안감이 증폭된 나머지 정말 개발자로서의 핵심 역량을 키울 수 있는 시간보다 불아낙ㅁ을 줄이기 위한 공부를 하는 현실이다.

공부의 기회는 온라인 광고에서 시작하는 것이 아닌 내가 만든 서비스가 한계에 부딪히는 그 순간부터 시작된다.

 

단순히 앱 레벨의 로직 작성을 넘어 배포 환경은 어떻게 구성해야 하는지, 어느 정도 스펙의 서버를 돌리면 한 달에 얼마나 비용이 나오는지, 배포 이후 어떤 에러들이 발생할 수 있는지, 이러한 에러 상황을 수집하는 방법에는 어떤 것들이 있는지 배울 수 있다. 이런식으로 진짜 체감해본 기술이야 말로 이력서의 '믿을맨'이 된다. 한 번 검색하면 나오는 수준의 지식은 신입 입장에서 꼬리질문 한 두번이면 벗겨지기 때문이다.

 

그렇기 때문에, 하나를 사용하더라도 꼭꼭 씹어먹은 사람만이 자신의 경험을 토대로 충분히 좋은 면접 대화를 만들 수 있다. 신입에게 대단한 최신 기술을 기대하지 않는다. 오히려, 문제를 만났을 때 어떻게 생각하고 해결하는지 궁금해한다.

 

 

경쟁력 있는 신입 포트폴리오

팀원들의 이력서를 글 하단에 첨부해두었다. 이 사이드 프로젝트를 진행한 멤버들에게 관심이 생긴다면 한번 커피챗을 요청해보자. 올해 대학생분들을 멘토링을 종종 했다. 자주 받던 고민이 "

jojoldu.tistory.com

 

기술 면접, 정공법으로 준비하기

개발자를 준비한다면, 한 번쯤 기술 면접을 뚫어야 한다. 구직자가 이 프로세스에서 해야 할 일은 '채용 과정에서 회서 측에', '나의 포텐션을', '측정 가능한 형태로 어필하기'이다.

 

채용은 회사와 지원자가 채용 과정을 통해 서로를 알아보는 교감하는 과정이다.

  • 지금 이 회사가 내게 필요한지(커리어) / 이 사람이 지금 우리 회사에 필요한지(비즈니스 상황)
  • 이 회사는 포텐셜이 있는지 / 이 사람은 포텐셜이 있는지

 

여기서 기대 가치를 말하는 '포텐셜'은 어떻게 확인할 수 있을까?

바로 관찰 가능한 수준이다. 만약, "채용이 된다면 개발 업무를 잘 할 수 있는, 혹은 근시일 내에 잘하게 될 수 있을 것으로 기대되는 사람인가?"에 가깝다.

 

관찰 가능한 수준으로 구체화한다면, "논리적으로 기술에 대해 자신의 견해를 밝힐 수 있는 사람인가?"이다.

즉, 면접관은 지원자가 논리적으로 기술에 대해 '자신의 견해'를 밝히는지 확인할 수 있다. 질문과 대답, 대답에 대한 꼬리질문에서 말이다.

 

포인트는 두 가지이다.

 

  • 논리적
  • 자신의 견해

 

이 꼬리질문에 대답을 잘 하기 위해서는 평소에 공부를 잘 해두어야 한다. 자기가 사용한 기술에 대한 고민과 이해를 통해 면접관과 의견을 교환할 수 있는 '형태'로 지식을 쌓아야 한다. 주의할 것은 어떤 기술에 정답이 있는 것처럼 "그렇다더라~"식의 공부는 오히려 부정적인 상황을 만든다.

 

실제 업무 환경은 상황에 따라 기술들의 트레이드 오프를 따진다.

이러한 상황에서 논리적으로, 자신의 견해를 개진하는 사람들이 더 나은 선택을 한다. 이를, '기본기'라고 할 수 있으며, 채용 과정에서 기본 지식이 아닌, 지식을 다뤄온 태도를 보고 싶다에 가깝다.

 

'기술에 대한 견해'를 준비하기 위해서는 "이해했는가?"의 기준을 "사용해봤다" 혹은 "의견을 낼 수 있다"로 잡으면 효과를 볼 수 있다. 이를 위해, 직접 사용해서 최대한 실전적인 지식을 쌓고, 그 고민 흔적들을 나의 언어로 소화한 뒤 기록하는 것이다.

 

가급적 좋은 레퍼런스로 공부해야 하며, MDN, 공식 문서, 오픈소스 코드, Issue, PR 등을 의식적으로 많이 접하자.

블로그, 스택 오버플로우 등 무지석 복붙은 이슈 해결에는 도움이 되지만, 성장에 대한 거름이 되기는 어렵다.

 

많이 말하고, 많이 글로 써보자. 경험과 의견을 다른 사람과 많이 섞을 수록 더 나은 학습이 된다. 즉, "공개적으로 학습하라!"

 

나라는 사람

대부분 질문들은 열심히 준비하는 기술 질문보다 '나'를 묻는 질문들이다.

 

  • 5년 후 어떤 개발자가 되길 바라는가?
  • 제품을 통해 어떤 가치를 전달하고 싶은가?
  • 회사에서 어떤 것을 이루고 싶은가?
  • 본인을 소개할 때 어떤 키워드로 표현하는가?

 

보통 이력서를 많이 탈락하는 사람들은 시간이 있다면 '나'를 자문하고, 그 속에서 '성공 경험(예. 뿌듯함)'을 반드시 찾는 게 중요하다. 취업에 대한 압박감으로 시장에서 요구하는 것처럼 보이는 추상적인 키워드를 어색하게 붙여놓은 이력서가 되는 경우가 많은데, 이런 일이 없도록 나에 대해 잘 알아야 한다.

 

여기서 말하는 성공 경험은 대단한 것이 아니다.

 

  • 외국 기술 블로그를 읽다가 마음에 들어 한글로 번역한 것
    • 왜 그런 번역 활동을 했는가?
    • 그 활동을 통해 무엇을 느끼고 얻었는가?

→ 그런 활동을 하는 나 자신은 어떤 사람인지 보여줄 수 있는 재료로 쓸 수 있다.

 

면접 대화는 추정하는 바 보다 사실을 기반으로 실제 어떤 상황에서 어떤 결정을 내렸는지에 대한 맥락을 공유하며 해상도 높게, 효과적으로 커뮤니케이션을 했을 때 만족스럽게 느껴진다. 딱 떨어지는 대화를 하기 어렵지만, 내가 어떤 사람이고, 이 일에 얼마나 열의를 가지고 있고, 채용 프로세스에 있어서 진심을 다하고 있는지 보여주는 일이다.

 

결국 채용은 옆자리 동료를 구하는 일이다. 면접관에게 가장 두려운 일은 본인 손으로 뽑은 사람이 결과적으로 채용 과정 동안의 안목 부족으로 인해 팀에 악영향을 끼치는 결과이다. 그렇기 때문에, 지원자의 캐릭터와 장단점 파악에 힘을 쓰게 된다.

 

위축할 수 밖에 없는 지원자 입장에서 어렵지만, 빠르게 본인이 누구인지 드러내고 면접관과 솔직하게 대화를 나누는 단계로 넘어가는 게 좋다. 정답 맞추기 퀴즈식 질문 대결보다 본인이 얼마나 이 회사에 맞는 사람인지에 대해 이야기를 나누기 위한 노력을 하는 것이 중요하다.

 

이런 대화를 위해서 내가 누구인지에 대한 정리가 잘 되어 있어야 한다.

내가 언제 즐거움을 느끼고, 그 즐거움 속에서 얼마나 열정적으로 일했었는지에 대한 사례를 근거로 대화를 나눠보자. 어력서와 면접이 기술적인 키워드로만 이뤄지지 않는다는 사실을 환기시켜줄 필요가 있다.