컴포넌트가 많아지면 데이터를 그냥 props로 전달하면 된다고 생각했습니다. 직접 부딪혀보기 전까지는요. 부모에서 자식으로, 자식에서 또 자식으로 계속 타고 내려가다 보니 어느 순간 어디서 데이터가 바뀐 건지 추적조차 힘들어졌습니다. 그때 처음으로 상태관리 라이브러리가 왜 필요한지 몸으로 느꼈습니다.

컴포넌트 구조가 복잡해질수록 props 전달이 한계에 부딪힌다
프로젝트 초반에는 데이터를 부모 컴포넌트에서 자식 컴포넌트로 props로 넘기는 방식이 직관적으로 느껴졌습니다. 그런데 컴포넌트 depth가 3단계, 4단계로 깊어지기 시작하면 이야기가 달라집니다. 중간에 끼어 있는 컴포넌트는 그 데이터가 필요하지도 않은데 단순히 아래로 전달하기 위해 받아야 합니다. 이걸 props drilling이라고 부르는데, 쉽게 말해 데이터를 전달하기 위해 관계없는 컴포넌트들이 줄줄이 엮이는 현상입니다.
이 문제를 해결하기 위해 등장한 것이 상태관리 라이브러리입니다. Vue 3 생태계에서 현재 공식 권장되는 선택지는 Pinia입니다. Pinia란 컴포넌트 밖에 독립적인 공간을 만들어 데이터와 함수를 저장해두고, 어느 컴포넌트에서든 직접 꺼내 쓸 수 있게 해주는 상태관리 라이브러리입니다. 마치 집 안에 공용 창고를 하나 만들어두고 각 방에서 필요할 때마다 꺼내 쓰는 것과 비슷한 개념입니다.
실제로 써보니 store 파일 구조 자체는 단순합니다. defineStore를 호출할 때 고유 id를 지정해야 하는데, 이 id가 겹치면 같은 저장소를 바라보게 되므로 파일명처럼 의미 있는 이름을 붙이는 것이 중요합니다. reactive나 ref로 만든 반응형 변수와 함수를 return으로 내보내면, 다른 어떤 컴포넌트에서든 해당 store를 import해서 바로 사용할 수 있습니다.
store를 도입하고 나서 제가 체감한 가장 큰 변화는, 헤더 컴포넌트와 하단 컴포넌트가 서로 props 연결 없이 같은 로그인 상태를 바라볼 수 있게 됐다는 점입니다. 예전이라면 App.vue에서 상태를 들고 있다가 양쪽으로 내려줘야 했을 텐데, store 하나로 깔끔하게 해결됐습니다.
새로고침 한 번에 날아가는 로그인 상태, EncryptStorage로 잡는다
처음에 store만 써서 로그인 상태를 관리했을 때 황당한 경험을 했습니다. 로그인 버튼을 누르고 메인 페이지로 이동해서 헤더를 보면 분명히 마이페이지가 보이는데, F5 하나 누르면 다시 로그인 화면으로 돌아오는 겁니다. 당연한 결과였습니다. Pinia의 store는 기본적으로 메모리에 저장되기 때문에, 페이지를 새로고침하면 JavaScript 런타임이 초기화되면서 모든 상태가 날아갑니다.
이 문제를 해결하는 방법이 localStorage에 데이터를 저장하는 것인데, 여기서 하나 더 고려해야 할 부분이 있습니다. 브라우저의 localStorage는 값을 평문으로 저장하기 때문에, 민감한 사용자 정보를 그대로 저장하면 보안상 좋지 않습니다. 그래서 사용하는 것이 EncryptStorage 라이브러리입니다. EncryptStorage란 localStorage에 데이터를 저장할 때 지정한 비밀 키를 이용해 자동으로 암호화하고, 꺼낼 때는 복호화해주는 라이브러리입니다.
직접 구현해보니 설정은 크게 복잡하지 않습니다. 비밀 키 문자열과 prefix 옵션만 넣어주면 됩니다. 여기서 prefix란 localStorage에 저장되는 key 앞에 붙는 고정 접두사로, 예를 들어 prefix를 'SJB'로, key를 'user'로 설정하면 실제 브라우저에는 'SJB:user'라는 이름으로 저장됩니다. 이 prefix 덕분에 다른 라이브러리나 코드가 만들어둔 key와 충돌 없이 구분할 수 있습니다.
한 가지 주의할 점은 비밀 키를 코드에 직접 하드코딩하는 방식은 실무에서는 권장되지 않는다는 겁니다. 제가 실습에서는 임의의 문자열을 직접 넣었는데, 실제 서비스라면 환경변수 파일(.env)이나 서버에서 받아오는 형태로 처리하는 것이 안전합니다. 프론트엔드 보안의 핵심 원칙 중 하나로, 민감한 키 값은 코드 저장소에 그대로 남기지 않는 것이 기본입니다(출처: OWASP).
axios와 vue-router로 실제 로그인 흐름 완성하기
상태관리와 암호화 저장을 익혔다고 해서 바로 로그인 기능이 완성되는 건 아닙니다. 실제 백엔드와 통신해야 하는데, 여기서 등장하는 것이 axios입니다. axios란 브라우저에서 HTTP 요청을 비동기로 처리할 수 있게 해주는 JavaScript 라이브러리로, fetch API에 비해 인터셉터(interceptor) 설정이나 에러 처리가 훨씬 편리합니다.
제가 직접 써봤는데, axios에서 인터셉터 설정이 특히 유용했습니다. 인터셉터(interceptor)란 요청이 서버로 나가기 전, 또는 서버 응답이 컴포넌트에 도달하기 전에 중간에서 특정 로직을 실행할 수 있는 미들웨어 기능입니다. 예를 들어 인증 토큰을 모든 요청 헤더에 자동으로 붙이거나, 특정 에러 코드를 공통으로 처리할 때 인터셉터를 활용하면 코드 중복을 크게 줄일 수 있습니다.
실습 흐름을 정리하면 다음과 같습니다.
- 회원가입: Signup.vue에서 v-model로 입력값을 reactive 객체에 바인딩하고, 폼 제출 시 axios로 POST /member/signup 호출, 성공 시 vue-router의 router.push("/")로 메인 이동
- 로그인: LoginModal.vue에서 POST /login 호출, 성공 시 EncryptStorage에 사용자 정보 저장 후 window.location.href = "/"로 새로고침 처리
- 상태 반영: Header.vue에서 userStore.checkLogin() 반환값을 v-if로 체크하여 로그인/비로그인 메뉴를 분기
여기서 router.push()가 아닌 window.location.href를 쓴 이유가 있습니다. vue-router의 push는 SPA(Single Page Application) 방식으로 화면만 교체하는데, 이 경우 Pinia store가 초기화되지 않아 방금 localStorage에 저장한 사용자 정보가 store에 반영되지 않은 상태로 페이지가 보일 수 있습니다. window.location.href는 실제로 브라우저가 페이지를 새로 불러오게 만들어서 store가 초기화됐다가 localStorage 값을 다시 읽어오는 흐름을 만들어줍니다. 솔직히 이건 처음에 예상 밖이었습니다. SPA라서 push가 만능인 줄 알았는데 새로고침이 필요한 시나리오가 있다는 걸 직접 겪고 나서야 이해했습니다.
프론트엔드 개발에서 컴포넌트 간 통신 구조에 대해 Vue 공식 문서도 같은 방향을 제안하고 있습니다. 컴포넌트 depth가 깊어질수록 props 대신 provide/inject 또는 상태관리 라이브러리 사용을 권장하고 있으며, Vue 3 환경에서는 Pinia를 공식 상태관리 솔루션으로 채택하고 있습니다(출처: Vue.js 공식 문서).
결국 Pinia, EncryptStorage, axios, vue-router는 각각 독립된 도구처럼 보이지만 실제 로그인 기능 하나를 구현할 때 모두 맞물려 돌아갑니다. 처음엔 왜 이렇게 파일이 많이 나뉘어야 하나 싶었는데, 직접 기능을 완성하고 나니 각자 역할이 명확하게 분리되어 있어서 오히려 유지보수가 편하다는 걸 느꼈습니다. 이 구조를 한 번이라도 손으로 직접 만들어본 것과 안 만들어본 것의 차이는 생각보다 큽니다. 코드를 타이핑하면서 흐름이 몸에 익혀지는 느낌, 그게 이 실습의 진짜 가치라고 생각합니다.