SEO 생각하기, Lighthouse 100점이 채울 수 없는 것
점수는 100점인데 보이지 않는 페이지 프런트엔드 개발에서 SEO는 흔히 배포 전에 한 번 훑고 지나가는 체크리스트의 모습으로 다뤄집니다. 페이지마다 고유한 title을 넣고, meta description을 다듬고…
점수는 100점인데 보이지 않는 페이지 프런트엔드 개발에서 SEO는 흔히 배포 전에 한 번 훑고 지나가는 체크리스트의 모습으로 다뤄집니다. 페이지마다 고유한 title을 넣고, meta description을 다듬고…
모바일 앱에서 다른 사람에게 돈을 송금하면 화면 한가운데에 귀여운 애니메이션이 나타나고, 그 아래에는 “송금했어요!”라는 문구가 뜹니다. 이제는 너무 익숙해서 특별히 눈길이 가지 않는 장면입니다. 그런데 같은 상황을 실제 은행 창구로 옮겨 보면 조금 이상합니다. 은행 직원이 송금 처리를 마친 뒤 환하게 웃으며 “송금했어요!”라고 말하는 상황, 뭔가 어색한 장면이 그려지는 것입니다. 오히려 “송금되었습니다”라는 안내가 더 익숙하고 심지어 실제 고객 응대에서는 “송금되셨습니다”처럼 상대를 과하게 높이려다 송금이라는 행위에까지 높임을 붙이는, 문법적으로는 틀린 표현을 종종 듣기도 합니다. (사실 일상에서는 "송금되셨습니다"라는 표현이 훨씬 더 익숙하게 느껴지기도 합니다. 참고로 이는 문법적으로 명백히 틀린 표현입니다. 송금이라는 행위나 이 행위의 주체가 되는 돈을 높이는 것이 아니라 듣는 상대방, '사람'을 높이는 것이 높임법이 옳은 사용입니다.) 은행 창구에서는 조금 어색하게 느껴질 수 있는 말투가 모바일 앱에서는 아무렇지 않게 받아들여진다는 사실이 제법 흥미롭습니다. 사람과 직접 대면하는 상황과 화면에 표시되는 문장을 똑같이 취급할 수는 없겠지만, 돈을 보내는 행위 자체가 달라진 것은 아닙니다. 모바일 앱에서는 여기에 귀여운 캐릭터와 경쾌한 애니메이션까지 더해집니다. 송금이라는 금융 거래가 하나의 즐거운 경험으로 연출되는 셈입니다. 이런 화면이 잘못되었다고 생각하는 것은 아니지만, 서비스를 이용하면서 느끼는 편안함과, 서비스가 나에게 말을 건네는 방식에서 기대하는 격식은 서로 다른 문제일 수 있다는 생각을 해봅니다. 하십시오체를 보기 어려워진 요즘 화면들 요즘 모바일 앱을 사용하다 보면 대부분의 문구가 “~해요”로 끝난다는 인상을 받습니다. “아직 등록된 카드가 없어요”, “본인 인증을 완료했어요”, “문제가 발생했어요. 다시 시도해 주세요” 같은 문장은 어느 앱 어느 화면에 놓아도 크게 어색하지 않습니다. 상품을 둘러보는 화면에서도, 대출 계약을 확정하는 화면에서도, 개인정보 삭제 결과를 통지하는 화면에서도 비슷한 말투를 만납니다. 저는 이런 화면을 볼 때 가끔 묘한 위화감(?)을 느낍니다. 사용자가 하는 일의 성격과 중요도는 서로 다른데, 서비스가 건네는 말의 무게는 크게 달라지지 않는 것처럼 느껴지기 때문입니다. 물론 모바일 앱 전체에서 해요체가 얼마나 많이 사용되는지 보여 주는 통계 자료를 확보한 것은 아니라서, 이 글은 단지 여러 서비스를 사용하면서 가지게 된 지극히 개인적인 의문에서 출발합니다. UX 라이팅은 사용자가 서비스를 쉽게 이해하고 사용할 수 있도록 화면의 언어를 설계하는 일입니다. 그 과정에서 쉽고 간결한 문장, 사용자에게 친근하게 다가가는 말투가 중요하게 다뤄져 왔습니다. 이러한 변화에는 충분한 이유가 있습니다. 과거의 서비스나 공공기관에서 흔히 볼 수 있었던 길고 딱딱한 문장들은 대중으로 하여금 필요한 정보를 이해하는 데 많은 어려움을 주었습니다. 거리감도 상당히 느껴졌고요. 어려운 한자어와 복잡한 표현을 덜어 내고, 서비스가 사용자에게 직접 말을 거는 방식으로 바뀐 것은 분명 좋은 변화입니다. 다만 저는 그 과정에서 친근한 말투가 여러 선택지 가운데 하나가 아니라, 요즘에는 대부분의 모바일 앱 서비스에서 거의 당연하게 적용되는 기본값이 된 것 같다는 생각이 드는 것이고 그게 좀 어딘가 이상하게 느껴진다는 것입니다. 한국어에는 말투를 고르지 않을 자유가 없다 한국어는 문장을 끝맺는 순간 말하는 사람과 듣는 사람의 관계가 드러나는 언어입니다. 학교 문법에서는 이를 상대 높임법이라고 부르며, 듣는 사람을 어떻게 대우하느냐에 따라 종결어미를 여섯 단계로 나눕니다. 격식체에 속하는 하십시오체, 하오체, 하게체, 해라체와 비격식체에 속하는 해요체, 해체가 그것입니다. 이 글에서 주로 비교하게 되는 것은 격식체의 하십시오체와 비격식체의 해요체가 될 것 같습니다. 하십시오체는 아주높임, 해요체는 두루높임으로 설명되지만, 두 말투의 차이는 상대방과 어느 정도의 격식을 유지하느냐에서도 드러납니다. 영어의 “Your transfer is complete.”는 누구에게 말하든 같은 문장으로 사용할 수 있지만, 한국어로 옮기려면 “송금이 완료되었습니다”, “송금이 완료됐어요”, “송금이 완료됐어”처럼 종결어미를 선택해야 합니다. 물론 “송금 완료”처럼 명사형으로 문장을 끝맺는 방법도 있지만, 완결된 문장으로 사용자에게 말을 건네려면 어떤 높임 표현을 사용할지 정해야 합니다. 영어에서도 어휘나 문장 구조를 통해 격식과 사회적 관계를 드러낼 수 있으므로, 영어에는 이런 문제가 전혀 없다고 볼 수는 없습니다. 다만 한국어에서는 상대 높임이 종결어미에 문법적으로 나타난다는 점에서 차이가 있습니다. 해요체를 사용한다는 것은 단순히 문장을 부드럽게 다듬는 일이 아니라, 사용자와 어느 정도까지 거리를 두고 이야기할지를 선택하는 일이기도 합니다. 구분하십시오체하오체하게체해라체해요체해체(반말)문체격식체격식체격식체격식체비격식체비격식체높임 등급아주높임예사높임예사낮춤아주낮춤두루높임두루낮춤주로 쓰이는 자리공식 발표, 뉴스, 고객 응대, 공문일상 대화에서는 드물고, 안내문의 “~하시오”에서 볼 수 있음손아래사람이나 허물없는 사이에서 쓰임손아래사람에게 하는 말, 일기와 기사 같은 글말일상 대화, 부드럽게 높이는 말친밀한 사이에서 쓰임송금 결과송금이 완료되었습니다송금이 완료되었소송금이 완료되었네송금이 완료되었다송금이 완료됐어요송금이 완료됐어개인정보 처리개인정보가 삭제되었습니다개인정보가 삭제되었소개인정보가 삭제되었네개인정보가 삭제되었다개인정보가 삭제됐어요개인정보가 삭제됐어일반적인 인상공식성, 격식예스러움, 손윗사람의 말투손윗사람의 다정한 예스러움단정적임, 낮춤친근함, 부드러움친밀함, 격의 없음유달리 존칭이 발달한 한국어의 상대높임법 요약 (위 표의 마지막 행은 문법적으로 정해진 의미가 아니라 각각의 말투에서 일반적으로 기대할 수 있는 인상을 정리한 것입니다. ) 화면마다 무게가 다르다 예를 들어 “회원님의 개인정보가 삭제됐어요”와 “회원님의 개인정보가 삭제되었습니다”는 같은 사실을 전달하지만, 후자는 개인정보를 관리하는 주체가 처리 결과를 공식적으로 통지한다는 인상을 줍니다. 물론 종결어미만으로 실제 처리의 정확성이나 법적 효력이 달라지는 것은 아닙니다. 다만 서비스가 어떤 성격의 행위를 수행하고 있으며, 그 결과를 어떤 태도로 전달하는지는 문체를 통해서도 드러납니다. 제 개인적으로는 이런 상황에서는 친근함보다 공식성을 조금 더 앞세우는 편이 자연스럽다고 생각합니다. 사업자에게 제공되는 서비스는 개인의 일상적인 소비 활동을 돕는 서비스와 조금 다른 성격을 갖습니다. 사용자가 자신의 이름으로 서비스를 이용하고 있더라도, 실제로는 사업체를 대표해 계약을 체결하거나 거래 상대방에게 정보를 전달하는 상황이 생기기 때문입니다. 이런 화면에서 서비스가 사용하는 문장은 단순히 사용자 한 사람에게 건네는 안내를 넘어, 사업자 간의 공식적인 업무를 처리하는 언어가 되기도 합니다. 저는 이러한 차이가 문체를 선택할 때도 고려되었으면 합니다. 사용자에게 친근하게 다가가는 것만큼, 그 사용자가 어떤 역할과 입장에서 서비스를 이용하고 있는지를 이해하는 일도 중요하기 때문입니다. 반대로 상품을 둘러보거나 취향에 맞는 콘텐츠를 추천받고, 알림 설정을 변경하는 화면에서는 해요체의 부드러움이 더 자연스럽습니다. 이러한 화면에서 사용자는 법적 효력이 있는 결정을 내리거나 중요한 거래의 결과를 확인하는 것이 아니라, 비교적 가벼운 탐색과 선택을 이어 가기 때문입니다. 그런데 상품을 탐색하다가 실제 결제를 확정하는 단계로 넘어가면 상황이 달라집니다. 서비스는 더 이상 상품을 추천하는 역할에 머무르지 않고, 사용자의 결제 의사를 확인하고 거래 결과를 정확하게 전달해야 합니다. 사용자가 하는 행위의 성격이 달라졌다면 서비스의 말투도 그에 맞게 달라지는 편이 자연스럽지 않을까 생각해봅니다. 물론 화면이 바뀔 때마다 무조건 종결어미를 바꿔야 한다는 뜻은 아닙니다. 다만 서로 다른 상황에 언제나 같은 말투를 적용하는 것이 정말로 좋은 사용자 경험을 위한 설계인지에 대해서는 생각해 볼 필요가 있습니다. 한국어로 UX 라이팅을 한다는 것 UX 라이팅에서는 일관성을 중요하게 다룹니다. 같은 기능을 서로 다른 이름으로 부르거나, 비슷한 상황에서 전혀 다른 표현을 사용하면 사용자가 서비스를 이해하기 어려워지기 때문입니다. 문체 역시 일정한 규칙이 필요하지만, 저는 일관성이 반드시 모든 문장을 같은 종결어미로 끝맺는 것을 의미하지는 않는다고 생각합니다. 같은 사람과 대화할 때도 우리는 상황과 상대방과의 관계에 따라 말투를 조절합니다. 그렇다고 상대방을 대하는 기본적인 태도까지 달라지는 것은 아닙니다. 서비스에서도 마찬가지로, 일관되게 유지해야 할 것은 해요체나 하십시오체 자체가 아니라 사용자에게 필요한 정보를 쉽고 정확하게 전달하고, 그 사람의 선택과 권리를 존중하려는 태도가 되어야 하는 것이 아닐까, 그런 태도를 유지하면서 상황에 따라 문체를 달리한다면 저는 그 편이 오히려 자연스럽게 느껴지지 않을까 싶습니다. 특히 한국어의 높임법은 단순히 상대방에게 예의를 갖추기 위한 문법이 아니라, 상황과 관계에 따라 말하는 방식을 조절할 수 있게 해 주는 언어적 자원이기도 합니다. 저는 많은 사람들이 UX 라이팅을 설계할 때 이 점을 좀 더 중요하게 다뤘으면 좋겠습니다. 사용자가 어떤 행동을 하고 있는지, 서비스가 어떤 입장에서 말을 건네는지, 그 문장이 어느 정도의 격식을 필요로 하는지까지 고려한다면 문체를 선택하는 기준도 달라질 것입니다. 친근한 표현이 사용자에게 좋은 경험을 준다는 이유만으로 해요체를 마치 디폴트처럼 사용하는 것은 한국어가 가진 표현의 차이를 지나치게 단순하게 만드는 일처럼 느껴집니다. 쉽고 간결하게 쓰는 것과 적절한 높임법을 선택하는 것은 서로 배타적인 목표가 아니기에, 어느 하나를 위해 다른 하나를 포기할 이유도 없습니다. 모든 사용자가 서비스와 친근한 관계를 맺고 싶어 하는 것은 아닐 것입니다. 어떤 사람은 금융 서비스가 친구처럼 말하는 것을 편안하게 느끼겠지만, 다른 사람은 자신의 재산을 관리하는 기관이 조금 더 격식 있게 말하기를 원할 수 있습니다. 처음의 송금 화면으로 돌아가 보면, 저는 여전히 “송금했어요!”보다 “송금이 완료되었습니다”라는 문장이 더 자연스럽게 느껴집니다. 한국어에는 상대방과의 관계와 상황에 따라 말투를 달리할 수 있는 여러 표현이 있고, 그중 무엇을 선택하느냐에 따라 같은 문장도 다른 인상과 경험을 남깁니다. UX 라이팅이 사용자의 경험을 세심하게 설계하는 일이라면, 그 경험을 전달하는 언어 자체의 특성도 충분히 이해할 필요가 있지 않을까 생각합니다. 적어도 높임법이 발달한 한국어로 서비스를 만든다면, 그 언어가 가진 다양한 표현과 미묘한 차이를 지금보다 조금 더 깊이 들여다봤으면 합니다. 참고 자료 Microsoft, Microsoft Writing Style Guide, 「Top 10 tips for Microsoft style and voice」. https://learn.microsoft.com/en-us/style-guide/top-10-tips-style-voice Mailchimp, Content Style Guide, 「Voice and Tone」. https://styleguide.mailchimp.com/voice-and-tone/
브라우저 탭 바깥에서 만난 리액트 저는 스타트업에서 정규직 개발자로 일하며 주로 리액트로 웹 서비스를 만들었고, 지금은 이런 저런 제조 대기업을 옮겨 다니며 상주 프리랜서로 일하고 있습니다. 직함과 계약 형태가 바뀌었을 뿐 쓰는 언어는 비슷할 것이라고 생각했는데, 생판 처음 겪어보는 제조업의 다양한 현장을 경험하다 보니 자바스크립트(타입스크립트)가 닿아 있는 영역이 제가 알던 것보다 훨씬 넓다는 것을 깨닫고 있는 중입니다. 설비 상태를 보여주는 모니터링 화면, 현장 작업자가 장갑을 낀 채 누르는 터치 패널, 장치가 보내는 신호를 모아 화면 쪽으로 넘겨주는 게이트웨이 서비스까지, 우리가 일반적으로 생각하는 웹 사이트라고 부르기는 어려운 것들이지만 많은 화면들이 웹 기반에서 웹 화면을 구성하는 언어와 도구 위에서 돌아가고 있었습니다. 이전 글에서 저는 “모든 웹 사이트에 정말 리액트가 필요한가”라고 물었는데, 어떻게 생각해보면, 웹 사이트를 웹이라는 환경 위의 html 문서들만으로 한정할 수는 없겠다는 생각이 듭니다. 프런트엔드라는 말이 가리키던 범위 프런트엔드, (많은 사람들이 '프론트엔드'라고 쓰지만 국립국어원이 공표하는 외래어 표기법에 의하면 정확히는 '프런트엔드'라고 쓰는 게 맞습니다...아무튼) 이 단어는 원래 사용자와 맞닿은 쪽, 클라이언트 영역, 말 그대로 "front-end", 앞단의 끝, 을 의미할 뿐인데, 웹이 압도적인 플랫폼이 되면서 그 "앞단의 끝"은 곧 "브라우저 뷰포트"로 굳어버린 것 같습니다. 많은 사람들이 프런트엔드(FE) 개발자라고 하면 웹 화면을 만드는 개발자를 떠올리고, 실제로 많은 FE 개발자들이 웹 화면을 개발하고 있으니 그런 인식은 언뜻 자연스럽게 여겨지기도 합니다. 지금 제가 일하는 곳도 마찬가지여서, 이곳의 FE팀은 오로지 웹 화면을 담당하는 개발자들로 이루어져 있고, 저는 이곳에서 모바일과 그 외의 각종 장비 쪽 화면을 담당하지만 저는 FE팀이 아니라 FE팀과는 분리된 Native팀에 소속되어 있습니다. 같은 화면을 만드는 일인데도 이름과 소속은 갈려 있습니다. 사람이 소프트웨어와 만나는 면은 식당 테이블 위의 주문용 태블릿이기도 하고, 아파트 현관과 거실을 잇는 월패드이기도 하며, 주차장 출구의 무인정산기, 병원 접수 키오스크, 마트의 셀프 계산대, 공장 라인 옆 설비 제어 패널이기도 한데, 이 화면들의 공통점은 사용자가 "주소창에 무언가를 입력하지 않는다"는 것입니다. 전원을 켜면 정해진 화면이 뜨고, 사용자는 그 화면 안에서 몇 가지 조작을 하고, 기계는 그 조작에 반응하며 자기 상태를 다시 화면에 돌려줍니다. 사용자는 이것을 “웹사이트에 접속했다”고 느끼지 않고 “기계를 조작했다”고 느낍니다. 이런 화면을 만드는 개발자도 결코 적지 않을 텐데, 단순히 제가 느끼기엔 프런트엔드라는 이름은 이상하게도 유독 웹 브라우저 뷰포트 안의 화면 개발에 초점이 맞춰져 온 것 같다는 생각이 듭니다. 문서가 아니라 기계를 조작하는 화면 웹 문서와 기기 화면은 해결하려는 문제가 근본적으로 다릅니다. 웹 문서의 핵심 단위는 URL이 가리키는 문서이고, 그래서 검색엔진 노출, 링크 공유, 첫 바이트까지의 시간, 초기 HTML에 콘텐츠가 들어 있는가 같은 질문이 중요해집니다. 이전 글에서 길게 다룬 서버 렌더링, 스트리밍, 서버 컴포넌트, 캐시 계층은 모두 이 문서 모델 위에서 생긴 문제와 그 해법입니다. 반면, 예컨대, 주문용 태블릿을 예시로 들어보겠습니다, 여기에서 중요한 것은 /menu/coffee/americano라는 주소가 아니라 지금 사용자가 메뉴를 고르는 중인지, 옵션을 정하는 중인지, 결제 단말과 통신하는 중인지, 결제가 끝나 주문 번호를 보여주는 중인지입니다. 검색엔진 크롤러는 이 화면을 영영 방문하지 않고, 링크를 공유할 사람도 없으며, 검색 유입을 위해 초기 HTML을 어떻게 채울지 고민할 이유도 없습니다. 물론 전원을 켠 뒤 화면이 빨리 준비되어야 한다는 별도의 시작 속도 문제는 남습니다. 대신 터치하는 순간 화면이 바뀌어야 하고, 서버나 장치에서 오는 상태 변화가 지연 없이 화면에 반영되어야 하며, 몇 주씩 재시작 없이 켜져 있어도 화면이 망가지지 않아야 합니다. 즉 같은 리액트라도 웹 문서에서는 “서버가 이미 아는 내용을 어떻게 빨리 보여줄 것인가”가 숙제였다면, 기기에서는 “계속 변하는 상태를 어떻게 일관되게 화면으로 투영할 것인가”가 숙제가 됩니다. 그런데 이 두 번째 질문은, 재미있게도, 리액트가 처음 만들어질 때 세상에 던졌던 질문이기도 합니다. 이 부분에 대해 부연을 해보자면, 리액트가 어떤 문제에서 출발했는지를 이야기해야겠습니다. 리액트는 2013년 5월 JSConf US에서 처음 공개되었는데, 그 전에 페이스북 안에서 먼저 쓰이고 있었고 인스타그램이 그 뒤를 이었습니다. 찾아보니 리액트 초기 멤버였던 피트 헌트(Pete Hunt)는 ACM Queue와의 인터뷰에서 페이스북의 웹 앱 가운데 가장 복잡한 것으로 광고를 만들고 광고 계정을 관리하는 도구를 꼽았고, 그 화면을 Bolt라는 DOM 변화 감시 시스템으로 만들던 시절에는 아무도 원인을 풀지 못하는 버그가 쌓였다고 회고합니다. 읽는 문서라기보다 값을 입력하고 상태를 확인하며 계속 조작하는 도구에 가까운 화면이었을 것으로 짐작합니다. 이 문제에 대한 리액트의 답은, 헌트가 2013년 인터뷰에서 설명한 표현을 빌리면, 데이터가 바뀔 때마다 서버 렌더링 앱에서 새로고침 버튼을 누르는 것처럼 화면 전체를 다시 그린다고 생각하게 만드는 것이었습니다. 어디를 어떻게 고칠지 개발자가 일일이 지시하는 대신 지금 상태에서 화면이 어떤 모습이어야 하는지만 선언하고, 실제로 무엇이 달라졌는지는 가상 DOM이라는 내부 표현을 비교해서 리액트가 찾아냅니다. 정리하면 리액트의 첫 질문은 “이 문서를 얼마나 빨리 보여줄 것인가”가 아니라 “계속 바뀌는 상태와 화면이 어긋나지 않게 하려면 어떻게 해야 하는가”였고, 이 질문은 제가 생각하기에, 서버 렌더링과 검색 노출이 중요한 웹 문서보다 인터폰이나 주문용 태블릿 같은 기기 화면의 질문에 훨씬 가깝습니다. 그래서 기기 화면에서 리액트가 자연스럽게 맞아떨어지는 것은 우연이 아니라 설계의 출발점에 가까운 일이라고 저는 생각합니다. 이 차이는 화면 바깥의 네트워크 지식에서 더 선명해집니다. 일반적인 웹 프런트엔드에서 개발자가 직접 마주하는 네트워크 모델은 대체로 HTTP 기반의 요청과 응답을 중심으로 합니다. DNS 조회, TCP나 QUIC 연결, TLS 같은 하위 계층의 상당 부분은 브라우저와 인프라가 추상화해주고, 애플리케이션 개발자는 주로 상태 코드, 헤더, 캐시, 인증, CORS 같은 상위 계층을 다룹니다. 물론 웹소켓이나 SSE(서버가 브라우저로 이벤트를 계속 흘려보내는 HTTP 기반 방식)처럼 연결을 길게 유지하는 웹 서비스도 있고, 장애와 재연결이 설계 대상이라는 점은 웹도 마찬가지입니다. 다만 일반적인 웹 애플리케이션에서는 장비 통신에 비해 전송 계층 자체를 직접 의식할 일이 상대적으로 적습니다. 기기와 장비가 서로 통신하는 네트워크에서는 이 전제가 거의 모두 뒤집힙니다. 통신의 상대가 서버 한 대가 아니라 센서, 게이트웨이, 그 외의 기타 다른 기기이고, 프로토콜도 HTTP 하나가 아니라 시리얼 회선 위의 Modbus RTU나 이더넷 위의 Modbus TCP, 산업 표준인 OPC UA, 발행/구독 방식의 MQTT처럼 목적에 따라 갈립니다. 특히 Modbus 같은 프로토콜에서는 레지스터 주소와 바이트 순서를 직접 다뤄야 하고, 32비트 값을 두 레지스터에 나누어 담을 때 제조사마다 순서가 다르기도 해서 데이터시트를 읽고 값의 의미를 직접 해석해야 합니다. MQTT처럼 전달되는 내용의 형식을 애플리케이션이 정하는 프로토콜도 있고, OPC UA처럼 데이터에 의미 정보까지 붙여 모델링하는 훨씬 높은 수준의 표준도 있습니다. 통신 모델도 프로토콜마다 크게 달라서, Modbus처럼 요청과 응답이 명확한 프로토콜이 있는가 하면 MQTT처럼 브로커와 연결을 유지한 채 발행/구독으로 비동기 메시지를 주고받는 프로토콜도 있습니다. TCP 기반 프로토콜에서는 연결을 장시간 유지하는 경우가 있고, 시리얼 통신에는 애초에 TCP식 연결 개념이 적용되지 않습니다. 어느 경우든 케이블이 빠지거나 장비가 재부팅되어 통신이 끊기는 것은 예외가 아니라 일상입니다. 그래서 하트비트와 타임아웃, 재연결 정책, MQTT의 QoS 단계, Last Will 메시지처럼 “상대가 사라졌다는 사실을 어떻게 알아챌 것인가”를 다루는 지식이 중요해지고, 망 구성도 공인 인터넷이 아니라 폐쇄망 안에서 사설 인증서와 고정 IP, 시간 동기화까지 신경 써야 하는 경우가 흔합니다. 브라우저가 임의의 TCP 소켓이나 시리얼 포트를 자유롭게 열 수 없다는 보안 모델상의 제약 때문에, 웹 화면이 장비와 직접 대화하기보다 게이트웨이가 장비의 언어를 HTTP나 웹소켓으로 번역해주는 구조가 일반적이고, 이 번역의 품질이 화면에 보이는 상태의 신뢰도를 좌우합니다. 여기 나온 이름들이 낯선 분들을 위해 짧게 풀어두겠습니다. Modbus는 1979년에 만들어진 오래된 프로토콜인데, 한쪽이 “몇 번 장비의 몇 번 레지스터 값을 알려달라”고 물으면 상대가 숫자로 답하는 아주 단순한 질의응답 구조입니다. 레지스터는 번호가 붙은 작은 숫자 저장칸이라고 생각하시면 되고, 그 숫자가 온도인지 압력인지는 통신 규약이 아니라 장비 제조사가 제공하는 데이터시트의 표에 적혀 있습니다. 같은 질의응답을 RS-485 같은 시리얼 회선으로 주고받으면 Modbus RTU, 이더넷의 TCP 위에서 주고받으면 Modbus TCP입니다. OPC UA는 제조사가 서로 다른 장비와 소프트웨어가 데이터를 주고받을 수 있도록 만든 국제 산업 표준(IEC 62541)으로, 번호 붙은 칸이 아니라 이름과 자료형 같은 의미 정보가 붙은 객체 구조로 데이터를 표현하고 인증과 암호화도 규격에 포함하고 있습니다. MQTT는 발행/구독 방식의 메시징 프로토콜로, 센서가 브로커라는 중개 서버의 어떤 주제(topic)에 값을 올리면 그 주제를 구독하던 화면이나 서비스가 전달받으며, 양쪽이 서로의 주소를 알 필요가 없고 헤더가 가벼워서 불안정한 망에서도 널리 쓰입니다. 나머지 용어도 마찬가지입니다. 하트비트(heartbeat)는 “나 아직 살아 있다”는 신호를 일정 간격으로 주고받다가 신호가 끊기면 상대가 사라졌다고 판단하는 방식이고, MQTT의 QoS는 메시지가 최대 한 번(0), 최소 한 번(1), 정확히 한 번(2) 전달되도록 보장하는 정도를 고르는 단계입니다. Last Will 메시지는 장비가 연결을 맺을 때 브로커에 미리 맡겨두는 메시지로, 장비가 비정상적으로 끊기면 브로커가 대신 발행해서 “이 장비가 사라졌다”는 사실을 화면에 알려줍니다. VLAN은 하나의 물리적 네트워크를 여러 개의 논리적 2계층(데이터 링크 계층) 네트워크로 나누는 기술로, 서로 다른 VLAN 사이의 통신에는 라우팅과 별도의 정책이 필요하고, NTP는 장비들의 시계를 서로 맞추는 프로토콜입니다. 시계가 어긋나면 어느 장비의 기록이 먼저인지조차 알 수 없어서, 현장에서는 생각보다 중요한 설정입니다. 웹에서는 응답이 늦으면 로딩 표시와 재시도 버튼으로 대부분 해결되지만, 기기에서는 응답이 오지 않는다는 사실 자체가 하나의 상태이고, 그 상태를 정의하지 않으면 사용자는 멈춘 화면 앞에서 기계가 고장 난 것인지 통신이 끊긴 것인지 알 수 없습니다. 상태로부터 화면을 계산한다는 리액트의 모델이 기기 화면에서 더 절실한 이유가 여기에 있습니다. 상태 기계로서의 화면 리액트가 2013년 JSConf US에서 처음 공개되었을 때의 핵심 주장은, 화면을 직접 조작하지 말고 상태로부터 화면을 계산하라는 것이었습니다. 이 발상은 UI의 상태를 명시적으로 모델링하려는 더 오래된 여러 흐름과도 닿아 있습니다. 예를 들어 데이비드 하렐이 1987년 「Statecharts: A Visual Formalism for Complex Systems」에서 정리한 스테이트차트 모델이 그렇습니다. 웹 쪽에서는 2014년 페이스북이 공개한 Flux가 단방향 데이터 흐름을 대중화했고, 그 영향을 받아 등장한 Redux는 여기에 엘름(Elm) 아키텍처의 아이디어까지 받아들였습니다. 이 모델이 현장 화면에서 얼마나 자연스러운지는 인터폰 같은 장치를 떠올릴 때 가장 선명하게 느껴졌습니다. 대기, 호출 수신, 통화 중, 문 열림 처리 중, 네트워크 끊김처럼 화면이 가질 수 있는 상태가 유한하고, 상태마다 허용되는 조작과 보여줄 것이 다르기 때문입니다. 타입스크립트의 판별 유니언(discriminated union)을 쓰면 이 구조가 거의 그대로 코드가 됩니다. type IntercomState = | { kind: "idle" } | { kind: "ringing"; visitorId: string } | { kind: "talking"; visitorId: string; startedAt: number } | { kind: "doorOpening"; visitorId: string } | { kind: "offline"; retryAt: number }; function Screen({ state }: { state: IntercomState }) { switch (state.kind) { case "idle": return <StandbyView />; case "ringing": return <IncomingCall visitorId={state.visitorId} />; case "talking": return <VideoCall visitorId={state.visitorId} since={state.startedAt} />; case "doorOpening": return <DoorProgress visitorId={state.visitorId} />; case "offline": return <OfflineNotice retryAt={state.retryAt} />; default: { const _exhaustive: never = state; return _exhaustive; } } } 여기서 never 검사가 중요합니다. 요구사항이 바뀌어 상태가 하나 추가되면 그 상태를 처리하지 않은 모든 지점이 컴파일 단계에서 드러납니다. 현장에 설치된 기기를 쉽게 고칠 수 없다는 점을 생각하면 “빠뜨린 상태”를 배포 전에 잡아주는 이 성질은 편의가 아니라 안전장치에 가깝습니다. 여기에 useReducer로 상태 전이를 한곳에 모으거나 XState 같은 상태 기계 라이브러리로 허용 가능한 전이(transition)를 명시하면 존재해서는 안 되는 상태 전이를 훨씬 체계적으로 차단할 수 있고, 충분한 상태 전이와 이벤트 로그를 남겨두면 현장에서 어떤 순서로 이상 동작이 발생했는지를 추적하고 재현하는 데 큰 도움이 됩니다. 컴포넌트 구조도 마찬가지입니다. 카메라 영상 영역, 통화 상태 표시, 숫자 키패드, 문 열림 버튼, 볼륨 조절은 실제 기기에서도 독립적인 조작 단위여서 컴포넌트 트리가 제품의 구조를 거의 그대로 반영하게 됩니다. 블로그의 한 문단까지 컴포넌트로 만드는 것이 과하다고 느꼈던 저에게, 이 일치는 “추상화가 비용이 아니라 설계 도구가 되는 자리”를 보여주었습니다. 렌더러라는 이음매 리액트가 브라우저 바깥으로 나갈 수 있는 이유는 변경 사항을 계산하는 부분과 그 결과를 실제 환경에 반영하는 부분을 분리해 둔 구조에 있습니다. 리액트는 무엇이 바뀌었는지를 계산하는 reconciler와, 그 결과를 실제 환경에 반영하는 렌더러를 구분하며, 이 구분은 파이버 이전인 2015년 React Native가 처음 소개될 때도 이미 존재했습니다. 2017년 리액트 16에서는 기존 reconciler를 파이버(Fiber) 아키텍처로 전면 재작성했고, 이 구조를 이용해 별도의 렌더링 환경을 구현할 수 있게 해주는 react-reconciler 패키지도 존재합니다(다만 이 패키지는 공식 저장소에서 실험적인 커스텀 렌더러용 패키지로 안내되고 있습니다). 렌더러를 만드는 사람은 호스트 환경에서 노드를 생성하고 자식을 붙이고 갱신하는 방법 등을 정의하는 호스트 설정(host config)을 구현해야 하고, 상태 관리와 훅, 컴포넌트 합성 같은 나머지는 리액트가 그대로 제공합니다. 이 이음매 덕분에 react-dom은 브라우저 DOM을, React Native는 iOS와 안드로이드의 네이티브 뷰를, react-three-fiber는 three.js 장면을, Ink는 터미널의 텍스트 UI를 대상으로 삼습니다. 여기에 리액트 컴포넌트로 영상의 프레임을 구성하는 Remotion처럼, 컴포넌트 모델을 전혀 다른 출력물에 활용하는 도구도 있습니다. 레이아웃에는 React Native와 Ink가 모두 플렉스박스 레이아웃 엔진 요가(Yoga)를 활용하는 것으로 알고 있는데, 덕분에 웹에서 익힌 플렉스박스식 배치 감각을 모바일 앱이나 터미널 화면에서도 상당 부분 그대로 쓸 수 있습니다. React Native가 2015년 처음 소개될 때 내건 표어가 “write once, run anywhere”가 아니라 “learn once, write anywhere”였다는 점도 의미심장합니다. 같은 코드를 모든 곳에서 돌리겠다는 약속이 아니라, 같은 사고방식을 모든 곳에서 쓰겠다는 약속이었습니다. 이후 React Native는 JSI를 기반으로 Fabric과 TurboModules 등을 도입한 New Architecture를 0.76부터 기본으로 활성화했고(모바일 환경에 최적화된 자바스크립트 엔진 Hermes는 그보다 앞선 0.70부터 기본 엔진이었습니다), 마이크로소프트가 윈도우와 맥OS용 React Native를 따로 유지한다는 사실에서도 이 접근이 모바일에만 머물지 않음을 짐작할 수 있습니다. 결국 리액트에서 본체는 DOM이 아니라 “상태 변화를 최소한의 변경 목록으로 환산하는 계산”이었고, DOM은 그 계산 결과를 받아주는 여러 호스트 가운데 가장 유명한 하나였을 뿐입니다. 크롬이 들어 있는 기계들 현장에서 가장 자주 마주치는 구성은 사실 React Native가 아니라 “크로미움이 들어 있는 기계”입니다. 키오스크, 사이니지, 설비 옆 패널 PC는 상당수가 전체 화면 크롬이나 일렉트론, 혹은 임베디드 웹뷰 위에서 동작하는 웹 애플리케이션입니다. (지금 제가 상주 프리랜서로 근무하고 있는 곳에서도 저는 일렉트론을 다루고 있습니다.) 일렉트론(Electron)은 2013년 GitHub의 Atom 에디터를 위해 Atom Shell이라는 이름으로 시작되어 2015년에 개명되었고, 크로미움과 Node.js를 하나로 묶어 데스크톱 앱을 웹 기술로 만들 수 있게 해준 도구입니다. 슬랙과 디스코드, VS Code가 그 위에서 돌아간다는 사실은 이미 유명하지만, 같은 구조가 화면 하나짜리 전용 기기에도 그대로 들어간다는 점은 상대적으로 많은 이들이 모르는 사실입니다. TV 쪽도 비슷합니다. 찾아보니 LG전자는 webOS TV용으로 리액트 기반의 Enact라는 프레임워크를 오픈소스로 공개했고, 넷플릭스는 TV 기기용 UI에 리액트를 쓰기 위해 Gibbon이라는 별도 렌더링 계층을 썼다고 기술 블로그에서 밝힌 것으로 기억합니다. 웹 기술이 이렇게 기기로 번지는 데에는 이유가 분명합니다. 웹은 가장 널리 퍼진 UI 런타임이고, 개발자 풀이 넓으며, 디자이너와 개발자가 같은 언어(HTML과 CSS)로 소통할 수 있고, 화면 갱신을 네트워크로 배포하는 일이 네이티브 펌웨어를 다시 굽는 일보다 훨씬 가볍기 때문입니다. 2007년에 제프 앳우드(Jeff Atwood)가 남긴 “자바스크립트로 작성할 수 있는 모든 애플리케이션은 결국 자바스크립트로 작성될 것이다”라는 말은 농담 반 예언 반이었는데, 제조 현장에서 일하다 보면 이 문장이 꽤 진지하게 읽힙니다. 화면 뒤에서 일하는 타입스크립트 화면만 자바스크립트인 것은 아닙니다. 설비와 화면 사이에는 데이터를 모으고 가공하고 전달하는 층이 있는데, 이 층에서도 Node.js와 타입스크립트가 의외로 깊숙이 들어 있습니다. IBM에서 시작해 지금은 OpenJS 재단에서 관리되는 Node-RED는 자바스크립트로 만든 플로우 기반 도구로 IoT와 산업 현장의 데이터 흐름을 시각적으로 연결하는 데 널리 쓰이고, 경량 메시징 프로토콜인 MQTT(1999년 IBM에서 탄생해 2014년 OASIS 표준이 된)에는 브라우저와 노드 양쪽에서 쓰는 mqtt.js 같은 클라이언트가 있으며, 산업 통신 표준인 OPC UA(IEC 62541)에도 node-opcua 같은 구현이 존재합니다. 재미있게도 Node-RED 자체도 처음에는 MQTT 토픽 사이의 데이터 연결을 시각화하려는 작은 프로젝트에서 출발했다고 합니다. 2011년 RFC 6455로 표준화된 웹소켓은 이런 실시간 데이터를 브라우저나 웹뷰 화면까지 밀어 올리는 대표적인 통로입니다. 이 구조가 주는 이득은 언어 하나로 통일된다는 점보다 데이터 규약을 공유할 수 있다는 점에 있다고 생각합니다. 장치 메시지의 데이터 규약을 Zod 같은 런타임 스키마와 타입스크립트 타입으로 함께 정의해 공유 패키지로 관리하면 게이트웨이와 화면이 같은 정의를 참조하게 되고, 현장에서 필드 하나의 의미가 바뀌었을 때 그 변경이 퍼져나가는 지점을 컴파일러와 스키마 검증이 함께 알려줍니다. 타입스크립트의 타입은 실행 시점에 데이터를 검사해주지 않기 때문에, 장비에서 올라오는 값을 믿고 쓰려면 런타임 검증이 따로 필요합니다. 서로 다른 언어와 팀이 경계를 맞대고 있던 구조에서는 문서와 구두 합의가 하던 일을 스키마와 타입이 맡게 됩니다. 모니터링 화면의 대표 격인 그라파나(Grafana)가 프런트엔드를 리액트와 타입스크립트로 만든 대표적인 오픈소스 사례라는 점도, 이 조합이 산업 데이터를 다루는 화면과 잘 맞는다는 방증이 아닐까 생각합니다. 현장은 브라우저 탭보다 가혹하다 물론 웹 기술이 기기로 번졌다고 해서 웹 개발의 감각이 그대로 통하는 것은 아닙니다. 일반적인 소비자용 웹 페이지는 사용자가 잠깐 쓰다 닫는 경우가 많지만 기기 화면은 몇 주, 몇 달씩 종료되지 않은 채 동작하는 경우가 있어서, 작은 메모리 누수나 이벤트 리스너 중복 등록이 며칠 뒤에야 화면 멈춤으로 나타납니다. (……새벽 세 시의 메모리 그래프는 정직합니다.) 네트워크는 끊어지는 것이 정상 상태이므로 오프라인에서 어떤 화면을 보여주고 복구 후에 어떻게 재동기화할지가 설계에 들어가야 하고, 마우스 호버도 키보드 단축키도 없는 터치 환경에서는 버튼 크기와 간격, 오조작 방지, 유휴 시 초기 화면 복귀 같은 인터랙션 규칙을 처음부터 다시 생각해야 합니다. 저사양 기기에 크로미움 하나를 통째로 올리는 것은 메모리와 부팅 시간 면에서 만만한 선택이 아니어서, 자원이 빠듯한 기기에서는 웹 기술이 아닌 네이티브 방식이 여전히 합리적일 수 있고, 플러터 같은 다른 선언형 UI 도구도 기기 쪽으로 영역을 넓히고 있습니다. 무엇보다 중요한 선은 제어 루프입니다. 설비를 실제로 움직이고 멈추는 안전 관련 실시간 제어는 설비 전용 제어기의 몫이고, 결정론적 응답이 필요한 그런 제어를 가비지 컬렉션이 있는 일반적인 자바스크립트 런타임에 맡길 일은 아닙니다. 화면은 상태를 보여주고 명령을 전달하는 층이지 안전을 책임지는 층이 아니라는 구분만큼은 늘 의식하려고 합니다. 그러니 제가 말하는 것은 "현대 화면 개발은 리액트가 최고"라거나 "이제는 리액트의 시대도 끝나야 한다" 따위의 것들이 아니라, 그저 리액트가 어울리는 문제의 범위가 제가 생각했던 것보다 훨씬 넓었다는 것 정도입니다. 읽히는 문서인가, 조작되는 장치인가 이전까지 제가 피로감을 느꼈던 것은 리액트 자체가 아니라, 단순한 웹 문서를 보여주는 일에까지 애플리케이션 아키텍처를 들이대면서 그것을 정답처럼 여기는 분위기였던 것 같습니다. 문제의 크기와 도구의 크기가 어긋나는 데에서 오는 피로감이었다고 말할 수 있지 않을까요. 병원이나 자영업자의 소개 페이지처럼 단순한 화면에 하이드레이션 전략까지는 필요하지 않다는 생각은 지금도 여전합니다. 그러나 하루 종일 상태가 변하고, 사람이 끊임없이 만지고, 하드웨어와 서버가 번갈아 상태를 밀어 넣는 화면이라면 같은 도구가 전혀 다른 무게를 갖습니다. 그래서 요즘 저는 기술을 고르기 전에 질문 하나를 먼저 던집니다. 이 화면은 읽히는 문서에 가까운가, 조작되는 장치에 가까운가. 쇼핑몰처럼 둘이 섞인 경우가 많아 정답이 하나로 떨어지지는 않지만, 어느 쪽 성격이 강한지를 따져보는 것만으로도 SSR, 서버 컴포넌트, 상태 기계, 오프라인 전략 같은 선택지는 훨씬 빨리 정리될 수 있을 것입니다.
여러분이 보고 계시는 이 웹 사이트는 워드프레스와 php로 구축되었습니다. 태어나 처음으로 워드프레스와 php를 만져보면서 생각했던 것들을 정리해봅니다. 화면을 위한 라이브러리였던 리액트 저는 회사에서 리액트로 서비스를 개발하던 프런트엔드 개발자였습니다. 물론 지금은 AI의 도움으로 프런트엔드에 국한되지 않고 회사에서 필요한 게 있으면 닥치는 대로 개발하는 잡부 느낌이 강하긴 하지만, 어쨌든 저의 명함에는 꽤 오랫동안 "Frontend Engineer"라는 직함이 박혀 있었습니다. 개발자라는 직업을 얻게 된 이래로 저는 리액트를 사용하며 컴포넌트 단위로 UI를 설계하는 법을 배웠고, 선언형 렌더링이 주는 생산성을 체감할 수 있었습니다. 상태가 바뀌면 화면이 따라 바뀐다는 단순한 모델은 확실히 jQuery로 DOM을 직접 조작하던 시절과 비교하면 분명한 진보였을 것입니다...... 하지만 현업에서 리액트만을 전문으로 수 년을 다뤘던 리액트 개발자였던 제가, 어쩌다 어느새부턴가 "이제는 리액트 좀 그만 쓰고 싶다", 는 생각을 하고 있었습니다. 이번에 새 블로그를 구축하기 위해 생전 처음 다뤄보는 워드프레스 php를 사용해보면서, 저는 이 글에서 웹 개발에서 프런트엔드의 기술 스택으로 리액트를 선택하는 것이 마치 업계 전체의 기본값이 되어버린 것 같은 오늘날의 모습에 대한 지극히 개인적인 저의 생각들을 나눠보려고 합니다. 리액트는 처음에 스스로를 "사용자 인터페이스를 만들기 위한 자바스크립트 라이브러리"라고 소개했습니다. 개발자들 사이에서 리액트가 프레임워크냐 라이브러리냐에 관하여 논쟁도 꽤 있었던 것으로 기억합니다만 분명 리액트의 처음 시작은 "라이브러리"였었고, 이에 따른 리액트의 역할도 명확했습니다. "브라우저에서 가상 DOM을 기반으로 화면을 그리고, 상태 변화에 따라 필요한 부분만 갱신하는 것". 이 때 당시만 해도 라우팅 데이터 요청이나 서버 렌더링 따위는 리액트의 관심사가 아니었습니다. 성능과 SEO가 불러온 서버로의 회귀 문제는 클라이언트 사이드 렌더링(CSR)의 구조적 한계에서 시작됩니다. CSR 방식의 싱글 페이지 애플리케이션은 서버에서 사실상 비어 있는 HTML 문서와 자바스크립트 번들을 내려보냅니다. 실제 콘텐츠는 브라우저가 번들을 내려받고, 파싱하고, 실행한 뒤에야 만들어집니다. 실제 로딩 과정에서 CSR이 문제가 되는 지점은 조금 더 구체적입니다. 브라우저는 HTML을 파싱하면서 스크립트 태그를 발견하면 자바스크립트를 병렬로 내려받기 시작할 수 있고, 브라우저의 preload scanner는 메인 파서보다 앞서 리소스를 미리 찾아 요청하기도 합니다. 하지만 문제는 페이지의 핵심 콘텐츠나 그 콘텐츠를 위한 API 요청이 애플리케이션 자바스크립트의 실행 이후에야 발견되는 경우에 발생합니다. 페이지의 핵심 콘텐츠를 로드하는 것이나 그 콘텐츠를 위한 API 요청이 애플리케이션이 자바스크립트가 실행된 이후에야 시작될 수 있다는 것, 자바스크립트 실행이 완료되어야 컴포넌트 트리가 만들어지고, 그제야 API 요청이 시작되고, 응답이 돌아온 뒤에야 콘텐츠를 화면에 그릴 수 있다는 사실은 자바스크립트 실행 → API 요청 → 데이터 수신 → 렌더링까지의 각 과정이 순차적으로 그 바로 전 단계에 의존하고 있다는 것을 의미합니다. 여기서 LCP(Largest Contentful Paint)가 늦어지는 이유는 단순히 "자바스크립트가 느리다"는 문제만으로는 설명할 수 없습니다. 여기서의 진짜 문제는 핵심 콘텐츠가 자바스크립트 실행이나 클라이언트 API 요청에 의존하는 구조, 그 자체입니다. 자바스크립트는 네트워크로 내려받는 것만으로 끝나지 않습니다. 브라우저는 그것을 파싱하고, 컴파일하고, 실행해야 하며, CPU가 느린 모바일 기기에서는 이 비용이 더욱 커질 수밖에 없고, 가끔 정말 똥컴(...)이나 오래된 모바일 기기를 사용하다 보면 자바스크립트가 메인 스레드를 점유하는 동안에는 화면이 보이는데도 클릭과 입력이 반응하지 않는 상황이 만들어지기도 합니다. 검색 노출에 관한 문제는 조금 더 미묘합니다. 구글봇은 크로미움 기반 렌더러로 자바스크립트를 실행할 수 있습니다. 다만 CSR 페이지에서 핵심 콘텐츠가 초기 HTML에 존재하지 않는다면, Google은 렌더링 단계를 거쳐야 그 콘텐츠를 확인할 수 있습니다. 이 렌더링은 별도의 큐에서 처리되며, 페이지가 그 단계에 도달하기까지 시간이 걸릴 수 있습니다. 서버가 완성된 HTML을 응답한다면 크롤러는 HTTP 응답만으로 콘텐츠를 바로 발견하지만, CSR 구조에서는 자바스크립트를 발견하고, 렌더링하고, 그 결과에서 콘텐츠를 발견하는 단계가 더 얹어집니다. 게다가 모든 크롤러가 구글봇만큼 완전한 렌더러를 가지고 있는 것도 아니어서, 많은 링크 미리보기 크롤러는 자바스크립트 실행을 보장하지 않습니다. 자바스크립트 실행 이후 주입되는 오픈 그래프 메타 태그나 타이틀이 제대로 인식되지 않는 경우가 생기는 이유입니다. 이제 콘텐츠와 검색 유입이 중요한 서비스에서는, 핵심 콘텐츠를 초기 HTML에 포함시키기 위한 SSR이나 SSG, 프리렌더링이 중요한 선택지가 되었습니다. 리액트 생태계는 이 요구에 응답하기 위해 그 컨트롤 범위를 UI 영역에서 점점 서버 영역으로 확장시키게 됩니다. 리액트 18, UI 라이브러리에서 웹 아키텍처로 리액트가 서버로 확장된 흐름을 이해하려면 최근 5~6년 사이의 리액트 버전 변화를 짚고 넘어가는 것이 도움이 될 것 같습니다. 리액트 17은 사실상 기능 추가가 없는 릴리즈였습니다. 리액트 팀이 공식적으로 밝혔듯이 17은 "아무 새 기능도 없는" 버전이었고, 핵심 목적은 리액트를 점진적으로 업그레이드할 수 있도록 기반을 정리하는 것이었습니다. 서로 다른 버전의 리액트 트리를 한 페이지에서 공존시키기 쉽게 만들기 위해 이벤트 위임 위치를 document에서 각 root container로 옮긴 것도 그 맥락이었습니다. 버전 17 자체는 조용했지만, 그 조용함은 버전 18에서의 엄청난 지각변동급 변화를 준비하고 있었습니다. 리액트 18에서 일어난 가장 근본적인 변화는 동시성 렌더링(Concurrent Rendering)의 도입입니다. 기존의 ReactDOM.render를 대체하는 새로운 루트 API createRoot는 리액트 18의 새로운 렌더러와 기능을 사용할 수 있게 하는 기반이 되었습니다. 이전까지 리액트의 렌더링은 동기적이었습니다. 한 번 시작하면 중간에 멈출 수 없었고, 렌더링이 진행되는 동안 브라우저는 다른 작업을 할 수 없었습니다. createRoot 위에서 startTransition이나 Suspense 같은 concurrent feature를 사용하면, 리액트는 렌더링 작업의 우선순위를 나누고 낮은 우선순위 작업을 중단했다가 다시 이어갈 수 있습니다. useTransition과 startTransition이 그 진입점입니다. 급하지 않은 상태 업데이트를 startTransition으로 감싸면, 리액트는 그 렌더링을 후순위로 밀어두고 사용자 인터랙션에 먼저 반응합니다. 자동 배칭(Automatic Batching)도 리액트 18의 주요 변화 중 하나입니다. 리액트 17까지는 이벤트 핸들러 내부의 상태 업데이트만 묶어서 한 번에 처리했습니다. setTimeout, Promise, 네이티브 이벤트 핸들러 안에서의 상태 업데이트는 각각 별도의 렌더링을 유발했습니다. 리액트 18부터는 이런 비동기 상황에서의 업데이트도 자동으로 묶입니다. 사소해 보이지만, 불필요한 렌더링이 줄어드는 실질적인 성능 개선입니다. 그러나 뭐니뭐니해도 리액트 18에서 가장 큰 의미를 가지는 변화는 역시 서버 영역입니다. 리액트 18은 renderToPipeableStream과 renderToReadableStream이라는 새로운 서버 렌더링 API를 도입했습니다. 기존의 renderToString은 페이지 전체 HTML이 완성될 때까지 기다렸다가 한꺼번에 전송하는 방식이었지만, 이제 새 API는 Suspense 경계를 기준으로 준비된 부분부터 스트리밍으로 흘려보낼 수 있게 되었습니다. 서버가 느린 데이터를 기다리는 동안 이미 준비된 헤더와 본문 영역을 먼저 브라우저에 내려보내고, 댓글이나 추천 콘텐츠처럼 시간이 걸리는 부분은 준비되는 대로 이어 붙이는 방식입니다. 이렇게 되면 브라우저는 전체 HTML을 기다리지 않고서도 먼저 수신되는 순서대로 렌더링을 시작할 수 있습니다. 그리고 여기에 더해, 리액트 서버 컴포넌트(RSC)가 있습니다. RSC는 사실 리액트 18보다 먼저 등장했습니다. 리액트 팀은 2020년 말 처음 RSC 연구 결과를 공개했고, React 18이 출시된 2022년에도 이 기능은 여전히 실험 단계였습니다. React 18이 스트리밍 SSR과 Suspense 기반 서버 렌더링의 토대를 정식으로 제공했다면, RSC는 이후 Next.js App Router 같은 프레임워크를 통해 먼저 현실의 애플리케이션에 들어오기 시작했고, React 19에서야 리액트 차원의 안정 기능으로 자리 잡았습니다. 동시성 렌더링이 클라이언트에서의 렌더링 방식을 바꿨다면, RSC는 컴포넌트 자체의 실행 위치를 다시 정의했습니다. 이 두 변화가 합쳐지면서 리액트는 더 이상 브라우저에서 동작하는 "UI 라이브러리"라는 정체성만으로는 설명되지 않게 되었습니다. 리액트는 여전히 스스로를 UI 라이브러리라고 부릅니다. 다만 현재의 공식 문서는 동시에 'full-stack React architecture'라는 방향을 명시하고 있고, 새로운 애플리케이션을 시작할 때는 리액트만 직접 구성하기보다 프레임워크를 사용할 것을 권장합니다. jQuery식 직접 DOM 조작이 일반적이던 시대에 등장한 도구가 십여 년을 거치면서 서버 런타임, 캐시 전략, 번들 경계, 데이터 페칭 패턴까지 의견을 내는 존재가 된 것입니다. 라이브러리라는 이름은 그대로지만, 그 주변에 제안되는 아키텍처의 범위는 초기와 비교하기 어려울 정도로 넓어졌습니다. 그런데 여기서 한 가지 중요한 맥락이 있습니다. 리액트 서버 컴포넌트는 리액트 18보다 먼저 연구 단계에서 공개되었지만, 리액트 자체는 이를 당장 프로덕션에서 쓸 수 있는 완성된 형태로 제공하지 않았습니다. RSC는 서버와 클라이언트를 아우르는 컴포넌트 모델이기 때문에, 실제로 동작시키려면 서버 런타임, 라우팅, 번들링, 스트리밍 처리까지 통합하는 프레임워크가 필요합니다. 그 역할을 가장 먼저, 그리고 가장 적극적으로 맡은 것이 Next.js입니다. Next.js를 만드는 Vercel은 리액트 팀과 긴밀히 협력하며 App Router라는 형태로 RSC를 프로덕션에서 쓸 수 있도록 구현했고, 현재 리액트 공식 문서도 새 프로젝트를 시작할 때 리액트만으로 프로젝트를 구성하기보다는 프레임워크를 사용하도록 권장하며 그 목록 맨 앞에 Next.js를 올려두고 있습니다. 그 결과, 특히 RSC를 중심으로 한 현대 React의 풀스택 아키텍처를 이야기하자면 이제는 Next.js를 빼놓기 어려워졌습니다. 생각해도 너무 복잡하게 느껴지는 Next.js 여기서 이야기하는 "복잡함"이란 제가 주로 실무에서 경험했던 Next.js 13~15 App Router 시기의 모델을 기준으로 합니다. 이후 Next.js 15에서 fetch와 Route Handler의 기본 캐싱 정책이 바뀌었고, Next.js 16에서는 Cache Components와 use cache를 중심으로 캐시를 명시적으로 선택하는 새로운 모델이 도입되었습니다. 흥미롭게도 이러한 변화 자체가, 초기 App Router의 암묵적 캐싱 모델이 개발자들에게 얼마나 큰 혼란을 만들었는지를 보여주기도 합니다. Next.js를 실무에서 다루며 가장 크게 느낀 것은 단순히 '복잡하다'는 감각이 아니었습니다. 더 정확히 말하면, 제가 쓴 코드의 의미를 이해하는 것과 그 코드가 Next.js 시스템 안에서 어떻게 작동하는지를 이해하는 것이 전혀 다른 일이 된다는 데에 있었습니다. 개발자는 자신의 비즈니스 로직뿐 아니라 프레임워크의 렌더링 결정 규칙, 캐시 계층 구조, 컴포넌트의 실행 위치까지 머릿속에 들고 있어야 합니다. App Router는 이름과 달리 단순한 라우터가 아닙니다. 과거에 제가 주로 사용하던 React Router의 선언적 라우팅을 떠올려보면 이는 URL과 컴포넌트를 연결하는 것이 전부였습니다. 하지만 Next.js App Router에서 폴더 구조는 단순히 라우팅 URL만 정하지 않습니다. layout.tsx는 하위 페이지들이 공유하면서 클라이언트 내비게이션 중에도 유지되는 UI를 정의하고, loading.tsx는 해당 route segment에 Suspense 경계를 만들고, error.tsx는 오류 경계를 담당합니다. 여기에 template.tsx, not-found.tsx, route.ts, parallel route, intercepting route까지 더해집니다. 각각의 파일이 단순히 '이 URL에서 보여줄 화면'이 아니라 렌더링 생명주기의 서로 다른 단계를 담당하는 구조로써 결합되고 있습니다. 특히 개발자로서 주의 깊게 신경써야 하는 부분은 렌더링 전략이 결정되는 방식입니다. 겉으로 보기에는 그냥 데이터를 가져오는 서버 컴포넌트가 있다고 해봅시다. 그런데 어느 하위 컴포넌트에서 cookies()를 한 번 호출하면, 그 route 전체가 빌드 시점에 만들어지는 정적 페이지에서 요청마다 실행되는 동적 페이지로 전환됩니다. 코드의 지역적인(local) 의미는 단순합니다. "쿠키를 읽는다.", 하지만 Next.js 아키텍처 안에서의 의미는 다릅니다. 그 한 줄이 이 route의 렌더링 전략을 바꾸고, Full Route Cache의 적용 여부까지 결정합니다. 코드의 지역적인 의미와 시스템 전체에서의 의미가 달라지는 이런 비지역적 효과(non-local effect)가 많아질수록, 코드를 읽는 것만으로는 동작을 온전히 파악하기 어려워집니다. 이와 더불어 Next.js의 캐싱 전략은 개발자들로 하여금 많은 혼란을 야기하였습니다. App Router의 캐시 구조는 '캐시됨/캐시 안 됨'이라는 단순한 이분법으로 이해할 수 없습니다. Next.js 문서 기준으로는 네 가지 계층이 존재합니다. Request Memoization은 한 번의 서버 렌더링 중 동일한 fetch 호출이 여러 컴포넌트에서 반복될 때 중복 요청을 막는 React 레벨의 최적화로, 요청이 끝나면 사라집니다. 요청을 넘어 유지되는 것은 Data Cache, Full Route Cache, 그리고 브라우저의 Router Cache입니다. Data Cache는 서버 요청을 넘어 지속되는 캐시로, 동일한 데이터를 여러 사용자 요청에 걸쳐 재사용합니다. Full Route Cache는 렌더링 결과인 HTML과 RSC Payload 자체를 저장하며, Data Cache와는 별개의 계층이지만 서로 영향을 주는 구조입니다. 브라우저에도 Router Cache가 있어서 이전에 방문했거나 prefetch된 route의 RSC Payload를 메모리에 저장합니다. 그래서 DB를 수정했는데 화면이 그대로일 때, 개발자는 특히 요청을 넘어서 유지되는 이 세 계층이 얽힌 상황에서 어디에 오래된 값이 살아 있는지 하나씩 추적해야 합니다. 데이터를 갱신하는 코드가 DB 수정 한 줄로 끝나지 않고, 어느 캐시를 무효화할 것인지, revalidatePath인지 revalidateTag인지, 클라이언트까지 router.refresh()가 필요한지까지 포함하게 됩니다. App Router 초기에는 성능을 극대화하려는 방향으로 캐싱이 공격적이었습니다. 명시하지 않은 서버 fetch가 force-cache 방향으로 동작했고, GET Route Handler도 조건에 따라 기본 캐시 대상이 됐습니다. 개발자가 데이터를 가져오는 코드를 작성할 때 '지금 데이터를 가져오는 것'인지 'Next.js가 저장해둔 데이터를 쓰는 것'인지 코드만으로는 알 수 없었습니다. Next.js 15에서 이 기본값을 크게 바꾼 것은 Next.js 팀 스스로 성능을 우선한 공격적인 기본값이 실제 개발자의 직관과 충돌했음을 인정한 결과였습니다. 초기의 추상화와 기본값이 개발자 경험 측면에서 충분히 안정적이지 않았음을 보여주는 사례입니다. 호스팅 환경에 대해서도 한 가지 짚어둘 필요가 있습니다. Next.js는 공식적으로 self-hosting을 지원하고, next start를 실행하면 이미지 최적화, Middleware, ISR, Streaming 등 대부분의 기능이 동작합니다. 문제는 규모가 커졌을 때입니다. 여러 인스턴스를 띄우면 각 서버가 자체 로컬 캐시를 가지기 때문에, 한 인스턴스가 갱신한 캐시를 다른 인스턴스는 알 수 없습니다. 캐시 일관성이 필요한 경우에는 Redis나 S3 같은 공유 저장소를 기반으로 한 custom cache handler가 필요하고, Nginx를 앞에 두면 버퍼링 설정을 따로 조정해야 스트리밍이 제대로 동작합니다. 이것이 Next.js가 Vercel에서만 동작한다는 뜻은 아닙니다. 다만 프레임워크가 추상화해주던 인프라의 복잡도가 자체 인프라에 배포하는 순간 다시 개발팀의 책임으로 모습을 드러낸다는 뜻입니다. App Router를 구성하는 각각의 아이디어는 떼어놓고 보면 상당히 논리적입니다. 정적 페이지는 캐시하면 빠르고, 같은 데이터를 여러 컴포넌트에서 중복 요청할 이유가 없고, RSC를 쓰면 클라이언트 번들을 줄일 수 있고, 스트리밍을 쓰면 느린 데이터 하나 때문에 전체 페이지가 늦어지는 것을 막을 수 있습니다. 문제는 그것들이 합쳐지면 개발자가 '이 컴포넌트는 서버인가 클라이언트인가, 이 route는 정적인가 동적인가, 이 fetch는 어느 캐시 계층에 들어가는가, 데이터가 바뀌면 어느 캐시를 무효화해야 하는가, 여러 인스턴스를 띄울 때 캐시 일관성은 어떻게 유지하는가'를 모두 머릿속에 들고 있어야 한다는 것입니다. 그렇게 이해하고 나서 지금 만들고 있는 페이지를 다시 보면 단순한 회사 소개, 블로그, 서비스 소개, 문의 폼...일 수 있습니다....... 먼 길을 돌아 다시 제자리 이러한 복잡함을 겪으면서 제 안에서는 근본적인 질문이 계속되었습니다. 서버에서 완성된 HTML을 내려주는 것이 필요하다면, 원래 그렇게 동작하던 전통적인 웹 개발 방식을 쓰면 되지 않을까? 웹의 기본 모델은 단순합니다. 서버는 요청에 HTML 문서를 응답하고, 브라우저는 그 문서를 렌더링합니다. 그 HTML은 정적 파일일 수도 있고, PHP 같은 서버 프로그램이 요청마다 생성한 결과일 수도 있습니다. 상호작용이 필요한 부분에만 자바스크립트를 더하는 것, 이 구조에서 검색엔진은 아무런 추가 장치 없이 완성된 문서를 읽습니다. 우리는 이 방식을 떠나 클라이언트에서 모든 것을 그리는 방향으로 이동했다가, 그로 인해 생긴 문제를 해결하기 위해 다시 서버로 돌아오고 있습니다. 다만 돌아오는 과정에서 훨씬 복잡한 구조를 짊어지고 왔습니다. 전통적인 React SSR에서 대표적으로 따라오는 비용이 바로 하이드레이션(hydration)입니다. 서버가 HTML을 만들어 보내더라도, 브라우저는 서버에서 만들어진 DOM을 재사용하면서 해당 컴포넌트의 렌더링 로직을 자바스크립트로 다시 실행해 이벤트 핸들러를 연결하고 React 트리와 맞춰야 합니다. SSR은 자바스크립트를 없앤 것이 아니라, 자바스크립트를 실행하기 전에 보여줄 HTML을 먼저 만들어둔 것에 가깝습니다. 화면은 빨리 보이지만 실제로 상호작용이 가능해지기까지는 시간이 걸리며, 서버와 클라이언트의 렌더링 결과가 조금이라도 어긋나면 하이드레이션 불일치 오류가 발생합니다. 그럼에도 이 여정 자체를 부정하고 싶은 것은 아닙니다. 앞서 살펴본 것처럼 리액트가 서버 영역으로 확장된 데에는 실제로 존재하는 많은 문제들이 있었고, 그 해법은 그 문제를 가진 서비스로서는 분명히 가치가 있었을 겁니다. 돌이켜보면 리액트가 걸어온 길에는 흥미로운 왕복 운동이 있습니다. 서버가 HTML을 만들던 전통적인 방식을 떠나 브라우저에서 모든 것을 그리는 방향으로 갔다가, 그로 인해 생긴 성능과 검색 문제를 해결하기 위해 다시 서버로 돌아갔다는 것, 이번에는 서버 컴포넌트, 서버 함수, 캐시 전략, 클라이언트와 서버의 실행 경계까지 함께 결정해야 하는 훨씬 복잡한 구조를 짊어지고서 말입니다. 매우 먼 길을 돌아, 서버가 HTML을 만들어 보내던 웹의 오래된 구조를 훨씬 복잡한 형태로 다시 발견하고 있는 것은 아닐까요. 한두 페이지짜리 사이트에 리액트가 필요할까 회사 소개 페이지, 이벤트 랜딩 페이지처럼 한두 페이지로 끝나는 정적 사이트를 만드는 데에도 리액트가 당연하게 선택되는 경우를 자주 보았습니다. 이 경우 개발자는 번들러를 설정하고, 수백 개의 패키지가 들어간 node_modules를 관리하고, 빌드와 배포 파이프라인을 구성해야 합니다. 물론 node_modules의 크기가 그대로 사용자에게 전달되는 것은 아닙니다. 하지만 단순한 정적 HTML로 충분했던 페이지에 리액트 런타임과 하이드레이션을 위한 자바스크립트까지 전달한다면 그만큼 다운로드와 파싱, 실행 비용이 추가될 수 있습니다. 의존성이 늘어날수록 업데이트와 호환성 관리 부담도 함께 늘어납니다. 해결하려는 문제에 비해 개발 환경과 런타임의 복잡성이 커지는 셈입니다. 보안 측면에서도 마찬가지입니다. 리액트가 아닌 기술을 이야기하면 흔히 보안 취약성이 거론되지만, 리액트 생태계 역시 보안에서 자유롭지 않습니다. npm 생태계는 수많은 전이 의존성 위에 서 있고, 패키지 탈취나 악성 코드 삽입 같은 공급망 공격은 반복적으로 발생해 왔습니다. 리액트가 서버로 영역을 넓히면서 공격 표면도 함께 넓어졌습니다. 2025년에는 React Server Components의 서버 함수 payload 처리 과정에서 인증 없이 원격 코드 실행이 가능한 CVE-2025-55182가 공개되기도 했습니다. 서버에서 코드를 실행하는 순간, 서버 개발의 보안 책임도 함께 지게 되는 것입니다. 어떤 기술이든 보안은 관리해야 할 대상이며, 구조가 단순하고 의존성이 적을수록 관리해야 할 지점은 줄어듭니다. 저는 무엇보다도, 리액트가 아닌 기술을 "레거시"라는 한 마디로 폄하하는 분위기가 답답했던 것 같습니다. 기업이 리액트를 고집하는 데에는 채용 시장의 풍부함, 기존 코드베이스와의 연속성, 팀 내 지식의 공유 같은 현실적인 이유가 있습니다. 그러한 판단 자체는 이해할 수 있습니다. 하지만 조직의 관성이 기술적 판단을 대신하기 시작하면, 우리는 문제를 보는 대신 도구를 보게 됩니다. 웹 개발의 역사는 서버 렌더링에서 SPA로, 다시 서버 중심으로 흘러가며 순환해 왔습니다. 오늘의 표준이 내일의 레거시가 되고, 어제의 방식이 다시 재발견되는 것이 이 분야의 흐름입니다. 최근에는 서버 중심의 렌더링을 다시 적극적으로 활용하려는 흐름도 뚜렷해지고 있습니다. Astro는 페이지 대부분을 정적 HTML로 내보내고 상호작용이 필요한 영역만 독립적으로 하이드레이션하는 아일랜드 아키텍처를 내세웁니다. htmx는 HTML 속성만으로 서버와 통신해 페이지 일부를 교체하는 방식을 제안하며, Rails의 Hotwire와 Laravel의 Livewire도 서버가 HTML을 만들고 클라이언트는 최소한의 역할만 하는 구조를 지향합니다. 모두 "서버가 문서를 만들고, 필요한 곳에만 상호작용을 더한다"는, 웹이 원래 가지고 있던 철학을 다시 꺼내 든 것입니다. AI 시대의 바닐라 자바스크립트 이런 생각 끝에 저는 개인적인 작업에서는 가능한 한 바닐라 자바스크립트를 사용하고, 리액트는 꼭 필요한 경우에만 쓰겠다는 생각을 하게 되었습니다...... 아니, 가능한 한 바닐라 자바스크립트를 사용한다기보다는, 굳이 프레임워크가 필요 없는 간단한 작업에까지 프레임워크라는 거대한 짐을 이고 지고 짊어지진 않겠다는 말이 더 정확할 것 같습니다. 이 선택을 가능하게 한 것은 두 가지의 변화입니다. 하나는 웹 플랫폼 자체의 성숙도가 올라갔다는 점입니다. 과거에 프레임워크나 라이브러리가 필요했던 기능들 상당수가 이제 브라우저 표준으로 제공됩니다. ES 모듈을 통해 빌드 없이 코드를 분리할 수 있고, fetch로 비동기 통신을 처리하며, 템플릿 리터럴로 마크업을 조립할 수 있습니다. <dialog> 요소는 모달을, Web Components는 재사용 가능한 컴포넌트를, View Transitions API는 화면 전환 애니메이션을 표준으로 지원합니다. CSS 역시 :has() 선택자와 컨테이너 쿼리, 네이티브 중첩 문법을 갖추면서 예전에는 자바스크립트로 처리하던 일들을 상당 부분 흡수했습니다. 다른 하나로는, 역시 AI의 발달을 들 수 있겠습니다. 예전에는 바닐라 자바스크립트로 기능을 구현하려면 반복적인 코드를 직접 작성해야 했고, 그 수고를 덜기 위해서라도 프레임워크에 의존했습니다. 이제는 AI의 도움으로 그러한 비용이 크게 줄었습니다. 빌드 과정 없이 브라우저에서 바로 실행되는 코드는 디버깅이 직관적이고, 의존성 업데이트에 시달릴 일도 없습니다. 다만 이 방식에도 한계가 있다는 점은 분명히 해두고 싶습니다. 애플리케이션의 규모가 커지면 상태 관리, 효율적인 DOM 갱신, 컴포넌트 간 통신 같은 문제를 결국 직접 해결해야 하고, 그 과정에서 문서도 규칙도 없는 무근본 자체 프레임워크(프레임워크를 의도하지 않았지만 어찌 저찌 나도 모르게 프레임워크가 되어 버리는)가 만들어지기도 쉽습니다. AI가 생성한 코드가 일관된 구조 없이 쌓이면 유지보수는 오히려 어려워집니다. 팀 단위 개발에서 프레임워크가 주는 공통의 규칙과 어휘는 여전히 큰 가치를 지닙니다. 다만, 그저 저에게 있어서 바닐라 자바스크립트는 작은 규모의 작업에서 불필요한 복잡성을 걷어내는 선택지일 뿐인 것입니다. 처음 다뤄본 워드프레스와 PHP 그리고 지금 저는 개인 블로그를 구축하기 위해 처음으로 워드프레스와 PHP를 다루고 있습니다. 가장 먼저 놀란 것은 생태계의 규모입니다. 워드프레스는 전 세계 웹사이트의 40% 이상을 구동하는 것으로 알려져 있습니다. 블로그를 구축하기 위해 필요한 기능 대부분은 이미 테마나 플러그인으로 구현되어 있고, 관련 문서와 커뮤니티의 축적량도 방대합니다. 구조를 들여다보면 그 설계가 꽤 실용적이라는 것도 알게 됩니다. 워드프레스의 확장성은 훅(hook) 시스템에 기반합니다. 특정 시점에 코드를 실행하는 액션(action)과, 데이터가 출력되기 전에 값을 가공하는 필터(filter)를 통해 코어를 수정하지 않고도 거의 모든 동작을 바꿀 수 있습니다. 템플릿 계층 구조는 요청된 페이지의 종류에 따라 어떤 템플릿 파일을 사용할지를 규칙적으로 결정하므로, 파일 이름만으로도 페이지별 화면을 쉽게 분리할 수 있습니다. WP_Query로 원하는 조건의 글을 조회하고, 내장된 REST API를 통해 콘텐츠를 외부에 제공할 수도 있습니다. 흥미로운 점은 워드프레스의 블록 에디터 일부는 리액트로 만들어졌다는 사실입니다. 글을 작성하는 편집 화면처럼 상호작용이 복잡한 영역에는 리액트를 쓰고, 방문자에게 보여지는 페이지는 서버에서 PHP가 완성된 HTML로 렌더링합니다. 워드프레스에서는 기본적으로 서버에서 HTML을 만들지만, 필요한 블록이나 기능에 따라서는 클라이언트단에서 자바스크립트를 추가할 수 있는데, 저는 이것이 도구를 적재적소에 배치한 좋은 사례라고 생각합니다. 블로그처럼 콘텐츠가 중심인 사이트에서 이 구조는 매우 자연스럽습니다. 글을 발행하면 서버가 완성된 HTML을 응답하고, 검색엔진은 그 문서를 그대로 읽습니다. 검색엔진으로 하여금 본문을 보게 하기 위해 CSR이니 SSR이니 하는 전략을 따로 고민할 필요가 없습니다. 제가 리액트와 Next.js로 먼 길을 돌아 도달하려 했던 결과를, 워드프레스는 처음부터 기본 동작으로 제공하고 있었습니다. PHP는 레거시가 아니다 많은 웹 프런트엔드 개발자들이 PHP를 레거시 기술로 치부하지만, 실제로 지금 이렇게 저렇게 php를 만져보면서, 저는 이러한 평가에 쉽게 동의할 수 없다는 생각이 듭니다. 아마 php가 레거시라는 인식의 상당 부분은 PHP 5 시절, HTML과 SQL과 로직이 한 파일에 뒤섞인 코드와 잦은 보안 사고의 기억에서 비롯된 것이 아닐까 생각합니다. 현재의 PHP는 과거의 PHP와는 많이 다릅니다. PHP 7에서 엔진이 재설계되며 성능이 크게 향상되었고, PHP 8에서는 JIT(just-in-time) 컴파일러, 유니언 타입, match 표현식, 네임드 인수, 어트리뷰트가 도입되었습니다. 이후 버전에서도 열거형(enum), readonly 속성, 파이버(Fiber) 등이 추가되며 언어는 꾸준히 현대화되었습니다. 엄격한 타입 선언을 활용하면 정적 분석 도구와 함께 상당히 견고한 코드를 작성할 수 있고, Composer를 중심으로 한 패키지 생태계와 Laravel, Symfony 같은 프레임워크도 성숙해 있습니다. 무엇보다 전통적인 PHP-FPM 기반 웹 애플리케이션에서는 애플리케이션의 사용자 영역 상태가 기본적으로 요청 단위의 생명주기를 가진다는 장점이 있습니다. 요청 처리가 끝나면 다음 요청은 다시 초기화된 상태에서 시작하므로, 애플리케이션 프로세스에 상태를 오래 보관하는 서버와 비교하면 요청 간 상태 공유에 대해 신경 써야 할 지점이 적습니다. 물론 FPM worker 자체는 여러 요청에 걸쳐 재사용되고, OPcache나 persistent connection처럼 요청을 넘어 유지되는 상태도 있습니다. 최근의 FrankenPHP나 Swoole 같은 long-running PHP 런타임에서는 이 전제가 달라지기도 합니다. 하지만 전통적인 PHP 배포에서는 언어 자체가 필수적인 애플리케이션 빌드 단계를 요구하지 않기 때문에, 단순히 소스 코드를 배포하는 것만으로도 프로덕션 런타임 실행이 가능합니다. 저는 이것을 낡은 방식이 아니라 그저 단순한 방식이라고 부르고 싶습니다. 물론 워드프레스를 쓴다고 모든 것이 해결되는 것은 아닙니다. 워드프레스 생태계에서 공개되는 취약점의 상당수는 코어 자체보다 서드파티 플러그인과 테마에서 발견됩니다. 플러그인을 무분별하게 설치하면 성능 저하와 보안 위험이 함께 커지므로, 필요한 것만 신중하게 고르고 꾸준히 업데이트하는 관리가 필요합니다. 다만 이는 자바스크립트 또는 타입스크립트를 사용하면서 npm 의존성을 꾸준히 관리하는 것과도 비슷한 문제라고 저는 생각합니다. 어떤 생태계든, 외부 코드를 가져다 쓰는 순간 그에 따른 책임은 따라오기 마련입니다. 도구는 문제에 맞춰 고른다 다시 강조하지만, 저는 리액트를 쓰지 말자고 주장하는 것이 아닙니다. 상태가 복잡하게 얽힌 대시보드, 여러 사용자가 실시간으로 협업하는 도구, 화면 전환 없이 끊임없이 상호작용하는 웹 애플리케이션이라면 리액트는 여전히 훌륭한 선택입니다. 워드프레스의 에디터 일부가 리액트로 만들어진 것처럼, 리액트가 빛나는 자리는 분명히 존재합니다. 제가 말하고 싶은 것은, 그저, 도구는 문제에 맞춰 골라야 하며, 도구에 문제를 맞춰서는 안 된다는 것. 콘텐츠 중심의 블로그라면 워드프레스가 가장 합리적인 선택일 수 있습니다. 한두 페이지짜리 정적 사이트라면 HTML과 CSS, 약간의 자바스크립트로 충분합니다. 콘텐츠 사이트에 부분적인 상호작용이 필요하다면 Astro나 htmx 같은 선택지를 고려할 수 있습니다. 복잡한 웹 애플리케이션이라면 그때 리액트를 꺼내면 됩니다. 이 판단을 제대로 내리려면 여러 도구를 알아야 하고, 각각의 도구를 편견 없이 평가할 수 있어야 합니다. 리액트로 실무를 해본 경험이 있었기에 저는 지금 워드프레스와 PHP의 장점을 더 선명하게 볼 수 있습니다. 반대로 워드프레스와 PHP를 경험하면서, 리액트가 정말로 필요한 순간이 언제인지도 더 분명해졌습니다. 어느 한쪽을 버릴 필요는 없습니다. 각자의 자리에 두면 됩니다. "요즘 누가 그걸 써요"라는 말보다 "이 문제에는 무엇이 맞을까요"라는 질문이 더 자주 오가는 개발 문화를 바랍니다. 기술을 고르는 기준은 유행이 아닌 우리가 해결해야 할 문제 그 자체이기를 바라는 마음입니다.