2년차 때, 백엔드 개발자와 API 스펙을 논의하고 있었다. 대시보드 페이지에 필요한 데이터를 정리해서 전달했다. 사용자 정보, 최근 활동, 통계 수치, 설정값, 알림 목록. 한 화면에 보여줘야 하는 게 많았고, 나는 API 하나에서 전부 내려받으면 편할 것 같았다.
"이 응답에 필드가 50개가 넘는데, 다 필요해요?"
백엔드 개발자가 물었다. 당연히 필요하지, 화면에 다 보여줘야 하니까. 그렇게 답했다.
그 사람이 잠시 멈추더니 이렇게 말했다. "이 데이터를 한 번에 내리려면 테이블 6개를 조인해야 하고, 알림 목록은 서브쿼리가 필요해요. 이 API가 호출될 때마다 DB에 꽤 무거운 쿼리가 날아가요."
솔직히 그때는 그 말이 잘 와닿지 않았다. 조인? 서브쿼리? 무거운 쿼리? 프론트엔드에서 그건 그냥 fetch 한 번이고 JSON 하나인데. 내 입장에서는 API가 느리면 로딩 스피너를 보여주면 되는 거 아닌가 싶었다.
하지만 그 생각이 얼마나 좁았는지를 나중에야 깨달았다.
API 하나의 뒷편
프론트엔드에서 API를 호출하면 JSON이 온다. 깔끔하다. 화면에 필요한 데이터가 구조화되어 들어있다. 이걸 받아서 상태에 넣고 렌더링하면 끝이다.
그 JSON이 만들어지기까지 뒤에서 무슨 일이 일어나는지를 프론트엔드 개발자는 잘 모른다. 나도 몰랐다.
대략 이런 일이 벌어진다. HTTP 요청이 서버에 도착한다. 라우터가 해당 엔드포인트의 핸들러를 찾는다. 핸들러가 비즈니스 로직을 실행한다. 대부분의 비즈니스 로직은 데이터베이스에서 데이터를 읽거나 쓰는 걸 포함한다. DB 드라이버가 SQL 쿼리를 데이터베이스 서버에 보낸다. 데이터베이스가 쿼리를 파싱하고, 실행 계획을 세우고, 디스크(또는 캐시)에서 데이터를 읽어서 결과를 반환한다. 서버가 이 결과를 가공해서 JSON으로 직렬화하고, HTTP 응답으로 보낸다.
이 과정에서 가장 시간이 많이 걸리는 건 보통 데이터베이스다. 네트워크 왕복, 쿼리 실행, 데이터 전송. API가 느리면 백엔드 코드의 문제일 수도 있지만, 대부분은 데이터베이스 쿼리가 느린 거다.
내가 50개 필드를 요구하면서 "다 필요해요"라고 했을 때, 그건 "테이블 6개를 조인하는 무거운 쿼리를 매 요청마다 실행해주세요"라고 한 것과 같았다. 프론트 입장에서는 JSON 필드 50개지만, DB 입장에서는 여러 테이블을 읽어서 합치는 비용이 드는 작업이다.
인덱스라는 게 왜 중요한지
데이터베이스를 공부하기 시작하면서 가장 먼저 "아하" 한 개념이 인덱스다.
책의 맨 뒤에 있는 색인(index)을 생각하면 된다. "React"라는 단어가 어디에 나오는지 찾고 싶을 때, 책을 1페이지부터 끝까지 넘기면서 찾을 수도 있지만, 색인을 보면 바로 찾을 수 있다. 데이터베이스 인덱스도 마찬가지다.
테이블에 100만 행이 있다고 하자. WHERE email = 'user@example.com'으로 검색할 때, 인덱스가 없으면 100만 행을 전부 읽어야 한다(풀 스캔). 인덱스가 있으면 바로 찾는다. 차이가 극적이다.
이걸 알고 나니 API 설계를 보는 눈이 달라졌다. 예를 들어 사용자 목록을 검색하는 API를 만든다고 하자. 프론트엔드에서 "이메일로 검색, 이름으로 검색, 가입일로 정렬"을 하고 싶다. 이 각각이 DB에서는 해당 컬럼에 인덱스가 있어야 빠르게 동작한다.
만약 "전화번호로도 검색하고 싶어요"라고 요구하면, 전화번호 컬럼에 인덱스를 추가해야 할 수 있다. 인덱스는 공짜가 아니다. 읽기는 빨라지지만 쓰기는 느려진다. 데이터를 넣거나 수정할 때마다 인덱스도 업데이트해야 하니까. 그래서 인덱스를 무한히 추가할 수는 없고, 실제로 자주 사용되는 검색 조건에 대해서만 추가하는 게 좋다.
프론트엔드 개발자가 이걸 알면 뭐가 좋으냐. 백엔드 개발자한테 "이 필터 추가해주세요"라고 할 때 좀 더 맥락 있는 대화를 할 수 있다. "이 필터는 사용 빈도가 높으니까 인덱스가 필요할 것 같은데, 어떻게 생각하세요?" 이런 식의 논의를 할 수 있다.
N+1 문제를 아는 프론트엔드 개발자
N+1 문제. 백엔드 개발자에게는 기본 상식인데, 프론트엔드 개발자는 모르는 경우가 많다. 나도 몰랐다.
예를 들어보겠다. 게시글 목록을 보여주는 페이지가 있다. 각 게시글에 작성자의 프로필 이미지와 이름을 보여줘야 한다. 게시글이 20개면, 이걸 어떻게 가져올까.
순진한 구현은 이렇다. 먼저 게시글 20개를 가져온다 (쿼리 1개). 그 다음 각 게시글의 작성자 정보를 가져온다 (쿼리 20개). 총 21개의 쿼리. 이게 N+1이다. 게시글 N개에 대해 N+1개의 쿼리가 실행되는 것.
20개면 체감이 안 될 수도 있다. 그런데 페이지에 게시글이 100개면? 쿼리가 101개 날아간다. 각 쿼리에 네트워크 왕복 시간과 실행 시간이 있으니까, 이게 누적되면 API 응답이 눈에 띄게 느려진다.
해결책은 보통 조인이나 배치 로딩이다. 게시글을 가져올 때 작성자 정보도 같이 조인해서 가져오면 쿼리 1개로 끝난다. 또는 작성자 ID를 모아서 WHERE id IN (1, 2, 3, ...) 같은 배치 쿼리를 날리면 쿼리 2개로 끝난다.
이걸 프론트엔드 개발자가 왜 알아야 하냐고? API를 설계할 때 같이 논의하기 위해서다.
프론트에서 "게시글 목록에 작성자 이름도 보여줘야 해요"라고 하면, 백엔드 개발자는 그걸 어떻게 가져올지 고민한다. 프론트가 API 구조를 같이 논의할 수 있으면 더 효율적인 결과가 나온다. "게시글과 작성자 정보를 별도 API로 분리하면 프론트에서 병렬 호출할 수 있는데, 한 번에 조인해서 내려주는 게 나을까요?" 이런 대화를 할 수 있다.
정규화를 알면 컴포넌트 설계가 달라진다
데이터베이스 정규화를 공부하면서 재미있는 걸 발견했다. DB 테이블 설계와 프론트엔드 상태 설계가 생각보다 비슷하다는 거다.
정규화의 핵심은 데이터 중복을 줄이는 거다. 예를 들어, 게시글 테이블에 작성자 이름을 직접 저장하면 중복이 발생한다. 같은 사용자가 10개의 게시글을 쓰면 그 이름이 10번 저장된다. 사용자가 이름을 바꾸면 10곳을 다 수정해야 한다. 그래서 작성자 ID만 저장하고, 이름은 사용자 테이블에서 참조한다.
프론트엔드 상태 관리에서도 동일한 원칙이 적용된다. Redux나 Zustand 같은 전역 상태에서 데이터를 정규화하는 패턴이 있다. 게시글 목록과 사용자 목록을 각각 별도의 슬라이스에 저장하고, 게시글에는 작성자 ID만 가지고 있는 식이다. 이렇게 하면 사용자 정보가 업데이트됐을 때 한 곳만 수정하면 된다.
이걸 DB 정규화를 공부하기 전에는 그냥 "패턴"으로만 알고 있었다. "상태 정규화가 좋은 패턴이래" 수준이었다. 근데 DB 정규화를 이해하고 나니 왜 좋은 패턴인지가 명확해졌다. 데이터 무결성, 갱신 이상, 삭제 이상 같은 문제를 프론트엔드 상태에서도 동일하게 겪을 수 있다는 걸 알게 됐다.
스키마를 읽으면 컴포넌트가 보인다
요즘은 새 기능을 만들기 전에 DB 스키마를 먼저 본다. 백엔드 개발자한테 "이 기능 관련 테이블 구조 좀 보여주세요"라고 한다. ERD(Entity Relationship Diagram)가 있으면 그걸 보고, 없으면 테이블 정의라도 본다.
이게 왜 도움이 되냐면, 데이터의 관계를 먼저 이해하면 컴포넌트 구조를 더 잘 잡을 수 있다.
예를 들어 주문 관리 페이지를 만든다고 하자. 스키마를 보면 주문(orders) 테이블, 주문 항목(order_items) 테이블, 상품(products) 테이블, 결제(payments) 테이블이 있다. 주문과 주문 항목은 1:N 관계이고, 주문 항목과 상품은 N:1 관계이고, 주문과 결제는 1:1 관계다.
이걸 알면 컴포넌트를 어떻게 나눌지가 자연스럽게 보인다. OrderDetail 안에 OrderItemList가 있고, 각 OrderItem은 ProductInfo를 참조하고, PaymentInfo는 OrderDetail과 같은 레벨에 있다. 데이터 관계가 곧 컴포넌트 관계다.
API도 어떻게 요청해야 효율적인지 감이 온다. 주문 상세를 볼 때 결제 정보도 같이 필요한지, 주문 항목의 상품 상세까지 필요한지, 아니면 상품 이름 정도만 있으면 되는지. 스키마를 알면 백엔드에 더 구체적으로 요청할 수 있다. "주문 상세 API에서 order_items는 product의 name과 price만 포함시켜 주세요. thumbnail은 목록에서는 필요하지만 상세에서는 안 쓰거든요."
페이지네이션의 진짜 의미
프론트엔드에서 페이지네이션은 그냥 UX 패턴이다. 데이터가 많으니까 나눠서 보여주는 거. "다음 페이지" 버튼을 누르면 다음 데이터를 보여주는 거.
DB 관점에서 보면 얘기가 달라진다.
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0 — 이게 첫 페이지다. OFFSET 20이면 두 번째 페이지. 여기까지는 괜찮다.
문제는 OFFSET 10000이다. 500번째 페이지쯤 되면 DB는 앞의 10000개 행을 건너뛰어야 한다. 건너뛴다는 게 공짜가 아니다. 실제로 10000개를 읽고 버린다. 데이터가 많을수록, 뒤쪽 페이지로 갈수록 느려진다.
이 때문에 대규모 데이터에서는 커서 기반 페이지네이션(cursor-based pagination)을 쓴다. "다음 20개"가 아니라 "이 ID 이후의 20개"를 가져오는 방식. 이렇게 하면 OFFSET 없이 인덱스를 타고 바로 접근할 수 있어서 항상 일정한 속도가 나온다.
프론트엔드 개발자가 이걸 알면 뭐가 달라지냐. 무한 스크롤을 구현할 때 "page=1, page=2" 방식으로 요청할 건지, "cursor=abc123" 방식으로 요청할 건지를 백엔드와 논의할 수 있다. 데이터가 수만 건 이상이면 커서 방식이 낫다는 걸 알고, 처음부터 그에 맞는 프론트엔드 구조를 잡을 수 있다.
SELECT *의 비용
처음에 50개 필드 얘기로 돌아가보겠다.
SELECT *는 편하다. 필요한 컬럼을 일일이 안 적어도 되니까. 백엔드에서 API를 빠르게 만들 때 자주 쓴다. 프론트에서 "어떤 필드가 필요할지 아직 확실하지 않으니 일단 다 내려주세요"라고 하면 자연스럽게 SELECT *가 된다.
하지만 불필요한 데이터를 읽고 전송하는 건 비용이다. DB에서 디스크 I/O가 늘어나고, 네트워크 대역폭을 차지하고, JSON 직렬화 시간이 늘어나고, 프론트엔드에서 파싱하는 시간도 늘어난다. 한 번의 요청에서는 차이가 미미하지만, 동시 접속자가 수천 명이면 누적된다.
지금은 API를 요청할 때 정말 필요한 필드만 명시한다. "이 화면에서는 user의 id, name, avatar만 필요합니다. email, phone, address는 안 씁니다." 이렇게 구체적으로 말하면 백엔드에서도 최적화된 쿼리를 작성할 수 있다.
GraphQL이 인기를 얻은 이유 중 하나도 이거다. 프론트엔드가 필요한 필드만 정확히 명시할 수 있으니까. REST에서는 API 설계 시점에 이걸 합의해야 하지만, GraphQL에서는 프론트엔드가 런타임에 결정할 수 있다.
풀스택이 아니어도 된다, T자만 되면
내가 하고 싶은 말은 "프론트엔드 개발자도 풀스택이 되어야 한다"가 아니다. 그럴 필요 없다. SQL을 능숙하게 쓰거나, 데이터베이스를 설계할 수 있을 필요는 없다.
하지만 기본적인 개념 — 인덱스, 조인, N+1, 정규화, 페이지네이션 방식 — 정도는 알면 좋다. 이게 "T자형 인재"의 가로 막대 부분이다. 깊은 전문성은 프론트엔드에 두되, 인접 분야에 대한 기본적인 이해가 있으면 협업의 질이 달라진다.
The Pragmatic Engineer의 Gergely Orosz가 쓴 글을 보면, 시니어 엔지니어에게 기대하는 역량 중 하나가 "자기 전문 분야를 넘어서 시스템 전체를 이해하는 능력"이다. 프론트엔드 개발자가 DB를 전혀 모르면, API를 블랙박스로 대할 수밖에 없다. "요청하면 응답이 온다. 느리면 백엔드 문제." 이 사고방식에서 벗어나면, 더 나은 설계를 할 수 있고, 백엔드 개발자와 훨씬 생산적인 대화를 할 수 있다.
2년 전의 나한테 돌아가서 다시 그 미팅을 한다면, "50개 필드 다 필요합니다"가 아니라 이렇게 말할 것 같다.
"대시보드 상단의 요약 영역은 user 테이블과 stats 테이블만 조인하면 될 것 같고, 알림 목록은 별도 API로 분리해서 lazy load 하면 초기 로딩이 빨라질 것 같은데, 어떻게 생각하세요?"
같은 프론트엔드 개발자인데, 대화의 수준이 다르다. 그 차이를 만드는 건 DB를 "조금" 아는 것이다.
조금이면 충분하다. 그 조금이 생각보다 크다.
