늦었지만 25년도 회고를 해보고자 한다.

 

실제로 24년 10월에 입사한 회사에서, 제대로 적응하고 무언가를 시도해본 해가 25년이지 않았나 싶다.

23년 2월부터 개발자를 시작해 26년 1월까지 약 3년의 기간 동안 정말 많은 변화가 있었는데, 그중에서도 25년의 변화가 가장 크지 않았나 싶다.

 

25년에는 사내 Vuex라는 플랫폼을 개발하는 데 전력을 기울였다. 회사와 팀의 지원이 있어 어느 정도 결과물을 낼 수 있었고, CES에서 시연하는 모습을 볼 수 있었다.


개발 환경의 변화

사실 개발 환경의 변화가 엄청났다. 나만의 커스터마이징을 시작한 한 해이지 않았나 싶다.

팀장님의 영향이 컸다. 본인의 개발 커리어 동안 필요한 모든 환경을 집어넣은 커스텀된 환경이 회사 입사하자마자 주어졌다.

신세계였다. 이렇게 편한 툴들이 있는지도 몰랐고, 찾아볼 생각조차 하지 못했다.

이런 커스텀이 주는 매력에 매료되었고, 나만의 개발 환경에 대한 욕심이 조금 생기기 시작했다.

Code Editor의 변화

Visual Studio Code → GoLand(IntelliJ) → Cursor → Neovim

 

처음 개발을 배울 때는 VS Code로 시작했고, 첫 회사에 입사했을 때는 GoLand가 제공되었다.

25년에는 엄청난 AI 발전에 따른 Cursor의 Tab 기능이 정말 충격적일 정도로 좋았다.

 

그럼에도 위 모든 것을 버리고 Neovim을 최종적으로 사용하고 있는 이유는 주변 환경의 영향이 컸다.

 

Cursor 같은 정말 좋은 툴이 있는데 왜 팀장님과 사수님은 아직까지 Neovim을 사용하고 있을까? 라는 의문에서 시작했고,

Cursor에게 빼앗긴 코드 작성의 즐거움을 되찾기 위해 Neovim을 과감하게 선택했다.

 

LazyVim 기반의 미니멀한 세팅을 구성했고, fzf-lua를 비롯해 LSP 등을 세팅한 현재의 결과물은 다음과 같다.

 

Terminal의 변화

Neovim 세팅을 하면서 터미널 또한 사용하던 iTerm2에서 Ghostty로 옮겨갔다.

 

팀에서 사용하던 iTerm2에는 다양한 세팅과 함께 tmux를 붙여 화면 분할, 세션 공유, Vim Copy Mode 등을 이용하고 있었는데,

생각보다 버벅이는 느낌이 없지 않아 존재해 Ghostty로 이동했다.

 

tmux 없이 Ghostty 기능만으로도 강력한 기능들이 제공되며, 무엇보다 빠릿빠릿한 반응성과 다양한 테마 제공이 정말 마음에 들었다.

 

각종 Tool의 변화

  • Zsh 기반으로 다양한 함수들을 작성해 사용하고 있다. 특히 Zoxide는 가장 많이 사용하지 않나 싶다.
    이동했던 경로를 기억해 c myFoldercd ~/somewhere/a/b/c/myFolder로 이동시켜주는 기능인데, 정말 한 번쯤은 사용해보라고 권하고 싶다.
    그 외에도 JWT parser를 비롯한 각종 util 함수들을 zsh에 등록해 사용하고 있다.

  • LazyGit Tui기반의 Git 관리 툴이다.
    TUI 기반의 Git 관리 툴이다. 간단한 Git 기능들을 비롯해 커밋 내용과 메시지를 보기 정말 편하다.

  • RayCast
    다양한 앱에 대한 단축키 설정을 할 수 있고, Rectangle 기능이 내장되어 있어 많이 사용하고 있다.

  • Karabiner
    키 매핑 툴인데, 원하는 단축키들을 등록해 사용하고 있다.
    특히 마우스 이동, 휠 이동, 왼손으로 사용하는 numpad, vim mode 등 다양하게 매핑해 사용 중인데, 없으면 고장 날 정도로 종속된 상태다.

  • AI Tool
    Web, OpenCode, Crush를 이용하고 있다. 전반적으로 터미널 환경으로 구성하다 보니 아무래도 TUI가 더 편하다.(Claude는 현재 Auth가 막혀 API 키 발급이 필요하고, 비용이 너무 비싸다.)
    단순한 질문은 팀 Gemini를 통해 하고 있고, OpenCode는 OpenAI를 사용 중이다.

전반적으로 GUI 환경에서 TUI 환경으로 넘어가며 다양한 세팅을 하게 된 한 해이지 않았나 싶다.

 

TUI 환경을 구축하다 보면 나만의 환경을 만드는 게 정말 재미있다.

 

요즘은 우스갯소리로 “터꾸(터미널 꾸미기)” 한다고 종종 팀원들과 이야기한다.


개발

25년에는 Vuex라는 제품군 개발에 심혈을 기울였다. 아무것도 없는 상태에서 해당 플랫폼을 개발하려다 보니 정말 다양한 문제를 겪지 않을 수 없었다.

 

- Go 언어라는 특성상 Java Spring 진영만큼 획일화된 프레임워크가 없고, 각자의 개인적 특성을 지닌 상태이다 보니 각자가 생각하는 도메인의 영역이 달랐고, 이렇게 다른 상태에서 시작되는 개발은 당연히 오차가 발생할 수밖에 없었다.

다만 DDD의 감을 잡아가며 도메인의 컨텍스트를 어디까지 정할지 기준이 생기고 나서부터는 무언가 합이 맞아간다는 느낌이 들었고, 정말 즐겁지 않을 수 없었다.

 

정말 많은 코드 리뷰와 나의 생각을 정리하는 시간을 가지게 되었고, 이쯤부터는 코드 작성보다는 생각과 AI와의 대화를 더 많이 하게 된 것 같다. 플랫폼 팀의 고도화된 프레임워크에 적응하는 게 생각보다 어려웠다.

 

- Contributor 작업을 처음 해보게 되었다.
해당 라이브러리를 사용해야 했는데 우리가 원하는 기능이 없어 깃 포크를 진행했고, 우선 작업한 뒤 PR을 통해 첫 컨트리뷰터가 되었다.
대형 Star가 박힌 프로젝트에 나의 PR이 main에 머지된다는 건 정말 짜릿한 경험이었다.

 

- SQL 튜닝 예를들어 특정 테이블의 하나의 칼럼값의 데이터가 너무 많아 발생한 SlowQuery는 Explain상에서 발견하기 어려웠다.

왜? ref Const 이고 Where도 index가 적용되는데 이게 왜이럴까? DB의 메모리풀을 의심하고 나중에는 DB 성능까지 의심하기 까지 이르렀다. 다시 돌아와서 데이터의 칼럼이 너무 많아 발생되는 단순 네트워크 문제였음을 확인했을때 정말이지 너무 바보같았다.

 

DB SlowQuery의 성능 문제를 단순 인덱스가 아닌 네트워크 대역폭, 메모리 버퍼등 시야를 보다 확장하는 계기가 되었으며, 다시한번

실행계획이 전부는 아니다 라는 사실을 진짜 뼈저리게 느낀 순간이지 아닌가 싶고, 테스트의 범위 폭을 좀더 넓히게 된 계기가 아닐까 싶다.

 

- 우리의 배치시스템은 Batch MQ를 통해 MQ에서 Batch를 발행하는 구조로 되어있고 이 Batch에서는 각 서비스에 등록된 함수들의 포인터 정보를 가져와 함수 포인터를 호출하는 구조이다

어느날 특정 작업이 동작하지 않는 이슈 리포트가 올라왔고 로그를 탐색해본 결과 MQ가 소구되지 않던 이슈가 있었다.
왜그런걸까 파악하는데 정말 많은 시간을 소모했다.

MQ를 통해 Batch가 실행되고 해당 Bacth 함수의 호출 결과가 발생되어야 MQ Subscribe가 다음 메세지를 수신할 수 있는데,

해당 Batch에서 Code Block이 걸리고 Release 하지 않는 이슈가 존재하여 해당 Batch Pipeline이 막혀버려 문제가 발생되었다. MQ의 Ctx Timeout을 사용하지 않은 문제 보다 근본적인 Batch 에 넘겨진 포인터 함수에서 Code Block이 걸린 근본적인 이유 특정 파일 업로드 할때 GoRoutin을 열고 회수하지 못해 wait가 걸려 Code Block이 발생된건데 이런 복합적인 이유가 발생되어 버그 파악이 생각 보다 힘들었다. 

 

사실 위에서야 MQ와 Batch Pipeline 형태에 대해 적었지만 개발 당시만해도, 단순하게 사용만 하면 됐지 라는 안일한 생각 때문에 발생하게된 버그이지 않을까 싶다. 

 

- 설계의 오류에 따른 나비효과를 경험했다.
위의 다양한 이유와 자잘한 이슈들을 경험을 하면서 상당히 보수적인 개발을 하고 있었다. 

해당 Table에 통계가 적재되어야 하는데 유저 * 작업량 * 시간당 작업량 이런식으로 계산하여 테이블이 이렇게 되고 로우가 대충 이정도 쌓일 테니 모든 row를 기록하기 보단 시간단위가 최소 단위이니 시간별 upsert를 진행하자 라는 생각이 엄청난 스노우볼을 굴려왔다. 

 

유저의 특정 작업이 디비의 부하를 많이 일으키다 보니 저런 메트릭 데이터를 적재함에 있어서 Dirty flag를 작업하고 해당 플래그를 기준으로 계산해서 집어넣자! 라는 생각이 DB 트랜잭션의 꼬임이 되어버렸고 결국 신뢰성 없는 통계 데이터가 되어 버렸다. 

사용자의 행동 기반으로 데이터를 적재하는것이 아닌 DB의 트랜잭션 단위의 설계가 필요했는데 이게 안되다 보니 점점 더 괴랄한 형태의 통계를 적재하는 방식이 되어버렸고, 이제는 내가 아니면 손볼 수 없는 지경에 까지 이르게 되었다.

 

리팩토링 계획을 세우고 있는데 사용자의 작업마다 Delay Batch에 집어넣어, 해당 배치가 실행되기 전까지 존재하는 모든 값들을 하나의 형태로 합쳐서 특정 시간단위 flush를 진행해볼까 한다. 

설계 오류에서부터 시작해 그 오류를 수정하기 위해 잘못된 탑을 쌓는 데 너무 많은 시간을 할애했고, 팀원분들, 나아가 사용자분들께 정말 죄송했다.

 

- 이미 서비스 중인 소프트웨어의 스키마 변경에 따른 업데이트는 지옥이었다. 

최초 설계가 잘못된 위와 비슷한 문제이긴 했다. 사내 특성상 ai로 감지할 수 있는 Label의 목록들이 정해져있다고 성급하게 판단한 문제였다. 그결과 단순하게 칼럼을 추가하여 해결했다. 예를들어 red , blue, green 만 있다고 생각하고 기존 테이블 칼럼에 red, blud, green을 추가해버리고, 통계, 메트릭 모든 곳이 저 red blue green에 종속된채 통계들이 잡히고 있는 상태였다. 그러나, 추가가 필요하다는 내부 회의결과가 나왔고 각 유저별로 다른 label들이 표현되어져야 한다고 하는 미쳐버린 요구사항이 들어왔다. 

이에 따라 테이블 분리 작업과 프론트와 협업하는 작업이 필요하고 기존 api 와 호환 가능하도록 작성을 해야했던 문제가 있었다. 

Unleash 기반의 Feature Flag 기능을 이용해서 기능별로 묶고있지만 이 테이블 분리에 따른 스키마 변경은 진짜 Unleash를 떠나서 모든 서비스 레이어에서부터 모델 Entity 까지 전부 변경이 필요하게 되었다.

이건 미친듯한 작업량이었고 로직 단위에서 칼럼에서 분리해서 테이블로 옮긴다 말은 쉽지만 내부에서 해당 로직들에 의해 변경되는 모든 부분에 대해 세밀하게 파악이 필요했는데 이게 너무 곤란하고 난감했다. api 응답으로 내려주는 값들도 새로운 버전 이후 버전 모두 호환 되어야했고 테스트를 비롯한 모든 작업에서 상당시간 할애했고, 결국 이모든 제약사항 덕에 기존버전 호환 없이 일괄 업데이트를 진행하게 되었다.

상당히 아쉬웠다. 대부분의 작업을 기존 버전 호환되게 작업을 마무리했는데 시간적인 여유가 부족하고, 프론트 엔드 작업자와 호흡이 잘 맞지 못해 이런판단이 내려졌다는 사실이 조금 아쉬웠다. 

근본적인 문제는 데이터 설계에 있었고, RDB의 이점을 잘못된 방법으로 사용하면서 편법으로 이용하고자 했던 나의 문제였다고 생각한다.

 

- OAuth 인증의 극치를 경험했다. 

최초 사내 서비스를 목적으로 개발되어 Google OAuth를 이용하고자 했으나 AWS 환경, MinIO 등 다양한 플랫폼들이 붙으면서 통합적으로 유저가 관리될 필요가 있었으며 그결과 AWS Cognito 를 활용하기로 결정했으며 Google의 OAuth, MinIO IDP 등 다양한곳 에서 IDP인증을 제공해주고 있어 Cognito를 통한 유저관리는 정말 쉬웠으나 이게 서비스가 글로벌로 퍼지다 보니 동일한 유저에 대한 Federation 작업이 생각보다 애를 먹었고, Cognito의 Group을 이용하여 유저의 Federation 또는 서비스 지역에 대한 기록을 이용하고 있다. 

또한 AWS Cognito를 사용하고 있어 Identity 발급을 이용해 다양한 서비스에 접근이 가능하다. 이말은 다시말해, 우리의 AWS 계정이 아닌 고객들의 AWS 계정에서 사용가능한 S3 버킷 목록을 우리의 로그인 시스템을 활용하여 로그인하여 사용할 수 있다는 의미이다.

AWS Cogntio를 통한 결정은 최고의 선택이 아니지 않은가 싶었다. 

 

AWS Cognito에 토큰을 수정하는 Lambda 트리거 함수를 이용하여 토큰의 데이터를 수정할 수 있지만 로그인의 성능을 고려하여, 해당 기능을 제거하고, UserAttibute로 이전하는 등의 성능 개선을 했던 경험도 나쁘지 않았다.

다만 Cognito 자체에 너무 의존적인 상태가 되어있어 만약 우리의 서비스를 로컬 시스템에서 사용한다고 할때 어떻게 해당 Token 인증 로직들을 해결할것인지에 대한 고민이 있고, 거기에 까지 도달한다면, IDP를 인증해줄수 있는 서버를 추가적으로 구축해야하지 않을까 싶다. 

 

- CloudFront를 통한 Cache 활용 극대화

Object Storage를 활용하고, 데이터의 용량이 생각보다 크다 개당 20MB 평균 10000개 정도 존재할 수있는 오브젝트들에 대한 값이 유연하게 Client Cache 적용전략이 필요했다. 

us-east 즉 미국 타겟으로 cloudfront를 적용하고, MinIO용 cloudfront, seoul용 cloudfront 등을 이용하고 있었으나. 하나로 합칠수 있지 않을까 고민했고 uri prefix를 통해 하나의 CloudFront로 결합하여 비용을 1/3로 줄였으며 생각보다 뿌듯했다. 

edge Lambda 들을 통해 요청에 대한 Validation, LoadBalance 기능들을 통합하였으나 해당 부분도 lambda의 cold start의 성능적이슈가 고려되어 cloudFunction에서 단순한 Validation 체크, EdgeLambda에서  Origin Request 전 Signing 처리, url 조작등을 처리하도록 분리하여,  위조된 요청은 Edgelambda 이전 , CloudFunction에서 모두 Validation 되도록 처리하는 경험은 생각보다 좋았다.

 

다만 우리가 사용하는 JwtToken을 이용하여 CloudFront 조회하는 로직상에 cloudFuction에서는 단순 Validation을 체크하고, origin Request Edge lambda에서는 라우팅 처리와, Signing 을 위한 작업을 진행하고 있었으나, Jwt의 검증은 CloudFront 성능상 제거를 했다. 해당 부분을 어떻게 검증을 해야할지 기술적 부채로 남아있다.

코드 베이스의 경우 관리도 하나의 기술적 부채로 남아있다. 팀은 Go 기반이 였으나 edge lambda의 경우 Javascript, 몇몇 lambda들은 Chalice를 이용한 Python 버전 관리를 하고 있는데 하나의 레포지토리 안에 모든 lambda를 밀어넣고 go, Javscript의 경우 shell script를 통한 배포 그외는 chlaice 이다.

최대한 언어의 다중성을 배제하고 go 로 마이그레이션 하는 작업이 필요하다고 생각한다.


다양한 부분에 있어서 개발 경험을 하지 않았나 싶다. 

정말 좋은 팀리더 밑에서 좋은 동료들 덕분에, 나에게 주어진 과분한 과제들을 해결할 수 있지 않았나 싶다. 

 

사실 웃으면서 넘긴 크고작은 버그들과 설계적 오류들이 존재하고 있으며, 그 기술적 부채들을 해결하고 있는 26년이 될것이라 생각된다. 

서비스의 안정성과 신뢰성을 26년에는 목표하고 싶으며, 버그에 대한 고민보다는 최적화에 대한 고민을 하는 26년의 한해가 되었으면 좋겠다.

'개발일지' 카테고리의 다른 글

FRP 적용  (0) 2024.12.31
10월 개발일지  (1) 2023.11.01
7월개발~ 8월초  (0) 2023.08.16
6월 개발  (1) 2023.07.09
5월 개발  (0) 2023.06.11

작년 ChatGPT의 등장은 개발 생태계를 송두리째 바꾸어 놓았습니다. 저의 사고방식 또한 예외는
아니었습니다. "Minio로 CDN을 구성하려면 어떻게 해야 하지?"라며 문서를 찾아 헤매던 과거의 저는,
이제 ChatGPT에게 같은 질문을 그대로 던지는 현재의 저로 바뀌었습니다.

단순한 검색을 넘어, Cursor, Zed와 같은 AI 네이티브 개발 툴들은 코드 자동완성은 물론, 아예
함수나 클래스 단위의 코드 생성을 책임지기 시작했습니다. 어느새 저는 AI가 작성한 코드를
검수하고 조립하는 역할에 익숙해져 갔습니다. 코드 작성의 주도권이 AI에게 넘어가고 있다는 사실을
어렴풋이 느끼면서도, 그 편리함에 취해 있었습니다.

그러던 어느 날, 등골이 서늘해지는 경험을 했습니다. 새 프로젝트의 킥오프 회의에서 기술 리더와
아키텍처를 논하던 중이었습니다. 핵심 로직을 설명하기 위해 당연히 알아야 할 언어의 기본 문법이
순간적으로 떠오르지 않았습니다.

'내가 미친 건가?'

짧은 순간이었지만 엄청난 충격과 위기감을 느꼈습니다. 그동안 편리함의 대가로 무엇을 잃고
있었는지 명확해지는 순간이었습니다. 저는 '왜 AI에게 코드 작성을 위임하면 안 되는가?'에 대해
진지하게 고민하기 시작했습니다.

물론 AI는 데이터 흐름을 설계하고 복잡한 아키텍처를 구상하는 창의적인 영역까지 대체할 수는
없습니다. 하지만 저는 문득 다른 질문을 스스로에게 던졌습니다.

"나는 코드 작성을 좋아했나? 그 즐거움을 알고 있나?"

네, 저는 코딩을 좋아했습니다. 머릿속의 논리를 코드로 한 줄 한 줄 구현하고, 더 나은 구조를 위해
지우고 다시 쓰는 그 모든 과정을 즐겼습니다. 그런데 이제 그 즐거움을 AI에게 송두리째 위임하려
하고 있었습니다. 그것은 저의 가장 큰 즐거움을 빼앗기는 것과 같았습니다.

결심이 서자마자 Cursor, VSCode, ChatGPT를 모두 삭제했습니다. 그리고 오직 nvim(Neovim) 하나만
남겨두었습니다.

처음에는 막막했습니다. 기존의 편리한 기능들을 nvim 환경에서 구현하는 데 2~3달이 걸렸고, 새로운
워크플로우에 손이 익숙해지는 데 또 1달이 걸렸습니다. 레딧(Reddit)을 뒤지고 수많은 플러그인을
테스트하며 저만의 환경을 한 땀 한 땀 만들어나갔습니다.


결론적으로, 이 모든 과정은 그만한 가치가 있었습니다.

다시 코드를 짜는 것이 즐거워졌습니다. 문법 하나, 함수 하나를 제 손으로 직접 타이핑하면서
코드에 대한 완전한 통제권을 되찾았습니다. 이제는 누구에게도 의존하지 않는, 오직 저만을 위한
개발 워크플로우를 갖추게 되었습니다.

개발 환경은 곧 개발자의 생산성과 직결됩니다. 다음 포스트부터는 제가 nvim으로 어떻게 저만의
개발 환경을 구축했는지, 그 여정을 공유해보고자 합니다. 요즘 저의 가장 큰 관심사이자, 개발의
즐거움을 되찾게 해준 소중한 경험이기 때문입니다.

최근 원격지원 서비스를 개발과 동시에 FRP Github Contributor가 된 경험을 작성하고자 한다.

FRP


1. FRP 적용 이유

우리 회사의 서비스 제품 중 일부는 데모 클라이언트 역할을 하는 서버들이 포함되어 있으며, 이러한 서버들은 외부 노출을 위해 Ngrok을 사용하고 있다.

NGROK 이란?
로컬에서 실행 중인 서버를 인터넷에서 접근할 수 있도록 안전한 터널을 제공하는 툴이다.
설정이 간단하다는 장점이 있지만, 속도와 기능이 유료버전에 비해 제한적이라는 단점이 존재한다.

 

LiDAR와 함께 패키지 형태로 제공되는 서비스는 대부분 클라이언트의 폐쇄망에서 사용된다. 하지만, 긴급 유지보수나 이슈 확인을 위해 NAT(Network Address Translation)를 우회해야 할 경우, 기존에는 RustDeskNgrok 같은 외부 도구를 활용했다.

 

그러나 이 환경은 여러 문제점을 가지고 있었다:

RustDesk: 원격 제어 툴로는 부족한 성능과 안정성.

Ngrok: 속도와 기능이 제한적.

터미널 환경: 원활하지 않은 연결로 인해 이슈 확인이 비효율적.

 

개선 필요성

이러한 문제를 해결하기 위해:

1. 외부 소프트웨어 의존도를 낮추고,

2. 더 나은 성능과 커스터마이징 옵션(소스코드와 동일한 언어)을 제공하며,

3. 원격 지원 및 터널링 기능을 하나로 통합한 설루션이 필요했다.

 

이에 따라, 우리는 FRP(Fast Reverse Proxy)를 적용하기로 결정했고, 아래와 같은 형태로 중계서버를 두고 중계 서버에서는 내부 네트워크망을 연결할 수 있는 방식의 형태를 이용해서 내-외부망의 통신을 가져갔다.

 

기존 구조에서 중계 서버는 FRP로 대체되었으며, 제공되는 데이터 수집 서버에 FRP 클라이언트를 설치하여 클라이언트 요청에 따라 중계 서버와 연결/해제가 가능한 형태로 설계했으며, 아래 그림과 같다.

1. 중계 서버 (FRP):
• 외부망과 내부망을 연결하는 브릿지 역할.
• 데이터 수집 서버의 클라이언트 요청을 처리하여 중계 서버와 연결하거나 해제할 수 있는 동적 환경 제공.

2. 내부망:
• 데이터 수집 서버는 FRP 클라이언트를 통해 중계 서버와 연결.
• 내부망에는 엔지 서버 및 LiDAR 센서가 위치하며, 데이터를 실시간으로 처리 및 제공.

3. AWS Private 환경:
• AWS 환경에 ALB(Application Load Balancer)와 Lambda를 활용하여 자동화된 데이터 흐름 관리.
• Lambda는 요청 처리를 통해 CloudWatch와 연동하여 로그 및 알림을 관리.
4. Slack Notification:
• FRP의 연결 상태나 요청 이벤트는 Slack 알림을 통해 실시간으로 전달.
• 운영자가 원격 연결 상태를 즉시 확인할 수 있도록 설계.

 

기존 RustDesk, Ngrok의 역할은 Frp가 대체하고 해당 연결의 Notification을 Slack을 통해 알람을 전달하는 구조를 생각했다.
- Lambda 함수의 관리를 위한 Chalice, LoadBalancer는 다음 포스팅에 다루고 FRP 위주로 작성해보고자 한다.


 

2. FRP 적용방법

기존 서버 환경은 Docker Container 환경으로 구축되어 있기 때문에 FRP에서 제공하는 Server, Client를 이용했다.

우선 FRPS의 설정부터 확인해 보면 다음과 같다.

bindPort = 7000

webServer.addr = "0.0.0.0"
webServer.port = 9500
webServer.user = "admin"
webServer.password = "admin"

transport.tcpMux = true
tcpmuxHTTPConnectPort = 1337

log.to = "console"
log.level = "trace"
log.maxDays = 3

 

1. bindPort:
• 중계 서버에서 외부망(public)으로부터 접근 가능한 포트를 설정.
• 해당 포트는 방화벽 또는 라우터에서 포트 포워딩이 설정되어 있어야 외부에서 접근이 가능.

2. transport.tcpMux:
• TCP Multiplexing 기능을 활성화하여 SSH 터널링과 같은 다중 연결을 효율적으로 처리할 수 있도록 설정.
• tcpmuxHTTPConnectPort: Multiplexing에 사용할 포트를 지정합니다. 이 포트는 SSH 터널링에 매우 중요하다.

3. webServer.addr & webServer.port:
• FRPS 관리 페이지(Web UI) 접근 주소와 포트 설정.
• 사용자 인증을 위해 webServer.user와 webServer.password를 지정.

4. log 설정:
• 로그 출력 대상과 수준(trace, debug, info 등)을 지정.
• log.maxDays는 로그 보관 기간을 설정.

 

회사 개발 서버에서 포트 포워딩을 통해 bindPort(7000)가 외부망에서 접근 가능하도록 라우터를 설정했다.

 

다음 FRPC의 설정을 확인해보면 다음과 같다.

user = "frp_test"

serverAddr = "외부 공개된 중계서버 IP"
serverPort = 설정된 포트 번호

log.to = "console"
log.level = "debug"

# Set admin address for control frpc's action by http api such as reload
webServer.addr = "127.0.0.1"
webServer.port = 9500
webServer.user = "admin"
webServer.password = "admin"

[[proxies]]
name = "ssh"
type = "tcpmux"
multiplexer = "httpconnect"
localPort = 22
localIP = "127.0.0.1"
customDomains = ["abc.com"]

[[proxies]]
name = "a"
type = "http"
localPort = 11111
customDomains = ["abc.com"]
locations = ["/api/v1/a"]

[[proxies]]
name = "b"
type = "http"
localPort = 22222
customDomains = ["abc.com"]
locations = ["/api/v1/b"]

[[proxies]]
name = "c"
type = "http"
localPort = 33333
customDomains = ["abc.com"]

 

1. serverAddr & serverPort:
• FRPS(Server)의 공인 IP와 bindPort를 설정합니다.

2. proxies:
• 중계 서버에서 전달할 요청의 규칙을 정의합니다.
• SSH 터널링: type = "tcpmux"를 사용하여 특정 포트(localPort 22)를 지정.
• HTTP 프록시: type = "http"를 사용하여 경로(/api/v1/a, /api/v1/b)에 맞는 요청을 특정 포트로 전달.

3. customDomains:
• 요청을 받을 도메인 이름을 정의합니다.
• abc.com이라는 도메인에 대해 특정 경로 요청을 설정한 포트로 전달하도록 구성했습니다.

 

그 외에는 location에 맞는 라우팅이 되는 것이 마치 프록시 설정과 상당히 비슷하다. 

 

FRP는 중계서버에서 리버스 프록시를 해주고 있는 것이다. 

예를 들어, 클라이언트가 abc.com/api/v1/a에 접속하면:

FRPS는 해당 요청을 FRPC로 전달한다.

FRPC는 로컬 IP(127.0.0.1)의 포트 11111로 요청을 라우팅 한다.

 

이 방식은 마치 리버스 프록시 설정과 유사하며, 클라이언트의 요청을 내부망으로 안전하게 전달할 수 있다.

 

SSH 터널링의 경우 proxy socat을 활용하여 로컬 -> 중계서버 -> 클라이언트서버로 갈 수 있도록 아래와 같은 커맨드를 활용했다.

ssh -o 'ProxyCommand socat - Proxy:127.0.0.1:abc.com:22,proxyport=1337'  xxxx@중계서버.com

1. ProxyCommand란?
ProxyCommand는 SSH 클라이언트 옵션으로, SSH 연결을 설정하기 전에 특정 명령어를 실행하도록 지정한다.
이를 통해 SSH가 직접 대상 서버에 연결하지 않고, 프록시(Proxy)나 다른 중계 프로그램을 경유하도록 설정할 수 있다.

2. socat - Proxy:란?
socat은 소켓 통신을 다루는 강력한 유틸리티입니다. 다양한 프로토콜(예: TCP, UDP, HTTP)을 사용하여 데이터를 전송하거나, 소켓 연결을 프록시 서버를 통해 중계하는 데 사용됩니다.

 

1337은 기존 중계서버에서 tcpmux의 openport임을 기억하자.


3. Customize

클라이언트 환경 최적화

대부분의 클라이언트는 교통량이 많은 교차로에서 소형 PC에 설치되어 작동하고 있다.

제한된 디스크 용량과 메모리를 고려해, 최적화 가능한 부분을 식별하고 개선 작업을 진행했다.

 

TinyFRP 적용

TinyFRP라는 경량화된 FRP 클라이언트를 도입하여 기존 20MB였던 클라이언트 크기를 약 6MB로 축소(95% 용량 절감).

용량 절감이 가능했던 이유:

필요 없는 Web Admin 페이지 제거.

JSON 및 TOML 파싱 기능 제거.

 

의존성 관리 문제와 해결

FRP 클라이언트를 라이브러리 화하는 과정에서 go mod 의존성 관리 문제가 발생했다:

1. FRP가 사용하는 samber.io 라이브러리의 버전업으로 인해 코드 충돌이 발생.

2. go.mod의 replace를 통해 문제를 해결할 수 있었으나, 자동화된 배포 과정에서 스크립트 관리 문제가 발생할 가능성이 있다고 판단.

 

PR(Pull Request)로 해결

장기적인 안정성을 위해 FRP 라이브러리에 Pull Request를 작성하여 수정 사항을 기여했다.

수정 사항이 프로젝트의 Main 브랜치에 Merge 되었을 때, 개발자로서 가장 기분 좋았던 순간 중 하나였다.


4. 결과

비용 절감

1. 기존에 사용하던 Ngrok을 제거하여 매월 31달러의 비용 절감을 달성.

2. 모든 클라이언트 구역에 FRP가 적용되면, 월 300달러 이상의 비용 절감이 예상된다.

 

안정성 및 효율성 향상

기존 터미널 환경에서 발생했던 끊김 현상이 크게 개선되어, 운영 환경의 스트레스를 줄였습니다.

VOC 처리와 모니터링 환경이 Slack 알림 연동으로 더욱 효율적으로 변화.

'개발일지' 카테고리의 다른 글

25년도 회고  (2) 2026.01.25
10월 개발일지  (1) 2023.11.01
7월개발~ 8월초  (0) 2023.08.16
6월 개발  (1) 2023.07.09
5월 개발  (0) 2023.06.11

항상 Json의 데이터를 가지고 엔코딩, 디코딩을 할 줄 알았다. 특별한 값에서 오는 바이너리 데이터를 디코딩하고 성능개선의 과정에 대해서 작성하고자 한다.


1. go tool pprof 활용하기

- 성능개선을 위해서는 성능을 개선하기 위한 프로파일링 결과가 필요하다. Go에서는 tool로 아주 쉽게 제공해 준다.

- pprof을 설정한다. 단순하게 import 해주고, pprof으로 접근할 수 있는 포트를 제공해 주면 된다.

import _ "net/http/pprof"

go func() {
	log.Println(http.ListenAndServe("localhost:6060", nil))
}()

 


해당 페이지로 접근하면 위와 같은 이미지로 볼 수 있다.

좀 더 손쉽게 보기 위해서는 graphviz를 설치하면 손쉽게 확인할 수 있다.

-  brew install garphviz

- go tool pprof -http 0.0.0.0:[그래프로 확인할 PORT] http://0.0.0.0:6060/debug/pprof/profile

 

1분간 profile 데이터를 모으고, 제공된 포트로 web 형태를 제공한다. 위에 제공된 이미지들은 graph, flame graph이다.

CPU 작업시간, 메모리 할당 등을 확인할 수 있다.

- 위의 툴과 benchmark test를 같이 이용하고 있다.

func Benchmark테스트명(b *testing.B){
	for i:=0; i<b.N; i++ {
    	// 벤치마크 테스트를 진행하고자 하는 작업 수행
    }
}

 

실행을 하면 아래와 같은 결과를 보여준다.
BenchmarkMyFunction-8    5000000    300 ns/op    64 B/op    2 allocs/op

- 5000000 벤치마크가 실행된 횟수

- 300 ns/op 반복에 소요된 평균 시간

- 64 b/op 반복에 할당된 메모리 바이트

- 2 allocs/op 반복당 메모리 할당 횟수

 

위 2가지 도구를 활용하여 성능을 개선하고자 한다.


2. 현재상태 파악

- 프로파일링 결과를 확인해 보면 다음과 같다.

binary Read에서 생각보다 많은 cpu 점유와 메모리 할당을 진행하고 있다. 성능개선의 포인트라고 생각한다.

- 벤치마크 결과

파싱 하나 하나 하는데 힙메모리 할당이 엄청나게 많이 일어나고 이는 gc가 수거할게 많아 성능의 문제가 될 지점이라고 생각한다.


3. Binary Read는 왜 많은 CPU 점유시간을  가질까?

- binary Read 함수 내부 구현을 확인하면

func Read(r io.Reader, order ByteOrder, data any) error {
	if n := intDataSize(data); n != 0 { // 들어온 데이터의 사이즈를 설정하고,
		bs := make([]byte, n) // 설정된 숫자만큼 바이트 슬라이스를 생성한다.
		if _, err := io.ReadFull(r, bs); err != nil { // 제공된 reader로 부터 정해진 바이트 슬라이스만 읽는다.
			return err
		}
		switch data := data.(type) {
        		// 타입에 맞춰서 data에 넣어주는 작업
		}
	}

	v := reflect.ValueOf(data)
	size := -1
	switch v.Kind() {
	case reflect.Pointer:
		v = v.Elem()
		size = dataSize(v)
	case reflect.Slice:
		size = dataSize(v)
	}
	if size < 0 {
		// 에러처리
	}
	d := &decoder{order: order, buf: make([]byte, size)}
	if _, err := io.ReadFull(r, d.buf); err != nil {
		return err
	}
	d.value(v)
	return nil
}

 

- Read 내부는 우선 전달받은 데이터가 원시타입의 경우라면 switch case를 통해 필터링된다.

- 이후 io.Reader 인터페이스의 ReadFull을 통해 전달받은 버퍼의 사이즈만큼 읽어오게 되고

- reflect 패키지를 활용하여 구조체가 전달된 경우의 케이스를 해결한다.

 

따라서 원시타입의 경우라면 값의 검증을 위해 2번의 검증을 하게 되고 이것이 반복된다면 2배의 연산을 더 진행하게 되는 것이다.

파싱 하고자 하는 데이터는 모두 숫자로 이루어져 있기 때문에 이는 부적절한 함수 사용이다.

 

binary.LittleEndian의 변수를 보면 ByteOrder의 인터페이스를 모두 제공하고 있으며 내부연산은 모두 비트연산을 통해 메모리 할당 없이 반환하고 있다는 것을 확인했다.

 

binary.Read -> binary.LittleEndian의 함수호출 변경 벤치마크 테스트

- 실행횟수 160 -> 646

- 한 번의 작업당 시간 7416761 -> 1952116

- 메모리 할당 136005 -> 2

 

저 메모리 할당이 비약적으로 줄어들며 전체적으로 성능이 많이 올라갔다.

프로파일링 결과를 보면

 

binary.Read의 CPU 점유와 힙메모리 할당이 완전히 사라졌다.


4. 자주 계산되는 항목들은 메모리 캐시 처리

모자이크 된 부분의 주된 계산은 sin, cos의 값을 구하는 것이다. 매 루프마다 특정 포인트의 x, y, z 축에 대한 sin, cos 값이 계산이 되는데

생각보다 cpu 연산과 딜레이가 되는 것 같아 고정적으로 반복되는 부분들은 구조체 선언과 동시에 sin, cos의 값을 미리 계산해서 처리했다.

모든 값에 대해서 캐시 처리를 하지 않은 이유는 어느 정도 참조가 덜된다 싶으면 메모리 해제를 통해 메모리의 여유를 두어야 하기때문에 해당 부분을 구현하기 까지 시간이 조금더 필요하여 필수 값들에 대해서만 어느정도 미리 계산을 진행했다.

 

벤치마크 테스트

- 실행횟수 646 -> 998

- 한 번의 작업당 시간 1952116 -> 

1252630

- 메모리 할당 2 -> 3

 

메모리 할당이 늘어난 것은 배열로 캐시처리를 진행해서 그렇다. 


5. 구역을 나누어 Go Routine 처리

파싱 되는 부분은 이차원 바이트 배열로 for 반복문을 통해 작업이 처리되고 있다.

따라서 각 for 반복문을 독립적인 시행영역이라고 생각한다면 아래와 같은 그림으로 변경할 수 있다.

 

벤치마크 테스트 결과

- 실행횟수 998 -> 2809

- 한 번의 작업당 시간 1252630 -> 577930

- 메모리 할당 3 -> 1005

확실히 어느 정도 메모리를 할당해야 성능향상을 기대할 수 있는 것 같다.

 

프로파일링 결과

 

최초 보였던 프로파일리의 결과보다 작업이 많이 줄은 것을 확인할 수 있다.


성능개선이 생각보다 괜찮게 되었다. 다만 마지막 개선작업의 go routine은 개발기에 적용하여 안정적인 평균속도를 제공해 줄 수 있는지는 지속적으로 확인해봐야 한다. 

1. 보통 100~150ms 단위로 약 500개의 패킷이 전달된다. 그렇다면 위의 방식대로 동작한다면 순간 최대 고 루틴은 500+&가 될 수 있다는 사실이다.

2. 현재는 하나의 데이터 원천으로부터 가져오지만, 추가될 가능성이 있다는 부분

3. 1~2번의 사실을 고려했을 때 최대 고 루틴의 Pool을 두어 파싱에 사용되는 go routine의 숫자를 관리할 필요가 있다고 생각한다.


번외로 go의 standard 패키지 json의 Marshaling과 UnMarshaling이 생각보다 성능이 좋지 않다는 사실을 알게 되었다.

테스트 코드

type Address struct {
	City        string
	ZipCode     string
	PostCode    uint32
	CountryCode uint16
	CityCode    uint16
	People      uint8
}

func BenchmarkJsonParser(b *testing.B) {
	seoul := Address{
		"Seoul", "117128", 11731, 82, 02, 128,
	}
	byteData, _ := json.Marshal(seoul)
	b.Run("Standard Json Marshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			json.Marshal(seoul)
		}
	})
	b.Run("Json Library Marshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			fastjson.Marshal(seoul)
		}
	})
	b.Run("Standard Json UnMarshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			json.Unmarshal(byteData, &seoul)
		}
	})
	b.Run("Json Library UnMarshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			fastjson.Unmarshal(byteData, &seoul)
		}
	})
}

 

위와 같이 단순한 구조체를 벤치마크를 돌렸을 때 

  실행횟수 평균 작업 속도 평균 메모리 사용량 평균 메모리 할당 횟수
Standard Library 마샬 6411848 165.5 ns 144 byte 2
Go-Json 마샬 12458299 94.85 ns 144 byte 2
Standard Library 언마샬 1562784 796.6 ns 232 byte 6
Go-Json 언마샬 8492500 137.0 ns 96 byte 1

 

프로파일링 결과이다.

 

go-json이 빠른 이유는 reflect 코드의 제거와 buf의 재사용과 인터페이스 사용을 지양하여 최대한 스택 메모리 할당을 하는 방법으로 성능 개선을 했다. 

'Go > Go Basic' 카테고리의 다른 글

Env-Config (kelseyhightower/envconfig)  (2) 2023.10.16
AES 암호화 적용 In Go  (1) 2023.10.10
Ultimate-Go-06 [에러처리]  (0) 2023.09.19
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07

지난달 약 10개월가량의 여정이 끝마치고 배포가 마무리되었다. 

운영을 하던 와중 CNS에서 하나의 메일이 왔는데 그게 이번 챕터의 주제 내용이다. 

메일의 답변을 쓰기위해서 찾아본 내용과 다르게 알고 있던 부분에 대해서 작성하고자 한다.


어느 날 느닷없이 CNS모니터링 팀으로부터 날아온 메일. MySQL의 Sleep Thread를 죽여도 되는지 문의를 하는 문의글이었다.

우선 답변을 하기 위해서 당시 제공된 이미지를 어떻게 얻어와야 할지 당최 알지를 못했다...

Sleep Thread는 유휴커넥션을 의미할 텐데 왜? 내가 작성한 숫자보다 많은지 이해하기 어려웠다.

 

그때 당시 아래 작성된 예시보다 훨씬 더 많은 프로세스? 의 목록들이 존재했다.

MySQL을 사용하면서 한 번도 고민해 본 적 없고, 심지어 쿼리를 작성하여 날려본 적도 없다. 어떤 역할을 하는지 확인해 보자.

MYSQL의 show processList

프로세스 목록은 서버 내에서 실행 중인 스레드 집합에서 현재 수행 중인 작업을 나타냅니다. (MySQL 공식홈페이지)
information_schema와 performance_schema 둘 다 해당 테이블이 존재하나, performance_schema를 사용할 것을 권장한다. 
information_schema는 다음 업데이트에 더 이상 사용하지 않는다고 한다.

 

MySQL의 InnoDB는 하나의 MySQL 프로세스 서버와 각각의 커넥션 스레드로 이루어져 있다. 

다시 말해 하나하나의 모든 커넥션은 스레드라는 의미가 된다.

좋다 그렇다면 보통 우리가 연결하는 db connection의 유휴커넥션을 비롯한 최대 커넥션 등은 모두 스레드의 단위로 MySQL과 연결이 된다는 사실을 알게 되었다.


잘못된 추측

 

내가 작성한 Go Application은 유휴커넥션 3과 최고 커넥션 10을 직접 설정하였다. 

심지어 공통으로 사용하는 커넥션의 코드작성을 내가 하였다. 

내가 의도한 대로라면 Go Appliaction(쿠버네티스 환경에 있으므로 Pods가 된다.)이 기동 됨과 동시에 3개의 유휴커넥션을 애플리케이션에서 가지고 있어야 한다.

아래처럼 동작한다고 생각했다.


특정 DB를 바라보고 있으며, 모든 Go Application보다 내가 작성하지 않은 Java Application이 제일 먼저 의심 갔다. 

현재 워크노드 A의 구조는 

노드A
- User Pods (Go)
- Storage Pods (Go)
- etc.... Pods (Go)
Auth Pods

 

위와 같이 구성되어 있었으며 내가 작성하지 않은 Auth Pods를 제일 먼저 의심했다. 
다른 어떤 것도 설정하지 않고 Jpa를 사용하고 있으며 검색한 결과 Hikari를 사용하고 있다는 판단이 섰으며  connection pooling을 제공하는 JDBC DataSource의 구현체이다.

열심히 구글링과 소스코드를 확인한 결과 Default Idle 연결이 10, 최대 연결이 10이다. 
(https://github.com/brettwooldridge/HikariCP/blob/dev/src/main/java/com/zaxxer/hikari/HikariConfig.java)

// https://github.com/brettwooldridge/HikariCP/blob/dev/src/main/java/com/zaxxer/hikari/HikariConfig.java

public final class HikariPool extends PoolBase implements HikariPoolMXBean, IBagStateListener
{
	// 중략
    private void validateNumerics(){
    	//중략
        if (minIdle < 0 || minIdle > maxPoolSize) {
         	minIdle = maxPoolSize;
         }
      }
    }

 

 

spring  부트를 이용하기 때문에 해당 hikari connection의 요소를 조정해 주었다.
spring.datasource.hikari.minimum-idle : 3

좀 더 확실한 결과를 위해 java_test라는 mysql 계정을 새로 생성하여 확인한 결과 아래와 같이 예상대로 동작하였다.

생각한 대로 정상작동하고 있다. 

스프링부투의 설정값을 변경하니 예상한 대로 동작하고 있다. 


모든 유휴커넥션에 대해서 올바르게 내가 예상한대로 동작하고 있다. 
그러나 go_test라는 계정을 통해 생서한 계정의 커넥션이 위의 사진에는 존재하지 않는다. 
뭐가 문제일까? 


 

 

유휴 커넥션이란? 네트워크가 연결된 상태이지만 데이터의 전송이 없는 상태를 통상적으로 말한다. 
왜 MySQL은 필요할까?  새로운 Connection을 가져가는 것보다 이미 연결된 유휴커넥션을 통해 DB 작업을 수행하는 것이 빠른 동작시간을 가지기 때문이다. 이로 인한 자원의 소모 또한 트레이드오프로 가져오고 있다.

 

내가 알고 있는 내용과 동일하다. 유휴커넥션이 설정됨이라 하면 애플리케이션에서 지속적으로 커넥션을 들고 있음을 의미해야 한다고 생각했다.
왜 내가 설정한 값이 정상동작하지 않는가. 설정 코드를 확인해 보자.

"데이터베이스.SetMaxIdleConns(3)"

뭔가 이름이 이상하다. 최소 연결개수도 아니고 최대 유휴커넥션 연결개수이다. 
해당 소스코드를 확인해 보니 

// SetMaxIdleConns sets the maximum number of connections in the idle connection pool.

최대 유휴 연결 개수의 풀을 설정한다고 한다. 

세상에 정말 잘못된 방식으로 사용하고 있는 것이 아닌가. 코드는 정상작동 한 것이다.

Go Apllication에서 사용하는 커넥션이 없으니 모든 유휴커넥션을 날려버린 것이고 

Java Application에서 사용하는 최소 유휴커넥션의 설정이 있으니 최소한의 커넥션을 유지하고 있는 것이다.


왜 위와 같은 견해차이가 발생하게 된 것일까?

유휴커넥션을 유지하는 비용보다 새로 연결하는 비용 즉 자원의 효율성을 위해 위와 같은 코드가 발생되고, 
자바의 경우 성능의 중요성 때문에 위와 같은 설정값의 견해차이가 발생되었다고 본다.


그렇다면 유휴 커넥션의 살아있는 시간과 MySQL의 wait_time 시간이 다른경우 어떤 방식으로 동작될까? 라는 의문점이 남는다.

위 사진과 같이 유휴 대기시간 과 인터렉티브 시간 모두 60초로 설정하였다.
어플리케이션 상에는 2분(120초) 으로 설정하였다.


DB 스텟의 결과 60초가 지나면

 

아직 3개의 유휴 커넥션이 존재한다. ?

반면 mysql 서버 상의 유휴 커넥션은 ?

 

위의 사진과 동일하게 존재하지 않는다.

 

만약 새로운 쿼리 작업이 발생하게 된다면 ?

보이는 것처럼 새로운 스레드를 생성하게 된다.(ID46번)

 

아무리 어플리케이션 상의 설정을 하더라도 MYSQL 서버상의 설정값이 이와 같이 상이하다면 의도한대로 동작하지 않는다.


결론 :
Go에서 구현한 라이브러리와, Spring의 HikariCP는 다르게 동작하고 있다는 사실을 명확하게 인지하지 않는다면 이번과 같은 다양한 값들을 확인하면서 VOC를 처리해야한다.

 

각 라이브러리에서 설정하는 설정값들에 대해 조금더 기민하게 받아들이고 모두 동일한 개념을 사용하고 있지는 않다는 사실을 알게 되었다.

 

MySQL 상의 다양한 설정값이 존재하고, 어플리케이션의 설정값이 존재하여 상이하게 작동하는 방식이 생겨날수 있기 때문에 기존 설정값의 가능여부와 어플리케이션의 설정값에 대해 명확하게 인지하고 사용을 해야한다.

전체코드

더보기
package config

import (
    "context"
    "database/sql"
    "fmt"
    "gorm.io/driver/mysql"
    "testing"
    "time"
)

func TestIdleConn(t *testing.T) {
    cfg := mysql.Config{
       DSN:       "test 하고자 하는 dsn",
    }
    db, err := sql.Open("mysql", cfg.DSN)
    if err != nil {
       panic(err)
    }
    db.SetMaxIdleConns(3)
    db.SetConnMaxIdleTime(2 * time.Minute)
    db.SetMaxOpenConns(10)

    ch := make(chan bool)
    done := make(chan bool)

    go func() {
       time.Sleep(5 * time.Second)
       fmt.Println("run")
       ch <- true
    }()

    query := func() {
       ctx := context.Background()
       var data string
       rows := db.QueryRowContext(ctx, "Select name from tb_contest").Scan(&data)
       if rows != nil {
          panic(err)
       }
       fmt.Println(data)
    }

    for i := 0; i < 50; i++ {
       go func() {
          query()
       }()
    }

    go func() {
       for {
          select {
          case <-ch:
             fmt.Println("hit")
             for i := 0; i < 300; i++ {
                if i == 77 {
                   query()
                }
                fmt.Printf("%+v\n", db.Stats())
                time.Sleep(1 * time.Second)
                fmt.Printf("cur time is %d \n", i+1)
             }
             done <- true
             return
          }
       }
    }()

    <-done
}

 

'Go > Gorm 삽질기' 카테고리의 다른 글

Gorm BulkUpsert ,BulkInsert, BulkUpdate  (0) 2023.09.07

지난번 포스팅을 통해 서비스에서 올바르게 작동하는 SSE가 구현되어 POC(개발의 기능구현이 클라이언트의 의도대로 되는지 확인하는 시험)도 잘 마무리되었지만. 마음의 짐이 남아있었다.

 

지난번 구현과정 에서 레이스컨디션을 발견했다. (https://guiwoo.tistory.com/96)
프로파일링 검증이 마무리되고 레이스컨디션으로 다시 실행하던 도중 발견하게 되었다.

 

 

해당 레이스 컨디션의 문제의 구간은 
허브 클라이언트를 통해 개별적으로 관리되는 map 데이터 상에서 알람의 메시지를 지우고 생성하는 과정에서 유저의 연속적인 등록, 해제가 발생될 시 레이스 컨디션이 발생하게 된다.

 

지난번 작성한 요청의 따른 예시이다.

1. Request 콜 요청에 따른 고루틴 생성으로 핸들러의 함수를 호출하게 되고 핸들러는 다시 허브 클라이언트를 호출하여 데이터의 싱크를 맞추게 된다. 

 

이에 따라 서버의 바이너리 파일이 실행되면 

1. 서버가 실행되며,

2. Hub클라이언트가 배치의 성향을 가지고 데이터를 주기적으로 가져와 싱크를 맞추게 된다.

 

이렇게 됨에 따라 request요청은 고 루틴을 통해 핸들링이 될텐데, 허브에서 내부적으로 각 고 루틴의 요청을 처리함에 있어 문제가 발생되었던 것이다. 

 

구조의 전체적인 점검을 위해 단계별로 다시 밟아가보자.


1. 단순한 구현 방법 


각 request의 고루틴 요청에 할당하는 것 전체적인 구조가 쉽고 데이터와 고루틴의 관리포인트가 명확하다.

 

 

 

코드를 확인해 보자. 

// 핸들러 내부 구현 

sse.GET("/call/:id", func(c echo.Context) error {
    time.Sleep(500 * time.Millisecond)
    id := c.Param("id")
    done := make(chan bool)
    log.Info().Msgf("Get Request Id :%+v", id)
    log.Info().Msgf("after register")

    go func(done chan bool, id string) {
        ticker := time.NewTicker(500 * time.Millisecond)
        defer log.Info().Msgf("go routine return")
        for {
            select {
            case <-ticker.C:
                c.Response().Header().Set("Content-Type", "text/event-stream")
                c.Response().Header().Set("Cache-Control", "no-cache")
                c.Response().Header().Set("Connection", "keep-alive")
                str := fmt.Sprintf(`{"alarm":%v}`, getData(id))
                if _, err := fmt.Fprintf(c.Response().Writer, "data: %s\n\n", str); err != nil {
                    log.Err(err).Msg("failed to send data")
                }

                c.Response().Flush()
            case <-c.Request().Context().Done():
                log.Info().Msg("get done signal from request")
                done <- true
                return
            }
        }
	}(done, id)
    <-done

    log.Info().Msgf("pass the groutine")
    log.Debug().Msgf("sse conection has been closed")
    return nil
})

 

생각보다 간단하게 구현되어 있다. 가장 기본이 되는 done 채널의 종료 시그널을 밖으로부터 받아와 <- done 라인의 블락을 걸어 sync.WaitGroup 없이 처리했다. 

 

자 이렇게 되었을 때 프로파일링 및 레이스 컨디션을 확인했을 때 고 루틴의 누수와, 레이스컨디션이 존재해서는 안된다.


예상했던 결과이다.
위와 같이 구성된 경우 각 request는 각자의 고유한 메모리를 할당해서 사용하기 때문에 레이스컨디션은 존재하지 않는다. 

만약 700명의 사용자가 이용한다면? 700개의 고 루틴이 생성되고, DB의 요청은 매 0.5초마다 700건의 조회 요청이 날아가 병목지점이 발생하게 된다.


 

구조를 다시 개선해 보자.

하나의 공유메모리를 두어 DB결과를 저장하고 각 Request 핸들러 고 루틴에서는 해당 공유메모리의 결과를 가져오는 방법으로 개선을 한다면? 

공유 메모리의 관리를 위한 관리 클라이언트와 DB 조회를 주기적으로 담당해 줄 클라이언트가 필요하다. 

 

지난번 허브 클라이언트보다 생각보다 간단한 로직이다. 
단순하게 배치성의 작업을 가지고 매 0.5초 단위로 알람이 있는 유저에 대해 조회해 와 공유 메모리에 저장하는 방식이다.

 

핸들러 코드는 그렇다면 아래와 같이 변경된다.

done := make(chan bool)
sse := e.Group("/sse")
sse.GET("/call/:id", func(c echo.Context) error {
    id := c.Param("id")
    log.Info().Msgf("Get Request Id :%+v", id)
    log.Info().Msgf("after register")
    go func(done chan bool, id string) {
        ticker := time.NewTicker(500 * time.Millisecond)
        defer log.Info().Msgf("go routine done")
        for {
            select {
            case <-ticker.C:
                c.Response().Header().Set("Content-Type", "text/event-stream")
                c.Response().Header().Set("Cache-Control", "no-cache")
                c.Response().Header().Set("Connection", "keep-alive")
                str := fmt.Sprintf(`{"alarm":%v}`, hub.getAlarm(id))
                if _, err := fmt.Fprintf(c.Response().Writer, "data: %s\n\n", str); err != nil {
                    log.Err(err).Msg("failed to send data")
                }

                c.Response().Flush()
            case <-c.Request().Context().Done():
                log.Info().Msg("get done signal from request")
                done <- true
                return
            }
        }
    }(done, id)
    <-done

    log.Info().Msgf("pass the groutine")
    log.Debug().Msgf("sse conection has been closed")

    log.Debug().Msgf("sse conection has been closed")
    return nil
})

 

허브클라이언트를 주입받아 단순하게 얻고자 하는 id를 얻는 방식이다. 

레이스 컨디션과, 고 루틴의 누수는 없어야 한다.

 

예상했던 대로 동작하고 있다.


조금 더 DB 최적화를 진행해 보자. 

현재 두 번째 스텝에서는 모든 유저의 알람을 조회하는 방식이다. 이를 최적화하기 위해서는 접속한 유저의 알람만 조회하는 방법이다.

좌측이 이번에 구현하면서 변경된 포인트, 우측이 지난번에 구현한 select 구문이다. 

차이점이라면 알람의 설정과 획득을 티커에 의해서 실행되는 것이 아닌 HubClient 호출부에서 해결하고 있다는 점이다.


허브 클라이언트의 Run 함수는 아래와 같이 변경되었으며 지난번 구현보다 코드의 양이 훨씬 줄었다.

func (client *HubClient) Run() {
	defer func() {
		if err := recover(); err != nil {
			client.log.Err(fmt.Errorf("panic and recover here")).Msgf("panic and recover %+v", err)
		}
	}()

	var (
		getDbTicker = time.NewTicker(500 * time.Millisecond)
	)

	for {
		select {
		case <-getDbTicker.C:
			client.setAlarm()
		case id := <-client.register:
			client.setUserAlarm(id)
		case id := <-client.unregister:
			client.unRegister(id)
		}
	}
}


func (client *HubClient) GetAlarm(id string) bool {
	// 알람을 가져온다.
}
func (client *HubClient) Connect(id string) {
	// register 신호를 보낸다.
}

func (client *HubClient) Disconnect(id string) {
	// unregister 신호를 보낸다.
}

func (client *HubClient) setUserAlarm(id string) {
	// 등록된 유저의 정보를 저장한다.
}
func (client *HubClient) unRegister(id string) {
	// 유저의 정보를 삭제한다.
}

 

코드의 가독성과 문제의 지점에 대해 확인하기 쉬워졌고, 지난번 보다 안정적인 sse 클라이언트 구현이 되었다고 생각한다.
아래 레이스 컨디션 플래그 실행과, 프로파일링 결과가 말해주고 있다.

 

이렇게 단계 별로 확인을 해보면 간단하게 구현될 부분이었는데 고 루틴과 채널의 잘못된 사용으로 레이스컨디션, 고루틴 누수등의 문제를 겪었다. 

 

동시성을 해결하기 위해 채널링을 사용하고, 병렬성의 문제를 해결하기 위해서는 mutex를 활용해서 메모리를 관리를 했어야 했는데 

이러한 채널과 mutex활용에 있어 미숙한 점으로 인하여 지난번과 같은 문제가 발생했다. 
다음에는 이러한 단계별로 구현을 직접 구현을 하지는 않더라도

어떠한 문제가 있고 그 문제를 해결하기 위해 어떠한 방법을 적용했는가? 에 대해 보다 명확하게 정리를 하면서 구현을 해야겠다.

 

코드 전문 (https://github.com/Guiwoo/go_study/tree/master/sse)

최종구현 클라이언트 

더보기
package third

import (
	"fmt"
	"github.com/rs/zerolog"
	"sync"
	"time"
)

var arr []string

func init() {
	n := int('z'-'a') + 1
	arr = make([]string, 0, n)
	for i := 0; i < n; i++ {
		arr = append(arr, string('a'+i))
	}
	fmt.Println(arr)
}

type HubClient struct {
	log        zerolog.Logger
	alarms     map[string]bool
	mutex      sync.Mutex
	register   chan string
	unregister chan string
}

func (client *HubClient) setAlarm() {
	time.Sleep(500 * time.Millisecond)
	client.mutex.Lock()
	for _, id := range arr {
		x := time.Now().Unix()
		if x%2 == 0 {
			client.alarms[id] = true
		} else {
			client.alarms[id] = false
		}
	}
	client.mutex.Unlock()
}

func (client *HubClient) GetAlarm(id string) bool {
	a := false
	client.mutex.Lock()
	a = client.alarms[id]
	client.mutex.Unlock()
	return a
}
func (client *HubClient) Connect(id string) {
	client.register <- id
}

func (client *HubClient) Disconnect(id string) {
	client.unregister <- id
}

func (client *HubClient) setUserAlarm(id string) {
	client.mutex.Lock()
	client.alarms[id] = false
	client.mutex.Unlock()
}
func (client *HubClient) unRegister(id string) {
	client.mutex.Lock()
	delete(client.alarms, id)
	client.mutex.Unlock()
}

func (client *HubClient) Run() {
	defer func() {
		if err := recover(); err != nil {
			client.log.Err(fmt.Errorf("panic and recover here")).Msgf("panic and recover %+v", err)
		}
	}()

	var (
		getDbTicker = time.NewTicker(500 * time.Millisecond)
	)

	for {
		select {
		case <-getDbTicker.C:
			client.setAlarm()
		case id := <-client.register:
			client.setUserAlarm(id)
		case id := <-client.unregister:
			client.unRegister(id)
		}
	}
}

func NewHubClient(log *zerolog.Logger) *HubClient {
	return &HubClient{
		log:        log.With().Str("component", "HUB CLIENT").Logger(),
		alarms:     make(map[string]bool),
		mutex:      sync.Mutex{},
		register:   make(chan string),
		unregister: make(chan string),
	}
}

main 함수 

더보기
package main

import (
	"fmt"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"github.com/rs/zerolog"
	"net/http"
	_ "net/http/pprof"
	"os"
	"sse/client/third"
	"time"
)

func main() {
	e := echo.New()
	log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger()
	hub := third.NewHubClient(&log)

	go hub.Run()

	e.Use(
		middleware.Recover(),
		middleware.LoggerWithConfig(middleware.LoggerConfig{}),
	)

	done := make(chan bool)
	sse := e.Group("/sse")
	sse.GET("/call/:id", func(c echo.Context) error {
		id := c.Param("id")
		log.Info().Msgf("Get Request Id :%+v", id)
		hub.Connect(id)
		log.Info().Msgf("after register")
		go func(done chan bool, id string) {
			ticker := time.NewTicker(500 * time.Millisecond)
			defer log.Info().Msgf("go routine done")
			for {
				select {
				case <-ticker.C:
					c.Response().Header().Set("Content-Type", "text/event-stream")
					c.Response().Header().Set("Cache-Control", "no-cache")
					c.Response().Header().Set("Connection", "keep-alive")
					str := fmt.Sprintf(`{"alarm":%v}`, hub.GetAlarm(id))
					if _, err := fmt.Fprintf(c.Response().Writer, "data: %s\n\n", str); err != nil {
						log.Err(err).Msg("failed to send data")
					}

					c.Response().Flush()
				case <-c.Request().Context().Done():
					log.Info().Msg("get done signal from request")
					hub.Disconnect(id)
					done <- true
					return
				}
			}
		}(done, id)
		<-done

		log.Info().Msgf("pass the groutine")
		log.Debug().Msgf("sse conection has been closed")

		log.Debug().Msgf("sse conection has been closed")
		return nil
	})

	go func() {
		http.ListenAndServe("0.0.0.0:6060", nil)
	}()

	if err := e.Start(":8080"); err != nil {
		log.Err(err).Msg("fail to start server")
	}
}

+ Recent posts