올해 8월부터 개인 프로젝트로 프로솔브라는 크롬 익스텐션 애플리케이션을 만들었어요.
프로솔브는 코딩 테스트 플랫폼을 제공하는 프로그래머스 서비스의 사용자 경험을 향상시키는 서드파티 애플리케이션입니다.
10월 초에 첫 버전인 v1.0.0를 릴리즈를 하고, 회고록을 현재 v1.0.9을 릴리즈 하였습니다. 생각보다 많은 분들이 다운받아주셔서 정말 감사했습니다! 계속 애플리케이션을 유지, 보수하면서 마음에 드는 애플리케이션이 되면 좋겠다는 생각을 해봅니다.
이 글은 프로솔브 개발에 대한 회고를 주제로 하고 있습니다.
"프로솔브" 서비스는 코딩 테스트 플랫폼을 제공하는 프로그래머스의 서드파티 애플리케이션입니다. 프로그래머스를 이용하면서 느꼈던 몇 아쉬움을 해결하기 위해 개발한 크롬 익스텐션입니다.
서비스하고 있는 기능은 다음과 같습니다.
React Chrome extension 개발을 위한 Webpack, Babel 환경을 구축하였습니다.
Presentational-Container과 Compound Component 디자인 패턴을 이용해 프로젝트를 구성했습니다.
사용자가 제출한 문제 정보를 저장 및 조회 기능을 구현하기 위해 Firebase를 이용했습니다.
유저들을 식별하기 위해 Firebase Google 소셜 로그인을 이용하였으며, Firebase Cloud FireStore을 이용해 사용자가 제출한 풀이 정보를 저장 및 조회합니다.
성공한 문제 차트 & 표 기능을 구현하기 위해 전체 문제와 유저가 성공한 문제 정보가 필요합니다.
문제 정보를 얻기 위해 GitHub Actions과 jsDelivr, Chrome Storage API를 이용하였습니다.
첫 서비스에다 개인 프로젝트라 정말 많이 삽질을 했어요.
유지보수를 하고 있는 현재, 가장 아쉬운 점은 크게 2가지입니다.
저는 그 동안 CRA나 Vite 빌드 도구를 이용해 개발 환경을 세팅했었어요. 위의 빌드 도구들을 사용하면서 내부적으로 어떤 식으로 동작되는 건지 궁금했었어요.
그래서 이번 프로젝트 때는 개발 환경을 직접 구축하고자 Webpack과 Babel을 이용했는데요!
아직 Webpack을 잘 몰라서 개발용 설정, 빌드, 배포용 설정을 제대로 나누어서 설정하지 못해서 최적화를 못해서 아쉽네요...✨ 계속 Webpack을 공부하면서 고쳐나가고자 합니다.
위에서 언급했듯이 프로솔브는 Presentational-Container과 Compound Component 디자인 패턴을 이용해 구성했습니다.
리액트 디자인 패턴을 적용한 것은 이번이 처음이었는데요!
디자인 패턴을 사용하고자 생각하게 된 계기는 "UI와 비즈니스 로직을 분리"하고 싶어서였습니다.
찾아보니 UI와 비즈니스 로직을 분리하는 패턴으로 Presentational-Container 패턴이 있었습니다. 그래서 Container는 page를, Presentational은 하위 컴포넌트들로 보고 page에서 비즈니스 로직을 하위 컴포넌트들로부터 전달을 해주는 식으로 했습니다.
(솔직히 Presentational-Container 패턴 자체를 염두에 두고 사용하기 보다는 MVC 패턴을 나름대로 적용해서 써보자는 마음으로 사용했습니다. Controller는 Container, View는 Presentational, Model은 API, Hook 등이 되겠구나 하면서요.)
그리고 또 UI가 너무 종속적이게 되는 것이 싫어서 UI를 유연하게 만드는 방법을 찾다가 Compound Component 패턴을 알게 되었고 사용하게 되었습니다.
Compound Component 패턴을 사용한 방식은 두 가지 입니다.
먼저 React Component 조합 및 제어역전의 경우, 프로솔브는 Container에 대해 적용하였습니다.
예시로 Popup 페이지를 들겠습니다.
// Popup
function PopupLayout() {
const [userEmail, setUserEmail] = React.useState('');
const [isLoaded, setIsLoaded] = React.useState(true);
auth.onAuthStateChanged(firebaseUser => {
if (firebaseUser) {
setUserEmail(firebaseUser.email as string);
}
setIsLoaded(false);
});
return (
<Popup>
<Popup.Content>
<Popup.Title />
<Popup.Login isLoaded={isLoaded} userEmail={userEmail} />
</Popup.Content>
<Popup.Footer />
</Popup>
);
}
둘 째로 Render props를 사용해 렌더링 추상화의 경우 아래 Select 컴포넌트에 대해 적용하였습니다.
해당 Select 컴포넌트는 레이아웃이며, 사용시 props를 전달해서 사용할 수 있습니다.
사용 예시로 SortSelect 컴포넌트가 있습니다.
// Select.js
const Select = ({ isOpen, trigger, options, onChangeDropdown, filterState }: SelectProps) => {
return (
<Dropdown>
<Dropdown.Trigger as={trigger} />
<Dropdown.Menu isOpen={isOpen}>
{options.map((option: string, index: number) => (
<Dropdown.Item
key={uid(index)}
onChangeDropdown={onChangeDropdown}
filterState={filterState}
>
{option}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
const Dropdown = ({ children }: Children) => {
return <ContainerStyle>{children}</ContainerStyle>;
};
Dropdown.Trigger = ({ as }: TriggerProps) => <>{as}</>;
Dropdown.Menu = ({ isOpen, children }: MenuProps) => {
return <MenuStyle isOpen={isOpen}>{children}</MenuStyle>;
};
Dropdown.Item = ({ onChangeDropdown, filterState, children }: ItemProps) => {
const optionName = filterState === undefined ? children : filterState[children];
const onPreventEvent = (event: React.MouseEvent) => event.preventDefault();
const onChangeOption = () => onChangeDropdown(children);
return (
<ItemStyle value={children} onMouseDown={onPreventEvent} onClick={onChangeOption}>
{optionName}
</ItemStyle>
);
};
// Select 컴포넌트 사용 예시 - SortSelect.js
const SortSelect = () => {
const [isOpen, setIsOpen] = React.useState(false);
const [selected, setSelected] = useRecoilState(sortedOption);
const selectedName = (filterState as SortType)[selected];
return (
<Select
isOpen={isOpen}
trigger={<CheckOption isOpen={isOpen} value={selectedName} onModalChange={setIsOpen} />}
options={options}
onChangeDropdown={setSelected}
filterState={filterState}
/>
);
};
trigger props는 select박스 버튼으로 Select 컴포넌트에서 전달받은 trigger 컴포넌트를 전달해주어 조합을 한다. 버튼 UI에 대해 유연하게 변경할 수 있게 되는 것입니다.
앞서 Compound Component 패턴 사용한 방법으로 아래 두 가지가 있다고 하였습니다.
첫 째는 Container 청사진을 그리는 용도이므로 조금 적용을 잘 하고 있는 것 같다는 생각이 듭니다. 하지만 UI 기능이 많아질수록 JSX가 너무 커져 오히려 복잡해지고 있다는 생각이 많이 들었습니다. 소규모일 때 적합한 패턴이라고 많이 느꼈습니다.
두번째의 경우 나름 UI 유연하게 변경하려고 노력한다고 했지만 아직 부족하다고 느꼈고, 상태가 조금이라도 다르게 만들려고 할 때 너무 복잡해진다고 느꼈습니다.
Compound Component 패턴을 아직 제대로 이해를 못했던 상태에서 적용한게 아닌가 라는 생각이 들었습니다. 저에게는 너무나도 매력적으로 느껴졌던 패턴이었던지라 욕심이 생겨 적용을 했네요 😂...
제대로 다시 공부해서 추상화 레벨과 복잡도를 적절히 trade-off 해서 리팩터링 하고자 합니다. 그리고 Compound Component를 쓰는게 좋을 때와 아닐 때를 스스로 기준을 세워보고자 합니다.
첫 술에 배불러선 안 된다고 하지만 아쉬운 점이 계속 남네요. 그만큼 제가 많이 성장할 기회가 있다는 의미인 거겠죠! 리팩터링 아자아자 화이팅...💪
이어서 트러블슈팅 주제에 대해 회고하고자 합니다.
프로솔브를 만들면서 정말 많은 트러블슈팅을 경험했는데요!
그 중 가장 인상깊었던 트러블슈팅 2가지를 설명하고자 해요!
이 밖의 트러블 슈팅 경험들이 궁금하시다면 Notion을 참고해주세요!
개발하면서 가장 많이 헤맸던 기능입니다.
크롬 익스텐션에서 로그인을 하는 방법이 나와있긴 하나 인증처리를 유지하는 방법에 대해 설명하는 게시물이 전혀 없었습니다.
저는 프로그래머스에서 풀이를 제출 시 firebase database의 유저의 문제 풀이를 저장하고 싶었는데요! 해당 작업을 하기 위해서는 인증이 필요하기에 구글 Oauth 2.0을 이용하기로 결정했습니다!
그래서 Popup에서 구글 Oauth 2.0으로 로그인을 하면 익스텐션에서 인증 처리가 되어 contentScript에서 인증이 필요한 작업들을 수행할 수 있게끔 구현하고자 했습니다.
하지만 Popup의 로직은 popup이 열려있는 상태일 때만 실행되며, contentScript에서 인증 처리가 되지 않았습니다.
인증을 유지하기 위해 크롬 API에 chrome.identity.getAuthToken 이용할 수 있습니다. getAuthToekn API가 accessToken을 캐싱하기에 인증이 유지되게 됩니다.
하지만 chrome identity api가 popup과 background(service worker)에서만 이용할 수 있습니다.
따라서 프로솔브는 아래의 방법을 이용해 contentScript에서 인증이 필요한 작업들을 수행할 수 있게 구현하였습니다.
Goggle Oauth 2.0 로그인 소스코드를 GitHub 레포에서 확인해보세요!
성공한 문제 차트와 표 기능은 전체 문제와 유저의 성공한 문제 정보가 필요해요.
해당 기능을 만들 당시 프로그래머스에서 문제 정보 api에 페이지 number를 쿼리 스트링으로 전달해주면 response로 각 페이지의 문제 리스트를 받았습니다.
그리고 존재하지 않는 페이지 number를 전달해주게 되면 빈 배열을 받았습니다.
초기에 프로솔브는 레이턴시를 최소화하기 위해 아래의 방법을 선택했습니다.
위의 방식으로 log_N으로 시간복잡도를 최소화 했으나... 성공한 문제 차트와 표를 조회할 때마다 수행하기엔 너무나도 부담스럽습니다.
그 이유는 다음과 같습니다.
프로솔브는 위에서 열거한 문제들을 아래의 방식을 통해 해결하였습니다.
이후 프로그래머스 문제 정보 API는 total page number를 얻을 수 있게끔 수정되었습니다.
API의 변경으로 위의 해결 방법이 이전보다 드라마틱한 레이턴시 축소를 이루어주진 않으나, 불필요한 리소스 낭비를 줄여준다는 점에서 의의가 있습니다.
프로솔브는 v1.0.0에서 시작해서 새로운 Feature을 추가하고 버그와 자잘한 UI를 수정하면서 현재 v1.0.9입니다.
Semantic versioning에 대해 잘 아시는 분이시라면 아마 단번에 알아차리셨을 것입니다.
"어? 기능이 추가되었는데 Minor 버전이 안 바꼈다고...?"
맞습니다... 저는 새로운 기능을 추가하고 릴리즈한 후에 Semantic versioning의 존재를 알아차렸습니다 😂😂
Semantic versioning은 아래와 같이 생겼습니다.
Major.Minor.Patch
Major 버전은 하위 버전과 호환되지 않는 대대적인 변화가 일어났을 때,
Minor 버전은 하위 버전과 호환되면서 새로운 기능을 추가했을 때,
Patch 버전은 버그를 수정했을 때
값을 올립니다.
하지만 저는 이런 컨벤션이 있다는 것을 미리 찾지 않은 채 작업을 했고... 기능이 추가되었는데 Minor를 변경하지 않았습니다.
빠르게 사용자들로부터 새로운 기능을 선보이고 싶다는 마음에 급급해서 컨벤션을 찾아보지 않았다는게 너무 부끄럽네요.
어쩌면 웃긴 에피소드로 넘길 수 있지만, 개발자 취준생으로서 이런 컨벤션들은 철저하게 지켜야한다는 다짐을 하게 되었습니다! 🔥🔥🔥
프로솔브는 첫 서비스라 제게 정말 의미있는 프로젝트입니다. 아직도 첫 릴리즈때의 벅참이 잊혀지지 않네요. 서비스하면서 조금씩 늘어나는 사용자 수를 보면서 정말 뿌듯했습니다. 개발자가 느끼는 최고의 보람이란 만든 서비스를 유용하게 써주는 유저분들에게서 오는 것이라는 생각이 많이 들었네요. 다음 프로젝트를 진행할 때도 유저분들이 만족해서 쓸 수 있을 앱을 만들고 싶은 바람입니다.
프로솔브 프로젝트의 릴리즈 노트, 소스 코드, 커밋은 깃헙 레포에서 보실 수 있어요!
좋은 글 너무 감사드립니다
저도 현재 크롬 익스텐션 구현 중에 있는데 로그인 구현은 다 끝났는데
유저가 로그인할 이메일 선택하고 그때 팝업에서 컨텐츠로 메시지를 보내야 하는데 이메일 선택하면 팝업이 바로 종료가 됩니다 ㅠㅠ
혹시 프로솔브에서는 어떻게 유저가 Google OAuth 2.0에서 퍼미션 주는 페이지에서 로그인 할 이메일을 선택하고도 팝업 종료를 안시켰는지 workaround가 무엇인지 좀 알 수 있을까요??
저는 먼저 시도 해본거는 팝업 ==> 컨텐스크립트 이렇게 바로 보내는게 아니라
팝업 ==> 백그라운드 ==> 컨텐츠 이 방법도 시도 중에 있는데 잘 안되네요 ㅠㅠ 로그인 할 이메일만 선택하면 팝업이
바로 종료가 되어서 컨텐츠 스크립트에서 처리해야할 로직이 작동을 하지 않았습니다.
이외에도 라이프사이클 훅을 써서 (저는 Vue3로 익스텐션을 개발 중에 있습니다) popup.vue가 destory 바로 직전에 content script로 메세지를 보내보려고도 해봤지만 제대로 작동을 하진 않았습니다.
근데 좀 이상한 점 발견한게, 팝업의 개발자 도구를 키고 로그인을 시도하면은 제가 로직을 만든대로 잘 수행을 합니다
로그인할 이메일선택 => 로그인 시도 => 팝업에서 컨텐츠로 메시지 => 팝업 종료 => 컨텐츠 로직 수행
혹시 이 점도 어떤건지 아시나용??
너무 두서없는 글 죄송합니다..