기억의 실마리
2025. 12. 11. 23:43

🎉 네트워킹 중심의 컨퍼런스, 테오콘

감사하게도 최근에 다녀온 구름톤에 참여할 수 있는 기회를 가지게 되어 다양한 개발자/디자이너/기획자분들과 다양한 경험과 사고하는 방식을 공유하며 정체되어있던 성장의 열쇠를 발견할 수 있었던 뜻 깊은 경험을 한 적이 있습니다.

 

그래서 저는 개발자가 개발만 해서는 키울 수 없는 "좋은 개발자"의 소양이 존재한다고 생각합니다.

다녀오고 느낀 것을 간단히 서술하자면, 서로의 경험을 공유함으로서 생각의 확장성과 방향성이 풍부해지고, 소통의 영역을 넓힐 수 있게 되어 커뮤니케이션에 반드시 필요한 소프트 스킬 향상에 큰 도움이 된다는 것을 느꼈습니다.

 

주니어 개발자에서 더 나아가 이제는 미드레벨 개발자로서 성장하기 위한 한 걸음 더 내딪은 것 같아, 뿌듯하고 즐거운 경험이었습니다.

 

이러한 경험 덕분에 적극적으로 컨퍼런스나 기타 대외활동에 관심을 기울이게 되었습니다.

그러던 도중 멋쟁이 백엔드 개발자 친구가 TEOConf라는 컨퍼런스를 추천해주었습니다.

 

테오콘? 테오콘이 뭐지... 하며 찾아보니 프론트엔드계에서 선한 영향력을 끼치고 있는 "테오"라는 멋쟁이 개발자가 주최하는 컨퍼런스였고, 지난 컨퍼런스들을 보며 "성장할 수 있는 영향력을 받을 수 있는 기회다"라는 생각이 들어 컨퍼런스에 신청했고, 감사하게도 선발되어 참여하게 되었습니다.

 

 

입구에 들어서자 귀엽고 말랑해보이는 블럭 캐릭터들이 맞이해주고 있었습니다.

 

컨퍼런스를 후원해주는 후원사가 elice기 때문에 상단에는 후원사 명과 '네트워킹 중심의 컨퍼런스'라는 주제답게 "테두리 없이 오늘의 경험을 나누는 놀이터"라는 슬로건에 '테오'를 강조해서 둔 것을 보고 센스가 좋다... 라는 생각했습니다.

 

 

캐릭터 디자인이 너무 귀여워서 받자마자 노트북에 붙여버렸습니다😊

 

 

그리고 굿즈로 키캡을 받았는데 얼마 전에 메이플키우기 개발사에 다니는 친구에게 선물받은 것과 똑같은 제품이여서 가방에 세트처럼 두개를 달고 다니게 되었습니다🥹

 

 

컨퍼런스 신청 시 종합 받았던 닉네임, 그리고 소개 한 줄과 관심사를 모두 담아낸 귀여운 명함도 받을 수 있었습니다!

 

이 후 서로 호칭을 닉네임으로 말하고 친근하고 수평적인 어투로 말하자는 Rule이 있었습니다.

 

"테오님은 이런 경우에 어떻게 하세요?" - X

"테오는 이런 경우에 어떻게 해요?" - O

 

재직중인 회사에서는 닉네임 문화가 없어서 새롭고 재밌는 문화구나 생각이 들었습니다😊

 

 

좌석은 '네트워킹 중심 컨퍼런스'답게 함께 스크린을 바라보는 배치 가 아닌, 둘러 앉아 편하게 대화할 수 있도록 테이블을 배치해둔 것이 인상적이었습니다.

 

이어서 '닉네임을 짓게 된 이유'에 대한 주제로 대화를 시작했는데, 정말 독특한 분도 계시고 유쾌하고 저와 성향이 비슷한 분도 계셔서 이야기 보따리가 끊이지 않고 풀어지며 쉴 틈 없이 재밌게 이야기할 수 있었습니다.

 

그리고 MC를 뽑아 컨퍼런스 세션의 시작을 주도하는 방식으로 진행했습니다.

MC 누가할까요? 라고 말을 꺼내자 5초만에 제가 할게요! 하신 분이 계셨고, 말도 잘하시고 재밌게 이야기하는 분이 맡아 주셔서 즐겁게 컨퍼런스를 이어 갈 수 있었습니다.

 

다음으로  “개발을 시작한 계기”, “가지고 싶은 초능력”, “이성을 볼 때 중요한 것”을 포스트잇에 적고 보드에 붙여 각자 주제에 대해 의견을 말하고 공감하며, 다양한 배경과 가치관에 공감하고 배울 수 있는 즐거운 이야기에 빠질 수 있었습니다.

 

그리고 "팀원의 얼굴 그려주기 게임"을 진행한다고 해서 그림 그리기에 자부심이 있기 때문에 내심 "내 실력을 보여 줄 때로군!"라고... 생각했지만 방식이 조금 특이했습니다. 자주 쓰는 손이 아닌 반대 손으로 그림을 5초 동안 그린 후 시계방향으로 동료에게 넘겨주며 다 함께 그림을 만들어가는 방식이었습니다🥲

 

 

모두 함께 그린 저의 초상화입니다...

마치 아따맘마에 등장하는 동동이가 안경을 쓴 것 같은 모습이 되었습니다😁

 

실물과는 정반대의 이미지라고 생각합니다...

 

 

이어지는 세션으로 스파게티면과 마시멜로를 이용해서 탑을 쌓는 것이었고, 가장 높게 쌓은 팀이 상품을 받을 수 있는 이벤트 세션이었습니다. 팀원들은 혹시 건축 관련 일이나 전공 해보신분 말을 했고, 저는 고등학생 때 인테리어 디자인과를 졸업했고, 캐드랑 건축 설계부분에 대해서 어느정도 알고 있다고 자신있게 전하고 전략을 전달했습니다.

 

저는 스파게티 면의 개수가 정해져있기 때문에 탑을 세울 수 있고 가장 적은 스파게티 면을 활용해서 높이 쌓을 수 있는 방법을 제안했고 모두 고개를 끄덕이며 완전 좋은 방법이라고 하며 진행하기로 했습니다.

 

결과는 6cm 탑을 쌓았습니다 ☺️👍🏻

 

제가 고려하지 못한 것은 너무 짧은 시간이었고 모두의 피지컬 다르고, 생각하는 것이 다르기 때문에 오히려 안전성에 대해 선택하고 집중하는 것이 더 좋은 선택이었던 것 같습니다...

 

스파게티 탑쌓기를 통해 모두를 이끄는 리더로서, 목표 달성을 위한 방향성을 선택하고 집중할수 있는 역량이 아직은 많이 부족하다는 것을 느꼈습니다. 좀 더 전체적인 맥락을 파악할 수 있는 역량을 키워보기로 다짐했습니다...🥹

 

테이블에 함께 앉은 팀과 함께 하는 마지막 세션으로는 '선물 교환식'이 있었습니다.

참여 신청을 할 때 미리 선물 교환식에 참여하기 위해 5,000 ~ 10,000원 정도의 선물을 준비해오라는 공지가 있었습니다. 물론 강제는 아니고 선택이지만 준비하지 않으면 해당 세션에 참여할 수 없다는 공지가 있었습니다.

 

제가 준비한 선물은 개발자에게 가장 중요한 "손"을 케어할 수 있는 6000원 상당의 핸드크림을 준비했습니다.

그러고 선물 교환식 때 제가 받은건...

 

귀여워.

 

아주 귀여운 카피바라 인형 키링을 받았습니다!

저는 귀여운 것을 좋아하기 때문에 아주 만족스러웠고, 당근을 당기고 귀를 살짝 건드리면 엄청난 속도로 귀를 움직이는

"인터랙티브"까지 갖춘... 마음에 쏙 드는 선물이었습니다! 🙏🏻

 

그리고 선물로 맥북을 받은 케요네즈!!!

진행 MC를 맡은 루키가 자기 선물이 특별하다 / 특이하다 하는 분 손을 들어달라고 했고, 케요네즈는 바로 손을 번쩍 들었습니다😁

 

루키는 뭐 받으셨냐고 물어봤고, 케요네즈는 "맥북이요!!" 라고 소리쳤습니다.

루키는 눈이 휘둥그레지며 "선물로 맥북을 받았다고??????"하며 성큼성큼 다가와서 확인하더니

귀여운 맥북 ㅎ

 

"아~~~ 맥북이네..."하고 이내 실망하는 듯한 리액션을 보여줬습니다😊

 

루키의 재치있는 진행과 리액션 덕분에 네트워킹 세션을 진행하는 내내 다들 웃으며 즐겁게

선물 교환식이 마무리 되었습니다.

 

마지막으로 '럭키드로우' 세션이 남았는데, 랜덤 핀볼을 통해서 추첨하는 방식으로 진행했습니다.

이런 뽑기 당첨운이 없는 저는 큰 기대를 하지 않고 있었습니다.

갑자기 루키가

"혹시 오늘 나는 진짜 당첨될 것 같다 손들어보세요!" 라고 했고, 저는 번쩍 손을 들었습니다.

 

루키: "왜 당첨될 것 같다고 생각한 근거는?"

제리옹: "저는 당첨운이 없기 때문에 말이라도 해보고 싶었습니다!!"

 

...

 

네, 결과는 당첨되지 않았습니다.

 

이렇게 팀원들과 함께하는 네트워킹 세션을 진행하면서

개개인이 가진 다양성이 불편하지 않고 기분좋은 소통으로 이어질 수 있는

귀한 자리를 마련해준 테오와 관계자분들께 감사한 마음을 느꼈습니다🙏🏻

 

📝 많이 배운 발표 세션 후기

[ Breaking Changes와 함께 자라기 - 준 ]

버전 관리를 배경으로 마이그레이션이 늦어지거나 기술의 버전이 정체되는 등 기술부채에 대한 문제를 체감하고 개선하고자 노력하는 과정에서 배울 점이 많다는 생각이 들었습니다.

해당 세션을 통해 "모듈페더레이션"이라는 개념을 처음 접하고 깊이 있는 지식을 얻을 수 있는 계기가 된 것 같아 좋았습니다.

추가적으로 마이그레이션 진행 시 도움이 되는 codemod에 대해서 알게 되었고, console.trace의 활용, 버전 관리 문화의 중요성 등을 배우게 되었고 LocatorJS를 활용한 ( 브라우저 ➔ IDE ) 파일 추적에 대해 알게되어 재직중인 회사에 적용해서 유지보수 시 DX향상에 기여하여 뿌듯한 경험도 할 수 있었습니다.

 

[ 쿠키를 공유하고 싶어 - 빡준 ( 데이원컴퍼니 프론트엔드 ) ]

하나의 서비스 이지만, PHP & Next.js 로 서비스가 나뉘고 서버가 완전히 다른 환경에서 돌아가는 상황에서 로그인 인증 방식이 세션 / 쿠키 인증으로 나뉘는 상황에서 인증을 통합할 수 있는 아이디어를 떠올리고 극복하는 과정과 결과에 대해서 발표해주셨습니다. 근본적인 문제를 해결하기 위해 도입한 것은 "Next.js 멀티존 마이크로 프론트엔드 전략" 이었습니다. Next.js에서 제공하는 자연스러운 방식으로 풀어낸 것이 돋보였고, "비즈니스는 나를 기다려주지 않는다" 라는 말도 인상적이었습니다. 생산성은 비즈니스 실현에서 결코 빠질 수 없는 요소이기 때문에 개발을 진행하며 설정된 서비스 구현 기간을 최대한 지키기 위해 노력하고, "비즈니스 실현에 있어 빠른 이터레이션이 요구되는 것이 불합리한 것이 아니다."라는 것을 상기하며 현실적이고 합리적인 방법으로 개발에 임해야겠다고 생각했습니다.

 

[ ZeroRuntime CSS in JS, Devup UI - 오정민 ( 데브파이브 대표이사/연구소장 ) ]

Devup UI를 개발한 오정민 대표님의 발표를 듣고 느낀 점은 지금까지 지향해온 "찐개발자다"라는 생각밖에 들지 않았습니다.

기술에 대한 집요함, 실험 정신, 문제 해결의 방향성, 커뮤니케이션 집중력 등 놀라움의 연속이었습니다. 그 무엇보다 성과에 주목하게 되었는데, Zero Runtime이라고 언급한 만큼 타 유명 라이브러리와 비교했을 때 성능적 우위를 차지하고 있었고, 현재 진행중인 개발의 방향성과 지향점이 뚜렷해서 정말로 배울점이 많은 개발자이자 사업가라는 생각이 들었습니다.

그리고 처음으로 개발 방향성을 잡을 때 "어떤 개발 환경이든 결과적으로 반영되어야 하는 코드는 변하지 않는다."에 집중하며 기존 문제들을 다루는 능력이 돋보였고, 테오컨퍼런스를 오지 않았다면 이런 에티튜드나 인사이트를 배우지 못했을 것이라는 생각에 더욱이 "오길 잘 했다!"라는 생각이 떠나지 않았습니다. 재밌게 들었던 내용은

"className을 생성하는 과정에서 a와 d가 붙어버리면 className이 ad로 시작하게 되어서 Chrome에서 광고 클래스네임으로 인식하고 차단해버리는 케이스를 발견하고 a와 d가 붙으면 a-d로 분류하는 추가 작업을 하게 되었다"라는 트러블슈팅 이야기였는데 뭔가 지난 삽질이 떠오르기도 하고...🥹 덕분에 공감하고 웃으며 집중할 수 있었습니다.

 

📌그리고 테오의 마무리

원래는 '테오의 고민 상담소' 세션이 먼저 시작되고 '럭키드로우' 세션을 마지막으로 단체 사진을 찍고 마무리하는 것으로 예정되어있었지만, [ Track B ] 일정이 먼저 마치게 되어 마지막 일정의 순서가 조정되었습니다.

[ Track A ]에 참가한 저는 마지막으로 '테오의 고민 상담소'를 마지막 세션으로 맞이하게 되었고, 그 동안 고민했던 내용들과 직접 제출했던 질문과 매우 유사한 질문도 포함되어있어서 정말 고민이 많이 해결되었습니다.

가장 고민하던 주제는 제가 주니어 개발자에서 미드레벨 개발자로 넘어가는 단계이기 때문에 보편적으로 미드레벨 개발자의 역할, 시니어 개발자의 역할에 대해 궁금했는데 전반적인 답변을 조합했을 때 많은 고민이 해결이 되어 유익했습니다.

그리고 직접 제출한 질문은 "AI에게 명령어를 작성할 때 필요한 역량이나 핵심 기술"에 대한 것들이었습니다.

답변을 듣고서 "내가 무엇을 할 수 있는지""AI가 잘하는 것은 무엇인지" 파악하고 시너지를 끌어올릴 수 있는 능력에 집중해서 방향성을 주도하는 것이 중요하다는 생각이 들었고, 고민하던 것들이 역설적으로 고민하지 않아도 되었던 부분들도 있었던 것 같습니다.

 

'테오의 고민 상담소'에서 받게 된 답변과 '발표 세션'에서 세분의 경험담과 방향성은 개발자로서의 시야를 넓혀주셨고, 본질을 찾아 집중하는 능력을 좀 더 키우고자 다짐할 수 있는 커다란 계기가 된 것 같아 보람차고 뜻 깊은 경험이었습니다☺️

 

2025. 12. 1. 18:18

✍🏻개요

이번 프로젝트에서는 Kakao Map을 활용한 🔗로컬실록지리지 서비스를 🔗스카우트 플랫폼으로 이관 구현이 결정되어 dev서버에서 개발을 진행하면대량의 마커를 지도에 표시할 때 흔히 마주치는 문제로, 브라우저 성능 저하를 개선한 경험을 바탕으로 포스팅했습니다.

 

마커의 개수는 2000개로 마커를 렌더링할 때 발생하는 성능 문제를 해결했습니다.

 

레거시 시스템( PHP )에서는 15.3fps로 버벅이던 지도를 최적화 하여 55.7fps( 약 3.6배 향상 )로 부드럽게 동작하게 되었습니다.

 

🔍 레거시 시스템에서는 왜 지도 이동 시 프레임 드랍이 심했을까?

 

PHP에서 마커를 HTML으로 구현한 부분

 

위 스크린샷은 전달 받은 PHP파일에서 직접 찾은 코드입니다.

kakao maps api에서 CustomOverlay매서드를 활용하여 content에 HTML을 직접 주입하여 마커를 사용하고 있습니다. 이렇게 구현할 경우 쉽게 끝나만, 명적인 성능 문가 발생합니다.

  • 지도 이동/줌 시 매번 Reflow 발생
  • 메인 스레드( CPU )에서 지속적으로 레이아웃 재계산
  • 수백 ~ 1000개 이상의 마커의 Reflow로 인해 브라우저에 부하가 커지며 프레임 드랍이 발생하여 버벅임

 

💻 렌더링 파이프라인에 의한 부하

라우저 렌더링은  단계를 거쳐 화면에 표시됩니다.

  1. Layout ( Reflow ) - 요소의 위치/크기 계산
  2. Paint - 픽셀 그리기
  3. Composite - 레이어 합성 ( GPU 가속 )

모든 "HTML" 마커는 화면이 변경될 때 마다 1 ~ 3단계를 반복 합니다. 이 단계중 프레임드랍의 주된 원인으로 Layout 단계는 메인 스레드에서 CPU를 사용하여 실행되며, 단일 스레드 구조로 인해 병렬 연산에는 적합하지 않습니다.

 

메인 스레드는 한 번에 하나의 작업만 처리할 수 있기 때문에, 레이아웃 계산이나 복잡한 스크립트 실행 등으로 작업이 지연되면 다음 렌더링 단계가 대기하게 되어 버벅임(프레임 드랍)이 발생할 수 있습니다.

 

반면 3단계인 Composite( 합성 )만 실행하면 GPU 가속으로 인해 병렬 연산 효율이 높아져 많은 연산을 빠르게 병렬 처리하고, 높은 프레임을 유지 할 수 있게 됩니!

 

🤔 지도를 축소 했을 때 마커를 다 보여 줄 필요가 있을까?

가장 먼저 사용자 입장에서 생각을 해보았습니다.

지도를 볼 때 축소하면 할수록 마커와 마커 사이의 간격이 줄어들고 겹쳐지며 점점 구분이 어려웠고 가려지는 마커는 존재만 할 뿐, 사실상 상호작용이 불가하여 원하는 컨텐츠를 확인 해 볼 수도 없었습니다.

 

그렇다면, 모든 마커를 화면에 보여주는 것 자체가 정말로 유의미한 구현 방향이었는가?

오히려 사용의 불편과 혼란을 야기하는 것이지 않을까?

 

스스로 떠올렸을 때는 아니라는 결론이 나왔습니다.

 

나만이 생각한 결론이 정답은 아니기 때문에 다양한 레퍼런스를 찾았지만, 역시나 찾아본 모든 지도 기반 서비스들이 존재하는 모든 마커를 전부 보여주는 식의 구현은 없었습니다.

 

지도 줌 레벨을 기준으로 특정한 범위로 그룹화하여 숫자로 표기하는 Clusterer 라는 기술을 사용하여 구현하고 있었습니다.

 

🍇클러스터러의 

  • 화면에 그려지는 마커의 수가 감소하여 부하가 적어짐
  • 줌 레벨에 따라 자동으로 마커 그룹화/해제
  • 혼란성이 감소하고 지도가 깔끔해져 UX 향상

Clusterer 기능 추가에 대한 필요성을 제기하여 기획/디자인에도 긍정적으로 수용되었습니다.

 

📌 보고 있는 화면의 데이터만 가져오자! [ DB조회 최소화 ]

함께 해당 컨텐츠를 개발하기로 한 서버 개발자와 함께 기능 구현 논의를 할 때 모든 데이터를 가져오는 방식 보다는, 사용자가 보고 있는 지도 범위에 있는 데이터만 조회해서 내려주고 중복 데이터는 프론트엔드에서 캐싱하고 요청 시 excludes 리스트에 담아서 요청하면 해당 데이터를 제외하고 응답받으면 좋을 것 같다는 의견을 내어 긍정적으로 수용되었고, 개발을 진행하게 되었습니다.

 

개발을 진행할 때 "어떤 기준의 범위로 지정할 것인가?" 에 대해 고민한 결과, 다양한 디바이스를 고려하기 위해 map을 렌더링하는 Element의 width와 height를 비교하여 긴 변의 1/2 길이 반경의 데이터만 조회할 수 있도록 설계했습니다.

 

줌레벨에 따른 실거리 기준으로 m km 거리 단위로 변환하여 요청하는 것으로 논의를 마치고 코드를 작성하여 적용했습니다.

 

/**
 * 두 좌표 사이의 거리 계산 (미터)
 * @param {kakao.maps.LatLng} point1 - 첫 번째 좌표
 * @param {kakao.maps.LatLng} point2 - 두 번째 좌표
 * @returns {number} 두 좌표 사이의 거리 (미터)
 */
export const getDistanceBetween = (point1, point2) => {
  // 임시 polyline을 만들어 거리 계산
  const line = new window.kakao.maps.Polyline({
    path: [point1, point2],
  });
  return line.getLength(); // 미터 단위 반환
};

/**
 * 지도 중심점과 반경 계산 함수
 * @param {kakao.maps.Map} map - 카카오 맵 인스턴스
 * @returns {Object} 중심 좌표, 줌 레벨, 지도 크기, 반경 정보
 * @returns {Object} return.center - 중심 좌표 {lat, lng}
 * @returns {number} return.zoomLevel - 현재 줌 레벨
 * @returns {Object} return.mapSize - 지도 크기 {width, height}
 * @returns {number} return.radius - 반경 (미터)
 * @returns {number} return.radiusKm - 반경 (킬로미터)
 */
export const calculateMapRadius = (map) => {
  if (!map) return null;

  // 1. 지도의 중심 좌표 가져오기
  const center = map.getCenter();
  const centerLat = center.getLat();
  const centerLng = center.getLng();

  // 2. 현재 줌 레벨
  const zoomLevel = map.getLevel();

  // 3. 지도의 영역(bounds) 가져오기
  const bounds = map.getBounds();
  // const swLatLng = bounds.getSouthWest(); // 남서쪽 좌표
  const neLatLng = bounds.getNorthEast(); // 북동쪽 좌표

  // 4. 지도 element의 크기 (픽셀)
  const mapElement = map.getNode();
  const mapWidth = mapElement.offsetWidth;
  const mapHeight = mapElement.offsetHeight;

  // 5. 중심점에서 각 방향 끝까지의 거리 계산 (m)
  // 가로 거리: 중심에서 동쪽 끝까지
  const eastPoint = new window.kakao.maps.LatLng(centerLat, neLatLng.getLng());
  const horizontalDistance = getDistanceBetween(center, eastPoint);

  // 세로 거리: 중심에서 북쪽 끝까지
  const northPoint = new window.kakao.maps.LatLng(neLatLng.getLat(), centerLng);
  const verticalDistance = getDistanceBetween(center, northPoint);

  // 6. 긴 변의 1/2 거리 (= 반경)
  const radius = Math.max(horizontalDistance, verticalDistance);

  return {
    center: { lat: centerLat, lng: centerLng },
    zoomLevel,
    mapSize: { width: mapWidth, height: mapHeight },
    radius: radius,
    radiusKm: radius / 1000,
  };
};

 

테스트 결과 성공적으로 사용자가 보고 있는 범위내의 데이터만 조회 할 수 있었고, 중복데이터는 제외하고 조회하여 기존대비

최대 95%까지 통신자원을 절약할 수 있는 시스템을 구축할 수 있었습니다.

 

🎨 커스텀 마커를 GPU 가속을 활용한 Composite 렌더링으로 구현하기

HTML 대신 Canvas 미지를 하여 렌더링하는 방식 떠올렸습니다.

 

구현해야 하는 커스텀 마커의 조건은 다음과 같습니다.

  • [ 문제 제시 - 단건/다건 ] [ 문제 해결 - 단건/다건 ] 총 4가지 카테고리 이미지로 분기하여 커스텀 마커를 보여준다.
  • 마커에 존재하는 게시글의 제목이 표기되어야하고 마커 이미지를 넘치지 않도록 말줄임표(..)로 생략한다.
  • 동일 좌표에 여러 게시글이 있는 경우 최신글이 대표 제목으로 표기된다.

해당 조건을 만족하기 위해선 첫 번째로 좌표 기준으로 데이터를 그룹화하는 것이 중요했습니다.

 

    // * 위치 맵 설정 Map setter
    const setLocationMap = (positions) => {
      const locationMap = new Map();
      positions.forEach((pos) => {
     	// 가까운 경우 하나로 묶음 (소수점 6자리까지만 사용하여 아주 가까운 위치는 같은 것으로 취급)
        const locationKey = `${pos.lat.toFixed(6)},${pos.lng.toFixed(6)}`;
        locationMap.set(locationKey, pos);
      });
      return locationMap;
    };

    // * 그룹화된 마커 추가 함수
    const addGroupedPositions = (groupedPositions, items, locationKey) => {
      if (!groupedPositions?.length || !items?.length || !locationKey) {
        return;
      }

      if (items.length === 1) {
        return groupedPositions.push(items[0]);
      }

      const [lat, lng] = locationKey.split(",").map(Number);
      const firstItem = items[0];
      const groupId = `grouped_${locationKey}_${firstItem.type}`;

      groupedPositions.push({
        id: groupId, // 그룹 전용 고유 ID
        lat,
        lng,
        type: firstItem.type, // QUESTION 또는 ANSWER 타입 유지
        title: firstItem.title, // 첫번째 아이템의 타이틀 적용
        countTag: items.length > 99 ? "99+" : `+${items.length}`, // 개수 텍스트 태그
        originalItems: items, // 원본 아이템들을 저장
        isGrouped: true, // 그룹화된 마커임을 표시
        groupCount: items.length,
        // 클릭 이벤트는 원본 아이템들의 정보를 전달
        onClick: (pos, marker) => {
          // 여러 아이템이 있으므로 적절한 처리 필요
          console.log(`${items.length}개의 마커가 이 위치에 있습니다:`, items);
          // 필요시 사용자 정의 처리 (예: 모달이나 팝업으로 리스트 표시)
          if (firstItem.onClick) {
            // 원본 아이템들 정보를 전달
            firstItem.onClick({ ...pos, items }, marker);
          }
        },
      });
    };

    /**
     * 같은 위치의 마커들을 그룹화
     * @param {Array} positions - 마커 위치 배열
     * @returns {Array} 그룹화된 위치 배열
     */
    const groupPositionsByLocation = (positions) => {
      const locationMap = setLocationMap(positions);

      // 그룹화된 결과를 새로운 마커 배열로 변환
      const groupedPositions = [];

      locationMap.forEach((items, locationKey) => {
        addGroupedPositions(groupedPositions, items, locationKey);
      });

      return groupedPositions;
    };

 

위처럼 함수를 구성하여 각 카테고리에 대응할 수 있고, 경도 / 위도 기준으로 데이터를 활용 가능하도록 그룹화 했습니다.

 

🐢 Canvas 생성도 리다...

Canvas로 HTML을 이미지로 변환하는 과정도 소스가 크기 때문에  번에 생성하면 라우저가 순차적으로 작업을 처리하기 때문에 해당 작업을 처리하는 동안 렌더링 작업이 블로킹되어 프레임 드랍이 발생하는 문제가 생겼습니다.

 

"어떻게 이 문제를 해결 할 수 있을까?" 고민하다

BatchRAF( request animation frame )에 대한 아이디어를 떠올렸습니다!

 

⚙️ Batch & RAF 를 활용한 렌더링 최적화

렌더링 과부하 방지를 위해 마커 생성 작업을 50개 단위로 배치 처리했습니다.
각 배치 완료 후 requestAnimationFrame을 활용해 브라우저에게 렌더링 시간을 양보함으로써 메인 스레드 블로킹을 방지했고, 이를 통해 프레임 드랍을 최소화했습니다.

결과적으로 대량의 마커 생성 중에도 사용자가 지도를 끊김 없이 조작할 수 있게 되었습니다.

 

⚠️ 배치 처리 중 마커 중복 생성 이슈

추가적인 문제로 대량의 마커를 배치 처리하는 과정에서 동일한 마커가 중복으로 생성되는 문제가 간헐적으로 발생했습니다.

// 문제 발생 시나리오
배치 1: 마커 A, B, C 생성 중...
배치 2: 마커 C, D, E 생성 시작
        ↓
결과: 마커 C가 2번 생성!

 

🔍 중복 생성의 원인 탐색

배치 처리의 비동기 특성으로 인해 여러 배치가 동시에 실행되면서 발생하는 경합 상태(Race Condition)가 근본적인 원인이었습니다.

1. positions 업데이트 → useEffect #1 시작

2. useEffect #1 배치 1 처리 중 (await Promise.all 실행 중)

3. positions 재업데이트 → useEffect #2 시작

4. useEffect #2에서 existingIds 확인 시,
   아직 배치 1의 마커가 markersRef에 추가되기 전!
   
5. 두 useEffect 모두 같은 마커를 "새 마커"로 판단

 

우선 이러한 문제를 보완하기 위해 3단계의 방어책을 구상하여 적용했습니다.

 

🛡️ 1차 방어 -  초기 필터링

// useEffect 시작 시점에서 중복 제거
const existingIds = new Set(markersRef.current.map((m) => m.id));
const newPositions = processedPositions.filter((pos) => !existingIds.has(pos.id));

if (newPositions.length === 0) {
  console.log("추가할 새 마커가 없습니다.");
  return;
}

위와 같이 이미 생성된 마커를 사전에 걸러내어 1차 보완했습니다.

 

🛡️🛡️ 2차 방어 - 배치별 실시간 체크

for (let i = 0; i < newPositions.length; i += BATCH_SIZE) {
  // 각 배치 시작 시 최신 상태 확인
  const currentExistingIds = new Set(markersRef.current.map((m) => m.id));
  
  const filteredBatch = rawBatch.filter((pos) => {
    if (currentExistingIds.has(pos.id)) {
      console.log(`중복 ID 스킵: ${pos.id}`);
      totalSkipped++;
      return false;
    }
    return true;
  });
  
  if (filteredBatch.length === 0) {
    console.log(`배치 ${batchNumber}: 모두 중복, 스킵`);
    continue;
  }
  
  // ...
}

이전 배치에서 추가된 마커를 배치마다 재확인하여 2차 보완했습니다.

currentExistingIds를 배치 루프 안에서 매번 재생성하고 이전 배치에서 추가한 마커가 즉시 반영됩니다.

 

🛡️🛡️🛡️ 3차 방어 - 생성 후 최종 검증

const batchMarkers = await Promise.all(...);  // 마커 생성 완료
const flattenedMarkers = batchMarkers.flat();

// 생성 완료 후 다시 한 번 중복 체크
const finalExistingIds = new Set(markersRef.current.map((m) => m.id));
const duplicateMarkers = [];
const uniqueMarkers = [];

flattenedMarkers.forEach((marker) => {
  if (finalExistingIds.has(marker.id)) {
    duplicateMarkers.push(marker);
    console.log(`생성 후 중복 감지: ${marker.id}`);
  } else {
    uniqueMarkers.push(marker);
  }
});

// 중복 마커 제거
if (duplicateMarkers.length > 0) {
  try {
    clusterer.removeMarkers(duplicateMarkers);
    console.log(`중복 마커 ${duplicateMarkers.length}개 제거됨`);
  } catch (error) {
    console.warn("중복 마커 제거 중 오류 (무시 가능):", error);
  }
}

// 고유한 마커만 추가
if (uniqueMarkers.length > 0) {
  clusterer.addMarkers(uniqueMarkers);
  markersRef.current = [...markersRef.current, ...uniqueMarkers];
}

마커 생성이 완료된 시점에서 최종 검증하여 동시성 문제로 생성된 중복 마커를 즉시 제거하는 안전망 역할을 합니다.

 

🤔 3차 방어를 구성한 이유는...

다음과 같은 세가지 이유에서 3중 방어책을 구현했습니다.

  • 1차만 하는 경우: useEffect가 여러 번 실행될 수 있음
  • 2차만 하는 경우 : 비동기 작업 완료 전 다음 배치 시작 가능
  • 3차는 최후의 보루: 실제로 생성된 마커의 중복 제거

 

✅ 방어 성공!

결과적으로 안정적인 배치 처리 구현으로 중복 마커 생성 완전히 해결하고,

메모리 효율성이 향상되어 UX가 향상되었습니다.

 

🤔 데이터 요청이 너무 많은데? [ React-Query ]

데이터를 요청할 때 기본적으로 Axios만을 사용하고 있고, 캐싱 기능을 직접 구현해서 적용해야 했습니다.

근본적인 문제를 기술적으로 개선하기 위해 react-query 도입을 적극 추천였고, 동료들과 논의 후 긍정적으로 수용되어 react-query 마이그레이션  작업을 유지보수 기획에 추가하여 점진적 마이그레이션을 진행하는 것으로 결정되었습니다.

 

해당 컨텐츠의 경우 지도를 조작할 때마다 데이터를 요청하기 때문에 요청이 매우 빈번하고 데이터도 많은 편에 속하기 때문에 통신 데이터 캐싱 기능이 필수불가결 핵심기능이었습니다.

 

때문에 해당 컨텐츠에는 react-query로 마이그레이션을 우선 진행했고, 요청하는 endpoint를 QueryKey로 지정하여 활용하였고, 평균 데이터 조회 수를 기존 대비 약 50% 이상 절감할 수 있었습니다.

 

🪢 마무리로 디바운싱

디바운싱 딜레이를 얼마나 주어야할 지에 대한 기준을 만들기 위해 동료들과 함께 지도를 직접 조작하며 사용자 입장에서 연계되는 조작 타이밍에 대해 분석하였고, 저희는 650ms 로 결정했습니다.

 

🤔 650ms로 결정하게 된 계기는?

그 이유는 너무 길게 잡아 지도 조작 간에 데이터를 한번도 받지 못했다면 조작을 멈추고 난 후에도

"어라? 왜 아무것도 안나오지?" 하며 잘못 인지 할 가능성도 있기 때문에 동료들에게 UX관점에서의 개선점을 제기하고 직접 QA를 진행하며 실사용 UX기반으로 논의하기 시작했습니다.

 

🐢조작을 느리게 하는 사람은 중간중간 컨텐츠 마커가 렌더링되어 인지 가능하고,

🐇조작이 빠른 사람은 너무 길지 않은 시간을 기다리면 마커를 볼 수 있는 적당한 시간

 

QA를 통해 약 600 ~ 800ms 정도로 의견이 좁혀졌고

650ms정도가 적당한 듯 하다는 의견으로 종합되어 결정하게 되었습니다.

 

🎡 로딩 스피너를 통한 사용자 인지력 강화 [ UX ]

서비스를 이용하는 일반 사용자 입장에서는 프로그램이 요구한 작업을 처리하는 동안 서비스 뒤에서 일어나는 프로그래밍 로직은 일절 인지 할 수가 없습니다.

 

때문에 사용자에게 현재 프로그램이 어떤 로직을 거치고 있는지 인지시켜주는 문구를 띄우고 처리중일 때는 로딩 스피너를 띄우는 등 좋은 서비스가 되기 위한 기본은 사용자 인지력이 좋은 서비스라고 생각합니다.

 

UX/DX 지향 개발자로서 사용자가 편리하게 서비스를 사용할 수 있도록 예측 가능한 방향성을 제공하고자 마커 데이터, 사이드바  데이터, 상세 모달 데이터 등 pending일 때와 Map에서 마커를 생성하는 배치작업을 진행중일 때 Map 중심에 로딩 스피너를 보여주도록 구현했습니다.

 

🐢 Map 로딩 스피너, 너도 느리구나...

표시할 마커의 총 개수가 약 2000개, 그 이상이 될 가능성도 있는 컨텐츠이기 때문에 연속적인 마커 생성을 유도하여 배치가 쌓여 순차적으로 작업을 처리하는 환경을 만들어 테스트를 진행해 보니 로딩 스피너에서 프레임 드랍이 발생했습니다.

 

다시 한번 고민에 빠지게 되었습니다...

가장 먼저 프레임 드랍의 주요 원인으로 유추되는 것은 크게 두가지 였습니다.

 

1. 마커 생성 과정에서 이미 1 frame단위로 작업을 진행하여 로딩 스피너의 동적 작업이 끼어들 틈이 없다.

2. 지도 조작 시 리렌더링 되는 지도 타일로 인한 추가 작업 발생

 

🖥️ 로딩 스피너의 렌더링 작업 제어권을 확보하기

우선 유추한 1번의 내용을 검증해보기 위해서 raf를 더블링/트리플/쿼드러플 ... 적용해 보았습니다.

결과는 생각보다 큰 효과를 가졌습니다.

 

하지만 raf내부에 raf -> raf -> raf ... 이런 형태로 콜백지옥이 형성되어 가독성이 떨어지고 보기에도 불편한(?) 문제도 있어 간결하게 재사용 하기 위해 "waitFrames" 라는 함수를 만들어 활용했습니다.

/**
 * 지정된 프레임 수만큼 대기하여 브라우저에게 렌더링 여유 제공
 * @param {number} frames - 대기할 프레임 수
 * @returns {Promise<void>}
 */
export const waitFrames = (frames = 1) => {
  return new Promise((resolve) => {
    let count = 0;
    const tick = () => {
      count++;
      if (count >= frames) {
        resolve();
      } else {
        requestAnimationFrame(tick);
      }
    };
    requestAnimationFrame(tick);
  });
};

 

배치 사이즈를 기존에 50만큼 clusterer 훅 내부에서 고정 값으로 처리 했었지만, props로 받아 제어 가능한 형태로 리팩터링하고 default value는150으로 늘렸습니다.

 

구성했던 waitFrames 유틸을 활용하여 5 frame 간격으로 설정하였고, Map 로딩 스피너가 제어권을 갖는 시간이 확보되어 눈에 띄게 부드러워졌습니다.

 

🖥️ Kakao Map Api 타일 렌더링 최적화?

kakao map api docs를 확인해 보니 tileAnimation 이라는 옵션이 있어 SDK를 사용할 때 boolean형으로 넣어주면 지도 타일 교체 시 애니메이션을 넣을지 말 지를 결정할 수 있는 옵션이 있어 false로 지정해주었더니 예상했던 것보다 프레임 드랍률이 많이 감소하여 타 애니메이션을 더 부드럽게 최적화할 수 있었습니다.

 

🎯 최적화의 선택과 집중

Map 로딩 스피너의 최적화를 위해 배치사이즈를 늘려, 한번에 보여 줄 마커의 개수가 150개로 늘어나 사용자가 마커를 확인하는 데에 까지 걸리는 시간이 조금 늘어나게 되었습니다.

 

하지만 로딩 스피너의 존재를 정의하자면 충분히 가능한 선택과 최적화였다고 생각했습니다.

데이터가 별로 없는데도 단순히 연속적인 작업이 추가되었다는 이유만으로 로딩스피너가 버벅이면 사용자 입장에서는 "왜 이렇게 느리지?" 라는 생각을 할 수도 있기 때문에 UX를 고려했을 때 합리적이라고 판단되어 해당 작업을 선택하고 최적화 작업은 마무리 했습니다.

 

🚩 선택한 마커 포커스 효과 만들기

사용자가 선택한 마커를 명확하게 인지할 수 있도록 지도에서 마커를 클릭하면 해당 마커만 포커스되도록 구현하고자 했습니다.


하지만 Kakao Map API에서는 지도 요소 내부에 직접 오버레이를 넣어 마커의 z-index를 핸들링하며 포커싱할 수 있는 방법을 끝내 찾아내지 못했습니다😭...


그래서 떠올린 아이디어가 바로 "선택한 마커를 복제해서 오버레이 위에 띄우면 되지 않을까?" 였고, 이를 적용하여 기획상의 기능을 구현했습니다.

 

💡 핵심 아이디어

  1. 마커 클릭 시 지도를 어둡게 하는 오버레이를 표시
  2. 클릭한 마커의 이미지 정보를 추출하여 복제
  3. 복제된 마커를 오버레이보다 높은 z-index로 화면 중앙에 표시

 

🛠️ 오버레이 표시 여부와 복제된 마커 정보를 관리하는 커스텀 훅입니다.

export const useMarkerOverlay = (map, mapRef) => {
  const [isShowMapOverlay, setIsShowMapOverlay] = useState(false);
  const [clonedMarkerElement, setClonedMarkerElement] = useState(null);
  const clickedMarkerRef = useRef(null);
  
  // 오버레이 초기화 시 마커 zIndex 복구
  const resetOverlay = useCallback(() => {
    if (clickedMarkerRef.current) {
      clickedMarkerRef.current.setZIndex(MARKER_CONFIG.Z_INDEX.default);
      clickedMarkerRef.current = null;
    }
    setClonedMarkerElement(null);
  }, []);

  // 오버레이가 닫힐 때 정리 작업
  useEffect(() => {
    if (!isShowMapOverlay) {
      resetOverlay();
      // 쿼리스트링 정리
      if (query.get("lat") || query.get("lng")) {
        query.removeAll(["lat", "lng"], true);
      }
    }
  }, [isShowMapOverlay]);

  // 줌 시작 시 오버레이 자동 닫기
  useEffect(() => {
    if (!map) return;
    const handleZoomStartEvent = () => setIsShowMapOverlay(false);
    window.kakao.maps.event.addListener(map, "zoom_start", handleZoomStartEvent);
    // ... cleanup
  }, [map]);


  return {
    isShowMapOverlay,
    setIsShowMapOverlay,
    clonedMarkerElement,
    setClonedMarkerElement,
    clickedMarkerRef,
  };
};

📌 핵심 포인트

  • 오버레이 상태와 복제 마커 상태를 함께 관리
  • 줌 이벤트 발생 시 자동으로 오버레이 닫기
  • 엣지케이스를 위한 안전장치( 지도 잠김 해제 )

 

🥹  마커 복제 요소 좌표가 일치하지 않는다

포커싱 효과를 얻긴 했지만, 사실상 원본 마커와 복제된 마커의 좌표가 일치하지 않아 포커스된 마커를 보면 미세하게 겹쳐보이는 현상이 있었고, 별도로 포지션을 약 1px만 보정하는 방식으로 해보았지만 여전히 미세하게 맞지 않았고 해상도와 PPI에 따라 다시 달라지는 문제가 있어, 근본적으로 해결 가능한 방법을 모색해야 했습니다.

 

공통 사항으로 포커스된 복제 요소는 원본마커와 최대한 유사하게 복제되어 정중앙에 나타난다는 것을 고려했을 때 어차피 포커스 요소인 복제 마커가 핵심 요소라면 원본 마커의 opacity를 0으로 변경하여 보이지 않도록 하고 복제 마커만을 보여주는 아이디어를 떠올렸습니다.

 

kakao map docs를 확인해보니 마커의 투명도를 변경할 수 있는 setOpacity매서드가 있어 매서드를 활용하여 구현했지만, 복제 마커의 render phase( 계산/결정 과정 )에서 실행하기 때문에 포커스효과가 나타나기 전 아주 잠시 깜빡임이 생기는 문제가 있었고, raf를 활용하여 1 frame을 진행시킨 후 원본 마커의 투명도를 0으로 변경하여 성공적으로 구현할 수 있었습니다.

 

커스텀 마커 이벤트 훅

export const useMarkerClickHandler = ({
  markerPositions,
  map,
  lockMap,
  unlockMap,
  clickedMarkerRef,
  setIsShowMapOverlay,
  setClonedMarkerElement,
}) => {

  // ...

  /**
   * 마커 투명도 설정 헬퍼 함수
   * @param {Object} marker - 카카오맵 마커 객체
   * @param {number} opacity - 투명도 (0~1)
   */
  const setMarkerOpacity = (marker, opacity) => {
    if (!marker) return;

    try {
      // 투명도 설정 메서드 호출
      marker.setOpacity(opacity);
      console.log(`✅ 마커 투명도 설정 (setOpacity): ${opacity}`);
    } catch (error) {
      console.error("❌ 마커 투명도 설정 오류:", error);
    }
  };
  
  // ...
  
  const markerPositionsWithClick = useMemo(() => {
    return markerPositions.map((item) => {
      // 이미 onClick이 정의되어 있으면 그대로 반환
      if (item.onClick) return item;

      return {
        ...item,
        onClick: (pos, marker) => {
        
        // ...
        
        // 이전에 클릭된 마커가 있고, 다른 마커를 클릭한 경우
        if (clickedMarkerRef.current && clickedMarkerRef.current !== marker) {
          // 이전 마커의 zIndex 복구
          if (clickedMarkerRef.current.setZIndex) {
            clickedMarkerRef.current.setZIndex(MARKER_CONFIG.Z_INDEX.default);
            console.log("이전 마커 zIndex 복구:", MARKER_CONFIG.Z_INDEX.default);
          }
          // 이전 마커의 투명도 복구
          setMarkerOpacity(clickedMarkerRef.current, 1);
        }
        
        // ...
        
        setIsShowMapOverlay(true);

        // * step1 - 복제마커를 생성하는 1 frame 이 지난 후
        requestAnimationFrame(() => {
          // * step2 - 원본 마커를 투명하게 설정
          setMarkerOpacity(marker, 0);
        });
        
        // ...
      }
    });
  }, [
    // ...deps
  ]);
  
  return markerPositionsWithClick
}

 

Map 커스텀 오버레이 핸들 훅

export const useMarkerOverlay = (map, mapRef) => {
  const [isShowMapOverlay, setIsShowMapOverlay] = useState(false);
  const [clonedMarkerElement, setClonedMarkerElement] = useState(null);
  const clickedMarkerRef = useRef(null);
  
  // 오버레이 초기화 시 마커 zIndex / opacity 복구
  const resetOverlay = useCallback(() => {
    if (clickedMarkerRef.current) {
      // 투명도를 1로 되돌림
      clickedMarkerRef.current.setOpacity(1);
      clickedMarkerRef.current.setZIndex(MARKER_CONFIG.Z_INDEX.default);
      clickedMarkerRef.current = null;
    }
    setClonedMarkerElement(null);
  }, []);

  // ... 
};

 

🔐지도 조작 Lock / Unlock, 방어적 프로그래밍

지도에 마커를 클릭 시 포커스 효과를 부여했고 오버레이까지 구현했지만

복합적인 경합상태가 발생할 수 있어, 포커스 시 Map 조작을 Lock/Unlock을 통해 강제해야 경합상태로 인한 수 많은 코너케이스 발생과 통신 자원 낭비를 최소화 할 수 있었습니다.

  • 마커 클릭과 동시에 Map Lock
  • idle 이벤트를 통해 panTo 이벤트가 끝난 후 Map Unlock

그럼에도 불구하고 커스텀 오버레이가 사라지고 나서도 지도가 Lock되어 조작을 하지 못하는 엣지케이스가 발생했고 경합상태로 인한 엣지케이스로 확인되어 방어적 프로그래밍으로 안전장치를 추가하여 대응했습니다.

 

Map 강제 Lock 대응 안전 장치 추가

export const useMarkerOverlay = (map, mapRef) => {
 
  // ...

  // 엣지케이스: 지도 잠김 상태 강제 해제
  useEffect(() => {
    if (!isShowMapOverlay && isWindowRef.current) {
      const safetyUnlockTimer = setTimeout(() => {
        // 1초 후에도 지도가 잠겨있으면 강제 unlock
        if (mapRef.current) {
          const isDraggable = map.getDraggable();
          const isZoomable = map.getZoomable();
          if (!isDraggable || !isZoomable) {
            map.setDraggable(true);
            map.setZoomable(true);
          }
        }
      }, 1000);
      return () => clearTimeout(safetyUnlockTimer);
    }
  }, [isShowMapOverlay, map, mapRef]);

  return {
    isShowMapOverlay,
    setIsShowMapOverlay,
    clonedMarkerElement,
    setClonedMarkerElement,
    clickedMarkerRef,
  };
};

 

📝 성능 측정 해보기

사용중 프레임을 직접적으로 확인하기 위해 AI의 도움을 받아 테스팅 코드를 작성하여 측정을 진행하기로 했습니다.

 

측정 도구

const framePerformanceMonitor = {
  frames: [],
  lastTime: null,
  rafId: null,
  startTime: null,
  stopTimer: null,
 
  start(durationSeconds = 10) {
    // ✅ 이전 측정이 있으면 먼저 정리
    this.stop();
   
    // 초기화
    this.frames = [];
    this.lastTime = performance.now();
    this.startTime = performance.now();
   
    // 측정 시작
    const measure = () => {
      const currentTime = performance.now();
      const frameDuration = currentTime - this.lastTime;
     
      this.frames.push(frameDuration);
      this.lastTime = currentTime;
      this.rafId = requestAnimationFrame(measure);
    };
   
    this.rafId = requestAnimationFrame(measure);
    console.log(`🎬 측정 시작 (${durationSeconds}초간 측정)`);
   
    // 지정된 시간 후 자동 종료 및 리포트
    this.stopTimer = setTimeout(() => {
      this.stop();
      this.report();
    }, durationSeconds * 1000);
  },
 
  stop() {
    if (this.rafId) {
      cancelAnimationFrame(this.rafId);
      this.rafId = null;
    }
    if (this.stopTimer) {
      clearTimeout(this.stopTimer);
      this.stopTimer = null;
    }
  },
 
  report() {
    if (this.frames.length === 0) {
      console.log('⚠️ 측정된 데이터가 없습니다.');
      return;
    }
   
    const totalFrames = this.frames.length;
    const testDuration = ((performance.now() - this.startTime) / 1000).toFixed(2);
   
    // 평균 프레임 시간 계산
    const avgFrameTime = (
      this.frames.reduce((sum, duration) => sum + duration, 0) / totalFrames
    ).toFixed(2);
   
    // 평균 FPS 계산
    const avgFPS = (1000 / avgFrameTime).toFixed(1);
   
    console.log(`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 프레임 성능 측정 결과
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
테스트 시간: ${testDuration}초
총 프레임: ${totalFrames}개
평균 FPS: ${avgFPS}
평균 프레임 시간: ${avgFrameTime}ms
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
    `);
   
    return {
      testDuration: parseFloat(testDuration),
      totalFrames,
      avgFPS: parseFloat(avgFPS),
      avgFrameTime: parseFloat(avgFrameTime)
    };
  }
};

console.log(`
🎯 간편한 사용법:
framePerformanceMonitor.start(30)  - 30초 측정 후 자동 리포트
framePerformanceMonitor.start(10)  - 10초 측정 후 자동 리포트
framePerformanceMonitor.start()    - 기본 10초 측정
`);

 

위 코드를 사이트 콘솔에 붙여넣고 

 

framePerformanceMonitor.start(30) 를 실행하여

레거시 사이트에서 프레임 드랍이 일어나는 상황을 지속 연출하는 방식으로 각 사이트를 30초간 테스트 해보았습니다.

 

PHP 레거시 시스템 프레임 테스트 결과

 

React 최적화 시스템 프레임 테스트 결과

 

📊 성능 테스트 결과

항목 최적화 효율 향상 배수
FPS 향상 72.5% 3.6배 더 부드러운 애니메이션
반응 속도 효율 72.6% 3.6배 빠른 프레임 처리
프레임 생성 71.6% 3.5배 많은 프레임 렌더링

* 정확한 계산 수치가 아닌 근사치로 작성하였습니다.

 

📌UX 개선 요약

  • 15.3fps → 55.7fps ( 약 3.6배 향상 )
  • 버벅임 구간을 부드러운 구간으로 전환
  • 반응 속도 72.6% 개선으로 즉각적인 피드백

 

✍🏻 마치며...

처음으로 kakao map SDK를 통해 약 2000개의 실 데이터 기반 커스텀 마커로 지도의 프론트엔드를 구현하고 다뤄보니 다시금 기술에 대한 딥다이브의 중요성을 느끼게 되었습니다.

 

만약 기존에 쌓아온 프론트엔드 지식이 없었다면 최적화하는 데에 아이디어를 떠올리기 쉽지않았을거고, 더 많은 시간과 시행착오를 겪었을 것 같습니다.

 

특히 Promise를 활용한 batch, request animation frame를 활용한 화면 최적화 경험을 통해 프론트엔드 기술에 대한 이해도가 한 층 더 깊어진 것 같아 성과있는 경험이었습니다.

 

앞으로도 더 많은 기술을 대용량 데이터 기반으로 다뤄 볼 수 있는 기회가 찾아온다면 더 나은 사고력과 이해도를 기반으로 더 나은 가치를 위해서 개발해보고 싶습니다!

2025. 11. 22. 03:08

✏️ 개요

사내 최초 in-house 서비스로 예정된 뷰티사업 관리 시스템 MVP 개발을 거듭 진행하다 문득 이왕 사내 in-house 서비스가 될 프로젝트인데 좀 더 신경 써서 최적화하고 싶다는 생각이 들었습니다.

 

재직 중인 회사는 시니어 개발자가 없고, 신입과 경력 3년 이하 주니어 개발자들로만 구성되어 있어 기본적으로 모든 최적화나 개발 노하우를 직접 찾아내어 공유하고 적용하지 않으면 기술의 부채가 심화되기 때문에 이를 경계하며 꾸준히 더 좋은 방향성을 가져가고자 부단히 노력해 왔던 것 같습니다.

 

주말 간 최적화 방법에 대해 탐색하다 보니 프론트엔드의 기술로 코드스플리팅, webp활용, preload, image lazy load 등 이미 적용한 최적화 기술들을 많이 소개 해주고 있었습니다. 그래서 이번에는 다른 영역인 DevOps 혹은 Infra 영역에서는 더 최적화 할 수 있지 않을까? 하는 생각이 들어 탐색하다 gzip, brotli, lz4 등 압축 포맷에 대해 알게 되었습니다.

 

그 중 가장 보편적으로 쓰이고 호환율도 가장 높은 gzip을 적용해보자 했습니다.

gzip은 아주 간단한 설정만으로 작동시킬 수 있었습니다. 빌드 시 번들러 설정으로 빌드 폴더에 .gz 확장자를 가지고 있는 브라우저에서 식별가능하고 압축해제를 직접 할 수 있는 압축파일을 포함시킬 수 있고, 웹서버에서 허용 설정을 추가해주면 정상적으로 빌드된 파일을 브라우저에 띄울 수 있다는 것을 알게 되었습니다.

 

프론트엔드 역량과는 무관하다고 볼 수 있지만, 저는 UX/DX 지향 개발자이기 때문에 이를 지나칠 수 없었습니다!

번들 사이즈 감소 빠른 다운로드 빠른 렌더링으로 UX 향상을 기대하며 실무에 적용하게 되었습니다.

 

🏠 Vite로 gz 파일 생성하기

gzip활용 설정 자체는 전혀 어렵지 않습니다.

vite.config.js 파일에서 default객체의 plugins 배열에서 compression 함수를 추가하고 아래 이미지와 같이 설정해 주면 빌드 시 .gz 확장자를 가진 압출 파일이 동봉됩니다.

 

vite compression 설정 추가

 

 

설정 후 빌드를 해보면 아래 이미지와 같이 청크파일과 .gz의 확장자를 가진 파일도 함께 생성된 것을 확인할 수 있습니다.

 

동봉된 .gz 파일

 

 

🌐 웹서버에서 .gz 파일 서빙

사내 인프라는 IaaS로 Ubuntu OS를 배포 환경을 ssh를 통하여 운영하고 있습니다.

통합 원격 접속 어플리케이션인 MobaXturm을 사용해서 리눅스 계정에 접속하여 NginX의 default 파일을 설정해 주었습니다.

 

보안상 NginX 설정 부만 캡쳐했습니다

 

매우 간단한 설정만을 추가하여 gzip 설정을 할 수 있었습니다.

 

🔍 index.js로 비교 측정 해보자

실제 사용 환경(Use Case)을 반영한 비교를 위해, 네트워크 속도는 조정하지 않은 상태에서 측정했습니다.

 

gzip 압축 전

 

번들사이즈: 1,025 KB

다운로드 속도: 794ms

 

압축 전에도 성능면에서는 나쁘다기 하기보다 오히려 좋은 수치에 속합니다.

 

gzip 압축 후

 

번들사이즈: 330 KB

다운로드 속도: 169ms

 

압축 후 실제 체감으로는 페이지 접속과 동시에 화면이 보이는 느낌이 들 정도로 매우 빠른 속도로 렌더링이 되었습니다.

그리고 마지막으로 LCP를 비교해 보았습니다.

 

gzip 압축 전 LCP

 

압축하지 않은 상태도 충분히 좋은 성능을 유지하고 있습니다.

 

gzip 압축 후 LCP

 

하지만 압축 후에는 더 압도적인 성능을 보여주는 것을 확인할 수 있었습니다.

 

✨ 정리하자면

  • 번들사이즈: [ 1,025 KB330 KB ] - 약 3.1배 감소 ( 67.8% )
  • 다운로드 속도: [ 794ms 169ms ] - 약 4.7배 단축 ( 78.7% )
  • LCP: [ 1.24s  0.58s ] - 약 2.1배 단축 ( 53.2% )

 

🤔 그렇다면 다른 파일은?

"혹시 index.js 파일만 이렇게 압축이 잘 된 것은 아닐까?" 궁금했습니다. 그렇다고 파일을 하나씩 열어보는 것은 너무 미련한 짓인 것 같아 최근 회사 복지로 지원받고 있는 Cursor AI를 활용하여 직접적으로 빌드 압축률을 계산할 수 있는 방법을 물어보았고, 덕분에 유용한 터미널 명령어를 받을 수 있었습니다!

 

우선 번들 파일들을 리스트로 확인 가능한 명령어를 실행시켜 보았습니다.

 

번들 파일 압축 리스트

 

확인해 봤을 때 직접 확인했던 index.js의 번들 압축률을 보니 거의 정확히 일치하는 것을 확인할 수 있었습니다. 추가적으로 번들 압축률을 살펴보니 전체적으로 압축이 정상 진행된 것 같아 우려했던 문제는 해결이 되었습니다.

 

아래 명령어는 번들 파일 압축 리스트를 모두 뽑아주는 명령어입니다.

# 반드시 빌드 명령어를 실행하여 dist가 root 경로에 존재해야 합니다.
# 그리고 경로는 루트 경로가 아니니, 꼭 확인해주세요!
cd C:\Users\{YourProject}\dist\assets

Get-ChildItem -Filter "*.js" | Where-Object { 
    Test-Path "$($_.FullName).gz" 
} | ForEach-Object {
    $original = $_.Length
    $compressed = (Get-Item "$($_.FullName).gz").Length
    $ratio = [math]::Round((1 - $compressed / $original) * 100, 1)
    
    [PSCustomObject]@{
        파일명 = $_.Name
        원본크기KB = [math]::Round($original / 1KB, 1)
        압축크기KB = [math]::Round($compressed / 1KB, 1)
        압축률 = "$ratio%"
    }
} | Sort-Object -Property 원본크기KB -Descending | Format-Table -AutoSize

 

추가적으로 통합 압축률을 계산해서 받을 수 있는 명령어를 실행하여 아래와 같은 결과를 얻을 수 있었습니다.

 

전체적으로 약 69.16% 압축된 것을 보니 압축 효율도 괜찮게 나온 것 같아 뿌듯했습니다.

그리고 아래 명령어는 전체 압축률을 받을 수 있는 명령어입니다.

 

# 프로젝트 루트에서 실행
cd C:\Users\{YourProject}

# 전체 압축률 계산 스크립트
$originalSize = 0
$compressedSize = 0

Get-ChildItem -Path "dist\assets" -Recurse -File | ForEach-Object {
    if ($_.Extension -eq ".gz") {
        $compressedSize += $_.Length
    } elseif ($_.Extension -match "\.(js|css|html)$") {
        # .gz가 있는 파일만 계산
        $gzPath = $_.FullName + ".gz"
        if (Test-Path $gzPath) {
            $originalSize += $_.Length
        }
    }
}

$compressionRatio = [math]::Round((1 - $compressedSize / $originalSize) * 100, 2)
$originalMB = [math]::Round($originalSize / 1MB, 2)
$compressedMB = [math]::Round($compressedSize / 1MB, 2)
$savedMB = [math]::Round(($originalSize - $compressedSize) / 1MB, 2)

Write-Host "==================================="
Write-Host "📦 빌드 파일 전체 압축 통계"
Write-Host "==================================="
Write-Host "원본 크기: $originalMB MB"
Write-Host "압축 후 크기: $compressedMB MB"
Write-Host "절약된 크기: $savedMB MB"
Write-Host "압축률: $compressionRatio%"
Write-Host "==================================="

 

🚩결과

가장 큰 성과는 "번들 사이즈 최적화로 이어지는 렌더링 속도 최적화" 입니다.

 

기대한 모든 성능이 2배 이상 향상되어 UX를 지향하는 저에게는 큰 성과였다고 생각이 듭니다.

간단한 설정 대비 얻는 효과가 큰 것 같아 사내 동료들과 공유하며 자연스럽게 더 좋은 최적화나 다양한 방법론에 대해서 발전된 이야기를 할 수 있게 된 계기가 된 것 같아 더욱 뿌듯했습니다.

 

앞으로도 동료들과 함께 성장하기 위해 더 노력하며 정진해야겠다는 다짐도 다시금 굳히게 되었습니다.

2025. 11. 20. 21:50

✏️ 개요

사내에서 가장 오랫동안 유지보수/고도화를 맡고 있는 🔗창업교육 플랫폼 Scout에서 고도화 기능 추가 요청사항 중에서 사업계획서를 플랫폼 스타일의 커스텀 프레임으로 출력하고 이를 PDF로 변환하는 신규 기능이 추가되었습니다.


초기 개발 완료 보고 이후에도 고객사 측에서 콘텐츠가 프레임 내에서 잘리는 현상이 반복적으로 발생하였고, 기능 담당자가 마감 내 구현을 완료하지 못하면서, 제가 해당 기능을 분석 및 재구현하는 역할로 투입되었습니다.

 

 

❓ 문제와 원인 탐색

해당 문제가 수면 위로 올라오기 전까지는 타개발 업무를 진행하고 있어 몰랐지만, 대표님께서 신뢰하는(?) 저를 불러 코드 한번 분석해 보고 피드백 달라고 전달해 주셔서 자세하게 버그가 나는 케이스를 찾기 위해 가장 먼저 QA를 진행했습니다.

 

다양한 케이스로 QA를 해본 결과 단순하게 작성한 글만 제대로 정상적으로 생성되고 줄 바꿈이 연속으로 일어나거나 이미지를 여러 개 추가하게 되는 경우에 콘텐츠가 들어갈 여백이 남아도 페이지가 넘어가거나, 잘리거나, 페이지 자체가 스킵되는 심각한 문제가 있었습니다.

 

코드를 직접 확인해 보니 바이브 코딩만으로 구현한 듯 보였고 관련 파일이 약 5개였는데 하나의 파일당 약 7000줄의 코드로 작성되어 있어 분석이 매우 어려운 상황이었습니다. 그래도 반나절의 시간을 투자하여 문제를 파악했습니다.

 

결과적으로 잘못된 기준으로 계산하고 있었고 이어지는 로직으로는 설계 자체가 기능을 구현할 수 없는 방향으로 구현되어 있다는 결론이 나왔습니다.

 

단순하게 풀 수 있는 로직을 반복의 반복을 반복하며 결과를 만들어내어 비효율적이고 혼잡하여 유지보수가 불가능한 수준으로 판단하게 되어 2주의 시간이 넉넉한 시간이 아닌, 터무니없이 부족한 시간으로 반전되는 순간이었습니다.

 

 

⛏️ 설계부터 다시

가장 먼저 본질적인 문제를 해결하기 위한 방향으로 설계하고자 했습니다.

다른 문제는 기본적으로 구현 난이도가 쉬웠으나, 커스텀 A4사이즈를 기반으로 json형태의 Text와 이미지를 잘림 없이 콘텐츠를 채우고, 페이지를 넘기는 기능을 구현하는 것이 생각보다 난이도 높았기 때문에 가장 우선 목표로 설계하였습니다.

 

 

🚨콘텐츠가 잘리거나 채우지 않고 넘어가는 문제

첫 번째로

가장 문제가 되는 부분은 바로 잘못된 대상을 기준으로 계산하는 문제였습니다.

단순히 초기 렌더링의 DOM을 기반으로 Layout 단계에서만 수치적 계산을 진행하고 실제 보이는 View와 미세한 규격 차이가 발생하여 제대로 된 페이지 넘김이나 생성이 어려운 문제가 있었습니다.

 

두 번째로

Text를 재귀하며 절삭하고 계산할 때 줄 바꿈에 대한 고려를 제대로 하지 못한 문제였습니다.

줄 바꿈을 절삭할 때 잘못된 로직으로 줄 바꿈이 절삭될 때 실제 요소에서 줄 바꿈의 높이만큼 취급되지 않거나, 단순히 공백으로 인식하는 문제가 있었습니다.

 

 

🔑 커스텀 PDF 프레임 규격 일치율 100%를 목표로

결과부터 말씀드리자면, 커스텀 PDF 프레임 규격 일치율 100%를 목표로 설계하고 개발하여 브라우저 View에서 보이는 수치를 정확히 반영하며 화면에 정상적으로 출력되는 PDF형 컴포넌트를 구현했습니다.


텍스트를 한 글자씩 계산/비교하며 오차와 리스크를 최소화하고, 콘텐츠 요소를 감싸는 wrapper의 height를 기준으로 내부 요소가 초과할 경우 남은 콘텐츠를 다음 페이지로 이어 재귀적으로 배치하도록 설계했습니다.


가상 measure 구조를 기반으로 카테고리별 콘텐츠를 점진적으로 커스텀 A4 프레임에 채우고, 모든 페이지 생성 완료 시 로직을 종료하며 각 페이지 우측 하단에 넘버링을 추가하여 차례대로 렌더링 되도록 구현했습니다.

 

✨ 정리하자면

  • 목표: 커스텀 PDF 프레임 규격 일치율 100%, 화면과 동일한 출력 보장
  • 접근 방법: 텍스트를 한 글자씩 계산/비교하며 오차와 리스크 최소화
  • 콘텐츠 Fit 처리:
    • 실제 콘텐츠를 감싸는 wrapper 요소의 height 기준
    • 초과 시 남은 콘텐츠를 다음 페이지로 이어 재귀적 배치
    • 가상 Measure 구조 활용: 정확한 위치와 크기 계산 기반
  • 페이지별 로직: 카테고리별 콘텐츠 점진적 배치 → 모든 페이지 생성 완료 시 로직 종료
  • 부가 기능: 각 페이지 우측 하단에 페이지 넘버링 추가
  • 결과: 안정적이고 규격에 맞는 PDF형 컴포넌트 렌더링 완성

 

 

🐢 완벽하지만, 느리다...

데드라인이 너무 가까워  "커스텀 PDF 프레임 규격 일치율 100%"를 최우선 목표로 정하고 안전성이 높은 방향으로 개발은 우선 완수했지만 안전성을 위해 구현했던 한 글자씩 넣으며 비교/계산하는 방식이 너무 많은 연산을 일으켜 Text 내용이 많아질수록 메인스레드에 부하가 커져 생성 완료까지의 시간이 너무 오래 걸린다는 문제가 있었습니다.

 

사실상 Text를 한 자씩 자르며 실제 요소에 넣어보고 비교하는 방식으로 생성하다 보니 어찌 보면 당연한 이슈였으며 극단적인 케이스로 얼마나 비효율적인 시스템인지 성능을 확인해 볼 필요가 있었습니다.

 

그 예시로 81페이지의 약 90% 이상이 Text로 가득 찬 사업계획서를 작성하여 테스트해 보았고 약 18초 정도소요되는 것을 확인하였고, 사용이 불가능한 수준이라고 판단되어 예정되어 있던 점진적 리팩터링을 시작했습니다.

 

 

💡 단순하면서도 본질적인 해법은?

가장 먼저 떠올린 것은 "이진 탐색 알고리즘"이었습니다.

한 글자씩 비교하는 본질적인 문제를 개선해야 하기 때문에 한 글자씩 탐색하는 것이 아닌, 최적의 해를 만들기 위해선 이진 탐색을 활용하면 좋겠다! 라고 생각했습니다.

 

다음으로 떠올린 방법은 raf( request animation frame )을 활용하는 것이었습니다.

 

 

🐇 신속한 아이디어 검증

이론적으로 확신을 가지고 떠올린 이진 탐색 알고리즘 반영은 콘텐츠 연산 관련 코드를 전체적으로 변경해야 하기 때문에 비교적 규모가 큰 반면,  raf를 적용하는 것은 비교적 간단하고 빠르게 변경/적용할 수 있어 우선 검증을 해보기로 했습니다.

 

결과적으로 생성까지 약 40초 이상으로 정도로 오히려 더 느려졌지만, 로딩스피너의 렌더링은 최적화되었으며 브라우저 자체 부하는 덜어진 것을 확인할 수 있었습니다.

 

두 번째로 떠올린 raf의 활용은 저의 오판이었습니다.

화면(렌더링) 최적화와 실질적인 연산의 최적화는 개념 자체가 다르다는 것을 순간 망각했었던 것 같습니다. 그렇게 검증까지 거친 후에서야 스스로 너무 안일했다는 생각이 들었습니다🥹...

 

raf는 렌더링 타이밍(fps)에 맞춰 매서드 등을 실행하기 위함이지, 단시간에 연산을 최적화하기 위해서가 아니라는 것을 다시금 마음에 새기는 계기가 되었습니다.

 

 

🚀 이진 탐색을 활용한 최적화, 그 결과는?

우선 수치화된 지표가 필요하여 측정을 위한 performance hook을 만들기로 했습니다.

 

간단히 구현한 performance 매서드를 활용한 측정 타이머 훅

 

 

위처럼 구현한 훅을 활용하여 먼저 이진 탐색 적용 전의 컴포넌트에서 PDF 규격 컴포넌트를 모두 생성하는데 얼마나 걸리는지 측정해 보니 아래와 같이 이전에 직접 세어본 18초와 유사한 18.7초 정도로 측정되었습니다.


알고리즘 적용 전 PDF 규격 컴포넌트 생성 완료 시간


 

 

다음으로 이진 탐색을 적용한 컴포넌트에서 생성 속도는 아래와 같이 1초가 채 되지 않는0.6초가 소요되었습니다.


이진 탐색 알고리즘을 활용한 PDF 규격 컴포넌트 생성 완료 시간


 

 

🚩결과

가장 큰 성과는 아무래도 "이진 탐색 알고리즘을 활용한 PDF 규격 컴포넌트 생성 완료 시간 단축" 입니다.

 

생성 완료 시간 [ 18784.40ms 633.80ms ] - 2,863% 향상( 약 96.6% 시간 단축 )

 

위와 같이 알고리즘의 활용은 효율면에서 극단적인 차이를 보여 줄 수 있다. 라는 결과를 직접 적용하여 확인할 수 있는 경험이었고 어떤 상황에서 어떠한 알고리즘을 활용할 수 있는지가 매우 중요하다는 생각이 들었습니다.

 

직접 특정 알고리즘을 구현 할 수 있는 능력도 중요하다고 생각하지만, AI가 상용화되고 발전한 지금에서는 어떠한 상황에서 어떤 알고리즘을 활용하면 더 효율성을 높일 수 있겠구나. 하고 알고리즘 활용 아이디어를 떠 올리는 능력 또한 중요해진 것 같습니다.

 

그 만큼 단순 코딩 능력보다는 확장된 사고력을 유연한 창의력과 함께 문제를 해결하고, 더 좋은 성능을 구현하여 사용자에게 제공할 수 있는 능력을 가진 개발자가 미래에는 더 인정받는 좋은 개발자이지 않을까? 하는 생각이 들어 앞으로도 꾸준히 딥다이브 하고자 하는 마음이 굳혀지는 계기가 되었습니다.

2025. 11. 15. 17:57

✏️ 갑자기 서버, 그것도 Python 서버를 개선하게 된 계기

저는 프론트엔드 개발자입니다.

 

제가 재직중인 회사는 SI/SM 스타트 기업으로 스타트업 & SI/SM 회사의 특징으로 필요하다면, 혹은 해야한다면 응당 누군가는 반드시 기술을 구현해서 서비스를 제공해야합니다.

 

고객사 측에서 유지보수/고도화를 진행하고 있는 🔗LMS 플랫폼에서 작성한 사업계획서를 시장에서 가상 검증이 가능하도록 LLM을 탑재한 페르소나형 챗봇 서비스를 요구했고 고객사와 회의 중 대표님께서 기술 스택으로 확장성을 고려하면 Python이 좋겠다고 이야기 했고 이내 신입 Python개발자를 채용해주셔서 개발이 순조롭게 진행되는 듯 하였습니다.

 

하지만 급하게 다른 프로젝트가 생겼는데 마침 요구 스택이 Python이었고 그렇게 production에 배포하기 전에 신입 python개발자는 사무실을 떠나 파견을 가버렸습니다...

 

당장 급한 상황에서 Python을 개발해본 사람은 아무도 없었고 대표님께서는 혹시 해보고 싶은 사람 있냐고 물어봤지만... 말이 쉽지 사실을 또 다른 언어를 한다는게 부담스러운게 사실이었습니다.

 

저는  오히려 지금이 Node.js 외 언어 서버를 직접 경험할 수 있는 기회다 싶었고 어차피 퍼블리싱만 되어있던 챗봇 화면까지 같이 통합해서 작업하면 개발효율도 좋으니 챗봇 서비스를 통합 구현하겠다고 전달 드려 승인받았습니다.

 

다른 서버 포지션도 아니고 프론트엔드 개발자이지만 제 🔗취준생 시절 포트폴리오가 풀스택이었고, 🔗Next.js Serverless Architecture를 활용한 풀스택 개발 경험을 믿고 맡기신 것 같았습니다.

 

🔗그렇게 AI 시장 검증 챗봇 서비스를 통합 개발하게 되었습니다.

 

📘 기초는 이해하자

Python을 처음 접하는 만큼 서버가 돌아가고 있으니 기존에 개발되어있는 코드에서 추가 기능와 요구사항을 적용하며 배울 수 있겠다 싶었습니다. 그렇게 프로젝트 소스를 받아 열어보았는데 일단 기본적으로 언어 자체가 이해하기 어려운 구조는 아니었습니다.

 

다만, 개발되어있던 프로젝트의 아키텍처가... 없었습니다.

단일 폴더에 내부에는 app.py와 .env만 존재하는 형태로 상당히 당황스러웠던 기억이 있습니다. 아마도 실무에서 프로젝트 처음 진행해보고 같은 언어를 사용하는 선임이 없어 방향성을 잡지 못한 상태였고, 초기 개발단계라서 그랬던 것 같았습니다.

 

분명 Python에도 디자인패턴과 지향하는 아키텍처가 있을 것이고 Python이라는 언어에 대해서 어느정도 이해를 하고 있어야 분명 개발할 때 해메지 않을 것일거라 생각하고 하루 날을 잡고 구현하며 학습하였고 다양하고 방대한 학습 자료들이 있었기 때문에 실무에 바로 도입하기 위한 전체적인 프로세스와 필요한 기능을 구현하기 위한 지식을 학습하기 위해 선택하여 집중하기로 하여 아래와 같이 집중 학습 내용을 정리했습니다.

 

1. 프론트엔드 개발자로서 JS가 익숙하다. "기본 작동방식에 차이점이 있는가?"

2. 패키지관리 파일이나 폴더가 보이지 않는다? "보편적으로는 라이브러리 패키지 관리를 어떻게 하는가?"

3. 자바에는 유명한 MVC패턴으로 관심사 분리를 한다. "보편적인 디자인 패턴이 있는가?"

4. 개발기간이 넉넉한 것이 아니기 때문에 개발 속도도 중요하다. "나에게 익숙한 Nest.js와 유사한 패턴이 있는가?"

 

우선 기본적으로 싱글스레드 기반 블로킹방식으로 작동한다는 사실을 알게 되었고,  __init__.py 파일을 통해 내보내는 방식 또한 알게 되었습니다. 그리고 pip라는 패키지 매니저가 있으며, venv를 활용하여 node_modules처럼 프로젝트 단위에 패키지를 적용할 수 있다는 것을 알게 되었으며 " pip freeze > requirements.txt" 명령어를 통해 package.json처럼 프로젝트 패키지 스팩을 만들고 해당 스팩을 통해 명시 패키지만 다운로드할 수 있다는 사실을 알게되었습니다.

 

이렇게 하나씩 공부하며 챗봇 서버를 리팩터링을 우선으로 진행하였습니다.

 

📚 이가 없다면 잇몸으로, 커스텀 디자인패턴 도입

일단 개발기간을 산정했을 때 당장 MVP패턴과 유사하게 구성하는 것은 문제가 있었습니다.

때문에 4번째로 고민했던 부분인 나에게 익숙한 패턴이 있는가에 대해서 분석했을 때 디자인 패턴을 Nest.js와 유사하게 가져가기로 했습니다.

 

Controller, Service, Repository, Module로 관심사를 분리하려 했으나... 초기 러닝커브와 짧은 데드라인을 고려하여 Controller, Service만 관심사를 분리하기로 했습니다. 데이터 형을 지정하기 위한 class를 별도로 구성하지 않았던 상태여서 추가적으로 도입할 경우 꽤나 작업이 커지기 때문에 Model을 생략하였고(기존 코드 그대로 타입 직접 명시) Module의 경우 마찬가지로 Python에 대한 깊은 이해도 없이 접근하기에 무리가 있어 보여 생략하였습니다.

 

추가적으로 확장 가능성에 대한 부분도 염두에 두어 조금 더 세분화하고 관리에 용이하도록 src폴더로 감싸고 config, main, etc 등으로 커다란 카테고리를 구분했습니다.

 

커스텀 디자인 패턴 폴더 구조

 

임시 디자인 패턴이라 아쉽기도 하고 추 후에 Python 개발자 채용 계획이 있다고 하셨으니 그때 한번 제대로 배워보고 싶다는 마음가짐도 생겼습니다.

 

❓ LLM 챗봇인데 취소기능이 없다

현재 종사하고 있는 회사에는 기획자와 PM이 없습니다.

 

때문에 각자 주포지션은 있지만 기획은 매번 다 함께 할 수 밖에 없는 환경이다보니 놓치는 기획이나 당연히 있어야할게 없어서 뒤늦게 추가되는 경우도 종종 있었습니다.

 

이번이 그런 상황이었습니다. 디자인까지 모두 완성되었지만 취소 버튼이 디자인 되어있지 않아 개발을 하고 나서 QA를 하는데 "어라? 생각해보니 GPT에는 취소 기능이 있는데?" 라는 생각을 하게 되었습니다.

 

본질적으로 "왜 필요한가?"를 먼저 생각해 보았습니다.

 

스스로 떠올린 첫 번째 케이스는 바로,

당시 LLM API 특성상 응답이 꽤나 느렸고 만약 응답을 기다리다 응답을 받지 못한 상태에서 브라우저를 급하게 끄거나 다른 화면으로 이동하는 케이스를 생각해보았습니다.

 

그렇게 시간이 흘러 어느날 다시 챗봇에게 질문하기 위해 사이트에 접속해서 보았는데 "어라? 나는 이런 질문을 한 적이 없는데 왜 이런 질문과 결과가 있지?" 라는 불안감이 들기 시작할 수 있을 것 같습니다.

 

당시 상황이 급박하여 기억에서 희미해질 수 있기 때문에 오히려 요청 취소기능은 반드시 있어야겠다는 생각이 들었습니다.

 

두 번째 케이스로는 단순히 사용자가 잘못보내서 취소하고 싶은 경우입니다.

너무 당연한 이유이기 때문에 부연설명은 하지않겠습니다...

 

네, 이렇게 고민해보니 UX를 생각했을 때 당연하게 있어야 할 기능으로 보입니다.

 

때문에 이에 대한 문제를 공론화하고 개발을 진행하기로 했습니다.

 

❓ 요청 취소가 안된다

이유는 아주 단순했습니다. Python은 싱글스레드 동기 작동방식으로 Blocking 처리 방식입니다.즉, get 요청이 들어왔다고 가정하면 해당 요청이 끝날 때까지 다음 요청은 Blocking되어 실행되지 않고 기다렸다가 작업이 끝나야 다음 작업을 실행하게 됩니다.

 

그렇다면 취소를 하기 위해서는 작업중인 매서드에 간섭 가능하도록 제어권을 가져야 하기 때문에 이벤트 루프의 존재가 필수 불가결입니다.

 

기존에 사용하고 있는 라이브러리를 확인해보니 pysql, requests를 활용하여 모두 동기식으로만 처리하고 있어 요청 취소가 불가능한 구조였기 때문에 프론트엔드에서 제 아무리 AbortController를 통해 통신을 취소하려 해도 불가능했던 것이였습니다...

 

🛠️ Blocking ➔ Non-Blocking 전환

통신 관련 라이브러리를 [ pysql, requests  ➔ aiomysql, aiohttp ] 로 전환하여 비동기 처리가 가능한 awaitable 구조로 전환했습니다. 코드 전체를 확인하며 컨버팅을 해야 해서 꽤나 대규모 작업이었지만 할 수 밖에 없는 상황이니 만큼 컨버팅을 진행했습니다.

 

그렇게 http요청과 I/O 작업 시 await을 통해 작업 제어권을 가질 수 있는 구조가 되어 챗봇 요청 취소 기능을 구현했습니다.

 

추가적으로 non-blocking의 특징으로 동시성 처리 효율이 확보되어 많은 사용자가 동시간대에 요청을 했을 때 이전 구조보다 명확하게 응답대기 시간이 줄어들었습니다.

 

스스로 생각해봤습니다. 그렇다면 얼마나 차이가 날까?

그래서 K6를 활용하여 200건 동시 요청 통해 확인해 보았습니다.

 

Blocking 구조 - 12.5s

 

기존의 Blocking 방식은 200건 동시 요청 시 모두 응답받기까지 12.5초정도의 시간이 소요됩니다.

 

Non-Blocking 구조 - 7.4s

 

Non-Blocking 구조에서는 총 응답 시간이 7.4초로 감소약 1.7배의 성능 개선을 보여주는 것을 확인할 수 있었습니다.

 

❗ 로그인 상태를 검증하지 않는다

기존 구성되어있던 서버에서 회원 전용 기능임에도 요청 header에 담긴 토큰을 검증하는 로직이 없어 비로그인자도 요청하고 응답을 받을 수 있는 문제를 발견했습니다.

 

이는 매우 치명적인 문제로 즉시 도입하기 위해 기존 main API 서버인 Java 서버에 존재하는 토큰 검증 endpoint로 요청하여 공용으로 활용 가능하도록 auth_guard라는 데코레이터로 구현하고 재사용하여 문제를 해결했습니다.

 

🚩결과 정리

다음과 같은 문제와 해결책을 강구하여 성과를 얻었습니다.

  • 디자인패턴이 없이 app.py에 모든 코드가 작성되어 유지보수 어려운 형태
    ➔ 짧은 데드라인을 고려하여 가장 익숙한 Nest.js 디자인 패턴을 모방하여 커스텀 디자인 패턴 적용

  • 챗봇 요청 취소 기능이 없음
    ➔ 문제를 공론화 하여 기획 추가 및 기능 구현

  • Python 서버가 동기적으로만 작동하여 취소를 할 수 없는 문제
    ➔ [ requests aiohttp ], [ pysql  aiomysql ] 비동기 처리가 가능한 라이브러리로 마이그레이션
    ( 200건 기준 동시 요청 처리 효율 1.7배 향상 )

  • 로그인 상태를 검증하지 않는 문제
    ➔ 짧은 데드라인을 고려하여 main API Java서버에 존재하는 토큰 검증 endpoint로 요청하여 검증하는 auth_guard 데코레이터를 구현하여 재사용
2025. 11. 14. 23:20

* 본 포스팅 내용은 2025.01에 실적용한 기술이며, 도입 과정에서 작성해둔 내용을 기반으로 서술하였습니다.

✏️ 개요

사내에서 처음으로 프로젝트/테크 리더를 맡아 주도적으로 개발을 진행했던 🔗JA Korea - Finance Park 프로젝트에서 Three.js를 도입한 서비스를 개발하다 겪은 직접 QA를 하다 발견했던 Corner case Fix 경험담입니다.

 

우선 Three.js를 활용하게 된 계기부터 말씀드리자면 초기 기획에서 사용자가 학생이라는 점을 고려하여 재미있고 동적이었으면 좋겠다. 라는 의견이 나와 3D오브젝트의 활용까지 고려 가능한 Three.js를 도입하는 것이 좋을 것 같아 기술 제안을 하였고 "확장 가능성과 애니메이션 최적화를 고려했을 때 적합하다" 라는 의견으로 취합되어 채용하게 되었습니다.

 

사실 해당 프로젝트는 긴급하게 수주한 건이었고 약 2개월만에 MVP를 완성해야 하는 프로젝트였기 때문에 Three.js의 도입을 처음에는 망설였지만, 취업준비 당시 Thee.js를 독학하여 🔗3D 포트폴리오를 만들어본 경험이 있어 주도하여 대표님께 프로젝트를 맡겠다고 제안하였고 채택되었습니다. 긴급하게 고객사에 제공해야 하는 만큼 빠르게 개발하기 위해 Next.js + Prisma 기반 서버리스 아키텍처를 선택하여 긴급하게 시작했습니다.

 

❓ 나는 어떤 서비스를 개발하는가

우선 Finance Park에 대해서 소개드리자면 JA Worldwide 글로벌 재단이 존재하고 한국 지부로 JA Korea 사업단에서 주도하는 경제 학습 플랫폼입니다.

 

총 9가지 섹션의 소비 컨텐츠가 Map Object위에 존재하고 상호작용을하며 경제 활동 시뮬레이션을 통해 체험하고 예측 불가한 지출/수입에 대한 이벤트도 존재하며 현금과 신용카드를 활용하고 투자/예금 등 다양한 경제 활동을 시뮬레이션 할 수 있는 플랫폼입니다.

 

국제 사업단인 만큼 다양한 나라에서 저학년 학생들에게 학습 목적으로 Finanace Park 플랫폼을 만들어 서비스를 하고 있습니다.

 

저희가 레퍼런스로 잡은 플랫폼은 홍콩에서 제작한 🔗Finance Park - Hong Kong이었습니다.

 

기본적으로 시뮬레이션 입장 시 Map같은 화면을 움직이며 클릭하고 경제활동을 시뮬레이션할 수 있는 학습 서비스를 제공하고 있었습니다.

 

🚨 시작부터 난관

무난하게 첫 세팅을 끝내고 서버 작업을 본격적으로 시작하려 했지만 이전부터 주 SI/SM 고객사에서 추가적인 고도화 작업을 요구하여 서버작업은 일시 중단 되어 작업기간이 더욱 더 촉박해질 수 밖에 없는 상황이었습니다.

 

다행이 이를 인지한 대표님께서 인원을 추가 채용하여 제가 프론트엔드에 주력할 수 있도록 백엔드 포지션인 동료를 함께 참여 시켜주셨습니다. 덕분에 빠듯하고 빠른 이터레이션을 요구하는 상황에서도 좀 더 신경써서 프로젝트를 진행할 수 있게 되었습니다.

 

하지만 같이 프로젝트를 진행하게 된 새로운 동료분께서 현재 Next.js의 버전이 14인데 앞으로 유지보수를 생각하면 15버전으로 진행하는게 좋다고 하였고, 조금 걱정이 되었지만 수용하는 태도 또한 중요한 덕목이니 도전하는 마음으로 "이 또한 나의 성장의 밑거름이다!" 생각하며 즉시 15버전으로 마이그레이션을 하고 작업을 진행하였습니다.

 

허나, 이 것이 화근이었고 React + Three.js와 활용하기 좋은 React Three Fiber( 일명 R3F )가 아직 Next15를 지원하지 않는 것이었습니다. 그래도 괜찮을 것이라 굳게 믿으며 작업을 진행했는데 결국 모든 기능을 Next/React 스럽지 못하게 개발을 하게 되었습니다...

마치 Vanilla JS처럼 구현하게 되었습니다ㅠㅠ

 

다음 마이그레이션을 기약하며 아쉬운 마음으로 우선 작업기간이 짧은 만큼 기능 구현에 힘을 싣고 개발을 진행했습니다.

 

🛠️ Three.js Camera 포지션 제한, 제어 구현하기

우선 이왕 만드는거 레퍼런스 사이트보다는 더 향상된 품질로 만들고 싶었습니다.

저희 팀에서 확인했을 때 크게 3가지 문제가 두드러지게 나타났습니다.

 

    1. 시뮬레이션 화면에 입장 시 미친 듯이 돌기 시작하는 노트북의 팬
    ➔ 기본적으로 구현에 급급하였는지, 메모리 누수가 심각한 수준

 

    2. 사이드바에 가려지는 경제활동 컨텐츠
    ➔ 3가지 컨텐츠를 Map화면에서 즉시 볼 수 없고 사이드바를 접거나 직접 사이드바에서 접근해야 함

 

    3. 줌인과 줌아웃, 포커싱 등 Three.js를 활용한 Map화면 장점의 부재
    ➔ 동적이고 재미요소를 줄 수 있는 장점이 있음에도 활용하지 않고 단순하게 표현 부분이 아쉬웠음

 

우선 기본적으로 디자인 된 Map형태 이미지를 최대한 최적화하여 Webp로 추출하였고 Three.js를 활용하여 Plane Mesh를 만들어 이미지를 Map의 바닥 오브젝트로 구현하였습니다.

 

그리고 맵의 크기 만큼 카메라 포지션이 고정되도록 계산하여 제한하는 함수를 만들었고

문제는 이제부터 시작이었습니다.

 

💫 NaN... 이 수모를 잊지 않겠다...

뭐야, 왜 안돼?

 

분명 제대로 한 것 같았습니다.

만약 x, y, distanceZ params가 제대로 number형이 아니라면 typescript가 컴파일에서부터 에러를 발생시키기 때문에 제대로 문제가 생기면 에러를 발생시키기 때문에 디버깅은 문제 없을거라고 생각했습니다.

 

하지만... 이상하게 단위 테스트를 진행하며 개발하는데 어느 순간 제대로 작동하지 않고 카메라 핸들링 기능 자체가 멈춰버리는 상황이 생겨버렸습니다.

 

상세히 테스트를 해 보았고 기나긴 케이스 테스트 끝에 x, y, distanceZ가 마우스를 드래그한 채 브라우저 바깥영역으로 나갔다가 들어오는 경우에 브라우저 내 이벤트 입력 간격이 매우 짧아 졌을 때 x, y, distanceZ에서 NaN이 들어오게 되는 엣지케이스를 발견하게 되었습니다.

 

NaN는 number타입이기 때문에 typescript에서 에러를 발생시키지 않은 것이었습니다...

typescript로 number니까 undefined나 null이면 제대로 디버깅이 될 것이라는 생각을 한 것 자체가 안일했구나... 하며 반성을 하게 되는 계기였습니다.ㅠ

 

만약 String을 Number로 형변환을 강제하는 로직이라면 반드시 isNaN으로 검증하자! 라는 교훈을 얻었습니다.

 

isNaN을 추가하고 해결

 

이렇게 isNaN을 통해 x, y, distanceZ의 타입을 초입에 검증하는 방식으로 어느 순간 카메라가 핸들링 되지 않는 코너케이스를 해결한 경험을 쌓았습니다...

 

🧨 이번에는 맵 이탈...

이번에도 단위 테스트를 진행하다가 얻게된 코너케이스 경험담입니다.

줌 기능을 추가로 구현하고 여러 케이스를 테스트를 하다보니 어느 순간 맵 바깥 영역이 보이기 시작했습니다.

바로 최대로 줌인을하고 모서리 끝으로 이동 후 줌아웃을 하면 제한된 영역 바깥이 보이는 코너케이스가 발견되었습니다.

 

이 문제는 단순히 드래그앤 드랍으로 카메라 포지션을 x, y 축으로 제한 이동하던 프로세스를 기반으로 아이디어를 만들어 단위 정복하여 기능을 추가하고 해결했습니다.

 

  1. 휠 이벤트가 발생 시 줌 레벨을 기반으로 수치화된 값을 얻는다
  2. 수치화된 값을 제한된 Map 바깥 영역 수치에 넘어서지 않는지 검증한다.
  3. 검증을 기반으로 넘치는 수치와 방향을 저장한다.
  4. 저장된 방향과 수치를 Commit하여 실제 기능을 수행한다.

위와 같은 4가지 단계로 나누어 설계하고 프로세스를 구현하여 성공적으로 문제가 되던 코너케이스를 해결하였습니다.

 

🔗 코너케이스가 해결된 시연 영상

 

🚩결과

가장 큰 성과로 첫 번째는 "Number로 형변환 되는 경우 반드시 isNaN을 통한 검증 거치자"라는 교훈을 얻은 것이었습니다.

Typescript가 만능은 아니다. 라는 것을 깨닫고 zod를 활용하는 이유에 대해서도 더 알아보는 계기가 되었습니다.

 

두 번째로는 기능을 구현 할 때에는 단위정복을 해 나아가는 동시에 [ 계산단계 ] ➔ [ 커밋단계 ] 처럼 계산 후 마지막으로 적용하는 관심사 분리 설계에 대한 중요성을 다시금 깨닫는 계기였고, 이를 통해 React의 Fiber 아키텍처에서 Render phase와 Commit phase로 나누어 설계된 의미를 작게나마 이해 할 수 있었습니다.

2025. 11. 13. 23:59

* 본 포스팅 내용은 2024.09에 실적용한 기술이며, 도입 과정에서 작성해둔 내용을 기반으로 서술하였습니다.

 

✏️ 기술을 도입하게 된 계기

사내 첫 in-house service 예정 프로젝트를 개발하는 과정에서 생긴 문제로, MVP개발 단계이기 때문에 잦은 수정으로 프론트/백엔드 개발자 간 소통이 잦았으며, 매우 중요하기도 했습니다.

 

둘 중 한 포지션에서 실수를 하거나 변경사항을 전달하지 못한다면 오히려 소통의 비용이 더 늘어나고 작업기간 지속 지연되는 문제가 있었습니다.

 

문제점 요약

  • Swagger 문서와 실제 API 타입 간 불일치
  • 백엔드 스펙 변경 시 프론트엔드 타입을 직접 수정해야 하는 번거로움
  • 매번 axios 호출 함수를 수동 작성 → 반복 작업 증가
  • 실수로 인한 타입 오류 및 런타임 에러 발생

결국, API 변경 시마다 "이게 body에 들어가는 필드 맞나요?" 혹은 "string이 아니고 number인가요?" 등의 소통이 반복되었고, 개발 효율이 점점 떨어졌습니다.

 

 

DX / UX를 지향하는 저는 다시금 스스로에게 질문을 던졌습니다.

"이러한 문제를 어떻게 해결할 수 있을까?"

 

 

취업준비 기간에 만든 풀스택 프로젝트인 🔗ZETE - AI 메모 서비스를 개발할 때 그런 생각을 한적이 있습니다.

api를 만들고 Swagger에 spec을 명시하는데 이걸 다시 프론트엔드로 끌고와서 자동화 할 순 없을까?

 

병아리 개발자인 내가 이렇게 생각하는 기능이라면 분명히 아득히 먼 선배 개발자들이 모두 만들어 두었을거라는 가정을 가지고 탐색해 보았고 첫번째 검색에서 바로 찾을 수 있었습니다.

 

그리고 OpenAPI와 openapi-generator-cli 에 대해 알게 되었고 즉시 공식문서를 확인하며 긴 노력 끝에 React + Nest.js를 사용한 프로젝트에 적용시켜 이 후에는 월등히 빠른 속도로 풀스택 개발을 진행할 수 있었습니다.

 

그렇게 활용했던 openapi-generator-cli 를 도입하면 많은 것이 해결될 것 같다는 생각이 들어 동료과 논의 후 대규모 리팩터링인 만큼 기술건의의 주체인 만큼 책임감을 가지고 업무 외 시간(아무도 개발을 하지 않는 시간)을 활용하여 직접 구축하여 통합 적용했습니다.

 

🪜 도입과정

가장 먼저 openapi-generator-cli를 설치했습니다.

npm i @openapitools/openapi-generator-cli

 

🥄 꺼내기 ( Eject )

eject한 jar파일 경로

 

기존에 찾아내어 적용한 방식대로 설치 후 node_modules에서 templates 파일을 eject하기 위해 jar파일을 찾아내어 복사 후 압축을 풀고 mustache파일을 꺼냈습니다.

 

🪄 설정파일 & 명령어 추가

openapi-generator-cli 생성 명령어가 워낙 길기도 하고 auth / api 두가지 카테고리로 서버가 분류되어 있기에 작업하기 좋은 환경 즉, DX향상을 위해서 OpenAPI-CLI를 실행시킬 js파일을 아래와 같은 형식으로 구성했습니다.

require("dotenv").config({
  path: ".env.production",
});

const { execSync } = require("child_process");

const genObject = {
  auth: { url: process.env.SWAGGER_AUTH_URL, path: "./src/libs/openapi/auth" },
  api: { url: process.env.SWAGGER_API_URL, path: "./src/libs/openapi/api" },
  test: "test",
};

const current = genObject[process.env.GENERATE_TYPE];

// 제너레이트타입이 존재하지 않는 경우
if (!current) {
  console.error("GENERATE_TYPE 환경 변수가 설정되지 않았습니다.");
  process.exit(1);
}

// 명령어 실행 함수
execSync(
  `openapi-generator-cli generate -i ${current.url} -g typescript-fetch -o ${current.path} -c ./openapi.json --skip-validate-spec -t ./src/libs/openapi/templates/${process.env.GENERATE_TYPE}`,
  { stdio: "inherit" },
);

 

추가적으로 openapi.json, openapitools.json 두가지의 별도 설정파일이 더 있지만 생략했습니다.

 

명령어를 구성할 때 사내 배포환경이 [ dev, qa, prod ] 총 세가지 환경으로 분기되어 있어 환경마다 각기 다른 script 명령어가 필요했습니다.

 

다양하게 테스트 하고 실무에 바로 적용하기 위해

 

카테고리 개별 생성 ( import-auth / import-api ),

전체 생성과 삭제 ( create-api / remove-api ),

전체 삭제 후 전체 생성 ( reset-api )

 

3가지 스크립트 로직으로 구분짓고 엮어서 자동화 프로세스를 완성시켰습니다.

 

    "import-auth:dev": "dotenv -e .env.development -- cross-env GENERATE_TYPE=auth node openapi-generate.js",
    "import-auth:qa": "dotenv -e .env.qa -- cross-env GENERATE_TYPE=auth node openapi-generate.js",
    "import-auth:prod": "dotenv -e .env.production -- cross-env GENERATE_TYPE=auth node openapi-generate.js",
    "import-api:dev": "dotenv -e .env.development -- cross-env GENERATE_TYPE=api node openapi-generate.js",
    "import-api:qa": "dotenv -e .env.qa -- cross-env GENERATE_TYPE=api node openapi-generate.js",
    "import-api:prod": "dotenv -e .env.production -- cross-env GENERATE_TYPE=api node openapi-generate.js",
    "create-api:dev": "dotenv -e .env.development -- cross-env pnpm run import-auth:dev && pnpm run import-api:dev",
    "create-api:qa": "dotenv -e .env.qa -- cross-env pnpm run import-auth:qa && pnpm run import-api:qa",
    "create-api:prod": "dotenv -e .env.production -- cross-env pnpm run import-auth:prod && pnpm run import-api:prod",
    "remove-api": "rimraf ./src/libs/openapi/api ./src/libs/openapi/auth",
    "reset-api:dev": "pnpm run remove-api && pnpm run create-api:dev",
    "reset-api:qa": "pnpm run remove-api && pnpm run create-api:qa",
    "reset-api:prod": "pnpm run remove-api && pnpm run create-api:prod",

 

꽤나 장황해 보입니다... dotenv와 각 환경 변수를 활용하여 작성하였습니다.

당시 저의 최선이었지만 분명 더 좋은 방법이 있을 것이라고 생각합니다🥲

 

🥸 API를 생성하는 템플릿 Mustache 파일 커스텀

첫인상이 폭력적인 mustache파일...

 

이게 뭐지 감도 안잡힌다 느낌이었지만 자세히 보면서 하나하나 주석을 남기며 어떤 부분이 어떤 역할을 하는지, 어떤 구문 어떤 코드를 생성해내는지 등 분석하여 기존 사내 프로세스와 커스텀 펫처 등이 정상작동하도록 templates를 커스텀 했습니다.

당시에는 ai를 제대로 활용하지 못했고 cursor - ai도 사용하지 않았기 때문에 주석을 남기며 [ 생성 / 검토 / 수정 ]을 반복하며 작업했기 때문에 좀 더 고된 경험이었습니다...🥹

 

고된 경험1: 주석을 남긴다.
고된 경험2: 생성하고 확인한다.

 

이렇게 openapi-generator-cli 를 활용한 api 생성 자동화를 적용하였습니다.

 

🚩결과

결과적으로는 3가지 이점을 얻을 수 있었습니다.


첫 번째로 불필요한 커뮤니케이션 제거되었습니다.

login api - body에 들어가는 필드 중 validateFlag가 number -> string으로 바뀌었어요!

➔ login api 변경사항 있어 재배포 합니다!

 

구체적인 설명이 필요 없이 특정 엔드포인트의 재배포 라는 단어만으로 프론트엔드는 npm run reset-api:dev 등의 명령어로 간단하게 수정사항이 반영된 api를 받아 빠르게 직접 확인할 수 있게 되어 커뮤니케이션 리소스가 감소했습니다.


두 번째로 유지보수 편의성이 향상되었습니다.

 

예시 변경 사항:

login api에서 env: "dev" | "qa" | "prod" 와 같이 req body 필드에 추가하고

response를 env에 따라 level: "noob" | "normal" | "super" 과 같이 내려주어야 하는 추가 기능이 생겼다.

 

[ Before ]

1. 변경되어야 하는 req의 필드 키 네임과 값의 타입 확인

2. req body에 정의된 interface 수정

3. api의 req body 필드를 변경사항과 동일한 필드로 수정

4. reponse에 정의된 interface 수정

 

[ After ]

1. npm run reset-api:dev

2. api의 req body 필드를 변경사항과 동일한 필드로 수정

 

하나하나 수정해 줄 필요 없이 명령어로 리세팅 후 req body만 변경해주면 되기 때문에 편의성이 크게 향상되었습니다.


세 번째로 코드 품질과 일관성이 향상되었습니다.

협업 과정에서 개발자마다 서로 다른 코드 스타일이 혼합되면 일관성이 떨어질 가능성이 높습니다.

그러나 openapi-generator-cli 기반 자동 생성 방식을 도입하면서, 검증된 템플릿을 기반으로 일관성 있는 api 생성이 가능해졌습니다.

2025. 11. 2. 18:17

나는 프론트엔드 개발자다.

이 사실은 분명하고 재직중인 회사에서도 프론트엔드 개발자로서 일하고 있다.

그리고 내가 개발한 것을 돌아 보았을 때는 꽤나 많은 기능과 화면을 개발한 것 같았다.

 

주로 LMS를 개발하였고

[ 학습 프로그램 개설, 설문조사 기능,

발급기관명이 찍히는 수료증 발급,학습 프로그램 관리, 사업계획서 작성,

사업계획서 pdf렌더링, 사업계획서 시장 검증 챗봇 ]

등 다양한 frontend를 구현했다.

 

frontend 개발자로서의 길을 잘 닦고 있다고 생각하고 있었지만,

다시 한번 잘 생각해보니 개발자라면 시키면 누구나 할 수 있는 작업이

대다수인 것 같다는 생각이 들기 시작했다.

 

단순히 어떠한 기능을 구현했다는 단순함 보다는 개발자의 역량으로서

어떤 기능을 어떻게 이해하고 구현했는지 등이 중요하다는 생각이 들었고

좀 더 딥다이브가 필요하고 더 확고한 방향성을 구체적으로 잡아야겠다는

생각이 자리잡았다.

 

가장 우선적으로 "Why"에 좀 더 집중해보기로 했다.

지금 이 글을 작성중인 블로그는 사실 기술 블로그로서 활용할 계획이었다.

 

그 이유는 AI가 사용화 되기 전에는 직접 구글링을하고 stack overflow에서

검색하며 트러블 슈팅이나 기술에 대한 이해도를 좀 더 명확히 이해하고 기록하고자

기술 블로그를 목적으로 개설하여 사용하고 있었다.

 

하지만 현 상황을 생각하며 "왜 필요한가?"를 떠올려보면 "글쌔..." 라는 생각이 들기 시작했다.

 

하지만 어째서 글쌔라는 답변을 스스로 하게 되었을까?

 

바로 AI의 활용 때문이다.

 

정보에 대한 것을 포스팅하는 것은 더 이상 무의마하다는 생각이 들기 시작했다.

때문에 이 포스팅을 기점으로 개발 과정에서 있었던 이야기를 서술하는 방식으로

방향성을 잡아보고자 한다.

 

어쩌다 보니 Infra? DevOps?

최근 나의 개발 이력을 정리하다보니 인프라, 데브옵스에 관련된 작업을 많이 하게 된 것 같다.

과연 장래 프론트엔드에 도움이 될 내용인가? 라는 자문을 했을 때

"글쌔..." 라는 답변이 또 나오게 되었다.

 

그렇다고 이러한 개발 과정이나 경험이 무의미하거나 필요 없나?

"아니" 라고 확고하게 이야기 할 수 있었다.

 

결국 체계가 잡혀있지 않은 스타트업에서는 누군가 체계를 어느 정도

잡을 필요가 있었고 그 역할을 내가 주도적으로 맡았기 때문에 자연스럽게 인프라와

데브옵스에 대해서 개발/작업을 해왔던 것 같다.

 

이제는 체계가 어느 정도 잡혀서 좀 더 프론트엔드 개발과 프론트엔드 딥다이브에

힘을 실을 수 있을 것 같단 생각이 들었고 앞으로 좀 더 체계적으로

방향성을 잡고 계획을 통해 딥다이브를 해볼 생각이다.

 

그리고 얼마전 SRE 라는 직무에 대해서 알게 되었다.

이 직무는 Server / Infra / DevOps 이 세가지 업무를 능통하게 다루며 개선시키고

확장시키는 직무라는 이야기를 듣게 되었다.

 

하지만 나는 프론트엔드 개발자이다. 그렇다면 프론트엔드에는 비슷한

복합직무가 없을까? 하고 찾아보던 도중 채용공고에서 종종 보았던

FrontOps, Platform Engineer, Fullstack Engineer 등이 비슷한 역할을

하고 있다는 사실을 알게되었다.

 

이제는 좀 더 포괄적인 개발 경험 덕분에 뛰어들 수 있는 개발 포지션의 폭이

넓어졌다는 긍정적인 생각을 할 수 있게 된 것 같다.

 

아무튼 딥다이브!

결론은 딥다이브를 해야된다. 무엇이든 유추와 표면적 흐름을 이해한다기 보다는

어떤 이유에서 이러한 작동을 하게 되는지, 어떤 개념에 의해 이러한 프로세스를 지향하고

어떤 방향성을 지양하는지 등 딥다이브의 중요성과 필요성을 많이 느끼게 되는 요즘이다.

 

스스로 생각했을 때는 결코 게으름을 피우거나 잘못된 방향으로 개발해왔다고 생각하지 않는다.

하지만 어디까지나 스스로 생각했을 뿐이다.

 

나는 좀 더 좋은 개발자가 되고싶다.

그렇기 때문에 스스로가 아닌, 마치 프로그래밍처럼 누가 생각해도 확고하게

방향성을 가지고 정론과 확장가능성을 이야기하고 개발을 통해 실현할 수 있는 더 나은

가치에 대해 이야기하는 개발자가 되기 위해 반드시 필요한 과정이라는 생각이 떠나지 않는다.

 

나만의 만족이 아닌 함께 하는 이들과 함께 더 나아갈 수 있는 개발자로 거듭나기 위해

딥다이브는 별도의 개념이 아닌, 기본자세가 되어 방향성을 바로 잡겠다고 다짐해본다.