diff --git a/1.html b/1.html index 16b5d2f2..40d5ba07 100644 --- a/1.html +++ b/1.html @@ -5,13 +5,13 @@ Hello World | CAR-FFEINE - - + +
-
본문으로 건너뛰기
- - +
본문으로 건너뛰기
+ + \ No newline at end of file diff --git a/10.html b/10.html index 03b76455..0c4110ac 100644 --- a/10.html +++ b/10.html @@ -5,13 +5,13 @@ webpack으로 msw 설정하기 | CAR-FFEINE - - + +
-
본문으로 건너뛰기

webpack으로 msw 설정하기

· 약 5분
센트

웹팩에서 msw 설정

이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

new CopyWebpackPlugin({
patterns: [
{ from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
],
}),

설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

  1. MSW를 적용해보려고 함.
  2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
  3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
  4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
- - +
본문으로 건너뛰기

webpack으로 msw 설정하기

· 약 5분
센트

웹팩에서 msw 설정

이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

new CopyWebpackPlugin({
patterns: [
{ from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
],
}),

설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

  1. MSW를 적용해보려고 함.
  2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
  3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
  4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
+ + \ No newline at end of file diff --git a/11.html b/11.html index 66b43d17..1bebf5c3 100644 --- a/11.html +++ b/11.html @@ -5,13 +5,13 @@ 카페인 팀에서 사용하는 지도 라이브러리를 소개합니다. | CAR-FFEINE - - + +
-
본문으로 건너뛰기

카페인 팀에서 사용하는 지도 라이브러리를 소개합니다.

· 약 9분
가브리엘

지도 api 벤더 선택 이유

국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

google maps api 관련 라이브러리

(선택한 라이브러리들은 ✅으로 표시했습니다.)

google maps API

https://github.com/tomchentw/react-google-maps

이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

@types/google.maps

https://www.npmjs.com/package/@types/google.maps

TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

@googlemaps/js-api-loader

https://www.npmjs.com/package/@googlemaps/js-api-loader

이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

대중적인 라이브러리 비교

react-google-maps@react-google-maps/api@googlemaps/react-wrapper
링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
선택여부

라이브러리 선택 이유

저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

  1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
  2. 현재 디스플레이 영역의 마커만을 호출해야한다.
  3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

구글 지도 제어 전략

  1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
  2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
  3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
  4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

- - +
본문으로 건너뛰기

카페인 팀에서 사용하는 지도 라이브러리를 소개합니다.

· 약 9분
가브리엘

지도 api 벤더 선택 이유

국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

google maps api 관련 라이브러리

(선택한 라이브러리들은 ✅으로 표시했습니다.)

google maps API

https://github.com/tomchentw/react-google-maps

이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

@types/google.maps

https://www.npmjs.com/package/@types/google.maps

TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

@googlemaps/js-api-loader

https://www.npmjs.com/package/@googlemaps/js-api-loader

이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

대중적인 라이브러리 비교

react-google-maps@react-google-maps/api@googlemaps/react-wrapper
링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
선택여부

라이브러리 선택 이유

저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

  1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
  2. 현재 디스플레이 영역의 마커만을 호출해야한다.
  3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

구글 지도 제어 전략

  1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
  2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
  3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
  4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

+ + \ No newline at end of file diff --git a/12.html b/12.html index 75a2dbac..c105cad1 100644 --- a/12.html +++ b/12.html @@ -5,13 +5,13 @@ jasypt를 활용하여 프로퍼티를 암호화하자 | CAR-FFEINE - - + +
-
본문으로 건너뛰기

jasypt를 활용하여 프로퍼티를 암호화하자

· 약 6분
키아라

서론

안녕하세요 카페인팀 키아라입니다.

이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

프로퍼티 암호화는 왜 필요할까?

spring:
datasource:
url: 데이터베이스 url
username: 계정
password: 비밀번호

프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

jasypt는 뭐지?

Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

사용 방법

정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

1. 라이브러리 추가 (= 의존성 추가)

implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

2. Jasypt 설정 및 Bean 등록

key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

@Configuration
public class JasyptConfig {

private String ENCRYPT_KEY = "hello";

@Bean(name = "jasyptEncryptor")
public StringEncryptor stringEncryptor() {
PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

SimpleStringPBEConfig config = new SimpleStringPBEConfig();

config.setPassword(ENCRYPT_KEY);
config.setAlgorithm("PBEWithMD5AndDES");
config.setKeyObtentionIterations(1000);
config.setPoolSize(1);
config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
config.setStringOutputType("base64");
encryptor.setConfig(config);
return encryptor;
}
}
jasypt:
encryptor:
bean: jasyptEncryptor

3. 암호화

라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

암복호화 사이트

평문

  datasource:
url: 데이터베이스 url
username: 계정
password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

나머지도 마저 암호화해줍시다.

  datasource:
url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

실행

올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

실행 실패

그런데 뭔가 이상하지 않나요?

프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

그럼 이 키를 어디에 숨길 수 있을까요?

저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

바로 환경변수를 설정하는 것이죠.

+ 환경변수 설정

private String ENCRYPT_KEY = "hello";

기존의 키를 관리하는 방식이었습니다.

우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

// JasyptConfig.class
@Value("${jasypt.encryptor.password}")
private String ENCRYPT_KEY;
// application.yml
jasypt:
encryptor:
password: hello

이제 환경변수를 설정해봅시다.

Run > Edit Configurations... 경로로 들어가면

Run/Debug Configurations 창이 나오는데

Environment variables: 부분에 ENCRYPT_KEY=hello

라고 적어주세요.

그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

jasypt:
encryptor:
password: ${ENCRYPT_KEY}

긴 글 읽어주셔서 감사합니다.

- - +
본문으로 건너뛰기

jasypt를 활용하여 프로퍼티를 암호화하자

· 약 6분
키아라

서론

안녕하세요 카페인팀 키아라입니다.

이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

프로퍼티 암호화는 왜 필요할까?

spring:
datasource:
url: 데이터베이스 url
username: 계정
password: 비밀번호

프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

jasypt는 뭐지?

Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

사용 방법

정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

1. 라이브러리 추가 (= 의존성 추가)

implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

2. Jasypt 설정 및 Bean 등록

key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

@Configuration
public class JasyptConfig {

private String ENCRYPT_KEY = "hello";

@Bean(name = "jasyptEncryptor")
public StringEncryptor stringEncryptor() {
PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

SimpleStringPBEConfig config = new SimpleStringPBEConfig();

config.setPassword(ENCRYPT_KEY);
config.setAlgorithm("PBEWithMD5AndDES");
config.setKeyObtentionIterations(1000);
config.setPoolSize(1);
config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
config.setStringOutputType("base64");
encryptor.setConfig(config);
return encryptor;
}
}
jasypt:
encryptor:
bean: jasyptEncryptor

3. 암호화

라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

암복호화 사이트

평문

  datasource:
url: 데이터베이스 url
username: 계정
password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

나머지도 마저 암호화해줍시다.

  datasource:
url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

실행

올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

실행 실패

그런데 뭔가 이상하지 않나요?

프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

그럼 이 키를 어디에 숨길 수 있을까요?

저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

바로 환경변수를 설정하는 것이죠.

+ 환경변수 설정

private String ENCRYPT_KEY = "hello";

기존의 키를 관리하는 방식이었습니다.

우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

// JasyptConfig.class
@Value("${jasypt.encryptor.password}")
private String ENCRYPT_KEY;
// application.yml
jasypt:
encryptor:
password: hello

이제 환경변수를 설정해봅시다.

Run > Edit Configurations... 경로로 들어가면

Run/Debug Configurations 창이 나오는데

Environment variables: 부분에 ENCRYPT_KEY=hello

라고 적어주세요.

그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

jasypt:
encryptor:
password: ${ENCRYPT_KEY}

긴 글 읽어주셔서 감사합니다.

+ + \ No newline at end of file diff --git a/13.html b/13.html index a1f2d17f..c67df9d5 100644 --- a/13.html +++ b/13.html @@ -5,13 +5,13 @@ 충전소 리스트 클릭시 마커에 간단정보 모달을 띄우는 기능 추가에서 겪었던 트러블 슈팅 | CAR-FFEINE - - + +
-
본문으로 건너뛰기

충전소 리스트 클릭시 마커에 간단정보 모달을 띄우는 기능 추가에서 겪었던 트러블 슈팅

· 약 18분
센트

Untitled

위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

  • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
  • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
  • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
  • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
  • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
  • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

이번에 새로 추가하고자 한 기능은 다음과 같다.

  • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

const infowindow = new google.maps.InfoWindow({
content: contentString,
ariaLabel: 'Uluru',
});

const marker = new google.maps.Marker({
position: uluru,
map,
title: 'Uluru (Ayers Rock)',
});

infowindow.open({
anchor: marker,
map,
});

간단하게 요약하자면 다음과 같다.

  • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
    • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
  • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
  • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

Untitled

충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

여기서 문제가 발생하게 되었다.


현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

  • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
  • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

  1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
  2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

각각의 문제점을 살펴보자.


1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

  • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
  • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
    • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
  • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
    • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

최종적으로 문제를 해결한 방식은 다음과 같다.

  • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
  • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
  • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
  • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

  • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
  • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
  • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
  • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

  • StationMarkersContainer 컴포넌트
    • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
  • StationMarker 컴포넌트
    • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
    • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
    • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

StationMarker 컴포넌트의 역할

  • marker 인스턴스를 생성한다.
  • marker 인스턴스의 이벤트 핸들러를 추가한다.
  • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
  • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

  1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
  2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

  • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
  • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
  • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
  • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

  • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
  • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
- - +
본문으로 건너뛰기

충전소 리스트 클릭시 마커에 간단정보 모달을 띄우는 기능 추가에서 겪었던 트러블 슈팅

· 약 18분
센트

Untitled

위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

  • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
  • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
  • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
  • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
  • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
  • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

이번에 새로 추가하고자 한 기능은 다음과 같다.

  • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

const infowindow = new google.maps.InfoWindow({
content: contentString,
ariaLabel: 'Uluru',
});

const marker = new google.maps.Marker({
position: uluru,
map,
title: 'Uluru (Ayers Rock)',
});

infowindow.open({
anchor: marker,
map,
});

간단하게 요약하자면 다음과 같다.

  • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
    • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
  • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
  • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

Untitled

충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

여기서 문제가 발생하게 되었다.


현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

  • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
  • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

  1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
  2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

각각의 문제점을 살펴보자.


1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

  • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
  • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
    • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
  • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
    • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

최종적으로 문제를 해결한 방식은 다음과 같다.

  • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
  • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
  • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
  • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

  • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
  • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
  • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
  • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

  • StationMarkersContainer 컴포넌트
    • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
  • StationMarker 컴포넌트
    • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
    • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
    • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

StationMarker 컴포넌트의 역할

  • marker 인스턴스를 생성한다.
  • marker 인스턴스의 이벤트 핸들러를 추가한다.
  • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
  • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

  1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
  2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

  • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
  • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
  • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
  • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

  • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
  • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
+ + \ No newline at end of file diff --git a/14.html b/14.html index 40c8d5bb..015475d6 100644 --- a/14.html +++ b/14.html @@ -5,13 +5,13 @@ 카페인팀 서버 아키텍처를 설명해드리겠습니다 | CAR-FFEINE - - + +
-
본문으로 건너뛰기

카페인팀 서버 아키텍처를 설명해드리겠습니다

· 약 11분
누누

안녕하세요 우아한테크코스 카페인팀 누누입니다

이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

배포 아키텍처

서버가 배포되는 과정은 다음과 같습니다.

server image

우아한테크코스 인스턴스에 대한 소개

우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

  1. 퍼블릭 서브넷에 있는 인스턴스
    • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
    • 미리 열려있는 포트들만 허용이 되어 있습니다.
    • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
  2. 프라이빗 서브넷에 있는 인스턴스
    • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
    • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

그전에 알면 좋아요

여기서는 Self Hosted Runner를 사용했는데요.

Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

배포 아키텍처에 대한 고민

저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

  1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
  2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
  3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

선택의 기준이 되었던 것은 총 3가지였습니다.

  1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
    • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
  2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
    • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
  3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
    • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

실제 내부 구성은 어떻게 될까요?

개발 서버

이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

  1. 프론트 서버
    • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
  2. 백엔드 서버
    • spring으로 되어있는 api 서버입니다.

물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

CD 툴과 모니터링 툴

이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

  1. CD 툴
    • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
  2. 보안을 위한 리버스 프록시
    • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
    • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
  3. 모니터링 툴
    • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
    • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

배포 과정 더 자세히 알아보기

아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

server image

  1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
  2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
  3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

느낀 점

좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

긴 글을 읽어주셔서 감사합니다

- - +
본문으로 건너뛰기

카페인팀 서버 아키텍처를 설명해드리겠습니다

· 약 11분
누누

안녕하세요 우아한테크코스 카페인팀 누누입니다

이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

배포 아키텍처

서버가 배포되는 과정은 다음과 같습니다.

server image

우아한테크코스 인스턴스에 대한 소개

우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

  1. 퍼블릭 서브넷에 있는 인스턴스
    • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
    • 미리 열려있는 포트들만 허용이 되어 있습니다.
    • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
  2. 프라이빗 서브넷에 있는 인스턴스
    • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
    • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

그전에 알면 좋아요

여기서는 Self Hosted Runner를 사용했는데요.

Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

배포 아키텍처에 대한 고민

저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

  1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
  2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
  3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

선택의 기준이 되었던 것은 총 3가지였습니다.

  1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
    • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
  2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
    • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
  3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
    • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

실제 내부 구성은 어떻게 될까요?

개발 서버

이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

  1. 프론트 서버
    • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
  2. 백엔드 서버
    • spring으로 되어있는 api 서버입니다.

물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

CD 툴과 모니터링 툴

이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

  1. CD 툴
    • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
  2. 보안을 위한 리버스 프록시
    • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
    • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
  3. 모니터링 툴
    • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
    • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

배포 과정 더 자세히 알아보기

아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

server image

  1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
  2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
  3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

느낀 점

좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

긴 글을 읽어주셔서 감사합니다

+ + \ No newline at end of file diff --git a/15.html b/15.html index a3c8e0d9..949842b1 100644 --- a/15.html +++ b/15.html @@ -5,12 +5,12 @@ 주기적인 데이터 요청으로 받은 데이터를 효율적으로 업데이트 및 삽입하기 (with. 박스터) | CAR-FFEINE - - + +
-
본문으로 건너뛰기

주기적인 데이터 요청으로 받은 데이터를 효율적으로 업데이트 및 삽입하기 (with. 박스터)

· 약 10분
제이
박스터

안녕하세요~ +

주기적인 데이터 요청으로 받은 데이터를 효율적으로 업데이트 및 삽입하기 (with. 박스터)

· 약 10분
제이
박스터

안녕하세요~ 우테코 카페인 팀의 제이입니다.

오늘은 카페인 팀의 프로젝트를 진행하면서 '박스터'와 함께 어떤 문제를 겪고 해결했는지 적어보도록 하겠습니다.

  • 배우는 단계이다 보니 틀린 부분이 있을 수 있는데, 피드백 부탁드립니다 :)

먼저 글을 쓰기 전에 문제 상황에 대해 간단하게 말씀드리겠습니다.

문제 상황

카페인 팀에서는 전기차 충전소 공공 API를 활용하여 충전소의 혼잡도 제공 및 여러 서비스를 제공합니다.

이런 서비스를 사용자들에게 제공하기 위해서 다음과 같은 작업들이 필요합니다.

  1. 첫 실행시 공공 API 데이터를 모두 불러서 데이터베이스에 삽입합니다.
  2. 혼잡도를 제공하기 위해서 주기적인 시간 (아직 정하진 않았지만 ex.12시간) 단위로 충전소와 충전기의 상태를 업데이트 하기 위해서 다시 데이터를 요청을 합니다.
  3. 새롭게 추가된 충전소와 충전기는 모두 Insert해주고, 기존에 있던 충전소 혹은 충전기가 업데이트 됐다면 변경된 데이터로 업데이트 해줍니다.

저랑 박스터는 2~3번 과정을 진행하는 역할을 맡았습니다.

테이블의 관계는 다음과 같습니다.

charge_station <---1------N---> charger
charger <---1------1---> charger_status

저희는 이 문제를 어떻게 해결 했는지 보겠습니다.

문제 해결 과정

전제조건

  • 첫 실행 모든 테이블은 초기화 상태이다.
  • 데이터는 9999건을 기준으로 한다.
  • 메서드 첫 시행에서는 모든 데이터가 새롭게 insert 되고
  • 그 다음 메서드 시행에서는 일부 데이터는 추가되고, 일부는 업데이트 된다.

Ver1. findAll() 조회 후 각각 save() 해주기 (약14초)

저희가 처음에 생각한 방법입니다. 알아서 바뀐 것들은 업데이트 해주고, 새로운 건 저장해주기 때문에 간단한 방법으로 생각했습니다.

실제로 해본 결과, 삽입의 경우는 SELECT 쿼리문 실행 후 INSERT 쿼리문을 발생 시켰고, 업데이트 시에도 SELECT 후 UPDATE 혹은 INSERT를 발생 시켰습니다. (변경 사항 없으면 SELECT만)

이는 식별자에 따른 JPA 작동 방식 때문인데요. @@ -26,7 +26,7 @@ 이를 통해서 Ver2에 비해서 1초정도 줄었습니다.

Ver4. 이전 방식 + Fetch Join 사용하기 (약 6초)

마지막 방법은 조회 과정의 시간 단축입니다.

처음에 Stations를 findAll()하는 쿼리를 확인해보니 N+1 문제가 발생하고 있었습니다. 그 이유는 Station에서 Chargers를 지연로딩으로 설정 했는데, 이를 그대로 get 메서드를 통해 조회해서 해당 문제가 발생했습니다.

List<ChargeStation> findAll(); // 기존

@Query("SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers"); // Fetch Join 적용
List<ChargeStation> findAll();

따라서 위에 코드와 같이 Fetch Join을 이용해서 처음에 데이터를 가져왔습니다. 이렇게 효율적인 조회로 변경하면서 시간을 많이 줄일 수 있었습니다.

지금까지의 방법을 정리를 하자면

Ver1 과 같은 방식에서는 업데이트 과정에서 JPA의 식별자에 따른 처리 방식으로 인해 [SELECT + UPDATE] or [SELECT + INSERT] 와 같이 쿼리가 두 번씩 나갔습니다.

그래서 Ver3까지 개선을 하기 위해서 저장과 업데이트를 한 번에 JDBC를 이용해서 Batch로 처리해주는 방식을 선택했고,

변경 감지 + 배치 데이터를 모으기 위해서 자료구조를 이용해서 시간을 조금씩 단축 했습니다.

마지막으로 Ver4에서는 findAll()에서 발생하는 N+1의 문제를 해결하면서 시간을 단축했습니다.

이런 과정을 통해서 동일 작업을 14초에서 6초 정도로 줄일 수 있었습니다!

- - + + \ No newline at end of file diff --git a/16.html b/16.html index d31d1940..d616ec59 100644 --- a/16.html +++ b/16.html @@ -5,12 +5,12 @@ JPA에서 ID가 있는 Entity에 대해 save 시에 select 쿼리가 나가는 이유 | CAR-FFEINE - - + +
-

JPA에서 ID가 있는 Entity에 대해 save 시에 select 쿼리가 나가는 이유

· 약 10분
박스터

안녕하세요 박스터 입니다.

먼저 이번에 글을 쓰게된 계기를 말씀드리겠습니다. 저희 팀은 공공 데이터 API에서 받아온 충전소와, 충전기들의 ID를 그대로 사용하고 있습니다. +

JPA에서 ID가 있는 Entity에 대해 save 시에 select 쿼리가 나가는 이유

· 약 10분
박스터

안녕하세요 박스터 입니다.

먼저 이번에 글을 쓰게된 계기를 말씀드리겠습니다. 저희 팀은 공공 데이터 API에서 받아온 충전소와, 충전기들의 ID를 그대로 사용하고 있습니다. 물론 다른 API, 제가 제어할 수 없는 곳에 의존하는 것은 좋지 않다고 생각합니다.

하지만 데이터를 받아오는 과정에서 마주한 성능적인 문제 때문에 그대로 사용하고 있습니다. 전국의 충전소는 6만개, 충전소 안에 존재하는 충전기는 23만기입니다. 하지만 공공 데이터는 충전소와, 충전기의 정보를 따로 제공하는 것이 아닌 중복된 충전소를 포함한 데이터를 충전기 개수만큼인 23만개의 row로 제공합니다.

따라서 저희가 ID를 따로 부여하게 된다면, 충전소를 저장하는 과정에서 받아오는 ID로 충전기를 연결해줘야하는데 그렇게 된다면 셀 수 없이 많은 쿼리가 발생합니다.

잠깐 생각해본다면

  1. 충전소를 각각 저장하고 ID를 부여받는 쿼리 6만번 (ID를 알아와야하기 때문에 batch를 사용할 수 없습니다.)
  2. 충전소에서 받아온 ID를 충전기에 매핑하고 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

하지만 ID를 그대로 사용하게 된다면,

  1. 충전소를 저장하는 쿼리 최소 1번 (만약 batch로 6만건을 한번에 저장한다는 가정)
  2. 충전기를 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

23만건이 넘는 정보를 확인했을 때, ID는 중복되지 않았고, 중복하지 않을 것이라 생각했습니다. 그 뿐만 아니라 처음 한번만 저장하는 것이 아닌 주기적으로 업데이트된 정보를 반영해주고 update or save 해주어야하기 때문에, ID를 그대로 가지고 있는 것이 훨씬 효율적이라 생각했습니다.

사족이 길었습니다. 각설하고 이런 방식으로 ID를 직접 넣어주는 경우 발생하는 문제에 대해 말씀드리겠습니다.

ID를 직접 넣어준 Entity를 저장할 때

먼저 간단한 예제 Entity로 설명드리겠습니다.

@Entity
public class ChargeStation {

@Id
private String stationId;

private String stationName;

...
}

보통의 Entity와 다른 부분은 Id를 직접 할당하기 때문에 @GeneratedValue(strategy = GenerationType.IDENTITY) 이러한 ID 생성 전략에 대한 정보가 없습니다.

그리고 save() 코드를 호출하면 어떤 쿼리가 나가는지 확인해보겠습니다. 아래와 같이 아주 간단한 선릉역 충전소를 저장하는 테스트를 실행해보겠습니다.

@DataJpaTest
class ChargeStationRepositoryTest {

@Autowired
private ChargeStationRepository chargeStationRepository;

@Test
void 충전소를_저장한다() {
ChargeStation station = ChargeStationFixture.선릉역_충전소_충전기_2개_사용가능_1개;

chargeStationRepository.save(station);

ChargeStation expect = chargeStationRepository.findByStationId(station.getStationId()).get();
assertThat(expect).isEqualTo(station);
}
}

먼저 코드만 보면 먼저 chargeStationRepository.save() 호출과 함께 insert 쿼리 1번, 그리고 chargeStationRepository.findByStationId()에서 select 쿼리 1번 @@ -22,7 +22,7 @@ 아주 간단하게 entity의 isNew()를 호출한다고 적혀있습니다. 하지만 Persistable 인터페이스를 구현한 Entity의 isNew() 를 호출하는 것 입니다.

그럼 남은 하나의 클래스를 확인하겠습니다.

info-support

위 사진처럼 이 클래스가 Entity 마다 Persistable 구현 유무에 따라 동적으로 구현체를 변경해주고 있었습니다.

그럼 답이 나온 것 같습니다. ID를 직접 할당하는 Entity에 Persistable을 구현해주면 됩니다.

Persistable 구현하기

@Entity
public class ChargeStation implements Pesistable{

@Id
private String stationId;

private String stationName;

@CreatedDate
private LocalDateTime createdTime;

...

@Override
public Object getId() {
return getStationId();
}

@Override
public boolean isNew() {
return createdTime == null;
}
}

간단히 만들어봤습니다. @CreatedDate는 Entity가 처음 영속화될 때 동작하기 때문에 이 Entity의 CreateTime 필드가 null 이면 새로운 Entity라고 확신할 수 있습니다. 그럼 이렇게 인터페이스를 구현하고 아까 실행했던 테스트를 다시 실행해보겠습니다.

solved

깔끔하게 구현된 것을 확인할 수 있었습니다. 원하던대로 쿼리가 2번 발생합니다. 이런 Persistable@MappedSuperClass를 통해 더 깔끔하게 구현할 수 있습니다. 하지만 따로 설명드리지는 않겠습니다.

결론

JPA는 많은 편의 기능을 제공해주는 것 같아보입니다. 쫄지맙시다.

- - + + \ No newline at end of file diff --git a/17.html b/17.html index b533cb55..e7669f9e 100644 --- a/17.html +++ b/17.html @@ -5,12 +5,12 @@ 카페인 팀의 CI/CD | CAR-FFEINE - - + +
-

카페인 팀의 CI/CD

· 약 8분
제이

안녕하세요. 카페인 팀의 제이입니다. +

카페인 팀의 CI/CD

· 약 8분
제이

안녕하세요. 카페인 팀의 제이입니다. 저희 팀에서 CI/CD는 어떻게 진행되는지 작성하겠습니다.

CI (지속적 통합)

ci

카페인 팀에서는 지속적 통합 즉 CI를 진행하기 위해서 위에 사진과 같이 Github Actions를 사용합니다.

main, develop 브랜치에 Push, Pull Request 요청이 들어간다면 이벤트가 발생하고, Github Actions를 통해 저희가 작성해둔 스크립트가 실행 됩니다.

이 스크립트에 여러가지를 등록할 순 있지만, 저희는 자동으로 테스트를 진행하도록 하였습니다. 자동으로 테스트를 돌리면서 테스트가 통과를 해야지만 Merge를 진행할 수 있습니다.

이를 통해 개발자의 실수를 줄일 수 있고 안정적으로 지속적 통합을 이룰 수 있게 됩니다.


CD (지속적 배포)

cd

저희의 지속적 배포 아키텍처입니다.

순서를 요약하자면 다음과 같습니다.
  1. Release 브랜치에 Push를 한다.
  2. Github Actions를 통해 Docker Hub에 레포지토리의 소스코드를 Docker Image로 빌드해서 Push 한다.
  3. 인프라 서버에서 Self Hosted Runner가 작동한다.
  4. 인프라 서버에서 배포 서버로 들어간다.
  5. 배포 서버 안에서 Docker Hub에 미리 업로드한 Docker Image를 Pull 해온다.
  6. 배포 서버 안에서 Docker Image를 컨테이너에 띄운다.

배포 자동화 툴 선택하기

먼저 배포 자동화 과정을 구축하기 위해서 여러가지 툴이 있습니다.

Travis, Jenkins, Github Actions 등등 여러가지가 있는데요. 저희 팀은 Github Actions를 선택했습니다.

이를 선택한 여러가지 이유가 있었지만 @@ -20,7 +20,7 @@ 위에 사진과 같이 설정을 해주시면 됩니다.

그리고 이를 yml에서 사용하기 위해선 secrets.Key이름으로 사용해주시면 됩니다.


이제 마지막으로 Dockerfile을 만들어줍니다.

저희는 /backend/ 경로에 만들어주었습니다.

FROM amazoncorretto:17-alpine-jdk
ARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar","/app.jar"]

저희는 위처럼 절대 경로를 기준으로 JAR_FILE 위치를 지정하고, profiles는 dev로 설정해서 만들어주었습니다.


3. 배포하기

트리거를 작동시켜서 저희가 yml 파일에서 지정해준 것들이 잘 작동하는지 확인합니다.

jobSuccess 위에 사진처럼 모든 Job이 성공적으로 통과하는 것을 보실 수 있습니다.

dockerPs 이렇게 인프라 서버에서 배포 서버로 들어가서 성공적으로 서버를 도커로 띄운 것을 보실 수 있습니다.

EC2 배포 서버에서 docker ps를 입력했을 때에도 잘 실행이 되네요!


CD 배포 과정 요약

지속적 배포 과정을 요약 하자면 다음과 같습니다.

  1. Self Hosted Runner를 EC2 인프라 서버에 등록해준다.
  2. yml 파일과 Dockerfile을 만들어준다.
  3. 트리거를 작동시켜서 Github Actions의 태스크가 모두 잘 되는지 확인한다.
  4. 잘 됐다면 EC2 배포 서버에 Docker image가 성공적으로 띄워진다.
- - + + \ No newline at end of file diff --git a/18.html b/18.html index 2aa7adc9..776d2396 100644 --- a/18.html +++ b/18.html @@ -5,13 +5,13 @@ private 서브넷에 인스턴스를 외부와 연결할 때, public ip? private ip? | CAR-FFEINE - - + +
-

private 서브넷에 인스턴스를 외부와 연결할 때, public ip? private ip?

· 약 11분
누누

어떤 문제가 있었나요?

우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

이 과정에서 총 2가지의 문제점이 있었습니다.

  1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
  2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

아래의 모든 설명은 AWS 를 기준으로 합니다.

private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

해결 방법

public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

이를 해결하기 위해 public ip 자동할당을 해주었습니다.

왜 public ip를 할당했더니 문제가 해결되었을까요?

private 서브넷이란?

정말 간단하게 설명했을 때

private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

조금 자세하게 들어가 보도록 하겠습니다

private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

private subnet

public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

하지만 이 방법은 너무 원시적이고, 비효율적입니다.

그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

인터넷으로 요청을 보낼 수 있도록 만드는 과정

인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

private 서브넷을 public 서브넷으로 바꾸기

보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

그래서 이 방법은 보통 사용하지 않습니다.

NAT 인스턴스(Gateway) 만들기

NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

public ip도 자동 할당을 해줘야 합니다

public ip 가 필요한 이유

NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

이제 2번째 문제를 해결해 보도록 하겠습니다.

public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

해결 방법

해결 방법에는 2가지 과정이 있습니다.

public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

private ip를 통해서 접속하기

public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

  1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
  2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
  3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
  4. 트래픽이 NAT 인스턴스에 도착합니다.
  5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

  1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
  2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
  3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
  4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
  5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

요약

  1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
  2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
  3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
  4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
- - +

private 서브넷에 인스턴스를 외부와 연결할 때, public ip? private ip?

· 약 11분
누누

어떤 문제가 있었나요?

우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

이 과정에서 총 2가지의 문제점이 있었습니다.

  1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
  2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

아래의 모든 설명은 AWS 를 기준으로 합니다.

private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

해결 방법

public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

이를 해결하기 위해 public ip 자동할당을 해주었습니다.

왜 public ip를 할당했더니 문제가 해결되었을까요?

private 서브넷이란?

정말 간단하게 설명했을 때

private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

조금 자세하게 들어가 보도록 하겠습니다

private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

private subnet

public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

하지만 이 방법은 너무 원시적이고, 비효율적입니다.

그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

인터넷으로 요청을 보낼 수 있도록 만드는 과정

인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

private 서브넷을 public 서브넷으로 바꾸기

보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

그래서 이 방법은 보통 사용하지 않습니다.

NAT 인스턴스(Gateway) 만들기

NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

public ip도 자동 할당을 해줘야 합니다

public ip 가 필요한 이유

NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

이제 2번째 문제를 해결해 보도록 하겠습니다.

public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

해결 방법

해결 방법에는 2가지 과정이 있습니다.

public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

private ip를 통해서 접속하기

public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

  1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
  2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
  3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
  4. 트래픽이 NAT 인스턴스에 도착합니다.
  5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

  1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
  2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
  3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
  4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
  5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

요약

  1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
  2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
  3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
  4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
+ + \ No newline at end of file diff --git a/19.html b/19.html index 050c8675..49f19d46 100644 --- a/19.html +++ b/19.html @@ -5,12 +5,12 @@ OAuth 2.0의 흐름과 설정 해보기 | CAR-FFEINE - - + +
-

OAuth 2.0의 흐름과 설정 해보기

· 약 13분
박스터

OAuth 2.0 ?

OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 +

OAuth 2.0의 흐름과 설정 해보기

· 약 13분
박스터

OAuth 2.0 ?

OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 굳이 한번 더 입력하지 않고도 다른 웹 사이트에서 사용할 수 있는 것 입니다. 그리고 다른 웹 사이트를 사용하더라도 google에서 로그인을 하는 과정을 거치기 때문에, 사용자는 비밀번호나, critical한 개인정보 같은 것을 한 곳에서 관리할 수 있다는 장점이 있습니다.

다시 한번 정리하자면 우리 웹 사이트의 사용자가 이용하는 다른 웹 사이트의 정보를 사용할 수 있게끔 다른 웹 사이트에서 권한을 위임 받는 것 입니다.

OAuth flow

OAuth Flow를 설명하기 전에 여기서 모르는 단어들이 많습니다. 해당 링크에서 더 자세하게 정리 되어있지만 설명해보겠습니다.

Resource Owner

Resource Owner는 말 그대로 리소스 소유자이고, 구글과 같은 플랫폼에 회원가입이 되어있는, 즉 구글에 자신의 정보들이 있는 사용자입니다.

Client

Client도 말 그대로 고객입니다. 하지만 어떤 관점에서 보느냐 고객이란 뜻은 달라집니다. 여기서는 Google과 같은 플랫폼에서 제공받은 리소스를 사용하는 고객입니다. @@ -24,7 +24,7 @@ Map<String, Object>로 지정해준 이유는, 플랫폼마다 반환되는 JSON 타입이 다르기 때문에 그런 부분에 대해 중복을 제거하기 위해 이러한 형태로 만들었습니다.

그리고 아까 yml에 작성했던 정보들을 가져와야합니다. @Value 어노테이션으로도 가져올 수 있습니다.

        @Value("oauth.provider.google.id")
private String id;
@Value("oauth.provider.google.secret")
private String secret;

...

하지만 이렇게 계속 binding을 해줘야한다는 점이 아주 귀찮고 보기도 안좋습니다.

build.gradle
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

하지만 위의 의존성을 추가해준다면 아주 편하게 property를 가져올 수 있습니다.

OAuthProviderProperties.java
@Component
@ConfigurationProperties(prefix = "oauth2")
public class OAuthProviderProperties {
// prefix oauth2 기준으로 알아서 google이 이름인 Provider Enum을 찾아서 Key로 바인딩
private final Map<Provider, OAuthProviderProperty> provider = new EnumMap<>(Provider.class);

public OAuthProviderProperty getProviderProperties(Provider provider) {
return this.provider.get(provider);
}

@Getter
@Setter
public static class OAuthProviderProperty {
// 그리고 provider 하위 정보들은 아래의 필드에 바인딩
private String id;
private String secret;
private String redirectUrl;
private String tokenUrl;
private String infoUrl;
}
}

이렇게 되면 구조적인 준비는 끝났습니다.

이제는 해당 플랫폼에 정보를 요청하는 작업만 하면 됩니다. 그럼 아까 말씀드렸던 순서로 요청을 해보겠습니다.

RestTemplateOAuthRequester.java
public class RestTemplateOAuthRequester implements OAuthRequester {

@Override
public OAuthMember login(OAuthLoginRequest request) {
// frontend에서 받아온 로그인 platform
Provider provider = Provider.from(request.provider());
// 해당 Platform에 맞는 정보 찾음
OAuthProviderProperty property = oAuthProviderProperties.getProviderProperties(provider);
// frontend에서 받아온 code와 등록해놓은 property로 Access Token 요청
OAuthTokenResponse token = requestAccessToken(property, requet.getCode());
// 받아온 Token으로 해당 Resource Owner의 정보 요청
Map<String, Object> userAttributes = getUserAttributes(property, token);
return provider.getOAuthProvider(userAttributes);
}

private OAuthTokenResponse requestAccessToken(OAuthProviderProperty property, String code) {
HttpHeaders headers = new HttpHeaders();
headers.setBasicAuth(property.getId(), property.getSecret());
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
URI tokenUri = getTokenUri(property, code);
return restTemplate.postForEntity(tokenUri, request, OAuthTokenResponse.class).getBody();
}

private URI getTokenUri(OAuthProviderProperty property, String code) {
return UriComponentsBuilder.fromUriString(property.getTokenUrl())
.queryParam(CODE, URLDecoder.decode(code, StandardCharsets.UTF_8))
.queryParam(GRANT_TYPE, AUTHORIZATION_CODE)
.queryParam(REDIRECT_URI, property.getRedirectUrl())
.build()
.toUri();
}

private Map<String, Object> getUserAttributes(OAuthProviderProperty property, OAuthTokenResponse tokenResponse) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(tokenResponse.accessToken());
headers.setContentType(MediaType.APPLICATION_JSON);
URI uri = URI.create(property.getInfoUrl());
RequestEntity<?> requestEntity = new RequestEntity<>(headers, HttpMethod.GET, uri);
ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(requestEntity, new ParameterizedTypeReference<>() {
});
return responseEntity.getBody();
}
}

이렇게만 한다면 그 어려워 보이던 OAuth 인증도 간단하게 해결할 수 있습니다. (물론 제 코드가 정답이 아닙니다)

Reference

https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16

https://developers.google.com/identity/protocols/oauth2?hl=ko

- - + + \ No newline at end of file diff --git a/2.html b/2.html index 310400b5..efdbcb3d 100644 --- a/2.html +++ b/2.html @@ -5,13 +5,13 @@ git branch 전략 작성해보기 | CAR-FFEINE - - + +
-

git branch 전략 작성해보기

· 약 11분
누누

현재 상황은 어떤데?

현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

Git Branch 전략이란?

git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

왜 git branch 전략이 중요한데?

아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

1. 동시 작업이 편하다

여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

2. 목적이 명확한 브랜치

애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

3. 배포 파이프라인 관리가 편함

브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

4. 버전 관리가 편리하다

서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

Git Branch 전략의 종류는?

총 3가지의 전략이 있습니다.

1. Github Flow

2. Gitlab Flow

3. Git Flow

git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

1. Github Flow

그림으로 flow 간단하게 보고 가도록 하겠습니다.

img

img2

브랜치는 총 2가지 종류가 존재합니다

1. master 브랜치

여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

안정된 버전의 코드가 관리되는 브랜치입니다.

2. feature 브랜치

기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

장점

위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

단점

모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

2. Gitlab Flow

그림으로 flow 간단하게 보고 가도록 하겠습니다.

img2

밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

1. pre-production 서버

2. production 서버

편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

img3

브랜치 종류

총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

1. main(or develop) 브랜치

기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

2. feature브랜치

기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

3. production 브랜치

실제 배포가 일어나는 브랜치입니다. 

여기에 머지가 되는 순간 배포가 일어납니다.

위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

특징

1. 무조건 단방향으로 머지가 일어납니다.

긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

위 사진에서는 pre-production 이 그 예시가 되겠네요.

장점

1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

3. Git Flow

브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

그림으로 보고 가도록 하겠습니다

img4

가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

특징

1. 브랜치에 대해서 양방향으로 머지가 일어납니다

release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

브랜치의 종류가 5가지나 됩니다

1. main

production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

2. develop 

위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

3. feature

기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

4. release

Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

5. hotfix

main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

우리 프로젝트에는 어떤 것이 적절할까?

나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

참고

https://techblog.woowahan.com/2553/

https://docs.gitlab.com/ee/topics/gitlab_flow.html

- - +

git branch 전략 작성해보기

· 약 11분
누누

현재 상황은 어떤데?

현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

Git Branch 전략이란?

git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

왜 git branch 전략이 중요한데?

아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

1. 동시 작업이 편하다

여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

2. 목적이 명확한 브랜치

애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

3. 배포 파이프라인 관리가 편함

브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

4. 버전 관리가 편리하다

서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

Git Branch 전략의 종류는?

총 3가지의 전략이 있습니다.

1. Github Flow

2. Gitlab Flow

3. Git Flow

git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

1. Github Flow

그림으로 flow 간단하게 보고 가도록 하겠습니다.

img

img2

브랜치는 총 2가지 종류가 존재합니다

1. master 브랜치

여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

안정된 버전의 코드가 관리되는 브랜치입니다.

2. feature 브랜치

기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

장점

위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

단점

모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

2. Gitlab Flow

그림으로 flow 간단하게 보고 가도록 하겠습니다.

img2

밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

1. pre-production 서버

2. production 서버

편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

img3

브랜치 종류

총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

1. main(or develop) 브랜치

기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

2. feature브랜치

기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

3. production 브랜치

실제 배포가 일어나는 브랜치입니다. 

여기에 머지가 되는 순간 배포가 일어납니다.

위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

특징

1. 무조건 단방향으로 머지가 일어납니다.

긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

위 사진에서는 pre-production 이 그 예시가 되겠네요.

장점

1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

3. Git Flow

브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

그림으로 보고 가도록 하겠습니다

img4

가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

특징

1. 브랜치에 대해서 양방향으로 머지가 일어납니다

release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

브랜치의 종류가 5가지나 됩니다

1. main

production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

2. develop 

위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

3. feature

기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

4. release

Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

5. hotfix

main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

우리 프로젝트에는 어떤 것이 적절할까?

나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

참고

https://techblog.woowahan.com/2553/

https://docs.gitlab.com/ee/topics/gitlab_flow.html

+ + \ No newline at end of file diff --git a/20.html b/20.html index ede633e2..76c7c910 100644 --- a/20.html +++ b/20.html @@ -5,13 +5,13 @@ 카페인 팀이 styled-components를 선택한 이유 | CAR-FFEINE - - + +
-

카페인 팀이 styled-components를 선택한 이유

· 약 2분
야미

왜 styled-components인가?


여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

  1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

  2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

  3. 팀원들 모두 styled-components가 익숙하다.

  4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

'새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

// *CSS Props 예시

const buttonStyle = css`
font-size: 18px;
color: white;
background: black;
`;

const ClickButton = styled.button<{ css: CSSProp }>`
width: 100px;

${({ css }) => css}
`;

<ClickButton css={buttonStyle}>Click me!</ClickButton>;
- - +

카페인 팀이 styled-components를 선택한 이유

· 약 2분
야미

왜 styled-components인가?


여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

  1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

  2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

  3. 팀원들 모두 styled-components가 익숙하다.

  4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

'새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

// *CSS Props 예시

const buttonStyle = css`
font-size: 18px;
color: white;
background: black;
`;

const ClickButton = styled.button<{ css: CSSProp }>`
width: 100px;

${({ css }) => css}
`;

<ClickButton css={buttonStyle}>Click me!</ClickButton>;
+ + \ No newline at end of file diff --git a/21.html b/21.html index 835f5a6d..61e026cb 100644 --- a/21.html +++ b/21.html @@ -5,13 +5,13 @@ 카페인 팀의 상태관리 전략 (왜 Tanstack Query여야 하는가?) | CAR-FFEINE - - + +
-

카페인 팀의 상태관리 전략 (왜 Tanstack Query여야 하는가?)

· 약 9분
가브리엘

안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

서버 상태와 클라이언트 상태의 구분

서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

상태의 특성으로는 크게 두 가지가 있습니다.

클라이언트 상태

클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

서버 상태

서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

왜 Tanstack Query였나?

vs RTK Query

RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

vs SWR

SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

카페인 팀에서 하려는 일은요…

저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

  • 지도에서 충전소 조회
    • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
    • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
    • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
    • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
  • 전국 충전소 검색기
    • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
    • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
    • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
    • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

- - +

카페인 팀의 상태관리 전략 (왜 Tanstack Query여야 하는가?)

· 약 9분
가브리엘

안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

서버 상태와 클라이언트 상태의 구분

서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

상태의 특성으로는 크게 두 가지가 있습니다.

클라이언트 상태

클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

서버 상태

서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

왜 Tanstack Query였나?

vs RTK Query

RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

vs SWR

SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

카페인 팀에서 하려는 일은요…

저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

  • 지도에서 충전소 조회
    • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
    • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
    • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
    • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
  • 전국 충전소 검색기
    • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
    • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
    • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
    • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

+ + \ No newline at end of file diff --git a/22.html b/22.html index 23c85ba8..eb2d6cb9 100644 --- a/22.html +++ b/22.html @@ -5,12 +5,12 @@ 필터링 기능 구현과 인덱스 이용한 조회 속도 개선하기 | CAR-FFEINE - - + +
-

필터링 기능 구현과 인덱스 이용한 조회 속도 개선하기

· 약 11분
제이

안녕하세요~

우테코 카페인 팀의 제이입니다.

오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

요구 사항과 기능 구현 목록

카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

필터링 기능 구현하기

저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

  1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

  2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

이렇게 두 가지 방법이 있었습니다.

1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

// 1. fetch join + 회사 이름만 조회
@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
"AND s.companyName IN :companyNames")
List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude,
@Param("companyNames") List<String> companyNames);

// 2. fetch join + 충전 타입
@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
"AND c.type IN :types")
List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude,
@Param("types") List<ChargerType> types);

진행 했다면 이런 느낌이었겠네요!

2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

이 이유는 다음과 같습니다.
  1. 어차피 사용자는 작은 범위에서 조회를 한다.
  2. 인덱스를 걸었을 때 가장 효율적이다.

1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

그럴 순 없었을 것 같습니다.

가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

인덱스 적용으로 조회 속도 향상시키기

먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

Hibernate:
select
station0_.station_id as station_1_0_0_,
...
...
...
chargersta2_.latest_update_time as latest_u4_2_2_
from
charge_station station0_
left outer join
charger chargers1_
on station0_.station_id=chargers1_.station_id
left outer join
charger_status chargersta2_
on chargers1_.charger_id=chargersta2_.charger_id
and chargers1_.station_id=chargersta2_.station_id
where
(
station0_.latitude between ? and ?
)
and (
station0_.longitude between ? and ?
)

where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. +

필터링 기능 구현과 인덱스 이용한 조회 속도 개선하기

· 약 11분
제이

안녕하세요~

우테코 카페인 팀의 제이입니다.

오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

요구 사항과 기능 구현 목록

카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

필터링 기능 구현하기

저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

  1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

  2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

이렇게 두 가지 방법이 있었습니다.

1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

// 1. fetch join + 회사 이름만 조회
@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
"AND s.companyName IN :companyNames")
List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude,
@Param("companyNames") List<String> companyNames);

// 2. fetch join + 충전 타입
@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
"AND c.type IN :types")
List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude,
@Param("types") List<ChargerType> types);

진행 했다면 이런 느낌이었겠네요!

2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

이 이유는 다음과 같습니다.
  1. 어차피 사용자는 작은 범위에서 조회를 한다.
  2. 인덱스를 걸었을 때 가장 효율적이다.

1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

그럴 순 없었을 것 같습니다.

가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

인덱스 적용으로 조회 속도 향상시키기

먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

Hibernate:
select
station0_.station_id as station_1_0_0_,
...
...
...
chargersta2_.latest_update_time as latest_u4_2_2_
from
charge_station station0_
left outer join
charger chargers1_
on station0_.station_id=chargers1_.station_id
left outer join
charger_status chargersta2_
on chargers1_.charger_id=chargersta2_.charger_id
and chargers1_.station_id=chargersta2_.station_id
where
(
station0_.latitude between ? and ?
)
and (
station0_.longitude between ? and ?
)

where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. Mysql Query Between 과 >=, <= 성능 차이 비교 ( 더미데이터 50만 )

@Query("SELECT DISTINCT s FROM Station s " +
"LEFT JOIN FETCH s.chargers c " +
"LEFT JOIN FETCH c.chargerStatus " +
"WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude " +
"AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude")
List<Station> findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param("minLatitude") BigDecimal minLatitude,
@Param("maxLatitude") BigDecimal maxLatitude,
@Param("minLongitude") BigDecimal minLongitude,
@Param("maxLongitude") BigDecimal maxLongitude);

위와 같이 조회해주는 쿼리를 만들었고, 인덱스를 만들어주었습니다.

인덱스 설정 기준은 인덱스 정리 및 팁 위에 링크와 같이 동욱님의 블로그를 참조해서 기준을 세웠습니다.

무조건 카디널리티가 높은 것을 설정할 순 없었기 때문에 (업데이트와 삽입 작업이 많기 때문에) 쿼리에서 사용되는 column과 update 작업을 고려하고 성능을 비교해가면서 가장 효율적인 것을 설정해주었습니다.

그리고 속도를 비교해주었습니다.



먼저 속도 비교를 위해서 데이터 셋은 다음과 같이 진행하였습니다.
  • Charger (23만 건)
  • Station (6만 건)
  • ChargerStatus(23만 건)
  • 선릉역 근처 조회

Ver1. 인덱스 적용을 하지 않고 조회 및 필터링 했을 때 속도 (0.84초)

이미지 @@ -18,7 +18,7 @@ 평균적으로 0.63초가 나왔습니다. 약 25 ~ 30%의 조회 속도가 개선되었습니다.

아직 이 부분은 개선이 더 필요해보입니다.

그래도 개선이 됐고, 삽입과 갱신에는 큰 지장이 없어서 일단 이정도로 마무리 하고, 추후에 개선을 해보도록 하겠습니다.

이미지 추가적으로 충전기 조회는 굉장히 빨라졌습니다!

배우는 단계이다보니 미숙하고 틀린 부분이 있을 수 있습니다.

긴 글 읽어주셔서 감사합니다 :)

- - + + \ No newline at end of file diff --git a/23.html b/23.html index 485652d8..97ce970c 100644 --- a/23.html +++ b/23.html @@ -5,12 +5,12 @@ Deadlock trouble shooting | CAR-FFEINE - - + +
-

Deadlock trouble shooting

· 약 13분
박스터

이 글을 쓰는 이유

먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

------------------------
LATEST DETECTED DEADLock
------------------------
2023-07-21 01:49:54 281472560787424
*** (1) TRANSACTION:
TRANSACTION 1000560, ACTIVE 373 sec inserting
mysql tables in use 1, Locked 1
Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

*** (1) HOLDS THE Lock(S):
RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

*** (1) WAITING FOR THIS Lock TO BE GRANTED:
RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 946331, ACTIVE 507 sec inserting
mysql tables in use 1, Locked 1
Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

*** (2) HOLDS THE Lock(S):
RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

*** (2) WAITING FOR THIS Lock TO BE GRANTED:
RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

Mysql Dead Lock이란

그럼 Dead Lock은 왜 생기고 언제 생길까요? +

Deadlock trouble shooting

· 약 13분
박스터

이 글을 쓰는 이유

먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

------------------------
LATEST DETECTED DEADLock
------------------------
2023-07-21 01:49:54 281472560787424
*** (1) TRANSACTION:
TRANSACTION 1000560, ACTIVE 373 sec inserting
mysql tables in use 1, Locked 1
Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

*** (1) HOLDS THE Lock(S):
RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

*** (1) WAITING FOR THIS Lock TO BE GRANTED:
RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 946331, ACTIVE 507 sec inserting
mysql tables in use 1, Locked 1
Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

*** (2) HOLDS THE Lock(S):
RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

*** (2) WAITING FOR THIS Lock TO BE GRANTED:
RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

Mysql Dead Lock이란

그럼 Dead Lock은 왜 생기고 언제 생길까요? 저는 이 Log를 직접 마주하기 전까지는 Dead Lock이 그냥 Lock의 시간이 오래 걸릴 때 생기는 줄 알았습니다. 하지만 그렇게 간단하게 발생하는 것은 아니였습니다.

  1. 상호 배제(Mutual Exclusion): MySQL은 기본적으로 트랜잭션 내에서 잠금(Lock)을 사용하여 데이터의 상호 배제를 제어합니다. 따라서 두 개 이상의 트랜잭션이 같은 데이터를 동시에 변경하려고 할 때, 해당 데이터에 대한 잠금이 설정되어 상호 배제 조건이 만족됩니다.

  2. 점유와 대기(Hold and Wait): 트랜잭션이 이미 하나 이상의 데이터를 잠근 상태에서 다른 데이터의 잠금을 얻기 위해 대기하고 있는 경우 점유와 대기 조건이 만족됩니다. 즉, 트랜잭션이 자신이 점유한 데이터를 유지한 상태에서 다른 데이터에 대한 잠금을 기다리고 있어야 합니다.

  3. 비선점(Non-Preemption): MySQL에서는 기본적으로 트랜잭션이 다른 트랜잭션이 점유한 데이터의 잠금을 강제로 해제할 수 없습니다. 따라서 비선점 조건이 만족됩니다.

  4. 순환 대기(Circular Wait): 두 개 이상의 트랜잭션이 각각 서로가 기다리는 데이터의 잠금을 보유해야 순환 대기 조건이 만족됩니다. 예를 들면, 트랜잭션 A가 데이터 X의 잠금을 기다리고, 트랜잭션 B는 데이터 Y의 잠금을 기다리며, 트랜잭션 C는 데이터 Z의 잠금을 기다리는 상태가 발생한다면 순환 대기 조건이 성립합니다.

사실 기본 컴퓨터 시스템의 dead Lock과 유사한 조건입니다. 이 부분을 모두 만족해야 데드락이 발생합니다. 하나씩 알아보겠습니다. 먼저 개발 서버에서 발생한 데드락으로 살펴보겠습니다.

*** (1) TRANSACTION:
TRANSACTION 1000560, ACTIVE 373 sec inserting
mysql tables in use 1, Locked 1
Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

*** (1) HOLDS THE Lock(S):
RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap


-------------------------------------------------------------------------
*** (2) TRANSACTION:
TRANSACTION 946331, ACTIVE 507 sec inserting
mysql tables in use 1, Locked 1
Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

*** (2) HOLDS THE Lock(S):
RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

1번 트랜잭션 1000560이 charge_status 테이블에 insert ~ on duplicate key update ~ 쿼리를 발생시키기 위해 space id 64 page no 742 n bits 424 index PRIMARY of table 에 X Lock을 가지고 있습니다 그리고 2번 트랜잭션 946331 도 똑같은 테이블에 비슷한 쿼리를 발생시키려고 합니다. 그리고 해당 트랜잭션도 X Lock을 가지고 있습니다.

저희 팀에 데드락이 발생한 이유

먼저 저희 팀은 공공 API를 통해 전기차 충전소 정보를 cron으로 업데이트 해주고 있습니다. @@ -26,7 +26,7 @@ 트랜잭션을 오래 가지고 있으면 Lock을 가지고 있는 시간이 오래걸립니다. 그래서 트랜잭션을 작게 분리할 수 있습니다. 페이징을 통해 트랜잭션을 작게 분리하다보면 쿼리가 여러번 나가 성능상 문제가 생길 수 있을 것 같습니다.

  • INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 사용하지 않기 해당 sql이 아닌 INSERT IGNORE을 사용하여 추가된 정보만 넣고, update는 다른 작업으로 분리하기
  • 이런 방법들을 사용하면 될 것 같았습니다. 그 중 저는 현재는 간단하게 2번째 방법이 제일 나을 것 같다는 생각에 쿼리를 수정했습니다.

    그리고 문제를 해결했습니다. 해당 문제가 발생하게 되어 좀 더 재밌는 것들을 고민하고 공부할 수 있는 저희 팀에게 감사하고 모르는 키워드를 많이 알려준 누누에게 감사합니다.

    아직 배우는 단계라 정확한 정보가 아닐 수 있습니다. 부족한 부분에 대해 많은 지적 부탁드립니다.

    - - + + \ No newline at end of file diff --git a/24.html b/24.html index 5057c9f3..0b90e875 100644 --- a/24.html +++ b/24.html @@ -5,12 +5,12 @@ Out of memory trouble shooting | CAR-FFEINE - - + +
    -

    Out of memory trouble shooting

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. +

    Out of memory trouble shooting

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. error-log

    왜 발생했을까

    먼저 간단히 저희가 처한 상황에 대해 설명드리겠습니다.

    처음 어플리케이션을 실행하면 공공 API를 호출하여 충전소와 충전기에 대한 모든 정보들을 가져와 저장합니다. (충전소 약 6만 곳 + 충전기 약 23만 기)

    하지만 이러한 정보들은 수정이 될 수 있고, 충전소와 충전기가 추가될 수 있습니다.

    그러므로 정확한 정보가 사용자에게 가장 중요시되는 서비스에서 이러한 정보들이 늦게 반영이 된다거나, 반영이 되지 않는다면 저희 서비스를 사용할 사용자가 없을 것이라 판단했습니다.

    그래서 하루에 한 번 충전소와 충전기들의 정보를 업데이트하고, 추가된 충전소와 충전기를 저장하는 로직을 만들었습니다.

    대략적인 로직은 아래와 같습니다.

        public void updatePeriodicStations() {
    List<Station> stations = requestStations();
    stationUpdateService.updateStations(stations);
    }

    public void updateStations(List<Station> updatedStations) {
    List<Station> stations = stationRepository.findAllFetch();

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    }

    간단하게 말씀드리면 requestStations() 메서드는 공공 API에서 모든 충전소와 충전기를 요청하고 받아오는 메서드입니다. 23만 + 6만개의 정보를 받아오는 것입니다. 이렇게 많은 정보를 받아오고 메모리에 올린다는 것은 누가봐도 비효율적입니다. 하지만 이러한 선택을 한 이유는 공공 API는 저희가 어떤 방식으로 보내줄 지 모른다는 것이였습니다. 그래서 어쩔 수 없이 23만건을 모두 요청해야한다는 부분은 바꿀 수 없는 한계입니다.

    그 다음으로는 요청해서 받아온 데이터들과 데이터베이스에 저장되어 있던 데이터들을 findAll()을 통해 비교하고 새로운 충전소와 충전기는 저장하고, 업데이트된 충전소와 충전기는 수정합니다.

    이런 로직은 총 (23 + 6) * 2 만건의 객체 약 58만개를 Heap 메모리에 적재합니다. 많다고는 생각했지만, 일단 제 로컬환경에서는 잘 작동했고, 기능 구현이 우선이기 때문에 추후에 개선을 하기로 하고 넘어갔습니다.

    하지만 개발 서버 배포를 하고 다음날 서버가 접속이 되지 않는 것을 확인했고, 로그를 보니 위의 사진과 같이 OOM이 발생한 것을 확인할 수 있었습니다.

    해결 방안

    Heap size 조절하기

    일단 임시 방편으로 Heap memory의 최대 크기를 늘리는 법이였습니다. JVM은 실행되는 환경에 따라 힙 메모리의 최대 사이즈를 정합니다. 힙 메모리는 설정하지 않으면 해당 환경의 메모리 1/4로 설정합니다. @@ -31,7 +31,7 @@ 하지만 직접 확인해보기 전까지는 확신할 수 없으니 간단히 Runtime 클래스에서 제공해주는 totalMemory(), freeMemory() 메서드를 통해 알아보겠습니다.

        @Test
    void 페이징을_사용한_조회() {
    List<Station> stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();
    System.out.println("paging 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    @Test
    void 페이징을_사용하지_않고_조회() {
    List<Station> stations = stationRepository.findAllFetch();

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();

    System.out.println("findAll() 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    findAll paging 확연히 차이가 나는 것을 확인할 수 있습니다.

    물론 테스트코드에서는 23만건의 API 요청은 같은 조건이니 배제하고 확인했습니다.

    이로써 하나의 문제가 또 해결된 것 같습니다.

    아직 배우는 단계라 혹시 틀린 점이 있다면 지적 부탁드리겠습니다.

    Reference

    - - + + \ No newline at end of file diff --git a/25.html b/25.html index 3b95af42..51a63a99 100644 --- a/25.html +++ b/25.html @@ -5,12 +5,12 @@ flyway를 이제서야 적용하는 이유 | CAR-FFEINE - - + +
    -

    flyway를 이제서야 적용하는 이유

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. +

    flyway를 이제서야 적용하는 이유

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. 그래서 저희는 아래와 같이 email을 추가합니다.

    class Member {

    private Long id;
    private String name;
    private String email;
    }

    그리고 다시 jpa의 ddl-auto 속성 중 create를 사용해서 새로운 테이블을 만들었습니다. 기존의 테이블을 다 날리면서요.

    하지만 저희의 데이터베이스의 데이터들을 그냥 drop해도 되는 것일까요? 개발 서버라도 힘들게 쌓은 데이터들을 테이블이 조금 변경되었다고 날려버리는 것은 바보같은 일이라고 생각했습니다. 그러면 ddl-auto의 다른 조건인 update를 사용하면 될 것 같습니다. 그랬더니 jpa가 아래와 같이 쿼리를 이쁘게 만들어 줬습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    update를 사용하니 아주 편하게 칼럼이 추가되는 것을 볼 수 있습니다.

    하지만 여기서 또 아래와 같은 요구사항이 추가되었습니다.

    email의 제약조건으로 null이 되면 안되고, 길이는 20자가 되어야합니다. @@ -21,7 +21,7 @@ 거기에 file을 만듭니다. 파일 이름이 중요한데요 V1__init.sql 이러한 방식으로 V{version 숫자}__{어떠한 파일인지에 대한 이름}.sql 언더스코어 2개는 필수로 작성해야합니다.

    create table member(
    id bigint auto_increment primary key,
    name varchar(255) null,
    );

    이렇게 V1__init.sql에 대한 파일을 작성했습니다. 이제는 email을 추가한다는 요구사항을 반영해보겠습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    이렇게 새로운 파일을 만들어서 해당 스크립트를 작성했습니다. 파일명이 중요한데요, 이전 파일의 숫자보다 +1 이 되는 숫자를 V 뒤에 붙입니다.

    따라서 이번 파일은 V2__add_column_email.sql 이라고 만들었습니다.

    그럼 이제 또 시간이 지나 회원이 많아졌습니다. 하지만 email이 없는 사용자도 많습니다. 이 상황에서 email을 not null로 변경해야한다는 요구사항이 생겼습니다.

    그러면 아래와 같이 반영할 수 있습니다.

    ALTER TABLE member
    MODIFY email VARCHAR(20) NOT NULL default 'default'

    이렇게 V3__add_constraints.sql 파일을 만들었습니다. 그러면 null이 있던 row들은 email이 default가 되고 not null 제약조건이 활성화 된 것을 볼 수 있습니다.

    그러면 주어진 요구사항은 모두 만족할 수 있습니다. 거기에다 v1, v2, v3 가 나뉘어져있어서 어느 커밋부터 해당 sql이 추가되었는지도 확인할 수 있습니다.

    그리고 ddl-auto update를 사용하면 반영되지 않았던 제약조건의 추가도 확인할 수 있습니다. 그러면 ddl-auto의 속성을 validate로 변경하여, db schema와 entity의 필드가 다르면 어플리케이션이 실행되지 않도록 해서 좀 더 안전한 개발을 할 수 있습니다.

    결론

    flyway는 roll back을 하는 것이 유료라서, production 서버에서 혹은 롤백을 해야하는 일이 있는 서버에서는 사용하는 것이 좋지 않지만, 이와 같이 데이터를 drop 할 수 없는 상황이라면, 사용하지 않을 이유가 없어보이는 좋은 도구입니다.

    짧은 글 읽어주셔서 감사합니다.

    - - + + \ No newline at end of file diff --git a/26.html b/26.html index a327ea70..b55a5fc2 100644 --- a/26.html +++ b/26.html @@ -5,12 +5,12 @@ 카페인 팀 클라이언트의 테스트 자동화 | CAR-FFEINE - - + +
    -

    카페인 팀 클라이언트의 테스트 자동화

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. +

    카페인 팀 클라이언트의 테스트 자동화

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. 기본 설정이 간편하고, 빠르게 테스트를 실행할 때 굉장히 유용합니다. 함수를 mocking하여 의존성이 강한 함수를 제거하여 원하는 테스트를 쉽게 구성할 수 있다는 특징이 있습니다.

    React Testing Library

    React Testing Library는 리액트 애플리케이션의 UI를 테스트하기 위한 라이브러리입니다. React 컴포넌트를 호출하여, 사용자의 의도대로 조작할 수 있는 행위를 정의할 수 있습니다. @@ -22,7 +22,7 @@ 하지만 Storybook을 이용하면 특정 컴포넌트를 Storybook 위에 올려놓고 테스트를 할 수 있어 빠르게 작업이 가능합니다. 인터렉션이나 웹접근성을 확인해주는 플러그인도 존재하여 프론트엔드 개발에서 굉장히 중요한 역할로 부상했습니다.

    저희 팀은 이외에 Cypress를 사용하는 것도 고려하였으나, 지도와 결합된 애플리케이션을 테스트하기에 다소 어려움이 있어 위 라이브러리들을 개발에 활용했습니다.

    저희는 위 테스팅 라이브러리들을 원활히 활용하기 위해 테스트 자동화를 구축했습니다.

    Jest와 React Testing Library 테스트 자동화

    name: frontend-test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**
    - .github/**

    permissions:
    contents: read

    jobs:
    test:
    name: test-when-pull-request
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Checkout PR
    uses: actions/checkout@v2
    - name: Install dependencies
    run: npm install
    - name: Test
    run: npm run test

    이벤트 트리거 설정

    pull_request 이벤트가 발생하였을 때, 해당 이벤트가 main 브랜치와 develop 브랜치에서만 동작합니다.

    변경 사항 경로 제한

    테스트를 실행할 때는 frontend 디렉토리와 .github 디렉토리 내의 파일들을 고려하도록 했습니다. 백엔드와의 환경 분리를 위해 이러한 접근 제한을 했습니다.

    권한 설정

    permissions은 읽기 권한만 설정되어 있어 코드나 파일을 변경을 방지합니다.

    작업(Job) 설정

    test라는 이름의 작업을 정의하였고, 이 작업에서는 Ubuntu 환경에서 테스트를 실행합니다. test라는 이름의 환경 변수를 사용합니다. 테스트는 (카페인 팀 레포지토리의) frontend 디렉토리에서 작업하도록 하였습니다.

    스텝(Step) 설정

    코드를 체크아웃하고, 의존성을 설치하며, 테스트를 실행하는 세 가지 단계로 구성되어 있습니다.

    이러한 설정을 통해 PR에 코드가 올라올 때 자동으로 프론트엔드 테스트가 실행됩니다.

    이러한 테스트 자동화 전략은 프론트엔드 애플리케이션을 안정적이게 개발하고 유지할 수 있도록 도와줍니다.

    Storybook의 빌드 자동화

    name: storybook-deploy

    on:
    pull_request:
    branches:
    - develop
    paths:
    - frontend/**
    - .github/**

    jobs:
    build:
    runs-on: ubuntu-22.04
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Setup Repository
    uses: actions/checkout@v3

    - name: Set up Node
    uses: actions/setup-node@v3
    with:
    node-version: 18.16.0

    - name: Install dependencies
    run: npm install

    - name: Cache node_modules
    id: cache
    uses: actions/cache@v3
    with:
    path: '**/node_modules'
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
    ${{ runner.os }}-node-

    - name: storybook build
    run: npm run build-storybook

    - name: Upload storybook build files to temp artifact
    uses: actions/upload-artifact@v3
    with:
    name: Storybook
    path: frontend/storybook-static
    deploy:
    needs: build
    runs-on: self-hosted
    steps:
    - name: Remove previous version app
    working-directory: .
    run: rm -rf dist

    - name: Download the built file to AWS
    uses: actions/download-artifact@v3
    with:
    name: Storybook
    path: frontend/dev/dist

    - name: Move folder
    working-directory: frontend/dev/
    run: |
    rm -rf /home/ubuntu/dist/*
    cp -r ./dist /home/ubuntu

    - name: comment PR
    uses: thollander/actions-comment-pull-request@v1
    env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    with:
    message: '🚀storybook: https://storybook.carffe.in/'

    비슷한 코드이지만, 매번 PR이 열릴 때 마다 스토리북이 자동으로 빌드 및 배포됩니다. 배포가 완료되면 배포된 URL을 알려 코드 리뷰할 때 참고할 수 있도록 돕습니다.

    이상 카페인 팀에서 사용하고 있는 테스팅 라이브러리와 테스트 자동화 방법을 알아봤습니다.

    - - + + \ No newline at end of file diff --git a/27.html b/27.html index bc3ee98a..d8e5ceba 100644 --- a/27.html +++ b/27.html @@ -5,19 +5,19 @@ EC2 서버 추가와 동시에 Dev, Prod 환경 분리하기 | CAR-FFEINE - - + +
    -

    EC2 서버 추가와 동시에 Dev, Prod 환경 분리하기

    · 약 3분
    제이

    안녕하세요. +

    EC2 서버 추가와 동시에 Dev, Prod 환경 분리하기

    · 약 3분
    제이

    안녕하세요. 카페인 팀의 제이입니다.

    오늘은 저희가 EC2 인스턴스를 받으면서, 어떻게 dev, prod 배포 환경을 분리했는지 적어보려고 합니다. 기존 카페인 팀의 EC2 구조는 여기서 보실 수 있습니다.


    기존 상황과 문제점

    카페인 팀에서는 기존에 3대의 EC2 인스턴스가 있었습니다. 각각 infra, dev, db 역할을 하는 인스턴스로 존재하고 있었습니다.

    저희는 release 브랜치를 통해 dev서버에 배포를 한 후 검증이 된다면, 실제 사용자들이 사용하는 prod 서버에 배포하고 있습니다.

    문제는 기존의 3대의 인스턴스 중에서 dev 서버에 있었습니다. 기존 dev 서버는 총 4개의 서버를 배포하고 있었고 배포하는 서버는 다음과 같습니다. prod-BE, prod-FE, dev-BE, dev-FE

    그리고, 기존 dev 서버에서는 환경을 분리해주기 위해서 Nginx를 통해서 포트 포워딩은 다음과 같이 해주었습니다.

    • prod-BE = 8080
    • prod-FE = 3031
    • dev-BE = 8081
    • dev-FE = 3031

    카페인 팀에서는 dev, prod 환경이 분리되지 않아서 인스턴스의 사용량이 높았고, 이에 따라 추가적인 EC2 인스턴스가 필요했습니다.


    문제 해결

    다행히도 카페인 팀에서 추가적인 EC2 인스턴스를 받았고, 저희는 배포 환경을 분리할 수 있었습니다.

    dev-prod-server

    이와 같이 기존 dev 서버 한 개가 infra 서버와 연결되어 있었는데, 두 갈래로 나뉜 것을 확인하실 수 있습니다.

    먼저 배포는 다음과 같이 진행됩니다.

    release branch에 push가 일어나면 dev서버에 배포 작업이 이뤄집니다. prod branch에 push가 일어나면 prod서버에 배포 작업이 이뤄집니다.

    또한 기존 dev 서버에서 4개의 포트포워딩 또한 굳이 그럴 필요가 없어졌습니다. 새로운 서버가 추가됨에 따라 dev, prod 서버 각각 Nginx에서 포트포워딩을 동일하게 FE:3000, BE:8080 으로 변경하였습니다.

    이렇게 카페인 팀에서는 dev, prod 환경을 분리했습니다.

    감사합니다!

    - - + + \ No newline at end of file diff --git a/28.html b/28.html index d321cf9a..e7f216dc 100644 --- a/28.html +++ b/28.html @@ -5,13 +5,13 @@ 카페인 팀에서 사용한 지도 시스템에 관하여 | CAR-FFEINE - - + +
    -

    카페인 팀에서 사용한 지도 시스템에 관하여

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    - - +

    카페인 팀에서 사용한 지도 시스템에 관하여

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git a/29.html b/29.html index 0951ff2c..dc071829 100644 --- a/29.html +++ b/29.html @@ -5,12 +5,12 @@ useSyncExternalStore로 만들어보는 전역상태관리 도구 | CAR-FFEINE - - + +
    -

    useSyncExternalStore로 만들어보는 전역상태관리 도구

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. +

    useSyncExternalStore로 만들어보는 전역상태관리 도구

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. 생성된 StateManager 인스턴스가 반환되어 store가 곧 초기 값을 가지는 StateManager가 됩니다.

    no offset

    예를 들어, 다음과 같은 코드가 있다고 할 때

    export const countStore = store<number>(0);

    countStore는 곧 0을 초기값으로 가지는 StateManager 인스턴스이기도 하게 됩니다.

    그러면 StateManager에 대해서 알아보겠습니다.

    StateManager는 react 바깥에 있는 어떤 저장소이다.

    (근데 이게 그냥 저장소는 아니고 좀 특별한 저장소다.)

    export type SetStateCallbackType<T> = (prevState: T) => T;

    export interface DataObserver<T> {
    setState: (param: SetStateCallbackType<T> | T) => void;
    getState: () => T;
    subscribe: (listener: () => void) => () => void;
    emitChange: () => void;
    }

    class StateManager<T> implements DataObserver<T> {
    public state: T;
    private listeners: Array<() => void> = [];

    constructor(initialState: T) {
    this.state = initialState;
    }

    setState = (param: SetStateCallbackType<T> | T) => {
    if (param instanceof Function) {
    const newState = param(this.state);
    this.state = newState;
    } else {
    this.state = param;
    }

    this.emitChange();
    };

    getState = () => {
    return this.state;
    };

    subscribe = (listener: () => void) => {
    this.listeners = [...this.listeners, listener];

    return () => {
    this.listeners = this.listeners.filter((l) => l !== listener);
    };
    };

    emitChange = () => {
    for (const listener of this.listeners) {
    listener();
    }
    };
    }

    export default StateManager;

    StateManager 클래스는 외부에서 받아온 초기값을 상태로 가집니다. setState, getState, subscribe, emitChange를 메서드로 가집니다. 여기서 작성된 코드들은 react에서 외부 저장소와 소통하기 위한 최소한의 규격입니다.

    • subscribe: 단일 콜백 인수를 사용하여 스토어에 구독하는 함수입니다. 스토어가 변경되면 제공된 콜백을 호출해야 합니다. 그러면 구성 요소가 다시 렌더링 됩니다. 구독 기능은 구독을 정리하는 기능을 반환해야 합니다. (구독에 관련된 데이터는 리스너 배열 필드에 넣어서 관리합니다.)

    • emitChange: 리스너 배열 필드에 담겨있는 모든 리스너를 실행합니다. 즉, 구독된 어떤 것을 순차적으로 실행하게 합니다. 이는 리액트 DOM을 강제로 일깨워주는 옵저버 패턴의 역할을 하게 됩니다. 이 과정 때문에 react DOM이 정확한 재 렌더링 지점을 파악할 수 있게됩니다. (최적화 문제에서 자유로워짐)

    • setState: 상태를 업데이트합니다. 다만 상태가 업데이트 됐음을 알려야 하므로 emitChange를 실행시켜 react DOM을 강제로 동기화시킵니다.

    • getState: 호출되는 순간 현재 상태 값을 읽습니다.

    좀 어렵지만 리액트에서 이런 규격을 가져야 useSyncExternalStore훅을 쓸 수 있게 해 줍니다. @@ -24,7 +24,7 @@ 빨간색은 개발자가 직접 건들지 못하지만 간접적으로 사용할 수 있는 영역 노란색은 React 18 엔진의 영역입니다.

    이외에 제공되는 다른 커스텀 훅들도 거의 비슷한 구조를 띄고 있습니다.

    // 추가로 구현할 수 있는 함수들

    export const useSetExternalState = <T>(store: DataObserver<T>) => {
    const { setState } = store;

    return setState;
    };

    export const useExternalValue = <T>(store: DataObserver<T>) => {
    const { subscribe, getState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return state;
    };

    // 바닐라JS 영역에서 자연스러운 읽기를 지원하는 함수

    export const getStoreSnapshot = <T>(store: DataObserver<T>) => {
    return store.getState();
    };

    더 다양한 예제는 여기에서 확인할 수 있고 작성한 라이브러리 코드 전문은 여기에서 확인할 수 있습니다.

    겨우 파일 수십 줄로 만든 초경량 상태관리 라이브러리였습니다

    - - + + \ No newline at end of file diff --git a/3.html b/3.html index 6fc7730f..3b592190 100644 --- a/3.html +++ b/3.html @@ -5,13 +5,13 @@ Java 17 을 도입한 이유 | CAR-FFEINE - - + +
    -

    Java 17 을 도입한 이유

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    - - +

    Java 17 을 도입한 이유

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    + + \ No newline at end of file diff --git a/30.html b/30.html index e0e09ba1..6d2fb8cc 100644 --- a/30.html +++ b/30.html @@ -5,17 +5,17 @@ 카페인 팀의 협업 일화 | CAR-FFEINE - - + +
    -

    카페인 팀의 협업 일화

    · 약 3분

    레벨3 때 프로젝트를 진행하면서, 저희 팀은 많은 협업을 진행했습니다.

    처음에는 프론트엔드, 백엔드 서로 각각의 분야만 개발을 해왔고 협업이 익숙하지 않아서 많은 부분에서 문제가 발생하곤 했습니다.

    이런 과정에서 저희 팀은 어떻게 대처를 했을까요?

    한 가지 일화로 저희 팀의 제이와 센트의 필터 적용 부분을 설명 드리겠습니다.

    조회 시에 필터 적용 부분을 만들 때 기존에 작성해둔 API 명세대로 서로 작업을 진행하고, 중간에 생각하지 못한 부분에 대해서는 서로 대화를 많이 했습니다. +

    카페인 팀의 협업 일화

    · 약 3분

    레벨3 때 프로젝트를 진행하면서, 저희 팀은 많은 협업을 진행했습니다.

    처음에는 프론트엔드, 백엔드 서로 각각의 분야만 개발을 해왔고 협업이 익숙하지 않아서 많은 부분에서 문제가 발생하곤 했습니다.

    이런 과정에서 저희 팀은 어떻게 대처를 했을까요?

    한 가지 일화로 저희 팀의 제이와 센트의 필터 적용 부분을 설명 드리겠습니다.

    조회 시에 필터 적용 부분을 만들 때 기존에 작성해둔 API 명세대로 서로 작업을 진행하고, 중간에 생각하지 못한 부분에 대해서는 서로 대화를 많이 했습니다. 대화를 하면서 진행을 했지만 발견하지 못한 문제점이 있었습니다.

    바로 충전소 회사 명에서 key 값을 어떻게 하냐에 문제였습니다. 예를 들면 충전소 회사 명에서 광주시라는 이름이 있었는데, 이 필터는 실제로 두 가지가 존재했습니다.

    하나는 경기도 광주, 하나는 전라도 광주였습니다.

    이런 부분에서 불필요한 지역의 필터까지 걸리게 되는 문제가 있었습니다. 협업하는 과정에서 이를 발견했고, 즉각 조치를 취했습니다.

    조치를 취할 때 서로에게 각자 편한 방법이 있었지만, 단순히 서로에게 편한 작업을 하지 않았고, 팀원과 상의하면서 추후 진행에 문제 없는 방향을 찾고 진행할 수 있었습니다.

    지금 생각해보면 만약 각자에게 편한 방식으로 문제를 수정했다면, 다른 팀원이 다른 작업을 할 때 지장이 갔을 수도 있고 불필요한 작업을 했을 수도 있었을 것 같습니다.

    이 시점을 계기로 저희 팀끼리 예상하지 못한 문제를 작업 중에 발견하더라도 다른 팀원에게 공유하고 서로 짧은 회의를 통해 문제 해결 방안을 같이 찾는 것이 자연스럽게 팀문화로 자리 잡게 되었습니다.

    - - + + \ No newline at end of file diff --git a/31.html b/31.html index 84134c4b..39bc0e40 100644 --- a/31.html +++ b/31.html @@ -5,16 +5,16 @@ 조회 성능 개선하기 | CAR-FFEINE - - + +
    -

    조회 성능 개선하기

    · 약 14분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 카페인 팀 프로젝트에는 사용자가 보고있는 지도에 충전소를 보여주는 조회 기능이 가장 중요하고, 제일 요청이 많이 들어옵니다.

    하지만 조회 성능이 좋지 않은 까닭인지 여러 사용자가 접속하면 아래와 같이 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. +

    조회 성능 개선하기

    · 약 14분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 카페인 팀 프로젝트에는 사용자가 보고있는 지도에 충전소를 보여주는 조회 기능이 가장 중요하고, 제일 요청이 많이 들어옵니다.

    하지만 조회 성능이 좋지 않은 까닭인지 여러 사용자가 접속하면 아래와 같이 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. cpu

    조회 성능 개선하기

    먼저 제가 개선하기 위해 사용했던 방법들에 대해 적어보겠습니다.

    DTO 이용하기

    현재 구조는 아래의 JPA를 이용해 아래와 같은 쿼리로 entity로 데이터를 조회합니다.

     select distinct station.station_id,
    charger.charger_id,
    charger.station_id,
    chargerStatus.charger_id,
    chargerStatus.station_id,
    station.created_at,
    station.updated_at,
    station.address,
    station.company_name,
    station.contact,
    station.detail_location,
    station.is_parking_free,
    station.is_private,
    station.latitude,
    station.longitude,
    station.operating_time,
    station.private_reason,
    station.station_name,
    station.station_state,
    charger.created_at,
    charger.updated_at,
    charger.capacity,
    charger.method,
    charger.price,
    charger.type,
    charger.station_id,
    charger.charger_id,
    chargerStatus.created_at,
    chargerStatus.updated_at,
    chargerStatus.charger_condition,
    chargerStatus.latest_update_time
    from charge_station station
    inner join
    charger charger on station.station_id = charger.station_id
    inner join
    charger_status chargerStatus on charger.charger_id = chargerStatus.charger_id
    and charger.station_id = chargerStatus.station_id
    where station.latitude >= 37.5019194727953082567
    and station.latitude <= 37.5092305272047217433
    and station.longitude >= 127.044542269049714936
    and station.longitude <= 127.058071330950285064

    JPA를 통해 이러한 방식으로 조회한다면 아주 편하게 값을 가져오고, fetch join을 통해 하위의 entity들의 정보도 깔끔하게 가져옵니다.

    가져온 값으로 필요한 정보들을 매핑하고 가공하여 응답을 내려줬습니다.

    하지만 조회만을 위해 JPA의 entity를 조회한다는 것은 여러 단점이 존재합니다.

    제일 먼저 응답을 내려줄 때 불필요한 데이터까지 모두 조회를 한다는 부분입니다. 이렇게 많은 필드들이 있습니다. 하지만 응답에서는 대부분의 경우 모든 정보가 필요하지 않습니다. 그리고 모든 정보를 다 보내주는 것도 좋지 않습니다. 대량의 데이터를 조회할 때의 성능이 아주 나빠집니다.

    그래서 필요한 칼럼만 조회하는 것이 좋습니다.

    그리고 또 다른 단점으로는 JPA로 entity를 조회할 때 Hibernate 캐시에 저장한다던가, One To One 에서 N+1 쿼리가 발생하기 때문에 성능적인 이슈가 여러가지 있습니다.

    그래서 조회만 하는 api라면 DTO Projection으로 하는 것이 좋을 것 같습니다. 그리고 아래와 같이 변경하였습니다.

    SELECT s.station_id,
    s.station_name,
    s.latitude,
    s.longitude,
    s.is_parking_free,
    s.is_private,
    sum(case
    when cs.charger_condition = 'STANDBY' then 1
    else 0
    end),
    sum(case
    when c.capacity >= 50 then 1
    else 0
    end)
    FROM charge_station s
    inner join charger c on (c.station_id = s.station_id)
    inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)
    where s.station_id in (?, ?)
    group by s.station_id;

    이렇게 필요한 칼럼만 조회하는 방식으로 변경하여, 선릉역 근처를 조회하는 기준으로 약 450ms -> 350ms로 개선되었습니다.

    하지만 아직도 너무 느린 것을 확인할 수 있습니다. 그래서 실행 계획을 확인했습니다.

    실행 계획 확인하기

    sql의 실행 계획은 아주 중요하고 성능을 개선할 때 아주 유용합니다.

    실행 계획에는 여러가지 정보들이 있습니다.

    1. ID: 실행 계획 내에서 각 작업 또는 단계를 식별하는 일련번호입니다. 실행 계획은 여러 단계로 나뉘며, ID를 통해 이러한 단계를 식별할 수 있습니다.

    2. Select Type: 쿼리의 각 단계(예: SIMPLE, PRIMARY, SUBQUERY)에 대한 실행 유형을 나타냅니다. 이는 MySQL이 데이터를 선택하고 처리하는 방식을 나타냅니다.

    3. Table: 실행 계획에 포함된 테이블의 이름 또는 별칭입니다. 어떤 테이블이 사용되는지를 확인할 수 있습니다.

    4. Type: 테이블 접근 방식을 나타냅니다. 이 값은 인덱스 스캔, 풀 테이블 스캔 등과 같은 값일 수 있으며, 성능에 큰 영향을 미칩니다.

    5. Possible Keys: 사용 가능한 인덱스를 나타냅니다. MySQL이 어떤 인덱스를 사용할 수 있는지 알려줍니다.

    6. Key: 실제로 선택된 인덱스입니다. 이 값은 가능한 인덱스 중에서 실제로 사용되는 인덱스를 나타냅니다.

    7. Key Len: 사용된 인덱스의 길이를 나타냅니다.

    8. Ref: 인덱스를 사용하여 테이블 간의 연결을 나타내는 열입니다.

    9. Rows: 각 단계에서 예상되는 행의 수입니다. 이 값은 성능 평가에 중요한 역할을 합니다.

    10. Extra: 기타 정보를 제공합니다. 이 칼럼에는 추가 정보 및 힌트가 포함될 수 있습니다.

    이렇게 여러 칼럼이 있습니다. 그 중 성능에 큰 영향을 미치는 칼럼 두 가지만 자세히 알아보겠습니다.

    Type

    1. const : 쿼리에 Primary key 혹은 unique key 칼럼을 이용하는 where 조건절을 가지고 있고, 반드시 하나의 데이터를 반환하는 방식이다. (옵티마이저가 해당 부분은 상수로 처리하기 때문에 const라고 한다.)
    2. eq_ref : 조인에서 Primary key 혹은 unique key 칼럼을 이용하는 where 조건절을 가지고 있고, 반드시 하나의 데이터를 반환하는 방식이다. (const와 다른 점은 eq_ref는 조인에서 사용된다는 점이다.)
    3. ref : eq_ref와 다르게 join의 순서와 관계없이 사용된다. 그리고 primary key, unique key도 관계없다. 그냥 인덱스의 종류와 관계없이 = 조건으로 검색할 때 사용된다
    4. fulltext: mysql 전문 검색 인덱스를 사용해서 레코드에 접근하는 방법, 전문 검색할 컬럼에 인덱스가 있어야 한다. "MATCH ... AGAINST ..." 구문을 사용해서 실행된다
    5. range: 인덱스를 이용해서 검색하는데, 검색 조건이 >, >=, <, <=, BETWEEN, IN() 등의 연산자를 사용하는 경우이다. 보통의 인덱스 스캔이라고 하면 range, const, ref를 칭한다
    6. index: 인덱스 풀 스캔이다. 인덱스를 이용해서 테이블의 모든 레코드를 읽는다. 인덱스를 이용해서 테이블을 읽는 것이기 때문에 all보다는 빠르다.
    7. all: 테이블 풀 스캔이다. 테이블의 모든 레코드를 읽는다. 가장 느린 방법이다.

    실행 계획에서 자주 보이는 type들만 성능이 좋은 순으로 정리해봤습니다.

    Extra

    1. using filesort: 정렬을 위해 별도의 파일 정렬을 수행한다. 이는 인덱스를 사용하지 않고 정렬을 수행한다는 의미이다. 이는 성능에 좋지 않다.
    2. using index: 인덱스만으로 쿼리를 처리한다. 이는 인덱스만으로 쿼리를 처리하기 때문에 성능이 좋다.
    3. using join buffer: join이 되는 칼럼은 인덱스를 생성한다. 하지만 driven table에 적절한 인덱스가 없다면 driving table에 있는 모든 레코드를 읽어서 join을 수행한다. 그래서 이걸 보완하기 위해 driving table에 읽은 레코드를 임시 공간에 저장하는데 그 곳이 join buffer이다.
    4. using temporary: 쿼리를 처리하기 위해 임시 테이블을 생성한다. 인덱스를 사용하지 못하는 group by 쿼리가 대표적인 예이다.
    5. using where: mysql 엔진이 별도의 가공, 필터링 작업을 처리한 경우일 때만 나타난다. 범위 조건은 스토리지 엔진에서 처리되어 레코드를 리턴해주지만, 체크 조건은 mysql 엔진에서 처리된다.

    type뿐만 아니라 extra도 쿼리의 문제를 파악하는데 아주 큰 도움을 줍니다. 그 중 자주 보이는 것들에 대해서만 정리해봤습니다.

    그럼 아까 생성한 쿼리의 실행 계획을 확인해봅시다.

    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+
    | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+
    | 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where; Using temporary |
    | 1 | SIMPLE | charger | NULL | ALL | PRIMARY | NULL | NULL | NULL | 240340 | 10.00 | Using where; Using join buffer (hash join) |
    | 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |
    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+

    station 테이블에 대해서는 range 스캔, 임시 테이블을 생성하고 있습니다, 그리고 charger에서는 테이블 풀 스캔, join buffer까지 생성하고 있습니다. 다행히도 chargersta 테이블에서는 적당한 조건을 생성한 것 같습니다.

    다시 한번 쿼리를 보고 실행 계획이 이렇게 나온 이유를 알아보겠습니다.

    SELECT
    ...
    FROM charge_station s
    inner join charger c on (c.station_id = s.station_id)
    inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)
    where s.station_id in (?, ?)
    group by s.station_id;

    아까 얘기했던, using temporary와 using join buffer가 발생하는 이유의 공통점을 찾아보면, 인덱스가 문제인 것을 유추할 수 있습니다.

    station과 charger를 join할 때, driven table 즉, charger 테이블에 적절한 인덱스가 없어 성능이 나빠진 것이라 의심하여, 인덱스를 생성하고 다시 한번 실행 계획을 확인했습니다.

    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+
    | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+
    | 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where |
    | 1 | SIMPLE | charger | NULL | ref | PRIMARY,idx_station_id | idx_station_id | 1022 | charge.s.station_id | 3 | 100.00 | NULL |
    | 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |
    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+

    이렇게 charger 테이블에 인덱스를 생성한 것만으로도 실행 계획을 깔끔하게 개선했습니다.

    결과

    아래는 인덱스를 생성하기 전 실행 속도입니다.

    개선_전

    아래는 인덱스를 생성한 후 실행 속도입니다.

    개선_후

    315ms -> 24ms 로 약 13배 빨라진 것을 확인할 수 있습니다.

    결론

    실행 계획 확인은 필수입니다!

    참고

    real mysql 책

    - - + + \ No newline at end of file diff --git a/32.html b/32.html index 02c44b41..56f10d1b 100644 --- a/32.html +++ b/32.html @@ -5,12 +5,12 @@ 데이터베이스 레플리케이션으로 조회 성능 개선하기 | CAR-FFEINE - - + +
    -

    데이터베이스 레플리케이션으로 조회 성능 개선하기

    · 약 24분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 지난 글에서 설명했듯이 저희 프로젝트에서는 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. +

    데이터베이스 레플리케이션으로 조회 성능 개선하기

    · 약 24분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 지난 글에서 설명했듯이 저희 프로젝트에서는 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. 이 부분에 대해서는 조회 성능을 높혀 어느정도 해결하고자 했습니다. 하지만 조회가 아닌 많은 데이터를 일정한 주기로 업데이트 해줘야하는 로직도 포함되어 있기 때문에 업데이트를 할 때 조회를 하게 된다면 cpu 사용률은 비슷할 것입니다. 이 부분을 해결하고자 데이터베이스 레플리케이션을 알아보겠습니다.

    결론

    결론부터 말씀드리면 데이터베이스 레플리케이션을 적용한 후 성능이 눈에 띄게 좋아졌습니다. 해당 부분은 다음 포스팅에 작성하겠습니다 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 179 -> 366

    Response Time 550 ms -> 271 ms

    약 2배 가량 성능이 향상된 것을 볼 수 있습니다.

    데이터베이스 레플리케이션이란?

    데이터베이스 레플리케이션이란 하나의 데이터베이스에서 다른 하나 이상의 데이터베이스로 데이터의 복제 또는 복사를 수행하는 프로세스 또는 기술입니다. 데이터베이스 레플리케이션은 주로 다음과 같은 목적으로 사용됩니다

    1. 고가용성: 데이터베이스 서버의 장애가 발생했을 때, 레플리카 데이터베이스를 사용하여 시스템을 계속 운영할 수 있습니다. 이렇게 하면 서비스 중단 시간을 최소화하고 비즈니스 연속성을 유지할 수 있습니다.

    2. 성능 향상 : @@ -22,7 +22,7 @@ 소스 서버에서 커밋된 트랜잭션은 바이너리 로그에 기록되고, 레플리카 서버에서는 주기적으로 새로운 트랜잭션에 대한 바이너리 로그를 요청합니다. 이러한 방식은 소스 서버는 레플리카 서버가 제대로 변경 되었는지 알 수 없습니다. 즉 데이터 정합성에 문제가 생긴다는 단점이 있습니다. 하지만 이러한 방식은 소스 서버가 각 트랜잭션에 대해 레플리카 서버로 전송되는 부분을 고려하지 않는다는 점이 속도 측면에서 빠르고, 또 여러 대의 레플리카 서버를 구성하더라도 큰 성능 저하가 없다는 점이서 장점이 있습니다.

      반동기 복제

      반동기 복제는 비동기 복제보다 좀 더 데이터 정합성이 올라갑니다. 소스 서버는 변경된 트랜잭션이 있을 때 레플리카 서버가 다 전송이 되었다는 ACK 신호를 받기 때문에 확실히 알 수 있습니다. 하지만 전송여부만 확인하기 때문에 트랜잭션이 반영이 되었다는 보장은 없습니다. 반동기 복제 방식은 2가지가 있습니다.

      1. After sync: After Sync 방식은 소스 서버에서 트랜잭션을 바이너리 로그에 기록 후 Storage Engine에 바로 커밋하지 않습니다. 먼저 바이너리 로그에 기록 후 레플리카 서버의 ACK 응답을 기다립니다. 그리고 ACK 응답이 도착하면 그제서야 스토리지 엔진을 커밋하여 트랜잭션을 처리하고 결과를 반환합니다.
      2. After commit: After commit은 이름 그대로 커밋을 먼저 하는 것입니다. 트랜잭션이 생기면 먼저 바이너리 로그에 기록 후 소스 서버 스토리지 엔진에 커밋합니다. 그리고 레플리카 서버의 ACK 응답이 내려오면 클라이언트는 처리 결과를 얻고 다음 쿼리를 수행할 수 있습니다.

      먼저 after commit 방식은 소스 서버에 장애가 발생했을 때 팬텀 리드가 발생하게 됩니다. 트랜잭션이 스토리지 엔진 커밋까지된 후 레플리카 서버의 응답을 기다립니다. 이처럼 스토리지 엔진 커밋까지 완료된 데이터는 다른 세션에서도 조회가 가능합니다. 트랜잭션이 커밋되었고, 레플리카 서버로 아직 응답을 기다릴 때, 소스 서버에 장애가 발생한다면 새로운 소스 서버로 승격된 레플리카 서버에서 데이터를 조회할 때 자신이 이전 소스 서버에서 조회했던 데이터를 보지 못할 수도 있습니다.

      그리고 이처럼 레플리카 서버가 승격된 상황에 소스 서버의 장애가 복구되어 재사용할 경우 이미 커밋된 그 트랜잭션을 수동으로 롤백 시켜야만 데이터가 맞는 상황이 생깁니다.

      저희 팀의 복제 동기화 방식

      이러한 장단점으로 저희 팀은 데이터 무결성이 중요하다 판단되어 반동기 복제 방식을 사용하고, After Sync 방식을 적용하였습니다.

      복제 토폴리지

      복제 토폴리지는 여러가지 방식 중 자신의 상황과 가장 맞는 방식을 사용하면 될 것 같습니다. 저희 팀이 고려해야할 문제는 먼저 성능을 올려야 했고, 단일 장애포인트를 개선해야했습니다. 하지만 사용할 수 있는 서버는 2대 뿐이였습니다. 이러한 상황에서 어떤 방식을 택할 수 있을까요?

      싱글 레플리카

      가장 기본적이며 가장 많이 쓰이는 형태입니다. 어플리케이션에서 레플리카 서버에 읽기 요청을 전달하면, 레플리카 서버에 문제가 생겼을 때, 서비스 장애 상황이 발생할 수 있습니다. 그러므로 소스 서버에서 Read, Write를 둘 다 하고, 레플리카 서버는 failover를 위해 대기하는 예비용 서버로 구성합니다. 소스 서버에 장애가 발생했을 때 소스 서버를 대체하거나 데이터를 백업하는 용도로 사용합니다.

      멀티 레플리카

      싱글 레플리카와 비슷한 구성이지만 레플리카 서버가 한 대 더 추가된 구성입니다. 해당 방식은 SPOF 문제가 없기 때문에 레플리카 서버 하나를 읽기 전용 서버로 둘 수 있습니다. 읽기 작업을 분산함으로 어플리케이션의 성능을 향상 시킬 수 있습니다. 아까 말했던 장애 상황이 발생하면 예비용 서버인 Replica2 서버를 Source 서버 혹은 Replica1(읽기 전용) 서버로 대체할 수 있습니다.

      체인 복제

      레플리카 서버가 많아져 소스 서버의 바이너리 로그를 읽는 부하가 많아질 때 할 수 있는 구성입니다. 좀 전에 설명드렸던 멀티 레플리카 방식에서 똑같은 구성을 추가한 방식으로 볼 수 있습니다. Source 1 의 정보를 복제한 Replica 1-1, 1-2 서버는 빠르게 데이터가 반영되지만, Source1의 이벤트를 복제한 Source2를 복제한 Replica 2-1, 2-2 서버는 당연히 늦게 반영되기 때문에 해당 그룹은 예비용으로 사용합니다.

      듀얼 소스 복제

      데이터베이스 둘 다 소스 서버이면서 레플리카 서버인 경우입니다. 이 경우는 Active-Active구성과 Active-Passive 구성으로 나뉩니다

      Active-Active는 서버 둘 다 읽기와 쓰기가 가능한 형태입니다. 즉 부하를 분산시키기 위해 서버 모두 읽고 쓰는 작업을 하는 것입니다. 하지만 이러한 방식은 뻔한 단점이 있습니다. 서로의 이벤트가 동기화 되기 전에는 정합성이 깨질 수 있습니다. 또 동시에 같은 데이터에 대해 쓰기 작업을 수행할 때, 하나의 서버에서 쓰기가 완료되었더라도, 다른 하나의 서버에 늦게 끝난 쓰기가 있다면 마지막 트랜잭션인 늦게 끝난 쓰기 작업이 반영되어 예상하지 못한 결과가 나올 수 있습니다.

      또 다른 문제로는 Auto Increment를 사용할 때입니다. 새로운 데이터가 동시에 생성될 때 Auto Increment가 중복되는 에러가 발생할 수 있기 때문에 해당 토폴로지에서는 ID를 DB에 의존하지 않는 것이 좋습니다.

      Active-Passive 방식은 하나의 서버만 읽기와 쓰기 요청이 되지만, 나머지 서버는 대기하고 있습니다. 두 서버 모두 언제나 쓰기 작업이 가능한 형태이기 때문에 장애 발생 시 빠르게 Faliover할 수 있다는 점이 있습니다.

      멀티 소스 복제

      하나의 레플리카 서버가 다수의 소스 서버를 갖는 구성입니다. 데이터베이스 샤딩을 해뒀는데, 다시 하나의 서버로 통합하고 싶을 때 사용할 수 있습니다. 혹은 서로 다른 데이터를 한 곳에 백업을 할 때도 사용할 수 있습니다.

      저희 팀의 토폴로지 방식

      그럼 이렇게나 많은 구성 중에 저희 팀에서 택할 수 있는 토폴로지 방식은 싱글 레플리카 방식과 듀얼 소스 복제 방식 밖에 없습니다. 왜냐하면 주어진 서버가 2대뿐이기 때문입니다. 하지만 듀얼 소스 방식은 적용하는데 무리가 있는 부분이 있습니다. 일단 저희가 레플리케이션을 적용하려는 가장 큰 이유는 성능 이기 때문에 성능이 변하지 않는 듀얼 소스의 Active-Passive 방식은 제외하겠습니다. 그리고 Active-Active 방식은 부하를 분산시킬 수 있다는 장점이 있지만, 단점으로는 Auto Increment를 사용하는데에 위험이 있다는 점과, 데이터의 정합성 문제가 생길 수 있다는 점에서 듀얼 소스 방식은 제외하도록 했습니다.

      그럼 싱글 레플리카 방식을 적용할 수 밖에 없는데요. 싱글 레플리카의 방식은 가용성 문제를 해결하기 위해 만들어진 방식입니다. 하지만 저희 서비스는 현재 가용성보다 성능을 더 신경써야하는 상황이기때문에 싱글 레플리카 토폴로지를 구성하지만 레플리카 서버를 예비용이 아닌 읽기 전용 방식으로 사용하도록 하고, 가용성 부분을 포기하기로 정했습니다.

      코드에 적용하기

      replication-datasource Github 소스 코드를 참고하시거나, DB 복제, @Transactional에 따라 요청 분리해보기 글을 참고하여 따라하면 금방하실 수 있습니다!

      결론

      데이터베이스 레플리케이션 생각보다 어렵지 않습니다.

      데이터베이스 재밌습니다. 인프라도 재밌습니다.

      참고

      Real Mysql 8.0

    - - + + \ No newline at end of file diff --git a/33.html b/33.html index 8c7e4920..12149a81 100644 --- a/33.html +++ b/33.html @@ -5,12 +5,12 @@ 혼잡도 조회 속도를 파티셔닝과 인덱스로 개선해보기 | CAR-FFEINE - - + +
    -

    혼잡도 조회 속도를 파티셔닝과 인덱스로 개선해보기

    · 약 7분
    제이

    안녕하세요. +

    혼잡도 조회 속도를 파티셔닝과 인덱스로 개선해보기

    · 약 7분
    제이

    안녕하세요. 카페인 팀의 제이입니다.

    저희 서비스에서는 충전소의 요일과 시간대 별로 충전소 혼잡도 정보를 제공을 차별적인 기능으로 제공하고 있습니다.

    이를 구현하기 위해서 공공 데이터에서 정보를 수집하고있습니다. 혼잡도를 조회하기 위해서는 약 23만 건의 충전소 7일 24시간 = 약 4000만 건의 데이터 중에서 조회를 하는 형식으로 되어있습니다.

    너무 많은 데이터가 있다보니 조회 속도가 많이 느린데요. 오늘은 이를 어떻게 개선했는지 작성해보도록 하겠습니다.

    참고로 해당 글의 성능 측정에 이용한 데이터의 수는 약 20만 건입니다.


    문제 확인

    기존의 저희는 많은 양의 데이터를 감당하기 힘들어서 [오전, 오후] 이렇게 두 부분으로 나눠서 혼잡도를 조회했습니다.

    하지만 실제 배포를 하기 위해서 더이상은 오전 오후로 나눌 수가 없었는데요.

    정상적인 데이터를 제공하기 위해서 먼저 24시간 기준으로 혼잡도를 갱신하도록 로직부터 바꾸었습니다.

    위와 같이 코드를 바꾸니 바로 성능에 문제가 생겼습니다. @@ -30,7 +30,7 @@ 위와 같은 조회 쿼리가 나왔으므로 인덱스를 아래와 같이 station_id, day_of_week에 걸어주었습니다.

    img 위 실행 속도에서 execution time을 확인해보면 인덱스를 걸고 134ms -> 5ms로 성능이 많이 개선 되었음을 확인할 수 있습니다.

    img 실행 계획도 의도한대로 잘 나오는 것을 보실 수 있습니다.


    정리

    1. DB Partitioning - (day_of_week : 요일)을 기준으로 파티셔닝
    2. 조회 쿼리에 맞게 인덱스 설정
    3. API 수정 (모든 요일의 혼잡도 조회 -> 해당 요일의 혼잡도 조회)

    결과적으로 기존 혼잡도 조회시 511ms가 나왔으나, 요일 별 조회 및 파티셔닝 & 인덱스를 적용하고 execution time = 5ms로 개선

    - - + + \ No newline at end of file diff --git a/34.html b/34.html index cd52b2bf..583345ae 100644 --- a/34.html +++ b/34.html @@ -5,18 +5,18 @@ 캐시와 이분 탐색으로 조회 성능 개선하기 | CAR-FFEINE - - + +
    -

    캐시와 이분 탐색으로 조회 성능 개선하기

    · 약 13분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    이전 글에서도 계속 설명했듯이 조회 성능을 최대한 빠르게 하는 것이 저희 서비스에서 핵심이라고 생각하기 때문에 지금도 예전에 비해 빨라졌지만 다른 개선점이 보여 개선을 하고자합니다.

    조회 성능 개선하기 1 (인덱스)

    조회 성능 개선하기 2 (데이터베이스 복제)

    결론

    결론부터 말씀드리면 로컬에서 캐싱을 적용한 후 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 78 -> 128

    Response Time 1236 ms -> 751 ms

    64% 성능이 개선 되었습니다.

    (저번 성능 테스트의 결과가 다른 이유는 비즈니스 로직이 변경되어 조회 방식이 바뀌었기 때문입니다. 그래서 캐싱을 적용하기전, 한 후 를 비교했습니다.)

    Caching

    In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.

    캐싱은 위키 백과에서 위와 같이 설명하고 있습니다. 즉 메모리에 데이터를 복사본을 올려 좀 더 빠르게 데이터에 접근하는 방식입니다.

    캐싱의 단점은 수정, 삽입, 삭제가 되었을 때, 관리 포인트가 두 군데가 된다는 점입니다. 만약 데이터베이스에만 새로운 정보를 저장하고, 캐시에는 저장해주지 않는다면 사용자는 그 정보를 볼 수 없습니다.

    하지만 저희 서비스에서 적용한 이유는 충전기의 충전 상태 (충전 중, 대기중, 고장)에 대한 정보는 최신화가 되어야하지만, 충전소의 이름이라던지, 위치, 다른 정보들은 쉽게 변하지 않기 때문에 해당 정보를 캐싱한다면 좋을 것 같았습니다.

    캐싱 적용하기

    먼저 캐싱을 어디에서 하는지도 중요합니다. 크게 로컬 캐시글로벌 캐시로 나눌 수 있을 것 같습니다. +

    캐시와 이분 탐색으로 조회 성능 개선하기

    · 약 13분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    이전 글에서도 계속 설명했듯이 조회 성능을 최대한 빠르게 하는 것이 저희 서비스에서 핵심이라고 생각하기 때문에 지금도 예전에 비해 빨라졌지만 다른 개선점이 보여 개선을 하고자합니다.

    조회 성능 개선하기 1 (인덱스)

    조회 성능 개선하기 2 (데이터베이스 복제)

    결론

    결론부터 말씀드리면 로컬에서 캐싱을 적용한 후 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 78 -> 128

    Response Time 1236 ms -> 751 ms

    64% 성능이 개선 되었습니다.

    (저번 성능 테스트의 결과가 다른 이유는 비즈니스 로직이 변경되어 조회 방식이 바뀌었기 때문입니다. 그래서 캐싱을 적용하기전, 한 후 를 비교했습니다.)

    Caching

    In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.

    캐싱은 위키 백과에서 위와 같이 설명하고 있습니다. 즉 메모리에 데이터를 복사본을 올려 좀 더 빠르게 데이터에 접근하는 방식입니다.

    캐싱의 단점은 수정, 삽입, 삭제가 되었을 때, 관리 포인트가 두 군데가 된다는 점입니다. 만약 데이터베이스에만 새로운 정보를 저장하고, 캐시에는 저장해주지 않는다면 사용자는 그 정보를 볼 수 없습니다.

    하지만 저희 서비스에서 적용한 이유는 충전기의 충전 상태 (충전 중, 대기중, 고장)에 대한 정보는 최신화가 되어야하지만, 충전소의 이름이라던지, 위치, 다른 정보들은 쉽게 변하지 않기 때문에 해당 정보를 캐싱한다면 좋을 것 같았습니다.

    캐싱 적용하기

    먼저 캐싱을 어디에서 하는지도 중요합니다. 크게 로컬 캐시글로벌 캐시로 나눌 수 있을 것 같습니다. 글로벌 캐시의 장점은 스케일 아웃을 했을 때, 모든 서버가 다 같은 데이터를 바라보기 때문에 데이터 정합성이 좋아집니다. 하지만 저희 서비스는 단일 서버로 구성되어 있기 때문에, 로컬 캐시를 해도 문제가 없습니다. 그리고 글로벌 캐시를 적용하기 위해서는 Redis나 Memcached 같은 도구를 모든 팀원이 알아야하지만 로컬 캐시는 그렇게 하지 않더라도 편하게 적용할 수 있다는 점에서 로컬에 캐싱하는 방법을 적용해보겠습니다.

    캐싱할 정보 가져오기

    캐싱을 하기 위해서는 먼저 캐싱할 데이터를 가져와야합니다. 저희 서비스는 출장 혹은 여행을 가는 전기차 오너가 핵심 페르소나이기 때문에 사용자들이 찾는 정보의 위치는 불특정합니다. 서울에서 다른 지방으로 출장을 가는 경우도 있을 것이고, 지방에서 서울에 가는 경우도 있기 때문에, 모든 데이터를 캐싱해야할 것이라 판단했습니다.

    그래서 어플리케이션 실행 시에 모든 충전소를 캐싱하기로 선택했습니다.

    @Configuration
    public class InitialStationCache implements ApplicationRunner {

    private final StationCacheRepository stationCacheRepository;
    private final StationQueryRepository stationQueryRepository;

    @Override
    public void run(ApplicationArguments args) {
    log.info("Initialize station cache");
    List<StationInfo> stations = stationQueryRepository.findAll();
    stationCacheRepository.initialize(stations);
    log.info("Station cache initialized");
    log.info("Station cache size: {}", stations.size());
    }
    }

    위와 같이 ApplicationRunner를 구현하여 어플리케이션 실행 시 모든 충전소의 정보를 가져오도록 만들었습니다. 여기서 Entity인 Station을 가져오지 않은 이유는 크게 두가지가 있습니다.

    1. 지도로 조회하는 부분의 성능을 개선하고자 했지만, Entity에는 지도를 조회할 때 불필요한 정보도 있기 때문에 메모리상의 낭비가 생길 수 있습니다.
    2. Entity를 캐싱하게 된다면 hibernate 1차 캐시에도 적재되고, 힙 메모리에도 적재되는 일이 발생하여 메모리상 낭비라고 생각했습니다.

    범위 검색하기

    충전소의 데이터를 조회하는 조건은 위도, 경도의 최소, 최대값을 기준으로 만족하는 데이터를 보여줍니다. 아래와 같이 간단히 조건을 stream()의 filter()를 사용해서 구현했습니다.

    public class StationCacheRepository {

    private final List<StationInfo> cachedStations;

    public List<StationInfo> findByCoordinate(
    BigDecimal minLatitude,
    BigDecimal maxLatitude,
    BigDecimal minLongitude,
    BigDecimal maxLongitude
    ) {
    return cachedStations.stream()
    .filter(it -> it.latitude().compareTo(minLatitude) >= 0 && it.latitude().compareTo(maxLatitude) <= 0)
    .filter(it -> it.longitude().compareTo(minLongitude) >= 0 && it.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }
    }

    하지만 해당 방법으로 로컬에서 조회를 테스트 했을 때 캐시를 적용한 것보다 더 느려진 결과가 나왔습니다. 캐싱을 해서 데이터베이스까지 요청을 보내지 않는데 왜 더 느려진 것일까요?

    답은 인덱스 였습니다. Mysql 에서 인덱스는 B Tree로 구성되어 있습니다. 데이터베이스에서는 위도, 경도로 복합 인덱스가 설정되어 있었지만, 현재 어플리케이션 로직에는 해당 부분이 없습니다.

    그래서 filter로 순회하는 시간복잡도가 O(n)이고, 데이터베이스에서는 O(log n)이기 때문에 더 느려진 것입니다. 그렇다고 제가 직접 B tree 자료구조를 직접 구현해야할까요?

    현재 해당 조회 API는 위도 경도로 범위 탐색을 하고 있습니다. 결국엔 station의 정보들이 위도, 경도로 정렬만 되어 있다면 B tree를 직접 구현하지 않더라도 같은 시간복잡도 O(log n)으로 탐색할 수 있습니다. 물론 B tree와 다른 부분은 해당 충전소의 정확한 위도, 경도로 단일 칼럼을 조회할 때는 O(n)이기 때문에 이런 방법이 문제가 될 수 있지만, 해당 캐시 데이터로는 무조건 범위 탐색을 하기 때문에, B tree를 구현하지 않고 이분 탐색으로 조회하는 방식으로 변경해보겠습니다.

        public void initialize(List<StationInfo> stations) {
    cachedStations.addAll(stations);
    cachedStations.sort((o1, o2) -> {
    int latitudeCompare = o1.latitude().compareTo(o2.latitude());
    if (latitudeCompare == 0) {
    return o1.longitude().compareTo(o2.longitude());
    }
    return latitudeCompare;
    });
    }

    private List<StationInfo> findStations(BigDecimal minLatitude, BigDecimal maxLatitude, BigDecimal minLongitude, BigDecimal maxLongitude) {
    int lowerBound = binarySearch(minLatitude, START_INDEX);
    int upperBound = binarySearch(maxLatitude, lowerBound);
    if (lowerBound == -1 || upperBound == -1) {
    return Collections.emptyList();
    }
    return cachedStations.stream()
    .skip(lowerBound)
    .limit(upperBound - lowerBound)
    .filter(station -> station.longitude().compareTo(minLongitude) >= 0 && station.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }

    private int binarySearch(BigDecimal latitude, int startIndex) {
    int left = startIndex;
    int right = cachedStations.size() - 1;
    int result = -1;
    while (left <= right) {
    int middle = left + (right - left) / 2;
    StationInfo middleStation = cachedStations.get(middle);
    if (middleStation.latitude().compareTo(latitude) >= 0) {
    result = middle;
    right = middle - 1;
    } else {
    left = middle + 1;
    }
    }
    return result;
    }

    먼저 어플리케이션이 실행될 때 cache 데이터를 찾아 저장하는 것 뿐만 아니라, 위도(Latitude)를 기준으로 정렬하도록 만들었습니다. 그리고 위도의 최소, 최대값의 인덱스를 가장 효율적으로 찾아올 수 있도록 binary search를 하는 메서드를 만들었습니다. 이렇게 한다면 O(log n) 으로 위도의 최대 최소 조건에 포함되는 모든 station의 값을 조회할 수 있습니다. 그리고 조회한 데이터들의 개수만큼 filter를 통해 경도(longitude) 가 포함되는지 확인합니다. 해당 방식의 구현은 B tree가 작동하는 방식과 유사할 것입니다.

    이분 탐색을 적용한 결과 로컬에서 응답 속도가 120 ms -> 50 ~ 70 ms로 약 2배 빨라진 것을 확인할 수 있습니다.

    실시간이 중요한 데이터는?

    앞서 말씀드렸다시피 지도로 충전소를 조회할 때, 충전소의 정보들에는 바뀌지 않는 정보뿐만 아니라, 최신화해야하는 충전기의 현재 상태 정보가 있습니다. 이러한 정보들은 캐싱해둘 수 없습니다. 하더라도, 관리 포인트가 늘어나기 때문에 데이터베이스에서 캐싱해둔 충전기 id로 충전기의 상태를 찾아와서 정보를 합쳐 반환하는 식으로 만들 수 있습니다.

        select cs.station_id,
    sum(case
    when cs.charger_condition = 'STANDBY' then 1
    else 0
    end)
    from charger_status cs
    where cs.station_id in (?, ?, ?, ?, ?, ?, ?)
    group by cs.station_id

    위와 같은 쿼리로 해당 충전소의 최신화된 충전기 상태를 가져올 수 있습니다.

    캐싱을 하기전에 데이터베이스를 이용해 데이터를 가져올 때의 쿼리는 아래와 같습니다.

     select
    distinct s.station_id
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    where
    s.latitude>=?
    and s.latitude<=?
    and s.longitude>=?
    and s.longitude<=?
    -------------------------------------------------
    select
    s.station_id,
    s.station_name,
    s.latitude,
    s.longitude,
    s.is_parking_free,
    s.is_private,
    sum(case
    when cs.charger_condition='STANDBY' then 1
    else 0
    end),
    sum(case
    when c.capacity>=50 then 1
    else 0
    end)
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    inner join
    charger_status cs
    on (
    c.station_id=cs.station_id
    and c.charger_id=cs.charger_id
    )
    where
    s.station_id in (
    ?,?,?,?
    )
    group by
    s.station_id

    원래는 위와 같이 여러번의 Join을 하고, 2번의 쿼리가 나갔던 반면 지금은 join을 하지않는 한번의 깔끔한 쿼리로 개선되었습니다.

    그리고 station 테이블의 위도, 경도로 범위 탐색을 위해 생성했던 index도 제거할 수 있게 되었습니다!

    결론

    1. 캐싱할 수 있는 부분은 하는 것도 좋을 것 같습니다
    2. 시간 복잡도를 계산해봅시다.
    3. 성능 개선 재밌습니다.
    - - + + \ No newline at end of file diff --git a/35.html b/35.html index d37ecbb6..3751ca9e 100644 --- a/35.html +++ b/35.html @@ -5,12 +5,12 @@ Scale-out 시 Scheduling 중복 실행 막기 | CAR-FFEINE - - + +
    -

    Scale-out 시 Scheduling 중복 실행 막기

    · 약 9분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    저희 서비스에서는 주기적으로 충전기의 상태와 정보를 업데이트하거나, 통계를 저장하는 스케줄링 작업이 있습니다. +

    Scale-out 시 Scheduling 중복 실행 막기

    · 약 9분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    저희 서비스에서는 주기적으로 충전기의 상태와 정보를 업데이트하거나, 통계를 저장하는 스케줄링 작업이 있습니다. 지금의 저희 서버는 단일 서버로 구성되어있어 문제가 없지만, 만약 서버를 scale-out 하게 된다면 어떻게 될까요?

    똑같은 schedule이 중복되어 실행될 것입니다. 그렇다고 어떤 서버는 schedule을 동작하지 않도록 하고, 어떤 서버는 schedule을 동작하도록 한다면 스케줄이 동작하는 서버가 다운된다면 동작하는 서버의 다운타임만큼 저희 서버의 데이터를 최신화할 수 없고, 최신화가 중요한 저희 서비스에서는 사용자의 불만을 초래할 수 있습니다.

    구현해보기

    Schedule 정보를 어떻게 다른 환경에서 같이 공유하여 관리할 수 있을까요? 간단히 생각하면 Local 환경이 아닌, Global 환경에서 정보를 관리하면 될 것 같습니다.

    따라서 Schedule의 정보를 저장할 수 있는 테이블을 아래의 Entity 의 필드와 같이 생성해보겠습니다.

    @Entity
    public class ScheduleTask extends BaseEntity {

    @Id
    private String id;

    private String jobName;

    @Enumerated(EnumType.STRING)
    private JobStatus status;
    }

    먼저 id는 해당 스케줄을 구분할 수 있는 id여야 할 것입니다. 가장 쉽게 정할 수 있는 id는 스케줄의 job 이름과, @@ -23,7 +23,7 @@ 따라서 Schedule Thread Pool Size를 늘리도록 하겠습니다.

    @Configuration
    public class ScheduleConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
    ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
    taskScheduler.setPoolSize(10);
    taskScheduler.setThreadNamePrefix("schedule-task-");
    taskScheduler.initialize();
    taskRegistrar.setTaskScheduler(taskScheduler);
    }
    }

    SchedulingConfigurer 를 구현하여 Thread Pool size를 일단 10개로 정의했습니다.

    success 스레드 풀을 늘렸더니 위와 같이 2의 배수의 시간에 정확히 작동이 되는 것을 확인할 수 있습니다.

    하지만 이렇게 여러 작업을 동시에 실행된다면 데이터베이스에 병목현상이 발생되어 오히려 작업이 더 느리게 끝날 수도 있다고 생각했습니다.

    그래서 해당 부분의 실행을 관리하는 클래스를 생성하여 해당 클래스에서 Schedule의 작업을 관리하도록 구현했습니다.

    @Service
    public class BusinessLogic {

    private final ApplicationEventPublisher applicationEventPublisher;

    @Scheduled(cron = "0/2 * * * * *")
    public void complexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::complexJob, "complexJob", LocalDateTime.now()));
    }

    @Scheduled(cron = "0/4 * * * * *")
    public void moreComplexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::moreComplexJob, "moreComplexJob", LocalDateTime.now()));
    }
    }

    로직이 있는 BusinessLogic 서비스에서 스케줄의 시간마다 실행해야할 메서드를 Event로 발행합니다.

    @Component
    public class ScheduleService {

    private final ExecutorService executorService = Executors.newFixedThreadPool(1);
    private final Queue<SchedulingEvent> scheduleTasks = new ConcurrentLinkedQueue<>();
    private final AtomicBoolean isRunning = new AtomicBoolean(false);

    @EventListener
    public void addTask(SchedulingEvent schedulingEvent) {
    scheduleTasks.add(schedulingEvent);
    }

    @Scheduled(cron = "0/1 * * * * *")
    public void polling() {
    if (!scheduleTasks.isEmpty() || isRunning.compareAndSet(false, true)) {
    SchedulingEvent schedulingEvent = scheduleTasks.poll();
    executorService.execute(() -> execute(schedulingEvent));
    }
    }
    }

    그리고 위와 같은 스케줄을 관리하는 서비스에서는 Schedule Event를 받아 실행하도록 만들었습니다. 해당 클래스에서는 ThreadPool을 새로 생성하여, schedule의 스레드에 영향을 받지 않도록 구현했습니다.

    그리고 1초마다 실행되는 스케줄을 만들어 queue에 작업이 있는지, 현재 작업 중인지 확인하여 그렇지 않다면 queue에서 작업을 꺼내 실행하도록 만들었습니다.

    거의 구현이 끝나갑니다. 이제는 해당 Schedule의 데이터를 저장하고, 작업이 실패했을 시에 다시 작업을 하기 위한 기능만 구현하면 될 것 같습니다.

    @Component
    public class ScheduleService {

    ...

    private void execute(SchedulingEvent schedulingEvent) {
    String jobId = schedulingEvent.jobId();
    LocalDateTime executionTime = schedulingEvent.executionTime();

    if (isJobInProgressOrDone(jobId)) {
    log.info("작업이 실행중입니다. {} {}", executionTime, jobId);
    return;
    }
    ScheduleTask entity = new ScheduleTask(jobId, executionTime, JobStatus.RUNNING);
    scheduleTaskJdbcRepository.save(entity);

    try {
    schedulingEvent.runnable().run();
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.DONE);
    } catch (Exception e) {
    log.error("{} 작업 실행 중 에러가 발생했습니다.", jobId);
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.ERROR);
    tasks.add(schedulingEvent);
    }
    }

    private boolean isJobInProgressOrDone(String jobId) {
    Optional<ScheduleTask> taskOptional = scheduleTaskRepository.findById(jobId);
    if (taskOptional.isPresent()) {
    ScheduleTask scheduleTask = taskOptional.get();
    return scheduleTask.getStatus() == JobStatus.RUNNING || scheduleTask.getStatus() == JobStatus.DONE;
    }
    return false;
    }
    }

    이 부분은 간단하게 구현할 수 있습니다. 위와 같이 작업의 실행 시간과, job의 이름으로 데이터베이스에서 조회하고, 없다면 작업을 실행하고 있다면 작업이 ERROR 인지 확인하여 작업을 실행해주면 될 것 같습니다.

    complete

    위와 같이 두 개의 서버를 동시에 띄웠을 때에도 스케줄이 잘 작동하는 것을 확인할 수 있습니다.

    결론

    스케줄을 이렇게 구현할 수도 있지만 환경이 된다면 Message Queue를 사용하는 것이 어떨까요?

    혹시 틀린 부분이 있다면 지적 부탁드립니다.

    - - + + \ No newline at end of file diff --git a/36.html b/36.html index 7bf78576..087a128f 100644 --- a/36.html +++ b/36.html @@ -5,13 +5,13 @@ 마커 렌더링 최적화 | CAR-FFEINE - - + +
    -

    마커 렌더링 최적화

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    - - +

    마커 렌더링 최적화

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    + + \ No newline at end of file diff --git a/37.html b/37.html index 4a827c79..ca7a83c3 100644 --- a/37.html +++ b/37.html @@ -5,12 +5,12 @@ 충전소 조회 api 분리 | CAR-FFEINE - - + +
    -

    충전소 조회 api 분리

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. +

    충전소 조회 api 분리

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. 기존에는 충전소 간단 정보와 마커 정보를 한 번에 받아오도록 설계되어 있었지만, 백엔드와 프론트엔드가 협업하여 간단 정보와 마커 정보를 각각 필요한 만큼만 조회하도록 명세를 수정하였습니다.

    이 과정에서 먼저, 백엔드와 프론트엔드는 함께 모여 기능 요구사항과 성능 개선 목표를 논의하였습니다. 그리고 충전소 간단 정보와 마커 정보를 각각 조회하는 API 엔드포인트를 새로 설계하였습니다.

    다음으로, 백엔드에서 간단 정보 조회를 위한 API를 구현하였습니다. @@ -21,7 +21,7 @@ 이 정보를 제외하고 마커를 띄우기 위해 필요한 최소한의 정보를 조회하도록 수정해 서버의 부하를 낮췄습니다.

    이러한 변경으로 인해 충전소 조회 API의 성능이 개선되었습니다. 필요한 정보만을 조회하므로써 데이터베이스의 부하를 줄이고 응답 시간을 단축할 수 있게 되었습니다. 또한, 프론트엔드에서는 필요한 정보만을 호출하여 불필요한 데이터를 받아오지 않아도 되므로 클라이언트 측의 성능도 향상되었습니다.

    - - + + \ No newline at end of file diff --git a/38.html b/38.html index da4f0779..8debe198 100644 --- a/38.html +++ b/38.html @@ -5,12 +5,12 @@ 카페인 서비스 방문자 분석 | CAR-FFEINE - - + +
    -

    카페인 서비스 방문자 분석

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset +

    카페인 서비스 방문자 분석

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset no offset

    네이버에 있는 전기자동차 동호회 카페 중 가장 큰 곳에 글을 올려 방문자를 모집하기로 했습니다.

    카페에 글을 올리는 것은 무료이며, 카페에 가입한 사람들은 전기차에 관심이 있는 사람들이기 때문에 저희가 원하는 방문자를 모집하기에 적합하다고 생각했습니다.

    카카오톡 오픈채팅

    no offset no offset

    카카오톡 오픈채팅에는 수많은 대화방이 존재합니다.

    특정 주제로 만들어진 대화방이 대부분이기에 전기차를 주제로 한 오픈채팅 대화방을 찾는 것은 전혀 어렵지 않았습니다.

    안타깝게도 일부 단톡방에서 강퇴를 당했지만, 차주들과 채팅하면서 피드백을 받아볼 수 있었습니다.

    기타 홍보 수단

    기타 홍보 수단은 아직 사용하지 않았습니다.

    네이버 밴드, 보배드림은 사용하는 크루가 없어서 홍보를 하기 어려웠고, 구글 애드센스와 같은 도구는 비용이 발생하기에 아직은 이르다고 판단했습니다.

    Google Analytics 4 통계 집계 결과

    단순 방문자

    no offset no offset @@ -20,7 +20,7 @@ no offset no offset no offset

    집계 된 자료처럼 방문자들이 단순 방문만 한 것이 아니라, 수 많은 이벤트를 발생시키고 평균 참여 시간도 상당 부분 확보했음을 확인할 수 있습니다.

    - - + + \ No newline at end of file diff --git a/39.html b/39.html index 2d9caf18..838dadec 100644 --- a/39.html +++ b/39.html @@ -5,12 +5,12 @@ 카페인 서비스와 함께하는 전기차 여행 1 | CAR-FFEINE - - + +
    -

    카페인 서비스와 함께하는 전기차 여행 1

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. +

    카페인 서비스와 함께하는 전기차 여행 1

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. 저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. 진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! 진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. @@ -27,7 +27,7 @@ 차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? 이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git a/4.html b/4.html index 7a85227f..861fed5e 100644 --- a/4.html +++ b/4.html @@ -5,12 +5,12 @@ 큰 틀에서 바라보는 서버 아키텍처 계획 | CAR-FFEINE - - + +
    -

    큰 틀에서 바라보는 서버 아키텍처 계획

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +

    큰 틀에서 바라보는 서버 아키텍처 계획

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, 우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. 성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. (물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? @@ -21,7 +21,7 @@ 물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? 저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. 이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - + + \ No newline at end of file diff --git a/40.html b/40.html index a8bf584a..81bba9fe 100644 --- a/40.html +++ b/40.html @@ -5,15 +5,15 @@ 카페인 서비스와 함께하는 전기차 여행 2 | CAR-FFEINE - - + +
    -

    카페인 서비스와 함께하는 전기차 여행 2

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset +

    카페인 서비스와 함께하는 전기차 여행 2

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset no offset

    충전기를 꽂고 나서 알게된 것은 카페인 서비스에 나온 충전소 회사명과 방금 꽂은 충전기 회사명이 다르다는 것이었습니다.

    알고보니 음성 인식으로 네비에 검색한 충전소는 판교공영주차장이 아닌 판교역 환승 주차장이라 엉뚱한 곳으로 온 것이었습니다!!!

    다행인 점은 우리 서비스에서 제공하는 충전기 사용 여부 정보가 잘못된 것이 아니었다는 것이었습니다.

    그래서 애초에 가고자 했던 판교공영주자창에 대한 카페인 서비스의 정보가 실제와 동일한지 확인해보러 걸어서 이동했습니다. (바로 앞에 있었기 때문입니다.)

    no offset -no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    - - +no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    + + \ No newline at end of file diff --git a/404.html b/404.html index 83221044..9a0a82e5 100644 --- a/404.html +++ b/404.html @@ -5,13 +5,13 @@ 페이지를 찾을 수 없습니다. | CAR-FFEINE - - + +

    페이지를 찾을 수 없습니다.

    원하는 페이지를 찾을 수 없습니다.

    사이트 관리자에게 링크가 깨진 것을 알려주세요.

    - - + + \ No newline at end of file diff --git a/41.html b/41.html new file mode 100644 index 00000000..f60127b7 --- /dev/null +++ b/41.html @@ -0,0 +1,19 @@ + + + + + +카페인 팀의 무중단 배포 | CAR-FFEINE + + + + + +
    +

    카페인 팀의 무중단 배포

    · 약 9분
    제이

    안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + + + \ No newline at end of file diff --git a/5.html b/5.html index 7c7c5da4..903f5b24 100644 --- a/5.html +++ b/5.html @@ -5,13 +5,13 @@ pr 본문에 이슈 번호를 달아주는 기능을 만들었습니다 | CAR-FFEINE - - + +
    -

    pr 본문에 이슈 번호를 달아주는 기능을 만들었습니다

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    - - +

    pr 본문에 이슈 번호를 달아주는 기능을 만들었습니다

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    + + \ No newline at end of file diff --git a/6.html b/6.html index 37d2558a..d6e5fd07 100644 --- a/6.html +++ b/6.html @@ -5,13 +5,13 @@ [DB] 대량의 데이터를 DB에 넣는 과정을 최적화해보자 | CAR-FFEINE - - + +
    -

    [DB] 대량의 데이터를 DB에 넣는 과정을 최적화해보자

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    - - +

    [DB] 대량의 데이터를 DB에 넣는 과정을 최적화해보자

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/7.html b/7.html index 63758a68..bb7e5067 100644 --- a/7.html +++ b/7.html @@ -5,14 +5,14 @@ 깃 커밋 메시지에 이슈 번호를 자동으로 입력할 순 없을까? | CAR-FFEINE - - + +
    -

    깃 커밋 메시지에 이슈 번호를 자동으로 입력할 순 없을까?

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 +

    깃 커밋 메시지에 이슈 번호를 자동으로 입력할 순 없을까?

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 https://blog.deering.co/commit-convention/

    - - + + \ No newline at end of file diff --git a/8.html b/8.html index 0b78577a..f32c5980 100644 --- a/8.html +++ b/8.html @@ -5,13 +5,13 @@ 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법 | CAR-FFEINE - - + +
    -

    스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    - - +

    스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/9.html b/9.html index 223573c2..84b11070 100644 --- a/9.html +++ b/9.html @@ -5,19 +5,19 @@ Pull Request 시 자동으로 test 실행하기 | CAR-FFEINE - - + +
    -

    Pull Request 시 자동으로 test 실행하기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. +

    Pull Request 시 자동으로 test 실행하기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. 많은 시간을 단축할 수 있습니다.

    그리고 test가 실패한다면 강제로 Merge가 되지 않도록 한다면 실수로 테스트가 되지 않는 커밋을 올리는 것을 방지할 수 있겠죠.

    이 두가지만으로도 생산성이 많이 올라갈 것을 기대할 수 있습니다.

    어떻게 할 수 있나요

    Github Action을 이용하여 설정한 조건에 맞는 상황에서 명령어를 실행하여 test를 할 수 있습니다.

    Github Action 파일 생성

    1. 먼저 최상위 폴더에 .github/workflows 폴더를 생성합니다.
    2. 해당 폴더 내에 example.yml을 생성합니다.
    3. 아래와 같이 yml 파일을 작성합니다.
    name: pr test

    on:
    pull_request:
    branches:
    - main
    - develop

    permissions:
    contents: read

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    Job 이름 설정

    복잡하지 않습니다. 먼저 name 속성은 github action에서 보여질 Job의 이름을 정하는 부분입니다.

    지금은 pr test로 해두었습니다. 그럼 아래 사진과 같이 반영됩니다.

    workflows name

    workflow 트리거 설정

    다음으론 on 속성입니다. 이 속성은 workflow를 실행할 이벤트를 지정하는데 사용됩니다. 특정 이벤트 유형과 조건을 기반으로 workflow를 트리거하도록 구성할 수 있습니다.

    예를 들어 아래와 같이 정의했습니다.

    on:
    push:
    branches:
    - main
    pull_request:
    branches:
    - develop

    그렇다면 이 workflow가 작동되는 시점은 main 브랜치에 push가 되거나 develop 브랜치에 pull request를 보낼 때 작동합니다.

    권한 부여

    permissions:
    contents: read

    이런 권한을 주게 된다면 이 job은 읽기 권한밖에 없기 때문에 실수로 다른 것을 추가하지 못하게 막을 수 있습니다

    동작할 명령어 입력

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    name

    제일 간단히 볼 수 있는 name 설정은 아래 사진처럼 어떤식으로 보여줄지 정할 수 있습니다.

    job image

    runs-on

    runs-on 속성입니다. 해당 운영체제를 사용한다고 정의하는 부분입니다. 지금은 저희가 사용할 ec2와 같은 환경인 ubuntu에서 작동하도록 설정했지만, windows-latest, macos-latest로 변경할 수도 있습니다.

    environment

    environment 속성입니다. 해당 속성은 꼭 필요한 부분이 아니지만 branch의 rule 설정에 사용할 수 있습니다. 그리고 환경을 한꺼번에 관리할 수 있습니다.

    이 부분은 아래에 branch rule을 정하는 부분을 보시면 아마 이해가 될 것 입니다.

    defaults

    해당 속성은 어떤 폴더에서 명령어를 실행할 지 지정합니다. 지금의 저희 프로젝트에서는 한 repository에 backend, frontend 폴더를 나누었기 때문에 backend 폴더로 이동하여 명령어를 실행해야 합니다.

    그래서 working-directory./backend라고 지정했습니다.

    steps

    제일 중요한 steps입니다. 해당 속성은 어떤 명령어를 어떤 순서로 실행시킬지 정의합니다. 지금의 workflow에선

    1. Java 17 설치
    2. gradlew 파일에 실행 권한 부여
    3. gradle build 실행

    순으로 동작합니다.

    다른 조건과 이벤트도 추가하고 싶어요

    저희 프로젝트는 하나의 repository에서 frontend, backend 코드를 같이 관리하는 상황입니다. 하지만 frontend 코드를 수정했다고 java 테스트를 돌리는 것은 오히려 생산성이 줄어들겠죠.

    그리고 frontend도 테스트를 돌리고 싶지만 gradle을 사용하지 않습니다.

    그럴 때 간단한 속성을 추가하면 파일의 변경에 따라 해당 job을 실행할 조건을 정의할 수 있습니다.

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - backend/**
    - .github/**

    위와 달리 지금 pull request에는 속성이 하나가 더 있는데요. paths를 적용하면 backend 폴더 하위의 무언가 변경이 있는 pull request에만 작동을 하게 됩니다.

    그럼 backend의 workflow 파일에 paths 속성을 하나 추가하고, 비슷한 frontend workflow를 만들어주면 되겠죠.

    name: frontend test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**

    permissions:
    contents: read

    jobs:
    test:
    name: jest
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - uses: actions/checkout@v3
    - name: NPM Install
    run: npm i
    - name: Jest run
    run: npm run test

    이런 식으로 yml 파일을 하나 추가하면 frontend의 수정이 일어날 때는 jest를 실행하고, backend 폴더의 수정이 일어나면 gradlew를 실행하게 할 수 있습니다.

    Test가 실패하는 PR은 Merge 막기

    Test가 실패하는 Pull Request가 Merge 되는 일은 절대로 없어야 합니다. 그런 실수를 방지하려면 팀원 전부가 리뷰할 때 테스트를 돌려봐야하는 귀찮음이 생길 수 있습니다.

    그리고 사람은 실수해도 기계는 거짓말을 하지 않습니다. 자동으로 막도록 동작하게 만들어놓으면 그럴 일을 미연에 방지할 수 있습니다.

    Environments 확인하기

    먼저 해당 Repository의 Settings -> Environments 탭으로 들어갑니다. environments 아까 environment 속성을 보면 test라고 설정해놓은 것을 볼 수 있습니다. 해당 환경이 여기에 적용됩니다.

    Branch rule 정의하기

    이번에는 해당 Repository의 Settings -> Branches 탭으로 들어갑니다. 그리고 원하는 branch에 들어가 edit 버튼을 누릅니다.

    그리고 사진과 같이 Require deployments to succeed before merging 속성을 클릭합니다. 그리고 아래와 같이 어떤 환경을 적용할 것인지 선택할 수 있습니다.

    이 속성은 해당 배포가 성공해야 merge 할 수 있도록 브랜치를 보호하는 기능입니다.

    그리고 저희는 frontend와 backend Job의 환경을 둘 다 test라는 이름으로 정의했기 때문에 하나의 environment만 선택해도 둘 다 적용되는 효과를 볼 수 있습니다. branch rule

    적용 후

    아래와 같이 merge가 안된다는 글과 빨간색으로 경고 표시를 해주고 있습니다. blocked

    결론

    간단한 github action을 통해서 생산성을 많이 올릴 수 있는 좋은 기능인 것 같습니다. 다른 팀들도 이 기능을 도입하여 사용하는 것을 추천드립니다.

    - - + + \ No newline at end of file diff --git a/archive.html b/archive.html index 41bdbada..73eb88d3 100644 --- a/archive.html +++ b/archive.html @@ -5,13 +5,13 @@ CAR-FFEINE | CAR-FFEINE - - + +
    -

    CAR-FFEINE

    팀 블로그

    2023

    - - +

    CAR-FFEINE

    팀 블로그

    2023

    + + \ No newline at end of file diff --git a/assets/js/007cdc83.f13482e1.js b/assets/js/007cdc83.62692369.js similarity index 68% rename from assets/js/007cdc83.f13482e1.js rename to assets/js/007cdc83.62692369.js index 9771f2c6..f65d2438 100644 --- a/assets/js/007cdc83.f13482e1.js +++ b/assets/js/007cdc83.62692369.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2897],{35587:e=>{e.exports=JSON.parse('{"label":"ec2","permalink":"/tags/ec-2","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2897],{35587:e=>{e.exports=JSON.parse('{"label":"ec2","permalink":"/tags/ec-2","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/00931cc3.a5aec421.js b/assets/js/00931cc3.3656edaa.js similarity index 57% rename from assets/js/00931cc3.a5aec421.js rename to assets/js/00931cc3.3656edaa.js index 4147c2b2..a9c63eff 100644 --- a/assets/js/00931cc3.a5aec421.js +++ b/assets/js/00931cc3.3656edaa.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5669],{92291:e=>{e.exports=JSON.parse('{"permalink":"/page/30","page":30,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/29","nextPage":"/page/31","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5669],{92291:e=>{e.exports=JSON.parse('{"permalink":"/page/30","page":30,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/29","nextPage":"/page/31","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/09fbb6bd.4f515501.js b/assets/js/09fbb6bd.ebbc3079.js similarity index 57% rename from assets/js/09fbb6bd.4f515501.js rename to assets/js/09fbb6bd.ebbc3079.js index 1af50707..21bcac01 100644 --- a/assets/js/09fbb6bd.4f515501.js +++ b/assets/js/09fbb6bd.ebbc3079.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5964],{41679:e=>{e.exports=JSON.parse('{"permalink":"/page/16","page":16,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/15","nextPage":"/page/17","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5964],{41679:e=>{e.exports=JSON.parse('{"permalink":"/page/16","page":16,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/15","nextPage":"/page/17","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/0b70ffa9.bcc5bbcb.js b/assets/js/0b70ffa9.36fc3dde.js similarity index 68% rename from assets/js/0b70ffa9.bcc5bbcb.js rename to assets/js/0b70ffa9.36fc3dde.js index 0ca970de..9d449cdb 100644 --- a/assets/js/0b70ffa9.bcc5bbcb.js +++ b/assets/js/0b70ffa9.36fc3dde.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[3694],{16605:e=>{e.exports=JSON.parse('{"label":"ec2","permalink":"/tags/ec-2","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[3694],{16605:e=>{e.exports=JSON.parse('{"label":"ec2","permalink":"/tags/ec-2","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/0c071de2.4df40956.js b/assets/js/0c071de2.002140d3.js similarity index 56% rename from assets/js/0c071de2.4df40956.js rename to assets/js/0c071de2.002140d3.js index 440b4801..2fa37306 100644 --- a/assets/js/0c071de2.4df40956.js +++ b/assets/js/0c071de2.002140d3.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[321],{23125:e=>{e.exports=JSON.parse('{"permalink":"/page/2","page":2,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/","nextPage":"/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[321],{23125:e=>{e.exports=JSON.parse('{"permalink":"/page/2","page":2,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/","nextPage":"/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/12cbeba7.9f93d224.js b/assets/js/12cbeba7.c4d24c4c.js similarity index 57% rename from assets/js/12cbeba7.9f93d224.js rename to assets/js/12cbeba7.c4d24c4c.js index 15f36ec2..9ce55327 100644 --- a/assets/js/12cbeba7.9f93d224.js +++ b/assets/js/12cbeba7.c4d24c4c.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6508],{16134:e=>{e.exports=JSON.parse('{"permalink":"/page/29","page":29,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/28","nextPage":"/page/30","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6508],{16134:e=>{e.exports=JSON.parse('{"permalink":"/page/29","page":29,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/28","nextPage":"/page/30","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/1f182e80.258e804c.js b/assets/js/1f182e80.f5e02180.js similarity index 80% rename from assets/js/1f182e80.258e804c.js rename to assets/js/1f182e80.f5e02180.js index 871806bd..65843796 100644 --- a/assets/js/1f182e80.258e804c.js +++ b/assets/js/1f182e80.f5e02180.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9583],{38681:e=>{e.exports=JSON.parse('{"permalink":"/tags/ec-2/page/2","page":2,"postsPerPage":1,"totalPages":3,"totalCount":3,"previousPage":"/tags/ec-2","nextPage":"/tags/ec-2/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9583],{38681:e=>{e.exports=JSON.parse('{"permalink":"/tags/ec-2/page/2","page":2,"postsPerPage":1,"totalPages":4,"totalCount":4,"previousPage":"/tags/ec-2","nextPage":"/tags/ec-2/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/226700de.2071033e.js b/assets/js/226700de.ebc0253d.js similarity index 57% rename from assets/js/226700de.2071033e.js rename to assets/js/226700de.ebc0253d.js index 8f2c061f..7479b4e6 100644 --- a/assets/js/226700de.2071033e.js +++ b/assets/js/226700de.ebc0253d.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6035],{41961:e=>{e.exports=JSON.parse('{"permalink":"/page/25","page":25,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/24","nextPage":"/page/26","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6035],{41961:e=>{e.exports=JSON.parse('{"permalink":"/page/25","page":25,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/24","nextPage":"/page/26","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/23e2728d.41ee2873.js b/assets/js/23e2728d.41ee2873.js new file mode 100644 index 00000000..66b33782 --- /dev/null +++ b/assets/js/23e2728d.41ee2873.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6573],{54295:a=>{a.exports=JSON.parse('{"label":"aws","permalink":"/tags/aws","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/270346fa.455f7668.js b/assets/js/270346fa.1fd5a584.js similarity index 57% rename from assets/js/270346fa.455f7668.js rename to assets/js/270346fa.1fd5a584.js index c2112f87..e8032842 100644 --- a/assets/js/270346fa.455f7668.js +++ b/assets/js/270346fa.1fd5a584.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7975],{89424:e=>{e.exports=JSON.parse('{"permalink":"/page/28","page":28,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/27","nextPage":"/page/29","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7975],{89424:e=>{e.exports=JSON.parse('{"permalink":"/page/28","page":28,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/27","nextPage":"/page/29","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/2832e534.56cded60.js b/assets/js/2832e534.69941c65.js similarity index 57% rename from assets/js/2832e534.56cded60.js rename to assets/js/2832e534.69941c65.js index 16d7429e..4e9735a1 100644 --- a/assets/js/2832e534.56cded60.js +++ b/assets/js/2832e534.69941c65.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2476],{69870:e=>{e.exports=JSON.parse('{"permalink":"/page/13","page":13,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/12","nextPage":"/page/14","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2476],{69870:e=>{e.exports=JSON.parse('{"permalink":"/page/13","page":13,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/12","nextPage":"/page/14","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/2e10a69c.6466fa52.js b/assets/js/2e10a69c.88e8383f.js similarity index 57% rename from assets/js/2e10a69c.6466fa52.js rename to assets/js/2e10a69c.88e8383f.js index 84993ac6..eeac81de 100644 --- a/assets/js/2e10a69c.6466fa52.js +++ b/assets/js/2e10a69c.88e8383f.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7581],{9981:e=>{e.exports=JSON.parse('{"permalink":"/page/38","page":38,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/37","nextPage":"/page/39","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7581],{9981:e=>{e.exports=JSON.parse('{"permalink":"/page/38","page":38,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/37","nextPage":"/page/39","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/2e801cce.9e385406.js b/assets/js/2e801cce.9e385406.js deleted file mode 100644 index 4ed291b9..00000000 --- a/assets/js/2e801cce.9e385406.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9450],{16029:n=>{n.exports=JSON.parse('{"blogPosts":[{"id":"40","metadata":{"permalink":"/40","source":"@site/blog/2023-10-15-carffeine-tester-2/index.mdx","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","description":"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.","date":"2023-10-15T00:00:00.000Z","formattedDate":"2023\ub144 10\uc6d4 15\uc77c","tags":[{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778"},{"label":"\uc11c\ube44\uc2a4 \uacbd\ud5d8","permalink":"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{"label":"\ud53c\ub4dc\ubc31","permalink":"/tags/\ud53c\ub4dc\ubc31"},{"label":"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","permalink":"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{"label":"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571","permalink":"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],"readingTime":14.665,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"},{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"40","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","authors":["gabriel","scent"],"tags":["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},"nextItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","permalink":"/39"}},"content":"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.\\n\\n\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub09c\ubc88 [\uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8](https://car-ffeine.github.io/39) \uc9c4\ud589 \uc774\ud6c4 \uc77c\ubd80 \uae30\ub2a5 \uac1c\uc120\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\ub2a5 \uac1c\uc120\uc758 \uc720\uc6a9\uc131\uc744 \ud310\ubcc4\ud558\uace0\uc790 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc744 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uac1c\uc120\ud55c \uc0ac\ud56d\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n### 1. \uc9c0\uc5ed\uac80\uc0c9\\n\\n![no offset](./city-search.png)\\n\\n- \uc774\uc81c\ub294 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud558\ub294 \uacbd\uc6b0, \uc804\uad6d \ub3c4\uc2dc\uc758 \uc8fc\uc18c\uac00 \uac19\uc774 \uc81c\uacf5\ub429\ub2c8\ub2e4.\\n\\n### 2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\\n\\n![no offset](./mobile-markers.png)\\n\\n(\uae30\uc874\uc5d0\ub294 \uc704 \uc0ac\uc9c4\ubcf4\ub2e4 \uc881\uc740 \uc601\uc5ed\ub9cc\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc774 \ud5c8\uc6a9\ub418\uc5c8\ub2e4.)\\n- \ubaa8\ubc14\uc77c\uc5d0\uc11c \uc880 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc744 \ud5c8\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uc6d0\ub798\ub294 \ub514\ubc14\uc774\uc2a4 \ub108\ube44\ub97c \uace0\ub824\ud558\uc9c0 \uc54a\uace0 \uc90c \ub808\ubca8 \uae30\uc900\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud588\uc73c\ub098, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc774\ub294 \uc9c0\ub3c4\uc758 \uc601\uc5ed \ud06c\uae30\ub97c \uae30\ubc18\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub294 \ubc29\uc2dd\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4.\\n- \uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \ub9c8\ucee4\uc758 \ub2e8\uc810\uc740, \uadf8 \ud06c\uae30\uac00 \ub108\ubb34 \ud06c\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub85c\uc778\ud574 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc8fc\ub294 \uacbd\uc6b0\uc5d0 \ub9c8\ucee4\ub4e4\uc774 \uacb9\uce58\ub294 \ud604\uc0c1\uc774 \uc788\uc5c8\ub294\ub370\uc694, \uc774\ub97c \uc218\uc815\ud558\uae30 \uc704\ud574 \ud2b9\uc815 \uc601\uc5ed \ud06c\uae30 \uc774\uc0c1\uc5d0\uc11c\ub294 \ub9c8\ucee4\ub97c \uc880 \ub354 \uac04\uc18c\ud654 \ub41c \ub514\uc790\uc778\uc73c\ub85c \ubcf4\uc774\ub3c4\ub85d \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n- \ub9c8\ucee4 \uc0ac\uc774\uc988\uac00 \uc791\uc544\uc9c0\uba74\uc11c \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\uac00 \ub354\uc774\uc0c1 \ub4e4\uc5b4\uac08 \uacf5\uac04\uc774 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub9c8\ucee4 \uc0c9\uc0c1\uc740 \uadf8\ub300\ub85c \uc720\uc9c0\ub97c \ud558\ub418, \uc778\ud3ec \uc708\ub3c4\uc6b0\uc5d0 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\ub97c \ubcf4\uc5ec\uc8fc\ub294 \ubc29\uc2dd\uc73c\ub85c \ub514\uc790\uc778\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n## \uccb4\ud5d8 \uaddc\uce59 \uc124\uc815\\n\\n\uac1c\uc120\ud55c \uae30\ub2a5\uc774 \uc2e4\uc81c\ub85c \uc720\uc6a9\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \uc800\ud76c\ub294 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc758 \uaddc\uce59\uc744 \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc880 \ub354 \uc758\ubbf8\uc788\ub294 \uacbd\ud5d8\uc744 \ud558\uae30\uc704\ud574 1\ucc28 \uccb4\ud5d8 \ub54c \uc815\ud588\ub358 \uaddc\uce59\uc5d0 \ub354\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ucd94\uac00 \uaddc\uce59\uc744 \uc124\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n### \uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4\\n\\n\uc9c0\ub09c \uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8\uc5d0\uc11c\ub294 \uc9c0\uc5ed \uac80\uc0c9\uc774 \uc5c6\uc5b4 \ubaa9\ud45c \uc9c0\uc810\uc744 \ucc3e\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\uc2b5\ub2c8\ub2e4. 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uc9c0\uc5ed \uac80\uc0c9\uc774 \ucd94\uac00 \ub418\uc5c8\uc73c\ubbc0\ub85c \uc774 \uae30\ub2a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \uc774 \uaddc\uce59\uc744 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ucd94\uac00\ub85c \ubaa9\ud45c \uc9c0\uc810 \uc8fc\ubcc0\uc758 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud560 \ub54c \uc0c8\ub85c \ucd94\uac00\ub41c \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0\ub3c4 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uccb4\ud5d8 \uac1c\uc694\\n\\n![no offset](./routes.png)\\n1. \uc7a0\uc2e4\uc5ed \ucd9c\ubc1c\\n2. \ud558\ub0a8 \ub9cc\ub450\uc9d1\\n3. \ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815\\n4. \ud310\uad50\\n\\n## \uccb4\ud5d8 \ud6c4\uae30\\n\\n### \uc7a0\uc2e4\uc5ed \ucd9c\ubc1c\\n\\n![no offset](./from-jamsil.png)\\n\\n\uc3d8\uce74\uc5d0\uc11c EV6\ub97c \ub300\uc5ec\ud574\uc11c `\uac00\ube0c\ub9ac\uc5d8`, `\uc13c\ud2b8`, `\ud0a4\uc544\ub77c`\uac00 \uc7a0\uc2e4\uc5ed\uc5d0\uc11c \ucd9c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc800\ub141 \ud1f4\uadfc \uc774\ud6c4\uc5d0 \ub0a8\uc774\uc12c\uc744 \uac00\ub824\uace0 \ubaa9\uc801\uc9c0\ub97c \uc124\uc815\ud558\uc600\uc73c\ub098 \ubc30\uac00 \ub108\ubb34 \uace0\ud30c\uc11c \uac00\ub294 \uae38\uc5d0 \uc2dd\uc0ac\ub97c \ud558\uc790\uace0 \uc598\uae30\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n### \ud558\ub0a8 \ub9cc\ub450\uc9d1\\n\\n\ub530\ub77c\uc11c \uc9c4\uc815\ud55c \ucc98\uc74c \ubaa9\uc801\uc9c0\ub294 \uc2a4\ud0c0\ud544\ub4dc\uc600\uc73c\ub098, \uac00\ube0c\ub9ac\uc5d8\uc740 \ub3d9\ub124 \uc8fc\ubbfc\uc774\ub77c \uc2a4\ud0c0\ud544\ub4dc\ub97c \ub108\ubb34 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc2a4\ud0c0\ud544\ub4dc\uc5d0 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uace0\uc788\uc73c\ubbc0\ub85c \ubaa9\uc801\uc9c0\ub97c \uae09\ud558\uac8c \ubcc0\uacbd\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4. \uc774 \ub54c \ubaa9\uc801\uc9c0 \ubcc0\uacbd\uc744 \uc704\ud574 \uc8fc\ubcc0 \uc2dd\ub2f9\uc744 \ub458\ub7ec\ubcf4\ub358 \uc911\uc5d0 \uad1c\ucc2e\uc740 \uc2dd\ub2f9\uc744 \ubc1c\uacac\ud574\uc11c \ud574\ub2f9 \uc2dd\ub2f9\uc744 \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-starfield.png)\\n\\n\uc2dd\ub2f9 \uc8fc\ubcc0\uc744 \uac00\uae30 \uc704\ud574 \uc9c0\uc5ed \uac80\uc0c9\uc744 \ucc98\uc74c\uc73c\ub85c \uc0ac\uc6a9\ud558\uc5ec \uc2dd\ub2f9\uacfc \uac00\uae4c\uc6b4 \uc9c0\uc5ed\uc744 \ud0d0\uc0c9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \uc2dd\ub2f9\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \uc5c6\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uac8c\ub418\uc5b4, \uadfc\ucc98 \ucda9\uc804\uc18c\ub97c \ucc3e\uc544\ubcf4\uae30 \uc704\ud574\uc11c \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud588\ub354\ub2c8 1\ucc28 \uccb4\ud5d8\ub54c\uc640\ub294 \ub2ec\ub9ac \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc92c\uc2b5\ub2c8\ub2e4. \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4 \uc790\uccb4\uac00 \ubcf4\uc774\uc9c0 \uc54a\uc544 \ub2f5\ub2f5\ud558\uc600\uc73c\ub098, \uc774\uc81c\ub294 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uac8c \ub418\uc5b4 \ud3b8\ub9ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\ub09c \uccb4\ud5d8 \uc774\ud6c4\ub85c \ud53c\ub4dc\ubc31\uc744 \uc790\uccb4 \uc218\uc9d1\ud558\uc5ec \uac1c\ubc1c\ud55c \uae30\ub2a5\ub4e4\uc774 \ud3b8\ud558\ub2e4\ub294 \uac83\uc744 \uc2dd\ub2f9\uc5d0 \uac00\ub294 \uae38\uc5d0 \ub290\ub084 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815\\n\\n![no offset](./mandu-mandu.png)\\n\\n\ud558\ub0a8 \ub9cc\ub450\uc9d1\uc5d0\uc11c \uc2dd\uc0ac\ub97c \ud558\ub2e4\uac00 \uc54c\uac8c\ub41c \uc0ac\uc2e4\uc740, \ub0a8\uc774\uc12c\uc740 \uc0dd\uac01\ubcf4\ub2e4 \ub108\ubb34 \uba40\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc2dd\uc0ac\ub97c \ub9c8\uce58\uace0 \ub0a8\uc774\uc12c\uc5d0 \uac00\uba74, \ucda9\uc804\ub3c4 \uc81c\ub300\ub85c \ubabb\ud558\uace0 \ub3cc\uc544\uc62c \ud310\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc2dd\uc0ac\ub97c \ud558\uba74\uc11c \ub2e4\ub978 \ubaa9\uc801\uc9c0\ub97c \uc54c\uc544\ubd24\ub294\ub370, \uac00\ube0c\ub9ac\uc5d8\uc774 \uc608\uc804\uc5d0 \uac00\ubd24\ub358 \uacf3 \uc911\uc5d0\uc11c \ub0a8\uc591\uc8fc\uc758 \ubb3c\uc758 \uc815\uc6d0\uc774 \uc2dc\uac04\uc744 \ub5bc\uc6b0\uae30 \uc88b\ub2e4\ub294 \uc18c\ub9ac\ub97c \ud558\uc600\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ubb3c\uc758 \uc815\uc6d0\uc744 \uac80\uc0c9\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ub180\ub78d\uac8c\ub3c4 \ubb3c\uc758\uc815\uc6d0\uc740 \uac80\uc0c9\uacb0\uacfc\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4!\\n\\n\uc5b4\uca54 \uc218 \uc5c6\uc774 \uce74\uce74\uc624 \uc9c0\ub3c4\ub85c \ubb3c\uc758 \uc815\uc6d0 \uc704\uce58\ub97c \ud655\uc778\ud558\uc5ec \uc8fc\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uace0, \uc774 \uc8fc\uc18c\ub97c \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4. \uc800\ud76c\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub294 \uc5c5\uccb4\uba85 \uc870\ud68c\uac00 \uc548\ub41c\ub2e4\ub294 \uac83\uc774 \uce58\uba85\uc801\uc778 \ub2e8\uc810\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\ub9cc, \uc774 \uae30\ub2a5\uc740 \uac80\uc0c9 \ud560 \ub54c\ub9c8\ub2e4 \ub9ce\uc740 \ube44\uc6a9\uc774 \uccad\uad6c\ub418\uc5b4 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uae30\ub2a5\uc744 \ub123\ub294 \uac83\uc740 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uacb0\uad6d \uc8fc\uc18c \uac80\uc0c9\uc744 \ud1b5\ud574 \ubb3c\uc758 \uc815\uc6d0\uacfc \uac00\uc7a5 \uac00\uae4c\uc6b4 \ucda9\uc804\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7f0\ub370! \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud574\uc11c \ud655\uc778\ud574 \ubcf4\ub2c8 \ud574\ub2f9 \ucda9\uc804\uc18c\ub294 \ubb3c\uc758 \uc815\uc6d0\uacfc \uc0dd\uac01\ubcf4\ub2e4 \uba40\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./near-water.png)\\n\\n![no offset](./30-minutes.png)\\n\\n\ubb34\ub824 \uac78\uc5b4\uc11c 30\ubd84\uc774\ub098 \uac78\ub9ac\ub294 \ucda9\uc804\uc18c\uc600\uc2b5\ub2c8\ub2e4!\\n\\n\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uc704\ud574 \uc655\ubcf5 1\uc2dc\uac04\uc774\ub098 \uac78\ub9ac\ub294 \uac70\ub9ac\ub97c \uac78\uc744 \uc218 \uc5c6\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \uc9c0\ub09c \uccb4\ud5d8\uc5d0\uc11c \uc804\uae30\ucc28\uac00 \uc0dd\uac01\ubcf4\ub2e4 \ubc30\ud130\ub9ac\uac00 \uc624\ub798\uac04\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uace0 \uc788\uc5c8\uc9c0\ub9cc, \ub9cc\uc57d \uc800\ud76c\ucc98\ub7fc \ucda9\uc804\uc774 \uae09\ud55c \uc0ac\uc6a9\uc790\ub77c\uba74 \ubaa9\uc801\uc9c0\ub97c \ud3ec\uae30\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uaca0\uad6c\ub098 \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c \uc815\ud55c \ubaa9\uc801\uc9c0\ub294, \uc758\uc678\uc758 \uacb0\uc815\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uad49\uc7a5\ud788 \ubc1c\uc804\ub41c \ucca8\ub2e8 \ub3c4\uc2dc\ub85c \uc54c\ub824\uc9c4 \ud310\uad50\uc600\uc2b5\ub2c8\ub2e4!\\n\\n\uc0ac\uc2e4\uc740 \uc55e\uc73c\ub85c \uac08\uc9c0\ub3c4 \ubaa8\ub974\ub294 \ud310\uad50\ub97c \ubbf8\ub9ac \uad6c\uacbd\uc774\ub098 \ud574\ubcf4\uc790\ub294\uac8c \uc774\uc720\uc600\uc9c0\ub9cc \ube44\ubc00\uc785\ub2c8\ub2e4(?)\\n\\n\uc77c\ub2e8 \ud310\uad50\uc5ed\uc740 IT\uc11c\ube44\uc2a4 \ud68c\uc0ac\ub4e4\uc774 \ub9ce\uc774 \ubab0\ub824\uc788\ub294 \uacf3\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud310\uad50\uc5ed\uc744 \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \uac80\uc0c9\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./pangyo.png)\\n\\n\uc9c0\ub3c4\ub97c \ud310\uad50\uc5ed\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \uc678\ubd80\uc778 \uac1c\ubc29\uc778 \ucda9\uc804\uc18c\ub97c \ucc3e\uc558\ub294\ub370, \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \ubcf4\uc5ec\uc11c \ud574\ub2f9 \ucda9\uc804\uc18c\ub97c \ubaa9\uc801\uc9c0\ub85c \uc7a1\uace0 \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \ud310\uad50\\n\\n\ud558\ub0a8\uc5d0\uc11c \ud310\uad50\ub97c \uac00\uae30 \uc704\ud574\uc11c\ub294 \uc11c\ud558\ub0a8IC\ub97c \uc9c0\ub098\uc57c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uac00\ub294 \uae38\uc5d0 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0 \ub098\uc624\ub294 \uc815\ubcf4\uc640 \uc2e4\uc81c \uc815\ubcf4\uac00 \uc77c\uce58\ud558\ub294\uc9c0 \uc810\uac80\ucc28 \uc11c\ud558\ub0a8 \uac04\uc774 \ud734\uac8c\uc18c\ub97c \ub4e4\ub824\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \uac80\uc0c9\uc774 \ub418\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4!\\n\\n![no offset](./hanam_station.png)\\n\\n\uac80\uc0c9 \ub2f9\uc2dc\uc5d0\ub294 2\ub300\uc758 \ucda9\uc804\uae30\uac00 \uc788\ub2e4\uace0 \ub098\uc654\uace0, \ub458\ub2e4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub2e4\uace0 \ub418\uc5b4\uc788\uc5c8\ub294\ub370 \uc2e4\uc81c\ub85c \ud655\uc778\ud574\ubcf4\ub2c8 \uc77c\uce58\ud558\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c \uae38\uc744 \ub2ec\ub824 \ud310\uad50\uc5d0 \ub3c4\ucc29\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc8fc\ucc28\uc7a5\uc5d0 \ub4e4\uc5b4\uc624\uae30 \uc804, \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc758 \ucda9\uc804\uae30 \ucd1d 12\uae30 \uc911 10\uae30\uac00 \uc0ac\uc6a9\uac00\ub2a5\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc815\uc791 \ub4e4\uc5b4\uc640\uc11c \ubcf4\ub2c8 \uc785\uad6c\ubd80\ud130 \ub108\ubb34 \ub9ce\uc740 \uc804\uae30\ucc28\ub4e4\uc774 \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\uc911\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubb54\uac00 \uc774\uc0c1\ud558\ub2e4 \uc2f6\uc5c8\uc9c0\ub9cc, \uc544\uc9c1 \uc11c\ubc84\uc5d0 \ubc18\uc601\uc774 \uc548\ub41c\uac74\uac00? \ud558\uba74\uc11c \ube44\uc5b4\uc788\ub294 \ucda9\uc804\uae30\ub97c \ucc3e\uc558\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./empty_station.png)\\n![no offset](./charging.png)\\n\\n\ucda9\uc804\uae30\ub97c \uaf42\uace0 \ub098\uc11c \uc54c\uac8c\ub41c \uac83\uc740 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0 \ub098\uc628 \ucda9\uc804\uc18c \ud68c\uc0ac\uba85\uacfc \ubc29\uae08 \uaf42\uc740 \ucda9\uc804\uae30 \ud68c\uc0ac\uba85\uc774 \ub2e4\ub974\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc54c\uace0\ubcf4\ub2c8 \uc74c\uc131 \uc778\uc2dd\uc73c\ub85c \ub124\ube44\uc5d0 \uac80\uc0c9\ud55c \ucda9\uc804\uc18c\ub294 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \uc544\ub2cc \ud310\uad50\uc5ed \ud658\uc2b9 \uc8fc\ucc28\uc7a5\uc774\ub77c \uc5c9\ub6b1\ud55c \uacf3\uc73c\ub85c \uc628 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4!!!\\n\\n\ub2e4\ud589\uc778 \uc810\uc740 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80 \uc815\ubcf4\uac00 \uc798\ubabb\ub41c \uac83\uc774 \uc544\ub2c8\uc5c8\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc560\ucd08\uc5d0 \uac00\uace0\uc790 \ud588\ub358 \ud310\uad50\uacf5\uc601\uc8fc\uc790\ucc3d\uc5d0 \ub300\ud55c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\uc640 \ub3d9\uc77c\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\ub7ec \uac78\uc5b4\uc11c \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4. (\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.)\\n\\n![no offset](./no-chargers.png)\\n![no offset](./real-pangyo.png)\\n\\n\ub3c4\ucc29\ud574\ubcf4\ub2c8 1\uce35\uc758 \ucda9\uc804\uae30\ub4e4\uc774 \ubaa8\ub450 \uacf5\uc0ac\uc911\uc774\uc5c8\uace0, \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\ub85c\ub3c4 \ubd88\uc77c\uce58 \ud558\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc0c1\uc138 \uc815\ubcf4\ub97c \ubcf4\ub2c8 3~6\uce35\uc5d0 \ucda9\uc804\uae30\ub4e4\uc5d0 \ub300\ud55c \uc815\ubcf4\ub77c\ub294 \uac83\uc774 \uba85\uc2dc\ub418\uc5b4 \uc788\uc5c8\uace0, \uc2e4\uc81c\ub85c\ub3c4 \uc774\uc640 \ub3d9\uc77c\ud55c \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./full-charged.png)\\n\\n\uc800\ud76c\ub294 \uc2dc\uac04\uc774 \ub108\ubb34 \ud758\ub7ec \ub2e4\uc2dc \uc7a0\uc2e4\ub85c \ub3cc\uc544\uc640 \ucc28\ub97c \ubc18\ub0a9\ud558\uace0 \uccb4\ud5d8\uc744 \ub9c8\ubb34\ub9ac \ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\n### \ubd88\ud3b8\ud588\ub358 \uc810\\n\\n- \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc2dc\uc5d0 \uc6d0\ud558\ub294 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\ub2e4.\\n - \uc9c0\ub3c4\ub97c \ud655\ub300\ud574\uc8fc\uc138\uc694 \ubaa8\ub2ec\uc774 \ub728\uace0, \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c \ub9c8\ucee4\uac00 \uc804\ubd80 \uc0ac\ub77c\uc9c4\ub2e4.\\n- \ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub294 \uc218\ub2e8\uc774 \uc5c6\uc5b4 \ubd88\ud3b8\ud588\ub2e4.\\n - \ud604\uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ud540 (1\ucc28 \uccb4\ud5d8\uae30\uc5d0\uc11c\ub3c4 \uc5b8\uae09\ud588\ub358 \ubd80\ubd84)\\n - \ub0b4 \uc704\uce58\ub97c \uc0c1\ub300\uc801\uc73c\ub85c \uc54c \uc218 \uc788\ub294 \ub79c\ub4dc\ub9c8\ud06c\uc758 \ubd80\uc871\\n- \ud2b9\uc815 \uc7a5\uc18c(\ub9e4\uc7a5\uba85) \uac80\uc0c9\uc774 \uc548\ub3fc\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac00\uae30 \ubd88\ud3b8\ud588\ub2e4.\\n - \uce74\uce74\uc624\ub9f5 \ub4f1\uc744 \ud65c\uc6a9\ud574 \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \uc9c4\ud589\ud574\uc57c \ud588\ub2e4.\\n\\n### \ub2e4\uc74c \ubaa9\ud45c\\n\\n\uc55e\uc120 \ubd88\ud3b8\ud588\ub358\uc810\uc744 \uac1c\uc120\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uae30\ub2a5 \uac1c\uc120\uc744 \ucd94\uac00\ub85c \uc9c4\ud589\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n- \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc5d0 \uc81c\ud55c\uc774 \uc0dd\uae30\uc9c0 \uc54a\uac8c \ucda9\uc804\uc18c \ub9c8\ucee4 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc744 \uc6b0\uc120\uc801\uc73c\ub85c \ub3c4\uc785\ud55c\ub2e4.\\n- \ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub3c4\ub85d \uc9c0\ud558\ucca0 \uc5ed\uacfc \uac19\uc740 \ub79c\ub4dc\ub9c8\ucee4\ub97c \uc9c0\uc6e0\ub358 \uac83\uc744 \ub864\ubc31\ud55c\ub2e4.\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac08 \uc218 \uc788\ub3c4\ub85d \ud558\uae30 \uc704\ud574\uc11c \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \ucd94\uac00\ud558\uace0 \uc2f6\uc9c0\ub9cc, \ud574\ub2f9 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 \uac80\uc0c9\ub2f9 \ube44\uc6a9\uc774 \ub9ce\uc774 \uccad\uad6c\ub418\ub294 \uc7a5\uc18c \uac80\uc0c9 API\ub97c \ucd94\uac00\ud574\uc57c \ud588\uae30\uc5d0 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uad6c\ud604\ud558\uae30 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."},{"id":"39","metadata":{"permalink":"/39","source":"@site/blog/2023-10-07-carffeine-tester-1/index.mdx","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","description":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\uba74\uc11c \uac00\uc7a5 \ub9ce\uc774 \ubc1b\uc740 \ud53c\ub4dc\ubc31 \uc911 \ud558\ub098\ub294 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc774 \ubc18\ub4dc\uc2dc \ud544\uc694\ud558\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.","date":"2023-10-07T00:00:00.000Z","formattedDate":"2023\ub144 10\uc6d4 7\uc77c","tags":[{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778"},{"label":"\uc11c\ube44\uc2a4 \uacbd\ud5d8","permalink":"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{"label":"\ud53c\ub4dc\ubc31","permalink":"/tags/\ud53c\ub4dc\ubc31"},{"label":"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","permalink":"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{"label":"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571","permalink":"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],"readingTime":18.085,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"39","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","authors":["gabriel"],"tags":["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},"prevItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","permalink":"/40"},"nextItem":{"title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","permalink":"/37"}},"content":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\uba74\uc11c \uac00\uc7a5 \ub9ce\uc774 \ubc1b\uc740 \ud53c\ub4dc\ubc31 \uc911 \ud558\ub098\ub294 `\uc0ac\uc6a9\uc790 \uacbd\ud5d8`\uc774 \ubc18\ub4dc\uc2dc \ud544\uc694\ud558\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ubb34\ub798\ub3c4 \uc804\uae30\uc790\ub3d9\ucc28\ub97c \ubcf4\uc720\ud55c \ud300\uc6d0\ub4e4\uc774 \uc544\ubb34\ub3c4 \uc5c6\ub2e4\ubcf4\ub2c8 \uc2e4\uc81c \uc0ac\uc6a9\uc790\ub4e4\uc774 \uacaa\ub294 \uc5b4\ub824\uc6c0\uc744 \uc608\uc0c1\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc804\uae30 \uc790\ub3d9\ucc28 \uc6b4\uc804\uc790\ub4e4\uc744 \ucc3e\uc544\ub0b4\uc5b4 \uc218\ucc28\ub840 \uc778\ud130\ubdf0\ub97c \uc9c4\ud589\ud558\uc600\ub294\ub370 \uc2e4\uc81c \ucc28\uc8fc\ub4e4\uc774 \uc6d0\ud558\ub294 \uae30\ub2a5\uc774 \ubb34\uc5c7\uc778\uc9c0, \uc5b4\ub5a4 \uc5b4\ub824\uc6c0\uc744 \uacaa\ub294\uc9c0\ub97c \ud655\uc778\ud558\uc5ec \uc774\ub97c \ubc14\ud0d5\uc73c\ub85c \uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc11c\ube44\uc2a4\ub97c \ucc98\uc74c \uac1c\ubc1c\ud558\uc600\uc744 \ub54c \uac00\uc7a5 \ub9ce\uc774 \ubc1b\uc558\ub358 \ud53c\ub4dc\ubc31\uc740 \uc571 \ub85c\ub4dc \uc18d\ub3c4\uac00 \ub108\ubb34 \ub290\ub9ac\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc11c\ube44\uc2a4 \ucd08\uae30\uc5d0\ub294 \ub85c\ub529 \uc18d\ub3c4\uac00 \ub108\ubb34 \ub290\ub824\uc11c \uc0ac\uc6a9\uc790\ub4e4\uc5d0\uac8c \uc11c\ube44\uc2a4 \uc0ac\uc6a9\uc744 \uad8c\uc7a5\ud558\uae30 \ubbf8\uc548\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc2e4\uc0ac\uc6a9\uc790\ub97c \ubaa8\uc9d1\ud558\ub294 \uac83 \ubcf4\ub2e4 `\uc11c\ube44\uc2a4 \uc548\uc815\ud654\uc5d0 \uc9d1\uc911\ud558\ub294 \uac83`\uc774 \ucd5c\uc6b0\uc120\uc774\ub77c\ub294 \ubaa9\ud45c \uc544\ub798\uc5d0 \uc11c\ube44\uc2a4\ub97c \uac1c\uc120\ud558\ub294 \uc2dc\uac04\uc744 \uac00\uc84c\uace0, \uc9c0\uae08\uc740 \ub85c\ub529 \uc18d\ub3c4\uac00 \ube60\ub974\ub2e4\ub294 \ud53c\ub4dc\ubc31\uc744 \ubc1b\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\ub09c \ud55c \ub2ec\uac04 \uc11c\ube44\uc2a4 \uc548\uc815\ud654\uc5d0 \uc9d1\uc911\uc744 \ud588\ub2e4\uba74, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \uac1c\uc120\ud558\ub294\ub370 \uc9d1\uc911\uc744 \ud574\uc57c\ud560 \ub54c\uac00 \uc654\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc0ac\uc6a9\uc790 \uc720\uce58\ub97c \uc704\ud574 \uc804\uae30\ucc28 \ub3d9\ud638\ud68c \uce74\ud398, \uce74\uce74\uc624\ud1a1 \uc624\ud508\ucc44\ud305, \uc790\ub3d9\ucc28 \ucee4\ubba4\ub2c8\ud2f0 \ub4f1\uc744 \ub3cc\uba74\uc11c \ud64d\ubcf4\ub97c \uc9c4\ud589\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\ud589\ud788\ub3c4 \ubd88\ud2b9\uc815 \ub2e4\uc218\uc758 \uc775\uba85 \uc0ac\uc6a9\uc790\ub4e4\uc744 \uc190\uc27d\uac8c \uad6c\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\ub4e4\ub85c\ubd80\ud130 \ub9ce\uc740 \ud53c\ub4dc\ubc31\uc744 \ubc1b\uc558\uace0, \ud574\ub2f9 \ud53c\ub4dc\ubc31\uc744 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0 \ucd5c\ub300\ud55c \ubc18\uc601\ud558\uace0\uc790 \ub178\ub825\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc, \ub300\ubd80\ubd84\uc758 \uc0ac\uc6a9\uc790\ub4e4\uc774 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0 \ub2e8\uc21c\ud788 \ubc29\ubb38\ud558\uc5ec \ud53c\ub4dc\ubc31\uc744 \uc900 \uac83\uc77c \ubfd0 `\uc2e4\uc81c\ub85c \uc0ac\uc6a9\ud558\uba74\uc11c \ud53c\ub4dc\ubc31\uc744 \uc900 \uac83 \uac19\uc9c0\ub294 \uc54a\ub2e4\ub294 \ub290\ub08c`\uc744 \ubc1b\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\ub4e4\uc774 \uc571\uc5d0 \uba38\ubb34\ub978 \uc2dc\uac04\uc744 GA4\ub97c \ud1b5\ud574 \ud655\uc778 \ud558\uc600\uc744 \ub54c \ud3c9\uade0 3\ubd84 \uc774\uc0c1\uc774\ub77c\ub294 \uae34 \uc2dc\uac04\uc744 \uba38\ubb3c\ub7ec\uc11c \uc571\uc744 \uaf3c\uaf3c\ud558\uac8c \uc0ac\uc6a9\ud588\uc744 \uac83\uc774\ub77c\uace0 \uae30\ub300\ub294 \ud558\uc600\uc73c\ub098, \uc0ac\uc6a9 \uc911\uc5d0 \ud53c\ub4dc\ubc31\uc744 \uc900\ub2e4\uac70\ub098 \uc0ac\uc6a9 \ud6c4\uc5d0 \ud53c\ub4dc\ubc31\uc744 \uc900 \uac83\uc774 \ub9de\ub294\uc9c0 \ud655\uc2e0\ud558\uae30 \uc5b4\ub824\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uace0 \uc8fc\ubcc0\uc5d0\uc11c \uc804\uae30\ucc28\uc8fc\ub4e4\uc744 \ucc3e\uc790\ub2c8 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc77c\ub2e8 \uc804\uae30\uc790\ub3d9\ucc28 \ubcf4\uae09\ub960\uc774 \uad49\uc7a5\ud788 \ub0ae\uc558\uc73c\uba70, 40~50\ub300\uc5d0 \ud3b8\uc911\ub418\uc5b4 \uc788\uc5b4 \uc800\ud76c\uc5d0\uac8c \ud611\uc870\ud574 \uc904 \ucc28\uc8fc\ubd84\ub4e4\uc744 \uc8fc\ubcc0\uc5d0\uc11c \ucc3e\uae30 \uc5b4\ub824\uc6e0\uc2b5\ub2c8\ub2e4. (\ub300\ubd80\ubd84 \uc0dd\uc5c5\uc73c\ub85c \uc778\ud574 \ubc14\uc058\uc2ed\ub2c8\ub2e4 \u3160\u3160)\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \uadf8\ub0e5 \uc9c1\uc811 \uc11c\ube44\uc2a4\ub97c \uc0ac\uc6a9\ud558\uba74\uc11c \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \ud558\uae30\ub85c \ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n## \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294\uc694\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \uc9c0\uc6d0\ud558\ub294 \ud575\uc2ec \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\uc558\uc2b5\ub2c8\ub2e4.\\n\\n- \uc804\uad6d \ucda9\uc804\uc18c \uc870\ud68c\\n - \uc9c0\ub3c4 \ud0d0\uc0c9\uc744 \ud1b5\ud55c \uac80\uc0c9\\n - \uac80\uc0c9\ucc3d\uc744 \ud1b5\ud55c \uac80\uc0c9\\n- \ucda9\uc804\uc18c\uc758 \uc6b4\uc601 \uc815\ubcf4 \ud655\uc778\\n- \ucda9\uc804\uc18c \ubcc4 \ucda9\uc804\uae30 \uc0c1\ud0dc \uc870\ud68c (\uc2e4\uc2dc\uac04)\\n- \ucda9\uc804\uc18c \ubc0f \ucda9\uc804\uae30 \uace0\uc7a5 \uc2e0\uace0\\n- \ucda9\uc804\uc18c \ubcc4 \ucda9\uc804\uae30 \uc0ac\uc6a9\ub7c9 \ud1b5\uacc4 \uc870\ud68c\\n- \ucda9\uc804\uc18c \ubcc4 \ub9ac\ubdf0 \uc870\ud68c\\n\\n\uc774\uc678\uc5d0\ub3c4 \ub9ce\uc740 \uae30\ub2a5\ub4e4\uc774 \uc788\uc5c8\uc9c0\ub9cc, \uc704\uc758 \uae30\ub2a5\ub4e4\uc774 \uc0ac\uc6a9\uc790\ub4e4\uc774 \uac00\uc7a5 \uc8fc\ub825\uc73c\ub85c \uc0ac\uc6a9\ud560 \uac83 \uac19\uc740 \uae30\ub2a5\ub4e4\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uacc4\ud68d\uc744 \uc138\uc6cc\ubcf4\uc790\\n\\n\uc804\uae30\uc790\ub3d9\ucc28 \ub80c\ud2b8\uc5d0 \uc55e\uc11c \uc5b4\ub514\uc5d0 \ubc29\ubb38\ud560 \uc9c0 \ubd80\ud130 \uc815\ud574\uc57c \ud588\uc2b5\ub2c8\ub2e4.\\n\uc800\ud76c\ub294 \uba87 \uac00\uc9c0 \uc6d0\uce59\uc744 \uac00\uc9c0\uace0 \ubc29\ubb38\uc9c0\ub97c \uc815\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc798 \ubaa8\ub974\ub294 \uc9c0\uc5ed\uc77c \uac83\\n2. \ub3c4\ucc29\uc9c0\uc5d0 \ucda9\uc804\uc18c\uac00 \ubc18\ub4dc\uc2dc \uc788\uc744 \uac83\\n3. \ud0c0\uc0ac \uc571\uc744 \uc804\ud600 \uc0ac\uc6a9\ud558\uc9c0 \ub9d0 \uac83\\n\\n\uc77c\ub2e8, \uc81c\uac00 \ucc98\uc74c \uc815\ud588\ub358 \ubaa9\ud45c\ub294 \uacbd\uc0c1\ub0a8\ub3c4 \uc9c4\uc8fc\uc2dc\uc600\uc2b5\ub2c8\ub2e4.\\n\uc9c4\uc8fc\uc2dc\uc5d0\uc11c \ubcf5\uadc0\ud574\uc57c\ud558\ub294 \ud300\uc6d0\uc774 \uc788\ub358 \uc810, \ubc29\ubb38\ud574 \ubcf8 \uc801\uc774 \uc5c6\ub294 \ub3c4\uc2dc\uc778 \uc810, \uc7a5\uac70\ub9ac\ub77c\uc11c \ucda9\uc804\uae30 \uc0ac\uc6a9\uc774 \ud544\uc5f0\uc801\uc778 \uc810 \ub4f1 \uc5ec\ub7ec \uac00\uc9c0 \uc774\uc720\ub85c \uc9c4\uc8fc\uc2dc\ub97c \ubc29\ubb38\ud558\uae30\ub85c \uacb0\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud0a8 \uc21c\uac04 \ub208\uc55e\uc774 \uce84\uce84\ud574\uc84c\uc2b5\ub2c8\ub2e4.\\n\\n\\"\uc9c4\uc8fc\uc2dc\uac00 \uc5b4\ub514\uc5d0 \uc788\uc9c0?\\"\\n\\n![no offset](./search-jinju.png)\\n\\n\ub2e4\ud589\ud788 \uc9c4\uc8fc\uc2dc\ub97c \uac80\uc0c9\ud558\ub2c8 \uc8fc\uc18c \uae30\ubc18\uc73c\ub85c \uac80\uc0c9\uc774 \ub418\uc5c8\uc2b5\ub2c8\ub2e4!\\n\uc9c4\uc8fc\uc2dc\ub97c \uac80\uc0c9\ud55c \uac83\uc740 \uc544\ub2c8\uc9c0\ub9cc \uac04\uc811\uc801\uc774\ub77c\ub3c4 \uac80\uc0c9\uc774 \ub418\ub294 \uac83\uc744 \ubcf4\uace0 \uc548\uc2ec\ud588\uc2b5\ub2c8\ub2e4.\\n\uc544\ubb34 \ucda9\uc804\uc18c\ub97c \ub20c\ub7ec\uc11c \uc9c4\uc8fc\uc2dc\ub85c \uc774\ub3d9\ud558\ub294 \uac83\uc740 \uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```\\n\uc5ec\uae30\uc5d0\uc11c \uc800\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \ub3c4\uc2dc\ub098 \uc9c0\uc5ed \uac80\uc0c9 \uae30\ub2a5\uc774 \ubc18\ub4dc\uc2dc \ud544\uc694\ud558\ub2e4\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n```\\n\\n\ud558\uc9c0\ub9cc \ub108\ubb34 \uba40\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc655\ubcf5 700km\ub97c \uc0dd\uac01\ud574\uc57c\ud558\uc5ec 1\ubc15 2\uc77c\uc774 \ud544\uc218\uc600\uace0, \ud300\uc6d0\ub4e4 \uac04\uc5d0 \uc77c\uc815\uc744 \uc870\uc815\ud558\uae30\uac00 \ub108\ubb34 \uc5b4\ub824\uc6e0\uc2b5\ub2c8\ub2e4.\\n\ub530\ub77c\uc11c \ub2e4\ub978 \ub3c4\uc2dc\ub97c \ucc3e\uc544\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./gangnam-to-majang-road.png)\\n\\n\uadf8\ub7ec\ub358 \uc911, \uc81c\uac00 \uc804\uc5d0 \ubc29\ubb38\ud588\ub358 \ud30c\uc8fc\uc2dc\uc758 `\ub9c8\uc7a5\ud638\uc218`\uac00 \uc0dd\uac01\ub0ac\uc2b5\ub2c8\ub2e4.\\n\uc11c\uc6b8\uc5d0\uc11c \uaf64\ub098 \uba3c \uac70\ub9ac(\uc57d 50km)\uc5d0 \uc788\uc5c8\uace0, \uc801\ub2f9\ud788 \uc2dc\uac04\uc744 \ubcf4\ub0bc\ub9cc\ud55c \uc7a5\uc18c\uc600\uc2b5\ub2c8\ub2e4.\\n\ub2e4\ud589\ud788\ub3c4 \ucda9\uc804\uc18c\uc758 \uc774\ub984\uc774 `\ub9c8\uc7a5\ud638\uc218\uad00\ub9ac\uc0ac\ubb34\uc18c`\uc5ec\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud1b5\ud574 \ubc14\ub85c \ucc3e\uc744 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc2ec\uc9c0\uc5b4 \ub9c8\uc7a5\ud638\uc218 \uc8fc\ubcc0\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \ub9ce\uc9c0 \uc54a\uc740 \ud3b8\uc774\uc5c8\uace0, \ucd08\uae09\uc18d \ucda9\uc804\uae30\uac00 \uc788\uc5b4 \uc800\ud76c \uc571\uc744 \uc2e4\ud5d8\ud558\uae30\uc5d0 \ub531 \uc88b\uc558\uc2b5\ub2c8\ub2e4.\\n\\n## \ub9c8\uc7a5\ud638\uc218\ub85c \ucd9c\ubc1c\\n\\n\uc800 `\uac00\ube0c\ub9ac\uc5d8`\uacfc `\uc81c\uc774`, `\ubc15\uc2a4\ud130`\ub294 \uc11c\uc6b8 \uc120\uc815\ub989\uc5ed\uc5d0\uc11c \uc544\uc774\uc624\ub2c95\ub97c \ub80c\ud2b8\ud558\uace0 \ub9c8\uc7a5\ud638\uc218\ub85c \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-majang-1.png)\\n\\n\ucc98\uc74c \uacc4\ud68d\ud588\ub358 \uac83 \ucc98\ub7fc \ud0c0\uc0ac\uc758 \uc571\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \ub9c8\uc7a5\ud638\uc218\ub97c \uac80\uc0c9\ud558\uc5ec \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-majang-2.png)\\n\\n\uc804\ub0a0 \uc774\ubbf8 \uac80\uc0c9\uc744 \ud588\uc9c0\ub9cc, \ud639\uc2dc \uc0ac\uc6a9 \uc911\uc77c\uc218\ub3c4 \uc788\uae30\uc5d0 \ud55c\ubc88 \ub354 \uac80\uc0c9\ud574\ubd24\uc73c\uba70 \ud574\ub2f9 \uc2dc\uac04\ub300\uc5d0 \ucda9\uc804\uc18c\uac00 \ud3c9\uc18c\uc5d0 \ub35c \ubd90\ube4c \uac83\uc774\ub77c\ub294 \ud1b5\uacc4 \uc790\ub8cc\ub97c \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-majang-3.png)\\n\\n![no offset](./go-to-majang-4.png)\\n\\n![no offset](./go-to-majang-5.png)\\n\\n\ub9c8\uc7a5 \ud638\uc218\uae4c\uc9c0 20\ubd84 \uac70\ub9ac\ub97c \ub0a8\uae30\uace0, \uac11\uc790\uae30 \ubc30\uac00 \uace0\ud30c\uc9c4 \uc800\ud76c\ub294 \ubaa9\uc801\uc9c0\ub97c \ud2c0\uc5b4 `\ud30c\uc8fc\ub2ed\uad6d\uc218 \ubcf8\uc810`\uc744 \uac00\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \ud30c\uc8fc\ub2ed\uad6d\uc218\uac00 \uc5b4\ub514\uc5d0 \uc788\uc9c0?\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud65c\uc6a9\ud558\uc5ec \ud30c\uc8fc\ub2ed\uad6d\uc218 \ubcf8\uc810 \uadfc\ucc98\uc758 \ucda9\uc804\uc18c\ub97c \uac80\uc0c9\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\uc790\ub3d9\ucc28 \ub0b4\ube44\uac8c\uc774\uc158\uc5d0\ub294 \ud30c\uc8fc\ub2ed\uad6d\uc218\uac00 \uc5b4\ub514\uc778\uc9c0 \ub098\uc640\uc788\uc9c0\ub9cc, \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\ub294 \uc2dd\ub2f9 \uc815\ubcf4\ub294 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\ud574\ub2f9 \uc2dd\ub2f9\uc774 \ub3c4\ub300\uccb4 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0 \ud655\uc778\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4. (\ud30c\uc8fc\ub2ed\uad6d\uc218\uc5d0\uc11c\ub294 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5c6\uc5c8\uae30 \ub584\ubb38\uc785\ub2c8\ub2e4.)\\n\\n![no offset](./songchoo-to-noodle-1.png)\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \uc790\ub3d9\ucc28 \ub0b4\ube44\uac8c\uc774\uc158\uc5d0 \uc788\ub294 \ub3c4\ub85c\uba85 \uc8fc\uc18c\ub97c \uac80\uc0c9\ud558\uc5ec \uc704\uce58\ub97c \ud30c\uc545\ud558\ub824\uace0 \ud558\uc600\uace0, \ub2e4\uc18c \ubd80\uc815\ud655 \ud558\uc9c0\ub9cc \ub3d9\ub124\uc5d0 \uc788\ub294 \uc778\uadfc \ucda9\uc804\uc18c\ub97c \ucc3e\uc744 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ud734\uac8c\uc18c\uc5d0 \ub4e4\ub9ac\ub2e4\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub85c \uac80\uc0c9\ud574\ubcf4\ub2c8 \uc2dd\ub2f9\uc73c\ub85c \uac00\ub294 \uae38 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \ud569\ub2c8\ub2e4.\\n\ud734\uac8c\uc18c \uc774\ub984\uc744 \uc785\ub825\ud558\ub2c8 \ubc14\ub85c \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-3.png)\\n\\n\uc2ec\uc9c0\uc5b4 \uc9c0\uae08 \uc0ac\uc6a9\uc911\uc774\ub77c\uace0 \ud569\ub2c8\ub2e4! \ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \ud734\uac8c\uc18c\uc5d0 \ub4e4\ub9ac\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-1.png)\\n![no offset](./yangju-station-2.png)\\n\\n\uc2e4\uc81c\ub85c \uc0ac\uc6a9 \uc911\uc784\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4. \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \uc0ac\uc6a9\uc911\uc774\ub77c\uace0 \ub098\uc654\ub294\ub370 \uc2e4\uc81c\ub85c \uc0ac\uc6a9\uc911\uc778 \uac83\uc744 \ubcf4\ub2c8 \uacf5\uacf5 api\uac00 \ub098\ub984 \uc2e4\uc2dc\uac04\uc73c\ub85c \ub370\uc774\ud130\ub97c \uc798 \ubcf4\ub0b4\uc8fc\uace0 \uc788\ub2e4\uace0 \uc0dd\uac01\ud558\uac8c \ub418\uc5c8\uace0, \uc800\ud76c \ud300 \uc11c\ubc84\uc5d0\uc11c\ub3c4 \uc774\ub97c \uc81c\ub300\ub85c \uc218\uc9d1\ud558\uace0 \uc788\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-5.png)\\n\\n\ub9d0\ub85c\ub9cc \ub4e3\ub358 \uace0\uc18d\ub3c4\ub85c \ud734\uac8c\uc18c\uc758 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \ub300\uae30\uc904\uc744 \uc9c1\uc811 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ucc28\uc8fc \ubd84\uacfc \uc778\ud130\ubdf0 \ud558\uace0 \uc2f6\uc5c8\uc9c0\ub9cc, \ucc28 \ub0b4\ubd80\uc5d0\uc11c \ub108\ubb34 \ubc14\ube60\ubcf4\uc774\uc154\uc11c \uadf8\ub7f4 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uae30\ub2e4\ub9ac\uba74\uc11c \ubb34\uc5c7\uc744 \ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\uc774 \ubd84\uc740 \ub2e4\ud589\ud788\ub3c4 \uc5c5\ubb34\ub97c \ubcf4\uace0 \uacc4\uc168\uc9c0\ub9cc, \ub2e4\ub978 \ucc28\uc8fc\ub4e4\uc740 \ubb34\uc5c7\uc744 \ud558\uace0 \ubcf4\ub0bc\uc9c0 \uad81\uae08\ud574\uc84c\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-4.png)\\n\\n\ud734\uac8c\uc18c\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \ud558\ub098 \ub354 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud55c \uacf3\uc740 \uc0ac\uc6a9\uc911\uc774\uc9c0\ub9cc, \ub2e4\ub978 \ud55c \uacf3\uc740 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc774 \ucda9\uc804\uc18c\ub97c \uc0ac\uc6a9\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-6.png)\\n\\n\uc0ac\uc6a9\ud560 \uc218 \uc788\uc73c\ub2c8\uae50 \ub4e4\uc5b4\uac00\ubd10\uc57c\uc9c0! \ud558\uace0 \ub3c4\ucc29\ud55c \uc21c\uac04 \uc544\ucc28 \uc2f6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\"\uc544, \ucda9\uc804\uc18c\uac00 \uc678\ubd80\uc778 \uc0ac\uc6a9 \uae08\uc9c0\uc77c \uc218 \uc788\uc5c8\uc9c0?\\"\\n\\n\uc800\ud76c\ub294 \ubd84\uba85\ud788 \uc11c\ube44\uc2a4\ub97c \uc9c1\uc811 \uac1c\ubc1c\ud588\uc73c\ub2c8\uae50 \ub2e4 \uc54c\uace0 \uc788\ub358 \uc0ac\ud56d\uc774\uc5c8\uc9c0\ub9cc, \uc804\ud600 \uc0dd\uac01\uce58 \ubabb\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\ub294 \ub0b4\ub0b4 \uc678\ubd80\uc778 \uac1c\ubc29 \ucda9\uc804\uc18c\uc5d0 \ub300\ud55c \uc911\uc694\uc131\uc744 \uac04\ud30c\ud558\uc600\uace0, \uc774 \uae30\ub2a5\uc744 \ub123\uc5c8\uc73c\uba74\uc11c\ub3c4 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \ucda9\uc804\uc18c\ub97c \ubc29\ubb38\ud55c \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5b4\uc11c \ub2e4\ud589\uc774\uc5c8\uc9c0\ub9cc, \uc5b4\ucc0c\ub410\ub4e0 \uc774 \ucda9\uc804\uc18c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud734\uac8c\uc18c\ub97c \ub5a0\ub098\ub294 \ub0b4\ub0b4 \uc774 \ubb38\uc81c\uc5d0 \ub300\ud574\uc11c \ud1a0\ub860\uc744 \ud560 \uc218 \ubc16\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```\\n\ubd84\uba85 \uc6b0\ub9ac\uac00 \ub9cc\ub4e0 \uc11c\ube44\uc2a4\uc778\ub370 \uc65c \ub193\ucce4\uc744\uae4c?\\n```\\n\\n## \ub9db\uc788\ub294 \uc810\uc2ec\\n\\n![no offset](./noodle-1.png)\\n\\n\ud30c\uc8fc\ub2ed\uad6d\uc218 \ubcf8\uc810\uc5d0\uc11c \ub9db\uc788\ub294 \uc2dd\uc0ac\ub97c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ube44\ub85d \uc2dd\ub2f9\uc5d0\ub294 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5c6\uc5c8\uc9c0\ub9cc, \uc778\uadfc\uc5d0 \ucda9\uc804\uc18c\uac00 \uc788\uc5b4 \uc2e4\ud5d8\uc744 \ud558\ub098 \ud574\ubcfc \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc778\uadfc \ucda9\uc804\uc18c\uc640 \uc2dd\ub2f9\uc758 \uac70\ub9ac\uac00 \uac00\uae4c\uc6cc \ubcf4\uc774\ub294\ub370, \uacfc\uc5f0 \uac78\uc5b4\uac08 \uc218 \uc788\uc744\uae4c?\\n\\n\uc2e4\uc81c\ub85c \uac77\uc9c0\ub294 \uc54a\uc558\uc2b5\ub2c8\ub2e4\ub9cc \ucc28 \ud0c0\uba74\uc11c \uc9c0\ub098\uac00\uba74\uc11c \ud655\uc778\ud574\ubcf8 \uacb0\uacfc \uc9c1\uc811 \uac78\uc744 \uc218 \uc5c6\ub294 \uac70\ub9ac\uc600\uc2b5\ub2c8\ub2e4. (\uad49\uc7a5\ud788 \uac77\uae30 \uc2eb\uc740 \uc218\uc900\uc758 \uba3c \uac70\ub9ac\uc600\uc2b5\ub2c8\ub2e4.)\\n\\n\uc9d1\uc5d0 \uc788\ub294 PHEV\ub97c \ud0c8 \uae30\ud68c\uac00 \ub9ce\uc544 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\ub97c \uc790\uc8fc \ubc29\ubb38\ud588\ub358 \uc800\ub294 \uc774\ub7f0 \uc810\uc744 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\ud589\ud788 \uc774 \ubd80\ubd84\uc744 \uc798 \uc54c\uace0 \uc788\uc5c8\uae30\uc5d0 \uc800\ud76c\ub294 \uc774 \ubd80\ubd84\uc744 \uc11c\ube44\uc2a4\uc5d0 \ubc18\uc601\ud558\uc600\uace0, \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c \ud3ec\uae30\ud558\uc9c0 \uc54a\uc558\ub358 \uac83\uc774 \uc633\uc740 \uc120\ud0dd\uc774\uc5c8\ub2e4\ub294 \uac83\uc744 \ud655\uc778\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./noodle-2.png)\\n\\n\uc2dd\uc0ac\uac00 \ub05d\ub098\uace0 \ub4dc\ub514\uc5b4 \ub9c8\uc7a5\ud638\uc218\ub85c \ucd9c\ubc1c\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ub9c8\uc7a5\ud638\uc218 \ub3c4\ucc29\\n\\n\ub9c8\uc7a5\ud638\uc218\uc5d0 \ub3c4\ucc29\ud558\uc790\ub9c8\uc790 \ucda9\uc804\uc18c\uc5d0 \ubc29\ubb38\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-1.png)\\n\\n\ud1b5\uacc4\uc5d0\uc11c\ub294 \uc0ac\uc6a9\ub960\uc774 \uc801\uc744 \uac83\uc774\ub77c\uace0 \ud558\uc600\ub294\ub370 \uc800\ud76c\ub9cc \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-2.png)\\n![no offset](./majang-4.png)\\n\\n2\uae30 \uc911 1\uacf3\uc744 \uc800\ud76c\uac00 \uc0ac\uc6a9\ud558\uc600\uace0, \ub9c8\uc7a5\ud638\uc218\ub97c \ub3cc\uc558\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-3.png)\\n\\n\uc57d 50\ubd84 \uac04 \uc0b0\ucc45\uc744 \ud558\uace0, \ub3cc\uc544\uc640\ubcf4\ub2c8 \ucda9\uc804\uae30 \ub2e4 \ub418\uc5b4\uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \ub9c8\uc7a5\ud638\uc218 \uae4c\uc9c0 \uc624\ub294 \ub0b4\ub0b4 \ub4e0 \uc0dd\uac01\uc774\uc5c8\uc9c0\ub9cc, \uc804\uae30\ucc28\uc758 \ubc30\ud130\ub9ac\uac00 \uc0dd\uac01\ubcf4\ub2e4 \uc624\ub798 \uac04\ub2e4\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc77c\ubd80\ub7ec \ud68c\uc0dd\uc81c\ub3d9 \uae30\ub2a5\ub3c4 \ub044\uace0, \uc5d0\uc5b4\ucee8\uc744 \uac15\ud558\uac8c \ud2c0\uc5b4\uc11c \ubc30\ud130\ub9ac\ub97c \uc18c\uc9c4\ud558\ub824\uace0 \ud558\uc600\uc73c\ub098, 85km\ub97c \uc8fc\ud589\ud558\ub294 \ub3d9\uc548 \uaca8\uc6b0 20%\ub97c \uc18c\ubaa8\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ucda9\uc804\uae30\ub97c \uaf42\uc744 \ub54c 50%\uc600\uc73c\ub098, \ud638\uc218\ub97c \ud55c\ubc14\ud034 \ub3cc\uace0 \uc624\ub2c8 \uc774\ubbf8 100%\uac00 \ub418\uc5b4\uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc5ec\ub2f4\uc774\uc9c0\ub9cc, \uc800\ud76c\uac00 \ub3cc\uc544\uc654\uc744 \ub54c \uc606 \uc790\ub9ac\uc5d0\ub294 \uc804\uae30 \ud654\ubb3c\ucc28\uac00 \uc788\uc5b4 \ucda9\uc804\uc18c\uac00 \uac00\ub4dd \ucc3c\uc2b5\ub2c8\ub2e4.\\n\\n\ub610, \uc571\uc5d0\uc11c\ub3c4 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80\uac00 \uc5c5\ub370\uc774\ud2b8 \ub418\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-5.png)\\n\\n\ubc30\ud130\ub9ac \uc131\ub2a5\uc5d0\ub294 \uc88b\uc9c0 \uc54a\uace0 \uac00\uaca9\ub3c4 \ube44\uc2f8\uc11c \uc774\ub97c \uc790\uc8fc \uc0ac\uc6a9\ud558\ub294 \uac83\uc740 \uc88b\uc9c0 \uc54a\uaca0\uc9c0\ub9cc, \uae09\ud55c \uc0ac\ub78c\ub4e4\uc740 \uae09\uc18d \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\ud558\uba74 \ub418\uaca0\uad6c\ub098 \uc2f6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```\\n\ub530\ub77c\uc11c \uae09\uc18d\uacfc \uc644\uc18d\uc740 \ub354\ub354\uc6b1 \ub2e4\ub978 \uac1c\ub150\uc73c\ub85c \ubd10\uc57c\uaca0\ub2e4\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n```\\n\\n\uc81c\uac00 \uadf8\ub3d9\uc548 \uacbd\ud5d8\ud588\ub358 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\ub294 \uc644\uc18d \uae30\uc900\uc774\uc5c8\uae30\uc5d0 \uc2e0\uc120\ud55c \uacbd\ud5d8\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uc120\ub989\uc73c\ub85c \ub3cc\uc544\uc624\ub2e4\\n\\n![no offset](./end.png)\\n\\n\uc120\ub989\uc73c\ub85c \ub3cc\uc544\uc640\uc11c \ucc28\ub7c9\uc744 \ubc18\ub0a9\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc774\ubc88 \uc5ec\uc815\uc744 \ud1b5\ud574 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc5b4\ub5a4 \uc810\uc744 \uac1c\uc120\ud574\uc57c\ud560\uc9c0 \uc880 \ub354 \uba85\ud655\ud558\uac8c \uc54c\uac8c\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1. \ud604\uc7ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \uae30\ub2a5\ub4e4\ub85c \ucda9\uc804\uc18c\ub97c \uac80\uc0c9\ud558\ub294 \uac83\uc740 \uac00\ub2a5\ud558\uba70, \ucda9\uc804\uc18c\uc758 \uc704\uce58\ub97c \uc815\ud655\ud558\uac8c \ud30c\uc545\ud558\ub294 \uac83\ub3c4 \uac00\ub2a5\ud558\ub2e4.\\n2. \ud558\uc9c0\ub9cc \ucda9\uc804\uc18c\uac00 \uc5c6\ub294 \ubaa9\uc801\uc9c0\ub294 \uac80\uc0c9\ud560 \uc218 \uc5c6\uace0, \ud604 \uc704\uce58\uac00 \uc5b4\ub514\uc778\uc9c0 \uac00\ub2a0\ud558\uae30\uac00 \uc5b4\ub824\uc6cc\uc9c4\ub2e4.\\n3. \ucda9\uc804\uc18c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub2e4\uace0 \ud45c\uae30\ub418\uc5b4 \uc788\ub354\ub77c\ub3c4 \uc678\ubd80\uc778 \uac1c\ubc29\uc774 \uc544\ub2d0 \uc218 \uc788\ub2e4. \uc815\ubcf4\uac00 \uc815\ud655\ud788 \uc81c\uacf5\ub428\uc5d0\ub3c4 \ubd88\uad6c\ud558\uace0 \uc774\ub97c \ub2e8\ubc88\uc5d0 \ub208\uce58\ucc44\uae30 \uc5b4\ub835\ub2e4.\\n4. \uc774\ub7ec\ud55c \ubb38\uc81c\ub97c \uc608\uc0c1\ud558\uc5ec `\uc678\ubd80\uc778 \uac1c\ubc29 \uc5ec\ubd80`\ub97c \ud544\ud130\ub9c1 \ud560 \uc218 \uc788\ub294 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\uace0 \uc788\uc74c\uc5d0\ub3c4 \ubd88\uad6c\ud558\uace0 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n5. \ucda9\uc804\uc18c\uc758 \ud1b5\uacc4 \uc790\ub8cc\uc758 \uc801\uc911\ub960\uc740 \ub192\uc558\uc73c\ub098, \uc880 \ub354 \ub9ce\uc740 \ucda9\uc804\uc18c\ub97c \ub4e4\ub824 \ud655\uc778\ud574\ubd10\uc57c \ud560 \uac83 \uac19\uc558\ub2e4.\\n6. \uc804\uae30\uc790\ub3d9\ucc28\ub294 \uc0dd\uac01\ubcf4\ub2e4 \uc624\ub798\uac00\uace0 \uc0c1\ud488\uc131\uc774 \uc788\uc5c8\ub2e4. \uc8fc\ud589 \ub2a5\ub825\ub3c4 \ucda9\ubd84\ud558\uace0, \uc778\ud504\ub77c\uac00 \uc798 \ub418\uc5b4\uc788\ub2e4. \uc774\uac78 \uc65c \uc695\ud558\uc9c0? \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\ub2e4.\\n7. \uc9c0\ub3c4 \ud655\ub300 \ud5c8\uc6a9 \ubc94\uc704\uac00 \ub108\ubb34 \uc881\uc544\uc11c \uc0ac\uc6a9\ud558\ub294\ub370 \ubd88\ud3b8\ud55c\uac74 \uc2e4\uc81c \uc0c1\ud669\uc5d0\uc11c \ub354 \ubd88\ud3b8\ud588\ub2e4.\\n\\n\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."},{"id":"37","metadata":{"permalink":"/37","source":"@site/blog/2023-09-22-station-api-separate.mdx","title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","description":"\uc131\ub2a5 \uac1c\uc120\uc744 \uc704\ud574 \ucda9\uc804\uc18c \uc870\ud68c API\uc758 \uc124\uacc4\ub97c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.","date":"2023-09-22T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 22\uc77c","tags":[{"label":"\ud611\uc5c5","permalink":"/tags/\ud611\uc5c5"},{"label":"\uc11c\ubc84 \ubd80\ud558 \uc904\uc774\uae30","permalink":"/tags/\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30"}],"readingTime":2.78,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"37","title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","authors":["scent"],"tags":["\ud611\uc5c5","\uc11c\ubc84 \ubd80\ud558 \uc904\uc774\uae30"]},"prevItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","permalink":"/39"},"nextItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/38"}},"content":"\uc131\ub2a5 \uac1c\uc120\uc744 \uc704\ud574 \ucda9\uc804\uc18c \uc870\ud68c API\uc758 \uc124\uacc4\ub97c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874\uc5d0\ub294 \ucda9\uc804\uc18c \uac04\ub2e8 \uc815\ubcf4\uc640 \ub9c8\ucee4 \uc815\ubcf4\ub97c \ud55c \ubc88\uc5d0 \ubc1b\uc544\uc624\ub3c4\ub85d \uc124\uacc4\ub418\uc5b4 \uc788\uc5c8\uc9c0\ub9cc,\\n\ubc31\uc5d4\ub4dc\uc640 \ud504\ub860\ud2b8\uc5d4\ub4dc\uac00 \ud611\uc5c5\ud558\uc5ec \uac04\ub2e8 \uc815\ubcf4\uc640 \ub9c8\ucee4 \uc815\ubcf4\ub97c \uac01\uac01 \ud544\uc694\ud55c \ub9cc\ud07c\ub9cc \uc870\ud68c\ud558\ub3c4\ub85d \uba85\uc138\ub97c \uc218\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \uba3c\uc800, \ubc31\uc5d4\ub4dc\uc640 \ud504\ub860\ud2b8\uc5d4\ub4dc\ub294 \ud568\uaed8 \ubaa8\uc5ec \uae30\ub2a5 \uc694\uad6c\uc0ac\ud56d\uacfc \uc131\ub2a5 \uac1c\uc120 \ubaa9\ud45c\ub97c \ub17c\uc758\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \ucda9\uc804\uc18c \uac04\ub2e8 \uc815\ubcf4\uc640 \ub9c8\ucee4 \uc815\ubcf4\ub97c \uac01\uac01 \uc870\ud68c\ud558\ub294 API \uc5d4\ub4dc\ud3ec\uc778\ud2b8\ub97c \uc0c8\ub85c \uc124\uacc4\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\uc74c\uc73c\ub85c, \ubc31\uc5d4\ub4dc\uc5d0\uc11c \uac04\ub2e8 \uc815\ubcf4 \uc870\ud68c\ub97c \uc704\ud55c API\ub97c \uad6c\ud604\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\ud544\uc694\ud55c \ud544\ub4dc\ub9cc\uc744 \uc870\ud68c\ud558\uc5ec \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ubd80\ud558\ub97c \uc904\uc774\uace0 \uc751\ub2f5 \uc2dc\uac04\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uc774\ud6c4\uc5d0\ub294 \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c \ud574\ub2f9 API\ub97c \ud638\ucd9c\ud558\uc5ec \ud544\uc694\ud55c \uc815\ubcf4\ub97c \ubc1b\uc544\uc624\ub3c4\ub85d \uc218\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c, \ub9c8\ucee4 \uc815\ubcf4 \uc870\ud68c\ub97c \uc704\ud55c API\ub97c \uad6c\ud604\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\ub9c8\ucee4 \uc815\ubcf4\ub294 \uc9c0\ub3c4\uc5d0 \ud45c\uc2dc\ub418\ub294 \uc815\ubcf4\ub85c\uc11c, \uc694\uccad\ud55c \uc601\uc5ed \uc678\ubd80\ub85c \uc9c0\ub3c4\uac00 \uc774\ub3d9\ud560 \uacbd\uc6b0 \ud638\ucd9c\ub418\ub3c4\ub85d \uc124\uacc4\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874\uc5d0\ub294 \uac04\ub2e8 \uc815\ubcf4 \ub9ac\uc2a4\ud2b8\ub97c \ubcf4\uc5ec\uc8fc\uae30 \uc704\ud574 \uc870\ud68c\ud558\ub358 \uc815\ubcf4\ub4e4\uc774 \ub2e4\uc218 \ud3ec\ud568\ub418\uc5b4 \uc788\uc5c8\uc9c0\ub9cc,\\n\uc774 \uc815\ubcf4\ub97c \uc81c\uc678\ud558\uace0 \ub9c8\ucee4\ub97c \ub744\uc6b0\uae30 \uc704\ud574 \ud544\uc694\ud55c \ucd5c\uc18c\ud55c\uc758 \uc815\ubcf4\ub97c \uc870\ud68c\ud558\ub3c4\ub85d \uc218\uc815\ud574 \uc11c\ubc84\uc758 \ubd80\ud558\ub97c \ub0ae\ucdc4\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7ec\ud55c \ubcc0\uacbd\uc73c\ub85c \uc778\ud574 \ucda9\uc804\uc18c \uc870\ud68c API\uc758 \uc131\ub2a5\uc774 \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ud544\uc694\ud55c \uc815\ubcf4\ub9cc\uc744 \uc870\ud68c\ud558\ubbc0\ub85c\uc368 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ubd80\ud558\ub97c \uc904\uc774\uace0 \uc751\ub2f5 \uc2dc\uac04\uc744 \ub2e8\ucd95\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ub610\ud55c, \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c\ub294 \ud544\uc694\ud55c \uc815\ubcf4\ub9cc\uc744 \ud638\ucd9c\ud558\uc5ec \ubd88\ud544\uc694\ud55c \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc624\uc9c0 \uc54a\uc544\ub3c4 \ub418\ubbc0\ub85c \ud074\ub77c\uc774\uc5b8\ud2b8 \uce21\uc758 \uc131\ub2a5\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uc2b5\ub2c8\ub2e4."},{"id":"38","metadata":{"permalink":"/38","source":"@site/blog/2023-09-22-visitors/index.mdx","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","description":"\uc800\ud76c \ud300\uc740 \ub2e8\uc21c \ubc29\ubb38\uc790 100\uba85\uc744 \ubaa8\uc544\uc57c\ud558\ub294 \ubbf8\uc158\uc744 \ubc1b\uc558\uc2b5\ub2c8\ub2e4.","date":"2023-09-22T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 22\uc77c","tags":[{"label":"ga4","permalink":"/tags/ga-4"},{"label":"google analytics 4","permalink":"/tags/google-analytics-4"},{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778"},{"label":"\ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/tags/\ubc29\ubb38\uc790-\ubd84\uc11d"}],"readingTime":3.82,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"38","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","authors":["gabriel"],"tags":["ga4","google analytics 4","\uce74\ud398\uc778","\ubc29\ubb38\uc790 \ubd84\uc11d"]},"prevItem":{"title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","permalink":"/37"},"nextItem":{"title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","permalink":"/36"}},"content":"\uc800\ud76c \ud300\uc740 \ub2e8\uc21c \ubc29\ubb38\uc790 100\uba85\uc744 \ubaa8\uc544\uc57c\ud558\ub294 \ubbf8\uc158\uc744 \ubc1b\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ubaa9\ud45c \ub2ec\uc131\uc744 \uc704\ud574 \uc57d 2\uc8fc \uc804\uc5d0 \uc2e4\ud589 \uacc4\ud68d\uc744 \uc81c\ucd9c\ud574\uc57c \ud588\ub294\ub370\uc694\\n\\n100\uba85\uc744 \ubaa8\uc9d1\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uacc4\ud68d\uc744 \uc138\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n![no offset](./plan.png)\\n\\n---\\n\\n\uc774 \ub2f9\uc2dc \uc800\ud76c \ud300\uc758 \uac00\uc7a5 \ud070 \uace0\ubbfc\uc740, \uc804\uae30\ucc28\uac00 \uc5ec\uc804\ud788 \uc18c\uc218\uc758 \uc6b4\uc804\uc790\uc5d0\uac8c\ub9cc \ubcf4\uae09\ub418\uc5c8\ub2e4\ub294 \uc810\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud2b9\ud788, \uc804\uae30\ucc28 \ubcf4\uae09 \uad00\ub828 \ud1b5\uacc4 \uc790\ub8cc\ub97c \ucc3e\uc544\ubcf4\uba74 \ub300\ubd80\ubd84\uc758 \ucc28\uc8fc\ub4e4\uc740 40~60\ub300\uc5d0 \uc555\ub3c4\uc801\uc73c\ub85c \ubab0\ub824\uc788\uc5b4 \uc80a\uc740 \uc5f0\ub839 \uce35\uc5d0\uc11c\ub294 \uac70\uc758 \uad6c\ub9e4\ub97c \ud558\uc9c0 \uc54a\uace0 \uc788\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n![no offset](./statistics.png)\\n\\n\uc704 \uc790\ub8cc\ub294 2021\ub144 7\uc6d4 \uae30\uc900\uc774\uc9c0\ub9cc, \ucd5c\uc2e0 \uc790\ub8cc\uc5d0\uc11c\ub3c4 \ub9c8\ucc2c\uac00\uc9c0\ub85c \uc80a\uc740 \uc5f0\ub839\uce35\uc5d0\uc11c\ub294 \uc804\uae30\ucc28\ub97c \ubcf4\uc720\ud55c \uc0ac\ub78c\uc744 \ucc3e\uae30 \uc5b4\ub835\ub2e4\uace0 \ub098\uc635\ub2c8\ub2e4. \uc2e4\uc81c\ub85c \uc8fc\ubcc0 \ub610\ub798\uc758 \uc6b4\uc804\uc790\ub97c \ucc3e\uc544\ubcf4\uba74 \ub300\ubd80\ubd84 \uac00\uc194\ub9b0 \ubaa8\ub378\uc744 \ud0c0\uace0 \ub2e4\ub2c8\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud64d\ubcf4 \ub300\uc0c1\uc744 \uc8fc\ubcc0\uc5d0\uc11c \ucc3e\uc9c0 \uc54a\uace0 \ubd88\ud2b9\uc815 \ub2e4\uc218\uc758 \uc0ac\ub78c\ub4e4\uc744 \ubaa8\uc9d1\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n# \ud64d\ubcf4 \ubc29\ubc95\\n\\n## \uce74\ud398\\n\\n![no offset](./insta1.png)\\n![no offset](./naver1.png)\\n\\n\ub124\uc774\ubc84\uc5d0 \uc788\ub294 \uc804\uae30\uc790\ub3d9\ucc28 \ub3d9\ud638\ud68c \uce74\ud398 \uc911 \uac00\uc7a5 \ud070 \uacf3\uc5d0 \uae00\uc744 \uc62c\ub824 \ubc29\ubb38\uc790\ub97c \ubaa8\uc9d1\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uce74\ud398\uc5d0 \uae00\uc744 \uc62c\ub9ac\ub294 \uac83\uc740 \ubb34\ub8cc\uc774\uba70, \uce74\ud398\uc5d0 \uac00\uc785\ud55c \uc0ac\ub78c\ub4e4\uc740 \uc804\uae30\ucc28\uc5d0 \uad00\uc2ec\uc774 \uc788\ub294 \uc0ac\ub78c\ub4e4\uc774\uae30 \ub54c\ubb38\uc5d0 \uc800\ud76c\uac00 \uc6d0\ud558\ub294 \ubc29\ubb38\uc790\ub97c \ubaa8\uc9d1\ud558\uae30\uc5d0 \uc801\ud569\ud558\ub2e4\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uce74\uce74\uc624\ud1a1 \uc624\ud508\ucc44\ud305\\n\\n![no offset](./kakao1.png)\\n![no offset](./kakao2.png)\\n\\n\uce74\uce74\uc624\ud1a1 \uc624\ud508\ucc44\ud305\uc5d0\ub294 \uc218\ub9ce\uc740 \ub300\ud654\ubc29\uc774 \uc874\uc7ac\ud569\ub2c8\ub2e4.\\n\\n\ud2b9\uc815 \uc8fc\uc81c\ub85c \ub9cc\ub4e4\uc5b4\uc9c4 \ub300\ud654\ubc29\uc774 \ub300\ubd80\ubd84\uc774\uae30\uc5d0 \uc804\uae30\ucc28\ub97c \uc8fc\uc81c\ub85c \ud55c \uc624\ud508\ucc44\ud305 \ub300\ud654\ubc29\uc744 \ucc3e\ub294 \uac83\uc740 \uc804\ud600 \uc5b4\ub835\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\uc548\ud0c0\uae5d\uac8c\ub3c4 \uc77c\ubd80 \ub2e8\ud1a1\ubc29\uc5d0\uc11c \uac15\ud1f4\ub97c \ub2f9\ud588\uc9c0\ub9cc, \ucc28\uc8fc\ub4e4\uacfc \ucc44\ud305\ud558\uba74\uc11c \ud53c\ub4dc\ubc31\uc744 \ubc1b\uc544\ubcfc \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uae30\ud0c0 \ud64d\ubcf4 \uc218\ub2e8\\n\\n\uae30\ud0c0 \ud64d\ubcf4 \uc218\ub2e8\uc740 \uc544\uc9c1 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ub124\uc774\ubc84 \ubc34\ub4dc, \ubcf4\ubc30\ub4dc\ub9bc\uc740 \uc0ac\uc6a9\ud558\ub294 \ud06c\ub8e8\uac00 \uc5c6\uc5b4\uc11c \ud64d\ubcf4\ub97c \ud558\uae30 \uc5b4\ub824\uc6e0\uace0, \uad6c\uae00 \uc560\ub4dc\uc13c\uc2a4\uc640 \uac19\uc740 \ub3c4\uad6c\ub294 \ube44\uc6a9\uc774 \ubc1c\uc0dd\ud558\uae30\uc5d0 \uc544\uc9c1\uc740 \uc774\ub974\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n# Google Analytics 4 \ud1b5\uacc4 \uc9d1\uacc4 \uacb0\uacfc\\n\\n## \ub2e8\uc21c \ubc29\ubb38\uc790\\n\\n![no offset](./ga1.png)\\n![no offset](./ga2.png)\\n![no offset](./ga3.png)\\n![no offset](./ga4.png)\\n\uc774\ucc98\ub7fc \uc678\ubd80 \uc9c0\uc5ed\uc5d0\uc11c\ub3c4 \ub9ce\uc774 \uc811\uc18d\ud574\uc8fc\uc2e0 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n![no offset](./ga5.png)\\n![no offset](./ga6.png)\\n![no offset](./ga7.png)\\n\\n\uc9d1\uacc4 \ub41c \uc790\ub8cc\ucc98\ub7fc \ubc29\ubb38\uc790\ub4e4\uc774 \ub2e8\uc21c \ubc29\ubb38\ub9cc \ud55c \uac83\uc774 \uc544\ub2c8\ub77c, \uc218 \ub9ce\uc740 \uc774\ubca4\ud2b8\ub97c \ubc1c\uc0dd\uc2dc\ud0a4\uace0 \ud3c9\uade0 \ucc38\uc5ec \uc2dc\uac04\ub3c4 \uc0c1\ub2f9 \ubd80\ubd84 \ud655\ubcf4\ud588\uc74c\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."},{"id":"36","metadata":{"permalink":"/36","source":"@site/blog/2023-09-21-marker-rendering-optimization.mdx","title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","description":"1. \uac1c\uc694","date":"2023-09-21T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 21\uc77c","tags":[{"label":"react","permalink":"/tags/react"},{"label":"useSyncExternalState","permalink":"/tags/use-sync-external-state"},{"label":"googleMap","permalink":"/tags/google-map"}],"readingTime":12.04,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"36","title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","authors":["scent"],"tags":["react","useSyncExternalState","googleMap"]},"prevItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/38"},"nextItem":{"title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","permalink":"/35"}},"content":"### 1. \uac1c\uc694\\n\\n\uae30\uc874\uc758 \uad6c\uc870\uc5d0\uc11c\ub294 \ub9c8\ucee4 \ud558\ub098\ub97c \ub80c\ub354\ub9c1\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uacfc\uc815\uc744 \uac70\ucce4\ub2e4.\\n\\n1. StationMarkersContainer \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ucda9\uc804\uc18c \uc815\ubcf4 \uc694\uccad\\n2. \ucda9\uc804\uc18c \uc815\ubcf4\ub97c props\ub85c \ub118\uaca8 Marker \ucef4\ud3ec\ub10c\ud2b8 \ud638\ucd9c\\n3. \uc9c0\ub3c4\uc5d0 \ubd80\ucc29\ub420 DOM\uc694\uc18c \uc0dd\uc131\\n4. createRoot\ub97c \ud1b5\ud574 \ub9ac\uc561\ud2b8 root \uc0dd\uc131\\n5. 2\ubc88\uc5d0\uc11c \uc0dd\uc131\ud55c DOM \uc694\uc18c\ub97c \uc804\ub2ec\ud574 \uad6c\uae00 \uc9c0\ub3c4 api\uc758 Marker \uc0dd\uc131\uc790 \ud568\uc218 \ud638\ucd9c\\n6. 3\ubc88\uc5d0\uc11c \uc0dd\uc131\ud588\ub358 root\uc758 render \uba54\uc11c\ub4dc \ud638\ucd9c\\n7. \ub9c8\ucee4 \uc778\uc2a4\ud134\uc2a4 \uc804\uc5ed \uc0c1\ud0dc\uc5d0 \uc0c8\ub85c \uc0dd\uc131\ud55c \ub9c8\ucee4 \ucd94\uac00\\n\\n\uc704 \uacfc\uc815\uc744 \uac70\ucce4\uc744 \ub54c\uc758 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \ubaa8\uc2b5\uc744 \ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n![before](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/28520ee3-2fa6-4110-b4e4-8a0bb706324e)\\n\\n\ub9c8\ucee4\ub4e4\uc774 \ud55c\ubc88\uc5d0 \ub80c\ub354\ub9c1 \ub418\ub294 \uac83\uc774 \uc544\ub2c8\ub77c \uc0b0\ubc1c\uc801\uc73c\ub85c \ub80c\ub354\ub9c1 \ub418\ub294 \ubaa8\uc2b5\uc744 \ud655\uc778\ud560 \uc218 \uc788\ub2e4.\\n\\n### 2. \ubb38\uc81c \uc6d0\uc778 \ubd84\uc11d\\n\\n\ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud558\uae30 \uc704\ud574 \uac70\uce58\ub294 \uacfc\uc815\uc744 \ubd84\uc11d\ud574 \ubcf4\uc558\ub2e4.\\n\\n1 ~ 3 \uacfc\uc815\uc5d0\uc11c\ub294 \uc131\ub2a5\uc5d0 \ud06c\uac8c \uc601\ud5a5\uc744 \ub07c\uce60 \uc694\uc18c\uac00 \uc5c6\uc9c0\ub9cc 4\ubc88 \uacfc\uc815\uc740 \uc77c\ubc18\uc801\uc778 \ub9ac\uc561\ud2b8 \ud504\ub85c\uc81d\ud2b8\ub97c \uac1c\ubc1c\ud560 \ub54c \uacaa\ub294 \uacfc\uc815\uc774 \uc544\ub2c8\ub2e4. \ub530\ub77c\uc11c createRoot\ub97c \ud1b5\ud574 \ub9ce\uc740 \uac1c\uc218\uc758 \ub8e8\ud2b8\ub97c \uc0dd\uc131\ud588\uc744 \ub54c\uc758 \uc601\ud5a5\uc5d0 \ub300\ud574 \uc54c\uc544\ubcf4\uc558\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/494a5bc5-be5d-4a58-b5b2-77ce7d3e5de7)\\n\\n\ub9ac\uc561\ud2b8 \uacf5\uc2dd \ubb38\uc11c\ub97c \ubcf4\ub2c8 \ud398\uc774\uc9c0\uc758 \uc77c\ubd80\uc5d0 \ub9ac\uc561\ud2b8\ub97c \ubfcc\ub824\uc11c \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0\uc5d0\ub294 \ub8e8\ud2b8\ub97c \ud544\uc694\ud55c \ub9cc\ud07c \uc0dd\uc131\ud574\ub3c4 \ub41c\ub2e4\ub294 \uc774\uc57c\uae30\uac00 \ud3ec\ud568\ub418\uc5b4 \uc788\uc5c8\ub2e4. \ub530\ub77c\uc11c 4\ubc88 \uacfc\uc815 \ub610\ud55c \ubb38\uc81c\uc758 \uc6d0\uc778\uc774\ub77c\uace0 \ubcfc \uc218 \uc5c6\uc5c8\ub2e4.\\n\\n5\ubc88 \uacfc\uc815\uc740 \uad6c\uae00 \uc9c0\ub3c4\uc5d0 \ub9c8\ucee4\ub97c \ud2b9\uc815 \uc704\ub3c4 \uacbd\ub3c4\uc5d0 \uc704\uce58\uc2dc\ud0a4\uae30 \uc704\ud574\uc11c \uc5b4\uca54 \uc218 \uc5c6\uc774 \uac70\uccd0\uc57c \ud558\ub294 \uacfc\uc815\uc774\ubbc0\ub85c \uc774 \uacfc\uc815\uc740 \ubb38\uc81c\uac00 \uc788\ub354\ub77c\ub3c4 \uac1c\uc120\uc774 \ubd88\uac00\ub2a5\ud574 \uc77c\ub2e8 \uace0\ub824\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n\\n6\ubc88 \uacfc\uc815\uc740 4\ubc88 \uacfc\uc815\uc5d0\uc11c \uc0dd\uc131\ud588\ub358 \ub9ac\uc561\ud2b8 \ub8e8\ud2b8\uc758 render \uba54\uc11c\ub4dc\ub97c \ud638\ucd9c\ud574 \uc2e4\uc81c\ub85c \ud654\uba74\uc5d0 \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \uadf8\ub9ac\ub3c4\ub85d \ud558\ub294 \uacfc\uc815\uc774\ub2e4. \uc774 \uacfc\uc815 \ub610\ud55c \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1\ud558\uae30 \uc704\ud574\uc120 \uc5b4\uca54 \uc218 \uc5c6\uc774 \uac70\uccd0\uc57c \ud558\ub294 \uacfc\uc815\uc774\ubbc0\ub85c \uace0\ub824\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n\\n> \ud558\uc9c0\ub9cc 6\ubc88 \uacfc\uc815\uc5d0\uc11c \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \uc9c1\uc811 \uadf8\ub9ac\ub294 \uac83\uc774 \uc544\ub2c8\ub77c \uad6c\uae00 \uc9c0\ub3c4 api\uc758 \uae30\ubcf8 \ub9c8\ucee4\ub97c \uc0ac\uc6a9\ud558\uba74 \uc131\ub2a5\uc744 \ud5a5\uc0c1\uc2dc\ud0ac \uc218 \uc788\uc9c0 \uc54a\ub0d0\uace0 \ubc18\ubb38\ud560 \uc218\ub3c4 \uc788\uc744 \uac83\uc774\ub2e4. \uc774\uc804\uc5d0\ub294 \uc774\ub7ec\ud55c \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud574 \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud588\uc5c8\ub2e4. \uc6b0\ub9ac\uc758 \uc11c\ube44\uc2a4\ub294 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uc18c \uac1c\uc218\ub97c \ub9c8\ucee4\ub97c \ud1b5\ud574\uc11c\ub3c4 \uc804\ub2ec\ud558\uae30 \ub54c\ubb38\uc5d0 \uc774\ub97c \uace0\ub824\ud574 \uae30\ubcf8 \ub9c8\ucee4\ub97c \uc0ac\uc6a9\ud560 \ub54c \ub2e4\uc74c\uc758 \ub450 \uac00\uc9c0 \ubb38\uc81c\uac00 \uc0dd\uae34\ub2e4.\\n>\\n> 1. \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uc18c \uac1c\uc218\ub97c \uae30\ubcf8 \ub9c8\ucee4\uc5d0 \ub80c\ub354\ub9c1 \ud560 \ub54c \uc131\ub2a5\uc774 \ub9e4\uc6b0 \uc88b\uc9c0 \uc54a\ub2e4.\\n> 2. \ub9c8\ucee4\uc758 \ub514\uc790\uc778\uc744 \ubc14\uafb8\uace0\uc790 \ud560 \ub54c \ubcc0\uacbd\uc5d0 \ub300\uc751\ud558\uae30 \uc5b4\ub835\ub2e4.\\n>\\n> \ub530\ub77c\uc11c \ub9c8\ucee4\ub294 \ub9ac\uc561\ud2b8 \ub8e8\ud2b8\uc758 render \uba54\uc11c\ub4dc\ub97c \ud638\ucd9c\ud574 \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ub80c\ub354\ub9c1\ud558\ub294 \uac83\uc73c\ub85c \uacb0\uc815\ud588\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ub0a8\uc740 7\ubc88 \uacfc\uc815\uc5d0\uc11c\ub294 useSyncExternalState \ud6c5\uc744 \uc0ac\uc6a9\ud574 \uc804\uc5ed\uc801\uc73c\ub85c \uad00\ub9ac\ud558\uace0 \uc788\ub358 \uc0c1\ud0dc\uc5d0 \uc218\uc815\uc744 \uac00\ud558\ub294 \uc5f0\uc0b0\uc744 \uc218\ud589\ud55c\ub2e4. \uc774 \uacfc\uc815\uc740 \uc774\uc804\uc5d0\ub3c4 \uc131\ub2a5 \uc800\ud558\ub97c \uc720\ubc1c\ud560 \uac83\uc73c\ub85c \uc608\uc0c1\ub418\ub358 \ubd80\ubd84\uc774\uc5c8\ub2e4. (\ud558\ub2e8 \ub9c1\ud06c \ucc38\uace0)\\n\\n[useSyncExternalStore \ud6c5\uc744 \ud1b5\ud574 \uad6c\ub3c5\ud55c state\uac00 \ud55c\ubc88\uc5d0 \uc5c5\ub370\uc774\ud2b8 \ub418\ub294 \uc774\uc720](https://www.notion.so/useSyncExternalStore-state-67e686eead8b4750b3015a1f75ea3e76?pvs=21)\\n\\n\uc694\uccad\uc758 \uacb0\uacfc\ub85c \ubc1b\uc544\uc628 \ub9c8\ucee4 \uc815\ubcf4\uc758 \uac1c\uc218\uac00 100\uac1c\ub77c\uace0 \uac00\uc815\ud574\ubcf4\uc790. \uc6b0\ub9ac\ub294 \uc774\uc81c \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud560 \uac83\uc774\ub2e4. \uccab \ubc88\uc9f8 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1\uc744 \uc704\ud574 1\ubc88 ~ 6\ubc88\uc758 \uacfc\uc815\uc744 \uac70\uce5c \ud6c4 7\ubc88 \uacfc\uc815\uc744 \uc218\ud589\ud55c\ub2e4. \uadf8\ub7ec\uba74 \ub9ac\uc561\ud2b8 \uc785\uc7a5\uc5d0\uc11c\ub294 \ub9ac\uc561\ud2b8 \ub8e8\ud2b8\uc758 render \uba54\uc11c\ub4dc \ud638\ucd9c\uc5d0 \ub300\ud55c \ub3d9\uc791\uc744 \uc218\ud589\ud574\uc57c \ud558\uace0, \uc0c8\ub85c\uc6b4 \ub9c8\ucee4 \uc778\uc2a4\ud134\uc2a4\uc5d0 \ub300\ud55c \uc804\uc5ed \uc0c1\ud0dc\ub97c \ubcc0\uacbd\uc2dc\ud0a4\ub294 \ub3d9\uc791\uc744 \uc218\ud589\ud574\uc57c \ud55c\ub2e4. \ub9ac\uc561\ud2b8\uac00 \uc774 \uacfc\uc815\uc744 100\ubc88 \ubc18\ubcf5\ud558\uace0 \ub098\uba74 \uc6b0\ub9ac\ub294 \ube44\ub85c\uc18c \ubaa8\ub4e0 \ub9c8\ucee4\uac00 \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1 \ub41c \ubaa8\uc2b5\uc744 \ubcfc \uc218 \uc788\uc744 \uac83\uc774\ub2e4.\\n\\n\ub098\ub294 \uc774 \ubd80\ubd84\uc5d0\uc11c \uc131\ub2a5 \uc800\ud558\uc758 \uc694\uc18c\uac00 \uc788\ub2e4\uace0 \uc0dd\uac01\ud588\ub2e4. \ub9ac\uc561\ud2b8\uc5d0\uc11c\uc758 \uc0c1\ud0dc \ubcc0\ud654\ub294 \uace7 \ub9ac\uc561\ud2b8 \ub0b4\ubd80\uc758 \ub80c\ub354\ub9c1\uc744 \uc704\ud55c \ub85c\uc9c1\uc774 \uc218\ud589\ub418\uac8c \ud568\uc744 \uc758\ubbf8\ud558\uace0, \uc774 \uacfc\uc815\uc744 \uac1c\uc120 \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4\uc758 \uac1c\uc218\ub9cc\ud07c \ubc18\ubcf5\ud558\uace0 \uc788\uc5c8\ub358 \uac83\uc774\ub2e4. \uc5ec\uae30\uae4c\uc9c0 \uc0dd\uac01\ud574\ubcf4\ub2c8 \uc804\uc5ed \uc0c1\ud0dc \ubcc0\ud654\uc5d0 \ub300\ud574 \ub9ac\uc561\ud2b8\uac00 \ub80c\ub354\ub9c1\uc744 \uc704\ud55c \uc5f0\uc0b0\uc744 \uc9c4\ud589\ud560 \ub3d9\uc548\uc5d0\ub294 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1(render \uba54\uc11c\ub4dc \ud638\ucd9c)\uc774 \uba48\ucd94\ub294 \uac83\uc774 \uc544\ub2d0\uae4c \ud558\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud06c\ub86c \uac1c\ubc1c\uc790 \ub3c4\uad6c\uc758 \ud37c\ud3ec\uba3c\uc2a4 \ud0ed\uc744 \ub4e4\uc5b4\uac00 \ubcf4\ub2c8 \uc0b0\ubc1c\uc801\uc73c\ub85c \ubc1c\uc0dd\ud558\ub358 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc758 \ubb38\uc81c \uc6d0\uc778\uc774 \uc9d0\uc791\ud588\ub358 \uadf8 \uc6d0\uc778\uc784\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/20926d19-79a5-4d49-b733-de1c2b87059c)\\n\\n\ud504\ub808\uc784 \uc774\ubbf8\uc9c0 \ud558\ub2e8\uc744 \ubcf4\uba74 \uc0b0\ubc1c\uc801\uc778 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc774 \uc218\ud589\ub420 \ub54c\ub9c8\ub2e4 \uc218\ubc18\ub418\ub294 \uc5b4\ub5a4 \ud568\uc218 \ud638\ucd9c\uc774 \uc788\uc74c\uc744 \ud655\uc778\ud560 \uc218 \uc788\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/20b8f1e4-eceb-4e18-82f0-8ef6cc5ee8a1)\\n\\n\uc774 \ubd80\ubd84\uc774 \ubb38\uc81c\uc758 \ud568\uc218 \ud638\ucd9c \ubd80\ubd84\uc774\ub2e4. \uc790\uc138\ud788 \uc0b4\ud3b4\ubcf4\uba74 \uc0c1\ub2e8\uc5d0 `performWorkUntilDeadline`\uc774\ub780 \ud568\uc218\uac00 \ud638\ucd9c\ub428\uc744 \ubcfc \uc218 \uc788\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/d7a91ce6-4907-4c79-948b-d80a205a0697)\\n\\n\uc774 `performWorkUntilDeadline` \ub77c\ub294 \ud568\uc218\ub97c \uc870\uae08 \uc54c\uc544\ubcf4\ub2c8 \ud574\ub2f9 \ud568\uc218\ub294 \uac04\ub2e8\ud788 \ub9d0\ud574 \ub9ac\uc561\ud2b8\uc5d0\uc11c state\uc758 \ubcc0\uacbd\uc774 \ud55c\ubc88\uc5d0 \ub9ce\uc774 \ubc1c\uc0dd\ud560 \ub54c 5ms\uc758 \ub370\ub4dc\ub77c\uc778 \uc2dc\uac04\uc744 \uc904 \ub54c \uc0ac\uc6a9\ud558\ub294 \ud568\uc218\ub77c\ub294 \uac83\uc744 \uc54c\uac8c \ub418\uc5c8\ub2e4. \ubb38\uc81c\uc758 \uc6d0\uc778\uc774\ub77c\uace0 \uc0dd\uac01\ud588\ub358 \ub9c8\ucee4 \uac1c\uc218 \ub9cc\ud07c\uc758 \uc804\uc5ed \uc0c1\ud0dc \ubcc0\ud654\uac00 \uc2e4\uc81c\ub85c \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc744 \uc7a0\uc2dc \uc911\ub2e8\ud558\uac8c \ub9cc\ub4e4\uace0 \uc788\uc74c\uc744 \uc54c\uac8c \ub418\uc5c8\ub2e4.\\n\\n### 3. \ubb38\uc81c \ud574\uacb0\\n\\n\uc55e\uc11c \ubd84\uc11d\ud55c \ubb38\uc81c\ub97c \uac1c\uc120\ud574\ubcf4\uace0\uc790 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc5d0 \ud544\uc694\ud55c \ucda9\uc804\uc18c \uc815\ubcf4 \ubc30\uc5f4\uc744 \ubd80\ubaa8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ubc1b\uc544\uc640 \uac01 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc790\uc2dd \ucef4\ud3ec\ub10c\ud2b8\uc5d0 \ub118\uaca8\uc8fc\uace0, \uc790\uc2dd \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ub9c8\ucee4 \uc0dd\uc131\uacfc \ub80c\ub354\ub9c1 \ub85c\uc9c1\uc744 \uc218\ud589\ud558\ub358 \uae30\uc874\uc758 \ubc29\uc2dd\uc744 \ubd80\uc218\uace0 \ubd80\ubaa8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ubaa8\ub4e0 \uac83\uc744 \uc77c\uad04 \ucc98\ub9ac\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \uace0\uccd0\ubcf4\uc558\ub2e4.\\n\\n\uace0\uce58\ub294 \uacfc\uc815\uc5d0\uc11c \uae30\uc874 \ubc29\uc2dd\uc5d0\uc11c\ub294 \ub9ac\uc561\ud2b8 \uc0dd\uba85 \uc8fc\uae30\uc5d0 \uc758\uc874\ud558\uc5ec \ud654\uba74\uc5d0 \ubcf4\uc5ec\uc9c0\uc9c0 \uc54a\ub294 \ub9c8\ucee4\ub97c \uc9c0\uc6cc\uc8fc\ub358 \ub85c\uc9c1\uc744 \uc774\uc81c\ub294 \ubaa8\ub450 \uc9c1\uc811 \uad6c\ud604\ud574\uc57c \ud588\ub2e4.\\n\\n\uc774\uc804\uc758 \uc601\uc5ed\uacfc \uacb9\uce58\ub294 \ubd80\ubd84\uc5d0 \uc788\ub294 \ucda9\uc804\uc18c\ub294 \ub2e4\uc2dc \uadf8\ub9ac\uc9c0 \uc54a\uace0, \uc601\uc5ed \ubc16\uc758 \ucda9\uc804\uc18c\ub97c \ub098\ud0c0\ub0b4\ub294 \ub9c8\ucee4\ub294 \uc9c0\uc6cc\uc8fc\uace0, \uc774\uc804\uc758 \uc601\uc5ed\uacfc \uacb9\uce58\uc9c0 \uc54a\ub294 \uc0c8\ub85c \ubc1b\uc544\uc628 \ucda9\uc804\uc18c\ub294 \uadf8\ub9ac\ub3c4\ub85d \ub2e4\uc74c\uacfc \uac19\uc774 \uba54\uc11c\ub4dc\ub97c \ubd84\ub9ac\ud574\ubcf4\uc558\ub2e4.\\n\\n- \uae30\uc874\uacfc \uacb9\uce58\uc9c0 \uc54a\ub294 \uc0c8\ub85c\uc6b4 \uc601\uc5ed\uc5d0 \ub300\ud55c \ub9c8\ucee4\ub97c \uc0dd\uc131\ud558\ub294 \uba54\uc11c\ub4dc\\n- \uae30\uc874\uacfc \uacb9\uccd0\uc9c0\ub294 \uc601\uc5ed\uc5d0 \ub300\ud55c \ub9c8\ucee4\ub4e4\uc744 \ubc18\ud658\ud558\ub294 \uba54\uc11c\ub4dc\\n- \uc0c8\ub85c\uc6b4 \uc601\uc5ed \ubc16\uc5d0 \uc788\ub294 \ub9c8\ucee4\ub4e4\uc744 \uc9c0\uc6cc\uc8fc\ub294 \uba54\uc11c\ub4dc\\n- \uc0c8\ub86d\uac8c \uc0dd\uc131\ub41c \ub9c8\ucee4\ub97c \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1\ud558\ub294 \uba54\uc11c\ub4dc\\n\\n\uc774 \uba54\uc11c\ub4dc\ub4e4\uc744 \ucee4\uc2a4\ud140 \ud6c5\uc73c\ub85c \ubd84\ub9ac\ud574 \ubd80\ubaa8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc774\ub97c \ud65c\uc6a9\ud558\ub3c4\ub85d \ud558\uc5ec \ub2e4\uc18c \ubcf5\uc7a1\ud560 \uc218 \uc788\ub294 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \ub85c\uc9c1\uc744 \uc120\uc5b8\uc801\uc73c\ub85c \uad6c\ud604\ud560 \uc218 \uc788\ub3c4\ub85d \ud588\ub2e4.\\n\\n\uacb0\uacfc\uc801\uc73c\ub85c \uae30\uc874\uc5d0 \uc0ac\uc6a9\ub418\ub358 \uae30\ub2a5\ub4e4\uc744 \uadf8\ub300\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc73c\uba74\uc11c \ud654\uba74\uc5d0 \ub9c8\ucee4\uac00 \uc0b0\ubc1c\uc801\uc73c\ub85c \ub80c\ub354\ub9c1 \ub418\ub358 \ubb38\uc81c\uac00 \ud574\uacb0 \ub418\uc5c8\uace0, \ubd80\uac00\uc801\uc778 \ud6a8\uacfc\ub85c \uc804\uccb4 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1 \uc2dc\uc810\ub3c4 \uc55e\ub2f9\uae38 \uc218 \uc788\uac8c \ub418\uc5c8\ub2e4. + \uae30\uc874\uc5d0\ub294 \uad6c\uc870\uc801\uc778 \ubb38\uc81c\ub85c \uc5f0\uc0b0\ub7c9\uc774 \ub108\ubb34 \ub9ce\uc544 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc774 \ub2a6\uc5b4\uc838 \uc774\ub97c \ub3c4\uc785\ud560 \uc218 \uc5c6\uc5c8\ub358 \ubb38\uc81c\ub97c \uad6c\uc870 \uc218\uc815\uc73c\ub85c \uc778\ud574 \uc801\uc6a9\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\ub2e4.\\n\\n### \uc791\uc5c5\ud55c PR\\n\\nhttps://github.com/woowacourse-teams/2023-car-ffeine/pull/737\\n\\n## \uacb0\uacfc \ubd84\uc11d (performance \ud0ed \ud65c\uc6a9)\\n\\n### before\\n\\n\ub9c8\ucee4 \uc870\ud68c \uc694\uccad\uc774 \uc885\ub8cc\ub41c \uc2dc\uc810: \uc57d `2499ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/033e8519-a1aa-43a4-959d-afeba93c1917)\\n\\n\uccab \ub9c8\ucee4 \ub80c\ub354\ub9c1 \uc2dc\uc810: `3093ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/b4fc47ca-4ef3-43f4-a9a5-7117edabc225)\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \uc885\ub8cc \uc2dc\uc810: \uc57d `3611ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/2b8a4c4c-218b-419a-8a47-e3b768d35bc2)\\n\\n\ucc98\uc74c\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub420 \ub54c\uae4c\uc9c0 \uc18c\uc694\ub41c \uc2dc\uac04: `594ms`\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc5d0 \uc18c\uc694\ub41c \uc2dc\uac04: `1112ms`\\n\\n### after\\n\\n\ub9c8\ucee4 \uc870\ud68c \uc694\uccad\uc758 \uc2dc\uc791\uc810: \uc57d `1875ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/b7b8ff0c-2314-4e3f-a9f4-72c445636283)\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \uc885\ub8cc \uc2dc\uc810: `2395ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/d75c323e-5c04-42a2-ad3e-1d13ea52216e)\\n\\n\ucc98\uc74c\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub420 \ub54c\uae4c\uc9c0 \uc18c\uc694\ub41c \uc2dc\uac04: `519ms`\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc5d0 \uc18c\uc694\ub41c \uc2dc\uac04: `519ms`\\n\\n### \uac1c\uc120 \uacb0\uacfc\\n\\n\ucc98\uc74c\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \uc2dc\uc810\uc740 \ub450 \ubc29\uc2dd \ubaa8\ub450 \ube44\uc2b7\ud55c \uacb0\uacfc\ub97c \ubcf4\uc778\ub2e4. \ud558\uc9c0\ub9cc \uac1c\uc120 \ud6c4 \ubc29\uc2dd\uc740 \ud55c\ubc88\uc5d0 \ubaa8\ub4e0 \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \ubc29\uc2dd\uc774\uace0, \uac1c\uc120 \uc774\uc804\uc758 \ubc29\uc2dd\uc740 \uc0b0\ubc1c\uc801\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \ubc29\uc2dd\uc774\ubbc0\ub85c \uac1c\uc120 \ud6c4\uc758 \ubc29\uc2dd\uc5d0\uc11c \uc804\uccb4 \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud558\ub294 \uc2dc\uc810\uc774 \ud6e8\uc52c \ube68\ub77c\uc9c0\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uacb0\uacfc\uc801\uc73c\ub85c \uc804\uccb4 \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \uc18d\ub3c4 \uc57d `55.6%` \ub2e8\ucd95\ud558\uac8c \ub418\uc5c8\ub2e4. \uc774 \uacb0\uacfc\ub294 \ub9c8\ucee4\uac00 \ub298\uc5b4\ub0a0 \uc218\ub85d \ub354\uc6b1 \ucc28\uc774\uac00 \uadf9\uc801\uc73c\ub85c \ubc8c\uc5b4\uc9c8 \uac83\uc73c\ub85c \uc608\uc0c1\ub41c\ub2e4.\\n\\nbefore\\n\\n![before](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/28520ee3-2fa6-4110-b4e4-8a0bb706324e)\\n\\nafter\\n\\n![after](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/1b1521c6-d220-4140-bbe9-fff40051c6a2)"},{"id":"35","metadata":{"permalink":"/35","source":"@site/blog/2023-09-18-scheduling.mdx","title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4","date":"2023-09-18T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 18\uc77c","tags":[{"label":"java","permalink":"/tags/java"}],"readingTime":8.89,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"35","title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","authors":["boxster"],"tags":["java"]},"prevItem":{"title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","permalink":"/36"},"nextItem":{"title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/34"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4\\n\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc8fc\uae30\uc801\uc73c\ub85c \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\uc640 \uc815\ubcf4\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\uac70\ub098, \ud1b5\uacc4\ub97c \uc800\uc7a5\ud558\ub294 \uc2a4\ucf00\uc904\ub9c1 \uc791\uc5c5\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc9c0\uae08\uc758 \uc800\ud76c \uc11c\ubc84\ub294 \ub2e8\uc77c \uc11c\ubc84\ub85c \uad6c\uc131\ub418\uc5b4\uc788\uc5b4 \ubb38\uc81c\uac00 \uc5c6\uc9c0\ub9cc, \ub9cc\uc57d **\uc11c\ubc84\ub97c scale-out** \ud558\uac8c \ub41c\ub2e4\uba74 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694?\\n\\n**\ub611\uac19\uc740 schedule\uc774 \uc911\ubcf5**\ub418\uc5b4 \uc2e4\ud589\ub420 \uac83\uc785\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uace0 \uc5b4\ub5a4 \uc11c\ubc84\ub294 schedule\uc744 \ub3d9\uc791\ud558\uc9c0 \uc54a\ub3c4\ub85d \ud558\uace0, \uc5b4\ub5a4 \uc11c\ubc84\ub294 schedule\uc744 \ub3d9\uc791\ud558\ub3c4\ub85d \ud55c\ub2e4\uba74 \uc2a4\ucf00\uc904\uc774 \ub3d9\uc791\ud558\ub294 \uc11c\ubc84\uac00 \ub2e4\uc6b4\ub41c\ub2e4\uba74 \ub3d9\uc791\ud558\ub294\\n\uc11c\ubc84\uc758 \ub2e4\uc6b4\ud0c0\uc784\ub9cc\ud07c \uc800\ud76c \uc11c\ubc84\uc758 \ub370\uc774\ud130\ub97c \ucd5c\uc2e0\ud654\ud560 \uc218 \uc5c6\uace0, \ucd5c\uc2e0\ud654\uac00 \uc911\uc694\ud55c \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc0ac\uc6a9\uc790\uc758 \ubd88\ub9cc\uc744 \ucd08\ub798\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uad6c\ud604\ud574\ubcf4\uae30\\n\\nSchedule \uc815\ubcf4\ub97c \uc5b4\ub5bb\uac8c \ub2e4\ub978 \ud658\uacbd\uc5d0\uc11c \uac19\uc774 \uacf5\uc720\ud558\uc5ec \uad00\ub9ac\ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\uac04\ub2e8\ud788 \uc0dd\uac01\ud558\uba74 Local \ud658\uacbd\uc774 \uc544\ub2cc, Global \ud658\uacbd\uc5d0\uc11c \uc815\ubcf4\ub97c \uad00\ub9ac\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c Schedule\uc758 \uc815\ubcf4\ub97c \uc800\uc7a5\ud560 \uc218 \uc788\ub294 \ud14c\uc774\ube14\uc744 \uc544\ub798\uc758 Entity \uc758 \ud544\ub4dc\uc640 \uac19\uc774 \uc0dd\uc131\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Entity\\npublic class ScheduleTask extends BaseEntity {\\n\\n @Id\\n private String id;\\n\\n private String jobName;\\n\\n @Enumerated(EnumType.STRING)\\n private JobStatus status;\\n}\\n```\\n\\n\uba3c\uc800 id\ub294 \ud574\ub2f9 \uc2a4\ucf00\uc904\uc744 \uad6c\ubd84\ud560 \uc218 \uc788\ub294 id\uc5ec\uc57c \ud560 \uac83\uc785\ub2c8\ub2e4. \uac00\uc7a5 \uc27d\uac8c \uc815\ud560 \uc218 \uc788\ub294 id\ub294 \uc2a4\ucf00\uc904\uc758 **job \uc774\ub984**\uacfc,\\nSchedule\uc73c\ub85c \ub4f1\ub85d\ud55c **\uc2dc\uac04**\uc744 \uc870\ud569\ud558\uc5ec \uc0dd\uc131\ud55c\ub2e4\uba74 unique\ud558\uace0 \ubd84\uc0b0 \ud658\uacbd\uc5d0\uc11c\ub3c4 \uc27d\uac8c \uad6c\ubd84\ud560 \uc218 \uc788\ub294 id\uac00 \ub420 \uac83 \uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc544\ub798\uc640 \uac19\uc740 Business Logic \uc788\ub2e4\uace0 \uac00\uc815\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\n@Service\\npublic class BusinessLogic {\\n\\n private final ApplicationEventPublisher applicationEventPublisher;\\n\\n @Scheduled(cron = \\"0/2 * * * * *\\")\\n public void complexJob() {\\n log.info(\\"\ubcf5\uc7a1\ud55c Job \uc2dc\uc791\\");\\n }\\n\\n @Scheduled(cron = \\"0/4 * * * * *\\")\\n public void moreComplexJob() {\\n log.info(\\"\uc880 \ub354 \ubcf5\uc7a1\ud55c Job \uc2dc\uc791\\");\\n try {\\n Thread.sleep(3000);\\n } catch (InterruptedException e) {\\n throw new RuntimeException(e);\\n }\\n }\\n}\\n```\\n\ud558\ub098\ub294 \ub9e4 2\ucd08\ub9c8\ub2e4 \uc2e4\ud589 \ud6c4 \ubc14\ub85c \uc885\ub8cc\ub418\uace0, \ud558\ub098\ub294 \ub9e4 4\ucd08\ub9c8\ub2e4 \uc2e4\ud589 \ud6c4 3\ucd08\uc758 \ub300\uae30\uc640 \uc885\ub8cc\ub418\ub294 \uba54\uc11c\ub4dc\uc785\ub2c8\ub2e4.\\n\uc774\ub7f0 \uc2a4\ucf00\uc904\uc740 \uc5b4\ub5bb\uac8c \ub3d9\uc791\ud560\uae4c\uc694? \uc800\ub294 \ub2f9\uc5f0\ud788 2\ucd08\uc640 4\ucd08\ub9c8\ub2e4 \ud574\ub2f9 \uba54\uc11c\ub4dc\uac00 \uc2e4\ud589\ub420 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ub85c\uadf8\ub97c \uc0b4\ud3b4\ubcf4\uba74 \uc544\ub798\uc640 \uac19\uc740 \uacb0\uacfc\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\n![log](https://github.com/drunkenhw/comments/assets/106640954/5e275085-fce6-43ae-88ca-d3f9c484b6f3)\\n\ubcf5\uc7a1\ud55c job\uc774 2\ubc88 \uc2e4\ud589\ub420 \ub54c, \uc880 \ub354 \ubcf5\uc7a1\ud55c job\uc774 1\ubc88 \uc2e4\ud589\ub418\ub294 \uac78 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc608\uc0c1\ud588\ub358 \uacb0\uacfc\uc785\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc2e4\ud589\ub41c \uc2dc\uac04\uc744 \uc0b4\ud3b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![log-with-time](https://github.com/drunkenhw/comments/assets/106640954/abbe2c65-c26b-46ba-a4e3-fc0f4e5a6612)\\n\\n\ubd84\uba85 \ub9e4 2\ucd08\uc640 4\ucd08\ub9c8\ub2e4 \uc2e4\ud589\ud558\uae30 \ub54c\ubb38\uc5d0 \uc791\uc5c5 \uc2dc\uac04\uc774 2\uc758 \ubc30\uc218\uac00 \ub418\uc5b4\uc57c\ud560\ud150\ub370\\n\\n34, 36, 36, **39**, 40, 40, **43**, 44, 44, **47**\ucd08 \ub85c \uc810\uc810 \uc791\uc5c5\uc774 \ubc00\ub9ac\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc65c \uadf8\ub7f4\uae4c\uc694? \uc2a4\ud504\ub9c1 \uacf5\uc2dd \ubb38\uc11c\uc5d0\uc11c\ub294 \uc544\ub798\uc640 \uac19\uc774 \uc124\uba85\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n> A ThreadPoolTaskScheduler can also be auto-configured if need to be associated to scheduled task execution (using @EnableScheduling for instance). The thread pool uses one thread by default and its settings can be fine-tuned using the spring.task.scheduling namespace, as shown in the following example:\\n\\n\\n[\ucc38\uace0 - \uc2a4\ud504\ub9c1 \uacf5\uc2dd \ubb38\uc11c](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.task-execution-and-scheduling)\\n\\n\uc2a4\ud504\ub9c1\uc758 Schedule\uc740 Default\ub85c \ud558\ub098\uc758 \uc2f1\uae00 \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\uadf8\ub807\uae30 \ub54c\ubb38\uc5d0 \ub9e4\ubc88 \uc791\uc5c5\uc774 \ubc00\ub824 \uc6d0\ud558\ub294 \uc2dc\uac04\uc5d0 \ub3d9\uc791\ud558\uc9c0 \uc54a\ub294 \ud604\uc0c1\uc774 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc Schedule\uc744 \ubd84\uc0b0 \ud658\uacbd\uc5d0\uc11c \uad6c\ubd84\ud558\uae30 \uc704\ud574\uc11c\ub294 job\uc774 \uc2e4\ud589\ub41c \uc2dc\uac04\uc774 \uc911\uc694\ud558\uae30 \ub54c\ubb38\uc5d0 \uc774\ub807\uac8c \uc791\uc5c5\uc774 \ubc00\ub824\ubc84\ub9b0\ub2e4\uba74 \uad6c\ubd84\uc744 \ud560 \uc218 \uc5c6\uac8c \ub429\ub2c8\ub2e4.\\n\ub530\ub77c\uc11c Schedule Thread Pool Size\ub97c \ub298\ub9ac\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Configuration\\npublic class ScheduleConfig implements SchedulingConfigurer {\\n @Override\\n public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {\\n ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();\\n taskScheduler.setPoolSize(10);\\n taskScheduler.setThreadNamePrefix(\\"schedule-task-\\");\\n taskScheduler.initialize();\\n taskRegistrar.setTaskScheduler(taskScheduler);\\n }\\n}\\n```\\nSchedulingConfigurer \ub97c \uad6c\ud604\ud558\uc5ec Thread Pool size\ub97c \uc77c\ub2e8 10\uac1c\ub85c \uc815\uc758\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![success](https://github.com/drunkenhw/comments/assets/106640954/14b225bc-297e-4e7d-b196-23d779f635c0)\\n\uc2a4\ub808\ub4dc \ud480\uc744 \ub298\ub838\ub354\ub2c8 \uc704\uc640 \uac19\uc774 2\uc758 \ubc30\uc218\uc758 \uc2dc\uac04\uc5d0 \uc815\ud655\ud788 \uc791\ub3d9\uc774 \ub418\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ub807\uac8c \uc5ec\ub7ec \uc791\uc5c5\uc744 \ub3d9\uc2dc\uc5d0 \uc2e4\ud589\ub41c\ub2e4\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \ubcd1\ubaa9\ud604\uc0c1\uc774 \ubc1c\uc0dd\ub418\uc5b4 \uc624\ud788\ub824 \uc791\uc5c5\uc774 \ub354 \ub290\ub9ac\uac8c \ub05d\ub0a0 \uc218\ub3c4 \uc788\ub2e4\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud574\ub2f9 \ubd80\ubd84\uc758 \uc2e4\ud589\uc744 \uad00\ub9ac\ud558\ub294 \ud074\ub798\uc2a4\ub97c \uc0dd\uc131\ud558\uc5ec \ud574\ub2f9 \ud074\ub798\uc2a4\uc5d0\uc11c Schedule\uc758 \uc791\uc5c5\uc744 \uad00\ub9ac\ud558\ub3c4\ub85d \uad6c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Service\\npublic class BusinessLogic {\\n\\n private final ApplicationEventPublisher applicationEventPublisher;\\n\\n @Scheduled(cron = \\"0/2 * * * * *\\")\\n public void complexJobSchedule() {\\n applicationEventPublisher.publishEvent(new SchedulingEvent(this::complexJob, \\"complexJob\\", LocalDateTime.now()));\\n }\\n\\n @Scheduled(cron = \\"0/4 * * * * *\\")\\n public void moreComplexJobSchedule() {\\n applicationEventPublisher.publishEvent(new SchedulingEvent(this::moreComplexJob, \\"moreComplexJob\\", LocalDateTime.now()));\\n }\\n}\\n```\\n\ub85c\uc9c1\uc774 \uc788\ub294 BusinessLogic \uc11c\ube44\uc2a4\uc5d0\uc11c \uc2a4\ucf00\uc904\uc758 \uc2dc\uac04\ub9c8\ub2e4 \uc2e4\ud589\ud574\uc57c\ud560 \uba54\uc11c\ub4dc\ub97c Event\ub85c \ubc1c\ud589\ud569\ub2c8\ub2e4.\\n\\n```java\\n@Component\\npublic class ScheduleService {\\n\\n private final ExecutorService executorService = Executors.newFixedThreadPool(1);\\n private final Queue scheduleTasks = new ConcurrentLinkedQueue<>();\\n private final AtomicBoolean isRunning = new AtomicBoolean(false);\\n\\n @EventListener\\n public void addTask(SchedulingEvent schedulingEvent) {\\n scheduleTasks.add(schedulingEvent);\\n }\\n\\n @Scheduled(cron = \\"0/1 * * * * *\\")\\n public void polling() {\\n if (!scheduleTasks.isEmpty() || isRunning.compareAndSet(false, true)) {\\n SchedulingEvent schedulingEvent = scheduleTasks.poll();\\n executorService.execute(() -> execute(schedulingEvent));\\n }\\n }\\n}\\n```\\n\uadf8\ub9ac\uace0 \uc704\uc640 \uac19\uc740 \uc2a4\ucf00\uc904\uc744 \uad00\ub9ac\ud558\ub294 \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 Schedule Event\ub97c \ubc1b\uc544 \uc2e4\ud589\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ud074\ub798\uc2a4\uc5d0\uc11c\ub294 ThreadPool\uc744 \uc0c8\ub85c \uc0dd\uc131\ud558\uc5ec, schedule\uc758 \uc2a4\ub808\ub4dc\uc5d0 \uc601\ud5a5\uc744 \ubc1b\uc9c0 \uc54a\ub3c4\ub85d \uad6c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 1\ucd08\ub9c8\ub2e4 \uc2e4\ud589\ub418\ub294 \uc2a4\ucf00\uc904\uc744 \ub9cc\ub4e4\uc5b4 queue\uc5d0 \uc791\uc5c5\uc774 \uc788\ub294\uc9c0, \ud604\uc7ac \uc791\uc5c5 \uc911\uc778\uc9c0 \ud655\uc778\ud558\uc5ec \uadf8\ub807\uc9c0 \uc54a\ub2e4\uba74 queue\uc5d0\uc11c \uc791\uc5c5\uc744 \uaebc\ub0b4 \uc2e4\ud589\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uac70\uc758 \uad6c\ud604\uc774 \ub05d\ub098\uac11\ub2c8\ub2e4. \uc774\uc81c\ub294 \ud574\ub2f9 Schedule\uc758 \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud558\uace0, \uc791\uc5c5\uc774 \uc2e4\ud328\ud588\uc744 \uc2dc\uc5d0 \ub2e4\uc2dc \uc791\uc5c5\uc744 \ud558\uae30 \uc704\ud55c \uae30\ub2a5\ub9cc \uad6c\ud604\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Component\\npublic class ScheduleService {\\n\\n ...\\n\\n private void execute(SchedulingEvent schedulingEvent) {\\n String jobId = schedulingEvent.jobId();\\n LocalDateTime executionTime = schedulingEvent.executionTime();\\n\\n if (isJobInProgressOrDone(jobId)) {\\n log.info(\\"\uc791\uc5c5\uc774 \uc2e4\ud589\uc911\uc785\ub2c8\ub2e4. {} {}\\", executionTime, jobId);\\n return;\\n }\\n ScheduleTask entity = new ScheduleTask(jobId, executionTime, JobStatus.RUNNING);\\n scheduleTaskJdbcRepository.save(entity);\\n\\n try {\\n schedulingEvent.runnable().run();\\n scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.DONE);\\n } catch (Exception e) {\\n log.error(\\"{} \uc791\uc5c5 \uc2e4\ud589 \uc911 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\", jobId);\\n scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.ERROR);\\n tasks.add(schedulingEvent);\\n }\\n }\\n\\n private boolean isJobInProgressOrDone(String jobId) {\\n Optional taskOptional = scheduleTaskRepository.findById(jobId);\\n if (taskOptional.isPresent()) {\\n ScheduleTask scheduleTask = taskOptional.get();\\n return scheduleTask.getStatus() == JobStatus.RUNNING || scheduleTask.getStatus() == JobStatus.DONE;\\n }\\n return false;\\n }\\n}\\n```\\n\uc774 \ubd80\ubd84\uc740 \uac04\ub2e8\ud558\uac8c \uad6c\ud604\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc704\uc640 \uac19\uc774 \uc791\uc5c5\uc758 \uc2e4\ud589 \uc2dc\uac04\uacfc, job\uc758 \uc774\ub984\uc73c\ub85c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uc870\ud68c\ud558\uace0, \uc5c6\ub2e4\uba74 \uc791\uc5c5\uc744 \uc2e4\ud589\ud558\uace0\\n\uc788\ub2e4\uba74 \uc791\uc5c5\uc774 ERROR \uc778\uc9c0 \ud655\uc778\ud558\uc5ec \uc791\uc5c5\uc744 \uc2e4\ud589\ud574\uc8fc\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![complete](https://github.com/drunkenhw/comments/assets/106640954/3ff855db-ff8e-4aa4-8b47-ed5b2ff6dd64)\\n\\n\uc704\uc640 \uac19\uc774 \ub450 \uac1c\uc758 \uc11c\ubc84\ub97c \ub3d9\uc2dc\uc5d0 \ub744\uc6e0\uc744 \ub54c\uc5d0\ub3c4 \uc2a4\ucf00\uc904\uc774 \uc798 \uc791\ub3d9\ud558\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\uc2a4\ucf00\uc904\uc744 \uc774\ub807\uac8c \uad6c\ud604\ud560 \uc218\ub3c4 \uc788\uc9c0\ub9cc \ud658\uacbd\uc774 \ub41c\ub2e4\uba74 Message Queue\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc5b4\ub5a8\uae4c\uc694?\\n\\n\\n\ud639\uc2dc \ud2c0\ub9b0 \ubd80\ubd84\uc774 \uc788\ub2e4\uba74 \uc9c0\uc801 \ubd80\ud0c1\ub4dc\ub9bd\ub2c8\ub2e4."},{"id":"34","metadata":{"permalink":"/34","source":"@site/blog/2023-09-17-caching.mdx","title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4","date":"2023-09-17T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 17\uc77c","tags":[{"label":"java","permalink":"/tags/java"}],"readingTime":12.495,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"34","title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","authors":["boxster"],"tags":["java"]},"prevItem":{"title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","permalink":"/35"},"nextItem":{"title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","permalink":"/33"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4\\n\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\uc774\uc804 \uae00\uc5d0\uc11c\ub3c4 \uacc4\uc18d \uc124\uba85\ud588\ub4ef\uc774 \uc870\ud68c \uc131\ub2a5\uc744 \ucd5c\ub300\ud55c \ube60\ub974\uac8c \ud558\ub294 \uac83\uc774 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \ud575\uc2ec\uc774\ub77c\uace0 \uc0dd\uac01\ud558\uae30 \ub54c\ubb38\uc5d0 \uc9c0\uae08\ub3c4 \uc608\uc804\uc5d0 \ube44\ud574 \ube68\ub77c\uc84c\uc9c0\ub9cc \ub2e4\ub978 \uac1c\uc120\uc810\uc774 \ubcf4\uc5ec \uac1c\uc120\uc744 \ud558\uace0\uc790\ud569\ub2c8\ub2e4.\\n\\n[\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30 1 (\uc778\ub371\uc2a4)](https://car-ffeine.github.io/31)\\n\\n[\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30 2 (\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ubcf5\uc81c)](https://car-ffeine.github.io/32)\\n## \uacb0\ub860\\n\uacb0\ub860\ubd80\ud130 \ub9d0\uc500\ub4dc\ub9ac\uba74 \ub85c\uceec\uc5d0\uc11c \uce90\uc2f1\uc744 \uc801\uc6a9\ud55c \ud6c4 100\uba85\uc758 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c\ub97c \uae30\uc900\uc73c\ub85c\\n\\n**TPS** 78 -> 128\\n\\n**Response Time** 1236 ms -> 751 ms\\n\\n\uc57d **64%** \uc131\ub2a5\uc774 \uac1c\uc120 \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n*(\uc800\ubc88 \uc131\ub2a5 \ud14c\uc2a4\ud2b8\uc758 \uacb0\uacfc\uac00 \ub2e4\ub978 \uc774\uc720\ub294 \ube44\uc988\ub2c8\uc2a4 \ub85c\uc9c1\uc774 \ubcc0\uacbd\ub418\uc5b4 \uc870\ud68c \ubc29\uc2dd\uc774 \ubc14\ub00c\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4. \uadf8\ub798\uc11c \uce90\uc2f1\uc744 \uc801\uc6a9\ud558\uae30\uc804, \ud55c \ud6c4 \ub97c \ube44\uad50\ud588\uc2b5\ub2c8\ub2e4.)*\\n\\n## Caching\\n\\n>In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.\\n\\n\uce90\uc2f1\uc740 \uc704\ud0a4 \ubc31\uacfc\uc5d0\uc11c \uc704\uc640 \uac19\uc774 \uc124\uba85\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc989 \uba54\ubaa8\ub9ac\uc5d0 \ub370\uc774\ud130\ub97c \ubcf5\uc0ac\ubcf8\uc744 \uc62c\ub824 \uc880 \ub354 \ube60\ub974\uac8c \ub370\uc774\ud130\uc5d0 \uc811\uadfc\ud558\ub294 \ubc29\uc2dd\uc785\ub2c8\ub2e4.\\n\\n\uce90\uc2f1\uc758 \ub2e8\uc810\uc740 \uc218\uc815, \uc0bd\uc785, \uc0ad\uc81c\uac00 \ub418\uc5c8\uc744 \ub54c, \uad00\ub9ac \ud3ec\uc778\ud2b8\uac00 \ub450 \uad70\ub370\uac00 \ub41c\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4. \ub9cc\uc57d \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\ub9cc \uc0c8\ub85c\uc6b4 \uc815\ubcf4\ub97c \uc800\uc7a5\ud558\uace0, \uce90\uc2dc\uc5d0\ub294 \uc800\uc7a5\ud574\uc8fc\uc9c0 \uc54a\ub294\ub2e4\uba74 \uc0ac\uc6a9\uc790\ub294 \uadf8 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \uc801\uc6a9\ud55c \uc774\uc720\ub294 \ucda9\uc804\uae30\uc758 \ucda9\uc804 \uc0c1\ud0dc (\ucda9\uc804 \uc911, \ub300\uae30\uc911, \uace0\uc7a5)\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ucd5c\uc2e0\ud654\uac00 \ub418\uc5b4\uc57c\ud558\uc9c0\ub9cc, \ucda9\uc804\uc18c\uc758 \uc774\ub984\uc774\ub77c\ub358\uc9c0, \uc704\uce58, \ub2e4\ub978 \uc815\ubcf4\ub4e4\uc740 \uc27d\uac8c \ubcc0\ud558\uc9c0 \uc54a\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \uc815\ubcf4\ub97c \uce90\uc2f1\ud55c\ub2e4\uba74 \uc88b\uc744 \uac83 \uac19\uc558\uc2b5\ub2c8\ub2e4.\\n\\n## \uce90\uc2f1 \uc801\uc6a9\ud558\uae30\\n\\n\uba3c\uc800 \uce90\uc2f1\uc744 \uc5b4\ub514\uc5d0\uc11c \ud558\ub294\uc9c0\ub3c4 \uc911\uc694\ud569\ub2c8\ub2e4. \ud06c\uac8c **\ub85c\uceec \uce90\uc2dc**\uc640 **\uae00\ub85c\ubc8c \uce90\uc2dc**\ub85c \ub098\ub20c \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\uae00\ub85c\ubc8c \uce90\uc2dc\uc758 \uc7a5\uc810\uc740 \uc2a4\ucf00\uc77c \uc544\uc6c3\uc744 \ud588\uc744 \ub54c, \ubaa8\ub4e0 \uc11c\ubc84\uac00 \ub2e4 \uac19\uc740 \ub370\uc774\ud130\ub97c \ubc14\ub77c\ubcf4\uae30 \ub54c\ubb38\uc5d0 \ub370\uc774\ud130 \uc815\ud569\uc131\uc774 \uc88b\uc544\uc9d1\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \ub2e8\uc77c \uc11c\ubc84\ub85c \uad6c\uc131\ub418\uc5b4 \uc788\uae30 \ub54c\ubb38\uc5d0, \ub85c\uceec \uce90\uc2dc\ub97c \ud574\ub3c4 \ubb38\uc81c\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uae00\ub85c\ubc8c \uce90\uc2dc\ub97c \uc801\uc6a9\ud558\uae30 \uc704\ud574\uc11c\ub294 Redis\ub098 Memcached \uac19\uc740 \ub3c4\uad6c\ub97c \ubaa8\ub4e0 \ud300\uc6d0\uc774 \uc54c\uc544\uc57c\ud558\uc9c0\ub9cc \ub85c\uceec \uce90\uc2dc\ub294 \uadf8\ub807\uac8c \ud558\uc9c0 \uc54a\ub354\ub77c\ub3c4 \ud3b8\ud558\uac8c \uc801\uc6a9\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc5d0\uc11c \ub85c\uceec\uc5d0 \uce90\uc2f1\ud558\ub294 \ubc29\ubc95\uc744 \uc801\uc6a9\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### \uce90\uc2f1\ud560 \uc815\ubcf4 \uac00\uc838\uc624\uae30\\n\\n\uce90\uc2f1\uc744 \ud558\uae30 \uc704\ud574\uc11c\ub294 \uba3c\uc800 \uce90\uc2f1\ud560 \ub370\uc774\ud130\ub97c \uac00\uc838\uc640\uc57c\ud569\ub2c8\ub2e4. \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \ucd9c\uc7a5 \ud639\uc740 \uc5ec\ud589\uc744 \uac00\ub294 \uc804\uae30\ucc28 \uc624\ub108\uac00 \ud575\uc2ec \ud398\ub974\uc18c\ub098\uc774\uae30 \ub54c\ubb38\uc5d0 \uc0ac\uc6a9\uc790\ub4e4\uc774 \ucc3e\ub294 \uc815\ubcf4\uc758 \uc704\uce58\ub294 \ubd88\ud2b9\uc815\ud569\ub2c8\ub2e4. \uc11c\uc6b8\uc5d0\uc11c \ub2e4\ub978 \uc9c0\ubc29\uc73c\ub85c \ucd9c\uc7a5\uc744 \uac00\ub294 \uacbd\uc6b0\ub3c4 \uc788\uc744 \uac83\uc774\uace0, \uc9c0\ubc29\uc5d0\uc11c \uc11c\uc6b8\uc5d0 \uac00\ub294 \uacbd\uc6b0\ub3c4 \uc788\uae30 \ub54c\ubb38\uc5d0, \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c \uce90\uc2f1\ud574\uc57c\ud560 \uac83\uc774\ub77c \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \uc2e4\ud589 \uc2dc\uc5d0 \ubaa8\ub4e0 \ucda9\uc804\uc18c\ub97c \uce90\uc2f1\ud558\uae30\ub85c \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Configuration\\npublic class InitialStationCache implements ApplicationRunner {\\n\\n private final StationCacheRepository stationCacheRepository;\\n private final StationQueryRepository stationQueryRepository;\\n\\n @Override\\n public void run(ApplicationArguments args) {\\n log.info(\\"Initialize station cache\\");\\n List stations = stationQueryRepository.findAll();\\n stationCacheRepository.initialize(stations);\\n log.info(\\"Station cache initialized\\");\\n log.info(\\"Station cache size: {}\\", stations.size());\\n }\\n}\\n\\n```\\n\\n\uc704\uc640 \uac19\uc774 ApplicationRunner\ub97c \uad6c\ud604\ud558\uc5ec \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \uc2e4\ud589 \uc2dc \ubaa8\ub4e0 \ucda9\uc804\uc18c\uc758 \uc815\ubcf4\ub97c \uac00\uc838\uc624\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc5ec\uae30\uc11c Entity\uc778 Station\uc744 \uac00\uc838\uc624\uc9c0 \uc54a\uc740 \uc774\uc720\ub294 \ud06c\uac8c \ub450\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n1. \uc9c0\ub3c4\ub85c \uc870\ud68c\ud558\ub294 \ubd80\ubd84\uc758 \uc131\ub2a5\uc744 \uac1c\uc120\ud558\uace0\uc790 \ud588\uc9c0\ub9cc, Entity\uc5d0\ub294 \uc9c0\ub3c4\ub97c \uc870\ud68c\ud560 \ub54c \ubd88\ud544\uc694\ud55c \uc815\ubcf4\ub3c4 \uc788\uae30 \ub54c\ubb38\uc5d0 \uba54\ubaa8\ub9ac\uc0c1\uc758 \ub0ad\ube44\uac00 \uc0dd\uae38 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n2. Entity\ub97c \uce90\uc2f1\ud558\uac8c \ub41c\ub2e4\uba74 hibernate 1\ucc28 \uce90\uc2dc\uc5d0\ub3c4 \uc801\uc7ac\ub418\uace0, \ud799 \uba54\ubaa8\ub9ac\uc5d0\ub3c4 \uc801\uc7ac\ub418\ub294 \uc77c\uc774 \ubc1c\uc0dd\ud558\uc5ec \uba54\ubaa8\ub9ac\uc0c1 \ub0ad\ube44\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \ubc94\uc704 \uac80\uc0c9\ud558\uae30\\n\\n\ucda9\uc804\uc18c\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud558\ub294 \uc870\uac74\uc740 \uc704\ub3c4, \uacbd\ub3c4\uc758 \ucd5c\uc18c, \ucd5c\ub300\uac12\uc744 \uae30\uc900\uc73c\ub85c \ub9cc\uc871\ud558\ub294 \ub370\uc774\ud130\ub97c \ubcf4\uc5ec\uc90d\ub2c8\ub2e4.\\n\uc544\ub798\uc640 \uac19\uc774 \uac04\ub2e8\ud788 \uc870\uac74\uc744 stream()\uc758 filter()\ub97c \uc0ac\uc6a9\ud574\uc11c \uad6c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n```java\\npublic class StationCacheRepository {\\n\\n private final List cachedStations;\\n\\n public List findByCoordinate(\\n BigDecimal minLatitude,\\n BigDecimal maxLatitude,\\n BigDecimal minLongitude,\\n BigDecimal maxLongitude\\n ) {\\n return cachedStations.stream()\\n .filter(it -> it.latitude().compareTo(minLatitude) >= 0 && it.latitude().compareTo(maxLatitude) <= 0)\\n .filter(it -> it.longitude().compareTo(minLongitude) >= 0 && it.longitude().compareTo(maxLongitude) <= 0)\\n .toList();\\n }\\n}\\n```\\n\ud558\uc9c0\ub9cc \ud574\ub2f9 \ubc29\ubc95\uc73c\ub85c \ub85c\uceec\uc5d0\uc11c \uc870\ud68c\ub97c \ud14c\uc2a4\ud2b8 \ud588\uc744 \ub54c \uce90\uc2dc\ub97c \uc801\uc6a9\ud55c \uac83\ubcf4\ub2e4 \ub354 \ub290\ub824\uc9c4 \uacb0\uacfc\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\uce90\uc2f1\uc744 \ud574\uc11c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uae4c\uc9c0 \uc694\uccad\uc744 \ubcf4\ub0b4\uc9c0 \uc54a\ub294\ub370 \uc65c \ub354 \ub290\ub824\uc9c4 \uac83\uc77c\uae4c\uc694?\\n\\n\ub2f5\uc740 **\uc778\ub371\uc2a4** \uc600\uc2b5\ub2c8\ub2e4. Mysql \uc5d0\uc11c \uc778\ub371\uc2a4\ub294 B Tree\ub85c \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c\ub294 \uc704\ub3c4, \uacbd\ub3c4\ub85c \ubcf5\ud569 \uc778\ub371\uc2a4\uac00 \uc124\uc815\ub418\uc5b4 \uc788\uc5c8\uc9c0\ub9cc, \ud604\uc7ac \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \ub85c\uc9c1\uc5d0\ub294 \ud574\ub2f9 \ubd80\ubd84\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c filter\ub85c \uc21c\ud68c\ud558\ub294 \uc2dc\uac04\ubcf5\uc7a1\ub3c4\uac00 O(n)\uc774\uace0, \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c\ub294 O(log n)\uc774\uae30 \ub54c\ubb38\uc5d0 \ub354 \ub290\ub824\uc9c4 \uac83\uc785\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uace0 \uc81c\uac00 \uc9c1\uc811 B tree \uc790\ub8cc\uad6c\uc870\ub97c \uc9c1\uc811 \uad6c\ud604\ud574\uc57c\ud560\uae4c\uc694?\\n\\n\ud604\uc7ac \ud574\ub2f9 \uc870\ud68c API\ub294 \uc704\ub3c4 \uacbd\ub3c4\ub85c \ubc94\uc704 \ud0d0\uc0c9\uc744 \ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uacb0\uad6d\uc5d4 station\uc758 \uc815\ubcf4\ub4e4\uc774 \uc704\ub3c4, \uacbd\ub3c4\ub85c \uc815\ub82c\ub9cc \ub418\uc5b4 \uc788\ub2e4\uba74 B tree\ub97c \uc9c1\uc811 \uad6c\ud604\ud558\uc9c0 \uc54a\ub354\ub77c\ub3c4 \uac19\uc740 \uc2dc\uac04\ubcf5\uc7a1\ub3c4 O(log n)\uc73c\ub85c \ud0d0\uc0c9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 B tree\uc640 \ub2e4\ub978 \ubd80\ubd84\uc740 \ud574\ub2f9 \ucda9\uc804\uc18c\uc758 \uc815\ud655\ud55c \uc704\ub3c4, \uacbd\ub3c4\ub85c \ub2e8\uc77c \uce7c\ub7fc\uc744 \uc870\ud68c\ud560 \ub54c\ub294 O(n)\uc774\uae30 \ub54c\ubb38\uc5d0 \uc774\ub7f0 \ubc29\ubc95\uc774 \ubb38\uc81c\uac00 \ub420 \uc218 \uc788\uc9c0\ub9cc, \ud574\ub2f9 \uce90\uc2dc \ub370\uc774\ud130\ub85c\ub294 \ubb34\uc870\uac74 \ubc94\uc704 \ud0d0\uc0c9\uc744 \ud558\uae30 \ub54c\ubb38\uc5d0, B tree\ub97c \uad6c\ud604\ud558\uc9c0 \uc54a\uace0 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \ubcc0\uacbd\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n public void initialize(List stations) {\\n cachedStations.addAll(stations);\\n cachedStations.sort((o1, o2) -> {\\n int latitudeCompare = o1.latitude().compareTo(o2.latitude());\\n if (latitudeCompare == 0) {\\n return o1.longitude().compareTo(o2.longitude());\\n }\\n return latitudeCompare;\\n });\\n }\\n\\n private List findStations(BigDecimal minLatitude, BigDecimal maxLatitude, BigDecimal minLongitude, BigDecimal maxLongitude) {\\n int lowerBound = binarySearch(minLatitude, START_INDEX);\\n int upperBound = binarySearch(maxLatitude, lowerBound);\\n if (lowerBound == -1 || upperBound == -1) {\\n return Collections.emptyList();\\n }\\n return cachedStations.stream()\\n .skip(lowerBound)\\n .limit(upperBound - lowerBound)\\n .filter(station -> station.longitude().compareTo(minLongitude) >= 0 && station.longitude().compareTo(maxLongitude) <= 0)\\n .toList();\\n }\\n\\n private int binarySearch(BigDecimal latitude, int startIndex) {\\n int left = startIndex;\\n int right = cachedStations.size() - 1;\\n int result = -1;\\n while (left <= right) {\\n int middle = left + (right - left) / 2;\\n StationInfo middleStation = cachedStations.get(middle);\\n if (middleStation.latitude().compareTo(latitude) >= 0) {\\n result = middle;\\n right = middle - 1;\\n } else {\\n left = middle + 1;\\n }\\n }\\n return result;\\n }\\n\\n```\\n\\n\uba3c\uc800 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc774 \uc2e4\ud589\ub420 \ub54c cache \ub370\uc774\ud130\ub97c \ucc3e\uc544 \uc800\uc7a5\ud558\ub294 \uac83 \ubfd0\ub9cc \uc544\ub2c8\ub77c, \uc704\ub3c4(Latitude)\ub97c \uae30\uc900\uc73c\ub85c \uc815\ub82c\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc704\ub3c4\uc758 \ucd5c\uc18c, \ucd5c\ub300\uac12\uc758 \uc778\ub371\uc2a4\ub97c \uac00\uc7a5 \ud6a8\uc728\uc801\uc73c\ub85c \ucc3e\uc544\uc62c \uc218 \uc788\ub3c4\ub85d binary search\ub97c \ud558\ub294 \uba54\uc11c\ub4dc\ub97c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub807\uac8c \ud55c\ub2e4\uba74 O(log n) \uc73c\ub85c \uc704\ub3c4\uc758 \ucd5c\ub300 \ucd5c\uc18c \uc870\uac74\uc5d0 \ud3ec\ud568\ub418\ub294 \ubaa8\ub4e0 station\uc758 \uac12\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc870\ud68c\ud55c \ub370\uc774\ud130\ub4e4\uc758 \uac1c\uc218\ub9cc\ud07c filter\ub97c \ud1b5\ud574 \uacbd\ub3c4(longitude) \uac00 \ud3ec\ud568\ub418\ub294\uc9c0 \ud655\uc778\ud569\ub2c8\ub2e4. \ud574\ub2f9 \ubc29\uc2dd\uc758 \uad6c\ud604\uc740 B tree\uac00 \uc791\ub3d9\ud558\ub294 \ubc29\uc2dd\uacfc \uc720\uc0ac\ud560 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc774\ubd84 \ud0d0\uc0c9\uc744 \uc801\uc6a9\ud55c \uacb0\uacfc \ub85c\uceec\uc5d0\uc11c \uc751\ub2f5 \uc18d\ub3c4\uac00 120 ms -> 50 ~ 70 ms\ub85c \uc57d 2\ubc30 \ube68\ub77c\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc2e4\uc2dc\uac04\uc774 \uc911\uc694\ud55c \ub370\uc774\ud130\ub294?\\n\\n\uc55e\uc11c \ub9d0\uc500\ub4dc\ub838\ub2e4\uc2dc\ud53c \uc9c0\ub3c4\ub85c \ucda9\uc804\uc18c\ub97c \uc870\ud68c\ud560 \ub54c, \ucda9\uc804\uc18c\uc758 \uc815\ubcf4\ub4e4\uc5d0\ub294 \ubc14\ub00c\uc9c0 \uc54a\ub294 \uc815\ubcf4\ubfd0\ub9cc \uc544\ub2c8\ub77c, \ucd5c\uc2e0\ud654\ud574\uc57c\ud558\ub294 \ucda9\uc804\uae30\uc758 \ud604\uc7ac \uc0c1\ud0dc \uc815\ubcf4\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub7ec\ud55c \uc815\ubcf4\ub4e4\uc740 \uce90\uc2f1\ud574\ub458 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ud558\ub354\ub77c\ub3c4, \uad00\ub9ac \ud3ec\uc778\ud2b8\uac00 \ub298\uc5b4\ub098\uae30 \ub54c\ubb38\uc5d0 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uce90\uc2f1\ud574\ub454 \ucda9\uc804\uae30 id\ub85c \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub97c \ucc3e\uc544\uc640\uc11c \uc815\ubcf4\ub97c \ud569\uccd0 \ubc18\ud658\ud558\ub294 \uc2dd\uc73c\ub85c \ub9cc\ub4e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n```sql\\n select cs.station_id,\\n sum(case\\n when cs.charger_condition = \'STANDBY\' then 1\\n else 0\\n end)\\n from charger_status cs\\n where cs.station_id in (?, ?, ?, ?, ?, ?, ?)\\n group by cs.station_id\\n```\\n\uc704\uc640 \uac19\uc740 \ucffc\ub9ac\ub85c \ud574\ub2f9 \ucda9\uc804\uc18c\uc758 \ucd5c\uc2e0\ud654\ub41c \ucda9\uc804\uae30 \uc0c1\ud0dc\ub97c \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uce90\uc2f1\uc744 \ud558\uae30\uc804\uc5d0 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \uc774\uc6a9\ud574 \ub370\uc774\ud130\ub97c \uac00\uc838\uc62c \ub54c\uc758 \ucffc\ub9ac\ub294 \uc544\ub798\uc640 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```sql\\n select\\n distinct s.station_id\\n from\\n charge_station s\\n inner join\\n charger c\\n on (\\n c.station_id=s.station_id\\n )\\n where\\n s.latitude>=?\\n and s.latitude<=?\\n and s.longitude>=?\\n and s.longitude<=?\\n -------------------------------------------------\\n select\\n s.station_id,\\n s.station_name,\\n s.latitude,\\n s.longitude,\\n s.is_parking_free,\\n s.is_private,\\n sum(case\\n when cs.charger_condition=\'STANDBY\' then 1\\n else 0\\n end),\\n sum(case\\n when c.capacity>=50 then 1\\n else 0\\n end)\\n from\\n charge_station s\\n inner join\\n charger c\\n on (\\n c.station_id=s.station_id\\n )\\n inner join\\n charger_status cs\\n on (\\n c.station_id=cs.station_id\\n and c.charger_id=cs.charger_id\\n )\\n where\\n s.station_id in (\\n ?,?,?,?\\n )\\n group by\\n s.station_id\\n```\\n\uc6d0\ub798\ub294 \uc704\uc640 \uac19\uc774 \uc5ec\ub7ec\ubc88\uc758 Join\uc744 \ud558\uace0, 2\ubc88\uc758 \ucffc\ub9ac\uac00 \ub098\uac14\ub358 \ubc18\uba74 \uc9c0\uae08\uc740 join\uc744 \ud558\uc9c0\uc54a\ub294 \ud55c\ubc88\uc758 \uae54\ub054\ud55c \ucffc\ub9ac\ub85c \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 **station \ud14c\uc774\ube14\uc758 \uc704\ub3c4, \uacbd\ub3c4\ub85c \ubc94\uc704 \ud0d0\uc0c9\uc744 \uc704\ud574 \uc0dd\uc131\ud588\ub358 index\ub3c4 \uc81c\uac70**\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4!\\n\\n## \uacb0\ub860\\n1. \uce90\uc2f1\ud560 \uc218 \uc788\ub294 \ubd80\ubd84\uc740 \ud558\ub294 \uac83\ub3c4 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4\\n2. \uc2dc\uac04 \ubcf5\uc7a1\ub3c4\ub97c \uacc4\uc0b0\ud574\ubd05\uc2dc\ub2e4.\\n3. \uc131\ub2a5 \uac1c\uc120 \uc7ac\ubc0c\uc2b5\ub2c8\ub2e4."},{"id":"33","metadata":{"permalink":"/33","source":"@site/blog/2023-09-11-congestion_speed_up.mdx","title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","description":"\uc548\ub155\ud558\uc138\uc694.","date":"2023-09-11T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 11\uc77c","tags":[{"label":"mysql","permalink":"/tags/mysql"}],"readingTime":6.19,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"33","title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","authors":["jay"],"tags":["mysql"]},"prevItem":{"title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/34"},"nextItem":{"title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/32"}},"content":"\uc548\ub155\ud558\uc138\uc694.\\n\uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \ucda9\uc804\uc18c\uc758 \uc694\uc77c\uacfc \uc2dc\uac04\ub300 \ubcc4\ub85c \ucda9\uc804\uc18c \ud63c\uc7a1\ub3c4 \uc815\ubcf4\ub97c \uc81c\uacf5\uc744 \ucc28\ubcc4\uc801\uc778 \uae30\ub2a5\uc73c\ub85c \uc81c\uacf5\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \uad6c\ud604\ud558\uae30 \uc704\ud574\uc11c \uacf5\uacf5 \ub370\uc774\ud130\uc5d0\uc11c \uc815\ubcf4\ub97c \uc218\uc9d1\ud558\uace0\uc788\uc2b5\ub2c8\ub2e4.\\n\ud63c\uc7a1\ub3c4\ub97c \uc870\ud68c\ud558\uae30 \uc704\ud574\uc11c\ub294 \uc57d 23\ub9cc \uac74\uc758 \ucda9\uc804\uc18c * 7\uc77c * 24\uc2dc\uac04 = \uc57d 4000\ub9cc \uac74\uc758 \ub370\uc774\ud130 \uc911\uc5d0\uc11c \uc870\ud68c\ub97c \ud558\ub294 \ud615\uc2dd\uc73c\ub85c \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub108\ubb34 \ub9ce\uc740 \ub370\uc774\ud130\uac00 \uc788\ub2e4\ubcf4\ub2c8 \uc870\ud68c \uc18d\ub3c4\uac00 \ub9ce\uc774 \ub290\ub9b0\ub370\uc694.\\n\uc624\ub298\uc740 \uc774\ub97c \uc5b4\ub5bb\uac8c \uac1c\uc120\ud588\ub294\uc9c0 \uc791\uc131\ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\ucc38\uace0\ub85c \ud574\ub2f9 \uae00\uc758 \uc131\ub2a5 \uce21\uc815\uc5d0 \uc774\uc6a9\ud55c \ub370\uc774\ud130\uc758 \uc218\ub294 \uc57d 20\ub9cc \uac74\uc785\ub2c8\ub2e4.\\n\\n---\\n\\n## \ubb38\uc81c \ud655\uc778\\n\uae30\uc874\uc758 \uc800\ud76c\ub294 \ub9ce\uc740 \uc591\uc758 \ub370\uc774\ud130\ub97c \uac10\ub2f9\ud558\uae30 \ud798\ub4e4\uc5b4\uc11c [\uc624\uc804, \uc624\ud6c4] \uc774\ub807\uac8c \ub450 \ubd80\ubd84\uc73c\ub85c \ub098\ub220\uc11c \ud63c\uc7a1\ub3c4\ub97c \uc870\ud68c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc2e4\uc81c \ubc30\ud3ec\ub97c \ud558\uae30 \uc704\ud574\uc11c \ub354\uc774\uc0c1\uc740 \uc624\uc804 \uc624\ud6c4\ub85c \ub098\ub20c \uc218\uac00 \uc5c6\uc5c8\ub294\ub370\uc694.\\n\\n\uc815\uc0c1\uc801\uc778 \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud558\uae30 \uc704\ud574\uc11c \uba3c\uc800 24\uc2dc\uac04 \uae30\uc900\uc73c\ub85c \ud63c\uc7a1\ub3c4\ub97c \uac31\uc2e0\ud558\ub3c4\ub85d \ub85c\uc9c1\ubd80\ud130 \ubc14\uafb8\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc704\uc640 \uac19\uc774 \ucf54\ub4dc\ub97c \ubc14\uafb8\ub2c8 \ubc14\ub85c \uc131\ub2a5\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uacbc\uc2b5\ub2c8\ub2e4.\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMTA4/MDAxNjk0NDIwNjEzOTU3.Q1_sK5nRvBVbJ9w4bdYkofc0zX00TQmJUQPIqRQiofwg.FRujZOroDjWC4znh0pueWi84EAh9-LVKk17z2ojLi1Ig.PNG.sosow0212/image.png?type=w773)\\n\\n\uc704\uc758 \uc0ac\uc9c4\uacfc \uac19\uc774 slow-query\ub97c \ubd84\uc11d\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4.\\n\ud63c\uc7a1\ub3c4 \uc5c5\ub370\uc774\ud2b8\uc5d0\ub3c4 \uc2dc\uac04\uc774 \uac78\ub9ac\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc9c0\ub9cc, \uc870\ud68c \uc2dc\uac04\uc740 \ucd5c\uc545\uc758 \uacbd\uc6b0 \uc57d 12\ubd84 \uc815\ub3c4\ub85c \uc0ac\uc6a9\uc790\ub4e4\uc774 \ubcfc \uc218\ub3c4 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud55c \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\uba3c\uc800 \uac00\uc7a5 \ud070 \ubb38\uc81c\ub294 \ub370\uc774\ud130\uac00 \ub9ce\uae30 \ub54c\ubb38\uc774\uace0, \ub450 \ubc88\uc9f8\ub85c\ub294 \ube44\ud6a8\uc728\uc801\uc778 API\ub85c \uc778\ud55c \ubb38\uc81c\uc785\ub2c8\ub2e4.\\n\\n\ud604\uc7ac \ud63c\uc7a1\ub3c4 \uc870\ud68c\uc2dc 0~23\uc2dc\uae4c\uc9c0 \ubaa8\ub4e0 \uc694\uc77c\uc758 \uae09\uc18d\uacfc \uc644\uc18d \ucda9\uc804\uae30\uc5d0 \ub300\ud55c \ud63c\uc7a1\ub3c4\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n\uad73\uc774 \uc774\ub7f4 \ud544\uc694 \uc5c6\uc774 \uc120\ud0dd\ud55c \uc694\uc77c\uc5d0\ub9cc \ud63c\uc7a1\ub3c4\ub97c \uac00\uc838\uc628\ub2e4\uba74 \ubd88\ud544\uc694\ud55c \uc870\ud68c\ub294 \uc5c6\uc744 \uac70\ub77c\uace0 \uc0dd\uac01\ud574\uc11c \uc77c\ubd80\ubd84 \ub9ac\ud329\ud1a0\ub9c1\uc744 \ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ucd94\uac00\uc801\uc73c\ub85c \ubc15\uc2a4\ud130\uac00 DB Replication\uc744 \uc801\uc6a9\ud574\uc11c, Update\ub85c \uc778\ud55c \uc18d\ub3c4 \uc800\ud558 \ud604\uc0c1\ub3c4 \ub9ce\uc774 \uc904\uc5b4\ub4e4 \uac83\uc744 \uae30\ub300\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \ubb38\uc81c \ud574\uacb0 \uacfc\uc815\\n\\n- \uba3c\uc800 \uae30\uc874 \ucf54\ub4dc\ub85c \uc870\ud68c\uc2dc\uc5d0 \uc18d\ub3c4\uac00 \uc5bc\ub9c8\ub098 \ub098\uc624\ub294\uc9c0 \ud655\uc778\uc744 \ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMTgz/MDAxNjk0NDIwODU1MDg0.d2ig3CCgdHDwkz_7d4qyhVKM0PQ4MV8BcUwm9LjqAcAg.LdVGDSqRuArzM32ZD1tHbxsD2xG5pt8xrOrDwhR25wcg.PNG.sosow0212/image.png)\\n\uae30\uc874\uc758 \ubaa8\ub4e0 \ud63c\uc7a1\ub3c4\ub97c \ub4e4\uace0\uc624\ub294 \uacbd\uc6b0 \uc704\uc640 \uac19\uc774 536ms\uc758 \uc2dc\uac04\uc774 \uc18c\ubaa8\ub418\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMjA5/MDAxNjk0NDIwODk3NDE4.N3tGXL52LYr5Koc1Lwk0Tfhe3Apkao9BEI8waHIkwNgg.AUEcIoBUg8AtXMiZCc2P13Vb_DCeWnsoXH2-6acaClIg.PNG.sosow0212/image.png)\\n\uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 `day_of_week` \uc989 \ud63c\uc7a1\ub3c4\ub97c \ud655\uc778\ud558\uace0 \uc2f6\uc740 \uc694\uc77c\uc744 \ucd94\uac00\uc801\uc73c\ub85c \uc870\uac74\uc5d0 \uba85\uc2dc\ud574\uc8fc\ub2c8\\n148ms\ub85c \uc904\uc740 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n148ms\ub294 \uc544\uc9c1 \ud55c\ucc38 \ub290\ub9bd\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c `DB Partitioning`\uc744 \uc801\uc6a9\ud588\uc2b5\ub2c8\ub2e4.\\n\\nDB Partitioning\uc5d0 \ub300\ud574 \uac04\ub2e8\ud558\uac8c \uc124\uba85\ud558\uc790\uba74 \ud070 \ud14c\uc774\ube14\uc744 \uc791\uc740 \ub2e8\uc704\ub85c \uad00\ub9ac\ud558\ub294 \uae30\ubc95\uc785\ub2c8\ub2e4.\\n\ud558\ub098\uc758 \ud14c\uc774\ube14\ub85c \ubcf4\uc774\uc9c0\ub9cc \uc774\ub97c \uc801\uc6a9\ud558\uba74 \uc2e4\uc81c\ub85c \uc5ec\ub7ec \uac1c\uc758 \ud14c\uc774\ube14\ub85c \ubd84\ub9ac\ud574\uc11c \uad00\ub9ac\ud558\ub294 \uae30\ubc95\uc774\uace0, \uc774\ub97c \ud1b5\ud574\uc11c \uc870\ud68c \ubc0f \uc5c5\ub370\uc774\ud2b8 \ucffc\ub9ac \uc131\ub2a5\uc774 \uac1c\uc120\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc740 List partitioning\uc744 \uc801\uc6a9\ud574\uc11c `day_of_week(\uc694\uc77c)`\uc744 \uae30\uc900\uc73c\ub85c \ud30c\ud2f0\uc154\ub2dd\uc744 \ud588\uc2b5\ub2c8\ub2e4.\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMTE0/MDAxNjk0NDIxMzg1NTMx.Q4VBItbFdityCKdRFYqpC1qVtoi81RRqcmysYMh-9xog.d8MIYW-tatGXoaxCJ-o6vS5wydEk1yQVQTtmmZvooFIg.PNG.sosow0212/image.png)\\n\uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 day_of_week\ub97c \uae30\uc900\uc73c\ub85c \ud30c\ud2f0\uc154\ub2dd\uc744 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMjA5/MDAxNjk0NDIxNDM3MTI2.QXclZKmnwVTcYrkR95yPJV3vxCCzcaisaWj29WGxFucg.CO0SafuQLRmWPzAs9-9ForUnT1fjcqxXBRmX1UmB-b8g.PNG.sosow0212/image.png)\\nList Partitioning\uc744 \uc801\uc6a9\ud558\uace0 \uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 \uc870\ud68c \ucffc\ub9ac\ub97c \ub2e4\uc2dc \ub0a0\ub824\ubcf4\uba74, `partitions = p_friday` \ub85c \uc798 \ub098\ub258\uc5b4\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud30c\ud2f0\uc154\ub2dd \uc791\uc5c5\uc774 \uc798 \ub418\uc5c8\uc73c\ub2c8 \uc774\uc81c API\uc5d0\uc11c \uc694\uc77c \ubcc4 \ud63c\uc7a1\ub3c4 \uc870\ud68c\ub85c \ubc14\uafd4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\uba3c\uc800 \ucffc\ub9ac\ub97c \ubcc0\uacbd\ud558\uace0 \ucffc\ub9ac\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ub2e4\uc74c\uacfc \uac19\uc774 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMjQ5/MDAxNjk0NDIxNTcwOTg3.mgx-mdBa6J6k8erhiksOOkzrMzOMmCLX7iuRPZf7RNEg.ALwxez4qUVHB1wlIr9zsZovCBlxoIsmgCa4wNv-7t_4g.PNG.sosow0212/image.png?type=w773)\\n\uc704\uc640 \uac19\uc740 \uc870\ud68c \ucffc\ub9ac\uac00 \ub098\uc654\uc73c\ubbc0\ub85c \uc778\ub371\uc2a4\ub97c \uc544\ub798\uc640 \uac19\uc774 `station_id, day_of_week`\uc5d0 \uac78\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMjgy/MDAxNjk0NDIxNjI2NDAz.XqGsab-JR_fQaIZhCYMiKy5r3cn85wFLwUNlmCo1Gqwg.a26_N5lnwzXX6z0JRqn35u8pGcj1TAa2nDamgRyOYjUg.PNG.sosow0212/image.png?type=w773)\\n\uc704 \uc2e4\ud589 \uc18d\ub3c4\uc5d0\uc11c execution time\uc744 \ud655\uc778\ud574\ubcf4\uba74 \uc778\ub371\uc2a4\ub97c \uac78\uace0 `134ms -> 5ms`\ub85c \uc131\ub2a5\uc774 \ub9ce\uc774 \uac1c\uc120 \ub418\uc5c8\uc74c\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMjI3/MDAxNjk0NDIxNjc5NDIw.kbfBLWKeY70QFeByKN5xVX1WhFSpFZbJnFkx9l0zyrQg.Wv0QU9W6Fqjfr8eyyLT2MyttjDKzN2cdrItGH7CDLPYg.PNG.sosow0212/image.png?type=w773)\\n\uc2e4\ud589 \uacc4\ud68d\ub3c4 \uc758\ub3c4\ud55c\ub300\ub85c \uc798 \ub098\uc624\ub294 \uac83\uc744 \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \uc815\ub9ac\\n\\n1. DB Partitioning - (day_of_week : \uc694\uc77c)\uc744 \uae30\uc900\uc73c\ub85c \ud30c\ud2f0\uc154\ub2dd\\n2. \uc870\ud68c \ucffc\ub9ac\uc5d0 \ub9de\uac8c \uc778\ub371\uc2a4 \uc124\uc815\\n3. API \uc218\uc815 (\ubaa8\ub4e0 \uc694\uc77c\uc758 \ud63c\uc7a1\ub3c4 \uc870\ud68c -> \ud574\ub2f9 \uc694\uc77c\uc758 \ud63c\uc7a1\ub3c4 \uc870\ud68c)\\n\\n\uacb0\uacfc\uc801\uc73c\ub85c \uae30\uc874 \ud63c\uc7a1\ub3c4 \uc870\ud68c\uc2dc 511ms\uac00 \ub098\uc654\uc73c\ub098, \uc694\uc77c \ubcc4 \uc870\ud68c \ubc0f \ud30c\ud2f0\uc154\ub2dd & \uc778\ub371\uc2a4\ub97c \uc801\uc6a9\ud558\uace0 execution time = 5ms\ub85c \uac1c\uc120"},{"id":"32","metadata":{"permalink":"/32","source":"@site/blog/2023-09-11-database-replication.mdx","title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","description":"\uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720","date":"2023-09-11T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 11\uc77c","tags":[{"label":"mysql","permalink":"/tags/mysql"}],"readingTime":23.75,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"32","title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","authors":["boxster"],"tags":["mysql"]},"prevItem":{"title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","permalink":"/33"},"nextItem":{"title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/31"}},"content":"## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\uac8c \ub41c \uacc4\uae30\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \uc9c0\ub09c \uae00\uc5d0\uc11c \uc124\uba85\ud588\ub4ef\uc774 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c\ub294 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc2e4\ud589\ub418\uace0 \uc788\ub294 \uc11c\ubc84\uc758 cpu \uc0ac\uc6a9\ub960\uc774 100%\uac00 \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc774 \ubd80\ubd84\uc5d0 \ub300\ud574\uc11c\ub294 \uc870\ud68c \uc131\ub2a5\uc744 \ub192\ud600 \uc5b4\ub290\uc815\ub3c4 \ud574\uacb0\ud558\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc870\ud68c\uac00 \uc544\ub2cc \ub9ce\uc740 \ub370\uc774\ud130\ub97c \uc77c\uc815\ud55c \uc8fc\uae30\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc918\uc57c\ud558\ub294 \ub85c\uc9c1\ub3c4 \ud3ec\ud568\ub418\uc5b4 \uc788\uae30 \ub54c\ubb38\uc5d0 \uc5c5\ub370\uc774\ud2b8\ub97c \ud560 \ub54c \uc870\ud68c\ub97c \ud558\uac8c \ub41c\ub2e4\uba74 cpu \uc0ac\uc6a9\ub960\uc740 \ube44\uc2b7\ud560 \uac83\uc785\ub2c8\ub2e4. \uc774 \ubd80\ubd84\uc744 \ud574\uacb0\ud558\uace0\uc790 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\uacb0\ub860\ubd80\ud130 \ub9d0\uc500\ub4dc\ub9ac\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud55c \ud6c4 \uc131\ub2a5\uc774 \ub208\uc5d0 \ub744\uac8c \uc88b\uc544\uc84c\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ubd80\ubd84\uc740 \ub2e4\uc74c \ud3ec\uc2a4\ud305\uc5d0 \uc791\uc131\ud558\uaca0\uc2b5\ub2c8\ub2e4\\n100\uba85\uc758 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c\ub97c \uae30\uc900\uc73c\ub85c\\n\\n**TPS** 179 -> 366\\n\\n**Response** Time 550 ms -> 271 ms\\n\\n\uc57d 2\ubc30 \uac00\ub7c9 \uc131\ub2a5\uc774 \ud5a5\uc0c1\ub41c \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n# \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc774\ub780?\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc774\ub780 \ud558\ub098\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \ub2e4\ub978 \ud558\ub098 \uc774\uc0c1\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub85c \ub370\uc774\ud130\uc758 \ubcf5\uc81c \ub610\ub294 \ubcf5\uc0ac\ub97c \uc218\ud589\ud558\ub294 \ud504\ub85c\uc138\uc2a4 \ub610\ub294 \uae30\uc220\uc785\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc740 \uc8fc\ub85c \ub2e4\uc74c\uacfc \uac19\uc740 \ubaa9\uc801\uc73c\ub85c \uc0ac\uc6a9\ub429\ub2c8\ub2e4\\n\\n1. **\uace0\uac00\uc6a9\uc131**:\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uc758 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c, \ub808\ud50c\ub9ac\uce74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc2dc\uc2a4\ud15c\uc744 \uacc4\uc18d \uc6b4\uc601\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub807\uac8c \ud558\uba74 \uc11c\ube44\uc2a4 \uc911\ub2e8 \uc2dc\uac04\uc744 \ucd5c\uc18c\ud654\ud558\uace0 \ube44\uc988\ub2c8\uc2a4 \uc5f0\uc18d\uc131\uc744 \uc720\uc9c0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n2. **\uc131\ub2a5 \ud5a5\uc0c1** :\\n\ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uba74 \uc77d\uae30 \uc791\uc5c5\uc744 \ubd84\uc0b0\uc2dc\ud0ac \uc218 \uc788\uc73c\ubbc0\ub85c \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uc758 \uc77d\uae30 \ubd80\ud558\ub97c \uc904\uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub97c \ud1b5\ud574 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc131\ub2a5\uc744 \ud5a5\uc0c1\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n3. **\uc9c0\uc5ed\uc801 \ubd84\uc0b0** :\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \ud1b5\ud574 \ub370\uc774\ud130\ub97c \uc9c0\ub9ac\uc801\uc73c\ub85c \ubd84\uc0b0\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub807\uac8c \ud558\uba74 \uc9c0\uc5ed\uc801\uc778 \uc0ac\uc6a9\uc790 \ub610\ub294 \uc751\uc6a9 \ud504\ub85c\uadf8\ub7a8\uc5d0 \ube60\ub974\uac8c \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud560 \uc218 \uc788\uc73c\uba70, \uc9c0\uc5ed\uc801\uc778 \uaddc\uc815 \uc900\uc218 \uc694\uad6c\uc0ac\ud56d\uc744 \ucda9\uc871\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n4. **\ubc31\uc5c5\uacfc \ubcf5\uad6c** :\\n\ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uc5ec \uc8fc \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ubc31\uc5c5\uc744 \uc0dd\uc131\ud558\uace0, \uc774\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc7a5\uc560 \ubcf5\uad6c\ub97c \uc218\ud589\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc8fc \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc190\uc0c1\ub418\uc5c8\uc744 \ub54c \ubc31\uc5c5 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc2dc\uc2a4\ud15c\uc744 \ube60\ub974\uac8c \ubcf5\uc6d0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n5. **\ub370\uc774\ud130 \ubd84\uc11d \ubc0f \ubcf4\uace0** :\\n\ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uc5ec \ub370\uc774\ud130\ub97c \ub2e4\ub978 \ubd84\uc11d \ub610\ub294 \ubcf4\uace0 \ub3c4\uad6c\ub85c \ubcf5\uc0ac\ud558\uc5ec \ub370\uc774\ud130 \uc6e8\uc5b4\ud558\uc6b0\uc2a4 \ub610\ub294 \ubd84\uc11d \uc2dc\uc2a4\ud15c\uc5d0\uc11c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud55c \uac00\uc7a5 \ud070 \uc774\uc720\ub294 \uc131\ub2a5 \ud5a5\uc0c1\uc785\ub2c8\ub2e4. \uc544\ubb34\ub798\ub3c4 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc77d\uae30 \uc791\uc5c5\uacfc \uc4f0\uae30 \uc791\uc5c5\uc774 \ub458 \ub2e4 \ube48\ubc88\ud558\uac8c \uc77c\uc5b4\ub098\uace0, \ud2b9\ud788 \uc4f0\uae30 \uc791\uc5c5\uc5d0 \ub9ce\uc740 \uc5f0\uc0b0\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \uc0ac\uc6a9\uc790\uc5d0\uac8c \ucd5c\uc2e0\uc758 \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud558\uace0\uc790 \uc4f0\uae30 \uc791\uc5c5\uc744 \uc790\uc8fc\ud558\uc5ec \ub370\uc774\ud130\ub97c \ucd5c\uc2e0\ud654\ud558\ub354\ub77c\ub3c4, \uc77d\uae30 \uc791\uc5c5\uc774 \ub290\ub824\uc9c0\uba74 \uc544\ubb34\ub3c4 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc744 \uac83\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub807\uac8c \uc11c\ubc84\ub97c \uc5ec\ub7ec \ub300 \ub450\uc5b4 \ud558\ub098\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uac00 \ubc1b\ub294 \ubd80\ud558\ub97c \ubd84\uc0b0\uc2dc\ud0a8\ub2e4\uba74 \uc131\ub2a5\uc774 \ud5a5\uc0c1 \ub420 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub450\ubc88\uc9f8\ub85c\ub294 \uace0\uac00\uc6a9\uc131\uc785\ub2c8\ub2e4. \ud604\uc7ac \uc800\ud76c\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub294 \ud558\ub098\uc758 \uc11c\ubc84\ub85c SPOF \ubb38\uc81c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud558\uc5ec \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \ubd84\uc0b0\ud55c\ub2e4\uba74 \ud558\ub098\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc7a5\uc560\uac00 \uc0dd\uaca8 \uc911\uc9c0\uac00 \ub418\ub354\ub77c\ub3c4, \ub2e4\ub978 \uc11c\ubc84\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub85c \uc11c\ube44\uc2a4\ub97c \uc774\uc5b4\ub098\uac08 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n# \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ubcf5\uc81c \ubc29\uc2dd\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ubcf5\uc81c \ubc29\uc2dd\uc740 \ud06c\uac8c \ub450\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4. **Binary Log\ub85c \ubcf5\uc81c\ud558\ub294 \ubc29\uc2dd**\uacfc **GTID(Global Transaction Id)\ub97c \ud1b5\ud574 \ubcf5\uc81c\ub97c \ud558\ub294 \ubc29\uc2dd**\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## Binary log \ubcf5\uc81c \ubc29\uc2dd\\n\uba3c\uc800 Binary Log \ub294 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uc218\ud589\ud55c \ucffc\ub9ac (\uc0ac\uc6a9\uc790 \ucd94\uac00, \uc778\ub371\uc2a4 \ucd94\uac00, Update, Insert, Delete \ub4f1 ) \ubaa8\ub4e0 \uc815\ubcf4\ub97c Binary Log\uc5d0 \uae30\ub85d\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ud574\ub2f9 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0\ub294 \uc774\ubca4\ud2b8\ub9c8\ub2e4 Mysql \uc11c\ubc84\uc758 \uace0\uc720\ud55c Server id\ub97c \uac00\uc9c0\uace0 \uc788\ub294\ub370, \ud574\ub2f9 Id\uac00 \uac19\uc740 \uc11c\ubc84\uc5d0\uc11c\ub294 \ud574\ub2f9 \uc774\ubca4\ud2b8\ub97c \uc790\uc2e0\uc774 \ubc1c\uc0dd\uc2dc\ud0a8 \uc774\ubca4\ud2b8\ub85c \uac04\uc8fc\ud558\uace0 \uc801\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\ubbc0\ub85c \uac01\uac01\uc758 \uace0\uc720\ud55c server id\ub97c \uc124\uc815\ud574\uc918\uc57c \ud569\ub2c8\ub2e4.\\n\uc774 **\ubc14\uc774\ub108\ub9ac \ub85c\uadf8 \ud30c\uc77c\uc758 \uc704\uce58\uc640 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8 \ud30c\uc77c\uba85**\uc744 \ud1b5\ud574 Replica \uc11c\ubc84\ub294 Source \uc11c\ubc84\uc758 \uc774\ubca4\ud2b8\ub97c \uc801\uc6a9\ud569\ub2c8\ub2e4\\n## GTID \ubcf5\uc81c \ubc29\uc2dd\\nMysql 5.5 \ubc84\uc804 \uc774\uc0c1\ubd80\ud130\ub294 GTID \uae30\ubc18 \ubcf5\uc81c\ub3c4 \uac00\ub2a5\ud558\uac8c \ucd94\uac00\ub418\uc5c8\uc2b5\ub2c8\ub2e4 GTID\ub294 source id\uc640 transaction id\uac00 \uc870\ud569\ub41c \ubc29\uc2dd\uc73c\ub85c \uc0dd\uc131\ub429\ub2c8\ub2e4. source id\ub294 \ud2b8\ub79c\uc7ad\uc158\uc774 \ubc1c\uc0dd\ud55c \uc18c\uc2a4 \uc11c\ubc84\ub97c \uc2dd\ubcc4\ud558\uae30 \uc704\ud55c \uac12\uc73c\ub85c server\uc758 uuid \uc785\ub2c8\ub2e4.\\n```sql\\n+--------------------------------------+\\n| source_uuid |\\n+--------------------------------------+\\n| c3a2296b-31a2-11ee-b887-02a8cf0173ac |\\n+--------------------------------------+\\n```\\n\uc774\ub7ec\ud55c GTID\ub97c \uae30\ubc18\uc73c\ub85c Source \uc11c\ubc84\ub97c \uad6c\ubd84\ud558\uace0 Binary Log \ud30c\uc77c\uc5d0 \uae30\ub85d\ub41c GTID\ub97c \ud655\uc778\ud558\uc5ec \ub9c8\uc9c0\ub9c9\uc5d0 \uc801\uc6a9\ud55c \uc774\ubca4\ud2b8\ub97c \ud655\uc778\ud558\uace0, \uc801\uc6a9\ud558\uc9c0 \uc54a\uc740 \uc774\ubca4\ud2b8\ub97c \uc21c\ucc28\ub300\ub85c \uc2e4\ud589\uc2dc\ucf1c \ubcf5\uc81c\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ub450\uac00\uc9c0 \ubc29\ubc95 \uc911 \uc800\ud76c\ub294 GTID \ubc29\uc2dd\uc758 \ubcf5\uc81c\ub97c \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4. \uc774\uc720\ub294 \uac04\ub2e8\ud569\ub2c8\ub2e4.\\n```mermaid\\ngraph TD\\n Source[Source Server: BinaryLog10] --\x3e Replica1[Replica1: BinaryLog10]\\n Source[Source Server: BinaryLog10] --\x3e Replica2[Replica2: BinaryLog9]\\n```\\n\uc774\ub7f0 \ubc29\uc2dd\uc73c\ub85c \ud1a0\ud3f4\ub85c\uc9c0\ub97c \uad6c\uc131\ud588\ub2e4\uace0 \uac00\uc815\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. Source \uc11c\ubc84\uc5d0\uc11c\ub294 Binary Log 10\ubc88 \ud30c\uc77c\uae4c\uc9c0 \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 Replica1 \uc5d0\uc11c\ub294 Source \uc11c\ubc84\uc758 \uc774\ubca4\ud2b8\uac00 \ucd5c\uc2e0\ud654 \ub418\uc5b4 \uc788\uc9c0\ub9cc, Replica2 \uc11c\ubc84\ub294 \uc544\uc9c1 \ucd5c\uc2e0\ud654\uac00 \ub418\uc9c0 \uc54a\uc740 \uc0c1\ud669\uc785\ub2c8\ub2e4. \uc774 \uc0c1\ud669\uc5d0\uc11c Source Server\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud558\uc5ec \uc11c\ubc84\uac00 \uc911\ub2e8 \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 Replica1 \uc11c\ubc84\ub97c Source \uc11c\ubc84\ub85c \uc2b9\uaca9\ud569\ub2c8\ub2e4. \uc774\ub807\uac8c \ub41c\ub2e4\uba74 Replica1 \uc11c\ubc84\uc5d0\uc11c \ubaa8\ub4e0 \ucffc\ub9ac\uc758 \uc694\uccad\uc774 \ub4e4\uc5b4\uc624\uac8c \ub429\ub2c8\ub2e4. BinaryLog10\uc774\ub77c\ub294 \ud30c\uc77c\uc758 \uc704\uce58\uc640 \ud30c\uc77c\uc744 \ucc3e\uc744 \ubc29\ubc95\uc774 \uc5c6\uae30 \ub54c\ubb38\uc5d0 Source\uc11c\ubc84\uac00 \ubcf5\uad6c\ub418\uc9c0 \uc54a\ub294 \uc774\uc0c1 \ud639\uc740 Replica 1 \uc11c\ubc84\uc758 Relay Log\uac00 \ub0a8\uc544\uc788\uc9c0 \uc54a\ub294 \uc774\uc0c1 Replica2 \uc11c\ubc84\ub294 \uc808\ub300 \ucd5c\uc2e0\ud654\ub420 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774\ub7f0 \uc2dd\uc758 \ubc29\uc2dd\uc774\ub77c\uba74 Source \uc11c\ubc84\uac00 \uc911\ub2e8\ub418\uc5c8\uc744 \ub54c \ub2e4\ub978 \uc11c\ubc84\uac00 \ub3d9\uc791\ud558\uae30 \ub54c\ubb38\uc5d0 \uace0\uac00\uc6a9\uc131 \ubb38\uc81c\ub294 \ud574\uacb0\ub41c \uac83 \uac19\uc9c0\ub9cc, Replica2 \uc11c\ubc84\ub294 \uc544\ubb34 \uc77c\ub3c4 \ud558\uc9c0\uc54a\uace0 \ub0a8\uc544\uc788\ub294 \uc11c\ubc84, \uc989 Source \uc11c\ubc84 \ud558\ub098\uac00 \uc911\ub2e8\ub418\uc5c8\uc73c\ub098 **2\ub300\uc758 \uc11c\ubc84\uac00 \uc911\ub2e8\ub41c \uac83**\uacfc \ub9c8\ucc2c\uac00\uc9c0\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub7ec\ud55c \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 GTID\uac00 \ub4f1\uc7a5\ud588\uc2b5\ub2c8\ub2e4. GTID \ubc29\uc2dd\uc740 Binary Log\uc758 \uc704\uce58\uc640 \ud30c\uc77c\uba85\uc774 \ud544\uc694\ud55c \uac83\uc774 \uc544\ub2cc \ub2e4\uc74c \uc774\ubca4\ud2b8\uc758 GTID\ub9cc \uc788\ub2e4\uba74 \ud574\ub2f9 \uc774\ubca4\ud2b8\ub97c \ubc14\ub85c \uc801\uc6a9\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4. Source \uc11c\ubc84\ub85c \uc2b9\uaca9\ub41c Replica1 \uc11c\ubc84\uc5d0\uc11c **GTID\ub97c \ubc1b\uc544 \uc801\uc6a9\ud558\uc5ec \ucd5c\uc2e0\ud654**\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc758 \ubcf5\uc81c \ubc29\uc2dd\\n\uc774\ub7ec\ud55c \uc7a5\uc810\uc73c\ub85c GTID\uae30\ubc18 \ubcf5\uc81c \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n# \ubcf5\uc81c \ub3d9\uae30\ud654 \ubc29\uc2dd\\n\\n\ubcf5\uc81c \ubc29\uc2dd\uc5d0\ub294 \ud06c\uac8c \ub450\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4. **\ube44\ub3d9\uae30 \ubcf5\uc81c**\uc640 **\ubc18\ub3d9\uae30 \ubcf5\uc81c**\uc785\ub2c8\ub2e4.\\n\\n## \ube44\ub3d9\uae30 \ubcf5\uc81c\\n\ube44\ub3d9\uae30 \ubcf5\uc81c\ub294 \ub9d0\uadf8\ub300\ub85c \ube44\ub3d9\uae30\ub85c \ubcf5\uc81c\ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \uc544\uc8fc \uac04\ub2e8\ud569\ub2c8\ub2e4. Source \uc11c\ubc84\uc5d0\uc11c \uc5b4\ub5a0\ud55c \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud560 \ub54c Replica \uc11c\ubc84\uc758 \ubc18\uc601\uacfc \uc0c1\uad00\uc5c6\uc774 \ub3d9\uc791\ud558\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c \ucee4\ubc0b\ub41c \ud2b8\ub79c\uc7ad\uc158\uc740 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d\ub418\uace0, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0\uc11c\ub294 \uc8fc\uae30\uc801\uc73c\ub85c \uc0c8\ub85c\uc6b4 \ud2b8\ub79c\uc7ad\uc158\uc5d0 \ub300\ud55c \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\ub97c \uc694\uccad\ud569\ub2c8\ub2e4. \uc774\ub7ec\ud55c \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\ub294 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \ubcc0\uacbd \ub418\uc5c8\ub294\uc9c0 \uc54c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc989 \ub370\uc774\ud130 \uc815\ud569\uc131\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uae34\ub2e4\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\uac00 \uac01 \ud2b8\ub79c\uc7ad\uc158\uc5d0 \ub300\ud574 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub85c \uc804\uc1a1\ub418\ub294 \ubd80\ubd84\uc744 \uace0\ub824\ud558\uc9c0 \uc54a\ub294\ub2e4\ub294 \uc810\uc774 \uc18d\ub3c4 \uce21\uba74\uc5d0\uc11c \ube60\ub974\uace0, \ub610 \uc5ec\ub7ec \ub300\uc758 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub97c \uad6c\uc131\ud558\ub354\ub77c\ub3c4 \ud070 \uc131\ub2a5 \uc800\ud558\uac00 \uc5c6\ub2e4\ub294 \uc810\uc774\uc11c \uc7a5\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \ubc18\ub3d9\uae30 \ubcf5\uc81c\\n\ubc18\ub3d9\uae30 \ubcf5\uc81c\ub294 \ube44\ub3d9\uae30 \ubcf5\uc81c\ubcf4\ub2e4 \uc880 \ub354 \ub370\uc774\ud130 \uc815\ud569\uc131\uc774 \uc62c\ub77c\uac11\ub2c8\ub2e4. \uc18c\uc2a4 \uc11c\ubc84\ub294 \ubcc0\uacbd\ub41c \ud2b8\ub79c\uc7ad\uc158\uc774 \uc788\uc744 \ub54c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ub2e4 \uc804\uc1a1\uc774 \ub418\uc5c8\ub2e4\ub294 ACK \uc2e0\ud638\ub97c \ubc1b\uae30 \ub54c\ubb38\uc5d0 \ud655\uc2e4\ud788 \uc54c \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc804\uc1a1\uc5ec\ubd80\ub9cc \ud655\uc778\ud558\uae30 \ub54c\ubb38\uc5d0 \ud2b8\ub79c\uc7ad\uc158\uc774 \ubc18\uc601\uc774 \ub418\uc5c8\ub2e4\ub294 \ubcf4\uc7a5\uc740 \uc5c6\uc2b5\ub2c8\ub2e4. \ubc18\ub3d9\uae30 \ubcf5\uc81c \ubc29\uc2dd\uc740 2\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. **After sync**: After Sync \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c \ud2b8\ub79c\uc7ad\uc158\uc744 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d \ud6c4 Storage Engine\uc5d0 \ubc14\ub85c \ucee4\ubc0b\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d \ud6c4 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc758 ACK \uc751\ub2f5\uc744 \uae30\ub2e4\ub9bd\ub2c8\ub2e4. \uadf8\ub9ac\uace0 ACK \uc751\ub2f5\uc774 \ub3c4\ucc29\ud558\uba74 \uadf8\uc81c\uc11c\uc57c \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4\uc744 \ucee4\ubc0b\ud558\uc5ec \ud2b8\ub79c\uc7ad\uc158\uc744 \ucc98\ub9ac\ud558\uace0 \uacb0\uacfc\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n2. **After commit**: After commit\uc740 \uc774\ub984 \uadf8\ub300\ub85c \ucee4\ubc0b\uc744 \uba3c\uc800 \ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \ud2b8\ub79c\uc7ad\uc158\uc774 \uc0dd\uae30\uba74 \uba3c\uc800 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d \ud6c4 \uc18c\uc2a4 \uc11c\ubc84 \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4\uc5d0 \ucee4\ubc0b\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc758 ACK \uc751\ub2f5\uc774 \ub0b4\ub824\uc624\uba74 \ud074\ub77c\uc774\uc5b8\ud2b8\ub294 \ucc98\ub9ac \uacb0\uacfc\ub97c \uc5bb\uace0 \ub2e4\uc74c \ucffc\ub9ac\ub97c \uc218\ud589\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 after commit \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c \ud32c\ud140 \ub9ac\ub4dc\uac00 \ubc1c\uc0dd\ud558\uac8c \ub429\ub2c8\ub2e4. \ud2b8\ub79c\uc7ad\uc158\uc774 \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4 \ucee4\ubc0b\uae4c\uc9c0\ub41c \ud6c4 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc758 \uc751\ub2f5\uc744 \uae30\ub2e4\ub9bd\ub2c8\ub2e4. \uc774\ucc98\ub7fc \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4 \ucee4\ubc0b\uae4c\uc9c0 \uc644\ub8cc\ub41c \ub370\uc774\ud130\ub294 \ub2e4\ub978 \uc138\uc158\uc5d0\uc11c\ub3c4 \uc870\ud68c\uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4. \ud2b8\ub79c\uc7ad\uc158\uc774 \ucee4\ubc0b\ub418\uc5c8\uace0, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub85c \uc544\uc9c1 \uc751\ub2f5\uc744 \uae30\ub2e4\ub9b4 \ub54c, \uc18c\uc2a4 \uc11c\ubc84\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud55c\ub2e4\uba74 \uc0c8\ub85c\uc6b4 \uc18c\uc2a4 \uc11c\ubc84\ub85c \uc2b9\uaca9\ub41c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0\uc11c \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c \uc790\uc2e0\uc774 \uc774\uc804 \uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c \uc870\ud68c\ud588\ub358 \ub370\uc774\ud130\ub97c \ubcf4\uc9c0 \ubabb\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc774\ucc98\ub7fc \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \uc2b9\uaca9\ub41c \uc0c1\ud669\uc5d0 \uc18c\uc2a4 \uc11c\ubc84\uc758 \uc7a5\uc560\uac00 \ubcf5\uad6c\ub418\uc5b4 \uc7ac\uc0ac\uc6a9\ud560 \uacbd\uc6b0 \uc774\ubbf8 \ucee4\ubc0b\ub41c \uadf8 \ud2b8\ub79c\uc7ad\uc158\uc744 \uc218\ub3d9\uc73c\ub85c \ub864\ubc31 \uc2dc\ucf1c\uc57c\ub9cc \ub370\uc774\ud130\uac00 \ub9de\ub294 \uc0c1\ud669\uc774 \uc0dd\uae41\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc758 \ubcf5\uc81c \ub3d9\uae30\ud654 \ubc29\uc2dd\\n\uc774\ub7ec\ud55c \uc7a5\ub2e8\uc810\uc73c\ub85c \uc800\ud76c \ud300\uc740 \ub370\uc774\ud130 \ubb34\uacb0\uc131\uc774 \uc911\uc694\ud558\ub2e4 \ud310\ub2e8\ub418\uc5b4 \ubc18\ub3d9\uae30 \ubcf5\uc81c \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud558\uace0, After Sync \ubc29\uc2dd\uc744 \uc801\uc6a9\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n# \ubcf5\uc81c \ud1a0\ud3f4\ub9ac\uc9c0\\n\\n\ubcf5\uc81c \ud1a0\ud3f4\ub9ac\uc9c0\ub294 \uc5ec\ub7ec\uac00\uc9c0 \ubc29\uc2dd \uc911 \uc790\uc2e0\uc758 \uc0c1\ud669\uacfc \uac00\uc7a5 \ub9de\ub294 \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4. \uc800\ud76c \ud300\uc774 \uace0\ub824\ud574\uc57c\ud560 \ubb38\uc81c\ub294 \uba3c\uc800 \uc131\ub2a5\uc744 \uc62c\ub824\uc57c \ud588\uace0, \ub2e8\uc77c \uc7a5\uc560\ud3ec\uc778\ud2b8\ub97c \uac1c\uc120\ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uc11c\ubc84\ub294 2\ub300 \ubfd0\uc774\uc600\uc2b5\ub2c8\ub2e4. \uc774\ub7ec\ud55c \uc0c1\ud669\uc5d0\uc11c \uc5b4\ub5a4 \ubc29\uc2dd\uc744 \ud0dd\ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n## \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S[Source]\\n A -- Read --\x3e R[Replica]\\n S--\x3e R\\n```\\n\uac00\uc7a5 \uae30\ubcf8\uc801\uc774\uba70 \uac00\uc7a5 \ub9ce\uc774 \uc4f0\uc774\ub294 \ud615\ud0dc\uc785\ub2c8\ub2e4. \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc5d0\uc11c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0 \uc77d\uae30 \uc694\uccad\uc744 \uc804\ub2ec\ud558\uba74, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uacbc\uc744 \ub54c, \uc11c\ube44\uc2a4 \uc7a5\uc560 \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\ubbc0\ub85c \uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c Read, Write\ub97c \ub458 \ub2e4 \ud558\uace0, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub294 failover\ub97c \uc704\ud574 \ub300\uae30\ud558\ub294 \uc608\ube44\uc6a9 \uc11c\ubc84\ub85c \uad6c\uc131\ud569\ub2c8\ub2e4.\\n\uc18c\uc2a4 \uc11c\ubc84\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c \uc18c\uc2a4 \uc11c\ubc84\ub97c \ub300\uccb4\ud558\uac70\ub098 \ub370\uc774\ud130\ub97c \ubc31\uc5c5\ud558\ub294 \uc6a9\ub3c4\ub85c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\n## \uba40\ud2f0 \ub808\ud50c\ub9ac\uce74\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S[Source]\\n A -- Read --\x3e R1[Replica1]\\n S --\x3e R1\\n S --\x3e R2[Replica2]\\n```\\n\uc2f1\uae00 \ub808\ud50c\ub9ac\uce74\uc640 \ube44\uc2b7\ud55c \uad6c\uc131\uc774\uc9c0\ub9cc \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ud55c \ub300 \ub354 \ucd94\uac00\ub41c \uad6c\uc131\uc785\ub2c8\ub2e4. \ud574\ub2f9 \ubc29\uc2dd\uc740 SPOF \ubb38\uc81c\uac00 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84 \ud558\ub098\ub97c \uc77d\uae30 \uc804\uc6a9 \uc11c\ubc84\ub85c \ub458 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc77d\uae30 \uc791\uc5c5\uc744 \ubd84\uc0b0\ud568\uc73c\ub85c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc758 \uc131\ub2a5\uc744 \ud5a5\uc0c1 \uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc544\uae4c \ub9d0\ud588\ub358 \uc7a5\uc560 \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud558\uba74 \uc608\ube44\uc6a9 \uc11c\ubc84\uc778 Replica2 \uc11c\ubc84\ub97c Source \uc11c\ubc84 \ud639\uc740 Replica1(\uc77d\uae30 \uc804\uc6a9) \uc11c\ubc84\ub85c \ub300\uccb4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uccb4\uc778 \ubcf5\uc81c\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S[Source1]\\n A -- Read --\x3e R1-1[Replica1-1]\\n S --\x3e R1-1\\n S --\x3e R1-2[Replica1-2]\\n S --\x3e R1-3[Replica1-3 / Source2]\\n R1-3 --\x3e R2-1[Replica2-1]\\n R1-3 --\x3e R2-2[Replica2-2]\\n B[Batch Server] --Read--\x3e R2-2\\n\\n```\\n\ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ub9ce\uc544\uc838 \uc18c\uc2a4 \uc11c\ubc84\uc758 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\ub97c \uc77d\ub294 \ubd80\ud558\uac00 \ub9ce\uc544\uc9c8 \ub54c \ud560 \uc218 \uc788\ub294 \uad6c\uc131\uc785\ub2c8\ub2e4. \uc880 \uc804\uc5d0 \uc124\uba85\ub4dc\ub838\ub358 \uba40\ud2f0 \ub808\ud50c\ub9ac\uce74 \ubc29\uc2dd\uc5d0\uc11c \ub611\uac19\uc740 \uad6c\uc131\uc744 \ucd94\uac00\ud55c \ubc29\uc2dd\uc73c\ub85c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4. Source 1 \uc758 \uc815\ubcf4\ub97c \ubcf5\uc81c\ud55c Replica 1-1, 1-2 \uc11c\ubc84\ub294 \ube60\ub974\uac8c \ub370\uc774\ud130\uac00 \ubc18\uc601\ub418\uc9c0\ub9cc, Source1\uc758 \uc774\ubca4\ud2b8\ub97c \ubcf5\uc81c\ud55c Source2\ub97c \ubcf5\uc81c\ud55c Replica 2-1, 2-2 \uc11c\ubc84\ub294 \ub2f9\uc5f0\ud788 \ub2a6\uac8c \ubc18\uc601\ub418\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \uadf8\ub8f9\uc740 \uc608\ube44\uc6a9\uc73c\ub85c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\n## \ub4c0\uc5bc \uc18c\uc2a4 \ubcf5\uc81c\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S1[Source/Replica 1]\\n A -- Read + Write --\x3e S2[Source/Replica 2]\\n S1 <-- Replication --\x3e S2\\n```\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub458 \ub2e4 \uc18c\uc2a4 \uc11c\ubc84\uc774\uba74\uc11c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc778 \uacbd\uc6b0\uc785\ub2c8\ub2e4. \uc774 \uacbd\uc6b0\ub294 **Active-Active**\uad6c\uc131\uacfc **Active-Passive** \uad6c\uc131\uc73c\ub85c \ub098\ub269\ub2c8\ub2e4\\n\\nActive-Active\ub294 \uc11c\ubc84 \ub458 \ub2e4 \uc77d\uae30\uc640 \uc4f0\uae30\uac00 \uac00\ub2a5\ud55c \ud615\ud0dc\uc785\ub2c8\ub2e4. \uc989 \ubd80\ud558\ub97c \ubd84\uc0b0\uc2dc\ud0a4\uae30 \uc704\ud574 \uc11c\ubc84 \ubaa8\ub450 \uc77d\uace0 \uc4f0\ub294 \uc791\uc5c5\uc744 \ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \ubc29\uc2dd\uc740 \ubed4\ud55c \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc11c\ub85c\uc758 \uc774\ubca4\ud2b8\uac00 \ub3d9\uae30\ud654 \ub418\uae30 \uc804\uc5d0\ub294 \uc815\ud569\uc131\uc774 \uae68\uc9c8 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub610 \ub3d9\uc2dc\uc5d0 \uac19\uc740 \ub370\uc774\ud130\uc5d0 \ub300\ud574 \uc4f0\uae30 \uc791\uc5c5\uc744 \uc218\ud589\ud560 \ub54c, \ud558\ub098\uc758 \uc11c\ubc84\uc5d0\uc11c \uc4f0\uae30\uac00 \uc644\ub8cc\ub418\uc5c8\ub354\ub77c\ub3c4, \ub2e4\ub978 \ud558\ub098\uc758 \uc11c\ubc84\uc5d0 \ub2a6\uac8c \ub05d\ub09c \uc4f0\uae30\uac00 \uc788\ub2e4\uba74 \ub9c8\uc9c0\ub9c9 \ud2b8\ub79c\uc7ad\uc158\uc778 \ub2a6\uac8c \ub05d\ub09c \uc4f0\uae30 \uc791\uc5c5\uc774 \ubc18\uc601\ub418\uc5b4 \uc608\uc0c1\ud558\uc9c0 \ubabb\ud55c \uacb0\uacfc\uac00 \ub098\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub610 \ub2e4\ub978 \ubb38\uc81c\ub85c\ub294 Auto Increment\ub97c \uc0ac\uc6a9\ud560 \ub54c\uc785\ub2c8\ub2e4. \uc0c8\ub85c\uc6b4 \ub370\uc774\ud130\uac00 \ub3d9\uc2dc\uc5d0 \uc0dd\uc131\ub420 \ub54c Auto Increment\uac00 \uc911\ubcf5\ub418\ub294 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \ud1a0\ud3f4\ub85c\uc9c0\uc5d0\uc11c\ub294 ID\ub97c DB\uc5d0 \uc758\uc874\ud558\uc9c0 \uc54a\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4.\\n\\nActive-Passive \ubc29\uc2dd\uc740 \ud558\ub098\uc758 \uc11c\ubc84\ub9cc \uc77d\uae30\uc640 \uc4f0\uae30 \uc694\uccad\uc774 \ub418\uc9c0\ub9cc, \ub098\uba38\uc9c0 \uc11c\ubc84\ub294 \ub300\uae30\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \ub450 \uc11c\ubc84 \ubaa8\ub450 \uc5b8\uc81c\ub098 \uc4f0\uae30 \uc791\uc5c5\uc774 \uac00\ub2a5\ud55c \ud615\ud0dc\uc774\uae30 \ub54c\ubb38\uc5d0 \uc7a5\uc560 \ubc1c\uc0dd \uc2dc \ube60\ub974\uac8c Faliover\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uba40\ud2f0 \uc18c\uc2a4 \ubcf5\uc81c\\n\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S1[Source 1]\\n A[Application Server] -- Read + Write --\x3e S2[Source 2]\\n A[Application Server] -- Read + Write --\x3e S3[Source 3]\\n A[Application Server] -- Read + Write --\x3e S4[Source 4]\\n S1 --\x3e R[Replica]\\n S2 --\x3e R[Replica]\\n S3 --\x3e R[Replica]\\n S4 --\x3e R[Replica]\\n```\\n\ud558\ub098\uc758 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ub2e4\uc218\uc758 \uc18c\uc2a4 \uc11c\ubc84\ub97c \uac16\ub294 \uad6c\uc131\uc785\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc0e4\ub529\uc744 \ud574\ub480\ub294\ub370, \ub2e4\uc2dc \ud558\ub098\uc758 \uc11c\ubc84\ub85c \ud1b5\ud569\ud558\uace0 \uc2f6\uc744 \ub54c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud639\uc740 \uc11c\ub85c \ub2e4\ub978 \ub370\uc774\ud130\ub97c \ud55c \uacf3\uc5d0 \ubc31\uc5c5\uc744 \ud560 \ub54c\ub3c4 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc758 \ud1a0\ud3f4\ub85c\uc9c0 \ubc29\uc2dd\\n\uadf8\ub7fc \uc774\ub807\uac8c\ub098 \ub9ce\uc740 \uad6c\uc131 \uc911\uc5d0 \uc800\ud76c \ud300\uc5d0\uc11c \ud0dd\ud560 \uc218 \uc788\ub294 \ud1a0\ud3f4\ub85c\uc9c0 \ubc29\uc2dd\uc740 \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74 \ubc29\uc2dd\uacfc \ub4c0\uc5bc \uc18c\uc2a4 \ubcf5\uc81c \ubc29\uc2dd \ubc16\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4. \uc65c\ub0d0\ud558\uba74 \uc8fc\uc5b4\uc9c4 \uc11c\ubc84\uac00 2\ub300\ubfd0\uc774\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \ub4c0\uc5bc \uc18c\uc2a4 \ubc29\uc2dd\uc740 \uc801\uc6a9\ud558\ub294\ub370 \ubb34\ub9ac\uac00 \uc788\ub294 \ubd80\ubd84\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc77c\ub2e8 \uc800\ud76c\uac00 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud558\ub824\ub294 \uac00\uc7a5 \ud070 \uc774\uc720\ub294 **\uc131\ub2a5** \uc774\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc774 \ubcc0\ud558\uc9c0 \uc54a\ub294 \ub4c0\uc5bc \uc18c\uc2a4\uc758 Active-Passive \ubc29\uc2dd\uc740 \uc81c\uc678\ud558\uaca0\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 Active-Active \ubc29\uc2dd\uc740 \ubd80\ud558\ub97c \ubd84\uc0b0\uc2dc\ud0ac \uc218 \uc788\ub2e4\ub294 \uc7a5\uc810\uc774 \uc788\uc9c0\ub9cc, \ub2e8\uc810\uc73c\ub85c\ub294 Auto Increment\ub97c \uc0ac\uc6a9\ud558\ub294\ub370\uc5d0 \uc704\ud5d8\uc774 \uc788\ub2e4\ub294 \uc810\uacfc, \ub370\uc774\ud130\uc758 \uc815\ud569\uc131 \ubb38\uc81c\uac00 \uc0dd\uae38 \uc218 \uc788\ub2e4\ub294 \uc810\uc5d0\uc11c \ub4c0\uc5bc \uc18c\uc2a4 \ubc29\uc2dd\uc740 \uc81c\uc678\ud558\ub3c4\ub85d \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74 \ubc29\uc2dd\uc744 \uc801\uc6a9\ud560 \uc218 \ubc16\uc5d0 \uc5c6\ub294\ub370\uc694. \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74\uc758 \ubc29\uc2dd\uc740 \uac00\uc6a9\uc131 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 \ub9cc\ub4e4\uc5b4\uc9c4 \ubc29\uc2dd\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \ud604\uc7ac \uac00\uc6a9\uc131\ubcf4\ub2e4 \uc131\ub2a5\uc744 \ub354 \uc2e0\uacbd\uc368\uc57c\ud558\ub294 \uc0c1\ud669\uc774\uae30\ub54c\ubb38\uc5d0 \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74 \ud1a0\ud3f4\ub85c\uc9c0\ub97c \uad6c\uc131\ud558\uc9c0\ub9cc \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub97c \uc608\ube44\uc6a9\uc774 \uc544\ub2cc \uc77d\uae30 \uc804\uc6a9 \ubc29\uc2dd\uc73c\ub85c \uc0ac\uc6a9\ud558\ub3c4\ub85d \ud558\uace0, \uac00\uc6a9\uc131 \ubd80\ubd84\uc744 \ud3ec\uae30\ud558\uae30\ub85c \uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n# \ucf54\ub4dc\uc5d0 \uc801\uc6a9\ud558\uae30\\n[replication-datasource](https://github.com/kwon37xi/replication-datasource) Github \uc18c\uc2a4 \ucf54\ub4dc\ub97c \ucc38\uace0\ud558\uc2dc\uac70\ub098, [DB \ubcf5\uc81c, @Transactional\uc5d0 \ub530\ub77c \uc694\uccad \ubd84\ub9ac\ud574\ubcf4\uae30](https://greeng00se.github.io/db-replication) \uae00\uc744 \ucc38\uace0\ud558\uc5ec \ub530\ub77c\ud558\uba74 \uae08\ubc29\ud558\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4!\\n\\n## \uacb0\ub860\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158 \uc0dd\uac01\ubcf4\ub2e4 \uc5b4\ub835\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc7ac\ubc0c\uc2b5\ub2c8\ub2e4. \uc778\ud504\ub77c\ub3c4 \uc7ac\ubc0c\uc2b5\ub2c8\ub2e4.\\n\\n## \ucc38\uace0\\nReal Mysql 8.0"},{"id":"31","metadata":{"permalink":"/31","source":"@site/blog/2023-09-03-improved-query-performance.mdx","title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4","date":"2023-09-03T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 3\uc77c","tags":[{"label":"mysql","permalink":"/tags/mysql"}],"readingTime":13.275,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"31","title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","authors":["boxster"],"tags":["mysql"]},"prevItem":{"title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/32"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","permalink":"/30"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\uac8c \ub41c \uacc4\uae30\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \uce74\ud398\uc778 \ud300 \ud504\ub85c\uc81d\ud2b8\uc5d0\ub294 \uc0ac\uc6a9\uc790\uac00 \ubcf4\uace0\uc788\ub294 \uc9c0\ub3c4\uc5d0 \ucda9\uc804\uc18c\ub97c \ubcf4\uc5ec\uc8fc\ub294 \uc870\ud68c \uae30\ub2a5\uc774 \uac00\uc7a5 \uc911\uc694\ud558\uace0, \uc81c\uc77c \uc694\uccad\uc774 \ub9ce\uc774 \ub4e4\uc5b4\uc635\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc870\ud68c \uc131\ub2a5\uc774 \uc88b\uc9c0 \uc54a\uc740 \uae4c\ub2ed\uc778\uc9c0 \uc5ec\ub7ec \uc0ac\uc6a9\uc790\uac00 \uc811\uc18d\ud558\uba74 \uc544\ub798\uc640 \uac19\uc774 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc2e4\ud589\ub418\uace0 \uc788\ub294 \uc11c\ubc84\uc758 cpu \uc0ac\uc6a9\ub960\uc774 100%\uac00 \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n![cpu](https://github.com/drunkenhw/drunkenhw.github.io/assets/106640954/2330435f-17b4-4d38-b16b-c72fd7017969)\\n\\n## \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30\\n\\n\uba3c\uc800 \uc81c\uac00 \uac1c\uc120\ud558\uae30 \uc704\ud574 \uc0ac\uc6a9\ud588\ub358 \ubc29\ubc95\ub4e4\uc5d0 \ub300\ud574 \uc801\uc5b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### DTO \uc774\uc6a9\ud558\uae30\\n\\n\ud604\uc7ac \uad6c\uc870\ub294 \uc544\ub798\uc758 JPA\ub97c \uc774\uc6a9\ud574 \uc544\ub798\uc640 \uac19\uc740 \ucffc\ub9ac\ub85c entity\ub85c \ub370\uc774\ud130\ub97c \uc870\ud68c\ud569\ub2c8\ub2e4.\\n\\n```sql\\n select distinct station.station_id,\\n charger.charger_id,\\n charger.station_id,\\n chargerStatus.charger_id,\\n chargerStatus.station_id,\\n station.created_at,\\n station.updated_at,\\n station.address,\\n station.company_name,\\n station.contact,\\n station.detail_location,\\n station.is_parking_free,\\n station.is_private,\\n station.latitude,\\n station.longitude,\\n station.operating_time,\\n station.private_reason,\\n station.station_name,\\n station.station_state,\\n charger.created_at,\\n charger.updated_at,\\n charger.capacity,\\n charger.method,\\n charger.price,\\n charger.type,\\n charger.station_id,\\n charger.charger_id,\\n chargerStatus.created_at,\\n chargerStatus.updated_at,\\n chargerStatus.charger_condition,\\n chargerStatus.latest_update_time\\n from charge_station station\\n inner join\\n charger charger on station.station_id = charger.station_id\\n inner join\\n charger_status chargerStatus on charger.charger_id = chargerStatus.charger_id\\n and charger.station_id = chargerStatus.station_id\\n where station.latitude >= 37.5019194727953082567\\n and station.latitude <= 37.5092305272047217433\\n and station.longitude >= 127.044542269049714936\\n and station.longitude <= 127.058071330950285064\\n\\n```\\n\\nJPA\ub97c \ud1b5\ud574 \uc774\ub7ec\ud55c \ubc29\uc2dd\uc73c\ub85c \uc870\ud68c\ud55c\ub2e4\uba74 \uc544\uc8fc \ud3b8\ud558\uac8c \uac12\uc744 \uac00\uc838\uc624\uace0, fetch join\uc744 \ud1b5\ud574 \ud558\uc704\uc758 entity\ub4e4\uc758 \uc815\ubcf4\ub3c4 \uae54\ub054\ud558\uac8c \uac00\uc838\uc635\ub2c8\ub2e4.\\n\\n\uac00\uc838\uc628 \uac12\uc73c\ub85c \ud544\uc694\ud55c \uc815\ubcf4\ub4e4\uc744 \ub9e4\ud551\ud558\uace0 \uac00\uacf5\ud558\uc5ec \uc751\ub2f5\uc744 \ub0b4\ub824\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc870\ud68c\ub9cc\uc744 \uc704\ud574 JPA\uc758 entity\ub97c \uc870\ud68c\ud55c\ub2e4\ub294 \uac83\uc740 \uc5ec\ub7ec \ub2e8\uc810\uc774 \uc874\uc7ac\ud569\ub2c8\ub2e4.\\n\\n\uc81c\uc77c \uba3c\uc800 \uc751\ub2f5\uc744 \ub0b4\ub824\uc904 \ub54c \ubd88\ud544\uc694\ud55c \ub370\uc774\ud130\uae4c\uc9c0 \ubaa8\ub450 \uc870\ud68c\ub97c \ud55c\ub2e4\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ub9ce\uc740 \ud544\ub4dc\ub4e4\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc751\ub2f5\uc5d0\uc11c\ub294 \ub300\ubd80\ubd84\uc758 \uacbd\uc6b0 \ubaa8\ub4e0 \uc815\ubcf4\uac00 \ud544\uc694\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ubaa8\ub4e0 \uc815\ubcf4\ub97c \ub2e4 \ubcf4\ub0b4\uc8fc\ub294 \uac83\ub3c4 \uc88b\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c\uc758 \uc131\ub2a5\uc774 \uc544\uc8fc \ub098\ube60\uc9d1\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud544\uc694\ud55c \uce7c\ub7fc\ub9cc \uc870\ud68c\ud558\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub610 \ub2e4\ub978 \ub2e8\uc810\uc73c\ub85c\ub294 JPA\ub85c entity\ub97c \uc870\ud68c\ud560 \ub54c Hibernate \uce90\uc2dc\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub358\uac00, One To One \uc5d0\uc11c N+1 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud558\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc801\uc778 \uc774\uc288\uac00 \uc5ec\ub7ec\uac00\uc9c0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc870\ud68c\ub9cc \ud558\ub294 api\ub77c\uba74 DTO Projection\uc73c\ub85c \ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \uc544\ub798\uc640 \uac19\uc774 \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n```sql\\nSELECT s.station_id,\\n s.station_name,\\n s.latitude,\\n s.longitude,\\n s.is_parking_free,\\n s.is_private,\\n sum(case\\n when cs.charger_condition = \'STANDBY\' then 1\\n else 0\\n end),\\n sum(case\\n when c.capacity >= 50 then 1\\n else 0\\n end)\\nFROM charge_station s\\n inner join charger c on (c.station_id = s.station_id)\\n inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)\\nwhere s.station_id in (?, ?)\\ngroup by s.station_id;\\n```\\n\\n\uc774\ub807\uac8c \ud544\uc694\ud55c \uce7c\ub7fc\ub9cc \uc870\ud68c\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \ubcc0\uacbd\ud558\uc5ec, \uc120\ub989\uc5ed \uadfc\ucc98\ub97c \uc870\ud68c\ud558\ub294 \uae30\uc900\uc73c\ub85c \uc57d 450ms -> 350ms\ub85c \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc544\uc9c1\ub3c4 \ub108\ubb34 \ub290\ub9b0 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub798\uc11c \uc2e4\ud589 \uacc4\ud68d\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \uc2e4\ud589 \uacc4\ud68d \ud655\uc778\ud558\uae30\\n\\nsql\uc758 \uc2e4\ud589 \uacc4\ud68d\uc740 \uc544\uc8fc \uc911\uc694\ud558\uace0 \uc131\ub2a5\uc744 \uac1c\uc120\ud560 \ub54c \uc544\uc8fc \uc720\uc6a9\ud569\ub2c8\ub2e4.\\n\\n\uc2e4\ud589 \uacc4\ud68d\uc5d0\ub294 \uc5ec\ub7ec\uac00\uc9c0 \uc815\ubcf4\ub4e4\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n1. **ID**: \uc2e4\ud589 \uacc4\ud68d \ub0b4\uc5d0\uc11c \uac01 \uc791\uc5c5 \ub610\ub294 \ub2e8\uacc4\ub97c \uc2dd\ubcc4\ud558\ub294 \uc77c\ub828\ubc88\ud638\uc785\ub2c8\ub2e4. \uc2e4\ud589 \uacc4\ud68d\uc740 \uc5ec\ub7ec \ub2e8\uacc4\ub85c \ub098\ub258\uba70, ID\ub97c \ud1b5\ud574 \uc774\ub7ec\ud55c \ub2e8\uacc4\ub97c \uc2dd\ubcc4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n2. **Select Type**: \ucffc\ub9ac\uc758 \uac01 \ub2e8\uacc4(\uc608: SIMPLE, PRIMARY, SUBQUERY)\uc5d0 \ub300\ud55c \uc2e4\ud589 \uc720\ud615\uc744 \ub098\ud0c0\ub0c5\ub2c8\ub2e4. \uc774\ub294 MySQL\uc774 \ub370\uc774\ud130\ub97c \uc120\ud0dd\ud558\uace0 \ucc98\ub9ac\ud558\ub294 \ubc29\uc2dd\uc744 \ub098\ud0c0\ub0c5\ub2c8\ub2e4.\\n\\n3. **Table**: \uc2e4\ud589 \uacc4\ud68d\uc5d0 \ud3ec\ud568\ub41c \ud14c\uc774\ube14\uc758 \uc774\ub984 \ub610\ub294 \ubcc4\uce6d\uc785\ub2c8\ub2e4. \uc5b4\ub5a4 \ud14c\uc774\ube14\uc774 \uc0ac\uc6a9\ub418\ub294\uc9c0\ub97c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n4. **Type**: \ud14c\uc774\ube14 \uc811\uadfc \ubc29\uc2dd\uc744 \ub098\ud0c0\ub0c5\ub2c8\ub2e4. \uc774 \uac12\uc740 \uc778\ub371\uc2a4 \uc2a4\uce94, \ud480 \ud14c\uc774\ube14 \uc2a4\uce94 \ub4f1\uacfc \uac19\uc740 \uac12\uc77c \uc218 \uc788\uc73c\uba70, \uc131\ub2a5\uc5d0 \ud070 \uc601\ud5a5\uc744 \ubbf8\uce69\ub2c8\ub2e4.\\n\\n5. **Possible Keys**: \uc0ac\uc6a9 \uac00\ub2a5\ud55c \uc778\ub371\uc2a4\ub97c \ub098\ud0c0\ub0c5\ub2c8\ub2e4. MySQL\uc774 \uc5b4\ub5a4 \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294\uc9c0 \uc54c\ub824\uc90d\ub2c8\ub2e4.\\n\\n6. **Key**: \uc2e4\uc81c\ub85c \uc120\ud0dd\ub41c \uc778\ub371\uc2a4\uc785\ub2c8\ub2e4. \uc774 \uac12\uc740 \uac00\ub2a5\ud55c \uc778\ub371\uc2a4 \uc911\uc5d0\uc11c \uc2e4\uc81c\ub85c \uc0ac\uc6a9\ub418\ub294 \uc778\ub371\uc2a4\ub97c \ub098\ud0c0\ub0c5\ub2c8\ub2e4.\\n\\n7. **Key Len**: \uc0ac\uc6a9\ub41c \uc778\ub371\uc2a4\uc758 \uae38\uc774\ub97c \ub098\ud0c0\ub0c5\ub2c8\ub2e4.\\n\\n8. **Ref**: \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc5ec \ud14c\uc774\ube14 \uac04\uc758 \uc5f0\uacb0\uc744 \ub098\ud0c0\ub0b4\ub294 \uc5f4\uc785\ub2c8\ub2e4.\\n\\n9. **Rows**: \uac01 \ub2e8\uacc4\uc5d0\uc11c \uc608\uc0c1\ub418\ub294 \ud589\uc758 \uc218\uc785\ub2c8\ub2e4. \uc774 \uac12\uc740 \uc131\ub2a5 \ud3c9\uac00\uc5d0 \uc911\uc694\ud55c \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n10. **Extra**: \uae30\ud0c0 \uc815\ubcf4\ub97c \uc81c\uacf5\ud569\ub2c8\ub2e4. \uc774 \uce7c\ub7fc\uc5d0\ub294 \ucd94\uac00 \uc815\ubcf4 \ubc0f \ud78c\ud2b8\uac00 \ud3ec\ud568\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \uc5ec\ub7ec \uce7c\ub7fc\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uadf8 \uc911 \uc131\ub2a5\uc5d0 \ud070 \uc601\ud5a5\uc744 \ubbf8\uce58\ub294 \uce7c\ub7fc \ub450 \uac00\uc9c0\ub9cc \uc790\uc138\ud788 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### Type\\n1. **const** : \ucffc\ub9ac\uc5d0 Primary key \ud639\uc740 unique key \uce7c\ub7fc\uc744 \uc774\uc6a9\ud558\ub294 where \uc870\uac74\uc808\uc744 \uac00\uc9c0\uace0 \uc788\uace0, \ubc18\ub4dc\uc2dc \ud558\ub098\uc758 \ub370\uc774\ud130\ub97c \ubc18\ud658\ud558\ub294 \ubc29\uc2dd\uc774\ub2e4. (\uc635\ud2f0\ub9c8\uc774\uc800\uac00 \ud574\ub2f9 \ubd80\ubd84\uc740 \uc0c1\uc218\ub85c \ucc98\ub9ac\ud558\uae30 \ub54c\ubb38\uc5d0 const\ub77c\uace0 \ud55c\ub2e4.)\\n2. **eq_ref** : \uc870\uc778\uc5d0\uc11c Primary key \ud639\uc740 unique key \uce7c\ub7fc\uc744 \uc774\uc6a9\ud558\ub294 where \uc870\uac74\uc808\uc744 \uac00\uc9c0\uace0 \uc788\uace0, \ubc18\ub4dc\uc2dc \ud558\ub098\uc758 \ub370\uc774\ud130\ub97c \ubc18\ud658\ud558\ub294 \ubc29\uc2dd\uc774\ub2e4. (const\uc640 \ub2e4\ub978 \uc810\uc740 eq_ref\ub294 \uc870\uc778\uc5d0\uc11c \uc0ac\uc6a9\ub41c\ub2e4\ub294 \uc810\uc774\ub2e4.)\\n3. **ref** : eq_ref\uc640 \ub2e4\ub974\uac8c join\uc758 \uc21c\uc11c\uc640 \uad00\uacc4\uc5c6\uc774 \uc0ac\uc6a9\ub41c\ub2e4. \uadf8\ub9ac\uace0 primary key, unique key\ub3c4 \uad00\uacc4\uc5c6\ub2e4. \uadf8\ub0e5 \uc778\ub371\uc2a4\uc758 \uc885\ub958\uc640 \uad00\uacc4\uc5c6\uc774 `=` \uc870\uac74\uc73c\ub85c \uac80\uc0c9\ud560 \ub54c \uc0ac\uc6a9\ub41c\ub2e4\\n4. **fulltext**: mysql \uc804\ubb38 \uac80\uc0c9 \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud574\uc11c \ub808\ucf54\ub4dc\uc5d0 \uc811\uadfc\ud558\ub294 \ubc29\ubc95, \uc804\ubb38 \uac80\uc0c9\ud560 \uceec\ub7fc\uc5d0 \uc778\ub371\uc2a4\uac00 \uc788\uc5b4\uc57c \ud55c\ub2e4. \\"MATCH ... AGAINST ...\\" \uad6c\ubb38\uc744 \uc0ac\uc6a9\ud574\uc11c \uc2e4\ud589\ub41c\ub2e4\\n5. **range**: \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574\uc11c \uac80\uc0c9\ud558\ub294\ub370, \uac80\uc0c9 \uc870\uac74\uc774 `>, >=, <, <=, BETWEEN, IN()` \ub4f1\uc758 \uc5f0\uc0b0\uc790\ub97c \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0\uc774\ub2e4. \ubcf4\ud1b5\uc758 \uc778\ub371\uc2a4 \uc2a4\uce94\uc774\ub77c\uace0 \ud558\uba74 range, const, ref\ub97c \uce6d\ud55c\ub2e4\\n6. **index**: \uc778\ub371\uc2a4 \ud480 \uc2a4\uce94\uc774\ub2e4. \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574\uc11c \ud14c\uc774\ube14\uc758 \ubaa8\ub4e0 \ub808\ucf54\ub4dc\ub97c \uc77d\ub294\ub2e4. \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574\uc11c \ud14c\uc774\ube14\uc744 \uc77d\ub294 \uac83\uc774\uae30 \ub54c\ubb38\uc5d0 all\ubcf4\ub2e4\ub294 \ube60\ub974\ub2e4.\\n7. **all**: \ud14c\uc774\ube14 \ud480 \uc2a4\uce94\uc774\ub2e4. \ud14c\uc774\ube14\uc758 \ubaa8\ub4e0 \ub808\ucf54\ub4dc\ub97c \uc77d\ub294\ub2e4. \uac00\uc7a5 \ub290\ub9b0 \ubc29\ubc95\uc774\ub2e4.\\n\\n\uc2e4\ud589 \uacc4\ud68d\uc5d0\uc11c \uc790\uc8fc \ubcf4\uc774\ub294 type\ub4e4\ub9cc **\uc131\ub2a5\uc774 \uc88b\uc740 \uc21c**\uc73c\ub85c \uc815\ub9ac\ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n### Extra\\n1. **using filesort**: \uc815\ub82c\uc744 \uc704\ud574 \ubcc4\ub3c4\uc758 \ud30c\uc77c \uc815\ub82c\uc744 \uc218\ud589\ud55c\ub2e4. \uc774\ub294 \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \uc815\ub82c\uc744 \uc218\ud589\ud55c\ub2e4\ub294 \uc758\ubbf8\uc774\ub2e4. \uc774\ub294 \uc131\ub2a5\uc5d0 \uc88b\uc9c0 \uc54a\ub2e4.\\n2. **using index**: \uc778\ub371\uc2a4\ub9cc\uc73c\ub85c \ucffc\ub9ac\ub97c \ucc98\ub9ac\ud55c\ub2e4. \uc774\ub294 \uc778\ub371\uc2a4\ub9cc\uc73c\ub85c \ucffc\ub9ac\ub97c \ucc98\ub9ac\ud558\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc774 \uc88b\ub2e4.\\n3. **using join** buffer: join\uc774 \ub418\ub294 \uce7c\ub7fc\uc740 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4. \ud558\uc9c0\ub9cc driven table\uc5d0 \uc801\uc808\ud55c \uc778\ub371\uc2a4\uac00 \uc5c6\ub2e4\uba74 driving table\uc5d0 \uc788\ub294 \ubaa8\ub4e0 \ub808\ucf54\ub4dc\ub97c \uc77d\uc5b4\uc11c join\uc744 \uc218\ud589\ud55c\ub2e4. \uadf8\ub798\uc11c \uc774\uac78 \ubcf4\uc644\ud558\uae30 \uc704\ud574 driving table\uc5d0 \uc77d\uc740 \ub808\ucf54\ub4dc\ub97c \uc784\uc2dc \uacf5\uac04\uc5d0 \uc800\uc7a5\ud558\ub294\ub370 \uadf8 \uacf3\uc774 join buffer\uc774\ub2e4.\\n4. **using temporary**: \ucffc\ub9ac\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud574 \uc784\uc2dc \ud14c\uc774\ube14\uc744 \uc0dd\uc131\ud55c\ub2e4. \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc9c0 \ubabb\ud558\ub294 group by \ucffc\ub9ac\uac00 \ub300\ud45c\uc801\uc778 \uc608\uc774\ub2e4.\\n5. **using where**: mysql \uc5d4\uc9c4\uc774 \ubcc4\ub3c4\uc758 \uac00\uacf5, \ud544\ud130\ub9c1 \uc791\uc5c5\uc744 \ucc98\ub9ac\ud55c \uacbd\uc6b0\uc77c \ub54c\ub9cc \ub098\ud0c0\ub09c\ub2e4. \ubc94\uc704 \uc870\uac74\uc740 \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4\uc5d0\uc11c \ucc98\ub9ac\ub418\uc5b4 \ub808\ucf54\ub4dc\ub97c \ub9ac\ud134\ud574\uc8fc\uc9c0\ub9cc, \uccb4\ud06c \uc870\uac74\uc740 mysql \uc5d4\uc9c4\uc5d0\uc11c \ucc98\ub9ac\ub41c\ub2e4.\\n\\n\\ntype\ubfd0\ub9cc \uc544\ub2c8\ub77c extra\ub3c4 \ucffc\ub9ac\uc758 \ubb38\uc81c\ub97c \ud30c\uc545\ud558\ub294\ub370 \uc544\uc8fc \ud070 \ub3c4\uc6c0\uc744 \uc90d\ub2c8\ub2e4. \uadf8 \uc911 \uc790\uc8fc \ubcf4\uc774\ub294 \uac83\ub4e4\uc5d0 \ub300\ud574\uc11c\ub9cc \uc815\ub9ac\ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc544\uae4c \uc0dd\uc131\ud55c \ucffc\ub9ac\uc758 \uc2e4\ud589 \uacc4\ud68d\uc744 \ud655\uc778\ud574\ubd05\uc2dc\ub2e4.\\n```\\n+----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+\\n| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |\\n+----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+\\n| 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where; Using temporary |\\n| 1 | SIMPLE | charger | NULL | ALL | PRIMARY | NULL | NULL | NULL | 240340 | 10.00 | Using where; Using join buffer (hash join) |\\n| 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |\\n+----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+\\n```\\n\\nstation \ud14c\uc774\ube14\uc5d0 \ub300\ud574\uc11c\ub294 range \uc2a4\uce94, \uc784\uc2dc \ud14c\uc774\ube14\uc744 \uc0dd\uc131\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4, \uadf8\ub9ac\uace0 charger\uc5d0\uc11c\ub294 \ud14c\uc774\ube14 \ud480 \uc2a4\uce94, join buffer\uae4c\uc9c0 \uc0dd\uc131\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \ub2e4\ud589\ud788\ub3c4 chargersta \ud14c\uc774\ube14\uc5d0\uc11c\ub294 \uc801\ub2f9\ud55c \uc870\uac74\uc744 \uc0dd\uc131\ud55c \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\uc2dc \ud55c\ubc88 \ucffc\ub9ac\ub97c \ubcf4\uace0 \uc2e4\ud589 \uacc4\ud68d\uc774 \uc774\ub807\uac8c \ub098\uc628 \uc774\uc720\ub97c \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```sql\\nSELECT\\n ...\\n FROM charge_station s\\n inner join charger c on (c.station_id = s.station_id)\\n inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)\\nwhere s.station_id in (?, ?)\\ngroup by s.station_id;\\n```\\n\\n\uc544\uae4c \uc598\uae30\ud588\ub358, using temporary\uc640 using join buffer\uac00 \ubc1c\uc0dd\ud558\ub294 \uc774\uc720\uc758 \uacf5\ud1b5\uc810\uc744 \ucc3e\uc544\ubcf4\uba74, \uc778\ub371\uc2a4\uac00 \ubb38\uc81c\uc778 \uac83\uc744 \uc720\ucd94\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nstation\uacfc charger\ub97c join\ud560 \ub54c, driven table \uc989, charger \ud14c\uc774\ube14\uc5d0 \uc801\uc808\ud55c \uc778\ub371\uc2a4\uac00 \uc5c6\uc5b4 \uc131\ub2a5\uc774 \ub098\ube60\uc9c4 \uac83\uc774\ub77c \uc758\uc2ec\ud558\uc5ec, \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud558\uace0 \ub2e4\uc2dc \ud55c\ubc88 \uc2e4\ud589 \uacc4\ud68d\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```\\n+----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+\\n| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |\\n+----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+\\n| 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where |\\n| 1 | SIMPLE | charger | NULL | ref | PRIMARY,idx_station_id | idx_station_id | 1022 | charge.s.station_id | 3 | 100.00 | NULL |\\n| 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |\\n+----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+\\n```\\n\\n\uc774\ub807\uac8c charger \ud14c\uc774\ube14\uc5d0 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud55c \uac83\ub9cc\uc73c\ub85c\ub3c4 \uc2e4\ud589 \uacc4\ud68d\uc744 \uae54\ub054\ud558\uac8c \uac1c\uc120\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \uacb0\uacfc\\n\uc544\ub798\ub294 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud558\uae30 \uc804 \uc2e4\ud589 \uc18d\ub3c4\uc785\ub2c8\ub2e4.\\n\\n![\uac1c\uc120_\uc804](https://github.com/woowacourse-teams/2023-car-ffeine/assets/106640954/1130eee6-c2b9-4846-b294-73de78b0f070)\\n\\n\uc544\ub798\ub294 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud55c \ud6c4 \uc2e4\ud589 \uc18d\ub3c4\uc785\ub2c8\ub2e4.\\n\\n![\uac1c\uc120_\ud6c4](https://github.com/woowacourse-teams/2023-car-ffeine/assets/106640954/d024330a-c233-4e75-a28b-1b01b6ae3245)\\n\\n315ms -> 24ms \ub85c \uc57d 13\ubc30 \ube68\ub77c\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\n**\uc2e4\ud589 \uacc4\ud68d \ud655\uc778\uc740 \ud544\uc218\uc785\ub2c8\ub2e4!**\\n\\n### \ucc38\uace0\\nreal mysql \ucc45"},{"id":"30","metadata":{"permalink":"/30","source":"@site/blog/2023-08-31-love-my-team.mdx","title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","description":"\ub808\ubca83 \ub54c \ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c, \uc800\ud76c \ud300\uc740 \ub9ce\uc740 \ud611\uc5c5\uc744 \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.","date":"2023-08-31T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 31\uc77c","tags":[],"readingTime":2.895,"hasTruncateMarker":false,"authors":[],"frontMatter":{"slug":"30","title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","authors":[],"tags":[]},"prevItem":{"title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/31"},"nextItem":{"title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","permalink":"/29"}},"content":"\ub808\ubca83 \ub54c \ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c, \uc800\ud76c \ud300\uc740 \ub9ce\uc740 \ud611\uc5c5\uc744 \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ucc98\uc74c\uc5d0\ub294 \ud504\ub860\ud2b8\uc5d4\ub4dc, \ubc31\uc5d4\ub4dc \uc11c\ub85c \uac01\uac01\uc758 \ubd84\uc57c\ub9cc \uac1c\ubc1c\uc744 \ud574\uc654\uace0 \ud611\uc5c5\uc774 \uc775\uc219\ud558\uc9c0 \uc54a\uc544\uc11c \ub9ce\uc740 \ubd80\ubd84\uc5d0\uc11c \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uace4 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc5d0\uc11c \uc800\ud76c \ud300\uc740 \uc5b4\ub5bb\uac8c \ub300\ucc98\ub97c \ud588\uc744\uae4c\uc694?\\n\\n\ud55c \uac00\uc9c0 \uc77c\ud654\ub85c \uc800\ud76c \ud300\uc758 \uc81c\uc774\uc640 \uc13c\ud2b8\uc758 \ud544\ud130 \uc801\uc6a9 \ubd80\ubd84\uc744 \uc124\uba85 \ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc870\ud68c \uc2dc\uc5d0 \ud544\ud130 \uc801\uc6a9 \ubd80\ubd84\uc744 \ub9cc\ub4e4 \ub54c \uae30\uc874\uc5d0 \uc791\uc131\ud574\ub454 API \uba85\uc138\ub300\ub85c \uc11c\ub85c \uc791\uc5c5\uc744 \uc9c4\ud589\ud558\uace0, \uc911\uac04\uc5d0 \uc0dd\uac01\ud558\uc9c0 \ubabb\ud55c \ubd80\ubd84\uc5d0 \ub300\ud574\uc11c\ub294 \uc11c\ub85c \ub300\ud654\ub97c \ub9ce\uc774 \ud588\uc2b5\ub2c8\ub2e4.\\n\ub300\ud654\ub97c \ud558\uba74\uc11c \uc9c4\ud589\uc744 \ud588\uc9c0\ub9cc \ubc1c\uacac\ud558\uc9c0 \ubabb\ud55c \ubb38\uc81c\uc810\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubc14\ub85c \ucda9\uc804\uc18c \ud68c\uc0ac \uba85\uc5d0\uc11c key \uac12\uc744 \uc5b4\ub5bb\uac8c \ud558\ub0d0\uc5d0 \ubb38\uc81c\uc600\uc2b5\ub2c8\ub2e4.\\n\uc608\ub97c \ub4e4\uba74 \ucda9\uc804\uc18c \ud68c\uc0ac \uba85\uc5d0\uc11c `\uad11\uc8fc\uc2dc`\ub77c\ub294 \uc774\ub984\uc774 \uc788\uc5c8\ub294\ub370, \uc774 \ud544\ud130\ub294 \uc2e4\uc81c\ub85c \ub450 \uac00\uc9c0\uac00 \uc874\uc7ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\ub098\ub294 \uacbd\uae30\ub3c4 \uad11\uc8fc, \ud558\ub098\ub294 \uc804\ub77c\ub3c4 \uad11\uc8fc\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubd80\ubd84\uc5d0\uc11c \ubd88\ud544\uc694\ud55c \uc9c0\uc5ed\uc758 \ud544\ud130\uae4c\uc9c0 \uac78\ub9ac\uac8c \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ud611\uc5c5\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uc774\ub97c \ubc1c\uacac\ud588\uace0, \uc989\uac01 \uc870\uce58\ub97c \ucde8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc870\uce58\ub97c \ucde8\ud560 \ub54c \uc11c\ub85c\uc5d0\uac8c \uac01\uc790 \ud3b8\ud55c \ubc29\ubc95\uc774 \uc788\uc5c8\uc9c0\ub9cc,\\n\ub2e8\uc21c\ud788 \uc11c\ub85c\uc5d0\uac8c \ud3b8\ud55c \uc791\uc5c5\uc744 \ud558\uc9c0 \uc54a\uc558\uace0, \ud300\uc6d0\uacfc \uc0c1\uc758\ud558\uba74\uc11c \ucd94\ud6c4 \uc9c4\ud589\uc5d0 \ubb38\uc81c \uc5c6\ub294 \ubc29\ud5a5\uc744 \ucc3e\uace0 \uc9c4\ud589\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\uae08 \uc0dd\uac01\ud574\ubcf4\uba74 \ub9cc\uc57d \uac01\uc790\uc5d0\uac8c \ud3b8\ud55c \ubc29\uc2dd\uc73c\ub85c \ubb38\uc81c\ub97c \uc218\uc815\ud588\ub2e4\uba74, \ub2e4\ub978 \ud300\uc6d0\uc774 \ub2e4\ub978 \uc791\uc5c5\uc744 \ud560 \ub54c \uc9c0\uc7a5\uc774 \uac14\uc744 \uc218\ub3c4 \uc788\uace0 \ubd88\ud544\uc694\ud55c \uc791\uc5c5\uc744 \ud588\uc744 \uc218\ub3c4 \uc788\uc5c8\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc2dc\uc810\uc744 \uacc4\uae30\ub85c \uc800\ud76c \ud300\ub07c\ub9ac \uc608\uc0c1\ud558\uc9c0 \ubabb\ud55c \ubb38\uc81c\ub97c \uc791\uc5c5 \uc911\uc5d0 \ubc1c\uacac\ud558\ub354\ub77c\ub3c4 \ub2e4\ub978 \ud300\uc6d0\uc5d0\uac8c \uacf5\uc720\ud558\uace0 \uc11c\ub85c \uc9e7\uc740 \ud68c\uc758\ub97c \ud1b5\ud574 \ubb38\uc81c \ud574\uacb0 \ubc29\uc548\uc744 \uac19\uc774 \ucc3e\ub294 \uac83\uc774 \uc790\uc5f0\uc2a4\ub7fd\uac8c \ud300\ubb38\ud654\ub85c \uc790\ub9ac \uc7a1\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4."},{"id":"29","metadata":{"permalink":"/29","source":"@site/blog/2023-08-25-external-state/index.mdx","title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","description":"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub3c4\uc640 React\ub97c \uacb0\ud569\uc744 \ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4.","date":"2023-08-25T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 25\uc77c","tags":[{"label":"useSyncExternalStore","permalink":"/tags/use-sync-external-store"},{"label":"\uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac","permalink":"/tags/\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac"},{"label":"\uc804\uc5ed\uc0c1\ud0dc","permalink":"/tags/\uc804\uc5ed\uc0c1\ud0dc"}],"readingTime":10.165,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"29","title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","authors":["gabriel"],"tags":["useSyncExternalStore","\uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac","\uc804\uc5ed\uc0c1\ud0dc"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","permalink":"/30"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","permalink":"/28"}},"content":"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub3c4\uc640 React\ub97c \uacb0\ud569\uc744 \ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud504\ub85c\uc81d\ud2b8 \ucd08\uae30\uc5d0\ub294 Google Maps API\ub97c React DOM\uc774 \uc544\ub2cc, \ubc14\ub2d0\ub77c JS\uc758 \uc601\uc5ed\uc5d0\uc11c \ub2e4\ub8e8\uae30\ub97c \ud76c\ub9dd\ud558\uc600\uace0, \uc5ec\ub7ec \ud14c\uc2a4\ud2b8 \uacb0\uacfc \ub450 \uc601\uc5ed\uc744 \ubd84\ub9ac\ud558\ub294 \uac83\uc740 \uc131\uacf5\uc801\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\nReact\ub294 \uadf8\uc800 \ubd80\ucc29 \ub2f9\ud560 DOM\uc744 \uc678\ubd80(Google Maps API)\ub85c \ub0b4\uc5b4\uc8fc\ub294 \uae30\ub2a5\uc5d0 \ubd88\uacfc\ud558\uc600\uace0, \uc9c0\ub3c4\uc640 React\uac00 \uc11c\ub85c \ud611\ub825 \ud574\uc57c\ud560 \ub54c\ub9cc \uc5f0\ub77d\uc744 \ud558\ub294 \uad6c\uc870\ub97c \ucde8\ud558\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uba74, React UI\ub294 UI\ub300\ub85c \ub3d9\uc791\ud558\uace0, \uc9c0\ub3c4\ub294 \uc9c0\ub3c4 \ub300\ub85c \ub3d9\uc791\ud558\ub2e4\uac00 \uc5b4\ub290 \uc21c\uac04\uc5d0\ub9cc \uc11c\ub85c\uac00 \uc11c\ub85c\ub97c \uc870\uc791\ud560 \uc218 \uc788\uc73c\uba74 \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \uac00\ub2a5\ud558\uac8c \ud558\ub294 \uae30\uc220\ub85c useSyncExternalStore\ub97c \uc120\uc815\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4. \uc774 \ud6c5\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\uc81c \ube14\ub85c\uadf8](https://leirbag.tistory.com/144)\ub098 [\uacf5\uc2dd\ubb38\uc11c](https://react.dev/reference/react/useSyncExternalStore)\uc5d0 \ub098\uc640\uc788\uc73c\ubbc0\ub85c \uc124\uba85\uc744 \uac04\ub7b5\ud788 \ud558\uc790\uba74 useSyncExternalStore\ub294 React DOM \ub0b4\ubd80\uac00 \uc544\ub2cc \uc678\ubd80 \uc800\uc7a5\uc18c(JS)\uc5d0\uc11c React DOM\uc744 \uc870\uc791\ud560 \uc218 \uc788\ub3c4\ub85d \ud558\ub294 \ucee4\uc2a4\ud140 \ud6c5\uc785\ub2c8\ub2e4.\\n\\n![no offset](./0-1.png)\\n\\n\uc774 \ud6c5\uc740 React 18\uc5d0 \ucd9c\uc2dc\ub418\uc5c8\uc73c\uba70, \uc678\ubd80 \uc800\uc7a5\uc18c\uc640 React\uc758 \uc18c\ud1b5\uc744 \uc6d0\ud65c\ud558\uac8c \ub3d5\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \ud65c\uc6a9\ud558\uae30 \uc801\uc808\ud558\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4. \uc774 \uae30\ub2a5\uc744 \uc5b4\ub5bb\uac8c \ud558\uba74 \ub354 \ud6a8\uc728\uc801\uc778 \ubc29\ubc95\uc73c\ub85c \uc7ac\uc0ac\uc6a9\ud560 \uc218 \uc788\uc744\uc9c0 \uace0\ubbfc\ud558\uc600\uace0, \uc5ec\ub7ec \ucd94\uc0c1\ud654 \ub2e8\uacc4\ub97c \uac70\uccd0 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc218\uc900\uc73c\ub85c \uc81c\uc791\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ud6c4\uc5d0 TanStack Query\ub97c \ub3c4\uc785\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uac01\uc885 \uae30\ub2a5\uc774 React Component \ub0b4\uc5d0\uc11c\ub9cc \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub3c4\ub85d \uac15\uc81c\ub418\uc5c8\uace0, \ub530\ub77c\uc11c \ub354\uc774\uc0c1 \uc9c0\ub3c4 API\ub97c \ubc14\ub2d0\ub77cJS \uc601\uc5ed\uc5d0\uc11c \ub2e4\ub8f0 \uc218 \uc5c6\uc5b4 React DOM\uc73c\ub85c \uc774\uc2dd \ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./0-2.png)\\n\\n\uc774\ubbf8 \ub9cc\ub4e4\uc5b4 \ub454 \uae30\ub2a5\uc774 \ubd95 \ub5a0\ubc84\ub9b0 \uc0c1\ud669\uc774\uc5c8\uc9c0\ub9cc \uc5b4\ucc0c \ub410\ub4e0 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\uc5d0 \uc9c0\ub3c4 \uc778\uc2a4\ud134\uc2a4\ub97c \ub123\uc5b4\uc57c \ud558\ub294 \uc0c1\ud669\uc774\ub77c useSyncExternalStore\ub97c \ud504\ub85c\uc81d\ud2b8 \ub05d\uae4c\uc9c0 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\ub85c\uc368 \uc0ac\uc6a9\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc0c1\ud0dc \uad00\ub9ac \ud6c5\uc758 \ucd94\uc0c1\ud654 \uacfc\uc815\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n## **use-external-state \uad6c\uc131 \ubc0f \ub3d9\uc791 \uc6d0\ub9ac**\\n\\n**Store\ub294 \uc0c1\ud0dc \uad00\ub9ac \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4**\\n\\n\ubc14\uae65\uc5d0\uc11c \uc8fc\uc5b4\uc9c4 \ucd08\uae30 \uc0c1\ud0dc \uac12\uc740 StateManager\ub77c\ub294 \ud074\ub798\uc2a4\uc5d0 \uc804\ub2ec\ub429\ub2c8\ub2e4.\\n\\n![no offset](./1.png)\\n\\n```typescript\\nexport const store = (initialState: T) => {\\n const stateManager = new StateManager(initialState);\\n return stateManager;\\n};\\n```\\n\\n\ucd08\uae30 \uc0c1\ud0dc \uac12\uc744 \uc804\ub2ec\ubc1b\uc740 store \ud568\uc218\ub294 StateManager\ub77c\ub294 \uc5b4\ub5a4 \uc0c1\ud0dc \uad00\ub9ac \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud569\ub2c8\ub2e4.\\n\uc0dd\uc131\ub41c StateManager \uc778\uc2a4\ud134\uc2a4\uac00 \ubc18\ud658\ub418\uc5b4 store\uac00 \uace7 \ucd08\uae30 \uac12\uc744 \uac00\uc9c0\ub294 StateManager\uac00 \ub429\ub2c8\ub2e4.\\n\\n![no offset](./2.png)\\n\\n\uc608\ub97c \ub4e4\uc5b4, \ub2e4\uc74c\uacfc \uac19\uc740 \ucf54\ub4dc\uac00 \uc788\ub2e4\uace0 \ud560 \ub54c\\n\\n```typescript\\nexport const countStore = store(0);\\n```\\n\\ncountStore\ub294 \uace7 0\uc744 \ucd08\uae30\uac12\uc73c\ub85c \uac00\uc9c0\ub294 StateManager \uc778\uc2a4\ud134\uc2a4\uc774\uae30\ub3c4 \ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 StateManager\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### StateManager\ub294 react \ubc14\uae65\uc5d0 \uc788\ub294 \uc5b4\ub5a4 \uc800\uc7a5\uc18c\uc774\ub2e4.\\n\\n(\uadfc\ub370 \uc774\uac8c \uadf8\ub0e5 \uc800\uc7a5\uc18c\ub294 \uc544\ub2c8\uace0 \uc880 \ud2b9\ubcc4\ud55c \uc800\uc7a5\uc18c\ub2e4.)\\n\\n```typescript\\nexport type SetStateCallbackType = (prevState: T) => T;\\n\\nexport interface DataObserver {\\n setState: (param: SetStateCallbackType | T) => void;\\n getState: () => T;\\n subscribe: (listener: () => void) => () => void;\\n emitChange: () => void;\\n}\\n\\nclass StateManager implements DataObserver {\\n public state: T;\\n private listeners: Array<() => void> = [];\\n\\n constructor(initialState: T) {\\n this.state = initialState;\\n }\\n\\n setState = (param: SetStateCallbackType | T) => {\\n if (param instanceof Function) {\\n const newState = param(this.state);\\n this.state = newState;\\n } else {\\n this.state = param;\\n }\\n\\n this.emitChange();\\n };\\n\\n getState = () => {\\n return this.state;\\n };\\n\\n subscribe = (listener: () => void) => {\\n this.listeners = [...this.listeners, listener];\\n\\n return () => {\\n this.listeners = this.listeners.filter((l) => l !== listener);\\n };\\n };\\n\\n emitChange = () => {\\n for (const listener of this.listeners) {\\n listener();\\n }\\n };\\n}\\n\\nexport default StateManager;\\n```\\n\\nStateManager \ud074\ub798\uc2a4\ub294 \uc678\ubd80\uc5d0\uc11c \ubc1b\uc544\uc628 \ucd08\uae30\uac12\uc744 \uc0c1\ud0dc\ub85c \uac00\uc9d1\ub2c8\ub2e4.\\nsetState, getState, subscribe, emitChange\ub97c \uba54\uc11c\ub4dc\ub85c \uac00\uc9d1\ub2c8\ub2e4.\\n\uc5ec\uae30\uc11c \uc791\uc131\ub41c \ucf54\ub4dc\ub4e4\uc740 react\uc5d0\uc11c \uc678\ubd80 \uc800\uc7a5\uc18c\uc640 \uc18c\ud1b5\ud558\uae30 \uc704\ud55c [\ucd5c\uc18c\ud55c\uc758 \uaddc\uaca9](https://react.dev/reference/react/useSyncExternalStore#subscribing-to-an-external-store)\uc785\ub2c8\ub2e4.\\n\\n- subscribe: \ub2e8\uc77c \ucf5c\ubc31 \uc778\uc218\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc2a4\ud1a0\uc5b4\uc5d0 \uad6c\ub3c5\ud558\ub294 \ud568\uc218\uc785\ub2c8\ub2e4. \uc2a4\ud1a0\uc5b4\uac00 \ubcc0\uacbd\ub418\uba74 \uc81c\uacf5\ub41c \ucf5c\ubc31\uc744 \ud638\ucd9c\ud574\uc57c \ud569\ub2c8\ub2e4. \uadf8\ub7ec\uba74 \uad6c\uc131 \uc694\uc18c\uac00 \ub2e4\uc2dc \ub80c\ub354\ub9c1 \ub429\ub2c8\ub2e4. \uad6c\ub3c5 \uae30\ub2a5\uc740 \uad6c\ub3c5\uc744 \uc815\ub9ac\ud558\ub294 \uae30\ub2a5\uc744 \ubc18\ud658\ud574\uc57c \ud569\ub2c8\ub2e4. (\uad6c\ub3c5\uc5d0 \uad00\ub828\ub41c \ub370\uc774\ud130\ub294 \ub9ac\uc2a4\ub108 \ubc30\uc5f4 \ud544\ub4dc\uc5d0 \ub123\uc5b4\uc11c \uad00\ub9ac\ud569\ub2c8\ub2e4.)\\n\\n- emitChange: \ub9ac\uc2a4\ub108 \ubc30\uc5f4 \ud544\ub4dc\uc5d0 \ub2f4\uaca8\uc788\ub294 \ubaa8\ub4e0 \ub9ac\uc2a4\ub108\ub97c \uc2e4\ud589\ud569\ub2c8\ub2e4. \uc989, \uad6c\ub3c5\ub41c \uc5b4\ub5a4 \uac83\uc744 \uc21c\ucc28\uc801\uc73c\ub85c \uc2e4\ud589\ud558\uac8c \ud569\ub2c8\ub2e4. \uc774\ub294 \ub9ac\uc561\ud2b8 DOM\uc744 \uac15\uc81c\ub85c \uc77c\uae68\uc6cc\uc8fc\ub294 \uc635\uc800\ubc84 \ud328\ud134\uc758 \uc5ed\ud560\uc744 \ud558\uac8c \ub429\ub2c8\ub2e4. \uc774 \uacfc\uc815 \ub54c\ubb38\uc5d0 react DOM\uc774 \uc815\ud655\ud55c \uc7ac \ub80c\ub354\ub9c1 \uc9c0\uc810\uc744 \ud30c\uc545\ud560 \uc218 \uc788\uac8c\ub429\ub2c8\ub2e4. (\ucd5c\uc801\ud654 \ubb38\uc81c\uc5d0\uc11c \uc790\uc720\ub85c\uc6cc\uc9d0)\\n\\n- setState: \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8\ud569\ub2c8\ub2e4. \ub2e4\ub9cc \uc0c1\ud0dc\uac00 \uc5c5\ub370\uc774\ud2b8 \ub410\uc74c\uc744 \uc54c\ub824\uc57c \ud558\ubbc0\ub85c emitChange\ub97c \uc2e4\ud589\uc2dc\ucf1c react DOM\uc744 \uac15\uc81c\ub85c \ub3d9\uae30\ud654\uc2dc\ud0b5\ub2c8\ub2e4.\\n\\n- getState: \ud638\ucd9c\ub418\ub294 \uc21c\uac04 \ud604\uc7ac \uc0c1\ud0dc \uac12\uc744 \uc77d\uc2b5\ub2c8\ub2e4.\\n\\n\uc880 \uc5b4\ub835\uc9c0\ub9cc \ub9ac\uc561\ud2b8\uc5d0\uc11c \uc774\ub7f0 \uaddc\uaca9\uc744 \uac00\uc838\uc57c useSyncExternalStore\ud6c5\uc744 \uc4f8 \uc218 \uc788\uac8c \ud574 \uc90d\ub2c8\ub2e4.\\n\uae30\uc874 \uc608\uc81c\uc5d0\uc11c\ub294 \ub2e8\uc21c\ud55c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8 \uac1d\uccb4\ub85c \uc9dc\uc5ec\uc788\uc5c8\uc9c0\ub9cc \uc778\uc2a4\ud134\uc2a4\ub97c \uc790\uc720\ub86d\uac8c \ucc0d\uc5b4\ub0bc \uc218 \uc788\ub294 class \uad6c\uc870\ub85c \uac1c\uc120\ud558\uace0 \ucd94\uc0c1\ud654\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uc5ec\uae30\uae4c\uc9c0\ub9cc \uad6c\ud604\ud574\ub3c4 useSyncExternalStore\ub97c \uc0ac\uc6a9\ud558\ub294\ub370 \uc9c0\uc7a5\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\uc55e\uc11c \uc120\uc5b8\ud55c store\uac1d\uccb4\uc5d0\uc11c subscribe\uc640 getState\ub97c \uaebc\ub0b4\uc11c \uc9c1\uc811 \uc804\ub2ec\ud574 \uc8fc\uba74 \uadf8\ub9cc\uc774\uae30 \ub54c\ubb38\uc774\uc8e0.\\n\\n\ud558\uc9c0\ub9cc \uacb0\uad6d \uc774 \uacfc\uc815 \uc790\uccb4\uac00 \ubc18\ubcf5\ub41c \uc791\uc5c5\uc744 \uc694\uad6c\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n### \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc27d\uac8c \uc811\uadfc\ud558\ub3c4\ub85d \ucd9c\uad6c\ub97c \uc5f4\uc5b4\uc8fc\uc790!\\n\\n\ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c\ub294 \ubc14\ub2d0\ub77c JS\ub85c \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\ub294 \uac83\ubcf4\ub2e4\ub294 useState\uc640 \ube44\uc2b7\ud55c \ud615\ud0dc\ub85c \ud6c5\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \ud6e8\uc52c \ubcf4\uae30 \uae54\ub054\ud560 \uac83\uc785\ub2c8\ub2e4.\\n\ub9e4\ubc88 \uc2a4\ud1a0\uc5b4\uc5d0\uc11c \ubb34\uc5b8\uac00\ub97c \uc9c1\uc811 \uaebc\ub0b4\uc9c0 \uc54a\ub3c4\ub85d \ud558\ub294 \uc911\uac04 \ucee4\uc2a4\ud140 \ud6c5\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n```typescript\\nexport const useExternalState = (\\n store: DataObserver\\n): [T, (param: SetStateCallbackType | T) => void] => {\\n const { subscribe, getState, setState } = store;\\n const state = useSyncExternalStore(subscribe, getState);\\n\\n return [state, setState];\\n};\\n```\\n\\n\uc774 \ud6c5\uc740, \ubc14\uae65\uc5d0\uc11c \ubc1b\uc544\uc628 store\ub97c \ud65c\uc6a9\ud558\uc5ec \uad6c\ub3c5/\uc5c5\ub370\uc774\ud2b8 \uae30\ub2a5\uc744 \ubc30\uc5f4\ub85c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\ubaa8\uc2dd\ub3c4\ub97c \uadf8\ub824\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./3.png)\\n\\nReact \ucef4\ud3ec\ub10c\ud2b8\ub294 \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c store() \uac1d\uccb4\ub97c useExternalStore\uc5d0 \ub118\uaca8\uc8fc\uace0, \\\\[\uc0c1\ud0dc, \uc0c1\ud0dc\uc5c5\ub370\uc774\ud2b8\ud568\uc218\\\\]\ub97c \ubc1b\uac8c \ub429\ub2c8\ub2e4.\\n\ub9c8\uce58 \uae30\uc874\uc758 useState\ub098 useRecoilState\ucc98\ub7fc \ub9d0\uc774\uc8e0.\\n\\n\uc815\ub9ac\ud558\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\ud478\ub978 \uc601\uc5ed\uc740 React DOM\\n\ub179\uc0c9 \uc601\uc5ed\uc740 \uc9c1\uc811 \ud638\ucd9c\ud574\uc57c \ud558\ub294 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 \uc601\uc5ed (\ud558\uc9c0\ub9cc \ucd5c\ub300\ud55c \ub2e8\uc21c\ud55c \ud615\ud0dc\ub85c \uad6c\uc131\ud574\uc11c \uac1c\ubc1c\uc790\uc758 \ubd80\ub2f4\uc744 \ub35c\uc5b4\uc8fc\ub294 \ud615\ud0dc)\\n\ube68\uac04\uc0c9\uc740 \uac1c\ubc1c\uc790\uac00 \uc9c1\uc811 \uac74\ub4e4\uc9c0 \ubabb\ud558\uc9c0\ub9cc \uac04\uc811\uc801\uc73c\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uc601\uc5ed\\n\ub178\ub780\uc0c9\uc740 React 18 \uc5d4\uc9c4\uc758 \uc601\uc5ed\uc785\ub2c8\ub2e4.\\n\\n\uc774\uc678\uc5d0 \uc81c\uacf5\ub418\ub294 \ub2e4\ub978 \ucee4\uc2a4\ud140 \ud6c5\ub4e4\ub3c4 \uac70\uc758 \ube44\uc2b7\ud55c \uad6c\uc870\ub97c \ub744\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```typescript\\n// \ucd94\uac00\ub85c \uad6c\ud604\ud560 \uc218 \uc788\ub294 \ud568\uc218\ub4e4\\n\\nexport const useSetExternalState = (store: DataObserver) => {\\n const { setState } = store;\\n\\n return setState;\\n};\\n\\nexport const useExternalValue = (store: DataObserver) => {\\n const { subscribe, getState } = store;\\n const state = useSyncExternalStore(subscribe, getState);\\n\\n return state;\\n};\\n\\n// \ubc14\ub2d0\ub77cJS \uc601\uc5ed\uc5d0\uc11c \uc790\uc5f0\uc2a4\ub7ec\uc6b4 \uc77d\uae30\ub97c \uc9c0\uc6d0\ud558\ub294 \ud568\uc218\\n\\nexport const getStoreSnapshot = (store: DataObserver) => {\\n return store.getState();\\n};\\n```\\n\\n\ub354 \ub2e4\uc591\ud55c \uc608\uc81c\ub294 [\uc5ec\uae30\uc5d0\uc11c \ud655\uc778](https://github.com/gabrielyoon7/external-state/tree/main/src/examples)\ud560 \uc218 \uc788\uace0\\n\uc791\uc131\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac \ucf54\ub4dc \uc804\ubb38\uc740 [\uc5ec\uae30\uc5d0\uc11c \ud655\uc778](https://github.com/gabrielyoon7/external-state/tree/main/src/lib/external-state)\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uaca8\uc6b0 \ud30c\uc77c \uc218\uc2ed \uc904\ub85c \ub9cc\ub4e0 \ucd08\uacbd\ub7c9 \uc0c1\ud0dc\uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\uc600\uc2b5\ub2c8\ub2e4"},{"id":"28","metadata":{"permalink":"/28","source":"@site/blog/2023-08-23-about-the-map-system-used-by-carffeine/index.mdx","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","description":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \ub300\ud574\uc11c \uc18c\uac1c\ud558\ub824\uace0 \ud569\ub2c8\ub2e4.","date":"2023-08-23T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 23\uc77c","tags":[{"label":"google maps api","permalink":"/tags/google-maps-api"},{"label":"\uad6c\uae00 \uc9c0\ub3c4","permalink":"/tags/\uad6c\uae00-\uc9c0\ub3c4"}],"readingTime":17.43,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"28","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","authors":["gabriel"],"tags":["google maps api","\uad6c\uae00 \uc9c0\ub3c4"]},"prevItem":{"title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","permalink":"/29"},"nextItem":{"title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","permalink":"/27"}},"content":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \ub300\ud574\uc11c \uc18c\uac1c\ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uc9c0\ub3c4 \uae30\ub2a5\uc5d0\uc11c \uac00\uc7a5 \ud575\uc2ec\uc778 \uae30\ub2a5 \ub450 \uac00\uc9c0\ub97c \ubf51\uc790\uba74, \uc9c0\ub3c4 \uadf8 \uc790\uccb4\uc640 \uc9c0\ub3c4 \uc704\uc5d0 \uadf8\ub824\uc9c0\ub294 \ub9c8\ucee4\ub97c \ubf51\uc744 \uc218 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4. \uc9c0\ub3c4 \uc704\uc5d0 \ub9c8\ucee4\ub97c \uadf8\ub9ac\ub294 \uc77c\uc740 \uadf8\ub2e4\uc9c0 \uc5b4\ub835\uc9c0 \uc54a\uace0, documents \uc5d0 \uc788\ub294 \uc608\uc81c\ub4e4\uc744 \uc798 \ub530\ub77c\ud558\uba74 \ub204\uad6c\ub098 \ucda9\ubd84\ud788 \uad6c\ud604\ud560 \uc218 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n![no offset](./markers-on-map.png)\\n\\n\ud558\uc9c0\ub9cc \ub9c8\ucee4\uc758 \uac2f\uc218\uac00 \uacfc\ub3c4\ud558\uac8c \ub9ce\ub2e4\uba74 \uc5b4\ub5a4 \uc804\ub7b5\uc744 \uc138\uc6b8 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n### \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294\uc694 ...\\n\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc9c0\ub3c4\ub294 \uad49\uc7a5\ud788 \uc911\uc694\ud55c \uc694\uc18c \uc911 \ud558\ub098\uc600\uc2b5\ub2c8\ub2e4. \uc0ac\uc6a9\uc790\ub4e4\uc774 \uad81\uae08\ud55c \uc7a5\uc18c\uc758 \uc8fc\ubcc0\uc5d0 \uc788\ub294 \ucda9\uc804\uc18c\ub97c \uc2dc\uac01\uc801\uc73c\ub85c \uc81c\uacf5\ud574\uc8fc\uae30 \uc704\ud574\uc11c\ub294 \uc9c0\ub3c4\ub97c \uc798 \uc81c\uc5b4\ud560 \uc218 \uc788\uc5b4\uc57c \ud588\uc2b5\ub2c8\ub2e4. \ud2b9\ud788 \uc804\uad6d\uc5d0 \uc774\ubbf8 `\uc218\ub9cc \ub300\uc758 \ucda9\uc804\uc18c`\uac00 \ubcf4\uae09\uc774 \ub41c \uc0c1\ud669\uc5d0\uc11c \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ubaa8\ub450 \uadf8\ub824\uc8fc\uae30 \uc704\ud574\uc11c\ub294 \ub9ce\uc740 \uc81c\uc57d\uc774 \uc788\uc5c8\uace0, \ub9c8\ucee4\ub97c \uc801\ub2f9\ud55c \uc218\uc900\uc73c\ub85c \ub80c\ub354\ub9c1 \ud558\ub824\uba74 \ud074\ub77c\uc774\uc5b8\ud2b8\uc640 \uc11c\ubc84 \uac04\uc5d0 \ud2b9\ubcc4\ud55c \uc791\uc5c5\uc774 \ud544\uc694\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc5b4\ub5a4 \uc804\ub7b5\uc744 \ud3bc\ucce4\ub294\uc9c0 \uc18c\uac1c\ud558\uae30\uc5d0 \uc55e\uc11c \ubbf8\ub9ac \ub9d0\uc500\ub4dc\ub9ac\uc9c0\ub9cc, \uc800\ud76c \ud300\uc5d0\uc11c \ucde8\ud55c \uc9c0\ub3c4 \uad00\ub9ac \uc804\ub7b5\uc740 \ubaa8\ub4e0 \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc744 \uac83\uc785\ub2c8\ub2e4. \uc9c0\ub3c4 \uc704\uc5d0 \ud55c\ubc88\uc5d0 \ud45c\ud604\ud560 \ub9c8\ucee4\uc758 \uac2f\uc218\uac00 \uc218\ubc31 \uac1c \uc774\ud558\ub77c\uba74, \uc11c\ubc84\uc5d0 \ub370\uc774\ud130\uac00 \uacfc\ub3c4\ud558\uac8c \ub9ce\uc740 \uac83\uc774 \uc544\ub2c8\ub77c\uba74 \uc624\ud788\ub824 \uc774\ub7ec\ud55c \uc804\ub7b5\uc774 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \ud574\uce60 \uc218 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4. (\ud658\uacbd\uc774 \uc6d0\ud65c\ud558\ub2e4\uba74 \ub370\uc774\ud130\ub97c \uac00\ub2a5\ud55c \ub9ce\uc774 \ubcf4\uc5ec\uc8fc\ub294 \uac83\uc774 \uc88b\uc744\ud14c\ub2c8\uae50\uc694.)\\n\\n\ub610, \uc774 \uae00\uc5d0\uc11c\ub294 Google Maps API\ub97c \uae30\uc900\uc73c\ub85c \uc124\uba85\ud558\uace0 \uc788\uc9c0\ub9cc, \uc9c0\uc6d0\ud558\ub294 \uae30\ub2a5\uc774 \uc77c\ubd80 \ub2e4\ub974\ub354\ub77c\ub3c4 \ub300\ubd80\ubd84\uc758 \uc9c0\ub3c4 API\uc5d0\uc11c \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud55c \uc804\ub7b5\uc77c \uac83\uc785\ub2c8\ub2e4. \ucc38\uace0\ub85c \uac1c\uc778\uc801\uc73c\ub85c \uc0ac\uc6a9 \ud574\ubcf8 \uc5ec\ub7ec \ubca4\ub354 \uc0ac\uc758 \uc9c0\ub3c4 API\ub4e4\uc740 \ubaa8\ub450 \uc774\uc640 \uc720\uc0ac\ud55c \uae30\ub2a5\uc744 \uc81c\uacf5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n### \uc88c\ud45c\ub780 \ubb34\uc5c7\uc77c\uae4c?\\n\\n\uc544\ub9c8 \uc5b4\ub9b0 \uc2dc\uc808\ubd80\ud130 \uc6b0\ub9ac\ub098\ub77c\uc5d0\ub294 \ud2b9\ubcc4\ud788 38\uc120\uc774\ub77c\ub294 \uac83\uc774 \uc874\uc7ac\ud55c\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uad50\uc721\ubc1b\uae30\uc5d0 `\uc88c\ud45c\uacc4\ub77c\ub294 \uac83\uc774 \uc788\ub2e4\ub294 \uc0ac\uc2e4`\uc740 \ub204\uad6c\ub098 \uc54c \uac83\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub2f9\uc7a5 \uc704\ub3c4\uc640 \uacbd\ub3c4\ub97c \uad6c\ubd84\uc9c0\uc73c\ub77c\uace0 \ud558\uba74 \uc5b4\ub5a4 \uc120\uc774 \uc704\uc120\uc774\uace0 \uacbd\uc120\uc778\uc9c0 \ud5f7\uac08\ub9ac\uae30\uc5d0 \ucc0d\uc5b4\uc57c \ud560 \uac83\uc785\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc774 \uc120\uc774 \uc5b4\ub5a4 \uc120\uc778\uc9c0, \uc5b4\ub5a4 \uac12\uc744 \uc598\uae30\ud558\ub824\ub294 \uac83\uc778\uc9c0 \uc0ac\uc9c4\uacfc \ud568\uaed8 \uac04\ub2e8\ud788 \uc124\uba85\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./latlng.jpeg)\\n\\n\uc0ac\uc9c4\uc744 \ubcf4\uc2dc\uba74 \uc544\uc2dc\uaca0\uc9c0\ub9cc \uc704\ub3c4\ub780, \ub0a8\ubd81\uc758 \uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ub370 \uc0ac\uc6a9\ub429\ub2c8\ub2e4. \uacbd\ub3c4\ub294 \ub3d9\uc11c\uc758 \uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ub370 \uc0ac\uc6a9\ub429\ub2c8\ub2e4. \ub300\ubd80\ubd84\uc758 \uacf5\uc2dd \ubb38\uc11c\uac00 \uc601\uc5b4\ub85c \uc791\uc131\ub418\uc5b4\uc788\uace0, \ucf54\ub4dc\uc5d0\uc11c\ub3c4 \uc774\ub97c \ub098\ud0c0\ub0b4\ub294 \uac83\uc774 \uc911\uc694\ud558\uae30\uc5d0 \uc601\ubb38 \ud45c\uae30\ubc95\uae4c\uc9c0 \uc18c\uac1c\ub97c \ud558\uc790\uba74 \uc704\ub3c4\ub294 Latitude, \uacbd\ub3c4\ub294 Longitude\ub85c \ud45c\uae30\ud569\ub2c8\ub2e4. \uc774\uc720\ub294 \ubaa8\ub974\uaca0\uc9c0\ub9cc \uc81c\uacf5\ub418\ub294 \ubcc0\uc218\ub098 \uba54\uc11c\ub4dc \uba85\uc73c\ub85c lat, lng\ub77c\uace0 \uc904\uc5ec\uc11c \ud45c\uae30\ud558\uae30\ub3c4 \ud569\ub2c8\ub2e4.\\n\\n![no offset](./latlngeng.gif)\\n\\n\uc704\ub3c4\uc640 \uacbd\ub3c4\ub9cc \uc54c\uba74, \uc9c0\uad6c \uc704\uc758 \uc5b4\ub5a4 \uc704\uce58\ub97c \ub098\ud0c0\ub0bc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c, \uc5b4\ub5a4 \ub9c8\ucee4\ub97c \uc5b4\ub5a4 \uc704\uce58\uc5d0 \ucc0d\uc744 \uac83\uc778\uc9c0\ub294 \uc704\ub3c4\uc640 \uacbd\ub3c4 \uac12\uc73c\ub85c \uacb0\uc815\ud560 \uc218 \uc788\uac8c \ub418\uaca0\uc8e0?\\n\\n### \uc0ac\uc6a9\uc790\uac00 \uc5b4\ub51c \ubcf4\uace0 \uc788\uc744\uae4c?\\n\\n\uc9c0\ub3c4 api\uc5d0\uc11c \uc81c\uacf5\ud574\uc8fc\ub294 \uba54\uc11c\ub4dc\ub97c \ud65c\uc6a9\ud558\uba74 \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4\uac00 \uc5b4\ub290 \uc704\uce58\ub97c \ubcf4\uace0 \uc788\ub294\uc9c0 \uc54c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```typescript\\nlet map = /* \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c \uad6c\uae00 \ub9f5 \uac1d\uccb4 */\\nconst center = map.getCenter();\\nconsole.log(center.lng()); // \ub514\ubc14\uc774\uc2a4 \uc911\uc2ec\uc758 longitude\\nconsole.log(center.lat()); // \ub514\ubc14\uc774\uc2a4 \uc911\uc2ec\uc758 latitude\\n```\\n\\n\uc9c0\ub3c4 \uac1d\uccb4\ub85c \ubd80\ud130 \uc911\uc2ec\uc810\uc744 \uc54c\uac8c\ub418\uba74 \ud574\ub2f9 \ub514\ubc14\uc774\uc2a4\uc758 \uc911\uc2ec\uc758 \uc88c\ud45c\ub97c \uc54c\uc544\ub0bc \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n![no offset](./get-center.png)\\n\\n### \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4\ub294 \uc5bc\ub9c8\ub098 \ub113\uac8c \ubcf4\uace0 \uc788\uc744\uae4c?\\n\\n\uc9c0\ub3c4 api\uc5d0\uc11c \uc81c\uacf5\ud574\uc8fc\ub294 \uba54\uc11c\ub4dc\ub97c \ud65c\uc6a9\ud558\uba74 \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4\uac00 \uc5b4\ub5a4 \uc601\uc5ed\uc744 \ubcf4\uace0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uac8c \ub429\ub2c8\ub2e4. \uc9c0\ub3c4 api \ub9c8\ub2e4 \uc81c\uacf5\ud558\ub294 \uc2a4\ud399\uc774 \ub2e4\ub974\uc9c0\ub9cc, \ub300\ubd80\ubd84\uc740 \uc5b4\ub5a4 \uc2dd\uc73c\ub85c\ub4e0 \uc54c\ub824\uc90d\ub2c8\ub2e4.\\n\\ngoogle maps API\uc5d0\uc11c\ub294 \ub514\uc2a4\ud50c\ub808\uc774\uc758 \ubd81\ub3d9\ucabd \ub05d \uc810\uc758 \uc88c\ud45c\uc640, \ub0a8\uc11c\ucabd \ub05d \uc810\uc758 \uc88c\ud45c\ub97c \uc81c\uacf5\ud574\uc90d\ub2c8\ub2e4.\\n\\n```typescript\\nconst map = /* \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c \uad6c\uae00 \ub9f5 \uac1d\uccb4 */\\nconst bounds = map.getBounds();\\nconsole.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // \ub514\ubc14\uc774\uc2a4 1\uc0ac\ubd84\uba74 \ub05d \uc810\uc758 longitude\uc640 latitude\\nconsole.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // \ub514\ubc14\uc774\uc2a4 3\uc0ac\ubd84\uba74 \ub05d \uc810\uc758 longitude\uc640 latitude\\n```\\n\\n![no offset](./get-bounds.png)\\n\\n\ud3b8\uc758\uc0c1 \uc88c\ud45c\ub97c \ub2e4\uc74c\uacfc \uac19\uc774 \uc815\uc758\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n- \uc911\uc2ec \uc810 p0: (x0, y0)\\n- \ub514\ubc14\uc774\uc2a4\uc758 \uc81c 1\uc0ac\ubd84\uba74 \ub05d\uc810 p2: (x2, y2)\\n- \ub514\ubc14\uc774\uc2a4\uc758 \uc81c 3\uc0ac\ubd84\uba74 \ub05d\uc810 p1: (x1, y1)\\n\\n```\\n\uc704 \uc815\uc758\ub294 \uc544\ub798\uc5d0\uc11c\ub3c4 \uacc4\uc18d \uc124\uba85 \ub420 \uc810\uacfc \uc88c\ud45c \uc785\ub2c8\ub2e4.\\n```\\n\\n\uc774\ub807\uac8c \uc54c\uc544\ub0b8 \uac12\uc73c\ub85c \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc758 \uc601\uc5ed\uc744 \uc54c\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc774 \uac12\uc744 \uc880 \ub354 \ud6a8\uc728\uc801\uc73c\ub85c \ub2e4\ub8e8\uae30 \uc704\ud574 delta \uac1c\ub150\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \ud654\uba74\uc5d0\uc11c \ubcf4\uace0 \uc788\ub294 \uc601\uc5ed\uc744 \ud655\ub300/\ucd95\uc18c \ud558\uba74 \uc5b4\ub5a4 \ud2b9\uc9d5\uc744 \ubcf4\uc77c\uae4c?\\n\\ndelta \uc124\uba85\uc744 \uc55e\uc11c, \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4 \uc601\uc5ed\uacfc \ud655\ub300 \uc218\uc900\uc5d0 \ub530\ub978 \uc2e4\uc81c \uc88c\ud45c\uc5d0 \ub300\ud574 \uc54c\uc544\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\uac00 \ud654\uba74\uc744 \uc5bc\ub9c8\ub098 \ub113\uac8c \ubcf4\uace0 \uc788\ub294\uc9c0\ub97c \uc27d\uac8c \uc54c\uae30 \uc704\ud574\uc11c\ub294 \ub05d\uc810\ub4e4\uc758 \uc218\uce58\ub97c \uacc4\uc0b0\ud574\uc904 \ud544\uc694\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc9c4\uc740 \uc0ac\uc6a9\uc790\uac00 \ub514\ubc14\uc774\uc2a4\ub97c \ud1b5\ud574 \ubc14\ub77c \ubcf4\uace0 \uc788\ub294 \uc911\uc2ec \uc88c\ud45c\uc640 \uadf8 \ub05d \uc810\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4.\\n\\n![no offset](./map-with-different-size.png)\\n\\n\\n\uc608\ub97c \ub4e4\uc5b4 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\ub97c \ub9ce\uc774 \ucd95\uc18c\ud55c \uacbd\uc6b0\uc5d0\ub294 \uc911\uc2ec \uc810 p0\uc740 \uadf8\ub300\ub85c\uc9c0\ub9cc \uc591 \ub05d\uc810 p1, p2\uc758 \uc704\uce58\uac00 \uc810\uc810 \uc911\uc2ec \uc810 p0\uc73c\ub85c \ubd80\ud130 \uba40\uc5b4\uc9c8 \uac83\uc785\ub2c8\ub2e4.\\n\\n\ubc18\uba74\uc5d0 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\ub97c \ub9ce\uc774 \ud655\ub300\ud55c \uacbd\uc6b0\uc5d0\ub294 \uc911\uc2ec \uc810 p0\uc740 \uadf8\ub300\ub85c\uc9c0\ub9cc \uc591 \ub05d\uc810 p1, p2\uc758 \uc704\uce58\uac00 \uc810\uc810 \uc911\uc2ec\uc810\uacfc \uac00\uae4c\uc6cc\uc9c8 \uac83\uc785\ub2c8\ub2e4.\\n\\n![no offset](./map-with-different-zoom.png)\\n\\n\uc591 \uc0ac\uc9c4 \ubaa8\ub450 \uc911\uc2ec \uc810 p0\ub294 \uadf8\ub300\ub85c\uc9c0\ub9cc, \ub514\ubc14\uc774\uc2a4\uc758 \ud655\ub300 \uc218\uc900\uc73c\ub85c \uc778\ud574 \uc591 \ub05d\uc810\uc778 p1\uacfc p2\uac00 \ub2ec\ub77c\uc9c4 \ubaa8\uc2b5\uc744 \ubcf4\uc778 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc989, \uc774\ub7f0 \uacb0\ub860\uc744 \ub0b4\ub9b4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc591 \ub05d\uc810 p1, p2\uac00 \uc911\uc2ec \uc810 p0\uc73c\ub85c \ubd80\ud130 \uba40\uc5b4\uc9c8 \uc218\ub85d \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud55c \uac83\uc774\ub2e4.\\n2. \uc591 \ub05d\uc810 p1, p2\uac00 \uc911\uc2ec \uc810 p0\uc73c\ub85c \ubd80\ud130 \uac00\uae4c\uc6cc \uc218\ub85d \uc9c0\ub3c4\ub97c \ud655\ub300\ud55c \uac83\uc774\ub2e4.\\n\\n\uc774 \ub54c \ub514\ubc14\uc774\uc2a4\uc758 \ub514\uc2a4\ud50c\ub808\uc774\uac00 \uc704\ub3c4 \uacbd\ub3c4 \uc0c1\uc73c\ub85c \uc5bc\ub9c8\ub098 \uba40\uc5b4\uc838\uc788\ub294\uc9c0\ub97c \uc218\uce58\ud654\ud558\uba74 \ud3b8\ud558\uac8c \ub2e4\ub8f0 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ud655\ub300 \uc218\uc900\uc744 \uc218\uce58\ud654 \ud560 \uc218 \uc5c6\uc744\uae4c?\\n\\n\uc0ac\uc6a9\uc790\uc758 \ub514\uc2a4\ud50c\ub808\uc774\uc758 \uc911\uc2ec \uc810 p0\uc744 \uae30\uc900\uc73c\ub85c \ud558\uc5ec \uc591 \ub05d\uc810 p1, p2\uc774 \uc5bc\ub9c8\ub098 \uba40\uc5b4\uc838\uc788\ub294\uc9c0\uc5d0 \ub530\ub77c \uc9c0\ub3c4\uc758 \uc601\uc5ed \ubfd0\ub9cc \uc544\ub2c8\ub77c \uc5bc\ub9c8\ub098 \ub9ce\uc774 \ud655\ub300 \ub418\uc5c8\ub294\uc9c0 \uc5ec\ubd80\ub97c \uc54c\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 \uc774\ub97c \uc880 \ub354 \ud6a8\uc728\uc801\uc778 \ubc29\ubc95\uc73c\ub85c \ub098\ud0c0\ub0b4\ub824\uba74 \uc5b4\ub5a4 \uc804\ub7b5\uc744 \ucde8\ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n\uc0ac\uc6a9\uc790 \ub514\uc2a4\ud50c\ub808\uc774\ub97c \uc870\uae08 \ub354 \uc790\uc138\ud788 \uc0b4\ud3b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./map-points.png)\\n\\n\uc911\ud559\uad50 \uc2dc\uc808 \ubc30\uc6e0\ub358 \uc88c\ud45c \ud3c9\uba74\uacc4\ub97c \ub5a0\uc62c\ub824\ubcf4\uba74 \ud654\uba74\uc5d0\uc11c \uc5bb\uc744 \uc218 \uc788\ub294 \uc88c\ud45c\ub4e4\uc740 \uc704\uc640 \uac19\uc2b5\ub2c8\ub2e4. \uc5ec\uae30\uc5d0\uc11c \uac01 \uc810\uc758 \uc218\uc9c1/\uc218\ud3c9\uc758 \ubcc0\ud654\ub7c9\uc778 delta\ub97c \uc54c\uc544\ubcf4\uba74 \uc5b4\ub5a8\uae4c\uc694?\\n\\n#### \uacbd\ub3c4 \ub378\ud0c0 (longitudeDelta)\\n\\np2\uc640 p0\uc758 \uacbd\ub3c4 \uac70\ub9ac, \uadf8\ub9ac\uace0 p1\uacfc p0\uc758 \uacbd\ub3c4 \uac70\ub9ac\ub294 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc989, `x2 - x0 === x0 - x1` \uc774\ub77c\ub294 \uacb0\ub860\uc744 \uc5bb\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c longitudeDelta\ub85c \uc815\uc758\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n#### \uc704\ub3c4 \ub378\ud0c0 (latitudeDelta)\\n\\np2\uc640 p0\uc758 \uc704\ub3c4 \uac70\ub9ac, \uadf8\ub9ac\uace0 p1\uacfc p0\uc758 \uc704\ub3c4 \uac70\ub9ac\ub294 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc989, `y2 - y0 === y0 - y1` \uc774\ub77c\ub294 \uacb0\ub860\uc744 \uc5bb\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c latitudeDelta\ub85c \uc815\uc758\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n![no offset](./delta.png)\\n\\n\ucf54\ub4dc\ub85c \uc54c\uc544\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```typescript\\nconst map = /* \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c \uad6c\uae00 \ub9f5 \uac1d\uccb4 */\\nconst bounds = map.getBounds();\\nconst longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // \uacbd\ub3c4 \ubcc0\ud654\ub7c9\\nconst latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // \uc704\ub3c4 \ubcc0\ud654\ub7c9\\n```\\n\\n\ub4dc\ub514\uc5b4 \ud074\ub77c\uc774\uc5b8\ud2b8\uc5d0\uc11c \ub378\ud0c0 \uac12\uc744 \uc0dd\uc131\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 \uc65c \uc774\ub807\uac8c \uad73\uc774 \ub378\ud0c0 \uac12\uc744 \uc0dd\uc131\ud55c \uac83\uc77c\uae4c\uc694?\\n\\n### delta\uc758 \uc720\uc6a9\ud55c \uc810 1: \uc6d0\ub798 \uc758\ub3c4\ud55c \uac12\uc744 \ubcf5\uc6d0\ud558\uae30 \uc27d\ub2e4.\\n\\n\uc11c\ubc84\uc758 \uc785\uc7a5\uc5d0\uc11c\ub294 \uc911\uc2ec \uc88c\ud45c\uc640 \ub378\ud0c0 \uac12\ub9cc \uc54c\uba74 \uc815\ud655\ud55c \uc601\uc5ed\ub9cc\ud07c \ub370\uc774\ud130\ub97c \ud638\ucd9c\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \ud074\ub77c\uc774\uc5b8\ud2b8\uc5d0\uc11c \uc11c\ubc84\ub85c \ub2e4\uc74c\uacfc \uac19\uc740 \ud30c\ub77c\ubbf8\ud130\ub97c \ub118\uaca8\uc92c\ub2e4\uace0 \uac00\uc815\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```json\\n{\\n \\"longitude\\": 127,\\n \\"latitude\\": 37,\\n \\"longitudeDelta\\": 0.1,\\n \\"longitudeDelta\\": 0.2,\\n}\\n```\\n\\n\uadf8\ub807\ub2e4\uba74 \uc11c\ubc84\uc5d0\uc11c\ub294 \ub2e4\uc74c\uacfc \uac19\uc774 \ud574\uc11d\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n```javascript\\nconst maxLongitude = longitude + longitudeDelta;\\nconst minLongitude = longitude - longitudeDelta;\\nconst maxLatitude = latitude + latitudeDelta;\\nconst minLatitude = latitude - latitudeDelta;\\n```\\n(javascript \uae30\uc900\uc73c\ub85c \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4.)\\n\\n\uc774\ub807\uac8c \uc54c\uc544\ub0b8 \uacbd\uacc4 \uac12\uc744 \uac00\uc9c0\uace0 \ub2e4\uc74c\uacfc \uac19\uc740 sql\ubb38\uc744 \uc791\uc131\ud560 \uc218 \uc788\uac8c \ub420 \uac83\uc785\ub2c8\ub2e4.\\n\\n```sql\\nSELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;\\n```\\n\\n![no offset](./find-within-range.png)\\n\\n\uc989, \uc704 \uadf8\ub9bc\ucc98\ub7fc, \uc6d0\ud558\ub294 \uc601\uc5ed\ub9cc\ud07c\ub9cc \uc815\ud655\ud558\uac8c \ub370\uc774\ud130\ub97c \ud638\ucd9c\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n### delta\uc758 \uc720\uc6a9\ud55c \uc810 2: \ub378\ud0c0\uac00 \ubb34\ubd84\ubcc4\ud558\uac8c \ucee4\uc9c0\ub294 \uac83\uc744 \ub9c9\uae30 \uc27d\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud558\uc5ec \ud55c\ubc18\ub3c4\ub97c \ub514\uc2a4\ud50c\ub808\uc774\uc5d0 \uac00\ub4dd \ucc44\uc6b4\ub2e4\uba74 \uc11c\ubc84\uac00 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694?\\n\\n\uc774\ub7ec\ud55c \ud589\uc704\ub97c \ub9c9\ub294 \uac00\uc7a5 \uc26c\uc6b4 \ubc29\ubc95\uc740 \uc9c0\ub3c4 api\uc5d0\uc11c \uc9c0\uc6d0\ud558\ub294 \uc90c \ub808\ubca8\uc744 \uc81c\ud55c \ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \ud6c4\uc220\ud558\uaca0\uc9c0\ub9cc _\uc90c \ub808\ubca8\uc740 \ub514\uc2a4\ud50c\ub808\uc774\uc758 \ud574\uc0c1\ub3c4\ub97c \uace0\ub824\ud558\uc9c0 \ubabb\ud569\ub2c8\ub2e4._\\n\\n\ub530\ub77c\uc11c \uadfc\ubcf8\uc801\uc73c\ub85c \ub378\ud0c0\uac00 \uc77c\uc815 \uac12 \uc774\uc0c1 \uc694\uccad\ub418\uc9c0 \ubabb\ud558\ub3c4\ub85d, \ud639\uc740 \uc5f0\uc0b0\ub418\uc9c0 \ubabb\ud558\ub3c4\ub85d \ub9c9\uac8c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \ub378\ud0c0\uac00 \uc5c6\ub354\ub77c\ub3c4 \ub378\ud0c0 \uac12\uc744 \ucd94\uc815\ud558\uc5ec \uc5f0\uc0b0\ud560 \uc218 \uc788\uaca0\uc9c0\ub9cc, \uc774\ub97c _\uc218\uce58\ud654 \ud574\uc11c \uad00\ub9ac\ud55c\ub2e4\uba74 \ud074\ub77c\uc774\uc5b8\ud2b8\uc640 \uc11c\ubc84 \ubaa8\ub450 \uc9c0\ub3c4\ub97c \uc190\uc27d\uac8c \ud1b5\uc81c\ud558\ub294 \uac83\uc774 \uac00\ub2a5_\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \ub2e4\uc74c\uacfc \uac19\uc774 \ub378\ud0c0 \uac12\uc744 \uace0\uc815\ud558\uc5ec \uc694\uccad \uc601\uc5ed\uc744 \uc81c\ud55c\ud560(\uc694\uccad\uc744 \ubcf4\ub0b4\uc9c0 \uc54a\uac70\ub098 \uace0\uc815\ub41c \uc0ac\uc774\uc988\ub85c\ub9cc \uc694\uccad\uc744 \ubcf4\ub0bc) \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```json\\n{\\n longitude,\\n latitude,\\n longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,\\n latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,\\n}\\n```\\n\\n\ud2b9\uc815 \uc218\uce58\ub97c \ub118\uae30\uc9c0 \ubabb\ud558\uac8c \ucc98\ub9ac\ud560 \ub54c \ub208\uc5d0 \ubcf4\uc774\ub294 \ubcc0\uc218\ub85c \ucde8\uae09\ud558\uae30 \uc27d\uc2b5\ub2c8\ub2e4. (\uc989, \ub9e4\ubc88 \uacc4\uc0b0\ud558\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.)\\n\\n\ub514\ubc14\uc774\uc2a4 \ud06c\uae30 \uad00\ub828 \ubb38\uc81c\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubd84\uba85\ud788 \uac19\uc740 \uc90c \ub808\ubca8\uc774\uc9c0\ub9cc, \ub514\ubc14\uc774\uc2a4\uc758 \ud06c\uae30\ub098 \ud574\uc0c1\ub3c4\uc5d0 \ub530\ub77c \uc9c0\ub3c4\uac00 \ubcf4\uc5ec\uc9c0\ub294 \uc815\ub3c4\uac00 \ub2e4\ub985\ub2c8\ub2e4.\\n\\n![no offset](./different-device-size.png)\\n\\n\uc704 \uc0ac\uc9c4\uc740 \uad6c\uae00\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 zoom \ub808\ubca8\uc744 \ub3d9\uc77c\ud558\uac8c \ub9de\ucd98 \ud6c4, \uc5ec\ub7ec \ub514\ubc14\uc774\uc2a4\uc5d0\uc11c \ud638\ucd9c\ud55c \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc90c \ub808\ubca8\uc744 \ud1b5\ud574\uc11c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub2e4\ubcf4\uba74 \uc5ec\ub7ec \ud574\uc0c1\ub3c4\ub97c \uc81c\uc5b4\ud558\uae30 \uc5b4\ub835\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./too-big-screen.png)\\n\\n\uc2e4\uc81c\ub85c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uace0\ud574\uc0c1\ub3c4 \ubaa8\ub2c8\ud130\ub97c \ub300\uc751\ud558\uae30 \uc704\ud574 \ub378\ud0c0 \uac12\uc774 \ub108\ubb34 \ud06c\uac8c \ub418\uba74 \uc694\uccad\uc758 \uc81c\ud55c\uc744 \ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc0ac\uc9c4\uc5d0\uc11c \ubcf4\uc2dc\ub2e4\uc2dc\ud53c \uace0\ud574\uc0c1\ub3c4 \ubaa8\ub2c8\ud130\uc758 \uacbd\uc6b0, \ub108\ubb34 \ub113\uc740 \ubc94\uc704\ub97c \uc694\uccad\ud55c\ub2e4 \uc2f6\uc73c\uba74 \uc911\uc2ec\uc810\uc73c\ub85c \ubd80\ud130 \uc77c\uc815 \uac70\ub9ac\ub9cc \ubcf4\uc5ec\uc8fc\ub3c4\ub85d \ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n(\ucc38\uace0\ub85c \uc90c \ub808\ubca8\uc5d0 \ub530\ub978 \uc694\uccad\ub3c4 \ub364\uc73c\ub85c \uc81c\ud55c\ud558\uace0 \uc788\uc5b4\uc11c \uba40\ub9ac\uc11c \ud638\ucd9c\ud558\ub294 \ud589\uc704\ub3c4 \uae08\uc9c0\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.)\\n\\n### delta\uc758 \uc720\uc6a9\ud55c \uc810 3: \uc801\ub2f9\ud55c \ubc94\uc704\ub97c \uc815\ud574\uc8fc\uae30 \ud3b8\ud558\ub2e4\\n\\n\uc704 \uc608\uc81c\uc5d0\uc11c\ub294 \uc815\ud655\ud55c \ubc94\uc704\ub9cc\ud07c \uc694\uccad\ud558\ub294 \uac83\uc744 \uc608\uc81c\ub85c \ud558\uc9c0\ub9cc, \ud504\ub85c\uc81d\ud2b8\uc5d0 \ub530\ub77c\uc11c \uc870\uae08 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\uace0 \uc2f6\uc744 \ub54c\uac00 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n![no offset](./bigger-than-delta.png)\\n\\n\uc608\ub97c \ub4e4\uc5b4 \ud604\uc7ac \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4 \ud06c\uae30\ubcf4\ub2e4 \uc0b4\uc9dd \ud070 \ubc94\uc704\uc758 \ub370\uc774\ud130\ub97c \ubbf8\ub9ac \ub85c\ub4dc\ud574 \ub193\uc73c\uba74 \uc0ac\uc6a9\uc790\uac00 \uc881\uc740 \uc6c0\uc9c1\uc784\uc744 \ubcf4\uc77c \ub54c \ubd88\ud544\uc694\ud55c \uc7ac \ub80c\ub354\ub9c1\uc744 \uc904\uc5ec\uc11c \ub354 \ube60\ub978 \ub80c\ub354\ub9c1\uc774 \uac00\ub2a5\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uc774 \uae30\ubc95\uc740 \ud504\ub85c\uc81d\ud2b8\ub9c8\ub2e4 \ub2e4\ub974\uaca0\uc9c0\ub9cc, \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud55c\ubc88 \ubd88\ub7ec\uc628 \ub9c8\ucee4\ub97c \ub9e4\ubc88 \ud574\uc81c \ud558\uc9c0 \uc54a\uace0 **\uc774\uc804 \uc694\uccad \ub370\uc774\ud130\uc640 \ub2e4\uc74c \uc694\uccad \ub370\uc774\ud130\ub97c \ube44\uad50\ud558\uc5ec \ub2ec\ub77c\uc9c4 \ub9c8\ucee4\ub9cc\uc744 \uc815\ud655\ud558\uac8c \ud0c8\ubd80\ucc29\ud558\ub294 \uc791\uc5c5\uc744 \uc9c4\ud589**\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uae30\ubc95\uc744 \ud65c\uc6a9\ud558\uba74 \uc0ac\uc6a9\uc790\uac00 \uc881\uc740 \ubc94\uc704\uc5d0\uc11c \uc6c0\uc9c1\uc784\uc744 \ubcf4\uc600\uc744 \ub54c, \uae30\uc874\uc5d0 \ubd88\ub7ec\uc628 \ub9c8\ucee4\ub97c \uba54\ubaa8\ub9ac\uc5d0\uc11c \ud0c8\ub77d\uc2dc\ud0a4\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \uac1c\uc120\ud560 \uc218\ub3c4 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n\ub9c8\ucee4\ub97c \uc0c1\ud0dc\uc5d0 \uc5f0\ub3d9\ud558\uc5ec \uc815\ud655\ud558\uac8c \uba54\ubaa8\ub9ac\uc5d0\uc11c \ud0c8\ubd80\ucc29 \uc2dc\ud0a4\ub294 \uc804\ub7b5\uc5d0 \ub300\ud55c \uae00\uc740 \uc774\ud6c4\uc5d0 \uc791\uc131\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4."},{"id":"27","metadata":{"permalink":"/27","source":"@site/blog/2023-08-17-given-ec2-prod-dev-sep.mdx","title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694.","date":"2023-08-17T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 17\uc77c","tags":[{"label":"ec2","permalink":"/tags/ec-2"},{"label":"prod","permalink":"/tags/prod"},{"label":"dev","permalink":"/tags/dev"}],"readingTime":3,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"27","title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","authors":["jay"],"tags":["ec2","prod","dev"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","permalink":"/28"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","permalink":"/26"}},"content":"\uc548\ub155\ud558\uc138\uc694.\\n\uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \uc800\ud76c\uac00 EC2 \uc778\uc2a4\ud134\uc2a4\ub97c \ubc1b\uc73c\uba74\uc11c, \uc5b4\ub5bb\uac8c dev, prod \ubc30\ud3ec \ud658\uacbd\uc744 \ubd84\ub9ac\ud588\ub294\uc9c0 \uc801\uc5b4\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\uae30\uc874 \uce74\ud398\uc778 \ud300\uc758 EC2 \uad6c\uc870\ub294 [\uc5ec\uae30\uc11c](https://blog.naver.com/sosow0212/223163203356) \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \uae30\uc874 \uc0c1\ud669\uacfc \ubb38\uc81c\uc810\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uae30\uc874\uc5d0 3\ub300\uc758 EC2 \uc778\uc2a4\ud134\uc2a4\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uac01\uac01 `infra, dev, db` \uc5ed\ud560\uc744 \ud558\ub294 \uc778\uc2a4\ud134\uc2a4\ub85c \uc874\uc7ac\ud558\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 release \ube0c\ub79c\uce58\ub97c \ud1b5\ud574 dev\uc11c\ubc84\uc5d0 \ubc30\ud3ec\ub97c \ud55c \ud6c4 \uac80\uc99d\uc774 \ub41c\ub2e4\uba74, \uc2e4\uc81c \uc0ac\uc6a9\uc790\ub4e4\uc774 \uc0ac\uc6a9\ud558\ub294 prod \uc11c\ubc84\uc5d0 \ubc30\ud3ec\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubb38\uc81c\ub294 \uae30\uc874\uc758 3\ub300\uc758 \uc778\uc2a4\ud134\uc2a4 \uc911\uc5d0\uc11c dev \uc11c\ubc84\uc5d0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874 dev \uc11c\ubc84\ub294 \ucd1d 4\uac1c\uc758 \uc11c\ubc84\ub97c \ubc30\ud3ec\ud558\uace0 \uc788\uc5c8\uace0 \ubc30\ud3ec\ud558\ub294 \uc11c\ubc84\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4. `prod-BE, prod-FE, dev-BE, dev-FE`\\n\\n\uadf8\ub9ac\uace0, \uae30\uc874 dev \uc11c\ubc84\uc5d0\uc11c\ub294 \ud658\uacbd\uc744 \ubd84\ub9ac\ud574\uc8fc\uae30 \uc704\ud574\uc11c Nginx\ub97c \ud1b5\ud574\uc11c \ud3ec\ud2b8 \ud3ec\uc6cc\ub529\uc740 \ub2e4\uc74c\uacfc \uac19\uc774 \ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n- prod-BE = 8080\\n- prod-FE = 3031\\n- dev-BE = 8081\\n- dev-FE = 3031\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 dev, prod \ud658\uacbd\uc774 \ubd84\ub9ac\ub418\uc9c0 \uc54a\uc544\uc11c \uc778\uc2a4\ud134\uc2a4\uc758 \uc0ac\uc6a9\ub7c9\uc774 \ub192\uc558\uace0, \uc774\uc5d0 \ub530\ub77c \ucd94\uac00\uc801\uc778 EC2 \uc778\uc2a4\ud134\uc2a4\uac00 \ud544\uc694\ud588\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \ubb38\uc81c \ud574\uacb0\\n\ub2e4\ud589\ud788\ub3c4 \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ucd94\uac00\uc801\uc778 EC2 \uc778\uc2a4\ud134\uc2a4\ub97c \ubc1b\uc558\uace0, \uc800\ud76c\ub294 \ubc30\ud3ec \ud658\uacbd\uc744 \ubd84\ub9ac\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![dev-prod-server](https://github.com/car-ffeine/car-ffeine.github.io/assets/63213487/52942893-3d8c-4c72-9972-278afa810d1d)\\n\\n\uc774\uc640 \uac19\uc774 \uae30\uc874 dev \uc11c\ubc84 \ud55c \uac1c\uac00 infra \uc11c\ubc84\uc640 \uc5f0\uacb0\ub418\uc5b4 \uc788\uc5c8\ub294\ub370, \ub450 \uac08\ub798\ub85c \ub098\ub25c \uac83\uc744 \ud655\uc778\ud558\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \ubc30\ud3ec\ub294 \ub2e4\uc74c\uacfc \uac19\uc774 \uc9c4\ud589\ub429\ub2c8\ub2e4.\\n\\n`release branch`\uc5d0 push\uac00 \uc77c\uc5b4\ub098\uba74 `dev\uc11c\ubc84\uc5d0 \ubc30\ud3ec \uc791\uc5c5`\uc774 \uc774\ub904\uc9d1\ub2c8\ub2e4.\\n`prod branch`\uc5d0 push\uac00 \uc77c\uc5b4\ub098\uba74 `prod\uc11c\ubc84\uc5d0 \ubc30\ud3ec \uc791\uc5c5`\uc774 \uc774\ub904\uc9d1\ub2c8\ub2e4.\\n\\n\ub610\ud55c \uae30\uc874 dev \uc11c\ubc84\uc5d0\uc11c 4\uac1c\uc758 \ud3ec\ud2b8\ud3ec\uc6cc\ub529 \ub610\ud55c \uad73\uc774 \uadf8\ub7f4 \ud544\uc694\uac00 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4.\\n\uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ucd94\uac00\ub428\uc5d0 \ub530\ub77c dev, prod \uc11c\ubc84 \uac01\uac01 Nginx\uc5d0\uc11c \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \ub3d9\uc77c\ud558\uac8c `FE:3000, BE:8080` \uc73c\ub85c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 dev, prod \ud658\uacbd\uc744 \ubd84\ub9ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uac10\uc0ac\ud569\ub2c8\ub2e4!"},{"id":"26","metadata":{"permalink":"/26","source":"@site/blog/2023-08-16-how-fe-test.mdx","title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","description":"\uc548\ub155\ud558\uc138\uc694, \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc5b4\ub5bb\uac8c \ud558\uace0 \uc788\uc744\uae4c\uc694?","date":"2023-08-16T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 16\uc77c","tags":[{"label":"\ud14c\uc2a4\ud2b8","permalink":"/tags/\ud14c\uc2a4\ud2b8"},{"label":"test","permalink":"/tags/test"}],"readingTime":7.65,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"26","title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","authors":["gabriel"],"tags":["\ud14c\uc2a4\ud2b8","test"]},"prevItem":{"title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","permalink":"/27"},"nextItem":{"title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","permalink":"/25"}},"content":"\uc548\ub155\ud558\uc138\uc694, \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc5b4\ub5bb\uac8c \ud558\uace0 \uc788\uc744\uae4c\uc694?\\n\\n\uc77c\ubc18\uc801\uc73c\ub85c \uc18c\ud504\ud2b8\uc6e8\uc5b4 \ud14c\uc2a4\ud2b8\ub780 \ubc31\uc5d4\ub4dc\uc5d0\uc11c \uadf8 \uc911\uc694\uc131\uc774 \uac15\uc870\ub418\uace4 \ud558\uc9c0\ub9cc, \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c\ub3c4 \uadf8\uc5d0 \ubabb\uc9c0 \uc54a\uac8c \uc911\uc694\ud55c \ubd80\ubd84\uc744 \ucc28\uc9c0\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc218\ub9ce\uc740 \ud234 \uc911\uc5d0\uc11c \uc5b4\ub5a4 \ud14c\uc2a4\ud2b8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub294\uc9c0 \uc18c\uac1c\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ub2e4\uc74c\uacfc \uac19\uc740 \ud504\ub860\ud2b8\uc5d4\ub4dc \ud14c\uc2a4\ud2b8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### Jest\\nJest\ub294 JavaScript\uc758 \ud14c\uc2a4\ud2b8\ub97c \uc704\ud55c \ub300\ud45c\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\uae30\ubcf8 \uc124\uc815\uc774 \uac04\ud3b8\ud558\uace0, \ube60\ub974\uac8c \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud560 \ub54c \uad49\uc7a5\ud788 \uc720\uc6a9\ud569\ub2c8\ub2e4.\\n\ud568\uc218\ub97c mocking\ud558\uc5ec \uc758\uc874\uc131\uc774 \uac15\ud55c \ud568\uc218\ub97c \uc81c\uac70\ud558\uc5ec \uc6d0\ud558\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc27d\uac8c \uad6c\uc131\ud560 \uc218 \uc788\ub2e4\ub294 \ud2b9\uc9d5\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n### React Testing Library\\nReact Testing Library\ub294 \ub9ac\uc561\ud2b8 \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc758 UI\ub97c \ud14c\uc2a4\ud2b8\ud558\uae30 \uc704\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\nReact \ucef4\ud3ec\ub10c\ud2b8\ub97c \ud638\ucd9c\ud558\uc5ec, \uc0ac\uc6a9\uc790\uc758 \uc758\ub3c4\ub300\ub85c \uc870\uc791\ud560 \uc218 \uc788\ub294 \ud589\uc704\ub97c \uc815\uc758\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc0ac\uc6a9\uc790 \uc785\uc7a5\uc5d0\uc11c \uc0c1\ud638\uc791\uc6a9 \ud560 \uc218 \uc788\ub294 \ubd80\ubd84\uc744 \uc2a4\ud06c\ub9bd\ud2b8\ub85c \uc791\uc131\ud558\uc5ec \ucef4\ud3ec\ub10c\ud2b8\uac00 \uc5b4\ub5bb\uac8c \ubcc0\ud654\ud558\ub294\uc9c0\ub97c \ud14c\uc2a4\ud2b8 \ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\uac00\ub839, \uc5b4\ub5a4 \uc0ac\uc6a9\uc790\uac00 \uc5b4\ub5a4 \ud3fc\uc5d0 \uc5b4\ub5a4 \uac12\uc744 \uc785\ub825\ud588\uc744 \ub54c\uc758 \uc608\uc0c1\ub418\ub294 \uacb0\uacfc\ub97c \uc791\uc131\ud574\ub450\uba74 \uc774\ud6c4\uc5d0 \ucf54\ub4dc \uc791\uc5c5 \uc911 \ubc84\uadf8\uac00 \ubc1c\uc0dd\ud55c\ub2e4\uba74 \ud574\ub2f9 \uc704\uce58\uc5d0\uc11c \ud14c\uc2a4\ud2b8\uac00 \uc2e4\ud328\ud560 \uac83\uc785\ub2c8\ub2e4.\\n\\n### Storybook\\nStorybook\uc740 UI\ub97c \ucef4\ud3ec\ub10c\ud2b8 \ub2e8\uc704\ub85c \uac1c\ubc1c\ud558\uace0 \uadf8 \uc989\uc2dc \uc2dc\uac01\ud654 \ud560 \uc218 \uc788\ub3c4\ub85d \ub3d5\ub294 \ud14c\uc2a4\ud305 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\ucef4\ud3ec\ub10c\ud2b8\ub97c \ub208 \uc55e\uc5d0 \ubc14\ub85c \ubcf4\uc5ec\uc8fc\uace0 \uc2e4\uc81c \ub9ac\uc561\ud2b8\uc5d0\uc11c \ub3d9\uc791\ud558\ub294 \uac83 \ucc98\ub7fc \ucef4\ud3ec\ub10c\ud2b8 \ub2e8\uc704\ub85c \uac1c\ubc1c\uc744 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. CDD\ub97c \uc9c0\ud5a5\ud55c\ub2e4\uba74 \uad49\uc7a5\ud788 \uc720\uc6a9\ud55c \uae30\ub2a5\uc774\uba70, \uac1c\ubc1c\uc790\uac00 \uc544\ub2cc \ud611\uc5c5\uc790\uc5d0\uac8c\ub3c4 \uc6d0\ud65c\ud55c \ucee4\ubba4\ub2c8\ucf00\uc774\uc158\uc744 \ub3c4\uc640\uc90d\ub2c8\ub2e4.\\n\ucef4\ud3ec\ub10c\ud2b8 \ub2e8\uc704\ub85c \uac1c\ubc1c\ud558\uae30 \ub54c\ubb38\uc5d0 \uac1c\ubcc4 \ucef4\ud3ec\ub10c\ud2b8\uac00 \uc5b4\ub5bb\uac8c \ub3d9\uc791\ud558\ub294\uc9c0 \ud655\uc778\ud560 \uc218 \uc788\ub2e4\ub294 \uac83 \uc790\uccb4\uac00 \uad49\uc7a5\ud55c \uc774\uc810\uc73c\ub85c \uc791\uc6a9\ud569\ub2c8\ub2e4.\\n\uc608\ub97c \ub4e4\uc5b4 \uc5b4\ub5a4 \ucef4\ud3ec\ub10c\ud2b8\uac00 \ud2b9\uc815 \uba54\ub274 \uc548\uc5d0 \uc874\uc7ac\ud574\uc57c \ud55c\ub2e4\uba74, \uc774\uac83\uc744 \ud655\uc778\ud558\uae30 \uc704\ud574 \ud574\ub2f9 \uba54\ub274\uae4c\uc9c0 \uc811\uadfc\ud574\uc57c \ud560 \uac83\uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc Storybook\uc744 \uc774\uc6a9\ud558\uba74 \ud2b9\uc815 \ucef4\ud3ec\ub10c\ud2b8\ub97c Storybook \uc704\uc5d0 \uc62c\ub824\ub193\uace0 \ud14c\uc2a4\ud2b8\ub97c \ud560 \uc218 \uc788\uc5b4 \ube60\ub974\uac8c \uc791\uc5c5\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\uc778\ud130\ub809\uc158\uc774\ub098 \uc6f9\uc811\uadfc\uc131\uc744 \ud655\uc778\ud574\uc8fc\ub294 \ud50c\ub7ec\uadf8\uc778\ub3c4 \uc874\uc7ac\ud558\uc5ec \ud504\ub860\ud2b8\uc5d4\ub4dc \uac1c\ubc1c\uc5d0\uc11c \uad49\uc7a5\ud788 \uc911\uc694\ud55c \uc5ed\ud560\ub85c \ubd80\uc0c1\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c \ud300\uc740 \uc774\uc678\uc5d0 Cypress\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\ub3c4 \uace0\ub824\ud558\uc600\uc73c\ub098, \uc9c0\ub3c4\uc640 \uacb0\ud569\ub41c \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \ud14c\uc2a4\ud2b8\ud558\uae30\uc5d0 \ub2e4\uc18c \uc5b4\ub824\uc6c0\uc774 \uc788\uc5b4 \uc704 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc744 \uac1c\ubc1c\uc5d0 \ud65c\uc6a9\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c\ub294 \uc704 \ud14c\uc2a4\ud305 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc744 \uc6d0\ud65c\ud788 \ud65c\uc6a9\ud558\uae30 \uc704\ud574 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654\ub97c \uad6c\ucd95\ud588\uc2b5\ub2c8\ub2e4.\\n\\n## Jest\uc640 React Testing Library \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654\\n\\n```yaml\\nname: frontend-test\\n\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n paths:\\n - frontend/**\\n - .github/**\\n\\npermissions:\\n contents: read\\n\\njobs:\\n test:\\n name: test-when-pull-request\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./frontend\\n steps:\\n - name: Checkout PR\\n uses: actions/checkout@v2\\n - name: Install dependencies\\n run: npm install\\n - name: Test\\n run: npm run test\\n```\\n\\n\\n#### \uc774\ubca4\ud2b8 \ud2b8\ub9ac\uac70 \uc124\uc815\\npull_request \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud558\uc600\uc744 \ub54c, \ud574\ub2f9 \uc774\ubca4\ud2b8\uac00 main \ube0c\ub79c\uce58\uc640 develop \ube0c\ub79c\uce58\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud569\ub2c8\ub2e4.\\n\\n#### \ubcc0\uacbd \uc0ac\ud56d \uacbd\ub85c \uc81c\ud55c\\n\ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud560 \ub54c\ub294 frontend \ub514\ub809\ud1a0\ub9ac\uc640 .github \ub514\ub809\ud1a0\ub9ac \ub0b4\uc758 \ud30c\uc77c\ub4e4\uc744 \uace0\ub824\ud558\ub3c4\ub85d \ud588\uc2b5\ub2c8\ub2e4. \ubc31\uc5d4\ub4dc\uc640\uc758 \ud658\uacbd \ubd84\ub9ac\ub97c \uc704\ud574 \uc774\ub7ec\ud55c \uc811\uadfc \uc81c\ud55c\uc744 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n#### \uad8c\ud55c \uc124\uc815\\npermissions\uc740 \uc77d\uae30 \uad8c\ud55c\ub9cc \uc124\uc815\ub418\uc5b4 \uc788\uc5b4 \ucf54\ub4dc\ub098 \ud30c\uc77c\uc744 \ubcc0\uacbd\uc744 \ubc29\uc9c0\ud569\ub2c8\ub2e4.\\n\\n#### \uc791\uc5c5(Job) \uc124\uc815\\ntest\ub77c\ub294 \uc774\ub984\uc758 \uc791\uc5c5\uc744 \uc815\uc758\ud558\uc600\uace0, \uc774 \uc791\uc5c5\uc5d0\uc11c\ub294 Ubuntu \ud658\uacbd\uc5d0\uc11c \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud569\ub2c8\ub2e4. test\ub77c\ub294 \uc774\ub984\uc758 \ud658\uacbd \ubcc0\uc218\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4. \ud14c\uc2a4\ud2b8\ub294 (\uce74\ud398\uc778 \ud300 \ub808\ud3ec\uc9c0\ud1a0\ub9ac\uc758) frontend \ub514\ub809\ud1a0\ub9ac\uc5d0\uc11c \uc791\uc5c5\ud558\ub3c4\ub85d \ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n#### \uc2a4\ud15d(Step) \uc124\uc815\\n\ucf54\ub4dc\ub97c \uccb4\ud06c\uc544\uc6c3\ud558\uace0, \uc758\uc874\uc131\uc744 \uc124\uce58\ud558\uba70, \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud558\ub294 \uc138 \uac00\uc9c0 \ub2e8\uacc4\ub85c \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n\\n\uc774\ub7ec\ud55c \uc124\uc815\uc744 \ud1b5\ud574 PR\uc5d0 \ucf54\ub4dc\uac00 \uc62c\ub77c\uc62c \ub54c \uc790\ub3d9\uc73c\ub85c \ud504\ub860\ud2b8\uc5d4\ub4dc \ud14c\uc2a4\ud2b8\uac00 \uc2e4\ud589\ub429\ub2c8\ub2e4.\\n\\n\uc774\ub7ec\ud55c \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654 \uc804\ub7b5\uc740 \ud504\ub860\ud2b8\uc5d4\ub4dc \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc548\uc815\uc801\uc774\uac8c \uac1c\ubc1c\ud558\uace0 \uc720\uc9c0\ud560 \uc218 \uc788\ub3c4\ub85d \ub3c4\uc640\uc90d\ub2c8\ub2e4.\\n\\n## Storybook\uc758 \ube4c\ub4dc \uc790\ub3d9\ud654\\n\\n```yaml\\nname: storybook-deploy\\n\\non:\\n pull_request:\\n branches:\\n - develop\\n paths:\\n - frontend/**\\n - .github/**\\n\\njobs:\\n build:\\n runs-on: ubuntu-22.04\\n defaults:\\n run:\\n working-directory: ./frontend\\n steps:\\n - name: Setup Repository\\n uses: actions/checkout@v3\\n\\n - name: Set up Node\\n uses: actions/setup-node@v3\\n with:\\n node-version: 18.16.0\\n\\n - name: Install dependencies\\n run: npm install\\n\\n - name: Cache node_modules\\n id: cache\\n uses: actions/cache@v3\\n with:\\n path: \'**/node_modules\'\\n key: ${{ runner.os }}-node-${{ hashFiles(\'**/package-lock.json\') }}\\n restore-keys: |\\n ${{ runner.os }}-node-\\n\\n - name: storybook build\\n run: npm run build-storybook\\n\\n - name: Upload storybook build files to temp artifact\\n uses: actions/upload-artifact@v3\\n with:\\n name: Storybook\\n path: frontend/storybook-static\\n deploy:\\n needs: build\\n runs-on: self-hosted\\n steps:\\n - name: Remove previous version app\\n working-directory: .\\n run: rm -rf dist\\n\\n - name: Download the built file to AWS\\n uses: actions/download-artifact@v3\\n with:\\n name: Storybook\\n path: frontend/dev/dist\\n\\n - name: Move folder\\n working-directory: frontend/dev/\\n run: |\\n rm -rf /home/ubuntu/dist/*\\n cp -r ./dist /home/ubuntu\\n\\n - name: comment PR\\n uses: thollander/actions-comment-pull-request@v1\\n env:\\n GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\\n with:\\n message: \'\ud83d\ude80storybook: https://storybook.carffe.in/\'\\n```\\n\\n\ube44\uc2b7\ud55c \ucf54\ub4dc\uc774\uc9c0\ub9cc, \ub9e4\ubc88 PR\uc774 \uc5f4\ub9b4 \ub54c \ub9c8\ub2e4 \uc2a4\ud1a0\ub9ac\ubd81\uc774 \uc790\ub3d9\uc73c\ub85c \ube4c\ub4dc \ubc0f \ubc30\ud3ec\ub429\ub2c8\ub2e4.\\n\ubc30\ud3ec\uac00 \uc644\ub8cc\ub418\uba74 \ubc30\ud3ec\ub41c URL\uc744 \uc54c\ub824 \ucf54\ub4dc \ub9ac\ubdf0\ud560 \ub54c \ucc38\uace0\ud560 \uc218 \uc788\ub3c4\ub85d \ub3d5\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc0c1 \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\uace0 \uc788\ub294 \ud14c\uc2a4\ud305 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc640 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654 \ubc29\ubc95\uc744 \uc54c\uc544\ubd24\uc2b5\ub2c8\ub2e4."},{"id":"25","metadata":{"permalink":"/25","source":"@site/blog/2023-08-15-flyway.mdx","title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","description":"\uc548\ub155\ud558\uc138\uc694","date":"2023-08-15T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 15\uc77c","tags":[{"label":"hello","permalink":"/tags/hello"},{"label":"world","permalink":"/tags/world"}],"readingTime":7.585,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"25","title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","authors":["boxster"],"tags":["hello","world"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","permalink":"/26"},"nextItem":{"title":"Out of memory trouble shooting","permalink":"/24"}},"content":"\uc548\ub155\ud558\uc138\uc694\\n\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uc800\ud76c \ud300\uc740 flyway\ub97c \uc801\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uac00\uc7a5 \ud070 \uc774\uc720\ub294 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ub370\uc774\ud130\ub97c drop \ud560 \uc218 \uc5c6\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c drop\ud558\ub294 \uac83\uacfc flyway\uac00 \ubb34\uc2a8 \uc0c1\uad00\uc774 \uc788\uae38\ub798 \uc801\uc6a9\ud560\uae4c\uc694.\\n\\n### \uc608\uc2dc \uc0c1\ud669\\n\\n\uc81c\uac00 \uc544\ub798\uc640 \uac19\uc774 Member\ub77c\ub294 entity\ub97c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```java\\nclass Member {\\n\\n private Long id;\\n private String name;\\n}\\n```\\n\uc9c0\uae08\uc758 entity\ub294 \ub450\uac1c\uc758 \ud544\ub4dc \ubc16\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4. \uc5b4\ub290 \ub0a0\ubd80\ud130 Member\uc5d0 email\uc774\ub77c\ub294 \uc815\ubcf4\uac00 \uc788\uc5b4\uc57c\ud55c\ub2e4\ub294 \uc694\uad6c\uc0ac\ud56d\uc774 \uc0dd\uae41\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc800\ud76c\ub294 \uc544\ub798\uc640 \uac19\uc774 email\uc744 \ucd94\uac00\ud569\ub2c8\ub2e4.\\n```java\\nclass Member {\\n\\n private Long id;\\n private String name;\\n private String email;\\n}\\n```\\n\uadf8\ub9ac\uace0 \ub2e4\uc2dc jpa\uc758 ddl-auto \uc18d\uc131 \uc911 create\ub97c \uc0ac\uc6a9\ud574\uc11c \uc0c8\ub85c\uc6b4 \ud14c\uc774\ube14\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\uc874\uc758 \ud14c\uc774\ube14\uc744 \ub2e4 \ub0a0\ub9ac\uba74\uc11c\uc694.\\n\\n\ud558\uc9c0\ub9cc \uc800\ud76c\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ub370\uc774\ud130\ub4e4\uc744 \uadf8\ub0e5 drop\ud574\ub3c4 \ub418\ub294 \uac83\uc77c\uae4c\uc694?\\n\uac1c\ubc1c \uc11c\ubc84\ub77c\ub3c4 \ud798\ub4e4\uac8c \uc313\uc740 \ub370\uc774\ud130\ub4e4\uc744 \ud14c\uc774\ube14\uc774 \uc870\uae08 \ubcc0\uacbd\ub418\uc5c8\ub2e4\uace0 \ub0a0\ub824\ubc84\ub9ac\ub294 \uac83\uc740 \ubc14\ubcf4\uac19\uc740 \uc77c\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub7ec\uba74 ddl-auto\uc758 \ub2e4\ub978 \uc870\uac74\uc778 update\ub97c \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4. \uadf8\ub7ac\ub354\ub2c8 jpa\uac00 \uc544\ub798\uc640 \uac19\uc774 \ucffc\ub9ac\ub97c \uc774\uc058\uac8c \ub9cc\ub4e4\uc5b4 \uc92c\uc2b5\ub2c8\ub2e4.\\n```sql\\nALTER TABLE member\\n ADD COLUMN email varchar(255);\\n```\\nupdate\ub97c \uc0ac\uc6a9\ud558\ub2c8 \uc544\uc8fc \ud3b8\ud558\uac8c \uce7c\ub7fc\uc774 \ucd94\uac00\ub418\ub294 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc5ec\uae30\uc11c \ub610 \uc544\ub798\uc640 \uac19\uc740 \uc694\uad6c\uc0ac\ud56d\uc774 \ucd94\uac00\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\nemail\uc758 \uc81c\uc57d\uc870\uac74\uc73c\ub85c null\uc774 \ub418\uba74 \uc548\ub418\uace0, \uae38\uc774\ub294 20\uc790\uac00 \ub418\uc5b4\uc57c\ud569\ub2c8\ub2e4.\\n\uadf8\ub7ec\uba74 \uc5b4\ub178\ud14c\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uc5ec \ubcc0\uacbd\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\nclass Member {\\n\\n private Long id;\\n private String name;\\n @Column(nullable= false, length = 20)\\n private String email;\\n}\\n```\\n\uc774\ub807\uac8c \ud558\uace0 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc7ac\uc2dc\uc791 \ud588\uc2b5\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uc544\ubb34\ub7f0 ddl\uc774 \ubc1c\uc0dd\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc65c\ub0d0\uba74 Jpa\uc758 ddl-auto: update\uc758 \uc18d\uc131\uc740 \uc81c\uc57d\uc870\uac74\uc774 \ubcc0\uacbd\ub41c \uac83\uc740 \ubc18\uc601\ud574\uc8fc\uc9c0 \uc54a\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub9cc\uc57d \uc774 \uc804\uc758 \ud68c\uc6d0\ub4e4\uc758 email\uc774 null\uc778 row\ub3c4 \uc788\ub2e4\uba74 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694? \uc81c\uc57d\uc870\uac74\uc744 \ubc18\uc601\ud560 \uc218 \uc5c6\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uc2dd\uc73c\ub85c \uc6b4\uc601 \ub3c4\uc911 table\uc758 \uce7c\ub7fc\ub4e4\uc774 \ucd94\uac00\ub418\uac70\ub098, \uc0ad\uc81c\ub418\uac70\ub098, \ud639\uc740 \uc81c\uc57d\uc870\uac74\uc774 \ubcc0\uacbd\ub420 \ub54c update \uc18d\uc131\ub9cc\uc73c\ub85c\ub294 \ubc18\uc601\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n## flyway\\n\\n\uadf8\ub798\uc11c flyway\ub97c \uc0ac\uc6a9\ud588\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 flyway \uc5c6\uc774\ub3c4 \uc774\ub7f0 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubc29\ubc95\uc740 \uac04\ub2e8\ud569\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc788\ub294 \uc11c\ubc84\uc5d0 \uc9c1\uc811 \uc811\uc18d\ud558\uc5ec ddl\uc744 \uc9c1\uc811 \ud558\ub098 \ud558\ub098 \ub2e4 \uc791\uc131\ud558\uba74 \ub429\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ub7f0 \ubc29\uc2dd\uc5d0\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ud558\ub098 \ud558\ub098 \uc9c1\uc811 \uc785\ub825\ud558\ub2e4\ubcf4\ub2c8 \ud734\uba3c \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \uc218\ub3c4 \uc788\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub9e4\ubc88 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uc5d0 \uc811\uc18d\ud574\uc57c\ud55c\ub2e4\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ub9e4\ubc88 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc811\uc18d\uc744 \ud574\uc57c\ud55c\ub2e4\uba74 cd\ub97c \ud558\ub294 \uc774\uc720\uac00 \uc788\uc744\uae4c\uc694?\\n\\n\ud558\uc9c0\ub9cc flyway\ub97c \uc0ac\uc6a9\ud558\uba74 \ud3b8\ud558\uac8c \ubcc0\uacbd\ub41c schema\ub97c \uad00\ub9ac\ud560 \uc218 \uc788\uace0 \uc5b8\uc81c \ubc14\ub00c\uc5c8\ub294\uc9c0 \uc5b4\ub5bb\uac8c \ubc14\ub00c\uc5c8\ub294\uc9c0 \ud655\uc778\ub3c4 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uae00\ub85c\ub294 \uc798 \uc640\ub2ff\uc9c0 \uc54a\uc744 \uc218\ub3c4 \uc788\uc73c\ub2c8 \uc0ac\uc6a9\ubc95\uc744 \ud655\uc778\ud558\uba74\uc11c \uc5b4\ub5a4 \uc7a5\uc810\uc774 \uc788\ub294\uc9c0 \ud655\uc778\ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 flyway \uc758\uc874\uc131\uc744 \ucd94\uac00\ud558\uace0 `resources/db/migration` \ud328\ud0a4\uc9c0\ub97c \ub9cc\ub4ed\ub2c8\ub2e4.\\n\uac70\uae30\uc5d0 file\uc744 \ub9cc\ub4ed\ub2c8\ub2e4. \ud30c\uc77c \uc774\ub984\uc774 \uc911\uc694\ud55c\ub370\uc694 `V1__init.sql` \uc774\ub7ec\ud55c \ubc29\uc2dd\uc73c\ub85c `V{version \uc22b\uc790}__{\uc5b4\ub5a0\ud55c \ud30c\uc77c\uc778\uc9c0\uc5d0 \ub300\ud55c \uc774\ub984}.sql` \uc5b8\ub354\uc2a4\ucf54\uc5b4 2\uac1c\ub294 \ud544\uc218\ub85c \uc791\uc131\ud574\uc57c\ud569\ub2c8\ub2e4.\\n\\n```sql\\ncreate table member(\\n id bigint auto_increment primary key,\\n name varchar(255) null,\\n);\\n```\\n\uc774\ub807\uac8c `V1__init.sql`\uc5d0 \ub300\ud55c \ud30c\uc77c\uc744 \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4. \uc774\uc81c\ub294 email\uc744 \ucd94\uac00\ud55c\ub2e4\ub294 \uc694\uad6c\uc0ac\ud56d\uc744 \ubc18\uc601\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```sql\\nALTER TABLE member\\n ADD COLUMN email varchar(255);\\n```\\n\uc774\ub807\uac8c \uc0c8\ub85c\uc6b4 \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5b4\uc11c \ud574\ub2f9 \uc2a4\ud06c\ub9bd\ud2b8\ub97c \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4. \ud30c\uc77c\uba85\uc774 \uc911\uc694\ud55c\ub370\uc694, \uc774\uc804 \ud30c\uc77c\uc758 \uc22b\uc790\ubcf4\ub2e4 +1 \uc774 \ub418\ub294 \uc22b\uc790\ub97c V \ub4a4\uc5d0 \ubd99\uc785\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc774\ubc88 \ud30c\uc77c\uc740 `V2__add_column_email.sql` \uc774\ub77c\uace0 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc774\uc81c \ub610 \uc2dc\uac04\uc774 \uc9c0\ub098 \ud68c\uc6d0\uc774 \ub9ce\uc544\uc84c\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc email\uc774 \uc5c6\ub294 \uc0ac\uc6a9\uc790\ub3c4 \ub9ce\uc2b5\ub2c8\ub2e4. \uc774 \uc0c1\ud669\uc5d0\uc11c email\uc744 not null\ub85c \ubcc0\uacbd\ud574\uc57c\ud55c\ub2e4\ub294 \uc694\uad6c\uc0ac\ud56d\uc774 \uc0dd\uacbc\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc544\ub798\uc640 \uac19\uc774 \ubc18\uc601\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n```sql\\nALTER TABLE member\\n MODIFY email VARCHAR(20) NOT NULL default \'default\'\\n```\\n\uc774\ub807\uac8c `V3__add_constraints.sql` \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\uba74 null\uc774 \uc788\ub358 row\ub4e4\uc740 email\uc774 default\uac00 \ub418\uace0 not null \uc81c\uc57d\uc870\uac74\uc774 \ud65c\uc131\ud654 \ub41c \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc8fc\uc5b4\uc9c4 \uc694\uad6c\uc0ac\ud56d\uc740 \ubaa8\ub450 \ub9cc\uc871\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uac70\uae30\uc5d0\ub2e4 v1, v2, v3 \uac00 \ub098\ub258\uc5b4\uc838\uc788\uc5b4\uc11c \uc5b4\ub290 \ucee4\ubc0b\ubd80\ud130 \ud574\ub2f9 sql\uc774 \ucd94\uac00\ub418\uc5c8\ub294\uc9c0\ub3c4 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 ddl-auto update\ub97c \uc0ac\uc6a9\ud558\uba74 \ubc18\uc601\ub418\uc9c0 \uc54a\uc558\ub358 \uc81c\uc57d\uc870\uac74\uc758 \ucd94\uac00\ub3c4 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\uba74 ddl-auto\uc758 \uc18d\uc131\uc744 validate\ub85c \ubcc0\uacbd\ud558\uc5ec, db schema\uc640 entity\uc758 \ud544\ub4dc\uac00 \ub2e4\ub974\uba74\\n\uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc774 \uc2e4\ud589\ub418\uc9c0 \uc54a\ub3c4\ub85d \ud574\uc11c \uc880 \ub354 \uc548\uc804\ud55c \uac1c\ubc1c\uc744 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\nflyway\ub294 roll back\uc744 \ud558\ub294 \uac83\uc774 \uc720\ub8cc\ub77c\uc11c, production \uc11c\ubc84\uc5d0\uc11c \ud639\uc740 \ub864\ubc31\uc744 \ud574\uc57c\ud558\ub294 \uc77c\uc774 \uc788\ub294 \uc11c\ubc84\uc5d0\uc11c\ub294 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc9c0 \uc54a\uc9c0\ub9cc,\\n\uc774\uc640 \uac19\uc774 \ub370\uc774\ud130\ub97c drop \ud560 \uc218 \uc5c6\ub294 \uc0c1\ud669\uc774\ub77c\uba74, \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc744 \uc774\uc720\uac00 \uc5c6\uc5b4\ubcf4\uc774\ub294 \uc88b\uc740 \ub3c4\uad6c\uc785\ub2c8\ub2e4.\\n\\n\uc9e7\uc740 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4."},{"id":"24","metadata":{"permalink":"/24","source":"@site/blog/2023-08-06-out-of-memory-trouble-shooting/index.mdx","title":"Out of memory trouble shooting","description":"\uc548\ub155\ud558\uc138\uc694 \ubd80\ub989\ubd80\ub989 \ud5c8\ub9ac\ucf00\uc778 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.","date":"2023-08-06T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 6\uc77c","tags":[{"label":"OOM","permalink":"/tags/oom"},{"label":"java","permalink":"/tags/java"},{"label":"trouble-shooting","permalink":"/tags/trouble-shooting"}],"readingTime":15.43,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"24","title":"Out of memory trouble shooting","authors":["boxster"],"tags":["OOM","java","trouble-shooting"]},"prevItem":{"title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","permalink":"/25"},"nextItem":{"title":"Deadlock trouble shooting","permalink":"/23"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubd80\ub989\ubd80\ub989 \ud5c8\ub9ac\ucf00\uc778 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\ub294 \uc800\ud76c \uce74\ud398\uc778 \ud300\uc758 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub4e4\uc758 \uc0c8\ub85c\uc6b4 \uc815\ubcf4\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\uac70\ub098, \uc800\uc7a5\ud558\ub294 \ub85c\uc9c1\uc5d0\uc11c \uc544\ub798\uc640 \uac19\uc774 OOM(Out of memory)\uac00 \ubc1c\uc0dd\ud588\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n![error-log](./error-log.png)\\n\\n### \uc65c \ubc1c\uc0dd\ud588\uc744\uae4c\\n\\n\uba3c\uc800 \uac04\ub2e8\ud788 \uc800\ud76c\uac00 \ucc98\ud55c \uc0c1\ud669\uc5d0 \ub300\ud574 \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\ucc98\uc74c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc2e4\ud589\ud558\uba74 \uacf5\uacf5 API\ub97c \ud638\ucd9c\ud558\uc5ec \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\uc5d0 \ub300\ud55c \ubaa8\ub4e0 \uc815\ubcf4\ub4e4\uc744 \uac00\uc838\uc640 \uc800\uc7a5\ud569\ub2c8\ub2e4. (\ucda9\uc804\uc18c \uc57d 6\ub9cc \uacf3 + \ucda9\uc804\uae30 \uc57d 23\ub9cc \uae30)\\n\\n\ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \uc815\ubcf4\ub4e4\uc740 \uc218\uc815\uc774 \ub420 \uc218 \uc788\uace0, \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\uac00 \ucd94\uac00\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\ubbc0\ub85c \uc815\ud655\ud55c \uc815\ubcf4\uac00 \uc0ac\uc6a9\uc790\uc5d0\uac8c \uac00\uc7a5 \uc911\uc694\uc2dc\ub418\ub294 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc774\ub7ec\ud55c \uc815\ubcf4\ub4e4\uc774 \ub2a6\uac8c \ubc18\uc601\uc774 \ub41c\ub2e4\uac70\ub098, \ubc18\uc601\uc774 \ub418\uc9c0 \uc54a\ub294\ub2e4\uba74 \uc800\ud76c \uc11c\ube44\uc2a4\ub97c \uc0ac\uc6a9\ud560 \uc0ac\uc6a9\uc790\uac00 \uc5c6\uc744 \uac83\uc774\ub77c \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud558\ub8e8\uc5d0 \ud55c \ubc88 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub4e4\uc758 \uc815\ubcf4\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\uace0, \ucd94\uac00\ub41c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \uc800\uc7a5\ud558\ub294 \ub85c\uc9c1\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub300\ub7b5\uc801\uc778 \ub85c\uc9c1\uc740 \uc544\ub798\uc640 \uac19\uc2b5\ub2c8\ub2e4.\\n```java\\n public void updatePeriodicStations() {\\n List stations = requestStations();\\n stationUpdateService.updateStations(stations);\\n }\\n\\n public void updateStations(List updatedStations) {\\n List stations = stationRepository.findAllFetch();\\n\\n Map savedStationsByStationId = stations.stream()\\n .collect(Collectors.toMap(Station::getStationId, Function.identity()));\\n\\n // \uc800\uc7a5\ub41c \uc815\ubcf4\uc640 \ube44\uad50\ud558\uc5ec \uc0c8\ub85c\uc6b4 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \ucc3e\ub294 \ub85c\uc9c1\\n ...\\n\\n saveAllStations(toSaveStations);\\n updateAllStations(toUpdateStations);\\n\\n saveAllChargers(toSaveChargers);\\n updateAllChargers(toUpdateChargers);\\n }\\n\\n```\\n\uac04\ub2e8\ud558\uac8c \ub9d0\uc500\ub4dc\ub9ac\uba74 `requestStations()` \uba54\uc11c\ub4dc\ub294 \uacf5\uacf5 API\uc5d0\uc11c \ubaa8\ub4e0 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \uc694\uccad\ud558\uace0 \ubc1b\uc544\uc624\ub294 \uba54\uc11c\ub4dc\uc785\ub2c8\ub2e4. 23\ub9cc + 6\ub9cc\uac1c\uc758 \uc815\ubcf4\ub97c \ubc1b\uc544\uc624\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ub9ce\uc740 \uc815\ubcf4\ub97c \ubc1b\uc544\uc624\uace0 \uba54\ubaa8\ub9ac\uc5d0 \uc62c\ub9b0\ub2e4\ub294 \uac83\uc740 \ub204\uac00\ubd10\ub3c4 \ube44\ud6a8\uc728\uc801\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \uc120\ud0dd\uc744 \ud55c \uc774\uc720\ub294 \uacf5\uacf5 API\ub294 \uc800\ud76c\uac00 \uc5b4\ub5a4 \ubc29\uc2dd\uc73c\ub85c \ubcf4\ub0b4\uc904 \uc9c0 \ubaa8\ub978\ub2e4\ub294 \uac83\uc774\uc600\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc5b4\uca54 \uc218 \uc5c6\uc774 23\ub9cc\uac74\uc744 \ubaa8\ub450 \uc694\uccad\ud574\uc57c\ud55c\ub2e4\ub294 \ubd80\ubd84\uc740 \ubc14\uafc0 \uc218 \uc5c6\ub294 \ud55c\uacc4\uc785\ub2c8\ub2e4.\\n\\n\uadf8 \ub2e4\uc74c\uc73c\ub85c\ub294 \uc694\uccad\ud574\uc11c \ubc1b\uc544\uc628 \ub370\uc774\ud130\ub4e4\uacfc \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc800\uc7a5\ub418\uc5b4 \uc788\ub358 \ub370\uc774\ud130\ub4e4\uc744 `findAll()`\uc744 \ud1b5\ud574 \ube44\uad50\ud558\uace0 \uc0c8\ub85c\uc6b4 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 \uc800\uc7a5\ud558\uace0, \uc5c5\ub370\uc774\ud2b8\ub41c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 \uc218\uc815\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ub85c\uc9c1\uc740 \ucd1d (23 + 6) * 2 \ub9cc\uac74\uc758 \uac1d\uccb4 \uc57d 58\ub9cc\uac1c\ub97c Heap \uba54\ubaa8\ub9ac\uc5d0 \uc801\uc7ac\ud569\ub2c8\ub2e4. \ub9ce\ub2e4\uace0\ub294 \uc0dd\uac01\ud588\uc9c0\ub9cc, \uc77c\ub2e8 \uc81c \ub85c\uceec\ud658\uacbd\uc5d0\uc11c\ub294 \uc798 \uc791\ub3d9\ud588\uace0, \uae30\ub2a5 \uad6c\ud604\uc774 \uc6b0\uc120\uc774\uae30 \ub54c\ubb38\uc5d0 \ucd94\ud6c4\uc5d0 \uac1c\uc120\uc744 \ud558\uae30\ub85c \ud558\uace0 \ub118\uc5b4\uac14\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uac1c\ubc1c \uc11c\ubc84 \ubc30\ud3ec\ub97c \ud558\uace0 \ub2e4\uc74c\ub0a0 \uc11c\ubc84\uac00 \uc811\uc18d\uc774 \ub418\uc9c0 \uc54a\ub294 \uac83\uc744 \ud655\uc778\ud588\uace0, \ub85c\uadf8\ub97c \ubcf4\ub2c8 \uc704\uc758 \uc0ac\uc9c4\uacfc \uac19\uc774 OOM\uc774 \ubc1c\uc0dd\ud55c \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ud574\uacb0 \ubc29\uc548\\n\\n\\n### Heap size \uc870\uc808\ud558\uae30\\n\uc77c\ub2e8 \uc784\uc2dc \ubc29\ud3b8\uc73c\ub85c Heap memory\uc758 \ucd5c\ub300 \ud06c\uae30\ub97c \ub298\ub9ac\ub294 \ubc95\uc774\uc600\uc2b5\ub2c8\ub2e4. JVM\uc740 \uc2e4\ud589\ub418\ub294 \ud658\uacbd\uc5d0 \ub530\ub77c \ud799 \uba54\ubaa8\ub9ac\uc758 \ucd5c\ub300 \uc0ac\uc774\uc988\ub97c \uc815\ud569\ub2c8\ub2e4. \ud799 \uba54\ubaa8\ub9ac\ub294 \uc124\uc815\ud558\uc9c0 \uc54a\uc73c\uba74 \ud574\ub2f9 \ud658\uacbd\uc758 \uba54\ubaa8\ub9ac 1/4\ub85c \uc124\uc815\ud569\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc800\ud76c EC2 \uc778\uc2a4\ud134\uc2a4\uc758 \uba54\ubaa8\ub9ac\ub294 \uc57d 2\uae30\uac00\ub85c, \uc57d 500MB\uac00 \ud560\ub2f9\ub418\uc5b4 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub798\uc11c \uc800\ud76c\ub294 \uba54\ubaa8\ub9ac\ub97c \uc870\uae08\uc529 \ub298\ub824\uac00\uba70 \uc870\uc815\ud558\uc5ec \uc57d 1\uae30\uac00\ub85c \ud799 \uba54\ubaa8\ub9ac\uc758 \ucd5c\ub300 \uc0ac\uc774\uc988\ub97c \uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\ud799 \uba54\ubaa8\ub9ac\uc758 \uc124\uc815\uc744 \ud558\ub294 \ubc29\ubc95\uc740 \uac04\ub2e8\ud569\ub2c8\ub2e4.\\n```shell\\njava -Xms512m -Xmx1024m boxster.jar\\n```\\n\uc2e4\ud589\ud560 \ub54c \uc774\ub7ec\ud55c \ubc29\uc2dd\uc73c\ub85c \ud558\uba74 \ucd5c\uc18c \ud799 \uba54\ubaa8\ub9ac \uc0ac\uc774\uc988\ub294 512MB, \ucd5c\ub300 1024MB\ub85c \uc124\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ud398\uc774\uc9d5\ud574\uc11c \uac00\uc838\uc624\uae30\\n\ud799 \uba54\ubaa8\ub9ac\uc758 \uc0ac\uc774\uc988\ub97c \uc870\uc808\ud574\uc11c \ud574\uacb0\ud55c\ub2e4\ub294 \ubd80\ubd84\uc740 \uc784\uc2dc \ubc29\ud3b8\uc774\uc9c0 \ub9cc\uc57d \uc800\ud76c EC2 \ud658\uacbd\uc774 \ub2e4\uc6b4\uadf8\ub808\uc774\ub4dc \ub418\uac70\ub098 \ud55c\ub2e4\uba74 \ub610 OOM\uc774 \ubc1c\uc0dd\ud560 \uac83\uc774 \ubed4\ud569\ub2c8\ub2e4. \uadf8\ub798\uc11c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \ub808\ubca8\uc5d0\uc11c \uc880 \ub354 \ud574\uacb0\ud560 \ubc29\uc548\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n\\nAPI\uc758 \uc694\uccad\uc5d0 \ub300\ud55c \ubd80\ubd84\uc740 \uc694\uccad\ubcf4\ub0b4\ub294 \ud68c\uc0ac\uc758 \uc815\ucc45\uc774 \ubc14\ub00c\uc9c0 \uc54a\ub294 \uc774\uc0c1 \uc800\ud76c\ub294 23\ub9cc\uac74\uc744 \ubaa8\ub450 \ub85c\ub529\ud574\uc57c\ud55c\ub2e4\ub294 \uc810\uc740 \uc5b4\uca54 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uba74 \uc800\ud76c\uac00 \uc81c\uc5b4\ud560 \uc218 \uc788\ub294 \uc720\uc77c\ud55c \ubd80\ubd84\uc740 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \ub370\uc774\ud130\ub97c \uaebc\ub0b4\uc624\ub294 \ubd80\ubd84 \ubc16\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 \uc774\uac83\uc744 \uc5b4\ub5bb\uac8c \uc870\uc808\ud560 \uc218 \uc788\uc744\uae4c\uc694.\\n\\n\uc5ec\ub7ec \ubc29\ubc95\uc744 \ucc3e\uc544\ubcf4\ub358 \uc911 `No Offset`\ubc29\uc2dd\uc73c\ub85c \ub370\uc774\ud130\ub97c \ud398\uc774\uc9d5\ud55c\ub2e4\ub294 \uae00\uc744 \uc77d\uc5c8\uc2b5\ub2c8\ub2e4. \ud398\uc774\uc9d5\uc744 \ud558\uae30\uc704\ud574\uc11c\ub294 \uc5b4\ub514\uc11c\ubd80\ud130 \uc2dc\uc791\ud558\uace0 \uc5b4\ub514\uae4c\uc9c0 \uac00\uc838\uc62c \uac83\uc778\uc9c0 \uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \uadf8 \uc911 \uba3c\uc800 \uc81c\uc77c \uc790\uc8fc \uc0ac\uc6a9\ub418\ub294 Offset \ubc29\uc2dd\uc5d0 \ub300\ud574 \uac04\ub2e8\ud788 \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\ud574\ub2f9 \ubc29\uc2dd\uc740\\n```sql\\nSELECT *\\nFROM station\\nORDER BY id DESC\\nOFFSET 20000\\nLIMIT 10000\\n```\\n\uc774\ub7ec\ud55c \ucffc\ub9ac\ub97c \ub9cc\ub4e4\uc5b4 \uc694\uccad\ud569\ub2c8\ub2e4. station \ud14c\uc774\ube14\uc758 20001\ubc88\uc9f8 \ub808\ucf54\ub4dc\ubd80\ud130 10000\uac1c\uc758 \ub370\uc774\ud130\ub97c \uc694\uccad\ud558\ub294 \ubc29\uc2dd\uc785\ub2c8\ub2e4. \uc774\ub7ec\ud55c \ucffc\ub9ac\ub3c4 \ub098\uc058\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\uc7a5\uc810\uc73c\ub85c\ub294 \uc5b8\uc81c\ub4e0 \ud574\ub2f9 \ud398\uc774\uc9c0\ub85c \uc774\ub3d9\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774 \ucffc\ub9ac\uc5d0\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ub4a4\ub85c \uac08\uc218\ub85d \uc131\ub2a5\uc774 \ub098\ube60\uc9c4\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4. 20001\ubc88\uc9f8 \ub808\ucf54\ub4dc\ubd80\ud130 10000\uac1c\ub97c \uc694\uccad\ud55c\ub2e4\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub294 \uc5b4\uca54 \uc218 \uc5c6\uc774 20001\ubc88\uc9f8 \ub808\ucf54\ub4dc\ub97c \ucc3e\uae30 \uc704\ud574\\n\uc815\ub82c\uc744 \ud558\uace0, \uc815\ub82c\ud55c \ud6c4\uc5d0 20001\ubc88\uc9f8\uae4c\uc9c0 \uc138\uc5b4\uac00\uba70 \uc77d\uace0, \uac70\uae30\uc11c\ubd80\ud130 10000\uac1c\uc758 \ub808\ucf54\ub4dc\ub97c \ubc18\ud658\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n![offset](./offset.png)\\n\\n\ud55c \ubb38\uc7a5\uc73c\ub85c \uc815\uc758\ud558\uba74, \uc21c\uc11c\ub97c \uc54c\uc544\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0 \ub0b4\uac00 \ud544\uc694\ud558\uc9c0 \uc54a\ub294 \ub808\ucf54\ub4dc\ub3c4 \uc77d\uc5b4\uc57c \ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n#### No Offset\\n\uadf8\ub7fc No offset \ubc29\uc2dd\uc5d0 \ub300\ud574 \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uc774\ub984\ub9cc \ub4e4\uc73c\uba74 \uc5b4\ub824\uc6b8 \uac83 \uac19\uc9c0\ub9cc \uadf8\ub0e5 offset\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \ud398\uc774\uc9d5\ud558\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc2a4\ud06c\ub864\uc744 \ub0b4\ub9ac\uba74\uc11c \uc790\ub3d9\uc73c\ub85c \ub9c8\uc9c0\ub9c9\uc758 \ub370\uc774\ud130\ub97c \uae30\uc900\uc73c\ub85c \ub2e4\uc74c \uba87\uac1c\uc758 \ub808\ucf54\ub4dc\ub97c \ubd88\ub7ec\uc624\ub294 \ubc29\uc2dd\uc774\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\ud574\ub2f9 \ubc29\uc2dd\uc740\\n```sql\\nselect *\\nfrom station\\nwhere id < \ub9c8\uc9c0\ub9c9\uc73c\ub85c \ubcf4\ub0b8 id\\norder by id desc\\nlimit 10000;\\n```\\n\uc774\ub7ec\ud55c \ucffc\ub9ac\ub85c \uc791\ub3d9\ud569\ub2c8\ub2e4. \uc544\uae4c\uc640\ub294 \ub2e4\ub978 \ubd80\ubd84\uc740 where \uc808\uc5d0 `\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ubcf4\ub0b8 id`\ub77c\ub294 \uc815\ubcf4\uac00 \ud544\uc694\ud558\ub2e4\ub294 \ubd80\ubd84\uacfc, offset\uc774 \uc0ac\ub77c\uc9c4 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\\n\uac19\uc740 \uacb0\uacfc\ub97c \ub9cc\ub4e4\uc5b4\ub0b4\ub294 \ucffc\ub9ac\uc9c0\ub9cc, \ud558\ub098\uac00 \ucd94\uac00\ub418\uace0 \ud558\ub098\uac00 \uc0ac\ub77c\uc84c\ub2e4\ub294 \uac83\uc740 \ucd94\uac00\ub41c \ubd80\ubd84\uc774 \uc0ac\ub77c\uc9c4 \ubd80\ubd84\uc744 \ub300\uc2e0\ud55c\ub2e4\ub294 \ub73b\uc774\uaca0\uc8e0.\\n\\n\uc774 \uc774\ub7ec\ud55c \ubc29\uc2dd\uc758 \ub2e8\uc810\uc740 offset\uc744 \uc774\uc6a9\ud55c \ubc29\uc2dd\uacfc\ub294 \ub2e4\ub974\uac8c page\ub97c \uc9c0\uc815\ud574\uc11c \ub3cc\uc544\uac00\uae30\ub294 \ud798\ub4ed\ub2c8\ub2e4.\\n\\n![no offset](./no-offset.png)\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ubcf4\ub0b8 id\ub97c \ubc1b\uc544 \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574 \ud574\ub2f9 id\uc5d0\uc11c\ubd80\ud130 \ub808\ucf54\ub4dc\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4. \uad73\uc774 \ud544\uc694\uc5c6\ub294 \ub808\ucf54\ub4dc\ub97c \uc77d\uc744 \ud544\uc694 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc774 \uc88b\uc544\uc84c\uc744 \uac83\uc774\ub77c \uc608\uc0c1\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc131\ub2a5 \ucc28\uc774\\n\\n\ubc14\ub85c \ud55c\ubc88 \ub450 \uac1c\uc758 \ucffc\ub9ac\ub97c \uc2e4\ud589\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![test](./test.png)\\n\\n\uc704\uc758 \ucffc\ub9ac\ub294 no offset, \uc544\ub798\ub294 offset \ubc29\uc2dd\uc785\ub2c8\ub2e4. \ud604\uc7ac \ub370\uc774\ud130\uac00 6\ub9cc\uac74 \ub4e4\uc5b4\uc788\ub294 \ud14c\uc774\ube14\uc758 \uc870\ud68c \uae30\uc900\uc73c\ub85c \uc57d 10\ubc30 \uac00\ub7c9 \uc131\ub2a5\uc774 \ucc28\uc774\ub098\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc2e4\ud589 \uacc4\ud68d\ub3c4 \uac04\ub2e8\ud788 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 offset \ubc29\uc2dd\uc758 \uc2e4\ud589 \uacc4\ud68d\uc785\ub2c8\ub2e4.\\n![offset explain](./offset-explain.png)\\n\\n type \uce7c\ub7fc\uc744 \ubcf4\uc2dc\uba74 `index`\ub77c\uace0 \ub418\uc5b4 \uc788\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc5ec\uae30\uc11c index \uc811\uadfc \ubc29\ubc95\uc740\\n \uc778\ub371\uc2a4\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc544\ub2cc \uc778\ub371\uc2a4\ub97c \ucc98\uc74c\ubd80\ud130 \ub05d\uae4c\uc9c0 \uc77d\ub294 full scan\uc744 \ub73b\ud569\ub2c8\ub2e4. \uadf8\ub798\uc11c \uadf8\ub2e4\uc9c0 \ud6a8\uc728\uc801\uc774\uc9c0 \ubabb\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 rows \uce7c\ub7fc\uc5d0\ub294 `40010`\uc774\ub77c\uace0 \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ubd80\ubd84\uc740 \uc81c\uac00 offset\uc744 40000, limit\uc744 10\uc73c\ub85c \ub450\uc5c8\uae30 \ub54c\ubb38\uc5d0 40010d\uc758 row\ub97c\\n\uc77d\uc5b4\uc57c\ud55c\ub2e4\uace0 \uc608\uc0c1 \uac12\uc744 \ub098\ud0c0\ub0b8 \uac83\uc785\ub2c8\ub2e4.\\n\\n\ub2e4\uc74c\uc740 no offset \ubc29\uc2dd\uc758 \uc2e4\ud589 \uacc4\ud68d\uc785\ub2c8\ub2e4.\\n![no offset explain](./no-offset-explain.png)\\n\uc544\uae4c\uc640\ub294 \ub2e4\ub974\uac8c type \uce7c\ub7fc\uc740 `range`\uc785\ub2c8\ub2e4. range \uc811\uadfc \ubc29\uc2dd\uc740 \uc778\ub371\uc2a4\ub97c \ud558\ub098\uc758 \uac12\uc774 \uc544\ub2c8\ub77c \ubc94\uc704\ub85c \uac80\uc0c9\ud558\ub294 \uacbd\uc6b0\ub97c \uc758\ubbf8\ud569\ub2c8\ub2e4.\\n\uc880 \uc804\uc758 index \uc811\uadfc \ubc29\uc2dd\uacfc\ub294 \ub2e4\ub974\uac8c \ud6e8\uc52c \ud6a8\uc728\uc801\uc778 \uc811\uadfc \ubc29\uc2dd\uc785\ub2c8\ub2e4. \uadf8\ub9ac\uace0 rows\ub3c4 \ub2ec\ub77c\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc9c4\uc9dc \ud574\uacb0\ud558\uae30\\n\uc774\uc81c \uc5f4\uc2ec\ud788 \ud398\uc774\uc9d5 \ucc98\ub9ac\ub97c \ud588\uc73c\ub2c8 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc5d0\uc11c \ud574\uacb0\uc744 \ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5b4\uc57c\ud569\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc740 \ub3d9\uc801 \ucffc\ub9ac \uc0dd\uc131\uc744 \ub3c4\uc640\uc8fc\ub294 Query DSL\uc744 \ub3c4\uc785\ud558\uc9c0 \uc54a\uc558\uace0 \uc544\uc9c1\uae4c\uc9c4 \uad73\uc774 \ud544\uc694\ud558\uc9c0 \uc54a\uc544\uc11c no offset \ubc29\uc2dd\uc744 jpa\uc758 jpql\uc744 \ud1b5\ud574 \uad6c\ud604\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \uccab \ud398\uc774\uc9c0\ub294 id\uc758 \uad00\uacc4\uc5c6\uc774 \uc6d0\ud558\ub294 \uac2f\uc218\ub9cc\ud07c\ub9cc \uac00\uc838\uc624\uba74 \ub429\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub450\ubc88\uc9f8 \ud398\uc774\uc9c0\ubd80\ud130\ub294 id\ub97c \ubc1b\uc544 \uadf8 \ub2e4\uc74c\ubd80\ud130 \ubc18\ud658\ud558\uba74 \ub429\ub2c8\ub2e4.\\n```java\\npublic interface StationRepository extends Repository {\\n\\n @Query(\\"SELECT s FROM Station s INNER JOIN FETCH s.chargers ORDER BY s.stationId\\")\\n List findAllByOrder(Pageable pageable);\\n\\n @Query(\\"SELECT s FROM Station s INNER JOIN FETCH s.chargers WHERE s.stationId > :stationId ORDER BY s.stationId\\")\\n List findAllByPaging(@Param(\\"stationId\\") String stationId, Pageable pageable);\\n}\\n```\\n\uadf8\ub7fc \uc544\uae4c update\ub97c \ud574\uc8fc\ub358 \uba54\uc11c\ub4dc\uc5d0\uc11c \uc870\uae08 \uc218\uc815\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\n public void updatePeriodicStations() {\\n List stations = getStations();\\n // \ucc98\uc74c\uc5d0\ub294 station\uc758 id\uac00 null\\n String lastStationId = null;\\n for (int i = 0; i < stations.size() / LIMIT + 1; i++) {\\n // \ub9c8\uc9c0\ub9c9 id\ub97c \uba54\uc11c\ub4dc \uc2e4\ud589\ud560 \ub54c\ub9c8\ub2e4 \ubcc0\uacbd\ud574\uc900\ub2e4.\\n lastStationId = stationUpdateService.updateStations(stations, lastStationId, LIMIT);\\n }\\n }\\n\\n public String updateStations(List updatedStations, String lastStationId, int limit) {\\n List savedStations = getStations(lastStationId, limit);\\n\\n Map savedStationsByStationId = stations.stream()\\n .collect(Collectors.toMap(Station::getStationId, Function.identity()));\\n\\n // \uc800\uc7a5\ub41c \uc815\ubcf4\uc640 \ube44\uad50\ud558\uc5ec \uc0c8\ub85c\uc6b4 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \ucc3e\ub294 \ub85c\uc9c1\\n ...\\n\\n saveAllStations(toSaveStations);\\n updateAllStations(toUpdateStations);\\n\\n saveAllChargers(toSaveChargers);\\n updateAllChargers(toUpdateChargers);\\n // \uac00\uc838\uc628 list\uc5d0\uc11c \uc81c\uc77c \ub9c8\uc9c0\ub9c9 station\uc758 id\ub97c \ubc18\ud658\\n return getLastStationId(savedStations);\\n }\\n // \ud398\uc774\uc9d5 \ucc98\ub9ac\\n private List getStations(String stationId, int limit) {\\n // Id \uac00 null \uc774\ub77c\uba74 \uccab \ud398\uc774\uc9c0\uc774\uae30 \ub54c\ubb38\uc5d0 limit \uc0ac\uc774\uc988\ub9cc\ud07c select\\n if (stationId == null) {\\n return stationRepository.findAllByOrder(Pageable.ofSize(limit));\\n }\\n // \uc544\ub2c8\ub77c\uba74 station Id \ubd80\ud130 limit \ub9cc\ud07c\\n return stationRepository.findAllByPaging(stationId, Pageable.ofSize(limit));\\n }\\n```\\n\uc774\ub807\uac8c \ub418\uba74 \uc6d0\ub798 23\ub9cc\uac1c\ub97c \ud55c\uaebc\ubc88\uc5d0 \uac00\uc838\uc624\ub358 \ub85c\uc9c1\uc744 \ub098\ub20c \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 Heap \uba54\ubaa8\ub9ac\uc758 \uc5ec\uc720\uac00 \uc0dd\uae38 \uac83\uc785\ub2c8\ub2e4.\\n\\n### \uc9c4\uc9dc \ud655\uc778\ud574\ubcf4\uae30\\n\\n\ubb3c\ub860 GC\uc758 \ub3d9\uc791\uc774 \uc5b4\ub5a8\uc9c0 \ubaa8\ub974\uaca0\uc9c0\ub9cc 23\ub9cc\uac1c \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uac83\ubcf4\ub2e4 5000\uac1c \ud639\uc740 \ub354 \uc801\uac8c \uc0dd\uc131\ud558\ub294 \uac83\uc774 Heap \uba54\ubaa8\ub9ac\ub97c \uc801\uac8c \uc0ac\uc6a9\ud560 \uac83\uc784\uc744 \uc720\ucd94\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uc9c1\uc811 \ud655\uc778\ud574\ubcf4\uae30 \uc804\uae4c\uc9c0\ub294 \ud655\uc2e0\ud560 \uc218 \uc5c6\uc73c\ub2c8 \uac04\ub2e8\ud788 `Runtime` \ud074\ub798\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud574\uc8fc\ub294 `totalMemory()`, `freeMemory()` \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n @Test\\n void \ud398\uc774\uc9d5\uc744_\uc0ac\uc6a9\ud55c_\uc870\ud68c() {\\n List stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));\\n\\n long total = Runtime.getRuntime().totalMemory();\\n long free = Runtime.getRuntime().freeMemory();\\n System.out.println(\\"paging \uc0ac\uc6a9 \uc911\uc778 \uba54\ubaa8\ub9ac: \\" + ((total - free) / 1024 / 1024) + \\"MB\\");\\n }\\n\\n @Test\\n void \ud398\uc774\uc9d5\uc744_\uc0ac\uc6a9\ud558\uc9c0_\uc54a\uace0_\uc870\ud68c() {\\n List stations = stationRepository.findAllFetch();\\n\\n long total = Runtime.getRuntime().totalMemory();\\n long free = Runtime.getRuntime().freeMemory();\\n\\n System.out.println(\\"findAll() \uc0ac\uc6a9 \uc911\uc778 \uba54\ubaa8\ub9ac: \\" + ((total - free) / 1024 / 1024) + \\"MB\\");\\n }\\n```\\n\\n![findAll](./findAll.png)\\n![paging](./paging.png)\\n\ud655\uc5f0\ud788 \ucc28\uc774\uac00 \ub098\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \ud14c\uc2a4\ud2b8\ucf54\ub4dc\uc5d0\uc11c\ub294 23\ub9cc\uac74\uc758 API \uc694\uccad\uc740 \uac19\uc740 \uc870\uac74\uc774\ub2c8 \ubc30\uc81c\ud558\uace0 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub85c\uc368 \ud558\ub098\uc758 \ubb38\uc81c\uac00 \ub610 \ud574\uacb0\ub41c \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\uc9c1 \ubc30\uc6b0\ub294 \ub2e8\uacc4\ub77c \ud639\uc2dc \ud2c0\ub9b0 \uc810\uc774 \uc788\ub2e4\uba74 \uc9c0\uc801 \ubd80\ud0c1\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## Reference\\n\\n- \ub9ac\uc5bc \ub9c8\uc774 \uc5d0\uc2a4\ud050\uc5d8 8.0\\n- https://jojoldu.tistory.com/528"},{"id":"23","metadata":{"permalink":"/23","source":"@site/blog/2023-07-31-deadlokc-trouble-shooting.mdx","title":"Deadlock trouble shooting","description":"\uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720","date":"2023-07-31T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 31\uc77c","tags":[{"label":"deadlock","permalink":"/tags/deadlock"},{"label":"trouble-shooting","permalink":"/tags/trouble-shooting"}],"readingTime":12.565,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"23","title":"Deadlock trouble shooting","authors":["boxster"],"tags":["deadlock","trouble-shooting"]},"prevItem":{"title":"Out of memory trouble shooting","permalink":"/24"},"nextItem":{"title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","permalink":"/22"}},"content":"## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\ub294 \uc800\ud76c \uce74\ud398\uc778 \ud300\uc758 \ud63c\uc7a1\ub3c4 \uc800\uc7a5 \ubc0f \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\ub294 \ub85c\uc9c1\uc5d0\uc11c dead Lock\uc774 \ubc1c\uc0dd\ud558\uc5ec mysql\uacfc connection\uc744 \uc783\ub294 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n```shell\\n------------------------\\nLATEST DETECTED DEADLock\\n------------------------\\n2023-07-21 01:49:54 281472560787424\\n*** (1) TRANSACTION:\\nTRANSACTION 1000560, ACTIVE 373 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328\\nMySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST414511\', \'01\', \'2023-07-21 08:27:43\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 08:27:43\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (1) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap\\n\\n*** (1) WAITING FOR THIS Lock TO BE GRANTED:\\nRECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting\\n\\n*** (2) TRANSACTION:\\nTRANSACTION 946331, ACTIVE 507 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432\\nMySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST412801\', \'11\', \'2023-07-21 10:48:20\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 10:48:20\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (2) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap\\n\\n*** (2) WAITING FOR THIS Lock TO BE GRANTED:\\nRECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting\\n\\n\\n```\\n\uc2e4\uc81c **\uac1c\ubc1c \uc11c\ubc84**\uc5d0\uc11c \ubc1c\uc0dd\ud55c \ub370\ub4dc\ub77d\uc758 \ub85c\uadf8\uc785\ub2c8\ub2e4. \ud574\ub2f9 \ub85c\uadf8\ub294 charger_status\uc5d0 \uc800\uc7a5 \uc2dc \uc11c\ub85c XLock\uc744 \ud68d\ub4dd\ud558\uc9c0 \ubabb\ud558\uc5ec \uc0dd\uae30\ub294 \uc5d0\ub7ec\uc785\ub2c8\ub2e4.\\n\\n## Mysql Dead Lock\uc774\ub780\\n\\n\uadf8\ub7fc Dead Lock\uc740 \uc65c \uc0dd\uae30\uace0 \uc5b8\uc81c \uc0dd\uae38\uae4c\uc694?\\n\uc800\ub294 \uc774 Log\ub97c \uc9c1\uc811 \ub9c8\uc8fc\ud558\uae30 \uc804\uae4c\uc9c0\ub294 Dead Lock\uc774 \uadf8\ub0e5 Lock\uc758 \uc2dc\uac04\uc774 \uc624\ub798 \uac78\ub9b4 \ub54c \uc0dd\uae30\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uadf8\ub807\uac8c \uac04\ub2e8\ud558\uac8c \ubc1c\uc0dd\ud558\ub294 \uac83\uc740 \uc544\ub2c8\uc600\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc0c1\ud638 \ubc30\uc81c(Mutual Exclusion): MySQL\uc740 \uae30\ubcf8\uc801\uc73c\ub85c \ud2b8\ub79c\uc7ad\uc158 \ub0b4\uc5d0\uc11c \uc7a0\uae08(Lock)\uc744 \uc0ac\uc6a9\ud558\uc5ec \ub370\uc774\ud130\uc758 \uc0c1\ud638 \ubc30\uc81c\ub97c \uc81c\uc5b4\ud569\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub450 \uac1c \uc774\uc0c1\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uac19\uc740 \ub370\uc774\ud130\ub97c \ub3d9\uc2dc\uc5d0 \ubcc0\uacbd\ud558\ub824\uace0 \ud560 \ub54c, \ud574\ub2f9 \ub370\uc774\ud130\uc5d0 \ub300\ud55c \uc7a0\uae08\uc774 \uc124\uc815\ub418\uc5b4 \uc0c1\ud638 \ubc30\uc81c \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4.\\n\\n2. \uc810\uc720\uc640 \ub300\uae30(Hold and Wait): \ud2b8\ub79c\uc7ad\uc158\uc774 \uc774\ubbf8 \ud558\ub098 \uc774\uc0c1\uc758 \ub370\uc774\ud130\ub97c \uc7a0\uadfc \uc0c1\ud0dc\uc5d0\uc11c \ub2e4\ub978 \ub370\uc774\ud130\uc758 \uc7a0\uae08\uc744 \uc5bb\uae30 \uc704\ud574 \ub300\uae30\ud558\uace0 \uc788\ub294 \uacbd\uc6b0 \uc810\uc720\uc640 \ub300\uae30 \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4. \uc989, \ud2b8\ub79c\uc7ad\uc158\uc774 \uc790\uc2e0\uc774 \uc810\uc720\ud55c \ub370\uc774\ud130\ub97c \uc720\uc9c0\ud55c \uc0c1\ud0dc\uc5d0\uc11c \ub2e4\ub978 \ub370\uc774\ud130\uc5d0 \ub300\ud55c \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\uace0 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n\\n3. \ube44\uc120\uc810(Non-Preemption): MySQL\uc5d0\uc11c\ub294 \uae30\ubcf8\uc801\uc73c\ub85c \ud2b8\ub79c\uc7ad\uc158\uc774 \ub2e4\ub978 \ud2b8\ub79c\uc7ad\uc158\uc774 \uc810\uc720\ud55c \ub370\uc774\ud130\uc758 \uc7a0\uae08\uc744 \uac15\uc81c\ub85c \ud574\uc81c\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ube44\uc120\uc810 \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4.\\n\\n4. \uc21c\ud658 \ub300\uae30(Circular Wait): \ub450 \uac1c \uc774\uc0c1\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uac01\uac01 \uc11c\ub85c\uac00 \uae30\ub2e4\ub9ac\ub294 \ub370\uc774\ud130\uc758 \uc7a0\uae08\uc744 \ubcf4\uc720\ud574\uc57c \uc21c\ud658 \ub300\uae30 \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4. \uc608\ub97c \ub4e4\uba74, \ud2b8\ub79c\uc7ad\uc158 A\uac00 \ub370\uc774\ud130 X\uc758 \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\uace0, \ud2b8\ub79c\uc7ad\uc158 B\ub294 \ub370\uc774\ud130 Y\uc758 \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\uba70, \ud2b8\ub79c\uc7ad\uc158 C\ub294 \ub370\uc774\ud130 Z\uc758 \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\ub294 \uc0c1\ud0dc\uac00 \ubc1c\uc0dd\ud55c\ub2e4\uba74 \uc21c\ud658 \ub300\uae30 \uc870\uac74\uc774 \uc131\ub9bd\ud569\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uae30\ubcf8 \ucef4\ud4e8\ud130 \uc2dc\uc2a4\ud15c\uc758 dead Lock\uacfc \uc720\uc0ac\ud55c \uc870\uac74\uc785\ub2c8\ub2e4. \uc774 \ubd80\ubd84\uc744 \ubaa8\ub450 \ub9cc\uc871\ud574\uc57c \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\ud558\ub098\uc529 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \uac1c\ubc1c \uc11c\ubc84\uc5d0\uc11c \ubc1c\uc0dd\ud55c \ub370\ub4dc\ub77d\uc73c\ub85c \uc0b4\ud3b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```shell\\n*** (1) TRANSACTION:\\nTRANSACTION 1000560, ACTIVE 373 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328\\nMySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST414511\', \'01\', \'2023-07-21 08:27:43\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 08:27:43\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (1) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap\\n\\n\\n-------------------------------------------------------------------------\\n*** (2) TRANSACTION:\\nTRANSACTION 946331, ACTIVE 507 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432\\nMySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST412801\', \'11\', \'2023-07-21 10:48:20\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 10:48:20\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (2) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap\\n```\\n\\n1\ubc88 \ud2b8\ub79c\uc7ad\uc158 1000560\uc774 charge_status \ud14c\uc774\ube14\uc5d0 insert ~~~ on duplicate key update ~~~ \ucffc\ub9ac\ub97c \ubc1c\uc0dd\uc2dc\ud0a4\uae30 \uc704\ud574 `space id 64 page no 742 n bits 424 index PRIMARY of table` \uc5d0 X Lock\uc744 \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4\\n\uadf8\ub9ac\uace0 2\ubc88 \ud2b8\ub79c\uc7ad\uc158 946331 \ub3c4 \ub611\uac19\uc740 \ud14c\uc774\ube14\uc5d0 \ube44\uc2b7\ud55c \ucffc\ub9ac\ub97c \ubc1c\uc0dd\uc2dc\ud0a4\ub824\uace0 \ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ud574\ub2f9 \ud2b8\ub79c\uc7ad\uc158\ub3c4 X Lock\uc744 \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc5d0 \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud55c \uc774\uc720\\n\uba3c\uc800 \uc800\ud76c \ud300\uc740 \uacf5\uacf5 API\ub97c \ud1b5\ud574 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c cron\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub3c4 \uc8fc\uae30\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uac31\uc2e0\ud560 \uacbd\uc6b0 \uc0c8\ub85c \uc0dd\uae34 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uba74 \ucd94\uac00\ud574\uc918\uc57c\ud558\uace0, \uc0c8\ub85c \uc0dd\uae34 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uba74 \uc0c8\ub85c\uc6b4 \ucda9\uc804\uae30\uac00 \uc788\uc744\ud14c\uace0, \uadf8\uc5d0\ub530\ub978 \ucda9\uc804\uae30 \uc0c1\ud0dc\ub3c4 \ucd94\uac00\ud574\uc918\uc57c\ud569\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c\uc758 \uc815\ubcf4\uac00 \ubc14\ub00c\ub294 \uac83\uc5d0 \ub300\ud574\uc11c\ub3c4 \uc5c5\ub370\uc774\ud2b8 \ud574\uc918\uc57c\ud569\ub2c8\ub2e4. \uc774\ub807\uac8c \ub41c\ub2e4\uba74 \uc5ec\ub7ec\ubc88\uc758 \ubd84\uae30 \ucc98\ub9ac\ub85c application \ub808\ubca8\uc5d0\uc11c \ud574\uacb0\ud558\ub294 \uac83\uc774 \ub098\uc744\uc9c0 \ud639\uc740 mysql\uc758 \ubb38\ubc95 \uc911 `INSERT ~~ ON DUPLICATE KEY UPDATE ~~`\uc744 \uc774\uc6a9\ud560\uae4c \uace0\ubbfc\uc744 \ud588\uc5c8\ub294\ub370\uc694.\\n\\n\uadf8 \uc911 \ud6c4\uc790\ub97c \ud0dd\ud55c \uc774\uc720\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \ucda9\uc804\uae30\uc758 \uc815\ubcf4\ub294 \ud558\ub8e8\uc5d0 \uacf5\uacf5 API\ub97c \uc694\uccad\ud560 \uc218 \uc788\ub294 key \uc81c\ud55c\uc774 \uc788\uace0 \uc9c0\ub3c4 \uae30\ubc18\uc73c\ub85c \uac80\uc0c9\ud560 \uc218 \uc788\ub294 \uc870\uac74\uc774 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \uc2e4\uc2dc\uac04\uc73c\ub85c \uc694\uccad\ud560 \uc218 \uc5c6\ub294 \uad6c\uc870\uc785\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc694\uccad key \uc81c\ud55c\uc744 \ub118\uc9c0 \uc54a\ub294 \uc120\uc5d0\uc11c \uc790\uc8fc \uc694\uccad\uc744 \ud574\uc57c\ud569\ub2c8\ub2e4. \uadf8\ub807\uac8c\ud574\uc57c \uc0ac\uc6a9\uc790\uc5d0\uac8c \uc815\ubcf4\uc5d0 \ub300\ud55c \uc2e0\ub8b0\ub97c \uc904 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc790\uc8fc \uc694\uccad\ub418\ub294 \uc791\uc5c5\uc5d0 Application \ub808\ubca8\uc5d0\uc11c \uad6c\ud604\ud55c\ub2e4\uba74, findAll() \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 23\ub9cc\uac74\uc758 \ucda9\uc804\uae30 \uc0c1\ud0dc \uc815\ubcf4\ub97c \uba54\ubaa8\ub9ac\uc5d0 \ub85c\ub529\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 api\uc758 \uc694\uccad\uc744 \ud1b5\ud574 \uc5bb\uc740 23\ub9cc+n\uac74\uc758 \ucda9\uc804\uae30 \uc0c1\ud0dc \uc815\ubcf4\ub97c \uba54\ubaa8\ub9ac\uc5d0 \uc62c\ub9bd\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ud574\ub2f9 \uc815\ubcf4\uac00 \uc788\ub294\uc9c0 \ube44\uad50\ud569\ub2c8\ub2e4. \ud574\ub2f9 \uc815\ubcf4\uac00 findAll()\ub85c \ucc3e\uc544\uc628 list\uc5d0 \uc5c6\uc73c\uba74 Insert, \ud574\ub2f9 \uc815\ubcf4\uac00 \uc788\ub2e4\uba74 update\ub97c \ud1b5\ud574 \uac31\uc2e0\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \ud55c\ub2e4\uba74 \ucd1d batch Insert, Update\ub97c \ud1b5\ud574 2\ubc88\uc758 \ucffc\ub9ac + 46\ub9cc n\uac74\uc758 \ucda9\uc804\uae30 \uc815\ubcf4\ub97c \uba54\ubaa8\ub9ac\uc5d0 \uc62c\ub824 \ube44\uad50\ud558\ub294 \uc5f0\uc0b0\uc774 \uc0dd\uae38 \uac83 \uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc `INSERT ~~ ON DUPLICATE KEY UPDATE ~~` \ub97c \uc0ac\uc6a9\ud55c\ub2e4\uba74 batch insert\ub97c \ud1b5\ud574 1\ubc88\uc758 \ucffc\ub9ac\ub85c \ud574\uacb0 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uba54\ubaa8\ub9ac\uc5d0 \ub370\uc774\ud130\ub3c4 \uc801\uc7ac\ud558\uc9c0 \uc54a\uace0 \ub9d0\uc774\uc8e0.\\n\\n\ud558\uc9c0\ub9cc \ubcf4\uc168\ub358 \uac83\uacfc \uac19\uc774 \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc774\uc720\ub294 `INSERT ~~ ON DUPLICATE KEY UPDATE ~~` \uc758 \ud2b9\uc218\ud55c Lock mechanism \ub54c\ubb38\uc785\ub2c8\ub2e4. \ud574\ub2f9 \ucffc\ub9ac\ub294\\n1. \uba3c\uc800 \uc0bd\uc785\ud558\ub824\ub294 \ud589\uc774 \ud14c\uc774\ube14\uc5d0 \uc874\uc7ac\ud558\ub294\uc9c0 \ud655\uc778\ud569\ub2c8\ub2e4.\\n2. \uadf8\ub9ac\uace0 \uc77d\uc740 record\uc5d0 \ub300\ud574 S Lock\uc744 \uc124\uc815\ud569\ub2c8\ub2e4.\\n3. \uadf8\ub9ac\uace0 \ud574\ub2f9 record\uac00 duplicate key\ub77c\ub294 \uc870\uac74\uc774\ub77c\uba74 X Lock\uc744 \uc124\uc815\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubc29\uc2dd\uc73c\ub85c \uc791\ub3d9\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\uc774\ub7f0 \ubc29\uc2dd\uc774 \uc65c \ubb38\uc81c\uac00 \ub420 \uc218 \uc788\ub294\uc9c0 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\uba3c\uc800 \uc790\uc2e0\uc774 \uc77d\uc740 record\uc5d0 S Lock\uc744 \uac01\uac01\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uc124\uc815\ud569\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \uc5c5\ub370\uc774\ud2b8\ub97c \ud558\uae30\uc704\ud574 record\uc5d0 X Lock\uc744 \uc124\uc815\ud558\ub824\uace0 \ud569\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uac01\uac01\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uc11c\ub85c S Lock\uc744 \uc124\uc815\ud588\uae30 \ub54c\ubb38\uc5d0 S Lock\uc744 \ubc18\ub0a9\ud558\uace0 X Lock\uc744 \uc124\uc815\ud558\ub824\uace0 \ud574\ub3c4 \ub450 \ud2b8\ub79c\uc7ad\uc158 \ubaa8\ub450 \uae30\ub2e4\ub9ac\ub294 \ub370\ub4dc\ub77d \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4. \uc774\ub7f0 \uc0c1\ud669\uc774 \ub418\uba74 \uc544\uae4c \uc704\uc5d0\uc11c \ub9d0\uc500\ub4dc\ub838\ub358 \ub370\ub4dc\ub77d\uc758 \uc870\uac74 4\uac00\uc9c0\uac00 \ub2e4 \ub9cc\uc871\ud558\uace0 \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n### \ud574\uacb0 \ubc29\uc548\\n\\n\ud574\uacb0 \ubc29\uc548\uc740 \uc5ec\ub7ec\uac00\uc9c0\uac00 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uadf8 \uc911 \uc81c \uc218\uc900\uc5d0\uc11c \uc0dd\uac01\ud560 \uc218 \uc788\ub294 \ubc29\ubc95\uc740 2\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. \ud2b8\ub79c\uc7ad\uc158\uc744 \uc791\uac8c \ubd84\ub9ac\\n\ud2b8\ub79c\uc7ad\uc158\uc744 \uc624\ub798 \uac00\uc9c0\uace0 \uc788\uc73c\uba74 Lock\uc744 \uac00\uc9c0\uace0 \uc788\ub294 \uc2dc\uac04\uc774 \uc624\ub798\uac78\ub9bd\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \ud2b8\ub79c\uc7ad\uc158\uc744 \uc791\uac8c \ubd84\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud398\uc774\uc9d5\uc744 \ud1b5\ud574 \ud2b8\ub79c\uc7ad\uc158\uc744 \uc791\uac8c \ubd84\ub9ac\ud558\ub2e4\ubcf4\uba74 \ucffc\ub9ac\uac00 \uc5ec\ub7ec\ubc88 \ub098\uac00 \uc131\ub2a5\uc0c1 \ubb38\uc81c\uac00 \uc0dd\uae38 \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n2. `INSERT ~~ ON DUPLICATE KEY UPDATE ~~` \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uae30\\n\ud574\ub2f9 sql\uc774 \uc544\ub2cc `INSERT IGNORE`\uc744 \uc0ac\uc6a9\ud558\uc5ec \ucd94\uac00\ub41c \uc815\ubcf4\ub9cc \ub123\uace0, update\ub294 \ub2e4\ub978 \uc791\uc5c5\uc73c\ub85c \ubd84\ub9ac\ud558\uae30\\n\\n\uc774\ub7f0 \ubc29\ubc95\ub4e4\uc744 \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc558\uc2b5\ub2c8\ub2e4. \uadf8 \uc911 \uc800\ub294 \ud604\uc7ac\ub294 \uac04\ub2e8\ud558\uac8c 2\ubc88\uc9f8 \ubc29\ubc95\uc774 \uc81c\uc77c \ub098\uc744 \uac83 \uac19\ub2e4\ub294 \uc0dd\uac01\uc5d0 \ucffc\ub9ac\ub97c \uc218\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ubb38\uc81c\ub97c \ud574\uacb0\ud588\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uac8c \ub418\uc5b4 \uc880 \ub354 \uc7ac\ubc0c\ub294 \uac83\ub4e4\uc744 \uace0\ubbfc\ud558\uace0 \uacf5\ubd80\ud560 \uc218 \uc788\ub294 \uc800\ud76c \ud300\uc5d0\uac8c \uac10\uc0ac\ud558\uace0 \ubaa8\ub974\ub294 \ud0a4\uc6cc\ub4dc\ub97c \ub9ce\uc774 \uc54c\ub824\uc900 \ub204\ub204\uc5d0\uac8c \uac10\uc0ac\ud569\ub2c8\ub2e4.\\n\\n\uc544\uc9c1 \ubc30\uc6b0\ub294 \ub2e8\uacc4\ub77c \uc815\ud655\ud55c \uc815\ubcf4\uac00 \uc544\ub2d0 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubd80\uc871\ud55c \ubd80\ubd84\uc5d0 \ub300\ud574 \ub9ce\uc740 \uc9c0\uc801 \ubd80\ud0c1\ub4dc\ub9bd\ub2c8\ub2e4."},{"id":"22","metadata":{"permalink":"/22","source":"@site/blog/2023-07-27-filtering-and-index.mdx","title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694~","date":"2023-07-27T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 27\uc77c","tags":[{"label":"filter","permalink":"/tags/filter"},{"label":"index","permalink":"/tags/index"}],"readingTime":10.86,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"22","title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","authors":["jay"],"tags":["filter","index"]},"prevItem":{"title":"Deadlock trouble shooting","permalink":"/23"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","permalink":"/20"}},"content":"\uc548\ub155\ud558\uc138\uc694~\\n\\n\uc6b0\ud14c\ucf54 \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604 \ubc0f \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\ub294 \uc791\uc5c5\uc744 \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \uc694\uad6c \uc0ac\ud56d\uacfc \uae30\ub2a5 \uad6c\ud604 \ubaa9\ub85d\\n\uce74\ud398\uc778 \ud300\uc740 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc870\ud68c \ubc0f \ud1b5\uacc4 \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud574\uc8fc\ub294 \uc11c\ube44\uc2a4\uc785\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790 \uc785\uc7a5\uc5d0\uc11c \uc804\uae30\ucc28 \ucda9\uc804\uc18c\ub97c \uc870\ud68c\ud560 \ub54c \ubcf8\uc778 \ucc28\uc5d0 \ub9de\ub294 \ucda9\uc804\uae30 \ud0c0\uc785\uacfc, \uc18d\ub3c4, \ub9c8\uc9c0\ub9c9\uc73c\ub85c \ucda9\uc804\uae30\ub97c \uc81c\uacf5\ud558\ub294 \ud68c\uc0ac\uba85 \uc694\uae08\uacfc \uad00\ub828\ub3c4 \ub418\uc5b4 \uc788\uc5b4\uc11c \uc911\uc694\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ubb34\uc218\ud788 \ub9ce\uc740 \ucda9\uc804\uc18c\ub97c \ubcf4\ub294 \uac83\uc774 \uc544\ub2cc \uc790\uc2e0\uc5d0\uac8c \ud544\uc694\ud55c \uac83\ub9cc \ubcf4\ub294 \uac83\uc774 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc5d0 \uc788\uc5b4\uc11c\ub294 \ub354 \uc911\uc694\ud55c\ub370\uc694.\\n\\n\uc800\ud76c \ud300\uc740 \uc774\ub97c \uc704\ud574 \ud544\ud130\ub9c1 \uae30\ub2a5\uc744 \ub3c4\uc785\ud558\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ub610\ud55c \uc870\ud68c\uac00 \ub9ce\uc740 \uc11c\ube44\uc2a4\uc778\ub9cc\ud07c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\uc744 \uc704\ud574 \uc778\ub371\uc2a4\ub97c \uc801\uc6a9\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud544\ud130\ub9c1 \ubfd0\ub9cc \uc544\ub2c8\ub77c \ud574\ub2f9 \uc791\uc5c5\uc744 \ud558\uba74\uc11c \uc5b4\ub5a4 \uace0\ubbfc\uc744 \ud588\uace0 \uc5b4\ub5a4 \uac83\uc744 \ud588\ub294\uc9c0 \uc801\uc5b4\ubcf4\uace0\uc790 \ud569\ub2c8\ub2e4.\\n\\n\\n## \ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\ud558\uae30\\n\uc800\ud76c \ud300\uc740 \ube60\ub974\uac8c \uae30\ub2a5\uc744 \uad6c\ud604\ud558\ub294 \ub2e8\uacc4\uc5d0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc77c\ub2e8 3\uac1c\uc758 \ud544\ud130\ub9cc \ub3c4\uc785\ud588\uace0, \ud544\ud130\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4. [\ucda9\uc804\uc18c \uc6b4\uc601 \ud68c\uc0ac \uc774\ub984, \ucda9\uc804 \ud0c0\uc785, \ucda9\uc804 \uc18d\ub3c4]\\n\\n\uc0ac\uc6a9\uc790\ub294 \ud544\ud130\ub97c \ud074\ub9ad\ud558\uba74 \ud604\uc7ac \uc704\uce58\ub97c \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0\uc5d0 \ud574\ub2f9 \ud544\ud130\uac00 \uc801\uc6a9\ub41c \ucda9\uc804\uc18c\ub97c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n3\uac1c\uc758 \ud544\ud130 \uc911\uc5d0\uc11c \ubaa8\ub450 \uc801\uc6a9\ub420 \uc218\ub3c4 \uc788\uace0, \ubaa8\ub450 \uc801\uc6a9\ub418\uc9c0 \uc54a\uc744 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c 2^3 = 8\uac00\uc9c0\uc758 \uacbd\uc6b0\ub97c \uc0dd\uac01\ud574\uc57c \ud588\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uadf8\ub798\uc11c \ucc98\uc74c\uc5d0 \ud544\ud130\ub97c \uc801\uc6a9\ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ubc29\ubc95\ub4e4\uc744 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. JPQL + \ud544\ud130\uc758 \uc870\ud569 (2^3)\ub9cc\ud07c if\ubb38 \uc0ac\uc6a9\ud558\uae30\\n\\n2. \uae30\uc874 \uc88c\ud45c\ub85c \uc870\ud68c\ud558\ub294 findAllByLatitudeBetweenAndLongitudeBetween() \uba54\uc11c\ub4dc\ub97c \uc0ac\uc6a9 \ud6c4 Stream\uc744 \uc774\uc6a9\ud574 \uc790\ubc14 \ucf54\ub4dc\ub85c \ud544\ud130\ub9c1\ud558\uae30\\n\\n\\n\uc774\ub807\uac8c \ub450 \uac00\uc9c0 \ubc29\ubc95\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1\ubc88\uc758 \uacbd\uc6b0 \uc6b0\ud14c\ucf54 \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c Querydsl\uc744 \uc0ac\uc6a9\ud574\ub3c4 \ub418\ub294\uc9c0 \ud655\uc2e4\ud558\uc9c0 \uc54a\uc558\uace0 \uc815\ud655\ud55c \ud544\ud130 \uba85\uc138\uac00 \uc544\uc9c1\uc740 \uc5c6\uace0 3\uac00\uc9c0\ub9cc \uc77c\ub2e8 \ub3c4\uc785\ud558\uace0\uc790 \ud574\uc11c JPQL\uc744 \uc774\uc6a9\ud574\uc11c \uc0c1\ud669\ub9c8\ub2e4 if\ubb38\uc73c\ub85c \ud574\ub2f9 \uba54\uc11c\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c\uc8fc\ub294 \ubc29\ubc95\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n```java\\n// 1. fetch join + \ud68c\uc0ac \uc774\ub984\ub9cc \uc870\ud68c\\n @Query(\\"SELECT DISTINCT s FROM Station s \\" +\\n \\"LEFT JOIN FETCH s.chargers c \\" +\\n \\"LEFT JOIN FETCH c.chargerStatus \\" +\\n \\"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude \\" +\\n \\"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude \\" +\\n \\"AND s.companyName IN :companyNames\\")\\n List findAllByFilteringBeingCompanyNames(@Param(\\"minLatitude\\") BigDecimal minLatitude,\\n @Param(\\"maxLatitude\\") BigDecimal maxLatitude,\\n @Param(\\"minLongitude\\") BigDecimal minLongitude,\\n @Param(\\"maxLongitude\\") BigDecimal maxLongitude,\\n @Param(\\"companyNames\\") List companyNames);\\n\\n // 2. fetch join + \ucda9\uc804 \ud0c0\uc785\\n @Query(\\"SELECT DISTINCT s FROM Station s \\" +\\n \\"LEFT JOIN FETCH s.chargers c \\" +\\n \\"LEFT JOIN FETCH c.chargerStatus \\" +\\n \\"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude \\" +\\n \\"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude \\" +\\n \\"AND c.type IN :types\\")\\n List findAllByFilteringBeingTypes(@Param(\\"minLatitude\\") BigDecimal minLatitude,\\n @Param(\\"maxLatitude\\") BigDecimal maxLatitude,\\n @Param(\\"minLongitude\\") BigDecimal minLongitude,\\n @Param(\\"maxLongitude\\") BigDecimal maxLongitude,\\n @Param(\\"types\\") List types);\\n\\n```\\n\\n\uc9c4\ud589 \ud588\ub2e4\uba74 \uc774\ub7f0 \ub290\ub08c\uc774\uc5c8\uaca0\ub124\uc694!\\n\\n\\n2\ubc88\uc758 \uacbd\uc6b0 \ubaa8\ub450 \uc870\ud68c\ub97c\ud558\uace0 \uc790\ubc14 \ucf54\ub4dc\ub97c \uc774\uc6a9\ud574\uc11c \ud544\ud130\ub9c1 \ud574\uc8fc\ub294 \ubc29\ubc95\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud604\uc7ac \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \uc88c\ud45c\ub97c \uc911\uc2ec\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \uc870\ud68c\ud569\ub2c8\ub2e4.\\n\\n\uc5b4\ucc28\ud53c \uc0ac\uc6a9\uc790\uac00 \ud654\uba74\uc744 \ucd95\uc18c\ud574\uc11c \ud070 \ubc94\uc704\uc758 \uc9c0\ub3c4\ub97c \ubcf4\ub294 \uac83\uc740 \uc5b4\ucc28\ud53c \ub9c9\ud790\ud14c\ub2c8 \uc0ac\uc6a9\uc790\ub294 \uc791\uc740 \ubc94\uc704\uc5d0 \ub300\ud574\uc11c \uc870\ud68c\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \ud558\ub098\uc758 \ucffc\ub9ac\ub97c \uc774\uc6a9\ud574\uc11c \uc790\ubc14 \ucf54\ub4dc\ub85c \ud544\ud130\ub9c1 \ud574\uc8fc\ub294 \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n\\n\\n\uc774\ub807\uac8c\ub9cc \ubd24\uc744 \ub550 1\ubc88 \ubc29\uc2dd\uc778 \ud544\ud130 \ubcc4\ub85c \uc870\ud68c\ud574\uc8fc\ub294 \uac83\uc740 \uc870\ud68c \ud6a8\uc728\uc740 \ub354 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc 1\ubc88\uc758 \ubc29\ubc95\uc740 \'\ud604\uc7ac \uad6c\uc870\'\uc5d0\uc11c\ub294 \ub9ce\uc740 \ucffc\ub9ac\ubb38\uacfc \uba54\uc11c\ub4dc\ub97c \uc791\uc131\ud574\uc57c\ud558\uace0, if\ubb38 \ubc94\ubc85\uc73c\ub85c \ubcf4\uae30 \uc88b\uc9c0 \uc54a\uc740 \ucf54\ub4dc\uac00 \uc644\uc131 \ub410\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uacb0\uad6d 2\ubc88 \ubc29\uc2dd\uc778 [\uc804\uccb4 \uc870\ud68c + \ucf54\ub4dc\ub85c \ud544\ud130\ub9c1] \ubc29\uc2dd\uc744 \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc5b4\ucc28\ud53c \uc0ac\uc6a9\uc790\ub294 \uc791\uc740 \ubc94\uc704\uc5d0\uc11c \uc870\ud68c\ub97c \ud55c\ub2e4.\\n2. \uc778\ub371\uc2a4\ub97c \uac78\uc5c8\uc744 \ub54c \uac00\uc7a5 \ud6a8\uc728\uc801\uc774\ub2e4.\\n\\n1\ubc88\uc758 \uc774\uc720\ub294 \uc704\uc5d0\uc11c \ub9d0\ud588\uace0, 2\ubc88\uc5d0 \ub300\ud574 \uac04\ub2e8\ud558\uac8c \uc124\uba85 \ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\ub294 \uc870\ud68c\uac00 \uad49\uc7a5\ud788 \ub9ce\uc9c0\ub9cc, \ucda9\uc804\uc18c\uc758 \uc8fc\uae30\uc801\uc778 \uc5c5\ub370\uc774\ud2b8\ub97c \uc704\ud574 \ub370\uc774\ud130 \uc5c5\ub370\uc774\ud2b8\uac00 \uad49\uc7a5\ud788 \ube48\ubc88\ud558\uac8c \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \ub9ce\uc9c0\ub294 \uc54a\uc9c0\ub9cc \ub370\uc774\ud130 \uc0bd\uc785\ub3c4 \ubc1c\uc0dd\ud558\uace0, \ub370\uc774\ud130 \uc5c5\ub370\uc774\ud2b8\ub3c4 \ub9ce\uc544\uc9d1\ub2c8\ub2e4.\\n\\nJPQL\ub85c \uc870\uac74\uc744 \ub098\ub220\uc11c \uc870\ud68c\ud574\uc900\ub2e4\uba74 \ud574\ub2f9\ud558\ub294 \ubaa8\ub4e0 \ud544\ud130\uc5d0 \uc778\ub371\uc2a4\ub97c \uac78\uc5b4\uc57c\ud560\uae4c\uc694?\\n\\n\uadf8\ub7f4 \uc21c \uc5c6\uc5c8\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uac00\uc7a5 \ud6a8\uc728\uc801\uc778 Column\uc5d0 \uc778\ub371\uc2a4\ub97c \uac78\uc5c8\uaca0\uc8e0, \uadf8\ub807\ub2e4\uba74 \uc870\ud68c\ub9c8\ub2e4 \uc18d\ub3c4\ub3c4 \ub2ec\ub77c\uc84c\uc744 \uac83\uc774\uace0 \uac00\ub839 \ud574\ub2f9\ud558\ub294 \ubaa8\ub4e0 Column\uc5d0 \uc778\ub371\uc2a4\ub97c \uc124\uc815\ud574\ub194\ub3c4 \uc5c5\ub370\uc774\ud2b8\uc640 \uc0bd\uc785\uc774 \ub290\ub824\uc84c\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub294 7\ubd84\ub9c8\ub2e4 \ub370\uc774\ud130\ub97c \uc5c5\ub370\uc774\ud2b8 \ud558\ub294 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc801\uc808\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ubc18\uba74\uc5d0 \ud55c \uac1c\uc758 \ucffc\ub9ac\ub85c \uc8fc\ubcc0\uc744 \ubaa8\ub450 \uc870\ud68c\ud558\uace0 \uc774\ub97c \uc790\ubc14 \ucf54\ub4dc\ub85c \ubc14\uafb8\ub294 \ubc29\ubc95\uc740 \ub354 \uc26c\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n\uc5b4\ucc28\ud53c \ub9ce\uc9c0 \uc54a\uc740 \uc591\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud558\uace0 \ud544\ud130\ub9c1 \ud558\uae30 \ub54c\ubb38\uc5d0 \uc18d\ub3c4 \uba74\uc5d0\uc11c\ub3c4 \ud070 \ucc28\uc774\uac00 \ub098\uc9c0 \uc54a\uc558\uace0, \uc778\ub371\uc2a4 \uc124\uc815\uc5d0\ub3c4 \uc720\ub9ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc870\ud68c\uc2dc \uc774\uc6a9\ud558\ub294 latitude\uc640 longitude\ub9cc \uc124\uc815\ud574\uc8fc\uba74 \uc5b4\ub5a4 \uacbd\uc6b0\ub4e0 \ube60\ub974\uac8c \uc870\ud68c\ub97c \ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \uc778\ub371\uc2a4 \uc801\uc6a9\uc73c\ub85c \uc870\ud68c \uc18d\ub3c4 \ud5a5\uc0c1\uc2dc\ud0a4\uae30\\n\\n\uba3c\uc800 \uc77c\ub2e8 \ud604\uc7ac \ucf54\ub4dc\uc5d0\uc11c \uc870\ud68c\uc2dc \ub2e4\uc74c\uacfc \uac19\uc740 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n```sql\\nHibernate:\\n select\\n station0_.station_id as station_1_0_0_,\\n ...\\n ...\\n ...\\n chargersta2_.latest_update_time as latest_u4_2_2_\\n from\\n charge_station station0_\\n left outer join\\n charger chargers1_\\n on station0_.station_id=chargers1_.station_id\\n left outer join\\n charger_status chargersta2_\\n on chargers1_.charger_id=chargersta2_.charger_id\\n and chargers1_.station_id=chargersta2_.station_id\\n where\\n (\\n station0_.latitude between ? and ?\\n )\\n and (\\n station0_.longitude between ? and ?\\n )\\n```\\n\\nwhere \uc808\uc5d0\uc11c \uc704\ub3c4 \uacbd\ub3c4\ub97c \ubc14\ud0d5\uc73c\ub85c \uc8fc\ubcc0\ub9cc \uac00\uc838\uc624\uac8c \ub429\ub2c8\ub2e4. \uae30\uc874\uc5d0 N+1 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud574\uc11c EntityGraph\ub85c \ubc14\uafe8\uace0 \uc2e4\ud589\uc2dc \ucffc\ub9ac\uc785\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc544\ub798 \uae00\uc744 \uc77d\uace0 BETWEEN \ucffc\ub9ac\uc5d0\uc11c \ubd80\ub4f1\ud638\ub97c \uc774\uc6a9\ud558\ub294 \ucffc\ub9ac\ub85c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n[Mysql Query Between \uacfc >=, <= \uc131\ub2a5 \ucc28\uc774 \ube44\uad50 ( \ub354\ubbf8\ub370\uc774\ud130 50\ub9cc )\\n](https://velog.io/@ggomjae/Mysql-Query-Between-%EA%B3%BC-%EC%84%B1%EB%8A%A5-%EC%B0%A8%EC%9D%B4-%EB%B9%84%EA%B5%90-%EB%8D%94%EB%AF%B8%EB%8D%B0%EC%9D%B4%ED%84%B0-50%EB%A7%8C)\\n\\n\\n```java\\n@Query(\\"SELECT DISTINCT s FROM Station s \\" +\\n \\"LEFT JOIN FETCH s.chargers c \\" +\\n \\"LEFT JOIN FETCH c.chargerStatus \\" +\\n \\"WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude \\" +\\n \\"AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude\\")\\n List findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param(\\"minLatitude\\") BigDecimal minLatitude,\\n @Param(\\"maxLatitude\\") BigDecimal maxLatitude,\\n @Param(\\"minLongitude\\") BigDecimal minLongitude,\\n @Param(\\"maxLongitude\\") BigDecimal maxLongitude);\\n\\n```\\n\uc704\uc640 \uac19\uc774 \uc870\ud68c\ud574\uc8fc\ub294 \ucffc\ub9ac\ub97c \ub9cc\ub4e4\uc5c8\uace0, \uc778\ub371\uc2a4\ub97c \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc778\ub371\uc2a4 \uc124\uc815 \uae30\uc900\uc740 [\uc778\ub371\uc2a4 \uc815\ub9ac \ubc0f \ud301](https://jojoldu.tistory.com/243)\\n\uc704\uc5d0 \ub9c1\ud06c\uc640 \uac19\uc774 \ub3d9\uc6b1\ub2d8\uc758 \ube14\ub85c\uadf8\ub97c \ucc38\uc870\ud574\uc11c \uae30\uc900\uc744 \uc138\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n\ubb34\uc870\uac74 \uce74\ub514\ub110\ub9ac\ud2f0\uac00 \ub192\uc740 \uac83\uc744 \uc124\uc815\ud560 \uc21c \uc5c6\uc5c8\uae30 \ub54c\ubb38\uc5d0 (\uc5c5\ub370\uc774\ud2b8\uc640 \uc0bd\uc785 \uc791\uc5c5\uc774 \ub9ce\uae30 \ub54c\ubb38\uc5d0) \ucffc\ub9ac\uc5d0\uc11c \uc0ac\uc6a9\ub418\ub294 column\uacfc update \uc791\uc5c5\uc744 \uace0\ub824\ud558\uace0 \uc131\ub2a5\uc744 \ube44\uad50\ud574\uac00\uba74\uc11c \uac00\uc7a5 \ud6a8\uc728\uc801\uc778 \uac83\uc744 \uc124\uc815\ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc18d\ub3c4\ub97c \ube44\uad50\ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n
    \\n
    \\n\\n\u200b\\n\\n\uba3c\uc800 \uc18d\ub3c4 \ube44\uad50\ub97c \uc704\ud574\uc11c \ub370\uc774\ud130 \uc14b\uc740 \ub2e4\uc74c\uacfc \uac19\uc774 \uc9c4\ud589\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n- Charger (23\ub9cc \uac74)\\n- Station (6\ub9cc \uac74)\\n- ChargerStatus(23\ub9cc \uac74)\\n- \uc120\ub989\uc5ed \uadfc\ucc98 \uc870\ud68c\\n\\n\\n### Ver1. \uc778\ub371\uc2a4 \uc801\uc6a9\uc744 \ud558\uc9c0 \uc54a\uace0 \uc870\ud68c \ubc0f \ud544\ud130\ub9c1 \ud588\uc744 \ub54c \uc18d\ub3c4 (0.84\ucd08)\\n![\uc774\ubbf8\uc9c0](https://postfiles.pstatic.net/MjAyMzA3MjdfMTYy/MDAxNjkwNDQwMDA0ODEw.vaeA83AD9ycHa26YN58rqzPV3XdX2zTvIZgKM6YKXWEg.Qqkkdr_lEJeGbYPpWji0E-IusfGpqMpZHKWZM4AyRrUg.PNG.sosow0212/image.png?type=w773)\\n\ud3c9\uade0\uc801\uc73c\ub85c 0.84\ucd08\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n### Ver2. \uc778\ub371\uc2a4 \uc801\uc6a9 \ubc0f \uc870\ud68c \ubc0f \ud544\ud130\ub9c1 \ud588\uc744 \ub54c \uc18d\ub3c4 (0.63\ucd08)\\n![\uc774\ubbf8\uc9c0](https://postfiles.pstatic.net/MjAyMzA3MjdfNTUg/MDAxNjkwNDQwMTUyMDcx.F3sSiDgLp3O2Rn1waqh31vC6yv1Uk0zZkRzjyuDQEM4g.eziRKLCmUbzW88ueQRozZcYvhsH10C17w-IDRLh0cJ4g.PNG.sosow0212/SE-48b3f814-3306-4add-ab95-381186bab6ca.png?type=w773)\\n\ud3c9\uade0\uc801\uc73c\ub85c 0.63\ucd08\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\uc57d 25 ~ 30%\uc758 \uc870\ud68c \uc18d\ub3c4\uac00 \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\uc9c1 \uc774 \ubd80\ubd84\uc740 \uac1c\uc120\uc774 \ub354 \ud544\uc694\ud574\ubcf4\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub798\ub3c4 \uac1c\uc120\uc774 \ub410\uace0, \uc0bd\uc785\uacfc \uac31\uc2e0\uc5d0\ub294 \ud070 \uc9c0\uc7a5\uc774 \uc5c6\uc5b4\uc11c \uc77c\ub2e8 \uc774\uc815\ub3c4\ub85c \ub9c8\ubb34\ub9ac \ud558\uace0, \ucd94\ud6c4\uc5d0 \uac1c\uc120\uc744 \ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n![\uc774\ubbf8\uc9c0](https://postfiles.pstatic.net/MjAyMzA3MjdfNzMg/MDAxNjkwNDQwODA1NzAy.b5gZPjl_E1x3wbjSMNcmfQDKB-hB9p8FEbIJqs5Kl4Qg.ZBq0-GmXJruPO7ejA_zq7RfaBaC17doHJUT19wje1Qkg.PNG.sosow0212/SE-f5396915-60ef-4293-a457-e30e8f5a2794.png?type=w773)\\n\ucd94\uac00\uc801\uc73c\ub85c \ucda9\uc804\uae30 \uc870\ud68c\ub294 \uad49\uc7a5\ud788 \ube68\ub77c\uc84c\uc2b5\ub2c8\ub2e4!\\n\\n\\n\ubc30\uc6b0\ub294 \ub2e8\uacc4\uc774\ub2e4\ubcf4\ub2c8 \ubbf8\uc219\ud558\uace0 \ud2c0\ub9b0 \ubd80\ubd84\uc774 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4 :)"},{"id":"20","metadata":{"permalink":"/20","source":"@site/blog/2023-07-26-why-styled-components.mdx","title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","description":"\uc65c styled-components\uc778\uac00?","date":"2023-07-26T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 26\uc77c","tags":[{"label":"styled-components","permalink":"/tags/styled-components"},{"label":"css","permalink":"/tags/css"},{"label":"css in js","permalink":"/tags/css-in-js"}],"readingTime":1.495,"hasTruncateMarker":false,"authors":[{"name":"\uc57c\ubbf8","title":"Frontend","url":"https://github.com/feb-dain","imageURL":"https://github.com/feb-dain.png","key":"yummy"}],"frontMatter":{"slug":"20","title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","authors":["yummy"],"tags":["styled-components","css","css in js"]},"prevItem":{"title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","permalink":"/22"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","permalink":"/21"}},"content":"## \uc65c styled-components\uc778\uac00?\\n\\n
    \\n\\n\uc5ec\ub7ec `CSS-in-JS` \uc911 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n1. \ucef4\ud3ec\ub10c\ud2b8 \uc548\uc5d0 \uad00\ub828 CSS\ub97c \uc791\uc131\ud560 \uc218 \uc788\uc5b4 \ucef4\ud3ec\ub10c\ud2b8\ubcc4 \ub514\uc790\uc778 \ucf54\ub4dc \ud655\uc778 \ubc0f \uc218\uc815\uc774 \uc6a9\uc774\ud558\ub2e4.\\n\\n2. \ud639\uc790\ub294 \ucf54\ub4dc \uac00\ub3c5\uc131\uc774 \uc548 \uc88b\uc544\uc9c4\ub2e4\uace0\ub3c4 \ud558\uc9c0\ub9cc, \uac1c\uc778\uc801\uc73c\ub85c\ub294 \ud0dc\uadf8\ub97c \ub354 \uc2dc\ub9e8\ud2f1 \ud558\uac8c \uc791\uc131\ud560 \uc218 \uc788\uc5b4\uc11c \uc88b\ub2e4\uace0 \ub290\uaf08\ub2e4.\\n\\n3. \ud300\uc6d0\ub4e4 \ubaa8\ub450 styled-components\uac00 \uc775\uc219\ud558\ub2e4.\\n\\n4. \uc9c0\uae08\uae4c\uc9c0 \uc0ac\uc6a9\ud558\uba74\uc11c \ubd88\ud3b8\ud55c \uc810\uc744 \ubabb \ub290\uaf08\ub2e4.\\n\\n
    \\n\\nstyled-components\uc640 emotion\uc740 \uae30\ub2a5\ub3c4, \uc791\uc131\ubc95\ub3c4 \uc0c1\ub2f9\ud788 \uc720\uc0ac\ud558\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc774\ubc88\uc5d0\ub294 styled-components \ub300\uc2e0 emotion\uc744 \uc368\ubcfc\uae4c\ub3c4 \uc0dd\uac01\ud588\uc5c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc emotion\uc5d0\uc11c\ub9cc \uc0ac\uc6a9 \uac00\ub2a5\ud558\ub358 \\\\*CSS Props\ub77c\ub294 \ud3b8\ub9ac\ud55c \uae30\ub2a5\uc744\\n\\nstyled-components(v5.2.0 \uc774\uc0c1)\uc5d0\uc11c \uc4f8 \uc218 \uc788\uac8c \ub418\uae30\ub3c4 \ud588\uace0,\\n\\n\'\uc0c8\ub85c\uc6b4 \uae30\uc220 \uacf5\ubd80\ub97c \ud574\ubcf4\uba74 \uc88b\uc744 \uac83 \uac19\ub2e4\'\ub294 \uc774\uc720\ub97c \uc81c\uc678\ud558\uace0\ub294\\n\\n\ub531\ud788 emotion\uc744 \uc0ac\uc6a9\ud560 \ud544\uc694\uc131\uc744 \ubabb \ub290\uaef4 styled-components\ub97c \ucc44\ud0dd\ud588\ub2e4.\\n\\n```typescript\\n// *CSS Props \uc608\uc2dc\\n\\nconst buttonStyle = css`\\n font-size: 18px;\\n color: white;\\n background: black;\\n`;\\n\\nconst ClickButton = styled.button<{ css: CSSProp }>`\\n width: 100px;\\n\\n ${({ css }) => css}\\n`;\\n\\nClick me!;\\n```"},{"id":"21","metadata":{"permalink":"/21","source":"@site/blog/2023-07-26-why-tanstack-query-is-good.mdx","title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","description":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300 FE\uc5d0\uc11c \uc0c1\ud0dc\uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc5b4\ub5bb\uac8c \ud574\uc57c\ud560 \uc9c0 \uace0\ubbfc \ub05d\uc5d0 \uc11c\ub4dc\ud30c\ud2f0 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \ud544\uc694\ud558\uac8c \ub418\uc5b4 \uae00\uc744 \uc791\uc131\ud558\uac8c\ub410\uc2b5\ub2c8\ub2e4.","date":"2023-07-26T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 26\uc77c","tags":[{"label":"tanstack query","permalink":"/tags/tanstack-query"},{"label":"react state management","permalink":"/tags/react-state-management"}],"readingTime":8.695,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"21","title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","authors":["gabriel"],"tags":["tanstack query","react state management"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","permalink":"/20"},"nextItem":{"title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","permalink":"/19"}},"content":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300 FE\uc5d0\uc11c \uc0c1\ud0dc\uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc5b4\ub5bb\uac8c \ud574\uc57c\ud560 \uc9c0 \uace0\ubbfc \ub05d\uc5d0 \uc11c\ub4dc\ud30c\ud2f0 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \ud544\uc694\ud558\uac8c \ub418\uc5b4 \uae00\uc744 \uc791\uc131\ud558\uac8c\ub410\uc2b5\ub2c8\ub2e4.\\n\\n# \uc11c\ubc84 \uc0c1\ud0dc\uc640 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\uc758 \uad6c\ubd84\\n\\n\uc11c\ubc84\uc0c1\ud0dc\uc640 UI\uc0c1\ud0dc\ub97c \uc774\ud574\ud558\ub294 \uac83\uc740 \uad49\uc7a5\ud788 \uc911\uc694\ud588\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130\ub97c \uc1a1\uc218\uc2e0\ud558\ub294 \uc791\uc5c5\uacfc \uc0c1\ud0dc\ub97c \uad00\ub9ac\ud558\ub294 \uc791\uc5c5\uc740 \uc720\uae30\uc801\uc73c\ub85c \ub3d9\uc791\ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4. \uae30\uc874\uc5d0\ub294 `\uc0c1\ud0dc\uc640 \ub370\uc774\ud130 \uc1a1\uc218\uc2e0 \uacfc\uc815\uc744 \ubd84\ub9ac\ud574\uc11c \uc0dd\uac01`\ud588\ub2e4\uba74, \ud604\ub300\uc758 react \ud504\ub85c\uc81d\ud2b8\ub4e4\uc740 `\uc11c\ubc84\uc640 \ub3d9\uae30\ud654\ub97c \ud574\uc57c\ud560 \uc0c1\ud0dc`\uc640 `\uadf8\ub807\uc9c0 \uc54a\uc740 \uc0c1\ud0dc`\ub85c \ubd84\ub9ac\ud574\uc11c \uc0dd\uac01\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n`React\uc5d0\uc11c \uc5b4\ub5a4 \ub370\uc774\ud130\ub97c \uc0c1\ud0dc\ub85c \ub2e4\ub904\uc57c \ud558\ub294\uac00`\uc5d0 \ub300\ud574\uc11c\ub294 \uc5ec\ub7ec \uc758\uacac\uc774 \ub098\uc62c \uc218 \uc788\ub2e4\uace0 \uc0dd\uac01\ud558\uc9c0\ub9cc `\uc0c1\ud0dc\uac00 \ud2b9\uc131\uc744 \uac00\uc9c0\uace0 \uc788\ub294\uac00`\uc5d0 \ub300\ud574\uc11c\ub294 \ub300\ubd80\ubd84 \ud2b9\uc131\uc774 \uc788\ub2e4\uace0 \ub3d9\uc758\ud560 \uac83\uc785\ub2c8\ub2e4. \uc774 \uae00\uc5d0\uc11c\ub294 React\uc758 \uc0c1\ud0dc\ub780 \ubb34\uc5c7\uc778\uac00?\uc5d0 \ub300\ud574\uc11c \ub2e4\ub8e8\uc9c0 \uc54a\uace0 React\uc758 \uc0c1\ud0dc\uc758 \ud2b9\uc131\uc5d0 \ub300\ud574\uc11c\ub9cc \uc5b8\uae09\uc744 \ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uc0c1\ud0dc\uc758 \ud2b9\uc131\uc73c\ub85c\ub294 \ud06c\uac8c \ub450 \uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\\n\\n\ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub294 \ucef4\ud3ec\ub10c\ud2b8\ub4e4 \uac04\uc5d0 \uc5b4\ub5a4 \uac12\uc744 \uacf5\uc720\ud574\uc57c\ud558\uba74\uc11c `\uc624\ub85c\uc9c0 React DOM \ub0b4\ubd80\uc5d0\uc11c\ub9cc CRUD\uac00 \uc77c\uc5b4\ub098\ub294 \uc0c1\ud0dc\ub97c \uc758\ubbf8`\ud569\ub2c8\ub2e4. \uc774 \uc0c1\ud0dc\ub4e4\uc740 React DOM \uc678\ubd80 \uc138\uacc4\uc640 \ud06c\uac8c \uad00\ub828\uc774 \uc5c6\uc73c\uba70 `\ub3d9\uae30\uc801\uc73c\ub85c \ubc18\uc601`\ub429\ub2c8\ub2e4. \ub300\ud45c\uc801\uc73c\ub85c\ub294 UI\ub97c \uc870\uc791\ud558\ub294 \uc0c1\ud0dc\ub4e4\uc774 \ub420 \uac83\uc785\ub2c8\ub2e4. \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub4e4\uc740 \ub300\ubd80\ubd84 \uc7a5\uae30\uc801\uc73c\ub85c \uc720\uc9c0\ub420 \ud544\uc694\uac00 \uc5c6\uae30\uc5d0 \ud654\uba74\uc744 \ubc97\uc5b4\ub098\uac70\ub098 \uc138\uc158\uc774 \ub04a\uae30\ub294 \uacbd\uc6b0 \uc0ac\ub77c\uc838\ub3c4 \uad1c\ucc2e\uc740 \uacbd\uc6b0\uac00 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\\n### \uc11c\ubc84 \uc0c1\ud0dc\\n\\n\uc11c\ubc84 \uc0c1\ud0dc\ub294 React\uc758 \ubc14\uae65 \uc138\uc0c1(\uc11c\ubc84)\uc5d0 \uc874\uc7ac\ud558\ub294 `\ub370\uc774\ud130\uac00 React\uc758 \uc0c1\ud0dc \uad00\ub9ac\uc640 \ube44\ub3d9\uae30\uc801\uc73c\ub85c \ub3d9\uae30\ud654 \ub41c \uac83`\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4. \uc5b4\ub5a4 \uc0c1\ud0dc\uac00 `\uc678\ubd80\uc5d0\uc11c \uad00\ub9ac\ub418\ub294 \ub370\uc774\ud130\uc640 \ubc18\ub4dc\uc2dc \uc5f0\ub3d9`\ub418\uc5b4\uc57c \ud55c\ub2e4\uba74 \uc774\ub294 \uace7 \uc11c\ubc84 \uc0c1\ud0dc\uc784\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4. React\uc758 \uc0c1\ud0dc\ub97c CRUD \ud558\ub294 \uac83 \ubfd0\ub9cc \uc544\ub2cc, \uc11c\ubc84\uc5d0\uc11c\ub3c4 \ud56d\uc0c1 \uac19\uc740 \uc77c\uc774 \uc77c\uc5b4\ub098\uc57c \ud569\ub2c8\ub2e4. \uc11c\ubc84 \uc0c1\ud0dc\ub294 \uc7a5\uae30\uc801\uc73c\ub85c \uc720\uc9c0\ub418\uc5b4\uc57c \ud558\uba70, \uc138\uc158\uc5d0\uc11c \ubc97\uc5b4\ub098\ub354\ub77c\ub3c4 \uc11c\ubc84\ub85c \ubd80\ud130 \ubcf5\uad6c\ub97c \ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uae30\uc874\uc758 \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 \ub9ac\uc561\ud2b8\uc758 \uc804\uc5ed\uc5d0\uc11c \uc0c1\ud0dc\ub97c \uc870\uc791\ud558\ub294 \uac83\uc5d0 \ud2b9\ud654\ub418\uc5b4\uc788\uace0, \ube44\ub3d9\uae30\uc801\uc778 \uc0c1\ud0dc \uad00\ub9ac\ub3c4 \uc9c0\uc6d0\ud558\uc5ec \uc11c\ubc84\uc640\uc758 \ud1b5\uc2e0\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub300\ubd80\ubd84\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 `\ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub97c \uc870\uc791\ud558\ub294 \uac83\uc5d0 \ucd08\uc810`\uc774 \ub9de\ucdb0\uc838\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub354\uad70\ub2e4\ub098 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\uc640 \uc11c\ubc84 \uc0c1\ud0dc\uac00 \ud558\ub294 \uc77c\uc774 \uba85\ud655\ud558\uac8c \ub2e4\ub978 \uc0c1\ud669\uc5d0\uc11c \uc774 \ub458\uc744 \ud55c \uacf3\uc5d0\uc11c \uad00\ub9ac\ud558\ub294 \uac83 \ubcf4\ub2e4\ub294 \uc644\ubcbd\ud558\uac8c \ubd84\ub9ac\ud558\ub294 \uac83\uc774 \ub354 \ub098\uc744 \uac83\uc785\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc11c\ubc84 \uc0c1\ud0dc\ub97c \uad00\ub9ac\ud558\ub294 \uac83\uc5d0 \uc911\uc810\uc744 \ub454 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc774 \ub4f1\uc7a5\ud558\uc600\uc2b5\ub2c8\ub2e4. \ub300\ud45c\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c\ub294 RTK Query, Tanstack Query, SWR \ub4f1\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n# \uc65c Tanstack Query\uc600\ub098?\\n\\n### vs RTK Query\\n\\nRTK Query\ub294 RTK\ub97c \ubc18\ub4dc\uc2dc \uc0ac\uc6a9\ud574\uc57c \ud558\ub294 \uac83\uc740 \uc544\ub2c8\uc9c0\ub9cc RTK\ub97c \ud0c0\uac9f\uc73c\ub85c \ub098\uc628 \uc11c\ubc84 \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4. `\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub97c \uad00\ub9ac\ud558\uae30 \uc704\ud574 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.` \ub354\uc6b1\uc774 Redux\uc758 \ubcf5\uc7a1\ud55c \ucf54\ub4dc \uad6c\uc131\uacfc \ubc29\ub300\ud55c \ubcf4\uc77c\ub7ec \ud50c\ub808\uc774\ud2b8\ub294 \ub9e4\ub825\uc801\uc774\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. tanstack query\uc5d0\uc11c\ub294 \ubb34\ud55c \ub370\uc774\ud130 \ud398\uce6d\uc744 \uc9c0\uc6d0\ud558\uae30 \uc704\ud574 **Infinite Queries**\uac00 \uc788\uc9c0\ub9cc RTK Query\ub294 \uadf8\ub807\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n### vs SWR\\n\\nSWR\ub3c4 \ud558\ub098\uc758 \uc88b\uc740 \uc120\ud0dd\uc9c0\uc600\uc9c0\ub9cc, \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc774 \ubc94\uc6a9\uc801\uc73c\ub85c \uc9c0\uc6d0\ud558\ub294 \uc140\ub809\ud130 \uae30\ub2a5\uc744 \uc9c0\uc6d0\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ub610, \uac00\ube44\uc9c0 \uceec\ub809\ud130\uc758 \ubd80\uc7ac\ub3c4 \uc544\uc26c\uc6e0\uc2b5\ub2c8\ub2e4. \uc7ac\uc694\uccad\uc744 \ud558\uae30 \uc704\ud55c stale time \uc124\uc815\uc774\ub098 \ucffc\ub9ac \ucde8\uc18c \uae30\ub2a5\uc774 \uc5c6\ub294 \uc810\ub3c4 \ub9e4\ub825\uc801\uc774\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n# \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ud558\ub824\ub294 \uc77c\uc740\uc694\u2026\\n\\n\uc800\ud76c \uce74\ud398\uc778 \ud300\uc758 \ud504\ub85c\uc81d\ud2b8\ub294 `\uc2e4\uc2dc\uac04 \uc804\uae30\uc790\ub3d9\ucc28 \ucda9\uc804\uc18c \uc9c0\ub3c4 \ubc0f \uc0ac\uc6a9 \ud1b5\uacc4 \uc870\ud68c \uc11c\ube44\uc2a4` \ub85c \uc9c0\ub3c4 \uae30\ubc18\uc758 \ud504\ub85c\uc81d\ud2b8\uc785\ub2c8\ub2e4. \uc11c\ubc84 \uc0c1\ud0dc\ub97c \uc801\uadf9\uc801\uc73c\ub85c \ub2e4\ub904\uc57c \ud558\ub294 \uc0c1\ud669\uc5d0\uc11c Tanstack Query\ub97c \uc11c\ubc84 \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c \uc120\uc815\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uba54\uc778 \uae30\ub2a5 \uc911 Tanstack Query\uac00 \ud575\uc2ec\uc73c\ub85c \uc0ac\uc6a9\ub420 \uac83 \uac19\uc740 \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n- \uc9c0\ub3c4\uc5d0\uc11c \ucda9\uc804\uc18c \uc870\ud68c\\n - \ud604\uc7ac \uc811\uc18d\ud55c \ud074\ub77c\uc774\uc5b8\ud2b8\uc5d0 \ub80c\ub354\ub9c1 \ub41c \uc9c0\ub3c4 \ud654\uba74(\ub514\uc2a4\ud50c\ub808\uc774)\uc758 \ud06c\uae30\uc5d0 \ub530\ub978 GPS\uc88c\ud45c\ub97c \uc54c\uc544\ub0b4\uc5b4 \uc11c\ubc84\ub85c \ubd80\ud130 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc218\uc2e0 \ubc1b\uc2b5\ub2c8\ub2e4. \uc989, \ud654\uba74\uc774 \uc774\ub3d9\ud558\uac8c \ub418\uba74 \uc0ac\uc6a9\uc790\uac00 \ubc14\ub77c\ubcf4\uace0 \uc788\ub294 \uc601\uc5ed\uc774 \ubcc0\ud558\ubbc0\ub85c \uc0c8\ub85c\uc6b4 \uc694\uccad\uc744 \ubcf4\ub0b4\uac8c \ub429\ub2c8\ub2e4.\\n - \uc11c\ubc84\uc5d0\uc11c \uc218\uc2e0\ud55c \ucda9\uc804\uc18c \uc815\ubcf4\ub294 \uc2e4\uc2dc\uac04 \uc0ac\uc6a9 \ud604\ud669\ub3c4 \ubc18\uc601\ub418\uc5b4\uc788\uc73c\ubbc0\ub85c `\uc8fc\uae30\uc801\uc778 \uc5c5\ub370\uc774\ud2b8`\ub3c4 \ud544\uc694\ud569\ub2c8\ub2e4.\\n - \ube48\ubc88\ud55c \ub370\uc774\ud130\uc758 \ubcc0\ud654\uac00 \ud544\uc694\ud558\uba70 \uadf8\ub9cc\ud07c \ud1b5\uc2e0 \uc2e4\ud328 \ub4f1 `\uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \uac00\ub2a5\uc131\ub3c4 \ub9ce\uc544\uc9c0\uac8c` \ub429\ub2c8\ub2e4.\\n - \uc0ac\uc6a9\uc790\uc758 \ube60\ub978 \uc9c0\ub3c4 \uc774\ub3d9\uc774 \ubc1c\uc0dd\ud558\ub294 \uacbd\uc6b0\ub97c \ub300\uc751\ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n- \uc804\uad6d \ucda9\uc804\uc18c \uac80\uc0c9\uae30\\n - \uc6d0\ud558\ub294 \ucda9\uc804\uc18c \uac80\uc0c9\uc744 \ud558\ub294 \uae30\ub2a5\uc744 \uc9c0\uc6d0\ud569\ub2c8\ub2e4. \uc804\uad6d \ub2e8\uc704\ub85c \uac80\uc0c9 \uacb0\uacfc\ub97c \uc218\uc2e0\ud558\ub294 \uae30\ub2a5\uc785\ub2c8\ub2e4.\\n - \ub124\uc774\ubc84\uc640 \uad6c\uae00 \uac80\uc0c9\ucc3d \ucc98\ub7fc \uc0ac\uc6a9\uc790\uac00 input \ucc3d\uc5d0 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud560 \ub54c \ub9c8\ub2e4 \uac80\uc0c9 \uacb0\uacfc\uac00 \ub3d9\uc801\uc73c\ub85c \ud45c\uc2dc\ub418\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n - \ube48\ubc88\ud55c \ub370\uc774\ud130\uc758 \ubcc0\ud654\uac00 \ud544\uc694\ud558\uace0, `\uc0ac\uc6a9\uc790\uc758 \ube60\ub978 \ud0c0\uc774\ud551\uc73c\ub85c \uc778\ud574 \uc7a6\uc740 \uac80\uc0c9\uc774 \ubc1c\uc0dd\ud558\ub294 \uacbd\uc6b0\ub97c \ub300\uc751\ud560 \uc218 \uc788\uc5b4\uc57c` \ud569\ub2c8\ub2e4.\\n - \uc774\ub97c \uc704\ud574 \ub370\uc774\ud130\ub97c \uce90\uc2f1\ud560 \ud544\uc694\ub3c4 \uc788\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n\\n\ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \ud074\ub77c\uc774\uc5b8\ud2b8\uc640 \uc11c\ubc84\uc640\uc758 \ud1b5\uc2e0\uc774 \uc5b4\uca4c\ub2e4 \ud55c\ubc88 \uc77c\uc5b4\ub09c\ub2e4\uba74 \uad73\uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \ud544\uc694\uac00 \uc5c6\uaca0\uc9c0\ub9cc, \uc11c\ubc84\uc758 \ub370\uc774\ud130 \uc804\uc801\uc73c\ub85c \uc758\uc874\ud574\uc57c \ud558\ub294 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8 \ud2b9\uc131\uc0c1 Tanstack Query\uc758 \uc5ec\ub7ec \uae30\ub2a5\uc774 \uc0dd\uc0b0\uc131\uc5d0 \ub9ce\uc740 \ub3c4\uc6c0\uc774 \ub420 \uac83\uc73c\ub85c \uae30\ub300\ud569\ub2c8\ub2e4."},{"id":"19","metadata":{"permalink":"/19","source":"@site/blog/2023-07-23-oauth.mdx","title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","description":"OAuth 2.0 ?","date":"2023-07-23T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 23\uc77c","tags":[{"label":"oauth","permalink":"/tags/oauth"},{"label":"login","permalink":"/tags/login"}],"readingTime":12.57,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"19","title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","authors":["boxster"],"tags":["oauth","login"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","permalink":"/21"},"nextItem":{"title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","permalink":"/18"}},"content":"## OAuth 2.0 ?\\n\\n\\n> OAuth(\\"Open Authorization\\")\ub294 \uc778\ud130\ub137 \uc0ac\uc6a9\uc790\ub4e4\uc774 \ube44\ubc00\ubc88\ud638\ub97c \uc81c\uacf5\ud558\uc9c0 \uc54a\uace0 \ub2e4\ub978 \uc6f9\uc0ac\uc774\ud2b8 \uc0c1\uc758 \uc790\uc2e0\ub4e4\uc758 \uc815\ubcf4\uc5d0 \ub300\ud574 \uc6f9\uc0ac\uc774\ud2b8\ub098 \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc758 \uc811\uadfc \uad8c\ud55c\uc744 \ubd80\uc5ec\ud560 \uc218 \uc788\ub294 \uacf5\ud1b5\uc801\uc778 \uc218\ub2e8\\n\\n\uc704\ud0a4 \ubc31\uacfc\uc5d0\uc11c\ub294 \uc704\uc640 \uac19\uc774 \uc124\uba85\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc6b0\ub9ac\uac00 google\uacfc \uac19\uc740 \uc6f9 \uc0ac\uc774\ud2b8\uc5d0 \ud68c\uc6d0\uac00\uc785\uc744 \ud558\uace0 \uc800\uc7a5\ud574\ub454 \uc774\ub984, \uc774\uba54\uc77c, \ud504\ub85c\ud544 \uc774\ubbf8\uc9c0 \uac19\uc740 \uc815\ubcf4\ub97c\\n\uad73\uc774 \ud55c\ubc88 \ub354 \uc785\ub825\ud558\uc9c0 \uc54a\uace0\ub3c4 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\uc5d0\uc11c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uac83 \uc785\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\ub97c \uc0ac\uc6a9\ud558\ub354\ub77c\ub3c4 google\uc5d0\uc11c \ub85c\uadf8\uc778\uc744 \ud558\ub294 \uacfc\uc815\uc744 \uac70\uce58\uae30 \ub54c\ubb38\uc5d0, \uc0ac\uc6a9\uc790\ub294\\n\ube44\ubc00\ubc88\ud638\ub098, critical\ud55c \uac1c\uc778\uc815\ubcf4 \uac19\uc740 \uac83\uc744 \ud55c \uacf3\uc5d0\uc11c \uad00\ub9ac\ud560 \uc218 \uc788\ub2e4\ub294 \uc7a5\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\uc2dc \ud55c\ubc88 \uc815\ub9ac\ud558\uc790\uba74 \uc6b0\ub9ac \uc6f9 \uc0ac\uc774\ud2b8\uc758 \uc0ac\uc6a9\uc790\uac00 \uc774\uc6a9\ud558\ub294 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\uc758 \uc815\ubcf4\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc788\uac8c\ub054 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\uc5d0\uc11c \uad8c\ud55c\uc744 \uc704\uc784 \ubc1b\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n### OAuth flow\\n\\n\\nOAuth Flow\ub97c \uc124\uba85\ud558\uae30 \uc804\uc5d0 \uc5ec\uae30\uc11c \ubaa8\ub974\ub294 \ub2e8\uc5b4\ub4e4\uc774 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\ud574\ub2f9 [\ub9c1\ud06c](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16#section-1.1)\uc5d0\uc11c \ub354 \uc790\uc138\ud558\uac8c \uc815\ub9ac \ub418\uc5b4\uc788\uc9c0\ub9cc \uc124\uba85\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n#### Resource Owner\\nResource Owner\ub294 \ub9d0 \uadf8\ub300\ub85c \ub9ac\uc18c\uc2a4 \uc18c\uc720\uc790\uc774\uace0, \uad6c\uae00\uacfc \uac19\uc740 \ud50c\ub7ab\ud3fc\uc5d0 \ud68c\uc6d0\uac00\uc785\uc774 \ub418\uc5b4\uc788\ub294, \uc989 \uad6c\uae00\uc5d0 \uc790\uc2e0\uc758 \uc815\ubcf4\ub4e4\uc774 \uc788\ub294 \uc0ac\uc6a9\uc790\uc785\ub2c8\ub2e4.\\n\\n#### Client\\nClient\ub3c4 \ub9d0 \uadf8\ub300\ub85c \uace0\uac1d\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc5b4\ub5a4 \uad00\uc810\uc5d0\uc11c \ubcf4\ub290\ub0d0 \uace0\uac1d\uc774\ub780 \ub73b\uc740 \ub2ec\ub77c\uc9d1\ub2c8\ub2e4. \uc5ec\uae30\uc11c\ub294 Google\uacfc \uac19\uc740 \ud50c\ub7ab\ud3fc\uc5d0\uc11c \uc81c\uacf5\ubc1b\uc740 \ub9ac\uc18c\uc2a4\ub97c \uc0ac\uc6a9\ud558\ub294 \uace0\uac1d\uc785\ub2c8\ub2e4.\\n\uc989 \uc6b0\ub9ac\uc758 \uc11c\ube44\uc2a4\uac00 Client\uac00 \ub418\ub294 \uac83\uc785\ub2c8\ub2e4. \uc65c\ub0d0\uba74 \uc6b0\ub9ac\ub294 \uad6c\uae00\uc5d0 \uc815\ubcf4\ub97c \uc694\uccad\ud558\uace0 \uc6b0\ub9ac\uc758 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc0ac\uc6a9\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n#### Authorization Server\\n\uc5ec\uae30\ub3c4 \ub9d0 \uadf8\ub300\ub85c \uc778\uc99d \uc11c\ubc84\uc785\ub2c8\ub2e4. Resource Owner\uac00 \uc62c\ubc14\ub978 \uc815\ubcf4\ub97c \uc785\ub825\ud588\ub294\uc9c0 \uac80\uc99d\ud558\uace0, \ubc1c\uae09 \ubc1b\uc740 Code\uc640 Token\uc774 \uc62c\ubc14\ub978 \uac83\uc778\uc9c0 \uac80\uc99d\ud569\ub2c8\ub2e4.\\n\\n#### Resource Server\\nResource Owner\uc758 \uc815\ubcf4\ub4e4\uc744 \uac00\uc9c0\uace0 \uc788\ub294 \uc11c\ubc84\uc785\ub2c8\ub2e4. \uc778\uc99d \uc11c\ubc84\uc5d0\uc11c \uc778\uc99d\uc744 \ub9c8\uce58\uace0 \ub09c \ub4a4 \uc6b0\ub9ac\ub294 Resource\ub97c \ubc1b\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc5ec\uae30\uc11c Authorization Server \uc640 Resource Server\uac00 \ub098\ub258\uc5b4\uc9c4 \uc774\uc720\ub294 \ub531\ud788 \uc5c6\uc2b5\ub2c8\ub2e4. **\ud574\ub2f9 \ud50c\ub7ab\ud3fc\uc758 \uc11c\ubc84 \uad6c\uc131\uc5d0 \ub530\ub77c \ub2e4\ub97c \uc218 \uc788\uc2b5\ub2c8\ub2e4.**\\n\\n\uc911\uc694\ud55c \uac83\uc740 **Authorization Server**\uc640 **Resource Server**\uac00 \uac19\uc740 \ubb36\uc74c\uc774\ub77c\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n```mermaid\\nsequenceDiagram\\n actor RO as Resource Owner (\ubc15\uc2a4\ud130)\\n participant C as Client (\uce74\ud398\uc778)\\n participant AS as Authorization Server (\uad6c\uae00)\\n participant RS as Resource Server (\uad6c\uae00)\\n\\n RO->>+C: 1. \ub85c\uadf8\uc778 \uc694\uccad\\n C--\x3e>-AS: 2. \ub85c\uadf8\uc778 \uc694\uccad\\n AS ->>+ RO: 3. \ub85c\uadf8\uc778 \ud398\uc774\uc9c0 \uc81c\uacf5\\n RO ->>+ AS: 4. ID/PW \uc785\ub825\\n AS ->> RO: 5. Authorization Code \ubc1c\uae09\\n RO ->> C: 6. Redirect URI\ub85c \uc774\ub3d9\\n C ->>+ AS: 7. \ubc1c\uae09 \ubc1b\uc740 Authorization Code\ub85c Token \uc694\uccad\\n AS ->> C: 8. Access Token \ubc1c\uae09\\n C ->> RO: 9. \ub85c\uadf8\uc778 \uc131\uacf5\\n RO ->> C: 10. \uc11c\ube44\uc2a4 \uc694\uccad\\n C ->> RS: 11. \ubc1c\uae09 \ubc1b\uc740 Token\uc73c\ub85c \uc815\ubcf4 \ud638\ucd9c\\n RS ->> C: 12. \uc815\ubcf4 \uc81c\uacf5\\n```\\n\\n\uac04\ub2e8\ud558\uac8c flow\ub97c \ub3c4\uc2dd\ud654 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. \uba3c\uc800 Resource Owner\ub294 \ub85c\uadf8\uc778\uc744 \ud558\uace0 \uc2f6\ub2e4\uba74 Client\uac00 \uc81c\uacf5\ud558\ub294 \ud574\ub2f9 Resource platform\uc758 URI\ub97c \ud074\ub9ad\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc778\uc99d\uc11c\ubc84\uc5d0\uc11c \ub85c\uadf8\uc778 \ud398\uc774\uc9c0\ub97c \uc81c\uacf5 \ubc1b\uc2b5\ub2c8\ub2e4.\\n2. \uadf8\ub9ac\uace0 Resource Owner\ub294 ID/PW\ub97c \uc785\ub825\ud558\uace0 Authorization Code\ub97c \ubc1c\uae09 \ubc1b\uc2b5\ub2c8\ub2e4. \ub3d9\uc2dc\uc5d0 Client\uc5d0\uc11c \ub4f1\ub85d\ud574\ub193\uc740 Redirect URI\ub85c code\uc640 \ud568\uaed8 \uc774\ub3d9\ud569\ub2c8\ub2e4.\\n3. Client\ub294 Resource Owner\uc5d0\uac8c \ubc1b\uc740 Code\ub97c \uac00\uc9c0\uace0 Authorization Server\uc5d0 \ud1a0\ud070\uc744 \uc694\uccad\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ubc1b\uc740 \ud1a0\ud070\uc744 \uc800\uc7a5\ud569\ub2c8\ub2e4.\\n4. \uadf8\ub9ac\uace0 Client\ub294 \ub85c\uadf8\uc778\uc744 \uc131\uacf5\ud558\uace0 \uc774\ud6c4 \ub2e4\ub978 platform\uc5d0\uc11c \uc815\ubcf4\ub97c \ud544\uc694\ud558\uac8c \ub41c\ub2e4\uba74 \uc800\uc7a5\ud55c Access Token\uc744 \ud1b5\ud574 Resource Server\uc5d0\uc11c \uc815\ubcf4\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n\\n\uadfc\ub370 \uc5ec\uae30\uc11c \uc774\uc0c1\ud55c \uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc774\uc0c1\ud558\ub2e4\uae30\ubcf4\ub2e8 \uc65c \uc774\ub807\uac8c \ubcf5\uc7a1\ud55c\uac00 \ub77c\ub294 \uc758\ubb38\uc744 \uac00\uc9c8 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uad73\uc774 Authorization code\ub97c \ubc1b\uc544 \ub2e4\uc2dc \ud55c\ubc88 \ub354 Access Token\uc744 \ubc1b\uc544\uc57c \ud55c\ub2e4\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4. \ubc14\ub85c **Client\uc5d0\uac8c Access Token\uc744 \uc900\ub2e4\uba74 \ud1b5\uc2e0\uc774 \ud55c\ubc88 \uc904\uc5b4\ub4e4 \uc218 \uc788\uc9c0 \uc54a\uc744\uae4c??**\\n\\n\ubcf4\uc548\ubb38\uc81c \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\ub9cc\uc57d \ubc14\ub85c Access Token\uc744 \uc900\ub2e4\uba74 \uadf8 Access Token\uc774 \ud0c8\ucde8 \ub2f9\ud558\uba74 \ud574\ub2f9 Resource Owner\uc758 \ubaa8\ub4e0 \uc815\ubcf4\uc5d0 \uc811\uadfc\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\nCode\ub294 Secret Key\uc640 \uac19\uc774 \uc804\ub2ec\ud574\uc57c Access Token\uc744 \ubc1c\uae09 \ubc1b\uc744 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \ud0c8\ucde8\ub418\uc5b4\ub3c4 \ub354 \uc548\uc804\ud569\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \ub2e4\ub978 \ud50c\ub7ab\ud3fc\uc5d0\uc11c Code\ub098 Token\uc774\ub098 \ud574\ub2f9 \uc815\ubcf4\ub97c \uc804\ub2ec\ud560 \ubc29\ubc95\uc740 URI\uc5d0 \uc804\ub2ec\ud558\ub294 \ubc29\ubc95\ubfd0 \uc785\ub2c8\ub2e4.\\n\uadf8\ub807\uae30 \ub54c\ubb38\uc5d0 Redircet URI\uc5d0 Access Token\uc744 \ub2f4\ub294\ub2e4\uba74 \ud0c8\ucde8 \uac00\ub2a5\uc131\uc774 \ucee4\uc9c0\uae30 \ub54c\ubb38\uc5d0 \ubcf4\uc548\ubb38\uc81c\uac00 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n\\n### \ubc31\uc5d4\ub4dc\uc640 \ud504\ub860\ud2b8\uc5d4\ub4dc\uc758 flow\\n\\n```mermaid\\n\\nsequenceDiagram\\n actor RO as Resource Owner\\n participant F as Frontend\\n participant B as Backend\\n participant AS as Authorization Server\\n participant RS as Resource Server\\n\\n RO->>+F: 1. \ub85c\uadf8\uc778 \uc694\uccad\\n F--\x3e>-B: 2. \ub85c\uadf8\uc778 \uc694\uccad\\n B->>+F: 3. \ud574\ub2f9 \ud50c\ub7ab\ud3fc \ub85c\uadf8\uc778 URI \uc81c\uacf5\\n F--\x3e>-RO: 4. \ud574\ub2f9 \ud50c\ub7ab\ud3fc \ub85c\uadf8\uc778 URI \uc81c\uacf5\\n AS ->>+ RO: 5. \ub85c\uadf8\uc778 \ud398\uc774\uc9c0 \uc81c\uacf5\\n RO ->>+ AS: 6. ID/PW \uc785\ub825\\n AS ->> RO: 7. Authorization Code \ubc1c\uae09\\n RO ->> F: 8. Redirect URI\ub85c \uc774\ub3d9 (w. Authorization Code)\\n F ->>+ B: 9. \ubc1c\uae09 \ubc1b\uc740 Authorization Code \uc804\ub2ec\\n B ->>+ AS: 10. \uc804\ub2ec \ubc1b\uc740 Authorization Code\ub85c Token \uc694\uccad\\n AS ->> B: 11. Access Token \ubc1c\uae09\\n B ->>+ F: 12. \ub85c\uadf8\uc778 \uc131\uacf5\\n F --\x3e>- RO: 13. \ub85c\uadf8\uc778 \uc131\uacf5\\n RO ->>+ F: 14. \uc11c\ube44\uc2a4 \uc694\uccad\\n F --\x3e>- B: 15. \uc11c\ube44\uc2a4 \uc694\uccad\\n B ->> RS: 16. \ubc1c\uae09 \ubc1b\uc740 Token\uc73c\ub85c \uc815\ubcf4 \ud638\ucd9c\\n RS ->> B: 17. \uc815\ubcf4 \uc81c\uacf5\\n\\n```\\n\uc544\uae4c Client \ubd80\ubd84\uc744 \uc880 \ub354 Frontend, Backend\ub85c \uad6c\ubd84\uc9c0\uc5b4 \uc138\ubd84\ud654 \ud574\ubd24\uc2b5\ub2c8\ub2e4. \ubcf5\uc7a1\ud574\ubcf4\uc774\uc9c0\ub9cc, \uc804\ud600 \uc5b4\ub835\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc544\uae4c \uc124\uba85\ud588\ub358 \ud750\ub984\uacfc \ub2e4\ub978 \ubd80\ubd84\uc740 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\\n\ub610 \uc5ec\uae30\uc11c\ub294 \uad73\uc774 \uc81c\uac00 Authorization Server\uc5d0\uc11c Code\ub97c \ubc1b\uc544\uc62c \ub54c Redirect URI\ub97c \ubc31\uc5d4\ub4dc \uc11c\ubc84\ub85c \ud558\uc9c0\uc54a\uace0 \ud504\ub860\ud2b8\uc5d4\ub4dc \uc11c\ubc84\ub85c \ud558\ub824\ub294 \uc774\uc720\ub294 Resource Owner\uac00 \ub2e4\ub978 platform\uacfc \uc778\uc99d\ud558\ub294 \ubd80\ubd84\uc740 \ubc31\uc5d4\ub4dc\uc758 \uc5ed\ud560\uc774 \uc544\ub2c8\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \ubc31\uc5d4\ub4dc\ub294 Resource Owner\uac00 \uac00\uc838\uc628 code\ub97c \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c \uc804\ub2ec \ubc1b\uc544 Resource Server\uc5d0 \uc815\ubcf4\ub97c \uc694\uccad\ud558\ub294 \uac83\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n***(\ubb3c\ub860 \uc81c \uac1c\uc778\uc801\uc778 \uc758\uacac\uc774\ub77c \uc815\ub2f5\uc740 \uc544\ub2d9\ub2c8\ub2e4.)***\\n\\n\\n## OAuth \uad6c\ud604\ud574\ubcf4\uae30\\n\\n\uac04\ub2e8\ud788 Spring Security \uc5c6\uc774 OAuth \uc778\uc99d\uc744 \uad6c\ud604\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc81c\uc77c \uba3c\uc800 \uad6c\uae00 \ud639\uc740 \ub2e4\ub978 \ud50c\ub7ab\ud3fc\uc5d0\uc11c \uc124\uc815\ud55c id, secret key \ub4f1\ub4f1\uc758 \uc815\ubcf4\ub97c yml\uc5d0 \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4.\\n```yml title=\\"application-oauth.yml\\"\\noauth2:\\n provider:\\n google:\\n id: google-id\\n secret: google-secret-key\\n redirect-url: http://localhost:8080/login/oauth2/code/google\\n token-url: https://www.googleapis.com/oauth2/v4/token\\n info-url: https://www.googleapis.com/oauth2/v2/userinfo\\n```\\n\uadf8\ub9ac\uace0 OAuth\ub294 \uc5b4\ub290 \ud50c\ub7ab\ud3fc\uc774 \ub420 \uc9c0 \ubaa8\ub974\uace0, \ud655\uc7a5\uc131 \uc788\uac8c \uad6c\uc131\ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc544 \uc778\ud130\ud398\uc774\uc2a4\ub85c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n```java title=\\"OAuthMember.java\\"\\npublic interface OAuthMember {\\n String id();\\n String email();\\n String nickname();\\n String imageUrl();\\n}\\n```\\n\uc774\ub7ec\ud55c \ud074\ub798\uc2a4\ub4e4\uc744 \uad00\ub9ac\ud558\uae30 \uc27d\uac8c Enum\uc744 \ucd94\uac00\ud569\ub2c8\ub2e4.\\n\\n```java title=\\"Provider.java\\"\\npublic enum Provider {\\n\\n GOOGLE(\\"google\\", GoogleMember::new),\\n ;\\n\\n private final String providerName;\\n private final Function, OAuthMember> function;\\n\\n Provider(String providerName, Function, OAuthMember> function) {\\n this.providerName = providerName;\\n this.function = function;\\n }\\n\\n public static Provider from(String name) {\\n return Arrays.stream(values())\\n .filter(it -> it.providerName.equals(name))\\n .findFirst()\\n .orElseThrow(() -> new RuntimeExceptin());\\n }\\n\\n public OAuthMember getOAuthProvider(Map body) {\\n return function.apply(body);\\n }\\n}\\n```\\n\ud574\ub2f9 Enum\uc740 \ub450\uac1c\uc758 \ud544\ub4dc\ub97c \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4. \ud558\ub098\ub294 \ud574\ub2f9 \ud50c\ub7ab\ud3fc\uc758 \uc774\ub984, \uadf8\ub9ac\uace0 `Map`\ub97c \uc544\uae4c \ub9cc\ub4e4\uc5c8\ub358 \uc778\ud130\ud398\uc774\uc2a4\ub85c \ubc18\ud658\ud558\ub294 Function \uc5ec\uae30\uc11c\\n`Map`\ub85c \uc9c0\uc815\ud574\uc900 \uc774\uc720\ub294, \ud50c\ub7ab\ud3fc\ub9c8\ub2e4 \ubc18\ud658\ub418\ub294 JSON \ud0c0\uc785\uc774 \ub2e4\ub974\uae30 \ub54c\ubb38\uc5d0 \uadf8\ub7f0 \ubd80\ubd84\uc5d0 \ub300\ud574 \uc911\ubcf5\uc744 \uc81c\uac70\ud558\uae30 \uc704\ud574 \uc774\ub7ec\ud55c \ud615\ud0dc\ub85c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc544\uae4c yml\uc5d0 \uc791\uc131\ud588\ub358 \uc815\ubcf4\ub4e4\uc744 \uac00\uc838\uc640\uc57c\ud569\ub2c8\ub2e4. `@Value` \uc5b4\ub178\ud14c\uc774\uc158\uc73c\ub85c\ub3c4 \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n```java\\n @Value(\\"oauth.provider.google.id\\")\\n private String id;\\n @Value(\\"oauth.provider.google.secret\\")\\n private String secret;\\n\\n ...\\n```\\n\ud558\uc9c0\ub9cc \uc774\ub807\uac8c \uacc4\uc18d binding\uc744 \ud574\uc918\uc57c\ud55c\ub2e4\ub294 \uc810\uc774 \uc544\uc8fc \uadc0\ucc2e\uace0 \ubcf4\uae30\ub3c4 \uc548\uc88b\uc2b5\ub2c8\ub2e4.\\n```groovy title=\\"build.gradle\\"\\nannotationProcessor \\"org.springframework.boot:spring-boot-configuration-processor\\"\\n```\\n\ud558\uc9c0\ub9cc \uc704\uc758 \uc758\uc874\uc131\uc744 \ucd94\uac00\ud574\uc900\ub2e4\uba74 \uc544\uc8fc \ud3b8\ud558\uac8c property\ub97c \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```java title=\\"OAuthProviderProperties.java\\"\\n@Component\\n@ConfigurationProperties(prefix = \\"oauth2\\")\\npublic class OAuthProviderProperties {\\n // prefix oauth2 \uae30\uc900\uc73c\ub85c \uc54c\uc544\uc11c google\uc774 \uc774\ub984\uc778 Provider Enum\uc744 \ucc3e\uc544\uc11c Key\ub85c \ubc14\uc778\ub529\\n private final Map provider = new EnumMap<>(Provider.class);\\n\\n public OAuthProviderProperty getProviderProperties(Provider provider) {\\n return this.provider.get(provider);\\n }\\n\\n @Getter\\n @Setter\\n public static class OAuthProviderProperty {\\n // \uadf8\ub9ac\uace0 provider \ud558\uc704 \uc815\ubcf4\ub4e4\uc740 \uc544\ub798\uc758 \ud544\ub4dc\uc5d0 \ubc14\uc778\ub529\\n private String id;\\n private String secret;\\n private String redirectUrl;\\n private String tokenUrl;\\n private String infoUrl;\\n }\\n}\\n```\\n\uc774\ub807\uac8c \ub418\uba74 \uad6c\uc870\uc801\uc778 \uc900\ube44\ub294 \ub05d\ub0ac\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc81c\ub294 \ud574\ub2f9 \ud50c\ub7ab\ud3fc\uc5d0 \uc815\ubcf4\ub97c \uc694\uccad\ud558\ub294 \uc791\uc5c5\ub9cc \ud558\uba74 \ub429\ub2c8\ub2e4.\\n\uadf8\ub7fc \uc544\uae4c \ub9d0\uc500\ub4dc\ub838\ub358 \uc21c\uc11c\ub85c \uc694\uccad\uc744 \ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java title=\\"RestTemplateOAuthRequester.java\\"\\npublic class RestTemplateOAuthRequester implements OAuthRequester {\\n\\n @Override\\n public OAuthMember login(OAuthLoginRequest request) {\\n // frontend\uc5d0\uc11c \ubc1b\uc544\uc628 \ub85c\uadf8\uc778 platform\\n Provider provider = Provider.from(request.provider());\\n // \ud574\ub2f9 Platform\uc5d0 \ub9de\ub294 \uc815\ubcf4 \ucc3e\uc74c\\n OAuthProviderProperty property = oAuthProviderProperties.getProviderProperties(provider);\\n // frontend\uc5d0\uc11c \ubc1b\uc544\uc628 code\uc640 \ub4f1\ub85d\ud574\ub193\uc740 property\ub85c Access Token \uc694\uccad\\n OAuthTokenResponse token = requestAccessToken(property, requet.getCode());\\n // \ubc1b\uc544\uc628 Token\uc73c\ub85c \ud574\ub2f9 Resource Owner\uc758 \uc815\ubcf4 \uc694\uccad\\n Map userAttributes = getUserAttributes(property, token);\\n return provider.getOAuthProvider(userAttributes);\\n }\\n\\n private OAuthTokenResponse requestAccessToken(OAuthProviderProperty property, String code) {\\n HttpHeaders headers = new HttpHeaders();\\n headers.setBasicAuth(property.getId(), property.getSecret());\\n headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);\\n\\n HttpEntity> request = new HttpEntity<>(headers);\\n URI tokenUri = getTokenUri(property, code);\\n return restTemplate.postForEntity(tokenUri, request, OAuthTokenResponse.class).getBody();\\n }\\n\\n private URI getTokenUri(OAuthProviderProperty property, String code) {\\n return UriComponentsBuilder.fromUriString(property.getTokenUrl())\\n .queryParam(CODE, URLDecoder.decode(code, StandardCharsets.UTF_8))\\n .queryParam(GRANT_TYPE, AUTHORIZATION_CODE)\\n .queryParam(REDIRECT_URI, property.getRedirectUrl())\\n .build()\\n .toUri();\\n }\\n\\n private Map getUserAttributes(OAuthProviderProperty property, OAuthTokenResponse tokenResponse) {\\n HttpHeaders headers = new HttpHeaders();\\n headers.setBearerAuth(tokenResponse.accessToken());\\n headers.setContentType(MediaType.APPLICATION_JSON);\\n URI uri = URI.create(property.getInfoUrl());\\n RequestEntity requestEntity = new RequestEntity<>(headers, HttpMethod.GET, uri);\\n ResponseEntity> responseEntity = restTemplate.exchange(requestEntity, new ParameterizedTypeReference<>() {\\n });\\n return responseEntity.getBody();\\n }\\n}\\n```\\n\\n\uc774\ub807\uac8c\ub9cc \ud55c\ub2e4\uba74 \uadf8 \uc5b4\ub824\uc6cc \ubcf4\uc774\ub358 OAuth \uc778\uc99d\ub3c4 \uac04\ub2e8\ud558\uac8c \ud574\uacb0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n***(\ubb3c\ub860 \uc81c \ucf54\ub4dc\uac00 \uc815\ub2f5\uc774 \uc544\ub2d9\ub2c8\ub2e4)***\\n\\n\\n### Reference\\nhttps://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16\\n\\nhttps://developers.google.com/identity/protocols/oauth2?hl=ko"},{"id":"18","metadata":{"permalink":"/18","source":"@site/blog/2023-07-23-why-private-ip-is-required-for-instance.mdx","title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","description":"\uc5b4\ub5a4 \ubb38\uc81c\uac00 \uc788\uc5c8\ub098\uc694?","date":"2023-07-23T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 23\uc77c","tags":[{"label":"aws","permalink":"/tags/aws"},{"label":"vpc","permalink":"/tags/vpc"},{"label":"subnet","permalink":"/tags/subnet"},{"label":"ip","permalink":"/tags/ip"}],"readingTime":10.365,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"18","title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","authors":["nunu"],"tags":["aws","vpc","subnet","ip"]},"prevItem":{"title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","permalink":"/19"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","permalink":"/17"}},"content":"## \uc5b4\ub5a4 \ubb38\uc81c\uac00 \uc788\uc5c8\ub098\uc694?\\n\\n\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c private \uc11c\ube0c\ub137\uc5d0 db \uc778\uc2a4\ud134\uc2a4\ub97c \ub450\uace0, \ubcf4\uc548\uc744 \uc704\ud574 \uc678\ubd80\uc5d0\uc11c \uc811\uc18d\uc744 \ucc28\ub2e8\ud558\ub824\uace0 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \ucd1d 2\uac00\uc9c0\uc758 \ubb38\uc81c\uc810\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1. private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0\uc11c mysql\uc744 \uc124\uce58\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n2. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc774 \uc548\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ubd80\ubd84\uc744 \uc5b4\ub5bb\uac8c \ud574\uacb0\ud588\ub294\uc9c0 \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ub798\uc758 \ubaa8\ub4e0 \uc124\uba85\uc740 AWS \ub97c \uae30\uc900\uc73c\ub85c \ud569\ub2c8\ub2e4.\\n\\n## private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0\uc11c mysql\uc744 \uc124\uce58\ud560 \uc218 \uc5c6\uc5c8\ub2e4.\\n\\n### \ud574\uacb0 \ubc29\ubc95\\n\\npublic ip \uc790\ub3d9\ud560\ub2f9\uc744 \ud574\uc8fc\uc9c0 \uc54a\uc544\uc11c, \uc778\ud130\ub137\uc5d0 \uc5f0\uacb0\uc774 \uc548 \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 public ip \uc790\ub3d9\ud560\ub2f9\uc744 \ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc65c public ip\ub97c \ud560\ub2f9\ud588\ub354\ub2c8 \ubb38\uc81c\uac00 \ud574\uacb0\ub418\uc5c8\uc744\uae4c\uc694?\\n\\n## private \uc11c\ube0c\ub137\uc774\ub780?\\n\\n\uc815\ub9d0 \uac04\ub2e8\ud558\uac8c \uc124\uba85\ud588\uc744 \ub54c\\n\\nprivate \uc11c\ube0c\ub137\uc740 \uc778\ud130\ub137\uc5d0 \uc5f0\uacb0\ub418\uc9c0 \uc54a\uc740 \uc11c\ube0c\ub137\uc785\ub2c8\ub2e4.\\n\\n\uc870\uae08 \uc790\uc138\ud558\uac8c \ub4e4\uc5b4\uac00 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\nprivate \uc11c\ube0c\ub137\uc740 \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc9c0 \uc54a\uc740 \uc11c\ube0c\ub137\uc785\ub2c8\ub2e4.\\n\\naws \uacf5\uc2dd\ubb38\uc11c\uc5d0\uc11c \uc0ac\uc9c4\uc744 \ud1b5\ud574 \ubcf4\uba74 \uc544\ub798\uc640 \uac19\uc774 \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4\\n\\n![private subnet](https://docs.aws.amazon.com/ko_kr/vpc/latest/userguide/images/internet-gateway-basics.png)\\n\\npublic \uc11c\ube0c\ub137\uc5d0\ub9cc \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc5b4 \uc788\uace0, private \uc11c\ube0c\ub137\uc5d0\ub294 \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc5b4\uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\nprivate \uc11c\ube0c\ub137\uc5d0 \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc5b4 \uc788\uc9c0 \uc54a\ub2e4\uace0 \ud588\uc744 \ub54c, \uae30\ubcf8\uc801\uc73c\ub85c \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc774 \uc548\ub429\ub2c8\ub2e4.\\n\\nmysql\uc744 \uc124\uce58\ud560 \ub54c\ub3c4, \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc744 \ud574\uc57c\ud558\ub294\ub370, \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc774 \uc548\ub418\ub2c8 \uc124\uce58\uac00 \uc548\ub418\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\\n### \uc5b4? \uc778\ud130\ub137 \uc790\uccb4\uac00 \uc811\uadfc\uc774 \uc548\ub418\uba74 \uc5b4\ub5bb\uac8c \uc124\uce58\ud558\ub098\uc694?\\n\\n\uc815\ub9d0 \uc6d0\uc2dc\uc801\uc73c\ub85c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c\ub294 public \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \ud558\ub098 \ub354 \ub9cc\ub4e4\uc5b4\uc11c, mysql \uc744 \uc555\ucd95\ud574\uc11c scp\ub97c \ud1b5\ud574 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc804\uc1a1\ud558\uace0, \uc555\ucd95\uc744 \ud480\uc5b4\uc11c \uc124\uce58\ud558\ub294 \ubc29\ubc95\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774 \ubc29\ubc95\uc740 \ub108\ubb34 \uc6d0\uc2dc\uc801\uc774\uace0, \ube44\ud6a8\uc728\uc801\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc778\ud130\ub137\uc73c\ub85c \uc694\uccad\uc744 \ubcf4\ub0bc \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4dc\ub294 \uacfc\uc815\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n### \uc778\ud130\ub137\uc73c\ub85c \uc694\uccad\uc744 \ubcf4\ub0bc \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4dc\ub294 \uacfc\uc815\\n\\n\uc778\ud130\ub137\uc73c\ub85c \uc694\uccad\uc744 \ubcf4\ub0bc \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4dc\ub294 \uacfc\uc815\uc740 \ud06c\uac8c 2\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### private \uc11c\ube0c\ub137\uc744 public \uc11c\ube0c\ub137\uc73c\ub85c \ubc14\uafb8\uae30\\n\\n\ubcf4\uc548\uc744 \uc704\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \ub450\ub824\uace0 \ud588\ub358 \uac83\uc744 public \uc11c\ube0c\ub137\uc73c\ub85c \ubc14\uafbc\ub2e4\ub294 \ubd80\ubd84\uc740 \ub9e4\uc6b0 \uc704\ud5d8\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc774 \ubc29\ubc95\uc740 \ubcf4\ud1b5 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n### NAT \uc778\uc2a4\ud134\uc2a4(Gateway) \ub9cc\ub4e4\uae30\\n\\nNAT \uc778\uc2a4\ud134\uc2a4\ub294 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0 \uc811\uc18d\ud560 \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4e4\uc5b4\uc8fc\ub294 \uc778\uc2a4\ud134\uc2a4\uc785\ub2c8\ub2e4.\\n\\n\uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc744 \ud558\uae30 \uc704\ud574\uc11c\ub294 public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c NAT \uc778\uc2a4\ud134\uc2a4, NAT \uac8c\uc774\ud2b8\uc6e8\uc774\ub294 public \uc11c\ube0c\ub137\uc5d0 \uc874\uc7ac\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uc5b4? NAT \uc778\uc2a4\ud134\uc2a4\ub97c \ud1b5\ud574\uc11c \ubc14\ub85c \ud1b5\uc2e0\uc774 \uac00\ub2a5\ud558\uba74 \uc65c private \uc11c\ube0c\ub137\uc774 \ud544\uc694\ud55c\uac00\uc694? \uadf8\ub0e5 \ub2e4 public \uc11c\ube0c\ub137\uc5d0 \ub450\uba74 \ub418\uc9c0 \uc54a\ub098\uc694?\\n\\nNAT \uc778\uc2a4\ud134\uc2a4, NAT Gateway\ub294 \ub0b4\ubd80\uc5d0\uc11c \ucd9c\ubc1c\ud55c \ud2b8\ub798\ud53d\ub9cc \ud1b5\uacfc\ud560 \uc218 \uc788\ub3c4\ub85d \uc124\uc815\uc774 \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uba74 private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\ud574\uc11c \uc9c1\uc811 mysql download \uc694\uccad\uc744 \ud588\uc744 \ub54c\ub9cc \ud5c8\uc6a9\uc774 \ub429\ub2c8\ub2e4.\\n\\n\uc678\ubd80\uc5d0\uc11c \ubc14\ub85c private \uc778\uc2a4\ud134\uc2a4\ub85c \uc811\uadfc\ud560 \uc218\ub294 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\nNAT \uc778\uc2a4\ud134\uc2a4\ub9cc \uc124\uc815\uc744 \ud558\uba74 \ubc14\ub85c \uc5f0\uacb0\uc774 \ub418\ub098\uc694?\\n\\npublic ip\ub3c4 \uc790\ub3d9 \ud560\ub2f9\uc744 \ud574\uc918\uc57c \ud569\ub2c8\ub2e4\\n\\n### public ip \uac00 \ud544\uc694\ud55c \uc774\uc720\\n\\nNAT \uc778\uc2a4\ud134\uc2a4\ub97c \ud1b5\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0 \uc811\uc18d\ud560 \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\ub294\ub370, \uc65c public ip \uac00 \ud544\uc694\ud560\uae4c\uc694?\\n\\n\uc678\ubd80 \uc778\ud130\ub137\uacfc \ud1b5\uc2e0\uc744 \ud560 \ub54c public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\nNAT \uc778\uc2a4\ud134\uc2a4 \ud639\uc740 NAT \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc778\ud130\ub137\uacfc \ud1b5\uc2e0\ud560 \ub54c, NAT \uc778\uc2a4\ud134\uc2a4\uc758 public ip + private ip\ub97c \ud1b5\ud574\uc11c \ud1b5\uc2e0\uc744 \ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ub0b4\ubd80 \uc778\uc2a4\ud134\uc2a4\uc758 public ip \ub97c \ud1b5\ud574\uc11c \ud1b5\uc2e0\uc744 \ud558\uac8c \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c NAT \uc778\uc2a4\ud134\uc2a4\uc640 \ub0b4\ubd80 \uc778\uc2a4\ud134\uc2a4 \ubaa8\ub450 public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c 1\ubc88 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc81c 2\ubc88\uc9f8 \ubb38\uc81c\ub97c \ud574\uacb0\ud574 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc774 \uc548 \ub418\ub294 \ubb38\uc81c\\n\\npublic \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc11c\ubc84\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc11c\ubc84\uc5d0 \uc811\uc18d\uc744 \ud558\ub824\uace0 \ud588\ub294\ub370, \uc811\uc18d\uc774 \uc548 \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \ud574\uacb0 \ubc29\ubc95\\n\\n\ud574\uacb0 \ubc29\ubc95\uc5d0\ub294 2\uac00\uc9c0 \uacfc\uc815\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc5d0 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc744 \ucd94\uac00\ud574 \uc8fc\uae30\\n\\n\uae30\ubcf8\uc801\uc73c\ub85c public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc5d0\ub294 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc774 \ucd94\uac00\ub418\uc5b4\uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc5d0 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc744 \ucd94\uac00\ud574\uc8fc\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n\\n### private ip\ub97c \ud1b5\ud574\uc11c \uc811\uc18d\ud558\uae30\\n\\npublic \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\ud560 \ub54c, public ip \ub97c \ud1b5\ud574\uc11c \uc811\uc18d\uc744 \ud558\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\npublic ip\ub97c \ud1b5\ud574\uc11c \uc811\uc18d\ud558\ub294 \uacfc\uc815\uc744 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n1. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 public ip \ub97c \ud1b5\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \uc2dc\ub3c4\ud569\ub2c8\ub2e4.\\n2. \ub77c\uc6b0\ud305 \ud14c\uc774\ube14\uc5d0\uc11c public ip \uc77c \uacbd\uc6b0\uc5d0 \uc5b4\ub5bb\uac8c \ucc98\ub9ac\ud560\uc9c0\uc5d0 \ub300\ud55c \uc815\ubcf4\ub97c \ucc3e\uc2b5\ub2c8\ub2e4.\\n3. \ub77c\uc6b0\ud130\ub97c \ud1b5\ud574\uc11c \uc678\ubd80 \uc778\ud130\ub137\uc73c\ub85c \ub098\uac00\uac8c \ub429\ub2c8\ub2e4.\\n4. \ud2b8\ub798\ud53d\uc774 NAT \uc778\uc2a4\ud134\uc2a4\uc5d0 \ub3c4\ucc29\ud569\ub2c8\ub2e4.\\n5. NAT \uc778\uc2a4\ud134\uc2a4\ub294 \ub0b4\ubd80\uc5d0\uc11c \ucd9c\ubc1c\ud55c \ud2b8\ub798\ud53d\uc774 \uc544\ub2c8\uae30 \ub54c\ubb38\uc5d0, \ud2b8\ub798\ud53d\uc744 \uac70\ubd80\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc774 \uc77c\uc5b4\ub098\uae30\uc5d0, public ip \ub97c \ud1b5\ud574\uc11c \uc811\uc18d\uc744 \ud558\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\nprivate ip\ub97c \ud1b5\ud574\uc11c \uc811\uadfc\ud558\uba74 \uc5b4\ub5bb\uac8c \ub418\ub294\uc9c0 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4\\n\\n1. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private ip \ub97c \ud1b5\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \uc2dc\ub3c4\ud569\ub2c8\ub2e4.\\n2. \ub77c\uc6b0\ud305 \ud14c\uc774\ube14\uc5d0\uc11c private ip \uc77c \uacbd\uc6b0\uc5d0 \uc5b4\ub5bb\uac8c \ucc98\ub9ac\ud560\uc9c0\uc5d0 \ub300\ud55c \uc815\ubcf4\ub97c \ucc3e\uc2b5\ub2c8\ub2e4.\\n3. \ub77c\uc6b0\ud130\ub97c \uac70\uccd0\uc11c private \uc11c\ube0c\ub137\uc758 \ub77c\uc6b0\ud130\ub85c \uc774\ub3d9\ud569\ub2c8\ub2e4.\\n4. private \uc11c\ube0c\ub137\uc758 \ub77c\uc6b0\ud130\ub294 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0\uac8c \ud2b8\ub798\ud53d\uc744 \uc804\ub2ec\ud569\ub2c8\ub2e4.\\n5. private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub294 \ud2b8\ub798\ud53d\uc744 \ubc1b\uc544\uc11c \ucc98\ub9ac\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c 2\ubc88 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uc694\uc57d\\n\\n1. private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc744 \ud558\ub824\uba74 NAT \uc778\uc2a4\ud134\uc2a4 \ud639\uc740 NAT \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n2. private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub3c4 public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n3. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \ud558\ub824\uba74 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc744 \ucd94\uac00\ud574\uc8fc\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n4. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \ud560 \ub54c, private ip \ub97c \ud1b5\ud574\uc11c \uc811\uc18d\uc744 \ud574\uc57c \ud569\ub2c8\ub2e4."},{"id":"17","metadata":{"permalink":"/17","source":"@site/blog/2023-07-22-ci-cd.mdx","title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","description":"\uc548\ub155\ud558\uc138\uc694. \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.","date":"2023-07-22T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 22\uc77c","tags":[{"label":"CI","permalink":"/tags/ci"},{"label":"CD","permalink":"/tags/cd"}],"readingTime":7.735,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"17","title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","authors":["jay"],"tags":["CI","CD"]},"prevItem":{"title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","permalink":"/18"},"nextItem":{"title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","permalink":"/16"}},"content":"\uc548\ub155\ud558\uc138\uc694. \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\uc800\ud76c \ud300\uc5d0\uc11c CI/CD\ub294 \uc5b4\ub5bb\uac8c \uc9c4\ud589\ub418\ub294\uc9c0 \uc791\uc131\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## CI (\uc9c0\uc18d\uc801 \ud1b5\ud569)\\n![ci](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/ci.png?raw=true)\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\uc18d\uc801 \ud1b5\ud569 \uc989 CI\ub97c \uc9c4\ud589\ud558\uae30 \uc704\ud574\uc11c \uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 Github Actions\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\nmain, develop \ube0c\ub79c\uce58\uc5d0 Push, Pull Request \uc694\uccad\uc774 \ub4e4\uc5b4\uac04\ub2e4\uba74 \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud558\uace0, Github Actions\ub97c \ud1b5\ud574 \uc800\ud76c\uac00 \uc791\uc131\ud574\ub454 \uc2a4\ud06c\ub9bd\ud2b8\uac00 \uc2e4\ud589 \ub429\ub2c8\ub2e4.\\n\\n\uc774 \uc2a4\ud06c\ub9bd\ud2b8\uc5d0 \uc5ec\ub7ec\uac00\uc9c0\ub97c \ub4f1\ub85d\ud560 \uc21c \uc788\uc9c0\ub9cc, \uc800\ud76c\ub294 \uc790\ub3d9\uc73c\ub85c \ud14c\uc2a4\ud2b8\ub97c \uc9c4\ud589\ud558\ub3c4\ub85d \ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uc790\ub3d9\uc73c\ub85c \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub9ac\uba74\uc11c \ud14c\uc2a4\ud2b8\uac00 \ud1b5\uacfc\ub97c \ud574\uc57c\uc9c0\ub9cc Merge\ub97c \uc9c4\ud589\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \ud1b5\ud574 \uac1c\ubc1c\uc790\uc758 \uc2e4\uc218\ub97c \uc904\uc77c \uc218 \uc788\uace0 \uc548\uc815\uc801\uc73c\ub85c \uc9c0\uc18d\uc801 \ud1b5\ud569\uc744 \uc774\ub8f0 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n\\n
    \\n\\n## CD (\uc9c0\uc18d\uc801 \ubc30\ud3ec)\\n![cd](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/cd.png?raw=true)\\n\\n\uc800\ud76c\uc758 \uc9c0\uc18d\uc801 \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98\uc785\ub2c8\ub2e4.\\n\\n\uc21c\uc11c\ub97c \uc694\uc57d\ud558\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. Release \ube0c\ub79c\uce58\uc5d0 Push\ub97c \ud55c\ub2e4.\\n2. Github Actions\ub97c \ud1b5\ud574 Docker Hub\uc5d0 \ub808\ud3ec\uc9c0\ud1a0\ub9ac\uc758 \uc18c\uc2a4\ucf54\ub4dc\ub97c Docker Image\ub85c \ube4c\ub4dc\ud574\uc11c Push \ud55c\ub2e4.\\n3. \uc778\ud504\ub77c \uc11c\ubc84\uc5d0\uc11c Self Hosted Runner\uac00 \uc791\ub3d9\ud55c\ub2e4.\\n4. \uc778\ud504\ub77c \uc11c\ubc84\uc5d0\uc11c \ubc30\ud3ec \uc11c\ubc84\ub85c \ub4e4\uc5b4\uac04\ub2e4.\\n5. \ubc30\ud3ec \uc11c\ubc84 \uc548\uc5d0\uc11c Docker Hub\uc5d0 \ubbf8\ub9ac \uc5c5\ub85c\ub4dc\ud55c Docker Image\ub97c Pull \ud574\uc628\ub2e4.\\n6. \ubc30\ud3ec \uc11c\ubc84 \uc548\uc5d0\uc11c Docker Image\ub97c \ucee8\ud14c\uc774\ub108\uc5d0 \ub744\uc6b4\ub2e4.\\n\\n\\n
    \\n\\n### \ubc30\ud3ec \uc790\ub3d9\ud654 \ud234 \uc120\ud0dd\ud558\uae30\\n\uba3c\uc800 \ubc30\ud3ec \uc790\ub3d9\ud654 \uacfc\uc815\uc744 \uad6c\ucd95\ud558\uae30 \uc704\ud574\uc11c \uc5ec\ub7ec\uac00\uc9c0 \ud234\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nTravis, Jenkins, Github Actions \ub4f1\ub4f1 \uc5ec\ub7ec\uac00\uc9c0\uac00 \uc788\ub294\ub370\uc694.\\n\uc800\ud76c \ud300\uc740 `Github Actions`\ub97c \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \uc120\ud0dd\ud55c \uc5ec\ub7ec\uac00\uc9c0 \uc774\uc720\uac00 \uc788\uc5c8\uc9c0\ub9cc\\n\uc800\ud76c \ud300 \ub204\ub204\ub97c \uc81c\uc678\ud558\uace0 CI/CD \uacbd\ud5d8\uc774 \ubd80\uc871\ud574\uc11c \ube44\uad50\uc801 \uc27d\uace0 \uc124\uce58 \ubc0f \ud070 \uc138\ud305\uc774 \uc5c6\ub294 \uc810\uc774 \uc800\ud76c\ud55c\ud14c\ub294 \ub9e4\ub825\uc801\uc73c\ub85c \ub2e4\uac00\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\ub610\ud55c Docker\ub97c \uc0ac\uc6a9\ud558\ub294\ub370, \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n1. JDK \ud639\uc740 Node \ubc84\uc804\uc744 \uad00\ub9ac\ud560 \uc218 \uc788\ub2e4.\\n2. Docker Image\ub97c \ube4c\ub4dc\ud55c \ud6c4 \ubc30\ud3ec\ud558\uae30 \ub54c\ubb38\uc5d0 \uc11c\ubc84 \ud658\uacbd \ucc28\uc774\ub85c \ubc1c\uc0dd\ud558\ub294 \ubb38\uc81c\ub97c \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\ub2e4.\\n3. \ubc30\ud3ec \uc11c\ubc84\uc5d0\uc11c Docker\ub9cc \uc124\uce58\ud558\uace0 Image\ub97c \ubc1b\uace0 \uc2e4\ud589\uc2dc\ud0a4\uba74 \ub3fc\uc11c \ube60\ub974\uace0 \uc27d\uac8c \ubc30\ud3ec \ud658\uacbd\uc744 \uad6c\ucd95\ud560 \uc218 \uc788\ub2e4.\\n\\n
    \\n\\n### \uacfc\uc815\\n\ubcf8\uaca9\uc801\uc73c\ub85c \uc800\ud76c\uc758 \ubc30\ud3ec \uc790\ub3d9\ud654\ub97c \uad6c\ucd95\ud558\ub294 \uacfc\uc815\uc744 \uc124\uba85\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n
    \\n\\n1. Github Actions\uc5d0 Runners \ub4f1\ub85d\\n\\n![runner](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/selfHosted.png?raw=true)\\n\uba3c\uc800 Self Hosted Runner\ub97c \uc774\uc6a9\ud558\uae30 \ub54c\ubb38\uc5d0 \uc800\ud76c\ub294 \uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 Runners\ub97c \ub4f1\ub85d\uc744 \ud574\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \ub4f1\ub85d\uc744 \ud560 \ub54c \uc81c\uacf5\ud574\uc8fc\ub294 \uc124\uc815 \ucf54\ub4dc\uac00 \ub098\uc624\ub294\ub370\uc694.\\n\uc774 \ucf54\ub4dc\ub4e4\uc744 infra \uc11c\ubc84\uc5d0 \ubaa8\ub450 \uc785\ub825\uc744 \ud574\uc8fc\uba74\uc11c \uc124\uc815\uc744 \ud574\uc8fc\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n
    \\n\\n2. Github workflow \ub9cc\ub4e4\uae30\\n\ub2e4\uc74c\uc73c\ub85c\ub294 \uc800\ud76c\uac00 \uc218\ud589\ud558\uace0\uc790 \ud558\ub294 Task\ub97c \ub4f1\ub85d\ud574\uc8fc\uae30 \uc704\ud574\uc11c yml \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5b4\uc90d\ub2c8\ub2e4.\\n\\nyml \ud30c\uc77c\uc758 \uacbd\ub85c\ub294 `./github/workflows/` \uc548\uc5d0 \ub9cc\ub4e4\uc5b4\uc8fc\uba74 \ub429\ub2c8\ub2e4.\\n```yaml\\nname: deploy\\n\\n# release/backend push \ud560 \ub54c\\non:\\n push:\\n branches:\\n - release/backend\\n\\njobs:\\n # Docker\\n docker-build:\\n runs-on: ubuntu-latest\\n defaults:\\n run:\\n working-directory: ./backend\\n steps:\\n\\t\\t# Docker Hub \ub85c\uadf8\uc778\\n - name: Log in to Docker Hub\\n uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a\\n with:\\n username: ${{ secrets.DOCKERHUB_USERNAME }}\\n password: ${{ secrets.DOCKERHUB_PASSWORD }}\\n - uses: actions/checkout@v3\\n\\n - name: Set up JDK 17\\n uses: actions/setup-java@v3\\n with:\\n java-version: \'17\'\\n distribution: \'adopt\'\\n\\n - name: Gradle Caching\\n uses: actions/cache@v3\\n with:\\n path: |\\n ~/.gradle/caches\\n ~/.gradle/wrapper\\n key: ${{ runner.os }}-gradle-${{ hashFiles(\'**/*.gradle*\', \'**/gradle-wrapper.properties\') }}\\n restore-keys: |\\n ${{ runner.os }}-gradle-\\n\\n - name: Grant execute permission for gradlew\\n run: chmod +x gradlew\\n\\n - name: Build with Gradle\\n run: ./gradlew bootjar\\n\\n - name: Extract metadata (tags, labels) for Docker\\n id: meta\\n uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7\\n with:\\n images: Docker Hub \uc0ac\uc6a9\uc790\uba85/\uc774\ubbf8\uc9c0 \uc774\ub984\\n\\n\\t # Build \ubc0f Docker image\ub97c Docker Hub\uc5d0 push\\n - name: Build and push Docker image\\n uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671\\n with:\\n context: .\\n file: ./backend/Dockerfile\\n push: true\\n platforms: linux/arm64\\n tags: woowacarffeine/backend:latest\\n labels: ${{ steps.meta.outputs.labels }}\\n\\n deploy:\\n runs-on: self-hosted\\n if: ${{ needs.docker-build.result == \'success\' }}\\n needs: [ docker-build ]\\n steps:\\n\\t\\t# EC2 \ubc30\ud3ec \uc11c\ubc84\ub85c \uc811\uc18d\\n - name: Join EC2 dev server\\n uses: appleboy/ssh-action@master\\n env:\\n JASYPT_KEY: ${{ secrets.JASYPT_KEY }}\\n with:\\n host: ${{ secrets.SERVER_HOST }}\\n username: ${{ secrets.SERVER_USERNAME }}\\n key: ${{ secrets.SERVER_KEY }}\\n port: ${{ secrets.SERVER_PORT }}\\n envs: JASYPT_KEY\\n\\n # 1. \ub3c4\ucee4 \uc774\ubbf8\uc9c0 \ubc1b\uae30\\n # 2. \uae30\uc874\uc5d0 \ucf1c\uc9c4 \ubc31\uc5d4\ub4dc \uc11c\ubc84(\ub3c4\ucee4 \uc774\ubbf8\uc9c0) stop\\n # 3. \ucd5c\uc2e0 \ubc31\uc5d4\ub4dc \uc11c\ubc84 run\\n # 4. \uc0ac\uc6a9\ud558\uc9c0 \uc54a\ub294 \uc774\ubbf8\uc9c0\uc640 \ucee8\ud14c\uc774\ub108 \uc0ad\uc81c\\n script: |\\n sudo docker pull woowacarffeine/backend:latest\\n sudo docker stop backend || true\\n sudo docker run -d --rm -p 8080:8080 \\\\\\n -e \\"ENCRYPT_KEY=${{secrets.JASYPT_KEY}}\\" \\\\\\n --name backend \\\\\\n Docker Hub \uc0ac\uc6a9\uc790\uba85/\uc774\ubbf8\uc9c0 \uc774\ub984:latest\\n\\n sudo docker image prune -f\\n```\\n\\n\uc800\ud76c \ud300\uc740 \uc704\uc640 \uac19\uc774 backend-deploy.yml \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc704\uc5d0 yml\uc5d0\uc11c \uc800\ud76c\ub294 \ud0a4\ub97c \uc228\uacbc\ub294\ub370\uc694.\\n\\n![img](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/selfHostedKeys.png?raw=true)\\n\uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 \uc124\uc815\uc744 \ud574\uc8fc\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc774\ub97c yml\uc5d0\uc11c \uc0ac\uc6a9\ud558\uae30 \uc704\ud574\uc120 `secrets.Key\uc774\ub984`\uc73c\ub85c \uc0ac\uc6a9\ud574\uc8fc\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n
    \\n\\n\uc774\uc81c \ub9c8\uc9c0\ub9c9\uc73c\ub85c `Dockerfile`\uc744 \ub9cc\ub4e4\uc5b4\uc90d\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 `/backend/` \uacbd\ub85c\uc5d0 \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```Dockerfile\\nFROM amazoncorretto:17-alpine-jdk\\nARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar\\nCOPY ${JAR_FILE} app.jar\\nENTRYPOINT [\\"java\\", \\"-Dspring.profiles.active=dev\\", \\"-jar\\",\\"/app.jar\\"]\\n```\\n\\n\uc800\ud76c\ub294 \uc704\ucc98\ub7fc \uc808\ub300 \uacbd\ub85c\ub97c \uae30\uc900\uc73c\ub85c JAR_FILE \uc704\uce58\ub97c \uc9c0\uc815\ud558\uace0, profiles\ub294 dev\ub85c \uc124\uc815\ud574\uc11c \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n
    \\n\\n3. \ubc30\ud3ec\ud558\uae30\\n\\n\ud2b8\ub9ac\uac70\ub97c \uc791\ub3d9\uc2dc\ucf1c\uc11c \uc800\ud76c\uac00 yml \ud30c\uc77c\uc5d0\uc11c \uc9c0\uc815\ud574\uc900 \uac83\ub4e4\uc774 \uc798 \uc791\ub3d9\ud558\ub294\uc9c0 \ud655\uc778\ud569\ub2c8\ub2e4.\\n\\n![jobSuccess](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/jobsSuccess.png?raw=true)\\n\uc704\uc5d0 \uc0ac\uc9c4\ucc98\ub7fc \ubaa8\ub4e0 Job\uc774 \uc131\uacf5\uc801\uc73c\ub85c \ud1b5\uacfc\ud558\ub294 \uac83\uc744 \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![dockerPs](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/success.png?raw=true)\\n\uc774\ub807\uac8c \uc778\ud504\ub77c \uc11c\ubc84\uc5d0\uc11c \ubc30\ud3ec \uc11c\ubc84\ub85c \ub4e4\uc5b4\uac00\uc11c \uc131\uacf5\uc801\uc73c\ub85c \uc11c\ubc84\ub97c \ub3c4\ucee4\ub85c \ub744\uc6b4 \uac83\uc744 \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nEC2 \ubc30\ud3ec \uc11c\ubc84\uc5d0\uc11c `docker ps`\ub97c \uc785\ub825\ud588\uc744 \ub54c\uc5d0\ub3c4 \uc798 \uc2e4\ud589\uc774 \ub418\ub124\uc694!\\n\\n
    \\n\\n### CD \ubc30\ud3ec \uacfc\uc815 \uc694\uc57d\\n\uc9c0\uc18d\uc801 \ubc30\ud3ec \uacfc\uc815\uc744 \uc694\uc57d \ud558\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. Self Hosted Runner\ub97c EC2 \uc778\ud504\ub77c \uc11c\ubc84\uc5d0 \ub4f1\ub85d\ud574\uc900\ub2e4.\\n2. yml \ud30c\uc77c\uacfc Dockerfile\uc744 \ub9cc\ub4e4\uc5b4\uc900\ub2e4.\\n3. \ud2b8\ub9ac\uac70\ub97c \uc791\ub3d9\uc2dc\ucf1c\uc11c Github Actions\uc758 \ud0dc\uc2a4\ud06c\uac00 \ubaa8\ub450 \uc798 \ub418\ub294\uc9c0 \ud655\uc778\ud55c\ub2e4.\\n4. \uc798 \ub410\ub2e4\uba74 EC2 \ubc30\ud3ec \uc11c\ubc84\uc5d0 Docker image\uac00 \uc131\uacf5\uc801\uc73c\ub85c \ub744\uc6cc\uc9c4\ub2e4."},{"id":"16","metadata":{"permalink":"/16","source":"@site/blog/2023-07-15-jpa-create-select-query-when-id-is-not-null-.mdx","title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130 \uc785\ub2c8\ub2e4.","date":"2023-07-15T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 15\uc77c","tags":[{"label":"jpa","permalink":"/tags/jpa"}],"readingTime":9.97,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"16","title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","authors":["boxster"],"tags":["jpa"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","permalink":"/17"},"nextItem":{"title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","permalink":"/15"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130 \uc785\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \uc774\ubc88\uc5d0 \uae00\uc744 \uc4f0\uac8c\ub41c \uacc4\uae30\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \uc800\ud76c \ud300\uc740 \uacf5\uacf5 \ub370\uc774\ud130 API\uc5d0\uc11c \ubc1b\uc544\uc628 \ucda9\uc804\uc18c\uc640, \ucda9\uc804\uae30\ub4e4\uc758 ID\ub97c \uadf8\ub300\ub85c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 \ub2e4\ub978 API, \uc81c\uac00 \uc81c\uc5b4\ud560 \uc218 \uc5c6\ub294 \uacf3\uc5d0 \uc758\uc874\ud558\ub294 \uac83\uc740 \uc88b\uc9c0 \uc54a\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc624\ub294 \uacfc\uc815\uc5d0\uc11c \ub9c8\uc8fc\ud55c \uc131\ub2a5\uc801\uc778 \ubb38\uc81c \ub54c\ubb38\uc5d0 \uadf8\ub300\ub85c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc804\uad6d\uc758 \ucda9\uc804\uc18c\ub294 6\ub9cc\uac1c, \ucda9\uc804\uc18c \uc548\uc5d0 \uc874\uc7ac\ud558\ub294 \ucda9\uc804\uae30\ub294 23\ub9cc\uae30\uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uacf5\uacf5 \ub370\uc774\ud130\ub294 \ucda9\uc804\uc18c\uc640, \ucda9\uc804\uae30\uc758 \uc815\ubcf4\ub97c \ub530\ub85c \uc81c\uacf5\ud558\ub294 \uac83\uc774 \uc544\ub2cc \uc911\ubcf5\ub41c \ucda9\uc804\uc18c\ub97c \ud3ec\ud568\ud55c \ub370\uc774\ud130\ub97c \ucda9\uc804\uae30 \uac1c\uc218\ub9cc\ud07c\uc778 23\ub9cc\uac1c\uc758 row\ub85c \uc81c\uacf5\ud569\ub2c8\ub2e4.\\n\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\uac00 ID\ub97c \ub530\ub85c \ubd80\uc5ec\ud558\uac8c \ub41c\ub2e4\uba74, \ucda9\uc804\uc18c\ub97c \uc800\uc7a5\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \ubc1b\uc544\uc624\ub294 ID\ub85c \ucda9\uc804\uae30\ub97c \uc5f0\uacb0\ud574\uc918\uc57c\ud558\ub294\ub370 \uadf8\ub807\uac8c \ub41c\ub2e4\uba74 \uc140 \uc218 \uc5c6\uc774 \ub9ce\uc740 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n\uc7a0\uae50 \uc0dd\uac01\ud574\ubcf8\ub2e4\uba74\\n1. \ucda9\uc804\uc18c\ub97c \uac01\uac01 \uc800\uc7a5\ud558\uace0 ID\ub97c \ubd80\uc5ec\ubc1b\ub294 \ucffc\ub9ac `6\ub9cc\ubc88` (ID\ub97c \uc54c\uc544\uc640\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0 batch\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.)\\n2. \ucda9\uc804\uc18c\uc5d0\uc11c \ubc1b\uc544\uc628 ID\ub97c \ucda9\uc804\uae30\uc5d0 \ub9e4\ud551\ud558\uace0 \uc800\uc7a5\ud558\ub294 \ucffc\ub9ac `\ucd5c\uc18c 1\ubc88` (\ub9cc\uc57d batch\ub85c 23\ub9cc\uac74\uc744 \ud55c\ubc88\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub294 \uac00\uc815)\\n\\n\ud558\uc9c0\ub9cc ID\ub97c \uadf8\ub300\ub85c \uc0ac\uc6a9\ud558\uac8c \ub41c\ub2e4\uba74,\\n1. \ucda9\uc804\uc18c\ub97c \uc800\uc7a5\ud558\ub294 \ucffc\ub9ac `\ucd5c\uc18c 1\ubc88` (\ub9cc\uc57d batch\ub85c 6\ub9cc\uac74\uc744 \ud55c\ubc88\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub294 \uac00\uc815)\\n2. \ucda9\uc804\uae30\ub97c \uc800\uc7a5\ud558\ub294 \ucffc\ub9ac `\ucd5c\uc18c 1\ubc88` (\ub9cc\uc57d batch\ub85c 23\ub9cc\uac74\uc744 \ud55c\ubc88\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub294 \uac00\uc815)\\n\\n23\ub9cc\uac74\uc774 \ub118\ub294 \uc815\ubcf4\ub97c \ud655\uc778\ud588\uc744 \ub54c, ID\ub294 \uc911\ubcf5\ub418\uc9c0 \uc54a\uc558\uace0, \uc911\ubcf5\ud558\uc9c0 \uc54a\uc744 \uac83\uc774\ub77c \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \uadf8 \ubfd0\ub9cc \uc544\ub2c8\ub77c \ucc98\uc74c \ud55c\ubc88\ub9cc \uc800\uc7a5\ud558\ub294 \uac83\uc774 \uc544\ub2cc \uc8fc\uae30\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8\ub41c \uc815\ubcf4\ub97c\\n\ubc18\uc601\ud574\uc8fc\uace0 `update` or `save` \ud574\uc8fc\uc5b4\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0, ID\ub97c \uadf8\ub300\ub85c \uac00\uc9c0\uace0 \uc788\ub294 \uac83\uc774 \ud6e8\uc52c \ud6a8\uc728\uc801\uc774\ub77c \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc871\uc774 \uae38\uc5c8\uc2b5\ub2c8\ub2e4. \uac01\uc124\ud558\uace0 \uc774\ub7f0 \ubc29\uc2dd\uc73c\ub85c ID\ub97c \uc9c1\uc811 \ub123\uc5b4\uc8fc\ub294 \uacbd\uc6b0 \ubc1c\uc0dd\ud558\ub294 \ubb38\uc81c\uc5d0 \ub300\ud574 \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## ID\ub97c \uc9c1\uc811 \ub123\uc5b4\uc900 Entity\ub97c \uc800\uc7a5\ud560 \ub54c\\n\\n\uba3c\uc800 \uac04\ub2e8\ud55c \uc608\uc81c Entity\ub85c \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Entity\\npublic class ChargeStation {\\n\\n @Id\\n private String stationId;\\n\\n private String stationName;\\n\\n ...\\n}\\n\\n```\\n\ubcf4\ud1b5\uc758 Entity\uc640 \ub2e4\ub978 \ubd80\ubd84\uc740 Id\ub97c \uc9c1\uc811 \ud560\ub2f9\ud558\uae30 \ub54c\ubb38\uc5d0 `@GeneratedValue(strategy = GenerationType.IDENTITY)` \uc774\ub7ec\ud55c ID \uc0dd\uc131 \uc804\ub7b5\uc5d0 \ub300\ud55c \uc815\ubcf4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 `save()` \ucf54\ub4dc\ub97c \ud638\ucd9c\ud558\uba74 \uc5b4\ub5a4 \ucffc\ub9ac\uac00 \ub098\uac00\ub294\uc9c0 \ud655\uc778\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. \uc544\ub798\uc640 \uac19\uc774 \uc544\uc8fc \uac04\ub2e8\ud55c \uc120\ub989\uc5ed \ucda9\uc804\uc18c\ub97c \uc800\uc7a5\ud558\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\n@DataJpaTest\\nclass ChargeStationRepositoryTest {\\n\\n @Autowired\\n private ChargeStationRepository chargeStationRepository;\\n\\n @Test\\n void \ucda9\uc804\uc18c\ub97c_\uc800\uc7a5\ud55c\ub2e4() {\\n ChargeStation station = ChargeStationFixture.\uc120\ub989\uc5ed_\ucda9\uc804\uc18c_\ucda9\uc804\uae30_2\uac1c_\uc0ac\uc6a9\uac00\ub2a5_1\uac1c;\\n\\n chargeStationRepository.save(station);\\n\\n ChargeStation expect = chargeStationRepository.findByStationId(station.getStationId()).get();\\n assertThat(expect).isEqualTo(station);\\n }\\n}\\n```\\n\\n\uba3c\uc800 \ucf54\ub4dc\ub9cc \ubcf4\uba74 \uba3c\uc800 `chargeStationRepository.save()` \ud638\ucd9c\uacfc \ud568\uaed8 insert \ucffc\ub9ac 1\ubc88, \uadf8\ub9ac\uace0 `chargeStationRepository.findByStationId()`\uc5d0\uc11c select \ucffc\ub9ac 1\ubc88\\n\ucd1d 2\ubc88 \ubc1c\uc0dd\ud560 \uac83\uc774\ub77c\uace0 \uc720\ucd94\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![query-three-times](https://github.com/car-ffeine/design-system/assets/106640954/f48b7f0f-3f39-41ce-8fcd-94b995e95fae)\\n\\n\ud558\uc9c0\ub9cc \uc608\uc0c1\uacfc \ub2e4\ub974\uac8c \uc704\uc758 \uc0ac\uc9c4\uacfc \uac19\uc774 \ucffc\ub9ac\uac00 \ucd1d 3\ubc88 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uccab\ubc88\uc9f8\ub294 \ud638\ucd9c\ud558\uc9c0 \uc54a\uc740 station id\ub85c station\uc744 \uc870\ud68c\ud558\ub294 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc720\ub97c \ucc3e\uae30 \uc704\ud574 `save()` \uba54\uc11c\ub4dc\ub97c \ub514\ubc84\uae45 \ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n### save \uc2dc SELECT \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud558\ub294 \uc774\uc720\\n\\n\\n![save-method](https://github.com/car-ffeine/design-system/assets/106640954/b1db00b7-d7fb-4647-912c-6f8e2fe44974)\\n\ub85c\uc9c1\uc740 \uac04\ub2e8\ud574\ubcf4\uc785\ub2c8\ub2e4. `isNew()` \ub97c \ud1b5\ud574 \uc0c8\ub85c\uc6b4 Entity\uc778\uc9c0 \ud655\uc778\ud55c \ud6c4, \uc0c8\ub85c\uc6b4 Entity\ub77c\uba74 `persist()`, \uc544\ub2c8\ub77c\uba74 `merge()`\ub97c \ud638\ucd9c\ud569\ub2c8\ub2e4.\\n\\n\uc5ec\uae30\uc11c `EntityManager#persist()` \uba54\uc11c\ub4dc\ub97c \uac04\ub2e8\ud788 \ub9d0\uc500\ub4dc\ub9ac\uba74, \uc0c8\ub85c\uc6b4 Entity\ub97c \uc601\uc18d\ud654\ud558\ub294 \uba54\uc11c\ub4dc\ub85c \ud2b8\ub79c\uc7ad\uc158\uc774 \ucee4\ubc0b\ub420 \ub54c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc800\uc7a5\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 `EntityManager#merge()` \uba54\uc11c\ub4dc\ub294 \uc900\uc601\uc18d \uc0c1\ud0dc\uc758 Entity\ub97c \uc601\uc18d \uc0c1\ud0dc\ub85c \ubcc0\uacbd\ud558\ub294\ub370 \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uc774\ub54c \uc601\uc18d\uc131 \ucee8\ud14d\uc2a4\ud2b8\uc5d0 \uc874\uc7ac\ud558\uc9c0 \uc54a\ub294 \uac1d\uccb4\ub77c\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uc870\ud68c \ud6c4 \uc601\uc18d\ud654\ud558\ub294 \uc791\uc5c5\uc744 \uc218\ud589\ud569\ub2c8\ub2e4.\\n\\n`merge()`\ub97c \ud638\ucd9c\ud558\uae30 \ub54c\ubb38\uc5d0 SELECT \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud558\uace0, \uc601\uc18d\ud654\ud558\ub294 \uc791\uc5c5\uc744 \uc218\ud589\ud558\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc81c\uac00 \uc800\uc7a5\ud55c \uac1d\uccb4\ub294 \ud655\uc2e4\ud788 \uc0c8\ub85c\uc6b4 Entity\uac00 \ub9de\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc `entityInformation.isNew()` \uba54\uc11c\ub4dc\ub294 false\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc5b4\ub5a4 \uac83\uc744 \uae30\uc900\uc73c\ub85c \uc0c8\ub85c\uc6b4 Entity\uc778 \uac83\uc744 \uad6c\ubd84\ud558\ub294\uc9c0 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### \uc0c8\ub85c\uc6b4 Entity\ub97c \uad6c\ubd84\ud558\ub294 \uae30\uc900\\n\\n\uc77c\ub2e8, \ub514\ubc84\uae45\uc744 \ud1b5\ud574 isNew \uba54\uc11c\ub4dc\ub97c \ud655\uc778\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![is-new](https://github.com/car-ffeine/design-system/assets/106640954/e4a56694-c623-46d8-badd-3345d557e29f)\\n\\n\uac04\ub2e8\ud569\ub2c8\ub2e4. \uba3c\uc800 Entity\uc5d0 ID\ub97c \uac00\uc838\uc635\ub2c8\ub2e4. \uadf8\ub9ac\uace0 id\uac00 `primitive` \ud0c0\uc785\uc778\uc9c0 \ud655\uc778 \ud6c4, \uc544\ub2d0\uacbd\uc6b0 id\uac00 null \uc774\uba74 \uc0c8\ub85c\uc6b4 Entity, \uc544\ub2d0\uacbd\uc6b0 false\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub54c, `primitve` \ud0c0\uc785\uc774\ub77c\uba74, id\uac00 \uc22b\uc790\uc778\uc9c0 \ud655\uc778 \ud6c4 id\uac00 0\uc774\uba74 \uc0c8\ub85c\uc6b4 Entity, \uc544\ub2d0\uacbd\uc6b0 false\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\\n## ID\ub97c \uc9c1\uc811 \ub123\uc5b4\uc8fc\ub294 \uac1d\uccb4\ub294 JPA \uc0ac\uc6a9\uc744 \ud3ec\uae30\ud574\uc57c\ud560\uae4c?\\n\\n\uacb0\ub860\ubd80\ud130 \ub9d0\uc500\ub4dc\ub9ac\uba74 \uc544\ub2d9\ub2c8\ub2e4. \ub2e4\ub978 \ubc29\ubc95\uc73c\ub85c \uc0c8\ub85c\uc6b4 Entity \uc784\uc744 \uc99d\uba85\ud560 \uc218 \uc788\ub2e4\uba74 `merge()`\uac00 \uc544\ub2cc `persist()`\ub97c \ud638\ucd9c\ud558\ub3c4\ub85d \ub9cc\ub4e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uadf8\ub7fc \uc5b4\ub5bb\uac8c?\\n\uba3c\uc800 save() \uba54\uc11c\ub4dc\uc758 \ud544\ub4dc \uc911 `JpaEntityInformation`\uc774\ub77c\ub294 \ud544\ub4dc\ub97c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![entity-info](https://github.com/car-ffeine/design-system/assets/106640954/d9956fe6-07c7-41a9-9d6b-7c01b5f31c5d)\\n\\n\uc774 \uc778\ud130\ud398\uc774\uc2a4\ub294 Entity\uc758 \ucd94\uac00 \uc815\ubcf4\ub97c \uc54c\uae30 \uc704\ud574 \ud544\ub4dc\uc5d0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud574\ub2f9 \uc778\ud130\ud398\uc774\uc2a4\uc758 \uad6c\ud604\uccb4\ub294 `JpaEntityInformationSupport`, `JpaMetamodelEntityInformation`, `JpaPersistableEntityInformation` \uc774\ub807\uac8c 3\uac1c\uc758 \ud074\ub798\uc2a4\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \ub2e4\ub978 \ubc29\ubc95\uc73c\ub85c\ub3c4 `isNew()`\uac00 \uad6c\ud604\ub418\uc5b4 \uc788\uc744\uac70\ub77c \ucd94\uce21\uc744 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub514\ubc84\uae45\uc744 \ud1b5\ud574 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\uae4c \uc704\uc758 \uc0ac\uc9c4\uc73c\ub85c \ubcf4\uace0 \uc2e4\uc81c\ub85c \uc2e4\ud589\ub410\ub358 `isNew()` \uba54\uc11c\ub4dc\uc758 \uc8fc\uc778\uc740 `JpaMetamodelEntityInformation` \ud074\ub798\uc2a4\uc600\uc2b5\ub2c8\ub2e4. \uadf8\ub798\uc11c \ud574\ub2f9 \ud074\ub798\uc2a4\ub294 \uc81c\uc678\ud558\uace0 \ub2e4\ub978 \ud074\ub798\uc2a4\ub97c \ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 `JpaPersistableEntityInformation` \ud074\ub798\uc2a4\uc785\ub2c8\ub2e4.\\n![is-new-persistable](https://github.com/car-ffeine/design-system/assets/106640954/dc2293c3-2854-4619-9ef6-d08e55b4581b)\\n\uc544\uc8fc \uac04\ub2e8\ud558\uac8c entity\uc758 `isNew()`\ub97c \ud638\ucd9c\ud55c\ub2e4\uace0 \uc801\ud600\uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc `Persistable` \uc778\ud130\ud398\uc774\uc2a4\ub97c \uad6c\ud604\ud55c Entity\uc758 `isNew()` \ub97c \ud638\ucd9c\ud558\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \ub0a8\uc740 \ud558\ub098\uc758 \ud074\ub798\uc2a4\ub97c \ud655\uc778\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![info-support](https://github.com/car-ffeine/design-system/assets/106640954/f1d654c0-e741-4db7-8e7f-4e758c36133a)\\n\\n\uc704 \uc0ac\uc9c4\ucc98\ub7fc \uc774 \ud074\ub798\uc2a4\uac00 Entity \ub9c8\ub2e4 `Persistable` \uad6c\ud604 \uc720\ubb34\uc5d0 \ub530\ub77c \ub3d9\uc801\uc73c\ub85c \uad6c\ud604\uccb4\ub97c \ubcc0\uacbd\ud574\uc8fc\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \ub2f5\uc774 \ub098\uc628 \uac83 \uac19\uc2b5\ub2c8\ub2e4. ID\ub97c \uc9c1\uc811 \ud560\ub2f9\ud558\ub294 Entity\uc5d0 `Persistable`\uc744 \uad6c\ud604\ud574\uc8fc\uba74 \ub429\ub2c8\ub2e4.\\n\\n### Persistable \uad6c\ud604\ud558\uae30\\n```java\\n@Entity\\npublic class ChargeStation implements Pesistable{\\n\\n @Id\\n private String stationId;\\n\\n private String stationName;\\n\\n @CreatedDate\\n private LocalDateTime createdTime;\\n\\n ...\\n\\n @Override\\n public Object getId() {\\n return getStationId();\\n }\\n\\n @Override\\n public boolean isNew() {\\n return createdTime == null;\\n }\\n}\\n```\\n\\n\uac04\ub2e8\ud788 \ub9cc\ub4e4\uc5b4\ubd24\uc2b5\ub2c8\ub2e4. `@CreatedDate`\ub294 Entity\uac00 \ucc98\uc74c \uc601\uc18d\ud654\ub420 \ub54c \ub3d9\uc791\ud558\uae30 \ub54c\ubb38\uc5d0 \uc774 Entity\uc758 CreateTime \ud544\ub4dc\uac00 null \uc774\uba74 \uc0c8\ub85c\uc6b4 Entity\ub77c\uace0 \ud655\uc2e0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub7fc \uc774\ub807\uac8c \uc778\ud130\ud398\uc774\uc2a4\ub97c \uad6c\ud604\ud558\uace0 \uc544\uae4c \uc2e4\ud589\ud588\ub358 \ud14c\uc2a4\ud2b8\ub97c \ub2e4\uc2dc \uc2e4\ud589\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![solved](https://github.com/car-ffeine/design-system/assets/106640954/ea5db719-9919-42f4-b431-00e14d6fea5e)\\n\\n\uae54\ub054\ud558\uac8c \uad6c\ud604\ub41c \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uc6d0\ud558\ub358\ub300\ub85c \ucffc\ub9ac\uac00 2\ubc88 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\uc774\ub7f0 `Persistable`\uc744 `@MappedSuperClass`\ub97c \ud1b5\ud574 \ub354 \uae54\ub054\ud558\uac8c \uad6c\ud604\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub530\ub85c \uc124\uba85\ub4dc\ub9ac\uc9c0\ub294 \uc54a\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n#### \uacb0\ub860\\nJPA\ub294 \ub9ce\uc740 \ud3b8\uc758 \uae30\ub2a5\uc744 \uc81c\uacf5\ud574\uc8fc\ub294 \uac83 \uac19\uc544\ubcf4\uc785\ub2c8\ub2e4. \ucac4\uc9c0\ub9d9\uc2dc\ub2e4."},{"id":"15","metadata":{"permalink":"/15","source":"@site/blog/2023-07-14-data-update-process-with-boxter.mdx","title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","description":"\uc548\ub155\ud558\uc138\uc694~","date":"2023-07-14T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 14\uc77c","tags":[{"label":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","permalink":"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4"},{"label":"\uc11c\ubc84","permalink":"/tags/\uc11c\ubc84"}],"readingTime":9.215,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"},{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"15","title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","authors":["jay","boxster"],"tags":["\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","\uc11c\ubc84"]},"prevItem":{"title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","permalink":"/16"},"nextItem":{"title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","permalink":"/14"}},"content":"\uc548\ub155\ud558\uc138\uc694~\\n\uc6b0\ud14c\ucf54 \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \uce74\ud398\uc778 \ud300\uc758 \ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c \'\ubc15\uc2a4\ud130\'\uc640 \ud568\uaed8 \uc5b4\ub5a4 \ubb38\uc81c\ub97c \uacaa\uace0 \ud574\uacb0\ud588\ub294\uc9c0 \uc801\uc5b4\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n* \ubc30\uc6b0\ub294 \ub2e8\uacc4\uc774\ub2e4 \ubcf4\ub2c8 \ud2c0\ub9b0 \ubd80\ubd84\uc774 \uc788\uc744 \uc218 \uc788\ub294\ub370, \ud53c\ub4dc\ubc31 \ubd80\ud0c1\ub4dc\ub9bd\ub2c8\ub2e4 :)\\n\\n\uba3c\uc800 \uae00\uc744 \uc4f0\uae30 \uc804\uc5d0 \ubb38\uc81c \uc0c1\ud669\uc5d0 \ub300\ud574 \uac04\ub2e8\ud558\uac8c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \ubb38\uc81c \uc0c1\ud669\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \uacf5\uacf5 API\ub97c \ud65c\uc6a9\ud558\uc5ec \ucda9\uc804\uc18c\uc758 \ud63c\uc7a1\ub3c4 \uc81c\uacf5 \ubc0f \uc5ec\ub7ec \uc11c\ube44\uc2a4\ub97c \uc81c\uacf5\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uc11c\ube44\uc2a4\ub97c \uc0ac\uc6a9\uc790\ub4e4\uc5d0\uac8c \uc81c\uacf5\ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \uc791\uc5c5\ub4e4\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n1. \uccab \uc2e4\ud589\uc2dc \uacf5\uacf5 API \ub370\uc774\ud130\ub97c \ubaa8\ub450 \ubd88\ub7ec\uc11c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc0bd\uc785\ud569\ub2c8\ub2e4.\\n2. \ud63c\uc7a1\ub3c4\ub97c \uc81c\uacf5\ud558\uae30 \uc704\ud574\uc11c \uc8fc\uae30\uc801\uc778 \uc2dc\uac04 (\uc544\uc9c1 \uc815\ud558\uc9c4 \uc54a\uc558\uc9c0\ub9cc ex.12\uc2dc\uac04) \ub2e8\uc704\ub85c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8 \ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc2dc \ub370\uc774\ud130\ub97c \uc694\uccad\uc744 \ud569\ub2c8\ub2e4.\\n3. \uc0c8\ub86d\uac8c \ucd94\uac00\ub41c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 \ubaa8\ub450 Insert\ud574\uc8fc\uace0, \uae30\uc874\uc5d0 \uc788\ub358 \ucda9\uc804\uc18c \ud639\uc740 \ucda9\uc804\uae30\uac00 \uc5c5\ub370\uc774\ud2b8 \ub410\ub2e4\uba74 \ubcc0\uacbd\ub41c \ub370\uc774\ud130\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc90d\ub2c8\ub2e4.\\n\\n\\n\uc800\ub791 \ubc15\uc2a4\ud130\ub294 2~3\ubc88 \uacfc\uc815\uc744 \uc9c4\ud589\ud558\ub294 \uc5ed\ud560\uc744 \ub9e1\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ud14c\uc774\ube14\uc758 \uad00\uacc4\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```\\ncharge_station <---1------N---\x3e charger\\n charger <---1------1---\x3e charger_status\\n```\\n\\n\uc800\ud76c\ub294 \uc774 \ubb38\uc81c\ub97c \uc5b4\ub5bb\uac8c \ud574\uacb0 \ud588\ub294\uc9c0 \ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \ubb38\uc81c \ud574\uacb0 \uacfc\uc815\\n\\n\uc804\uc81c\uc870\uac74\\n\\n- \uccab \uc2e4\ud589 \ubaa8\ub4e0 \ud14c\uc774\ube14\uc740 \ucd08\uae30\ud654 \uc0c1\ud0dc\uc774\ub2e4.\\n- \ub370\uc774\ud130\ub294 9999\uac74\uc744 \uae30\uc900\uc73c\ub85c \ud55c\ub2e4.\\n- \uba54\uc11c\ub4dc \uccab \uc2dc\ud589\uc5d0\uc11c\ub294 \ubaa8\ub4e0 \ub370\uc774\ud130\uac00 \uc0c8\ub86d\uac8c insert \ub418\uace0\\n- \uadf8 \ub2e4\uc74c \uba54\uc11c\ub4dc \uc2dc\ud589\uc5d0\uc11c\ub294 \uc77c\ubd80 \ub370\uc774\ud130\ub294 \ucd94\uac00\ub418\uace0, \uc77c\ubd80\ub294 \uc5c5\ub370\uc774\ud2b8 \ub41c\ub2e4.\\n\\n\\n## Ver1. findAll() \uc870\ud68c \ud6c4 \uac01\uac01 save() \ud574\uc8fc\uae30 (\uc57d14\ucd08)\\n\\n\uc800\ud76c\uac00 \ucc98\uc74c\uc5d0 \uc0dd\uac01\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\uc54c\uc544\uc11c \ubc14\ub010 \uac83\ub4e4\uc740 \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uace0, \uc0c8\ub85c\uc6b4 \uac74 \uc800\uc7a5\ud574\uc8fc\uae30 \ub54c\ubb38\uc5d0 \uac04\ub2e8\ud55c \ubc29\ubc95\uc73c\ub85c \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc2e4\uc81c\ub85c \ud574\ubcf8 \uacb0\uacfc, \uc0bd\uc785\uc758 \uacbd\uc6b0\ub294 SELECT \ucffc\ub9ac\ubb38 \uc2e4\ud589 \ud6c4 INSERT \ucffc\ub9ac\ubb38\uc744 \ubc1c\uc0dd \uc2dc\ucf30\uace0,\\n\uc5c5\ub370\uc774\ud2b8 \uc2dc\uc5d0\ub3c4 SELECT \ud6c4 UPDATE \ud639\uc740 INSERT\ub97c \ubc1c\uc0dd \uc2dc\ucf30\uc2b5\ub2c8\ub2e4. (\ubcc0\uacbd \uc0ac\ud56d \uc5c6\uc73c\uba74 SELECT\ub9cc)\\n\\n\uc774\ub294 \uc2dd\ubcc4\uc790\uc5d0 \ub530\ub978 JPA \uc791\ub3d9 \ubc29\uc2dd \ub54c\ubb38\uc778\ub370\uc694.\\n\uc774 \ubc29\ubc95\uc758 \uacb0\uacfc\ub294 \uc57d 14\ucd08\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc774\ub807\uac8c \ubd88\ud544\uc694\ud55c SELECT \uc791\uc5c5\uc744 \ub9c9\uc544\ubcf4\uace0\uc790 \ub2e4\ub978 \ubc29\ubc95\uc744 \uad6c\uc0c1\ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uae30\ubcf8\uc801\uc73c\ub85c Jdbc\ub97c \uc774\uc6a9\ud574\uc11c Batch Insert\uc640 Batch Update\ub97c \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\uace0, \uc774 \uc791\uc5c5\uc744 \uc704\ud574\uc11c \ubcc0\uacbd \ud639\uc740 \uc0bd\uc785\ub420 \ub370\uc774\ud130\ub4e4\uc744 \uc9c1\uc811 \ucc3e\ub294 \uacfc\uc815\uc774 \uc911\uc694\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n## Ver2. \ubcc0\uacbd \uac10\uc9c0\ub97c \uc9c1\uc811 \ud574\uc8fc\uace0, \uc790\ub8cc\uad6c\uc870\ub85c \ubc30\uce58 \ub370\uc774\ud130 \ubaa8\uc73c\uae30 : O(n^2) (\uc57d 11\ucd08)\\n\\n\ub450 \ubc88\uc9f8\ub85c \uc800\ud76c\uac00 \uc0dd\uac01\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\uba3c\uc800 \ub370\uc774\ud130 \ucd94\uac00 \ubc0f \ubcc0\uacbd \uac10\uc9c0 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\\n\uae30\uc874 \uc5c5\ub370\uc774\ud2b8 \uc2dc\uc5d0 SELECT\uc640 UPDATE(or INSERT) \ub450\ubc88\uc758 \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uac83\uc774 \ub9d8\uc5d0 \ub4e4\uc9c0 \uc54a\uc544\uc11c\\n\ubcc0\uacbd \uac10\uc9c0\ub97c \uc9c1\uc811 \ud574\uc8fc\ub824\uace0 \uba54\uc11c\ub4dc\ub97c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\uac00 \uc0dd\uac01\ud55c \ubcc0\uacbd \uac10\uc9c0\ub294 \uc0dd\uac01\ubcf4\ub2e4 \uac04\ub2e8\ud55c\ub370\uc694.\\n\ub3c4\uba54\uc778\uc5d0 \uba54\uc11c\ub4dc\ub97c \ub9cc\ub4e4\uc5b4\uc11c \ud544\ub4dc\ub97c if\ubb38\uc73c\ub85c \ud558\ub098\uc529 \ube44\uad50\ud574\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\ucda9\uc804\uc18c\uc758 \ub370\uc774\ud130 \ud2b9\uc9d5\uc0c1 \ub370\uc774\ud130\uac00 \uc790\uc8fc \ubc14\ub00c\ub294 \ub370\uc774\ud130\ub294 \ube44\uad50\uc801 \ucd08\ubc18\uc5d0 \ube44\uad50\ud558\ub3c4\ub85d \uad6c\ud604\ud558\uace0, \uc790\uc8fc \ubc14\ub00c\uc9c0 \uc54a\ub294 \ub370\uc774\ud130\ub294 \ud6c4\uc5d0 \ube44\uad50\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub370\uc774\ud130 \uc800\uc7a5 \ubc0f \uc5c5\ub370\uc774\ud2b8 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\uba3c\uc800 findAll()\ub85c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30 \ub4f1 \uad00\ub828\ub41c \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c Map\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4.\\nMap\uc758 \uad6c\uc870\ub85c \uae30\uc874\uc5d0 \ud14c\uc774\ube14\uc5d0 \uc800\uc7a5\ub41c \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c \uc790\ub8cc\uad6c\uc870\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uacf5\uacf5 API\ub97c \ubd88\ub7ec\uc640\uc11c, \ub611\uac19\uc774 Map\uc758 \uad6c\uc870\ub85c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n(Station \uc548\uc5d0\ub294 `List`\uac00 \uc874\uc7ac)\\n\\n\uc800\ud76c\ub294 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uc18c\uc5d0 \ud574\ub2f9\ud558\ub294 \ubaa8\ub4e0 \ucda9\uc804\uae30\ub4e4\uc744 \ube44\uad50\ud558\uba74\uc11c \ubcc0\uacbd \uac10\uc9c0\ub97c \ud574\uc918\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0\\n\uac01\uac01\uc758 Map.values()\uc778 `List : \uae30\uc874 \ucda9\uc804\uc18c`\uc640 `List : \uc5c5\ub370\uc774\ud2b8\ub41c \ucda9\uc804\uc18c`\ub97c \ube44\uad50\ud574\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\ube44\uad50\ub97c \ud558\uba74\uc11c \uc0c8\ub85c \uc0bd\uc785\ub41c \ucda9\uc804\uc18c\uc640 \uc5c5\ub370\uc774\ud2b8 \ub41c \ucda9\uc804\uc18c\ub97c \uac01\uac01 \ucc98\ub9ac\ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ucda9\uc804\uc18c\uc758 \ubcc0\uacbd \uac10\uc9c0\ub97c \uc704\ud574\uc11c \ucda9\uc804\uae30\ub4e4\uc740 \ucda9\uc804\uc18c \uc548\uc5d0 List\ub85c \uc18d\ud574\uc788\uae30 \ub54c\ubb38\uc5d0 O(n^2)\uc758 \uc2dc\uac04 \ubcf5\uc7a1\ub3c4\ub97c \uac00\uc9c0\uace0 \uc804\uccb4 \ub370\uc774\ud130\ub4e4\uc740 \uc57d 23\ub9cc \uac74\uc774\ubbc0\ub85c, \uc804\uccb4 \ub370\uc774\ud130\ub97c \ub300\uc0c1\uc73c\ub85c \ud55c\ub2e4\uba74 \uc57d 530\uc5b5\ubc88\uc758 \uc5f0\uc0b0\uc774 \uc774\ub904\uc84c\uaca0\ub124\uc694.\\n\\nVer1\uc5d0 \ube44\ud574\uc11c\ub294 \ud06c\uc9c0\ub294 \uc54a\uc9c0\ub9cc \ud3c9\uade0\uc801\uc73c\ub85c \uc57d 2\ucd08 \uc815\ub3c4 \uc904\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n\\n## Ver3. \ubcc0\uacbd \uac10\uc9c0\ub97c \uc9c1\uc811 \ud574\uc8fc\uace0, \uc790\ub8cc\uad6c\uc870\ub85c \ubc30\uce58 \ub370\uc774\ud130 \ubaa8\uc73c\uae30 : O(1) (\uc57d 10\ucd08)\\nVer2\uc640 \uac70\uc758 \uc720\uc0ac\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n\ucc28\uc774\uc810\uc740 Map \uc790\ub8cc \uad6c\uc870 \uc0ac\uc6a9\ubc29\ubc95\uc744 \ubcc0\uacbd\ud588\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874 2\uc911 for\ubb38\uc5d0\uc11c, 1\uc911 for\ubb38\uc744 \ub3cc\uba74\uc11c \ud0a4 \uac12\uc744 \ud1b5\ud574\uc11c \uc2e0\uaddc \ub370\uc774\ud130\uc640 \uc5c5\ub370\uc774\ud2b8 \ub420 \ub370\uc774\ud130\ub4e4\uc744 \ubd84\ub958\ud558\uace0, \uc774\ub4e4\uc744 \uac01\uac01 List\uc5d0 \ub123\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc774\ub97c \ud1b5\ud574\uc11c Ver2\uc5d0 \ube44\ud574\uc11c 1\ucd08\uc815\ub3c4 \uc904\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n\\n## Ver4. \uc774\uc804 \ubc29\uc2dd + Fetch Join \uc0ac\uc6a9\ud558\uae30 (\uc57d 6\ucd08)\\n\ub9c8\uc9c0\ub9c9 \ubc29\ubc95\uc740 \uc870\ud68c \uacfc\uc815\uc758 \uc2dc\uac04 \ub2e8\ucd95\uc785\ub2c8\ub2e4.\\n\\n\ucc98\uc74c\uc5d0 Stations\ub97c findAll()\ud558\ub294 \ucffc\ub9ac\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 N+1 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uadf8 \uc774\uc720\ub294 Station\uc5d0\uc11c Chargers\ub97c \uc9c0\uc5f0\ub85c\ub529\uc73c\ub85c \uc124\uc815 \ud588\ub294\ub370, \uc774\ub97c \uadf8\ub300\ub85c get \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 \uc870\ud68c\ud574\uc11c \ud574\ub2f9 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```java\\nList findAll(); // \uae30\uc874\\n\\n@Query(\\"SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers\\"); // Fetch Join \uc801\uc6a9\\nList findAll();\\n```\\n\\n\ub530\ub77c\uc11c \uc704\uc5d0 \ucf54\ub4dc\uc640 \uac19\uc774 Fetch Join\uc744 \uc774\uc6a9\ud574\uc11c \ucc98\uc74c\uc5d0 \ub370\uc774\ud130\ub97c \uac00\uc838\uc654\uc2b5\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ud6a8\uc728\uc801\uc778 \uc870\ud68c\ub85c \ubcc0\uacbd\ud558\uba74\uc11c \uc2dc\uac04\uc744 \ub9ce\uc774 \uc904\uc77c \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n### \uc9c0\uae08\uae4c\uc9c0\uc758 \ubc29\ubc95\uc744 \uc815\ub9ac\ub97c \ud558\uc790\uba74\\nVer1 \uacfc \uac19\uc740 \ubc29\uc2dd\uc5d0\uc11c\ub294 \uc5c5\ub370\uc774\ud2b8 \uacfc\uc815\uc5d0\uc11c JPA\uc758 \uc2dd\ubcc4\uc790\uc5d0 \ub530\ub978 \ucc98\ub9ac \ubc29\uc2dd\uc73c\ub85c \uc778\ud574 [SELECT + UPDATE] or [SELECT + INSERT] \uc640 \uac19\uc774 \ucffc\ub9ac\uac00 \ub450 \ubc88\uc529 \ub098\uac14\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c Ver3\uae4c\uc9c0 \uac1c\uc120\uc744 \ud558\uae30 \uc704\ud574\uc11c \uc800\uc7a5\uacfc \uc5c5\ub370\uc774\ud2b8\ub97c \ud55c \ubc88\uc5d0 JDBC\ub97c \uc774\uc6a9\ud574\uc11c Batch\ub85c \ucc98\ub9ac\ud574\uc8fc\ub294 \ubc29\uc2dd\uc744 \uc120\ud0dd\ud588\uace0,\\n\\n\ubcc0\uacbd \uac10\uc9c0 + \ubc30\uce58 \ub370\uc774\ud130\ub97c \ubaa8\uc73c\uae30 \uc704\ud574\uc11c \uc790\ub8cc\uad6c\uc870\ub97c \uc774\uc6a9\ud574\uc11c \uc2dc\uac04\uc744 \uc870\uae08\uc529 \ub2e8\ucd95 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c Ver4\uc5d0\uc11c\ub294 findAll()\uc5d0\uc11c \ubc1c\uc0dd\ud558\ub294 N+1\uc758 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uba74\uc11c \uc2dc\uac04\uc744 \ub2e8\ucd95\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c \ub3d9\uc77c \uc791\uc5c5\uc744 14\ucd08\uc5d0\uc11c 6\ucd08 \uc815\ub3c4\ub85c \uc904\uc77c \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4!"},{"id":"14","metadata":{"permalink":"/14","source":"@site/blog/2023-07-14-server-architecture.mdx","title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","description":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4","date":"2023-07-14T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 14\uc77c","tags":[{"label":"ec2","permalink":"/tags/ec-2"},{"label":"aws","permalink":"/tags/aws"},{"label":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","permalink":"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4"},{"label":"\ubc30\ud3ec","permalink":"/tags/\ubc30\ud3ec"},{"label":"\uc11c\ubc84","permalink":"/tags/\uc11c\ubc84"},{"label":"\uc544\ud0a4\ud14d\ucc98","permalink":"/tags/\uc544\ud0a4\ud14d\ucc98"}],"readingTime":10.495,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"14","title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","authors":["nunu"],"tags":["ec2","aws","\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","\ubc30\ud3ec","\uc11c\ubc84","\uc544\ud0a4\ud14d\ucc98"]},"prevItem":{"title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","permalink":"/15"},"nextItem":{"title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","permalink":"/13"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4\\n\\n\uc774\ubc88\uc5d0 \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98\ub97c \uacb0\uc815\ud558\uac8c \ub418\uc5c8\ub358 \uacfc\uc815\uc5d0 \ub300\ud574\uc11c \uc815\ub9ac\ub97c \ud574\ubcf4\uace0 \uc2f6\uc5b4\uc11c \uae00\uc744 \uc4f0\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ud0a4\ud14d\ucc98\uc640 \uc11c\ubc84\uac00 \ubc30\ud3ec\ub418\ub294 \uacfc\uc815\uc744 \ubcf4\uc5ec\ub4dc\ub9ac\uba74\uc11c \uc2dc\uc791\ud558\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n![\ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98](https://blog.kakaocdn.net/dn/dKVRTG/btsnFE7Nb82/GRONsIJPqd8WFVzjzqsgqk/img.png)\\n\\n\uc11c\ubc84\uac00 \ubc30\ud3ec\ub418\ub294 \uacfc\uc815\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![server image](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdto7By%2FbtsnD31hYHy%2F7rWKwxulxXzfhRigE60Sd0%2Fimg.png)\\n\\n## \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4 \uc778\uc2a4\ud134\uc2a4\uc5d0 \ub300\ud55c \uc18c\uac1c\\n\\n\uc6b0\ud14c\ucf54\uc5d0\uc11c \uc120\ud0dd\ud560 \uc218 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub294 \ucd1d 2\uac00\uc9c0 \uc885\ub958\uc785\ub2c8\ub2e4.\\n\\n1. \ud37c\ube14\ub9ad \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\\n - \ucea0\ud37c\uc2a4\uc5d0\uc11c\ub9cc SSH \uc811\uadfc\uc774 \uac00\ub2a5\ud55c \uc778\uc2a4\ud134\uc2a4\uc785\ub2c8\ub2e4.\\n - \ubbf8\ub9ac \uc5f4\ub824\uc788\ub294 \ud3ec\ud2b8\ub4e4\ub9cc \ud5c8\uc6a9\uc774 \ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n - \uac19\uc740 \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub07c\ub9ac\ub294 \ubaa8\ub4e0 \ud3ec\ud2b8\uac00 \ud5c8\uc6a9\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4\\n2. \ud504\ub77c\uc774\ube57 \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\\n - \ud37c\ube14\ub9ad \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub97c \ud1b5\ud574\uc11c\ub9cc \uc811\uadfc\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n - \uac19\uc740 \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub07c\ub9ac\ub294 \ubaa8\ub4e0 \ud3ec\ud2b8\uac00 \ud5c8\uc6a9\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\ubc88 \uc778\uc2a4\ud134\uc2a4\ub97c 2\uac1c \uc0ac\uc6a9 \uac00\ub2a5\ud558\uace0, 2\ubc88 \uc778\uc2a4\ud134\uc2a4\ub97c 1\uac1c \uc0ac\uc6a9 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n\uad8c\uc7a5\ub418\ub294 \ud658\uacbd\uc5d0\uc11c 1\uac1c\ub294 db \uc11c\ubc84\ub85c \uc0ac\uc6a9\ud558\uace0, \ub098\uba38\uc9c0 2\uac1c\ub294 \uc790\uc720\ub86d\uac8c \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uadf8\uc804\uc5d0 \uc54c\uba74 \uc88b\uc544\uc694\\n\\n\uc5ec\uae30\uc11c\ub294 Self Hosted Runner\ub97c \uc0ac\uc6a9\ud588\ub294\ub370\uc694.\\n\\nSelf Hosted Runner\uc5d0 \ub300\ud55c \ub0b4\uc6a9\uc740 [\uc5ec\uae30](https://be-student.tistory.com/75#%EC%99%9C%20Self%20Hosted%20Runner%EC%95%BC%3F-1) \uc5d0 \uc798 \ub098\uc640\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc678\ubd80 IP\ub85c\ubd80\ud130 SSH \uc811\uadfc\uc774 \ubd88\uac00\ub2a5\ud558\uae30\uc5d0, Self Hosted Runner \ub098, Jenkins \uac19\uc740 \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5c8\ub294\ub370, \ub7ec\ub2dd \ucee4\ube0c\ub97c \uace0\ub824\ud574\uc11c Self Hosted Runner\ub97c \uc120\ud0dd\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98\uc5d0 \ub300\ud55c \uace0\ubbfc\\n\\n\uc800\ud76c \ud300\uc774 \uc774\ubc88 \uc544\ud0a4\ud14d\ucc98\ub97c \ub9cc\ub4e4\uae30 \uc704\ud574\uc11c \uace0\ubbfc\ud588\ub358 \uc810\ub4e4\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc5b4\ub5bb\uac8c \ud558\uba74 \uc7a5\uc560\uc758 \uc601\ud5a5\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n2. \uc6b4\uc601 \uc11c\ubc84\ub97c \ub098\uc911\uc5d0 \ucd94\uac00\ud558\uac8c \ub418\uc5c8\uc744 \ub54c, \uc5b4\ub5bb\uac8c \uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ub418\ub294 \ubd80\ubd84\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n3. 2\ucc28 \ub370\ubaa8\ub370\uc774\uae4c\uc9c0\uc758 \uacfc\uc81c\uc778 \uac1c\ubc1c \uc11c\ubc84\ub97c \uc5b4\ub5bb\uac8c \uad6c\uc131\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n\uc5ec\uae30\uc11c 1\ubc88\uc744 \uac00\uc7a5 \uba3c\uc800 \uc0dd\uac01\ud55c \uc544\ud0a4\ud14d\ucc98\ub97c \uad6c\uc131\ud558\uac8c \ub418\uc5c8\ub294\ub370, \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc120\ud0dd\uc758 \uae30\uc900\uc774 \ub418\uc5c8\ub358 \uac83\uc740 \ucd1d 3\uac00\uc9c0\uc600\uc2b5\ub2c8\ub2e4.\\n\\n1. DB\ub294 \ud504\ub77c\uc774\ube57 \uc11c\ube0c\ub137\uc5d0 \uc704\uce58\uc2dc\ud0a4\uace0, \uc6b0\ub9ac \uc778\uc2a4\ud134\uc2a4\ub97c \uac70\uccd0\uc11c\ub9cc \uc811\uadfc\uc774 \uac00\ub2a5\ud558\uac8c \ud55c\ub2e4.\\n - \uc774 \ubd80\ubd84\uc740 \ubcf4\uc548\uc744 \uc704\ud574\uc11c \uc5b4\uca54 \uc218 \uc5c6\uc774 \uc120\ud0dd\ud558\uac8c \ub41c \ubd80\ubd84\uc785\ub2c8\ub2e4.\uc774 \ubd80\ubd84\uc744 \uace0\ub824\ud558\ub2e4 \ubcf4\ub2c8, \ucd5c\uc18c\ud55c\uc73c\ub85c \uad6c\uc131\ud560 \uc218 \uc788\ub294 \uad6c\uc870\uac00 db \uc6a9 private \uc778\uc2a4\ud134\uc2a4 1\uac1c, \uadf8\ub9ac\uace0 \uc6b0\ub9ac\uac00 \uc0ac\uc6a9\ud560 public \uc778\uc2a4\ud134\uc2a4 1\uac1c\uac00 \ub429\ub2c8\ub2e4\\n2. \uc6b4\uc601 \uc11c\ubc84\ub97c \ub098\uc911\uc5d0 \ucd94\uac00\ud558\uac8c \ub418\uc5c8\uc744 \ub54c, \uc5b4\ub5bb\uac8c \uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ub418\ub294 \ubd80\ubd84\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n - \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0 CD \ud234\uc774\ub098, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud558\uac8c \ub418\uba74, \uc6b4\uc601 \uc11c\ubc84\uc5d0\ub3c4 \ub3d9\uc77c\ud558\uac8c \uc791\uc5c5\uc744 \ud574\uc57c \ud569\ub2c8\ub2e4.\\n - \uc774 \ubd80\ubd84\uc744 \ucd5c\uc18c\ud654\ud558\uae30 \uc704\ud574\uc11c, \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc640, CD \ud234, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub97c \ubd84\ub9ac\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n3. \uc5b4\ub5bb\uac8c \ud558\uba74 \uc7a5\uc560\uc758 \uc601\ud5a5\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n - \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc640, CD \ud234, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub97c \ubd84\ub9ac\ud558\uac8c \uc54a\ub294\ub2e4\uba74 \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c, CD \ud234\uacfc \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc5d0\ub3c4 \uc601\ud5a5\uc744 \ubbf8\uce58\uac8c \ub429\ub2c8\ub2e4. \uc774 \ubd80\ubd84\uc744 \uc0dd\uac01\ud588\uc744 \ub54c\ub3c4, \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc640, CD \ud234, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub97c \ubd84\ub9ac\ud574\uc57c \ud55c\ub2e4\uace0 \uacb0\uc815\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4\\n - \ud55c \ubd80\ubd84\uc758 \uc7a5\uc560\uac00 \ub2e4\ub978 \ud234\uae4c\uc9c0 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uac8c \ub9cc\ub4e4\uac8c \ub418\uc5b4\uc11c, \ub864\ubc31\uc774\ub098, \uc0c1\ud669 \ud30c\uc545\uc744 \ud558\uae30 \ud798\ub4e4\uac8c \ub9cc\ub4e4\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\ub4e4\uc744 \uc0dd\uac01\ud588\uc744 \ub54c, \uc778\uc2a4\ud134\uc2a4 1\uac1c\ub97c \uac1c\ubc1c \uc11c\ubc84\uc6a9\uc73c\ub85c, \uc778\uc2a4\ud134\uc2a4 1\uac1c\ub97c CD \ud234\uacfc \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub85c \uc0ac\uc6a9\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uc2e4\uc81c \ub0b4\ubd80 \uad6c\uc131\uc740 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694?\\n\\n### \uac1c\ubc1c \uc11c\ubc84\\n\\n\uc774 \uc778\uc2a4\ud134\uc2a4\uc5d0\ub294 \ucd1d 2\uac00\uc9c0 \uae30\ub2a5\uc774 \ub4e4\uc5b4\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. \ud504\ub860\ud2b8 \uc11c\ubc84\\n - react\ub85c \ub418\uc5b4\uc788\ub294 \ud504\ub860\ud2b8\uc5d4\ub4dc \ucf54\ub4dc\ub97c \uc0ac\uc6a9\uc790\uc5d0\uac8c \uc804\ub2ec\ud574 \uc8fc\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n2. \ubc31\uc5d4\ub4dc \uc11c\ubc84\\n - spring\uc73c\ub85c \ub418\uc5b4\uc788\ub294 api \uc11c\ubc84\uc785\ub2c8\ub2e4.\\n\\n\ubb3c\ub860, \uc774\ub807\uac8c \ud558\uba74 \ub450 \uacf3 \uc911 \ud55c \uacf3\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c, \ud504\ub860\ud2b8 \uc11c\ubc84\uc640 \ubc31\uc5d4\ub4dc \uc11c\ubc84\uac00 \ubaa8\ub450 \uc601\ud5a5\uc744 \ubc1b\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uac19\uc774 \uad00\ub9ac\ud558\uac8c \ub41c \uccab \ubc88\uc9f8 \uc774\uc720\ub85c \ube44\uc6a9\uc774 \ub4e4\uae30 \ub54c\ubb38\uc5d0 \ube44\uc6a9\uc758 \ubb38\uc81c\ub97c \uace0\ub824\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uac1c\ubc1c \uc11c\ubc84\uc5d0\uc11c \ud504\ub860\ud2b8 \uc11c\ubc84\uc640 \ubc31\uc5d4\ub4dc \uc11c\ubc84\ub97c \uad00\ub9ac\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub450 \ubc88\uc9f8 \uc774\uc720\ub85c\ub294, \uc544\uc9c1 \ud504\ub85c\uc81d\ud2b8 \ucd08\ucc3d\uae30 \uc774\uae30 \ub54c\ubb38\uc5d0, \ubc31\uc5d4\ub4dc\uc5d0\uc11c \uc7a5\uc560\uac00 \ub0ac\uc744 \ub54c, \ud504\ub860\ud2b8\uc5d0\uc11c \uc77c\uc815 \uc774\uc0c1\uc758 \uc5d0\ub7ec \ucc98\ub9ac\uac00 \ubd88\uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud504\ub85c\uc81d\ud2b8\uac00 \ub9ce\uc774 \uc9c4\ud589\ub418\uc5c8\ub2e4\uba74, \ud504\ub860\ud2b8\uc5d4\ub4dc\ub9cc\uc73c\ub85c \ud639\uc740 \uc7a5\uc560\uac00 \ub098\uc9c0 \uc54a\uc740 \uc11c\ubc84\ub97c \ud65c\uc6a9\ud574 \uc5d0\ub7ec \ucc98\ub9ac\ub97c \ud560 \uc218 \uc788\uc9c0\ub9cc, \uc544\uc9c1\uc740 \uadf8\ub7f0 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc640\ub294 \ubcc4\uac1c\ub85c \uc2e4\ud589 \uc2dc \ud3b8\uc758\ub97c \uc704\ud574\uc11c \ub3c4\ucee4\ub97c \uc0ac\uc6a9\ud574 \uac1c\ubc1c \uc11c\ubc84\ub97c \uad00\ub9ac\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### CD \ud234\uacfc \ubaa8\ub2c8\ud130\ub9c1 \ud234\\n\\n\uc774 \uc778\uc2a4\ud134\uc2a4\uc5d0\ub294 \ucd1d 3\uac00\uc9c0 \uae30\ub2a5\uc774 \ub4e4\uc5b4\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. CD \ud234\\n - \uc704\uc5d0\uc11c \uc124\uba85\ub4dc\ub9b0 \uac83\ucc98\ub7fc, self hosted runner \uac00 \ub3d9\uc791\ud558\uac8c \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4\\n2. \ubcf4\uc548\uc744 \uc704\ud55c \ub9ac\ubc84\uc2a4 \ud504\ub85d\uc2dc\\n - \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \uc0ac\uc6a9\ud558\uac8c \ub418\ub294\ub370, \uc774\ub54c API \ud0a4\ub97c \uc0ac\uc6a9\ud558\uac8c \ub429\ub2c8\ub2e4. \uc774\ub807\uac8c \ud558\uba74, API \ud0a4\ub97c \ub178\ucd9c\uc2dc\ud0a4\uc9c0 \uc54a\uace0, \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n - \uc774 API \ud0a4\ub97c \ub178\ucd9c\uc2dc\ud0a4\uc9c0 \uc54a\uae30 \uc704\ud574\uc11c, \ub9ac\ubc84\uc2a4 \ud504\ub85d\uc2dc\ub97c \ud558\ub098 \ub450\uace0, \uc5ec\uae30\uc11c API \ud0a4\ub97c \ucd94\uac00\ud574 \uc694\uccad\uc744 \ubcf4\ub0b4\ub294 \ubc29\uc2dd\uc73c\ub85c \uad6c\uc131\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n3. \ubaa8\ub2c8\ud130\ub9c1 \ud234\\n - \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \uc544\uc9c1 \ub3c4\uc785\ud558\uc9c0 \uc54a\uc558\uc9c0\ub9cc, \ud604\uc7ac \uc774\uc288\ub85c\ub294 \uc62c\ub77c\uac00 \uc788\ub294 \uc0c1\ud0dc\uc785\ub2c8\ub2e4.\\n - Actuator, \ud504\ub85c\uba54\ud14c\uc6b0\uc2a4, \uadf8\ub77c\ud30c\ub098 \uc774 3\uac00\uc9c0\ub97c \ud65c\uc6a9\ud574\uc11c \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uad6c\uc131\ud558\uac8c \ub420 \uc608\uc815\uc785\ub2c8\ub2e4\\n\\n\uc704 \uae30\ub2a5\ub4e4\uc774 \ud55c \uc778\uc2a4\ud134\uc2a4\uc5d0 \ubaa8\uc5ec\uc788\uae30\uc5d0, \uc704\uc758 \uae30\ub2a5\ub4e4\uc740 \ucd94\ud6c4\uc5d0 \uc6b4\uc601 \uc11c\ubc84\uac00 \ucd94\uac00\ub418\uc5c8\uc744 \ub54c, \uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ud558\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.\\n\\n## \ubc30\ud3ec \uacfc\uc815 \ub354 \uc790\uc138\ud788 \uc54c\uc544\ubcf4\uae30\\n\\n\uc544\ub798\uc5d0 \uc0ac\uc9c4\uc5d0\uc11c \ubcf4\uc774\ub294 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c \ubc30\ud3ec\ub97c \uc9c4\ud589\ud558\uace0 \uc788\ub294\ub370\uc694\\n\\n![server image](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdto7By%2FbtsnD31hYHy%2F7rWKwxulxXzfhRigE60Sd0%2Fimg.png)\\n\\n1. \uc0ac\uc6a9\uc790\uac00 push\ub97c \ud558\uba74, github actions\uc5d0\uc11c \ub3c4\ucee4 \ube4c\ub4dc\ub97c \uc9c4\ud589\ud558\uace0, \ub3c4\ucee4 \ud5c8\ube0c\uc5d0 \uc774\ubbf8\uc9c0\ub97c \uc62c\ub9bd\ub2c8\ub2e4.\\n2. \ub3c4\ucee4 \ud5c8\ube0c\uc5d0 \uc774\ubbf8\uc9c0\uac00 \uc62c\ub77c\uac04 \uc774\ud6c4\uc5d0, self hosted runner \uac00 \uc791\ub3d9\uc744 \uc2dc\uc791\ud569\ub2c8\ub2e4.\\n3. \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uadfc\ud574\uc11c, \uc774\ubbf8\uc9c0\ub97c \ubc1b\uace0, \ucee8\ud14c\uc774\ub108\ub97c \uc2e4\ud589\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c, \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0 \ubc30\ud3ec\ub97c \uc9c4\ud589\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \ub290\ub080 \uc810\\n\\n\uc88b\uc740 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uacc4\ud558\uae30 \uc704\ud574\uc11c\ub294 \uace0\ub824\ud574\uc57c \ud560 \uc810\ub4e4\uc774 \uc815\ub9d0 \ub9ce\ub2e4\ub294 \uac83\uc744 \ub2e4\uc2dc \ud55c\ubc88 \ub290\uaf08\uc2b5\ub2c8\ub2e4.\\n\\n\uc6b4\uc601 \uc11c\ubc84\uac00 \ucd94\uac00\ub41c\ub2e4\ub358\uac00, \uc778\uc2a4\ud134\uc2a4\uac00 \ub298\uc5b4\ub098\uace0, \uc904\uc5b4\ub4dc\ub294 \uc0c1\ud669\uc5d0 \uc720\uc5f0\ud558\uac8c \ub300\ucc98\ud560 \uc218 \uc788\ub3c4\ub85d \uc124\uacc4\ub97c \ud574\uc57c \ud55c\ub2e4\ub294 \uac83\uc744 \ub2e4\uc2dc \ud55c\ubc88 \ub290\uaf08\uc2b5\ub2c8\ub2e4.\\n\\n\uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ub420 \ud3ec\uc778\ud2b8\ub97c \uc904\uc5ec\uc57c \ud55c\ub2e4\ub294 \uac83\ub3c4 \ub2e4\uc2dc \ud55c\ubc88 \ub290\ub084 \uc218 \uc788\uc5c8\uace0\uc694\\n\\n\uae34 \uae00\uc744 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4"},{"id":"13","metadata":{"permalink":"/13","source":"@site/blog/2023-07-14-trouble-shooting-with-info-window.mdx","title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","description":"Untitled","date":"2023-07-14T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 14\uc77c","tags":[{"label":"react","permalink":"/tags/react"},{"label":"google maps api","permalink":"/tags/google-maps-api"},{"label":"useSyncExternalStore","permalink":"/tags/use-sync-external-store"}],"readingTime":17.7,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"13","title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","authors":["scent"],"tags":["react","google maps api","useSyncExternalStore"]},"prevItem":{"title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","permalink":"/14"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","permalink":"/11"}},"content":"![Untitled](https://file.notion.so/f/s/16a32751-2088-4261-8bf6-3d556c0bf2e8/Untitled.png?id=020fb0e2-81d8-4dca-bb76-cf4536ca7b29&table=block&spaceId=e725e94b-8029-47f5-aecb-8eb1ef7c939f&expirationTimestamp=1689364800000&signature=3KH3gvfzTgKmmFsrNBluQ3evQ6jwe2C-tj8LqB6gQyw&downloadName=Untitled.png)\\n\\n\uc704 \uc774\ubbf8\uc9c0\ub294 \ud604\uc7ac\uae4c\uc9c0 \uad6c\ud604\ud55c \uc9c0\ub3c4\uc758 \ubaa8\uc2b5\uc774\ub2e4. \uad6c\ud604\ub41c \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc11c\ubc84\uc5d0 \uc694\uccad\ud574 \ubc1b\uc544\uc628 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \ubc14\ud0d5\uc73c\ub85c \ud654\uba74\uc5d0 \ub9c8\ucee4\ub97c \ud45c\uc2dc\ud558\ub294 \uae30\ub2a5\\n- \ud654\uba74\uc774 \uc774\ub3d9\ud558\uac70\ub098 \uc90c\uc778, \uc90c \uc544\uc6c3\uc744 \ud560 \uc2dc \ud654\uba74\uc758 \ub9c8\ucee4 \uc815\ubcf4\uac00 \ucd5c\uc2e0\ud654 \ub418\ub294 \uae30\ub2a5\\n- \ub9c8\ucee4 \uc815\ubcf4\ub97c \ucd5c\uc2e0\ud654 \ud560 \ub54c \ud654\uba74\uc5d0\uc11c \uc0ac\ub77c\uc9c4 \ub9c8\ucee4\ub97c dom\uc5d0\uc11c \uc81c\uac70\ud558\ub294 \uae30\ub2a5\\n- \ub9c8\ucee4 \uc815\ubcf4\ub97c \ucd5c\uc2e0\ud654 \ud560 \ub54c \uc774\uc804 \ud654\uba74\uc5d0\uc11c\ub3c4 \uc788\uc5c8\ub358 \ub9c8\ucee4\ub97c \uc7ac\uc0dd\uc131 \ud558\uc9c0 \uc54a\ub294 \uae30\ub2a5\\n- \ub9c8\ucee4\ub97c \ud074\ub9ad\ud588\uc744 \uc2dc \ud574\ub2f9 \ub9c8\ucee4\uc5d0 \ub300\ud55c \uac04\ub2e8 \uc815\ubcf4\ub97c \ubaa8\ub2ec\ub85c \ub744\uc6cc\uc8fc\ub294 \uae30\ub2a5\\n- \ud654\uba74\uc5d0 \ud45c\uc2dc\ub41c \ub9c8\ucee4\ub4e4\uc5d0 \ub300\ud55c \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \ub9ac\uc2a4\ud2b8\ub85c \ubcf4\uc5ec\uc8fc\ub294 \uae30\ub2a5\\n\\n\uc774\ubc88\uc5d0 \uc0c8\ub85c \ucd94\uac00\ud558\uace0\uc790 \ud55c \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- \ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8\uc5d0\uc11c \ucda9\uc804\uc18c\ub97c \uc120\ud0dd\ud558\uba74 \ud654\uba74\uc758 \uc911\uc2ec\uc774 \uc120\ud0dd\ud55c \ucda9\uc804\uc18c \ub9c8\ucee4\ub85c \uc774\ub3d9\ud558\uace0, \ucda9\uc804\uc18c\uc758 \uac04\ub2e8 \uc815\ubcf4\ub97c \ubaa8\ub2ec\ub85c \ub744\uc6cc\uc8fc\ub294 \uae30\ub2a5\\n\\n\uc704 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 google maps api\uc758 InfoWindow\uac1d\uccb4\ub97c \uc774\uc6a9\ud574\uc57c \ud55c\ub2e4. \uc0ac\uc6a9 \ubc29\uc2dd\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n```jsx\\nconst infowindow = new google.maps.InfoWindow({\\n content: contentString,\\n ariaLabel: \'Uluru\',\\n});\\n\\nconst marker = new google.maps.Marker({\\n position: uluru,\\n map,\\n title: \'Uluru (Ayers Rock)\',\\n});\\n\\ninfowindow.open({\\n anchor: marker,\\n map,\\n});\\n```\\n\\n\uac04\ub2e8\ud558\uac8c \uc694\uc57d\ud558\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- `InfoWindow` \uc0dd\uc131\uc790 \ud568\uc218\ub97c \ud1b5\ud574 `infoWindow` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n - \uc0dd\uc131\uc2dc dom \uc694\uc18c \ud639\uc740 string\uc744 \uc804\ub2ec\ud574 `infoWindow`\uac00 \uc0dd\uc131\ub420 dom\uc704\uce58\ub97c \uc9c0\uc815\ud574\uc900\ub2e4.\\n- `marker` \uc778\uc2a4\ud134\uc2a4\ub97c `infoWindow` \uc778\uc2a4\ud134\uc2a4\uc758 `open` \uba54\uc11c\ub4dc\uc5d0 \uc778\uc790\ub85c \uc804\ub2ec\ud55c\ub2e4.\\n- `infoWindow` \uc0dd\uc131 \uc2dc \uc804\ub2ec\ud588\ub358 dom\uc694\uc18c\uc758 \uc704\uce58\uac00 `marker`\uc758 \uc704\uce58\ub85c \uace0\uc815\ub418\uba74\uc11c \ud654\uba74\uc5d0 \uadf8\ub824\uc9c4\ub2e4.\\n\\n---\\n\\n![Untitled](https://file.notion.so/f/s/3079d7b9-8226-46b1-9482-054d1ea78016/Untitled.png?id=bce7685b-8a95-429c-bb75-98a4402cfc17&table=block&spaceId=e725e94b-8029-47f5-aecb-8eb1ef7c939f&expirationTimestamp=1689364800000&signature=jKnY-AhoxwqTiWrMi66uUtIamSOZDj8GGBTzgKeu_qY&downloadName=Untitled.png)\\n\\n\ucda9\uc804\uc18c \uc815\ubcf4\ub97c \ubcf4\uc5ec\uc8fc\ub294 \uc704 `StationList` \ucef4\ud3ec\ub10c\ud2b8\ub294 \ucda9\uc804\uc18c \uc815\ubcf4\uc5d0 \uc811\uadfc\ud560 \ub54c react-query\ub97c \ud1b5\ud574 \uc11c\ubc84 \uc0c1\ud0dc\ub97c \uc9c1\uc811 \ub0b4\ub824 \ubc1b\uc544 \ucef4\ud3ec\ub10c\ud2b8 \ub0b4\ubd80 \ub9ac\uc2a4\ud2b8\ub97c \ub80c\ub354\ub9c1 \ud55c\ub2e4.\\n\\n\ub610\ud55c, `StationMarkersContainer`\uc5d0\uc11c\ub3c4 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c react-query\uc758 \uc11c\ubc84 \uc0c1\ud0dc\uc5d0\uc11c \ucc38\uc870\ud574 \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud558\uace0 \uc788\ub2e4.\\n\\n\ub530\ub77c\uc11c `StationList` \ucef4\ud3ec\ub10c\ud2b8\uc640 `StationMarkersContainer`\ub294 \uac01\uac01 \ub530\ub85c \uc11c\ubc84 \uc0c1\ud0dc\uc5d0 \uc811\uadfc\ud574 \ub80c\ub354\ub9c1\uc744 \uc218\ud589\ud558\uace0 \uc788\uc73c\ubbc0\ub85c \ub458 \uc0ac\uc774\uc5d0\ub294 \uc5b4\ub5a0\ud55c \uc5f0\uacb0 \uace0\ub9ac\uac00 \uc5c6\ub2e4.\\n\\n\uc5ec\uae30\uc11c \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n---\\n\\n\ud604\uc7ac\uae4c\uc9c0\uc758 \ucf54\ub4dc\uc5d0\uc11c\ub294 `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c `StationMarkersContainer`\ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc0dd\uc131\ud55c\ub2e4. \uc774\ub97c \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc778 `StationMarker`\uc5d0 \ub0b4\ub824\uc8fc\uace0, \uc774 \ucef4\ud3ec\ub10c\ud2b8 \ub0b4\ubd80\uc5d0\uc11c `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n\\n\uc774\ubc88\uc5d0 \uad6c\ud604\ud558\uae30\ub85c \ud55c \uae30\ub2a5\uc740 `StationList`\uc758 \ud56d\ubaa9 \uc911 \ud558\ub098\ub97c \uc120\ud0dd\ud588\uc744 \uc2dc \uc120\ud0dd\ub41c \ucda9\uc804\uc18c\uc5d0 \ud574\ub2f9\ud558\ub294 \ub9c8\ucee4\uc5d0 \uac04\ub2e8 \uc815\ubcf4 \ubaa8\ub2ec\uc774 \ub728\uba70 \ud654\uba74\uc744 \ud574\ub2f9 \ub9c8\ucee4\uac00 \uc911\uc2ec\uc73c\ub85c \uc624\ub3c4\ub85d \uc774\ub3d9 \uc2dc\ud0a4\ub294 \uac83\uc774\uc5c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc9c0\uae08\uc758 \ucf54\ub4dc \uad6c\uc870\uc0c1 `StationList`\uc640 `StationMarkersContainer`\uc0ac\uc774\uc5d0\ub294 \uc5b4\ub5a0\ud55c \uc5f0\uacb0 \uace0\ub9ac\ub3c4 \uc5c6\uc73c\ubbc0\ub85c `infoWindow`\uc640 `marker`\uc5d0 `StationList`\ub294 \uc811\uadfc\ud560 \uc218 \uc5c6\ub294 \uc0c1\ud0dc\uac00 \ub41c\ub2e4.\\n\\n\uc774\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\ub2e4.\\n\\n- `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c root \ub2e8\uc5d0\uc11c \uc0dd\uc131\ud574 \uc804\uc5ed\uc801\uc73c\ub85c \uad00\ub9ac\ud55c\ub2e4.\\n- \uc0dd\uc131\ub420 `marker` \uc778\uc2a4\ud134\uc2a4\ub4e4\uc744 \ubc30\uc5f4 \ud615\ud0dc\uc758 \uc804\uc5ed \uc0c1\ud0dc\ub85c \uad00\ub9ac\ud55c\ub2e4.\\n\\n\uc704 \ub0b4\uc6a9\uc744 \ub9d0\ub85c\ub9cc \ubcf8\ub2e4\uba74 \ubcc4\ub85c \uc5b4\ub824\uc6b8 \uac83 \uc5c6\uc5b4 \ubcf4\uc774\uc9c0\ub9cc \uc2e4\uc81c \uad6c\ud604\uc744 \uc9c4\ud589\ud574\ubcf4\ub2c8 \ub0b4\ubd80\uc801\uc73c\ub85c \ud070 \ubb38\uc81c\uac00 \ub450 \uac00\uc9c0 \uc874\uc7ac\ud588\ub2e4.\\n\\n1. \ub530\ub85c \ubaa8\ub4c8\uc744 \ubd84\ub9ac\ud574 `infoWindow`\ub97c \uc0dd\uc131\ud560 \uc218 \uc5c6\ub2e4.\\n2. `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uac00 `StationMarkersContainer`\uac00 \ub418\uc5b4\uc11c\ub294 \uc548\ub41c\ub2e4.\\n\\n\uac01\uac01\uc758 \ubb38\uc81c\uc810\uc744 \uc0b4\ud3b4\ubcf4\uc790.\\n\\n---\\n\\n### 1. \ub530\ub85c \ubaa8\ub4c8\uc744 \ubd84\ub9ac\ud574 `infoWindow`\ub97c \uc0dd\uc131\ud560 \uc218 \uc5c6\ub2e4.\\n\\n`infoWinodw`\ub97c \uc804\uc5ed \uc0c1\ud0dc\ub85c \ub9cc\ub4e4\uc5b4 \uc0ac\uc6a9\ud558\uae30 \uc704\ud574 \ucc98\uc74c\uc73c\ub85c \ud588\ub358 \uc0dd\uac01\uc740 `infoWindowStore.ts`\ub85c \ubaa8\ub4c8\uc744 \ubd84\ub9ac\ud558\uc5ec `infoWindow`\ub97c \uc0dd\uc131\ud574 store\uc758 \ucd08\uae30\uac12\uc73c\ub85c \uc9c0\uc815\ud558\ub294 \uac83\uc774\uc5c8\ub2e4.\\n\\n\uc704 \uc0dd\uac01\uc744 \uac00\uc9c0\uace0 \uadf8\ub300\ub85c \uad6c\ud604\ud574\ubcf4\uc558\ub354\ub2c8 `google`\uc744 \ucc38\uc870\ud560 \uc218 \uc5c6\ub2e4\ub294 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\ub2e4. `InfoWindow`\uc0dd\uc131\uc790 \ud568\uc218\ub294 `google.maps.InfoWindow`\ub97c \ud1b5\ud574 \uc811\uadfc\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \uc5d0\ub7ec\ub294 `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud560 \uc218 \uc5c6\ub2e4\ub294 \uac83\uc744 \uc758\ubbf8\ud588\ub2e4.\\n\\n\uc65c `google`\uc744 \ucc38\uc870\ud560 \uc218 \uc5c6\ub294\uc9c0 \uc774\uc720\ub97c \ubd84\uc11d\ud574\ubcf4\ub2c8 \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc558\ub2e4.\\n\\n\uc6b0\ub9ac \ud300\uc774 \uad6c\uae00 \uc9c0\ub3c4 \ub85c\ub4dc\ub97c \uc704\ud574 \uc120\ud0dd\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 `@googlemaps/react-wrapper`\uc774\ub2e4. \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 \ub3d9\uc791\uc744 \uc0b4\ud3b4\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- `Wrapper`\ucef4\ud3ec\ub10c\ud2b8\uac00 `@googlemaps/js-loader`\ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 `Loader`\uc0dd\uc131\uc790 \ud568\uc218\ub97c \ud638\ucd9c\ud55c\ub2e4.\\n- \uc0dd\uc131\ub41c `loader`\uc778\uc2a4\ud134\uc2a4\uc758 `load`\uba54\uc11c\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c \uc9c0\ub3c4\uc758 \ub85c\ub529 \uc791\uc5c5\uc744 \uc2dc\uc791\ud55c\ub2e4.\\n - `load` \uba54\uc11c\ub4dc\ub294 \ucd5c\uc885\uc801\uc73c\ub85c `Promise`\uc744 \ubc18\ud658\ud558\ub294\ub370, \uc9c0\ub3c4 \ub85c\ub4dc\uc5d0 \uc131\uacf5\ud558\uba74 `resolve(window.google)` \uc744 \uc2e4\ud589\uc2dc\ucf1c `google`\uc744 \uc804\uc5ed\uc801\uc73c\ub85c \uc0ac\uc6a9 \uac00\ub2a5\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5b4\uc900\ub2e4.\\n- \uc9c0\ub3c4\uc758 \ub85c\ub529\uc774 \uc644\ub8cc\ub418\uba74 `Wrapper`\uc758 `render` props\ub97c \ud1b5\ud574 \ubc1b\uc740 \ucf5c\ubc31 \ud568\uc218\ub97c \uc2e4\ud589\uc2dc\ud0a8\ub2e4.\\n - `render`\ucf5c\ubc31 \ud568\uc218\ub294 \ub85c\ub529 \uc0c1\ud0dc\ub97c \ub098\ud0c0\ub0b4\ub294 Status\ub97c \ud30c\ub77c\ubbf8\ud130\ub85c \ub118\uaca8 \ubc1b\uc544 \ud638\ucd9c\ub41c\ub2e4.\\n\\n\ucd5c\uc885\uc801\uc73c\ub85c `render`\ub97c \uc2e4\ud589 \uc2dc\ucf30\uc744 \ub54c \ubc18\ud658 \ub418\ub294 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c\ub294 `google` \ub85c\ub529 \ub418\uc5b4 \uc804\uc5ed\uc801\uc73c\ub85c \uc811\uadfc\uc774 \uac00\ub2a5\ud568\uc744 \ubcf4\uc7a5\ud560 \uc218 \uc788\uc73c\ubbc0\ub85c \uc774\ub54c\ubd80\ud130 `google`\uc5d0 \uc811\uadfc\uc774 \uac00\ub2a5\ud574\uc9c4\ub2e4. \u2192 \ub530\ub77c\uc11c `Wrapper`\ub97c \ud1b5\ud574 \ubc18\ud658\ub418\ub294 \ucef4\ud3ec\ub10c\ud2b8\uc758 \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c `google.maps.Map`\uc0dd\uc131\uc790 \ud568\uc218\ub97c \uc0ac\uc6a9\ud574 \uc9c0\ub3c4\ub97c \uc0dd\uc131\ud560 \uc218 \uc788\uac8c \ub41c\ub2e4.\\n\\n`infoWindow`\ub97c \uc0dd\uc131\ud558\uae30 \uc704\ud574 \ub9cc\ub4e0 \uc0c8\ub85c\uc6b4 \ubaa8\ub4c8\uc740 \uccab `import`\uc2dc\uae30\uc5d0 \ud3c9\uac00\ub420 \uac83\uc774\uae30 \ub54c\ubb38\uc5d0 `Wrapper`\uc758 \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c `import`\ub97c \uc218\ud589\ud55c\ub2e4\uba74 \ub85c\ub4dc\uac00 \uc644\ub8cc\ub41c \uc774\ud6c4 \uc2dc\uc810\uc77c \uac83\uc774\ubbc0\ub85c `window.google`\uc774 \ub4f1\ub85d\ub418\uc5b4 `google`\uc5d0 \uc811\uadfc\uc774 \uac00\ub2a5\ud560 \uac83\uc73c\ub85c \uc608\uc0c1\ud588\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc6f9\ud329\uc744 \ud1b5\ud55c \ubc88\ub4e4\ub9c1 \uacfc\uc815\uc5d0\uc11c \ubaa8\ub4c8\uc774 \ub4a4\uc11e\uc5ec \ud30c\uc77c\uc758 \ud3c9\uac00 \uc2dc\uae30\ub97c \ubcf4\uc7a5\ud560 \uc218 \uc5c6\uc5b4\uc838 \uc0c8\ub85c \ub9cc\ub4e0 \ubaa8\ub4c8\uc5d0\uc11c\ub294 `google`\uc5d0 \ub300\ud55c \uc811\uadfc\uc774 \ubd88\uac00\ub2a5\ud574\uc9c0\uac8c \ub418\uc5c8\ub2e4. \uc6f9\ud329\uc744 \uc880 \ub354 \uacf5\ubd80\ud574\ubcf8\ub2e4\uba74 \uc774 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc744 \uac83 \uac19\uc558\uc9c0\ub9cc, \ub108\ubb34 \uc9c0\uc5fd\uc801\uc778 \ubd80\ubd84\uc5d0\uc11c \ub9ce\uc740 \uc2dc\uac04\uc744 \ub4e4\uc774\uae30 \ubcf4\ub2e8 \uae30\uc874\uc5d0 \uac1c\ubc1c\ud558\ub358 \ubc29\uc2dd\uc744 \ud1b5\ud574 \ubb38\uc81c\ub97c \ud574\uacb0\ud574\ubcf4\uae30\ub85c \uacb0\uc815\ud588\ub2e4.\\n\\n\ucd5c\uc885\uc801\uc73c\ub85c \ubb38\uc81c\ub97c \ud574\uacb0\ud55c \ubc29\uc2dd\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- `InfoWindow`\uc0dd\uc131\uc790 \ud568\uc218\ub97c \ud638\ucd9c\ud560 `CarFfeineInfoWindowInitializer`\ucef4\ud3ec\ub10c\ud2b8\ub97c \ub9cc\ub4e0\ub2e4.\\n- `Wrapper`\ub85c \uac10\uc2f8\uc9c4 \ucef4\ud3ec\ub10c\ud2b8 \ud558\uc704\uc5d0 `CarFfeineInfoWindowInitializer` \ucef4\ud3ec\ub10c\ud2b8\ub97c \ucd94\uac00\ud55c\ub2e4.\\n- `google`\uc5d0 \uc811\uadfc\uc774 \uac00\ub2a5\ud55c \uc0c1\ud0dc\ub97c \ubcf4\uc7a5\ubc1b\uc740 `CarFfeineInfoWindowInitializer`\ub0b4\ubd80\uc5d0\uc11c `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n- `store`\uc5d0 `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c `set`\ud574\uc8fc\uc5b4 \uc804\uc5ed\uc801\uc73c\ub85c `infoWindow`\ub97c \uc0ac\uc6a9 \uac00\ub2a5\ud558\ub3c4\ub85d \ud55c\ub2e4.\\n\\n---\\n\\n### 2. `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uac00 `StationMarkersContainer`\uac00 \ub418\uc5b4\uc11c\ub294 \uc548\ub41c\ub2e4.\\n\\n\uc774\ubc88 \ud300 \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \uc9c0\ub3c4\ub97c \uad6c\ud604\ud558\uae30 \uc704\ud574 google maps api\ub97c \uc0ac\uc6a9\ud558\uac8c \ub418\uc5c8\ub2e4. \ub72c\uae08\uc5c6\uc774 \uc774 \uc774\uc57c\uae30\ub97c \ud55c \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- google maps api\ub294 \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8\ub97c \uae30\ubc18\uc73c\ub85c \ub3d9\uc791\ud55c\ub2e4.\\n- \uc774\ubc88 \ud300 \ud504\ub85c\uc81d\ud2b8\ub294 \ub9ac\uc561\ud2b8\ub97c \uae30\ubc18\uc73c\ub85c \uac1c\ubc1c\uc744 \uc9c4\ud589\ud560 \uac83\uc774\ub2e4.\\n- \uc9c0\ub3c4\ub97c \uadf8\ub9ac\uae30 \uc704\ud574\uc11c \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8\uc640 \ub9ac\uc561\ud2b8\uc758 \uc801\uc808\ud55c \uc870\ud654\uac00 \ud544\uc694\ud558\ub2e4.\\n- \ub2e4\uc18c \ud63c\ub780\uc2a4\ub7ec\uc6b8 \uc218 \uc788\ub294 \uc9c0\ub3c4\uc758 \uc870\uc791 \ubc29\uc2dd\uc744 \ub9ac\uc561\ud2b8\uc640 \uc870\ud654\ub86d\uac8c \uc0ac\uc6a9\ud558\uae30 \uc704\ud574\uc11c \ucef4\ud3ec\ub10c\ud2b8 \uc124\uacc4\uc2dc \ucef4\ud3ec\ub10c\ud2b8\uc758 \ucc45\uc784\uc744 \ud655\uc2e4\ud558\uac8c \uad6c\ubd84\ud574\uc57c\uaca0\ub2e4\ub294 \uc0dd\uac01\uc744 \ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uc774 \ucef4\ud3ec\ub10c\ud2b8\uc758 \ucc45\uc784\uc5d0 \ub300\ud55c \ubb38\uc81c\ub85c \uc778\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uc5d0 \ub300\ud574 \ub9ce\uc740 \uace0\ubbfc\uc744 \ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uc77c\ub2e8 \uc6d0\ub798 \ucf54\ub4dc \uad6c\uc870\uc5d0\uc11c \ub9c8\ucee4\ub97c \uadf8\ub9ac\uae30 \uc704\ud574 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ub2e4\uc74c\uacfc \uac19\uc774 \ucd94\uc0c1\ud654 \ud588\ub2e4.\\n\\n- `StationMarkersContainer` \ucef4\ud3ec\ub10c\ud2b8\\n - \ub9ac\uc561\ud2b8 \ucffc\ub9ac\ub97c \ud1b5\ud574 \ubc1b\uc544\uc628 \uc11c\ubc84 \uc0c1\ud0dc(\ucda9\uc804\uc18c \uc815\ubcf4 \ubc30\uc5f4)\ub85c `StationMarker`\ub97c \ud638\ucd9c\ud55c\ub2e4.\\n- `StationMarker` \ucef4\ud3ec\ub10c\ud2b8\\n - \uc0c1\uc704\uc5d0\uc11c \ub0b4\ub824\ubc1b\uc740 \ucda9\uc804\uc18c \uc815\ubcf4 props\ub97c \ud1b5\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4. (google maps api\uc5d0\uc11c\ub294 \uc778\uc2a4\ud134\uc2a4 \uc0dd\uc131\uc774 \uace7 \ub80c\ub354\ub9c1\uc744 \uc758\ubbf8\ud55c\ub2e4)\\n - \uc0dd\uc131\ud55c `marker` \uc778\uc2a4\ud134\uc2a4\uc5d0 `infoWindow` \uc778\uc2a4\ud134\uc2a4\uc758 `open` \uba54\uc11c\ub4dc\ub97c \ud2b8\ub9ac\uac70 \ud558\ub294 \ud074\ub9ad \uc774\ubca4\ud2b8 \ub9ac\uc2a4\ub108\ub97c \ucd94\uac00\ud574\uc900\ub2e4.\\n - `useEffect`\uc758 \ud074\ub9b0\uc5c5 \ud568\uc218\ub97c \uc774\uc6a9\ud574 \ucda9\uc804\uc18c \uc815\ubcf4\uac00 \ucd5c\uc2e0\ud654 \ub418\uc5c8\uc744 \ub54c \ub9c8\ucee4\uac00 \ub354\uc774\uc0c1 \ud654\uba74\uc5d0 \ubcf4\uc774\uc9c0 \uc54a\ub294\ub2e4\uba74 `marker` \uc778\uc2a4\ud134\uc2a4\uc758 `setMap(null)` \uba54\uc11c\ub4dc\ub97c \ud638\ucd9c\ud574 google maps api\uc5d0\uc11c \ub9c8\ucee4\ub97c \uc9c0\uc6b0\ub3c4\ub85d \ud55c\ub2e4. (\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654)\\n\\n\uac04\ub7b5\ud788 \uc124\uba85\ud558\uc790\uba74 `StationMarkersContainer` \ucef4\ud3ec\ub10c\ud2b8\ub294 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc11c\ubc84\uc5d0\uc11c \ubc1b\uc544 `StationMarker`\ub97c \ud638\ucd9c\ud558\ub294 \uc5ed\ud560\ub9cc\uc744 \uc218\ud589\ud558\uace0, \ub9c8\ucee4\uc5d0 \ub300\ud55c \ubaa8\ub4e0 \uc138\ubd80 \ub85c\uc9c1\uc740 `StationMarker`\uac00 \uc218\ud589\ud558\ub3c4\ub85d \ucef4\ud3ec\ub10c\ud2b8\ub97c \ucd94\uc0c1\ud654 \ud574\ubcf4\uc558\ub2e4.\\n\\n\uc774\ub984\uc5d0\uc11c\ub3c4 \ub4dc\ub7ec\ub098\ub4ef `StationMarker` \ucef4\ud3ec\ub10c\ud2b8\uac00 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uac00 \ub418\uc5b4\uc57c \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8\uc640 \ub9ac\uc561\ud2b8\uc758 \ud63c\uc885\uc778 \uc774 \ud504\ub85c\uc81d\ud2b8\uc758 \ucf54\ub4dc\ub97c \ucd94\ud6c4 \uc720\uc9c0\ubcf4\uc218 \ud560 \ub54c \ubb38\uc81c\uac00 \uc5c6\uc73c\ub9ac\ub77c \ud310\ub2e8\ud588\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ub807\uac8c \ucd94\uc0c1\ud654 \ub41c \ucef4\ud3ec\ub10c\ud2b8\ub4e4\uc740 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \ubc30\uc5f4 \ud615\uc2dd\uc758 \uc804\uc5ed \uc0c1\ud0dc\uc5d0 \ub2f4\uc544 \uad00\ub9ac\ud558\uace0\uc790 \ud560 \ub54c \ubb38\uc81c\uac00 \ub418\uc5c8\ub2e4.\\n\\n---\\n\\n\uc77c\ub2e8 \uba3c\uc800 \uc11c\ubc84\uc5d0\uc11c \ub0b4\ub824 \ubc1b\uc740 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c `station`\uc774\ub77c\uace0 \ud558\uc790, \uc6b0\ub9ac\ub294 \uc774 `station`\uc744 \ud1b5\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\uace0\uc790 \ud55c\ub2e4.\\n\\n\uc774\ub54c \uc0dd\uac01 \ud560 \uc218 \uc788\ub294 \uac00\uc7a5 \uac04\ub2e8\ud55c \ubc29\ubc95\uc740 `station`\uc5d0\uc11c `map` \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\uc5ec \uc774 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc778 `StationMarker`\uc5d0 \ub118\uaca8\uc8fc\ub294 \ubc29\uc2dd\uc77c \uac83\uc774\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774 \ubc29\uc2dd\uc740 \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uac83\uc774 \uace7 \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1\uc744 \ubc1c\uc0dd\uc2dc\ud0a4\ub294 \uac83\uc744 \uc758\ubbf8\ud558\ub294 google maps api\uc758 \ud2b9\uc131\uc0c1 \uc6b0\ub9ac\uac00 \ucc98\uc74c \uc124\uacc4\ud55c \ucef4\ud3ec\ub10c\ud2b8\uc758 \ucc45\uc784\uc744 \ubc18\ud558\ub294 \uad6c\uc870\ub97c \ub9cc\ub4e4\uc5b4\ub0b4\uac8c \ub41c\ub2e4.\\n\\n\uc790\uc138\ud788 \uc124\uba85\ud574\ubcf4\uc790\uba74 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1\uc740 `StationMarkersContainer`\uac00 \uc218\ud589\ud558\uace0 \uc788\ub294\ub370 \ud654\uba74\uc5d0 \ubcf4\uc774\uc9c0 \uc54a\ub294 \ub9c8\ucee4\ub97c \uc9c0\uc6b0\ub294 \uc5ed\ud560\uc740 `StationMarker`\ucef4\ud3ec\ub10c\ud2b8\uac00 \uc218\ud589\ud558\uace0 \uc788\uace0, \uc774\ubca4\ud2b8 \ud578\ub4e4\ub7ec\uc758 \ucd94\uac00 \uc5ed\uc2dc \ub9c8\ucee4\uac00 \uc0dd\uc131\ub41c \uc774\ud6c4\uc5d0 \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc774\ub97c \uc218\ud589\ud558\ub294 \uad34\uc0c1\ud55c \ucf54\ub4dc\uac00 \ub9cc\ub4e4\uc5b4\uc9c0\uac8c \ub41c\ub2e4.\\n\\n\ucd94\ud6c4 \ucf54\ub4dc\uc758 \uc720\uc9c0\ubcf4\uc218\uc131\uc744 \uc704\ud574\uc120 \ud53c\ud574\uc57c \ud560 \ubc29\uc2dd\uc784\uc774 \uba85\ud655\ud588\ub2e4.\\n\\n\ud574\uacb0 \ubc29\uc2dd\uc744 \uace0\ubbfc\ud574\ubcf4\ub2e4\uac00 \ub2e4\uc74c\uacfc \uac19\uc740 \ud574\uacb0 \ubc29\uc548\uc744 \uc0dd\uac01\ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n`StationMarker` \ucef4\ud3ec\ub10c\ud2b8\uc758 \uc5ed\ud560\\n\\n- `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n- `marker` \uc778\uc2a4\ud134\uc2a4\uc758 \uc774\ubca4\ud2b8 \ud578\ub4e4\ub7ec\ub97c \ucd94\uac00\ud55c\ub2e4.\\n- \uc0dd\uc131\ub41c `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \ubc30\uc5f4 \ud615\uc2dd\uc758 \uc804\uc5ed \uc0c1\ud0dc\uc5d0 \ucd94\uac00\ud55c\ub2e4.\\n- \ucda9\uc804\uc18c \uc815\ubcf4\uac00 \ucd5c\uc2e0\ud654 \ub418\uc5c8\uc744 \ub54c \ub9c8\ucee4\uac00 \ud654\uba74\uc5d0 \ubcf4\uc774\uc9c0 \uc54a\ub294 \uc0c1\ud0dc\uac00 \ub418\uc5c8\ub2e4\uba74 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc804\uc5ed \uc0c1\ud0dc\uc5d0\uc11c \uc0ad\uc81c\ud55c\ub2e4.\\n\\n\uc704\uc640 \uac19\uc774 `StationMarker` \uc758 \uc5ed\ud560\uc744 \uc7a1\uac8c \ub418\uba74 \uae30\uc874\uc758 \ucef4\ud3ec\ub10c\ud2b8 \uc124\uacc4 \uad6c\uc870\ub97c \ud574\uce58\uc9c0 \uc54a\uc73c\uba74\uc11c \uc804\uc5ed \uc0c1\ud0dc\uc5d0 `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc798 \ucd94\uac00\ud560 \uc218 \uc788\uac8c \ub41c\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub807\uac8c \ub418\uba74 `StationMarker` \ucef4\ud3ec\ub10c\ud2b8\ub294 \ub2e4\uc74c\uc758 \ud070 \ubb38\uc81c\ub4e4\uc744 \uac00\uc9c0\uac8c \ub41c\ub2e4.\\n\\n1. `marker`\ub4e4\uc744 \uac00\uc9c0\ub294 \uc804\uc5ed \uc0c1\ud0dc\ub97c \uad6c\ub3c5\ud558\uace0 \uc788\ub294 \ucef4\ud3ec\ub10c\ud2b8\uac00 \uc0c8\ub85c \uc0dd\uc131\ub418\ub294 \ub9c8\ucee4\uc758 \uac1c\uc218\ub9cc\ud07c \ub9ac\ub80c\ub354\ub9c1 \ub41c\ub2e4.\\n2. \ud604\uc7ac \uc0ac\uc6a9\ud558\uace0 \uc788\ub294 \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\uc758 \ud2b9\uc131\uc0c1 \uc774\uc804 \uc0c1\ud0dc\ub97c \ucc38\uc870\ud574\uc640\uc57c `marker`\ub97c \ucd94\uac00\ud560 \uc218 \uc788\uac8c \ub418\ub294\ub370, \uc774 \ub54c \uc774\uc804 \uc0c1\ud0dc\uac00 \ucd5c\uc2e0\uc758 \uc0c1\ud0dc\uc784\uc744 \ubcf4\uc7a5\ud558\uc9c0 \ubabb\ud560 \uc218 \uc788\ub2e4.\\n\\n\uc774 \ub450 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \ubc29\uc2dd\uc744 \uace0\ubbfc\ud574\ubcf4\uc558\uc744 \ub54c \ub2e4\uc74c\uacfc \uac19\uc740 \uacb0\ub860\uc5d0 \ub3c4\ub2ec\ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n- \ud604\uc7ac \uc0ac\uc6a9\ud558\uace0 \uc788\ub294 \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\ub294 React 18\uc5d0 \uc0c8\ub85c \ucd94\uac00\ub41c `useSyncExternalState` \ud6c5\uc744 \uae30\ubc18\uc73c\ub85c `recoil`\uacfc \ube44\uc2b7\ud558\uac8c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub3c4\ub85d \uacc4\uce35\uc744 \ubd84\ub9ac\ud558\uc5ec \ub9cc\ub4e0 \ub3c4\uad6c\uc774\ub2e4.\\n- \uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\uc758 \uba54\uc11c\ub4dc `useExternalState`, `useExternalValue`, `useSetExternalState` \uc774\uc678\uc5d0 `store` \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc9c1\uc811 \uc811\uadfc\ud558\uc5ec \ucd5c\uc2e0\uc758 \uc0c1\ud0dc\ub97c \ucc38\uc870\ud558\ub294 `getStoreSnapShot` \uba54\uc11c\ub4dc\ub97c \ucd94\uac00\ud55c\ub2e4.\\n- `store`\uc5d0 \uc9c1\uc811 \uc811\uadfc\ud574 \ubc1b\uc544\uc628 \ucd5c\uc2e0\uc758 \uc0c1\ud0dc\ub294 \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8 \uac1d\uccb4 \uc774\ubbc0\ub85c \ub9ac\uc561\ud2b8\uc758 \ub9ac\ub80c\ub354\ub9c1\uc744 \ubc1c\uc0dd \uc2dc\ud0a4\uc9c0 \uc54a\ub294\ub2e4.\\n- \ub9ac\ub80c\ub354\ub9c1\uc73c\ub85c \uc778\ud55c \ubb38\uc81c\uc810\ub4e4\uc744 `getStoreSnapShot` \uba54\uc11c\ub4dc\ub97c \ucd94\uac00\ud568\uc73c\ub85c\uc368 \ud574\uacb0\ud560 \uc218 \uc788\ub2e4.\\n\\n---\\n\\n\uc0c8\ub85c\uc6b4 \uae30\ub2a5 \ucd94\uac00\ub97c \uc704\ud574 \ub9c8\uc8fc\ud588\ub358 \uc55e\uc120 \ub450 \uac00\uc9c0\uc758 \ubb38\uc81c\uc640 \ud574\uacb0 \ubc29\uc2dd\uc744 \uc0b4\ud3b4 \ubcf4\uc558\ub2e4. \uadf8\ub798\uc11c \ucd5c\uc885\uc801\uc73c\ub85c \uc774\uc804\uae4c\uc9c0 \uacc4\uc18d\ud574\uc11c \uace0\ubbfc\ud574\uc654\ub358 \ubb38\uc81c\ub97c \ud574\uacb0\ud55c \uacfc\uc815\uc744 \uac04\ucd94\ub824\ubcf4\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc11c\ubc84\uc5d0\uc11c \ubc1b\uc544\uc640 \ub80c\ub354\ub9c1 \ud558\ub294 `StationList` \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c `marker` \uc778\uc2a4\ud134\uc2a4 \ubc30\uc5f4\uc744 \uc800\uc7a5\ud558\uace0 \uc788\ub294 `store`\uc778\uc2a4\ud134\uc2a4\uc5d0 \uc9c1\uc811 \uc811\uadfc\ud574 \ucd5c\uc2e0\uc758 `marker`\uc778\uc2a4\ud134\uc2a4\ub4e4\uc744 \uac00\uc838\uc628\ub2e4.\\n- \ucda9\uc804\uc18c \ubaa9\ub85d\uc5d0\uc11c \uc0ac\uc6a9\uc790\uac00 \ucda9\uc804\uc18c\ub97c \ud074\ub9ad\ud588\uc744 \ub54c \uc804\uc5ed\uc73c\ub85c \uad00\ub9ac\ub418\ub294 `infoWindow` \uc778\uc2a4\ud134\uc2a4\uc758 `open`\uba54\uc11c\ub4dc\uc5d0 `marker` \uc778\uc2a4\ud134\uc2a4\ub4e4 \uc911 \uc120\ud0dd\ub41c `marker`\ub97c \uc804\ub2ec\ud574 \uac04\ub2e8 \uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6cc\uc900\ub2e4."},{"id":"11","metadata":{"permalink":"/11","source":"@site/blog/2023-07-10-google-maps-api-with-car-ffeine.mdx","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","description":"\uc9c0\ub3c4 api \ubca4\ub354 \uc120\ud0dd \uc774\uc720","date":"2023-07-10T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 10\uc77c","tags":[{"label":"react","permalink":"/tags/react"},{"label":"google maps","permalink":"/tags/google-maps"},{"label":"google maps api","permalink":"/tags/google-maps-api"},{"label":"react-wrapper","permalink":"/tags/react-wrapper"},{"label":"@googlemaps/react-wrapper","permalink":"/tags/googlemaps-react-wrapper"}],"readingTime":8.165,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"11","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","authors":["gabriel"],"tags":["react","google maps","google maps api","react-wrapper","@googlemaps/react-wrapper"]},"prevItem":{"title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","permalink":"/13"},"nextItem":{"title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","permalink":"/12"}},"content":"## \uc9c0\ub3c4 api \ubca4\ub354 \uc120\ud0dd \uc774\uc720\\n\\n\uad6d\ub0b4 \uc11c\ube44\uc2a4 \uc911\uc778 \uc9c0\ub3c4 \uc11c\ube44\uc2a4\ub85c\ub294 google, naver, kakao\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc911\uc5d0\uc11c\ub3c4 google maps api\ub294 css\ub85c `\uc9c0\ub3c4\uc758 \ud14c\ub9c8\ub97c \uc9c1\uc811 \uc2a4\ud0c0\uc77c\ub9c1\ud560 \uc218 \uc788\ub294 \uae30\ub2a5\uc774 \uc788\uc5b4\uc11c \uc120\ud0dd`\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\ngoogle maps api\ub97c \uc0ac\uc6a9\ud558\uae30 \uc704\ud574\uc11c \ubcc4\ub3c4\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc0ac\uc6a9\uc774 \ud544\uc218\ub294 \uc544\ub2c8\uc9c0\ub9cc\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c \ub300\uc911\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uacfc \uae30\ubcf8 \ud658\uacbd \uc124\uc815\ubc95\uc744 \ubaa8\ub450 \ud14c\uc2a4\ud2b8 \ud588\uc744 \ub54c, \ubc18\ub4dc\uc2dc \uc0ac\uc6a9\ud558\uace0 \uc2f6\uc740 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \uc874\uc7ac\ud558\uc5ec \ube44\uad50\ub97c \uae30\ub85d\uc73c\ub85c \ub0a8\uae30\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n# google maps api \uad00\ub828 \ub77c\uc774\ube0c\ub7ec\ub9ac\\n\\n(\uc120\ud0dd\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 \u2705\uc73c\ub85c \ud45c\uc2dc\ud588\uc2b5\ub2c8\ub2e4.)\\n\\n### google maps API\\n\\nhttps://github.com/tomchentw/react-google-maps\\n\\n\uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uad6c\uae00\uc5d0\uc11c \uacf5\uc2dd\uc73c\ub85c \uc81c\uacf5\ud558\ub294 \uc9c0\ub3c4 api\ub85c, HTML DOM\uc5d0 \uad6c\uae00 \uc9c0\ub3c4\ub97c \ubd80\ucc29\ud558\uace0, \uc0ac\uc6a9(\uc870\uc791)\ud560 \uc218 \uc788\ub3c4\ub85d \ub3c4\uc640\uc90d\ub2c8\ub2e4. \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 `vanilla Javascript \uae30\ubc18\uc73c\ub85c \ub3d9\uc791`\ud569\ub2c8\ub2e4.\\n\\n### **@types/google.maps** \u2705\\n\\nhttps://www.npmjs.com/package/@types/google.maps\\n\\nTypeScript\uc5d0\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \uc0ac\uc6a9\ud560 \ub54c `\ud0c0\uc785\uc744 \uc81c\uacf5`\ud574\uc8fc\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n### **@googlemaps/js-api-loader**\\n\\nhttps://www.npmjs.com/package/@googlemaps/js-api-loader\\n\\n\uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uad6c\uae00\uc5d0\uc11c \uacf5\uc2dd\uc73c\ub85c \uc81c\uacf5\ud558\ub294 \uc9c0\ub3c4 \ud638\ucd9c api\ub85c, api key\ub9cc \ub118\uaca8\uc8fc\ub354\ub77c\ub3c4 \uad6c\uae00 \uc9c0\ub3c4\ub97c \uc2a4\ud06c\ub9bd\ud2b8 \ud615\ud0dc\ub85c \ubd88\ub7ec\uc640\uc8fc\ub294 \uc5ed\ud560\uc744 \ud558\ub294 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4. \ubcc4\ub3c4\ub85c html \uc870\uc791 \uc5c6\uc774 \ubd88\ub7ec\uc628 `\ub77c\uc774\ube0c\ub7ec\ub9ac\uc5d0\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \uaebc\ub0b4\uc11c \ub3d9\uc801\uc73c\ub85c \uc0ac\uc6a9`\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. vanilla Javascript \uae30\ubc18\uc73c\ub85c \ub3d9\uc791\ud558\uc5ec \uc5b4\ub514\uc5d0\uc11c\ub098 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n### \ub300\uc911\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac \ube44\uad50\\n\\n| | react-google-maps | @react-google-maps/api | @googlemaps/react-wrapper |\\n| --- | --- | --- | --- |\\n| \ub9c1\ud06c | https://www.npmjs.com/package/react-google-maps | https://www.npmjs.com/package/@react-google-maps/api | https://www.npmjs.com/package/@googlemaps/react-wrapper |\\n| \uc124\uba85 | \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uac1c\uc778\uc774 \ub9cc\ub4e0 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c, google maps API\ub97c react DOM \uc704\uc5d0 \uc62c\ub824\uc11c \uc0ac\uc6a9\ud558\uac8c \ub3d5\uc2b5\ub2c8\ub2e4.
    \uad6c\uae00 \uc9c0\ub3c4\uc640 \ub9c8\ucee4\ub97c react component \ucc98\ub7fc \uc0ac\uc6a9\ud558\uc5ec react\uc2a4\ub7fd\uac8c \ub80c\ub354\ub9c1 \ud558\ub294 \uac83\uc744 \uc9c0\uc6d0\ud569\ub2c8\ub2e4.
    react \uc9c4\uc601\uc5d0\uc11c \uac00\uc7a5 \ub300\uc911\uc801\uc73c\ub85c \uc0ac\uc6a9\ub418\ub294 \uad6c\uae00 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc600\uc9c0\ub9cc 2018\ub144 \uc774\ud6c4\ub85c \uc5c5\ub370\uc774\ud2b8\uac00 \ub04a\uacbc\uc2b5\ub2c8\ub2e4. | \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub3c4 \uac1c\uc778\uc774 \ub9cc\ub4e0 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c \uc55e\uc11c \uc18c\uac1c\ud55c react-google-maps\ub97c \uac1c\ub7c9\ud558\uc5ec \ub9cc\ub4e0 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.
    \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc5ed\uc2dc react\uc5d0 \uc9c0\ub3c4\ub098 \ub9c8\ucee4 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ud638\ucd9c\ud574\uc11c \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.
    \ud604\uc7ac react \uc9c4\uc601\uc5d0\uc11c \uac00\uc7a5 \ub300\uc911\uc801\uc73c\ub85c \uc0ac\uc6a9\ub418\ub294 \uad6c\uae00 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc785\ub2c8\ub2e4. | \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uad6c\uae00\uc5d0\uc11c \uacf5\uc2dd\uc73c\ub85c \uc81c\uacf5\ud558\ub294 react\uc6a9 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.
    \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uc55e\uc11c \uc18c\uac1c\ud55c js-api-loader\ub97c \ud65c\uc6a9\ud558\uc5ec \ub9cc\ub4e0 Wrapper \ucef4\ud3ec\ub10c\ud2b8\ub97c \uc81c\uacf5\ud558\ub294\ub370, \uad6c\uae00 \uc9c0\ub3c4\ub97c \ud638\ucd9c\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uc218\uc2e0\uc911, \uc2e4\ud328, \uc131\uacf5\uc5d0 \ub530\ub77c \uc9c0\ub3c4\ub97c \ubcf4\uc5ec\uc904 \uc9c0, \ub85c\ub529\uc911 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ubcf4\uc5ec\uc904 \uc9c0, \uc5d0\ub7ec \ucef4\ud3ec\ub10c\ud2b8\ub97c \ubcf4\uc5ec\uc904 \uc9c0 \uacb0\uc815\ud558\ub294 \uae30\ub2a5\uc774 \uc788\uc2b5\ub2c8\ub2e4.
    \uc774\uc678\uc5d0\ub294 \uae30\uc874\uc758 js-api-loader\uc758 \uae30\ub2a5\uacfc \uc644\ubcbd\ud558\uac8c \ub3d9\uc77c\ud569\ub2c8\ub2e4. (\ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc5f4\uc5b4\uc11c \uc9c1\uc811 \ud655\uc778\ud574\ubd24\uc2b5\ub2c8\ub2e4.) |\\n| \uc120\ud0dd\uc5ec\ubd80 | | | \u2705 |\\n\\n# \ub77c\uc774\ube0c\ub7ec\ub9ac \uc120\ud0dd \uc774\uc720\\n\\n\uc800\ud76c \ud504\ub85c\uc81d\ud2b8\ub294 `\uc2e4\uc2dc\uac04 \uc804\uae30\uc790\ub3d9\ucc28 \ucda9\uc804\uc18c \uc9c0\ub3c4 \ubc0f \uc0ac\uc6a9 \ud1b5\uacc4 \uc870\ud68c \uc11c\ube44\uc2a4` \ub2e4\ubcf4\ub2c8 \uc9c0\ub3c4 \uc704\uc5d0 \ub744\uc6cc\uc918\uc57c \ud560 \ub9c8\ucee4\ub97c \ucd5c\uc801\ud654 \ud558\ub294 \uacfc\uc815\uc774 \uad49\uc7a5\ud788 \uc911\uc694\ud569\ub2c8\ub2e4.\\n\\n1. \uc804\uad6d 6\ub9cc\uc5ec \uac1c\uc758 \ub9c8\ucee4\ub97c \uc804\ubd80 \ubcf4\uc5ec\uc904 \uc218 \uc5c6\ub2e4.\\n2. \ud604\uc7ac \ub514\uc2a4\ud50c\ub808\uc774 \uc601\uc5ed\uc758 \ub9c8\ucee4\ub9cc\uc744 \ud638\ucd9c\ud574\uc57c\ud55c\ub2e4.\\n3. \uadf8 \ub9c8\ucee4\ub4e4\uc758 \ub80c\ub354\ub9c1 \uacfc\uc815\uc744 \uc800\uc218\uc900\uc5d0\uc11c \ub2e4\ub8f0 \uc218 \uc788\uc5b4\uc57c \ud55c\ub2e4.\\n\\n\uc774\ub7f0 \uc6d0\uce59\uc744 \uac00\uc9c0\uace0 \uc788\uae30\uc5d0 \ub300\uc911\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4(react-google-maps, @react-google-maps/api)\uc740 \uc800\ud76c\uc758 \uc120\ud0dd\uc9c0\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub294 \uc624\ub85c\uc9c0 vanilla\ub85c \uc81c\uacf5\ub418\ub294 \uc0c1\ud0dc\uc5d0\uc11c \uc9c1\uc811 \uc81c\uc5b4\ud558\uae30\ub85c \uacb0\uc815\ud558\uc600\uace0, \ub9c8\ucee4\ub97c \uad00\ub9ac\ud558\ub294 \uc8fc\uccb4 \ub610\ud55c \uad6c\uae00 \uc9c0\ub3c4\uc5d0\uc11c \uc9c1\uc811 \ucee8\ud2b8\ub864\uc744 \ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \ud638\ucd9c\ud558\ub294 \uc791\uc5c5\uc740 @googlemaps/react-wrapper\uc5d0 \ub9e1\uae30\uace0, \ubd88\ub7ec\uc628 \uad6c\uae00 \uc9c0\ub3c4\ub294 vanilla\ub85c \ud1b5\uc81c\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\ub3c4\uc758 \uc870\uc791, \uc9c0\ub3c4\uc5d0 \ub9c8\ucee4\ub97c \ucc0d\ub294 \uacfc\uc815\uc744 \ubaa8\ub450 `\uacf5\uc2dd \ubb38\uc11c\uc5d0 \ub098\uc640\uc788\ub294 \ubc29\ubc95\ub300\ub85c \ud1b5\uc81c`\ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uae30\uc874\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 \ub9c8\ucee4\ub098 \uc9c0\ub3c4\ub97c \ucef4\ud3ec\ub10c\ud2b8\ud654 \ud55c \uc0c1\ud0dc\uc774\uae30\uc5d0 \ucd5c\uc801\ud654 \uacfc\uc815\uc5d0\uc11c \uc800\ud76c\uac00 \uc81c\uc5b4\ud560 \uc218 \uc5c6\ub294 \ubd80\ubd84\ub4e4\uc774 \uc788\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4. \ub530\ub77c\uc11c \ud2b8\ub7ec\ube14\uc288\ud305 \uacfc\uc815\uc5d0\uc11c \ub9c8\ucee4\uc758 \ud638\ucd9c \uc2dc\uc810, \uba54\ubaa8\ub9ac\uc5d0\uc11c \ud574\uc81c\ud558\ub294 \uc2dc\uc810, \ub80c\ub354\ub9c1\ud558\ub294 \uc2dc\uc810 \ub4f1\uc758 \uc791\uc5c5\ub4e4\uc744 \ud6e8\uc52c \ub354 \uc138\ubc00\ud558\uac8c \ud558\ub824\uba74 google maps api\uc744 \uc788\ub294 \uadf8\ub300\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc9c0\ub3c4\uc5d0 \uad00\ub828\ub41c \uae30\ub2a5\uc740 react DOM \uc704\uc5d0\uc11c\uac00 \uc544\ub2cc vanilla \ud658\uacbd\uc5d0\uc11c \uc791\uc5c5\uc744 \ud560 \uac83\uc785\ub2c8\ub2e4.\\n\\n# \uad6c\uae00 \uc9c0\ub3c4 \uc81c\uc5b4 \uc804\ub7b5\\n\\n1. \uad6c\uae00 \uc9c0\ub3c4\uc640 \ub9c8\ucee4\ub294 \ud56d\uc0c1 \ubc14\ub2d0\ub77c \ud658\uacbd(react DOM \ubc14\uae65)\uc5d0\uc11c \ub3d9\uc791\ud558\uac8c \ud55c\ub2e4.\\n2. \ubc14\ub2d0\ub77c \ud658\uacbd\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud558\uac8c \ud558\uc5ec \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c\uc758 \uc7ac \ub80c\ub354\ub9c1\uc744 \uc77c\uc808 \ubc29\uc9c0\ud55c\ub2e4.\\n3. \ub9c8\ucee4\ub098 \uc9c0\ub3c4\uc758 \ub3d9\uc791 \uc774\ubca4\ud2b8\uc5d0 \uc758\ud574 UI\ub97c \uc870\uc791\ud574\uc57c\ud558\ub294 \uacbd\uc6b0\uc5d0\ub294 react DOM \uc870\uc791\uc744 \ud558\ub3c4\ub85d \ud55c\ub2e4.\\n4. \ubc14\ub2d0\ub77c \ud658\uacbd\uc778 google maps api\uc640 react DOM \uc0ac\uc774\uc758 \uc81c\uc5b4 \uacfc\uc815\uc5d0\ub294 useSyncExternalStore \ud6c5\uc744 \uc774\uc6a9\ud558\uc5ec \ub9ac\uc561\ud2b8 UI\ub97c \uac15\uc81c\ub85c \ub3d9\uae30\ud654 \uc2dc\ud0ac \uc218 \uc788\ub3c4\ub85d \ud55c\ub2e4.\\n\\n\uad6c\uae00 \uc9c0\ub3c4\ub294 \ubc14\ub2d0\ub77c \ud658\uacbd\uc5d0\uc11c, \uac01\uc885 UI \ud1b5\uc81c\ub294 \ub9ac\uc561\ud2b8\uc5d0\uc11c \ud1b5\ud569\ud558\uc5ec \uc0ac\uc6a9\ud558\ub294 \ud658\uacbd\uc744 \uad6c\uc0c1\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc2dc\uc911\uc5d0 \ub098\uc640\uc788\ub294 \ub300\ubd80\ubd84\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc744 \ud65c\uc6a9\ud558\uc5ec \ube44\uad50\ud558\uace0 \ud14c\uc2a4\ud2b8\ud55c \uacb0\uacfc @googlemaps/react-wrapper\ub97c \uc120\ud0dd\ud558\ub294 \uac83\uc774 \ucd5c\uc801\ud654\uc640 \uc0dd\uc0b0\uc131, \uc571 \uc548\uc815\uc131\uc744 \ubaa8\ub450 \ud655\ubcf4\ud560 \uc218 \uc788\ub294 \uc120\ud0dd\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud604\uc7ac \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\uc911\uc778 \uc9c0\ub3c4 \uc81c\uc5b4\uc5d0 \uad00\ud55c \ubc29\ubc95\uc740 \uc774\ud6c4\uc5d0 \uc791\uc131 \ub420 \uae00\uc5d0\uc11c \uc0c1\uc138\ud558\uac8c \uc124\uba85\ud558\uaca0\uc2b5\ub2c8\ub2e4."},{"id":"12","metadata":{"permalink":"/12","source":"@site/blog/2023-07-10-kiara-jasypt.mdx","title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","description":"\uc11c\ub860","date":"2023-07-10T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 10\uc77c","tags":[{"label":"jasypt","permalink":"/tags/jasypt"},{"label":"Spring","permalink":"/tags/spring"}],"readingTime":5.59,"hasTruncateMarker":false,"authors":[{"name":"\ud0a4\uc544\ub77c","title":"Backend","url":"https://github.com/kiarakim","imageURL":"https://github.com/kiarakim.png","key":"kiara"}],"frontMatter":{"slug":"12","title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","authors":["kiara"],"tags":["jasypt","Spring"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","permalink":"/11"},"nextItem":{"title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","permalink":"/9"}},"content":"## \uc11c\ub860\\n\\n\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 `\ud0a4\uc544\ub77c`\uc785\ub2c8\ub2e4.\\n\\n\uc774\ubc88 \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud558\uba74\uc11c \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\ub294 \ubc29\ubc95\uc73c\ub85c jasypt\ub97c \uc54c\uac8c\ub418\uc5b4\\n\\n\uc0ac\uc6a9\ud558\ub294 \ubc29\ubc95\uc744 \uc775\ud600 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc801\uc6a9\ud574\ubcfc \uacc4\ud68d\uc785\ub2c8\ub2e4.\\n\\n## \ud504\ub85c\ud37c\ud2f0 \uc554\ud638\ud654\ub294 \uc65c \ud544\uc694\ud560\uae4c?\\n\\n```java\\nspring:\\n datasource:\\n url: \ub370\uc774\ud130\ubca0\uc774\uc2a4 url\\n username: \uacc4\uc815\\n password: \ube44\ubc00\ubc88\ud638\\n```\\n\\n\ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c yml \ud30c\uc77c\uc5d0 DB \uc5f0\uacb0 URL\uc774\ub098 \uacc4\uc815, \ube44\ubc00\ubc88\ud638 \uac19\uc774 \ub178\ucd9c\ub418\uc5b4\uc120 \uc548 \ub418\ub294 \ubbfc\uac10\ud55c \uc815\ubcf4\ub4e4\uc774 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\\ngit\uc758 public repository\uc640 CI/CD\ub97c \uc5f0\ub3d9\ud574 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \ubc30\ud3ec\ud55c\ub2e4\uba74 \uc911\uc694\ud55c \uc815\ubcf4\uac00 \ud0c8\ucde8\ub420 \uac00\ub2a5\uc131\uc774 \uc788\uc8e0.\\n\\nJasypt \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uba74 \ud3c9\ubb38\uc73c\ub85c \ub41c \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc811\uc18d \uc815\ubcf4\ub97c \uc554\ud638\ud654 \ud558\uc5ec \ubc29\uc5b4\ub9c9\uc744 \ud55c \uacb9 \uc313\uc744 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uac04\ub7b5\ud558\uac8c \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud558\uace0 \uc0ac\uc6a9 \ubc29\ubc95\uc744 \uc54c\uc544\ubcfc\uae4c\uc694?\\n\\n## jasypt\ub294 \ubb50\uc9c0?\\n\\nJasypt\uc774\ub780 \uc27d\uac8c \uc554\ud638\ud654 \uae30\ub2a5\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\ub3c4\ub85d \uc81c\uacf5\ud558\ub294 Java \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\\n\ubbfc\uac10\ud55c \ud3c9\ubb38 \uc815\ubcf4\ub97c \uc554\ud638\ud654\ud558\uace0, \uc544\ub798\ucc98\ub7fc \uc124\uc815 \uac12\uc744 \uc9c0\uc815\ud558\uba74 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc774 \uc2e4\ud589\ub420 \ub54c \uc790\ub3d9\uc73c\ub85c \uc774\ub97c \ubcf5\ud638\ud654\ud558\uc5ec \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\uac00 \ud3b8\ud558\uac8c \uc554\ud638\ud654 \uae30\ub2a5\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\ub3c4\ub85d \uc81c\uacf5\ud558\ub294 Java \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c\\n\\n\uacf5\uc2dd \ud648\ud398\uc774\uc9c0\ub294 http://www.jasypt.org/ \uc5d0 \uac00\uba74 \ub354 \uc790\uc138\ud55c \uc815\ubcf4\ub97c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc0ac\uc6a9 \ubc29\ubc95\\n\\n\uc815\ub9d0 \uac04\ub2e8\ud558\uac8c \ub77c\uc774\ube0c\ub7ec\ub9ac \ucd94\uac00, key\uac12 \ub118\uaca8\uc8fc\uae30, \uc554\ud638\ud654 \uc138 \uac00\uc9c0 \ub2e8\uacc4\ub85c \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc5ec \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### 1. \ub77c\uc774\ube0c\ub7ec\ub9ac \ucd94\uac00 (= \uc758\uc874\uc131 \ucd94\uac00)\\n\\n```java\\nimplementation \\"com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3\\"\\n```\\n\\n### 2. Jasypt \uc124\uc815 \ubc0f Bean \ub4f1\ub85d\\n\\nkey\ub97c \uc0ac\uc6a9\ud574\uc11c Bean\uc744 \ub4f1\ub85d\ud558\ub294 \uae30\ubcf8 \uc124\uc815\uc785\ub2c8\ub2e4. \uc5ec\uae30\uc11c Bean\uc758 \uc774\ub984\uc744 jasyptEncryptor\ub77c\uace0 \uc124\uc815\ud588\ub2e4\uba74 \ud504\ub85c\ud37c\ud2f0 \ub4f1\ub85d\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n```java\\n@Configuration\\npublic class JasyptConfig {\\n\\n private String ENCRYPT_KEY = \\"hello\\";\\n\\n @Bean(name = \\"jasyptEncryptor\\")\\n public StringEncryptor stringEncryptor() {\\n PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();\\n\\n SimpleStringPBEConfig config = new SimpleStringPBEConfig();\\n\\n config.setPassword(ENCRYPT_KEY);\\n config.setAlgorithm(\\"PBEWithMD5AndDES\\");\\n config.setKeyObtentionIterations(1000);\\n config.setPoolSize(1);\\n config.setSaltGeneratorClassName(\\"org.jasypt.salt.RandomSaltGenerator\\");\\n config.setStringOutputType(\\"base64\\");\\n encryptor.setConfig(config);\\n return encryptor;\\n }\\n}\\n```\\n\\n```java\\njasypt:\\n encryptor:\\n bean: jasyptEncryptor\\n```\\n\\n### 3. \uc554\ud638\ud654\\n\\n\ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud560 \uc900\ube44\ub294 \uac70\uc758 \ub2e4 \ub05d\ub0ac\uc2b5\ub2c8\ub2e4. \uc774\uc81c \uc554\ud638\ud654\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\uc5d0 \uc791\uc131\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub54c \uc554\ud638\ud654 \ud558\ub294 \ubc29\ubc95\uc740, \uc544\ub798 \uc0ac\uc774\ud2b8\uc5d0 \uc811\uc18d\ud574 \ud3c9\ubb38\uacfc \ud0a4\ub97c \uc785\ub825\ud55c \ud6c4 \ub098\uc628 \uc554\ud638\ubb38\uc744 \ud504\ub85c\ud37c\ud2f0 \ud30c\uc77c\uc5d0 \'ENC(\uc554\ud638\ubb38)\' \ub85c \uc791\uc131\ud569\ub2c8\ub2e4.\\n\\n[\uc554\ubcf5\ud638\ud654 \uc0ac\uc774\ud2b8](https://www.devglan.com/online-tools/jasypt-online-encryption-decryption)\\n\\n![\ud3c9\ubb38](https://github.com/kiarakim/algorithm/assets/101039161/b0293dfc-e0d8-45a0-91af-becf790a1002)\\n\\n```java\\n datasource:\\n url: \ub370\uc774\ud130\ubca0\uc774\uc2a4 url\\n username: \uacc4\uc815\\n password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)\\n```\\n\\n\ub098\uba38\uc9c0\ub3c4 \ub9c8\uc800 \uc554\ud638\ud654\ud574\uc90d\uc2dc\ub2e4.\\n\\n```java\\n datasource:\\n url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)\\n username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)\\n password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)\\n```\\n\\n## \uc2e4\ud589\\n\\n\uc62c\ubc14\ub978 \uc554\ud638\ubb38\uc744 \uc785\ub825\ud588\ub2e4\uba74 \uc815\uc0c1\uc801\uc73c\ub85c \uc2e4\ud589\uc774 \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\ub098 \uc774\ub54c \uc784\uc758\ub85c \uc554\ud638\ubb38\uc744 \uc218\uc815\ud55c\ub2e4\uba74 \ub2e4\uc74c\uacfc \uac19\uc774 \ube4c\ub4dc\ub97c \uc2e4\ud328\ud569\ub2c8\ub2e4.\\n\\n![\uc2e4\ud589 \uc2e4\ud328](https://github.com/kiarakim/algorithm/assets/101039161/d003df00-bf4f-4ed2-a1ee-293cd7da6fc1)\\n\\n\uadf8\ub7f0\ub370 \ubb54\uac00 \uc774\uc0c1\ud558\uc9c0 \uc54a\ub098\uc694?\\n\\n\ud504\ub85c\ud37c\ud2f0\ub294 \ubd84\uba85 \uc554\ud638\ud654 \ud588\ub294\ub370 \ud0a4\uac00 \ucf54\ub4dc\uc5d0 \uadf8\ub300\ub85c \ub178\ucd9c\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nGit\uc758 public Repository\uc5d0 \ubc30\ud3ec\ud558\uba74 \ub2e4\ub978 \uc0ac\ub78c\ub4e4\ub3c4 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc774 \ud0a4\ub97c \uc5b4\ub514\uc5d0 \uc228\uae38 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n\uc800\ub294 \ucc98\uc74c\uc5d0 \uc77c\ubc18 file\uc5d0 \ud0a4\ub97c \ub123\uc5b4\ub193\uace0 \ud30c\uc77c\uc744 \uc77d\uc5b4\uc624\ub294 \uc2dd\uc73c\ub85c \ud0a4\ub97c \uad00\ub9ac\ud558\ub824\uace0 \ud588\uc2b5\ub2c8\ub2e4. \ub2f9\uc5f0\ud788 \ud574\ub2f9 \ud30c\uc77c\uc740 .gitignore\ub85c \ucee4\ubc0b \ub300\uc0c1\uc5d0\uc11c \uc81c\uc678\ud574\uc57c\uaca0\uc8e0.\\n\\n\uadf8\ub7f0\ub370 \uc774\uac83\ubcf4\ub2e4 \ub354 \uc27d\uace0 \ube60\ub978 \ubc29\ubc95\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubc14\ub85c \ud658\uacbd\ubcc0\uc218\ub97c \uc124\uc815\ud558\ub294 \uac83\uc774\uc8e0.\\n\\n### + \ud658\uacbd\ubcc0\uc218 \uc124\uc815\\n\\n```java\\nprivate String ENCRYPT_KEY = \\"hello\\";\\n```\\n\uae30\uc874\uc758 \ud0a4\ub97c \uad00\ub9ac\ud558\ub294 \ubc29\uc2dd\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc6b0\uc120 \uc774 \ud0a4\ub97c \ud504\ub85c\ud37c\ud2f0\uc5d0\uc11c \uad00\ub9ac\ud558\ub3c4\ub85d \uc124\uc815\ud574\ubcfc\uae4c\uc694?\\n\\n```java\\n// JasyptConfig.class\\n@Value(\\"${jasypt.encryptor.password}\\")\\n private String ENCRYPT_KEY;\\n```\\n```java\\n// application.yml\\njasypt:\\n encryptor:\\n password: hello\\n```\\n\\n\uc774\uc81c \ud658\uacbd\ubcc0\uc218\ub97c \uc124\uc815\ud574\ubd05\uc2dc\ub2e4.\\n\\nRun > Edit Configurations... \uacbd\ub85c\ub85c \ub4e4\uc5b4\uac00\uba74\\n\\nRun/Debug Configurations \ucc3d\uc774 \ub098\uc624\ub294\ub370\\n\\nEnvironment variables: \ubd80\ubd84\uc5d0 ENCRYPT_KEY=hello\\n\\n\ub77c\uace0 \uc801\uc5b4\uc8fc\uc138\uc694.\\n\\n\uadf8 \ud6c4 \ub2e4\uc2dc yml \ud30c\uc77c\ub85c \ub3cc\uc544\uc640 \uae30\uc874 hello\ub85c \ub418\uc5b4\uc788\ub294 \ubd80\ubd84\uc744 ${ENCRYPT_KEY}\ub85c \ubcc0\uacbd\ud558\uace0 \uc2e4\ud589\ud55c\ub2e4\uba74 \uc815\uc0c1\uc801\uc73c\ub85c \uc791\ub3d9\ub429\ub2c8\ub2e4.\\n\\n```java\\njasypt:\\n encryptor:\\n password: ${ENCRYPT_KEY}\\n```\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4."},{"id":"9","metadata":{"permalink":"/9","source":"@site/blog/2023-07-09-github_actions_pull_request_test.mdx","title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.","date":"2023-07-09T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 9\uc77c","tags":[{"label":"github","permalink":"/tags/github"},{"label":"action","permalink":"/tags/action"},{"label":"pr","permalink":"/tags/pr"},{"label":"test","permalink":"/tags/test"}],"readingTime":8.985,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"9","title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","authors":["boxster"],"tags":["github","action","pr","test"]},"prevItem":{"title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","permalink":"/12"},"nextItem":{"title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","permalink":"/10"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.\\n## Pull Request\uc2dc \uc790\ub3d9\uc73c\ub85c test\ub97c \uc2e4\ud589\ud558\uba74 \uc88b\uc740 \uc810\\npull request \uc0dd\uc131 \uc2dc \uc790\ub3d9\uc73c\ub85c \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub824\uc900\ub2e4\uba74 \ub2e4\ub978 \ud300\uc6d0\uc758 pr\uc744 \uad73\uc774 \uc81c \ub85c\uceec\uc5d0 clone\ud558\uc5ec \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub824\ubcf4\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.\\n\ub9ce\uc740 \uc2dc\uac04\uc744 \ub2e8\ucd95\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 test\uac00 \uc2e4\ud328\ud55c\ub2e4\uba74 \uac15\uc81c\ub85c Merge\uac00 \ub418\uc9c0 \uc54a\ub3c4\ub85d \ud55c\ub2e4\uba74 \uc2e4\uc218\ub85c \ud14c\uc2a4\ud2b8\uac00 \ub418\uc9c0 \uc54a\ub294 \ucee4\ubc0b\uc744 \uc62c\ub9ac\ub294 \uac83\uc744 \ubc29\uc9c0\ud560 \uc218 \uc788\uaca0\uc8e0.\\n\\n\uc774 \ub450\uac00\uc9c0\ub9cc\uc73c\ub85c\ub3c4 \uc0dd\uc0b0\uc131\uc774 \ub9ce\uc774 \uc62c\ub77c\uac08 \uac83\uc744 \uae30\ub300\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc5b4\ub5bb\uac8c \ud560 \uc218 \uc788\ub098\uc694\\n\\nGithub Action\uc744 \uc774\uc6a9\ud558\uc5ec \uc124\uc815\ud55c \uc870\uac74\uc5d0 \ub9de\ub294 \uc0c1\ud669\uc5d0\uc11c \uba85\ub839\uc5b4\ub97c \uc2e4\ud589\ud558\uc5ec test\ub97c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### Github Action \ud30c\uc77c \uc0dd\uc131\\n\\n1. \uba3c\uc800 \ucd5c\uc0c1\uc704 \ud3f4\ub354\uc5d0 `.github/workflows` \ud3f4\ub354\ub97c \uc0dd\uc131\ud569\ub2c8\ub2e4.\\n2. \ud574\ub2f9 \ud3f4\ub354 \ub0b4\uc5d0 `example.yml`\uc744 \uc0dd\uc131\ud569\ub2c8\ub2e4.\\n3. \uc544\ub798\uc640 \uac19\uc774 yml \ud30c\uc77c\uc744 \uc791\uc131\ud569\ub2c8\ub2e4.\\n\\n```yml\\nname: pr test\\n\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n\\npermissions:\\n contents: read\\n\\njobs:\\n test:\\n name: merge-test\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./backend\\n steps:\\n - uses: actions/checkout@v3\\n - name: Set up JDK 17\\n uses: actions/setup-java@v3\\n with:\\n java-version: \'17\'\\n distribution: \'adopt\'\\n - name: Grant execute permission for gradlew\\n run: chmod +x gradlew\\n - name: Test with Gradle\\n run: ./gradlew build\\n```\\n\\n### Job \uc774\ub984 \uc124\uc815\\n\ubcf5\uc7a1\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uba3c\uc800 **name** \uc18d\uc131\uc740 github action\uc5d0\uc11c \ubcf4\uc5ec\uc9c8 Job\uc758 \uc774\ub984\uc744 \uc815\ud558\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\\n\uc9c0\uae08\uc740 `pr test`\ub85c \ud574\ub450\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub7fc \uc544\ub798 \uc0ac\uc9c4\uacfc \uac19\uc774 \ubc18\uc601\ub429\ub2c8\ub2e4.\\n\\n![workflows name](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/28494d8e-66b5-4eec-a98a-414968b03306)\\n\\n### workflow \ud2b8\ub9ac\uac70 \uc124\uc815\\n\ub2e4\uc74c\uc73c\ub860 `on` \uc18d\uc131\uc785\ub2c8\ub2e4. \uc774 \uc18d\uc131\uc740 workflow\ub97c \uc2e4\ud589\ud560 \uc774\ubca4\ud2b8\ub97c \uc9c0\uc815\ud558\ub294\ub370 \uc0ac\uc6a9\ub429\ub2c8\ub2e4. \ud2b9\uc815 \uc774\ubca4\ud2b8 \uc720\ud615\uacfc \uc870\uac74\uc744 \uae30\ubc18\uc73c\ub85c workflow\ub97c \ud2b8\ub9ac\uac70\ud558\ub3c4\ub85d \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \uc544\ub798\uc640 \uac19\uc774 \uc815\uc758\ud588\uc2b5\ub2c8\ub2e4.\\n```yml\\non:\\n push:\\n branches:\\n - main\\n pull_request:\\n branches:\\n - develop\\n```\\n\uadf8\ub807\ub2e4\uba74 \uc774 workflow\uac00 \uc791\ub3d9\ub418\ub294 \uc2dc\uc810\uc740 `main` \ube0c\ub79c\uce58\uc5d0 **push**\uac00 \ub418\uac70\ub098 `develop` \ube0c\ub79c\uce58\uc5d0 **pull request**\ub97c \ubcf4\ub0bc \ub54c \uc791\ub3d9\ud569\ub2c8\ub2e4.\\n\\n### \uad8c\ud55c \ubd80\uc5ec\\n```yml\\npermissions:\\n contents: read\\n```\\n\uc774\ub7f0 \uad8c\ud55c\uc744 \uc8fc\uac8c \ub41c\ub2e4\uba74 \uc774 job\uc740 \uc77d\uae30 \uad8c\ud55c\ubc16\uc5d0 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \uc2e4\uc218\ub85c \ub2e4\ub978 \uac83\uc744 \ucd94\uac00\ud558\uc9c0 \ubabb\ud558\uac8c \ub9c9\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n### \ub3d9\uc791\ud560 \uba85\ub839\uc5b4 \uc785\ub825\\n```yml\\njobs:\\n test:\\n name: merge-test\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./backend\\n steps:\\n - uses: actions/checkout@v3\\n - name: Set up JDK 17\\n uses: actions/setup-java@v3\\n with:\\n java-version: \'17\'\\n distribution: \'adopt\'\\n - name: Grant execute permission for gradlew\\n run: chmod +x gradlew\\n - name: Test with Gradle\\n run: ./gradlew build\\n```\\n\\n#### name\\n\uc81c\uc77c \uac04\ub2e8\ud788 \ubcfc \uc218 \uc788\ub294 **name** \uc124\uc815\uc740 \uc544\ub798 \uc0ac\uc9c4\ucc98\ub7fc \uc5b4\ub5a4\uc2dd\uc73c\ub85c \ubcf4\uc5ec\uc904\uc9c0 \uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![job image](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/43ebf3c1-4632-447f-89c3-0e74ed01dc3c)\\n\\n#### runs-on\\n**runs-on** \uc18d\uc131\uc785\ub2c8\ub2e4. \ud574\ub2f9 \uc6b4\uc601\uccb4\uc81c\ub97c \uc0ac\uc6a9\ud55c\ub2e4\uace0 \uc815\uc758\ud558\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4. \uc9c0\uae08\uc740 \uc800\ud76c\uac00 \uc0ac\uc6a9\ud560 ec2\uc640 \uac19\uc740 \ud658\uacbd\uc778 `ubuntu`\uc5d0\uc11c \uc791\ub3d9\ud558\ub3c4\ub85d \uc124\uc815\ud588\uc9c0\ub9cc,\\n`windows-latest`, `macos-latest`\ub85c \ubcc0\uacbd\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### environment\\n**environment** \uc18d\uc131\uc785\ub2c8\ub2e4. \ud574\ub2f9 \uc18d\uc131\uc740 \uaf2d \ud544\uc694\ud55c \ubd80\ubd84\uc774 \uc544\ub2c8\uc9c0\ub9cc branch\uc758 rule \uc124\uc815\uc5d0 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ud658\uacbd\uc744 \ud55c\uaebc\ubc88\uc5d0 \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ubd80\ubd84\uc740 \uc544\ub798\uc5d0 branch rule\uc744 \uc815\ud558\ub294 \ubd80\ubd84\uc744 \ubcf4\uc2dc\uba74 \uc544\ub9c8 \uc774\ud574\uac00 \ub420 \uac83 \uc785\ub2c8\ub2e4.\\n\\n#### defaults\\n\ud574\ub2f9 \uc18d\uc131\uc740 \uc5b4\ub5a4 \ud3f4\ub354\uc5d0\uc11c \uba85\ub839\uc5b4\ub97c \uc2e4\ud589\ud560 \uc9c0 \uc9c0\uc815\ud569\ub2c8\ub2e4. \uc9c0\uae08\uc758 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c\ub294 \ud55c repository\uc5d0 backend, frontend \ud3f4\ub354\ub97c \ub098\ub204\uc5c8\uae30 \ub54c\ubb38\uc5d0 backend \ud3f4\ub354\ub85c \uc774\ub3d9\ud558\uc5ec \uba85\ub839\uc5b4\ub97c \uc2e4\ud589\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c **working-directory**\ub97c `./backend`\ub77c\uace0 \uc9c0\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n#### steps\\n\\n\uc81c\uc77c \uc911\uc694\ud55c **steps**\uc785\ub2c8\ub2e4. \ud574\ub2f9 \uc18d\uc131\uc740 \uc5b4\ub5a4 \uba85\ub839\uc5b4\ub97c \uc5b4\ub5a4 \uc21c\uc11c\ub85c \uc2e4\ud589\uc2dc\ud0ac\uc9c0 \uc815\uc758\ud569\ub2c8\ub2e4. \uc9c0\uae08\uc758 workflow\uc5d0\uc120\\n\\n1. Java 17 \uc124\uce58\\n2. gradlew \ud30c\uc77c\uc5d0 \uc2e4\ud589 \uad8c\ud55c \ubd80\uc5ec\\n3. gradle build \uc2e4\ud589\\n\\n\uc21c\uc73c\ub85c \ub3d9\uc791\ud569\ub2c8\ub2e4.\\n\\n### \ub2e4\ub978 \uc870\uac74\uacfc \uc774\ubca4\ud2b8\ub3c4 \ucd94\uac00\ud558\uace0 \uc2f6\uc5b4\uc694\\n\\n\uc800\ud76c \ud504\ub85c\uc81d\ud2b8\ub294 \ud558\ub098\uc758 repository\uc5d0\uc11c frontend, backend \ucf54\ub4dc\ub97c \uac19\uc774 \uad00\ub9ac\ud558\ub294 \uc0c1\ud669\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc frontend \ucf54\ub4dc\ub97c \uc218\uc815\ud588\ub2e4\uace0 java \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub9ac\ub294 \uac83\uc740 \uc624\ud788\ub824 \uc0dd\uc0b0\uc131\uc774 \uc904\uc5b4\ub4e4\uaca0\uc8e0.\\n\\n\uadf8\ub9ac\uace0 frontend\ub3c4 \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub9ac\uace0 \uc2f6\uc9c0\ub9cc gradle\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7f4 \ub54c \uac04\ub2e8\ud55c \uc18d\uc131\uc744 \ucd94\uac00\ud558\uba74 **\ud30c\uc77c\uc758 \ubcc0\uacbd\uc5d0 \ub530\ub77c \ud574\ub2f9 job\uc744 \uc2e4\ud589\ud560 \uc870\uac74**\uc744 \uc815\uc758\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```yml\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n paths:\\n - backend/**\\n - .github/**\\n```\\n\uc704\uc640 \ub2ec\ub9ac \uc9c0\uae08 **pull request**\uc5d0\ub294 \uc18d\uc131\uc774 \ud558\ub098\uac00 \ub354 \uc788\ub294\ub370\uc694. **paths**\ub97c \uc801\uc6a9\ud558\uba74 `backend` \ud3f4\ub354 \ud558\uc704\uc758 \ubb34\uc5b8\uac00 \ubcc0\uacbd\uc774 \uc788\ub294 **pull request**\uc5d0\ub9cc \uc791\ub3d9\uc744 \ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc backend\uc758 workflow \ud30c\uc77c\uc5d0 **paths** \uc18d\uc131\uc744 \ud558\ub098 \ucd94\uac00\ud558\uace0, \ube44\uc2b7\ud55c frontend workflow\ub97c \ub9cc\ub4e4\uc5b4\uc8fc\uba74 \ub418\uaca0\uc8e0.\\n```yml\\nname: frontend test\\n\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n paths:\\n - frontend/**\\n\\npermissions:\\n contents: read\\n\\njobs:\\n test:\\n name: jest\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./frontend\\n steps:\\n - uses: actions/checkout@v3\\n - name: NPM Install\\n run: npm i\\n - name: Jest run\\n run: npm run test\\n```\\n\uc774\ub7f0 \uc2dd\uc73c\ub85c yml \ud30c\uc77c\uc744 \ud558\ub098 \ucd94\uac00\ud558\uba74 **frontend\uc758 \uc218\uc815\uc774 \uc77c\uc5b4\ub0a0 \ub54c\ub294 jest**\ub97c \uc2e4\ud589\ud558\uace0, **backend \ud3f4\ub354\uc758 \uc218\uc815\uc774 \uc77c\uc5b4\ub098\uba74 gradlew**\ub97c \uc2e4\ud589\ud558\uac8c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## Test\uac00 \uc2e4\ud328\ud558\ub294 PR\uc740 Merge \ub9c9\uae30\\n\\nTest\uac00 \uc2e4\ud328\ud558\ub294 Pull Request\uac00 Merge \ub418\ub294 \uc77c\uc740 \uc808\ub300\ub85c \uc5c6\uc5b4\uc57c \ud569\ub2c8\ub2e4. \uadf8\ub7f0 \uc2e4\uc218\ub97c \ubc29\uc9c0\ud558\ub824\uba74 \ud300\uc6d0 \uc804\ubd80\uac00 \ub9ac\ubdf0\ud560 \ub54c \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub824\ubd10\uc57c\ud558\ub294 \uadc0\ucc2e\uc74c\uc774 \uc0dd\uae38 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc0ac\ub78c\uc740 \uc2e4\uc218\ud574\ub3c4 \uae30\uacc4\ub294 \uac70\uc9d3\ub9d0\uc744 \ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc790\ub3d9\uc73c\ub85c \ub9c9\ub3c4\ub85d \ub3d9\uc791\ud558\uac8c \ub9cc\ub4e4\uc5b4\ub193\uc73c\uba74 \uadf8\ub7f4 \uc77c\uc744 \ubbf8\uc5f0\uc5d0 \ubc29\uc9c0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### Environments \ud655\uc778\ud558\uae30\\n\uba3c\uc800 \ud574\ub2f9 Repository\uc758 Settings -> Environments \ud0ed\uc73c\ub85c \ub4e4\uc5b4\uac11\ub2c8\ub2e4.\\n![environments](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/4e3e867a-1037-46bc-865a-6d7d52527518)\\n\uc544\uae4c **environment** \uc18d\uc131\uc744 \ubcf4\uba74 `test`\ub77c\uace0 \uc124\uc815\ud574\ub193\uc740 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ud658\uacbd\uc774 \uc5ec\uae30\uc5d0 \uc801\uc6a9\ub429\ub2c8\ub2e4.\\n\\n### Branch rule \uc815\uc758\ud558\uae30\\n\uc774\ubc88\uc5d0\ub294 \ud574\ub2f9 Repository\uc758 Settings -> Branches \ud0ed\uc73c\ub85c \ub4e4\uc5b4\uac11\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc6d0\ud558\ub294 branch\uc5d0 \ub4e4\uc5b4\uac00 `edit` \ubc84\ud2bc\uc744 \ub204\ub985\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc0ac\uc9c4\uacfc \uac19\uc774 **Require deployments to succeed before merging** \uc18d\uc131\uc744 \ud074\ub9ad\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc544\ub798\uc640 \uac19\uc774 \uc5b4\ub5a4 \ud658\uacbd\uc744 \uc801\uc6a9\ud560 \uac83\uc778\uc9c0 \uc120\ud0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc18d\uc131\uc740 \ud574\ub2f9 \ubc30\ud3ec\uac00 \uc131\uacf5\ud574\uc57c merge \ud560 \uc218 \uc788\ub3c4\ub85d \ube0c\ub79c\uce58\ub97c \ubcf4\ud638\ud558\ub294 \uae30\ub2a5\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc800\ud76c\ub294 frontend\uc640 backend Job\uc758 \ud658\uacbd\uc744 \ub458 \ub2e4 test\ub77c\ub294 \uc774\ub984\uc73c\ub85c \uc815\uc758\ud588\uae30 \ub54c\ubb38\uc5d0 \ud558\ub098\uc758 environment\ub9cc \uc120\ud0dd\ud574\ub3c4 \ub458 \ub2e4 \uc801\uc6a9\ub418\ub294 \ud6a8\uacfc\ub97c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n![branch rule](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/02be4679-56a2-4e47-ae01-b7025f8778a4)\\n\\n#### \uc801\uc6a9 \ud6c4\\n\\n\uc544\ub798\uc640 \uac19\uc774 merge\uac00 \uc548\ub41c\ub2e4\ub294 \uae00\uacfc \ube68\uac04\uc0c9\uc73c\ub85c \uacbd\uace0 \ud45c\uc2dc\ub97c \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n![blocked](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/7dfba566-c8c8-4a24-a0e3-42081f3af31c)\\n\\n\\n## \uacb0\ub860\\n\\n\uac04\ub2e8\ud55c github action\uc744 \ud1b5\ud574\uc11c \uc0dd\uc0b0\uc131\uc744 \ub9ce\uc774 \uc62c\ub9b4 \uc218 \uc788\ub294 \uc88b\uc740 \uae30\ub2a5\uc778 \uac83 \uac19\uc2b5\ub2c8\ub2e4. \ub2e4\ub978 \ud300\ub4e4\ub3c4 \uc774 \uae30\ub2a5\uc744 \ub3c4\uc785\ud558\uc5ec \uc0ac\uc6a9\ud558\ub294 \uac83\uc744 \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4."},{"id":"10","metadata":{"permalink":"/10","source":"@site/blog/2023-07-09-msw-setup-with-webpack.mdx","title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","description":"\uc6f9\ud329\uc5d0\uc11c msw \uc124\uc815","date":"2023-07-09T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 9\uc77c","tags":[{"label":"msw","permalink":"/tags/msw"},{"label":"webpack","permalink":"/tags/webpack"}],"readingTime":4.35,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"10","title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","authors":["scent"],"tags":["msw","webpack"]},"prevItem":{"title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","permalink":"/9"},"nextItem":{"title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","permalink":"/8"}},"content":"## \uc6f9\ud329\uc5d0\uc11c msw \uc124\uc815\\n\\n\uc774\ubc88 \ud300 \ud504\ub85c\uc81d\ud2b8\ub294 CRA\uc640 \uac19\uc740 \ubcf4\uc77c\ub7ec \ud50c\ub808\uc774\ud2b8 \ucf54\ub4dc\ub97c \uc0ac\uc6a9\ud558\uc9c0 \ubabb\ud558\uac8c \uc81c\ud55c\uc774 \uc788\ub2e4. \ub610\ud55c \uc694\uc998 \ub9ce\uc774 \uc0ac\uc6a9\ub41c\ub2e4\ub294 Vite\uc758 \uc0ac\uc6a9\ub3c4 \uc81c\ud55c\uc774 \uc788\uace0, \uc6f9\ud329\uc73c\ub85c \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud558\ub3c4\ub85d \uac15\uc81c\ud558\uace0 \uc788\ub2e4.\\n\\n\ud300\uc6d0 \ubaa8\ub450 \ud55c \ubc88\ub3c4 \uc6f9\ud329\uc744 \ud1b5\ud574 \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud574\ubcf8 \uacbd\ud5d8\uc774 \uc5c6\uc5b4 \ud504\ub860\ud2b8\uc5d4\ub4dc \ud300\uc6d0 \uac01\uc790 \uac1c\uc778 \ub808\ud3ec\uc5d0\uc11c \uc6f9\ud329 \uacf5\ubd80\ub97c \uc9c4\ud589\ud55c \ud6c4 \uc5b4\ub290\uc815\ub3c4 \uc9c4\ucc99\uc774 \uc788\uc744 \ub54c \ud300 \ub808\ud3ec\uc5d0 \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud558\uae30\ub85c \ud588\ub2e4.\\n\\n\ub2e4\ud589\ud788 \uc6f9\ud329\uc73c\ub85c \uc2dc\uc791\ud558\ub294 \ud504\ub85c\uc81d\ud2b8\uc5d0 \ub300\ud55c \ub9ce\uc740 \ucc38\uace0 \uc790\ub8cc\ub4e4\uc774 \uc788\uc5b4 \uccab \ub9ac\uc561\ud2b8 \ud504\ub85c\uc81d\ud2b8 \ud654\uba74\uc744 \ub744\uc6b0\ub294\ub370 \uae4c\uc9c0\ub294 \uadf8\ub9ac \uc624\ub79c \uc2dc\uac04\uc774 \uac78\ub9ac\uc9c0 \uc54a\uc558\ub2e4. \uadf8\ub807\uac8c \ubaa8\ub4e0 \ud300\uc6d0\uc774 \uccab \uc6f9\ud329 \ud504\ub85c\uc81d\ud2b8\ub97c \uc131\uacf5\uc2dc\ud0a8 \ud6c4 \ubaa8\uc5ec \ud300 \ud504\ub85c\uc81d\ud2b8 \ucd08\uae30 \uc124\uc815\uc744 \uc2dc\uc791\ud574\ubcf4\uc558\ub2e4.\\n\\neslint, prettier, \uc6f9\ud329 \ub4f1\ub4f1 \uc5ec\ub7ec \uc124\uc815\ub4e4\uc744 \ud558\uace0 \ud544\uc694\ud55c \ud328\ud0a4\uc9c0\ub97c \uc124\uce58\ud558\ub294\ub370 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud588\ub2e4. \ud070 \ub370\uc774\ud130\ub97c \ub2e4\ub8e8\ub294 \ubc31\uc5d4\ub4dc\uc758 \uac1c\ubc1c \uc18d\ub3c4\ub97c \uace0\ub824\ud574 \ud504\ub860\ud2b8\uc5d4\ub4dc \uac1c\ubc1c\uc744 \uc9c4\ud589\ud558\uae30 \uc704\ud574\uc11c \ubbf8\uc158\uc911\uc5d0 \ubc30\uc6e0\ub358 MSW \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uae30\ub85c \uacb0\uc815\ud588\ub294\ub370, \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \uc6b0\ub9ac \ud300\uc758 \uac1c\ubc1c \ud658\uacbd\uc5d0\uc11c \ub3d9\uc791\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n\\n\uc65c \ub3d9\uc791\ud558\uc9c0 \uc54a\ub294\uc9c0 \uc6d0\uc778\uc744 \ucc3e\uc544\ubcf4\ub2c8 MSW service worker \ud30c\uc77c\uc744 \ucc3e\uc744 \uc218 \uc5c6\ub2e4\ub294 \uc624\ub958 \uba54\uc138\uc9c0\uac00 \ub098\uc624\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\ub2e4. \uc6d0\uc778\uc744 \ub354 \uc790\uc138\ud788 \uc54c\uc544\ubcf4\ub2c8 public \ud3f4\ub354\uc5d0 \uc788\ub294 \ud30c\uc77c\ub4e4\uc740 \uc6f9\ud329\uc774 \ubc88\ub4e4\ub9c1\uc744 \uc9c4\ud589\ud560 \ub54c \ud3ec\ud568\uc774 \ub418\uc9c0 \uc54a\ub294\ub2e4\ub294 \uac83\uc744 \uc54c \uc218 \uc788\uc5c8\uace0, \uc774\ub97c \uc5b4\ub5bb\uac8c \ud574\uacb0\ud560 \uc9c0 \ud300\uc6d0\ub4e4\uacfc \ubc29\ubc95\uc744 \ucc3e\uc544\ubcf4\uc558\ub2e4.\\n\\n\uc57d \ud55c\uc2dc\uac04\ucbe4 \uc9c0\ub0ac\uc744 \ubb34\ub835 copy-webpack-plugin \ud328\ud0a4\uc9c0\ub97c \ud1b5\ud574 public \uacbd\ub85c\uc5d0 \uc788\ub294 \ud30c\uc77c\ub4e4\ub3c4 \ube4c\ub4dc \ud3f4\ub354\uc5d0 \ud3ec\ud568\uc2dc\ud0ac \uc218 \uc788\ub2e4\ub294 \uac83\uc744 \uc54c\uac8c \ub418\uc5c8\ub2e4. \ud558\uc9c0\ub9cc \uc774 copy-webpack-plugin\uc5d0 \ub300\ud55c \uc0ac\uc6a9\ubc95\uc774 \ubbf8\uc219\ud574 public \ud3f4\ub354\uc5d0 \uc788\ub294 mockServiceWorker.js \ud30c\uc77c\ub9cc \ube4c\ub4dc \ud3f4\ub354\ub85c \uc62e\uacbc\uc5b4\uc57c \ud588\ub294\ub370 index.html\uacfc \uac19\uc740 \ub2e4\ub978 \ud30c\uc77c\ub4e4 \uae4c\uc9c0 \ud55c\uaebc\ubc88\uc5d0 \ube4c\ub4dc \ud3f4\ub354\ub85c \uc62e\uaca8\uc9c0\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uc774\ub7f0 \uc800\ub7f0 \ubc29\ubc95\ub4e4\uc744 \uc2dc\ub3c4\ud574\ubcf4\ub2e4 webpack.config.js \ud30c\uc77c\uc758 plugins\uc5d0 \uc544\ub798\uc640 \uac19\uc740 \uc124\uc815\uc744 \ucd94\uac00 \ud574\uc8fc\uc5b4 MSW\ub97c \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc801\uc6a9\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\ub2e4.\\n\\n```jsx\\nnew CopyWebpackPlugin({\\n patterns: [\\n { from: \'public/mockServiceWorker.js\', to: \'.\' }, // msw service worker\\n ],\\n}),\\n```\\n\\n\uc124\uc815\uc744 \uac04\ub2e8\ud788 \ubcf4\uba74 public \uacbd\ub85c\uc5d0 \uc788\ub294 mockServiceWorker.js \ud30c\uc77c\uc744 \ube4c\ub4dc \ud6c4 \ud3f4\ub354\uc758 \ub8e8\ud2b8 \ub514\ub809\ud1a0\ub9ac\uc5d0 \ucd94\uac00\ud574\uc900\ub2e4\ub294 \uc124\uc815\uc774\ub2e4.\\n\\n\ubb38\uc81c \uc0c1\ud669\uacfc \ud574\uacb0 \ubc29\ubc95\uc744 \uac04\ub2e8\ud558\uac8c \ub2e4\uc2dc \uc815\ub9ac\ud574\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n1. MSW\ub97c \uc801\uc6a9\ud574\ubcf4\ub824\uace0 \ud568.\\n2. \uc6f9\ud329\uc5d0\uc11c \uac1c\ubc1c \uc11c\ubc84\ub97c \uc5f4\uc5c8\uc744 \ub54c MSW \uc2e4\ud589\uc744 \uc704\ud574 \ud544\uc694\ud55c mockServiceWorker.js \ud30c\uc77c\uc744 \ucc3e\uc744 \uc218 \uc5c6\ub2e4\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud568.\\n3. \ubb38\uc81c\uc758 \uc6d0\uc778\uc740 \uc6f9\ud329\uc5d0\uc11c \ubc88\ub4e4\ub9c1\uc744 \uc9c4\ud589\ud560 \ub54c public \ud3f4\ub354 \ud558\uc704 \uacbd\ub85c\uc5d0 \uc788\ub294 \ud30c\uc77c\ub4e4\uc744 \ubb34\uc2dc\ud558\uae30 \ub54c\ubb38\uc774\uc5c8\uc74c.\\n4. \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 public \uacbd\ub85c\uc5d0 \uc788\ub294 mockServiceWorker.js \ud30c\uc77c\uc744 \ubc88\ub4e4\ub9c1 \ud6c4 \ud3f4\ub354\uc758 \ub8e8\ud2b8 \ub514\ub809\ud1a0\ub9ac\uc5d0 \uc800\uc7a5\ud558\ub3c4\ub85d \ud558\ub294 \uc124\uc815\uc744 \ucd94\uac00\ud574\uc90c."},{"id":"8","metadata":{"permalink":"/8","source":"@site/blog/2023-07-07-error-slack-notification.mdx","title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","description":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 nunu\uc785\ub2c8\ub2e4.","date":"2023-07-07T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 7\uc77c","tags":[{"label":"spring","permalink":"/tags/spring"},{"label":"slack","permalink":"/tags/slack"},{"label":"error","permalink":"/tags/error"}],"readingTime":11.83,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"8","title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","authors":["nunu"],"tags":["spring","slack","error"]},"prevItem":{"title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","permalink":"/10"},"nextItem":{"title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","permalink":"/7"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 nunu\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\ubaa9\ucc28\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uadf8\ub97c \ub0a8\uae30\ub294 \ubc29\ubc95\\n2. Slf4 j\uc758 \ub3d9\uc791\uc6d0\ub9ac\\n3. Logback\uc758 \ub3d9\uc791\uc6d0\ub9ac\\n4. Logback\uc744 \uc0ac\uc6a9\ud574\uc11c \uc2ac\ub799\uc73c\ub85c \uc5d0\ub7ec \ub85c\uadf8\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95\\n\\n## \uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uadf8\ub294 \uc5b4\ub5bb\uac8c \ucc0d\uc744\uae4c?\\n\\n\uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uadf8\ub97c \ucc0d\ub294 \ubc29\ubc95\uc740 \uc5ec\ub7ec \uac00\uc9c0\uac00 \uc788\uc9c0\ub9cc, \uac00\uc7a5 \uac04\ub2e8\ud55c \ubc29\ubc95\uc740 `System.out.println()`\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\\n```java\\n@RestController\\npublic class TestController {\\n\\n @GetMapping(\\"/test\\")\\n public String test() {\\n System.out.println(\\"test\\");\\n return \\"test\\";\\n }\\n}\\n```\\n\\n\ub2f9\uc5f0\ud558\uc9c0\ub9cc, \uc131\ub2a5\uc774 \uc548 \uc88b\uc544\uc11c \uc2e4\uc81c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\uc2a4\ud504\ub9c1\uc5d0\uc11c\ub294 Slf4 j\ub97c \ud1b5\ud574\uc11c \ub85c\uadf8\ub97c \ub0a8\uae38 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); \uc640 \uac19\ub2e4.\\n@RestController\\npublic class TestController {\\n\\n @GetMapping(\\"/test\\")\\n public String test() {\\n log.info(\\"test\\");\\n return \\"test\\";\\n }\\n}\\n```\\n\\n\uc774 \ucf54\ub4dc\ub97c \ud1b5\ud574\uc11c \ub85c\uadf8\ub97c \ub0a8\uae38 \uc218 \uc788\ub294\ub370, \uc790\ub3d9\uc73c\ub85c \ucf58\uc194\uc5d0 \ucd9c\ub825\uc774 \ub429\ub2c8\ub2e4.\\n\\n## \uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uae45\uc740 \uc5b4\ub5bb\uac8c \uc791\ub3d9\ud558\ub294 \uac70\uc9c0?\\n\\n\uc2a4\ud504\ub9c1 4\uae4c\uc9c0\ub294 `Commons Logging`\uc744 \uc0ac\uc6a9\ud588\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n`Commons Logging`\uc740 `JCL`\uc774\ub77c\uace0\ub3c4 \ubd88\ub9ac\uba70, `JDK Logging`, `Log4 j,` `Logback` \ub4f1 \ub2e4\uc591\ud55c \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc9c0\uc6d0\ud569\ub2c8\ub2e4.\\n\\nJCL \uc740 \ub7f0\ud0c0\uc784\uc5d0 \uc5b4\ub5a4 \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub7f0\ud0c0\uc784\uc5d0 \uc5b4\ub5a4 \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \ud074\ub798\uc2a4 \ub85c\ub354\uc5d0\uac8c \uc9c8\uc758\ub97c \ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \uc791\ub3d9\ud558\uac8c \ub418\ub294\ub370\\n\\n\ud074\ub798\uc2a4 \ub85c\ub354\uc5d0\uac8c \uc9c8\uc758\ub97c \ud588\uc744 \uacbd\uc6b0\uc5d0 \uba87 \uac00\uc9c0 \ubb38\uc81c\uc810\uc774 \uc0dd\uae41\ub2c8\ub2e4\\n\\n1. \ud074\ub798\uc2a4 \ub85c\ub354\uc5d0 \uba85\ud655\ud55c \ud45c\uc900\uc774 \uc5c6\uace0, \ubd80\ubaa8 \uc790\uc2dd \ubaa8\ub378\uc774 \uc788\uc5b4\uc11c, \ud074\ub798\uc2a4 \ub85c\ub354\uc5d0 \ub530\ub77c\uc11c \ub2e4\ub978 \uacb0\uacfc\uac00 \ub098\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4. [\ucc38\uace0](http://articles.qos.ch/classloader.html)\\n2. \ud074\ub798\uc2a4\ub85c\ub354\ub294 gc\uc758 \ub3d9\uc791\uc5d0 \ubc29\ud574\ub97c \uc77c\uc73c\ucf1c\uc11c \uba54\ubaa8\ub9ac \ub204\uc218\ub97c \ubc1c\uc0dd\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4. [\ucc38\uace0](https://cwiki.apache.org/confluence/display/COMMONS/Logging+UndeployMemoryLeak)\\n\\n`@Slf4j` \uc5b4\ub178\ud14c\uc774\uc158\uc744 \ubd99\uc774\uba74, \ucef4\ud30c\uc77c \uc2dc\uc810\uc5d0 `private final Logger log = LoggerFactory.getLogger(this.getClass());` \uc640 \uac19\uc740 \ucf54\ub4dc\ub85c \ubcc0\ud658\ub429\ub2c8\ub2e4.\\n\\n\uc2a4\ud504\ub9c1 5\uc5d0\uc11c\ub294 Slf4j \uac00 \uc0ac\uc6a9\ud558\ub294 \uac83\ucc98\ub7fc, \ucef4\ud30c\uc77c \ud0c0\uc784\uc5d0 \uc5b4\ub5a4 \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud558\ub294 \uae30\ub2a5\uc744 \uc791\uc131\ud588\uace0, `Commons Logging`\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n[spring 5\uc5d0\uc11c \ubcc0\uacbd\ub418\uc5c8\ub2e4\ub294 \ub9c1\ud06c](https://docs.spring.io/spring-framework/docs/5.0.0.RC3/spring-framework-reference/overview.html#overview-logging)\\n\\n## Slf4 j\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\uc790\\n\\nSlf4 j\ub294 \ub85c\uae45\uc744 \uc704\ud55c \uc778\ud130\ud398\uc774\uc2a4\ub97c \uc81c\uacf5\ud558\ub294 \ud504\ub808\uc784\uc6cc\ud06c\uc785\ub2c8\ub2e4.(Simple Logging Facade for Java)\\n\\n\ucef4\ud30c\uc77c \ud0c0\uc784\uc5d0, \uc5b4\ub5a4 \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud558\ub294 \uae30\ub2a5\uc744 \uc81c\uacf5\ud569\ub2c8\ub2e4.\\n\\n\ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \ubc14\uafb8\ub824\uace0 \ud588\uc744 \ub54c, \uae30\uc874 \ucf54\ub4dc\ub294 \ud558\ub098\ub3c4 \uac74\ub4dc\ub9ac\uc9c0 \uc54a\uace0, \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub9cc \ubc14\uafd4\uc8fc\uba74 \ub418\ub3c4\ub85d \ud574\uc90d\ub2c8\ub2e4.\\n\\n### \uc870\uae08 \ub354 \uc790\uc138\ud55c \ub3d9\uc791 \uc6d0\ub9ac\ub97c \uc54c\uc544\ubcf4\uc790\\n\\n![only slf4j](https://blog.kakaocdn.net/dn/lCcTc/btsmBw3OEJz/1njLV283KdUWc9qyppEdak/img.png)\\n\\nSlf4 j \ub9cc\uc744 \uc0ac\uc6a9\ud588\uc744 \uacbd\uc6b0 \uc704 \uc0ac\uc9c4 \uac19\uc740 \ud615\ud0dc\ub85c \uc694\uccad\uc774 \ucc98\ub9ac\uac00 \ub429\ub2c8\ub2e4.\\n\\nSlf4 j \ub77c\ub294 \uc778\ud130\ud398\uc774\uc2a4\ub97c \ud1b5\ud574\uc11c \ub85c\uadf8\ub97c \ub0a8\uae30\uace0, \uc5b4\ub5a4 \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud560\uc9c0\ub294 `Slf4j binding`\uc774\ub77c\ub294 \uac83\uc744 \ud1b5\ud574\uc11c \uacb0\uc815\ud569\ub2c8\ub2e4.\\n\\n`Slf4j binding` \uc740 `Slf4j`\uc758 \uc778\ud130\ud398\uc774\uc2a4\ub97c \uad6c\ud604\ud558\uace0 \uc788\uc9c0 \uc54a\uc740 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 \uad6c\ud604\uccb4\ub97c \uc5f0\uacb0\ud574 \uc8fc\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n\uadf8 \uad6c\ud604\uccb4\ub85c `Slf4j-log4 j12-{version}. jar` \uac19\uc740 \uac83\uc774 \uc788\ub2e4.\\n\\n\uc774\uc640\ub294 \ub2e4\ub974\uac8c Logback \uc740 Slf4 j \ub97c \uad6c\ud604\ud558\uace0 \uc788\uae30\uc5d0, `Slf4j binding` \uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.\\n\\n![logback example](https://blog.kakaocdn.net/dn/IYC3k/btsmy0einLF/F0aiMnteJeGB00fkGdBjRK/img.png)\\n\\n\uc704 \uc0ac\uc9c4\ucc98\ub7fc `Slf4j binding` \uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0, `Logback` \ubc14\ub85c \uc0ac\uc6a9\ud558\ub294 \uac83\ub3c4 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 Slf4 j\ub97c \ubc14\ub85c \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc740 \ucf54\ub4dc\uc5d0\uc11c `Slf4j` \ub97c \uc0ac\uc6a9\ud558\ub824\uba74 \uc5b4\ub5bb\uac8c \ud574\uc57c \ud560\uae4c\uc694?\\n\\n![slf4j working principle](https://blog.kakaocdn.net/dn/msTPw/btsmziy04VE/sXSOKYvi9yXSoiRmg6mIGk/img.png)\\n\\n\uc704 \uc0ac\uc9c4\ucc98\ub7fc `Slf4j bridge` \ub97c \ud1b5\ud574\uc11c \uc678\ubd80 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\ucc98\ub7fc \uac08\uc544 \ub07c\uc6b8 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n`Log4j2` \ub97c \uc0ac\uc6a9\ud558\ub294 \ucf54\ub4dc\ub97c \uc804\ud600 \ubc14\uafb8\uc9c0 \uc54a\uc544\ub3c4, `Bridge` \uac00 `Slf4j` \ub97c \ud1b5\ud574 `Logback`\uc73c\ub85c \uc790\uc5f0\uc2a4\ub7fd\uac8c \ub85c\uadf8\ub97c \ub0a8\uae38 \uc218 \uc788\ub3c4\ub85d \ud574\uc90d\ub2c8\ub2e4.\\n\\n## Logback\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\uc790\\n\\nLogback \uc740 \uc2a4\ud504\ub9c1\uc5d0\uc11c \uae30\ubcf8\uc73c\ub85c \uc0ac\uc6a9\ub420 \ub9cc\ud07c \uc778\uae30 \uc788\ub294 \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\\n![logback \ub3d9\uc791 \uacfc\uc815](https://logback.qos.ch/manual/images/chapters/architecture/underTheHoodSequence2_small.gif)\\n\\n\uacf5\uc2dd\ubb38\uc11c\uc5d0\uc11c \uc544\uc8fc \ud575\uc2ec\uc801\uc778 \ub3d9\uc791\uc6d0\ub9ac\ub97c \uc124\uba85\ud574\uc8fc\uace0 \uc788\ub294 \uc0ac\uc9c4\uc774\ub77c\uc11c \uac00\uc838\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\ub108\ubb34 \uc5b4\ub824\uc6cc \ubcf4\uc5ec\uc11c, \uc870\uae08 \uc790\uc138\ud558\uac8c \uac01\uac01\uc758 \uad6c\uc131\uc694\uc18c\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n\uc774\uc5d0 \ub300\ud574 \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n## \ub85c\uadf8\ubc31\uc758 \uad6c\uc131\uc694\uc18c\\n\\n### Appender\\n\\nAppender\ub294 \ub85c\uadf8\ub97c \uc5b4\ub514\uc5d0 \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n\uc678\ubd80\ub85c\ubd80\ud130 \uc5b4\ub5a4 \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc11c, \uc5b4\ub5a4 \ubc29\uc2dd\uc73c\ub85c \ucc98\ub9ac\ud560\uc9c0\uc5d0 \ub300\ud574\uc11c \uc804\uccb4\uc801\uc73c\ub85c \uc124\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uae30\ubcf8\uc801\uc73c\ub85c \uc218\ub9ce\uc740 Appender \uac00 \uc81c\uacf5\ub418\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n- ConsoleAppender\\n- FileAppender\\n- RollingFileAppender\\n- AsyncAppender\\n- DBAppender\\n- SMTPAppender\\n- SocketAppender\\n- SyslogAppender\\n\\n\uc800\ud76c\ub294 Slack\uc5d0 \uc54c\ub9bc\uc744 \uc8fc\ub294 \uac83\uc774 \ubaa9\uc801\uc774\uae30 \ub54c\ubb38\uc5d0, SlackAppender\ub97c \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc SlackAppender\ub294 \uc81c\uacf5\ub418\uace0 \uc788\uc9c0 \uc54a\uae30\uc5d0 \uc9c1\uc811 \uad6c\ud604\uc744 \ud574\uc57c \ud558\ub294\ub370\uc694\\n\\n\uc774\ub97c \uad6c\ud604\ud588\uc744 \ub54c, Slack API \uac00 \ub05d\ub0a0 \ub54c\uae4c\uc9c0, \uacc4\uc18d \uae30\ub2e4\ub9ac\uace0 \uc788\uc744 \ud544\uc694\uac00 \uc5c6\uae30\uc5d0, AsyncAppender\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9 \ubc29\ubc95\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4. xml \uae30\ubc18\uc73c\ub85c \uac00\ub2a5\ud55c\ub370\uc694\\n\\n```xml\\n\\n \\n myapp.log\\n \\n %logger{35} -%kvp -%msg%n\\n \\n \\n\\n \\n \\n \\n\\n \\n \\n \\n\\n```\\n\\n\ub9cc\uc57d \uc5ec\uae30\uc5d0 \uc788\ub294 \uae30\ub2a5\ub4e4\ub85c \ubd80\uc871\ud558\ub2e4\uba74, \uc9c1\uc811 Appender \ub97c \uad6c\ud604\ud574\uc11c \uc0ac\uc6a9\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c1\uc811 \uad6c\ud604\ud558\ub824\uba74 AppenderBase\ub97c \uc0c1\uc18d\ubc1b\uc544\uc11c \uad6c\ud604\ud558\uba74 \ub429\ub2c8\ub2e4.\\n\\n\uc774 \ud074\ub798\uc2a4\ub294 \ud544\uc694\ud55c \ubd80\ubd84\uc774 \ub300\ubd80\ubd84 \uad6c\ud604\ub418\uc5b4 \uc788\uace0, appender \ub9cc \uad6c\ud604\ud558\uba74 \ubc14\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub2f9\uc5f0\ud558\uc9c0\ub9cc \ud544\uc694\ud558\ub2e4\uba74 override \ub3c4 \uac00\ub2a5\ud558\uc8e0\\n\\n### Layout\\n\\nLayout \uc740 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \ud615\uc2dd\uc73c\ub85c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nAppender\ub294 \ub85c\uadf8\ub97c \uc5b4\ub514\uc5d0 \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud558\uace0, Layout \uc740 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \ud615\uc2dd\uc73c\ub85c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud558\ub3c4\ub85d \ud558\ub294 \uac83\uc774 \uc774\uc0c1\uc801\uc774\uc9c0\ub9cc\\n\\nLogback \uc740 Appender\uc5d0\uc11c Layout \uc744 \uc9c1\uc811 \uc9c0\uc815\ud560 \uc218 \uc788\ub3c4\ub85d \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c, \uc9c1\uc811 Layout \uc744 \ub9cc\ub4e4\uc9c0 \uc54a\uace0, Appender \uc5d0\uc11c \uae30\uc874\uc5d0 \uc774\ubbf8 \uc788\ub294 \ud328\ud134\ub9cc \uc0ac\uc6a9\ud558\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n### Encoder\\n\\nEncoder\ub294 Layout \uacfc \ube44\uc2b7\ud55c \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nLayout \uc740 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \ud615\uc2dd\uc73c\ub85c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud558\uace0, Encoder \ub294 \uc2e4\uc81c byte \ud615\ud0dc\ub85c \ubcc0\ud658\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nSlack\uc758 webhook\uc744 \uc0ac\uc6a9\ud560 \uac83\uc774\uc9c0\ub9cc, AppenderBase\ub97c \uc0ac\uc6a9\ud558\uae30\uc5d0, \uc774\ubc88\uc5d0\ub294 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n### Filter\\n\\nFilter\ub294 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \uc870\uac74\uc5d0 \ub530\ub77c\uc11c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nFilter \ub294 Appender\ub97c \ub4f1\ub85d\ud558\uba70 \uac19\uc774 \ub4f1\ub85d\ud560 \uc218 \uc788\ub294\ub370\uc694\\n\\n\uc774\ubc88 \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c\ub294 Level \uc774 ERROR \uc774\uc0c1\uc778 \uac83\ub9cc \ucd9c\ub825\ud558\ub3c4\ub85d \ud558\uace0 \uc2f6\uae30\uc5d0, LevelFilter\ub97c \uc0ac\uc6a9\ud558\uba74 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```xml\\n\\n \\n \\n INFO\\n ACCEPT\\n DENY\\n \\n \\n \\n %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n\\n \\n \\n \\n \\n \\n \\n\\n```\\n\\n\uc640 \ube44\uc2b7\ud558\uac8c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5b4 \ubcf4\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc2e4\uc81c\ub85c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c error \ubc1c\uc0dd \uc2dc slack\uc73c\ub85c \uc54c\ub9bc\uc744 \uc8fc\ub294 \uac83\uc744 \uad6c\ud604\ud574 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## \uc2ac\ub799\uc5d0 \ucd94\uac00\ud558\ub294 \ubc29\ubc95\\n\\n[\uc774 \ube14\ub85c\uadf8](https://velog.io/@king/slack-incoming-webhook)\ub97c \ubcf4\uace0\uc11c \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4\\n\\n## \uc2e4\uc81c \uad6c\ud604\\n\\n\uad6c\ud604\ub41c \uacb0\uacfc\ubb3c\uc740 \uc544\ub798\uc640 \uac19\uc2b5\ub2c8\ub2e4\\n\\n![slack appender](https://blog.kakaocdn.net/dn/d3z7QG/btsmQCCV69f/NwiyNhQGZOBnKBP2hT8kf0/img.png)\\n\\n### SlackAppender \uad6c\ud604\ud558\uae30\\n\\n```java\\npublic class SlackAppender extends AppenderBase {\\n\\n @Override\\n protected void append(final ILoggingEvent eventObject) {\\n final var restTemplate = new RestTemplate();\\n final var url = \\"https://hooks.slack.com/services/\\";\\n final Map body = createSlackErrorBody(eventObject);\\n restTemplate.postForEntity(url, body, String.class);\\n }\\n\\n private Map createSlackErrorBody(final ILoggingEvent eventObject) {\\n final String message = createMessage(eventObject);\\n return Map.of(\\n \\"attachments\\", List.of(\\n Map.of(\\n \\"fallback\\", \\"\uc694\uccad\uc744 \uc2e4\ud328\ud588\uc5b4\uc694 :cry:\\",\\n \\"color\\", \\"#2eb886\\",\\n \\"pretext\\", \\"\uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uc5b4\uc694 \ud655\uc778\ud574\uc8fc\uc138\uc694 :cry:\\",\\n \\"author_name\\", \\"car-ffeine\\",\\n \\"text\\", message,\\n \\"fields\\", List.of(\\n Map.of(\\n \\"title\\", \\"\uc6b0\uc120\uc21c\uc704\\",\\n \\"value\\", \\"High\\",\\n \\"short\\", false\\n ),\\n Map.of(\\n \\"title\\", \\"\uc11c\ubc84 \ud658\uacbd\\",\\n \\"value\\", \\"local\\",\\n \\"short\\", false\\n )\\n ),\\n \\"ts\\", eventObject.getTimeStamp()\\n )\\n )\\n );\\n }\\n\\n private String createMessage(final ILoggingEvent eventObject) {\\n final String baseMessage = \\"\uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\\\n\\";\\n final String pattern = baseMessage + \\"```%s %s %s [%s] - %s```\\";\\n final SimpleDateFormat simpleDateFormat = new SimpleDateFormat(\\"yyyy-MM-dd HH:mm:ss.SSS\\");\\n return String.format(pattern,\\n simpleDateFormat.format(eventObject.getTimeStamp()),\\n eventObject.getLevel(),\\n eventObject.getThreadName(),\\n eventObject.getLoggerName(),\\n eventObject.getFormattedMessage());\\n }\\n}\\n```\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c url\uc744 \uc9c1\uc811 \uc785\ub825\ud558\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0, \uc774\ub807\uac8c \ub9cc\ub4e0 SlackAppender\ub97c logback-spring.xml \uc5d0 \ub4f1\ub85d\ud558\uba74 \ub429\ub2c8\ub2e4.\\n\\n```xml\\n\\n\\n\\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n\\n \\n\\n\\n```\\n\\n\uc774\ub807\uac8c \ud558\uba74, racingcar \ud328\ud0a4\uc9c0\uc5d0\uc11c \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \ub54c\ub9cc slack\uc73c\ub85c \uc54c\ub9bc\uc744 \ubc1b\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\n![slack appender](https://blog.kakaocdn.net/dn/d3z7QG/btsmQCCV69f/NwiyNhQGZOBnKBP2hT8kf0/img.png)\\n\\n\uc774\ubc88 \uae00\uc5d0\uc11c\ub294 log \ub808\ubca8\uc5d0 \ub530\ub77c slack \uc73c\ub85c \uc54c\ub9bc\uc744 \ubc1b\ub294 \ubc29\ubc95\uc744 \uc54c\uc544\ubcf4\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\uae34 \uae00\uc744 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4"},{"id":"7","metadata":{"permalink":"/7","source":"@site/blog/2023-07-06-auto-issue-number-commit-msg.mdx","title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","description":"\ud504\ub85c\uc81d\ud2b8 \ube0c\ub79c\uce58\uba85 \ucee8\ubca4\uc158\uc774 feat/\uc774\uc288\ubc88\ud638\uc5ec\uc11c, \ube0c\ub79c\uce58\uba85\uc5d0\uc11c \uc774\uc288\ubc88\ud638\ub9cc \uac00\uc838\uc628 \ub2e4\uc74c \ucee4\ubc0b\ud560 \ub54c\ub9c8\ub2e4 \ucee4\ubc0b \uba54\uc2dc\uc9c0 \uc544\ub798\ub2e8(footer)\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud574\uc8fc\uace0 \uc2f6\uc5c8\ub2e4. \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ub41c\ub2e4\uba74 \uae5c\ube61\ud558\uace0 \uc774\uc288 \ubc88\ud638\ub97c \uc548 \uc801\ub294 \uc77c\ub3c4 \uc5c6\uace0, \uc2dc\uac04\ub3c4 \ub2e8\ucd95\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc774\ub2e4.","date":"2023-07-06T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 6\uc77c","tags":[{"label":"git","permalink":"/tags/git"},{"label":"commit","permalink":"/tags/commit"},{"label":"message","permalink":"/tags/message"},{"label":"issue","permalink":"/tags/issue"},{"label":"auto","permalink":"/tags/auto"}],"readingTime":2.89,"hasTruncateMarker":false,"authors":[{"name":"\uc57c\ubbf8","title":"Frontend","url":"https://github.com/feb-dain","imageURL":"https://github.com/feb-dain.png","key":"yummy"}],"frontMatter":{"slug":"7","title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","authors":["yummy"],"tags":["git","commit","message","issue","auto"]},"prevItem":{"title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","permalink":"/8"},"nextItem":{"title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","permalink":"/6"}},"content":"\ud504\ub85c\uc81d\ud2b8 \ube0c\ub79c\uce58\uba85 \ucee8\ubca4\uc158\uc774 feat/\uc774\uc288\ubc88\ud638\uc5ec\uc11c, \ube0c\ub79c\uce58\uba85\uc5d0\uc11c \uc774\uc288\ubc88\ud638\ub9cc \uac00\uc838\uc628 \ub2e4\uc74c \ucee4\ubc0b\ud560 \ub54c\ub9c8\ub2e4 \ucee4\ubc0b \uba54\uc2dc\uc9c0 \uc544\ub798\ub2e8(footer)\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud574\uc8fc\uace0 \uc2f6\uc5c8\ub2e4. \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ub41c\ub2e4\uba74 \uae5c\ube61\ud558\uace0 \uc774\uc288 \ubc88\ud638\ub97c \uc548 \uc801\ub294 \uc77c\ub3c4 \uc5c6\uace0, \uc2dc\uac04\ub3c4 \ub2e8\ucd95\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc774\ub2e4.\\n\\n\uc544\ub798 \uc21c\uc11c\ub300\ub85c \uc9c4\ud589\ud55c\ub2e4\uba74 \uc774\uc288 \ubc88\ud638 POSTFIX \uc790\ub3d9\ud654\ub97c \ud560 \uc218 \uc788\ub2e4.\\n\\n### 1) \ud504\ub85c\uc81d\ud2b8 \ud3f4\ub354\uc5d0 .githooks \ud3f4\ub354 \uc0dd\uc131\\n\\n### 2) .githooks \ud3f4\ub354\uc5d0 commit-msg \ud30c\uc77c \uc0dd\uc131\\n\\n```shell\\n#!/bin/bash\\n\\nCOMMIT_MESSAGE_FILE_PATH=$1\\nMESSAGE=$(cat \\"$COMMIT_MESSAGE_FILE_PATH\\")\\n\\n# \ucee4\ubc0b \uba54\uc2dc\uc9c0\uac00 \uc5c6\uc744 \ub54c, \ucee4\ubc0b \ubc29\uc9c0\\nif [[ $(head -1 \\"$COMMIT_MESSAGE_FILE_PATH\\") == \'\' ]]; then\\n exit 0\\nfi\\n\\n# \ube0c\ub79c\uce58\uba85\uc5d0\uc11c \uc774\uc288 \ubc88\ud638\ub9cc \ucd94\ucd9c (\'/\' \ub4a4\uc5d0 \uc788\ub294 \ubb38\uc790\ub9cc \ucd94\ucd9c)\\nPOSTFIX=$(git branch | grep \'\\\\*\' | sed \'s/* //\' | sed \'s/^.*\\\\///\' | sed \'s/^\\\\([^-]*-[^-]*\\\\).*/\\\\1/\')\\n\\nCOMMIT_SOURCE=$2\\nCURRENT_BRANCH=$(git branch --show-current)\\n\\n# [[ \\"$CURRENT_BRANCH\\" != \\"$POSTFIX\\" ]] \ud83d\udc49\ud83c\udffb \ud604\uc7ac \ube0c\ub79c\uce58\uba85\uacfc POSTFIX\uac00 \ub611\uac19\uc73c\uba74 POSTFIX \uc785\ub825 \ubc29\uc9c0\\n# [ \\"$COMMIT_SOURCE\\" != \\"merge\\" ] \ud83d\udc49\ud83c\udffb merge\ud560 \ub54c, POSTFIX \uc785\ub825 \ubc29\uc9c0\\n# [[ \\"$MESSAGE\\" != *\\"[#$POSTFIX]\\"* ]] \ud83d\udc49\ud83c\udffb \uc774\ubbf8 POSTFIX\uac00 \uc874\uc7ac\ud560 \ub54c, POSTFIX \uc911\ubcf5 \uc785\ub825 \ubc29\uc9c0\\nif [[ \\"$CURRENT_BRANCH\\" != \\"$POSTFIX\\" ]] && [ \\"$COMMIT_SOURCE\\" != \\"merge\\" ] && [[ \\"$MESSAGE\\" != *\\"[#$POSTFIX]\\"* ]]; then\\n printf \\"%s\\\\n\\\\n[#%s]\\" \\"$MESSAGE\\" \\"$POSTFIX\\" > \\"$COMMIT_MESSAGE_FILE_PATH\\"\\nfi\\n```\\n\\n\ud83e\uddd0 \uc774\uc288 \ubc88\ud638 \ucd94\ucd9c\uc5d0 \uc0ac\uc6a9\ub41c \uba85\ub839\uc5b4 \uc124\uba85\\n\\n- grep \'\\\\*\' \ud83d\udc49 `*` \ud45c\uc2dc\ub41c \ube0c\ub79c\uce58(\ud604\uc7ac \uc704\uce58\uc758 \ube0c\ub79c\uce58)\ub97c \uac00\uc838\uc628\ub2e4.\\n- sed \'s/_ //\' \ud83d\udc49 `*` \uc81c\uac70\\n- sed \'s/\\\\([^/]_\\\\)._/\\\\1/\' \ud83d\udc49 `/` \uc774\ud6c4\uc758 \ubb38\uc790\ub9cc \ucd94\ucd9c\\n- sed \'s/^\\\\([^-]_-[^-]_\\\\).\\\\_/\\\\1/\' \ud83d\udc49 \ud558\ub098\uc758 \uc774\uc288\uc5d0 \uc5ec\ub7ec \ube0c\ub79c\uce58\ub97c \ub9cc\ub4e4\uba74\uc11c feat/10-1 \uc774\ub7f0 \ud615\ud0dc\ub85c \ube0c\ub79c\uce58\ub97c \ub9cc\ub4e4 \uacbd\uc6b0, \uccab \ubc88\uc9f8 \'-\' \uc55e \ub4a4\ub9cc \ucd94\ucd9c (ex. 10-1)\\n\\n### 3) \ud504\ub85c\uc81d\ud2b8 \ud3f4\ub354\uc5d0 Makefile \ud30c\uc77c \uc0dd\uc131\\n\\n```shell\\ninit:\\n git config core.hooksPath .githooks\\n chmod +x .githooks/commit-msg\\n git update-index --chmod=+x .githooks/commit-msg\\n\\n # chmod +x .githooks/commit-msg \ud83d\udc49\ud83c\udffb macOS, \ub9ac\ub205\uc2a4\uc5d0\uc11c \uc2a4\ud06c\ub9bd\ud2b8 \uad8c\ud55c \ubd80\uc5ec\\n # git update-index --chmod=+x .githooks/commit-msg\\n # \ud83d\udc49 macOS, \ub9ac\ub205\uc2a4\uc5d0\uc11c \ube0c\ub79c\uce58\uac00 \ubc14\ub014 \ub54c\ub9c8\ub2e4 \uc2a4\ud06c\ub9bd\ud2b8 \uc2e4\ud589\uc2dc\ucf1c\uc918\uc57c \ud558\ub294 \ubb38\uc81c \ud574\uacb0\\n```\\n\\n### 4) \uc544\ub798 \ucf54\ub4dc \uc2e4\ud589\\n\\n\uc0c8\ub85c git clone\uc744 \ud560 \ub54c\ub9c8\ub2e4 \uc544\ub798 \ucf54\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c\uc918\uc57c \ud55c\ub2e4. \ud55c \ubc88\ub9cc \uc2e4\ud589\uc2dc\ud0a4\uba74 \uacc4\uc18d \uc801\uc6a9\ub41c\ub2e4. (window \uae30\uc900)\\n\\n```shell\\ngit config core.hooksPath .githooks\\n```\\n\\n\u2757macOS\ub294 git clone \ud560 \ub54c\ub9c8\ub2e4 \uc544\ub798 \ucf54\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c\uc918\uc57c \ud55c\ub2e4.\\n\\n```shell\\nmake\\n```\\n\\n---\\n\\n\ucc38\uace0 \ube14\ub85c\uadf8\\nhttps://blog.deering.co/commit-convention/"},{"id":"6","metadata":{"permalink":"/6","source":"@site/blog/2023-07-05-nunu-db-optimization.mdx","title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","description":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4","date":"2023-07-05T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 5\uc77c","tags":[{"label":"DB","permalink":"/tags/db"},{"label":"JPA","permalink":"/tags/jpa"},{"label":"Hibernate","permalink":"/tags/hibernate"},{"label":"Spring","permalink":"/tags/spring"}],"readingTime":8.16,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"},{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"6","title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","authors":["nunu","boxster"],"tags":["DB","JPA","Hibernate","Spring"]},"prevItem":{"title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","permalink":"/7"},"nextItem":{"title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","permalink":"/5"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 `\ub204\ub204`\uc785\ub2c8\ub2e4\\n\\n\uc774\ubc88\uc5d0\ub294 \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uc54c\uac8c \ub41c \ub0b4\uc6a9\uc744 \uacf5\uc720\ud558\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n## \uc774\ubc88 \ucd5c\uc801\ud654\uc758 \ubaa9\ud45c\\n\\n\uc804\uae30\ucc28 \ucda9\uc804\uc18c\uc5d0 \ub300\ud55c \uacf5\uacf5 \ub370\uc774\ud130\ub97c \uac00\uc838\uc624\uace0, \uadf8 \ub370\uc774\ud130\ub97c DB \uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790\\n\\n## \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c \uc0bd\uc785\ud558\ub294 \uacfc\uc815\\n\\n\uc800\ud76c \ud300\uc758 \uc694\uad6c\uc0ac\ud56d\uc744 \uac04\ub2e8\ud558\uac8c \uc815\ub9ac\ud558\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4\\n\\n1. \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c \uacf5\uacf5 \ub370\uc774\ud130\uc5d0\uc11c \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uc640 \uc804\uae30\ucc28 \ucda9\uc804\uae30\uc5d0 \ub300\ud55c \ub370\uc774\ud130\ub97c \uac00\uc838\uc628\ub2e4\\n - \ucda9\uc804\uc18c\ub294 6\ub9cc \uac1c, \ucda9\uc804\uae30\ub294 23\ub9cc \uac1c\uc758 \ub370\uc774\ud130\uac00 \uc874\uc7ac\ud55c\ub2e4.\\n - \ud55c \ubc88\uc5d0 \uac00\uc838\uc62c \uc218 \uc788\ub294 \uc591\uc740 9999\uac1c \uae4c\uc9c0\ub2e4.\\n2. \uc774 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294\ub2e4\\n - \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 1:N \uad00\uacc4\uc774\ub2e4\\n\\n## \ucd5c\uc801\ud654 \uc804\uc740 \uc5b4\ub5a4 \uc0c1\ud669\uc774\uc5c8\ub294\ub370?\\n\\n![before_optimize](https://veiled-starfish-4c7.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Ffb934c88-4589-4096-90bc-36b4bc88f6a2%2FUntitled.png?id=f7f7c2af-7b95-42e8-8d95-ddd952e53005&table=block&spaceId=9db11c89-12d2-4910-8822-5ffecbdb8ccd&width=2000&userId=&cache=v2)\\n\\n\uc704 \uc0ac\uc9c4\uc744 \uc798 \ubcf4\uc2dc\uba74 \uc544\uc2e4 \uc218 \uc788\uc73c\uc2dc\uaca0\uc9c0\ub9cc, 2000\uac1c\ub97c \uc800\uc7a5\ud558\ub294\ub370, 231.762 \ucd08\uac00 \uc0ac\uc6a9\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \ucd9c\ub825\uc744 \uc704\ud55c \uc2dc\uac04\ub3c4 \ud3ec\ud568\ub418\uc5c8\uae30\uc5d0, 230\ucd08 \uc815\ub3c4\ub77c\uace0 \uc0dd\uac01\ud558\uc154\ub3c4 \uc88b\uc2b5\ub2c8\ub2e4\\n\\n1\ub9cc \uac1c\ub77c\uba74? 231.762\ucd08 \\\\* 5 = 1,158.81\ucd08\\n\\n23\ub9cc \uac1c\ub77c\uba74? 1158.81 \\\\* 23 = 26,652.63\ucd08\\n\\n\uc2dc\uac04\uc73c\ub85c \ubc14\uafd4\ubcf4\uba74 7.4 \uc2dc\uac04\uc774 \uac78\ub9b0\ub2e4\ub294 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n1. \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, \uc0c8\ub85c\uc6b4 Transaction \uc774 \uc0dd\uc131\ub41c\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n\ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, \uc0c8\ub85c\uc6b4 Transaction \uc774 \uc0dd\uc131\ub418\ub294 \uac83\uc744 \ubc29\uc9c0\ud558\uae30 \uc704\ud574, \uc804\uccb4\ub97c \ud558\ub098\uc758 \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\ub294\ub2e4\\n\\n## \uc804\uccb4\ub97c \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc740 \ubc84\uc804\\n\\n![all_in_transaction](https://veiled-starfish-4c7.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F9ff34622-4a26-4acd-980c-ae175c83143d%2FUntitled.png?id=979aa2c5-e972-4c52-a44a-1669c497c84e&table=block&spaceId=9db11c89-12d2-4910-8822-5ffecbdb8ccd&width=2000&userId=&cache=v2)\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c 2000\uac1c\ub97c \uc800\uc7a5\ud558\ub294\ub370 65\ucd08 \uac00 \uc0ac\uc6a9\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1\ub9cc \uac1c\ub77c\uba74? 65\ucd08 \\\\* 5 = 325\ucd08\\n\\n23\ub9cc \uac1c\ub77c\uba74? 325\ucd08 \\\\* 23 = 7,475\ucd08\\n\\n\uc2dc\uac04\uc73c\ub85c \ubc14\uafd4\ubcf4\uba74 2\uc2dc\uac04\uc774 \uac78\ub9b0\ub2e4\ub294 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n\uc804\uccb4\uc801\uc73c\ub85c 3\ubc30 \uc815\ub3c4 \ube68\ub77c\uc84c\uc2b5\ub2c8\ub2e4\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n1. 23\ub9cc \uac1c\uc758 \uc800\uc7a5\uc774 \ubaa8\ub450 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc774 \ub418\uc5b4\uc11c, \ud558\ub098\uac00 \uc2e4\ud328\ud558\uba74 23\ub9cc\uac1c\ub97c \uc0c8\ub85c \uc800\uc7a5\ud574\uc57c \ud558\ub294 \uc0c1\ud669\uc5d0 \ucc98\ud55c\ub2e4\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n23\ub9cc\uac1c\uc758 \uc800\uc7a5\uc774 \ubaa8\ub450 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc774 \ub418\ub294 \uac83\uc744 \ubc29\uc9c0\ud558\uae30 \uc704\ud574, 1\ub9cc \uac1c\uc529 \uc601\uc18d\ud654\uc2dc\ud0a8\ub2e4\\n\\n## 1\ub9cc \uac1c\uac00 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc778 \ubc84\uc804\\n\\n![separateTransaction](https://blog.kakaocdn.net/dn/c2mgfd/btsmrWCfnKy/9Y6Dv8vYzcftsket61tub1/img.png)\\n\\n\uc131\ub2a5\uc0c1\uc73c\ub85c \uac1c\uc120\ud55c \ubd80\ubd84\uc740 \uadf8\ub807\uac8c \ud06c\uc9c0 \uc54a\uc9c0\ub9cc, \uc2e4\ud328\ud588\uc744 \ub54c, 1\ub9cc \uac1c\ub9cc \ub2e4\uc2dc \uc800\uc7a5\ud558\uba74 \ub418\uae30\uc5d0, \ud6e8\uc52c \ube60\ub974\uac8c \ubcf5\uad6c\uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n\uc5ec\uae30\uc11c PageNo\ub77c\ub294 \ud074\ub798\uc2a4\ub294, i\ub97c \ubc14\ub85c \ucc38\uc870\ud588\uc744 \uacbd\uc6b0, effectively final\uc744 \ubcf4\uc7a5\ud560 \uc218 \uc5c6\uc5b4\uc11c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc131\ub2a5\uc740 \uc804\uccb4\ub97c \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc740 \ubc84\uc804\uacfc \ud070 \ucc28\uc774\uac00 \ub098\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n1. id \uc0dd\uc131 \uc804\ub7b5\uc774 `GenerationType.IDENTITY` \uc774\uae30\uc5d0, \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, DB\uc5d0\uc11c id\ub97c \uc0dd\uc131\ud574\uc57c \ud55c\ub2e4.\\n\\nJPA\uc5d0 \uc788\ub294 \uc4f0\uae30 \uc9c0\uc5f0\uc744 \uc804\ud600 \ud65c\uc6a9\ud560 \uc218 \uc5c6\uace0, DB\uc5d0\uc11c id\ub97c \uc0dd\uc131\ud558\uae30 \uc704\ud574, DB\uc640 \ub9e4\ubc88 \ud1b5\uc2e0\uc744 \ud574\uc57c \ud55c\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\nid\ub97c \ubbf8\ub9ac \uc0dd\uc131\ud574\uc11c, DB \uc5d0\uc11c id \ub97c \uc0dd\uc131\ud558\ub294 \uacfc\uc815\uc744 \uc0dd\ub7b5\ud55c\ub2e4\\n\\nID \uc0dd\uc131 \uc804\ub7b5\uc744 `GenerationType.Table\uc758` \ud615\ud0dc\ub85c \ubc14\uafd4\uc11c, DB\uc5d0\uc11c id\ub97c \uc0dd\uc131\ud558\ub294 \uacfc\uc815\uc744 \uc904\uc5ec\uc11c, \uc131\ub2a5\uc744 \uac1c\uc120\ud55c\ub2e4\\n\\n## 1\ub9cc \uac1c\uac00 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc774\uace0, id\ub97c \ubbf8\ub9ac \uc0dd\uc131\ud55c \ubc84\uc804\\n\\n\uc774\ub54c batch size\ub97c 1000 \ub2e8\uc704\ub85c \uc124\uc815\ud574\uc11c 1000\uac1c\uc529 id \uac00 \ub298\uc5b4\ub098\ub3c4\ub85d \uc124\uc815\ud588\ub2e4\\n\\n![charger_generator](https://blog.kakaocdn.net/dn/bFjNWb/btsmuoLmzVh/GddHebu2V43fpk2t3IUmz0/img.png)![station_generator](https://blog.kakaocdn.net/dn/pae8w/btsmrANjAGi/gjUhD6sMvBLpmsPl9c1tAk/img.png)\\n\\n```\\nspring.jdbc.template.fetch-size=10000\\n```\\n\\n![10000batch_size](https://blog.kakaocdn.net/dn/mtBFp/btsmtEt48jp/3mFOfrIBWbjJhHHuyP4zPk/img.png)\\n\\n1\uc790\ub9ac \uc22b\uc790\ub294 \uc55e\uc5d0\uc11c\ubd80\ud130 n(\ub9cc\uac1c)\ub97c \uc758\ubbf8\ud558\uace0, 2\ubc88\uc9f8 \uc22b\uc790\ub294 1\ub9cc \uac1c\ub97c \uc800\uc7a5\ud558\ub294 \ub370 \uac78\ub9b0 \uc2dc\uac04(ms)\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4.\\n\\n\ucc98\uc74c 1\ub9cc \uac1c\ub294 142\ucd08\uac00 \uac78\ub9ac\uace0, 2\ub9cc \uac1c\ub294 285\ucd08\uac00 \uac78\ub838\uc2b5\ub2c8\ub2e4.\\n\\n23\ub9cc \uac1c\ub77c\uba74? 142 \\\\* 26 = 3,266\ucd08\\n\\n\ucc98\uc74c\uacfc \ube44\uad50\ud558\uc790\uba74 7.4\uc2dc\uac04\uc774 \uac78\ub9ac\ub294 \uac83\uc5d0\uc11c 54\ubd84 \uc815\ub3c4 \uac78\ub9ac\ub294 \uac83\uc73c\ub85c \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n\ud558\ub098\uc758 \uc2a4\ub808\ub4dc\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud558\uae30\uc5d0, \uc131\ub2a5\uc774 \uac1c\uc120\ub418\uc5c8\uc9c0\ub9cc, \uc5ec\uc804\ud788 \ub290\ub9bd\ub2c8\ub2e4.\\n\\n\ud558\ub098\uc758 \uc2a4\ub808\ub4dc\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud558\uae30\uc5d0, \ud558\ub098\uc758 \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n\uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uac8c \ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uac8c \ud569\ub2c8\ub2e4.\\n\\n## \uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\ub294 \ubc84\uc804\\n\\n![multi_thread](https://blog.kakaocdn.net/dn/bPV2aa/btsmrSfU2D4/phDwk77XiKvwiXa5geX0PK/img.png)\\n\\n\uc774 \ubc84\uc804\uc5d0\uc11c 89991 \uac1c\ub97c \uc800\uc7a5\ud558\ub294\ub370 \ucd1d 157\ucd08\uac00 \uac78\ub838\uc2b5\ub2c8\ub2e4.\\n\\n23\ub9cc \uac1c\ub77c\uba74? 157 \\\\* 3 = 471\ucd08\\n\\n\uc2dc\uac04\uc73c\ub85c \ubc14\uafd4\ubcf4\uba74 5\ubd84\ub3c4 \ucc44 \uac78\ub9ac\uc9c0 \uc54a\ub294 \uc2dc\uac04\uc774\uc8e0\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\nhikari connection pool \uc0ac\uc774\uc988\ub97c 10\uc73c\ub85c \uc124\uc815\ud588\ub294\ub370, 10\uac1c\uc758 \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uba74\uc11c \uc800\uc7a5\uc744 \ud558\ub2e4 \ubcf4\ub2c8, 10\uac1c\uc758 \ucee4\ub125\uc158\uc744 \ubaa8\ub450 \uc0ac\uc6a9\ud558\uace0 \ub098\uc11c, 11\ubc88\uc9f8\ubd80\ud130\ub294 \ucee4\ub125\uc158\uc744 \uac00\uc838\uc624\uae30 \uc704\ud574, \uae30\ub2e4\ub824\uc57c \ud558\ub294 \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\nhikari connection pool \uc0ac\uc774\uc988\ub97c 25\ub85c \uc124\uc815\ud574\uc11c, 25\uac1c\uc758 \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\ub3c4\ub85d \ud569\ub2c8\ub2e4.\\n\\n```\\nspring.datasource.hikari.maximum-pool-size=25\\n```\\n\\n## \uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\ub294 \ubc84\uc804 2\\n\\n![multi_thread2](https://blog.kakaocdn.net/dn/vJEoD/btsmsfau8Mv/j0CT8fVrAp3LKGRMmyMVeK/img.png)\\n\\n\ucd1d 13\ub9cc \uac1c\uc758 \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud558\ub294\ub370, 147\ucd08\uac00 \uac78\ub9ac\uace0, db \uc778\uc2a4\ud134\uc2a4\uc758 cpu \uc0ac\uc6a9\ub960\uc774 100%\uc5d0 \uac00\uae4c\uc6cc\uc838\uc11c ec2 \uac00 \ub2e4\uc6b4\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\ndb\uc758 cpu \uc0ac\uc6a9\ub7c9\uc744 \uace0\ub824\ud558\uc9c0 \uc54a\uace0, 23\ub9cc \uac1c\uac00 \uc870\uae08 \ub118\ub294 \ub370\uc774\ud130\ub97c 25\uac1c\uc758 \ucee4\ub125\uc158\uc744 \ud65c\uc6a9\ud574 \uc800\uc7a5\ud558\ub824\uace0 \ud588\uc2b5\ub2c8\ub2e4\\n\\n# \uacb0\ub860\\n\\n1. \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, transaction\uc744 \uc0ac\uc6a9\ud558\uc9c0 \ub9d0\uc790\\n2. \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, id\ub97c \uc0dd\uc131\ud558\uc9c0 \ub9d0\uc790\\n3. \uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uc790\\n4. db\uc758 cpu \uc0ac\uc6a9\ub7c9\uc744 \uace0\ub824\ud558\uc790\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4"},{"id":"5","metadata":{"permalink":"/5","source":"@site/blog/2023-07-04-github_actions_pullrequest_issue.mdx","title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","description":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\ud14c\ucf54 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4","date":"2023-07-04T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 4\uc77c","tags":[{"label":"github","permalink":"/tags/github"},{"label":"action","permalink":"/tags/action"},{"label":"pr","permalink":"/tags/pr"},{"label":"issue","permalink":"/tags/issue"}],"readingTime":3.19,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"5","title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","authors":["nunu"],"tags":["github","action","pr","issue"]},"prevItem":{"title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","permalink":"/6"},"nextItem":{"title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","permalink":"/4"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\ud14c\ucf54 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4\\n\\n\ube60\ub974\uac8c \uacb0\uacfc\ubd80\ud130 \ubcf4\uace0 \uac00\uc2dc\uc8e0.\\n\\n## \uc5b4\ub5a4 \uacb0\uacfc\uac00 \ub098\uc654\ub098\uc694?\\n\\npr\uc758 \ubcf8\ubb38 \ub05d\uc5d0, \uc5f0\uad00\ub41c \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubc11\uc5d0 \uc0ac\uc9c4\uc744 \ubcf4\uc2dc\uba74 \uc27d\uac8c \uc774\ud574\ud558\uc2e4 \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://user-images.githubusercontent.com/80899085/250614527-e2672cf2-786a-434c-a8b6-8b374de4d689.png)![img](https://user-images.githubusercontent.com/80899085/250614882-d99aa570-51e2-4565-ab4c-ccdbd4d36e57.png)\\n\\ngithub\uc5d0\uc11c issue \ubc88\ud638\uac00 pr\uc5d0 \ub2f4\uaca8\uc788\ub2e4\uba74 2\uac00\uc9c0 \uc7a5\uc810\uc774 \uc0dd\uae30\ub294\ub370\uc694.\\n\\n1. issue\ub97c \ud074\ub9ad\ud588\uc744 \ub54c, \uc790\ub3d9\uc73c\ub85c \uadf8 issue\ub85c \ub118\uc5b4\uac08 \uc218 \uc788\uc2b5\ub2c8\ub2e4. (\ud638\ubc84\ub9cc\uc73c\ub85c \uc774\uc288\uc5d0 \ub300\ud55c \uac04\ub2e8\ud55c \uc815\ubcf4\ub97c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4)\\n2. pr \uc774 merge \ub418\uc5c8\uc744 \ub54c, \uc790\ub3d9\uc73c\ub85c issue \uac00 close \ub429\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \uc190\uc73c\ub85c \uc9c4\ud589\ud558\ub294 \uac83\ubcf4\ub2e4, \uc790\ub3d9\uc73c\ub85c \uc9c4\ud589\ud558\uac8c \ub418\uba74 \uc2e4\uc218\ub3c4 \uc904\uc5b4\ub4e4\uace0, \uac1c\ubc1c \uacfc\uc815\uc774 \ud3b8\ud574\uc9c8 \uac83 \uac19\uc544\uc11c \uc774 \uae30\ub2a5\uc744 \uc81c\uc791\ud558\uac8c \ub418\uc5c8\ub294\ub370\uc694\\n\\n## \uc911\uc694\ud55c \uc810\\n\\n**\uc774 \uacfc\uc815\uc744 \uc9c4\ud589\ud558\ub824\uba74 \ubc11\uc5d0\uc11c \uc18c\uac1c\ud574\ub4dc\ub9b4 \ube0c\ub79c\uce58 \ub124\uc774\ubc0d \uaddc\uce59\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.**\\n\\n## \ube0c\ub79c\uce58 \uc774\ub984 \uaddc\uce59\\n\\n- \ube0c\ub79c\uce58 \uc774\ub984\uc740 `\ud0c0\uc785/\uc774\uc288\ubc88\ud638` \uc73c\ub85c \uad6c\uc131\ud569\ub2c8\ub2e4. ex) `feat/1`\\n- \ud0c0\uc785\uc740 `feat`, `fix`, `docs`, `refactor`, `test` \ub4f1 \uc5ec\ub7ec \uac00\uc9c0\uac00 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \ud588\uc744 \ub54c, \uc774\uc288 \ubc88\ud638\ub97c \ube0c\ub79c\uce58 \uba85\uc5d0\uc11c\ubd80\ud130 \uac00\uc838\uc62c \uc218 \uc788\uae30\uc5d0, \uc790\ub3d9\ud654\ub97c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uaddc\uce59\uc774 \uc544\ub2cc, feat/action \uac19\uc740 \ud615\ud0dc\uac00 \ub41c\ub2e4\uba74 issue \ubc88\ud638\ub97c \ucc3e\uae30 \uc5b4\ub835\uaca0\uc8e0?\\n\\n## \uc0ac\uc6a9 \ubc29\ubc95\\n\\n\uc791\uc131\ub41c \ucf54\ub4dc\ubd80\ud130 \ubcf4\uc2dc\uace0, \uc124\uba85\uc744 \ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ub798\uc5d0 \uc791\uc131\ub41c \ucf54\ub4dc\ub97c. github/workflows/assign\\\\_issue\\\\_number\\\\_to\\\\_pr\\\\_body.yml\ub85c \uc800\uc7a5\ud558\uc2dc\uba74 \ub05d\uc785\ub2c8\ub2e4.\\n\\n```yml\\nname: assign_issue_number_to_pr_body\\n\\non:\\n pull_request:\\n types: [ opened ]\\n branches-ignore:\\n - develop\\n\\njobs:\\n append_issue_number_to_pr_body:\\n runs-on: ubuntu-latest\\n steps:\\n - name: append feature number to pr body pr branch = feat/(issueNumber)\\n uses: actions/github-script@v4\\n with:\\n github-token: ${{ secrets.GITHUB_TOKEN }}\\n script: |\\n const pr = await github.pulls.get({\\n owner: context.repo.owner,\\n repo: context.repo.repo,\\n pull_number: context.issue.number\\n });\\n const body = pr.data.body;\\n const issueNumber= pr.data.head.ref.split(\'/\')[1];\\n const newBody = body + \\"\\\\n\\\\n\\" + \\"close #\\" + issueNumber;\\n await github.pulls.update({\\n owner: context.repo.owner,\\n repo: context.repo.repo,\\n pull_number: context.issue.number,\\n body: newBody\\n });\\n```\\n\\n## \uc9c4\ud589 \uacfc\uc815\\n\\n1. pr \uc774 \uc0dd\uc131\ub418\uba74, pr\uc5d0 \ub300\ud55c \uc815\ubcf4\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n2. pr\uc758 \ubcf8\ubb38\uc744 \uac00\uc838\uc635\ub2c8\ub2e4.\\n3. pr\uc758 \ube0c\ub79c\uce58 \uc774\ub984\uc5d0\uc11c \uc774\uc288 \ubc88\ud638\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n4. pr\uc758 \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ucd94\uac00\ud569\ub2c8\ub2e4.\\n5. pr\uc758 \ubcf8\ubb38\uc744 \uc5c5\ub370\uc774\ud2b8\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c, \uc9c1\uc811 pr\uc758 \ubcf8\ubb38\uc744 \uc218\uc815\ud558\uc9c0 \uc54a\uc544\ub3c4, \uc790\ub3d9\uc73c\ub85c \uc774\uc288 \ubc88\ud638\uac00 \ucd94\uac00\ub418\uae30\uc5d0, \uc2e4\uc218\ub97c \uc904\uc77c \uc218 \uc788\uc73c\ub2c8, \ud55c \ubc88 \uc2dc\ub3c4\ud574 \ubcf4\uc138\uc694"},{"id":"4","metadata":{"permalink":"/4","source":"@site/blog/2023-07-03-jay-infra.mdx","title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","description":"\uc11c\ub860","date":"2023-07-03T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 3\uc77c","tags":[{"label":"java17","permalink":"/tags/java-17"},{"label":"infra","permalink":"/tags/infra"},{"label":"ec2","permalink":"/tags/ec-2"},{"label":"ci","permalink":"/tags/ci"},{"label":"cd","permalink":"/tags/cd"},{"label":"aws","permalink":"/tags/aws"}],"readingTime":7.19,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"4","title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","authors":["jay"],"tags":["java17","infra","ec2","ci","cd","aws"]},"prevItem":{"title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","permalink":"/5"},"nextItem":{"title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","permalink":"/3"}},"content":"## \uc11c\ub860\\n\\n\uc548\ub155\ud558\uc138\uc694\ud83d\udc4b\ud83d\udc4b `\uce74\ud398\uc778` \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\ud68c\uc758\ub97c \ud558\uba74\uc11c \uc774\ubc88 \uc8fc \uc81c\uac00 \ub9e1\uc740 \ud30c\ud2b8\ub294 \uc11c\ubc84 \uc778\ud504\ub77c\uc785\ub2c8\ub2e4.\\n\\n\uc544\uc9c1\uc740 EC2 \uc2a4\ud399\uacfc \ub370\uc774\ud130\ub4e4\uc774 \uc815\ud655\ud788 \ub098\uc624\uc9c4 \uc54a\uc558\uc9c0\ub9cc,\\n\uc6b0\ud14c\ucf54\uc5d0\uc11c \uc801\uc740 EC2 \uc2a4\ud399\uc744 \uc81c\uacf5\ud55c\ub2e4\ub294 \uae30\uc900\uc73c\ub85c \uacc4\ud68d\ub3c4\ub97c \uc801\uc5b4\ubcfc \uc0dd\uac01\uc785\ub2c8\ub2e4.\\n\\n\\n## \uc0c1\ud669 \uc778\uc2dd\\n\\n\uc608\uc0c1\ud558\ub294 \uc0c1\ud669\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n- API\uc758 \ub370\uc774\ud130\ub97c \ub2e4\ub8e8\ub294 \uc0c1\ud669\uc5d0\uc11c \ucd5c\uc18c \uc57d 150\ub9cc \uac74\uc5d0\uc11c \ucd5c\uc545 \uc57d 3700\ub9cc \uac74\uc758 \ub370\uc774\ud130\ub97c \ub2e4\ub8f9\ub2c8\ub2e4.\\n- \uc774\uc804 \uae30\uc218\ub97c \ubd24\uc744 \ub54c EC2\uc758 \uac1c\uc218\ub294 \ub9ce\uc774 \ub098\ub220\uc8fc\ub294 \uac83\uc73c\ub85c \ud30c\uc545 \ub410\uc2b5\ub2c8\ub2e4. (\uc774 \ubd80\ubd84\uc740 \ub2ec\ub77c\uc9c8 \uc218 \uc788\uc2b5\ub2c8\ub2e4.)\\n- \uc0c1\ud669\uc5d0 \ub530\ub77c\uc11c \uacf5\uacf5 API\ub97c \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\ub294 \uc11c\ubc84\uc640, \uc81c\uacf5 \uc11c\ubc84\ub97c \ub098\ub20c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n- Conflict\uac00 \ub098\uc9c0 \uc54a\uae30 \uc704\ud574\uc11c \uc548\uc815\uc801\uc778 \uac80\uc99d\uc744 \uac70\uce5c \ud6c4 Merge\ub97c \ud574\uc57c\ud569\ub2c8\ub2e4.\\n- \ud504\ub85c\uc81d\ud2b8\uc758 \ubc84\uc804\uc774 \uac31\uc2e0\ub41c\ub2e4\uba74 EC2 \uc11c\ubc84\uc5d0\uc11c \uc790\ub3d9\uc73c\ub85c \uc2a4\ud06c\ub9bd\ud2b8\ub97c \uc791\ub3d9\uc2dc\ucf1c Pull \ubc0f \uc11c\ubc84 \uc7ac\ubc30\ud3ec\ub97c \ud574\uc57c\ud569\ub2c8\ub2e4.\\n- \uc11c\ubc84\uc758 \ubc84\uc804\uc774 \ubc14\ub00c\ub294 \uacbd\uc6b0 \uae30\uc874 \uc11c\ubc84\ub97c \ub044\uace0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ud0a4\uba74 \uc0ac\uc6a9\uc790\uac00 \uc774\uc6a9\ud560 \uc218 \uc5c6\ub294 \ud140\uc774 \uc0dd\uae30\uae30 \ub54c\ubb38\uc5d0 \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \ud574\uc57c\ud569\ub2c8\ub2e4.\\n\\n## \ubb38\uc81c\uc810\\n\\n\uc704\uc5d0 \uc0c1\ud669\uc5d0\uc11c \ud30c\uc545\ub418\ub294 \ubb38\uc81c\uc810\ub4e4\uc740 \uba3c\uc800 \uc801\uc740 \uc131\ub2a5\uc758 EC2 \uc11c\ubc84\ub85c \uc778\ud574 \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc624\ub294 \uacfc\uc815 \ud639\uc740 \uc5c5\ub370\uc774\ud2b8 \uacfc\uc815\uc5d0\uc11c \uc11c\ubc84\uac00 \ud130\uc9c8 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc131\ub2a5\uc774 \uc88b\ub2e4\uba74 \ud558\ub098\ub85c \ubaa8\ub4e0 \uac83\uc744 \ud560 \uc218 \uc788\uc9c0\ub9cc, \uadf8\ub807\uc9c0 \uc54a\uae30 \ub54c\ubb38\uc5d0 \ud604\uc7ac \uc5ec\ub7ec \uac1c\uc758 EC2\ub97c \uae30\uc900\uc73c\ub85c \uc544\ud0a4\ud14d\ucc98\ub97c \uad6c\uc131\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n## \ubb38\uc81c \ud574\uacb0\uc744 \uc704\ud55c \ud604\uc7ac \uc0dd\uac01\\n\\n### \uc11c\ubc84\uc758 \uae30\ub2a5 \ubd84\uc0b0\\n\uc704\uc5d0\uc11c \uc5b8\uae09\ud55c \uac83\ucc98\ub7fc \uc11c\ubc84\uc758 \uc131\ub2a5\uc774 \ubc1b\uccd0\uc8fc\uc9c0 \ubabb\ud560 \uac00\ub2a5\uc131\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc131\ub2a5\uc744 \uc0dd\uac01\ud574\uc11c \uc774\ub97c \ub098\ub204\uae30 \uc704\ud574\uc11c\ub294 \uba3c\uc800 \ub2e4\uc74c\uacfc \uac19\uc774 \uc11c\ubc84\ub97c \ubd84\uc0b0\ud560 \ud544\uc694\uac00 \uc788\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n(\ubb3c\ub860 \uc11c\ubc84\uac00 \ubabb \ubc84\ud2f8 \uacbd\uc6b0\uc774\uace0, \uc5b4\ub5bb\uac8c \ub098\ub258\ub294 \uc9c0\ub294 \ud68c\uc758 \ud6c4 \uacb0\uc815\ud558\uaca0\uc9c0\ub9cc!)\\n- `\uacf5\uacf5 API \ub370\uc774\ud130 \uc801\uc7ac \ubc0f \uc8fc\uae30\uc801\uc778 \uc5c5\ub370\uc774\ud2b8`\\n- `\uc2e4\uc2dc\uac04 \ud63c\uc7a1\ub3c4\ub97c \uc704\ud55c \uc2e4\uc2dc\uac04 \ub370\uc774\ud130 \uc5c5\ub370\uc774\ud2b8`\\n- `\uc694\uccad \ucc98\ub9ac`\\n\\n\uc801\uc740 \uc131\ub2a5\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8\uc640 \uc694\uccad \ucc98\ub9ac\ub97c \ub3d9\uc2dc\uc5d0 \ud55c\ub2e4\uba74, \uc11c\ubc84\uac00 \uadf8 \ubd80\ud558\ub97c \uacac\ub514\uc9c0 \ubabb\ud560 \uc218\ub3c4 \uc788\uaca0\uc8e0?\\n\ub530\ub77c\uc11c \uc11c\ubc84\uc758 \uc5ed\ud560\uc744 \ubd84\ub2f4\ud558\uace0, \uac01 \uc5ed\ud560\uc5d0 \ucda9\uc2e4\ud558\ub3c4\ub85d \uad6c\ud604\ud55c\ub2e4\uba74 \ubcf4\ub2e4 \ud6a8\uc728\uc801\uc778 \ucc98\ub9ac\ub97c \ud560 \uc218 \uc788\uc744 \uac83\uc774\ub77c\uace0 \uc608\uc0c1\ub429\ub2c8\ub2e4.\\n\\n\\n### \uc548\uc815\uc801\uc778 Merge\\n\\n\uc798\ubabb\ub41c PR\uc744 Merge \uc2dc\ucf1c\ubc84\ub9ac\uba74 \uc5b4\ub5a8\uae4c\uc694? Conflict\ub3c4 \ub0a0 \uc218 \uc788\uace0.. \uc0dd\uac01\ub9cc\ud574\ub3c4 \ub054\ucc0d\ud569\ub2c8\ub2e4.\\n\\n\ucf54\ub4dc\ub9ac\ubdf0\ub97c \ud1b5\ud574\uc11c \uc774\ub97c \uc5b4\ub290\uc815\ub3c4 \ud574\uc18c\ud55c\ub2e4\uace0 \ud574\ub3c4, \uc0ac\ub78c\uc774\ub2e4\ubcf4\ub2c8 \uc2e4\uc218\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc774\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c `Github Actions`\ub97c \uc774\uc6a9\ud558\uc5ec \ubbf8\ub9ac \uc9c0\uc815\ud574\ub454 Task\ub97c \uc2dc\ud0a4\uace0, \uc774\uac8c \ud1b5\uacfc\ud55c\ub2e4\uba74 Merge\ud560 \uc218 \uc788\ub3c4\ub85d \ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \ud55c\ub2e4\uba74 \ud611\uc5c5\ud560 \ub54c\uc5d0\ub3c4 \uc548\uc804\ud55c Merge\uac00 \uac00\ub2a5\ud558\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n\\n### CI/CD\\n\\n\uc9c0\uae08\uae4c\uc9c0 \uc6b0\ud14c\ucf54 \ubbf8\uc158\uc5d0\uc11c\ub294 \ubc30\ud3ec\ub97c \ub2e4\uc74c\uacfc \uac19\uc740 \uacfc\uc815\uc73c\ub85c \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. \ubc30\ud3ec\\n2. \ub9ac\ud329\ud1a0\ub9c1 \ubc0f \ucee4\ubc0b\\n3. EC2 \uc11c\ubc84\uc5d0\uc11c \uc2a4\ud06c\ub9bd\ud2b8 \uc2e4\ud589\ud558\uc5ec \uc7ac\ubc30\ud3ec\\n\\n\uc774\ub807\uac8c \ubc30\ud3ec\ub97c \ud574\ub3c4 \uc0c1\uad00\uc5c6\uc9c0\ub9cc, \ub9e4\ubc88 \ub9ac\ud329\ud1a0\ub9c1\uacfc \uae30\ub2a5 \ucd94\uac00\ub97c \ud560 \ub54c\ub9c8\ub2e4 EC2 \uc11c\ubc84\ub85c \ub4e4\uc5b4\uac00\uc11c \ube4c\ub4dc \uc2a4\ud06c\ub9bd\ud2b8\ub97c \uc0ac\uc6a9\ud574\uc11c \uc11c\ubc84\ub97c \uc7ac\uc2dc\uc791 \ud574\uc57c\ud560\uae4c\uc694?\\n\uc774\ub807\uac8c \ub41c\ub2e4\uba74 \ubd88\ud544\uc694\ud55c \uc2dc\uac04\uc774 \uc18c\ubaa8\ub418\uace0, \ubd88\ud3b8\ud55c \uc810\uc774 \ub9ce\uc744 \uac83\uc774\ub77c\uace0 \uc0dd\uac01\ub429\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c CI/CD \uac1c\ub150\uc744 \uc801\uc6a9\ud574\uc11c \uc774 \uacfc\uc815\uc744 \uc790\ub3d9\uc73c\ub85c \uc9c4\ud589\ud558\uace0\uc790 \ud569\ub2c8\ub2e4.\\n\\n\uc774 \ubd80\ubd84\uc740 \ub354 \uc54c\uc544\ubd10\uc57c\uaca0\uc9c0\ub9cc, Github Actions\ub97c \uc774\uc6a9\ud574\uc11c \uc774\ub97c \uc801\uc6a9\ud558\uba74, \uc678\ubd80\uc5d0\uc11c SSH \uc811\uadfc\uc774 \ubd88\uac00\ub2a5\ud558\uae30 \ub54c\ubb38\uc5d0 Jenkins\ub97c \uc774\uc6a9\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\uae43\ud5c8\ube0c\uc758 \ubcc0\ub3d9 \uc0ac\ud56d\uc744 Webhook\uc744 \uc774\uc6a9\ud574\uc11c Jenkins\ub85c \ub118\uae30\uace0, \uc774\ub97c \ud1b5\ud574 CI\ub97c \uc801\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 \uc774\ub294 \uacc4\ud68d\uc774\uace0 \uacf5\ubd80\ud558\uc9c0 \uc54a\uc740 \ub2e4\ub978 \ub0b4\uc6a9\uc774 \uc788\uc744 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \uc5b8\uc81c\ub4e0 \ubc14\ub014 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ubb34\uc911\ub2e8 \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98 \uc801\uc6a9\\n\uc774 \ub610\ud55c \uc544\uc9c1\uc740 \uba3c \uc774\uc57c\uae30\uc9c0\ub9cc, \uace0\ub824\ud574 \ubcfc \uc0c1\ud669\uc774\ub77c\uc11c \uc801\uc5b4\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\uac00 \uc774\uc6a9\ud558\uace0 \uc788\ub294 \uc11c\ube44\uc2a4\uac00 \uac11\uc790\uae30 \uc911\ub2e8\ub41c\ub2e4\uba74 \uc5b4\ub5a8\uae4c\uc694?\\n\uc800\ub294 \ud654\uac00 \ub9ce\uc774 \ub0a0 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ud53c\uce58 \ubabb\ud560 \uc0ac\uc815\uc73c\ub85c \uc11c\ubc84\uac00 \ud130\uc838\ub3c4, \uc0ac\uc6a9\uc790\uac00 \uc11c\ube44\uc2a4\ub97c \uacc4\uc18d \uc774\uc6a9\ud560 \ubc29\ubc95\uc774 \uc5c6\uc744\uae4c\uc694?\\n\\n\uc774\ub7f0 \uace0\ubbfc\uc744 \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c \ub098\uc628 \uac1c\ub150\uc774 \ubb34\uc911\ub2e8 \ubc30\ud3ec\uc785\ub2c8\ub2e4.\\n\\n`\uce74\ub098\ub9ac\uc544 \ubc30\ud3ec`, `Blue/Green \ubc30\ud3ec`, `\ub864\ub9c1`\ub4f1 \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \uc704\ud55c \uc5ec\ub7ec\uac00\uc9c0 \uc804\ub7b5\uc740 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4.\\n\uc774 \ubd80\ubd84\uc740 \uc544\uc9c1\uc740 \uc11c\ubc84\uc758 \uba85\uc138\uac00 \uc815\ud655\ud558\uc9c0 \uc54a\uc544\uc11c \uc5b4\ub5a4 \ubc29\uc2dd\uc73c\ub85c \uc5b4\ub5bb\uac8c \ucc98\ub9ac\ud560 \uac83\uc778\uc9c0\uc5d0 \ub300\ud574\uc11c\ub294 \uc544\uc9c1 \uc815\ud560 \uc218\ub294 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub294 \uba85\uc138\uac00 \ud655\uc2e4\ud558\uac8c \uc815\ud574\uc9c4 \ud6c4 \ud300\uc6d0\uacfc \uc7a5\ub2e8\uc810\uc744 \uc0c1\uc758\ud558\uba70 \uacb0\uc815\ud560 \uc77c\uc774\uae30 \ub54c\ubb38\uc5d0 \ud604\uc7ac\uae4c\uc9c0\ub294 \\"\uc774 \uc815\ub3c4\ub97c \uace0\ub824\ud558\uace0 \uc788\ub2e4.\\" \uc815\ub3c4\ub9cc \uc54c\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4."},{"id":"3","metadata":{"permalink":"/3","source":"@site/blog/2023-07-02-nunu-java-version.mdx","title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","description":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c \uc790\ubc14 11\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \ub108\ubb34 \uc775\uc219\ud574\uc9c4 \uc0c1\ud669\uc774\uc5b4\uc11c, java 11 \ub300\uc2e0 java 17\uc744 \uc4f0\ub824\uba74 \uc4f0\ub294 \ub300\uc2e0, \uc65c java 17\uc744 \uc4f0\uba74 \uc88b\uc740\uc9c0\uc5d0 \ub300\ud574\uc11c \uc124\ub4dd\uc744 \ud558\ub294 \uc2dc\uac04\uc774 \uc788\uc5b4\uc57c \ud558\ub294\ub370\uc694","date":"2023-07-02T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 2\uc77c","tags":[{"label":"java17","permalink":"/tags/java-17"},{"label":"java11","permalink":"/tags/java-11"},{"label":"record","permalink":"/tags/record"},{"label":"toList","permalink":"/tags/to-list"},{"label":"gc","permalink":"/tags/gc"}],"readingTime":5.88,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"3","title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","authors":["nunu"],"tags":["java17","java11","record","toList","gc"]},"prevItem":{"title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","permalink":"/4"},"nextItem":{"title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","permalink":"/2"}},"content":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c \uc790\ubc14 11\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \ub108\ubb34 \uc775\uc219\ud574\uc9c4 \uc0c1\ud669\uc774\uc5b4\uc11c, java 11 \ub300\uc2e0 java 17\uc744 \uc4f0\ub824\uba74 \uc4f0\ub294 \ub300\uc2e0, \uc65c java 17\uc744 \uc4f0\uba74 \uc88b\uc740\uc9c0\uc5d0 \ub300\ud574\uc11c \uc124\ub4dd\uc744 \ud558\ub294 \uc2dc\uac04\uc774 \uc788\uc5b4\uc57c \ud558\ub294\ub370\uc694\\n\\n\ucc98\uc74c\uc5d0\ub294 \ub2e8\uc21c\ud788 record \ud074\ub798\uc2a4\uac00 \uc88b\uc544\uc694, collect(Collectors.toList()); \ub300\uc2e0 toList() \ub9cc\uc73c\ub85c \ud574\uacb0\ud560 \uc218 \uc788\uc5b4\uc11c \uc88b\uc544\uc694\\n\\n\uae4c\uc9c0\ubc16\uc5d0 \uc124\uba85\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uac83\ub9cc\uc73c\ub85c \ub3d9\uc758\ub97c \ud574\uc918\uc11c \uc77c\ub2e8 java 17 \uc744 \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\uc9c0\ub9cc, \uc774\ubc88 \uae30\ud68c\uc5d0 \uc870\uae08 \ub354 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n## Java 17 \uacfc Java 11\uc758 \uc911\uc694\ud55c \ucc28\uc774\ub4e4\\n\\n\uae30\ub2a5\uc801\uc778 \ubd80\ubd84\uacfc, \uc228\uaca8\uc9c4 \ubd80\ubd84\uc744 \ub098\ub204\uc5b4\ubcfc \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n## \uae30\ub2a5\uc801\uc778 \ucc28\uc774\uc810\\n\\n\uc5b8\uc81c\ub098 \uc9c1\uc811 \ucc28\uc774\ub97c \ubcf4\uba74 \ub354 \uc9c1\uad00\uc801\uc774\uae30 \ub54c\ubb38\uc5d0, \uc9c1\uc811 \ucf54\ub4dc\ub97c \ubcf4\uba74\uc11c \uc124\uba85\uc744 \ud574\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n### record \ud074\ub798\uc2a4\\n\\n\uac04\ub2e8\ud55c dto \ud074\ub798\uc2a4\ub97c \ub9cc\ub4e4\uc5c8\uc744 \ub54c \ucf54\ub4dc\uac00 \uc815\ub9d0 \uac04\ub2e8\ud574\uc9c0\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n#### Java 11\\n\\n```\\npublic class Dto {\\n private final int data;\\n\\n public Dto(int data) {\\n this.data = data;\\n }\\n\\n public int getData() {\\n return data;\\n }\\n}\\n```\\n\\nlombok \uc744 \uc0ac\uc6a9\ud588\uc744 \ub54c\\n\\n```\\n\\n@Getter\\n@AllArgsConstructor\\npublic class Dto {\\n private final int data;\\n}\\n```\\n\\n#### Java17\\n\\n```\\npublic record Record(int data) {\\n}\\n```\\n\\n\uc774\ub807\uac8c \ubcf4\uba74 \ud6e8\uc52c \uac04\ub2e8\ud574\uc9c4 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n#### \uc608\uc0c1\ub418\ub294 \ubb38\uc81c\uc810\\n\\nobjectMapper\ub97c \uc0ac\uc6a9\ud558\uba74 \uc5b4\ub5bb\uac8c \ub418\ub098\uc694? noArgsConstructor \uac00 \ud544\uc694\ud558\uc9c0 \uc54a\ub098\uc694?\\n\\n```java\\nclass RecordTest {\\n\\n @Test\\n void objectMapper_\ub85c_\ubcc0\ud658() throws JsonProcessingException {\\n // given\\n ObjectMapper objectMapper = new ObjectMapper();\\n Record record = new Record(1);\\n\\n // when\\n String json = objectMapper.writeValueAsString(record);\\n\\n // then\\n assertEquals(\\"{\\\\\\"data\\\\\\":1}\\", json);\\n }\\n\\n @Test\\n void string_\uc5d0\uc11c_\uac1d\uccb4\ub85c_\ubcc0\ud658() throws JsonProcessingException {\\n // given\\n String json = \\"{\\\\\\"data\\\\\\":1}\\";\\n ObjectMapper objectMapper = new ObjectMapper();\\n\\n // when\\n Record record = objectMapper.readValue(json, Record.class);\\n\\n // then\\n assertEquals(1, record.data());\\n }\\n}\\n```\\n\\n\uc774 \ud14c\uc2a4\ud2b8\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \uac83\ucc98\ub7fc \uc131\uacf5\uc801\uc73c\ub85c deserialize, serialize \uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4\\n\\n### toList() method\\n\\n#### Java 11\\n\\n\uc774 \ubd80\ubd84\ub3c4 \uc815\ub9d0 \ud3b8\uc758\uc131\uc774 \ub192\ub2e4\uace0 \uc0dd\uac01\ud558\ub294 \ubd80\ubd84 \uc911 \ud558\ub098\uc778\ub370\uc694\\n\\nCollectors.toList() \ub300\uc2e0 toList() \ub9cc\uc73c\ub85c\ub3c4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4\\n\\n```java\\npublic class ToListWith11 {\\n\\n public static void main(String[] args) {\\n List list = List.of(1, 2, 3, 4, 5);\\n List result = list.stream()\\n .filter(i -> i > 3)\\n .collect(Collectors.toList());\\n System.out.println(result);\\n }\\n}\\n```\\n\\n#### Java 17\\n\\n```java\\npublic class ToListWith17 {\\n\\n public static void main(String[] args) {\\n List list = List.of(1, 2, 3, 4, 5);\\n List result = list.stream()\\n .filter(i -> i > 3)\\n .toList();\\n System.out.println(result);\\n }\\n}\\n```\\n\\n### switch expression\\n\\n#### Java 11\\n\\n\uc6b0\ud14c\ucf54\uc5d0\uc11c\ub294 switch, case \ub97c \uc2eb\uc5b4\ud558\uae30\uc5d0 \ubcfc \uc218\ub294 \uc5c6\uaca0\uc9c0\ub9cc\\n\\nswitch \ubb38\uc5d0\ub3c4 \uc815\ub9d0 \ud3b8\ud558\uac8c \ubc14\ub00c\uc5c8\ub294\ub370\uc694\\n\\n```java\\npublic class SwitchWith11 {\\n\\n public static void main(String[] args) {\\n String day = \\"Sunday\\";\\n int result = 0;\\n switch (day) {\\n case \\"Monday\\":\\n result = 1;\\n break;\\n case \\"Tuesday\\":\\n result = 2;\\n break;\\n case \\"Wednesday\\":\\n result = 3;\\n break;\\n case \\"Thursday\\":\\n result = 4;\\n break;\\n case \\"Friday\\":\\n result = 5;\\n break;\\n case \\"Saturday\\":\\n result = 6;\\n break;\\n case \\"Sunday\\":\\n result = 7;\\n break;\\n }\\n System.out.println(result);\\n }\\n}\\n```\\n\\n#### Java 17\\n\\n```java\\npublic class SwitchWith17 {\\n\\n public static void main(String[] args) {\\n String day = \\"Sunday\\";\\n int result = switch (day) {\\n case \\"Monday\\" -> 1;\\n case \\"Tuesday\\" -> 2;\\n case \\"Wednesday\\" -> 3;\\n case \\"Thursday\\" -> 4;\\n case \\"Friday\\" -> 5;\\n case \\"Saturday\\" -> 6;\\n case \\"Sunday\\" -> 7;\\n default -> 0;\\n };\\n System.out.println(result);\\n }\\n}\\n```\\n\\n\ucf54\ub4dc \ub7c9\uc774 \uc5c4\uccad \uc904\uc5b4\ub4e0 \uac83\uc744 \ud655\uc778\ud558\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n### instanceof pattern matching\\n\\n\ubb3c\ub860 instanceof \ub97c \uc0ac\uc6a9\ud560 \uacbd\uc6b0\uac00 \ub9ce\uc740\uac00? \ud558\uba74 \ub9ce\uc9c0\ub294 \uc54a\uaca0\uc9c0\ub9cc\\n\\n\uc544\ub798\uc640 \uac19\uc774 \ubcc0\uacbd\ub418\uc5c8\uc2b5\ub2c8\ub2e4\\n\\n#### Java 11\\n\\n```java\\npublic class InstanceOfWith11 {\\n\\n public static void main(String[] args) {\\n Object obj = \\"Hello\\";\\n if (obj instanceof String) {\\n String str = (String) obj;\\n System.out.println(str.toUpperCase());\\n }\\n }\\n}\\n```\\n\\n#### Java 17\\n\\n```java\\npublic class InstanceOfWith17 {\\n\\n public static void main(String[] args) {\\n Object obj = \\"Hello\\";\\n if (obj instanceof String str) {\\n System.out.println(str.toUpperCase());\\n }\\n }\\n}\\n```\\n\\n### number format\\n\\n\uc774 \uae30\ub2a5\uc740 12\uc5d0 \ub098\uc654\ub294\ub370\uc694\\n\\n\uc5b8\uc5b4\ubcc4\ub85c \uc22b\uc790\ub97c \ud45c\ud604\ud558\ub294 \ubc29\uc2dd\uc774 \ub2e4\ub974\uc9c0\ub9cc, \uc27d\uac8c \ud45c\ud604\ud560 \uc218 \uc788\ub3c4\ub85d \ub3c4\uc640\uc8fc\ub294 \uae30\ub2a5\uc785\ub2c8\ub2e4\\n\\n#### Java 17\\n\\n```java\\npublic class NumberFormatterWith11 {\\n public static void main(String[] args) {\\n int number = 1_000_000;\\n\\n String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);\\n\\n System.out.println(result.equals(\\"100\ub9cc\\"));\\n }\\n}\\n```\\n\\n\ub098\uba38\uc9c0 \ubd80\ubd84\uc740 \uc0ac\uc2e4 \uadf8\ub807\uac8c \ud070 \uc5ed\ud560\uc744 \ud560 \uac83 \uac19\uc9c0\ub294 \uc54a\uc544\uc11c \uc0dd\ub7b5\ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n## \uc228\uaca8\uc9c4 \ubd80\ubd84\ub4e4\\n\\n![gc throughput](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXhFJg%2Fbtsl9uZOa5R%2FrzrlotCERUqAWM2pknDwq0%2Fimg.png)\\n\\n\uc704\uc758 \uc0ac\uc9c4\uc740 gc \uc758 \ubc84\uc804\ubcc4 \ucc98\ub9ac\ub7c9\uc785\ub2c8\ub2e4.\\n\\nG1 GC \ub97c \uae30\uc900\uc73c\ub85c \ubcf8\ub2e4\uba74 Java8 \uacfc\uc758 \ucc28\uc774\ub294 15% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uace0, java 11\uacfc\ub294 10% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![gc latency](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZusmb%2Fbtsl5jYN68u%2FWCKRCFnYjQK4AjkcHRNAt0%2Fimg.png)\\n\\n\uc704\uc758 \uc0ac\uc9c4\uc740 gc\uc758 \ubc84\uc804\ubcc4 \uc9c0\uc5f0\uc2dc\uac04\uc785\ub2c8\ub2e4.\\n\\nG1 GC \ub97c \uae30\uc900\uc73c\ub85c \ubcf8\ub2e4\uba74 Java8 \uacfc\uc758 \ucc28\uc774\ub294 30% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uace0, java 11\uacfc\ub294 25% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc640 \uac19\uc774, \ub2e8\uc21c\ud558\uac8c \uc0c8\ub85c\uc6b4 \uae30\ub2a5\ub9cc \ucd94\uac00\ub418\ub294 \uac83\uc774 \uc544\ub2c8\ub77c \uafb8\uc900\ud788 \uc131\ub2a5\ub3c4 \ud5a5\uc0c1\ub418\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubd80\ubd84\uc744 \uace0\ub824\ud588\uc744 \ub54c, Java 17\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ucc38\uace0\\n\\n- [https://kstefanj.github.io/2021/11/24/gc-progress-8-17.html](https://kstefanj.github.io/2021/11/24/gc-progress-8-17.html)"},{"id":"2","metadata":{"permalink":"/2","source":"@site/blog/2023-07-01-nunu-gitbranch.mdx","title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","description":"\ud604\uc7ac \uc0c1\ud669\uc740 \uc5b4\ub5a4\ub370?","date":"2023-07-01T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 1\uc77c","tags":[{"label":"git","permalink":"/tags/git"},{"label":"branch","permalink":"/tags/branch"},{"label":"git branch","permalink":"/tags/git-branch"},{"label":"github flow","permalink":"/tags/github-flow"},{"label":"gitlab flow","permalink":"/tags/gitlab-flow"},{"label":"git flow","permalink":"/tags/git-flow"}],"readingTime":10.735,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"2","title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","authors":["nunu"],"tags":["git","branch","git branch","github flow","gitlab flow","git flow"]},"prevItem":{"title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","permalink":"/3"},"nextItem":{"title":"Hello World","permalink":"/1"}},"content":"## \ud604\uc7ac \uc0c1\ud669\uc740 \uc5b4\ub5a4\ub370?\\n\\n\ud604\uc7ac \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c\ub294 \ud504\ub860\ud2b8 \ucf54\ub4dc\uc640 \ubc31\uc5d4\ub4dc \ucf54\ub4dc\uac00 \uac19\uc740 \ub808\ud3ec\uc9c0\ud1a0\ub9ac\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud504\ub860\ud2b8\uc640 \ubc31\uc5d4\ub4dc\uac00 \uac19\uc774 \uc791\uc5c5\ud558\uae30\uc5d0, \uc758\ub3c4\uce58 \uc54a\uc740 \ucda9\ub3cc\uc774 \uc790\uc8fc \uc0dd\uae38 \uc218 \uc788\ub294 \uad6c\uc870\uc774\uae30\uc5d0, \uc774\ub97c git branch \uc804\ub7b5\uc73c\ub85c \ucda9\ub3cc\uc744 \uc904\uc774\uace0\uc790 \ud569\ub2c8\ub2e4\\n\\n## Git Branch \uc804\ub7b5\uc774\ub780?\\n\\ngit\uc744 \uc0ac\uc6a9\ud574\uc11c \uc18c\ud504\ud2b8\uc6e8\uc5b4 \uac1c\ubc1c\uc744 \uad00\ub9ac\ud558\ub294 \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n\uc5ec\ub7ec \uac1c\ubc1c\uc790\uac00 \ub3d9\uc2dc\uc5d0 \uc791\uc5c5\ud558\uace0 \ucf54\ub4dc\ub97c \ud1b5\ud569\ud560 \ub54c \uc0dd\uae30\ub294 \ucda9\ub3cc\uc744 \ud6a8\uc728\uc801\uc73c\ub85c \uc870\uc815\ud558\uae30 \uc704\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n## \uc65c git branch \uc804\ub7b5\uc774 \uc911\uc694\ud55c\ub370?\\n\\n\uc544\ub798\uc5d0 \uc788\ub294 4\uac00\uc9c0\ub97c \uc81c\uc678\ud558\uace0\ub3c4 \ud6e8\uc52c \ub9ce\uc740 \uc7a5\uc810\uc774 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### 1\\\\. \ub3d9\uc2dc \uc791\uc5c5\uc774 \ud3b8\ud558\ub2e4\\n\\n\uc5ec\ub7ec \uc0ac\ub78c\uc774 \ub3c5\ub9bd\uc801\uc73c\ub85c \uc791\uc5c5\ud558\uace0, \ucee4\ubc0b\uc744 \ud560 \ub54c, \uc790\uc2e0\uc758 \ube0c\ub79c\uce58\uc5d0\uc11c \ubcc0\uacbd \uc0ac\ud56d\uc744 \ucee4\ubc0b\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\ube0c\ub79c\uce58\uac00 \ubcd1\ud569\ub420 \ub54c\ub9cc \ucda9\ub3cc\uc744 \ud574\uacb0\ud558\uba74 \ub418\ub2c8, \uc544\ubb34 \uaddc\uce59\uc774 \uc5c6\ub294 \uac83\ubcf4\ub2e4 \ucda9\ub3cc \uc2dc\uc810\uc774 \uba85\ud655\ud574\uc9c0\uae30\uc5d0 \uc0dd\uc0b0\uc131\uc744 \ub192\uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### 2\\\\. \ubaa9\uc801\uc774 \uba85\ud655\ud55c \ube0c\ub79c\uce58\\n\\n\uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc758 \uc0c1\ud0dc\uc5d0 \uba87 \uac00\uc9c0\uac00 \uc788\ub294\ub370, \uc548\uc815\ub41c \ud504\ub85c\ub355\uc158, \ud14c\uc2a4\ud2b8 \ud658\uacbd, \uae30\ub2a5 \ucd94\uac00 \ud658\uacbd... \ub4f1\uc774 \uc788\uc2b5\ub2c8\ub2e4\\n\\n\uc5ec\ub7ec \uae30\ub2a5\ubcc4 \ube0c\ub79c\uce58(\uc548\uc815\ub41c \ubc84\uc804\uc758 \ucf54\ub4dc\ub9cc\uc774 \uad00\ub9ac\ub418\ub294 \ube0c\ub79c\uce58, \ud14c\uc2a4\ud2b8 \ud658\uacbd\uc744 \uc704\ud55c \ube0c\ub79c\uce58, \uae30\ub2a5 \ucd94\uac00\ub97c \uc704\ud55c \ube0c\ub79c\uce58)\ub97c\\n\\n\ub124\uc774\ubc0d\uc744 \ud1b5\ud574 \uad6c\ubd84\ud558\uba74 \uac01\uac01\uc758 \ube0c\ub79c\uce58\uc5d0 \ub300\ud574\uc11c \ucd94\uac00\uc801\uc778 \uc124\uba85\uc744 \ud560 \ud544\uc694 \uc5c6\uc774 \uba85\ud655\ud558\uac8c \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### 3\\\\. \ubc30\ud3ec \ud30c\uc774\ud504\ub77c\uc778 \uad00\ub9ac\uac00 \ud3b8\ud568\\n\\n\ube0c\ub79c\uce58\uac00 \ub124\uc774\ubc0d\uc73c\ub85c \uba85\ud655\ud558\uac8c \uad6c\ubd84\uc774 \ub418\uc5b4\uc788\ub2e4\uba74, \uc870\uac74\uc744 \uc124\uc815\ud558\uae30 \uc27d\uc2b5\ub2c8\ub2e4.\\n\\n\ud2b9\uc815 \ud0c0\uc785\uc758 \ube0c\ub79c\uce58\uc5d0 push \ub418\uc5c8\uc744 \ub54c, pull request\ub97c \ub9cc\ub4e4\uc5c8\uc744 \ub54c \uac19\uc740 \uc870\uac74\uc5d0 \ub530\ub978 \uc2a4\ud06c\ub9bd\ud2b8\ub97c \ub9cc\ub4e4\uc5b4\ub454\ub2e4\uba74 CI/CD\ub97c \uad6c\ucd95\ud558\uae30 \uc27d\uc2b5\ub2c8\ub2e4.\\n\\n#### 4\\\\. \ubc84\uc804 \uad00\ub9ac\uac00 \ud3b8\ub9ac\ud558\ub2e4\\n\\n\uc11c\ubc84\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uacbc\uc744 \ub54c, \uc5b4\ub5a4 \ube0c\ub79c\uce58\ub85c \ub3cc\uc544\uac00\uc11c \ub864\ubc31\uc744 \ud574\uc57c \ud558\ub294\uc9c0\uc5d0 \ub300\ud55c \uac83\ub4e4\uc774 \uba85\ud655\ud569\ub2c8\ub2e4.\\n\\n\uc548\uc815\ub41c \ube0c\ub79c\uce58\uac00 \uc5b4\ub5a4 \uac83\uc778\uc9c0 \uba85\ud655\ud558\uae30\uc5d0, \ub864\ubc31 \uacfc\uc815\uc5d0 \ub300\ud55c \uc758\uc0ac\uacb0\uc815\uc744 \uc904\uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc5b4\ub5a4 \uc885\ub958\uac00 \uc788\ub294\uc9c0 \ub354 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## Git Branch \uc804\ub7b5\uc758 \uc885\ub958\ub294?\\n\\n\ucd1d 3\uac00\uc9c0\uc758 \uc804\ub7b5\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\\\\. Github Flow\\n\\n2\\\\. Gitlab Flow\\n\\n3\\\\. Git Flow\\n\\ngit\uc744 \uc0ac\uc6a9\ud558\uae30\uc5d0, Git Flow\ub77c\ub294 \ub124\uc774\ubc0d\uc774 \uac00\uc7a5 \uc9c1\uad00\uc801\uc774\uace0 \uc720\uba85\ud55c\ub370\uc694.\xa0\\n\\n3\uac00\uc9c0 \uc804\ub7b5 \uc911\uc5d0\uc11c \uac00\uc7a5 \ubcf5\uc7a1\ud558\uae30\uc5d0, \uc26c\uc6b4 \uc21c\uc11c\ub300\ub85c \uc9c4\ud589\ud574 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## 1\\\\. Github Flow\\n\\n\uadf8\ub9bc\uc73c\ub85c flow \uac04\ub2e8\ud558\uac8c \ubcf4\uace0 \uac00\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblgfI6%2FbtslEWRFdaJ%2F3KmwR2yqlfgKk0msnufYNk%2Fimg.png)\\n\\n![img2](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtUzxm%2FbtslJ1xWHzy%2FMP0s11FoCTKpqwQnUJUm30%2Fimg.png)\\n\\n\ube0c\ub79c\uce58\ub294 \ucd1d 2\uac00\uc9c0 \uc885\ub958\uac00 \uc874\uc7ac\ud569\ub2c8\ub2e4\\n\\n#### 1\\\\. master \ube0c\ub79c\uce58\\n\\n\uc5ec\uae30\uc5d0 \uba38\uc9c0\uac00 \ub418\uba74 \ubc30\ud3ec\uac00 \ub418\ub3c4\ub85d CD\ub97c \uc5f0\uacb0\ud574 \ub193\uc740 \uacbd\uc6b0\uac00 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\\n\uc548\uc815\ub41c \ubc84\uc804\uc758 \ucf54\ub4dc\uac00 \uad00\ub9ac\ub418\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4.\\n\\n#### 2\\\\. feature \ube0c\ub79c\uce58\\n\\n\uae30\ub2a5 \ucd94\uac00, \ubc84\uadf8 \uc218\uc815 \ub4f1 \ubaa8\ub4e0 \uc791\uc5c5\uc740 feature \ube0c\ub79c\uce58\uc5d0\uc11c \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\nmaster \ube0c\ub79c\uce58\uc5d0\uc11c \uc0c8\ub85c\uc6b4 \ube0c\ub79c\uce58\ub97c \ub9cc\ub4e4\uc5b4\uc11c, \ub9c8\uc2a4\ud130\ub85c \uba38\uc9c0\ub418\ub294 \ub2e8\uc21c\ud55c \uc0ac\uc774\ud074\uc744 \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### \uc7a5\uc810\\n\\n\uc704\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \uac83\ucc98\ub7fc 2\uc885\ub958\uc758 \ube0c\ub79c\uce58\ub9cc \uc788\uae30\uc5d0, \uc815\ub9d0 \uac04\ub2e8\ud569\ub2c8\ub2e4.\\n\\n\ud559\uc2b5 \uacfc\uc815\uae4c\uc9c0\uc758 \ub7ec\ub2dd \ucee4\ube0c\uac00 \uac70\uc758 \uc5c6\ub2e4\uc2dc\ud53c \ud558\uae30\uc5d0, \uac04\ub2e8\ud55c \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc801\uc6a9\ud558\uae30 \uc815\ub9d0 \uc88b\uc2b5\ub2c8\ub2e4.\\n\\n\ub9b4\ub9ac\uc988 \ub418\uc9c0 \uc54a\uc740 \ucf54\ub4dc\uac00 \ucd5c\uc18c\ud654\ub429\ub2c8\ub2e4. \ucd5c\uc2e0 \ubc84\uc804\uc758 \ucf54\ub4dc\uc640 \ucd5c\ub300\ud55c \ube60\ub974\uac8c \ub3d9\uae30\ud654\ub97c \uacc4\uc18d\ud574\uc11c \uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n#### \ub2e8\uc810\\n\\n\ubaa8\ub4e0 \ucf54\ub4dc\ub294 \ub2e4 master \ube0c\ub79c\uce58\uc5d0 \uba38\uc9c0\uac00 \ub418\uc5b4\uc57c \ud55c\ub2e4\ub294 \uc810\uc774 \uac1c\ubc1c \uc11c\ubc84\uc640, \uc6b4\uc601\uc11c\ubc84\ub97c \ub098\ub204\uae30 \uc560\ub9e4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uac1c\ubc1c \uc11c\ubc84\uc5d0 \ubc30\ud3ec\ub97c \ud558\uace0 \uc2f6\uc740 \uc0c1\ud669\uc774\ub77c\uba74, master\uc5d0 \uba38\uc9c0\uac00 \ub418\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uba38\uc9c0\uac00 \ub41c \uc774\ud6c4\uc5d0 cd \ud30c\uc774\ud504\ub77c\uc778\uc744 \ud1b5\ud574\uc11c \uac1c\ubc1c \uc11c\ubc84\uc640 \uc6b4\uc601 \uc11c\ubc84 \ubaa8\ub450\uc5d0 \ubc30\ud3ec\uac00 \ub429\ub2c8\ub2e4.\\n\\n\uc5ec\ub7ec \ud658\uacbd\uc744 \ub098\ub204\uace0 \uad00\ub9ac\ub97c \ud558\uace0 \uc2f6\uc73c\uc2dc\ub2e4\uba74 \ub2e4\uc74c\uc5d0 \uc18c\uac1c\ud574\ub4dc\ub9b4 \uc804\ub7b5\uc744 \uc0ac\uc6a9\ud574 \ubcf4\uc154\ub3c4 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4\\n\\n## 2\\\\. Gitlab Flow\\n\\n\uadf8\ub9bc\uc73c\ub85c flow \uac04\ub2e8\ud558\uac8c \ubcf4\uace0 \uac00\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![img2](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdlarwn%2FbtslKkYqqTR%2FXi8NnZIEXahoVFusk0xV31%2Fimg.png)\\n\\n\ubc11\uc5d0 \ud658\uacbd\uc740 \ucd1d 2\uac1c\uc758 \uc11c\ubc84\uac00 \uc874\uc7ac\ud560 \ub54c\ub97c \uac00\uc815\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\\\\. pre-production \uc11c\ubc84\\n\\n2\\\\. production \uc11c\ubc84\\n\\n\ud3b8\uc758\ub97c \uc704\ud574 main\uc5d0 \uba38\uc9c0\ub418\ub294 \uacfc\uc815\uc740 \uac04\ub2e8\ud558\uac8c \ud45c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![img3](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbkNc9%2FbtslJ0MBrWb%2F0CT7DVQoCDFOpbqyAko9mk%2Fimg.png)\\n\\n#### \ube0c\ub79c\uce58 \uc885\ub958\\n\\n\ucd1d 3\uac00\uc9c0 \ube0c\ub79c\uce58\uac00 \ud544\uc694\ud558\uace0, \ucd94\uac00\uc5d0 \ub530\ub77c\uc11c \ub354 \ucd94\uac00\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\\\\. main(or develop) \ube0c\ub79c\uce58\\n\\n\uae30\ub2a5\uc5d0 \ub300\ud55c \uac1c\ubc1c\uc774 \uc644\ub8cc\ub418\uc5c8\uc9c0\ub9cc, \uc5ec\uae30\uc5d0 \uba38\uc9c0\ub418\uc5b4\ub3c4 \ubc14\ub85c \ubc30\ud3ec\ub418\uc9c0\ub294 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n2\\\\. feature\ube0c\ub79c\uce58\\n\\n\uae30\ub2a5\uc744 \uac1c\ubc1c\ud558\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4. Github Flow \uc640\ub3c4 \uc720\uc0ac\ud569\ub2c8\ub2e4.\\n\\n3\\\\. production \ube0c\ub79c\uce58\\n\\n\uc2e4\uc81c \ubc30\ud3ec\uac00 \uc77c\uc5b4\ub098\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4.\xa0\\n\\n\uc5ec\uae30\uc5d0 \uba38\uc9c0\uac00 \ub418\ub294 \uc21c\uac04 \ubc30\ud3ec\uac00 \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\n\uc704 \uc0ac\uc9c4\uc5d0 \uc788\ub294 \uac83\ucc98\ub7fc, \ud544\uc694\uc5d0 \ub530\ub77c\uc11c pre-production\uc774\ub098, staging \uac19\uc740 \ud658\uacbd\uc5d0 \ub530\ub978 \ube0c\ub79c\uce58\ub97c \ucd94\uac00\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### \ud2b9\uc9d5\\n\\n1\\\\. \ubb34\uc870\uac74 \ub2e8\ubc29\ud5a5\uc73c\ub85c \uba38\uc9c0\uac00 \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\n\uae34\uae09\ud558\uac8c \ub77c\uc774\ube0c \uc11c\ubc84\uc5d0 \uc218\uc815\uc744 \ud574\uc57c \ud560 \ub54c, production \ubd80\ud130 \uc2dc\uc791\ud558\ub294 \uac83\uc774 \uc544\ub2cc, main \ubd80\ud130 \ucc28\uadfc\ucc28\uadfc \uc62c\ub77c\uac00\uc57c \ud569\ub2c8\ub2e4\\n\\n2\\\\. \ud658\uacbd\uc5d0 \ub530\ub77c \ube0c\ub79c\uce58 \uc885\ub958\uac00 \ub298\uc5b4\ub0a0 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc704 \uc0ac\uc9c4\uc5d0\uc11c\ub294 pre-production \uc774 \uadf8 \uc608\uc2dc\uac00 \ub418\uaca0\ub124\uc694.\\n\\n#### \uc7a5\uc810\\n\\n1\\\\. Github Flow\uc5d0\uc11c \ud658\uacbd\ubcc4 \ube0c\ub79c\uce58\ub97c \ud1b5\ud574\uc11c \uac1c\ubc1c \uc11c\ubc84\ub098 pre-production \uc11c\ubc84\uc5d0 \ubc84\uc804\uc744 \uae54\ub054\ud558\uac8c \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## 3\\\\. Git Flow\\n\\n\ube0c\ub79c\uce58 \uc804\ub7b5 \uc911 \uac00\uc7a5 \ucc98\uc74c\uc73c\ub85c \uc720\uba85\ud574\uc9c4 \ube0c\ub79c\uce58 \uc804\ub7b5\uc785\ub2c8\ub2e4.\\n\\n\ubc30\ud3ec\uac00 \ud2b9\uc815 \uc8fc\uae30\ub97c \uac00\uc9c0\uace0 \uc788\ub294 \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc77c \ub54c, \uac00\uc7a5 \uc801\ud569\ud569\ub2c8\ub2e4.\\n\\n\uac00\uc7a5 \ubcf5\uc7a1\ud55c \uc804\ub7b5\uc744 \uac00\uc9c0\uace0 \uc788\uc5b4\uc11c, \ubaa8\ub450\uac00 \ube0c\ub79c\uce58 \uc804\ub7b5\uc5d0 \ub300\ud574\uc11c \uc774\ud574\ud558\uace0 \uc788\ub2e4\uba74 \uc5ed\ud560\uc5d0 \ub530\ub978 \uae54\ub054\ud55c \ubd84\ub9ac\uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4\\n\\n\uadf8\ub9bc\uc73c\ub85c \ubcf4\uace0 \uac00\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n![img4](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd9WzKn%2FbtslKdkAHNP%2F2fCAqKSVxtPVWqYnBS8juk%2Fimg.png)\\n\\n\uac00\uc7a5 \uc720\uba85\ud55c \ube0c\ub79c\uce58 \uc804\ub7b5\uc774\uc9c0\ub9cc, \uac00\uc7a5 \uc5b4\ub824\uc6b4 \uc804\ub7b5\uc774\uae30\ub3c4 \ud569\ub2c8\ub2e4.\\n\\n#### \ud2b9\uc9d5\\n\\n1\\\\. \ube0c\ub79c\uce58\uc5d0 \ub300\ud574\uc11c \uc591\ubc29\ud5a5\uc73c\ub85c \uba38\uc9c0\uac00 \uc77c\uc5b4\ub0a9\ub2c8\ub2e4\\n\\nrelease \ube0c\ub79c\uce58\uc5d0\uc11c \ubc84\uadf8 \uc218\uc815\uc774 \uc77c\uc5b4\ub098\uba74, develop \ube0c\ub79c\uce58\uc5d0\ub3c4 \uba38\uc9c0\ud574\uc918\uc57c \ud569\ub2c8\ub2e4.\\n\\nhotfix \ube0c\ub79c\uce58\ub97c main \ube0c\ub79c\uce58\ubfd0\ub9cc \uc544\ub2c8\ub77c, develop \ube0c\ub79c\uce58\uc5d0\ub3c4 \uba38\uc9c0\ud574\uc918\uc57c \ud569\ub2c8\ub2e4\\n\\n\ube0c\ub79c\uce58\uc758 \uc885\ub958\uac00 5\uac00\uc9c0\ub098 \ub429\ub2c8\ub2e4\\n\\n1\\\\. main\\n\\nproduction \uc774 \ubc30\ud3ec\ub418\uc5c8\uc744 \ub54c, \uc774 \ube0c\ub79c\uce58\uc5d0 \uba38\uc9c0\ub418\ub294 \uac83\uc774 \uae30\uc900\uc774 \ub429\ub2c8\ub2e4.\\n\\n2\\\\. develop\xa0\\n\\n\uc704\uc5d0\uc11c \uc124\uba85\ub4dc\ub838\ub358 \ube0c\ub79c\uce58\ub4e4\uacfc \ud070 \ucc28\uc774\uac00 \uc5c6\uc774 \ubc30\ud3ec \uc804 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4.\\n\\n3\\\\. feature\\n\\n\uae30\ub2a5\uc744 \uac1c\ubc1c\ud560 \ub54c \uc0ac\uc6a9\ud558\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4. \uc774\uac83\ub3c4 \uc704\uc640 \ud070 \ucc28\uc774\uac00 \uc5c6\uc2b5\ub2c8\ub2e4\\n\\n4\\\\. release\\n\\nGitlab Flow\uc5d0\uc11c pre-production\uc5d0 \ud574\ub2f9\ud55c\ub2e4\uace0 \ubd10\ub3c4 \ubb34\ubc29\ud569\ub2c8\ub2e4.\\n\\n\uc5ec\uae30\uc11c \ubc84\uadf8 \uc218\uc815\uc774 \uc77c\uc5b4\ub0ac\uc744 \uacbd\uc6b0\uc5d0,\xa0 develop\uc5d0 \uba38\uc9c0\ud558\ub294 \uac83\uc744 \uae4c\uba39\uc73c\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\n5\\\\. hotfix\\n\\nmain \ube0c\ub79c\uce58\uc5d0\uc11c \uc0dd\uc131\ub41c \ube0c\ub79c\uce58\ub85c, \uae34\uae09\ud55c \ubcc0\uacbd\uc0ac\ud56d\uc744 \ucc98\ub9ac\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub54c, develop\uc5d0 \uba38\uc9c0\ud558\ub294 \uac83\uc744 \uae5c\ube61\ud558\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\n\ub354 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\uc2e4 \ubd84\uc740 \uc544\ub798 \ub9c1\ud06c\ub4e4\uc744 \ud655\uc778\ud574 \ubcf4\uc138\uc694\\n\\n## \uc6b0\ub9ac \ud504\ub85c\uc81d\ud2b8\uc5d0\ub294 \uc5b4\ub5a4 \uac83\uc774 \uc801\uc808\ud560\uae4c?\\n\\n\ub098\uc911\uc5d0 \uac1c\ubc1c \uc11c\ubc84 \ud639\uc740 \uc2a4\ud14c\uc774\uc9d5 \uc11c\ubc84\ub97c \ub450\uace0 \uc2f6\uae30\uc5d0, \uc774 \ubd80\ubd84\uc5d0 \ub300\ud55c \ucc98\ub9ac\uac00 \ubd80\uc871\ud55c Github Flow\ub294 \uc801\uc808\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\nGit Flow\ub294 \uae54\ub054\ud558\uac8c \ucc98\ub9ac\ud560 \uc218 \uc788\uc9c0\ub9cc, \ub7ec\ub2dd \ucee4\ube0c\uac00 Gitlab Flow \ubcf4\ub2e4 \uc57d\uac04 \ub354 \uc788\uc5b4\uc11c, \ube60\ub974\uac8c \uac1c\ubc1c\ud558\ub294 \ucde8\uc9c0\uc5d0 \ub9de\uc9c0 \uc54a\uc544 \ubcf4\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c Gitlab Flow\ub97c \uc0ac\uc6a9\ud558\ub824\uace0 \ud569\ub2c8\ub2e4\xa0\\n\\n\ucc38\uace0\\n\\n[https://techblog.woowahan.com/2553/](https://techblog.woowahan.com/2553/)\\n\\n[https://docs.gitlab.com/ee/topics/gitlab\\\\_flow.html](https://docs.gitlab.com/ee/topics/gitlab_flow.html)"},{"id":"1","metadata":{"permalink":"/1","source":"@site/blog/2023-06-29-hello-car-ffeine.mdx","title":"Hello World","description":"\uc548\ub155\ud558\uc138\uc694","date":"2023-06-29T00:00:00.000Z","formattedDate":"2023\ub144 6\uc6d4 29\uc77c","tags":[{"label":"hello","permalink":"/tags/hello"},{"label":"world","permalink":"/tags/world"}],"readingTime":0.025,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"},{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"},{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"},{"name":"\ud0a4\uc544\ub77c","title":"Backend","url":"https://github.com/kiarakim","imageURL":"https://github.com/kiarakim.png","key":"kiara"},{"name":"\uc57c\ubbf8","title":"Frontend","url":"https://github.com/feb-dain","imageURL":"https://github.com/feb-dain.png","key":"yummy"},{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"},{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"1","title":"Hello World","authors":["boxster","nunu","jay","kiara","yummy","scent","gabriel"],"tags":["hello","world"]},"prevItem":{"title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","permalink":"/2"}},"content":"\uc548\ub155\ud558\uc138\uc694"}]}')}}]); \ No newline at end of file diff --git a/assets/js/2e801cce.cf96116c.js b/assets/js/2e801cce.cf96116c.js new file mode 100644 index 00000000..e3417f43 --- /dev/null +++ b/assets/js/2e801cce.cf96116c.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9450],{16029:n=>{n.exports=JSON.parse('{"blogPosts":[{"id":"41","metadata":{"permalink":"/41","source":"@site/blog/2023-10-18-zero-time-deploy.mdx","title":"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec","description":"\uc548\ub155\ud558\uc138\uc694! \uce74\ud398\uc778\ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.","date":"2023-10-18T00:00:00.000Z","formattedDate":"2023\ub144 10\uc6d4 18\uc77c","tags":[{"label":"infra","permalink":"/tags/infra"},{"label":"ec2","permalink":"/tags/ec-2"},{"label":"cd","permalink":"/tags/cd"},{"label":"aws","permalink":"/tags/aws"},{"label":"zero-time","permalink":"/tags/zero-time"},{"label":"blue-green","permalink":"/tags/blue-green"}],"readingTime":8.93,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"41","title":"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec","authors":["jay"],"tags":["infra","ec2","cd","aws","zero-time","blue-green"]},"nextItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","permalink":"/40"}},"content":"\uc548\ub155\ud558\uc138\uc694! \uce74\ud398\uc778\ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\\n\uc5b4\ub5a4 \uacfc\uc815\uc73c\ub85c \uc9c4\ud589\uc744 \ud588\ub294\uc9c0 \uc791\uc131\ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4!\\n\\n---\\n\\n## \uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uacfc \ubb38\uc81c\uc810\\n\\n\uba3c\uc800 \uce74\ud398\uc778 \ud300\uc758 \uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n

    \\n\\n\\n1. Target branch\uc5d0 push\uac00 \ub418\uba74 Github Actions\uac00 \uc791\ub3d9\ud569\ub2c8\ub2e4.\\n2. Target branch\uc758 \uc18c\uc2a4 \ucf54\ub4dc\uac00 \ube4c\ub4dc\ub418\uc5b4\uc11c Docker Hub\uc5d0 \uc62c\ub77c\uac00\uac8c \ub429\ub2c8\ub2e4.\\n3. Github Actions\uc758 self-hosted runner\ub97c \ud1b5\ud574 infra \uc11c\ubc84\uc5d0\uc11c prod \uc11c\ubc84\ub85c \uc811\uadfc\ud558\uc5ec\uc11c \uae30\uc874\uc5d0 \ub744\uc6cc\uc9c4 \uc11c\ubc84\ub97c \ub2e4\uc6b4 \uc2dc\ud0b5\ub2c8\ub2e4.\\n4. Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c Docker image\ub97c pull\ud574\uc11c \uc11c\ubc84\ub97c \uac00\ub3d9\uc2dc\ud0b5\ub2c8\ub2e4.\\n\\n\\n
    \\n\uc774\ub7f0 \uacfc\uc815\uc73c\ub85c \ubc30\ud3ec \uc2a4\ud06c\ub9bd\ud2b8\uac00 \uc791\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uc774 \ubc29\ubc95\uc740 \uae30\uc874 \uc11c\ubc84\ub97c \ub2e4\uc6b4 \uc2dc\ud0a4\uace0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ub744\uc6b8 \ub54c \ub2e4\uc6b4 \ud0c0\uc784\uc774 \uc874\uc7ac\ud55c\ub2e4\ub294 \ubb38\uc81c\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n
    \\n\\n
    \\n\uc0ac\uc6a9\uc790 \uc785\uc7a5\uc5d0\uc11c\ub294 \uc798 \uc0ac\uc6a9\ud558\uace0 \uc788\ub294\ub370 \uac11\uc790\uae30 \uc11c\ube44\uc2a4\uac00 \uc791\ub3d9\ub418\uc9c0 \uc54a\ub294\ub2e4\uba74 \uc11c\ube44\uc2a4\uc5d0 \ub300\ud55c \uc2e0\ub8b0\uc131\uc774 \ub0ae\uc544\uc9c8 \uc218\ub3c4 \uc788\uace0 \uc774\ub7f0 \uc774\uc720\ub85c \uc774\ud0c8\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \uae30\uc874 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30\\n\\n\uc800\ud76c\ub294 \uba3c\uc800 \uc81c\ud55c\ub41c EC2 \uc778\uc2a4\ud134\uc2a4\ub85c \uc778\ud574 \ub864\ub9c1 \ubc30\ud3ec\uc758 \uc7a5\uc810\uc744 \uac00\uc838\uac08 \uc218 \uc5c6\uc5c8\uace0, \uce74\ub098\ub9ac \ubc29\uc2dd \ub610\ud55c \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \ud544\uc694\ub85c\ud55c \uc804\ub7b5\uc774 \uc544\ub2c8\uae30 \ub54c\ubb38\uc5d0 \ube44\uad50\uc801 \ub864\ubc31\ub3c4 \ube60\ub978 Blue/Green \uc804\ub7b5\uc744 \uc120\ud0dd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c\uc758 Blue/Green \ubb34\uc911\ub2e8 \ubc30\ud3ec \uc2dc\ub098\ub9ac\uc624\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\ud3b8\uc758\ub97c \uc704\ud574\uc11c [\uae30\uc874 \uc11c\ubc84(\uae30\uc874 \ud3ec\ud2b8) / \uc0c8\ub85c\uc6b4 \uc11c\ubc84(\uc0c8\ub85c\uc6b4 \ud3ec\ud2b8)] \ub77c\ub294 \uba85\uce6d\uc744 \uc0ac\uc6a9\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n
    \\n\\n1. Target branch\uc5d0 push\uac00 \ub418\uba74 Github Actions\uac00 \uc791\ub3d9\ud569\ub2c8\ub2e4.\\n2. Target branch\uc758 \uc18c\uc2a4 \ucf54\ub4dc\uac00 \ube4c\ub4dc\ub418\uc5b4\uc11c Docker Hub \uc5d0 \uc62c\ub77c\uac00\uac8c \ub429\ub2c8\ub2e4.\\n3. Github Actions\uc758 self-hosted runner\ub97c \ud1b5\ud574 infra \uc11c\ubc84\uc5d0\uc11c prod \uc11c\ubc84\ub85c \uc811\uadfc\ud574\uc11c Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c \uc0c8\ub85c\uc6b4 \ubc84\uc804\uc758 Image\ub97c\\n pull \ud574\uc635\ub2c8\ub2e4.\\n4. \ub9cc\uc57d 8080 \ud3ec\ud2b8\uc5d0 \uae30\uc874 \uc11c\ubc84\uac00 \ub744\uc6cc\uc838 \uc788\uc73c\uba74 8081 \ud3ec\ud2b8\ub97c \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c8 \ud3ec\ud2b8\ub85c \uc9c0\uc815\ud574\uc8fc\uace0, \ubc18\ub300\ub85c 8081 \ud3ec\ud2b8\uc5d0 \uae30\uc874 \uc11c\ubc84\uac00 \ub744\uc6cc\uc838 \uc788\uc73c\uba74 8080 \ud3ec\ud2b8\uc5d0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c8 \ud3ec\ud2b8\ub85c \uc9c0\uc815\ud574\uc90d\ub2c8\ub2e4.\\n5. \ubbf8\ub9ac Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c Docker image\ub97c [image+port]\ub77c\ub294 \ub124\uc774\ubc0d\uc73c\ub85c pull\uc744 \ud55c \ud6c4 \uc0c8\ub85c\uc6b4 \ud3ec\ud2b8\ub85c \uc11c\ubc84\ub97c \uac00\ub3d9\uc2dc\ud0b5\ub2c8\ub2e4.\\n6. \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \uac00\ub3d9 \ub410\ub294\uc9c0 \ud655\uc778\ud558\uae30 \uc704\ud574\uc11c \ud5ec\uc2a4 \uccb4\ud06c\ub97c \uc9c4\ud589\ud569\ub2c8\ub2e4. 20\ubc88 \ub3d9\uc548 \uc11c\ubc84\uac00 \uc815\uc0c1 \ub3d9\uc791\ud558\ub294\uc9c0 Spring Actuactor\ub97c \ud1b5\ud574\uc11c \ud655\uc778\uc744 \ud569\ub2c8\ub2e4.\\n7. \uc815\uc0c1 \uc791\ub3d9\uc774 \ub410\uc74c\uc744 \ud655\uc778\ud558\uba74 \ud604\uc7ac \uc778\uc2a4\ud134\uc2a4\uc5d0\ub294 2\ub300\uc758 \uc11c\ubc84\uac00 \ub744\uc6cc\uc838\uc788\uace0 \uc694\uccad\uc740 \uc5ec\uc804\ud788 \uae30\uc874 \uc11c\ubc84\ub85c \ub4e4\uc5b4\uac00\uac8c \ub429\ub2c8\ub2e4. \ub530\ub77c\uc11c Nginx\ub97c \ud1b5\ud574 \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub85c\\n \uc9c0\uc815\ud574\uc8fc\uace0 \uae30\uc874 \uc11c\ubc84\ub294 \ub0b4\ub824\uc90d\ub2c8\ub2e4.\\n\\n
    \\n\uc5ec\uae30\uae4c\uc9c0\uac00 \uce74\ud398\uc778 \ud300\uc758 \uc2dc\ub098\ub9ac\uc624\uc785\ub2c8\ub2e4.\\n\uadf8\ub807\ub2e4\uba74 \ud558\ub098\uc529 \uc2a4\ud06c\ub9bd\ud2b8\ub97c \ud655\uc778\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc740 \uc8fc\uc11d\uc73c\ub85c \ub2ec\uc544\ub450\uaca0\uc2b5\ub2c8\ub2e4 :)\\n\\n
    \\n
    \\n\\n### backend-deploy.yml\\n(Github Actions\uc5d0\uc11c \uc0ac\uc6a9)\\n\\n```yml\\nname: deploy\\n\\n# 1. prod/backend branch\uc5d0 push \uc791\uc5c5\uc774 \uc77c\uc5b4\ub098\uba74 \ud574\ub2f9 \uc791\uc5c5\uc744 \uc218\ud589\ud55c\ub2e4\\non:\\n push:\\n branches:\\n - prod/backend\\n\\njobs:\\n docker-build:\\n runs-on: ubuntu-latest\\n defaults:\\n run:\\n working-directory: ./backend\\n\\n steps:\\n # 2. \ub3c4\ucee4 \ud5c8\ube0c\uc5d0 \ub85c\uadf8\uc778\\n - name: Log in to Docker Hub\\n uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a\\n with:\\n username: ${{ secrets.DOCKERHUB_USERNAME }}\\n password: ${{ secrets.DOCKERHUB_PASSWORD }}\\n - uses: actions/checkout@v3\\n\\n # 3. JDK 17 \uc124\uce58 \ubc0f \ube4c\ub4dc (\ud504\ub85c\uc81d\ud2b8 Java version)\\n - name: Set up JDK 17\\n uses: actions/setup-java@v3\\n with:\\n java-version: \'17\'\\n distribution: \'adopt\'\\n\\n - name: Gradle Caching\\n uses: actions/cache@v3\\n with:\\n path: |\\n ~/.gradle/caches\\n ~/.gradle/wrapper\\n key: ${{ runner.os }}-gradle-${{ hashFiles(\'**/*.gradle*\', \'**/gradle-wrapper.properties\') }}\\n restore-keys: |\\n ${{ runner.os }}-gradle-\\n\\n - name: Grant execute permission for gradlew\\n run: chmod +x gradlew\\n - name: Build for asciiDoc\\n run: ./gradlew bootjar\\n\\n - name: Build with Gradle\\n run: ./gradlew bootjar\\n\\n # 4. \uc0b0\ucd9c\ubb3c\uc744 Image\ub85c \ube4c\ub4dc \ud6c4 Docker Hub\uc5d0 Image Push\ud558\uae30\\n - name: Extract metadata (tags, labels) for Docker\\n id: meta\\n uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7\\n with:\\n images: woowacarffeine/backend\\n\\n - name: Build and push Docker image\\n uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671\\n with:\\n context: .\\n file: ./backend/Dockerfile\\n push: true\\n platforms: linux/arm64\\n tags: woowacarffeine/backend:latest\\n labels: ${{ steps.meta.outputs.labels }}\\n\\n\\n deploy:\\n # 5. Self-hosted \uc791\ub3d9 -> infra \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c \uc791\ub3d9\ub428\\n runs-on: self-hosted\\n if: ${{ needs.docker-build.result == \'success\' }}\\n needs: [ docker-build ]\\n steps:\\n\\n # 6. infra \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c prod \uc778\uc2a4\ud134\uc2a4\ub85c \uc811\uadfc (\uc544\ub798\ubd80\ud130\ub294 prod \uc11c\ubc84 \ub0b4\uc5d0\uc11c \uc791\uc5c5)\\n - name: Join EC2 prod server\\n uses: appleboy/ssh-action@master\\n env:\\n JASYPT_KEY: ${{ secrets.JASYPT_KEY }}\\n DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}\\n DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}\\n with:\\n host: ${{ secrets.SERVER_HOST }}\\n username: ${{ secrets.SERVER_USERNAME }}\\n key: ${{ secrets.SERVER_KEY }}\\n port: ${{ secrets.SERVER_PORT }}\\n envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD\\n\\n script: |\\n\\n # 7. Docker Hub\uc5d0\uc11c Image\ub97c pull\ud574\uc628\ub2e4\\n sudo docker pull woowacarffeine/backend:latest\\n\\n # 8. \ub9cc\uc57d 8080 \ud3ec\ud2b8\uac00 \ucf1c\uc838 \uc788\uc73c\uba74 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub294 8081\ub85c \uc124\uc815\\n if sudo docker ps | grep \\":8080\\"; then\\n export BEFORE_PORT=8080\\n export NEW_PORT=8081\\n export NEW_ACTUATOR_PORT=8089\\n\\n # 9. \ub9cc\uc57d 8081 \ud3ec\ud2b8\uac00 \ucf1c\uc838 \uc788\uc73c\uba74 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub294 8080\ub85c \uc124\uc815\\n else\\n export BEFORE_PORT=8081\\n export NEW_PORT=8080\\n export NEW_ACTUATOR_PORT=8088\\n fi\\n\\n # 10. Docker\ub85c \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ub744\uc6b4\ub2e4.\\n sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \\\\\\n -e \\"SPRING_PROFILE=prod\\" \\\\\\n -e \\"ENCRYPT_KEY=${{secrets.JASYPT_KEY}}\\" \\\\\\n -e \\"DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}\\" \\\\\\n -e \\"DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}\\" \\\\\\n -e \\"REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}\\" \\\\\\n -e \\"REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}\\" \\\\\\n -e \\"SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}\\" \\\\\\n --name backend$NEW_PORT \\\\\\n woowacarffeine/backend:latest\\n\\n # 11. prod \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc788\ub294 bluegreen.sh \ub97c \uc791\ub3d9\ud55c\ub2e4. (\uc774 \ub54c port \uac12\uc744 \uac19\uc774 \ub123\uc5b4\uc900\ub2e4.)\\n sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT\\n\\n```\\n\\n
    \\n
    \\n\\n### bluegreen.sh\\n(prod \uc778\uc2a4\ud134\uc2a4 \ub0b4\ubd80\uc5d0 \uc874\uc7ac)\\n\\n```shell\\n#!/bin/bash\\n\\n# 1. Github Actions\ub97c \ud1b5\ud574 \ub118\uaca8 \ubc1b\uc740 \ud658\uacbd\ubcc0\uc218 \uac12\\nBEFORE_PORT=$1\\nNEW_PORT=$2\\nNEW_ACTUATOR_PORT=$3\\n\\necho \\"\uae30\uc874 \ud3ec\ud2b8 : $BEFORE_PORT\\"\\necho \\"\uc0c8\ub85c\uc6b4 \ud3ec\ud2b8: $NEW_PORT\\"\\necho \\"\uc0c8\ub85c\uc6b4 ACTUATOR_PORT: $NEW_ACTUATOR_PORT\\"\\n\\n\\n# 2. 20\ubc88 \ub3d9\uc548 \ud5ec\uc2a4 \uccb4\ud06c\ub97c \uc9c4\ud589\\ncount=0\\nfor count in {0..20}\\ndo\\n echo \\"\uc11c\ubc84 \uc0c1\ud0dc \ud655\uc778(${count}/20)\\";\\n\\n # 3. \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc791\ub3d9\ub418\ub294\uc9c0 Actuator\ub97c \ud1b5\ud574 \uac12\uc744 \ubc1b\uc544\uc634\\n STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)\\n\\n # 4. Actuator\ub97c \ud1b5\ud574 \uc131\uacf5\uc801\uc73c\ub85c \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c0\uc9c0 \uc54a\uc740 \uacbd\uc6b0\\n if [ \\"${STATUS}\\" != \'{\\"status\\":\\"up\\"}\' ]\\n \\tthen\\n # 5. 10\ucd08\ub97c \uae30\ub2e4\ub9b0 \ud6c4 \ub2e4\uc2dc \ud5ec\uc2a4 \uccb4\ud06c\ub97c \uc9c4\ud589\ud55c\ub2e4.\\n \\t\\tsleep 10\\n \\t\\tcontinue\\n \\telse\\n # 6. \ud5ec\uc2a4 \uccb4\ud06c\ub97c \ud1b5\ud574 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc131\uacf5\uc801\uc73c\ub85c \uc791\ub3d9\ub41c\ub2e4\uba74 \uba48\ucd98\ub2e4.\\n \\t\\tbreak\\n fi\\ndone\\n\\n\\n# 7. 20\ubc88\uc758 \ud5ec\uc2a4 \uccb4\ud06c\ub97c \ud558\ub294 \ub3d9\uc548 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \uc791\ub3d9\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc885\ub8cc\\nif [ $count -eq 20 ]\\nthen\\n\\techo \\"\uc0c8\ub85c\uc6b4 \uc11c\ubc84 \ubc30\ud3ec\ub97c \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.\\"\\n\\texit 1\\nfi\\n\\n\\n# 8. \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc131\uacf5\uc801\uc73c\ub85c \uc791\ub3d9\ud55c \uacbd\uc6b0\\n# Nginx\ub97c \ud1b5\ud574 \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \uae30\uc874 \ud3ec\ud2b8\uc5d0\uc11c \uc0c8\ub85c\uc6b4 \ud3ec\ud2b8\ub85c \ubcc0\uacbd\ud574\uc900\ub2e4.\\n# \uc774 \ubd80\ubd84\uc740 .inc \ud30c\uc77c\uc744 \ud1b5\ud574 Nginx\uc5d0\uc11c \uc8fc\uc785 \ubc1b\uc544\uc11c \ud3ec\ud2b8\ub9cc \ubcc0\uacbd\ud574\ub3c4 \ub429\ub2c8\ub2e4!\\nexport BACKEND_PORT=$NEW_PORT\\nenvsubst \'${BACKEND_PORT}\' < backend.template > backend.conf\\nsudo mv backend.conf /etc/nginx/conf.d/\\nsudo nginx -s reload\\n\\n\\n# 9. \uae30\uc874 \uc11c\ubc84\ub97c \ub0b4\ub824\uc8fc\uace0, \ub3c4\ucee4 \ub9ac\uc18c\uc2a4\ub97c \uc815\ub9ac\ud574\uc900\ub2e4\\ndocker stop backend$BEFORE_PORT\\nsudo docker container prune -f\\n```\\n\\n\\n\uc774\ub807\uac8c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \ub3c4\uc785\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4 :)"},{"id":"40","metadata":{"permalink":"/40","source":"@site/blog/2023-10-15-carffeine-tester-2/index.mdx","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","description":"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.","date":"2023-10-15T00:00:00.000Z","formattedDate":"2023\ub144 10\uc6d4 15\uc77c","tags":[{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778"},{"label":"\uc11c\ube44\uc2a4 \uacbd\ud5d8","permalink":"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{"label":"\ud53c\ub4dc\ubc31","permalink":"/tags/\ud53c\ub4dc\ubc31"},{"label":"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","permalink":"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{"label":"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571","permalink":"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],"readingTime":14.665,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"},{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"40","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","authors":["gabriel","scent"],"tags":["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec","permalink":"/41"},"nextItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","permalink":"/39"}},"content":"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.\\n\\n\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub09c\ubc88 [\uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8](https://car-ffeine.github.io/39) \uc9c4\ud589 \uc774\ud6c4 \uc77c\ubd80 \uae30\ub2a5 \uac1c\uc120\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\ub2a5 \uac1c\uc120\uc758 \uc720\uc6a9\uc131\uc744 \ud310\ubcc4\ud558\uace0\uc790 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc744 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uac1c\uc120\ud55c \uc0ac\ud56d\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n### 1. \uc9c0\uc5ed\uac80\uc0c9\\n\\n![no offset](./city-search.png)\\n\\n- \uc774\uc81c\ub294 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud558\ub294 \uacbd\uc6b0, \uc804\uad6d \ub3c4\uc2dc\uc758 \uc8fc\uc18c\uac00 \uac19\uc774 \uc81c\uacf5\ub429\ub2c8\ub2e4.\\n\\n### 2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\\n\\n![no offset](./mobile-markers.png)\\n\\n(\uae30\uc874\uc5d0\ub294 \uc704 \uc0ac\uc9c4\ubcf4\ub2e4 \uc881\uc740 \uc601\uc5ed\ub9cc\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc774 \ud5c8\uc6a9\ub418\uc5c8\ub2e4.)\\n- \ubaa8\ubc14\uc77c\uc5d0\uc11c \uc880 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc744 \ud5c8\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uc6d0\ub798\ub294 \ub514\ubc14\uc774\uc2a4 \ub108\ube44\ub97c \uace0\ub824\ud558\uc9c0 \uc54a\uace0 \uc90c \ub808\ubca8 \uae30\uc900\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud588\uc73c\ub098, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc774\ub294 \uc9c0\ub3c4\uc758 \uc601\uc5ed \ud06c\uae30\ub97c \uae30\ubc18\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub294 \ubc29\uc2dd\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4.\\n- \uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \ub9c8\ucee4\uc758 \ub2e8\uc810\uc740, \uadf8 \ud06c\uae30\uac00 \ub108\ubb34 \ud06c\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub85c\uc778\ud574 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc8fc\ub294 \uacbd\uc6b0\uc5d0 \ub9c8\ucee4\ub4e4\uc774 \uacb9\uce58\ub294 \ud604\uc0c1\uc774 \uc788\uc5c8\ub294\ub370\uc694, \uc774\ub97c \uc218\uc815\ud558\uae30 \uc704\ud574 \ud2b9\uc815 \uc601\uc5ed \ud06c\uae30 \uc774\uc0c1\uc5d0\uc11c\ub294 \ub9c8\ucee4\ub97c \uc880 \ub354 \uac04\uc18c\ud654 \ub41c \ub514\uc790\uc778\uc73c\ub85c \ubcf4\uc774\ub3c4\ub85d \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n- \ub9c8\ucee4 \uc0ac\uc774\uc988\uac00 \uc791\uc544\uc9c0\uba74\uc11c \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\uac00 \ub354\uc774\uc0c1 \ub4e4\uc5b4\uac08 \uacf5\uac04\uc774 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub9c8\ucee4 \uc0c9\uc0c1\uc740 \uadf8\ub300\ub85c \uc720\uc9c0\ub97c \ud558\ub418, \uc778\ud3ec \uc708\ub3c4\uc6b0\uc5d0 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\ub97c \ubcf4\uc5ec\uc8fc\ub294 \ubc29\uc2dd\uc73c\ub85c \ub514\uc790\uc778\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n## \uccb4\ud5d8 \uaddc\uce59 \uc124\uc815\\n\\n\uac1c\uc120\ud55c \uae30\ub2a5\uc774 \uc2e4\uc81c\ub85c \uc720\uc6a9\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \uc800\ud76c\ub294 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc758 \uaddc\uce59\uc744 \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc880 \ub354 \uc758\ubbf8\uc788\ub294 \uacbd\ud5d8\uc744 \ud558\uae30\uc704\ud574 1\ucc28 \uccb4\ud5d8 \ub54c \uc815\ud588\ub358 \uaddc\uce59\uc5d0 \ub354\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ucd94\uac00 \uaddc\uce59\uc744 \uc124\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n### \uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4\\n\\n\uc9c0\ub09c \uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8\uc5d0\uc11c\ub294 \uc9c0\uc5ed \uac80\uc0c9\uc774 \uc5c6\uc5b4 \ubaa9\ud45c \uc9c0\uc810\uc744 \ucc3e\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\uc2b5\ub2c8\ub2e4. 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uc9c0\uc5ed \uac80\uc0c9\uc774 \ucd94\uac00 \ub418\uc5c8\uc73c\ubbc0\ub85c \uc774 \uae30\ub2a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \uc774 \uaddc\uce59\uc744 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ucd94\uac00\ub85c \ubaa9\ud45c \uc9c0\uc810 \uc8fc\ubcc0\uc758 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud560 \ub54c \uc0c8\ub85c \ucd94\uac00\ub41c \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0\ub3c4 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uccb4\ud5d8 \uac1c\uc694\\n\\n![no offset](./routes.png)\\n1. \uc7a0\uc2e4\uc5ed \ucd9c\ubc1c\\n2. \ud558\ub0a8 \ub9cc\ub450\uc9d1\\n3. \ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815\\n4. \ud310\uad50\\n\\n## \uccb4\ud5d8 \ud6c4\uae30\\n\\n### \uc7a0\uc2e4\uc5ed \ucd9c\ubc1c\\n\\n![no offset](./from-jamsil.png)\\n\\n\uc3d8\uce74\uc5d0\uc11c EV6\ub97c \ub300\uc5ec\ud574\uc11c `\uac00\ube0c\ub9ac\uc5d8`, `\uc13c\ud2b8`, `\ud0a4\uc544\ub77c`\uac00 \uc7a0\uc2e4\uc5ed\uc5d0\uc11c \ucd9c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc800\ub141 \ud1f4\uadfc \uc774\ud6c4\uc5d0 \ub0a8\uc774\uc12c\uc744 \uac00\ub824\uace0 \ubaa9\uc801\uc9c0\ub97c \uc124\uc815\ud558\uc600\uc73c\ub098 \ubc30\uac00 \ub108\ubb34 \uace0\ud30c\uc11c \uac00\ub294 \uae38\uc5d0 \uc2dd\uc0ac\ub97c \ud558\uc790\uace0 \uc598\uae30\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n### \ud558\ub0a8 \ub9cc\ub450\uc9d1\\n\\n\ub530\ub77c\uc11c \uc9c4\uc815\ud55c \ucc98\uc74c \ubaa9\uc801\uc9c0\ub294 \uc2a4\ud0c0\ud544\ub4dc\uc600\uc73c\ub098, \uac00\ube0c\ub9ac\uc5d8\uc740 \ub3d9\ub124 \uc8fc\ubbfc\uc774\ub77c \uc2a4\ud0c0\ud544\ub4dc\ub97c \ub108\ubb34 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc2a4\ud0c0\ud544\ub4dc\uc5d0 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uace0\uc788\uc73c\ubbc0\ub85c \ubaa9\uc801\uc9c0\ub97c \uae09\ud558\uac8c \ubcc0\uacbd\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4. \uc774 \ub54c \ubaa9\uc801\uc9c0 \ubcc0\uacbd\uc744 \uc704\ud574 \uc8fc\ubcc0 \uc2dd\ub2f9\uc744 \ub458\ub7ec\ubcf4\ub358 \uc911\uc5d0 \uad1c\ucc2e\uc740 \uc2dd\ub2f9\uc744 \ubc1c\uacac\ud574\uc11c \ud574\ub2f9 \uc2dd\ub2f9\uc744 \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-starfield.png)\\n\\n\uc2dd\ub2f9 \uc8fc\ubcc0\uc744 \uac00\uae30 \uc704\ud574 \uc9c0\uc5ed \uac80\uc0c9\uc744 \ucc98\uc74c\uc73c\ub85c \uc0ac\uc6a9\ud558\uc5ec \uc2dd\ub2f9\uacfc \uac00\uae4c\uc6b4 \uc9c0\uc5ed\uc744 \ud0d0\uc0c9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \uc2dd\ub2f9\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \uc5c6\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uac8c\ub418\uc5b4, \uadfc\ucc98 \ucda9\uc804\uc18c\ub97c \ucc3e\uc544\ubcf4\uae30 \uc704\ud574\uc11c \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud588\ub354\ub2c8 1\ucc28 \uccb4\ud5d8\ub54c\uc640\ub294 \ub2ec\ub9ac \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc92c\uc2b5\ub2c8\ub2e4. \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4 \uc790\uccb4\uac00 \ubcf4\uc774\uc9c0 \uc54a\uc544 \ub2f5\ub2f5\ud558\uc600\uc73c\ub098, \uc774\uc81c\ub294 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uac8c \ub418\uc5b4 \ud3b8\ub9ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\ub09c \uccb4\ud5d8 \uc774\ud6c4\ub85c \ud53c\ub4dc\ubc31\uc744 \uc790\uccb4 \uc218\uc9d1\ud558\uc5ec \uac1c\ubc1c\ud55c \uae30\ub2a5\ub4e4\uc774 \ud3b8\ud558\ub2e4\ub294 \uac83\uc744 \uc2dd\ub2f9\uc5d0 \uac00\ub294 \uae38\uc5d0 \ub290\ub084 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815\\n\\n![no offset](./mandu-mandu.png)\\n\\n\ud558\ub0a8 \ub9cc\ub450\uc9d1\uc5d0\uc11c \uc2dd\uc0ac\ub97c \ud558\ub2e4\uac00 \uc54c\uac8c\ub41c \uc0ac\uc2e4\uc740, \ub0a8\uc774\uc12c\uc740 \uc0dd\uac01\ubcf4\ub2e4 \ub108\ubb34 \uba40\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc2dd\uc0ac\ub97c \ub9c8\uce58\uace0 \ub0a8\uc774\uc12c\uc5d0 \uac00\uba74, \ucda9\uc804\ub3c4 \uc81c\ub300\ub85c \ubabb\ud558\uace0 \ub3cc\uc544\uc62c \ud310\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc2dd\uc0ac\ub97c \ud558\uba74\uc11c \ub2e4\ub978 \ubaa9\uc801\uc9c0\ub97c \uc54c\uc544\ubd24\ub294\ub370, \uac00\ube0c\ub9ac\uc5d8\uc774 \uc608\uc804\uc5d0 \uac00\ubd24\ub358 \uacf3 \uc911\uc5d0\uc11c \ub0a8\uc591\uc8fc\uc758 \ubb3c\uc758 \uc815\uc6d0\uc774 \uc2dc\uac04\uc744 \ub5bc\uc6b0\uae30 \uc88b\ub2e4\ub294 \uc18c\ub9ac\ub97c \ud558\uc600\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ubb3c\uc758 \uc815\uc6d0\uc744 \uac80\uc0c9\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ub180\ub78d\uac8c\ub3c4 \ubb3c\uc758\uc815\uc6d0\uc740 \uac80\uc0c9\uacb0\uacfc\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4!\\n\\n\uc5b4\uca54 \uc218 \uc5c6\uc774 \uce74\uce74\uc624 \uc9c0\ub3c4\ub85c \ubb3c\uc758 \uc815\uc6d0 \uc704\uce58\ub97c \ud655\uc778\ud558\uc5ec \uc8fc\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uace0, \uc774 \uc8fc\uc18c\ub97c \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4. \uc800\ud76c\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub294 \uc5c5\uccb4\uba85 \uc870\ud68c\uac00 \uc548\ub41c\ub2e4\ub294 \uac83\uc774 \uce58\uba85\uc801\uc778 \ub2e8\uc810\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\ub9cc, \uc774 \uae30\ub2a5\uc740 \uac80\uc0c9 \ud560 \ub54c\ub9c8\ub2e4 \ub9ce\uc740 \ube44\uc6a9\uc774 \uccad\uad6c\ub418\uc5b4 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uae30\ub2a5\uc744 \ub123\ub294 \uac83\uc740 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uacb0\uad6d \uc8fc\uc18c \uac80\uc0c9\uc744 \ud1b5\ud574 \ubb3c\uc758 \uc815\uc6d0\uacfc \uac00\uc7a5 \uac00\uae4c\uc6b4 \ucda9\uc804\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7f0\ub370! \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud574\uc11c \ud655\uc778\ud574 \ubcf4\ub2c8 \ud574\ub2f9 \ucda9\uc804\uc18c\ub294 \ubb3c\uc758 \uc815\uc6d0\uacfc \uc0dd\uac01\ubcf4\ub2e4 \uba40\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./near-water.png)\\n\\n![no offset](./30-minutes.png)\\n\\n\ubb34\ub824 \uac78\uc5b4\uc11c 30\ubd84\uc774\ub098 \uac78\ub9ac\ub294 \ucda9\uc804\uc18c\uc600\uc2b5\ub2c8\ub2e4!\\n\\n\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uc704\ud574 \uc655\ubcf5 1\uc2dc\uac04\uc774\ub098 \uac78\ub9ac\ub294 \uac70\ub9ac\ub97c \uac78\uc744 \uc218 \uc5c6\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \uc9c0\ub09c \uccb4\ud5d8\uc5d0\uc11c \uc804\uae30\ucc28\uac00 \uc0dd\uac01\ubcf4\ub2e4 \ubc30\ud130\ub9ac\uac00 \uc624\ub798\uac04\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uace0 \uc788\uc5c8\uc9c0\ub9cc, \ub9cc\uc57d \uc800\ud76c\ucc98\ub7fc \ucda9\uc804\uc774 \uae09\ud55c \uc0ac\uc6a9\uc790\ub77c\uba74 \ubaa9\uc801\uc9c0\ub97c \ud3ec\uae30\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uaca0\uad6c\ub098 \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c \uc815\ud55c \ubaa9\uc801\uc9c0\ub294, \uc758\uc678\uc758 \uacb0\uc815\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uad49\uc7a5\ud788 \ubc1c\uc804\ub41c \ucca8\ub2e8 \ub3c4\uc2dc\ub85c \uc54c\ub824\uc9c4 \ud310\uad50\uc600\uc2b5\ub2c8\ub2e4!\\n\\n\uc0ac\uc2e4\uc740 \uc55e\uc73c\ub85c \uac08\uc9c0\ub3c4 \ubaa8\ub974\ub294 \ud310\uad50\ub97c \ubbf8\ub9ac \uad6c\uacbd\uc774\ub098 \ud574\ubcf4\uc790\ub294\uac8c \uc774\uc720\uc600\uc9c0\ub9cc \ube44\ubc00\uc785\ub2c8\ub2e4(?)\\n\\n\uc77c\ub2e8 \ud310\uad50\uc5ed\uc740 IT\uc11c\ube44\uc2a4 \ud68c\uc0ac\ub4e4\uc774 \ub9ce\uc774 \ubab0\ub824\uc788\ub294 \uacf3\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud310\uad50\uc5ed\uc744 \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \uac80\uc0c9\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./pangyo.png)\\n\\n\uc9c0\ub3c4\ub97c \ud310\uad50\uc5ed\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \uc678\ubd80\uc778 \uac1c\ubc29\uc778 \ucda9\uc804\uc18c\ub97c \ucc3e\uc558\ub294\ub370, \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \ubcf4\uc5ec\uc11c \ud574\ub2f9 \ucda9\uc804\uc18c\ub97c \ubaa9\uc801\uc9c0\ub85c \uc7a1\uace0 \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \ud310\uad50\\n\\n\ud558\ub0a8\uc5d0\uc11c \ud310\uad50\ub97c \uac00\uae30 \uc704\ud574\uc11c\ub294 \uc11c\ud558\ub0a8IC\ub97c \uc9c0\ub098\uc57c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uac00\ub294 \uae38\uc5d0 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0 \ub098\uc624\ub294 \uc815\ubcf4\uc640 \uc2e4\uc81c \uc815\ubcf4\uac00 \uc77c\uce58\ud558\ub294\uc9c0 \uc810\uac80\ucc28 \uc11c\ud558\ub0a8 \uac04\uc774 \ud734\uac8c\uc18c\ub97c \ub4e4\ub824\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \uac80\uc0c9\uc774 \ub418\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4!\\n\\n![no offset](./hanam_station.png)\\n\\n\uac80\uc0c9 \ub2f9\uc2dc\uc5d0\ub294 2\ub300\uc758 \ucda9\uc804\uae30\uac00 \uc788\ub2e4\uace0 \ub098\uc654\uace0, \ub458\ub2e4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub2e4\uace0 \ub418\uc5b4\uc788\uc5c8\ub294\ub370 \uc2e4\uc81c\ub85c \ud655\uc778\ud574\ubcf4\ub2c8 \uc77c\uce58\ud558\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c \uae38\uc744 \ub2ec\ub824 \ud310\uad50\uc5d0 \ub3c4\ucc29\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc8fc\ucc28\uc7a5\uc5d0 \ub4e4\uc5b4\uc624\uae30 \uc804, \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc758 \ucda9\uc804\uae30 \ucd1d 12\uae30 \uc911 10\uae30\uac00 \uc0ac\uc6a9\uac00\ub2a5\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc815\uc791 \ub4e4\uc5b4\uc640\uc11c \ubcf4\ub2c8 \uc785\uad6c\ubd80\ud130 \ub108\ubb34 \ub9ce\uc740 \uc804\uae30\ucc28\ub4e4\uc774 \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\uc911\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubb54\uac00 \uc774\uc0c1\ud558\ub2e4 \uc2f6\uc5c8\uc9c0\ub9cc, \uc544\uc9c1 \uc11c\ubc84\uc5d0 \ubc18\uc601\uc774 \uc548\ub41c\uac74\uac00? \ud558\uba74\uc11c \ube44\uc5b4\uc788\ub294 \ucda9\uc804\uae30\ub97c \ucc3e\uc558\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./empty_station.png)\\n![no offset](./charging.png)\\n\\n\ucda9\uc804\uae30\ub97c \uaf42\uace0 \ub098\uc11c \uc54c\uac8c\ub41c \uac83\uc740 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0 \ub098\uc628 \ucda9\uc804\uc18c \ud68c\uc0ac\uba85\uacfc \ubc29\uae08 \uaf42\uc740 \ucda9\uc804\uae30 \ud68c\uc0ac\uba85\uc774 \ub2e4\ub974\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc54c\uace0\ubcf4\ub2c8 \uc74c\uc131 \uc778\uc2dd\uc73c\ub85c \ub124\ube44\uc5d0 \uac80\uc0c9\ud55c \ucda9\uc804\uc18c\ub294 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \uc544\ub2cc \ud310\uad50\uc5ed \ud658\uc2b9 \uc8fc\ucc28\uc7a5\uc774\ub77c \uc5c9\ub6b1\ud55c \uacf3\uc73c\ub85c \uc628 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4!!!\\n\\n\ub2e4\ud589\uc778 \uc810\uc740 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80 \uc815\ubcf4\uac00 \uc798\ubabb\ub41c \uac83\uc774 \uc544\ub2c8\uc5c8\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc560\ucd08\uc5d0 \uac00\uace0\uc790 \ud588\ub358 \ud310\uad50\uacf5\uc601\uc8fc\uc790\ucc3d\uc5d0 \ub300\ud55c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\uc640 \ub3d9\uc77c\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\ub7ec \uac78\uc5b4\uc11c \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4. (\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.)\\n\\n![no offset](./no-chargers.png)\\n![no offset](./real-pangyo.png)\\n\\n\ub3c4\ucc29\ud574\ubcf4\ub2c8 1\uce35\uc758 \ucda9\uc804\uae30\ub4e4\uc774 \ubaa8\ub450 \uacf5\uc0ac\uc911\uc774\uc5c8\uace0, \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\ub85c\ub3c4 \ubd88\uc77c\uce58 \ud558\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc0c1\uc138 \uc815\ubcf4\ub97c \ubcf4\ub2c8 3~6\uce35\uc5d0 \ucda9\uc804\uae30\ub4e4\uc5d0 \ub300\ud55c \uc815\ubcf4\ub77c\ub294 \uac83\uc774 \uba85\uc2dc\ub418\uc5b4 \uc788\uc5c8\uace0, \uc2e4\uc81c\ub85c\ub3c4 \uc774\uc640 \ub3d9\uc77c\ud55c \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./full-charged.png)\\n\\n\uc800\ud76c\ub294 \uc2dc\uac04\uc774 \ub108\ubb34 \ud758\ub7ec \ub2e4\uc2dc \uc7a0\uc2e4\ub85c \ub3cc\uc544\uc640 \ucc28\ub97c \ubc18\ub0a9\ud558\uace0 \uccb4\ud5d8\uc744 \ub9c8\ubb34\ub9ac \ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\n### \ubd88\ud3b8\ud588\ub358 \uc810\\n\\n- \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc2dc\uc5d0 \uc6d0\ud558\ub294 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\ub2e4.\\n - \uc9c0\ub3c4\ub97c \ud655\ub300\ud574\uc8fc\uc138\uc694 \ubaa8\ub2ec\uc774 \ub728\uace0, \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c \ub9c8\ucee4\uac00 \uc804\ubd80 \uc0ac\ub77c\uc9c4\ub2e4.\\n- \ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub294 \uc218\ub2e8\uc774 \uc5c6\uc5b4 \ubd88\ud3b8\ud588\ub2e4.\\n - \ud604\uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ud540 (1\ucc28 \uccb4\ud5d8\uae30\uc5d0\uc11c\ub3c4 \uc5b8\uae09\ud588\ub358 \ubd80\ubd84)\\n - \ub0b4 \uc704\uce58\ub97c \uc0c1\ub300\uc801\uc73c\ub85c \uc54c \uc218 \uc788\ub294 \ub79c\ub4dc\ub9c8\ud06c\uc758 \ubd80\uc871\\n- \ud2b9\uc815 \uc7a5\uc18c(\ub9e4\uc7a5\uba85) \uac80\uc0c9\uc774 \uc548\ub3fc\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac00\uae30 \ubd88\ud3b8\ud588\ub2e4.\\n - \uce74\uce74\uc624\ub9f5 \ub4f1\uc744 \ud65c\uc6a9\ud574 \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \uc9c4\ud589\ud574\uc57c \ud588\ub2e4.\\n\\n### \ub2e4\uc74c \ubaa9\ud45c\\n\\n\uc55e\uc120 \ubd88\ud3b8\ud588\ub358\uc810\uc744 \uac1c\uc120\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uae30\ub2a5 \uac1c\uc120\uc744 \ucd94\uac00\ub85c \uc9c4\ud589\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n- \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc5d0 \uc81c\ud55c\uc774 \uc0dd\uae30\uc9c0 \uc54a\uac8c \ucda9\uc804\uc18c \ub9c8\ucee4 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc744 \uc6b0\uc120\uc801\uc73c\ub85c \ub3c4\uc785\ud55c\ub2e4.\\n- \ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub3c4\ub85d \uc9c0\ud558\ucca0 \uc5ed\uacfc \uac19\uc740 \ub79c\ub4dc\ub9c8\ucee4\ub97c \uc9c0\uc6e0\ub358 \uac83\uc744 \ub864\ubc31\ud55c\ub2e4.\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac08 \uc218 \uc788\ub3c4\ub85d \ud558\uae30 \uc704\ud574\uc11c \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \ucd94\uac00\ud558\uace0 \uc2f6\uc9c0\ub9cc, \ud574\ub2f9 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 \uac80\uc0c9\ub2f9 \ube44\uc6a9\uc774 \ub9ce\uc774 \uccad\uad6c\ub418\ub294 \uc7a5\uc18c \uac80\uc0c9 API\ub97c \ucd94\uac00\ud574\uc57c \ud588\uae30\uc5d0 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uad6c\ud604\ud558\uae30 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."},{"id":"39","metadata":{"permalink":"/39","source":"@site/blog/2023-10-07-carffeine-tester-1/index.mdx","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","description":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\uba74\uc11c \uac00\uc7a5 \ub9ce\uc774 \ubc1b\uc740 \ud53c\ub4dc\ubc31 \uc911 \ud558\ub098\ub294 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc774 \ubc18\ub4dc\uc2dc \ud544\uc694\ud558\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.","date":"2023-10-07T00:00:00.000Z","formattedDate":"2023\ub144 10\uc6d4 7\uc77c","tags":[{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778"},{"label":"\uc11c\ube44\uc2a4 \uacbd\ud5d8","permalink":"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{"label":"\ud53c\ub4dc\ubc31","permalink":"/tags/\ud53c\ub4dc\ubc31"},{"label":"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","permalink":"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{"label":"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571","permalink":"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],"readingTime":18.085,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"39","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","authors":["gabriel"],"tags":["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},"prevItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","permalink":"/40"},"nextItem":{"title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","permalink":"/37"}},"content":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\uba74\uc11c \uac00\uc7a5 \ub9ce\uc774 \ubc1b\uc740 \ud53c\ub4dc\ubc31 \uc911 \ud558\ub098\ub294 `\uc0ac\uc6a9\uc790 \uacbd\ud5d8`\uc774 \ubc18\ub4dc\uc2dc \ud544\uc694\ud558\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ubb34\ub798\ub3c4 \uc804\uae30\uc790\ub3d9\ucc28\ub97c \ubcf4\uc720\ud55c \ud300\uc6d0\ub4e4\uc774 \uc544\ubb34\ub3c4 \uc5c6\ub2e4\ubcf4\ub2c8 \uc2e4\uc81c \uc0ac\uc6a9\uc790\ub4e4\uc774 \uacaa\ub294 \uc5b4\ub824\uc6c0\uc744 \uc608\uc0c1\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc804\uae30 \uc790\ub3d9\ucc28 \uc6b4\uc804\uc790\ub4e4\uc744 \ucc3e\uc544\ub0b4\uc5b4 \uc218\ucc28\ub840 \uc778\ud130\ubdf0\ub97c \uc9c4\ud589\ud558\uc600\ub294\ub370 \uc2e4\uc81c \ucc28\uc8fc\ub4e4\uc774 \uc6d0\ud558\ub294 \uae30\ub2a5\uc774 \ubb34\uc5c7\uc778\uc9c0, \uc5b4\ub5a4 \uc5b4\ub824\uc6c0\uc744 \uacaa\ub294\uc9c0\ub97c \ud655\uc778\ud558\uc5ec \uc774\ub97c \ubc14\ud0d5\uc73c\ub85c \uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc11c\ube44\uc2a4\ub97c \ucc98\uc74c \uac1c\ubc1c\ud558\uc600\uc744 \ub54c \uac00\uc7a5 \ub9ce\uc774 \ubc1b\uc558\ub358 \ud53c\ub4dc\ubc31\uc740 \uc571 \ub85c\ub4dc \uc18d\ub3c4\uac00 \ub108\ubb34 \ub290\ub9ac\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc11c\ube44\uc2a4 \ucd08\uae30\uc5d0\ub294 \ub85c\ub529 \uc18d\ub3c4\uac00 \ub108\ubb34 \ub290\ub824\uc11c \uc0ac\uc6a9\uc790\ub4e4\uc5d0\uac8c \uc11c\ube44\uc2a4 \uc0ac\uc6a9\uc744 \uad8c\uc7a5\ud558\uae30 \ubbf8\uc548\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc2e4\uc0ac\uc6a9\uc790\ub97c \ubaa8\uc9d1\ud558\ub294 \uac83 \ubcf4\ub2e4 `\uc11c\ube44\uc2a4 \uc548\uc815\ud654\uc5d0 \uc9d1\uc911\ud558\ub294 \uac83`\uc774 \ucd5c\uc6b0\uc120\uc774\ub77c\ub294 \ubaa9\ud45c \uc544\ub798\uc5d0 \uc11c\ube44\uc2a4\ub97c \uac1c\uc120\ud558\ub294 \uc2dc\uac04\uc744 \uac00\uc84c\uace0, \uc9c0\uae08\uc740 \ub85c\ub529 \uc18d\ub3c4\uac00 \ube60\ub974\ub2e4\ub294 \ud53c\ub4dc\ubc31\uc744 \ubc1b\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\ub09c \ud55c \ub2ec\uac04 \uc11c\ube44\uc2a4 \uc548\uc815\ud654\uc5d0 \uc9d1\uc911\uc744 \ud588\ub2e4\uba74, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \uac1c\uc120\ud558\ub294\ub370 \uc9d1\uc911\uc744 \ud574\uc57c\ud560 \ub54c\uac00 \uc654\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc0ac\uc6a9\uc790 \uc720\uce58\ub97c \uc704\ud574 \uc804\uae30\ucc28 \ub3d9\ud638\ud68c \uce74\ud398, \uce74\uce74\uc624\ud1a1 \uc624\ud508\ucc44\ud305, \uc790\ub3d9\ucc28 \ucee4\ubba4\ub2c8\ud2f0 \ub4f1\uc744 \ub3cc\uba74\uc11c \ud64d\ubcf4\ub97c \uc9c4\ud589\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\ud589\ud788\ub3c4 \ubd88\ud2b9\uc815 \ub2e4\uc218\uc758 \uc775\uba85 \uc0ac\uc6a9\uc790\ub4e4\uc744 \uc190\uc27d\uac8c \uad6c\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\ub4e4\ub85c\ubd80\ud130 \ub9ce\uc740 \ud53c\ub4dc\ubc31\uc744 \ubc1b\uc558\uace0, \ud574\ub2f9 \ud53c\ub4dc\ubc31\uc744 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0 \ucd5c\ub300\ud55c \ubc18\uc601\ud558\uace0\uc790 \ub178\ub825\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc, \ub300\ubd80\ubd84\uc758 \uc0ac\uc6a9\uc790\ub4e4\uc774 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0 \ub2e8\uc21c\ud788 \ubc29\ubb38\ud558\uc5ec \ud53c\ub4dc\ubc31\uc744 \uc900 \uac83\uc77c \ubfd0 `\uc2e4\uc81c\ub85c \uc0ac\uc6a9\ud558\uba74\uc11c \ud53c\ub4dc\ubc31\uc744 \uc900 \uac83 \uac19\uc9c0\ub294 \uc54a\ub2e4\ub294 \ub290\ub08c`\uc744 \ubc1b\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\ub4e4\uc774 \uc571\uc5d0 \uba38\ubb34\ub978 \uc2dc\uac04\uc744 GA4\ub97c \ud1b5\ud574 \ud655\uc778 \ud558\uc600\uc744 \ub54c \ud3c9\uade0 3\ubd84 \uc774\uc0c1\uc774\ub77c\ub294 \uae34 \uc2dc\uac04\uc744 \uba38\ubb3c\ub7ec\uc11c \uc571\uc744 \uaf3c\uaf3c\ud558\uac8c \uc0ac\uc6a9\ud588\uc744 \uac83\uc774\ub77c\uace0 \uae30\ub300\ub294 \ud558\uc600\uc73c\ub098, \uc0ac\uc6a9 \uc911\uc5d0 \ud53c\ub4dc\ubc31\uc744 \uc900\ub2e4\uac70\ub098 \uc0ac\uc6a9 \ud6c4\uc5d0 \ud53c\ub4dc\ubc31\uc744 \uc900 \uac83\uc774 \ub9de\ub294\uc9c0 \ud655\uc2e0\ud558\uae30 \uc5b4\ub824\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uace0 \uc8fc\ubcc0\uc5d0\uc11c \uc804\uae30\ucc28\uc8fc\ub4e4\uc744 \ucc3e\uc790\ub2c8 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc77c\ub2e8 \uc804\uae30\uc790\ub3d9\ucc28 \ubcf4\uae09\ub960\uc774 \uad49\uc7a5\ud788 \ub0ae\uc558\uc73c\uba70, 40~50\ub300\uc5d0 \ud3b8\uc911\ub418\uc5b4 \uc788\uc5b4 \uc800\ud76c\uc5d0\uac8c \ud611\uc870\ud574 \uc904 \ucc28\uc8fc\ubd84\ub4e4\uc744 \uc8fc\ubcc0\uc5d0\uc11c \ucc3e\uae30 \uc5b4\ub824\uc6e0\uc2b5\ub2c8\ub2e4. (\ub300\ubd80\ubd84 \uc0dd\uc5c5\uc73c\ub85c \uc778\ud574 \ubc14\uc058\uc2ed\ub2c8\ub2e4 \u3160\u3160)\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \uadf8\ub0e5 \uc9c1\uc811 \uc11c\ube44\uc2a4\ub97c \uc0ac\uc6a9\ud558\uba74\uc11c \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \ud558\uae30\ub85c \ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n## \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294\uc694\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \uc9c0\uc6d0\ud558\ub294 \ud575\uc2ec \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\uc558\uc2b5\ub2c8\ub2e4.\\n\\n- \uc804\uad6d \ucda9\uc804\uc18c \uc870\ud68c\\n - \uc9c0\ub3c4 \ud0d0\uc0c9\uc744 \ud1b5\ud55c \uac80\uc0c9\\n - \uac80\uc0c9\ucc3d\uc744 \ud1b5\ud55c \uac80\uc0c9\\n- \ucda9\uc804\uc18c\uc758 \uc6b4\uc601 \uc815\ubcf4 \ud655\uc778\\n- \ucda9\uc804\uc18c \ubcc4 \ucda9\uc804\uae30 \uc0c1\ud0dc \uc870\ud68c (\uc2e4\uc2dc\uac04)\\n- \ucda9\uc804\uc18c \ubc0f \ucda9\uc804\uae30 \uace0\uc7a5 \uc2e0\uace0\\n- \ucda9\uc804\uc18c \ubcc4 \ucda9\uc804\uae30 \uc0ac\uc6a9\ub7c9 \ud1b5\uacc4 \uc870\ud68c\\n- \ucda9\uc804\uc18c \ubcc4 \ub9ac\ubdf0 \uc870\ud68c\\n\\n\uc774\uc678\uc5d0\ub3c4 \ub9ce\uc740 \uae30\ub2a5\ub4e4\uc774 \uc788\uc5c8\uc9c0\ub9cc, \uc704\uc758 \uae30\ub2a5\ub4e4\uc774 \uc0ac\uc6a9\uc790\ub4e4\uc774 \uac00\uc7a5 \uc8fc\ub825\uc73c\ub85c \uc0ac\uc6a9\ud560 \uac83 \uac19\uc740 \uae30\ub2a5\ub4e4\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uacc4\ud68d\uc744 \uc138\uc6cc\ubcf4\uc790\\n\\n\uc804\uae30\uc790\ub3d9\ucc28 \ub80c\ud2b8\uc5d0 \uc55e\uc11c \uc5b4\ub514\uc5d0 \ubc29\ubb38\ud560 \uc9c0 \ubd80\ud130 \uc815\ud574\uc57c \ud588\uc2b5\ub2c8\ub2e4.\\n\uc800\ud76c\ub294 \uba87 \uac00\uc9c0 \uc6d0\uce59\uc744 \uac00\uc9c0\uace0 \ubc29\ubb38\uc9c0\ub97c \uc815\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc798 \ubaa8\ub974\ub294 \uc9c0\uc5ed\uc77c \uac83\\n2. \ub3c4\ucc29\uc9c0\uc5d0 \ucda9\uc804\uc18c\uac00 \ubc18\ub4dc\uc2dc \uc788\uc744 \uac83\\n3. \ud0c0\uc0ac \uc571\uc744 \uc804\ud600 \uc0ac\uc6a9\ud558\uc9c0 \ub9d0 \uac83\\n\\n\uc77c\ub2e8, \uc81c\uac00 \ucc98\uc74c \uc815\ud588\ub358 \ubaa9\ud45c\ub294 \uacbd\uc0c1\ub0a8\ub3c4 \uc9c4\uc8fc\uc2dc\uc600\uc2b5\ub2c8\ub2e4.\\n\uc9c4\uc8fc\uc2dc\uc5d0\uc11c \ubcf5\uadc0\ud574\uc57c\ud558\ub294 \ud300\uc6d0\uc774 \uc788\ub358 \uc810, \ubc29\ubb38\ud574 \ubcf8 \uc801\uc774 \uc5c6\ub294 \ub3c4\uc2dc\uc778 \uc810, \uc7a5\uac70\ub9ac\ub77c\uc11c \ucda9\uc804\uae30 \uc0ac\uc6a9\uc774 \ud544\uc5f0\uc801\uc778 \uc810 \ub4f1 \uc5ec\ub7ec \uac00\uc9c0 \uc774\uc720\ub85c \uc9c4\uc8fc\uc2dc\ub97c \ubc29\ubb38\ud558\uae30\ub85c \uacb0\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud0a8 \uc21c\uac04 \ub208\uc55e\uc774 \uce84\uce84\ud574\uc84c\uc2b5\ub2c8\ub2e4.\\n\\n\\"\uc9c4\uc8fc\uc2dc\uac00 \uc5b4\ub514\uc5d0 \uc788\uc9c0?\\"\\n\\n![no offset](./search-jinju.png)\\n\\n\ub2e4\ud589\ud788 \uc9c4\uc8fc\uc2dc\ub97c \uac80\uc0c9\ud558\ub2c8 \uc8fc\uc18c \uae30\ubc18\uc73c\ub85c \uac80\uc0c9\uc774 \ub418\uc5c8\uc2b5\ub2c8\ub2e4!\\n\uc9c4\uc8fc\uc2dc\ub97c \uac80\uc0c9\ud55c \uac83\uc740 \uc544\ub2c8\uc9c0\ub9cc \uac04\uc811\uc801\uc774\ub77c\ub3c4 \uac80\uc0c9\uc774 \ub418\ub294 \uac83\uc744 \ubcf4\uace0 \uc548\uc2ec\ud588\uc2b5\ub2c8\ub2e4.\\n\uc544\ubb34 \ucda9\uc804\uc18c\ub97c \ub20c\ub7ec\uc11c \uc9c4\uc8fc\uc2dc\ub85c \uc774\ub3d9\ud558\ub294 \uac83\uc740 \uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```\\n\uc5ec\uae30\uc5d0\uc11c \uc800\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \ub3c4\uc2dc\ub098 \uc9c0\uc5ed \uac80\uc0c9 \uae30\ub2a5\uc774 \ubc18\ub4dc\uc2dc \ud544\uc694\ud558\ub2e4\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n```\\n\\n\ud558\uc9c0\ub9cc \ub108\ubb34 \uba40\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc655\ubcf5 700km\ub97c \uc0dd\uac01\ud574\uc57c\ud558\uc5ec 1\ubc15 2\uc77c\uc774 \ud544\uc218\uc600\uace0, \ud300\uc6d0\ub4e4 \uac04\uc5d0 \uc77c\uc815\uc744 \uc870\uc815\ud558\uae30\uac00 \ub108\ubb34 \uc5b4\ub824\uc6e0\uc2b5\ub2c8\ub2e4.\\n\ub530\ub77c\uc11c \ub2e4\ub978 \ub3c4\uc2dc\ub97c \ucc3e\uc544\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./gangnam-to-majang-road.png)\\n\\n\uadf8\ub7ec\ub358 \uc911, \uc81c\uac00 \uc804\uc5d0 \ubc29\ubb38\ud588\ub358 \ud30c\uc8fc\uc2dc\uc758 `\ub9c8\uc7a5\ud638\uc218`\uac00 \uc0dd\uac01\ub0ac\uc2b5\ub2c8\ub2e4.\\n\uc11c\uc6b8\uc5d0\uc11c \uaf64\ub098 \uba3c \uac70\ub9ac(\uc57d 50km)\uc5d0 \uc788\uc5c8\uace0, \uc801\ub2f9\ud788 \uc2dc\uac04\uc744 \ubcf4\ub0bc\ub9cc\ud55c \uc7a5\uc18c\uc600\uc2b5\ub2c8\ub2e4.\\n\ub2e4\ud589\ud788\ub3c4 \ucda9\uc804\uc18c\uc758 \uc774\ub984\uc774 `\ub9c8\uc7a5\ud638\uc218\uad00\ub9ac\uc0ac\ubb34\uc18c`\uc5ec\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud1b5\ud574 \ubc14\ub85c \ucc3e\uc744 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc2ec\uc9c0\uc5b4 \ub9c8\uc7a5\ud638\uc218 \uc8fc\ubcc0\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \ub9ce\uc9c0 \uc54a\uc740 \ud3b8\uc774\uc5c8\uace0, \ucd08\uae09\uc18d \ucda9\uc804\uae30\uac00 \uc788\uc5b4 \uc800\ud76c \uc571\uc744 \uc2e4\ud5d8\ud558\uae30\uc5d0 \ub531 \uc88b\uc558\uc2b5\ub2c8\ub2e4.\\n\\n## \ub9c8\uc7a5\ud638\uc218\ub85c \ucd9c\ubc1c\\n\\n\uc800 `\uac00\ube0c\ub9ac\uc5d8`\uacfc `\uc81c\uc774`, `\ubc15\uc2a4\ud130`\ub294 \uc11c\uc6b8 \uc120\uc815\ub989\uc5ed\uc5d0\uc11c \uc544\uc774\uc624\ub2c95\ub97c \ub80c\ud2b8\ud558\uace0 \ub9c8\uc7a5\ud638\uc218\ub85c \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-majang-1.png)\\n\\n\ucc98\uc74c \uacc4\ud68d\ud588\ub358 \uac83 \ucc98\ub7fc \ud0c0\uc0ac\uc758 \uc571\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \ub9c8\uc7a5\ud638\uc218\ub97c \uac80\uc0c9\ud558\uc5ec \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-majang-2.png)\\n\\n\uc804\ub0a0 \uc774\ubbf8 \uac80\uc0c9\uc744 \ud588\uc9c0\ub9cc, \ud639\uc2dc \uc0ac\uc6a9 \uc911\uc77c\uc218\ub3c4 \uc788\uae30\uc5d0 \ud55c\ubc88 \ub354 \uac80\uc0c9\ud574\ubd24\uc73c\uba70 \ud574\ub2f9 \uc2dc\uac04\ub300\uc5d0 \ucda9\uc804\uc18c\uac00 \ud3c9\uc18c\uc5d0 \ub35c \ubd90\ube4c \uac83\uc774\ub77c\ub294 \ud1b5\uacc4 \uc790\ub8cc\ub97c \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./go-to-majang-3.png)\\n\\n![no offset](./go-to-majang-4.png)\\n\\n![no offset](./go-to-majang-5.png)\\n\\n\ub9c8\uc7a5 \ud638\uc218\uae4c\uc9c0 20\ubd84 \uac70\ub9ac\ub97c \ub0a8\uae30\uace0, \uac11\uc790\uae30 \ubc30\uac00 \uace0\ud30c\uc9c4 \uc800\ud76c\ub294 \ubaa9\uc801\uc9c0\ub97c \ud2c0\uc5b4 `\ud30c\uc8fc\ub2ed\uad6d\uc218 \ubcf8\uc810`\uc744 \uac00\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \ud30c\uc8fc\ub2ed\uad6d\uc218\uac00 \uc5b4\ub514\uc5d0 \uc788\uc9c0?\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud65c\uc6a9\ud558\uc5ec \ud30c\uc8fc\ub2ed\uad6d\uc218 \ubcf8\uc810 \uadfc\ucc98\uc758 \ucda9\uc804\uc18c\ub97c \uac80\uc0c9\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\uc790\ub3d9\ucc28 \ub0b4\ube44\uac8c\uc774\uc158\uc5d0\ub294 \ud30c\uc8fc\ub2ed\uad6d\uc218\uac00 \uc5b4\ub514\uc778\uc9c0 \ub098\uc640\uc788\uc9c0\ub9cc, \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\ub294 \uc2dd\ub2f9 \uc815\ubcf4\ub294 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\ud574\ub2f9 \uc2dd\ub2f9\uc774 \ub3c4\ub300\uccb4 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0 \ud655\uc778\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4. (\ud30c\uc8fc\ub2ed\uad6d\uc218\uc5d0\uc11c\ub294 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5c6\uc5c8\uae30 \ub584\ubb38\uc785\ub2c8\ub2e4.)\\n\\n![no offset](./songchoo-to-noodle-1.png)\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \uc790\ub3d9\ucc28 \ub0b4\ube44\uac8c\uc774\uc158\uc5d0 \uc788\ub294 \ub3c4\ub85c\uba85 \uc8fc\uc18c\ub97c \uac80\uc0c9\ud558\uc5ec \uc704\uce58\ub97c \ud30c\uc545\ud558\ub824\uace0 \ud558\uc600\uace0, \ub2e4\uc18c \ubd80\uc815\ud655 \ud558\uc9c0\ub9cc \ub3d9\ub124\uc5d0 \uc788\ub294 \uc778\uadfc \ucda9\uc804\uc18c\ub97c \ucc3e\uc744 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ud734\uac8c\uc18c\uc5d0 \ub4e4\ub9ac\ub2e4\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub85c \uac80\uc0c9\ud574\ubcf4\ub2c8 \uc2dd\ub2f9\uc73c\ub85c \uac00\ub294 \uae38 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \ud569\ub2c8\ub2e4.\\n\ud734\uac8c\uc18c \uc774\ub984\uc744 \uc785\ub825\ud558\ub2c8 \ubc14\ub85c \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-3.png)\\n\\n\uc2ec\uc9c0\uc5b4 \uc9c0\uae08 \uc0ac\uc6a9\uc911\uc774\ub77c\uace0 \ud569\ub2c8\ub2e4! \ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \ud734\uac8c\uc18c\uc5d0 \ub4e4\ub9ac\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-1.png)\\n![no offset](./yangju-station-2.png)\\n\\n\uc2e4\uc81c\ub85c \uc0ac\uc6a9 \uc911\uc784\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4. \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \uc0ac\uc6a9\uc911\uc774\ub77c\uace0 \ub098\uc654\ub294\ub370 \uc2e4\uc81c\ub85c \uc0ac\uc6a9\uc911\uc778 \uac83\uc744 \ubcf4\ub2c8 \uacf5\uacf5 api\uac00 \ub098\ub984 \uc2e4\uc2dc\uac04\uc73c\ub85c \ub370\uc774\ud130\ub97c \uc798 \ubcf4\ub0b4\uc8fc\uace0 \uc788\ub2e4\uace0 \uc0dd\uac01\ud558\uac8c \ub418\uc5c8\uace0, \uc800\ud76c \ud300 \uc11c\ubc84\uc5d0\uc11c\ub3c4 \uc774\ub97c \uc81c\ub300\ub85c \uc218\uc9d1\ud558\uace0 \uc788\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-5.png)\\n\\n\ub9d0\ub85c\ub9cc \ub4e3\ub358 \uace0\uc18d\ub3c4\ub85c \ud734\uac8c\uc18c\uc758 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \ub300\uae30\uc904\uc744 \uc9c1\uc811 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ucc28\uc8fc \ubd84\uacfc \uc778\ud130\ubdf0 \ud558\uace0 \uc2f6\uc5c8\uc9c0\ub9cc, \ucc28 \ub0b4\ubd80\uc5d0\uc11c \ub108\ubb34 \ubc14\ube60\ubcf4\uc774\uc154\uc11c \uadf8\ub7f4 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uae30\ub2e4\ub9ac\uba74\uc11c \ubb34\uc5c7\uc744 \ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\uc774 \ubd84\uc740 \ub2e4\ud589\ud788\ub3c4 \uc5c5\ubb34\ub97c \ubcf4\uace0 \uacc4\uc168\uc9c0\ub9cc, \ub2e4\ub978 \ucc28\uc8fc\ub4e4\uc740 \ubb34\uc5c7\uc744 \ud558\uace0 \ubcf4\ub0bc\uc9c0 \uad81\uae08\ud574\uc84c\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-4.png)\\n\\n\ud734\uac8c\uc18c\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \ud558\ub098 \ub354 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud55c \uacf3\uc740 \uc0ac\uc6a9\uc911\uc774\uc9c0\ub9cc, \ub2e4\ub978 \ud55c \uacf3\uc740 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc774 \ucda9\uc804\uc18c\ub97c \uc0ac\uc6a9\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./yangju-station-6.png)\\n\\n\uc0ac\uc6a9\ud560 \uc218 \uc788\uc73c\ub2c8\uae50 \ub4e4\uc5b4\uac00\ubd10\uc57c\uc9c0! \ud558\uace0 \ub3c4\ucc29\ud55c \uc21c\uac04 \uc544\ucc28 \uc2f6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\"\uc544, \ucda9\uc804\uc18c\uac00 \uc678\ubd80\uc778 \uc0ac\uc6a9 \uae08\uc9c0\uc77c \uc218 \uc788\uc5c8\uc9c0?\\"\\n\\n\uc800\ud76c\ub294 \ubd84\uba85\ud788 \uc11c\ube44\uc2a4\ub97c \uc9c1\uc811 \uac1c\ubc1c\ud588\uc73c\ub2c8\uae50 \ub2e4 \uc54c\uace0 \uc788\ub358 \uc0ac\ud56d\uc774\uc5c8\uc9c0\ub9cc, \uc804\ud600 \uc0dd\uac01\uce58 \ubabb\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc11c\ube44\uc2a4\ub97c \uac1c\ubc1c\ud558\ub294 \ub0b4\ub0b4 \uc678\ubd80\uc778 \uac1c\ubc29 \ucda9\uc804\uc18c\uc5d0 \ub300\ud55c \uc911\uc694\uc131\uc744 \uac04\ud30c\ud558\uc600\uace0, \uc774 \uae30\ub2a5\uc744 \ub123\uc5c8\uc73c\uba74\uc11c\ub3c4 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \ucda9\uc804\uc18c\ub97c \ubc29\ubb38\ud55c \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5b4\uc11c \ub2e4\ud589\uc774\uc5c8\uc9c0\ub9cc, \uc5b4\ucc0c\ub410\ub4e0 \uc774 \ucda9\uc804\uc18c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud734\uac8c\uc18c\ub97c \ub5a0\ub098\ub294 \ub0b4\ub0b4 \uc774 \ubb38\uc81c\uc5d0 \ub300\ud574\uc11c \ud1a0\ub860\uc744 \ud560 \uc218 \ubc16\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```\\n\ubd84\uba85 \uc6b0\ub9ac\uac00 \ub9cc\ub4e0 \uc11c\ube44\uc2a4\uc778\ub370 \uc65c \ub193\ucce4\uc744\uae4c?\\n```\\n\\n## \ub9db\uc788\ub294 \uc810\uc2ec\\n\\n![no offset](./noodle-1.png)\\n\\n\ud30c\uc8fc\ub2ed\uad6d\uc218 \ubcf8\uc810\uc5d0\uc11c \ub9db\uc788\ub294 \uc2dd\uc0ac\ub97c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ube44\ub85d \uc2dd\ub2f9\uc5d0\ub294 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5c6\uc5c8\uc9c0\ub9cc, \uc778\uadfc\uc5d0 \ucda9\uc804\uc18c\uac00 \uc788\uc5b4 \uc2e4\ud5d8\uc744 \ud558\ub098 \ud574\ubcfc \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc778\uadfc \ucda9\uc804\uc18c\uc640 \uc2dd\ub2f9\uc758 \uac70\ub9ac\uac00 \uac00\uae4c\uc6cc \ubcf4\uc774\ub294\ub370, \uacfc\uc5f0 \uac78\uc5b4\uac08 \uc218 \uc788\uc744\uae4c?\\n\\n\uc2e4\uc81c\ub85c \uac77\uc9c0\ub294 \uc54a\uc558\uc2b5\ub2c8\ub2e4\ub9cc \ucc28 \ud0c0\uba74\uc11c \uc9c0\ub098\uac00\uba74\uc11c \ud655\uc778\ud574\ubcf8 \uacb0\uacfc \uc9c1\uc811 \uac78\uc744 \uc218 \uc5c6\ub294 \uac70\ub9ac\uc600\uc2b5\ub2c8\ub2e4. (\uad49\uc7a5\ud788 \uac77\uae30 \uc2eb\uc740 \uc218\uc900\uc758 \uba3c \uac70\ub9ac\uc600\uc2b5\ub2c8\ub2e4.)\\n\\n\uc9d1\uc5d0 \uc788\ub294 PHEV\ub97c \ud0c8 \uae30\ud68c\uac00 \ub9ce\uc544 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\ub97c \uc790\uc8fc \ubc29\ubb38\ud588\ub358 \uc800\ub294 \uc774\ub7f0 \uc810\uc744 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\ud589\ud788 \uc774 \ubd80\ubd84\uc744 \uc798 \uc54c\uace0 \uc788\uc5c8\uae30\uc5d0 \uc800\ud76c\ub294 \uc774 \ubd80\ubd84\uc744 \uc11c\ube44\uc2a4\uc5d0 \ubc18\uc601\ud558\uc600\uace0, \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c \ud3ec\uae30\ud558\uc9c0 \uc54a\uc558\ub358 \uac83\uc774 \uc633\uc740 \uc120\ud0dd\uc774\uc5c8\ub2e4\ub294 \uac83\uc744 \ud655\uc778\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./noodle-2.png)\\n\\n\uc2dd\uc0ac\uac00 \ub05d\ub098\uace0 \ub4dc\ub514\uc5b4 \ub9c8\uc7a5\ud638\uc218\ub85c \ucd9c\ubc1c\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ub9c8\uc7a5\ud638\uc218 \ub3c4\ucc29\\n\\n\ub9c8\uc7a5\ud638\uc218\uc5d0 \ub3c4\ucc29\ud558\uc790\ub9c8\uc790 \ucda9\uc804\uc18c\uc5d0 \ubc29\ubb38\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-1.png)\\n\\n\ud1b5\uacc4\uc5d0\uc11c\ub294 \uc0ac\uc6a9\ub960\uc774 \uc801\uc744 \uac83\uc774\ub77c\uace0 \ud558\uc600\ub294\ub370 \uc800\ud76c\ub9cc \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-2.png)\\n![no offset](./majang-4.png)\\n\\n2\uae30 \uc911 1\uacf3\uc744 \uc800\ud76c\uac00 \uc0ac\uc6a9\ud558\uc600\uace0, \ub9c8\uc7a5\ud638\uc218\ub97c \ub3cc\uc558\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-3.png)\\n\\n\uc57d 50\ubd84 \uac04 \uc0b0\ucc45\uc744 \ud558\uace0, \ub3cc\uc544\uc640\ubcf4\ub2c8 \ucda9\uc804\uae30 \ub2e4 \ub418\uc5b4\uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \ub9c8\uc7a5\ud638\uc218 \uae4c\uc9c0 \uc624\ub294 \ub0b4\ub0b4 \ub4e0 \uc0dd\uac01\uc774\uc5c8\uc9c0\ub9cc, \uc804\uae30\ucc28\uc758 \ubc30\ud130\ub9ac\uac00 \uc0dd\uac01\ubcf4\ub2e4 \uc624\ub798 \uac04\ub2e4\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc77c\ubd80\ub7ec \ud68c\uc0dd\uc81c\ub3d9 \uae30\ub2a5\ub3c4 \ub044\uace0, \uc5d0\uc5b4\ucee8\uc744 \uac15\ud558\uac8c \ud2c0\uc5b4\uc11c \ubc30\ud130\ub9ac\ub97c \uc18c\uc9c4\ud558\ub824\uace0 \ud558\uc600\uc73c\ub098, 85km\ub97c \uc8fc\ud589\ud558\ub294 \ub3d9\uc548 \uaca8\uc6b0 20%\ub97c \uc18c\ubaa8\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ucda9\uc804\uae30\ub97c \uaf42\uc744 \ub54c 50%\uc600\uc73c\ub098, \ud638\uc218\ub97c \ud55c\ubc14\ud034 \ub3cc\uace0 \uc624\ub2c8 \uc774\ubbf8 100%\uac00 \ub418\uc5b4\uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc5ec\ub2f4\uc774\uc9c0\ub9cc, \uc800\ud76c\uac00 \ub3cc\uc544\uc654\uc744 \ub54c \uc606 \uc790\ub9ac\uc5d0\ub294 \uc804\uae30 \ud654\ubb3c\ucc28\uac00 \uc788\uc5b4 \ucda9\uc804\uc18c\uac00 \uac00\ub4dd \ucc3c\uc2b5\ub2c8\ub2e4.\\n\\n\ub610, \uc571\uc5d0\uc11c\ub3c4 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80\uac00 \uc5c5\ub370\uc774\ud2b8 \ub418\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./majang-5.png)\\n\\n\ubc30\ud130\ub9ac \uc131\ub2a5\uc5d0\ub294 \uc88b\uc9c0 \uc54a\uace0 \uac00\uaca9\ub3c4 \ube44\uc2f8\uc11c \uc774\ub97c \uc790\uc8fc \uc0ac\uc6a9\ud558\ub294 \uac83\uc740 \uc88b\uc9c0 \uc54a\uaca0\uc9c0\ub9cc, \uae09\ud55c \uc0ac\ub78c\ub4e4\uc740 \uae09\uc18d \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\ud558\uba74 \ub418\uaca0\uad6c\ub098 \uc2f6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```\\n\ub530\ub77c\uc11c \uae09\uc18d\uacfc \uc644\uc18d\uc740 \ub354\ub354\uc6b1 \ub2e4\ub978 \uac1c\ub150\uc73c\ub85c \ubd10\uc57c\uaca0\ub2e4\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n```\\n\\n\uc81c\uac00 \uadf8\ub3d9\uc548 \uacbd\ud5d8\ud588\ub358 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\ub294 \uc644\uc18d \uae30\uc900\uc774\uc5c8\uae30\uc5d0 \uc2e0\uc120\ud55c \uacbd\ud5d8\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uc120\ub989\uc73c\ub85c \ub3cc\uc544\uc624\ub2e4\\n\\n![no offset](./end.png)\\n\\n\uc120\ub989\uc73c\ub85c \ub3cc\uc544\uc640\uc11c \ucc28\ub7c9\uc744 \ubc18\ub0a9\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc774\ubc88 \uc5ec\uc815\uc744 \ud1b5\ud574 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc5b4\ub5a4 \uc810\uc744 \uac1c\uc120\ud574\uc57c\ud560\uc9c0 \uc880 \ub354 \uba85\ud655\ud558\uac8c \uc54c\uac8c\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1. \ud604\uc7ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \uae30\ub2a5\ub4e4\ub85c \ucda9\uc804\uc18c\ub97c \uac80\uc0c9\ud558\ub294 \uac83\uc740 \uac00\ub2a5\ud558\uba70, \ucda9\uc804\uc18c\uc758 \uc704\uce58\ub97c \uc815\ud655\ud558\uac8c \ud30c\uc545\ud558\ub294 \uac83\ub3c4 \uac00\ub2a5\ud558\ub2e4.\\n2. \ud558\uc9c0\ub9cc \ucda9\uc804\uc18c\uac00 \uc5c6\ub294 \ubaa9\uc801\uc9c0\ub294 \uac80\uc0c9\ud560 \uc218 \uc5c6\uace0, \ud604 \uc704\uce58\uac00 \uc5b4\ub514\uc778\uc9c0 \uac00\ub2a0\ud558\uae30\uac00 \uc5b4\ub824\uc6cc\uc9c4\ub2e4.\\n3. \ucda9\uc804\uc18c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub2e4\uace0 \ud45c\uae30\ub418\uc5b4 \uc788\ub354\ub77c\ub3c4 \uc678\ubd80\uc778 \uac1c\ubc29\uc774 \uc544\ub2d0 \uc218 \uc788\ub2e4. \uc815\ubcf4\uac00 \uc815\ud655\ud788 \uc81c\uacf5\ub428\uc5d0\ub3c4 \ubd88\uad6c\ud558\uace0 \uc774\ub97c \ub2e8\ubc88\uc5d0 \ub208\uce58\ucc44\uae30 \uc5b4\ub835\ub2e4.\\n4. \uc774\ub7ec\ud55c \ubb38\uc81c\ub97c \uc608\uc0c1\ud558\uc5ec `\uc678\ubd80\uc778 \uac1c\ubc29 \uc5ec\ubd80`\ub97c \ud544\ud130\ub9c1 \ud560 \uc218 \uc788\ub294 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\uace0 \uc788\uc74c\uc5d0\ub3c4 \ubd88\uad6c\ud558\uace0 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n5. \ucda9\uc804\uc18c\uc758 \ud1b5\uacc4 \uc790\ub8cc\uc758 \uc801\uc911\ub960\uc740 \ub192\uc558\uc73c\ub098, \uc880 \ub354 \ub9ce\uc740 \ucda9\uc804\uc18c\ub97c \ub4e4\ub824 \ud655\uc778\ud574\ubd10\uc57c \ud560 \uac83 \uac19\uc558\ub2e4.\\n6. \uc804\uae30\uc790\ub3d9\ucc28\ub294 \uc0dd\uac01\ubcf4\ub2e4 \uc624\ub798\uac00\uace0 \uc0c1\ud488\uc131\uc774 \uc788\uc5c8\ub2e4. \uc8fc\ud589 \ub2a5\ub825\ub3c4 \ucda9\ubd84\ud558\uace0, \uc778\ud504\ub77c\uac00 \uc798 \ub418\uc5b4\uc788\ub2e4. \uc774\uac78 \uc65c \uc695\ud558\uc9c0? \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\ub2e4.\\n7. \uc9c0\ub3c4 \ud655\ub300 \ud5c8\uc6a9 \ubc94\uc704\uac00 \ub108\ubb34 \uc881\uc544\uc11c \uc0ac\uc6a9\ud558\ub294\ub370 \ubd88\ud3b8\ud55c\uac74 \uc2e4\uc81c \uc0c1\ud669\uc5d0\uc11c \ub354 \ubd88\ud3b8\ud588\ub2e4.\\n\\n\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."},{"id":"37","metadata":{"permalink":"/37","source":"@site/blog/2023-09-22-station-api-separate.mdx","title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","description":"\uc131\ub2a5 \uac1c\uc120\uc744 \uc704\ud574 \ucda9\uc804\uc18c \uc870\ud68c API\uc758 \uc124\uacc4\ub97c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.","date":"2023-09-22T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 22\uc77c","tags":[{"label":"\ud611\uc5c5","permalink":"/tags/\ud611\uc5c5"},{"label":"\uc11c\ubc84 \ubd80\ud558 \uc904\uc774\uae30","permalink":"/tags/\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30"}],"readingTime":2.78,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"37","title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","authors":["scent"],"tags":["\ud611\uc5c5","\uc11c\ubc84 \ubd80\ud558 \uc904\uc774\uae30"]},"prevItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","permalink":"/39"},"nextItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/38"}},"content":"\uc131\ub2a5 \uac1c\uc120\uc744 \uc704\ud574 \ucda9\uc804\uc18c \uc870\ud68c API\uc758 \uc124\uacc4\ub97c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874\uc5d0\ub294 \ucda9\uc804\uc18c \uac04\ub2e8 \uc815\ubcf4\uc640 \ub9c8\ucee4 \uc815\ubcf4\ub97c \ud55c \ubc88\uc5d0 \ubc1b\uc544\uc624\ub3c4\ub85d \uc124\uacc4\ub418\uc5b4 \uc788\uc5c8\uc9c0\ub9cc,\\n\ubc31\uc5d4\ub4dc\uc640 \ud504\ub860\ud2b8\uc5d4\ub4dc\uac00 \ud611\uc5c5\ud558\uc5ec \uac04\ub2e8 \uc815\ubcf4\uc640 \ub9c8\ucee4 \uc815\ubcf4\ub97c \uac01\uac01 \ud544\uc694\ud55c \ub9cc\ud07c\ub9cc \uc870\ud68c\ud558\ub3c4\ub85d \uba85\uc138\ub97c \uc218\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \uba3c\uc800, \ubc31\uc5d4\ub4dc\uc640 \ud504\ub860\ud2b8\uc5d4\ub4dc\ub294 \ud568\uaed8 \ubaa8\uc5ec \uae30\ub2a5 \uc694\uad6c\uc0ac\ud56d\uacfc \uc131\ub2a5 \uac1c\uc120 \ubaa9\ud45c\ub97c \ub17c\uc758\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \ucda9\uc804\uc18c \uac04\ub2e8 \uc815\ubcf4\uc640 \ub9c8\ucee4 \uc815\ubcf4\ub97c \uac01\uac01 \uc870\ud68c\ud558\ub294 API \uc5d4\ub4dc\ud3ec\uc778\ud2b8\ub97c \uc0c8\ub85c \uc124\uacc4\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\uc74c\uc73c\ub85c, \ubc31\uc5d4\ub4dc\uc5d0\uc11c \uac04\ub2e8 \uc815\ubcf4 \uc870\ud68c\ub97c \uc704\ud55c API\ub97c \uad6c\ud604\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\ud544\uc694\ud55c \ud544\ub4dc\ub9cc\uc744 \uc870\ud68c\ud558\uc5ec \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ubd80\ud558\ub97c \uc904\uc774\uace0 \uc751\ub2f5 \uc2dc\uac04\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uc774\ud6c4\uc5d0\ub294 \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c \ud574\ub2f9 API\ub97c \ud638\ucd9c\ud558\uc5ec \ud544\uc694\ud55c \uc815\ubcf4\ub97c \ubc1b\uc544\uc624\ub3c4\ub85d \uc218\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c, \ub9c8\ucee4 \uc815\ubcf4 \uc870\ud68c\ub97c \uc704\ud55c API\ub97c \uad6c\ud604\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\ub9c8\ucee4 \uc815\ubcf4\ub294 \uc9c0\ub3c4\uc5d0 \ud45c\uc2dc\ub418\ub294 \uc815\ubcf4\ub85c\uc11c, \uc694\uccad\ud55c \uc601\uc5ed \uc678\ubd80\ub85c \uc9c0\ub3c4\uac00 \uc774\ub3d9\ud560 \uacbd\uc6b0 \ud638\ucd9c\ub418\ub3c4\ub85d \uc124\uacc4\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874\uc5d0\ub294 \uac04\ub2e8 \uc815\ubcf4 \ub9ac\uc2a4\ud2b8\ub97c \ubcf4\uc5ec\uc8fc\uae30 \uc704\ud574 \uc870\ud68c\ud558\ub358 \uc815\ubcf4\ub4e4\uc774 \ub2e4\uc218 \ud3ec\ud568\ub418\uc5b4 \uc788\uc5c8\uc9c0\ub9cc,\\n\uc774 \uc815\ubcf4\ub97c \uc81c\uc678\ud558\uace0 \ub9c8\ucee4\ub97c \ub744\uc6b0\uae30 \uc704\ud574 \ud544\uc694\ud55c \ucd5c\uc18c\ud55c\uc758 \uc815\ubcf4\ub97c \uc870\ud68c\ud558\ub3c4\ub85d \uc218\uc815\ud574 \uc11c\ubc84\uc758 \ubd80\ud558\ub97c \ub0ae\ucdc4\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7ec\ud55c \ubcc0\uacbd\uc73c\ub85c \uc778\ud574 \ucda9\uc804\uc18c \uc870\ud68c API\uc758 \uc131\ub2a5\uc774 \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ud544\uc694\ud55c \uc815\ubcf4\ub9cc\uc744 \uc870\ud68c\ud558\ubbc0\ub85c\uc368 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ubd80\ud558\ub97c \uc904\uc774\uace0 \uc751\ub2f5 \uc2dc\uac04\uc744 \ub2e8\ucd95\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ub610\ud55c, \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c\ub294 \ud544\uc694\ud55c \uc815\ubcf4\ub9cc\uc744 \ud638\ucd9c\ud558\uc5ec \ubd88\ud544\uc694\ud55c \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc624\uc9c0 \uc54a\uc544\ub3c4 \ub418\ubbc0\ub85c \ud074\ub77c\uc774\uc5b8\ud2b8 \uce21\uc758 \uc131\ub2a5\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uc2b5\ub2c8\ub2e4."},{"id":"38","metadata":{"permalink":"/38","source":"@site/blog/2023-09-22-visitors/index.mdx","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","description":"\uc800\ud76c \ud300\uc740 \ub2e8\uc21c \ubc29\ubb38\uc790 100\uba85\uc744 \ubaa8\uc544\uc57c\ud558\ub294 \ubbf8\uc158\uc744 \ubc1b\uc558\uc2b5\ub2c8\ub2e4.","date":"2023-09-22T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 22\uc77c","tags":[{"label":"ga4","permalink":"/tags/ga-4"},{"label":"google analytics 4","permalink":"/tags/google-analytics-4"},{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778"},{"label":"\ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/tags/\ubc29\ubb38\uc790-\ubd84\uc11d"}],"readingTime":3.82,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"38","title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","authors":["gabriel"],"tags":["ga4","google analytics 4","\uce74\ud398\uc778","\ubc29\ubb38\uc790 \ubd84\uc11d"]},"prevItem":{"title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","permalink":"/37"},"nextItem":{"title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","permalink":"/36"}},"content":"\uc800\ud76c \ud300\uc740 \ub2e8\uc21c \ubc29\ubb38\uc790 100\uba85\uc744 \ubaa8\uc544\uc57c\ud558\ub294 \ubbf8\uc158\uc744 \ubc1b\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ubaa9\ud45c \ub2ec\uc131\uc744 \uc704\ud574 \uc57d 2\uc8fc \uc804\uc5d0 \uc2e4\ud589 \uacc4\ud68d\uc744 \uc81c\ucd9c\ud574\uc57c \ud588\ub294\ub370\uc694\\n\\n100\uba85\uc744 \ubaa8\uc9d1\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uacc4\ud68d\uc744 \uc138\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n![no offset](./plan.png)\\n\\n---\\n\\n\uc774 \ub2f9\uc2dc \uc800\ud76c \ud300\uc758 \uac00\uc7a5 \ud070 \uace0\ubbfc\uc740, \uc804\uae30\ucc28\uac00 \uc5ec\uc804\ud788 \uc18c\uc218\uc758 \uc6b4\uc804\uc790\uc5d0\uac8c\ub9cc \ubcf4\uae09\ub418\uc5c8\ub2e4\ub294 \uc810\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud2b9\ud788, \uc804\uae30\ucc28 \ubcf4\uae09 \uad00\ub828 \ud1b5\uacc4 \uc790\ub8cc\ub97c \ucc3e\uc544\ubcf4\uba74 \ub300\ubd80\ubd84\uc758 \ucc28\uc8fc\ub4e4\uc740 40~60\ub300\uc5d0 \uc555\ub3c4\uc801\uc73c\ub85c \ubab0\ub824\uc788\uc5b4 \uc80a\uc740 \uc5f0\ub839 \uce35\uc5d0\uc11c\ub294 \uac70\uc758 \uad6c\ub9e4\ub97c \ud558\uc9c0 \uc54a\uace0 \uc788\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n![no offset](./statistics.png)\\n\\n\uc704 \uc790\ub8cc\ub294 2021\ub144 7\uc6d4 \uae30\uc900\uc774\uc9c0\ub9cc, \ucd5c\uc2e0 \uc790\ub8cc\uc5d0\uc11c\ub3c4 \ub9c8\ucc2c\uac00\uc9c0\ub85c \uc80a\uc740 \uc5f0\ub839\uce35\uc5d0\uc11c\ub294 \uc804\uae30\ucc28\ub97c \ubcf4\uc720\ud55c \uc0ac\ub78c\uc744 \ucc3e\uae30 \uc5b4\ub835\ub2e4\uace0 \ub098\uc635\ub2c8\ub2e4. \uc2e4\uc81c\ub85c \uc8fc\ubcc0 \ub610\ub798\uc758 \uc6b4\uc804\uc790\ub97c \ucc3e\uc544\ubcf4\uba74 \ub300\ubd80\ubd84 \uac00\uc194\ub9b0 \ubaa8\ub378\uc744 \ud0c0\uace0 \ub2e4\ub2c8\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud64d\ubcf4 \ub300\uc0c1\uc744 \uc8fc\ubcc0\uc5d0\uc11c \ucc3e\uc9c0 \uc54a\uace0 \ubd88\ud2b9\uc815 \ub2e4\uc218\uc758 \uc0ac\ub78c\ub4e4\uc744 \ubaa8\uc9d1\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n# \ud64d\ubcf4 \ubc29\ubc95\\n\\n## \uce74\ud398\\n\\n![no offset](./insta1.png)\\n![no offset](./naver1.png)\\n\\n\ub124\uc774\ubc84\uc5d0 \uc788\ub294 \uc804\uae30\uc790\ub3d9\ucc28 \ub3d9\ud638\ud68c \uce74\ud398 \uc911 \uac00\uc7a5 \ud070 \uacf3\uc5d0 \uae00\uc744 \uc62c\ub824 \ubc29\ubb38\uc790\ub97c \ubaa8\uc9d1\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uce74\ud398\uc5d0 \uae00\uc744 \uc62c\ub9ac\ub294 \uac83\uc740 \ubb34\ub8cc\uc774\uba70, \uce74\ud398\uc5d0 \uac00\uc785\ud55c \uc0ac\ub78c\ub4e4\uc740 \uc804\uae30\ucc28\uc5d0 \uad00\uc2ec\uc774 \uc788\ub294 \uc0ac\ub78c\ub4e4\uc774\uae30 \ub54c\ubb38\uc5d0 \uc800\ud76c\uac00 \uc6d0\ud558\ub294 \ubc29\ubb38\uc790\ub97c \ubaa8\uc9d1\ud558\uae30\uc5d0 \uc801\ud569\ud558\ub2e4\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uce74\uce74\uc624\ud1a1 \uc624\ud508\ucc44\ud305\\n\\n![no offset](./kakao1.png)\\n![no offset](./kakao2.png)\\n\\n\uce74\uce74\uc624\ud1a1 \uc624\ud508\ucc44\ud305\uc5d0\ub294 \uc218\ub9ce\uc740 \ub300\ud654\ubc29\uc774 \uc874\uc7ac\ud569\ub2c8\ub2e4.\\n\\n\ud2b9\uc815 \uc8fc\uc81c\ub85c \ub9cc\ub4e4\uc5b4\uc9c4 \ub300\ud654\ubc29\uc774 \ub300\ubd80\ubd84\uc774\uae30\uc5d0 \uc804\uae30\ucc28\ub97c \uc8fc\uc81c\ub85c \ud55c \uc624\ud508\ucc44\ud305 \ub300\ud654\ubc29\uc744 \ucc3e\ub294 \uac83\uc740 \uc804\ud600 \uc5b4\ub835\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\uc548\ud0c0\uae5d\uac8c\ub3c4 \uc77c\ubd80 \ub2e8\ud1a1\ubc29\uc5d0\uc11c \uac15\ud1f4\ub97c \ub2f9\ud588\uc9c0\ub9cc, \ucc28\uc8fc\ub4e4\uacfc \ucc44\ud305\ud558\uba74\uc11c \ud53c\ub4dc\ubc31\uc744 \ubc1b\uc544\ubcfc \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uae30\ud0c0 \ud64d\ubcf4 \uc218\ub2e8\\n\\n\uae30\ud0c0 \ud64d\ubcf4 \uc218\ub2e8\uc740 \uc544\uc9c1 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ub124\uc774\ubc84 \ubc34\ub4dc, \ubcf4\ubc30\ub4dc\ub9bc\uc740 \uc0ac\uc6a9\ud558\ub294 \ud06c\ub8e8\uac00 \uc5c6\uc5b4\uc11c \ud64d\ubcf4\ub97c \ud558\uae30 \uc5b4\ub824\uc6e0\uace0, \uad6c\uae00 \uc560\ub4dc\uc13c\uc2a4\uc640 \uac19\uc740 \ub3c4\uad6c\ub294 \ube44\uc6a9\uc774 \ubc1c\uc0dd\ud558\uae30\uc5d0 \uc544\uc9c1\uc740 \uc774\ub974\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n# Google Analytics 4 \ud1b5\uacc4 \uc9d1\uacc4 \uacb0\uacfc\\n\\n## \ub2e8\uc21c \ubc29\ubb38\uc790\\n\\n![no offset](./ga1.png)\\n![no offset](./ga2.png)\\n![no offset](./ga3.png)\\n![no offset](./ga4.png)\\n\uc774\ucc98\ub7fc \uc678\ubd80 \uc9c0\uc5ed\uc5d0\uc11c\ub3c4 \ub9ce\uc774 \uc811\uc18d\ud574\uc8fc\uc2e0 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n![no offset](./ga5.png)\\n![no offset](./ga6.png)\\n![no offset](./ga7.png)\\n\\n\uc9d1\uacc4 \ub41c \uc790\ub8cc\ucc98\ub7fc \ubc29\ubb38\uc790\ub4e4\uc774 \ub2e8\uc21c \ubc29\ubb38\ub9cc \ud55c \uac83\uc774 \uc544\ub2c8\ub77c, \uc218 \ub9ce\uc740 \uc774\ubca4\ud2b8\ub97c \ubc1c\uc0dd\uc2dc\ud0a4\uace0 \ud3c9\uade0 \ucc38\uc5ec \uc2dc\uac04\ub3c4 \uc0c1\ub2f9 \ubd80\ubd84 \ud655\ubcf4\ud588\uc74c\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."},{"id":"36","metadata":{"permalink":"/36","source":"@site/blog/2023-09-21-marker-rendering-optimization.mdx","title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","description":"1. \uac1c\uc694","date":"2023-09-21T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 21\uc77c","tags":[{"label":"react","permalink":"/tags/react"},{"label":"useSyncExternalState","permalink":"/tags/use-sync-external-state"},{"label":"googleMap","permalink":"/tags/google-map"}],"readingTime":12.04,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"36","title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","authors":["scent"],"tags":["react","useSyncExternalState","googleMap"]},"prevItem":{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/38"},"nextItem":{"title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","permalink":"/35"}},"content":"### 1. \uac1c\uc694\\n\\n\uae30\uc874\uc758 \uad6c\uc870\uc5d0\uc11c\ub294 \ub9c8\ucee4 \ud558\ub098\ub97c \ub80c\ub354\ub9c1\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uacfc\uc815\uc744 \uac70\ucce4\ub2e4.\\n\\n1. StationMarkersContainer \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ucda9\uc804\uc18c \uc815\ubcf4 \uc694\uccad\\n2. \ucda9\uc804\uc18c \uc815\ubcf4\ub97c props\ub85c \ub118\uaca8 Marker \ucef4\ud3ec\ub10c\ud2b8 \ud638\ucd9c\\n3. \uc9c0\ub3c4\uc5d0 \ubd80\ucc29\ub420 DOM\uc694\uc18c \uc0dd\uc131\\n4. createRoot\ub97c \ud1b5\ud574 \ub9ac\uc561\ud2b8 root \uc0dd\uc131\\n5. 2\ubc88\uc5d0\uc11c \uc0dd\uc131\ud55c DOM \uc694\uc18c\ub97c \uc804\ub2ec\ud574 \uad6c\uae00 \uc9c0\ub3c4 api\uc758 Marker \uc0dd\uc131\uc790 \ud568\uc218 \ud638\ucd9c\\n6. 3\ubc88\uc5d0\uc11c \uc0dd\uc131\ud588\ub358 root\uc758 render \uba54\uc11c\ub4dc \ud638\ucd9c\\n7. \ub9c8\ucee4 \uc778\uc2a4\ud134\uc2a4 \uc804\uc5ed \uc0c1\ud0dc\uc5d0 \uc0c8\ub85c \uc0dd\uc131\ud55c \ub9c8\ucee4 \ucd94\uac00\\n\\n\uc704 \uacfc\uc815\uc744 \uac70\ucce4\uc744 \ub54c\uc758 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \ubaa8\uc2b5\uc744 \ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n![before](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/28520ee3-2fa6-4110-b4e4-8a0bb706324e)\\n\\n\ub9c8\ucee4\ub4e4\uc774 \ud55c\ubc88\uc5d0 \ub80c\ub354\ub9c1 \ub418\ub294 \uac83\uc774 \uc544\ub2c8\ub77c \uc0b0\ubc1c\uc801\uc73c\ub85c \ub80c\ub354\ub9c1 \ub418\ub294 \ubaa8\uc2b5\uc744 \ud655\uc778\ud560 \uc218 \uc788\ub2e4.\\n\\n### 2. \ubb38\uc81c \uc6d0\uc778 \ubd84\uc11d\\n\\n\ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud558\uae30 \uc704\ud574 \uac70\uce58\ub294 \uacfc\uc815\uc744 \ubd84\uc11d\ud574 \ubcf4\uc558\ub2e4.\\n\\n1 ~ 3 \uacfc\uc815\uc5d0\uc11c\ub294 \uc131\ub2a5\uc5d0 \ud06c\uac8c \uc601\ud5a5\uc744 \ub07c\uce60 \uc694\uc18c\uac00 \uc5c6\uc9c0\ub9cc 4\ubc88 \uacfc\uc815\uc740 \uc77c\ubc18\uc801\uc778 \ub9ac\uc561\ud2b8 \ud504\ub85c\uc81d\ud2b8\ub97c \uac1c\ubc1c\ud560 \ub54c \uacaa\ub294 \uacfc\uc815\uc774 \uc544\ub2c8\ub2e4. \ub530\ub77c\uc11c createRoot\ub97c \ud1b5\ud574 \ub9ce\uc740 \uac1c\uc218\uc758 \ub8e8\ud2b8\ub97c \uc0dd\uc131\ud588\uc744 \ub54c\uc758 \uc601\ud5a5\uc5d0 \ub300\ud574 \uc54c\uc544\ubcf4\uc558\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/494a5bc5-be5d-4a58-b5b2-77ce7d3e5de7)\\n\\n\ub9ac\uc561\ud2b8 \uacf5\uc2dd \ubb38\uc11c\ub97c \ubcf4\ub2c8 \ud398\uc774\uc9c0\uc758 \uc77c\ubd80\uc5d0 \ub9ac\uc561\ud2b8\ub97c \ubfcc\ub824\uc11c \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0\uc5d0\ub294 \ub8e8\ud2b8\ub97c \ud544\uc694\ud55c \ub9cc\ud07c \uc0dd\uc131\ud574\ub3c4 \ub41c\ub2e4\ub294 \uc774\uc57c\uae30\uac00 \ud3ec\ud568\ub418\uc5b4 \uc788\uc5c8\ub2e4. \ub530\ub77c\uc11c 4\ubc88 \uacfc\uc815 \ub610\ud55c \ubb38\uc81c\uc758 \uc6d0\uc778\uc774\ub77c\uace0 \ubcfc \uc218 \uc5c6\uc5c8\ub2e4.\\n\\n5\ubc88 \uacfc\uc815\uc740 \uad6c\uae00 \uc9c0\ub3c4\uc5d0 \ub9c8\ucee4\ub97c \ud2b9\uc815 \uc704\ub3c4 \uacbd\ub3c4\uc5d0 \uc704\uce58\uc2dc\ud0a4\uae30 \uc704\ud574\uc11c \uc5b4\uca54 \uc218 \uc5c6\uc774 \uac70\uccd0\uc57c \ud558\ub294 \uacfc\uc815\uc774\ubbc0\ub85c \uc774 \uacfc\uc815\uc740 \ubb38\uc81c\uac00 \uc788\ub354\ub77c\ub3c4 \uac1c\uc120\uc774 \ubd88\uac00\ub2a5\ud574 \uc77c\ub2e8 \uace0\ub824\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n\\n6\ubc88 \uacfc\uc815\uc740 4\ubc88 \uacfc\uc815\uc5d0\uc11c \uc0dd\uc131\ud588\ub358 \ub9ac\uc561\ud2b8 \ub8e8\ud2b8\uc758 render \uba54\uc11c\ub4dc\ub97c \ud638\ucd9c\ud574 \uc2e4\uc81c\ub85c \ud654\uba74\uc5d0 \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \uadf8\ub9ac\ub3c4\ub85d \ud558\ub294 \uacfc\uc815\uc774\ub2e4. \uc774 \uacfc\uc815 \ub610\ud55c \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1\ud558\uae30 \uc704\ud574\uc120 \uc5b4\uca54 \uc218 \uc5c6\uc774 \uac70\uccd0\uc57c \ud558\ub294 \uacfc\uc815\uc774\ubbc0\ub85c \uace0\ub824\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n\\n> \ud558\uc9c0\ub9cc 6\ubc88 \uacfc\uc815\uc5d0\uc11c \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \uc9c1\uc811 \uadf8\ub9ac\ub294 \uac83\uc774 \uc544\ub2c8\ub77c \uad6c\uae00 \uc9c0\ub3c4 api\uc758 \uae30\ubcf8 \ub9c8\ucee4\ub97c \uc0ac\uc6a9\ud558\uba74 \uc131\ub2a5\uc744 \ud5a5\uc0c1\uc2dc\ud0ac \uc218 \uc788\uc9c0 \uc54a\ub0d0\uace0 \ubc18\ubb38\ud560 \uc218\ub3c4 \uc788\uc744 \uac83\uc774\ub2e4. \uc774\uc804\uc5d0\ub294 \uc774\ub7ec\ud55c \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud574 \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud588\uc5c8\ub2e4. \uc6b0\ub9ac\uc758 \uc11c\ube44\uc2a4\ub294 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uc18c \uac1c\uc218\ub97c \ub9c8\ucee4\ub97c \ud1b5\ud574\uc11c\ub3c4 \uc804\ub2ec\ud558\uae30 \ub54c\ubb38\uc5d0 \uc774\ub97c \uace0\ub824\ud574 \uae30\ubcf8 \ub9c8\ucee4\ub97c \uc0ac\uc6a9\ud560 \ub54c \ub2e4\uc74c\uc758 \ub450 \uac00\uc9c0 \ubb38\uc81c\uac00 \uc0dd\uae34\ub2e4.\\n>\\n> 1. \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uc18c \uac1c\uc218\ub97c \uae30\ubcf8 \ub9c8\ucee4\uc5d0 \ub80c\ub354\ub9c1 \ud560 \ub54c \uc131\ub2a5\uc774 \ub9e4\uc6b0 \uc88b\uc9c0 \uc54a\ub2e4.\\n> 2. \ub9c8\ucee4\uc758 \ub514\uc790\uc778\uc744 \ubc14\uafb8\uace0\uc790 \ud560 \ub54c \ubcc0\uacbd\uc5d0 \ub300\uc751\ud558\uae30 \uc5b4\ub835\ub2e4.\\n>\\n> \ub530\ub77c\uc11c \ub9c8\ucee4\ub294 \ub9ac\uc561\ud2b8 \ub8e8\ud2b8\uc758 render \uba54\uc11c\ub4dc\ub97c \ud638\ucd9c\ud574 \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ub80c\ub354\ub9c1\ud558\ub294 \uac83\uc73c\ub85c \uacb0\uc815\ud588\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ub0a8\uc740 7\ubc88 \uacfc\uc815\uc5d0\uc11c\ub294 useSyncExternalState \ud6c5\uc744 \uc0ac\uc6a9\ud574 \uc804\uc5ed\uc801\uc73c\ub85c \uad00\ub9ac\ud558\uace0 \uc788\ub358 \uc0c1\ud0dc\uc5d0 \uc218\uc815\uc744 \uac00\ud558\ub294 \uc5f0\uc0b0\uc744 \uc218\ud589\ud55c\ub2e4. \uc774 \uacfc\uc815\uc740 \uc774\uc804\uc5d0\ub3c4 \uc131\ub2a5 \uc800\ud558\ub97c \uc720\ubc1c\ud560 \uac83\uc73c\ub85c \uc608\uc0c1\ub418\ub358 \ubd80\ubd84\uc774\uc5c8\ub2e4. (\ud558\ub2e8 \ub9c1\ud06c \ucc38\uace0)\\n\\n[useSyncExternalStore \ud6c5\uc744 \ud1b5\ud574 \uad6c\ub3c5\ud55c state\uac00 \ud55c\ubc88\uc5d0 \uc5c5\ub370\uc774\ud2b8 \ub418\ub294 \uc774\uc720](https://www.notion.so/useSyncExternalStore-state-67e686eead8b4750b3015a1f75ea3e76?pvs=21)\\n\\n\uc694\uccad\uc758 \uacb0\uacfc\ub85c \ubc1b\uc544\uc628 \ub9c8\ucee4 \uc815\ubcf4\uc758 \uac1c\uc218\uac00 100\uac1c\ub77c\uace0 \uac00\uc815\ud574\ubcf4\uc790. \uc6b0\ub9ac\ub294 \uc774\uc81c \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud560 \uac83\uc774\ub2e4. \uccab \ubc88\uc9f8 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1\uc744 \uc704\ud574 1\ubc88 ~ 6\ubc88\uc758 \uacfc\uc815\uc744 \uac70\uce5c \ud6c4 7\ubc88 \uacfc\uc815\uc744 \uc218\ud589\ud55c\ub2e4. \uadf8\ub7ec\uba74 \ub9ac\uc561\ud2b8 \uc785\uc7a5\uc5d0\uc11c\ub294 \ub9ac\uc561\ud2b8 \ub8e8\ud2b8\uc758 render \uba54\uc11c\ub4dc \ud638\ucd9c\uc5d0 \ub300\ud55c \ub3d9\uc791\uc744 \uc218\ud589\ud574\uc57c \ud558\uace0, \uc0c8\ub85c\uc6b4 \ub9c8\ucee4 \uc778\uc2a4\ud134\uc2a4\uc5d0 \ub300\ud55c \uc804\uc5ed \uc0c1\ud0dc\ub97c \ubcc0\uacbd\uc2dc\ud0a4\ub294 \ub3d9\uc791\uc744 \uc218\ud589\ud574\uc57c \ud55c\ub2e4. \ub9ac\uc561\ud2b8\uac00 \uc774 \uacfc\uc815\uc744 100\ubc88 \ubc18\ubcf5\ud558\uace0 \ub098\uba74 \uc6b0\ub9ac\ub294 \ube44\ub85c\uc18c \ubaa8\ub4e0 \ub9c8\ucee4\uac00 \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1 \ub41c \ubaa8\uc2b5\uc744 \ubcfc \uc218 \uc788\uc744 \uac83\uc774\ub2e4.\\n\\n\ub098\ub294 \uc774 \ubd80\ubd84\uc5d0\uc11c \uc131\ub2a5 \uc800\ud558\uc758 \uc694\uc18c\uac00 \uc788\ub2e4\uace0 \uc0dd\uac01\ud588\ub2e4. \ub9ac\uc561\ud2b8\uc5d0\uc11c\uc758 \uc0c1\ud0dc \ubcc0\ud654\ub294 \uace7 \ub9ac\uc561\ud2b8 \ub0b4\ubd80\uc758 \ub80c\ub354\ub9c1\uc744 \uc704\ud55c \ub85c\uc9c1\uc774 \uc218\ud589\ub418\uac8c \ud568\uc744 \uc758\ubbf8\ud558\uace0, \uc774 \uacfc\uc815\uc744 \uac1c\uc120 \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4\uc758 \uac1c\uc218\ub9cc\ud07c \ubc18\ubcf5\ud558\uace0 \uc788\uc5c8\ub358 \uac83\uc774\ub2e4. \uc5ec\uae30\uae4c\uc9c0 \uc0dd\uac01\ud574\ubcf4\ub2c8 \uc804\uc5ed \uc0c1\ud0dc \ubcc0\ud654\uc5d0 \ub300\ud574 \ub9ac\uc561\ud2b8\uac00 \ub80c\ub354\ub9c1\uc744 \uc704\ud55c \uc5f0\uc0b0\uc744 \uc9c4\ud589\ud560 \ub3d9\uc548\uc5d0\ub294 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1(render \uba54\uc11c\ub4dc \ud638\ucd9c)\uc774 \uba48\ucd94\ub294 \uac83\uc774 \uc544\ub2d0\uae4c \ud558\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud06c\ub86c \uac1c\ubc1c\uc790 \ub3c4\uad6c\uc758 \ud37c\ud3ec\uba3c\uc2a4 \ud0ed\uc744 \ub4e4\uc5b4\uac00 \ubcf4\ub2c8 \uc0b0\ubc1c\uc801\uc73c\ub85c \ubc1c\uc0dd\ud558\ub358 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc758 \ubb38\uc81c \uc6d0\uc778\uc774 \uc9d0\uc791\ud588\ub358 \uadf8 \uc6d0\uc778\uc784\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/20926d19-79a5-4d49-b733-de1c2b87059c)\\n\\n\ud504\ub808\uc784 \uc774\ubbf8\uc9c0 \ud558\ub2e8\uc744 \ubcf4\uba74 \uc0b0\ubc1c\uc801\uc778 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc774 \uc218\ud589\ub420 \ub54c\ub9c8\ub2e4 \uc218\ubc18\ub418\ub294 \uc5b4\ub5a4 \ud568\uc218 \ud638\ucd9c\uc774 \uc788\uc74c\uc744 \ud655\uc778\ud560 \uc218 \uc788\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/20b8f1e4-eceb-4e18-82f0-8ef6cc5ee8a1)\\n\\n\uc774 \ubd80\ubd84\uc774 \ubb38\uc81c\uc758 \ud568\uc218 \ud638\ucd9c \ubd80\ubd84\uc774\ub2e4. \uc790\uc138\ud788 \uc0b4\ud3b4\ubcf4\uba74 \uc0c1\ub2e8\uc5d0 `performWorkUntilDeadline`\uc774\ub780 \ud568\uc218\uac00 \ud638\ucd9c\ub428\uc744 \ubcfc \uc218 \uc788\ub2e4.\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/d7a91ce6-4907-4c79-948b-d80a205a0697)\\n\\n\uc774 `performWorkUntilDeadline` \ub77c\ub294 \ud568\uc218\ub97c \uc870\uae08 \uc54c\uc544\ubcf4\ub2c8 \ud574\ub2f9 \ud568\uc218\ub294 \uac04\ub2e8\ud788 \ub9d0\ud574 \ub9ac\uc561\ud2b8\uc5d0\uc11c state\uc758 \ubcc0\uacbd\uc774 \ud55c\ubc88\uc5d0 \ub9ce\uc774 \ubc1c\uc0dd\ud560 \ub54c 5ms\uc758 \ub370\ub4dc\ub77c\uc778 \uc2dc\uac04\uc744 \uc904 \ub54c \uc0ac\uc6a9\ud558\ub294 \ud568\uc218\ub77c\ub294 \uac83\uc744 \uc54c\uac8c \ub418\uc5c8\ub2e4. \ubb38\uc81c\uc758 \uc6d0\uc778\uc774\ub77c\uace0 \uc0dd\uac01\ud588\ub358 \ub9c8\ucee4 \uac1c\uc218 \ub9cc\ud07c\uc758 \uc804\uc5ed \uc0c1\ud0dc \ubcc0\ud654\uac00 \uc2e4\uc81c\ub85c \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc744 \uc7a0\uc2dc \uc911\ub2e8\ud558\uac8c \ub9cc\ub4e4\uace0 \uc788\uc74c\uc744 \uc54c\uac8c \ub418\uc5c8\ub2e4.\\n\\n### 3. \ubb38\uc81c \ud574\uacb0\\n\\n\uc55e\uc11c \ubd84\uc11d\ud55c \ubb38\uc81c\ub97c \uac1c\uc120\ud574\ubcf4\uace0\uc790 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc5d0 \ud544\uc694\ud55c \ucda9\uc804\uc18c \uc815\ubcf4 \ubc30\uc5f4\uc744 \ubd80\ubaa8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ubc1b\uc544\uc640 \uac01 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc790\uc2dd \ucef4\ud3ec\ub10c\ud2b8\uc5d0 \ub118\uaca8\uc8fc\uace0, \uc790\uc2dd \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ub9c8\ucee4 \uc0dd\uc131\uacfc \ub80c\ub354\ub9c1 \ub85c\uc9c1\uc744 \uc218\ud589\ud558\ub358 \uae30\uc874\uc758 \ubc29\uc2dd\uc744 \ubd80\uc218\uace0 \ubd80\ubaa8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \ubaa8\ub4e0 \uac83\uc744 \uc77c\uad04 \ucc98\ub9ac\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \uace0\uccd0\ubcf4\uc558\ub2e4.\\n\\n\uace0\uce58\ub294 \uacfc\uc815\uc5d0\uc11c \uae30\uc874 \ubc29\uc2dd\uc5d0\uc11c\ub294 \ub9ac\uc561\ud2b8 \uc0dd\uba85 \uc8fc\uae30\uc5d0 \uc758\uc874\ud558\uc5ec \ud654\uba74\uc5d0 \ubcf4\uc5ec\uc9c0\uc9c0 \uc54a\ub294 \ub9c8\ucee4\ub97c \uc9c0\uc6cc\uc8fc\ub358 \ub85c\uc9c1\uc744 \uc774\uc81c\ub294 \ubaa8\ub450 \uc9c1\uc811 \uad6c\ud604\ud574\uc57c \ud588\ub2e4.\\n\\n\uc774\uc804\uc758 \uc601\uc5ed\uacfc \uacb9\uce58\ub294 \ubd80\ubd84\uc5d0 \uc788\ub294 \ucda9\uc804\uc18c\ub294 \ub2e4\uc2dc \uadf8\ub9ac\uc9c0 \uc54a\uace0, \uc601\uc5ed \ubc16\uc758 \ucda9\uc804\uc18c\ub97c \ub098\ud0c0\ub0b4\ub294 \ub9c8\ucee4\ub294 \uc9c0\uc6cc\uc8fc\uace0, \uc774\uc804\uc758 \uc601\uc5ed\uacfc \uacb9\uce58\uc9c0 \uc54a\ub294 \uc0c8\ub85c \ubc1b\uc544\uc628 \ucda9\uc804\uc18c\ub294 \uadf8\ub9ac\ub3c4\ub85d \ub2e4\uc74c\uacfc \uac19\uc774 \uba54\uc11c\ub4dc\ub97c \ubd84\ub9ac\ud574\ubcf4\uc558\ub2e4.\\n\\n- \uae30\uc874\uacfc \uacb9\uce58\uc9c0 \uc54a\ub294 \uc0c8\ub85c\uc6b4 \uc601\uc5ed\uc5d0 \ub300\ud55c \ub9c8\ucee4\ub97c \uc0dd\uc131\ud558\ub294 \uba54\uc11c\ub4dc\\n- \uae30\uc874\uacfc \uacb9\uccd0\uc9c0\ub294 \uc601\uc5ed\uc5d0 \ub300\ud55c \ub9c8\ucee4\ub4e4\uc744 \ubc18\ud658\ud558\ub294 \uba54\uc11c\ub4dc\\n- \uc0c8\ub85c\uc6b4 \uc601\uc5ed \ubc16\uc5d0 \uc788\ub294 \ub9c8\ucee4\ub4e4\uc744 \uc9c0\uc6cc\uc8fc\ub294 \uba54\uc11c\ub4dc\\n- \uc0c8\ub86d\uac8c \uc0dd\uc131\ub41c \ub9c8\ucee4\ub97c \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1\ud558\ub294 \uba54\uc11c\ub4dc\\n\\n\uc774 \uba54\uc11c\ub4dc\ub4e4\uc744 \ucee4\uc2a4\ud140 \ud6c5\uc73c\ub85c \ubd84\ub9ac\ud574 \ubd80\ubaa8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc774\ub97c \ud65c\uc6a9\ud558\ub3c4\ub85d \ud558\uc5ec \ub2e4\uc18c \ubcf5\uc7a1\ud560 \uc218 \uc788\ub294 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \ub85c\uc9c1\uc744 \uc120\uc5b8\uc801\uc73c\ub85c \uad6c\ud604\ud560 \uc218 \uc788\ub3c4\ub85d \ud588\ub2e4.\\n\\n\uacb0\uacfc\uc801\uc73c\ub85c \uae30\uc874\uc5d0 \uc0ac\uc6a9\ub418\ub358 \uae30\ub2a5\ub4e4\uc744 \uadf8\ub300\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc73c\uba74\uc11c \ud654\uba74\uc5d0 \ub9c8\ucee4\uac00 \uc0b0\ubc1c\uc801\uc73c\ub85c \ub80c\ub354\ub9c1 \ub418\ub358 \ubb38\uc81c\uac00 \ud574\uacb0 \ub418\uc5c8\uace0, \ubd80\uac00\uc801\uc778 \ud6a8\uacfc\ub85c \uc804\uccb4 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1 \uc2dc\uc810\ub3c4 \uc55e\ub2f9\uae38 \uc218 \uc788\uac8c \ub418\uc5c8\ub2e4. + \uae30\uc874\uc5d0\ub294 \uad6c\uc870\uc801\uc778 \ubb38\uc81c\ub85c \uc5f0\uc0b0\ub7c9\uc774 \ub108\ubb34 \ub9ce\uc544 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc774 \ub2a6\uc5b4\uc838 \uc774\ub97c \ub3c4\uc785\ud560 \uc218 \uc5c6\uc5c8\ub358 \ubb38\uc81c\ub97c \uad6c\uc870 \uc218\uc815\uc73c\ub85c \uc778\ud574 \uc801\uc6a9\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\ub2e4.\\n\\n### \uc791\uc5c5\ud55c PR\\n\\nhttps://github.com/woowacourse-teams/2023-car-ffeine/pull/737\\n\\n## \uacb0\uacfc \ubd84\uc11d (performance \ud0ed \ud65c\uc6a9)\\n\\n### before\\n\\n\ub9c8\ucee4 \uc870\ud68c \uc694\uccad\uc774 \uc885\ub8cc\ub41c \uc2dc\uc810: \uc57d `2499ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/033e8519-a1aa-43a4-959d-afeba93c1917)\\n\\n\uccab \ub9c8\ucee4 \ub80c\ub354\ub9c1 \uc2dc\uc810: `3093ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/b4fc47ca-4ef3-43f4-a9a5-7117edabc225)\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \uc885\ub8cc \uc2dc\uc810: \uc57d `3611ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/2b8a4c4c-218b-419a-8a47-e3b768d35bc2)\\n\\n\ucc98\uc74c\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub420 \ub54c\uae4c\uc9c0 \uc18c\uc694\ub41c \uc2dc\uac04: `594ms`\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc5d0 \uc18c\uc694\ub41c \uc2dc\uac04: `1112ms`\\n\\n### after\\n\\n\ub9c8\ucee4 \uc870\ud68c \uc694\uccad\uc758 \uc2dc\uc791\uc810: \uc57d `1875ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/b7b8ff0c-2314-4e3f-a9f4-72c445636283)\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1 \uc885\ub8cc \uc2dc\uc810: `2395ms`\\n\\n![image](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/d75c323e-5c04-42a2-ad3e-1d13ea52216e)\\n\\n\ucc98\uc74c\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub420 \ub54c\uae4c\uc9c0 \uc18c\uc694\ub41c \uc2dc\uac04: `519ms`\\n\\n\ubaa8\ub4e0 \ub9c8\ucee4 \ub80c\ub354\ub9c1\uc5d0 \uc18c\uc694\ub41c \uc2dc\uac04: `519ms`\\n\\n### \uac1c\uc120 \uacb0\uacfc\\n\\n\ucc98\uc74c\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \uc2dc\uc810\uc740 \ub450 \ubc29\uc2dd \ubaa8\ub450 \ube44\uc2b7\ud55c \uacb0\uacfc\ub97c \ubcf4\uc778\ub2e4. \ud558\uc9c0\ub9cc \uac1c\uc120 \ud6c4 \ubc29\uc2dd\uc740 \ud55c\ubc88\uc5d0 \ubaa8\ub4e0 \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \ubc29\uc2dd\uc774\uace0, \uac1c\uc120 \uc774\uc804\uc758 \ubc29\uc2dd\uc740 \uc0b0\ubc1c\uc801\uc73c\ub85c \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \ubc29\uc2dd\uc774\ubbc0\ub85c \uac1c\uc120 \ud6c4\uc758 \ubc29\uc2dd\uc5d0\uc11c \uc804\uccb4 \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud558\ub294 \uc2dc\uc810\uc774 \ud6e8\uc52c \ube68\ub77c\uc9c0\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uacb0\uacfc\uc801\uc73c\ub85c \uc804\uccb4 \ub9c8\ucee4\uac00 \ub80c\ub354\ub9c1 \ub418\ub294 \uc18d\ub3c4 \uc57d `55.6%` \ub2e8\ucd95\ud558\uac8c \ub418\uc5c8\ub2e4. \uc774 \uacb0\uacfc\ub294 \ub9c8\ucee4\uac00 \ub298\uc5b4\ub0a0 \uc218\ub85d \ub354\uc6b1 \ucc28\uc774\uac00 \uadf9\uc801\uc73c\ub85c \ubc8c\uc5b4\uc9c8 \uac83\uc73c\ub85c \uc608\uc0c1\ub41c\ub2e4.\\n\\nbefore\\n\\n![before](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/28520ee3-2fa6-4110-b4e4-8a0bb706324e)\\n\\nafter\\n\\n![after](https://github.com/woowacourse-teams/2023-car-ffeine/assets/77326660/1b1521c6-d220-4140-bbe9-fff40051c6a2)"},{"id":"35","metadata":{"permalink":"/35","source":"@site/blog/2023-09-18-scheduling.mdx","title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4","date":"2023-09-18T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 18\uc77c","tags":[{"label":"java","permalink":"/tags/java"}],"readingTime":8.89,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"35","title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","authors":["boxster"],"tags":["java"]},"prevItem":{"title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","permalink":"/36"},"nextItem":{"title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/34"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4\\n\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc8fc\uae30\uc801\uc73c\ub85c \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\uc640 \uc815\ubcf4\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\uac70\ub098, \ud1b5\uacc4\ub97c \uc800\uc7a5\ud558\ub294 \uc2a4\ucf00\uc904\ub9c1 \uc791\uc5c5\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc9c0\uae08\uc758 \uc800\ud76c \uc11c\ubc84\ub294 \ub2e8\uc77c \uc11c\ubc84\ub85c \uad6c\uc131\ub418\uc5b4\uc788\uc5b4 \ubb38\uc81c\uac00 \uc5c6\uc9c0\ub9cc, \ub9cc\uc57d **\uc11c\ubc84\ub97c scale-out** \ud558\uac8c \ub41c\ub2e4\uba74 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694?\\n\\n**\ub611\uac19\uc740 schedule\uc774 \uc911\ubcf5**\ub418\uc5b4 \uc2e4\ud589\ub420 \uac83\uc785\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uace0 \uc5b4\ub5a4 \uc11c\ubc84\ub294 schedule\uc744 \ub3d9\uc791\ud558\uc9c0 \uc54a\ub3c4\ub85d \ud558\uace0, \uc5b4\ub5a4 \uc11c\ubc84\ub294 schedule\uc744 \ub3d9\uc791\ud558\ub3c4\ub85d \ud55c\ub2e4\uba74 \uc2a4\ucf00\uc904\uc774 \ub3d9\uc791\ud558\ub294 \uc11c\ubc84\uac00 \ub2e4\uc6b4\ub41c\ub2e4\uba74 \ub3d9\uc791\ud558\ub294\\n\uc11c\ubc84\uc758 \ub2e4\uc6b4\ud0c0\uc784\ub9cc\ud07c \uc800\ud76c \uc11c\ubc84\uc758 \ub370\uc774\ud130\ub97c \ucd5c\uc2e0\ud654\ud560 \uc218 \uc5c6\uace0, \ucd5c\uc2e0\ud654\uac00 \uc911\uc694\ud55c \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc0ac\uc6a9\uc790\uc758 \ubd88\ub9cc\uc744 \ucd08\ub798\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uad6c\ud604\ud574\ubcf4\uae30\\n\\nSchedule \uc815\ubcf4\ub97c \uc5b4\ub5bb\uac8c \ub2e4\ub978 \ud658\uacbd\uc5d0\uc11c \uac19\uc774 \uacf5\uc720\ud558\uc5ec \uad00\ub9ac\ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\uac04\ub2e8\ud788 \uc0dd\uac01\ud558\uba74 Local \ud658\uacbd\uc774 \uc544\ub2cc, Global \ud658\uacbd\uc5d0\uc11c \uc815\ubcf4\ub97c \uad00\ub9ac\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c Schedule\uc758 \uc815\ubcf4\ub97c \uc800\uc7a5\ud560 \uc218 \uc788\ub294 \ud14c\uc774\ube14\uc744 \uc544\ub798\uc758 Entity \uc758 \ud544\ub4dc\uc640 \uac19\uc774 \uc0dd\uc131\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Entity\\npublic class ScheduleTask extends BaseEntity {\\n\\n @Id\\n private String id;\\n\\n private String jobName;\\n\\n @Enumerated(EnumType.STRING)\\n private JobStatus status;\\n}\\n```\\n\\n\uba3c\uc800 id\ub294 \ud574\ub2f9 \uc2a4\ucf00\uc904\uc744 \uad6c\ubd84\ud560 \uc218 \uc788\ub294 id\uc5ec\uc57c \ud560 \uac83\uc785\ub2c8\ub2e4. \uac00\uc7a5 \uc27d\uac8c \uc815\ud560 \uc218 \uc788\ub294 id\ub294 \uc2a4\ucf00\uc904\uc758 **job \uc774\ub984**\uacfc,\\nSchedule\uc73c\ub85c \ub4f1\ub85d\ud55c **\uc2dc\uac04**\uc744 \uc870\ud569\ud558\uc5ec \uc0dd\uc131\ud55c\ub2e4\uba74 unique\ud558\uace0 \ubd84\uc0b0 \ud658\uacbd\uc5d0\uc11c\ub3c4 \uc27d\uac8c \uad6c\ubd84\ud560 \uc218 \uc788\ub294 id\uac00 \ub420 \uac83 \uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc544\ub798\uc640 \uac19\uc740 Business Logic \uc788\ub2e4\uace0 \uac00\uc815\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\n@Service\\npublic class BusinessLogic {\\n\\n private final ApplicationEventPublisher applicationEventPublisher;\\n\\n @Scheduled(cron = \\"0/2 * * * * *\\")\\n public void complexJob() {\\n log.info(\\"\ubcf5\uc7a1\ud55c Job \uc2dc\uc791\\");\\n }\\n\\n @Scheduled(cron = \\"0/4 * * * * *\\")\\n public void moreComplexJob() {\\n log.info(\\"\uc880 \ub354 \ubcf5\uc7a1\ud55c Job \uc2dc\uc791\\");\\n try {\\n Thread.sleep(3000);\\n } catch (InterruptedException e) {\\n throw new RuntimeException(e);\\n }\\n }\\n}\\n```\\n\ud558\ub098\ub294 \ub9e4 2\ucd08\ub9c8\ub2e4 \uc2e4\ud589 \ud6c4 \ubc14\ub85c \uc885\ub8cc\ub418\uace0, \ud558\ub098\ub294 \ub9e4 4\ucd08\ub9c8\ub2e4 \uc2e4\ud589 \ud6c4 3\ucd08\uc758 \ub300\uae30\uc640 \uc885\ub8cc\ub418\ub294 \uba54\uc11c\ub4dc\uc785\ub2c8\ub2e4.\\n\uc774\ub7f0 \uc2a4\ucf00\uc904\uc740 \uc5b4\ub5bb\uac8c \ub3d9\uc791\ud560\uae4c\uc694? \uc800\ub294 \ub2f9\uc5f0\ud788 2\ucd08\uc640 4\ucd08\ub9c8\ub2e4 \ud574\ub2f9 \uba54\uc11c\ub4dc\uac00 \uc2e4\ud589\ub420 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ub85c\uadf8\ub97c \uc0b4\ud3b4\ubcf4\uba74 \uc544\ub798\uc640 \uac19\uc740 \uacb0\uacfc\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\n![log](https://github.com/drunkenhw/comments/assets/106640954/5e275085-fce6-43ae-88ca-d3f9c484b6f3)\\n\ubcf5\uc7a1\ud55c job\uc774 2\ubc88 \uc2e4\ud589\ub420 \ub54c, \uc880 \ub354 \ubcf5\uc7a1\ud55c job\uc774 1\ubc88 \uc2e4\ud589\ub418\ub294 \uac78 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc608\uc0c1\ud588\ub358 \uacb0\uacfc\uc785\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc2e4\ud589\ub41c \uc2dc\uac04\uc744 \uc0b4\ud3b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![log-with-time](https://github.com/drunkenhw/comments/assets/106640954/abbe2c65-c26b-46ba-a4e3-fc0f4e5a6612)\\n\\n\ubd84\uba85 \ub9e4 2\ucd08\uc640 4\ucd08\ub9c8\ub2e4 \uc2e4\ud589\ud558\uae30 \ub54c\ubb38\uc5d0 \uc791\uc5c5 \uc2dc\uac04\uc774 2\uc758 \ubc30\uc218\uac00 \ub418\uc5b4\uc57c\ud560\ud150\ub370\\n\\n34, 36, 36, **39**, 40, 40, **43**, 44, 44, **47**\ucd08 \ub85c \uc810\uc810 \uc791\uc5c5\uc774 \ubc00\ub9ac\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc65c \uadf8\ub7f4\uae4c\uc694? \uc2a4\ud504\ub9c1 \uacf5\uc2dd \ubb38\uc11c\uc5d0\uc11c\ub294 \uc544\ub798\uc640 \uac19\uc774 \uc124\uba85\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n> A ThreadPoolTaskScheduler can also be auto-configured if need to be associated to scheduled task execution (using @EnableScheduling for instance). The thread pool uses one thread by default and its settings can be fine-tuned using the spring.task.scheduling namespace, as shown in the following example:\\n\\n\\n[\ucc38\uace0 - \uc2a4\ud504\ub9c1 \uacf5\uc2dd \ubb38\uc11c](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.task-execution-and-scheduling)\\n\\n\uc2a4\ud504\ub9c1\uc758 Schedule\uc740 Default\ub85c \ud558\ub098\uc758 \uc2f1\uae00 \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\uadf8\ub807\uae30 \ub54c\ubb38\uc5d0 \ub9e4\ubc88 \uc791\uc5c5\uc774 \ubc00\ub824 \uc6d0\ud558\ub294 \uc2dc\uac04\uc5d0 \ub3d9\uc791\ud558\uc9c0 \uc54a\ub294 \ud604\uc0c1\uc774 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc Schedule\uc744 \ubd84\uc0b0 \ud658\uacbd\uc5d0\uc11c \uad6c\ubd84\ud558\uae30 \uc704\ud574\uc11c\ub294 job\uc774 \uc2e4\ud589\ub41c \uc2dc\uac04\uc774 \uc911\uc694\ud558\uae30 \ub54c\ubb38\uc5d0 \uc774\ub807\uac8c \uc791\uc5c5\uc774 \ubc00\ub824\ubc84\ub9b0\ub2e4\uba74 \uad6c\ubd84\uc744 \ud560 \uc218 \uc5c6\uac8c \ub429\ub2c8\ub2e4.\\n\ub530\ub77c\uc11c Schedule Thread Pool Size\ub97c \ub298\ub9ac\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Configuration\\npublic class ScheduleConfig implements SchedulingConfigurer {\\n @Override\\n public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {\\n ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();\\n taskScheduler.setPoolSize(10);\\n taskScheduler.setThreadNamePrefix(\\"schedule-task-\\");\\n taskScheduler.initialize();\\n taskRegistrar.setTaskScheduler(taskScheduler);\\n }\\n}\\n```\\nSchedulingConfigurer \ub97c \uad6c\ud604\ud558\uc5ec Thread Pool size\ub97c \uc77c\ub2e8 10\uac1c\ub85c \uc815\uc758\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![success](https://github.com/drunkenhw/comments/assets/106640954/14b225bc-297e-4e7d-b196-23d779f635c0)\\n\uc2a4\ub808\ub4dc \ud480\uc744 \ub298\ub838\ub354\ub2c8 \uc704\uc640 \uac19\uc774 2\uc758 \ubc30\uc218\uc758 \uc2dc\uac04\uc5d0 \uc815\ud655\ud788 \uc791\ub3d9\uc774 \ub418\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ub807\uac8c \uc5ec\ub7ec \uc791\uc5c5\uc744 \ub3d9\uc2dc\uc5d0 \uc2e4\ud589\ub41c\ub2e4\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \ubcd1\ubaa9\ud604\uc0c1\uc774 \ubc1c\uc0dd\ub418\uc5b4 \uc624\ud788\ub824 \uc791\uc5c5\uc774 \ub354 \ub290\ub9ac\uac8c \ub05d\ub0a0 \uc218\ub3c4 \uc788\ub2e4\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud574\ub2f9 \ubd80\ubd84\uc758 \uc2e4\ud589\uc744 \uad00\ub9ac\ud558\ub294 \ud074\ub798\uc2a4\ub97c \uc0dd\uc131\ud558\uc5ec \ud574\ub2f9 \ud074\ub798\uc2a4\uc5d0\uc11c Schedule\uc758 \uc791\uc5c5\uc744 \uad00\ub9ac\ud558\ub3c4\ub85d \uad6c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Service\\npublic class BusinessLogic {\\n\\n private final ApplicationEventPublisher applicationEventPublisher;\\n\\n @Scheduled(cron = \\"0/2 * * * * *\\")\\n public void complexJobSchedule() {\\n applicationEventPublisher.publishEvent(new SchedulingEvent(this::complexJob, \\"complexJob\\", LocalDateTime.now()));\\n }\\n\\n @Scheduled(cron = \\"0/4 * * * * *\\")\\n public void moreComplexJobSchedule() {\\n applicationEventPublisher.publishEvent(new SchedulingEvent(this::moreComplexJob, \\"moreComplexJob\\", LocalDateTime.now()));\\n }\\n}\\n```\\n\ub85c\uc9c1\uc774 \uc788\ub294 BusinessLogic \uc11c\ube44\uc2a4\uc5d0\uc11c \uc2a4\ucf00\uc904\uc758 \uc2dc\uac04\ub9c8\ub2e4 \uc2e4\ud589\ud574\uc57c\ud560 \uba54\uc11c\ub4dc\ub97c Event\ub85c \ubc1c\ud589\ud569\ub2c8\ub2e4.\\n\\n```java\\n@Component\\npublic class ScheduleService {\\n\\n private final ExecutorService executorService = Executors.newFixedThreadPool(1);\\n private final Queue scheduleTasks = new ConcurrentLinkedQueue<>();\\n private final AtomicBoolean isRunning = new AtomicBoolean(false);\\n\\n @EventListener\\n public void addTask(SchedulingEvent schedulingEvent) {\\n scheduleTasks.add(schedulingEvent);\\n }\\n\\n @Scheduled(cron = \\"0/1 * * * * *\\")\\n public void polling() {\\n if (!scheduleTasks.isEmpty() || isRunning.compareAndSet(false, true)) {\\n SchedulingEvent schedulingEvent = scheduleTasks.poll();\\n executorService.execute(() -> execute(schedulingEvent));\\n }\\n }\\n}\\n```\\n\uadf8\ub9ac\uace0 \uc704\uc640 \uac19\uc740 \uc2a4\ucf00\uc904\uc744 \uad00\ub9ac\ud558\ub294 \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 Schedule Event\ub97c \ubc1b\uc544 \uc2e4\ud589\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ud074\ub798\uc2a4\uc5d0\uc11c\ub294 ThreadPool\uc744 \uc0c8\ub85c \uc0dd\uc131\ud558\uc5ec, schedule\uc758 \uc2a4\ub808\ub4dc\uc5d0 \uc601\ud5a5\uc744 \ubc1b\uc9c0 \uc54a\ub3c4\ub85d \uad6c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 1\ucd08\ub9c8\ub2e4 \uc2e4\ud589\ub418\ub294 \uc2a4\ucf00\uc904\uc744 \ub9cc\ub4e4\uc5b4 queue\uc5d0 \uc791\uc5c5\uc774 \uc788\ub294\uc9c0, \ud604\uc7ac \uc791\uc5c5 \uc911\uc778\uc9c0 \ud655\uc778\ud558\uc5ec \uadf8\ub807\uc9c0 \uc54a\ub2e4\uba74 queue\uc5d0\uc11c \uc791\uc5c5\uc744 \uaebc\ub0b4 \uc2e4\ud589\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uac70\uc758 \uad6c\ud604\uc774 \ub05d\ub098\uac11\ub2c8\ub2e4. \uc774\uc81c\ub294 \ud574\ub2f9 Schedule\uc758 \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud558\uace0, \uc791\uc5c5\uc774 \uc2e4\ud328\ud588\uc744 \uc2dc\uc5d0 \ub2e4\uc2dc \uc791\uc5c5\uc744 \ud558\uae30 \uc704\ud55c \uae30\ub2a5\ub9cc \uad6c\ud604\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Component\\npublic class ScheduleService {\\n\\n ...\\n\\n private void execute(SchedulingEvent schedulingEvent) {\\n String jobId = schedulingEvent.jobId();\\n LocalDateTime executionTime = schedulingEvent.executionTime();\\n\\n if (isJobInProgressOrDone(jobId)) {\\n log.info(\\"\uc791\uc5c5\uc774 \uc2e4\ud589\uc911\uc785\ub2c8\ub2e4. {} {}\\", executionTime, jobId);\\n return;\\n }\\n ScheduleTask entity = new ScheduleTask(jobId, executionTime, JobStatus.RUNNING);\\n scheduleTaskJdbcRepository.save(entity);\\n\\n try {\\n schedulingEvent.runnable().run();\\n scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.DONE);\\n } catch (Exception e) {\\n log.error(\\"{} \uc791\uc5c5 \uc2e4\ud589 \uc911 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\", jobId);\\n scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.ERROR);\\n tasks.add(schedulingEvent);\\n }\\n }\\n\\n private boolean isJobInProgressOrDone(String jobId) {\\n Optional taskOptional = scheduleTaskRepository.findById(jobId);\\n if (taskOptional.isPresent()) {\\n ScheduleTask scheduleTask = taskOptional.get();\\n return scheduleTask.getStatus() == JobStatus.RUNNING || scheduleTask.getStatus() == JobStatus.DONE;\\n }\\n return false;\\n }\\n}\\n```\\n\uc774 \ubd80\ubd84\uc740 \uac04\ub2e8\ud558\uac8c \uad6c\ud604\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc704\uc640 \uac19\uc774 \uc791\uc5c5\uc758 \uc2e4\ud589 \uc2dc\uac04\uacfc, job\uc758 \uc774\ub984\uc73c\ub85c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uc870\ud68c\ud558\uace0, \uc5c6\ub2e4\uba74 \uc791\uc5c5\uc744 \uc2e4\ud589\ud558\uace0\\n\uc788\ub2e4\uba74 \uc791\uc5c5\uc774 ERROR \uc778\uc9c0 \ud655\uc778\ud558\uc5ec \uc791\uc5c5\uc744 \uc2e4\ud589\ud574\uc8fc\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![complete](https://github.com/drunkenhw/comments/assets/106640954/3ff855db-ff8e-4aa4-8b47-ed5b2ff6dd64)\\n\\n\uc704\uc640 \uac19\uc774 \ub450 \uac1c\uc758 \uc11c\ubc84\ub97c \ub3d9\uc2dc\uc5d0 \ub744\uc6e0\uc744 \ub54c\uc5d0\ub3c4 \uc2a4\ucf00\uc904\uc774 \uc798 \uc791\ub3d9\ud558\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\uc2a4\ucf00\uc904\uc744 \uc774\ub807\uac8c \uad6c\ud604\ud560 \uc218\ub3c4 \uc788\uc9c0\ub9cc \ud658\uacbd\uc774 \ub41c\ub2e4\uba74 Message Queue\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc5b4\ub5a8\uae4c\uc694?\\n\\n\\n\ud639\uc2dc \ud2c0\ub9b0 \ubd80\ubd84\uc774 \uc788\ub2e4\uba74 \uc9c0\uc801 \ubd80\ud0c1\ub4dc\ub9bd\ub2c8\ub2e4."},{"id":"34","metadata":{"permalink":"/34","source":"@site/blog/2023-09-17-caching.mdx","title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4","date":"2023-09-17T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 17\uc77c","tags":[{"label":"java","permalink":"/tags/java"}],"readingTime":12.495,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"34","title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","authors":["boxster"],"tags":["java"]},"prevItem":{"title":"Scale-out \uc2dc Scheduling \uc911\ubcf5 \uc2e4\ud589 \ub9c9\uae30","permalink":"/35"},"nextItem":{"title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","permalink":"/33"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4\\n\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\uc774\uc804 \uae00\uc5d0\uc11c\ub3c4 \uacc4\uc18d \uc124\uba85\ud588\ub4ef\uc774 \uc870\ud68c \uc131\ub2a5\uc744 \ucd5c\ub300\ud55c \ube60\ub974\uac8c \ud558\ub294 \uac83\uc774 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \ud575\uc2ec\uc774\ub77c\uace0 \uc0dd\uac01\ud558\uae30 \ub54c\ubb38\uc5d0 \uc9c0\uae08\ub3c4 \uc608\uc804\uc5d0 \ube44\ud574 \ube68\ub77c\uc84c\uc9c0\ub9cc \ub2e4\ub978 \uac1c\uc120\uc810\uc774 \ubcf4\uc5ec \uac1c\uc120\uc744 \ud558\uace0\uc790\ud569\ub2c8\ub2e4.\\n\\n[\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30 1 (\uc778\ub371\uc2a4)](https://car-ffeine.github.io/31)\\n\\n[\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30 2 (\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ubcf5\uc81c)](https://car-ffeine.github.io/32)\\n## \uacb0\ub860\\n\uacb0\ub860\ubd80\ud130 \ub9d0\uc500\ub4dc\ub9ac\uba74 \ub85c\uceec\uc5d0\uc11c \uce90\uc2f1\uc744 \uc801\uc6a9\ud55c \ud6c4 100\uba85\uc758 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c\ub97c \uae30\uc900\uc73c\ub85c\\n\\n**TPS** 78 -> 128\\n\\n**Response Time** 1236 ms -> 751 ms\\n\\n\uc57d **64%** \uc131\ub2a5\uc774 \uac1c\uc120 \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n*(\uc800\ubc88 \uc131\ub2a5 \ud14c\uc2a4\ud2b8\uc758 \uacb0\uacfc\uac00 \ub2e4\ub978 \uc774\uc720\ub294 \ube44\uc988\ub2c8\uc2a4 \ub85c\uc9c1\uc774 \ubcc0\uacbd\ub418\uc5b4 \uc870\ud68c \ubc29\uc2dd\uc774 \ubc14\ub00c\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4. \uadf8\ub798\uc11c \uce90\uc2f1\uc744 \uc801\uc6a9\ud558\uae30\uc804, \ud55c \ud6c4 \ub97c \ube44\uad50\ud588\uc2b5\ub2c8\ub2e4.)*\\n\\n## Caching\\n\\n>In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.\\n\\n\uce90\uc2f1\uc740 \uc704\ud0a4 \ubc31\uacfc\uc5d0\uc11c \uc704\uc640 \uac19\uc774 \uc124\uba85\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc989 \uba54\ubaa8\ub9ac\uc5d0 \ub370\uc774\ud130\ub97c \ubcf5\uc0ac\ubcf8\uc744 \uc62c\ub824 \uc880 \ub354 \ube60\ub974\uac8c \ub370\uc774\ud130\uc5d0 \uc811\uadfc\ud558\ub294 \ubc29\uc2dd\uc785\ub2c8\ub2e4.\\n\\n\uce90\uc2f1\uc758 \ub2e8\uc810\uc740 \uc218\uc815, \uc0bd\uc785, \uc0ad\uc81c\uac00 \ub418\uc5c8\uc744 \ub54c, \uad00\ub9ac \ud3ec\uc778\ud2b8\uac00 \ub450 \uad70\ub370\uac00 \ub41c\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4. \ub9cc\uc57d \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\ub9cc \uc0c8\ub85c\uc6b4 \uc815\ubcf4\ub97c \uc800\uc7a5\ud558\uace0, \uce90\uc2dc\uc5d0\ub294 \uc800\uc7a5\ud574\uc8fc\uc9c0 \uc54a\ub294\ub2e4\uba74 \uc0ac\uc6a9\uc790\ub294 \uadf8 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \uc801\uc6a9\ud55c \uc774\uc720\ub294 \ucda9\uc804\uae30\uc758 \ucda9\uc804 \uc0c1\ud0dc (\ucda9\uc804 \uc911, \ub300\uae30\uc911, \uace0\uc7a5)\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 \ucd5c\uc2e0\ud654\uac00 \ub418\uc5b4\uc57c\ud558\uc9c0\ub9cc, \ucda9\uc804\uc18c\uc758 \uc774\ub984\uc774\ub77c\ub358\uc9c0, \uc704\uce58, \ub2e4\ub978 \uc815\ubcf4\ub4e4\uc740 \uc27d\uac8c \ubcc0\ud558\uc9c0 \uc54a\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \uc815\ubcf4\ub97c \uce90\uc2f1\ud55c\ub2e4\uba74 \uc88b\uc744 \uac83 \uac19\uc558\uc2b5\ub2c8\ub2e4.\\n\\n## \uce90\uc2f1 \uc801\uc6a9\ud558\uae30\\n\\n\uba3c\uc800 \uce90\uc2f1\uc744 \uc5b4\ub514\uc5d0\uc11c \ud558\ub294\uc9c0\ub3c4 \uc911\uc694\ud569\ub2c8\ub2e4. \ud06c\uac8c **\ub85c\uceec \uce90\uc2dc**\uc640 **\uae00\ub85c\ubc8c \uce90\uc2dc**\ub85c \ub098\ub20c \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\uae00\ub85c\ubc8c \uce90\uc2dc\uc758 \uc7a5\uc810\uc740 \uc2a4\ucf00\uc77c \uc544\uc6c3\uc744 \ud588\uc744 \ub54c, \ubaa8\ub4e0 \uc11c\ubc84\uac00 \ub2e4 \uac19\uc740 \ub370\uc774\ud130\ub97c \ubc14\ub77c\ubcf4\uae30 \ub54c\ubb38\uc5d0 \ub370\uc774\ud130 \uc815\ud569\uc131\uc774 \uc88b\uc544\uc9d1\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \ub2e8\uc77c \uc11c\ubc84\ub85c \uad6c\uc131\ub418\uc5b4 \uc788\uae30 \ub54c\ubb38\uc5d0, \ub85c\uceec \uce90\uc2dc\ub97c \ud574\ub3c4 \ubb38\uc81c\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uae00\ub85c\ubc8c \uce90\uc2dc\ub97c \uc801\uc6a9\ud558\uae30 \uc704\ud574\uc11c\ub294 Redis\ub098 Memcached \uac19\uc740 \ub3c4\uad6c\ub97c \ubaa8\ub4e0 \ud300\uc6d0\uc774 \uc54c\uc544\uc57c\ud558\uc9c0\ub9cc \ub85c\uceec \uce90\uc2dc\ub294 \uadf8\ub807\uac8c \ud558\uc9c0 \uc54a\ub354\ub77c\ub3c4 \ud3b8\ud558\uac8c \uc801\uc6a9\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc5d0\uc11c \ub85c\uceec\uc5d0 \uce90\uc2f1\ud558\ub294 \ubc29\ubc95\uc744 \uc801\uc6a9\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### \uce90\uc2f1\ud560 \uc815\ubcf4 \uac00\uc838\uc624\uae30\\n\\n\uce90\uc2f1\uc744 \ud558\uae30 \uc704\ud574\uc11c\ub294 \uba3c\uc800 \uce90\uc2f1\ud560 \ub370\uc774\ud130\ub97c \uac00\uc838\uc640\uc57c\ud569\ub2c8\ub2e4. \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \ucd9c\uc7a5 \ud639\uc740 \uc5ec\ud589\uc744 \uac00\ub294 \uc804\uae30\ucc28 \uc624\ub108\uac00 \ud575\uc2ec \ud398\ub974\uc18c\ub098\uc774\uae30 \ub54c\ubb38\uc5d0 \uc0ac\uc6a9\uc790\ub4e4\uc774 \ucc3e\ub294 \uc815\ubcf4\uc758 \uc704\uce58\ub294 \ubd88\ud2b9\uc815\ud569\ub2c8\ub2e4. \uc11c\uc6b8\uc5d0\uc11c \ub2e4\ub978 \uc9c0\ubc29\uc73c\ub85c \ucd9c\uc7a5\uc744 \uac00\ub294 \uacbd\uc6b0\ub3c4 \uc788\uc744 \uac83\uc774\uace0, \uc9c0\ubc29\uc5d0\uc11c \uc11c\uc6b8\uc5d0 \uac00\ub294 \uacbd\uc6b0\ub3c4 \uc788\uae30 \ub54c\ubb38\uc5d0, \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c \uce90\uc2f1\ud574\uc57c\ud560 \uac83\uc774\ub77c \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \uc2e4\ud589 \uc2dc\uc5d0 \ubaa8\ub4e0 \ucda9\uc804\uc18c\ub97c \uce90\uc2f1\ud558\uae30\ub85c \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Configuration\\npublic class InitialStationCache implements ApplicationRunner {\\n\\n private final StationCacheRepository stationCacheRepository;\\n private final StationQueryRepository stationQueryRepository;\\n\\n @Override\\n public void run(ApplicationArguments args) {\\n log.info(\\"Initialize station cache\\");\\n List stations = stationQueryRepository.findAll();\\n stationCacheRepository.initialize(stations);\\n log.info(\\"Station cache initialized\\");\\n log.info(\\"Station cache size: {}\\", stations.size());\\n }\\n}\\n\\n```\\n\\n\uc704\uc640 \uac19\uc774 ApplicationRunner\ub97c \uad6c\ud604\ud558\uc5ec \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \uc2e4\ud589 \uc2dc \ubaa8\ub4e0 \ucda9\uc804\uc18c\uc758 \uc815\ubcf4\ub97c \uac00\uc838\uc624\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc5ec\uae30\uc11c Entity\uc778 Station\uc744 \uac00\uc838\uc624\uc9c0 \uc54a\uc740 \uc774\uc720\ub294 \ud06c\uac8c \ub450\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n1. \uc9c0\ub3c4\ub85c \uc870\ud68c\ud558\ub294 \ubd80\ubd84\uc758 \uc131\ub2a5\uc744 \uac1c\uc120\ud558\uace0\uc790 \ud588\uc9c0\ub9cc, Entity\uc5d0\ub294 \uc9c0\ub3c4\ub97c \uc870\ud68c\ud560 \ub54c \ubd88\ud544\uc694\ud55c \uc815\ubcf4\ub3c4 \uc788\uae30 \ub54c\ubb38\uc5d0 \uba54\ubaa8\ub9ac\uc0c1\uc758 \ub0ad\ube44\uac00 \uc0dd\uae38 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n2. Entity\ub97c \uce90\uc2f1\ud558\uac8c \ub41c\ub2e4\uba74 hibernate 1\ucc28 \uce90\uc2dc\uc5d0\ub3c4 \uc801\uc7ac\ub418\uace0, \ud799 \uba54\ubaa8\ub9ac\uc5d0\ub3c4 \uc801\uc7ac\ub418\ub294 \uc77c\uc774 \ubc1c\uc0dd\ud558\uc5ec \uba54\ubaa8\ub9ac\uc0c1 \ub0ad\ube44\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \ubc94\uc704 \uac80\uc0c9\ud558\uae30\\n\\n\ucda9\uc804\uc18c\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud558\ub294 \uc870\uac74\uc740 \uc704\ub3c4, \uacbd\ub3c4\uc758 \ucd5c\uc18c, \ucd5c\ub300\uac12\uc744 \uae30\uc900\uc73c\ub85c \ub9cc\uc871\ud558\ub294 \ub370\uc774\ud130\ub97c \ubcf4\uc5ec\uc90d\ub2c8\ub2e4.\\n\uc544\ub798\uc640 \uac19\uc774 \uac04\ub2e8\ud788 \uc870\uac74\uc744 stream()\uc758 filter()\ub97c \uc0ac\uc6a9\ud574\uc11c \uad6c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n```java\\npublic class StationCacheRepository {\\n\\n private final List cachedStations;\\n\\n public List findByCoordinate(\\n BigDecimal minLatitude,\\n BigDecimal maxLatitude,\\n BigDecimal minLongitude,\\n BigDecimal maxLongitude\\n ) {\\n return cachedStations.stream()\\n .filter(it -> it.latitude().compareTo(minLatitude) >= 0 && it.latitude().compareTo(maxLatitude) <= 0)\\n .filter(it -> it.longitude().compareTo(minLongitude) >= 0 && it.longitude().compareTo(maxLongitude) <= 0)\\n .toList();\\n }\\n}\\n```\\n\ud558\uc9c0\ub9cc \ud574\ub2f9 \ubc29\ubc95\uc73c\ub85c \ub85c\uceec\uc5d0\uc11c \uc870\ud68c\ub97c \ud14c\uc2a4\ud2b8 \ud588\uc744 \ub54c \uce90\uc2dc\ub97c \uc801\uc6a9\ud55c \uac83\ubcf4\ub2e4 \ub354 \ub290\ub824\uc9c4 \uacb0\uacfc\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\uce90\uc2f1\uc744 \ud574\uc11c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uae4c\uc9c0 \uc694\uccad\uc744 \ubcf4\ub0b4\uc9c0 \uc54a\ub294\ub370 \uc65c \ub354 \ub290\ub824\uc9c4 \uac83\uc77c\uae4c\uc694?\\n\\n\ub2f5\uc740 **\uc778\ub371\uc2a4** \uc600\uc2b5\ub2c8\ub2e4. Mysql \uc5d0\uc11c \uc778\ub371\uc2a4\ub294 B Tree\ub85c \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c\ub294 \uc704\ub3c4, \uacbd\ub3c4\ub85c \ubcf5\ud569 \uc778\ub371\uc2a4\uac00 \uc124\uc815\ub418\uc5b4 \uc788\uc5c8\uc9c0\ub9cc, \ud604\uc7ac \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \ub85c\uc9c1\uc5d0\ub294 \ud574\ub2f9 \ubd80\ubd84\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c filter\ub85c \uc21c\ud68c\ud558\ub294 \uc2dc\uac04\ubcf5\uc7a1\ub3c4\uac00 O(n)\uc774\uace0, \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c\ub294 O(log n)\uc774\uae30 \ub54c\ubb38\uc5d0 \ub354 \ub290\ub824\uc9c4 \uac83\uc785\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uace0 \uc81c\uac00 \uc9c1\uc811 B tree \uc790\ub8cc\uad6c\uc870\ub97c \uc9c1\uc811 \uad6c\ud604\ud574\uc57c\ud560\uae4c\uc694?\\n\\n\ud604\uc7ac \ud574\ub2f9 \uc870\ud68c API\ub294 \uc704\ub3c4 \uacbd\ub3c4\ub85c \ubc94\uc704 \ud0d0\uc0c9\uc744 \ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uacb0\uad6d\uc5d4 station\uc758 \uc815\ubcf4\ub4e4\uc774 \uc704\ub3c4, \uacbd\ub3c4\ub85c \uc815\ub82c\ub9cc \ub418\uc5b4 \uc788\ub2e4\uba74 B tree\ub97c \uc9c1\uc811 \uad6c\ud604\ud558\uc9c0 \uc54a\ub354\ub77c\ub3c4 \uac19\uc740 \uc2dc\uac04\ubcf5\uc7a1\ub3c4 O(log n)\uc73c\ub85c \ud0d0\uc0c9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 B tree\uc640 \ub2e4\ub978 \ubd80\ubd84\uc740 \ud574\ub2f9 \ucda9\uc804\uc18c\uc758 \uc815\ud655\ud55c \uc704\ub3c4, \uacbd\ub3c4\ub85c \ub2e8\uc77c \uce7c\ub7fc\uc744 \uc870\ud68c\ud560 \ub54c\ub294 O(n)\uc774\uae30 \ub54c\ubb38\uc5d0 \uc774\ub7f0 \ubc29\ubc95\uc774 \ubb38\uc81c\uac00 \ub420 \uc218 \uc788\uc9c0\ub9cc, \ud574\ub2f9 \uce90\uc2dc \ub370\uc774\ud130\ub85c\ub294 \ubb34\uc870\uac74 \ubc94\uc704 \ud0d0\uc0c9\uc744 \ud558\uae30 \ub54c\ubb38\uc5d0, B tree\ub97c \uad6c\ud604\ud558\uc9c0 \uc54a\uace0 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \ubcc0\uacbd\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n public void initialize(List stations) {\\n cachedStations.addAll(stations);\\n cachedStations.sort((o1, o2) -> {\\n int latitudeCompare = o1.latitude().compareTo(o2.latitude());\\n if (latitudeCompare == 0) {\\n return o1.longitude().compareTo(o2.longitude());\\n }\\n return latitudeCompare;\\n });\\n }\\n\\n private List findStations(BigDecimal minLatitude, BigDecimal maxLatitude, BigDecimal minLongitude, BigDecimal maxLongitude) {\\n int lowerBound = binarySearch(minLatitude, START_INDEX);\\n int upperBound = binarySearch(maxLatitude, lowerBound);\\n if (lowerBound == -1 || upperBound == -1) {\\n return Collections.emptyList();\\n }\\n return cachedStations.stream()\\n .skip(lowerBound)\\n .limit(upperBound - lowerBound)\\n .filter(station -> station.longitude().compareTo(minLongitude) >= 0 && station.longitude().compareTo(maxLongitude) <= 0)\\n .toList();\\n }\\n\\n private int binarySearch(BigDecimal latitude, int startIndex) {\\n int left = startIndex;\\n int right = cachedStations.size() - 1;\\n int result = -1;\\n while (left <= right) {\\n int middle = left + (right - left) / 2;\\n StationInfo middleStation = cachedStations.get(middle);\\n if (middleStation.latitude().compareTo(latitude) >= 0) {\\n result = middle;\\n right = middle - 1;\\n } else {\\n left = middle + 1;\\n }\\n }\\n return result;\\n }\\n\\n```\\n\\n\uba3c\uc800 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc774 \uc2e4\ud589\ub420 \ub54c cache \ub370\uc774\ud130\ub97c \ucc3e\uc544 \uc800\uc7a5\ud558\ub294 \uac83 \ubfd0\ub9cc \uc544\ub2c8\ub77c, \uc704\ub3c4(Latitude)\ub97c \uae30\uc900\uc73c\ub85c \uc815\ub82c\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc704\ub3c4\uc758 \ucd5c\uc18c, \ucd5c\ub300\uac12\uc758 \uc778\ub371\uc2a4\ub97c \uac00\uc7a5 \ud6a8\uc728\uc801\uc73c\ub85c \ucc3e\uc544\uc62c \uc218 \uc788\ub3c4\ub85d binary search\ub97c \ud558\ub294 \uba54\uc11c\ub4dc\ub97c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub807\uac8c \ud55c\ub2e4\uba74 O(log n) \uc73c\ub85c \uc704\ub3c4\uc758 \ucd5c\ub300 \ucd5c\uc18c \uc870\uac74\uc5d0 \ud3ec\ud568\ub418\ub294 \ubaa8\ub4e0 station\uc758 \uac12\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc870\ud68c\ud55c \ub370\uc774\ud130\ub4e4\uc758 \uac1c\uc218\ub9cc\ud07c filter\ub97c \ud1b5\ud574 \uacbd\ub3c4(longitude) \uac00 \ud3ec\ud568\ub418\ub294\uc9c0 \ud655\uc778\ud569\ub2c8\ub2e4. \ud574\ub2f9 \ubc29\uc2dd\uc758 \uad6c\ud604\uc740 B tree\uac00 \uc791\ub3d9\ud558\ub294 \ubc29\uc2dd\uacfc \uc720\uc0ac\ud560 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc774\ubd84 \ud0d0\uc0c9\uc744 \uc801\uc6a9\ud55c \uacb0\uacfc \ub85c\uceec\uc5d0\uc11c \uc751\ub2f5 \uc18d\ub3c4\uac00 120 ms -> 50 ~ 70 ms\ub85c \uc57d 2\ubc30 \ube68\ub77c\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc2e4\uc2dc\uac04\uc774 \uc911\uc694\ud55c \ub370\uc774\ud130\ub294?\\n\\n\uc55e\uc11c \ub9d0\uc500\ub4dc\ub838\ub2e4\uc2dc\ud53c \uc9c0\ub3c4\ub85c \ucda9\uc804\uc18c\ub97c \uc870\ud68c\ud560 \ub54c, \ucda9\uc804\uc18c\uc758 \uc815\ubcf4\ub4e4\uc5d0\ub294 \ubc14\ub00c\uc9c0 \uc54a\ub294 \uc815\ubcf4\ubfd0\ub9cc \uc544\ub2c8\ub77c, \ucd5c\uc2e0\ud654\ud574\uc57c\ud558\ub294 \ucda9\uc804\uae30\uc758 \ud604\uc7ac \uc0c1\ud0dc \uc815\ubcf4\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub7ec\ud55c \uc815\ubcf4\ub4e4\uc740 \uce90\uc2f1\ud574\ub458 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ud558\ub354\ub77c\ub3c4, \uad00\ub9ac \ud3ec\uc778\ud2b8\uac00 \ub298\uc5b4\ub098\uae30 \ub54c\ubb38\uc5d0 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uce90\uc2f1\ud574\ub454 \ucda9\uc804\uae30 id\ub85c \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub97c \ucc3e\uc544\uc640\uc11c \uc815\ubcf4\ub97c \ud569\uccd0 \ubc18\ud658\ud558\ub294 \uc2dd\uc73c\ub85c \ub9cc\ub4e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n```sql\\n select cs.station_id,\\n sum(case\\n when cs.charger_condition = \'STANDBY\' then 1\\n else 0\\n end)\\n from charger_status cs\\n where cs.station_id in (?, ?, ?, ?, ?, ?, ?)\\n group by cs.station_id\\n```\\n\uc704\uc640 \uac19\uc740 \ucffc\ub9ac\ub85c \ud574\ub2f9 \ucda9\uc804\uc18c\uc758 \ucd5c\uc2e0\ud654\ub41c \ucda9\uc804\uae30 \uc0c1\ud0dc\ub97c \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uce90\uc2f1\uc744 \ud558\uae30\uc804\uc5d0 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \uc774\uc6a9\ud574 \ub370\uc774\ud130\ub97c \uac00\uc838\uc62c \ub54c\uc758 \ucffc\ub9ac\ub294 \uc544\ub798\uc640 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```sql\\n select\\n distinct s.station_id\\n from\\n charge_station s\\n inner join\\n charger c\\n on (\\n c.station_id=s.station_id\\n )\\n where\\n s.latitude>=?\\n and s.latitude<=?\\n and s.longitude>=?\\n and s.longitude<=?\\n -------------------------------------------------\\n select\\n s.station_id,\\n s.station_name,\\n s.latitude,\\n s.longitude,\\n s.is_parking_free,\\n s.is_private,\\n sum(case\\n when cs.charger_condition=\'STANDBY\' then 1\\n else 0\\n end),\\n sum(case\\n when c.capacity>=50 then 1\\n else 0\\n end)\\n from\\n charge_station s\\n inner join\\n charger c\\n on (\\n c.station_id=s.station_id\\n )\\n inner join\\n charger_status cs\\n on (\\n c.station_id=cs.station_id\\n and c.charger_id=cs.charger_id\\n )\\n where\\n s.station_id in (\\n ?,?,?,?\\n )\\n group by\\n s.station_id\\n```\\n\uc6d0\ub798\ub294 \uc704\uc640 \uac19\uc774 \uc5ec\ub7ec\ubc88\uc758 Join\uc744 \ud558\uace0, 2\ubc88\uc758 \ucffc\ub9ac\uac00 \ub098\uac14\ub358 \ubc18\uba74 \uc9c0\uae08\uc740 join\uc744 \ud558\uc9c0\uc54a\ub294 \ud55c\ubc88\uc758 \uae54\ub054\ud55c \ucffc\ub9ac\ub85c \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 **station \ud14c\uc774\ube14\uc758 \uc704\ub3c4, \uacbd\ub3c4\ub85c \ubc94\uc704 \ud0d0\uc0c9\uc744 \uc704\ud574 \uc0dd\uc131\ud588\ub358 index\ub3c4 \uc81c\uac70**\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4!\\n\\n## \uacb0\ub860\\n1. \uce90\uc2f1\ud560 \uc218 \uc788\ub294 \ubd80\ubd84\uc740 \ud558\ub294 \uac83\ub3c4 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4\\n2. \uc2dc\uac04 \ubcf5\uc7a1\ub3c4\ub97c \uacc4\uc0b0\ud574\ubd05\uc2dc\ub2e4.\\n3. \uc131\ub2a5 \uac1c\uc120 \uc7ac\ubc0c\uc2b5\ub2c8\ub2e4."},{"id":"33","metadata":{"permalink":"/33","source":"@site/blog/2023-09-11-congestion_speed_up.mdx","title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","description":"\uc548\ub155\ud558\uc138\uc694.","date":"2023-09-11T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 11\uc77c","tags":[{"label":"mysql","permalink":"/tags/mysql"}],"readingTime":6.19,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"33","title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","authors":["jay"],"tags":["mysql"]},"prevItem":{"title":"\uce90\uc2dc\uc640 \uc774\ubd84 \ud0d0\uc0c9\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/34"},"nextItem":{"title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/32"}},"content":"\uc548\ub155\ud558\uc138\uc694.\\n\uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \ucda9\uc804\uc18c\uc758 \uc694\uc77c\uacfc \uc2dc\uac04\ub300 \ubcc4\ub85c \ucda9\uc804\uc18c \ud63c\uc7a1\ub3c4 \uc815\ubcf4\ub97c \uc81c\uacf5\uc744 \ucc28\ubcc4\uc801\uc778 \uae30\ub2a5\uc73c\ub85c \uc81c\uacf5\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \uad6c\ud604\ud558\uae30 \uc704\ud574\uc11c \uacf5\uacf5 \ub370\uc774\ud130\uc5d0\uc11c \uc815\ubcf4\ub97c \uc218\uc9d1\ud558\uace0\uc788\uc2b5\ub2c8\ub2e4.\\n\ud63c\uc7a1\ub3c4\ub97c \uc870\ud68c\ud558\uae30 \uc704\ud574\uc11c\ub294 \uc57d 23\ub9cc \uac74\uc758 \ucda9\uc804\uc18c * 7\uc77c * 24\uc2dc\uac04 = \uc57d 4000\ub9cc \uac74\uc758 \ub370\uc774\ud130 \uc911\uc5d0\uc11c \uc870\ud68c\ub97c \ud558\ub294 \ud615\uc2dd\uc73c\ub85c \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub108\ubb34 \ub9ce\uc740 \ub370\uc774\ud130\uac00 \uc788\ub2e4\ubcf4\ub2c8 \uc870\ud68c \uc18d\ub3c4\uac00 \ub9ce\uc774 \ub290\ub9b0\ub370\uc694.\\n\uc624\ub298\uc740 \uc774\ub97c \uc5b4\ub5bb\uac8c \uac1c\uc120\ud588\ub294\uc9c0 \uc791\uc131\ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\ucc38\uace0\ub85c \ud574\ub2f9 \uae00\uc758 \uc131\ub2a5 \uce21\uc815\uc5d0 \uc774\uc6a9\ud55c \ub370\uc774\ud130\uc758 \uc218\ub294 \uc57d 20\ub9cc \uac74\uc785\ub2c8\ub2e4.\\n\\n---\\n\\n## \ubb38\uc81c \ud655\uc778\\n\uae30\uc874\uc758 \uc800\ud76c\ub294 \ub9ce\uc740 \uc591\uc758 \ub370\uc774\ud130\ub97c \uac10\ub2f9\ud558\uae30 \ud798\ub4e4\uc5b4\uc11c [\uc624\uc804, \uc624\ud6c4] \uc774\ub807\uac8c \ub450 \ubd80\ubd84\uc73c\ub85c \ub098\ub220\uc11c \ud63c\uc7a1\ub3c4\ub97c \uc870\ud68c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc2e4\uc81c \ubc30\ud3ec\ub97c \ud558\uae30 \uc704\ud574\uc11c \ub354\uc774\uc0c1\uc740 \uc624\uc804 \uc624\ud6c4\ub85c \ub098\ub20c \uc218\uac00 \uc5c6\uc5c8\ub294\ub370\uc694.\\n\\n\uc815\uc0c1\uc801\uc778 \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud558\uae30 \uc704\ud574\uc11c \uba3c\uc800 24\uc2dc\uac04 \uae30\uc900\uc73c\ub85c \ud63c\uc7a1\ub3c4\ub97c \uac31\uc2e0\ud558\ub3c4\ub85d \ub85c\uc9c1\ubd80\ud130 \ubc14\uafb8\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc704\uc640 \uac19\uc774 \ucf54\ub4dc\ub97c \ubc14\uafb8\ub2c8 \ubc14\ub85c \uc131\ub2a5\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uacbc\uc2b5\ub2c8\ub2e4.\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMTA4/MDAxNjk0NDIwNjEzOTU3.Q1_sK5nRvBVbJ9w4bdYkofc0zX00TQmJUQPIqRQiofwg.FRujZOroDjWC4znh0pueWi84EAh9-LVKk17z2ojLi1Ig.PNG.sosow0212/image.png?type=w773)\\n\\n\uc704\uc758 \uc0ac\uc9c4\uacfc \uac19\uc774 slow-query\ub97c \ubd84\uc11d\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4.\\n\ud63c\uc7a1\ub3c4 \uc5c5\ub370\uc774\ud2b8\uc5d0\ub3c4 \uc2dc\uac04\uc774 \uac78\ub9ac\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc9c0\ub9cc, \uc870\ud68c \uc2dc\uac04\uc740 \ucd5c\uc545\uc758 \uacbd\uc6b0 \uc57d 12\ubd84 \uc815\ub3c4\ub85c \uc0ac\uc6a9\uc790\ub4e4\uc774 \ubcfc \uc218\ub3c4 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud55c \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\uba3c\uc800 \uac00\uc7a5 \ud070 \ubb38\uc81c\ub294 \ub370\uc774\ud130\uac00 \ub9ce\uae30 \ub54c\ubb38\uc774\uace0, \ub450 \ubc88\uc9f8\ub85c\ub294 \ube44\ud6a8\uc728\uc801\uc778 API\ub85c \uc778\ud55c \ubb38\uc81c\uc785\ub2c8\ub2e4.\\n\\n\ud604\uc7ac \ud63c\uc7a1\ub3c4 \uc870\ud68c\uc2dc 0~23\uc2dc\uae4c\uc9c0 \ubaa8\ub4e0 \uc694\uc77c\uc758 \uae09\uc18d\uacfc \uc644\uc18d \ucda9\uc804\uae30\uc5d0 \ub300\ud55c \ud63c\uc7a1\ub3c4\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n\uad73\uc774 \uc774\ub7f4 \ud544\uc694 \uc5c6\uc774 \uc120\ud0dd\ud55c \uc694\uc77c\uc5d0\ub9cc \ud63c\uc7a1\ub3c4\ub97c \uac00\uc838\uc628\ub2e4\uba74 \ubd88\ud544\uc694\ud55c \uc870\ud68c\ub294 \uc5c6\uc744 \uac70\ub77c\uace0 \uc0dd\uac01\ud574\uc11c \uc77c\ubd80\ubd84 \ub9ac\ud329\ud1a0\ub9c1\uc744 \ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ucd94\uac00\uc801\uc73c\ub85c \ubc15\uc2a4\ud130\uac00 DB Replication\uc744 \uc801\uc6a9\ud574\uc11c, Update\ub85c \uc778\ud55c \uc18d\ub3c4 \uc800\ud558 \ud604\uc0c1\ub3c4 \ub9ce\uc774 \uc904\uc5b4\ub4e4 \uac83\uc744 \uae30\ub300\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \ubb38\uc81c \ud574\uacb0 \uacfc\uc815\\n\\n- \uba3c\uc800 \uae30\uc874 \ucf54\ub4dc\ub85c \uc870\ud68c\uc2dc\uc5d0 \uc18d\ub3c4\uac00 \uc5bc\ub9c8\ub098 \ub098\uc624\ub294\uc9c0 \ud655\uc778\uc744 \ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMTgz/MDAxNjk0NDIwODU1MDg0.d2ig3CCgdHDwkz_7d4qyhVKM0PQ4MV8BcUwm9LjqAcAg.LdVGDSqRuArzM32ZD1tHbxsD2xG5pt8xrOrDwhR25wcg.PNG.sosow0212/image.png)\\n\uae30\uc874\uc758 \ubaa8\ub4e0 \ud63c\uc7a1\ub3c4\ub97c \ub4e4\uace0\uc624\ub294 \uacbd\uc6b0 \uc704\uc640 \uac19\uc774 536ms\uc758 \uc2dc\uac04\uc774 \uc18c\ubaa8\ub418\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMjA5/MDAxNjk0NDIwODk3NDE4.N3tGXL52LYr5Koc1Lwk0Tfhe3Apkao9BEI8waHIkwNgg.AUEcIoBUg8AtXMiZCc2P13Vb_DCeWnsoXH2-6acaClIg.PNG.sosow0212/image.png)\\n\uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 `day_of_week` \uc989 \ud63c\uc7a1\ub3c4\ub97c \ud655\uc778\ud558\uace0 \uc2f6\uc740 \uc694\uc77c\uc744 \ucd94\uac00\uc801\uc73c\ub85c \uc870\uac74\uc5d0 \uba85\uc2dc\ud574\uc8fc\ub2c8\\n148ms\ub85c \uc904\uc740 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n148ms\ub294 \uc544\uc9c1 \ud55c\ucc38 \ub290\ub9bd\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c `DB Partitioning`\uc744 \uc801\uc6a9\ud588\uc2b5\ub2c8\ub2e4.\\n\\nDB Partitioning\uc5d0 \ub300\ud574 \uac04\ub2e8\ud558\uac8c \uc124\uba85\ud558\uc790\uba74 \ud070 \ud14c\uc774\ube14\uc744 \uc791\uc740 \ub2e8\uc704\ub85c \uad00\ub9ac\ud558\ub294 \uae30\ubc95\uc785\ub2c8\ub2e4.\\n\ud558\ub098\uc758 \ud14c\uc774\ube14\ub85c \ubcf4\uc774\uc9c0\ub9cc \uc774\ub97c \uc801\uc6a9\ud558\uba74 \uc2e4\uc81c\ub85c \uc5ec\ub7ec \uac1c\uc758 \ud14c\uc774\ube14\ub85c \ubd84\ub9ac\ud574\uc11c \uad00\ub9ac\ud558\ub294 \uae30\ubc95\uc774\uace0, \uc774\ub97c \ud1b5\ud574\uc11c \uc870\ud68c \ubc0f \uc5c5\ub370\uc774\ud2b8 \ucffc\ub9ac \uc131\ub2a5\uc774 \uac1c\uc120\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc740 List partitioning\uc744 \uc801\uc6a9\ud574\uc11c `day_of_week(\uc694\uc77c)`\uc744 \uae30\uc900\uc73c\ub85c \ud30c\ud2f0\uc154\ub2dd\uc744 \ud588\uc2b5\ub2c8\ub2e4.\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMTE0/MDAxNjk0NDIxMzg1NTMx.Q4VBItbFdityCKdRFYqpC1qVtoi81RRqcmysYMh-9xog.d8MIYW-tatGXoaxCJ-o6vS5wydEk1yQVQTtmmZvooFIg.PNG.sosow0212/image.png)\\n\uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 day_of_week\ub97c \uae30\uc900\uc73c\ub85c \ud30c\ud2f0\uc154\ub2dd\uc744 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://blogfiles.pstatic.net/MjAyMzA5MTFfMjA5/MDAxNjk0NDIxNDM3MTI2.QXclZKmnwVTcYrkR95yPJV3vxCCzcaisaWj29WGxFucg.CO0SafuQLRmWPzAs9-9ForUnT1fjcqxXBRmX1UmB-b8g.PNG.sosow0212/image.png)\\nList Partitioning\uc744 \uc801\uc6a9\ud558\uace0 \uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 \uc870\ud68c \ucffc\ub9ac\ub97c \ub2e4\uc2dc \ub0a0\ub824\ubcf4\uba74, `partitions = p_friday` \ub85c \uc798 \ub098\ub258\uc5b4\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud30c\ud2f0\uc154\ub2dd \uc791\uc5c5\uc774 \uc798 \ub418\uc5c8\uc73c\ub2c8 \uc774\uc81c API\uc5d0\uc11c \uc694\uc77c \ubcc4 \ud63c\uc7a1\ub3c4 \uc870\ud68c\ub85c \ubc14\uafd4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\uba3c\uc800 \ucffc\ub9ac\ub97c \ubcc0\uacbd\ud558\uace0 \ucffc\ub9ac\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ub2e4\uc74c\uacfc \uac19\uc774 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMjQ5/MDAxNjk0NDIxNTcwOTg3.mgx-mdBa6J6k8erhiksOOkzrMzOMmCLX7iuRPZf7RNEg.ALwxez4qUVHB1wlIr9zsZovCBlxoIsmgCa4wNv-7t_4g.PNG.sosow0212/image.png?type=w773)\\n\uc704\uc640 \uac19\uc740 \uc870\ud68c \ucffc\ub9ac\uac00 \ub098\uc654\uc73c\ubbc0\ub85c \uc778\ub371\uc2a4\ub97c \uc544\ub798\uc640 \uac19\uc774 `station_id, day_of_week`\uc5d0 \uac78\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMjgy/MDAxNjk0NDIxNjI2NDAz.XqGsab-JR_fQaIZhCYMiKy5r3cn85wFLwUNlmCo1Gqwg.a26_N5lnwzXX6z0JRqn35u8pGcj1TAa2nDamgRyOYjUg.PNG.sosow0212/image.png?type=w773)\\n\uc704 \uc2e4\ud589 \uc18d\ub3c4\uc5d0\uc11c execution time\uc744 \ud655\uc778\ud574\ubcf4\uba74 \uc778\ub371\uc2a4\ub97c \uac78\uace0 `134ms -> 5ms`\ub85c \uc131\ub2a5\uc774 \ub9ce\uc774 \uac1c\uc120 \ub418\uc5c8\uc74c\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://postfiles.pstatic.net/MjAyMzA5MTFfMjI3/MDAxNjk0NDIxNjc5NDIw.kbfBLWKeY70QFeByKN5xVX1WhFSpFZbJnFkx9l0zyrQg.Wv0QU9W6Fqjfr8eyyLT2MyttjDKzN2cdrItGH7CDLPYg.PNG.sosow0212/image.png?type=w773)\\n\uc2e4\ud589 \uacc4\ud68d\ub3c4 \uc758\ub3c4\ud55c\ub300\ub85c \uc798 \ub098\uc624\ub294 \uac83\uc744 \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \uc815\ub9ac\\n\\n1. DB Partitioning - (day_of_week : \uc694\uc77c)\uc744 \uae30\uc900\uc73c\ub85c \ud30c\ud2f0\uc154\ub2dd\\n2. \uc870\ud68c \ucffc\ub9ac\uc5d0 \ub9de\uac8c \uc778\ub371\uc2a4 \uc124\uc815\\n3. API \uc218\uc815 (\ubaa8\ub4e0 \uc694\uc77c\uc758 \ud63c\uc7a1\ub3c4 \uc870\ud68c -> \ud574\ub2f9 \uc694\uc77c\uc758 \ud63c\uc7a1\ub3c4 \uc870\ud68c)\\n\\n\uacb0\uacfc\uc801\uc73c\ub85c \uae30\uc874 \ud63c\uc7a1\ub3c4 \uc870\ud68c\uc2dc 511ms\uac00 \ub098\uc654\uc73c\ub098, \uc694\uc77c \ubcc4 \uc870\ud68c \ubc0f \ud30c\ud2f0\uc154\ub2dd & \uc778\ub371\uc2a4\ub97c \uc801\uc6a9\ud558\uace0 execution time = 5ms\ub85c \uac1c\uc120"},{"id":"32","metadata":{"permalink":"/32","source":"@site/blog/2023-09-11-database-replication.mdx","title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","description":"\uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720","date":"2023-09-11T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 11\uc77c","tags":[{"label":"mysql","permalink":"/tags/mysql"}],"readingTime":23.75,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"32","title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","authors":["boxster"],"tags":["mysql"]},"prevItem":{"title":"\ud63c\uc7a1\ub3c4 \uc870\ud68c \uc18d\ub3c4\ub97c \ud30c\ud2f0\uc154\ub2dd\uacfc \uc778\ub371\uc2a4\ub85c \uac1c\uc120\ud574\ubcf4\uae30","permalink":"/33"},"nextItem":{"title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/31"}},"content":"## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\uac8c \ub41c \uacc4\uae30\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \uc9c0\ub09c \uae00\uc5d0\uc11c \uc124\uba85\ud588\ub4ef\uc774 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c\ub294 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc2e4\ud589\ub418\uace0 \uc788\ub294 \uc11c\ubc84\uc758 cpu \uc0ac\uc6a9\ub960\uc774 100%\uac00 \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc774 \ubd80\ubd84\uc5d0 \ub300\ud574\uc11c\ub294 \uc870\ud68c \uc131\ub2a5\uc744 \ub192\ud600 \uc5b4\ub290\uc815\ub3c4 \ud574\uacb0\ud558\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc870\ud68c\uac00 \uc544\ub2cc \ub9ce\uc740 \ub370\uc774\ud130\ub97c \uc77c\uc815\ud55c \uc8fc\uae30\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc918\uc57c\ud558\ub294 \ub85c\uc9c1\ub3c4 \ud3ec\ud568\ub418\uc5b4 \uc788\uae30 \ub54c\ubb38\uc5d0 \uc5c5\ub370\uc774\ud2b8\ub97c \ud560 \ub54c \uc870\ud68c\ub97c \ud558\uac8c \ub41c\ub2e4\uba74 cpu \uc0ac\uc6a9\ub960\uc740 \ube44\uc2b7\ud560 \uac83\uc785\ub2c8\ub2e4. \uc774 \ubd80\ubd84\uc744 \ud574\uacb0\ud558\uace0\uc790 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\uacb0\ub860\ubd80\ud130 \ub9d0\uc500\ub4dc\ub9ac\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud55c \ud6c4 \uc131\ub2a5\uc774 \ub208\uc5d0 \ub744\uac8c \uc88b\uc544\uc84c\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ubd80\ubd84\uc740 \ub2e4\uc74c \ud3ec\uc2a4\ud305\uc5d0 \uc791\uc131\ud558\uaca0\uc2b5\ub2c8\ub2e4\\n100\uba85\uc758 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c\ub97c \uae30\uc900\uc73c\ub85c\\n\\n**TPS** 179 -> 366\\n\\n**Response** Time 550 ms -> 271 ms\\n\\n\uc57d 2\ubc30 \uac00\ub7c9 \uc131\ub2a5\uc774 \ud5a5\uc0c1\ub41c \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n# \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc774\ub780?\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc774\ub780 \ud558\ub098\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \ub2e4\ub978 \ud558\ub098 \uc774\uc0c1\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub85c \ub370\uc774\ud130\uc758 \ubcf5\uc81c \ub610\ub294 \ubcf5\uc0ac\ub97c \uc218\ud589\ud558\ub294 \ud504\ub85c\uc138\uc2a4 \ub610\ub294 \uae30\uc220\uc785\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc740 \uc8fc\ub85c \ub2e4\uc74c\uacfc \uac19\uc740 \ubaa9\uc801\uc73c\ub85c \uc0ac\uc6a9\ub429\ub2c8\ub2e4\\n\\n1. **\uace0\uac00\uc6a9\uc131**:\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uc758 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c, \ub808\ud50c\ub9ac\uce74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc2dc\uc2a4\ud15c\uc744 \uacc4\uc18d \uc6b4\uc601\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub807\uac8c \ud558\uba74 \uc11c\ube44\uc2a4 \uc911\ub2e8 \uc2dc\uac04\uc744 \ucd5c\uc18c\ud654\ud558\uace0 \ube44\uc988\ub2c8\uc2a4 \uc5f0\uc18d\uc131\uc744 \uc720\uc9c0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n2. **\uc131\ub2a5 \ud5a5\uc0c1** :\\n\ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uba74 \uc77d\uae30 \uc791\uc5c5\uc744 \ubd84\uc0b0\uc2dc\ud0ac \uc218 \uc788\uc73c\ubbc0\ub85c \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uc758 \uc77d\uae30 \ubd80\ud558\ub97c \uc904\uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub97c \ud1b5\ud574 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc131\ub2a5\uc744 \ud5a5\uc0c1\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n3. **\uc9c0\uc5ed\uc801 \ubd84\uc0b0** :\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \ud1b5\ud574 \ub370\uc774\ud130\ub97c \uc9c0\ub9ac\uc801\uc73c\ub85c \ubd84\uc0b0\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc774\ub807\uac8c \ud558\uba74 \uc9c0\uc5ed\uc801\uc778 \uc0ac\uc6a9\uc790 \ub610\ub294 \uc751\uc6a9 \ud504\ub85c\uadf8\ub7a8\uc5d0 \ube60\ub974\uac8c \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud560 \uc218 \uc788\uc73c\uba70, \uc9c0\uc5ed\uc801\uc778 \uaddc\uc815 \uc900\uc218 \uc694\uad6c\uc0ac\ud56d\uc744 \ucda9\uc871\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n4. **\ubc31\uc5c5\uacfc \ubcf5\uad6c** :\\n\ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uc5ec \uc8fc \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ubc31\uc5c5\uc744 \uc0dd\uc131\ud558\uace0, \uc774\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc7a5\uc560 \ubcf5\uad6c\ub97c \uc218\ud589\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc8fc \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc190\uc0c1\ub418\uc5c8\uc744 \ub54c \ubc31\uc5c5 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc2dc\uc2a4\ud15c\uc744 \ube60\ub974\uac8c \ubcf5\uc6d0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n5. **\ub370\uc774\ud130 \ubd84\uc11d \ubc0f \ubcf4\uace0** :\\n\ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uc5ec \ub370\uc774\ud130\ub97c \ub2e4\ub978 \ubd84\uc11d \ub610\ub294 \ubcf4\uace0 \ub3c4\uad6c\ub85c \ubcf5\uc0ac\ud558\uc5ec \ub370\uc774\ud130 \uc6e8\uc5b4\ud558\uc6b0\uc2a4 \ub610\ub294 \ubd84\uc11d \uc2dc\uc2a4\ud15c\uc5d0\uc11c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud55c \uac00\uc7a5 \ud070 \uc774\uc720\ub294 \uc131\ub2a5 \ud5a5\uc0c1\uc785\ub2c8\ub2e4. \uc544\ubb34\ub798\ub3c4 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc77d\uae30 \uc791\uc5c5\uacfc \uc4f0\uae30 \uc791\uc5c5\uc774 \ub458 \ub2e4 \ube48\ubc88\ud558\uac8c \uc77c\uc5b4\ub098\uace0, \ud2b9\ud788 \uc4f0\uae30 \uc791\uc5c5\uc5d0 \ub9ce\uc740 \uc5f0\uc0b0\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \uc0ac\uc6a9\uc790\uc5d0\uac8c \ucd5c\uc2e0\uc758 \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud558\uace0\uc790 \uc4f0\uae30 \uc791\uc5c5\uc744 \uc790\uc8fc\ud558\uc5ec \ub370\uc774\ud130\ub97c \ucd5c\uc2e0\ud654\ud558\ub354\ub77c\ub3c4, \uc77d\uae30 \uc791\uc5c5\uc774 \ub290\ub824\uc9c0\uba74 \uc544\ubb34\ub3c4 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc744 \uac83\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub807\uac8c \uc11c\ubc84\ub97c \uc5ec\ub7ec \ub300 \ub450\uc5b4 \ud558\ub098\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uac00 \ubc1b\ub294 \ubd80\ud558\ub97c \ubd84\uc0b0\uc2dc\ud0a8\ub2e4\uba74 \uc131\ub2a5\uc774 \ud5a5\uc0c1 \ub420 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub450\ubc88\uc9f8\ub85c\ub294 \uace0\uac00\uc6a9\uc131\uc785\ub2c8\ub2e4. \ud604\uc7ac \uc800\ud76c\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub294 \ud558\ub098\uc758 \uc11c\ubc84\ub85c SPOF \ubb38\uc81c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud558\uc5ec \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c \ubd84\uc0b0\ud55c\ub2e4\uba74 \ud558\ub098\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc7a5\uc560\uac00 \uc0dd\uaca8 \uc911\uc9c0\uac00 \ub418\ub354\ub77c\ub3c4, \ub2e4\ub978 \uc11c\ubc84\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub85c \uc11c\ube44\uc2a4\ub97c \uc774\uc5b4\ub098\uac08 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n# \ub370\uc774\ud130\ubca0\uc774\uc2a4 \ubcf5\uc81c \ubc29\uc2dd\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ubcf5\uc81c \ubc29\uc2dd\uc740 \ud06c\uac8c \ub450\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4. **Binary Log\ub85c \ubcf5\uc81c\ud558\ub294 \ubc29\uc2dd**\uacfc **GTID(Global Transaction Id)\ub97c \ud1b5\ud574 \ubcf5\uc81c\ub97c \ud558\ub294 \ubc29\uc2dd**\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## Binary log \ubcf5\uc81c \ubc29\uc2dd\\n\uba3c\uc800 Binary Log \ub294 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uc218\ud589\ud55c \ucffc\ub9ac (\uc0ac\uc6a9\uc790 \ucd94\uac00, \uc778\ub371\uc2a4 \ucd94\uac00, Update, Insert, Delete \ub4f1 ) \ubaa8\ub4e0 \uc815\ubcf4\ub97c Binary Log\uc5d0 \uae30\ub85d\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ud574\ub2f9 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0\ub294 \uc774\ubca4\ud2b8\ub9c8\ub2e4 Mysql \uc11c\ubc84\uc758 \uace0\uc720\ud55c Server id\ub97c \uac00\uc9c0\uace0 \uc788\ub294\ub370, \ud574\ub2f9 Id\uac00 \uac19\uc740 \uc11c\ubc84\uc5d0\uc11c\ub294 \ud574\ub2f9 \uc774\ubca4\ud2b8\ub97c \uc790\uc2e0\uc774 \ubc1c\uc0dd\uc2dc\ud0a8 \uc774\ubca4\ud2b8\ub85c \uac04\uc8fc\ud558\uace0 \uc801\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\ubbc0\ub85c \uac01\uac01\uc758 \uace0\uc720\ud55c server id\ub97c \uc124\uc815\ud574\uc918\uc57c \ud569\ub2c8\ub2e4.\\n\uc774 **\ubc14\uc774\ub108\ub9ac \ub85c\uadf8 \ud30c\uc77c\uc758 \uc704\uce58\uc640 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8 \ud30c\uc77c\uba85**\uc744 \ud1b5\ud574 Replica \uc11c\ubc84\ub294 Source \uc11c\ubc84\uc758 \uc774\ubca4\ud2b8\ub97c \uc801\uc6a9\ud569\ub2c8\ub2e4\\n## GTID \ubcf5\uc81c \ubc29\uc2dd\\nMysql 5.5 \ubc84\uc804 \uc774\uc0c1\ubd80\ud130\ub294 GTID \uae30\ubc18 \ubcf5\uc81c\ub3c4 \uac00\ub2a5\ud558\uac8c \ucd94\uac00\ub418\uc5c8\uc2b5\ub2c8\ub2e4 GTID\ub294 source id\uc640 transaction id\uac00 \uc870\ud569\ub41c \ubc29\uc2dd\uc73c\ub85c \uc0dd\uc131\ub429\ub2c8\ub2e4. source id\ub294 \ud2b8\ub79c\uc7ad\uc158\uc774 \ubc1c\uc0dd\ud55c \uc18c\uc2a4 \uc11c\ubc84\ub97c \uc2dd\ubcc4\ud558\uae30 \uc704\ud55c \uac12\uc73c\ub85c server\uc758 uuid \uc785\ub2c8\ub2e4.\\n```sql\\n+--------------------------------------+\\n| source_uuid |\\n+--------------------------------------+\\n| c3a2296b-31a2-11ee-b887-02a8cf0173ac |\\n+--------------------------------------+\\n```\\n\uc774\ub7ec\ud55c GTID\ub97c \uae30\ubc18\uc73c\ub85c Source \uc11c\ubc84\ub97c \uad6c\ubd84\ud558\uace0 Binary Log \ud30c\uc77c\uc5d0 \uae30\ub85d\ub41c GTID\ub97c \ud655\uc778\ud558\uc5ec \ub9c8\uc9c0\ub9c9\uc5d0 \uc801\uc6a9\ud55c \uc774\ubca4\ud2b8\ub97c \ud655\uc778\ud558\uace0, \uc801\uc6a9\ud558\uc9c0 \uc54a\uc740 \uc774\ubca4\ud2b8\ub97c \uc21c\ucc28\ub300\ub85c \uc2e4\ud589\uc2dc\ucf1c \ubcf5\uc81c\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ub450\uac00\uc9c0 \ubc29\ubc95 \uc911 \uc800\ud76c\ub294 GTID \ubc29\uc2dd\uc758 \ubcf5\uc81c\ub97c \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4. \uc774\uc720\ub294 \uac04\ub2e8\ud569\ub2c8\ub2e4.\\n```mermaid\\ngraph TD\\n Source[Source Server: BinaryLog10] --\x3e Replica1[Replica1: BinaryLog10]\\n Source[Source Server: BinaryLog10] --\x3e Replica2[Replica2: BinaryLog9]\\n```\\n\uc774\ub7f0 \ubc29\uc2dd\uc73c\ub85c \ud1a0\ud3f4\ub85c\uc9c0\ub97c \uad6c\uc131\ud588\ub2e4\uace0 \uac00\uc815\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. Source \uc11c\ubc84\uc5d0\uc11c\ub294 Binary Log 10\ubc88 \ud30c\uc77c\uae4c\uc9c0 \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 Replica1 \uc5d0\uc11c\ub294 Source \uc11c\ubc84\uc758 \uc774\ubca4\ud2b8\uac00 \ucd5c\uc2e0\ud654 \ub418\uc5b4 \uc788\uc9c0\ub9cc, Replica2 \uc11c\ubc84\ub294 \uc544\uc9c1 \ucd5c\uc2e0\ud654\uac00 \ub418\uc9c0 \uc54a\uc740 \uc0c1\ud669\uc785\ub2c8\ub2e4. \uc774 \uc0c1\ud669\uc5d0\uc11c Source Server\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud558\uc5ec \uc11c\ubc84\uac00 \uc911\ub2e8 \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 Replica1 \uc11c\ubc84\ub97c Source \uc11c\ubc84\ub85c \uc2b9\uaca9\ud569\ub2c8\ub2e4. \uc774\ub807\uac8c \ub41c\ub2e4\uba74 Replica1 \uc11c\ubc84\uc5d0\uc11c \ubaa8\ub4e0 \ucffc\ub9ac\uc758 \uc694\uccad\uc774 \ub4e4\uc5b4\uc624\uac8c \ub429\ub2c8\ub2e4. BinaryLog10\uc774\ub77c\ub294 \ud30c\uc77c\uc758 \uc704\uce58\uc640 \ud30c\uc77c\uc744 \ucc3e\uc744 \ubc29\ubc95\uc774 \uc5c6\uae30 \ub54c\ubb38\uc5d0 Source\uc11c\ubc84\uac00 \ubcf5\uad6c\ub418\uc9c0 \uc54a\ub294 \uc774\uc0c1 \ud639\uc740 Replica 1 \uc11c\ubc84\uc758 Relay Log\uac00 \ub0a8\uc544\uc788\uc9c0 \uc54a\ub294 \uc774\uc0c1 Replica2 \uc11c\ubc84\ub294 \uc808\ub300 \ucd5c\uc2e0\ud654\ub420 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774\ub7f0 \uc2dd\uc758 \ubc29\uc2dd\uc774\ub77c\uba74 Source \uc11c\ubc84\uac00 \uc911\ub2e8\ub418\uc5c8\uc744 \ub54c \ub2e4\ub978 \uc11c\ubc84\uac00 \ub3d9\uc791\ud558\uae30 \ub54c\ubb38\uc5d0 \uace0\uac00\uc6a9\uc131 \ubb38\uc81c\ub294 \ud574\uacb0\ub41c \uac83 \uac19\uc9c0\ub9cc, Replica2 \uc11c\ubc84\ub294 \uc544\ubb34 \uc77c\ub3c4 \ud558\uc9c0\uc54a\uace0 \ub0a8\uc544\uc788\ub294 \uc11c\ubc84, \uc989 Source \uc11c\ubc84 \ud558\ub098\uac00 \uc911\ub2e8\ub418\uc5c8\uc73c\ub098 **2\ub300\uc758 \uc11c\ubc84\uac00 \uc911\ub2e8\ub41c \uac83**\uacfc \ub9c8\ucc2c\uac00\uc9c0\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub7ec\ud55c \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 GTID\uac00 \ub4f1\uc7a5\ud588\uc2b5\ub2c8\ub2e4. GTID \ubc29\uc2dd\uc740 Binary Log\uc758 \uc704\uce58\uc640 \ud30c\uc77c\uba85\uc774 \ud544\uc694\ud55c \uac83\uc774 \uc544\ub2cc \ub2e4\uc74c \uc774\ubca4\ud2b8\uc758 GTID\ub9cc \uc788\ub2e4\uba74 \ud574\ub2f9 \uc774\ubca4\ud2b8\ub97c \ubc14\ub85c \uc801\uc6a9\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4. Source \uc11c\ubc84\ub85c \uc2b9\uaca9\ub41c Replica1 \uc11c\ubc84\uc5d0\uc11c **GTID\ub97c \ubc1b\uc544 \uc801\uc6a9\ud558\uc5ec \ucd5c\uc2e0\ud654**\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc758 \ubcf5\uc81c \ubc29\uc2dd\\n\uc774\ub7ec\ud55c \uc7a5\uc810\uc73c\ub85c GTID\uae30\ubc18 \ubcf5\uc81c \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n# \ubcf5\uc81c \ub3d9\uae30\ud654 \ubc29\uc2dd\\n\\n\ubcf5\uc81c \ubc29\uc2dd\uc5d0\ub294 \ud06c\uac8c \ub450\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4. **\ube44\ub3d9\uae30 \ubcf5\uc81c**\uc640 **\ubc18\ub3d9\uae30 \ubcf5\uc81c**\uc785\ub2c8\ub2e4.\\n\\n## \ube44\ub3d9\uae30 \ubcf5\uc81c\\n\ube44\ub3d9\uae30 \ubcf5\uc81c\ub294 \ub9d0\uadf8\ub300\ub85c \ube44\ub3d9\uae30\ub85c \ubcf5\uc81c\ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \uc544\uc8fc \uac04\ub2e8\ud569\ub2c8\ub2e4. Source \uc11c\ubc84\uc5d0\uc11c \uc5b4\ub5a0\ud55c \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud560 \ub54c Replica \uc11c\ubc84\uc758 \ubc18\uc601\uacfc \uc0c1\uad00\uc5c6\uc774 \ub3d9\uc791\ud558\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c \ucee4\ubc0b\ub41c \ud2b8\ub79c\uc7ad\uc158\uc740 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d\ub418\uace0, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0\uc11c\ub294 \uc8fc\uae30\uc801\uc73c\ub85c \uc0c8\ub85c\uc6b4 \ud2b8\ub79c\uc7ad\uc158\uc5d0 \ub300\ud55c \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\ub97c \uc694\uccad\ud569\ub2c8\ub2e4. \uc774\ub7ec\ud55c \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\ub294 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \ubcc0\uacbd \ub418\uc5c8\ub294\uc9c0 \uc54c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc989 \ub370\uc774\ud130 \uc815\ud569\uc131\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uae34\ub2e4\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\uac00 \uac01 \ud2b8\ub79c\uc7ad\uc158\uc5d0 \ub300\ud574 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub85c \uc804\uc1a1\ub418\ub294 \ubd80\ubd84\uc744 \uace0\ub824\ud558\uc9c0 \uc54a\ub294\ub2e4\ub294 \uc810\uc774 \uc18d\ub3c4 \uce21\uba74\uc5d0\uc11c \ube60\ub974\uace0, \ub610 \uc5ec\ub7ec \ub300\uc758 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub97c \uad6c\uc131\ud558\ub354\ub77c\ub3c4 \ud070 \uc131\ub2a5 \uc800\ud558\uac00 \uc5c6\ub2e4\ub294 \uc810\uc774\uc11c \uc7a5\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \ubc18\ub3d9\uae30 \ubcf5\uc81c\\n\ubc18\ub3d9\uae30 \ubcf5\uc81c\ub294 \ube44\ub3d9\uae30 \ubcf5\uc81c\ubcf4\ub2e4 \uc880 \ub354 \ub370\uc774\ud130 \uc815\ud569\uc131\uc774 \uc62c\ub77c\uac11\ub2c8\ub2e4. \uc18c\uc2a4 \uc11c\ubc84\ub294 \ubcc0\uacbd\ub41c \ud2b8\ub79c\uc7ad\uc158\uc774 \uc788\uc744 \ub54c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ub2e4 \uc804\uc1a1\uc774 \ub418\uc5c8\ub2e4\ub294 ACK \uc2e0\ud638\ub97c \ubc1b\uae30 \ub54c\ubb38\uc5d0 \ud655\uc2e4\ud788 \uc54c \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc804\uc1a1\uc5ec\ubd80\ub9cc \ud655\uc778\ud558\uae30 \ub54c\ubb38\uc5d0 \ud2b8\ub79c\uc7ad\uc158\uc774 \ubc18\uc601\uc774 \ub418\uc5c8\ub2e4\ub294 \ubcf4\uc7a5\uc740 \uc5c6\uc2b5\ub2c8\ub2e4. \ubc18\ub3d9\uae30 \ubcf5\uc81c \ubc29\uc2dd\uc740 2\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. **After sync**: After Sync \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c \ud2b8\ub79c\uc7ad\uc158\uc744 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d \ud6c4 Storage Engine\uc5d0 \ubc14\ub85c \ucee4\ubc0b\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d \ud6c4 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc758 ACK \uc751\ub2f5\uc744 \uae30\ub2e4\ub9bd\ub2c8\ub2e4. \uadf8\ub9ac\uace0 ACK \uc751\ub2f5\uc774 \ub3c4\ucc29\ud558\uba74 \uadf8\uc81c\uc11c\uc57c \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4\uc744 \ucee4\ubc0b\ud558\uc5ec \ud2b8\ub79c\uc7ad\uc158\uc744 \ucc98\ub9ac\ud558\uace0 \uacb0\uacfc\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n2. **After commit**: After commit\uc740 \uc774\ub984 \uadf8\ub300\ub85c \ucee4\ubc0b\uc744 \uba3c\uc800 \ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \ud2b8\ub79c\uc7ad\uc158\uc774 \uc0dd\uae30\uba74 \uba3c\uc800 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\uc5d0 \uae30\ub85d \ud6c4 \uc18c\uc2a4 \uc11c\ubc84 \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4\uc5d0 \ucee4\ubc0b\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc758 ACK \uc751\ub2f5\uc774 \ub0b4\ub824\uc624\uba74 \ud074\ub77c\uc774\uc5b8\ud2b8\ub294 \ucc98\ub9ac \uacb0\uacfc\ub97c \uc5bb\uace0 \ub2e4\uc74c \ucffc\ub9ac\ub97c \uc218\ud589\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 after commit \ubc29\uc2dd\uc740 \uc18c\uc2a4 \uc11c\ubc84\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c \ud32c\ud140 \ub9ac\ub4dc\uac00 \ubc1c\uc0dd\ud558\uac8c \ub429\ub2c8\ub2e4. \ud2b8\ub79c\uc7ad\uc158\uc774 \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4 \ucee4\ubc0b\uae4c\uc9c0\ub41c \ud6c4 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc758 \uc751\ub2f5\uc744 \uae30\ub2e4\ub9bd\ub2c8\ub2e4. \uc774\ucc98\ub7fc \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4 \ucee4\ubc0b\uae4c\uc9c0 \uc644\ub8cc\ub41c \ub370\uc774\ud130\ub294 \ub2e4\ub978 \uc138\uc158\uc5d0\uc11c\ub3c4 \uc870\ud68c\uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4. \ud2b8\ub79c\uc7ad\uc158\uc774 \ucee4\ubc0b\ub418\uc5c8\uace0, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub85c \uc544\uc9c1 \uc751\ub2f5\uc744 \uae30\ub2e4\ub9b4 \ub54c, \uc18c\uc2a4 \uc11c\ubc84\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud55c\ub2e4\uba74 \uc0c8\ub85c\uc6b4 \uc18c\uc2a4 \uc11c\ubc84\ub85c \uc2b9\uaca9\ub41c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0\uc11c \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c \uc790\uc2e0\uc774 \uc774\uc804 \uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c \uc870\ud68c\ud588\ub358 \ub370\uc774\ud130\ub97c \ubcf4\uc9c0 \ubabb\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc774\ucc98\ub7fc \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \uc2b9\uaca9\ub41c \uc0c1\ud669\uc5d0 \uc18c\uc2a4 \uc11c\ubc84\uc758 \uc7a5\uc560\uac00 \ubcf5\uad6c\ub418\uc5b4 \uc7ac\uc0ac\uc6a9\ud560 \uacbd\uc6b0 \uc774\ubbf8 \ucee4\ubc0b\ub41c \uadf8 \ud2b8\ub79c\uc7ad\uc158\uc744 \uc218\ub3d9\uc73c\ub85c \ub864\ubc31 \uc2dc\ucf1c\uc57c\ub9cc \ub370\uc774\ud130\uac00 \ub9de\ub294 \uc0c1\ud669\uc774 \uc0dd\uae41\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc758 \ubcf5\uc81c \ub3d9\uae30\ud654 \ubc29\uc2dd\\n\uc774\ub7ec\ud55c \uc7a5\ub2e8\uc810\uc73c\ub85c \uc800\ud76c \ud300\uc740 \ub370\uc774\ud130 \ubb34\uacb0\uc131\uc774 \uc911\uc694\ud558\ub2e4 \ud310\ub2e8\ub418\uc5b4 \ubc18\ub3d9\uae30 \ubcf5\uc81c \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud558\uace0, After Sync \ubc29\uc2dd\uc744 \uc801\uc6a9\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n# \ubcf5\uc81c \ud1a0\ud3f4\ub9ac\uc9c0\\n\\n\ubcf5\uc81c \ud1a0\ud3f4\ub9ac\uc9c0\ub294 \uc5ec\ub7ec\uac00\uc9c0 \ubc29\uc2dd \uc911 \uc790\uc2e0\uc758 \uc0c1\ud669\uacfc \uac00\uc7a5 \ub9de\ub294 \ubc29\uc2dd\uc744 \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4. \uc800\ud76c \ud300\uc774 \uace0\ub824\ud574\uc57c\ud560 \ubb38\uc81c\ub294 \uba3c\uc800 \uc131\ub2a5\uc744 \uc62c\ub824\uc57c \ud588\uace0, \ub2e8\uc77c \uc7a5\uc560\ud3ec\uc778\ud2b8\ub97c \uac1c\uc120\ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uc11c\ubc84\ub294 2\ub300 \ubfd0\uc774\uc600\uc2b5\ub2c8\ub2e4. \uc774\ub7ec\ud55c \uc0c1\ud669\uc5d0\uc11c \uc5b4\ub5a4 \ubc29\uc2dd\uc744 \ud0dd\ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n## \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S[Source]\\n A -- Read --\x3e R[Replica]\\n S--\x3e R\\n```\\n\uac00\uc7a5 \uae30\ubcf8\uc801\uc774\uba70 \uac00\uc7a5 \ub9ce\uc774 \uc4f0\uc774\ub294 \ud615\ud0dc\uc785\ub2c8\ub2e4. \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc5d0\uc11c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0 \uc77d\uae30 \uc694\uccad\uc744 \uc804\ub2ec\ud558\uba74, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uacbc\uc744 \ub54c, \uc11c\ube44\uc2a4 \uc7a5\uc560 \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\ubbc0\ub85c \uc18c\uc2a4 \uc11c\ubc84\uc5d0\uc11c Read, Write\ub97c \ub458 \ub2e4 \ud558\uace0, \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub294 failover\ub97c \uc704\ud574 \ub300\uae30\ud558\ub294 \uc608\ube44\uc6a9 \uc11c\ubc84\ub85c \uad6c\uc131\ud569\ub2c8\ub2e4.\\n\uc18c\uc2a4 \uc11c\ubc84\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c \uc18c\uc2a4 \uc11c\ubc84\ub97c \ub300\uccb4\ud558\uac70\ub098 \ub370\uc774\ud130\ub97c \ubc31\uc5c5\ud558\ub294 \uc6a9\ub3c4\ub85c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\n## \uba40\ud2f0 \ub808\ud50c\ub9ac\uce74\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S[Source]\\n A -- Read --\x3e R1[Replica1]\\n S --\x3e R1\\n S --\x3e R2[Replica2]\\n```\\n\uc2f1\uae00 \ub808\ud50c\ub9ac\uce74\uc640 \ube44\uc2b7\ud55c \uad6c\uc131\uc774\uc9c0\ub9cc \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ud55c \ub300 \ub354 \ucd94\uac00\ub41c \uad6c\uc131\uc785\ub2c8\ub2e4. \ud574\ub2f9 \ubc29\uc2dd\uc740 SPOF \ubb38\uc81c\uac00 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84 \ud558\ub098\ub97c \uc77d\uae30 \uc804\uc6a9 \uc11c\ubc84\ub85c \ub458 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc77d\uae30 \uc791\uc5c5\uc744 \ubd84\uc0b0\ud568\uc73c\ub85c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc758 \uc131\ub2a5\uc744 \ud5a5\uc0c1 \uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc544\uae4c \ub9d0\ud588\ub358 \uc7a5\uc560 \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud558\uba74 \uc608\ube44\uc6a9 \uc11c\ubc84\uc778 Replica2 \uc11c\ubc84\ub97c Source \uc11c\ubc84 \ud639\uc740 Replica1(\uc77d\uae30 \uc804\uc6a9) \uc11c\ubc84\ub85c \ub300\uccb4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uccb4\uc778 \ubcf5\uc81c\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S[Source1]\\n A -- Read --\x3e R1-1[Replica1-1]\\n S --\x3e R1-1\\n S --\x3e R1-2[Replica1-2]\\n S --\x3e R1-3[Replica1-3 / Source2]\\n R1-3 --\x3e R2-1[Replica2-1]\\n R1-3 --\x3e R2-2[Replica2-2]\\n B[Batch Server] --Read--\x3e R2-2\\n\\n```\\n\ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ub9ce\uc544\uc838 \uc18c\uc2a4 \uc11c\ubc84\uc758 \ubc14\uc774\ub108\ub9ac \ub85c\uadf8\ub97c \uc77d\ub294 \ubd80\ud558\uac00 \ub9ce\uc544\uc9c8 \ub54c \ud560 \uc218 \uc788\ub294 \uad6c\uc131\uc785\ub2c8\ub2e4. \uc880 \uc804\uc5d0 \uc124\uba85\ub4dc\ub838\ub358 \uba40\ud2f0 \ub808\ud50c\ub9ac\uce74 \ubc29\uc2dd\uc5d0\uc11c \ub611\uac19\uc740 \uad6c\uc131\uc744 \ucd94\uac00\ud55c \ubc29\uc2dd\uc73c\ub85c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4. Source 1 \uc758 \uc815\ubcf4\ub97c \ubcf5\uc81c\ud55c Replica 1-1, 1-2 \uc11c\ubc84\ub294 \ube60\ub974\uac8c \ub370\uc774\ud130\uac00 \ubc18\uc601\ub418\uc9c0\ub9cc, Source1\uc758 \uc774\ubca4\ud2b8\ub97c \ubcf5\uc81c\ud55c Source2\ub97c \ubcf5\uc81c\ud55c Replica 2-1, 2-2 \uc11c\ubc84\ub294 \ub2f9\uc5f0\ud788 \ub2a6\uac8c \ubc18\uc601\ub418\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \uadf8\ub8f9\uc740 \uc608\ube44\uc6a9\uc73c\ub85c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\n## \ub4c0\uc5bc \uc18c\uc2a4 \ubcf5\uc81c\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S1[Source/Replica 1]\\n A -- Read + Write --\x3e S2[Source/Replica 2]\\n S1 <-- Replication --\x3e S2\\n```\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub458 \ub2e4 \uc18c\uc2a4 \uc11c\ubc84\uc774\uba74\uc11c \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uc778 \uacbd\uc6b0\uc785\ub2c8\ub2e4. \uc774 \uacbd\uc6b0\ub294 **Active-Active**\uad6c\uc131\uacfc **Active-Passive** \uad6c\uc131\uc73c\ub85c \ub098\ub269\ub2c8\ub2e4\\n\\nActive-Active\ub294 \uc11c\ubc84 \ub458 \ub2e4 \uc77d\uae30\uc640 \uc4f0\uae30\uac00 \uac00\ub2a5\ud55c \ud615\ud0dc\uc785\ub2c8\ub2e4. \uc989 \ubd80\ud558\ub97c \ubd84\uc0b0\uc2dc\ud0a4\uae30 \uc704\ud574 \uc11c\ubc84 \ubaa8\ub450 \uc77d\uace0 \uc4f0\ub294 \uc791\uc5c5\uc744 \ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \ubc29\uc2dd\uc740 \ubed4\ud55c \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc11c\ub85c\uc758 \uc774\ubca4\ud2b8\uac00 \ub3d9\uae30\ud654 \ub418\uae30 \uc804\uc5d0\ub294 \uc815\ud569\uc131\uc774 \uae68\uc9c8 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub610 \ub3d9\uc2dc\uc5d0 \uac19\uc740 \ub370\uc774\ud130\uc5d0 \ub300\ud574 \uc4f0\uae30 \uc791\uc5c5\uc744 \uc218\ud589\ud560 \ub54c, \ud558\ub098\uc758 \uc11c\ubc84\uc5d0\uc11c \uc4f0\uae30\uac00 \uc644\ub8cc\ub418\uc5c8\ub354\ub77c\ub3c4, \ub2e4\ub978 \ud558\ub098\uc758 \uc11c\ubc84\uc5d0 \ub2a6\uac8c \ub05d\ub09c \uc4f0\uae30\uac00 \uc788\ub2e4\uba74 \ub9c8\uc9c0\ub9c9 \ud2b8\ub79c\uc7ad\uc158\uc778 \ub2a6\uac8c \ub05d\ub09c \uc4f0\uae30 \uc791\uc5c5\uc774 \ubc18\uc601\ub418\uc5b4 \uc608\uc0c1\ud558\uc9c0 \ubabb\ud55c \uacb0\uacfc\uac00 \ub098\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub610 \ub2e4\ub978 \ubb38\uc81c\ub85c\ub294 Auto Increment\ub97c \uc0ac\uc6a9\ud560 \ub54c\uc785\ub2c8\ub2e4. \uc0c8\ub85c\uc6b4 \ub370\uc774\ud130\uac00 \ub3d9\uc2dc\uc5d0 \uc0dd\uc131\ub420 \ub54c Auto Increment\uac00 \uc911\ubcf5\ub418\ub294 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \ud1a0\ud3f4\ub85c\uc9c0\uc5d0\uc11c\ub294 ID\ub97c DB\uc5d0 \uc758\uc874\ud558\uc9c0 \uc54a\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4.\\n\\nActive-Passive \ubc29\uc2dd\uc740 \ud558\ub098\uc758 \uc11c\ubc84\ub9cc \uc77d\uae30\uc640 \uc4f0\uae30 \uc694\uccad\uc774 \ub418\uc9c0\ub9cc, \ub098\uba38\uc9c0 \uc11c\ubc84\ub294 \ub300\uae30\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \ub450 \uc11c\ubc84 \ubaa8\ub450 \uc5b8\uc81c\ub098 \uc4f0\uae30 \uc791\uc5c5\uc774 \uac00\ub2a5\ud55c \ud615\ud0dc\uc774\uae30 \ub54c\ubb38\uc5d0 \uc7a5\uc560 \ubc1c\uc0dd \uc2dc \ube60\ub974\uac8c Faliover\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uba40\ud2f0 \uc18c\uc2a4 \ubcf5\uc81c\\n\\n\\n```mermaid\\ngraph LR\\n A[Application Server] -- Read + Write --\x3e S1[Source 1]\\n A[Application Server] -- Read + Write --\x3e S2[Source 2]\\n A[Application Server] -- Read + Write --\x3e S3[Source 3]\\n A[Application Server] -- Read + Write --\x3e S4[Source 4]\\n S1 --\x3e R[Replica]\\n S2 --\x3e R[Replica]\\n S3 --\x3e R[Replica]\\n S4 --\x3e R[Replica]\\n```\\n\ud558\ub098\uc758 \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\uac00 \ub2e4\uc218\uc758 \uc18c\uc2a4 \uc11c\ubc84\ub97c \uac16\ub294 \uad6c\uc131\uc785\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc0e4\ub529\uc744 \ud574\ub480\ub294\ub370, \ub2e4\uc2dc \ud558\ub098\uc758 \uc11c\ubc84\ub85c \ud1b5\ud569\ud558\uace0 \uc2f6\uc744 \ub54c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud639\uc740 \uc11c\ub85c \ub2e4\ub978 \ub370\uc774\ud130\ub97c \ud55c \uacf3\uc5d0 \ubc31\uc5c5\uc744 \ud560 \ub54c\ub3c4 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc758 \ud1a0\ud3f4\ub85c\uc9c0 \ubc29\uc2dd\\n\uadf8\ub7fc \uc774\ub807\uac8c\ub098 \ub9ce\uc740 \uad6c\uc131 \uc911\uc5d0 \uc800\ud76c \ud300\uc5d0\uc11c \ud0dd\ud560 \uc218 \uc788\ub294 \ud1a0\ud3f4\ub85c\uc9c0 \ubc29\uc2dd\uc740 \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74 \ubc29\uc2dd\uacfc \ub4c0\uc5bc \uc18c\uc2a4 \ubcf5\uc81c \ubc29\uc2dd \ubc16\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4. \uc65c\ub0d0\ud558\uba74 \uc8fc\uc5b4\uc9c4 \uc11c\ubc84\uac00 2\ub300\ubfd0\uc774\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \ub4c0\uc5bc \uc18c\uc2a4 \ubc29\uc2dd\uc740 \uc801\uc6a9\ud558\ub294\ub370 \ubb34\ub9ac\uac00 \uc788\ub294 \ubd80\ubd84\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc77c\ub2e8 \uc800\ud76c\uac00 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc801\uc6a9\ud558\ub824\ub294 \uac00\uc7a5 \ud070 \uc774\uc720\ub294 **\uc131\ub2a5** \uc774\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc774 \ubcc0\ud558\uc9c0 \uc54a\ub294 \ub4c0\uc5bc \uc18c\uc2a4\uc758 Active-Passive \ubc29\uc2dd\uc740 \uc81c\uc678\ud558\uaca0\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 Active-Active \ubc29\uc2dd\uc740 \ubd80\ud558\ub97c \ubd84\uc0b0\uc2dc\ud0ac \uc218 \uc788\ub2e4\ub294 \uc7a5\uc810\uc774 \uc788\uc9c0\ub9cc, \ub2e8\uc810\uc73c\ub85c\ub294 Auto Increment\ub97c \uc0ac\uc6a9\ud558\ub294\ub370\uc5d0 \uc704\ud5d8\uc774 \uc788\ub2e4\ub294 \uc810\uacfc, \ub370\uc774\ud130\uc758 \uc815\ud569\uc131 \ubb38\uc81c\uac00 \uc0dd\uae38 \uc218 \uc788\ub2e4\ub294 \uc810\uc5d0\uc11c \ub4c0\uc5bc \uc18c\uc2a4 \ubc29\uc2dd\uc740 \uc81c\uc678\ud558\ub3c4\ub85d \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74 \ubc29\uc2dd\uc744 \uc801\uc6a9\ud560 \uc218 \ubc16\uc5d0 \uc5c6\ub294\ub370\uc694. \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74\uc758 \ubc29\uc2dd\uc740 \uac00\uc6a9\uc131 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 \ub9cc\ub4e4\uc5b4\uc9c4 \ubc29\uc2dd\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \ud604\uc7ac \uac00\uc6a9\uc131\ubcf4\ub2e4 \uc131\ub2a5\uc744 \ub354 \uc2e0\uacbd\uc368\uc57c\ud558\ub294 \uc0c1\ud669\uc774\uae30\ub54c\ubb38\uc5d0 \uc2f1\uae00 \ub808\ud50c\ub9ac\uce74 \ud1a0\ud3f4\ub85c\uc9c0\ub97c \uad6c\uc131\ud558\uc9c0\ub9cc \ub808\ud50c\ub9ac\uce74 \uc11c\ubc84\ub97c \uc608\ube44\uc6a9\uc774 \uc544\ub2cc \uc77d\uae30 \uc804\uc6a9 \ubc29\uc2dd\uc73c\ub85c \uc0ac\uc6a9\ud558\ub3c4\ub85d \ud558\uace0, \uac00\uc6a9\uc131 \ubd80\ubd84\uc744 \ud3ec\uae30\ud558\uae30\ub85c \uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n# \ucf54\ub4dc\uc5d0 \uc801\uc6a9\ud558\uae30\\n[replication-datasource](https://github.com/kwon37xi/replication-datasource) Github \uc18c\uc2a4 \ucf54\ub4dc\ub97c \ucc38\uace0\ud558\uc2dc\uac70\ub098, [DB \ubcf5\uc81c, @Transactional\uc5d0 \ub530\ub77c \uc694\uccad \ubd84\ub9ac\ud574\ubcf4\uae30](https://greeng00se.github.io/db-replication) \uae00\uc744 \ucc38\uace0\ud558\uc5ec \ub530\ub77c\ud558\uba74 \uae08\ubc29\ud558\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4!\\n\\n## \uacb0\ub860\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158 \uc0dd\uac01\ubcf4\ub2e4 \uc5b4\ub835\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc7ac\ubc0c\uc2b5\ub2c8\ub2e4. \uc778\ud504\ub77c\ub3c4 \uc7ac\ubc0c\uc2b5\ub2c8\ub2e4.\\n\\n## \ucc38\uace0\\nReal Mysql 8.0"},{"id":"31","metadata":{"permalink":"/31","source":"@site/blog/2023-09-03-improved-query-performance.mdx","title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4","date":"2023-09-03T00:00:00.000Z","formattedDate":"2023\ub144 9\uc6d4 3\uc77c","tags":[{"label":"mysql","permalink":"/tags/mysql"}],"readingTime":13.275,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"31","title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","authors":["boxster"],"tags":["mysql"]},"prevItem":{"title":"\ub370\uc774\ud130\ubca0\uc774\uc2a4 \ub808\ud50c\ub9ac\ucf00\uc774\uc158\uc73c\ub85c \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/32"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","permalink":"/30"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\uac8c \ub41c \uacc4\uae30\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \uce74\ud398\uc778 \ud300 \ud504\ub85c\uc81d\ud2b8\uc5d0\ub294 \uc0ac\uc6a9\uc790\uac00 \ubcf4\uace0\uc788\ub294 \uc9c0\ub3c4\uc5d0 \ucda9\uc804\uc18c\ub97c \ubcf4\uc5ec\uc8fc\ub294 \uc870\ud68c \uae30\ub2a5\uc774 \uac00\uc7a5 \uc911\uc694\ud558\uace0, \uc81c\uc77c \uc694\uccad\uc774 \ub9ce\uc774 \ub4e4\uc5b4\uc635\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc870\ud68c \uc131\ub2a5\uc774 \uc88b\uc9c0 \uc54a\uc740 \uae4c\ub2ed\uc778\uc9c0 \uc5ec\ub7ec \uc0ac\uc6a9\uc790\uac00 \uc811\uc18d\ud558\uba74 \uc544\ub798\uc640 \uac19\uc774 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc2e4\ud589\ub418\uace0 \uc788\ub294 \uc11c\ubc84\uc758 cpu \uc0ac\uc6a9\ub960\uc774 100%\uac00 \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n![cpu](https://github.com/drunkenhw/drunkenhw.github.io/assets/106640954/2330435f-17b4-4d38-b16b-c72fd7017969)\\n\\n## \uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30\\n\\n\uba3c\uc800 \uc81c\uac00 \uac1c\uc120\ud558\uae30 \uc704\ud574 \uc0ac\uc6a9\ud588\ub358 \ubc29\ubc95\ub4e4\uc5d0 \ub300\ud574 \uc801\uc5b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### DTO \uc774\uc6a9\ud558\uae30\\n\\n\ud604\uc7ac \uad6c\uc870\ub294 \uc544\ub798\uc758 JPA\ub97c \uc774\uc6a9\ud574 \uc544\ub798\uc640 \uac19\uc740 \ucffc\ub9ac\ub85c entity\ub85c \ub370\uc774\ud130\ub97c \uc870\ud68c\ud569\ub2c8\ub2e4.\\n\\n```sql\\n select distinct station.station_id,\\n charger.charger_id,\\n charger.station_id,\\n chargerStatus.charger_id,\\n chargerStatus.station_id,\\n station.created_at,\\n station.updated_at,\\n station.address,\\n station.company_name,\\n station.contact,\\n station.detail_location,\\n station.is_parking_free,\\n station.is_private,\\n station.latitude,\\n station.longitude,\\n station.operating_time,\\n station.private_reason,\\n station.station_name,\\n station.station_state,\\n charger.created_at,\\n charger.updated_at,\\n charger.capacity,\\n charger.method,\\n charger.price,\\n charger.type,\\n charger.station_id,\\n charger.charger_id,\\n chargerStatus.created_at,\\n chargerStatus.updated_at,\\n chargerStatus.charger_condition,\\n chargerStatus.latest_update_time\\n from charge_station station\\n inner join\\n charger charger on station.station_id = charger.station_id\\n inner join\\n charger_status chargerStatus on charger.charger_id = chargerStatus.charger_id\\n and charger.station_id = chargerStatus.station_id\\n where station.latitude >= 37.5019194727953082567\\n and station.latitude <= 37.5092305272047217433\\n and station.longitude >= 127.044542269049714936\\n and station.longitude <= 127.058071330950285064\\n\\n```\\n\\nJPA\ub97c \ud1b5\ud574 \uc774\ub7ec\ud55c \ubc29\uc2dd\uc73c\ub85c \uc870\ud68c\ud55c\ub2e4\uba74 \uc544\uc8fc \ud3b8\ud558\uac8c \uac12\uc744 \uac00\uc838\uc624\uace0, fetch join\uc744 \ud1b5\ud574 \ud558\uc704\uc758 entity\ub4e4\uc758 \uc815\ubcf4\ub3c4 \uae54\ub054\ud558\uac8c \uac00\uc838\uc635\ub2c8\ub2e4.\\n\\n\uac00\uc838\uc628 \uac12\uc73c\ub85c \ud544\uc694\ud55c \uc815\ubcf4\ub4e4\uc744 \ub9e4\ud551\ud558\uace0 \uac00\uacf5\ud558\uc5ec \uc751\ub2f5\uc744 \ub0b4\ub824\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc870\ud68c\ub9cc\uc744 \uc704\ud574 JPA\uc758 entity\ub97c \uc870\ud68c\ud55c\ub2e4\ub294 \uac83\uc740 \uc5ec\ub7ec \ub2e8\uc810\uc774 \uc874\uc7ac\ud569\ub2c8\ub2e4.\\n\\n\uc81c\uc77c \uba3c\uc800 \uc751\ub2f5\uc744 \ub0b4\ub824\uc904 \ub54c \ubd88\ud544\uc694\ud55c \ub370\uc774\ud130\uae4c\uc9c0 \ubaa8\ub450 \uc870\ud68c\ub97c \ud55c\ub2e4\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ub9ce\uc740 \ud544\ub4dc\ub4e4\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc751\ub2f5\uc5d0\uc11c\ub294 \ub300\ubd80\ubd84\uc758 \uacbd\uc6b0 \ubaa8\ub4e0 \uc815\ubcf4\uac00 \ud544\uc694\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ubaa8\ub4e0 \uc815\ubcf4\ub97c \ub2e4 \ubcf4\ub0b4\uc8fc\ub294 \uac83\ub3c4 \uc88b\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud560 \ub54c\uc758 \uc131\ub2a5\uc774 \uc544\uc8fc \ub098\ube60\uc9d1\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud544\uc694\ud55c \uce7c\ub7fc\ub9cc \uc870\ud68c\ud558\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub610 \ub2e4\ub978 \ub2e8\uc810\uc73c\ub85c\ub294 JPA\ub85c entity\ub97c \uc870\ud68c\ud560 \ub54c Hibernate \uce90\uc2dc\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub358\uac00, One To One \uc5d0\uc11c N+1 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud558\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc801\uc778 \uc774\uc288\uac00 \uc5ec\ub7ec\uac00\uc9c0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc870\ud68c\ub9cc \ud558\ub294 api\ub77c\uba74 DTO Projection\uc73c\ub85c \ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \uc544\ub798\uc640 \uac19\uc774 \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n```sql\\nSELECT s.station_id,\\n s.station_name,\\n s.latitude,\\n s.longitude,\\n s.is_parking_free,\\n s.is_private,\\n sum(case\\n when cs.charger_condition = \'STANDBY\' then 1\\n else 0\\n end),\\n sum(case\\n when c.capacity >= 50 then 1\\n else 0\\n end)\\nFROM charge_station s\\n inner join charger c on (c.station_id = s.station_id)\\n inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)\\nwhere s.station_id in (?, ?)\\ngroup by s.station_id;\\n```\\n\\n\uc774\ub807\uac8c \ud544\uc694\ud55c \uce7c\ub7fc\ub9cc \uc870\ud68c\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \ubcc0\uacbd\ud558\uc5ec, \uc120\ub989\uc5ed \uadfc\ucc98\ub97c \uc870\ud68c\ud558\ub294 \uae30\uc900\uc73c\ub85c \uc57d 450ms -> 350ms\ub85c \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc544\uc9c1\ub3c4 \ub108\ubb34 \ub290\ub9b0 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub798\uc11c \uc2e4\ud589 \uacc4\ud68d\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \uc2e4\ud589 \uacc4\ud68d \ud655\uc778\ud558\uae30\\n\\nsql\uc758 \uc2e4\ud589 \uacc4\ud68d\uc740 \uc544\uc8fc \uc911\uc694\ud558\uace0 \uc131\ub2a5\uc744 \uac1c\uc120\ud560 \ub54c \uc544\uc8fc \uc720\uc6a9\ud569\ub2c8\ub2e4.\\n\\n\uc2e4\ud589 \uacc4\ud68d\uc5d0\ub294 \uc5ec\ub7ec\uac00\uc9c0 \uc815\ubcf4\ub4e4\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n1. **ID**: \uc2e4\ud589 \uacc4\ud68d \ub0b4\uc5d0\uc11c \uac01 \uc791\uc5c5 \ub610\ub294 \ub2e8\uacc4\ub97c \uc2dd\ubcc4\ud558\ub294 \uc77c\ub828\ubc88\ud638\uc785\ub2c8\ub2e4. \uc2e4\ud589 \uacc4\ud68d\uc740 \uc5ec\ub7ec \ub2e8\uacc4\ub85c \ub098\ub258\uba70, ID\ub97c \ud1b5\ud574 \uc774\ub7ec\ud55c \ub2e8\uacc4\ub97c \uc2dd\ubcc4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n2. **Select Type**: \ucffc\ub9ac\uc758 \uac01 \ub2e8\uacc4(\uc608: SIMPLE, PRIMARY, SUBQUERY)\uc5d0 \ub300\ud55c \uc2e4\ud589 \uc720\ud615\uc744 \ub098\ud0c0\ub0c5\ub2c8\ub2e4. \uc774\ub294 MySQL\uc774 \ub370\uc774\ud130\ub97c \uc120\ud0dd\ud558\uace0 \ucc98\ub9ac\ud558\ub294 \ubc29\uc2dd\uc744 \ub098\ud0c0\ub0c5\ub2c8\ub2e4.\\n\\n3. **Table**: \uc2e4\ud589 \uacc4\ud68d\uc5d0 \ud3ec\ud568\ub41c \ud14c\uc774\ube14\uc758 \uc774\ub984 \ub610\ub294 \ubcc4\uce6d\uc785\ub2c8\ub2e4. \uc5b4\ub5a4 \ud14c\uc774\ube14\uc774 \uc0ac\uc6a9\ub418\ub294\uc9c0\ub97c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n4. **Type**: \ud14c\uc774\ube14 \uc811\uadfc \ubc29\uc2dd\uc744 \ub098\ud0c0\ub0c5\ub2c8\ub2e4. \uc774 \uac12\uc740 \uc778\ub371\uc2a4 \uc2a4\uce94, \ud480 \ud14c\uc774\ube14 \uc2a4\uce94 \ub4f1\uacfc \uac19\uc740 \uac12\uc77c \uc218 \uc788\uc73c\uba70, \uc131\ub2a5\uc5d0 \ud070 \uc601\ud5a5\uc744 \ubbf8\uce69\ub2c8\ub2e4.\\n\\n5. **Possible Keys**: \uc0ac\uc6a9 \uac00\ub2a5\ud55c \uc778\ub371\uc2a4\ub97c \ub098\ud0c0\ub0c5\ub2c8\ub2e4. MySQL\uc774 \uc5b4\ub5a4 \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294\uc9c0 \uc54c\ub824\uc90d\ub2c8\ub2e4.\\n\\n6. **Key**: \uc2e4\uc81c\ub85c \uc120\ud0dd\ub41c \uc778\ub371\uc2a4\uc785\ub2c8\ub2e4. \uc774 \uac12\uc740 \uac00\ub2a5\ud55c \uc778\ub371\uc2a4 \uc911\uc5d0\uc11c \uc2e4\uc81c\ub85c \uc0ac\uc6a9\ub418\ub294 \uc778\ub371\uc2a4\ub97c \ub098\ud0c0\ub0c5\ub2c8\ub2e4.\\n\\n7. **Key Len**: \uc0ac\uc6a9\ub41c \uc778\ub371\uc2a4\uc758 \uae38\uc774\ub97c \ub098\ud0c0\ub0c5\ub2c8\ub2e4.\\n\\n8. **Ref**: \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc5ec \ud14c\uc774\ube14 \uac04\uc758 \uc5f0\uacb0\uc744 \ub098\ud0c0\ub0b4\ub294 \uc5f4\uc785\ub2c8\ub2e4.\\n\\n9. **Rows**: \uac01 \ub2e8\uacc4\uc5d0\uc11c \uc608\uc0c1\ub418\ub294 \ud589\uc758 \uc218\uc785\ub2c8\ub2e4. \uc774 \uac12\uc740 \uc131\ub2a5 \ud3c9\uac00\uc5d0 \uc911\uc694\ud55c \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n10. **Extra**: \uae30\ud0c0 \uc815\ubcf4\ub97c \uc81c\uacf5\ud569\ub2c8\ub2e4. \uc774 \uce7c\ub7fc\uc5d0\ub294 \ucd94\uac00 \uc815\ubcf4 \ubc0f \ud78c\ud2b8\uac00 \ud3ec\ud568\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \uc5ec\ub7ec \uce7c\ub7fc\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uadf8 \uc911 \uc131\ub2a5\uc5d0 \ud070 \uc601\ud5a5\uc744 \ubbf8\uce58\ub294 \uce7c\ub7fc \ub450 \uac00\uc9c0\ub9cc \uc790\uc138\ud788 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### Type\\n1. **const** : \ucffc\ub9ac\uc5d0 Primary key \ud639\uc740 unique key \uce7c\ub7fc\uc744 \uc774\uc6a9\ud558\ub294 where \uc870\uac74\uc808\uc744 \uac00\uc9c0\uace0 \uc788\uace0, \ubc18\ub4dc\uc2dc \ud558\ub098\uc758 \ub370\uc774\ud130\ub97c \ubc18\ud658\ud558\ub294 \ubc29\uc2dd\uc774\ub2e4. (\uc635\ud2f0\ub9c8\uc774\uc800\uac00 \ud574\ub2f9 \ubd80\ubd84\uc740 \uc0c1\uc218\ub85c \ucc98\ub9ac\ud558\uae30 \ub54c\ubb38\uc5d0 const\ub77c\uace0 \ud55c\ub2e4.)\\n2. **eq_ref** : \uc870\uc778\uc5d0\uc11c Primary key \ud639\uc740 unique key \uce7c\ub7fc\uc744 \uc774\uc6a9\ud558\ub294 where \uc870\uac74\uc808\uc744 \uac00\uc9c0\uace0 \uc788\uace0, \ubc18\ub4dc\uc2dc \ud558\ub098\uc758 \ub370\uc774\ud130\ub97c \ubc18\ud658\ud558\ub294 \ubc29\uc2dd\uc774\ub2e4. (const\uc640 \ub2e4\ub978 \uc810\uc740 eq_ref\ub294 \uc870\uc778\uc5d0\uc11c \uc0ac\uc6a9\ub41c\ub2e4\ub294 \uc810\uc774\ub2e4.)\\n3. **ref** : eq_ref\uc640 \ub2e4\ub974\uac8c join\uc758 \uc21c\uc11c\uc640 \uad00\uacc4\uc5c6\uc774 \uc0ac\uc6a9\ub41c\ub2e4. \uadf8\ub9ac\uace0 primary key, unique key\ub3c4 \uad00\uacc4\uc5c6\ub2e4. \uadf8\ub0e5 \uc778\ub371\uc2a4\uc758 \uc885\ub958\uc640 \uad00\uacc4\uc5c6\uc774 `=` \uc870\uac74\uc73c\ub85c \uac80\uc0c9\ud560 \ub54c \uc0ac\uc6a9\ub41c\ub2e4\\n4. **fulltext**: mysql \uc804\ubb38 \uac80\uc0c9 \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud574\uc11c \ub808\ucf54\ub4dc\uc5d0 \uc811\uadfc\ud558\ub294 \ubc29\ubc95, \uc804\ubb38 \uac80\uc0c9\ud560 \uceec\ub7fc\uc5d0 \uc778\ub371\uc2a4\uac00 \uc788\uc5b4\uc57c \ud55c\ub2e4. \\"MATCH ... AGAINST ...\\" \uad6c\ubb38\uc744 \uc0ac\uc6a9\ud574\uc11c \uc2e4\ud589\ub41c\ub2e4\\n5. **range**: \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574\uc11c \uac80\uc0c9\ud558\ub294\ub370, \uac80\uc0c9 \uc870\uac74\uc774 `>, >=, <, <=, BETWEEN, IN()` \ub4f1\uc758 \uc5f0\uc0b0\uc790\ub97c \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0\uc774\ub2e4. \ubcf4\ud1b5\uc758 \uc778\ub371\uc2a4 \uc2a4\uce94\uc774\ub77c\uace0 \ud558\uba74 range, const, ref\ub97c \uce6d\ud55c\ub2e4\\n6. **index**: \uc778\ub371\uc2a4 \ud480 \uc2a4\uce94\uc774\ub2e4. \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574\uc11c \ud14c\uc774\ube14\uc758 \ubaa8\ub4e0 \ub808\ucf54\ub4dc\ub97c \uc77d\ub294\ub2e4. \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574\uc11c \ud14c\uc774\ube14\uc744 \uc77d\ub294 \uac83\uc774\uae30 \ub54c\ubb38\uc5d0 all\ubcf4\ub2e4\ub294 \ube60\ub974\ub2e4.\\n7. **all**: \ud14c\uc774\ube14 \ud480 \uc2a4\uce94\uc774\ub2e4. \ud14c\uc774\ube14\uc758 \ubaa8\ub4e0 \ub808\ucf54\ub4dc\ub97c \uc77d\ub294\ub2e4. \uac00\uc7a5 \ub290\ub9b0 \ubc29\ubc95\uc774\ub2e4.\\n\\n\uc2e4\ud589 \uacc4\ud68d\uc5d0\uc11c \uc790\uc8fc \ubcf4\uc774\ub294 type\ub4e4\ub9cc **\uc131\ub2a5\uc774 \uc88b\uc740 \uc21c**\uc73c\ub85c \uc815\ub9ac\ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n### Extra\\n1. **using filesort**: \uc815\ub82c\uc744 \uc704\ud574 \ubcc4\ub3c4\uc758 \ud30c\uc77c \uc815\ub82c\uc744 \uc218\ud589\ud55c\ub2e4. \uc774\ub294 \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \uc815\ub82c\uc744 \uc218\ud589\ud55c\ub2e4\ub294 \uc758\ubbf8\uc774\ub2e4. \uc774\ub294 \uc131\ub2a5\uc5d0 \uc88b\uc9c0 \uc54a\ub2e4.\\n2. **using index**: \uc778\ub371\uc2a4\ub9cc\uc73c\ub85c \ucffc\ub9ac\ub97c \ucc98\ub9ac\ud55c\ub2e4. \uc774\ub294 \uc778\ub371\uc2a4\ub9cc\uc73c\ub85c \ucffc\ub9ac\ub97c \ucc98\ub9ac\ud558\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc774 \uc88b\ub2e4.\\n3. **using join** buffer: join\uc774 \ub418\ub294 \uce7c\ub7fc\uc740 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4. \ud558\uc9c0\ub9cc driven table\uc5d0 \uc801\uc808\ud55c \uc778\ub371\uc2a4\uac00 \uc5c6\ub2e4\uba74 driving table\uc5d0 \uc788\ub294 \ubaa8\ub4e0 \ub808\ucf54\ub4dc\ub97c \uc77d\uc5b4\uc11c join\uc744 \uc218\ud589\ud55c\ub2e4. \uadf8\ub798\uc11c \uc774\uac78 \ubcf4\uc644\ud558\uae30 \uc704\ud574 driving table\uc5d0 \uc77d\uc740 \ub808\ucf54\ub4dc\ub97c \uc784\uc2dc \uacf5\uac04\uc5d0 \uc800\uc7a5\ud558\ub294\ub370 \uadf8 \uacf3\uc774 join buffer\uc774\ub2e4.\\n4. **using temporary**: \ucffc\ub9ac\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud574 \uc784\uc2dc \ud14c\uc774\ube14\uc744 \uc0dd\uc131\ud55c\ub2e4. \uc778\ub371\uc2a4\ub97c \uc0ac\uc6a9\ud558\uc9c0 \ubabb\ud558\ub294 group by \ucffc\ub9ac\uac00 \ub300\ud45c\uc801\uc778 \uc608\uc774\ub2e4.\\n5. **using where**: mysql \uc5d4\uc9c4\uc774 \ubcc4\ub3c4\uc758 \uac00\uacf5, \ud544\ud130\ub9c1 \uc791\uc5c5\uc744 \ucc98\ub9ac\ud55c \uacbd\uc6b0\uc77c \ub54c\ub9cc \ub098\ud0c0\ub09c\ub2e4. \ubc94\uc704 \uc870\uac74\uc740 \uc2a4\ud1a0\ub9ac\uc9c0 \uc5d4\uc9c4\uc5d0\uc11c \ucc98\ub9ac\ub418\uc5b4 \ub808\ucf54\ub4dc\ub97c \ub9ac\ud134\ud574\uc8fc\uc9c0\ub9cc, \uccb4\ud06c \uc870\uac74\uc740 mysql \uc5d4\uc9c4\uc5d0\uc11c \ucc98\ub9ac\ub41c\ub2e4.\\n\\n\\ntype\ubfd0\ub9cc \uc544\ub2c8\ub77c extra\ub3c4 \ucffc\ub9ac\uc758 \ubb38\uc81c\ub97c \ud30c\uc545\ud558\ub294\ub370 \uc544\uc8fc \ud070 \ub3c4\uc6c0\uc744 \uc90d\ub2c8\ub2e4. \uadf8 \uc911 \uc790\uc8fc \ubcf4\uc774\ub294 \uac83\ub4e4\uc5d0 \ub300\ud574\uc11c\ub9cc \uc815\ub9ac\ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc544\uae4c \uc0dd\uc131\ud55c \ucffc\ub9ac\uc758 \uc2e4\ud589 \uacc4\ud68d\uc744 \ud655\uc778\ud574\ubd05\uc2dc\ub2e4.\\n```\\n+----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+\\n| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |\\n+----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+\\n| 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where; Using temporary |\\n| 1 | SIMPLE | charger | NULL | ALL | PRIMARY | NULL | NULL | NULL | 240340 | 10.00 | Using where; Using join buffer (hash join) |\\n| 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |\\n+----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+\\n```\\n\\nstation \ud14c\uc774\ube14\uc5d0 \ub300\ud574\uc11c\ub294 range \uc2a4\uce94, \uc784\uc2dc \ud14c\uc774\ube14\uc744 \uc0dd\uc131\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4, \uadf8\ub9ac\uace0 charger\uc5d0\uc11c\ub294 \ud14c\uc774\ube14 \ud480 \uc2a4\uce94, join buffer\uae4c\uc9c0 \uc0dd\uc131\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \ub2e4\ud589\ud788\ub3c4 chargersta \ud14c\uc774\ube14\uc5d0\uc11c\ub294 \uc801\ub2f9\ud55c \uc870\uac74\uc744 \uc0dd\uc131\ud55c \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\uc2dc \ud55c\ubc88 \ucffc\ub9ac\ub97c \ubcf4\uace0 \uc2e4\ud589 \uacc4\ud68d\uc774 \uc774\ub807\uac8c \ub098\uc628 \uc774\uc720\ub97c \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```sql\\nSELECT\\n ...\\n FROM charge_station s\\n inner join charger c on (c.station_id = s.station_id)\\n inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)\\nwhere s.station_id in (?, ?)\\ngroup by s.station_id;\\n```\\n\\n\uc544\uae4c \uc598\uae30\ud588\ub358, using temporary\uc640 using join buffer\uac00 \ubc1c\uc0dd\ud558\ub294 \uc774\uc720\uc758 \uacf5\ud1b5\uc810\uc744 \ucc3e\uc544\ubcf4\uba74, \uc778\ub371\uc2a4\uac00 \ubb38\uc81c\uc778 \uac83\uc744 \uc720\ucd94\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nstation\uacfc charger\ub97c join\ud560 \ub54c, driven table \uc989, charger \ud14c\uc774\ube14\uc5d0 \uc801\uc808\ud55c \uc778\ub371\uc2a4\uac00 \uc5c6\uc5b4 \uc131\ub2a5\uc774 \ub098\ube60\uc9c4 \uac83\uc774\ub77c \uc758\uc2ec\ud558\uc5ec, \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud558\uace0 \ub2e4\uc2dc \ud55c\ubc88 \uc2e4\ud589 \uacc4\ud68d\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```\\n+----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+\\n| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |\\n+----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+\\n| 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where |\\n| 1 | SIMPLE | charger | NULL | ref | PRIMARY,idx_station_id | idx_station_id | 1022 | charge.s.station_id | 3 | 100.00 | NULL |\\n| 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |\\n+----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+\\n```\\n\\n\uc774\ub807\uac8c charger \ud14c\uc774\ube14\uc5d0 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud55c \uac83\ub9cc\uc73c\ub85c\ub3c4 \uc2e4\ud589 \uacc4\ud68d\uc744 \uae54\ub054\ud558\uac8c \uac1c\uc120\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \uacb0\uacfc\\n\uc544\ub798\ub294 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud558\uae30 \uc804 \uc2e4\ud589 \uc18d\ub3c4\uc785\ub2c8\ub2e4.\\n\\n![\uac1c\uc120_\uc804](https://github.com/woowacourse-teams/2023-car-ffeine/assets/106640954/1130eee6-c2b9-4846-b294-73de78b0f070)\\n\\n\uc544\ub798\ub294 \uc778\ub371\uc2a4\ub97c \uc0dd\uc131\ud55c \ud6c4 \uc2e4\ud589 \uc18d\ub3c4\uc785\ub2c8\ub2e4.\\n\\n![\uac1c\uc120_\ud6c4](https://github.com/woowacourse-teams/2023-car-ffeine/assets/106640954/d024330a-c233-4e75-a28b-1b01b6ae3245)\\n\\n315ms -> 24ms \ub85c \uc57d 13\ubc30 \ube68\ub77c\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\n**\uc2e4\ud589 \uacc4\ud68d \ud655\uc778\uc740 \ud544\uc218\uc785\ub2c8\ub2e4!**\\n\\n### \ucc38\uace0\\nreal mysql \ucc45"},{"id":"30","metadata":{"permalink":"/30","source":"@site/blog/2023-08-31-love-my-team.mdx","title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","description":"\ub808\ubca83 \ub54c \ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c, \uc800\ud76c \ud300\uc740 \ub9ce\uc740 \ud611\uc5c5\uc744 \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.","date":"2023-08-31T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 31\uc77c","tags":[],"readingTime":2.895,"hasTruncateMarker":false,"authors":[],"frontMatter":{"slug":"30","title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","authors":[],"tags":[]},"prevItem":{"title":"\uc870\ud68c \uc131\ub2a5 \uac1c\uc120\ud558\uae30","permalink":"/31"},"nextItem":{"title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","permalink":"/29"}},"content":"\ub808\ubca83 \ub54c \ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c, \uc800\ud76c \ud300\uc740 \ub9ce\uc740 \ud611\uc5c5\uc744 \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ucc98\uc74c\uc5d0\ub294 \ud504\ub860\ud2b8\uc5d4\ub4dc, \ubc31\uc5d4\ub4dc \uc11c\ub85c \uac01\uac01\uc758 \ubd84\uc57c\ub9cc \uac1c\ubc1c\uc744 \ud574\uc654\uace0 \ud611\uc5c5\uc774 \uc775\uc219\ud558\uc9c0 \uc54a\uc544\uc11c \ub9ce\uc740 \ubd80\ubd84\uc5d0\uc11c \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uace4 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc5d0\uc11c \uc800\ud76c \ud300\uc740 \uc5b4\ub5bb\uac8c \ub300\ucc98\ub97c \ud588\uc744\uae4c\uc694?\\n\\n\ud55c \uac00\uc9c0 \uc77c\ud654\ub85c \uc800\ud76c \ud300\uc758 \uc81c\uc774\uc640 \uc13c\ud2b8\uc758 \ud544\ud130 \uc801\uc6a9 \ubd80\ubd84\uc744 \uc124\uba85 \ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc870\ud68c \uc2dc\uc5d0 \ud544\ud130 \uc801\uc6a9 \ubd80\ubd84\uc744 \ub9cc\ub4e4 \ub54c \uae30\uc874\uc5d0 \uc791\uc131\ud574\ub454 API \uba85\uc138\ub300\ub85c \uc11c\ub85c \uc791\uc5c5\uc744 \uc9c4\ud589\ud558\uace0, \uc911\uac04\uc5d0 \uc0dd\uac01\ud558\uc9c0 \ubabb\ud55c \ubd80\ubd84\uc5d0 \ub300\ud574\uc11c\ub294 \uc11c\ub85c \ub300\ud654\ub97c \ub9ce\uc774 \ud588\uc2b5\ub2c8\ub2e4.\\n\ub300\ud654\ub97c \ud558\uba74\uc11c \uc9c4\ud589\uc744 \ud588\uc9c0\ub9cc \ubc1c\uacac\ud558\uc9c0 \ubabb\ud55c \ubb38\uc81c\uc810\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubc14\ub85c \ucda9\uc804\uc18c \ud68c\uc0ac \uba85\uc5d0\uc11c key \uac12\uc744 \uc5b4\ub5bb\uac8c \ud558\ub0d0\uc5d0 \ubb38\uc81c\uc600\uc2b5\ub2c8\ub2e4.\\n\uc608\ub97c \ub4e4\uba74 \ucda9\uc804\uc18c \ud68c\uc0ac \uba85\uc5d0\uc11c `\uad11\uc8fc\uc2dc`\ub77c\ub294 \uc774\ub984\uc774 \uc788\uc5c8\ub294\ub370, \uc774 \ud544\ud130\ub294 \uc2e4\uc81c\ub85c \ub450 \uac00\uc9c0\uac00 \uc874\uc7ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\ub098\ub294 \uacbd\uae30\ub3c4 \uad11\uc8fc, \ud558\ub098\ub294 \uc804\ub77c\ub3c4 \uad11\uc8fc\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubd80\ubd84\uc5d0\uc11c \ubd88\ud544\uc694\ud55c \uc9c0\uc5ed\uc758 \ud544\ud130\uae4c\uc9c0 \uac78\ub9ac\uac8c \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\ud611\uc5c5\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uc774\ub97c \ubc1c\uacac\ud588\uace0, \uc989\uac01 \uc870\uce58\ub97c \ucde8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc870\uce58\ub97c \ucde8\ud560 \ub54c \uc11c\ub85c\uc5d0\uac8c \uac01\uc790 \ud3b8\ud55c \ubc29\ubc95\uc774 \uc788\uc5c8\uc9c0\ub9cc,\\n\ub2e8\uc21c\ud788 \uc11c\ub85c\uc5d0\uac8c \ud3b8\ud55c \uc791\uc5c5\uc744 \ud558\uc9c0 \uc54a\uc558\uace0, \ud300\uc6d0\uacfc \uc0c1\uc758\ud558\uba74\uc11c \ucd94\ud6c4 \uc9c4\ud589\uc5d0 \ubb38\uc81c \uc5c6\ub294 \ubc29\ud5a5\uc744 \ucc3e\uace0 \uc9c4\ud589\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\uae08 \uc0dd\uac01\ud574\ubcf4\uba74 \ub9cc\uc57d \uac01\uc790\uc5d0\uac8c \ud3b8\ud55c \ubc29\uc2dd\uc73c\ub85c \ubb38\uc81c\ub97c \uc218\uc815\ud588\ub2e4\uba74, \ub2e4\ub978 \ud300\uc6d0\uc774 \ub2e4\ub978 \uc791\uc5c5\uc744 \ud560 \ub54c \uc9c0\uc7a5\uc774 \uac14\uc744 \uc218\ub3c4 \uc788\uace0 \ubd88\ud544\uc694\ud55c \uc791\uc5c5\uc744 \ud588\uc744 \uc218\ub3c4 \uc788\uc5c8\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc2dc\uc810\uc744 \uacc4\uae30\ub85c \uc800\ud76c \ud300\ub07c\ub9ac \uc608\uc0c1\ud558\uc9c0 \ubabb\ud55c \ubb38\uc81c\ub97c \uc791\uc5c5 \uc911\uc5d0 \ubc1c\uacac\ud558\ub354\ub77c\ub3c4 \ub2e4\ub978 \ud300\uc6d0\uc5d0\uac8c \uacf5\uc720\ud558\uace0 \uc11c\ub85c \uc9e7\uc740 \ud68c\uc758\ub97c \ud1b5\ud574 \ubb38\uc81c \ud574\uacb0 \ubc29\uc548\uc744 \uac19\uc774 \ucc3e\ub294 \uac83\uc774 \uc790\uc5f0\uc2a4\ub7fd\uac8c \ud300\ubb38\ud654\ub85c \uc790\ub9ac \uc7a1\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4."},{"id":"29","metadata":{"permalink":"/29","source":"@site/blog/2023-08-25-external-state/index.mdx","title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","description":"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub3c4\uc640 React\ub97c \uacb0\ud569\uc744 \ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4.","date":"2023-08-25T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 25\uc77c","tags":[{"label":"useSyncExternalStore","permalink":"/tags/use-sync-external-store"},{"label":"\uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac","permalink":"/tags/\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac"},{"label":"\uc804\uc5ed\uc0c1\ud0dc","permalink":"/tags/\uc804\uc5ed\uc0c1\ud0dc"}],"readingTime":10.165,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"29","title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","authors":["gabriel"],"tags":["useSyncExternalStore","\uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac","\uc804\uc5ed\uc0c1\ud0dc"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \ud611\uc5c5 \uc77c\ud654","permalink":"/30"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","permalink":"/28"}},"content":"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub3c4\uc640 React\ub97c \uacb0\ud569\uc744 \ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud504\ub85c\uc81d\ud2b8 \ucd08\uae30\uc5d0\ub294 Google Maps API\ub97c React DOM\uc774 \uc544\ub2cc, \ubc14\ub2d0\ub77c JS\uc758 \uc601\uc5ed\uc5d0\uc11c \ub2e4\ub8e8\uae30\ub97c \ud76c\ub9dd\ud558\uc600\uace0, \uc5ec\ub7ec \ud14c\uc2a4\ud2b8 \uacb0\uacfc \ub450 \uc601\uc5ed\uc744 \ubd84\ub9ac\ud558\ub294 \uac83\uc740 \uc131\uacf5\uc801\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\nReact\ub294 \uadf8\uc800 \ubd80\ucc29 \ub2f9\ud560 DOM\uc744 \uc678\ubd80(Google Maps API)\ub85c \ub0b4\uc5b4\uc8fc\ub294 \uae30\ub2a5\uc5d0 \ubd88\uacfc\ud558\uc600\uace0, \uc9c0\ub3c4\uc640 React\uac00 \uc11c\ub85c \ud611\ub825 \ud574\uc57c\ud560 \ub54c\ub9cc \uc5f0\ub77d\uc744 \ud558\ub294 \uad6c\uc870\ub97c \ucde8\ud558\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uba74, React UI\ub294 UI\ub300\ub85c \ub3d9\uc791\ud558\uace0, \uc9c0\ub3c4\ub294 \uc9c0\ub3c4 \ub300\ub85c \ub3d9\uc791\ud558\ub2e4\uac00 \uc5b4\ub290 \uc21c\uac04\uc5d0\ub9cc \uc11c\ub85c\uac00 \uc11c\ub85c\ub97c \uc870\uc791\ud560 \uc218 \uc788\uc73c\uba74 \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \uac00\ub2a5\ud558\uac8c \ud558\ub294 \uae30\uc220\ub85c useSyncExternalStore\ub97c \uc120\uc815\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4. \uc774 \ud6c5\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\uc81c \ube14\ub85c\uadf8](https://leirbag.tistory.com/144)\ub098 [\uacf5\uc2dd\ubb38\uc11c](https://react.dev/reference/react/useSyncExternalStore)\uc5d0 \ub098\uc640\uc788\uc73c\ubbc0\ub85c \uc124\uba85\uc744 \uac04\ub7b5\ud788 \ud558\uc790\uba74 useSyncExternalStore\ub294 React DOM \ub0b4\ubd80\uac00 \uc544\ub2cc \uc678\ubd80 \uc800\uc7a5\uc18c(JS)\uc5d0\uc11c React DOM\uc744 \uc870\uc791\ud560 \uc218 \uc788\ub3c4\ub85d \ud558\ub294 \ucee4\uc2a4\ud140 \ud6c5\uc785\ub2c8\ub2e4.\\n\\n![no offset](./0-1.png)\\n\\n\uc774 \ud6c5\uc740 React 18\uc5d0 \ucd9c\uc2dc\ub418\uc5c8\uc73c\uba70, \uc678\ubd80 \uc800\uc7a5\uc18c\uc640 React\uc758 \uc18c\ud1b5\uc744 \uc6d0\ud65c\ud558\uac8c \ub3d5\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \ud65c\uc6a9\ud558\uae30 \uc801\uc808\ud558\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4. \uc774 \uae30\ub2a5\uc744 \uc5b4\ub5bb\uac8c \ud558\uba74 \ub354 \ud6a8\uc728\uc801\uc778 \ubc29\ubc95\uc73c\ub85c \uc7ac\uc0ac\uc6a9\ud560 \uc218 \uc788\uc744\uc9c0 \uace0\ubbfc\ud558\uc600\uace0, \uc5ec\ub7ec \ucd94\uc0c1\ud654 \ub2e8\uacc4\ub97c \uac70\uccd0 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc218\uc900\uc73c\ub85c \uc81c\uc791\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ud6c4\uc5d0 TanStack Query\ub97c \ub3c4\uc785\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uac01\uc885 \uae30\ub2a5\uc774 React Component \ub0b4\uc5d0\uc11c\ub9cc \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub3c4\ub85d \uac15\uc81c\ub418\uc5c8\uace0, \ub530\ub77c\uc11c \ub354\uc774\uc0c1 \uc9c0\ub3c4 API\ub97c \ubc14\ub2d0\ub77cJS \uc601\uc5ed\uc5d0\uc11c \ub2e4\ub8f0 \uc218 \uc5c6\uc5b4 React DOM\uc73c\ub85c \uc774\uc2dd \ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./0-2.png)\\n\\n\uc774\ubbf8 \ub9cc\ub4e4\uc5b4 \ub454 \uae30\ub2a5\uc774 \ubd95 \ub5a0\ubc84\ub9b0 \uc0c1\ud669\uc774\uc5c8\uc9c0\ub9cc \uc5b4\ucc0c \ub410\ub4e0 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\uc5d0 \uc9c0\ub3c4 \uc778\uc2a4\ud134\uc2a4\ub97c \ub123\uc5b4\uc57c \ud558\ub294 \uc0c1\ud669\uc774\ub77c useSyncExternalStore\ub97c \ud504\ub85c\uc81d\ud2b8 \ub05d\uae4c\uc9c0 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\ub85c\uc368 \uc0ac\uc6a9\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc0c1\ud0dc \uad00\ub9ac \ud6c5\uc758 \ucd94\uc0c1\ud654 \uacfc\uc815\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n## **use-external-state \uad6c\uc131 \ubc0f \ub3d9\uc791 \uc6d0\ub9ac**\\n\\n**Store\ub294 \uc0c1\ud0dc \uad00\ub9ac \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4**\\n\\n\ubc14\uae65\uc5d0\uc11c \uc8fc\uc5b4\uc9c4 \ucd08\uae30 \uc0c1\ud0dc \uac12\uc740 StateManager\ub77c\ub294 \ud074\ub798\uc2a4\uc5d0 \uc804\ub2ec\ub429\ub2c8\ub2e4.\\n\\n![no offset](./1.png)\\n\\n```typescript\\nexport const store = (initialState: T) => {\\n const stateManager = new StateManager(initialState);\\n return stateManager;\\n};\\n```\\n\\n\ucd08\uae30 \uc0c1\ud0dc \uac12\uc744 \uc804\ub2ec\ubc1b\uc740 store \ud568\uc218\ub294 StateManager\ub77c\ub294 \uc5b4\ub5a4 \uc0c1\ud0dc \uad00\ub9ac \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud569\ub2c8\ub2e4.\\n\uc0dd\uc131\ub41c StateManager \uc778\uc2a4\ud134\uc2a4\uac00 \ubc18\ud658\ub418\uc5b4 store\uac00 \uace7 \ucd08\uae30 \uac12\uc744 \uac00\uc9c0\ub294 StateManager\uac00 \ub429\ub2c8\ub2e4.\\n\\n![no offset](./2.png)\\n\\n\uc608\ub97c \ub4e4\uc5b4, \ub2e4\uc74c\uacfc \uac19\uc740 \ucf54\ub4dc\uac00 \uc788\ub2e4\uace0 \ud560 \ub54c\\n\\n```typescript\\nexport const countStore = store(0);\\n```\\n\\ncountStore\ub294 \uace7 0\uc744 \ucd08\uae30\uac12\uc73c\ub85c \uac00\uc9c0\ub294 StateManager \uc778\uc2a4\ud134\uc2a4\uc774\uae30\ub3c4 \ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 StateManager\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### StateManager\ub294 react \ubc14\uae65\uc5d0 \uc788\ub294 \uc5b4\ub5a4 \uc800\uc7a5\uc18c\uc774\ub2e4.\\n\\n(\uadfc\ub370 \uc774\uac8c \uadf8\ub0e5 \uc800\uc7a5\uc18c\ub294 \uc544\ub2c8\uace0 \uc880 \ud2b9\ubcc4\ud55c \uc800\uc7a5\uc18c\ub2e4.)\\n\\n```typescript\\nexport type SetStateCallbackType = (prevState: T) => T;\\n\\nexport interface DataObserver {\\n setState: (param: SetStateCallbackType | T) => void;\\n getState: () => T;\\n subscribe: (listener: () => void) => () => void;\\n emitChange: () => void;\\n}\\n\\nclass StateManager implements DataObserver {\\n public state: T;\\n private listeners: Array<() => void> = [];\\n\\n constructor(initialState: T) {\\n this.state = initialState;\\n }\\n\\n setState = (param: SetStateCallbackType | T) => {\\n if (param instanceof Function) {\\n const newState = param(this.state);\\n this.state = newState;\\n } else {\\n this.state = param;\\n }\\n\\n this.emitChange();\\n };\\n\\n getState = () => {\\n return this.state;\\n };\\n\\n subscribe = (listener: () => void) => {\\n this.listeners = [...this.listeners, listener];\\n\\n return () => {\\n this.listeners = this.listeners.filter((l) => l !== listener);\\n };\\n };\\n\\n emitChange = () => {\\n for (const listener of this.listeners) {\\n listener();\\n }\\n };\\n}\\n\\nexport default StateManager;\\n```\\n\\nStateManager \ud074\ub798\uc2a4\ub294 \uc678\ubd80\uc5d0\uc11c \ubc1b\uc544\uc628 \ucd08\uae30\uac12\uc744 \uc0c1\ud0dc\ub85c \uac00\uc9d1\ub2c8\ub2e4.\\nsetState, getState, subscribe, emitChange\ub97c \uba54\uc11c\ub4dc\ub85c \uac00\uc9d1\ub2c8\ub2e4.\\n\uc5ec\uae30\uc11c \uc791\uc131\ub41c \ucf54\ub4dc\ub4e4\uc740 react\uc5d0\uc11c \uc678\ubd80 \uc800\uc7a5\uc18c\uc640 \uc18c\ud1b5\ud558\uae30 \uc704\ud55c [\ucd5c\uc18c\ud55c\uc758 \uaddc\uaca9](https://react.dev/reference/react/useSyncExternalStore#subscribing-to-an-external-store)\uc785\ub2c8\ub2e4.\\n\\n- subscribe: \ub2e8\uc77c \ucf5c\ubc31 \uc778\uc218\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc2a4\ud1a0\uc5b4\uc5d0 \uad6c\ub3c5\ud558\ub294 \ud568\uc218\uc785\ub2c8\ub2e4. \uc2a4\ud1a0\uc5b4\uac00 \ubcc0\uacbd\ub418\uba74 \uc81c\uacf5\ub41c \ucf5c\ubc31\uc744 \ud638\ucd9c\ud574\uc57c \ud569\ub2c8\ub2e4. \uadf8\ub7ec\uba74 \uad6c\uc131 \uc694\uc18c\uac00 \ub2e4\uc2dc \ub80c\ub354\ub9c1 \ub429\ub2c8\ub2e4. \uad6c\ub3c5 \uae30\ub2a5\uc740 \uad6c\ub3c5\uc744 \uc815\ub9ac\ud558\ub294 \uae30\ub2a5\uc744 \ubc18\ud658\ud574\uc57c \ud569\ub2c8\ub2e4. (\uad6c\ub3c5\uc5d0 \uad00\ub828\ub41c \ub370\uc774\ud130\ub294 \ub9ac\uc2a4\ub108 \ubc30\uc5f4 \ud544\ub4dc\uc5d0 \ub123\uc5b4\uc11c \uad00\ub9ac\ud569\ub2c8\ub2e4.)\\n\\n- emitChange: \ub9ac\uc2a4\ub108 \ubc30\uc5f4 \ud544\ub4dc\uc5d0 \ub2f4\uaca8\uc788\ub294 \ubaa8\ub4e0 \ub9ac\uc2a4\ub108\ub97c \uc2e4\ud589\ud569\ub2c8\ub2e4. \uc989, \uad6c\ub3c5\ub41c \uc5b4\ub5a4 \uac83\uc744 \uc21c\ucc28\uc801\uc73c\ub85c \uc2e4\ud589\ud558\uac8c \ud569\ub2c8\ub2e4. \uc774\ub294 \ub9ac\uc561\ud2b8 DOM\uc744 \uac15\uc81c\ub85c \uc77c\uae68\uc6cc\uc8fc\ub294 \uc635\uc800\ubc84 \ud328\ud134\uc758 \uc5ed\ud560\uc744 \ud558\uac8c \ub429\ub2c8\ub2e4. \uc774 \uacfc\uc815 \ub54c\ubb38\uc5d0 react DOM\uc774 \uc815\ud655\ud55c \uc7ac \ub80c\ub354\ub9c1 \uc9c0\uc810\uc744 \ud30c\uc545\ud560 \uc218 \uc788\uac8c\ub429\ub2c8\ub2e4. (\ucd5c\uc801\ud654 \ubb38\uc81c\uc5d0\uc11c \uc790\uc720\ub85c\uc6cc\uc9d0)\\n\\n- setState: \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8\ud569\ub2c8\ub2e4. \ub2e4\ub9cc \uc0c1\ud0dc\uac00 \uc5c5\ub370\uc774\ud2b8 \ub410\uc74c\uc744 \uc54c\ub824\uc57c \ud558\ubbc0\ub85c emitChange\ub97c \uc2e4\ud589\uc2dc\ucf1c react DOM\uc744 \uac15\uc81c\ub85c \ub3d9\uae30\ud654\uc2dc\ud0b5\ub2c8\ub2e4.\\n\\n- getState: \ud638\ucd9c\ub418\ub294 \uc21c\uac04 \ud604\uc7ac \uc0c1\ud0dc \uac12\uc744 \uc77d\uc2b5\ub2c8\ub2e4.\\n\\n\uc880 \uc5b4\ub835\uc9c0\ub9cc \ub9ac\uc561\ud2b8\uc5d0\uc11c \uc774\ub7f0 \uaddc\uaca9\uc744 \uac00\uc838\uc57c useSyncExternalStore\ud6c5\uc744 \uc4f8 \uc218 \uc788\uac8c \ud574 \uc90d\ub2c8\ub2e4.\\n\uae30\uc874 \uc608\uc81c\uc5d0\uc11c\ub294 \ub2e8\uc21c\ud55c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8 \uac1d\uccb4\ub85c \uc9dc\uc5ec\uc788\uc5c8\uc9c0\ub9cc \uc778\uc2a4\ud134\uc2a4\ub97c \uc790\uc720\ub86d\uac8c \ucc0d\uc5b4\ub0bc \uc218 \uc788\ub294 class \uad6c\uc870\ub85c \uac1c\uc120\ud558\uace0 \ucd94\uc0c1\ud654\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uc5ec\uae30\uae4c\uc9c0\ub9cc \uad6c\ud604\ud574\ub3c4 useSyncExternalStore\ub97c \uc0ac\uc6a9\ud558\ub294\ub370 \uc9c0\uc7a5\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\uc55e\uc11c \uc120\uc5b8\ud55c store\uac1d\uccb4\uc5d0\uc11c subscribe\uc640 getState\ub97c \uaebc\ub0b4\uc11c \uc9c1\uc811 \uc804\ub2ec\ud574 \uc8fc\uba74 \uadf8\ub9cc\uc774\uae30 \ub54c\ubb38\uc774\uc8e0.\\n\\n\ud558\uc9c0\ub9cc \uacb0\uad6d \uc774 \uacfc\uc815 \uc790\uccb4\uac00 \ubc18\ubcf5\ub41c \uc791\uc5c5\uc744 \uc694\uad6c\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n### \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc27d\uac8c \uc811\uadfc\ud558\ub3c4\ub85d \ucd9c\uad6c\ub97c \uc5f4\uc5b4\uc8fc\uc790!\\n\\n\ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c\ub294 \ubc14\ub2d0\ub77c JS\ub85c \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\ub294 \uac83\ubcf4\ub2e4\ub294 useState\uc640 \ube44\uc2b7\ud55c \ud615\ud0dc\ub85c \ud6c5\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \ud6e8\uc52c \ubcf4\uae30 \uae54\ub054\ud560 \uac83\uc785\ub2c8\ub2e4.\\n\ub9e4\ubc88 \uc2a4\ud1a0\uc5b4\uc5d0\uc11c \ubb34\uc5b8\uac00\ub97c \uc9c1\uc811 \uaebc\ub0b4\uc9c0 \uc54a\ub3c4\ub85d \ud558\ub294 \uc911\uac04 \ucee4\uc2a4\ud140 \ud6c5\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n```typescript\\nexport const useExternalState = (\\n store: DataObserver\\n): [T, (param: SetStateCallbackType | T) => void] => {\\n const { subscribe, getState, setState } = store;\\n const state = useSyncExternalStore(subscribe, getState);\\n\\n return [state, setState];\\n};\\n```\\n\\n\uc774 \ud6c5\uc740, \ubc14\uae65\uc5d0\uc11c \ubc1b\uc544\uc628 store\ub97c \ud65c\uc6a9\ud558\uc5ec \uad6c\ub3c5/\uc5c5\ub370\uc774\ud2b8 \uae30\ub2a5\uc744 \ubc30\uc5f4\ub85c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\ubaa8\uc2dd\ub3c4\ub97c \uadf8\ub824\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./3.png)\\n\\nReact \ucef4\ud3ec\ub10c\ud2b8\ub294 \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c store() \uac1d\uccb4\ub97c useExternalStore\uc5d0 \ub118\uaca8\uc8fc\uace0, \\\\[\uc0c1\ud0dc, \uc0c1\ud0dc\uc5c5\ub370\uc774\ud2b8\ud568\uc218\\\\]\ub97c \ubc1b\uac8c \ub429\ub2c8\ub2e4.\\n\ub9c8\uce58 \uae30\uc874\uc758 useState\ub098 useRecoilState\ucc98\ub7fc \ub9d0\uc774\uc8e0.\\n\\n\uc815\ub9ac\ud558\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\ud478\ub978 \uc601\uc5ed\uc740 React DOM\\n\ub179\uc0c9 \uc601\uc5ed\uc740 \uc9c1\uc811 \ud638\ucd9c\ud574\uc57c \ud558\ub294 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 \uc601\uc5ed (\ud558\uc9c0\ub9cc \ucd5c\ub300\ud55c \ub2e8\uc21c\ud55c \ud615\ud0dc\ub85c \uad6c\uc131\ud574\uc11c \uac1c\ubc1c\uc790\uc758 \ubd80\ub2f4\uc744 \ub35c\uc5b4\uc8fc\ub294 \ud615\ud0dc)\\n\ube68\uac04\uc0c9\uc740 \uac1c\ubc1c\uc790\uac00 \uc9c1\uc811 \uac74\ub4e4\uc9c0 \ubabb\ud558\uc9c0\ub9cc \uac04\uc811\uc801\uc73c\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uc601\uc5ed\\n\ub178\ub780\uc0c9\uc740 React 18 \uc5d4\uc9c4\uc758 \uc601\uc5ed\uc785\ub2c8\ub2e4.\\n\\n\uc774\uc678\uc5d0 \uc81c\uacf5\ub418\ub294 \ub2e4\ub978 \ucee4\uc2a4\ud140 \ud6c5\ub4e4\ub3c4 \uac70\uc758 \ube44\uc2b7\ud55c \uad6c\uc870\ub97c \ub744\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```typescript\\n// \ucd94\uac00\ub85c \uad6c\ud604\ud560 \uc218 \uc788\ub294 \ud568\uc218\ub4e4\\n\\nexport const useSetExternalState = (store: DataObserver) => {\\n const { setState } = store;\\n\\n return setState;\\n};\\n\\nexport const useExternalValue = (store: DataObserver) => {\\n const { subscribe, getState } = store;\\n const state = useSyncExternalStore(subscribe, getState);\\n\\n return state;\\n};\\n\\n// \ubc14\ub2d0\ub77cJS \uc601\uc5ed\uc5d0\uc11c \uc790\uc5f0\uc2a4\ub7ec\uc6b4 \uc77d\uae30\ub97c \uc9c0\uc6d0\ud558\ub294 \ud568\uc218\\n\\nexport const getStoreSnapshot = (store: DataObserver) => {\\n return store.getState();\\n};\\n```\\n\\n\ub354 \ub2e4\uc591\ud55c \uc608\uc81c\ub294 [\uc5ec\uae30\uc5d0\uc11c \ud655\uc778](https://github.com/gabrielyoon7/external-state/tree/main/src/examples)\ud560 \uc218 \uc788\uace0\\n\uc791\uc131\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac \ucf54\ub4dc \uc804\ubb38\uc740 [\uc5ec\uae30\uc5d0\uc11c \ud655\uc778](https://github.com/gabrielyoon7/external-state/tree/main/src/lib/external-state)\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uaca8\uc6b0 \ud30c\uc77c \uc218\uc2ed \uc904\ub85c \ub9cc\ub4e0 \ucd08\uacbd\ub7c9 \uc0c1\ud0dc\uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\uc600\uc2b5\ub2c8\ub2e4"},{"id":"28","metadata":{"permalink":"/28","source":"@site/blog/2023-08-23-about-the-map-system-used-by-carffeine/index.mdx","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","description":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \ub300\ud574\uc11c \uc18c\uac1c\ud558\ub824\uace0 \ud569\ub2c8\ub2e4.","date":"2023-08-23T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 23\uc77c","tags":[{"label":"google maps api","permalink":"/tags/google-maps-api"},{"label":"\uad6c\uae00 \uc9c0\ub3c4","permalink":"/tags/\uad6c\uae00-\uc9c0\ub3c4"}],"readingTime":17.43,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"28","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","authors":["gabriel"],"tags":["google maps api","\uad6c\uae00 \uc9c0\ub3c4"]},"prevItem":{"title":"useSyncExternalStore\ub85c \ub9cc\ub4e4\uc5b4\ubcf4\ub294 \uc804\uc5ed\uc0c1\ud0dc\uad00\ub9ac \ub3c4\uad6c","permalink":"/29"},"nextItem":{"title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","permalink":"/27"}},"content":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \ub300\ud574\uc11c \uc18c\uac1c\ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uc9c0\ub3c4 \uae30\ub2a5\uc5d0\uc11c \uac00\uc7a5 \ud575\uc2ec\uc778 \uae30\ub2a5 \ub450 \uac00\uc9c0\ub97c \ubf51\uc790\uba74, \uc9c0\ub3c4 \uadf8 \uc790\uccb4\uc640 \uc9c0\ub3c4 \uc704\uc5d0 \uadf8\ub824\uc9c0\ub294 \ub9c8\ucee4\ub97c \ubf51\uc744 \uc218 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4. \uc9c0\ub3c4 \uc704\uc5d0 \ub9c8\ucee4\ub97c \uadf8\ub9ac\ub294 \uc77c\uc740 \uadf8\ub2e4\uc9c0 \uc5b4\ub835\uc9c0 \uc54a\uace0, documents \uc5d0 \uc788\ub294 \uc608\uc81c\ub4e4\uc744 \uc798 \ub530\ub77c\ud558\uba74 \ub204\uad6c\ub098 \ucda9\ubd84\ud788 \uad6c\ud604\ud560 \uc218 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n![no offset](./markers-on-map.png)\\n\\n\ud558\uc9c0\ub9cc \ub9c8\ucee4\uc758 \uac2f\uc218\uac00 \uacfc\ub3c4\ud558\uac8c \ub9ce\ub2e4\uba74 \uc5b4\ub5a4 \uc804\ub7b5\uc744 \uc138\uc6b8 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n### \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294\uc694 ...\\n\\n\\n\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc9c0\ub3c4\ub294 \uad49\uc7a5\ud788 \uc911\uc694\ud55c \uc694\uc18c \uc911 \ud558\ub098\uc600\uc2b5\ub2c8\ub2e4. \uc0ac\uc6a9\uc790\ub4e4\uc774 \uad81\uae08\ud55c \uc7a5\uc18c\uc758 \uc8fc\ubcc0\uc5d0 \uc788\ub294 \ucda9\uc804\uc18c\ub97c \uc2dc\uac01\uc801\uc73c\ub85c \uc81c\uacf5\ud574\uc8fc\uae30 \uc704\ud574\uc11c\ub294 \uc9c0\ub3c4\ub97c \uc798 \uc81c\uc5b4\ud560 \uc218 \uc788\uc5b4\uc57c \ud588\uc2b5\ub2c8\ub2e4. \ud2b9\ud788 \uc804\uad6d\uc5d0 \uc774\ubbf8 `\uc218\ub9cc \ub300\uc758 \ucda9\uc804\uc18c`\uac00 \ubcf4\uae09\uc774 \ub41c \uc0c1\ud669\uc5d0\uc11c \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ubaa8\ub450 \uadf8\ub824\uc8fc\uae30 \uc704\ud574\uc11c\ub294 \ub9ce\uc740 \uc81c\uc57d\uc774 \uc788\uc5c8\uace0, \ub9c8\ucee4\ub97c \uc801\ub2f9\ud55c \uc218\uc900\uc73c\ub85c \ub80c\ub354\ub9c1 \ud558\ub824\uba74 \ud074\ub77c\uc774\uc5b8\ud2b8\uc640 \uc11c\ubc84 \uac04\uc5d0 \ud2b9\ubcc4\ud55c \uc791\uc5c5\uc774 \ud544\uc694\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc5b4\ub5a4 \uc804\ub7b5\uc744 \ud3bc\ucce4\ub294\uc9c0 \uc18c\uac1c\ud558\uae30\uc5d0 \uc55e\uc11c \ubbf8\ub9ac \ub9d0\uc500\ub4dc\ub9ac\uc9c0\ub9cc, \uc800\ud76c \ud300\uc5d0\uc11c \ucde8\ud55c \uc9c0\ub3c4 \uad00\ub9ac \uc804\ub7b5\uc740 \ubaa8\ub4e0 \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc744 \uac83\uc785\ub2c8\ub2e4. \uc9c0\ub3c4 \uc704\uc5d0 \ud55c\ubc88\uc5d0 \ud45c\ud604\ud560 \ub9c8\ucee4\uc758 \uac2f\uc218\uac00 \uc218\ubc31 \uac1c \uc774\ud558\ub77c\uba74, \uc11c\ubc84\uc5d0 \ub370\uc774\ud130\uac00 \uacfc\ub3c4\ud558\uac8c \ub9ce\uc740 \uac83\uc774 \uc544\ub2c8\ub77c\uba74 \uc624\ud788\ub824 \uc774\ub7ec\ud55c \uc804\ub7b5\uc774 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \ud574\uce60 \uc218 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4. (\ud658\uacbd\uc774 \uc6d0\ud65c\ud558\ub2e4\uba74 \ub370\uc774\ud130\ub97c \uac00\ub2a5\ud55c \ub9ce\uc774 \ubcf4\uc5ec\uc8fc\ub294 \uac83\uc774 \uc88b\uc744\ud14c\ub2c8\uae50\uc694.)\\n\\n\ub610, \uc774 \uae00\uc5d0\uc11c\ub294 Google Maps API\ub97c \uae30\uc900\uc73c\ub85c \uc124\uba85\ud558\uace0 \uc788\uc9c0\ub9cc, \uc9c0\uc6d0\ud558\ub294 \uae30\ub2a5\uc774 \uc77c\ubd80 \ub2e4\ub974\ub354\ub77c\ub3c4 \ub300\ubd80\ubd84\uc758 \uc9c0\ub3c4 API\uc5d0\uc11c \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud55c \uc804\ub7b5\uc77c \uac83\uc785\ub2c8\ub2e4. \ucc38\uace0\ub85c \uac1c\uc778\uc801\uc73c\ub85c \uc0ac\uc6a9 \ud574\ubcf8 \uc5ec\ub7ec \ubca4\ub354 \uc0ac\uc758 \uc9c0\ub3c4 API\ub4e4\uc740 \ubaa8\ub450 \uc774\uc640 \uc720\uc0ac\ud55c \uae30\ub2a5\uc744 \uc81c\uacf5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n### \uc88c\ud45c\ub780 \ubb34\uc5c7\uc77c\uae4c?\\n\\n\uc544\ub9c8 \uc5b4\ub9b0 \uc2dc\uc808\ubd80\ud130 \uc6b0\ub9ac\ub098\ub77c\uc5d0\ub294 \ud2b9\ubcc4\ud788 38\uc120\uc774\ub77c\ub294 \uac83\uc774 \uc874\uc7ac\ud55c\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uad50\uc721\ubc1b\uae30\uc5d0 `\uc88c\ud45c\uacc4\ub77c\ub294 \uac83\uc774 \uc788\ub2e4\ub294 \uc0ac\uc2e4`\uc740 \ub204\uad6c\ub098 \uc54c \uac83\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub2f9\uc7a5 \uc704\ub3c4\uc640 \uacbd\ub3c4\ub97c \uad6c\ubd84\uc9c0\uc73c\ub77c\uace0 \ud558\uba74 \uc5b4\ub5a4 \uc120\uc774 \uc704\uc120\uc774\uace0 \uacbd\uc120\uc778\uc9c0 \ud5f7\uac08\ub9ac\uae30\uc5d0 \ucc0d\uc5b4\uc57c \ud560 \uac83\uc785\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc774 \uc120\uc774 \uc5b4\ub5a4 \uc120\uc778\uc9c0, \uc5b4\ub5a4 \uac12\uc744 \uc598\uae30\ud558\ub824\ub294 \uac83\uc778\uc9c0 \uc0ac\uc9c4\uacfc \ud568\uaed8 \uac04\ub2e8\ud788 \uc124\uba85\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./latlng.jpeg)\\n\\n\uc0ac\uc9c4\uc744 \ubcf4\uc2dc\uba74 \uc544\uc2dc\uaca0\uc9c0\ub9cc \uc704\ub3c4\ub780, \ub0a8\ubd81\uc758 \uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ub370 \uc0ac\uc6a9\ub429\ub2c8\ub2e4. \uacbd\ub3c4\ub294 \ub3d9\uc11c\uc758 \uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ub370 \uc0ac\uc6a9\ub429\ub2c8\ub2e4. \ub300\ubd80\ubd84\uc758 \uacf5\uc2dd \ubb38\uc11c\uac00 \uc601\uc5b4\ub85c \uc791\uc131\ub418\uc5b4\uc788\uace0, \ucf54\ub4dc\uc5d0\uc11c\ub3c4 \uc774\ub97c \ub098\ud0c0\ub0b4\ub294 \uac83\uc774 \uc911\uc694\ud558\uae30\uc5d0 \uc601\ubb38 \ud45c\uae30\ubc95\uae4c\uc9c0 \uc18c\uac1c\ub97c \ud558\uc790\uba74 \uc704\ub3c4\ub294 Latitude, \uacbd\ub3c4\ub294 Longitude\ub85c \ud45c\uae30\ud569\ub2c8\ub2e4. \uc774\uc720\ub294 \ubaa8\ub974\uaca0\uc9c0\ub9cc \uc81c\uacf5\ub418\ub294 \ubcc0\uc218\ub098 \uba54\uc11c\ub4dc \uba85\uc73c\ub85c lat, lng\ub77c\uace0 \uc904\uc5ec\uc11c \ud45c\uae30\ud558\uae30\ub3c4 \ud569\ub2c8\ub2e4.\\n\\n![no offset](./latlngeng.gif)\\n\\n\uc704\ub3c4\uc640 \uacbd\ub3c4\ub9cc \uc54c\uba74, \uc9c0\uad6c \uc704\uc758 \uc5b4\ub5a4 \uc704\uce58\ub97c \ub098\ud0c0\ub0bc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c, \uc5b4\ub5a4 \ub9c8\ucee4\ub97c \uc5b4\ub5a4 \uc704\uce58\uc5d0 \ucc0d\uc744 \uac83\uc778\uc9c0\ub294 \uc704\ub3c4\uc640 \uacbd\ub3c4 \uac12\uc73c\ub85c \uacb0\uc815\ud560 \uc218 \uc788\uac8c \ub418\uaca0\uc8e0?\\n\\n### \uc0ac\uc6a9\uc790\uac00 \uc5b4\ub51c \ubcf4\uace0 \uc788\uc744\uae4c?\\n\\n\uc9c0\ub3c4 api\uc5d0\uc11c \uc81c\uacf5\ud574\uc8fc\ub294 \uba54\uc11c\ub4dc\ub97c \ud65c\uc6a9\ud558\uba74 \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4\uac00 \uc5b4\ub290 \uc704\uce58\ub97c \ubcf4\uace0 \uc788\ub294\uc9c0 \uc54c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```typescript\\nlet map = /* \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c \uad6c\uae00 \ub9f5 \uac1d\uccb4 */\\nconst center = map.getCenter();\\nconsole.log(center.lng()); // \ub514\ubc14\uc774\uc2a4 \uc911\uc2ec\uc758 longitude\\nconsole.log(center.lat()); // \ub514\ubc14\uc774\uc2a4 \uc911\uc2ec\uc758 latitude\\n```\\n\\n\uc9c0\ub3c4 \uac1d\uccb4\ub85c \ubd80\ud130 \uc911\uc2ec\uc810\uc744 \uc54c\uac8c\ub418\uba74 \ud574\ub2f9 \ub514\ubc14\uc774\uc2a4\uc758 \uc911\uc2ec\uc758 \uc88c\ud45c\ub97c \uc54c\uc544\ub0bc \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n![no offset](./get-center.png)\\n\\n### \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4\ub294 \uc5bc\ub9c8\ub098 \ub113\uac8c \ubcf4\uace0 \uc788\uc744\uae4c?\\n\\n\uc9c0\ub3c4 api\uc5d0\uc11c \uc81c\uacf5\ud574\uc8fc\ub294 \uba54\uc11c\ub4dc\ub97c \ud65c\uc6a9\ud558\uba74 \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4\uac00 \uc5b4\ub5a4 \uc601\uc5ed\uc744 \ubcf4\uace0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uac8c \ub429\ub2c8\ub2e4. \uc9c0\ub3c4 api \ub9c8\ub2e4 \uc81c\uacf5\ud558\ub294 \uc2a4\ud399\uc774 \ub2e4\ub974\uc9c0\ub9cc, \ub300\ubd80\ubd84\uc740 \uc5b4\ub5a4 \uc2dd\uc73c\ub85c\ub4e0 \uc54c\ub824\uc90d\ub2c8\ub2e4.\\n\\ngoogle maps API\uc5d0\uc11c\ub294 \ub514\uc2a4\ud50c\ub808\uc774\uc758 \ubd81\ub3d9\ucabd \ub05d \uc810\uc758 \uc88c\ud45c\uc640, \ub0a8\uc11c\ucabd \ub05d \uc810\uc758 \uc88c\ud45c\ub97c \uc81c\uacf5\ud574\uc90d\ub2c8\ub2e4.\\n\\n```typescript\\nconst map = /* \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c \uad6c\uae00 \ub9f5 \uac1d\uccb4 */\\nconst bounds = map.getBounds();\\nconsole.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // \ub514\ubc14\uc774\uc2a4 1\uc0ac\ubd84\uba74 \ub05d \uc810\uc758 longitude\uc640 latitude\\nconsole.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // \ub514\ubc14\uc774\uc2a4 3\uc0ac\ubd84\uba74 \ub05d \uc810\uc758 longitude\uc640 latitude\\n```\\n\\n![no offset](./get-bounds.png)\\n\\n\ud3b8\uc758\uc0c1 \uc88c\ud45c\ub97c \ub2e4\uc74c\uacfc \uac19\uc774 \uc815\uc758\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n- \uc911\uc2ec \uc810 p0: (x0, y0)\\n- \ub514\ubc14\uc774\uc2a4\uc758 \uc81c 1\uc0ac\ubd84\uba74 \ub05d\uc810 p2: (x2, y2)\\n- \ub514\ubc14\uc774\uc2a4\uc758 \uc81c 3\uc0ac\ubd84\uba74 \ub05d\uc810 p1: (x1, y1)\\n\\n```\\n\uc704 \uc815\uc758\ub294 \uc544\ub798\uc5d0\uc11c\ub3c4 \uacc4\uc18d \uc124\uba85 \ub420 \uc810\uacfc \uc88c\ud45c \uc785\ub2c8\ub2e4.\\n```\\n\\n\uc774\ub807\uac8c \uc54c\uc544\ub0b8 \uac12\uc73c\ub85c \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc758 \uc601\uc5ed\uc744 \uc54c\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc774 \uac12\uc744 \uc880 \ub354 \ud6a8\uc728\uc801\uc73c\ub85c \ub2e4\ub8e8\uae30 \uc704\ud574 delta \uac1c\ub150\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4.\\n\\n### \ud654\uba74\uc5d0\uc11c \ubcf4\uace0 \uc788\ub294 \uc601\uc5ed\uc744 \ud655\ub300/\ucd95\uc18c \ud558\uba74 \uc5b4\ub5a4 \ud2b9\uc9d5\uc744 \ubcf4\uc77c\uae4c?\\n\\ndelta \uc124\uba85\uc744 \uc55e\uc11c, \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4 \uc601\uc5ed\uacfc \ud655\ub300 \uc218\uc900\uc5d0 \ub530\ub978 \uc2e4\uc81c \uc88c\ud45c\uc5d0 \ub300\ud574 \uc54c\uc544\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\uac00 \ud654\uba74\uc744 \uc5bc\ub9c8\ub098 \ub113\uac8c \ubcf4\uace0 \uc788\ub294\uc9c0\ub97c \uc27d\uac8c \uc54c\uae30 \uc704\ud574\uc11c\ub294 \ub05d\uc810\ub4e4\uc758 \uc218\uce58\ub97c \uacc4\uc0b0\ud574\uc904 \ud544\uc694\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc9c4\uc740 \uc0ac\uc6a9\uc790\uac00 \ub514\ubc14\uc774\uc2a4\ub97c \ud1b5\ud574 \ubc14\ub77c \ubcf4\uace0 \uc788\ub294 \uc911\uc2ec \uc88c\ud45c\uc640 \uadf8 \ub05d \uc810\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4.\\n\\n![no offset](./map-with-different-size.png)\\n\\n\\n\uc608\ub97c \ub4e4\uc5b4 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\ub97c \ub9ce\uc774 \ucd95\uc18c\ud55c \uacbd\uc6b0\uc5d0\ub294 \uc911\uc2ec \uc810 p0\uc740 \uadf8\ub300\ub85c\uc9c0\ub9cc \uc591 \ub05d\uc810 p1, p2\uc758 \uc704\uce58\uac00 \uc810\uc810 \uc911\uc2ec \uc810 p0\uc73c\ub85c \ubd80\ud130 \uba40\uc5b4\uc9c8 \uac83\uc785\ub2c8\ub2e4.\\n\\n\ubc18\uba74\uc5d0 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\ub97c \ub9ce\uc774 \ud655\ub300\ud55c \uacbd\uc6b0\uc5d0\ub294 \uc911\uc2ec \uc810 p0\uc740 \uadf8\ub300\ub85c\uc9c0\ub9cc \uc591 \ub05d\uc810 p1, p2\uc758 \uc704\uce58\uac00 \uc810\uc810 \uc911\uc2ec\uc810\uacfc \uac00\uae4c\uc6cc\uc9c8 \uac83\uc785\ub2c8\ub2e4.\\n\\n![no offset](./map-with-different-zoom.png)\\n\\n\uc591 \uc0ac\uc9c4 \ubaa8\ub450 \uc911\uc2ec \uc810 p0\ub294 \uadf8\ub300\ub85c\uc9c0\ub9cc, \ub514\ubc14\uc774\uc2a4\uc758 \ud655\ub300 \uc218\uc900\uc73c\ub85c \uc778\ud574 \uc591 \ub05d\uc810\uc778 p1\uacfc p2\uac00 \ub2ec\ub77c\uc9c4 \ubaa8\uc2b5\uc744 \ubcf4\uc778 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc989, \uc774\ub7f0 \uacb0\ub860\uc744 \ub0b4\ub9b4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc591 \ub05d\uc810 p1, p2\uac00 \uc911\uc2ec \uc810 p0\uc73c\ub85c \ubd80\ud130 \uba40\uc5b4\uc9c8 \uc218\ub85d \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud55c \uac83\uc774\ub2e4.\\n2. \uc591 \ub05d\uc810 p1, p2\uac00 \uc911\uc2ec \uc810 p0\uc73c\ub85c \ubd80\ud130 \uac00\uae4c\uc6cc \uc218\ub85d \uc9c0\ub3c4\ub97c \ud655\ub300\ud55c \uac83\uc774\ub2e4.\\n\\n\uc774 \ub54c \ub514\ubc14\uc774\uc2a4\uc758 \ub514\uc2a4\ud50c\ub808\uc774\uac00 \uc704\ub3c4 \uacbd\ub3c4 \uc0c1\uc73c\ub85c \uc5bc\ub9c8\ub098 \uba40\uc5b4\uc838\uc788\ub294\uc9c0\ub97c \uc218\uce58\ud654\ud558\uba74 \ud3b8\ud558\uac8c \ub2e4\ub8f0 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ud655\ub300 \uc218\uc900\uc744 \uc218\uce58\ud654 \ud560 \uc218 \uc5c6\uc744\uae4c?\\n\\n\uc0ac\uc6a9\uc790\uc758 \ub514\uc2a4\ud50c\ub808\uc774\uc758 \uc911\uc2ec \uc810 p0\uc744 \uae30\uc900\uc73c\ub85c \ud558\uc5ec \uc591 \ub05d\uc810 p1, p2\uc774 \uc5bc\ub9c8\ub098 \uba40\uc5b4\uc838\uc788\ub294\uc9c0\uc5d0 \ub530\ub77c \uc9c0\ub3c4\uc758 \uc601\uc5ed \ubfd0\ub9cc \uc544\ub2c8\ub77c \uc5bc\ub9c8\ub098 \ub9ce\uc774 \ud655\ub300 \ub418\uc5c8\ub294\uc9c0 \uc5ec\ubd80\ub97c \uc54c\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 \uc774\ub97c \uc880 \ub354 \ud6a8\uc728\uc801\uc778 \ubc29\ubc95\uc73c\ub85c \ub098\ud0c0\ub0b4\ub824\uba74 \uc5b4\ub5a4 \uc804\ub7b5\uc744 \ucde8\ud560 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n\uc0ac\uc6a9\uc790 \ub514\uc2a4\ud50c\ub808\uc774\ub97c \uc870\uae08 \ub354 \uc790\uc138\ud788 \uc0b4\ud3b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./map-points.png)\\n\\n\uc911\ud559\uad50 \uc2dc\uc808 \ubc30\uc6e0\ub358 \uc88c\ud45c \ud3c9\uba74\uacc4\ub97c \ub5a0\uc62c\ub824\ubcf4\uba74 \ud654\uba74\uc5d0\uc11c \uc5bb\uc744 \uc218 \uc788\ub294 \uc88c\ud45c\ub4e4\uc740 \uc704\uc640 \uac19\uc2b5\ub2c8\ub2e4. \uc5ec\uae30\uc5d0\uc11c \uac01 \uc810\uc758 \uc218\uc9c1/\uc218\ud3c9\uc758 \ubcc0\ud654\ub7c9\uc778 delta\ub97c \uc54c\uc544\ubcf4\uba74 \uc5b4\ub5a8\uae4c\uc694?\\n\\n#### \uacbd\ub3c4 \ub378\ud0c0 (longitudeDelta)\\n\\np2\uc640 p0\uc758 \uacbd\ub3c4 \uac70\ub9ac, \uadf8\ub9ac\uace0 p1\uacfc p0\uc758 \uacbd\ub3c4 \uac70\ub9ac\ub294 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc989, `x2 - x0 === x0 - x1` \uc774\ub77c\ub294 \uacb0\ub860\uc744 \uc5bb\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c longitudeDelta\ub85c \uc815\uc758\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n#### \uc704\ub3c4 \ub378\ud0c0 (latitudeDelta)\\n\\np2\uc640 p0\uc758 \uc704\ub3c4 \uac70\ub9ac, \uadf8\ub9ac\uace0 p1\uacfc p0\uc758 \uc704\ub3c4 \uac70\ub9ac\ub294 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc989, `y2 - y0 === y0 - y1` \uc774\ub77c\ub294 \uacb0\ub860\uc744 \uc5bb\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c latitudeDelta\ub85c \uc815\uc758\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n![no offset](./delta.png)\\n\\n\ucf54\ub4dc\ub85c \uc54c\uc544\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```typescript\\nconst map = /* \uc5b4\ub514\uc120\uac00 \uc0dd\uc131\ub41c \uad6c\uae00 \ub9f5 \uac1d\uccb4 */\\nconst bounds = map.getBounds();\\nconst longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // \uacbd\ub3c4 \ubcc0\ud654\ub7c9\\nconst latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // \uc704\ub3c4 \ubcc0\ud654\ub7c9\\n```\\n\\n\ub4dc\ub514\uc5b4 \ud074\ub77c\uc774\uc5b8\ud2b8\uc5d0\uc11c \ub378\ud0c0 \uac12\uc744 \uc0dd\uc131\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 \uc65c \uc774\ub807\uac8c \uad73\uc774 \ub378\ud0c0 \uac12\uc744 \uc0dd\uc131\ud55c \uac83\uc77c\uae4c\uc694?\\n\\n### delta\uc758 \uc720\uc6a9\ud55c \uc810 1: \uc6d0\ub798 \uc758\ub3c4\ud55c \uac12\uc744 \ubcf5\uc6d0\ud558\uae30 \uc27d\ub2e4.\\n\\n\uc11c\ubc84\uc758 \uc785\uc7a5\uc5d0\uc11c\ub294 \uc911\uc2ec \uc88c\ud45c\uc640 \ub378\ud0c0 \uac12\ub9cc \uc54c\uba74 \uc815\ud655\ud55c \uc601\uc5ed\ub9cc\ud07c \ub370\uc774\ud130\ub97c \ud638\ucd9c\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \ud074\ub77c\uc774\uc5b8\ud2b8\uc5d0\uc11c \uc11c\ubc84\ub85c \ub2e4\uc74c\uacfc \uac19\uc740 \ud30c\ub77c\ubbf8\ud130\ub97c \ub118\uaca8\uc92c\ub2e4\uace0 \uac00\uc815\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```json\\n{\\n \\"longitude\\": 127,\\n \\"latitude\\": 37,\\n \\"longitudeDelta\\": 0.1,\\n \\"longitudeDelta\\": 0.2,\\n}\\n```\\n\\n\uadf8\ub807\ub2e4\uba74 \uc11c\ubc84\uc5d0\uc11c\ub294 \ub2e4\uc74c\uacfc \uac19\uc774 \ud574\uc11d\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n```javascript\\nconst maxLongitude = longitude + longitudeDelta;\\nconst minLongitude = longitude - longitudeDelta;\\nconst maxLatitude = latitude + latitudeDelta;\\nconst minLatitude = latitude - latitudeDelta;\\n```\\n(javascript \uae30\uc900\uc73c\ub85c \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4.)\\n\\n\uc774\ub807\uac8c \uc54c\uc544\ub0b8 \uacbd\uacc4 \uac12\uc744 \uac00\uc9c0\uace0 \ub2e4\uc74c\uacfc \uac19\uc740 sql\ubb38\uc744 \uc791\uc131\ud560 \uc218 \uc788\uac8c \ub420 \uac83\uc785\ub2c8\ub2e4.\\n\\n```sql\\nSELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;\\n```\\n\\n![no offset](./find-within-range.png)\\n\\n\uc989, \uc704 \uadf8\ub9bc\ucc98\ub7fc, \uc6d0\ud558\ub294 \uc601\uc5ed\ub9cc\ud07c\ub9cc \uc815\ud655\ud558\uac8c \ub370\uc774\ud130\ub97c \ud638\ucd9c\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n### delta\uc758 \uc720\uc6a9\ud55c \uc810 2: \ub378\ud0c0\uac00 \ubb34\ubd84\ubcc4\ud558\uac8c \ucee4\uc9c0\ub294 \uac83\uc744 \ub9c9\uae30 \uc27d\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \uc0ac\uc6a9\uc790\uac00 \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud558\uc5ec \ud55c\ubc18\ub3c4\ub97c \ub514\uc2a4\ud50c\ub808\uc774\uc5d0 \uac00\ub4dd \ucc44\uc6b4\ub2e4\uba74 \uc11c\ubc84\uac00 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694?\\n\\n\uc774\ub7ec\ud55c \ud589\uc704\ub97c \ub9c9\ub294 \uac00\uc7a5 \uc26c\uc6b4 \ubc29\ubc95\uc740 \uc9c0\ub3c4 api\uc5d0\uc11c \uc9c0\uc6d0\ud558\ub294 \uc90c \ub808\ubca8\uc744 \uc81c\ud55c \ud558\ub294 \uac83\uc785\ub2c8\ub2e4. \ud6c4\uc220\ud558\uaca0\uc9c0\ub9cc _\uc90c \ub808\ubca8\uc740 \ub514\uc2a4\ud50c\ub808\uc774\uc758 \ud574\uc0c1\ub3c4\ub97c \uace0\ub824\ud558\uc9c0 \ubabb\ud569\ub2c8\ub2e4._\\n\\n\ub530\ub77c\uc11c \uadfc\ubcf8\uc801\uc73c\ub85c \ub378\ud0c0\uac00 \uc77c\uc815 \uac12 \uc774\uc0c1 \uc694\uccad\ub418\uc9c0 \ubabb\ud558\ub3c4\ub85d, \ud639\uc740 \uc5f0\uc0b0\ub418\uc9c0 \ubabb\ud558\ub3c4\ub85d \ub9c9\uac8c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \ub378\ud0c0\uac00 \uc5c6\ub354\ub77c\ub3c4 \ub378\ud0c0 \uac12\uc744 \ucd94\uc815\ud558\uc5ec \uc5f0\uc0b0\ud560 \uc218 \uc788\uaca0\uc9c0\ub9cc, \uc774\ub97c _\uc218\uce58\ud654 \ud574\uc11c \uad00\ub9ac\ud55c\ub2e4\uba74 \ud074\ub77c\uc774\uc5b8\ud2b8\uc640 \uc11c\ubc84 \ubaa8\ub450 \uc9c0\ub3c4\ub97c \uc190\uc27d\uac8c \ud1b5\uc81c\ud558\ub294 \uac83\uc774 \uac00\ub2a5_\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \ub2e4\uc74c\uacfc \uac19\uc774 \ub378\ud0c0 \uac12\uc744 \uace0\uc815\ud558\uc5ec \uc694\uccad \uc601\uc5ed\uc744 \uc81c\ud55c\ud560(\uc694\uccad\uc744 \ubcf4\ub0b4\uc9c0 \uc54a\uac70\ub098 \uace0\uc815\ub41c \uc0ac\uc774\uc988\ub85c\ub9cc \uc694\uccad\uc744 \ubcf4\ub0bc) \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```json\\n{\\n longitude,\\n latitude,\\n longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,\\n latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,\\n}\\n```\\n\\n\ud2b9\uc815 \uc218\uce58\ub97c \ub118\uae30\uc9c0 \ubabb\ud558\uac8c \ucc98\ub9ac\ud560 \ub54c \ub208\uc5d0 \ubcf4\uc774\ub294 \ubcc0\uc218\ub85c \ucde8\uae09\ud558\uae30 \uc27d\uc2b5\ub2c8\ub2e4. (\uc989, \ub9e4\ubc88 \uacc4\uc0b0\ud558\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.)\\n\\n\ub514\ubc14\uc774\uc2a4 \ud06c\uae30 \uad00\ub828 \ubb38\uc81c\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubd84\uba85\ud788 \uac19\uc740 \uc90c \ub808\ubca8\uc774\uc9c0\ub9cc, \ub514\ubc14\uc774\uc2a4\uc758 \ud06c\uae30\ub098 \ud574\uc0c1\ub3c4\uc5d0 \ub530\ub77c \uc9c0\ub3c4\uac00 \ubcf4\uc5ec\uc9c0\ub294 \uc815\ub3c4\uac00 \ub2e4\ub985\ub2c8\ub2e4.\\n\\n![no offset](./different-device-size.png)\\n\\n\uc704 \uc0ac\uc9c4\uc740 \uad6c\uae00\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 zoom \ub808\ubca8\uc744 \ub3d9\uc77c\ud558\uac8c \ub9de\ucd98 \ud6c4, \uc5ec\ub7ec \ub514\ubc14\uc774\uc2a4\uc5d0\uc11c \ud638\ucd9c\ud55c \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc90c \ub808\ubca8\uc744 \ud1b5\ud574\uc11c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub2e4\ubcf4\uba74 \uc5ec\ub7ec \ud574\uc0c1\ub3c4\ub97c \uc81c\uc5b4\ud558\uae30 \uc5b4\ub835\uc2b5\ub2c8\ub2e4.\\n\\n![no offset](./too-big-screen.png)\\n\\n\uc2e4\uc81c\ub85c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uace0\ud574\uc0c1\ub3c4 \ubaa8\ub2c8\ud130\ub97c \ub300\uc751\ud558\uae30 \uc704\ud574 \ub378\ud0c0 \uac12\uc774 \ub108\ubb34 \ud06c\uac8c \ub418\uba74 \uc694\uccad\uc758 \uc81c\ud55c\uc744 \ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc0ac\uc9c4\uc5d0\uc11c \ubcf4\uc2dc\ub2e4\uc2dc\ud53c \uace0\ud574\uc0c1\ub3c4 \ubaa8\ub2c8\ud130\uc758 \uacbd\uc6b0, \ub108\ubb34 \ub113\uc740 \ubc94\uc704\ub97c \uc694\uccad\ud55c\ub2e4 \uc2f6\uc73c\uba74 \uc911\uc2ec\uc810\uc73c\ub85c \ubd80\ud130 \uc77c\uc815 \uac70\ub9ac\ub9cc \ubcf4\uc5ec\uc8fc\ub3c4\ub85d \ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n(\ucc38\uace0\ub85c \uc90c \ub808\ubca8\uc5d0 \ub530\ub978 \uc694\uccad\ub3c4 \ub364\uc73c\ub85c \uc81c\ud55c\ud558\uace0 \uc788\uc5b4\uc11c \uba40\ub9ac\uc11c \ud638\ucd9c\ud558\ub294 \ud589\uc704\ub3c4 \uae08\uc9c0\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.)\\n\\n### delta\uc758 \uc720\uc6a9\ud55c \uc810 3: \uc801\ub2f9\ud55c \ubc94\uc704\ub97c \uc815\ud574\uc8fc\uae30 \ud3b8\ud558\ub2e4\\n\\n\uc704 \uc608\uc81c\uc5d0\uc11c\ub294 \uc815\ud655\ud55c \ubc94\uc704\ub9cc\ud07c \uc694\uccad\ud558\ub294 \uac83\uc744 \uc608\uc81c\ub85c \ud558\uc9c0\ub9cc, \ud504\ub85c\uc81d\ud2b8\uc5d0 \ub530\ub77c\uc11c \uc870\uae08 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\uace0 \uc2f6\uc744 \ub54c\uac00 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n![no offset](./bigger-than-delta.png)\\n\\n\uc608\ub97c \ub4e4\uc5b4 \ud604\uc7ac \uc0ac\uc6a9\uc790\uc758 \ub514\ubc14\uc774\uc2a4 \ud06c\uae30\ubcf4\ub2e4 \uc0b4\uc9dd \ud070 \ubc94\uc704\uc758 \ub370\uc774\ud130\ub97c \ubbf8\ub9ac \ub85c\ub4dc\ud574 \ub193\uc73c\uba74 \uc0ac\uc6a9\uc790\uac00 \uc881\uc740 \uc6c0\uc9c1\uc784\uc744 \ubcf4\uc77c \ub54c \ubd88\ud544\uc694\ud55c \uc7ac \ub80c\ub354\ub9c1\uc744 \uc904\uc5ec\uc11c \ub354 \ube60\ub978 \ub80c\ub354\ub9c1\uc774 \uac00\ub2a5\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uc774 \uae30\ubc95\uc740 \ud504\ub85c\uc81d\ud2b8\ub9c8\ub2e4 \ub2e4\ub974\uaca0\uc9c0\ub9cc, \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud55c\ubc88 \ubd88\ub7ec\uc628 \ub9c8\ucee4\ub97c \ub9e4\ubc88 \ud574\uc81c \ud558\uc9c0 \uc54a\uace0 **\uc774\uc804 \uc694\uccad \ub370\uc774\ud130\uc640 \ub2e4\uc74c \uc694\uccad \ub370\uc774\ud130\ub97c \ube44\uad50\ud558\uc5ec \ub2ec\ub77c\uc9c4 \ub9c8\ucee4\ub9cc\uc744 \uc815\ud655\ud558\uac8c \ud0c8\ubd80\ucc29\ud558\ub294 \uc791\uc5c5\uc744 \uc9c4\ud589**\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uae30\ubc95\uc744 \ud65c\uc6a9\ud558\uba74 \uc0ac\uc6a9\uc790\uac00 \uc881\uc740 \ubc94\uc704\uc5d0\uc11c \uc6c0\uc9c1\uc784\uc744 \ubcf4\uc600\uc744 \ub54c, \uae30\uc874\uc5d0 \ubd88\ub7ec\uc628 \ub9c8\ucee4\ub97c \uba54\ubaa8\ub9ac\uc5d0\uc11c \ud0c8\ub77d\uc2dc\ud0a4\uc9c0 \uc54a\uc73c\ubbc0\ub85c \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc744 \uac1c\uc120\ud560 \uc218\ub3c4 \uc788\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n\ub9c8\ucee4\ub97c \uc0c1\ud0dc\uc5d0 \uc5f0\ub3d9\ud558\uc5ec \uc815\ud655\ud558\uac8c \uba54\ubaa8\ub9ac\uc5d0\uc11c \ud0c8\ubd80\ucc29 \uc2dc\ud0a4\ub294 \uc804\ub7b5\uc5d0 \ub300\ud55c \uae00\uc740 \uc774\ud6c4\uc5d0 \uc791\uc131\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4."},{"id":"27","metadata":{"permalink":"/27","source":"@site/blog/2023-08-17-given-ec2-prod-dev-sep.mdx","title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694.","date":"2023-08-17T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 17\uc77c","tags":[{"label":"ec2","permalink":"/tags/ec-2"},{"label":"prod","permalink":"/tags/prod"},{"label":"dev","permalink":"/tags/dev"}],"readingTime":3,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"27","title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","authors":["jay"],"tags":["ec2","prod","dev"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud55c \uc9c0\ub3c4 \uc2dc\uc2a4\ud15c\uc5d0 \uad00\ud558\uc5ec","permalink":"/28"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","permalink":"/26"}},"content":"\uc548\ub155\ud558\uc138\uc694.\\n\uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \uc800\ud76c\uac00 EC2 \uc778\uc2a4\ud134\uc2a4\ub97c \ubc1b\uc73c\uba74\uc11c, \uc5b4\ub5bb\uac8c dev, prod \ubc30\ud3ec \ud658\uacbd\uc744 \ubd84\ub9ac\ud588\ub294\uc9c0 \uc801\uc5b4\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\uae30\uc874 \uce74\ud398\uc778 \ud300\uc758 EC2 \uad6c\uc870\ub294 [\uc5ec\uae30\uc11c](https://blog.naver.com/sosow0212/223163203356) \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \uae30\uc874 \uc0c1\ud669\uacfc \ubb38\uc81c\uc810\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uae30\uc874\uc5d0 3\ub300\uc758 EC2 \uc778\uc2a4\ud134\uc2a4\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uac01\uac01 `infra, dev, db` \uc5ed\ud560\uc744 \ud558\ub294 \uc778\uc2a4\ud134\uc2a4\ub85c \uc874\uc7ac\ud558\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 release \ube0c\ub79c\uce58\ub97c \ud1b5\ud574 dev\uc11c\ubc84\uc5d0 \ubc30\ud3ec\ub97c \ud55c \ud6c4 \uac80\uc99d\uc774 \ub41c\ub2e4\uba74, \uc2e4\uc81c \uc0ac\uc6a9\uc790\ub4e4\uc774 \uc0ac\uc6a9\ud558\ub294 prod \uc11c\ubc84\uc5d0 \ubc30\ud3ec\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubb38\uc81c\ub294 \uae30\uc874\uc758 3\ub300\uc758 \uc778\uc2a4\ud134\uc2a4 \uc911\uc5d0\uc11c dev \uc11c\ubc84\uc5d0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874 dev \uc11c\ubc84\ub294 \ucd1d 4\uac1c\uc758 \uc11c\ubc84\ub97c \ubc30\ud3ec\ud558\uace0 \uc788\uc5c8\uace0 \ubc30\ud3ec\ud558\ub294 \uc11c\ubc84\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4. `prod-BE, prod-FE, dev-BE, dev-FE`\\n\\n\uadf8\ub9ac\uace0, \uae30\uc874 dev \uc11c\ubc84\uc5d0\uc11c\ub294 \ud658\uacbd\uc744 \ubd84\ub9ac\ud574\uc8fc\uae30 \uc704\ud574\uc11c Nginx\ub97c \ud1b5\ud574\uc11c \ud3ec\ud2b8 \ud3ec\uc6cc\ub529\uc740 \ub2e4\uc74c\uacfc \uac19\uc774 \ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n- prod-BE = 8080\\n- prod-FE = 3031\\n- dev-BE = 8081\\n- dev-FE = 3031\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 dev, prod \ud658\uacbd\uc774 \ubd84\ub9ac\ub418\uc9c0 \uc54a\uc544\uc11c \uc778\uc2a4\ud134\uc2a4\uc758 \uc0ac\uc6a9\ub7c9\uc774 \ub192\uc558\uace0, \uc774\uc5d0 \ub530\ub77c \ucd94\uac00\uc801\uc778 EC2 \uc778\uc2a4\ud134\uc2a4\uac00 \ud544\uc694\ud588\uc2b5\ub2c8\ub2e4.\\n\\n---\\n\\n## \ubb38\uc81c \ud574\uacb0\\n\ub2e4\ud589\ud788\ub3c4 \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ucd94\uac00\uc801\uc778 EC2 \uc778\uc2a4\ud134\uc2a4\ub97c \ubc1b\uc558\uace0, \uc800\ud76c\ub294 \ubc30\ud3ec \ud658\uacbd\uc744 \ubd84\ub9ac\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![dev-prod-server](https://github.com/car-ffeine/car-ffeine.github.io/assets/63213487/52942893-3d8c-4c72-9972-278afa810d1d)\\n\\n\uc774\uc640 \uac19\uc774 \uae30\uc874 dev \uc11c\ubc84 \ud55c \uac1c\uac00 infra \uc11c\ubc84\uc640 \uc5f0\uacb0\ub418\uc5b4 \uc788\uc5c8\ub294\ub370, \ub450 \uac08\ub798\ub85c \ub098\ub25c \uac83\uc744 \ud655\uc778\ud558\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \ubc30\ud3ec\ub294 \ub2e4\uc74c\uacfc \uac19\uc774 \uc9c4\ud589\ub429\ub2c8\ub2e4.\\n\\n`release branch`\uc5d0 push\uac00 \uc77c\uc5b4\ub098\uba74 `dev\uc11c\ubc84\uc5d0 \ubc30\ud3ec \uc791\uc5c5`\uc774 \uc774\ub904\uc9d1\ub2c8\ub2e4.\\n`prod branch`\uc5d0 push\uac00 \uc77c\uc5b4\ub098\uba74 `prod\uc11c\ubc84\uc5d0 \ubc30\ud3ec \uc791\uc5c5`\uc774 \uc774\ub904\uc9d1\ub2c8\ub2e4.\\n\\n\ub610\ud55c \uae30\uc874 dev \uc11c\ubc84\uc5d0\uc11c 4\uac1c\uc758 \ud3ec\ud2b8\ud3ec\uc6cc\ub529 \ub610\ud55c \uad73\uc774 \uadf8\ub7f4 \ud544\uc694\uac00 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4.\\n\uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ucd94\uac00\ub428\uc5d0 \ub530\ub77c dev, prod \uc11c\ubc84 \uac01\uac01 Nginx\uc5d0\uc11c \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \ub3d9\uc77c\ud558\uac8c `FE:3000, BE:8080` \uc73c\ub85c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 dev, prod \ud658\uacbd\uc744 \ubd84\ub9ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uac10\uc0ac\ud569\ub2c8\ub2e4!"},{"id":"26","metadata":{"permalink":"/26","source":"@site/blog/2023-08-16-how-fe-test.mdx","title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","description":"\uc548\ub155\ud558\uc138\uc694, \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc5b4\ub5bb\uac8c \ud558\uace0 \uc788\uc744\uae4c\uc694?","date":"2023-08-16T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 16\uc77c","tags":[{"label":"\ud14c\uc2a4\ud2b8","permalink":"/tags/\ud14c\uc2a4\ud2b8"},{"label":"test","permalink":"/tags/test"}],"readingTime":7.65,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"26","title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","authors":["gabriel"],"tags":["\ud14c\uc2a4\ud2b8","test"]},"prevItem":{"title":"EC2 \uc11c\ubc84 \ucd94\uac00\uc640 \ub3d9\uc2dc\uc5d0 Dev, Prod \ud658\uacbd \ubd84\ub9ac\ud558\uae30","permalink":"/27"},"nextItem":{"title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","permalink":"/25"}},"content":"\uc548\ub155\ud558\uc138\uc694, \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc5b4\ub5bb\uac8c \ud558\uace0 \uc788\uc744\uae4c\uc694?\\n\\n\uc77c\ubc18\uc801\uc73c\ub85c \uc18c\ud504\ud2b8\uc6e8\uc5b4 \ud14c\uc2a4\ud2b8\ub780 \ubc31\uc5d4\ub4dc\uc5d0\uc11c \uadf8 \uc911\uc694\uc131\uc774 \uac15\uc870\ub418\uace4 \ud558\uc9c0\ub9cc, \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c\ub3c4 \uadf8\uc5d0 \ubabb\uc9c0 \uc54a\uac8c \uc911\uc694\ud55c \ubd80\ubd84\uc744 \ucc28\uc9c0\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc218\ub9ce\uc740 \ud234 \uc911\uc5d0\uc11c \uc5b4\ub5a4 \ud14c\uc2a4\ud2b8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub294\uc9c0 \uc18c\uac1c\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ub2e4\uc74c\uacfc \uac19\uc740 \ud504\ub860\ud2b8\uc5d4\ub4dc \ud14c\uc2a4\ud2b8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### Jest\\nJest\ub294 JavaScript\uc758 \ud14c\uc2a4\ud2b8\ub97c \uc704\ud55c \ub300\ud45c\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\uae30\ubcf8 \uc124\uc815\uc774 \uac04\ud3b8\ud558\uace0, \ube60\ub974\uac8c \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud560 \ub54c \uad49\uc7a5\ud788 \uc720\uc6a9\ud569\ub2c8\ub2e4.\\n\ud568\uc218\ub97c mocking\ud558\uc5ec \uc758\uc874\uc131\uc774 \uac15\ud55c \ud568\uc218\ub97c \uc81c\uac70\ud558\uc5ec \uc6d0\ud558\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc27d\uac8c \uad6c\uc131\ud560 \uc218 \uc788\ub2e4\ub294 \ud2b9\uc9d5\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n### React Testing Library\\nReact Testing Library\ub294 \ub9ac\uc561\ud2b8 \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc758 UI\ub97c \ud14c\uc2a4\ud2b8\ud558\uae30 \uc704\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\nReact \ucef4\ud3ec\ub10c\ud2b8\ub97c \ud638\ucd9c\ud558\uc5ec, \uc0ac\uc6a9\uc790\uc758 \uc758\ub3c4\ub300\ub85c \uc870\uc791\ud560 \uc218 \uc788\ub294 \ud589\uc704\ub97c \uc815\uc758\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc0ac\uc6a9\uc790 \uc785\uc7a5\uc5d0\uc11c \uc0c1\ud638\uc791\uc6a9 \ud560 \uc218 \uc788\ub294 \ubd80\ubd84\uc744 \uc2a4\ud06c\ub9bd\ud2b8\ub85c \uc791\uc131\ud558\uc5ec \ucef4\ud3ec\ub10c\ud2b8\uac00 \uc5b4\ub5bb\uac8c \ubcc0\ud654\ud558\ub294\uc9c0\ub97c \ud14c\uc2a4\ud2b8 \ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\uac00\ub839, \uc5b4\ub5a4 \uc0ac\uc6a9\uc790\uac00 \uc5b4\ub5a4 \ud3fc\uc5d0 \uc5b4\ub5a4 \uac12\uc744 \uc785\ub825\ud588\uc744 \ub54c\uc758 \uc608\uc0c1\ub418\ub294 \uacb0\uacfc\ub97c \uc791\uc131\ud574\ub450\uba74 \uc774\ud6c4\uc5d0 \ucf54\ub4dc \uc791\uc5c5 \uc911 \ubc84\uadf8\uac00 \ubc1c\uc0dd\ud55c\ub2e4\uba74 \ud574\ub2f9 \uc704\uce58\uc5d0\uc11c \ud14c\uc2a4\ud2b8\uac00 \uc2e4\ud328\ud560 \uac83\uc785\ub2c8\ub2e4.\\n\\n### Storybook\\nStorybook\uc740 UI\ub97c \ucef4\ud3ec\ub10c\ud2b8 \ub2e8\uc704\ub85c \uac1c\ubc1c\ud558\uace0 \uadf8 \uc989\uc2dc \uc2dc\uac01\ud654 \ud560 \uc218 \uc788\ub3c4\ub85d \ub3d5\ub294 \ud14c\uc2a4\ud305 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\ucef4\ud3ec\ub10c\ud2b8\ub97c \ub208 \uc55e\uc5d0 \ubc14\ub85c \ubcf4\uc5ec\uc8fc\uace0 \uc2e4\uc81c \ub9ac\uc561\ud2b8\uc5d0\uc11c \ub3d9\uc791\ud558\ub294 \uac83 \ucc98\ub7fc \ucef4\ud3ec\ub10c\ud2b8 \ub2e8\uc704\ub85c \uac1c\ubc1c\uc744 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. CDD\ub97c \uc9c0\ud5a5\ud55c\ub2e4\uba74 \uad49\uc7a5\ud788 \uc720\uc6a9\ud55c \uae30\ub2a5\uc774\uba70, \uac1c\ubc1c\uc790\uac00 \uc544\ub2cc \ud611\uc5c5\uc790\uc5d0\uac8c\ub3c4 \uc6d0\ud65c\ud55c \ucee4\ubba4\ub2c8\ucf00\uc774\uc158\uc744 \ub3c4\uc640\uc90d\ub2c8\ub2e4.\\n\ucef4\ud3ec\ub10c\ud2b8 \ub2e8\uc704\ub85c \uac1c\ubc1c\ud558\uae30 \ub54c\ubb38\uc5d0 \uac1c\ubcc4 \ucef4\ud3ec\ub10c\ud2b8\uac00 \uc5b4\ub5bb\uac8c \ub3d9\uc791\ud558\ub294\uc9c0 \ud655\uc778\ud560 \uc218 \uc788\ub2e4\ub294 \uac83 \uc790\uccb4\uac00 \uad49\uc7a5\ud55c \uc774\uc810\uc73c\ub85c \uc791\uc6a9\ud569\ub2c8\ub2e4.\\n\uc608\ub97c \ub4e4\uc5b4 \uc5b4\ub5a4 \ucef4\ud3ec\ub10c\ud2b8\uac00 \ud2b9\uc815 \uba54\ub274 \uc548\uc5d0 \uc874\uc7ac\ud574\uc57c \ud55c\ub2e4\uba74, \uc774\uac83\uc744 \ud655\uc778\ud558\uae30 \uc704\ud574 \ud574\ub2f9 \uba54\ub274\uae4c\uc9c0 \uc811\uadfc\ud574\uc57c \ud560 \uac83\uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc Storybook\uc744 \uc774\uc6a9\ud558\uba74 \ud2b9\uc815 \ucef4\ud3ec\ub10c\ud2b8\ub97c Storybook \uc704\uc5d0 \uc62c\ub824\ub193\uace0 \ud14c\uc2a4\ud2b8\ub97c \ud560 \uc218 \uc788\uc5b4 \ube60\ub974\uac8c \uc791\uc5c5\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\uc778\ud130\ub809\uc158\uc774\ub098 \uc6f9\uc811\uadfc\uc131\uc744 \ud655\uc778\ud574\uc8fc\ub294 \ud50c\ub7ec\uadf8\uc778\ub3c4 \uc874\uc7ac\ud558\uc5ec \ud504\ub860\ud2b8\uc5d4\ub4dc \uac1c\ubc1c\uc5d0\uc11c \uad49\uc7a5\ud788 \uc911\uc694\ud55c \uc5ed\ud560\ub85c \ubd80\uc0c1\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c \ud300\uc740 \uc774\uc678\uc5d0 Cypress\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\ub3c4 \uace0\ub824\ud558\uc600\uc73c\ub098, \uc9c0\ub3c4\uc640 \uacb0\ud569\ub41c \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \ud14c\uc2a4\ud2b8\ud558\uae30\uc5d0 \ub2e4\uc18c \uc5b4\ub824\uc6c0\uc774 \uc788\uc5b4 \uc704 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc744 \uac1c\ubc1c\uc5d0 \ud65c\uc6a9\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c\ub294 \uc704 \ud14c\uc2a4\ud305 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc744 \uc6d0\ud65c\ud788 \ud65c\uc6a9\ud558\uae30 \uc704\ud574 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654\ub97c \uad6c\ucd95\ud588\uc2b5\ub2c8\ub2e4.\\n\\n## Jest\uc640 React Testing Library \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654\\n\\n```yaml\\nname: frontend-test\\n\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n paths:\\n - frontend/**\\n - .github/**\\n\\npermissions:\\n contents: read\\n\\njobs:\\n test:\\n name: test-when-pull-request\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./frontend\\n steps:\\n - name: Checkout PR\\n uses: actions/checkout@v2\\n - name: Install dependencies\\n run: npm install\\n - name: Test\\n run: npm run test\\n```\\n\\n\\n#### \uc774\ubca4\ud2b8 \ud2b8\ub9ac\uac70 \uc124\uc815\\npull_request \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud558\uc600\uc744 \ub54c, \ud574\ub2f9 \uc774\ubca4\ud2b8\uac00 main \ube0c\ub79c\uce58\uc640 develop \ube0c\ub79c\uce58\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud569\ub2c8\ub2e4.\\n\\n#### \ubcc0\uacbd \uc0ac\ud56d \uacbd\ub85c \uc81c\ud55c\\n\ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud560 \ub54c\ub294 frontend \ub514\ub809\ud1a0\ub9ac\uc640 .github \ub514\ub809\ud1a0\ub9ac \ub0b4\uc758 \ud30c\uc77c\ub4e4\uc744 \uace0\ub824\ud558\ub3c4\ub85d \ud588\uc2b5\ub2c8\ub2e4. \ubc31\uc5d4\ub4dc\uc640\uc758 \ud658\uacbd \ubd84\ub9ac\ub97c \uc704\ud574 \uc774\ub7ec\ud55c \uc811\uadfc \uc81c\ud55c\uc744 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n#### \uad8c\ud55c \uc124\uc815\\npermissions\uc740 \uc77d\uae30 \uad8c\ud55c\ub9cc \uc124\uc815\ub418\uc5b4 \uc788\uc5b4 \ucf54\ub4dc\ub098 \ud30c\uc77c\uc744 \ubcc0\uacbd\uc744 \ubc29\uc9c0\ud569\ub2c8\ub2e4.\\n\\n#### \uc791\uc5c5(Job) \uc124\uc815\\ntest\ub77c\ub294 \uc774\ub984\uc758 \uc791\uc5c5\uc744 \uc815\uc758\ud558\uc600\uace0, \uc774 \uc791\uc5c5\uc5d0\uc11c\ub294 Ubuntu \ud658\uacbd\uc5d0\uc11c \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud569\ub2c8\ub2e4. test\ub77c\ub294 \uc774\ub984\uc758 \ud658\uacbd \ubcc0\uc218\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4. \ud14c\uc2a4\ud2b8\ub294 (\uce74\ud398\uc778 \ud300 \ub808\ud3ec\uc9c0\ud1a0\ub9ac\uc758) frontend \ub514\ub809\ud1a0\ub9ac\uc5d0\uc11c \uc791\uc5c5\ud558\ub3c4\ub85d \ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n#### \uc2a4\ud15d(Step) \uc124\uc815\\n\ucf54\ub4dc\ub97c \uccb4\ud06c\uc544\uc6c3\ud558\uace0, \uc758\uc874\uc131\uc744 \uc124\uce58\ud558\uba70, \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud558\ub294 \uc138 \uac00\uc9c0 \ub2e8\uacc4\ub85c \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n\\n\uc774\ub7ec\ud55c \uc124\uc815\uc744 \ud1b5\ud574 PR\uc5d0 \ucf54\ub4dc\uac00 \uc62c\ub77c\uc62c \ub54c \uc790\ub3d9\uc73c\ub85c \ud504\ub860\ud2b8\uc5d4\ub4dc \ud14c\uc2a4\ud2b8\uac00 \uc2e4\ud589\ub429\ub2c8\ub2e4.\\n\\n\uc774\ub7ec\ud55c \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654 \uc804\ub7b5\uc740 \ud504\ub860\ud2b8\uc5d4\ub4dc \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc548\uc815\uc801\uc774\uac8c \uac1c\ubc1c\ud558\uace0 \uc720\uc9c0\ud560 \uc218 \uc788\ub3c4\ub85d \ub3c4\uc640\uc90d\ub2c8\ub2e4.\\n\\n## Storybook\uc758 \ube4c\ub4dc \uc790\ub3d9\ud654\\n\\n```yaml\\nname: storybook-deploy\\n\\non:\\n pull_request:\\n branches:\\n - develop\\n paths:\\n - frontend/**\\n - .github/**\\n\\njobs:\\n build:\\n runs-on: ubuntu-22.04\\n defaults:\\n run:\\n working-directory: ./frontend\\n steps:\\n - name: Setup Repository\\n uses: actions/checkout@v3\\n\\n - name: Set up Node\\n uses: actions/setup-node@v3\\n with:\\n node-version: 18.16.0\\n\\n - name: Install dependencies\\n run: npm install\\n\\n - name: Cache node_modules\\n id: cache\\n uses: actions/cache@v3\\n with:\\n path: \'**/node_modules\'\\n key: ${{ runner.os }}-node-${{ hashFiles(\'**/package-lock.json\') }}\\n restore-keys: |\\n ${{ runner.os }}-node-\\n\\n - name: storybook build\\n run: npm run build-storybook\\n\\n - name: Upload storybook build files to temp artifact\\n uses: actions/upload-artifact@v3\\n with:\\n name: Storybook\\n path: frontend/storybook-static\\n deploy:\\n needs: build\\n runs-on: self-hosted\\n steps:\\n - name: Remove previous version app\\n working-directory: .\\n run: rm -rf dist\\n\\n - name: Download the built file to AWS\\n uses: actions/download-artifact@v3\\n with:\\n name: Storybook\\n path: frontend/dev/dist\\n\\n - name: Move folder\\n working-directory: frontend/dev/\\n run: |\\n rm -rf /home/ubuntu/dist/*\\n cp -r ./dist /home/ubuntu\\n\\n - name: comment PR\\n uses: thollander/actions-comment-pull-request@v1\\n env:\\n GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\\n with:\\n message: \'\ud83d\ude80storybook: https://storybook.carffe.in/\'\\n```\\n\\n\ube44\uc2b7\ud55c \ucf54\ub4dc\uc774\uc9c0\ub9cc, \ub9e4\ubc88 PR\uc774 \uc5f4\ub9b4 \ub54c \ub9c8\ub2e4 \uc2a4\ud1a0\ub9ac\ubd81\uc774 \uc790\ub3d9\uc73c\ub85c \ube4c\ub4dc \ubc0f \ubc30\ud3ec\ub429\ub2c8\ub2e4.\\n\ubc30\ud3ec\uac00 \uc644\ub8cc\ub418\uba74 \ubc30\ud3ec\ub41c URL\uc744 \uc54c\ub824 \ucf54\ub4dc \ub9ac\ubdf0\ud560 \ub54c \ucc38\uace0\ud560 \uc218 \uc788\ub3c4\ub85d \ub3d5\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc0c1 \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\uace0 \uc788\ub294 \ud14c\uc2a4\ud305 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc640 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654 \ubc29\ubc95\uc744 \uc54c\uc544\ubd24\uc2b5\ub2c8\ub2e4."},{"id":"25","metadata":{"permalink":"/25","source":"@site/blog/2023-08-15-flyway.mdx","title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","description":"\uc548\ub155\ud558\uc138\uc694","date":"2023-08-15T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 15\uc77c","tags":[{"label":"hello","permalink":"/tags/hello"},{"label":"world","permalink":"/tags/world"}],"readingTime":7.585,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"25","title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","authors":["boxster"],"tags":["hello","world"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300 \ud074\ub77c\uc774\uc5b8\ud2b8\uc758 \ud14c\uc2a4\ud2b8 \uc790\ub3d9\ud654","permalink":"/26"},"nextItem":{"title":"Out of memory trouble shooting","permalink":"/24"}},"content":"\uc548\ub155\ud558\uc138\uc694\\n\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\\n\uc800\ud76c \ud300\uc740 flyway\ub97c \uc801\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uac00\uc7a5 \ud070 \uc774\uc720\ub294 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ub370\uc774\ud130\ub97c drop \ud560 \uc218 \uc5c6\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n\ub370\uc774\ud130\ubca0\uc774\uc2a4\ub97c drop\ud558\ub294 \uac83\uacfc flyway\uac00 \ubb34\uc2a8 \uc0c1\uad00\uc774 \uc788\uae38\ub798 \uc801\uc6a9\ud560\uae4c\uc694.\\n\\n### \uc608\uc2dc \uc0c1\ud669\\n\\n\uc81c\uac00 \uc544\ub798\uc640 \uac19\uc774 Member\ub77c\ub294 entity\ub97c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```java\\nclass Member {\\n\\n private Long id;\\n private String name;\\n}\\n```\\n\uc9c0\uae08\uc758 entity\ub294 \ub450\uac1c\uc758 \ud544\ub4dc \ubc16\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4. \uc5b4\ub290 \ub0a0\ubd80\ud130 Member\uc5d0 email\uc774\ub77c\ub294 \uc815\ubcf4\uac00 \uc788\uc5b4\uc57c\ud55c\ub2e4\ub294 \uc694\uad6c\uc0ac\ud56d\uc774 \uc0dd\uae41\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc800\ud76c\ub294 \uc544\ub798\uc640 \uac19\uc774 email\uc744 \ucd94\uac00\ud569\ub2c8\ub2e4.\\n```java\\nclass Member {\\n\\n private Long id;\\n private String name;\\n private String email;\\n}\\n```\\n\uadf8\ub9ac\uace0 \ub2e4\uc2dc jpa\uc758 ddl-auto \uc18d\uc131 \uc911 create\ub97c \uc0ac\uc6a9\ud574\uc11c \uc0c8\ub85c\uc6b4 \ud14c\uc774\ube14\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\uc874\uc758 \ud14c\uc774\ube14\uc744 \ub2e4 \ub0a0\ub9ac\uba74\uc11c\uc694.\\n\\n\ud558\uc9c0\ub9cc \uc800\ud76c\uc758 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc758 \ub370\uc774\ud130\ub4e4\uc744 \uadf8\ub0e5 drop\ud574\ub3c4 \ub418\ub294 \uac83\uc77c\uae4c\uc694?\\n\uac1c\ubc1c \uc11c\ubc84\ub77c\ub3c4 \ud798\ub4e4\uac8c \uc313\uc740 \ub370\uc774\ud130\ub4e4\uc744 \ud14c\uc774\ube14\uc774 \uc870\uae08 \ubcc0\uacbd\ub418\uc5c8\ub2e4\uace0 \ub0a0\ub824\ubc84\ub9ac\ub294 \uac83\uc740 \ubc14\ubcf4\uac19\uc740 \uc77c\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub7ec\uba74 ddl-auto\uc758 \ub2e4\ub978 \uc870\uac74\uc778 update\ub97c \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4. \uadf8\ub7ac\ub354\ub2c8 jpa\uac00 \uc544\ub798\uc640 \uac19\uc774 \ucffc\ub9ac\ub97c \uc774\uc058\uac8c \ub9cc\ub4e4\uc5b4 \uc92c\uc2b5\ub2c8\ub2e4.\\n```sql\\nALTER TABLE member\\n ADD COLUMN email varchar(255);\\n```\\nupdate\ub97c \uc0ac\uc6a9\ud558\ub2c8 \uc544\uc8fc \ud3b8\ud558\uac8c \uce7c\ub7fc\uc774 \ucd94\uac00\ub418\ub294 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc5ec\uae30\uc11c \ub610 \uc544\ub798\uc640 \uac19\uc740 \uc694\uad6c\uc0ac\ud56d\uc774 \ucd94\uac00\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\nemail\uc758 \uc81c\uc57d\uc870\uac74\uc73c\ub85c null\uc774 \ub418\uba74 \uc548\ub418\uace0, \uae38\uc774\ub294 20\uc790\uac00 \ub418\uc5b4\uc57c\ud569\ub2c8\ub2e4.\\n\uadf8\ub7ec\uba74 \uc5b4\ub178\ud14c\uc774\uc158\uc744 \uc0ac\uc6a9\ud558\uc5ec \ubcc0\uacbd\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\nclass Member {\\n\\n private Long id;\\n private String name;\\n @Column(nullable= false, length = 20)\\n private String email;\\n}\\n```\\n\uc774\ub807\uac8c \ud558\uace0 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc7ac\uc2dc\uc791 \ud588\uc2b5\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uc544\ubb34\ub7f0 ddl\uc774 \ubc1c\uc0dd\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc65c\ub0d0\uba74 Jpa\uc758 ddl-auto: update\uc758 \uc18d\uc131\uc740 \uc81c\uc57d\uc870\uac74\uc774 \ubcc0\uacbd\ub41c \uac83\uc740 \ubc18\uc601\ud574\uc8fc\uc9c0 \uc54a\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub9cc\uc57d \uc774 \uc804\uc758 \ud68c\uc6d0\ub4e4\uc758 email\uc774 null\uc778 row\ub3c4 \uc788\ub2e4\uba74 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694? \uc81c\uc57d\uc870\uac74\uc744 \ubc18\uc601\ud560 \uc218 \uc5c6\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uc2dd\uc73c\ub85c \uc6b4\uc601 \ub3c4\uc911 table\uc758 \uce7c\ub7fc\ub4e4\uc774 \ucd94\uac00\ub418\uac70\ub098, \uc0ad\uc81c\ub418\uac70\ub098, \ud639\uc740 \uc81c\uc57d\uc870\uac74\uc774 \ubcc0\uacbd\ub420 \ub54c update \uc18d\uc131\ub9cc\uc73c\ub85c\ub294 \ubc18\uc601\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n## flyway\\n\\n\uadf8\ub798\uc11c flyway\ub97c \uc0ac\uc6a9\ud588\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 flyway \uc5c6\uc774\ub3c4 \uc774\ub7f0 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubc29\ubc95\uc740 \uac04\ub2e8\ud569\ub2c8\ub2e4. \ub370\uc774\ud130\ubca0\uc774\uc2a4\uac00 \uc788\ub294 \uc11c\ubc84\uc5d0 \uc9c1\uc811 \uc811\uc18d\ud558\uc5ec ddl\uc744 \uc9c1\uc811 \ud558\ub098 \ud558\ub098 \ub2e4 \uc791\uc131\ud558\uba74 \ub429\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ub7f0 \ubc29\uc2dd\uc5d0\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ud558\ub098 \ud558\ub098 \uc9c1\uc811 \uc785\ub825\ud558\ub2e4\ubcf4\ub2c8 \ud734\uba3c \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \uc218\ub3c4 \uc788\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub9e4\ubc88 \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc11c\ubc84\uc5d0 \uc811\uc18d\ud574\uc57c\ud55c\ub2e4\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ub9e4\ubc88 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc811\uc18d\uc744 \ud574\uc57c\ud55c\ub2e4\uba74 cd\ub97c \ud558\ub294 \uc774\uc720\uac00 \uc788\uc744\uae4c\uc694?\\n\\n\ud558\uc9c0\ub9cc flyway\ub97c \uc0ac\uc6a9\ud558\uba74 \ud3b8\ud558\uac8c \ubcc0\uacbd\ub41c schema\ub97c \uad00\ub9ac\ud560 \uc218 \uc788\uace0 \uc5b8\uc81c \ubc14\ub00c\uc5c8\ub294\uc9c0 \uc5b4\ub5bb\uac8c \ubc14\ub00c\uc5c8\ub294\uc9c0 \ud655\uc778\ub3c4 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uae00\ub85c\ub294 \uc798 \uc640\ub2ff\uc9c0 \uc54a\uc744 \uc218\ub3c4 \uc788\uc73c\ub2c8 \uc0ac\uc6a9\ubc95\uc744 \ud655\uc778\ud558\uba74\uc11c \uc5b4\ub5a4 \uc7a5\uc810\uc774 \uc788\ub294\uc9c0 \ud655\uc778\ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 flyway \uc758\uc874\uc131\uc744 \ucd94\uac00\ud558\uace0 `resources/db/migration` \ud328\ud0a4\uc9c0\ub97c \ub9cc\ub4ed\ub2c8\ub2e4.\\n\uac70\uae30\uc5d0 file\uc744 \ub9cc\ub4ed\ub2c8\ub2e4. \ud30c\uc77c \uc774\ub984\uc774 \uc911\uc694\ud55c\ub370\uc694 `V1__init.sql` \uc774\ub7ec\ud55c \ubc29\uc2dd\uc73c\ub85c `V{version \uc22b\uc790}__{\uc5b4\ub5a0\ud55c \ud30c\uc77c\uc778\uc9c0\uc5d0 \ub300\ud55c \uc774\ub984}.sql` \uc5b8\ub354\uc2a4\ucf54\uc5b4 2\uac1c\ub294 \ud544\uc218\ub85c \uc791\uc131\ud574\uc57c\ud569\ub2c8\ub2e4.\\n\\n```sql\\ncreate table member(\\n id bigint auto_increment primary key,\\n name varchar(255) null,\\n);\\n```\\n\uc774\ub807\uac8c `V1__init.sql`\uc5d0 \ub300\ud55c \ud30c\uc77c\uc744 \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4. \uc774\uc81c\ub294 email\uc744 \ucd94\uac00\ud55c\ub2e4\ub294 \uc694\uad6c\uc0ac\ud56d\uc744 \ubc18\uc601\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```sql\\nALTER TABLE member\\n ADD COLUMN email varchar(255);\\n```\\n\uc774\ub807\uac8c \uc0c8\ub85c\uc6b4 \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5b4\uc11c \ud574\ub2f9 \uc2a4\ud06c\ub9bd\ud2b8\ub97c \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4. \ud30c\uc77c\uba85\uc774 \uc911\uc694\ud55c\ub370\uc694, \uc774\uc804 \ud30c\uc77c\uc758 \uc22b\uc790\ubcf4\ub2e4 +1 \uc774 \ub418\ub294 \uc22b\uc790\ub97c V \ub4a4\uc5d0 \ubd99\uc785\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc774\ubc88 \ud30c\uc77c\uc740 `V2__add_column_email.sql` \uc774\ub77c\uace0 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc774\uc81c \ub610 \uc2dc\uac04\uc774 \uc9c0\ub098 \ud68c\uc6d0\uc774 \ub9ce\uc544\uc84c\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc email\uc774 \uc5c6\ub294 \uc0ac\uc6a9\uc790\ub3c4 \ub9ce\uc2b5\ub2c8\ub2e4. \uc774 \uc0c1\ud669\uc5d0\uc11c email\uc744 not null\ub85c \ubcc0\uacbd\ud574\uc57c\ud55c\ub2e4\ub294 \uc694\uad6c\uc0ac\ud56d\uc774 \uc0dd\uacbc\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc544\ub798\uc640 \uac19\uc774 \ubc18\uc601\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n```sql\\nALTER TABLE member\\n MODIFY email VARCHAR(20) NOT NULL default \'default\'\\n```\\n\uc774\ub807\uac8c `V3__add_constraints.sql` \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\uba74 null\uc774 \uc788\ub358 row\ub4e4\uc740 email\uc774 default\uac00 \ub418\uace0 not null \uc81c\uc57d\uc870\uac74\uc774 \ud65c\uc131\ud654 \ub41c \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc8fc\uc5b4\uc9c4 \uc694\uad6c\uc0ac\ud56d\uc740 \ubaa8\ub450 \ub9cc\uc871\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uac70\uae30\uc5d0\ub2e4 v1, v2, v3 \uac00 \ub098\ub258\uc5b4\uc838\uc788\uc5b4\uc11c \uc5b4\ub290 \ucee4\ubc0b\ubd80\ud130 \ud574\ub2f9 sql\uc774 \ucd94\uac00\ub418\uc5c8\ub294\uc9c0\ub3c4 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 ddl-auto update\ub97c \uc0ac\uc6a9\ud558\uba74 \ubc18\uc601\ub418\uc9c0 \uc54a\uc558\ub358 \uc81c\uc57d\uc870\uac74\uc758 \ucd94\uac00\ub3c4 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub7ec\uba74 ddl-auto\uc758 \uc18d\uc131\uc744 validate\ub85c \ubcc0\uacbd\ud558\uc5ec, db schema\uc640 entity\uc758 \ud544\ub4dc\uac00 \ub2e4\ub974\uba74\\n\uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc774 \uc2e4\ud589\ub418\uc9c0 \uc54a\ub3c4\ub85d \ud574\uc11c \uc880 \ub354 \uc548\uc804\ud55c \uac1c\ubc1c\uc744 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\nflyway\ub294 roll back\uc744 \ud558\ub294 \uac83\uc774 \uc720\ub8cc\ub77c\uc11c, production \uc11c\ubc84\uc5d0\uc11c \ud639\uc740 \ub864\ubc31\uc744 \ud574\uc57c\ud558\ub294 \uc77c\uc774 \uc788\ub294 \uc11c\ubc84\uc5d0\uc11c\ub294 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc9c0 \uc54a\uc9c0\ub9cc,\\n\uc774\uc640 \uac19\uc774 \ub370\uc774\ud130\ub97c drop \ud560 \uc218 \uc5c6\ub294 \uc0c1\ud669\uc774\ub77c\uba74, \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc744 \uc774\uc720\uac00 \uc5c6\uc5b4\ubcf4\uc774\ub294 \uc88b\uc740 \ub3c4\uad6c\uc785\ub2c8\ub2e4.\\n\\n\uc9e7\uc740 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4."},{"id":"24","metadata":{"permalink":"/24","source":"@site/blog/2023-08-06-out-of-memory-trouble-shooting/index.mdx","title":"Out of memory trouble shooting","description":"\uc548\ub155\ud558\uc138\uc694 \ubd80\ub989\ubd80\ub989 \ud5c8\ub9ac\ucf00\uc778 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.","date":"2023-08-06T00:00:00.000Z","formattedDate":"2023\ub144 8\uc6d4 6\uc77c","tags":[{"label":"OOM","permalink":"/tags/oom"},{"label":"java","permalink":"/tags/java"},{"label":"trouble-shooting","permalink":"/tags/trouble-shooting"}],"readingTime":15.43,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"24","title":"Out of memory trouble shooting","authors":["boxster"],"tags":["OOM","java","trouble-shooting"]},"prevItem":{"title":"flyway\ub97c \uc774\uc81c\uc11c\uc57c \uc801\uc6a9\ud558\ub294 \uc774\uc720","permalink":"/25"},"nextItem":{"title":"Deadlock trouble shooting","permalink":"/23"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubd80\ub989\ubd80\ub989 \ud5c8\ub9ac\ucf00\uc778 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.\\n## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\ub294 \uc800\ud76c \uce74\ud398\uc778 \ud300\uc758 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub4e4\uc758 \uc0c8\ub85c\uc6b4 \uc815\ubcf4\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\uac70\ub098, \uc800\uc7a5\ud558\ub294 \ub85c\uc9c1\uc5d0\uc11c \uc544\ub798\uc640 \uac19\uc774 OOM(Out of memory)\uac00 \ubc1c\uc0dd\ud588\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n![error-log](./error-log.png)\\n\\n### \uc65c \ubc1c\uc0dd\ud588\uc744\uae4c\\n\\n\uba3c\uc800 \uac04\ub2e8\ud788 \uc800\ud76c\uac00 \ucc98\ud55c \uc0c1\ud669\uc5d0 \ub300\ud574 \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\ucc98\uc74c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \uc2e4\ud589\ud558\uba74 \uacf5\uacf5 API\ub97c \ud638\ucd9c\ud558\uc5ec \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\uc5d0 \ub300\ud55c \ubaa8\ub4e0 \uc815\ubcf4\ub4e4\uc744 \uac00\uc838\uc640 \uc800\uc7a5\ud569\ub2c8\ub2e4. (\ucda9\uc804\uc18c \uc57d 6\ub9cc \uacf3 + \ucda9\uc804\uae30 \uc57d 23\ub9cc \uae30)\\n\\n\ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \uc815\ubcf4\ub4e4\uc740 \uc218\uc815\uc774 \ub420 \uc218 \uc788\uace0, \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\uac00 \ucd94\uac00\ub420 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\ubbc0\ub85c \uc815\ud655\ud55c \uc815\ubcf4\uac00 \uc0ac\uc6a9\uc790\uc5d0\uac8c \uac00\uc7a5 \uc911\uc694\uc2dc\ub418\ub294 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc774\ub7ec\ud55c \uc815\ubcf4\ub4e4\uc774 \ub2a6\uac8c \ubc18\uc601\uc774 \ub41c\ub2e4\uac70\ub098, \ubc18\uc601\uc774 \ub418\uc9c0 \uc54a\ub294\ub2e4\uba74 \uc800\ud76c \uc11c\ube44\uc2a4\ub97c \uc0ac\uc6a9\ud560 \uc0ac\uc6a9\uc790\uac00 \uc5c6\uc744 \uac83\uc774\ub77c \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ud558\ub8e8\uc5d0 \ud55c \ubc88 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub4e4\uc758 \uc815\ubcf4\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\uace0, \ucd94\uac00\ub41c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \uc800\uc7a5\ud558\ub294 \ub85c\uc9c1\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub300\ub7b5\uc801\uc778 \ub85c\uc9c1\uc740 \uc544\ub798\uc640 \uac19\uc2b5\ub2c8\ub2e4.\\n```java\\n public void updatePeriodicStations() {\\n List stations = requestStations();\\n stationUpdateService.updateStations(stations);\\n }\\n\\n public void updateStations(List updatedStations) {\\n List stations = stationRepository.findAllFetch();\\n\\n Map savedStationsByStationId = stations.stream()\\n .collect(Collectors.toMap(Station::getStationId, Function.identity()));\\n\\n // \uc800\uc7a5\ub41c \uc815\ubcf4\uc640 \ube44\uad50\ud558\uc5ec \uc0c8\ub85c\uc6b4 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \ucc3e\ub294 \ub85c\uc9c1\\n ...\\n\\n saveAllStations(toSaveStations);\\n updateAllStations(toUpdateStations);\\n\\n saveAllChargers(toSaveChargers);\\n updateAllChargers(toUpdateChargers);\\n }\\n\\n```\\n\uac04\ub2e8\ud558\uac8c \ub9d0\uc500\ub4dc\ub9ac\uba74 `requestStations()` \uba54\uc11c\ub4dc\ub294 \uacf5\uacf5 API\uc5d0\uc11c \ubaa8\ub4e0 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \uc694\uccad\ud558\uace0 \ubc1b\uc544\uc624\ub294 \uba54\uc11c\ub4dc\uc785\ub2c8\ub2e4. 23\ub9cc + 6\ub9cc\uac1c\uc758 \uc815\ubcf4\ub97c \ubc1b\uc544\uc624\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ub9ce\uc740 \uc815\ubcf4\ub97c \ubc1b\uc544\uc624\uace0 \uba54\ubaa8\ub9ac\uc5d0 \uc62c\ub9b0\ub2e4\ub294 \uac83\uc740 \ub204\uac00\ubd10\ub3c4 \ube44\ud6a8\uc728\uc801\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub7ec\ud55c \uc120\ud0dd\uc744 \ud55c \uc774\uc720\ub294 \uacf5\uacf5 API\ub294 \uc800\ud76c\uac00 \uc5b4\ub5a4 \ubc29\uc2dd\uc73c\ub85c \ubcf4\ub0b4\uc904 \uc9c0 \ubaa8\ub978\ub2e4\ub294 \uac83\uc774\uc600\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc5b4\uca54 \uc218 \uc5c6\uc774 23\ub9cc\uac74\uc744 \ubaa8\ub450 \uc694\uccad\ud574\uc57c\ud55c\ub2e4\ub294 \ubd80\ubd84\uc740 \ubc14\uafc0 \uc218 \uc5c6\ub294 \ud55c\uacc4\uc785\ub2c8\ub2e4.\\n\\n\uadf8 \ub2e4\uc74c\uc73c\ub85c\ub294 \uc694\uccad\ud574\uc11c \ubc1b\uc544\uc628 \ub370\uc774\ud130\ub4e4\uacfc \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc800\uc7a5\ub418\uc5b4 \uc788\ub358 \ub370\uc774\ud130\ub4e4\uc744 `findAll()`\uc744 \ud1b5\ud574 \ube44\uad50\ud558\uace0 \uc0c8\ub85c\uc6b4 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 \uc800\uc7a5\ud558\uace0, \uc5c5\ub370\uc774\ud2b8\ub41c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 \uc218\uc815\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ub85c\uc9c1\uc740 \ucd1d (23 + 6) * 2 \ub9cc\uac74\uc758 \uac1d\uccb4 \uc57d 58\ub9cc\uac1c\ub97c Heap \uba54\ubaa8\ub9ac\uc5d0 \uc801\uc7ac\ud569\ub2c8\ub2e4. \ub9ce\ub2e4\uace0\ub294 \uc0dd\uac01\ud588\uc9c0\ub9cc, \uc77c\ub2e8 \uc81c \ub85c\uceec\ud658\uacbd\uc5d0\uc11c\ub294 \uc798 \uc791\ub3d9\ud588\uace0, \uae30\ub2a5 \uad6c\ud604\uc774 \uc6b0\uc120\uc774\uae30 \ub54c\ubb38\uc5d0 \ucd94\ud6c4\uc5d0 \uac1c\uc120\uc744 \ud558\uae30\ub85c \ud558\uace0 \ub118\uc5b4\uac14\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uac1c\ubc1c \uc11c\ubc84 \ubc30\ud3ec\ub97c \ud558\uace0 \ub2e4\uc74c\ub0a0 \uc11c\ubc84\uac00 \uc811\uc18d\uc774 \ub418\uc9c0 \uc54a\ub294 \uac83\uc744 \ud655\uc778\ud588\uace0, \ub85c\uadf8\ub97c \ubcf4\ub2c8 \uc704\uc758 \uc0ac\uc9c4\uacfc \uac19\uc774 OOM\uc774 \ubc1c\uc0dd\ud55c \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ud574\uacb0 \ubc29\uc548\\n\\n\\n### Heap size \uc870\uc808\ud558\uae30\\n\uc77c\ub2e8 \uc784\uc2dc \ubc29\ud3b8\uc73c\ub85c Heap memory\uc758 \ucd5c\ub300 \ud06c\uae30\ub97c \ub298\ub9ac\ub294 \ubc95\uc774\uc600\uc2b5\ub2c8\ub2e4. JVM\uc740 \uc2e4\ud589\ub418\ub294 \ud658\uacbd\uc5d0 \ub530\ub77c \ud799 \uba54\ubaa8\ub9ac\uc758 \ucd5c\ub300 \uc0ac\uc774\uc988\ub97c \uc815\ud569\ub2c8\ub2e4. \ud799 \uba54\ubaa8\ub9ac\ub294 \uc124\uc815\ud558\uc9c0 \uc54a\uc73c\uba74 \ud574\ub2f9 \ud658\uacbd\uc758 \uba54\ubaa8\ub9ac 1/4\ub85c \uc124\uc815\ud569\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc800\ud76c EC2 \uc778\uc2a4\ud134\uc2a4\uc758 \uba54\ubaa8\ub9ac\ub294 \uc57d 2\uae30\uac00\ub85c, \uc57d 500MB\uac00 \ud560\ub2f9\ub418\uc5b4 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub798\uc11c \uc800\ud76c\ub294 \uba54\ubaa8\ub9ac\ub97c \uc870\uae08\uc529 \ub298\ub824\uac00\uba70 \uc870\uc815\ud558\uc5ec \uc57d 1\uae30\uac00\ub85c \ud799 \uba54\ubaa8\ub9ac\uc758 \ucd5c\ub300 \uc0ac\uc774\uc988\ub97c \uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\ud799 \uba54\ubaa8\ub9ac\uc758 \uc124\uc815\uc744 \ud558\ub294 \ubc29\ubc95\uc740 \uac04\ub2e8\ud569\ub2c8\ub2e4.\\n```shell\\njava -Xms512m -Xmx1024m boxster.jar\\n```\\n\uc2e4\ud589\ud560 \ub54c \uc774\ub7ec\ud55c \ubc29\uc2dd\uc73c\ub85c \ud558\uba74 \ucd5c\uc18c \ud799 \uba54\ubaa8\ub9ac \uc0ac\uc774\uc988\ub294 512MB, \ucd5c\ub300 1024MB\ub85c \uc124\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ud398\uc774\uc9d5\ud574\uc11c \uac00\uc838\uc624\uae30\\n\ud799 \uba54\ubaa8\ub9ac\uc758 \uc0ac\uc774\uc988\ub97c \uc870\uc808\ud574\uc11c \ud574\uacb0\ud55c\ub2e4\ub294 \ubd80\ubd84\uc740 \uc784\uc2dc \ubc29\ud3b8\uc774\uc9c0 \ub9cc\uc57d \uc800\ud76c EC2 \ud658\uacbd\uc774 \ub2e4\uc6b4\uadf8\ub808\uc774\ub4dc \ub418\uac70\ub098 \ud55c\ub2e4\uba74 \ub610 OOM\uc774 \ubc1c\uc0dd\ud560 \uac83\uc774 \ubed4\ud569\ub2c8\ub2e4. \uadf8\ub798\uc11c \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158 \ub808\ubca8\uc5d0\uc11c \uc880 \ub354 \ud574\uacb0\ud560 \ubc29\uc548\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n\\nAPI\uc758 \uc694\uccad\uc5d0 \ub300\ud55c \ubd80\ubd84\uc740 \uc694\uccad\ubcf4\ub0b4\ub294 \ud68c\uc0ac\uc758 \uc815\ucc45\uc774 \ubc14\ub00c\uc9c0 \uc54a\ub294 \uc774\uc0c1 \uc800\ud76c\ub294 23\ub9cc\uac74\uc744 \ubaa8\ub450 \ub85c\ub529\ud574\uc57c\ud55c\ub2e4\ub294 \uc810\uc740 \uc5b4\uca54 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uba74 \uc800\ud76c\uac00 \uc81c\uc5b4\ud560 \uc218 \uc788\ub294 \uc720\uc77c\ud55c \ubd80\ubd84\uc740 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \ub370\uc774\ud130\ub97c \uaebc\ub0b4\uc624\ub294 \ubd80\ubd84 \ubc16\uc5d0 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 \uc774\uac83\uc744 \uc5b4\ub5bb\uac8c \uc870\uc808\ud560 \uc218 \uc788\uc744\uae4c\uc694.\\n\\n\uc5ec\ub7ec \ubc29\ubc95\uc744 \ucc3e\uc544\ubcf4\ub358 \uc911 `No Offset`\ubc29\uc2dd\uc73c\ub85c \ub370\uc774\ud130\ub97c \ud398\uc774\uc9d5\ud55c\ub2e4\ub294 \uae00\uc744 \uc77d\uc5c8\uc2b5\ub2c8\ub2e4. \ud398\uc774\uc9d5\uc744 \ud558\uae30\uc704\ud574\uc11c\ub294 \uc5b4\ub514\uc11c\ubd80\ud130 \uc2dc\uc791\ud558\uace0 \uc5b4\ub514\uae4c\uc9c0 \uac00\uc838\uc62c \uac83\uc778\uc9c0 \uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \uadf8 \uc911 \uba3c\uc800 \uc81c\uc77c \uc790\uc8fc \uc0ac\uc6a9\ub418\ub294 Offset \ubc29\uc2dd\uc5d0 \ub300\ud574 \uac04\ub2e8\ud788 \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\ud574\ub2f9 \ubc29\uc2dd\uc740\\n```sql\\nSELECT *\\nFROM station\\nORDER BY id DESC\\nOFFSET 20000\\nLIMIT 10000\\n```\\n\uc774\ub7ec\ud55c \ucffc\ub9ac\ub97c \ub9cc\ub4e4\uc5b4 \uc694\uccad\ud569\ub2c8\ub2e4. station \ud14c\uc774\ube14\uc758 20001\ubc88\uc9f8 \ub808\ucf54\ub4dc\ubd80\ud130 10000\uac1c\uc758 \ub370\uc774\ud130\ub97c \uc694\uccad\ud558\ub294 \ubc29\uc2dd\uc785\ub2c8\ub2e4. \uc774\ub7ec\ud55c \ucffc\ub9ac\ub3c4 \ub098\uc058\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\uc7a5\uc810\uc73c\ub85c\ub294 \uc5b8\uc81c\ub4e0 \ud574\ub2f9 \ud398\uc774\uc9c0\ub85c \uc774\ub3d9\ud560 \uc218 \uc788\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774 \ucffc\ub9ac\uc5d0\ub294 \ub2e8\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \ub4a4\ub85c \uac08\uc218\ub85d \uc131\ub2a5\uc774 \ub098\ube60\uc9c4\ub2e4\ub294 \uc810\uc785\ub2c8\ub2e4. 20001\ubc88\uc9f8 \ub808\ucf54\ub4dc\ubd80\ud130 10000\uac1c\ub97c \uc694\uccad\ud55c\ub2e4\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\ub294 \uc5b4\uca54 \uc218 \uc5c6\uc774 20001\ubc88\uc9f8 \ub808\ucf54\ub4dc\ub97c \ucc3e\uae30 \uc704\ud574\\n\uc815\ub82c\uc744 \ud558\uace0, \uc815\ub82c\ud55c \ud6c4\uc5d0 20001\ubc88\uc9f8\uae4c\uc9c0 \uc138\uc5b4\uac00\uba70 \uc77d\uace0, \uac70\uae30\uc11c\ubd80\ud130 10000\uac1c\uc758 \ub808\ucf54\ub4dc\ub97c \ubc18\ud658\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n![offset](./offset.png)\\n\\n\ud55c \ubb38\uc7a5\uc73c\ub85c \uc815\uc758\ud558\uba74, \uc21c\uc11c\ub97c \uc54c\uc544\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0 \ub0b4\uac00 \ud544\uc694\ud558\uc9c0 \uc54a\ub294 \ub808\ucf54\ub4dc\ub3c4 \uc77d\uc5b4\uc57c \ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n#### No Offset\\n\uadf8\ub7fc No offset \ubc29\uc2dd\uc5d0 \ub300\ud574 \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uc774\ub984\ub9cc \ub4e4\uc73c\uba74 \uc5b4\ub824\uc6b8 \uac83 \uac19\uc9c0\ub9cc \uadf8\ub0e5 offset\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0 \ud398\uc774\uc9d5\ud558\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc2a4\ud06c\ub864\uc744 \ub0b4\ub9ac\uba74\uc11c \uc790\ub3d9\uc73c\ub85c \ub9c8\uc9c0\ub9c9\uc758 \ub370\uc774\ud130\ub97c \uae30\uc900\uc73c\ub85c \ub2e4\uc74c \uba87\uac1c\uc758 \ub808\ucf54\ub4dc\ub97c \ubd88\ub7ec\uc624\ub294 \ubc29\uc2dd\uc774\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\ud574\ub2f9 \ubc29\uc2dd\uc740\\n```sql\\nselect *\\nfrom station\\nwhere id < \ub9c8\uc9c0\ub9c9\uc73c\ub85c \ubcf4\ub0b8 id\\norder by id desc\\nlimit 10000;\\n```\\n\uc774\ub7ec\ud55c \ucffc\ub9ac\ub85c \uc791\ub3d9\ud569\ub2c8\ub2e4. \uc544\uae4c\uc640\ub294 \ub2e4\ub978 \ubd80\ubd84\uc740 where \uc808\uc5d0 `\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ubcf4\ub0b8 id`\ub77c\ub294 \uc815\ubcf4\uac00 \ud544\uc694\ud558\ub2e4\ub294 \ubd80\ubd84\uacfc, offset\uc774 \uc0ac\ub77c\uc9c4 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\\n\uac19\uc740 \uacb0\uacfc\ub97c \ub9cc\ub4e4\uc5b4\ub0b4\ub294 \ucffc\ub9ac\uc9c0\ub9cc, \ud558\ub098\uac00 \ucd94\uac00\ub418\uace0 \ud558\ub098\uac00 \uc0ac\ub77c\uc84c\ub2e4\ub294 \uac83\uc740 \ucd94\uac00\ub41c \ubd80\ubd84\uc774 \uc0ac\ub77c\uc9c4 \ubd80\ubd84\uc744 \ub300\uc2e0\ud55c\ub2e4\ub294 \ub73b\uc774\uaca0\uc8e0.\\n\\n\uc774 \uc774\ub7ec\ud55c \ubc29\uc2dd\uc758 \ub2e8\uc810\uc740 offset\uc744 \uc774\uc6a9\ud55c \ubc29\uc2dd\uacfc\ub294 \ub2e4\ub974\uac8c page\ub97c \uc9c0\uc815\ud574\uc11c \ub3cc\uc544\uac00\uae30\ub294 \ud798\ub4ed\ub2c8\ub2e4.\\n\\n![no offset](./no-offset.png)\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ubcf4\ub0b8 id\ub97c \ubc1b\uc544 \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud574 \ud574\ub2f9 id\uc5d0\uc11c\ubd80\ud130 \ub808\ucf54\ub4dc\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4. \uad73\uc774 \ud544\uc694\uc5c6\ub294 \ub808\ucf54\ub4dc\ub97c \uc77d\uc744 \ud544\uc694 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \uc131\ub2a5\uc774 \uc88b\uc544\uc84c\uc744 \uac83\uc774\ub77c \uc608\uc0c1\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc131\ub2a5 \ucc28\uc774\\n\\n\ubc14\ub85c \ud55c\ubc88 \ub450 \uac1c\uc758 \ucffc\ub9ac\ub97c \uc2e4\ud589\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![test](./test.png)\\n\\n\uc704\uc758 \ucffc\ub9ac\ub294 no offset, \uc544\ub798\ub294 offset \ubc29\uc2dd\uc785\ub2c8\ub2e4. \ud604\uc7ac \ub370\uc774\ud130\uac00 6\ub9cc\uac74 \ub4e4\uc5b4\uc788\ub294 \ud14c\uc774\ube14\uc758 \uc870\ud68c \uae30\uc900\uc73c\ub85c \uc57d 10\ubc30 \uac00\ub7c9 \uc131\ub2a5\uc774 \ucc28\uc774\ub098\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc2e4\ud589 \uacc4\ud68d\ub3c4 \uac04\ub2e8\ud788 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 offset \ubc29\uc2dd\uc758 \uc2e4\ud589 \uacc4\ud68d\uc785\ub2c8\ub2e4.\\n![offset explain](./offset-explain.png)\\n\\n type \uce7c\ub7fc\uc744 \ubcf4\uc2dc\uba74 `index`\ub77c\uace0 \ub418\uc5b4 \uc788\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc5ec\uae30\uc11c index \uc811\uadfc \ubc29\ubc95\uc740\\n \uc778\ub371\uc2a4\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc544\ub2cc \uc778\ub371\uc2a4\ub97c \ucc98\uc74c\ubd80\ud130 \ub05d\uae4c\uc9c0 \uc77d\ub294 full scan\uc744 \ub73b\ud569\ub2c8\ub2e4. \uadf8\ub798\uc11c \uadf8\ub2e4\uc9c0 \ud6a8\uc728\uc801\uc774\uc9c0 \ubabb\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 rows \uce7c\ub7fc\uc5d0\ub294 `40010`\uc774\ub77c\uace0 \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ubd80\ubd84\uc740 \uc81c\uac00 offset\uc744 40000, limit\uc744 10\uc73c\ub85c \ub450\uc5c8\uae30 \ub54c\ubb38\uc5d0 40010d\uc758 row\ub97c\\n\uc77d\uc5b4\uc57c\ud55c\ub2e4\uace0 \uc608\uc0c1 \uac12\uc744 \ub098\ud0c0\ub0b8 \uac83\uc785\ub2c8\ub2e4.\\n\\n\ub2e4\uc74c\uc740 no offset \ubc29\uc2dd\uc758 \uc2e4\ud589 \uacc4\ud68d\uc785\ub2c8\ub2e4.\\n![no offset explain](./no-offset-explain.png)\\n\uc544\uae4c\uc640\ub294 \ub2e4\ub974\uac8c type \uce7c\ub7fc\uc740 `range`\uc785\ub2c8\ub2e4. range \uc811\uadfc \ubc29\uc2dd\uc740 \uc778\ub371\uc2a4\ub97c \ud558\ub098\uc758 \uac12\uc774 \uc544\ub2c8\ub77c \ubc94\uc704\ub85c \uac80\uc0c9\ud558\ub294 \uacbd\uc6b0\ub97c \uc758\ubbf8\ud569\ub2c8\ub2e4.\\n\uc880 \uc804\uc758 index \uc811\uadfc \ubc29\uc2dd\uacfc\ub294 \ub2e4\ub974\uac8c \ud6e8\uc52c \ud6a8\uc728\uc801\uc778 \uc811\uadfc \ubc29\uc2dd\uc785\ub2c8\ub2e4. \uadf8\ub9ac\uace0 rows\ub3c4 \ub2ec\ub77c\uc9c4 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc9c4\uc9dc \ud574\uacb0\ud558\uae30\\n\uc774\uc81c \uc5f4\uc2ec\ud788 \ud398\uc774\uc9d5 \ucc98\ub9ac\ub97c \ud588\uc73c\ub2c8 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc5d0\uc11c \ud574\uacb0\uc744 \ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5b4\uc57c\ud569\ub2c8\ub2e4.\\n\\n\uc800\ud76c \ud300\uc740 \ub3d9\uc801 \ucffc\ub9ac \uc0dd\uc131\uc744 \ub3c4\uc640\uc8fc\ub294 Query DSL\uc744 \ub3c4\uc785\ud558\uc9c0 \uc54a\uc558\uace0 \uc544\uc9c1\uae4c\uc9c4 \uad73\uc774 \ud544\uc694\ud558\uc9c0 \uc54a\uc544\uc11c no offset \ubc29\uc2dd\uc744 jpa\uc758 jpql\uc744 \ud1b5\ud574 \uad6c\ud604\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \uccab \ud398\uc774\uc9c0\ub294 id\uc758 \uad00\uacc4\uc5c6\uc774 \uc6d0\ud558\ub294 \uac2f\uc218\ub9cc\ud07c\ub9cc \uac00\uc838\uc624\uba74 \ub429\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub450\ubc88\uc9f8 \ud398\uc774\uc9c0\ubd80\ud130\ub294 id\ub97c \ubc1b\uc544 \uadf8 \ub2e4\uc74c\ubd80\ud130 \ubc18\ud658\ud558\uba74 \ub429\ub2c8\ub2e4.\\n```java\\npublic interface StationRepository extends Repository {\\n\\n @Query(\\"SELECT s FROM Station s INNER JOIN FETCH s.chargers ORDER BY s.stationId\\")\\n List findAllByOrder(Pageable pageable);\\n\\n @Query(\\"SELECT s FROM Station s INNER JOIN FETCH s.chargers WHERE s.stationId > :stationId ORDER BY s.stationId\\")\\n List findAllByPaging(@Param(\\"stationId\\") String stationId, Pageable pageable);\\n}\\n```\\n\uadf8\ub7fc \uc544\uae4c update\ub97c \ud574\uc8fc\ub358 \uba54\uc11c\ub4dc\uc5d0\uc11c \uc870\uae08 \uc218\uc815\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\n public void updatePeriodicStations() {\\n List stations = getStations();\\n // \ucc98\uc74c\uc5d0\ub294 station\uc758 id\uac00 null\\n String lastStationId = null;\\n for (int i = 0; i < stations.size() / LIMIT + 1; i++) {\\n // \ub9c8\uc9c0\ub9c9 id\ub97c \uba54\uc11c\ub4dc \uc2e4\ud589\ud560 \ub54c\ub9c8\ub2e4 \ubcc0\uacbd\ud574\uc900\ub2e4.\\n lastStationId = stationUpdateService.updateStations(stations, lastStationId, LIMIT);\\n }\\n }\\n\\n public String updateStations(List updatedStations, String lastStationId, int limit) {\\n List savedStations = getStations(lastStationId, limit);\\n\\n Map savedStationsByStationId = stations.stream()\\n .collect(Collectors.toMap(Station::getStationId, Function.identity()));\\n\\n // \uc800\uc7a5\ub41c \uc815\ubcf4\uc640 \ube44\uad50\ud558\uc5ec \uc0c8\ub85c\uc6b4 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub97c \ucc3e\ub294 \ub85c\uc9c1\\n ...\\n\\n saveAllStations(toSaveStations);\\n updateAllStations(toUpdateStations);\\n\\n saveAllChargers(toSaveChargers);\\n updateAllChargers(toUpdateChargers);\\n // \uac00\uc838\uc628 list\uc5d0\uc11c \uc81c\uc77c \ub9c8\uc9c0\ub9c9 station\uc758 id\ub97c \ubc18\ud658\\n return getLastStationId(savedStations);\\n }\\n // \ud398\uc774\uc9d5 \ucc98\ub9ac\\n private List getStations(String stationId, int limit) {\\n // Id \uac00 null \uc774\ub77c\uba74 \uccab \ud398\uc774\uc9c0\uc774\uae30 \ub54c\ubb38\uc5d0 limit \uc0ac\uc774\uc988\ub9cc\ud07c select\\n if (stationId == null) {\\n return stationRepository.findAllByOrder(Pageable.ofSize(limit));\\n }\\n // \uc544\ub2c8\ub77c\uba74 station Id \ubd80\ud130 limit \ub9cc\ud07c\\n return stationRepository.findAllByPaging(stationId, Pageable.ofSize(limit));\\n }\\n```\\n\uc774\ub807\uac8c \ub418\uba74 \uc6d0\ub798 23\ub9cc\uac1c\ub97c \ud55c\uaebc\ubc88\uc5d0 \uac00\uc838\uc624\ub358 \ub85c\uc9c1\uc744 \ub098\ub20c \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 Heap \uba54\ubaa8\ub9ac\uc758 \uc5ec\uc720\uac00 \uc0dd\uae38 \uac83\uc785\ub2c8\ub2e4.\\n\\n### \uc9c4\uc9dc \ud655\uc778\ud574\ubcf4\uae30\\n\\n\ubb3c\ub860 GC\uc758 \ub3d9\uc791\uc774 \uc5b4\ub5a8\uc9c0 \ubaa8\ub974\uaca0\uc9c0\ub9cc 23\ub9cc\uac1c \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uac83\ubcf4\ub2e4 5000\uac1c \ud639\uc740 \ub354 \uc801\uac8c \uc0dd\uc131\ud558\ub294 \uac83\uc774 Heap \uba54\ubaa8\ub9ac\ub97c \uc801\uac8c \uc0ac\uc6a9\ud560 \uac83\uc784\uc744 \uc720\ucd94\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uc9c1\uc811 \ud655\uc778\ud574\ubcf4\uae30 \uc804\uae4c\uc9c0\ub294 \ud655\uc2e0\ud560 \uc218 \uc5c6\uc73c\ub2c8 \uac04\ub2e8\ud788 `Runtime` \ud074\ub798\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud574\uc8fc\ub294 `totalMemory()`, `freeMemory()` \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n @Test\\n void \ud398\uc774\uc9d5\uc744_\uc0ac\uc6a9\ud55c_\uc870\ud68c() {\\n List stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));\\n\\n long total = Runtime.getRuntime().totalMemory();\\n long free = Runtime.getRuntime().freeMemory();\\n System.out.println(\\"paging \uc0ac\uc6a9 \uc911\uc778 \uba54\ubaa8\ub9ac: \\" + ((total - free) / 1024 / 1024) + \\"MB\\");\\n }\\n\\n @Test\\n void \ud398\uc774\uc9d5\uc744_\uc0ac\uc6a9\ud558\uc9c0_\uc54a\uace0_\uc870\ud68c() {\\n List stations = stationRepository.findAllFetch();\\n\\n long total = Runtime.getRuntime().totalMemory();\\n long free = Runtime.getRuntime().freeMemory();\\n\\n System.out.println(\\"findAll() \uc0ac\uc6a9 \uc911\uc778 \uba54\ubaa8\ub9ac: \\" + ((total - free) / 1024 / 1024) + \\"MB\\");\\n }\\n```\\n\\n![findAll](./findAll.png)\\n![paging](./paging.png)\\n\ud655\uc5f0\ud788 \ucc28\uc774\uac00 \ub098\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \ud14c\uc2a4\ud2b8\ucf54\ub4dc\uc5d0\uc11c\ub294 23\ub9cc\uac74\uc758 API \uc694\uccad\uc740 \uac19\uc740 \uc870\uac74\uc774\ub2c8 \ubc30\uc81c\ud558\uace0 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub85c\uc368 \ud558\ub098\uc758 \ubb38\uc81c\uac00 \ub610 \ud574\uacb0\ub41c \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\uc9c1 \ubc30\uc6b0\ub294 \ub2e8\uacc4\ub77c \ud639\uc2dc \ud2c0\ub9b0 \uc810\uc774 \uc788\ub2e4\uba74 \uc9c0\uc801 \ubd80\ud0c1\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## Reference\\n\\n- \ub9ac\uc5bc \ub9c8\uc774 \uc5d0\uc2a4\ud050\uc5d8 8.0\\n- https://jojoldu.tistory.com/528"},{"id":"23","metadata":{"permalink":"/23","source":"@site/blog/2023-07-31-deadlokc-trouble-shooting.mdx","title":"Deadlock trouble shooting","description":"\uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720","date":"2023-07-31T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 31\uc77c","tags":[{"label":"deadlock","permalink":"/tags/deadlock"},{"label":"trouble-shooting","permalink":"/tags/trouble-shooting"}],"readingTime":12.565,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"23","title":"Deadlock trouble shooting","authors":["boxster"],"tags":["deadlock","trouble-shooting"]},"prevItem":{"title":"Out of memory trouble shooting","permalink":"/24"},"nextItem":{"title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","permalink":"/22"}},"content":"## \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\\n\uba3c\uc800 \uc774 \uae00\uc744 \uc4f0\ub294 \uc774\uc720\ub294 \uc800\ud76c \uce74\ud398\uc778 \ud300\uc758 \ud63c\uc7a1\ub3c4 \uc800\uc7a5 \ubc0f \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8\ud558\ub294 \ub85c\uc9c1\uc5d0\uc11c dead Lock\uc774 \ubc1c\uc0dd\ud558\uc5ec mysql\uacfc connection\uc744 \uc783\ub294 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n```shell\\n------------------------\\nLATEST DETECTED DEADLock\\n------------------------\\n2023-07-21 01:49:54 281472560787424\\n*** (1) TRANSACTION:\\nTRANSACTION 1000560, ACTIVE 373 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328\\nMySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST414511\', \'01\', \'2023-07-21 08:27:43\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 08:27:43\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (1) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap\\n\\n*** (1) WAITING FOR THIS Lock TO BE GRANTED:\\nRECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting\\n\\n*** (2) TRANSACTION:\\nTRANSACTION 946331, ACTIVE 507 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432\\nMySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST412801\', \'11\', \'2023-07-21 10:48:20\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 10:48:20\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (2) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap\\n\\n*** (2) WAITING FOR THIS Lock TO BE GRANTED:\\nRECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting\\n\\n\\n```\\n\uc2e4\uc81c **\uac1c\ubc1c \uc11c\ubc84**\uc5d0\uc11c \ubc1c\uc0dd\ud55c \ub370\ub4dc\ub77d\uc758 \ub85c\uadf8\uc785\ub2c8\ub2e4. \ud574\ub2f9 \ub85c\uadf8\ub294 charger_status\uc5d0 \uc800\uc7a5 \uc2dc \uc11c\ub85c XLock\uc744 \ud68d\ub4dd\ud558\uc9c0 \ubabb\ud558\uc5ec \uc0dd\uae30\ub294 \uc5d0\ub7ec\uc785\ub2c8\ub2e4.\\n\\n## Mysql Dead Lock\uc774\ub780\\n\\n\uadf8\ub7fc Dead Lock\uc740 \uc65c \uc0dd\uae30\uace0 \uc5b8\uc81c \uc0dd\uae38\uae4c\uc694?\\n\uc800\ub294 \uc774 Log\ub97c \uc9c1\uc811 \ub9c8\uc8fc\ud558\uae30 \uc804\uae4c\uc9c0\ub294 Dead Lock\uc774 \uadf8\ub0e5 Lock\uc758 \uc2dc\uac04\uc774 \uc624\ub798 \uac78\ub9b4 \ub54c \uc0dd\uae30\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uadf8\ub807\uac8c \uac04\ub2e8\ud558\uac8c \ubc1c\uc0dd\ud558\ub294 \uac83\uc740 \uc544\ub2c8\uc600\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc0c1\ud638 \ubc30\uc81c(Mutual Exclusion): MySQL\uc740 \uae30\ubcf8\uc801\uc73c\ub85c \ud2b8\ub79c\uc7ad\uc158 \ub0b4\uc5d0\uc11c \uc7a0\uae08(Lock)\uc744 \uc0ac\uc6a9\ud558\uc5ec \ub370\uc774\ud130\uc758 \uc0c1\ud638 \ubc30\uc81c\ub97c \uc81c\uc5b4\ud569\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub450 \uac1c \uc774\uc0c1\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uac19\uc740 \ub370\uc774\ud130\ub97c \ub3d9\uc2dc\uc5d0 \ubcc0\uacbd\ud558\ub824\uace0 \ud560 \ub54c, \ud574\ub2f9 \ub370\uc774\ud130\uc5d0 \ub300\ud55c \uc7a0\uae08\uc774 \uc124\uc815\ub418\uc5b4 \uc0c1\ud638 \ubc30\uc81c \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4.\\n\\n2. \uc810\uc720\uc640 \ub300\uae30(Hold and Wait): \ud2b8\ub79c\uc7ad\uc158\uc774 \uc774\ubbf8 \ud558\ub098 \uc774\uc0c1\uc758 \ub370\uc774\ud130\ub97c \uc7a0\uadfc \uc0c1\ud0dc\uc5d0\uc11c \ub2e4\ub978 \ub370\uc774\ud130\uc758 \uc7a0\uae08\uc744 \uc5bb\uae30 \uc704\ud574 \ub300\uae30\ud558\uace0 \uc788\ub294 \uacbd\uc6b0 \uc810\uc720\uc640 \ub300\uae30 \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4. \uc989, \ud2b8\ub79c\uc7ad\uc158\uc774 \uc790\uc2e0\uc774 \uc810\uc720\ud55c \ub370\uc774\ud130\ub97c \uc720\uc9c0\ud55c \uc0c1\ud0dc\uc5d0\uc11c \ub2e4\ub978 \ub370\uc774\ud130\uc5d0 \ub300\ud55c \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\uace0 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n\\n3. \ube44\uc120\uc810(Non-Preemption): MySQL\uc5d0\uc11c\ub294 \uae30\ubcf8\uc801\uc73c\ub85c \ud2b8\ub79c\uc7ad\uc158\uc774 \ub2e4\ub978 \ud2b8\ub79c\uc7ad\uc158\uc774 \uc810\uc720\ud55c \ub370\uc774\ud130\uc758 \uc7a0\uae08\uc744 \uac15\uc81c\ub85c \ud574\uc81c\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ube44\uc120\uc810 \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4.\\n\\n4. \uc21c\ud658 \ub300\uae30(Circular Wait): \ub450 \uac1c \uc774\uc0c1\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uac01\uac01 \uc11c\ub85c\uac00 \uae30\ub2e4\ub9ac\ub294 \ub370\uc774\ud130\uc758 \uc7a0\uae08\uc744 \ubcf4\uc720\ud574\uc57c \uc21c\ud658 \ub300\uae30 \uc870\uac74\uc774 \ub9cc\uc871\ub429\ub2c8\ub2e4. \uc608\ub97c \ub4e4\uba74, \ud2b8\ub79c\uc7ad\uc158 A\uac00 \ub370\uc774\ud130 X\uc758 \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\uace0, \ud2b8\ub79c\uc7ad\uc158 B\ub294 \ub370\uc774\ud130 Y\uc758 \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\uba70, \ud2b8\ub79c\uc7ad\uc158 C\ub294 \ub370\uc774\ud130 Z\uc758 \uc7a0\uae08\uc744 \uae30\ub2e4\ub9ac\ub294 \uc0c1\ud0dc\uac00 \ubc1c\uc0dd\ud55c\ub2e4\uba74 \uc21c\ud658 \ub300\uae30 \uc870\uac74\uc774 \uc131\ub9bd\ud569\ub2c8\ub2e4.\\n\\n\uc0ac\uc2e4 \uae30\ubcf8 \ucef4\ud4e8\ud130 \uc2dc\uc2a4\ud15c\uc758 dead Lock\uacfc \uc720\uc0ac\ud55c \uc870\uac74\uc785\ub2c8\ub2e4. \uc774 \ubd80\ubd84\uc744 \ubaa8\ub450 \ub9cc\uc871\ud574\uc57c \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\ud558\ub098\uc529 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \uac1c\ubc1c \uc11c\ubc84\uc5d0\uc11c \ubc1c\uc0dd\ud55c \ub370\ub4dc\ub77d\uc73c\ub85c \uc0b4\ud3b4\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```shell\\n*** (1) TRANSACTION:\\nTRANSACTION 1000560, ACTIVE 373 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328\\nMySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST414511\', \'01\', \'2023-07-21 08:27:43\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 08:27:43\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (1) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap\\n\\n\\n-------------------------------------------------------------------------\\n*** (2) TRANSACTION:\\nTRANSACTION 946331, ACTIVE 507 sec inserting\\nmysql tables in use 1, Locked 1\\nLock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432\\nMySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update\\nINSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES (\'ST412801\', \'11\', \'2023-07-21 10:48:20\', \'CHARGING_IN_PROGRESS\') ON DUPLICATE KEY UPDATE latest_update_time = \'2023-07-21 10:48:20\', charger_state = \'CHARGING_IN_PROGRESS\'\\n\\n*** (2) HOLDS THE Lock(S):\\nRECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap\\n```\\n\\n1\ubc88 \ud2b8\ub79c\uc7ad\uc158 1000560\uc774 charge_status \ud14c\uc774\ube14\uc5d0 insert ~~~ on duplicate key update ~~~ \ucffc\ub9ac\ub97c \ubc1c\uc0dd\uc2dc\ud0a4\uae30 \uc704\ud574 `space id 64 page no 742 n bits 424 index PRIMARY of table` \uc5d0 X Lock\uc744 \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4\\n\uadf8\ub9ac\uace0 2\ubc88 \ud2b8\ub79c\uc7ad\uc158 946331 \ub3c4 \ub611\uac19\uc740 \ud14c\uc774\ube14\uc5d0 \ube44\uc2b7\ud55c \ucffc\ub9ac\ub97c \ubc1c\uc0dd\uc2dc\ud0a4\ub824\uace0 \ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ud574\ub2f9 \ud2b8\ub79c\uc7ad\uc158\ub3c4 X Lock\uc744 \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uc800\ud76c \ud300\uc5d0 \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud55c \uc774\uc720\\n\uba3c\uc800 \uc800\ud76c \ud300\uc740 \uacf5\uacf5 API\ub97c \ud1b5\ud574 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c cron\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub3c4 \uc8fc\uae30\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uac31\uc2e0\ud560 \uacbd\uc6b0 \uc0c8\ub85c \uc0dd\uae34 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uba74 \ucd94\uac00\ud574\uc918\uc57c\ud558\uace0, \uc0c8\ub85c \uc0dd\uae34 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uba74 \uc0c8\ub85c\uc6b4 \ucda9\uc804\uae30\uac00 \uc788\uc744\ud14c\uace0, \uadf8\uc5d0\ub530\ub978 \ucda9\uc804\uae30 \uc0c1\ud0dc\ub3c4 \ucd94\uac00\ud574\uc918\uc57c\ud569\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c\uc758 \uc815\ubcf4\uac00 \ubc14\ub00c\ub294 \uac83\uc5d0 \ub300\ud574\uc11c\ub3c4 \uc5c5\ub370\uc774\ud2b8 \ud574\uc918\uc57c\ud569\ub2c8\ub2e4. \uc774\ub807\uac8c \ub41c\ub2e4\uba74 \uc5ec\ub7ec\ubc88\uc758 \ubd84\uae30 \ucc98\ub9ac\ub85c application \ub808\ubca8\uc5d0\uc11c \ud574\uacb0\ud558\ub294 \uac83\uc774 \ub098\uc744\uc9c0 \ud639\uc740 mysql\uc758 \ubb38\ubc95 \uc911 `INSERT ~~ ON DUPLICATE KEY UPDATE ~~`\uc744 \uc774\uc6a9\ud560\uae4c \uace0\ubbfc\uc744 \ud588\uc5c8\ub294\ub370\uc694.\\n\\n\uadf8 \uc911 \ud6c4\uc790\ub97c \ud0dd\ud55c \uc774\uc720\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \ucda9\uc804\uae30\uc758 \uc815\ubcf4\ub294 \ud558\ub8e8\uc5d0 \uacf5\uacf5 API\ub97c \uc694\uccad\ud560 \uc218 \uc788\ub294 key \uc81c\ud55c\uc774 \uc788\uace0 \uc9c0\ub3c4 \uae30\ubc18\uc73c\ub85c \uac80\uc0c9\ud560 \uc218 \uc788\ub294 \uc870\uac74\uc774 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \uc2e4\uc2dc\uac04\uc73c\ub85c \uc694\uccad\ud560 \uc218 \uc5c6\ub294 \uad6c\uc870\uc785\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \uc694\uccad key \uc81c\ud55c\uc744 \ub118\uc9c0 \uc54a\ub294 \uc120\uc5d0\uc11c \uc790\uc8fc \uc694\uccad\uc744 \ud574\uc57c\ud569\ub2c8\ub2e4. \uadf8\ub807\uac8c\ud574\uc57c \uc0ac\uc6a9\uc790\uc5d0\uac8c \uc815\ubcf4\uc5d0 \ub300\ud55c \uc2e0\ub8b0\ub97c \uc904 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc790\uc8fc \uc694\uccad\ub418\ub294 \uc791\uc5c5\uc5d0 Application \ub808\ubca8\uc5d0\uc11c \uad6c\ud604\ud55c\ub2e4\uba74, findAll() \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 23\ub9cc\uac74\uc758 \ucda9\uc804\uae30 \uc0c1\ud0dc \uc815\ubcf4\ub97c \uba54\ubaa8\ub9ac\uc5d0 \ub85c\ub529\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 api\uc758 \uc694\uccad\uc744 \ud1b5\ud574 \uc5bb\uc740 23\ub9cc+n\uac74\uc758 \ucda9\uc804\uae30 \uc0c1\ud0dc \uc815\ubcf4\ub97c \uba54\ubaa8\ub9ac\uc5d0 \uc62c\ub9bd\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ud574\ub2f9 \uc815\ubcf4\uac00 \uc788\ub294\uc9c0 \ube44\uad50\ud569\ub2c8\ub2e4. \ud574\ub2f9 \uc815\ubcf4\uac00 findAll()\ub85c \ucc3e\uc544\uc628 list\uc5d0 \uc5c6\uc73c\uba74 Insert, \ud574\ub2f9 \uc815\ubcf4\uac00 \uc788\ub2e4\uba74 update\ub97c \ud1b5\ud574 \uac31\uc2e0\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \ud55c\ub2e4\uba74 \ucd1d batch Insert, Update\ub97c \ud1b5\ud574 2\ubc88\uc758 \ucffc\ub9ac + 46\ub9cc n\uac74\uc758 \ucda9\uc804\uae30 \uc815\ubcf4\ub97c \uba54\ubaa8\ub9ac\uc5d0 \uc62c\ub824 \ube44\uad50\ud558\ub294 \uc5f0\uc0b0\uc774 \uc0dd\uae38 \uac83 \uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc `INSERT ~~ ON DUPLICATE KEY UPDATE ~~` \ub97c \uc0ac\uc6a9\ud55c\ub2e4\uba74 batch insert\ub97c \ud1b5\ud574 1\ubc88\uc758 \ucffc\ub9ac\ub85c \ud574\uacb0 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uba54\ubaa8\ub9ac\uc5d0 \ub370\uc774\ud130\ub3c4 \uc801\uc7ac\ud558\uc9c0 \uc54a\uace0 \ub9d0\uc774\uc8e0.\\n\\n\ud558\uc9c0\ub9cc \ubcf4\uc168\ub358 \uac83\uacfc \uac19\uc774 \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc774\uc720\ub294 `INSERT ~~ ON DUPLICATE KEY UPDATE ~~` \uc758 \ud2b9\uc218\ud55c Lock mechanism \ub54c\ubb38\uc785\ub2c8\ub2e4. \ud574\ub2f9 \ucffc\ub9ac\ub294\\n1. \uba3c\uc800 \uc0bd\uc785\ud558\ub824\ub294 \ud589\uc774 \ud14c\uc774\ube14\uc5d0 \uc874\uc7ac\ud558\ub294\uc9c0 \ud655\uc778\ud569\ub2c8\ub2e4.\\n2. \uadf8\ub9ac\uace0 \uc77d\uc740 record\uc5d0 \ub300\ud574 S Lock\uc744 \uc124\uc815\ud569\ub2c8\ub2e4.\\n3. \uadf8\ub9ac\uace0 \ud574\ub2f9 record\uac00 duplicate key\ub77c\ub294 \uc870\uac74\uc774\ub77c\uba74 X Lock\uc744 \uc124\uc815\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubc29\uc2dd\uc73c\ub85c \uc791\ub3d9\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\uc774\ub7f0 \ubc29\uc2dd\uc774 \uc65c \ubb38\uc81c\uac00 \ub420 \uc218 \uc788\ub294\uc9c0 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\uba3c\uc800 \uc790\uc2e0\uc774 \uc77d\uc740 record\uc5d0 S Lock\uc744 \uac01\uac01\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uc124\uc815\ud569\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \uc5c5\ub370\uc774\ud2b8\ub97c \ud558\uae30\uc704\ud574 record\uc5d0 X Lock\uc744 \uc124\uc815\ud558\ub824\uace0 \ud569\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uac01\uac01\uc758 \ud2b8\ub79c\uc7ad\uc158\uc774 \uc11c\ub85c S Lock\uc744 \uc124\uc815\ud588\uae30 \ub54c\ubb38\uc5d0 S Lock\uc744 \ubc18\ub0a9\ud558\uace0 X Lock\uc744 \uc124\uc815\ud558\ub824\uace0 \ud574\ub3c4 \ub450 \ud2b8\ub79c\uc7ad\uc158 \ubaa8\ub450 \uae30\ub2e4\ub9ac\ub294 \ub370\ub4dc\ub77d \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4. \uc774\ub7f0 \uc0c1\ud669\uc774 \ub418\uba74 \uc544\uae4c \uc704\uc5d0\uc11c \ub9d0\uc500\ub4dc\ub838\ub358 \ub370\ub4dc\ub77d\uc758 \uc870\uac74 4\uac00\uc9c0\uac00 \ub2e4 \ub9cc\uc871\ud558\uace0 \ub370\ub4dc\ub77d\uc774 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n### \ud574\uacb0 \ubc29\uc548\\n\\n\ud574\uacb0 \ubc29\uc548\uc740 \uc5ec\ub7ec\uac00\uc9c0\uac00 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uadf8 \uc911 \uc81c \uc218\uc900\uc5d0\uc11c \uc0dd\uac01\ud560 \uc218 \uc788\ub294 \ubc29\ubc95\uc740 2\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. \ud2b8\ub79c\uc7ad\uc158\uc744 \uc791\uac8c \ubd84\ub9ac\\n\ud2b8\ub79c\uc7ad\uc158\uc744 \uc624\ub798 \uac00\uc9c0\uace0 \uc788\uc73c\uba74 Lock\uc744 \uac00\uc9c0\uace0 \uc788\ub294 \uc2dc\uac04\uc774 \uc624\ub798\uac78\ub9bd\ub2c8\ub2e4.\\n\uadf8\ub798\uc11c \ud2b8\ub79c\uc7ad\uc158\uc744 \uc791\uac8c \ubd84\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud398\uc774\uc9d5\uc744 \ud1b5\ud574 \ud2b8\ub79c\uc7ad\uc158\uc744 \uc791\uac8c \ubd84\ub9ac\ud558\ub2e4\ubcf4\uba74 \ucffc\ub9ac\uac00 \uc5ec\ub7ec\ubc88 \ub098\uac00 \uc131\ub2a5\uc0c1 \ubb38\uc81c\uac00 \uc0dd\uae38 \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n2. `INSERT ~~ ON DUPLICATE KEY UPDATE ~~` \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uae30\\n\ud574\ub2f9 sql\uc774 \uc544\ub2cc `INSERT IGNORE`\uc744 \uc0ac\uc6a9\ud558\uc5ec \ucd94\uac00\ub41c \uc815\ubcf4\ub9cc \ub123\uace0, update\ub294 \ub2e4\ub978 \uc791\uc5c5\uc73c\ub85c \ubd84\ub9ac\ud558\uae30\\n\\n\uc774\ub7f0 \ubc29\ubc95\ub4e4\uc744 \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc558\uc2b5\ub2c8\ub2e4. \uadf8 \uc911 \uc800\ub294 \ud604\uc7ac\ub294 \uac04\ub2e8\ud558\uac8c 2\ubc88\uc9f8 \ubc29\ubc95\uc774 \uc81c\uc77c \ub098\uc744 \uac83 \uac19\ub2e4\ub294 \uc0dd\uac01\uc5d0 \ucffc\ub9ac\ub97c \uc218\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ubb38\uc81c\ub97c \ud574\uacb0\ud588\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uac8c \ub418\uc5b4 \uc880 \ub354 \uc7ac\ubc0c\ub294 \uac83\ub4e4\uc744 \uace0\ubbfc\ud558\uace0 \uacf5\ubd80\ud560 \uc218 \uc788\ub294 \uc800\ud76c \ud300\uc5d0\uac8c \uac10\uc0ac\ud558\uace0 \ubaa8\ub974\ub294 \ud0a4\uc6cc\ub4dc\ub97c \ub9ce\uc774 \uc54c\ub824\uc900 \ub204\ub204\uc5d0\uac8c \uac10\uc0ac\ud569\ub2c8\ub2e4.\\n\\n\uc544\uc9c1 \ubc30\uc6b0\ub294 \ub2e8\uacc4\ub77c \uc815\ud655\ud55c \uc815\ubcf4\uac00 \uc544\ub2d0 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubd80\uc871\ud55c \ubd80\ubd84\uc5d0 \ub300\ud574 \ub9ce\uc740 \uc9c0\uc801 \ubd80\ud0c1\ub4dc\ub9bd\ub2c8\ub2e4."},{"id":"22","metadata":{"permalink":"/22","source":"@site/blog/2023-07-27-filtering-and-index.mdx","title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694~","date":"2023-07-27T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 27\uc77c","tags":[{"label":"filter","permalink":"/tags/filter"},{"label":"index","permalink":"/tags/index"}],"readingTime":10.86,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"22","title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","authors":["jay"],"tags":["filter","index"]},"prevItem":{"title":"Deadlock trouble shooting","permalink":"/23"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","permalink":"/20"}},"content":"\uc548\ub155\ud558\uc138\uc694~\\n\\n\uc6b0\ud14c\ucf54 \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604 \ubc0f \uc778\ub371\uc2a4\ub97c \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\ub294 \uc791\uc5c5\uc744 \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \uc694\uad6c \uc0ac\ud56d\uacfc \uae30\ub2a5 \uad6c\ud604 \ubaa9\ub85d\\n\uce74\ud398\uc778 \ud300\uc740 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc870\ud68c \ubc0f \ud1b5\uacc4 \ub370\uc774\ud130\ub97c \uc81c\uacf5\ud574\uc8fc\ub294 \uc11c\ube44\uc2a4\uc785\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790 \uc785\uc7a5\uc5d0\uc11c \uc804\uae30\ucc28 \ucda9\uc804\uc18c\ub97c \uc870\ud68c\ud560 \ub54c \ubcf8\uc778 \ucc28\uc5d0 \ub9de\ub294 \ucda9\uc804\uae30 \ud0c0\uc785\uacfc, \uc18d\ub3c4, \ub9c8\uc9c0\ub9c9\uc73c\ub85c \ucda9\uc804\uae30\ub97c \uc81c\uacf5\ud558\ub294 \ud68c\uc0ac\uba85 \uc694\uae08\uacfc \uad00\ub828\ub3c4 \ub418\uc5b4 \uc788\uc5b4\uc11c \uc911\uc694\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \ubb34\uc218\ud788 \ub9ce\uc740 \ucda9\uc804\uc18c\ub97c \ubcf4\ub294 \uac83\uc774 \uc544\ub2cc \uc790\uc2e0\uc5d0\uac8c \ud544\uc694\ud55c \uac83\ub9cc \ubcf4\ub294 \uac83\uc774 \uc0ac\uc6a9\uc790 \uacbd\ud5d8\uc5d0 \uc788\uc5b4\uc11c\ub294 \ub354 \uc911\uc694\ud55c\ub370\uc694.\\n\\n\uc800\ud76c \ud300\uc740 \uc774\ub97c \uc704\ud574 \ud544\ud130\ub9c1 \uae30\ub2a5\uc744 \ub3c4\uc785\ud558\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ub610\ud55c \uc870\ud68c\uac00 \ub9ce\uc740 \uc11c\ube44\uc2a4\uc778\ub9cc\ud07c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\uc744 \uc704\ud574 \uc778\ub371\uc2a4\ub97c \uc801\uc6a9\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud544\ud130\ub9c1 \ubfd0\ub9cc \uc544\ub2c8\ub77c \ud574\ub2f9 \uc791\uc5c5\uc744 \ud558\uba74\uc11c \uc5b4\ub5a4 \uace0\ubbfc\uc744 \ud588\uace0 \uc5b4\ub5a4 \uac83\uc744 \ud588\ub294\uc9c0 \uc801\uc5b4\ubcf4\uace0\uc790 \ud569\ub2c8\ub2e4.\\n\\n\\n## \ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\ud558\uae30\\n\uc800\ud76c \ud300\uc740 \ube60\ub974\uac8c \uae30\ub2a5\uc744 \uad6c\ud604\ud558\ub294 \ub2e8\uacc4\uc5d0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc77c\ub2e8 3\uac1c\uc758 \ud544\ud130\ub9cc \ub3c4\uc785\ud588\uace0, \ud544\ud130\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4. [\ucda9\uc804\uc18c \uc6b4\uc601 \ud68c\uc0ac \uc774\ub984, \ucda9\uc804 \ud0c0\uc785, \ucda9\uc804 \uc18d\ub3c4]\\n\\n\uc0ac\uc6a9\uc790\ub294 \ud544\ud130\ub97c \ud074\ub9ad\ud558\uba74 \ud604\uc7ac \uc704\uce58\ub97c \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0\uc5d0 \ud574\ub2f9 \ud544\ud130\uac00 \uc801\uc6a9\ub41c \ucda9\uc804\uc18c\ub97c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n3\uac1c\uc758 \ud544\ud130 \uc911\uc5d0\uc11c \ubaa8\ub450 \uc801\uc6a9\ub420 \uc218\ub3c4 \uc788\uace0, \ubaa8\ub450 \uc801\uc6a9\ub418\uc9c0 \uc54a\uc744 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c 2^3 = 8\uac00\uc9c0\uc758 \uacbd\uc6b0\ub97c \uc0dd\uac01\ud574\uc57c \ud588\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uadf8\ub798\uc11c \ucc98\uc74c\uc5d0 \ud544\ud130\ub97c \uc801\uc6a9\ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ubc29\ubc95\ub4e4\uc744 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. JPQL + \ud544\ud130\uc758 \uc870\ud569 (2^3)\ub9cc\ud07c if\ubb38 \uc0ac\uc6a9\ud558\uae30\\n\\n2. \uae30\uc874 \uc88c\ud45c\ub85c \uc870\ud68c\ud558\ub294 findAllByLatitudeBetweenAndLongitudeBetween() \uba54\uc11c\ub4dc\ub97c \uc0ac\uc6a9 \ud6c4 Stream\uc744 \uc774\uc6a9\ud574 \uc790\ubc14 \ucf54\ub4dc\ub85c \ud544\ud130\ub9c1\ud558\uae30\\n\\n\\n\uc774\ub807\uac8c \ub450 \uac00\uc9c0 \ubc29\ubc95\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1\ubc88\uc758 \uacbd\uc6b0 \uc6b0\ud14c\ucf54 \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c Querydsl\uc744 \uc0ac\uc6a9\ud574\ub3c4 \ub418\ub294\uc9c0 \ud655\uc2e4\ud558\uc9c0 \uc54a\uc558\uace0 \uc815\ud655\ud55c \ud544\ud130 \uba85\uc138\uac00 \uc544\uc9c1\uc740 \uc5c6\uace0 3\uac00\uc9c0\ub9cc \uc77c\ub2e8 \ub3c4\uc785\ud558\uace0\uc790 \ud574\uc11c JPQL\uc744 \uc774\uc6a9\ud574\uc11c \uc0c1\ud669\ub9c8\ub2e4 if\ubb38\uc73c\ub85c \ud574\ub2f9 \uba54\uc11c\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c\uc8fc\ub294 \ubc29\ubc95\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n```java\\n// 1. fetch join + \ud68c\uc0ac \uc774\ub984\ub9cc \uc870\ud68c\\n @Query(\\"SELECT DISTINCT s FROM Station s \\" +\\n \\"LEFT JOIN FETCH s.chargers c \\" +\\n \\"LEFT JOIN FETCH c.chargerStatus \\" +\\n \\"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude \\" +\\n \\"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude \\" +\\n \\"AND s.companyName IN :companyNames\\")\\n List findAllByFilteringBeingCompanyNames(@Param(\\"minLatitude\\") BigDecimal minLatitude,\\n @Param(\\"maxLatitude\\") BigDecimal maxLatitude,\\n @Param(\\"minLongitude\\") BigDecimal minLongitude,\\n @Param(\\"maxLongitude\\") BigDecimal maxLongitude,\\n @Param(\\"companyNames\\") List companyNames);\\n\\n // 2. fetch join + \ucda9\uc804 \ud0c0\uc785\\n @Query(\\"SELECT DISTINCT s FROM Station s \\" +\\n \\"LEFT JOIN FETCH s.chargers c \\" +\\n \\"LEFT JOIN FETCH c.chargerStatus \\" +\\n \\"WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude \\" +\\n \\"AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude \\" +\\n \\"AND c.type IN :types\\")\\n List findAllByFilteringBeingTypes(@Param(\\"minLatitude\\") BigDecimal minLatitude,\\n @Param(\\"maxLatitude\\") BigDecimal maxLatitude,\\n @Param(\\"minLongitude\\") BigDecimal minLongitude,\\n @Param(\\"maxLongitude\\") BigDecimal maxLongitude,\\n @Param(\\"types\\") List types);\\n\\n```\\n\\n\uc9c4\ud589 \ud588\ub2e4\uba74 \uc774\ub7f0 \ub290\ub08c\uc774\uc5c8\uaca0\ub124\uc694!\\n\\n\\n2\ubc88\uc758 \uacbd\uc6b0 \ubaa8\ub450 \uc870\ud68c\ub97c\ud558\uace0 \uc790\ubc14 \ucf54\ub4dc\ub97c \uc774\uc6a9\ud574\uc11c \ud544\ud130\ub9c1 \ud574\uc8fc\ub294 \ubc29\ubc95\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ud604\uc7ac \uc800\ud76c \uc11c\ube44\uc2a4\ub294 \uc88c\ud45c\ub97c \uc911\uc2ec\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \uc870\ud68c\ud569\ub2c8\ub2e4.\\n\\n\uc5b4\ucc28\ud53c \uc0ac\uc6a9\uc790\uac00 \ud654\uba74\uc744 \ucd95\uc18c\ud574\uc11c \ud070 \ubc94\uc704\uc758 \uc9c0\ub3c4\ub97c \ubcf4\ub294 \uac83\uc740 \uc5b4\ucc28\ud53c \ub9c9\ud790\ud14c\ub2c8 \uc0ac\uc6a9\uc790\ub294 \uc791\uc740 \ubc94\uc704\uc5d0 \ub300\ud574\uc11c \uc870\ud68c\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \ud558\ub098\uc758 \ucffc\ub9ac\ub97c \uc774\uc6a9\ud574\uc11c \uc790\ubc14 \ucf54\ub4dc\ub85c \ud544\ud130\ub9c1 \ud574\uc8fc\ub294 \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n\\n\\n\uc774\ub807\uac8c\ub9cc \ubd24\uc744 \ub550 1\ubc88 \ubc29\uc2dd\uc778 \ud544\ud130 \ubcc4\ub85c \uc870\ud68c\ud574\uc8fc\ub294 \uac83\uc740 \uc870\ud68c \ud6a8\uc728\uc740 \ub354 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc 1\ubc88\uc758 \ubc29\ubc95\uc740 \'\ud604\uc7ac \uad6c\uc870\'\uc5d0\uc11c\ub294 \ub9ce\uc740 \ucffc\ub9ac\ubb38\uacfc \uba54\uc11c\ub4dc\ub97c \uc791\uc131\ud574\uc57c\ud558\uace0, if\ubb38 \ubc94\ubc85\uc73c\ub85c \ubcf4\uae30 \uc88b\uc9c0 \uc54a\uc740 \ucf54\ub4dc\uac00 \uc644\uc131 \ub410\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uacb0\uad6d 2\ubc88 \ubc29\uc2dd\uc778 [\uc804\uccb4 \uc870\ud68c + \ucf54\ub4dc\ub85c \ud544\ud130\ub9c1] \ubc29\uc2dd\uc744 \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc5b4\ucc28\ud53c \uc0ac\uc6a9\uc790\ub294 \uc791\uc740 \ubc94\uc704\uc5d0\uc11c \uc870\ud68c\ub97c \ud55c\ub2e4.\\n2. \uc778\ub371\uc2a4\ub97c \uac78\uc5c8\uc744 \ub54c \uac00\uc7a5 \ud6a8\uc728\uc801\uc774\ub2e4.\\n\\n1\ubc88\uc758 \uc774\uc720\ub294 \uc704\uc5d0\uc11c \ub9d0\ud588\uace0, 2\ubc88\uc5d0 \ub300\ud574 \uac04\ub2e8\ud558\uac8c \uc124\uba85 \ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uc800\ud76c \uc11c\ube44\uc2a4\ub294 \uc870\ud68c\uac00 \uad49\uc7a5\ud788 \ub9ce\uc9c0\ub9cc, \ucda9\uc804\uc18c\uc758 \uc8fc\uae30\uc801\uc778 \uc5c5\ub370\uc774\ud2b8\ub97c \uc704\ud574 \ub370\uc774\ud130 \uc5c5\ub370\uc774\ud2b8\uac00 \uad49\uc7a5\ud788 \ube48\ubc88\ud558\uac8c \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \ub9ce\uc9c0\ub294 \uc54a\uc9c0\ub9cc \ub370\uc774\ud130 \uc0bd\uc785\ub3c4 \ubc1c\uc0dd\ud558\uace0, \ub370\uc774\ud130 \uc5c5\ub370\uc774\ud2b8\ub3c4 \ub9ce\uc544\uc9d1\ub2c8\ub2e4.\\n\\nJPQL\ub85c \uc870\uac74\uc744 \ub098\ub220\uc11c \uc870\ud68c\ud574\uc900\ub2e4\uba74 \ud574\ub2f9\ud558\ub294 \ubaa8\ub4e0 \ud544\ud130\uc5d0 \uc778\ub371\uc2a4\ub97c \uac78\uc5b4\uc57c\ud560\uae4c\uc694?\\n\\n\uadf8\ub7f4 \uc21c \uc5c6\uc5c8\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uac00\uc7a5 \ud6a8\uc728\uc801\uc778 Column\uc5d0 \uc778\ub371\uc2a4\ub97c \uac78\uc5c8\uaca0\uc8e0, \uadf8\ub807\ub2e4\uba74 \uc870\ud68c\ub9c8\ub2e4 \uc18d\ub3c4\ub3c4 \ub2ec\ub77c\uc84c\uc744 \uac83\uc774\uace0 \uac00\ub839 \ud574\ub2f9\ud558\ub294 \ubaa8\ub4e0 Column\uc5d0 \uc778\ub371\uc2a4\ub97c \uc124\uc815\ud574\ub194\ub3c4 \uc5c5\ub370\uc774\ud2b8\uc640 \uc0bd\uc785\uc774 \ub290\ub824\uc84c\uc744 \uac83\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub294 7\ubd84\ub9c8\ub2e4 \ub370\uc774\ud130\ub97c \uc5c5\ub370\uc774\ud2b8 \ud558\ub294 \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc801\uc808\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ubc18\uba74\uc5d0 \ud55c \uac1c\uc758 \ucffc\ub9ac\ub85c \uc8fc\ubcc0\uc744 \ubaa8\ub450 \uc870\ud68c\ud558\uace0 \uc774\ub97c \uc790\ubc14 \ucf54\ub4dc\ub85c \ubc14\uafb8\ub294 \ubc29\ubc95\uc740 \ub354 \uc26c\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n\uc5b4\ucc28\ud53c \ub9ce\uc9c0 \uc54a\uc740 \uc591\uc758 \ub370\uc774\ud130\ub97c \uc870\ud68c\ud558\uace0 \ud544\ud130\ub9c1 \ud558\uae30 \ub54c\ubb38\uc5d0 \uc18d\ub3c4 \uba74\uc5d0\uc11c\ub3c4 \ud070 \ucc28\uc774\uac00 \ub098\uc9c0 \uc54a\uc558\uace0, \uc778\ub371\uc2a4 \uc124\uc815\uc5d0\ub3c4 \uc720\ub9ac\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc870\ud68c\uc2dc \uc774\uc6a9\ud558\ub294 latitude\uc640 longitude\ub9cc \uc124\uc815\ud574\uc8fc\uba74 \uc5b4\ub5a4 \uacbd\uc6b0\ub4e0 \ube60\ub974\uac8c \uc870\ud68c\ub97c \ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \uc778\ub371\uc2a4 \uc801\uc6a9\uc73c\ub85c \uc870\ud68c \uc18d\ub3c4 \ud5a5\uc0c1\uc2dc\ud0a4\uae30\\n\\n\uba3c\uc800 \uc77c\ub2e8 \ud604\uc7ac \ucf54\ub4dc\uc5d0\uc11c \uc870\ud68c\uc2dc \ub2e4\uc74c\uacfc \uac19\uc740 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n```sql\\nHibernate:\\n select\\n station0_.station_id as station_1_0_0_,\\n ...\\n ...\\n ...\\n chargersta2_.latest_update_time as latest_u4_2_2_\\n from\\n charge_station station0_\\n left outer join\\n charger chargers1_\\n on station0_.station_id=chargers1_.station_id\\n left outer join\\n charger_status chargersta2_\\n on chargers1_.charger_id=chargersta2_.charger_id\\n and chargers1_.station_id=chargersta2_.station_id\\n where\\n (\\n station0_.latitude between ? and ?\\n )\\n and (\\n station0_.longitude between ? and ?\\n )\\n```\\n\\nwhere \uc808\uc5d0\uc11c \uc704\ub3c4 \uacbd\ub3c4\ub97c \ubc14\ud0d5\uc73c\ub85c \uc8fc\ubcc0\ub9cc \uac00\uc838\uc624\uac8c \ub429\ub2c8\ub2e4. \uae30\uc874\uc5d0 N+1 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud574\uc11c EntityGraph\ub85c \ubc14\uafe8\uace0 \uc2e4\ud589\uc2dc \ucffc\ub9ac\uc785\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uc544\ub798 \uae00\uc744 \uc77d\uace0 BETWEEN \ucffc\ub9ac\uc5d0\uc11c \ubd80\ub4f1\ud638\ub97c \uc774\uc6a9\ud558\ub294 \ucffc\ub9ac\ub85c \ubcc0\uacbd\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n[Mysql Query Between \uacfc >=, <= \uc131\ub2a5 \ucc28\uc774 \ube44\uad50 ( \ub354\ubbf8\ub370\uc774\ud130 50\ub9cc )\\n](https://velog.io/@ggomjae/Mysql-Query-Between-%EA%B3%BC-%EC%84%B1%EB%8A%A5-%EC%B0%A8%EC%9D%B4-%EB%B9%84%EA%B5%90-%EB%8D%94%EB%AF%B8%EB%8D%B0%EC%9D%B4%ED%84%B0-50%EB%A7%8C)\\n\\n\\n```java\\n@Query(\\"SELECT DISTINCT s FROM Station s \\" +\\n \\"LEFT JOIN FETCH s.chargers c \\" +\\n \\"LEFT JOIN FETCH c.chargerStatus \\" +\\n \\"WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude \\" +\\n \\"AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude\\")\\n List findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param(\\"minLatitude\\") BigDecimal minLatitude,\\n @Param(\\"maxLatitude\\") BigDecimal maxLatitude,\\n @Param(\\"minLongitude\\") BigDecimal minLongitude,\\n @Param(\\"maxLongitude\\") BigDecimal maxLongitude);\\n\\n```\\n\uc704\uc640 \uac19\uc774 \uc870\ud68c\ud574\uc8fc\ub294 \ucffc\ub9ac\ub97c \ub9cc\ub4e4\uc5c8\uace0, \uc778\ub371\uc2a4\ub97c \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc778\ub371\uc2a4 \uc124\uc815 \uae30\uc900\uc740 [\uc778\ub371\uc2a4 \uc815\ub9ac \ubc0f \ud301](https://jojoldu.tistory.com/243)\\n\uc704\uc5d0 \ub9c1\ud06c\uc640 \uac19\uc774 \ub3d9\uc6b1\ub2d8\uc758 \ube14\ub85c\uadf8\ub97c \ucc38\uc870\ud574\uc11c \uae30\uc900\uc744 \uc138\uc6e0\uc2b5\ub2c8\ub2e4.\\n\\n\ubb34\uc870\uac74 \uce74\ub514\ub110\ub9ac\ud2f0\uac00 \ub192\uc740 \uac83\uc744 \uc124\uc815\ud560 \uc21c \uc5c6\uc5c8\uae30 \ub54c\ubb38\uc5d0 (\uc5c5\ub370\uc774\ud2b8\uc640 \uc0bd\uc785 \uc791\uc5c5\uc774 \ub9ce\uae30 \ub54c\ubb38\uc5d0) \ucffc\ub9ac\uc5d0\uc11c \uc0ac\uc6a9\ub418\ub294 column\uacfc update \uc791\uc5c5\uc744 \uace0\ub824\ud558\uace0 \uc131\ub2a5\uc744 \ube44\uad50\ud574\uac00\uba74\uc11c \uac00\uc7a5 \ud6a8\uc728\uc801\uc778 \uac83\uc744 \uc124\uc815\ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc18d\ub3c4\ub97c \ube44\uad50\ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n
    \\n
    \\n\\n\u200b\\n\\n\uba3c\uc800 \uc18d\ub3c4 \ube44\uad50\ub97c \uc704\ud574\uc11c \ub370\uc774\ud130 \uc14b\uc740 \ub2e4\uc74c\uacfc \uac19\uc774 \uc9c4\ud589\ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\\n- Charger (23\ub9cc \uac74)\\n- Station (6\ub9cc \uac74)\\n- ChargerStatus(23\ub9cc \uac74)\\n- \uc120\ub989\uc5ed \uadfc\ucc98 \uc870\ud68c\\n\\n\\n### Ver1. \uc778\ub371\uc2a4 \uc801\uc6a9\uc744 \ud558\uc9c0 \uc54a\uace0 \uc870\ud68c \ubc0f \ud544\ud130\ub9c1 \ud588\uc744 \ub54c \uc18d\ub3c4 (0.84\ucd08)\\n![\uc774\ubbf8\uc9c0](https://postfiles.pstatic.net/MjAyMzA3MjdfMTYy/MDAxNjkwNDQwMDA0ODEw.vaeA83AD9ycHa26YN58rqzPV3XdX2zTvIZgKM6YKXWEg.Qqkkdr_lEJeGbYPpWji0E-IusfGpqMpZHKWZM4AyRrUg.PNG.sosow0212/image.png?type=w773)\\n\ud3c9\uade0\uc801\uc73c\ub85c 0.84\ucd08\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n### Ver2. \uc778\ub371\uc2a4 \uc801\uc6a9 \ubc0f \uc870\ud68c \ubc0f \ud544\ud130\ub9c1 \ud588\uc744 \ub54c \uc18d\ub3c4 (0.63\ucd08)\\n![\uc774\ubbf8\uc9c0](https://postfiles.pstatic.net/MjAyMzA3MjdfNTUg/MDAxNjkwNDQwMTUyMDcx.F3sSiDgLp3O2Rn1waqh31vC6yv1Uk0zZkRzjyuDQEM4g.eziRKLCmUbzW88ueQRozZcYvhsH10C17w-IDRLh0cJ4g.PNG.sosow0212/SE-48b3f814-3306-4add-ab95-381186bab6ca.png?type=w773)\\n\ud3c9\uade0\uc801\uc73c\ub85c 0.63\ucd08\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\uc57d 25 ~ 30%\uc758 \uc870\ud68c \uc18d\ub3c4\uac00 \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\uc9c1 \uc774 \ubd80\ubd84\uc740 \uac1c\uc120\uc774 \ub354 \ud544\uc694\ud574\ubcf4\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub798\ub3c4 \uac1c\uc120\uc774 \ub410\uace0, \uc0bd\uc785\uacfc \uac31\uc2e0\uc5d0\ub294 \ud070 \uc9c0\uc7a5\uc774 \uc5c6\uc5b4\uc11c \uc77c\ub2e8 \uc774\uc815\ub3c4\ub85c \ub9c8\ubb34\ub9ac \ud558\uace0, \ucd94\ud6c4\uc5d0 \uac1c\uc120\uc744 \ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n![\uc774\ubbf8\uc9c0](https://postfiles.pstatic.net/MjAyMzA3MjdfNzMg/MDAxNjkwNDQwODA1NzAy.b5gZPjl_E1x3wbjSMNcmfQDKB-hB9p8FEbIJqs5Kl4Qg.ZBq0-GmXJruPO7ejA_zq7RfaBaC17doHJUT19wje1Qkg.PNG.sosow0212/SE-f5396915-60ef-4293-a457-e30e8f5a2794.png?type=w773)\\n\ucd94\uac00\uc801\uc73c\ub85c \ucda9\uc804\uae30 \uc870\ud68c\ub294 \uad49\uc7a5\ud788 \ube68\ub77c\uc84c\uc2b5\ub2c8\ub2e4!\\n\\n\\n\ubc30\uc6b0\ub294 \ub2e8\uacc4\uc774\ub2e4\ubcf4\ub2c8 \ubbf8\uc219\ud558\uace0 \ud2c0\ub9b0 \ubd80\ubd84\uc774 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4 :)"},{"id":"20","metadata":{"permalink":"/20","source":"@site/blog/2023-07-26-why-styled-components.mdx","title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","description":"\uc65c styled-components\uc778\uac00?","date":"2023-07-26T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 26\uc77c","tags":[{"label":"styled-components","permalink":"/tags/styled-components"},{"label":"css","permalink":"/tags/css"},{"label":"css in js","permalink":"/tags/css-in-js"}],"readingTime":1.495,"hasTruncateMarker":false,"authors":[{"name":"\uc57c\ubbf8","title":"Frontend","url":"https://github.com/feb-dain","imageURL":"https://github.com/feb-dain.png","key":"yummy"}],"frontMatter":{"slug":"20","title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","authors":["yummy"],"tags":["styled-components","css","css in js"]},"prevItem":{"title":"\ud544\ud130\ub9c1 \uae30\ub2a5 \uad6c\ud604\uacfc \uc778\ub371\uc2a4 \uc774\uc6a9\ud55c \uc870\ud68c \uc18d\ub3c4 \uac1c\uc120\ud558\uae30","permalink":"/22"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","permalink":"/21"}},"content":"## \uc65c styled-components\uc778\uac00?\\n\\n
    \\n\\n\uc5ec\ub7ec `CSS-in-JS` \uc911 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n1. \ucef4\ud3ec\ub10c\ud2b8 \uc548\uc5d0 \uad00\ub828 CSS\ub97c \uc791\uc131\ud560 \uc218 \uc788\uc5b4 \ucef4\ud3ec\ub10c\ud2b8\ubcc4 \ub514\uc790\uc778 \ucf54\ub4dc \ud655\uc778 \ubc0f \uc218\uc815\uc774 \uc6a9\uc774\ud558\ub2e4.\\n\\n2. \ud639\uc790\ub294 \ucf54\ub4dc \uac00\ub3c5\uc131\uc774 \uc548 \uc88b\uc544\uc9c4\ub2e4\uace0\ub3c4 \ud558\uc9c0\ub9cc, \uac1c\uc778\uc801\uc73c\ub85c\ub294 \ud0dc\uadf8\ub97c \ub354 \uc2dc\ub9e8\ud2f1 \ud558\uac8c \uc791\uc131\ud560 \uc218 \uc788\uc5b4\uc11c \uc88b\ub2e4\uace0 \ub290\uaf08\ub2e4.\\n\\n3. \ud300\uc6d0\ub4e4 \ubaa8\ub450 styled-components\uac00 \uc775\uc219\ud558\ub2e4.\\n\\n4. \uc9c0\uae08\uae4c\uc9c0 \uc0ac\uc6a9\ud558\uba74\uc11c \ubd88\ud3b8\ud55c \uc810\uc744 \ubabb \ub290\uaf08\ub2e4.\\n\\n
    \\n\\nstyled-components\uc640 emotion\uc740 \uae30\ub2a5\ub3c4, \uc791\uc131\ubc95\ub3c4 \uc0c1\ub2f9\ud788 \uc720\uc0ac\ud558\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc774\ubc88\uc5d0\ub294 styled-components \ub300\uc2e0 emotion\uc744 \uc368\ubcfc\uae4c\ub3c4 \uc0dd\uac01\ud588\uc5c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc emotion\uc5d0\uc11c\ub9cc \uc0ac\uc6a9 \uac00\ub2a5\ud558\ub358 \\\\*CSS Props\ub77c\ub294 \ud3b8\ub9ac\ud55c \uae30\ub2a5\uc744\\n\\nstyled-components(v5.2.0 \uc774\uc0c1)\uc5d0\uc11c \uc4f8 \uc218 \uc788\uac8c \ub418\uae30\ub3c4 \ud588\uace0,\\n\\n\'\uc0c8\ub85c\uc6b4 \uae30\uc220 \uacf5\ubd80\ub97c \ud574\ubcf4\uba74 \uc88b\uc744 \uac83 \uac19\ub2e4\'\ub294 \uc774\uc720\ub97c \uc81c\uc678\ud558\uace0\ub294\\n\\n\ub531\ud788 emotion\uc744 \uc0ac\uc6a9\ud560 \ud544\uc694\uc131\uc744 \ubabb \ub290\uaef4 styled-components\ub97c \ucc44\ud0dd\ud588\ub2e4.\\n\\n```typescript\\n// *CSS Props \uc608\uc2dc\\n\\nconst buttonStyle = css`\\n font-size: 18px;\\n color: white;\\n background: black;\\n`;\\n\\nconst ClickButton = styled.button<{ css: CSSProp }>`\\n width: 100px;\\n\\n ${({ css }) => css}\\n`;\\n\\nClick me!;\\n```"},{"id":"21","metadata":{"permalink":"/21","source":"@site/blog/2023-07-26-why-tanstack-query-is-good.mdx","title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","description":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300 FE\uc5d0\uc11c \uc0c1\ud0dc\uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc5b4\ub5bb\uac8c \ud574\uc57c\ud560 \uc9c0 \uace0\ubbfc \ub05d\uc5d0 \uc11c\ub4dc\ud30c\ud2f0 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \ud544\uc694\ud558\uac8c \ub418\uc5b4 \uae00\uc744 \uc791\uc131\ud558\uac8c\ub410\uc2b5\ub2c8\ub2e4.","date":"2023-07-26T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 26\uc77c","tags":[{"label":"tanstack query","permalink":"/tags/tanstack-query"},{"label":"react state management","permalink":"/tags/react-state-management"}],"readingTime":8.695,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"21","title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","authors":["gabriel"],"tags":["tanstack query","react state management"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc774 styled-components\ub97c \uc120\ud0dd\ud55c \uc774\uc720","permalink":"/20"},"nextItem":{"title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","permalink":"/19"}},"content":"\uc548\ub155\ud558\uc138\uc694? \uce74\ud398\uc778 \ud300 FE\uc5d0\uc11c \uc0c1\ud0dc\uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc5b4\ub5bb\uac8c \ud574\uc57c\ud560 \uc9c0 \uace0\ubbfc \ub05d\uc5d0 \uc11c\ub4dc\ud30c\ud2f0 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \ud544\uc694\ud558\uac8c \ub418\uc5b4 \uae00\uc744 \uc791\uc131\ud558\uac8c\ub410\uc2b5\ub2c8\ub2e4.\\n\\n# \uc11c\ubc84 \uc0c1\ud0dc\uc640 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\uc758 \uad6c\ubd84\\n\\n\uc11c\ubc84\uc0c1\ud0dc\uc640 UI\uc0c1\ud0dc\ub97c \uc774\ud574\ud558\ub294 \uac83\uc740 \uad49\uc7a5\ud788 \uc911\uc694\ud588\uc2b5\ub2c8\ub2e4. \ub370\uc774\ud130\ub97c \uc1a1\uc218\uc2e0\ud558\ub294 \uc791\uc5c5\uacfc \uc0c1\ud0dc\ub97c \uad00\ub9ac\ud558\ub294 \uc791\uc5c5\uc740 \uc720\uae30\uc801\uc73c\ub85c \ub3d9\uc791\ud574\uc57c\ud588\uc2b5\ub2c8\ub2e4. \uae30\uc874\uc5d0\ub294 `\uc0c1\ud0dc\uc640 \ub370\uc774\ud130 \uc1a1\uc218\uc2e0 \uacfc\uc815\uc744 \ubd84\ub9ac\ud574\uc11c \uc0dd\uac01`\ud588\ub2e4\uba74, \ud604\ub300\uc758 react \ud504\ub85c\uc81d\ud2b8\ub4e4\uc740 `\uc11c\ubc84\uc640 \ub3d9\uae30\ud654\ub97c \ud574\uc57c\ud560 \uc0c1\ud0dc`\uc640 `\uadf8\ub807\uc9c0 \uc54a\uc740 \uc0c1\ud0dc`\ub85c \ubd84\ub9ac\ud574\uc11c \uc0dd\uac01\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n`React\uc5d0\uc11c \uc5b4\ub5a4 \ub370\uc774\ud130\ub97c \uc0c1\ud0dc\ub85c \ub2e4\ub904\uc57c \ud558\ub294\uac00`\uc5d0 \ub300\ud574\uc11c\ub294 \uc5ec\ub7ec \uc758\uacac\uc774 \ub098\uc62c \uc218 \uc788\ub2e4\uace0 \uc0dd\uac01\ud558\uc9c0\ub9cc `\uc0c1\ud0dc\uac00 \ud2b9\uc131\uc744 \uac00\uc9c0\uace0 \uc788\ub294\uac00`\uc5d0 \ub300\ud574\uc11c\ub294 \ub300\ubd80\ubd84 \ud2b9\uc131\uc774 \uc788\ub2e4\uace0 \ub3d9\uc758\ud560 \uac83\uc785\ub2c8\ub2e4. \uc774 \uae00\uc5d0\uc11c\ub294 React\uc758 \uc0c1\ud0dc\ub780 \ubb34\uc5c7\uc778\uac00?\uc5d0 \ub300\ud574\uc11c \ub2e4\ub8e8\uc9c0 \uc54a\uace0 React\uc758 \uc0c1\ud0dc\uc758 \ud2b9\uc131\uc5d0 \ub300\ud574\uc11c\ub9cc \uc5b8\uae09\uc744 \ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uc0c1\ud0dc\uc758 \ud2b9\uc131\uc73c\ub85c\ub294 \ud06c\uac8c \ub450 \uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\\n\\n\ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub294 \ucef4\ud3ec\ub10c\ud2b8\ub4e4 \uac04\uc5d0 \uc5b4\ub5a4 \uac12\uc744 \uacf5\uc720\ud574\uc57c\ud558\uba74\uc11c `\uc624\ub85c\uc9c0 React DOM \ub0b4\ubd80\uc5d0\uc11c\ub9cc CRUD\uac00 \uc77c\uc5b4\ub098\ub294 \uc0c1\ud0dc\ub97c \uc758\ubbf8`\ud569\ub2c8\ub2e4. \uc774 \uc0c1\ud0dc\ub4e4\uc740 React DOM \uc678\ubd80 \uc138\uacc4\uc640 \ud06c\uac8c \uad00\ub828\uc774 \uc5c6\uc73c\uba70 `\ub3d9\uae30\uc801\uc73c\ub85c \ubc18\uc601`\ub429\ub2c8\ub2e4. \ub300\ud45c\uc801\uc73c\ub85c\ub294 UI\ub97c \uc870\uc791\ud558\ub294 \uc0c1\ud0dc\ub4e4\uc774 \ub420 \uac83\uc785\ub2c8\ub2e4. \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub4e4\uc740 \ub300\ubd80\ubd84 \uc7a5\uae30\uc801\uc73c\ub85c \uc720\uc9c0\ub420 \ud544\uc694\uac00 \uc5c6\uae30\uc5d0 \ud654\uba74\uc744 \ubc97\uc5b4\ub098\uac70\ub098 \uc138\uc158\uc774 \ub04a\uae30\ub294 \uacbd\uc6b0 \uc0ac\ub77c\uc838\ub3c4 \uad1c\ucc2e\uc740 \uacbd\uc6b0\uac00 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\\n### \uc11c\ubc84 \uc0c1\ud0dc\\n\\n\uc11c\ubc84 \uc0c1\ud0dc\ub294 React\uc758 \ubc14\uae65 \uc138\uc0c1(\uc11c\ubc84)\uc5d0 \uc874\uc7ac\ud558\ub294 `\ub370\uc774\ud130\uac00 React\uc758 \uc0c1\ud0dc \uad00\ub9ac\uc640 \ube44\ub3d9\uae30\uc801\uc73c\ub85c \ub3d9\uae30\ud654 \ub41c \uac83`\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4. \uc5b4\ub5a4 \uc0c1\ud0dc\uac00 `\uc678\ubd80\uc5d0\uc11c \uad00\ub9ac\ub418\ub294 \ub370\uc774\ud130\uc640 \ubc18\ub4dc\uc2dc \uc5f0\ub3d9`\ub418\uc5b4\uc57c \ud55c\ub2e4\uba74 \uc774\ub294 \uace7 \uc11c\ubc84 \uc0c1\ud0dc\uc784\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4. React\uc758 \uc0c1\ud0dc\ub97c CRUD \ud558\ub294 \uac83 \ubfd0\ub9cc \uc544\ub2cc, \uc11c\ubc84\uc5d0\uc11c\ub3c4 \ud56d\uc0c1 \uac19\uc740 \uc77c\uc774 \uc77c\uc5b4\ub098\uc57c \ud569\ub2c8\ub2e4. \uc11c\ubc84 \uc0c1\ud0dc\ub294 \uc7a5\uae30\uc801\uc73c\ub85c \uc720\uc9c0\ub418\uc5b4\uc57c \ud558\uba70, \uc138\uc158\uc5d0\uc11c \ubc97\uc5b4\ub098\ub354\ub77c\ub3c4 \uc11c\ubc84\ub85c \ubd80\ud130 \ubcf5\uad6c\ub97c \ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uae30\uc874\uc758 \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 \ub9ac\uc561\ud2b8\uc758 \uc804\uc5ed\uc5d0\uc11c \uc0c1\ud0dc\ub97c \uc870\uc791\ud558\ub294 \uac83\uc5d0 \ud2b9\ud654\ub418\uc5b4\uc788\uace0, \ube44\ub3d9\uae30\uc801\uc778 \uc0c1\ud0dc \uad00\ub9ac\ub3c4 \uc9c0\uc6d0\ud558\uc5ec \uc11c\ubc84\uc640\uc758 \ud1b5\uc2e0\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub300\ubd80\ubd84\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 `\ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub97c \uc870\uc791\ud558\ub294 \uac83\uc5d0 \ucd08\uc810`\uc774 \ub9de\ucdb0\uc838\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub354\uad70\ub2e4\ub098 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\uc640 \uc11c\ubc84 \uc0c1\ud0dc\uac00 \ud558\ub294 \uc77c\uc774 \uba85\ud655\ud558\uac8c \ub2e4\ub978 \uc0c1\ud669\uc5d0\uc11c \uc774 \ub458\uc744 \ud55c \uacf3\uc5d0\uc11c \uad00\ub9ac\ud558\ub294 \uac83 \ubcf4\ub2e4\ub294 \uc644\ubcbd\ud558\uac8c \ubd84\ub9ac\ud558\ub294 \uac83\uc774 \ub354 \ub098\uc744 \uac83\uc785\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc11c\ubc84 \uc0c1\ud0dc\ub97c \uad00\ub9ac\ud558\ub294 \uac83\uc5d0 \uc911\uc810\uc744 \ub454 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc774 \ub4f1\uc7a5\ud558\uc600\uc2b5\ub2c8\ub2e4. \ub300\ud45c\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c\ub294 RTK Query, Tanstack Query, SWR \ub4f1\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n# \uc65c Tanstack Query\uc600\ub098?\\n\\n### vs RTK Query\\n\\nRTK Query\ub294 RTK\ub97c \ubc18\ub4dc\uc2dc \uc0ac\uc6a9\ud574\uc57c \ud558\ub294 \uac83\uc740 \uc544\ub2c8\uc9c0\ub9cc RTK\ub97c \ud0c0\uac9f\uc73c\ub85c \ub098\uc628 \uc11c\ubc84 \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4. `\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ud074\ub77c\uc774\uc5b8\ud2b8 \uc0c1\ud0dc\ub97c \uad00\ub9ac\ud558\uae30 \uc704\ud574 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.` \ub354\uc6b1\uc774 Redux\uc758 \ubcf5\uc7a1\ud55c \ucf54\ub4dc \uad6c\uc131\uacfc \ubc29\ub300\ud55c \ubcf4\uc77c\ub7ec \ud50c\ub808\uc774\ud2b8\ub294 \ub9e4\ub825\uc801\uc774\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. tanstack query\uc5d0\uc11c\ub294 \ubb34\ud55c \ub370\uc774\ud130 \ud398\uce6d\uc744 \uc9c0\uc6d0\ud558\uae30 \uc704\ud574 **Infinite Queries**\uac00 \uc788\uc9c0\ub9cc RTK Query\ub294 \uadf8\ub807\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n### vs SWR\\n\\nSWR\ub3c4 \ud558\ub098\uc758 \uc88b\uc740 \uc120\ud0dd\uc9c0\uc600\uc9c0\ub9cc, \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc774 \ubc94\uc6a9\uc801\uc73c\ub85c \uc9c0\uc6d0\ud558\ub294 \uc140\ub809\ud130 \uae30\ub2a5\uc744 \uc9c0\uc6d0\ud558\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ub610, \uac00\ube44\uc9c0 \uceec\ub809\ud130\uc758 \ubd80\uc7ac\ub3c4 \uc544\uc26c\uc6e0\uc2b5\ub2c8\ub2e4. \uc7ac\uc694\uccad\uc744 \ud558\uae30 \uc704\ud55c stale time \uc124\uc815\uc774\ub098 \ucffc\ub9ac \ucde8\uc18c \uae30\ub2a5\uc774 \uc5c6\ub294 \uc810\ub3c4 \ub9e4\ub825\uc801\uc774\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.\\n\\n# \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ud558\ub824\ub294 \uc77c\uc740\uc694\u2026\\n\\n\uc800\ud76c \uce74\ud398\uc778 \ud300\uc758 \ud504\ub85c\uc81d\ud2b8\ub294 `\uc2e4\uc2dc\uac04 \uc804\uae30\uc790\ub3d9\ucc28 \ucda9\uc804\uc18c \uc9c0\ub3c4 \ubc0f \uc0ac\uc6a9 \ud1b5\uacc4 \uc870\ud68c \uc11c\ube44\uc2a4` \ub85c \uc9c0\ub3c4 \uae30\ubc18\uc758 \ud504\ub85c\uc81d\ud2b8\uc785\ub2c8\ub2e4. \uc11c\ubc84 \uc0c1\ud0dc\ub97c \uc801\uadf9\uc801\uc73c\ub85c \ub2e4\ub904\uc57c \ud558\ub294 \uc0c1\ud669\uc5d0\uc11c Tanstack Query\ub97c \uc11c\ubc84 \uc0c1\ud0dc \uad00\ub9ac \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c \uc120\uc815\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n\uba54\uc778 \uae30\ub2a5 \uc911 Tanstack Query\uac00 \ud575\uc2ec\uc73c\ub85c \uc0ac\uc6a9\ub420 \uac83 \uac19\uc740 \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n- \uc9c0\ub3c4\uc5d0\uc11c \ucda9\uc804\uc18c \uc870\ud68c\\n - \ud604\uc7ac \uc811\uc18d\ud55c \ud074\ub77c\uc774\uc5b8\ud2b8\uc5d0 \ub80c\ub354\ub9c1 \ub41c \uc9c0\ub3c4 \ud654\uba74(\ub514\uc2a4\ud50c\ub808\uc774)\uc758 \ud06c\uae30\uc5d0 \ub530\ub978 GPS\uc88c\ud45c\ub97c \uc54c\uc544\ub0b4\uc5b4 \uc11c\ubc84\ub85c \ubd80\ud130 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc218\uc2e0 \ubc1b\uc2b5\ub2c8\ub2e4. \uc989, \ud654\uba74\uc774 \uc774\ub3d9\ud558\uac8c \ub418\uba74 \uc0ac\uc6a9\uc790\uac00 \ubc14\ub77c\ubcf4\uace0 \uc788\ub294 \uc601\uc5ed\uc774 \ubcc0\ud558\ubbc0\ub85c \uc0c8\ub85c\uc6b4 \uc694\uccad\uc744 \ubcf4\ub0b4\uac8c \ub429\ub2c8\ub2e4.\\n - \uc11c\ubc84\uc5d0\uc11c \uc218\uc2e0\ud55c \ucda9\uc804\uc18c \uc815\ubcf4\ub294 \uc2e4\uc2dc\uac04 \uc0ac\uc6a9 \ud604\ud669\ub3c4 \ubc18\uc601\ub418\uc5b4\uc788\uc73c\ubbc0\ub85c `\uc8fc\uae30\uc801\uc778 \uc5c5\ub370\uc774\ud2b8`\ub3c4 \ud544\uc694\ud569\ub2c8\ub2e4.\\n - \ube48\ubc88\ud55c \ub370\uc774\ud130\uc758 \ubcc0\ud654\uac00 \ud544\uc694\ud558\uba70 \uadf8\ub9cc\ud07c \ud1b5\uc2e0 \uc2e4\ud328 \ub4f1 `\uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \uac00\ub2a5\uc131\ub3c4 \ub9ce\uc544\uc9c0\uac8c` \ub429\ub2c8\ub2e4.\\n - \uc0ac\uc6a9\uc790\uc758 \ube60\ub978 \uc9c0\ub3c4 \uc774\ub3d9\uc774 \ubc1c\uc0dd\ud558\ub294 \uacbd\uc6b0\ub97c \ub300\uc751\ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n- \uc804\uad6d \ucda9\uc804\uc18c \uac80\uc0c9\uae30\\n - \uc6d0\ud558\ub294 \ucda9\uc804\uc18c \uac80\uc0c9\uc744 \ud558\ub294 \uae30\ub2a5\uc744 \uc9c0\uc6d0\ud569\ub2c8\ub2e4. \uc804\uad6d \ub2e8\uc704\ub85c \uac80\uc0c9 \uacb0\uacfc\ub97c \uc218\uc2e0\ud558\ub294 \uae30\ub2a5\uc785\ub2c8\ub2e4.\\n - \ub124\uc774\ubc84\uc640 \uad6c\uae00 \uac80\uc0c9\ucc3d \ucc98\ub7fc \uc0ac\uc6a9\uc790\uac00 input \ucc3d\uc5d0 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud560 \ub54c \ub9c8\ub2e4 \uac80\uc0c9 \uacb0\uacfc\uac00 \ub3d9\uc801\uc73c\ub85c \ud45c\uc2dc\ub418\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n - \ube48\ubc88\ud55c \ub370\uc774\ud130\uc758 \ubcc0\ud654\uac00 \ud544\uc694\ud558\uace0, `\uc0ac\uc6a9\uc790\uc758 \ube60\ub978 \ud0c0\uc774\ud551\uc73c\ub85c \uc778\ud574 \uc7a6\uc740 \uac80\uc0c9\uc774 \ubc1c\uc0dd\ud558\ub294 \uacbd\uc6b0\ub97c \ub300\uc751\ud560 \uc218 \uc788\uc5b4\uc57c` \ud569\ub2c8\ub2e4.\\n - \uc774\ub97c \uc704\ud574 \ub370\uc774\ud130\ub97c \uce90\uc2f1\ud560 \ud544\uc694\ub3c4 \uc788\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n\\n\ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \ud074\ub77c\uc774\uc5b8\ud2b8\uc640 \uc11c\ubc84\uc640\uc758 \ud1b5\uc2e0\uc774 \uc5b4\uca4c\ub2e4 \ud55c\ubc88 \uc77c\uc5b4\ub09c\ub2e4\uba74 \uad73\uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \ud544\uc694\uac00 \uc5c6\uaca0\uc9c0\ub9cc, \uc11c\ubc84\uc758 \ub370\uc774\ud130 \uc804\uc801\uc73c\ub85c \uc758\uc874\ud574\uc57c \ud558\ub294 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8 \ud2b9\uc131\uc0c1 Tanstack Query\uc758 \uc5ec\ub7ec \uae30\ub2a5\uc774 \uc0dd\uc0b0\uc131\uc5d0 \ub9ce\uc740 \ub3c4\uc6c0\uc774 \ub420 \uac83\uc73c\ub85c \uae30\ub300\ud569\ub2c8\ub2e4."},{"id":"19","metadata":{"permalink":"/19","source":"@site/blog/2023-07-23-oauth.mdx","title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","description":"OAuth 2.0 ?","date":"2023-07-23T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 23\uc77c","tags":[{"label":"oauth","permalink":"/tags/oauth"},{"label":"login","permalink":"/tags/login"}],"readingTime":12.57,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"19","title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","authors":["boxster"],"tags":["oauth","login"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 \uc0c1\ud0dc\uad00\ub9ac \uc804\ub7b5 (\uc65c Tanstack Query\uc5ec\uc57c \ud558\ub294\uac00?)","permalink":"/21"},"nextItem":{"title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","permalink":"/18"}},"content":"## OAuth 2.0 ?\\n\\n\\n> OAuth(\\"Open Authorization\\")\ub294 \uc778\ud130\ub137 \uc0ac\uc6a9\uc790\ub4e4\uc774 \ube44\ubc00\ubc88\ud638\ub97c \uc81c\uacf5\ud558\uc9c0 \uc54a\uace0 \ub2e4\ub978 \uc6f9\uc0ac\uc774\ud2b8 \uc0c1\uc758 \uc790\uc2e0\ub4e4\uc758 \uc815\ubcf4\uc5d0 \ub300\ud574 \uc6f9\uc0ac\uc774\ud2b8\ub098 \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc758 \uc811\uadfc \uad8c\ud55c\uc744 \ubd80\uc5ec\ud560 \uc218 \uc788\ub294 \uacf5\ud1b5\uc801\uc778 \uc218\ub2e8\\n\\n\uc704\ud0a4 \ubc31\uacfc\uc5d0\uc11c\ub294 \uc704\uc640 \uac19\uc774 \uc124\uba85\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc6b0\ub9ac\uac00 google\uacfc \uac19\uc740 \uc6f9 \uc0ac\uc774\ud2b8\uc5d0 \ud68c\uc6d0\uac00\uc785\uc744 \ud558\uace0 \uc800\uc7a5\ud574\ub454 \uc774\ub984, \uc774\uba54\uc77c, \ud504\ub85c\ud544 \uc774\ubbf8\uc9c0 \uac19\uc740 \uc815\ubcf4\ub97c\\n\uad73\uc774 \ud55c\ubc88 \ub354 \uc785\ub825\ud558\uc9c0 \uc54a\uace0\ub3c4 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\uc5d0\uc11c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uac83 \uc785\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\ub97c \uc0ac\uc6a9\ud558\ub354\ub77c\ub3c4 google\uc5d0\uc11c \ub85c\uadf8\uc778\uc744 \ud558\ub294 \uacfc\uc815\uc744 \uac70\uce58\uae30 \ub54c\ubb38\uc5d0, \uc0ac\uc6a9\uc790\ub294\\n\ube44\ubc00\ubc88\ud638\ub098, critical\ud55c \uac1c\uc778\uc815\ubcf4 \uac19\uc740 \uac83\uc744 \ud55c \uacf3\uc5d0\uc11c \uad00\ub9ac\ud560 \uc218 \uc788\ub2e4\ub294 \uc7a5\uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub2e4\uc2dc \ud55c\ubc88 \uc815\ub9ac\ud558\uc790\uba74 \uc6b0\ub9ac \uc6f9 \uc0ac\uc774\ud2b8\uc758 \uc0ac\uc6a9\uc790\uac00 \uc774\uc6a9\ud558\ub294 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\uc758 \uc815\ubcf4\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc788\uac8c\ub054 \ub2e4\ub978 \uc6f9 \uc0ac\uc774\ud2b8\uc5d0\uc11c \uad8c\ud55c\uc744 \uc704\uc784 \ubc1b\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n### OAuth flow\\n\\n\\nOAuth Flow\ub97c \uc124\uba85\ud558\uae30 \uc804\uc5d0 \uc5ec\uae30\uc11c \ubaa8\ub974\ub294 \ub2e8\uc5b4\ub4e4\uc774 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\ud574\ub2f9 [\ub9c1\ud06c](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16#section-1.1)\uc5d0\uc11c \ub354 \uc790\uc138\ud558\uac8c \uc815\ub9ac \ub418\uc5b4\uc788\uc9c0\ub9cc \uc124\uba85\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n#### Resource Owner\\nResource Owner\ub294 \ub9d0 \uadf8\ub300\ub85c \ub9ac\uc18c\uc2a4 \uc18c\uc720\uc790\uc774\uace0, \uad6c\uae00\uacfc \uac19\uc740 \ud50c\ub7ab\ud3fc\uc5d0 \ud68c\uc6d0\uac00\uc785\uc774 \ub418\uc5b4\uc788\ub294, \uc989 \uad6c\uae00\uc5d0 \uc790\uc2e0\uc758 \uc815\ubcf4\ub4e4\uc774 \uc788\ub294 \uc0ac\uc6a9\uc790\uc785\ub2c8\ub2e4.\\n\\n#### Client\\nClient\ub3c4 \ub9d0 \uadf8\ub300\ub85c \uace0\uac1d\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc5b4\ub5a4 \uad00\uc810\uc5d0\uc11c \ubcf4\ub290\ub0d0 \uace0\uac1d\uc774\ub780 \ub73b\uc740 \ub2ec\ub77c\uc9d1\ub2c8\ub2e4. \uc5ec\uae30\uc11c\ub294 Google\uacfc \uac19\uc740 \ud50c\ub7ab\ud3fc\uc5d0\uc11c \uc81c\uacf5\ubc1b\uc740 \ub9ac\uc18c\uc2a4\ub97c \uc0ac\uc6a9\ud558\ub294 \uace0\uac1d\uc785\ub2c8\ub2e4.\\n\uc989 \uc6b0\ub9ac\uc758 \uc11c\ube44\uc2a4\uac00 Client\uac00 \ub418\ub294 \uac83\uc785\ub2c8\ub2e4. \uc65c\ub0d0\uba74 \uc6b0\ub9ac\ub294 \uad6c\uae00\uc5d0 \uc815\ubcf4\ub97c \uc694\uccad\ud558\uace0 \uc6b0\ub9ac\uc758 \uc11c\ube44\uc2a4\uc5d0\uc11c \uc0ac\uc6a9\ud558\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\\n#### Authorization Server\\n\uc5ec\uae30\ub3c4 \ub9d0 \uadf8\ub300\ub85c \uc778\uc99d \uc11c\ubc84\uc785\ub2c8\ub2e4. Resource Owner\uac00 \uc62c\ubc14\ub978 \uc815\ubcf4\ub97c \uc785\ub825\ud588\ub294\uc9c0 \uac80\uc99d\ud558\uace0, \ubc1c\uae09 \ubc1b\uc740 Code\uc640 Token\uc774 \uc62c\ubc14\ub978 \uac83\uc778\uc9c0 \uac80\uc99d\ud569\ub2c8\ub2e4.\\n\\n#### Resource Server\\nResource Owner\uc758 \uc815\ubcf4\ub4e4\uc744 \uac00\uc9c0\uace0 \uc788\ub294 \uc11c\ubc84\uc785\ub2c8\ub2e4. \uc778\uc99d \uc11c\ubc84\uc5d0\uc11c \uc778\uc99d\uc744 \ub9c8\uce58\uace0 \ub09c \ub4a4 \uc6b0\ub9ac\ub294 Resource\ub97c \ubc1b\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc5ec\uae30\uc11c Authorization Server \uc640 Resource Server\uac00 \ub098\ub258\uc5b4\uc9c4 \uc774\uc720\ub294 \ub531\ud788 \uc5c6\uc2b5\ub2c8\ub2e4. **\ud574\ub2f9 \ud50c\ub7ab\ud3fc\uc758 \uc11c\ubc84 \uad6c\uc131\uc5d0 \ub530\ub77c \ub2e4\ub97c \uc218 \uc788\uc2b5\ub2c8\ub2e4.**\\n\\n\uc911\uc694\ud55c \uac83\uc740 **Authorization Server**\uc640 **Resource Server**\uac00 \uac19\uc740 \ubb36\uc74c\uc774\ub77c\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n```mermaid\\nsequenceDiagram\\n actor RO as Resource Owner (\ubc15\uc2a4\ud130)\\n participant C as Client (\uce74\ud398\uc778)\\n participant AS as Authorization Server (\uad6c\uae00)\\n participant RS as Resource Server (\uad6c\uae00)\\n\\n RO->>+C: 1. \ub85c\uadf8\uc778 \uc694\uccad\\n C--\x3e>-AS: 2. \ub85c\uadf8\uc778 \uc694\uccad\\n AS ->>+ RO: 3. \ub85c\uadf8\uc778 \ud398\uc774\uc9c0 \uc81c\uacf5\\n RO ->>+ AS: 4. ID/PW \uc785\ub825\\n AS ->> RO: 5. Authorization Code \ubc1c\uae09\\n RO ->> C: 6. Redirect URI\ub85c \uc774\ub3d9\\n C ->>+ AS: 7. \ubc1c\uae09 \ubc1b\uc740 Authorization Code\ub85c Token \uc694\uccad\\n AS ->> C: 8. Access Token \ubc1c\uae09\\n C ->> RO: 9. \ub85c\uadf8\uc778 \uc131\uacf5\\n RO ->> C: 10. \uc11c\ube44\uc2a4 \uc694\uccad\\n C ->> RS: 11. \ubc1c\uae09 \ubc1b\uc740 Token\uc73c\ub85c \uc815\ubcf4 \ud638\ucd9c\\n RS ->> C: 12. \uc815\ubcf4 \uc81c\uacf5\\n```\\n\\n\uac04\ub2e8\ud558\uac8c flow\ub97c \ub3c4\uc2dd\ud654 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. \uba3c\uc800 Resource Owner\ub294 \ub85c\uadf8\uc778\uc744 \ud558\uace0 \uc2f6\ub2e4\uba74 Client\uac00 \uc81c\uacf5\ud558\ub294 \ud574\ub2f9 Resource platform\uc758 URI\ub97c \ud074\ub9ad\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc778\uc99d\uc11c\ubc84\uc5d0\uc11c \ub85c\uadf8\uc778 \ud398\uc774\uc9c0\ub97c \uc81c\uacf5 \ubc1b\uc2b5\ub2c8\ub2e4.\\n2. \uadf8\ub9ac\uace0 Resource Owner\ub294 ID/PW\ub97c \uc785\ub825\ud558\uace0 Authorization Code\ub97c \ubc1c\uae09 \ubc1b\uc2b5\ub2c8\ub2e4. \ub3d9\uc2dc\uc5d0 Client\uc5d0\uc11c \ub4f1\ub85d\ud574\ub193\uc740 Redirect URI\ub85c code\uc640 \ud568\uaed8 \uc774\ub3d9\ud569\ub2c8\ub2e4.\\n3. Client\ub294 Resource Owner\uc5d0\uac8c \ubc1b\uc740 Code\ub97c \uac00\uc9c0\uace0 Authorization Server\uc5d0 \ud1a0\ud070\uc744 \uc694\uccad\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ubc1b\uc740 \ud1a0\ud070\uc744 \uc800\uc7a5\ud569\ub2c8\ub2e4.\\n4. \uadf8\ub9ac\uace0 Client\ub294 \ub85c\uadf8\uc778\uc744 \uc131\uacf5\ud558\uace0 \uc774\ud6c4 \ub2e4\ub978 platform\uc5d0\uc11c \uc815\ubcf4\ub97c \ud544\uc694\ud558\uac8c \ub41c\ub2e4\uba74 \uc800\uc7a5\ud55c Access Token\uc744 \ud1b5\ud574 Resource Server\uc5d0\uc11c \uc815\ubcf4\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n\\n\uadfc\ub370 \uc5ec\uae30\uc11c \uc774\uc0c1\ud55c \uc810\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc774\uc0c1\ud558\ub2e4\uae30\ubcf4\ub2e8 \uc65c \uc774\ub807\uac8c \ubcf5\uc7a1\ud55c\uac00 \ub77c\ub294 \uc758\ubb38\uc744 \uac00\uc9c8 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\\n\uad73\uc774 Authorization code\ub97c \ubc1b\uc544 \ub2e4\uc2dc \ud55c\ubc88 \ub354 Access Token\uc744 \ubc1b\uc544\uc57c \ud55c\ub2e4\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4. \ubc14\ub85c **Client\uc5d0\uac8c Access Token\uc744 \uc900\ub2e4\uba74 \ud1b5\uc2e0\uc774 \ud55c\ubc88 \uc904\uc5b4\ub4e4 \uc218 \uc788\uc9c0 \uc54a\uc744\uae4c??**\\n\\n\ubcf4\uc548\ubb38\uc81c \ub54c\ubb38\uc785\ub2c8\ub2e4.\\n\ub9cc\uc57d \ubc14\ub85c Access Token\uc744 \uc900\ub2e4\uba74 \uadf8 Access Token\uc774 \ud0c8\ucde8 \ub2f9\ud558\uba74 \ud574\ub2f9 Resource Owner\uc758 \ubaa8\ub4e0 \uc815\ubcf4\uc5d0 \uc811\uadfc\ud560 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\nCode\ub294 Secret Key\uc640 \uac19\uc774 \uc804\ub2ec\ud574\uc57c Access Token\uc744 \ubc1c\uae09 \ubc1b\uc744 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \ud0c8\ucde8\ub418\uc5b4\ub3c4 \ub354 \uc548\uc804\ud569\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \ub2e4\ub978 \ud50c\ub7ab\ud3fc\uc5d0\uc11c Code\ub098 Token\uc774\ub098 \ud574\ub2f9 \uc815\ubcf4\ub97c \uc804\ub2ec\ud560 \ubc29\ubc95\uc740 URI\uc5d0 \uc804\ub2ec\ud558\ub294 \ubc29\ubc95\ubfd0 \uc785\ub2c8\ub2e4.\\n\uadf8\ub807\uae30 \ub54c\ubb38\uc5d0 Redircet URI\uc5d0 Access Token\uc744 \ub2f4\ub294\ub2e4\uba74 \ud0c8\ucde8 \uac00\ub2a5\uc131\uc774 \ucee4\uc9c0\uae30 \ub54c\ubb38\uc5d0 \ubcf4\uc548\ubb38\uc81c\uac00 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n\\n### \ubc31\uc5d4\ub4dc\uc640 \ud504\ub860\ud2b8\uc5d4\ub4dc\uc758 flow\\n\\n```mermaid\\n\\nsequenceDiagram\\n actor RO as Resource Owner\\n participant F as Frontend\\n participant B as Backend\\n participant AS as Authorization Server\\n participant RS as Resource Server\\n\\n RO->>+F: 1. \ub85c\uadf8\uc778 \uc694\uccad\\n F--\x3e>-B: 2. \ub85c\uadf8\uc778 \uc694\uccad\\n B->>+F: 3. \ud574\ub2f9 \ud50c\ub7ab\ud3fc \ub85c\uadf8\uc778 URI \uc81c\uacf5\\n F--\x3e>-RO: 4. \ud574\ub2f9 \ud50c\ub7ab\ud3fc \ub85c\uadf8\uc778 URI \uc81c\uacf5\\n AS ->>+ RO: 5. \ub85c\uadf8\uc778 \ud398\uc774\uc9c0 \uc81c\uacf5\\n RO ->>+ AS: 6. ID/PW \uc785\ub825\\n AS ->> RO: 7. Authorization Code \ubc1c\uae09\\n RO ->> F: 8. Redirect URI\ub85c \uc774\ub3d9 (w. Authorization Code)\\n F ->>+ B: 9. \ubc1c\uae09 \ubc1b\uc740 Authorization Code \uc804\ub2ec\\n B ->>+ AS: 10. \uc804\ub2ec \ubc1b\uc740 Authorization Code\ub85c Token \uc694\uccad\\n AS ->> B: 11. Access Token \ubc1c\uae09\\n B ->>+ F: 12. \ub85c\uadf8\uc778 \uc131\uacf5\\n F --\x3e>- RO: 13. \ub85c\uadf8\uc778 \uc131\uacf5\\n RO ->>+ F: 14. \uc11c\ube44\uc2a4 \uc694\uccad\\n F --\x3e>- B: 15. \uc11c\ube44\uc2a4 \uc694\uccad\\n B ->> RS: 16. \ubc1c\uae09 \ubc1b\uc740 Token\uc73c\ub85c \uc815\ubcf4 \ud638\ucd9c\\n RS ->> B: 17. \uc815\ubcf4 \uc81c\uacf5\\n\\n```\\n\uc544\uae4c Client \ubd80\ubd84\uc744 \uc880 \ub354 Frontend, Backend\ub85c \uad6c\ubd84\uc9c0\uc5b4 \uc138\ubd84\ud654 \ud574\ubd24\uc2b5\ub2c8\ub2e4. \ubcf5\uc7a1\ud574\ubcf4\uc774\uc9c0\ub9cc, \uc804\ud600 \uc5b4\ub835\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc544\uae4c \uc124\uba85\ud588\ub358 \ud750\ub984\uacfc \ub2e4\ub978 \ubd80\ubd84\uc740 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\\n\ub610 \uc5ec\uae30\uc11c\ub294 \uad73\uc774 \uc81c\uac00 Authorization Server\uc5d0\uc11c Code\ub97c \ubc1b\uc544\uc62c \ub54c Redirect URI\ub97c \ubc31\uc5d4\ub4dc \uc11c\ubc84\ub85c \ud558\uc9c0\uc54a\uace0 \ud504\ub860\ud2b8\uc5d4\ub4dc \uc11c\ubc84\ub85c \ud558\ub824\ub294 \uc774\uc720\ub294 Resource Owner\uac00 \ub2e4\ub978 platform\uacfc \uc778\uc99d\ud558\ub294 \ubd80\ubd84\uc740 \ubc31\uc5d4\ub4dc\uc758 \uc5ed\ud560\uc774 \uc544\ub2c8\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub9ac\uace0 \ubc31\uc5d4\ub4dc\ub294 Resource Owner\uac00 \uac00\uc838\uc628 code\ub97c \ud504\ub860\ud2b8\uc5d4\ub4dc\uc5d0\uc11c \uc804\ub2ec \ubc1b\uc544 Resource Server\uc5d0 \uc815\ubcf4\ub97c \uc694\uccad\ud558\ub294 \uac83\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n***(\ubb3c\ub860 \uc81c \uac1c\uc778\uc801\uc778 \uc758\uacac\uc774\ub77c \uc815\ub2f5\uc740 \uc544\ub2d9\ub2c8\ub2e4.)***\\n\\n\\n## OAuth \uad6c\ud604\ud574\ubcf4\uae30\\n\\n\uac04\ub2e8\ud788 Spring Security \uc5c6\uc774 OAuth \uc778\uc99d\uc744 \uad6c\ud604\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc81c\uc77c \uba3c\uc800 \uad6c\uae00 \ud639\uc740 \ub2e4\ub978 \ud50c\ub7ab\ud3fc\uc5d0\uc11c \uc124\uc815\ud55c id, secret key \ub4f1\ub4f1\uc758 \uc815\ubcf4\ub97c yml\uc5d0 \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4.\\n```yml title=\\"application-oauth.yml\\"\\noauth2:\\n provider:\\n google:\\n id: google-id\\n secret: google-secret-key\\n redirect-url: http://localhost:8080/login/oauth2/code/google\\n token-url: https://www.googleapis.com/oauth2/v4/token\\n info-url: https://www.googleapis.com/oauth2/v2/userinfo\\n```\\n\uadf8\ub9ac\uace0 OAuth\ub294 \uc5b4\ub290 \ud50c\ub7ab\ud3fc\uc774 \ub420 \uc9c0 \ubaa8\ub974\uace0, \ud655\uc7a5\uc131 \uc788\uac8c \uad6c\uc131\ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc544 \uc778\ud130\ud398\uc774\uc2a4\ub85c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n```java title=\\"OAuthMember.java\\"\\npublic interface OAuthMember {\\n String id();\\n String email();\\n String nickname();\\n String imageUrl();\\n}\\n```\\n\uc774\ub7ec\ud55c \ud074\ub798\uc2a4\ub4e4\uc744 \uad00\ub9ac\ud558\uae30 \uc27d\uac8c Enum\uc744 \ucd94\uac00\ud569\ub2c8\ub2e4.\\n\\n```java title=\\"Provider.java\\"\\npublic enum Provider {\\n\\n GOOGLE(\\"google\\", GoogleMember::new),\\n ;\\n\\n private final String providerName;\\n private final Function, OAuthMember> function;\\n\\n Provider(String providerName, Function, OAuthMember> function) {\\n this.providerName = providerName;\\n this.function = function;\\n }\\n\\n public static Provider from(String name) {\\n return Arrays.stream(values())\\n .filter(it -> it.providerName.equals(name))\\n .findFirst()\\n .orElseThrow(() -> new RuntimeExceptin());\\n }\\n\\n public OAuthMember getOAuthProvider(Map body) {\\n return function.apply(body);\\n }\\n}\\n```\\n\ud574\ub2f9 Enum\uc740 \ub450\uac1c\uc758 \ud544\ub4dc\ub97c \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4. \ud558\ub098\ub294 \ud574\ub2f9 \ud50c\ub7ab\ud3fc\uc758 \uc774\ub984, \uadf8\ub9ac\uace0 `Map`\ub97c \uc544\uae4c \ub9cc\ub4e4\uc5c8\ub358 \uc778\ud130\ud398\uc774\uc2a4\ub85c \ubc18\ud658\ud558\ub294 Function \uc5ec\uae30\uc11c\\n`Map`\ub85c \uc9c0\uc815\ud574\uc900 \uc774\uc720\ub294, \ud50c\ub7ab\ud3fc\ub9c8\ub2e4 \ubc18\ud658\ub418\ub294 JSON \ud0c0\uc785\uc774 \ub2e4\ub974\uae30 \ub54c\ubb38\uc5d0 \uadf8\ub7f0 \ubd80\ubd84\uc5d0 \ub300\ud574 \uc911\ubcf5\uc744 \uc81c\uac70\ud558\uae30 \uc704\ud574 \uc774\ub7ec\ud55c \ud615\ud0dc\ub85c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc544\uae4c yml\uc5d0 \uc791\uc131\ud588\ub358 \uc815\ubcf4\ub4e4\uc744 \uac00\uc838\uc640\uc57c\ud569\ub2c8\ub2e4. `@Value` \uc5b4\ub178\ud14c\uc774\uc158\uc73c\ub85c\ub3c4 \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n```java\\n @Value(\\"oauth.provider.google.id\\")\\n private String id;\\n @Value(\\"oauth.provider.google.secret\\")\\n private String secret;\\n\\n ...\\n```\\n\ud558\uc9c0\ub9cc \uc774\ub807\uac8c \uacc4\uc18d binding\uc744 \ud574\uc918\uc57c\ud55c\ub2e4\ub294 \uc810\uc774 \uc544\uc8fc \uadc0\ucc2e\uace0 \ubcf4\uae30\ub3c4 \uc548\uc88b\uc2b5\ub2c8\ub2e4.\\n```groovy title=\\"build.gradle\\"\\nannotationProcessor \\"org.springframework.boot:spring-boot-configuration-processor\\"\\n```\\n\ud558\uc9c0\ub9cc \uc704\uc758 \uc758\uc874\uc131\uc744 \ucd94\uac00\ud574\uc900\ub2e4\uba74 \uc544\uc8fc \ud3b8\ud558\uac8c property\ub97c \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```java title=\\"OAuthProviderProperties.java\\"\\n@Component\\n@ConfigurationProperties(prefix = \\"oauth2\\")\\npublic class OAuthProviderProperties {\\n // prefix oauth2 \uae30\uc900\uc73c\ub85c \uc54c\uc544\uc11c google\uc774 \uc774\ub984\uc778 Provider Enum\uc744 \ucc3e\uc544\uc11c Key\ub85c \ubc14\uc778\ub529\\n private final Map provider = new EnumMap<>(Provider.class);\\n\\n public OAuthProviderProperty getProviderProperties(Provider provider) {\\n return this.provider.get(provider);\\n }\\n\\n @Getter\\n @Setter\\n public static class OAuthProviderProperty {\\n // \uadf8\ub9ac\uace0 provider \ud558\uc704 \uc815\ubcf4\ub4e4\uc740 \uc544\ub798\uc758 \ud544\ub4dc\uc5d0 \ubc14\uc778\ub529\\n private String id;\\n private String secret;\\n private String redirectUrl;\\n private String tokenUrl;\\n private String infoUrl;\\n }\\n}\\n```\\n\uc774\ub807\uac8c \ub418\uba74 \uad6c\uc870\uc801\uc778 \uc900\ube44\ub294 \ub05d\ub0ac\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc81c\ub294 \ud574\ub2f9 \ud50c\ub7ab\ud3fc\uc5d0 \uc815\ubcf4\ub97c \uc694\uccad\ud558\ub294 \uc791\uc5c5\ub9cc \ud558\uba74 \ub429\ub2c8\ub2e4.\\n\uadf8\ub7fc \uc544\uae4c \ub9d0\uc500\ub4dc\ub838\ub358 \uc21c\uc11c\ub85c \uc694\uccad\uc744 \ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java title=\\"RestTemplateOAuthRequester.java\\"\\npublic class RestTemplateOAuthRequester implements OAuthRequester {\\n\\n @Override\\n public OAuthMember login(OAuthLoginRequest request) {\\n // frontend\uc5d0\uc11c \ubc1b\uc544\uc628 \ub85c\uadf8\uc778 platform\\n Provider provider = Provider.from(request.provider());\\n // \ud574\ub2f9 Platform\uc5d0 \ub9de\ub294 \uc815\ubcf4 \ucc3e\uc74c\\n OAuthProviderProperty property = oAuthProviderProperties.getProviderProperties(provider);\\n // frontend\uc5d0\uc11c \ubc1b\uc544\uc628 code\uc640 \ub4f1\ub85d\ud574\ub193\uc740 property\ub85c Access Token \uc694\uccad\\n OAuthTokenResponse token = requestAccessToken(property, requet.getCode());\\n // \ubc1b\uc544\uc628 Token\uc73c\ub85c \ud574\ub2f9 Resource Owner\uc758 \uc815\ubcf4 \uc694\uccad\\n Map userAttributes = getUserAttributes(property, token);\\n return provider.getOAuthProvider(userAttributes);\\n }\\n\\n private OAuthTokenResponse requestAccessToken(OAuthProviderProperty property, String code) {\\n HttpHeaders headers = new HttpHeaders();\\n headers.setBasicAuth(property.getId(), property.getSecret());\\n headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);\\n\\n HttpEntity> request = new HttpEntity<>(headers);\\n URI tokenUri = getTokenUri(property, code);\\n return restTemplate.postForEntity(tokenUri, request, OAuthTokenResponse.class).getBody();\\n }\\n\\n private URI getTokenUri(OAuthProviderProperty property, String code) {\\n return UriComponentsBuilder.fromUriString(property.getTokenUrl())\\n .queryParam(CODE, URLDecoder.decode(code, StandardCharsets.UTF_8))\\n .queryParam(GRANT_TYPE, AUTHORIZATION_CODE)\\n .queryParam(REDIRECT_URI, property.getRedirectUrl())\\n .build()\\n .toUri();\\n }\\n\\n private Map getUserAttributes(OAuthProviderProperty property, OAuthTokenResponse tokenResponse) {\\n HttpHeaders headers = new HttpHeaders();\\n headers.setBearerAuth(tokenResponse.accessToken());\\n headers.setContentType(MediaType.APPLICATION_JSON);\\n URI uri = URI.create(property.getInfoUrl());\\n RequestEntity requestEntity = new RequestEntity<>(headers, HttpMethod.GET, uri);\\n ResponseEntity> responseEntity = restTemplate.exchange(requestEntity, new ParameterizedTypeReference<>() {\\n });\\n return responseEntity.getBody();\\n }\\n}\\n```\\n\\n\uc774\ub807\uac8c\ub9cc \ud55c\ub2e4\uba74 \uadf8 \uc5b4\ub824\uc6cc \ubcf4\uc774\ub358 OAuth \uc778\uc99d\ub3c4 \uac04\ub2e8\ud558\uac8c \ud574\uacb0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n***(\ubb3c\ub860 \uc81c \ucf54\ub4dc\uac00 \uc815\ub2f5\uc774 \uc544\ub2d9\ub2c8\ub2e4)***\\n\\n\\n### Reference\\nhttps://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16\\n\\nhttps://developers.google.com/identity/protocols/oauth2?hl=ko"},{"id":"18","metadata":{"permalink":"/18","source":"@site/blog/2023-07-23-why-private-ip-is-required-for-instance.mdx","title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","description":"\uc5b4\ub5a4 \ubb38\uc81c\uac00 \uc788\uc5c8\ub098\uc694?","date":"2023-07-23T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 23\uc77c","tags":[{"label":"aws","permalink":"/tags/aws"},{"label":"vpc","permalink":"/tags/vpc"},{"label":"subnet","permalink":"/tags/subnet"},{"label":"ip","permalink":"/tags/ip"}],"readingTime":10.365,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"18","title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","authors":["nunu"],"tags":["aws","vpc","subnet","ip"]},"prevItem":{"title":"OAuth 2.0\uc758 \ud750\ub984\uacfc \uc124\uc815 \ud574\ubcf4\uae30","permalink":"/19"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","permalink":"/17"}},"content":"## \uc5b4\ub5a4 \ubb38\uc81c\uac00 \uc788\uc5c8\ub098\uc694?\\n\\n\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c private \uc11c\ube0c\ub137\uc5d0 db \uc778\uc2a4\ud134\uc2a4\ub97c \ub450\uace0, \ubcf4\uc548\uc744 \uc704\ud574 \uc678\ubd80\uc5d0\uc11c \uc811\uc18d\uc744 \ucc28\ub2e8\ud558\ub824\uace0 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c \ucd1d 2\uac00\uc9c0\uc758 \ubb38\uc81c\uc810\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1. private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0\uc11c mysql\uc744 \uc124\uce58\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n2. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc774 \uc548\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ubd80\ubd84\uc744 \uc5b4\ub5bb\uac8c \ud574\uacb0\ud588\ub294\uc9c0 \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ub798\uc758 \ubaa8\ub4e0 \uc124\uba85\uc740 AWS \ub97c \uae30\uc900\uc73c\ub85c \ud569\ub2c8\ub2e4.\\n\\n## private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0\uc11c mysql\uc744 \uc124\uce58\ud560 \uc218 \uc5c6\uc5c8\ub2e4.\\n\\n### \ud574\uacb0 \ubc29\ubc95\\n\\npublic ip \uc790\ub3d9\ud560\ub2f9\uc744 \ud574\uc8fc\uc9c0 \uc54a\uc544\uc11c, \uc778\ud130\ub137\uc5d0 \uc5f0\uacb0\uc774 \uc548 \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 public ip \uc790\ub3d9\ud560\ub2f9\uc744 \ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc65c public ip\ub97c \ud560\ub2f9\ud588\ub354\ub2c8 \ubb38\uc81c\uac00 \ud574\uacb0\ub418\uc5c8\uc744\uae4c\uc694?\\n\\n## private \uc11c\ube0c\ub137\uc774\ub780?\\n\\n\uc815\ub9d0 \uac04\ub2e8\ud558\uac8c \uc124\uba85\ud588\uc744 \ub54c\\n\\nprivate \uc11c\ube0c\ub137\uc740 \uc778\ud130\ub137\uc5d0 \uc5f0\uacb0\ub418\uc9c0 \uc54a\uc740 \uc11c\ube0c\ub137\uc785\ub2c8\ub2e4.\\n\\n\uc870\uae08 \uc790\uc138\ud558\uac8c \ub4e4\uc5b4\uac00 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\nprivate \uc11c\ube0c\ub137\uc740 \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc9c0 \uc54a\uc740 \uc11c\ube0c\ub137\uc785\ub2c8\ub2e4.\\n\\naws \uacf5\uc2dd\ubb38\uc11c\uc5d0\uc11c \uc0ac\uc9c4\uc744 \ud1b5\ud574 \ubcf4\uba74 \uc544\ub798\uc640 \uac19\uc774 \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4\\n\\n![private subnet](https://docs.aws.amazon.com/ko_kr/vpc/latest/userguide/images/internet-gateway-basics.png)\\n\\npublic \uc11c\ube0c\ub137\uc5d0\ub9cc \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc5b4 \uc788\uace0, private \uc11c\ube0c\ub137\uc5d0\ub294 \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc5b4\uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\nprivate \uc11c\ube0c\ub137\uc5d0 \uc778\ud130\ub137 \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc5f0\uacb0\ub418\uc5b4 \uc788\uc9c0 \uc54a\ub2e4\uace0 \ud588\uc744 \ub54c, \uae30\ubcf8\uc801\uc73c\ub85c \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc774 \uc548\ub429\ub2c8\ub2e4.\\n\\nmysql\uc744 \uc124\uce58\ud560 \ub54c\ub3c4, \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc744 \ud574\uc57c\ud558\ub294\ub370, \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc774 \uc548\ub418\ub2c8 \uc124\uce58\uac00 \uc548\ub418\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\\n### \uc5b4? \uc778\ud130\ub137 \uc790\uccb4\uac00 \uc811\uadfc\uc774 \uc548\ub418\uba74 \uc5b4\ub5bb\uac8c \uc124\uce58\ud558\ub098\uc694?\\n\\n\uc815\ub9d0 \uc6d0\uc2dc\uc801\uc73c\ub85c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c\ub294 public \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \ud558\ub098 \ub354 \ub9cc\ub4e4\uc5b4\uc11c, mysql \uc744 \uc555\ucd95\ud574\uc11c scp\ub97c \ud1b5\ud574 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc804\uc1a1\ud558\uace0, \uc555\ucd95\uc744 \ud480\uc5b4\uc11c \uc124\uce58\ud558\ub294 \ubc29\ubc95\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774 \ubc29\ubc95\uc740 \ub108\ubb34 \uc6d0\uc2dc\uc801\uc774\uace0, \ube44\ud6a8\uc728\uc801\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc778\ud130\ub137\uc73c\ub85c \uc694\uccad\uc744 \ubcf4\ub0bc \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4dc\ub294 \uacfc\uc815\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n### \uc778\ud130\ub137\uc73c\ub85c \uc694\uccad\uc744 \ubcf4\ub0bc \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4dc\ub294 \uacfc\uc815\\n\\n\uc778\ud130\ub137\uc73c\ub85c \uc694\uccad\uc744 \ubcf4\ub0bc \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4dc\ub294 \uacfc\uc815\uc740 \ud06c\uac8c 2\uac00\uc9c0\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### private \uc11c\ube0c\ub137\uc744 public \uc11c\ube0c\ub137\uc73c\ub85c \ubc14\uafb8\uae30\\n\\n\ubcf4\uc548\uc744 \uc704\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \ub450\ub824\uace0 \ud588\ub358 \uac83\uc744 public \uc11c\ube0c\ub137\uc73c\ub85c \ubc14\uafbc\ub2e4\ub294 \ubd80\ubd84\uc740 \ub9e4\uc6b0 \uc704\ud5d8\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc774 \ubc29\ubc95\uc740 \ubcf4\ud1b5 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n### NAT \uc778\uc2a4\ud134\uc2a4(Gateway) \ub9cc\ub4e4\uae30\\n\\nNAT \uc778\uc2a4\ud134\uc2a4\ub294 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0 \uc811\uc18d\ud560 \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4e4\uc5b4\uc8fc\ub294 \uc778\uc2a4\ud134\uc2a4\uc785\ub2c8\ub2e4.\\n\\n\uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc744 \ud558\uae30 \uc704\ud574\uc11c\ub294 public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c NAT \uc778\uc2a4\ud134\uc2a4, NAT \uac8c\uc774\ud2b8\uc6e8\uc774\ub294 public \uc11c\ube0c\ub137\uc5d0 \uc874\uc7ac\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uc5b4? NAT \uc778\uc2a4\ud134\uc2a4\ub97c \ud1b5\ud574\uc11c \ubc14\ub85c \ud1b5\uc2e0\uc774 \uac00\ub2a5\ud558\uba74 \uc65c private \uc11c\ube0c\ub137\uc774 \ud544\uc694\ud55c\uac00\uc694? \uadf8\ub0e5 \ub2e4 public \uc11c\ube0c\ub137\uc5d0 \ub450\uba74 \ub418\uc9c0 \uc54a\ub098\uc694?\\n\\nNAT \uc778\uc2a4\ud134\uc2a4, NAT Gateway\ub294 \ub0b4\ubd80\uc5d0\uc11c \ucd9c\ubc1c\ud55c \ud2b8\ub798\ud53d\ub9cc \ud1b5\uacfc\ud560 \uc218 \uc788\ub3c4\ub85d \uc124\uc815\uc774 \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uba74 private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\ud574\uc11c \uc9c1\uc811 mysql download \uc694\uccad\uc744 \ud588\uc744 \ub54c\ub9cc \ud5c8\uc6a9\uc774 \ub429\ub2c8\ub2e4.\\n\\n\uc678\ubd80\uc5d0\uc11c \ubc14\ub85c private \uc778\uc2a4\ud134\uc2a4\ub85c \uc811\uadfc\ud560 \uc218\ub294 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\nNAT \uc778\uc2a4\ud134\uc2a4\ub9cc \uc124\uc815\uc744 \ud558\uba74 \ubc14\ub85c \uc5f0\uacb0\uc774 \ub418\ub098\uc694?\\n\\npublic ip\ub3c4 \uc790\ub3d9 \ud560\ub2f9\uc744 \ud574\uc918\uc57c \ud569\ub2c8\ub2e4\\n\\n### public ip \uac00 \ud544\uc694\ud55c \uc774\uc720\\n\\nNAT \uc778\uc2a4\ud134\uc2a4\ub97c \ud1b5\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0 \uc811\uc18d\ud560 \uc218 \uc788\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\ub294\ub370, \uc65c public ip \uac00 \ud544\uc694\ud560\uae4c\uc694?\\n\\n\uc678\ubd80 \uc778\ud130\ub137\uacfc \ud1b5\uc2e0\uc744 \ud560 \ub54c public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\nNAT \uc778\uc2a4\ud134\uc2a4 \ud639\uc740 NAT \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \uc778\ud130\ub137\uacfc \ud1b5\uc2e0\ud560 \ub54c, NAT \uc778\uc2a4\ud134\uc2a4\uc758 public ip + private ip\ub97c \ud1b5\ud574\uc11c \ud1b5\uc2e0\uc744 \ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ub0b4\ubd80 \uc778\uc2a4\ud134\uc2a4\uc758 public ip \ub97c \ud1b5\ud574\uc11c \ud1b5\uc2e0\uc744 \ud558\uac8c \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c NAT \uc778\uc2a4\ud134\uc2a4\uc640 \ub0b4\ubd80 \uc778\uc2a4\ud134\uc2a4 \ubaa8\ub450 public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c 1\ubc88 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc81c 2\ubc88\uc9f8 \ubb38\uc81c\ub97c \ud574\uacb0\ud574 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc774 \uc548 \ub418\ub294 \ubb38\uc81c\\n\\npublic \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc11c\ubc84\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc11c\ubc84\uc5d0 \uc811\uc18d\uc744 \ud558\ub824\uace0 \ud588\ub294\ub370, \uc811\uc18d\uc774 \uc548 \ub418\ub294 \ubb38\uc81c\uac00 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \ud574\uacb0 \ubc29\ubc95\\n\\n\ud574\uacb0 \ubc29\ubc95\uc5d0\ub294 2\uac00\uc9c0 \uacfc\uc815\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc5d0 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc744 \ucd94\uac00\ud574 \uc8fc\uae30\\n\\n\uae30\ubcf8\uc801\uc73c\ub85c public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc5d0\ub294 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc774 \ucd94\uac00\ub418\uc5b4\uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc5d0 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc744 \ucd94\uac00\ud574\uc8fc\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n\\n### private ip\ub97c \ud1b5\ud574\uc11c \uc811\uc18d\ud558\uae30\\n\\npublic \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\ud560 \ub54c, public ip \ub97c \ud1b5\ud574\uc11c \uc811\uc18d\uc744 \ud558\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\npublic ip\ub97c \ud1b5\ud574\uc11c \uc811\uc18d\ud558\ub294 \uacfc\uc815\uc744 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n1. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 public ip \ub97c \ud1b5\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \uc2dc\ub3c4\ud569\ub2c8\ub2e4.\\n2. \ub77c\uc6b0\ud305 \ud14c\uc774\ube14\uc5d0\uc11c public ip \uc77c \uacbd\uc6b0\uc5d0 \uc5b4\ub5bb\uac8c \ucc98\ub9ac\ud560\uc9c0\uc5d0 \ub300\ud55c \uc815\ubcf4\ub97c \ucc3e\uc2b5\ub2c8\ub2e4.\\n3. \ub77c\uc6b0\ud130\ub97c \ud1b5\ud574\uc11c \uc678\ubd80 \uc778\ud130\ub137\uc73c\ub85c \ub098\uac00\uac8c \ub429\ub2c8\ub2e4.\\n4. \ud2b8\ub798\ud53d\uc774 NAT \uc778\uc2a4\ud134\uc2a4\uc5d0 \ub3c4\ucc29\ud569\ub2c8\ub2e4.\\n5. NAT \uc778\uc2a4\ud134\uc2a4\ub294 \ub0b4\ubd80\uc5d0\uc11c \ucd9c\ubc1c\ud55c \ud2b8\ub798\ud53d\uc774 \uc544\ub2c8\uae30 \ub54c\ubb38\uc5d0, \ud2b8\ub798\ud53d\uc744 \uac70\ubd80\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc774 \uc77c\uc5b4\ub098\uae30\uc5d0, public ip \ub97c \ud1b5\ud574\uc11c \uc811\uc18d\uc744 \ud558\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\nprivate ip\ub97c \ud1b5\ud574\uc11c \uc811\uadfc\ud558\uba74 \uc5b4\ub5bb\uac8c \ub418\ub294\uc9c0 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4\\n\\n1. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private ip \ub97c \ud1b5\ud574\uc11c private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \uc2dc\ub3c4\ud569\ub2c8\ub2e4.\\n2. \ub77c\uc6b0\ud305 \ud14c\uc774\ube14\uc5d0\uc11c private ip \uc77c \uacbd\uc6b0\uc5d0 \uc5b4\ub5bb\uac8c \ucc98\ub9ac\ud560\uc9c0\uc5d0 \ub300\ud55c \uc815\ubcf4\ub97c \ucc3e\uc2b5\ub2c8\ub2e4.\\n3. \ub77c\uc6b0\ud130\ub97c \uac70\uccd0\uc11c private \uc11c\ube0c\ub137\uc758 \ub77c\uc6b0\ud130\ub85c \uc774\ub3d9\ud569\ub2c8\ub2e4.\\n4. private \uc11c\ube0c\ub137\uc758 \ub77c\uc6b0\ud130\ub294 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0\uac8c \ud2b8\ub798\ud53d\uc744 \uc804\ub2ec\ud569\ub2c8\ub2e4.\\n5. private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub294 \ud2b8\ub798\ud53d\uc744 \ubc1b\uc544\uc11c \ucc98\ub9ac\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c 2\ubc88 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uc694\uc57d\\n\\n1. private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 \uc778\ud130\ub137\uc5d0 \uc811\uc18d\uc744 \ud558\ub824\uba74 NAT \uc778\uc2a4\ud134\uc2a4 \ud639\uc740 NAT \uac8c\uc774\ud2b8\uc6e8\uc774\uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n2. private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub3c4 public ip \uac00 \ud544\uc694\ud569\ub2c8\ub2e4.\\n3. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \ud558\ub824\uba74 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc758 \ubcf4\uc548 \uadf8\ub8f9\uc744 \ucd94\uac00\ud574\uc8fc\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n4. public \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uac00 private \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uc18d\uc744 \ud560 \ub54c, private ip \ub97c \ud1b5\ud574\uc11c \uc811\uc18d\uc744 \ud574\uc57c \ud569\ub2c8\ub2e4."},{"id":"17","metadata":{"permalink":"/17","source":"@site/blog/2023-07-22-ci-cd.mdx","title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","description":"\uc548\ub155\ud558\uc138\uc694. \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.","date":"2023-07-22T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 22\uc77c","tags":[{"label":"CI","permalink":"/tags/ci"},{"label":"CD","permalink":"/tags/cd"}],"readingTime":7.735,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"17","title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","authors":["jay"],"tags":["CI","CD"]},"prevItem":{"title":"private \uc11c\ube0c\ub137\uc5d0 \uc778\uc2a4\ud134\uc2a4\ub97c \uc678\ubd80\uc640 \uc5f0\uacb0\ud560 \ub54c, public ip? private ip?","permalink":"/18"},"nextItem":{"title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","permalink":"/16"}},"content":"\uc548\ub155\ud558\uc138\uc694. \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\uc800\ud76c \ud300\uc5d0\uc11c CI/CD\ub294 \uc5b4\ub5bb\uac8c \uc9c4\ud589\ub418\ub294\uc9c0 \uc791\uc131\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## CI (\uc9c0\uc18d\uc801 \ud1b5\ud569)\\n![ci](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/ci.png?raw=true)\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\uc18d\uc801 \ud1b5\ud569 \uc989 CI\ub97c \uc9c4\ud589\ud558\uae30 \uc704\ud574\uc11c \uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 Github Actions\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\nmain, develop \ube0c\ub79c\uce58\uc5d0 Push, Pull Request \uc694\uccad\uc774 \ub4e4\uc5b4\uac04\ub2e4\uba74 \uc774\ubca4\ud2b8\uac00 \ubc1c\uc0dd\ud558\uace0, Github Actions\ub97c \ud1b5\ud574 \uc800\ud76c\uac00 \uc791\uc131\ud574\ub454 \uc2a4\ud06c\ub9bd\ud2b8\uac00 \uc2e4\ud589 \ub429\ub2c8\ub2e4.\\n\\n\uc774 \uc2a4\ud06c\ub9bd\ud2b8\uc5d0 \uc5ec\ub7ec\uac00\uc9c0\ub97c \ub4f1\ub85d\ud560 \uc21c \uc788\uc9c0\ub9cc, \uc800\ud76c\ub294 \uc790\ub3d9\uc73c\ub85c \ud14c\uc2a4\ud2b8\ub97c \uc9c4\ud589\ud558\ub3c4\ub85d \ud558\uc600\uc2b5\ub2c8\ub2e4.\\n\uc790\ub3d9\uc73c\ub85c \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub9ac\uba74\uc11c \ud14c\uc2a4\ud2b8\uac00 \ud1b5\uacfc\ub97c \ud574\uc57c\uc9c0\ub9cc Merge\ub97c \uc9c4\ud589\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \ud1b5\ud574 \uac1c\ubc1c\uc790\uc758 \uc2e4\uc218\ub97c \uc904\uc77c \uc218 \uc788\uace0 \uc548\uc815\uc801\uc73c\ub85c \uc9c0\uc18d\uc801 \ud1b5\ud569\uc744 \uc774\ub8f0 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n\\n
    \\n\\n## CD (\uc9c0\uc18d\uc801 \ubc30\ud3ec)\\n![cd](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/cd.png?raw=true)\\n\\n\uc800\ud76c\uc758 \uc9c0\uc18d\uc801 \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98\uc785\ub2c8\ub2e4.\\n\\n\uc21c\uc11c\ub97c \uc694\uc57d\ud558\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. Release \ube0c\ub79c\uce58\uc5d0 Push\ub97c \ud55c\ub2e4.\\n2. Github Actions\ub97c \ud1b5\ud574 Docker Hub\uc5d0 \ub808\ud3ec\uc9c0\ud1a0\ub9ac\uc758 \uc18c\uc2a4\ucf54\ub4dc\ub97c Docker Image\ub85c \ube4c\ub4dc\ud574\uc11c Push \ud55c\ub2e4.\\n3. \uc778\ud504\ub77c \uc11c\ubc84\uc5d0\uc11c Self Hosted Runner\uac00 \uc791\ub3d9\ud55c\ub2e4.\\n4. \uc778\ud504\ub77c \uc11c\ubc84\uc5d0\uc11c \ubc30\ud3ec \uc11c\ubc84\ub85c \ub4e4\uc5b4\uac04\ub2e4.\\n5. \ubc30\ud3ec \uc11c\ubc84 \uc548\uc5d0\uc11c Docker Hub\uc5d0 \ubbf8\ub9ac \uc5c5\ub85c\ub4dc\ud55c Docker Image\ub97c Pull \ud574\uc628\ub2e4.\\n6. \ubc30\ud3ec \uc11c\ubc84 \uc548\uc5d0\uc11c Docker Image\ub97c \ucee8\ud14c\uc774\ub108\uc5d0 \ub744\uc6b4\ub2e4.\\n\\n\\n
    \\n\\n### \ubc30\ud3ec \uc790\ub3d9\ud654 \ud234 \uc120\ud0dd\ud558\uae30\\n\uba3c\uc800 \ubc30\ud3ec \uc790\ub3d9\ud654 \uacfc\uc815\uc744 \uad6c\ucd95\ud558\uae30 \uc704\ud574\uc11c \uc5ec\ub7ec\uac00\uc9c0 \ud234\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nTravis, Jenkins, Github Actions \ub4f1\ub4f1 \uc5ec\ub7ec\uac00\uc9c0\uac00 \uc788\ub294\ub370\uc694.\\n\uc800\ud76c \ud300\uc740 `Github Actions`\ub97c \uc120\ud0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \uc120\ud0dd\ud55c \uc5ec\ub7ec\uac00\uc9c0 \uc774\uc720\uac00 \uc788\uc5c8\uc9c0\ub9cc\\n\uc800\ud76c \ud300 \ub204\ub204\ub97c \uc81c\uc678\ud558\uace0 CI/CD \uacbd\ud5d8\uc774 \ubd80\uc871\ud574\uc11c \ube44\uad50\uc801 \uc27d\uace0 \uc124\uce58 \ubc0f \ud070 \uc138\ud305\uc774 \uc5c6\ub294 \uc810\uc774 \uc800\ud76c\ud55c\ud14c\ub294 \ub9e4\ub825\uc801\uc73c\ub85c \ub2e4\uac00\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\ub610\ud55c Docker\ub97c \uc0ac\uc6a9\ud558\ub294\ub370, \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n1. JDK \ud639\uc740 Node \ubc84\uc804\uc744 \uad00\ub9ac\ud560 \uc218 \uc788\ub2e4.\\n2. Docker Image\ub97c \ube4c\ub4dc\ud55c \ud6c4 \ubc30\ud3ec\ud558\uae30 \ub54c\ubb38\uc5d0 \uc11c\ubc84 \ud658\uacbd \ucc28\uc774\ub85c \ubc1c\uc0dd\ud558\ub294 \ubb38\uc81c\ub97c \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\ub2e4.\\n3. \ubc30\ud3ec \uc11c\ubc84\uc5d0\uc11c Docker\ub9cc \uc124\uce58\ud558\uace0 Image\ub97c \ubc1b\uace0 \uc2e4\ud589\uc2dc\ud0a4\uba74 \ub3fc\uc11c \ube60\ub974\uace0 \uc27d\uac8c \ubc30\ud3ec \ud658\uacbd\uc744 \uad6c\ucd95\ud560 \uc218 \uc788\ub2e4.\\n\\n
    \\n\\n### \uacfc\uc815\\n\ubcf8\uaca9\uc801\uc73c\ub85c \uc800\ud76c\uc758 \ubc30\ud3ec \uc790\ub3d9\ud654\ub97c \uad6c\ucd95\ud558\ub294 \uacfc\uc815\uc744 \uc124\uba85\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n
    \\n\\n1. Github Actions\uc5d0 Runners \ub4f1\ub85d\\n\\n![runner](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/selfHosted.png?raw=true)\\n\uba3c\uc800 Self Hosted Runner\ub97c \uc774\uc6a9\ud558\uae30 \ub54c\ubb38\uc5d0 \uc800\ud76c\ub294 \uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 Runners\ub97c \ub4f1\ub85d\uc744 \ud574\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub97c \ub4f1\ub85d\uc744 \ud560 \ub54c \uc81c\uacf5\ud574\uc8fc\ub294 \uc124\uc815 \ucf54\ub4dc\uac00 \ub098\uc624\ub294\ub370\uc694.\\n\uc774 \ucf54\ub4dc\ub4e4\uc744 infra \uc11c\ubc84\uc5d0 \ubaa8\ub450 \uc785\ub825\uc744 \ud574\uc8fc\uba74\uc11c \uc124\uc815\uc744 \ud574\uc8fc\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n
    \\n\\n2. Github workflow \ub9cc\ub4e4\uae30\\n\ub2e4\uc74c\uc73c\ub85c\ub294 \uc800\ud76c\uac00 \uc218\ud589\ud558\uace0\uc790 \ud558\ub294 Task\ub97c \ub4f1\ub85d\ud574\uc8fc\uae30 \uc704\ud574\uc11c yml \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5b4\uc90d\ub2c8\ub2e4.\\n\\nyml \ud30c\uc77c\uc758 \uacbd\ub85c\ub294 `./github/workflows/` \uc548\uc5d0 \ub9cc\ub4e4\uc5b4\uc8fc\uba74 \ub429\ub2c8\ub2e4.\\n```yaml\\nname: deploy\\n\\n# release/backend push \ud560 \ub54c\\non:\\n push:\\n branches:\\n - release/backend\\n\\njobs:\\n # Docker\\n docker-build:\\n runs-on: ubuntu-latest\\n defaults:\\n run:\\n working-directory: ./backend\\n steps:\\n\\t\\t# Docker Hub \ub85c\uadf8\uc778\\n - name: Log in to Docker Hub\\n uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a\\n with:\\n username: ${{ secrets.DOCKERHUB_USERNAME }}\\n password: ${{ secrets.DOCKERHUB_PASSWORD }}\\n - uses: actions/checkout@v3\\n\\n - name: Set up JDK 17\\n uses: actions/setup-java@v3\\n with:\\n java-version: \'17\'\\n distribution: \'adopt\'\\n\\n - name: Gradle Caching\\n uses: actions/cache@v3\\n with:\\n path: |\\n ~/.gradle/caches\\n ~/.gradle/wrapper\\n key: ${{ runner.os }}-gradle-${{ hashFiles(\'**/*.gradle*\', \'**/gradle-wrapper.properties\') }}\\n restore-keys: |\\n ${{ runner.os }}-gradle-\\n\\n - name: Grant execute permission for gradlew\\n run: chmod +x gradlew\\n\\n - name: Build with Gradle\\n run: ./gradlew bootjar\\n\\n - name: Extract metadata (tags, labels) for Docker\\n id: meta\\n uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7\\n with:\\n images: Docker Hub \uc0ac\uc6a9\uc790\uba85/\uc774\ubbf8\uc9c0 \uc774\ub984\\n\\n\\t # Build \ubc0f Docker image\ub97c Docker Hub\uc5d0 push\\n - name: Build and push Docker image\\n uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671\\n with:\\n context: .\\n file: ./backend/Dockerfile\\n push: true\\n platforms: linux/arm64\\n tags: woowacarffeine/backend:latest\\n labels: ${{ steps.meta.outputs.labels }}\\n\\n deploy:\\n runs-on: self-hosted\\n if: ${{ needs.docker-build.result == \'success\' }}\\n needs: [ docker-build ]\\n steps:\\n\\t\\t# EC2 \ubc30\ud3ec \uc11c\ubc84\ub85c \uc811\uc18d\\n - name: Join EC2 dev server\\n uses: appleboy/ssh-action@master\\n env:\\n JASYPT_KEY: ${{ secrets.JASYPT_KEY }}\\n with:\\n host: ${{ secrets.SERVER_HOST }}\\n username: ${{ secrets.SERVER_USERNAME }}\\n key: ${{ secrets.SERVER_KEY }}\\n port: ${{ secrets.SERVER_PORT }}\\n envs: JASYPT_KEY\\n\\n # 1. \ub3c4\ucee4 \uc774\ubbf8\uc9c0 \ubc1b\uae30\\n # 2. \uae30\uc874\uc5d0 \ucf1c\uc9c4 \ubc31\uc5d4\ub4dc \uc11c\ubc84(\ub3c4\ucee4 \uc774\ubbf8\uc9c0) stop\\n # 3. \ucd5c\uc2e0 \ubc31\uc5d4\ub4dc \uc11c\ubc84 run\\n # 4. \uc0ac\uc6a9\ud558\uc9c0 \uc54a\ub294 \uc774\ubbf8\uc9c0\uc640 \ucee8\ud14c\uc774\ub108 \uc0ad\uc81c\\n script: |\\n sudo docker pull woowacarffeine/backend:latest\\n sudo docker stop backend || true\\n sudo docker run -d --rm -p 8080:8080 \\\\\\n -e \\"ENCRYPT_KEY=${{secrets.JASYPT_KEY}}\\" \\\\\\n --name backend \\\\\\n Docker Hub \uc0ac\uc6a9\uc790\uba85/\uc774\ubbf8\uc9c0 \uc774\ub984:latest\\n\\n sudo docker image prune -f\\n```\\n\\n\uc800\ud76c \ud300\uc740 \uc704\uc640 \uac19\uc774 backend-deploy.yml \ud30c\uc77c\uc744 \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc704\uc5d0 yml\uc5d0\uc11c \uc800\ud76c\ub294 \ud0a4\ub97c \uc228\uacbc\ub294\ub370\uc694.\\n\\n![img](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/selfHostedKeys.png?raw=true)\\n\uc704\uc5d0 \uc0ac\uc9c4\uacfc \uac19\uc774 \uc124\uc815\uc744 \ud574\uc8fc\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc774\ub97c yml\uc5d0\uc11c \uc0ac\uc6a9\ud558\uae30 \uc704\ud574\uc120 `secrets.Key\uc774\ub984`\uc73c\ub85c \uc0ac\uc6a9\ud574\uc8fc\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n
    \\n\\n\uc774\uc81c \ub9c8\uc9c0\ub9c9\uc73c\ub85c `Dockerfile`\uc744 \ub9cc\ub4e4\uc5b4\uc90d\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 `/backend/` \uacbd\ub85c\uc5d0 \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n```Dockerfile\\nFROM amazoncorretto:17-alpine-jdk\\nARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar\\nCOPY ${JAR_FILE} app.jar\\nENTRYPOINT [\\"java\\", \\"-Dspring.profiles.active=dev\\", \\"-jar\\",\\"/app.jar\\"]\\n```\\n\\n\uc800\ud76c\ub294 \uc704\ucc98\ub7fc \uc808\ub300 \uacbd\ub85c\ub97c \uae30\uc900\uc73c\ub85c JAR_FILE \uc704\uce58\ub97c \uc9c0\uc815\ud558\uace0, profiles\ub294 dev\ub85c \uc124\uc815\ud574\uc11c \ub9cc\ub4e4\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n
    \\n\\n3. \ubc30\ud3ec\ud558\uae30\\n\\n\ud2b8\ub9ac\uac70\ub97c \uc791\ub3d9\uc2dc\ucf1c\uc11c \uc800\ud76c\uac00 yml \ud30c\uc77c\uc5d0\uc11c \uc9c0\uc815\ud574\uc900 \uac83\ub4e4\uc774 \uc798 \uc791\ub3d9\ud558\ub294\uc9c0 \ud655\uc778\ud569\ub2c8\ub2e4.\\n\\n![jobSuccess](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/jobsSuccess.png?raw=true)\\n\uc704\uc5d0 \uc0ac\uc9c4\ucc98\ub7fc \ubaa8\ub4e0 Job\uc774 \uc131\uacf5\uc801\uc73c\ub85c \ud1b5\uacfc\ud558\ub294 \uac83\uc744 \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![dockerPs](https://github.com/car-ffeine/car-ffeine.github.io/blob/main/ci-cd/success.png?raw=true)\\n\uc774\ub807\uac8c \uc778\ud504\ub77c \uc11c\ubc84\uc5d0\uc11c \ubc30\ud3ec \uc11c\ubc84\ub85c \ub4e4\uc5b4\uac00\uc11c \uc131\uacf5\uc801\uc73c\ub85c \uc11c\ubc84\ub97c \ub3c4\ucee4\ub85c \ub744\uc6b4 \uac83\uc744 \ubcf4\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nEC2 \ubc30\ud3ec \uc11c\ubc84\uc5d0\uc11c `docker ps`\ub97c \uc785\ub825\ud588\uc744 \ub54c\uc5d0\ub3c4 \uc798 \uc2e4\ud589\uc774 \ub418\ub124\uc694!\\n\\n
    \\n\\n### CD \ubc30\ud3ec \uacfc\uc815 \uc694\uc57d\\n\uc9c0\uc18d\uc801 \ubc30\ud3ec \uacfc\uc815\uc744 \uc694\uc57d \ud558\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. Self Hosted Runner\ub97c EC2 \uc778\ud504\ub77c \uc11c\ubc84\uc5d0 \ub4f1\ub85d\ud574\uc900\ub2e4.\\n2. yml \ud30c\uc77c\uacfc Dockerfile\uc744 \ub9cc\ub4e4\uc5b4\uc900\ub2e4.\\n3. \ud2b8\ub9ac\uac70\ub97c \uc791\ub3d9\uc2dc\ucf1c\uc11c Github Actions\uc758 \ud0dc\uc2a4\ud06c\uac00 \ubaa8\ub450 \uc798 \ub418\ub294\uc9c0 \ud655\uc778\ud55c\ub2e4.\\n4. \uc798 \ub410\ub2e4\uba74 EC2 \ubc30\ud3ec \uc11c\ubc84\uc5d0 Docker image\uac00 \uc131\uacf5\uc801\uc73c\ub85c \ub744\uc6cc\uc9c4\ub2e4."},{"id":"16","metadata":{"permalink":"/16","source":"@site/blog/2023-07-15-jpa-create-select-query-when-id-is-not-null-.mdx","title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130 \uc785\ub2c8\ub2e4.","date":"2023-07-15T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 15\uc77c","tags":[{"label":"jpa","permalink":"/tags/jpa"}],"readingTime":9.97,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"16","title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","authors":["boxster"],"tags":["jpa"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc758 CI/CD","permalink":"/17"},"nextItem":{"title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","permalink":"/15"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130 \uc785\ub2c8\ub2e4.\\n\\n\uba3c\uc800 \uc774\ubc88\uc5d0 \uae00\uc744 \uc4f0\uac8c\ub41c \uacc4\uae30\ub97c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4. \uc800\ud76c \ud300\uc740 \uacf5\uacf5 \ub370\uc774\ud130 API\uc5d0\uc11c \ubc1b\uc544\uc628 \ucda9\uc804\uc18c\uc640, \ucda9\uc804\uae30\ub4e4\uc758 ID\ub97c \uadf8\ub300\ub85c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 \ub2e4\ub978 API, \uc81c\uac00 \uc81c\uc5b4\ud560 \uc218 \uc5c6\ub294 \uacf3\uc5d0 \uc758\uc874\ud558\ub294 \uac83\uc740 \uc88b\uc9c0 \uc54a\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc624\ub294 \uacfc\uc815\uc5d0\uc11c \ub9c8\uc8fc\ud55c \uc131\ub2a5\uc801\uc778 \ubb38\uc81c \ub54c\ubb38\uc5d0 \uadf8\ub300\ub85c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4. \uc804\uad6d\uc758 \ucda9\uc804\uc18c\ub294 6\ub9cc\uac1c, \ucda9\uc804\uc18c \uc548\uc5d0 \uc874\uc7ac\ud558\ub294 \ucda9\uc804\uae30\ub294 23\ub9cc\uae30\uc785\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uacf5\uacf5 \ub370\uc774\ud130\ub294 \ucda9\uc804\uc18c\uc640, \ucda9\uc804\uae30\uc758 \uc815\ubcf4\ub97c \ub530\ub85c \uc81c\uacf5\ud558\ub294 \uac83\uc774 \uc544\ub2cc \uc911\ubcf5\ub41c \ucda9\uc804\uc18c\ub97c \ud3ec\ud568\ud55c \ub370\uc774\ud130\ub97c \ucda9\uc804\uae30 \uac1c\uc218\ub9cc\ud07c\uc778 23\ub9cc\uac1c\uc758 row\ub85c \uc81c\uacf5\ud569\ub2c8\ub2e4.\\n\\n\\n\ub530\ub77c\uc11c \uc800\ud76c\uac00 ID\ub97c \ub530\ub85c \ubd80\uc5ec\ud558\uac8c \ub41c\ub2e4\uba74, \ucda9\uc804\uc18c\ub97c \uc800\uc7a5\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \ubc1b\uc544\uc624\ub294 ID\ub85c \ucda9\uc804\uae30\ub97c \uc5f0\uacb0\ud574\uc918\uc57c\ud558\ub294\ub370 \uadf8\ub807\uac8c \ub41c\ub2e4\uba74 \uc140 \uc218 \uc5c6\uc774 \ub9ce\uc740 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n\uc7a0\uae50 \uc0dd\uac01\ud574\ubcf8\ub2e4\uba74\\n1. \ucda9\uc804\uc18c\ub97c \uac01\uac01 \uc800\uc7a5\ud558\uace0 ID\ub97c \ubd80\uc5ec\ubc1b\ub294 \ucffc\ub9ac `6\ub9cc\ubc88` (ID\ub97c \uc54c\uc544\uc640\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0 batch\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.)\\n2. \ucda9\uc804\uc18c\uc5d0\uc11c \ubc1b\uc544\uc628 ID\ub97c \ucda9\uc804\uae30\uc5d0 \ub9e4\ud551\ud558\uace0 \uc800\uc7a5\ud558\ub294 \ucffc\ub9ac `\ucd5c\uc18c 1\ubc88` (\ub9cc\uc57d batch\ub85c 23\ub9cc\uac74\uc744 \ud55c\ubc88\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub294 \uac00\uc815)\\n\\n\ud558\uc9c0\ub9cc ID\ub97c \uadf8\ub300\ub85c \uc0ac\uc6a9\ud558\uac8c \ub41c\ub2e4\uba74,\\n1. \ucda9\uc804\uc18c\ub97c \uc800\uc7a5\ud558\ub294 \ucffc\ub9ac `\ucd5c\uc18c 1\ubc88` (\ub9cc\uc57d batch\ub85c 6\ub9cc\uac74\uc744 \ud55c\ubc88\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub294 \uac00\uc815)\\n2. \ucda9\uc804\uae30\ub97c \uc800\uc7a5\ud558\ub294 \ucffc\ub9ac `\ucd5c\uc18c 1\ubc88` (\ub9cc\uc57d batch\ub85c 23\ub9cc\uac74\uc744 \ud55c\ubc88\uc5d0 \uc800\uc7a5\ud55c\ub2e4\ub294 \uac00\uc815)\\n\\n23\ub9cc\uac74\uc774 \ub118\ub294 \uc815\ubcf4\ub97c \ud655\uc778\ud588\uc744 \ub54c, ID\ub294 \uc911\ubcf5\ub418\uc9c0 \uc54a\uc558\uace0, \uc911\ubcf5\ud558\uc9c0 \uc54a\uc744 \uac83\uc774\ub77c \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \uadf8 \ubfd0\ub9cc \uc544\ub2c8\ub77c \ucc98\uc74c \ud55c\ubc88\ub9cc \uc800\uc7a5\ud558\ub294 \uac83\uc774 \uc544\ub2cc \uc8fc\uae30\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8\ub41c \uc815\ubcf4\ub97c\\n\ubc18\uc601\ud574\uc8fc\uace0 `update` or `save` \ud574\uc8fc\uc5b4\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0, ID\ub97c \uadf8\ub300\ub85c \uac00\uc9c0\uace0 \uc788\ub294 \uac83\uc774 \ud6e8\uc52c \ud6a8\uc728\uc801\uc774\ub77c \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc871\uc774 \uae38\uc5c8\uc2b5\ub2c8\ub2e4. \uac01\uc124\ud558\uace0 \uc774\ub7f0 \ubc29\uc2dd\uc73c\ub85c ID\ub97c \uc9c1\uc811 \ub123\uc5b4\uc8fc\ub294 \uacbd\uc6b0 \ubc1c\uc0dd\ud558\ub294 \ubb38\uc81c\uc5d0 \ub300\ud574 \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## ID\ub97c \uc9c1\uc811 \ub123\uc5b4\uc900 Entity\ub97c \uc800\uc7a5\ud560 \ub54c\\n\\n\uba3c\uc800 \uac04\ub2e8\ud55c \uc608\uc81c Entity\ub85c \uc124\uba85\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Entity\\npublic class ChargeStation {\\n\\n @Id\\n private String stationId;\\n\\n private String stationName;\\n\\n ...\\n}\\n\\n```\\n\ubcf4\ud1b5\uc758 Entity\uc640 \ub2e4\ub978 \ubd80\ubd84\uc740 Id\ub97c \uc9c1\uc811 \ud560\ub2f9\ud558\uae30 \ub54c\ubb38\uc5d0 `@GeneratedValue(strategy = GenerationType.IDENTITY)` \uc774\ub7ec\ud55c ID \uc0dd\uc131 \uc804\ub7b5\uc5d0 \ub300\ud55c \uc815\ubcf4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 `save()` \ucf54\ub4dc\ub97c \ud638\ucd9c\ud558\uba74 \uc5b4\ub5a4 \ucffc\ub9ac\uac00 \ub098\uac00\ub294\uc9c0 \ud655\uc778\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. \uc544\ub798\uc640 \uac19\uc774 \uc544\uc8fc \uac04\ub2e8\ud55c \uc120\ub989\uc5ed \ucda9\uc804\uc18c\ub97c \uc800\uc7a5\ud558\ub294 \ud14c\uc2a4\ud2b8\ub97c \uc2e4\ud589\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n```java\\n@DataJpaTest\\nclass ChargeStationRepositoryTest {\\n\\n @Autowired\\n private ChargeStationRepository chargeStationRepository;\\n\\n @Test\\n void \ucda9\uc804\uc18c\ub97c_\uc800\uc7a5\ud55c\ub2e4() {\\n ChargeStation station = ChargeStationFixture.\uc120\ub989\uc5ed_\ucda9\uc804\uc18c_\ucda9\uc804\uae30_2\uac1c_\uc0ac\uc6a9\uac00\ub2a5_1\uac1c;\\n\\n chargeStationRepository.save(station);\\n\\n ChargeStation expect = chargeStationRepository.findByStationId(station.getStationId()).get();\\n assertThat(expect).isEqualTo(station);\\n }\\n}\\n```\\n\\n\uba3c\uc800 \ucf54\ub4dc\ub9cc \ubcf4\uba74 \uba3c\uc800 `chargeStationRepository.save()` \ud638\ucd9c\uacfc \ud568\uaed8 insert \ucffc\ub9ac 1\ubc88, \uadf8\ub9ac\uace0 `chargeStationRepository.findByStationId()`\uc5d0\uc11c select \ucffc\ub9ac 1\ubc88\\n\ucd1d 2\ubc88 \ubc1c\uc0dd\ud560 \uac83\uc774\ub77c\uace0 \uc720\ucd94\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![query-three-times](https://github.com/car-ffeine/design-system/assets/106640954/f48b7f0f-3f39-41ce-8fcd-94b995e95fae)\\n\\n\ud558\uc9c0\ub9cc \uc608\uc0c1\uacfc \ub2e4\ub974\uac8c \uc704\uc758 \uc0ac\uc9c4\uacfc \uac19\uc774 \ucffc\ub9ac\uac00 \ucd1d 3\ubc88 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uccab\ubc88\uc9f8\ub294 \ud638\ucd9c\ud558\uc9c0 \uc54a\uc740 station id\ub85c station\uc744 \uc870\ud68c\ud558\ub294 \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc720\ub97c \ucc3e\uae30 \uc704\ud574 `save()` \uba54\uc11c\ub4dc\ub97c \ub514\ubc84\uae45 \ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n### save \uc2dc SELECT \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud558\ub294 \uc774\uc720\\n\\n\\n![save-method](https://github.com/car-ffeine/design-system/assets/106640954/b1db00b7-d7fb-4647-912c-6f8e2fe44974)\\n\ub85c\uc9c1\uc740 \uac04\ub2e8\ud574\ubcf4\uc785\ub2c8\ub2e4. `isNew()` \ub97c \ud1b5\ud574 \uc0c8\ub85c\uc6b4 Entity\uc778\uc9c0 \ud655\uc778\ud55c \ud6c4, \uc0c8\ub85c\uc6b4 Entity\ub77c\uba74 `persist()`, \uc544\ub2c8\ub77c\uba74 `merge()`\ub97c \ud638\ucd9c\ud569\ub2c8\ub2e4.\\n\\n\uc5ec\uae30\uc11c `EntityManager#persist()` \uba54\uc11c\ub4dc\ub97c \uac04\ub2e8\ud788 \ub9d0\uc500\ub4dc\ub9ac\uba74, \uc0c8\ub85c\uc6b4 Entity\ub97c \uc601\uc18d\ud654\ud558\ub294 \uba54\uc11c\ub4dc\ub85c \ud2b8\ub79c\uc7ad\uc158\uc774 \ucee4\ubc0b\ub420 \ub54c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc800\uc7a5\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 `EntityManager#merge()` \uba54\uc11c\ub4dc\ub294 \uc900\uc601\uc18d \uc0c1\ud0dc\uc758 Entity\ub97c \uc601\uc18d \uc0c1\ud0dc\ub85c \ubcc0\uacbd\ud558\ub294\ub370 \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\ud558\uc9c0\ub9cc \uc774\ub54c \uc601\uc18d\uc131 \ucee8\ud14d\uc2a4\ud2b8\uc5d0 \uc874\uc7ac\ud558\uc9c0 \uc54a\ub294 \uac1d\uccb4\ub77c\uba74 \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0\uc11c \uc870\ud68c \ud6c4 \uc601\uc18d\ud654\ud558\ub294 \uc791\uc5c5\uc744 \uc218\ud589\ud569\ub2c8\ub2e4.\\n\\n`merge()`\ub97c \ud638\ucd9c\ud558\uae30 \ub54c\ubb38\uc5d0 SELECT \ucffc\ub9ac\uac00 \ubc1c\uc0dd\ud558\uace0, \uc601\uc18d\ud654\ud558\ub294 \uc791\uc5c5\uc744 \uc218\ud589\ud558\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc81c\uac00 \uc800\uc7a5\ud55c \uac1d\uccb4\ub294 \ud655\uc2e4\ud788 \uc0c8\ub85c\uc6b4 Entity\uac00 \ub9de\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc `entityInformation.isNew()` \uba54\uc11c\ub4dc\ub294 false\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c \uc5b4\ub5a4 \uac83\uc744 \uae30\uc900\uc73c\ub85c \uc0c8\ub85c\uc6b4 Entity\uc778 \uac83\uc744 \uad6c\ubd84\ud558\ub294\uc9c0 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n### \uc0c8\ub85c\uc6b4 Entity\ub97c \uad6c\ubd84\ud558\ub294 \uae30\uc900\\n\\n\uc77c\ub2e8, \ub514\ubc84\uae45\uc744 \ud1b5\ud574 isNew \uba54\uc11c\ub4dc\ub97c \ud655\uc778\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n![is-new](https://github.com/car-ffeine/design-system/assets/106640954/e4a56694-c623-46d8-badd-3345d557e29f)\\n\\n\uac04\ub2e8\ud569\ub2c8\ub2e4. \uba3c\uc800 Entity\uc5d0 ID\ub97c \uac00\uc838\uc635\ub2c8\ub2e4. \uadf8\ub9ac\uace0 id\uac00 `primitive` \ud0c0\uc785\uc778\uc9c0 \ud655\uc778 \ud6c4, \uc544\ub2d0\uacbd\uc6b0 id\uac00 null \uc774\uba74 \uc0c8\ub85c\uc6b4 Entity, \uc544\ub2d0\uacbd\uc6b0 false\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub54c, `primitve` \ud0c0\uc785\uc774\ub77c\uba74, id\uac00 \uc22b\uc790\uc778\uc9c0 \ud655\uc778 \ud6c4 id\uac00 0\uc774\uba74 \uc0c8\ub85c\uc6b4 Entity, \uc544\ub2d0\uacbd\uc6b0 false\ub97c \ubc18\ud658\ud569\ub2c8\ub2e4.\\n\\n## ID\ub97c \uc9c1\uc811 \ub123\uc5b4\uc8fc\ub294 \uac1d\uccb4\ub294 JPA \uc0ac\uc6a9\uc744 \ud3ec\uae30\ud574\uc57c\ud560\uae4c?\\n\\n\uacb0\ub860\ubd80\ud130 \ub9d0\uc500\ub4dc\ub9ac\uba74 \uc544\ub2d9\ub2c8\ub2e4. \ub2e4\ub978 \ubc29\ubc95\uc73c\ub85c \uc0c8\ub85c\uc6b4 Entity \uc784\uc744 \uc99d\uba85\ud560 \uc218 \uc788\ub2e4\uba74 `merge()`\uac00 \uc544\ub2cc `persist()`\ub97c \ud638\ucd9c\ud558\ub3c4\ub85d \ub9cc\ub4e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \uadf8\ub7fc \uc5b4\ub5bb\uac8c?\\n\uba3c\uc800 save() \uba54\uc11c\ub4dc\uc758 \ud544\ub4dc \uc911 `JpaEntityInformation`\uc774\ub77c\ub294 \ud544\ub4dc\ub97c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![entity-info](https://github.com/car-ffeine/design-system/assets/106640954/d9956fe6-07c7-41a9-9d6b-7c01b5f31c5d)\\n\\n\uc774 \uc778\ud130\ud398\uc774\uc2a4\ub294 Entity\uc758 \ucd94\uac00 \uc815\ubcf4\ub97c \uc54c\uae30 \uc704\ud574 \ud544\ub4dc\uc5d0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud574\ub2f9 \uc778\ud130\ud398\uc774\uc2a4\uc758 \uad6c\ud604\uccb4\ub294 `JpaEntityInformationSupport`, `JpaMetamodelEntityInformation`, `JpaPersistableEntityInformation` \uc774\ub807\uac8c 3\uac1c\uc758 \ud074\ub798\uc2a4\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \ub2e4\ub978 \ubc29\ubc95\uc73c\ub85c\ub3c4 `isNew()`\uac00 \uad6c\ud604\ub418\uc5b4 \uc788\uc744\uac70\ub77c \ucd94\uce21\uc744 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub514\ubc84\uae45\uc744 \ud1b5\ud574 \uc54c\uc544\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\uae4c \uc704\uc758 \uc0ac\uc9c4\uc73c\ub85c \ubcf4\uace0 \uc2e4\uc81c\ub85c \uc2e4\ud589\ub410\ub358 `isNew()` \uba54\uc11c\ub4dc\uc758 \uc8fc\uc778\uc740 `JpaMetamodelEntityInformation` \ud074\ub798\uc2a4\uc600\uc2b5\ub2c8\ub2e4. \uadf8\ub798\uc11c \ud574\ub2f9 \ud074\ub798\uc2a4\ub294 \uc81c\uc678\ud558\uace0 \ub2e4\ub978 \ud074\ub798\uc2a4\ub97c \ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uba3c\uc800 `JpaPersistableEntityInformation` \ud074\ub798\uc2a4\uc785\ub2c8\ub2e4.\\n![is-new-persistable](https://github.com/car-ffeine/design-system/assets/106640954/dc2293c3-2854-4619-9ef6-d08e55b4581b)\\n\uc544\uc8fc \uac04\ub2e8\ud558\uac8c entity\uc758 `isNew()`\ub97c \ud638\ucd9c\ud55c\ub2e4\uace0 \uc801\ud600\uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc `Persistable` \uc778\ud130\ud398\uc774\uc2a4\ub97c \uad6c\ud604\ud55c Entity\uc758 `isNew()` \ub97c \ud638\ucd9c\ud558\ub294 \uac83 \uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \ub0a8\uc740 \ud558\ub098\uc758 \ud074\ub798\uc2a4\ub97c \ud655\uc778\ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![info-support](https://github.com/car-ffeine/design-system/assets/106640954/f1d654c0-e741-4db7-8e7f-4e758c36133a)\\n\\n\uc704 \uc0ac\uc9c4\ucc98\ub7fc \uc774 \ud074\ub798\uc2a4\uac00 Entity \ub9c8\ub2e4 `Persistable` \uad6c\ud604 \uc720\ubb34\uc5d0 \ub530\ub77c \ub3d9\uc801\uc73c\ub85c \uad6c\ud604\uccb4\ub97c \ubcc0\uacbd\ud574\uc8fc\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \ub2f5\uc774 \ub098\uc628 \uac83 \uac19\uc2b5\ub2c8\ub2e4. ID\ub97c \uc9c1\uc811 \ud560\ub2f9\ud558\ub294 Entity\uc5d0 `Persistable`\uc744 \uad6c\ud604\ud574\uc8fc\uba74 \ub429\ub2c8\ub2e4.\\n\\n### Persistable \uad6c\ud604\ud558\uae30\\n```java\\n@Entity\\npublic class ChargeStation implements Pesistable{\\n\\n @Id\\n private String stationId;\\n\\n private String stationName;\\n\\n @CreatedDate\\n private LocalDateTime createdTime;\\n\\n ...\\n\\n @Override\\n public Object getId() {\\n return getStationId();\\n }\\n\\n @Override\\n public boolean isNew() {\\n return createdTime == null;\\n }\\n}\\n```\\n\\n\uac04\ub2e8\ud788 \ub9cc\ub4e4\uc5b4\ubd24\uc2b5\ub2c8\ub2e4. `@CreatedDate`\ub294 Entity\uac00 \ucc98\uc74c \uc601\uc18d\ud654\ub420 \ub54c \ub3d9\uc791\ud558\uae30 \ub54c\ubb38\uc5d0 \uc774 Entity\uc758 CreateTime \ud544\ub4dc\uac00 null \uc774\uba74 \uc0c8\ub85c\uc6b4 Entity\ub77c\uace0 \ud655\uc2e0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uadf8\ub7fc \uc774\ub807\uac8c \uc778\ud130\ud398\uc774\uc2a4\ub97c \uad6c\ud604\ud558\uace0 \uc544\uae4c \uc2e4\ud589\ud588\ub358 \ud14c\uc2a4\ud2b8\ub97c \ub2e4\uc2dc \uc2e4\ud589\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![solved](https://github.com/car-ffeine/design-system/assets/106640954/ea5db719-9919-42f4-b431-00e14d6fea5e)\\n\\n\uae54\ub054\ud558\uac8c \uad6c\ud604\ub41c \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uc6d0\ud558\ub358\ub300\ub85c \ucffc\ub9ac\uac00 2\ubc88 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\uc774\ub7f0 `Persistable`\uc744 `@MappedSuperClass`\ub97c \ud1b5\ud574 \ub354 \uae54\ub054\ud558\uac8c \uad6c\ud604\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \ub530\ub85c \uc124\uba85\ub4dc\ub9ac\uc9c0\ub294 \uc54a\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n#### \uacb0\ub860\\nJPA\ub294 \ub9ce\uc740 \ud3b8\uc758 \uae30\ub2a5\uc744 \uc81c\uacf5\ud574\uc8fc\ub294 \uac83 \uac19\uc544\ubcf4\uc785\ub2c8\ub2e4. \ucac4\uc9c0\ub9d9\uc2dc\ub2e4."},{"id":"15","metadata":{"permalink":"/15","source":"@site/blog/2023-07-14-data-update-process-with-boxter.mdx","title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","description":"\uc548\ub155\ud558\uc138\uc694~","date":"2023-07-14T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 14\uc77c","tags":[{"label":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","permalink":"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4"},{"label":"\uc11c\ubc84","permalink":"/tags/\uc11c\ubc84"}],"readingTime":9.215,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"},{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"15","title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","authors":["jay","boxster"],"tags":["\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","\uc11c\ubc84"]},"prevItem":{"title":"JPA\uc5d0\uc11c ID\uac00 \uc788\ub294 Entity\uc5d0 \ub300\ud574 save \uc2dc\uc5d0 select \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uc774\uc720","permalink":"/16"},"nextItem":{"title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","permalink":"/14"}},"content":"\uc548\ub155\ud558\uc138\uc694~\\n\uc6b0\ud14c\ucf54 \uce74\ud398\uc778 \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \uce74\ud398\uc778 \ud300\uc758 \ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c \'\ubc15\uc2a4\ud130\'\uc640 \ud568\uaed8 \uc5b4\ub5a4 \ubb38\uc81c\ub97c \uacaa\uace0 \ud574\uacb0\ud588\ub294\uc9c0 \uc801\uc5b4\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n* \ubc30\uc6b0\ub294 \ub2e8\uacc4\uc774\ub2e4 \ubcf4\ub2c8 \ud2c0\ub9b0 \ubd80\ubd84\uc774 \uc788\uc744 \uc218 \uc788\ub294\ub370, \ud53c\ub4dc\ubc31 \ubd80\ud0c1\ub4dc\ub9bd\ub2c8\ub2e4 :)\\n\\n\uba3c\uc800 \uae00\uc744 \uc4f0\uae30 \uc804\uc5d0 \ubb38\uc81c \uc0c1\ud669\uc5d0 \ub300\ud574 \uac04\ub2e8\ud558\uac8c \ub9d0\uc500\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \ubb38\uc81c \uc0c1\ud669\\n\\n\uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc804\uae30\ucc28 \ucda9\uc804\uc18c \uacf5\uacf5 API\ub97c \ud65c\uc6a9\ud558\uc5ec \ucda9\uc804\uc18c\uc758 \ud63c\uc7a1\ub3c4 \uc81c\uacf5 \ubc0f \uc5ec\ub7ec \uc11c\ube44\uc2a4\ub97c \uc81c\uacf5\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uc11c\ube44\uc2a4\ub97c \uc0ac\uc6a9\uc790\ub4e4\uc5d0\uac8c \uc81c\uacf5\ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \uc791\uc5c5\ub4e4\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.\\n\\n1. \uccab \uc2e4\ud589\uc2dc \uacf5\uacf5 API \ub370\uc774\ud130\ub97c \ubaa8\ub450 \ubd88\ub7ec\uc11c \ub370\uc774\ud130\ubca0\uc774\uc2a4\uc5d0 \uc0bd\uc785\ud569\ub2c8\ub2e4.\\n2. \ud63c\uc7a1\ub3c4\ub97c \uc81c\uacf5\ud558\uae30 \uc704\ud574\uc11c \uc8fc\uae30\uc801\uc778 \uc2dc\uac04 (\uc544\uc9c1 \uc815\ud558\uc9c4 \uc54a\uc558\uc9c0\ub9cc ex.12\uc2dc\uac04) \ub2e8\uc704\ub85c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\uc758 \uc0c1\ud0dc\ub97c \uc5c5\ub370\uc774\ud2b8 \ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc2dc \ub370\uc774\ud130\ub97c \uc694\uccad\uc744 \ud569\ub2c8\ub2e4.\\n3. \uc0c8\ub86d\uac8c \ucd94\uac00\ub41c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 \ubaa8\ub450 Insert\ud574\uc8fc\uace0, \uae30\uc874\uc5d0 \uc788\ub358 \ucda9\uc804\uc18c \ud639\uc740 \ucda9\uc804\uae30\uac00 \uc5c5\ub370\uc774\ud2b8 \ub410\ub2e4\uba74 \ubcc0\uacbd\ub41c \ub370\uc774\ud130\ub85c \uc5c5\ub370\uc774\ud2b8 \ud574\uc90d\ub2c8\ub2e4.\\n\\n\\n\uc800\ub791 \ubc15\uc2a4\ud130\ub294 2~3\ubc88 \uacfc\uc815\uc744 \uc9c4\ud589\ud558\ub294 \uc5ed\ud560\uc744 \ub9e1\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\ud14c\uc774\ube14\uc758 \uad00\uacc4\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```\\ncharge_station <---1------N---\x3e charger\\n charger <---1------1---\x3e charger_status\\n```\\n\\n\uc800\ud76c\ub294 \uc774 \ubb38\uc81c\ub97c \uc5b4\ub5bb\uac8c \ud574\uacb0 \ud588\ub294\uc9c0 \ubcf4\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\\n## \ubb38\uc81c \ud574\uacb0 \uacfc\uc815\\n\\n\uc804\uc81c\uc870\uac74\\n\\n- \uccab \uc2e4\ud589 \ubaa8\ub4e0 \ud14c\uc774\ube14\uc740 \ucd08\uae30\ud654 \uc0c1\ud0dc\uc774\ub2e4.\\n- \ub370\uc774\ud130\ub294 9999\uac74\uc744 \uae30\uc900\uc73c\ub85c \ud55c\ub2e4.\\n- \uba54\uc11c\ub4dc \uccab \uc2dc\ud589\uc5d0\uc11c\ub294 \ubaa8\ub4e0 \ub370\uc774\ud130\uac00 \uc0c8\ub86d\uac8c insert \ub418\uace0\\n- \uadf8 \ub2e4\uc74c \uba54\uc11c\ub4dc \uc2dc\ud589\uc5d0\uc11c\ub294 \uc77c\ubd80 \ub370\uc774\ud130\ub294 \ucd94\uac00\ub418\uace0, \uc77c\ubd80\ub294 \uc5c5\ub370\uc774\ud2b8 \ub41c\ub2e4.\\n\\n\\n## Ver1. findAll() \uc870\ud68c \ud6c4 \uac01\uac01 save() \ud574\uc8fc\uae30 (\uc57d14\ucd08)\\n\\n\uc800\ud76c\uac00 \ucc98\uc74c\uc5d0 \uc0dd\uac01\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\uc54c\uc544\uc11c \ubc14\ub010 \uac83\ub4e4\uc740 \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uace0, \uc0c8\ub85c\uc6b4 \uac74 \uc800\uc7a5\ud574\uc8fc\uae30 \ub54c\ubb38\uc5d0 \uac04\ub2e8\ud55c \ubc29\ubc95\uc73c\ub85c \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc2e4\uc81c\ub85c \ud574\ubcf8 \uacb0\uacfc, \uc0bd\uc785\uc758 \uacbd\uc6b0\ub294 SELECT \ucffc\ub9ac\ubb38 \uc2e4\ud589 \ud6c4 INSERT \ucffc\ub9ac\ubb38\uc744 \ubc1c\uc0dd \uc2dc\ucf30\uace0,\\n\uc5c5\ub370\uc774\ud2b8 \uc2dc\uc5d0\ub3c4 SELECT \ud6c4 UPDATE \ud639\uc740 INSERT\ub97c \ubc1c\uc0dd \uc2dc\ucf30\uc2b5\ub2c8\ub2e4. (\ubcc0\uacbd \uc0ac\ud56d \uc5c6\uc73c\uba74 SELECT\ub9cc)\\n\\n\uc774\ub294 \uc2dd\ubcc4\uc790\uc5d0 \ub530\ub978 JPA \uc791\ub3d9 \ubc29\uc2dd \ub54c\ubb38\uc778\ub370\uc694.\\n\uc774 \ubc29\ubc95\uc758 \uacb0\uacfc\ub294 \uc57d 14\ucd08\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\ub294 \uc774\ub807\uac8c \ubd88\ud544\uc694\ud55c SELECT \uc791\uc5c5\uc744 \ub9c9\uc544\ubcf4\uace0\uc790 \ub2e4\ub978 \ubc29\ubc95\uc744 \uad6c\uc0c1\ud574\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uae30\ubcf8\uc801\uc73c\ub85c Jdbc\ub97c \uc774\uc6a9\ud574\uc11c Batch Insert\uc640 Batch Update\ub97c \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\uace0, \uc774 \uc791\uc5c5\uc744 \uc704\ud574\uc11c \ubcc0\uacbd \ud639\uc740 \uc0bd\uc785\ub420 \ub370\uc774\ud130\ub4e4\uc744 \uc9c1\uc811 \ucc3e\ub294 \uacfc\uc815\uc774 \uc911\uc694\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\\n## Ver2. \ubcc0\uacbd \uac10\uc9c0\ub97c \uc9c1\uc811 \ud574\uc8fc\uace0, \uc790\ub8cc\uad6c\uc870\ub85c \ubc30\uce58 \ub370\uc774\ud130 \ubaa8\uc73c\uae30 : O(n^2) (\uc57d 11\ucd08)\\n\\n\ub450 \ubc88\uc9f8\ub85c \uc800\ud76c\uac00 \uc0dd\uac01\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\uba3c\uc800 \ub370\uc774\ud130 \ucd94\uac00 \ubc0f \ubcc0\uacbd \uac10\uc9c0 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\\n\uae30\uc874 \uc5c5\ub370\uc774\ud2b8 \uc2dc\uc5d0 SELECT\uc640 UPDATE(or INSERT) \ub450\ubc88\uc758 \ucffc\ub9ac\uac00 \ub098\uac00\ub294 \uac83\uc774 \ub9d8\uc5d0 \ub4e4\uc9c0 \uc54a\uc544\uc11c\\n\ubcc0\uacbd \uac10\uc9c0\ub97c \uc9c1\uc811 \ud574\uc8fc\ub824\uace0 \uba54\uc11c\ub4dc\ub97c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc800\ud76c\uac00 \uc0dd\uac01\ud55c \ubcc0\uacbd \uac10\uc9c0\ub294 \uc0dd\uac01\ubcf4\ub2e4 \uac04\ub2e8\ud55c\ub370\uc694.\\n\ub3c4\uba54\uc778\uc5d0 \uba54\uc11c\ub4dc\ub97c \ub9cc\ub4e4\uc5b4\uc11c \ud544\ub4dc\ub97c if\ubb38\uc73c\ub85c \ud558\ub098\uc529 \ube44\uad50\ud574\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\ucda9\uc804\uc18c\uc758 \ub370\uc774\ud130 \ud2b9\uc9d5\uc0c1 \ub370\uc774\ud130\uac00 \uc790\uc8fc \ubc14\ub00c\ub294 \ub370\uc774\ud130\ub294 \ube44\uad50\uc801 \ucd08\ubc18\uc5d0 \ube44\uad50\ud558\ub3c4\ub85d \uad6c\ud604\ud558\uace0, \uc790\uc8fc \ubc14\ub00c\uc9c0 \uc54a\ub294 \ub370\uc774\ud130\ub294 \ud6c4\uc5d0 \ube44\uad50\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \ub370\uc774\ud130 \uc800\uc7a5 \ubc0f \uc5c5\ub370\uc774\ud2b8 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\uba3c\uc800 findAll()\ub85c \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30 \ub4f1 \uad00\ub828\ub41c \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c Map\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4.\\nMap\uc758 \uad6c\uc870\ub85c \uae30\uc874\uc5d0 \ud14c\uc774\ube14\uc5d0 \uc800\uc7a5\ub41c \ubaa8\ub4e0 \ub370\uc774\ud130\ub97c \uc790\ub8cc\uad6c\uc870\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uacf5\uacf5 API\ub97c \ubd88\ub7ec\uc640\uc11c, \ub611\uac19\uc774 Map\uc758 \uad6c\uc870\ub85c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n(Station \uc548\uc5d0\ub294 `List`\uac00 \uc874\uc7ac)\\n\\n\uc800\ud76c\ub294 \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uc18c\uc5d0 \ud574\ub2f9\ud558\ub294 \ubaa8\ub4e0 \ucda9\uc804\uae30\ub4e4\uc744 \ube44\uad50\ud558\uba74\uc11c \ubcc0\uacbd \uac10\uc9c0\ub97c \ud574\uc918\uc57c\ud558\uae30 \ub54c\ubb38\uc5d0\\n\uac01\uac01\uc758 Map.values()\uc778 `List : \uae30\uc874 \ucda9\uc804\uc18c`\uc640 `List : \uc5c5\ub370\uc774\ud2b8\ub41c \ucda9\uc804\uc18c`\ub97c \ube44\uad50\ud574\uc92c\uc2b5\ub2c8\ub2e4.\\n\\n\ube44\uad50\ub97c \ud558\uba74\uc11c \uc0c8\ub85c \uc0bd\uc785\ub41c \ucda9\uc804\uc18c\uc640 \uc5c5\ub370\uc774\ud2b8 \ub41c \ucda9\uc804\uc18c\ub97c \uac01\uac01 \ucc98\ub9ac\ud574\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ucda9\uc804\uc18c\uc758 \ubcc0\uacbd \uac10\uc9c0\ub97c \uc704\ud574\uc11c \ucda9\uc804\uae30\ub4e4\uc740 \ucda9\uc804\uc18c \uc548\uc5d0 List\ub85c \uc18d\ud574\uc788\uae30 \ub54c\ubb38\uc5d0 O(n^2)\uc758 \uc2dc\uac04 \ubcf5\uc7a1\ub3c4\ub97c \uac00\uc9c0\uace0 \uc804\uccb4 \ub370\uc774\ud130\ub4e4\uc740 \uc57d 23\ub9cc \uac74\uc774\ubbc0\ub85c, \uc804\uccb4 \ub370\uc774\ud130\ub97c \ub300\uc0c1\uc73c\ub85c \ud55c\ub2e4\uba74 \uc57d 530\uc5b5\ubc88\uc758 \uc5f0\uc0b0\uc774 \uc774\ub904\uc84c\uaca0\ub124\uc694.\\n\\nVer1\uc5d0 \ube44\ud574\uc11c\ub294 \ud06c\uc9c0\ub294 \uc54a\uc9c0\ub9cc \ud3c9\uade0\uc801\uc73c\ub85c \uc57d 2\ucd08 \uc815\ub3c4 \uc904\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n\\n## Ver3. \ubcc0\uacbd \uac10\uc9c0\ub97c \uc9c1\uc811 \ud574\uc8fc\uace0, \uc790\ub8cc\uad6c\uc870\ub85c \ubc30\uce58 \ub370\uc774\ud130 \ubaa8\uc73c\uae30 : O(1) (\uc57d 10\ucd08)\\nVer2\uc640 \uac70\uc758 \uc720\uc0ac\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n\ucc28\uc774\uc810\uc740 Map \uc790\ub8cc \uad6c\uc870 \uc0ac\uc6a9\ubc29\ubc95\uc744 \ubcc0\uacbd\ud588\uc2b5\ub2c8\ub2e4.\\n\uae30\uc874 2\uc911 for\ubb38\uc5d0\uc11c, 1\uc911 for\ubb38\uc744 \ub3cc\uba74\uc11c \ud0a4 \uac12\uc744 \ud1b5\ud574\uc11c \uc2e0\uaddc \ub370\uc774\ud130\uc640 \uc5c5\ub370\uc774\ud2b8 \ub420 \ub370\uc774\ud130\ub4e4\uc744 \ubd84\ub958\ud558\uace0, \uc774\ub4e4\uc744 \uac01\uac01 List\uc5d0 \ub123\uc5b4\uc8fc\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uc774\ub97c \ud1b5\ud574\uc11c Ver2\uc5d0 \ube44\ud574\uc11c 1\ucd08\uc815\ub3c4 \uc904\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n\\n## Ver4. \uc774\uc804 \ubc29\uc2dd + Fetch Join \uc0ac\uc6a9\ud558\uae30 (\uc57d 6\ucd08)\\n\ub9c8\uc9c0\ub9c9 \ubc29\ubc95\uc740 \uc870\ud68c \uacfc\uc815\uc758 \uc2dc\uac04 \ub2e8\ucd95\uc785\ub2c8\ub2e4.\\n\\n\ucc98\uc74c\uc5d0 Stations\ub97c findAll()\ud558\ub294 \ucffc\ub9ac\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 N+1 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\uadf8 \uc774\uc720\ub294 Station\uc5d0\uc11c Chargers\ub97c \uc9c0\uc5f0\ub85c\ub529\uc73c\ub85c \uc124\uc815 \ud588\ub294\ub370, \uc774\ub97c \uadf8\ub300\ub85c get \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 \uc870\ud68c\ud574\uc11c \ud574\ub2f9 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\n\\n```java\\nList findAll(); // \uae30\uc874\\n\\n@Query(\\"SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers\\"); // Fetch Join \uc801\uc6a9\\nList findAll();\\n```\\n\\n\ub530\ub77c\uc11c \uc704\uc5d0 \ucf54\ub4dc\uc640 \uac19\uc774 Fetch Join\uc744 \uc774\uc6a9\ud574\uc11c \ucc98\uc74c\uc5d0 \ub370\uc774\ud130\ub97c \uac00\uc838\uc654\uc2b5\ub2c8\ub2e4.\\n\uc774\ub807\uac8c \ud6a8\uc728\uc801\uc778 \uc870\ud68c\ub85c \ubcc0\uacbd\ud558\uba74\uc11c \uc2dc\uac04\uc744 \ub9ce\uc774 \uc904\uc77c \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\\n### \uc9c0\uae08\uae4c\uc9c0\uc758 \ubc29\ubc95\uc744 \uc815\ub9ac\ub97c \ud558\uc790\uba74\\nVer1 \uacfc \uac19\uc740 \ubc29\uc2dd\uc5d0\uc11c\ub294 \uc5c5\ub370\uc774\ud2b8 \uacfc\uc815\uc5d0\uc11c JPA\uc758 \uc2dd\ubcc4\uc790\uc5d0 \ub530\ub978 \ucc98\ub9ac \ubc29\uc2dd\uc73c\ub85c \uc778\ud574 [SELECT + UPDATE] or [SELECT + INSERT] \uc640 \uac19\uc774 \ucffc\ub9ac\uac00 \ub450 \ubc88\uc529 \ub098\uac14\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c Ver3\uae4c\uc9c0 \uac1c\uc120\uc744 \ud558\uae30 \uc704\ud574\uc11c \uc800\uc7a5\uacfc \uc5c5\ub370\uc774\ud2b8\ub97c \ud55c \ubc88\uc5d0 JDBC\ub97c \uc774\uc6a9\ud574\uc11c Batch\ub85c \ucc98\ub9ac\ud574\uc8fc\ub294 \ubc29\uc2dd\uc744 \uc120\ud0dd\ud588\uace0,\\n\\n\ubcc0\uacbd \uac10\uc9c0 + \ubc30\uce58 \ub370\uc774\ud130\ub97c \ubaa8\uc73c\uae30 \uc704\ud574\uc11c \uc790\ub8cc\uad6c\uc870\ub97c \uc774\uc6a9\ud574\uc11c \uc2dc\uac04\uc744 \uc870\uae08\uc529 \ub2e8\ucd95 \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ub9c8\uc9c0\ub9c9\uc73c\ub85c Ver4\uc5d0\uc11c\ub294 findAll()\uc5d0\uc11c \ubc1c\uc0dd\ud558\ub294 N+1\uc758 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uba74\uc11c \uc2dc\uac04\uc744 \ub2e8\ucd95\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c \ub3d9\uc77c \uc791\uc5c5\uc744 14\ucd08\uc5d0\uc11c 6\ucd08 \uc815\ub3c4\ub85c \uc904\uc77c \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4!"},{"id":"14","metadata":{"permalink":"/14","source":"@site/blog/2023-07-14-server-architecture.mdx","title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","description":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4","date":"2023-07-14T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 14\uc77c","tags":[{"label":"ec2","permalink":"/tags/ec-2"},{"label":"aws","permalink":"/tags/aws"},{"label":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","permalink":"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4"},{"label":"\ubc30\ud3ec","permalink":"/tags/\ubc30\ud3ec"},{"label":"\uc11c\ubc84","permalink":"/tags/\uc11c\ubc84"},{"label":"\uc544\ud0a4\ud14d\ucc98","permalink":"/tags/\uc544\ud0a4\ud14d\ucc98"}],"readingTime":10.495,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"14","title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","authors":["nunu"],"tags":["ec2","aws","\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","\ubc30\ud3ec","\uc11c\ubc84","\uc544\ud0a4\ud14d\ucc98"]},"prevItem":{"title":"\uc8fc\uae30\uc801\uc778 \ub370\uc774\ud130 \uc694\uccad\uc73c\ub85c \ubc1b\uc740 \ub370\uc774\ud130\ub97c \ud6a8\uc728\uc801\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8 \ubc0f \uc0bd\uc785\ud558\uae30 (with. \ubc15\uc2a4\ud130)","permalink":"/15"},"nextItem":{"title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","permalink":"/13"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4\\n\\n\uc774\ubc88\uc5d0 \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98\ub97c \uacb0\uc815\ud558\uac8c \ub418\uc5c8\ub358 \uacfc\uc815\uc5d0 \ub300\ud574\uc11c \uc815\ub9ac\ub97c \ud574\ubcf4\uace0 \uc2f6\uc5b4\uc11c \uae00\uc744 \uc4f0\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ud0a4\ud14d\ucc98\uc640 \uc11c\ubc84\uac00 \ubc30\ud3ec\ub418\ub294 \uacfc\uc815\uc744 \ubcf4\uc5ec\ub4dc\ub9ac\uba74\uc11c \uc2dc\uc791\ud558\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n![\ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98](https://blog.kakaocdn.net/dn/dKVRTG/btsnFE7Nb82/GRONsIJPqd8WFVzjzqsgqk/img.png)\\n\\n\uc11c\ubc84\uac00 \ubc30\ud3ec\ub418\ub294 \uacfc\uc815\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![server image](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdto7By%2FbtsnD31hYHy%2F7rWKwxulxXzfhRigE60Sd0%2Fimg.png)\\n\\n## \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4 \uc778\uc2a4\ud134\uc2a4\uc5d0 \ub300\ud55c \uc18c\uac1c\\n\\n\uc6b0\ud14c\ucf54\uc5d0\uc11c \uc120\ud0dd\ud560 \uc218 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub294 \ucd1d 2\uac00\uc9c0 \uc885\ub958\uc785\ub2c8\ub2e4.\\n\\n1. \ud37c\ube14\ub9ad \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\\n - \ucea0\ud37c\uc2a4\uc5d0\uc11c\ub9cc SSH \uc811\uadfc\uc774 \uac00\ub2a5\ud55c \uc778\uc2a4\ud134\uc2a4\uc785\ub2c8\ub2e4.\\n - \ubbf8\ub9ac \uc5f4\ub824\uc788\ub294 \ud3ec\ud2b8\ub4e4\ub9cc \ud5c8\uc6a9\uc774 \ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n - \uac19\uc740 \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub07c\ub9ac\ub294 \ubaa8\ub4e0 \ud3ec\ud2b8\uac00 \ud5c8\uc6a9\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4\\n2. \ud504\ub77c\uc774\ube57 \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\\n - \ud37c\ube14\ub9ad \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub97c \ud1b5\ud574\uc11c\ub9cc \uc811\uadfc\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n - \uac19\uc740 \uc11c\ube0c\ub137\uc5d0 \uc788\ub294 \uc778\uc2a4\ud134\uc2a4\ub07c\ub9ac\ub294 \ubaa8\ub4e0 \ud3ec\ud2b8\uac00 \ud5c8\uc6a9\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\ubc88 \uc778\uc2a4\ud134\uc2a4\ub97c 2\uac1c \uc0ac\uc6a9 \uac00\ub2a5\ud558\uace0, 2\ubc88 \uc778\uc2a4\ud134\uc2a4\ub97c 1\uac1c \uc0ac\uc6a9 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n\uad8c\uc7a5\ub418\ub294 \ud658\uacbd\uc5d0\uc11c 1\uac1c\ub294 db \uc11c\ubc84\ub85c \uc0ac\uc6a9\ud558\uace0, \ub098\uba38\uc9c0 2\uac1c\ub294 \uc790\uc720\ub86d\uac8c \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n## \uadf8\uc804\uc5d0 \uc54c\uba74 \uc88b\uc544\uc694\\n\\n\uc5ec\uae30\uc11c\ub294 Self Hosted Runner\ub97c \uc0ac\uc6a9\ud588\ub294\ub370\uc694.\\n\\nSelf Hosted Runner\uc5d0 \ub300\ud55c \ub0b4\uc6a9\uc740 [\uc5ec\uae30](https://be-student.tistory.com/75#%EC%99%9C%20Self%20Hosted%20Runner%EC%95%BC%3F-1) \uc5d0 \uc798 \ub098\uc640\uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc678\ubd80 IP\ub85c\ubd80\ud130 SSH \uc811\uadfc\uc774 \ubd88\uac00\ub2a5\ud558\uae30\uc5d0, Self Hosted Runner \ub098, Jenkins \uac19\uc740 \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5c8\ub294\ub370, \ub7ec\ub2dd \ucee4\ube0c\ub97c \uace0\ub824\ud574\uc11c Self Hosted Runner\ub97c \uc120\ud0dd\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98\uc5d0 \ub300\ud55c \uace0\ubbfc\\n\\n\uc800\ud76c \ud300\uc774 \uc774\ubc88 \uc544\ud0a4\ud14d\ucc98\ub97c \ub9cc\ub4e4\uae30 \uc704\ud574\uc11c \uace0\ubbfc\ud588\ub358 \uc810\ub4e4\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc5b4\ub5bb\uac8c \ud558\uba74 \uc7a5\uc560\uc758 \uc601\ud5a5\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n2. \uc6b4\uc601 \uc11c\ubc84\ub97c \ub098\uc911\uc5d0 \ucd94\uac00\ud558\uac8c \ub418\uc5c8\uc744 \ub54c, \uc5b4\ub5bb\uac8c \uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ub418\ub294 \ubd80\ubd84\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n3. 2\ucc28 \ub370\ubaa8\ub370\uc774\uae4c\uc9c0\uc758 \uacfc\uc81c\uc778 \uac1c\ubc1c \uc11c\ubc84\ub97c \uc5b4\ub5bb\uac8c \uad6c\uc131\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n\uc5ec\uae30\uc11c 1\ubc88\uc744 \uac00\uc7a5 \uba3c\uc800 \uc0dd\uac01\ud55c \uc544\ud0a4\ud14d\ucc98\ub97c \uad6c\uc131\ud558\uac8c \ub418\uc5c8\ub294\ub370, \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc120\ud0dd\uc758 \uae30\uc900\uc774 \ub418\uc5c8\ub358 \uac83\uc740 \ucd1d 3\uac00\uc9c0\uc600\uc2b5\ub2c8\ub2e4.\\n\\n1. DB\ub294 \ud504\ub77c\uc774\ube57 \uc11c\ube0c\ub137\uc5d0 \uc704\uce58\uc2dc\ud0a4\uace0, \uc6b0\ub9ac \uc778\uc2a4\ud134\uc2a4\ub97c \uac70\uccd0\uc11c\ub9cc \uc811\uadfc\uc774 \uac00\ub2a5\ud558\uac8c \ud55c\ub2e4.\\n - \uc774 \ubd80\ubd84\uc740 \ubcf4\uc548\uc744 \uc704\ud574\uc11c \uc5b4\uca54 \uc218 \uc5c6\uc774 \uc120\ud0dd\ud558\uac8c \ub41c \ubd80\ubd84\uc785\ub2c8\ub2e4.\uc774 \ubd80\ubd84\uc744 \uace0\ub824\ud558\ub2e4 \ubcf4\ub2c8, \ucd5c\uc18c\ud55c\uc73c\ub85c \uad6c\uc131\ud560 \uc218 \uc788\ub294 \uad6c\uc870\uac00 db \uc6a9 private \uc778\uc2a4\ud134\uc2a4 1\uac1c, \uadf8\ub9ac\uace0 \uc6b0\ub9ac\uac00 \uc0ac\uc6a9\ud560 public \uc778\uc2a4\ud134\uc2a4 1\uac1c\uac00 \ub429\ub2c8\ub2e4\\n2. \uc6b4\uc601 \uc11c\ubc84\ub97c \ub098\uc911\uc5d0 \ucd94\uac00\ud558\uac8c \ub418\uc5c8\uc744 \ub54c, \uc5b4\ub5bb\uac8c \uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ub418\ub294 \ubd80\ubd84\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n - \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0 CD \ud234\uc774\ub098, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud558\uac8c \ub418\uba74, \uc6b4\uc601 \uc11c\ubc84\uc5d0\ub3c4 \ub3d9\uc77c\ud558\uac8c \uc791\uc5c5\uc744 \ud574\uc57c \ud569\ub2c8\ub2e4.\\n - \uc774 \ubd80\ubd84\uc744 \ucd5c\uc18c\ud654\ud558\uae30 \uc704\ud574\uc11c, \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc640, CD \ud234, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub97c \ubd84\ub9ac\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n3. \uc5b4\ub5bb\uac8c \ud558\uba74 \uc7a5\uc560\uc758 \uc601\ud5a5\uc744 \ucd5c\uc18c\ud654\ud560 \uc218 \uc788\uc744\uae4c?\\n - \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc640, CD \ud234, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub97c \ubd84\ub9ac\ud558\uac8c \uc54a\ub294\ub2e4\uba74 \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c, CD \ud234\uacfc \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc5d0\ub3c4 \uc601\ud5a5\uc744 \ubbf8\uce58\uac8c \ub429\ub2c8\ub2e4. \uc774 \ubd80\ubd84\uc744 \uc0dd\uac01\ud588\uc744 \ub54c\ub3c4, \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc640, CD \ud234, \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub97c \ubd84\ub9ac\ud574\uc57c \ud55c\ub2e4\uace0 \uacb0\uc815\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4\\n - \ud55c \ubd80\ubd84\uc758 \uc7a5\uc560\uac00 \ub2e4\ub978 \ud234\uae4c\uc9c0 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uac8c \ub9cc\ub4e4\uac8c \ub418\uc5b4\uc11c, \ub864\ubc31\uc774\ub098, \uc0c1\ud669 \ud30c\uc545\uc744 \ud558\uae30 \ud798\ub4e4\uac8c \ub9cc\ub4e4\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\ub4e4\uc744 \uc0dd\uac01\ud588\uc744 \ub54c, \uc778\uc2a4\ud134\uc2a4 1\uac1c\ub97c \uac1c\ubc1c \uc11c\ubc84\uc6a9\uc73c\ub85c, \uc778\uc2a4\ud134\uc2a4 1\uac1c\ub97c CD \ud234\uacfc \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uc124\uce58\ud55c \uc778\uc2a4\ud134\uc2a4\ub85c \uc0ac\uc6a9\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n## \uc2e4\uc81c \ub0b4\ubd80 \uad6c\uc131\uc740 \uc5b4\ub5bb\uac8c \ub420\uae4c\uc694?\\n\\n### \uac1c\ubc1c \uc11c\ubc84\\n\\n\uc774 \uc778\uc2a4\ud134\uc2a4\uc5d0\ub294 \ucd1d 2\uac00\uc9c0 \uae30\ub2a5\uc774 \ub4e4\uc5b4\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. \ud504\ub860\ud2b8 \uc11c\ubc84\\n - react\ub85c \ub418\uc5b4\uc788\ub294 \ud504\ub860\ud2b8\uc5d4\ub4dc \ucf54\ub4dc\ub97c \uc0ac\uc6a9\uc790\uc5d0\uac8c \uc804\ub2ec\ud574 \uc8fc\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n2. \ubc31\uc5d4\ub4dc \uc11c\ubc84\\n - spring\uc73c\ub85c \ub418\uc5b4\uc788\ub294 api \uc11c\ubc84\uc785\ub2c8\ub2e4.\\n\\n\ubb3c\ub860, \uc774\ub807\uac8c \ud558\uba74 \ub450 \uacf3 \uc911 \ud55c \uacf3\uc5d0 \uc7a5\uc560\uac00 \ubc1c\uc0dd\ud588\uc744 \ub54c, \ud504\ub860\ud2b8 \uc11c\ubc84\uc640 \ubc31\uc5d4\ub4dc \uc11c\ubc84\uac00 \ubaa8\ub450 \uc601\ud5a5\uc744 \ubc1b\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uac19\uc774 \uad00\ub9ac\ud558\uac8c \ub41c \uccab \ubc88\uc9f8 \uc774\uc720\ub85c \ube44\uc6a9\uc774 \ub4e4\uae30 \ub54c\ubb38\uc5d0 \ube44\uc6a9\uc758 \ubb38\uc81c\ub97c \uace0\ub824\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uac1c\ubc1c \uc11c\ubc84\uc5d0\uc11c \ud504\ub860\ud2b8 \uc11c\ubc84\uc640 \ubc31\uc5d4\ub4dc \uc11c\ubc84\ub97c \uad00\ub9ac\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub450 \ubc88\uc9f8 \uc774\uc720\ub85c\ub294, \uc544\uc9c1 \ud504\ub85c\uc81d\ud2b8 \ucd08\ucc3d\uae30 \uc774\uae30 \ub54c\ubb38\uc5d0, \ubc31\uc5d4\ub4dc\uc5d0\uc11c \uc7a5\uc560\uac00 \ub0ac\uc744 \ub54c, \ud504\ub860\ud2b8\uc5d0\uc11c \uc77c\uc815 \uc774\uc0c1\uc758 \uc5d0\ub7ec \ucc98\ub9ac\uac00 \ubd88\uac00\ub2a5\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud504\ub85c\uc81d\ud2b8\uac00 \ub9ce\uc774 \uc9c4\ud589\ub418\uc5c8\ub2e4\uba74, \ud504\ub860\ud2b8\uc5d4\ub4dc\ub9cc\uc73c\ub85c \ud639\uc740 \uc7a5\uc560\uac00 \ub098\uc9c0 \uc54a\uc740 \uc11c\ubc84\ub97c \ud65c\uc6a9\ud574 \uc5d0\ub7ec \ucc98\ub9ac\ub97c \ud560 \uc218 \uc788\uc9c0\ub9cc, \uc544\uc9c1\uc740 \uadf8\ub7f0 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc640\ub294 \ubcc4\uac1c\ub85c \uc2e4\ud589 \uc2dc \ud3b8\uc758\ub97c \uc704\ud574\uc11c \ub3c4\ucee4\ub97c \uc0ac\uc6a9\ud574 \uac1c\ubc1c \uc11c\ubc84\ub97c \uad00\ub9ac\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### CD \ud234\uacfc \ubaa8\ub2c8\ud130\ub9c1 \ud234\\n\\n\uc774 \uc778\uc2a4\ud134\uc2a4\uc5d0\ub294 \ucd1d 3\uac00\uc9c0 \uae30\ub2a5\uc774 \ub4e4\uc5b4\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1. CD \ud234\\n - \uc704\uc5d0\uc11c \uc124\uba85\ub4dc\ub9b0 \uac83\ucc98\ub7fc, self hosted runner \uac00 \ub3d9\uc791\ud558\uac8c \ub418\uc5b4\uc788\uc2b5\ub2c8\ub2e4\\n2. \ubcf4\uc548\uc744 \uc704\ud55c \ub9ac\ubc84\uc2a4 \ud504\ub85d\uc2dc\\n - \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \uc0ac\uc6a9\ud558\uac8c \ub418\ub294\ub370, \uc774\ub54c API \ud0a4\ub97c \uc0ac\uc6a9\ud558\uac8c \ub429\ub2c8\ub2e4. \uc774\ub807\uac8c \ud558\uba74, API \ud0a4\ub97c \ub178\ucd9c\uc2dc\ud0a4\uc9c0 \uc54a\uace0, \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n - \uc774 API \ud0a4\ub97c \ub178\ucd9c\uc2dc\ud0a4\uc9c0 \uc54a\uae30 \uc704\ud574\uc11c, \ub9ac\ubc84\uc2a4 \ud504\ub85d\uc2dc\ub97c \ud558\ub098 \ub450\uace0, \uc5ec\uae30\uc11c API \ud0a4\ub97c \ucd94\uac00\ud574 \uc694\uccad\uc744 \ubcf4\ub0b4\ub294 \ubc29\uc2dd\uc73c\ub85c \uad6c\uc131\ud558\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n3. \ubaa8\ub2c8\ud130\ub9c1 \ud234\\n - \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \uc544\uc9c1 \ub3c4\uc785\ud558\uc9c0 \uc54a\uc558\uc9c0\ub9cc, \ud604\uc7ac \uc774\uc288\ub85c\ub294 \uc62c\ub77c\uac00 \uc788\ub294 \uc0c1\ud0dc\uc785\ub2c8\ub2e4.\\n - Actuator, \ud504\ub85c\uba54\ud14c\uc6b0\uc2a4, \uadf8\ub77c\ud30c\ub098 \uc774 3\uac00\uc9c0\ub97c \ud65c\uc6a9\ud574\uc11c \ubaa8\ub2c8\ud130\ub9c1 \ud234\uc744 \uad6c\uc131\ud558\uac8c \ub420 \uc608\uc815\uc785\ub2c8\ub2e4\\n\\n\uc704 \uae30\ub2a5\ub4e4\uc774 \ud55c \uc778\uc2a4\ud134\uc2a4\uc5d0 \ubaa8\uc5ec\uc788\uae30\uc5d0, \uc704\uc758 \uae30\ub2a5\ub4e4\uc740 \ucd94\ud6c4\uc5d0 \uc6b4\uc601 \uc11c\ubc84\uac00 \ucd94\uac00\ub418\uc5c8\uc744 \ub54c, \uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ud558\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.\\n\\n## \ubc30\ud3ec \uacfc\uc815 \ub354 \uc790\uc138\ud788 \uc54c\uc544\ubcf4\uae30\\n\\n\uc544\ub798\uc5d0 \uc0ac\uc9c4\uc5d0\uc11c \ubcf4\uc774\ub294 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c \ubc30\ud3ec\ub97c \uc9c4\ud589\ud558\uace0 \uc788\ub294\ub370\uc694\\n\\n![server image](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdto7By%2FbtsnD31hYHy%2F7rWKwxulxXzfhRigE60Sd0%2Fimg.png)\\n\\n1. \uc0ac\uc6a9\uc790\uac00 push\ub97c \ud558\uba74, github actions\uc5d0\uc11c \ub3c4\ucee4 \ube4c\ub4dc\ub97c \uc9c4\ud589\ud558\uace0, \ub3c4\ucee4 \ud5c8\ube0c\uc5d0 \uc774\ubbf8\uc9c0\ub97c \uc62c\ub9bd\ub2c8\ub2e4.\\n2. \ub3c4\ucee4 \ud5c8\ube0c\uc5d0 \uc774\ubbf8\uc9c0\uac00 \uc62c\ub77c\uac04 \uc774\ud6c4\uc5d0, self hosted runner \uac00 \uc791\ub3d9\uc744 \uc2dc\uc791\ud569\ub2c8\ub2e4.\\n3. \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc811\uadfc\ud574\uc11c, \uc774\ubbf8\uc9c0\ub97c \ubc1b\uace0, \ucee8\ud14c\uc774\ub108\ub97c \uc2e4\ud589\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c, \uac1c\ubc1c\uc6a9 \uc778\uc2a4\ud134\uc2a4\uc5d0 \ubc30\ud3ec\ub97c \uc9c4\ud589\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \ub290\ub080 \uc810\\n\\n\uc88b\uc740 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uacc4\ud558\uae30 \uc704\ud574\uc11c\ub294 \uace0\ub824\ud574\uc57c \ud560 \uc810\ub4e4\uc774 \uc815\ub9d0 \ub9ce\ub2e4\ub294 \uac83\uc744 \ub2e4\uc2dc \ud55c\ubc88 \ub290\uaf08\uc2b5\ub2c8\ub2e4.\\n\\n\uc6b4\uc601 \uc11c\ubc84\uac00 \ucd94\uac00\ub41c\ub2e4\ub358\uac00, \uc778\uc2a4\ud134\uc2a4\uac00 \ub298\uc5b4\ub098\uace0, \uc904\uc5b4\ub4dc\ub294 \uc0c1\ud669\uc5d0 \uc720\uc5f0\ud558\uac8c \ub300\ucc98\ud560 \uc218 \uc788\ub3c4\ub85d \uc124\uacc4\ub97c \ud574\uc57c \ud55c\ub2e4\ub294 \uac83\uc744 \ub2e4\uc2dc \ud55c\ubc88 \ub290\uaf08\uc2b5\ub2c8\ub2e4.\\n\\n\uc911\ubcf5\uc73c\ub85c \uad00\ub9ac\ub420 \ud3ec\uc778\ud2b8\ub97c \uc904\uc5ec\uc57c \ud55c\ub2e4\ub294 \uac83\ub3c4 \ub2e4\uc2dc \ud55c\ubc88 \ub290\ub084 \uc218 \uc788\uc5c8\uace0\uc694\\n\\n\uae34 \uae00\uc744 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4"},{"id":"13","metadata":{"permalink":"/13","source":"@site/blog/2023-07-14-trouble-shooting-with-info-window.mdx","title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","description":"Untitled","date":"2023-07-14T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 14\uc77c","tags":[{"label":"react","permalink":"/tags/react"},{"label":"google maps api","permalink":"/tags/google-maps-api"},{"label":"useSyncExternalStore","permalink":"/tags/use-sync-external-store"}],"readingTime":17.7,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"13","title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","authors":["scent"],"tags":["react","google maps api","useSyncExternalStore"]},"prevItem":{"title":"\uce74\ud398\uc778\ud300 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98\ub97c \uc124\uba85\ud574\ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4","permalink":"/14"},"nextItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","permalink":"/11"}},"content":"![Untitled](https://file.notion.so/f/s/16a32751-2088-4261-8bf6-3d556c0bf2e8/Untitled.png?id=020fb0e2-81d8-4dca-bb76-cf4536ca7b29&table=block&spaceId=e725e94b-8029-47f5-aecb-8eb1ef7c939f&expirationTimestamp=1689364800000&signature=3KH3gvfzTgKmmFsrNBluQ3evQ6jwe2C-tj8LqB6gQyw&downloadName=Untitled.png)\\n\\n\uc704 \uc774\ubbf8\uc9c0\ub294 \ud604\uc7ac\uae4c\uc9c0 \uad6c\ud604\ud55c \uc9c0\ub3c4\uc758 \ubaa8\uc2b5\uc774\ub2e4. \uad6c\ud604\ub41c \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc11c\ubc84\uc5d0 \uc694\uccad\ud574 \ubc1b\uc544\uc628 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \ubc14\ud0d5\uc73c\ub85c \ud654\uba74\uc5d0 \ub9c8\ucee4\ub97c \ud45c\uc2dc\ud558\ub294 \uae30\ub2a5\\n- \ud654\uba74\uc774 \uc774\ub3d9\ud558\uac70\ub098 \uc90c\uc778, \uc90c \uc544\uc6c3\uc744 \ud560 \uc2dc \ud654\uba74\uc758 \ub9c8\ucee4 \uc815\ubcf4\uac00 \ucd5c\uc2e0\ud654 \ub418\ub294 \uae30\ub2a5\\n- \ub9c8\ucee4 \uc815\ubcf4\ub97c \ucd5c\uc2e0\ud654 \ud560 \ub54c \ud654\uba74\uc5d0\uc11c \uc0ac\ub77c\uc9c4 \ub9c8\ucee4\ub97c dom\uc5d0\uc11c \uc81c\uac70\ud558\ub294 \uae30\ub2a5\\n- \ub9c8\ucee4 \uc815\ubcf4\ub97c \ucd5c\uc2e0\ud654 \ud560 \ub54c \uc774\uc804 \ud654\uba74\uc5d0\uc11c\ub3c4 \uc788\uc5c8\ub358 \ub9c8\ucee4\ub97c \uc7ac\uc0dd\uc131 \ud558\uc9c0 \uc54a\ub294 \uae30\ub2a5\\n- \ub9c8\ucee4\ub97c \ud074\ub9ad\ud588\uc744 \uc2dc \ud574\ub2f9 \ub9c8\ucee4\uc5d0 \ub300\ud55c \uac04\ub2e8 \uc815\ubcf4\ub97c \ubaa8\ub2ec\ub85c \ub744\uc6cc\uc8fc\ub294 \uae30\ub2a5\\n- \ud654\uba74\uc5d0 \ud45c\uc2dc\ub41c \ub9c8\ucee4\ub4e4\uc5d0 \ub300\ud55c \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \ub9ac\uc2a4\ud2b8\ub85c \ubcf4\uc5ec\uc8fc\ub294 \uae30\ub2a5\\n\\n\uc774\ubc88\uc5d0 \uc0c8\ub85c \ucd94\uac00\ud558\uace0\uc790 \ud55c \uae30\ub2a5\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- \ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8\uc5d0\uc11c \ucda9\uc804\uc18c\ub97c \uc120\ud0dd\ud558\uba74 \ud654\uba74\uc758 \uc911\uc2ec\uc774 \uc120\ud0dd\ud55c \ucda9\uc804\uc18c \ub9c8\ucee4\ub85c \uc774\ub3d9\ud558\uace0, \ucda9\uc804\uc18c\uc758 \uac04\ub2e8 \uc815\ubcf4\ub97c \ubaa8\ub2ec\ub85c \ub744\uc6cc\uc8fc\ub294 \uae30\ub2a5\\n\\n\uc704 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 google maps api\uc758 InfoWindow\uac1d\uccb4\ub97c \uc774\uc6a9\ud574\uc57c \ud55c\ub2e4. \uc0ac\uc6a9 \ubc29\uc2dd\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n```jsx\\nconst infowindow = new google.maps.InfoWindow({\\n content: contentString,\\n ariaLabel: \'Uluru\',\\n});\\n\\nconst marker = new google.maps.Marker({\\n position: uluru,\\n map,\\n title: \'Uluru (Ayers Rock)\',\\n});\\n\\ninfowindow.open({\\n anchor: marker,\\n map,\\n});\\n```\\n\\n\uac04\ub2e8\ud558\uac8c \uc694\uc57d\ud558\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- `InfoWindow` \uc0dd\uc131\uc790 \ud568\uc218\ub97c \ud1b5\ud574 `infoWindow` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n - \uc0dd\uc131\uc2dc dom \uc694\uc18c \ud639\uc740 string\uc744 \uc804\ub2ec\ud574 `infoWindow`\uac00 \uc0dd\uc131\ub420 dom\uc704\uce58\ub97c \uc9c0\uc815\ud574\uc900\ub2e4.\\n- `marker` \uc778\uc2a4\ud134\uc2a4\ub97c `infoWindow` \uc778\uc2a4\ud134\uc2a4\uc758 `open` \uba54\uc11c\ub4dc\uc5d0 \uc778\uc790\ub85c \uc804\ub2ec\ud55c\ub2e4.\\n- `infoWindow` \uc0dd\uc131 \uc2dc \uc804\ub2ec\ud588\ub358 dom\uc694\uc18c\uc758 \uc704\uce58\uac00 `marker`\uc758 \uc704\uce58\ub85c \uace0\uc815\ub418\uba74\uc11c \ud654\uba74\uc5d0 \uadf8\ub824\uc9c4\ub2e4.\\n\\n---\\n\\n![Untitled](https://file.notion.so/f/s/3079d7b9-8226-46b1-9482-054d1ea78016/Untitled.png?id=bce7685b-8a95-429c-bb75-98a4402cfc17&table=block&spaceId=e725e94b-8029-47f5-aecb-8eb1ef7c939f&expirationTimestamp=1689364800000&signature=jKnY-AhoxwqTiWrMi66uUtIamSOZDj8GGBTzgKeu_qY&downloadName=Untitled.png)\\n\\n\ucda9\uc804\uc18c \uc815\ubcf4\ub97c \ubcf4\uc5ec\uc8fc\ub294 \uc704 `StationList` \ucef4\ud3ec\ub10c\ud2b8\ub294 \ucda9\uc804\uc18c \uc815\ubcf4\uc5d0 \uc811\uadfc\ud560 \ub54c react-query\ub97c \ud1b5\ud574 \uc11c\ubc84 \uc0c1\ud0dc\ub97c \uc9c1\uc811 \ub0b4\ub824 \ubc1b\uc544 \ucef4\ud3ec\ub10c\ud2b8 \ub0b4\ubd80 \ub9ac\uc2a4\ud2b8\ub97c \ub80c\ub354\ub9c1 \ud55c\ub2e4.\\n\\n\ub610\ud55c, `StationMarkersContainer`\uc5d0\uc11c\ub3c4 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c react-query\uc758 \uc11c\ubc84 \uc0c1\ud0dc\uc5d0\uc11c \ucc38\uc870\ud574 \ub9c8\ucee4\ub97c \ub80c\ub354\ub9c1 \ud558\uace0 \uc788\ub2e4.\\n\\n\ub530\ub77c\uc11c `StationList` \ucef4\ud3ec\ub10c\ud2b8\uc640 `StationMarkersContainer`\ub294 \uac01\uac01 \ub530\ub85c \uc11c\ubc84 \uc0c1\ud0dc\uc5d0 \uc811\uadfc\ud574 \ub80c\ub354\ub9c1\uc744 \uc218\ud589\ud558\uace0 \uc788\uc73c\ubbc0\ub85c \ub458 \uc0ac\uc774\uc5d0\ub294 \uc5b4\ub5a0\ud55c \uc5f0\uacb0 \uace0\ub9ac\uac00 \uc5c6\ub2e4.\\n\\n\uc5ec\uae30\uc11c \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n---\\n\\n\ud604\uc7ac\uae4c\uc9c0\uc758 \ucf54\ub4dc\uc5d0\uc11c\ub294 `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c `StationMarkersContainer`\ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc0dd\uc131\ud55c\ub2e4. \uc774\ub97c \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc778 `StationMarker`\uc5d0 \ub0b4\ub824\uc8fc\uace0, \uc774 \ucef4\ud3ec\ub10c\ud2b8 \ub0b4\ubd80\uc5d0\uc11c `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n\\n\uc774\ubc88\uc5d0 \uad6c\ud604\ud558\uae30\ub85c \ud55c \uae30\ub2a5\uc740 `StationList`\uc758 \ud56d\ubaa9 \uc911 \ud558\ub098\ub97c \uc120\ud0dd\ud588\uc744 \uc2dc \uc120\ud0dd\ub41c \ucda9\uc804\uc18c\uc5d0 \ud574\ub2f9\ud558\ub294 \ub9c8\ucee4\uc5d0 \uac04\ub2e8 \uc815\ubcf4 \ubaa8\ub2ec\uc774 \ub728\uba70 \ud654\uba74\uc744 \ud574\ub2f9 \ub9c8\ucee4\uac00 \uc911\uc2ec\uc73c\ub85c \uc624\ub3c4\ub85d \uc774\ub3d9 \uc2dc\ud0a4\ub294 \uac83\uc774\uc5c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc9c0\uae08\uc758 \ucf54\ub4dc \uad6c\uc870\uc0c1 `StationList`\uc640 `StationMarkersContainer`\uc0ac\uc774\uc5d0\ub294 \uc5b4\ub5a0\ud55c \uc5f0\uacb0 \uace0\ub9ac\ub3c4 \uc5c6\uc73c\ubbc0\ub85c `infoWindow`\uc640 `marker`\uc5d0 `StationList`\ub294 \uc811\uadfc\ud560 \uc218 \uc5c6\ub294 \uc0c1\ud0dc\uac00 \ub41c\ub2e4.\\n\\n\uc774\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ubc29\ubc95\uc744 \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\ub2e4.\\n\\n- `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c root \ub2e8\uc5d0\uc11c \uc0dd\uc131\ud574 \uc804\uc5ed\uc801\uc73c\ub85c \uad00\ub9ac\ud55c\ub2e4.\\n- \uc0dd\uc131\ub420 `marker` \uc778\uc2a4\ud134\uc2a4\ub4e4\uc744 \ubc30\uc5f4 \ud615\ud0dc\uc758 \uc804\uc5ed \uc0c1\ud0dc\ub85c \uad00\ub9ac\ud55c\ub2e4.\\n\\n\uc704 \ub0b4\uc6a9\uc744 \ub9d0\ub85c\ub9cc \ubcf8\ub2e4\uba74 \ubcc4\ub85c \uc5b4\ub824\uc6b8 \uac83 \uc5c6\uc5b4 \ubcf4\uc774\uc9c0\ub9cc \uc2e4\uc81c \uad6c\ud604\uc744 \uc9c4\ud589\ud574\ubcf4\ub2c8 \ub0b4\ubd80\uc801\uc73c\ub85c \ud070 \ubb38\uc81c\uac00 \ub450 \uac00\uc9c0 \uc874\uc7ac\ud588\ub2e4.\\n\\n1. \ub530\ub85c \ubaa8\ub4c8\uc744 \ubd84\ub9ac\ud574 `infoWindow`\ub97c \uc0dd\uc131\ud560 \uc218 \uc5c6\ub2e4.\\n2. `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uac00 `StationMarkersContainer`\uac00 \ub418\uc5b4\uc11c\ub294 \uc548\ub41c\ub2e4.\\n\\n\uac01\uac01\uc758 \ubb38\uc81c\uc810\uc744 \uc0b4\ud3b4\ubcf4\uc790.\\n\\n---\\n\\n### 1. \ub530\ub85c \ubaa8\ub4c8\uc744 \ubd84\ub9ac\ud574 `infoWindow`\ub97c \uc0dd\uc131\ud560 \uc218 \uc5c6\ub2e4.\\n\\n`infoWinodw`\ub97c \uc804\uc5ed \uc0c1\ud0dc\ub85c \ub9cc\ub4e4\uc5b4 \uc0ac\uc6a9\ud558\uae30 \uc704\ud574 \ucc98\uc74c\uc73c\ub85c \ud588\ub358 \uc0dd\uac01\uc740 `infoWindowStore.ts`\ub85c \ubaa8\ub4c8\uc744 \ubd84\ub9ac\ud558\uc5ec `infoWindow`\ub97c \uc0dd\uc131\ud574 store\uc758 \ucd08\uae30\uac12\uc73c\ub85c \uc9c0\uc815\ud558\ub294 \uac83\uc774\uc5c8\ub2e4.\\n\\n\uc704 \uc0dd\uac01\uc744 \uac00\uc9c0\uace0 \uadf8\ub300\ub85c \uad6c\ud604\ud574\ubcf4\uc558\ub354\ub2c8 `google`\uc744 \ucc38\uc870\ud560 \uc218 \uc5c6\ub2e4\ub294 \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\ub2e4. `InfoWindow`\uc0dd\uc131\uc790 \ud568\uc218\ub294 `google.maps.InfoWindow`\ub97c \ud1b5\ud574 \uc811\uadfc\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \ud574\ub2f9 \uc5d0\ub7ec\ub294 `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud560 \uc218 \uc5c6\ub2e4\ub294 \uac83\uc744 \uc758\ubbf8\ud588\ub2e4.\\n\\n\uc65c `google`\uc744 \ucc38\uc870\ud560 \uc218 \uc5c6\ub294\uc9c0 \uc774\uc720\ub97c \ubd84\uc11d\ud574\ubcf4\ub2c8 \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\uc558\ub2e4.\\n\\n\uc6b0\ub9ac \ud300\uc774 \uad6c\uae00 \uc9c0\ub3c4 \ub85c\ub4dc\ub97c \uc704\ud574 \uc120\ud0dd\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 `@googlemaps/react-wrapper`\uc774\ub2e4. \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 \ub3d9\uc791\uc744 \uc0b4\ud3b4\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- `Wrapper`\ucef4\ud3ec\ub10c\ud2b8\uac00 `@googlemaps/js-loader`\ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 `Loader`\uc0dd\uc131\uc790 \ud568\uc218\ub97c \ud638\ucd9c\ud55c\ub2e4.\\n- \uc0dd\uc131\ub41c `loader`\uc778\uc2a4\ud134\uc2a4\uc758 `load`\uba54\uc11c\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c \uc9c0\ub3c4\uc758 \ub85c\ub529 \uc791\uc5c5\uc744 \uc2dc\uc791\ud55c\ub2e4.\\n - `load` \uba54\uc11c\ub4dc\ub294 \ucd5c\uc885\uc801\uc73c\ub85c `Promise`\uc744 \ubc18\ud658\ud558\ub294\ub370, \uc9c0\ub3c4 \ub85c\ub4dc\uc5d0 \uc131\uacf5\ud558\uba74 `resolve(window.google)` \uc744 \uc2e4\ud589\uc2dc\ucf1c `google`\uc744 \uc804\uc5ed\uc801\uc73c\ub85c \uc0ac\uc6a9 \uac00\ub2a5\ud558\ub3c4\ub85d \ub9cc\ub4e4\uc5b4\uc900\ub2e4.\\n- \uc9c0\ub3c4\uc758 \ub85c\ub529\uc774 \uc644\ub8cc\ub418\uba74 `Wrapper`\uc758 `render` props\ub97c \ud1b5\ud574 \ubc1b\uc740 \ucf5c\ubc31 \ud568\uc218\ub97c \uc2e4\ud589\uc2dc\ud0a8\ub2e4.\\n - `render`\ucf5c\ubc31 \ud568\uc218\ub294 \ub85c\ub529 \uc0c1\ud0dc\ub97c \ub098\ud0c0\ub0b4\ub294 Status\ub97c \ud30c\ub77c\ubbf8\ud130\ub85c \ub118\uaca8 \ubc1b\uc544 \ud638\ucd9c\ub41c\ub2e4.\\n\\n\ucd5c\uc885\uc801\uc73c\ub85c `render`\ub97c \uc2e4\ud589 \uc2dc\ucf30\uc744 \ub54c \ubc18\ud658 \ub418\ub294 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c\ub294 `google` \ub85c\ub529 \ub418\uc5b4 \uc804\uc5ed\uc801\uc73c\ub85c \uc811\uadfc\uc774 \uac00\ub2a5\ud568\uc744 \ubcf4\uc7a5\ud560 \uc218 \uc788\uc73c\ubbc0\ub85c \uc774\ub54c\ubd80\ud130 `google`\uc5d0 \uc811\uadfc\uc774 \uac00\ub2a5\ud574\uc9c4\ub2e4. \u2192 \ub530\ub77c\uc11c `Wrapper`\ub97c \ud1b5\ud574 \ubc18\ud658\ub418\ub294 \ucef4\ud3ec\ub10c\ud2b8\uc758 \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c `google.maps.Map`\uc0dd\uc131\uc790 \ud568\uc218\ub97c \uc0ac\uc6a9\ud574 \uc9c0\ub3c4\ub97c \uc0dd\uc131\ud560 \uc218 \uc788\uac8c \ub41c\ub2e4.\\n\\n`infoWindow`\ub97c \uc0dd\uc131\ud558\uae30 \uc704\ud574 \ub9cc\ub4e0 \uc0c8\ub85c\uc6b4 \ubaa8\ub4c8\uc740 \uccab `import`\uc2dc\uae30\uc5d0 \ud3c9\uac00\ub420 \uac83\uc774\uae30 \ub54c\ubb38\uc5d0 `Wrapper`\uc758 \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c `import`\ub97c \uc218\ud589\ud55c\ub2e4\uba74 \ub85c\ub4dc\uac00 \uc644\ub8cc\ub41c \uc774\ud6c4 \uc2dc\uc810\uc77c \uac83\uc774\ubbc0\ub85c `window.google`\uc774 \ub4f1\ub85d\ub418\uc5b4 `google`\uc5d0 \uc811\uadfc\uc774 \uac00\ub2a5\ud560 \uac83\uc73c\ub85c \uc608\uc0c1\ud588\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc6f9\ud329\uc744 \ud1b5\ud55c \ubc88\ub4e4\ub9c1 \uacfc\uc815\uc5d0\uc11c \ubaa8\ub4c8\uc774 \ub4a4\uc11e\uc5ec \ud30c\uc77c\uc758 \ud3c9\uac00 \uc2dc\uae30\ub97c \ubcf4\uc7a5\ud560 \uc218 \uc5c6\uc5b4\uc838 \uc0c8\ub85c \ub9cc\ub4e0 \ubaa8\ub4c8\uc5d0\uc11c\ub294 `google`\uc5d0 \ub300\ud55c \uc811\uadfc\uc774 \ubd88\uac00\ub2a5\ud574\uc9c0\uac8c \ub418\uc5c8\ub2e4. \uc6f9\ud329\uc744 \uc880 \ub354 \uacf5\ubd80\ud574\ubcf8\ub2e4\uba74 \uc774 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \uc218 \uc788\uc744 \uac83 \uac19\uc558\uc9c0\ub9cc, \ub108\ubb34 \uc9c0\uc5fd\uc801\uc778 \ubd80\ubd84\uc5d0\uc11c \ub9ce\uc740 \uc2dc\uac04\uc744 \ub4e4\uc774\uae30 \ubcf4\ub2e8 \uae30\uc874\uc5d0 \uac1c\ubc1c\ud558\ub358 \ubc29\uc2dd\uc744 \ud1b5\ud574 \ubb38\uc81c\ub97c \ud574\uacb0\ud574\ubcf4\uae30\ub85c \uacb0\uc815\ud588\ub2e4.\\n\\n\ucd5c\uc885\uc801\uc73c\ub85c \ubb38\uc81c\ub97c \ud574\uacb0\ud55c \ubc29\uc2dd\uc740 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- `InfoWindow`\uc0dd\uc131\uc790 \ud568\uc218\ub97c \ud638\ucd9c\ud560 `CarFfeineInfoWindowInitializer`\ucef4\ud3ec\ub10c\ud2b8\ub97c \ub9cc\ub4e0\ub2e4.\\n- `Wrapper`\ub85c \uac10\uc2f8\uc9c4 \ucef4\ud3ec\ub10c\ud2b8 \ud558\uc704\uc5d0 `CarFfeineInfoWindowInitializer` \ucef4\ud3ec\ub10c\ud2b8\ub97c \ucd94\uac00\ud55c\ub2e4.\\n- `google`\uc5d0 \uc811\uadfc\uc774 \uac00\ub2a5\ud55c \uc0c1\ud0dc\ub97c \ubcf4\uc7a5\ubc1b\uc740 `CarFfeineInfoWindowInitializer`\ub0b4\ubd80\uc5d0\uc11c `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n- `store`\uc5d0 `infoWindow`\uc778\uc2a4\ud134\uc2a4\ub97c `set`\ud574\uc8fc\uc5b4 \uc804\uc5ed\uc801\uc73c\ub85c `infoWindow`\ub97c \uc0ac\uc6a9 \uac00\ub2a5\ud558\ub3c4\ub85d \ud55c\ub2e4.\\n\\n---\\n\\n### 2. `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uac00 `StationMarkersContainer`\uac00 \ub418\uc5b4\uc11c\ub294 \uc548\ub41c\ub2e4.\\n\\n\uc774\ubc88 \ud300 \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c \uc9c0\ub3c4\ub97c \uad6c\ud604\ud558\uae30 \uc704\ud574 google maps api\ub97c \uc0ac\uc6a9\ud558\uac8c \ub418\uc5c8\ub2e4. \ub72c\uae08\uc5c6\uc774 \uc774 \uc774\uc57c\uae30\ub97c \ud55c \uc774\uc720\ub294 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- google maps api\ub294 \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8\ub97c \uae30\ubc18\uc73c\ub85c \ub3d9\uc791\ud55c\ub2e4.\\n- \uc774\ubc88 \ud300 \ud504\ub85c\uc81d\ud2b8\ub294 \ub9ac\uc561\ud2b8\ub97c \uae30\ubc18\uc73c\ub85c \uac1c\ubc1c\uc744 \uc9c4\ud589\ud560 \uac83\uc774\ub2e4.\\n- \uc9c0\ub3c4\ub97c \uadf8\ub9ac\uae30 \uc704\ud574\uc11c \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8\uc640 \ub9ac\uc561\ud2b8\uc758 \uc801\uc808\ud55c \uc870\ud654\uac00 \ud544\uc694\ud558\ub2e4.\\n- \ub2e4\uc18c \ud63c\ub780\uc2a4\ub7ec\uc6b8 \uc218 \uc788\ub294 \uc9c0\ub3c4\uc758 \uc870\uc791 \ubc29\uc2dd\uc744 \ub9ac\uc561\ud2b8\uc640 \uc870\ud654\ub86d\uac8c \uc0ac\uc6a9\ud558\uae30 \uc704\ud574\uc11c \ucef4\ud3ec\ub10c\ud2b8 \uc124\uacc4\uc2dc \ucef4\ud3ec\ub10c\ud2b8\uc758 \ucc45\uc784\uc744 \ud655\uc2e4\ud558\uac8c \uad6c\ubd84\ud574\uc57c\uaca0\ub2e4\ub294 \uc0dd\uac01\uc744 \ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uc774 \ucef4\ud3ec\ub10c\ud2b8\uc758 \ucc45\uc784\uc5d0 \ub300\ud55c \ubb38\uc81c\ub85c \uc778\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uc5d0 \ub300\ud574 \ub9ce\uc740 \uace0\ubbfc\uc744 \ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uc77c\ub2e8 \uc6d0\ub798 \ucf54\ub4dc \uad6c\uc870\uc5d0\uc11c \ub9c8\ucee4\ub97c \uadf8\ub9ac\uae30 \uc704\ud574 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ub2e4\uc74c\uacfc \uac19\uc774 \ucd94\uc0c1\ud654 \ud588\ub2e4.\\n\\n- `StationMarkersContainer` \ucef4\ud3ec\ub10c\ud2b8\\n - \ub9ac\uc561\ud2b8 \ucffc\ub9ac\ub97c \ud1b5\ud574 \ubc1b\uc544\uc628 \uc11c\ubc84 \uc0c1\ud0dc(\ucda9\uc804\uc18c \uc815\ubcf4 \ubc30\uc5f4)\ub85c `StationMarker`\ub97c \ud638\ucd9c\ud55c\ub2e4.\\n- `StationMarker` \ucef4\ud3ec\ub10c\ud2b8\\n - \uc0c1\uc704\uc5d0\uc11c \ub0b4\ub824\ubc1b\uc740 \ucda9\uc804\uc18c \uc815\ubcf4 props\ub97c \ud1b5\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4. (google maps api\uc5d0\uc11c\ub294 \uc778\uc2a4\ud134\uc2a4 \uc0dd\uc131\uc774 \uace7 \ub80c\ub354\ub9c1\uc744 \uc758\ubbf8\ud55c\ub2e4)\\n - \uc0dd\uc131\ud55c `marker` \uc778\uc2a4\ud134\uc2a4\uc5d0 `infoWindow` \uc778\uc2a4\ud134\uc2a4\uc758 `open` \uba54\uc11c\ub4dc\ub97c \ud2b8\ub9ac\uac70 \ud558\ub294 \ud074\ub9ad \uc774\ubca4\ud2b8 \ub9ac\uc2a4\ub108\ub97c \ucd94\uac00\ud574\uc900\ub2e4.\\n - `useEffect`\uc758 \ud074\ub9b0\uc5c5 \ud568\uc218\ub97c \uc774\uc6a9\ud574 \ucda9\uc804\uc18c \uc815\ubcf4\uac00 \ucd5c\uc2e0\ud654 \ub418\uc5c8\uc744 \ub54c \ub9c8\ucee4\uac00 \ub354\uc774\uc0c1 \ud654\uba74\uc5d0 \ubcf4\uc774\uc9c0 \uc54a\ub294\ub2e4\uba74 `marker` \uc778\uc2a4\ud134\uc2a4\uc758 `setMap(null)` \uba54\uc11c\ub4dc\ub97c \ud638\ucd9c\ud574 google maps api\uc5d0\uc11c \ub9c8\ucee4\ub97c \uc9c0\uc6b0\ub3c4\ub85d \ud55c\ub2e4. (\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654)\\n\\n\uac04\ub7b5\ud788 \uc124\uba85\ud558\uc790\uba74 `StationMarkersContainer` \ucef4\ud3ec\ub10c\ud2b8\ub294 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc11c\ubc84\uc5d0\uc11c \ubc1b\uc544 `StationMarker`\ub97c \ud638\ucd9c\ud558\ub294 \uc5ed\ud560\ub9cc\uc744 \uc218\ud589\ud558\uace0, \ub9c8\ucee4\uc5d0 \ub300\ud55c \ubaa8\ub4e0 \uc138\ubd80 \ub85c\uc9c1\uc740 `StationMarker`\uac00 \uc218\ud589\ud558\ub3c4\ub85d \ucef4\ud3ec\ub10c\ud2b8\ub97c \ucd94\uc0c1\ud654 \ud574\ubcf4\uc558\ub2e4.\\n\\n\uc774\ub984\uc5d0\uc11c\ub3c4 \ub4dc\ub7ec\ub098\ub4ef `StationMarker` \ucef4\ud3ec\ub10c\ud2b8\uac00 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uc8fc\uccb4\uac00 \ub418\uc5b4\uc57c \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8\uc640 \ub9ac\uc561\ud2b8\uc758 \ud63c\uc885\uc778 \uc774 \ud504\ub85c\uc81d\ud2b8\uc758 \ucf54\ub4dc\ub97c \ucd94\ud6c4 \uc720\uc9c0\ubcf4\uc218 \ud560 \ub54c \ubb38\uc81c\uac00 \uc5c6\uc73c\ub9ac\ub77c \ud310\ub2e8\ud588\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774\ub807\uac8c \ucd94\uc0c1\ud654 \ub41c \ucef4\ud3ec\ub10c\ud2b8\ub4e4\uc740 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \ubc30\uc5f4 \ud615\uc2dd\uc758 \uc804\uc5ed \uc0c1\ud0dc\uc5d0 \ub2f4\uc544 \uad00\ub9ac\ud558\uace0\uc790 \ud560 \ub54c \ubb38\uc81c\uac00 \ub418\uc5c8\ub2e4.\\n\\n---\\n\\n\uc77c\ub2e8 \uba3c\uc800 \uc11c\ubc84\uc5d0\uc11c \ub0b4\ub824 \ubc1b\uc740 \ucda9\uc804\uc18c \uc815\ubcf4\ub97c `station`\uc774\ub77c\uace0 \ud558\uc790, \uc6b0\ub9ac\ub294 \uc774 `station`\uc744 \ud1b5\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\uace0\uc790 \ud55c\ub2e4.\\n\\n\uc774\ub54c \uc0dd\uac01 \ud560 \uc218 \uc788\ub294 \uac00\uc7a5 \uac04\ub2e8\ud55c \ubc29\ubc95\uc740 `station`\uc5d0\uc11c `map` \uba54\uc11c\ub4dc\ub97c \ud1b5\ud574 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\uc5ec \uc774 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc778 `StationMarker`\uc5d0 \ub118\uaca8\uc8fc\ub294 \ubc29\uc2dd\uc77c \uac83\uc774\ub2e4.\\n\\n\ud558\uc9c0\ub9cc \uc774 \ubc29\uc2dd\uc740 \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud558\ub294 \uac83\uc774 \uace7 \ud654\uba74\uc5d0 \ub80c\ub354\ub9c1\uc744 \ubc1c\uc0dd\uc2dc\ud0a4\ub294 \uac83\uc744 \uc758\ubbf8\ud558\ub294 google maps api\uc758 \ud2b9\uc131\uc0c1 \uc6b0\ub9ac\uac00 \ucc98\uc74c \uc124\uacc4\ud55c \ucef4\ud3ec\ub10c\ud2b8\uc758 \ucc45\uc784\uc744 \ubc18\ud558\ub294 \uad6c\uc870\ub97c \ub9cc\ub4e4\uc5b4\ub0b4\uac8c \ub41c\ub2e4.\\n\\n\uc790\uc138\ud788 \uc124\uba85\ud574\ubcf4\uc790\uba74 \ub9c8\ucee4\uc758 \ub80c\ub354\ub9c1\uc740 `StationMarkersContainer`\uac00 \uc218\ud589\ud558\uace0 \uc788\ub294\ub370 \ud654\uba74\uc5d0 \ubcf4\uc774\uc9c0 \uc54a\ub294 \ub9c8\ucee4\ub97c \uc9c0\uc6b0\ub294 \uc5ed\ud560\uc740 `StationMarker`\ucef4\ud3ec\ub10c\ud2b8\uac00 \uc218\ud589\ud558\uace0 \uc788\uace0, \uc774\ubca4\ud2b8 \ud578\ub4e4\ub7ec\uc758 \ucd94\uac00 \uc5ed\uc2dc \ub9c8\ucee4\uac00 \uc0dd\uc131\ub41c \uc774\ud6c4\uc5d0 \ud558\uc704 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c \uc774\ub97c \uc218\ud589\ud558\ub294 \uad34\uc0c1\ud55c \ucf54\ub4dc\uac00 \ub9cc\ub4e4\uc5b4\uc9c0\uac8c \ub41c\ub2e4.\\n\\n\ucd94\ud6c4 \ucf54\ub4dc\uc758 \uc720\uc9c0\ubcf4\uc218\uc131\uc744 \uc704\ud574\uc120 \ud53c\ud574\uc57c \ud560 \ubc29\uc2dd\uc784\uc774 \uba85\ud655\ud588\ub2e4.\\n\\n\ud574\uacb0 \ubc29\uc2dd\uc744 \uace0\ubbfc\ud574\ubcf4\ub2e4\uac00 \ub2e4\uc74c\uacfc \uac19\uc740 \ud574\uacb0 \ubc29\uc548\uc744 \uc0dd\uac01\ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n`StationMarker` \ucef4\ud3ec\ub10c\ud2b8\uc758 \uc5ed\ud560\\n\\n- `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc0dd\uc131\ud55c\ub2e4.\\n- `marker` \uc778\uc2a4\ud134\uc2a4\uc758 \uc774\ubca4\ud2b8 \ud578\ub4e4\ub7ec\ub97c \ucd94\uac00\ud55c\ub2e4.\\n- \uc0dd\uc131\ub41c `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \ubc30\uc5f4 \ud615\uc2dd\uc758 \uc804\uc5ed \uc0c1\ud0dc\uc5d0 \ucd94\uac00\ud55c\ub2e4.\\n- \ucda9\uc804\uc18c \uc815\ubcf4\uac00 \ucd5c\uc2e0\ud654 \ub418\uc5c8\uc744 \ub54c \ub9c8\ucee4\uac00 \ud654\uba74\uc5d0 \ubcf4\uc774\uc9c0 \uc54a\ub294 \uc0c1\ud0dc\uac00 \ub418\uc5c8\ub2e4\uba74 `marker` \uc778\uc2a4\ud134\uc2a4\ub97c \uc804\uc5ed \uc0c1\ud0dc\uc5d0\uc11c \uc0ad\uc81c\ud55c\ub2e4.\\n\\n\uc704\uc640 \uac19\uc774 `StationMarker` \uc758 \uc5ed\ud560\uc744 \uc7a1\uac8c \ub418\uba74 \uae30\uc874\uc758 \ucef4\ud3ec\ub10c\ud2b8 \uc124\uacc4 \uad6c\uc870\ub97c \ud574\uce58\uc9c0 \uc54a\uc73c\uba74\uc11c \uc804\uc5ed \uc0c1\ud0dc\uc5d0 `marker`\uc778\uc2a4\ud134\uc2a4\ub97c \uc798 \ucd94\uac00\ud560 \uc218 \uc788\uac8c \ub41c\ub2e4. \ud558\uc9c0\ub9cc \uc774\ub807\uac8c \ub418\uba74 `StationMarker` \ucef4\ud3ec\ub10c\ud2b8\ub294 \ub2e4\uc74c\uc758 \ud070 \ubb38\uc81c\ub4e4\uc744 \uac00\uc9c0\uac8c \ub41c\ub2e4.\\n\\n1. `marker`\ub4e4\uc744 \uac00\uc9c0\ub294 \uc804\uc5ed \uc0c1\ud0dc\ub97c \uad6c\ub3c5\ud558\uace0 \uc788\ub294 \ucef4\ud3ec\ub10c\ud2b8\uac00 \uc0c8\ub85c \uc0dd\uc131\ub418\ub294 \ub9c8\ucee4\uc758 \uac1c\uc218\ub9cc\ud07c \ub9ac\ub80c\ub354\ub9c1 \ub41c\ub2e4.\\n2. \ud604\uc7ac \uc0ac\uc6a9\ud558\uace0 \uc788\ub294 \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\uc758 \ud2b9\uc131\uc0c1 \uc774\uc804 \uc0c1\ud0dc\ub97c \ucc38\uc870\ud574\uc640\uc57c `marker`\ub97c \ucd94\uac00\ud560 \uc218 \uc788\uac8c \ub418\ub294\ub370, \uc774 \ub54c \uc774\uc804 \uc0c1\ud0dc\uac00 \ucd5c\uc2e0\uc758 \uc0c1\ud0dc\uc784\uc744 \ubcf4\uc7a5\ud558\uc9c0 \ubabb\ud560 \uc218 \uc788\ub2e4.\\n\\n\uc774 \ub450 \ubb38\uc81c\ub97c \ud574\uacb0\ud560 \ubc29\uc2dd\uc744 \uace0\ubbfc\ud574\ubcf4\uc558\uc744 \ub54c \ub2e4\uc74c\uacfc \uac19\uc740 \uacb0\ub860\uc5d0 \ub3c4\ub2ec\ud558\uac8c \ub418\uc5c8\ub2e4.\\n\\n- \ud604\uc7ac \uc0ac\uc6a9\ud558\uace0 \uc788\ub294 \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\ub294 React 18\uc5d0 \uc0c8\ub85c \ucd94\uac00\ub41c `useSyncExternalState` \ud6c5\uc744 \uae30\ubc18\uc73c\ub85c `recoil`\uacfc \ube44\uc2b7\ud558\uac8c \uc0ac\uc6a9\ud560 \uc218 \uc788\ub3c4\ub85d \uacc4\uce35\uc744 \ubd84\ub9ac\ud558\uc5ec \ub9cc\ub4e0 \ub3c4\uad6c\uc774\ub2e4.\\n- \uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac \ub3c4\uad6c\uc758 \uba54\uc11c\ub4dc `useExternalState`, `useExternalValue`, `useSetExternalState` \uc774\uc678\uc5d0 `store` \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc9c1\uc811 \uc811\uadfc\ud558\uc5ec \ucd5c\uc2e0\uc758 \uc0c1\ud0dc\ub97c \ucc38\uc870\ud558\ub294 `getStoreSnapShot` \uba54\uc11c\ub4dc\ub97c \ucd94\uac00\ud55c\ub2e4.\\n- `store`\uc5d0 \uc9c1\uc811 \uc811\uadfc\ud574 \ubc1b\uc544\uc628 \ucd5c\uc2e0\uc758 \uc0c1\ud0dc\ub294 \ubc14\ub2d0\ub77c \uc790\ubc14\uc2a4\ud06c\ub9bd\ud2b8 \uac1d\uccb4 \uc774\ubbc0\ub85c \ub9ac\uc561\ud2b8\uc758 \ub9ac\ub80c\ub354\ub9c1\uc744 \ubc1c\uc0dd \uc2dc\ud0a4\uc9c0 \uc54a\ub294\ub2e4.\\n- \ub9ac\ub80c\ub354\ub9c1\uc73c\ub85c \uc778\ud55c \ubb38\uc81c\uc810\ub4e4\uc744 `getStoreSnapShot` \uba54\uc11c\ub4dc\ub97c \ucd94\uac00\ud568\uc73c\ub85c\uc368 \ud574\uacb0\ud560 \uc218 \uc788\ub2e4.\\n\\n---\\n\\n\uc0c8\ub85c\uc6b4 \uae30\ub2a5 \ucd94\uac00\ub97c \uc704\ud574 \ub9c8\uc8fc\ud588\ub358 \uc55e\uc120 \ub450 \uac00\uc9c0\uc758 \ubb38\uc81c\uc640 \ud574\uacb0 \ubc29\uc2dd\uc744 \uc0b4\ud3b4 \ubcf4\uc558\ub2e4. \uadf8\ub798\uc11c \ucd5c\uc885\uc801\uc73c\ub85c \uc774\uc804\uae4c\uc9c0 \uacc4\uc18d\ud574\uc11c \uace0\ubbfc\ud574\uc654\ub358 \ubb38\uc81c\ub97c \ud574\uacb0\ud55c \uacfc\uc815\uc744 \uac04\ucd94\ub824\ubcf4\uc790\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n- \ucda9\uc804\uc18c \uc815\ubcf4\ub97c \uc11c\ubc84\uc5d0\uc11c \ubc1b\uc544\uc640 \ub80c\ub354\ub9c1 \ud558\ub294 `StationList` \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c `marker` \uc778\uc2a4\ud134\uc2a4 \ubc30\uc5f4\uc744 \uc800\uc7a5\ud558\uace0 \uc788\ub294 `store`\uc778\uc2a4\ud134\uc2a4\uc5d0 \uc9c1\uc811 \uc811\uadfc\ud574 \ucd5c\uc2e0\uc758 `marker`\uc778\uc2a4\ud134\uc2a4\ub4e4\uc744 \uac00\uc838\uc628\ub2e4.\\n- \ucda9\uc804\uc18c \ubaa9\ub85d\uc5d0\uc11c \uc0ac\uc6a9\uc790\uac00 \ucda9\uc804\uc18c\ub97c \ud074\ub9ad\ud588\uc744 \ub54c \uc804\uc5ed\uc73c\ub85c \uad00\ub9ac\ub418\ub294 `infoWindow` \uc778\uc2a4\ud134\uc2a4\uc758 `open`\uba54\uc11c\ub4dc\uc5d0 `marker` \uc778\uc2a4\ud134\uc2a4\ub4e4 \uc911 \uc120\ud0dd\ub41c `marker`\ub97c \uc804\ub2ec\ud574 \uac04\ub2e8 \uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6cc\uc900\ub2e4."},{"id":"11","metadata":{"permalink":"/11","source":"@site/blog/2023-07-10-google-maps-api-with-car-ffeine.mdx","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","description":"\uc9c0\ub3c4 api \ubca4\ub354 \uc120\ud0dd \uc774\uc720","date":"2023-07-10T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 10\uc77c","tags":[{"label":"react","permalink":"/tags/react"},{"label":"google maps","permalink":"/tags/google-maps"},{"label":"google maps api","permalink":"/tags/google-maps-api"},{"label":"react-wrapper","permalink":"/tags/react-wrapper"},{"label":"@googlemaps/react-wrapper","permalink":"/tags/googlemaps-react-wrapper"}],"readingTime":8.165,"hasTruncateMarker":false,"authors":[{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"11","title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","authors":["gabriel"],"tags":["react","google maps","google maps api","react-wrapper","@googlemaps/react-wrapper"]},"prevItem":{"title":"\ucda9\uc804\uc18c \ub9ac\uc2a4\ud2b8 \ud074\ub9ad\uc2dc \ub9c8\ucee4\uc5d0 \uac04\ub2e8\uc815\ubcf4 \ubaa8\ub2ec\uc744 \ub744\uc6b0\ub294 \uae30\ub2a5 \ucd94\uac00\uc5d0\uc11c \uacaa\uc5c8\ub358 \ud2b8\ub7ec\ube14 \uc288\ud305","permalink":"/13"},"nextItem":{"title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","permalink":"/12"}},"content":"## \uc9c0\ub3c4 api \ubca4\ub354 \uc120\ud0dd \uc774\uc720\\n\\n\uad6d\ub0b4 \uc11c\ube44\uc2a4 \uc911\uc778 \uc9c0\ub3c4 \uc11c\ube44\uc2a4\ub85c\ub294 google, naver, kakao\uac00 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc911\uc5d0\uc11c\ub3c4 google maps api\ub294 css\ub85c `\uc9c0\ub3c4\uc758 \ud14c\ub9c8\ub97c \uc9c1\uc811 \uc2a4\ud0c0\uc77c\ub9c1\ud560 \uc218 \uc788\ub294 \uae30\ub2a5\uc774 \uc788\uc5b4\uc11c \uc120\ud0dd`\ud558\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\ngoogle maps api\ub97c \uc0ac\uc6a9\ud558\uae30 \uc704\ud574\uc11c \ubcc4\ub3c4\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc0ac\uc6a9\uc774 \ud544\uc218\ub294 \uc544\ub2c8\uc9c0\ub9cc\\n\\n\uc800\ud76c \ud300\uc5d0\uc11c \ub300\uc911\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uacfc \uae30\ubcf8 \ud658\uacbd \uc124\uc815\ubc95\uc744 \ubaa8\ub450 \ud14c\uc2a4\ud2b8 \ud588\uc744 \ub54c, \ubc18\ub4dc\uc2dc \uc0ac\uc6a9\ud558\uace0 \uc2f6\uc740 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \uc874\uc7ac\ud558\uc5ec \ube44\uad50\ub97c \uae30\ub85d\uc73c\ub85c \ub0a8\uae30\uac8c \ub410\uc2b5\ub2c8\ub2e4.\\n\\n# google maps api \uad00\ub828 \ub77c\uc774\ube0c\ub7ec\ub9ac\\n\\n(\uc120\ud0dd\ud55c \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 \u2705\uc73c\ub85c \ud45c\uc2dc\ud588\uc2b5\ub2c8\ub2e4.)\\n\\n### google maps API\\n\\nhttps://github.com/tomchentw/react-google-maps\\n\\n\uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uad6c\uae00\uc5d0\uc11c \uacf5\uc2dd\uc73c\ub85c \uc81c\uacf5\ud558\ub294 \uc9c0\ub3c4 api\ub85c, HTML DOM\uc5d0 \uad6c\uae00 \uc9c0\ub3c4\ub97c \ubd80\ucc29\ud558\uace0, \uc0ac\uc6a9(\uc870\uc791)\ud560 \uc218 \uc788\ub3c4\ub85d \ub3c4\uc640\uc90d\ub2c8\ub2e4. \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 `vanilla Javascript \uae30\ubc18\uc73c\ub85c \ub3d9\uc791`\ud569\ub2c8\ub2e4.\\n\\n### **@types/google.maps** \u2705\\n\\nhttps://www.npmjs.com/package/@types/google.maps\\n\\nTypeScript\uc5d0\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \uc0ac\uc6a9\ud560 \ub54c `\ud0c0\uc785\uc744 \uc81c\uacf5`\ud574\uc8fc\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n### **@googlemaps/js-api-loader**\\n\\nhttps://www.npmjs.com/package/@googlemaps/js-api-loader\\n\\n\uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uad6c\uae00\uc5d0\uc11c \uacf5\uc2dd\uc73c\ub85c \uc81c\uacf5\ud558\ub294 \uc9c0\ub3c4 \ud638\ucd9c api\ub85c, api key\ub9cc \ub118\uaca8\uc8fc\ub354\ub77c\ub3c4 \uad6c\uae00 \uc9c0\ub3c4\ub97c \uc2a4\ud06c\ub9bd\ud2b8 \ud615\ud0dc\ub85c \ubd88\ub7ec\uc640\uc8fc\ub294 \uc5ed\ud560\uc744 \ud558\ub294 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4. \ubcc4\ub3c4\ub85c html \uc870\uc791 \uc5c6\uc774 \ubd88\ub7ec\uc628 `\ub77c\uc774\ube0c\ub7ec\ub9ac\uc5d0\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \uaebc\ub0b4\uc11c \ub3d9\uc801\uc73c\ub85c \uc0ac\uc6a9`\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. vanilla Javascript \uae30\ubc18\uc73c\ub85c \ub3d9\uc791\ud558\uc5ec \uc5b4\ub514\uc5d0\uc11c\ub098 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n### \ub300\uc911\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac \ube44\uad50\\n\\n| | react-google-maps | @react-google-maps/api | @googlemaps/react-wrapper |\\n| --- | --- | --- | --- |\\n| \ub9c1\ud06c | https://www.npmjs.com/package/react-google-maps | https://www.npmjs.com/package/@react-google-maps/api | https://www.npmjs.com/package/@googlemaps/react-wrapper |\\n| \uc124\uba85 | \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uac1c\uc778\uc774 \ub9cc\ub4e0 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c, google maps API\ub97c react DOM \uc704\uc5d0 \uc62c\ub824\uc11c \uc0ac\uc6a9\ud558\uac8c \ub3d5\uc2b5\ub2c8\ub2e4.
    \uad6c\uae00 \uc9c0\ub3c4\uc640 \ub9c8\ucee4\ub97c react component \ucc98\ub7fc \uc0ac\uc6a9\ud558\uc5ec react\uc2a4\ub7fd\uac8c \ub80c\ub354\ub9c1 \ud558\ub294 \uac83\uc744 \uc9c0\uc6d0\ud569\ub2c8\ub2e4.
    react \uc9c4\uc601\uc5d0\uc11c \uac00\uc7a5 \ub300\uc911\uc801\uc73c\ub85c \uc0ac\uc6a9\ub418\ub294 \uad6c\uae00 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc600\uc9c0\ub9cc 2018\ub144 \uc774\ud6c4\ub85c \uc5c5\ub370\uc774\ud2b8\uac00 \ub04a\uacbc\uc2b5\ub2c8\ub2e4. | \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub3c4 \uac1c\uc778\uc774 \ub9cc\ub4e0 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c \uc55e\uc11c \uc18c\uac1c\ud55c react-google-maps\ub97c \uac1c\ub7c9\ud558\uc5ec \ub9cc\ub4e0 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.
    \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc5ed\uc2dc react\uc5d0 \uc9c0\ub3c4\ub098 \ub9c8\ucee4 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ud638\ucd9c\ud574\uc11c \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4.
    \ud604\uc7ac react \uc9c4\uc601\uc5d0\uc11c \uac00\uc7a5 \ub300\uc911\uc801\uc73c\ub85c \uc0ac\uc6a9\ub418\ub294 \uad6c\uae00 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac \uc785\ub2c8\ub2e4. | \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uad6c\uae00\uc5d0\uc11c \uacf5\uc2dd\uc73c\ub85c \uc81c\uacf5\ud558\ub294 react\uc6a9 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.
    \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub294 \uc55e\uc11c \uc18c\uac1c\ud55c js-api-loader\ub97c \ud65c\uc6a9\ud558\uc5ec \ub9cc\ub4e0 Wrapper \ucef4\ud3ec\ub10c\ud2b8\ub97c \uc81c\uacf5\ud558\ub294\ub370, \uad6c\uae00 \uc9c0\ub3c4\ub97c \ud638\ucd9c\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uc218\uc2e0\uc911, \uc2e4\ud328, \uc131\uacf5\uc5d0 \ub530\ub77c \uc9c0\ub3c4\ub97c \ubcf4\uc5ec\uc904 \uc9c0, \ub85c\ub529\uc911 \ucef4\ud3ec\ub10c\ud2b8\ub97c \ubcf4\uc5ec\uc904 \uc9c0, \uc5d0\ub7ec \ucef4\ud3ec\ub10c\ud2b8\ub97c \ubcf4\uc5ec\uc904 \uc9c0 \uacb0\uc815\ud558\ub294 \uae30\ub2a5\uc774 \uc788\uc2b5\ub2c8\ub2e4.
    \uc774\uc678\uc5d0\ub294 \uae30\uc874\uc758 js-api-loader\uc758 \uae30\ub2a5\uacfc \uc644\ubcbd\ud558\uac8c \ub3d9\uc77c\ud569\ub2c8\ub2e4. (\ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc5f4\uc5b4\uc11c \uc9c1\uc811 \ud655\uc778\ud574\ubd24\uc2b5\ub2c8\ub2e4.) |\\n| \uc120\ud0dd\uc5ec\ubd80 | | | \u2705 |\\n\\n# \ub77c\uc774\ube0c\ub7ec\ub9ac \uc120\ud0dd \uc774\uc720\\n\\n\uc800\ud76c \ud504\ub85c\uc81d\ud2b8\ub294 `\uc2e4\uc2dc\uac04 \uc804\uae30\uc790\ub3d9\ucc28 \ucda9\uc804\uc18c \uc9c0\ub3c4 \ubc0f \uc0ac\uc6a9 \ud1b5\uacc4 \uc870\ud68c \uc11c\ube44\uc2a4` \ub2e4\ubcf4\ub2c8 \uc9c0\ub3c4 \uc704\uc5d0 \ub744\uc6cc\uc918\uc57c \ud560 \ub9c8\ucee4\ub97c \ucd5c\uc801\ud654 \ud558\ub294 \uacfc\uc815\uc774 \uad49\uc7a5\ud788 \uc911\uc694\ud569\ub2c8\ub2e4.\\n\\n1. \uc804\uad6d 6\ub9cc\uc5ec \uac1c\uc758 \ub9c8\ucee4\ub97c \uc804\ubd80 \ubcf4\uc5ec\uc904 \uc218 \uc5c6\ub2e4.\\n2. \ud604\uc7ac \ub514\uc2a4\ud50c\ub808\uc774 \uc601\uc5ed\uc758 \ub9c8\ucee4\ub9cc\uc744 \ud638\ucd9c\ud574\uc57c\ud55c\ub2e4.\\n3. \uadf8 \ub9c8\ucee4\ub4e4\uc758 \ub80c\ub354\ub9c1 \uacfc\uc815\uc744 \uc800\uc218\uc900\uc5d0\uc11c \ub2e4\ub8f0 \uc218 \uc788\uc5b4\uc57c \ud55c\ub2e4.\\n\\n\uc774\ub7f0 \uc6d0\uce59\uc744 \uac00\uc9c0\uace0 \uc788\uae30\uc5d0 \ub300\uc911\uc801\uc778 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4(react-google-maps, @react-google-maps/api)\uc740 \uc800\ud76c\uc758 \uc120\ud0dd\uc9c0\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub294 \uc624\ub85c\uc9c0 vanilla\ub85c \uc81c\uacf5\ub418\ub294 \uc0c1\ud0dc\uc5d0\uc11c \uc9c1\uc811 \uc81c\uc5b4\ud558\uae30\ub85c \uacb0\uc815\ud558\uc600\uace0, \ub9c8\ucee4\ub97c \uad00\ub9ac\ud558\ub294 \uc8fc\uccb4 \ub610\ud55c \uad6c\uae00 \uc9c0\ub3c4\uc5d0\uc11c \uc9c1\uc811 \ucee8\ud2b8\ub864\uc744 \ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c \uad6c\uae00 \uc9c0\ub3c4\ub97c \ud638\ucd9c\ud558\ub294 \uc791\uc5c5\uc740 @googlemaps/react-wrapper\uc5d0 \ub9e1\uae30\uace0, \ubd88\ub7ec\uc628 \uad6c\uae00 \uc9c0\ub3c4\ub294 vanilla\ub85c \ud1b5\uc81c\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c0\ub3c4\uc758 \uc870\uc791, \uc9c0\ub3c4\uc5d0 \ub9c8\ucee4\ub97c \ucc0d\ub294 \uacfc\uc815\uc744 \ubaa8\ub450 `\uacf5\uc2dd \ubb38\uc11c\uc5d0 \ub098\uc640\uc788\ub294 \ubc29\ubc95\ub300\ub85c \ud1b5\uc81c`\ud558\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\uae30\uc874\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc740 \ub9c8\ucee4\ub098 \uc9c0\ub3c4\ub97c \ucef4\ud3ec\ub10c\ud2b8\ud654 \ud55c \uc0c1\ud0dc\uc774\uae30\uc5d0 \ucd5c\uc801\ud654 \uacfc\uc815\uc5d0\uc11c \uc800\ud76c\uac00 \uc81c\uc5b4\ud560 \uc218 \uc5c6\ub294 \ubd80\ubd84\ub4e4\uc774 \uc788\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4. \ub530\ub77c\uc11c \ud2b8\ub7ec\ube14\uc288\ud305 \uacfc\uc815\uc5d0\uc11c \ub9c8\ucee4\uc758 \ud638\ucd9c \uc2dc\uc810, \uba54\ubaa8\ub9ac\uc5d0\uc11c \ud574\uc81c\ud558\ub294 \uc2dc\uc810, \ub80c\ub354\ub9c1\ud558\ub294 \uc2dc\uc810 \ub4f1\uc758 \uc791\uc5c5\ub4e4\uc744 \ud6e8\uc52c \ub354 \uc138\ubc00\ud558\uac8c \ud558\ub824\uba74 google maps api\uc744 \uc788\ub294 \uadf8\ub300\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc9c0\ub3c4\uc5d0 \uad00\ub828\ub41c \uae30\ub2a5\uc740 react DOM \uc704\uc5d0\uc11c\uac00 \uc544\ub2cc vanilla \ud658\uacbd\uc5d0\uc11c \uc791\uc5c5\uc744 \ud560 \uac83\uc785\ub2c8\ub2e4.\\n\\n# \uad6c\uae00 \uc9c0\ub3c4 \uc81c\uc5b4 \uc804\ub7b5\\n\\n1. \uad6c\uae00 \uc9c0\ub3c4\uc640 \ub9c8\ucee4\ub294 \ud56d\uc0c1 \ubc14\ub2d0\ub77c \ud658\uacbd(react DOM \ubc14\uae65)\uc5d0\uc11c \ub3d9\uc791\ud558\uac8c \ud55c\ub2e4.\\n2. \ubc14\ub2d0\ub77c \ud658\uacbd\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud558\uac8c \ud558\uc5ec \ub9ac\uc561\ud2b8 \ucef4\ud3ec\ub10c\ud2b8\uc5d0\uc11c\uc758 \uc7ac \ub80c\ub354\ub9c1\uc744 \uc77c\uc808 \ubc29\uc9c0\ud55c\ub2e4.\\n3. \ub9c8\ucee4\ub098 \uc9c0\ub3c4\uc758 \ub3d9\uc791 \uc774\ubca4\ud2b8\uc5d0 \uc758\ud574 UI\ub97c \uc870\uc791\ud574\uc57c\ud558\ub294 \uacbd\uc6b0\uc5d0\ub294 react DOM \uc870\uc791\uc744 \ud558\ub3c4\ub85d \ud55c\ub2e4.\\n4. \ubc14\ub2d0\ub77c \ud658\uacbd\uc778 google maps api\uc640 react DOM \uc0ac\uc774\uc758 \uc81c\uc5b4 \uacfc\uc815\uc5d0\ub294 useSyncExternalStore \ud6c5\uc744 \uc774\uc6a9\ud558\uc5ec \ub9ac\uc561\ud2b8 UI\ub97c \uac15\uc81c\ub85c \ub3d9\uae30\ud654 \uc2dc\ud0ac \uc218 \uc788\ub3c4\ub85d \ud55c\ub2e4.\\n\\n\uad6c\uae00 \uc9c0\ub3c4\ub294 \ubc14\ub2d0\ub77c \ud658\uacbd\uc5d0\uc11c, \uac01\uc885 UI \ud1b5\uc81c\ub294 \ub9ac\uc561\ud2b8\uc5d0\uc11c \ud1b5\ud569\ud558\uc5ec \uc0ac\uc6a9\ud558\ub294 \ud658\uacbd\uc744 \uad6c\uc0c1\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc2dc\uc911\uc5d0 \ub098\uc640\uc788\ub294 \ub300\ubd80\ubd84\uc758 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub4e4\uc744 \ud65c\uc6a9\ud558\uc5ec \ube44\uad50\ud558\uace0 \ud14c\uc2a4\ud2b8\ud55c \uacb0\uacfc @googlemaps/react-wrapper\ub97c \uc120\ud0dd\ud558\ub294 \uac83\uc774 \ucd5c\uc801\ud654\uc640 \uc0dd\uc0b0\uc131, \uc571 \uc548\uc815\uc131\uc744 \ubaa8\ub450 \ud655\ubcf4\ud560 \uc218 \uc788\ub294 \uc120\ud0dd\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4.\\n\\n\ud604\uc7ac \uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\uc911\uc778 \uc9c0\ub3c4 \uc81c\uc5b4\uc5d0 \uad00\ud55c \ubc29\ubc95\uc740 \uc774\ud6c4\uc5d0 \uc791\uc131 \ub420 \uae00\uc5d0\uc11c \uc0c1\uc138\ud558\uac8c \uc124\uba85\ud558\uaca0\uc2b5\ub2c8\ub2e4."},{"id":"12","metadata":{"permalink":"/12","source":"@site/blog/2023-07-10-kiara-jasypt.mdx","title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","description":"\uc11c\ub860","date":"2023-07-10T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 10\uc77c","tags":[{"label":"jasypt","permalink":"/tags/jasypt"},{"label":"Spring","permalink":"/tags/spring"}],"readingTime":5.59,"hasTruncateMarker":false,"authors":[{"name":"\ud0a4\uc544\ub77c","title":"Backend","url":"https://github.com/kiarakim","imageURL":"https://github.com/kiarakim.png","key":"kiara"}],"frontMatter":{"slug":"12","title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","authors":["kiara"],"tags":["jasypt","Spring"]},"prevItem":{"title":"\uce74\ud398\uc778 \ud300\uc5d0\uc11c \uc0ac\uc6a9\ud558\ub294 \uc9c0\ub3c4 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud569\ub2c8\ub2e4.","permalink":"/11"},"nextItem":{"title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","permalink":"/9"}},"content":"## \uc11c\ub860\\n\\n\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 `\ud0a4\uc544\ub77c`\uc785\ub2c8\ub2e4.\\n\\n\uc774\ubc88 \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud558\uba74\uc11c \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\ub294 \ubc29\ubc95\uc73c\ub85c jasypt\ub97c \uc54c\uac8c\ub418\uc5b4\\n\\n\uc0ac\uc6a9\ud558\ub294 \ubc29\ubc95\uc744 \uc775\ud600 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc801\uc6a9\ud574\ubcfc \uacc4\ud68d\uc785\ub2c8\ub2e4.\\n\\n## \ud504\ub85c\ud37c\ud2f0 \uc554\ud638\ud654\ub294 \uc65c \ud544\uc694\ud560\uae4c?\\n\\n```java\\nspring:\\n datasource:\\n url: \ub370\uc774\ud130\ubca0\uc774\uc2a4 url\\n username: \uacc4\uc815\\n password: \ube44\ubc00\ubc88\ud638\\n```\\n\\n\ud504\ub85c\uc81d\ud2b8\ub97c \uc9c4\ud589\ud558\uba74\uc11c yml \ud30c\uc77c\uc5d0 DB \uc5f0\uacb0 URL\uc774\ub098 \uacc4\uc815, \ube44\ubc00\ubc88\ud638 \uac19\uc774 \ub178\ucd9c\ub418\uc5b4\uc120 \uc548 \ub418\ub294 \ubbfc\uac10\ud55c \uc815\ubcf4\ub4e4\uc774 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\\ngit\uc758 public repository\uc640 CI/CD\ub97c \uc5f0\ub3d9\ud574 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc744 \ubc30\ud3ec\ud55c\ub2e4\uba74 \uc911\uc694\ud55c \uc815\ubcf4\uac00 \ud0c8\ucde8\ub420 \uac00\ub2a5\uc131\uc774 \uc788\uc8e0.\\n\\nJasypt \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uba74 \ud3c9\ubb38\uc73c\ub85c \ub41c \ub370\uc774\ud130\ubca0\uc774\uc2a4 \uc811\uc18d \uc815\ubcf4\ub97c \uc554\ud638\ud654 \ud558\uc5ec \ubc29\uc5b4\ub9c9\uc744 \ud55c \uacb9 \uc313\uc744 \uc218 \uc788\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uac04\ub7b5\ud558\uac8c \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc18c\uac1c\ud558\uace0 \uc0ac\uc6a9 \ubc29\ubc95\uc744 \uc54c\uc544\ubcfc\uae4c\uc694?\\n\\n## jasypt\ub294 \ubb50\uc9c0?\\n\\nJasypt\uc774\ub780 \uc27d\uac8c \uc554\ud638\ud654 \uae30\ub2a5\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\ub3c4\ub85d \uc81c\uacf5\ud558\ub294 Java \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\\n\ubbfc\uac10\ud55c \ud3c9\ubb38 \uc815\ubcf4\ub97c \uc554\ud638\ud654\ud558\uace0, \uc544\ub798\ucc98\ub7fc \uc124\uc815 \uac12\uc744 \uc9c0\uc815\ud558\uba74 \uc5b4\ud50c\ub9ac\ucf00\uc774\uc158\uc774 \uc2e4\ud589\ub420 \ub54c \uc790\ub3d9\uc73c\ub85c \uc774\ub97c \ubcf5\ud638\ud654\ud558\uc5ec \uc0ac\uc6a9\ud569\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\uac00 \ud3b8\ud558\uac8c \uc554\ud638\ud654 \uae30\ub2a5\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc788\ub3c4\ub85d \uc81c\uacf5\ud558\ub294 Java \ub77c\uc774\ube0c\ub7ec\ub9ac\ub85c\\n\\n\uacf5\uc2dd \ud648\ud398\uc774\uc9c0\ub294 http://www.jasypt.org/ \uc5d0 \uac00\uba74 \ub354 \uc790\uc138\ud55c \uc815\ubcf4\ub97c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc0ac\uc6a9 \ubc29\ubc95\\n\\n\uc815\ub9d0 \uac04\ub2e8\ud558\uac8c \ub77c\uc774\ube0c\ub7ec\ub9ac \ucd94\uac00, key\uac12 \ub118\uaca8\uc8fc\uae30, \uc554\ud638\ud654 \uc138 \uac00\uc9c0 \ub2e8\uacc4\ub85c \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc5ec \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### 1. \ub77c\uc774\ube0c\ub7ec\ub9ac \ucd94\uac00 (= \uc758\uc874\uc131 \ucd94\uac00)\\n\\n```java\\nimplementation \\"com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3\\"\\n```\\n\\n### 2. Jasypt \uc124\uc815 \ubc0f Bean \ub4f1\ub85d\\n\\nkey\ub97c \uc0ac\uc6a9\ud574\uc11c Bean\uc744 \ub4f1\ub85d\ud558\ub294 \uae30\ubcf8 \uc124\uc815\uc785\ub2c8\ub2e4. \uc5ec\uae30\uc11c Bean\uc758 \uc774\ub984\uc744 jasyptEncryptor\ub77c\uace0 \uc124\uc815\ud588\ub2e4\uba74 \ud504\ub85c\ud37c\ud2f0 \ub4f1\ub85d\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n```java\\n@Configuration\\npublic class JasyptConfig {\\n\\n private String ENCRYPT_KEY = \\"hello\\";\\n\\n @Bean(name = \\"jasyptEncryptor\\")\\n public StringEncryptor stringEncryptor() {\\n PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();\\n\\n SimpleStringPBEConfig config = new SimpleStringPBEConfig();\\n\\n config.setPassword(ENCRYPT_KEY);\\n config.setAlgorithm(\\"PBEWithMD5AndDES\\");\\n config.setKeyObtentionIterations(1000);\\n config.setPoolSize(1);\\n config.setSaltGeneratorClassName(\\"org.jasypt.salt.RandomSaltGenerator\\");\\n config.setStringOutputType(\\"base64\\");\\n encryptor.setConfig(config);\\n return encryptor;\\n }\\n}\\n```\\n\\n```java\\njasypt:\\n encryptor:\\n bean: jasyptEncryptor\\n```\\n\\n### 3. \uc554\ud638\ud654\\n\\n\ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud560 \uc900\ube44\ub294 \uac70\uc758 \ub2e4 \ub05d\ub0ac\uc2b5\ub2c8\ub2e4. \uc774\uc81c \uc554\ud638\ud654\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\uc5d0 \uc791\uc131\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub54c \uc554\ud638\ud654 \ud558\ub294 \ubc29\ubc95\uc740, \uc544\ub798 \uc0ac\uc774\ud2b8\uc5d0 \uc811\uc18d\ud574 \ud3c9\ubb38\uacfc \ud0a4\ub97c \uc785\ub825\ud55c \ud6c4 \ub098\uc628 \uc554\ud638\ubb38\uc744 \ud504\ub85c\ud37c\ud2f0 \ud30c\uc77c\uc5d0 \'ENC(\uc554\ud638\ubb38)\' \ub85c \uc791\uc131\ud569\ub2c8\ub2e4.\\n\\n[\uc554\ubcf5\ud638\ud654 \uc0ac\uc774\ud2b8](https://www.devglan.com/online-tools/jasypt-online-encryption-decryption)\\n\\n![\ud3c9\ubb38](https://github.com/kiarakim/algorithm/assets/101039161/b0293dfc-e0d8-45a0-91af-becf790a1002)\\n\\n```java\\n datasource:\\n url: \ub370\uc774\ud130\ubca0\uc774\uc2a4 url\\n username: \uacc4\uc815\\n password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)\\n```\\n\\n\ub098\uba38\uc9c0\ub3c4 \ub9c8\uc800 \uc554\ud638\ud654\ud574\uc90d\uc2dc\ub2e4.\\n\\n```java\\n datasource:\\n url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)\\n username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)\\n password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)\\n```\\n\\n## \uc2e4\ud589\\n\\n\uc62c\ubc14\ub978 \uc554\ud638\ubb38\uc744 \uc785\ub825\ud588\ub2e4\uba74 \uc815\uc0c1\uc801\uc73c\ub85c \uc2e4\ud589\uc774 \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\ub098 \uc774\ub54c \uc784\uc758\ub85c \uc554\ud638\ubb38\uc744 \uc218\uc815\ud55c\ub2e4\uba74 \ub2e4\uc74c\uacfc \uac19\uc774 \ube4c\ub4dc\ub97c \uc2e4\ud328\ud569\ub2c8\ub2e4.\\n\\n![\uc2e4\ud589 \uc2e4\ud328](https://github.com/kiarakim/algorithm/assets/101039161/d003df00-bf4f-4ed2-a1ee-293cd7da6fc1)\\n\\n\uadf8\ub7f0\ub370 \ubb54\uac00 \uc774\uc0c1\ud558\uc9c0 \uc54a\ub098\uc694?\\n\\n\ud504\ub85c\ud37c\ud2f0\ub294 \ubd84\uba85 \uc554\ud638\ud654 \ud588\ub294\ub370 \ud0a4\uac00 \ucf54\ub4dc\uc5d0 \uadf8\ub300\ub85c \ub178\ucd9c\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\nGit\uc758 public Repository\uc5d0 \ubc30\ud3ec\ud558\uba74 \ub2e4\ub978 \uc0ac\ub78c\ub4e4\ub3c4 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc \uc774 \ud0a4\ub97c \uc5b4\ub514\uc5d0 \uc228\uae38 \uc218 \uc788\uc744\uae4c\uc694?\\n\\n\uc800\ub294 \ucc98\uc74c\uc5d0 \uc77c\ubc18 file\uc5d0 \ud0a4\ub97c \ub123\uc5b4\ub193\uace0 \ud30c\uc77c\uc744 \uc77d\uc5b4\uc624\ub294 \uc2dd\uc73c\ub85c \ud0a4\ub97c \uad00\ub9ac\ud558\ub824\uace0 \ud588\uc2b5\ub2c8\ub2e4. \ub2f9\uc5f0\ud788 \ud574\ub2f9 \ud30c\uc77c\uc740 .gitignore\ub85c \ucee4\ubc0b \ub300\uc0c1\uc5d0\uc11c \uc81c\uc678\ud574\uc57c\uaca0\uc8e0.\\n\\n\uadf8\ub7f0\ub370 \uc774\uac83\ubcf4\ub2e4 \ub354 \uc27d\uace0 \ube60\ub978 \ubc29\ubc95\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ubc14\ub85c \ud658\uacbd\ubcc0\uc218\ub97c \uc124\uc815\ud558\ub294 \uac83\uc774\uc8e0.\\n\\n### + \ud658\uacbd\ubcc0\uc218 \uc124\uc815\\n\\n```java\\nprivate String ENCRYPT_KEY = \\"hello\\";\\n```\\n\uae30\uc874\uc758 \ud0a4\ub97c \uad00\ub9ac\ud558\ub294 \ubc29\uc2dd\uc774\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc6b0\uc120 \uc774 \ud0a4\ub97c \ud504\ub85c\ud37c\ud2f0\uc5d0\uc11c \uad00\ub9ac\ud558\ub3c4\ub85d \uc124\uc815\ud574\ubcfc\uae4c\uc694?\\n\\n```java\\n// JasyptConfig.class\\n@Value(\\"${jasypt.encryptor.password}\\")\\n private String ENCRYPT_KEY;\\n```\\n```java\\n// application.yml\\njasypt:\\n encryptor:\\n password: hello\\n```\\n\\n\uc774\uc81c \ud658\uacbd\ubcc0\uc218\ub97c \uc124\uc815\ud574\ubd05\uc2dc\ub2e4.\\n\\nRun > Edit Configurations... \uacbd\ub85c\ub85c \ub4e4\uc5b4\uac00\uba74\\n\\nRun/Debug Configurations \ucc3d\uc774 \ub098\uc624\ub294\ub370\\n\\nEnvironment variables: \ubd80\ubd84\uc5d0 ENCRYPT_KEY=hello\\n\\n\ub77c\uace0 \uc801\uc5b4\uc8fc\uc138\uc694.\\n\\n\uadf8 \ud6c4 \ub2e4\uc2dc yml \ud30c\uc77c\ub85c \ub3cc\uc544\uc640 \uae30\uc874 hello\ub85c \ub418\uc5b4\uc788\ub294 \ubd80\ubd84\uc744 ${ENCRYPT_KEY}\ub85c \ubcc0\uacbd\ud558\uace0 \uc2e4\ud589\ud55c\ub2e4\uba74 \uc815\uc0c1\uc801\uc73c\ub85c \uc791\ub3d9\ub429\ub2c8\ub2e4.\\n\\n```java\\njasypt:\\n encryptor:\\n password: ${ENCRYPT_KEY}\\n```\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4."},{"id":"9","metadata":{"permalink":"/9","source":"@site/blog/2023-07-09-github_actions_pull_request_test.mdx","title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","description":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.","date":"2023-07-09T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 9\uc77c","tags":[{"label":"github","permalink":"/tags/github"},{"label":"action","permalink":"/tags/action"},{"label":"pr","permalink":"/tags/pr"},{"label":"test","permalink":"/tags/test"}],"readingTime":8.985,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"9","title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","authors":["boxster"],"tags":["github","action","pr","test"]},"prevItem":{"title":"jasypt\ub97c \ud65c\uc6a9\ud558\uc5ec \ud504\ub85c\ud37c\ud2f0\ub97c \uc554\ud638\ud654\ud558\uc790","permalink":"/12"},"nextItem":{"title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","permalink":"/10"}},"content":"\uc548\ub155\ud558\uc138\uc694 \ubc15\uc2a4\ud130\uc785\ub2c8\ub2e4.\\n## Pull Request\uc2dc \uc790\ub3d9\uc73c\ub85c test\ub97c \uc2e4\ud589\ud558\uba74 \uc88b\uc740 \uc810\\npull request \uc0dd\uc131 \uc2dc \uc790\ub3d9\uc73c\ub85c \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub824\uc900\ub2e4\uba74 \ub2e4\ub978 \ud300\uc6d0\uc758 pr\uc744 \uad73\uc774 \uc81c \ub85c\uceec\uc5d0 clone\ud558\uc5ec \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub824\ubcf4\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.\\n\ub9ce\uc740 \uc2dc\uac04\uc744 \ub2e8\ucd95\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 test\uac00 \uc2e4\ud328\ud55c\ub2e4\uba74 \uac15\uc81c\ub85c Merge\uac00 \ub418\uc9c0 \uc54a\ub3c4\ub85d \ud55c\ub2e4\uba74 \uc2e4\uc218\ub85c \ud14c\uc2a4\ud2b8\uac00 \ub418\uc9c0 \uc54a\ub294 \ucee4\ubc0b\uc744 \uc62c\ub9ac\ub294 \uac83\uc744 \ubc29\uc9c0\ud560 \uc218 \uc788\uaca0\uc8e0.\\n\\n\uc774 \ub450\uac00\uc9c0\ub9cc\uc73c\ub85c\ub3c4 \uc0dd\uc0b0\uc131\uc774 \ub9ce\uc774 \uc62c\ub77c\uac08 \uac83\uc744 \uae30\ub300\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uc5b4\ub5bb\uac8c \ud560 \uc218 \uc788\ub098\uc694\\n\\nGithub Action\uc744 \uc774\uc6a9\ud558\uc5ec \uc124\uc815\ud55c \uc870\uac74\uc5d0 \ub9de\ub294 \uc0c1\ud669\uc5d0\uc11c \uba85\ub839\uc5b4\ub97c \uc2e4\ud589\ud558\uc5ec test\ub97c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### Github Action \ud30c\uc77c \uc0dd\uc131\\n\\n1. \uba3c\uc800 \ucd5c\uc0c1\uc704 \ud3f4\ub354\uc5d0 `.github/workflows` \ud3f4\ub354\ub97c \uc0dd\uc131\ud569\ub2c8\ub2e4.\\n2. \ud574\ub2f9 \ud3f4\ub354 \ub0b4\uc5d0 `example.yml`\uc744 \uc0dd\uc131\ud569\ub2c8\ub2e4.\\n3. \uc544\ub798\uc640 \uac19\uc774 yml \ud30c\uc77c\uc744 \uc791\uc131\ud569\ub2c8\ub2e4.\\n\\n```yml\\nname: pr test\\n\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n\\npermissions:\\n contents: read\\n\\njobs:\\n test:\\n name: merge-test\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./backend\\n steps:\\n - uses: actions/checkout@v3\\n - name: Set up JDK 17\\n uses: actions/setup-java@v3\\n with:\\n java-version: \'17\'\\n distribution: \'adopt\'\\n - name: Grant execute permission for gradlew\\n run: chmod +x gradlew\\n - name: Test with Gradle\\n run: ./gradlew build\\n```\\n\\n### Job \uc774\ub984 \uc124\uc815\\n\ubcf5\uc7a1\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uba3c\uc800 **name** \uc18d\uc131\uc740 github action\uc5d0\uc11c \ubcf4\uc5ec\uc9c8 Job\uc758 \uc774\ub984\uc744 \uc815\ud558\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4.\\n\\n\uc9c0\uae08\uc740 `pr test`\ub85c \ud574\ub450\uc5c8\uc2b5\ub2c8\ub2e4. \uadf8\ub7fc \uc544\ub798 \uc0ac\uc9c4\uacfc \uac19\uc774 \ubc18\uc601\ub429\ub2c8\ub2e4.\\n\\n![workflows name](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/28494d8e-66b5-4eec-a98a-414968b03306)\\n\\n### workflow \ud2b8\ub9ac\uac70 \uc124\uc815\\n\ub2e4\uc74c\uc73c\ub860 `on` \uc18d\uc131\uc785\ub2c8\ub2e4. \uc774 \uc18d\uc131\uc740 workflow\ub97c \uc2e4\ud589\ud560 \uc774\ubca4\ud2b8\ub97c \uc9c0\uc815\ud558\ub294\ub370 \uc0ac\uc6a9\ub429\ub2c8\ub2e4. \ud2b9\uc815 \uc774\ubca4\ud2b8 \uc720\ud615\uacfc \uc870\uac74\uc744 \uae30\ubc18\uc73c\ub85c workflow\ub97c \ud2b8\ub9ac\uac70\ud558\ub3c4\ub85d \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc608\ub97c \ub4e4\uc5b4 \uc544\ub798\uc640 \uac19\uc774 \uc815\uc758\ud588\uc2b5\ub2c8\ub2e4.\\n```yml\\non:\\n push:\\n branches:\\n - main\\n pull_request:\\n branches:\\n - develop\\n```\\n\uadf8\ub807\ub2e4\uba74 \uc774 workflow\uac00 \uc791\ub3d9\ub418\ub294 \uc2dc\uc810\uc740 `main` \ube0c\ub79c\uce58\uc5d0 **push**\uac00 \ub418\uac70\ub098 `develop` \ube0c\ub79c\uce58\uc5d0 **pull request**\ub97c \ubcf4\ub0bc \ub54c \uc791\ub3d9\ud569\ub2c8\ub2e4.\\n\\n### \uad8c\ud55c \ubd80\uc5ec\\n```yml\\npermissions:\\n contents: read\\n```\\n\uc774\ub7f0 \uad8c\ud55c\uc744 \uc8fc\uac8c \ub41c\ub2e4\uba74 \uc774 job\uc740 \uc77d\uae30 \uad8c\ud55c\ubc16\uc5d0 \uc5c6\uae30 \ub54c\ubb38\uc5d0 \uc2e4\uc218\ub85c \ub2e4\ub978 \uac83\uc744 \ucd94\uac00\ud558\uc9c0 \ubabb\ud558\uac8c \ub9c9\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n### \ub3d9\uc791\ud560 \uba85\ub839\uc5b4 \uc785\ub825\\n```yml\\njobs:\\n test:\\n name: merge-test\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./backend\\n steps:\\n - uses: actions/checkout@v3\\n - name: Set up JDK 17\\n uses: actions/setup-java@v3\\n with:\\n java-version: \'17\'\\n distribution: \'adopt\'\\n - name: Grant execute permission for gradlew\\n run: chmod +x gradlew\\n - name: Test with Gradle\\n run: ./gradlew build\\n```\\n\\n#### name\\n\uc81c\uc77c \uac04\ub2e8\ud788 \ubcfc \uc218 \uc788\ub294 **name** \uc124\uc815\uc740 \uc544\ub798 \uc0ac\uc9c4\ucc98\ub7fc \uc5b4\ub5a4\uc2dd\uc73c\ub85c \ubcf4\uc5ec\uc904\uc9c0 \uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n![job image](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/43ebf3c1-4632-447f-89c3-0e74ed01dc3c)\\n\\n#### runs-on\\n**runs-on** \uc18d\uc131\uc785\ub2c8\ub2e4. \ud574\ub2f9 \uc6b4\uc601\uccb4\uc81c\ub97c \uc0ac\uc6a9\ud55c\ub2e4\uace0 \uc815\uc758\ud558\ub294 \ubd80\ubd84\uc785\ub2c8\ub2e4. \uc9c0\uae08\uc740 \uc800\ud76c\uac00 \uc0ac\uc6a9\ud560 ec2\uc640 \uac19\uc740 \ud658\uacbd\uc778 `ubuntu`\uc5d0\uc11c \uc791\ub3d9\ud558\ub3c4\ub85d \uc124\uc815\ud588\uc9c0\ub9cc,\\n`windows-latest`, `macos-latest`\ub85c \ubcc0\uacbd\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### environment\\n**environment** \uc18d\uc131\uc785\ub2c8\ub2e4. \ud574\ub2f9 \uc18d\uc131\uc740 \uaf2d \ud544\uc694\ud55c \ubd80\ubd84\uc774 \uc544\ub2c8\uc9c0\ub9cc branch\uc758 rule \uc124\uc815\uc5d0 \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \ud658\uacbd\uc744 \ud55c\uaebc\ubc88\uc5d0 \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \ubd80\ubd84\uc740 \uc544\ub798\uc5d0 branch rule\uc744 \uc815\ud558\ub294 \ubd80\ubd84\uc744 \ubcf4\uc2dc\uba74 \uc544\ub9c8 \uc774\ud574\uac00 \ub420 \uac83 \uc785\ub2c8\ub2e4.\\n\\n#### defaults\\n\ud574\ub2f9 \uc18d\uc131\uc740 \uc5b4\ub5a4 \ud3f4\ub354\uc5d0\uc11c \uba85\ub839\uc5b4\ub97c \uc2e4\ud589\ud560 \uc9c0 \uc9c0\uc815\ud569\ub2c8\ub2e4. \uc9c0\uae08\uc758 \uc800\ud76c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c\ub294 \ud55c repository\uc5d0 backend, frontend \ud3f4\ub354\ub97c \ub098\ub204\uc5c8\uae30 \ub54c\ubb38\uc5d0 backend \ud3f4\ub354\ub85c \uc774\ub3d9\ud558\uc5ec \uba85\ub839\uc5b4\ub97c \uc2e4\ud589\ud574\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub798\uc11c **working-directory**\ub97c `./backend`\ub77c\uace0 \uc9c0\uc815\ud588\uc2b5\ub2c8\ub2e4.\\n\\n#### steps\\n\\n\uc81c\uc77c \uc911\uc694\ud55c **steps**\uc785\ub2c8\ub2e4. \ud574\ub2f9 \uc18d\uc131\uc740 \uc5b4\ub5a4 \uba85\ub839\uc5b4\ub97c \uc5b4\ub5a4 \uc21c\uc11c\ub85c \uc2e4\ud589\uc2dc\ud0ac\uc9c0 \uc815\uc758\ud569\ub2c8\ub2e4. \uc9c0\uae08\uc758 workflow\uc5d0\uc120\\n\\n1. Java 17 \uc124\uce58\\n2. gradlew \ud30c\uc77c\uc5d0 \uc2e4\ud589 \uad8c\ud55c \ubd80\uc5ec\\n3. gradle build \uc2e4\ud589\\n\\n\uc21c\uc73c\ub85c \ub3d9\uc791\ud569\ub2c8\ub2e4.\\n\\n### \ub2e4\ub978 \uc870\uac74\uacfc \uc774\ubca4\ud2b8\ub3c4 \ucd94\uac00\ud558\uace0 \uc2f6\uc5b4\uc694\\n\\n\uc800\ud76c \ud504\ub85c\uc81d\ud2b8\ub294 \ud558\ub098\uc758 repository\uc5d0\uc11c frontend, backend \ucf54\ub4dc\ub97c \uac19\uc774 \uad00\ub9ac\ud558\ub294 \uc0c1\ud669\uc785\ub2c8\ub2e4. \ud558\uc9c0\ub9cc frontend \ucf54\ub4dc\ub97c \uc218\uc815\ud588\ub2e4\uace0 java \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub9ac\ub294 \uac83\uc740 \uc624\ud788\ub824 \uc0dd\uc0b0\uc131\uc774 \uc904\uc5b4\ub4e4\uaca0\uc8e0.\\n\\n\uadf8\ub9ac\uace0 frontend\ub3c4 \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub9ac\uace0 \uc2f6\uc9c0\ub9cc gradle\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7f4 \ub54c \uac04\ub2e8\ud55c \uc18d\uc131\uc744 \ucd94\uac00\ud558\uba74 **\ud30c\uc77c\uc758 \ubcc0\uacbd\uc5d0 \ub530\ub77c \ud574\ub2f9 job\uc744 \uc2e4\ud589\ud560 \uc870\uac74**\uc744 \uc815\uc758\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```yml\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n paths:\\n - backend/**\\n - .github/**\\n```\\n\uc704\uc640 \ub2ec\ub9ac \uc9c0\uae08 **pull request**\uc5d0\ub294 \uc18d\uc131\uc774 \ud558\ub098\uac00 \ub354 \uc788\ub294\ub370\uc694. **paths**\ub97c \uc801\uc6a9\ud558\uba74 `backend` \ud3f4\ub354 \ud558\uc704\uc758 \ubb34\uc5b8\uac00 \ubcc0\uacbd\uc774 \uc788\ub294 **pull request**\uc5d0\ub9cc \uc791\ub3d9\uc744 \ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub7fc backend\uc758 workflow \ud30c\uc77c\uc5d0 **paths** \uc18d\uc131\uc744 \ud558\ub098 \ucd94\uac00\ud558\uace0, \ube44\uc2b7\ud55c frontend workflow\ub97c \ub9cc\ub4e4\uc5b4\uc8fc\uba74 \ub418\uaca0\uc8e0.\\n```yml\\nname: frontend test\\n\\non:\\n pull_request:\\n branches:\\n - main\\n - develop\\n paths:\\n - frontend/**\\n\\npermissions:\\n contents: read\\n\\njobs:\\n test:\\n name: jest\\n runs-on: ubuntu-latest\\n environment: test\\n defaults:\\n run:\\n working-directory: ./frontend\\n steps:\\n - uses: actions/checkout@v3\\n - name: NPM Install\\n run: npm i\\n - name: Jest run\\n run: npm run test\\n```\\n\uc774\ub7f0 \uc2dd\uc73c\ub85c yml \ud30c\uc77c\uc744 \ud558\ub098 \ucd94\uac00\ud558\uba74 **frontend\uc758 \uc218\uc815\uc774 \uc77c\uc5b4\ub0a0 \ub54c\ub294 jest**\ub97c \uc2e4\ud589\ud558\uace0, **backend \ud3f4\ub354\uc758 \uc218\uc815\uc774 \uc77c\uc5b4\ub098\uba74 gradlew**\ub97c \uc2e4\ud589\ud558\uac8c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## Test\uac00 \uc2e4\ud328\ud558\ub294 PR\uc740 Merge \ub9c9\uae30\\n\\nTest\uac00 \uc2e4\ud328\ud558\ub294 Pull Request\uac00 Merge \ub418\ub294 \uc77c\uc740 \uc808\ub300\ub85c \uc5c6\uc5b4\uc57c \ud569\ub2c8\ub2e4. \uadf8\ub7f0 \uc2e4\uc218\ub97c \ubc29\uc9c0\ud558\ub824\uba74 \ud300\uc6d0 \uc804\ubd80\uac00 \ub9ac\ubdf0\ud560 \ub54c \ud14c\uc2a4\ud2b8\ub97c \ub3cc\ub824\ubd10\uc57c\ud558\ub294 \uadc0\ucc2e\uc74c\uc774 \uc0dd\uae38 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc0ac\ub78c\uc740 \uc2e4\uc218\ud574\ub3c4 \uae30\uacc4\ub294 \uac70\uc9d3\ub9d0\uc744 \ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc790\ub3d9\uc73c\ub85c \ub9c9\ub3c4\ub85d \ub3d9\uc791\ud558\uac8c \ub9cc\ub4e4\uc5b4\ub193\uc73c\uba74 \uadf8\ub7f4 \uc77c\uc744 \ubbf8\uc5f0\uc5d0 \ubc29\uc9c0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### Environments \ud655\uc778\ud558\uae30\\n\uba3c\uc800 \ud574\ub2f9 Repository\uc758 Settings -> Environments \ud0ed\uc73c\ub85c \ub4e4\uc5b4\uac11\ub2c8\ub2e4.\\n![environments](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/4e3e867a-1037-46bc-865a-6d7d52527518)\\n\uc544\uae4c **environment** \uc18d\uc131\uc744 \ubcf4\uba74 `test`\ub77c\uace0 \uc124\uc815\ud574\ub193\uc740 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \ud658\uacbd\uc774 \uc5ec\uae30\uc5d0 \uc801\uc6a9\ub429\ub2c8\ub2e4.\\n\\n### Branch rule \uc815\uc758\ud558\uae30\\n\uc774\ubc88\uc5d0\ub294 \ud574\ub2f9 Repository\uc758 Settings -> Branches \ud0ed\uc73c\ub85c \ub4e4\uc5b4\uac11\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc6d0\ud558\ub294 branch\uc5d0 \ub4e4\uc5b4\uac00 `edit` \ubc84\ud2bc\uc744 \ub204\ub985\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc0ac\uc9c4\uacfc \uac19\uc774 **Require deployments to succeed before merging** \uc18d\uc131\uc744 \ud074\ub9ad\ud569\ub2c8\ub2e4. \uadf8\ub9ac\uace0 \uc544\ub798\uc640 \uac19\uc774 \uc5b4\ub5a4 \ud658\uacbd\uc744 \uc801\uc6a9\ud560 \uac83\uc778\uc9c0 \uc120\ud0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774 \uc18d\uc131\uc740 \ud574\ub2f9 \ubc30\ud3ec\uac00 \uc131\uacf5\ud574\uc57c merge \ud560 \uc218 \uc788\ub3c4\ub85d \ube0c\ub79c\uce58\ub97c \ubcf4\ud638\ud558\ub294 \uae30\ub2a5\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0 \uc800\ud76c\ub294 frontend\uc640 backend Job\uc758 \ud658\uacbd\uc744 \ub458 \ub2e4 test\ub77c\ub294 \uc774\ub984\uc73c\ub85c \uc815\uc758\ud588\uae30 \ub54c\ubb38\uc5d0 \ud558\ub098\uc758 environment\ub9cc \uc120\ud0dd\ud574\ub3c4 \ub458 \ub2e4 \uc801\uc6a9\ub418\ub294 \ud6a8\uacfc\ub97c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n![branch rule](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/02be4679-56a2-4e47-ae01-b7025f8778a4)\\n\\n#### \uc801\uc6a9 \ud6c4\\n\\n\uc544\ub798\uc640 \uac19\uc774 merge\uac00 \uc548\ub41c\ub2e4\ub294 \uae00\uacfc \ube68\uac04\uc0c9\uc73c\ub85c \uacbd\uace0 \ud45c\uc2dc\ub97c \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n![blocked](https://github.com/car-ffeine/car-ffeine.github.io/assets/106640954/7dfba566-c8c8-4a24-a0e3-42081f3af31c)\\n\\n\\n## \uacb0\ub860\\n\\n\uac04\ub2e8\ud55c github action\uc744 \ud1b5\ud574\uc11c \uc0dd\uc0b0\uc131\uc744 \ub9ce\uc774 \uc62c\ub9b4 \uc218 \uc788\ub294 \uc88b\uc740 \uae30\ub2a5\uc778 \uac83 \uac19\uc2b5\ub2c8\ub2e4. \ub2e4\ub978 \ud300\ub4e4\ub3c4 \uc774 \uae30\ub2a5\uc744 \ub3c4\uc785\ud558\uc5ec \uc0ac\uc6a9\ud558\ub294 \uac83\uc744 \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4."},{"id":"10","metadata":{"permalink":"/10","source":"@site/blog/2023-07-09-msw-setup-with-webpack.mdx","title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","description":"\uc6f9\ud329\uc5d0\uc11c msw \uc124\uc815","date":"2023-07-09T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 9\uc77c","tags":[{"label":"msw","permalink":"/tags/msw"},{"label":"webpack","permalink":"/tags/webpack"}],"readingTime":4.35,"hasTruncateMarker":false,"authors":[{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"}],"frontMatter":{"slug":"10","title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","authors":["scent"],"tags":["msw","webpack"]},"prevItem":{"title":"Pull Request \uc2dc \uc790\ub3d9\uc73c\ub85c test \uc2e4\ud589\ud558\uae30","permalink":"/9"},"nextItem":{"title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","permalink":"/8"}},"content":"## \uc6f9\ud329\uc5d0\uc11c msw \uc124\uc815\\n\\n\uc774\ubc88 \ud300 \ud504\ub85c\uc81d\ud2b8\ub294 CRA\uc640 \uac19\uc740 \ubcf4\uc77c\ub7ec \ud50c\ub808\uc774\ud2b8 \ucf54\ub4dc\ub97c \uc0ac\uc6a9\ud558\uc9c0 \ubabb\ud558\uac8c \uc81c\ud55c\uc774 \uc788\ub2e4. \ub610\ud55c \uc694\uc998 \ub9ce\uc774 \uc0ac\uc6a9\ub41c\ub2e4\ub294 Vite\uc758 \uc0ac\uc6a9\ub3c4 \uc81c\ud55c\uc774 \uc788\uace0, \uc6f9\ud329\uc73c\ub85c \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud558\ub3c4\ub85d \uac15\uc81c\ud558\uace0 \uc788\ub2e4.\\n\\n\ud300\uc6d0 \ubaa8\ub450 \ud55c \ubc88\ub3c4 \uc6f9\ud329\uc744 \ud1b5\ud574 \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud574\ubcf8 \uacbd\ud5d8\uc774 \uc5c6\uc5b4 \ud504\ub860\ud2b8\uc5d4\ub4dc \ud300\uc6d0 \uac01\uc790 \uac1c\uc778 \ub808\ud3ec\uc5d0\uc11c \uc6f9\ud329 \uacf5\ubd80\ub97c \uc9c4\ud589\ud55c \ud6c4 \uc5b4\ub290\uc815\ub3c4 \uc9c4\ucc99\uc774 \uc788\uc744 \ub54c \ud300 \ub808\ud3ec\uc5d0 \ud504\ub85c\uc81d\ud2b8\ub97c \uc2dc\uc791\ud558\uae30\ub85c \ud588\ub2e4.\\n\\n\ub2e4\ud589\ud788 \uc6f9\ud329\uc73c\ub85c \uc2dc\uc791\ud558\ub294 \ud504\ub85c\uc81d\ud2b8\uc5d0 \ub300\ud55c \ub9ce\uc740 \ucc38\uace0 \uc790\ub8cc\ub4e4\uc774 \uc788\uc5b4 \uccab \ub9ac\uc561\ud2b8 \ud504\ub85c\uc81d\ud2b8 \ud654\uba74\uc744 \ub744\uc6b0\ub294\ub370 \uae4c\uc9c0\ub294 \uadf8\ub9ac \uc624\ub79c \uc2dc\uac04\uc774 \uac78\ub9ac\uc9c0 \uc54a\uc558\ub2e4. \uadf8\ub807\uac8c \ubaa8\ub4e0 \ud300\uc6d0\uc774 \uccab \uc6f9\ud329 \ud504\ub85c\uc81d\ud2b8\ub97c \uc131\uacf5\uc2dc\ud0a8 \ud6c4 \ubaa8\uc5ec \ud300 \ud504\ub85c\uc81d\ud2b8 \ucd08\uae30 \uc124\uc815\uc744 \uc2dc\uc791\ud574\ubcf4\uc558\ub2e4.\\n\\neslint, prettier, \uc6f9\ud329 \ub4f1\ub4f1 \uc5ec\ub7ec \uc124\uc815\ub4e4\uc744 \ud558\uace0 \ud544\uc694\ud55c \ud328\ud0a4\uc9c0\ub97c \uc124\uce58\ud558\ub294\ub370 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud588\ub2e4. \ud070 \ub370\uc774\ud130\ub97c \ub2e4\ub8e8\ub294 \ubc31\uc5d4\ub4dc\uc758 \uac1c\ubc1c \uc18d\ub3c4\ub97c \uace0\ub824\ud574 \ud504\ub860\ud2b8\uc5d4\ub4dc \uac1c\ubc1c\uc744 \uc9c4\ud589\ud558\uae30 \uc704\ud574\uc11c \ubbf8\uc158\uc911\uc5d0 \ubc30\uc6e0\ub358 MSW \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\uae30\ub85c \uacb0\uc815\ud588\ub294\ub370, \uc774 \ub77c\uc774\ube0c\ub7ec\ub9ac\uac00 \uc6b0\ub9ac \ud300\uc758 \uac1c\ubc1c \ud658\uacbd\uc5d0\uc11c \ub3d9\uc791\ud558\uc9c0 \uc54a\uc558\ub2e4.\\n\\n\uc65c \ub3d9\uc791\ud558\uc9c0 \uc54a\ub294\uc9c0 \uc6d0\uc778\uc744 \ucc3e\uc544\ubcf4\ub2c8 MSW service worker \ud30c\uc77c\uc744 \ucc3e\uc744 \uc218 \uc5c6\ub2e4\ub294 \uc624\ub958 \uba54\uc138\uc9c0\uac00 \ub098\uc624\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc5c8\ub2e4. \uc6d0\uc778\uc744 \ub354 \uc790\uc138\ud788 \uc54c\uc544\ubcf4\ub2c8 public \ud3f4\ub354\uc5d0 \uc788\ub294 \ud30c\uc77c\ub4e4\uc740 \uc6f9\ud329\uc774 \ubc88\ub4e4\ub9c1\uc744 \uc9c4\ud589\ud560 \ub54c \ud3ec\ud568\uc774 \ub418\uc9c0 \uc54a\ub294\ub2e4\ub294 \uac83\uc744 \uc54c \uc218 \uc788\uc5c8\uace0, \uc774\ub97c \uc5b4\ub5bb\uac8c \ud574\uacb0\ud560 \uc9c0 \ud300\uc6d0\ub4e4\uacfc \ubc29\ubc95\uc744 \ucc3e\uc544\ubcf4\uc558\ub2e4.\\n\\n\uc57d \ud55c\uc2dc\uac04\ucbe4 \uc9c0\ub0ac\uc744 \ubb34\ub835 copy-webpack-plugin \ud328\ud0a4\uc9c0\ub97c \ud1b5\ud574 public \uacbd\ub85c\uc5d0 \uc788\ub294 \ud30c\uc77c\ub4e4\ub3c4 \ube4c\ub4dc \ud3f4\ub354\uc5d0 \ud3ec\ud568\uc2dc\ud0ac \uc218 \uc788\ub2e4\ub294 \uac83\uc744 \uc54c\uac8c \ub418\uc5c8\ub2e4. \ud558\uc9c0\ub9cc \uc774 copy-webpack-plugin\uc5d0 \ub300\ud55c \uc0ac\uc6a9\ubc95\uc774 \ubbf8\uc219\ud574 public \ud3f4\ub354\uc5d0 \uc788\ub294 mockServiceWorker.js \ud30c\uc77c\ub9cc \ube4c\ub4dc \ud3f4\ub354\ub85c \uc62e\uacbc\uc5b4\uc57c \ud588\ub294\ub370 index.html\uacfc \uac19\uc740 \ub2e4\ub978 \ud30c\uc77c\ub4e4 \uae4c\uc9c0 \ud55c\uaebc\ubc88\uc5d0 \ube4c\ub4dc \ud3f4\ub354\ub85c \uc62e\uaca8\uc9c0\uac8c \ub418\uc5c8\ub2e4.\\n\\n\uc774\ub7f0 \uc800\ub7f0 \ubc29\ubc95\ub4e4\uc744 \uc2dc\ub3c4\ud574\ubcf4\ub2e4 webpack.config.js \ud30c\uc77c\uc758 plugins\uc5d0 \uc544\ub798\uc640 \uac19\uc740 \uc124\uc815\uc744 \ucd94\uac00 \ud574\uc8fc\uc5b4 MSW\ub97c \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc801\uc6a9\ud560 \uc218 \uc788\uac8c \ub418\uc5c8\ub2e4.\\n\\n```jsx\\nnew CopyWebpackPlugin({\\n patterns: [\\n { from: \'public/mockServiceWorker.js\', to: \'.\' }, // msw service worker\\n ],\\n}),\\n```\\n\\n\uc124\uc815\uc744 \uac04\ub2e8\ud788 \ubcf4\uba74 public \uacbd\ub85c\uc5d0 \uc788\ub294 mockServiceWorker.js \ud30c\uc77c\uc744 \ube4c\ub4dc \ud6c4 \ud3f4\ub354\uc758 \ub8e8\ud2b8 \ub514\ub809\ud1a0\ub9ac\uc5d0 \ucd94\uac00\ud574\uc900\ub2e4\ub294 \uc124\uc815\uc774\ub2e4.\\n\\n\ubb38\uc81c \uc0c1\ud669\uacfc \ud574\uacb0 \ubc29\ubc95\uc744 \uac04\ub2e8\ud558\uac8c \ub2e4\uc2dc \uc815\ub9ac\ud574\ubcf4\uba74 \ub2e4\uc74c\uacfc \uac19\ub2e4.\\n\\n1. MSW\ub97c \uc801\uc6a9\ud574\ubcf4\ub824\uace0 \ud568.\\n2. \uc6f9\ud329\uc5d0\uc11c \uac1c\ubc1c \uc11c\ubc84\ub97c \uc5f4\uc5c8\uc744 \ub54c MSW \uc2e4\ud589\uc744 \uc704\ud574 \ud544\uc694\ud55c mockServiceWorker.js \ud30c\uc77c\uc744 \ucc3e\uc744 \uc218 \uc5c6\ub2e4\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud568.\\n3. \ubb38\uc81c\uc758 \uc6d0\uc778\uc740 \uc6f9\ud329\uc5d0\uc11c \ubc88\ub4e4\ub9c1\uc744 \uc9c4\ud589\ud560 \ub54c public \ud3f4\ub354 \ud558\uc704 \uacbd\ub85c\uc5d0 \uc788\ub294 \ud30c\uc77c\ub4e4\uc744 \ubb34\uc2dc\ud558\uae30 \ub54c\ubb38\uc774\uc5c8\uc74c.\\n4. \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574 public \uacbd\ub85c\uc5d0 \uc788\ub294 mockServiceWorker.js \ud30c\uc77c\uc744 \ubc88\ub4e4\ub9c1 \ud6c4 \ud3f4\ub354\uc758 \ub8e8\ud2b8 \ub514\ub809\ud1a0\ub9ac\uc5d0 \uc800\uc7a5\ud558\ub3c4\ub85d \ud558\ub294 \uc124\uc815\uc744 \ucd94\uac00\ud574\uc90c."},{"id":"8","metadata":{"permalink":"/8","source":"@site/blog/2023-07-07-error-slack-notification.mdx","title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","description":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 nunu\uc785\ub2c8\ub2e4.","date":"2023-07-07T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 7\uc77c","tags":[{"label":"spring","permalink":"/tags/spring"},{"label":"slack","permalink":"/tags/slack"},{"label":"error","permalink":"/tags/error"}],"readingTime":11.83,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"8","title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","authors":["nunu"],"tags":["spring","slack","error"]},"prevItem":{"title":"webpack\uc73c\ub85c msw \uc124\uc815\ud558\uae30","permalink":"/10"},"nextItem":{"title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","permalink":"/7"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 nunu\uc785\ub2c8\ub2e4.\\n\\n\uc624\ub298\uc740 \uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4.\\n\\n\ubaa9\ucc28\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n1. \uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uadf8\ub97c \ub0a8\uae30\ub294 \ubc29\ubc95\\n2. Slf4 j\uc758 \ub3d9\uc791\uc6d0\ub9ac\\n3. Logback\uc758 \ub3d9\uc791\uc6d0\ub9ac\\n4. Logback\uc744 \uc0ac\uc6a9\ud574\uc11c \uc2ac\ub799\uc73c\ub85c \uc5d0\ub7ec \ub85c\uadf8\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95\\n\\n## \uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uadf8\ub294 \uc5b4\ub5bb\uac8c \ucc0d\uc744\uae4c?\\n\\n\uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uadf8\ub97c \ucc0d\ub294 \ubc29\ubc95\uc740 \uc5ec\ub7ec \uac00\uc9c0\uac00 \uc788\uc9c0\ub9cc, \uac00\uc7a5 \uac04\ub2e8\ud55c \ubc29\ubc95\uc740 `System.out.println()`\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc785\ub2c8\ub2e4.\\n\\n```java\\n@RestController\\npublic class TestController {\\n\\n @GetMapping(\\"/test\\")\\n public String test() {\\n System.out.println(\\"test\\");\\n return \\"test\\";\\n }\\n}\\n```\\n\\n\ub2f9\uc5f0\ud558\uc9c0\ub9cc, \uc131\ub2a5\uc774 \uc548 \uc88b\uc544\uc11c \uc2e4\uc81c \uc11c\ube44\uc2a4\uc5d0\uc11c\ub294 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n\uc2a4\ud504\ub9c1\uc5d0\uc11c\ub294 Slf4 j\ub97c \ud1b5\ud574\uc11c \ub85c\uadf8\ub97c \ub0a8\uae38 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n```java\\n@Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); \uc640 \uac19\ub2e4.\\n@RestController\\npublic class TestController {\\n\\n @GetMapping(\\"/test\\")\\n public String test() {\\n log.info(\\"test\\");\\n return \\"test\\";\\n }\\n}\\n```\\n\\n\uc774 \ucf54\ub4dc\ub97c \ud1b5\ud574\uc11c \ub85c\uadf8\ub97c \ub0a8\uae38 \uc218 \uc788\ub294\ub370, \uc790\ub3d9\uc73c\ub85c \ucf58\uc194\uc5d0 \ucd9c\ub825\uc774 \ub429\ub2c8\ub2e4.\\n\\n## \uc2a4\ud504\ub9c1\uc5d0\uc11c \ub85c\uae45\uc740 \uc5b4\ub5bb\uac8c \uc791\ub3d9\ud558\ub294 \uac70\uc9c0?\\n\\n\uc2a4\ud504\ub9c1 4\uae4c\uc9c0\ub294 `Commons Logging`\uc744 \uc0ac\uc6a9\ud588\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n`Commons Logging`\uc740 `JCL`\uc774\ub77c\uace0\ub3c4 \ubd88\ub9ac\uba70, `JDK Logging`, `Log4 j,` `Logback` \ub4f1 \ub2e4\uc591\ud55c \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc9c0\uc6d0\ud569\ub2c8\ub2e4.\\n\\nJCL \uc740 \ub7f0\ud0c0\uc784\uc5d0 \uc5b4\ub5a4 \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub7f0\ud0c0\uc784\uc5d0 \uc5b4\ub5a4 \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \ud074\ub798\uc2a4 \ub85c\ub354\uc5d0\uac8c \uc9c8\uc758\ub97c \ud558\ub294 \ubc29\uc2dd\uc73c\ub85c \uc791\ub3d9\ud558\uac8c \ub418\ub294\ub370\\n\\n\ud074\ub798\uc2a4 \ub85c\ub354\uc5d0\uac8c \uc9c8\uc758\ub97c \ud588\uc744 \uacbd\uc6b0\uc5d0 \uba87 \uac00\uc9c0 \ubb38\uc81c\uc810\uc774 \uc0dd\uae41\ub2c8\ub2e4\\n\\n1. \ud074\ub798\uc2a4 \ub85c\ub354\uc5d0 \uba85\ud655\ud55c \ud45c\uc900\uc774 \uc5c6\uace0, \ubd80\ubaa8 \uc790\uc2dd \ubaa8\ub378\uc774 \uc788\uc5b4\uc11c, \ud074\ub798\uc2a4 \ub85c\ub354\uc5d0 \ub530\ub77c\uc11c \ub2e4\ub978 \uacb0\uacfc\uac00 \ub098\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4. [\ucc38\uace0](http://articles.qos.ch/classloader.html)\\n2. \ud074\ub798\uc2a4\ub85c\ub354\ub294 gc\uc758 \ub3d9\uc791\uc5d0 \ubc29\ud574\ub97c \uc77c\uc73c\ucf1c\uc11c \uba54\ubaa8\ub9ac \ub204\uc218\ub97c \ubc1c\uc0dd\uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4. [\ucc38\uace0](https://cwiki.apache.org/confluence/display/COMMONS/Logging+UndeployMemoryLeak)\\n\\n`@Slf4j` \uc5b4\ub178\ud14c\uc774\uc158\uc744 \ubd99\uc774\uba74, \ucef4\ud30c\uc77c \uc2dc\uc810\uc5d0 `private final Logger log = LoggerFactory.getLogger(this.getClass());` \uc640 \uac19\uc740 \ucf54\ub4dc\ub85c \ubcc0\ud658\ub429\ub2c8\ub2e4.\\n\\n\uc2a4\ud504\ub9c1 5\uc5d0\uc11c\ub294 Slf4j \uac00 \uc0ac\uc6a9\ud558\ub294 \uac83\ucc98\ub7fc, \ucef4\ud30c\uc77c \ud0c0\uc784\uc5d0 \uc5b4\ub5a4 \ub85c\uae45 \ud504\ub808\uc784\uc6cc\ud06c\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud558\ub294 \uae30\ub2a5\uc744 \uc791\uc131\ud588\uace0, `Commons Logging`\uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uac8c \ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n[spring 5\uc5d0\uc11c \ubcc0\uacbd\ub418\uc5c8\ub2e4\ub294 \ub9c1\ud06c](https://docs.spring.io/spring-framework/docs/5.0.0.RC3/spring-framework-reference/overview.html#overview-logging)\\n\\n## Slf4 j\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\uc790\\n\\nSlf4 j\ub294 \ub85c\uae45\uc744 \uc704\ud55c \uc778\ud130\ud398\uc774\uc2a4\ub97c \uc81c\uacf5\ud558\ub294 \ud504\ub808\uc784\uc6cc\ud06c\uc785\ub2c8\ub2e4.(Simple Logging Facade for Java)\\n\\n\ucef4\ud30c\uc77c \ud0c0\uc784\uc5d0, \uc5b4\ub5a4 \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud560\uc9c0 \uacb0\uc815\ud558\ub294 \uae30\ub2a5\uc744 \uc81c\uacf5\ud569\ub2c8\ub2e4.\\n\\n\ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \ubc14\uafb8\ub824\uace0 \ud588\uc744 \ub54c, \uae30\uc874 \ucf54\ub4dc\ub294 \ud558\ub098\ub3c4 \uac74\ub4dc\ub9ac\uc9c0 \uc54a\uace0, \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub9cc \ubc14\uafd4\uc8fc\uba74 \ub418\ub3c4\ub85d \ud574\uc90d\ub2c8\ub2e4.\\n\\n### \uc870\uae08 \ub354 \uc790\uc138\ud55c \ub3d9\uc791 \uc6d0\ub9ac\ub97c \uc54c\uc544\ubcf4\uc790\\n\\n![only slf4j](https://blog.kakaocdn.net/dn/lCcTc/btsmBw3OEJz/1njLV283KdUWc9qyppEdak/img.png)\\n\\nSlf4 j \ub9cc\uc744 \uc0ac\uc6a9\ud588\uc744 \uacbd\uc6b0 \uc704 \uc0ac\uc9c4 \uac19\uc740 \ud615\ud0dc\ub85c \uc694\uccad\uc774 \ucc98\ub9ac\uac00 \ub429\ub2c8\ub2e4.\\n\\nSlf4 j \ub77c\ub294 \uc778\ud130\ud398\uc774\uc2a4\ub97c \ud1b5\ud574\uc11c \ub85c\uadf8\ub97c \ub0a8\uae30\uace0, \uc5b4\ub5a4 \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud560\uc9c0\ub294 `Slf4j binding`\uc774\ub77c\ub294 \uac83\uc744 \ud1b5\ud574\uc11c \uacb0\uc815\ud569\ub2c8\ub2e4.\\n\\n`Slf4j binding` \uc740 `Slf4j`\uc758 \uc778\ud130\ud398\uc774\uc2a4\ub97c \uad6c\ud604\ud558\uace0 \uc788\uc9c0 \uc54a\uc740 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc758 \uad6c\ud604\uccb4\ub97c \uc5f0\uacb0\ud574 \uc8fc\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n\uadf8 \uad6c\ud604\uccb4\ub85c `Slf4j-log4 j12-{version}. jar` \uac19\uc740 \uac83\uc774 \uc788\ub2e4.\\n\\n\uc774\uc640\ub294 \ub2e4\ub974\uac8c Logback \uc740 Slf4 j \ub97c \uad6c\ud604\ud558\uace0 \uc788\uae30\uc5d0, `Slf4j binding` \uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc544\ub3c4 \ub429\ub2c8\ub2e4.\\n\\n![logback example](https://blog.kakaocdn.net/dn/IYC3k/btsmy0einLF/F0aiMnteJeGB00fkGdBjRK/img.png)\\n\\n\uc704 \uc0ac\uc9c4\ucc98\ub7fc `Slf4j binding` \uc744 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uace0, `Logback` \ubc14\ub85c \uc0ac\uc6a9\ud558\ub294 \uac83\ub3c4 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n\uadf8\ub807\ub2e4\uba74 Slf4 j\ub97c \ubc14\ub85c \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc740 \ucf54\ub4dc\uc5d0\uc11c `Slf4j` \ub97c \uc0ac\uc6a9\ud558\ub824\uba74 \uc5b4\ub5bb\uac8c \ud574\uc57c \ud560\uae4c\uc694?\\n\\n![slf4j working principle](https://blog.kakaocdn.net/dn/msTPw/btsmziy04VE/sXSOKYvi9yXSoiRmg6mIGk/img.png)\\n\\n\uc704 \uc0ac\uc9c4\ucc98\ub7fc `Slf4j bridge` \ub97c \ud1b5\ud574\uc11c \uc678\ubd80 \ub77c\uc774\ube0c\ub7ec\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\ucc98\ub7fc \uac08\uc544 \ub07c\uc6b8 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n`Log4j2` \ub97c \uc0ac\uc6a9\ud558\ub294 \ucf54\ub4dc\ub97c \uc804\ud600 \ubc14\uafb8\uc9c0 \uc54a\uc544\ub3c4, `Bridge` \uac00 `Slf4j` \ub97c \ud1b5\ud574 `Logback`\uc73c\ub85c \uc790\uc5f0\uc2a4\ub7fd\uac8c \ub85c\uadf8\ub97c \ub0a8\uae38 \uc218 \uc788\ub3c4\ub85d \ud574\uc90d\ub2c8\ub2e4.\\n\\n## Logback\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\uc790\\n\\nLogback \uc740 \uc2a4\ud504\ub9c1\uc5d0\uc11c \uae30\ubcf8\uc73c\ub85c \uc0ac\uc6a9\ub420 \ub9cc\ud07c \uc778\uae30 \uc788\ub294 \ub85c\uadf8 \ub77c\uc774\ube0c\ub7ec\ub9ac\uc785\ub2c8\ub2e4.\\n\\n![logback \ub3d9\uc791 \uacfc\uc815](https://logback.qos.ch/manual/images/chapters/architecture/underTheHoodSequence2_small.gif)\\n\\n\uacf5\uc2dd\ubb38\uc11c\uc5d0\uc11c \uc544\uc8fc \ud575\uc2ec\uc801\uc778 \ub3d9\uc791\uc6d0\ub9ac\ub97c \uc124\uba85\ud574\uc8fc\uace0 \uc788\ub294 \uc0ac\uc9c4\uc774\ub77c\uc11c \uac00\uc838\uc654\uc2b5\ub2c8\ub2e4.\\n\\n\ub108\ubb34 \uc5b4\ub824\uc6cc \ubcf4\uc5ec\uc11c, \uc870\uae08 \uc790\uc138\ud558\uac8c \uac01\uac01\uc758 \uad6c\uc131\uc694\uc18c\uc5d0 \ub300\ud574\uc11c \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n\uc774\uc5d0 \ub300\ud574 \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n## \ub85c\uadf8\ubc31\uc758 \uad6c\uc131\uc694\uc18c\\n\\n### Appender\\n\\nAppender\ub294 \ub85c\uadf8\ub97c \uc5b4\ub514\uc5d0 \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\n\uc678\ubd80\ub85c\ubd80\ud130 \uc5b4\ub5a4 \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc11c, \uc5b4\ub5a4 \ubc29\uc2dd\uc73c\ub85c \ucc98\ub9ac\ud560\uc9c0\uc5d0 \ub300\ud574\uc11c \uc804\uccb4\uc801\uc73c\ub85c \uc124\uc815\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uae30\ubcf8\uc801\uc73c\ub85c \uc218\ub9ce\uc740 Appender \uac00 \uc81c\uacf5\ub418\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n- ConsoleAppender\\n- FileAppender\\n- RollingFileAppender\\n- AsyncAppender\\n- DBAppender\\n- SMTPAppender\\n- SocketAppender\\n- SyslogAppender\\n\\n\uc800\ud76c\ub294 Slack\uc5d0 \uc54c\ub9bc\uc744 \uc8fc\ub294 \uac83\uc774 \ubaa9\uc801\uc774\uae30 \ub54c\ubb38\uc5d0, SlackAppender\ub97c \uc0ac\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ud558\uc9c0\ub9cc SlackAppender\ub294 \uc81c\uacf5\ub418\uace0 \uc788\uc9c0 \uc54a\uae30\uc5d0 \uc9c1\uc811 \uad6c\ud604\uc744 \ud574\uc57c \ud558\ub294\ub370\uc694\\n\\n\uc774\ub97c \uad6c\ud604\ud588\uc744 \ub54c, Slack API \uac00 \ub05d\ub0a0 \ub54c\uae4c\uc9c0, \uacc4\uc18d \uae30\ub2e4\ub9ac\uace0 \uc788\uc744 \ud544\uc694\uac00 \uc5c6\uae30\uc5d0, AsyncAppender\ub97c \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9 \ubc29\ubc95\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4. xml \uae30\ubc18\uc73c\ub85c \uac00\ub2a5\ud55c\ub370\uc694\\n\\n```xml\\n\\n \\n myapp.log\\n \\n %logger{35} -%kvp -%msg%n\\n \\n \\n\\n \\n \\n \\n\\n \\n \\n \\n\\n```\\n\\n\ub9cc\uc57d \uc5ec\uae30\uc5d0 \uc788\ub294 \uae30\ub2a5\ub4e4\ub85c \ubd80\uc871\ud558\ub2e4\uba74, \uc9c1\uc811 Appender \ub97c \uad6c\ud604\ud574\uc11c \uc0ac\uc6a9\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc9c1\uc811 \uad6c\ud604\ud558\ub824\uba74 AppenderBase\ub97c \uc0c1\uc18d\ubc1b\uc544\uc11c \uad6c\ud604\ud558\uba74 \ub429\ub2c8\ub2e4.\\n\\n\uc774 \ud074\ub798\uc2a4\ub294 \ud544\uc694\ud55c \ubd80\ubd84\uc774 \ub300\ubd80\ubd84 \uad6c\ud604\ub418\uc5b4 \uc788\uace0, appender \ub9cc \uad6c\ud604\ud558\uba74 \ubc14\ub85c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ub2f9\uc5f0\ud558\uc9c0\ub9cc \ud544\uc694\ud558\ub2e4\uba74 override \ub3c4 \uac00\ub2a5\ud558\uc8e0\\n\\n### Layout\\n\\nLayout \uc740 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \ud615\uc2dd\uc73c\ub85c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nAppender\ub294 \ub85c\uadf8\ub97c \uc5b4\ub514\uc5d0 \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud558\uace0, Layout \uc740 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \ud615\uc2dd\uc73c\ub85c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud558\ub3c4\ub85d \ud558\ub294 \uac83\uc774 \uc774\uc0c1\uc801\uc774\uc9c0\ub9cc\\n\\nLogback \uc740 Appender\uc5d0\uc11c Layout \uc744 \uc9c1\uc811 \uc9c0\uc815\ud560 \uc218 \uc788\ub3c4\ub85d \ud574\uc8fc\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c, \uc9c1\uc811 Layout \uc744 \ub9cc\ub4e4\uc9c0 \uc54a\uace0, Appender \uc5d0\uc11c \uae30\uc874\uc5d0 \uc774\ubbf8 \uc788\ub294 \ud328\ud134\ub9cc \uc0ac\uc6a9\ud558\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n### Encoder\\n\\nEncoder\ub294 Layout \uacfc \ube44\uc2b7\ud55c \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nLayout \uc740 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \ud615\uc2dd\uc73c\ub85c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud558\uace0, Encoder \ub294 \uc2e4\uc81c byte \ud615\ud0dc\ub85c \ubcc0\ud658\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nSlack\uc758 webhook\uc744 \uc0ac\uc6a9\ud560 \uac83\uc774\uc9c0\ub9cc, AppenderBase\ub97c \uc0ac\uc6a9\ud558\uae30\uc5d0, \uc774\ubc88\uc5d0\ub294 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n### Filter\\n\\nFilter\ub294 \ub85c\uadf8\ub97c \uc5b4\ub5a4 \uc870\uac74\uc5d0 \ub530\ub77c\uc11c \ucd9c\ub825\ud560\uc9c0\ub97c \uacb0\uc815\ud558\ub294 \uc5ed\ud560\uc744 \ud569\ub2c8\ub2e4.\\n\\nFilter \ub294 Appender\ub97c \ub4f1\ub85d\ud558\uba70 \uac19\uc774 \ub4f1\ub85d\ud560 \uc218 \uc788\ub294\ub370\uc694\\n\\n\uc774\ubc88 \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c\ub294 Level \uc774 ERROR \uc774\uc0c1\uc778 \uac83\ub9cc \ucd9c\ub825\ud558\ub3c4\ub85d \ud558\uace0 \uc2f6\uae30\uc5d0, LevelFilter\ub97c \uc0ac\uc6a9\ud558\uba74 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n```xml\\n\\n \\n \\n INFO\\n ACCEPT\\n DENY\\n \\n \\n \\n %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n\\n \\n \\n \\n \\n \\n \\n\\n```\\n\\n\uc640 \ube44\uc2b7\ud558\uac8c \uc0ac\uc6a9\ud560 \uc218 \uc788\uc5b4 \ubcf4\uc785\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc2e4\uc81c\ub85c \ud504\ub85c\uc81d\ud2b8\uc5d0\uc11c error \ubc1c\uc0dd \uc2dc slack\uc73c\ub85c \uc54c\ub9bc\uc744 \uc8fc\ub294 \uac83\uc744 \uad6c\ud604\ud574 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## \uc2ac\ub799\uc5d0 \ucd94\uac00\ud558\ub294 \ubc29\ubc95\\n\\n[\uc774 \ube14\ub85c\uadf8](https://velog.io/@king/slack-incoming-webhook)\ub97c \ubcf4\uace0\uc11c \uc791\uc131\ud588\uc2b5\ub2c8\ub2e4\\n\\n## \uc2e4\uc81c \uad6c\ud604\\n\\n\uad6c\ud604\ub41c \uacb0\uacfc\ubb3c\uc740 \uc544\ub798\uc640 \uac19\uc2b5\ub2c8\ub2e4\\n\\n![slack appender](https://blog.kakaocdn.net/dn/d3z7QG/btsmQCCV69f/NwiyNhQGZOBnKBP2hT8kf0/img.png)\\n\\n### SlackAppender \uad6c\ud604\ud558\uae30\\n\\n```java\\npublic class SlackAppender extends AppenderBase {\\n\\n @Override\\n protected void append(final ILoggingEvent eventObject) {\\n final var restTemplate = new RestTemplate();\\n final var url = \\"https://hooks.slack.com/services/\\";\\n final Map body = createSlackErrorBody(eventObject);\\n restTemplate.postForEntity(url, body, String.class);\\n }\\n\\n private Map createSlackErrorBody(final ILoggingEvent eventObject) {\\n final String message = createMessage(eventObject);\\n return Map.of(\\n \\"attachments\\", List.of(\\n Map.of(\\n \\"fallback\\", \\"\uc694\uccad\uc744 \uc2e4\ud328\ud588\uc5b4\uc694 :cry:\\",\\n \\"color\\", \\"#2eb886\\",\\n \\"pretext\\", \\"\uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uc5b4\uc694 \ud655\uc778\ud574\uc8fc\uc138\uc694 :cry:\\",\\n \\"author_name\\", \\"car-ffeine\\",\\n \\"text\\", message,\\n \\"fields\\", List.of(\\n Map.of(\\n \\"title\\", \\"\uc6b0\uc120\uc21c\uc704\\",\\n \\"value\\", \\"High\\",\\n \\"short\\", false\\n ),\\n Map.of(\\n \\"title\\", \\"\uc11c\ubc84 \ud658\uacbd\\",\\n \\"value\\", \\"local\\",\\n \\"short\\", false\\n )\\n ),\\n \\"ts\\", eventObject.getTimeStamp()\\n )\\n )\\n );\\n }\\n\\n private String createMessage(final ILoggingEvent eventObject) {\\n final String baseMessage = \\"\uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.\\\\n\\";\\n final String pattern = baseMessage + \\"```%s %s %s [%s] - %s```\\";\\n final SimpleDateFormat simpleDateFormat = new SimpleDateFormat(\\"yyyy-MM-dd HH:mm:ss.SSS\\");\\n return String.format(pattern,\\n simpleDateFormat.format(eventObject.getTimeStamp()),\\n eventObject.getLevel(),\\n eventObject.getThreadName(),\\n eventObject.getLoggerName(),\\n eventObject.getFormattedMessage());\\n }\\n}\\n```\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c url\uc744 \uc9c1\uc811 \uc785\ub825\ud558\uc2dc\uba74 \ub429\ub2c8\ub2e4.\\n\\n\uadf8\ub9ac\uace0, \uc774\ub807\uac8c \ub9cc\ub4e0 SlackAppender\ub97c logback-spring.xml \uc5d0 \ub4f1\ub85d\ud558\uba74 \ub429\ub2c8\ub2e4.\\n\\n```xml\\n\\n\\n\\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n\\n \\n\\n\\n```\\n\\n\uc774\ub807\uac8c \ud558\uba74, racingcar \ud328\ud0a4\uc9c0\uc5d0\uc11c \uc5d0\ub7ec\uac00 \ubc1c\uc0dd\ud560 \ub54c\ub9cc slack\uc73c\ub85c \uc54c\ub9bc\uc744 \ubc1b\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## \uacb0\ub860\\n\\n![slack appender](https://blog.kakaocdn.net/dn/d3z7QG/btsmQCCV69f/NwiyNhQGZOBnKBP2hT8kf0/img.png)\\n\\n\uc774\ubc88 \uae00\uc5d0\uc11c\ub294 log \ub808\ubca8\uc5d0 \ub530\ub77c slack \uc73c\ub85c \uc54c\ub9bc\uc744 \ubc1b\ub294 \ubc29\ubc95\uc744 \uc54c\uc544\ubcf4\uc558\uc2b5\ub2c8\ub2e4.\\n\\n\uae34 \uae00\uc744 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4"},{"id":"7","metadata":{"permalink":"/7","source":"@site/blog/2023-07-06-auto-issue-number-commit-msg.mdx","title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","description":"\ud504\ub85c\uc81d\ud2b8 \ube0c\ub79c\uce58\uba85 \ucee8\ubca4\uc158\uc774 feat/\uc774\uc288\ubc88\ud638\uc5ec\uc11c, \ube0c\ub79c\uce58\uba85\uc5d0\uc11c \uc774\uc288\ubc88\ud638\ub9cc \uac00\uc838\uc628 \ub2e4\uc74c \ucee4\ubc0b\ud560 \ub54c\ub9c8\ub2e4 \ucee4\ubc0b \uba54\uc2dc\uc9c0 \uc544\ub798\ub2e8(footer)\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud574\uc8fc\uace0 \uc2f6\uc5c8\ub2e4. \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ub41c\ub2e4\uba74 \uae5c\ube61\ud558\uace0 \uc774\uc288 \ubc88\ud638\ub97c \uc548 \uc801\ub294 \uc77c\ub3c4 \uc5c6\uace0, \uc2dc\uac04\ub3c4 \ub2e8\ucd95\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc774\ub2e4.","date":"2023-07-06T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 6\uc77c","tags":[{"label":"git","permalink":"/tags/git"},{"label":"commit","permalink":"/tags/commit"},{"label":"message","permalink":"/tags/message"},{"label":"issue","permalink":"/tags/issue"},{"label":"auto","permalink":"/tags/auto"}],"readingTime":2.89,"hasTruncateMarker":false,"authors":[{"name":"\uc57c\ubbf8","title":"Frontend","url":"https://github.com/feb-dain","imageURL":"https://github.com/feb-dain.png","key":"yummy"}],"frontMatter":{"slug":"7","title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","authors":["yummy"],"tags":["git","commit","message","issue","auto"]},"prevItem":{"title":"\uc2a4\ud504\ub9c1\uc5d0\uc11c \ubc1c\uc0dd\ud55c \uc5d0\ub7ec \ub85c\uadf8\ub97c \uc2ac\ub799\uc73c\ub85c \ubaa8\ub2c8\ud130\ub9c1\ud558\ub294 \ubc29\ubc95","permalink":"/8"},"nextItem":{"title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","permalink":"/6"}},"content":"\ud504\ub85c\uc81d\ud2b8 \ube0c\ub79c\uce58\uba85 \ucee8\ubca4\uc158\uc774 feat/\uc774\uc288\ubc88\ud638\uc5ec\uc11c, \ube0c\ub79c\uce58\uba85\uc5d0\uc11c \uc774\uc288\ubc88\ud638\ub9cc \uac00\uc838\uc628 \ub2e4\uc74c \ucee4\ubc0b\ud560 \ub54c\ub9c8\ub2e4 \ucee4\ubc0b \uba54\uc2dc\uc9c0 \uc544\ub798\ub2e8(footer)\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud574\uc8fc\uace0 \uc2f6\uc5c8\ub2e4. \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ub41c\ub2e4\uba74 \uae5c\ube61\ud558\uace0 \uc774\uc288 \ubc88\ud638\ub97c \uc548 \uc801\ub294 \uc77c\ub3c4 \uc5c6\uace0, \uc2dc\uac04\ub3c4 \ub2e8\ucd95\ud560 \uc218 \uc788\uae30 \ub54c\ubb38\uc774\ub2e4.\\n\\n\uc544\ub798 \uc21c\uc11c\ub300\ub85c \uc9c4\ud589\ud55c\ub2e4\uba74 \uc774\uc288 \ubc88\ud638 POSTFIX \uc790\ub3d9\ud654\ub97c \ud560 \uc218 \uc788\ub2e4.\\n\\n### 1) \ud504\ub85c\uc81d\ud2b8 \ud3f4\ub354\uc5d0 .githooks \ud3f4\ub354 \uc0dd\uc131\\n\\n### 2) .githooks \ud3f4\ub354\uc5d0 commit-msg \ud30c\uc77c \uc0dd\uc131\\n\\n```shell\\n#!/bin/bash\\n\\nCOMMIT_MESSAGE_FILE_PATH=$1\\nMESSAGE=$(cat \\"$COMMIT_MESSAGE_FILE_PATH\\")\\n\\n# \ucee4\ubc0b \uba54\uc2dc\uc9c0\uac00 \uc5c6\uc744 \ub54c, \ucee4\ubc0b \ubc29\uc9c0\\nif [[ $(head -1 \\"$COMMIT_MESSAGE_FILE_PATH\\") == \'\' ]]; then\\n exit 0\\nfi\\n\\n# \ube0c\ub79c\uce58\uba85\uc5d0\uc11c \uc774\uc288 \ubc88\ud638\ub9cc \ucd94\ucd9c (\'/\' \ub4a4\uc5d0 \uc788\ub294 \ubb38\uc790\ub9cc \ucd94\ucd9c)\\nPOSTFIX=$(git branch | grep \'\\\\*\' | sed \'s/* //\' | sed \'s/^.*\\\\///\' | sed \'s/^\\\\([^-]*-[^-]*\\\\).*/\\\\1/\')\\n\\nCOMMIT_SOURCE=$2\\nCURRENT_BRANCH=$(git branch --show-current)\\n\\n# [[ \\"$CURRENT_BRANCH\\" != \\"$POSTFIX\\" ]] \ud83d\udc49\ud83c\udffb \ud604\uc7ac \ube0c\ub79c\uce58\uba85\uacfc POSTFIX\uac00 \ub611\uac19\uc73c\uba74 POSTFIX \uc785\ub825 \ubc29\uc9c0\\n# [ \\"$COMMIT_SOURCE\\" != \\"merge\\" ] \ud83d\udc49\ud83c\udffb merge\ud560 \ub54c, POSTFIX \uc785\ub825 \ubc29\uc9c0\\n# [[ \\"$MESSAGE\\" != *\\"[#$POSTFIX]\\"* ]] \ud83d\udc49\ud83c\udffb \uc774\ubbf8 POSTFIX\uac00 \uc874\uc7ac\ud560 \ub54c, POSTFIX \uc911\ubcf5 \uc785\ub825 \ubc29\uc9c0\\nif [[ \\"$CURRENT_BRANCH\\" != \\"$POSTFIX\\" ]] && [ \\"$COMMIT_SOURCE\\" != \\"merge\\" ] && [[ \\"$MESSAGE\\" != *\\"[#$POSTFIX]\\"* ]]; then\\n printf \\"%s\\\\n\\\\n[#%s]\\" \\"$MESSAGE\\" \\"$POSTFIX\\" > \\"$COMMIT_MESSAGE_FILE_PATH\\"\\nfi\\n```\\n\\n\ud83e\uddd0 \uc774\uc288 \ubc88\ud638 \ucd94\ucd9c\uc5d0 \uc0ac\uc6a9\ub41c \uba85\ub839\uc5b4 \uc124\uba85\\n\\n- grep \'\\\\*\' \ud83d\udc49 `*` \ud45c\uc2dc\ub41c \ube0c\ub79c\uce58(\ud604\uc7ac \uc704\uce58\uc758 \ube0c\ub79c\uce58)\ub97c \uac00\uc838\uc628\ub2e4.\\n- sed \'s/_ //\' \ud83d\udc49 `*` \uc81c\uac70\\n- sed \'s/\\\\([^/]_\\\\)._/\\\\1/\' \ud83d\udc49 `/` \uc774\ud6c4\uc758 \ubb38\uc790\ub9cc \ucd94\ucd9c\\n- sed \'s/^\\\\([^-]_-[^-]_\\\\).\\\\_/\\\\1/\' \ud83d\udc49 \ud558\ub098\uc758 \uc774\uc288\uc5d0 \uc5ec\ub7ec \ube0c\ub79c\uce58\ub97c \ub9cc\ub4e4\uba74\uc11c feat/10-1 \uc774\ub7f0 \ud615\ud0dc\ub85c \ube0c\ub79c\uce58\ub97c \ub9cc\ub4e4 \uacbd\uc6b0, \uccab \ubc88\uc9f8 \'-\' \uc55e \ub4a4\ub9cc \ucd94\ucd9c (ex. 10-1)\\n\\n### 3) \ud504\ub85c\uc81d\ud2b8 \ud3f4\ub354\uc5d0 Makefile \ud30c\uc77c \uc0dd\uc131\\n\\n```shell\\ninit:\\n git config core.hooksPath .githooks\\n chmod +x .githooks/commit-msg\\n git update-index --chmod=+x .githooks/commit-msg\\n\\n # chmod +x .githooks/commit-msg \ud83d\udc49\ud83c\udffb macOS, \ub9ac\ub205\uc2a4\uc5d0\uc11c \uc2a4\ud06c\ub9bd\ud2b8 \uad8c\ud55c \ubd80\uc5ec\\n # git update-index --chmod=+x .githooks/commit-msg\\n # \ud83d\udc49 macOS, \ub9ac\ub205\uc2a4\uc5d0\uc11c \ube0c\ub79c\uce58\uac00 \ubc14\ub014 \ub54c\ub9c8\ub2e4 \uc2a4\ud06c\ub9bd\ud2b8 \uc2e4\ud589\uc2dc\ucf1c\uc918\uc57c \ud558\ub294 \ubb38\uc81c \ud574\uacb0\\n```\\n\\n### 4) \uc544\ub798 \ucf54\ub4dc \uc2e4\ud589\\n\\n\uc0c8\ub85c git clone\uc744 \ud560 \ub54c\ub9c8\ub2e4 \uc544\ub798 \ucf54\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c\uc918\uc57c \ud55c\ub2e4. \ud55c \ubc88\ub9cc \uc2e4\ud589\uc2dc\ud0a4\uba74 \uacc4\uc18d \uc801\uc6a9\ub41c\ub2e4. (window \uae30\uc900)\\n\\n```shell\\ngit config core.hooksPath .githooks\\n```\\n\\n\u2757macOS\ub294 git clone \ud560 \ub54c\ub9c8\ub2e4 \uc544\ub798 \ucf54\ub4dc\ub97c \uc2e4\ud589\uc2dc\ucf1c\uc918\uc57c \ud55c\ub2e4.\\n\\n```shell\\nmake\\n```\\n\\n---\\n\\n\ucc38\uace0 \ube14\ub85c\uadf8\\nhttps://blog.deering.co/commit-convention/"},{"id":"6","metadata":{"permalink":"/6","source":"@site/blog/2023-07-05-nunu-db-optimization.mdx","title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","description":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4","date":"2023-07-05T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 5\uc77c","tags":[{"label":"DB","permalink":"/tags/db"},{"label":"JPA","permalink":"/tags/jpa"},{"label":"Hibernate","permalink":"/tags/hibernate"},{"label":"Spring","permalink":"/tags/spring"}],"readingTime":8.16,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"},{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"}],"frontMatter":{"slug":"6","title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","authors":["nunu","boxster"],"tags":["DB","JPA","Hibernate","Spring"]},"prevItem":{"title":"\uae43 \ucee4\ubc0b \uba54\uc2dc\uc9c0\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \uc790\ub3d9\uc73c\ub85c \uc785\ub825\ud560 \uc21c \uc5c6\uc744\uae4c?","permalink":"/7"},"nextItem":{"title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","permalink":"/5"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uce74\ud398\uc778\ud300 `\ub204\ub204`\uc785\ub2c8\ub2e4\\n\\n\uc774\ubc88\uc5d0\ub294 \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud558\ub294 \uacfc\uc815\uc5d0\uc11c \uc54c\uac8c \ub41c \ub0b4\uc6a9\uc744 \uacf5\uc720\ud558\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n## \uc774\ubc88 \ucd5c\uc801\ud654\uc758 \ubaa9\ud45c\\n\\n\uc804\uae30\ucc28 \ucda9\uc804\uc18c\uc5d0 \ub300\ud55c \uacf5\uacf5 \ub370\uc774\ud130\ub97c \uac00\uc838\uc624\uace0, \uadf8 \ub370\uc774\ud130\ub97c DB \uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790\\n\\n## \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c \uc0bd\uc785\ud558\ub294 \uacfc\uc815\\n\\n\uc800\ud76c \ud300\uc758 \uc694\uad6c\uc0ac\ud56d\uc744 \uac04\ub2e8\ud558\uac8c \uc815\ub9ac\ud558\uba74 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4\\n\\n1. \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c \uacf5\uacf5 \ub370\uc774\ud130\uc5d0\uc11c \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uc640 \uc804\uae30\ucc28 \ucda9\uc804\uae30\uc5d0 \ub300\ud55c \ub370\uc774\ud130\ub97c \uac00\uc838\uc628\ub2e4\\n - \ucda9\uc804\uc18c\ub294 6\ub9cc \uac1c, \ucda9\uc804\uae30\ub294 23\ub9cc \uac1c\uc758 \ub370\uc774\ud130\uac00 \uc874\uc7ac\ud55c\ub2e4.\\n - \ud55c \ubc88\uc5d0 \uac00\uc838\uc62c \uc218 \uc788\ub294 \uc591\uc740 9999\uac1c \uae4c\uc9c0\ub2e4.\\n2. \uc774 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294\ub2e4\\n - \ucda9\uc804\uc18c\uc640 \ucda9\uc804\uae30\ub294 1:N \uad00\uacc4\uc774\ub2e4\\n\\n## \ucd5c\uc801\ud654 \uc804\uc740 \uc5b4\ub5a4 \uc0c1\ud669\uc774\uc5c8\ub294\ub370?\\n\\n![before_optimize](https://veiled-starfish-4c7.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Ffb934c88-4589-4096-90bc-36b4bc88f6a2%2FUntitled.png?id=f7f7c2af-7b95-42e8-8d95-ddd952e53005&table=block&spaceId=9db11c89-12d2-4910-8822-5ffecbdb8ccd&width=2000&userId=&cache=v2)\\n\\n\uc704 \uc0ac\uc9c4\uc744 \uc798 \ubcf4\uc2dc\uba74 \uc544\uc2e4 \uc218 \uc788\uc73c\uc2dc\uaca0\uc9c0\ub9cc, 2000\uac1c\ub97c \uc800\uc7a5\ud558\ub294\ub370, 231.762 \ucd08\uac00 \uc0ac\uc6a9\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubb3c\ub860 \ucd9c\ub825\uc744 \uc704\ud55c \uc2dc\uac04\ub3c4 \ud3ec\ud568\ub418\uc5c8\uae30\uc5d0, 230\ucd08 \uc815\ub3c4\ub77c\uace0 \uc0dd\uac01\ud558\uc154\ub3c4 \uc88b\uc2b5\ub2c8\ub2e4\\n\\n1\ub9cc \uac1c\ub77c\uba74? 231.762\ucd08 \\\\* 5 = 1,158.81\ucd08\\n\\n23\ub9cc \uac1c\ub77c\uba74? 1158.81 \\\\* 23 = 26,652.63\ucd08\\n\\n\uc2dc\uac04\uc73c\ub85c \ubc14\uafd4\ubcf4\uba74 7.4 \uc2dc\uac04\uc774 \uac78\ub9b0\ub2e4\ub294 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n1. \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, \uc0c8\ub85c\uc6b4 Transaction \uc774 \uc0dd\uc131\ub41c\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n\ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, \uc0c8\ub85c\uc6b4 Transaction \uc774 \uc0dd\uc131\ub418\ub294 \uac83\uc744 \ubc29\uc9c0\ud558\uae30 \uc704\ud574, \uc804\uccb4\ub97c \ud558\ub098\uc758 \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\ub294\ub2e4\\n\\n## \uc804\uccb4\ub97c \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc740 \ubc84\uc804\\n\\n![all_in_transaction](https://veiled-starfish-4c7.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F9ff34622-4a26-4acd-980c-ae175c83143d%2FUntitled.png?id=979aa2c5-e972-4c52-a44a-1669c497c84e&table=block&spaceId=9db11c89-12d2-4910-8822-5ffecbdb8ccd&width=2000&userId=&cache=v2)\\n\\n\uc774 \uacfc\uc815\uc5d0\uc11c 2000\uac1c\ub97c \uc800\uc7a5\ud558\ub294\ub370 65\ucd08 \uac00 \uc0ac\uc6a9\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n1\ub9cc \uac1c\ub77c\uba74? 65\ucd08 \\\\* 5 = 325\ucd08\\n\\n23\ub9cc \uac1c\ub77c\uba74? 325\ucd08 \\\\* 23 = 7,475\ucd08\\n\\n\uc2dc\uac04\uc73c\ub85c \ubc14\uafd4\ubcf4\uba74 2\uc2dc\uac04\uc774 \uac78\ub9b0\ub2e4\ub294 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n\uc804\uccb4\uc801\uc73c\ub85c 3\ubc30 \uc815\ub3c4 \ube68\ub77c\uc84c\uc2b5\ub2c8\ub2e4\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n1. 23\ub9cc \uac1c\uc758 \uc800\uc7a5\uc774 \ubaa8\ub450 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc774 \ub418\uc5b4\uc11c, \ud558\ub098\uac00 \uc2e4\ud328\ud558\uba74 23\ub9cc\uac1c\ub97c \uc0c8\ub85c \uc800\uc7a5\ud574\uc57c \ud558\ub294 \uc0c1\ud669\uc5d0 \ucc98\ud55c\ub2e4\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n23\ub9cc\uac1c\uc758 \uc800\uc7a5\uc774 \ubaa8\ub450 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc774 \ub418\ub294 \uac83\uc744 \ubc29\uc9c0\ud558\uae30 \uc704\ud574, 1\ub9cc \uac1c\uc529 \uc601\uc18d\ud654\uc2dc\ud0a8\ub2e4\\n\\n## 1\ub9cc \uac1c\uac00 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc778 \ubc84\uc804\\n\\n![separateTransaction](https://blog.kakaocdn.net/dn/c2mgfd/btsmrWCfnKy/9Y6Dv8vYzcftsket61tub1/img.png)\\n\\n\uc131\ub2a5\uc0c1\uc73c\ub85c \uac1c\uc120\ud55c \ubd80\ubd84\uc740 \uadf8\ub807\uac8c \ud06c\uc9c0 \uc54a\uc9c0\ub9cc, \uc2e4\ud328\ud588\uc744 \ub54c, 1\ub9cc \uac1c\ub9cc \ub2e4\uc2dc \uc800\uc7a5\ud558\uba74 \ub418\uae30\uc5d0, \ud6e8\uc52c \ube60\ub974\uac8c \ubcf5\uad6c\uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4.\\n\\n\uc5ec\uae30\uc11c PageNo\ub77c\ub294 \ud074\ub798\uc2a4\ub294, i\ub97c \ubc14\ub85c \ucc38\uc870\ud588\uc744 \uacbd\uc6b0, effectively final\uc744 \ubcf4\uc7a5\ud560 \uc218 \uc5c6\uc5b4\uc11c \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc131\ub2a5\uc740 \uc804\uccb4\ub97c \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc740 \ubc84\uc804\uacfc \ud070 \ucc28\uc774\uac00 \ub098\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n1. id \uc0dd\uc131 \uc804\ub7b5\uc774 `GenerationType.IDENTITY` \uc774\uae30\uc5d0, \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, DB\uc5d0\uc11c id\ub97c \uc0dd\uc131\ud574\uc57c \ud55c\ub2e4.\\n\\nJPA\uc5d0 \uc788\ub294 \uc4f0\uae30 \uc9c0\uc5f0\uc744 \uc804\ud600 \ud65c\uc6a9\ud560 \uc218 \uc5c6\uace0, DB\uc5d0\uc11c id\ub97c \uc0dd\uc131\ud558\uae30 \uc704\ud574, DB\uc640 \ub9e4\ubc88 \ud1b5\uc2e0\uc744 \ud574\uc57c \ud55c\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\nid\ub97c \ubbf8\ub9ac \uc0dd\uc131\ud574\uc11c, DB \uc5d0\uc11c id \ub97c \uc0dd\uc131\ud558\ub294 \uacfc\uc815\uc744 \uc0dd\ub7b5\ud55c\ub2e4\\n\\nID \uc0dd\uc131 \uc804\ub7b5\uc744 `GenerationType.Table\uc758` \ud615\ud0dc\ub85c \ubc14\uafd4\uc11c, DB\uc5d0\uc11c id\ub97c \uc0dd\uc131\ud558\ub294 \uacfc\uc815\uc744 \uc904\uc5ec\uc11c, \uc131\ub2a5\uc744 \uac1c\uc120\ud55c\ub2e4\\n\\n## 1\ub9cc \uac1c\uac00 \ud55c \ud2b8\ub79c\uc7ad\uc158\uc73c\ub85c \ubb36\uc774\uace0, id\ub97c \ubbf8\ub9ac \uc0dd\uc131\ud55c \ubc84\uc804\\n\\n\uc774\ub54c batch size\ub97c 1000 \ub2e8\uc704\ub85c \uc124\uc815\ud574\uc11c 1000\uac1c\uc529 id \uac00 \ub298\uc5b4\ub098\ub3c4\ub85d \uc124\uc815\ud588\ub2e4\\n\\n![charger_generator](https://blog.kakaocdn.net/dn/bFjNWb/btsmuoLmzVh/GddHebu2V43fpk2t3IUmz0/img.png)![station_generator](https://blog.kakaocdn.net/dn/pae8w/btsmrANjAGi/gjUhD6sMvBLpmsPl9c1tAk/img.png)\\n\\n```\\nspring.jdbc.template.fetch-size=10000\\n```\\n\\n![10000batch_size](https://blog.kakaocdn.net/dn/mtBFp/btsmtEt48jp/3mFOfrIBWbjJhHHuyP4zPk/img.png)\\n\\n1\uc790\ub9ac \uc22b\uc790\ub294 \uc55e\uc5d0\uc11c\ubd80\ud130 n(\ub9cc\uac1c)\ub97c \uc758\ubbf8\ud558\uace0, 2\ubc88\uc9f8 \uc22b\uc790\ub294 1\ub9cc \uac1c\ub97c \uc800\uc7a5\ud558\ub294 \ub370 \uac78\ub9b0 \uc2dc\uac04(ms)\uc744 \uc758\ubbf8\ud569\ub2c8\ub2e4.\\n\\n\ucc98\uc74c 1\ub9cc \uac1c\ub294 142\ucd08\uac00 \uac78\ub9ac\uace0, 2\ub9cc \uac1c\ub294 285\ucd08\uac00 \uac78\ub838\uc2b5\ub2c8\ub2e4.\\n\\n23\ub9cc \uac1c\ub77c\uba74? 142 \\\\* 26 = 3,266\ucd08\\n\\n\ucc98\uc74c\uacfc \ube44\uad50\ud558\uc790\uba74 7.4\uc2dc\uac04\uc774 \uac78\ub9ac\ub294 \uac83\uc5d0\uc11c 54\ubd84 \uc815\ub3c4 \uac78\ub9ac\ub294 \uac83\uc73c\ub85c \uac1c\uc120\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\n\ud558\ub098\uc758 \uc2a4\ub808\ub4dc\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud558\uae30\uc5d0, \uc131\ub2a5\uc774 \uac1c\uc120\ub418\uc5c8\uc9c0\ub9cc, \uc5ec\uc804\ud788 \ub290\ub9bd\ub2c8\ub2e4.\\n\\n\ud558\ub098\uc758 \uc2a4\ub808\ub4dc\uc5d0\uc11c\ub9cc \ub3d9\uc791\ud558\uae30\uc5d0, \ud558\ub098\uc758 \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\n\uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uac8c \ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uac8c \ud569\ub2c8\ub2e4.\\n\\n## \uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\ub294 \ubc84\uc804\\n\\n![multi_thread](https://blog.kakaocdn.net/dn/bPV2aa/btsmrSfU2D4/phDwk77XiKvwiXa5geX0PK/img.png)\\n\\n\uc774 \ubc84\uc804\uc5d0\uc11c 89991 \uac1c\ub97c \uc800\uc7a5\ud558\ub294\ub370 \ucd1d 157\ucd08\uac00 \uac78\ub838\uc2b5\ub2c8\ub2e4.\\n\\n23\ub9cc \uac1c\ub77c\uba74? 157 \\\\* 3 = 471\ucd08\\n\\n\uc2dc\uac04\uc73c\ub85c \ubc14\uafd4\ubcf4\uba74 5\ubd84\ub3c4 \ucc44 \uac78\ub9ac\uc9c0 \uc54a\ub294 \uc2dc\uac04\uc774\uc8e0\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\nhikari connection pool \uc0ac\uc774\uc988\ub97c 10\uc73c\ub85c \uc124\uc815\ud588\ub294\ub370, 10\uac1c\uc758 \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uba74\uc11c \uc800\uc7a5\uc744 \ud558\ub2e4 \ubcf4\ub2c8, 10\uac1c\uc758 \ucee4\ub125\uc158\uc744 \ubaa8\ub450 \uc0ac\uc6a9\ud558\uace0 \ub098\uc11c, 11\ubc88\uc9f8\ubd80\ud130\ub294 \ucee4\ub125\uc158\uc744 \uac00\uc838\uc624\uae30 \uc704\ud574, \uae30\ub2e4\ub824\uc57c \ud558\ub294 \uc0c1\ud669\uc774 \ubc1c\uc0dd\ud569\ub2c8\ub2e4.\\n\\n### \uc5b4\ub5bb\uac8c \uac1c\uc120\ud560 \uc218 \uc788\uc744\uae4c?\\n\\nhikari connection pool \uc0ac\uc774\uc988\ub97c 25\ub85c \uc124\uc815\ud574\uc11c, 25\uac1c\uc758 \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\ub3c4\ub85d \ud569\ub2c8\ub2e4.\\n\\n```\\nspring.datasource.hikari.maximum-pool-size=25\\n```\\n\\n## \uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\ub294 \ubc84\uc804 2\\n\\n![multi_thread2](https://blog.kakaocdn.net/dn/vJEoD/btsmsfau8Mv/j0CT8fVrAp3LKGRMmyMVeK/img.png)\\n\\n\ucd1d 13\ub9cc \uac1c\uc758 \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud558\ub294\ub370, 147\ucd08\uac00 \uac78\ub9ac\uace0, db \uc778\uc2a4\ud134\uc2a4\uc758 cpu \uc0ac\uc6a9\ub960\uc774 100%\uc5d0 \uac00\uae4c\uc6cc\uc838\uc11c ec2 \uac00 \ub2e4\uc6b4\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n### \uc774 \uacfc\uc815\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \ubb38\uc81c\uc810\\n\\ndb\uc758 cpu \uc0ac\uc6a9\ub7c9\uc744 \uace0\ub824\ud558\uc9c0 \uc54a\uace0, 23\ub9cc \uac1c\uac00 \uc870\uae08 \ub118\ub294 \ub370\uc774\ud130\ub97c 25\uac1c\uc758 \ucee4\ub125\uc158\uc744 \ud65c\uc6a9\ud574 \uc800\uc7a5\ud558\ub824\uace0 \ud588\uc2b5\ub2c8\ub2e4\\n\\n# \uacb0\ub860\\n\\n1. \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, transaction\uc744 \uc0ac\uc6a9\ud558\uc9c0 \ub9d0\uc790\\n2. \ub370\uc774\ud130\ub97c \uc800\uc7a5\ud560 \ub54c\ub9c8\ub2e4, id\ub97c \uc0dd\uc131\ud558\uc9c0 \ub9d0\uc790\\n3. \uc5ec\ub7ec \uc2a4\ub808\ub4dc\uc5d0\uc11c \ub3d9\uc791\ud558\uace0, \uc5ec\ub7ec \ucee4\ub125\uc158\uc744 \uc0ac\uc6a9\ud558\uc790\\n4. db\uc758 cpu \uc0ac\uc6a9\ub7c9\uc744 \uace0\ub824\ud558\uc790\\n\\n\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4"},{"id":"5","metadata":{"permalink":"/5","source":"@site/blog/2023-07-04-github_actions_pullrequest_issue.mdx","title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","description":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\ud14c\ucf54 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4","date":"2023-07-04T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 4\uc77c","tags":[{"label":"github","permalink":"/tags/github"},{"label":"action","permalink":"/tags/action"},{"label":"pr","permalink":"/tags/pr"},{"label":"issue","permalink":"/tags/issue"}],"readingTime":3.19,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"5","title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","authors":["nunu"],"tags":["github","action","pr","issue"]},"prevItem":{"title":"[DB] \ub300\ub7c9\uc758 \ub370\uc774\ud130\ub97c DB\uc5d0 \ub123\ub294 \uacfc\uc815\uc744 \ucd5c\uc801\ud654\ud574\ubcf4\uc790","permalink":"/6"},"nextItem":{"title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","permalink":"/4"}},"content":"\uc548\ub155\ud558\uc138\uc694 \uc6b0\ud14c\ucf54 \uce74\ud398\uc778\ud300 \ub204\ub204\uc785\ub2c8\ub2e4\\n\\n\ube60\ub974\uac8c \uacb0\uacfc\ubd80\ud130 \ubcf4\uace0 \uac00\uc2dc\uc8e0.\\n\\n## \uc5b4\ub5a4 \uacb0\uacfc\uac00 \ub098\uc654\ub098\uc694?\\n\\npr\uc758 \ubcf8\ubb38 \ub05d\uc5d0, \uc5f0\uad00\ub41c \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\ubc11\uc5d0 \uc0ac\uc9c4\uc744 \ubcf4\uc2dc\uba74 \uc27d\uac8c \uc774\ud574\ud558\uc2e4 \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://user-images.githubusercontent.com/80899085/250614527-e2672cf2-786a-434c-a8b6-8b374de4d689.png)![img](https://user-images.githubusercontent.com/80899085/250614882-d99aa570-51e2-4565-ab4c-ccdbd4d36e57.png)\\n\\ngithub\uc5d0\uc11c issue \ubc88\ud638\uac00 pr\uc5d0 \ub2f4\uaca8\uc788\ub2e4\uba74 2\uac00\uc9c0 \uc7a5\uc810\uc774 \uc0dd\uae30\ub294\ub370\uc694.\\n\\n1. issue\ub97c \ud074\ub9ad\ud588\uc744 \ub54c, \uc790\ub3d9\uc73c\ub85c \uadf8 issue\ub85c \ub118\uc5b4\uac08 \uc218 \uc788\uc2b5\ub2c8\ub2e4. (\ud638\ubc84\ub9cc\uc73c\ub85c \uc774\uc288\uc5d0 \ub300\ud55c \uac04\ub2e8\ud55c \uc815\ubcf4\ub97c \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4)\\n2. pr \uc774 merge \ub418\uc5c8\uc744 \ub54c, \uc790\ub3d9\uc73c\ub85c issue \uac00 close \ub429\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \uc190\uc73c\ub85c \uc9c4\ud589\ud558\ub294 \uac83\ubcf4\ub2e4, \uc790\ub3d9\uc73c\ub85c \uc9c4\ud589\ud558\uac8c \ub418\uba74 \uc2e4\uc218\ub3c4 \uc904\uc5b4\ub4e4\uace0, \uac1c\ubc1c \uacfc\uc815\uc774 \ud3b8\ud574\uc9c8 \uac83 \uac19\uc544\uc11c \uc774 \uae30\ub2a5\uc744 \uc81c\uc791\ud558\uac8c \ub418\uc5c8\ub294\ub370\uc694\\n\\n## \uc911\uc694\ud55c \uc810\\n\\n**\uc774 \uacfc\uc815\uc744 \uc9c4\ud589\ud558\ub824\uba74 \ubc11\uc5d0\uc11c \uc18c\uac1c\ud574\ub4dc\ub9b4 \ube0c\ub79c\uce58 \ub124\uc774\ubc0d \uaddc\uce59\uc774 \ud544\uc694\ud569\ub2c8\ub2e4.**\\n\\n## \ube0c\ub79c\uce58 \uc774\ub984 \uaddc\uce59\\n\\n- \ube0c\ub79c\uce58 \uc774\ub984\uc740 `\ud0c0\uc785/\uc774\uc288\ubc88\ud638` \uc73c\ub85c \uad6c\uc131\ud569\ub2c8\ub2e4. ex) `feat/1`\\n- \ud0c0\uc785\uc740 `feat`, `fix`, `docs`, `refactor`, `test` \ub4f1 \uc5ec\ub7ec \uac00\uc9c0\uac00 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \ud588\uc744 \ub54c, \uc774\uc288 \ubc88\ud638\ub97c \ube0c\ub79c\uce58 \uba85\uc5d0\uc11c\ubd80\ud130 \uac00\uc838\uc62c \uc218 \uc788\uae30\uc5d0, \uc790\ub3d9\ud654\ub97c \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uaddc\uce59\uc774 \uc544\ub2cc, feat/action \uac19\uc740 \ud615\ud0dc\uac00 \ub41c\ub2e4\uba74 issue \ubc88\ud638\ub97c \ucc3e\uae30 \uc5b4\ub835\uaca0\uc8e0?\\n\\n## \uc0ac\uc6a9 \ubc29\ubc95\\n\\n\uc791\uc131\ub41c \ucf54\ub4dc\ubd80\ud130 \ubcf4\uc2dc\uace0, \uc124\uba85\uc744 \ub4dc\ub9ac\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n\uc544\ub798\uc5d0 \uc791\uc131\ub41c \ucf54\ub4dc\ub97c. github/workflows/assign\\\\_issue\\\\_number\\\\_to\\\\_pr\\\\_body.yml\ub85c \uc800\uc7a5\ud558\uc2dc\uba74 \ub05d\uc785\ub2c8\ub2e4.\\n\\n```yml\\nname: assign_issue_number_to_pr_body\\n\\non:\\n pull_request:\\n types: [ opened ]\\n branches-ignore:\\n - develop\\n\\njobs:\\n append_issue_number_to_pr_body:\\n runs-on: ubuntu-latest\\n steps:\\n - name: append feature number to pr body pr branch = feat/(issueNumber)\\n uses: actions/github-script@v4\\n with:\\n github-token: ${{ secrets.GITHUB_TOKEN }}\\n script: |\\n const pr = await github.pulls.get({\\n owner: context.repo.owner,\\n repo: context.repo.repo,\\n pull_number: context.issue.number\\n });\\n const body = pr.data.body;\\n const issueNumber= pr.data.head.ref.split(\'/\')[1];\\n const newBody = body + \\"\\\\n\\\\n\\" + \\"close #\\" + issueNumber;\\n await github.pulls.update({\\n owner: context.repo.owner,\\n repo: context.repo.repo,\\n pull_number: context.issue.number,\\n body: newBody\\n });\\n```\\n\\n## \uc9c4\ud589 \uacfc\uc815\\n\\n1. pr \uc774 \uc0dd\uc131\ub418\uba74, pr\uc5d0 \ub300\ud55c \uc815\ubcf4\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n2. pr\uc758 \ubcf8\ubb38\uc744 \uac00\uc838\uc635\ub2c8\ub2e4.\\n3. pr\uc758 \ube0c\ub79c\uce58 \uc774\ub984\uc5d0\uc11c \uc774\uc288 \ubc88\ud638\ub97c \uac00\uc838\uc635\ub2c8\ub2e4.\\n4. pr\uc758 \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ucd94\uac00\ud569\ub2c8\ub2e4.\\n5. pr\uc758 \ubcf8\ubb38\uc744 \uc5c5\ub370\uc774\ud2b8\ud569\ub2c8\ub2e4.\\n\\n\uc774 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c, \uc9c1\uc811 pr\uc758 \ubcf8\ubb38\uc744 \uc218\uc815\ud558\uc9c0 \uc54a\uc544\ub3c4, \uc790\ub3d9\uc73c\ub85c \uc774\uc288 \ubc88\ud638\uac00 \ucd94\uac00\ub418\uae30\uc5d0, \uc2e4\uc218\ub97c \uc904\uc77c \uc218 \uc788\uc73c\ub2c8, \ud55c \ubc88 \uc2dc\ub3c4\ud574 \ubcf4\uc138\uc694"},{"id":"4","metadata":{"permalink":"/4","source":"@site/blog/2023-07-03-jay-infra.mdx","title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","description":"\uc11c\ub860","date":"2023-07-03T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 3\uc77c","tags":[{"label":"java17","permalink":"/tags/java-17"},{"label":"infra","permalink":"/tags/infra"},{"label":"ec2","permalink":"/tags/ec-2"},{"label":"ci","permalink":"/tags/ci"},{"label":"cd","permalink":"/tags/cd"},{"label":"aws","permalink":"/tags/aws"}],"readingTime":7.19,"hasTruncateMarker":false,"authors":[{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"}],"frontMatter":{"slug":"4","title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","authors":["jay"],"tags":["java17","infra","ec2","ci","cd","aws"]},"prevItem":{"title":"pr \ubcf8\ubb38\uc5d0 \uc774\uc288 \ubc88\ud638\ub97c \ub2ec\uc544\uc8fc\ub294 \uae30\ub2a5\uc744 \ub9cc\ub4e4\uc5c8\uc2b5\ub2c8\ub2e4","permalink":"/5"},"nextItem":{"title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","permalink":"/3"}},"content":"## \uc11c\ub860\\n\\n\uc548\ub155\ud558\uc138\uc694\ud83d\udc4b\ud83d\udc4b `\uce74\ud398\uc778` \ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.\\n\\n\ud68c\uc758\ub97c \ud558\uba74\uc11c \uc774\ubc88 \uc8fc \uc81c\uac00 \ub9e1\uc740 \ud30c\ud2b8\ub294 \uc11c\ubc84 \uc778\ud504\ub77c\uc785\ub2c8\ub2e4.\\n\\n\uc544\uc9c1\uc740 EC2 \uc2a4\ud399\uacfc \ub370\uc774\ud130\ub4e4\uc774 \uc815\ud655\ud788 \ub098\uc624\uc9c4 \uc54a\uc558\uc9c0\ub9cc,\\n\uc6b0\ud14c\ucf54\uc5d0\uc11c \uc801\uc740 EC2 \uc2a4\ud399\uc744 \uc81c\uacf5\ud55c\ub2e4\ub294 \uae30\uc900\uc73c\ub85c \uacc4\ud68d\ub3c4\ub97c \uc801\uc5b4\ubcfc \uc0dd\uac01\uc785\ub2c8\ub2e4.\\n\\n\\n## \uc0c1\ud669 \uc778\uc2dd\\n\\n\uc608\uc0c1\ud558\ub294 \uc0c1\ud669\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\\n\\n- API\uc758 \ub370\uc774\ud130\ub97c \ub2e4\ub8e8\ub294 \uc0c1\ud669\uc5d0\uc11c \ucd5c\uc18c \uc57d 150\ub9cc \uac74\uc5d0\uc11c \ucd5c\uc545 \uc57d 3700\ub9cc \uac74\uc758 \ub370\uc774\ud130\ub97c \ub2e4\ub8f9\ub2c8\ub2e4.\\n- \uc774\uc804 \uae30\uc218\ub97c \ubd24\uc744 \ub54c EC2\uc758 \uac1c\uc218\ub294 \ub9ce\uc774 \ub098\ub220\uc8fc\ub294 \uac83\uc73c\ub85c \ud30c\uc545 \ub410\uc2b5\ub2c8\ub2e4. (\uc774 \ubd80\ubd84\uc740 \ub2ec\ub77c\uc9c8 \uc218 \uc788\uc2b5\ub2c8\ub2e4.)\\n- \uc0c1\ud669\uc5d0 \ub530\ub77c\uc11c \uacf5\uacf5 API\ub97c \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\ub294 \uc11c\ubc84\uc640, \uc81c\uacf5 \uc11c\ubc84\ub97c \ub098\ub20c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n- Conflict\uac00 \ub098\uc9c0 \uc54a\uae30 \uc704\ud574\uc11c \uc548\uc815\uc801\uc778 \uac80\uc99d\uc744 \uac70\uce5c \ud6c4 Merge\ub97c \ud574\uc57c\ud569\ub2c8\ub2e4.\\n- \ud504\ub85c\uc81d\ud2b8\uc758 \ubc84\uc804\uc774 \uac31\uc2e0\ub41c\ub2e4\uba74 EC2 \uc11c\ubc84\uc5d0\uc11c \uc790\ub3d9\uc73c\ub85c \uc2a4\ud06c\ub9bd\ud2b8\ub97c \uc791\ub3d9\uc2dc\ucf1c Pull \ubc0f \uc11c\ubc84 \uc7ac\ubc30\ud3ec\ub97c \ud574\uc57c\ud569\ub2c8\ub2e4.\\n- \uc11c\ubc84\uc758 \ubc84\uc804\uc774 \ubc14\ub00c\ub294 \uacbd\uc6b0 \uae30\uc874 \uc11c\ubc84\ub97c \ub044\uace0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ud0a4\uba74 \uc0ac\uc6a9\uc790\uac00 \uc774\uc6a9\ud560 \uc218 \uc5c6\ub294 \ud140\uc774 \uc0dd\uae30\uae30 \ub54c\ubb38\uc5d0 \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \ud574\uc57c\ud569\ub2c8\ub2e4.\\n\\n## \ubb38\uc81c\uc810\\n\\n\uc704\uc5d0 \uc0c1\ud669\uc5d0\uc11c \ud30c\uc545\ub418\ub294 \ubb38\uc81c\uc810\ub4e4\uc740 \uba3c\uc800 \uc801\uc740 \uc131\ub2a5\uc758 EC2 \uc11c\ubc84\ub85c \uc778\ud574 \ub370\uc774\ud130\ub97c \ubc1b\uc544\uc624\ub294 \uacfc\uc815 \ud639\uc740 \uc5c5\ub370\uc774\ud2b8 \uacfc\uc815\uc5d0\uc11c \uc11c\ubc84\uac00 \ud130\uc9c8 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc131\ub2a5\uc774 \uc88b\ub2e4\uba74 \ud558\ub098\ub85c \ubaa8\ub4e0 \uac83\uc744 \ud560 \uc218 \uc788\uc9c0\ub9cc, \uadf8\ub807\uc9c0 \uc54a\uae30 \ub54c\ubb38\uc5d0 \ud604\uc7ac \uc5ec\ub7ec \uac1c\uc758 EC2\ub97c \uae30\uc900\uc73c\ub85c \uc544\ud0a4\ud14d\ucc98\ub97c \uad6c\uc131\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n## \ubb38\uc81c \ud574\uacb0\uc744 \uc704\ud55c \ud604\uc7ac \uc0dd\uac01\\n\\n### \uc11c\ubc84\uc758 \uae30\ub2a5 \ubd84\uc0b0\\n\uc704\uc5d0\uc11c \uc5b8\uae09\ud55c \uac83\ucc98\ub7fc \uc11c\ubc84\uc758 \uc131\ub2a5\uc774 \ubc1b\uccd0\uc8fc\uc9c0 \ubabb\ud560 \uac00\ub2a5\uc131\uc774 \uc788\uc2b5\ub2c8\ub2e4. \uc131\ub2a5\uc744 \uc0dd\uac01\ud574\uc11c \uc774\ub97c \ub098\ub204\uae30 \uc704\ud574\uc11c\ub294 \uba3c\uc800 \ub2e4\uc74c\uacfc \uac19\uc774 \uc11c\ubc84\ub97c \ubd84\uc0b0\ud560 \ud544\uc694\uac00 \uc788\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n(\ubb3c\ub860 \uc11c\ubc84\uac00 \ubabb \ubc84\ud2f8 \uacbd\uc6b0\uc774\uace0, \uc5b4\ub5bb\uac8c \ub098\ub258\ub294 \uc9c0\ub294 \ud68c\uc758 \ud6c4 \uacb0\uc815\ud558\uaca0\uc9c0\ub9cc!)\\n- `\uacf5\uacf5 API \ub370\uc774\ud130 \uc801\uc7ac \ubc0f \uc8fc\uae30\uc801\uc778 \uc5c5\ub370\uc774\ud2b8`\\n- `\uc2e4\uc2dc\uac04 \ud63c\uc7a1\ub3c4\ub97c \uc704\ud55c \uc2e4\uc2dc\uac04 \ub370\uc774\ud130 \uc5c5\ub370\uc774\ud2b8`\\n- `\uc694\uccad \ucc98\ub9ac`\\n\\n\uc801\uc740 \uc131\ub2a5\uc73c\ub85c \uc5c5\ub370\uc774\ud2b8\uc640 \uc694\uccad \ucc98\ub9ac\ub97c \ub3d9\uc2dc\uc5d0 \ud55c\ub2e4\uba74, \uc11c\ubc84\uac00 \uadf8 \ubd80\ud558\ub97c \uacac\ub514\uc9c0 \ubabb\ud560 \uc218\ub3c4 \uc788\uaca0\uc8e0?\\n\ub530\ub77c\uc11c \uc11c\ubc84\uc758 \uc5ed\ud560\uc744 \ubd84\ub2f4\ud558\uace0, \uac01 \uc5ed\ud560\uc5d0 \ucda9\uc2e4\ud558\ub3c4\ub85d \uad6c\ud604\ud55c\ub2e4\uba74 \ubcf4\ub2e4 \ud6a8\uc728\uc801\uc778 \ucc98\ub9ac\ub97c \ud560 \uc218 \uc788\uc744 \uac83\uc774\ub77c\uace0 \uc608\uc0c1\ub429\ub2c8\ub2e4.\\n\\n\\n### \uc548\uc815\uc801\uc778 Merge\\n\\n\uc798\ubabb\ub41c PR\uc744 Merge \uc2dc\ucf1c\ubc84\ub9ac\uba74 \uc5b4\ub5a8\uae4c\uc694? Conflict\ub3c4 \ub0a0 \uc218 \uc788\uace0.. \uc0dd\uac01\ub9cc\ud574\ub3c4 \ub054\ucc0d\ud569\ub2c8\ub2e4.\\n\\n\ucf54\ub4dc\ub9ac\ubdf0\ub97c \ud1b5\ud574\uc11c \uc774\ub97c \uc5b4\ub290\uc815\ub3c4 \ud574\uc18c\ud55c\ub2e4\uace0 \ud574\ub3c4, \uc0ac\ub78c\uc774\ub2e4\ubcf4\ub2c8 \uc2e4\uc218\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\uc774\ub97c \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c `Github Actions`\ub97c \uc774\uc6a9\ud558\uc5ec \ubbf8\ub9ac \uc9c0\uc815\ud574\ub454 Task\ub97c \uc2dc\ud0a4\uace0, \uc774\uac8c \ud1b5\uacfc\ud55c\ub2e4\uba74 Merge\ud560 \uc218 \uc788\ub3c4\ub85d \ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\\n\uc774\ub807\uac8c \ud55c\ub2e4\uba74 \ud611\uc5c5\ud560 \ub54c\uc5d0\ub3c4 \uc548\uc804\ud55c Merge\uac00 \uac00\ub2a5\ud558\ub2e4\uace0 \uc0dd\uac01\ud569\ub2c8\ub2e4.\\n\\n### CI/CD\\n\\n\uc9c0\uae08\uae4c\uc9c0 \uc6b0\ud14c\ucf54 \ubbf8\uc158\uc5d0\uc11c\ub294 \ubc30\ud3ec\ub97c \ub2e4\uc74c\uacfc \uac19\uc740 \uacfc\uc815\uc73c\ub85c \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\\n\\n1. \ubc30\ud3ec\\n2. \ub9ac\ud329\ud1a0\ub9c1 \ubc0f \ucee4\ubc0b\\n3. EC2 \uc11c\ubc84\uc5d0\uc11c \uc2a4\ud06c\ub9bd\ud2b8 \uc2e4\ud589\ud558\uc5ec \uc7ac\ubc30\ud3ec\\n\\n\uc774\ub807\uac8c \ubc30\ud3ec\ub97c \ud574\ub3c4 \uc0c1\uad00\uc5c6\uc9c0\ub9cc, \ub9e4\ubc88 \ub9ac\ud329\ud1a0\ub9c1\uacfc \uae30\ub2a5 \ucd94\uac00\ub97c \ud560 \ub54c\ub9c8\ub2e4 EC2 \uc11c\ubc84\ub85c \ub4e4\uc5b4\uac00\uc11c \ube4c\ub4dc \uc2a4\ud06c\ub9bd\ud2b8\ub97c \uc0ac\uc6a9\ud574\uc11c \uc11c\ubc84\ub97c \uc7ac\uc2dc\uc791 \ud574\uc57c\ud560\uae4c\uc694?\\n\uc774\ub807\uac8c \ub41c\ub2e4\uba74 \ubd88\ud544\uc694\ud55c \uc2dc\uac04\uc774 \uc18c\ubaa8\ub418\uace0, \ubd88\ud3b8\ud55c \uc810\uc774 \ub9ce\uc744 \uac83\uc774\ub77c\uace0 \uc0dd\uac01\ub429\ub2c8\ub2e4.\\n\\n\ub530\ub77c\uc11c CI/CD \uac1c\ub150\uc744 \uc801\uc6a9\ud574\uc11c \uc774 \uacfc\uc815\uc744 \uc790\ub3d9\uc73c\ub85c \uc9c4\ud589\ud558\uace0\uc790 \ud569\ub2c8\ub2e4.\\n\\n\uc774 \ubd80\ubd84\uc740 \ub354 \uc54c\uc544\ubd10\uc57c\uaca0\uc9c0\ub9cc, Github Actions\ub97c \uc774\uc6a9\ud574\uc11c \uc774\ub97c \uc801\uc6a9\ud558\uba74, \uc678\ubd80\uc5d0\uc11c SSH \uc811\uadfc\uc774 \ubd88\uac00\ub2a5\ud558\uae30 \ub54c\ubb38\uc5d0 Jenkins\ub97c \uc774\uc6a9\ud560 \uc608\uc815\uc785\ub2c8\ub2e4.\\n\uae43\ud5c8\ube0c\uc758 \ubcc0\ub3d9 \uc0ac\ud56d\uc744 Webhook\uc744 \uc774\uc6a9\ud574\uc11c Jenkins\ub85c \ub118\uae30\uace0, \uc774\ub97c \ud1b5\ud574 CI\ub97c \uc801\uc6a9\ud558\uba74 \ub420 \uac83 \uac19\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4.\\n\ubb3c\ub860 \uc774\ub294 \uacc4\ud68d\uc774\uace0 \uacf5\ubd80\ud558\uc9c0 \uc54a\uc740 \ub2e4\ub978 \ub0b4\uc6a9\uc774 \uc788\uc744 \uc218 \uc788\uae30 \ub54c\ubb38\uc5d0 \uc5b8\uc81c\ub4e0 \ubc14\ub014 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n### \ubb34\uc911\ub2e8 \ubc30\ud3ec \uc544\ud0a4\ud14d\ucc98 \uc801\uc6a9\\n\uc774 \ub610\ud55c \uc544\uc9c1\uc740 \uba3c \uc774\uc57c\uae30\uc9c0\ub9cc, \uace0\ub824\ud574 \ubcfc \uc0c1\ud669\uc774\ub77c\uc11c \uc801\uc5b4\ubd24\uc2b5\ub2c8\ub2e4.\\n\\n\uc0ac\uc6a9\uc790\uac00 \uc774\uc6a9\ud558\uace0 \uc788\ub294 \uc11c\ube44\uc2a4\uac00 \uac11\uc790\uae30 \uc911\ub2e8\ub41c\ub2e4\uba74 \uc5b4\ub5a8\uae4c\uc694?\\n\uc800\ub294 \ud654\uac00 \ub9ce\uc774 \ub0a0 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ud53c\uce58 \ubabb\ud560 \uc0ac\uc815\uc73c\ub85c \uc11c\ubc84\uac00 \ud130\uc838\ub3c4, \uc0ac\uc6a9\uc790\uac00 \uc11c\ube44\uc2a4\ub97c \uacc4\uc18d \uc774\uc6a9\ud560 \ubc29\ubc95\uc774 \uc5c6\uc744\uae4c\uc694?\\n\\n\uc774\ub7f0 \uace0\ubbfc\uc744 \ud574\uacb0\ud558\uae30 \uc704\ud574\uc11c \ub098\uc628 \uac1c\ub150\uc774 \ubb34\uc911\ub2e8 \ubc30\ud3ec\uc785\ub2c8\ub2e4.\\n\\n`\uce74\ub098\ub9ac\uc544 \ubc30\ud3ec`, `Blue/Green \ubc30\ud3ec`, `\ub864\ub9c1`\ub4f1 \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \uc704\ud55c \uc5ec\ub7ec\uac00\uc9c0 \uc804\ub7b5\uc740 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4.\\n\uc774 \ubd80\ubd84\uc740 \uc544\uc9c1\uc740 \uc11c\ubc84\uc758 \uba85\uc138\uac00 \uc815\ud655\ud558\uc9c0 \uc54a\uc544\uc11c \uc5b4\ub5a4 \ubc29\uc2dd\uc73c\ub85c \uc5b4\ub5bb\uac8c \ucc98\ub9ac\ud560 \uac83\uc778\uc9c0\uc5d0 \ub300\ud574\uc11c\ub294 \uc544\uc9c1 \uc815\ud560 \uc218\ub294 \uc5c6\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub294 \uba85\uc138\uac00 \ud655\uc2e4\ud558\uac8c \uc815\ud574\uc9c4 \ud6c4 \ud300\uc6d0\uacfc \uc7a5\ub2e8\uc810\uc744 \uc0c1\uc758\ud558\uba70 \uacb0\uc815\ud560 \uc77c\uc774\uae30 \ub54c\ubb38\uc5d0 \ud604\uc7ac\uae4c\uc9c0\ub294 \\"\uc774 \uc815\ub3c4\ub97c \uace0\ub824\ud558\uace0 \uc788\ub2e4.\\" \uc815\ub3c4\ub9cc \uc54c\uba74 \ub420 \uac83 \uac19\uc2b5\ub2c8\ub2e4."},{"id":"3","metadata":{"permalink":"/3","source":"@site/blog/2023-07-02-nunu-java-version.mdx","title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","description":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c \uc790\ubc14 11\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \ub108\ubb34 \uc775\uc219\ud574\uc9c4 \uc0c1\ud669\uc774\uc5b4\uc11c, java 11 \ub300\uc2e0 java 17\uc744 \uc4f0\ub824\uba74 \uc4f0\ub294 \ub300\uc2e0, \uc65c java 17\uc744 \uc4f0\uba74 \uc88b\uc740\uc9c0\uc5d0 \ub300\ud574\uc11c \uc124\ub4dd\uc744 \ud558\ub294 \uc2dc\uac04\uc774 \uc788\uc5b4\uc57c \ud558\ub294\ub370\uc694","date":"2023-07-02T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 2\uc77c","tags":[{"label":"java17","permalink":"/tags/java-17"},{"label":"java11","permalink":"/tags/java-11"},{"label":"record","permalink":"/tags/record"},{"label":"toList","permalink":"/tags/to-list"},{"label":"gc","permalink":"/tags/gc"}],"readingTime":5.88,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"3","title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","authors":["nunu"],"tags":["java17","java11","record","toList","gc"]},"prevItem":{"title":"\ud070 \ud2c0\uc5d0\uc11c \ubc14\ub77c\ubcf4\ub294 \uc11c\ubc84 \uc544\ud0a4\ud14d\ucc98 \uacc4\ud68d","permalink":"/4"},"nextItem":{"title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","permalink":"/2"}},"content":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c \uc790\ubc14 11\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \ub108\ubb34 \uc775\uc219\ud574\uc9c4 \uc0c1\ud669\uc774\uc5b4\uc11c, java 11 \ub300\uc2e0 java 17\uc744 \uc4f0\ub824\uba74 \uc4f0\ub294 \ub300\uc2e0, \uc65c java 17\uc744 \uc4f0\uba74 \uc88b\uc740\uc9c0\uc5d0 \ub300\ud574\uc11c \uc124\ub4dd\uc744 \ud558\ub294 \uc2dc\uac04\uc774 \uc788\uc5b4\uc57c \ud558\ub294\ub370\uc694\\n\\n\ucc98\uc74c\uc5d0\ub294 \ub2e8\uc21c\ud788 record \ud074\ub798\uc2a4\uac00 \uc88b\uc544\uc694, collect(Collectors.toList()); \ub300\uc2e0 toList() \ub9cc\uc73c\ub85c \ud574\uacb0\ud560 \uc218 \uc788\uc5b4\uc11c \uc88b\uc544\uc694\\n\\n\uae4c\uc9c0\ubc16\uc5d0 \uc124\uba85\ud560 \uc218 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uac83\ub9cc\uc73c\ub85c \ub3d9\uc758\ub97c \ud574\uc918\uc11c \uc77c\ub2e8 java 17 \uc744 \uc0ac\uc6a9\ud558\uae30\ub85c \ud588\uc9c0\ub9cc, \uc774\ubc88 \uae30\ud68c\uc5d0 \uc870\uae08 \ub354 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n## Java 17 \uacfc Java 11\uc758 \uc911\uc694\ud55c \ucc28\uc774\ub4e4\\n\\n\uae30\ub2a5\uc801\uc778 \ubd80\ubd84\uacfc, \uc228\uaca8\uc9c4 \ubd80\ubd84\uc744 \ub098\ub204\uc5b4\ubcfc \uc218 \uc788\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n## \uae30\ub2a5\uc801\uc778 \ucc28\uc774\uc810\\n\\n\uc5b8\uc81c\ub098 \uc9c1\uc811 \ucc28\uc774\ub97c \ubcf4\uba74 \ub354 \uc9c1\uad00\uc801\uc774\uae30 \ub54c\ubb38\uc5d0, \uc9c1\uc811 \ucf54\ub4dc\ub97c \ubcf4\uba74\uc11c \uc124\uba85\uc744 \ud574\ubcf4\ub824\uace0 \ud569\ub2c8\ub2e4\\n\\n### record \ud074\ub798\uc2a4\\n\\n\uac04\ub2e8\ud55c dto \ud074\ub798\uc2a4\ub97c \ub9cc\ub4e4\uc5c8\uc744 \ub54c \ucf54\ub4dc\uac00 \uc815\ub9d0 \uac04\ub2e8\ud574\uc9c0\ub294 \uac83\uc744 \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n#### Java 11\\n\\n```\\npublic class Dto {\\n private final int data;\\n\\n public Dto(int data) {\\n this.data = data;\\n }\\n\\n public int getData() {\\n return data;\\n }\\n}\\n```\\n\\nlombok \uc744 \uc0ac\uc6a9\ud588\uc744 \ub54c\\n\\n```\\n\\n@Getter\\n@AllArgsConstructor\\npublic class Dto {\\n private final int data;\\n}\\n```\\n\\n#### Java17\\n\\n```\\npublic record Record(int data) {\\n}\\n```\\n\\n\uc774\ub807\uac8c \ubcf4\uba74 \ud6e8\uc52c \uac04\ub2e8\ud574\uc9c4 \uac83\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n#### \uc608\uc0c1\ub418\ub294 \ubb38\uc81c\uc810\\n\\nobjectMapper\ub97c \uc0ac\uc6a9\ud558\uba74 \uc5b4\ub5bb\uac8c \ub418\ub098\uc694? noArgsConstructor \uac00 \ud544\uc694\ud558\uc9c0 \uc54a\ub098\uc694?\\n\\n```java\\nclass RecordTest {\\n\\n @Test\\n void objectMapper_\ub85c_\ubcc0\ud658() throws JsonProcessingException {\\n // given\\n ObjectMapper objectMapper = new ObjectMapper();\\n Record record = new Record(1);\\n\\n // when\\n String json = objectMapper.writeValueAsString(record);\\n\\n // then\\n assertEquals(\\"{\\\\\\"data\\\\\\":1}\\", json);\\n }\\n\\n @Test\\n void string_\uc5d0\uc11c_\uac1d\uccb4\ub85c_\ubcc0\ud658() throws JsonProcessingException {\\n // given\\n String json = \\"{\\\\\\"data\\\\\\":1}\\";\\n ObjectMapper objectMapper = new ObjectMapper();\\n\\n // when\\n Record record = objectMapper.readValue(json, Record.class);\\n\\n // then\\n assertEquals(1, record.data());\\n }\\n}\\n```\\n\\n\uc774 \ud14c\uc2a4\ud2b8\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \uac83\ucc98\ub7fc \uc131\uacf5\uc801\uc73c\ub85c deserialize, serialize \uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4\\n\\n### toList() method\\n\\n#### Java 11\\n\\n\uc774 \ubd80\ubd84\ub3c4 \uc815\ub9d0 \ud3b8\uc758\uc131\uc774 \ub192\ub2e4\uace0 \uc0dd\uac01\ud558\ub294 \ubd80\ubd84 \uc911 \ud558\ub098\uc778\ub370\uc694\\n\\nCollectors.toList() \ub300\uc2e0 toList() \ub9cc\uc73c\ub85c\ub3c4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud569\ub2c8\ub2e4\\n\\n```java\\npublic class ToListWith11 {\\n\\n public static void main(String[] args) {\\n List list = List.of(1, 2, 3, 4, 5);\\n List result = list.stream()\\n .filter(i -> i > 3)\\n .collect(Collectors.toList());\\n System.out.println(result);\\n }\\n}\\n```\\n\\n#### Java 17\\n\\n```java\\npublic class ToListWith17 {\\n\\n public static void main(String[] args) {\\n List list = List.of(1, 2, 3, 4, 5);\\n List result = list.stream()\\n .filter(i -> i > 3)\\n .toList();\\n System.out.println(result);\\n }\\n}\\n```\\n\\n### switch expression\\n\\n#### Java 11\\n\\n\uc6b0\ud14c\ucf54\uc5d0\uc11c\ub294 switch, case \ub97c \uc2eb\uc5b4\ud558\uae30\uc5d0 \ubcfc \uc218\ub294 \uc5c6\uaca0\uc9c0\ub9cc\\n\\nswitch \ubb38\uc5d0\ub3c4 \uc815\ub9d0 \ud3b8\ud558\uac8c \ubc14\ub00c\uc5c8\ub294\ub370\uc694\\n\\n```java\\npublic class SwitchWith11 {\\n\\n public static void main(String[] args) {\\n String day = \\"Sunday\\";\\n int result = 0;\\n switch (day) {\\n case \\"Monday\\":\\n result = 1;\\n break;\\n case \\"Tuesday\\":\\n result = 2;\\n break;\\n case \\"Wednesday\\":\\n result = 3;\\n break;\\n case \\"Thursday\\":\\n result = 4;\\n break;\\n case \\"Friday\\":\\n result = 5;\\n break;\\n case \\"Saturday\\":\\n result = 6;\\n break;\\n case \\"Sunday\\":\\n result = 7;\\n break;\\n }\\n System.out.println(result);\\n }\\n}\\n```\\n\\n#### Java 17\\n\\n```java\\npublic class SwitchWith17 {\\n\\n public static void main(String[] args) {\\n String day = \\"Sunday\\";\\n int result = switch (day) {\\n case \\"Monday\\" -> 1;\\n case \\"Tuesday\\" -> 2;\\n case \\"Wednesday\\" -> 3;\\n case \\"Thursday\\" -> 4;\\n case \\"Friday\\" -> 5;\\n case \\"Saturday\\" -> 6;\\n case \\"Sunday\\" -> 7;\\n default -> 0;\\n };\\n System.out.println(result);\\n }\\n}\\n```\\n\\n\ucf54\ub4dc \ub7c9\uc774 \uc5c4\uccad \uc904\uc5b4\ub4e0 \uac83\uc744 \ud655\uc778\ud558\uc2e4 \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n### instanceof pattern matching\\n\\n\ubb3c\ub860 instanceof \ub97c \uc0ac\uc6a9\ud560 \uacbd\uc6b0\uac00 \ub9ce\uc740\uac00? \ud558\uba74 \ub9ce\uc9c0\ub294 \uc54a\uaca0\uc9c0\ub9cc\\n\\n\uc544\ub798\uc640 \uac19\uc774 \ubcc0\uacbd\ub418\uc5c8\uc2b5\ub2c8\ub2e4\\n\\n#### Java 11\\n\\n```java\\npublic class InstanceOfWith11 {\\n\\n public static void main(String[] args) {\\n Object obj = \\"Hello\\";\\n if (obj instanceof String) {\\n String str = (String) obj;\\n System.out.println(str.toUpperCase());\\n }\\n }\\n}\\n```\\n\\n#### Java 17\\n\\n```java\\npublic class InstanceOfWith17 {\\n\\n public static void main(String[] args) {\\n Object obj = \\"Hello\\";\\n if (obj instanceof String str) {\\n System.out.println(str.toUpperCase());\\n }\\n }\\n}\\n```\\n\\n### number format\\n\\n\uc774 \uae30\ub2a5\uc740 12\uc5d0 \ub098\uc654\ub294\ub370\uc694\\n\\n\uc5b8\uc5b4\ubcc4\ub85c \uc22b\uc790\ub97c \ud45c\ud604\ud558\ub294 \ubc29\uc2dd\uc774 \ub2e4\ub974\uc9c0\ub9cc, \uc27d\uac8c \ud45c\ud604\ud560 \uc218 \uc788\ub3c4\ub85d \ub3c4\uc640\uc8fc\ub294 \uae30\ub2a5\uc785\ub2c8\ub2e4\\n\\n#### Java 17\\n\\n```java\\npublic class NumberFormatterWith11 {\\n public static void main(String[] args) {\\n int number = 1_000_000;\\n\\n String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);\\n\\n System.out.println(result.equals(\\"100\ub9cc\\"));\\n }\\n}\\n```\\n\\n\ub098\uba38\uc9c0 \ubd80\ubd84\uc740 \uc0ac\uc2e4 \uadf8\ub807\uac8c \ud070 \uc5ed\ud560\uc744 \ud560 \uac83 \uac19\uc9c0\ub294 \uc54a\uc544\uc11c \uc0dd\ub7b5\ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n## \uc228\uaca8\uc9c4 \ubd80\ubd84\ub4e4\\n\\n![gc throughput](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXhFJg%2Fbtsl9uZOa5R%2FrzrlotCERUqAWM2pknDwq0%2Fimg.png)\\n\\n\uc704\uc758 \uc0ac\uc9c4\uc740 gc \uc758 \ubc84\uc804\ubcc4 \ucc98\ub9ac\ub7c9\uc785\ub2c8\ub2e4.\\n\\nG1 GC \ub97c \uae30\uc900\uc73c\ub85c \ubcf8\ub2e4\uba74 Java8 \uacfc\uc758 \ucc28\uc774\ub294 15% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uace0, java 11\uacfc\ub294 10% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n![gc latency](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZusmb%2Fbtsl5jYN68u%2FWCKRCFnYjQK4AjkcHRNAt0%2Fimg.png)\\n\\n\uc704\uc758 \uc0ac\uc9c4\uc740 gc\uc758 \ubc84\uc804\ubcc4 \uc9c0\uc5f0\uc2dc\uac04\uc785\ub2c8\ub2e4.\\n\\nG1 GC \ub97c \uae30\uc900\uc73c\ub85c \ubcf8\ub2e4\uba74 Java8 \uacfc\uc758 \ucc28\uc774\ub294 30% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uace0, java 11\uacfc\ub294 25% \uc815\ub3c4 \ud5a5\uc0c1\ub418\uc5c8\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\uc640 \uac19\uc774, \ub2e8\uc21c\ud558\uac8c \uc0c8\ub85c\uc6b4 \uae30\ub2a5\ub9cc \ucd94\uac00\ub418\ub294 \uac83\uc774 \uc544\ub2c8\ub77c \uafb8\uc900\ud788 \uc131\ub2a5\ub3c4 \ud5a5\uc0c1\ub418\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \ubd80\ubd84\uc744 \uace0\ub824\ud588\uc744 \ub54c, Java 17\uc744 \uc0ac\uc6a9\ud558\ub294 \uac83\uc774 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4.\\n\\n\ucc38\uace0\\n\\n- [https://kstefanj.github.io/2021/11/24/gc-progress-8-17.html](https://kstefanj.github.io/2021/11/24/gc-progress-8-17.html)"},{"id":"2","metadata":{"permalink":"/2","source":"@site/blog/2023-07-01-nunu-gitbranch.mdx","title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","description":"\ud604\uc7ac \uc0c1\ud669\uc740 \uc5b4\ub5a4\ub370?","date":"2023-07-01T00:00:00.000Z","formattedDate":"2023\ub144 7\uc6d4 1\uc77c","tags":[{"label":"git","permalink":"/tags/git"},{"label":"branch","permalink":"/tags/branch"},{"label":"git branch","permalink":"/tags/git-branch"},{"label":"github flow","permalink":"/tags/github-flow"},{"label":"gitlab flow","permalink":"/tags/gitlab-flow"},{"label":"git flow","permalink":"/tags/git-flow"}],"readingTime":10.735,"hasTruncateMarker":false,"authors":[{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"}],"frontMatter":{"slug":"2","title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","authors":["nunu"],"tags":["git","branch","git branch","github flow","gitlab flow","git flow"]},"prevItem":{"title":"Java 17 \uc744 \ub3c4\uc785\ud55c \uc774\uc720","permalink":"/3"},"nextItem":{"title":"Hello World","permalink":"/1"}},"content":"## \ud604\uc7ac \uc0c1\ud669\uc740 \uc5b4\ub5a4\ub370?\\n\\n\ud604\uc7ac \uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4\uc5d0\uc11c\ub294 \ud504\ub860\ud2b8 \ucf54\ub4dc\uc640 \ubc31\uc5d4\ub4dc \ucf54\ub4dc\uac00 \uac19\uc740 \ub808\ud3ec\uc9c0\ud1a0\ub9ac\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\ud504\ub860\ud2b8\uc640 \ubc31\uc5d4\ub4dc\uac00 \uac19\uc774 \uc791\uc5c5\ud558\uae30\uc5d0, \uc758\ub3c4\uce58 \uc54a\uc740 \ucda9\ub3cc\uc774 \uc790\uc8fc \uc0dd\uae38 \uc218 \uc788\ub294 \uad6c\uc870\uc774\uae30\uc5d0, \uc774\ub97c git branch \uc804\ub7b5\uc73c\ub85c \ucda9\ub3cc\uc744 \uc904\uc774\uace0\uc790 \ud569\ub2c8\ub2e4\\n\\n## Git Branch \uc804\ub7b5\uc774\ub780?\\n\\ngit\uc744 \uc0ac\uc6a9\ud574\uc11c \uc18c\ud504\ud2b8\uc6e8\uc5b4 \uac1c\ubc1c\uc744 \uad00\ub9ac\ud558\ub294 \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n\uc5ec\ub7ec \uac1c\ubc1c\uc790\uac00 \ub3d9\uc2dc\uc5d0 \uc791\uc5c5\ud558\uace0 \ucf54\ub4dc\ub97c \ud1b5\ud569\ud560 \ub54c \uc0dd\uae30\ub294 \ucda9\ub3cc\uc744 \ud6a8\uc728\uc801\uc73c\ub85c \uc870\uc815\ud558\uae30 \uc704\ud55c \ubc29\ubc95\uc785\ub2c8\ub2e4.\\n\\n## \uc65c git branch \uc804\ub7b5\uc774 \uc911\uc694\ud55c\ub370?\\n\\n\uc544\ub798\uc5d0 \uc788\ub294 4\uac00\uc9c0\ub97c \uc81c\uc678\ud558\uace0\ub3c4 \ud6e8\uc52c \ub9ce\uc740 \uc7a5\uc810\uc774 \uc788\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### 1\\\\. \ub3d9\uc2dc \uc791\uc5c5\uc774 \ud3b8\ud558\ub2e4\\n\\n\uc5ec\ub7ec \uc0ac\ub78c\uc774 \ub3c5\ub9bd\uc801\uc73c\ub85c \uc791\uc5c5\ud558\uace0, \ucee4\ubc0b\uc744 \ud560 \ub54c, \uc790\uc2e0\uc758 \ube0c\ub79c\uce58\uc5d0\uc11c \ubcc0\uacbd \uc0ac\ud56d\uc744 \ucee4\ubc0b\ud558\uac8c \ub429\ub2c8\ub2e4.\\n\\n\ube0c\ub79c\uce58\uac00 \ubcd1\ud569\ub420 \ub54c\ub9cc \ucda9\ub3cc\uc744 \ud574\uacb0\ud558\uba74 \ub418\ub2c8, \uc544\ubb34 \uaddc\uce59\uc774 \uc5c6\ub294 \uac83\ubcf4\ub2e4 \ucda9\ub3cc \uc2dc\uc810\uc774 \uba85\ud655\ud574\uc9c0\uae30\uc5d0 \uc0dd\uc0b0\uc131\uc744 \ub192\uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### 2\\\\. \ubaa9\uc801\uc774 \uba85\ud655\ud55c \ube0c\ub79c\uce58\\n\\n\uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc758 \uc0c1\ud0dc\uc5d0 \uba87 \uac00\uc9c0\uac00 \uc788\ub294\ub370, \uc548\uc815\ub41c \ud504\ub85c\ub355\uc158, \ud14c\uc2a4\ud2b8 \ud658\uacbd, \uae30\ub2a5 \ucd94\uac00 \ud658\uacbd... \ub4f1\uc774 \uc788\uc2b5\ub2c8\ub2e4\\n\\n\uc5ec\ub7ec \uae30\ub2a5\ubcc4 \ube0c\ub79c\uce58(\uc548\uc815\ub41c \ubc84\uc804\uc758 \ucf54\ub4dc\ub9cc\uc774 \uad00\ub9ac\ub418\ub294 \ube0c\ub79c\uce58, \ud14c\uc2a4\ud2b8 \ud658\uacbd\uc744 \uc704\ud55c \ube0c\ub79c\uce58, \uae30\ub2a5 \ucd94\uac00\ub97c \uc704\ud55c \ube0c\ub79c\uce58)\ub97c\\n\\n\ub124\uc774\ubc0d\uc744 \ud1b5\ud574 \uad6c\ubd84\ud558\uba74 \uac01\uac01\uc758 \ube0c\ub79c\uce58\uc5d0 \ub300\ud574\uc11c \ucd94\uac00\uc801\uc778 \uc124\uba85\uc744 \ud560 \ud544\uc694 \uc5c6\uc774 \uba85\ud655\ud558\uac8c \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### 3\\\\. \ubc30\ud3ec \ud30c\uc774\ud504\ub77c\uc778 \uad00\ub9ac\uac00 \ud3b8\ud568\\n\\n\ube0c\ub79c\uce58\uac00 \ub124\uc774\ubc0d\uc73c\ub85c \uba85\ud655\ud558\uac8c \uad6c\ubd84\uc774 \ub418\uc5b4\uc788\ub2e4\uba74, \uc870\uac74\uc744 \uc124\uc815\ud558\uae30 \uc27d\uc2b5\ub2c8\ub2e4.\\n\\n\ud2b9\uc815 \ud0c0\uc785\uc758 \ube0c\ub79c\uce58\uc5d0 push \ub418\uc5c8\uc744 \ub54c, pull request\ub97c \ub9cc\ub4e4\uc5c8\uc744 \ub54c \uac19\uc740 \uc870\uac74\uc5d0 \ub530\ub978 \uc2a4\ud06c\ub9bd\ud2b8\ub97c \ub9cc\ub4e4\uc5b4\ub454\ub2e4\uba74 CI/CD\ub97c \uad6c\ucd95\ud558\uae30 \uc27d\uc2b5\ub2c8\ub2e4.\\n\\n#### 4\\\\. \ubc84\uc804 \uad00\ub9ac\uac00 \ud3b8\ub9ac\ud558\ub2e4\\n\\n\uc11c\ubc84\uc5d0 \ubb38\uc81c\uac00 \uc0dd\uacbc\uc744 \ub54c, \uc5b4\ub5a4 \ube0c\ub79c\uce58\ub85c \ub3cc\uc544\uac00\uc11c \ub864\ubc31\uc744 \ud574\uc57c \ud558\ub294\uc9c0\uc5d0 \ub300\ud55c \uac83\ub4e4\uc774 \uba85\ud655\ud569\ub2c8\ub2e4.\\n\\n\uc548\uc815\ub41c \ube0c\ub79c\uce58\uac00 \uc5b4\ub5a4 \uac83\uc778\uc9c0 \uba85\ud655\ud558\uae30\uc5d0, \ub864\ubc31 \uacfc\uc815\uc5d0 \ub300\ud55c \uc758\uc0ac\uacb0\uc815\uc744 \uc904\uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uadf8\ub7ec\uba74 \uc5b4\ub5a4 \uc885\ub958\uac00 \uc788\ub294\uc9c0 \ub354 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## Git Branch \uc804\ub7b5\uc758 \uc885\ub958\ub294?\\n\\n\ucd1d 3\uac00\uc9c0\uc758 \uc804\ub7b5\uc774 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\\\\. Github Flow\\n\\n2\\\\. Gitlab Flow\\n\\n3\\\\. Git Flow\\n\\ngit\uc744 \uc0ac\uc6a9\ud558\uae30\uc5d0, Git Flow\ub77c\ub294 \ub124\uc774\ubc0d\uc774 \uac00\uc7a5 \uc9c1\uad00\uc801\uc774\uace0 \uc720\uba85\ud55c\ub370\uc694.\xa0\\n\\n3\uac00\uc9c0 \uc804\ub7b5 \uc911\uc5d0\uc11c \uac00\uc7a5 \ubcf5\uc7a1\ud558\uae30\uc5d0, \uc26c\uc6b4 \uc21c\uc11c\ub300\ub85c \uc9c4\ud589\ud574 \ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n## 1\\\\. Github Flow\\n\\n\uadf8\ub9bc\uc73c\ub85c flow \uac04\ub2e8\ud558\uac8c \ubcf4\uace0 \uac00\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![img](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblgfI6%2FbtslEWRFdaJ%2F3KmwR2yqlfgKk0msnufYNk%2Fimg.png)\\n\\n![img2](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtUzxm%2FbtslJ1xWHzy%2FMP0s11FoCTKpqwQnUJUm30%2Fimg.png)\\n\\n\ube0c\ub79c\uce58\ub294 \ucd1d 2\uac00\uc9c0 \uc885\ub958\uac00 \uc874\uc7ac\ud569\ub2c8\ub2e4\\n\\n#### 1\\\\. master \ube0c\ub79c\uce58\\n\\n\uc5ec\uae30\uc5d0 \uba38\uc9c0\uac00 \ub418\uba74 \ubc30\ud3ec\uac00 \ub418\ub3c4\ub85d CD\ub97c \uc5f0\uacb0\ud574 \ub193\uc740 \uacbd\uc6b0\uac00 \ub9ce\uc2b5\ub2c8\ub2e4.\\n\\n\uc548\uc815\ub41c \ubc84\uc804\uc758 \ucf54\ub4dc\uac00 \uad00\ub9ac\ub418\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4.\\n\\n#### 2\\\\. feature \ube0c\ub79c\uce58\\n\\n\uae30\ub2a5 \ucd94\uac00, \ubc84\uadf8 \uc218\uc815 \ub4f1 \ubaa8\ub4e0 \uc791\uc5c5\uc740 feature \ube0c\ub79c\uce58\uc5d0\uc11c \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\nmaster \ube0c\ub79c\uce58\uc5d0\uc11c \uc0c8\ub85c\uc6b4 \ube0c\ub79c\uce58\ub97c \ub9cc\ub4e4\uc5b4\uc11c, \ub9c8\uc2a4\ud130\ub85c \uba38\uc9c0\ub418\ub294 \ub2e8\uc21c\ud55c \uc0ac\uc774\ud074\uc744 \uac00\uc9c0\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### \uc7a5\uc810\\n\\n\uc704\uc5d0\uc11c \ubcfc \uc218 \uc788\ub294 \uac83\ucc98\ub7fc 2\uc885\ub958\uc758 \ube0c\ub79c\uce58\ub9cc \uc788\uae30\uc5d0, \uc815\ub9d0 \uac04\ub2e8\ud569\ub2c8\ub2e4.\\n\\n\ud559\uc2b5 \uacfc\uc815\uae4c\uc9c0\uc758 \ub7ec\ub2dd \ucee4\ube0c\uac00 \uac70\uc758 \uc5c6\ub2e4\uc2dc\ud53c \ud558\uae30\uc5d0, \uac04\ub2e8\ud55c \ud504\ub85c\uc81d\ud2b8\uc5d0 \uc801\uc6a9\ud558\uae30 \uc815\ub9d0 \uc88b\uc2b5\ub2c8\ub2e4.\\n\\n\ub9b4\ub9ac\uc988 \ub418\uc9c0 \uc54a\uc740 \ucf54\ub4dc\uac00 \ucd5c\uc18c\ud654\ub429\ub2c8\ub2e4. \ucd5c\uc2e0 \ubc84\uc804\uc758 \ucf54\ub4dc\uc640 \ucd5c\ub300\ud55c \ube60\ub974\uac8c \ub3d9\uae30\ud654\ub97c \uacc4\uc18d\ud574\uc11c \uc2dc\ud0ac \uc218 \uc788\uc2b5\ub2c8\ub2e4\\n\\n#### \ub2e8\uc810\\n\\n\ubaa8\ub4e0 \ucf54\ub4dc\ub294 \ub2e4 master \ube0c\ub79c\uce58\uc5d0 \uba38\uc9c0\uac00 \ub418\uc5b4\uc57c \ud55c\ub2e4\ub294 \uc810\uc774 \uac1c\ubc1c \uc11c\ubc84\uc640, \uc6b4\uc601\uc11c\ubc84\ub97c \ub098\ub204\uae30 \uc560\ub9e4\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uac1c\ubc1c \uc11c\ubc84\uc5d0 \ubc30\ud3ec\ub97c \ud558\uace0 \uc2f6\uc740 \uc0c1\ud669\uc774\ub77c\uba74, master\uc5d0 \uba38\uc9c0\uac00 \ub418\uc5b4\uc57c \ud569\ub2c8\ub2e4.\\n\\n\uba38\uc9c0\uac00 \ub41c \uc774\ud6c4\uc5d0 cd \ud30c\uc774\ud504\ub77c\uc778\uc744 \ud1b5\ud574\uc11c \uac1c\ubc1c \uc11c\ubc84\uc640 \uc6b4\uc601 \uc11c\ubc84 \ubaa8\ub450\uc5d0 \ubc30\ud3ec\uac00 \ub429\ub2c8\ub2e4.\\n\\n\uc5ec\ub7ec \ud658\uacbd\uc744 \ub098\ub204\uace0 \uad00\ub9ac\ub97c \ud558\uace0 \uc2f6\uc73c\uc2dc\ub2e4\uba74 \ub2e4\uc74c\uc5d0 \uc18c\uac1c\ud574\ub4dc\ub9b4 \uc804\ub7b5\uc744 \uc0ac\uc6a9\ud574 \ubcf4\uc154\ub3c4 \uc88b\uc744 \uac83 \uac19\uc2b5\ub2c8\ub2e4\\n\\n## 2\\\\. Gitlab Flow\\n\\n\uadf8\ub9bc\uc73c\ub85c flow \uac04\ub2e8\ud558\uac8c \ubcf4\uace0 \uac00\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4.\\n\\n![img2](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdlarwn%2FbtslKkYqqTR%2FXi8NnZIEXahoVFusk0xV31%2Fimg.png)\\n\\n\ubc11\uc5d0 \ud658\uacbd\uc740 \ucd1d 2\uac1c\uc758 \uc11c\ubc84\uac00 \uc874\uc7ac\ud560 \ub54c\ub97c \uac00\uc815\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\\\\. pre-production \uc11c\ubc84\\n\\n2\\\\. production \uc11c\ubc84\\n\\n\ud3b8\uc758\ub97c \uc704\ud574 main\uc5d0 \uba38\uc9c0\ub418\ub294 \uacfc\uc815\uc740 \uac04\ub2e8\ud558\uac8c \ud45c\ud604\ud588\uc2b5\ub2c8\ub2e4.\\n\\n![img3](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdbkNc9%2FbtslJ0MBrWb%2F0CT7DVQoCDFOpbqyAko9mk%2Fimg.png)\\n\\n#### \ube0c\ub79c\uce58 \uc885\ub958\\n\\n\ucd1d 3\uac00\uc9c0 \ube0c\ub79c\uce58\uac00 \ud544\uc694\ud558\uace0, \ucd94\uac00\uc5d0 \ub530\ub77c\uc11c \ub354 \ucd94\uac00\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n1\\\\. main(or develop) \ube0c\ub79c\uce58\\n\\n\uae30\ub2a5\uc5d0 \ub300\ud55c \uac1c\ubc1c\uc774 \uc644\ub8cc\ub418\uc5c8\uc9c0\ub9cc, \uc5ec\uae30\uc5d0 \uba38\uc9c0\ub418\uc5b4\ub3c4 \ubc14\ub85c \ubc30\ud3ec\ub418\uc9c0\ub294 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\n2\\\\. feature\ube0c\ub79c\uce58\\n\\n\uae30\ub2a5\uc744 \uac1c\ubc1c\ud558\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4. Github Flow \uc640\ub3c4 \uc720\uc0ac\ud569\ub2c8\ub2e4.\\n\\n3\\\\. production \ube0c\ub79c\uce58\\n\\n\uc2e4\uc81c \ubc30\ud3ec\uac00 \uc77c\uc5b4\ub098\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4.\xa0\\n\\n\uc5ec\uae30\uc5d0 \uba38\uc9c0\uac00 \ub418\ub294 \uc21c\uac04 \ubc30\ud3ec\uac00 \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\n\uc704 \uc0ac\uc9c4\uc5d0 \uc788\ub294 \uac83\ucc98\ub7fc, \ud544\uc694\uc5d0 \ub530\ub77c\uc11c pre-production\uc774\ub098, staging \uac19\uc740 \ud658\uacbd\uc5d0 \ub530\ub978 \ube0c\ub79c\uce58\ub97c \ucd94\uac00\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n#### \ud2b9\uc9d5\\n\\n1\\\\. \ubb34\uc870\uac74 \ub2e8\ubc29\ud5a5\uc73c\ub85c \uba38\uc9c0\uac00 \uc77c\uc5b4\ub0a9\ub2c8\ub2e4.\\n\\n\uae34\uae09\ud558\uac8c \ub77c\uc774\ube0c \uc11c\ubc84\uc5d0 \uc218\uc815\uc744 \ud574\uc57c \ud560 \ub54c, production \ubd80\ud130 \uc2dc\uc791\ud558\ub294 \uac83\uc774 \uc544\ub2cc, main \ubd80\ud130 \ucc28\uadfc\ucc28\uadfc \uc62c\ub77c\uac00\uc57c \ud569\ub2c8\ub2e4\\n\\n2\\\\. \ud658\uacbd\uc5d0 \ub530\ub77c \ube0c\ub79c\uce58 \uc885\ub958\uac00 \ub298\uc5b4\ub0a0 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n\uc704 \uc0ac\uc9c4\uc5d0\uc11c\ub294 pre-production \uc774 \uadf8 \uc608\uc2dc\uac00 \ub418\uaca0\ub124\uc694.\\n\\n#### \uc7a5\uc810\\n\\n1\\\\. Github Flow\uc5d0\uc11c \ud658\uacbd\ubcc4 \ube0c\ub79c\uce58\ub97c \ud1b5\ud574\uc11c \uac1c\ubc1c \uc11c\ubc84\ub098 pre-production \uc11c\ubc84\uc5d0 \ubc84\uc804\uc744 \uae54\ub054\ud558\uac8c \uad00\ub9ac\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.\\n\\n## 3\\\\. Git Flow\\n\\n\ube0c\ub79c\uce58 \uc804\ub7b5 \uc911 \uac00\uc7a5 \ucc98\uc74c\uc73c\ub85c \uc720\uba85\ud574\uc9c4 \ube0c\ub79c\uce58 \uc804\ub7b5\uc785\ub2c8\ub2e4.\\n\\n\ubc30\ud3ec\uac00 \ud2b9\uc815 \uc8fc\uae30\ub97c \uac00\uc9c0\uace0 \uc788\ub294 \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc77c \ub54c, \uac00\uc7a5 \uc801\ud569\ud569\ub2c8\ub2e4.\\n\\n\uac00\uc7a5 \ubcf5\uc7a1\ud55c \uc804\ub7b5\uc744 \uac00\uc9c0\uace0 \uc788\uc5b4\uc11c, \ubaa8\ub450\uac00 \ube0c\ub79c\uce58 \uc804\ub7b5\uc5d0 \ub300\ud574\uc11c \uc774\ud574\ud558\uace0 \uc788\ub2e4\uba74 \uc5ed\ud560\uc5d0 \ub530\ub978 \uae54\ub054\ud55c \ubd84\ub9ac\uac00 \uac00\ub2a5\ud569\ub2c8\ub2e4\\n\\n\uadf8\ub9bc\uc73c\ub85c \ubcf4\uace0 \uac00\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4\\n\\n![img4](https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd9WzKn%2FbtslKdkAHNP%2F2fCAqKSVxtPVWqYnBS8juk%2Fimg.png)\\n\\n\uac00\uc7a5 \uc720\uba85\ud55c \ube0c\ub79c\uce58 \uc804\ub7b5\uc774\uc9c0\ub9cc, \uac00\uc7a5 \uc5b4\ub824\uc6b4 \uc804\ub7b5\uc774\uae30\ub3c4 \ud569\ub2c8\ub2e4.\\n\\n#### \ud2b9\uc9d5\\n\\n1\\\\. \ube0c\ub79c\uce58\uc5d0 \ub300\ud574\uc11c \uc591\ubc29\ud5a5\uc73c\ub85c \uba38\uc9c0\uac00 \uc77c\uc5b4\ub0a9\ub2c8\ub2e4\\n\\nrelease \ube0c\ub79c\uce58\uc5d0\uc11c \ubc84\uadf8 \uc218\uc815\uc774 \uc77c\uc5b4\ub098\uba74, develop \ube0c\ub79c\uce58\uc5d0\ub3c4 \uba38\uc9c0\ud574\uc918\uc57c \ud569\ub2c8\ub2e4.\\n\\nhotfix \ube0c\ub79c\uce58\ub97c main \ube0c\ub79c\uce58\ubfd0\ub9cc \uc544\ub2c8\ub77c, develop \ube0c\ub79c\uce58\uc5d0\ub3c4 \uba38\uc9c0\ud574\uc918\uc57c \ud569\ub2c8\ub2e4\\n\\n\ube0c\ub79c\uce58\uc758 \uc885\ub958\uac00 5\uac00\uc9c0\ub098 \ub429\ub2c8\ub2e4\\n\\n1\\\\. main\\n\\nproduction \uc774 \ubc30\ud3ec\ub418\uc5c8\uc744 \ub54c, \uc774 \ube0c\ub79c\uce58\uc5d0 \uba38\uc9c0\ub418\ub294 \uac83\uc774 \uae30\uc900\uc774 \ub429\ub2c8\ub2e4.\\n\\n2\\\\. develop\xa0\\n\\n\uc704\uc5d0\uc11c \uc124\uba85\ub4dc\ub838\ub358 \ube0c\ub79c\uce58\ub4e4\uacfc \ud070 \ucc28\uc774\uac00 \uc5c6\uc774 \ubc30\ud3ec \uc804 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4.\\n\\n3\\\\. feature\\n\\n\uae30\ub2a5\uc744 \uac1c\ubc1c\ud560 \ub54c \uc0ac\uc6a9\ud558\ub294 \ube0c\ub79c\uce58\uc785\ub2c8\ub2e4. \uc774\uac83\ub3c4 \uc704\uc640 \ud070 \ucc28\uc774\uac00 \uc5c6\uc2b5\ub2c8\ub2e4\\n\\n4\\\\. release\\n\\nGitlab Flow\uc5d0\uc11c pre-production\uc5d0 \ud574\ub2f9\ud55c\ub2e4\uace0 \ubd10\ub3c4 \ubb34\ubc29\ud569\ub2c8\ub2e4.\\n\\n\uc5ec\uae30\uc11c \ubc84\uadf8 \uc218\uc815\uc774 \uc77c\uc5b4\ub0ac\uc744 \uacbd\uc6b0\uc5d0,\xa0 develop\uc5d0 \uba38\uc9c0\ud558\ub294 \uac83\uc744 \uae4c\uba39\uc73c\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\n5\\\\. hotfix\\n\\nmain \ube0c\ub79c\uce58\uc5d0\uc11c \uc0dd\uc131\ub41c \ube0c\ub79c\uce58\ub85c, \uae34\uae09\ud55c \ubcc0\uacbd\uc0ac\ud56d\uc744 \ucc98\ub9ac\ud569\ub2c8\ub2e4.\\n\\n\uc774\ub54c, develop\uc5d0 \uba38\uc9c0\ud558\ub294 \uac83\uc744 \uae5c\ube61\ud558\uba74 \uc548 \ub429\ub2c8\ub2e4.\\n\\n\ub354 \uc790\uc138\ud558\uac8c \uc54c\uc544\ubcf4\uc2e4 \ubd84\uc740 \uc544\ub798 \ub9c1\ud06c\ub4e4\uc744 \ud655\uc778\ud574 \ubcf4\uc138\uc694\\n\\n## \uc6b0\ub9ac \ud504\ub85c\uc81d\ud2b8\uc5d0\ub294 \uc5b4\ub5a4 \uac83\uc774 \uc801\uc808\ud560\uae4c?\\n\\n\ub098\uc911\uc5d0 \uac1c\ubc1c \uc11c\ubc84 \ud639\uc740 \uc2a4\ud14c\uc774\uc9d5 \uc11c\ubc84\ub97c \ub450\uace0 \uc2f6\uae30\uc5d0, \uc774 \ubd80\ubd84\uc5d0 \ub300\ud55c \ucc98\ub9ac\uac00 \ubd80\uc871\ud55c Github Flow\ub294 \uc801\uc808\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.\\n\\nGit Flow\ub294 \uae54\ub054\ud558\uac8c \ucc98\ub9ac\ud560 \uc218 \uc788\uc9c0\ub9cc, \ub7ec\ub2dd \ucee4\ube0c\uac00 Gitlab Flow \ubcf4\ub2e4 \uc57d\uac04 \ub354 \uc788\uc5b4\uc11c, \ube60\ub974\uac8c \uac1c\ubc1c\ud558\ub294 \ucde8\uc9c0\uc5d0 \ub9de\uc9c0 \uc54a\uc544 \ubcf4\uc600\uc2b5\ub2c8\ub2e4.\\n\\n\uc774\ub7f0 \uacfc\uc815\uc744 \ud1b5\ud574\uc11c Gitlab Flow\ub97c \uc0ac\uc6a9\ud558\ub824\uace0 \ud569\ub2c8\ub2e4\xa0\\n\\n\ucc38\uace0\\n\\n[https://techblog.woowahan.com/2553/](https://techblog.woowahan.com/2553/)\\n\\n[https://docs.gitlab.com/ee/topics/gitlab\\\\_flow.html](https://docs.gitlab.com/ee/topics/gitlab_flow.html)"},{"id":"1","metadata":{"permalink":"/1","source":"@site/blog/2023-06-29-hello-car-ffeine.mdx","title":"Hello World","description":"\uc548\ub155\ud558\uc138\uc694","date":"2023-06-29T00:00:00.000Z","formattedDate":"2023\ub144 6\uc6d4 29\uc77c","tags":[{"label":"hello","permalink":"/tags/hello"},{"label":"world","permalink":"/tags/world"}],"readingTime":0.025,"hasTruncateMarker":false,"authors":[{"name":"\ubc15\uc2a4\ud130","title":"Backend","url":"https://github.com/drunkenhw","imageURL":"https://github.com/drunkenhw.png","key":"boxster"},{"name":"\ub204\ub204","title":"Backend","url":"https://github.com/be-student","imageURL":"https://github.com/be-student.png","key":"nunu"},{"name":"\uc81c\uc774","title":"Backend","url":"https://github.com/sosow0212","imageURL":"https://github.com/sosow0212.png","key":"jay"},{"name":"\ud0a4\uc544\ub77c","title":"Backend","url":"https://github.com/kiarakim","imageURL":"https://github.com/kiarakim.png","key":"kiara"},{"name":"\uc57c\ubbf8","title":"Frontend","url":"https://github.com/feb-dain","imageURL":"https://github.com/feb-dain.png","key":"yummy"},{"name":"\uc13c\ud2b8","title":"Frontend","url":"https://github.com/kyw0716","imageURL":"https://github.com/kyw0716.png","key":"scent"},{"name":"\uac00\ube0c\ub9ac\uc5d8","title":"Frontend","url":"https://github.com/gabrielyoon7","imageURL":"https://github.com/gabrielyoon7.png","key":"gabriel"}],"frontMatter":{"slug":"1","title":"Hello World","authors":["boxster","nunu","jay","kiara","yummy","scent","gabriel"],"tags":["hello","world"]},"prevItem":{"title":"git branch \uc804\ub7b5 \uc791\uc131\ud574\ubcf4\uae30","permalink":"/2"}},"content":"\uc548\ub155\ud558\uc138\uc694"}]}')}}]); \ No newline at end of file diff --git a/assets/js/32b2299c.d0b6d577.js b/assets/js/32b2299c.d0b6d577.js new file mode 100644 index 00000000..752ce4ab --- /dev/null +++ b/assets/js/32b2299c.d0b6d577.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[970],{5280:e=>{e.exports=JSON.parse('{"permalink":"/page/41","page":41,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/40","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/35293ec4.4499a773.js b/assets/js/35293ec4.cff587ca.js similarity index 57% rename from assets/js/35293ec4.4499a773.js rename to assets/js/35293ec4.cff587ca.js index 7104445e..3b47f427 100644 --- a/assets/js/35293ec4.4499a773.js +++ b/assets/js/35293ec4.cff587ca.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7697],{14:e=>{e.exports=JSON.parse('{"permalink":"/page/20","page":20,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/19","nextPage":"/page/21","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7697],{14:e=>{e.exports=JSON.parse('{"permalink":"/page/20","page":20,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/19","nextPage":"/page/21","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/37d538cb.ce775806.js b/assets/js/37d538cb.ce775806.js new file mode 100644 index 00000000..3616c947 --- /dev/null +++ b/assets/js/37d538cb.ce775806.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7654],{74875:e=>{e.exports=JSON.parse('{"permalink":"/tags/infra/page/2","page":2,"postsPerPage":1,"totalPages":2,"totalCount":2,"previousPage":"/tags/infra","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/38d8699e.7497c290.js b/assets/js/38d8699e.2fab8607.js similarity index 57% rename from assets/js/38d8699e.7497c290.js rename to assets/js/38d8699e.2fab8607.js index 17e90e14..718f9517 100644 --- a/assets/js/38d8699e.7497c290.js +++ b/assets/js/38d8699e.2fab8607.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[471],{97481:e=>{e.exports=JSON.parse('{"permalink":"/page/15","page":15,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/14","nextPage":"/page/16","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[471],{97481:e=>{e.exports=JSON.parse('{"permalink":"/page/15","page":15,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/14","nextPage":"/page/16","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/417cb48d.18943eef.js b/assets/js/417cb48d.18943eef.js deleted file mode 100644 index 842a42ae..00000000 --- a/assets/js/417cb48d.18943eef.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9993],{22278:e=>{e.exports=JSON.parse('{"permalink":"/tags/infra","page":1,"postsPerPage":1,"totalPages":1,"totalCount":1,"blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/417cb48d.5da28777.js b/assets/js/417cb48d.5da28777.js new file mode 100644 index 00000000..75c15830 --- /dev/null +++ b/assets/js/417cb48d.5da28777.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9993],{22278:e=>{e.exports=JSON.parse('{"permalink":"/tags/infra","page":1,"postsPerPage":1,"totalPages":2,"totalCount":2,"nextPage":"/tags/infra/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/43bed105.6e62c2bd.js b/assets/js/43bed105.6c16d091.js similarity index 67% rename from assets/js/43bed105.6e62c2bd.js rename to assets/js/43bed105.6c16d091.js index 65781e86..ee70f6d9 100644 --- a/assets/js/43bed105.6e62c2bd.js +++ b/assets/js/43bed105.6c16d091.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5061],{57306:e=>{e.exports=JSON.parse('{"label":"CD","permalink":"/tags/cd","allTagsPath":"/tags","count":2}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5061],{57306:e=>{e.exports=JSON.parse('{"label":"cd","permalink":"/tags/cd","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file diff --git a/assets/js/494882d1.c7e28fe8.js b/assets/js/494882d1.f18f9d7e.js similarity index 57% rename from assets/js/494882d1.c7e28fe8.js rename to assets/js/494882d1.f18f9d7e.js index 177c19c1..ebd39555 100644 --- a/assets/js/494882d1.c7e28fe8.js +++ b/assets/js/494882d1.f18f9d7e.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4471],{2098:e=>{e.exports=JSON.parse('{"permalink":"/page/37","page":37,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/36","nextPage":"/page/38","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4471],{2098:e=>{e.exports=JSON.parse('{"permalink":"/page/37","page":37,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/36","nextPage":"/page/38","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/4959fc42.03feb34b.js b/assets/js/4959fc42.dde05cd9.js similarity index 57% rename from assets/js/4959fc42.03feb34b.js rename to assets/js/4959fc42.dde05cd9.js index b3f4f2ab..cb90d654 100644 --- a/assets/js/4959fc42.03feb34b.js +++ b/assets/js/4959fc42.dde05cd9.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[240],{80897:e=>{e.exports=JSON.parse('{"permalink":"/page/14","page":14,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/13","nextPage":"/page/15","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[240],{80897:e=>{e.exports=JSON.parse('{"permalink":"/page/14","page":14,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/13","nextPage":"/page/15","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/49859754.ffa2cf6c.js b/assets/js/49859754.f54519c5.js similarity index 79% rename from assets/js/49859754.ffa2cf6c.js rename to assets/js/49859754.f54519c5.js index b393a3cb..0a8e2973 100644 --- a/assets/js/49859754.ffa2cf6c.js +++ b/assets/js/49859754.f54519c5.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4468],{62868:e=>{e.exports=JSON.parse('{"permalink":"/tags/aws/page/2","page":2,"postsPerPage":1,"totalPages":3,"totalCount":3,"previousPage":"/tags/aws","nextPage":"/tags/aws/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4468],{62868:e=>{e.exports=JSON.parse('{"permalink":"/tags/aws/page/2","page":2,"postsPerPage":1,"totalPages":4,"totalCount":4,"previousPage":"/tags/aws","nextPage":"/tags/aws/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/54cb095e.fa4451a4.js b/assets/js/54cb095e.be04e66d.js similarity index 57% rename from assets/js/54cb095e.fa4451a4.js rename to assets/js/54cb095e.be04e66d.js index fcf21607..5719a3cb 100644 --- a/assets/js/54cb095e.fa4451a4.js +++ b/assets/js/54cb095e.be04e66d.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7009],{95159:e=>{e.exports=JSON.parse('{"permalink":"/page/26","page":26,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/25","nextPage":"/page/27","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7009],{95159:e=>{e.exports=JSON.parse('{"permalink":"/page/26","page":26,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/25","nextPage":"/page/27","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/5f81b25c.7566a843.js b/assets/js/5f81b25c.a798eb5b.js similarity index 57% rename from assets/js/5f81b25c.7566a843.js rename to assets/js/5f81b25c.a798eb5b.js index 44fed1d5..ddd1a339 100644 --- a/assets/js/5f81b25c.7566a843.js +++ b/assets/js/5f81b25c.a798eb5b.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4889],{29492:e=>{e.exports=JSON.parse('{"permalink":"/page/27","page":27,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/26","nextPage":"/page/28","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4889],{29492:e=>{e.exports=JSON.parse('{"permalink":"/page/27","page":27,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/26","nextPage":"/page/28","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/6093f82b.4b56e02b.js b/assets/js/6093f82b.4f39bfcc.js similarity index 57% rename from assets/js/6093f82b.4b56e02b.js rename to assets/js/6093f82b.4f39bfcc.js index 534f8c04..e0e94993 100644 --- a/assets/js/6093f82b.4b56e02b.js +++ b/assets/js/6093f82b.4f39bfcc.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6017],{30708:e=>{e.exports=JSON.parse('{"permalink":"/page/9","page":9,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/8","nextPage":"/page/10","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6017],{30708:e=>{e.exports=JSON.parse('{"permalink":"/page/9","page":9,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/8","nextPage":"/page/10","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/635a92d5.dfe3d6d7.js b/assets/js/635a92d5.b6bb8328.js similarity index 57% rename from assets/js/635a92d5.dfe3d6d7.js rename to assets/js/635a92d5.b6bb8328.js index 4c1ff057..69d8b423 100644 --- a/assets/js/635a92d5.dfe3d6d7.js +++ b/assets/js/635a92d5.b6bb8328.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7891],{72126:e=>{e.exports=JSON.parse('{"permalink":"/page/24","page":24,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/23","nextPage":"/page/25","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7891],{72126:e=>{e.exports=JSON.parse('{"permalink":"/page/24","page":24,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/23","nextPage":"/page/25","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/64868a43.9da834fe.js b/assets/js/64868a43.e8baab49.js similarity index 57% rename from assets/js/64868a43.9da834fe.js rename to assets/js/64868a43.e8baab49.js index 1eefc53c..27265b3d 100644 --- a/assets/js/64868a43.9da834fe.js +++ b/assets/js/64868a43.e8baab49.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1501],{33159:e=>{e.exports=JSON.parse('{"permalink":"/page/39","page":39,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/38","nextPage":"/page/40","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1501],{33159:e=>{e.exports=JSON.parse('{"permalink":"/page/39","page":39,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/38","nextPage":"/page/40","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/69c28c32.4949e2ac.js b/assets/js/69c28c32.9661c66d.js similarity index 57% rename from assets/js/69c28c32.4949e2ac.js rename to assets/js/69c28c32.9661c66d.js index c29ff7ae..44eb477c 100644 --- a/assets/js/69c28c32.4949e2ac.js +++ b/assets/js/69c28c32.9661c66d.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1065],{99263:e=>{e.exports=JSON.parse('{"permalink":"/page/36","page":36,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/35","nextPage":"/page/37","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1065],{99263:e=>{e.exports=JSON.parse('{"permalink":"/page/36","page":36,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/35","nextPage":"/page/37","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/6c756788.0f6926f5.js b/assets/js/6c756788.0f6926f5.js new file mode 100644 index 00000000..6577d476 --- /dev/null +++ b/assets/js/6c756788.0f6926f5.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2504],{43944:e=>{e.exports=JSON.parse('{"permalink":"/tags/cd/page/3","page":3,"postsPerPage":1,"totalPages":3,"totalCount":3,"previousPage":"/tags/cd/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/6dd1c948.9cc1ea4f.js b/assets/js/6dd1c948.39709f1f.js similarity index 57% rename from assets/js/6dd1c948.9cc1ea4f.js rename to assets/js/6dd1c948.39709f1f.js index 46730be0..958b713e 100644 --- a/assets/js/6dd1c948.9cc1ea4f.js +++ b/assets/js/6dd1c948.39709f1f.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7064],{76376:e=>{e.exports=JSON.parse('{"permalink":"/page/34","page":34,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/33","nextPage":"/page/35","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7064],{76376:e=>{e.exports=JSON.parse('{"permalink":"/page/34","page":34,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/33","nextPage":"/page/35","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/71016178.a46a29bb.js b/assets/js/71016178.36ae86d5.js similarity index 54% rename from assets/js/71016178.a46a29bb.js rename to assets/js/71016178.36ae86d5.js index 2d0cca9b..2c01eebc 100644 --- a/assets/js/71016178.a46a29bb.js +++ b/assets/js/71016178.36ae86d5.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1119],{36207:e=>{e.exports=JSON.parse('{"permalink":"/tags/cd","page":1,"postsPerPage":1,"totalPages":2,"totalCount":2,"nextPage":"/tags/cd/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1119],{36207:e=>{e.exports=JSON.parse('{"permalink":"/tags/cd","page":1,"postsPerPage":1,"totalPages":3,"totalCount":3,"nextPage":"/tags/cd/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/754fb852.83a93bf4.js b/assets/js/754fb852.978a963c.js similarity index 57% rename from assets/js/754fb852.83a93bf4.js rename to assets/js/754fb852.978a963c.js index 616bd41d..d38aff04 100644 --- a/assets/js/754fb852.83a93bf4.js +++ b/assets/js/754fb852.978a963c.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[988],{38242:e=>{e.exports=JSON.parse('{"permalink":"/page/32","page":32,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/31","nextPage":"/page/33","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[988],{38242:e=>{e.exports=JSON.parse('{"permalink":"/page/32","page":32,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/31","nextPage":"/page/33","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/7762a24e.4cb6d5c4.js b/assets/js/7762a24e.94a865f2.js similarity index 57% rename from assets/js/7762a24e.4cb6d5c4.js rename to assets/js/7762a24e.94a865f2.js index a1dad62a..7f4cf9aa 100644 --- a/assets/js/7762a24e.4cb6d5c4.js +++ b/assets/js/7762a24e.94a865f2.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2753],{55095:e=>{e.exports=JSON.parse('{"permalink":"/page/4","page":4,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/3","nextPage":"/page/5","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2753],{55095:e=>{e.exports=JSON.parse('{"permalink":"/page/4","page":4,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/3","nextPage":"/page/5","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/7af1d52f.c98fe0fa.js b/assets/js/7af1d52f.deedde80.js similarity index 57% rename from assets/js/7af1d52f.c98fe0fa.js rename to assets/js/7af1d52f.deedde80.js index fe72bbf5..fbc40b5f 100644 --- a/assets/js/7af1d52f.c98fe0fa.js +++ b/assets/js/7af1d52f.deedde80.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2334],{59565:e=>{e.exports=JSON.parse('{"permalink":"/page/6","page":6,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/5","nextPage":"/page/7","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2334],{59565:e=>{e.exports=JSON.parse('{"permalink":"/page/6","page":6,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/5","nextPage":"/page/7","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/803fa4b0.7dbfa2b3.js b/assets/js/803fa4b0.7dbfa2b3.js new file mode 100644 index 00000000..258bd4bd --- /dev/null +++ b/assets/js/803fa4b0.7dbfa2b3.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1664],{3905:(t,e,l)=>{l.d(e,{Zo:()=>u,kt:()=>m});var n=l(67294);function a(t,e,l){return e in t?Object.defineProperty(t,e,{value:l,enumerable:!0,configurable:!0,writable:!0}):t[e]=l,t}function r(t,e){var l=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),l.push.apply(l,n)}return l}function i(t){for(var e=1;e=0||(a[l]=t[l]);return a}(t,e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);for(n=0;n=0||Object.prototype.propertyIsEnumerable.call(t,l)&&(a[l]=t[l])}return a}var s=n.createContext({}),o=function(t){var e=n.useContext(s),l=e;return t&&(l="function"==typeof t?t(e):i(i({},e),t)),l},u=function(t){var e=o(t.components);return n.createElement(s.Provider,{value:e},t.children)},c="mdxType",k={inlineCode:"code",wrapper:function(t){var e=t.children;return n.createElement(n.Fragment,{},e)}},d=n.forwardRef((function(t,e){var l=t.components,a=t.mdxType,r=t.originalType,s=t.parentName,u=p(t,["components","mdxType","originalType","parentName"]),c=o(l),d=a,m=c["".concat(s,".").concat(d)]||c[d]||k[d]||r;return l?n.createElement(m,i(i({ref:e},u),{},{components:l})):n.createElement(m,i({ref:e},u))}));function m(t,e){var l=arguments,a=e&&e.mdxType;if("string"==typeof t||a){var r=l.length,i=new Array(r);i[0]=d;var p={};for(var s in e)hasOwnProperty.call(e,s)&&(p[s]=e[s]);p.originalType=t,p[c]="string"==typeof t?t:a,i[1]=p;for(var o=2;o{l.r(e),l.d(e,{assets:()=>s,contentTitle:()=>i,default:()=>k,frontMatter:()=>r,metadata:()=>p,toc:()=>o});var n=l(87462),a=(l(67294),l(3905));const r={slug:40,title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},i=void 0,p={permalink:"/40",source:"@site/blog/2023-10-15-carffeine-tester-2/index.mdx",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",description:"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.",date:"2023-10-15T00:00:00.000Z",formattedDate:"2023\ub144 10\uc6d4 15\uc77c",tags:[{label:"\uce74\ud398\uc778",permalink:"/tags/\uce74\ud398\uc778"},{label:"\uc11c\ube44\uc2a4 \uacbd\ud5d8",permalink:"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{label:"\ud53c\ub4dc\ubc31",permalink:"/tags/\ud53c\ub4dc\ubc31"},{label:"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30",permalink:"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{label:"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571",permalink:"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],readingTime:14.665,hasTruncateMarker:!1,authors:[{name:"\uac00\ube0c\ub9ac\uc5d8",title:"Frontend",url:"https://github.com/gabrielyoon7",imageURL:"https://github.com/gabrielyoon7.png",key:"gabriel"},{name:"\uc13c\ud2b8",title:"Frontend",url:"https://github.com/kyw0716",imageURL:"https://github.com/kyw0716.png",key:"scent"}],frontMatter:{slug:"40",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},prevItem:{title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",permalink:"/41"},nextItem:{title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1",permalink:"/39"}},s={authorsImageUrls:[void 0,void 0]},o=[{value:"1. \uc9c0\uc5ed\uac80\uc0c9",id:"1-\uc9c0\uc5ed\uac80\uc0c9",level:3},{value:"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5",id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5",level:3},{value:"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815",id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815",level:2},{value:"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4",id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4",level:3},{value:"\uccb4\ud5d8 \uac1c\uc694",id:"\uccb4\ud5d8-\uac1c\uc694",level:2},{value:"\uccb4\ud5d8 \ud6c4\uae30",id:"\uccb4\ud5d8-\ud6c4\uae30",level:2},{value:"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c",id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c",level:3},{value:"\ud558\ub0a8 \ub9cc\ub450\uc9d1",id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1",level:3},{value:"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815",id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815",level:3},{value:"\ud310\uad50",id:"\ud310\uad50",level:3},{value:"\uacb0\ub860",id:"\uacb0\ub860",level:2},{value:"\ubd88\ud3b8\ud588\ub358 \uc810",id:"\ubd88\ud3b8\ud588\ub358-\uc810",level:3},{value:"\ub2e4\uc74c \ubaa9\ud45c",id:"\ub2e4\uc74c-\ubaa9\ud45c",level:3}],u={toc:o},c="wrapper";function k(t){let{components:e,...r}=t;return(0,a.kt)(c,(0,n.Z)({},u,r,{components:e,mdxType:"MDXLayout"}),(0,a.kt)("p",null,"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub09c\ubc88 ",(0,a.kt)("a",{parentName:"p",href:"https://car-ffeine.github.io/39"},"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8")," \uc9c4\ud589 \uc774\ud6c4 \uc77c\ubd80 \uae30\ub2a5 \uac1c\uc120\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\ub2a5 \uac1c\uc120\uc758 \uc720\uc6a9\uc131\uc744 \ud310\ubcc4\ud558\uace0\uc790 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc744 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \ud300\uc5d0\uc11c 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uac1c\uc120\ud55c \uc0ac\ud56d\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"1-\uc9c0\uc5ed\uac80\uc0c9"},"1. \uc9c0\uc5ed\uac80\uc0c9"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(45739).Z,width:"551",height:"396"})),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\uc774\uc81c\ub294 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud558\ub294 \uacbd\uc6b0, \uc804\uad6d \ub3c4\uc2dc\uc758 \uc8fc\uc18c\uac00 \uac19\uc774 \uc81c\uacf5\ub429\ub2c8\ub2e4.")),(0,a.kt)("h3",{id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5"},"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(98203).Z,width:"341",height:"907"})),(0,a.kt)("p",null,"(\uae30\uc874\uc5d0\ub294 \uc704 \uc0ac\uc9c4\ubcf4\ub2e4 \uc881\uc740 \uc601\uc5ed\ub9cc\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc774 \ud5c8\uc6a9\ub418\uc5c8\ub2e4.)"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ubaa8\ubc14\uc77c\uc5d0\uc11c \uc880 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc744 \ud5c8\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uc6d0\ub798\ub294 \ub514\ubc14\uc774\uc2a4 \ub108\ube44\ub97c \uace0\ub824\ud558\uc9c0 \uc54a\uace0 \uc90c \ub808\ubca8 \uae30\uc900\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud588\uc73c\ub098, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc774\ub294 \uc9c0\ub3c4\uc758 \uc601\uc5ed \ud06c\uae30\ub97c \uae30\ubc18\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub294 \ubc29\uc2dd\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \ub9c8\ucee4\uc758 \ub2e8\uc810\uc740, \uadf8 \ud06c\uae30\uac00 \ub108\ubb34 \ud06c\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub85c\uc778\ud574 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc8fc\ub294 \uacbd\uc6b0\uc5d0 \ub9c8\ucee4\ub4e4\uc774 \uacb9\uce58\ub294 \ud604\uc0c1\uc774 \uc788\uc5c8\ub294\ub370\uc694, \uc774\ub97c \uc218\uc815\ud558\uae30 \uc704\ud574 \ud2b9\uc815 \uc601\uc5ed \ud06c\uae30 \uc774\uc0c1\uc5d0\uc11c\ub294 \ub9c8\ucee4\ub97c \uc880 \ub354 \uac04\uc18c\ud654 \ub41c \ub514\uc790\uc778\uc73c\ub85c \ubcf4\uc774\ub3c4\ub85d \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ub9c8\ucee4 \uc0ac\uc774\uc988\uac00 \uc791\uc544\uc9c0\uba74\uc11c \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\uac00 \ub354\uc774\uc0c1 \ub4e4\uc5b4\uac08 \uacf5\uac04\uc774 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub9c8\ucee4 \uc0c9\uc0c1\uc740 \uadf8\ub300\ub85c \uc720\uc9c0\ub97c \ud558\ub418, \uc778\ud3ec \uc708\ub3c4\uc6b0\uc5d0 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\ub97c \ubcf4\uc5ec\uc8fc\ub294 \ubc29\uc2dd\uc73c\ub85c \ub514\uc790\uc778\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815"},"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815"),(0,a.kt)("p",null,"\uac1c\uc120\ud55c \uae30\ub2a5\uc774 \uc2e4\uc81c\ub85c \uc720\uc6a9\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \uc800\ud76c\ub294 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc758 \uaddc\uce59\uc744 \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc880 \ub354 \uc758\ubbf8\uc788\ub294 \uacbd\ud5d8\uc744 \ud558\uae30\uc704\ud574 1\ucc28 \uccb4\ud5d8 \ub54c \uc815\ud588\ub358 \uaddc\uce59\uc5d0 \ub354\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ucd94\uac00 \uaddc\uce59\uc744 \uc124\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4"},"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4"),(0,a.kt)("p",null,"\uc9c0\ub09c \uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8\uc5d0\uc11c\ub294 \uc9c0\uc5ed \uac80\uc0c9\uc774 \uc5c6\uc5b4 \ubaa9\ud45c \uc9c0\uc810\uc744 \ucc3e\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\uc2b5\ub2c8\ub2e4. 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uc9c0\uc5ed \uac80\uc0c9\uc774 \ucd94\uac00 \ub418\uc5c8\uc73c\ubbc0\ub85c \uc774 \uae30\ub2a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \uc774 \uaddc\uce59\uc744 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ucd94\uac00\ub85c \ubaa9\ud45c \uc9c0\uc810 \uc8fc\ubcc0\uc758 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud560 \ub54c \uc0c8\ub85c \ucd94\uac00\ub41c \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0\ub3c4 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uac1c\uc694"},"\uccb4\ud5d8 \uac1c\uc694"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69058).Z,width:"494",height:"720"})),(0,a.kt)("ol",null,(0,a.kt)("li",{parentName:"ol"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("li",{parentName:"ol"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("li",{parentName:"ol"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("li",{parentName:"ol"},"\ud310\uad50")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\ud6c4\uae30"},"\uccb4\ud5d8 \ud6c4\uae30"),(0,a.kt)("h3",{id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(76752).Z,width:"400",height:"400"})),(0,a.kt)("p",null,"\uc3d8\uce74\uc5d0\uc11c EV6\ub97c \ub300\uc5ec\ud574\uc11c ",(0,a.kt)("inlineCode",{parentName:"p"},"\uac00\ube0c\ub9ac\uc5d8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\uc13c\ud2b8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\ud0a4\uc544\ub77c"),"\uac00 \uc7a0\uc2e4\uc5ed\uc5d0\uc11c \ucd9c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc800\ub141 \ud1f4\uadfc \uc774\ud6c4\uc5d0 \ub0a8\uc774\uc12c\uc744 \uac00\ub824\uace0 \ubaa9\uc801\uc9c0\ub97c \uc124\uc815\ud558\uc600\uc73c\ub098 \ubc30\uac00 \ub108\ubb34 \uace0\ud30c\uc11c \uac00\ub294 \uae38\uc5d0 \uc2dd\uc0ac\ub97c \ud558\uc790\uace0 \uc598\uae30\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc9c4\uc815\ud55c \ucc98\uc74c \ubaa9\uc801\uc9c0\ub294 \uc2a4\ud0c0\ud544\ub4dc\uc600\uc73c\ub098, \uac00\ube0c\ub9ac\uc5d8\uc740 \ub3d9\ub124 \uc8fc\ubbfc\uc774\ub77c \uc2a4\ud0c0\ud544\ub4dc\ub97c \ub108\ubb34 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc2a4\ud0c0\ud544\ub4dc\uc5d0 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uace0\uc788\uc73c\ubbc0\ub85c \ubaa9\uc801\uc9c0\ub97c \uae09\ud558\uac8c \ubcc0\uacbd\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4. \uc774 \ub54c \ubaa9\uc801\uc9c0 \ubcc0\uacbd\uc744 \uc704\ud574 \uc8fc\ubcc0 \uc2dd\ub2f9\uc744 \ub458\ub7ec\ubcf4\ub358 \uc911\uc5d0 \uad1c\ucc2e\uc740 \uc2dd\ub2f9\uc744 \ubc1c\uacac\ud574\uc11c \ud574\ub2f9 \uc2dd\ub2f9\uc744 \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(99070).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uc2dd\ub2f9 \uc8fc\ubcc0\uc744 \uac00\uae30 \uc704\ud574 \uc9c0\uc5ed \uac80\uc0c9\uc744 \ucc98\uc74c\uc73c\ub85c \uc0ac\uc6a9\ud558\uc5ec \uc2dd\ub2f9\uacfc \uac00\uae4c\uc6b4 \uc9c0\uc5ed\uc744 \ud0d0\uc0c9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \uacfc\uc815\uc5d0\uc11c \uc2dd\ub2f9\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \uc5c6\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uac8c\ub418\uc5b4, \uadfc\ucc98 \ucda9\uc804\uc18c\ub97c \ucc3e\uc544\ubcf4\uae30 \uc704\ud574\uc11c \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud588\ub354\ub2c8 1\ucc28 \uccb4\ud5d8\ub54c\uc640\ub294 \ub2ec\ub9ac \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc92c\uc2b5\ub2c8\ub2e4. \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4 \uc790\uccb4\uac00 \ubcf4\uc774\uc9c0 \uc54a\uc544 \ub2f5\ub2f5\ud558\uc600\uc73c\ub098, \uc774\uc81c\ub294 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uac8c \ub418\uc5b4 \ud3b8\ub9ac\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc9c0\ub09c \uccb4\ud5d8 \uc774\ud6c4\ub85c \ud53c\ub4dc\ubc31\uc744 \uc790\uccb4 \uc218\uc9d1\ud558\uc5ec \uac1c\ubc1c\ud55c \uae30\ub2a5\ub4e4\uc774 \ud3b8\ud558\ub2e4\ub294 \uac83\uc744 \uc2dd\ub2f9\uc5d0 \uac00\ub294 \uae38\uc5d0 \ub290\ub084 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(94199).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ud558\ub0a8 \ub9cc\ub450\uc9d1\uc5d0\uc11c \uc2dd\uc0ac\ub97c \ud558\ub2e4\uac00 \uc54c\uac8c\ub41c \uc0ac\uc2e4\uc740, \ub0a8\uc774\uc12c\uc740 \uc0dd\uac01\ubcf4\ub2e4 \ub108\ubb34 \uba40\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc2dd\uc0ac\ub97c \ub9c8\uce58\uace0 \ub0a8\uc774\uc12c\uc5d0 \uac00\uba74, \ucda9\uc804\ub3c4 \uc81c\ub300\ub85c \ubabb\ud558\uace0 \ub3cc\uc544\uc62c \ud310\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc2dd\uc0ac\ub97c \ud558\uba74\uc11c \ub2e4\ub978 \ubaa9\uc801\uc9c0\ub97c \uc54c\uc544\ubd24\ub294\ub370, \uac00\ube0c\ub9ac\uc5d8\uc774 \uc608\uc804\uc5d0 \uac00\ubd24\ub358 \uacf3 \uc911\uc5d0\uc11c \ub0a8\uc591\uc8fc\uc758 \ubb3c\uc758 \uc815\uc6d0\uc774 \uc2dc\uac04\uc744 \ub5bc\uc6b0\uae30 \uc88b\ub2e4\ub294 \uc18c\ub9ac\ub97c \ud558\uc600\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ubb3c\uc758 \uc815\uc6d0\uc744 \uac80\uc0c9\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub180\ub78d\uac8c\ub3c4 \ubb3c\uc758\uc815\uc6d0\uc740 \uac80\uc0c9\uacb0\uacfc\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc5b4\uca54 \uc218 \uc5c6\uc774 \uce74\uce74\uc624 \uc9c0\ub3c4\ub85c \ubb3c\uc758 \uc815\uc6d0 \uc704\uce58\ub97c \ud655\uc778\ud558\uc5ec \uc8fc\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uace0, \uc774 \uc8fc\uc18c\ub97c \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4. \uc800\ud76c\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub294 \uc5c5\uccb4\uba85 \uc870\ud68c\uac00 \uc548\ub41c\ub2e4\ub294 \uac83\uc774 \uce58\uba85\uc801\uc778 \ub2e8\uc810\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\ub9cc, \uc774 \uae30\ub2a5\uc740 \uac80\uc0c9 \ud560 \ub54c\ub9c8\ub2e4 \ub9ce\uc740 \ube44\uc6a9\uc774 \uccad\uad6c\ub418\uc5b4 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uae30\ub2a5\uc744 \ub123\ub294 \uac83\uc740 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uacb0\uad6d \uc8fc\uc18c \uac80\uc0c9\uc744 \ud1b5\ud574 \ubb3c\uc758 \uc815\uc6d0\uacfc \uac00\uc7a5 \uac00\uae4c\uc6b4 \ucda9\uc804\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub7f0\ub370! \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud574\uc11c \ud655\uc778\ud574 \ubcf4\ub2c8 \ud574\ub2f9 \ucda9\uc804\uc18c\ub294 \ubb3c\uc758 \uc815\uc6d0\uacfc \uc0dd\uac01\ubcf4\ub2e4 \uba40\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(42143).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(9836).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,"\ubb34\ub824 \uac78\uc5b4\uc11c 30\ubd84\uc774\ub098 \uac78\ub9ac\ub294 \ucda9\uc804\uc18c\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uc704\ud574 \uc655\ubcf5 1\uc2dc\uac04\uc774\ub098 \uac78\ub9ac\ub294 \uac70\ub9ac\ub97c \uac78\uc744 \uc218 \uc5c6\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb3c\ub860 \uc9c0\ub09c \uccb4\ud5d8\uc5d0\uc11c \uc804\uae30\ucc28\uac00 \uc0dd\uac01\ubcf4\ub2e4 \ubc30\ud130\ub9ac\uac00 \uc624\ub798\uac04\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uace0 \uc788\uc5c8\uc9c0\ub9cc, \ub9cc\uc57d \uc800\ud76c\ucc98\ub7fc \ucda9\uc804\uc774 \uae09\ud55c \uc0ac\uc6a9\uc790\ub77c\uba74 \ubaa9\uc801\uc9c0\ub97c \ud3ec\uae30\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uaca0\uad6c\ub098 \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub9c8\uc9c0\ub9c9\uc73c\ub85c \uc815\ud55c \ubaa9\uc801\uc9c0\ub294, \uc758\uc678\uc758 \uacb0\uc815\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uad49\uc7a5\ud788 \ubc1c\uc804\ub41c \ucca8\ub2e8 \ub3c4\uc2dc\ub85c \uc54c\ub824\uc9c4 \ud310\uad50\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc0ac\uc2e4\uc740 \uc55e\uc73c\ub85c \uac08\uc9c0\ub3c4 \ubaa8\ub974\ub294 \ud310\uad50\ub97c \ubbf8\ub9ac \uad6c\uacbd\uc774\ub098 \ud574\ubcf4\uc790\ub294\uac8c \uc774\uc720\uc600\uc9c0\ub9cc \ube44\ubc00\uc785\ub2c8\ub2e4(?)"),(0,a.kt)("p",null,"\uc77c\ub2e8 \ud310\uad50\uc5ed\uc740 IT\uc11c\ube44\uc2a4 \ud68c\uc0ac\ub4e4\uc774 \ub9ce\uc774 \ubab0\ub824\uc788\ub294 \uacf3\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud310\uad50\uc5ed\uc744 \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \uac80\uc0c9\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(52597).Z,width:"370",height:"630"})),(0,a.kt)("p",null,"\uc9c0\ub3c4\ub97c \ud310\uad50\uc5ed\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \uc678\ubd80\uc778 \uac1c\ubc29\uc778 \ucda9\uc804\uc18c\ub97c \ucc3e\uc558\ub294\ub370, \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \ubcf4\uc5ec\uc11c \ud574\ub2f9 \ucda9\uc804\uc18c\ub97c \ubaa9\uc801\uc9c0\ub85c \uc7a1\uace0 \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud310\uad50"},"\ud310\uad50"),(0,a.kt)("p",null,"\ud558\ub0a8\uc5d0\uc11c \ud310\uad50\ub97c \uac00\uae30 \uc704\ud574\uc11c\ub294 \uc11c\ud558\ub0a8IC\ub97c \uc9c0\ub098\uc57c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uac00\ub294 \uae38\uc5d0 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0 \ub098\uc624\ub294 \uc815\ubcf4\uc640 \uc2e4\uc81c \uc815\ubcf4\uac00 \uc77c\uce58\ud558\ub294\uc9c0 \uc810\uac80\ucc28 \uc11c\ud558\ub0a8 \uac04\uc774 \ud734\uac8c\uc18c\ub97c \ub4e4\ub824\ubd24\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \uac80\uc0c9\uc774 \ub418\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4!"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(27181).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uac80\uc0c9 \ub2f9\uc2dc\uc5d0\ub294 2\ub300\uc758 \ucda9\uc804\uae30\uac00 \uc788\ub2e4\uace0 \ub098\uc654\uace0, \ub458\ub2e4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub2e4\uace0 \ub418\uc5b4\uc788\uc5c8\ub294\ub370 \uc2e4\uc81c\ub85c \ud655\uc778\ud574\ubcf4\ub2c8 \uc77c\uce58\ud558\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uba3c \uae38\uc744 \ub2ec\ub824 \ud310\uad50\uc5d0 \ub3c4\ucc29\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc8fc\ucc28\uc7a5\uc5d0 \ub4e4\uc5b4\uc624\uae30 \uc804, \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc758 \ucda9\uc804\uae30 \ucd1d 12\uae30 \uc911 10\uae30\uac00 \uc0ac\uc6a9\uac00\ub2a5\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc815\uc791 \ub4e4\uc5b4\uc640\uc11c \ubcf4\ub2c8 \uc785\uad6c\ubd80\ud130 \ub108\ubb34 \ub9ce\uc740 \uc804\uae30\ucc28\ub4e4\uc774 \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\uc911\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb54\uac00 \uc774\uc0c1\ud558\ub2e4 \uc2f6\uc5c8\uc9c0\ub9cc, \uc544\uc9c1 \uc11c\ubc84\uc5d0 \ubc18\uc601\uc774 \uc548\ub41c\uac74\uac00? \ud558\uba74\uc11c \ube44\uc5b4\uc788\ub294 \ucda9\uc804\uae30\ub97c \ucc3e\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(51482).Z,width:"500",height:"667"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(95179).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ucda9\uc804\uae30\ub97c \uaf42\uace0 \ub098\uc11c \uc54c\uac8c\ub41c \uac83\uc740 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0 \ub098\uc628 \ucda9\uc804\uc18c \ud68c\uc0ac\uba85\uacfc \ubc29\uae08 \uaf42\uc740 \ucda9\uc804\uae30 \ud68c\uc0ac\uba85\uc774 \ub2e4\ub974\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc54c\uace0\ubcf4\ub2c8 \uc74c\uc131 \uc778\uc2dd\uc73c\ub85c \ub124\ube44\uc5d0 \uac80\uc0c9\ud55c \ucda9\uc804\uc18c\ub294 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \uc544\ub2cc \ud310\uad50\uc5ed \ud658\uc2b9 \uc8fc\ucc28\uc7a5\uc774\ub77c \uc5c9\ub6b1\ud55c \uacf3\uc73c\ub85c \uc628 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4!!!"),(0,a.kt)("p",null,"\ub2e4\ud589\uc778 \uc810\uc740 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80 \uc815\ubcf4\uac00 \uc798\ubabb\ub41c \uac83\uc774 \uc544\ub2c8\uc5c8\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub798\uc11c \uc560\ucd08\uc5d0 \uac00\uace0\uc790 \ud588\ub358 \ud310\uad50\uacf5\uc601\uc8fc\uc790\ucc3d\uc5d0 \ub300\ud55c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\uc640 \ub3d9\uc77c\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\ub7ec \uac78\uc5b4\uc11c \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4. (\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.)"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69760).Z,width:"500",height:"375"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(33730).Z,width:"418",height:"228"})),(0,a.kt)("p",null,"\ub3c4\ucc29\ud574\ubcf4\ub2c8 1\uce35\uc758 \ucda9\uc804\uae30\ub4e4\uc774 \ubaa8\ub450 \uacf5\uc0ac\uc911\uc774\uc5c8\uace0, \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\ub85c\ub3c4 \ubd88\uc77c\uce58 \ud558\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc0c1\uc138 \uc815\ubcf4\ub97c \ubcf4\ub2c8 3~6\uce35\uc5d0 \ucda9\uc804\uae30\ub4e4\uc5d0 \ub300\ud55c \uc815\ubcf4\ub77c\ub294 \uac83\uc774 \uba85\uc2dc\ub418\uc5b4 \uc788\uc5c8\uace0, \uc2e4\uc81c\ub85c\ub3c4 \uc774\uc640 \ub3d9\uc77c\ud55c \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(71085).Z,width:"500",height:"375"})),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc2dc\uac04\uc774 \ub108\ubb34 \ud758\ub7ec \ub2e4\uc2dc \uc7a0\uc2e4\ub85c \ub3cc\uc544\uc640 \ucc28\ub97c \ubc18\ub0a9\ud558\uace0 \uccb4\ud5d8\uc744 \ub9c8\ubb34\ub9ac \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uacb0\ub860"},"\uacb0\ub860"),(0,a.kt)("h3",{id:"\ubd88\ud3b8\ud588\ub358-\uc810"},"\ubd88\ud3b8\ud588\ub358 \uc810"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc2dc\uc5d0 \uc6d0\ud558\ub294 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uc9c0\ub3c4\ub97c \ud655\ub300\ud574\uc8fc\uc138\uc694 \ubaa8\ub2ec\uc774 \ub728\uace0, \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c \ub9c8\ucee4\uac00 \uc804\ubd80 \uc0ac\ub77c\uc9c4\ub2e4."))),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub294 \uc218\ub2e8\uc774 \uc5c6\uc5b4 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\ud604\uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ud540 (1\ucc28 \uccb4\ud5d8\uae30\uc5d0\uc11c\ub3c4 \uc5b8\uae09\ud588\ub358 \ubd80\ubd84)"),(0,a.kt)("li",{parentName:"ul"},"\ub0b4 \uc704\uce58\ub97c \uc0c1\ub300\uc801\uc73c\ub85c \uc54c \uc218 \uc788\ub294 \ub79c\ub4dc\ub9c8\ud06c\uc758 \ubd80\uc871"))),(0,a.kt)("li",{parentName:"ul"},"\ud2b9\uc815 \uc7a5\uc18c(\ub9e4\uc7a5\uba85) \uac80\uc0c9\uc774 \uc548\ub3fc\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac00\uae30 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uce74\uce74\uc624\ub9f5 \ub4f1\uc744 \ud65c\uc6a9\ud574 \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \uc9c4\ud589\ud574\uc57c \ud588\ub2e4.")))),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\ud45c"},"\ub2e4\uc74c \ubaa9\ud45c"),(0,a.kt)("p",null,"\uc55e\uc120 \ubd88\ud3b8\ud588\ub358\uc810\uc744 \uac1c\uc120\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uae30\ub2a5 \uac1c\uc120\uc744 \ucd94\uac00\ub85c \uc9c4\ud589\ud560 \uc608\uc815\uc785\ub2c8\ub2e4."),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc5d0 \uc81c\ud55c\uc774 \uc0dd\uae30\uc9c0 \uc54a\uac8c \ucda9\uc804\uc18c \ub9c8\ucee4 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc744 \uc6b0\uc120\uc801\uc73c\ub85c \ub3c4\uc785\ud55c\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub3c4\ub85d \uc9c0\ud558\ucca0 \uc5ed\uacfc \uac19\uc740 \ub79c\ub4dc\ub9c8\ucee4\ub97c \uc9c0\uc6e0\ub358 \uac83\uc744 \ub864\ubc31\ud55c\ub2e4.")),(0,a.kt)("p",null,"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac08 \uc218 \uc788\ub3c4\ub85d \ud558\uae30 \uc704\ud574\uc11c \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \ucd94\uac00\ud558\uace0 \uc2f6\uc9c0\ub9cc, \ud574\ub2f9 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 \uac80\uc0c9\ub2f9 \ube44\uc6a9\uc774 \ub9ce\uc774 \uccad\uad6c\ub418\ub294 \uc7a5\uc18c \uac80\uc0c9 API\ub97c \ucd94\uac00\ud574\uc57c \ud588\uae30\uc5d0 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uad6c\ud604\ud558\uae30 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."))}k.isMDXComponent=!0},9836:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/30-minutes-c85532af9807b46a836114f47d9370a8.png"},95179:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/charging-ffe0ad234bee030a3c530c6535014a1c.png"},45739:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/city-search-2c43a5fed62ee0aa8a90519d91092b83.png"},51482:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/empty_station-051c14502d2c5ad67f2353c987e78397.png"},76752:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/from-jamsil-6642367d758b59285f047247604f3b73.png"},71085:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/full-charged-5c139dee4a42c83edfb5a71b71220cda.png"},99070:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/go-to-starfield-cafc0eef5101f3d1f0b81bf033fd2d99.png"},27181:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/hanam_station-f703f3a6946ea1106e2a022e886577a9.png"},94199:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mandu-mandu-10754738289399ba0e8782a13a873c34.png"},98203:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mobile-markers-2cd607c6d10d89914731914ded7c421b.png"},42143:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/near-water-cebfb5c4cd07a3c477e3106a1c7ccb7e.png"},69760:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/no-chargers-342cb105d18982d69b1571832f59cb3c.png"},52597:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/pangyo-26dd2c251b15ce4e6c7830e01f2e80a5.png"},33730:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/real-pangyo-fb5b6d1a87ed372fd36e6e0e352168ca.png"},69058:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/routes-f67d5286798a1ebb66fc487e25eb788c.png"}}]); \ No newline at end of file diff --git a/assets/js/803fa4b0.b3be3ddd.js b/assets/js/803fa4b0.b3be3ddd.js deleted file mode 100644 index 4461946f..00000000 --- a/assets/js/803fa4b0.b3be3ddd.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1664],{3905:(t,e,l)=>{l.d(e,{Zo:()=>u,kt:()=>m});var n=l(67294);function a(t,e,l){return e in t?Object.defineProperty(t,e,{value:l,enumerable:!0,configurable:!0,writable:!0}):t[e]=l,t}function r(t,e){var l=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),l.push.apply(l,n)}return l}function i(t){for(var e=1;e=0||(a[l]=t[l]);return a}(t,e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);for(n=0;n=0||Object.prototype.propertyIsEnumerable.call(t,l)&&(a[l]=t[l])}return a}var s=n.createContext({}),o=function(t){var e=n.useContext(s),l=e;return t&&(l="function"==typeof t?t(e):i(i({},e),t)),l},u=function(t){var e=o(t.components);return n.createElement(s.Provider,{value:e},t.children)},c="mdxType",k={inlineCode:"code",wrapper:function(t){var e=t.children;return n.createElement(n.Fragment,{},e)}},d=n.forwardRef((function(t,e){var l=t.components,a=t.mdxType,r=t.originalType,s=t.parentName,u=p(t,["components","mdxType","originalType","parentName"]),c=o(l),d=a,m=c["".concat(s,".").concat(d)]||c[d]||k[d]||r;return l?n.createElement(m,i(i({ref:e},u),{},{components:l})):n.createElement(m,i({ref:e},u))}));function m(t,e){var l=arguments,a=e&&e.mdxType;if("string"==typeof t||a){var r=l.length,i=new Array(r);i[0]=d;var p={};for(var s in e)hasOwnProperty.call(e,s)&&(p[s]=e[s]);p.originalType=t,p[c]="string"==typeof t?t:a,i[1]=p;for(var o=2;o{l.r(e),l.d(e,{assets:()=>s,contentTitle:()=>i,default:()=>k,frontMatter:()=>r,metadata:()=>p,toc:()=>o});var n=l(87462),a=(l(67294),l(3905));const r={slug:40,title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},i=void 0,p={permalink:"/40",source:"@site/blog/2023-10-15-carffeine-tester-2/index.mdx",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",description:"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.",date:"2023-10-15T00:00:00.000Z",formattedDate:"2023\ub144 10\uc6d4 15\uc77c",tags:[{label:"\uce74\ud398\uc778",permalink:"/tags/\uce74\ud398\uc778"},{label:"\uc11c\ube44\uc2a4 \uacbd\ud5d8",permalink:"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{label:"\ud53c\ub4dc\ubc31",permalink:"/tags/\ud53c\ub4dc\ubc31"},{label:"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30",permalink:"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{label:"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571",permalink:"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],readingTime:14.665,hasTruncateMarker:!1,authors:[{name:"\uac00\ube0c\ub9ac\uc5d8",title:"Frontend",url:"https://github.com/gabrielyoon7",imageURL:"https://github.com/gabrielyoon7.png",key:"gabriel"},{name:"\uc13c\ud2b8",title:"Frontend",url:"https://github.com/kyw0716",imageURL:"https://github.com/kyw0716.png",key:"scent"}],frontMatter:{slug:"40",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},nextItem:{title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1",permalink:"/39"}},s={authorsImageUrls:[void 0,void 0]},o=[{value:"1. \uc9c0\uc5ed\uac80\uc0c9",id:"1-\uc9c0\uc5ed\uac80\uc0c9",level:3},{value:"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5",id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5",level:3},{value:"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815",id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815",level:2},{value:"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4",id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4",level:3},{value:"\uccb4\ud5d8 \uac1c\uc694",id:"\uccb4\ud5d8-\uac1c\uc694",level:2},{value:"\uccb4\ud5d8 \ud6c4\uae30",id:"\uccb4\ud5d8-\ud6c4\uae30",level:2},{value:"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c",id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c",level:3},{value:"\ud558\ub0a8 \ub9cc\ub450\uc9d1",id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1",level:3},{value:"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815",id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815",level:3},{value:"\ud310\uad50",id:"\ud310\uad50",level:3},{value:"\uacb0\ub860",id:"\uacb0\ub860",level:2},{value:"\ubd88\ud3b8\ud588\ub358 \uc810",id:"\ubd88\ud3b8\ud588\ub358-\uc810",level:3},{value:"\ub2e4\uc74c \ubaa9\ud45c",id:"\ub2e4\uc74c-\ubaa9\ud45c",level:3}],u={toc:o},c="wrapper";function k(t){let{components:e,...r}=t;return(0,a.kt)(c,(0,n.Z)({},u,r,{components:e,mdxType:"MDXLayout"}),(0,a.kt)("p",null,"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub09c\ubc88 ",(0,a.kt)("a",{parentName:"p",href:"https://car-ffeine.github.io/39"},"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8")," \uc9c4\ud589 \uc774\ud6c4 \uc77c\ubd80 \uae30\ub2a5 \uac1c\uc120\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\ub2a5 \uac1c\uc120\uc758 \uc720\uc6a9\uc131\uc744 \ud310\ubcc4\ud558\uace0\uc790 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc744 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \ud300\uc5d0\uc11c 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uac1c\uc120\ud55c \uc0ac\ud56d\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"1-\uc9c0\uc5ed\uac80\uc0c9"},"1. \uc9c0\uc5ed\uac80\uc0c9"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(45739).Z,width:"551",height:"396"})),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\uc774\uc81c\ub294 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud558\ub294 \uacbd\uc6b0, \uc804\uad6d \ub3c4\uc2dc\uc758 \uc8fc\uc18c\uac00 \uac19\uc774 \uc81c\uacf5\ub429\ub2c8\ub2e4.")),(0,a.kt)("h3",{id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5"},"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(98203).Z,width:"341",height:"907"})),(0,a.kt)("p",null,"(\uae30\uc874\uc5d0\ub294 \uc704 \uc0ac\uc9c4\ubcf4\ub2e4 \uc881\uc740 \uc601\uc5ed\ub9cc\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc774 \ud5c8\uc6a9\ub418\uc5c8\ub2e4.)"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ubaa8\ubc14\uc77c\uc5d0\uc11c \uc880 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc744 \ud5c8\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uc6d0\ub798\ub294 \ub514\ubc14\uc774\uc2a4 \ub108\ube44\ub97c \uace0\ub824\ud558\uc9c0 \uc54a\uace0 \uc90c \ub808\ubca8 \uae30\uc900\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud588\uc73c\ub098, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc774\ub294 \uc9c0\ub3c4\uc758 \uc601\uc5ed \ud06c\uae30\ub97c \uae30\ubc18\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub294 \ubc29\uc2dd\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \ub9c8\ucee4\uc758 \ub2e8\uc810\uc740, \uadf8 \ud06c\uae30\uac00 \ub108\ubb34 \ud06c\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub85c\uc778\ud574 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc8fc\ub294 \uacbd\uc6b0\uc5d0 \ub9c8\ucee4\ub4e4\uc774 \uacb9\uce58\ub294 \ud604\uc0c1\uc774 \uc788\uc5c8\ub294\ub370\uc694, \uc774\ub97c \uc218\uc815\ud558\uae30 \uc704\ud574 \ud2b9\uc815 \uc601\uc5ed \ud06c\uae30 \uc774\uc0c1\uc5d0\uc11c\ub294 \ub9c8\ucee4\ub97c \uc880 \ub354 \uac04\uc18c\ud654 \ub41c \ub514\uc790\uc778\uc73c\ub85c \ubcf4\uc774\ub3c4\ub85d \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ub9c8\ucee4 \uc0ac\uc774\uc988\uac00 \uc791\uc544\uc9c0\uba74\uc11c \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\uac00 \ub354\uc774\uc0c1 \ub4e4\uc5b4\uac08 \uacf5\uac04\uc774 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub9c8\ucee4 \uc0c9\uc0c1\uc740 \uadf8\ub300\ub85c \uc720\uc9c0\ub97c \ud558\ub418, \uc778\ud3ec \uc708\ub3c4\uc6b0\uc5d0 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\ub97c \ubcf4\uc5ec\uc8fc\ub294 \ubc29\uc2dd\uc73c\ub85c \ub514\uc790\uc778\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815"},"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815"),(0,a.kt)("p",null,"\uac1c\uc120\ud55c \uae30\ub2a5\uc774 \uc2e4\uc81c\ub85c \uc720\uc6a9\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \uc800\ud76c\ub294 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc758 \uaddc\uce59\uc744 \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc880 \ub354 \uc758\ubbf8\uc788\ub294 \uacbd\ud5d8\uc744 \ud558\uae30\uc704\ud574 1\ucc28 \uccb4\ud5d8 \ub54c \uc815\ud588\ub358 \uaddc\uce59\uc5d0 \ub354\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ucd94\uac00 \uaddc\uce59\uc744 \uc124\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4"},"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4"),(0,a.kt)("p",null,"\uc9c0\ub09c \uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8\uc5d0\uc11c\ub294 \uc9c0\uc5ed \uac80\uc0c9\uc774 \uc5c6\uc5b4 \ubaa9\ud45c \uc9c0\uc810\uc744 \ucc3e\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\uc2b5\ub2c8\ub2e4. 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uc9c0\uc5ed \uac80\uc0c9\uc774 \ucd94\uac00 \ub418\uc5c8\uc73c\ubbc0\ub85c \uc774 \uae30\ub2a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \uc774 \uaddc\uce59\uc744 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ucd94\uac00\ub85c \ubaa9\ud45c \uc9c0\uc810 \uc8fc\ubcc0\uc758 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud560 \ub54c \uc0c8\ub85c \ucd94\uac00\ub41c \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0\ub3c4 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uac1c\uc694"},"\uccb4\ud5d8 \uac1c\uc694"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69058).Z,width:"494",height:"720"})),(0,a.kt)("ol",null,(0,a.kt)("li",{parentName:"ol"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("li",{parentName:"ol"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("li",{parentName:"ol"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("li",{parentName:"ol"},"\ud310\uad50")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\ud6c4\uae30"},"\uccb4\ud5d8 \ud6c4\uae30"),(0,a.kt)("h3",{id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(76752).Z,width:"400",height:"400"})),(0,a.kt)("p",null,"\uc3d8\uce74\uc5d0\uc11c EV6\ub97c \ub300\uc5ec\ud574\uc11c ",(0,a.kt)("inlineCode",{parentName:"p"},"\uac00\ube0c\ub9ac\uc5d8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\uc13c\ud2b8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\ud0a4\uc544\ub77c"),"\uac00 \uc7a0\uc2e4\uc5ed\uc5d0\uc11c \ucd9c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc800\ub141 \ud1f4\uadfc \uc774\ud6c4\uc5d0 \ub0a8\uc774\uc12c\uc744 \uac00\ub824\uace0 \ubaa9\uc801\uc9c0\ub97c \uc124\uc815\ud558\uc600\uc73c\ub098 \ubc30\uac00 \ub108\ubb34 \uace0\ud30c\uc11c \uac00\ub294 \uae38\uc5d0 \uc2dd\uc0ac\ub97c \ud558\uc790\uace0 \uc598\uae30\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc9c4\uc815\ud55c \ucc98\uc74c \ubaa9\uc801\uc9c0\ub294 \uc2a4\ud0c0\ud544\ub4dc\uc600\uc73c\ub098, \uac00\ube0c\ub9ac\uc5d8\uc740 \ub3d9\ub124 \uc8fc\ubbfc\uc774\ub77c \uc2a4\ud0c0\ud544\ub4dc\ub97c \ub108\ubb34 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc2a4\ud0c0\ud544\ub4dc\uc5d0 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uace0\uc788\uc73c\ubbc0\ub85c \ubaa9\uc801\uc9c0\ub97c \uae09\ud558\uac8c \ubcc0\uacbd\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4. \uc774 \ub54c \ubaa9\uc801\uc9c0 \ubcc0\uacbd\uc744 \uc704\ud574 \uc8fc\ubcc0 \uc2dd\ub2f9\uc744 \ub458\ub7ec\ubcf4\ub358 \uc911\uc5d0 \uad1c\ucc2e\uc740 \uc2dd\ub2f9\uc744 \ubc1c\uacac\ud574\uc11c \ud574\ub2f9 \uc2dd\ub2f9\uc744 \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(99070).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uc2dd\ub2f9 \uc8fc\ubcc0\uc744 \uac00\uae30 \uc704\ud574 \uc9c0\uc5ed \uac80\uc0c9\uc744 \ucc98\uc74c\uc73c\ub85c \uc0ac\uc6a9\ud558\uc5ec \uc2dd\ub2f9\uacfc \uac00\uae4c\uc6b4 \uc9c0\uc5ed\uc744 \ud0d0\uc0c9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \uacfc\uc815\uc5d0\uc11c \uc2dd\ub2f9\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \uc5c6\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uac8c\ub418\uc5b4, \uadfc\ucc98 \ucda9\uc804\uc18c\ub97c \ucc3e\uc544\ubcf4\uae30 \uc704\ud574\uc11c \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud588\ub354\ub2c8 1\ucc28 \uccb4\ud5d8\ub54c\uc640\ub294 \ub2ec\ub9ac \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc92c\uc2b5\ub2c8\ub2e4. \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4 \uc790\uccb4\uac00 \ubcf4\uc774\uc9c0 \uc54a\uc544 \ub2f5\ub2f5\ud558\uc600\uc73c\ub098, \uc774\uc81c\ub294 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uac8c \ub418\uc5b4 \ud3b8\ub9ac\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc9c0\ub09c \uccb4\ud5d8 \uc774\ud6c4\ub85c \ud53c\ub4dc\ubc31\uc744 \uc790\uccb4 \uc218\uc9d1\ud558\uc5ec \uac1c\ubc1c\ud55c \uae30\ub2a5\ub4e4\uc774 \ud3b8\ud558\ub2e4\ub294 \uac83\uc744 \uc2dd\ub2f9\uc5d0 \uac00\ub294 \uae38\uc5d0 \ub290\ub084 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(94199).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ud558\ub0a8 \ub9cc\ub450\uc9d1\uc5d0\uc11c \uc2dd\uc0ac\ub97c \ud558\ub2e4\uac00 \uc54c\uac8c\ub41c \uc0ac\uc2e4\uc740, \ub0a8\uc774\uc12c\uc740 \uc0dd\uac01\ubcf4\ub2e4 \ub108\ubb34 \uba40\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc2dd\uc0ac\ub97c \ub9c8\uce58\uace0 \ub0a8\uc774\uc12c\uc5d0 \uac00\uba74, \ucda9\uc804\ub3c4 \uc81c\ub300\ub85c \ubabb\ud558\uace0 \ub3cc\uc544\uc62c \ud310\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc2dd\uc0ac\ub97c \ud558\uba74\uc11c \ub2e4\ub978 \ubaa9\uc801\uc9c0\ub97c \uc54c\uc544\ubd24\ub294\ub370, \uac00\ube0c\ub9ac\uc5d8\uc774 \uc608\uc804\uc5d0 \uac00\ubd24\ub358 \uacf3 \uc911\uc5d0\uc11c \ub0a8\uc591\uc8fc\uc758 \ubb3c\uc758 \uc815\uc6d0\uc774 \uc2dc\uac04\uc744 \ub5bc\uc6b0\uae30 \uc88b\ub2e4\ub294 \uc18c\ub9ac\ub97c \ud558\uc600\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ubb3c\uc758 \uc815\uc6d0\uc744 \uac80\uc0c9\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub180\ub78d\uac8c\ub3c4 \ubb3c\uc758\uc815\uc6d0\uc740 \uac80\uc0c9\uacb0\uacfc\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc5b4\uca54 \uc218 \uc5c6\uc774 \uce74\uce74\uc624 \uc9c0\ub3c4\ub85c \ubb3c\uc758 \uc815\uc6d0 \uc704\uce58\ub97c \ud655\uc778\ud558\uc5ec \uc8fc\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uace0, \uc774 \uc8fc\uc18c\ub97c \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4. \uc800\ud76c\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub294 \uc5c5\uccb4\uba85 \uc870\ud68c\uac00 \uc548\ub41c\ub2e4\ub294 \uac83\uc774 \uce58\uba85\uc801\uc778 \ub2e8\uc810\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\ub9cc, \uc774 \uae30\ub2a5\uc740 \uac80\uc0c9 \ud560 \ub54c\ub9c8\ub2e4 \ub9ce\uc740 \ube44\uc6a9\uc774 \uccad\uad6c\ub418\uc5b4 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uae30\ub2a5\uc744 \ub123\ub294 \uac83\uc740 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uacb0\uad6d \uc8fc\uc18c \uac80\uc0c9\uc744 \ud1b5\ud574 \ubb3c\uc758 \uc815\uc6d0\uacfc \uac00\uc7a5 \uac00\uae4c\uc6b4 \ucda9\uc804\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub7f0\ub370! \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud574\uc11c \ud655\uc778\ud574 \ubcf4\ub2c8 \ud574\ub2f9 \ucda9\uc804\uc18c\ub294 \ubb3c\uc758 \uc815\uc6d0\uacfc \uc0dd\uac01\ubcf4\ub2e4 \uba40\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(42143).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(9836).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,"\ubb34\ub824 \uac78\uc5b4\uc11c 30\ubd84\uc774\ub098 \uac78\ub9ac\ub294 \ucda9\uc804\uc18c\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uc704\ud574 \uc655\ubcf5 1\uc2dc\uac04\uc774\ub098 \uac78\ub9ac\ub294 \uac70\ub9ac\ub97c \uac78\uc744 \uc218 \uc5c6\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb3c\ub860 \uc9c0\ub09c \uccb4\ud5d8\uc5d0\uc11c \uc804\uae30\ucc28\uac00 \uc0dd\uac01\ubcf4\ub2e4 \ubc30\ud130\ub9ac\uac00 \uc624\ub798\uac04\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uace0 \uc788\uc5c8\uc9c0\ub9cc, \ub9cc\uc57d \uc800\ud76c\ucc98\ub7fc \ucda9\uc804\uc774 \uae09\ud55c \uc0ac\uc6a9\uc790\ub77c\uba74 \ubaa9\uc801\uc9c0\ub97c \ud3ec\uae30\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uaca0\uad6c\ub098 \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub9c8\uc9c0\ub9c9\uc73c\ub85c \uc815\ud55c \ubaa9\uc801\uc9c0\ub294, \uc758\uc678\uc758 \uacb0\uc815\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uad49\uc7a5\ud788 \ubc1c\uc804\ub41c \ucca8\ub2e8 \ub3c4\uc2dc\ub85c \uc54c\ub824\uc9c4 \ud310\uad50\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc0ac\uc2e4\uc740 \uc55e\uc73c\ub85c \uac08\uc9c0\ub3c4 \ubaa8\ub974\ub294 \ud310\uad50\ub97c \ubbf8\ub9ac \uad6c\uacbd\uc774\ub098 \ud574\ubcf4\uc790\ub294\uac8c \uc774\uc720\uc600\uc9c0\ub9cc \ube44\ubc00\uc785\ub2c8\ub2e4(?)"),(0,a.kt)("p",null,"\uc77c\ub2e8 \ud310\uad50\uc5ed\uc740 IT\uc11c\ube44\uc2a4 \ud68c\uc0ac\ub4e4\uc774 \ub9ce\uc774 \ubab0\ub824\uc788\ub294 \uacf3\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud310\uad50\uc5ed\uc744 \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \uac80\uc0c9\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(52597).Z,width:"370",height:"630"})),(0,a.kt)("p",null,"\uc9c0\ub3c4\ub97c \ud310\uad50\uc5ed\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \uc678\ubd80\uc778 \uac1c\ubc29\uc778 \ucda9\uc804\uc18c\ub97c \ucc3e\uc558\ub294\ub370, \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \ubcf4\uc5ec\uc11c \ud574\ub2f9 \ucda9\uc804\uc18c\ub97c \ubaa9\uc801\uc9c0\ub85c \uc7a1\uace0 \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud310\uad50"},"\ud310\uad50"),(0,a.kt)("p",null,"\ud558\ub0a8\uc5d0\uc11c \ud310\uad50\ub97c \uac00\uae30 \uc704\ud574\uc11c\ub294 \uc11c\ud558\ub0a8IC\ub97c \uc9c0\ub098\uc57c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uac00\ub294 \uae38\uc5d0 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0 \ub098\uc624\ub294 \uc815\ubcf4\uc640 \uc2e4\uc81c \uc815\ubcf4\uac00 \uc77c\uce58\ud558\ub294\uc9c0 \uc810\uac80\ucc28 \uc11c\ud558\ub0a8 \uac04\uc774 \ud734\uac8c\uc18c\ub97c \ub4e4\ub824\ubd24\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \uac80\uc0c9\uc774 \ub418\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4!"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(27181).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uac80\uc0c9 \ub2f9\uc2dc\uc5d0\ub294 2\ub300\uc758 \ucda9\uc804\uae30\uac00 \uc788\ub2e4\uace0 \ub098\uc654\uace0, \ub458\ub2e4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub2e4\uace0 \ub418\uc5b4\uc788\uc5c8\ub294\ub370 \uc2e4\uc81c\ub85c \ud655\uc778\ud574\ubcf4\ub2c8 \uc77c\uce58\ud558\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uba3c \uae38\uc744 \ub2ec\ub824 \ud310\uad50\uc5d0 \ub3c4\ucc29\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc8fc\ucc28\uc7a5\uc5d0 \ub4e4\uc5b4\uc624\uae30 \uc804, \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc758 \ucda9\uc804\uae30 \ucd1d 12\uae30 \uc911 10\uae30\uac00 \uc0ac\uc6a9\uac00\ub2a5\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc815\uc791 \ub4e4\uc5b4\uc640\uc11c \ubcf4\ub2c8 \uc785\uad6c\ubd80\ud130 \ub108\ubb34 \ub9ce\uc740 \uc804\uae30\ucc28\ub4e4\uc774 \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\uc911\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb54\uac00 \uc774\uc0c1\ud558\ub2e4 \uc2f6\uc5c8\uc9c0\ub9cc, \uc544\uc9c1 \uc11c\ubc84\uc5d0 \ubc18\uc601\uc774 \uc548\ub41c\uac74\uac00? \ud558\uba74\uc11c \ube44\uc5b4\uc788\ub294 \ucda9\uc804\uae30\ub97c \ucc3e\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(51482).Z,width:"500",height:"667"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(95179).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ucda9\uc804\uae30\ub97c \uaf42\uace0 \ub098\uc11c \uc54c\uac8c\ub41c \uac83\uc740 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0 \ub098\uc628 \ucda9\uc804\uc18c \ud68c\uc0ac\uba85\uacfc \ubc29\uae08 \uaf42\uc740 \ucda9\uc804\uae30 \ud68c\uc0ac\uba85\uc774 \ub2e4\ub974\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc54c\uace0\ubcf4\ub2c8 \uc74c\uc131 \uc778\uc2dd\uc73c\ub85c \ub124\ube44\uc5d0 \uac80\uc0c9\ud55c \ucda9\uc804\uc18c\ub294 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \uc544\ub2cc \ud310\uad50\uc5ed \ud658\uc2b9 \uc8fc\ucc28\uc7a5\uc774\ub77c \uc5c9\ub6b1\ud55c \uacf3\uc73c\ub85c \uc628 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4!!!"),(0,a.kt)("p",null,"\ub2e4\ud589\uc778 \uc810\uc740 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80 \uc815\ubcf4\uac00 \uc798\ubabb\ub41c \uac83\uc774 \uc544\ub2c8\uc5c8\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub798\uc11c \uc560\ucd08\uc5d0 \uac00\uace0\uc790 \ud588\ub358 \ud310\uad50\uacf5\uc601\uc8fc\uc790\ucc3d\uc5d0 \ub300\ud55c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\uc640 \ub3d9\uc77c\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\ub7ec \uac78\uc5b4\uc11c \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4. (\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.)"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69760).Z,width:"500",height:"375"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(33730).Z,width:"418",height:"228"})),(0,a.kt)("p",null,"\ub3c4\ucc29\ud574\ubcf4\ub2c8 1\uce35\uc758 \ucda9\uc804\uae30\ub4e4\uc774 \ubaa8\ub450 \uacf5\uc0ac\uc911\uc774\uc5c8\uace0, \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\ub85c\ub3c4 \ubd88\uc77c\uce58 \ud558\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc0c1\uc138 \uc815\ubcf4\ub97c \ubcf4\ub2c8 3~6\uce35\uc5d0 \ucda9\uc804\uae30\ub4e4\uc5d0 \ub300\ud55c \uc815\ubcf4\ub77c\ub294 \uac83\uc774 \uba85\uc2dc\ub418\uc5b4 \uc788\uc5c8\uace0, \uc2e4\uc81c\ub85c\ub3c4 \uc774\uc640 \ub3d9\uc77c\ud55c \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(71085).Z,width:"500",height:"375"})),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc2dc\uac04\uc774 \ub108\ubb34 \ud758\ub7ec \ub2e4\uc2dc \uc7a0\uc2e4\ub85c \ub3cc\uc544\uc640 \ucc28\ub97c \ubc18\ub0a9\ud558\uace0 \uccb4\ud5d8\uc744 \ub9c8\ubb34\ub9ac \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uacb0\ub860"},"\uacb0\ub860"),(0,a.kt)("h3",{id:"\ubd88\ud3b8\ud588\ub358-\uc810"},"\ubd88\ud3b8\ud588\ub358 \uc810"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc2dc\uc5d0 \uc6d0\ud558\ub294 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uc9c0\ub3c4\ub97c \ud655\ub300\ud574\uc8fc\uc138\uc694 \ubaa8\ub2ec\uc774 \ub728\uace0, \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c \ub9c8\ucee4\uac00 \uc804\ubd80 \uc0ac\ub77c\uc9c4\ub2e4."))),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub294 \uc218\ub2e8\uc774 \uc5c6\uc5b4 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\ud604\uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ud540 (1\ucc28 \uccb4\ud5d8\uae30\uc5d0\uc11c\ub3c4 \uc5b8\uae09\ud588\ub358 \ubd80\ubd84)"),(0,a.kt)("li",{parentName:"ul"},"\ub0b4 \uc704\uce58\ub97c \uc0c1\ub300\uc801\uc73c\ub85c \uc54c \uc218 \uc788\ub294 \ub79c\ub4dc\ub9c8\ud06c\uc758 \ubd80\uc871"))),(0,a.kt)("li",{parentName:"ul"},"\ud2b9\uc815 \uc7a5\uc18c(\ub9e4\uc7a5\uba85) \uac80\uc0c9\uc774 \uc548\ub3fc\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac00\uae30 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uce74\uce74\uc624\ub9f5 \ub4f1\uc744 \ud65c\uc6a9\ud574 \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \uc9c4\ud589\ud574\uc57c \ud588\ub2e4.")))),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\ud45c"},"\ub2e4\uc74c \ubaa9\ud45c"),(0,a.kt)("p",null,"\uc55e\uc120 \ubd88\ud3b8\ud588\ub358\uc810\uc744 \uac1c\uc120\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uae30\ub2a5 \uac1c\uc120\uc744 \ucd94\uac00\ub85c \uc9c4\ud589\ud560 \uc608\uc815\uc785\ub2c8\ub2e4."),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc5d0 \uc81c\ud55c\uc774 \uc0dd\uae30\uc9c0 \uc54a\uac8c \ucda9\uc804\uc18c \ub9c8\ucee4 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc744 \uc6b0\uc120\uc801\uc73c\ub85c \ub3c4\uc785\ud55c\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub3c4\ub85d \uc9c0\ud558\ucca0 \uc5ed\uacfc \uac19\uc740 \ub79c\ub4dc\ub9c8\ucee4\ub97c \uc9c0\uc6e0\ub358 \uac83\uc744 \ub864\ubc31\ud55c\ub2e4.")),(0,a.kt)("p",null,"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac08 \uc218 \uc788\ub3c4\ub85d \ud558\uae30 \uc704\ud574\uc11c \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \ucd94\uac00\ud558\uace0 \uc2f6\uc9c0\ub9cc, \ud574\ub2f9 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 \uac80\uc0c9\ub2f9 \ube44\uc6a9\uc774 \ub9ce\uc774 \uccad\uad6c\ub418\ub294 \uc7a5\uc18c \uac80\uc0c9 API\ub97c \ucd94\uac00\ud574\uc57c \ud588\uae30\uc5d0 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uad6c\ud604\ud558\uae30 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."))}k.isMDXComponent=!0},9836:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/30-minutes-c85532af9807b46a836114f47d9370a8.png"},95179:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/charging-ffe0ad234bee030a3c530c6535014a1c.png"},45739:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/city-search-2c43a5fed62ee0aa8a90519d91092b83.png"},51482:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/empty_station-051c14502d2c5ad67f2353c987e78397.png"},76752:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/from-jamsil-6642367d758b59285f047247604f3b73.png"},71085:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/full-charged-5c139dee4a42c83edfb5a71b71220cda.png"},99070:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/go-to-starfield-cafc0eef5101f3d1f0b81bf033fd2d99.png"},27181:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/hanam_station-f703f3a6946ea1106e2a022e886577a9.png"},94199:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mandu-mandu-10754738289399ba0e8782a13a873c34.png"},98203:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mobile-markers-2cd607c6d10d89914731914ded7c421b.png"},42143:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/near-water-cebfb5c4cd07a3c477e3106a1c7ccb7e.png"},69760:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/no-chargers-342cb105d18982d69b1571832f59cb3c.png"},52597:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/pangyo-26dd2c251b15ce4e6c7830e01f2e80a5.png"},33730:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/real-pangyo-fb5b6d1a87ed372fd36e6e0e352168ca.png"},69058:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/routes-f67d5286798a1ebb66fc487e25eb788c.png"}}]); \ No newline at end of file diff --git a/assets/js/80960b4b.ab52a95f.js b/assets/js/80960b4b.d05d7f9e.js similarity index 57% rename from assets/js/80960b4b.ab52a95f.js rename to assets/js/80960b4b.d05d7f9e.js index 33866f3c..20b4e448 100644 --- a/assets/js/80960b4b.ab52a95f.js +++ b/assets/js/80960b4b.d05d7f9e.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7599],{28386:e=>{e.exports=JSON.parse('{"permalink":"/page/21","page":21,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/20","nextPage":"/page/22","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7599],{28386:e=>{e.exports=JSON.parse('{"permalink":"/page/21","page":21,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/20","nextPage":"/page/22","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/814f3328.6cd13bd4.js b/assets/js/814f3328.6cd13bd4.js new file mode 100644 index 00000000..99b9e16f --- /dev/null +++ b/assets/js/814f3328.6cd13bd4.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2535],{45641:e=>{e.exports=JSON.parse('{"title":"Recent posts","items":[{"title":"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec","permalink":"/41"},{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","permalink":"/40"},{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","permalink":"/39"},{"title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","permalink":"/37"},{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/38"}]}')}}]); \ No newline at end of file diff --git a/assets/js/814f3328.c22c504d.js b/assets/js/814f3328.c22c504d.js deleted file mode 100644 index d89dadfc..00000000 --- a/assets/js/814f3328.c22c504d.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2535],{45641:e=>{e.exports=JSON.parse('{"title":"Recent posts","items":[{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2","permalink":"/40"},{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1","permalink":"/39"},{"title":"\ucda9\uc804\uc18c \uc870\ud68c api \ubd84\ub9ac","permalink":"/37"},{"title":"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 \ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/38"},{"title":"\ub9c8\ucee4 \ub80c\ub354\ub9c1 \ucd5c\uc801\ud654","permalink":"/36"}]}')}}]); \ No newline at end of file diff --git a/assets/js/871c1e5a.db239483.js b/assets/js/871c1e5a.fa3bb160.js similarity index 57% rename from assets/js/871c1e5a.db239483.js rename to assets/js/871c1e5a.fa3bb160.js index 8410403a..a693cd66 100644 --- a/assets/js/871c1e5a.db239483.js +++ b/assets/js/871c1e5a.fa3bb160.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5966],{71247:e=>{e.exports=JSON.parse('{"permalink":"/page/23","page":23,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/22","nextPage":"/page/24","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5966],{71247:e=>{e.exports=JSON.parse('{"permalink":"/page/23","page":23,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/22","nextPage":"/page/24","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/88c8cf4c.048d91e3.js b/assets/js/88c8cf4c.2d5af889.js similarity index 59% rename from assets/js/88c8cf4c.048d91e3.js rename to assets/js/88c8cf4c.2d5af889.js index 6ed68a7f..f33c52f8 100644 --- a/assets/js/88c8cf4c.048d91e3.js +++ b/assets/js/88c8cf4c.2d5af889.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[8721],{17731:e=>{e.exports=JSON.parse('{"permalink":"/tags/aws/page/3","page":3,"postsPerPage":1,"totalPages":3,"totalCount":3,"previousPage":"/tags/aws/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[8721],{17731:e=>{e.exports=JSON.parse('{"permalink":"/tags/aws/page/3","page":3,"postsPerPage":1,"totalPages":4,"totalCount":4,"previousPage":"/tags/aws/page/2","nextPage":"/tags/aws/page/4","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/895a9c33.528cab00.js b/assets/js/895a9c33.89e12578.js similarity index 54% rename from assets/js/895a9c33.528cab00.js rename to assets/js/895a9c33.89e12578.js index cbfc98ea..67da9095 100644 --- a/assets/js/895a9c33.528cab00.js +++ b/assets/js/895a9c33.89e12578.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9792],{75293:e=>{e.exports=JSON.parse('{"permalink":"/tags/aws","page":1,"postsPerPage":1,"totalPages":3,"totalCount":3,"nextPage":"/tags/aws/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9792],{75293:e=>{e.exports=JSON.parse('{"permalink":"/tags/aws","page":1,"postsPerPage":1,"totalPages":4,"totalCount":4,"nextPage":"/tags/aws/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/89e28a64.fdcd1137.js b/assets/js/89e28a64.fdcd1137.js new file mode 100644 index 00000000..a11d44fd --- /dev/null +++ b/assets/js/89e28a64.fdcd1137.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6794],{78449:e=>{e.exports=JSON.parse('{"label":"zero-time","permalink":"/tags/zero-time","allTagsPath":"/tags","count":1}')}}]); \ No newline at end of file diff --git a/assets/js/8ac474ef.fa1e8ed5.js b/assets/js/8ac474ef.fa1e8ed5.js new file mode 100644 index 00000000..65e734a7 --- /dev/null +++ b/assets/js/8ac474ef.fa1e8ed5.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5721],{3905:(e,n,t)=>{t.d(n,{Zo:()=>i,kt:()=>k});var r=t(67294);function l(e,n,t){return n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t,e}function a(e,n){var t=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);n&&(r=r.filter((function(n){return Object.getOwnPropertyDescriptor(e,n).enumerable}))),t.push.apply(t,r)}return t}function o(e){for(var n=1;n=0||(l[t]=e[t]);return l}(e,n);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,t)&&(l[t]=e[t])}return l}var s=r.createContext({}),c=function(e){var n=r.useContext(s),t=n;return e&&(t="function"==typeof e?e(n):o(o({},n),e)),t},i=function(e){var n=c(e.components);return r.createElement(s.Provider,{value:n},e.children)},p="mdxType",d={inlineCode:"code",wrapper:function(e){var n=e.children;return r.createElement(r.Fragment,{},n)}},b=r.forwardRef((function(e,n){var t=e.components,l=e.mdxType,a=e.originalType,s=e.parentName,i=u(e,["components","mdxType","originalType","parentName"]),p=c(t),b=l,k=p["".concat(s,".").concat(b)]||p[b]||d[b]||a;return t?r.createElement(k,o(o({ref:n},i),{},{components:t})):r.createElement(k,o({ref:n},i))}));function k(e,n){var t=arguments,l=n&&n.mdxType;if("string"==typeof e||l){var a=t.length,o=new Array(a);o[0]=b;var u={};for(var s in n)hasOwnProperty.call(n,s)&&(u[s]=n[s]);u.originalType=e,u[p]="string"==typeof e?e:l,o[1]=u;for(var c=2;c{t.r(n),t.d(n,{assets:()=>s,contentTitle:()=>o,default:()=>d,frontMatter:()=>a,metadata:()=>u,toc:()=>c});var r=t(87462),l=(t(67294),t(3905));const a={slug:41,title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",authors:["jay"],tags:["infra","ec2","cd","aws","zero-time","blue-green"]},o=void 0,u={permalink:"/41",source:"@site/blog/2023-10-18-zero-time-deploy.mdx",title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",description:"\uc548\ub155\ud558\uc138\uc694! \uce74\ud398\uc778\ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.",date:"2023-10-18T00:00:00.000Z",formattedDate:"2023\ub144 10\uc6d4 18\uc77c",tags:[{label:"infra",permalink:"/tags/infra"},{label:"ec2",permalink:"/tags/ec-2"},{label:"cd",permalink:"/tags/cd"},{label:"aws",permalink:"/tags/aws"},{label:"zero-time",permalink:"/tags/zero-time"},{label:"blue-green",permalink:"/tags/blue-green"}],readingTime:8.93,hasTruncateMarker:!1,authors:[{name:"\uc81c\uc774",title:"Backend",url:"https://github.com/sosow0212",imageURL:"https://github.com/sosow0212.png",key:"jay"}],frontMatter:{slug:"41",title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",authors:["jay"],tags:["infra","ec2","cd","aws","zero-time","blue-green"]},nextItem:{title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",permalink:"/40"}},s={authorsImageUrls:[void 0]},c=[{value:"\uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uacfc \ubb38\uc81c\uc810",id:"\uae30\uc874-\ubc30\ud3ec-\ubc29\uc2dd\uacfc-\ubb38\uc81c\uc810",level:2},{value:"\uae30\uc874 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30",id:"\uae30\uc874-\ubb38\uc81c\ub97c-\ud574\uacb0\ud558\uae30",level:2},{value:"backend-deploy.yml",id:"backend-deployyml",level:3},{value:"bluegreen.sh",id:"bluegreensh",level:3}],i={toc:c},p="wrapper";function d(e){let{components:n,...t}=e;return(0,l.kt)(p,(0,r.Z)({},i,t,{components:n,mdxType:"MDXLayout"}),(0,l.kt)("p",null,"\uc548\ub155\ud558\uc138\uc694! \uce74\ud398\uc778\ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4."),(0,l.kt)("p",null,"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\n\uc5b4\ub5a4 \uacfc\uc815\uc73c\ub85c \uc9c4\ud589\uc744 \ud588\ub294\uc9c0 \uc791\uc131\ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4!"),(0,l.kt)("hr",null),(0,l.kt)("h2",{id:"\uae30\uc874-\ubc30\ud3ec-\ubc29\uc2dd\uacfc-\ubb38\uc81c\uc810"},"\uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uacfc \ubb38\uc81c\uc810"),(0,l.kt)("b",null,"\uba3c\uc800 \uce74\ud398\uc778 \ud300\uc758 \uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4."),(0,l.kt)("br",null),(0,l.kt)("br",null),(0,l.kt)("ol",null,(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Target branch\uc5d0 push"),"\uac00 \ub418\uba74 ",(0,l.kt)("b",null,"Github Actions"),"\uac00 \uc791\ub3d9\ud569\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"Target branch\uc758 ",(0,l.kt)("b",null,"\uc18c\uc2a4 \ucf54\ub4dc\uac00 \ube4c\ub4dc\ub418\uc5b4\uc11c Docker Hub"),"\uc5d0 \uc62c\ub77c\uac00\uac8c \ub429\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Github Actions\uc758 self-hosted runner"),"\ub97c \ud1b5\ud574 ",(0,l.kt)("b",null,"infra \uc11c\ubc84\uc5d0\uc11c prod \uc11c\ubc84\ub85c \uc811\uadfc"),"\ud558\uc5ec\uc11c ",(0,l.kt)("b",null,"\uae30\uc874\uc5d0 \ub744\uc6cc\uc9c4 \uc11c\ubc84\ub97c \ub2e4\uc6b4")," \uc2dc\ud0b5\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c ",(0,l.kt)("b",null,"Docker image\ub97c pull\ud574\uc11c \uc11c\ubc84\ub97c \uac00\ub3d9"),"\uc2dc\ud0b5\ub2c8\ub2e4.")),(0,l.kt)("br",null),"\uc774\ub7f0 \uacfc\uc815\uc73c\ub85c \ubc30\ud3ec \uc2a4\ud06c\ub9bd\ud2b8\uac00 \uc791\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774 \ubc29\ubc95\uc740 ",(0,l.kt)("b",null,"\uae30\uc874 \uc11c\ubc84\ub97c \ub2e4\uc6b4 \uc2dc\ud0a4\uace0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ub744\uc6b8 \ub54c \ub2e4\uc6b4 \ud0c0\uc784\uc774 \uc874\uc7ac\ud55c\ub2e4\ub294 \ubb38\uc81c\uc810"),"\uc774 \uc788\uc2b5\ub2c8\ub2e4.",(0,l.kt)("br",null),(0,l.kt)("br",null),"\uc0ac\uc6a9\uc790 \uc785\uc7a5\uc5d0\uc11c\ub294 \uc798 \uc0ac\uc6a9\ud558\uace0 \uc788\ub294\ub370 \uac11\uc790\uae30 \uc11c\ube44\uc2a4\uac00 \uc791\ub3d9\ub418\uc9c0 \uc54a\ub294\ub2e4\uba74 \uc11c\ube44\uc2a4\uc5d0 \ub300\ud55c \uc2e0\ub8b0\uc131\uc774 \ub0ae\uc544\uc9c8 \uc218\ub3c4 \uc788\uace0 \uc774\ub7f0 \uc774\uc720\ub85c \uc774\ud0c8\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.",(0,l.kt)("hr",null),(0,l.kt)("h2",{id:"\uae30\uc874-\ubb38\uc81c\ub97c-\ud574\uacb0\ud558\uae30"},"\uae30\uc874 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30"),(0,l.kt)("p",null,"\uc800\ud76c\ub294 \uba3c\uc800 \uc81c\ud55c\ub41c EC2 \uc778\uc2a4\ud134\uc2a4\ub85c \uc778\ud574 \ub864\ub9c1 \ubc30\ud3ec\uc758 \uc7a5\uc810\uc744 \uac00\uc838\uac08 \uc218 \uc5c6\uc5c8\uace0, \uce74\ub098\ub9ac \ubc29\uc2dd \ub610\ud55c \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \ud544\uc694\ub85c\ud55c \uc804\ub7b5\uc774 \uc544\ub2c8\uae30 \ub54c\ubb38\uc5d0 \ube44\uad50\uc801 \ub864\ubc31\ub3c4 \ube60\ub978 ",(0,l.kt)("b",null,"Blue/Green")," \uc804\ub7b5\uc744 \uc120\ud0dd\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,l.kt)("p",null,"\uc800\ud76c\uc758 Blue/Green \ubb34\uc911\ub2e8 \ubc30\ud3ec \uc2dc\ub098\ub9ac\uc624\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\n\ud3b8\uc758\ub97c \uc704\ud574\uc11c ",(0,l.kt)("b",null,"[\uae30\uc874 \uc11c\ubc84(\uae30\uc874 \ud3ec\ud2b8) / \uc0c8\ub85c\uc6b4 \uc11c\ubc84(\uc0c8\ub85c\uc6b4 \ud3ec\ud2b8)]")," \ub77c\ub294 \uba85\uce6d\uc744 \uc0ac\uc6a9\ud558\uaca0\uc2b5\ub2c8\ub2e4."),(0,l.kt)("br",null),(0,l.kt)("ol",null,(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Target branch\uc5d0 push"),"\uac00 \ub418\uba74 ",(0,l.kt)("b",null,"Github Actions\uac00 \uc791\ub3d9"),"\ud569\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"Target branch\uc758 ",(0,l.kt)("b",null,"\uc18c\uc2a4 \ucf54\ub4dc\uac00 \ube4c\ub4dc\ub418\uc5b4\uc11c Docker Hub")," \uc5d0 \uc62c\ub77c\uac00\uac8c \ub429\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Github Actions\uc758 self-hosted runner"),"\ub97c \ud1b5\ud574 ",(0,l.kt)("b",null,"infra \uc11c\ubc84\uc5d0\uc11c prod \uc11c\ubc84\ub85c \uc811\uadfc"),"\ud574\uc11c Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c ",(0,l.kt)("b",null,"\uc0c8\ub85c\uc6b4 \ubc84\uc804\uc758 Image\ub97c pull")," \ud574\uc635\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"\ub9cc\uc57d ",(0,l.kt)("b",null,"8080 \ud3ec\ud2b8\uc5d0 \uae30\uc874 \uc11c\ubc84\uac00 \ub744\uc6cc\uc838 \uc788\uc73c\uba74 8081 \ud3ec\ud2b8\ub97c \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c8 \ud3ec\ud2b8\ub85c \uc9c0\uc815"),"\ud574\uc8fc\uace0, \ubc18\ub300\ub85c ",(0,l.kt)("b",null,"8081 \ud3ec\ud2b8\uc5d0 \uae30\uc874 \uc11c\ubc84\uac00 \ub744\uc6cc\uc838 \uc788\uc73c\uba74 8080 \ud3ec\ud2b8\uc5d0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c8 \ud3ec\ud2b8\ub85c \uc9c0\uc815"),"\ud574\uc90d\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"\ubbf8\ub9ac Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c ",(0,l.kt)("b",null,"Docker image\ub97c ","[image+port]","\ub77c\ub294 \ub124\uc774\ubc0d\uc73c\ub85c pull\uc744 \ud55c \ud6c4 \uc0c8\ub85c\uc6b4 \ud3ec\ud2b8\ub85c \uc11c\ubc84\ub97c \uac00\ub3d9"),"\uc2dc\ud0b5\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"\uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \uac00\ub3d9 \ub410\ub294\uc9c0 \ud655\uc778\ud558\uae30 \uc704\ud574\uc11c ",(0,l.kt)("b",null,"\ud5ec\uc2a4 \uccb4\ud06c"),"\ub97c \uc9c4\ud589\ud569\ub2c8\ub2e4. 20\ubc88 \ub3d9\uc548 \uc11c\ubc84\uac00 \uc815\uc0c1 \ub3d9\uc791\ud558\ub294\uc9c0 Spring Actuactor\ub97c \ud1b5\ud574\uc11c \ud655\uc778\uc744 \ud569\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"\uc815\uc0c1 \uc791\ub3d9\uc774 \ub410\uc74c\uc744 \ud655\uc778\ud558\uba74 \ud604\uc7ac \uc778\uc2a4\ud134\uc2a4\uc5d0\ub294 2\ub300\uc758 \uc11c\ubc84"),"\uac00 \ub744\uc6cc\uc838\uc788\uace0 ",(0,l.kt)("b",null,"\uc694\uccad\uc740 \uc5ec\uc804\ud788 \uae30\uc874 \uc11c\ubc84"),"\ub85c \ub4e4\uc5b4\uac00\uac8c \ub429\ub2c8\ub2e4. \ub530\ub77c\uc11c ",(0,l.kt)("b",null,"Nginx\ub97c \ud1b5\ud574 \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub85c \uc9c0\uc815"),"\ud574\uc8fc\uace0 ",(0,l.kt)("b",null,"\uae30\uc874 \uc11c\ubc84\ub294 \ub0b4\ub824"),"\uc90d\ub2c8\ub2e4.")),(0,l.kt)("br",null),"\uc5ec\uae30\uae4c\uc9c0\uac00 \uce74\ud398\uc778 \ud300\uc758 \uc2dc\ub098\ub9ac\uc624\uc785\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uba74 \ud558\ub098\uc529 \uc2a4\ud06c\ub9bd\ud2b8\ub97c \ud655\uc778\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc740 \uc8fc\uc11d\uc73c\ub85c \ub2ec\uc544\ub450\uaca0\uc2b5\ub2c8\ub2e4 :)",(0,l.kt)("br",null),(0,l.kt)("br",null),(0,l.kt)("h3",{id:"backend-deployyml"},"backend-deploy.yml"),(0,l.kt)("p",null,"(Github Actions\uc5d0\uc11c \uc0ac\uc6a9)"),(0,l.kt)("pre",null,(0,l.kt)("code",{parentName:"pre",className:"language-yml"},'name: deploy\n\n# 1. prod/backend branch\uc5d0 push \uc791\uc5c5\uc774 \uc77c\uc5b4\ub098\uba74 \ud574\ub2f9 \uc791\uc5c5\uc744 \uc218\ud589\ud55c\ub2e4\non:\n push:\n branches:\n - prod/backend\n\njobs:\n docker-build:\n runs-on: ubuntu-latest\n defaults:\n run:\n working-directory: ./backend\n\n steps:\n # 2. \ub3c4\ucee4 \ud5c8\ube0c\uc5d0 \ub85c\uadf8\uc778\n - name: Log in to Docker Hub\n uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a\n with:\n username: ${{ secrets.DOCKERHUB_USERNAME }}\n password: ${{ secrets.DOCKERHUB_PASSWORD }}\n - uses: actions/checkout@v3\n\n # 3. JDK 17 \uc124\uce58 \ubc0f \ube4c\ub4dc (\ud504\ub85c\uc81d\ud2b8 Java version)\n - name: Set up JDK 17\n uses: actions/setup-java@v3\n with:\n java-version: \'17\'\n distribution: \'adopt\'\n\n - name: Gradle Caching\n uses: actions/cache@v3\n with:\n path: |\n ~/.gradle/caches\n ~/.gradle/wrapper\n key: ${{ runner.os }}-gradle-${{ hashFiles(\'**/*.gradle*\', \'**/gradle-wrapper.properties\') }}\n restore-keys: |\n ${{ runner.os }}-gradle-\n\n - name: Grant execute permission for gradlew\n run: chmod +x gradlew\n - name: Build for asciiDoc\n run: ./gradlew bootjar\n\n - name: Build with Gradle\n run: ./gradlew bootjar\n\n # 4. \uc0b0\ucd9c\ubb3c\uc744 Image\ub85c \ube4c\ub4dc \ud6c4 Docker Hub\uc5d0 Image Push\ud558\uae30\n - name: Extract metadata (tags, labels) for Docker\n id: meta\n uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7\n with:\n images: woowacarffeine/backend\n\n - name: Build and push Docker image\n uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671\n with:\n context: .\n file: ./backend/Dockerfile\n push: true\n platforms: linux/arm64\n tags: woowacarffeine/backend:latest\n labels: ${{ steps.meta.outputs.labels }}\n\n\n deploy:\n # 5. Self-hosted \uc791\ub3d9 -> infra \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c \uc791\ub3d9\ub428\n runs-on: self-hosted\n if: ${{ needs.docker-build.result == \'success\' }}\n needs: [ docker-build ]\n steps:\n\n # 6. infra \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c prod \uc778\uc2a4\ud134\uc2a4\ub85c \uc811\uadfc (\uc544\ub798\ubd80\ud130\ub294 prod \uc11c\ubc84 \ub0b4\uc5d0\uc11c \uc791\uc5c5)\n - name: Join EC2 prod server\n uses: appleboy/ssh-action@master\n env:\n JASYPT_KEY: ${{ secrets.JASYPT_KEY }}\n DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}\n DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}\n with:\n host: ${{ secrets.SERVER_HOST }}\n username: ${{ secrets.SERVER_USERNAME }}\n key: ${{ secrets.SERVER_KEY }}\n port: ${{ secrets.SERVER_PORT }}\n envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD\n\n script: |\n\n # 7. Docker Hub\uc5d0\uc11c Image\ub97c pull\ud574\uc628\ub2e4\n sudo docker pull woowacarffeine/backend:latest\n\n # 8. \ub9cc\uc57d 8080 \ud3ec\ud2b8\uac00 \ucf1c\uc838 \uc788\uc73c\uba74 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub294 8081\ub85c \uc124\uc815\n if sudo docker ps | grep ":8080"; then\n export BEFORE_PORT=8080\n export NEW_PORT=8081\n export NEW_ACTUATOR_PORT=8089\n\n # 9. \ub9cc\uc57d 8081 \ud3ec\ud2b8\uac00 \ucf1c\uc838 \uc788\uc73c\uba74 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub294 8080\ub85c \uc124\uc815\n else\n export BEFORE_PORT=8081\n export NEW_PORT=8080\n export NEW_ACTUATOR_PORT=8088\n fi\n\n # 10. Docker\ub85c \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ub744\uc6b4\ub2e4.\n sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \\\n -e "SPRING_PROFILE=prod" \\\n -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \\\n -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \\\n -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \\\n -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \\\n -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \\\n -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \\\n --name backend$NEW_PORT \\\n woowacarffeine/backend:latest\n\n # 11. prod \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc788\ub294 bluegreen.sh \ub97c \uc791\ub3d9\ud55c\ub2e4. (\uc774 \ub54c port \uac12\uc744 \uac19\uc774 \ub123\uc5b4\uc900\ub2e4.)\n sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT\n\n')),(0,l.kt)("br",null),(0,l.kt)("br",null),(0,l.kt)("h3",{id:"bluegreensh"},"bluegreen.sh"),(0,l.kt)("p",null,"(prod \uc778\uc2a4\ud134\uc2a4 \ub0b4\ubd80\uc5d0 \uc874\uc7ac)"),(0,l.kt)("pre",null,(0,l.kt)("code",{parentName:"pre",className:"language-shell"},'#!/bin/bash\n\n# 1. Github Actions\ub97c \ud1b5\ud574 \ub118\uaca8 \ubc1b\uc740 \ud658\uacbd\ubcc0\uc218 \uac12\nBEFORE_PORT=$1\nNEW_PORT=$2\nNEW_ACTUATOR_PORT=$3\n\necho "\uae30\uc874 \ud3ec\ud2b8 : $BEFORE_PORT"\necho "\uc0c8\ub85c\uc6b4 \ud3ec\ud2b8: $NEW_PORT"\necho "\uc0c8\ub85c\uc6b4 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"\n\n\n# 2. 20\ubc88 \ub3d9\uc548 \ud5ec\uc2a4 \uccb4\ud06c\ub97c \uc9c4\ud589\ncount=0\nfor count in {0..20}\ndo\n echo "\uc11c\ubc84 \uc0c1\ud0dc \ud655\uc778(${count}/20)";\n\n # 3. \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc791\ub3d9\ub418\ub294\uc9c0 Actuator\ub97c \ud1b5\ud574 \uac12\uc744 \ubc1b\uc544\uc634\n STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)\n\n # 4. Actuator\ub97c \ud1b5\ud574 \uc131\uacf5\uc801\uc73c\ub85c \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c0\uc9c0 \uc54a\uc740 \uacbd\uc6b0\n if [ "${STATUS}" != \'{"status":"up"}\' ]\n then\n # 5. 10\ucd08\ub97c \uae30\ub2e4\ub9b0 \ud6c4 \ub2e4\uc2dc \ud5ec\uc2a4 \uccb4\ud06c\ub97c \uc9c4\ud589\ud55c\ub2e4.\n sleep 10\n continue\n else\n # 6. \ud5ec\uc2a4 \uccb4\ud06c\ub97c \ud1b5\ud574 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc131\uacf5\uc801\uc73c\ub85c \uc791\ub3d9\ub41c\ub2e4\uba74 \uba48\ucd98\ub2e4.\n break\n fi\ndone\n\n\n# 7. 20\ubc88\uc758 \ud5ec\uc2a4 \uccb4\ud06c\ub97c \ud558\ub294 \ub3d9\uc548 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \uc791\ub3d9\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc885\ub8cc\nif [ $count -eq 20 ]\nthen\n echo "\uc0c8\ub85c\uc6b4 \uc11c\ubc84 \ubc30\ud3ec\ub97c \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4."\n exit 1\nfi\n\n\n# 8. \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc131\uacf5\uc801\uc73c\ub85c \uc791\ub3d9\ud55c \uacbd\uc6b0\n# Nginx\ub97c \ud1b5\ud574 \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \uae30\uc874 \ud3ec\ud2b8\uc5d0\uc11c \uc0c8\ub85c\uc6b4 \ud3ec\ud2b8\ub85c \ubcc0\uacbd\ud574\uc900\ub2e4.\n# \uc774 \ubd80\ubd84\uc740 .inc \ud30c\uc77c\uc744 \ud1b5\ud574 Nginx\uc5d0\uc11c \uc8fc\uc785 \ubc1b\uc544\uc11c \ud3ec\ud2b8\ub9cc \ubcc0\uacbd\ud574\ub3c4 \ub429\ub2c8\ub2e4!\nexport BACKEND_PORT=$NEW_PORT\nenvsubst \'${BACKEND_PORT}\' < backend.template > backend.conf\nsudo mv backend.conf /etc/nginx/conf.d/\nsudo nginx -s reload\n\n\n# 9. \uae30\uc874 \uc11c\ubc84\ub97c \ub0b4\ub824\uc8fc\uace0, \ub3c4\ucee4 \ub9ac\uc18c\uc2a4\ub97c \uc815\ub9ac\ud574\uc900\ub2e4\ndocker stop backend$BEFORE_PORT\nsudo docker container prune -f\n')),(0,l.kt)("p",null,"\uc774\ub807\uac8c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \ub3c4\uc785\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,l.kt)("p",null,"\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4 :)"))}d.isMDXComponent=!0}}]); \ No newline at end of file diff --git a/assets/js/8d05b77c.7f01a2b6.js b/assets/js/8d05b77c.edea9eb8.js similarity index 57% rename from assets/js/8d05b77c.7f01a2b6.js rename to assets/js/8d05b77c.edea9eb8.js index acf0c495..ccda3db4 100644 --- a/assets/js/8d05b77c.7f01a2b6.js +++ b/assets/js/8d05b77c.edea9eb8.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4149],{22801:e=>{e.exports=JSON.parse('{"permalink":"/page/5","page":5,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/4","nextPage":"/page/6","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4149],{22801:e=>{e.exports=JSON.parse('{"permalink":"/page/5","page":5,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/4","nextPage":"/page/6","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/9348a89e.6c1b9ca1.js b/assets/js/9348a89e.6c1b9ca1.js new file mode 100644 index 00000000..2cd7fda1 --- /dev/null +++ b/assets/js/9348a89e.6c1b9ca1.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5161],{88115:e=>{e.exports=JSON.parse('{"permalink":"/tags/zero-time","page":1,"postsPerPage":1,"totalPages":1,"totalCount":1,"blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/943a7d8e.98a01976.js b/assets/js/943a7d8e.d08557f0.js similarity index 68% rename from assets/js/943a7d8e.98a01976.js rename to assets/js/943a7d8e.d08557f0.js index 6910163d..8db08443 100644 --- a/assets/js/943a7d8e.98a01976.js +++ b/assets/js/943a7d8e.d08557f0.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9354],{95522:e=>{e.exports=JSON.parse('{"label":"ec2","permalink":"/tags/ec-2","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9354],{95522:e=>{e.exports=JSON.parse('{"label":"ec2","permalink":"/tags/ec-2","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/96adae60.52f145f3.js b/assets/js/96adae60.7d5b22d6.js similarity index 57% rename from assets/js/96adae60.52f145f3.js rename to assets/js/96adae60.7d5b22d6.js index ce8b6056..e4cf208a 100644 --- a/assets/js/96adae60.52f145f3.js +++ b/assets/js/96adae60.7d5b22d6.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[172],{54217:e=>{e.exports=JSON.parse('{"permalink":"/page/19","page":19,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/18","nextPage":"/page/20","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[172],{54217:e=>{e.exports=JSON.parse('{"permalink":"/page/19","page":19,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/18","nextPage":"/page/20","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/9a8c4dbd.eff1fb05.js b/assets/js/9a8c4dbd.eff1fb05.js new file mode 100644 index 00000000..cfeb998d --- /dev/null +++ b/assets/js/9a8c4dbd.eff1fb05.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7088],{15103:e=>{e.exports=JSON.parse('{"permalink":"/tags/ec-2/page/4","page":4,"postsPerPage":1,"totalPages":4,"totalCount":4,"previousPage":"/tags/ec-2/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/9bc95288.d98629b6.js b/assets/js/9bc95288.3957b2a9.js similarity index 95% rename from assets/js/9bc95288.d98629b6.js rename to assets/js/9bc95288.3957b2a9.js index 96e6e5c3..6a092d2d 100644 --- a/assets/js/9bc95288.d98629b6.js +++ b/assets/js/9bc95288.3957b2a9.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6325],{3148:a=>{a.exports=JSON.parse('{"label":"infra","permalink":"/tags/infra","allTagsPath":"/tags","count":1}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6325],{3148:a=>{a.exports=JSON.parse('{"label":"infra","permalink":"/tags/infra","allTagsPath":"/tags","count":2}')}}]); \ No newline at end of file diff --git a/assets/js/9cfe8fd1.380c1b6e.js b/assets/js/9cfe8fd1.3dcde1b4.js similarity index 57% rename from assets/js/9cfe8fd1.380c1b6e.js rename to assets/js/9cfe8fd1.3dcde1b4.js index d3737de5..05243b42 100644 --- a/assets/js/9cfe8fd1.380c1b6e.js +++ b/assets/js/9cfe8fd1.3dcde1b4.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7725],{97113:e=>{e.exports=JSON.parse('{"permalink":"/page/18","page":18,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/17","nextPage":"/page/19","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7725],{97113:e=>{e.exports=JSON.parse('{"permalink":"/page/18","page":18,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/17","nextPage":"/page/19","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/9d4c58e5.f78fe1e0.js b/assets/js/9d4c58e5.70094647.js similarity index 67% rename from assets/js/9d4c58e5.f78fe1e0.js rename to assets/js/9d4c58e5.70094647.js index 04d8c4bc..64777b85 100644 --- a/assets/js/9d4c58e5.f78fe1e0.js +++ b/assets/js/9d4c58e5.70094647.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9563],{43548:a=>{a.exports=JSON.parse('{"label":"aws","permalink":"/tags/aws","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9563],{43548:a=>{a.exports=JSON.parse('{"label":"aws","permalink":"/tags/aws","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/a37176f0.a0426a6c.js b/assets/js/a37176f0.a0426a6c.js new file mode 100644 index 00000000..7e61df4f --- /dev/null +++ b/assets/js/a37176f0.a0426a6c.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4335],{64748:e=>{e.exports=JSON.parse('{"permalink":"/tags/cd/page/2","page":2,"postsPerPage":1,"totalPages":3,"totalCount":3,"previousPage":"/tags/cd","nextPage":"/tags/cd/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/a37176f0.ddfa748c.js b/assets/js/a37176f0.ddfa748c.js deleted file mode 100644 index c8717425..00000000 --- a/assets/js/a37176f0.ddfa748c.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4335],{64748:e=>{e.exports=JSON.parse('{"permalink":"/tags/cd/page/2","page":2,"postsPerPage":1,"totalPages":2,"totalCount":2,"previousPage":"/tags/cd","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/a3d6bdf1.50cea83a.js b/assets/js/a3d6bdf1.50cea83a.js deleted file mode 100644 index 094f5b96..00000000 --- a/assets/js/a3d6bdf1.50cea83a.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9417],{3905:(t,e,l)=>{l.d(e,{Zo:()=>u,kt:()=>m});var n=l(67294);function a(t,e,l){return e in t?Object.defineProperty(t,e,{value:l,enumerable:!0,configurable:!0,writable:!0}):t[e]=l,t}function r(t,e){var l=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),l.push.apply(l,n)}return l}function i(t){for(var e=1;e=0||(a[l]=t[l]);return a}(t,e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);for(n=0;n=0||Object.prototype.propertyIsEnumerable.call(t,l)&&(a[l]=t[l])}return a}var s=n.createContext({}),o=function(t){var e=n.useContext(s),l=e;return t&&(l="function"==typeof t?t(e):i(i({},e),t)),l},u=function(t){var e=o(t.components);return n.createElement(s.Provider,{value:e},t.children)},c="mdxType",k={inlineCode:"code",wrapper:function(t){var e=t.children;return n.createElement(n.Fragment,{},e)}},d=n.forwardRef((function(t,e){var l=t.components,a=t.mdxType,r=t.originalType,s=t.parentName,u=p(t,["components","mdxType","originalType","parentName"]),c=o(l),d=a,m=c["".concat(s,".").concat(d)]||c[d]||k[d]||r;return l?n.createElement(m,i(i({ref:e},u),{},{components:l})):n.createElement(m,i({ref:e},u))}));function m(t,e){var l=arguments,a=e&&e.mdxType;if("string"==typeof t||a){var r=l.length,i=new Array(r);i[0]=d;var p={};for(var s in e)hasOwnProperty.call(e,s)&&(p[s]=e[s]);p.originalType=t,p[c]="string"==typeof t?t:a,i[1]=p;for(var o=2;o{l.r(e),l.d(e,{assets:()=>s,contentTitle:()=>i,default:()=>k,frontMatter:()=>r,metadata:()=>p,toc:()=>o});var n=l(87462),a=(l(67294),l(3905));const r={slug:40,title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},i=void 0,p={permalink:"/40",source:"@site/blog/2023-10-15-carffeine-tester-2/index.mdx",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",description:"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.",date:"2023-10-15T00:00:00.000Z",formattedDate:"2023\ub144 10\uc6d4 15\uc77c",tags:[{label:"\uce74\ud398\uc778",permalink:"/tags/\uce74\ud398\uc778"},{label:"\uc11c\ube44\uc2a4 \uacbd\ud5d8",permalink:"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{label:"\ud53c\ub4dc\ubc31",permalink:"/tags/\ud53c\ub4dc\ubc31"},{label:"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30",permalink:"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{label:"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571",permalink:"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],readingTime:14.665,hasTruncateMarker:!1,authors:[{name:"\uac00\ube0c\ub9ac\uc5d8",title:"Frontend",url:"https://github.com/gabrielyoon7",imageURL:"https://github.com/gabrielyoon7.png",key:"gabriel"},{name:"\uc13c\ud2b8",title:"Frontend",url:"https://github.com/kyw0716",imageURL:"https://github.com/kyw0716.png",key:"scent"}],frontMatter:{slug:"40",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},nextItem:{title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1",permalink:"/39"}},s={authorsImageUrls:[void 0,void 0]},o=[{value:"1. \uc9c0\uc5ed\uac80\uc0c9",id:"1-\uc9c0\uc5ed\uac80\uc0c9",level:3},{value:"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5",id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5",level:3},{value:"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815",id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815",level:2},{value:"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4",id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4",level:3},{value:"\uccb4\ud5d8 \uac1c\uc694",id:"\uccb4\ud5d8-\uac1c\uc694",level:2},{value:"\uccb4\ud5d8 \ud6c4\uae30",id:"\uccb4\ud5d8-\ud6c4\uae30",level:2},{value:"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c",id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c",level:3},{value:"\ud558\ub0a8 \ub9cc\ub450\uc9d1",id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1",level:3},{value:"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815",id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815",level:3},{value:"\ud310\uad50",id:"\ud310\uad50",level:3},{value:"\uacb0\ub860",id:"\uacb0\ub860",level:2},{value:"\ubd88\ud3b8\ud588\ub358 \uc810",id:"\ubd88\ud3b8\ud588\ub358-\uc810",level:3},{value:"\ub2e4\uc74c \ubaa9\ud45c",id:"\ub2e4\uc74c-\ubaa9\ud45c",level:3}],u={toc:o},c="wrapper";function k(t){let{components:e,...r}=t;return(0,a.kt)(c,(0,n.Z)({},u,r,{components:e,mdxType:"MDXLayout"}),(0,a.kt)("p",null,"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub09c\ubc88 ",(0,a.kt)("a",{parentName:"p",href:"https://car-ffeine.github.io/39"},"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8")," \uc9c4\ud589 \uc774\ud6c4 \uc77c\ubd80 \uae30\ub2a5 \uac1c\uc120\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\ub2a5 \uac1c\uc120\uc758 \uc720\uc6a9\uc131\uc744 \ud310\ubcc4\ud558\uace0\uc790 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc744 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \ud300\uc5d0\uc11c 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uac1c\uc120\ud55c \uc0ac\ud56d\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"1-\uc9c0\uc5ed\uac80\uc0c9"},"1. \uc9c0\uc5ed\uac80\uc0c9"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(45739).Z,width:"551",height:"396"})),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\uc774\uc81c\ub294 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud558\ub294 \uacbd\uc6b0, \uc804\uad6d \ub3c4\uc2dc\uc758 \uc8fc\uc18c\uac00 \uac19\uc774 \uc81c\uacf5\ub429\ub2c8\ub2e4.")),(0,a.kt)("h3",{id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5"},"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(98203).Z,width:"341",height:"907"})),(0,a.kt)("p",null,"(\uae30\uc874\uc5d0\ub294 \uc704 \uc0ac\uc9c4\ubcf4\ub2e4 \uc881\uc740 \uc601\uc5ed\ub9cc\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc774 \ud5c8\uc6a9\ub418\uc5c8\ub2e4.)"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ubaa8\ubc14\uc77c\uc5d0\uc11c \uc880 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc744 \ud5c8\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uc6d0\ub798\ub294 \ub514\ubc14\uc774\uc2a4 \ub108\ube44\ub97c \uace0\ub824\ud558\uc9c0 \uc54a\uace0 \uc90c \ub808\ubca8 \uae30\uc900\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud588\uc73c\ub098, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc774\ub294 \uc9c0\ub3c4\uc758 \uc601\uc5ed \ud06c\uae30\ub97c \uae30\ubc18\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub294 \ubc29\uc2dd\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \ub9c8\ucee4\uc758 \ub2e8\uc810\uc740, \uadf8 \ud06c\uae30\uac00 \ub108\ubb34 \ud06c\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub85c\uc778\ud574 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc8fc\ub294 \uacbd\uc6b0\uc5d0 \ub9c8\ucee4\ub4e4\uc774 \uacb9\uce58\ub294 \ud604\uc0c1\uc774 \uc788\uc5c8\ub294\ub370\uc694, \uc774\ub97c \uc218\uc815\ud558\uae30 \uc704\ud574 \ud2b9\uc815 \uc601\uc5ed \ud06c\uae30 \uc774\uc0c1\uc5d0\uc11c\ub294 \ub9c8\ucee4\ub97c \uc880 \ub354 \uac04\uc18c\ud654 \ub41c \ub514\uc790\uc778\uc73c\ub85c \ubcf4\uc774\ub3c4\ub85d \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ub9c8\ucee4 \uc0ac\uc774\uc988\uac00 \uc791\uc544\uc9c0\uba74\uc11c \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\uac00 \ub354\uc774\uc0c1 \ub4e4\uc5b4\uac08 \uacf5\uac04\uc774 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub9c8\ucee4 \uc0c9\uc0c1\uc740 \uadf8\ub300\ub85c \uc720\uc9c0\ub97c \ud558\ub418, \uc778\ud3ec \uc708\ub3c4\uc6b0\uc5d0 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\ub97c \ubcf4\uc5ec\uc8fc\ub294 \ubc29\uc2dd\uc73c\ub85c \ub514\uc790\uc778\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815"},"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815"),(0,a.kt)("p",null,"\uac1c\uc120\ud55c \uae30\ub2a5\uc774 \uc2e4\uc81c\ub85c \uc720\uc6a9\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \uc800\ud76c\ub294 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc758 \uaddc\uce59\uc744 \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc880 \ub354 \uc758\ubbf8\uc788\ub294 \uacbd\ud5d8\uc744 \ud558\uae30\uc704\ud574 1\ucc28 \uccb4\ud5d8 \ub54c \uc815\ud588\ub358 \uaddc\uce59\uc5d0 \ub354\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ucd94\uac00 \uaddc\uce59\uc744 \uc124\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4"},"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4"),(0,a.kt)("p",null,"\uc9c0\ub09c \uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8\uc5d0\uc11c\ub294 \uc9c0\uc5ed \uac80\uc0c9\uc774 \uc5c6\uc5b4 \ubaa9\ud45c \uc9c0\uc810\uc744 \ucc3e\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\uc2b5\ub2c8\ub2e4. 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uc9c0\uc5ed \uac80\uc0c9\uc774 \ucd94\uac00 \ub418\uc5c8\uc73c\ubbc0\ub85c \uc774 \uae30\ub2a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \uc774 \uaddc\uce59\uc744 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ucd94\uac00\ub85c \ubaa9\ud45c \uc9c0\uc810 \uc8fc\ubcc0\uc758 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud560 \ub54c \uc0c8\ub85c \ucd94\uac00\ub41c \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0\ub3c4 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uac1c\uc694"},"\uccb4\ud5d8 \uac1c\uc694"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69058).Z,width:"494",height:"720"})),(0,a.kt)("ol",null,(0,a.kt)("li",{parentName:"ol"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("li",{parentName:"ol"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("li",{parentName:"ol"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("li",{parentName:"ol"},"\ud310\uad50")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\ud6c4\uae30"},"\uccb4\ud5d8 \ud6c4\uae30"),(0,a.kt)("h3",{id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(76752).Z,width:"400",height:"400"})),(0,a.kt)("p",null,"\uc3d8\uce74\uc5d0\uc11c EV6\ub97c \ub300\uc5ec\ud574\uc11c ",(0,a.kt)("inlineCode",{parentName:"p"},"\uac00\ube0c\ub9ac\uc5d8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\uc13c\ud2b8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\ud0a4\uc544\ub77c"),"\uac00 \uc7a0\uc2e4\uc5ed\uc5d0\uc11c \ucd9c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc800\ub141 \ud1f4\uadfc \uc774\ud6c4\uc5d0 \ub0a8\uc774\uc12c\uc744 \uac00\ub824\uace0 \ubaa9\uc801\uc9c0\ub97c \uc124\uc815\ud558\uc600\uc73c\ub098 \ubc30\uac00 \ub108\ubb34 \uace0\ud30c\uc11c \uac00\ub294 \uae38\uc5d0 \uc2dd\uc0ac\ub97c \ud558\uc790\uace0 \uc598\uae30\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc9c4\uc815\ud55c \ucc98\uc74c \ubaa9\uc801\uc9c0\ub294 \uc2a4\ud0c0\ud544\ub4dc\uc600\uc73c\ub098, \uac00\ube0c\ub9ac\uc5d8\uc740 \ub3d9\ub124 \uc8fc\ubbfc\uc774\ub77c \uc2a4\ud0c0\ud544\ub4dc\ub97c \ub108\ubb34 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc2a4\ud0c0\ud544\ub4dc\uc5d0 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uace0\uc788\uc73c\ubbc0\ub85c \ubaa9\uc801\uc9c0\ub97c \uae09\ud558\uac8c \ubcc0\uacbd\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4. \uc774 \ub54c \ubaa9\uc801\uc9c0 \ubcc0\uacbd\uc744 \uc704\ud574 \uc8fc\ubcc0 \uc2dd\ub2f9\uc744 \ub458\ub7ec\ubcf4\ub358 \uc911\uc5d0 \uad1c\ucc2e\uc740 \uc2dd\ub2f9\uc744 \ubc1c\uacac\ud574\uc11c \ud574\ub2f9 \uc2dd\ub2f9\uc744 \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(99070).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uc2dd\ub2f9 \uc8fc\ubcc0\uc744 \uac00\uae30 \uc704\ud574 \uc9c0\uc5ed \uac80\uc0c9\uc744 \ucc98\uc74c\uc73c\ub85c \uc0ac\uc6a9\ud558\uc5ec \uc2dd\ub2f9\uacfc \uac00\uae4c\uc6b4 \uc9c0\uc5ed\uc744 \ud0d0\uc0c9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \uacfc\uc815\uc5d0\uc11c \uc2dd\ub2f9\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \uc5c6\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uac8c\ub418\uc5b4, \uadfc\ucc98 \ucda9\uc804\uc18c\ub97c \ucc3e\uc544\ubcf4\uae30 \uc704\ud574\uc11c \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud588\ub354\ub2c8 1\ucc28 \uccb4\ud5d8\ub54c\uc640\ub294 \ub2ec\ub9ac \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc92c\uc2b5\ub2c8\ub2e4. \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4 \uc790\uccb4\uac00 \ubcf4\uc774\uc9c0 \uc54a\uc544 \ub2f5\ub2f5\ud558\uc600\uc73c\ub098, \uc774\uc81c\ub294 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uac8c \ub418\uc5b4 \ud3b8\ub9ac\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc9c0\ub09c \uccb4\ud5d8 \uc774\ud6c4\ub85c \ud53c\ub4dc\ubc31\uc744 \uc790\uccb4 \uc218\uc9d1\ud558\uc5ec \uac1c\ubc1c\ud55c \uae30\ub2a5\ub4e4\uc774 \ud3b8\ud558\ub2e4\ub294 \uac83\uc744 \uc2dd\ub2f9\uc5d0 \uac00\ub294 \uae38\uc5d0 \ub290\ub084 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(94199).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ud558\ub0a8 \ub9cc\ub450\uc9d1\uc5d0\uc11c \uc2dd\uc0ac\ub97c \ud558\ub2e4\uac00 \uc54c\uac8c\ub41c \uc0ac\uc2e4\uc740, \ub0a8\uc774\uc12c\uc740 \uc0dd\uac01\ubcf4\ub2e4 \ub108\ubb34 \uba40\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc2dd\uc0ac\ub97c \ub9c8\uce58\uace0 \ub0a8\uc774\uc12c\uc5d0 \uac00\uba74, \ucda9\uc804\ub3c4 \uc81c\ub300\ub85c \ubabb\ud558\uace0 \ub3cc\uc544\uc62c \ud310\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc2dd\uc0ac\ub97c \ud558\uba74\uc11c \ub2e4\ub978 \ubaa9\uc801\uc9c0\ub97c \uc54c\uc544\ubd24\ub294\ub370, \uac00\ube0c\ub9ac\uc5d8\uc774 \uc608\uc804\uc5d0 \uac00\ubd24\ub358 \uacf3 \uc911\uc5d0\uc11c \ub0a8\uc591\uc8fc\uc758 \ubb3c\uc758 \uc815\uc6d0\uc774 \uc2dc\uac04\uc744 \ub5bc\uc6b0\uae30 \uc88b\ub2e4\ub294 \uc18c\ub9ac\ub97c \ud558\uc600\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ubb3c\uc758 \uc815\uc6d0\uc744 \uac80\uc0c9\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub180\ub78d\uac8c\ub3c4 \ubb3c\uc758\uc815\uc6d0\uc740 \uac80\uc0c9\uacb0\uacfc\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc5b4\uca54 \uc218 \uc5c6\uc774 \uce74\uce74\uc624 \uc9c0\ub3c4\ub85c \ubb3c\uc758 \uc815\uc6d0 \uc704\uce58\ub97c \ud655\uc778\ud558\uc5ec \uc8fc\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uace0, \uc774 \uc8fc\uc18c\ub97c \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4. \uc800\ud76c\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub294 \uc5c5\uccb4\uba85 \uc870\ud68c\uac00 \uc548\ub41c\ub2e4\ub294 \uac83\uc774 \uce58\uba85\uc801\uc778 \ub2e8\uc810\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\ub9cc, \uc774 \uae30\ub2a5\uc740 \uac80\uc0c9 \ud560 \ub54c\ub9c8\ub2e4 \ub9ce\uc740 \ube44\uc6a9\uc774 \uccad\uad6c\ub418\uc5b4 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uae30\ub2a5\uc744 \ub123\ub294 \uac83\uc740 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uacb0\uad6d \uc8fc\uc18c \uac80\uc0c9\uc744 \ud1b5\ud574 \ubb3c\uc758 \uc815\uc6d0\uacfc \uac00\uc7a5 \uac00\uae4c\uc6b4 \ucda9\uc804\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub7f0\ub370! \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud574\uc11c \ud655\uc778\ud574 \ubcf4\ub2c8 \ud574\ub2f9 \ucda9\uc804\uc18c\ub294 \ubb3c\uc758 \uc815\uc6d0\uacfc \uc0dd\uac01\ubcf4\ub2e4 \uba40\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(42143).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(9836).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,"\ubb34\ub824 \uac78\uc5b4\uc11c 30\ubd84\uc774\ub098 \uac78\ub9ac\ub294 \ucda9\uc804\uc18c\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uc704\ud574 \uc655\ubcf5 1\uc2dc\uac04\uc774\ub098 \uac78\ub9ac\ub294 \uac70\ub9ac\ub97c \uac78\uc744 \uc218 \uc5c6\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb3c\ub860 \uc9c0\ub09c \uccb4\ud5d8\uc5d0\uc11c \uc804\uae30\ucc28\uac00 \uc0dd\uac01\ubcf4\ub2e4 \ubc30\ud130\ub9ac\uac00 \uc624\ub798\uac04\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uace0 \uc788\uc5c8\uc9c0\ub9cc, \ub9cc\uc57d \uc800\ud76c\ucc98\ub7fc \ucda9\uc804\uc774 \uae09\ud55c \uc0ac\uc6a9\uc790\ub77c\uba74 \ubaa9\uc801\uc9c0\ub97c \ud3ec\uae30\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uaca0\uad6c\ub098 \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub9c8\uc9c0\ub9c9\uc73c\ub85c \uc815\ud55c \ubaa9\uc801\uc9c0\ub294, \uc758\uc678\uc758 \uacb0\uc815\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uad49\uc7a5\ud788 \ubc1c\uc804\ub41c \ucca8\ub2e8 \ub3c4\uc2dc\ub85c \uc54c\ub824\uc9c4 \ud310\uad50\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc0ac\uc2e4\uc740 \uc55e\uc73c\ub85c \uac08\uc9c0\ub3c4 \ubaa8\ub974\ub294 \ud310\uad50\ub97c \ubbf8\ub9ac \uad6c\uacbd\uc774\ub098 \ud574\ubcf4\uc790\ub294\uac8c \uc774\uc720\uc600\uc9c0\ub9cc \ube44\ubc00\uc785\ub2c8\ub2e4(?)"),(0,a.kt)("p",null,"\uc77c\ub2e8 \ud310\uad50\uc5ed\uc740 IT\uc11c\ube44\uc2a4 \ud68c\uc0ac\ub4e4\uc774 \ub9ce\uc774 \ubab0\ub824\uc788\ub294 \uacf3\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud310\uad50\uc5ed\uc744 \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \uac80\uc0c9\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(52597).Z,width:"370",height:"630"})),(0,a.kt)("p",null,"\uc9c0\ub3c4\ub97c \ud310\uad50\uc5ed\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \uc678\ubd80\uc778 \uac1c\ubc29\uc778 \ucda9\uc804\uc18c\ub97c \ucc3e\uc558\ub294\ub370, \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \ubcf4\uc5ec\uc11c \ud574\ub2f9 \ucda9\uc804\uc18c\ub97c \ubaa9\uc801\uc9c0\ub85c \uc7a1\uace0 \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud310\uad50"},"\ud310\uad50"),(0,a.kt)("p",null,"\ud558\ub0a8\uc5d0\uc11c \ud310\uad50\ub97c \uac00\uae30 \uc704\ud574\uc11c\ub294 \uc11c\ud558\ub0a8IC\ub97c \uc9c0\ub098\uc57c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uac00\ub294 \uae38\uc5d0 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0 \ub098\uc624\ub294 \uc815\ubcf4\uc640 \uc2e4\uc81c \uc815\ubcf4\uac00 \uc77c\uce58\ud558\ub294\uc9c0 \uc810\uac80\ucc28 \uc11c\ud558\ub0a8 \uac04\uc774 \ud734\uac8c\uc18c\ub97c \ub4e4\ub824\ubd24\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \uac80\uc0c9\uc774 \ub418\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4!"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(27181).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uac80\uc0c9 \ub2f9\uc2dc\uc5d0\ub294 2\ub300\uc758 \ucda9\uc804\uae30\uac00 \uc788\ub2e4\uace0 \ub098\uc654\uace0, \ub458\ub2e4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub2e4\uace0 \ub418\uc5b4\uc788\uc5c8\ub294\ub370 \uc2e4\uc81c\ub85c \ud655\uc778\ud574\ubcf4\ub2c8 \uc77c\uce58\ud558\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uba3c \uae38\uc744 \ub2ec\ub824 \ud310\uad50\uc5d0 \ub3c4\ucc29\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc8fc\ucc28\uc7a5\uc5d0 \ub4e4\uc5b4\uc624\uae30 \uc804, \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc758 \ucda9\uc804\uae30 \ucd1d 12\uae30 \uc911 10\uae30\uac00 \uc0ac\uc6a9\uac00\ub2a5\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc815\uc791 \ub4e4\uc5b4\uc640\uc11c \ubcf4\ub2c8 \uc785\uad6c\ubd80\ud130 \ub108\ubb34 \ub9ce\uc740 \uc804\uae30\ucc28\ub4e4\uc774 \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\uc911\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb54\uac00 \uc774\uc0c1\ud558\ub2e4 \uc2f6\uc5c8\uc9c0\ub9cc, \uc544\uc9c1 \uc11c\ubc84\uc5d0 \ubc18\uc601\uc774 \uc548\ub41c\uac74\uac00? \ud558\uba74\uc11c \ube44\uc5b4\uc788\ub294 \ucda9\uc804\uae30\ub97c \ucc3e\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(51482).Z,width:"500",height:"667"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(95179).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ucda9\uc804\uae30\ub97c \uaf42\uace0 \ub098\uc11c \uc54c\uac8c\ub41c \uac83\uc740 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0 \ub098\uc628 \ucda9\uc804\uc18c \ud68c\uc0ac\uba85\uacfc \ubc29\uae08 \uaf42\uc740 \ucda9\uc804\uae30 \ud68c\uc0ac\uba85\uc774 \ub2e4\ub974\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc54c\uace0\ubcf4\ub2c8 \uc74c\uc131 \uc778\uc2dd\uc73c\ub85c \ub124\ube44\uc5d0 \uac80\uc0c9\ud55c \ucda9\uc804\uc18c\ub294 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \uc544\ub2cc \ud310\uad50\uc5ed \ud658\uc2b9 \uc8fc\ucc28\uc7a5\uc774\ub77c \uc5c9\ub6b1\ud55c \uacf3\uc73c\ub85c \uc628 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4!!!"),(0,a.kt)("p",null,"\ub2e4\ud589\uc778 \uc810\uc740 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80 \uc815\ubcf4\uac00 \uc798\ubabb\ub41c \uac83\uc774 \uc544\ub2c8\uc5c8\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub798\uc11c \uc560\ucd08\uc5d0 \uac00\uace0\uc790 \ud588\ub358 \ud310\uad50\uacf5\uc601\uc8fc\uc790\ucc3d\uc5d0 \ub300\ud55c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\uc640 \ub3d9\uc77c\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\ub7ec \uac78\uc5b4\uc11c \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4. (\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.)"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69760).Z,width:"500",height:"375"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(33730).Z,width:"418",height:"228"})),(0,a.kt)("p",null,"\ub3c4\ucc29\ud574\ubcf4\ub2c8 1\uce35\uc758 \ucda9\uc804\uae30\ub4e4\uc774 \ubaa8\ub450 \uacf5\uc0ac\uc911\uc774\uc5c8\uace0, \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\ub85c\ub3c4 \ubd88\uc77c\uce58 \ud558\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc0c1\uc138 \uc815\ubcf4\ub97c \ubcf4\ub2c8 3~6\uce35\uc5d0 \ucda9\uc804\uae30\ub4e4\uc5d0 \ub300\ud55c \uc815\ubcf4\ub77c\ub294 \uac83\uc774 \uba85\uc2dc\ub418\uc5b4 \uc788\uc5c8\uace0, \uc2e4\uc81c\ub85c\ub3c4 \uc774\uc640 \ub3d9\uc77c\ud55c \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(71085).Z,width:"500",height:"375"})),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc2dc\uac04\uc774 \ub108\ubb34 \ud758\ub7ec \ub2e4\uc2dc \uc7a0\uc2e4\ub85c \ub3cc\uc544\uc640 \ucc28\ub97c \ubc18\ub0a9\ud558\uace0 \uccb4\ud5d8\uc744 \ub9c8\ubb34\ub9ac \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uacb0\ub860"},"\uacb0\ub860"),(0,a.kt)("h3",{id:"\ubd88\ud3b8\ud588\ub358-\uc810"},"\ubd88\ud3b8\ud588\ub358 \uc810"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc2dc\uc5d0 \uc6d0\ud558\ub294 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uc9c0\ub3c4\ub97c \ud655\ub300\ud574\uc8fc\uc138\uc694 \ubaa8\ub2ec\uc774 \ub728\uace0, \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c \ub9c8\ucee4\uac00 \uc804\ubd80 \uc0ac\ub77c\uc9c4\ub2e4."))),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub294 \uc218\ub2e8\uc774 \uc5c6\uc5b4 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\ud604\uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ud540 (1\ucc28 \uccb4\ud5d8\uae30\uc5d0\uc11c\ub3c4 \uc5b8\uae09\ud588\ub358 \ubd80\ubd84)"),(0,a.kt)("li",{parentName:"ul"},"\ub0b4 \uc704\uce58\ub97c \uc0c1\ub300\uc801\uc73c\ub85c \uc54c \uc218 \uc788\ub294 \ub79c\ub4dc\ub9c8\ud06c\uc758 \ubd80\uc871"))),(0,a.kt)("li",{parentName:"ul"},"\ud2b9\uc815 \uc7a5\uc18c(\ub9e4\uc7a5\uba85) \uac80\uc0c9\uc774 \uc548\ub3fc\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac00\uae30 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uce74\uce74\uc624\ub9f5 \ub4f1\uc744 \ud65c\uc6a9\ud574 \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \uc9c4\ud589\ud574\uc57c \ud588\ub2e4.")))),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\ud45c"},"\ub2e4\uc74c \ubaa9\ud45c"),(0,a.kt)("p",null,"\uc55e\uc120 \ubd88\ud3b8\ud588\ub358\uc810\uc744 \uac1c\uc120\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uae30\ub2a5 \uac1c\uc120\uc744 \ucd94\uac00\ub85c \uc9c4\ud589\ud560 \uc608\uc815\uc785\ub2c8\ub2e4."),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc5d0 \uc81c\ud55c\uc774 \uc0dd\uae30\uc9c0 \uc54a\uac8c \ucda9\uc804\uc18c \ub9c8\ucee4 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc744 \uc6b0\uc120\uc801\uc73c\ub85c \ub3c4\uc785\ud55c\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub3c4\ub85d \uc9c0\ud558\ucca0 \uc5ed\uacfc \uac19\uc740 \ub79c\ub4dc\ub9c8\ucee4\ub97c \uc9c0\uc6e0\ub358 \uac83\uc744 \ub864\ubc31\ud55c\ub2e4.")),(0,a.kt)("p",null,"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac08 \uc218 \uc788\ub3c4\ub85d \ud558\uae30 \uc704\ud574\uc11c \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \ucd94\uac00\ud558\uace0 \uc2f6\uc9c0\ub9cc, \ud574\ub2f9 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 \uac80\uc0c9\ub2f9 \ube44\uc6a9\uc774 \ub9ce\uc774 \uccad\uad6c\ub418\ub294 \uc7a5\uc18c \uac80\uc0c9 API\ub97c \ucd94\uac00\ud574\uc57c \ud588\uae30\uc5d0 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uad6c\ud604\ud558\uae30 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."))}k.isMDXComponent=!0},9836:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/30-minutes-c85532af9807b46a836114f47d9370a8.png"},95179:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/charging-ffe0ad234bee030a3c530c6535014a1c.png"},45739:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/city-search-2c43a5fed62ee0aa8a90519d91092b83.png"},51482:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/empty_station-051c14502d2c5ad67f2353c987e78397.png"},76752:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/from-jamsil-6642367d758b59285f047247604f3b73.png"},71085:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/full-charged-5c139dee4a42c83edfb5a71b71220cda.png"},99070:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/go-to-starfield-cafc0eef5101f3d1f0b81bf033fd2d99.png"},27181:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/hanam_station-f703f3a6946ea1106e2a022e886577a9.png"},94199:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mandu-mandu-10754738289399ba0e8782a13a873c34.png"},98203:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mobile-markers-2cd607c6d10d89914731914ded7c421b.png"},42143:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/near-water-cebfb5c4cd07a3c477e3106a1c7ccb7e.png"},69760:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/no-chargers-342cb105d18982d69b1571832f59cb3c.png"},52597:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/pangyo-26dd2c251b15ce4e6c7830e01f2e80a5.png"},33730:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/real-pangyo-fb5b6d1a87ed372fd36e6e0e352168ca.png"},69058:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/routes-f67d5286798a1ebb66fc487e25eb788c.png"}}]); \ No newline at end of file diff --git a/assets/js/a3d6bdf1.daf7025d.js b/assets/js/a3d6bdf1.daf7025d.js new file mode 100644 index 00000000..8cfbb6a5 --- /dev/null +++ b/assets/js/a3d6bdf1.daf7025d.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9417],{3905:(t,e,l)=>{l.d(e,{Zo:()=>u,kt:()=>m});var n=l(67294);function a(t,e,l){return e in t?Object.defineProperty(t,e,{value:l,enumerable:!0,configurable:!0,writable:!0}):t[e]=l,t}function r(t,e){var l=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),l.push.apply(l,n)}return l}function i(t){for(var e=1;e=0||(a[l]=t[l]);return a}(t,e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);for(n=0;n=0||Object.prototype.propertyIsEnumerable.call(t,l)&&(a[l]=t[l])}return a}var s=n.createContext({}),o=function(t){var e=n.useContext(s),l=e;return t&&(l="function"==typeof t?t(e):i(i({},e),t)),l},u=function(t){var e=o(t.components);return n.createElement(s.Provider,{value:e},t.children)},c="mdxType",k={inlineCode:"code",wrapper:function(t){var e=t.children;return n.createElement(n.Fragment,{},e)}},d=n.forwardRef((function(t,e){var l=t.components,a=t.mdxType,r=t.originalType,s=t.parentName,u=p(t,["components","mdxType","originalType","parentName"]),c=o(l),d=a,m=c["".concat(s,".").concat(d)]||c[d]||k[d]||r;return l?n.createElement(m,i(i({ref:e},u),{},{components:l})):n.createElement(m,i({ref:e},u))}));function m(t,e){var l=arguments,a=e&&e.mdxType;if("string"==typeof t||a){var r=l.length,i=new Array(r);i[0]=d;var p={};for(var s in e)hasOwnProperty.call(e,s)&&(p[s]=e[s]);p.originalType=t,p[c]="string"==typeof t?t:a,i[1]=p;for(var o=2;o{l.r(e),l.d(e,{assets:()=>s,contentTitle:()=>i,default:()=>k,frontMatter:()=>r,metadata:()=>p,toc:()=>o});var n=l(87462),a=(l(67294),l(3905));const r={slug:40,title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},i=void 0,p={permalink:"/40",source:"@site/blog/2023-10-15-carffeine-tester-2/index.mdx",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",description:"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4.",date:"2023-10-15T00:00:00.000Z",formattedDate:"2023\ub144 10\uc6d4 15\uc77c",tags:[{label:"\uce74\ud398\uc778",permalink:"/tags/\uce74\ud398\uc778"},{label:"\uc11c\ube44\uc2a4 \uacbd\ud5d8",permalink:"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8"},{label:"\ud53c\ub4dc\ubc31",permalink:"/tags/\ud53c\ub4dc\ubc31"},{label:"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30",permalink:"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30"},{label:"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571",permalink:"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571"}],readingTime:14.665,hasTruncateMarker:!1,authors:[{name:"\uac00\ube0c\ub9ac\uc5d8",title:"Frontend",url:"https://github.com/gabrielyoon7",imageURL:"https://github.com/gabrielyoon7.png",key:"gabriel"},{name:"\uc13c\ud2b8",title:"Frontend",url:"https://github.com/kyw0716",imageURL:"https://github.com/kyw0716.png",key:"scent"}],frontMatter:{slug:"40",title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",authors:["gabriel","scent"],tags:["\uce74\ud398\uc778","\uc11c\ube44\uc2a4 \uacbd\ud5d8","\ud53c\ub4dc\ubc31","\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571"]},prevItem:{title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",permalink:"/41"},nextItem:{title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 1",permalink:"/39"}},s={authorsImageUrls:[void 0,void 0]},o=[{value:"1. \uc9c0\uc5ed\uac80\uc0c9",id:"1-\uc9c0\uc5ed\uac80\uc0c9",level:3},{value:"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5",id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5",level:3},{value:"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815",id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815",level:2},{value:"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4",id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4",level:3},{value:"\uccb4\ud5d8 \uac1c\uc694",id:"\uccb4\ud5d8-\uac1c\uc694",level:2},{value:"\uccb4\ud5d8 \ud6c4\uae30",id:"\uccb4\ud5d8-\ud6c4\uae30",level:2},{value:"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c",id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c",level:3},{value:"\ud558\ub0a8 \ub9cc\ub450\uc9d1",id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1",level:3},{value:"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815",id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815",level:3},{value:"\ud310\uad50",id:"\ud310\uad50",level:3},{value:"\uacb0\ub860",id:"\uacb0\ub860",level:2},{value:"\ubd88\ud3b8\ud588\ub358 \uc810",id:"\ubd88\ud3b8\ud588\ub358-\uc810",level:3},{value:"\ub2e4\uc74c \ubaa9\ud45c",id:"\ub2e4\uc74c-\ubaa9\ud45c",level:3}],u={toc:o},c="wrapper";function k(t){let{components:e,...r}=t;return(0,a.kt)(c,(0,n.Z)({},u,r,{components:e,mdxType:"MDXLayout"}),(0,a.kt)("p",null,"\uc548\ub155\ud558\uc138\uc694? \uc13c\ud2b8\uc640 \uac00\ube0c\ub9ac\uc5d8 \uc785\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \uc9c0\ub09c\ubc88 ",(0,a.kt)("a",{parentName:"p",href:"https://car-ffeine.github.io/39"},"\uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8")," \uc9c4\ud589 \uc774\ud6c4 \uc77c\ubd80 \uae30\ub2a5 \uac1c\uc120\uc774 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \uae30\ub2a5 \uac1c\uc120\uc758 \uc720\uc6a9\uc131\uc744 \ud310\ubcc4\ud558\uace0\uc790 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc744 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c \ud300\uc5d0\uc11c 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uac1c\uc120\ud55c \uc0ac\ud56d\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"1-\uc9c0\uc5ed\uac80\uc0c9"},"1. \uc9c0\uc5ed\uac80\uc0c9"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(45739).Z,width:"551",height:"396"})),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\uc774\uc81c\ub294 \uac80\uc0c9\uc5b4\ub97c \uc785\ub825\ud558\ub294 \uacbd\uc6b0, \uc804\uad6d \ub3c4\uc2dc\uc758 \uc8fc\uc18c\uac00 \uac19\uc774 \uc81c\uacf5\ub429\ub2c8\ub2e4.")),(0,a.kt)("h3",{id:"2-\ucda9\uc804\uc18c-\ub9c8\ucee4\ub97c-\ud655\uc778\ud560-\uc218-\uc788\ub294-\uc9c0\ub3c4-\uc601\uc5ed-\ud655\uc7a5"},"2. \ucda9\uc804\uc18c \ub9c8\ucee4\ub97c \ud655\uc778\ud560 \uc218 \uc788\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(98203).Z,width:"341",height:"907"})),(0,a.kt)("p",null,"(\uae30\uc874\uc5d0\ub294 \uc704 \uc0ac\uc9c4\ubcf4\ub2e4 \uc881\uc740 \uc601\uc5ed\ub9cc\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc774 \ud5c8\uc6a9\ub418\uc5c8\ub2e4.)"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ubaa8\ubc14\uc77c\uc5d0\uc11c \uc880 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ud638\ucd9c\ud558\ub294 \uac83\uc744 \ud5c8\uc6a9\ud588\uc2b5\ub2c8\ub2e4. \uc6d0\ub798\ub294 \ub514\ubc14\uc774\uc2a4 \ub108\ube44\ub97c \uace0\ub824\ud558\uc9c0 \uc54a\uace0 \uc90c \ub808\ubca8 \uae30\uc900\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud588\uc73c\ub098, \uc774\uc81c\ub294 \uc0ac\uc6a9\uc790 \ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc774\ub294 \uc9c0\ub3c4\uc758 \uc601\uc5ed \ud06c\uae30\ub97c \uae30\ubc18\uc73c\ub85c \uc694\uccad\uc744 \uc81c\ud55c\ud558\ub294 \ubc29\uc2dd\uc744 \ub3c4\uc785\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\uae30\uc874\uc5d0 \uc0ac\uc6a9\ud558\ub358 \ub9c8\ucee4\uc758 \ub2e8\uc810\uc740, \uadf8 \ud06c\uae30\uac00 \ub108\ubb34 \ud06c\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc774\ub85c\uc778\ud574 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc8fc\ub294 \uacbd\uc6b0\uc5d0 \ub9c8\ucee4\ub4e4\uc774 \uacb9\uce58\ub294 \ud604\uc0c1\uc774 \uc788\uc5c8\ub294\ub370\uc694, \uc774\ub97c \uc218\uc815\ud558\uae30 \uc704\ud574 \ud2b9\uc815 \uc601\uc5ed \ud06c\uae30 \uc774\uc0c1\uc5d0\uc11c\ub294 \ub9c8\ucee4\ub97c \uc880 \ub354 \uac04\uc18c\ud654 \ub41c \ub514\uc790\uc778\uc73c\ub85c \ubcf4\uc774\ub3c4\ub85d \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ub9c8\ucee4 \uc0ac\uc774\uc988\uac00 \uc791\uc544\uc9c0\uba74\uc11c \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\uac00 \ub354\uc774\uc0c1 \ub4e4\uc5b4\uac08 \uacf5\uac04\uc774 \uc5c6\uc5b4\uc84c\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ub9c8\ucee4 \uc0c9\uc0c1\uc740 \uadf8\ub300\ub85c \uc720\uc9c0\ub97c \ud558\ub418, \uc778\ud3ec \uc708\ub3c4\uc6b0\uc5d0 \ud604\uc7ac \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ucda9\uc804\uae30 \uac1c\uc218\ub97c \ubcf4\uc5ec\uc8fc\ub294 \ubc29\uc2dd\uc73c\ub85c \ub514\uc790\uc778\uc744 \uac1c\uc120\ud558\uc600\uc2b5\ub2c8\ub2e4.")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uaddc\uce59-\uc124\uc815"},"\uccb4\ud5d8 \uaddc\uce59 \uc124\uc815"),(0,a.kt)("p",null,"\uac1c\uc120\ud55c \uae30\ub2a5\uc774 \uc2e4\uc81c\ub85c \uc720\uc6a9\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\uae30 \uc704\ud574 \uc800\ud76c\ub294 \uce74\ud398\uc778 \uc11c\ube44\uc2a4 2\ucc28 \uccb4\ud5d8\uc758 \uaddc\uce59\uc744 \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc880 \ub354 \uc758\ubbf8\uc788\ub294 \uacbd\ud5d8\uc744 \ud558\uae30\uc704\ud574 1\ucc28 \uccb4\ud5d8 \ub54c \uc815\ud588\ub358 \uaddc\uce59\uc5d0 \ub354\ud574\uc11c \ub2e4\uc74c\uacfc \uac19\uc740 \ucd94\uac00 \uaddc\uce59\uc744 \uc124\uc815\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\uc911\uac04\uc5d0-\ubaa9\ud45c-\uc9c0\uc810\uc774-\ub9ce\uc774-\ubcc0\uacbd\ub41c\ub2e4"},"\uc911\uac04\uc5d0 \ubaa9\ud45c \uc9c0\uc810\uc774 \ub9ce\uc774 \ubcc0\uacbd\ub41c\ub2e4"),(0,a.kt)("p",null,"\uc9c0\ub09c \uce74\ud398\uc778 \uc11c\ube44\uc2a4 1\ucc28 \uccb4\ud5d8\uc5d0\uc11c\ub294 \uc9c0\uc5ed \uac80\uc0c9\uc774 \uc5c6\uc5b4 \ubaa9\ud45c \uc9c0\uc810\uc744 \ucc3e\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\uc2b5\ub2c8\ub2e4. 1\ucc28 \uccb4\ud5d8 \uc774\ud6c4 \uc9c0\uc5ed \uac80\uc0c9\uc774 \ucd94\uac00 \ub418\uc5c8\uc73c\ubbc0\ub85c \uc774 \uae30\ub2a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \uc774 \uaddc\uce59\uc744 \uc124\uc815\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ucd94\uac00\ub85c \ubaa9\ud45c \uc9c0\uc810 \uc8fc\ubcc0\uc758 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud560 \ub54c \uc0c8\ub85c \ucd94\uac00\ub41c \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc774 \uc5bc\ub9c8\ub098 \uc720\uc6a9\ud55c\uc9c0\ub3c4 \uacbd\ud5d8\ud574\ubcf4\uace0\uc790 \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\uac1c\uc694"},"\uccb4\ud5d8 \uac1c\uc694"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69058).Z,width:"494",height:"720"})),(0,a.kt)("ol",null,(0,a.kt)("li",{parentName:"ol"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("li",{parentName:"ol"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("li",{parentName:"ol"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("li",{parentName:"ol"},"\ud310\uad50")),(0,a.kt)("h2",{id:"\uccb4\ud5d8-\ud6c4\uae30"},"\uccb4\ud5d8 \ud6c4\uae30"),(0,a.kt)("h3",{id:"\uc7a0\uc2e4\uc5ed-\ucd9c\ubc1c"},"\uc7a0\uc2e4\uc5ed \ucd9c\ubc1c"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(76752).Z,width:"400",height:"400"})),(0,a.kt)("p",null,"\uc3d8\uce74\uc5d0\uc11c EV6\ub97c \ub300\uc5ec\ud574\uc11c ",(0,a.kt)("inlineCode",{parentName:"p"},"\uac00\ube0c\ub9ac\uc5d8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\uc13c\ud2b8"),", ",(0,a.kt)("inlineCode",{parentName:"p"},"\ud0a4\uc544\ub77c"),"\uac00 \uc7a0\uc2e4\uc5ed\uc5d0\uc11c \ucd9c\ubc1c\ud558\uc600\uc2b5\ub2c8\ub2e4. \uc800\ub141 \ud1f4\uadfc \uc774\ud6c4\uc5d0 \ub0a8\uc774\uc12c\uc744 \uac00\ub824\uace0 \ubaa9\uc801\uc9c0\ub97c \uc124\uc815\ud558\uc600\uc73c\ub098 \ubc30\uac00 \ub108\ubb34 \uace0\ud30c\uc11c \uac00\ub294 \uae38\uc5d0 \uc2dd\uc0ac\ub97c \ud558\uc790\uace0 \uc598\uae30\uac00 \ub098\uc654\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud558\ub0a8-\ub9cc\ub450\uc9d1"},"\ud558\ub0a8 \ub9cc\ub450\uc9d1"),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc9c4\uc815\ud55c \ucc98\uc74c \ubaa9\uc801\uc9c0\ub294 \uc2a4\ud0c0\ud544\ub4dc\uc600\uc73c\ub098, \uac00\ube0c\ub9ac\uc5d8\uc740 \ub3d9\ub124 \uc8fc\ubbfc\uc774\ub77c \uc2a4\ud0c0\ud544\ub4dc\ub97c \ub108\ubb34 \uc798 \uc54c\uace0 \uc788\uc5c8\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \uc2a4\ud0c0\ud544\ub4dc\uc5d0 \uc804\uae30\ucc28 \ucda9\uc804\uc18c\uac00 \uc5b4\ub514\uc5d0 \uc788\ub294\uc9c0\ub3c4 \uc54c\uace0\uc788\uc73c\ubbc0\ub85c \ubaa9\uc801\uc9c0\ub97c \uae09\ud558\uac8c \ubcc0\uacbd\ud558\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4. \uc774 \ub54c \ubaa9\uc801\uc9c0 \ubcc0\uacbd\uc744 \uc704\ud574 \uc8fc\ubcc0 \uc2dd\ub2f9\uc744 \ub458\ub7ec\ubcf4\ub358 \uc911\uc5d0 \uad1c\ucc2e\uc740 \uc2dd\ub2f9\uc744 \ubc1c\uacac\ud574\uc11c \ud574\ub2f9 \uc2dd\ub2f9\uc744 \uae30\uc900\uc73c\ub85c \uc8fc\ubcc0 \ucda9\uc804\uc18c\ub97c \ud655\uc778\ud574\ubcf4\uae30\ub85c \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(99070).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uc2dd\ub2f9 \uc8fc\ubcc0\uc744 \uac00\uae30 \uc704\ud574 \uc9c0\uc5ed \uac80\uc0c9\uc744 \ucc98\uc74c\uc73c\ub85c \uc0ac\uc6a9\ud558\uc5ec \uc2dd\ub2f9\uacfc \uac00\uae4c\uc6b4 \uc9c0\uc5ed\uc744 \ud0d0\uc0c9\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \uacfc\uc815\uc5d0\uc11c \uc2dd\ub2f9\uc5d0\ub294 \ucda9\uc804\uc18c\uac00 \uc5c6\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uac8c\ub418\uc5b4, \uadfc\ucc98 \ucda9\uc804\uc18c\ub97c \ucc3e\uc544\ubcf4\uae30 \uc704\ud574\uc11c \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud588\ub354\ub2c8 1\ucc28 \uccb4\ud5d8\ub54c\uc640\ub294 \ub2ec\ub9ac \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \ubcf4\uc5ec\uc92c\uc2b5\ub2c8\ub2e4. \uc774\uc804\uc5d0\ub294 \ub9c8\ucee4 \uc790\uccb4\uac00 \ubcf4\uc774\uc9c0 \uc54a\uc544 \ub2f5\ub2f5\ud558\uc600\uc73c\ub098, \uc774\uc81c\ub294 \ub354 \ub113\uc740 \uc601\uc5ed\uc744 \uc870\ud68c\ud560 \uc218 \uc788\uac8c \ub418\uc5b4 \ud3b8\ub9ac\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc9c0\ub09c \uccb4\ud5d8 \uc774\ud6c4\ub85c \ud53c\ub4dc\ubc31\uc744 \uc790\uccb4 \uc218\uc9d1\ud558\uc5ec \uac1c\ubc1c\ud55c \uae30\ub2a5\ub4e4\uc774 \ud3b8\ud558\ub2e4\ub294 \uac83\uc744 \uc2dd\ub2f9\uc5d0 \uac00\ub294 \uae38\uc5d0 \ub290\ub084 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\uc801\uc9c0-\uc124\uc815"},"\ub2e4\uc74c \ubaa9\uc801\uc9c0 \uc124\uc815"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(94199).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ud558\ub0a8 \ub9cc\ub450\uc9d1\uc5d0\uc11c \uc2dd\uc0ac\ub97c \ud558\ub2e4\uac00 \uc54c\uac8c\ub41c \uc0ac\uc2e4\uc740, \ub0a8\uc774\uc12c\uc740 \uc0dd\uac01\ubcf4\ub2e4 \ub108\ubb34 \uba40\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4. \uc2dd\uc0ac\ub97c \ub9c8\uce58\uace0 \ub0a8\uc774\uc12c\uc5d0 \uac00\uba74, \ucda9\uc804\ub3c4 \uc81c\ub300\ub85c \ubabb\ud558\uace0 \ub3cc\uc544\uc62c \ud310\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc2dd\uc0ac\ub97c \ud558\uba74\uc11c \ub2e4\ub978 \ubaa9\uc801\uc9c0\ub97c \uc54c\uc544\ubd24\ub294\ub370, \uac00\ube0c\ub9ac\uc5d8\uc774 \uc608\uc804\uc5d0 \uac00\ubd24\ub358 \uacf3 \uc911\uc5d0\uc11c \ub0a8\uc591\uc8fc\uc758 \ubb3c\uc758 \uc815\uc6d0\uc774 \uc2dc\uac04\uc744 \ub5bc\uc6b0\uae30 \uc88b\ub2e4\ub294 \uc18c\ub9ac\ub97c \ud558\uc600\uc2b5\ub2c8\ub2e4. \ub530\ub77c\uc11c \ubb3c\uc758 \uc815\uc6d0\uc744 \uac80\uc0c9\ud574\ubcf4\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub180\ub78d\uac8c\ub3c4 \ubb3c\uc758\uc815\uc6d0\uc740 \uac80\uc0c9\uacb0\uacfc\uc5d0 \uc5c6\uc5c8\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc5b4\uca54 \uc218 \uc5c6\uc774 \uce74\uce74\uc624 \uc9c0\ub3c4\ub85c \ubb3c\uc758 \uc815\uc6d0 \uc704\uce58\ub97c \ud655\uc778\ud558\uc5ec \uc8fc\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uace0, \uc774 \uc8fc\uc18c\ub97c \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \ub123\uc5c8\uc2b5\ub2c8\ub2e4. \uc800\ud76c\ub294 \uc774 \uacfc\uc815\uc5d0\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub294 \uc5c5\uccb4\uba85 \uc870\ud68c\uac00 \uc548\ub41c\ub2e4\ub294 \uac83\uc774 \uce58\uba85\uc801\uc778 \ub2e8\uc810\uc774\ub77c\uace0 \uc0dd\uac01\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\ub9cc, \uc774 \uae30\ub2a5\uc740 \uac80\uc0c9 \ud560 \ub54c\ub9c8\ub2e4 \ub9ce\uc740 \ube44\uc6a9\uc774 \uccad\uad6c\ub418\uc5b4 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uae30\ub2a5\uc744 \ub123\ub294 \uac83\uc740 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uacb0\uad6d \uc8fc\uc18c \uac80\uc0c9\uc744 \ud1b5\ud574 \ubb3c\uc758 \uc815\uc6d0\uacfc \uac00\uc7a5 \uac00\uae4c\uc6b4 \ucda9\uc804\uc18c\ub97c \uc54c\uc544\ub0b4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub7f0\ub370! \uc9c0\ub3c4\ub97c \ucd95\uc18c\ud574\uc11c \ud655\uc778\ud574 \ubcf4\ub2c8 \ud574\ub2f9 \ucda9\uc804\uc18c\ub294 \ubb3c\uc758 \uc815\uc6d0\uacfc \uc0dd\uac01\ubcf4\ub2e4 \uba40\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(42143).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(9836).Z,width:"500",height:"1082"})),(0,a.kt)("p",null,"\ubb34\ub824 \uac78\uc5b4\uc11c 30\ubd84\uc774\ub098 \uac78\ub9ac\ub294 \ucda9\uc804\uc18c\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc804\uae30\ucc28 \ucda9\uc804\uc744 \uc704\ud574 \uc655\ubcf5 1\uc2dc\uac04\uc774\ub098 \uac78\ub9ac\ub294 \uac70\ub9ac\ub97c \uac78\uc744 \uc218 \uc5c6\ub2e4\uace0 \uc0dd\uac01\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb3c\ub860 \uc9c0\ub09c \uccb4\ud5d8\uc5d0\uc11c \uc804\uae30\ucc28\uac00 \uc0dd\uac01\ubcf4\ub2e4 \ubc30\ud130\ub9ac\uac00 \uc624\ub798\uac04\ub2e4\ub294 \uc0ac\uc2e4\uc744 \uc54c\uace0 \uc788\uc5c8\uc9c0\ub9cc, \ub9cc\uc57d \uc800\ud76c\ucc98\ub7fc \ucda9\uc804\uc774 \uae09\ud55c \uc0ac\uc6a9\uc790\ub77c\uba74 \ubaa9\uc801\uc9c0\ub97c \ud3ec\uae30\ud560 \uc218 \ubc16\uc5d0 \uc5c6\uaca0\uad6c\ub098 \ub77c\ub294 \uc0dd\uac01\uc774 \ub4e4\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub9c8\uc9c0\ub9c9\uc73c\ub85c \uc815\ud55c \ubaa9\uc801\uc9c0\ub294, \uc758\uc678\uc758 \uacb0\uc815\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uad49\uc7a5\ud788 \ubc1c\uc804\ub41c \ucca8\ub2e8 \ub3c4\uc2dc\ub85c \uc54c\ub824\uc9c4 \ud310\uad50\uc600\uc2b5\ub2c8\ub2e4!"),(0,a.kt)("p",null,"\uc0ac\uc2e4\uc740 \uc55e\uc73c\ub85c \uac08\uc9c0\ub3c4 \ubaa8\ub974\ub294 \ud310\uad50\ub97c \ubbf8\ub9ac \uad6c\uacbd\uc774\ub098 \ud574\ubcf4\uc790\ub294\uac8c \uc774\uc720\uc600\uc9c0\ub9cc \ube44\ubc00\uc785\ub2c8\ub2e4(?)"),(0,a.kt)("p",null,"\uc77c\ub2e8 \ud310\uad50\uc5ed\uc740 IT\uc11c\ube44\uc2a4 \ud68c\uc0ac\ub4e4\uc774 \ub9ce\uc774 \ubab0\ub824\uc788\ub294 \uacf3\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ub530\ub77c\uc11c \uc800\ud76c\ub294 \ud310\uad50\uc5ed\uc744 \uce74\ud398\uc778 \uac80\uc0c9\ucc3d\uc5d0 \uac80\uc0c9\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(52597).Z,width:"370",height:"630"})),(0,a.kt)("p",null,"\uc9c0\ub3c4\ub97c \ud310\uad50\uc5ed\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \uc678\ubd80\uc778 \uac1c\ubc29\uc778 \ucda9\uc804\uc18c\ub97c \ucc3e\uc558\ub294\ub370, \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \ubcf4\uc5ec\uc11c \ud574\ub2f9 \ucda9\uc804\uc18c\ub97c \ubaa9\uc801\uc9c0\ub85c \uc7a1\uace0 \ucd9c\ubc1c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h3",{id:"\ud310\uad50"},"\ud310\uad50"),(0,a.kt)("p",null,"\ud558\ub0a8\uc5d0\uc11c \ud310\uad50\ub97c \uac00\uae30 \uc704\ud574\uc11c\ub294 \uc11c\ud558\ub0a8IC\ub97c \uc9c0\ub098\uc57c\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uac00\ub294 \uae38\uc5d0 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0 \ub098\uc624\ub294 \uc815\ubcf4\uc640 \uc2e4\uc81c \uc815\ubcf4\uac00 \uc77c\uce58\ud558\ub294\uc9c0 \uc810\uac80\ucc28 \uc11c\ud558\ub0a8 \uac04\uc774 \ud734\uac8c\uc18c\ub97c \ub4e4\ub824\ubd24\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774 \ud734\uac8c\uc18c\uc5d0\ub3c4 \ucda9\uc804\uc18c\uac00 \uc788\ub2e4\uace0 \uac80\uc0c9\uc774 \ub418\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4!"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(27181).Z,width:"500",height:"667"})),(0,a.kt)("p",null,"\uac80\uc0c9 \ub2f9\uc2dc\uc5d0\ub294 2\ub300\uc758 \ucda9\uc804\uae30\uac00 \uc788\ub2e4\uace0 \ub098\uc654\uace0, \ub458\ub2e4 \uc0ac\uc6a9\uc774 \uac00\ub2a5\ud558\ub2e4\uace0 \ub418\uc5b4\uc788\uc5c8\ub294\ub370 \uc2e4\uc81c\ub85c \ud655\uc778\ud574\ubcf4\ub2c8 \uc77c\uce58\ud558\ub294 \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uba3c \uae38\uc744 \ub2ec\ub824 \ud310\uad50\uc5d0 \ub3c4\ucc29\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc8fc\ucc28\uc7a5\uc5d0 \ub4e4\uc5b4\uc624\uae30 \uc804, \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub97c \ud655\uc778\ud574\ubcf4\ub2c8 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc758 \ucda9\uc804\uae30 \ucd1d 12\uae30 \uc911 10\uae30\uac00 \uc0ac\uc6a9\uac00\ub2a5\ud55c \uc0c1\ud0dc\uc600\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc815\uc791 \ub4e4\uc5b4\uc640\uc11c \ubcf4\ub2c8 \uc785\uad6c\ubd80\ud130 \ub108\ubb34 \ub9ce\uc740 \uc804\uae30\ucc28\ub4e4\uc774 \ucda9\uc804\uae30\ub97c \uc0ac\uc6a9\uc911\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\ubb54\uac00 \uc774\uc0c1\ud558\ub2e4 \uc2f6\uc5c8\uc9c0\ub9cc, \uc544\uc9c1 \uc11c\ubc84\uc5d0 \ubc18\uc601\uc774 \uc548\ub41c\uac74\uac00? \ud558\uba74\uc11c \ube44\uc5b4\uc788\ub294 \ucda9\uc804\uae30\ub97c \ucc3e\uc558\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(51482).Z,width:"500",height:"667"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(95179).Z,width:"500",height:"500"})),(0,a.kt)("p",null,"\ucda9\uc804\uae30\ub97c \uaf42\uace0 \ub098\uc11c \uc54c\uac8c\ub41c \uac83\uc740 \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc5d0 \ub098\uc628 \ucda9\uc804\uc18c \ud68c\uc0ac\uba85\uacfc \ubc29\uae08 \uaf42\uc740 \ucda9\uc804\uae30 \ud68c\uc0ac\uba85\uc774 \ub2e4\ub974\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc54c\uace0\ubcf4\ub2c8 \uc74c\uc131 \uc778\uc2dd\uc73c\ub85c \ub124\ube44\uc5d0 \uac80\uc0c9\ud55c \ucda9\uc804\uc18c\ub294 \ud310\uad50\uacf5\uc601\uc8fc\ucc28\uc7a5\uc774 \uc544\ub2cc \ud310\uad50\uc5ed \ud658\uc2b9 \uc8fc\ucc28\uc7a5\uc774\ub77c \uc5c9\ub6b1\ud55c \uacf3\uc73c\ub85c \uc628 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4!!!"),(0,a.kt)("p",null,"\ub2e4\ud589\uc778 \uc810\uc740 \uc6b0\ub9ac \uc11c\ube44\uc2a4\uc5d0\uc11c \uc81c\uacf5\ud558\ub294 \ucda9\uc804\uae30 \uc0ac\uc6a9 \uc5ec\ubd80 \uc815\ubcf4\uac00 \uc798\ubabb\ub41c \uac83\uc774 \uc544\ub2c8\uc5c8\ub2e4\ub294 \uac83\uc774\uc5c8\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uadf8\ub798\uc11c \uc560\ucd08\uc5d0 \uac00\uace0\uc790 \ud588\ub358 \ud310\uad50\uacf5\uc601\uc8fc\uc790\ucc3d\uc5d0 \ub300\ud55c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\uc640 \ub3d9\uc77c\ud55c\uc9c0 \ud655\uc778\ud574\ubcf4\ub7ec \uac78\uc5b4\uc11c \uc774\ub3d9\ud588\uc2b5\ub2c8\ub2e4. (\ubc14\ub85c \uc55e\uc5d0 \uc788\uc5c8\uae30 \ub54c\ubb38\uc785\ub2c8\ub2e4.)"),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(69760).Z,width:"500",height:"375"}),"\n",(0,a.kt)("img",{alt:"no offset",src:l(33730).Z,width:"418",height:"228"})),(0,a.kt)("p",null,"\ub3c4\ucc29\ud574\ubcf4\ub2c8 1\uce35\uc758 \ucda9\uc804\uae30\ub4e4\uc774 \ubaa8\ub450 \uacf5\uc0ac\uc911\uc774\uc5c8\uace0, \uc11c\ube44\uc2a4\uc758 \uc815\ubcf4\uac00 \uc2e4\uc81c\ub85c\ub3c4 \ubd88\uc77c\uce58 \ud558\ub294 \uc904 \uc54c\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc0c1\uc138 \uc815\ubcf4\ub97c \ubcf4\ub2c8 3~6\uce35\uc5d0 \ucda9\uc804\uae30\ub4e4\uc5d0 \ub300\ud55c \uc815\ubcf4\ub77c\ub294 \uac83\uc774 \uba85\uc2dc\ub418\uc5b4 \uc788\uc5c8\uace0, \uc2e4\uc81c\ub85c\ub3c4 \uc774\uc640 \ub3d9\uc77c\ud55c \uac83\uc744 \ud655\uc778\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,(0,a.kt)("img",{alt:"no offset",src:l(71085).Z,width:"500",height:"375"})),(0,a.kt)("p",null,"\uc800\ud76c\ub294 \uc2dc\uac04\uc774 \ub108\ubb34 \ud758\ub7ec \ub2e4\uc2dc \uc7a0\uc2e4\ub85c \ub3cc\uc544\uc640 \ucc28\ub97c \ubc18\ub0a9\ud558\uace0 \uccb4\ud5d8\uc744 \ub9c8\ubb34\ub9ac \ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("h2",{id:"\uacb0\ub860"},"\uacb0\ub860"),(0,a.kt)("h3",{id:"\ubd88\ud3b8\ud588\ub358-\uc810"},"\ubd88\ud3b8\ud588\ub358 \uc810"),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc2dc\uc5d0 \uc6d0\ud558\ub294 \uc815\ubcf4\ub97c \ubcfc \uc218 \uc5c6\ub294 \uac83\uc774 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uc9c0\ub3c4\ub97c \ud655\ub300\ud574\uc8fc\uc138\uc694 \ubaa8\ub2ec\uc774 \ub728\uace0, \uc6d0\ub798 \uc788\ub358 \ucda9\uc804\uc18c \ub9c8\ucee4\uac00 \uc804\ubd80 \uc0ac\ub77c\uc9c4\ub2e4."))),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub294 \uc218\ub2e8\uc774 \uc5c6\uc5b4 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\ud604\uc704\uce58\ub97c \ub098\ud0c0\ub0b4\ub294 \ud540 (1\ucc28 \uccb4\ud5d8\uae30\uc5d0\uc11c\ub3c4 \uc5b8\uae09\ud588\ub358 \ubd80\ubd84)"),(0,a.kt)("li",{parentName:"ul"},"\ub0b4 \uc704\uce58\ub97c \uc0c1\ub300\uc801\uc73c\ub85c \uc54c \uc218 \uc788\ub294 \ub79c\ub4dc\ub9c8\ud06c\uc758 \ubd80\uc871"))),(0,a.kt)("li",{parentName:"ul"},"\ud2b9\uc815 \uc7a5\uc18c(\ub9e4\uc7a5\uba85) \uac80\uc0c9\uc774 \uc548\ub3fc\uc11c \uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac00\uae30 \ubd88\ud3b8\ud588\ub2e4.",(0,a.kt)("ul",{parentName:"li"},(0,a.kt)("li",{parentName:"ul"},"\uce74\uce74\uc624\ub9f5 \ub4f1\uc744 \ud65c\uc6a9\ud574 \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \uc9c4\ud589\ud574\uc57c \ud588\ub2e4.")))),(0,a.kt)("h3",{id:"\ub2e4\uc74c-\ubaa9\ud45c"},"\ub2e4\uc74c \ubaa9\ud45c"),(0,a.kt)("p",null,"\uc55e\uc120 \ubd88\ud3b8\ud588\ub358\uc810\uc744 \uac1c\uc120\ud558\uae30 \uc704\ud574 \ub2e4\uc74c\uacfc \uac19\uc740 \uae30\ub2a5 \uac1c\uc120\uc744 \ucd94\uac00\ub85c \uc9c4\ud589\ud560 \uc608\uc815\uc785\ub2c8\ub2e4."),(0,a.kt)("ul",null,(0,a.kt)("li",{parentName:"ul"},"\ub514\ubc14\uc774\uc2a4\uc5d0 \ubcf4\uc5ec\uc9c0\ub294 \uc9c0\ub3c4 \uc601\uc5ed \ud655\uc7a5\uc5d0 \uc81c\ud55c\uc774 \uc0dd\uae30\uc9c0 \uc54a\uac8c \ucda9\uc804\uc18c \ub9c8\ucee4 \ud074\ub7ec\uc2a4\ud130\ub9c1\uc744 \uc6b0\uc120\uc801\uc73c\ub85c \ub3c4\uc785\ud55c\ub2e4."),(0,a.kt)("li",{parentName:"ul"},"\ud604\uc7ac \ub098\uc758 \uc704\uce58\ub97c \uc54c\uc544\ubcfc \uc218 \uc788\ub3c4\ub85d \uc9c0\ud558\ucca0 \uc5ed\uacfc \uac19\uc740 \ub79c\ub4dc\ub9c8\ucee4\ub97c \uc9c0\uc6e0\ub358 \uac83\uc744 \ub864\ubc31\ud55c\ub2e4.")),(0,a.kt)("p",null,"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\ub9cc\uc73c\ub85c \ubaa9\uc801\uc9c0\ub97c \ucc3e\uc544\uac08 \uc218 \uc788\ub3c4\ub85d \ud558\uae30 \uc704\ud574\uc11c \ud2b9\uc815 \uc7a5\uc18c \uac80\uc0c9\uc744 \ucd94\uac00\ud558\uace0 \uc2f6\uc9c0\ub9cc, \ud574\ub2f9 \uae30\ub2a5\uc744 \uad6c\ud604\ud558\uae30 \uc704\ud574\uc120 \uac80\uc0c9\ub2f9 \ube44\uc6a9\uc774 \ub9ce\uc774 \uccad\uad6c\ub418\ub294 \uc7a5\uc18c \uac80\uc0c9 API\ub97c \ucd94\uac00\ud574\uc57c \ud588\uae30\uc5d0 \ud604\uc2e4\uc801\uc73c\ub85c \uc9c0\uae08 \ub2f9\uc7a5 \uad6c\ud604\ud558\uae30 \uc5b4\ub835\ub2e4\uace0 \ud310\ub2e8\ud588\uc2b5\ub2c8\ub2e4."),(0,a.kt)("p",null,"\uc774\uc0c1 \uce74\ud398\uc778 \uc0ac\uc6a9\uae30\uc600\uc2b5\ub2c8\ub2e4."))}k.isMDXComponent=!0},9836:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/30-minutes-c85532af9807b46a836114f47d9370a8.png"},95179:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/charging-ffe0ad234bee030a3c530c6535014a1c.png"},45739:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/city-search-2c43a5fed62ee0aa8a90519d91092b83.png"},51482:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/empty_station-051c14502d2c5ad67f2353c987e78397.png"},76752:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/from-jamsil-6642367d758b59285f047247604f3b73.png"},71085:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/full-charged-5c139dee4a42c83edfb5a71b71220cda.png"},99070:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/go-to-starfield-cafc0eef5101f3d1f0b81bf033fd2d99.png"},27181:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/hanam_station-f703f3a6946ea1106e2a022e886577a9.png"},94199:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mandu-mandu-10754738289399ba0e8782a13a873c34.png"},98203:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/mobile-markers-2cd607c6d10d89914731914ded7c421b.png"},42143:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/near-water-cebfb5c4cd07a3c477e3106a1c7ccb7e.png"},69760:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/no-chargers-342cb105d18982d69b1571832f59cb3c.png"},52597:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/pangyo-26dd2c251b15ce4e6c7830e01f2e80a5.png"},33730:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/real-pangyo-fb5b6d1a87ed372fd36e6e0e352168ca.png"},69058:(t,e,l)=>{l.d(e,{Z:()=>n});const n=l.p+"assets/images/routes-f67d5286798a1ebb66fc487e25eb788c.png"}}]); \ No newline at end of file diff --git a/assets/js/a5557bb9.9689dc75.js b/assets/js/a5557bb9.ad396ec5.js similarity index 52% rename from assets/js/a5557bb9.9689dc75.js rename to assets/js/a5557bb9.ad396ec5.js index ff27c6a1..39b4bfd2 100644 --- a/assets/js/a5557bb9.9689dc75.js +++ b/assets/js/a5557bb9.ad396ec5.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5991],{93885:e=>{e.exports=JSON.parse('{"permalink":"/","page":1,"postsPerPage":1,"totalPages":40,"totalCount":40,"nextPage":"/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5991],{93885:e=>{e.exports=JSON.parse('{"permalink":"/","page":1,"postsPerPage":1,"totalPages":41,"totalCount":41,"nextPage":"/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/ab705a1f.15e7147c.js b/assets/js/ab705a1f.b7fcfeca.js similarity index 58% rename from assets/js/ab705a1f.15e7147c.js rename to assets/js/ab705a1f.b7fcfeca.js index 7e944e3a..fd8036b9 100644 --- a/assets/js/ab705a1f.15e7147c.js +++ b/assets/js/ab705a1f.b7fcfeca.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1655],{14651:e=>{e.exports=JSON.parse('{"permalink":"/tags/ec-2/page/3","page":3,"postsPerPage":1,"totalPages":3,"totalCount":3,"previousPage":"/tags/ec-2/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[1655],{14651:e=>{e.exports=JSON.parse('{"permalink":"/tags/ec-2/page/3","page":3,"postsPerPage":1,"totalPages":4,"totalCount":4,"previousPage":"/tags/ec-2/page/2","nextPage":"/tags/ec-2/page/4","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/bae98e44.096d27d2.js b/assets/js/bae98e44.096d27d2.js new file mode 100644 index 00000000..108d9ef9 --- /dev/null +++ b/assets/js/bae98e44.096d27d2.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4768],{2001:e=>{e.exports=JSON.parse('{"label":"blue-green","permalink":"/tags/blue-green","allTagsPath":"/tags","count":1}')}}]); \ No newline at end of file diff --git a/assets/js/bf03d367.272be9fa.js b/assets/js/bf03d367.548b5f97.js similarity index 67% rename from assets/js/bf03d367.272be9fa.js rename to assets/js/bf03d367.548b5f97.js index 5ddfae4a..4f2da12b 100644 --- a/assets/js/bf03d367.272be9fa.js +++ b/assets/js/bf03d367.548b5f97.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7056],{75165:a=>{a.exports=JSON.parse('{"label":"aws","permalink":"/tags/aws","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[7056],{75165:a=>{a.exports=JSON.parse('{"label":"aws","permalink":"/tags/aws","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/c29bedb9.c1f4d363.js b/assets/js/c29bedb9.7204bc00.js similarity index 57% rename from assets/js/c29bedb9.c1f4d363.js rename to assets/js/c29bedb9.7204bc00.js index 2f64e0dc..3d1e5ee6 100644 --- a/assets/js/c29bedb9.c1f4d363.js +++ b/assets/js/c29bedb9.7204bc00.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9242],{44025:e=>{e.exports=JSON.parse('{"permalink":"/page/35","page":35,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/34","nextPage":"/page/36","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9242],{44025:e=>{e.exports=JSON.parse('{"permalink":"/page/35","page":35,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/34","nextPage":"/page/36","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/c573638f.df2e3eea.js b/assets/js/c573638f.df2e3eea.js new file mode 100644 index 00000000..14cde847 --- /dev/null +++ b/assets/js/c573638f.df2e3eea.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[964],{28866:a=>{a.exports=JSON.parse('[{"label":"infra","permalink":"/tags/infra","count":2},{"label":"ec2","permalink":"/tags/ec-2","count":4},{"label":"cd","permalink":"/tags/cd","count":3},{"label":"aws","permalink":"/tags/aws","count":4},{"label":"zero-time","permalink":"/tags/zero-time","count":1},{"label":"blue-green","permalink":"/tags/blue-green","count":1},{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778","count":3},{"label":"\uc11c\ube44\uc2a4 \uacbd\ud5d8","permalink":"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8","count":2},{"label":"\ud53c\ub4dc\ubc31","permalink":"/tags/\ud53c\ub4dc\ubc31","count":2},{"label":"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","permalink":"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30","count":2},{"label":"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571","permalink":"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571","count":2},{"label":"\ud611\uc5c5","permalink":"/tags/\ud611\uc5c5","count":1},{"label":"\uc11c\ubc84 \ubd80\ud558 \uc904\uc774\uae30","permalink":"/tags/\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30","count":1},{"label":"ga4","permalink":"/tags/ga-4","count":1},{"label":"google analytics 4","permalink":"/tags/google-analytics-4","count":1},{"label":"\ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/tags/\ubc29\ubb38\uc790-\ubd84\uc11d","count":1},{"label":"react","permalink":"/tags/react","count":3},{"label":"useSyncExternalState","permalink":"/tags/use-sync-external-state","count":1},{"label":"googleMap","permalink":"/tags/google-map","count":1},{"label":"java","permalink":"/tags/java","count":3},{"label":"mysql","permalink":"/tags/mysql","count":3},{"label":"useSyncExternalStore","permalink":"/tags/use-sync-external-store","count":2},{"label":"\uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac","permalink":"/tags/\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac","count":1},{"label":"\uc804\uc5ed\uc0c1\ud0dc","permalink":"/tags/\uc804\uc5ed\uc0c1\ud0dc","count":1},{"label":"google maps api","permalink":"/tags/google-maps-api","count":3},{"label":"\uad6c\uae00 \uc9c0\ub3c4","permalink":"/tags/\uad6c\uae00-\uc9c0\ub3c4","count":1},{"label":"prod","permalink":"/tags/prod","count":1},{"label":"dev","permalink":"/tags/dev","count":1},{"label":"\ud14c\uc2a4\ud2b8","permalink":"/tags/\ud14c\uc2a4\ud2b8","count":1},{"label":"test","permalink":"/tags/test","count":2},{"label":"hello","permalink":"/tags/hello","count":2},{"label":"world","permalink":"/tags/world","count":2},{"label":"OOM","permalink":"/tags/oom","count":1},{"label":"trouble-shooting","permalink":"/tags/trouble-shooting","count":2},{"label":"deadlock","permalink":"/tags/deadlock","count":1},{"label":"filter","permalink":"/tags/filter","count":1},{"label":"index","permalink":"/tags/index","count":1},{"label":"styled-components","permalink":"/tags/styled-components","count":1},{"label":"css","permalink":"/tags/css","count":1},{"label":"css in js","permalink":"/tags/css-in-js","count":1},{"label":"tanstack query","permalink":"/tags/tanstack-query","count":1},{"label":"react state management","permalink":"/tags/react-state-management","count":1},{"label":"oauth","permalink":"/tags/oauth","count":1},{"label":"login","permalink":"/tags/login","count":1},{"label":"vpc","permalink":"/tags/vpc","count":1},{"label":"subnet","permalink":"/tags/subnet","count":1},{"label":"ip","permalink":"/tags/ip","count":1},{"label":"CI","permalink":"/tags/ci","count":2},{"label":"jpa","permalink":"/tags/jpa","count":2},{"label":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","permalink":"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","count":2},{"label":"\uc11c\ubc84","permalink":"/tags/\uc11c\ubc84","count":2},{"label":"\ubc30\ud3ec","permalink":"/tags/\ubc30\ud3ec","count":1},{"label":"\uc544\ud0a4\ud14d\ucc98","permalink":"/tags/\uc544\ud0a4\ud14d\ucc98","count":1},{"label":"google maps","permalink":"/tags/google-maps","count":1},{"label":"react-wrapper","permalink":"/tags/react-wrapper","count":1},{"label":"@googlemaps/react-wrapper","permalink":"/tags/googlemaps-react-wrapper","count":1},{"label":"jasypt","permalink":"/tags/jasypt","count":1},{"label":"Spring","permalink":"/tags/spring","count":3},{"label":"github","permalink":"/tags/github","count":2},{"label":"action","permalink":"/tags/action","count":2},{"label":"pr","permalink":"/tags/pr","count":2},{"label":"msw","permalink":"/tags/msw","count":1},{"label":"webpack","permalink":"/tags/webpack","count":1},{"label":"slack","permalink":"/tags/slack","count":1},{"label":"error","permalink":"/tags/error","count":1},{"label":"git","permalink":"/tags/git","count":2},{"label":"commit","permalink":"/tags/commit","count":1},{"label":"message","permalink":"/tags/message","count":1},{"label":"issue","permalink":"/tags/issue","count":2},{"label":"auto","permalink":"/tags/auto","count":1},{"label":"DB","permalink":"/tags/db","count":1},{"label":"Hibernate","permalink":"/tags/hibernate","count":1},{"label":"java17","permalink":"/tags/java-17","count":2},{"label":"java11","permalink":"/tags/java-11","count":1},{"label":"record","permalink":"/tags/record","count":1},{"label":"toList","permalink":"/tags/to-list","count":1},{"label":"gc","permalink":"/tags/gc","count":1},{"label":"branch","permalink":"/tags/branch","count":1},{"label":"git branch","permalink":"/tags/git-branch","count":1},{"label":"github flow","permalink":"/tags/github-flow","count":1},{"label":"gitlab flow","permalink":"/tags/gitlab-flow","count":1},{"label":"git flow","permalink":"/tags/git-flow","count":1}]')}}]); \ No newline at end of file diff --git a/assets/js/c573638f.ed7b2464.js b/assets/js/c573638f.ed7b2464.js deleted file mode 100644 index 082e1109..00000000 --- a/assets/js/c573638f.ed7b2464.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[964],{28866:a=>{a.exports=JSON.parse('[{"label":"\uce74\ud398\uc778","permalink":"/tags/\uce74\ud398\uc778","count":3},{"label":"\uc11c\ube44\uc2a4 \uacbd\ud5d8","permalink":"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8","count":2},{"label":"\ud53c\ub4dc\ubc31","permalink":"/tags/\ud53c\ub4dc\ubc31","count":2},{"label":"\uc804\uae30\ucc28 \uc0ac\uc6a9\uae30","permalink":"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30","count":2},{"label":"\uc804\uae30\ucc28 \ucda9\uc804\uc18c \uc571","permalink":"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571","count":2},{"label":"\ud611\uc5c5","permalink":"/tags/\ud611\uc5c5","count":1},{"label":"\uc11c\ubc84 \ubd80\ud558 \uc904\uc774\uae30","permalink":"/tags/\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30","count":1},{"label":"ga4","permalink":"/tags/ga-4","count":1},{"label":"google analytics 4","permalink":"/tags/google-analytics-4","count":1},{"label":"\ubc29\ubb38\uc790 \ubd84\uc11d","permalink":"/tags/\ubc29\ubb38\uc790-\ubd84\uc11d","count":1},{"label":"react","permalink":"/tags/react","count":3},{"label":"useSyncExternalState","permalink":"/tags/use-sync-external-state","count":1},{"label":"googleMap","permalink":"/tags/google-map","count":1},{"label":"java","permalink":"/tags/java","count":3},{"label":"mysql","permalink":"/tags/mysql","count":3},{"label":"useSyncExternalStore","permalink":"/tags/use-sync-external-store","count":2},{"label":"\uc804\uc5ed \uc0c1\ud0dc \uad00\ub9ac","permalink":"/tags/\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac","count":1},{"label":"\uc804\uc5ed\uc0c1\ud0dc","permalink":"/tags/\uc804\uc5ed\uc0c1\ud0dc","count":1},{"label":"google maps api","permalink":"/tags/google-maps-api","count":3},{"label":"\uad6c\uae00 \uc9c0\ub3c4","permalink":"/tags/\uad6c\uae00-\uc9c0\ub3c4","count":1},{"label":"ec2","permalink":"/tags/ec-2","count":3},{"label":"prod","permalink":"/tags/prod","count":1},{"label":"dev","permalink":"/tags/dev","count":1},{"label":"\ud14c\uc2a4\ud2b8","permalink":"/tags/\ud14c\uc2a4\ud2b8","count":1},{"label":"test","permalink":"/tags/test","count":2},{"label":"hello","permalink":"/tags/hello","count":2},{"label":"world","permalink":"/tags/world","count":2},{"label":"OOM","permalink":"/tags/oom","count":1},{"label":"trouble-shooting","permalink":"/tags/trouble-shooting","count":2},{"label":"deadlock","permalink":"/tags/deadlock","count":1},{"label":"filter","permalink":"/tags/filter","count":1},{"label":"index","permalink":"/tags/index","count":1},{"label":"styled-components","permalink":"/tags/styled-components","count":1},{"label":"css","permalink":"/tags/css","count":1},{"label":"css in js","permalink":"/tags/css-in-js","count":1},{"label":"tanstack query","permalink":"/tags/tanstack-query","count":1},{"label":"react state management","permalink":"/tags/react-state-management","count":1},{"label":"oauth","permalink":"/tags/oauth","count":1},{"label":"login","permalink":"/tags/login","count":1},{"label":"aws","permalink":"/tags/aws","count":3},{"label":"vpc","permalink":"/tags/vpc","count":1},{"label":"subnet","permalink":"/tags/subnet","count":1},{"label":"ip","permalink":"/tags/ip","count":1},{"label":"CI","permalink":"/tags/ci","count":2},{"label":"CD","permalink":"/tags/cd","count":2},{"label":"jpa","permalink":"/tags/jpa","count":2},{"label":"\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","permalink":"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","count":2},{"label":"\uc11c\ubc84","permalink":"/tags/\uc11c\ubc84","count":2},{"label":"\ubc30\ud3ec","permalink":"/tags/\ubc30\ud3ec","count":1},{"label":"\uc544\ud0a4\ud14d\ucc98","permalink":"/tags/\uc544\ud0a4\ud14d\ucc98","count":1},{"label":"google maps","permalink":"/tags/google-maps","count":1},{"label":"react-wrapper","permalink":"/tags/react-wrapper","count":1},{"label":"@googlemaps/react-wrapper","permalink":"/tags/googlemaps-react-wrapper","count":1},{"label":"jasypt","permalink":"/tags/jasypt","count":1},{"label":"Spring","permalink":"/tags/spring","count":3},{"label":"github","permalink":"/tags/github","count":2},{"label":"action","permalink":"/tags/action","count":2},{"label":"pr","permalink":"/tags/pr","count":2},{"label":"msw","permalink":"/tags/msw","count":1},{"label":"webpack","permalink":"/tags/webpack","count":1},{"label":"slack","permalink":"/tags/slack","count":1},{"label":"error","permalink":"/tags/error","count":1},{"label":"git","permalink":"/tags/git","count":2},{"label":"commit","permalink":"/tags/commit","count":1},{"label":"message","permalink":"/tags/message","count":1},{"label":"issue","permalink":"/tags/issue","count":2},{"label":"auto","permalink":"/tags/auto","count":1},{"label":"DB","permalink":"/tags/db","count":1},{"label":"Hibernate","permalink":"/tags/hibernate","count":1},{"label":"java17","permalink":"/tags/java-17","count":2},{"label":"infra","permalink":"/tags/infra","count":1},{"label":"java11","permalink":"/tags/java-11","count":1},{"label":"record","permalink":"/tags/record","count":1},{"label":"toList","permalink":"/tags/to-list","count":1},{"label":"gc","permalink":"/tags/gc","count":1},{"label":"branch","permalink":"/tags/branch","count":1},{"label":"git branch","permalink":"/tags/git-branch","count":1},{"label":"github flow","permalink":"/tags/github-flow","count":1},{"label":"gitlab flow","permalink":"/tags/gitlab-flow","count":1},{"label":"git flow","permalink":"/tags/git-flow","count":1}]')}}]); \ No newline at end of file diff --git a/assets/js/cc24784a.7c499f3f.js b/assets/js/cc24784a.7c499f3f.js new file mode 100644 index 00000000..67562edf --- /dev/null +++ b/assets/js/cc24784a.7c499f3f.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9984],{66282:e=>{e.exports=JSON.parse('{"permalink":"/tags/blue-green","page":1,"postsPerPage":1,"totalPages":1,"totalCount":1,"blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/d0e4cdf1.f7a50ec4.js b/assets/js/d0e4cdf1.31e89b8a.js similarity index 57% rename from assets/js/d0e4cdf1.f7a50ec4.js rename to assets/js/d0e4cdf1.31e89b8a.js index 2e24fb1d..b65a0733 100644 --- a/assets/js/d0e4cdf1.f7a50ec4.js +++ b/assets/js/d0e4cdf1.31e89b8a.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5465],{64020:e=>{e.exports=JSON.parse('{"permalink":"/page/7","page":7,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/6","nextPage":"/page/8","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5465],{64020:e=>{e.exports=JSON.parse('{"permalink":"/page/7","page":7,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/6","nextPage":"/page/8","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/d0e4eea8.edb09eec.js b/assets/js/d0e4eea8.edb09eec.js new file mode 100644 index 00000000..50bafc71 --- /dev/null +++ b/assets/js/d0e4eea8.edb09eec.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2595],{94927:e=>{e.exports=JSON.parse('{"permalink":"/tags/aws/page/4","page":4,"postsPerPage":1,"totalPages":4,"totalCount":4,"previousPage":"/tags/aws/page/3","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/d1cef389.7aae2b3d.js b/assets/js/d1cef389.547205f4.js similarity index 57% rename from assets/js/d1cef389.7aae2b3d.js rename to assets/js/d1cef389.547205f4.js index 041ff155..4d0b09e1 100644 --- a/assets/js/d1cef389.7aae2b3d.js +++ b/assets/js/d1cef389.547205f4.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9310],{40836:e=>{e.exports=JSON.parse('{"permalink":"/page/17","page":17,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/16","nextPage":"/page/18","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9310],{40836:e=>{e.exports=JSON.parse('{"permalink":"/page/17","page":17,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/16","nextPage":"/page/18","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/d50fd269.5fee77a2.js b/assets/js/d50fd269.6aeb1081.js similarity index 57% rename from assets/js/d50fd269.5fee77a2.js rename to assets/js/d50fd269.6aeb1081.js index c4e521cd..6863ed36 100644 --- a/assets/js/d50fd269.5fee77a2.js +++ b/assets/js/d50fd269.6aeb1081.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[100],{38132:e=>{e.exports=JSON.parse('{"permalink":"/page/31","page":31,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/30","nextPage":"/page/32","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[100],{38132:e=>{e.exports=JSON.parse('{"permalink":"/page/31","page":31,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/30","nextPage":"/page/32","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/db0d15fb.1e38451e.js b/assets/js/db0d15fb.1e38451e.js new file mode 100644 index 00000000..6c0a5b23 --- /dev/null +++ b/assets/js/db0d15fb.1e38451e.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4194],{3905:(e,n,t)=>{t.d(n,{Zo:()=>i,kt:()=>k});var r=t(67294);function l(e,n,t){return n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t,e}function a(e,n){var t=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);n&&(r=r.filter((function(n){return Object.getOwnPropertyDescriptor(e,n).enumerable}))),t.push.apply(t,r)}return t}function o(e){for(var n=1;n=0||(l[t]=e[t]);return l}(e,n);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,t)&&(l[t]=e[t])}return l}var s=r.createContext({}),c=function(e){var n=r.useContext(s),t=n;return e&&(t="function"==typeof e?e(n):o(o({},n),e)),t},i=function(e){var n=c(e.components);return r.createElement(s.Provider,{value:n},e.children)},p="mdxType",d={inlineCode:"code",wrapper:function(e){var n=e.children;return r.createElement(r.Fragment,{},n)}},b=r.forwardRef((function(e,n){var t=e.components,l=e.mdxType,a=e.originalType,s=e.parentName,i=u(e,["components","mdxType","originalType","parentName"]),p=c(t),b=l,k=p["".concat(s,".").concat(b)]||p[b]||d[b]||a;return t?r.createElement(k,o(o({ref:n},i),{},{components:t})):r.createElement(k,o({ref:n},i))}));function k(e,n){var t=arguments,l=n&&n.mdxType;if("string"==typeof e||l){var a=t.length,o=new Array(a);o[0]=b;var u={};for(var s in n)hasOwnProperty.call(n,s)&&(u[s]=n[s]);u.originalType=e,u[p]="string"==typeof e?e:l,o[1]=u;for(var c=2;c{t.r(n),t.d(n,{assets:()=>s,contentTitle:()=>o,default:()=>d,frontMatter:()=>a,metadata:()=>u,toc:()=>c});var r=t(87462),l=(t(67294),t(3905));const a={slug:41,title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",authors:["jay"],tags:["infra","ec2","cd","aws","zero-time","blue-green"]},o=void 0,u={permalink:"/41",source:"@site/blog/2023-10-18-zero-time-deploy.mdx",title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",description:"\uc548\ub155\ud558\uc138\uc694! \uce74\ud398\uc778\ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4.",date:"2023-10-18T00:00:00.000Z",formattedDate:"2023\ub144 10\uc6d4 18\uc77c",tags:[{label:"infra",permalink:"/tags/infra"},{label:"ec2",permalink:"/tags/ec-2"},{label:"cd",permalink:"/tags/cd"},{label:"aws",permalink:"/tags/aws"},{label:"zero-time",permalink:"/tags/zero-time"},{label:"blue-green",permalink:"/tags/blue-green"}],readingTime:8.93,hasTruncateMarker:!1,authors:[{name:"\uc81c\uc774",title:"Backend",url:"https://github.com/sosow0212",imageURL:"https://github.com/sosow0212.png",key:"jay"}],frontMatter:{slug:"41",title:"\uce74\ud398\uc778 \ud300\uc758 \ubb34\uc911\ub2e8 \ubc30\ud3ec",authors:["jay"],tags:["infra","ec2","cd","aws","zero-time","blue-green"]},nextItem:{title:"\uce74\ud398\uc778 \uc11c\ube44\uc2a4\uc640 \ud568\uaed8\ud558\ub294 \uc804\uae30\ucc28 \uc5ec\ud589 2",permalink:"/40"}},s={authorsImageUrls:[void 0]},c=[{value:"\uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uacfc \ubb38\uc81c\uc810",id:"\uae30\uc874-\ubc30\ud3ec-\ubc29\uc2dd\uacfc-\ubb38\uc81c\uc810",level:2},{value:"\uae30\uc874 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30",id:"\uae30\uc874-\ubb38\uc81c\ub97c-\ud574\uacb0\ud558\uae30",level:2},{value:"backend-deploy.yml",id:"backend-deployyml",level:3},{value:"bluegreen.sh",id:"bluegreensh",level:3}],i={toc:c},p="wrapper";function d(e){let{components:n,...t}=e;return(0,l.kt)(p,(0,r.Z)({},i,t,{components:n,mdxType:"MDXLayout"}),(0,l.kt)("p",null,"\uc548\ub155\ud558\uc138\uc694! \uce74\ud398\uc778\ud300\uc758 \uc81c\uc774\uc785\ub2c8\ub2e4."),(0,l.kt)("p",null,"\uc800\ud76c \uce74\ud398\uc778 \ud300\uc5d0\uc11c \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \uc9c4\ud589\ud588\uc2b5\ub2c8\ub2e4.\n\uc5b4\ub5a4 \uacfc\uc815\uc73c\ub85c \uc9c4\ud589\uc744 \ud588\ub294\uc9c0 \uc791\uc131\ud574\ubcf4\ub3c4\ub85d \ud558\uaca0\uc2b5\ub2c8\ub2e4!"),(0,l.kt)("hr",null),(0,l.kt)("h2",{id:"\uae30\uc874-\ubc30\ud3ec-\ubc29\uc2dd\uacfc-\ubb38\uc81c\uc810"},"\uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uacfc \ubb38\uc81c\uc810"),(0,l.kt)("b",null,"\uba3c\uc800 \uce74\ud398\uc778 \ud300\uc758 \uae30\uc874 \ubc30\ud3ec \ubc29\uc2dd\uc740 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4."),(0,l.kt)("br",null),(0,l.kt)("br",null),(0,l.kt)("ol",null,(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Target branch\uc5d0 push"),"\uac00 \ub418\uba74 ",(0,l.kt)("b",null,"Github Actions"),"\uac00 \uc791\ub3d9\ud569\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"Target branch\uc758 ",(0,l.kt)("b",null,"\uc18c\uc2a4 \ucf54\ub4dc\uac00 \ube4c\ub4dc\ub418\uc5b4\uc11c Docker Hub"),"\uc5d0 \uc62c\ub77c\uac00\uac8c \ub429\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Github Actions\uc758 self-hosted runner"),"\ub97c \ud1b5\ud574 ",(0,l.kt)("b",null,"infra \uc11c\ubc84\uc5d0\uc11c prod \uc11c\ubc84\ub85c \uc811\uadfc"),"\ud558\uc5ec\uc11c ",(0,l.kt)("b",null,"\uae30\uc874\uc5d0 \ub744\uc6cc\uc9c4 \uc11c\ubc84\ub97c \ub2e4\uc6b4")," \uc2dc\ud0b5\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c ",(0,l.kt)("b",null,"Docker image\ub97c pull\ud574\uc11c \uc11c\ubc84\ub97c \uac00\ub3d9"),"\uc2dc\ud0b5\ub2c8\ub2e4.")),(0,l.kt)("br",null),"\uc774\ub7f0 \uacfc\uc815\uc73c\ub85c \ubc30\ud3ec \uc2a4\ud06c\ub9bd\ud2b8\uac00 \uc791\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \ud558\uc9c0\ub9cc \uc774 \ubc29\ubc95\uc740 ",(0,l.kt)("b",null,"\uae30\uc874 \uc11c\ubc84\ub97c \ub2e4\uc6b4 \uc2dc\ud0a4\uace0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ub744\uc6b8 \ub54c \ub2e4\uc6b4 \ud0c0\uc784\uc774 \uc874\uc7ac\ud55c\ub2e4\ub294 \ubb38\uc81c\uc810"),"\uc774 \uc788\uc2b5\ub2c8\ub2e4.",(0,l.kt)("br",null),(0,l.kt)("br",null),"\uc0ac\uc6a9\uc790 \uc785\uc7a5\uc5d0\uc11c\ub294 \uc798 \uc0ac\uc6a9\ud558\uace0 \uc788\ub294\ub370 \uac11\uc790\uae30 \uc11c\ube44\uc2a4\uac00 \uc791\ub3d9\ub418\uc9c0 \uc54a\ub294\ub2e4\uba74 \uc11c\ube44\uc2a4\uc5d0 \ub300\ud55c \uc2e0\ub8b0\uc131\uc774 \ub0ae\uc544\uc9c8 \uc218\ub3c4 \uc788\uace0 \uc774\ub7f0 \uc774\uc720\ub85c \uc774\ud0c8\ud560 \uc218\ub3c4 \uc788\uc2b5\ub2c8\ub2e4.",(0,l.kt)("hr",null),(0,l.kt)("h2",{id:"\uae30\uc874-\ubb38\uc81c\ub97c-\ud574\uacb0\ud558\uae30"},"\uae30\uc874 \ubb38\uc81c\ub97c \ud574\uacb0\ud558\uae30"),(0,l.kt)("p",null,"\uc800\ud76c\ub294 \uba3c\uc800 \uc81c\ud55c\ub41c EC2 \uc778\uc2a4\ud134\uc2a4\ub85c \uc778\ud574 \ub864\ub9c1 \ubc30\ud3ec\uc758 \uc7a5\uc810\uc744 \uac00\uc838\uac08 \uc218 \uc5c6\uc5c8\uace0, \uce74\ub098\ub9ac \ubc29\uc2dd \ub610\ud55c \uc800\ud76c \uc11c\ube44\uc2a4\uc5d0\uc11c \ud544\uc694\ub85c\ud55c \uc804\ub7b5\uc774 \uc544\ub2c8\uae30 \ub54c\ubb38\uc5d0 \ube44\uad50\uc801 \ub864\ubc31\ub3c4 \ube60\ub978 ",(0,l.kt)("b",null,"Blue/Green")," \uc804\ub7b5\uc744 \uc120\ud0dd\ud558\uc600\uc2b5\ub2c8\ub2e4."),(0,l.kt)("p",null,"\uc800\ud76c\uc758 Blue/Green \ubb34\uc911\ub2e8 \ubc30\ud3ec \uc2dc\ub098\ub9ac\uc624\ub294 \ub2e4\uc74c\uacfc \uac19\uc2b5\ub2c8\ub2e4.\n\ud3b8\uc758\ub97c \uc704\ud574\uc11c ",(0,l.kt)("b",null,"[\uae30\uc874 \uc11c\ubc84(\uae30\uc874 \ud3ec\ud2b8) / \uc0c8\ub85c\uc6b4 \uc11c\ubc84(\uc0c8\ub85c\uc6b4 \ud3ec\ud2b8)]")," \ub77c\ub294 \uba85\uce6d\uc744 \uc0ac\uc6a9\ud558\uaca0\uc2b5\ub2c8\ub2e4."),(0,l.kt)("br",null),(0,l.kt)("ol",null,(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Target branch\uc5d0 push"),"\uac00 \ub418\uba74 ",(0,l.kt)("b",null,"Github Actions\uac00 \uc791\ub3d9"),"\ud569\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"Target branch\uc758 ",(0,l.kt)("b",null,"\uc18c\uc2a4 \ucf54\ub4dc\uac00 \ube4c\ub4dc\ub418\uc5b4\uc11c Docker Hub")," \uc5d0 \uc62c\ub77c\uac00\uac8c \ub429\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"Github Actions\uc758 self-hosted runner"),"\ub97c \ud1b5\ud574 ",(0,l.kt)("b",null,"infra \uc11c\ubc84\uc5d0\uc11c prod \uc11c\ubc84\ub85c \uc811\uadfc"),"\ud574\uc11c Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c ",(0,l.kt)("b",null,"\uc0c8\ub85c\uc6b4 \ubc84\uc804\uc758 Image\ub97c pull")," \ud574\uc635\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"\ub9cc\uc57d ",(0,l.kt)("b",null,"8080 \ud3ec\ud2b8\uc5d0 \uae30\uc874 \uc11c\ubc84\uac00 \ub744\uc6cc\uc838 \uc788\uc73c\uba74 8081 \ud3ec\ud2b8\ub97c \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c8 \ud3ec\ud2b8\ub85c \uc9c0\uc815"),"\ud574\uc8fc\uace0, \ubc18\ub300\ub85c ",(0,l.kt)("b",null,"8081 \ud3ec\ud2b8\uc5d0 \uae30\uc874 \uc11c\ubc84\uac00 \ub744\uc6cc\uc838 \uc788\uc73c\uba74 8080 \ud3ec\ud2b8\uc5d0 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c8 \ud3ec\ud2b8\ub85c \uc9c0\uc815"),"\ud574\uc90d\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"\ubbf8\ub9ac Docker Hub\uc5d0 \uc5c5\ub85c\ub4dc\ud55c ",(0,l.kt)("b",null,"Docker image\ub97c ","[image+port]","\ub77c\ub294 \ub124\uc774\ubc0d\uc73c\ub85c pull\uc744 \ud55c \ud6c4 \uc0c8\ub85c\uc6b4 \ud3ec\ud2b8\ub85c \uc11c\ubc84\ub97c \uac00\ub3d9"),"\uc2dc\ud0b5\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},"\uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \uac00\ub3d9 \ub410\ub294\uc9c0 \ud655\uc778\ud558\uae30 \uc704\ud574\uc11c ",(0,l.kt)("b",null,"\ud5ec\uc2a4 \uccb4\ud06c"),"\ub97c \uc9c4\ud589\ud569\ub2c8\ub2e4. 20\ubc88 \ub3d9\uc548 \uc11c\ubc84\uac00 \uc815\uc0c1 \ub3d9\uc791\ud558\ub294\uc9c0 Spring Actuactor\ub97c \ud1b5\ud574\uc11c \ud655\uc778\uc744 \ud569\ub2c8\ub2e4."),(0,l.kt)("li",{parentName:"ol"},(0,l.kt)("b",null,"\uc815\uc0c1 \uc791\ub3d9\uc774 \ub410\uc74c\uc744 \ud655\uc778\ud558\uba74 \ud604\uc7ac \uc778\uc2a4\ud134\uc2a4\uc5d0\ub294 2\ub300\uc758 \uc11c\ubc84"),"\uac00 \ub744\uc6cc\uc838\uc788\uace0 ",(0,l.kt)("b",null,"\uc694\uccad\uc740 \uc5ec\uc804\ud788 \uae30\uc874 \uc11c\ubc84"),"\ub85c \ub4e4\uc5b4\uac00\uac8c \ub429\ub2c8\ub2e4. \ub530\ub77c\uc11c ",(0,l.kt)("b",null,"Nginx\ub97c \ud1b5\ud574 \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub85c \uc9c0\uc815"),"\ud574\uc8fc\uace0 ",(0,l.kt)("b",null,"\uae30\uc874 \uc11c\ubc84\ub294 \ub0b4\ub824"),"\uc90d\ub2c8\ub2e4.")),(0,l.kt)("br",null),"\uc5ec\uae30\uae4c\uc9c0\uac00 \uce74\ud398\uc778 \ud300\uc758 \uc2dc\ub098\ub9ac\uc624\uc785\ub2c8\ub2e4. \uadf8\ub807\ub2e4\uba74 \ud558\ub098\uc529 \uc2a4\ud06c\ub9bd\ud2b8\ub97c \ud655\uc778\ud574\ubcf4\uaca0\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc740 \uc8fc\uc11d\uc73c\ub85c \ub2ec\uc544\ub450\uaca0\uc2b5\ub2c8\ub2e4 :)",(0,l.kt)("br",null),(0,l.kt)("br",null),(0,l.kt)("h3",{id:"backend-deployyml"},"backend-deploy.yml"),(0,l.kt)("p",null,"(Github Actions\uc5d0\uc11c \uc0ac\uc6a9)"),(0,l.kt)("pre",null,(0,l.kt)("code",{parentName:"pre",className:"language-yml"},'name: deploy\n\n# 1. prod/backend branch\uc5d0 push \uc791\uc5c5\uc774 \uc77c\uc5b4\ub098\uba74 \ud574\ub2f9 \uc791\uc5c5\uc744 \uc218\ud589\ud55c\ub2e4\non:\n push:\n branches:\n - prod/backend\n\njobs:\n docker-build:\n runs-on: ubuntu-latest\n defaults:\n run:\n working-directory: ./backend\n\n steps:\n # 2. \ub3c4\ucee4 \ud5c8\ube0c\uc5d0 \ub85c\uadf8\uc778\n - name: Log in to Docker Hub\n uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a\n with:\n username: ${{ secrets.DOCKERHUB_USERNAME }}\n password: ${{ secrets.DOCKERHUB_PASSWORD }}\n - uses: actions/checkout@v3\n\n # 3. JDK 17 \uc124\uce58 \ubc0f \ube4c\ub4dc (\ud504\ub85c\uc81d\ud2b8 Java version)\n - name: Set up JDK 17\n uses: actions/setup-java@v3\n with:\n java-version: \'17\'\n distribution: \'adopt\'\n\n - name: Gradle Caching\n uses: actions/cache@v3\n with:\n path: |\n ~/.gradle/caches\n ~/.gradle/wrapper\n key: ${{ runner.os }}-gradle-${{ hashFiles(\'**/*.gradle*\', \'**/gradle-wrapper.properties\') }}\n restore-keys: |\n ${{ runner.os }}-gradle-\n\n - name: Grant execute permission for gradlew\n run: chmod +x gradlew\n - name: Build for asciiDoc\n run: ./gradlew bootjar\n\n - name: Build with Gradle\n run: ./gradlew bootjar\n\n # 4. \uc0b0\ucd9c\ubb3c\uc744 Image\ub85c \ube4c\ub4dc \ud6c4 Docker Hub\uc5d0 Image Push\ud558\uae30\n - name: Extract metadata (tags, labels) for Docker\n id: meta\n uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7\n with:\n images: woowacarffeine/backend\n\n - name: Build and push Docker image\n uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671\n with:\n context: .\n file: ./backend/Dockerfile\n push: true\n platforms: linux/arm64\n tags: woowacarffeine/backend:latest\n labels: ${{ steps.meta.outputs.labels }}\n\n\n deploy:\n # 5. Self-hosted \uc791\ub3d9 -> infra \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c \uc791\ub3d9\ub428\n runs-on: self-hosted\n if: ${{ needs.docker-build.result == \'success\' }}\n needs: [ docker-build ]\n steps:\n\n # 6. infra \uc778\uc2a4\ud134\uc2a4\uc5d0\uc11c prod \uc778\uc2a4\ud134\uc2a4\ub85c \uc811\uadfc (\uc544\ub798\ubd80\ud130\ub294 prod \uc11c\ubc84 \ub0b4\uc5d0\uc11c \uc791\uc5c5)\n - name: Join EC2 prod server\n uses: appleboy/ssh-action@master\n env:\n JASYPT_KEY: ${{ secrets.JASYPT_KEY }}\n DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}\n DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}\n with:\n host: ${{ secrets.SERVER_HOST }}\n username: ${{ secrets.SERVER_USERNAME }}\n key: ${{ secrets.SERVER_KEY }}\n port: ${{ secrets.SERVER_PORT }}\n envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD\n\n script: |\n\n # 7. Docker Hub\uc5d0\uc11c Image\ub97c pull\ud574\uc628\ub2e4\n sudo docker pull woowacarffeine/backend:latest\n\n # 8. \ub9cc\uc57d 8080 \ud3ec\ud2b8\uac00 \ucf1c\uc838 \uc788\uc73c\uba74 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub294 8081\ub85c \uc124\uc815\n if sudo docker ps | grep ":8080"; then\n export BEFORE_PORT=8080\n export NEW_PORT=8081\n export NEW_ACTUATOR_PORT=8089\n\n # 9. \ub9cc\uc57d 8081 \ud3ec\ud2b8\uac00 \ucf1c\uc838 \uc788\uc73c\uba74 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uc758 \ud3ec\ud2b8\ub294 8080\ub85c \uc124\uc815\n else\n export BEFORE_PORT=8081\n export NEW_PORT=8080\n export NEW_ACTUATOR_PORT=8088\n fi\n\n # 10. Docker\ub85c \uc0c8\ub85c\uc6b4 \uc11c\ubc84\ub97c \ub744\uc6b4\ub2e4.\n sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \\\n -e "SPRING_PROFILE=prod" \\\n -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \\\n -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \\\n -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \\\n -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \\\n -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \\\n -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \\\n --name backend$NEW_PORT \\\n woowacarffeine/backend:latest\n\n # 11. prod \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc788\ub294 bluegreen.sh \ub97c \uc791\ub3d9\ud55c\ub2e4. (\uc774 \ub54c port \uac12\uc744 \uac19\uc774 \ub123\uc5b4\uc900\ub2e4.)\n sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT\n\n')),(0,l.kt)("br",null),(0,l.kt)("br",null),(0,l.kt)("h3",{id:"bluegreensh"},"bluegreen.sh"),(0,l.kt)("p",null,"(prod \uc778\uc2a4\ud134\uc2a4 \ub0b4\ubd80\uc5d0 \uc874\uc7ac)"),(0,l.kt)("pre",null,(0,l.kt)("code",{parentName:"pre",className:"language-shell"},'#!/bin/bash\n\n# 1. Github Actions\ub97c \ud1b5\ud574 \ub118\uaca8 \ubc1b\uc740 \ud658\uacbd\ubcc0\uc218 \uac12\nBEFORE_PORT=$1\nNEW_PORT=$2\nNEW_ACTUATOR_PORT=$3\n\necho "\uae30\uc874 \ud3ec\ud2b8 : $BEFORE_PORT"\necho "\uc0c8\ub85c\uc6b4 \ud3ec\ud2b8: $NEW_PORT"\necho "\uc0c8\ub85c\uc6b4 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"\n\n\n# 2. 20\ubc88 \ub3d9\uc548 \ud5ec\uc2a4 \uccb4\ud06c\ub97c \uc9c4\ud589\ncount=0\nfor count in {0..20}\ndo\n echo "\uc11c\ubc84 \uc0c1\ud0dc \ud655\uc778(${count}/20)";\n\n # 3. \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc791\ub3d9\ub418\ub294\uc9c0 Actuator\ub97c \ud1b5\ud574 \uac12\uc744 \ubc1b\uc544\uc634\n STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)\n\n # 4. Actuator\ub97c \ud1b5\ud574 \uc131\uacf5\uc801\uc73c\ub85c \uc11c\ubc84\uac00 \ub744\uc6cc\uc9c0\uc9c0 \uc54a\uc740 \uacbd\uc6b0\n if [ "${STATUS}" != \'{"status":"up"}\' ]\n then\n # 5. 10\ucd08\ub97c \uae30\ub2e4\ub9b0 \ud6c4 \ub2e4\uc2dc \ud5ec\uc2a4 \uccb4\ud06c\ub97c \uc9c4\ud589\ud55c\ub2e4.\n sleep 10\n continue\n else\n # 6. \ud5ec\uc2a4 \uccb4\ud06c\ub97c \ud1b5\ud574 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc131\uacf5\uc801\uc73c\ub85c \uc791\ub3d9\ub41c\ub2e4\uba74 \uba48\ucd98\ub2e4.\n break\n fi\ndone\n\n\n# 7. 20\ubc88\uc758 \ud5ec\uc2a4 \uccb4\ud06c\ub97c \ud558\ub294 \ub3d9\uc548 \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc81c\ub300\ub85c \uc791\ub3d9\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc885\ub8cc\nif [ $count -eq 20 ]\nthen\n echo "\uc0c8\ub85c\uc6b4 \uc11c\ubc84 \ubc30\ud3ec\ub97c \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4."\n exit 1\nfi\n\n\n# 8. \uc0c8\ub85c\uc6b4 \uc11c\ubc84\uac00 \uc131\uacf5\uc801\uc73c\ub85c \uc791\ub3d9\ud55c \uacbd\uc6b0\n# Nginx\ub97c \ud1b5\ud574 \ud3ec\ud2b8\ud3ec\uc6cc\ub529\uc744 \uae30\uc874 \ud3ec\ud2b8\uc5d0\uc11c \uc0c8\ub85c\uc6b4 \ud3ec\ud2b8\ub85c \ubcc0\uacbd\ud574\uc900\ub2e4.\n# \uc774 \ubd80\ubd84\uc740 .inc \ud30c\uc77c\uc744 \ud1b5\ud574 Nginx\uc5d0\uc11c \uc8fc\uc785 \ubc1b\uc544\uc11c \ud3ec\ud2b8\ub9cc \ubcc0\uacbd\ud574\ub3c4 \ub429\ub2c8\ub2e4!\nexport BACKEND_PORT=$NEW_PORT\nenvsubst \'${BACKEND_PORT}\' < backend.template > backend.conf\nsudo mv backend.conf /etc/nginx/conf.d/\nsudo nginx -s reload\n\n\n# 9. \uae30\uc874 \uc11c\ubc84\ub97c \ub0b4\ub824\uc8fc\uace0, \ub3c4\ucee4 \ub9ac\uc18c\uc2a4\ub97c \uc815\ub9ac\ud574\uc900\ub2e4\ndocker stop backend$BEFORE_PORT\nsudo docker container prune -f\n')),(0,l.kt)("p",null,"\uc774\ub807\uac8c \uce74\ud398\uc778 \ud300\uc5d0\uc11c\ub294 \ubb34\uc911\ub2e8 \ubc30\ud3ec\ub97c \ub3c4\uc785\ud560 \uc218 \uc788\uc5c8\uc2b5\ub2c8\ub2e4."),(0,l.kt)("p",null,"\uae34 \uae00 \uc77d\uc5b4\uc8fc\uc154\uc11c \uac10\uc0ac\ud569\ub2c8\ub2e4 :)"))}d.isMDXComponent=!0}}]); \ No newline at end of file diff --git a/assets/js/de6bae66.3e86cd6a.js b/assets/js/de6bae66.1d9e59b5.js similarity index 67% rename from assets/js/de6bae66.3e86cd6a.js rename to assets/js/de6bae66.1d9e59b5.js index 8fd605f1..e68d6c37 100644 --- a/assets/js/de6bae66.3e86cd6a.js +++ b/assets/js/de6bae66.1d9e59b5.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9926],{24301:a=>{a.exports=JSON.parse('{"label":"aws","permalink":"/tags/aws","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9926],{24301:a=>{a.exports=JSON.parse('{"label":"aws","permalink":"/tags/aws","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/decd4a10.0ab7320a.js b/assets/js/decd4a10.0ab7320a.js new file mode 100644 index 00000000..9ebf420b --- /dev/null +++ b/assets/js/decd4a10.0ab7320a.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5632],{68573:e=>{e.exports=JSON.parse('{"label":"ec2","permalink":"/tags/ec-2","allTagsPath":"/tags","count":4}')}}]); \ No newline at end of file diff --git a/assets/js/e4ebfe18.ff7a1881.js b/assets/js/e4ebfe18.7901e32a.js similarity index 57% rename from assets/js/e4ebfe18.ff7a1881.js rename to assets/js/e4ebfe18.7901e32a.js index 4f02c019..1ab4df43 100644 --- a/assets/js/e4ebfe18.ff7a1881.js +++ b/assets/js/e4ebfe18.7901e32a.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[843],{57954:e=>{e.exports=JSON.parse('{"permalink":"/page/3","page":3,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/2","nextPage":"/page/4","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[843],{57954:e=>{e.exports=JSON.parse('{"permalink":"/page/3","page":3,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/2","nextPage":"/page/4","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/e8622e75.a2446d95.js b/assets/js/e8622e75.a2446d95.js new file mode 100644 index 00000000..2d438c14 --- /dev/null +++ b/assets/js/e8622e75.a2446d95.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6871],{70835:a=>{a.exports=JSON.parse('{"label":"infra","permalink":"/tags/infra","allTagsPath":"/tags","count":2}')}}]); \ No newline at end of file diff --git a/assets/js/ea23132c.f4eac952.js b/assets/js/ea23132c.f4eac952.js new file mode 100644 index 00000000..80a17e05 --- /dev/null +++ b/assets/js/ea23132c.f4eac952.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6332],{63241:e=>{e.exports=JSON.parse('{"label":"cd","permalink":"/tags/cd","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file diff --git a/assets/js/eec33099.a7f79178.js b/assets/js/eec33099.a7f79178.js deleted file mode 100644 index dbd3091e..00000000 --- a/assets/js/eec33099.a7f79178.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4953],{80133:e=>{e.exports=JSON.parse('{"permalink":"/page/40","page":40,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/39","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/eec33099.ef68d3ea.js b/assets/js/eec33099.ef68d3ea.js new file mode 100644 index 00000000..baeec08e --- /dev/null +++ b/assets/js/eec33099.ef68d3ea.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[4953],{80133:e=>{e.exports=JSON.parse('{"permalink":"/page/40","page":40,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/39","nextPage":"/page/41","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/ef5b2427.ab5a4503.js b/assets/js/ef5b2427.c1000fb6.js similarity index 57% rename from assets/js/ef5b2427.ab5a4503.js rename to assets/js/ef5b2427.c1000fb6.js index f5d6adb6..c551047d 100644 --- a/assets/js/ef5b2427.ab5a4503.js +++ b/assets/js/ef5b2427.c1000fb6.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9606],{50195:e=>{e.exports=JSON.parse('{"permalink":"/page/22","page":22,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/21","nextPage":"/page/23","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[9606],{50195:e=>{e.exports=JSON.parse('{"permalink":"/page/22","page":22,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/21","nextPage":"/page/23","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/f332d221.f7691c26.js b/assets/js/f332d221.7e3f1470.js similarity index 57% rename from assets/js/f332d221.f7691c26.js rename to assets/js/f332d221.7e3f1470.js index 5b20cdec..ee649e36 100644 --- a/assets/js/f332d221.f7691c26.js +++ b/assets/js/f332d221.7e3f1470.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2717],{99371:e=>{e.exports=JSON.parse('{"permalink":"/page/10","page":10,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/9","nextPage":"/page/11","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[2717],{99371:e=>{e.exports=JSON.parse('{"permalink":"/page/10","page":10,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/9","nextPage":"/page/11","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/f3e308ad.0641fb05.js b/assets/js/f3e308ad.c30e5b09.js similarity index 57% rename from assets/js/f3e308ad.0641fb05.js rename to assets/js/f3e308ad.c30e5b09.js index 110d5ce8..6f43226d 100644 --- a/assets/js/f3e308ad.0641fb05.js +++ b/assets/js/f3e308ad.c30e5b09.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6123],{16240:e=>{e.exports=JSON.parse('{"permalink":"/page/33","page":33,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/32","nextPage":"/page/34","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6123],{16240:e=>{e.exports=JSON.parse('{"permalink":"/page/33","page":33,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/32","nextPage":"/page/34","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/f4948b2c.5b87e103.js b/assets/js/f4948b2c.6b664d6a.js similarity index 67% rename from assets/js/f4948b2c.5b87e103.js rename to assets/js/f4948b2c.6b664d6a.js index 168278f8..e537b7e8 100644 --- a/assets/js/f4948b2c.5b87e103.js +++ b/assets/js/f4948b2c.6b664d6a.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5160],{75524:e=>{e.exports=JSON.parse('{"label":"CD","permalink":"/tags/cd","allTagsPath":"/tags","count":2}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5160],{75524:e=>{e.exports=JSON.parse('{"label":"cd","permalink":"/tags/cd","allTagsPath":"/tags","count":3}')}}]); \ No newline at end of file diff --git a/assets/js/f4f49e13.c83e4097.js b/assets/js/f4f49e13.44eecddc.js similarity index 57% rename from assets/js/f4f49e13.c83e4097.js rename to assets/js/f4f49e13.44eecddc.js index cb39392b..26c7b493 100644 --- a/assets/js/f4f49e13.c83e4097.js +++ b/assets/js/f4f49e13.44eecddc.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6887],{26329:e=>{e.exports=JSON.parse('{"permalink":"/page/12","page":12,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/11","nextPage":"/page/13","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6887],{26329:e=>{e.exports=JSON.parse('{"permalink":"/page/12","page":12,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/11","nextPage":"/page/13","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/f75a8651.9a259934.js b/assets/js/f75a8651.3d05aaa1.js similarity index 57% rename from assets/js/f75a8651.9a259934.js rename to assets/js/f75a8651.3d05aaa1.js index 6927d5f3..7cc030a8 100644 --- a/assets/js/f75a8651.9a259934.js +++ b/assets/js/f75a8651.3d05aaa1.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[8882],{44633:e=>{e.exports=JSON.parse('{"permalink":"/page/8","page":8,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/7","nextPage":"/page/9","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[8882],{44633:e=>{e.exports=JSON.parse('{"permalink":"/page/8","page":8,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/7","nextPage":"/page/9","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/fbd57548.04992a42.js b/assets/js/fbd57548.b9ecf020.js similarity index 57% rename from assets/js/fbd57548.04992a42.js rename to assets/js/fbd57548.b9ecf020.js index 20da7249..d94c4123 100644 --- a/assets/js/fbd57548.04992a42.js +++ b/assets/js/fbd57548.b9ecf020.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6837],{30990:e=>{e.exports=JSON.parse('{"permalink":"/page/11","page":11,"postsPerPage":1,"totalPages":40,"totalCount":40,"previousPage":"/page/10","nextPage":"/page/12","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[6837],{30990:e=>{e.exports=JSON.parse('{"permalink":"/page/11","page":11,"postsPerPage":1,"totalPages":41,"totalCount":41,"previousPage":"/page/10","nextPage":"/page/12","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/ff4d8b69.917dc6f6.js b/assets/js/ff4d8b69.41619737.js similarity index 55% rename from assets/js/ff4d8b69.917dc6f6.js rename to assets/js/ff4d8b69.41619737.js index 6caf12e9..df067e83 100644 --- a/assets/js/ff4d8b69.917dc6f6.js +++ b/assets/js/ff4d8b69.41619737.js @@ -1 +1 @@ -"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5026],{22991:e=>{e.exports=JSON.parse('{"permalink":"/tags/ec-2","page":1,"postsPerPage":1,"totalPages":3,"totalCount":3,"nextPage":"/tags/ec-2/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file +"use strict";(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[5026],{22991:e=>{e.exports=JSON.parse('{"permalink":"/tags/ec-2","page":1,"postsPerPage":1,"totalPages":4,"totalCount":4,"nextPage":"/tags/ec-2/page/2","blogDescription":"Blog","blogTitle":"Blog"}')}}]); \ No newline at end of file diff --git a/assets/js/main.4ca3dd4c.js b/assets/js/main.4ca3dd4c.js new file mode 100644 index 00000000..647f633f --- /dev/null +++ b/assets/js/main.4ca3dd4c.js @@ -0,0 +1,2 @@ +/*! For license information please see main.4ca3dd4c.js.LICENSE.txt */ +(self.webpackChunkcar_ffeine=self.webpackChunkcar_ffeine||[]).push([[179],{723:(e,t,n)=>{"use strict";n.d(t,{Z:()=>p});var a=n(67294),r=n(87462),o=n(68356),i=n.n(o),l=n(16887);const s={"0023d7b0":[()=>n.e(5877).then(n.t.bind(n,36142,19)),"~blog/default/tags-react-page-2-c7b-list.json",36142],"002c05d5":[()=>n.e(9056).then(n.bind(n,34642)),"@site/blog/2023-09-22-visitors/index.mdx",34642],"007cdc83":[()=>n.e(2897).then(n.t.bind(n,35587,19)),"~blog/default/tags-ec-2-page-3-191.json",35587],"00931cc3":[()=>n.e(5669).then(n.t.bind(n,92291,19)),"~blog/default/page-30-25c.json",92291],"01a85c17":[()=>Promise.all([n.e(532),n.e(4013)]).then(n.bind(n,91223)),"@theme/BlogTagsListPage",91223],"0260fa5b":[()=>n.e(2041).then(n.bind(n,73252)),"@site/blog/2023-07-10-kiara-jasypt.mdx?truncated=true",73252],"029258fc":[()=>n.e(299).then(n.t.bind(n,35079,19)),"~blog/default/tags-trouble-shooting-803.json",35079],"02cc5bda":[()=>n.e(4496).then(n.t.bind(n,76642,19)),"~blog/default/tags-styled-components-40f.json",76642],"02ee3536":[()=>n.e(8684).then(n.t.bind(n,30639,19)),"~blog/default/tags-ga-4-3a1.json",30639],"034316ba":[()=>n.e(4708).then(n.t.bind(n,85426,19)),"~blog/default/tags-db-cf1.json",85426],"03f1f4ea":[()=>n.e(2955).then(n.bind(n,11824)),"@site/blog/2023-07-03-jay-infra.mdx",11824],"06a46f69":[()=>n.e(7555).then(n.t.bind(n,95621,19)),"~blog/default/tags-css-3b5.json",95621],"06f0a746":[()=>n.e(6572).then(n.t.bind(n,77548,19)),"~blog/default/tags-msw-cda.json",77548],"074793ea":[()=>n.e(3079).then(n.bind(n,58980)),"@site/blog/2023-07-15-jpa-create-select-query-when-id-is-not-null-.mdx",58980],"09d822bf":[()=>n.e(7605).then(n.t.bind(n,9027,19)),"~blog/default/tags-css-in-js-abf.json",9027],"09fbb6bd":[()=>n.e(5964).then(n.t.bind(n,41679,19)),"~blog/default/page-16-d6c.json",41679],"0abff1f0":[()=>n.e(2752).then(n.t.bind(n,65386,19)),"~blog/default/tags-use-sync-external-state-fd6.json",65386],"0b036d6e":[()=>n.e(8209).then(n.t.bind(n,90998,19)),"~blog/default/tags-error-d1a.json",90998],"0b70ffa9":[()=>n.e(3694).then(n.t.bind(n,16605,19)),"~blog/default/tags-ec-2-55f.json",16605],"0be517de":[()=>n.e(4999).then(n.bind(n,88609)),"@site/blog/2023-08-25-external-state/index.mdx?truncated=true",88609],"0c071de2":[()=>n.e(321).then(n.t.bind(n,23125,19)),"~blog/default/page-2-b45.json",23125],"0cd70852":[()=>n.e(8534).then(n.t.bind(n,10286,19)),"~blog/default/tags-use-sync-external-store-page-2-5ba.json",10286],"0d81c928":[()=>n.e(4014).then(n.t.bind(n,15745,19)),"/home/runner/work/car-ffeine.github.io/car-ffeine.github.io/.docusaurus/docusaurus-plugin-content-pages/default/plugin-route-context-module-100.json",15745],"0d852ea0":[()=>n.e(9497).then(n.t.bind(n,43458,19)),"~blog/default/tags-\ud611\uc5c5-d8f.json",43458],"0e384e19":[()=>n.e(9671).then(n.bind(n,59881)),"@site/docs/intro.md",59881],"0f313731":[()=>n.e(2064).then(n.t.bind(n,9881,19)),"~blog/default/tags-world-page-2-fe6.json",9881],"0faaa152":[()=>n.e(1704).then(n.t.bind(n,82190,19)),"~blog/default/tags-\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac-c13.json",82190],"1252d667":[()=>n.e(1566).then(n.t.bind(n,62229,19)),"~blog/default/tags-use-sync-external-store-page-2-5ba-list.json",62229],"12cbeba7":[()=>n.e(6508).then(n.t.bind(n,16134,19)),"~blog/default/page-29-e3c.json",16134],"1457c284":[()=>n.e(2160).then(n.t.bind(n,36219,19)),"~blog/default/tags-webpack-29a.json",36219],"14759c52":[()=>n.e(8450).then(n.t.bind(n,99208,19)),"~blog/default/tags-\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4-page-2-edd-list.json",99208],"14eb3368":[()=>Promise.all([n.e(532),n.e(9817)]).then(n.bind(n,34228)),"@theme/DocCategoryGeneratedIndexPage",34228],"153869a1":[()=>n.e(2772).then(n.t.bind(n,95772,19)),"~blog/default/tags-pr-page-2-461.json",95772],"163d37ca":[()=>n.e(1651).then(n.t.bind(n,5361,19)),"~blog/default/tags-action-302.json",5361],"16d0e52e":[()=>n.e(5208).then(n.t.bind(n,34442,19)),"~blog/default/tags-mysql-page-2-b03.json",34442],17896441:[()=>Promise.all([n.e(532),n.e(1098),n.e(7918)]).then(n.bind(n,15154)),"@theme/DocItem",15154],"1809876f":[()=>n.e(2950).then(n.t.bind(n,20415,19)),"~blog/default/tags-react-wrapper-12e.json",20415],"181cad37":[()=>n.e(6430).then(n.t.bind(n,21843,19)),"~blog/default/tags-\ubc30\ud3ec-a2c.json",21843],"1893cb59":[()=>n.e(286).then(n.t.bind(n,16269,19)),"~blog/default/tags-java-page-2-8c6.json",16269],"18c41134":[()=>n.e(2859).then(n.bind(n,43494)),"@site/docs/tutorial-basics/markdown-features.mdx",43494],"18de7563":[()=>n.e(8744).then(n.bind(n,8682)),"@site/blog/2023-08-25-external-state/index.mdx",8682],"18e1dbe5":[()=>n.e(5573).then(n.t.bind(n,39934,19)),"~blog/default/tags-java-17-page-2-19d.json",39934],"198f8d8a":[()=>n.e(9059).then(n.t.bind(n,17238,19)),"~blog/default/tags-java-page-3-b02-list.json",17238],"1a665c6f":[()=>n.e(454).then(n.t.bind(n,28767,19)),"~blog/default/tags-test-435-list.json",28767],"1aff6e78":[()=>n.e(7218).then(n.bind(n,12584)),"@site/blog/2023-07-31-deadlokc-trouble-shooting.mdx?truncated=true",12584],"1be78505":[()=>Promise.all([n.e(532),n.e(9514)]).then(n.bind(n,19963)),"@theme/DocPage",19963],"1c01e504":[()=>n.e(9627).then(n.t.bind(n,64522,19)),"~blog/default/tags-to-list-8c7-list.json",64522],"1cc4c623":[()=>n.e(5439).then(n.bind(n,25975)),"@site/blog/2023-07-09-msw-setup-with-webpack.mdx",25975],"1cd7fa68":[()=>n.e(3842).then(n.t.bind(n,24469,19)),"/home/runner/work/car-ffeine.github.io/car-ffeine.github.io/.docusaurus/docusaurus-plugin-content-blog/default/plugin-route-context-module-100.json",24469],"1d6afaf2":[()=>n.e(4200).then(n.bind(n,38996)),"@site/blog/2023-07-10-google-maps-api-with-car-ffeine.mdx",38996],"1df93b7f":[()=>n.e(3237).then(n.bind(n,69754)),"@site/src/pages/index.tsx",69754],"1e4232ab":[()=>n.e(8818).then(n.bind(n,6193)),"@site/docs/tutorial-basics/create-a-document.md",6193],"1ec645a9":[()=>n.e(6048).then(n.t.bind(n,60275,19)),"~blog/default/tags-\uc544\ud0a4\ud14d\ucc98-6e7-list.json",60275],"1f182e80":[()=>n.e(9583).then(n.t.bind(n,38681,19)),"~blog/default/tags-ec-2-page-2-b71-list.json",38681],"1f391b9e":[()=>Promise.all([n.e(532),n.e(1098),n.e(3085)]).then(n.bind(n,14247)),"@theme/MDXPage",14247],"203a6f4f":[()=>n.e(9996).then(n.t.bind(n,1134,19)),"~blog/default/tags-\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30-81b.json",1134],"207a8efc":[()=>n.e(1416).then(n.bind(n,92994)),"@site/blog/2023-07-31-deadlokc-trouble-shooting.mdx",92994],"20fcf238":[()=>n.e(4402).then(n.t.bind(n,82816,19)),"~blog/default/tags-google-analytics-4-548-list.json",82816],"210bacf6":[()=>n.e(3553).then(n.bind(n,62852)),"@site/blog/2023-09-22-visitors/index.mdx?truncated=true",62852],"226700de":[()=>n.e(6035).then(n.t.bind(n,41961,19)),"~blog/default/page-25-52d.json",41961],"22c62d68":[()=>n.e(1722).then(n.t.bind(n,40967,19)),"~blog/default/tags-use-sync-external-store-a2c.json",40967],"23e2728d":[()=>n.e(6573).then(n.t.bind(n,54295,19)),"~blog/default/tags-aws-page-4-b73.json",54295],"2487d3de":[()=>n.e(808).then(n.t.bind(n,99493,19)),"~blog/default/tags-subnet-21b-list.json",99493],"2514f2ba":[()=>n.e(2346).then(n.bind(n,54286)),"@site/blog/2023-08-31-love-my-team.mdx?truncated=true",54286],"258958af":[()=>n.e(8113).then(n.t.bind(n,9725,19)),"~blog/default/tags-google-maps-api-dc4.json",9725],"2620e7b9":[()=>n.e(1120).then(n.t.bind(n,8329,19)),"~blog/default/tags-git-flow-d74.json",8329],"26896e6a":[()=>n.e(5331).then(n.bind(n,57490)),"@site/blog/2023-08-06-out-of-memory-trouble-shooting/index.mdx?truncated=true",57490],"270346fa":[()=>n.e(7975).then(n.t.bind(n,89424,19)),"~blog/default/page-28-907.json",89424],"27097cf2":[()=>n.e(3252).then(n.bind(n,66561)),"@site/blog/2023-07-10-google-maps-api-with-car-ffeine.mdx?truncated=true",66561],"2728eda2":[()=>n.e(7145).then(n.t.bind(n,7581,19)),"~blog/default/tags-\ud14c\uc2a4\ud2b8-cba-list.json",7581],"274c9143":[()=>n.e(6984).then(n.t.bind(n,90058,19)),"~blog/default/tags-java-a6e.json",90058],"280572f1":[()=>n.e(324).then(n.t.bind(n,77874,19)),"~blog/default/tags-mysql-331.json",77874],"28177e2f":[()=>n.e(682).then(n.t.bind(n,28616,19)),"~blog/default/tags-dev-eff.json",28616],"2832e534":[()=>n.e(2476).then(n.t.bind(n,69870,19)),"~blog/default/page-13-99f.json",69870],28773698:[()=>n.e(9008).then(n.t.bind(n,76061,19)),"~blog/default/tags-\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4-282.json",76061],"29bf81f3":[()=>n.e(8692).then(n.t.bind(n,11516,19)),"~blog/default/tags-git-branch-e79-list.json",11516],"2a8faff0":[()=>n.e(7901).then(n.t.bind(n,1150,19)),"~blog/default/tags-test-435.json",1150],"2cec5164":[()=>n.e(721).then(n.t.bind(n,57606,19)),"~blog/default/tags-record-000.json",57606],"2d05811a":[()=>n.e(4837).then(n.bind(n,69469)),"@site/blog/2023-08-31-love-my-team.mdx",69469],"2dcd9e41":[()=>n.e(9357).then(n.t.bind(n,64685,19)),"~blog/default/tags-hello-page-2-023.json",64685],"2e044ce1":[()=>n.e(1894).then(n.t.bind(n,70116,19)),"~blog/default/tags-\uc11c\ube44\uc2a4-\uacbd\ud5d8-page-2-92c.json",70116],"2e10a69c":[()=>n.e(7581).then(n.t.bind(n,9981,19)),"~blog/default/page-38-d34.json",9981],"2e801cce":[()=>n.e(9450).then(n.t.bind(n,16029,19)),"~blog/default/archive-3ef.json",16029],"32b2299c":[()=>n.e(970).then(n.t.bind(n,5280,19)),"~blog/default/page-41-fe1.json",5280],"337c555a":[()=>n.e(5666).then(n.t.bind(n,34654,19)),"~blog/default/tags-google-maps-api-dc4-list.json",34654],"35293ec4":[()=>n.e(7697).then(n.t.bind(n,14,19)),"~blog/default/page-20-038.json",14],"3617515a":[()=>n.e(1971).then(n.t.bind(n,82436,19)),"~blog/default/tags-react-page-3-63f.json",82436],"36c3ed9f":[()=>n.e(6526).then(n.t.bind(n,87422,19)),"~blog/default/tags-pr-602.json",87422],"3766ff11":[()=>n.e(2616).then(n.t.bind(n,73434,19)),"~blog/default/tags-mysql-page-3-f25.json",73434],"37b2180d":[()=>n.e(6412).then(n.t.bind(n,40260,19)),"~blog/default/tags-\uce74\ud398\uc778-page-2-c8c.json",40260],"37b9d916":[()=>n.e(3293).then(n.t.bind(n,92031,19)),"~blog/default/tags-\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30-page-2-444-list.json",92031],"37be277e":[()=>n.e(2040).then(n.t.bind(n,27502,19)),"~blog/default/tags-trouble-shooting-page-2-804.json",27502],"37d538cb":[()=>n.e(7654).then(n.t.bind(n,74875,19)),"~blog/default/tags-infra-page-2-a42-list.json",74875],"389b50e0":[()=>n.e(8035).then(n.t.bind(n,4138,19)),"~blog/default/tags-github-page-2-8f5-list.json",4138],"38d8699e":[()=>n.e(471).then(n.t.bind(n,97481,19)),"~blog/default/page-15-208.json",97481],"38f82c99":[()=>n.e(4202).then(n.bind(n,76286)),"@site/blog/2023-10-07-carffeine-tester-1/index.mdx?truncated=true",76286],"393be207":[()=>n.e(7414).then(n.bind(n,53123)),"@site/src/pages/markdown-page.md",53123],"3ce54efd":[()=>n.e(5187).then(n.bind(n,30287)),"@site/blog/2023-08-23-about-the-map-system-used-by-carffeine/index.mdx?truncated=true",30287],"3ed04b60":[()=>n.e(7157).then(n.t.bind(n,84792,19)),"~blog/default/tags-spring-de1.json",84792],"3ee6368b":[()=>n.e(5501).then(n.t.bind(n,39147,19)),"~blog/default/tags-prod-ecb.json",39147],"3ff85ced":[()=>n.e(9039).then(n.t.bind(n,20727,19)),"~blog/default/tags-\ud53c\ub4dc\ubc31-page-2-33c.json",20727],"417cb48d":[()=>n.e(9993).then(n.t.bind(n,22278,19)),"~blog/default/tags-infra-117-list.json",22278],"41ce545f":[()=>n.e(8419).then(n.t.bind(n,73995,19)),"~blog/default/tags-hello-754.json",73995],"43bed105":[()=>n.e(5061).then(n.t.bind(n,57306,19)),"~blog/default/tags-cd-3bd.json",57306],"43ea9b4e":[()=>n.e(4234).then(n.t.bind(n,50196,19)),"~blog/default/tags-\uc11c\ubc84-193-list.json",50196],"469dc3ee":[()=>n.e(4191).then(n.t.bind(n,32001,19)),"~blog/default/tags-\uc11c\ubc84-page-2-693-list.json",32001],"483fd5d0":[()=>n.e(4957).then(n.bind(n,68940)),"@site/blog/2023-07-22-ci-cd.mdx",68940],"48a67c51":[()=>n.e(5478).then(n.t.bind(n,51835,19)),"~blog/default/tags-vpc-de0.json",51835],"494882d1":[()=>n.e(4471).then(n.t.bind(n,2098,19)),"~blog/default/page-37-cb2.json",2098],"4959fc42":[()=>n.e(240).then(n.t.bind(n,80897,19)),"~blog/default/page-14-0a2.json",80897],49859754:[()=>n.e(4468).then(n.t.bind(n,62868,19)),"~blog/default/tags-aws-page-2-25c-list.json",62868],"49f50ebd":[()=>n.e(1567).then(n.bind(n,73151)),"@site/blog/2023-07-23-oauth.mdx",73151],"4a412608":[()=>n.e(8586).then(n.t.bind(n,40454,19)),"~blog/default/tags-oom-ddf-list.json",40454],"4b1569d6":[()=>n.e(9142).then(n.bind(n,94811)),"@site/blog/2023-07-06-auto-issue-number-commit-msg.mdx",94811],"4cbec242":[()=>n.e(8060).then(n.t.bind(n,1449,19)),"~blog/default/tags-record-000-list.json",1449],"4d920b72":[()=>n.e(1955).then(n.t.bind(n,53378,19)),"~blog/default/tags-react-page-2-c7b.json",53378],"4e8d87e8":[()=>n.e(1803).then(n.t.bind(n,80051,19)),"~blog/default/tags-ga-4-3a1-list.json",80051],"4ed0d1cc":[()=>n.e(2670).then(n.t.bind(n,25284,19)),"~blog/default/tags-\uc11c\ube44\uc2a4-\uacbd\ud5d8-page-2-92c-list.json",25284],"4fbdb8ff":[()=>n.e(4695).then(n.t.bind(n,70671,19)),"~blog/default/tags-git-5fb.json",70671],"5128a070":[()=>n.e(8385).then(n.t.bind(n,62900,19)),"~blog/default/tags-github-a1f-list.json",62900],"5133b0c6":[()=>n.e(5159).then(n.t.bind(n,12132,19)),"~blog/default/tags-git-page-2-d7b.json",12132],"517acb4e":[()=>n.e(7116).then(n.bind(n,13171)),"@site/blog/2023-09-21-marker-rendering-optimization.mdx?truncated=true",13171],"52b9c8f3":[()=>n.e(8561).then(n.t.bind(n,93971,19)),"~blog/default/tags-\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30-81b-list.json",93971],"533a09ca":[()=>n.e(4607).then(n.bind(n,95802)),"@site/docs/tutorial-basics/create-a-blog-post.md",95802],"537b82b2":[()=>n.e(9727).then(n.t.bind(n,81187,19)),"~blog/default/tags-slack-1d6.json",81187],"53abb968":[()=>n.e(9858).then(n.t.bind(n,13359,19)),"~blog/default/tags-deadlock-9ff-list.json",13359],"54150be7":[()=>n.e(5088).then(n.t.bind(n,98707,19)),"~blog/default/tags-java-page-2-8c6-list.json",98707],"547d36c4":[()=>n.e(3426).then(n.t.bind(n,24917,19)),"~blog/default/tags-\ubc29\ubb38\uc790-\ubd84\uc11d-5d0-list.json",24917],"54cb095e":[()=>n.e(7009).then(n.t.bind(n,95159,19)),"~blog/default/page-26-a44.json",95159],"55347c97":[()=>n.e(211).then(n.t.bind(n,76867,19)),"~blog/default/tags-git-branch-e79.json",76867],"5924da1d":[()=>n.e(1826).then(n.t.bind(n,5961,19)),"~blog/default/tags-css-in-js-abf-list.json",5961],"5c31d10c":[()=>n.e(4337).then(n.t.bind(n,77196,19)),"~blog/default/tags-oom-ddf.json",77196],"5c868d36":[()=>n.e(5589).then(n.bind(n,90187)),"@site/docs/tutorial-basics/create-a-page.md",90187],"5cfd338e":[()=>n.e(1772).then(n.t.bind(n,45809,19)),"~blog/default/tags-jasypt-a66-list.json",45809],"5e5ae3cc":[()=>n.e(4349).then(n.t.bind(n,86575,19)),"~blog/default/tags-\uce74\ud398\uc778-page-3-704.json",86575],"5e9f5e1a":[()=>Promise.resolve().then(n.bind(n,36809)),"@generated/docusaurus.config",36809],"5f81b25c":[()=>n.e(4889).then(n.t.bind(n,29492,19)),"~blog/default/page-27-eb3.json",29492],"600a7791":[()=>n.e(1318).then(n.t.bind(n,75123,19)),"~blog/default/tags-issue-page-2-ae4.json",75123],"6093f82b":[()=>n.e(6017).then(n.t.bind(n,30708,19)),"~blog/default/page-9-361.json",30708],"6120dc83":[()=>n.e(4759).then(n.t.bind(n,59493,19)),"~blog/default/tags-pr-602-list.json",59493],"61f14ad3":[()=>n.e(4273).then(n.bind(n,99709)),"@site/blog/2023-08-06-out-of-memory-trouble-shooting/index.mdx",99709],"626b0166":[()=>n.e(3091).then(n.t.bind(n,1493,19)),"~blog/default/tags-react-wrapper-12e-list.json",1493],"635a92d5":[()=>n.e(7891).then(n.t.bind(n,72126,19)),"~blog/default/page-24-fbb.json",72126],"64868a43":[()=>n.e(1501).then(n.t.bind(n,33159,19)),"~blog/default/page-39-76c.json",33159],"660c0a9e":[()=>n.e(3997).then(n.t.bind(n,5481,19)),"~blog/default/tags-\ud53c\ub4dc\ubc31-d1e-list.json",5481],"668a56ad":[()=>n.e(6735).then(n.t.bind(n,39656,19)),"~blog/default/tags-google-maps-api-page-2-e71-list.json",39656],"6692f06b":[()=>n.e(5392).then(n.t.bind(n,80415,19)),"~blog/default/tags-world-page-2-fe6-list.json",80415],"66e6cd6b":[()=>n.e(6897).then(n.t.bind(n,63025,19)),"~blog/default/tags-commit-d51.json",63025],"67f9c5aa":[()=>n.e(9953).then(n.t.bind(n,59300,19)),"~blog/default/tags-jasypt-a66.json",59300],"6875c492":[()=>Promise.all([n.e(532),n.e(1098),n.e(2529),n.e(8610)]).then(n.bind(n,41714)),"@theme/BlogTagsPostsPage",41714],"69c28c32":[()=>n.e(1065).then(n.t.bind(n,99263,19)),"~blog/default/page-36-1da.json",99263],"6ab5bcea":[()=>n.e(6789).then(n.bind(n,55590)),"@site/blog/2023-07-03-jay-infra.mdx?truncated=true",55590],"6ba49b42":[()=>n.e(498).then(n.t.bind(n,64450,19)),"~blog/default/tags-test-page-2-d33-list.json",64450],"6c756788":[()=>n.e(2504).then(n.t.bind(n,43944,19)),"~blog/default/tags-cd-page-3-456-list.json",43944],"6d7fbd92":[()=>n.e(994).then(n.bind(n,91089)),"@site/blog/2023-07-23-why-private-ip-is-required-for-instance.mdx",91089],"6dd1c948":[()=>n.e(7064).then(n.t.bind(n,76376,19)),"~blog/default/page-34-16c.json",76376],"6efb579b":[()=>n.e(8428).then(n.bind(n,84671)),"@site/blog/2023-09-11-database-replication.mdx",84671],"6f6ec9cb":[()=>n.e(8786).then(n.bind(n,73328)),"@site/blog/2023-07-04-github_actions_pullrequest_issue.mdx?truncated=true",73328],"7085ca87":[()=>n.e(7949).then(n.t.bind(n,90437,19)),"~blog/default/tags-subnet-21b.json",90437],"70d3c5f0":[()=>n.e(9529).then(n.t.bind(n,47751,19)),"~blog/default/tags-google-maps-7b6.json",47751],71016178:[()=>n.e(1119).then(n.t.bind(n,36207,19)),"~blog/default/tags-cd-3bd-list.json",36207],"7188ba4d":[()=>n.e(9745).then(n.t.bind(n,7744,19)),"~blog/default/tags-java-17-page-2-19d-list.json",7744],"7220f6f9":[()=>n.e(6491).then(n.t.bind(n,90868,19)),"~blog/default/tags-\ubc30\ud3ec-a2c-list.json",90868],"73eafdfe":[()=>n.e(9020).then(n.t.bind(n,16490,19)),"~blog/default/tags-auto-378-list.json",16490],"743a3b39":[()=>n.e(6823).then(n.t.bind(n,33802,19)),"~blog/default/tags-spring-page-3-698-list.json",33802],"754fb852":[()=>n.e(988).then(n.t.bind(n,38242,19)),"~blog/default/page-32-596.json",38242],"75945c6d":[()=>n.e(367).then(n.t.bind(n,43436,19)),"~blog/default/tags-\uce74\ud398\uc778-page-2-c8c-list.json",43436],"75f50328":[()=>n.e(7511).then(n.t.bind(n,58695,19)),"~blog/default/tags-mysql-331-list.json",58695],"76760c9d":[()=>n.e(7640).then(n.t.bind(n,16741,19)),"~blog/default/tags-github-flow-f17.json",16741],"77310d5d":[()=>n.e(6831).then(n.t.bind(n,42521,19)),"~blog/default/tags-\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4-page-2-edd.json",42521],"7762a24e":[()=>n.e(2753).then(n.t.bind(n,55095,19)),"~blog/default/page-4-365.json",55095],"77b86cd7":[()=>n.e(2420).then(n.t.bind(n,23292,19)),"~blog/default/tags-gc-e90.json",23292],"7853a999":[()=>n.e(461).then(n.t.bind(n,78605,19)),"~blog/default/tags-action-page-2-54b.json",78605],79422113:[()=>n.e(8718).then(n.t.bind(n,52998,19)),"~blog/default/tags-msw-cda-list.json",52998],"7995b933":[()=>n.e(9840).then(n.bind(n,575)),"@site/blog/2023-08-16-how-fe-test.mdx",575],"7aafac26":[()=>n.e(2582).then(n.t.bind(n,2818,19)),"~blog/default/tags-\uc804\uc5ed\uc0c1\ud0dc-eda.json",2818],"7af1d52f":[()=>n.e(2334).then(n.t.bind(n,59565,19)),"~blog/default/page-6-d10.json",59565],"7b1db77a":[()=>n.e(2619).then(n.t.bind(n,84413,19)),"~blog/default/tags-\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30-page-2-444.json",84413],"7db1f2ec":[()=>n.e(2001).then(n.t.bind(n,35412,19)),"~blog/default/tags-github-flow-f17-list.json",35412],"7fbacf84":[()=>n.e(5797).then(n.t.bind(n,58701,19)),"~blog/default/tags-spring-de1-list.json",58701],"803fa4b0":[()=>n.e(1664).then(n.bind(n,98653)),"@site/blog/2023-10-15-carffeine-tester-2/index.mdx?truncated=true",98653],"807f61b6":[()=>n.e(2203).then(n.t.bind(n,5184,19)),"~blog/default/tags-react-df8-list.json",5184],"80960b4b":[()=>n.e(7599).then(n.t.bind(n,28386,19)),"~blog/default/page-21-7a8.json",28386],"814f3328":[()=>n.e(2535).then(n.t.bind(n,45641,19)),"~blog/default/blog-post-list-prop-default.json",45641],"81a656f3":[()=>n.e(2468).then(n.t.bind(n,47150,19)),"~blog/default/tags-react-page-3-63f-list.json",47150],"81c2fdf5":[()=>n.e(3433).then(n.bind(n,33877)),"@site/blog/2023-07-14-data-update-process-with-boxter.mdx?truncated=true",33877],"820d7f62":[()=>n.e(6919).then(n.bind(n,100)),"@site/blog/2023-07-14-trouble-shooting-with-info-window.mdx?truncated=true",100],"822bd8ab":[()=>n.e(6504).then(n.bind(n,27428)),"@site/docs/tutorial-basics/congratulations.md",27428],"852b2c90":[()=>n.e(7310).then(n.bind(n,44626)),"@site/blog/2023-06-29-hello-car-ffeine.mdx?truncated=true",44626],"8660c6f2":[()=>n.e(3760).then(n.t.bind(n,21552,19)),"~blog/default/tags-\uc11c\ube44\uc2a4-\uacbd\ud5d8-f8b-list.json",21552],"871c1e5a":[()=>n.e(5966).then(n.t.bind(n,71247,19)),"~blog/default/page-23-651.json",71247],"87506be9":[()=>n.e(2288).then(n.bind(n,33028)),"@site/blog/2023-08-16-how-fe-test.mdx?truncated=true",33028],87847907:[()=>n.e(2821).then(n.t.bind(n,2015,19)),"~blog/default/tags-google-maps-api-page-3-70f.json",2015],"88c8cf4c":[()=>n.e(8721).then(n.t.bind(n,17731,19)),"~blog/default/tags-aws-page-3-285-list.json",17731],"895a9c33":[()=>n.e(9792).then(n.t.bind(n,75293,19)),"~blog/default/tags-aws-192-list.json",75293],"899b6f7f":[()=>n.e(7346).then(n.bind(n,4105)),"@site/blog/2023-07-07-error-slack-notification.mdx?truncated=true",4105],"89e28a64":[()=>n.e(6794).then(n.t.bind(n,78449,19)),"~blog/default/tags-zero-time-888.json",78449],"8a0a9511":[()=>n.e(3560).then(n.bind(n,22269)),"@site/blog/2023-09-17-caching.mdx",22269],"8ac474ef":[()=>n.e(5721).then(n.bind(n,40263)),"@site/blog/2023-10-18-zero-time-deploy.mdx?truncated=true",40263],"8b74b8e0":[()=>n.e(1877).then(n.t.bind(n,17642,19)),"~blog/default/tags-prod-ecb-list.json",17642],"8bcbaad2":[()=>n.e(292).then(n.bind(n,7722)),"@site/blog/2023-08-15-flyway.mdx?truncated=true",7722],"8bd490e1":[()=>n.e(3999).then(n.bind(n,13369)),"@site/blog/2023-09-11-congestion_speed_up.mdx?truncated=true",13369],"8d05b77c":[()=>n.e(4149).then(n.t.bind(n,22801,19)),"~blog/default/page-5-264.json",22801],"8d7fa36c":[()=>n.e(3759).then(n.t.bind(n,13788,19)),"~blog/default/tags-\uce74\ud398\uc778-d37-list.json",13788],"8e044d98":[()=>n.e(3165).then(n.t.bind(n,8686,19)),"~blog/default/tags-\uce74\ud398\uc778-d37.json",8686],"8e21b108":[()=>n.e(1511).then(n.t.bind(n,61998,19)),"~blog/default/tags-jpa-page-2-cff-list.json",61998],"8e498bb6":[()=>n.e(1436).then(n.t.bind(n,50257,19)),"~blog/default/tags-java-page-3-b02.json",50257],"8e751dff":[()=>n.e(6205).then(n.t.bind(n,92891,19)),"~blog/default/tags-issue-page-2-ae4-list.json",92891],"8f4b370c":[()=>n.e(1483).then(n.t.bind(n,4469,19)),"~blog/default/tags-java-11-27a-list.json",4469],"9031057c":[()=>n.e(6815).then(n.bind(n,55070)),"@site/blog/2023-09-11-congestion_speed_up.mdx",55070],"9097e19c":[()=>n.e(6337).then(n.bind(n,42298)),"@site/blog/2023-08-17-given-ec2-prod-dev-sep.mdx?truncated=true",42298],"90a7e6ea":[()=>n.e(4201).then(n.t.bind(n,45504,19)),"~blog/default/tags-ci-page-2-bb4.json",45504],"9348a89e":[()=>n.e(5161).then(n.t.bind(n,88115,19)),"~blog/default/tags-zero-time-888-list.json",88115],"935f2afb":[()=>n.e(53).then(n.t.bind(n,1109,19)),"~docs/default/version-current-metadata-prop-751.json",1109],"9391e08d":[()=>n.e(7534).then(n.t.bind(n,35414,19)),"~blog/default/tags-slack-1d6-list.json",35414],"93f098ab":[()=>n.e(5963).then(n.bind(n,36270)),"@site/blog/2023-07-27-filtering-and-index.mdx?truncated=true",36270],"943a7d8e":[()=>n.e(9354).then(n.t.bind(n,95522,19)),"~blog/default/tags-ec-2-page-2-b71.json",95522],"950dc7df":[()=>n.e(8502).then(n.t.bind(n,74680,19)),"~blog/default/tags-\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac-c13-list.json",74680],"96adae60":[()=>n.e(172).then(n.t.bind(n,54217,19)),"~blog/default/page-19-21b.json",54217],"97136fd7":[()=>n.e(3354).then(n.t.bind(n,73364,19)),"~blog/default/tags-git-page-2-d7b-list.json",73364],"97788a79":[()=>n.e(3823).then(n.t.bind(n,32679,19)),"~blog/default/tags-index-a01.json",32679],"9899d8a5":[()=>n.e(5610).then(n.bind(n,32749)),"@site/blog/2023-07-09-msw-setup-with-webpack.mdx?truncated=true",32749],"992b7323":[()=>n.e(7806).then(n.t.bind(n,60873,19)),"~blog/default/tags-\uc804\uc5ed\uc0c1\ud0dc-eda-list.json",60873],"9a8c4dbd":[()=>n.e(7088).then(n.t.bind(n,15103,19)),"~blog/default/tags-ec-2-page-4-b09-list.json",15103],"9a9cf8cc":[()=>n.e(8207).then(n.t.bind(n,11999,19)),"~blog/default/tags-use-sync-external-store-a2c-list.json",11999],"9ae81301":[()=>n.e(3654).then(n.bind(n,19406)),"@site/blog/2023-07-23-oauth.mdx?truncated=true",19406],"9bc95288":[()=>n.e(6325).then(n.t.bind(n,3148,19)),"~blog/default/tags-infra-117.json",3148],"9c25eec0":[()=>n.e(1002).then(n.bind(n,52199)),"@site/blog/2023-07-10-kiara-jasypt.mdx",52199],"9cfe8fd1":[()=>n.e(7725).then(n.t.bind(n,97113,19)),"~blog/default/page-18-46d.json",97113],"9d4c58e5":[()=>n.e(9563).then(n.t.bind(n,43548,19)),"~blog/default/tags-aws-page-3-285.json",43548],"9dbc8adf":[()=>n.e(1587).then(n.t.bind(n,60782,19)),"~blog/default/tags-spring-page-2-806.json",60782],"9e4087bc":[()=>n.e(3608).then(n.bind(n,63169)),"@theme/BlogArchivePage",63169],"9ffe4f1a":[()=>n.e(4395).then(n.t.bind(n,87149,19)),"~blog/default/tags-message-0a5.json",87149],a05878b0:[()=>n.e(8787).then(n.t.bind(n,79495,19)),"~blog/default/tags-db-cf1-list.json",79495],a064989a:[()=>n.e(8461).then(n.t.bind(n,87077,19)),"~blog/default/tags-\uc544\ud0a4\ud14d\ucc98-6e7.json",87077],a09e4d68:[()=>n.e(4089).then(n.t.bind(n,4106,19)),"~blog/default/tags-jpa-page-2-cff.json",4106],a0a681be:[()=>n.e(3739).then(n.t.bind(n,67952,19)),"~blog/default/tags-\ubc29\ubb38\uc790-\ubd84\uc11d-5d0.json",67952],a0ce7679:[()=>n.e(2758).then(n.t.bind(n,37784,19)),"~blog/default/tags-vpc-de0-list.json",37784],a1c4ff9a:[()=>n.e(4877).then(n.t.bind(n,88303,19)),"~blog/default/tags-\uc11c\ube44\uc2a4-\uacbd\ud5d8-f8b.json",88303],a37176f0:[()=>n.e(4335).then(n.t.bind(n,64748,19)),"~blog/default/tags-cd-page-2-753-list.json",64748],a3d6bdf1:[()=>n.e(9417).then(n.bind(n,31251)),"@site/blog/2023-10-15-carffeine-tester-2/index.mdx",31251],a50e10e9:[()=>n.e(3367).then(n.t.bind(n,61052,19)),"~blog/default/tags-mysql-page-3-f25-list.json",61052],a5557bb9:[()=>n.e(5991).then(n.t.bind(n,93885,19)),"~blog/default/index.json",93885],a555d30b:[()=>n.e(3959).then(n.t.bind(n,23199,19)),"~blog/default/tags-\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30-b42-list.json",23199],a6aa9e1f:[()=>Promise.all([n.e(532),n.e(1098),n.e(2529),n.e(3089)]).then(n.bind(n,80046)),"@theme/BlogListPage",80046],a7b32a40:[()=>n.e(4573).then(n.bind(n,98805)),"@site/blog/2023-09-18-scheduling.mdx",98805],aa137ad6:[()=>n.e(1144).then(n.t.bind(n,4533,19)),"~blog/default/tags-oauth-8f0.json",4533],ab37b3d8:[()=>n.e(8566).then(n.t.bind(n,85420,19)),"~blog/default/tags-\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571-8f0-list.json",85420],ab705a1f:[()=>n.e(1655).then(n.t.bind(n,14651,19)),"~blog/default/tags-ec-2-page-3-191-list.json",14651],acbf6f0e:[()=>n.e(6445).then(n.t.bind(n,38651,19)),"~blog/default/tags-ci-bc0-list.json",38651],ad932f90:[()=>n.e(2262).then(n.t.bind(n,92991,19)),"~blog/default/tags-action-page-2-54b-list.json",92991],ae2f64a1:[()=>n.e(5883).then(n.bind(n,38719)),"@site/blog/2023-09-18-scheduling.mdx?truncated=true",38719],ae61c7bf:[()=>n.e(2889).then(n.t.bind(n,38983,19)),"~blog/default/tags-ci-bc0.json",38983],ae6362ae:[()=>n.e(3495).then(n.t.bind(n,67538,19)),"~blog/default/tags-google-maps-api-page-3-70f-list.json",67538],aec899bc:[()=>n.e(5308).then(n.t.bind(n,59334,19)),"~blog/default/tags-\uad6c\uae00-\uc9c0\ub3c4-d86-list.json",59334],af971a0b:[()=>n.e(6432).then(n.t.bind(n,14219,19)),"~blog/default/tags-tanstack-query-3bb.json",14219],b0e32a59:[()=>n.e(161).then(n.t.bind(n,39010,19)),"~blog/default/tags-world-231.json",39010],b18281db:[()=>n.e(3063).then(n.bind(n,52164)),"@site/blog/2023-09-22-station-api-separate.mdx?truncated=true",52164],b1bcf66a:[()=>n.e(2463).then(n.t.bind(n,16148,19)),"~blog/default/tags-hibernate-5bd.json",16148],b3597569:[()=>n.e(1386).then(n.t.bind(n,11967,19)),"~blog/default/tags-message-0a5-list.json",11967],b3706a4c:[()=>n.e(1627).then(n.t.bind(n,98704,19)),"~blog/default/tags-use-sync-external-state-fd6-list.json",98704],b3b4a184:[()=>n.e(5570).then(n.t.bind(n,12401,19)),"~blog/default/tags-google-map-14e.json",12401],b5230011:[()=>n.e(2516).then(n.t.bind(n,1035,19)),"~blog/default/tags-google-map-14e-list.json",1035],b5698685:[()=>n.e(9481).then(n.t.bind(n,61788,19)),"~blog/default/tags-login-a07-list.json",61788],b583d190:[()=>n.e(2730).then(n.t.bind(n,38061,19)),"~blog/default/tags-google-analytics-4-548.json",38061],b58cb1a9:[()=>n.e(5612).then(n.t.bind(n,57029,19)),"~blog/default/tags-trouble-shooting-803-list.json",57029],b6c52e21:[()=>n.e(4171).then(n.bind(n,31669)),"@site/blog/2023-07-05-nunu-db-optimization.mdx?truncated=true",31669],b7972c94:[()=>n.e(3146).then(n.t.bind(n,48917,19)),"~blog/default/tags-\uc11c\ubc84-193.json",48917],b7a74434:[()=>n.e(6184).then(n.bind(n,28661)),"@site/blog/2023-07-27-filtering-and-index.mdx",28661],b840888d:[()=>n.e(3851).then(n.bind(n,50043)),"@site/blog/2023-07-26-why-tanstack-query-is-good.mdx?truncated=true",50043],b88b1bad:[()=>n.e(719).then(n.bind(n,36935)),"@site/blog/2023-09-11-database-replication.mdx?truncated=true",36935],b98794bd:[()=>n.e(5670).then(n.t.bind(n,92931,19)),"~blog/default/tags-ip-089.json",92931],bacd660c:[()=>n.e(4820).then(n.bind(n,90946)),"@site/blog/2023-09-17-caching.mdx?truncated=true",90946],bae98e44:[()=>n.e(4768).then(n.t.bind(n,2001,19)),"~blog/default/tags-blue-green-b27.json",2001],bb25b787:[()=>n.e(6518).then(n.bind(n,47909)),"@site/blog/2023-07-01-nunu-gitbranch.mdx",47909],bbf87d95:[()=>n.e(4800).then(n.t.bind(n,16865,19)),"~blog/default/tags-issue-79e.json",16865],bc0a62f1:[()=>n.e(7339).then(n.t.bind(n,20424,19)),"~blog/default/tags-ip-089-list.json",20424],bcddcc8f:[()=>n.e(3667).then(n.t.bind(n,53500,19)),"~blog/default/tags-react-state-management-872.json",53500],bce37a09:[()=>n.e(3688).then(n.bind(n,37816)),"@site/blog/2023-07-23-why-private-ip-is-required-for-instance.mdx?truncated=true",37816],be4773d4:[()=>n.e(5987).then(n.t.bind(n,75895,19)),"~blog/default/tags-ci-page-2-bb4-list.json",75895],bf03d367:[()=>n.e(7056).then(n.t.bind(n,75165,19)),"~blog/default/tags-aws-192.json",75165],bfe1055c:[()=>n.e(6524).then(n.t.bind(n,79118,19)),"~blog/default/tags-mysql-page-2-b03-list.json",79118],c185dccb:[()=>n.e(6885).then(n.t.bind(n,24100,19)),"~blog/default/tags-\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4-282-list.json",24100],c29bedb9:[()=>n.e(9242).then(n.t.bind(n,44025,19)),"~blog/default/page-35-8fd.json",44025],c4c26f23:[()=>n.e(582).then(n.t.bind(n,8755,19)),"~blog/default/tags-to-list-8c7.json",8755],c4d52cca:[()=>n.e(2155).then(n.t.bind(n,94317,19)),"~blog/default/tags-commit-d51-list.json",94317],c573638f:[()=>n.e(964).then(n.t.bind(n,28866,19)),"~blog/default/tags-tags-c2b.json",28866],c5c65120:[()=>n.e(224).then(n.t.bind(n,58696,19)),"~blog/default/tags-deadlock-9ff.json",58696],c613688f:[()=>n.e(3936).then(n.bind(n,42737)),"@site/blog/2023-09-03-improved-query-performance.mdx?truncated=true",42737],c6a127bd:[()=>n.e(229).then(n.t.bind(n,49196,19)),"~blog/default/tags-hibernate-5bd-list.json",49196],c6b4d86c:[()=>n.e(1363).then(n.bind(n,15754)),"@site/blog/2023-07-14-data-update-process-with-boxter.mdx",15754],c844b82d:[()=>n.e(9326).then(n.t.bind(n,55262,19)),"~docs/default/category-docs-tutorialsidebar-category-tutorial-extras-3e4.json",55262],c8862eea:[()=>n.e(9118).then(n.bind(n,46859)),"@site/blog/2023-09-22-station-api-separate.mdx",46859],c9630fa2:[()=>n.e(5391).then(n.t.bind(n,53389,19)),"~blog/default/tags-pr-page-2-461-list.json",53389],ca4b344f:[()=>n.e(5006).then(n.t.bind(n,5426,19)),"~blog/default/tags-\ud53c\ub4dc\ubc31-d1e.json",5426],cace0f84:[()=>n.e(4358).then(n.bind(n,12044)),"@site/blog/2023-07-22-ci-cd.mdx?truncated=true",12044],cb4b66ee:[()=>n.e(5256).then(n.t.bind(n,13902,19)),"~blog/default/tags-googlemaps-react-wrapper-b40.json",13902],cc24784a:[()=>n.e(9984).then(n.t.bind(n,66282,19)),"~blog/default/tags-blue-green-b27-list.json",66282],cc909c85:[()=>n.e(1185).then(n.t.bind(n,53181,19)),"~blog/default/tags-index-a01-list.json",53181],cc9b0e25:[()=>n.e(2922).then(n.t.bind(n,65456,19)),"~blog/default/tags-java-17-24a.json",65456],ccc49370:[()=>Promise.all([n.e(532),n.e(1098),n.e(2529),n.e(6103)]).then(n.bind(n,65203)),"@theme/BlogPostPage",65203],cd9dc9ea:[()=>n.e(8875).then(n.bind(n,74313)),"@site/blog/2023-10-07-carffeine-tester-1/index.mdx",74313],cef71b63:[()=>n.e(8740).then(n.bind(n,25308)),"@site/blog/2023-07-04-github_actions_pullrequest_issue.mdx",25308],cefbed25:[()=>n.e(3796).then(n.t.bind(n,664,19)),"~blog/default/tags-login-a07.json",664],d01b1d1b:[()=>n.e(5802).then(n.t.bind(n,5526,19)),"~blog/default/tags-spring-page-2-806-list.json",5526],d097edc6:[()=>n.e(8311).then(n.t.bind(n,27044,19)),"~blog/default/tags-\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571-page-2-386-list.json",27044],d0a8fb3e:[()=>n.e(8117).then(n.bind(n,61315)),"@site/blog/2023-07-07-error-slack-notification.mdx",61315],d0e4cdf1:[()=>n.e(5465).then(n.t.bind(n,64020,19)),"~blog/default/page-7-3c3.json",64020],d0e4eea8:[()=>n.e(2595).then(n.t.bind(n,94927,19)),"~blog/default/tags-aws-page-4-b73-list.json",94927],d1cef389:[()=>n.e(9310).then(n.t.bind(n,40836,19)),"~blog/default/page-17-62c.json",40836],d26080ac:[()=>n.e(5102).then(n.bind(n,99236)),"@site/blog/2023-06-29-hello-car-ffeine.mdx",99236],d350f5d9:[()=>n.e(2098).then(n.t.bind(n,82622,19)),"~blog/default/tags-test-page-2-d33.json",82622],d3ff88aa:[()=>n.e(2289).then(n.t.bind(n,60270,19)),"~blog/default/tags-google-maps-7b6-list.json",60270],d4122def:[()=>n.e(1109).then(n.bind(n,2011)),"@site/blog/2023-09-21-marker-rendering-optimization.mdx",2011],d4497f2f:[()=>n.e(3193).then(n.t.bind(n,64256,19)),"~blog/default/tags-\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571-page-2-386.json",64256],d455ae7d:[()=>n.e(3888).then(n.t.bind(n,79735,19)),"~blog/default/tags-hello-page-2-023-list.json",79735],d50b0fbf:[()=>n.e(8979).then(n.t.bind(n,18412,19)),"~blog/default/tags-dev-eff-list.json",18412],d50fd269:[()=>n.e(100).then(n.t.bind(n,38132,19)),"~blog/default/page-31-308.json",38132],d52058f1:[()=>n.e(4170).then(n.t.bind(n,83769,19)),"/home/runner/work/car-ffeine.github.io/car-ffeine.github.io/.docusaurus/docusaurus-plugin-content-docs/default/plugin-route-context-module-100.json",83769],d5dc80ab:[()=>n.e(5983).then(n.bind(n,63644)),"@site/blog/2023-07-02-nunu-java-version.mdx",63644],d5e55b08:[()=>n.e(8758).then(n.t.bind(n,66705,19)),"~blog/default/tags-oauth-8f0-list.json",66705],d613ee27:[()=>n.e(5722).then(n.bind(n,53624)),"@site/blog/2023-07-02-nunu-java-version.mdx?truncated=true",53624],d7c95adf:[()=>n.e(7216).then(n.t.bind(n,77276,19)),"~blog/default/tags-css-3b5-list.json",77276],d9c03e5c:[()=>n.e(6447).then(n.t.bind(n,99174,19)),"~blog/default/tags-react-df8.json",99174],db0d15fb:[()=>n.e(4194).then(n.bind(n,28862)),"@site/blog/2023-10-18-zero-time-deploy.mdx",28862],dbe0b734:[()=>n.e(9377).then(n.bind(n,63879)),"@site/blog/2023-07-06-auto-issue-number-commit-msg.mdx?truncated=true",63879],dc154858:[()=>n.e(8276).then(n.t.bind(n,75953,19)),"~blog/default/tags-\ud14c\uc2a4\ud2b8-cba.json",75953],dc36452a:[()=>n.e(9438).then(n.t.bind(n,53443,19)),"~blog/default/tags-\ud611\uc5c5-d8f-list.json",53443],dce2839d:[()=>n.e(7779).then(n.bind(n,72084)),"@site/blog/2023-07-15-jpa-create-select-query-when-id-is-not-null-.mdx?truncated=true",72084],ddfb44b9:[()=>n.e(8788).then(n.t.bind(n,86490,19)),"~blog/default/tags-webpack-29a-list.json",86490],de6bae66:[()=>n.e(9926).then(n.t.bind(n,24301,19)),"~blog/default/tags-aws-page-2-25c.json",24301],decd4a10:[()=>n.e(5632).then(n.t.bind(n,68573,19)),"~blog/default/tags-ec-2-page-4-b09.json",68573],df01e1d1:[()=>n.e(6261).then(n.t.bind(n,62074,19)),"~blog/default/tags-world-231-list.json",62074],dff1c289:[()=>n.e(3792).then(n.bind(n,30089)),"@site/docs/tutorial-extras/manage-docs-versions.md",30089],e1d88fa0:[()=>n.e(7342).then(n.t.bind(n,28266,19)),"~blog/default/tags-hello-754-list.json",28266],e2f1a170:[()=>n.e(9107).then(n.t.bind(n,86277,19)),"~blog/default/tags-jpa-2dc-list.json",86277],e357b521:[()=>n.e(7412).then(n.t.bind(n,99355,19)),"~blog/default/tags-\uad6c\uae00-\uc9c0\ub3c4-d86.json",99355],e44a2883:[()=>n.e(6755).then(n.bind(n,80740)),"@site/docs/tutorial-extras/translate-your-site.md",80740],e4e8611b:[()=>n.e(9).then(n.bind(n,51670)),"@site/blog/2023-07-26-why-styled-components.mdx?truncated=true",51670],e4ebfe18:[()=>n.e(843).then(n.t.bind(n,57954,19)),"~blog/default/page-3-02e.json",57954],e5531274:[()=>n.e(8071).then(n.t.bind(n,55823,19)),"~blog/default/tags-issue-79e-list.json",55823],e613e09c:[()=>n.e(4568).then(n.t.bind(n,51744,19)),"~blog/default/tags-auto-378.json",51744],e63633a5:[()=>n.e(3907).then(n.bind(n,94180)),"@site/blog/2023-09-03-improved-query-performance.mdx",94180],e6caa061:[()=>n.e(5922).then(n.bind(n,42206)),"@site/blog/2023-07-14-server-architecture.mdx",42206],e70a8c2b:[()=>n.e(7769).then(n.t.bind(n,52499,19)),"~blog/default/tags-gitlab-flow-68b-list.json",52499],e726a561:[()=>n.e(6404).then(n.bind(n,65978)),"@site/blog/2023-07-01-nunu-gitbranch.mdx?truncated=true",65978],e7972e79:[()=>n.e(4446).then(n.bind(n,91818)),"@site/blog/2023-08-15-flyway.mdx",91818],e806107b:[()=>n.e(8351).then(n.t.bind(n,1259,19)),"~blog/default/tags-error-d1a-list.json",1259],e8622e75:[()=>n.e(6871).then(n.t.bind(n,70835,19)),"~blog/default/tags-infra-page-2-a42.json",70835],e8c68abf:[()=>n.e(7059).then(n.t.bind(n,13169,19)),"~blog/default/tags-gc-e90-list.json",13169],e947f001:[()=>n.e(6293).then(n.t.bind(n,42826,19)),"~blog/default/tags-spring-page-3-698.json",42826],e9652e75:[()=>n.e(5772).then(n.t.bind(n,47219,19)),"~blog/default/tags-filter-025-list.json",47219],ea10567b:[()=>n.e(9582).then(n.t.bind(n,55528,19)),"~blog/default/tags-branch-447.json",55528],ea23132c:[()=>n.e(6332).then(n.t.bind(n,63241,19)),"~blog/default/tags-cd-page-3-456.json",63241],ea88f2a1:[()=>n.e(6525).then(n.t.bind(n,80123,19)),"~docs/default/category-docs-tutorialsidebar-category-tutorial-basics-918.json",80123],eb7def01:[()=>n.e(5306).then(n.t.bind(n,77240,19)),"~blog/default/tags-\uce74\ud398\uc778-page-3-704-list.json",77240],ebbab0c1:[()=>n.e(5853).then(n.t.bind(n,47878,19)),"~blog/default/tags-git-5fb-list.json",47878],ee7d88ad:[()=>n.e(6173).then(n.bind(n,29561)),"@site/blog/2023-07-05-nunu-db-optimization.mdx",29561],eec33099:[()=>n.e(4953).then(n.t.bind(n,80133,19)),"~blog/default/page-40-397.json",80133],eed983e8:[()=>n.e(8053).then(n.t.bind(n,48456,19)),"~blog/default/tags-\uc11c\ubc84-page-2-693.json",48456],eef36cfd:[()=>n.e(4689).then(n.t.bind(n,91864,19)),"~blog/default/tags-filter-025.json",91864],ef5b2427:[()=>n.e(9606).then(n.t.bind(n,50195,19)),"~blog/default/page-22-f33.json",50195],f152f207:[()=>n.e(7516).then(n.t.bind(n,49188,19)),"~blog/default/tags-styled-components-40f-list.json",49188],f1568e05:[()=>n.e(5783).then(n.t.bind(n,68949,19)),"~blog/default/tags-java-11-27a.json",68949],f1a9275a:[()=>n.e(1866).then(n.t.bind(n,41149,19)),"~blog/default/tags-action-302-list.json",41149],f27c2916:[()=>n.e(2460).then(n.t.bind(n,98394,19)),"~blog/default/tags-branch-447-list.json",98394],f3277db2:[()=>n.e(9462).then(n.t.bind(n,81021,19)),"~blog/default/tags-github-page-2-8f5.json",81021],f3322a15:[()=>n.e(4344).then(n.bind(n,31644)),"@site/blog/2023-07-09-github_actions_pull_request_test.mdx",31644],f332d221:[()=>n.e(2717).then(n.t.bind(n,99371,19)),"~blog/default/page-10-857.json",99371],f37f9c20:[()=>n.e(9062).then(n.t.bind(n,91601,19)),"~blog/default/tags-trouble-shooting-page-2-804-list.json",91601],f3e308ad:[()=>n.e(6123).then(n.t.bind(n,16240,19)),"~blog/default/page-33-758.json",16240],f4738242:[()=>n.e(949).then(n.bind(n,63235)),"@site/blog/2023-08-23-about-the-map-system-used-by-carffeine/index.mdx",63235],f4948b2c:[()=>n.e(5160).then(n.t.bind(n,75524,19)),"~blog/default/tags-cd-page-2-753.json",75524],f4af9f40:[()=>n.e(998).then(n.t.bind(n,60332,19)),"~blog/default/tags-github-a1f.json",60332],f4f49e13:[()=>n.e(6887).then(n.t.bind(n,26329,19)),"~blog/default/page-12-b6a.json",26329],f51c3f5f:[()=>n.e(8659).then(n.t.bind(n,43137,19)),"~blog/default/tags-react-state-management-872-list.json",43137],f55d3e7a:[()=>n.e(4193).then(n.bind(n,78030)),"@site/docs/tutorial-basics/deploy-your-site.md",78030],f5bc9f59:[()=>n.e(8667).then(n.bind(n,68836)),"@site/blog/2023-07-26-why-styled-components.mdx",68836],f5eecd74:[()=>n.e(5650).then(n.bind(n,55943)),"@site/blog/2023-08-17-given-ec2-prod-dev-sep.mdx",55943],f75a8651:[()=>n.e(8882).then(n.t.bind(n,44633,19)),"~blog/default/page-8-8c2.json",44633],f7743d3b:[()=>n.e(6148).then(n.t.bind(n,26249,19)),"~blog/default/tags-java-17-24a-list.json",26249],f7ae9295:[()=>n.e(3025).then(n.t.bind(n,13729,19)),"~blog/default/tags-tanstack-query-3bb-list.json",13729],f9ed38fd:[()=>n.e(56).then(n.bind(n,57188)),"@site/blog/2023-07-09-github_actions_pull_request_test.mdx?truncated=true",57188],fa940ad3:[()=>n.e(5583).then(n.t.bind(n,37659,19)),"~blog/default/tags-\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30-b42.json",37659],fae4bc46:[()=>n.e(424).then(n.bind(n,29717)),"@site/blog/2023-07-26-why-tanstack-query-is-good.mdx",29717],fb4958d9:[()=>n.e(8921).then(n.t.bind(n,21664,19)),"~blog/default/tags-\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571-8f0.json",21664],fbd57548:[()=>n.e(6837).then(n.t.bind(n,30990,19)),"~blog/default/page-11-f65.json",30990],fc04ac13:[()=>n.e(1417).then(n.t.bind(n,33587,19)),"~blog/default/tags-jpa-2dc.json",33587],fc3046f6:[()=>n.e(5634).then(n.t.bind(n,6920,19)),"~blog/default/tags-\ud53c\ub4dc\ubc31-page-2-33c-list.json",6920],fc69ad3c:[()=>n.e(4361).then(n.t.bind(n,24802,19)),"~blog/default/tags-googlemaps-react-wrapper-b40-list.json",24802],fcad85e8:[()=>n.e(199).then(n.bind(n,32500)),"@site/blog/2023-07-14-server-architecture.mdx?truncated=true",32500],fd250280:[()=>n.e(5407).then(n.t.bind(n,52944,19)),"~blog/default/tags-google-maps-api-page-2-e71.json",52944],fd355a6b:[()=>n.e(9734).then(n.bind(n,19430)),"@site/blog/2023-07-14-trouble-shooting-with-info-window.mdx",19430],fdf3f179:[()=>n.e(5205).then(n.t.bind(n,94437,19)),"~blog/default/tags-git-flow-d74-list.json",94437],fe273484:[()=>n.e(8355).then(n.t.bind(n,53034,19)),"~blog/default/tags-java-a6e-list.json",53034],ff4d8b69:[()=>n.e(5026).then(n.t.bind(n,22991,19)),"~blog/default/tags-ec-2-55f-list.json",22991],ffe1d649:[()=>n.e(9467).then(n.t.bind(n,5071,19)),"~blog/default/tags-gitlab-flow-68b.json",5071]};function c(e){let{error:t,retry:n,pastDelay:r}=e;return t?a.createElement("div",{style:{textAlign:"center",color:"#fff",backgroundColor:"#fa383e",borderColor:"#fa383e",borderStyle:"solid",borderRadius:"0.25rem",borderWidth:"1px",boxSizing:"border-box",display:"block",padding:"1rem",flex:"0 0 50%",marginLeft:"25%",marginRight:"25%",marginTop:"5rem",maxWidth:"50%",width:"100%"}},a.createElement("p",null,String(t)),a.createElement("div",null,a.createElement("button",{type:"button",onClick:n},"Retry"))):r?a.createElement("div",{style:{display:"flex",justifyContent:"center",alignItems:"center",height:"100vh"}},a.createElement("svg",{id:"loader",style:{width:128,height:110,position:"absolute",top:"calc(100vh - 64%)"},viewBox:"0 0 45 45",xmlns:"http://www.w3.org/2000/svg",stroke:"#61dafb"},a.createElement("g",{fill:"none",fillRule:"evenodd",transform:"translate(1 1)",strokeWidth:"2"},a.createElement("circle",{cx:"22",cy:"22",r:"6",strokeOpacity:"0"},a.createElement("animate",{attributeName:"r",begin:"1.5s",dur:"3s",values:"6;22",calcMode:"linear",repeatCount:"indefinite"}),a.createElement("animate",{attributeName:"stroke-opacity",begin:"1.5s",dur:"3s",values:"1;0",calcMode:"linear",repeatCount:"indefinite"}),a.createElement("animate",{attributeName:"stroke-width",begin:"1.5s",dur:"3s",values:"2;0",calcMode:"linear",repeatCount:"indefinite"})),a.createElement("circle",{cx:"22",cy:"22",r:"6",strokeOpacity:"0"},a.createElement("animate",{attributeName:"r",begin:"3s",dur:"3s",values:"6;22",calcMode:"linear",repeatCount:"indefinite"}),a.createElement("animate",{attributeName:"stroke-opacity",begin:"3s",dur:"3s",values:"1;0",calcMode:"linear",repeatCount:"indefinite"}),a.createElement("animate",{attributeName:"stroke-width",begin:"3s",dur:"3s",values:"2;0",calcMode:"linear",repeatCount:"indefinite"})),a.createElement("circle",{cx:"22",cy:"22",r:"8"},a.createElement("animate",{attributeName:"r",begin:"0s",dur:"1.5s",values:"6;1;2;3;4;5;6",calcMode:"linear",repeatCount:"indefinite"}))))):null}var u=n(99670),d=n(30226);function f(e,t){if("*"===e)return i()({loading:c,loader:()=>n.e(4972).then(n.bind(n,4972)),modules:["@theme/NotFound"],webpack:()=>[4972],render(e,t){const n=e.default;return a.createElement(d.z,{value:{plugin:{name:"native",id:"default"}}},a.createElement(n,t))}});const o=l[`${e}-${t}`],f={},p=[],g=[],m=(0,u.Z)(o);return Object.entries(m).forEach((e=>{let[t,n]=e;const a=s[n];a&&(f[t]=a[0],p.push(a[1]),g.push(a[2]))})),i().Map({loading:c,loader:f,modules:p,webpack:()=>g,render(t,n){const i=JSON.parse(JSON.stringify(o));Object.entries(t).forEach((t=>{let[n,a]=t;const r=a.default;if(!r)throw new Error(`The page component at ${e} doesn't have a default export. This makes it impossible to render anything. Consider default-exporting a React component.`);"object"!=typeof r&&"function"!=typeof r||Object.keys(a).filter((e=>"default"!==e)).forEach((e=>{r[e]=a[e]}));let o=i;const l=n.split(".");l.slice(0,-1).forEach((e=>{o=o[e]})),o[l[l.length-1]]=r}));const l=i.__comp;delete i.__comp;const s=i.__context;return delete i.__context,a.createElement(d.z,{value:s},a.createElement(l,(0,r.Z)({},i,n)))}})}const p=[{path:"/1",component:f("/1","0ad"),exact:!0},{path:"/10",component:f("/10","9a7"),exact:!0},{path:"/11",component:f("/11","b88"),exact:!0},{path:"/12",component:f("/12","ec5"),exact:!0},{path:"/13",component:f("/13","d88"),exact:!0},{path:"/14",component:f("/14","a7a"),exact:!0},{path:"/15",component:f("/15","177"),exact:!0},{path:"/16",component:f("/16","147"),exact:!0},{path:"/17",component:f("/17","b76"),exact:!0},{path:"/18",component:f("/18","465"),exact:!0},{path:"/19",component:f("/19","a99"),exact:!0},{path:"/2",component:f("/2","b76"),exact:!0},{path:"/20",component:f("/20","62f"),exact:!0},{path:"/21",component:f("/21","3d7"),exact:!0},{path:"/22",component:f("/22","c05"),exact:!0},{path:"/23",component:f("/23","6df"),exact:!0},{path:"/24",component:f("/24","a44"),exact:!0},{path:"/25",component:f("/25","a1a"),exact:!0},{path:"/26",component:f("/26","109"),exact:!0},{path:"/27",component:f("/27","90c"),exact:!0},{path:"/28",component:f("/28","656"),exact:!0},{path:"/29",component:f("/29","9db"),exact:!0},{path:"/3",component:f("/3","05c"),exact:!0},{path:"/30",component:f("/30","0a1"),exact:!0},{path:"/31",component:f("/31","371"),exact:!0},{path:"/32",component:f("/32","d7b"),exact:!0},{path:"/33",component:f("/33","b89"),exact:!0},{path:"/34",component:f("/34","93e"),exact:!0},{path:"/35",component:f("/35","81d"),exact:!0},{path:"/36",component:f("/36","4f8"),exact:!0},{path:"/37",component:f("/37","a79"),exact:!0},{path:"/38",component:f("/38","099"),exact:!0},{path:"/39",component:f("/39","c17"),exact:!0},{path:"/4",component:f("/4","4d3"),exact:!0},{path:"/40",component:f("/40","8db"),exact:!0},{path:"/41",component:f("/41","c58"),exact:!0},{path:"/5",component:f("/5","f21"),exact:!0},{path:"/6",component:f("/6","f21"),exact:!0},{path:"/7",component:f("/7","112"),exact:!0},{path:"/8",component:f("/8","6fd"),exact:!0},{path:"/9",component:f("/9","ab8"),exact:!0},{path:"/archive",component:f("/archive","ac2"),exact:!0},{path:"/markdown-page",component:f("/markdown-page","0a5"),exact:!0},{path:"/page/10",component:f("/page/10","42b"),exact:!0},{path:"/page/11",component:f("/page/11","2b6"),exact:!0},{path:"/page/12",component:f("/page/12","cea"),exact:!0},{path:"/page/13",component:f("/page/13","e2c"),exact:!0},{path:"/page/14",component:f("/page/14","e19"),exact:!0},{path:"/page/15",component:f("/page/15","555"),exact:!0},{path:"/page/16",component:f("/page/16","2ac"),exact:!0},{path:"/page/17",component:f("/page/17","121"),exact:!0},{path:"/page/18",component:f("/page/18","4ed"),exact:!0},{path:"/page/19",component:f("/page/19","7f0"),exact:!0},{path:"/page/2",component:f("/page/2","34a"),exact:!0},{path:"/page/20",component:f("/page/20","9d1"),exact:!0},{path:"/page/21",component:f("/page/21","6ec"),exact:!0},{path:"/page/22",component:f("/page/22","66e"),exact:!0},{path:"/page/23",component:f("/page/23","289"),exact:!0},{path:"/page/24",component:f("/page/24","3d0"),exact:!0},{path:"/page/25",component:f("/page/25","c2b"),exact:!0},{path:"/page/26",component:f("/page/26","1e6"),exact:!0},{path:"/page/27",component:f("/page/27","222"),exact:!0},{path:"/page/28",component:f("/page/28","317"),exact:!0},{path:"/page/29",component:f("/page/29","0fc"),exact:!0},{path:"/page/3",component:f("/page/3","04c"),exact:!0},{path:"/page/30",component:f("/page/30","67d"),exact:!0},{path:"/page/31",component:f("/page/31","a34"),exact:!0},{path:"/page/32",component:f("/page/32","634"),exact:!0},{path:"/page/33",component:f("/page/33","3e4"),exact:!0},{path:"/page/34",component:f("/page/34","f3f"),exact:!0},{path:"/page/35",component:f("/page/35","064"),exact:!0},{path:"/page/36",component:f("/page/36","6ea"),exact:!0},{path:"/page/37",component:f("/page/37","dbd"),exact:!0},{path:"/page/38",component:f("/page/38","ee5"),exact:!0},{path:"/page/39",component:f("/page/39","49c"),exact:!0},{path:"/page/4",component:f("/page/4","dce"),exact:!0},{path:"/page/40",component:f("/page/40","780"),exact:!0},{path:"/page/41",component:f("/page/41","d1a"),exact:!0},{path:"/page/5",component:f("/page/5","a57"),exact:!0},{path:"/page/6",component:f("/page/6","689"),exact:!0},{path:"/page/7",component:f("/page/7","983"),exact:!0},{path:"/page/8",component:f("/page/8","a7a"),exact:!0},{path:"/page/9",component:f("/page/9","785"),exact:!0},{path:"/tags",component:f("/tags","0ad"),exact:!0},{path:"/tags/action",component:f("/tags/action","d3f"),exact:!0},{path:"/tags/action/page/2",component:f("/tags/action/page/2","f18"),exact:!0},{path:"/tags/auto",component:f("/tags/auto","e63"),exact:!0},{path:"/tags/aws",component:f("/tags/aws","e3d"),exact:!0},{path:"/tags/aws/page/2",component:f("/tags/aws/page/2","f62"),exact:!0},{path:"/tags/aws/page/3",component:f("/tags/aws/page/3","102"),exact:!0},{path:"/tags/aws/page/4",component:f("/tags/aws/page/4","9da"),exact:!0},{path:"/tags/blue-green",component:f("/tags/blue-green","cfc"),exact:!0},{path:"/tags/branch",component:f("/tags/branch","039"),exact:!0},{path:"/tags/cd",component:f("/tags/cd","6cc"),exact:!0},{path:"/tags/cd/page/2",component:f("/tags/cd/page/2","f12"),exact:!0},{path:"/tags/cd/page/3",component:f("/tags/cd/page/3","1b6"),exact:!0},{path:"/tags/ci",component:f("/tags/ci","466"),exact:!0},{path:"/tags/ci/page/2",component:f("/tags/ci/page/2","a62"),exact:!0},{path:"/tags/commit",component:f("/tags/commit","6a0"),exact:!0},{path:"/tags/css",component:f("/tags/css","a72"),exact:!0},{path:"/tags/css-in-js",component:f("/tags/css-in-js","c72"),exact:!0},{path:"/tags/db",component:f("/tags/db","462"),exact:!0},{path:"/tags/deadlock",component:f("/tags/deadlock","9b7"),exact:!0},{path:"/tags/dev",component:f("/tags/dev","910"),exact:!0},{path:"/tags/ec-2",component:f("/tags/ec-2","63e"),exact:!0},{path:"/tags/ec-2/page/2",component:f("/tags/ec-2/page/2","7ff"),exact:!0},{path:"/tags/ec-2/page/3",component:f("/tags/ec-2/page/3","f0a"),exact:!0},{path:"/tags/ec-2/page/4",component:f("/tags/ec-2/page/4","135"),exact:!0},{path:"/tags/error",component:f("/tags/error","758"),exact:!0},{path:"/tags/filter",component:f("/tags/filter","d18"),exact:!0},{path:"/tags/ga-4",component:f("/tags/ga-4","8c7"),exact:!0},{path:"/tags/gc",component:f("/tags/gc","9dd"),exact:!0},{path:"/tags/git",component:f("/tags/git","c9b"),exact:!0},{path:"/tags/git-branch",component:f("/tags/git-branch","3e3"),exact:!0},{path:"/tags/git-flow",component:f("/tags/git-flow","670"),exact:!0},{path:"/tags/git/page/2",component:f("/tags/git/page/2","04e"),exact:!0},{path:"/tags/github",component:f("/tags/github","ad4"),exact:!0},{path:"/tags/github-flow",component:f("/tags/github-flow","770"),exact:!0},{path:"/tags/github/page/2",component:f("/tags/github/page/2","06d"),exact:!0},{path:"/tags/gitlab-flow",component:f("/tags/gitlab-flow","861"),exact:!0},{path:"/tags/google-analytics-4",component:f("/tags/google-analytics-4","225"),exact:!0},{path:"/tags/google-map",component:f("/tags/google-map","797"),exact:!0},{path:"/tags/google-maps",component:f("/tags/google-maps","857"),exact:!0},{path:"/tags/google-maps-api",component:f("/tags/google-maps-api","01a"),exact:!0},{path:"/tags/google-maps-api/page/2",component:f("/tags/google-maps-api/page/2","4c9"),exact:!0},{path:"/tags/google-maps-api/page/3",component:f("/tags/google-maps-api/page/3","eb3"),exact:!0},{path:"/tags/googlemaps-react-wrapper",component:f("/tags/googlemaps-react-wrapper","176"),exact:!0},{path:"/tags/hello",component:f("/tags/hello","44c"),exact:!0},{path:"/tags/hello/page/2",component:f("/tags/hello/page/2","42e"),exact:!0},{path:"/tags/hibernate",component:f("/tags/hibernate","dca"),exact:!0},{path:"/tags/index",component:f("/tags/index","fcb"),exact:!0},{path:"/tags/infra",component:f("/tags/infra","949"),exact:!0},{path:"/tags/infra/page/2",component:f("/tags/infra/page/2","227"),exact:!0},{path:"/tags/ip",component:f("/tags/ip","93f"),exact:!0},{path:"/tags/issue",component:f("/tags/issue","369"),exact:!0},{path:"/tags/issue/page/2",component:f("/tags/issue/page/2","87f"),exact:!0},{path:"/tags/jasypt",component:f("/tags/jasypt","b5d"),exact:!0},{path:"/tags/java",component:f("/tags/java","273"),exact:!0},{path:"/tags/java-11",component:f("/tags/java-11","f9b"),exact:!0},{path:"/tags/java-17",component:f("/tags/java-17","362"),exact:!0},{path:"/tags/java-17/page/2",component:f("/tags/java-17/page/2","ea5"),exact:!0},{path:"/tags/java/page/2",component:f("/tags/java/page/2","a63"),exact:!0},{path:"/tags/java/page/3",component:f("/tags/java/page/3","980"),exact:!0},{path:"/tags/jpa",component:f("/tags/jpa","fe0"),exact:!0},{path:"/tags/jpa/page/2",component:f("/tags/jpa/page/2","75c"),exact:!0},{path:"/tags/login",component:f("/tags/login","850"),exact:!0},{path:"/tags/message",component:f("/tags/message","828"),exact:!0},{path:"/tags/msw",component:f("/tags/msw","9b4"),exact:!0},{path:"/tags/mysql",component:f("/tags/mysql","01b"),exact:!0},{path:"/tags/mysql/page/2",component:f("/tags/mysql/page/2","1ce"),exact:!0},{path:"/tags/mysql/page/3",component:f("/tags/mysql/page/3","d28"),exact:!0},{path:"/tags/oauth",component:f("/tags/oauth","a65"),exact:!0},{path:"/tags/oom",component:f("/tags/oom","cd1"),exact:!0},{path:"/tags/pr",component:f("/tags/pr","0bf"),exact:!0},{path:"/tags/pr/page/2",component:f("/tags/pr/page/2","126"),exact:!0},{path:"/tags/prod",component:f("/tags/prod","3a9"),exact:!0},{path:"/tags/react",component:f("/tags/react","1fd"),exact:!0},{path:"/tags/react-state-management",component:f("/tags/react-state-management","5db"),exact:!0},{path:"/tags/react-wrapper",component:f("/tags/react-wrapper","c7c"),exact:!0},{path:"/tags/react/page/2",component:f("/tags/react/page/2","54f"),exact:!0},{path:"/tags/react/page/3",component:f("/tags/react/page/3","282"),exact:!0},{path:"/tags/record",component:f("/tags/record","2da"),exact:!0},{path:"/tags/slack",component:f("/tags/slack","baf"),exact:!0},{path:"/tags/spring",component:f("/tags/spring","826"),exact:!0},{path:"/tags/spring/page/2",component:f("/tags/spring/page/2","907"),exact:!0},{path:"/tags/spring/page/3",component:f("/tags/spring/page/3","6d0"),exact:!0},{path:"/tags/styled-components",component:f("/tags/styled-components","127"),exact:!0},{path:"/tags/subnet",component:f("/tags/subnet","e58"),exact:!0},{path:"/tags/tanstack-query",component:f("/tags/tanstack-query","62c"),exact:!0},{path:"/tags/test",component:f("/tags/test","132"),exact:!0},{path:"/tags/test/page/2",component:f("/tags/test/page/2","0b3"),exact:!0},{path:"/tags/to-list",component:f("/tags/to-list","582"),exact:!0},{path:"/tags/trouble-shooting",component:f("/tags/trouble-shooting","82b"),exact:!0},{path:"/tags/trouble-shooting/page/2",component:f("/tags/trouble-shooting/page/2","7e4"),exact:!0},{path:"/tags/use-sync-external-state",component:f("/tags/use-sync-external-state","de3"),exact:!0},{path:"/tags/use-sync-external-store",component:f("/tags/use-sync-external-store","f6e"),exact:!0},{path:"/tags/use-sync-external-store/page/2",component:f("/tags/use-sync-external-store/page/2","671"),exact:!0},{path:"/tags/vpc",component:f("/tags/vpc","3e4"),exact:!0},{path:"/tags/webpack",component:f("/tags/webpack","1e6"),exact:!0},{path:"/tags/world",component:f("/tags/world","7fc"),exact:!0},{path:"/tags/world/page/2",component:f("/tags/world/page/2","b50"),exact:!0},{path:"/tags/zero-time",component:f("/tags/zero-time","164"),exact:!0},{path:"/tags/\uad6c\uae00-\uc9c0\ub3c4",component:f("/tags/\uad6c\uae00-\uc9c0\ub3c4","2e3"),exact:!0},{path:"/tags/\ubc29\ubb38\uc790-\ubd84\uc11d",component:f("/tags/\ubc29\ubb38\uc790-\ubd84\uc11d","014"),exact:!0},{path:"/tags/\ubc30\ud3ec",component:f("/tags/\ubc30\ud3ec","0fc"),exact:!0},{path:"/tags/\uc11c\ubc84",component:f("/tags/\uc11c\ubc84","249"),exact:!0},{path:"/tags/\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30",component:f("/tags/\uc11c\ubc84-\ubd80\ud558-\uc904\uc774\uae30","39b"),exact:!0},{path:"/tags/\uc11c\ubc84/page/2",component:f("/tags/\uc11c\ubc84/page/2","ff7"),exact:!0},{path:"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8",component:f("/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8","cd5"),exact:!0},{path:"/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8/page/2",component:f("/tags/\uc11c\ube44\uc2a4-\uacbd\ud5d8/page/2","f72"),exact:!0},{path:"/tags/\uc544\ud0a4\ud14d\ucc98",component:f("/tags/\uc544\ud0a4\ud14d\ucc98","dd4"),exact:!0},{path:"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4",component:f("/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4","c04"),exact:!0},{path:"/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4/page/2",component:f("/tags/\uc6b0\uc544\ud55c\ud14c\ud06c\ucf54\uc2a4/page/2","dae"),exact:!0},{path:"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30",component:f("/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30","50f"),exact:!0},{path:"/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30/page/2",component:f("/tags/\uc804\uae30\ucc28-\uc0ac\uc6a9\uae30/page/2","e21"),exact:!0},{path:"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571",component:f("/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571","727"),exact:!0},{path:"/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571/page/2",component:f("/tags/\uc804\uae30\ucc28-\ucda9\uc804\uc18c-\uc571/page/2","202"),exact:!0},{path:"/tags/\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac",component:f("/tags/\uc804\uc5ed-\uc0c1\ud0dc-\uad00\ub9ac","964"),exact:!0},{path:"/tags/\uc804\uc5ed\uc0c1\ud0dc",component:f("/tags/\uc804\uc5ed\uc0c1\ud0dc","774"),exact:!0},{path:"/tags/\uce74\ud398\uc778",component:f("/tags/\uce74\ud398\uc778","2ce"),exact:!0},{path:"/tags/\uce74\ud398\uc778/page/2",component:f("/tags/\uce74\ud398\uc778/page/2","f45"),exact:!0},{path:"/tags/\uce74\ud398\uc778/page/3",component:f("/tags/\uce74\ud398\uc778/page/3","ecf"),exact:!0},{path:"/tags/\ud14c\uc2a4\ud2b8",component:f("/tags/\ud14c\uc2a4\ud2b8","1a9"),exact:!0},{path:"/tags/\ud53c\ub4dc\ubc31",component:f("/tags/\ud53c\ub4dc\ubc31","fc0"),exact:!0},{path:"/tags/\ud53c\ub4dc\ubc31/page/2",component:f("/tags/\ud53c\ub4dc\ubc31/page/2","60b"),exact:!0},{path:"/tags/\ud611\uc5c5",component:f("/tags/\ud611\uc5c5","731"),exact:!0},{path:"/docs",component:f("/docs","2e4"),routes:[{path:"/docs/category/tutorial---basics",component:f("/docs/category/tutorial---basics","d44"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/category/tutorial---extras",component:f("/docs/category/tutorial---extras","f09"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/intro",component:f("/docs/intro","aed"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-basics/congratulations",component:f("/docs/tutorial-basics/congratulations","793"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-basics/create-a-blog-post",component:f("/docs/tutorial-basics/create-a-blog-post","68e"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-basics/create-a-document",component:f("/docs/tutorial-basics/create-a-document","c2d"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-basics/create-a-page",component:f("/docs/tutorial-basics/create-a-page","f44"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-basics/deploy-your-site",component:f("/docs/tutorial-basics/deploy-your-site","e46"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-basics/markdown-features",component:f("/docs/tutorial-basics/markdown-features","4b7"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-extras/manage-docs-versions",component:f("/docs/tutorial-extras/manage-docs-versions","fdd"),exact:!0,sidebar:"tutorialSidebar"},{path:"/docs/tutorial-extras/translate-your-site",component:f("/docs/tutorial-extras/translate-your-site","2d7"),exact:!0,sidebar:"tutorialSidebar"}]},{path:"/",component:f("/","06d"),exact:!0},{path:"/",component:f("/","48a"),exact:!0},{path:"*",component:f("*")}]},98934:(e,t,n)=>{"use strict";n.d(t,{_:()=>r,t:()=>o});var a=n(67294);const r=a.createContext(!1);function o(e){let{children:t}=e;const[n,o]=(0,a.useState)(!1);return(0,a.useEffect)((()=>{o(!0)}),[]),a.createElement(r.Provider,{value:n},t)}},49383:(e,t,n)=>{"use strict";var a=n(67294),r=n(73935),o=n(73727),i=n(70405),l=n(10412);const s=[n(32497),n(3310),n(18320),n(52295)];var c=n(723),u=n(16550),d=n(18790);function f(e){let{children:t}=e;return a.createElement(a.Fragment,null,t)}var p=n(87462),g=n(35742),m=n(52263),b=n(44996),h=n(86668),v=n(1944),y=n(94711),_=n(19727),w=n(43320),x=n(90197);function k(){const{i18n:{defaultLocale:e,localeConfigs:t}}=(0,m.Z)(),n=(0,y.l)();return a.createElement(g.Z,null,Object.entries(t).map((e=>{let[t,{htmlLang:r}]=e;return a.createElement("link",{key:t,rel:"alternate",href:n.createUrl({locale:t,fullyQualified:!0}),hrefLang:r})})),a.createElement("link",{rel:"alternate",href:n.createUrl({locale:e,fullyQualified:!0}),hrefLang:"x-default"}))}function E(e){let{permalink:t}=e;const{siteConfig:{url:n}}=(0,m.Z)(),r=function(){const{siteConfig:{url:e}}=(0,m.Z)(),{pathname:t}=(0,u.TH)();return e+(0,b.Z)(t)}(),o=t?`${n}${t}`:r;return a.createElement(g.Z,null,a.createElement("meta",{property:"og:url",content:o}),a.createElement("link",{rel:"canonical",href:o}))}function S(){const{i18n:{currentLocale:e}}=(0,m.Z)(),{metadata:t,image:n}=(0,h.L)();return a.createElement(a.Fragment,null,a.createElement(g.Z,null,a.createElement("meta",{name:"twitter:card",content:"summary_large_image"}),a.createElement("body",{className:_.h})),n&&a.createElement(v.d,{image:n}),a.createElement(E,null),a.createElement(k,null),a.createElement(x.Z,{tag:w.HX,locale:e}),a.createElement(g.Z,null,t.map(((e,t)=>a.createElement("meta",(0,p.Z)({key:t},e))))))}const C=new Map;function T(e){if(C.has(e.pathname))return{...e,pathname:C.get(e.pathname)};if((0,d.f)(c.Z,e.pathname).some((e=>{let{route:t}=e;return!0===t.exact})))return C.set(e.pathname,e.pathname),e;const t=e.pathname.trim().replace(/(?:\/index)?\.html$/,"")||"/";return C.set(e.pathname,t),{...e,pathname:t}}var L=n(98934),A=n(58940);function N(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),a=1;a{const a=t.default?.[e]??t[e];return a?.(...n)}));return()=>r.forEach((e=>e?.()))}const j=function(e){let{children:t,location:n,previousLocation:r}=e;return(0,a.useLayoutEffect)((()=>{r!==n&&(!function(e){let{location:t,previousLocation:n}=e;if(!n)return;const a=t.pathname===n.pathname,r=t.hash===n.hash,o=t.search===n.search;if(a&&r&&!o)return;const{hash:i}=t;if(i){const e=decodeURIComponent(i.substring(1)),t=document.getElementById(e);t?.scrollIntoView()}else window.scrollTo(0,0)}({location:n,previousLocation:r}),N("onRouteDidUpdate",{previousLocation:r,location:n}))}),[r,n]),t};function O(e){const t=Array.from(new Set([e,decodeURI(e)])).map((e=>(0,d.f)(c.Z,e))).flat();return Promise.all(t.map((e=>e.route.component.preload?.())))}class P extends a.Component{previousLocation;routeUpdateCleanupCb;constructor(e){super(e),this.previousLocation=null,this.routeUpdateCleanupCb=l.Z.canUseDOM?N("onRouteUpdate",{previousLocation:null,location:this.props.location}):()=>{},this.state={nextRouteHasLoaded:!0}}shouldComponentUpdate(e,t){if(e.location===this.props.location)return t.nextRouteHasLoaded;const n=e.location;return this.previousLocation=this.props.location,this.setState({nextRouteHasLoaded:!1}),this.routeUpdateCleanupCb=N("onRouteUpdate",{previousLocation:this.previousLocation,location:n}),O(n.pathname).then((()=>{this.routeUpdateCleanupCb(),this.setState({nextRouteHasLoaded:!0})})).catch((e=>{console.warn(e),window.location.reload()})),!1}render(){const{children:e,location:t}=this.props;return a.createElement(j,{previousLocation:this.previousLocation,location:t},a.createElement(u.AW,{location:t,render:()=>e}))}}const M=P,I="__docusaurus-base-url-issue-banner-container",R="__docusaurus-base-url-issue-banner",D="__docusaurus-base-url-issue-banner-suggestion-container",F="__DOCUSAURUS_INSERT_BASEURL_BANNER";function B(e){return`\nwindow['${F}'] = true;\n\ndocument.addEventListener('DOMContentLoaded', maybeInsertBanner);\n\nfunction maybeInsertBanner() {\n var shouldInsert = window['${F}'];\n shouldInsert && insertBanner();\n}\n\nfunction insertBanner() {\n var bannerContainer = document.getElementById('${I}');\n if (!bannerContainer) {\n return;\n }\n var bannerHtml = ${JSON.stringify(function(e){return`\n
    \n

    Your Docusaurus site did not load properly.

    \n

    A very common reason is a wrong site baseUrl configuration.

    \n

    Current configured baseUrl = ${e} ${"/"===e?" (default value)":""}

    \n

    We suggest trying baseUrl =

    \n
    \n`}(e)).replace(/{window[F]=!1}),[]),a.createElement(a.Fragment,null,!l.Z.canUseDOM&&a.createElement(g.Z,null,a.createElement("script",null,B(e))),a.createElement("div",{id:I}))}function z(){const{siteConfig:{baseUrl:e,baseUrlIssueBanner:t}}=(0,m.Z)(),{pathname:n}=(0,u.TH)();return t&&n===e?a.createElement($,null):null}function U(){const{siteConfig:{favicon:e,title:t,noIndex:n},i18n:{currentLocale:r,localeConfigs:o}}=(0,m.Z)(),i=(0,b.Z)(e),{htmlLang:l,direction:s}=o[r];return a.createElement(g.Z,null,a.createElement("html",{lang:l,dir:s}),a.createElement("title",null,t),a.createElement("meta",{property:"og:title",content:t}),a.createElement("meta",{name:"viewport",content:"width=device-width, initial-scale=1.0"}),n&&a.createElement("meta",{name:"robots",content:"noindex, nofollow"}),e&&a.createElement("link",{rel:"icon",href:i}))}var Z=n(44763);function H(){const e=(0,d.H)(c.Z),t=(0,u.TH)();return a.createElement(Z.Z,null,a.createElement(A.M,null,a.createElement(L.t,null,a.createElement(f,null,a.createElement(U,null),a.createElement(S,null),a.createElement(z,null),a.createElement(M,{location:T(t)},e)))))}var V=n(16887);const W=function(e){try{return document.createElement("link").relList.supports(e)}catch{return!1}}("prefetch")?function(e){return new Promise(((t,n)=>{if("undefined"==typeof document)return void n();const a=document.createElement("link");a.setAttribute("rel","prefetch"),a.setAttribute("href",e),a.onload=()=>t(),a.onerror=()=>n();const r=document.getElementsByTagName("head")[0]??document.getElementsByName("script")[0]?.parentNode;r?.appendChild(a)}))}:function(e){return new Promise(((t,n)=>{const a=new XMLHttpRequest;a.open("GET",e,!0),a.withCredentials=!0,a.onload=()=>{200===a.status?t():n()},a.send(null)}))};var q=n(99670);const G=new Set,Y=new Set,K=()=>navigator.connection?.effectiveType.includes("2g")||navigator.connection?.saveData,X={prefetch(e){if(!(e=>!K()&&!Y.has(e)&&!G.has(e))(e))return!1;G.add(e);const t=(0,d.f)(c.Z,e).flatMap((e=>{return t=e.route.path,Object.entries(V).filter((e=>{let[n]=e;return n.replace(/-[^-]+$/,"")===t})).flatMap((e=>{let[,t]=e;return Object.values((0,q.Z)(t))}));var t}));return Promise.all(t.map((e=>{const t=n.gca(e);return t&&!t.includes("undefined")?W(t).catch((()=>{})):Promise.resolve()})))},preload:e=>!!(e=>!K()&&!Y.has(e))(e)&&(Y.add(e),O(e))},Q=Object.freeze(X);if(l.Z.canUseDOM){window.docusaurus=Q;const e=r.hydrate;O(window.location.pathname).then((()=>{e(a.createElement(i.B6,null,a.createElement(o.VK,null,a.createElement(H,null))),document.getElementById("__docusaurus"))}))}},58940:(e,t,n)=>{"use strict";n.d(t,{_:()=>u,M:()=>d});var a=n(67294),r=n(36809);const o=JSON.parse('{"docusaurus-plugin-content-docs":{"default":{"path":"/docs","versions":[{"name":"current","label":"Next","isLast":true,"path":"/docs","mainDocId":"intro","docs":[{"id":"intro","path":"/docs/intro","sidebar":"tutorialSidebar"},{"id":"tutorial-basics/congratulations","path":"/docs/tutorial-basics/congratulations","sidebar":"tutorialSidebar"},{"id":"tutorial-basics/create-a-blog-post","path":"/docs/tutorial-basics/create-a-blog-post","sidebar":"tutorialSidebar"},{"id":"tutorial-basics/create-a-document","path":"/docs/tutorial-basics/create-a-document","sidebar":"tutorialSidebar"},{"id":"tutorial-basics/create-a-page","path":"/docs/tutorial-basics/create-a-page","sidebar":"tutorialSidebar"},{"id":"tutorial-basics/deploy-your-site","path":"/docs/tutorial-basics/deploy-your-site","sidebar":"tutorialSidebar"},{"id":"tutorial-basics/markdown-features","path":"/docs/tutorial-basics/markdown-features","sidebar":"tutorialSidebar"},{"id":"tutorial-extras/manage-docs-versions","path":"/docs/tutorial-extras/manage-docs-versions","sidebar":"tutorialSidebar"},{"id":"tutorial-extras/translate-your-site","path":"/docs/tutorial-extras/translate-your-site","sidebar":"tutorialSidebar"},{"id":"/category/tutorial---basics","path":"/docs/category/tutorial---basics","sidebar":"tutorialSidebar"},{"id":"/category/tutorial---extras","path":"/docs/category/tutorial---extras","sidebar":"tutorialSidebar"}],"draftIds":[],"sidebars":{"tutorialSidebar":{"link":{"path":"/docs/intro","label":"intro"}}}}],"breadcrumbs":true}}}'),i=JSON.parse('{"defaultLocale":"ko","locales":["ko"],"path":"i18n","currentLocale":"ko","localeConfigs":{"ko":{"label":"\ud55c\uad6d\uc5b4","direction":"ltr","htmlLang":"ko","calendar":"gregory","path":"ko"}}}');var l=n(57529);const s=JSON.parse('{"docusaurusVersion":"2.4.1","siteVersion":"0.0.0","pluginVersions":{"docusaurus-plugin-content-docs":{"type":"package","name":"@docusaurus/plugin-content-docs","version":"2.4.1"},"docusaurus-plugin-content-blog":{"type":"package","name":"@docusaurus/plugin-content-blog","version":"2.4.1"},"docusaurus-plugin-content-pages":{"type":"package","name":"@docusaurus/plugin-content-pages","version":"2.4.1"},"docusaurus-plugin-sitemap":{"type":"package","name":"@docusaurus/plugin-sitemap","version":"2.4.1"},"docusaurus-theme-classic":{"type":"package","name":"@docusaurus/theme-classic","version":"2.4.1"},"docusaurus-theme-mermaid":{"type":"package","name":"@docusaurus/theme-mermaid","version":"2.4.1"}}}'),c={siteConfig:r.default,siteMetadata:s,globalData:o,i18n:i,codeTranslations:l},u=a.createContext(c);function d(e){let{children:t}=e;return a.createElement(u.Provider,{value:c},t)}},44763:(e,t,n)=>{"use strict";n.d(t,{Z:()=>f});var a=n(67294),r=n(10412),o=n(35742),i=n(18780),l=n(71155);function s(e){let{error:t,tryAgain:n}=e;return a.createElement("div",{style:{display:"flex",flexDirection:"column",justifyContent:"center",alignItems:"flex-start",minHeight:"100vh",width:"100%",maxWidth:"80ch",fontSize:"20px",margin:"0 auto",padding:"1rem"}},a.createElement("h1",{style:{fontSize:"3rem"}},"This page crashed"),a.createElement("button",{type:"button",onClick:n,style:{margin:"1rem 0",fontSize:"2rem",cursor:"pointer",borderRadius:20,padding:"1rem"}},"Try again"),a.createElement(c,{error:t}))}function c(e){let{error:t}=e;const n=(0,i.getErrorCausalChain)(t).map((e=>e.message)).join("\n\nCause:\n");return a.createElement("p",{style:{whiteSpace:"pre-wrap"}},n)}function u(e){let{error:t,tryAgain:n}=e;return a.createElement(f,{fallback:()=>a.createElement(s,{error:t,tryAgain:n})},a.createElement(o.Z,null,a.createElement("title",null,"Page Error")),a.createElement(l.Z,null,a.createElement(s,{error:t,tryAgain:n})))}const d=e=>a.createElement(u,e);class f extends a.Component{constructor(e){super(e),this.state={error:null}}componentDidCatch(e){r.Z.canUseDOM&&this.setState({error:e})}render(){const{children:e}=this.props,{error:t}=this.state;if(t){const e={error:t,tryAgain:()=>this.setState({error:null})};return(this.props.fallback??d)(e)}return e??null}}},10412:(e,t,n)=>{"use strict";n.d(t,{Z:()=>r});const a="undefined"!=typeof window&&"document"in window&&"createElement"in window.document,r={canUseDOM:a,canUseEventListeners:a&&("addEventListener"in window||"attachEvent"in window),canUseIntersectionObserver:a&&"IntersectionObserver"in window,canUseViewport:a&&"screen"in window}},35742:(e,t,n)=>{"use strict";n.d(t,{Z:()=>o});var a=n(67294),r=n(70405);function o(e){return a.createElement(r.ql,e)}},39960:(e,t,n)=>{"use strict";n.d(t,{Z:()=>p});var a=n(87462),r=n(67294),o=n(73727),i=n(18780),l=n(52263),s=n(13919),c=n(10412);const u=r.createContext({collectLink:()=>{}});var d=n(44996);function f(e,t){let{isNavLink:n,to:f,href:p,activeClassName:g,isActive:m,"data-noBrokenLinkCheck":b,autoAddBaseUrl:h=!0,...v}=e;const{siteConfig:{trailingSlash:y,baseUrl:_}}=(0,l.Z)(),{withBaseUrl:w}=(0,d.C)(),x=(0,r.useContext)(u),k=(0,r.useRef)(null);(0,r.useImperativeHandle)(t,(()=>k.current));const E=f||p;const S=(0,s.Z)(E),C=E?.replace("pathname://","");let T=void 0!==C?(L=C,h&&(e=>e.startsWith("/"))(L)?w(L):L):void 0;var L;T&&S&&(T=(0,i.applyTrailingSlash)(T,{trailingSlash:y,baseUrl:_}));const A=(0,r.useRef)(!1),N=n?o.OL:o.rU,j=c.Z.canUseIntersectionObserver,O=(0,r.useRef)(),P=()=>{A.current||null==T||(window.docusaurus.preload(T),A.current=!0)};(0,r.useEffect)((()=>(!j&&S&&null!=T&&window.docusaurus.prefetch(T),()=>{j&&O.current&&O.current.disconnect()})),[O,T,j,S]);const M=T?.startsWith("#")??!1,I=!T||!S||M;return I||b||x.collectLink(T),I?r.createElement("a",(0,a.Z)({ref:k,href:T},E&&!S&&{target:"_blank",rel:"noopener noreferrer"},v)):r.createElement(N,(0,a.Z)({},v,{onMouseEnter:P,onTouchStart:P,innerRef:e=>{k.current=e,j&&e&&S&&(O.current=new window.IntersectionObserver((t=>{t.forEach((t=>{e===t.target&&(t.isIntersecting||t.intersectionRatio>0)&&(O.current.unobserve(e),O.current.disconnect(),null!=T&&window.docusaurus.prefetch(T))}))})),O.current.observe(e))},to:T},n&&{isActive:m,activeClassName:g}))}const p=r.forwardRef(f)},95999:(e,t,n)=>{"use strict";n.d(t,{Z:()=>s,I:()=>l});var a=n(67294);function r(e,t){const n=e.split(/(\{\w+\})/).map(((e,n)=>{if(n%2==1){const n=t?.[e.slice(1,-1)];if(void 0!==n)return n}return e}));return n.some((e=>(0,a.isValidElement)(e)))?n.map(((e,t)=>(0,a.isValidElement)(e)?a.cloneElement(e,{key:t}):e)).filter((e=>""!==e)):n.join("")}var o=n(57529);function i(e){let{id:t,message:n}=e;if(void 0===t&&void 0===n)throw new Error("Docusaurus translation declarations must have at least a translation id or a default translation message");return o[t??n]??n??t}function l(e,t){let{message:n,id:a}=e;return r(i({message:n,id:a}),t)}function s(e){let{children:t,id:n,values:o}=e;if(t&&"string"!=typeof t)throw console.warn("Illegal children",t),new Error("The Docusaurus component only accept simple string values");const l=i({message:t,id:n});return a.createElement(a.Fragment,null,r(l,o))}},29935:(e,t,n)=>{"use strict";n.d(t,{m:()=>a});const a="default"},13919:(e,t,n)=>{"use strict";function a(e){return/^(?:\w*:|\/\/)/.test(e)}function r(e){return void 0!==e&&!a(e)}n.d(t,{Z:()=>r,b:()=>a})},44996:(e,t,n)=>{"use strict";n.d(t,{C:()=>i,Z:()=>l});var a=n(67294),r=n(52263),o=n(13919);function i(){const{siteConfig:{baseUrl:e,url:t}}=(0,r.Z)(),n=(0,a.useCallback)(((n,a)=>function(e,t,n,a){let{forcePrependBaseUrl:r=!1,absolute:i=!1}=void 0===a?{}:a;if(!n||n.startsWith("#")||(0,o.b)(n))return n;if(r)return t+n.replace(/^\//,"");if(n===t.replace(/\/$/,""))return t;const l=n.startsWith(t)?n:t+n.replace(/^\//,"");return i?e+l:l}(t,e,n,a)),[t,e]);return{withBaseUrl:n}}function l(e,t){void 0===t&&(t={});const{withBaseUrl:n}=i();return n(e,t)}},52263:(e,t,n)=>{"use strict";n.d(t,{Z:()=>o});var a=n(67294),r=n(58940);function o(){return(0,a.useContext)(r._)}},72389:(e,t,n)=>{"use strict";n.d(t,{Z:()=>o});var a=n(67294),r=n(98934);function o(){return(0,a.useContext)(r._)}},99670:(e,t,n)=>{"use strict";n.d(t,{Z:()=>r});const a=e=>"object"==typeof e&&!!e&&Object.keys(e).length>0;function r(e){const t={};return function e(n,r){Object.entries(n).forEach((n=>{let[o,i]=n;const l=r?`${r}.${o}`:o;a(i)?e(i,l):t[l]=i}))}(e),t}},30226:(e,t,n)=>{"use strict";n.d(t,{_:()=>r,z:()=>o});var a=n(67294);const r=a.createContext(null);function o(e){let{children:t,value:n}=e;const o=a.useContext(r),i=(0,a.useMemo)((()=>function(e){let{parent:t,value:n}=e;if(!t){if(!n)throw new Error("Unexpected: no Docusaurus route context found");if(!("plugin"in n))throw new Error("Unexpected: Docusaurus topmost route context has no `plugin` attribute");return n}const a={...t.data,...n?.data};return{plugin:t.plugin,data:a}}({parent:o,value:n})),[o,n]);return a.createElement(r.Provider,{value:i},t)}},80143:(e,t,n)=>{"use strict";n.d(t,{Iw:()=>m,gA:()=>f,_r:()=>u,Jo:()=>b,zh:()=>d,yW:()=>g,gB:()=>p});var a=n(16550),r=n(52263),o=n(29935);function i(e,t){void 0===t&&(t={});const n=function(){const{globalData:e}=(0,r.Z)();return e}()[e];if(!n&&t.failfast)throw new Error(`Docusaurus plugin global data not found for "${e}" plugin.`);return n}const l=e=>e.versions.find((e=>e.isLast));function s(e,t){const n=function(e,t){const n=l(e);return[...e.versions.filter((e=>e!==n)),n].find((e=>!!(0,a.LX)(t,{path:e.path,exact:!1,strict:!1})))}(e,t),r=n?.docs.find((e=>!!(0,a.LX)(t,{path:e.path,exact:!0,strict:!1})));return{activeVersion:n,activeDoc:r,alternateDocVersions:r?function(t){const n={};return e.versions.forEach((e=>{e.docs.forEach((a=>{a.id===t&&(n[e.name]=a)}))})),n}(r.id):{}}}const c={},u=()=>i("docusaurus-plugin-content-docs")??c,d=e=>function(e,t,n){void 0===t&&(t=o.m),void 0===n&&(n={});const a=i(e),r=a?.[t];if(!r&&n.failfast)throw new Error(`Docusaurus plugin global data not found for "${e}" plugin with id "${t}".`);return r}("docusaurus-plugin-content-docs",e,{failfast:!0});function f(e){void 0===e&&(e={});const t=u(),{pathname:n}=(0,a.TH)();return function(e,t,n){void 0===n&&(n={});const r=Object.entries(e).sort(((e,t)=>t[1].path.localeCompare(e[1].path))).find((e=>{let[,n]=e;return!!(0,a.LX)(t,{path:n.path,exact:!1,strict:!1})})),o=r?{pluginId:r[0],pluginData:r[1]}:void 0;if(!o&&n.failfast)throw new Error(`Can't find active docs plugin for "${t}" pathname, while it was expected to be found. Maybe you tried to use a docs feature that can only be used on a docs-related page? Existing docs plugin paths are: ${Object.values(e).map((e=>e.path)).join(", ")}`);return o}(t,n,e)}function p(e){return d(e).versions}function g(e){const t=d(e);return l(t)}function m(e){const t=d(e),{pathname:n}=(0,a.TH)();return s(t,n)}function b(e){const t=d(e),{pathname:n}=(0,a.TH)();return function(e,t){const n=l(e);return{latestDocSuggestion:s(e,t).alternateDocVersions[n.name],latestVersionSuggestion:n}}(t,n)}},18320:(e,t,n)=>{"use strict";n.r(t),n.d(t,{default:()=>o});var a=n(74865),r=n.n(a);r().configure({showSpinner:!1});const o={onRouteUpdate(e){let{location:t,previousLocation:n}=e;if(n&&t.pathname!==n.pathname){const e=window.setTimeout((()=>{r().start()}),200);return()=>window.clearTimeout(e)}},onRouteDidUpdate(){r().done()}}},3310:(e,t,n)=>{"use strict";n.r(t);var a=n(87410),r=n(36809);!function(e){const{themeConfig:{prism:t}}=r.default,{additionalLanguages:a}=t;globalThis.Prism=e,a.forEach((e=>{n(78823)(`./prism-${e}`)})),delete globalThis.Prism}(a.Z)},39471:(e,t,n)=>{"use strict";n.d(t,{Z:()=>o});var a=n(67294);const r={iconExternalLink:"iconExternalLink_nPIU"};function o(e){let{width:t=13.5,height:n=13.5}=e;return a.createElement("svg",{width:t,height:n,"aria-hidden":"true",viewBox:"0 0 24 24",className:r.iconExternalLink},a.createElement("path",{fill:"currentColor",d:"M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"}))}},71155:(e,t,n)=>{"use strict";n.d(t,{Z:()=>dt});var a=n(67294),r=n(86010),o=n(44763),i=n(1944),l=n(87462),s=n(16550),c=n(95999),u=n(85936);const d="__docusaurus_skipToContent_fallback";function f(e){e.setAttribute("tabindex","-1"),e.focus(),e.removeAttribute("tabindex")}function p(){const e=(0,a.useRef)(null),{action:t}=(0,s.k6)(),n=(0,a.useCallback)((e=>{e.preventDefault();const t=document.querySelector("main:first-of-type")??document.getElementById(d);t&&f(t)}),[]);return(0,u.S)((n=>{let{location:a}=n;e.current&&!a.hash&&"PUSH"===t&&f(e.current)})),{containerRef:e,onClick:n}}const g=(0,c.I)({id:"theme.common.skipToMainContent",description:"The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation",message:"Skip to main content"});function m(e){const t=e.children??g,{containerRef:n,onClick:r}=p();return a.createElement("div",{ref:n,role:"region","aria-label":g},a.createElement("a",(0,l.Z)({},e,{href:`#${d}`,onClick:r}),t))}var b=n(35281),h=n(19727);const v={skipToContent:"skipToContent_fXgn"};function y(){return a.createElement(m,{className:v.skipToContent})}var _=n(86668),w=n(59689);function x(e){let{width:t=21,height:n=21,color:r="currentColor",strokeWidth:o=1.2,className:i,...s}=e;return a.createElement("svg",(0,l.Z)({viewBox:"0 0 15 15",width:t,height:n},s),a.createElement("g",{stroke:r,strokeWidth:o},a.createElement("path",{d:"M.75.75l13.5 13.5M14.25.75L.75 14.25"})))}const k={closeButton:"closeButton_CVFx"};function E(e){return a.createElement("button",(0,l.Z)({type:"button","aria-label":(0,c.I)({id:"theme.AnnouncementBar.closeButtonAriaLabel",message:"Close",description:"The ARIA label for close button of announcement bar"})},e,{className:(0,r.Z)("clean-btn close",k.closeButton,e.className)}),a.createElement(x,{width:14,height:14,strokeWidth:3.1}))}const S={content:"content_knG7"};function C(e){const{announcementBar:t}=(0,_.L)(),{content:n}=t;return a.createElement("div",(0,l.Z)({},e,{className:(0,r.Z)(S.content,e.className),dangerouslySetInnerHTML:{__html:n}}))}const T={announcementBar:"announcementBar_mb4j",announcementBarPlaceholder:"announcementBarPlaceholder_vyr4",announcementBarClose:"announcementBarClose_gvF7",announcementBarContent:"announcementBarContent_xLdY"};function L(){const{announcementBar:e}=(0,_.L)(),{isActive:t,close:n}=(0,w.nT)();if(!t)return null;const{backgroundColor:r,textColor:o,isCloseable:i}=e;return a.createElement("div",{className:T.announcementBar,style:{backgroundColor:r,color:o},role:"banner"},i&&a.createElement("div",{className:T.announcementBarPlaceholder}),a.createElement(C,{className:T.announcementBarContent}),i&&a.createElement(E,{onClick:n,className:T.announcementBarClose}))}var A=n(72961),N=n(12466);var j=n(902),O=n(13102);const P=a.createContext(null);function M(e){let{children:t}=e;const n=function(){const e=(0,A.e)(),t=(0,O.HY)(),[n,r]=(0,a.useState)(!1),o=null!==t.component,i=(0,j.D9)(o);return(0,a.useEffect)((()=>{o&&!i&&r(!0)}),[o,i]),(0,a.useEffect)((()=>{o?e.shown||r(!0):r(!1)}),[e.shown,o]),(0,a.useMemo)((()=>[n,r]),[n])}();return a.createElement(P.Provider,{value:n},t)}function I(e){if(e.component){const t=e.component;return a.createElement(t,e.props)}}function R(){const e=(0,a.useContext)(P);if(!e)throw new j.i6("NavbarSecondaryMenuDisplayProvider");const[t,n]=e,r=(0,a.useCallback)((()=>n(!1)),[n]),o=(0,O.HY)();return(0,a.useMemo)((()=>({shown:t,hide:r,content:I(o)})),[r,o,t])}function D(e){let{header:t,primaryMenu:n,secondaryMenu:o}=e;const{shown:i}=R();return a.createElement("div",{className:"navbar-sidebar"},t,a.createElement("div",{className:(0,r.Z)("navbar-sidebar__items",{"navbar-sidebar__items--show-secondary":i})},a.createElement("div",{className:"navbar-sidebar__item menu"},n),a.createElement("div",{className:"navbar-sidebar__item menu"},o)))}var F=n(92949),B=n(72389);function $(e){return a.createElement("svg",(0,l.Z)({viewBox:"0 0 24 24",width:24,height:24},e),a.createElement("path",{fill:"currentColor",d:"M12,9c1.65,0,3,1.35,3,3s-1.35,3-3,3s-3-1.35-3-3S10.35,9,12,9 M12,7c-2.76,0-5,2.24-5,5s2.24,5,5,5s5-2.24,5-5 S14.76,7,12,7L12,7z M2,13l2,0c0.55,0,1-0.45,1-1s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S1.45,13,2,13z M20,13l2,0c0.55,0,1-0.45,1-1 s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S19.45,13,20,13z M11,2v2c0,0.55,0.45,1,1,1s1-0.45,1-1V2c0-0.55-0.45-1-1-1S11,1.45,11,2z M11,20v2c0,0.55,0.45,1,1,1s1-0.45,1-1v-2c0-0.55-0.45-1-1-1C11.45,19,11,19.45,11,20z M5.99,4.58c-0.39-0.39-1.03-0.39-1.41,0 c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0s0.39-1.03,0-1.41L5.99,4.58z M18.36,16.95 c-0.39-0.39-1.03-0.39-1.41,0c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0c0.39-0.39,0.39-1.03,0-1.41 L18.36,16.95z M19.42,5.99c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06c-0.39,0.39-0.39,1.03,0,1.41 s1.03,0.39,1.41,0L19.42,5.99z M7.05,18.36c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06 c-0.39,0.39-0.39,1.03,0,1.41s1.03,0.39,1.41,0L7.05,18.36z"}))}function z(e){return a.createElement("svg",(0,l.Z)({viewBox:"0 0 24 24",width:24,height:24},e),a.createElement("path",{fill:"currentColor",d:"M9.37,5.51C9.19,6.15,9.1,6.82,9.1,7.5c0,4.08,3.32,7.4,7.4,7.4c0.68,0,1.35-0.09,1.99-0.27C17.45,17.19,14.93,19,12,19 c-3.86,0-7-3.14-7-7C5,9.07,6.81,6.55,9.37,5.51z M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36 c-0.98,1.37-2.58,2.26-4.4,2.26c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z"}))}const U={toggle:"toggle_vylO",toggleButton:"toggleButton_gllP",darkToggleIcon:"darkToggleIcon_wfgR",lightToggleIcon:"lightToggleIcon_pyhR",toggleButtonDisabled:"toggleButtonDisabled_aARS"};function Z(e){let{className:t,buttonClassName:n,value:o,onChange:i}=e;const l=(0,B.Z)(),s=(0,c.I)({message:"Switch between dark and light mode (currently {mode})",id:"theme.colorToggle.ariaLabel",description:"The ARIA label for the navbar color mode toggle"},{mode:"dark"===o?(0,c.I)({message:"dark mode",id:"theme.colorToggle.ariaLabel.mode.dark",description:"The name for the dark color mode"}):(0,c.I)({message:"light mode",id:"theme.colorToggle.ariaLabel.mode.light",description:"The name for the light color mode"})});return a.createElement("div",{className:(0,r.Z)(U.toggle,t)},a.createElement("button",{className:(0,r.Z)("clean-btn",U.toggleButton,!l&&U.toggleButtonDisabled,n),type:"button",onClick:()=>i("dark"===o?"light":"dark"),disabled:!l,title:s,"aria-label":s,"aria-live":"polite"},a.createElement($,{className:(0,r.Z)(U.toggleIcon,U.lightToggleIcon)}),a.createElement(z,{className:(0,r.Z)(U.toggleIcon,U.darkToggleIcon)})))}const H=a.memo(Z),V={darkNavbarColorModeToggle:"darkNavbarColorModeToggle_X3D1"};function W(e){let{className:t}=e;const n=(0,_.L)().navbar.style,r=(0,_.L)().colorMode.disableSwitch,{colorMode:o,setColorMode:i}=(0,F.I)();return r?null:a.createElement(H,{className:t,buttonClassName:"dark"===n?V.darkNavbarColorModeToggle:void 0,value:o,onChange:i})}var q=n(21327);function G(){return a.createElement(q.Z,{className:"navbar__brand",imageClassName:"navbar__logo",titleClassName:"navbar__title text--truncate"})}function Y(){const e=(0,A.e)();return a.createElement("button",{type:"button","aria-label":(0,c.I)({id:"theme.docs.sidebar.closeSidebarButtonAriaLabel",message:"Close navigation bar",description:"The ARIA label for close button of mobile sidebar"}),className:"clean-btn navbar-sidebar__close",onClick:()=>e.toggle()},a.createElement(x,{color:"var(--ifm-color-emphasis-600)"}))}function K(){return a.createElement("div",{className:"navbar-sidebar__brand"},a.createElement(G,null),a.createElement(W,{className:"margin-right--md"}),a.createElement(Y,null))}var X=n(39960),Q=n(44996),J=n(13919);function ee(e,t){return void 0!==e&&void 0!==t&&new RegExp(e,"gi").test(t)}var te=n(39471);function ne(e){let{activeBasePath:t,activeBaseRegex:n,to:r,href:o,label:i,html:s,isDropdownLink:c,prependBaseUrlToHref:u,...d}=e;const f=(0,Q.Z)(r),p=(0,Q.Z)(t),g=(0,Q.Z)(o,{forcePrependBaseUrl:!0}),m=i&&o&&!(0,J.Z)(o),b=s?{dangerouslySetInnerHTML:{__html:s}}:{children:a.createElement(a.Fragment,null,i,m&&a.createElement(te.Z,c&&{width:12,height:12}))};return o?a.createElement(X.Z,(0,l.Z)({href:u?g:o},d,b)):a.createElement(X.Z,(0,l.Z)({to:f,isNavLink:!0},(t||n)&&{isActive:(e,t)=>n?ee(n,t.pathname):t.pathname.startsWith(p)},d,b))}function ae(e){let{className:t,isDropdownItem:n=!1,...o}=e;const i=a.createElement(ne,(0,l.Z)({className:(0,r.Z)(n?"dropdown__link":"navbar__item navbar__link",t),isDropdownLink:n},o));return n?a.createElement("li",null,i):i}function re(e){let{className:t,isDropdownItem:n,...o}=e;return a.createElement("li",{className:"menu__list-item"},a.createElement(ne,(0,l.Z)({className:(0,r.Z)("menu__link",t)},o)))}function oe(e){let{mobile:t=!1,position:n,...r}=e;const o=t?re:ae;return a.createElement(o,(0,l.Z)({},r,{activeClassName:r.activeClassName??(t?"menu__link--active":"navbar__link--active")}))}var ie=n(86043),le=n(48596),se=n(52263);function ce(e,t){return e.some((e=>function(e,t){return!!(0,le.Mg)(e.to,t)||!!ee(e.activeBaseRegex,t)||!(!e.activeBasePath||!t.startsWith(e.activeBasePath))}(e,t)))}function ue(e){let{items:t,position:n,className:o,onClick:i,...s}=e;const c=(0,a.useRef)(null),[u,d]=(0,a.useState)(!1);return(0,a.useEffect)((()=>{const e=e=>{c.current&&!c.current.contains(e.target)&&d(!1)};return document.addEventListener("mousedown",e),document.addEventListener("touchstart",e),document.addEventListener("focusin",e),()=>{document.removeEventListener("mousedown",e),document.removeEventListener("touchstart",e),document.removeEventListener("focusin",e)}}),[c]),a.createElement("div",{ref:c,className:(0,r.Z)("navbar__item","dropdown","dropdown--hoverable",{"dropdown--right":"right"===n,"dropdown--show":u})},a.createElement(ne,(0,l.Z)({"aria-haspopup":"true","aria-expanded":u,role:"button",href:s.to?void 0:"#",className:(0,r.Z)("navbar__link",o)},s,{onClick:s.to?void 0:e=>e.preventDefault(),onKeyDown:e=>{"Enter"===e.key&&(e.preventDefault(),d(!u))}}),s.children??s.label),a.createElement("ul",{className:"dropdown__menu"},t.map(((e,t)=>a.createElement(Ee,(0,l.Z)({isDropdownItem:!0,activeClassName:"dropdown__link--active"},e,{key:t}))))))}function de(e){let{items:t,className:n,position:o,onClick:i,...c}=e;const u=function(){const{siteConfig:{baseUrl:e}}=(0,se.Z)(),{pathname:t}=(0,s.TH)();return t.replace(e,"/")}(),d=ce(t,u),{collapsed:f,toggleCollapsed:p,setCollapsed:g}=(0,ie.u)({initialState:()=>!d});return(0,a.useEffect)((()=>{d&&g(!d)}),[u,d,g]),a.createElement("li",{className:(0,r.Z)("menu__list-item",{"menu__list-item--collapsed":f})},a.createElement(ne,(0,l.Z)({role:"button",className:(0,r.Z)("menu__link menu__link--sublist menu__link--sublist-caret",n)},c,{onClick:e=>{e.preventDefault(),p()}}),c.children??c.label),a.createElement(ie.z,{lazy:!0,as:"ul",className:"menu__list",collapsed:f},t.map(((e,t)=>a.createElement(Ee,(0,l.Z)({mobile:!0,isDropdownItem:!0,onClick:i,activeClassName:"menu__link--active"},e,{key:t}))))))}function fe(e){let{mobile:t=!1,...n}=e;const r=t?de:ue;return a.createElement(r,n)}var pe=n(94711);function ge(e){let{width:t=20,height:n=20,...r}=e;return a.createElement("svg",(0,l.Z)({viewBox:"0 0 24 24",width:t,height:n,"aria-hidden":!0},r),a.createElement("path",{fill:"currentColor",d:"M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"}))}const me="iconLanguage_nlXk";const be=()=>null,he={searchBox:"searchBox_ZlJk"};function ve(e){let{children:t,className:n}=e;return a.createElement("div",{className:(0,r.Z)(n,he.searchBox)},t)}var ye=n(80143),_e=n(52802);var we=n(60373);const xe=e=>e.docs.find((t=>t.id===e.mainDocId));const ke={default:oe,localeDropdown:function(e){let{mobile:t,dropdownItemsBefore:n,dropdownItemsAfter:r,...o}=e;const{i18n:{currentLocale:i,locales:u,localeConfigs:d}}=(0,se.Z)(),f=(0,pe.l)(),{search:p,hash:g}=(0,s.TH)(),m=[...n,...u.map((e=>{const n=`${`pathname://${f.createUrl({locale:e,fullyQualified:!1})}`}${p}${g}`;return{label:d[e].label,lang:d[e].htmlLang,to:n,target:"_self",autoAddBaseUrl:!1,className:e===i?t?"menu__link--active":"dropdown__link--active":""}})),...r],b=t?(0,c.I)({message:"Languages",id:"theme.navbar.mobileLanguageDropdown.label",description:"The label for the mobile language switcher dropdown"}):d[i].label;return a.createElement(fe,(0,l.Z)({},o,{mobile:t,label:a.createElement(a.Fragment,null,a.createElement(ge,{className:me}),b),items:m}))},search:function(e){let{mobile:t,className:n}=e;return t?null:a.createElement(ve,{className:n},a.createElement(be,null))},dropdown:fe,html:function(e){let{value:t,className:n,mobile:o=!1,isDropdownItem:i=!1}=e;const l=i?"li":"div";return a.createElement(l,{className:(0,r.Z)({navbar__item:!o&&!i,"menu__list-item":o},n),dangerouslySetInnerHTML:{__html:t}})},doc:function(e){let{docId:t,label:n,docsPluginId:r,...o}=e;const{activeDoc:i}=(0,ye.Iw)(r),s=(0,_e.vY)(t,r);return null===s?null:a.createElement(oe,(0,l.Z)({exact:!0},o,{isActive:()=>i?.path===s.path||!!i?.sidebar&&i.sidebar===s.sidebar,label:n??s.id,to:s.path}))},docSidebar:function(e){let{sidebarId:t,label:n,docsPluginId:r,...o}=e;const{activeDoc:i}=(0,ye.Iw)(r),s=(0,_e.oz)(t,r).link;if(!s)throw new Error(`DocSidebarNavbarItem: Sidebar with ID "${t}" doesn't have anything to be linked to.`);return a.createElement(oe,(0,l.Z)({exact:!0},o,{isActive:()=>i?.sidebar===t,label:n??s.label,to:s.path}))},docsVersion:function(e){let{label:t,to:n,docsPluginId:r,...o}=e;const i=(0,_e.lO)(r)[0],s=t??i.label,c=n??(e=>e.docs.find((t=>t.id===e.mainDocId)))(i).path;return a.createElement(oe,(0,l.Z)({},o,{label:s,to:c}))},docsVersionDropdown:function(e){let{mobile:t,docsPluginId:n,dropdownActiveClassDisabled:r,dropdownItemsBefore:o,dropdownItemsAfter:i,...u}=e;const{search:d,hash:f}=(0,s.TH)(),p=(0,ye.Iw)(n),g=(0,ye.gB)(n),{savePreferredVersionName:m}=(0,we.J)(n),b=[...o,...g.map((e=>{const t=p.alternateDocVersions[e.name]??xe(e);return{label:e.label,to:`${t.path}${d}${f}`,isActive:()=>e===p.activeVersion,onClick:()=>m(e.name)}})),...i],h=(0,_e.lO)(n)[0],v=t&&b.length>1?(0,c.I)({id:"theme.navbar.mobileVersionsDropdown.label",message:"Versions",description:"The label for the navbar versions dropdown on mobile view"}):h.label,y=t&&b.length>1?void 0:xe(h).path;return b.length<=1?a.createElement(oe,(0,l.Z)({},u,{mobile:t,label:v,to:y,isActive:r?()=>!1:void 0})):a.createElement(fe,(0,l.Z)({},u,{mobile:t,label:v,to:y,items:b,isActive:r?()=>!1:void 0}))}};function Ee(e){let{type:t,...n}=e;const r=function(e,t){return e&&"default"!==e?e:"items"in t?"dropdown":"default"}(t,n),o=ke[r];if(!o)throw new Error(`No NavbarItem component found for type "${t}".`);return a.createElement(o,n)}function Se(){const e=(0,A.e)(),t=(0,_.L)().navbar.items;return a.createElement("ul",{className:"menu__list"},t.map(((t,n)=>a.createElement(Ee,(0,l.Z)({mobile:!0},t,{onClick:()=>e.toggle(),key:n})))))}function Ce(e){return a.createElement("button",(0,l.Z)({},e,{type:"button",className:"clean-btn navbar-sidebar__back"}),a.createElement(c.Z,{id:"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel",description:"The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)"},"\u2190 Back to main menu"))}function Te(){const e=0===(0,_.L)().navbar.items.length,t=R();return a.createElement(a.Fragment,null,!e&&a.createElement(Ce,{onClick:()=>t.hide()}),t.content)}function Le(){const e=(0,A.e)();var t;return void 0===(t=e.shown)&&(t=!0),(0,a.useEffect)((()=>(document.body.style.overflow=t?"hidden":"visible",()=>{document.body.style.overflow="visible"})),[t]),e.shouldRender?a.createElement(D,{header:a.createElement(K,null),primaryMenu:a.createElement(Se,null),secondaryMenu:a.createElement(Te,null)}):null}const Ae={navbarHideable:"navbarHideable_m1mJ",navbarHidden:"navbarHidden_jGov"};function Ne(e){return a.createElement("div",(0,l.Z)({role:"presentation"},e,{className:(0,r.Z)("navbar-sidebar__backdrop",e.className)}))}function je(e){let{children:t}=e;const{navbar:{hideOnScroll:n,style:o}}=(0,_.L)(),i=(0,A.e)(),{navbarRef:l,isNavbarVisible:s}=function(e){const[t,n]=(0,a.useState)(e),r=(0,a.useRef)(!1),o=(0,a.useRef)(0),i=(0,a.useCallback)((e=>{null!==e&&(o.current=e.getBoundingClientRect().height)}),[]);return(0,N.RF)(((t,a)=>{let{scrollY:i}=t;if(!e)return;if(i=l?n(!1):i+c{if(!e)return;const a=t.location.hash;if(a?document.getElementById(a.substring(1)):void 0)return r.current=!0,void n(!1);n(!0)})),{navbarRef:i,isNavbarVisible:t}}(n);return a.createElement("nav",{ref:l,"aria-label":(0,c.I)({id:"theme.NavBar.navAriaLabel",message:"Main",description:"The ARIA label for the main navigation"}),className:(0,r.Z)("navbar","navbar--fixed-top",n&&[Ae.navbarHideable,!s&&Ae.navbarHidden],{"navbar--dark":"dark"===o,"navbar--primary":"primary"===o,"navbar-sidebar--show":i.shown})},t,a.createElement(Ne,{onClick:i.toggle}),a.createElement(Le,null))}var Oe=n(18780);const Pe={errorBoundaryError:"errorBoundaryError_a6uf"};function Me(e){return a.createElement("button",(0,l.Z)({type:"button"},e),a.createElement(c.Z,{id:"theme.ErrorPageContent.tryAgain",description:"The label of the button to try again rendering when the React error boundary captures an error"},"Try again"))}function Ie(e){let{error:t}=e;const n=(0,Oe.getErrorCausalChain)(t).map((e=>e.message)).join("\n\nCause:\n");return a.createElement("p",{className:Pe.errorBoundaryError},n)}class Re extends a.Component{componentDidCatch(e,t){throw this.props.onError(e,t)}render(){return this.props.children}}const De="right";function Fe(e){let{width:t=30,height:n=30,className:r,...o}=e;return a.createElement("svg",(0,l.Z)({className:r,width:t,height:n,viewBox:"0 0 30 30","aria-hidden":"true"},o),a.createElement("path",{stroke:"currentColor",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"2",d:"M4 7h22M4 15h22M4 23h22"}))}function Be(){const{toggle:e,shown:t}=(0,A.e)();return a.createElement("button",{onClick:e,"aria-label":(0,c.I)({id:"theme.docs.sidebar.toggleSidebarButtonAriaLabel",message:"Toggle navigation bar",description:"The ARIA label for hamburger menu button of mobile navigation"}),"aria-expanded":t,className:"navbar__toggle clean-btn",type:"button"},a.createElement(Fe,null))}const $e={colorModeToggle:"colorModeToggle_DEke"};function ze(e){let{items:t}=e;return a.createElement(a.Fragment,null,t.map(((e,t)=>a.createElement(Re,{key:t,onError:t=>new Error(`A theme navbar item failed to render.\nPlease double-check the following navbar item (themeConfig.navbar.items) of your Docusaurus config:\n${JSON.stringify(e,null,2)}`,{cause:t})},a.createElement(Ee,e)))))}function Ue(e){let{left:t,right:n}=e;return a.createElement("div",{className:"navbar__inner"},a.createElement("div",{className:"navbar__items"},t),a.createElement("div",{className:"navbar__items navbar__items--right"},n))}function Ze(){const e=(0,A.e)(),t=(0,_.L)().navbar.items,[n,r]=function(e){function t(e){return"left"===(e.position??De)}return[e.filter(t),e.filter((e=>!t(e)))]}(t),o=t.find((e=>"search"===e.type));return a.createElement(Ue,{left:a.createElement(a.Fragment,null,!e.disabled&&a.createElement(Be,null),a.createElement(G,null),a.createElement(ze,{items:n})),right:a.createElement(a.Fragment,null,a.createElement(ze,{items:r}),a.createElement(W,{className:$e.colorModeToggle}),!o&&a.createElement(ve,null,a.createElement(be,null)))})}function He(){return a.createElement(je,null,a.createElement(Ze,null))}function Ve(e){let{item:t}=e;const{to:n,href:r,label:o,prependBaseUrlToHref:i,...s}=t,c=(0,Q.Z)(n),u=(0,Q.Z)(r,{forcePrependBaseUrl:!0});return a.createElement(X.Z,(0,l.Z)({className:"footer__link-item"},r?{href:i?u:r}:{to:c},s),o,r&&!(0,J.Z)(r)&&a.createElement(te.Z,null))}function We(e){let{item:t}=e;return t.html?a.createElement("li",{className:"footer__item",dangerouslySetInnerHTML:{__html:t.html}}):a.createElement("li",{key:t.href??t.to,className:"footer__item"},a.createElement(Ve,{item:t}))}function qe(e){let{column:t}=e;return a.createElement("div",{className:"col footer__col"},a.createElement("div",{className:"footer__title"},t.title),a.createElement("ul",{className:"footer__items clean-list"},t.items.map(((e,t)=>a.createElement(We,{key:t,item:e})))))}function Ge(e){let{columns:t}=e;return a.createElement("div",{className:"row footer__links"},t.map(((e,t)=>a.createElement(qe,{key:t,column:e}))))}function Ye(){return a.createElement("span",{className:"footer__link-separator"},"\xb7")}function Ke(e){let{item:t}=e;return t.html?a.createElement("span",{className:"footer__link-item",dangerouslySetInnerHTML:{__html:t.html}}):a.createElement(Ve,{item:t})}function Xe(e){let{links:t}=e;return a.createElement("div",{className:"footer__links text--center"},a.createElement("div",{className:"footer__links"},t.map(((e,n)=>a.createElement(a.Fragment,{key:n},a.createElement(Ke,{item:e}),t.length!==n+1&&a.createElement(Ye,null))))))}function Qe(e){let{links:t}=e;return function(e){return"title"in e[0]}(t)?a.createElement(Ge,{columns:t}):a.createElement(Xe,{links:t})}var Je=n(50941);const et={footerLogoLink:"footerLogoLink_BH7S"};function tt(e){let{logo:t}=e;const{withBaseUrl:n}=(0,Q.C)(),o={light:n(t.src),dark:n(t.srcDark??t.src)};return a.createElement(Je.Z,{className:(0,r.Z)("footer__logo",t.className),alt:t.alt,sources:o,width:t.width,height:t.height,style:t.style})}function nt(e){let{logo:t}=e;return t.href?a.createElement(X.Z,{href:t.href,className:et.footerLogoLink,target:t.target},a.createElement(tt,{logo:t})):a.createElement(tt,{logo:t})}function at(e){let{copyright:t}=e;return a.createElement("div",{className:"footer__copyright",dangerouslySetInnerHTML:{__html:t}})}function rt(e){let{style:t,links:n,logo:o,copyright:i}=e;return a.createElement("footer",{className:(0,r.Z)("footer",{"footer--dark":"dark"===t})},a.createElement("div",{className:"container container-fluid"},n,(o||i)&&a.createElement("div",{className:"footer__bottom text--center"},o&&a.createElement("div",{className:"margin-bottom--sm"},o),i)))}function ot(){const{footer:e}=(0,_.L)();if(!e)return null;const{copyright:t,links:n,logo:r,style:o}=e;return a.createElement(rt,{style:o,links:n&&n.length>0&&a.createElement(Qe,{links:n}),logo:r&&a.createElement(nt,{logo:r}),copyright:t&&a.createElement(at,{copyright:t})})}const it=a.memo(ot),lt=(0,j.Qc)([F.S,w.pl,N.OC,we.L5,i.VC,function(e){let{children:t}=e;return a.createElement(O.n2,null,a.createElement(A.M,null,a.createElement(M,null,t)))}]);function st(e){let{children:t}=e;return a.createElement(lt,null,t)}function ct(e){let{error:t,tryAgain:n}=e;return a.createElement("main",{className:"container margin-vert--xl"},a.createElement("div",{className:"row"},a.createElement("div",{className:"col col--6 col--offset-3"},a.createElement("h1",{className:"hero__title"},a.createElement(c.Z,{id:"theme.ErrorPageContent.title",description:"The title of the fallback page when the page crashed"},"This page crashed.")),a.createElement("div",{className:"margin-vert--lg"},a.createElement(Me,{onClick:n,className:"button button--primary shadow--lw"})),a.createElement("hr",null),a.createElement("div",{className:"margin-vert--md"},a.createElement(Ie,{error:t})))))}const ut={mainWrapper:"mainWrapper_z2l0"};function dt(e){const{children:t,noFooter:n,wrapperClassName:l,title:s,description:c}=e;return(0,h.t)(),a.createElement(st,null,a.createElement(i.d,{title:s,description:c}),a.createElement(y,null),a.createElement(L,null),a.createElement(He,null),a.createElement("div",{id:d,className:(0,r.Z)(b.k.wrapper.main,ut.mainWrapper,l)},a.createElement(o.Z,{fallback:e=>a.createElement(ct,e)},t)),!n&&a.createElement(it,null))}},21327:(e,t,n)=>{"use strict";n.d(t,{Z:()=>d});var a=n(87462),r=n(67294),o=n(39960),i=n(44996),l=n(52263),s=n(86668),c=n(50941);function u(e){let{logo:t,alt:n,imageClassName:a}=e;const o={light:(0,i.Z)(t.src),dark:(0,i.Z)(t.srcDark||t.src)},l=r.createElement(c.Z,{className:t.className,sources:o,height:t.height,width:t.width,alt:n,style:t.style});return a?r.createElement("div",{className:a},l):l}function d(e){const{siteConfig:{title:t}}=(0,l.Z)(),{navbar:{title:n,logo:c}}=(0,s.L)(),{imageClassName:d,titleClassName:f,...p}=e,g=(0,i.Z)(c?.href||"/"),m=n?"":t,b=c?.alt??m;return r.createElement(o.Z,(0,a.Z)({to:g},p,c?.target&&{target:c.target}),c&&r.createElement(u,{logo:c,alt:b,imageClassName:d}),null!=n&&r.createElement("b",{className:f},n))}},90197:(e,t,n)=>{"use strict";n.d(t,{Z:()=>o});var a=n(67294),r=n(35742);function o(e){let{locale:t,version:n,tag:o}=e;const i=t;return a.createElement(r.Z,null,t&&a.createElement("meta",{name:"docusaurus_locale",content:t}),n&&a.createElement("meta",{name:"docusaurus_version",content:n}),o&&a.createElement("meta",{name:"docusaurus_tag",content:o}),i&&a.createElement("meta",{name:"docsearch:language",content:i}),n&&a.createElement("meta",{name:"docsearch:version",content:n}),o&&a.createElement("meta",{name:"docsearch:docusaurus_tag",content:o}))}},50941:(e,t,n)=>{"use strict";n.d(t,{Z:()=>c});var a=n(87462),r=n(67294),o=n(86010),i=n(72389),l=n(92949);const s={themedImage:"themedImage_ToTc","themedImage--light":"themedImage--light_HNdA","themedImage--dark":"themedImage--dark_i4oU"};function c(e){const t=(0,i.Z)(),{colorMode:n}=(0,l.I)(),{sources:c,className:u,alt:d,...f}=e,p=t?"dark"===n?["dark"]:["light"]:["light","dark"];return r.createElement(r.Fragment,null,p.map((e=>r.createElement("img",(0,a.Z)({key:e,src:c[e],alt:d,className:(0,o.Z)(s.themedImage,s[`themedImage--${e}`],u)},f)))))}},86043:(e,t,n)=>{"use strict";n.d(t,{u:()=>s,z:()=>b});var a=n(87462),r=n(67294),o=n(10412),i=n(91442);const l="ease-in-out";function s(e){let{initialState:t}=e;const[n,a]=(0,r.useState)(t??!1),o=(0,r.useCallback)((()=>{a((e=>!e))}),[]);return{collapsed:n,setCollapsed:a,toggleCollapsed:o}}const c={display:"none",overflow:"hidden",height:"0px"},u={display:"block",overflow:"visible",height:"auto"};function d(e,t){const n=t?c:u;e.style.display=n.display,e.style.overflow=n.overflow,e.style.height=n.height}function f(e){let{collapsibleRef:t,collapsed:n,animation:a}=e;const o=(0,r.useRef)(!1);(0,r.useEffect)((()=>{const e=t.current;function r(){const t=e.scrollHeight,n=a?.duration??function(e){if((0,i.n)())return 1;const t=e/36;return Math.round(10*(4+15*t**.25+t/5))}(t);return{transition:`height ${n}ms ${a?.easing??l}`,height:`${t}px`}}function s(){const t=r();e.style.transition=t.transition,e.style.height=t.height}if(!o.current)return d(e,n),void(o.current=!0);return e.style.willChange="height",function(){const t=requestAnimationFrame((()=>{n?(s(),requestAnimationFrame((()=>{e.style.height=c.height,e.style.overflow=c.overflow}))):(e.style.display="block",requestAnimationFrame((()=>{s()})))}));return()=>cancelAnimationFrame(t)}()}),[t,n,a])}function p(e){if(!o.Z.canUseDOM)return e?c:u}function g(e){let{as:t="div",collapsed:n,children:a,animation:o,onCollapseTransitionEnd:i,className:l,disableSSRStyle:s}=e;const c=(0,r.useRef)(null);return f({collapsibleRef:c,collapsed:n,animation:o}),r.createElement(t,{ref:c,style:s?void 0:p(n),onTransitionEnd:e=>{"height"===e.propertyName&&(d(c.current,n),i?.(n))},className:l},a)}function m(e){let{collapsed:t,...n}=e;const[o,i]=(0,r.useState)(!t),[l,s]=(0,r.useState)(t);return(0,r.useLayoutEffect)((()=>{t||i(!0)}),[t]),(0,r.useLayoutEffect)((()=>{o&&s(t)}),[o,t]),o?r.createElement(g,(0,a.Z)({},n,{collapsed:l})):null}function b(e){let{lazy:t,...n}=e;const a=t?m:g;return r.createElement(a,n)}},59689:(e,t,n)=>{"use strict";n.d(t,{nT:()=>g,pl:()=>p});var a=n(67294),r=n(72389),o=n(50012),i=n(902),l=n(86668);const s=(0,o.WA)("docusaurus.announcement.dismiss"),c=(0,o.WA)("docusaurus.announcement.id"),u=()=>"true"===s.get(),d=e=>s.set(String(e)),f=a.createContext(null);function p(e){let{children:t}=e;const n=function(){const{announcementBar:e}=(0,l.L)(),t=(0,r.Z)(),[n,o]=(0,a.useState)((()=>!!t&&u()));(0,a.useEffect)((()=>{o(u())}),[]);const i=(0,a.useCallback)((()=>{d(!0),o(!0)}),[]);return(0,a.useEffect)((()=>{if(!e)return;const{id:t}=e;let n=c.get();"annoucement-bar"===n&&(n="announcement-bar");const a=t!==n;c.set(t),a&&d(!1),!a&&u()||o(!1)}),[e]),(0,a.useMemo)((()=>({isActive:!!e&&!n,close:i})),[e,n,i])}();return a.createElement(f.Provider,{value:n},t)}function g(){const e=(0,a.useContext)(f);if(!e)throw new i.i6("AnnouncementBarProvider");return e}},92949:(e,t,n)=>{"use strict";n.d(t,{I:()=>b,S:()=>m});var a=n(67294),r=n(10412),o=n(902),i=n(50012),l=n(86668);const s=a.createContext(void 0),c="theme",u=(0,i.WA)(c),d={light:"light",dark:"dark"},f=e=>e===d.dark?d.dark:d.light,p=e=>r.Z.canUseDOM?f(document.documentElement.getAttribute("data-theme")):f(e),g=e=>{u.set(f(e))};function m(e){let{children:t}=e;const n=function(){const{colorMode:{defaultMode:e,disableSwitch:t,respectPrefersColorScheme:n}}=(0,l.L)(),[r,o]=(0,a.useState)(p(e));(0,a.useEffect)((()=>{t&&u.del()}),[t]);const i=(0,a.useCallback)((function(t,a){void 0===a&&(a={});const{persist:r=!0}=a;t?(o(t),r&&g(t)):(o(n?window.matchMedia("(prefers-color-scheme: dark)").matches?d.dark:d.light:e),u.del())}),[n,e]);(0,a.useEffect)((()=>{document.documentElement.setAttribute("data-theme",f(r))}),[r]),(0,a.useEffect)((()=>{if(t)return;const e=e=>{if(e.key!==c)return;const t=u.get();null!==t&&i(f(t))};return window.addEventListener("storage",e),()=>window.removeEventListener("storage",e)}),[t,i]);const s=(0,a.useRef)(!1);return(0,a.useEffect)((()=>{if(t&&!n)return;const e=window.matchMedia("(prefers-color-scheme: dark)"),a=()=>{window.matchMedia("print").matches||s.current?s.current=window.matchMedia("print").matches:i(null)};return e.addListener(a),()=>e.removeListener(a)}),[i,t,n]),(0,a.useMemo)((()=>({colorMode:r,setColorMode:i,get isDarkTheme(){return r===d.dark},setLightTheme(){i(d.light)},setDarkTheme(){i(d.dark)}})),[r,i])}();return a.createElement(s.Provider,{value:n},t)}function b(){const e=(0,a.useContext)(s);if(null==e)throw new o.i6("ColorModeProvider","Please see https://docusaurus.io/docs/api/themes/configuration#use-color-mode.");return e}},60373:(e,t,n)=>{"use strict";n.d(t,{J:()=>v,L5:()=>b});var a=n(67294),r=n(80143),o=n(29935),i=n(86668),l=n(52802),s=n(902),c=n(50012);const u=e=>`docs-preferred-version-${e}`,d={save:(e,t,n)=>{(0,c.WA)(u(e),{persistence:t}).set(n)},read:(e,t)=>(0,c.WA)(u(e),{persistence:t}).get(),clear:(e,t)=>{(0,c.WA)(u(e),{persistence:t}).del()}},f=e=>Object.fromEntries(e.map((e=>[e,{preferredVersionName:null}])));const p=a.createContext(null);function g(){const e=(0,r._r)(),t=(0,i.L)().docs.versionPersistence,n=(0,a.useMemo)((()=>Object.keys(e)),[e]),[o,l]=(0,a.useState)((()=>f(n)));(0,a.useEffect)((()=>{l(function(e){let{pluginIds:t,versionPersistence:n,allDocsData:a}=e;function r(e){const t=d.read(e,n);return a[e].versions.some((e=>e.name===t))?{preferredVersionName:t}:(d.clear(e,n),{preferredVersionName:null})}return Object.fromEntries(t.map((e=>[e,r(e)])))}({allDocsData:e,versionPersistence:t,pluginIds:n}))}),[e,t,n]);return[o,(0,a.useMemo)((()=>({savePreferredVersion:function(e,n){d.save(e,t,n),l((t=>({...t,[e]:{preferredVersionName:n}})))}})),[t])]}function m(e){let{children:t}=e;const n=g();return a.createElement(p.Provider,{value:n},t)}function b(e){let{children:t}=e;return l.cE?a.createElement(m,null,t):a.createElement(a.Fragment,null,t)}function h(){const e=(0,a.useContext)(p);if(!e)throw new s.i6("DocsPreferredVersionContextProvider");return e}function v(e){void 0===e&&(e=o.m);const t=(0,r.zh)(e),[n,i]=h(),{preferredVersionName:l}=n[e];return{preferredVersion:t.versions.find((e=>e.name===l))??null,savePreferredVersionName:(0,a.useCallback)((t=>{i.savePreferredVersion(e,t)}),[i,e])}}},1116:(e,t,n)=>{"use strict";n.d(t,{V:()=>s,b:()=>l});var a=n(67294),r=n(902);const o=Symbol("EmptyContext"),i=a.createContext(o);function l(e){let{children:t,name:n,items:r}=e;const o=(0,a.useMemo)((()=>n&&r?{name:n,items:r}:null),[n,r]);return a.createElement(i.Provider,{value:o},t)}function s(){const e=(0,a.useContext)(i);if(e===o)throw new r.i6("DocsSidebarProvider");return e}},74477:(e,t,n)=>{"use strict";n.d(t,{E:()=>l,q:()=>i});var a=n(67294),r=n(902);const o=a.createContext(null);function i(e){let{children:t,version:n}=e;return a.createElement(o.Provider,{value:n},t)}function l(){const e=(0,a.useContext)(o);if(null===e)throw new r.i6("DocsVersionProvider");return e}},72961:(e,t,n)=>{"use strict";n.d(t,{M:()=>f,e:()=>p});var a=n(67294),r=n(13102),o=n(87524),i=n(16550),l=(n(61688),n(902));function s(e){!function(e){const t=(0,i.k6)(),n=(0,l.zX)(e);(0,a.useEffect)((()=>t.block(((e,t)=>n(e,t)))),[t,n])}(((t,n)=>{if("POP"===n)return e(t,n)}))}var c=n(86668);const u=a.createContext(void 0);function d(){const e=function(){const e=(0,r.HY)(),{items:t}=(0,c.L)().navbar;return 0===t.length&&!e.component}(),t=(0,o.i)(),n=!e&&"mobile"===t,[i,l]=(0,a.useState)(!1);s((()=>{if(i)return l(!1),!1}));const u=(0,a.useCallback)((()=>{l((e=>!e))}),[]);return(0,a.useEffect)((()=>{"desktop"===t&&l(!1)}),[t]),(0,a.useMemo)((()=>({disabled:e,shouldRender:n,toggle:u,shown:i})),[e,n,u,i])}function f(e){let{children:t}=e;const n=d();return a.createElement(u.Provider,{value:n},t)}function p(){const e=a.useContext(u);if(void 0===e)throw new l.i6("NavbarMobileSidebarProvider");return e}},13102:(e,t,n)=>{"use strict";n.d(t,{HY:()=>l,Zo:()=>s,n2:()=>i});var a=n(67294),r=n(902);const o=a.createContext(null);function i(e){let{children:t}=e;const n=(0,a.useState)({component:null,props:null});return a.createElement(o.Provider,{value:n},t)}function l(){const e=(0,a.useContext)(o);if(!e)throw new r.i6("NavbarSecondaryMenuContentProvider");return e[0]}function s(e){let{component:t,props:n}=e;const i=(0,a.useContext)(o);if(!i)throw new r.i6("NavbarSecondaryMenuContentProvider");const[,l]=i,s=(0,r.Ql)(n);return(0,a.useEffect)((()=>{l({component:t,props:s})}),[l,t,s]),(0,a.useEffect)((()=>()=>l({component:null,props:null})),[l]),null}},19727:(e,t,n)=>{"use strict";n.d(t,{h:()=>r,t:()=>o});var a=n(67294);const r="navigation-with-keyboard";function o(){(0,a.useEffect)((()=>{function e(e){"keydown"===e.type&&"Tab"===e.key&&document.body.classList.add(r),"mousedown"===e.type&&document.body.classList.remove(r)}return document.addEventListener("keydown",e),document.addEventListener("mousedown",e),()=>{document.body.classList.remove(r),document.removeEventListener("keydown",e),document.removeEventListener("mousedown",e)}}),[])}},87524:(e,t,n)=>{"use strict";n.d(t,{i:()=>c});var a=n(67294),r=n(10412);const o={desktop:"desktop",mobile:"mobile",ssr:"ssr"},i=996;function l(){return r.Z.canUseDOM?window.innerWidth>i?o.desktop:o.mobile:o.ssr}const s=!1;function c(){const[e,t]=(0,a.useState)((()=>s?"ssr":l()));return(0,a.useEffect)((()=>{function e(){t(l())}const n=s?window.setTimeout(e,1e3):void 0;return window.addEventListener("resize",e),()=>{window.removeEventListener("resize",e),clearTimeout(n)}}),[]),e}},35281:(e,t,n)=>{"use strict";n.d(t,{k:()=>a});const a={page:{blogListPage:"blog-list-page",blogPostPage:"blog-post-page",blogTagsListPage:"blog-tags-list-page",blogTagPostListPage:"blog-tags-post-list-page",docsDocPage:"docs-doc-page",docsTagsListPage:"docs-tags-list-page",docsTagDocListPage:"docs-tags-doc-list-page",mdxPage:"mdx-page"},wrapper:{main:"main-wrapper",blogPages:"blog-wrapper",docsPages:"docs-wrapper",mdxPages:"mdx-wrapper"},common:{editThisPage:"theme-edit-this-page",lastUpdated:"theme-last-updated",backToTopButton:"theme-back-to-top-button",codeBlock:"theme-code-block",admonition:"theme-admonition",admonitionType:e=>`theme-admonition-${e}`},layout:{},docs:{docVersionBanner:"theme-doc-version-banner",docVersionBadge:"theme-doc-version-badge",docBreadcrumbs:"theme-doc-breadcrumbs",docMarkdown:"theme-doc-markdown",docTocMobile:"theme-doc-toc-mobile",docTocDesktop:"theme-doc-toc-desktop",docFooter:"theme-doc-footer",docFooterTagsRow:"theme-doc-footer-tags-row",docFooterEditMetaRow:"theme-doc-footer-edit-meta-row",docSidebarContainer:"theme-doc-sidebar-container",docSidebarMenu:"theme-doc-sidebar-menu",docSidebarItemCategory:"theme-doc-sidebar-item-category",docSidebarItemLink:"theme-doc-sidebar-item-link",docSidebarItemCategoryLevel:e=>`theme-doc-sidebar-item-category-level-${e}`,docSidebarItemLinkLevel:e=>`theme-doc-sidebar-item-link-level-${e}`},blog:{}}},91442:(e,t,n)=>{"use strict";function a(){return window.matchMedia("(prefers-reduced-motion: reduce)").matches}n.d(t,{n:()=>a})},52802:(e,t,n)=>{"use strict";n.d(t,{MN:()=>S,Wl:()=>g,_F:()=>v,cE:()=>f,jA:()=>m,xz:()=>p,hI:()=>E,lO:()=>w,vY:()=>k,oz:()=>x,s1:()=>_});var a=n(67294),r=n(16550),o=n(18790),i=n(80143),l=n(60373),s=n(74477),c=n(1116);function u(e){return Array.from(new Set(e))}var d=n(48596);const f=!!i._r;function p(e){const t=(0,s.E)();if(!e)return;const n=t.docs[e];if(!n)throw new Error(`no version doc found by id=${e}`);return n}function g(e){if(e.href)return e.href;for(const t of e.items){if("link"===t.type)return t.href;if("category"===t.type){const e=g(t);if(e)return e}}}function m(){const{pathname:e}=(0,r.TH)(),t=(0,c.V)();if(!t)throw new Error("Unexpected: cant find current sidebar in context");const n=y({sidebarItems:t.items,pathname:e,onlyCategories:!0}).slice(-1)[0];if(!n)throw new Error(`${e} is not associated with a category. useCurrentSidebarCategory() should only be used on category index pages.`);return n}const b=(e,t)=>void 0!==e&&(0,d.Mg)(e,t),h=(e,t)=>e.some((e=>v(e,t)));function v(e,t){return"link"===e.type?b(e.href,t):"category"===e.type&&(b(e.href,t)||h(e.items,t))}function y(e){let{sidebarItems:t,pathname:n,onlyCategories:a=!1}=e;const r=[];return function e(t){for(const o of t)if("category"===o.type&&((0,d.Mg)(o.href,n)||e(o.items))||"link"===o.type&&(0,d.Mg)(o.href,n)){return a&&"category"!==o.type||r.unshift(o),!0}return!1}(t),r}function _(){const e=(0,c.V)(),{pathname:t}=(0,r.TH)(),n=(0,i.gA)()?.pluginData.breadcrumbs;return!1!==n&&e?y({sidebarItems:e.items,pathname:t}):null}function w(e){const{activeVersion:t}=(0,i.Iw)(e),{preferredVersion:n}=(0,l.J)(e),r=(0,i.yW)(e);return(0,a.useMemo)((()=>u([t,n,r].filter(Boolean))),[t,n,r])}function x(e,t){const n=w(t);return(0,a.useMemo)((()=>{const t=n.flatMap((e=>e.sidebars?Object.entries(e.sidebars):[])),a=t.find((t=>t[0]===e));if(!a)throw new Error(`Can't find any sidebar with id "${e}" in version${n.length>1?"s":""} ${n.map((e=>e.name)).join(", ")}".\nAvailable sidebar ids are:\n- ${t.map((e=>e[0])).join("\n- ")}`);return a[1]}),[e,n])}function k(e,t){const n=w(t);return(0,a.useMemo)((()=>{const t=n.flatMap((e=>e.docs)),a=t.find((t=>t.id===e));if(!a){if(n.flatMap((e=>e.draftIds)).includes(e))return null;throw new Error(`Couldn't find any doc with id "${e}" in version${n.length>1?"s":""} "${n.map((e=>e.name)).join(", ")}".\nAvailable doc ids are:\n- ${u(t.map((e=>e.id))).join("\n- ")}`)}return a}),[e,n])}function E(e){let{route:t,versionMetadata:n}=e;const a=(0,r.TH)(),i=t.routes,l=i.find((e=>(0,r.LX)(a.pathname,e)));if(!l)return null;const s=l.sidebar,c=s?n.docsSidebars[s]:void 0;return{docElement:(0,o.H)(i),sidebarName:s,sidebarItems:c}}function S(e){return e.filter((e=>"category"!==e.type||!!g(e)))}},1944:(e,t,n)=>{"use strict";n.d(t,{FG:()=>f,d:()=>u,VC:()=>p});var a=n(67294),r=n(86010),o=n(35742),i=n(30226);function l(){const e=a.useContext(i._);if(!e)throw new Error("Unexpected: no Docusaurus route context found");return e}var s=n(44996),c=n(52263);function u(e){let{title:t,description:n,keywords:r,image:i,children:l}=e;const u=function(e){const{siteConfig:t}=(0,c.Z)(),{title:n,titleDelimiter:a}=t;return e?.trim().length?`${e.trim()} ${a} ${n}`:n}(t),{withBaseUrl:d}=(0,s.C)(),f=i?d(i,{absolute:!0}):void 0;return a.createElement(o.Z,null,t&&a.createElement("title",null,u),t&&a.createElement("meta",{property:"og:title",content:u}),n&&a.createElement("meta",{name:"description",content:n}),n&&a.createElement("meta",{property:"og:description",content:n}),r&&a.createElement("meta",{name:"keywords",content:Array.isArray(r)?r.join(","):r}),f&&a.createElement("meta",{property:"og:image",content:f}),f&&a.createElement("meta",{name:"twitter:image",content:f}),l)}const d=a.createContext(void 0);function f(e){let{className:t,children:n}=e;const i=a.useContext(d),l=(0,r.Z)(i,t);return a.createElement(d.Provider,{value:l},a.createElement(o.Z,null,a.createElement("html",{className:l})),n)}function p(e){let{children:t}=e;const n=l(),o=`plugin-${n.plugin.name.replace(/docusaurus-(?:plugin|theme)-(?:content-)?/gi,"")}`;const i=`plugin-id-${n.plugin.id}`;return a.createElement(f,{className:(0,r.Z)(o,i)},t)}},902:(e,t,n)=>{"use strict";n.d(t,{D9:()=>i,Qc:()=>c,Ql:()=>s,i6:()=>l,zX:()=>o});var a=n(67294);const r=n(10412).Z.canUseDOM?a.useLayoutEffect:a.useEffect;function o(e){const t=(0,a.useRef)(e);return r((()=>{t.current=e}),[e]),(0,a.useCallback)((function(){return t.current(...arguments)}),[])}function i(e){const t=(0,a.useRef)();return r((()=>{t.current=e})),t.current}class l extends Error{constructor(e,t){super(),this.name="ReactContextError",this.message=`Hook ${this.stack?.split("\n")[1]?.match(/at (?:\w+\.)?(?\w+)/)?.groups.name??""} is called outside the <${e}>. ${t??""}`}}function s(e){const t=Object.entries(e);return t.sort(((e,t)=>e[0].localeCompare(t[0]))),(0,a.useMemo)((()=>e),t.flat())}function c(e){return t=>{let{children:n}=t;return a.createElement(a.Fragment,null,e.reduceRight(((e,t)=>a.createElement(t,null,e)),n))}}},48596:(e,t,n)=>{"use strict";n.d(t,{Mg:()=>i,Ns:()=>l});var a=n(67294),r=n(723),o=n(52263);function i(e,t){const n=e=>(!e||e.endsWith("/")?e:`${e}/`)?.toLowerCase();return n(e)===n(t)}function l(){const{baseUrl:e}=(0,o.Z)().siteConfig;return(0,a.useMemo)((()=>function(e){let{baseUrl:t,routes:n}=e;function a(e){return e.path===t&&!0===e.exact}function r(e){return e.path===t&&!e.exact}return function e(t){if(0===t.length)return;return t.find(a)||e(t.filter(r).flatMap((e=>e.routes??[])))}(n)}({routes:r.Z,baseUrl:e})),[e])}},12466:(e,t,n)=>{"use strict";n.d(t,{Ct:()=>f,OC:()=>s,RF:()=>d});var a=n(67294),r=n(10412),o=n(72389),i=n(902);const l=a.createContext(void 0);function s(e){let{children:t}=e;const n=function(){const e=(0,a.useRef)(!0);return(0,a.useMemo)((()=>({scrollEventsEnabledRef:e,enableScrollEvents:()=>{e.current=!0},disableScrollEvents:()=>{e.current=!1}})),[])}();return a.createElement(l.Provider,{value:n},t)}function c(){const e=(0,a.useContext)(l);if(null==e)throw new i.i6("ScrollControllerProvider");return e}const u=()=>r.Z.canUseDOM?{scrollX:window.pageXOffset,scrollY:window.pageYOffset}:null;function d(e,t){void 0===t&&(t=[]);const{scrollEventsEnabledRef:n}=c(),r=(0,a.useRef)(u()),o=(0,i.zX)(e);(0,a.useEffect)((()=>{const e=()=>{if(!n.current)return;const e=u();o(e,r.current),r.current=e},t={passive:!0};return e(),window.addEventListener("scroll",e,t),()=>window.removeEventListener("scroll",e,t)}),[o,n,...t])}function f(){const e=(0,a.useRef)(null),t=(0,o.Z)()&&"smooth"===getComputedStyle(document.documentElement).scrollBehavior;return{startScroll:n=>{e.current=t?function(e){return window.scrollTo({top:e,behavior:"smooth"}),()=>{}}(n):function(e){let t=null;const n=document.documentElement.scrollTop>e;return function a(){const r=document.documentElement.scrollTop;(n&&r>e||!n&&rt&&cancelAnimationFrame(t)}(n)},cancelScroll:()=>e.current?.()}}},43320:(e,t,n)=>{"use strict";n.d(t,{HX:()=>a,os:()=>r});n(52263);const a="default";function r(e,t){return`docs-${e}-${t}`}},50012:(e,t,n)=>{"use strict";n.d(t,{WA:()=>s});n(67294),n(61688);const a="localStorage";function r(e){let{key:t,oldValue:n,newValue:a,storage:r}=e;if(n===a)return;const o=document.createEvent("StorageEvent");o.initStorageEvent("storage",!1,!1,t,n,a,window.location.href,r),window.dispatchEvent(o)}function o(e){if(void 0===e&&(e=a),"undefined"==typeof window)throw new Error("Browser storage is not available on Node.js/Docusaurus SSR process.");if("none"===e)return null;try{return window[e]}catch(n){return t=n,i||(console.warn("Docusaurus browser storage is not available.\nPossible reasons: running Docusaurus in an iframe, in an incognito browser session, or using too strict browser privacy settings.",t),i=!0),null}var t}let i=!1;const l={get:()=>null,set:()=>{},del:()=>{},listen:()=>()=>{}};function s(e,t){if("undefined"==typeof window)return function(e){function t(){throw new Error(`Illegal storage API usage for storage key "${e}".\nDocusaurus storage APIs are not supposed to be called on the server-rendering process.\nPlease only call storage APIs in effects and event handlers.`)}return{get:t,set:t,del:t,listen:t}}(e);const n=o(t?.persistence);return null===n?l:{get:()=>{try{return n.getItem(e)}catch(t){return console.error(`Docusaurus storage error, can't get key=${e}`,t),null}},set:t=>{try{const a=n.getItem(e);n.setItem(e,t),r({key:e,oldValue:a,newValue:t,storage:n})}catch(a){console.error(`Docusaurus storage error, can't set ${e}=${t}`,a)}},del:()=>{try{const t=n.getItem(e);n.removeItem(e),r({key:e,oldValue:t,newValue:null,storage:n})}catch(t){console.error(`Docusaurus storage error, can't delete key=${e}`,t)}},listen:t=>{try{const a=a=>{a.storageArea===n&&a.key===e&&t(a)};return window.addEventListener("storage",a),()=>window.removeEventListener("storage",a)}catch(a){return console.error(`Docusaurus storage error, can't listen for changes of key=${e}`,a),()=>{}}}}}},94711:(e,t,n)=>{"use strict";n.d(t,{l:()=>o});var a=n(52263),r=n(16550);function o(){const{siteConfig:{baseUrl:e,url:t},i18n:{defaultLocale:n,currentLocale:o}}=(0,a.Z)(),{pathname:i}=(0,r.TH)(),l=o===n?e:e.replace(`/${o}/`,"/"),s=i.replace(e,"");return{createUrl:function(e){let{locale:a,fullyQualified:r}=e;return`${r?t:""}${function(e){return e===n?`${l}`:`${l}${e}/`}(a)}${s}`}}}},85936:(e,t,n)=>{"use strict";n.d(t,{S:()=>i});var a=n(67294),r=n(16550),o=n(902);function i(e){const t=(0,r.TH)(),n=(0,o.D9)(t),i=(0,o.zX)(e);(0,a.useEffect)((()=>{n&&t!==n&&i({location:t,previousLocation:n})}),[i,t,n])}},86668:(e,t,n)=>{"use strict";n.d(t,{L:()=>r});var a=n(52263);function r(){return(0,a.Z)().siteConfig.themeConfig}},8802:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=function(e,t){const{trailingSlash:n,baseUrl:a}=t;if(e.startsWith("#"))return e;if(void 0===n)return e;const[r]=e.split(/[#?]/),o="/"===r||r===a?r:(i=r,n?function(e){return e.endsWith("/")?e:`${e}/`}(i):function(e){return e.endsWith("/")?e.slice(0,-1):e}(i));var i;return e.replace(r,o)}},54143:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.getErrorCausalChain=void 0,t.getErrorCausalChain=function e(t){return t.cause?[t,...e(t.cause)]:[t]}},18780:function(e,t,n){"use strict";var a=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.getErrorCausalChain=t.applyTrailingSlash=t.blogPostContainerID=void 0,t.blogPostContainerID="__blog-post-container";var r=n(8802);Object.defineProperty(t,"applyTrailingSlash",{enumerable:!0,get:function(){return a(r).default}});var o=n(54143);Object.defineProperty(t,"getErrorCausalChain",{enumerable:!0,get:function(){return o.getErrorCausalChain}})},86010:(e,t,n)=>{"use strict";function a(e){var t,n,r="";if("string"==typeof e||"number"==typeof e)r+=e;else if("object"==typeof e)if(Array.isArray(e))for(t=0;tr});const r=function(){for(var e,t,n=0,r="";n{"use strict";n.d(t,{lX:()=>k,q_:()=>A,ob:()=>m,PP:()=>j,Ep:()=>g,Hp:()=>b});var a=n(87462);function r(e){return"/"===e.charAt(0)}function o(e,t){for(var n=t,a=n+1,r=e.length;a=0;f--){var p=i[f];"."===p?o(i,f):".."===p?(o(i,f),d++):d&&(o(i,f),d--)}if(!c)for(;d--;d)i.unshift("..");!c||""===i[0]||i[0]&&r(i[0])||i.unshift("");var g=i.join("/");return n&&"/"!==g.substr(-1)&&(g+="/"),g};function l(e){return e.valueOf?e.valueOf():Object.prototype.valueOf.call(e)}const s=function e(t,n){if(t===n)return!0;if(null==t||null==n)return!1;if(Array.isArray(t))return Array.isArray(n)&&t.length===n.length&&t.every((function(t,a){return e(t,n[a])}));if("object"==typeof t||"object"==typeof n){var a=l(t),r=l(n);return a!==t||r!==n?e(a,r):Object.keys(Object.assign({},t,n)).every((function(a){return e(t[a],n[a])}))}return!1};var c=n(38776);function u(e){return"/"===e.charAt(0)?e:"/"+e}function d(e){return"/"===e.charAt(0)?e.substr(1):e}function f(e,t){return function(e,t){return 0===e.toLowerCase().indexOf(t.toLowerCase())&&-1!=="/?#".indexOf(e.charAt(t.length))}(e,t)?e.substr(t.length):e}function p(e){return"/"===e.charAt(e.length-1)?e.slice(0,-1):e}function g(e){var t=e.pathname,n=e.search,a=e.hash,r=t||"/";return n&&"?"!==n&&(r+="?"===n.charAt(0)?n:"?"+n),a&&"#"!==a&&(r+="#"===a.charAt(0)?a:"#"+a),r}function m(e,t,n,r){var o;"string"==typeof e?(o=function(e){var t=e||"/",n="",a="",r=t.indexOf("#");-1!==r&&(a=t.substr(r),t=t.substr(0,r));var o=t.indexOf("?");return-1!==o&&(n=t.substr(o),t=t.substr(0,o)),{pathname:t,search:"?"===n?"":n,hash:"#"===a?"":a}}(e),o.state=t):(void 0===(o=(0,a.Z)({},e)).pathname&&(o.pathname=""),o.search?"?"!==o.search.charAt(0)&&(o.search="?"+o.search):o.search="",o.hash?"#"!==o.hash.charAt(0)&&(o.hash="#"+o.hash):o.hash="",void 0!==t&&void 0===o.state&&(o.state=t));try{o.pathname=decodeURI(o.pathname)}catch(l){throw l instanceof URIError?new URIError('Pathname "'+o.pathname+'" could not be decoded. This is likely caused by an invalid percent-encoding.'):l}return n&&(o.key=n),r?o.pathname?"/"!==o.pathname.charAt(0)&&(o.pathname=i(o.pathname,r.pathname)):o.pathname=r.pathname:o.pathname||(o.pathname="/"),o}function b(e,t){return e.pathname===t.pathname&&e.search===t.search&&e.hash===t.hash&&e.key===t.key&&s(e.state,t.state)}function h(){var e=null;var t=[];return{setPrompt:function(t){return e=t,function(){e===t&&(e=null)}},confirmTransitionTo:function(t,n,a,r){if(null!=e){var o="function"==typeof e?e(t,n):e;"string"==typeof o?"function"==typeof a?a(o,r):r(!0):r(!1!==o)}else r(!0)},appendListener:function(e){var n=!0;function a(){n&&e.apply(void 0,arguments)}return t.push(a),function(){n=!1,t=t.filter((function(e){return e!==a}))}},notifyListeners:function(){for(var e=arguments.length,n=new Array(e),a=0;at?n.splice(t,n.length-t,r):n.push(r),d({action:a,location:r,index:t,entries:n})}}))},replace:function(e,t){var a="REPLACE",r=m(e,t,f(),_.location);u.confirmTransitionTo(r,a,n,(function(e){e&&(_.entries[_.index]=r,d({action:a,location:r}))}))},go:y,goBack:function(){y(-1)},goForward:function(){y(1)},canGo:function(e){var t=_.index+e;return t>=0&&t<_.entries.length},block:function(e){return void 0===e&&(e=!1),u.setPrompt(e)},listen:function(e){return u.appendListener(e)}};return _}},8679:(e,t,n)=>{"use strict";var a=n(59864),r={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},o={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},i={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},l={};function s(e){return a.isMemo(e)?i:l[e.$$typeof]||r}l[a.ForwardRef]={$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},l[a.Memo]=i;var c=Object.defineProperty,u=Object.getOwnPropertyNames,d=Object.getOwnPropertySymbols,f=Object.getOwnPropertyDescriptor,p=Object.getPrototypeOf,g=Object.prototype;e.exports=function e(t,n,a){if("string"!=typeof n){if(g){var r=p(n);r&&r!==g&&e(t,r,a)}var i=u(n);d&&(i=i.concat(d(n)));for(var l=s(t),m=s(n),b=0;b{"use strict";e.exports=function(e,t,n,a,r,o,i,l){if(!e){var s;if(void 0===t)s=new Error("Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.");else{var c=[n,a,r,o,i,l],u=0;(s=new Error(t.replace(/%s/g,(function(){return c[u++]})))).name="Invariant Violation"}throw s.framesToPop=1,s}}},5826:e=>{e.exports=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)}},32497:(e,t,n)=>{"use strict";n.r(t)},52295:(e,t,n)=>{"use strict";n.r(t)},74865:function(e,t,n){var a,r;a=function(){var e,t,n={version:"0.2.0"},a=n.settings={minimum:.08,easing:"ease",positionUsing:"",speed:200,trickle:!0,trickleRate:.02,trickleSpeed:800,showSpinner:!0,barSelector:'[role="bar"]',spinnerSelector:'[role="spinner"]',parent:"body",template:'
    '};function r(e,t,n){return en?n:e}function o(e){return 100*(-1+e)}function i(e,t,n){var r;return(r="translate3d"===a.positionUsing?{transform:"translate3d("+o(e)+"%,0,0)"}:"translate"===a.positionUsing?{transform:"translate("+o(e)+"%,0)"}:{"margin-left":o(e)+"%"}).transition="all "+t+"ms "+n,r}n.configure=function(e){var t,n;for(t in e)void 0!==(n=e[t])&&e.hasOwnProperty(t)&&(a[t]=n);return this},n.status=null,n.set=function(e){var t=n.isStarted();e=r(e,a.minimum,1),n.status=1===e?null:e;var o=n.render(!t),c=o.querySelector(a.barSelector),u=a.speed,d=a.easing;return o.offsetWidth,l((function(t){""===a.positionUsing&&(a.positionUsing=n.getPositioningCSS()),s(c,i(e,u,d)),1===e?(s(o,{transition:"none",opacity:1}),o.offsetWidth,setTimeout((function(){s(o,{transition:"all "+u+"ms linear",opacity:0}),setTimeout((function(){n.remove(),t()}),u)}),u)):setTimeout(t,u)})),this},n.isStarted=function(){return"number"==typeof n.status},n.start=function(){n.status||n.set(0);var e=function(){setTimeout((function(){n.status&&(n.trickle(),e())}),a.trickleSpeed)};return a.trickle&&e(),this},n.done=function(e){return e||n.status?n.inc(.3+.5*Math.random()).set(1):this},n.inc=function(e){var t=n.status;return t?("number"!=typeof e&&(e=(1-t)*r(Math.random()*t,.1,.95)),t=r(t+e,0,.994),n.set(t)):n.start()},n.trickle=function(){return n.inc(Math.random()*a.trickleRate)},e=0,t=0,n.promise=function(a){return a&&"resolved"!==a.state()?(0===t&&n.start(),e++,t++,a.always((function(){0==--t?(e=0,n.done()):n.set((e-t)/e)})),this):this},n.render=function(e){if(n.isRendered())return document.getElementById("nprogress");u(document.documentElement,"nprogress-busy");var t=document.createElement("div");t.id="nprogress",t.innerHTML=a.template;var r,i=t.querySelector(a.barSelector),l=e?"-100":o(n.status||0),c=document.querySelector(a.parent);return s(i,{transition:"all 0 linear",transform:"translate3d("+l+"%,0,0)"}),a.showSpinner||(r=t.querySelector(a.spinnerSelector))&&p(r),c!=document.body&&u(c,"nprogress-custom-parent"),c.appendChild(t),t},n.remove=function(){d(document.documentElement,"nprogress-busy"),d(document.querySelector(a.parent),"nprogress-custom-parent");var e=document.getElementById("nprogress");e&&p(e)},n.isRendered=function(){return!!document.getElementById("nprogress")},n.getPositioningCSS=function(){var e=document.body.style,t="WebkitTransform"in e?"Webkit":"MozTransform"in e?"Moz":"msTransform"in e?"ms":"OTransform"in e?"O":"";return t+"Perspective"in e?"translate3d":t+"Transform"in e?"translate":"margin"};var l=function(){var e=[];function t(){var n=e.shift();n&&n(t)}return function(n){e.push(n),1==e.length&&t()}}(),s=function(){var e=["Webkit","O","Moz","ms"],t={};function n(e){return e.replace(/^-ms-/,"ms-").replace(/-([\da-z])/gi,(function(e,t){return t.toUpperCase()}))}function a(t){var n=document.body.style;if(t in n)return t;for(var a,r=e.length,o=t.charAt(0).toUpperCase()+t.slice(1);r--;)if((a=e[r]+o)in n)return a;return t}function r(e){return e=n(e),t[e]||(t[e]=a(e))}function o(e,t,n){t=r(t),e.style[t]=n}return function(e,t){var n,a,r=arguments;if(2==r.length)for(n in t)void 0!==(a=t[n])&&t.hasOwnProperty(n)&&o(e,n,a);else o(e,r[1],r[2])}}();function c(e,t){return("string"==typeof e?e:f(e)).indexOf(" "+t+" ")>=0}function u(e,t){var n=f(e),a=n+t;c(n,t)||(e.className=a.substring(1))}function d(e,t){var n,a=f(e);c(e,t)&&(n=a.replace(" "+t+" "," "),e.className=n.substring(1,n.length-1))}function f(e){return(" "+(e.className||"")+" ").replace(/\s+/gi," ")}function p(e){e&&e.parentNode&&e.parentNode.removeChild(e)}return n},void 0===(r="function"==typeof a?a.call(t,n,t,e):a)||(e.exports=r)},27418:e=>{"use strict";var t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable;e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map((function(e){return t[e]})).join(""))return!1;var a={};return"abcdefghijklmnopqrst".split("").forEach((function(e){a[e]=e})),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},a)).join("")}catch(r){return!1}}()?Object.assign:function(e,r){for(var o,i,l=function(e){if(null==e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}(e),s=1;s{"use strict";n.d(t,{Z:()=>o});var a=function(){var e=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,n={},a={util:{encode:function e(t){return t instanceof r?new r(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,"&").replace(/=d.reach);k+=x.value.length,x=x.next){var E=x.value;if(t.length>e.length)return;if(!(E instanceof r)){var S,C=1;if(v){if(!(S=o(w,k,e,h))||S.index>=e.length)break;var T=S.index,L=S.index+S[0].length,A=k;for(A+=x.value.length;T>=A;)A+=(x=x.next).value.length;if(k=A-=x.value.length,x.value instanceof r)continue;for(var N=x;N!==t.tail&&(Ad.reach&&(d.reach=M);var I=x.prev;if(O&&(I=s(t,I,O),k+=O.length),c(t,I,C),x=s(t,I,new r(f,b?a.tokenize(j,b):j,y,j)),P&&s(t,x,P),C>1){var R={cause:f+","+g,reach:M};i(e,t,n,x.prev,k,R),d&&R.reach>d.reach&&(d.reach=R.reach)}}}}}}function l(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function s(e,t,n){var a=t.next,r={value:n,prev:t,next:a};return t.next=r,a.prev=r,e.length++,r}function c(e,t,n){for(var a=t.next,r=0;r"+o.content+""},a}(),r=a;a.default=a,r.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},r.languages.markup.tag.inside["attr-value"].inside.entity=r.languages.markup.entity,r.languages.markup.doctype.inside["internal-subset"].inside=r.languages.markup,r.hooks.add("wrap",(function(e){"entity"===e.type&&(e.attributes.title=e.content.replace(/&/,"&"))})),Object.defineProperty(r.languages.markup.tag,"addInlined",{value:function(e,t){var n={};n["language-"+t]={pattern:/(^$)/i,lookbehind:!0,inside:r.languages[t]},n.cdata=/^$/i;var a={"included-cdata":{pattern://i,inside:n}};a["language-"+t]={pattern:/[\s\S]+/,inside:r.languages[t]};var o={};o[e]={pattern:RegExp(/(<__[^>]*>)(?:))*\]\]>|(?!)/.source.replace(/__/g,(function(){return e})),"i"),lookbehind:!0,greedy:!0,inside:a},r.languages.insertBefore("markup","cdata",o)}}),Object.defineProperty(r.languages.markup.tag,"addAttribute",{value:function(e,t){r.languages.markup.tag.inside["special-attr"].push({pattern:RegExp(/(^|["'\s])/.source+"(?:"+e+")"+/\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))/.source,"i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[t,"language-"+t],inside:r.languages[t]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),r.languages.html=r.languages.markup,r.languages.mathml=r.languages.markup,r.languages.svg=r.languages.markup,r.languages.xml=r.languages.extend("markup",{}),r.languages.ssml=r.languages.xml,r.languages.atom=r.languages.xml,r.languages.rss=r.languages.xml,function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",n={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},a={bash:n,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:a},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:n}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:a},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:a.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:a.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},n.inside=e.languages.bash;for(var r=["comment","function-name","for-or-select","assign-left","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],o=a.variable[1].inside,i=0;i]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/},r.languages.c=r.languages.extend("clike",{comment:{pattern:/\/\/(?:[^\r\n\\]|\\(?:\r\n?|\n|(?![\r\n])))*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},"class-name":{pattern:/(\b(?:enum|struct)\s+(?:__attribute__\s*\(\([\s\S]*?\)\)\s*)?)\w+|\b[a-z]\w*_t\b/,lookbehind:!0},keyword:/\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\b/,function:/\b[a-z_]\w*(?=\s*\()/i,number:/(?:\b0x(?:[\da-f]+(?:\.[\da-f]*)?|\.[\da-f]+)(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?)[ful]{0,4}/i,operator:/>>=?|<<=?|->|([-+&|:])\1|[?:~]|[-+*/%&|^!=<>]=?/}),r.languages.insertBefore("c","string",{char:{pattern:/'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n]){0,32}'/,greedy:!0}}),r.languages.insertBefore("c","string",{macro:{pattern:/(^[\t ]*)#\s*[a-z](?:[^\r\n\\/]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:\r\n|[\s\S]))*/im,lookbehind:!0,greedy:!0,alias:"property",inside:{string:[{pattern:/^(#\s*include\s*)<[^>]+>/,lookbehind:!0},r.languages.c.string],char:r.languages.c.char,comment:r.languages.c.comment,"macro-name":[{pattern:/(^#\s*define\s+)\w+\b(?!\()/i,lookbehind:!0},{pattern:/(^#\s*define\s+)\w+\b(?=\()/i,lookbehind:!0,alias:"function"}],directive:{pattern:/^(#\s*)[a-z]+/,lookbehind:!0,alias:"keyword"},"directive-hash":/^#/,punctuation:/##|\\(?=[\r\n])/,expression:{pattern:/\S[\s\S]*/,inside:r.languages.c}}}}),r.languages.insertBefore("c","function",{constant:/\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\b/}),delete r.languages.c.boolean,function(e){var t=/\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\b/,n=/\b(?!)\w+(?:\s*\.\s*\w+)*\b/.source.replace(//g,(function(){return t.source}));e.languages.cpp=e.languages.extend("c",{"class-name":[{pattern:RegExp(/(\b(?:class|concept|enum|struct|typename)\s+)(?!)\w+/.source.replace(//g,(function(){return t.source}))),lookbehind:!0},/\b[A-Z]\w*(?=\s*::\s*\w+\s*\()/,/\b[A-Z_]\w*(?=\s*::\s*~\w+\s*\()/i,/\b\w+(?=\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\s*::\s*\w+\s*\()/],keyword:t,number:{pattern:/(?:\b0b[01']+|\b0x(?:[\da-f']+(?:\.[\da-f']*)?|\.[\da-f']+)(?:p[+-]?[\d']+)?|(?:\b[\d']+(?:\.[\d']*)?|\B\.[\d']+)(?:e[+-]?[\d']+)?)[ful]{0,4}/i,greedy:!0},operator:/>>=?|<<=?|->|--|\+\+|&&|\|\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\b/,boolean:/\b(?:false|true)\b/}),e.languages.insertBefore("cpp","string",{module:{pattern:RegExp(/(\b(?:import|module)\s+)/.source+"(?:"+/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|<[^<>\r\n]*>/.source+"|"+/(?:\s*:\s*)?|:\s*/.source.replace(//g,(function(){return n}))+")"),lookbehind:!0,greedy:!0,inside:{string:/^[<"][\s\S]+/,operator:/:/,punctuation:/\./}},"raw-string":{pattern:/R"([^()\\ ]{0,16})\([\s\S]*?\)\1"/,alias:"string",greedy:!0}}),e.languages.insertBefore("cpp","keyword",{"generic-function":{pattern:/\b(?!operator\b)[a-z_]\w*\s*<(?:[^<>]|<[^<>]*>)*>(?=\s*\()/i,inside:{function:/^\w+/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:e.languages.cpp}}}}),e.languages.insertBefore("cpp","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}}),e.languages.insertBefore("cpp","class-name",{"base-clause":{pattern:/(\b(?:class|struct)\s+\w+\s*:\s*)[^;{}"'\s]+(?:\s+[^;{}"'\s]+)*(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:e.languages.extend("cpp",{})}}),e.languages.insertBefore("inside","double-colon",{"class-name":/\b[a-z_]\w*\b(?!\s*::)/i},e.languages.cpp["base-clause"])}(r),function(e){var t=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;e.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:/@[\w-](?:[^;{\s]|\s+(?![\s{]))*(?:;|(?=\s*\{))/,inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+t.source+"|"+/(?:[^\\\r\n()"']|\\[\s\S])*/.source+")\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+t.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+t.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:t,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},e.languages.css.atrule.inside.rest=e.languages.css;var n=e.languages.markup;n&&(n.tag.addInlined("style","css"),n.tag.addAttribute("style","css"))}(r),function(e){var t,n=/("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/;e.languages.css.selector={pattern:e.languages.css.selector.pattern,lookbehind:!0,inside:t={"pseudo-element":/:(?:after|before|first-letter|first-line|selection)|::[-\w]+/,"pseudo-class":/:[-\w]+/,class:/\.[-\w]+/,id:/#[-\w]+/,attribute:{pattern:RegExp("\\[(?:[^[\\]\"']|"+n.source+")*\\]"),greedy:!0,inside:{punctuation:/^\[|\]$/,"case-sensitivity":{pattern:/(\s)[si]$/i,lookbehind:!0,alias:"keyword"},namespace:{pattern:/^(\s*)(?:(?!\s)[-*\w\xA0-\uFFFF])*\|(?!=)/,lookbehind:!0,inside:{punctuation:/\|$/}},"attr-name":{pattern:/^(\s*)(?:(?!\s)[-\w\xA0-\uFFFF])+/,lookbehind:!0},"attr-value":[n,{pattern:/(=\s*)(?:(?!\s)[-\w\xA0-\uFFFF])+(?=\s*$)/,lookbehind:!0}],operator:/[|~*^$]?=/}},"n-th":[{pattern:/(\(\s*)[+-]?\d*[\dn](?:\s*[+-]\s*\d+)?(?=\s*\))/,lookbehind:!0,inside:{number:/[\dn]+/,operator:/[+-]/}},{pattern:/(\(\s*)(?:even|odd)(?=\s*\))/i,lookbehind:!0}],combinator:/>|\+|~|\|\|/,punctuation:/[(),]/}},e.languages.css.atrule.inside["selector-function-argument"].inside=t,e.languages.insertBefore("css","property",{variable:{pattern:/(^|[^-\w\xA0-\uFFFF])--(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*/i,lookbehind:!0}});var a={pattern:/(\b\d+)(?:%|[a-z]+(?![\w-]))/,lookbehind:!0},r={pattern:/(^|[^\w.-])-?(?:\d+(?:\.\d+)?|\.\d+)/,lookbehind:!0};e.languages.insertBefore("css","function",{operator:{pattern:/(\s)[+\-*\/](?=\s)/,lookbehind:!0},hexcode:{pattern:/\B#[\da-f]{3,8}\b/i,alias:"color"},color:[{pattern:/(^|[^\w-])(?:AliceBlue|AntiqueWhite|Aqua|Aquamarine|Azure|Beige|Bisque|Black|BlanchedAlmond|Blue|BlueViolet|Brown|BurlyWood|CadetBlue|Chartreuse|Chocolate|Coral|CornflowerBlue|Cornsilk|Crimson|Cyan|DarkBlue|DarkCyan|DarkGoldenRod|DarkGr[ae]y|DarkGreen|DarkKhaki|DarkMagenta|DarkOliveGreen|DarkOrange|DarkOrchid|DarkRed|DarkSalmon|DarkSeaGreen|DarkSlateBlue|DarkSlateGr[ae]y|DarkTurquoise|DarkViolet|DeepPink|DeepSkyBlue|DimGr[ae]y|DodgerBlue|FireBrick|FloralWhite|ForestGreen|Fuchsia|Gainsboro|GhostWhite|Gold|GoldenRod|Gr[ae]y|Green|GreenYellow|HoneyDew|HotPink|IndianRed|Indigo|Ivory|Khaki|Lavender|LavenderBlush|LawnGreen|LemonChiffon|LightBlue|LightCoral|LightCyan|LightGoldenRodYellow|LightGr[ae]y|LightGreen|LightPink|LightSalmon|LightSeaGreen|LightSkyBlue|LightSlateGr[ae]y|LightSteelBlue|LightYellow|Lime|LimeGreen|Linen|Magenta|Maroon|MediumAquaMarine|MediumBlue|MediumOrchid|MediumPurple|MediumSeaGreen|MediumSlateBlue|MediumSpringGreen|MediumTurquoise|MediumVioletRed|MidnightBlue|MintCream|MistyRose|Moccasin|NavajoWhite|Navy|OldLace|Olive|OliveDrab|Orange|OrangeRed|Orchid|PaleGoldenRod|PaleGreen|PaleTurquoise|PaleVioletRed|PapayaWhip|PeachPuff|Peru|Pink|Plum|PowderBlue|Purple|Red|RosyBrown|RoyalBlue|SaddleBrown|Salmon|SandyBrown|SeaGreen|SeaShell|Sienna|Silver|SkyBlue|SlateBlue|SlateGr[ae]y|Snow|SpringGreen|SteelBlue|Tan|Teal|Thistle|Tomato|Transparent|Turquoise|Violet|Wheat|White|WhiteSmoke|Yellow|YellowGreen)(?![\w-])/i,lookbehind:!0},{pattern:/\b(?:hsl|rgb)\(\s*\d{1,3}\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*\)\B|\b(?:hsl|rgb)a\(\s*\d{1,3}\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*,\s*(?:0|0?\.\d+|1)\s*\)\B/i,inside:{unit:a,number:r,function:/[\w-]+(?=\()/,punctuation:/[(),]/}}],entity:/\\[\da-f]{1,8}/i,unit:a,number:r})}(r),r.languages.javascript=r.languages.extend("clike",{"class-name":[r.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp(/(^|[^\w$])/.source+"(?:"+/NaN|Infinity/.source+"|"+/0[bB][01]+(?:_[01]+)*n?/.source+"|"+/0[oO][0-7]+(?:_[0-7]+)*n?/.source+"|"+/0[xX][\dA-Fa-f]+(?:_[\dA-Fa-f]+)*n?/.source+"|"+/\d+(?:_\d+)*n/.source+"|"+/(?:\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\.\d+(?:_\d+)*)(?:[Ee][+-]?\d+(?:_\d+)*)?/.source+")"+/(?![\w$])/.source),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),r.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,r.languages.insertBefore("javascript","keyword",{regex:{pattern:/((?:^|[^$\w\xA0-\uFFFF."'\])\s]|\b(?:return|yield))\s*)\/(?:\[(?:[^\]\\\r\n]|\\.)*\]|\\.|[^/\\\[\r\n])+\/[dgimyus]{0,7}(?=(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:$|[\r\n,.;:})\]]|\/\/))/,lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:r.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:r.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:r.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:r.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:r.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),r.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:r.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),r.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),r.languages.markup&&(r.languages.markup.tag.addInlined("script","javascript"),r.languages.markup.tag.addAttribute(/on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)/.source,"javascript")),r.languages.js=r.languages.javascript,function(e){var t=/#(?!\{).+/,n={pattern:/#\{[^}]+\}/,alias:"variable"};e.languages.coffeescript=e.languages.extend("javascript",{comment:t,string:[{pattern:/'(?:\\[\s\S]|[^\\'])*'/,greedy:!0},{pattern:/"(?:\\[\s\S]|[^\\"])*"/,greedy:!0,inside:{interpolation:n}}],keyword:/\b(?:and|break|by|catch|class|continue|debugger|delete|do|each|else|extend|extends|false|finally|for|if|in|instanceof|is|isnt|let|loop|namespace|new|no|not|null|of|off|on|or|own|return|super|switch|then|this|throw|true|try|typeof|undefined|unless|until|when|while|window|with|yes|yield)\b/,"class-member":{pattern:/@(?!\d)\w+/,alias:"variable"}}),e.languages.insertBefore("coffeescript","comment",{"multiline-comment":{pattern:/###[\s\S]+?###/,alias:"comment"},"block-regex":{pattern:/\/{3}[\s\S]*?\/{3}/,alias:"regex",inside:{comment:t,interpolation:n}}}),e.languages.insertBefore("coffeescript","string",{"inline-javascript":{pattern:/`(?:\\[\s\S]|[^\\`])*`/,inside:{delimiter:{pattern:/^`|`$/,alias:"punctuation"},script:{pattern:/[\s\S]+/,alias:"language-javascript",inside:e.languages.javascript}}},"multiline-string":[{pattern:/'''[\s\S]*?'''/,greedy:!0,alias:"string"},{pattern:/"""[\s\S]*?"""/,greedy:!0,alias:"string",inside:{interpolation:n}}]}),e.languages.insertBefore("coffeescript","keyword",{property:/(?!\d)\w+(?=\s*:(?!:))/}),delete e.languages.coffeescript["template-string"],e.languages.coffee=e.languages.coffeescript}(r),function(e){var t=/[*&][^\s[\]{},]+/,n=/!(?:<[\w\-%#;/?:@&=+$,.!~*'()[\]]+>|(?:[a-zA-Z\d-]*!)?[\w\-%#;/?:@&=+$.~*'()]+)?/,a="(?:"+n.source+"(?:[ \t]+"+t.source+")?|"+t.source+"(?:[ \t]+"+n.source+")?)",r=/(?:[^\s\x00-\x08\x0e-\x1f!"#%&'*,\-:>?@[\]`{|}\x7f-\x84\x86-\x9f\ud800-\udfff\ufffe\uffff]|[?:-])(?:[ \t]*(?:(?![#:])|:))*/.source.replace(//g,(function(){return/[^\s\x00-\x08\x0e-\x1f,[\]{}\x7f-\x84\x86-\x9f\ud800-\udfff\ufffe\uffff]/.source})),o=/"(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*'/.source;function i(e,t){t=(t||"").replace(/m/g,"")+"m";var n=/([:\-,[{]\s*(?:\s<>[ \t]+)?)(?:<>)(?=[ \t]*(?:$|,|\]|\}|(?:[\r\n]\s*)?#))/.source.replace(/<>/g,(function(){return a})).replace(/<>/g,(function(){return e}));return RegExp(n,t)}e.languages.yaml={scalar:{pattern:RegExp(/([\-:]\s*(?:\s<>[ \t]+)?[|>])[ \t]*(?:((?:\r?\n|\r)[ \t]+)\S[^\r\n]*(?:\2[^\r\n]+)*)/.source.replace(/<>/g,(function(){return a}))),lookbehind:!0,alias:"string"},comment:/#.*/,key:{pattern:RegExp(/((?:^|[:\-,[{\r\n?])[ \t]*(?:<>[ \t]+)?)<>(?=\s*:\s)/.source.replace(/<>/g,(function(){return a})).replace(/<>/g,(function(){return"(?:"+r+"|"+o+")"}))),lookbehind:!0,greedy:!0,alias:"atrule"},directive:{pattern:/(^[ \t]*)%.+/m,lookbehind:!0,alias:"important"},datetime:{pattern:i(/\d{4}-\d\d?-\d\d?(?:[tT]|[ \t]+)\d\d?:\d{2}:\d{2}(?:\.\d*)?(?:[ \t]*(?:Z|[-+]\d\d?(?::\d{2})?))?|\d{4}-\d{2}-\d{2}|\d\d?:\d{2}(?::\d{2}(?:\.\d*)?)?/.source),lookbehind:!0,alias:"number"},boolean:{pattern:i(/false|true/.source,"i"),lookbehind:!0,alias:"important"},null:{pattern:i(/null|~/.source,"i"),lookbehind:!0,alias:"important"},string:{pattern:i(o),lookbehind:!0,greedy:!0},number:{pattern:i(/[+-]?(?:0x[\da-f]+|0o[0-7]+|(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|\.inf|\.nan)/.source,"i"),lookbehind:!0},tag:n,important:t,punctuation:/---|[:[\]{}\-,|>?]|\.\.\./},e.languages.yml=e.languages.yaml}(r),function(e){var t=/(?:\\.|[^\\\n\r]|(?:\n|\r\n?)(?![\r\n]))/.source;function n(e){return e=e.replace(//g,(function(){return t})),RegExp(/((?:^|[^\\])(?:\\{2})*)/.source+"(?:"+e+")")}var a=/(?:\\.|``(?:[^`\r\n]|`(?!`))+``|`[^`\r\n]+`|[^\\|\r\n`])+/.source,r=/\|?__(?:\|__)+\|?(?:(?:\n|\r\n?)|(?![\s\S]))/.source.replace(/__/g,(function(){return a})),o=/\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)+\|?(?:\n|\r\n?)/.source;e.languages.markdown=e.languages.extend("markup",{}),e.languages.insertBefore("markdown","prolog",{"front-matter-block":{pattern:/(^(?:\s*[\r\n])?)---(?!.)[\s\S]*?[\r\n]---(?!.)/,lookbehind:!0,greedy:!0,inside:{punctuation:/^---|---$/,"front-matter":{pattern:/\S+(?:\s+\S+)*/,alias:["yaml","language-yaml"],inside:e.languages.yaml}}},blockquote:{pattern:/^>(?:[\t ]*>)*/m,alias:"punctuation"},table:{pattern:RegExp("^"+r+o+"(?:"+r+")*","m"),inside:{"table-data-rows":{pattern:RegExp("^("+r+o+")(?:"+r+")*$"),lookbehind:!0,inside:{"table-data":{pattern:RegExp(a),inside:e.languages.markdown},punctuation:/\|/}},"table-line":{pattern:RegExp("^("+r+")"+o+"$"),lookbehind:!0,inside:{punctuation:/\||:?-{3,}:?/}},"table-header-row":{pattern:RegExp("^"+r+"$"),inside:{"table-header":{pattern:RegExp(a),alias:"important",inside:e.languages.markdown},punctuation:/\|/}}}},code:[{pattern:/((?:^|\n)[ \t]*\n|(?:^|\r\n?)[ \t]*\r\n?)(?: {4}|\t).+(?:(?:\n|\r\n?)(?: {4}|\t).+)*/,lookbehind:!0,alias:"keyword"},{pattern:/^```[\s\S]*?^```$/m,greedy:!0,inside:{"code-block":{pattern:/^(```.*(?:\n|\r\n?))[\s\S]+?(?=(?:\n|\r\n?)^```$)/m,lookbehind:!0},"code-language":{pattern:/^(```).+/,lookbehind:!0},punctuation:/```/}}],title:[{pattern:/\S.*(?:\n|\r\n?)(?:==+|--+)(?=[ \t]*$)/m,alias:"important",inside:{punctuation:/==+$|--+$/}},{pattern:/(^\s*)#.+/m,lookbehind:!0,alias:"important",inside:{punctuation:/^#+|#+$/}}],hr:{pattern:/(^\s*)([*-])(?:[\t ]*\2){2,}(?=\s*$)/m,lookbehind:!0,alias:"punctuation"},list:{pattern:/(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,lookbehind:!0,alias:"punctuation"},"url-reference":{pattern:/!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,inside:{variable:{pattern:/^(!?\[)[^\]]+/,lookbehind:!0},string:/(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,punctuation:/^[\[\]!:]|[<>]/},alias:"url"},bold:{pattern:n(/\b__(?:(?!_)|_(?:(?!_))+_)+__\b|\*\*(?:(?!\*)|\*(?:(?!\*))+\*)+\*\*/.source),lookbehind:!0,greedy:!0,inside:{content:{pattern:/(^..)[\s\S]+(?=..$)/,lookbehind:!0,inside:{}},punctuation:/\*\*|__/}},italic:{pattern:n(/\b_(?:(?!_)|__(?:(?!_))+__)+_\b|\*(?:(?!\*)|\*\*(?:(?!\*))+\*\*)+\*/.source),lookbehind:!0,greedy:!0,inside:{content:{pattern:/(^.)[\s\S]+(?=.$)/,lookbehind:!0,inside:{}},punctuation:/[*_]/}},strike:{pattern:n(/(~~?)(?:(?!~))+\2/.source),lookbehind:!0,greedy:!0,inside:{content:{pattern:/(^~~?)[\s\S]+(?=\1$)/,lookbehind:!0,inside:{}},punctuation:/~~?/}},"code-snippet":{pattern:/(^|[^\\`])(?:``[^`\r\n]+(?:`[^`\r\n]+)*``(?!`)|`[^`\r\n]+`(?!`))/,lookbehind:!0,greedy:!0,alias:["code","keyword"]},url:{pattern:n(/!?\[(?:(?!\]))+\](?:\([^\s)]+(?:[\t ]+"(?:\\.|[^"\\])*")?\)|[ \t]?\[(?:(?!\]))+\])/.source),lookbehind:!0,greedy:!0,inside:{operator:/^!/,content:{pattern:/(^\[)[^\]]+(?=\])/,lookbehind:!0,inside:{}},variable:{pattern:/(^\][ \t]?\[)[^\]]+(?=\]$)/,lookbehind:!0},url:{pattern:/(^\]\()[^\s)]+/,lookbehind:!0},string:{pattern:/(^[ \t]+)"(?:\\.|[^"\\])*"(?=\)$)/,lookbehind:!0}}}}),["url","bold","italic","strike"].forEach((function(t){["url","bold","italic","strike","code-snippet"].forEach((function(n){t!==n&&(e.languages.markdown[t].inside.content.inside[n]=e.languages.markdown[n])}))})),e.hooks.add("after-tokenize",(function(e){"markdown"!==e.language&&"md"!==e.language||function e(t){if(t&&"string"!=typeof t)for(var n=0,a=t.length;n",quot:'"'},s=String.fromCodePoint||String.fromCharCode;e.languages.md=e.languages.markdown}(r),r.languages.graphql={comment:/#.*/,description:{pattern:/(?:"""(?:[^"]|(?!""")")*"""|"(?:\\.|[^\\"\r\n])*")(?=\s*[a-z_])/i,greedy:!0,alias:"string",inside:{"language-markdown":{pattern:/(^"(?:"")?)(?!\1)[\s\S]+(?=\1$)/,lookbehind:!0,inside:r.languages.markdown}}},string:{pattern:/"""(?:[^"]|(?!""")")*"""|"(?:\\.|[^\\"\r\n])*"/,greedy:!0},number:/(?:\B-|\b)\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,boolean:/\b(?:false|true)\b/,variable:/\$[a-z_]\w*/i,directive:{pattern:/@[a-z_]\w*/i,alias:"function"},"attr-name":{pattern:/\b[a-z_]\w*(?=\s*(?:\((?:[^()"]|"(?:\\.|[^\\"\r\n])*")*\))?:)/i,greedy:!0},"atom-input":{pattern:/\b[A-Z]\w*Input\b/,alias:"class-name"},scalar:/\b(?:Boolean|Float|ID|Int|String)\b/,constant:/\b[A-Z][A-Z_\d]*\b/,"class-name":{pattern:/(\b(?:enum|implements|interface|on|scalar|type|union)\s+|&\s*|:\s*|\[)[A-Z_]\w*/,lookbehind:!0},fragment:{pattern:/(\bfragment\s+|\.{3}\s*(?!on\b))[a-zA-Z_]\w*/,lookbehind:!0,alias:"function"},"definition-mutation":{pattern:/(\bmutation\s+)[a-zA-Z_]\w*/,lookbehind:!0,alias:"function"},"definition-query":{pattern:/(\bquery\s+)[a-zA-Z_]\w*/,lookbehind:!0,alias:"function"},keyword:/\b(?:directive|enum|extend|fragment|implements|input|interface|mutation|on|query|repeatable|scalar|schema|subscription|type|union)\b/,operator:/[!=|&]|\.{3}/,"property-query":/\w+(?=\s*\()/,object:/\w+(?=\s*\{)/,punctuation:/[!(){}\[\]:=,]/,property:/\w+/},r.hooks.add("after-tokenize",(function(e){if("graphql"===e.language)for(var t=e.tokens.filter((function(e){return"string"!=typeof e&&"comment"!==e.type&&"scalar"!==e.type})),n=0;n0)){var l=f(/^\{$/,/^\}$/);if(-1===l)continue;for(var s=n;s=0&&p(c,"variable-input")}}}}function u(e){return t[n+e]}function d(e,t){t=t||0;for(var n=0;n?|<|>)?|>[>=]?|\b(?:AND|BETWEEN|DIV|ILIKE|IN|IS|LIKE|NOT|OR|REGEXP|RLIKE|SOUNDS LIKE|XOR)\b/i,punctuation:/[;[\]()`,.]/},function(e){var t=e.languages.javascript["template-string"],n=t.pattern.source,a=t.inside.interpolation,r=a.inside["interpolation-punctuation"],o=a.pattern.source;function i(t,a){if(e.languages[t])return{pattern:RegExp("((?:"+a+")\\s*)"+n),lookbehind:!0,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},"embedded-code":{pattern:/[\s\S]+/,alias:t}}}}function l(e,t){return"___"+t.toUpperCase()+"_"+e+"___"}function s(t,n,a){var r={code:t,grammar:n,language:a};return e.hooks.run("before-tokenize",r),r.tokens=e.tokenize(r.code,r.grammar),e.hooks.run("after-tokenize",r),r.tokens}function c(t){var n={};n["interpolation-punctuation"]=r;var o=e.tokenize(t,n);if(3===o.length){var i=[1,1];i.push.apply(i,s(o[1],e.languages.javascript,"javascript")),o.splice.apply(o,i)}return new e.Token("interpolation",o,a.alias,t)}function u(t,n,a){var r=e.tokenize(t,{interpolation:{pattern:RegExp(o),lookbehind:!0}}),i=0,u={},d=s(r.map((function(e){if("string"==typeof e)return e;for(var n,r=e.content;-1!==t.indexOf(n=l(i++,a)););return u[n]=r,n})).join(""),n,a),f=Object.keys(u);return i=0,function e(t){for(var n=0;n=f.length)return;var a=t[n];if("string"==typeof a||"string"==typeof a.content){var r=f[i],o="string"==typeof a?a:a.content,l=o.indexOf(r);if(-1!==l){++i;var s=o.substring(0,l),d=c(u[r]),p=o.substring(l+r.length),g=[];if(s&&g.push(s),g.push(d),p){var m=[p];e(m),g.push.apply(g,m)}"string"==typeof a?(t.splice.apply(t,[n,1].concat(g)),n+=g.length-1):a.content=g}}else{var b=a.content;Array.isArray(b)?e(b):e([b])}}}(d),new e.Token(a,d,"language-"+a,t)}e.languages.javascript["template-string"]=[i("css",/\b(?:styled(?:\([^)]*\))?(?:\s*\.\s*\w+(?:\([^)]*\))*)*|css(?:\s*\.\s*(?:global|resolve))?|createGlobalStyle|keyframes)/.source),i("html",/\bhtml|\.\s*(?:inner|outer)HTML\s*\+?=/.source),i("svg",/\bsvg/.source),i("markdown",/\b(?:markdown|md)/.source),i("graphql",/\b(?:gql|graphql(?:\s*\.\s*experimental)?)/.source),i("sql",/\bsql/.source),t].filter(Boolean);var d={javascript:!0,js:!0,typescript:!0,ts:!0,jsx:!0,tsx:!0};function f(e){return"string"==typeof e?e:Array.isArray(e)?e.map(f).join(""):f(e.content)}e.hooks.add("after-tokenize",(function(t){t.language in d&&function t(n){for(var a=0,r=n.length;a]|<(?:[^<>]|<[^<>]*>)*>)*>)?/,lookbehind:!0,greedy:!0,inside:null},builtin:/\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\b/}),e.languages.typescript.keyword.push(/\b(?:abstract|declare|is|keyof|readonly|require)\b/,/\b(?:asserts|infer|interface|module|namespace|type)\b(?=\s*(?:[{_$a-zA-Z\xA0-\uFFFF]|$))/,/\btype\b(?=\s*(?:[\{*]|$))/),delete e.languages.typescript.parameter,delete e.languages.typescript["literal-property"];var t=e.languages.extend("typescript",{});delete t["class-name"],e.languages.typescript["class-name"].inside=t,e.languages.insertBefore("typescript","function",{decorator:{pattern:/@[$\w\xA0-\uFFFF]+/,inside:{at:{pattern:/^@/,alias:"operator"},function:/^[\s\S]+/}},"generic-function":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\s*\()/,greedy:!0,inside:{function:/^#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:t}}}}),e.languages.ts=e.languages.typescript}(r),function(e){function t(e,t){return RegExp(e.replace(//g,(function(){return/(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*/.source})),t)}e.languages.insertBefore("javascript","function-variable",{"method-variable":{pattern:RegExp("(\\.\\s*)"+e.languages.javascript["function-variable"].pattern.source),lookbehind:!0,alias:["function-variable","method","function","property-access"]}}),e.languages.insertBefore("javascript","function",{method:{pattern:RegExp("(\\.\\s*)"+e.languages.javascript.function.source),lookbehind:!0,alias:["function","property-access"]}}),e.languages.insertBefore("javascript","constant",{"known-class-name":[{pattern:/\b(?:(?:Float(?:32|64)|(?:Int|Uint)(?:8|16|32)|Uint8Clamped)?Array|ArrayBuffer|BigInt|Boolean|DataView|Date|Error|Function|Intl|JSON|(?:Weak)?(?:Map|Set)|Math|Number|Object|Promise|Proxy|Reflect|RegExp|String|Symbol|WebAssembly)\b/,alias:"class-name"},{pattern:/\b(?:[A-Z]\w*)Error\b/,alias:"class-name"}]}),e.languages.insertBefore("javascript","keyword",{imports:{pattern:t(/(\bimport\b\s*)(?:(?:\s*,\s*(?:\*\s*as\s+|\{[^{}]*\}))?|\*\s*as\s+|\{[^{}]*\})(?=\s*\bfrom\b)/.source),lookbehind:!0,inside:e.languages.javascript},exports:{pattern:t(/(\bexport\b\s*)(?:\*(?:\s*as\s+)?(?=\s*\bfrom\b)|\{[^{}]*\})/.source),lookbehind:!0,inside:e.languages.javascript}}),e.languages.javascript.keyword.unshift({pattern:/\b(?:as|default|export|from|import)\b/,alias:"module"},{pattern:/\b(?:await|break|catch|continue|do|else|finally|for|if|return|switch|throw|try|while|yield)\b/,alias:"control-flow"},{pattern:/\bnull\b/,alias:["null","nil"]},{pattern:/\bundefined\b/,alias:"nil"}),e.languages.insertBefore("javascript","operator",{spread:{pattern:/\.{3}/,alias:"operator"},arrow:{pattern:/=>/,alias:"operator"}}),e.languages.insertBefore("javascript","punctuation",{"property-access":{pattern:t(/(\.\s*)#?/.source),lookbehind:!0},"maybe-class-name":{pattern:/(^|[^$\w\xA0-\uFFFF])[A-Z][$\w\xA0-\uFFFF]+/,lookbehind:!0},dom:{pattern:/\b(?:document|(?:local|session)Storage|location|navigator|performance|window)\b/,alias:"variable"},console:{pattern:/\bconsole(?=\s*\.)/,alias:"class-name"}});for(var n=["function","function-variable","method","method-variable","property-access"],a=0;a*\.{3}(?:[^{}]|)*\})/.source;function o(e,t){return e=e.replace(//g,(function(){return n})).replace(//g,(function(){return a})).replace(//g,(function(){return r})),RegExp(e,t)}r=o(r).source,e.languages.jsx=e.languages.extend("markup",t),e.languages.jsx.tag.pattern=o(/<\/?(?:[\w.:-]+(?:+(?:[\w.:$-]+(?:=(?:"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*'|[^\s{'"/>=]+|))?|))**\/?)?>/.source),e.languages.jsx.tag.inside.tag.pattern=/^<\/?[^\s>\/]*/,e.languages.jsx.tag.inside["attr-value"].pattern=/=(?!\{)(?:"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*'|[^\s'">]+)/,e.languages.jsx.tag.inside.tag.inside["class-name"]=/^[A-Z]\w*(?:\.[A-Z]\w*)*$/,e.languages.jsx.tag.inside.comment=t.comment,e.languages.insertBefore("inside","attr-name",{spread:{pattern:o(//.source),inside:e.languages.jsx}},e.languages.jsx.tag),e.languages.insertBefore("inside","special-attr",{script:{pattern:o(/=/.source),alias:"language-javascript",inside:{"script-punctuation":{pattern:/^=(?=\{)/,alias:"punctuation"},rest:e.languages.jsx}}},e.languages.jsx.tag);var i=function(e){return e?"string"==typeof e?e:"string"==typeof e.content?e.content:e.content.map(i).join(""):""},l=function(t){for(var n=[],a=0;a0&&n[n.length-1].tagName===i(r.content[0].content[1])&&n.pop():"/>"===r.content[r.content.length-1].content||n.push({tagName:i(r.content[0].content[1]),openedBraces:0}):n.length>0&&"punctuation"===r.type&&"{"===r.content?n[n.length-1].openedBraces++:n.length>0&&n[n.length-1].openedBraces>0&&"punctuation"===r.type&&"}"===r.content?n[n.length-1].openedBraces--:o=!0),(o||"string"==typeof r)&&n.length>0&&0===n[n.length-1].openedBraces){var s=i(r);a0&&("string"==typeof t[a-1]||"plain-text"===t[a-1].type)&&(s=i(t[a-1])+s,t.splice(a-1,1),a--),t[a]=new e.Token("plain-text",s,null,s)}r.content&&"string"!=typeof r.content&&l(r.content)}};e.hooks.add("after-tokenize",(function(e){"jsx"!==e.language&&"tsx"!==e.language||l(e.tokens)}))}(r),function(e){e.languages.diff={coord:[/^(?:\*{3}|-{3}|\+{3}).*$/m,/^@@.*@@$/m,/^\d.*$/m]};var t={"deleted-sign":"-","deleted-arrow":"<","inserted-sign":"+","inserted-arrow":">",unchanged:" ",diff:"!"};Object.keys(t).forEach((function(n){var a=t[n],r=[];/^\w+$/.test(n)||r.push(/\w+/.exec(n)[0]),"diff"===n&&r.push("bold"),e.languages.diff[n]={pattern:RegExp("^(?:["+a+"].*(?:\r\n?|\n|(?![\\s\\S])))+","m"),alias:r,inside:{line:{pattern:/(.)(?=[\s\S]).*(?:\r\n?|\n)?/,lookbehind:!0},prefix:{pattern:/[\s\S]/,alias:/\w+/.exec(n)[0]}}}})),Object.defineProperty(e.languages.diff,"PREFIXES",{value:t})}(r),r.languages.git={comment:/^#.*/m,deleted:/^[-\u2013].*/m,inserted:/^\+.*/m,string:/("|')(?:\\.|(?!\1)[^\\\r\n])*\1/,command:{pattern:/^.*\$ git .*$/m,inside:{parameter:/\s--?\w+/}},coord:/^@@.*@@$/m,"commit-sha1":/^commit \w{40}$/m},r.languages.go=r.languages.extend("clike",{string:{pattern:/(^|[^\\])"(?:\\.|[^"\\\r\n])*"|`[^`]*`/,lookbehind:!0,greedy:!0},keyword:/\b(?:break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go(?:to)?|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/,boolean:/\b(?:_|false|iota|nil|true)\b/,number:[/\b0(?:b[01_]+|o[0-7_]+)i?\b/i,/\b0x(?:[a-f\d_]+(?:\.[a-f\d_]*)?|\.[a-f\d_]+)(?:p[+-]?\d+(?:_\d+)*)?i?(?!\w)/i,/(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?[\d_]+)?i?(?!\w)/i],operator:/[*\/%^!=]=?|\+[=+]?|-[=-]?|\|[=|]?|&(?:=|&|\^=?)?|>(?:>=?|=)?|<(?:<=?|=|-)?|:=|\.\.\./,builtin:/\b(?:append|bool|byte|cap|close|complex|complex(?:64|128)|copy|delete|error|float(?:32|64)|u?int(?:8|16|32|64)?|imag|len|make|new|panic|print(?:ln)?|real|recover|rune|string|uintptr)\b/}),r.languages.insertBefore("go","string",{char:{pattern:/'(?:\\.|[^'\\\r\n]){0,10}'/,greedy:!0}}),delete r.languages.go["class-name"],function(e){function t(e,t){return"___"+e.toUpperCase()+t+"___"}Object.defineProperties(e.languages["markup-templating"]={},{buildPlaceholders:{value:function(n,a,r,o){if(n.language===a){var i=n.tokenStack=[];n.code=n.code.replace(r,(function(e){if("function"==typeof o&&!o(e))return e;for(var r,l=i.length;-1!==n.code.indexOf(r=t(a,l));)++l;return i[l]=e,r})),n.grammar=e.languages.markup}}},tokenizePlaceholders:{value:function(n,a){if(n.language===a&&n.tokenStack){n.grammar=e.languages[a];var r=0,o=Object.keys(n.tokenStack);!function i(l){for(var s=0;s=o.length);s++){var c=l[s];if("string"==typeof c||c.content&&"string"==typeof c.content){var u=o[r],d=n.tokenStack[u],f="string"==typeof c?c:c.content,p=t(a,u),g=f.indexOf(p);if(g>-1){++r;var m=f.substring(0,g),b=new e.Token(a,e.tokenize(d,n.grammar),"language-"+a,d),h=f.substring(g+p.length),v=[];m&&v.push.apply(v,i([m])),v.push(b),h&&v.push.apply(v,i([h])),"string"==typeof c?l.splice.apply(l,[s,1].concat(v)):c.content=v}}else c.content&&i(c.content)}return l}(n.tokens)}}}})}(r),function(e){e.languages.handlebars={comment:/\{\{![\s\S]*?\}\}/,delimiter:{pattern:/^\{\{\{?|\}\}\}?$/,alias:"punctuation"},string:/(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/,number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee][+-]?\d+)?/,boolean:/\b(?:false|true)\b/,block:{pattern:/^(\s*(?:~\s*)?)[#\/]\S+?(?=\s*(?:~\s*)?$|\s)/,lookbehind:!0,alias:"keyword"},brackets:{pattern:/\[[^\]]+\]/,inside:{punctuation:/\[|\]/,variable:/[\s\S]+/}},punctuation:/[!"#%&':()*+,.\/;<=>@\[\\\]^`{|}~]/,variable:/[^!"#%&'()*+,\/;<=>@\[\\\]^`{|}~\s]+/},e.hooks.add("before-tokenize",(function(t){e.languages["markup-templating"].buildPlaceholders(t,"handlebars",/\{\{\{[\s\S]+?\}\}\}|\{\{[\s\S]+?\}\}/g)})),e.hooks.add("after-tokenize",(function(t){e.languages["markup-templating"].tokenizePlaceholders(t,"handlebars")})),e.languages.hbs=e.languages.handlebars}(r),r.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},r.languages.webmanifest=r.languages.json,r.languages.less=r.languages.extend("css",{comment:[/\/\*[\s\S]*?\*\//,{pattern:/(^|[^\\])\/\/.*/,lookbehind:!0}],atrule:{pattern:/@[\w-](?:\((?:[^(){}]|\([^(){}]*\))*\)|[^(){};\s]|\s+(?!\s))*?(?=\s*\{)/,inside:{punctuation:/[:()]/}},selector:{pattern:/(?:@\{[\w-]+\}|[^{};\s@])(?:@\{[\w-]+\}|\((?:[^(){}]|\([^(){}]*\))*\)|[^(){};@\s]|\s+(?!\s))*?(?=\s*\{)/,inside:{variable:/@+[\w-]+/}},property:/(?:@\{[\w-]+\}|[\w-])+(?:\+_?)?(?=\s*:)/,operator:/[+\-*\/]/}),r.languages.insertBefore("less","property",{variable:[{pattern:/@[\w-]+\s*:/,inside:{punctuation:/:/}},/@@?[\w-]+/],"mixin-usage":{pattern:/([{;]\s*)[.#](?!\d)[\w-].*?(?=[(;])/,lookbehind:!0,alias:"function"}}),r.languages.makefile={comment:{pattern:/(^|[^\\])#(?:\\(?:\r\n|[\s\S])|[^\\\r\n])*/,lookbehind:!0},string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"builtin-target":{pattern:/\.[A-Z][^:#=\s]+(?=\s*:(?!=))/,alias:"builtin"},target:{pattern:/^(?:[^:=\s]|[ \t]+(?![\s:]))+(?=\s*:(?!=))/m,alias:"symbol",inside:{variable:/\$+(?:(?!\$)[^(){}:#=\s]+|(?=[({]))/}},variable:/\$+(?:(?!\$)[^(){}:#=\s]+|\([@*%<^+?][DF]\)|(?=[({]))/,keyword:/-include\b|\b(?:define|else|endef|endif|export|ifn?def|ifn?eq|include|override|private|sinclude|undefine|unexport|vpath)\b/,function:{pattern:/(\()(?:abspath|addsuffix|and|basename|call|dir|error|eval|file|filter(?:-out)?|findstring|firstword|flavor|foreach|guile|if|info|join|lastword|load|notdir|or|origin|patsubst|realpath|shell|sort|strip|subst|suffix|value|warning|wildcard|word(?:list|s)?)(?=[ \t])/,lookbehind:!0},operator:/(?:::|[?:+!])?=|[|@]/,punctuation:/[:;(){}]/},r.languages.objectivec=r.languages.extend("c",{string:{pattern:/@?"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},keyword:/\b(?:asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|in|inline|int|long|register|return|self|short|signed|sizeof|static|struct|super|switch|typedef|typeof|union|unsigned|void|volatile|while)\b|(?:@interface|@end|@implementation|@protocol|@class|@public|@protected|@private|@property|@try|@catch|@finally|@throw|@synthesize|@dynamic|@selector)\b/,operator:/-[->]?|\+\+?|!=?|<>?=?|==?|&&?|\|\|?|[~^%?*\/@]/}),delete r.languages.objectivec["class-name"],r.languages.objc=r.languages.objectivec,r.languages.ocaml={comment:{pattern:/\(\*[\s\S]*?\*\)/,greedy:!0},char:{pattern:/'(?:[^\\\r\n']|\\(?:.|[ox]?[0-9a-f]{1,3}))'/i,greedy:!0},string:[{pattern:/"(?:\\(?:[\s\S]|\r\n)|[^\\\r\n"])*"/,greedy:!0},{pattern:/\{([a-z_]*)\|[\s\S]*?\|\1\}/,greedy:!0}],number:[/\b(?:0b[01][01_]*|0o[0-7][0-7_]*)\b/i,/\b0x[a-f0-9][a-f0-9_]*(?:\.[a-f0-9_]*)?(?:p[+-]?\d[\d_]*)?(?!\w)/i,/\b\d[\d_]*(?:\.[\d_]*)?(?:e[+-]?\d[\d_]*)?(?!\w)/i],directive:{pattern:/\B#\w+/,alias:"property"},label:{pattern:/\B~\w+/,alias:"property"},"type-variable":{pattern:/\B'\w+/,alias:"function"},variant:{pattern:/`\w+/,alias:"symbol"},keyword:/\b(?:as|assert|begin|class|constraint|do|done|downto|else|end|exception|external|for|fun|function|functor|if|in|include|inherit|initializer|lazy|let|match|method|module|mutable|new|nonrec|object|of|open|private|rec|sig|struct|then|to|try|type|val|value|virtual|when|where|while|with)\b/,boolean:/\b(?:false|true)\b/,"operator-like-punctuation":{pattern:/\[[<>|]|[>|]\]|\{<|>\}/,alias:"punctuation"},operator:/\.[.~]|:[=>]|[=<>@^|&+\-*\/$%!?~][!$%&*+\-.\/:<=>?@^|~]*|\b(?:and|asr|land|lor|lsl|lsr|lxor|mod|or)\b/,punctuation:/;;|::|[(){}\[\].,:;#]|\b_\b/},r.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/,lookbehind:!0,inside:{"format-spec":{pattern:/(:)[^:(){}]+(?=\}$)/,lookbehind:!0},"conversion-option":{pattern:/![sra](?=[:}]$)/,alias:"punctuation"},rest:null}},string:/[\s\S]+/}},"triple-quoted-string":{pattern:/(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i,greedy:!0,alias:"string"},string:{pattern:/(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i,greedy:!0},function:{pattern:/((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/g,lookbehind:!0},"class-name":{pattern:/(\bclass\s+)\w+/i,lookbehind:!0},decorator:{pattern:/(^[\t ]*)@\w+(?:\.\w+)*/m,lookbehind:!0,alias:["annotation","punctuation"],inside:{punctuation:/\./}},keyword:/\b(?:_(?=\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,builtin:/\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\b/,boolean:/\b(?:False|None|True)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},r.languages.python["string-interpolation"].inside.interpolation.inside.rest=r.languages.python,r.languages.py=r.languages.python,r.languages.reason=r.languages.extend("clike",{string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^\\\r\n"])*"/,greedy:!0},"class-name":/\b[A-Z]\w*/,keyword:/\b(?:and|as|assert|begin|class|constraint|do|done|downto|else|end|exception|external|for|fun|function|functor|if|in|include|inherit|initializer|lazy|let|method|module|mutable|new|nonrec|object|of|open|or|private|rec|sig|struct|switch|then|to|try|type|val|virtual|when|while|with)\b/,operator:/\.{3}|:[:=]|\|>|->|=(?:==?|>)?|<=?|>=?|[|^?'#!~`]|[+\-*\/]\.?|\b(?:asr|land|lor|lsl|lsr|lxor|mod)\b/}),r.languages.insertBefore("reason","class-name",{char:{pattern:/'(?:\\x[\da-f]{2}|\\o[0-3][0-7][0-7]|\\\d{3}|\\.|[^'\\\r\n])'/,greedy:!0},constructor:/\b[A-Z]\w*\b(?!\s*\.)/,label:{pattern:/\b[a-z]\w*(?=::)/,alias:"symbol"}}),delete r.languages.reason.function,function(e){e.languages.sass=e.languages.extend("css",{comment:{pattern:/^([ \t]*)\/[\/*].*(?:(?:\r?\n|\r)\1[ \t].+)*/m,lookbehind:!0,greedy:!0}}),e.languages.insertBefore("sass","atrule",{"atrule-line":{pattern:/^(?:[ \t]*)[@+=].+/m,greedy:!0,inside:{atrule:/(?:@[\w-]+|[+=])/}}}),delete e.languages.sass.atrule;var t=/\$[-\w]+|#\{\$[-\w]+\}/,n=[/[+*\/%]|[=!]=|<=?|>=?|\b(?:and|not|or)\b/,{pattern:/(\s)-(?=\s)/,lookbehind:!0}];e.languages.insertBefore("sass","property",{"variable-line":{pattern:/^[ \t]*\$.+/m,greedy:!0,inside:{punctuation:/:/,variable:t,operator:n}},"property-line":{pattern:/^[ \t]*(?:[^:\s]+ *:.*|:[^:\s].*)/m,greedy:!0,inside:{property:[/[^:\s]+(?=\s*:)/,{pattern:/(:)[^:\s]+/,lookbehind:!0}],punctuation:/:/,variable:t,operator:n,important:e.languages.sass.important}}}),delete e.languages.sass.property,delete e.languages.sass.important,e.languages.insertBefore("sass","punctuation",{selector:{pattern:/^([ \t]*)\S(?:,[^,\r\n]+|[^,\r\n]*)(?:,[^,\r\n]+)*(?:,(?:\r?\n|\r)\1[ \t]+\S(?:,[^,\r\n]+|[^,\r\n]*)(?:,[^,\r\n]+)*)*/m,lookbehind:!0,greedy:!0}})}(r),r.languages.scss=r.languages.extend("css",{comment:{pattern:/(^|[^\\])(?:\/\*[\s\S]*?\*\/|\/\/.*)/,lookbehind:!0},atrule:{pattern:/@[\w-](?:\([^()]+\)|[^()\s]|\s+(?!\s))*?(?=\s+[{;])/,inside:{rule:/@[\w-]+/}},url:/(?:[-a-z]+-)?url(?=\()/i,selector:{pattern:/(?=\S)[^@;{}()]?(?:[^@;{}()\s]|\s+(?!\s)|#\{\$[-\w]+\})+(?=\s*\{(?:\}|\s|[^}][^:{}]*[:{][^}]))/,inside:{parent:{pattern:/&/,alias:"important"},placeholder:/%[-\w]+/,variable:/\$[-\w]+|#\{\$[-\w]+\}/}},property:{pattern:/(?:[-\w]|\$[-\w]|#\{\$[-\w]+\})+(?=\s*:)/,inside:{variable:/\$[-\w]+|#\{\$[-\w]+\}/}}}),r.languages.insertBefore("scss","atrule",{keyword:[/@(?:content|debug|each|else(?: if)?|extend|for|forward|function|if|import|include|mixin|return|use|warn|while)\b/i,{pattern:/( )(?:from|through)(?= )/,lookbehind:!0}]}),r.languages.insertBefore("scss","important",{variable:/\$[-\w]+|#\{\$[-\w]+\}/}),r.languages.insertBefore("scss","function",{"module-modifier":{pattern:/\b(?:as|hide|show|with)\b/i,alias:"keyword"},placeholder:{pattern:/%[-\w]+/,alias:"selector"},statement:{pattern:/\B!(?:default|optional)\b/i,alias:"keyword"},boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"},operator:{pattern:/(\s)(?:[-+*\/%]|[=!]=|<=?|>=?|and|not|or)(?=\s)/,lookbehind:!0}}),r.languages.scss.atrule.inside.rest=r.languages.scss,function(e){var t={pattern:/(\b\d+)(?:%|[a-z]+)/,lookbehind:!0},n={pattern:/(^|[^\w.-])-?(?:\d+(?:\.\d+)?|\.\d+)/,lookbehind:!0},a={comment:{pattern:/(^|[^\\])(?:\/\*[\s\S]*?\*\/|\/\/.*)/,lookbehind:!0},url:{pattern:/\burl\((["']?).*?\1\)/i,greedy:!0},string:{pattern:/("|')(?:(?!\1)[^\\\r\n]|\\(?:\r\n|[\s\S]))*\1/,greedy:!0},interpolation:null,func:null,important:/\B!(?:important|optional)\b/i,keyword:{pattern:/(^|\s+)(?:(?:else|for|if|return|unless)(?=\s|$)|@[\w-]+)/,lookbehind:!0},hexcode:/#[\da-f]{3,6}/i,color:[/\b(?:AliceBlue|AntiqueWhite|Aqua|Aquamarine|Azure|Beige|Bisque|Black|BlanchedAlmond|Blue|BlueViolet|Brown|BurlyWood|CadetBlue|Chartreuse|Chocolate|Coral|CornflowerBlue|Cornsilk|Crimson|Cyan|DarkBlue|DarkCyan|DarkGoldenRod|DarkGr[ae]y|DarkGreen|DarkKhaki|DarkMagenta|DarkOliveGreen|DarkOrange|DarkOrchid|DarkRed|DarkSalmon|DarkSeaGreen|DarkSlateBlue|DarkSlateGr[ae]y|DarkTurquoise|DarkViolet|DeepPink|DeepSkyBlue|DimGr[ae]y|DodgerBlue|FireBrick|FloralWhite|ForestGreen|Fuchsia|Gainsboro|GhostWhite|Gold|GoldenRod|Gr[ae]y|Green|GreenYellow|HoneyDew|HotPink|IndianRed|Indigo|Ivory|Khaki|Lavender|LavenderBlush|LawnGreen|LemonChiffon|LightBlue|LightCoral|LightCyan|LightGoldenRodYellow|LightGr[ae]y|LightGreen|LightPink|LightSalmon|LightSeaGreen|LightSkyBlue|LightSlateGr[ae]y|LightSteelBlue|LightYellow|Lime|LimeGreen|Linen|Magenta|Maroon|MediumAquaMarine|MediumBlue|MediumOrchid|MediumPurple|MediumSeaGreen|MediumSlateBlue|MediumSpringGreen|MediumTurquoise|MediumVioletRed|MidnightBlue|MintCream|MistyRose|Moccasin|NavajoWhite|Navy|OldLace|Olive|OliveDrab|Orange|OrangeRed|Orchid|PaleGoldenRod|PaleGreen|PaleTurquoise|PaleVioletRed|PapayaWhip|PeachPuff|Peru|Pink|Plum|PowderBlue|Purple|Red|RosyBrown|RoyalBlue|SaddleBrown|Salmon|SandyBrown|SeaGreen|SeaShell|Sienna|Silver|SkyBlue|SlateBlue|SlateGr[ae]y|Snow|SpringGreen|SteelBlue|Tan|Teal|Thistle|Tomato|Transparent|Turquoise|Violet|Wheat|White|WhiteSmoke|Yellow|YellowGreen)\b/i,{pattern:/\b(?:hsl|rgb)\(\s*\d{1,3}\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*\)\B|\b(?:hsl|rgb)a\(\s*\d{1,3}\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*,\s*(?:0|0?\.\d+|1)\s*\)\B/i,inside:{unit:t,number:n,function:/[\w-]+(?=\()/,punctuation:/[(),]/}}],entity:/\\[\da-f]{1,8}/i,unit:t,boolean:/\b(?:false|true)\b/,operator:[/~|[+!\/%<>?=]=?|[-:]=|\*[*=]?|\.{2,3}|&&|\|\||\B-\B|\b(?:and|in|is(?: a| defined| not|nt)?|not|or)\b/],number:n,punctuation:/[{}()\[\];:,]/};a.interpolation={pattern:/\{[^\r\n}:]+\}/,alias:"variable",inside:{delimiter:{pattern:/^\{|\}$/,alias:"punctuation"},rest:a}},a.func={pattern:/[\w-]+\([^)]*\).*/,inside:{function:/^[^(]+/,rest:a}},e.languages.stylus={"atrule-declaration":{pattern:/(^[ \t]*)@.+/m,lookbehind:!0,inside:{atrule:/^@[\w-]+/,rest:a}},"variable-declaration":{pattern:/(^[ \t]*)[\w$-]+\s*.?=[ \t]*(?:\{[^{}]*\}|\S.*|$)/m,lookbehind:!0,inside:{variable:/^\S+/,rest:a}},statement:{pattern:/(^[ \t]*)(?:else|for|if|return|unless)[ \t].+/m,lookbehind:!0,inside:{keyword:/^\S+/,rest:a}},"property-declaration":{pattern:/((?:^|\{)([ \t]*))(?:[\w-]|\{[^}\r\n]+\})+(?:\s*:\s*|[ \t]+)(?!\s)[^{\r\n]*(?:;|[^{\r\n,]$(?!(?:\r?\n|\r)(?:\{|\2[ \t])))/m,lookbehind:!0,inside:{property:{pattern:/^[^\s:]+/,inside:{interpolation:a.interpolation}},rest:a}},selector:{pattern:/(^[ \t]*)(?:(?=\S)(?:[^{}\r\n:()]|::?[\w-]+(?:\([^)\r\n]*\)|(?![\w-]))|\{[^}\r\n]+\})+)(?:(?:\r?\n|\r)(?:\1(?:(?=\S)(?:[^{}\r\n:()]|::?[\w-]+(?:\([^)\r\n]*\)|(?![\w-]))|\{[^}\r\n]+\})+)))*(?:,$|\{|(?=(?:\r?\n|\r)(?:\{|\1[ \t])))/m,lookbehind:!0,inside:{interpolation:a.interpolation,comment:a.comment,punctuation:/[{},]/}},func:a.func,string:a.string,comment:{pattern:/(^|[^\\])(?:\/\*[\s\S]*?\*\/|\/\/.*)/,lookbehind:!0,greedy:!0},interpolation:a.interpolation,punctuation:/[{}()\[\];:.]/}}(r),function(e){var t=e.util.clone(e.languages.typescript);e.languages.tsx=e.languages.extend("jsx",t),delete e.languages.tsx.parameter,delete e.languages.tsx["literal-property"];var n=e.languages.tsx.tag;n.pattern=RegExp(/(^|[^\w$]|(?=<\/))/.source+"(?:"+n.pattern.source+")",n.pattern.flags),n.lookbehind=!0}(r),r.languages.wasm={comment:[/\(;[\s\S]*?;\)/,{pattern:/;;.*/,greedy:!0}],string:{pattern:/"(?:\\[\s\S]|[^"\\])*"/,greedy:!0},keyword:[{pattern:/\b(?:align|offset)=/,inside:{operator:/=/}},{pattern:/\b(?:(?:f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|neg?|nearest|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|sqrt|store(?:8|16|32)?|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))?|memory\.(?:grow|size))\b/,inside:{punctuation:/\./}},/\b(?:anyfunc|block|br(?:_if|_table)?|call(?:_indirect)?|data|drop|elem|else|end|export|func|get_(?:global|local)|global|if|import|local|loop|memory|module|mut|nop|offset|param|result|return|select|set_(?:global|local)|start|table|tee_local|then|type|unreachable)\b/],variable:/\$[\w!#$%&'*+\-./:<=>?@\\^`|~]+/,number:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/,punctuation:/[()]/};const o=r},52503:()=>{!function(e){var t=/\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\s*[(){}[\]<>=%~.:,;?+\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/,n=/(?:[a-z]\w*\s*\.\s*)*(?:[A-Z]\w*\s*\.\s*)*/.source,a={pattern:RegExp(/(^|[^\w.])/.source+n+/[A-Z](?:[\d_A-Z]*[a-z]\w*)?\b/.source),lookbehind:!0,inside:{namespace:{pattern:/^[a-z]\w*(?:\s*\.\s*[a-z]\w*)*(?:\s*\.)?/,inside:{punctuation:/\./}},punctuation:/\./}};e.languages.java=e.languages.extend("clike",{string:{pattern:/(^|[^\\])"(?:\\.|[^"\\\r\n])*"/,lookbehind:!0,greedy:!0},"class-name":[a,{pattern:RegExp(/(^|[^\w.])/.source+n+/[A-Z]\w*(?=\s+\w+\s*[;,=()]|\s*(?:\[[\s,]*\]\s*)?::\s*new\b)/.source),lookbehind:!0,inside:a.inside},{pattern:RegExp(/(\b(?:class|enum|extends|implements|instanceof|interface|new|record|throws)\s+)/.source+n+/[A-Z]\w*\b/.source),lookbehind:!0,inside:a.inside}],keyword:t,function:[e.languages.clike.function,{pattern:/(::\s*)[a-z_]\w*/,lookbehind:!0}],number:/\b0b[01][01_]*L?\b|\b0x(?:\.[\da-f_p+-]+|[\da-f_]+(?:\.[\da-f_p+-]+)?)\b|(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i,operator:{pattern:/(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m,lookbehind:!0},constant:/\b[A-Z][A-Z_\d]+\b/}),e.languages.insertBefore("java","string",{"triple-quoted-string":{pattern:/"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/,greedy:!0,alias:"string"},char:{pattern:/'(?:\\.|[^'\\\r\n]){1,6}'/,greedy:!0}}),e.languages.insertBefore("java","class-name",{annotation:{pattern:/(^|[^.])@\w+(?:\s*\.\s*\w+)*/,lookbehind:!0,alias:"punctuation"},generics:{pattern:/<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&))*>)*>)*>)*>/,inside:{"class-name":a,keyword:t,punctuation:/[<>(),.:]/,operator:/[?&|]/}},import:[{pattern:RegExp(/(\bimport\s+)/.source+n+/(?:[A-Z]\w*|\*)(?=\s*;)/.source),lookbehind:!0,inside:{namespace:a.inside.namespace,punctuation:/\./,operator:/\*/,"class-name":/\w+/}},{pattern:RegExp(/(\bimport\s+static\s+)/.source+n+/(?:\w+|\*)(?=\s*;)/.source),lookbehind:!0,alias:"static",inside:{namespace:a.inside.namespace,static:/\b\w+$/,punctuation:/\./,operator:/\*/,"class-name":/\w+/}}],namespace:{pattern:RegExp(/(\b(?:exports|import(?:\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\s+)(?!)[a-z]\w*(?:\.[a-z]\w*)*\.?/.source.replace(//g,(function(){return t.source}))),lookbehind:!0,inside:{punctuation:/\./}}})}(Prism)},32334:()=>{!function(e){e.languages.kotlin=e.languages.extend("clike",{keyword:{pattern:/(^|[^.])\b(?:abstract|actual|annotation|as|break|by|catch|class|companion|const|constructor|continue|crossinline|data|do|dynamic|else|enum|expect|external|final|finally|for|fun|get|if|import|in|infix|init|inline|inner|interface|internal|is|lateinit|noinline|null|object|open|operator|out|override|package|private|protected|public|reified|return|sealed|set|super|suspend|tailrec|this|throw|to|try|typealias|val|var|vararg|when|where|while)\b/,lookbehind:!0},function:[{pattern:/(?:`[^\r\n`]+`|\b\w+)(?=\s*\()/,greedy:!0},{pattern:/(\.)(?:`[^\r\n`]+`|\w+)(?=\s*\{)/,lookbehind:!0,greedy:!0}],number:/\b(?:0[xX][\da-fA-F]+(?:_[\da-fA-F]+)*|0[bB][01]+(?:_[01]+)*|\d+(?:_\d+)*(?:\.\d+(?:_\d+)*)?(?:[eE][+-]?\d+(?:_\d+)*)?[fFL]?)\b/,operator:/\+[+=]?|-[-=>]?|==?=?|!(?:!|==?)?|[\/*%<>]=?|[?:]:?|\.\.|&&|\|\||\b(?:and|inv|or|shl|shr|ushr|xor)\b/}),delete e.languages.kotlin["class-name"];var t={"interpolation-punctuation":{pattern:/^\$\{?|\}$/,alias:"punctuation"},expression:{pattern:/[\s\S]+/,inside:e.languages.kotlin}};e.languages.insertBefore("kotlin","string",{"string-literal":[{pattern:/"""(?:[^$]|\$(?:(?!\{)|\{[^{}]*\}))*?"""/,alias:"multiline",inside:{interpolation:{pattern:/\$(?:[a-z_]\w*|\{[^{}]*\})/i,inside:t},string:/[\s\S]+/}},{pattern:/"(?:[^"\\\r\n$]|\\.|\$(?:(?!\{)|\{[^{}]*\}))*"/,alias:"singleline",inside:{interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$(?:[a-z_]\w*|\{[^{}]*\})/i,lookbehind:!0,inside:t},string:/[\s\S]+/}}],char:{pattern:/'(?:[^'\\\r\n]|\\(?:.|u[a-fA-F0-9]{0,4}))'/,greedy:!0}}),delete e.languages.kotlin.string,e.languages.insertBefore("kotlin","keyword",{annotation:{pattern:/\B@(?:\w+:)?(?:[A-Z]\w*|\[[^\]]+\])/,alias:"builtin"}}),e.languages.insertBefore("kotlin","function",{label:{pattern:/\b\w+@|@\w+\b/,alias:"symbol"}}),e.languages.kt=e.languages.kotlin,e.languages.kts=e.languages.kotlin}(Prism)},78823:(e,t,n)=>{var a={"./prism-java":52503,"./prism-kotlin":32334};function r(e){var t=o(e);return n(t)}function o(e){if(!n.o(a,e)){var t=new Error("Cannot find module '"+e+"'");throw t.code="MODULE_NOT_FOUND",t}return a[e]}r.keys=function(){return Object.keys(a)},r.resolve=o,e.exports=r,r.id=78823},92703:(e,t,n)=>{"use strict";var a=n(50414);function r(){}function o(){}o.resetWarningCache=r,e.exports=function(){function e(e,t,n,r,o,i){if(i!==a){var l=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw l.name="Invariant Violation",l}}function t(){return e}e.isRequired=e;var n={array:e,bigint:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:o,resetWarningCache:r};return n.PropTypes=n,n}},45697:(e,t,n)=>{e.exports=n(92703)()},50414:e=>{"use strict";e.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},64448:(e,t,n)=>{"use strict";var a=n(67294),r=n(27418),o=n(63840);function i(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n
    + + \ No newline at end of file diff --git a/page/11.html b/page/11.html index af218ff6..8bfbbe03 100644 --- a/page/11.html +++ b/page/11.html @@ -5,17 +5,16 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 3분

    레벨3 때 프로젝트를 진행하면서, 저희 팀은 많은 협업을 진행했습니다.

    처음에는 프론트엔드, 백엔드 서로 각각의 분야만 개발을 해왔고 협업이 익숙하지 않아서 많은 부분에서 문제가 발생하곤 했습니다.

    이런 과정에서 저희 팀은 어떻게 대처를 했을까요?

    한 가지 일화로 저희 팀의 제이와 센트의 필터 적용 부분을 설명 드리겠습니다.

    조회 시에 필터 적용 부분을 만들 때 기존에 작성해둔 API 명세대로 서로 작업을 진행하고, 중간에 생각하지 못한 부분에 대해서는 서로 대화를 많이 했습니다. -대화를 하면서 진행을 했지만 발견하지 못한 문제점이 있었습니다.

    바로 충전소 회사 명에서 key 값을 어떻게 하냐에 문제였습니다. -예를 들면 충전소 회사 명에서 광주시라는 이름이 있었는데, 이 필터는 실제로 두 가지가 존재했습니다.

    하나는 경기도 광주, 하나는 전라도 광주였습니다.

    이런 부분에서 불필요한 지역의 필터까지 걸리게 되는 문제가 있었습니다. -협업하는 과정에서 이를 발견했고, 즉각 조치를 취했습니다.

    조치를 취할 때 서로에게 각자 편한 방법이 있었지만, -단순히 서로에게 편한 작업을 하지 않았고, 팀원과 상의하면서 추후 진행에 문제 없는 방향을 찾고 진행할 수 있었습니다.

    지금 생각해보면 만약 각자에게 편한 방식으로 문제를 수정했다면, 다른 팀원이 다른 작업을 할 때 지장이 갔을 수도 있고 불필요한 작업을 했을 수도 있었을 것 같습니다.

    이 시점을 계기로 저희 팀끼리 예상하지 못한 문제를 작업 중에 발견하더라도 다른 팀원에게 공유하고 서로 짧은 회의를 통해 문제 해결 방안을 같이 찾는 것이 자연스럽게 팀문화로 자리 잡게 되었습니다.

    - - +

    · 약 14분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 카페인 팀 프로젝트에는 사용자가 보고있는 지도에 충전소를 보여주는 조회 기능이 가장 중요하고, 제일 요청이 많이 들어옵니다.

    하지만 조회 성능이 좋지 않은 까닭인지 여러 사용자가 접속하면 아래와 같이 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. +cpu

    조회 성능 개선하기

    먼저 제가 개선하기 위해 사용했던 방법들에 대해 적어보겠습니다.

    DTO 이용하기

    현재 구조는 아래의 JPA를 이용해 아래와 같은 쿼리로 entity로 데이터를 조회합니다.

     select distinct station.station_id,
    charger.charger_id,
    charger.station_id,
    chargerStatus.charger_id,
    chargerStatus.station_id,
    station.created_at,
    station.updated_at,
    station.address,
    station.company_name,
    station.contact,
    station.detail_location,
    station.is_parking_free,
    station.is_private,
    station.latitude,
    station.longitude,
    station.operating_time,
    station.private_reason,
    station.station_name,
    station.station_state,
    charger.created_at,
    charger.updated_at,
    charger.capacity,
    charger.method,
    charger.price,
    charger.type,
    charger.station_id,
    charger.charger_id,
    chargerStatus.created_at,
    chargerStatus.updated_at,
    chargerStatus.charger_condition,
    chargerStatus.latest_update_time
    from charge_station station
    inner join
    charger charger on station.station_id = charger.station_id
    inner join
    charger_status chargerStatus on charger.charger_id = chargerStatus.charger_id
    and charger.station_id = chargerStatus.station_id
    where station.latitude >= 37.5019194727953082567
    and station.latitude <= 37.5092305272047217433
    and station.longitude >= 127.044542269049714936
    and station.longitude <= 127.058071330950285064

    JPA를 통해 이러한 방식으로 조회한다면 아주 편하게 값을 가져오고, fetch join을 통해 하위의 entity들의 정보도 깔끔하게 가져옵니다.

    가져온 값으로 필요한 정보들을 매핑하고 가공하여 응답을 내려줬습니다.

    하지만 조회만을 위해 JPA의 entity를 조회한다는 것은 여러 단점이 존재합니다.

    제일 먼저 응답을 내려줄 때 불필요한 데이터까지 모두 조회를 한다는 부분입니다. +이렇게 많은 필드들이 있습니다. 하지만 응답에서는 대부분의 경우 모든 정보가 필요하지 않습니다. 그리고 모든 정보를 다 보내주는 것도 좋지 않습니다. 대량의 데이터를 조회할 때의 성능이 아주 나빠집니다.

    그래서 필요한 칼럼만 조회하는 것이 좋습니다.

    그리고 또 다른 단점으로는 JPA로 entity를 조회할 때 Hibernate 캐시에 저장한다던가, One To One 에서 N+1 쿼리가 발생하기 때문에 성능적인 이슈가 여러가지 있습니다.

    그래서 조회만 하는 api라면 DTO Projection으로 하는 것이 좋을 것 같습니다. +그리고 아래와 같이 변경하였습니다.

    SELECT s.station_id,
    s.station_name,
    s.latitude,
    s.longitude,
    s.is_parking_free,
    s.is_private,
    sum(case
    when cs.charger_condition = 'STANDBY' then 1
    else 0
    end),
    sum(case
    when c.capacity >= 50 then 1
    else 0
    end)
    FROM charge_station s
    inner join charger c on (c.station_id = s.station_id)
    inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)
    where s.station_id in (?, ?)
    group by s.station_id;

    이렇게 필요한 칼럼만 조회하는 방식으로 변경하여, 선릉역 근처를 조회하는 기준으로 약 450ms -> 350ms로 개선되었습니다.

    하지만 아직도 너무 느린 것을 확인할 수 있습니다. 그래서 실행 계획을 확인했습니다.

    실행 계획 확인하기

    sql의 실행 계획은 아주 중요하고 성능을 개선할 때 아주 유용합니다.

    실행 계획에는 여러가지 정보들이 있습니다.

    1. ID: 실행 계획 내에서 각 작업 또는 단계를 식별하는 일련번호입니다. 실행 계획은 여러 단계로 나뉘며, ID를 통해 이러한 단계를 식별할 수 있습니다.

    2. Select Type: 쿼리의 각 단계(예: SIMPLE, PRIMARY, SUBQUERY)에 대한 실행 유형을 나타냅니다. 이는 MySQL이 데이터를 선택하고 처리하는 방식을 나타냅니다.

    3. Table: 실행 계획에 포함된 테이블의 이름 또는 별칭입니다. 어떤 테이블이 사용되는지를 확인할 수 있습니다.

    4. Type: 테이블 접근 방식을 나타냅니다. 이 값은 인덱스 스캔, 풀 테이블 스캔 등과 같은 값일 수 있으며, 성능에 큰 영향을 미칩니다.

    5. Possible Keys: 사용 가능한 인덱스를 나타냅니다. MySQL이 어떤 인덱스를 사용할 수 있는지 알려줍니다.

    6. Key: 실제로 선택된 인덱스입니다. 이 값은 가능한 인덱스 중에서 실제로 사용되는 인덱스를 나타냅니다.

    7. Key Len: 사용된 인덱스의 길이를 나타냅니다.

    8. Ref: 인덱스를 사용하여 테이블 간의 연결을 나타내는 열입니다.

    9. Rows: 각 단계에서 예상되는 행의 수입니다. 이 값은 성능 평가에 중요한 역할을 합니다.

    10. Extra: 기타 정보를 제공합니다. 이 칼럼에는 추가 정보 및 힌트가 포함될 수 있습니다.

    이렇게 여러 칼럼이 있습니다. 그 중 성능에 큰 영향을 미치는 칼럼 두 가지만 자세히 알아보겠습니다.

    Type

    1. const : 쿼리에 Primary key 혹은 unique key 칼럼을 이용하는 where 조건절을 가지고 있고, 반드시 하나의 데이터를 반환하는 방식이다. (옵티마이저가 해당 부분은 상수로 처리하기 때문에 const라고 한다.)
    2. eq_ref : 조인에서 Primary key 혹은 unique key 칼럼을 이용하는 where 조건절을 가지고 있고, 반드시 하나의 데이터를 반환하는 방식이다. (const와 다른 점은 eq_ref는 조인에서 사용된다는 점이다.)
    3. ref : eq_ref와 다르게 join의 순서와 관계없이 사용된다. 그리고 primary key, unique key도 관계없다. 그냥 인덱스의 종류와 관계없이 = 조건으로 검색할 때 사용된다
    4. fulltext: mysql 전문 검색 인덱스를 사용해서 레코드에 접근하는 방법, 전문 검색할 컬럼에 인덱스가 있어야 한다. "MATCH ... AGAINST ..." 구문을 사용해서 실행된다
    5. range: 인덱스를 이용해서 검색하는데, 검색 조건이 >, >=, <, <=, BETWEEN, IN() 등의 연산자를 사용하는 경우이다. 보통의 인덱스 스캔이라고 하면 range, const, ref를 칭한다
    6. index: 인덱스 풀 스캔이다. 인덱스를 이용해서 테이블의 모든 레코드를 읽는다. 인덱스를 이용해서 테이블을 읽는 것이기 때문에 all보다는 빠르다.
    7. all: 테이블 풀 스캔이다. 테이블의 모든 레코드를 읽는다. 가장 느린 방법이다.

    실행 계획에서 자주 보이는 type들만 성능이 좋은 순으로 정리해봤습니다.

    Extra

    1. using filesort: 정렬을 위해 별도의 파일 정렬을 수행한다. 이는 인덱스를 사용하지 않고 정렬을 수행한다는 의미이다. 이는 성능에 좋지 않다.
    2. using index: 인덱스만으로 쿼리를 처리한다. 이는 인덱스만으로 쿼리를 처리하기 때문에 성능이 좋다.
    3. using join buffer: join이 되는 칼럼은 인덱스를 생성한다. 하지만 driven table에 적절한 인덱스가 없다면 driving table에 있는 모든 레코드를 읽어서 join을 수행한다. 그래서 이걸 보완하기 위해 driving table에 읽은 레코드를 임시 공간에 저장하는데 그 곳이 join buffer이다.
    4. using temporary: 쿼리를 처리하기 위해 임시 테이블을 생성한다. 인덱스를 사용하지 못하는 group by 쿼리가 대표적인 예이다.
    5. using where: mysql 엔진이 별도의 가공, 필터링 작업을 처리한 경우일 때만 나타난다. 범위 조건은 스토리지 엔진에서 처리되어 레코드를 리턴해주지만, 체크 조건은 mysql 엔진에서 처리된다.

    type뿐만 아니라 extra도 쿼리의 문제를 파악하는데 아주 큰 도움을 줍니다. 그 중 자주 보이는 것들에 대해서만 정리해봤습니다.

    그럼 아까 생성한 쿼리의 실행 계획을 확인해봅시다.

    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+
    | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+
    | 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where; Using temporary |
    | 1 | SIMPLE | charger | NULL | ALL | PRIMARY | NULL | NULL | NULL | 240340 | 10.00 | Using where; Using join buffer (hash join) |
    | 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |
    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+

    station 테이블에 대해서는 range 스캔, 임시 테이블을 생성하고 있습니다, 그리고 charger에서는 테이블 풀 스캔, join buffer까지 생성하고 있습니다. 다행히도 chargersta 테이블에서는 적당한 조건을 생성한 것 같습니다.

    다시 한번 쿼리를 보고 실행 계획이 이렇게 나온 이유를 알아보겠습니다.

    SELECT
    ...
    FROM charge_station s
    inner join charger c on (c.station_id = s.station_id)
    inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)
    where s.station_id in (?, ?)
    group by s.station_id;

    아까 얘기했던, using temporary와 using join buffer가 발생하는 이유의 공통점을 찾아보면, 인덱스가 문제인 것을 유추할 수 있습니다.

    station과 charger를 join할 때, driven table 즉, charger 테이블에 적절한 인덱스가 없어 성능이 나빠진 것이라 의심하여, 인덱스를 생성하고 다시 한번 실행 계획을 확인했습니다.

    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+
    | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+
    | 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where |
    | 1 | SIMPLE | charger | NULL | ref | PRIMARY,idx_station_id | idx_station_id | 1022 | charge.s.station_id | 3 | 100.00 | NULL |
    | 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |
    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+

    이렇게 charger 테이블에 인덱스를 생성한 것만으로도 실행 계획을 깔끔하게 개선했습니다.

    결과

    아래는 인덱스를 생성하기 전 실행 속도입니다.

    개선_전

    아래는 인덱스를 생성한 후 실행 속도입니다.

    개선_후

    315ms -> 24ms 로 약 13배 빨라진 것을 확인할 수 있습니다.

    결론

    실행 계획 확인은 필수입니다!

    참고

    real mysql 책

    + + \ No newline at end of file diff --git a/page/12.html b/page/12.html index f01623bb..c538c5be 100644 --- a/page/12.html +++ b/page/12.html @@ -5,26 +5,17 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. -생성된 StateManager 인스턴스가 반환되어 store가 곧 초기 값을 가지는 StateManager가 됩니다.

    no offset

    예를 들어, 다음과 같은 코드가 있다고 할 때

    export const countStore = store<number>(0);

    countStore는 곧 0을 초기값으로 가지는 StateManager 인스턴스이기도 하게 됩니다.

    그러면 StateManager에 대해서 알아보겠습니다.

    StateManager는 react 바깥에 있는 어떤 저장소이다.

    (근데 이게 그냥 저장소는 아니고 좀 특별한 저장소다.)

    export type SetStateCallbackType<T> = (prevState: T) => T;

    export interface DataObserver<T> {
    setState: (param: SetStateCallbackType<T> | T) => void;
    getState: () => T;
    subscribe: (listener: () => void) => () => void;
    emitChange: () => void;
    }

    class StateManager<T> implements DataObserver<T> {
    public state: T;
    private listeners: Array<() => void> = [];

    constructor(initialState: T) {
    this.state = initialState;
    }

    setState = (param: SetStateCallbackType<T> | T) => {
    if (param instanceof Function) {
    const newState = param(this.state);
    this.state = newState;
    } else {
    this.state = param;
    }

    this.emitChange();
    };

    getState = () => {
    return this.state;
    };

    subscribe = (listener: () => void) => {
    this.listeners = [...this.listeners, listener];

    return () => {
    this.listeners = this.listeners.filter((l) => l !== listener);
    };
    };

    emitChange = () => {
    for (const listener of this.listeners) {
    listener();
    }
    };
    }

    export default StateManager;

    StateManager 클래스는 외부에서 받아온 초기값을 상태로 가집니다. -setState, getState, subscribe, emitChange를 메서드로 가집니다. -여기서 작성된 코드들은 react에서 외부 저장소와 소통하기 위한 최소한의 규격입니다.

    • subscribe: 단일 콜백 인수를 사용하여 스토어에 구독하는 함수입니다. 스토어가 변경되면 제공된 콜백을 호출해야 합니다. 그러면 구성 요소가 다시 렌더링 됩니다. 구독 기능은 구독을 정리하는 기능을 반환해야 합니다. (구독에 관련된 데이터는 리스너 배열 필드에 넣어서 관리합니다.)

    • emitChange: 리스너 배열 필드에 담겨있는 모든 리스너를 실행합니다. 즉, 구독된 어떤 것을 순차적으로 실행하게 합니다. 이는 리액트 DOM을 강제로 일깨워주는 옵저버 패턴의 역할을 하게 됩니다. 이 과정 때문에 react DOM이 정확한 재 렌더링 지점을 파악할 수 있게됩니다. (최적화 문제에서 자유로워짐)

    • setState: 상태를 업데이트합니다. 다만 상태가 업데이트 됐음을 알려야 하므로 emitChange를 실행시켜 react DOM을 강제로 동기화시킵니다.

    • getState: 호출되는 순간 현재 상태 값을 읽습니다.

    좀 어렵지만 리액트에서 이런 규격을 가져야 useSyncExternalStore훅을 쓸 수 있게 해 줍니다. -기존 예제에서는 단순한 자바스크립트 객체로 짜여있었지만 인스턴스를 자유롭게 찍어낼 수 있는 class 구조로 개선하고 추상화하였습니다.

    사실 여기까지만 구현해도 useSyncExternalStore를 사용하는데 지장이 없습니다. -앞서 선언한 store객체에서 subscribe와 getState를 꺼내서 직접 전달해 주면 그만이기 때문이죠.

    하지만 결국 이 과정 자체가 반복된 작업을 요구하게 됩니다.

    리액트 컴포넌트에서 쉽게 접근하도록 출구를 열어주자!

    리액트 컴포넌트에서는 바닐라 JS로 상태를 업데이트하는 것보다는 useState와 비슷한 형태로 훅을 사용하는 것이 훨씬 보기 깔끔할 것입니다. -매번 스토어에서 무언가를 직접 꺼내지 않도록 하는 중간 커스텀 훅이 필요합니다.

    export const useExternalState = <T>(
    store: DataObserver<T>
    ): [T, (param: SetStateCallbackType<T> | T) => void] => {
    const { subscribe, getState, setState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return [state, setState];
    };

    이 훅은, 바깥에서 받아온 store를 활용하여 구독/업데이트 기능을 배열로 반환합니다. -모식도를 그려보면 다음과 같습니다.

    no offset

    React 컴포넌트는 어디선가 생성된 store() 객체를 useExternalStore에 넘겨주고, [상태, 상태업데이트함수]를 받게 됩니다. -마치 기존의 useState나 useRecoilState처럼 말이죠.

    정리하면 다음과 같습니다. -푸른 영역은 React DOM -녹색 영역은 직접 호출해야 하는 라이브러리의 영역 (하지만 최대한 단순한 형태로 구성해서 개발자의 부담을 덜어주는 형태) -빨간색은 개발자가 직접 건들지 못하지만 간접적으로 사용할 수 있는 영역 -노란색은 React 18 엔진의 영역입니다.

    이외에 제공되는 다른 커스텀 훅들도 거의 비슷한 구조를 띄고 있습니다.

    // 추가로 구현할 수 있는 함수들

    export const useSetExternalState = <T>(store: DataObserver<T>) => {
    const { setState } = store;

    return setState;
    };

    export const useExternalValue = <T>(store: DataObserver<T>) => {
    const { subscribe, getState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return state;
    };

    // 바닐라JS 영역에서 자연스러운 읽기를 지원하는 함수

    export const getStoreSnapshot = <T>(store: DataObserver<T>) => {
    return store.getState();
    };

    더 다양한 예제는 여기에서 확인할 수 있고 -작성한 라이브러리 코드 전문은 여기에서 확인할 수 있습니다.

    겨우 파일 수십 줄로 만든 초경량 상태관리 라이브러리였습니다

    - - +

    · 약 3분

    레벨3 때 프로젝트를 진행하면서, 저희 팀은 많은 협업을 진행했습니다.

    처음에는 프론트엔드, 백엔드 서로 각각의 분야만 개발을 해왔고 협업이 익숙하지 않아서 많은 부분에서 문제가 발생하곤 했습니다.

    이런 과정에서 저희 팀은 어떻게 대처를 했을까요?

    한 가지 일화로 저희 팀의 제이와 센트의 필터 적용 부분을 설명 드리겠습니다.

    조회 시에 필터 적용 부분을 만들 때 기존에 작성해둔 API 명세대로 서로 작업을 진행하고, 중간에 생각하지 못한 부분에 대해서는 서로 대화를 많이 했습니다. +대화를 하면서 진행을 했지만 발견하지 못한 문제점이 있었습니다.

    바로 충전소 회사 명에서 key 값을 어떻게 하냐에 문제였습니다. +예를 들면 충전소 회사 명에서 광주시라는 이름이 있었는데, 이 필터는 실제로 두 가지가 존재했습니다.

    하나는 경기도 광주, 하나는 전라도 광주였습니다.

    이런 부분에서 불필요한 지역의 필터까지 걸리게 되는 문제가 있었습니다. +협업하는 과정에서 이를 발견했고, 즉각 조치를 취했습니다.

    조치를 취할 때 서로에게 각자 편한 방법이 있었지만, +단순히 서로에게 편한 작업을 하지 않았고, 팀원과 상의하면서 추후 진행에 문제 없는 방향을 찾고 진행할 수 있었습니다.

    지금 생각해보면 만약 각자에게 편한 방식으로 문제를 수정했다면, 다른 팀원이 다른 작업을 할 때 지장이 갔을 수도 있고 불필요한 작업을 했을 수도 있었을 것 같습니다.

    이 시점을 계기로 저희 팀끼리 예상하지 못한 문제를 작업 중에 발견하더라도 다른 팀원에게 공유하고 서로 짧은 회의를 통해 문제 해결 방안을 같이 찾는 것이 자연스럽게 팀문화로 자리 잡게 되었습니다.

    + + \ No newline at end of file diff --git a/page/13.html b/page/13.html index 98d52a1e..0745e2f1 100644 --- a/page/13.html +++ b/page/13.html @@ -5,13 +5,26 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    - - +

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. +생성된 StateManager 인스턴스가 반환되어 store가 곧 초기 값을 가지는 StateManager가 됩니다.

    no offset

    예를 들어, 다음과 같은 코드가 있다고 할 때

    export const countStore = store<number>(0);

    countStore는 곧 0을 초기값으로 가지는 StateManager 인스턴스이기도 하게 됩니다.

    그러면 StateManager에 대해서 알아보겠습니다.

    StateManager는 react 바깥에 있는 어떤 저장소이다.

    (근데 이게 그냥 저장소는 아니고 좀 특별한 저장소다.)

    export type SetStateCallbackType<T> = (prevState: T) => T;

    export interface DataObserver<T> {
    setState: (param: SetStateCallbackType<T> | T) => void;
    getState: () => T;
    subscribe: (listener: () => void) => () => void;
    emitChange: () => void;
    }

    class StateManager<T> implements DataObserver<T> {
    public state: T;
    private listeners: Array<() => void> = [];

    constructor(initialState: T) {
    this.state = initialState;
    }

    setState = (param: SetStateCallbackType<T> | T) => {
    if (param instanceof Function) {
    const newState = param(this.state);
    this.state = newState;
    } else {
    this.state = param;
    }

    this.emitChange();
    };

    getState = () => {
    return this.state;
    };

    subscribe = (listener: () => void) => {
    this.listeners = [...this.listeners, listener];

    return () => {
    this.listeners = this.listeners.filter((l) => l !== listener);
    };
    };

    emitChange = () => {
    for (const listener of this.listeners) {
    listener();
    }
    };
    }

    export default StateManager;

    StateManager 클래스는 외부에서 받아온 초기값을 상태로 가집니다. +setState, getState, subscribe, emitChange를 메서드로 가집니다. +여기서 작성된 코드들은 react에서 외부 저장소와 소통하기 위한 최소한의 규격입니다.

    • subscribe: 단일 콜백 인수를 사용하여 스토어에 구독하는 함수입니다. 스토어가 변경되면 제공된 콜백을 호출해야 합니다. 그러면 구성 요소가 다시 렌더링 됩니다. 구독 기능은 구독을 정리하는 기능을 반환해야 합니다. (구독에 관련된 데이터는 리스너 배열 필드에 넣어서 관리합니다.)

    • emitChange: 리스너 배열 필드에 담겨있는 모든 리스너를 실행합니다. 즉, 구독된 어떤 것을 순차적으로 실행하게 합니다. 이는 리액트 DOM을 강제로 일깨워주는 옵저버 패턴의 역할을 하게 됩니다. 이 과정 때문에 react DOM이 정확한 재 렌더링 지점을 파악할 수 있게됩니다. (최적화 문제에서 자유로워짐)

    • setState: 상태를 업데이트합니다. 다만 상태가 업데이트 됐음을 알려야 하므로 emitChange를 실행시켜 react DOM을 강제로 동기화시킵니다.

    • getState: 호출되는 순간 현재 상태 값을 읽습니다.

    좀 어렵지만 리액트에서 이런 규격을 가져야 useSyncExternalStore훅을 쓸 수 있게 해 줍니다. +기존 예제에서는 단순한 자바스크립트 객체로 짜여있었지만 인스턴스를 자유롭게 찍어낼 수 있는 class 구조로 개선하고 추상화하였습니다.

    사실 여기까지만 구현해도 useSyncExternalStore를 사용하는데 지장이 없습니다. +앞서 선언한 store객체에서 subscribe와 getState를 꺼내서 직접 전달해 주면 그만이기 때문이죠.

    하지만 결국 이 과정 자체가 반복된 작업을 요구하게 됩니다.

    리액트 컴포넌트에서 쉽게 접근하도록 출구를 열어주자!

    리액트 컴포넌트에서는 바닐라 JS로 상태를 업데이트하는 것보다는 useState와 비슷한 형태로 훅을 사용하는 것이 훨씬 보기 깔끔할 것입니다. +매번 스토어에서 무언가를 직접 꺼내지 않도록 하는 중간 커스텀 훅이 필요합니다.

    export const useExternalState = <T>(
    store: DataObserver<T>
    ): [T, (param: SetStateCallbackType<T> | T) => void] => {
    const { subscribe, getState, setState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return [state, setState];
    };

    이 훅은, 바깥에서 받아온 store를 활용하여 구독/업데이트 기능을 배열로 반환합니다. +모식도를 그려보면 다음과 같습니다.

    no offset

    React 컴포넌트는 어디선가 생성된 store() 객체를 useExternalStore에 넘겨주고, [상태, 상태업데이트함수]를 받게 됩니다. +마치 기존의 useState나 useRecoilState처럼 말이죠.

    정리하면 다음과 같습니다. +푸른 영역은 React DOM +녹색 영역은 직접 호출해야 하는 라이브러리의 영역 (하지만 최대한 단순한 형태로 구성해서 개발자의 부담을 덜어주는 형태) +빨간색은 개발자가 직접 건들지 못하지만 간접적으로 사용할 수 있는 영역 +노란색은 React 18 엔진의 영역입니다.

    이외에 제공되는 다른 커스텀 훅들도 거의 비슷한 구조를 띄고 있습니다.

    // 추가로 구현할 수 있는 함수들

    export const useSetExternalState = <T>(store: DataObserver<T>) => {
    const { setState } = store;

    return setState;
    };

    export const useExternalValue = <T>(store: DataObserver<T>) => {
    const { subscribe, getState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return state;
    };

    // 바닐라JS 영역에서 자연스러운 읽기를 지원하는 함수

    export const getStoreSnapshot = <T>(store: DataObserver<T>) => {
    return store.getState();
    };

    더 다양한 예제는 여기에서 확인할 수 있고 +작성한 라이브러리 코드 전문은 여기에서 확인할 수 있습니다.

    겨우 파일 수십 줄로 만든 초경량 상태관리 라이브러리였습니다

    + + \ No newline at end of file diff --git a/page/14.html b/page/14.html index 3dee947e..5ddd6be0 100644 --- a/page/14.html +++ b/page/14.html @@ -5,19 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 3분
    제이

    안녕하세요. -카페인 팀의 제이입니다.

    오늘은 저희가 EC2 인스턴스를 받으면서, 어떻게 dev, prod 배포 환경을 분리했는지 적어보려고 합니다. -기존 카페인 팀의 EC2 구조는 여기서 보실 수 있습니다.


    기존 상황과 문제점

    카페인 팀에서는 기존에 3대의 EC2 인스턴스가 있었습니다. -각각 infra, dev, db 역할을 하는 인스턴스로 존재하고 있었습니다.

    저희는 release 브랜치를 통해 dev서버에 배포를 한 후 검증이 된다면, 실제 사용자들이 사용하는 prod 서버에 배포하고 있습니다.

    문제는 기존의 3대의 인스턴스 중에서 dev 서버에 있었습니다. -기존 dev 서버는 총 4개의 서버를 배포하고 있었고 배포하는 서버는 다음과 같습니다. prod-BE, prod-FE, dev-BE, dev-FE

    그리고, 기존 dev 서버에서는 환경을 분리해주기 위해서 Nginx를 통해서 포트 포워딩은 다음과 같이 해주었습니다.

    • prod-BE = 8080
    • prod-FE = 3031
    • dev-BE = 8081
    • dev-FE = 3031

    카페인 팀에서는 dev, prod 환경이 분리되지 않아서 인스턴스의 사용량이 높았고, 이에 따라 추가적인 EC2 인스턴스가 필요했습니다.


    문제 해결

    다행히도 카페인 팀에서 추가적인 EC2 인스턴스를 받았고, 저희는 배포 환경을 분리할 수 있었습니다.

    dev-prod-server

    이와 같이 기존 dev 서버 한 개가 infra 서버와 연결되어 있었는데, 두 갈래로 나뉜 것을 확인하실 수 있습니다.

    먼저 배포는 다음과 같이 진행됩니다.

    release branch에 push가 일어나면 dev서버에 배포 작업이 이뤄집니다. -prod branch에 push가 일어나면 prod서버에 배포 작업이 이뤄집니다.

    또한 기존 dev 서버에서 4개의 포트포워딩 또한 굳이 그럴 필요가 없어졌습니다. -새로운 서버가 추가됨에 따라 dev, prod 서버 각각 Nginx에서 포트포워딩을 동일하게 FE:3000, BE:8080 으로 변경하였습니다.

    이렇게 카페인 팀에서는 dev, prod 환경을 분리했습니다.

    감사합니다!

    - - +

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git a/page/15.html b/page/15.html index af17177e..6a62ea2a 100644 --- a/page/15.html +++ b/page/15.html @@ -5,24 +5,19 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. -기본 설정이 간편하고, 빠르게 테스트를 실행할 때 굉장히 유용합니다. -함수를 mocking하여 의존성이 강한 함수를 제거하여 원하는 테스트를 쉽게 구성할 수 있다는 특징이 있습니다.

    React Testing Library

    React Testing Library는 리액트 애플리케이션의 UI를 테스트하기 위한 라이브러리입니다. -React 컴포넌트를 호출하여, 사용자의 의도대로 조작할 수 있는 행위를 정의할 수 있습니다. -사용자 입장에서 상호작용 할 수 있는 부분을 스크립트로 작성하여 컴포넌트가 어떻게 변화하는지를 테스트 할 수 있게 됩니다. -가령, 어떤 사용자가 어떤 폼에 어떤 값을 입력했을 때의 예상되는 결과를 작성해두면 이후에 코드 작업 중 버그가 발생한다면 해당 위치에서 테스트가 실패할 것입니다.

    Storybook

    Storybook은 UI를 컴포넌트 단위로 개발하고 그 즉시 시각화 할 수 있도록 돕는 테스팅 라이브러리입니다. -컴포넌트를 눈 앞에 바로 보여주고 실제 리액트에서 동작하는 것 처럼 컴포넌트 단위로 개발을 할 수 있습니다. CDD를 지향한다면 굉장히 유용한 기능이며, 개발자가 아닌 협업자에게도 원활한 커뮤니케이션을 도와줍니다. -컴포넌트 단위로 개발하기 때문에 개별 컴포넌트가 어떻게 동작하는지 확인할 수 있다는 것 자체가 굉장한 이점으로 작용합니다. -예를 들어 어떤 컴포넌트가 특정 메뉴 안에 존재해야 한다면, 이것을 확인하기 위해 해당 메뉴까지 접근해야 할 것입니다. -하지만 Storybook을 이용하면 특정 컴포넌트를 Storybook 위에 올려놓고 테스트를 할 수 있어 빠르게 작업이 가능합니다. -인터렉션이나 웹접근성을 확인해주는 플러그인도 존재하여 프론트엔드 개발에서 굉장히 중요한 역할로 부상했습니다.

    저희 팀은 이외에 Cypress를 사용하는 것도 고려하였으나, 지도와 결합된 애플리케이션을 테스트하기에 다소 어려움이 있어 위 라이브러리들을 개발에 활용했습니다.

    저희는 위 테스팅 라이브러리들을 원활히 활용하기 위해 테스트 자동화를 구축했습니다.

    Jest와 React Testing Library 테스트 자동화

    name: frontend-test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**
    - .github/**

    permissions:
    contents: read

    jobs:
    test:
    name: test-when-pull-request
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Checkout PR
    uses: actions/checkout@v2
    - name: Install dependencies
    run: npm install
    - name: Test
    run: npm run test

    이벤트 트리거 설정

    pull_request 이벤트가 발생하였을 때, 해당 이벤트가 main 브랜치와 develop 브랜치에서만 동작합니다.

    변경 사항 경로 제한

    테스트를 실행할 때는 frontend 디렉토리와 .github 디렉토리 내의 파일들을 고려하도록 했습니다. 백엔드와의 환경 분리를 위해 이러한 접근 제한을 했습니다.

    권한 설정

    permissions은 읽기 권한만 설정되어 있어 코드나 파일을 변경을 방지합니다.

    작업(Job) 설정

    test라는 이름의 작업을 정의하였고, 이 작업에서는 Ubuntu 환경에서 테스트를 실행합니다. test라는 이름의 환경 변수를 사용합니다. 테스트는 (카페인 팀 레포지토리의) frontend 디렉토리에서 작업하도록 하였습니다.

    스텝(Step) 설정

    코드를 체크아웃하고, 의존성을 설치하며, 테스트를 실행하는 세 가지 단계로 구성되어 있습니다.

    이러한 설정을 통해 PR에 코드가 올라올 때 자동으로 프론트엔드 테스트가 실행됩니다.

    이러한 테스트 자동화 전략은 프론트엔드 애플리케이션을 안정적이게 개발하고 유지할 수 있도록 도와줍니다.

    Storybook의 빌드 자동화

    name: storybook-deploy

    on:
    pull_request:
    branches:
    - develop
    paths:
    - frontend/**
    - .github/**

    jobs:
    build:
    runs-on: ubuntu-22.04
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Setup Repository
    uses: actions/checkout@v3

    - name: Set up Node
    uses: actions/setup-node@v3
    with:
    node-version: 18.16.0

    - name: Install dependencies
    run: npm install

    - name: Cache node_modules
    id: cache
    uses: actions/cache@v3
    with:
    path: '**/node_modules'
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
    ${{ runner.os }}-node-

    - name: storybook build
    run: npm run build-storybook

    - name: Upload storybook build files to temp artifact
    uses: actions/upload-artifact@v3
    with:
    name: Storybook
    path: frontend/storybook-static
    deploy:
    needs: build
    runs-on: self-hosted
    steps:
    - name: Remove previous version app
    working-directory: .
    run: rm -rf dist

    - name: Download the built file to AWS
    uses: actions/download-artifact@v3
    with:
    name: Storybook
    path: frontend/dev/dist

    - name: Move folder
    working-directory: frontend/dev/
    run: |
    rm -rf /home/ubuntu/dist/*
    cp -r ./dist /home/ubuntu

    - name: comment PR
    uses: thollander/actions-comment-pull-request@v1
    env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    with:
    message: '🚀storybook: https://storybook.carffe.in/'

    비슷한 코드이지만, 매번 PR이 열릴 때 마다 스토리북이 자동으로 빌드 및 배포됩니다. -배포가 완료되면 배포된 URL을 알려 코드 리뷰할 때 참고할 수 있도록 돕습니다.

    이상 카페인 팀에서 사용하고 있는 테스팅 라이브러리와 테스트 자동화 방법을 알아봤습니다.

    - - +

    · 약 3분
    제이

    안녕하세요. +카페인 팀의 제이입니다.

    오늘은 저희가 EC2 인스턴스를 받으면서, 어떻게 dev, prod 배포 환경을 분리했는지 적어보려고 합니다. +기존 카페인 팀의 EC2 구조는 여기서 보실 수 있습니다.


    기존 상황과 문제점

    카페인 팀에서는 기존에 3대의 EC2 인스턴스가 있었습니다. +각각 infra, dev, db 역할을 하는 인스턴스로 존재하고 있었습니다.

    저희는 release 브랜치를 통해 dev서버에 배포를 한 후 검증이 된다면, 실제 사용자들이 사용하는 prod 서버에 배포하고 있습니다.

    문제는 기존의 3대의 인스턴스 중에서 dev 서버에 있었습니다. +기존 dev 서버는 총 4개의 서버를 배포하고 있었고 배포하는 서버는 다음과 같습니다. prod-BE, prod-FE, dev-BE, dev-FE

    그리고, 기존 dev 서버에서는 환경을 분리해주기 위해서 Nginx를 통해서 포트 포워딩은 다음과 같이 해주었습니다.

    • prod-BE = 8080
    • prod-FE = 3031
    • dev-BE = 8081
    • dev-FE = 3031

    카페인 팀에서는 dev, prod 환경이 분리되지 않아서 인스턴스의 사용량이 높았고, 이에 따라 추가적인 EC2 인스턴스가 필요했습니다.


    문제 해결

    다행히도 카페인 팀에서 추가적인 EC2 인스턴스를 받았고, 저희는 배포 환경을 분리할 수 있었습니다.

    dev-prod-server

    이와 같이 기존 dev 서버 한 개가 infra 서버와 연결되어 있었는데, 두 갈래로 나뉜 것을 확인하실 수 있습니다.

    먼저 배포는 다음과 같이 진행됩니다.

    release branch에 push가 일어나면 dev서버에 배포 작업이 이뤄집니다. +prod branch에 push가 일어나면 prod서버에 배포 작업이 이뤄집니다.

    또한 기존 dev 서버에서 4개의 포트포워딩 또한 굳이 그럴 필요가 없어졌습니다. +새로운 서버가 추가됨에 따라 dev, prod 서버 각각 Nginx에서 포트포워딩을 동일하게 FE:3000, BE:8080 으로 변경하였습니다.

    이렇게 카페인 팀에서는 dev, prod 환경을 분리했습니다.

    감사합니다!

    + + \ No newline at end of file diff --git a/page/16.html b/page/16.html index 0f494106..de4a2a11 100644 --- a/page/16.html +++ b/page/16.html @@ -5,23 +5,24 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. -그래서 저희는 아래와 같이 email을 추가합니다.

    class Member {

    private Long id;
    private String name;
    private String email;
    }

    그리고 다시 jpa의 ddl-auto 속성 중 create를 사용해서 새로운 테이블을 만들었습니다. 기존의 테이블을 다 날리면서요.

    하지만 저희의 데이터베이스의 데이터들을 그냥 drop해도 되는 것일까요? -개발 서버라도 힘들게 쌓은 데이터들을 테이블이 조금 변경되었다고 날려버리는 것은 바보같은 일이라고 생각했습니다. -그러면 ddl-auto의 다른 조건인 update를 사용하면 될 것 같습니다. 그랬더니 jpa가 아래와 같이 쿼리를 이쁘게 만들어 줬습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    update를 사용하니 아주 편하게 칼럼이 추가되는 것을 볼 수 있습니다.

    하지만 여기서 또 아래와 같은 요구사항이 추가되었습니다.

    email의 제약조건으로 null이 되면 안되고, 길이는 20자가 되어야합니다. -그러면 어노테이션을 사용하여 변경해보겠습니다.

    class Member {

    private Long id;
    private String name;
    @Column(nullable= false, length = 20)
    private String email;
    }

    이렇게 하고 어플리케이션을 재시작 했습니다. -하지만 아무런 ddl이 발생하지 않습니다. 왜냐면 Jpa의 ddl-auto: update의 속성은 제약조건이 변경된 것은 반영해주지 않기 때문입니다.

    그리고 만약 이 전의 회원들의 email이 null인 row도 있다면 어떻게 될까요? 제약조건을 반영할 수 없을 것입니다.

    이런 식으로 운영 도중 table의 칼럼들이 추가되거나, 삭제되거나, 혹은 제약조건이 변경될 때 update 속성만으로는 반영할 수 없습니다.

    flyway

    그래서 flyway를 사용했습니다. -물론 flyway 없이도 이런 문제를 해결할 수 있습니다. 방법은 간단합니다. 데이터베이스가 있는 서버에 직접 접속하여 ddl을 직접 하나 하나 다 작성하면 됩니다.

    하지만 이런 방식에는 단점이 있습니다. 하나 하나 직접 입력하다보니 휴먼 에러가 발생할 수도 있기 때문입니다. 그리고 매번 데이터베이스 서버에 접속해야한다는 단점이 있습니다. -이렇게 매번 데이터베이스에 접속을 해야한다면 cd를 하는 이유가 있을까요?

    하지만 flyway를 사용하면 편하게 변경된 schema를 관리할 수 있고 언제 바뀌었는지 어떻게 바뀌었는지 확인도 할 수 있습니다.

    글로는 잘 와닿지 않을 수도 있으니 사용법을 확인하면서 어떤 장점이 있는지 확인해보도록 하겠습니다.

    먼저 flyway 의존성을 추가하고 resources/db/migration 패키지를 만듭니다. -거기에 file을 만듭니다. 파일 이름이 중요한데요 V1__init.sql 이러한 방식으로 V{version 숫자}__{어떠한 파일인지에 대한 이름}.sql 언더스코어 2개는 필수로 작성해야합니다.

    create table member(
    id bigint auto_increment primary key,
    name varchar(255) null,
    );

    이렇게 V1__init.sql에 대한 파일을 작성했습니다. 이제는 email을 추가한다는 요구사항을 반영해보겠습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    이렇게 새로운 파일을 만들어서 해당 스크립트를 작성했습니다. 파일명이 중요한데요, 이전 파일의 숫자보다 +1 이 되는 숫자를 V 뒤에 붙입니다.

    따라서 이번 파일은 V2__add_column_email.sql 이라고 만들었습니다.

    그럼 이제 또 시간이 지나 회원이 많아졌습니다. 하지만 email이 없는 사용자도 많습니다. 이 상황에서 email을 not null로 변경해야한다는 요구사항이 생겼습니다.

    그러면 아래와 같이 반영할 수 있습니다.

    ALTER TABLE member
    MODIFY email VARCHAR(20) NOT NULL default 'default'

    이렇게 V3__add_constraints.sql 파일을 만들었습니다. 그러면 null이 있던 row들은 email이 default가 되고 not null 제약조건이 활성화 된 것을 볼 수 있습니다.

    그러면 주어진 요구사항은 모두 만족할 수 있습니다. 거기에다 v1, v2, v3 가 나뉘어져있어서 어느 커밋부터 해당 sql이 추가되었는지도 확인할 수 있습니다.

    그리고 ddl-auto update를 사용하면 반영되지 않았던 제약조건의 추가도 확인할 수 있습니다. 그러면 ddl-auto의 속성을 validate로 변경하여, db schema와 entity의 필드가 다르면 -어플리케이션이 실행되지 않도록 해서 좀 더 안전한 개발을 할 수 있습니다.

    결론

    flyway는 roll back을 하는 것이 유료라서, production 서버에서 혹은 롤백을 해야하는 일이 있는 서버에서는 사용하는 것이 좋지 않지만, -이와 같이 데이터를 drop 할 수 없는 상황이라면, 사용하지 않을 이유가 없어보이는 좋은 도구입니다.

    짧은 글 읽어주셔서 감사합니다.

    - - +

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. +기본 설정이 간편하고, 빠르게 테스트를 실행할 때 굉장히 유용합니다. +함수를 mocking하여 의존성이 강한 함수를 제거하여 원하는 테스트를 쉽게 구성할 수 있다는 특징이 있습니다.

    React Testing Library

    React Testing Library는 리액트 애플리케이션의 UI를 테스트하기 위한 라이브러리입니다. +React 컴포넌트를 호출하여, 사용자의 의도대로 조작할 수 있는 행위를 정의할 수 있습니다. +사용자 입장에서 상호작용 할 수 있는 부분을 스크립트로 작성하여 컴포넌트가 어떻게 변화하는지를 테스트 할 수 있게 됩니다. +가령, 어떤 사용자가 어떤 폼에 어떤 값을 입력했을 때의 예상되는 결과를 작성해두면 이후에 코드 작업 중 버그가 발생한다면 해당 위치에서 테스트가 실패할 것입니다.

    Storybook

    Storybook은 UI를 컴포넌트 단위로 개발하고 그 즉시 시각화 할 수 있도록 돕는 테스팅 라이브러리입니다. +컴포넌트를 눈 앞에 바로 보여주고 실제 리액트에서 동작하는 것 처럼 컴포넌트 단위로 개발을 할 수 있습니다. CDD를 지향한다면 굉장히 유용한 기능이며, 개발자가 아닌 협업자에게도 원활한 커뮤니케이션을 도와줍니다. +컴포넌트 단위로 개발하기 때문에 개별 컴포넌트가 어떻게 동작하는지 확인할 수 있다는 것 자체가 굉장한 이점으로 작용합니다. +예를 들어 어떤 컴포넌트가 특정 메뉴 안에 존재해야 한다면, 이것을 확인하기 위해 해당 메뉴까지 접근해야 할 것입니다. +하지만 Storybook을 이용하면 특정 컴포넌트를 Storybook 위에 올려놓고 테스트를 할 수 있어 빠르게 작업이 가능합니다. +인터렉션이나 웹접근성을 확인해주는 플러그인도 존재하여 프론트엔드 개발에서 굉장히 중요한 역할로 부상했습니다.

    저희 팀은 이외에 Cypress를 사용하는 것도 고려하였으나, 지도와 결합된 애플리케이션을 테스트하기에 다소 어려움이 있어 위 라이브러리들을 개발에 활용했습니다.

    저희는 위 테스팅 라이브러리들을 원활히 활용하기 위해 테스트 자동화를 구축했습니다.

    Jest와 React Testing Library 테스트 자동화

    name: frontend-test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**
    - .github/**

    permissions:
    contents: read

    jobs:
    test:
    name: test-when-pull-request
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Checkout PR
    uses: actions/checkout@v2
    - name: Install dependencies
    run: npm install
    - name: Test
    run: npm run test

    이벤트 트리거 설정

    pull_request 이벤트가 발생하였을 때, 해당 이벤트가 main 브랜치와 develop 브랜치에서만 동작합니다.

    변경 사항 경로 제한

    테스트를 실행할 때는 frontend 디렉토리와 .github 디렉토리 내의 파일들을 고려하도록 했습니다. 백엔드와의 환경 분리를 위해 이러한 접근 제한을 했습니다.

    권한 설정

    permissions은 읽기 권한만 설정되어 있어 코드나 파일을 변경을 방지합니다.

    작업(Job) 설정

    test라는 이름의 작업을 정의하였고, 이 작업에서는 Ubuntu 환경에서 테스트를 실행합니다. test라는 이름의 환경 변수를 사용합니다. 테스트는 (카페인 팀 레포지토리의) frontend 디렉토리에서 작업하도록 하였습니다.

    스텝(Step) 설정

    코드를 체크아웃하고, 의존성을 설치하며, 테스트를 실행하는 세 가지 단계로 구성되어 있습니다.

    이러한 설정을 통해 PR에 코드가 올라올 때 자동으로 프론트엔드 테스트가 실행됩니다.

    이러한 테스트 자동화 전략은 프론트엔드 애플리케이션을 안정적이게 개발하고 유지할 수 있도록 도와줍니다.

    Storybook의 빌드 자동화

    name: storybook-deploy

    on:
    pull_request:
    branches:
    - develop
    paths:
    - frontend/**
    - .github/**

    jobs:
    build:
    runs-on: ubuntu-22.04
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Setup Repository
    uses: actions/checkout@v3

    - name: Set up Node
    uses: actions/setup-node@v3
    with:
    node-version: 18.16.0

    - name: Install dependencies
    run: npm install

    - name: Cache node_modules
    id: cache
    uses: actions/cache@v3
    with:
    path: '**/node_modules'
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
    ${{ runner.os }}-node-

    - name: storybook build
    run: npm run build-storybook

    - name: Upload storybook build files to temp artifact
    uses: actions/upload-artifact@v3
    with:
    name: Storybook
    path: frontend/storybook-static
    deploy:
    needs: build
    runs-on: self-hosted
    steps:
    - name: Remove previous version app
    working-directory: .
    run: rm -rf dist

    - name: Download the built file to AWS
    uses: actions/download-artifact@v3
    with:
    name: Storybook
    path: frontend/dev/dist

    - name: Move folder
    working-directory: frontend/dev/
    run: |
    rm -rf /home/ubuntu/dist/*
    cp -r ./dist /home/ubuntu

    - name: comment PR
    uses: thollander/actions-comment-pull-request@v1
    env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    with:
    message: '🚀storybook: https://storybook.carffe.in/'

    비슷한 코드이지만, 매번 PR이 열릴 때 마다 스토리북이 자동으로 빌드 및 배포됩니다. +배포가 완료되면 배포된 URL을 알려 코드 리뷰할 때 참고할 수 있도록 돕습니다.

    이상 카페인 팀에서 사용하고 있는 테스팅 라이브러리와 테스트 자동화 방법을 알아봤습니다.

    + + \ No newline at end of file diff --git a/page/17.html b/page/17.html index 868d64e3..70864bac 100644 --- a/page/17.html +++ b/page/17.html @@ -5,33 +5,23 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. -error-log

    왜 발생했을까

    먼저 간단히 저희가 처한 상황에 대해 설명드리겠습니다.

    처음 어플리케이션을 실행하면 공공 API를 호출하여 충전소와 충전기에 대한 모든 정보들을 가져와 저장합니다. (충전소 약 6만 곳 + 충전기 약 23만 기)

    하지만 이러한 정보들은 수정이 될 수 있고, 충전소와 충전기가 추가될 수 있습니다.

    그러므로 정확한 정보가 사용자에게 가장 중요시되는 서비스에서 이러한 정보들이 늦게 반영이 된다거나, 반영이 되지 않는다면 저희 서비스를 사용할 사용자가 없을 것이라 판단했습니다.

    그래서 하루에 한 번 충전소와 충전기들의 정보를 업데이트하고, 추가된 충전소와 충전기를 저장하는 로직을 만들었습니다.

    대략적인 로직은 아래와 같습니다.

        public void updatePeriodicStations() {
    List<Station> stations = requestStations();
    stationUpdateService.updateStations(stations);
    }

    public void updateStations(List<Station> updatedStations) {
    List<Station> stations = stationRepository.findAllFetch();

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    }

    간단하게 말씀드리면 requestStations() 메서드는 공공 API에서 모든 충전소와 충전기를 요청하고 받아오는 메서드입니다. 23만 + 6만개의 정보를 받아오는 것입니다. -이렇게 많은 정보를 받아오고 메모리에 올린다는 것은 누가봐도 비효율적입니다. 하지만 이러한 선택을 한 이유는 공공 API는 저희가 어떤 방식으로 보내줄 지 모른다는 것이였습니다. -그래서 어쩔 수 없이 23만건을 모두 요청해야한다는 부분은 바꿀 수 없는 한계입니다.

    그 다음으로는 요청해서 받아온 데이터들과 데이터베이스에 저장되어 있던 데이터들을 findAll()을 통해 비교하고 새로운 충전소와 충전기는 저장하고, 업데이트된 충전소와 충전기는 수정합니다.

    이런 로직은 총 (23 + 6) * 2 만건의 객체 약 58만개를 Heap 메모리에 적재합니다. 많다고는 생각했지만, 일단 제 로컬환경에서는 잘 작동했고, 기능 구현이 우선이기 때문에 추후에 개선을 하기로 하고 넘어갔습니다.

    하지만 개발 서버 배포를 하고 다음날 서버가 접속이 되지 않는 것을 확인했고, 로그를 보니 위의 사진과 같이 OOM이 발생한 것을 확인할 수 있었습니다.

    해결 방안

    Heap size 조절하기

    일단 임시 방편으로 Heap memory의 최대 크기를 늘리는 법이였습니다. JVM은 실행되는 환경에 따라 힙 메모리의 최대 사이즈를 정합니다. 힙 메모리는 설정하지 않으면 해당 환경의 메모리 1/4로 설정합니다. -그래서 저희 EC2 인스턴스의 메모리는 약 2기가로, 약 500MB가 할당되어 있었습니다. 그래서 저희는 메모리를 조금씩 늘려가며 조정하여 약 1기가로 힙 메모리의 최대 사이즈를 정했습니다. -힙 메모리의 설정을 하는 방법은 간단합니다.

    java -Xms512m -Xmx1024m boxster.jar

    실행할 때 이러한 방식으로 하면 최소 힙 메모리 사이즈는 512MB, 최대 1024MB로 설정할 수 있습니다.

    페이징해서 가져오기

    힙 메모리의 사이즈를 조절해서 해결한다는 부분은 임시 방편이지 만약 저희 EC2 환경이 다운그레이드 되거나 한다면 또 OOM이 발생할 것이 뻔합니다. 그래서 어플리케이션 레벨에서 좀 더 해결할 방안이 필요합니다.

    API의 요청에 대한 부분은 요청보내는 회사의 정책이 바뀌지 않는 이상 저희는 23만건을 모두 로딩해야한다는 점은 어쩔 수 없습니다. 그렇다면 저희가 제어할 수 있는 유일한 부분은 데이터베이스에서 데이터를 꺼내오는 부분 밖에 없습니다.

    그렇다면 이것을 어떻게 조절할 수 있을까요.

    여러 방법을 찾아보던 중 No Offset방식으로 데이터를 페이징한다는 글을 읽었습니다. 페이징을 하기위해서는 어디서부터 시작하고 어디까지 가져올 것인지 정해야합니다. 그 중 먼저 제일 자주 사용되는 Offset 방식에 대해 간단히 설명드리겠습니다.

    해당 방식은

    SELECT *
    FROM station
    ORDER BY id DESC
    OFFSET 20000
    LIMIT 10000

    이러한 쿼리를 만들어 요청합니다. station 테이블의 20001번째 레코드부터 10000개의 데이터를 요청하는 방식입니다. 이러한 쿼리도 나쁘지 않습니다. -장점으로는 언제든 해당 페이지로 이동할 수 있다는 점입니다.

    하지만 이 쿼리에는 단점이 있습니다. 뒤로 갈수록 성능이 나빠진다는 점입니다. 20001번째 레코드부터 10000개를 요청한다면 데이터베이스는 어쩔 수 없이 20001번째 레코드를 찾기 위해 -정렬을 하고, 정렬한 후에 20001번째까지 세어가며 읽고, 거기서부터 10000개의 레코드를 반환하기 때문입니다. -offset

    한 문장으로 정의하면, 순서를 알아야하기 때문에 내가 필요하지 않는 레코드도 읽어야 하기 때문입니다.

    No Offset

    그럼 No offset 방식에 대해 설명드리겠습니다.

    사실 이름만 들으면 어려울 것 같지만 그냥 offset을 사용하지 않고 페이징하는 것입니다.

    스크롤을 내리면서 자동으로 마지막의 데이터를 기준으로 다음 몇개의 레코드를 불러오는 방식이기 때문입니다. -해당 방식은

    select *
    from station
    where id < 마지막으로 보낸 id
    order by id desc
    limit 10000;

    이러한 쿼리로 작동합니다. 아까와는 다른 부분은 where 절에 마지막으로 보낸 id라는 정보가 필요하다는 부분과, offset이 사라진 부분입니다.

    같은 결과를 만들어내는 쿼리지만, 하나가 추가되고 하나가 사라졌다는 것은 추가된 부분이 사라진 부분을 대신한다는 뜻이겠죠.

    이 이러한 방식의 단점은 offset을 이용한 방식과는 다르게 page를 지정해서 돌아가기는 힘듭니다.

    no offset

    마지막으로 보낸 id를 받아 인덱스를 이용해 해당 id에서부터 레코드를 반환합니다. 굳이 필요없는 레코드를 읽을 필요 없기 때문에 성능이 좋아졌을 것이라 예상할 수 있습니다.

    성능 차이

    바로 한번 두 개의 쿼리를 실행해보겠습니다. -test

    위의 쿼리는 no offset, 아래는 offset 방식입니다. 현재 데이터가 6만건 들어있는 테이블의 조회 기준으로 약 10배 가량 성능이 차이나는 것을 확인할 수 있습니다.

    그럼 실행 계획도 간단히 알아보겠습니다.

    먼저 offset 방식의 실행 계획입니다. -offset explain

    type 칼럼을 보시면 index라고 되어 있는 것을 확인할 수 있습니다. 여기서 index 접근 방법은 -인덱스를 효율적으로 사용하는 것이 아닌 인덱스를 처음부터 끝까지 읽는 full scan을 뜻합니다. 그래서 그다지 효율적이지 못한 방법입니다. -그리고 rows 칼럼에는 40010이라고 되어있습니다. 해당 부분은 제가 offset을 40000, limit을 10으로 두었기 때문에 40010d의 row를 -읽어야한다고 예상 값을 나타낸 것입니다.

    다음은 no offset 방식의 실행 계획입니다. -no offset explain -아까와는 다르게 type 칼럼은 range입니다. range 접근 방식은 인덱스를 하나의 값이 아니라 범위로 검색하는 경우를 의미합니다. -좀 전의 index 접근 방식과는 다르게 훨씬 효율적인 접근 방식입니다. 그리고 rows도 달라진 것을 확인할 수 있습니다.

    진짜 해결하기

    이제 열심히 페이징 처리를 했으니 어플리케이션에서 해결을 하도록 만들어야합니다.

    저희 팀은 동적 쿼리 생성을 도와주는 Query DSL을 도입하지 않았고 아직까진 굳이 필요하지 않아서 no offset 방식을 jpa의 jpql을 통해 구현해보겠습니다.

    먼저 첫 페이지는 id의 관계없이 원하는 갯수만큼만 가져오면 됩니다. 그리고 두번째 페이지부터는 id를 받아 그 다음부터 반환하면 됩니다.

    public interface StationRepository extends Repository<Station, Long> {

    @Query("SELECT s FROM Station s INNER JOIN FETCH s.chargers ORDER BY s.stationId")
    List<Station> findAllByOrder(Pageable pageable);

    @Query("SELECT s FROM Station s INNER JOIN FETCH s.chargers WHERE s.stationId > :stationId ORDER BY s.stationId")
    List<Station> findAllByPaging(@Param("stationId") String stationId, Pageable pageable);
    }

    그럼 아까 update를 해주던 메서드에서 조금 수정해보겠습니다.

        public void updatePeriodicStations() {
    List<Station> stations = getStations();
    // 처음에는 station의 id가 null
    String lastStationId = null;
    for (int i = 0; i < stations.size() / LIMIT + 1; i++) {
    // 마지막 id를 메서드 실행할 때마다 변경해준다.
    lastStationId = stationUpdateService.updateStations(stations, lastStationId, LIMIT);
    }
    }

    public String updateStations(List<Station> updatedStations, String lastStationId, int limit) {
    List<Station> savedStations = getStations(lastStationId, limit);

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    // 가져온 list에서 제일 마지막 station의 id를 반환
    return getLastStationId(savedStations);
    }
    // 페이징 처리
    private List<Station> getStations(String stationId, int limit) {
    // Id 가 null 이라면 첫 페이지이기 때문에 limit 사이즈만큼 select
    if (stationId == null) {
    return stationRepository.findAllByOrder(Pageable.ofSize(limit));
    }
    // 아니라면 station Id 부터 limit 만큼
    return stationRepository.findAllByPaging(stationId, Pageable.ofSize(limit));
    }

    이렇게 되면 원래 23만개를 한꺼번에 가져오던 로직을 나눌 수 있기 때문에 Heap 메모리의 여유가 생길 것입니다.

    진짜 확인해보기

    물론 GC의 동작이 어떨지 모르겠지만 23만개 인스턴스를 생성하는 것보다 5000개 혹은 더 적게 생성하는 것이 Heap 메모리를 적게 사용할 것임을 유추할 수 있습니다. -하지만 직접 확인해보기 전까지는 확신할 수 없으니 간단히 Runtime 클래스에서 제공해주는 totalMemory(), freeMemory() 메서드를 통해 알아보겠습니다.

        @Test
    void 페이징을_사용한_조회() {
    List<Station> stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();
    System.out.println("paging 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    @Test
    void 페이징을_사용하지_않고_조회() {
    List<Station> stations = stationRepository.findAllFetch();

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();

    System.out.println("findAll() 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    findAll -paging -확연히 차이가 나는 것을 확인할 수 있습니다.

    물론 테스트코드에서는 23만건의 API 요청은 같은 조건이니 배제하고 확인했습니다.

    이로써 하나의 문제가 또 해결된 것 같습니다.

    아직 배우는 단계라 혹시 틀린 점이 있다면 지적 부탁드리겠습니다.

    Reference

    - - +

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. +그래서 저희는 아래와 같이 email을 추가합니다.

    class Member {

    private Long id;
    private String name;
    private String email;
    }

    그리고 다시 jpa의 ddl-auto 속성 중 create를 사용해서 새로운 테이블을 만들었습니다. 기존의 테이블을 다 날리면서요.

    하지만 저희의 데이터베이스의 데이터들을 그냥 drop해도 되는 것일까요? +개발 서버라도 힘들게 쌓은 데이터들을 테이블이 조금 변경되었다고 날려버리는 것은 바보같은 일이라고 생각했습니다. +그러면 ddl-auto의 다른 조건인 update를 사용하면 될 것 같습니다. 그랬더니 jpa가 아래와 같이 쿼리를 이쁘게 만들어 줬습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    update를 사용하니 아주 편하게 칼럼이 추가되는 것을 볼 수 있습니다.

    하지만 여기서 또 아래와 같은 요구사항이 추가되었습니다.

    email의 제약조건으로 null이 되면 안되고, 길이는 20자가 되어야합니다. +그러면 어노테이션을 사용하여 변경해보겠습니다.

    class Member {

    private Long id;
    private String name;
    @Column(nullable= false, length = 20)
    private String email;
    }

    이렇게 하고 어플리케이션을 재시작 했습니다. +하지만 아무런 ddl이 발생하지 않습니다. 왜냐면 Jpa의 ddl-auto: update의 속성은 제약조건이 변경된 것은 반영해주지 않기 때문입니다.

    그리고 만약 이 전의 회원들의 email이 null인 row도 있다면 어떻게 될까요? 제약조건을 반영할 수 없을 것입니다.

    이런 식으로 운영 도중 table의 칼럼들이 추가되거나, 삭제되거나, 혹은 제약조건이 변경될 때 update 속성만으로는 반영할 수 없습니다.

    flyway

    그래서 flyway를 사용했습니다. +물론 flyway 없이도 이런 문제를 해결할 수 있습니다. 방법은 간단합니다. 데이터베이스가 있는 서버에 직접 접속하여 ddl을 직접 하나 하나 다 작성하면 됩니다.

    하지만 이런 방식에는 단점이 있습니다. 하나 하나 직접 입력하다보니 휴먼 에러가 발생할 수도 있기 때문입니다. 그리고 매번 데이터베이스 서버에 접속해야한다는 단점이 있습니다. +이렇게 매번 데이터베이스에 접속을 해야한다면 cd를 하는 이유가 있을까요?

    하지만 flyway를 사용하면 편하게 변경된 schema를 관리할 수 있고 언제 바뀌었는지 어떻게 바뀌었는지 확인도 할 수 있습니다.

    글로는 잘 와닿지 않을 수도 있으니 사용법을 확인하면서 어떤 장점이 있는지 확인해보도록 하겠습니다.

    먼저 flyway 의존성을 추가하고 resources/db/migration 패키지를 만듭니다. +거기에 file을 만듭니다. 파일 이름이 중요한데요 V1__init.sql 이러한 방식으로 V{version 숫자}__{어떠한 파일인지에 대한 이름}.sql 언더스코어 2개는 필수로 작성해야합니다.

    create table member(
    id bigint auto_increment primary key,
    name varchar(255) null,
    );

    이렇게 V1__init.sql에 대한 파일을 작성했습니다. 이제는 email을 추가한다는 요구사항을 반영해보겠습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    이렇게 새로운 파일을 만들어서 해당 스크립트를 작성했습니다. 파일명이 중요한데요, 이전 파일의 숫자보다 +1 이 되는 숫자를 V 뒤에 붙입니다.

    따라서 이번 파일은 V2__add_column_email.sql 이라고 만들었습니다.

    그럼 이제 또 시간이 지나 회원이 많아졌습니다. 하지만 email이 없는 사용자도 많습니다. 이 상황에서 email을 not null로 변경해야한다는 요구사항이 생겼습니다.

    그러면 아래와 같이 반영할 수 있습니다.

    ALTER TABLE member
    MODIFY email VARCHAR(20) NOT NULL default 'default'

    이렇게 V3__add_constraints.sql 파일을 만들었습니다. 그러면 null이 있던 row들은 email이 default가 되고 not null 제약조건이 활성화 된 것을 볼 수 있습니다.

    그러면 주어진 요구사항은 모두 만족할 수 있습니다. 거기에다 v1, v2, v3 가 나뉘어져있어서 어느 커밋부터 해당 sql이 추가되었는지도 확인할 수 있습니다.

    그리고 ddl-auto update를 사용하면 반영되지 않았던 제약조건의 추가도 확인할 수 있습니다. 그러면 ddl-auto의 속성을 validate로 변경하여, db schema와 entity의 필드가 다르면 +어플리케이션이 실행되지 않도록 해서 좀 더 안전한 개발을 할 수 있습니다.

    결론

    flyway는 roll back을 하는 것이 유료라서, production 서버에서 혹은 롤백을 해야하는 일이 있는 서버에서는 사용하는 것이 좋지 않지만, +이와 같이 데이터를 drop 할 수 없는 상황이라면, 사용하지 않을 이유가 없어보이는 좋은 도구입니다.

    짧은 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git a/page/18.html b/page/18.html index 7b5a63cc..a43c10fd 100644 --- a/page/18.html +++ b/page/18.html @@ -5,28 +5,33 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 13분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

    ------------------------
    LATEST DETECTED DEADLock
    ------------------------
    2023-07-21 01:49:54 281472560787424
    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

    *** (1) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    *** (2) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


    실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

    Mysql Dead Lock이란

    그럼 Dead Lock은 왜 생기고 언제 생길까요? -저는 이 Log를 직접 마주하기 전까지는 Dead Lock이 그냥 Lock의 시간이 오래 걸릴 때 생기는 줄 알았습니다. 하지만 그렇게 간단하게 발생하는 것은 아니였습니다.

    1. 상호 배제(Mutual Exclusion): MySQL은 기본적으로 트랜잭션 내에서 잠금(Lock)을 사용하여 데이터의 상호 배제를 제어합니다. 따라서 두 개 이상의 트랜잭션이 같은 데이터를 동시에 변경하려고 할 때, 해당 데이터에 대한 잠금이 설정되어 상호 배제 조건이 만족됩니다.

    2. 점유와 대기(Hold and Wait): 트랜잭션이 이미 하나 이상의 데이터를 잠근 상태에서 다른 데이터의 잠금을 얻기 위해 대기하고 있는 경우 점유와 대기 조건이 만족됩니다. 즉, 트랜잭션이 자신이 점유한 데이터를 유지한 상태에서 다른 데이터에 대한 잠금을 기다리고 있어야 합니다.

    3. 비선점(Non-Preemption): MySQL에서는 기본적으로 트랜잭션이 다른 트랜잭션이 점유한 데이터의 잠금을 강제로 해제할 수 없습니다. 따라서 비선점 조건이 만족됩니다.

    4. 순환 대기(Circular Wait): 두 개 이상의 트랜잭션이 각각 서로가 기다리는 데이터의 잠금을 보유해야 순환 대기 조건이 만족됩니다. 예를 들면, 트랜잭션 A가 데이터 X의 잠금을 기다리고, 트랜잭션 B는 데이터 Y의 잠금을 기다리며, 트랜잭션 C는 데이터 Z의 잠금을 기다리는 상태가 발생한다면 순환 대기 조건이 성립합니다.

    사실 기본 컴퓨터 시스템의 dead Lock과 유사한 조건입니다. 이 부분을 모두 만족해야 데드락이 발생합니다. -하나씩 알아보겠습니다. 먼저 개발 서버에서 발생한 데드락으로 살펴보겠습니다.

    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap


    -------------------------------------------------------------------------
    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    1번 트랜잭션 1000560이 charge_status 테이블에 insert ~ on duplicate key update ~ 쿼리를 발생시키기 위해 space id 64 page no 742 n bits 424 index PRIMARY of table 에 X Lock을 가지고 있습니다 -그리고 2번 트랜잭션 946331 도 똑같은 테이블에 비슷한 쿼리를 발생시키려고 합니다. 그리고 해당 트랜잭션도 X Lock을 가지고 있습니다.

    저희 팀에 데드락이 발생한 이유

    먼저 저희 팀은 공공 API를 통해 전기차 충전소 정보를 cron으로 업데이트 해주고 있습니다. -그리고 충전기의 상태도 주기적으로 업데이트 해주고 있습니다. -충전소 정보를 갱신할 경우 새로 생긴 충전소가 있다면 추가해줘야하고, 새로 생긴 충전소가 있다면 새로운 충전기가 있을테고, 그에따른 충전기 상태도 추가해줘야합니다. -그리고 원래 있던 충전소의 정보가 바뀌는 것에 대해서도 업데이트 해줘야합니다. 이렇게 된다면 여러번의 분기 처리로 application 레벨에서 해결하는 것이 나을지 혹은 mysql의 문법 중 INSERT ~~ ON DUPLICATE KEY UPDATE ~~을 이용할까 고민을 했었는데요.

    그 중 후자를 택한 이유를 말씀드리겠습니다. 충전기의 정보는 하루에 공공 API를 요청할 수 있는 key 제한이 있고 지도 기반으로 검색할 수 있는 조건이 없기 때문에 실시간으로 요청할 수 없는 구조입니다. -그래서 요청 key 제한을 넘지 않는 선에서 자주 요청을 해야합니다. 그렇게해야 사용자에게 정보에 대한 신뢰를 줄 수 있습니다. 따라서 자주 요청되는 작업에 Application 레벨에서 구현한다면, findAll() 메서드를 통해 23만건의 충전기 상태 정보를 메모리에 로딩합니다. 그리고 api의 요청을 통해 얻은 23만+n건의 충전기 상태 정보를 메모리에 올립니다.

    그리고 해당 정보가 있는지 비교합니다. 해당 정보가 findAll()로 찾아온 list에 없으면 Insert, 해당 정보가 있다면 update를 통해 갱신합니다.

    이렇게 한다면 총 batch Insert, Update를 통해 2번의 쿼리 + 46만 n건의 충전기 정보를 메모리에 올려 비교하는 연산이 생길 것 입니다. -하지만 INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 를 사용한다면 batch insert를 통해 1번의 쿼리로 해결 할 수 있습니다. 메모리에 데이터도 적재하지 않고 말이죠.

    하지만 보셨던 것과 같이 데드락이 발생했습니다. 이유는 INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 의 특수한 Lock mechanism 때문입니다. 해당 쿼리는

    1. 먼저 삽입하려는 행이 테이블에 존재하는지 확인합니다.
    2. 그리고 읽은 record에 대해 S Lock을 설정합니다.
    3. 그리고 해당 record가 duplicate key라는 조건이라면 X Lock을 설정합니다.

    이런 방식으로 작동하기 때문입니다. -이런 방식이 왜 문제가 될 수 있는지 알아보겠습니다. -먼저 자신이 읽은 record에 S Lock을 각각의 트랜잭션이 설정합니다. -그리고 업데이트를 하기위해 record에 X Lock을 설정하려고 합니다. 하지만 각각의 트랜잭션이 서로 S Lock을 설정했기 때문에 S Lock을 반납하고 X Lock을 설정하려고 해도 두 트랜잭션 모두 기다리는 데드락 상황이 발생하기 때문입니다. 이런 상황이 되면 아까 위에서 말씀드렸던 데드락의 조건 4가지가 다 만족하고 데드락이 발생합니다.

    해결 방안

    해결 방안은 여러가지가 있을 수 있습니다. -그 중 제 수준에서 생각할 수 있는 방법은 2가지가 있습니다.

    1. 트랜잭션을 작게 분리 -트랜잭션을 오래 가지고 있으면 Lock을 가지고 있는 시간이 오래걸립니다. -그래서 트랜잭션을 작게 분리할 수 있습니다. 페이징을 통해 트랜잭션을 작게 분리하다보면 쿼리가 여러번 나가 성능상 문제가 생길 수 있을 것 같습니다.
    2. INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 사용하지 않기 -해당 sql이 아닌 INSERT IGNORE을 사용하여 추가된 정보만 넣고, update는 다른 작업으로 분리하기

    이런 방법들을 사용하면 될 것 같았습니다. 그 중 저는 현재는 간단하게 2번째 방법이 제일 나을 것 같다는 생각에 쿼리를 수정했습니다.

    그리고 문제를 해결했습니다. 해당 문제가 발생하게 되어 좀 더 재밌는 것들을 고민하고 공부할 수 있는 저희 팀에게 감사하고 모르는 키워드를 많이 알려준 누누에게 감사합니다.

    아직 배우는 단계라 정확한 정보가 아닐 수 있습니다. 부족한 부분에 대해 많은 지적 부탁드립니다.

    - - +

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. +error-log

    왜 발생했을까

    먼저 간단히 저희가 처한 상황에 대해 설명드리겠습니다.

    처음 어플리케이션을 실행하면 공공 API를 호출하여 충전소와 충전기에 대한 모든 정보들을 가져와 저장합니다. (충전소 약 6만 곳 + 충전기 약 23만 기)

    하지만 이러한 정보들은 수정이 될 수 있고, 충전소와 충전기가 추가될 수 있습니다.

    그러므로 정확한 정보가 사용자에게 가장 중요시되는 서비스에서 이러한 정보들이 늦게 반영이 된다거나, 반영이 되지 않는다면 저희 서비스를 사용할 사용자가 없을 것이라 판단했습니다.

    그래서 하루에 한 번 충전소와 충전기들의 정보를 업데이트하고, 추가된 충전소와 충전기를 저장하는 로직을 만들었습니다.

    대략적인 로직은 아래와 같습니다.

        public void updatePeriodicStations() {
    List<Station> stations = requestStations();
    stationUpdateService.updateStations(stations);
    }

    public void updateStations(List<Station> updatedStations) {
    List<Station> stations = stationRepository.findAllFetch();

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    }

    간단하게 말씀드리면 requestStations() 메서드는 공공 API에서 모든 충전소와 충전기를 요청하고 받아오는 메서드입니다. 23만 + 6만개의 정보를 받아오는 것입니다. +이렇게 많은 정보를 받아오고 메모리에 올린다는 것은 누가봐도 비효율적입니다. 하지만 이러한 선택을 한 이유는 공공 API는 저희가 어떤 방식으로 보내줄 지 모른다는 것이였습니다. +그래서 어쩔 수 없이 23만건을 모두 요청해야한다는 부분은 바꿀 수 없는 한계입니다.

    그 다음으로는 요청해서 받아온 데이터들과 데이터베이스에 저장되어 있던 데이터들을 findAll()을 통해 비교하고 새로운 충전소와 충전기는 저장하고, 업데이트된 충전소와 충전기는 수정합니다.

    이런 로직은 총 (23 + 6) * 2 만건의 객체 약 58만개를 Heap 메모리에 적재합니다. 많다고는 생각했지만, 일단 제 로컬환경에서는 잘 작동했고, 기능 구현이 우선이기 때문에 추후에 개선을 하기로 하고 넘어갔습니다.

    하지만 개발 서버 배포를 하고 다음날 서버가 접속이 되지 않는 것을 확인했고, 로그를 보니 위의 사진과 같이 OOM이 발생한 것을 확인할 수 있었습니다.

    해결 방안

    Heap size 조절하기

    일단 임시 방편으로 Heap memory의 최대 크기를 늘리는 법이였습니다. JVM은 실행되는 환경에 따라 힙 메모리의 최대 사이즈를 정합니다. 힙 메모리는 설정하지 않으면 해당 환경의 메모리 1/4로 설정합니다. +그래서 저희 EC2 인스턴스의 메모리는 약 2기가로, 약 500MB가 할당되어 있었습니다. 그래서 저희는 메모리를 조금씩 늘려가며 조정하여 약 1기가로 힙 메모리의 최대 사이즈를 정했습니다. +힙 메모리의 설정을 하는 방법은 간단합니다.

    java -Xms512m -Xmx1024m boxster.jar

    실행할 때 이러한 방식으로 하면 최소 힙 메모리 사이즈는 512MB, 최대 1024MB로 설정할 수 있습니다.

    페이징해서 가져오기

    힙 메모리의 사이즈를 조절해서 해결한다는 부분은 임시 방편이지 만약 저희 EC2 환경이 다운그레이드 되거나 한다면 또 OOM이 발생할 것이 뻔합니다. 그래서 어플리케이션 레벨에서 좀 더 해결할 방안이 필요합니다.

    API의 요청에 대한 부분은 요청보내는 회사의 정책이 바뀌지 않는 이상 저희는 23만건을 모두 로딩해야한다는 점은 어쩔 수 없습니다. 그렇다면 저희가 제어할 수 있는 유일한 부분은 데이터베이스에서 데이터를 꺼내오는 부분 밖에 없습니다.

    그렇다면 이것을 어떻게 조절할 수 있을까요.

    여러 방법을 찾아보던 중 No Offset방식으로 데이터를 페이징한다는 글을 읽었습니다. 페이징을 하기위해서는 어디서부터 시작하고 어디까지 가져올 것인지 정해야합니다. 그 중 먼저 제일 자주 사용되는 Offset 방식에 대해 간단히 설명드리겠습니다.

    해당 방식은

    SELECT *
    FROM station
    ORDER BY id DESC
    OFFSET 20000
    LIMIT 10000

    이러한 쿼리를 만들어 요청합니다. station 테이블의 20001번째 레코드부터 10000개의 데이터를 요청하는 방식입니다. 이러한 쿼리도 나쁘지 않습니다. +장점으로는 언제든 해당 페이지로 이동할 수 있다는 점입니다.

    하지만 이 쿼리에는 단점이 있습니다. 뒤로 갈수록 성능이 나빠진다는 점입니다. 20001번째 레코드부터 10000개를 요청한다면 데이터베이스는 어쩔 수 없이 20001번째 레코드를 찾기 위해 +정렬을 하고, 정렬한 후에 20001번째까지 세어가며 읽고, 거기서부터 10000개의 레코드를 반환하기 때문입니다. +offset

    한 문장으로 정의하면, 순서를 알아야하기 때문에 내가 필요하지 않는 레코드도 읽어야 하기 때문입니다.

    No Offset

    그럼 No offset 방식에 대해 설명드리겠습니다.

    사실 이름만 들으면 어려울 것 같지만 그냥 offset을 사용하지 않고 페이징하는 것입니다.

    스크롤을 내리면서 자동으로 마지막의 데이터를 기준으로 다음 몇개의 레코드를 불러오는 방식이기 때문입니다. +해당 방식은

    select *
    from station
    where id < 마지막으로 보낸 id
    order by id desc
    limit 10000;

    이러한 쿼리로 작동합니다. 아까와는 다른 부분은 where 절에 마지막으로 보낸 id라는 정보가 필요하다는 부분과, offset이 사라진 부분입니다.

    같은 결과를 만들어내는 쿼리지만, 하나가 추가되고 하나가 사라졌다는 것은 추가된 부분이 사라진 부분을 대신한다는 뜻이겠죠.

    이 이러한 방식의 단점은 offset을 이용한 방식과는 다르게 page를 지정해서 돌아가기는 힘듭니다.

    no offset

    마지막으로 보낸 id를 받아 인덱스를 이용해 해당 id에서부터 레코드를 반환합니다. 굳이 필요없는 레코드를 읽을 필요 없기 때문에 성능이 좋아졌을 것이라 예상할 수 있습니다.

    성능 차이

    바로 한번 두 개의 쿼리를 실행해보겠습니다. +test

    위의 쿼리는 no offset, 아래는 offset 방식입니다. 현재 데이터가 6만건 들어있는 테이블의 조회 기준으로 약 10배 가량 성능이 차이나는 것을 확인할 수 있습니다.

    그럼 실행 계획도 간단히 알아보겠습니다.

    먼저 offset 방식의 실행 계획입니다. +offset explain

    type 칼럼을 보시면 index라고 되어 있는 것을 확인할 수 있습니다. 여기서 index 접근 방법은 +인덱스를 효율적으로 사용하는 것이 아닌 인덱스를 처음부터 끝까지 읽는 full scan을 뜻합니다. 그래서 그다지 효율적이지 못한 방법입니다. +그리고 rows 칼럼에는 40010이라고 되어있습니다. 해당 부분은 제가 offset을 40000, limit을 10으로 두었기 때문에 40010d의 row를 +읽어야한다고 예상 값을 나타낸 것입니다.

    다음은 no offset 방식의 실행 계획입니다. +no offset explain +아까와는 다르게 type 칼럼은 range입니다. range 접근 방식은 인덱스를 하나의 값이 아니라 범위로 검색하는 경우를 의미합니다. +좀 전의 index 접근 방식과는 다르게 훨씬 효율적인 접근 방식입니다. 그리고 rows도 달라진 것을 확인할 수 있습니다.

    진짜 해결하기

    이제 열심히 페이징 처리를 했으니 어플리케이션에서 해결을 하도록 만들어야합니다.

    저희 팀은 동적 쿼리 생성을 도와주는 Query DSL을 도입하지 않았고 아직까진 굳이 필요하지 않아서 no offset 방식을 jpa의 jpql을 통해 구현해보겠습니다.

    먼저 첫 페이지는 id의 관계없이 원하는 갯수만큼만 가져오면 됩니다. 그리고 두번째 페이지부터는 id를 받아 그 다음부터 반환하면 됩니다.

    public interface StationRepository extends Repository<Station, Long> {

    @Query("SELECT s FROM Station s INNER JOIN FETCH s.chargers ORDER BY s.stationId")
    List<Station> findAllByOrder(Pageable pageable);

    @Query("SELECT s FROM Station s INNER JOIN FETCH s.chargers WHERE s.stationId > :stationId ORDER BY s.stationId")
    List<Station> findAllByPaging(@Param("stationId") String stationId, Pageable pageable);
    }

    그럼 아까 update를 해주던 메서드에서 조금 수정해보겠습니다.

        public void updatePeriodicStations() {
    List<Station> stations = getStations();
    // 처음에는 station의 id가 null
    String lastStationId = null;
    for (int i = 0; i < stations.size() / LIMIT + 1; i++) {
    // 마지막 id를 메서드 실행할 때마다 변경해준다.
    lastStationId = stationUpdateService.updateStations(stations, lastStationId, LIMIT);
    }
    }

    public String updateStations(List<Station> updatedStations, String lastStationId, int limit) {
    List<Station> savedStations = getStations(lastStationId, limit);

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    // 가져온 list에서 제일 마지막 station의 id를 반환
    return getLastStationId(savedStations);
    }
    // 페이징 처리
    private List<Station> getStations(String stationId, int limit) {
    // Id 가 null 이라면 첫 페이지이기 때문에 limit 사이즈만큼 select
    if (stationId == null) {
    return stationRepository.findAllByOrder(Pageable.ofSize(limit));
    }
    // 아니라면 station Id 부터 limit 만큼
    return stationRepository.findAllByPaging(stationId, Pageable.ofSize(limit));
    }

    이렇게 되면 원래 23만개를 한꺼번에 가져오던 로직을 나눌 수 있기 때문에 Heap 메모리의 여유가 생길 것입니다.

    진짜 확인해보기

    물론 GC의 동작이 어떨지 모르겠지만 23만개 인스턴스를 생성하는 것보다 5000개 혹은 더 적게 생성하는 것이 Heap 메모리를 적게 사용할 것임을 유추할 수 있습니다. +하지만 직접 확인해보기 전까지는 확신할 수 없으니 간단히 Runtime 클래스에서 제공해주는 totalMemory(), freeMemory() 메서드를 통해 알아보겠습니다.

        @Test
    void 페이징을_사용한_조회() {
    List<Station> stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();
    System.out.println("paging 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    @Test
    void 페이징을_사용하지_않고_조회() {
    List<Station> stations = stationRepository.findAllFetch();

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();

    System.out.println("findAll() 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    findAll +paging +확연히 차이가 나는 것을 확인할 수 있습니다.

    물론 테스트코드에서는 23만건의 API 요청은 같은 조건이니 배제하고 확인했습니다.

    이로써 하나의 문제가 또 해결된 것 같습니다.

    아직 배우는 단계라 혹시 틀린 점이 있다면 지적 부탁드리겠습니다.

    Reference

    + + \ No newline at end of file diff --git a/page/19.html b/page/19.html index 0837ea5d..cd3a3339 100644 --- a/page/19.html +++ b/page/19.html @@ -5,20 +5,28 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 11분
    제이

    안녕하세요~

    우테코 카페인 팀의 제이입니다.

    오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

    요구 사항과 기능 구현 목록

    카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

    사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

    그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

    저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

    또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

    필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

    필터링 기능 구현하기

    저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

    따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

    사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

    3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

    그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

    그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

    1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

    2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

    이렇게 두 가지 방법이 있었습니다.

    1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

    // 1. fetch join + 회사 이름만 조회
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND s.companyName IN :companyNames")
    List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("companyNames") List<String> companyNames);

    // 2. fetch join + 충전 타입
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND c.type IN :types")
    List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("types") List<ChargerType> types);

    진행 했다면 이런 느낌이었겠네요!

    2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

    현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

    어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

    따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

    이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

    하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

    결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

    이 이유는 다음과 같습니다.
    1. 어차피 사용자는 작은 범위에서 조회를 한다.
    2. 인덱스를 걸었을 때 가장 효율적이다.

    1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

    저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

    이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

    JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

    그럴 순 없었을 것 같습니다.

    가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

    이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

    반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

    어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

    조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

    인덱스 적용으로 조회 속도 향상시키기

    먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

    Hibernate:
    select
    station0_.station_id as station_1_0_0_,
    ...
    ...
    ...
    chargersta2_.latest_update_time as latest_u4_2_2_
    from
    charge_station station0_
    left outer join
    charger chargers1_
    on station0_.station_id=chargers1_.station_id
    left outer join
    charger_status chargersta2_
    on chargers1_.charger_id=chargersta2_.charger_id
    and chargers1_.station_id=chargersta2_.station_id
    where
    (
    station0_.latitude between ? and ?
    )
    and (
    station0_.longitude between ? and ?
    )

    where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

    따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. -Mysql Query Between 과 >=, <= 성능 차이 비교 ( 더미데이터 50만 ) -

    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude " +
    "AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude")
    List<Station> findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude);

    위와 같이 조회해주는 쿼리를 만들었고, 인덱스를 만들어주었습니다.

    인덱스 설정 기준은 인덱스 정리 및 팁 -위에 링크와 같이 동욱님의 블로그를 참조해서 기준을 세웠습니다.

    무조건 카디널리티가 높은 것을 설정할 순 없었기 때문에 (업데이트와 삽입 작업이 많기 때문에) 쿼리에서 사용되는 column과 update 작업을 고려하고 성능을 비교해가면서 가장 효율적인 것을 설정해주었습니다.

    그리고 속도를 비교해주었습니다.



    먼저 속도 비교를 위해서 데이터 셋은 다음과 같이 진행하였습니다.
    • Charger (23만 건)
    • Station (6만 건)
    • ChargerStatus(23만 건)
    • 선릉역 근처 조회

    Ver1. 인덱스 적용을 하지 않고 조회 및 필터링 했을 때 속도 (0.84초)

    이미지 -평균적으로 0.84초가 나왔습니다.

    Ver2. 인덱스 적용 및 조회 및 필터링 했을 때 속도 (0.63초)

    이미지 -평균적으로 0.63초가 나왔습니다. -약 25 ~ 30%의 조회 속도가 개선되었습니다.

    아직 이 부분은 개선이 더 필요해보입니다.

    그래도 개선이 됐고, 삽입과 갱신에는 큰 지장이 없어서 일단 이정도로 마무리 하고, 추후에 개선을 해보도록 하겠습니다.

    이미지 -추가적으로 충전기 조회는 굉장히 빨라졌습니다!

    배우는 단계이다보니 미숙하고 틀린 부분이 있을 수 있습니다.

    긴 글 읽어주셔서 감사합니다 :)

    - - +

    · 약 13분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

    ------------------------
    LATEST DETECTED DEADLock
    ------------------------
    2023-07-21 01:49:54 281472560787424
    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

    *** (1) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    *** (2) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


    실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

    Mysql Dead Lock이란

    그럼 Dead Lock은 왜 생기고 언제 생길까요? +저는 이 Log를 직접 마주하기 전까지는 Dead Lock이 그냥 Lock의 시간이 오래 걸릴 때 생기는 줄 알았습니다. 하지만 그렇게 간단하게 발생하는 것은 아니였습니다.

    1. 상호 배제(Mutual Exclusion): MySQL은 기본적으로 트랜잭션 내에서 잠금(Lock)을 사용하여 데이터의 상호 배제를 제어합니다. 따라서 두 개 이상의 트랜잭션이 같은 데이터를 동시에 변경하려고 할 때, 해당 데이터에 대한 잠금이 설정되어 상호 배제 조건이 만족됩니다.

    2. 점유와 대기(Hold and Wait): 트랜잭션이 이미 하나 이상의 데이터를 잠근 상태에서 다른 데이터의 잠금을 얻기 위해 대기하고 있는 경우 점유와 대기 조건이 만족됩니다. 즉, 트랜잭션이 자신이 점유한 데이터를 유지한 상태에서 다른 데이터에 대한 잠금을 기다리고 있어야 합니다.

    3. 비선점(Non-Preemption): MySQL에서는 기본적으로 트랜잭션이 다른 트랜잭션이 점유한 데이터의 잠금을 강제로 해제할 수 없습니다. 따라서 비선점 조건이 만족됩니다.

    4. 순환 대기(Circular Wait): 두 개 이상의 트랜잭션이 각각 서로가 기다리는 데이터의 잠금을 보유해야 순환 대기 조건이 만족됩니다. 예를 들면, 트랜잭션 A가 데이터 X의 잠금을 기다리고, 트랜잭션 B는 데이터 Y의 잠금을 기다리며, 트랜잭션 C는 데이터 Z의 잠금을 기다리는 상태가 발생한다면 순환 대기 조건이 성립합니다.

    사실 기본 컴퓨터 시스템의 dead Lock과 유사한 조건입니다. 이 부분을 모두 만족해야 데드락이 발생합니다. +하나씩 알아보겠습니다. 먼저 개발 서버에서 발생한 데드락으로 살펴보겠습니다.

    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap


    -------------------------------------------------------------------------
    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    1번 트랜잭션 1000560이 charge_status 테이블에 insert ~ on duplicate key update ~ 쿼리를 발생시키기 위해 space id 64 page no 742 n bits 424 index PRIMARY of table 에 X Lock을 가지고 있습니다 +그리고 2번 트랜잭션 946331 도 똑같은 테이블에 비슷한 쿼리를 발생시키려고 합니다. 그리고 해당 트랜잭션도 X Lock을 가지고 있습니다.

    저희 팀에 데드락이 발생한 이유

    먼저 저희 팀은 공공 API를 통해 전기차 충전소 정보를 cron으로 업데이트 해주고 있습니다. +그리고 충전기의 상태도 주기적으로 업데이트 해주고 있습니다. +충전소 정보를 갱신할 경우 새로 생긴 충전소가 있다면 추가해줘야하고, 새로 생긴 충전소가 있다면 새로운 충전기가 있을테고, 그에따른 충전기 상태도 추가해줘야합니다. +그리고 원래 있던 충전소의 정보가 바뀌는 것에 대해서도 업데이트 해줘야합니다. 이렇게 된다면 여러번의 분기 처리로 application 레벨에서 해결하는 것이 나을지 혹은 mysql의 문법 중 INSERT ~~ ON DUPLICATE KEY UPDATE ~~을 이용할까 고민을 했었는데요.

    그 중 후자를 택한 이유를 말씀드리겠습니다. 충전기의 정보는 하루에 공공 API를 요청할 수 있는 key 제한이 있고 지도 기반으로 검색할 수 있는 조건이 없기 때문에 실시간으로 요청할 수 없는 구조입니다. +그래서 요청 key 제한을 넘지 않는 선에서 자주 요청을 해야합니다. 그렇게해야 사용자에게 정보에 대한 신뢰를 줄 수 있습니다. 따라서 자주 요청되는 작업에 Application 레벨에서 구현한다면, findAll() 메서드를 통해 23만건의 충전기 상태 정보를 메모리에 로딩합니다. 그리고 api의 요청을 통해 얻은 23만+n건의 충전기 상태 정보를 메모리에 올립니다.

    그리고 해당 정보가 있는지 비교합니다. 해당 정보가 findAll()로 찾아온 list에 없으면 Insert, 해당 정보가 있다면 update를 통해 갱신합니다.

    이렇게 한다면 총 batch Insert, Update를 통해 2번의 쿼리 + 46만 n건의 충전기 정보를 메모리에 올려 비교하는 연산이 생길 것 입니다. +하지만 INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 를 사용한다면 batch insert를 통해 1번의 쿼리로 해결 할 수 있습니다. 메모리에 데이터도 적재하지 않고 말이죠.

    하지만 보셨던 것과 같이 데드락이 발생했습니다. 이유는 INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 의 특수한 Lock mechanism 때문입니다. 해당 쿼리는

    1. 먼저 삽입하려는 행이 테이블에 존재하는지 확인합니다.
    2. 그리고 읽은 record에 대해 S Lock을 설정합니다.
    3. 그리고 해당 record가 duplicate key라는 조건이라면 X Lock을 설정합니다.

    이런 방식으로 작동하기 때문입니다. +이런 방식이 왜 문제가 될 수 있는지 알아보겠습니다. +먼저 자신이 읽은 record에 S Lock을 각각의 트랜잭션이 설정합니다. +그리고 업데이트를 하기위해 record에 X Lock을 설정하려고 합니다. 하지만 각각의 트랜잭션이 서로 S Lock을 설정했기 때문에 S Lock을 반납하고 X Lock을 설정하려고 해도 두 트랜잭션 모두 기다리는 데드락 상황이 발생하기 때문입니다. 이런 상황이 되면 아까 위에서 말씀드렸던 데드락의 조건 4가지가 다 만족하고 데드락이 발생합니다.

    해결 방안

    해결 방안은 여러가지가 있을 수 있습니다. +그 중 제 수준에서 생각할 수 있는 방법은 2가지가 있습니다.

    1. 트랜잭션을 작게 분리 +트랜잭션을 오래 가지고 있으면 Lock을 가지고 있는 시간이 오래걸립니다. +그래서 트랜잭션을 작게 분리할 수 있습니다. 페이징을 통해 트랜잭션을 작게 분리하다보면 쿼리가 여러번 나가 성능상 문제가 생길 수 있을 것 같습니다.
    2. INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 사용하지 않기 +해당 sql이 아닌 INSERT IGNORE을 사용하여 추가된 정보만 넣고, update는 다른 작업으로 분리하기

    이런 방법들을 사용하면 될 것 같았습니다. 그 중 저는 현재는 간단하게 2번째 방법이 제일 나을 것 같다는 생각에 쿼리를 수정했습니다.

    그리고 문제를 해결했습니다. 해당 문제가 발생하게 되어 좀 더 재밌는 것들을 고민하고 공부할 수 있는 저희 팀에게 감사하고 모르는 키워드를 많이 알려준 누누에게 감사합니다.

    아직 배우는 단계라 정확한 정보가 아닐 수 있습니다. 부족한 부분에 대해 많은 지적 부탁드립니다.

    + + \ No newline at end of file diff --git a/page/2.html b/page/2.html index 0b9c0d71..2fe98528 100644 --- a/page/2.html +++ b/page/2.html @@ -5,29 +5,15 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. -저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. -진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! -진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. -아무 충전소를 눌러서 진주시로 이동하는 것은 가능했습니다.

    여기에서 저는 이 과정에서 도시나 지역 검색 기능이 반드시 필요하다고 생각했습니다.

    하지만 너무 멀었습니다. -왕복 700km를 생각해야하여 1박 2일이 필수였고, 팀원들 간에 일정을 조정하기가 너무 어려웠습니다. -따라서 다른 도시를 찾아보기로 했습니다.

    no offset

    그러던 중, 제가 전에 방문했던 파주시의 마장호수가 생각났습니다. -서울에서 꽤나 먼 거리(약 50km)에 있었고, 적당히 시간을 보낼만한 장소였습니다. -다행히도 충전소의 이름이 마장호수관리사무소여서 카페인 서비스를 통해 바로 찾을 수 있었습니다. -심지어 마장호수 주변에는 충전소가 많지 않은 편이었고, 초급속 충전기가 있어 저희 앱을 실험하기에 딱 좋았습니다.

    마장호수로 출발

    가브리엘제이, 박스터는 서울 선정릉역에서 아이오닉5를 렌트하고 마장호수로 출발했습니다.

    no offset

    처음 계획했던 것 처럼 타사의 앱을 사용하지 않고 마장호수를 검색하여 이동했습니다.

    no offset

    전날 이미 검색을 했지만, 혹시 사용 중일수도 있기에 한번 더 검색해봤으며 해당 시간대에 충전소가 평소에 덜 붐빌 것이라는 통계 자료를 확인했습니다.

    no offset

    no offset

    no offset

    마장 호수까지 20분 거리를 남기고, 갑자기 배가 고파진 저희는 목적지를 틀어 파주닭국수 본점을 가기로 했습니다.

    파주닭국수가 어디에 있지?

    카페인 서비스를 활용하여 파주닭국수 본점 근처의 충전소를 검색해보기로 했습니다. -자동차 내비게이션에는 파주닭국수가 어디인지 나와있지만, 저희 서비스에는 식당 정보는 존재하지 않았습니다. -해당 식당이 도대체 어디에 있는지 확인할 수 없었습니다. (파주닭국수에서는 전기차 충전소가 없었기 떄문입니다.)

    no offset

    따라서 저희는 자동차 내비게이션에 있는 도로명 주소를 검색하여 위치를 파악하려고 하였고, 다소 부정확 하지만 동네에 있는 인근 충전소를 찾을 수 있었습니다.

    휴게소에 들리다

    카페인 서비스로 검색해보니 식당으로 가는 길 휴게소에도 충전소가 있다고 합니다. -휴게소 이름을 입력하니 바로 나왔습니다.

    no offset

    심지어 지금 사용중이라고 합니다! 따라서 저희는 확인해보기 위해 휴게소에 들리기로 했습니다.

    no offset -no offset

    실제로 사용 중임을 확인했습니다. 저희 서비스에서 사용중이라고 나왔는데 실제로 사용중인 것을 보니 공공 api가 나름 실시간으로 데이터를 잘 보내주고 있다고 생각하게 되었고, 저희 팀 서버에서도 이를 제대로 수집하고 있다고 생각하였습니다.

    no offset

    말로만 듣던 고속도로 휴게소의 전기차 충전소 대기줄을 직접 확인할 수 있었습니다. -차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? -이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset -no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    - - +

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset +no offset

    충전기를 꽂고 나서 알게된 것은 카페인 서비스에 나온 충전소 회사명과 방금 꽂은 충전기 회사명이 다르다는 것이었습니다.

    알고보니 음성 인식으로 네비에 검색한 충전소는 판교공영주차장이 아닌 판교역 환승 주차장이라 엉뚱한 곳으로 온 것이었습니다!!!

    다행인 점은 우리 서비스에서 제공하는 충전기 사용 여부 정보가 잘못된 것이 아니었다는 것이었습니다.

    그래서 애초에 가고자 했던 판교공영주자창에 대한 카페인 서비스의 정보가 실제와 동일한지 확인해보러 걸어서 이동했습니다. (바로 앞에 있었기 때문입니다.)

    no offset +no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    + + \ No newline at end of file diff --git a/page/20.html b/page/20.html index 1d77acb0..6530298d 100644 --- a/page/20.html +++ b/page/20.html @@ -5,13 +5,20 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    - - +

    · 약 11분
    제이

    안녕하세요~

    우테코 카페인 팀의 제이입니다.

    오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

    요구 사항과 기능 구현 목록

    카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

    사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

    그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

    저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

    또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

    필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

    필터링 기능 구현하기

    저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

    따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

    사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

    3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

    그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

    그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

    1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

    2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

    이렇게 두 가지 방법이 있었습니다.

    1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

    // 1. fetch join + 회사 이름만 조회
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND s.companyName IN :companyNames")
    List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("companyNames") List<String> companyNames);

    // 2. fetch join + 충전 타입
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND c.type IN :types")
    List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("types") List<ChargerType> types);

    진행 했다면 이런 느낌이었겠네요!

    2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

    현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

    어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

    따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

    이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

    하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

    결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

    이 이유는 다음과 같습니다.
    1. 어차피 사용자는 작은 범위에서 조회를 한다.
    2. 인덱스를 걸었을 때 가장 효율적이다.

    1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

    저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

    이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

    JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

    그럴 순 없었을 것 같습니다.

    가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

    이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

    반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

    어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

    조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

    인덱스 적용으로 조회 속도 향상시키기

    먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

    Hibernate:
    select
    station0_.station_id as station_1_0_0_,
    ...
    ...
    ...
    chargersta2_.latest_update_time as latest_u4_2_2_
    from
    charge_station station0_
    left outer join
    charger chargers1_
    on station0_.station_id=chargers1_.station_id
    left outer join
    charger_status chargersta2_
    on chargers1_.charger_id=chargersta2_.charger_id
    and chargers1_.station_id=chargersta2_.station_id
    where
    (
    station0_.latitude between ? and ?
    )
    and (
    station0_.longitude between ? and ?
    )

    where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

    따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. +Mysql Query Between 과 >=, <= 성능 차이 비교 ( 더미데이터 50만 ) +

    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude " +
    "AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude")
    List<Station> findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude);

    위와 같이 조회해주는 쿼리를 만들었고, 인덱스를 만들어주었습니다.

    인덱스 설정 기준은 인덱스 정리 및 팁 +위에 링크와 같이 동욱님의 블로그를 참조해서 기준을 세웠습니다.

    무조건 카디널리티가 높은 것을 설정할 순 없었기 때문에 (업데이트와 삽입 작업이 많기 때문에) 쿼리에서 사용되는 column과 update 작업을 고려하고 성능을 비교해가면서 가장 효율적인 것을 설정해주었습니다.

    그리고 속도를 비교해주었습니다.



    먼저 속도 비교를 위해서 데이터 셋은 다음과 같이 진행하였습니다.
    • Charger (23만 건)
    • Station (6만 건)
    • ChargerStatus(23만 건)
    • 선릉역 근처 조회

    Ver1. 인덱스 적용을 하지 않고 조회 및 필터링 했을 때 속도 (0.84초)

    이미지 +평균적으로 0.84초가 나왔습니다.

    Ver2. 인덱스 적용 및 조회 및 필터링 했을 때 속도 (0.63초)

    이미지 +평균적으로 0.63초가 나왔습니다. +약 25 ~ 30%의 조회 속도가 개선되었습니다.

    아직 이 부분은 개선이 더 필요해보입니다.

    그래도 개선이 됐고, 삽입과 갱신에는 큰 지장이 없어서 일단 이정도로 마무리 하고, 추후에 개선을 해보도록 하겠습니다.

    이미지 +추가적으로 충전기 조회는 굉장히 빨라졌습니다!

    배우는 단계이다보니 미숙하고 틀린 부분이 있을 수 있습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + \ No newline at end of file diff --git a/page/21.html b/page/21.html index 78ab74c0..b6a25b5f 100644 --- a/page/21.html +++ b/page/21.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 9분
    가브리엘

    안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

    서버 상태와 클라이언트 상태의 구분

    서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

    React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

    상태의 특성으로는 크게 두 가지가 있습니다.

    클라이언트 상태

    클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

    서버 상태

    서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

    기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

    더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

    왜 Tanstack Query였나?

    vs RTK Query

    RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

    vs SWR

    SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

    카페인 팀에서 하려는 일은요…

    저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

    메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

    • 지도에서 충전소 조회
      • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
      • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
      • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
      • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
    • 전국 충전소 검색기
      • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
      • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
      • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
      • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

    프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

    - - +

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    + + \ No newline at end of file diff --git a/page/22.html b/page/22.html index 3935bb02..f87c6cf8 100644 --- a/page/22.html +++ b/page/22.html @@ -5,26 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 13분
    박스터

    OAuth 2.0 ?

    OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

    위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 -굳이 한번 더 입력하지 않고도 다른 웹 사이트에서 사용할 수 있는 것 입니다. 그리고 다른 웹 사이트를 사용하더라도 google에서 로그인을 하는 과정을 거치기 때문에, 사용자는 -비밀번호나, critical한 개인정보 같은 것을 한 곳에서 관리할 수 있다는 장점이 있습니다.

    다시 한번 정리하자면 우리 웹 사이트의 사용자가 이용하는 다른 웹 사이트의 정보를 사용할 수 있게끔 다른 웹 사이트에서 권한을 위임 받는 것 입니다.

    OAuth flow

    OAuth Flow를 설명하기 전에 여기서 모르는 단어들이 많습니다. -해당 링크에서 더 자세하게 정리 되어있지만 설명해보겠습니다.

    Resource Owner

    Resource Owner는 말 그대로 리소스 소유자이고, 구글과 같은 플랫폼에 회원가입이 되어있는, 즉 구글에 자신의 정보들이 있는 사용자입니다.

    Client

    Client도 말 그대로 고객입니다. 하지만 어떤 관점에서 보느냐 고객이란 뜻은 달라집니다. 여기서는 Google과 같은 플랫폼에서 제공받은 리소스를 사용하는 고객입니다. -즉 우리의 서비스가 Client가 되는 것입니다. 왜냐면 우리는 구글에 정보를 요청하고 우리의 서비스에서 사용하기 때문입니다.

    Authorization Server

    여기도 말 그대로 인증 서버입니다. Resource Owner가 올바른 정보를 입력했는지 검증하고, 발급 받은 Code와 Token이 올바른 것인지 검증합니다.

    Resource Server

    Resource Owner의 정보들을 가지고 있는 서버입니다. 인증 서버에서 인증을 마치고 난 뒤 우리는 Resource를 받습니다.

    하지만 여기서 Authorization Server 와 Resource Server가 나뉘어진 이유는 딱히 없습니다. 해당 플랫폼의 서버 구성에 따라 다를 수 있습니다.

    중요한 것은 Authorization ServerResource Server가 같은 묶음이라는 것 입니다.

    간단하게 flow를 도식화 했습니다.

    1. 먼저 Resource Owner는 로그인을 하고 싶다면 Client가 제공하는 해당 Resource platform의 URI를 클릭합니다. 그리고 인증서버에서 로그인 페이지를 제공 받습니다.
    2. 그리고 Resource Owner는 ID/PW를 입력하고 Authorization Code를 발급 받습니다. 동시에 Client에서 등록해놓은 Redirect URI로 code와 함께 이동합니다.
    3. Client는 Resource Owner에게 받은 Code를 가지고 Authorization Server에 토큰을 요청합니다. 그리고 받은 토큰을 저장합니다.
    4. 그리고 Client는 로그인을 성공하고 이후 다른 platform에서 정보를 필요하게 된다면 저장한 Access Token을 통해 Resource Server에서 정보를 가져옵니다.

    근데 여기서 이상한 점이 있습니다. 이상하다기보단 왜 이렇게 복잡한가 라는 의문을 가질 수 있습니다.

    굳이 Authorization code를 받아 다시 한번 더 Access Token을 받아야 한다는 부분입니다. 바로 Client에게 Access Token을 준다면 통신이 한번 줄어들 수 있지 않을까??

    보안문제 때문입니다. -만약 바로 Access Token을 준다면 그 Access Token이 탈취 당하면 해당 Resource Owner의 모든 정보에 접근할 수 있게 됩니다. -Code는 Secret Key와 같이 전달해야 Access Token을 발급 받을 수 있기 때문에 탈취되어도 더 안전합니다. -하지만 다른 플랫폼에서 Code나 Token이나 해당 정보를 전달할 방법은 URI에 전달하는 방법뿐 입니다. -그렇기 때문에 Redircet URI에 Access Token을 담는다면 탈취 가능성이 커지기 때문에 보안문제가 발생합니다.

    백엔드와 프론트엔드의 flow

    아까 Client 부분을 좀 더 Frontend, Backend로 구분지어 세분화 해봤습니다. 복잡해보이지만, 전혀 어렵지 않습니다. 아까 설명했던 흐름과 다른 부분은 없습니다.

    또 여기서는 굳이 제가 Authorization Server에서 Code를 받아올 때 Redirect URI를 백엔드 서버로 하지않고 프론트엔드 서버로 하려는 이유는 Resource Owner가 다른 platform과 인증하는 부분은 백엔드의 역할이 아니라고 생각했습니다. -그리고 백엔드는 Resource Owner가 가져온 code를 프론트엔드에서 전달 받아 Resource Server에 정보를 요청하는 것이라고 생각했습니다. -(물론 제 개인적인 의견이라 정답은 아닙니다.)

    OAuth 구현해보기

    간단히 Spring Security 없이 OAuth 인증을 구현해보겠습니다.

    제일 먼저 구글 혹은 다른 플랫폼에서 설정한 id, secret key 등등의 정보를 yml에 작성했습니다.

    application-oauth.yml
    oauth2:
    provider:
    google:
    id: google-id
    secret: google-secret-key
    redirect-url: http://localhost:8080/login/oauth2/code/google
    token-url: https://www.googleapis.com/oauth2/v4/token
    info-url: https://www.googleapis.com/oauth2/v2/userinfo

    그리고 OAuth는 어느 플랫폼이 될 지 모르고, 확장성 있게 구성하는 것이 좋을 것 같아 인터페이스로 만들었습니다.

    OAuthMember.java
    public interface OAuthMember {
    String id();
    String email();
    String nickname();
    String imageUrl();
    }

    이러한 클래스들을 관리하기 쉽게 Enum을 추가합니다.

    Provider.java
    public enum Provider {

    GOOGLE("google", GoogleMember::new),
    ;

    private final String providerName;
    private final Function<Map<String, Object>, OAuthMember> function;

    Provider(String providerName, Function<Map<String, Object>, OAuthMember> function) {
    this.providerName = providerName;
    this.function = function;
    }

    public static Provider from(String name) {
    return Arrays.stream(values())
    .filter(it -> it.providerName.equals(name))
    .findFirst()
    .orElseThrow(() -> new RuntimeExceptin());
    }

    public OAuthMember getOAuthProvider(Map<String, Object> body) {
    return function.apply(body);
    }
    }

    해당 Enum은 두개의 필드를 가지고 있습니다. 하나는 해당 플랫폼의 이름, 그리고 Map<String, Object>를 아까 만들었던 인터페이스로 반환하는 Function 여기서 -Map<String, Object>로 지정해준 이유는, 플랫폼마다 반환되는 JSON 타입이 다르기 때문에 그런 부분에 대해 중복을 제거하기 위해 이러한 형태로 만들었습니다.

    그리고 아까 yml에 작성했던 정보들을 가져와야합니다. @Value 어노테이션으로도 가져올 수 있습니다.

            @Value("oauth.provider.google.id")
    private String id;
    @Value("oauth.provider.google.secret")
    private String secret;

    ...

    하지만 이렇게 계속 binding을 해줘야한다는 점이 아주 귀찮고 보기도 안좋습니다.

    build.gradle
    annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

    하지만 위의 의존성을 추가해준다면 아주 편하게 property를 가져올 수 있습니다.

    OAuthProviderProperties.java
    @Component
    @ConfigurationProperties(prefix = "oauth2")
    public class OAuthProviderProperties {
    // prefix oauth2 기준으로 알아서 google이 이름인 Provider Enum을 찾아서 Key로 바인딩
    private final Map<Provider, OAuthProviderProperty> provider = new EnumMap<>(Provider.class);

    public OAuthProviderProperty getProviderProperties(Provider provider) {
    return this.provider.get(provider);
    }

    @Getter
    @Setter
    public static class OAuthProviderProperty {
    // 그리고 provider 하위 정보들은 아래의 필드에 바인딩
    private String id;
    private String secret;
    private String redirectUrl;
    private String tokenUrl;
    private String infoUrl;
    }
    }

    이렇게 되면 구조적인 준비는 끝났습니다.

    이제는 해당 플랫폼에 정보를 요청하는 작업만 하면 됩니다. -그럼 아까 말씀드렸던 순서로 요청을 해보겠습니다.

    RestTemplateOAuthRequester.java
    public class RestTemplateOAuthRequester implements OAuthRequester {

    @Override
    public OAuthMember login(OAuthLoginRequest request) {
    // frontend에서 받아온 로그인 platform
    Provider provider = Provider.from(request.provider());
    // 해당 Platform에 맞는 정보 찾음
    OAuthProviderProperty property = oAuthProviderProperties.getProviderProperties(provider);
    // frontend에서 받아온 code와 등록해놓은 property로 Access Token 요청
    OAuthTokenResponse token = requestAccessToken(property, requet.getCode());
    // 받아온 Token으로 해당 Resource Owner의 정보 요청
    Map<String, Object> userAttributes = getUserAttributes(property, token);
    return provider.getOAuthProvider(userAttributes);
    }

    private OAuthTokenResponse requestAccessToken(OAuthProviderProperty property, String code) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBasicAuth(property.getId(), property.getSecret());
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
    URI tokenUri = getTokenUri(property, code);
    return restTemplate.postForEntity(tokenUri, request, OAuthTokenResponse.class).getBody();
    }

    private URI getTokenUri(OAuthProviderProperty property, String code) {
    return UriComponentsBuilder.fromUriString(property.getTokenUrl())
    .queryParam(CODE, URLDecoder.decode(code, StandardCharsets.UTF_8))
    .queryParam(GRANT_TYPE, AUTHORIZATION_CODE)
    .queryParam(REDIRECT_URI, property.getRedirectUrl())
    .build()
    .toUri();
    }

    private Map<String, Object> getUserAttributes(OAuthProviderProperty property, OAuthTokenResponse tokenResponse) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(tokenResponse.accessToken());
    headers.setContentType(MediaType.APPLICATION_JSON);
    URI uri = URI.create(property.getInfoUrl());
    RequestEntity<?> requestEntity = new RequestEntity<>(headers, HttpMethod.GET, uri);
    ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(requestEntity, new ParameterizedTypeReference<>() {
    });
    return responseEntity.getBody();
    }
    }

    이렇게만 한다면 그 어려워 보이던 OAuth 인증도 간단하게 해결할 수 있습니다. -(물론 제 코드가 정답이 아닙니다)

    Reference

    https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16

    https://developers.google.com/identity/protocols/oauth2?hl=ko

    - - +

    · 약 9분
    가브리엘

    안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

    서버 상태와 클라이언트 상태의 구분

    서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

    React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

    상태의 특성으로는 크게 두 가지가 있습니다.

    클라이언트 상태

    클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

    서버 상태

    서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

    기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

    더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

    왜 Tanstack Query였나?

    vs RTK Query

    RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

    vs SWR

    SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

    카페인 팀에서 하려는 일은요…

    저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

    메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

    • 지도에서 충전소 조회
      • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
      • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
      • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
      • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
    • 전국 충전소 검색기
      • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
      • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
      • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
      • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

    프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

    + + \ No newline at end of file diff --git a/page/23.html b/page/23.html index cc7530df..b5de48a6 100644 --- a/page/23.html +++ b/page/23.html @@ -5,13 +5,26 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    - - +

    · 약 13분
    박스터

    OAuth 2.0 ?

    OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

    위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 +굳이 한번 더 입력하지 않고도 다른 웹 사이트에서 사용할 수 있는 것 입니다. 그리고 다른 웹 사이트를 사용하더라도 google에서 로그인을 하는 과정을 거치기 때문에, 사용자는 +비밀번호나, critical한 개인정보 같은 것을 한 곳에서 관리할 수 있다는 장점이 있습니다.

    다시 한번 정리하자면 우리 웹 사이트의 사용자가 이용하는 다른 웹 사이트의 정보를 사용할 수 있게끔 다른 웹 사이트에서 권한을 위임 받는 것 입니다.

    OAuth flow

    OAuth Flow를 설명하기 전에 여기서 모르는 단어들이 많습니다. +해당 링크에서 더 자세하게 정리 되어있지만 설명해보겠습니다.

    Resource Owner

    Resource Owner는 말 그대로 리소스 소유자이고, 구글과 같은 플랫폼에 회원가입이 되어있는, 즉 구글에 자신의 정보들이 있는 사용자입니다.

    Client

    Client도 말 그대로 고객입니다. 하지만 어떤 관점에서 보느냐 고객이란 뜻은 달라집니다. 여기서는 Google과 같은 플랫폼에서 제공받은 리소스를 사용하는 고객입니다. +즉 우리의 서비스가 Client가 되는 것입니다. 왜냐면 우리는 구글에 정보를 요청하고 우리의 서비스에서 사용하기 때문입니다.

    Authorization Server

    여기도 말 그대로 인증 서버입니다. Resource Owner가 올바른 정보를 입력했는지 검증하고, 발급 받은 Code와 Token이 올바른 것인지 검증합니다.

    Resource Server

    Resource Owner의 정보들을 가지고 있는 서버입니다. 인증 서버에서 인증을 마치고 난 뒤 우리는 Resource를 받습니다.

    하지만 여기서 Authorization Server 와 Resource Server가 나뉘어진 이유는 딱히 없습니다. 해당 플랫폼의 서버 구성에 따라 다를 수 있습니다.

    중요한 것은 Authorization ServerResource Server가 같은 묶음이라는 것 입니다.

    간단하게 flow를 도식화 했습니다.

    1. 먼저 Resource Owner는 로그인을 하고 싶다면 Client가 제공하는 해당 Resource platform의 URI를 클릭합니다. 그리고 인증서버에서 로그인 페이지를 제공 받습니다.
    2. 그리고 Resource Owner는 ID/PW를 입력하고 Authorization Code를 발급 받습니다. 동시에 Client에서 등록해놓은 Redirect URI로 code와 함께 이동합니다.
    3. Client는 Resource Owner에게 받은 Code를 가지고 Authorization Server에 토큰을 요청합니다. 그리고 받은 토큰을 저장합니다.
    4. 그리고 Client는 로그인을 성공하고 이후 다른 platform에서 정보를 필요하게 된다면 저장한 Access Token을 통해 Resource Server에서 정보를 가져옵니다.

    근데 여기서 이상한 점이 있습니다. 이상하다기보단 왜 이렇게 복잡한가 라는 의문을 가질 수 있습니다.

    굳이 Authorization code를 받아 다시 한번 더 Access Token을 받아야 한다는 부분입니다. 바로 Client에게 Access Token을 준다면 통신이 한번 줄어들 수 있지 않을까??

    보안문제 때문입니다. +만약 바로 Access Token을 준다면 그 Access Token이 탈취 당하면 해당 Resource Owner의 모든 정보에 접근할 수 있게 됩니다. +Code는 Secret Key와 같이 전달해야 Access Token을 발급 받을 수 있기 때문에 탈취되어도 더 안전합니다. +하지만 다른 플랫폼에서 Code나 Token이나 해당 정보를 전달할 방법은 URI에 전달하는 방법뿐 입니다. +그렇기 때문에 Redircet URI에 Access Token을 담는다면 탈취 가능성이 커지기 때문에 보안문제가 발생합니다.

    백엔드와 프론트엔드의 flow

    아까 Client 부분을 좀 더 Frontend, Backend로 구분지어 세분화 해봤습니다. 복잡해보이지만, 전혀 어렵지 않습니다. 아까 설명했던 흐름과 다른 부분은 없습니다.

    또 여기서는 굳이 제가 Authorization Server에서 Code를 받아올 때 Redirect URI를 백엔드 서버로 하지않고 프론트엔드 서버로 하려는 이유는 Resource Owner가 다른 platform과 인증하는 부분은 백엔드의 역할이 아니라고 생각했습니다. +그리고 백엔드는 Resource Owner가 가져온 code를 프론트엔드에서 전달 받아 Resource Server에 정보를 요청하는 것이라고 생각했습니다. +(물론 제 개인적인 의견이라 정답은 아닙니다.)

    OAuth 구현해보기

    간단히 Spring Security 없이 OAuth 인증을 구현해보겠습니다.

    제일 먼저 구글 혹은 다른 플랫폼에서 설정한 id, secret key 등등의 정보를 yml에 작성했습니다.

    application-oauth.yml
    oauth2:
    provider:
    google:
    id: google-id
    secret: google-secret-key
    redirect-url: http://localhost:8080/login/oauth2/code/google
    token-url: https://www.googleapis.com/oauth2/v4/token
    info-url: https://www.googleapis.com/oauth2/v2/userinfo

    그리고 OAuth는 어느 플랫폼이 될 지 모르고, 확장성 있게 구성하는 것이 좋을 것 같아 인터페이스로 만들었습니다.

    OAuthMember.java
    public interface OAuthMember {
    String id();
    String email();
    String nickname();
    String imageUrl();
    }

    이러한 클래스들을 관리하기 쉽게 Enum을 추가합니다.

    Provider.java
    public enum Provider {

    GOOGLE("google", GoogleMember::new),
    ;

    private final String providerName;
    private final Function<Map<String, Object>, OAuthMember> function;

    Provider(String providerName, Function<Map<String, Object>, OAuthMember> function) {
    this.providerName = providerName;
    this.function = function;
    }

    public static Provider from(String name) {
    return Arrays.stream(values())
    .filter(it -> it.providerName.equals(name))
    .findFirst()
    .orElseThrow(() -> new RuntimeExceptin());
    }

    public OAuthMember getOAuthProvider(Map<String, Object> body) {
    return function.apply(body);
    }
    }

    해당 Enum은 두개의 필드를 가지고 있습니다. 하나는 해당 플랫폼의 이름, 그리고 Map<String, Object>를 아까 만들었던 인터페이스로 반환하는 Function 여기서 +Map<String, Object>로 지정해준 이유는, 플랫폼마다 반환되는 JSON 타입이 다르기 때문에 그런 부분에 대해 중복을 제거하기 위해 이러한 형태로 만들었습니다.

    그리고 아까 yml에 작성했던 정보들을 가져와야합니다. @Value 어노테이션으로도 가져올 수 있습니다.

            @Value("oauth.provider.google.id")
    private String id;
    @Value("oauth.provider.google.secret")
    private String secret;

    ...

    하지만 이렇게 계속 binding을 해줘야한다는 점이 아주 귀찮고 보기도 안좋습니다.

    build.gradle
    annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

    하지만 위의 의존성을 추가해준다면 아주 편하게 property를 가져올 수 있습니다.

    OAuthProviderProperties.java
    @Component
    @ConfigurationProperties(prefix = "oauth2")
    public class OAuthProviderProperties {
    // prefix oauth2 기준으로 알아서 google이 이름인 Provider Enum을 찾아서 Key로 바인딩
    private final Map<Provider, OAuthProviderProperty> provider = new EnumMap<>(Provider.class);

    public OAuthProviderProperty getProviderProperties(Provider provider) {
    return this.provider.get(provider);
    }

    @Getter
    @Setter
    public static class OAuthProviderProperty {
    // 그리고 provider 하위 정보들은 아래의 필드에 바인딩
    private String id;
    private String secret;
    private String redirectUrl;
    private String tokenUrl;
    private String infoUrl;
    }
    }

    이렇게 되면 구조적인 준비는 끝났습니다.

    이제는 해당 플랫폼에 정보를 요청하는 작업만 하면 됩니다. +그럼 아까 말씀드렸던 순서로 요청을 해보겠습니다.

    RestTemplateOAuthRequester.java
    public class RestTemplateOAuthRequester implements OAuthRequester {

    @Override
    public OAuthMember login(OAuthLoginRequest request) {
    // frontend에서 받아온 로그인 platform
    Provider provider = Provider.from(request.provider());
    // 해당 Platform에 맞는 정보 찾음
    OAuthProviderProperty property = oAuthProviderProperties.getProviderProperties(provider);
    // frontend에서 받아온 code와 등록해놓은 property로 Access Token 요청
    OAuthTokenResponse token = requestAccessToken(property, requet.getCode());
    // 받아온 Token으로 해당 Resource Owner의 정보 요청
    Map<String, Object> userAttributes = getUserAttributes(property, token);
    return provider.getOAuthProvider(userAttributes);
    }

    private OAuthTokenResponse requestAccessToken(OAuthProviderProperty property, String code) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBasicAuth(property.getId(), property.getSecret());
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
    URI tokenUri = getTokenUri(property, code);
    return restTemplate.postForEntity(tokenUri, request, OAuthTokenResponse.class).getBody();
    }

    private URI getTokenUri(OAuthProviderProperty property, String code) {
    return UriComponentsBuilder.fromUriString(property.getTokenUrl())
    .queryParam(CODE, URLDecoder.decode(code, StandardCharsets.UTF_8))
    .queryParam(GRANT_TYPE, AUTHORIZATION_CODE)
    .queryParam(REDIRECT_URI, property.getRedirectUrl())
    .build()
    .toUri();
    }

    private Map<String, Object> getUserAttributes(OAuthProviderProperty property, OAuthTokenResponse tokenResponse) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(tokenResponse.accessToken());
    headers.setContentType(MediaType.APPLICATION_JSON);
    URI uri = URI.create(property.getInfoUrl());
    RequestEntity<?> requestEntity = new RequestEntity<>(headers, HttpMethod.GET, uri);
    ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(requestEntity, new ParameterizedTypeReference<>() {
    });
    return responseEntity.getBody();
    }
    }

    이렇게만 한다면 그 어려워 보이던 OAuth 인증도 간단하게 해결할 수 있습니다. +(물론 제 코드가 정답이 아닙니다)

    Reference

    https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16

    https://developers.google.com/identity/protocols/oauth2?hl=ko

    + + \ No newline at end of file diff --git a/page/24.html b/page/24.html index 403ff4b3..628df09e 100644 --- a/page/24.html +++ b/page/24.html @@ -5,22 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 8분
    제이

    안녕하세요. 카페인 팀의 제이입니다. -저희 팀에서 CI/CD는 어떻게 진행되는지 작성하겠습니다.

    CI (지속적 통합)

    ci

    카페인 팀에서는 지속적 통합 즉 CI를 진행하기 위해서 위에 사진과 같이 Github Actions를 사용합니다.

    main, develop 브랜치에 Push, Pull Request 요청이 들어간다면 이벤트가 발생하고, Github Actions를 통해 저희가 작성해둔 스크립트가 실행 됩니다.

    이 스크립트에 여러가지를 등록할 순 있지만, 저희는 자동으로 테스트를 진행하도록 하였습니다. -자동으로 테스트를 돌리면서 테스트가 통과를 해야지만 Merge를 진행할 수 있습니다.

    이를 통해 개발자의 실수를 줄일 수 있고 안정적으로 지속적 통합을 이룰 수 있게 됩니다.


    CD (지속적 배포)

    cd

    저희의 지속적 배포 아키텍처입니다.

    순서를 요약하자면 다음과 같습니다.
    1. Release 브랜치에 Push를 한다.
    2. Github Actions를 통해 Docker Hub에 레포지토리의 소스코드를 Docker Image로 빌드해서 Push 한다.
    3. 인프라 서버에서 Self Hosted Runner가 작동한다.
    4. 인프라 서버에서 배포 서버로 들어간다.
    5. 배포 서버 안에서 Docker Hub에 미리 업로드한 Docker Image를 Pull 해온다.
    6. 배포 서버 안에서 Docker Image를 컨테이너에 띄운다.

    배포 자동화 툴 선택하기

    먼저 배포 자동화 과정을 구축하기 위해서 여러가지 툴이 있습니다.

    Travis, Jenkins, Github Actions 등등 여러가지가 있는데요. -저희 팀은 Github Actions를 선택했습니다.

    이를 선택한 여러가지 이유가 있었지만 -저희 팀 누누를 제외하고 CI/CD 경험이 부족해서 비교적 쉽고 설치 및 큰 세팅이 없는 점이 저희한테는 매력적으로 다가왔습니다.

    또한 Docker를 사용하는데, 이유는 다음과 같습니다.

    1. JDK 혹은 Node 버전을 관리할 수 있다.
    2. Docker Image를 빌드한 후 배포하기 때문에 서버 환경 차이로 발생하는 문제를 최소화할 수 있다.
    3. 배포 서버에서 Docker만 설치하고 Image를 받고 실행시키면 돼서 빠르고 쉽게 배포 환경을 구축할 수 있다.

    과정

    본격적으로 저희의 배포 자동화를 구축하는 과정을 설명하겠습니다.


    1. Github Actions에 Runners 등록

    runner -먼저 Self Hosted Runner를 이용하기 때문에 저희는 위에 사진과 같이 Runners를 등록을 해줬습니다.

    이를 등록을 할 때 제공해주는 설정 코드가 나오는데요. -이 코드들을 infra 서버에 모두 입력을 해주면서 설정을 해주시면 됩니다.


    2. Github workflow 만들기다음으로는 저희가 수행하고자 하는 Task를 등록해주기 위해서 yml 파일을 만들어줍니다.

    yml 파일의 경로는 ./github/workflows/ 안에 만들어주면 됩니다.

    name: deploy

    # release/backend push 할 때
    on:
    push:
    branches:
    - release/backend

    jobs:
    # Docker
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend
    steps:
    # Docker Hub 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew

    - name: Build with Gradle
    run: ./gradlew bootjar

    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: Docker Hub 사용자명/이미지 이름

    # Build 및 Docker image를 Docker Hub에 push
    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}

    deploy:
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:
    # EC2 배포 서버로 접속
    - name: Join EC2 dev server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY

    # 1. 도커 이미지 받기
    # 2. 기존에 켜진 백엔드 서버(도커 이미지) stop
    # 3. 최신 백엔드 서버 run
    # 4. 사용하지 않는 이미지와 컨테이너 삭제
    script: |
    sudo docker pull woowacarffeine/backend:latest
    sudo docker stop backend || true
    sudo docker run -d --rm -p 8080:8080 \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    --name backend \
    Docker Hub 사용자명/이미지 이름:latest

    sudo docker image prune -f

    저희 팀은 위와 같이 backend-deploy.yml 파일을 만들어주었습니다.

    위에 yml에서 저희는 키를 숨겼는데요.

    img -위에 사진과 같이 설정을 해주시면 됩니다.

    그리고 이를 yml에서 사용하기 위해선 secrets.Key이름으로 사용해주시면 됩니다.


    이제 마지막으로 Dockerfile을 만들어줍니다.

    저희는 /backend/ 경로에 만들어주었습니다.

    FROM amazoncorretto:17-alpine-jdk
    ARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar
    COPY ${JAR_FILE} app.jar
    ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar","/app.jar"]

    저희는 위처럼 절대 경로를 기준으로 JAR_FILE 위치를 지정하고, profiles는 dev로 설정해서 만들어주었습니다.


    3. 배포하기

    트리거를 작동시켜서 저희가 yml 파일에서 지정해준 것들이 잘 작동하는지 확인합니다.

    jobSuccess -위에 사진처럼 모든 Job이 성공적으로 통과하는 것을 보실 수 있습니다.

    dockerPs -이렇게 인프라 서버에서 배포 서버로 들어가서 성공적으로 서버를 도커로 띄운 것을 보실 수 있습니다.

    EC2 배포 서버에서 docker ps를 입력했을 때에도 잘 실행이 되네요!


    CD 배포 과정 요약

    지속적 배포 과정을 요약 하자면 다음과 같습니다.

    1. Self Hosted Runner를 EC2 인프라 서버에 등록해준다.
    2. yml 파일과 Dockerfile을 만들어준다.
    3. 트리거를 작동시켜서 Github Actions의 태스크가 모두 잘 되는지 확인한다.
    4. 잘 됐다면 EC2 배포 서버에 Docker image가 성공적으로 띄워진다.
    - - +

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    + + \ No newline at end of file diff --git a/page/25.html b/page/25.html index d6d0d24a..01466c08 100644 --- a/page/25.html +++ b/page/25.html @@ -5,24 +5,22 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 10분
    박스터

    안녕하세요 박스터 입니다.

    먼저 이번에 글을 쓰게된 계기를 말씀드리겠습니다. 저희 팀은 공공 데이터 API에서 받아온 충전소와, 충전기들의 ID를 그대로 사용하고 있습니다. -물론 다른 API, 제가 제어할 수 없는 곳에 의존하는 것은 좋지 않다고 생각합니다.

    하지만 데이터를 받아오는 과정에서 마주한 성능적인 문제 때문에 그대로 사용하고 있습니다. 전국의 충전소는 6만개, 충전소 안에 존재하는 충전기는 23만기입니다. -하지만 공공 데이터는 충전소와, 충전기의 정보를 따로 제공하는 것이 아닌 중복된 충전소를 포함한 데이터를 충전기 개수만큼인 23만개의 row로 제공합니다.

    따라서 저희가 ID를 따로 부여하게 된다면, 충전소를 저장하는 과정에서 받아오는 ID로 충전기를 연결해줘야하는데 그렇게 된다면 셀 수 없이 많은 쿼리가 발생합니다.

    잠깐 생각해본다면

    1. 충전소를 각각 저장하고 ID를 부여받는 쿼리 6만번 (ID를 알아와야하기 때문에 batch를 사용할 수 없습니다.)
    2. 충전소에서 받아온 ID를 충전기에 매핑하고 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

    하지만 ID를 그대로 사용하게 된다면,

    1. 충전소를 저장하는 쿼리 최소 1번 (만약 batch로 6만건을 한번에 저장한다는 가정)
    2. 충전기를 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

    23만건이 넘는 정보를 확인했을 때, ID는 중복되지 않았고, 중복하지 않을 것이라 생각했습니다. 그 뿐만 아니라 처음 한번만 저장하는 것이 아닌 주기적으로 업데이트된 정보를 -반영해주고 update or save 해주어야하기 때문에, ID를 그대로 가지고 있는 것이 훨씬 효율적이라 생각했습니다.

    사족이 길었습니다. 각설하고 이런 방식으로 ID를 직접 넣어주는 경우 발생하는 문제에 대해 말씀드리겠습니다.

    ID를 직접 넣어준 Entity를 저장할 때

    먼저 간단한 예제 Entity로 설명드리겠습니다.

    @Entity
    public class ChargeStation {

    @Id
    private String stationId;

    private String stationName;

    ...
    }

    보통의 Entity와 다른 부분은 Id를 직접 할당하기 때문에 @GeneratedValue(strategy = GenerationType.IDENTITY) 이러한 ID 생성 전략에 대한 정보가 없습니다.

    그리고 save() 코드를 호출하면 어떤 쿼리가 나가는지 확인해보겠습니다. 아래와 같이 아주 간단한 선릉역 충전소를 저장하는 테스트를 실행해보겠습니다.

    @DataJpaTest
    class ChargeStationRepositoryTest {

    @Autowired
    private ChargeStationRepository chargeStationRepository;

    @Test
    void 충전소를_저장한다() {
    ChargeStation station = ChargeStationFixture.선릉역_충전소_충전기_2개_사용가능_1개;

    chargeStationRepository.save(station);

    ChargeStation expect = chargeStationRepository.findByStationId(station.getStationId()).get();
    assertThat(expect).isEqualTo(station);
    }
    }

    먼저 코드만 보면 먼저 chargeStationRepository.save() 호출과 함께 insert 쿼리 1번, 그리고 chargeStationRepository.findByStationId()에서 select 쿼리 1번 -총 2번 발생할 것이라고 유추할 수 있습니다.

    query-three-times

    하지만 예상과 다르게 위의 사진과 같이 쿼리가 총 3번 발생했습니다. 첫번째는 호출하지 않은 station id로 station을 조회하는 쿼리가 발생했습니다.

    이유를 찾기 위해 save() 메서드를 디버깅 해봤습니다.

    save 시 SELECT 쿼리가 발생하는 이유

    save-method -로직은 간단해보입니다. isNew() 를 통해 새로운 Entity인지 확인한 후, 새로운 Entity라면 persist(), 아니라면 merge()를 호출합니다.

    여기서 EntityManager#persist() 메서드를 간단히 말씀드리면, 새로운 Entity를 영속화하는 메서드로 트랜잭션이 커밋될 때 데이터베이스에 저장합니다.

    그리고 EntityManager#merge() 메서드는 준영속 상태의 Entity를 영속 상태로 변경하는데 사용합니다. -하지만 이때 영속성 컨텍스트에 존재하지 않는 객체라면 데이터베이스에서 조회 후 영속화하는 작업을 수행합니다.

    merge()를 호출하기 때문에 SELECT 쿼리가 발생하고, 영속화하는 작업을 수행하는 것 입니다.

    하지만 제가 저장한 객체는 확실히 새로운 Entity가 맞습니다. 하지만 entityInformation.isNew() 메서드는 false를 반환합니다.

    그래서 어떤 것을 기준으로 새로운 Entity인 것을 구분하는지 알아보겠습니다.

    새로운 Entity를 구분하는 기준

    일단, 디버깅을 통해 isNew 메서드를 확인해보겠습니다. -is-new

    간단합니다. 먼저 Entity에 ID를 가져옵니다. 그리고 id가 primitive 타입인지 확인 후, 아닐경우 id가 null 이면 새로운 Entity, 아닐경우 false를 반환합니다.

    이때, primitve 타입이라면, id가 숫자인지 확인 후 id가 0이면 새로운 Entity, 아닐경우 false를 반환합니다.

    ID를 직접 넣어주는 객체는 JPA 사용을 포기해야할까?

    결론부터 말씀드리면 아닙니다. 다른 방법으로 새로운 Entity 임을 증명할 수 있다면 merge()가 아닌 persist()를 호출하도록 만들 수 있습니다.

    그럼 어떻게?

    먼저 save() 메서드의 필드 중 JpaEntityInformation이라는 필드를 확인할 수 있습니다.

    entity-info

    이 인터페이스는 Entity의 추가 정보를 알기 위해 필드에 있습니다.

    해당 인터페이스의 구현체는 JpaEntityInformationSupport, JpaMetamodelEntityInformation, JpaPersistableEntityInformation 이렇게 3개의 클래스가 있습니다.

    그럼 다른 방법으로도 isNew()가 구현되어 있을거라 추측을 할 수 있습니다. 디버깅을 통해 알아보겠습니다.

    아까 위의 사진으로 보고 실제로 실행됐던 isNew() 메서드의 주인은 JpaMetamodelEntityInformation 클래스였습니다. 그래서 해당 클래스는 제외하고 다른 클래스를 보겠습니다.

    먼저 JpaPersistableEntityInformation 클래스입니다. -is-new-persistable -아주 간단하게 entity의 isNew()를 호출한다고 적혀있습니다. 하지만 Persistable 인터페이스를 구현한 Entity의 isNew() 를 호출하는 것 입니다.

    그럼 남은 하나의 클래스를 확인하겠습니다.

    info-support

    위 사진처럼 이 클래스가 Entity 마다 Persistable 구현 유무에 따라 동적으로 구현체를 변경해주고 있었습니다.

    그럼 답이 나온 것 같습니다. ID를 직접 할당하는 Entity에 Persistable을 구현해주면 됩니다.

    Persistable 구현하기

    @Entity
    public class ChargeStation implements Pesistable{

    @Id
    private String stationId;

    private String stationName;

    @CreatedDate
    private LocalDateTime createdTime;

    ...

    @Override
    public Object getId() {
    return getStationId();
    }

    @Override
    public boolean isNew() {
    return createdTime == null;
    }
    }

    간단히 만들어봤습니다. @CreatedDate는 Entity가 처음 영속화될 때 동작하기 때문에 이 Entity의 CreateTime 필드가 null 이면 새로운 Entity라고 확신할 수 있습니다. -그럼 이렇게 인터페이스를 구현하고 아까 실행했던 테스트를 다시 실행해보겠습니다.

    solved

    깔끔하게 구현된 것을 확인할 수 있었습니다. 원하던대로 쿼리가 2번 발생합니다. -이런 Persistable@MappedSuperClass를 통해 더 깔끔하게 구현할 수 있습니다. 하지만 따로 설명드리지는 않겠습니다.

    결론

    JPA는 많은 편의 기능을 제공해주는 것 같아보입니다. 쫄지맙시다.

    - - +

    · 약 8분
    제이

    안녕하세요. 카페인 팀의 제이입니다. +저희 팀에서 CI/CD는 어떻게 진행되는지 작성하겠습니다.

    CI (지속적 통합)

    ci

    카페인 팀에서는 지속적 통합 즉 CI를 진행하기 위해서 위에 사진과 같이 Github Actions를 사용합니다.

    main, develop 브랜치에 Push, Pull Request 요청이 들어간다면 이벤트가 발생하고, Github Actions를 통해 저희가 작성해둔 스크립트가 실행 됩니다.

    이 스크립트에 여러가지를 등록할 순 있지만, 저희는 자동으로 테스트를 진행하도록 하였습니다. +자동으로 테스트를 돌리면서 테스트가 통과를 해야지만 Merge를 진행할 수 있습니다.

    이를 통해 개발자의 실수를 줄일 수 있고 안정적으로 지속적 통합을 이룰 수 있게 됩니다.


    CD (지속적 배포)

    cd

    저희의 지속적 배포 아키텍처입니다.

    순서를 요약하자면 다음과 같습니다.
    1. Release 브랜치에 Push를 한다.
    2. Github Actions를 통해 Docker Hub에 레포지토리의 소스코드를 Docker Image로 빌드해서 Push 한다.
    3. 인프라 서버에서 Self Hosted Runner가 작동한다.
    4. 인프라 서버에서 배포 서버로 들어간다.
    5. 배포 서버 안에서 Docker Hub에 미리 업로드한 Docker Image를 Pull 해온다.
    6. 배포 서버 안에서 Docker Image를 컨테이너에 띄운다.

    배포 자동화 툴 선택하기

    먼저 배포 자동화 과정을 구축하기 위해서 여러가지 툴이 있습니다.

    Travis, Jenkins, Github Actions 등등 여러가지가 있는데요. +저희 팀은 Github Actions를 선택했습니다.

    이를 선택한 여러가지 이유가 있었지만 +저희 팀 누누를 제외하고 CI/CD 경험이 부족해서 비교적 쉽고 설치 및 큰 세팅이 없는 점이 저희한테는 매력적으로 다가왔습니다.

    또한 Docker를 사용하는데, 이유는 다음과 같습니다.

    1. JDK 혹은 Node 버전을 관리할 수 있다.
    2. Docker Image를 빌드한 후 배포하기 때문에 서버 환경 차이로 발생하는 문제를 최소화할 수 있다.
    3. 배포 서버에서 Docker만 설치하고 Image를 받고 실행시키면 돼서 빠르고 쉽게 배포 환경을 구축할 수 있다.

    과정

    본격적으로 저희의 배포 자동화를 구축하는 과정을 설명하겠습니다.


    1. Github Actions에 Runners 등록

    runner +먼저 Self Hosted Runner를 이용하기 때문에 저희는 위에 사진과 같이 Runners를 등록을 해줬습니다.

    이를 등록을 할 때 제공해주는 설정 코드가 나오는데요. +이 코드들을 infra 서버에 모두 입력을 해주면서 설정을 해주시면 됩니다.


    2. Github workflow 만들기다음으로는 저희가 수행하고자 하는 Task를 등록해주기 위해서 yml 파일을 만들어줍니다.

    yml 파일의 경로는 ./github/workflows/ 안에 만들어주면 됩니다.

    name: deploy

    # release/backend push 할 때
    on:
    push:
    branches:
    - release/backend

    jobs:
    # Docker
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend
    steps:
    # Docker Hub 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew

    - name: Build with Gradle
    run: ./gradlew bootjar

    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: Docker Hub 사용자명/이미지 이름

    # Build 및 Docker image를 Docker Hub에 push
    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}

    deploy:
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:
    # EC2 배포 서버로 접속
    - name: Join EC2 dev server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY

    # 1. 도커 이미지 받기
    # 2. 기존에 켜진 백엔드 서버(도커 이미지) stop
    # 3. 최신 백엔드 서버 run
    # 4. 사용하지 않는 이미지와 컨테이너 삭제
    script: |
    sudo docker pull woowacarffeine/backend:latest
    sudo docker stop backend || true
    sudo docker run -d --rm -p 8080:8080 \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    --name backend \
    Docker Hub 사용자명/이미지 이름:latest

    sudo docker image prune -f

    저희 팀은 위와 같이 backend-deploy.yml 파일을 만들어주었습니다.

    위에 yml에서 저희는 키를 숨겼는데요.

    img +위에 사진과 같이 설정을 해주시면 됩니다.

    그리고 이를 yml에서 사용하기 위해선 secrets.Key이름으로 사용해주시면 됩니다.


    이제 마지막으로 Dockerfile을 만들어줍니다.

    저희는 /backend/ 경로에 만들어주었습니다.

    FROM amazoncorretto:17-alpine-jdk
    ARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar
    COPY ${JAR_FILE} app.jar
    ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar","/app.jar"]

    저희는 위처럼 절대 경로를 기준으로 JAR_FILE 위치를 지정하고, profiles는 dev로 설정해서 만들어주었습니다.


    3. 배포하기

    트리거를 작동시켜서 저희가 yml 파일에서 지정해준 것들이 잘 작동하는지 확인합니다.

    jobSuccess +위에 사진처럼 모든 Job이 성공적으로 통과하는 것을 보실 수 있습니다.

    dockerPs +이렇게 인프라 서버에서 배포 서버로 들어가서 성공적으로 서버를 도커로 띄운 것을 보실 수 있습니다.

    EC2 배포 서버에서 docker ps를 입력했을 때에도 잘 실행이 되네요!


    CD 배포 과정 요약

    지속적 배포 과정을 요약 하자면 다음과 같습니다.

    1. Self Hosted Runner를 EC2 인프라 서버에 등록해준다.
    2. yml 파일과 Dockerfile을 만들어준다.
    3. 트리거를 작동시켜서 Github Actions의 태스크가 모두 잘 되는지 확인한다.
    4. 잘 됐다면 EC2 배포 서버에 Docker image가 성공적으로 띄워진다.
    + + \ No newline at end of file diff --git a/page/26.html b/page/26.html index ea889f47..4ef3c1e2 100644 --- a/page/26.html +++ b/page/26.html @@ -5,28 +5,24 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 10분
    제이
    박스터

    안녕하세요~ -우테코 카페인 팀의 제이입니다.

    오늘은 카페인 팀의 프로젝트를 진행하면서 '박스터'와 함께 어떤 문제를 겪고 해결했는지 적어보도록 하겠습니다.

    • 배우는 단계이다 보니 틀린 부분이 있을 수 있는데, 피드백 부탁드립니다 :)

    먼저 글을 쓰기 전에 문제 상황에 대해 간단하게 말씀드리겠습니다.

    문제 상황

    카페인 팀에서는 전기차 충전소 공공 API를 활용하여 충전소의 혼잡도 제공 및 여러 서비스를 제공합니다.

    이런 서비스를 사용자들에게 제공하기 위해서 다음과 같은 작업들이 필요합니다.

    1. 첫 실행시 공공 API 데이터를 모두 불러서 데이터베이스에 삽입합니다.
    2. 혼잡도를 제공하기 위해서 주기적인 시간 (아직 정하진 않았지만 ex.12시간) 단위로 충전소와 충전기의 상태를 업데이트 하기 위해서 다시 데이터를 요청을 합니다.
    3. 새롭게 추가된 충전소와 충전기는 모두 Insert해주고, 기존에 있던 충전소 혹은 충전기가 업데이트 됐다면 변경된 데이터로 업데이트 해줍니다.

    저랑 박스터는 2~3번 과정을 진행하는 역할을 맡았습니다.

    테이블의 관계는 다음과 같습니다.

    charge_station <---1------N---> charger
    charger <---1------1---> charger_status

    저희는 이 문제를 어떻게 해결 했는지 보겠습니다.

    문제 해결 과정

    전제조건

    • 첫 실행 모든 테이블은 초기화 상태이다.
    • 데이터는 9999건을 기준으로 한다.
    • 메서드 첫 시행에서는 모든 데이터가 새롭게 insert 되고
    • 그 다음 메서드 시행에서는 일부 데이터는 추가되고, 일부는 업데이트 된다.

    Ver1. findAll() 조회 후 각각 save() 해주기 (약14초)

    저희가 처음에 생각한 방법입니다. -알아서 바뀐 것들은 업데이트 해주고, 새로운 건 저장해주기 때문에 간단한 방법으로 생각했습니다.

    실제로 해본 결과, 삽입의 경우는 SELECT 쿼리문 실행 후 INSERT 쿼리문을 발생 시켰고, -업데이트 시에도 SELECT 후 UPDATE 혹은 INSERT를 발생 시켰습니다. (변경 사항 없으면 SELECT만)

    이는 식별자에 따른 JPA 작동 방식 때문인데요. -이 방법의 결과는 약 14초가 나왔습니다.

    저희는 이렇게 불필요한 SELECT 작업을 막아보고자 다른 방법을 구상해봤습니다.

    기본적으로 Jdbc를 이용해서 Batch Insert와 Batch Update를 사용하기로 했고, 이 작업을 위해서 변경 혹은 삽입될 데이터들을 직접 찾는 과정이 중요했습니다.

    Ver2. 변경 감지를 직접 해주고, 자료구조로 배치 데이터 모으기 : O(n^2) (약 11초)

    두 번째로 저희가 생각한 방법입니다. -먼저 데이터 추가 및 변경 감지 부분입니다.

    기존 업데이트 시에 SELECT와 UPDATE(or INSERT) 두번의 쿼리가 나가는 것이 맘에 들지 않아서 -변경 감지를 직접 해주려고 메서드를 만들었습니다.

    저희가 생각한 변경 감지는 생각보다 간단한데요. -도메인에 메서드를 만들어서 필드를 if문으로 하나씩 비교해줬습니다.

    충전소의 데이터 특징상 데이터가 자주 바뀌는 데이터는 비교적 초반에 비교하도록 구현하고, 자주 바뀌지 않는 데이터는 후에 비교하도록 만들었습니다.

    그리고 데이터 저장 및 업데이트 부분입니다. -먼저 findAll()로 충전소와 충전기 등 관련된 모든 데이터를 Map에 넣었습니다. -Map<stationId, Station>의 구조로 기존에 테이블에 저장된 모든 데이터를 자료구조에 넣었습니다.

    그리고 공공 API를 불러와서, 똑같이 Map<updatedStationId, Station>의 구조로 만들었습니다. -(Station 안에는 List<Charger>가 존재)

    저희는 충전소와 충전소에 해당하는 모든 충전기들을 비교하면서 변경 감지를 해줘야하기 때문에 -각각의 Map.values()인 List<Station> : 기존 충전소List<Station> : 업데이트된 충전소를 비교해줬습니다.

    비교를 하면서 새로 삽입된 충전소와 업데이트 된 충전소를 각각 처리해주었습니다.

    충전소의 변경 감지를 위해서 충전기들은 충전소 안에 List로 속해있기 때문에 O(n^2)의 시간 복잡도를 가지고 전체 데이터들은 약 23만 건이므로, 전체 데이터를 대상으로 한다면 약 530억번의 연산이 이뤄졌겠네요.

    Ver1에 비해서는 크지는 않지만 평균적으로 약 2초 정도 줄었습니다.

    Ver3. 변경 감지를 직접 해주고, 자료구조로 배치 데이터 모으기 : O(1) (약 10초)

    Ver2와 거의 유사한 방법입니다.

    차이점은 Map 자료 구조 사용방법을 변경했습니다. -기존 2중 for문에서, 1중 for문을 돌면서 키 값을 통해서 신규 데이터와 업데이트 될 데이터들을 분류하고, 이들을 각각 List에 넣어주었습니다. -이를 통해서 Ver2에 비해서 1초정도 줄었습니다.

    Ver4. 이전 방식 + Fetch Join 사용하기 (약 6초)

    마지막 방법은 조회 과정의 시간 단축입니다.

    처음에 Stations를 findAll()하는 쿼리를 확인해보니 N+1 문제가 발생하고 있었습니다. -그 이유는 Station에서 Chargers를 지연로딩으로 설정 했는데, 이를 그대로 get 메서드를 통해 조회해서 해당 문제가 발생했습니다.

    List<ChargeStation> findAll(); // 기존

    @Query("SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers"); // Fetch Join 적용
    List<ChargeStation> findAll();

    따라서 위에 코드와 같이 Fetch Join을 이용해서 처음에 데이터를 가져왔습니다. -이렇게 효율적인 조회로 변경하면서 시간을 많이 줄일 수 있었습니다.

    지금까지의 방법을 정리를 하자면

    Ver1 과 같은 방식에서는 업데이트 과정에서 JPA의 식별자에 따른 처리 방식으로 인해 [SELECT + UPDATE] or [SELECT + INSERT] 와 같이 쿼리가 두 번씩 나갔습니다.

    그래서 Ver3까지 개선을 하기 위해서 저장과 업데이트를 한 번에 JDBC를 이용해서 Batch로 처리해주는 방식을 선택했고,

    변경 감지 + 배치 데이터를 모으기 위해서 자료구조를 이용해서 시간을 조금씩 단축 했습니다.

    마지막으로 Ver4에서는 findAll()에서 발생하는 N+1의 문제를 해결하면서 시간을 단축했습니다.

    이런 과정을 통해서 동일 작업을 14초에서 6초 정도로 줄일 수 있었습니다!

    - - +

    · 약 10분
    박스터

    안녕하세요 박스터 입니다.

    먼저 이번에 글을 쓰게된 계기를 말씀드리겠습니다. 저희 팀은 공공 데이터 API에서 받아온 충전소와, 충전기들의 ID를 그대로 사용하고 있습니다. +물론 다른 API, 제가 제어할 수 없는 곳에 의존하는 것은 좋지 않다고 생각합니다.

    하지만 데이터를 받아오는 과정에서 마주한 성능적인 문제 때문에 그대로 사용하고 있습니다. 전국의 충전소는 6만개, 충전소 안에 존재하는 충전기는 23만기입니다. +하지만 공공 데이터는 충전소와, 충전기의 정보를 따로 제공하는 것이 아닌 중복된 충전소를 포함한 데이터를 충전기 개수만큼인 23만개의 row로 제공합니다.

    따라서 저희가 ID를 따로 부여하게 된다면, 충전소를 저장하는 과정에서 받아오는 ID로 충전기를 연결해줘야하는데 그렇게 된다면 셀 수 없이 많은 쿼리가 발생합니다.

    잠깐 생각해본다면

    1. 충전소를 각각 저장하고 ID를 부여받는 쿼리 6만번 (ID를 알아와야하기 때문에 batch를 사용할 수 없습니다.)
    2. 충전소에서 받아온 ID를 충전기에 매핑하고 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

    하지만 ID를 그대로 사용하게 된다면,

    1. 충전소를 저장하는 쿼리 최소 1번 (만약 batch로 6만건을 한번에 저장한다는 가정)
    2. 충전기를 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

    23만건이 넘는 정보를 확인했을 때, ID는 중복되지 않았고, 중복하지 않을 것이라 생각했습니다. 그 뿐만 아니라 처음 한번만 저장하는 것이 아닌 주기적으로 업데이트된 정보를 +반영해주고 update or save 해주어야하기 때문에, ID를 그대로 가지고 있는 것이 훨씬 효율적이라 생각했습니다.

    사족이 길었습니다. 각설하고 이런 방식으로 ID를 직접 넣어주는 경우 발생하는 문제에 대해 말씀드리겠습니다.

    ID를 직접 넣어준 Entity를 저장할 때

    먼저 간단한 예제 Entity로 설명드리겠습니다.

    @Entity
    public class ChargeStation {

    @Id
    private String stationId;

    private String stationName;

    ...
    }

    보통의 Entity와 다른 부분은 Id를 직접 할당하기 때문에 @GeneratedValue(strategy = GenerationType.IDENTITY) 이러한 ID 생성 전략에 대한 정보가 없습니다.

    그리고 save() 코드를 호출하면 어떤 쿼리가 나가는지 확인해보겠습니다. 아래와 같이 아주 간단한 선릉역 충전소를 저장하는 테스트를 실행해보겠습니다.

    @DataJpaTest
    class ChargeStationRepositoryTest {

    @Autowired
    private ChargeStationRepository chargeStationRepository;

    @Test
    void 충전소를_저장한다() {
    ChargeStation station = ChargeStationFixture.선릉역_충전소_충전기_2개_사용가능_1개;

    chargeStationRepository.save(station);

    ChargeStation expect = chargeStationRepository.findByStationId(station.getStationId()).get();
    assertThat(expect).isEqualTo(station);
    }
    }

    먼저 코드만 보면 먼저 chargeStationRepository.save() 호출과 함께 insert 쿼리 1번, 그리고 chargeStationRepository.findByStationId()에서 select 쿼리 1번 +총 2번 발생할 것이라고 유추할 수 있습니다.

    query-three-times

    하지만 예상과 다르게 위의 사진과 같이 쿼리가 총 3번 발생했습니다. 첫번째는 호출하지 않은 station id로 station을 조회하는 쿼리가 발생했습니다.

    이유를 찾기 위해 save() 메서드를 디버깅 해봤습니다.

    save 시 SELECT 쿼리가 발생하는 이유

    save-method +로직은 간단해보입니다. isNew() 를 통해 새로운 Entity인지 확인한 후, 새로운 Entity라면 persist(), 아니라면 merge()를 호출합니다.

    여기서 EntityManager#persist() 메서드를 간단히 말씀드리면, 새로운 Entity를 영속화하는 메서드로 트랜잭션이 커밋될 때 데이터베이스에 저장합니다.

    그리고 EntityManager#merge() 메서드는 준영속 상태의 Entity를 영속 상태로 변경하는데 사용합니다. +하지만 이때 영속성 컨텍스트에 존재하지 않는 객체라면 데이터베이스에서 조회 후 영속화하는 작업을 수행합니다.

    merge()를 호출하기 때문에 SELECT 쿼리가 발생하고, 영속화하는 작업을 수행하는 것 입니다.

    하지만 제가 저장한 객체는 확실히 새로운 Entity가 맞습니다. 하지만 entityInformation.isNew() 메서드는 false를 반환합니다.

    그래서 어떤 것을 기준으로 새로운 Entity인 것을 구분하는지 알아보겠습니다.

    새로운 Entity를 구분하는 기준

    일단, 디버깅을 통해 isNew 메서드를 확인해보겠습니다. +is-new

    간단합니다. 먼저 Entity에 ID를 가져옵니다. 그리고 id가 primitive 타입인지 확인 후, 아닐경우 id가 null 이면 새로운 Entity, 아닐경우 false를 반환합니다.

    이때, primitve 타입이라면, id가 숫자인지 확인 후 id가 0이면 새로운 Entity, 아닐경우 false를 반환합니다.

    ID를 직접 넣어주는 객체는 JPA 사용을 포기해야할까?

    결론부터 말씀드리면 아닙니다. 다른 방법으로 새로운 Entity 임을 증명할 수 있다면 merge()가 아닌 persist()를 호출하도록 만들 수 있습니다.

    그럼 어떻게?

    먼저 save() 메서드의 필드 중 JpaEntityInformation이라는 필드를 확인할 수 있습니다.

    entity-info

    이 인터페이스는 Entity의 추가 정보를 알기 위해 필드에 있습니다.

    해당 인터페이스의 구현체는 JpaEntityInformationSupport, JpaMetamodelEntityInformation, JpaPersistableEntityInformation 이렇게 3개의 클래스가 있습니다.

    그럼 다른 방법으로도 isNew()가 구현되어 있을거라 추측을 할 수 있습니다. 디버깅을 통해 알아보겠습니다.

    아까 위의 사진으로 보고 실제로 실행됐던 isNew() 메서드의 주인은 JpaMetamodelEntityInformation 클래스였습니다. 그래서 해당 클래스는 제외하고 다른 클래스를 보겠습니다.

    먼저 JpaPersistableEntityInformation 클래스입니다. +is-new-persistable +아주 간단하게 entity의 isNew()를 호출한다고 적혀있습니다. 하지만 Persistable 인터페이스를 구현한 Entity의 isNew() 를 호출하는 것 입니다.

    그럼 남은 하나의 클래스를 확인하겠습니다.

    info-support

    위 사진처럼 이 클래스가 Entity 마다 Persistable 구현 유무에 따라 동적으로 구현체를 변경해주고 있었습니다.

    그럼 답이 나온 것 같습니다. ID를 직접 할당하는 Entity에 Persistable을 구현해주면 됩니다.

    Persistable 구현하기

    @Entity
    public class ChargeStation implements Pesistable{

    @Id
    private String stationId;

    private String stationName;

    @CreatedDate
    private LocalDateTime createdTime;

    ...

    @Override
    public Object getId() {
    return getStationId();
    }

    @Override
    public boolean isNew() {
    return createdTime == null;
    }
    }

    간단히 만들어봤습니다. @CreatedDate는 Entity가 처음 영속화될 때 동작하기 때문에 이 Entity의 CreateTime 필드가 null 이면 새로운 Entity라고 확신할 수 있습니다. +그럼 이렇게 인터페이스를 구현하고 아까 실행했던 테스트를 다시 실행해보겠습니다.

    solved

    깔끔하게 구현된 것을 확인할 수 있었습니다. 원하던대로 쿼리가 2번 발생합니다. +이런 Persistable@MappedSuperClass를 통해 더 깔끔하게 구현할 수 있습니다. 하지만 따로 설명드리지는 않겠습니다.

    결론

    JPA는 많은 편의 기능을 제공해주는 것 같아보입니다. 쫄지맙시다.

    + + \ No newline at end of file diff --git a/page/27.html b/page/27.html index 25edb98f..bbac106c 100644 --- a/page/27.html +++ b/page/27.html @@ -5,13 +5,28 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    - - +

    · 약 10분
    제이
    박스터

    안녕하세요~ +우테코 카페인 팀의 제이입니다.

    오늘은 카페인 팀의 프로젝트를 진행하면서 '박스터'와 함께 어떤 문제를 겪고 해결했는지 적어보도록 하겠습니다.

    • 배우는 단계이다 보니 틀린 부분이 있을 수 있는데, 피드백 부탁드립니다 :)

    먼저 글을 쓰기 전에 문제 상황에 대해 간단하게 말씀드리겠습니다.

    문제 상황

    카페인 팀에서는 전기차 충전소 공공 API를 활용하여 충전소의 혼잡도 제공 및 여러 서비스를 제공합니다.

    이런 서비스를 사용자들에게 제공하기 위해서 다음과 같은 작업들이 필요합니다.

    1. 첫 실행시 공공 API 데이터를 모두 불러서 데이터베이스에 삽입합니다.
    2. 혼잡도를 제공하기 위해서 주기적인 시간 (아직 정하진 않았지만 ex.12시간) 단위로 충전소와 충전기의 상태를 업데이트 하기 위해서 다시 데이터를 요청을 합니다.
    3. 새롭게 추가된 충전소와 충전기는 모두 Insert해주고, 기존에 있던 충전소 혹은 충전기가 업데이트 됐다면 변경된 데이터로 업데이트 해줍니다.

    저랑 박스터는 2~3번 과정을 진행하는 역할을 맡았습니다.

    테이블의 관계는 다음과 같습니다.

    charge_station <---1------N---> charger
    charger <---1------1---> charger_status

    저희는 이 문제를 어떻게 해결 했는지 보겠습니다.

    문제 해결 과정

    전제조건

    • 첫 실행 모든 테이블은 초기화 상태이다.
    • 데이터는 9999건을 기준으로 한다.
    • 메서드 첫 시행에서는 모든 데이터가 새롭게 insert 되고
    • 그 다음 메서드 시행에서는 일부 데이터는 추가되고, 일부는 업데이트 된다.

    Ver1. findAll() 조회 후 각각 save() 해주기 (약14초)

    저희가 처음에 생각한 방법입니다. +알아서 바뀐 것들은 업데이트 해주고, 새로운 건 저장해주기 때문에 간단한 방법으로 생각했습니다.

    실제로 해본 결과, 삽입의 경우는 SELECT 쿼리문 실행 후 INSERT 쿼리문을 발생 시켰고, +업데이트 시에도 SELECT 후 UPDATE 혹은 INSERT를 발생 시켰습니다. (변경 사항 없으면 SELECT만)

    이는 식별자에 따른 JPA 작동 방식 때문인데요. +이 방법의 결과는 약 14초가 나왔습니다.

    저희는 이렇게 불필요한 SELECT 작업을 막아보고자 다른 방법을 구상해봤습니다.

    기본적으로 Jdbc를 이용해서 Batch Insert와 Batch Update를 사용하기로 했고, 이 작업을 위해서 변경 혹은 삽입될 데이터들을 직접 찾는 과정이 중요했습니다.

    Ver2. 변경 감지를 직접 해주고, 자료구조로 배치 데이터 모으기 : O(n^2) (약 11초)

    두 번째로 저희가 생각한 방법입니다. +먼저 데이터 추가 및 변경 감지 부분입니다.

    기존 업데이트 시에 SELECT와 UPDATE(or INSERT) 두번의 쿼리가 나가는 것이 맘에 들지 않아서 +변경 감지를 직접 해주려고 메서드를 만들었습니다.

    저희가 생각한 변경 감지는 생각보다 간단한데요. +도메인에 메서드를 만들어서 필드를 if문으로 하나씩 비교해줬습니다.

    충전소의 데이터 특징상 데이터가 자주 바뀌는 데이터는 비교적 초반에 비교하도록 구현하고, 자주 바뀌지 않는 데이터는 후에 비교하도록 만들었습니다.

    그리고 데이터 저장 및 업데이트 부분입니다. +먼저 findAll()로 충전소와 충전기 등 관련된 모든 데이터를 Map에 넣었습니다. +Map<stationId, Station>의 구조로 기존에 테이블에 저장된 모든 데이터를 자료구조에 넣었습니다.

    그리고 공공 API를 불러와서, 똑같이 Map<updatedStationId, Station>의 구조로 만들었습니다. +(Station 안에는 List<Charger>가 존재)

    저희는 충전소와 충전소에 해당하는 모든 충전기들을 비교하면서 변경 감지를 해줘야하기 때문에 +각각의 Map.values()인 List<Station> : 기존 충전소List<Station> : 업데이트된 충전소를 비교해줬습니다.

    비교를 하면서 새로 삽입된 충전소와 업데이트 된 충전소를 각각 처리해주었습니다.

    충전소의 변경 감지를 위해서 충전기들은 충전소 안에 List로 속해있기 때문에 O(n^2)의 시간 복잡도를 가지고 전체 데이터들은 약 23만 건이므로, 전체 데이터를 대상으로 한다면 약 530억번의 연산이 이뤄졌겠네요.

    Ver1에 비해서는 크지는 않지만 평균적으로 약 2초 정도 줄었습니다.

    Ver3. 변경 감지를 직접 해주고, 자료구조로 배치 데이터 모으기 : O(1) (약 10초)

    Ver2와 거의 유사한 방법입니다.

    차이점은 Map 자료 구조 사용방법을 변경했습니다. +기존 2중 for문에서, 1중 for문을 돌면서 키 값을 통해서 신규 데이터와 업데이트 될 데이터들을 분류하고, 이들을 각각 List에 넣어주었습니다. +이를 통해서 Ver2에 비해서 1초정도 줄었습니다.

    Ver4. 이전 방식 + Fetch Join 사용하기 (약 6초)

    마지막 방법은 조회 과정의 시간 단축입니다.

    처음에 Stations를 findAll()하는 쿼리를 확인해보니 N+1 문제가 발생하고 있었습니다. +그 이유는 Station에서 Chargers를 지연로딩으로 설정 했는데, 이를 그대로 get 메서드를 통해 조회해서 해당 문제가 발생했습니다.

    List<ChargeStation> findAll(); // 기존

    @Query("SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers"); // Fetch Join 적용
    List<ChargeStation> findAll();

    따라서 위에 코드와 같이 Fetch Join을 이용해서 처음에 데이터를 가져왔습니다. +이렇게 효율적인 조회로 변경하면서 시간을 많이 줄일 수 있었습니다.

    지금까지의 방법을 정리를 하자면

    Ver1 과 같은 방식에서는 업데이트 과정에서 JPA의 식별자에 따른 처리 방식으로 인해 [SELECT + UPDATE] or [SELECT + INSERT] 와 같이 쿼리가 두 번씩 나갔습니다.

    그래서 Ver3까지 개선을 하기 위해서 저장과 업데이트를 한 번에 JDBC를 이용해서 Batch로 처리해주는 방식을 선택했고,

    변경 감지 + 배치 데이터를 모으기 위해서 자료구조를 이용해서 시간을 조금씩 단축 했습니다.

    마지막으로 Ver4에서는 findAll()에서 발생하는 N+1의 문제를 해결하면서 시간을 단축했습니다.

    이런 과정을 통해서 동일 작업을 14초에서 6초 정도로 줄일 수 있었습니다!

    + + \ No newline at end of file diff --git a/page/28.html b/page/28.html index 9136680e..9dd421b7 100644 --- a/page/28.html +++ b/page/28.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    - - +

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/page/29.html b/page/29.html index cf00af46..1da6e33d 100644 --- a/page/29.html +++ b/page/29.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    - - +

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    + + \ No newline at end of file diff --git a/page/3.html b/page/3.html index 68b3f17b..8151d8ac 100644 --- a/page/3.html +++ b/page/3.html @@ -5,23 +5,29 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. -기존에는 충전소 간단 정보와 마커 정보를 한 번에 받아오도록 설계되어 있었지만, -백엔드와 프론트엔드가 협업하여 간단 정보와 마커 정보를 각각 필요한 만큼만 조회하도록 명세를 수정하였습니다.

    이 과정에서 먼저, 백엔드와 프론트엔드는 함께 모여 기능 요구사항과 성능 개선 목표를 논의하였습니다. -그리고 충전소 간단 정보와 마커 정보를 각각 조회하는 API 엔드포인트를 새로 설계하였습니다.

    다음으로, 백엔드에서 간단 정보 조회를 위한 API를 구현하였습니다. -필요한 필드만을 조회하여 데이터베이스의 부하를 줄이고 응답 시간을 개선하였습니다. -이후에는 프론트엔드에서 해당 API를 호출하여 필요한 정보를 받아오도록 수정하였습니다.

    마지막으로, 마커 정보 조회를 위한 API를 구현하였습니다. -마커 정보는 지도에 표시되는 정보로서, 요청한 영역 외부로 지도가 이동할 경우 호출되도록 설계되었습니다. -기존에는 간단 정보 리스트를 보여주기 위해 조회하던 정보들이 다수 포함되어 있었지만, -이 정보를 제외하고 마커를 띄우기 위해 필요한 최소한의 정보를 조회하도록 수정해 서버의 부하를 낮췄습니다.

    이러한 변경으로 인해 충전소 조회 API의 성능이 개선되었습니다. -필요한 정보만을 조회하므로써 데이터베이스의 부하를 줄이고 응답 시간을 단축할 수 있게 되었습니다. -또한, 프론트엔드에서는 필요한 정보만을 호출하여 불필요한 데이터를 받아오지 않아도 되므로 클라이언트 측의 성능도 향상되었습니다.

    - - +

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. +저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. +진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! +진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. +아무 충전소를 눌러서 진주시로 이동하는 것은 가능했습니다.

    여기에서 저는 이 과정에서 도시나 지역 검색 기능이 반드시 필요하다고 생각했습니다.

    하지만 너무 멀었습니다. +왕복 700km를 생각해야하여 1박 2일이 필수였고, 팀원들 간에 일정을 조정하기가 너무 어려웠습니다. +따라서 다른 도시를 찾아보기로 했습니다.

    no offset

    그러던 중, 제가 전에 방문했던 파주시의 마장호수가 생각났습니다. +서울에서 꽤나 먼 거리(약 50km)에 있었고, 적당히 시간을 보낼만한 장소였습니다. +다행히도 충전소의 이름이 마장호수관리사무소여서 카페인 서비스를 통해 바로 찾을 수 있었습니다. +심지어 마장호수 주변에는 충전소가 많지 않은 편이었고, 초급속 충전기가 있어 저희 앱을 실험하기에 딱 좋았습니다.

    마장호수로 출발

    가브리엘제이, 박스터는 서울 선정릉역에서 아이오닉5를 렌트하고 마장호수로 출발했습니다.

    no offset

    처음 계획했던 것 처럼 타사의 앱을 사용하지 않고 마장호수를 검색하여 이동했습니다.

    no offset

    전날 이미 검색을 했지만, 혹시 사용 중일수도 있기에 한번 더 검색해봤으며 해당 시간대에 충전소가 평소에 덜 붐빌 것이라는 통계 자료를 확인했습니다.

    no offset

    no offset

    no offset

    마장 호수까지 20분 거리를 남기고, 갑자기 배가 고파진 저희는 목적지를 틀어 파주닭국수 본점을 가기로 했습니다.

    파주닭국수가 어디에 있지?

    카페인 서비스를 활용하여 파주닭국수 본점 근처의 충전소를 검색해보기로 했습니다. +자동차 내비게이션에는 파주닭국수가 어디인지 나와있지만, 저희 서비스에는 식당 정보는 존재하지 않았습니다. +해당 식당이 도대체 어디에 있는지 확인할 수 없었습니다. (파주닭국수에서는 전기차 충전소가 없었기 떄문입니다.)

    no offset

    따라서 저희는 자동차 내비게이션에 있는 도로명 주소를 검색하여 위치를 파악하려고 하였고, 다소 부정확 하지만 동네에 있는 인근 충전소를 찾을 수 있었습니다.

    휴게소에 들리다

    카페인 서비스로 검색해보니 식당으로 가는 길 휴게소에도 충전소가 있다고 합니다. +휴게소 이름을 입력하니 바로 나왔습니다.

    no offset

    심지어 지금 사용중이라고 합니다! 따라서 저희는 확인해보기 위해 휴게소에 들리기로 했습니다.

    no offset +no offset

    실제로 사용 중임을 확인했습니다. 저희 서비스에서 사용중이라고 나왔는데 실제로 사용중인 것을 보니 공공 api가 나름 실시간으로 데이터를 잘 보내주고 있다고 생각하게 되었고, 저희 팀 서버에서도 이를 제대로 수집하고 있다고 생각하였습니다.

    no offset

    말로만 듣던 고속도로 휴게소의 전기차 충전소 대기줄을 직접 확인할 수 있었습니다. +차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? +이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset +no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    + + \ No newline at end of file diff --git a/page/30.html b/page/30.html index e5d22ba5..ca255926 100644 --- a/page/30.html +++ b/page/30.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 6분
    키아라

    서론

    안녕하세요 카페인팀 키아라입니다.

    이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

    사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

    프로퍼티 암호화는 왜 필요할까?

    spring:
    datasource:
    url: 데이터베이스 url
    username: 계정
    password: 비밀번호

    프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

    git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

    Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

    간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

    jasypt는 뭐지?

    Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

    민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

    사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

    공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

    사용 방법

    정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

    1. 라이브러리 추가 (= 의존성 추가)

    implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

    2. Jasypt 설정 및 Bean 등록

    key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

    @Configuration
    public class JasyptConfig {

    private String ENCRYPT_KEY = "hello";

    @Bean(name = "jasyptEncryptor")
    public StringEncryptor stringEncryptor() {
    PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

    SimpleStringPBEConfig config = new SimpleStringPBEConfig();

    config.setPassword(ENCRYPT_KEY);
    config.setAlgorithm("PBEWithMD5AndDES");
    config.setKeyObtentionIterations(1000);
    config.setPoolSize(1);
    config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
    config.setStringOutputType("base64");
    encryptor.setConfig(config);
    return encryptor;
    }
    }
    jasypt:
    encryptor:
    bean: jasyptEncryptor

    3. 암호화

    라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

    이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

    암복호화 사이트

    평문

      datasource:
    url: 데이터베이스 url
    username: 계정
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    나머지도 마저 암호화해줍시다.

      datasource:
    url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
    username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    실행

    올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

    그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

    실행 실패

    그런데 뭔가 이상하지 않나요?

    프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

    Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

    그럼 이 키를 어디에 숨길 수 있을까요?

    저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

    그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

    바로 환경변수를 설정하는 것이죠.

    + 환경변수 설정

    private String ENCRYPT_KEY = "hello";

    기존의 키를 관리하는 방식이었습니다.

    우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

    // JasyptConfig.class
    @Value("${jasypt.encryptor.password}")
    private String ENCRYPT_KEY;
    // application.yml
    jasypt:
    encryptor:
    password: hello

    이제 환경변수를 설정해봅시다.

    Run > Edit Configurations... 경로로 들어가면

    Run/Debug Configurations 창이 나오는데

    Environment variables: 부분에 ENCRYPT_KEY=hello

    라고 적어주세요.

    그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

    jasypt:
    encryptor:
    password: ${ENCRYPT_KEY}

    긴 글 읽어주셔서 감사합니다.

    - - +

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    + + \ No newline at end of file diff --git a/page/31.html b/page/31.html index 30044ab3..89a1d75d 100644 --- a/page/31.html +++ b/page/31.html @@ -5,19 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. -많은 시간을 단축할 수 있습니다.

    그리고 test가 실패한다면 강제로 Merge가 되지 않도록 한다면 실수로 테스트가 되지 않는 커밋을 올리는 것을 방지할 수 있겠죠.

    이 두가지만으로도 생산성이 많이 올라갈 것을 기대할 수 있습니다.

    어떻게 할 수 있나요

    Github Action을 이용하여 설정한 조건에 맞는 상황에서 명령어를 실행하여 test를 할 수 있습니다.

    Github Action 파일 생성

    1. 먼저 최상위 폴더에 .github/workflows 폴더를 생성합니다.
    2. 해당 폴더 내에 example.yml을 생성합니다.
    3. 아래와 같이 yml 파일을 작성합니다.
    name: pr test

    on:
    pull_request:
    branches:
    - main
    - develop

    permissions:
    contents: read

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    Job 이름 설정

    복잡하지 않습니다. 먼저 name 속성은 github action에서 보여질 Job의 이름을 정하는 부분입니다.

    지금은 pr test로 해두었습니다. 그럼 아래 사진과 같이 반영됩니다.

    workflows name

    workflow 트리거 설정

    다음으론 on 속성입니다. 이 속성은 workflow를 실행할 이벤트를 지정하는데 사용됩니다. 특정 이벤트 유형과 조건을 기반으로 workflow를 트리거하도록 구성할 수 있습니다.

    예를 들어 아래와 같이 정의했습니다.

    on:
    push:
    branches:
    - main
    pull_request:
    branches:
    - develop

    그렇다면 이 workflow가 작동되는 시점은 main 브랜치에 push가 되거나 develop 브랜치에 pull request를 보낼 때 작동합니다.

    권한 부여

    permissions:
    contents: read

    이런 권한을 주게 된다면 이 job은 읽기 권한밖에 없기 때문에 실수로 다른 것을 추가하지 못하게 막을 수 있습니다

    동작할 명령어 입력

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    name

    제일 간단히 볼 수 있는 name 설정은 아래 사진처럼 어떤식으로 보여줄지 정할 수 있습니다.

    job image

    runs-on

    runs-on 속성입니다. 해당 운영체제를 사용한다고 정의하는 부분입니다. 지금은 저희가 사용할 ec2와 같은 환경인 ubuntu에서 작동하도록 설정했지만, -windows-latest, macos-latest로 변경할 수도 있습니다.

    environment

    environment 속성입니다. 해당 속성은 꼭 필요한 부분이 아니지만 branch의 rule 설정에 사용할 수 있습니다. 그리고 환경을 한꺼번에 관리할 수 있습니다.

    이 부분은 아래에 branch rule을 정하는 부분을 보시면 아마 이해가 될 것 입니다.

    defaults

    해당 속성은 어떤 폴더에서 명령어를 실행할 지 지정합니다. 지금의 저희 프로젝트에서는 한 repository에 backend, frontend 폴더를 나누었기 때문에 backend 폴더로 이동하여 명령어를 실행해야 합니다.

    그래서 working-directory./backend라고 지정했습니다.

    steps

    제일 중요한 steps입니다. 해당 속성은 어떤 명령어를 어떤 순서로 실행시킬지 정의합니다. 지금의 workflow에선

    1. Java 17 설치
    2. gradlew 파일에 실행 권한 부여
    3. gradle build 실행

    순으로 동작합니다.

    다른 조건과 이벤트도 추가하고 싶어요

    저희 프로젝트는 하나의 repository에서 frontend, backend 코드를 같이 관리하는 상황입니다. 하지만 frontend 코드를 수정했다고 java 테스트를 돌리는 것은 오히려 생산성이 줄어들겠죠.

    그리고 frontend도 테스트를 돌리고 싶지만 gradle을 사용하지 않습니다.

    그럴 때 간단한 속성을 추가하면 파일의 변경에 따라 해당 job을 실행할 조건을 정의할 수 있습니다.

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - backend/**
    - .github/**

    위와 달리 지금 pull request에는 속성이 하나가 더 있는데요. paths를 적용하면 backend 폴더 하위의 무언가 변경이 있는 pull request에만 작동을 하게 됩니다.

    그럼 backend의 workflow 파일에 paths 속성을 하나 추가하고, 비슷한 frontend workflow를 만들어주면 되겠죠.

    name: frontend test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**

    permissions:
    contents: read

    jobs:
    test:
    name: jest
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - uses: actions/checkout@v3
    - name: NPM Install
    run: npm i
    - name: Jest run
    run: npm run test

    이런 식으로 yml 파일을 하나 추가하면 frontend의 수정이 일어날 때는 jest를 실행하고, backend 폴더의 수정이 일어나면 gradlew를 실행하게 할 수 있습니다.

    Test가 실패하는 PR은 Merge 막기

    Test가 실패하는 Pull Request가 Merge 되는 일은 절대로 없어야 합니다. 그런 실수를 방지하려면 팀원 전부가 리뷰할 때 테스트를 돌려봐야하는 귀찮음이 생길 수 있습니다.

    그리고 사람은 실수해도 기계는 거짓말을 하지 않습니다. 자동으로 막도록 동작하게 만들어놓으면 그럴 일을 미연에 방지할 수 있습니다.

    Environments 확인하기

    먼저 해당 Repository의 Settings -> Environments 탭으로 들어갑니다. -environments -아까 environment 속성을 보면 test라고 설정해놓은 것을 볼 수 있습니다. 해당 환경이 여기에 적용됩니다.

    Branch rule 정의하기

    이번에는 해당 Repository의 Settings -> Branches 탭으로 들어갑니다. 그리고 원하는 branch에 들어가 edit 버튼을 누릅니다.

    그리고 사진과 같이 Require deployments to succeed before merging 속성을 클릭합니다. 그리고 아래와 같이 어떤 환경을 적용할 것인지 선택할 수 있습니다.

    이 속성은 해당 배포가 성공해야 merge 할 수 있도록 브랜치를 보호하는 기능입니다.

    그리고 저희는 frontend와 backend Job의 환경을 둘 다 test라는 이름으로 정의했기 때문에 하나의 environment만 선택해도 둘 다 적용되는 효과를 볼 수 있습니다. -branch rule

    적용 후

    아래와 같이 merge가 안된다는 글과 빨간색으로 경고 표시를 해주고 있습니다. -blocked

    결론

    간단한 github action을 통해서 생산성을 많이 올릴 수 있는 좋은 기능인 것 같습니다. 다른 팀들도 이 기능을 도입하여 사용하는 것을 추천드립니다.

    - - +

    · 약 6분
    키아라

    서론

    안녕하세요 카페인팀 키아라입니다.

    이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

    사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

    프로퍼티 암호화는 왜 필요할까?

    spring:
    datasource:
    url: 데이터베이스 url
    username: 계정
    password: 비밀번호

    프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

    git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

    Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

    간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

    jasypt는 뭐지?

    Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

    민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

    사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

    공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

    사용 방법

    정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

    1. 라이브러리 추가 (= 의존성 추가)

    implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

    2. Jasypt 설정 및 Bean 등록

    key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

    @Configuration
    public class JasyptConfig {

    private String ENCRYPT_KEY = "hello";

    @Bean(name = "jasyptEncryptor")
    public StringEncryptor stringEncryptor() {
    PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

    SimpleStringPBEConfig config = new SimpleStringPBEConfig();

    config.setPassword(ENCRYPT_KEY);
    config.setAlgorithm("PBEWithMD5AndDES");
    config.setKeyObtentionIterations(1000);
    config.setPoolSize(1);
    config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
    config.setStringOutputType("base64");
    encryptor.setConfig(config);
    return encryptor;
    }
    }
    jasypt:
    encryptor:
    bean: jasyptEncryptor

    3. 암호화

    라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

    이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

    암복호화 사이트

    평문

      datasource:
    url: 데이터베이스 url
    username: 계정
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    나머지도 마저 암호화해줍시다.

      datasource:
    url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
    username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    실행

    올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

    그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

    실행 실패

    그런데 뭔가 이상하지 않나요?

    프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

    Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

    그럼 이 키를 어디에 숨길 수 있을까요?

    저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

    그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

    바로 환경변수를 설정하는 것이죠.

    + 환경변수 설정

    private String ENCRYPT_KEY = "hello";

    기존의 키를 관리하는 방식이었습니다.

    우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

    // JasyptConfig.class
    @Value("${jasypt.encryptor.password}")
    private String ENCRYPT_KEY;
    // application.yml
    jasypt:
    encryptor:
    password: hello

    이제 환경변수를 설정해봅시다.

    Run > Edit Configurations... 경로로 들어가면

    Run/Debug Configurations 창이 나오는데

    Environment variables: 부분에 ENCRYPT_KEY=hello

    라고 적어주세요.

    그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

    jasypt:
    encryptor:
    password: ${ENCRYPT_KEY}

    긴 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git a/page/32.html b/page/32.html index e391eacf..235e5427 100644 --- a/page/32.html +++ b/page/32.html @@ -5,13 +5,19 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 5분
    센트

    웹팩에서 msw 설정

    이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

    팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

    다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

    eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

    왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

    약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

    이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

    new CopyWebpackPlugin({
    patterns: [
    { from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
    ],
    }),

    설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

    문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

    1. MSW를 적용해보려고 함.
    2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
    3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
    4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
    - - +

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. +많은 시간을 단축할 수 있습니다.

    그리고 test가 실패한다면 강제로 Merge가 되지 않도록 한다면 실수로 테스트가 되지 않는 커밋을 올리는 것을 방지할 수 있겠죠.

    이 두가지만으로도 생산성이 많이 올라갈 것을 기대할 수 있습니다.

    어떻게 할 수 있나요

    Github Action을 이용하여 설정한 조건에 맞는 상황에서 명령어를 실행하여 test를 할 수 있습니다.

    Github Action 파일 생성

    1. 먼저 최상위 폴더에 .github/workflows 폴더를 생성합니다.
    2. 해당 폴더 내에 example.yml을 생성합니다.
    3. 아래와 같이 yml 파일을 작성합니다.
    name: pr test

    on:
    pull_request:
    branches:
    - main
    - develop

    permissions:
    contents: read

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    Job 이름 설정

    복잡하지 않습니다. 먼저 name 속성은 github action에서 보여질 Job의 이름을 정하는 부분입니다.

    지금은 pr test로 해두었습니다. 그럼 아래 사진과 같이 반영됩니다.

    workflows name

    workflow 트리거 설정

    다음으론 on 속성입니다. 이 속성은 workflow를 실행할 이벤트를 지정하는데 사용됩니다. 특정 이벤트 유형과 조건을 기반으로 workflow를 트리거하도록 구성할 수 있습니다.

    예를 들어 아래와 같이 정의했습니다.

    on:
    push:
    branches:
    - main
    pull_request:
    branches:
    - develop

    그렇다면 이 workflow가 작동되는 시점은 main 브랜치에 push가 되거나 develop 브랜치에 pull request를 보낼 때 작동합니다.

    권한 부여

    permissions:
    contents: read

    이런 권한을 주게 된다면 이 job은 읽기 권한밖에 없기 때문에 실수로 다른 것을 추가하지 못하게 막을 수 있습니다

    동작할 명령어 입력

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    name

    제일 간단히 볼 수 있는 name 설정은 아래 사진처럼 어떤식으로 보여줄지 정할 수 있습니다.

    job image

    runs-on

    runs-on 속성입니다. 해당 운영체제를 사용한다고 정의하는 부분입니다. 지금은 저희가 사용할 ec2와 같은 환경인 ubuntu에서 작동하도록 설정했지만, +windows-latest, macos-latest로 변경할 수도 있습니다.

    environment

    environment 속성입니다. 해당 속성은 꼭 필요한 부분이 아니지만 branch의 rule 설정에 사용할 수 있습니다. 그리고 환경을 한꺼번에 관리할 수 있습니다.

    이 부분은 아래에 branch rule을 정하는 부분을 보시면 아마 이해가 될 것 입니다.

    defaults

    해당 속성은 어떤 폴더에서 명령어를 실행할 지 지정합니다. 지금의 저희 프로젝트에서는 한 repository에 backend, frontend 폴더를 나누었기 때문에 backend 폴더로 이동하여 명령어를 실행해야 합니다.

    그래서 working-directory./backend라고 지정했습니다.

    steps

    제일 중요한 steps입니다. 해당 속성은 어떤 명령어를 어떤 순서로 실행시킬지 정의합니다. 지금의 workflow에선

    1. Java 17 설치
    2. gradlew 파일에 실행 권한 부여
    3. gradle build 실행

    순으로 동작합니다.

    다른 조건과 이벤트도 추가하고 싶어요

    저희 프로젝트는 하나의 repository에서 frontend, backend 코드를 같이 관리하는 상황입니다. 하지만 frontend 코드를 수정했다고 java 테스트를 돌리는 것은 오히려 생산성이 줄어들겠죠.

    그리고 frontend도 테스트를 돌리고 싶지만 gradle을 사용하지 않습니다.

    그럴 때 간단한 속성을 추가하면 파일의 변경에 따라 해당 job을 실행할 조건을 정의할 수 있습니다.

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - backend/**
    - .github/**

    위와 달리 지금 pull request에는 속성이 하나가 더 있는데요. paths를 적용하면 backend 폴더 하위의 무언가 변경이 있는 pull request에만 작동을 하게 됩니다.

    그럼 backend의 workflow 파일에 paths 속성을 하나 추가하고, 비슷한 frontend workflow를 만들어주면 되겠죠.

    name: frontend test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**

    permissions:
    contents: read

    jobs:
    test:
    name: jest
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - uses: actions/checkout@v3
    - name: NPM Install
    run: npm i
    - name: Jest run
    run: npm run test

    이런 식으로 yml 파일을 하나 추가하면 frontend의 수정이 일어날 때는 jest를 실행하고, backend 폴더의 수정이 일어나면 gradlew를 실행하게 할 수 있습니다.

    Test가 실패하는 PR은 Merge 막기

    Test가 실패하는 Pull Request가 Merge 되는 일은 절대로 없어야 합니다. 그런 실수를 방지하려면 팀원 전부가 리뷰할 때 테스트를 돌려봐야하는 귀찮음이 생길 수 있습니다.

    그리고 사람은 실수해도 기계는 거짓말을 하지 않습니다. 자동으로 막도록 동작하게 만들어놓으면 그럴 일을 미연에 방지할 수 있습니다.

    Environments 확인하기

    먼저 해당 Repository의 Settings -> Environments 탭으로 들어갑니다. +environments +아까 environment 속성을 보면 test라고 설정해놓은 것을 볼 수 있습니다. 해당 환경이 여기에 적용됩니다.

    Branch rule 정의하기

    이번에는 해당 Repository의 Settings -> Branches 탭으로 들어갑니다. 그리고 원하는 branch에 들어가 edit 버튼을 누릅니다.

    그리고 사진과 같이 Require deployments to succeed before merging 속성을 클릭합니다. 그리고 아래와 같이 어떤 환경을 적용할 것인지 선택할 수 있습니다.

    이 속성은 해당 배포가 성공해야 merge 할 수 있도록 브랜치를 보호하는 기능입니다.

    그리고 저희는 frontend와 backend Job의 환경을 둘 다 test라는 이름으로 정의했기 때문에 하나의 environment만 선택해도 둘 다 적용되는 효과를 볼 수 있습니다. +branch rule

    적용 후

    아래와 같이 merge가 안된다는 글과 빨간색으로 경고 표시를 해주고 있습니다. +blocked

    결론

    간단한 github action을 통해서 생산성을 많이 올릴 수 있는 좋은 기능인 것 같습니다. 다른 팀들도 이 기능을 도입하여 사용하는 것을 추천드립니다.

    + + \ No newline at end of file diff --git a/page/33.html b/page/33.html index f3a38854..eaf33b62 100644 --- a/page/33.html +++ b/page/33.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    - - +

    · 약 5분
    센트

    웹팩에서 msw 설정

    이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

    팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

    다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

    eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

    왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

    약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

    이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

    new CopyWebpackPlugin({
    patterns: [
    { from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
    ],
    }),

    설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

    문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

    1. MSW를 적용해보려고 함.
    2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
    3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
    4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
    + + \ No newline at end of file diff --git a/page/34.html b/page/34.html index f18eead2..3ac6710b 100644 --- a/page/34.html +++ b/page/34.html @@ -5,14 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 -https://blog.deering.co/commit-convention/

    - - +

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/page/35.html b/page/35.html index ce0a5016..521289cb 100644 --- a/page/35.html +++ b/page/35.html @@ -5,13 +5,14 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    - - +

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 +https://blog.deering.co/commit-convention/

    + + \ No newline at end of file diff --git a/page/36.html b/page/36.html index 9c1b9b53..b83d90d8 100644 --- a/page/36.html +++ b/page/36.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    - - +

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/page/37.html b/page/37.html index 5eefd59c..d12d255c 100644 --- a/page/37.html +++ b/page/37.html @@ -5,23 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, -우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. -성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. -(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? -따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. -이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? -이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. -깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. -물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? -저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. -이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - +

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    + + \ No newline at end of file diff --git a/page/38.html b/page/38.html index 23132f10..42f7de08 100644 --- a/page/38.html +++ b/page/38.html @@ -5,13 +5,23 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    - - +

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. +성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. +(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? +따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. +이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? +이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. +깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. +물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? +저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. +이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    + + \ No newline at end of file diff --git a/page/39.html b/page/39.html index a5db7e03..d1d38ffb 100644 --- a/page/39.html +++ b/page/39.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    - - +

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    + + \ No newline at end of file diff --git a/page/4.html b/page/4.html index 1c42095e..d9e51aea 100644 --- a/page/4.html +++ b/page/4.html @@ -5,22 +5,23 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset -no offset

    네이버에 있는 전기자동차 동호회 카페 중 가장 큰 곳에 글을 올려 방문자를 모집하기로 했습니다.

    카페에 글을 올리는 것은 무료이며, 카페에 가입한 사람들은 전기차에 관심이 있는 사람들이기 때문에 저희가 원하는 방문자를 모집하기에 적합하다고 생각했습니다.

    카카오톡 오픈채팅

    no offset -no offset

    카카오톡 오픈채팅에는 수많은 대화방이 존재합니다.

    특정 주제로 만들어진 대화방이 대부분이기에 전기차를 주제로 한 오픈채팅 대화방을 찾는 것은 전혀 어렵지 않았습니다.

    안타깝게도 일부 단톡방에서 강퇴를 당했지만, 차주들과 채팅하면서 피드백을 받아볼 수 있었습니다.

    기타 홍보 수단

    기타 홍보 수단은 아직 사용하지 않았습니다.

    네이버 밴드, 보배드림은 사용하는 크루가 없어서 홍보를 하기 어려웠고, 구글 애드센스와 같은 도구는 비용이 발생하기에 아직은 이르다고 판단했습니다.

    Google Analytics 4 통계 집계 결과

    단순 방문자

    no offset -no offset -no offset -no offset -이처럼 외부 지역에서도 많이 접속해주신 것을 확인할 수 있습니다. -no offset -no offset -no offset

    집계 된 자료처럼 방문자들이 단순 방문만 한 것이 아니라, 수 많은 이벤트를 발생시키고 평균 참여 시간도 상당 부분 확보했음을 확인할 수 있습니다.

    - - +

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. +기존에는 충전소 간단 정보와 마커 정보를 한 번에 받아오도록 설계되어 있었지만, +백엔드와 프론트엔드가 협업하여 간단 정보와 마커 정보를 각각 필요한 만큼만 조회하도록 명세를 수정하였습니다.

    이 과정에서 먼저, 백엔드와 프론트엔드는 함께 모여 기능 요구사항과 성능 개선 목표를 논의하였습니다. +그리고 충전소 간단 정보와 마커 정보를 각각 조회하는 API 엔드포인트를 새로 설계하였습니다.

    다음으로, 백엔드에서 간단 정보 조회를 위한 API를 구현하였습니다. +필요한 필드만을 조회하여 데이터베이스의 부하를 줄이고 응답 시간을 개선하였습니다. +이후에는 프론트엔드에서 해당 API를 호출하여 필요한 정보를 받아오도록 수정하였습니다.

    마지막으로, 마커 정보 조회를 위한 API를 구현하였습니다. +마커 정보는 지도에 표시되는 정보로서, 요청한 영역 외부로 지도가 이동할 경우 호출되도록 설계되었습니다. +기존에는 간단 정보 리스트를 보여주기 위해 조회하던 정보들이 다수 포함되어 있었지만, +이 정보를 제외하고 마커를 띄우기 위해 필요한 최소한의 정보를 조회하도록 수정해 서버의 부하를 낮췄습니다.

    이러한 변경으로 인해 충전소 조회 API의 성능이 개선되었습니다. +필요한 정보만을 조회하므로써 데이터베이스의 부하를 줄이고 응답 시간을 단축할 수 있게 되었습니다. +또한, 프론트엔드에서는 필요한 정보만을 호출하여 불필요한 데이터를 받아오지 않아도 되므로 클라이언트 측의 성능도 향상되었습니다.

    + + \ No newline at end of file diff --git a/page/40.html b/page/40.html index 84537eca..c1a9c847 100644 --- a/page/40.html +++ b/page/40.html @@ -5,13 +5,13 @@ Blog | CAR-FFEINE - - + + - - +

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    + + \ No newline at end of file diff --git a/page/41.html b/page/41.html new file mode 100644 index 00000000..adb82415 --- /dev/null +++ b/page/41.html @@ -0,0 +1,17 @@ + + + + + +Blog | CAR-FFEINE + + + + + + + + + + \ No newline at end of file diff --git a/page/5.html b/page/5.html index 7fde1252..a5df7ba5 100644 --- a/page/5.html +++ b/page/5.html @@ -5,13 +5,22 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    - - +

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset +no offset

    네이버에 있는 전기자동차 동호회 카페 중 가장 큰 곳에 글을 올려 방문자를 모집하기로 했습니다.

    카페에 글을 올리는 것은 무료이며, 카페에 가입한 사람들은 전기차에 관심이 있는 사람들이기 때문에 저희가 원하는 방문자를 모집하기에 적합하다고 생각했습니다.

    카카오톡 오픈채팅

    no offset +no offset

    카카오톡 오픈채팅에는 수많은 대화방이 존재합니다.

    특정 주제로 만들어진 대화방이 대부분이기에 전기차를 주제로 한 오픈채팅 대화방을 찾는 것은 전혀 어렵지 않았습니다.

    안타깝게도 일부 단톡방에서 강퇴를 당했지만, 차주들과 채팅하면서 피드백을 받아볼 수 있었습니다.

    기타 홍보 수단

    기타 홍보 수단은 아직 사용하지 않았습니다.

    네이버 밴드, 보배드림은 사용하는 크루가 없어서 홍보를 하기 어려웠고, 구글 애드센스와 같은 도구는 비용이 발생하기에 아직은 이르다고 판단했습니다.

    Google Analytics 4 통계 집계 결과

    단순 방문자

    no offset +no offset +no offset +no offset +이처럼 외부 지역에서도 많이 접속해주신 것을 확인할 수 있습니다. +no offset +no offset +no offset

    집계 된 자료처럼 방문자들이 단순 방문만 한 것이 아니라, 수 많은 이벤트를 발생시키고 평균 참여 시간도 상당 부분 확보했음을 확인할 수 있습니다.

    + + \ No newline at end of file diff --git a/page/6.html b/page/6.html index 59dde566..76612261 100644 --- a/page/6.html +++ b/page/6.html @@ -5,25 +5,13 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 9분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    저희 서비스에서는 주기적으로 충전기의 상태와 정보를 업데이트하거나, 통계를 저장하는 스케줄링 작업이 있습니다. -지금의 저희 서버는 단일 서버로 구성되어있어 문제가 없지만, 만약 서버를 scale-out 하게 된다면 어떻게 될까요?

    똑같은 schedule이 중복되어 실행될 것입니다. 그렇다고 어떤 서버는 schedule을 동작하지 않도록 하고, 어떤 서버는 schedule을 동작하도록 한다면 스케줄이 동작하는 서버가 다운된다면 동작하는 -서버의 다운타임만큼 저희 서버의 데이터를 최신화할 수 없고, 최신화가 중요한 저희 서비스에서는 사용자의 불만을 초래할 수 있습니다.

    구현해보기

    Schedule 정보를 어떻게 다른 환경에서 같이 공유하여 관리할 수 있을까요? -간단히 생각하면 Local 환경이 아닌, Global 환경에서 정보를 관리하면 될 것 같습니다.

    따라서 Schedule의 정보를 저장할 수 있는 테이블을 아래의 Entity 의 필드와 같이 생성해보겠습니다.

    @Entity
    public class ScheduleTask extends BaseEntity {

    @Id
    private String id;

    private String jobName;

    @Enumerated(EnumType.STRING)
    private JobStatus status;
    }

    먼저 id는 해당 스케줄을 구분할 수 있는 id여야 할 것입니다. 가장 쉽게 정할 수 있는 id는 스케줄의 job 이름과, -Schedule으로 등록한 시간을 조합하여 생성한다면 unique하고 분산 환경에서도 쉽게 구분할 수 있는 id가 될 것 입니다.

    그리고 아래와 같은 Business Logic 있다고 가정하겠습니다.

    @Service
    public class BusinessLogic {

    private final ApplicationEventPublisher applicationEventPublisher;

    @Scheduled(cron = "0/2 * * * * *")
    public void complexJob() {
    log.info("복잡한 Job 시작");
    }

    @Scheduled(cron = "0/4 * * * * *")
    public void moreComplexJob() {
    log.info("좀 더 복잡한 Job 시작");
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    }
    }
    }

    하나는 매 2초마다 실행 후 바로 종료되고, 하나는 매 4초마다 실행 후 3초의 대기와 종료되는 메서드입니다. -이런 스케줄은 어떻게 동작할까요? 저는 당연히 2초와 4초마다 해당 메서드가 실행될 줄 알았습니다.

    로그를 살펴보면 아래와 같은 결과가 발생했습니다. -log -복잡한 job이 2번 실행될 때, 좀 더 복잡한 job이 1번 실행되는 걸 볼 수 있습니다. 예상했던 결과입니다.

    하지만 실행된 시간을 살펴보겠습니다. -log-with-time

    분명 매 2초와 4초마다 실행하기 때문에 작업 시간이 2의 배수가 되어야할텐데

    34, 36, 36, 39, 40, 40, 43, 44, 44, 47초 로 점점 작업이 밀리는 것을 확인할 수 있습니다.

    왜 그럴까요? 스프링 공식 문서에서는 아래와 같이 설명하고 있습니다.

    A ThreadPoolTaskScheduler can also be auto-configured if need to be associated to scheduled task execution (using @EnableScheduling for instance). The thread pool uses one thread by default and its settings can be fine-tuned using the spring.task.scheduling namespace, as shown in the following example:

    참고 - 스프링 공식 문서

    스프링의 Schedule은 Default로 하나의 싱글 스레드에서 동작하기 때문입니다. -그렇기 때문에 매번 작업이 밀려 원하는 시간에 동작하지 않는 현상이 발생할 수 있습니다.

    하지만 Schedule을 분산 환경에서 구분하기 위해서는 job이 실행된 시간이 중요하기 때문에 이렇게 작업이 밀려버린다면 구분을 할 수 없게 됩니다. -따라서 Schedule Thread Pool Size를 늘리도록 하겠습니다.

    @Configuration
    public class ScheduleConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
    ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
    taskScheduler.setPoolSize(10);
    taskScheduler.setThreadNamePrefix("schedule-task-");
    taskScheduler.initialize();
    taskRegistrar.setTaskScheduler(taskScheduler);
    }
    }

    SchedulingConfigurer 를 구현하여 Thread Pool size를 일단 10개로 정의했습니다.

    success -스레드 풀을 늘렸더니 위와 같이 2의 배수의 시간에 정확히 작동이 되는 것을 확인할 수 있습니다.

    하지만 이렇게 여러 작업을 동시에 실행된다면 데이터베이스에 병목현상이 발생되어 오히려 작업이 더 느리게 끝날 수도 있다고 생각했습니다.

    그래서 해당 부분의 실행을 관리하는 클래스를 생성하여 해당 클래스에서 Schedule의 작업을 관리하도록 구현했습니다.

    @Service
    public class BusinessLogic {

    private final ApplicationEventPublisher applicationEventPublisher;

    @Scheduled(cron = "0/2 * * * * *")
    public void complexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::complexJob, "complexJob", LocalDateTime.now()));
    }

    @Scheduled(cron = "0/4 * * * * *")
    public void moreComplexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::moreComplexJob, "moreComplexJob", LocalDateTime.now()));
    }
    }

    로직이 있는 BusinessLogic 서비스에서 스케줄의 시간마다 실행해야할 메서드를 Event로 발행합니다.

    @Component
    public class ScheduleService {

    private final ExecutorService executorService = Executors.newFixedThreadPool(1);
    private final Queue<SchedulingEvent> scheduleTasks = new ConcurrentLinkedQueue<>();
    private final AtomicBoolean isRunning = new AtomicBoolean(false);

    @EventListener
    public void addTask(SchedulingEvent schedulingEvent) {
    scheduleTasks.add(schedulingEvent);
    }

    @Scheduled(cron = "0/1 * * * * *")
    public void polling() {
    if (!scheduleTasks.isEmpty() || isRunning.compareAndSet(false, true)) {
    SchedulingEvent schedulingEvent = scheduleTasks.poll();
    executorService.execute(() -> execute(schedulingEvent));
    }
    }
    }

    그리고 위와 같은 스케줄을 관리하는 서비스에서는 Schedule Event를 받아 실행하도록 만들었습니다. 해당 클래스에서는 ThreadPool을 새로 생성하여, schedule의 스레드에 영향을 받지 않도록 구현했습니다.

    그리고 1초마다 실행되는 스케줄을 만들어 queue에 작업이 있는지, 현재 작업 중인지 확인하여 그렇지 않다면 queue에서 작업을 꺼내 실행하도록 만들었습니다.

    거의 구현이 끝나갑니다. 이제는 해당 Schedule의 데이터를 저장하고, 작업이 실패했을 시에 다시 작업을 하기 위한 기능만 구현하면 될 것 같습니다.

    @Component
    public class ScheduleService {

    ...

    private void execute(SchedulingEvent schedulingEvent) {
    String jobId = schedulingEvent.jobId();
    LocalDateTime executionTime = schedulingEvent.executionTime();

    if (isJobInProgressOrDone(jobId)) {
    log.info("작업이 실행중입니다. {} {}", executionTime, jobId);
    return;
    }
    ScheduleTask entity = new ScheduleTask(jobId, executionTime, JobStatus.RUNNING);
    scheduleTaskJdbcRepository.save(entity);

    try {
    schedulingEvent.runnable().run();
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.DONE);
    } catch (Exception e) {
    log.error("{} 작업 실행 중 에러가 발생했습니다.", jobId);
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.ERROR);
    tasks.add(schedulingEvent);
    }
    }

    private boolean isJobInProgressOrDone(String jobId) {
    Optional<ScheduleTask> taskOptional = scheduleTaskRepository.findById(jobId);
    if (taskOptional.isPresent()) {
    ScheduleTask scheduleTask = taskOptional.get();
    return scheduleTask.getStatus() == JobStatus.RUNNING || scheduleTask.getStatus() == JobStatus.DONE;
    }
    return false;
    }
    }

    이 부분은 간단하게 구현할 수 있습니다. 위와 같이 작업의 실행 시간과, job의 이름으로 데이터베이스에서 조회하고, 없다면 작업을 실행하고 -있다면 작업이 ERROR 인지 확인하여 작업을 실행해주면 될 것 같습니다.

    complete

    위와 같이 두 개의 서버를 동시에 띄웠을 때에도 스케줄이 잘 작동하는 것을 확인할 수 있습니다.

    결론

    스케줄을 이렇게 구현할 수도 있지만 환경이 된다면 Message Queue를 사용하는 것이 어떨까요?

    혹시 틀린 부분이 있다면 지적 부탁드립니다.

    - - +

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    + + \ No newline at end of file diff --git a/page/7.html b/page/7.html index 8ffdcf31..c951d1da 100644 --- a/page/7.html +++ b/page/7.html @@ -5,18 +5,25 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 13분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    이전 글에서도 계속 설명했듯이 조회 성능을 최대한 빠르게 하는 것이 저희 서비스에서 핵심이라고 생각하기 때문에 지금도 예전에 비해 빨라졌지만 다른 개선점이 보여 개선을 하고자합니다.

    조회 성능 개선하기 1 (인덱스)

    조회 성능 개선하기 2 (데이터베이스 복제)

    결론

    결론부터 말씀드리면 로컬에서 캐싱을 적용한 후 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 78 -> 128

    Response Time 1236 ms -> 751 ms

    64% 성능이 개선 되었습니다.

    (저번 성능 테스트의 결과가 다른 이유는 비즈니스 로직이 변경되어 조회 방식이 바뀌었기 때문입니다. 그래서 캐싱을 적용하기전, 한 후 를 비교했습니다.)

    Caching

    In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.

    캐싱은 위키 백과에서 위와 같이 설명하고 있습니다. 즉 메모리에 데이터를 복사본을 올려 좀 더 빠르게 데이터에 접근하는 방식입니다.

    캐싱의 단점은 수정, 삽입, 삭제가 되었을 때, 관리 포인트가 두 군데가 된다는 점입니다. 만약 데이터베이스에만 새로운 정보를 저장하고, 캐시에는 저장해주지 않는다면 사용자는 그 정보를 볼 수 없습니다.

    하지만 저희 서비스에서 적용한 이유는 충전기의 충전 상태 (충전 중, 대기중, 고장)에 대한 정보는 최신화가 되어야하지만, 충전소의 이름이라던지, 위치, 다른 정보들은 쉽게 변하지 않기 때문에 해당 정보를 캐싱한다면 좋을 것 같았습니다.

    캐싱 적용하기

    먼저 캐싱을 어디에서 하는지도 중요합니다. 크게 로컬 캐시글로벌 캐시로 나눌 수 있을 것 같습니다. -글로벌 캐시의 장점은 스케일 아웃을 했을 때, 모든 서버가 다 같은 데이터를 바라보기 때문에 데이터 정합성이 좋아집니다. 하지만 저희 서비스는 단일 서버로 구성되어 있기 때문에, 로컬 캐시를 해도 문제가 없습니다. 그리고 글로벌 캐시를 적용하기 위해서는 Redis나 Memcached 같은 도구를 모든 팀원이 알아야하지만 로컬 캐시는 그렇게 하지 않더라도 편하게 적용할 수 있다는 점에서 로컬에 캐싱하는 방법을 적용해보겠습니다.

    캐싱할 정보 가져오기

    캐싱을 하기 위해서는 먼저 캐싱할 데이터를 가져와야합니다. 저희 서비스는 출장 혹은 여행을 가는 전기차 오너가 핵심 페르소나이기 때문에 사용자들이 찾는 정보의 위치는 불특정합니다. 서울에서 다른 지방으로 출장을 가는 경우도 있을 것이고, 지방에서 서울에 가는 경우도 있기 때문에, 모든 데이터를 캐싱해야할 것이라 판단했습니다.

    그래서 어플리케이션 실행 시에 모든 충전소를 캐싱하기로 선택했습니다.

    @Configuration
    public class InitialStationCache implements ApplicationRunner {

    private final StationCacheRepository stationCacheRepository;
    private final StationQueryRepository stationQueryRepository;

    @Override
    public void run(ApplicationArguments args) {
    log.info("Initialize station cache");
    List<StationInfo> stations = stationQueryRepository.findAll();
    stationCacheRepository.initialize(stations);
    log.info("Station cache initialized");
    log.info("Station cache size: {}", stations.size());
    }
    }

    위와 같이 ApplicationRunner를 구현하여 어플리케이션 실행 시 모든 충전소의 정보를 가져오도록 만들었습니다. -여기서 Entity인 Station을 가져오지 않은 이유는 크게 두가지가 있습니다.

    1. 지도로 조회하는 부분의 성능을 개선하고자 했지만, Entity에는 지도를 조회할 때 불필요한 정보도 있기 때문에 메모리상의 낭비가 생길 수 있습니다.
    2. Entity를 캐싱하게 된다면 hibernate 1차 캐시에도 적재되고, 힙 메모리에도 적재되는 일이 발생하여 메모리상 낭비라고 생각했습니다.

    범위 검색하기

    충전소의 데이터를 조회하는 조건은 위도, 경도의 최소, 최대값을 기준으로 만족하는 데이터를 보여줍니다. -아래와 같이 간단히 조건을 stream()의 filter()를 사용해서 구현했습니다.

    public class StationCacheRepository {

    private final List<StationInfo> cachedStations;

    public List<StationInfo> findByCoordinate(
    BigDecimal minLatitude,
    BigDecimal maxLatitude,
    BigDecimal minLongitude,
    BigDecimal maxLongitude
    ) {
    return cachedStations.stream()
    .filter(it -> it.latitude().compareTo(minLatitude) >= 0 && it.latitude().compareTo(maxLatitude) <= 0)
    .filter(it -> it.longitude().compareTo(minLongitude) >= 0 && it.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }
    }

    하지만 해당 방법으로 로컬에서 조회를 테스트 했을 때 캐시를 적용한 것보다 더 느려진 결과가 나왔습니다. -캐싱을 해서 데이터베이스까지 요청을 보내지 않는데 왜 더 느려진 것일까요?

    답은 인덱스 였습니다. Mysql 에서 인덱스는 B Tree로 구성되어 있습니다. 데이터베이스에서는 위도, 경도로 복합 인덱스가 설정되어 있었지만, 현재 어플리케이션 로직에는 해당 부분이 없습니다.

    그래서 filter로 순회하는 시간복잡도가 O(n)이고, 데이터베이스에서는 O(log n)이기 때문에 더 느려진 것입니다. 그렇다고 제가 직접 B tree 자료구조를 직접 구현해야할까요?

    현재 해당 조회 API는 위도 경도로 범위 탐색을 하고 있습니다. 결국엔 station의 정보들이 위도, 경도로 정렬만 되어 있다면 B tree를 직접 구현하지 않더라도 같은 시간복잡도 O(log n)으로 탐색할 수 있습니다. -물론 B tree와 다른 부분은 해당 충전소의 정확한 위도, 경도로 단일 칼럼을 조회할 때는 O(n)이기 때문에 이런 방법이 문제가 될 수 있지만, 해당 캐시 데이터로는 무조건 범위 탐색을 하기 때문에, B tree를 구현하지 않고 이분 탐색으로 조회하는 방식으로 변경해보겠습니다.

        public void initialize(List<StationInfo> stations) {
    cachedStations.addAll(stations);
    cachedStations.sort((o1, o2) -> {
    int latitudeCompare = o1.latitude().compareTo(o2.latitude());
    if (latitudeCompare == 0) {
    return o1.longitude().compareTo(o2.longitude());
    }
    return latitudeCompare;
    });
    }

    private List<StationInfo> findStations(BigDecimal minLatitude, BigDecimal maxLatitude, BigDecimal minLongitude, BigDecimal maxLongitude) {
    int lowerBound = binarySearch(minLatitude, START_INDEX);
    int upperBound = binarySearch(maxLatitude, lowerBound);
    if (lowerBound == -1 || upperBound == -1) {
    return Collections.emptyList();
    }
    return cachedStations.stream()
    .skip(lowerBound)
    .limit(upperBound - lowerBound)
    .filter(station -> station.longitude().compareTo(minLongitude) >= 0 && station.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }

    private int binarySearch(BigDecimal latitude, int startIndex) {
    int left = startIndex;
    int right = cachedStations.size() - 1;
    int result = -1;
    while (left <= right) {
    int middle = left + (right - left) / 2;
    StationInfo middleStation = cachedStations.get(middle);
    if (middleStation.latitude().compareTo(latitude) >= 0) {
    result = middle;
    right = middle - 1;
    } else {
    left = middle + 1;
    }
    }
    return result;
    }

    먼저 어플리케이션이 실행될 때 cache 데이터를 찾아 저장하는 것 뿐만 아니라, 위도(Latitude)를 기준으로 정렬하도록 만들었습니다. 그리고 위도의 최소, 최대값의 인덱스를 가장 효율적으로 찾아올 수 있도록 binary search를 하는 메서드를 만들었습니다. 이렇게 한다면 O(log n) 으로 위도의 최대 최소 조건에 포함되는 모든 station의 값을 조회할 수 있습니다. 그리고 조회한 데이터들의 개수만큼 filter를 통해 경도(longitude) 가 포함되는지 확인합니다. 해당 방식의 구현은 B tree가 작동하는 방식과 유사할 것입니다.

    이분 탐색을 적용한 결과 로컬에서 응답 속도가 120 ms -> 50 ~ 70 ms로 약 2배 빨라진 것을 확인할 수 있습니다.

    실시간이 중요한 데이터는?

    앞서 말씀드렸다시피 지도로 충전소를 조회할 때, 충전소의 정보들에는 바뀌지 않는 정보뿐만 아니라, 최신화해야하는 충전기의 현재 상태 정보가 있습니다. 이러한 정보들은 캐싱해둘 수 없습니다. 하더라도, 관리 포인트가 늘어나기 때문에 데이터베이스에서 캐싱해둔 충전기 id로 충전기의 상태를 찾아와서 정보를 합쳐 반환하는 식으로 만들 수 있습니다.

        select cs.station_id,
    sum(case
    when cs.charger_condition = 'STANDBY' then 1
    else 0
    end)
    from charger_status cs
    where cs.station_id in (?, ?, ?, ?, ?, ?, ?)
    group by cs.station_id

    위와 같은 쿼리로 해당 충전소의 최신화된 충전기 상태를 가져올 수 있습니다.

    캐싱을 하기전에 데이터베이스를 이용해 데이터를 가져올 때의 쿼리는 아래와 같습니다.

     select
    distinct s.station_id
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    where
    s.latitude>=?
    and s.latitude<=?
    and s.longitude>=?
    and s.longitude<=?
    -------------------------------------------------
    select
    s.station_id,
    s.station_name,
    s.latitude,
    s.longitude,
    s.is_parking_free,
    s.is_private,
    sum(case
    when cs.charger_condition='STANDBY' then 1
    else 0
    end),
    sum(case
    when c.capacity>=50 then 1
    else 0
    end)
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    inner join
    charger_status cs
    on (
    c.station_id=cs.station_id
    and c.charger_id=cs.charger_id
    )
    where
    s.station_id in (
    ?,?,?,?
    )
    group by
    s.station_id

    원래는 위와 같이 여러번의 Join을 하고, 2번의 쿼리가 나갔던 반면 지금은 join을 하지않는 한번의 깔끔한 쿼리로 개선되었습니다.

    그리고 station 테이블의 위도, 경도로 범위 탐색을 위해 생성했던 index도 제거할 수 있게 되었습니다!

    결론

    1. 캐싱할 수 있는 부분은 하는 것도 좋을 것 같습니다
    2. 시간 복잡도를 계산해봅시다.
    3. 성능 개선 재밌습니다.
    - - +

    · 약 9분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    저희 서비스에서는 주기적으로 충전기의 상태와 정보를 업데이트하거나, 통계를 저장하는 스케줄링 작업이 있습니다. +지금의 저희 서버는 단일 서버로 구성되어있어 문제가 없지만, 만약 서버를 scale-out 하게 된다면 어떻게 될까요?

    똑같은 schedule이 중복되어 실행될 것입니다. 그렇다고 어떤 서버는 schedule을 동작하지 않도록 하고, 어떤 서버는 schedule을 동작하도록 한다면 스케줄이 동작하는 서버가 다운된다면 동작하는 +서버의 다운타임만큼 저희 서버의 데이터를 최신화할 수 없고, 최신화가 중요한 저희 서비스에서는 사용자의 불만을 초래할 수 있습니다.

    구현해보기

    Schedule 정보를 어떻게 다른 환경에서 같이 공유하여 관리할 수 있을까요? +간단히 생각하면 Local 환경이 아닌, Global 환경에서 정보를 관리하면 될 것 같습니다.

    따라서 Schedule의 정보를 저장할 수 있는 테이블을 아래의 Entity 의 필드와 같이 생성해보겠습니다.

    @Entity
    public class ScheduleTask extends BaseEntity {

    @Id
    private String id;

    private String jobName;

    @Enumerated(EnumType.STRING)
    private JobStatus status;
    }

    먼저 id는 해당 스케줄을 구분할 수 있는 id여야 할 것입니다. 가장 쉽게 정할 수 있는 id는 스케줄의 job 이름과, +Schedule으로 등록한 시간을 조합하여 생성한다면 unique하고 분산 환경에서도 쉽게 구분할 수 있는 id가 될 것 입니다.

    그리고 아래와 같은 Business Logic 있다고 가정하겠습니다.

    @Service
    public class BusinessLogic {

    private final ApplicationEventPublisher applicationEventPublisher;

    @Scheduled(cron = "0/2 * * * * *")
    public void complexJob() {
    log.info("복잡한 Job 시작");
    }

    @Scheduled(cron = "0/4 * * * * *")
    public void moreComplexJob() {
    log.info("좀 더 복잡한 Job 시작");
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    }
    }
    }

    하나는 매 2초마다 실행 후 바로 종료되고, 하나는 매 4초마다 실행 후 3초의 대기와 종료되는 메서드입니다. +이런 스케줄은 어떻게 동작할까요? 저는 당연히 2초와 4초마다 해당 메서드가 실행될 줄 알았습니다.

    로그를 살펴보면 아래와 같은 결과가 발생했습니다. +log +복잡한 job이 2번 실행될 때, 좀 더 복잡한 job이 1번 실행되는 걸 볼 수 있습니다. 예상했던 결과입니다.

    하지만 실행된 시간을 살펴보겠습니다. +log-with-time

    분명 매 2초와 4초마다 실행하기 때문에 작업 시간이 2의 배수가 되어야할텐데

    34, 36, 36, 39, 40, 40, 43, 44, 44, 47초 로 점점 작업이 밀리는 것을 확인할 수 있습니다.

    왜 그럴까요? 스프링 공식 문서에서는 아래와 같이 설명하고 있습니다.

    A ThreadPoolTaskScheduler can also be auto-configured if need to be associated to scheduled task execution (using @EnableScheduling for instance). The thread pool uses one thread by default and its settings can be fine-tuned using the spring.task.scheduling namespace, as shown in the following example:

    참고 - 스프링 공식 문서

    스프링의 Schedule은 Default로 하나의 싱글 스레드에서 동작하기 때문입니다. +그렇기 때문에 매번 작업이 밀려 원하는 시간에 동작하지 않는 현상이 발생할 수 있습니다.

    하지만 Schedule을 분산 환경에서 구분하기 위해서는 job이 실행된 시간이 중요하기 때문에 이렇게 작업이 밀려버린다면 구분을 할 수 없게 됩니다. +따라서 Schedule Thread Pool Size를 늘리도록 하겠습니다.

    @Configuration
    public class ScheduleConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
    ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
    taskScheduler.setPoolSize(10);
    taskScheduler.setThreadNamePrefix("schedule-task-");
    taskScheduler.initialize();
    taskRegistrar.setTaskScheduler(taskScheduler);
    }
    }

    SchedulingConfigurer 를 구현하여 Thread Pool size를 일단 10개로 정의했습니다.

    success +스레드 풀을 늘렸더니 위와 같이 2의 배수의 시간에 정확히 작동이 되는 것을 확인할 수 있습니다.

    하지만 이렇게 여러 작업을 동시에 실행된다면 데이터베이스에 병목현상이 발생되어 오히려 작업이 더 느리게 끝날 수도 있다고 생각했습니다.

    그래서 해당 부분의 실행을 관리하는 클래스를 생성하여 해당 클래스에서 Schedule의 작업을 관리하도록 구현했습니다.

    @Service
    public class BusinessLogic {

    private final ApplicationEventPublisher applicationEventPublisher;

    @Scheduled(cron = "0/2 * * * * *")
    public void complexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::complexJob, "complexJob", LocalDateTime.now()));
    }

    @Scheduled(cron = "0/4 * * * * *")
    public void moreComplexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::moreComplexJob, "moreComplexJob", LocalDateTime.now()));
    }
    }

    로직이 있는 BusinessLogic 서비스에서 스케줄의 시간마다 실행해야할 메서드를 Event로 발행합니다.

    @Component
    public class ScheduleService {

    private final ExecutorService executorService = Executors.newFixedThreadPool(1);
    private final Queue<SchedulingEvent> scheduleTasks = new ConcurrentLinkedQueue<>();
    private final AtomicBoolean isRunning = new AtomicBoolean(false);

    @EventListener
    public void addTask(SchedulingEvent schedulingEvent) {
    scheduleTasks.add(schedulingEvent);
    }

    @Scheduled(cron = "0/1 * * * * *")
    public void polling() {
    if (!scheduleTasks.isEmpty() || isRunning.compareAndSet(false, true)) {
    SchedulingEvent schedulingEvent = scheduleTasks.poll();
    executorService.execute(() -> execute(schedulingEvent));
    }
    }
    }

    그리고 위와 같은 스케줄을 관리하는 서비스에서는 Schedule Event를 받아 실행하도록 만들었습니다. 해당 클래스에서는 ThreadPool을 새로 생성하여, schedule의 스레드에 영향을 받지 않도록 구현했습니다.

    그리고 1초마다 실행되는 스케줄을 만들어 queue에 작업이 있는지, 현재 작업 중인지 확인하여 그렇지 않다면 queue에서 작업을 꺼내 실행하도록 만들었습니다.

    거의 구현이 끝나갑니다. 이제는 해당 Schedule의 데이터를 저장하고, 작업이 실패했을 시에 다시 작업을 하기 위한 기능만 구현하면 될 것 같습니다.

    @Component
    public class ScheduleService {

    ...

    private void execute(SchedulingEvent schedulingEvent) {
    String jobId = schedulingEvent.jobId();
    LocalDateTime executionTime = schedulingEvent.executionTime();

    if (isJobInProgressOrDone(jobId)) {
    log.info("작업이 실행중입니다. {} {}", executionTime, jobId);
    return;
    }
    ScheduleTask entity = new ScheduleTask(jobId, executionTime, JobStatus.RUNNING);
    scheduleTaskJdbcRepository.save(entity);

    try {
    schedulingEvent.runnable().run();
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.DONE);
    } catch (Exception e) {
    log.error("{} 작업 실행 중 에러가 발생했습니다.", jobId);
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.ERROR);
    tasks.add(schedulingEvent);
    }
    }

    private boolean isJobInProgressOrDone(String jobId) {
    Optional<ScheduleTask> taskOptional = scheduleTaskRepository.findById(jobId);
    if (taskOptional.isPresent()) {
    ScheduleTask scheduleTask = taskOptional.get();
    return scheduleTask.getStatus() == JobStatus.RUNNING || scheduleTask.getStatus() == JobStatus.DONE;
    }
    return false;
    }
    }

    이 부분은 간단하게 구현할 수 있습니다. 위와 같이 작업의 실행 시간과, job의 이름으로 데이터베이스에서 조회하고, 없다면 작업을 실행하고 +있다면 작업이 ERROR 인지 확인하여 작업을 실행해주면 될 것 같습니다.

    complete

    위와 같이 두 개의 서버를 동시에 띄웠을 때에도 스케줄이 잘 작동하는 것을 확인할 수 있습니다.

    결론

    스케줄을 이렇게 구현할 수도 있지만 환경이 된다면 Message Queue를 사용하는 것이 어떨까요?

    혹시 틀린 부분이 있다면 지적 부탁드립니다.

    + + \ No newline at end of file diff --git a/page/8.html b/page/8.html index d8d525c2..5031bd6c 100644 --- a/page/8.html +++ b/page/8.html @@ -5,32 +5,18 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 7분
    제이

    안녕하세요. -카페인 팀의 제이입니다.

    저희 서비스에서는 충전소의 요일과 시간대 별로 충전소 혼잡도 정보를 제공을 차별적인 기능으로 제공하고 있습니다.

    이를 구현하기 위해서 공공 데이터에서 정보를 수집하고있습니다. -혼잡도를 조회하기 위해서는 약 23만 건의 충전소 7일 24시간 = 약 4000만 건의 데이터 중에서 조회를 하는 형식으로 되어있습니다.

    너무 많은 데이터가 있다보니 조회 속도가 많이 느린데요. -오늘은 이를 어떻게 개선했는지 작성해보도록 하겠습니다.

    참고로 해당 글의 성능 측정에 이용한 데이터의 수는 약 20만 건입니다.


    문제 확인

    기존의 저희는 많은 양의 데이터를 감당하기 힘들어서 [오전, 오후] 이렇게 두 부분으로 나눠서 혼잡도를 조회했습니다.

    하지만 실제 배포를 하기 위해서 더이상은 오전 오후로 나눌 수가 없었는데요.

    정상적인 데이터를 제공하기 위해서 먼저 24시간 기준으로 혼잡도를 갱신하도록 로직부터 바꾸었습니다.

    위와 같이 코드를 바꾸니 바로 성능에 문제가 생겼습니다. -img

    위의 사진과 같이 slow-query를 분석해보았습니다. -혼잡도 업데이트에도 시간이 걸리는 것을 확인할 수 있지만, 조회 시간은 최악의 경우 약 12분 정도로 사용자들이 볼 수도 없었습니다.

    이런 문제가 발생한 이유는 다음과 같습니다. -먼저 가장 큰 문제는 데이터가 많기 때문이고, 두 번째로는 비효율적인 API로 인한 문제입니다.

    현재 혼잡도 조회시 0~23시까지 모든 요일의 급속과 완속 충전기에 대한 혼잡도를 가져옵니다. -굳이 이럴 필요 없이 선택한 요일에만 혼잡도를 가져온다면 불필요한 조회는 없을 거라고 생각해서 일부분 리팩토링을 하기로 했습니다.

    추가적으로 박스터가 DB Replication을 적용해서, Update로 인한 속도 저하 현상도 많이 줄어들 것을 기대할 수 있었습니다.


    문제 해결 과정

    • 먼저 기존 코드로 조회시에 속도가 얼마나 나오는지 확인을 해보겠습니다. -img -기존의 모든 혼잡도를 들고오는 경우 위와 같이 536ms의 시간이 소모되는 것을 확인할 수 있습니다.

    img -위에 사진과 같이 day_of_week 즉 혼잡도를 확인하고 싶은 요일을 추가적으로 조건에 명시해주니 -148ms로 줄은 것을 확인할 수 있습니다.

    148ms는 아직 한참 느립니다.

    먼저 문제를 해결하기 위해서 DB Partitioning을 적용했습니다.

    DB Partitioning에 대해 간단하게 설명하자면 큰 테이블을 작은 단위로 관리하는 기법입니다. -하나의 테이블로 보이지만 이를 적용하면 실제로 여러 개의 테이블로 분리해서 관리하는 기법이고, 이를 통해서 조회 및 업데이트 쿼리 성능이 개선될 수 있습니다.

    저희 팀은 List partitioning을 적용해서 day_of_week(요일)을 기준으로 파티셔닝을 했습니다. -img -위에 사진과 같이 day_of_week를 기준으로 파티셔닝을 했습니다.

    img -List Partitioning을 적용하고 위에 사진과 같이 조회 쿼리를 다시 날려보면, partitions = p_friday 로 잘 나뉘어진 것을 확인할 수 있습니다.

    파티셔닝 작업이 잘 되었으니 이제 API에서 요일 별 혼잡도 조회로 바꿔보겠습니다. -먼저 쿼리를 변경하고 쿼리를 확인해보니 다음과 같이 나왔습니다.

    img -위와 같은 조회 쿼리가 나왔으므로 인덱스를 아래와 같이 station_id, day_of_week에 걸어주었습니다.

    img -위 실행 속도에서 execution time을 확인해보면 인덱스를 걸고 134ms -> 5ms로 성능이 많이 개선 되었음을 확인할 수 있습니다.

    img -실행 계획도 의도한대로 잘 나오는 것을 보실 수 있습니다.


    정리

    1. DB Partitioning - (day_of_week : 요일)을 기준으로 파티셔닝
    2. 조회 쿼리에 맞게 인덱스 설정
    3. API 수정 (모든 요일의 혼잡도 조회 -> 해당 요일의 혼잡도 조회)

    결과적으로 기존 혼잡도 조회시 511ms가 나왔으나, 요일 별 조회 및 파티셔닝 & 인덱스를 적용하고 execution time = 5ms로 개선

    - - +

    · 약 13분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    이전 글에서도 계속 설명했듯이 조회 성능을 최대한 빠르게 하는 것이 저희 서비스에서 핵심이라고 생각하기 때문에 지금도 예전에 비해 빨라졌지만 다른 개선점이 보여 개선을 하고자합니다.

    조회 성능 개선하기 1 (인덱스)

    조회 성능 개선하기 2 (데이터베이스 복제)

    결론

    결론부터 말씀드리면 로컬에서 캐싱을 적용한 후 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 78 -> 128

    Response Time 1236 ms -> 751 ms

    64% 성능이 개선 되었습니다.

    (저번 성능 테스트의 결과가 다른 이유는 비즈니스 로직이 변경되어 조회 방식이 바뀌었기 때문입니다. 그래서 캐싱을 적용하기전, 한 후 를 비교했습니다.)

    Caching

    In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.

    캐싱은 위키 백과에서 위와 같이 설명하고 있습니다. 즉 메모리에 데이터를 복사본을 올려 좀 더 빠르게 데이터에 접근하는 방식입니다.

    캐싱의 단점은 수정, 삽입, 삭제가 되었을 때, 관리 포인트가 두 군데가 된다는 점입니다. 만약 데이터베이스에만 새로운 정보를 저장하고, 캐시에는 저장해주지 않는다면 사용자는 그 정보를 볼 수 없습니다.

    하지만 저희 서비스에서 적용한 이유는 충전기의 충전 상태 (충전 중, 대기중, 고장)에 대한 정보는 최신화가 되어야하지만, 충전소의 이름이라던지, 위치, 다른 정보들은 쉽게 변하지 않기 때문에 해당 정보를 캐싱한다면 좋을 것 같았습니다.

    캐싱 적용하기

    먼저 캐싱을 어디에서 하는지도 중요합니다. 크게 로컬 캐시글로벌 캐시로 나눌 수 있을 것 같습니다. +글로벌 캐시의 장점은 스케일 아웃을 했을 때, 모든 서버가 다 같은 데이터를 바라보기 때문에 데이터 정합성이 좋아집니다. 하지만 저희 서비스는 단일 서버로 구성되어 있기 때문에, 로컬 캐시를 해도 문제가 없습니다. 그리고 글로벌 캐시를 적용하기 위해서는 Redis나 Memcached 같은 도구를 모든 팀원이 알아야하지만 로컬 캐시는 그렇게 하지 않더라도 편하게 적용할 수 있다는 점에서 로컬에 캐싱하는 방법을 적용해보겠습니다.

    캐싱할 정보 가져오기

    캐싱을 하기 위해서는 먼저 캐싱할 데이터를 가져와야합니다. 저희 서비스는 출장 혹은 여행을 가는 전기차 오너가 핵심 페르소나이기 때문에 사용자들이 찾는 정보의 위치는 불특정합니다. 서울에서 다른 지방으로 출장을 가는 경우도 있을 것이고, 지방에서 서울에 가는 경우도 있기 때문에, 모든 데이터를 캐싱해야할 것이라 판단했습니다.

    그래서 어플리케이션 실행 시에 모든 충전소를 캐싱하기로 선택했습니다.

    @Configuration
    public class InitialStationCache implements ApplicationRunner {

    private final StationCacheRepository stationCacheRepository;
    private final StationQueryRepository stationQueryRepository;

    @Override
    public void run(ApplicationArguments args) {
    log.info("Initialize station cache");
    List<StationInfo> stations = stationQueryRepository.findAll();
    stationCacheRepository.initialize(stations);
    log.info("Station cache initialized");
    log.info("Station cache size: {}", stations.size());
    }
    }

    위와 같이 ApplicationRunner를 구현하여 어플리케이션 실행 시 모든 충전소의 정보를 가져오도록 만들었습니다. +여기서 Entity인 Station을 가져오지 않은 이유는 크게 두가지가 있습니다.

    1. 지도로 조회하는 부분의 성능을 개선하고자 했지만, Entity에는 지도를 조회할 때 불필요한 정보도 있기 때문에 메모리상의 낭비가 생길 수 있습니다.
    2. Entity를 캐싱하게 된다면 hibernate 1차 캐시에도 적재되고, 힙 메모리에도 적재되는 일이 발생하여 메모리상 낭비라고 생각했습니다.

    범위 검색하기

    충전소의 데이터를 조회하는 조건은 위도, 경도의 최소, 최대값을 기준으로 만족하는 데이터를 보여줍니다. +아래와 같이 간단히 조건을 stream()의 filter()를 사용해서 구현했습니다.

    public class StationCacheRepository {

    private final List<StationInfo> cachedStations;

    public List<StationInfo> findByCoordinate(
    BigDecimal minLatitude,
    BigDecimal maxLatitude,
    BigDecimal minLongitude,
    BigDecimal maxLongitude
    ) {
    return cachedStations.stream()
    .filter(it -> it.latitude().compareTo(minLatitude) >= 0 && it.latitude().compareTo(maxLatitude) <= 0)
    .filter(it -> it.longitude().compareTo(minLongitude) >= 0 && it.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }
    }

    하지만 해당 방법으로 로컬에서 조회를 테스트 했을 때 캐시를 적용한 것보다 더 느려진 결과가 나왔습니다. +캐싱을 해서 데이터베이스까지 요청을 보내지 않는데 왜 더 느려진 것일까요?

    답은 인덱스 였습니다. Mysql 에서 인덱스는 B Tree로 구성되어 있습니다. 데이터베이스에서는 위도, 경도로 복합 인덱스가 설정되어 있었지만, 현재 어플리케이션 로직에는 해당 부분이 없습니다.

    그래서 filter로 순회하는 시간복잡도가 O(n)이고, 데이터베이스에서는 O(log n)이기 때문에 더 느려진 것입니다. 그렇다고 제가 직접 B tree 자료구조를 직접 구현해야할까요?

    현재 해당 조회 API는 위도 경도로 범위 탐색을 하고 있습니다. 결국엔 station의 정보들이 위도, 경도로 정렬만 되어 있다면 B tree를 직접 구현하지 않더라도 같은 시간복잡도 O(log n)으로 탐색할 수 있습니다. +물론 B tree와 다른 부분은 해당 충전소의 정확한 위도, 경도로 단일 칼럼을 조회할 때는 O(n)이기 때문에 이런 방법이 문제가 될 수 있지만, 해당 캐시 데이터로는 무조건 범위 탐색을 하기 때문에, B tree를 구현하지 않고 이분 탐색으로 조회하는 방식으로 변경해보겠습니다.

        public void initialize(List<StationInfo> stations) {
    cachedStations.addAll(stations);
    cachedStations.sort((o1, o2) -> {
    int latitudeCompare = o1.latitude().compareTo(o2.latitude());
    if (latitudeCompare == 0) {
    return o1.longitude().compareTo(o2.longitude());
    }
    return latitudeCompare;
    });
    }

    private List<StationInfo> findStations(BigDecimal minLatitude, BigDecimal maxLatitude, BigDecimal minLongitude, BigDecimal maxLongitude) {
    int lowerBound = binarySearch(minLatitude, START_INDEX);
    int upperBound = binarySearch(maxLatitude, lowerBound);
    if (lowerBound == -1 || upperBound == -1) {
    return Collections.emptyList();
    }
    return cachedStations.stream()
    .skip(lowerBound)
    .limit(upperBound - lowerBound)
    .filter(station -> station.longitude().compareTo(minLongitude) >= 0 && station.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }

    private int binarySearch(BigDecimal latitude, int startIndex) {
    int left = startIndex;
    int right = cachedStations.size() - 1;
    int result = -1;
    while (left <= right) {
    int middle = left + (right - left) / 2;
    StationInfo middleStation = cachedStations.get(middle);
    if (middleStation.latitude().compareTo(latitude) >= 0) {
    result = middle;
    right = middle - 1;
    } else {
    left = middle + 1;
    }
    }
    return result;
    }

    먼저 어플리케이션이 실행될 때 cache 데이터를 찾아 저장하는 것 뿐만 아니라, 위도(Latitude)를 기준으로 정렬하도록 만들었습니다. 그리고 위도의 최소, 최대값의 인덱스를 가장 효율적으로 찾아올 수 있도록 binary search를 하는 메서드를 만들었습니다. 이렇게 한다면 O(log n) 으로 위도의 최대 최소 조건에 포함되는 모든 station의 값을 조회할 수 있습니다. 그리고 조회한 데이터들의 개수만큼 filter를 통해 경도(longitude) 가 포함되는지 확인합니다. 해당 방식의 구현은 B tree가 작동하는 방식과 유사할 것입니다.

    이분 탐색을 적용한 결과 로컬에서 응답 속도가 120 ms -> 50 ~ 70 ms로 약 2배 빨라진 것을 확인할 수 있습니다.

    실시간이 중요한 데이터는?

    앞서 말씀드렸다시피 지도로 충전소를 조회할 때, 충전소의 정보들에는 바뀌지 않는 정보뿐만 아니라, 최신화해야하는 충전기의 현재 상태 정보가 있습니다. 이러한 정보들은 캐싱해둘 수 없습니다. 하더라도, 관리 포인트가 늘어나기 때문에 데이터베이스에서 캐싱해둔 충전기 id로 충전기의 상태를 찾아와서 정보를 합쳐 반환하는 식으로 만들 수 있습니다.

        select cs.station_id,
    sum(case
    when cs.charger_condition = 'STANDBY' then 1
    else 0
    end)
    from charger_status cs
    where cs.station_id in (?, ?, ?, ?, ?, ?, ?)
    group by cs.station_id

    위와 같은 쿼리로 해당 충전소의 최신화된 충전기 상태를 가져올 수 있습니다.

    캐싱을 하기전에 데이터베이스를 이용해 데이터를 가져올 때의 쿼리는 아래와 같습니다.

     select
    distinct s.station_id
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    where
    s.latitude>=?
    and s.latitude<=?
    and s.longitude>=?
    and s.longitude<=?
    -------------------------------------------------
    select
    s.station_id,
    s.station_name,
    s.latitude,
    s.longitude,
    s.is_parking_free,
    s.is_private,
    sum(case
    when cs.charger_condition='STANDBY' then 1
    else 0
    end),
    sum(case
    when c.capacity>=50 then 1
    else 0
    end)
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    inner join
    charger_status cs
    on (
    c.station_id=cs.station_id
    and c.charger_id=cs.charger_id
    )
    where
    s.station_id in (
    ?,?,?,?
    )
    group by
    s.station_id

    원래는 위와 같이 여러번의 Join을 하고, 2번의 쿼리가 나갔던 반면 지금은 join을 하지않는 한번의 깔끔한 쿼리로 개선되었습니다.

    그리고 station 테이블의 위도, 경도로 범위 탐색을 위해 생성했던 index도 제거할 수 있게 되었습니다!

    결론

    1. 캐싱할 수 있는 부분은 하는 것도 좋을 것 같습니다
    2. 시간 복잡도를 계산해봅시다.
    3. 성능 개선 재밌습니다.
    + + \ No newline at end of file diff --git a/page/9.html b/page/9.html index 712b4c3b..79886ff7 100644 --- a/page/9.html +++ b/page/9.html @@ -5,24 +5,32 @@ Blog | CAR-FFEINE - - + +
    -

    · 약 24분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 지난 글에서 설명했듯이 저희 프로젝트에서는 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. -이 부분에 대해서는 조회 성능을 높혀 어느정도 해결하고자 했습니다. 하지만 조회가 아닌 많은 데이터를 일정한 주기로 업데이트 해줘야하는 로직도 포함되어 있기 때문에 업데이트를 할 때 조회를 하게 된다면 cpu 사용률은 비슷할 것입니다. 이 부분을 해결하고자 데이터베이스 레플리케이션을 알아보겠습니다.

    결론

    결론부터 말씀드리면 데이터베이스 레플리케이션을 적용한 후 성능이 눈에 띄게 좋아졌습니다. 해당 부분은 다음 포스팅에 작성하겠습니다 -100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 179 -> 366

    Response Time 550 ms -> 271 ms

    약 2배 가량 성능이 향상된 것을 볼 수 있습니다.

    데이터베이스 레플리케이션이란?

    데이터베이스 레플리케이션이란 하나의 데이터베이스에서 다른 하나 이상의 데이터베이스로 데이터의 복제 또는 복사를 수행하는 프로세스 또는 기술입니다. 데이터베이스 레플리케이션은 주로 다음과 같은 목적으로 사용됩니다

    1. 고가용성: -데이터베이스 서버의 장애가 발생했을 때, 레플리카 데이터베이스를 사용하여 시스템을 계속 운영할 수 있습니다. 이렇게 하면 서비스 중단 시간을 최소화하고 비즈니스 연속성을 유지할 수 있습니다.

    2. 성능 향상 : -레플리케이션을 사용하면 읽기 작업을 분산시킬 수 있으므로 데이터베이스 서버의 읽기 부하를 줄일 수 있습니다. 이를 통해 데이터베이스 성능을 향상시킬 수 있습니다.

    3. 지역적 분산 : -데이터베이스 레플리케이션을 통해 데이터를 지리적으로 분산시킬 수 있습니다. 이렇게 하면 지역적인 사용자 또는 응용 프로그램에 빠르게 데이터를 제공할 수 있으며, 지역적인 규정 준수 요구사항을 충족시킬 수 있습니다.

    4. 백업과 복구 : -레플리케이션을 사용하여 주 데이터베이스의 백업을 생성하고, 이를 사용하여 장애 복구를 수행할 수 있습니다. 주 데이터베이스가 손상되었을 때 백업 데이터베이스를 사용하여 시스템을 빠르게 복원할 수 있습니다.

    5. 데이터 분석 및 보고 : -레플리케이션을 사용하여 데이터를 다른 분석 또는 보고 도구로 복사하여 데이터 웨어하우스 또는 분석 시스템에서 사용할 수 있습니다.

    저희 팀에서 레플리케이션을 적용한 가장 큰 이유는 성능 향상입니다. 아무래도 저희 서비스에서는 읽기 작업과 쓰기 작업이 둘 다 빈번하게 일어나고, 특히 쓰기 작업에 많은 연산이 필요합니다. 사용자에게 최신의 데이터를 제공하고자 쓰기 작업을 자주하여 데이터를 최신화하더라도, 읽기 작업이 느려지면 아무도 사용하지 않을 것입니다. 하지만 이렇게 서버를 여러 대 두어 하나의 데이터베이스 서버가 받는 부하를 분산시킨다면 성능이 향상 될 것입니다.

    그리고 두번째로는 고가용성입니다. 현재 저희의 데이터베이스는 하나의 서버로 SPOF 문제가 있습니다. 하지만 레플리케이션을 적용하여 데이터베이스를 분산한다면 하나의 데이터베이스가 장애가 생겨 중지가 되더라도, 다른 서버의 데이터베이스로 서비스를 이어나갈 수 있습니다.

    데이터베이스 복제 방식

    데이터베이스 복제 방식은 크게 두가지가 있습니다. Binary Log로 복제하는 방식GTID(Global Transaction Id)를 통해 복제를 하는 방식이 있습니다.

    Binary log 복제 방식

    먼저 Binary Log 는 데이터베이스에서 수행한 쿼리 (사용자 추가, 인덱스 추가, Update, Insert, Delete 등 ) 모든 정보를 Binary Log에 기록합니다. 그리고 해당 바이너리 로그에는 이벤트마다 Mysql 서버의 고유한 Server id를 가지고 있는데, 해당 Id가 같은 서버에서는 해당 이벤트를 자신이 발생시킨 이벤트로 간주하고 적용하지 않습니다. 그러므로 각각의 고유한 server id를 설정해줘야 합니다. -이 바이너리 로그 파일의 위치와 바이너리 로그 파일명을 통해 Replica 서버는 Source 서버의 이벤트를 적용합니다

    GTID 복제 방식

    Mysql 5.5 버전 이상부터는 GTID 기반 복제도 가능하게 추가되었습니다 GTID는 source id와 transaction id가 조합된 방식으로 생성됩니다. source id는 트랜잭션이 발생한 소스 서버를 식별하기 위한 값으로 server의 uuid 입니다.

    +--------------------------------------+
    | source_uuid |
    +--------------------------------------+
    | c3a2296b-31a2-11ee-b887-02a8cf0173ac |
    +--------------------------------------+

    이러한 GTID를 기반으로 Source 서버를 구분하고 Binary Log 파일에 기록된 GTID를 확인하여 마지막에 적용한 이벤트를 확인하고, 적용하지 않은 이벤트를 순차대로 실행시켜 복제할 수 있습니다.

    이 두가지 방법 중 저희는 GTID 방식의 복제를 선택했습니다. 이유는 간단합니다.

    이런 방식으로 토폴로지를 구성했다고 가정해보겠습니다. Source 서버에서는 Binary Log 10번 파일까지 이벤트가 발생했습니다. 그리고 Replica1 에서는 Source 서버의 이벤트가 최신화 되어 있지만, Replica2 서버는 아직 최신화가 되지 않은 상황입니다. 이 상황에서 Source Server에 장애가 발생하여 서버가 중단 되었습니다.

    그러면 Replica1 서버를 Source 서버로 승격합니다. 이렇게 된다면 Replica1 서버에서 모든 쿼리의 요청이 들어오게 됩니다. BinaryLog10이라는 파일의 위치와 파일을 찾을 방법이 없기 때문에 Source서버가 복구되지 않는 이상 혹은 Replica 1 서버의 Relay Log가 남아있지 않는 이상 Replica2 서버는 절대 최신화될 수 없습니다. 이런 식의 방식이라면 Source 서버가 중단되었을 때 다른 서버가 동작하기 때문에 고가용성 문제는 해결된 것 같지만, Replica2 서버는 아무 일도 하지않고 남아있는 서버, 즉 Source 서버 하나가 중단되었으나 2대의 서버가 중단된 것과 마찬가지입니다.

    이러한 문제를 해결하기 위해 GTID가 등장했습니다. GTID 방식은 Binary Log의 위치와 파일명이 필요한 것이 아닌 다음 이벤트의 GTID만 있다면 해당 이벤트를 바로 적용할 수 있다는 점입니다. Source 서버로 승격된 Replica1 서버에서 GTID를 받아 적용하여 최신화할 수 있습니다.

    저희 팀의 복제 방식

    이러한 장점으로 GTID기반 복제 방식을 사용하였습니다.

    복제 동기화 방식

    복제 방식에는 크게 두가지가 있습니다. 비동기 복제반동기 복제입니다.

    비동기 복제

    비동기 복제는 말그대로 비동기로 복제하는 것입니다. 아주 간단합니다. Source 서버에서 어떠한 이벤트가 발생할 때 Replica 서버의 반영과 상관없이 동작하는 것입니다. -소스 서버에서 커밋된 트랜잭션은 바이너리 로그에 기록되고, 레플리카 서버에서는 주기적으로 새로운 트랜잭션에 대한 바이너리 로그를 요청합니다. 이러한 방식은 소스 서버는 레플리카 서버가 제대로 변경 되었는지 알 수 없습니다. 즉 데이터 정합성에 문제가 생긴다는 단점이 있습니다. 하지만 이러한 방식은 소스 서버가 각 트랜잭션에 대해 레플리카 서버로 전송되는 부분을 고려하지 않는다는 점이 속도 측면에서 빠르고, 또 여러 대의 레플리카 서버를 구성하더라도 큰 성능 저하가 없다는 점이서 장점이 있습니다.

    반동기 복제

    반동기 복제는 비동기 복제보다 좀 더 데이터 정합성이 올라갑니다. 소스 서버는 변경된 트랜잭션이 있을 때 레플리카 서버가 다 전송이 되었다는 ACK 신호를 받기 때문에 확실히 알 수 있습니다. 하지만 전송여부만 확인하기 때문에 트랜잭션이 반영이 되었다는 보장은 없습니다. 반동기 복제 방식은 2가지가 있습니다.

    1. After sync: After Sync 방식은 소스 서버에서 트랜잭션을 바이너리 로그에 기록 후 Storage Engine에 바로 커밋하지 않습니다. 먼저 바이너리 로그에 기록 후 레플리카 서버의 ACK 응답을 기다립니다. 그리고 ACK 응답이 도착하면 그제서야 스토리지 엔진을 커밋하여 트랜잭션을 처리하고 결과를 반환합니다.
    2. After commit: After commit은 이름 그대로 커밋을 먼저 하는 것입니다. 트랜잭션이 생기면 먼저 바이너리 로그에 기록 후 소스 서버 스토리지 엔진에 커밋합니다. 그리고 레플리카 서버의 ACK 응답이 내려오면 클라이언트는 처리 결과를 얻고 다음 쿼리를 수행할 수 있습니다.

    먼저 after commit 방식은 소스 서버에 장애가 발생했을 때 팬텀 리드가 발생하게 됩니다. 트랜잭션이 스토리지 엔진 커밋까지된 후 레플리카 서버의 응답을 기다립니다. 이처럼 스토리지 엔진 커밋까지 완료된 데이터는 다른 세션에서도 조회가 가능합니다. 트랜잭션이 커밋되었고, 레플리카 서버로 아직 응답을 기다릴 때, 소스 서버에 장애가 발생한다면 새로운 소스 서버로 승격된 레플리카 서버에서 데이터를 조회할 때 자신이 이전 소스 서버에서 조회했던 데이터를 보지 못할 수도 있습니다.

    그리고 이처럼 레플리카 서버가 승격된 상황에 소스 서버의 장애가 복구되어 재사용할 경우 이미 커밋된 그 트랜잭션을 수동으로 롤백 시켜야만 데이터가 맞는 상황이 생깁니다.

    저희 팀의 복제 동기화 방식

    이러한 장단점으로 저희 팀은 데이터 무결성이 중요하다 판단되어 반동기 복제 방식을 사용하고, After Sync 방식을 적용하였습니다.

    복제 토폴리지

    복제 토폴리지는 여러가지 방식 중 자신의 상황과 가장 맞는 방식을 사용하면 될 것 같습니다. 저희 팀이 고려해야할 문제는 먼저 성능을 올려야 했고, 단일 장애포인트를 개선해야했습니다. 하지만 사용할 수 있는 서버는 2대 뿐이였습니다. 이러한 상황에서 어떤 방식을 택할 수 있을까요?

    싱글 레플리카

    가장 기본적이며 가장 많이 쓰이는 형태입니다. 어플리케이션에서 레플리카 서버에 읽기 요청을 전달하면, 레플리카 서버에 문제가 생겼을 때, 서비스 장애 상황이 발생할 수 있습니다. 그러므로 소스 서버에서 Read, Write를 둘 다 하고, 레플리카 서버는 failover를 위해 대기하는 예비용 서버로 구성합니다. -소스 서버에 장애가 발생했을 때 소스 서버를 대체하거나 데이터를 백업하는 용도로 사용합니다.

    멀티 레플리카

    싱글 레플리카와 비슷한 구성이지만 레플리카 서버가 한 대 더 추가된 구성입니다. 해당 방식은 SPOF 문제가 없기 때문에 레플리카 서버 하나를 읽기 전용 서버로 둘 수 있습니다. 읽기 작업을 분산함으로 어플리케이션의 성능을 향상 시킬 수 있습니다. 아까 말했던 장애 상황이 발생하면 예비용 서버인 Replica2 서버를 Source 서버 혹은 Replica1(읽기 전용) 서버로 대체할 수 있습니다.

    체인 복제

    레플리카 서버가 많아져 소스 서버의 바이너리 로그를 읽는 부하가 많아질 때 할 수 있는 구성입니다. 좀 전에 설명드렸던 멀티 레플리카 방식에서 똑같은 구성을 추가한 방식으로 볼 수 있습니다. Source 1 의 정보를 복제한 Replica 1-1, 1-2 서버는 빠르게 데이터가 반영되지만, Source1의 이벤트를 복제한 Source2를 복제한 Replica 2-1, 2-2 서버는 당연히 늦게 반영되기 때문에 해당 그룹은 예비용으로 사용합니다.

    듀얼 소스 복제

    데이터베이스 둘 다 소스 서버이면서 레플리카 서버인 경우입니다. 이 경우는 Active-Active구성과 Active-Passive 구성으로 나뉩니다

    Active-Active는 서버 둘 다 읽기와 쓰기가 가능한 형태입니다. 즉 부하를 분산시키기 위해 서버 모두 읽고 쓰는 작업을 하는 것입니다. 하지만 이러한 방식은 뻔한 단점이 있습니다. 서로의 이벤트가 동기화 되기 전에는 정합성이 깨질 수 있습니다. 또 동시에 같은 데이터에 대해 쓰기 작업을 수행할 때, 하나의 서버에서 쓰기가 완료되었더라도, 다른 하나의 서버에 늦게 끝난 쓰기가 있다면 마지막 트랜잭션인 늦게 끝난 쓰기 작업이 반영되어 예상하지 못한 결과가 나올 수 있습니다.

    또 다른 문제로는 Auto Increment를 사용할 때입니다. 새로운 데이터가 동시에 생성될 때 Auto Increment가 중복되는 에러가 발생할 수 있기 때문에 해당 토폴로지에서는 ID를 DB에 의존하지 않는 것이 좋습니다.

    Active-Passive 방식은 하나의 서버만 읽기와 쓰기 요청이 되지만, 나머지 서버는 대기하고 있습니다. 두 서버 모두 언제나 쓰기 작업이 가능한 형태이기 때문에 장애 발생 시 빠르게 Faliover할 수 있다는 점이 있습니다.

    멀티 소스 복제

    하나의 레플리카 서버가 다수의 소스 서버를 갖는 구성입니다. 데이터베이스 샤딩을 해뒀는데, 다시 하나의 서버로 통합하고 싶을 때 사용할 수 있습니다. 혹은 서로 다른 데이터를 한 곳에 백업을 할 때도 사용할 수 있습니다.

    저희 팀의 토폴로지 방식

    그럼 이렇게나 많은 구성 중에 저희 팀에서 택할 수 있는 토폴로지 방식은 싱글 레플리카 방식과 듀얼 소스 복제 방식 밖에 없습니다. 왜냐하면 주어진 서버가 2대뿐이기 때문입니다. -하지만 듀얼 소스 방식은 적용하는데 무리가 있는 부분이 있습니다. 일단 저희가 레플리케이션을 적용하려는 가장 큰 이유는 성능 이기 때문에 성능이 변하지 않는 듀얼 소스의 Active-Passive 방식은 제외하겠습니다. 그리고 Active-Active 방식은 부하를 분산시킬 수 있다는 장점이 있지만, 단점으로는 Auto Increment를 사용하는데에 위험이 있다는 점과, 데이터의 정합성 문제가 생길 수 있다는 점에서 듀얼 소스 방식은 제외하도록 했습니다.

    그럼 싱글 레플리카 방식을 적용할 수 밖에 없는데요. 싱글 레플리카의 방식은 가용성 문제를 해결하기 위해 만들어진 방식입니다. 하지만 저희 서비스는 현재 가용성보다 성능을 더 신경써야하는 상황이기때문에 싱글 레플리카 토폴로지를 구성하지만 레플리카 서버를 예비용이 아닌 읽기 전용 방식으로 사용하도록 하고, 가용성 부분을 포기하기로 정했습니다.

    코드에 적용하기

    replication-datasource Github 소스 코드를 참고하시거나, DB 복제, @Transactional에 따라 요청 분리해보기 글을 참고하여 따라하면 금방하실 수 있습니다!

    결론

    데이터베이스 레플리케이션 생각보다 어렵지 않습니다.

    데이터베이스 재밌습니다. 인프라도 재밌습니다.

    참고

    Real Mysql 8.0

    - - +

    · 약 7분
    제이

    안녕하세요. +카페인 팀의 제이입니다.

    저희 서비스에서는 충전소의 요일과 시간대 별로 충전소 혼잡도 정보를 제공을 차별적인 기능으로 제공하고 있습니다.

    이를 구현하기 위해서 공공 데이터에서 정보를 수집하고있습니다. +혼잡도를 조회하기 위해서는 약 23만 건의 충전소 7일 24시간 = 약 4000만 건의 데이터 중에서 조회를 하는 형식으로 되어있습니다.

    너무 많은 데이터가 있다보니 조회 속도가 많이 느린데요. +오늘은 이를 어떻게 개선했는지 작성해보도록 하겠습니다.

    참고로 해당 글의 성능 측정에 이용한 데이터의 수는 약 20만 건입니다.


    문제 확인

    기존의 저희는 많은 양의 데이터를 감당하기 힘들어서 [오전, 오후] 이렇게 두 부분으로 나눠서 혼잡도를 조회했습니다.

    하지만 실제 배포를 하기 위해서 더이상은 오전 오후로 나눌 수가 없었는데요.

    정상적인 데이터를 제공하기 위해서 먼저 24시간 기준으로 혼잡도를 갱신하도록 로직부터 바꾸었습니다.

    위와 같이 코드를 바꾸니 바로 성능에 문제가 생겼습니다. +img

    위의 사진과 같이 slow-query를 분석해보았습니다. +혼잡도 업데이트에도 시간이 걸리는 것을 확인할 수 있지만, 조회 시간은 최악의 경우 약 12분 정도로 사용자들이 볼 수도 없었습니다.

    이런 문제가 발생한 이유는 다음과 같습니다. +먼저 가장 큰 문제는 데이터가 많기 때문이고, 두 번째로는 비효율적인 API로 인한 문제입니다.

    현재 혼잡도 조회시 0~23시까지 모든 요일의 급속과 완속 충전기에 대한 혼잡도를 가져옵니다. +굳이 이럴 필요 없이 선택한 요일에만 혼잡도를 가져온다면 불필요한 조회는 없을 거라고 생각해서 일부분 리팩토링을 하기로 했습니다.

    추가적으로 박스터가 DB Replication을 적용해서, Update로 인한 속도 저하 현상도 많이 줄어들 것을 기대할 수 있었습니다.


    문제 해결 과정

    • 먼저 기존 코드로 조회시에 속도가 얼마나 나오는지 확인을 해보겠습니다. +img +기존의 모든 혼잡도를 들고오는 경우 위와 같이 536ms의 시간이 소모되는 것을 확인할 수 있습니다.

    img +위에 사진과 같이 day_of_week 즉 혼잡도를 확인하고 싶은 요일을 추가적으로 조건에 명시해주니 +148ms로 줄은 것을 확인할 수 있습니다.

    148ms는 아직 한참 느립니다.

    먼저 문제를 해결하기 위해서 DB Partitioning을 적용했습니다.

    DB Partitioning에 대해 간단하게 설명하자면 큰 테이블을 작은 단위로 관리하는 기법입니다. +하나의 테이블로 보이지만 이를 적용하면 실제로 여러 개의 테이블로 분리해서 관리하는 기법이고, 이를 통해서 조회 및 업데이트 쿼리 성능이 개선될 수 있습니다.

    저희 팀은 List partitioning을 적용해서 day_of_week(요일)을 기준으로 파티셔닝을 했습니다. +img +위에 사진과 같이 day_of_week를 기준으로 파티셔닝을 했습니다.

    img +List Partitioning을 적용하고 위에 사진과 같이 조회 쿼리를 다시 날려보면, partitions = p_friday 로 잘 나뉘어진 것을 확인할 수 있습니다.

    파티셔닝 작업이 잘 되었으니 이제 API에서 요일 별 혼잡도 조회로 바꿔보겠습니다. +먼저 쿼리를 변경하고 쿼리를 확인해보니 다음과 같이 나왔습니다.

    img +위와 같은 조회 쿼리가 나왔으므로 인덱스를 아래와 같이 station_id, day_of_week에 걸어주었습니다.

    img +위 실행 속도에서 execution time을 확인해보면 인덱스를 걸고 134ms -> 5ms로 성능이 많이 개선 되었음을 확인할 수 있습니다.

    img +실행 계획도 의도한대로 잘 나오는 것을 보실 수 있습니다.


    정리

    1. DB Partitioning - (day_of_week : 요일)을 기준으로 파티셔닝
    2. 조회 쿼리에 맞게 인덱스 설정
    3. API 수정 (모든 요일의 혼잡도 조회 -> 해당 요일의 혼잡도 조회)

    결과적으로 기존 혼잡도 조회시 511ms가 나왔으나, 요일 별 조회 및 파티셔닝 & 인덱스를 적용하고 execution time = 5ms로 개선

    + + \ No newline at end of file diff --git a/rss.xml b/rss.xml index 144de58e..8eaa04bc 100644 --- a/rss.xml +++ b/rss.xml @@ -4,10 +4,26 @@ CAR-FFEINE Blog https://car-ffeine.github.io/ CAR-FFEINE Blog - Sun, 15 Oct 2023 00:00:00 GMT + Wed, 18 Oct 2023 00:00:00 GMT https://validator.w3.org/feed/docs/rss2.html https://github.com/jpmonette/feed ko + + <![CDATA[카페인 팀의 무중단 배포]]> + https://car-ffeine.github.io/41 + https://car-ffeine.github.io/41 + Wed, 18 Oct 2023 00:00:00 GMT + + 안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    ]]>
    + infra + ec2 + cd + aws + zero-time + blue-green +
    <![CDATA[카페인 서비스와 함께하는 전기차 여행 2]]> https://car-ffeine.github.io/40 diff --git a/sitemap.xml b/sitemap.xml index 5876a3bc..ddd66213 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -1 +1 @@ -https://car-ffeine.github.io/1weekly0.5https://car-ffeine.github.io/10weekly0.5https://car-ffeine.github.io/11weekly0.5https://car-ffeine.github.io/12weekly0.5https://car-ffeine.github.io/13weekly0.5https://car-ffeine.github.io/14weekly0.5https://car-ffeine.github.io/15weekly0.5https://car-ffeine.github.io/16weekly0.5https://car-ffeine.github.io/17weekly0.5https://car-ffeine.github.io/18weekly0.5https://car-ffeine.github.io/19weekly0.5https://car-ffeine.github.io/2weekly0.5https://car-ffeine.github.io/20weekly0.5https://car-ffeine.github.io/21weekly0.5https://car-ffeine.github.io/22weekly0.5https://car-ffeine.github.io/23weekly0.5https://car-ffeine.github.io/24weekly0.5https://car-ffeine.github.io/25weekly0.5https://car-ffeine.github.io/26weekly0.5https://car-ffeine.github.io/27weekly0.5https://car-ffeine.github.io/28weekly0.5https://car-ffeine.github.io/29weekly0.5https://car-ffeine.github.io/3weekly0.5https://car-ffeine.github.io/30weekly0.5https://car-ffeine.github.io/31weekly0.5https://car-ffeine.github.io/32weekly0.5https://car-ffeine.github.io/33weekly0.5https://car-ffeine.github.io/34weekly0.5https://car-ffeine.github.io/35weekly0.5https://car-ffeine.github.io/36weekly0.5https://car-ffeine.github.io/37weekly0.5https://car-ffeine.github.io/38weekly0.5https://car-ffeine.github.io/39weekly0.5https://car-ffeine.github.io/4weekly0.5https://car-ffeine.github.io/40weekly0.5https://car-ffeine.github.io/5weekly0.5https://car-ffeine.github.io/6weekly0.5https://car-ffeine.github.io/7weekly0.5https://car-ffeine.github.io/8weekly0.5https://car-ffeine.github.io/9weekly0.5https://car-ffeine.github.io/archiveweekly0.5https://car-ffeine.github.io/markdown-pageweekly0.5https://car-ffeine.github.io/page/10weekly0.5https://car-ffeine.github.io/page/11weekly0.5https://car-ffeine.github.io/page/12weekly0.5https://car-ffeine.github.io/page/13weekly0.5https://car-ffeine.github.io/page/14weekly0.5https://car-ffeine.github.io/page/15weekly0.5https://car-ffeine.github.io/page/16weekly0.5https://car-ffeine.github.io/page/17weekly0.5https://car-ffeine.github.io/page/18weekly0.5https://car-ffeine.github.io/page/19weekly0.5https://car-ffeine.github.io/page/2weekly0.5https://car-ffeine.github.io/page/20weekly0.5https://car-ffeine.github.io/page/21weekly0.5https://car-ffeine.github.io/page/22weekly0.5https://car-ffeine.github.io/page/23weekly0.5https://car-ffeine.github.io/page/24weekly0.5https://car-ffeine.github.io/page/25weekly0.5https://car-ffeine.github.io/page/26weekly0.5https://car-ffeine.github.io/page/27weekly0.5https://car-ffeine.github.io/page/28weekly0.5https://car-ffeine.github.io/page/29weekly0.5https://car-ffeine.github.io/page/3weekly0.5https://car-ffeine.github.io/page/30weekly0.5https://car-ffeine.github.io/page/31weekly0.5https://car-ffeine.github.io/page/32weekly0.5https://car-ffeine.github.io/page/33weekly0.5https://car-ffeine.github.io/page/34weekly0.5https://car-ffeine.github.io/page/35weekly0.5https://car-ffeine.github.io/page/36weekly0.5https://car-ffeine.github.io/page/37weekly0.5https://car-ffeine.github.io/page/38weekly0.5https://car-ffeine.github.io/page/39weekly0.5https://car-ffeine.github.io/page/4weekly0.5https://car-ffeine.github.io/page/40weekly0.5https://car-ffeine.github.io/page/5weekly0.5https://car-ffeine.github.io/page/6weekly0.5https://car-ffeine.github.io/page/7weekly0.5https://car-ffeine.github.io/page/8weekly0.5https://car-ffeine.github.io/page/9weekly0.5https://car-ffeine.github.io/tagsweekly0.5https://car-ffeine.github.io/tags/actionweekly0.5https://car-ffeine.github.io/tags/action/page/2weekly0.5https://car-ffeine.github.io/tags/autoweekly0.5https://car-ffeine.github.io/tags/awsweekly0.5https://car-ffeine.github.io/tags/aws/page/2weekly0.5https://car-ffeine.github.io/tags/aws/page/3weekly0.5https://car-ffeine.github.io/tags/branchweekly0.5https://car-ffeine.github.io/tags/cdweekly0.5https://car-ffeine.github.io/tags/cd/page/2weekly0.5https://car-ffeine.github.io/tags/ciweekly0.5https://car-ffeine.github.io/tags/ci/page/2weekly0.5https://car-ffeine.github.io/tags/commitweekly0.5https://car-ffeine.github.io/tags/cssweekly0.5https://car-ffeine.github.io/tags/css-in-jsweekly0.5https://car-ffeine.github.io/tags/dbweekly0.5https://car-ffeine.github.io/tags/deadlockweekly0.5https://car-ffeine.github.io/tags/devweekly0.5https://car-ffeine.github.io/tags/ec-2weekly0.5https://car-ffeine.github.io/tags/ec-2/page/2weekly0.5https://car-ffeine.github.io/tags/ec-2/page/3weekly0.5https://car-ffeine.github.io/tags/errorweekly0.5https://car-ffeine.github.io/tags/filterweekly0.5https://car-ffeine.github.io/tags/ga-4weekly0.5https://car-ffeine.github.io/tags/gcweekly0.5https://car-ffeine.github.io/tags/gitweekly0.5https://car-ffeine.github.io/tags/git-branchweekly0.5https://car-ffeine.github.io/tags/git-flowweekly0.5https://car-ffeine.github.io/tags/git/page/2weekly0.5https://car-ffeine.github.io/tags/githubweekly0.5https://car-ffeine.github.io/tags/github-flowweekly0.5https://car-ffeine.github.io/tags/github/page/2weekly0.5https://car-ffeine.github.io/tags/gitlab-flowweekly0.5https://car-ffeine.github.io/tags/google-analytics-4weekly0.5https://car-ffeine.github.io/tags/google-mapweekly0.5https://car-ffeine.github.io/tags/google-mapsweekly0.5https://car-ffeine.github.io/tags/google-maps-apiweekly0.5https://car-ffeine.github.io/tags/google-maps-api/page/2weekly0.5https://car-ffeine.github.io/tags/google-maps-api/page/3weekly0.5https://car-ffeine.github.io/tags/googlemaps-react-wrapperweekly0.5https://car-ffeine.github.io/tags/helloweekly0.5https://car-ffeine.github.io/tags/hello/page/2weekly0.5https://car-ffeine.github.io/tags/hibernateweekly0.5https://car-ffeine.github.io/tags/indexweekly0.5https://car-ffeine.github.io/tags/infraweekly0.5https://car-ffeine.github.io/tags/ipweekly0.5https://car-ffeine.github.io/tags/issueweekly0.5https://car-ffeine.github.io/tags/issue/page/2weekly0.5https://car-ffeine.github.io/tags/jasyptweekly0.5https://car-ffeine.github.io/tags/javaweekly0.5https://car-ffeine.github.io/tags/java-11weekly0.5https://car-ffeine.github.io/tags/java-17weekly0.5https://car-ffeine.github.io/tags/java-17/page/2weekly0.5https://car-ffeine.github.io/tags/java/page/2weekly0.5https://car-ffeine.github.io/tags/java/page/3weekly0.5https://car-ffeine.github.io/tags/jpaweekly0.5https://car-ffeine.github.io/tags/jpa/page/2weekly0.5https://car-ffeine.github.io/tags/loginweekly0.5https://car-ffeine.github.io/tags/messageweekly0.5https://car-ffeine.github.io/tags/mswweekly0.5https://car-ffeine.github.io/tags/mysqlweekly0.5https://car-ffeine.github.io/tags/mysql/page/2weekly0.5https://car-ffeine.github.io/tags/mysql/page/3weekly0.5https://car-ffeine.github.io/tags/oauthweekly0.5https://car-ffeine.github.io/tags/oomweekly0.5https://car-ffeine.github.io/tags/prweekly0.5https://car-ffeine.github.io/tags/pr/page/2weekly0.5https://car-ffeine.github.io/tags/prodweekly0.5https://car-ffeine.github.io/tags/reactweekly0.5https://car-ffeine.github.io/tags/react-state-managementweekly0.5https://car-ffeine.github.io/tags/react-wrapperweekly0.5https://car-ffeine.github.io/tags/react/page/2weekly0.5https://car-ffeine.github.io/tags/react/page/3weekly0.5https://car-ffeine.github.io/tags/recordweekly0.5https://car-ffeine.github.io/tags/slackweekly0.5https://car-ffeine.github.io/tags/springweekly0.5https://car-ffeine.github.io/tags/spring/page/2weekly0.5https://car-ffeine.github.io/tags/spring/page/3weekly0.5https://car-ffeine.github.io/tags/styled-componentsweekly0.5https://car-ffeine.github.io/tags/subnetweekly0.5https://car-ffeine.github.io/tags/tanstack-queryweekly0.5https://car-ffeine.github.io/tags/testweekly0.5https://car-ffeine.github.io/tags/test/page/2weekly0.5https://car-ffeine.github.io/tags/to-listweekly0.5https://car-ffeine.github.io/tags/trouble-shootingweekly0.5https://car-ffeine.github.io/tags/trouble-shooting/page/2weekly0.5https://car-ffeine.github.io/tags/use-sync-external-stateweekly0.5https://car-ffeine.github.io/tags/use-sync-external-storeweekly0.5https://car-ffeine.github.io/tags/use-sync-external-store/page/2weekly0.5https://car-ffeine.github.io/tags/vpcweekly0.5https://car-ffeine.github.io/tags/webpackweekly0.5https://car-ffeine.github.io/tags/worldweekly0.5https://car-ffeine.github.io/tags/world/page/2weekly0.5https://car-ffeine.github.io/tags/%EA%B5%AC%EA%B8%80-%EC%A7%80%EB%8F%84weekly0.5https://car-ffeine.github.io/tags/%EB%B0%A9%EB%AC%B8%EC%9E%90-%EB%B6%84%EC%84%9Dweekly0.5https://car-ffeine.github.io/tags/%EB%B0%B0%ED%8F%ACweekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B2%84weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B2%84-%EB%B6%80%ED%95%98-%EC%A4%84%EC%9D%B4%EA%B8%B0weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B2%84/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B2%BD%ED%97%98weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B2%BD%ED%97%98/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98weekly0.5https://car-ffeine.github.io/tags/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4weekly0.5https://car-ffeine.github.io/tags/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%82%AC%EC%9A%A9%EA%B8%B0weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%82%AC%EC%9A%A9%EA%B8%B0/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%B6%A9%EC%A0%84%EC%86%8C-%EC%95%B1weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%B6%A9%EC%A0%84%EC%86%8C-%EC%95%B1/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EC%97%AD-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%ACweekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EC%97%AD%EC%83%81%ED%83%9Cweekly0.5https://car-ffeine.github.io/tags/%EC%B9%B4%ED%8E%98%EC%9D%B8weekly0.5https://car-ffeine.github.io/tags/%EC%B9%B4%ED%8E%98%EC%9D%B8/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%B9%B4%ED%8E%98%EC%9D%B8/page/3weekly0.5https://car-ffeine.github.io/tags/%ED%85%8C%EC%8A%A4%ED%8A%B8weekly0.5https://car-ffeine.github.io/tags/%ED%94%BC%EB%93%9C%EB%B0%B1weekly0.5https://car-ffeine.github.io/tags/%ED%94%BC%EB%93%9C%EB%B0%B1/page/2weekly0.5https://car-ffeine.github.io/tags/%ED%98%91%EC%97%85weekly0.5https://car-ffeine.github.io/docs/category/tutorial---basicsweekly0.5https://car-ffeine.github.io/docs/category/tutorial---extrasweekly0.5https://car-ffeine.github.io/docs/introweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/congratulationsweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/create-a-blog-postweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/create-a-documentweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/create-a-pageweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/deploy-your-siteweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/markdown-featuresweekly0.5https://car-ffeine.github.io/docs/tutorial-extras/manage-docs-versionsweekly0.5https://car-ffeine.github.io/docs/tutorial-extras/translate-your-siteweekly0.5https://car-ffeine.github.io/weekly0.5https://car-ffeine.github.io/weekly0.5 \ No newline at end of file +https://car-ffeine.github.io/1weekly0.5https://car-ffeine.github.io/10weekly0.5https://car-ffeine.github.io/11weekly0.5https://car-ffeine.github.io/12weekly0.5https://car-ffeine.github.io/13weekly0.5https://car-ffeine.github.io/14weekly0.5https://car-ffeine.github.io/15weekly0.5https://car-ffeine.github.io/16weekly0.5https://car-ffeine.github.io/17weekly0.5https://car-ffeine.github.io/18weekly0.5https://car-ffeine.github.io/19weekly0.5https://car-ffeine.github.io/2weekly0.5https://car-ffeine.github.io/20weekly0.5https://car-ffeine.github.io/21weekly0.5https://car-ffeine.github.io/22weekly0.5https://car-ffeine.github.io/23weekly0.5https://car-ffeine.github.io/24weekly0.5https://car-ffeine.github.io/25weekly0.5https://car-ffeine.github.io/26weekly0.5https://car-ffeine.github.io/27weekly0.5https://car-ffeine.github.io/28weekly0.5https://car-ffeine.github.io/29weekly0.5https://car-ffeine.github.io/3weekly0.5https://car-ffeine.github.io/30weekly0.5https://car-ffeine.github.io/31weekly0.5https://car-ffeine.github.io/32weekly0.5https://car-ffeine.github.io/33weekly0.5https://car-ffeine.github.io/34weekly0.5https://car-ffeine.github.io/35weekly0.5https://car-ffeine.github.io/36weekly0.5https://car-ffeine.github.io/37weekly0.5https://car-ffeine.github.io/38weekly0.5https://car-ffeine.github.io/39weekly0.5https://car-ffeine.github.io/4weekly0.5https://car-ffeine.github.io/40weekly0.5https://car-ffeine.github.io/41weekly0.5https://car-ffeine.github.io/5weekly0.5https://car-ffeine.github.io/6weekly0.5https://car-ffeine.github.io/7weekly0.5https://car-ffeine.github.io/8weekly0.5https://car-ffeine.github.io/9weekly0.5https://car-ffeine.github.io/archiveweekly0.5https://car-ffeine.github.io/markdown-pageweekly0.5https://car-ffeine.github.io/page/10weekly0.5https://car-ffeine.github.io/page/11weekly0.5https://car-ffeine.github.io/page/12weekly0.5https://car-ffeine.github.io/page/13weekly0.5https://car-ffeine.github.io/page/14weekly0.5https://car-ffeine.github.io/page/15weekly0.5https://car-ffeine.github.io/page/16weekly0.5https://car-ffeine.github.io/page/17weekly0.5https://car-ffeine.github.io/page/18weekly0.5https://car-ffeine.github.io/page/19weekly0.5https://car-ffeine.github.io/page/2weekly0.5https://car-ffeine.github.io/page/20weekly0.5https://car-ffeine.github.io/page/21weekly0.5https://car-ffeine.github.io/page/22weekly0.5https://car-ffeine.github.io/page/23weekly0.5https://car-ffeine.github.io/page/24weekly0.5https://car-ffeine.github.io/page/25weekly0.5https://car-ffeine.github.io/page/26weekly0.5https://car-ffeine.github.io/page/27weekly0.5https://car-ffeine.github.io/page/28weekly0.5https://car-ffeine.github.io/page/29weekly0.5https://car-ffeine.github.io/page/3weekly0.5https://car-ffeine.github.io/page/30weekly0.5https://car-ffeine.github.io/page/31weekly0.5https://car-ffeine.github.io/page/32weekly0.5https://car-ffeine.github.io/page/33weekly0.5https://car-ffeine.github.io/page/34weekly0.5https://car-ffeine.github.io/page/35weekly0.5https://car-ffeine.github.io/page/36weekly0.5https://car-ffeine.github.io/page/37weekly0.5https://car-ffeine.github.io/page/38weekly0.5https://car-ffeine.github.io/page/39weekly0.5https://car-ffeine.github.io/page/4weekly0.5https://car-ffeine.github.io/page/40weekly0.5https://car-ffeine.github.io/page/41weekly0.5https://car-ffeine.github.io/page/5weekly0.5https://car-ffeine.github.io/page/6weekly0.5https://car-ffeine.github.io/page/7weekly0.5https://car-ffeine.github.io/page/8weekly0.5https://car-ffeine.github.io/page/9weekly0.5https://car-ffeine.github.io/tagsweekly0.5https://car-ffeine.github.io/tags/actionweekly0.5https://car-ffeine.github.io/tags/action/page/2weekly0.5https://car-ffeine.github.io/tags/autoweekly0.5https://car-ffeine.github.io/tags/awsweekly0.5https://car-ffeine.github.io/tags/aws/page/2weekly0.5https://car-ffeine.github.io/tags/aws/page/3weekly0.5https://car-ffeine.github.io/tags/aws/page/4weekly0.5https://car-ffeine.github.io/tags/blue-greenweekly0.5https://car-ffeine.github.io/tags/branchweekly0.5https://car-ffeine.github.io/tags/cdweekly0.5https://car-ffeine.github.io/tags/cd/page/2weekly0.5https://car-ffeine.github.io/tags/cd/page/3weekly0.5https://car-ffeine.github.io/tags/ciweekly0.5https://car-ffeine.github.io/tags/ci/page/2weekly0.5https://car-ffeine.github.io/tags/commitweekly0.5https://car-ffeine.github.io/tags/cssweekly0.5https://car-ffeine.github.io/tags/css-in-jsweekly0.5https://car-ffeine.github.io/tags/dbweekly0.5https://car-ffeine.github.io/tags/deadlockweekly0.5https://car-ffeine.github.io/tags/devweekly0.5https://car-ffeine.github.io/tags/ec-2weekly0.5https://car-ffeine.github.io/tags/ec-2/page/2weekly0.5https://car-ffeine.github.io/tags/ec-2/page/3weekly0.5https://car-ffeine.github.io/tags/ec-2/page/4weekly0.5https://car-ffeine.github.io/tags/errorweekly0.5https://car-ffeine.github.io/tags/filterweekly0.5https://car-ffeine.github.io/tags/ga-4weekly0.5https://car-ffeine.github.io/tags/gcweekly0.5https://car-ffeine.github.io/tags/gitweekly0.5https://car-ffeine.github.io/tags/git-branchweekly0.5https://car-ffeine.github.io/tags/git-flowweekly0.5https://car-ffeine.github.io/tags/git/page/2weekly0.5https://car-ffeine.github.io/tags/githubweekly0.5https://car-ffeine.github.io/tags/github-flowweekly0.5https://car-ffeine.github.io/tags/github/page/2weekly0.5https://car-ffeine.github.io/tags/gitlab-flowweekly0.5https://car-ffeine.github.io/tags/google-analytics-4weekly0.5https://car-ffeine.github.io/tags/google-mapweekly0.5https://car-ffeine.github.io/tags/google-mapsweekly0.5https://car-ffeine.github.io/tags/google-maps-apiweekly0.5https://car-ffeine.github.io/tags/google-maps-api/page/2weekly0.5https://car-ffeine.github.io/tags/google-maps-api/page/3weekly0.5https://car-ffeine.github.io/tags/googlemaps-react-wrapperweekly0.5https://car-ffeine.github.io/tags/helloweekly0.5https://car-ffeine.github.io/tags/hello/page/2weekly0.5https://car-ffeine.github.io/tags/hibernateweekly0.5https://car-ffeine.github.io/tags/indexweekly0.5https://car-ffeine.github.io/tags/infraweekly0.5https://car-ffeine.github.io/tags/infra/page/2weekly0.5https://car-ffeine.github.io/tags/ipweekly0.5https://car-ffeine.github.io/tags/issueweekly0.5https://car-ffeine.github.io/tags/issue/page/2weekly0.5https://car-ffeine.github.io/tags/jasyptweekly0.5https://car-ffeine.github.io/tags/javaweekly0.5https://car-ffeine.github.io/tags/java-11weekly0.5https://car-ffeine.github.io/tags/java-17weekly0.5https://car-ffeine.github.io/tags/java-17/page/2weekly0.5https://car-ffeine.github.io/tags/java/page/2weekly0.5https://car-ffeine.github.io/tags/java/page/3weekly0.5https://car-ffeine.github.io/tags/jpaweekly0.5https://car-ffeine.github.io/tags/jpa/page/2weekly0.5https://car-ffeine.github.io/tags/loginweekly0.5https://car-ffeine.github.io/tags/messageweekly0.5https://car-ffeine.github.io/tags/mswweekly0.5https://car-ffeine.github.io/tags/mysqlweekly0.5https://car-ffeine.github.io/tags/mysql/page/2weekly0.5https://car-ffeine.github.io/tags/mysql/page/3weekly0.5https://car-ffeine.github.io/tags/oauthweekly0.5https://car-ffeine.github.io/tags/oomweekly0.5https://car-ffeine.github.io/tags/prweekly0.5https://car-ffeine.github.io/tags/pr/page/2weekly0.5https://car-ffeine.github.io/tags/prodweekly0.5https://car-ffeine.github.io/tags/reactweekly0.5https://car-ffeine.github.io/tags/react-state-managementweekly0.5https://car-ffeine.github.io/tags/react-wrapperweekly0.5https://car-ffeine.github.io/tags/react/page/2weekly0.5https://car-ffeine.github.io/tags/react/page/3weekly0.5https://car-ffeine.github.io/tags/recordweekly0.5https://car-ffeine.github.io/tags/slackweekly0.5https://car-ffeine.github.io/tags/springweekly0.5https://car-ffeine.github.io/tags/spring/page/2weekly0.5https://car-ffeine.github.io/tags/spring/page/3weekly0.5https://car-ffeine.github.io/tags/styled-componentsweekly0.5https://car-ffeine.github.io/tags/subnetweekly0.5https://car-ffeine.github.io/tags/tanstack-queryweekly0.5https://car-ffeine.github.io/tags/testweekly0.5https://car-ffeine.github.io/tags/test/page/2weekly0.5https://car-ffeine.github.io/tags/to-listweekly0.5https://car-ffeine.github.io/tags/trouble-shootingweekly0.5https://car-ffeine.github.io/tags/trouble-shooting/page/2weekly0.5https://car-ffeine.github.io/tags/use-sync-external-stateweekly0.5https://car-ffeine.github.io/tags/use-sync-external-storeweekly0.5https://car-ffeine.github.io/tags/use-sync-external-store/page/2weekly0.5https://car-ffeine.github.io/tags/vpcweekly0.5https://car-ffeine.github.io/tags/webpackweekly0.5https://car-ffeine.github.io/tags/worldweekly0.5https://car-ffeine.github.io/tags/world/page/2weekly0.5https://car-ffeine.github.io/tags/zero-timeweekly0.5https://car-ffeine.github.io/tags/%EA%B5%AC%EA%B8%80-%EC%A7%80%EB%8F%84weekly0.5https://car-ffeine.github.io/tags/%EB%B0%A9%EB%AC%B8%EC%9E%90-%EB%B6%84%EC%84%9Dweekly0.5https://car-ffeine.github.io/tags/%EB%B0%B0%ED%8F%ACweekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B2%84weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B2%84-%EB%B6%80%ED%95%98-%EC%A4%84%EC%9D%B4%EA%B8%B0weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B2%84/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B2%BD%ED%97%98weekly0.5https://car-ffeine.github.io/tags/%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B2%BD%ED%97%98/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98weekly0.5https://car-ffeine.github.io/tags/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4weekly0.5https://car-ffeine.github.io/tags/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%BD%94%EC%8A%A4/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%82%AC%EC%9A%A9%EA%B8%B0weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%82%AC%EC%9A%A9%EA%B8%B0/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%B6%A9%EC%A0%84%EC%86%8C-%EC%95%B1weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EA%B8%B0%EC%B0%A8-%EC%B6%A9%EC%A0%84%EC%86%8C-%EC%95%B1/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EC%97%AD-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%ACweekly0.5https://car-ffeine.github.io/tags/%EC%A0%84%EC%97%AD%EC%83%81%ED%83%9Cweekly0.5https://car-ffeine.github.io/tags/%EC%B9%B4%ED%8E%98%EC%9D%B8weekly0.5https://car-ffeine.github.io/tags/%EC%B9%B4%ED%8E%98%EC%9D%B8/page/2weekly0.5https://car-ffeine.github.io/tags/%EC%B9%B4%ED%8E%98%EC%9D%B8/page/3weekly0.5https://car-ffeine.github.io/tags/%ED%85%8C%EC%8A%A4%ED%8A%B8weekly0.5https://car-ffeine.github.io/tags/%ED%94%BC%EB%93%9C%EB%B0%B1weekly0.5https://car-ffeine.github.io/tags/%ED%94%BC%EB%93%9C%EB%B0%B1/page/2weekly0.5https://car-ffeine.github.io/tags/%ED%98%91%EC%97%85weekly0.5https://car-ffeine.github.io/docs/category/tutorial---basicsweekly0.5https://car-ffeine.github.io/docs/category/tutorial---extrasweekly0.5https://car-ffeine.github.io/docs/introweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/congratulationsweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/create-a-blog-postweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/create-a-documentweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/create-a-pageweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/deploy-your-siteweekly0.5https://car-ffeine.github.io/docs/tutorial-basics/markdown-featuresweekly0.5https://car-ffeine.github.io/docs/tutorial-extras/manage-docs-versionsweekly0.5https://car-ffeine.github.io/docs/tutorial-extras/translate-your-siteweekly0.5https://car-ffeine.github.io/weekly0.5https://car-ffeine.github.io/weekly0.5 \ No newline at end of file diff --git a/tags.html b/tags.html index c51baad5..220559e7 100644 --- a/tags.html +++ b/tags.html @@ -5,13 +5,13 @@ 태그 | CAR-FFEINE - - + + - - +
    + + \ No newline at end of file diff --git a/tags/action.html b/tags/action.html index e94baf6a..a45fd462 100644 --- a/tags/action.html +++ b/tags/action.html @@ -5,19 +5,19 @@ "action" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "action" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. +

    "action" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. 많은 시간을 단축할 수 있습니다.

    그리고 test가 실패한다면 강제로 Merge가 되지 않도록 한다면 실수로 테스트가 되지 않는 커밋을 올리는 것을 방지할 수 있겠죠.

    이 두가지만으로도 생산성이 많이 올라갈 것을 기대할 수 있습니다.

    어떻게 할 수 있나요

    Github Action을 이용하여 설정한 조건에 맞는 상황에서 명령어를 실행하여 test를 할 수 있습니다.

    Github Action 파일 생성

    1. 먼저 최상위 폴더에 .github/workflows 폴더를 생성합니다.
    2. 해당 폴더 내에 example.yml을 생성합니다.
    3. 아래와 같이 yml 파일을 작성합니다.
    name: pr test

    on:
    pull_request:
    branches:
    - main
    - develop

    permissions:
    contents: read

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    Job 이름 설정

    복잡하지 않습니다. 먼저 name 속성은 github action에서 보여질 Job의 이름을 정하는 부분입니다.

    지금은 pr test로 해두었습니다. 그럼 아래 사진과 같이 반영됩니다.

    workflows name

    workflow 트리거 설정

    다음으론 on 속성입니다. 이 속성은 workflow를 실행할 이벤트를 지정하는데 사용됩니다. 특정 이벤트 유형과 조건을 기반으로 workflow를 트리거하도록 구성할 수 있습니다.

    예를 들어 아래와 같이 정의했습니다.

    on:
    push:
    branches:
    - main
    pull_request:
    branches:
    - develop

    그렇다면 이 workflow가 작동되는 시점은 main 브랜치에 push가 되거나 develop 브랜치에 pull request를 보낼 때 작동합니다.

    권한 부여

    permissions:
    contents: read

    이런 권한을 주게 된다면 이 job은 읽기 권한밖에 없기 때문에 실수로 다른 것을 추가하지 못하게 막을 수 있습니다

    동작할 명령어 입력

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    name

    제일 간단히 볼 수 있는 name 설정은 아래 사진처럼 어떤식으로 보여줄지 정할 수 있습니다.

    job image

    runs-on

    runs-on 속성입니다. 해당 운영체제를 사용한다고 정의하는 부분입니다. 지금은 저희가 사용할 ec2와 같은 환경인 ubuntu에서 작동하도록 설정했지만, windows-latest, macos-latest로 변경할 수도 있습니다.

    environment

    environment 속성입니다. 해당 속성은 꼭 필요한 부분이 아니지만 branch의 rule 설정에 사용할 수 있습니다. 그리고 환경을 한꺼번에 관리할 수 있습니다.

    이 부분은 아래에 branch rule을 정하는 부분을 보시면 아마 이해가 될 것 입니다.

    defaults

    해당 속성은 어떤 폴더에서 명령어를 실행할 지 지정합니다. 지금의 저희 프로젝트에서는 한 repository에 backend, frontend 폴더를 나누었기 때문에 backend 폴더로 이동하여 명령어를 실행해야 합니다.

    그래서 working-directory./backend라고 지정했습니다.

    steps

    제일 중요한 steps입니다. 해당 속성은 어떤 명령어를 어떤 순서로 실행시킬지 정의합니다. 지금의 workflow에선

    1. Java 17 설치
    2. gradlew 파일에 실행 권한 부여
    3. gradle build 실행

    순으로 동작합니다.

    다른 조건과 이벤트도 추가하고 싶어요

    저희 프로젝트는 하나의 repository에서 frontend, backend 코드를 같이 관리하는 상황입니다. 하지만 frontend 코드를 수정했다고 java 테스트를 돌리는 것은 오히려 생산성이 줄어들겠죠.

    그리고 frontend도 테스트를 돌리고 싶지만 gradle을 사용하지 않습니다.

    그럴 때 간단한 속성을 추가하면 파일의 변경에 따라 해당 job을 실행할 조건을 정의할 수 있습니다.

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - backend/**
    - .github/**

    위와 달리 지금 pull request에는 속성이 하나가 더 있는데요. paths를 적용하면 backend 폴더 하위의 무언가 변경이 있는 pull request에만 작동을 하게 됩니다.

    그럼 backend의 workflow 파일에 paths 속성을 하나 추가하고, 비슷한 frontend workflow를 만들어주면 되겠죠.

    name: frontend test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**

    permissions:
    contents: read

    jobs:
    test:
    name: jest
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - uses: actions/checkout@v3
    - name: NPM Install
    run: npm i
    - name: Jest run
    run: npm run test

    이런 식으로 yml 파일을 하나 추가하면 frontend의 수정이 일어날 때는 jest를 실행하고, backend 폴더의 수정이 일어나면 gradlew를 실행하게 할 수 있습니다.

    Test가 실패하는 PR은 Merge 막기

    Test가 실패하는 Pull Request가 Merge 되는 일은 절대로 없어야 합니다. 그런 실수를 방지하려면 팀원 전부가 리뷰할 때 테스트를 돌려봐야하는 귀찮음이 생길 수 있습니다.

    그리고 사람은 실수해도 기계는 거짓말을 하지 않습니다. 자동으로 막도록 동작하게 만들어놓으면 그럴 일을 미연에 방지할 수 있습니다.

    Environments 확인하기

    먼저 해당 Repository의 Settings -> Environments 탭으로 들어갑니다. environments 아까 environment 속성을 보면 test라고 설정해놓은 것을 볼 수 있습니다. 해당 환경이 여기에 적용됩니다.

    Branch rule 정의하기

    이번에는 해당 Repository의 Settings -> Branches 탭으로 들어갑니다. 그리고 원하는 branch에 들어가 edit 버튼을 누릅니다.

    그리고 사진과 같이 Require deployments to succeed before merging 속성을 클릭합니다. 그리고 아래와 같이 어떤 환경을 적용할 것인지 선택할 수 있습니다.

    이 속성은 해당 배포가 성공해야 merge 할 수 있도록 브랜치를 보호하는 기능입니다.

    그리고 저희는 frontend와 backend Job의 환경을 둘 다 test라는 이름으로 정의했기 때문에 하나의 environment만 선택해도 둘 다 적용되는 효과를 볼 수 있습니다. branch rule

    적용 후

    아래와 같이 merge가 안된다는 글과 빨간색으로 경고 표시를 해주고 있습니다. blocked

    결론

    간단한 github action을 통해서 생산성을 많이 올릴 수 있는 좋은 기능인 것 같습니다. 다른 팀들도 이 기능을 도입하여 사용하는 것을 추천드립니다.

    - - + + \ No newline at end of file diff --git a/tags/action/page/2.html b/tags/action/page/2.html index 81f7be72..59d2294e 100644 --- a/tags/action/page/2.html +++ b/tags/action/page/2.html @@ -5,13 +5,13 @@ "action" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "action" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    - - +

    "action" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    + + \ No newline at end of file diff --git a/tags/auto.html b/tags/auto.html index 8ffe21d5..72b54b37 100644 --- a/tags/auto.html +++ b/tags/auto.html @@ -5,14 +5,14 @@ "auto" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "auto" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 +

    "auto" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 https://blog.deering.co/commit-convention/

    - - + + \ No newline at end of file diff --git a/tags/aws.html b/tags/aws.html index 72bd3d29..7f700573 100644 --- a/tags/aws.html +++ b/tags/aws.html @@ -3,15 +3,17 @@ -"aws" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "aws" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    - - +

    "aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    제이

    안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + \ No newline at end of file diff --git a/tags/aws/page/2.html b/tags/aws/page/2.html index 13c340bf..dc0084a1 100644 --- a/tags/aws/page/2.html +++ b/tags/aws/page/2.html @@ -3,15 +3,15 @@ -"aws" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "aws" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    - - +

    "aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    + + \ No newline at end of file diff --git a/tags/aws/page/3.html b/tags/aws/page/3.html index 3c2b13a1..a3389853 100644 --- a/tags/aws/page/3.html +++ b/tags/aws/page/3.html @@ -3,25 +3,15 @@ -"aws" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "aws" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, -우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. -성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. -(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? -따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. -이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? -이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. -깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. -물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? -저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. -이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - +

    "aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/aws/page/4.html b/tags/aws/page/4.html new file mode 100644 index 00000000..42bf770d --- /dev/null +++ b/tags/aws/page/4.html @@ -0,0 +1,27 @@ + + + + + +"aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE + + + + + +
    +

    "aws" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. +성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. +(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? +따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. +이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? +이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. +깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. +물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? +저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. +이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    + + + + \ No newline at end of file diff --git a/tags/blue-green.html b/tags/blue-green.html new file mode 100644 index 00000000..26b01200 --- /dev/null +++ b/tags/blue-green.html @@ -0,0 +1,19 @@ + + + + + +"blue-green" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE + + + + + +
    +

    "blue-green" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    제이

    안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + + + \ No newline at end of file diff --git a/tags/branch.html b/tags/branch.html index 8f7e691a..97ead492 100644 --- a/tags/branch.html +++ b/tags/branch.html @@ -5,13 +5,13 @@ "branch" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "branch" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    - - +

    "branch" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    + + \ No newline at end of file diff --git a/tags/cd.html b/tags/cd.html index 03d5f9fb..ec0a9cee 100644 --- a/tags/cd.html +++ b/tags/cd.html @@ -3,24 +3,17 @@ -"CD" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"cd" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "CD" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    안녕하세요. 카페인 팀의 제이입니다. -저희 팀에서 CI/CD는 어떻게 진행되는지 작성하겠습니다.

    CI (지속적 통합)

    ci

    카페인 팀에서는 지속적 통합 즉 CI를 진행하기 위해서 위에 사진과 같이 Github Actions를 사용합니다.

    main, develop 브랜치에 Push, Pull Request 요청이 들어간다면 이벤트가 발생하고, Github Actions를 통해 저희가 작성해둔 스크립트가 실행 됩니다.

    이 스크립트에 여러가지를 등록할 순 있지만, 저희는 자동으로 테스트를 진행하도록 하였습니다. -자동으로 테스트를 돌리면서 테스트가 통과를 해야지만 Merge를 진행할 수 있습니다.

    이를 통해 개발자의 실수를 줄일 수 있고 안정적으로 지속적 통합을 이룰 수 있게 됩니다.


    CD (지속적 배포)

    cd

    저희의 지속적 배포 아키텍처입니다.

    순서를 요약하자면 다음과 같습니다.
    1. Release 브랜치에 Push를 한다.
    2. Github Actions를 통해 Docker Hub에 레포지토리의 소스코드를 Docker Image로 빌드해서 Push 한다.
    3. 인프라 서버에서 Self Hosted Runner가 작동한다.
    4. 인프라 서버에서 배포 서버로 들어간다.
    5. 배포 서버 안에서 Docker Hub에 미리 업로드한 Docker Image를 Pull 해온다.
    6. 배포 서버 안에서 Docker Image를 컨테이너에 띄운다.

    배포 자동화 툴 선택하기

    먼저 배포 자동화 과정을 구축하기 위해서 여러가지 툴이 있습니다.

    Travis, Jenkins, Github Actions 등등 여러가지가 있는데요. -저희 팀은 Github Actions를 선택했습니다.

    이를 선택한 여러가지 이유가 있었지만 -저희 팀 누누를 제외하고 CI/CD 경험이 부족해서 비교적 쉽고 설치 및 큰 세팅이 없는 점이 저희한테는 매력적으로 다가왔습니다.

    또한 Docker를 사용하는데, 이유는 다음과 같습니다.

    1. JDK 혹은 Node 버전을 관리할 수 있다.
    2. Docker Image를 빌드한 후 배포하기 때문에 서버 환경 차이로 발생하는 문제를 최소화할 수 있다.
    3. 배포 서버에서 Docker만 설치하고 Image를 받고 실행시키면 돼서 빠르고 쉽게 배포 환경을 구축할 수 있다.

    과정

    본격적으로 저희의 배포 자동화를 구축하는 과정을 설명하겠습니다.


    1. Github Actions에 Runners 등록

    runner -먼저 Self Hosted Runner를 이용하기 때문에 저희는 위에 사진과 같이 Runners를 등록을 해줬습니다.

    이를 등록을 할 때 제공해주는 설정 코드가 나오는데요. -이 코드들을 infra 서버에 모두 입력을 해주면서 설정을 해주시면 됩니다.


    2. Github workflow 만들기다음으로는 저희가 수행하고자 하는 Task를 등록해주기 위해서 yml 파일을 만들어줍니다.

    yml 파일의 경로는 ./github/workflows/ 안에 만들어주면 됩니다.

    name: deploy

    # release/backend push 할 때
    on:
    push:
    branches:
    - release/backend

    jobs:
    # Docker
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend
    steps:
    # Docker Hub 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew

    - name: Build with Gradle
    run: ./gradlew bootjar

    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: Docker Hub 사용자명/이미지 이름

    # Build 및 Docker image를 Docker Hub에 push
    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}

    deploy:
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:
    # EC2 배포 서버로 접속
    - name: Join EC2 dev server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY

    # 1. 도커 이미지 받기
    # 2. 기존에 켜진 백엔드 서버(도커 이미지) stop
    # 3. 최신 백엔드 서버 run
    # 4. 사용하지 않는 이미지와 컨테이너 삭제
    script: |
    sudo docker pull woowacarffeine/backend:latest
    sudo docker stop backend || true
    sudo docker run -d --rm -p 8080:8080 \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    --name backend \
    Docker Hub 사용자명/이미지 이름:latest

    sudo docker image prune -f

    저희 팀은 위와 같이 backend-deploy.yml 파일을 만들어주었습니다.

    위에 yml에서 저희는 키를 숨겼는데요.

    img -위에 사진과 같이 설정을 해주시면 됩니다.

    그리고 이를 yml에서 사용하기 위해선 secrets.Key이름으로 사용해주시면 됩니다.


    이제 마지막으로 Dockerfile을 만들어줍니다.

    저희는 /backend/ 경로에 만들어주었습니다.

    FROM amazoncorretto:17-alpine-jdk
    ARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar
    COPY ${JAR_FILE} app.jar
    ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar","/app.jar"]

    저희는 위처럼 절대 경로를 기준으로 JAR_FILE 위치를 지정하고, profiles는 dev로 설정해서 만들어주었습니다.


    3. 배포하기

    트리거를 작동시켜서 저희가 yml 파일에서 지정해준 것들이 잘 작동하는지 확인합니다.

    jobSuccess -위에 사진처럼 모든 Job이 성공적으로 통과하는 것을 보실 수 있습니다.

    dockerPs -이렇게 인프라 서버에서 배포 서버로 들어가서 성공적으로 서버를 도커로 띄운 것을 보실 수 있습니다.

    EC2 배포 서버에서 docker ps를 입력했을 때에도 잘 실행이 되네요!


    CD 배포 과정 요약

    지속적 배포 과정을 요약 하자면 다음과 같습니다.

    1. Self Hosted Runner를 EC2 인프라 서버에 등록해준다.
    2. yml 파일과 Dockerfile을 만들어준다.
    3. 트리거를 작동시켜서 Github Actions의 태스크가 모두 잘 되는지 확인한다.
    4. 잘 됐다면 EC2 배포 서버에 Docker image가 성공적으로 띄워진다.
    - - +

    "cd" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    제이

    안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + \ No newline at end of file diff --git a/tags/cd/page/2.html b/tags/cd/page/2.html index 6a32644c..3d382e8d 100644 --- a/tags/cd/page/2.html +++ b/tags/cd/page/2.html @@ -3,25 +3,24 @@ -"CD" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"cd" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "CD" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, -우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. -성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. -(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? -따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. -이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? -이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. -깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. -물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? -저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. -이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - +

    "cd" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    안녕하세요. 카페인 팀의 제이입니다. +저희 팀에서 CI/CD는 어떻게 진행되는지 작성하겠습니다.

    CI (지속적 통합)

    ci

    카페인 팀에서는 지속적 통합 즉 CI를 진행하기 위해서 위에 사진과 같이 Github Actions를 사용합니다.

    main, develop 브랜치에 Push, Pull Request 요청이 들어간다면 이벤트가 발생하고, Github Actions를 통해 저희가 작성해둔 스크립트가 실행 됩니다.

    이 스크립트에 여러가지를 등록할 순 있지만, 저희는 자동으로 테스트를 진행하도록 하였습니다. +자동으로 테스트를 돌리면서 테스트가 통과를 해야지만 Merge를 진행할 수 있습니다.

    이를 통해 개발자의 실수를 줄일 수 있고 안정적으로 지속적 통합을 이룰 수 있게 됩니다.


    CD (지속적 배포)

    cd

    저희의 지속적 배포 아키텍처입니다.

    순서를 요약하자면 다음과 같습니다.
    1. Release 브랜치에 Push를 한다.
    2. Github Actions를 통해 Docker Hub에 레포지토리의 소스코드를 Docker Image로 빌드해서 Push 한다.
    3. 인프라 서버에서 Self Hosted Runner가 작동한다.
    4. 인프라 서버에서 배포 서버로 들어간다.
    5. 배포 서버 안에서 Docker Hub에 미리 업로드한 Docker Image를 Pull 해온다.
    6. 배포 서버 안에서 Docker Image를 컨테이너에 띄운다.

    배포 자동화 툴 선택하기

    먼저 배포 자동화 과정을 구축하기 위해서 여러가지 툴이 있습니다.

    Travis, Jenkins, Github Actions 등등 여러가지가 있는데요. +저희 팀은 Github Actions를 선택했습니다.

    이를 선택한 여러가지 이유가 있었지만 +저희 팀 누누를 제외하고 CI/CD 경험이 부족해서 비교적 쉽고 설치 및 큰 세팅이 없는 점이 저희한테는 매력적으로 다가왔습니다.

    또한 Docker를 사용하는데, 이유는 다음과 같습니다.

    1. JDK 혹은 Node 버전을 관리할 수 있다.
    2. Docker Image를 빌드한 후 배포하기 때문에 서버 환경 차이로 발생하는 문제를 최소화할 수 있다.
    3. 배포 서버에서 Docker만 설치하고 Image를 받고 실행시키면 돼서 빠르고 쉽게 배포 환경을 구축할 수 있다.

    과정

    본격적으로 저희의 배포 자동화를 구축하는 과정을 설명하겠습니다.


    1. Github Actions에 Runners 등록

    runner +먼저 Self Hosted Runner를 이용하기 때문에 저희는 위에 사진과 같이 Runners를 등록을 해줬습니다.

    이를 등록을 할 때 제공해주는 설정 코드가 나오는데요. +이 코드들을 infra 서버에 모두 입력을 해주면서 설정을 해주시면 됩니다.


    2. Github workflow 만들기다음으로는 저희가 수행하고자 하는 Task를 등록해주기 위해서 yml 파일을 만들어줍니다.

    yml 파일의 경로는 ./github/workflows/ 안에 만들어주면 됩니다.

    name: deploy

    # release/backend push 할 때
    on:
    push:
    branches:
    - release/backend

    jobs:
    # Docker
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend
    steps:
    # Docker Hub 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew

    - name: Build with Gradle
    run: ./gradlew bootjar

    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: Docker Hub 사용자명/이미지 이름

    # Build 및 Docker image를 Docker Hub에 push
    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}

    deploy:
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:
    # EC2 배포 서버로 접속
    - name: Join EC2 dev server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY

    # 1. 도커 이미지 받기
    # 2. 기존에 켜진 백엔드 서버(도커 이미지) stop
    # 3. 최신 백엔드 서버 run
    # 4. 사용하지 않는 이미지와 컨테이너 삭제
    script: |
    sudo docker pull woowacarffeine/backend:latest
    sudo docker stop backend || true
    sudo docker run -d --rm -p 8080:8080 \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    --name backend \
    Docker Hub 사용자명/이미지 이름:latest

    sudo docker image prune -f

    저희 팀은 위와 같이 backend-deploy.yml 파일을 만들어주었습니다.

    위에 yml에서 저희는 키를 숨겼는데요.

    img +위에 사진과 같이 설정을 해주시면 됩니다.

    그리고 이를 yml에서 사용하기 위해선 secrets.Key이름으로 사용해주시면 됩니다.


    이제 마지막으로 Dockerfile을 만들어줍니다.

    저희는 /backend/ 경로에 만들어주었습니다.

    FROM amazoncorretto:17-alpine-jdk
    ARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar
    COPY ${JAR_FILE} app.jar
    ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar","/app.jar"]

    저희는 위처럼 절대 경로를 기준으로 JAR_FILE 위치를 지정하고, profiles는 dev로 설정해서 만들어주었습니다.


    3. 배포하기

    트리거를 작동시켜서 저희가 yml 파일에서 지정해준 것들이 잘 작동하는지 확인합니다.

    jobSuccess +위에 사진처럼 모든 Job이 성공적으로 통과하는 것을 보실 수 있습니다.

    dockerPs +이렇게 인프라 서버에서 배포 서버로 들어가서 성공적으로 서버를 도커로 띄운 것을 보실 수 있습니다.

    EC2 배포 서버에서 docker ps를 입력했을 때에도 잘 실행이 되네요!


    CD 배포 과정 요약

    지속적 배포 과정을 요약 하자면 다음과 같습니다.

    1. Self Hosted Runner를 EC2 인프라 서버에 등록해준다.
    2. yml 파일과 Dockerfile을 만들어준다.
    3. 트리거를 작동시켜서 Github Actions의 태스크가 모두 잘 되는지 확인한다.
    4. 잘 됐다면 EC2 배포 서버에 Docker image가 성공적으로 띄워진다.
    + + \ No newline at end of file diff --git a/tags/cd/page/3.html b/tags/cd/page/3.html new file mode 100644 index 00000000..b539dfb0 --- /dev/null +++ b/tags/cd/page/3.html @@ -0,0 +1,27 @@ + + + + + +"cd" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE + + + + + +
    +

    "cd" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. +성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. +(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? +따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. +이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? +이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. +깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. +물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? +저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. +이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    + + + + \ No newline at end of file diff --git a/tags/ci.html b/tags/ci.html index 65678c37..6b0569d1 100644 --- a/tags/ci.html +++ b/tags/ci.html @@ -5,12 +5,12 @@ "CI" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "CI" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    안녕하세요. 카페인 팀의 제이입니다. +

    "CI" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    안녕하세요. 카페인 팀의 제이입니다. 저희 팀에서 CI/CD는 어떻게 진행되는지 작성하겠습니다.

    CI (지속적 통합)

    ci

    카페인 팀에서는 지속적 통합 즉 CI를 진행하기 위해서 위에 사진과 같이 Github Actions를 사용합니다.

    main, develop 브랜치에 Push, Pull Request 요청이 들어간다면 이벤트가 발생하고, Github Actions를 통해 저희가 작성해둔 스크립트가 실행 됩니다.

    이 스크립트에 여러가지를 등록할 순 있지만, 저희는 자동으로 테스트를 진행하도록 하였습니다. 자동으로 테스트를 돌리면서 테스트가 통과를 해야지만 Merge를 진행할 수 있습니다.

    이를 통해 개발자의 실수를 줄일 수 있고 안정적으로 지속적 통합을 이룰 수 있게 됩니다.


    CD (지속적 배포)

    cd

    저희의 지속적 배포 아키텍처입니다.

    순서를 요약하자면 다음과 같습니다.
    1. Release 브랜치에 Push를 한다.
    2. Github Actions를 통해 Docker Hub에 레포지토리의 소스코드를 Docker Image로 빌드해서 Push 한다.
    3. 인프라 서버에서 Self Hosted Runner가 작동한다.
    4. 인프라 서버에서 배포 서버로 들어간다.
    5. 배포 서버 안에서 Docker Hub에 미리 업로드한 Docker Image를 Pull 해온다.
    6. 배포 서버 안에서 Docker Image를 컨테이너에 띄운다.

    배포 자동화 툴 선택하기

    먼저 배포 자동화 과정을 구축하기 위해서 여러가지 툴이 있습니다.

    Travis, Jenkins, Github Actions 등등 여러가지가 있는데요. 저희 팀은 Github Actions를 선택했습니다.

    이를 선택한 여러가지 이유가 있었지만 @@ -20,7 +20,7 @@ 위에 사진과 같이 설정을 해주시면 됩니다.

    그리고 이를 yml에서 사용하기 위해선 secrets.Key이름으로 사용해주시면 됩니다.


    이제 마지막으로 Dockerfile을 만들어줍니다.

    저희는 /backend/ 경로에 만들어주었습니다.

    FROM amazoncorretto:17-alpine-jdk
    ARG JAR_FILE=./backend/build/libs/carffeine-0.0.1-SNAPSHOT.jar
    COPY ${JAR_FILE} app.jar
    ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar","/app.jar"]

    저희는 위처럼 절대 경로를 기준으로 JAR_FILE 위치를 지정하고, profiles는 dev로 설정해서 만들어주었습니다.


    3. 배포하기

    트리거를 작동시켜서 저희가 yml 파일에서 지정해준 것들이 잘 작동하는지 확인합니다.

    jobSuccess 위에 사진처럼 모든 Job이 성공적으로 통과하는 것을 보실 수 있습니다.

    dockerPs 이렇게 인프라 서버에서 배포 서버로 들어가서 성공적으로 서버를 도커로 띄운 것을 보실 수 있습니다.

    EC2 배포 서버에서 docker ps를 입력했을 때에도 잘 실행이 되네요!


    CD 배포 과정 요약

    지속적 배포 과정을 요약 하자면 다음과 같습니다.

    1. Self Hosted Runner를 EC2 인프라 서버에 등록해준다.
    2. yml 파일과 Dockerfile을 만들어준다.
    3. 트리거를 작동시켜서 Github Actions의 태스크가 모두 잘 되는지 확인한다.
    4. 잘 됐다면 EC2 배포 서버에 Docker image가 성공적으로 띄워진다.
    - - + + \ No newline at end of file diff --git a/tags/ci/page/2.html b/tags/ci/page/2.html index e3ce32fa..e5f47eb6 100644 --- a/tags/ci/page/2.html +++ b/tags/ci/page/2.html @@ -5,12 +5,12 @@ "CI" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "CI" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +

    "CI" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, 우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. 성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. (물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? @@ -21,7 +21,7 @@ 물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? 저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. 이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - + + \ No newline at end of file diff --git a/tags/commit.html b/tags/commit.html index 60402506..e99ee5f7 100644 --- a/tags/commit.html +++ b/tags/commit.html @@ -5,14 +5,14 @@ "commit" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "commit" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 +

    "commit" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 https://blog.deering.co/commit-convention/

    - - + + \ No newline at end of file diff --git a/tags/css-in-js.html b/tags/css-in-js.html index a9530b43..8bab75c8 100644 --- a/tags/css-in-js.html +++ b/tags/css-in-js.html @@ -5,13 +5,13 @@ "css in js" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "css in js" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    - - +

    "css in js" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    + + \ No newline at end of file diff --git a/tags/css.html b/tags/css.html index 37637dbd..2876ccc7 100644 --- a/tags/css.html +++ b/tags/css.html @@ -5,13 +5,13 @@ "css" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "css" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    - - +

    "css" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    + + \ No newline at end of file diff --git a/tags/db.html b/tags/db.html index 79c62a38..0d32b45b 100644 --- a/tags/db.html +++ b/tags/db.html @@ -5,13 +5,13 @@ "DB" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "DB" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    - - +

    "DB" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/deadlock.html b/tags/deadlock.html index 6aad37bb..a049c2cf 100644 --- a/tags/deadlock.html +++ b/tags/deadlock.html @@ -5,12 +5,12 @@ "deadlock" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "deadlock" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

    ------------------------
    LATEST DETECTED DEADLock
    ------------------------
    2023-07-21 01:49:54 281472560787424
    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

    *** (1) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    *** (2) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


    실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

    Mysql Dead Lock이란

    그럼 Dead Lock은 왜 생기고 언제 생길까요? +

    "deadlock" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

    ------------------------
    LATEST DETECTED DEADLock
    ------------------------
    2023-07-21 01:49:54 281472560787424
    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

    *** (1) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    *** (2) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


    실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

    Mysql Dead Lock이란

    그럼 Dead Lock은 왜 생기고 언제 생길까요? 저는 이 Log를 직접 마주하기 전까지는 Dead Lock이 그냥 Lock의 시간이 오래 걸릴 때 생기는 줄 알았습니다. 하지만 그렇게 간단하게 발생하는 것은 아니였습니다.

    1. 상호 배제(Mutual Exclusion): MySQL은 기본적으로 트랜잭션 내에서 잠금(Lock)을 사용하여 데이터의 상호 배제를 제어합니다. 따라서 두 개 이상의 트랜잭션이 같은 데이터를 동시에 변경하려고 할 때, 해당 데이터에 대한 잠금이 설정되어 상호 배제 조건이 만족됩니다.

    2. 점유와 대기(Hold and Wait): 트랜잭션이 이미 하나 이상의 데이터를 잠근 상태에서 다른 데이터의 잠금을 얻기 위해 대기하고 있는 경우 점유와 대기 조건이 만족됩니다. 즉, 트랜잭션이 자신이 점유한 데이터를 유지한 상태에서 다른 데이터에 대한 잠금을 기다리고 있어야 합니다.

    3. 비선점(Non-Preemption): MySQL에서는 기본적으로 트랜잭션이 다른 트랜잭션이 점유한 데이터의 잠금을 강제로 해제할 수 없습니다. 따라서 비선점 조건이 만족됩니다.

    4. 순환 대기(Circular Wait): 두 개 이상의 트랜잭션이 각각 서로가 기다리는 데이터의 잠금을 보유해야 순환 대기 조건이 만족됩니다. 예를 들면, 트랜잭션 A가 데이터 X의 잠금을 기다리고, 트랜잭션 B는 데이터 Y의 잠금을 기다리며, 트랜잭션 C는 데이터 Z의 잠금을 기다리는 상태가 발생한다면 순환 대기 조건이 성립합니다.

    사실 기본 컴퓨터 시스템의 dead Lock과 유사한 조건입니다. 이 부분을 모두 만족해야 데드락이 발생합니다. 하나씩 알아보겠습니다. 먼저 개발 서버에서 발생한 데드락으로 살펴보겠습니다.

    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap


    -------------------------------------------------------------------------
    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    1번 트랜잭션 1000560이 charge_status 테이블에 insert ~ on duplicate key update ~ 쿼리를 발생시키기 위해 space id 64 page no 742 n bits 424 index PRIMARY of table 에 X Lock을 가지고 있습니다 그리고 2번 트랜잭션 946331 도 똑같은 테이블에 비슷한 쿼리를 발생시키려고 합니다. 그리고 해당 트랜잭션도 X Lock을 가지고 있습니다.

    저희 팀에 데드락이 발생한 이유

    먼저 저희 팀은 공공 API를 통해 전기차 충전소 정보를 cron으로 업데이트 해주고 있습니다. @@ -26,7 +26,7 @@ 트랜잭션을 오래 가지고 있으면 Lock을 가지고 있는 시간이 오래걸립니다. 그래서 트랜잭션을 작게 분리할 수 있습니다. 페이징을 통해 트랜잭션을 작게 분리하다보면 쿼리가 여러번 나가 성능상 문제가 생길 수 있을 것 같습니다.

  • INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 사용하지 않기 해당 sql이 아닌 INSERT IGNORE을 사용하여 추가된 정보만 넣고, update는 다른 작업으로 분리하기
  • 이런 방법들을 사용하면 될 것 같았습니다. 그 중 저는 현재는 간단하게 2번째 방법이 제일 나을 것 같다는 생각에 쿼리를 수정했습니다.

    그리고 문제를 해결했습니다. 해당 문제가 발생하게 되어 좀 더 재밌는 것들을 고민하고 공부할 수 있는 저희 팀에게 감사하고 모르는 키워드를 많이 알려준 누누에게 감사합니다.

    아직 배우는 단계라 정확한 정보가 아닐 수 있습니다. 부족한 부분에 대해 많은 지적 부탁드립니다.

    - - + + \ No newline at end of file diff --git a/tags/dev.html b/tags/dev.html index bac349dc..be38a14c 100644 --- a/tags/dev.html +++ b/tags/dev.html @@ -5,19 +5,19 @@ "dev" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "dev" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    제이

    안녕하세요. +

    "dev" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    제이

    안녕하세요. 카페인 팀의 제이입니다.

    오늘은 저희가 EC2 인스턴스를 받으면서, 어떻게 dev, prod 배포 환경을 분리했는지 적어보려고 합니다. 기존 카페인 팀의 EC2 구조는 여기서 보실 수 있습니다.


    기존 상황과 문제점

    카페인 팀에서는 기존에 3대의 EC2 인스턴스가 있었습니다. 각각 infra, dev, db 역할을 하는 인스턴스로 존재하고 있었습니다.

    저희는 release 브랜치를 통해 dev서버에 배포를 한 후 검증이 된다면, 실제 사용자들이 사용하는 prod 서버에 배포하고 있습니다.

    문제는 기존의 3대의 인스턴스 중에서 dev 서버에 있었습니다. 기존 dev 서버는 총 4개의 서버를 배포하고 있었고 배포하는 서버는 다음과 같습니다. prod-BE, prod-FE, dev-BE, dev-FE

    그리고, 기존 dev 서버에서는 환경을 분리해주기 위해서 Nginx를 통해서 포트 포워딩은 다음과 같이 해주었습니다.

    • prod-BE = 8080
    • prod-FE = 3031
    • dev-BE = 8081
    • dev-FE = 3031

    카페인 팀에서는 dev, prod 환경이 분리되지 않아서 인스턴스의 사용량이 높았고, 이에 따라 추가적인 EC2 인스턴스가 필요했습니다.


    문제 해결

    다행히도 카페인 팀에서 추가적인 EC2 인스턴스를 받았고, 저희는 배포 환경을 분리할 수 있었습니다.

    dev-prod-server

    이와 같이 기존 dev 서버 한 개가 infra 서버와 연결되어 있었는데, 두 갈래로 나뉜 것을 확인하실 수 있습니다.

    먼저 배포는 다음과 같이 진행됩니다.

    release branch에 push가 일어나면 dev서버에 배포 작업이 이뤄집니다. prod branch에 push가 일어나면 prod서버에 배포 작업이 이뤄집니다.

    또한 기존 dev 서버에서 4개의 포트포워딩 또한 굳이 그럴 필요가 없어졌습니다. 새로운 서버가 추가됨에 따라 dev, prod 서버 각각 Nginx에서 포트포워딩을 동일하게 FE:3000, BE:8080 으로 변경하였습니다.

    이렇게 카페인 팀에서는 dev, prod 환경을 분리했습니다.

    감사합니다!

    - - + + \ No newline at end of file diff --git a/tags/ec-2.html b/tags/ec-2.html index 227135ad..de3a0bd0 100644 --- a/tags/ec-2.html +++ b/tags/ec-2.html @@ -3,21 +3,17 @@ -"ec2" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "ec2" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    제이

    안녕하세요. -카페인 팀의 제이입니다.

    오늘은 저희가 EC2 인스턴스를 받으면서, 어떻게 dev, prod 배포 환경을 분리했는지 적어보려고 합니다. -기존 카페인 팀의 EC2 구조는 여기서 보실 수 있습니다.


    기존 상황과 문제점

    카페인 팀에서는 기존에 3대의 EC2 인스턴스가 있었습니다. -각각 infra, dev, db 역할을 하는 인스턴스로 존재하고 있었습니다.

    저희는 release 브랜치를 통해 dev서버에 배포를 한 후 검증이 된다면, 실제 사용자들이 사용하는 prod 서버에 배포하고 있습니다.

    문제는 기존의 3대의 인스턴스 중에서 dev 서버에 있었습니다. -기존 dev 서버는 총 4개의 서버를 배포하고 있었고 배포하는 서버는 다음과 같습니다. prod-BE, prod-FE, dev-BE, dev-FE

    그리고, 기존 dev 서버에서는 환경을 분리해주기 위해서 Nginx를 통해서 포트 포워딩은 다음과 같이 해주었습니다.

    • prod-BE = 8080
    • prod-FE = 3031
    • dev-BE = 8081
    • dev-FE = 3031

    카페인 팀에서는 dev, prod 환경이 분리되지 않아서 인스턴스의 사용량이 높았고, 이에 따라 추가적인 EC2 인스턴스가 필요했습니다.


    문제 해결

    다행히도 카페인 팀에서 추가적인 EC2 인스턴스를 받았고, 저희는 배포 환경을 분리할 수 있었습니다.

    dev-prod-server

    이와 같이 기존 dev 서버 한 개가 infra 서버와 연결되어 있었는데, 두 갈래로 나뉜 것을 확인하실 수 있습니다.

    먼저 배포는 다음과 같이 진행됩니다.

    release branch에 push가 일어나면 dev서버에 배포 작업이 이뤄집니다. -prod branch에 push가 일어나면 prod서버에 배포 작업이 이뤄집니다.

    또한 기존 dev 서버에서 4개의 포트포워딩 또한 굳이 그럴 필요가 없어졌습니다. -새로운 서버가 추가됨에 따라 dev, prod 서버 각각 Nginx에서 포트포워딩을 동일하게 FE:3000, BE:8080 으로 변경하였습니다.

    이렇게 카페인 팀에서는 dev, prod 환경을 분리했습니다.

    감사합니다!

    - - +

    "ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    제이

    안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + \ No newline at end of file diff --git a/tags/ec-2/page/2.html b/tags/ec-2/page/2.html index 77ebc974..3e0f129d 100644 --- a/tags/ec-2/page/2.html +++ b/tags/ec-2/page/2.html @@ -3,15 +3,21 @@ -"ec2" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "ec2" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    - - +

    "ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    제이

    안녕하세요. +카페인 팀의 제이입니다.

    오늘은 저희가 EC2 인스턴스를 받으면서, 어떻게 dev, prod 배포 환경을 분리했는지 적어보려고 합니다. +기존 카페인 팀의 EC2 구조는 여기서 보실 수 있습니다.


    기존 상황과 문제점

    카페인 팀에서는 기존에 3대의 EC2 인스턴스가 있었습니다. +각각 infra, dev, db 역할을 하는 인스턴스로 존재하고 있었습니다.

    저희는 release 브랜치를 통해 dev서버에 배포를 한 후 검증이 된다면, 실제 사용자들이 사용하는 prod 서버에 배포하고 있습니다.

    문제는 기존의 3대의 인스턴스 중에서 dev 서버에 있었습니다. +기존 dev 서버는 총 4개의 서버를 배포하고 있었고 배포하는 서버는 다음과 같습니다. prod-BE, prod-FE, dev-BE, dev-FE

    그리고, 기존 dev 서버에서는 환경을 분리해주기 위해서 Nginx를 통해서 포트 포워딩은 다음과 같이 해주었습니다.

    • prod-BE = 8080
    • prod-FE = 3031
    • dev-BE = 8081
    • dev-FE = 3031

    카페인 팀에서는 dev, prod 환경이 분리되지 않아서 인스턴스의 사용량이 높았고, 이에 따라 추가적인 EC2 인스턴스가 필요했습니다.


    문제 해결

    다행히도 카페인 팀에서 추가적인 EC2 인스턴스를 받았고, 저희는 배포 환경을 분리할 수 있었습니다.

    dev-prod-server

    이와 같이 기존 dev 서버 한 개가 infra 서버와 연결되어 있었는데, 두 갈래로 나뉜 것을 확인하실 수 있습니다.

    먼저 배포는 다음과 같이 진행됩니다.

    release branch에 push가 일어나면 dev서버에 배포 작업이 이뤄집니다. +prod branch에 push가 일어나면 prod서버에 배포 작업이 이뤄집니다.

    또한 기존 dev 서버에서 4개의 포트포워딩 또한 굳이 그럴 필요가 없어졌습니다. +새로운 서버가 추가됨에 따라 dev, prod 서버 각각 Nginx에서 포트포워딩을 동일하게 FE:3000, BE:8080 으로 변경하였습니다.

    이렇게 카페인 팀에서는 dev, prod 환경을 분리했습니다.

    감사합니다!

    + + \ No newline at end of file diff --git a/tags/ec-2/page/3.html b/tags/ec-2/page/3.html index c3bb5abb..786aa82c 100644 --- a/tags/ec-2/page/3.html +++ b/tags/ec-2/page/3.html @@ -3,25 +3,15 @@ -"ec2" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "ec2" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, -우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. -성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. -(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? -따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. -이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? -이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. -깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. -물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? -저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. -이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - +

    "ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/ec-2/page/4.html b/tags/ec-2/page/4.html new file mode 100644 index 00000000..8a2fa024 --- /dev/null +++ b/tags/ec-2/page/4.html @@ -0,0 +1,27 @@ + + + + + +"ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다. | CAR-FFEINE + + + + + +
    +

    "ec2" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. +성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. +(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? +따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. +이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? +이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. +깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. +물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? +저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. +이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    + + + + \ No newline at end of file diff --git a/tags/error.html b/tags/error.html index d1cfe09b..a4b26859 100644 --- a/tags/error.html +++ b/tags/error.html @@ -5,13 +5,13 @@ "error" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "error" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    - - +

    "error" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/filter.html b/tags/filter.html index 2176cd93..900ddbbe 100644 --- a/tags/filter.html +++ b/tags/filter.html @@ -5,12 +5,12 @@ "filter" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "filter" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    제이

    안녕하세요~

    우테코 카페인 팀의 제이입니다.

    오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

    요구 사항과 기능 구현 목록

    카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

    사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

    그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

    저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

    또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

    필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

    필터링 기능 구현하기

    저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

    따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

    사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

    3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

    그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

    그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

    1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

    2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

    이렇게 두 가지 방법이 있었습니다.

    1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

    // 1. fetch join + 회사 이름만 조회
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND s.companyName IN :companyNames")
    List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("companyNames") List<String> companyNames);

    // 2. fetch join + 충전 타입
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND c.type IN :types")
    List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("types") List<ChargerType> types);

    진행 했다면 이런 느낌이었겠네요!

    2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

    현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

    어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

    따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

    이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

    하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

    결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

    이 이유는 다음과 같습니다.
    1. 어차피 사용자는 작은 범위에서 조회를 한다.
    2. 인덱스를 걸었을 때 가장 효율적이다.

    1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

    저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

    이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

    JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

    그럴 순 없었을 것 같습니다.

    가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

    이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

    반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

    어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

    조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

    인덱스 적용으로 조회 속도 향상시키기

    먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

    Hibernate:
    select
    station0_.station_id as station_1_0_0_,
    ...
    ...
    ...
    chargersta2_.latest_update_time as latest_u4_2_2_
    from
    charge_station station0_
    left outer join
    charger chargers1_
    on station0_.station_id=chargers1_.station_id
    left outer join
    charger_status chargersta2_
    on chargers1_.charger_id=chargersta2_.charger_id
    and chargers1_.station_id=chargersta2_.station_id
    where
    (
    station0_.latitude between ? and ?
    )
    and (
    station0_.longitude between ? and ?
    )

    where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

    따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. +

    "filter" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    제이

    안녕하세요~

    우테코 카페인 팀의 제이입니다.

    오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

    요구 사항과 기능 구현 목록

    카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

    사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

    그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

    저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

    또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

    필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

    필터링 기능 구현하기

    저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

    따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

    사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

    3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

    그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

    그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

    1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

    2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

    이렇게 두 가지 방법이 있었습니다.

    1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

    // 1. fetch join + 회사 이름만 조회
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND s.companyName IN :companyNames")
    List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("companyNames") List<String> companyNames);

    // 2. fetch join + 충전 타입
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND c.type IN :types")
    List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("types") List<ChargerType> types);

    진행 했다면 이런 느낌이었겠네요!

    2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

    현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

    어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

    따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

    이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

    하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

    결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

    이 이유는 다음과 같습니다.
    1. 어차피 사용자는 작은 범위에서 조회를 한다.
    2. 인덱스를 걸었을 때 가장 효율적이다.

    1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

    저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

    이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

    JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

    그럴 순 없었을 것 같습니다.

    가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

    이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

    반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

    어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

    조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

    인덱스 적용으로 조회 속도 향상시키기

    먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

    Hibernate:
    select
    station0_.station_id as station_1_0_0_,
    ...
    ...
    ...
    chargersta2_.latest_update_time as latest_u4_2_2_
    from
    charge_station station0_
    left outer join
    charger chargers1_
    on station0_.station_id=chargers1_.station_id
    left outer join
    charger_status chargersta2_
    on chargers1_.charger_id=chargersta2_.charger_id
    and chargers1_.station_id=chargersta2_.station_id
    where
    (
    station0_.latitude between ? and ?
    )
    and (
    station0_.longitude between ? and ?
    )

    where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

    따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. Mysql Query Between 과 >=, <= 성능 차이 비교 ( 더미데이터 50만 )

    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude " +
    "AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude")
    List<Station> findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude);

    위와 같이 조회해주는 쿼리를 만들었고, 인덱스를 만들어주었습니다.

    인덱스 설정 기준은 인덱스 정리 및 팁 위에 링크와 같이 동욱님의 블로그를 참조해서 기준을 세웠습니다.

    무조건 카디널리티가 높은 것을 설정할 순 없었기 때문에 (업데이트와 삽입 작업이 많기 때문에) 쿼리에서 사용되는 column과 update 작업을 고려하고 성능을 비교해가면서 가장 효율적인 것을 설정해주었습니다.

    그리고 속도를 비교해주었습니다.



    먼저 속도 비교를 위해서 데이터 셋은 다음과 같이 진행하였습니다.
    • Charger (23만 건)
    • Station (6만 건)
    • ChargerStatus(23만 건)
    • 선릉역 근처 조회

    Ver1. 인덱스 적용을 하지 않고 조회 및 필터링 했을 때 속도 (0.84초)

    이미지 @@ -18,7 +18,7 @@ 평균적으로 0.63초가 나왔습니다. 약 25 ~ 30%의 조회 속도가 개선되었습니다.

    아직 이 부분은 개선이 더 필요해보입니다.

    그래도 개선이 됐고, 삽입과 갱신에는 큰 지장이 없어서 일단 이정도로 마무리 하고, 추후에 개선을 해보도록 하겠습니다.

    이미지 추가적으로 충전기 조회는 굉장히 빨라졌습니다!

    배우는 단계이다보니 미숙하고 틀린 부분이 있을 수 있습니다.

    긴 글 읽어주셔서 감사합니다 :)

    - - + + \ No newline at end of file diff --git a/tags/ga-4.html b/tags/ga-4.html index 4ff198a5..8f8f032b 100644 --- a/tags/ga-4.html +++ b/tags/ga-4.html @@ -5,12 +5,12 @@ "ga4" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "ga4" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset +

    "ga4" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset no offset

    네이버에 있는 전기자동차 동호회 카페 중 가장 큰 곳에 글을 올려 방문자를 모집하기로 했습니다.

    카페에 글을 올리는 것은 무료이며, 카페에 가입한 사람들은 전기차에 관심이 있는 사람들이기 때문에 저희가 원하는 방문자를 모집하기에 적합하다고 생각했습니다.

    카카오톡 오픈채팅

    no offset no offset

    카카오톡 오픈채팅에는 수많은 대화방이 존재합니다.

    특정 주제로 만들어진 대화방이 대부분이기에 전기차를 주제로 한 오픈채팅 대화방을 찾는 것은 전혀 어렵지 않았습니다.

    안타깝게도 일부 단톡방에서 강퇴를 당했지만, 차주들과 채팅하면서 피드백을 받아볼 수 있었습니다.

    기타 홍보 수단

    기타 홍보 수단은 아직 사용하지 않았습니다.

    네이버 밴드, 보배드림은 사용하는 크루가 없어서 홍보를 하기 어려웠고, 구글 애드센스와 같은 도구는 비용이 발생하기에 아직은 이르다고 판단했습니다.

    Google Analytics 4 통계 집계 결과

    단순 방문자

    no offset no offset @@ -20,7 +20,7 @@ no offset no offset no offset

    집계 된 자료처럼 방문자들이 단순 방문만 한 것이 아니라, 수 많은 이벤트를 발생시키고 평균 참여 시간도 상당 부분 확보했음을 확인할 수 있습니다.

    - - + + \ No newline at end of file diff --git a/tags/gc.html b/tags/gc.html index ebaf84d9..9f935a54 100644 --- a/tags/gc.html +++ b/tags/gc.html @@ -5,13 +5,13 @@ "gc" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "gc" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    - - +

    "gc" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    + + \ No newline at end of file diff --git a/tags/git-branch.html b/tags/git-branch.html index 605f2a56..16489cec 100644 --- a/tags/git-branch.html +++ b/tags/git-branch.html @@ -5,13 +5,13 @@ "git branch" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "git branch" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    - - +

    "git branch" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    + + \ No newline at end of file diff --git a/tags/git-flow.html b/tags/git-flow.html index df5cc23d..556b82ee 100644 --- a/tags/git-flow.html +++ b/tags/git-flow.html @@ -5,13 +5,13 @@ "git flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "git flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    - - +

    "git flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    + + \ No newline at end of file diff --git a/tags/git.html b/tags/git.html index ad58d2a1..7bf04ebf 100644 --- a/tags/git.html +++ b/tags/git.html @@ -5,14 +5,14 @@ "git" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "git" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 +

    "git" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 https://blog.deering.co/commit-convention/

    - - + + \ No newline at end of file diff --git a/tags/git/page/2.html b/tags/git/page/2.html index f8100226..66d33838 100644 --- a/tags/git/page/2.html +++ b/tags/git/page/2.html @@ -5,13 +5,13 @@ "git" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "git" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    - - +

    "git" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    + + \ No newline at end of file diff --git a/tags/github-flow.html b/tags/github-flow.html index 0fad9982..3dba4c7d 100644 --- a/tags/github-flow.html +++ b/tags/github-flow.html @@ -5,13 +5,13 @@ "github flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "github flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    - - +

    "github flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    + + \ No newline at end of file diff --git a/tags/github.html b/tags/github.html index 4ae37f1d..4885212f 100644 --- a/tags/github.html +++ b/tags/github.html @@ -5,19 +5,19 @@ "github" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "github" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. +

    "github" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. 많은 시간을 단축할 수 있습니다.

    그리고 test가 실패한다면 강제로 Merge가 되지 않도록 한다면 실수로 테스트가 되지 않는 커밋을 올리는 것을 방지할 수 있겠죠.

    이 두가지만으로도 생산성이 많이 올라갈 것을 기대할 수 있습니다.

    어떻게 할 수 있나요

    Github Action을 이용하여 설정한 조건에 맞는 상황에서 명령어를 실행하여 test를 할 수 있습니다.

    Github Action 파일 생성

    1. 먼저 최상위 폴더에 .github/workflows 폴더를 생성합니다.
    2. 해당 폴더 내에 example.yml을 생성합니다.
    3. 아래와 같이 yml 파일을 작성합니다.
    name: pr test

    on:
    pull_request:
    branches:
    - main
    - develop

    permissions:
    contents: read

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    Job 이름 설정

    복잡하지 않습니다. 먼저 name 속성은 github action에서 보여질 Job의 이름을 정하는 부분입니다.

    지금은 pr test로 해두었습니다. 그럼 아래 사진과 같이 반영됩니다.

    workflows name

    workflow 트리거 설정

    다음으론 on 속성입니다. 이 속성은 workflow를 실행할 이벤트를 지정하는데 사용됩니다. 특정 이벤트 유형과 조건을 기반으로 workflow를 트리거하도록 구성할 수 있습니다.

    예를 들어 아래와 같이 정의했습니다.

    on:
    push:
    branches:
    - main
    pull_request:
    branches:
    - develop

    그렇다면 이 workflow가 작동되는 시점은 main 브랜치에 push가 되거나 develop 브랜치에 pull request를 보낼 때 작동합니다.

    권한 부여

    permissions:
    contents: read

    이런 권한을 주게 된다면 이 job은 읽기 권한밖에 없기 때문에 실수로 다른 것을 추가하지 못하게 막을 수 있습니다

    동작할 명령어 입력

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    name

    제일 간단히 볼 수 있는 name 설정은 아래 사진처럼 어떤식으로 보여줄지 정할 수 있습니다.

    job image

    runs-on

    runs-on 속성입니다. 해당 운영체제를 사용한다고 정의하는 부분입니다. 지금은 저희가 사용할 ec2와 같은 환경인 ubuntu에서 작동하도록 설정했지만, windows-latest, macos-latest로 변경할 수도 있습니다.

    environment

    environment 속성입니다. 해당 속성은 꼭 필요한 부분이 아니지만 branch의 rule 설정에 사용할 수 있습니다. 그리고 환경을 한꺼번에 관리할 수 있습니다.

    이 부분은 아래에 branch rule을 정하는 부분을 보시면 아마 이해가 될 것 입니다.

    defaults

    해당 속성은 어떤 폴더에서 명령어를 실행할 지 지정합니다. 지금의 저희 프로젝트에서는 한 repository에 backend, frontend 폴더를 나누었기 때문에 backend 폴더로 이동하여 명령어를 실행해야 합니다.

    그래서 working-directory./backend라고 지정했습니다.

    steps

    제일 중요한 steps입니다. 해당 속성은 어떤 명령어를 어떤 순서로 실행시킬지 정의합니다. 지금의 workflow에선

    1. Java 17 설치
    2. gradlew 파일에 실행 권한 부여
    3. gradle build 실행

    순으로 동작합니다.

    다른 조건과 이벤트도 추가하고 싶어요

    저희 프로젝트는 하나의 repository에서 frontend, backend 코드를 같이 관리하는 상황입니다. 하지만 frontend 코드를 수정했다고 java 테스트를 돌리는 것은 오히려 생산성이 줄어들겠죠.

    그리고 frontend도 테스트를 돌리고 싶지만 gradle을 사용하지 않습니다.

    그럴 때 간단한 속성을 추가하면 파일의 변경에 따라 해당 job을 실행할 조건을 정의할 수 있습니다.

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - backend/**
    - .github/**

    위와 달리 지금 pull request에는 속성이 하나가 더 있는데요. paths를 적용하면 backend 폴더 하위의 무언가 변경이 있는 pull request에만 작동을 하게 됩니다.

    그럼 backend의 workflow 파일에 paths 속성을 하나 추가하고, 비슷한 frontend workflow를 만들어주면 되겠죠.

    name: frontend test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**

    permissions:
    contents: read

    jobs:
    test:
    name: jest
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - uses: actions/checkout@v3
    - name: NPM Install
    run: npm i
    - name: Jest run
    run: npm run test

    이런 식으로 yml 파일을 하나 추가하면 frontend의 수정이 일어날 때는 jest를 실행하고, backend 폴더의 수정이 일어나면 gradlew를 실행하게 할 수 있습니다.

    Test가 실패하는 PR은 Merge 막기

    Test가 실패하는 Pull Request가 Merge 되는 일은 절대로 없어야 합니다. 그런 실수를 방지하려면 팀원 전부가 리뷰할 때 테스트를 돌려봐야하는 귀찮음이 생길 수 있습니다.

    그리고 사람은 실수해도 기계는 거짓말을 하지 않습니다. 자동으로 막도록 동작하게 만들어놓으면 그럴 일을 미연에 방지할 수 있습니다.

    Environments 확인하기

    먼저 해당 Repository의 Settings -> Environments 탭으로 들어갑니다. environments 아까 environment 속성을 보면 test라고 설정해놓은 것을 볼 수 있습니다. 해당 환경이 여기에 적용됩니다.

    Branch rule 정의하기

    이번에는 해당 Repository의 Settings -> Branches 탭으로 들어갑니다. 그리고 원하는 branch에 들어가 edit 버튼을 누릅니다.

    그리고 사진과 같이 Require deployments to succeed before merging 속성을 클릭합니다. 그리고 아래와 같이 어떤 환경을 적용할 것인지 선택할 수 있습니다.

    이 속성은 해당 배포가 성공해야 merge 할 수 있도록 브랜치를 보호하는 기능입니다.

    그리고 저희는 frontend와 backend Job의 환경을 둘 다 test라는 이름으로 정의했기 때문에 하나의 environment만 선택해도 둘 다 적용되는 효과를 볼 수 있습니다. branch rule

    적용 후

    아래와 같이 merge가 안된다는 글과 빨간색으로 경고 표시를 해주고 있습니다. blocked

    결론

    간단한 github action을 통해서 생산성을 많이 올릴 수 있는 좋은 기능인 것 같습니다. 다른 팀들도 이 기능을 도입하여 사용하는 것을 추천드립니다.

    - - + + \ No newline at end of file diff --git a/tags/github/page/2.html b/tags/github/page/2.html index 1b9c8eb1..4cddcad1 100644 --- a/tags/github/page/2.html +++ b/tags/github/page/2.html @@ -5,13 +5,13 @@ "github" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "github" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    - - +

    "github" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    + + \ No newline at end of file diff --git a/tags/gitlab-flow.html b/tags/gitlab-flow.html index a0b3bcd6..f361e841 100644 --- a/tags/gitlab-flow.html +++ b/tags/gitlab-flow.html @@ -5,13 +5,13 @@ "gitlab flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "gitlab flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    - - +

    "gitlab flow" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    현재 상황은 어떤데?

    현재 우아한테크코스에서는 프론트 코드와 백엔드 코드가 같은 레포지토리를 사용하고 있습니다.

    프론트와 백엔드가 같이 작업하기에, 의도치 않은 충돌이 자주 생길 수 있는 구조이기에, 이를 git branch 전략으로 충돌을 줄이고자 합니다

    Git Branch 전략이란?

    git을 사용해서 소프트웨어 개발을 관리하는 방법입니다.

    여러 개발자가 동시에 작업하고 코드를 통합할 때 생기는 충돌을 효율적으로 조정하기 위한 방법입니다.

    왜 git branch 전략이 중요한데?

    아래에 있는 4가지를 제외하고도 훨씬 많은 장점이 있을 수 있습니다.

    1. 동시 작업이 편하다

    여러 사람이 독립적으로 작업하고, 커밋을 할 때, 자신의 브랜치에서 변경 사항을 커밋하게 됩니다.

    브랜치가 병합될 때만 충돌을 해결하면 되니, 아무 규칙이 없는 것보다 충돌 시점이 명확해지기에 생산성을 높일 수 있습니다.

    2. 목적이 명확한 브랜치

    애플리케이션의 상태에 몇 가지가 있는데, 안정된 프로덕션, 테스트 환경, 기능 추가 환경... 등이 있습니다

    여러 기능별 브랜치(안정된 버전의 코드만이 관리되는 브랜치, 테스트 환경을 위한 브랜치, 기능 추가를 위한 브랜치)를

    네이밍을 통해 구분하면 각각의 브랜치에 대해서 추가적인 설명을 할 필요 없이 명확하게 관리할 수 있습니다.

    3. 배포 파이프라인 관리가 편함

    브랜치가 네이밍으로 명확하게 구분이 되어있다면, 조건을 설정하기 쉽습니다.

    특정 타입의 브랜치에 push 되었을 때, pull request를 만들었을 때 같은 조건에 따른 스크립트를 만들어둔다면 CI/CD를 구축하기 쉽습니다.

    4. 버전 관리가 편리하다

    서버에 문제가 생겼을 때, 어떤 브랜치로 돌아가서 롤백을 해야 하는지에 대한 것들이 명확합니다.

    안정된 브랜치가 어떤 것인지 명확하기에, 롤백 과정에 대한 의사결정을 줄일 수 있습니다.

    그러면 어떤 종류가 있는지 더 자세하게 알아보도록 하겠습니다.

    Git Branch 전략의 종류는?

    총 3가지의 전략이 있습니다.

    1. Github Flow

    2. Gitlab Flow

    3. Git Flow

    git을 사용하기에, Git Flow라는 네이밍이 가장 직관적이고 유명한데요. 

    3가지 전략 중에서 가장 복잡하기에, 쉬운 순서대로 진행해 보도록 하겠습니다.

    1. Github Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img

    img2

    브랜치는 총 2가지 종류가 존재합니다

    1. master 브랜치

    여기에 머지가 되면 배포가 되도록 CD를 연결해 놓은 경우가 많습니다.

    안정된 버전의 코드가 관리되는 브랜치입니다.

    2. feature 브랜치

    기능 추가, 버그 수정 등 모든 작업은 feature 브랜치에서 일어납니다.

    master 브랜치에서 새로운 브랜치를 만들어서, 마스터로 머지되는 단순한 사이클을 가지고 있습니다.

    장점

    위에서 볼 수 있는 것처럼 2종류의 브랜치만 있기에, 정말 간단합니다.

    학습 과정까지의 러닝 커브가 거의 없다시피 하기에, 간단한 프로젝트에 적용하기 정말 좋습니다.

    릴리즈 되지 않은 코드가 최소화됩니다. 최신 버전의 코드와 최대한 빠르게 동기화를 계속해서 시킬 수 있습니다

    단점

    모든 코드는 다 master 브랜치에 머지가 되어야 한다는 점이 개발 서버와, 운영서버를 나누기 애매할 수 있습니다.

    개발 서버에 배포를 하고 싶은 상황이라면, master에 머지가 되어야 합니다.

    머지가 된 이후에 cd 파이프라인을 통해서 개발 서버와 운영 서버 모두에 배포가 됩니다.

    여러 환경을 나누고 관리를 하고 싶으시다면 다음에 소개해드릴 전략을 사용해 보셔도 좋을 것 같습니다

    2. Gitlab Flow

    그림으로 flow 간단하게 보고 가도록 하겠습니다.

    img2

    밑에 환경은 총 2개의 서버가 존재할 때를 가정하고 있습니다.

    1. pre-production 서버

    2. production 서버

    편의를 위해 main에 머지되는 과정은 간단하게 표현했습니다.

    img3

    브랜치 종류

    총 3가지 브랜치가 필요하고, 추가에 따라서 더 추가할 수 있습니다.

    1. main(or develop) 브랜치

    기능에 대한 개발이 완료되었지만, 여기에 머지되어도 바로 배포되지는 않습니다.

    2. feature브랜치

    기능을 개발하는 브랜치입니다. Github Flow 와도 유사합니다.

    3. production 브랜치

    실제 배포가 일어나는 브랜치입니다. 

    여기에 머지가 되는 순간 배포가 일어납니다.

    위 사진에 있는 것처럼, 필요에 따라서 pre-production이나, staging 같은 환경에 따른 브랜치를 추가할 수 있습니다.

    특징

    1. 무조건 단방향으로 머지가 일어납니다.

    긴급하게 라이브 서버에 수정을 해야 할 때, production 부터 시작하는 것이 아닌, main 부터 차근차근 올라가야 합니다

    2. 환경에 따라 브랜치 종류가 늘어날 수 있습니다.

    위 사진에서는 pre-production 이 그 예시가 되겠네요.

    장점

    1. Github Flow에서 환경별 브랜치를 통해서 개발 서버나 pre-production 서버에 버전을 깔끔하게 관리할 수 있습니다.

    3. Git Flow

    브랜치 전략 중 가장 처음으로 유명해진 브랜치 전략입니다.

    배포가 특정 주기를 가지고 있는 애플리케이션일 때, 가장 적합합니다.

    가장 복잡한 전략을 가지고 있어서, 모두가 브랜치 전략에 대해서 이해하고 있다면 역할에 따른 깔끔한 분리가 가능합니다

    그림으로 보고 가도록 하겠습니다

    img4

    가장 유명한 브랜치 전략이지만, 가장 어려운 전략이기도 합니다.

    특징

    1. 브랜치에 대해서 양방향으로 머지가 일어납니다

    release 브랜치에서 버그 수정이 일어나면, develop 브랜치에도 머지해줘야 합니다.

    hotfix 브랜치를 main 브랜치뿐만 아니라, develop 브랜치에도 머지해줘야 합니다

    브랜치의 종류가 5가지나 됩니다

    1. main

    production 이 배포되었을 때, 이 브랜치에 머지되는 것이 기준이 됩니다.

    2. develop 

    위에서 설명드렸던 브랜치들과 큰 차이가 없이 배포 전 브랜치입니다.

    3. feature

    기능을 개발할 때 사용하는 브랜치입니다. 이것도 위와 큰 차이가 없습니다

    4. release

    Gitlab Flow에서 pre-production에 해당한다고 봐도 무방합니다.

    여기서 버그 수정이 일어났을 경우에,  develop에 머지하는 것을 까먹으면 안 됩니다.

    5. hotfix

    main 브랜치에서 생성된 브랜치로, 긴급한 변경사항을 처리합니다.

    이때, develop에 머지하는 것을 깜빡하면 안 됩니다.

    더 자세하게 알아보실 분은 아래 링크들을 확인해 보세요

    우리 프로젝트에는 어떤 것이 적절할까?

    나중에 개발 서버 혹은 스테이징 서버를 두고 싶기에, 이 부분에 대한 처리가 부족한 Github Flow는 적절하지 않습니다.

    Git Flow는 깔끔하게 처리할 수 있지만, 러닝 커브가 Gitlab Flow 보다 약간 더 있어서, 빠르게 개발하는 취지에 맞지 않아 보였습니다.

    이런 과정을 통해서 Gitlab Flow를 사용하려고 합니다 

    참고

    https://techblog.woowahan.com/2553/

    https://docs.gitlab.com/ee/topics/gitlab_flow.html

    + + \ No newline at end of file diff --git a/tags/google-analytics-4.html b/tags/google-analytics-4.html index 5ea18827..dd967609 100644 --- a/tags/google-analytics-4.html +++ b/tags/google-analytics-4.html @@ -5,12 +5,12 @@ "google analytics 4" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "google analytics 4" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset +

    "google analytics 4" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset no offset

    네이버에 있는 전기자동차 동호회 카페 중 가장 큰 곳에 글을 올려 방문자를 모집하기로 했습니다.

    카페에 글을 올리는 것은 무료이며, 카페에 가입한 사람들은 전기차에 관심이 있는 사람들이기 때문에 저희가 원하는 방문자를 모집하기에 적합하다고 생각했습니다.

    카카오톡 오픈채팅

    no offset no offset

    카카오톡 오픈채팅에는 수많은 대화방이 존재합니다.

    특정 주제로 만들어진 대화방이 대부분이기에 전기차를 주제로 한 오픈채팅 대화방을 찾는 것은 전혀 어렵지 않았습니다.

    안타깝게도 일부 단톡방에서 강퇴를 당했지만, 차주들과 채팅하면서 피드백을 받아볼 수 있었습니다.

    기타 홍보 수단

    기타 홍보 수단은 아직 사용하지 않았습니다.

    네이버 밴드, 보배드림은 사용하는 크루가 없어서 홍보를 하기 어려웠고, 구글 애드센스와 같은 도구는 비용이 발생하기에 아직은 이르다고 판단했습니다.

    Google Analytics 4 통계 집계 결과

    단순 방문자

    no offset no offset @@ -20,7 +20,7 @@ no offset no offset no offset

    집계 된 자료처럼 방문자들이 단순 방문만 한 것이 아니라, 수 많은 이벤트를 발생시키고 평균 참여 시간도 상당 부분 확보했음을 확인할 수 있습니다.

    - - + + \ No newline at end of file diff --git a/tags/google-map.html b/tags/google-map.html index 84960e19..13cc80b7 100644 --- a/tags/google-map.html +++ b/tags/google-map.html @@ -5,13 +5,13 @@ "googleMap" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "googleMap" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    - - +

    "googleMap" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    + + \ No newline at end of file diff --git a/tags/google-maps-api.html b/tags/google-maps-api.html index 0f895171..4602bc7f 100644 --- a/tags/google-maps-api.html +++ b/tags/google-maps-api.html @@ -5,13 +5,13 @@ "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    - - +

    "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git a/tags/google-maps-api/page/2.html b/tags/google-maps-api/page/2.html index eec5beed..3e517725 100644 --- a/tags/google-maps-api/page/2.html +++ b/tags/google-maps-api/page/2.html @@ -5,13 +5,13 @@ "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    - - +

    "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    + + \ No newline at end of file diff --git a/tags/google-maps-api/page/3.html b/tags/google-maps-api/page/3.html index 144b1883..de4ee01c 100644 --- a/tags/google-maps-api/page/3.html +++ b/tags/google-maps-api/page/3.html @@ -5,13 +5,13 @@ "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    - - +

    "google maps api" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    + + \ No newline at end of file diff --git a/tags/google-maps.html b/tags/google-maps.html index 67a7a421..4de63f98 100644 --- a/tags/google-maps.html +++ b/tags/google-maps.html @@ -5,13 +5,13 @@ "google maps" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "google maps" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    - - +

    "google maps" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    + + \ No newline at end of file diff --git a/tags/googlemaps-react-wrapper.html b/tags/googlemaps-react-wrapper.html index f7d5e786..46f8b308 100644 --- a/tags/googlemaps-react-wrapper.html +++ b/tags/googlemaps-react-wrapper.html @@ -5,13 +5,13 @@ "@googlemaps/react-wrapper" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "@googlemaps/react-wrapper" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    - - +

    "@googlemaps/react-wrapper" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    + + \ No newline at end of file diff --git a/tags/hello.html b/tags/hello.html index c200114e..550d373e 100644 --- a/tags/hello.html +++ b/tags/hello.html @@ -5,12 +5,12 @@ "hello" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "hello" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. +

    "hello" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. 그래서 저희는 아래와 같이 email을 추가합니다.

    class Member {

    private Long id;
    private String name;
    private String email;
    }

    그리고 다시 jpa의 ddl-auto 속성 중 create를 사용해서 새로운 테이블을 만들었습니다. 기존의 테이블을 다 날리면서요.

    하지만 저희의 데이터베이스의 데이터들을 그냥 drop해도 되는 것일까요? 개발 서버라도 힘들게 쌓은 데이터들을 테이블이 조금 변경되었다고 날려버리는 것은 바보같은 일이라고 생각했습니다. 그러면 ddl-auto의 다른 조건인 update를 사용하면 될 것 같습니다. 그랬더니 jpa가 아래와 같이 쿼리를 이쁘게 만들어 줬습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    update를 사용하니 아주 편하게 칼럼이 추가되는 것을 볼 수 있습니다.

    하지만 여기서 또 아래와 같은 요구사항이 추가되었습니다.

    email의 제약조건으로 null이 되면 안되고, 길이는 20자가 되어야합니다. @@ -21,7 +21,7 @@ 거기에 file을 만듭니다. 파일 이름이 중요한데요 V1__init.sql 이러한 방식으로 V{version 숫자}__{어떠한 파일인지에 대한 이름}.sql 언더스코어 2개는 필수로 작성해야합니다.

    create table member(
    id bigint auto_increment primary key,
    name varchar(255) null,
    );

    이렇게 V1__init.sql에 대한 파일을 작성했습니다. 이제는 email을 추가한다는 요구사항을 반영해보겠습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    이렇게 새로운 파일을 만들어서 해당 스크립트를 작성했습니다. 파일명이 중요한데요, 이전 파일의 숫자보다 +1 이 되는 숫자를 V 뒤에 붙입니다.

    따라서 이번 파일은 V2__add_column_email.sql 이라고 만들었습니다.

    그럼 이제 또 시간이 지나 회원이 많아졌습니다. 하지만 email이 없는 사용자도 많습니다. 이 상황에서 email을 not null로 변경해야한다는 요구사항이 생겼습니다.

    그러면 아래와 같이 반영할 수 있습니다.

    ALTER TABLE member
    MODIFY email VARCHAR(20) NOT NULL default 'default'

    이렇게 V3__add_constraints.sql 파일을 만들었습니다. 그러면 null이 있던 row들은 email이 default가 되고 not null 제약조건이 활성화 된 것을 볼 수 있습니다.

    그러면 주어진 요구사항은 모두 만족할 수 있습니다. 거기에다 v1, v2, v3 가 나뉘어져있어서 어느 커밋부터 해당 sql이 추가되었는지도 확인할 수 있습니다.

    그리고 ddl-auto update를 사용하면 반영되지 않았던 제약조건의 추가도 확인할 수 있습니다. 그러면 ddl-auto의 속성을 validate로 변경하여, db schema와 entity의 필드가 다르면 어플리케이션이 실행되지 않도록 해서 좀 더 안전한 개발을 할 수 있습니다.

    결론

    flyway는 roll back을 하는 것이 유료라서, production 서버에서 혹은 롤백을 해야하는 일이 있는 서버에서는 사용하는 것이 좋지 않지만, 이와 같이 데이터를 drop 할 수 없는 상황이라면, 사용하지 않을 이유가 없어보이는 좋은 도구입니다.

    짧은 글 읽어주셔서 감사합니다.

    - - + + \ No newline at end of file diff --git a/tags/hello/page/2.html b/tags/hello/page/2.html index 1bf37107..285e551e 100644 --- a/tags/hello/page/2.html +++ b/tags/hello/page/2.html @@ -5,13 +5,13 @@ "hello" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -
    - - +
    + + \ No newline at end of file diff --git a/tags/hibernate.html b/tags/hibernate.html index 33b180f0..d457738f 100644 --- a/tags/hibernate.html +++ b/tags/hibernate.html @@ -5,13 +5,13 @@ "Hibernate" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "Hibernate" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    - - +

    "Hibernate" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/index.html b/tags/index.html index 39ba3dcf..d1610e5c 100644 --- a/tags/index.html +++ b/tags/index.html @@ -5,12 +5,12 @@ "index" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "index" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    제이

    안녕하세요~

    우테코 카페인 팀의 제이입니다.

    오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

    요구 사항과 기능 구현 목록

    카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

    사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

    그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

    저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

    또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

    필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

    필터링 기능 구현하기

    저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

    따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

    사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

    3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

    그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

    그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

    1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

    2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

    이렇게 두 가지 방법이 있었습니다.

    1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

    // 1. fetch join + 회사 이름만 조회
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND s.companyName IN :companyNames")
    List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("companyNames") List<String> companyNames);

    // 2. fetch join + 충전 타입
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND c.type IN :types")
    List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("types") List<ChargerType> types);

    진행 했다면 이런 느낌이었겠네요!

    2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

    현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

    어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

    따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

    이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

    하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

    결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

    이 이유는 다음과 같습니다.
    1. 어차피 사용자는 작은 범위에서 조회를 한다.
    2. 인덱스를 걸었을 때 가장 효율적이다.

    1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

    저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

    이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

    JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

    그럴 순 없었을 것 같습니다.

    가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

    이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

    반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

    어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

    조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

    인덱스 적용으로 조회 속도 향상시키기

    먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

    Hibernate:
    select
    station0_.station_id as station_1_0_0_,
    ...
    ...
    ...
    chargersta2_.latest_update_time as latest_u4_2_2_
    from
    charge_station station0_
    left outer join
    charger chargers1_
    on station0_.station_id=chargers1_.station_id
    left outer join
    charger_status chargersta2_
    on chargers1_.charger_id=chargersta2_.charger_id
    and chargers1_.station_id=chargersta2_.station_id
    where
    (
    station0_.latitude between ? and ?
    )
    and (
    station0_.longitude between ? and ?
    )

    where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

    따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. +

    "index" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    제이

    안녕하세요~

    우테코 카페인 팀의 제이입니다.

    오늘은 필터링 기능 구현 및 인덱스를 이용한 조회 속도 개선하는 작업을 진행했습니다.

    요구 사항과 기능 구현 목록

    카페인 팀은 전기차 충전소 조회 및 통계 데이터를 제공해주는 서비스입니다.

    사용자 입장에서 전기차 충전소를 조회할 때 본인 차에 맞는 충전기 타입과, 속도, 마지막으로 충전기를 제공하는 회사명 요금과 관련도 되어 있어서 중요할 수 있습니다.

    그래서 무수히 많은 충전소를 보는 것이 아닌 자신에게 필요한 것만 보는 것이 사용자 경험에 있어서는 더 중요한데요.

    저희 팀은 이를 위해 필터링 기능을 도입하고자 했습니다.

    또한 조회가 많은 서비스인만큼 조회 속도 개선을 위해 인덱스를 적용하기로 했습니다.

    필터링 뿐만 아니라 해당 작업을 하면서 어떤 고민을 했고 어떤 것을 했는지 적어보고자 합니다.

    필터링 기능 구현하기

    저희 팀은 빠르게 기능을 구현하는 단계에 있습니다.

    따라서 일단 3개의 필터만 도입했고, 필터는 다음과 같습니다. [충전소 운영 회사 이름, 충전 타입, 충전 속도]

    사용자는 필터를 클릭하면 현재 위치를 기준으로 주변에 해당 필터가 적용된 충전소를 볼 수 있습니다.

    3개의 필터 중에서 모두 적용될 수도 있고, 모두 적용되지 않을 수도 있습니다.

    그래서 2^3 = 8가지의 경우를 생각해야 했었습니다.

    그래서 처음에 필터를 적용하기 위해서 다음과 같은 방법들을 생각했습니다.

    1. JPQL + 필터의 조합 (2^3)만큼 if문 사용하기

    2. 기존 좌표로 조회하는 findAllByLatitudeBetweenAndLongitudeBetween() 메서드를 사용 후 Stream을 이용해 자바 코드로 필터링하기

    이렇게 두 가지 방법이 있었습니다.

    1번의 경우 우테코 프로젝트에서 Querydsl을 사용해도 되는지 확실하지 않았고 정확한 필터 명세가 아직은 없고 3가지만 일단 도입하고자 해서 JPQL을 이용해서 상황마다 if문으로 해당 메서드를 실행시켜주는 방법이었습니다.

    // 1. fetch join + 회사 이름만 조회
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND s.companyName IN :companyNames")
    List<Station> findAllByFilteringBeingCompanyNames(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("companyNames") List<String> companyNames);

    // 2. fetch join + 충전 타입
    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value BETWEEN :minLatitude AND :maxLatitude " +
    "AND s.longitude.value BETWEEN :minLongitude AND :maxLongitude " +
    "AND c.type IN :types")
    List<Station> findAllByFilteringBeingTypes(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude,
    @Param("types") List<ChargerType> types);

    진행 했다면 이런 느낌이었겠네요!

    2번의 경우 모두 조회를하고 자바 코드를 이용해서 필터링 해주는 방법이었습니다.

    현재 저희 서비스는 좌표를 중심으로 주변 충전소를 조회합니다.

    어차피 사용자가 화면을 축소해서 큰 범위의 지도를 보는 것은 어차피 막힐테니 사용자는 작은 범위에 대해서 조회하게 됩니다.

    따라서 하나의 쿼리를 이용해서 자바 코드로 필터링 해주는 방법입니다.

    이렇게만 봤을 땐 1번 방식인 필터 별로 조회해주는 것은 조회 효율은 더 좋을 것 같습니다.

    하지만 1번의 방법은 '현재 구조'에서는 많은 쿼리문과 메서드를 작성해야하고, if문 범벅으로 보기 좋지 않은 코드가 완성 됐을 것 같습니다.

    결국 2번 방식인 [전체 조회 + 코드로 필터링] 방식을 선택했습니다.

    이 이유는 다음과 같습니다.
    1. 어차피 사용자는 작은 범위에서 조회를 한다.
    2. 인덱스를 걸었을 때 가장 효율적이다.

    1번의 이유는 위에서 말했고, 2번에 대해 간단하게 설명 드리겠습니다.

    저희 서비스는 조회가 굉장히 많지만, 충전소의 주기적인 업데이트를 위해 데이터 업데이트가 굉장히 빈번하게 일어납니다.

    이 과정에서 많지는 않지만 데이터 삽입도 발생하고, 데이터 업데이트도 많아집니다.

    JPQL로 조건을 나눠서 조회해준다면 해당하는 모든 필터에 인덱스를 걸어야할까요?

    그럴 순 없었을 것 같습니다.

    가장 효율적인 Column에 인덱스를 걸었겠죠, 그렇다면 조회마다 속도도 달라졌을 것이고 가령 해당하는 모든 Column에 인덱스를 설정해놔도 업데이트와 삽입이 느려졌을 것입니다.

    이는 7분마다 데이터를 업데이트 하는 저희 서비스에서는 적절하지 않습니다.

    반면에 한 개의 쿼리로 주변을 모두 조회하고 이를 자바 코드로 바꾸는 방법은 더 쉬웠습니다.

    어차피 많지 않은 양의 데이터를 조회하고 필터링 하기 때문에 속도 면에서도 큰 차이가 나지 않았고, 인덱스 설정에도 유리했습니다.

    조회시 이용하는 latitude와 longitude만 설정해주면 어떤 경우든 빠르게 조회를 할 수 있었습니다.

    인덱스 적용으로 조회 속도 향상시키기

    먼저 일단 현재 코드에서 조회시 다음과 같은 쿼리가 발생합니다.

    Hibernate:
    select
    station0_.station_id as station_1_0_0_,
    ...
    ...
    ...
    chargersta2_.latest_update_time as latest_u4_2_2_
    from
    charge_station station0_
    left outer join
    charger chargers1_
    on station0_.station_id=chargers1_.station_id
    left outer join
    charger_status chargersta2_
    on chargers1_.charger_id=chargersta2_.charger_id
    and chargers1_.station_id=chargersta2_.station_id
    where
    (
    station0_.latitude between ? and ?
    )
    and (
    station0_.longitude between ? and ?
    )

    where 절에서 위도 경도를 바탕으로 주변만 가져오게 됩니다. 기존에 N+1 문제가 발생해서 EntityGraph로 바꿨고 실행시 쿼리입니다.

    따라서 아래 글을 읽고 BETWEEN 쿼리에서 부등호를 이용하는 쿼리로 변경하였습니다. Mysql Query Between 과 >=, <= 성능 차이 비교 ( 더미데이터 50만 )

    @Query("SELECT DISTINCT s FROM Station s " +
    "LEFT JOIN FETCH s.chargers c " +
    "LEFT JOIN FETCH c.chargerStatus " +
    "WHERE s.latitude.value >= :minLatitude AND s.latitude.value <= :maxLatitude " +
    "AND s.longitude.value >= :minLongitude AND s.longitude.value <= :maxLongitude")
    List<Station> findAllByLatitudeBetweenAndLongitudeBetweenWithFetch(@Param("minLatitude") BigDecimal minLatitude,
    @Param("maxLatitude") BigDecimal maxLatitude,
    @Param("minLongitude") BigDecimal minLongitude,
    @Param("maxLongitude") BigDecimal maxLongitude);

    위와 같이 조회해주는 쿼리를 만들었고, 인덱스를 만들어주었습니다.

    인덱스 설정 기준은 인덱스 정리 및 팁 위에 링크와 같이 동욱님의 블로그를 참조해서 기준을 세웠습니다.

    무조건 카디널리티가 높은 것을 설정할 순 없었기 때문에 (업데이트와 삽입 작업이 많기 때문에) 쿼리에서 사용되는 column과 update 작업을 고려하고 성능을 비교해가면서 가장 효율적인 것을 설정해주었습니다.

    그리고 속도를 비교해주었습니다.



    먼저 속도 비교를 위해서 데이터 셋은 다음과 같이 진행하였습니다.
    • Charger (23만 건)
    • Station (6만 건)
    • ChargerStatus(23만 건)
    • 선릉역 근처 조회

    Ver1. 인덱스 적용을 하지 않고 조회 및 필터링 했을 때 속도 (0.84초)

    이미지 @@ -18,7 +18,7 @@ 평균적으로 0.63초가 나왔습니다. 약 25 ~ 30%의 조회 속도가 개선되었습니다.

    아직 이 부분은 개선이 더 필요해보입니다.

    그래도 개선이 됐고, 삽입과 갱신에는 큰 지장이 없어서 일단 이정도로 마무리 하고, 추후에 개선을 해보도록 하겠습니다.

    이미지 추가적으로 충전기 조회는 굉장히 빨라졌습니다!

    배우는 단계이다보니 미숙하고 틀린 부분이 있을 수 있습니다.

    긴 글 읽어주셔서 감사합니다 :)

    - - + + \ No newline at end of file diff --git a/tags/infra.html b/tags/infra.html index 6a8a40bf..81aa13b6 100644 --- a/tags/infra.html +++ b/tags/infra.html @@ -3,25 +3,17 @@ -"infra" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE +"infra" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "infra" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, -우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. -성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. -(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? -따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. -이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? -이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. -깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. -물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? -저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. -이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - +

    "infra" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    제이

    안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + \ No newline at end of file diff --git a/tags/infra/page/2.html b/tags/infra/page/2.html new file mode 100644 index 00000000..17b9a545 --- /dev/null +++ b/tags/infra/page/2.html @@ -0,0 +1,27 @@ + + + + + +"infra" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE + + + + + +
    +

    "infra" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. +성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. +(물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? +따라서 서버의 역할을 분담하고, 각 역할에 충실하도록 구현한다면 보다 효율적인 처리를 할 수 있을 것이라고 예상됩니다.

    안정적인 Merge

    잘못된 PR을 Merge 시켜버리면 어떨까요? Conflict도 날 수 있고.. 생각만해도 끔찍합니다.

    코드리뷰를 통해서 이를 어느정도 해소한다고 해도, 사람이다보니 실수할 수 있습니다. +이를 해결하기 위해서 Github Actions를 이용하여 미리 지정해둔 Task를 시키고, 이게 통과한다면 Merge할 수 있도록 할 예정입니다.

    이렇게 한다면 협업할 때에도 안전한 Merge가 가능하다고 생각합니다.

    CI/CD

    지금까지 우테코 미션에서는 배포를 다음과 같은 과정으로 진행했습니다.

    1. 배포
    2. 리팩토링 및 커밋
    3. EC2 서버에서 스크립트 실행하여 재배포

    이렇게 배포를 해도 상관없지만, 매번 리팩토링과 기능 추가를 할 때마다 EC2 서버로 들어가서 빌드 스크립트를 사용해서 서버를 재시작 해야할까요? +이렇게 된다면 불필요한 시간이 소모되고, 불편한 점이 많을 것이라고 생각됩니다.

    따라서 CI/CD 개념을 적용해서 이 과정을 자동으로 진행하고자 합니다.

    이 부분은 더 알아봐야겠지만, Github Actions를 이용해서 이를 적용하면, 외부에서 SSH 접근이 불가능하기 때문에 Jenkins를 이용할 예정입니다. +깃허브의 변동 사항을 Webhook을 이용해서 Jenkins로 넘기고, 이를 통해 CI를 적용하면 될 것 같다고 판단했습니다. +물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? +저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. +이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    + + + + \ No newline at end of file diff --git a/tags/ip.html b/tags/ip.html index 0bc1e895..e96ef8de 100644 --- a/tags/ip.html +++ b/tags/ip.html @@ -5,13 +5,13 @@ "ip" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "ip" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    - - +

    "ip" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    + + \ No newline at end of file diff --git a/tags/issue.html b/tags/issue.html index 21dac4d2..dbfc20db 100644 --- a/tags/issue.html +++ b/tags/issue.html @@ -5,14 +5,14 @@ "issue" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "issue" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 +

    "issue" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 https://blog.deering.co/commit-convention/

    - - + + \ No newline at end of file diff --git a/tags/issue/page/2.html b/tags/issue/page/2.html index 3a603128..cdaecb89 100644 --- a/tags/issue/page/2.html +++ b/tags/issue/page/2.html @@ -5,13 +5,13 @@ "issue" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "issue" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    - - +

    "issue" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    + + \ No newline at end of file diff --git a/tags/jasypt.html b/tags/jasypt.html index 29505c17..19b25328 100644 --- a/tags/jasypt.html +++ b/tags/jasypt.html @@ -5,13 +5,13 @@ "jasypt" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "jasypt" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    키아라

    서론

    안녕하세요 카페인팀 키아라입니다.

    이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

    사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

    프로퍼티 암호화는 왜 필요할까?

    spring:
    datasource:
    url: 데이터베이스 url
    username: 계정
    password: 비밀번호

    프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

    git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

    Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

    간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

    jasypt는 뭐지?

    Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

    민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

    사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

    공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

    사용 방법

    정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

    1. 라이브러리 추가 (= 의존성 추가)

    implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

    2. Jasypt 설정 및 Bean 등록

    key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

    @Configuration
    public class JasyptConfig {

    private String ENCRYPT_KEY = "hello";

    @Bean(name = "jasyptEncryptor")
    public StringEncryptor stringEncryptor() {
    PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

    SimpleStringPBEConfig config = new SimpleStringPBEConfig();

    config.setPassword(ENCRYPT_KEY);
    config.setAlgorithm("PBEWithMD5AndDES");
    config.setKeyObtentionIterations(1000);
    config.setPoolSize(1);
    config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
    config.setStringOutputType("base64");
    encryptor.setConfig(config);
    return encryptor;
    }
    }
    jasypt:
    encryptor:
    bean: jasyptEncryptor

    3. 암호화

    라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

    이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

    암복호화 사이트

    평문

      datasource:
    url: 데이터베이스 url
    username: 계정
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    나머지도 마저 암호화해줍시다.

      datasource:
    url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
    username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    실행

    올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

    그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

    실행 실패

    그런데 뭔가 이상하지 않나요?

    프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

    Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

    그럼 이 키를 어디에 숨길 수 있을까요?

    저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

    그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

    바로 환경변수를 설정하는 것이죠.

    + 환경변수 설정

    private String ENCRYPT_KEY = "hello";

    기존의 키를 관리하는 방식이었습니다.

    우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

    // JasyptConfig.class
    @Value("${jasypt.encryptor.password}")
    private String ENCRYPT_KEY;
    // application.yml
    jasypt:
    encryptor:
    password: hello

    이제 환경변수를 설정해봅시다.

    Run > Edit Configurations... 경로로 들어가면

    Run/Debug Configurations 창이 나오는데

    Environment variables: 부분에 ENCRYPT_KEY=hello

    라고 적어주세요.

    그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

    jasypt:
    encryptor:
    password: ${ENCRYPT_KEY}

    긴 글 읽어주셔서 감사합니다.

    - - +

    "jasypt" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    키아라

    서론

    안녕하세요 카페인팀 키아라입니다.

    이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

    사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

    프로퍼티 암호화는 왜 필요할까?

    spring:
    datasource:
    url: 데이터베이스 url
    username: 계정
    password: 비밀번호

    프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

    git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

    Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

    간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

    jasypt는 뭐지?

    Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

    민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

    사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

    공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

    사용 방법

    정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

    1. 라이브러리 추가 (= 의존성 추가)

    implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

    2. Jasypt 설정 및 Bean 등록

    key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

    @Configuration
    public class JasyptConfig {

    private String ENCRYPT_KEY = "hello";

    @Bean(name = "jasyptEncryptor")
    public StringEncryptor stringEncryptor() {
    PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

    SimpleStringPBEConfig config = new SimpleStringPBEConfig();

    config.setPassword(ENCRYPT_KEY);
    config.setAlgorithm("PBEWithMD5AndDES");
    config.setKeyObtentionIterations(1000);
    config.setPoolSize(1);
    config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
    config.setStringOutputType("base64");
    encryptor.setConfig(config);
    return encryptor;
    }
    }
    jasypt:
    encryptor:
    bean: jasyptEncryptor

    3. 암호화

    라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

    이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

    암복호화 사이트

    평문

      datasource:
    url: 데이터베이스 url
    username: 계정
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    나머지도 마저 암호화해줍시다.

      datasource:
    url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
    username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    실행

    올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

    그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

    실행 실패

    그런데 뭔가 이상하지 않나요?

    프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

    Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

    그럼 이 키를 어디에 숨길 수 있을까요?

    저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

    그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

    바로 환경변수를 설정하는 것이죠.

    + 환경변수 설정

    private String ENCRYPT_KEY = "hello";

    기존의 키를 관리하는 방식이었습니다.

    우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

    // JasyptConfig.class
    @Value("${jasypt.encryptor.password}")
    private String ENCRYPT_KEY;
    // application.yml
    jasypt:
    encryptor:
    password: hello

    이제 환경변수를 설정해봅시다.

    Run > Edit Configurations... 경로로 들어가면

    Run/Debug Configurations 창이 나오는데

    Environment variables: 부분에 ENCRYPT_KEY=hello

    라고 적어주세요.

    그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

    jasypt:
    encryptor:
    password: ${ENCRYPT_KEY}

    긴 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git a/tags/java-11.html b/tags/java-11.html index 8571a218..2633c705 100644 --- a/tags/java-11.html +++ b/tags/java-11.html @@ -5,13 +5,13 @@ "java11" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "java11" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    - - +

    "java11" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    + + \ No newline at end of file diff --git a/tags/java-17.html b/tags/java-17.html index 0fdc48a5..5b1fbfbc 100644 --- a/tags/java-17.html +++ b/tags/java-17.html @@ -5,12 +5,12 @@ "java17" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "java17" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, +

    "java17" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    제이

    서론

    안녕하세요👋👋 카페인 팀의 제이입니다.

    회의를 하면서 이번 주 제가 맡은 파트는 서버 인프라입니다.

    아직은 EC2 스펙과 데이터들이 정확히 나오진 않았지만, 우테코에서 적은 EC2 스펙을 제공한다는 기준으로 계획도를 적어볼 생각입니다.

    상황 인식

    예상하는 상황은 다음과 같습니다.
    • API의 데이터를 다루는 상황에서 최소 약 150만 건에서 최악 약 3700만 건의 데이터를 다룹니다.
    • 이전 기수를 봤을 때 EC2의 개수는 많이 나눠주는 것으로 파악 됐습니다. (이 부분은 달라질 수 있습니다.)
    • 상황에 따라서 공공 API를 업데이트 해주는 서버와, 제공 서버를 나눌 수 있습니다.
    • Conflict가 나지 않기 위해서 안정적인 검증을 거친 후 Merge를 해야합니다.
    • 프로젝트의 버전이 갱신된다면 EC2 서버에서 자동으로 스크립트를 작동시켜 Pull 및 서버 재배포를 해야합니다.
    • 서버의 버전이 바뀌는 경우 기존 서버를 끄고 새로운 서버를 키면 사용자가 이용할 수 없는 텀이 생기기 때문에 무중단 배포를 해야합니다.

    문제점

    위에 상황에서 파악되는 문제점들은 먼저 적은 성능의 EC2 서버로 인해 데이터를 받아오는 과정 혹은 업데이트 과정에서 서버가 터질 수도 있습니다. 성능이 좋다면 하나로 모든 것을 할 수 있지만, 그렇지 않기 때문에 현재 여러 개의 EC2를 기준으로 아키텍처를 구성할 예정입니다.

    문제 해결을 위한 현재 생각

    서버의 기능 분산

    위에서 언급한 것처럼 서버의 성능이 받쳐주지 못할 가능성이 있습니다. 성능을 생각해서 이를 나누기 위해서는 먼저 다음과 같이 서버를 분산할 필요가 있다고 생각합니다. (물론 서버가 못 버틸 경우이고, 어떻게 나뉘는 지는 회의 후 결정하겠지만!)

    • 공공 API 데이터 적재 및 주기적인 업데이트
    • 실시간 혼잡도를 위한 실시간 데이터 업데이트
    • 요청 처리

    적은 성능으로 업데이트와 요청 처리를 동시에 한다면, 서버가 그 부하를 견디지 못할 수도 있겠죠? @@ -21,7 +21,7 @@ 물론 이는 계획이고 공부하지 않은 다른 내용이 있을 수 있기 때문에 언제든 바뀔 수 있습니다.

    무중단 배포 아키텍처 적용

    이 또한 아직은 먼 이야기지만, 고려해 볼 상황이라서 적어봤습니다.

    사용자가 이용하고 있는 서비스가 갑자기 중단된다면 어떨까요? 저는 화가 많이 날 것 같습니다.

    피치 못할 사정으로 서버가 터져도, 사용자가 서비스를 계속 이용할 방법이 없을까요?

    이런 고민을 해결하기 위해서 나온 개념이 무중단 배포입니다.

    카나리아 배포, Blue/Green 배포, 롤링등 무중단 배포를 위한 여러가지 전략은 이미 존재합니다. 이 부분은 아직은 서버의 명세가 정확하지 않아서 어떤 방식으로 어떻게 처리할 것인지에 대해서는 아직 정할 수는 없습니다.

    이는 명세가 확실하게 정해진 후 팀원과 장단점을 상의하며 결정할 일이기 때문에 현재까지는 "이 정도를 고려하고 있다." 정도만 알면 될 것 같습니다.

    - - + + \ No newline at end of file diff --git a/tags/java-17/page/2.html b/tags/java-17/page/2.html index c842638c..6cf50056 100644 --- a/tags/java-17/page/2.html +++ b/tags/java-17/page/2.html @@ -5,13 +5,13 @@ "java17" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "java17" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    - - +

    "java17" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    + + \ No newline at end of file diff --git a/tags/java.html b/tags/java.html index 9098240d..e62a63d2 100644 --- a/tags/java.html +++ b/tags/java.html @@ -5,12 +5,12 @@ "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    저희 서비스에서는 주기적으로 충전기의 상태와 정보를 업데이트하거나, 통계를 저장하는 스케줄링 작업이 있습니다. +

    "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    저희 서비스에서는 주기적으로 충전기의 상태와 정보를 업데이트하거나, 통계를 저장하는 스케줄링 작업이 있습니다. 지금의 저희 서버는 단일 서버로 구성되어있어 문제가 없지만, 만약 서버를 scale-out 하게 된다면 어떻게 될까요?

    똑같은 schedule이 중복되어 실행될 것입니다. 그렇다고 어떤 서버는 schedule을 동작하지 않도록 하고, 어떤 서버는 schedule을 동작하도록 한다면 스케줄이 동작하는 서버가 다운된다면 동작하는 서버의 다운타임만큼 저희 서버의 데이터를 최신화할 수 없고, 최신화가 중요한 저희 서비스에서는 사용자의 불만을 초래할 수 있습니다.

    구현해보기

    Schedule 정보를 어떻게 다른 환경에서 같이 공유하여 관리할 수 있을까요? 간단히 생각하면 Local 환경이 아닌, Global 환경에서 정보를 관리하면 될 것 같습니다.

    따라서 Schedule의 정보를 저장할 수 있는 테이블을 아래의 Entity 의 필드와 같이 생성해보겠습니다.

    @Entity
    public class ScheduleTask extends BaseEntity {

    @Id
    private String id;

    private String jobName;

    @Enumerated(EnumType.STRING)
    private JobStatus status;
    }

    먼저 id는 해당 스케줄을 구분할 수 있는 id여야 할 것입니다. 가장 쉽게 정할 수 있는 id는 스케줄의 job 이름과, @@ -23,7 +23,7 @@ 따라서 Schedule Thread Pool Size를 늘리도록 하겠습니다.

    @Configuration
    public class ScheduleConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
    ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
    taskScheduler.setPoolSize(10);
    taskScheduler.setThreadNamePrefix("schedule-task-");
    taskScheduler.initialize();
    taskRegistrar.setTaskScheduler(taskScheduler);
    }
    }

    SchedulingConfigurer 를 구현하여 Thread Pool size를 일단 10개로 정의했습니다.

    success 스레드 풀을 늘렸더니 위와 같이 2의 배수의 시간에 정확히 작동이 되는 것을 확인할 수 있습니다.

    하지만 이렇게 여러 작업을 동시에 실행된다면 데이터베이스에 병목현상이 발생되어 오히려 작업이 더 느리게 끝날 수도 있다고 생각했습니다.

    그래서 해당 부분의 실행을 관리하는 클래스를 생성하여 해당 클래스에서 Schedule의 작업을 관리하도록 구현했습니다.

    @Service
    public class BusinessLogic {

    private final ApplicationEventPublisher applicationEventPublisher;

    @Scheduled(cron = "0/2 * * * * *")
    public void complexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::complexJob, "complexJob", LocalDateTime.now()));
    }

    @Scheduled(cron = "0/4 * * * * *")
    public void moreComplexJobSchedule() {
    applicationEventPublisher.publishEvent(new SchedulingEvent(this::moreComplexJob, "moreComplexJob", LocalDateTime.now()));
    }
    }

    로직이 있는 BusinessLogic 서비스에서 스케줄의 시간마다 실행해야할 메서드를 Event로 발행합니다.

    @Component
    public class ScheduleService {

    private final ExecutorService executorService = Executors.newFixedThreadPool(1);
    private final Queue<SchedulingEvent> scheduleTasks = new ConcurrentLinkedQueue<>();
    private final AtomicBoolean isRunning = new AtomicBoolean(false);

    @EventListener
    public void addTask(SchedulingEvent schedulingEvent) {
    scheduleTasks.add(schedulingEvent);
    }

    @Scheduled(cron = "0/1 * * * * *")
    public void polling() {
    if (!scheduleTasks.isEmpty() || isRunning.compareAndSet(false, true)) {
    SchedulingEvent schedulingEvent = scheduleTasks.poll();
    executorService.execute(() -> execute(schedulingEvent));
    }
    }
    }

    그리고 위와 같은 스케줄을 관리하는 서비스에서는 Schedule Event를 받아 실행하도록 만들었습니다. 해당 클래스에서는 ThreadPool을 새로 생성하여, schedule의 스레드에 영향을 받지 않도록 구현했습니다.

    그리고 1초마다 실행되는 스케줄을 만들어 queue에 작업이 있는지, 현재 작업 중인지 확인하여 그렇지 않다면 queue에서 작업을 꺼내 실행하도록 만들었습니다.

    거의 구현이 끝나갑니다. 이제는 해당 Schedule의 데이터를 저장하고, 작업이 실패했을 시에 다시 작업을 하기 위한 기능만 구현하면 될 것 같습니다.

    @Component
    public class ScheduleService {

    ...

    private void execute(SchedulingEvent schedulingEvent) {
    String jobId = schedulingEvent.jobId();
    LocalDateTime executionTime = schedulingEvent.executionTime();

    if (isJobInProgressOrDone(jobId)) {
    log.info("작업이 실행중입니다. {} {}", executionTime, jobId);
    return;
    }
    ScheduleTask entity = new ScheduleTask(jobId, executionTime, JobStatus.RUNNING);
    scheduleTaskJdbcRepository.save(entity);

    try {
    schedulingEvent.runnable().run();
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.DONE);
    } catch (Exception e) {
    log.error("{} 작업 실행 중 에러가 발생했습니다.", jobId);
    scheduleTaskJdbcRepository.updateById(entity.getId(), JobStatus.ERROR);
    tasks.add(schedulingEvent);
    }
    }

    private boolean isJobInProgressOrDone(String jobId) {
    Optional<ScheduleTask> taskOptional = scheduleTaskRepository.findById(jobId);
    if (taskOptional.isPresent()) {
    ScheduleTask scheduleTask = taskOptional.get();
    return scheduleTask.getStatus() == JobStatus.RUNNING || scheduleTask.getStatus() == JobStatus.DONE;
    }
    return false;
    }
    }

    이 부분은 간단하게 구현할 수 있습니다. 위와 같이 작업의 실행 시간과, job의 이름으로 데이터베이스에서 조회하고, 없다면 작업을 실행하고 있다면 작업이 ERROR 인지 확인하여 작업을 실행해주면 될 것 같습니다.

    complete

    위와 같이 두 개의 서버를 동시에 띄웠을 때에도 스케줄이 잘 작동하는 것을 확인할 수 있습니다.

    결론

    스케줄을 이렇게 구현할 수도 있지만 환경이 된다면 Message Queue를 사용하는 것이 어떨까요?

    혹시 틀린 부분이 있다면 지적 부탁드립니다.

    - - + + \ No newline at end of file diff --git a/tags/java/page/2.html b/tags/java/page/2.html index bb4ef648..883636c4 100644 --- a/tags/java/page/2.html +++ b/tags/java/page/2.html @@ -5,18 +5,18 @@ "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    이전 글에서도 계속 설명했듯이 조회 성능을 최대한 빠르게 하는 것이 저희 서비스에서 핵심이라고 생각하기 때문에 지금도 예전에 비해 빨라졌지만 다른 개선점이 보여 개선을 하고자합니다.

    조회 성능 개선하기 1 (인덱스)

    조회 성능 개선하기 2 (데이터베이스 복제)

    결론

    결론부터 말씀드리면 로컬에서 캐싱을 적용한 후 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 78 -> 128

    Response Time 1236 ms -> 751 ms

    64% 성능이 개선 되었습니다.

    (저번 성능 테스트의 결과가 다른 이유는 비즈니스 로직이 변경되어 조회 방식이 바뀌었기 때문입니다. 그래서 캐싱을 적용하기전, 한 후 를 비교했습니다.)

    Caching

    In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.

    캐싱은 위키 백과에서 위와 같이 설명하고 있습니다. 즉 메모리에 데이터를 복사본을 올려 좀 더 빠르게 데이터에 접근하는 방식입니다.

    캐싱의 단점은 수정, 삽입, 삭제가 되었을 때, 관리 포인트가 두 군데가 된다는 점입니다. 만약 데이터베이스에만 새로운 정보를 저장하고, 캐시에는 저장해주지 않는다면 사용자는 그 정보를 볼 수 없습니다.

    하지만 저희 서비스에서 적용한 이유는 충전기의 충전 상태 (충전 중, 대기중, 고장)에 대한 정보는 최신화가 되어야하지만, 충전소의 이름이라던지, 위치, 다른 정보들은 쉽게 변하지 않기 때문에 해당 정보를 캐싱한다면 좋을 것 같았습니다.

    캐싱 적용하기

    먼저 캐싱을 어디에서 하는지도 중요합니다. 크게 로컬 캐시글로벌 캐시로 나눌 수 있을 것 같습니다. +

    "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    이전 글에서도 계속 설명했듯이 조회 성능을 최대한 빠르게 하는 것이 저희 서비스에서 핵심이라고 생각하기 때문에 지금도 예전에 비해 빨라졌지만 다른 개선점이 보여 개선을 하고자합니다.

    조회 성능 개선하기 1 (인덱스)

    조회 성능 개선하기 2 (데이터베이스 복제)

    결론

    결론부터 말씀드리면 로컬에서 캐싱을 적용한 후 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 78 -> 128

    Response Time 1236 ms -> 751 ms

    64% 성능이 개선 되었습니다.

    (저번 성능 테스트의 결과가 다른 이유는 비즈니스 로직이 변경되어 조회 방식이 바뀌었기 때문입니다. 그래서 캐싱을 적용하기전, 한 후 를 비교했습니다.)

    Caching

    In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.

    캐싱은 위키 백과에서 위와 같이 설명하고 있습니다. 즉 메모리에 데이터를 복사본을 올려 좀 더 빠르게 데이터에 접근하는 방식입니다.

    캐싱의 단점은 수정, 삽입, 삭제가 되었을 때, 관리 포인트가 두 군데가 된다는 점입니다. 만약 데이터베이스에만 새로운 정보를 저장하고, 캐시에는 저장해주지 않는다면 사용자는 그 정보를 볼 수 없습니다.

    하지만 저희 서비스에서 적용한 이유는 충전기의 충전 상태 (충전 중, 대기중, 고장)에 대한 정보는 최신화가 되어야하지만, 충전소의 이름이라던지, 위치, 다른 정보들은 쉽게 변하지 않기 때문에 해당 정보를 캐싱한다면 좋을 것 같았습니다.

    캐싱 적용하기

    먼저 캐싱을 어디에서 하는지도 중요합니다. 크게 로컬 캐시글로벌 캐시로 나눌 수 있을 것 같습니다. 글로벌 캐시의 장점은 스케일 아웃을 했을 때, 모든 서버가 다 같은 데이터를 바라보기 때문에 데이터 정합성이 좋아집니다. 하지만 저희 서비스는 단일 서버로 구성되어 있기 때문에, 로컬 캐시를 해도 문제가 없습니다. 그리고 글로벌 캐시를 적용하기 위해서는 Redis나 Memcached 같은 도구를 모든 팀원이 알아야하지만 로컬 캐시는 그렇게 하지 않더라도 편하게 적용할 수 있다는 점에서 로컬에 캐싱하는 방법을 적용해보겠습니다.

    캐싱할 정보 가져오기

    캐싱을 하기 위해서는 먼저 캐싱할 데이터를 가져와야합니다. 저희 서비스는 출장 혹은 여행을 가는 전기차 오너가 핵심 페르소나이기 때문에 사용자들이 찾는 정보의 위치는 불특정합니다. 서울에서 다른 지방으로 출장을 가는 경우도 있을 것이고, 지방에서 서울에 가는 경우도 있기 때문에, 모든 데이터를 캐싱해야할 것이라 판단했습니다.

    그래서 어플리케이션 실행 시에 모든 충전소를 캐싱하기로 선택했습니다.

    @Configuration
    public class InitialStationCache implements ApplicationRunner {

    private final StationCacheRepository stationCacheRepository;
    private final StationQueryRepository stationQueryRepository;

    @Override
    public void run(ApplicationArguments args) {
    log.info("Initialize station cache");
    List<StationInfo> stations = stationQueryRepository.findAll();
    stationCacheRepository.initialize(stations);
    log.info("Station cache initialized");
    log.info("Station cache size: {}", stations.size());
    }
    }

    위와 같이 ApplicationRunner를 구현하여 어플리케이션 실행 시 모든 충전소의 정보를 가져오도록 만들었습니다. 여기서 Entity인 Station을 가져오지 않은 이유는 크게 두가지가 있습니다.

    1. 지도로 조회하는 부분의 성능을 개선하고자 했지만, Entity에는 지도를 조회할 때 불필요한 정보도 있기 때문에 메모리상의 낭비가 생길 수 있습니다.
    2. Entity를 캐싱하게 된다면 hibernate 1차 캐시에도 적재되고, 힙 메모리에도 적재되는 일이 발생하여 메모리상 낭비라고 생각했습니다.

    범위 검색하기

    충전소의 데이터를 조회하는 조건은 위도, 경도의 최소, 최대값을 기준으로 만족하는 데이터를 보여줍니다. 아래와 같이 간단히 조건을 stream()의 filter()를 사용해서 구현했습니다.

    public class StationCacheRepository {

    private final List<StationInfo> cachedStations;

    public List<StationInfo> findByCoordinate(
    BigDecimal minLatitude,
    BigDecimal maxLatitude,
    BigDecimal minLongitude,
    BigDecimal maxLongitude
    ) {
    return cachedStations.stream()
    .filter(it -> it.latitude().compareTo(minLatitude) >= 0 && it.latitude().compareTo(maxLatitude) <= 0)
    .filter(it -> it.longitude().compareTo(minLongitude) >= 0 && it.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }
    }

    하지만 해당 방법으로 로컬에서 조회를 테스트 했을 때 캐시를 적용한 것보다 더 느려진 결과가 나왔습니다. 캐싱을 해서 데이터베이스까지 요청을 보내지 않는데 왜 더 느려진 것일까요?

    답은 인덱스 였습니다. Mysql 에서 인덱스는 B Tree로 구성되어 있습니다. 데이터베이스에서는 위도, 경도로 복합 인덱스가 설정되어 있었지만, 현재 어플리케이션 로직에는 해당 부분이 없습니다.

    그래서 filter로 순회하는 시간복잡도가 O(n)이고, 데이터베이스에서는 O(log n)이기 때문에 더 느려진 것입니다. 그렇다고 제가 직접 B tree 자료구조를 직접 구현해야할까요?

    현재 해당 조회 API는 위도 경도로 범위 탐색을 하고 있습니다. 결국엔 station의 정보들이 위도, 경도로 정렬만 되어 있다면 B tree를 직접 구현하지 않더라도 같은 시간복잡도 O(log n)으로 탐색할 수 있습니다. 물론 B tree와 다른 부분은 해당 충전소의 정확한 위도, 경도로 단일 칼럼을 조회할 때는 O(n)이기 때문에 이런 방법이 문제가 될 수 있지만, 해당 캐시 데이터로는 무조건 범위 탐색을 하기 때문에, B tree를 구현하지 않고 이분 탐색으로 조회하는 방식으로 변경해보겠습니다.

        public void initialize(List<StationInfo> stations) {
    cachedStations.addAll(stations);
    cachedStations.sort((o1, o2) -> {
    int latitudeCompare = o1.latitude().compareTo(o2.latitude());
    if (latitudeCompare == 0) {
    return o1.longitude().compareTo(o2.longitude());
    }
    return latitudeCompare;
    });
    }

    private List<StationInfo> findStations(BigDecimal minLatitude, BigDecimal maxLatitude, BigDecimal minLongitude, BigDecimal maxLongitude) {
    int lowerBound = binarySearch(minLatitude, START_INDEX);
    int upperBound = binarySearch(maxLatitude, lowerBound);
    if (lowerBound == -1 || upperBound == -1) {
    return Collections.emptyList();
    }
    return cachedStations.stream()
    .skip(lowerBound)
    .limit(upperBound - lowerBound)
    .filter(station -> station.longitude().compareTo(minLongitude) >= 0 && station.longitude().compareTo(maxLongitude) <= 0)
    .toList();
    }

    private int binarySearch(BigDecimal latitude, int startIndex) {
    int left = startIndex;
    int right = cachedStations.size() - 1;
    int result = -1;
    while (left <= right) {
    int middle = left + (right - left) / 2;
    StationInfo middleStation = cachedStations.get(middle);
    if (middleStation.latitude().compareTo(latitude) >= 0) {
    result = middle;
    right = middle - 1;
    } else {
    left = middle + 1;
    }
    }
    return result;
    }

    먼저 어플리케이션이 실행될 때 cache 데이터를 찾아 저장하는 것 뿐만 아니라, 위도(Latitude)를 기준으로 정렬하도록 만들었습니다. 그리고 위도의 최소, 최대값의 인덱스를 가장 효율적으로 찾아올 수 있도록 binary search를 하는 메서드를 만들었습니다. 이렇게 한다면 O(log n) 으로 위도의 최대 최소 조건에 포함되는 모든 station의 값을 조회할 수 있습니다. 그리고 조회한 데이터들의 개수만큼 filter를 통해 경도(longitude) 가 포함되는지 확인합니다. 해당 방식의 구현은 B tree가 작동하는 방식과 유사할 것입니다.

    이분 탐색을 적용한 결과 로컬에서 응답 속도가 120 ms -> 50 ~ 70 ms로 약 2배 빨라진 것을 확인할 수 있습니다.

    실시간이 중요한 데이터는?

    앞서 말씀드렸다시피 지도로 충전소를 조회할 때, 충전소의 정보들에는 바뀌지 않는 정보뿐만 아니라, 최신화해야하는 충전기의 현재 상태 정보가 있습니다. 이러한 정보들은 캐싱해둘 수 없습니다. 하더라도, 관리 포인트가 늘어나기 때문에 데이터베이스에서 캐싱해둔 충전기 id로 충전기의 상태를 찾아와서 정보를 합쳐 반환하는 식으로 만들 수 있습니다.

        select cs.station_id,
    sum(case
    when cs.charger_condition = 'STANDBY' then 1
    else 0
    end)
    from charger_status cs
    where cs.station_id in (?, ?, ?, ?, ?, ?, ?)
    group by cs.station_id

    위와 같은 쿼리로 해당 충전소의 최신화된 충전기 상태를 가져올 수 있습니다.

    캐싱을 하기전에 데이터베이스를 이용해 데이터를 가져올 때의 쿼리는 아래와 같습니다.

     select
    distinct s.station_id
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    where
    s.latitude>=?
    and s.latitude<=?
    and s.longitude>=?
    and s.longitude<=?
    -------------------------------------------------
    select
    s.station_id,
    s.station_name,
    s.latitude,
    s.longitude,
    s.is_parking_free,
    s.is_private,
    sum(case
    when cs.charger_condition='STANDBY' then 1
    else 0
    end),
    sum(case
    when c.capacity>=50 then 1
    else 0
    end)
    from
    charge_station s
    inner join
    charger c
    on (
    c.station_id=s.station_id
    )
    inner join
    charger_status cs
    on (
    c.station_id=cs.station_id
    and c.charger_id=cs.charger_id
    )
    where
    s.station_id in (
    ?,?,?,?
    )
    group by
    s.station_id

    원래는 위와 같이 여러번의 Join을 하고, 2번의 쿼리가 나갔던 반면 지금은 join을 하지않는 한번의 깔끔한 쿼리로 개선되었습니다.

    그리고 station 테이블의 위도, 경도로 범위 탐색을 위해 생성했던 index도 제거할 수 있게 되었습니다!

    결론

    1. 캐싱할 수 있는 부분은 하는 것도 좋을 것 같습니다
    2. 시간 복잡도를 계산해봅시다.
    3. 성능 개선 재밌습니다.
    - - + + \ No newline at end of file diff --git a/tags/java/page/3.html b/tags/java/page/3.html index b39e4d73..d7e26c45 100644 --- a/tags/java/page/3.html +++ b/tags/java/page/3.html @@ -5,12 +5,12 @@ "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. +

    "java" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. error-log

    왜 발생했을까

    먼저 간단히 저희가 처한 상황에 대해 설명드리겠습니다.

    처음 어플리케이션을 실행하면 공공 API를 호출하여 충전소와 충전기에 대한 모든 정보들을 가져와 저장합니다. (충전소 약 6만 곳 + 충전기 약 23만 기)

    하지만 이러한 정보들은 수정이 될 수 있고, 충전소와 충전기가 추가될 수 있습니다.

    그러므로 정확한 정보가 사용자에게 가장 중요시되는 서비스에서 이러한 정보들이 늦게 반영이 된다거나, 반영이 되지 않는다면 저희 서비스를 사용할 사용자가 없을 것이라 판단했습니다.

    그래서 하루에 한 번 충전소와 충전기들의 정보를 업데이트하고, 추가된 충전소와 충전기를 저장하는 로직을 만들었습니다.

    대략적인 로직은 아래와 같습니다.

        public void updatePeriodicStations() {
    List<Station> stations = requestStations();
    stationUpdateService.updateStations(stations);
    }

    public void updateStations(List<Station> updatedStations) {
    List<Station> stations = stationRepository.findAllFetch();

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    }

    간단하게 말씀드리면 requestStations() 메서드는 공공 API에서 모든 충전소와 충전기를 요청하고 받아오는 메서드입니다. 23만 + 6만개의 정보를 받아오는 것입니다. 이렇게 많은 정보를 받아오고 메모리에 올린다는 것은 누가봐도 비효율적입니다. 하지만 이러한 선택을 한 이유는 공공 API는 저희가 어떤 방식으로 보내줄 지 모른다는 것이였습니다. 그래서 어쩔 수 없이 23만건을 모두 요청해야한다는 부분은 바꿀 수 없는 한계입니다.

    그 다음으로는 요청해서 받아온 데이터들과 데이터베이스에 저장되어 있던 데이터들을 findAll()을 통해 비교하고 새로운 충전소와 충전기는 저장하고, 업데이트된 충전소와 충전기는 수정합니다.

    이런 로직은 총 (23 + 6) * 2 만건의 객체 약 58만개를 Heap 메모리에 적재합니다. 많다고는 생각했지만, 일단 제 로컬환경에서는 잘 작동했고, 기능 구현이 우선이기 때문에 추후에 개선을 하기로 하고 넘어갔습니다.

    하지만 개발 서버 배포를 하고 다음날 서버가 접속이 되지 않는 것을 확인했고, 로그를 보니 위의 사진과 같이 OOM이 발생한 것을 확인할 수 있었습니다.

    해결 방안

    Heap size 조절하기

    일단 임시 방편으로 Heap memory의 최대 크기를 늘리는 법이였습니다. JVM은 실행되는 환경에 따라 힙 메모리의 최대 사이즈를 정합니다. 힙 메모리는 설정하지 않으면 해당 환경의 메모리 1/4로 설정합니다. @@ -31,7 +31,7 @@ 하지만 직접 확인해보기 전까지는 확신할 수 없으니 간단히 Runtime 클래스에서 제공해주는 totalMemory(), freeMemory() 메서드를 통해 알아보겠습니다.

        @Test
    void 페이징을_사용한_조회() {
    List<Station> stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();
    System.out.println("paging 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    @Test
    void 페이징을_사용하지_않고_조회() {
    List<Station> stations = stationRepository.findAllFetch();

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();

    System.out.println("findAll() 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    findAll paging 확연히 차이가 나는 것을 확인할 수 있습니다.

    물론 테스트코드에서는 23만건의 API 요청은 같은 조건이니 배제하고 확인했습니다.

    이로써 하나의 문제가 또 해결된 것 같습니다.

    아직 배우는 단계라 혹시 틀린 점이 있다면 지적 부탁드리겠습니다.

    Reference

    - - + + \ No newline at end of file diff --git a/tags/jpa.html b/tags/jpa.html index 95c972d9..b5f003e5 100644 --- a/tags/jpa.html +++ b/tags/jpa.html @@ -5,12 +5,12 @@ "jpa" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "jpa" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 10분
    박스터

    안녕하세요 박스터 입니다.

    먼저 이번에 글을 쓰게된 계기를 말씀드리겠습니다. 저희 팀은 공공 데이터 API에서 받아온 충전소와, 충전기들의 ID를 그대로 사용하고 있습니다. +

    "jpa" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 10분
    박스터

    안녕하세요 박스터 입니다.

    먼저 이번에 글을 쓰게된 계기를 말씀드리겠습니다. 저희 팀은 공공 데이터 API에서 받아온 충전소와, 충전기들의 ID를 그대로 사용하고 있습니다. 물론 다른 API, 제가 제어할 수 없는 곳에 의존하는 것은 좋지 않다고 생각합니다.

    하지만 데이터를 받아오는 과정에서 마주한 성능적인 문제 때문에 그대로 사용하고 있습니다. 전국의 충전소는 6만개, 충전소 안에 존재하는 충전기는 23만기입니다. 하지만 공공 데이터는 충전소와, 충전기의 정보를 따로 제공하는 것이 아닌 중복된 충전소를 포함한 데이터를 충전기 개수만큼인 23만개의 row로 제공합니다.

    따라서 저희가 ID를 따로 부여하게 된다면, 충전소를 저장하는 과정에서 받아오는 ID로 충전기를 연결해줘야하는데 그렇게 된다면 셀 수 없이 많은 쿼리가 발생합니다.

    잠깐 생각해본다면

    1. 충전소를 각각 저장하고 ID를 부여받는 쿼리 6만번 (ID를 알아와야하기 때문에 batch를 사용할 수 없습니다.)
    2. 충전소에서 받아온 ID를 충전기에 매핑하고 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

    하지만 ID를 그대로 사용하게 된다면,

    1. 충전소를 저장하는 쿼리 최소 1번 (만약 batch로 6만건을 한번에 저장한다는 가정)
    2. 충전기를 저장하는 쿼리 최소 1번 (만약 batch로 23만건을 한번에 저장한다는 가정)

    23만건이 넘는 정보를 확인했을 때, ID는 중복되지 않았고, 중복하지 않을 것이라 생각했습니다. 그 뿐만 아니라 처음 한번만 저장하는 것이 아닌 주기적으로 업데이트된 정보를 반영해주고 update or save 해주어야하기 때문에, ID를 그대로 가지고 있는 것이 훨씬 효율적이라 생각했습니다.

    사족이 길었습니다. 각설하고 이런 방식으로 ID를 직접 넣어주는 경우 발생하는 문제에 대해 말씀드리겠습니다.

    ID를 직접 넣어준 Entity를 저장할 때

    먼저 간단한 예제 Entity로 설명드리겠습니다.

    @Entity
    public class ChargeStation {

    @Id
    private String stationId;

    private String stationName;

    ...
    }

    보통의 Entity와 다른 부분은 Id를 직접 할당하기 때문에 @GeneratedValue(strategy = GenerationType.IDENTITY) 이러한 ID 생성 전략에 대한 정보가 없습니다.

    그리고 save() 코드를 호출하면 어떤 쿼리가 나가는지 확인해보겠습니다. 아래와 같이 아주 간단한 선릉역 충전소를 저장하는 테스트를 실행해보겠습니다.

    @DataJpaTest
    class ChargeStationRepositoryTest {

    @Autowired
    private ChargeStationRepository chargeStationRepository;

    @Test
    void 충전소를_저장한다() {
    ChargeStation station = ChargeStationFixture.선릉역_충전소_충전기_2개_사용가능_1개;

    chargeStationRepository.save(station);

    ChargeStation expect = chargeStationRepository.findByStationId(station.getStationId()).get();
    assertThat(expect).isEqualTo(station);
    }
    }

    먼저 코드만 보면 먼저 chargeStationRepository.save() 호출과 함께 insert 쿼리 1번, 그리고 chargeStationRepository.findByStationId()에서 select 쿼리 1번 @@ -22,7 +22,7 @@ 아주 간단하게 entity의 isNew()를 호출한다고 적혀있습니다. 하지만 Persistable 인터페이스를 구현한 Entity의 isNew() 를 호출하는 것 입니다.

    그럼 남은 하나의 클래스를 확인하겠습니다.

    info-support

    위 사진처럼 이 클래스가 Entity 마다 Persistable 구현 유무에 따라 동적으로 구현체를 변경해주고 있었습니다.

    그럼 답이 나온 것 같습니다. ID를 직접 할당하는 Entity에 Persistable을 구현해주면 됩니다.

    Persistable 구현하기

    @Entity
    public class ChargeStation implements Pesistable{

    @Id
    private String stationId;

    private String stationName;

    @CreatedDate
    private LocalDateTime createdTime;

    ...

    @Override
    public Object getId() {
    return getStationId();
    }

    @Override
    public boolean isNew() {
    return createdTime == null;
    }
    }

    간단히 만들어봤습니다. @CreatedDate는 Entity가 처음 영속화될 때 동작하기 때문에 이 Entity의 CreateTime 필드가 null 이면 새로운 Entity라고 확신할 수 있습니다. 그럼 이렇게 인터페이스를 구현하고 아까 실행했던 테스트를 다시 실행해보겠습니다.

    solved

    깔끔하게 구현된 것을 확인할 수 있었습니다. 원하던대로 쿼리가 2번 발생합니다. 이런 Persistable@MappedSuperClass를 통해 더 깔끔하게 구현할 수 있습니다. 하지만 따로 설명드리지는 않겠습니다.

    결론

    JPA는 많은 편의 기능을 제공해주는 것 같아보입니다. 쫄지맙시다.

    - - + + \ No newline at end of file diff --git a/tags/jpa/page/2.html b/tags/jpa/page/2.html index 1aca8114..f009b662 100644 --- a/tags/jpa/page/2.html +++ b/tags/jpa/page/2.html @@ -5,13 +5,13 @@ "jpa" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "jpa" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    - - +

    "jpa" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/login.html b/tags/login.html index a935228f..00c45d95 100644 --- a/tags/login.html +++ b/tags/login.html @@ -5,12 +5,12 @@ "login" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "login" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    OAuth 2.0 ?

    OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

    위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 +

    "login" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    OAuth 2.0 ?

    OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

    위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 굳이 한번 더 입력하지 않고도 다른 웹 사이트에서 사용할 수 있는 것 입니다. 그리고 다른 웹 사이트를 사용하더라도 google에서 로그인을 하는 과정을 거치기 때문에, 사용자는 비밀번호나, critical한 개인정보 같은 것을 한 곳에서 관리할 수 있다는 장점이 있습니다.

    다시 한번 정리하자면 우리 웹 사이트의 사용자가 이용하는 다른 웹 사이트의 정보를 사용할 수 있게끔 다른 웹 사이트에서 권한을 위임 받는 것 입니다.

    OAuth flow

    OAuth Flow를 설명하기 전에 여기서 모르는 단어들이 많습니다. 해당 링크에서 더 자세하게 정리 되어있지만 설명해보겠습니다.

    Resource Owner

    Resource Owner는 말 그대로 리소스 소유자이고, 구글과 같은 플랫폼에 회원가입이 되어있는, 즉 구글에 자신의 정보들이 있는 사용자입니다.

    Client

    Client도 말 그대로 고객입니다. 하지만 어떤 관점에서 보느냐 고객이란 뜻은 달라집니다. 여기서는 Google과 같은 플랫폼에서 제공받은 리소스를 사용하는 고객입니다. @@ -24,7 +24,7 @@ Map<String, Object>로 지정해준 이유는, 플랫폼마다 반환되는 JSON 타입이 다르기 때문에 그런 부분에 대해 중복을 제거하기 위해 이러한 형태로 만들었습니다.

    그리고 아까 yml에 작성했던 정보들을 가져와야합니다. @Value 어노테이션으로도 가져올 수 있습니다.

            @Value("oauth.provider.google.id")
    private String id;
    @Value("oauth.provider.google.secret")
    private String secret;

    ...

    하지만 이렇게 계속 binding을 해줘야한다는 점이 아주 귀찮고 보기도 안좋습니다.

    build.gradle
    annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

    하지만 위의 의존성을 추가해준다면 아주 편하게 property를 가져올 수 있습니다.

    OAuthProviderProperties.java
    @Component
    @ConfigurationProperties(prefix = "oauth2")
    public class OAuthProviderProperties {
    // prefix oauth2 기준으로 알아서 google이 이름인 Provider Enum을 찾아서 Key로 바인딩
    private final Map<Provider, OAuthProviderProperty> provider = new EnumMap<>(Provider.class);

    public OAuthProviderProperty getProviderProperties(Provider provider) {
    return this.provider.get(provider);
    }

    @Getter
    @Setter
    public static class OAuthProviderProperty {
    // 그리고 provider 하위 정보들은 아래의 필드에 바인딩
    private String id;
    private String secret;
    private String redirectUrl;
    private String tokenUrl;
    private String infoUrl;
    }
    }

    이렇게 되면 구조적인 준비는 끝났습니다.

    이제는 해당 플랫폼에 정보를 요청하는 작업만 하면 됩니다. 그럼 아까 말씀드렸던 순서로 요청을 해보겠습니다.

    RestTemplateOAuthRequester.java
    public class RestTemplateOAuthRequester implements OAuthRequester {

    @Override
    public OAuthMember login(OAuthLoginRequest request) {
    // frontend에서 받아온 로그인 platform
    Provider provider = Provider.from(request.provider());
    // 해당 Platform에 맞는 정보 찾음
    OAuthProviderProperty property = oAuthProviderProperties.getProviderProperties(provider);
    // frontend에서 받아온 code와 등록해놓은 property로 Access Token 요청
    OAuthTokenResponse token = requestAccessToken(property, requet.getCode());
    // 받아온 Token으로 해당 Resource Owner의 정보 요청
    Map<String, Object> userAttributes = getUserAttributes(property, token);
    return provider.getOAuthProvider(userAttributes);
    }

    private OAuthTokenResponse requestAccessToken(OAuthProviderProperty property, String code) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBasicAuth(property.getId(), property.getSecret());
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
    URI tokenUri = getTokenUri(property, code);
    return restTemplate.postForEntity(tokenUri, request, OAuthTokenResponse.class).getBody();
    }

    private URI getTokenUri(OAuthProviderProperty property, String code) {
    return UriComponentsBuilder.fromUriString(property.getTokenUrl())
    .queryParam(CODE, URLDecoder.decode(code, StandardCharsets.UTF_8))
    .queryParam(GRANT_TYPE, AUTHORIZATION_CODE)
    .queryParam(REDIRECT_URI, property.getRedirectUrl())
    .build()
    .toUri();
    }

    private Map<String, Object> getUserAttributes(OAuthProviderProperty property, OAuthTokenResponse tokenResponse) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(tokenResponse.accessToken());
    headers.setContentType(MediaType.APPLICATION_JSON);
    URI uri = URI.create(property.getInfoUrl());
    RequestEntity<?> requestEntity = new RequestEntity<>(headers, HttpMethod.GET, uri);
    ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(requestEntity, new ParameterizedTypeReference<>() {
    });
    return responseEntity.getBody();
    }
    }

    이렇게만 한다면 그 어려워 보이던 OAuth 인증도 간단하게 해결할 수 있습니다. (물론 제 코드가 정답이 아닙니다)

    Reference

    https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16

    https://developers.google.com/identity/protocols/oauth2?hl=ko

    - - + + \ No newline at end of file diff --git a/tags/message.html b/tags/message.html index 25f58465..5edda049 100644 --- a/tags/message.html +++ b/tags/message.html @@ -5,14 +5,14 @@ "message" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "message" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 +

    "message" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    야미

    프로젝트 브랜치명 컨벤션이 feat/이슈번호여서, 브랜치명에서 이슈번호만 가져온 다음 커밋할 때마다 커밋 메시지 아래단(footer)에 이슈 번호를 자동으로 입력해주고 싶었다. 자동으로 입력된다면 깜빡하고 이슈 번호를 안 적는 일도 없고, 시간도 단축할 수 있기 때문이다.

    아래 순서대로 진행한다면 이슈 번호 POSTFIX 자동화를 할 수 있다.

    1) 프로젝트 폴더에 .githooks 폴더 생성

    2) .githooks 폴더에 commit-msg 파일 생성

    #!/bin/bash

    COMMIT_MESSAGE_FILE_PATH=$1
    MESSAGE=$(cat "$COMMIT_MESSAGE_FILE_PATH")

    # 커밋 메시지가 없을 때, 커밋 방지
    if [[ $(head -1 "$COMMIT_MESSAGE_FILE_PATH") == '' ]]; then
    exit 0
    fi

    # 브랜치명에서 이슈 번호만 추출 ('/' 뒤에 있는 문자만 추출)
    POSTFIX=$(git branch | grep '\*' | sed 's/* //' | sed 's/^.*\///' | sed 's/^\([^-]*-[^-]*\).*/\1/')

    COMMIT_SOURCE=$2
    CURRENT_BRANCH=$(git branch --show-current)

    # [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] 👉🏻 현재 브랜치명과 POSTFIX가 똑같으면 POSTFIX 입력 방지
    # [ "$COMMIT_SOURCE" != "merge" ] 👉🏻 merge할 때, POSTFIX 입력 방지
    # [[ "$MESSAGE" != *"[#$POSTFIX]"* ]] 👉🏻 이미 POSTFIX가 존재할 때, POSTFIX 중복 입력 방지
    if [[ "$CURRENT_BRANCH" != "$POSTFIX" ]] && [ "$COMMIT_SOURCE" != "merge" ] && [[ "$MESSAGE" != *"[#$POSTFIX]"* ]]; then
    printf "%s\n\n[#%s]" "$MESSAGE" "$POSTFIX" > "$COMMIT_MESSAGE_FILE_PATH"
    fi

    🧐 이슈 번호 추출에 사용된 명령어 설명

    • grep '*' 👉 * 표시된 브랜치(현재 위치의 브랜치)를 가져온다.
    • sed 's/_ //' 👉 * 제거
    • sed 's/(/)./\1/' 👉 / 이후의 문자만 추출
    • sed 's/^(---)._/\1/' 👉 하나의 이슈에 여러 브랜치를 만들면서 feat/10-1 이런 형태로 브랜치를 만들 경우, 첫 번째 '-' 앞 뒤만 추출 (ex. 10-1)

    3) 프로젝트 폴더에 Makefile 파일 생성

    init:
    git config core.hooksPath .githooks
    chmod +x .githooks/commit-msg
    git update-index --chmod=+x .githooks/commit-msg

    # chmod +x .githooks/commit-msg 👉🏻 macOS, 리눅스에서 스크립트 권한 부여
    # git update-index --chmod=+x .githooks/commit-msg
    # 👉 macOS, 리눅스에서 브랜치가 바뀔 때마다 스크립트 실행시켜줘야 하는 문제 해결

    4) 아래 코드 실행

    새로 git clone을 할 때마다 아래 코드를 실행시켜줘야 한다. 한 번만 실행시키면 계속 적용된다. (window 기준)

    git config core.hooksPath .githooks

    ❗macOS는 git clone 할 때마다 아래 코드를 실행시켜줘야 한다.

    make

    참고 블로그 https://blog.deering.co/commit-convention/

    - - + + \ No newline at end of file diff --git a/tags/msw.html b/tags/msw.html index 68e948c7..fbb25c6c 100644 --- a/tags/msw.html +++ b/tags/msw.html @@ -5,13 +5,13 @@ "msw" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "msw" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 5분
    센트

    웹팩에서 msw 설정

    이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

    팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

    다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

    eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

    왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

    약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

    이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

    new CopyWebpackPlugin({
    patterns: [
    { from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
    ],
    }),

    설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

    문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

    1. MSW를 적용해보려고 함.
    2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
    3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
    4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
    - - +

    "msw" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 5분
    센트

    웹팩에서 msw 설정

    이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

    팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

    다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

    eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

    왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

    약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

    이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

    new CopyWebpackPlugin({
    patterns: [
    { from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
    ],
    }),

    설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

    문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

    1. MSW를 적용해보려고 함.
    2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
    3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
    4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
    + + \ No newline at end of file diff --git a/tags/mysql.html b/tags/mysql.html index c8b2637d..69a2f1ed 100644 --- a/tags/mysql.html +++ b/tags/mysql.html @@ -5,12 +5,12 @@ "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 7분
    제이

    안녕하세요. +

    "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 7분
    제이

    안녕하세요. 카페인 팀의 제이입니다.

    저희 서비스에서는 충전소의 요일과 시간대 별로 충전소 혼잡도 정보를 제공을 차별적인 기능으로 제공하고 있습니다.

    이를 구현하기 위해서 공공 데이터에서 정보를 수집하고있습니다. 혼잡도를 조회하기 위해서는 약 23만 건의 충전소 7일 24시간 = 약 4000만 건의 데이터 중에서 조회를 하는 형식으로 되어있습니다.

    너무 많은 데이터가 있다보니 조회 속도가 많이 느린데요. 오늘은 이를 어떻게 개선했는지 작성해보도록 하겠습니다.

    참고로 해당 글의 성능 측정에 이용한 데이터의 수는 약 20만 건입니다.


    문제 확인

    기존의 저희는 많은 양의 데이터를 감당하기 힘들어서 [오전, 오후] 이렇게 두 부분으로 나눠서 혼잡도를 조회했습니다.

    하지만 실제 배포를 하기 위해서 더이상은 오전 오후로 나눌 수가 없었는데요.

    정상적인 데이터를 제공하기 위해서 먼저 24시간 기준으로 혼잡도를 갱신하도록 로직부터 바꾸었습니다.

    위와 같이 코드를 바꾸니 바로 성능에 문제가 생겼습니다. @@ -30,7 +30,7 @@ 위와 같은 조회 쿼리가 나왔으므로 인덱스를 아래와 같이 station_id, day_of_week에 걸어주었습니다.

    img 위 실행 속도에서 execution time을 확인해보면 인덱스를 걸고 134ms -> 5ms로 성능이 많이 개선 되었음을 확인할 수 있습니다.

    img 실행 계획도 의도한대로 잘 나오는 것을 보실 수 있습니다.


    정리

    1. DB Partitioning - (day_of_week : 요일)을 기준으로 파티셔닝
    2. 조회 쿼리에 맞게 인덱스 설정
    3. API 수정 (모든 요일의 혼잡도 조회 -> 해당 요일의 혼잡도 조회)

    결과적으로 기존 혼잡도 조회시 511ms가 나왔으나, 요일 별 조회 및 파티셔닝 & 인덱스를 적용하고 execution time = 5ms로 개선

    - - + + \ No newline at end of file diff --git a/tags/mysql/page/2.html b/tags/mysql/page/2.html index 9ed656ee..da6d7097 100644 --- a/tags/mysql/page/2.html +++ b/tags/mysql/page/2.html @@ -5,12 +5,12 @@ "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 24분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 지난 글에서 설명했듯이 저희 프로젝트에서는 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. +

    "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 24분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 지난 글에서 설명했듯이 저희 프로젝트에서는 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. 이 부분에 대해서는 조회 성능을 높혀 어느정도 해결하고자 했습니다. 하지만 조회가 아닌 많은 데이터를 일정한 주기로 업데이트 해줘야하는 로직도 포함되어 있기 때문에 업데이트를 할 때 조회를 하게 된다면 cpu 사용률은 비슷할 것입니다. 이 부분을 해결하고자 데이터베이스 레플리케이션을 알아보겠습니다.

    결론

    결론부터 말씀드리면 데이터베이스 레플리케이션을 적용한 후 성능이 눈에 띄게 좋아졌습니다. 해당 부분은 다음 포스팅에 작성하겠습니다 100명의 사용자가 지도의 데이터를 조회할 때를 기준으로

    TPS 179 -> 366

    Response Time 550 ms -> 271 ms

    약 2배 가량 성능이 향상된 것을 볼 수 있습니다.

    데이터베이스 레플리케이션이란?

    데이터베이스 레플리케이션이란 하나의 데이터베이스에서 다른 하나 이상의 데이터베이스로 데이터의 복제 또는 복사를 수행하는 프로세스 또는 기술입니다. 데이터베이스 레플리케이션은 주로 다음과 같은 목적으로 사용됩니다

    1. 고가용성: 데이터베이스 서버의 장애가 발생했을 때, 레플리카 데이터베이스를 사용하여 시스템을 계속 운영할 수 있습니다. 이렇게 하면 서비스 중단 시간을 최소화하고 비즈니스 연속성을 유지할 수 있습니다.

    2. 성능 향상 : @@ -22,7 +22,7 @@ 소스 서버에서 커밋된 트랜잭션은 바이너리 로그에 기록되고, 레플리카 서버에서는 주기적으로 새로운 트랜잭션에 대한 바이너리 로그를 요청합니다. 이러한 방식은 소스 서버는 레플리카 서버가 제대로 변경 되었는지 알 수 없습니다. 즉 데이터 정합성에 문제가 생긴다는 단점이 있습니다. 하지만 이러한 방식은 소스 서버가 각 트랜잭션에 대해 레플리카 서버로 전송되는 부분을 고려하지 않는다는 점이 속도 측면에서 빠르고, 또 여러 대의 레플리카 서버를 구성하더라도 큰 성능 저하가 없다는 점이서 장점이 있습니다.

      반동기 복제

      반동기 복제는 비동기 복제보다 좀 더 데이터 정합성이 올라갑니다. 소스 서버는 변경된 트랜잭션이 있을 때 레플리카 서버가 다 전송이 되었다는 ACK 신호를 받기 때문에 확실히 알 수 있습니다. 하지만 전송여부만 확인하기 때문에 트랜잭션이 반영이 되었다는 보장은 없습니다. 반동기 복제 방식은 2가지가 있습니다.

      1. After sync: After Sync 방식은 소스 서버에서 트랜잭션을 바이너리 로그에 기록 후 Storage Engine에 바로 커밋하지 않습니다. 먼저 바이너리 로그에 기록 후 레플리카 서버의 ACK 응답을 기다립니다. 그리고 ACK 응답이 도착하면 그제서야 스토리지 엔진을 커밋하여 트랜잭션을 처리하고 결과를 반환합니다.
      2. After commit: After commit은 이름 그대로 커밋을 먼저 하는 것입니다. 트랜잭션이 생기면 먼저 바이너리 로그에 기록 후 소스 서버 스토리지 엔진에 커밋합니다. 그리고 레플리카 서버의 ACK 응답이 내려오면 클라이언트는 처리 결과를 얻고 다음 쿼리를 수행할 수 있습니다.

      먼저 after commit 방식은 소스 서버에 장애가 발생했을 때 팬텀 리드가 발생하게 됩니다. 트랜잭션이 스토리지 엔진 커밋까지된 후 레플리카 서버의 응답을 기다립니다. 이처럼 스토리지 엔진 커밋까지 완료된 데이터는 다른 세션에서도 조회가 가능합니다. 트랜잭션이 커밋되었고, 레플리카 서버로 아직 응답을 기다릴 때, 소스 서버에 장애가 발생한다면 새로운 소스 서버로 승격된 레플리카 서버에서 데이터를 조회할 때 자신이 이전 소스 서버에서 조회했던 데이터를 보지 못할 수도 있습니다.

      그리고 이처럼 레플리카 서버가 승격된 상황에 소스 서버의 장애가 복구되어 재사용할 경우 이미 커밋된 그 트랜잭션을 수동으로 롤백 시켜야만 데이터가 맞는 상황이 생깁니다.

      저희 팀의 복제 동기화 방식

      이러한 장단점으로 저희 팀은 데이터 무결성이 중요하다 판단되어 반동기 복제 방식을 사용하고, After Sync 방식을 적용하였습니다.

      복제 토폴리지

      복제 토폴리지는 여러가지 방식 중 자신의 상황과 가장 맞는 방식을 사용하면 될 것 같습니다. 저희 팀이 고려해야할 문제는 먼저 성능을 올려야 했고, 단일 장애포인트를 개선해야했습니다. 하지만 사용할 수 있는 서버는 2대 뿐이였습니다. 이러한 상황에서 어떤 방식을 택할 수 있을까요?

      싱글 레플리카

      가장 기본적이며 가장 많이 쓰이는 형태입니다. 어플리케이션에서 레플리카 서버에 읽기 요청을 전달하면, 레플리카 서버에 문제가 생겼을 때, 서비스 장애 상황이 발생할 수 있습니다. 그러므로 소스 서버에서 Read, Write를 둘 다 하고, 레플리카 서버는 failover를 위해 대기하는 예비용 서버로 구성합니다. 소스 서버에 장애가 발생했을 때 소스 서버를 대체하거나 데이터를 백업하는 용도로 사용합니다.

      멀티 레플리카

      싱글 레플리카와 비슷한 구성이지만 레플리카 서버가 한 대 더 추가된 구성입니다. 해당 방식은 SPOF 문제가 없기 때문에 레플리카 서버 하나를 읽기 전용 서버로 둘 수 있습니다. 읽기 작업을 분산함으로 어플리케이션의 성능을 향상 시킬 수 있습니다. 아까 말했던 장애 상황이 발생하면 예비용 서버인 Replica2 서버를 Source 서버 혹은 Replica1(읽기 전용) 서버로 대체할 수 있습니다.

      체인 복제

      레플리카 서버가 많아져 소스 서버의 바이너리 로그를 읽는 부하가 많아질 때 할 수 있는 구성입니다. 좀 전에 설명드렸던 멀티 레플리카 방식에서 똑같은 구성을 추가한 방식으로 볼 수 있습니다. Source 1 의 정보를 복제한 Replica 1-1, 1-2 서버는 빠르게 데이터가 반영되지만, Source1의 이벤트를 복제한 Source2를 복제한 Replica 2-1, 2-2 서버는 당연히 늦게 반영되기 때문에 해당 그룹은 예비용으로 사용합니다.

      듀얼 소스 복제

      데이터베이스 둘 다 소스 서버이면서 레플리카 서버인 경우입니다. 이 경우는 Active-Active구성과 Active-Passive 구성으로 나뉩니다

      Active-Active는 서버 둘 다 읽기와 쓰기가 가능한 형태입니다. 즉 부하를 분산시키기 위해 서버 모두 읽고 쓰는 작업을 하는 것입니다. 하지만 이러한 방식은 뻔한 단점이 있습니다. 서로의 이벤트가 동기화 되기 전에는 정합성이 깨질 수 있습니다. 또 동시에 같은 데이터에 대해 쓰기 작업을 수행할 때, 하나의 서버에서 쓰기가 완료되었더라도, 다른 하나의 서버에 늦게 끝난 쓰기가 있다면 마지막 트랜잭션인 늦게 끝난 쓰기 작업이 반영되어 예상하지 못한 결과가 나올 수 있습니다.

      또 다른 문제로는 Auto Increment를 사용할 때입니다. 새로운 데이터가 동시에 생성될 때 Auto Increment가 중복되는 에러가 발생할 수 있기 때문에 해당 토폴로지에서는 ID를 DB에 의존하지 않는 것이 좋습니다.

      Active-Passive 방식은 하나의 서버만 읽기와 쓰기 요청이 되지만, 나머지 서버는 대기하고 있습니다. 두 서버 모두 언제나 쓰기 작업이 가능한 형태이기 때문에 장애 발생 시 빠르게 Faliover할 수 있다는 점이 있습니다.

      멀티 소스 복제

      하나의 레플리카 서버가 다수의 소스 서버를 갖는 구성입니다. 데이터베이스 샤딩을 해뒀는데, 다시 하나의 서버로 통합하고 싶을 때 사용할 수 있습니다. 혹은 서로 다른 데이터를 한 곳에 백업을 할 때도 사용할 수 있습니다.

      저희 팀의 토폴로지 방식

      그럼 이렇게나 많은 구성 중에 저희 팀에서 택할 수 있는 토폴로지 방식은 싱글 레플리카 방식과 듀얼 소스 복제 방식 밖에 없습니다. 왜냐하면 주어진 서버가 2대뿐이기 때문입니다. 하지만 듀얼 소스 방식은 적용하는데 무리가 있는 부분이 있습니다. 일단 저희가 레플리케이션을 적용하려는 가장 큰 이유는 성능 이기 때문에 성능이 변하지 않는 듀얼 소스의 Active-Passive 방식은 제외하겠습니다. 그리고 Active-Active 방식은 부하를 분산시킬 수 있다는 장점이 있지만, 단점으로는 Auto Increment를 사용하는데에 위험이 있다는 점과, 데이터의 정합성 문제가 생길 수 있다는 점에서 듀얼 소스 방식은 제외하도록 했습니다.

      그럼 싱글 레플리카 방식을 적용할 수 밖에 없는데요. 싱글 레플리카의 방식은 가용성 문제를 해결하기 위해 만들어진 방식입니다. 하지만 저희 서비스는 현재 가용성보다 성능을 더 신경써야하는 상황이기때문에 싱글 레플리카 토폴로지를 구성하지만 레플리카 서버를 예비용이 아닌 읽기 전용 방식으로 사용하도록 하고, 가용성 부분을 포기하기로 정했습니다.

      코드에 적용하기

      replication-datasource Github 소스 코드를 참고하시거나, DB 복제, @Transactional에 따라 요청 분리해보기 글을 참고하여 따라하면 금방하실 수 있습니다!

      결론

      데이터베이스 레플리케이션 생각보다 어렵지 않습니다.

      데이터베이스 재밌습니다. 인프라도 재밌습니다.

      참고

      Real Mysql 8.0

    - - + + \ No newline at end of file diff --git a/tags/mysql/page/3.html b/tags/mysql/page/3.html index 0a0c1aaf..f2844efb 100644 --- a/tags/mysql/page/3.html +++ b/tags/mysql/page/3.html @@ -5,16 +5,16 @@ "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 14분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 카페인 팀 프로젝트에는 사용자가 보고있는 지도에 충전소를 보여주는 조회 기능이 가장 중요하고, 제일 요청이 많이 들어옵니다.

    하지만 조회 성능이 좋지 않은 까닭인지 여러 사용자가 접속하면 아래와 같이 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. +

    "mysql" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 14분
    박스터

    안녕하세요 박스터입니다

    이 글을 쓰는 이유

    먼저 이 글을 쓰게 된 계기를 말씀드리겠습니다. 카페인 팀 프로젝트에는 사용자가 보고있는 지도에 충전소를 보여주는 조회 기능이 가장 중요하고, 제일 요청이 많이 들어옵니다.

    하지만 조회 성능이 좋지 않은 까닭인지 여러 사용자가 접속하면 아래와 같이 데이터베이스가 실행되고 있는 서버의 cpu 사용률이 100%가 되는 문제가 있었습니다. cpu

    조회 성능 개선하기

    먼저 제가 개선하기 위해 사용했던 방법들에 대해 적어보겠습니다.

    DTO 이용하기

    현재 구조는 아래의 JPA를 이용해 아래와 같은 쿼리로 entity로 데이터를 조회합니다.

     select distinct station.station_id,
    charger.charger_id,
    charger.station_id,
    chargerStatus.charger_id,
    chargerStatus.station_id,
    station.created_at,
    station.updated_at,
    station.address,
    station.company_name,
    station.contact,
    station.detail_location,
    station.is_parking_free,
    station.is_private,
    station.latitude,
    station.longitude,
    station.operating_time,
    station.private_reason,
    station.station_name,
    station.station_state,
    charger.created_at,
    charger.updated_at,
    charger.capacity,
    charger.method,
    charger.price,
    charger.type,
    charger.station_id,
    charger.charger_id,
    chargerStatus.created_at,
    chargerStatus.updated_at,
    chargerStatus.charger_condition,
    chargerStatus.latest_update_time
    from charge_station station
    inner join
    charger charger on station.station_id = charger.station_id
    inner join
    charger_status chargerStatus on charger.charger_id = chargerStatus.charger_id
    and charger.station_id = chargerStatus.station_id
    where station.latitude >= 37.5019194727953082567
    and station.latitude <= 37.5092305272047217433
    and station.longitude >= 127.044542269049714936
    and station.longitude <= 127.058071330950285064

    JPA를 통해 이러한 방식으로 조회한다면 아주 편하게 값을 가져오고, fetch join을 통해 하위의 entity들의 정보도 깔끔하게 가져옵니다.

    가져온 값으로 필요한 정보들을 매핑하고 가공하여 응답을 내려줬습니다.

    하지만 조회만을 위해 JPA의 entity를 조회한다는 것은 여러 단점이 존재합니다.

    제일 먼저 응답을 내려줄 때 불필요한 데이터까지 모두 조회를 한다는 부분입니다. 이렇게 많은 필드들이 있습니다. 하지만 응답에서는 대부분의 경우 모든 정보가 필요하지 않습니다. 그리고 모든 정보를 다 보내주는 것도 좋지 않습니다. 대량의 데이터를 조회할 때의 성능이 아주 나빠집니다.

    그래서 필요한 칼럼만 조회하는 것이 좋습니다.

    그리고 또 다른 단점으로는 JPA로 entity를 조회할 때 Hibernate 캐시에 저장한다던가, One To One 에서 N+1 쿼리가 발생하기 때문에 성능적인 이슈가 여러가지 있습니다.

    그래서 조회만 하는 api라면 DTO Projection으로 하는 것이 좋을 것 같습니다. 그리고 아래와 같이 변경하였습니다.

    SELECT s.station_id,
    s.station_name,
    s.latitude,
    s.longitude,
    s.is_parking_free,
    s.is_private,
    sum(case
    when cs.charger_condition = 'STANDBY' then 1
    else 0
    end),
    sum(case
    when c.capacity >= 50 then 1
    else 0
    end)
    FROM charge_station s
    inner join charger c on (c.station_id = s.station_id)
    inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)
    where s.station_id in (?, ?)
    group by s.station_id;

    이렇게 필요한 칼럼만 조회하는 방식으로 변경하여, 선릉역 근처를 조회하는 기준으로 약 450ms -> 350ms로 개선되었습니다.

    하지만 아직도 너무 느린 것을 확인할 수 있습니다. 그래서 실행 계획을 확인했습니다.

    실행 계획 확인하기

    sql의 실행 계획은 아주 중요하고 성능을 개선할 때 아주 유용합니다.

    실행 계획에는 여러가지 정보들이 있습니다.

    1. ID: 실행 계획 내에서 각 작업 또는 단계를 식별하는 일련번호입니다. 실행 계획은 여러 단계로 나뉘며, ID를 통해 이러한 단계를 식별할 수 있습니다.

    2. Select Type: 쿼리의 각 단계(예: SIMPLE, PRIMARY, SUBQUERY)에 대한 실행 유형을 나타냅니다. 이는 MySQL이 데이터를 선택하고 처리하는 방식을 나타냅니다.

    3. Table: 실행 계획에 포함된 테이블의 이름 또는 별칭입니다. 어떤 테이블이 사용되는지를 확인할 수 있습니다.

    4. Type: 테이블 접근 방식을 나타냅니다. 이 값은 인덱스 스캔, 풀 테이블 스캔 등과 같은 값일 수 있으며, 성능에 큰 영향을 미칩니다.

    5. Possible Keys: 사용 가능한 인덱스를 나타냅니다. MySQL이 어떤 인덱스를 사용할 수 있는지 알려줍니다.

    6. Key: 실제로 선택된 인덱스입니다. 이 값은 가능한 인덱스 중에서 실제로 사용되는 인덱스를 나타냅니다.

    7. Key Len: 사용된 인덱스의 길이를 나타냅니다.

    8. Ref: 인덱스를 사용하여 테이블 간의 연결을 나타내는 열입니다.

    9. Rows: 각 단계에서 예상되는 행의 수입니다. 이 값은 성능 평가에 중요한 역할을 합니다.

    10. Extra: 기타 정보를 제공합니다. 이 칼럼에는 추가 정보 및 힌트가 포함될 수 있습니다.

    이렇게 여러 칼럼이 있습니다. 그 중 성능에 큰 영향을 미치는 칼럼 두 가지만 자세히 알아보겠습니다.

    Type

    1. const : 쿼리에 Primary key 혹은 unique key 칼럼을 이용하는 where 조건절을 가지고 있고, 반드시 하나의 데이터를 반환하는 방식이다. (옵티마이저가 해당 부분은 상수로 처리하기 때문에 const라고 한다.)
    2. eq_ref : 조인에서 Primary key 혹은 unique key 칼럼을 이용하는 where 조건절을 가지고 있고, 반드시 하나의 데이터를 반환하는 방식이다. (const와 다른 점은 eq_ref는 조인에서 사용된다는 점이다.)
    3. ref : eq_ref와 다르게 join의 순서와 관계없이 사용된다. 그리고 primary key, unique key도 관계없다. 그냥 인덱스의 종류와 관계없이 = 조건으로 검색할 때 사용된다
    4. fulltext: mysql 전문 검색 인덱스를 사용해서 레코드에 접근하는 방법, 전문 검색할 컬럼에 인덱스가 있어야 한다. "MATCH ... AGAINST ..." 구문을 사용해서 실행된다
    5. range: 인덱스를 이용해서 검색하는데, 검색 조건이 >, >=, <, <=, BETWEEN, IN() 등의 연산자를 사용하는 경우이다. 보통의 인덱스 스캔이라고 하면 range, const, ref를 칭한다
    6. index: 인덱스 풀 스캔이다. 인덱스를 이용해서 테이블의 모든 레코드를 읽는다. 인덱스를 이용해서 테이블을 읽는 것이기 때문에 all보다는 빠르다.
    7. all: 테이블 풀 스캔이다. 테이블의 모든 레코드를 읽는다. 가장 느린 방법이다.

    실행 계획에서 자주 보이는 type들만 성능이 좋은 순으로 정리해봤습니다.

    Extra

    1. using filesort: 정렬을 위해 별도의 파일 정렬을 수행한다. 이는 인덱스를 사용하지 않고 정렬을 수행한다는 의미이다. 이는 성능에 좋지 않다.
    2. using index: 인덱스만으로 쿼리를 처리한다. 이는 인덱스만으로 쿼리를 처리하기 때문에 성능이 좋다.
    3. using join buffer: join이 되는 칼럼은 인덱스를 생성한다. 하지만 driven table에 적절한 인덱스가 없다면 driving table에 있는 모든 레코드를 읽어서 join을 수행한다. 그래서 이걸 보완하기 위해 driving table에 읽은 레코드를 임시 공간에 저장하는데 그 곳이 join buffer이다.
    4. using temporary: 쿼리를 처리하기 위해 임시 테이블을 생성한다. 인덱스를 사용하지 못하는 group by 쿼리가 대표적인 예이다.
    5. using where: mysql 엔진이 별도의 가공, 필터링 작업을 처리한 경우일 때만 나타난다. 범위 조건은 스토리지 엔진에서 처리되어 레코드를 리턴해주지만, 체크 조건은 mysql 엔진에서 처리된다.

    type뿐만 아니라 extra도 쿼리의 문제를 파악하는데 아주 큰 도움을 줍니다. 그 중 자주 보이는 것들에 대해서만 정리해봤습니다.

    그럼 아까 생성한 쿼리의 실행 계획을 확인해봅시다.

    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+
    | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+
    | 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where; Using temporary |
    | 1 | SIMPLE | charger | NULL | ALL | PRIMARY | NULL | NULL | NULL | 240340 | 10.00 | Using where; Using join buffer (hash join) |
    | 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |
    +----+-------------+--------------+------------+--------+----------------------------------+---------+---------+---------------------------------------------------------+--------+----------+--------------------------------------------+

    station 테이블에 대해서는 range 스캔, 임시 테이블을 생성하고 있습니다, 그리고 charger에서는 테이블 풀 스캔, join buffer까지 생성하고 있습니다. 다행히도 chargersta 테이블에서는 적당한 조건을 생성한 것 같습니다.

    다시 한번 쿼리를 보고 실행 계획이 이렇게 나온 이유를 알아보겠습니다.

    SELECT
    ...
    FROM charge_station s
    inner join charger c on (c.station_id = s.station_id)
    inner join charger_status cs on (c.charger_id = cs.charger_id and c.station_id = cs.station_id)
    where s.station_id in (?, ?)
    group by s.station_id;

    아까 얘기했던, using temporary와 using join buffer가 발생하는 이유의 공통점을 찾아보면, 인덱스가 문제인 것을 유추할 수 있습니다.

    station과 charger를 join할 때, driven table 즉, charger 테이블에 적절한 인덱스가 없어 성능이 나빠진 것이라 의심하여, 인덱스를 생성하고 다시 한번 실행 계획을 확인했습니다.

    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+
    | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+
    | 1 | SIMPLE | station | NULL | range | PRIMARY,idx_station_coordination | PRIMARY | 1022 | NULL | 2 | 100.00 | Using where |
    | 1 | SIMPLE | charger | NULL | ref | PRIMARY,idx_station_id | idx_station_id | 1022 | charge.s.station_id | 3 | 100.00 | NULL |
    | 1 | SIMPLE | chargersta | NULL | eq_ref | PRIMARY | PRIMARY | 2044 | charge.charger1_.charger_id,charge.station0_.station_id | 1 | 100.00 | NULL |
    +----+-------------+--------------+------------+--------+----------------------------------+-------------------+---------+---------------------------------------------------------+--------+----------+---------------+

    이렇게 charger 테이블에 인덱스를 생성한 것만으로도 실행 계획을 깔끔하게 개선했습니다.

    결과

    아래는 인덱스를 생성하기 전 실행 속도입니다.

    개선_전

    아래는 인덱스를 생성한 후 실행 속도입니다.

    개선_후

    315ms -> 24ms 로 약 13배 빨라진 것을 확인할 수 있습니다.

    결론

    실행 계획 확인은 필수입니다!

    참고

    real mysql 책

    - - + + \ No newline at end of file diff --git a/tags/oauth.html b/tags/oauth.html index 0a87ec98..968d52f3 100644 --- a/tags/oauth.html +++ b/tags/oauth.html @@ -5,12 +5,12 @@ "oauth" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "oauth" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    OAuth 2.0 ?

    OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

    위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 +

    "oauth" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    OAuth 2.0 ?

    OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단

    위키 백과에서는 위와 같이 설명하고 있습니다. 우리가 google과 같은 웹 사이트에 회원가입을 하고 저장해둔 이름, 이메일, 프로필 이미지 같은 정보를 굳이 한번 더 입력하지 않고도 다른 웹 사이트에서 사용할 수 있는 것 입니다. 그리고 다른 웹 사이트를 사용하더라도 google에서 로그인을 하는 과정을 거치기 때문에, 사용자는 비밀번호나, critical한 개인정보 같은 것을 한 곳에서 관리할 수 있다는 장점이 있습니다.

    다시 한번 정리하자면 우리 웹 사이트의 사용자가 이용하는 다른 웹 사이트의 정보를 사용할 수 있게끔 다른 웹 사이트에서 권한을 위임 받는 것 입니다.

    OAuth flow

    OAuth Flow를 설명하기 전에 여기서 모르는 단어들이 많습니다. 해당 링크에서 더 자세하게 정리 되어있지만 설명해보겠습니다.

    Resource Owner

    Resource Owner는 말 그대로 리소스 소유자이고, 구글과 같은 플랫폼에 회원가입이 되어있는, 즉 구글에 자신의 정보들이 있는 사용자입니다.

    Client

    Client도 말 그대로 고객입니다. 하지만 어떤 관점에서 보느냐 고객이란 뜻은 달라집니다. 여기서는 Google과 같은 플랫폼에서 제공받은 리소스를 사용하는 고객입니다. @@ -24,7 +24,7 @@ Map<String, Object>로 지정해준 이유는, 플랫폼마다 반환되는 JSON 타입이 다르기 때문에 그런 부분에 대해 중복을 제거하기 위해 이러한 형태로 만들었습니다.

    그리고 아까 yml에 작성했던 정보들을 가져와야합니다. @Value 어노테이션으로도 가져올 수 있습니다.

            @Value("oauth.provider.google.id")
    private String id;
    @Value("oauth.provider.google.secret")
    private String secret;

    ...

    하지만 이렇게 계속 binding을 해줘야한다는 점이 아주 귀찮고 보기도 안좋습니다.

    build.gradle
    annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

    하지만 위의 의존성을 추가해준다면 아주 편하게 property를 가져올 수 있습니다.

    OAuthProviderProperties.java
    @Component
    @ConfigurationProperties(prefix = "oauth2")
    public class OAuthProviderProperties {
    // prefix oauth2 기준으로 알아서 google이 이름인 Provider Enum을 찾아서 Key로 바인딩
    private final Map<Provider, OAuthProviderProperty> provider = new EnumMap<>(Provider.class);

    public OAuthProviderProperty getProviderProperties(Provider provider) {
    return this.provider.get(provider);
    }

    @Getter
    @Setter
    public static class OAuthProviderProperty {
    // 그리고 provider 하위 정보들은 아래의 필드에 바인딩
    private String id;
    private String secret;
    private String redirectUrl;
    private String tokenUrl;
    private String infoUrl;
    }
    }

    이렇게 되면 구조적인 준비는 끝났습니다.

    이제는 해당 플랫폼에 정보를 요청하는 작업만 하면 됩니다. 그럼 아까 말씀드렸던 순서로 요청을 해보겠습니다.

    RestTemplateOAuthRequester.java
    public class RestTemplateOAuthRequester implements OAuthRequester {

    @Override
    public OAuthMember login(OAuthLoginRequest request) {
    // frontend에서 받아온 로그인 platform
    Provider provider = Provider.from(request.provider());
    // 해당 Platform에 맞는 정보 찾음
    OAuthProviderProperty property = oAuthProviderProperties.getProviderProperties(provider);
    // frontend에서 받아온 code와 등록해놓은 property로 Access Token 요청
    OAuthTokenResponse token = requestAccessToken(property, requet.getCode());
    // 받아온 Token으로 해당 Resource Owner의 정보 요청
    Map<String, Object> userAttributes = getUserAttributes(property, token);
    return provider.getOAuthProvider(userAttributes);
    }

    private OAuthTokenResponse requestAccessToken(OAuthProviderProperty property, String code) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBasicAuth(property.getId(), property.getSecret());
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
    URI tokenUri = getTokenUri(property, code);
    return restTemplate.postForEntity(tokenUri, request, OAuthTokenResponse.class).getBody();
    }

    private URI getTokenUri(OAuthProviderProperty property, String code) {
    return UriComponentsBuilder.fromUriString(property.getTokenUrl())
    .queryParam(CODE, URLDecoder.decode(code, StandardCharsets.UTF_8))
    .queryParam(GRANT_TYPE, AUTHORIZATION_CODE)
    .queryParam(REDIRECT_URI, property.getRedirectUrl())
    .build()
    .toUri();
    }

    private Map<String, Object> getUserAttributes(OAuthProviderProperty property, OAuthTokenResponse tokenResponse) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(tokenResponse.accessToken());
    headers.setContentType(MediaType.APPLICATION_JSON);
    URI uri = URI.create(property.getInfoUrl());
    RequestEntity<?> requestEntity = new RequestEntity<>(headers, HttpMethod.GET, uri);
    ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(requestEntity, new ParameterizedTypeReference<>() {
    });
    return responseEntity.getBody();
    }
    }

    이렇게만 한다면 그 어려워 보이던 OAuth 인증도 간단하게 해결할 수 있습니다. (물론 제 코드가 정답이 아닙니다)

    Reference

    https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-16

    https://developers.google.com/identity/protocols/oauth2?hl=ko

    - - + + \ No newline at end of file diff --git a/tags/oom.html b/tags/oom.html index 70fc5a71..c8582340 100644 --- a/tags/oom.html +++ b/tags/oom.html @@ -5,12 +5,12 @@ "OOM" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "OOM" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. +

    "OOM" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. error-log

    왜 발생했을까

    먼저 간단히 저희가 처한 상황에 대해 설명드리겠습니다.

    처음 어플리케이션을 실행하면 공공 API를 호출하여 충전소와 충전기에 대한 모든 정보들을 가져와 저장합니다. (충전소 약 6만 곳 + 충전기 약 23만 기)

    하지만 이러한 정보들은 수정이 될 수 있고, 충전소와 충전기가 추가될 수 있습니다.

    그러므로 정확한 정보가 사용자에게 가장 중요시되는 서비스에서 이러한 정보들이 늦게 반영이 된다거나, 반영이 되지 않는다면 저희 서비스를 사용할 사용자가 없을 것이라 판단했습니다.

    그래서 하루에 한 번 충전소와 충전기들의 정보를 업데이트하고, 추가된 충전소와 충전기를 저장하는 로직을 만들었습니다.

    대략적인 로직은 아래와 같습니다.

        public void updatePeriodicStations() {
    List<Station> stations = requestStations();
    stationUpdateService.updateStations(stations);
    }

    public void updateStations(List<Station> updatedStations) {
    List<Station> stations = stationRepository.findAllFetch();

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    }

    간단하게 말씀드리면 requestStations() 메서드는 공공 API에서 모든 충전소와 충전기를 요청하고 받아오는 메서드입니다. 23만 + 6만개의 정보를 받아오는 것입니다. 이렇게 많은 정보를 받아오고 메모리에 올린다는 것은 누가봐도 비효율적입니다. 하지만 이러한 선택을 한 이유는 공공 API는 저희가 어떤 방식으로 보내줄 지 모른다는 것이였습니다. 그래서 어쩔 수 없이 23만건을 모두 요청해야한다는 부분은 바꿀 수 없는 한계입니다.

    그 다음으로는 요청해서 받아온 데이터들과 데이터베이스에 저장되어 있던 데이터들을 findAll()을 통해 비교하고 새로운 충전소와 충전기는 저장하고, 업데이트된 충전소와 충전기는 수정합니다.

    이런 로직은 총 (23 + 6) * 2 만건의 객체 약 58만개를 Heap 메모리에 적재합니다. 많다고는 생각했지만, 일단 제 로컬환경에서는 잘 작동했고, 기능 구현이 우선이기 때문에 추후에 개선을 하기로 하고 넘어갔습니다.

    하지만 개발 서버 배포를 하고 다음날 서버가 접속이 되지 않는 것을 확인했고, 로그를 보니 위의 사진과 같이 OOM이 발생한 것을 확인할 수 있었습니다.

    해결 방안

    Heap size 조절하기

    일단 임시 방편으로 Heap memory의 최대 크기를 늘리는 법이였습니다. JVM은 실행되는 환경에 따라 힙 메모리의 최대 사이즈를 정합니다. 힙 메모리는 설정하지 않으면 해당 환경의 메모리 1/4로 설정합니다. @@ -31,7 +31,7 @@ 하지만 직접 확인해보기 전까지는 확신할 수 없으니 간단히 Runtime 클래스에서 제공해주는 totalMemory(), freeMemory() 메서드를 통해 알아보겠습니다.

        @Test
    void 페이징을_사용한_조회() {
    List<Station> stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();
    System.out.println("paging 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    @Test
    void 페이징을_사용하지_않고_조회() {
    List<Station> stations = stationRepository.findAllFetch();

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();

    System.out.println("findAll() 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    findAll paging 확연히 차이가 나는 것을 확인할 수 있습니다.

    물론 테스트코드에서는 23만건의 API 요청은 같은 조건이니 배제하고 확인했습니다.

    이로써 하나의 문제가 또 해결된 것 같습니다.

    아직 배우는 단계라 혹시 틀린 점이 있다면 지적 부탁드리겠습니다.

    Reference

    - - + + \ No newline at end of file diff --git a/tags/pr.html b/tags/pr.html index 80bccde1..6a77444b 100644 --- a/tags/pr.html +++ b/tags/pr.html @@ -5,19 +5,19 @@ "pr" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "pr" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. +

    "pr" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. 많은 시간을 단축할 수 있습니다.

    그리고 test가 실패한다면 강제로 Merge가 되지 않도록 한다면 실수로 테스트가 되지 않는 커밋을 올리는 것을 방지할 수 있겠죠.

    이 두가지만으로도 생산성이 많이 올라갈 것을 기대할 수 있습니다.

    어떻게 할 수 있나요

    Github Action을 이용하여 설정한 조건에 맞는 상황에서 명령어를 실행하여 test를 할 수 있습니다.

    Github Action 파일 생성

    1. 먼저 최상위 폴더에 .github/workflows 폴더를 생성합니다.
    2. 해당 폴더 내에 example.yml을 생성합니다.
    3. 아래와 같이 yml 파일을 작성합니다.
    name: pr test

    on:
    pull_request:
    branches:
    - main
    - develop

    permissions:
    contents: read

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    Job 이름 설정

    복잡하지 않습니다. 먼저 name 속성은 github action에서 보여질 Job의 이름을 정하는 부분입니다.

    지금은 pr test로 해두었습니다. 그럼 아래 사진과 같이 반영됩니다.

    workflows name

    workflow 트리거 설정

    다음으론 on 속성입니다. 이 속성은 workflow를 실행할 이벤트를 지정하는데 사용됩니다. 특정 이벤트 유형과 조건을 기반으로 workflow를 트리거하도록 구성할 수 있습니다.

    예를 들어 아래와 같이 정의했습니다.

    on:
    push:
    branches:
    - main
    pull_request:
    branches:
    - develop

    그렇다면 이 workflow가 작동되는 시점은 main 브랜치에 push가 되거나 develop 브랜치에 pull request를 보낼 때 작동합니다.

    권한 부여

    permissions:
    contents: read

    이런 권한을 주게 된다면 이 job은 읽기 권한밖에 없기 때문에 실수로 다른 것을 추가하지 못하게 막을 수 있습니다

    동작할 명령어 입력

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    name

    제일 간단히 볼 수 있는 name 설정은 아래 사진처럼 어떤식으로 보여줄지 정할 수 있습니다.

    job image

    runs-on

    runs-on 속성입니다. 해당 운영체제를 사용한다고 정의하는 부분입니다. 지금은 저희가 사용할 ec2와 같은 환경인 ubuntu에서 작동하도록 설정했지만, windows-latest, macos-latest로 변경할 수도 있습니다.

    environment

    environment 속성입니다. 해당 속성은 꼭 필요한 부분이 아니지만 branch의 rule 설정에 사용할 수 있습니다. 그리고 환경을 한꺼번에 관리할 수 있습니다.

    이 부분은 아래에 branch rule을 정하는 부분을 보시면 아마 이해가 될 것 입니다.

    defaults

    해당 속성은 어떤 폴더에서 명령어를 실행할 지 지정합니다. 지금의 저희 프로젝트에서는 한 repository에 backend, frontend 폴더를 나누었기 때문에 backend 폴더로 이동하여 명령어를 실행해야 합니다.

    그래서 working-directory./backend라고 지정했습니다.

    steps

    제일 중요한 steps입니다. 해당 속성은 어떤 명령어를 어떤 순서로 실행시킬지 정의합니다. 지금의 workflow에선

    1. Java 17 설치
    2. gradlew 파일에 실행 권한 부여
    3. gradle build 실행

    순으로 동작합니다.

    다른 조건과 이벤트도 추가하고 싶어요

    저희 프로젝트는 하나의 repository에서 frontend, backend 코드를 같이 관리하는 상황입니다. 하지만 frontend 코드를 수정했다고 java 테스트를 돌리는 것은 오히려 생산성이 줄어들겠죠.

    그리고 frontend도 테스트를 돌리고 싶지만 gradle을 사용하지 않습니다.

    그럴 때 간단한 속성을 추가하면 파일의 변경에 따라 해당 job을 실행할 조건을 정의할 수 있습니다.

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - backend/**
    - .github/**

    위와 달리 지금 pull request에는 속성이 하나가 더 있는데요. paths를 적용하면 backend 폴더 하위의 무언가 변경이 있는 pull request에만 작동을 하게 됩니다.

    그럼 backend의 workflow 파일에 paths 속성을 하나 추가하고, 비슷한 frontend workflow를 만들어주면 되겠죠.

    name: frontend test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**

    permissions:
    contents: read

    jobs:
    test:
    name: jest
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - uses: actions/checkout@v3
    - name: NPM Install
    run: npm i
    - name: Jest run
    run: npm run test

    이런 식으로 yml 파일을 하나 추가하면 frontend의 수정이 일어날 때는 jest를 실행하고, backend 폴더의 수정이 일어나면 gradlew를 실행하게 할 수 있습니다.

    Test가 실패하는 PR은 Merge 막기

    Test가 실패하는 Pull Request가 Merge 되는 일은 절대로 없어야 합니다. 그런 실수를 방지하려면 팀원 전부가 리뷰할 때 테스트를 돌려봐야하는 귀찮음이 생길 수 있습니다.

    그리고 사람은 실수해도 기계는 거짓말을 하지 않습니다. 자동으로 막도록 동작하게 만들어놓으면 그럴 일을 미연에 방지할 수 있습니다.

    Environments 확인하기

    먼저 해당 Repository의 Settings -> Environments 탭으로 들어갑니다. environments 아까 environment 속성을 보면 test라고 설정해놓은 것을 볼 수 있습니다. 해당 환경이 여기에 적용됩니다.

    Branch rule 정의하기

    이번에는 해당 Repository의 Settings -> Branches 탭으로 들어갑니다. 그리고 원하는 branch에 들어가 edit 버튼을 누릅니다.

    그리고 사진과 같이 Require deployments to succeed before merging 속성을 클릭합니다. 그리고 아래와 같이 어떤 환경을 적용할 것인지 선택할 수 있습니다.

    이 속성은 해당 배포가 성공해야 merge 할 수 있도록 브랜치를 보호하는 기능입니다.

    그리고 저희는 frontend와 backend Job의 환경을 둘 다 test라는 이름으로 정의했기 때문에 하나의 environment만 선택해도 둘 다 적용되는 효과를 볼 수 있습니다. branch rule

    적용 후

    아래와 같이 merge가 안된다는 글과 빨간색으로 경고 표시를 해주고 있습니다. blocked

    결론

    간단한 github action을 통해서 생산성을 많이 올릴 수 있는 좋은 기능인 것 같습니다. 다른 팀들도 이 기능을 도입하여 사용하는 것을 추천드립니다.

    - - + + \ No newline at end of file diff --git a/tags/pr/page/2.html b/tags/pr/page/2.html index 397eb359..0893cac7 100644 --- a/tags/pr/page/2.html +++ b/tags/pr/page/2.html @@ -5,13 +5,13 @@ "pr" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "pr" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    - - +

    "pr" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    누누

    안녕하세요 우테코 카페인팀 누누입니다

    빠르게 결과부터 보고 가시죠.

    어떤 결과가 나왔나요?

    pr의 본문 끝에, 연관된 이슈 번호를 달아주는 기능을 만들었습니다.

    밑에 사진을 보시면 쉽게 이해하실 수 있을 것 같습니다.

    imgimg

    github에서 issue 번호가 pr에 담겨있다면 2가지 장점이 생기는데요.

    1. issue를 클릭했을 때, 자동으로 그 issue로 넘어갈 수 있습니다. (호버만으로 이슈에 대한 간단한 정보를 볼 수 있습니다)
    2. pr 이 merge 되었을 때, 자동으로 issue 가 close 됩니다.

    이 과정을 손으로 진행하는 것보다, 자동으로 진행하게 되면 실수도 줄어들고, 개발 과정이 편해질 것 같아서 이 기능을 제작하게 되었는데요

    중요한 점

    이 과정을 진행하려면 밑에서 소개해드릴 브랜치 네이밍 규칙이 필요합니다.

    브랜치 이름 규칙

    • 브랜치 이름은 타입/이슈번호 으로 구성합니다. ex) feat/1
    • 타입은 feat, fix, docs, refactor, test 등 여러 가지가 있을 수 있습니다.

    이렇게 했을 때, 이슈 번호를 브랜치 명에서부터 가져올 수 있기에, 자동화를 할 수 있습니다.

    이런 규칙이 아닌, feat/action 같은 형태가 된다면 issue 번호를 찾기 어렵겠죠?

    사용 방법

    작성된 코드부터 보시고, 설명을 드리겠습니다.

    아래에 작성된 코드를. github/workflows/assign_issue_number_to_pr_body.yml로 저장하시면 끝입니다.

    name: assign_issue_number_to_pr_body

    on:
    pull_request:
    types: [ opened ]
    branches-ignore:
    - develop

    jobs:
    append_issue_number_to_pr_body:
    runs-on: ubuntu-latest
    steps:
    - name: append feature number to pr body pr branch = feat/(issueNumber)
    uses: actions/github-script@v4
    with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
    const pr = await github.pulls.get({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number
    });
    const body = pr.data.body;
    const issueNumber= pr.data.head.ref.split('/')[1];
    const newBody = body + "\n\n" + "close #" + issueNumber;
    await github.pulls.update({
    owner: context.repo.owner,
    repo: context.repo.repo,
    pull_number: context.issue.number,
    body: newBody
    });

    진행 과정

    1. pr 이 생성되면, pr에 대한 정보를 가져옵니다.
    2. pr의 본문을 가져옵니다.
    3. pr의 브랜치 이름에서 이슈 번호를 가져옵니다.
    4. pr의 본문에 이슈 번호를 추가합니다.
    5. pr의 본문을 업데이트합니다.

    이 과정을 통해서, 직접 pr의 본문을 수정하지 않아도, 자동으로 이슈 번호가 추가되기에, 실수를 줄일 수 있으니, 한 번 시도해 보세요

    + + \ No newline at end of file diff --git a/tags/prod.html b/tags/prod.html index 1d23d82f..064287ee 100644 --- a/tags/prod.html +++ b/tags/prod.html @@ -5,19 +5,19 @@ "prod" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "prod" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    제이

    안녕하세요. +

    "prod" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    제이

    안녕하세요. 카페인 팀의 제이입니다.

    오늘은 저희가 EC2 인스턴스를 받으면서, 어떻게 dev, prod 배포 환경을 분리했는지 적어보려고 합니다. 기존 카페인 팀의 EC2 구조는 여기서 보실 수 있습니다.


    기존 상황과 문제점

    카페인 팀에서는 기존에 3대의 EC2 인스턴스가 있었습니다. 각각 infra, dev, db 역할을 하는 인스턴스로 존재하고 있었습니다.

    저희는 release 브랜치를 통해 dev서버에 배포를 한 후 검증이 된다면, 실제 사용자들이 사용하는 prod 서버에 배포하고 있습니다.

    문제는 기존의 3대의 인스턴스 중에서 dev 서버에 있었습니다. 기존 dev 서버는 총 4개의 서버를 배포하고 있었고 배포하는 서버는 다음과 같습니다. prod-BE, prod-FE, dev-BE, dev-FE

    그리고, 기존 dev 서버에서는 환경을 분리해주기 위해서 Nginx를 통해서 포트 포워딩은 다음과 같이 해주었습니다.

    • prod-BE = 8080
    • prod-FE = 3031
    • dev-BE = 8081
    • dev-FE = 3031

    카페인 팀에서는 dev, prod 환경이 분리되지 않아서 인스턴스의 사용량이 높았고, 이에 따라 추가적인 EC2 인스턴스가 필요했습니다.


    문제 해결

    다행히도 카페인 팀에서 추가적인 EC2 인스턴스를 받았고, 저희는 배포 환경을 분리할 수 있었습니다.

    dev-prod-server

    이와 같이 기존 dev 서버 한 개가 infra 서버와 연결되어 있었는데, 두 갈래로 나뉜 것을 확인하실 수 있습니다.

    먼저 배포는 다음과 같이 진행됩니다.

    release branch에 push가 일어나면 dev서버에 배포 작업이 이뤄집니다. prod branch에 push가 일어나면 prod서버에 배포 작업이 이뤄집니다.

    또한 기존 dev 서버에서 4개의 포트포워딩 또한 굳이 그럴 필요가 없어졌습니다. 새로운 서버가 추가됨에 따라 dev, prod 서버 각각 Nginx에서 포트포워딩을 동일하게 FE:3000, BE:8080 으로 변경하였습니다.

    이렇게 카페인 팀에서는 dev, prod 환경을 분리했습니다.

    감사합니다!

    - - + + \ No newline at end of file diff --git a/tags/react-state-management.html b/tags/react-state-management.html index dd0350da..c8367200 100644 --- a/tags/react-state-management.html +++ b/tags/react-state-management.html @@ -5,13 +5,13 @@ "react state management" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "react state management" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

    서버 상태와 클라이언트 상태의 구분

    서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

    React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

    상태의 특성으로는 크게 두 가지가 있습니다.

    클라이언트 상태

    클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

    서버 상태

    서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

    기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

    더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

    왜 Tanstack Query였나?

    vs RTK Query

    RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

    vs SWR

    SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

    카페인 팀에서 하려는 일은요…

    저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

    메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

    • 지도에서 충전소 조회
      • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
      • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
      • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
      • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
    • 전국 충전소 검색기
      • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
      • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
      • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
      • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

    프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

    - - +

    "react state management" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

    서버 상태와 클라이언트 상태의 구분

    서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

    React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

    상태의 특성으로는 크게 두 가지가 있습니다.

    클라이언트 상태

    클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

    서버 상태

    서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

    기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

    더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

    왜 Tanstack Query였나?

    vs RTK Query

    RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

    vs SWR

    SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

    카페인 팀에서 하려는 일은요…

    저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

    메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

    • 지도에서 충전소 조회
      • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
      • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
      • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
      • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
    • 전국 충전소 검색기
      • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
      • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
      • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
      • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

    프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

    + + \ No newline at end of file diff --git a/tags/react-wrapper.html b/tags/react-wrapper.html index c80a23be..f0f18dbb 100644 --- a/tags/react-wrapper.html +++ b/tags/react-wrapper.html @@ -5,13 +5,13 @@ "react-wrapper" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "react-wrapper" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    - - +

    "react-wrapper" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    + + \ No newline at end of file diff --git a/tags/react.html b/tags/react.html index 34e31d56..e664f364 100644 --- a/tags/react.html +++ b/tags/react.html @@ -5,13 +5,13 @@ "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    - - +

    "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    + + \ No newline at end of file diff --git a/tags/react/page/2.html b/tags/react/page/2.html index 2dcf0192..87cb7811 100644 --- a/tags/react/page/2.html +++ b/tags/react/page/2.html @@ -5,13 +5,13 @@ "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    - - +

    "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    + + \ No newline at end of file diff --git a/tags/react/page/3.html b/tags/react/page/3.html index a293b49b..670b089e 100644 --- a/tags/react/page/3.html +++ b/tags/react/page/3.html @@ -5,13 +5,13 @@ "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    - - +

    "react" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    지도 api 벤더 선택 이유

    국내 서비스 중인 지도 서비스로는 google, naver, kakao가 있습니다.

    이 중에서도 google maps api는 css로 지도의 테마를 직접 스타일링할 수 있는 기능이 있어서 선택하게 됐습니다.

    google maps api를 사용하기 위해서 별도의 라이브러리 사용이 필수는 아니지만

    저희 팀에서 대중적인 라이브러리들과 기본 환경 설정법을 모두 테스트 했을 때, 반드시 사용하고 싶은 라이브러리가 존재하여 비교를 기록으로 남기게 됐습니다.

    google maps api 관련 라이브러리

    (선택한 라이브러리들은 ✅으로 표시했습니다.)

    google maps API

    https://github.com/tomchentw/react-google-maps

    이 라이브러리는 구글에서 공식으로 제공하는 지도 api로, HTML DOM에 구글 지도를 부착하고, 사용(조작)할 수 있도록 도와줍니다. 이 라이브러리는 vanilla Javascript 기반으로 동작합니다.

    @types/google.maps

    https://www.npmjs.com/package/@types/google.maps

    TypeScript에서 구글 지도를 사용할 때 타입을 제공해주는 역할을 합니다.

    @googlemaps/js-api-loader

    https://www.npmjs.com/package/@googlemaps/js-api-loader

    이 라이브러리는 구글에서 공식으로 제공하는 지도 호출 api로, api key만 넘겨주더라도 구글 지도를 스크립트 형태로 불러와주는 역할을 하는 라이브러리입니다. 별도로 html 조작 없이 불러온 라이브러리에서 구글 지도를 꺼내서 동적으로 사용할 수 있습니다. vanilla Javascript 기반으로 동작하여 어디에서나 사용이 가능합니다.

    대중적인 라이브러리 비교

    react-google-maps@react-google-maps/api@googlemaps/react-wrapper
    링크https://www.npmjs.com/package/react-google-mapshttps://www.npmjs.com/package/@react-google-maps/apihttps://www.npmjs.com/package/@googlemaps/react-wrapper
    설명이 라이브러리는 개인이 만든 라이브러리로, google maps API를 react DOM 위에 올려서 사용하게 돕습니다.
    구글 지도와 마커를 react component 처럼 사용하여 react스럽게 렌더링 하는 것을 지원합니다.
    react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리였지만 2018년 이후로 업데이트가 끊겼습니다.
    이 라이브러리도 개인이 만든 라이브러리로 앞서 소개한 react-google-maps를 개량하여 만든 라이브러리입니다.
    이 라이브러리 역시 react에 지도나 마커 컴포넌트를 호출해서 사용이 가능합니다.
    현재 react 진영에서 가장 대중적으로 사용되는 구글 지도 라이브러리 입니다.
    이 라이브러리는 구글에서 공식으로 제공하는 react용 라이브러리입니다.
    이 라이브러리는 앞서 소개한 js-api-loader를 활용하여 만든 Wrapper 컴포넌트를 제공하는데, 구글 지도를 호출하는 과정에서 수신중, 실패, 성공에 따라 지도를 보여줄 지, 로딩중 컴포넌트를 보여줄 지, 에러 컴포넌트를 보여줄 지 결정하는 기능이 있습니다.
    이외에는 기존의 js-api-loader의 기능과 완벽하게 동일합니다. (라이브러리를 열어서 직접 확인해봤습니다.)
    선택여부

    라이브러리 선택 이유

    저희 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 다보니 지도 위에 띄워줘야 할 마커를 최적화 하는 과정이 굉장히 중요합니다.

    1. 전국 6만여 개의 마커를 전부 보여줄 수 없다.
    2. 현재 디스플레이 영역의 마커만을 호출해야한다.
    3. 그 마커들의 렌더링 과정을 저수준에서 다룰 수 있어야 한다.

    이런 원칙을 가지고 있기에 대중적인 라이브러리들(react-google-maps, @react-google-maps/api)은 저희의 선택지에 없었습니다.

    따라서 구글 지도는 오로지 vanilla로 제공되는 상태에서 직접 제어하기로 결정하였고, 마커를 관리하는 주체 또한 구글 지도에서 직접 컨트롤을 하려고 합니다.

    따라서 구글 지도를 호출하는 작업은 @googlemaps/react-wrapper에 맡기고, 불러온 구글 지도는 vanilla로 통제하기로 했습니다.

    지도의 조작, 지도에 마커를 찍는 과정을 모두 공식 문서에 나와있는 방법대로 통제하려고 합니다.

    기존의 라이브러리들은 마커나 지도를 컴포넌트화 한 상태이기에 최적화 과정에서 저희가 제어할 수 없는 부분들이 있다고 생각합니다. 따라서 트러블슈팅 과정에서 마커의 호출 시점, 메모리에서 해제하는 시점, 렌더링하는 시점 등의 작업들을 훨씬 더 세밀하게 하려면 google maps api을 있는 그대로 사용할 수 있어야 합니다. 따라서 지도에 관련된 기능은 react DOM 위에서가 아닌 vanilla 환경에서 작업을 할 것입니다.

    구글 지도 제어 전략

    1. 구글 지도와 마커는 항상 바닐라 환경(react DOM 바깥)에서 동작하게 한다.
    2. 바닐라 환경에서만 동작하게 하여 리액트 컴포넌트에서의 재 렌더링을 일절 방지한다.
    3. 마커나 지도의 동작 이벤트에 의해 UI를 조작해야하는 경우에는 react DOM 조작을 하도록 한다.
    4. 바닐라 환경인 google maps api와 react DOM 사이의 제어 과정에는 useSyncExternalStore 훅을 이용하여 리액트 UI를 강제로 동기화 시킬 수 있도록 한다.

    구글 지도는 바닐라 환경에서, 각종 UI 통제는 리액트에서 통합하여 사용하는 환경을 구상하고 있습니다.

    시중에 나와있는 대부분의 라이브러리들을 활용하여 비교하고 테스트한 결과 @googlemaps/react-wrapper를 선택하는 것이 최적화와 생산성, 앱 안정성을 모두 확보할 수 있는 선택이라고 생각했습니다.

    현재 카페인 팀에서 사용중인 지도 제어에 관한 방법은 이후에 작성 될 글에서 상세하게 설명하겠습니다.

    + + \ No newline at end of file diff --git a/tags/record.html b/tags/record.html index cc6bf786..60d2ecce 100644 --- a/tags/record.html +++ b/tags/record.html @@ -5,13 +5,13 @@ "record" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "record" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    - - +

    "record" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    + + \ No newline at end of file diff --git a/tags/slack.html b/tags/slack.html index 5d9c415f..39c63dff 100644 --- a/tags/slack.html +++ b/tags/slack.html @@ -5,13 +5,13 @@ "slack" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "slack" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    - - +

    "slack" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/spring.html b/tags/spring.html index f939c31c..f3ec7994 100644 --- a/tags/spring.html +++ b/tags/spring.html @@ -5,13 +5,13 @@ "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    키아라

    서론

    안녕하세요 카페인팀 키아라입니다.

    이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

    사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

    프로퍼티 암호화는 왜 필요할까?

    spring:
    datasource:
    url: 데이터베이스 url
    username: 계정
    password: 비밀번호

    프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

    git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

    Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

    간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

    jasypt는 뭐지?

    Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

    민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

    사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

    공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

    사용 방법

    정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

    1. 라이브러리 추가 (= 의존성 추가)

    implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

    2. Jasypt 설정 및 Bean 등록

    key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

    @Configuration
    public class JasyptConfig {

    private String ENCRYPT_KEY = "hello";

    @Bean(name = "jasyptEncryptor")
    public StringEncryptor stringEncryptor() {
    PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

    SimpleStringPBEConfig config = new SimpleStringPBEConfig();

    config.setPassword(ENCRYPT_KEY);
    config.setAlgorithm("PBEWithMD5AndDES");
    config.setKeyObtentionIterations(1000);
    config.setPoolSize(1);
    config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
    config.setStringOutputType("base64");
    encryptor.setConfig(config);
    return encryptor;
    }
    }
    jasypt:
    encryptor:
    bean: jasyptEncryptor

    3. 암호화

    라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

    이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

    암복호화 사이트

    평문

      datasource:
    url: 데이터베이스 url
    username: 계정
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    나머지도 마저 암호화해줍시다.

      datasource:
    url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
    username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    실행

    올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

    그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

    실행 실패

    그런데 뭔가 이상하지 않나요?

    프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

    Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

    그럼 이 키를 어디에 숨길 수 있을까요?

    저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

    그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

    바로 환경변수를 설정하는 것이죠.

    + 환경변수 설정

    private String ENCRYPT_KEY = "hello";

    기존의 키를 관리하는 방식이었습니다.

    우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

    // JasyptConfig.class
    @Value("${jasypt.encryptor.password}")
    private String ENCRYPT_KEY;
    // application.yml
    jasypt:
    encryptor:
    password: hello

    이제 환경변수를 설정해봅시다.

    Run > Edit Configurations... 경로로 들어가면

    Run/Debug Configurations 창이 나오는데

    Environment variables: 부분에 ENCRYPT_KEY=hello

    라고 적어주세요.

    그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

    jasypt:
    encryptor:
    password: ${ENCRYPT_KEY}

    긴 글 읽어주셔서 감사합니다.

    - - +

    "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    키아라

    서론

    안녕하세요 카페인팀 키아라입니다.

    이번 프로젝트를 시작하면서 프로퍼티를 암호화하는 방법으로 jasypt를 알게되어

    사용하는 방법을 익혀 저희 프로젝트에 적용해볼 계획입니다.

    프로퍼티 암호화는 왜 필요할까?

    spring:
    datasource:
    url: 데이터베이스 url
    username: 계정
    password: 비밀번호

    프로젝트를 진행하면서 yml 파일에 DB 연결 URL이나 계정, 비밀번호 같이 노출되어선 안 되는 민감한 정보들이 많습니다.

    git의 public repository와 CI/CD를 연동해 어플리케이션을 배포한다면 중요한 정보가 탈취될 가능성이 있죠.

    Jasypt 라이브러리를 사용하면 평문으로 된 데이터베이스 접속 정보를 암호화 하여 방어막을 한 겹 쌓을 수 있게 됩니다.

    간략하게 라이브러리를 소개하고 사용 방법을 알아볼까요?

    jasypt는 뭐지?

    Jasypt이란 쉽게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리입니다.

    민감한 평문 정보를 암호화하고, 아래처럼 설정 값을 지정하면 어플리케이션이 실행될 때 자동으로 이를 복호화하여 사용합니다.

    사용자가 편하게 암호화 기능을 사용할 수 있도록 제공하는 Java 라이브러리로

    공식 홈페이지는 http://www.jasypt.org/ 에 가면 더 자세한 정보를 확인할 수 있습니다.

    사용 방법

    정말 간단하게 라이브러리 추가, key값 넘겨주기, 암호화 세 가지 단계로 프로퍼티를 암호화하여 관리할 수 있습니다.

    1. 라이브러리 추가 (= 의존성 추가)

    implementation "com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.3"

    2. Jasypt 설정 및 Bean 등록

    key를 사용해서 Bean을 등록하는 기본 설정입니다. 여기서 Bean의 이름을 jasyptEncryptor라고 설정했다면 프로퍼티 등록해야 합니다.

    @Configuration
    public class JasyptConfig {

    private String ENCRYPT_KEY = "hello";

    @Bean(name = "jasyptEncryptor")
    public StringEncryptor stringEncryptor() {
    PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();

    SimpleStringPBEConfig config = new SimpleStringPBEConfig();

    config.setPassword(ENCRYPT_KEY);
    config.setAlgorithm("PBEWithMD5AndDES");
    config.setKeyObtentionIterations(1000);
    config.setPoolSize(1);
    config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
    config.setStringOutputType("base64");
    encryptor.setConfig(config);
    return encryptor;
    }
    }
    jasypt:
    encryptor:
    bean: jasyptEncryptor

    3. 암호화

    라이브러리를 사용할 준비는 거의 다 끝났습니다. 이제 암호화하여 프로퍼티에 작성합니다.

    이때 암호화 하는 방법은, 아래 사이트에 접속해 평문과 키를 입력한 후 나온 암호문을 프로퍼티 파일에 'ENC(암호문)' 로 작성합니다.

    암복호화 사이트

    평문

      datasource:
    url: 데이터베이스 url
    username: 계정
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    나머지도 마저 암호화해줍시다.

      datasource:
    url: ENC(j94r94hQbd1SfFHGCUeweg+GGDosfnxP8dL0FQxfXtE=)
    username: ENC(vp3Gw8kLpwDZhmMMqf88/Q==)
    password: ENC(piAhHYGHR3dWDkdco6C3n8TpJdyq8FnO)

    실행

    올바른 암호문을 입력했다면 정상적으로 실행이 됩니다.

    그러나 이때 임의로 암호문을 수정한다면 다음과 같이 빌드를 실패합니다.

    실행 실패

    그런데 뭔가 이상하지 않나요?

    프로퍼티는 분명 암호화 했는데 키가 코드에 그대로 노출되어 있습니다.

    Git의 public Repository에 배포하면 다른 사람들도 볼 수 있습니다.

    그럼 이 키를 어디에 숨길 수 있을까요?

    저는 처음에 일반 file에 키를 넣어놓고 파일을 읽어오는 식으로 키를 관리하려고 했습니다. 당연히 해당 파일은 .gitignore로 커밋 대상에서 제외해야겠죠.

    그런데 이것보다 더 쉽고 빠른 방법이 있습니다.

    바로 환경변수를 설정하는 것이죠.

    + 환경변수 설정

    private String ENCRYPT_KEY = "hello";

    기존의 키를 관리하는 방식이었습니다.

    우선 이 키를 프로퍼티에서 관리하도록 설정해볼까요?

    // JasyptConfig.class
    @Value("${jasypt.encryptor.password}")
    private String ENCRYPT_KEY;
    // application.yml
    jasypt:
    encryptor:
    password: hello

    이제 환경변수를 설정해봅시다.

    Run > Edit Configurations... 경로로 들어가면

    Run/Debug Configurations 창이 나오는데

    Environment variables: 부분에 ENCRYPT_KEY=hello

    라고 적어주세요.

    그 후 다시 yml 파일로 돌아와 기존 hello로 되어있는 부분을 ${ENCRYPT_KEY}로 변경하고 실행한다면 정상적으로 작동됩니다.

    jasypt:
    encryptor:
    password: ${ENCRYPT_KEY}

    긴 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git a/tags/spring/page/2.html b/tags/spring/page/2.html index 1f6e918c..cf170107 100644 --- a/tags/spring/page/2.html +++ b/tags/spring/page/2.html @@ -5,13 +5,13 @@ "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    - - +

    "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 12분
    누누

    안녕하세요 카페인팀 nunu입니다.

    오늘은 스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법에 대해서 알아보려고 합니다.

    목차는 다음과 같습니다.

    1. 스프링에서 로그를 남기는 방법
    2. Slf4 j의 동작원리
    3. Logback의 동작원리
    4. Logback을 사용해서 슬랙으로 에러 로그를 모니터링하는 방법

    스프링에서 로그는 어떻게 찍을까?

    스프링에서 로그를 찍는 방법은 여러 가지가 있지만, 가장 간단한 방법은 System.out.println()을 사용하는 것입니다.

    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    System.out.println("test");
    return "test";
    }
    }

    당연하지만, 성능이 안 좋아서 실제 서비스에서는 사용하지 않습니다.

    스프링에서는 Slf4 j를 통해서 로그를 남길 수 있습니다.

    @Slf4j // private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같다.
    @RestController
    public class TestController {

    @GetMapping("/test")
    public String test() {
    log.info("test");
    return "test";
    }
    }

    이 코드를 통해서 로그를 남길 수 있는데, 자동으로 콘솔에 출력이 됩니다.

    스프링에서 로깅은 어떻게 작동하는 거지?

    스프링 4까지는 Commons Logging을 사용했었습니다.

    Commons LoggingJCL이라고도 불리며, JDK Logging, Log4 j, Logback 등 다양한 로깅 프레임워크를 지원합니다.

    JCL 은 런타임에 어떤 로깅 프레임워크를 사용할지 결정할 수 있습니다.

    런타임에 어떤 로깅 프레임워크를 사용할지 결정하는 방식으로 클래스 로더에게 질의를 하는 방식으로 작동하게 되는데

    클래스 로더에게 질의를 했을 경우에 몇 가지 문제점이 생깁니다

    1. 클래스 로더에 명확한 표준이 없고, 부모 자식 모델이 있어서, 클래스 로더에 따라서 다른 결과가 나올 수 있습니다. 참고
    2. 클래스로더는 gc의 동작에 방해를 일으켜서 메모리 누수를 발생시킬 수 있습니다. 참고

    @Slf4j 어노테이션을 붙이면, 컴파일 시점에 private final Logger log = LoggerFactory.getLogger(this.getClass()); 와 같은 코드로 변환됩니다.

    스프링 5에서는 Slf4j 가 사용하는 것처럼, 컴파일 타임에 어떤 로깅 프레임워크를 사용할지 결정하는 기능을 작성했고, Commons Logging을 사용하지 않게 되었습니다.

    spring 5에서 변경되었다는 링크

    Slf4 j에 대해서 알아보자

    Slf4 j는 로깅을 위한 인터페이스를 제공하는 프레임워크입니다.(Simple Logging Facade for Java)

    컴파일 타임에, 어떤 로그 라이브러리를 사용할지 결정하는 기능을 제공합니다.

    로그 라이브러리를 바꾸려고 했을 때, 기존 코드는 하나도 건드리지 않고, 로그 라이브러리만 바꿔주면 되도록 해줍니다.

    조금 더 자세한 동작 원리를 알아보자

    only slf4j

    Slf4 j 만을 사용했을 경우 위 사진 같은 형태로 요청이 처리가 됩니다.

    Slf4 j 라는 인터페이스를 통해서 로그를 남기고, 어떤 로그 라이브러리를 사용할지는 Slf4j binding이라는 것을 통해서 결정합니다.

    Slf4j bindingSlf4j의 인터페이스를 구현하고 있지 않은 라이브러리의 구현체를 연결해 주는 역할을 합니다.

    그 구현체로 Slf4j-log4 j12-{version}. jar 같은 것이 있다.

    이와는 다르게 Logback 은 Slf4 j 를 구현하고 있기에, Slf4j binding 을 사용하지 않아도 됩니다.

    logback example

    위 사진처럼 Slf4j binding 을 사용하지 않고, Logback 바로 사용하는 것도 가능합니다.

    그렇다면 Slf4 j를 바로 사용하지 않은 코드에서 Slf4j 를 사용하려면 어떻게 해야 할까요?

    slf4j working principle

    위 사진처럼 Slf4j bridge 를 통해서 외부 라이브러리를 사용하는 것처럼 갈아 끼울 수 있습니다.

    Log4j2 를 사용하는 코드를 전혀 바꾸지 않아도, BridgeSlf4j 를 통해 Logback으로 자연스럽게 로그를 남길 수 있도록 해줍니다.

    Logback에 대해서 알아보자

    Logback 은 스프링에서 기본으로 사용될 만큼 인기 있는 로그 라이브러리입니다.

    logback 동작 과정

    공식문서에서 아주 핵심적인 동작원리를 설명해주고 있는 사진이라서 가져왔습니다.

    너무 어려워 보여서, 조금 자세하게 각각의 구성요소에 대해서 알아보도록 하겠습니다

    이에 대해 알아보도록 하겠습니다

    로그백의 구성요소

    Appender

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 합니다.

    외부로부터 어떤 데이터를 받아서, 어떤 방식으로 처리할지에 대해서 전체적으로 설정할 수 있습니다.

    기본적으로 수많은 Appender 가 제공되고 있습니다.

    • ConsoleAppender
    • FileAppender
    • RollingFileAppender
    • AsyncAppender
    • DBAppender
    • SMTPAppender
    • SocketAppender
    • SyslogAppender

    저희는 Slack에 알림을 주는 것이 목적이기 때문에, SlackAppender를 사용하면 될 것 같습니다.

    하지만 SlackAppender는 제공되고 있지 않기에 직접 구현을 해야 하는데요

    이를 구현했을 때, Slack API 가 끝날 때까지, 계속 기다리고 있을 필요가 없기에, AsyncAppender를 사용하는 것이 좋을 것 같습니다.

    사용 방법은 다음과 같습니다. xml 기반으로 가능한데요

    <configuration>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>myapp.log</file>
    <encoder>
    <pattern>%logger{35} -%kvp -%msg%n</pattern>
    </encoder>
    </appender>

    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE" />
    </appender>

    <root level="DEBUG">
    <appender-ref ref="ASYNC" />
    </root>
    </configuration>

    만약 여기에 있는 기능들로 부족하다면, 직접 Appender 를 구현해서 사용할 수도 있습니다.

    직접 구현하려면 AppenderBase를 상속받아서 구현하면 됩니다.

    이 클래스는 필요한 부분이 대부분 구현되어 있고, appender 만 구현하면 바로 사용할 수 있습니다. 당연하지만 필요하다면 override 도 가능하죠

    Layout

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 합니다.

    Appender는 로그를 어디에 출력할지를 결정하는 역할을 하고, Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하도록 하는 것이 이상적이지만

    Logback 은 Appender에서 Layout 을 직접 지정할 수 있도록 해주고 있습니다.

    따라서, 직접 Layout 을 만들지 않고, Appender 에서 기존에 이미 있는 패턴만 사용하려고 합니다

    Encoder

    Encoder는 Layout 과 비슷한 역할을 합니다.

    Layout 은 로그를 어떤 형식으로 출력할지를 결정하는 역할을 하고, Encoder 는 실제 byte 형태로 변환하는 역할을 합니다.

    Slack의 webhook을 사용할 것이지만, AppenderBase를 사용하기에, 이번에는 사용할 수 없습니다.

    Filter

    Filter는 로그를 어떤 조건에 따라서 출력할지를 결정하는 역할을 합니다.

    Filter 는 Appender를 등록하며 같이 등록할 수 있는데요

    이번 프로젝트에서는 Level 이 ERROR 이상인 것만 출력하도록 하고 싶기에, LevelFilter를 사용하면 좋을 것 같습니다.

    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>INFO</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
    </filter>
    <encoder>
    <pattern>
    %-4relative [%thread] %-5level %logger{30} -%kvp -%msg%n
    </pattern>
    </encoder>
    </appender>
    <root level="DEBUG">
    <appender-ref ref="CONSOLE" />
    </root>
    </configuration>

    와 비슷하게 사용할 수 있어 보입니다.

    그러면 실제로 프로젝트에서 error 발생 시 slack으로 알림을 주는 것을 구현해 보도록 하겠습니다.

    슬랙에 추가하는 방법

    이 블로그를 보고서 작성했습니다

    실제 구현

    구현된 결과물은 아래와 같습니다

    slack appender

    SlackAppender 구현하기

    public class SlackAppender extends AppenderBase<ILoggingEvent> {

    @Override
    protected void append(final ILoggingEvent eventObject) {
    final var restTemplate = new RestTemplate();
    final var url = "https://hooks.slack.com/services/";
    final Map<String, Object> body = createSlackErrorBody(eventObject);
    restTemplate.postForEntity(url, body, String.class);
    }

    private Map<String, Object> createSlackErrorBody(final ILoggingEvent eventObject) {
    final String message = createMessage(eventObject);
    return Map.of(
    "attachments", List.of(
    Map.of(
    "fallback", "요청을 실패했어요 :cry:",
    "color", "#2eb886",
    "pretext", "에러가 발생했어요 확인해주세요 :cry:",
    "author_name", "car-ffeine",
    "text", message,
    "fields", List.of(
    Map.of(
    "title", "우선순위",
    "value", "High",
    "short", false
    ),
    Map.of(
    "title", "서버 환경",
    "value", "local",
    "short", false
    )
    ),
    "ts", eventObject.getTimeStamp()
    )
    )
    );
    }

    private String createMessage(final ILoggingEvent eventObject) {
    final String baseMessage = "에러가 발생했습니다.\n";
    final String pattern = baseMessage + "```%s %s %s [%s] - %s```";
    final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    return String.format(pattern,
    simpleDateFormat.format(eventObject.getTimeStamp()),
    eventObject.getLevel(),
    eventObject.getThreadName(),
    eventObject.getLoggerName(),
    eventObject.getFormattedMessage());
    }
    }

    이 과정에서 url을 직접 입력하시면 됩니다.

    그리고, 이렇게 만든 SlackAppender를 logback-spring.xml 에 등록하면 됩니다.

    <?xml version="1.0" encoding="UTF-8"?>

    <configuration scan="true" scanPeriod="60 seconds">
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_FILE" value="${LOG_FILE:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}}/spring.log}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
    <root level="INFO">
    <appender-ref ref="FILE"/>
    <appender-ref ref="CONSOLE"/>
    </root>
    <appender name="SLACK_APPENDER" class="racingcar.SlackAppender">
    </appender>
    <appender name="ASYNC_SLACK_APPENDER" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="SLACK_APPENDER"/>
    </appender>
    <logger name="racingcar" level="ERROR" additivity="true">
    <appender-ref ref="ASYNC_SLACK_APPENDER"/>

    </logger>

    </configuration>

    이렇게 하면, racingcar 패키지에서 에러가 발생할 때만 slack으로 알림을 받을 수 있습니다.

    결론

    slack appender

    이번 글에서는 log 레벨에 따라 slack 으로 알림을 받는 방법을 알아보았습니다.

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/spring/page/3.html b/tags/spring/page/3.html index 6bbf6933..d1e1ee41 100644 --- a/tags/spring/page/3.html +++ b/tags/spring/page/3.html @@ -5,13 +5,13 @@ "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    - - +

    "Spring" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    누누
    박스터

    안녕하세요 카페인팀 누누입니다

    이번에는 대량의 데이터를 DB에 넣는 과정을 최적화하는 과정에서 알게 된 내용을 공유하려고 합니다

    이번 최적화의 목표

    전기차 충전소에 대한 공공 데이터를 가져오고, 그 데이터를 DB 에 넣는 과정을 최적화해보자

    대량의 데이터를 삽입하는 과정

    저희 팀의 요구사항을 간단하게 정리하면 다음과 같습니다

    1. 대량의 데이터를 공공 데이터에서 전기차 충전소와 전기차 충전기에 대한 데이터를 가져온다
      • 충전소는 6만 개, 충전기는 23만 개의 데이터가 존재한다.
      • 한 번에 가져올 수 있는 양은 9999개 까지다.
    2. 이 데이터를 DB에 넣는다
      • 충전소와 충전기는 1:N 관계이다

    최적화 전은 어떤 상황이었는데?

    before_optimize

    위 사진을 잘 보시면 아실 수 있으시겠지만, 2000개를 저장하는데, 231.762 초가 사용되었습니다.

    물론 출력을 위한 시간도 포함되었기에, 230초 정도라고 생각하셔도 좋습니다

    1만 개라면? 231.762초 * 5 = 1,158.81초

    23만 개라면? 1158.81 * 23 = 26,652.63초

    시간으로 바꿔보면 7.4 시간이 걸린다는 것을 볼 수 있습니다

    이 과정에서 볼 수 있는 문제점

    1. 데이터를 저장할 때마다, 새로운 Transaction 이 생성된다.

    어떻게 개선할 수 있을까?

    데이터를 저장할 때마다, 새로운 Transaction 이 생성되는 것을 방지하기 위해, 전체를 하나의 트랜잭션으로 묶는다

    전체를 한 트랜잭션으로 묶은 버전

    all_in_transaction

    이 과정에서 2000개를 저장하는데 65초 가 사용되었습니다.

    1만 개라면? 65초 * 5 = 325초

    23만 개라면? 325초 * 23 = 7,475초

    시간으로 바꿔보면 2시간이 걸린다는 것을 볼 수 있습니다

    전체적으로 3배 정도 빨라졌습니다

    이 과정에서 볼 수 있는 문제점

    1. 23만 개의 저장이 모두 한 트랜잭션이 되어서, 하나가 실패하면 23만개를 새로 저장해야 하는 상황에 처한다

    어떻게 개선할 수 있을까?

    23만개의 저장이 모두 한 트랜잭션이 되는 것을 방지하기 위해, 1만 개씩 영속화시킨다

    1만 개가 한 트랜잭션으로 묶인 버전

    separateTransaction

    성능상으로 개선한 부분은 그렇게 크지 않지만, 실패했을 때, 1만 개만 다시 저장하면 되기에, 훨씬 빠르게 복구가 가능합니다.

    여기서 PageNo라는 클래스는, i를 바로 참조했을 경우, effectively final을 보장할 수 없어서 만들었습니다.

    성능은 전체를 한 트랜잭션으로 묶은 버전과 큰 차이가 나지 않습니다.

    이 과정에서 볼 수 있는 문제점

    1. id 생성 전략이 GenerationType.IDENTITY 이기에, 데이터를 저장할 때마다, DB에서 id를 생성해야 한다.

    JPA에 있는 쓰기 지연을 전혀 활용할 수 없고, DB에서 id를 생성하기 위해, DB와 매번 통신을 해야 한다.

    어떻게 개선할 수 있을까?

    id를 미리 생성해서, DB 에서 id 를 생성하는 과정을 생략한다

    ID 생성 전략을 GenerationType.Table의 형태로 바꿔서, DB에서 id를 생성하는 과정을 줄여서, 성능을 개선한다

    1만 개가 한 트랜잭션으로 묶이고, id를 미리 생성한 버전

    이때 batch size를 1000 단위로 설정해서 1000개씩 id 가 늘어나도록 설정했다

    charger_generatorstation_generator

    spring.jdbc.template.fetch-size=10000

    10000batch_size

    1자리 숫자는 앞에서부터 n(만개)를 의미하고, 2번째 숫자는 1만 개를 저장하는 데 걸린 시간(ms)을 의미합니다.

    처음 1만 개는 142초가 걸리고, 2만 개는 285초가 걸렸습니다.

    23만 개라면? 142 * 26 = 3,266초

    처음과 비교하자면 7.4시간이 걸리는 것에서 54분 정도 걸리는 것으로 개선되었습니다.

    이 과정에서 볼 수 있는 문제점

    하나의 스레드에서만 동작하기에, 성능이 개선되었지만, 여전히 느립니다.

    하나의 스레드에서만 동작하기에, 하나의 커넥션을 사용하게 됩니다.

    어떻게 개선할 수 있을까?

    여러 스레드에서 동작하게 하고, 여러 커넥션을 사용하게 합니다.

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전

    multi_thread

    이 버전에서 89991 개를 저장하는데 총 157초가 걸렸습니다.

    23만 개라면? 157 * 3 = 471초

    시간으로 바꿔보면 5분도 채 걸리지 않는 시간이죠

    이 과정에서 볼 수 있는 문제점

    hikari connection pool 사이즈를 10으로 설정했는데, 10개의 커넥션을 사용하면서 저장을 하다 보니, 10개의 커넥션을 모두 사용하고 나서, 11번째부터는 커넥션을 가져오기 위해, 기다려야 하는 상황이 발생합니다.

    어떻게 개선할 수 있을까?

    hikari connection pool 사이즈를 25로 설정해서, 25개의 커넥션을 사용하도록 합니다.

    spring.datasource.hikari.maximum-pool-size=25

    여러 스레드에서 동작하고, 여러 커넥션을 사용하는 버전 2

    multi_thread2

    총 13만 개의 데이터를 저장하는데, 147초가 걸리고, db 인스턴스의 cpu 사용률이 100%에 가까워져서 ec2 가 다운되었습니다.

    이 과정에서 볼 수 있는 문제점

    db의 cpu 사용량을 고려하지 않고, 23만 개가 조금 넘는 데이터를 25개의 커넥션을 활용해 저장하려고 했습니다

    결론

    1. 데이터를 저장할 때마다, transaction을 사용하지 말자
    2. 데이터를 저장할 때마다, id를 생성하지 말자
    3. 여러 스레드에서 동작하고, 여러 커넥션을 사용하자
    4. db의 cpu 사용량을 고려하자

    긴 글 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git a/tags/styled-components.html b/tags/styled-components.html index 81bf5be2..e9e762d3 100644 --- a/tags/styled-components.html +++ b/tags/styled-components.html @@ -5,13 +5,13 @@ "styled-components" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "styled-components" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    - - +

    "styled-components" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 2분
    야미

    왜 styled-components인가?


    여러 CSS-in-JS 중 styled-components를 선택한 이유는 다음과 같다.

    1. 컴포넌트 안에 관련 CSS를 작성할 수 있어 컴포넌트별 디자인 코드 확인 및 수정이 용이하다.

    2. 혹자는 코드 가독성이 안 좋아진다고도 하지만, 개인적으로는 태그를 더 시맨틱 하게 작성할 수 있어서 좋다고 느꼈다.

    3. 팀원들 모두 styled-components가 익숙하다.

    4. 지금까지 사용하면서 불편한 점을 못 느꼈다.


    styled-components와 emotion은 기능도, 작성법도 상당히 유사하다.

    그래서 이번에는 styled-components 대신 emotion을 써볼까도 생각했었다.

    하지만 emotion에서만 사용 가능하던 *CSS Props라는 편리한 기능을

    styled-components(v5.2.0 이상)에서 쓸 수 있게 되기도 했고,

    '새로운 기술 공부를 해보면 좋을 것 같다'는 이유를 제외하고는

    딱히 emotion을 사용할 필요성을 못 느껴 styled-components를 채택했다.

    // *CSS Props 예시

    const buttonStyle = css`
    font-size: 18px;
    color: white;
    background: black;
    `;

    const ClickButton = styled.button<{ css: CSSProp }>`
    width: 100px;

    ${({ css }) => css}
    `;

    <ClickButton css={buttonStyle}>Click me!</ClickButton>;
    + + \ No newline at end of file diff --git a/tags/subnet.html b/tags/subnet.html index a9442588..96e7fd42 100644 --- a/tags/subnet.html +++ b/tags/subnet.html @@ -5,13 +5,13 @@ "subnet" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "subnet" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    - - +

    "subnet" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    + + \ No newline at end of file diff --git a/tags/tanstack-query.html b/tags/tanstack-query.html index 547e36cb..02e42a11 100644 --- a/tags/tanstack-query.html +++ b/tags/tanstack-query.html @@ -5,13 +5,13 @@ "tanstack query" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "tanstack query" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

    서버 상태와 클라이언트 상태의 구분

    서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

    React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

    상태의 특성으로는 크게 두 가지가 있습니다.

    클라이언트 상태

    클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

    서버 상태

    서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

    기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

    더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

    왜 Tanstack Query였나?

    vs RTK Query

    RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

    vs SWR

    SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

    카페인 팀에서 하려는 일은요…

    저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

    메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

    • 지도에서 충전소 조회
      • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
      • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
      • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
      • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
    • 전국 충전소 검색기
      • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
      • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
      • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
      • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

    프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

    - - +

    "tanstack query" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    가브리엘

    안녕하세요? 카페인 팀 FE에서 상태관리 라이브러리를 어떻게 해야할 지 고민 끝에 서드파티 라이브러리가 필요하게 되어 글을 작성하게됐습니다.

    서버 상태와 클라이언트 상태의 구분

    서버상태와 UI상태를 이해하는 것은 굉장히 중요했습니다. 데이터를 송수신하는 작업과 상태를 관리하는 작업은 유기적으로 동작해야했습니다. 기존에는 상태와 데이터 송수신 과정을 분리해서 생각했다면, 현대의 react 프로젝트들은 서버와 동기화를 해야할 상태그렇지 않은 상태로 분리해서 생각해야 합니다.

    React에서 어떤 데이터를 상태로 다뤄야 하는가에 대해서는 여러 의견이 나올 수 있다고 생각하지만 상태가 특성을 가지고 있는가에 대해서는 대부분 특성이 있다고 동의할 것입니다. 이 글에서는 React의 상태란 무엇인가?에 대해서 다루지 않고 React의 상태의 특성에 대해서만 언급을 하려고 합니다.

    상태의 특성으로는 크게 두 가지가 있습니다.

    클라이언트 상태

    클라이언트 상태는 컴포넌트들 간에 어떤 값을 공유해야하면서 오로지 React DOM 내부에서만 CRUD가 일어나는 상태를 의미합니다. 이 상태들은 React DOM 외부 세계와 크게 관련이 없으며 동기적으로 반영됩니다. 대표적으로는 UI를 조작하는 상태들이 될 것입니다. 클라이언트 상태들은 대부분 장기적으로 유지될 필요가 없기에 화면을 벗어나거나 세션이 끊기는 경우 사라져도 괜찮은 경우가 많습니다.

    서버 상태

    서버 상태는 React의 바깥 세상(서버)에 존재하는 데이터가 React의 상태 관리와 비동기적으로 동기화 된 것을 의미합니다. 어떤 상태가 외부에서 관리되는 데이터와 반드시 연동되어야 한다면 이는 곧 서버 상태임을 의미합니다. React의 상태를 CRUD 하는 것 뿐만 아닌, 서버에서도 항상 같은 일이 일어나야 합니다. 서버 상태는 장기적으로 유지되어야 하며, 세션에서 벗어나더라도 서버로 부터 복구를 해야 합니다.

    기존의 상태 관리 라이브러리들은 리액트의 전역에서 상태를 조작하는 것에 특화되어있고, 비동기적인 상태 관리도 지원하여 서버와의 통신이 가능합니다. 하지만 대부분의 라이브러리들은 클라이언트 상태를 조작하는 것에 초점이 맞춰져있습니다.

    더군다나 클라이언트 상태와 서버 상태가 하는 일이 명확하게 다른 상황에서 이 둘을 한 곳에서 관리하는 것 보다는 완벽하게 분리하는 것이 더 나을 것입니다. 따라서 서버 상태를 관리하는 것에 중점을 둔 라이브러리들이 등장하였습니다. 대표적인 라이브러리로는 RTK Query, Tanstack Query, SWR 등이 있습니다.

    왜 Tanstack Query였나?

    vs RTK Query

    RTK Query는 RTK를 반드시 사용해야 하는 것은 아니지만 RTK를 타겟으로 나온 서버 상태 관리 라이브러리입니다. 카페인 팀에서는 클라이언트 상태를 관리하기 위해 라이브러리를 사용하지 않습니다. 더욱이 Redux의 복잡한 코드 구성과 방대한 보일러 플레이트는 매력적이지 않았습니다. tanstack query에서는 무한 데이터 페칭을 지원하기 위해 Infinite Queries가 있지만 RTK Query는 그렇지 않았습니다.

    vs SWR

    SWR도 하나의 좋은 선택지였지만, 전역 상태 관리 라이브러리들이 범용적으로 지원하는 셀렉터 기능을 지원하지 않았습니다. 또, 가비지 컬렉터의 부재도 아쉬웠습니다. 재요청을 하기 위한 stale time 설정이나 쿼리 취소 기능이 없는 점도 매력적이지 않았습니다.

    카페인 팀에서 하려는 일은요…

    저희 카페인 팀의 프로젝트는 실시간 전기자동차 충전소 지도 및 사용 통계 조회 서비스 로 지도 기반의 프로젝트입니다. 서버 상태를 적극적으로 다뤄야 하는 상황에서 Tanstack Query를 서버 상태 관리 라이브러리로 선정하게 됐습니다.

    메인 기능 중 Tanstack Query가 핵심으로 사용될 것 같은 기능은 다음과 같습니다.

    • 지도에서 충전소 조회
      • 현재 접속한 클라이언트에 렌더링 된 지도 화면(디스플레이)의 크기에 따른 GPS좌표를 알아내어 서버로 부터 충전소 정보를 수신 받습니다. 즉, 화면이 이동하게 되면 사용자가 바라보고 있는 영역이 변하므로 새로운 요청을 보내게 됩니다.
      • 서버에서 수신한 충전소 정보는 실시간 사용 현황도 반영되어있으므로 주기적인 업데이트도 필요합니다.
      • 빈번한 데이터의 변화가 필요하며 그만큼 통신 실패 등 에러가 발생할 가능성도 많아지게 됩니다.
      • 사용자의 빠른 지도 이동이 발생하는 경우를 대응할 수 있어야 합니다.
    • 전국 충전소 검색기
      • 원하는 충전소 검색을 하는 기능을 지원합니다. 전국 단위로 검색 결과를 수신하는 기능입니다.
      • 네이버와 구글 검색창 처럼 사용자가 input 창에 검색어를 입력할 때 마다 검색 결과가 동적으로 표시되어야 합니다.
      • 빈번한 데이터의 변화가 필요하고, 사용자의 빠른 타이핑으로 인해 잦은 검색이 발생하는 경우를 대응할 수 있어야 합니다.
      • 이를 위해 데이터를 캐싱할 필요도 있다고 생각합니다.

    프로젝트에서 클라이언트와 서버와의 통신이 어쩌다 한번 일어난다면 굳이 라이브러리가 필요가 없겠지만, 서버의 데이터 전적으로 의존해야 하는 저희 프로젝트 특성상 Tanstack Query의 여러 기능이 생산성에 많은 도움이 될 것으로 기대합니다.

    + + \ No newline at end of file diff --git a/tags/test.html b/tags/test.html index db28c8d6..f9056a1f 100644 --- a/tags/test.html +++ b/tags/test.html @@ -5,12 +5,12 @@ "test" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "test" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. +

    "test" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. 기본 설정이 간편하고, 빠르게 테스트를 실행할 때 굉장히 유용합니다. 함수를 mocking하여 의존성이 강한 함수를 제거하여 원하는 테스트를 쉽게 구성할 수 있다는 특징이 있습니다.

    React Testing Library

    React Testing Library는 리액트 애플리케이션의 UI를 테스트하기 위한 라이브러리입니다. React 컴포넌트를 호출하여, 사용자의 의도대로 조작할 수 있는 행위를 정의할 수 있습니다. @@ -22,7 +22,7 @@ 하지만 Storybook을 이용하면 특정 컴포넌트를 Storybook 위에 올려놓고 테스트를 할 수 있어 빠르게 작업이 가능합니다. 인터렉션이나 웹접근성을 확인해주는 플러그인도 존재하여 프론트엔드 개발에서 굉장히 중요한 역할로 부상했습니다.

    저희 팀은 이외에 Cypress를 사용하는 것도 고려하였으나, 지도와 결합된 애플리케이션을 테스트하기에 다소 어려움이 있어 위 라이브러리들을 개발에 활용했습니다.

    저희는 위 테스팅 라이브러리들을 원활히 활용하기 위해 테스트 자동화를 구축했습니다.

    Jest와 React Testing Library 테스트 자동화

    name: frontend-test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**
    - .github/**

    permissions:
    contents: read

    jobs:
    test:
    name: test-when-pull-request
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Checkout PR
    uses: actions/checkout@v2
    - name: Install dependencies
    run: npm install
    - name: Test
    run: npm run test

    이벤트 트리거 설정

    pull_request 이벤트가 발생하였을 때, 해당 이벤트가 main 브랜치와 develop 브랜치에서만 동작합니다.

    변경 사항 경로 제한

    테스트를 실행할 때는 frontend 디렉토리와 .github 디렉토리 내의 파일들을 고려하도록 했습니다. 백엔드와의 환경 분리를 위해 이러한 접근 제한을 했습니다.

    권한 설정

    permissions은 읽기 권한만 설정되어 있어 코드나 파일을 변경을 방지합니다.

    작업(Job) 설정

    test라는 이름의 작업을 정의하였고, 이 작업에서는 Ubuntu 환경에서 테스트를 실행합니다. test라는 이름의 환경 변수를 사용합니다. 테스트는 (카페인 팀 레포지토리의) frontend 디렉토리에서 작업하도록 하였습니다.

    스텝(Step) 설정

    코드를 체크아웃하고, 의존성을 설치하며, 테스트를 실행하는 세 가지 단계로 구성되어 있습니다.

    이러한 설정을 통해 PR에 코드가 올라올 때 자동으로 프론트엔드 테스트가 실행됩니다.

    이러한 테스트 자동화 전략은 프론트엔드 애플리케이션을 안정적이게 개발하고 유지할 수 있도록 도와줍니다.

    Storybook의 빌드 자동화

    name: storybook-deploy

    on:
    pull_request:
    branches:
    - develop
    paths:
    - frontend/**
    - .github/**

    jobs:
    build:
    runs-on: ubuntu-22.04
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Setup Repository
    uses: actions/checkout@v3

    - name: Set up Node
    uses: actions/setup-node@v3
    with:
    node-version: 18.16.0

    - name: Install dependencies
    run: npm install

    - name: Cache node_modules
    id: cache
    uses: actions/cache@v3
    with:
    path: '**/node_modules'
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
    ${{ runner.os }}-node-

    - name: storybook build
    run: npm run build-storybook

    - name: Upload storybook build files to temp artifact
    uses: actions/upload-artifact@v3
    with:
    name: Storybook
    path: frontend/storybook-static
    deploy:
    needs: build
    runs-on: self-hosted
    steps:
    - name: Remove previous version app
    working-directory: .
    run: rm -rf dist

    - name: Download the built file to AWS
    uses: actions/download-artifact@v3
    with:
    name: Storybook
    path: frontend/dev/dist

    - name: Move folder
    working-directory: frontend/dev/
    run: |
    rm -rf /home/ubuntu/dist/*
    cp -r ./dist /home/ubuntu

    - name: comment PR
    uses: thollander/actions-comment-pull-request@v1
    env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    with:
    message: '🚀storybook: https://storybook.carffe.in/'

    비슷한 코드이지만, 매번 PR이 열릴 때 마다 스토리북이 자동으로 빌드 및 배포됩니다. 배포가 완료되면 배포된 URL을 알려 코드 리뷰할 때 참고할 수 있도록 돕습니다.

    이상 카페인 팀에서 사용하고 있는 테스팅 라이브러리와 테스트 자동화 방법을 알아봤습니다.

    - - + + \ No newline at end of file diff --git a/tags/test/page/2.html b/tags/test/page/2.html index 5b345e77..03165549 100644 --- a/tags/test/page/2.html +++ b/tags/test/page/2.html @@ -5,19 +5,19 @@ "test" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "test" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. +

    "test" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    박스터

    안녕하세요 박스터입니다.

    Pull Request시 자동으로 test를 실행하면 좋은 점

    pull request 생성 시 자동으로 테스트를 돌려준다면 다른 팀원의 pr을 굳이 제 로컬에 clone하여 테스트를 돌려보지 않아도 됩니다. 많은 시간을 단축할 수 있습니다.

    그리고 test가 실패한다면 강제로 Merge가 되지 않도록 한다면 실수로 테스트가 되지 않는 커밋을 올리는 것을 방지할 수 있겠죠.

    이 두가지만으로도 생산성이 많이 올라갈 것을 기대할 수 있습니다.

    어떻게 할 수 있나요

    Github Action을 이용하여 설정한 조건에 맞는 상황에서 명령어를 실행하여 test를 할 수 있습니다.

    Github Action 파일 생성

    1. 먼저 최상위 폴더에 .github/workflows 폴더를 생성합니다.
    2. 해당 폴더 내에 example.yml을 생성합니다.
    3. 아래와 같이 yml 파일을 작성합니다.
    name: pr test

    on:
    pull_request:
    branches:
    - main
    - develop

    permissions:
    contents: read

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    Job 이름 설정

    복잡하지 않습니다. 먼저 name 속성은 github action에서 보여질 Job의 이름을 정하는 부분입니다.

    지금은 pr test로 해두었습니다. 그럼 아래 사진과 같이 반영됩니다.

    workflows name

    workflow 트리거 설정

    다음으론 on 속성입니다. 이 속성은 workflow를 실행할 이벤트를 지정하는데 사용됩니다. 특정 이벤트 유형과 조건을 기반으로 workflow를 트리거하도록 구성할 수 있습니다.

    예를 들어 아래와 같이 정의했습니다.

    on:
    push:
    branches:
    - main
    pull_request:
    branches:
    - develop

    그렇다면 이 workflow가 작동되는 시점은 main 브랜치에 push가 되거나 develop 브랜치에 pull request를 보낼 때 작동합니다.

    권한 부여

    permissions:
    contents: read

    이런 권한을 주게 된다면 이 job은 읽기 권한밖에 없기 때문에 실수로 다른 것을 추가하지 못하게 막을 수 있습니다

    동작할 명령어 입력

    jobs:
    test:
    name: merge-test
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./backend
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'
    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Test with Gradle
    run: ./gradlew build

    name

    제일 간단히 볼 수 있는 name 설정은 아래 사진처럼 어떤식으로 보여줄지 정할 수 있습니다.

    job image

    runs-on

    runs-on 속성입니다. 해당 운영체제를 사용한다고 정의하는 부분입니다. 지금은 저희가 사용할 ec2와 같은 환경인 ubuntu에서 작동하도록 설정했지만, windows-latest, macos-latest로 변경할 수도 있습니다.

    environment

    environment 속성입니다. 해당 속성은 꼭 필요한 부분이 아니지만 branch의 rule 설정에 사용할 수 있습니다. 그리고 환경을 한꺼번에 관리할 수 있습니다.

    이 부분은 아래에 branch rule을 정하는 부분을 보시면 아마 이해가 될 것 입니다.

    defaults

    해당 속성은 어떤 폴더에서 명령어를 실행할 지 지정합니다. 지금의 저희 프로젝트에서는 한 repository에 backend, frontend 폴더를 나누었기 때문에 backend 폴더로 이동하여 명령어를 실행해야 합니다.

    그래서 working-directory./backend라고 지정했습니다.

    steps

    제일 중요한 steps입니다. 해당 속성은 어떤 명령어를 어떤 순서로 실행시킬지 정의합니다. 지금의 workflow에선

    1. Java 17 설치
    2. gradlew 파일에 실행 권한 부여
    3. gradle build 실행

    순으로 동작합니다.

    다른 조건과 이벤트도 추가하고 싶어요

    저희 프로젝트는 하나의 repository에서 frontend, backend 코드를 같이 관리하는 상황입니다. 하지만 frontend 코드를 수정했다고 java 테스트를 돌리는 것은 오히려 생산성이 줄어들겠죠.

    그리고 frontend도 테스트를 돌리고 싶지만 gradle을 사용하지 않습니다.

    그럴 때 간단한 속성을 추가하면 파일의 변경에 따라 해당 job을 실행할 조건을 정의할 수 있습니다.

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - backend/**
    - .github/**

    위와 달리 지금 pull request에는 속성이 하나가 더 있는데요. paths를 적용하면 backend 폴더 하위의 무언가 변경이 있는 pull request에만 작동을 하게 됩니다.

    그럼 backend의 workflow 파일에 paths 속성을 하나 추가하고, 비슷한 frontend workflow를 만들어주면 되겠죠.

    name: frontend test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**

    permissions:
    contents: read

    jobs:
    test:
    name: jest
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - uses: actions/checkout@v3
    - name: NPM Install
    run: npm i
    - name: Jest run
    run: npm run test

    이런 식으로 yml 파일을 하나 추가하면 frontend의 수정이 일어날 때는 jest를 실행하고, backend 폴더의 수정이 일어나면 gradlew를 실행하게 할 수 있습니다.

    Test가 실패하는 PR은 Merge 막기

    Test가 실패하는 Pull Request가 Merge 되는 일은 절대로 없어야 합니다. 그런 실수를 방지하려면 팀원 전부가 리뷰할 때 테스트를 돌려봐야하는 귀찮음이 생길 수 있습니다.

    그리고 사람은 실수해도 기계는 거짓말을 하지 않습니다. 자동으로 막도록 동작하게 만들어놓으면 그럴 일을 미연에 방지할 수 있습니다.

    Environments 확인하기

    먼저 해당 Repository의 Settings -> Environments 탭으로 들어갑니다. environments 아까 environment 속성을 보면 test라고 설정해놓은 것을 볼 수 있습니다. 해당 환경이 여기에 적용됩니다.

    Branch rule 정의하기

    이번에는 해당 Repository의 Settings -> Branches 탭으로 들어갑니다. 그리고 원하는 branch에 들어가 edit 버튼을 누릅니다.

    그리고 사진과 같이 Require deployments to succeed before merging 속성을 클릭합니다. 그리고 아래와 같이 어떤 환경을 적용할 것인지 선택할 수 있습니다.

    이 속성은 해당 배포가 성공해야 merge 할 수 있도록 브랜치를 보호하는 기능입니다.

    그리고 저희는 frontend와 backend Job의 환경을 둘 다 test라는 이름으로 정의했기 때문에 하나의 environment만 선택해도 둘 다 적용되는 효과를 볼 수 있습니다. branch rule

    적용 후

    아래와 같이 merge가 안된다는 글과 빨간색으로 경고 표시를 해주고 있습니다. blocked

    결론

    간단한 github action을 통해서 생산성을 많이 올릴 수 있는 좋은 기능인 것 같습니다. 다른 팀들도 이 기능을 도입하여 사용하는 것을 추천드립니다.

    - - + + \ No newline at end of file diff --git a/tags/to-list.html b/tags/to-list.html index 664592b1..b461308d 100644 --- a/tags/to-list.html +++ b/tags/to-list.html @@ -5,13 +5,13 @@ "toList" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "toList" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    - - +

    "toList" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 6분
    누누

    우아한테크코스에서 자바 11을 사용하는 것이 너무 익숙해진 상황이어서, java 11 대신 java 17을 쓰려면 쓰는 대신, 왜 java 17을 쓰면 좋은지에 대해서 설득을 하는 시간이 있어야 하는데요

    처음에는 단순히 record 클래스가 좋아요, collect(Collectors.toList()); 대신 toList() 만으로 해결할 수 있어서 좋아요

    까지밖에 설명할 수 없었습니다.

    이것만으로 동의를 해줘서 일단 java 17 을 사용하기로 했지만, 이번 기회에 조금 더 자세하게 알아보려고 합니다

    Java 17 과 Java 11의 중요한 차이들

    기능적인 부분과, 숨겨진 부분을 나누어볼 수 있을 것 같습니다.

    기능적인 차이점

    언제나 직접 차이를 보면 더 직관적이기 때문에, 직접 코드를 보면서 설명을 해보려고 합니다

    record 클래스

    간단한 dto 클래스를 만들었을 때 코드가 정말 간단해지는 것을 확인할 수 있습니다

    Java 11

    public class Dto {
    private final int data;

    public Dto(int data) {
    this.data = data;
    }

    public int getData() {
    return data;
    }
    }

    lombok 을 사용했을 때


    @Getter
    @AllArgsConstructor
    public class Dto {
    private final int data;
    }

    Java17

    public record Record(int data) {
    }

    이렇게 보면 훨씬 간단해진 것을 볼 수 있습니다

    예상되는 문제점

    objectMapper를 사용하면 어떻게 되나요? noArgsConstructor 가 필요하지 않나요?

    class RecordTest {

    @Test
    void objectMapper_로_변환() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    Record record = new Record(1);

    // when
    String json = objectMapper.writeValueAsString(record);

    // then
    assertEquals("{\"data\":1}", json);
    }

    @Test
    void string_에서_객체로_변환() throws JsonProcessingException {
    // given
    String json = "{\"data\":1}";
    ObjectMapper objectMapper = new ObjectMapper();

    // when
    Record record = objectMapper.readValue(json, Record.class);

    // then
    assertEquals(1, record.data());
    }
    }

    이 테스트에서 볼 수 있는 것처럼 성공적으로 deserialize, serialize 가 가능합니다

    toList() method

    Java 11

    이 부분도 정말 편의성이 높다고 생각하는 부분 중 하나인데요

    Collectors.toList() 대신 toList() 만으로도 사용이 가능합니다

    public class ToListWith11 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .collect(Collectors.toList());
    System.out.println(result);
    }
    }

    Java 17

    public class ToListWith17 {

    public static void main(String[] args) {
    List<Integer> list = List.of(1, 2, 3, 4, 5);
    List<Integer> result = list.stream()
    .filter(i -> i > 3)
    .toList();
    System.out.println(result);
    }
    }

    switch expression

    Java 11

    우테코에서는 switch, case 를 싫어하기에 볼 수는 없겠지만

    switch 문에도 정말 편하게 바뀌었는데요

    public class SwitchWith11 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = 0;
    switch (day) {
    case "Monday":
    result = 1;
    break;
    case "Tuesday":
    result = 2;
    break;
    case "Wednesday":
    result = 3;
    break;
    case "Thursday":
    result = 4;
    break;
    case "Friday":
    result = 5;
    break;
    case "Saturday":
    result = 6;
    break;
    case "Sunday":
    result = 7;
    break;
    }
    System.out.println(result);
    }
    }

    Java 17

    public class SwitchWith17 {

    public static void main(String[] args) {
    String day = "Sunday";
    int result = switch (day) {
    case "Monday" -> 1;
    case "Tuesday" -> 2;
    case "Wednesday" -> 3;
    case "Thursday" -> 4;
    case "Friday" -> 5;
    case "Saturday" -> 6;
    case "Sunday" -> 7;
    default -> 0;
    };
    System.out.println(result);
    }
    }

    코드 량이 엄청 줄어든 것을 확인하실 수 있습니다

    instanceof pattern matching

    물론 instanceof 를 사용할 경우가 많은가? 하면 많지는 않겠지만

    아래와 같이 변경되었습니다

    Java 11

    public class InstanceOfWith11 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
    }
    }
    }

    Java 17

    public class InstanceOfWith17 {

    public static void main(String[] args) {
    Object obj = "Hello";
    if (obj instanceof String str) {
    System.out.println(str.toUpperCase());
    }
    }
    }

    number format

    이 기능은 12에 나왔는데요

    언어별로 숫자를 표현하는 방식이 다르지만, 쉽게 표현할 수 있도록 도와주는 기능입니다

    Java 17

    public class NumberFormatterWith11 {
    public static void main(String[] args) {
    int number = 1_000_000;

    String result = NumberFormat.getCompactNumberInstance(Locale.KOREA, NumberFormat.Style.LONG).format(number);

    System.out.println(result.equals("100만"));
    }
    }

    나머지 부분은 사실 그렇게 큰 역할을 할 것 같지는 않아서 생략하겠습니다

    숨겨진 부분들

    gc throughput

    위의 사진은 gc 의 버전별 처리량입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 15% 정도 향상되었고, java 11과는 10% 정도 향상되었습니다.

    gc latency

    위의 사진은 gc의 버전별 지연시간입니다.

    G1 GC 를 기준으로 본다면 Java8 과의 차이는 30% 정도 향상되었고, java 11과는 25% 정도 향상되었습니다.

    이와 같이, 단순하게 새로운 기능만 추가되는 것이 아니라 꾸준히 성능도 향상되고 있습니다.

    이런 부분을 고려했을 때, Java 17을 사용하는 것이 좋을 것 같습니다.

    참고

    + + \ No newline at end of file diff --git a/tags/trouble-shooting.html b/tags/trouble-shooting.html index f6e21c20..f40d2114 100644 --- a/tags/trouble-shooting.html +++ b/tags/trouble-shooting.html @@ -5,12 +5,12 @@ "trouble-shooting" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "trouble-shooting" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. +

    "trouble-shooting" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 16분
    박스터

    안녕하세요 부릉부릉 허리케인 박스터입니다.

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 충전소와 충전기들의 새로운 정보를 업데이트하거나, 저장하는 로직에서 아래와 같이 OOM(Out of memory)가 발생했기 때문입니다. error-log

    왜 발생했을까

    먼저 간단히 저희가 처한 상황에 대해 설명드리겠습니다.

    처음 어플리케이션을 실행하면 공공 API를 호출하여 충전소와 충전기에 대한 모든 정보들을 가져와 저장합니다. (충전소 약 6만 곳 + 충전기 약 23만 기)

    하지만 이러한 정보들은 수정이 될 수 있고, 충전소와 충전기가 추가될 수 있습니다.

    그러므로 정확한 정보가 사용자에게 가장 중요시되는 서비스에서 이러한 정보들이 늦게 반영이 된다거나, 반영이 되지 않는다면 저희 서비스를 사용할 사용자가 없을 것이라 판단했습니다.

    그래서 하루에 한 번 충전소와 충전기들의 정보를 업데이트하고, 추가된 충전소와 충전기를 저장하는 로직을 만들었습니다.

    대략적인 로직은 아래와 같습니다.

        public void updatePeriodicStations() {
    List<Station> stations = requestStations();
    stationUpdateService.updateStations(stations);
    }

    public void updateStations(List<Station> updatedStations) {
    List<Station> stations = stationRepository.findAllFetch();

    Map<String, Station> savedStationsByStationId = stations.stream()
    .collect(Collectors.toMap(Station::getStationId, Function.identity()));

    // 저장된 정보와 비교하여 새로운 충전소와 충전기를 찾는 로직
    ...

    saveAllStations(toSaveStations);
    updateAllStations(toUpdateStations);

    saveAllChargers(toSaveChargers);
    updateAllChargers(toUpdateChargers);
    }

    간단하게 말씀드리면 requestStations() 메서드는 공공 API에서 모든 충전소와 충전기를 요청하고 받아오는 메서드입니다. 23만 + 6만개의 정보를 받아오는 것입니다. 이렇게 많은 정보를 받아오고 메모리에 올린다는 것은 누가봐도 비효율적입니다. 하지만 이러한 선택을 한 이유는 공공 API는 저희가 어떤 방식으로 보내줄 지 모른다는 것이였습니다. 그래서 어쩔 수 없이 23만건을 모두 요청해야한다는 부분은 바꿀 수 없는 한계입니다.

    그 다음으로는 요청해서 받아온 데이터들과 데이터베이스에 저장되어 있던 데이터들을 findAll()을 통해 비교하고 새로운 충전소와 충전기는 저장하고, 업데이트된 충전소와 충전기는 수정합니다.

    이런 로직은 총 (23 + 6) * 2 만건의 객체 약 58만개를 Heap 메모리에 적재합니다. 많다고는 생각했지만, 일단 제 로컬환경에서는 잘 작동했고, 기능 구현이 우선이기 때문에 추후에 개선을 하기로 하고 넘어갔습니다.

    하지만 개발 서버 배포를 하고 다음날 서버가 접속이 되지 않는 것을 확인했고, 로그를 보니 위의 사진과 같이 OOM이 발생한 것을 확인할 수 있었습니다.

    해결 방안

    Heap size 조절하기

    일단 임시 방편으로 Heap memory의 최대 크기를 늘리는 법이였습니다. JVM은 실행되는 환경에 따라 힙 메모리의 최대 사이즈를 정합니다. 힙 메모리는 설정하지 않으면 해당 환경의 메모리 1/4로 설정합니다. @@ -31,7 +31,7 @@ 하지만 직접 확인해보기 전까지는 확신할 수 없으니 간단히 Runtime 클래스에서 제공해주는 totalMemory(), freeMemory() 메서드를 통해 알아보겠습니다.

        @Test
    void 페이징을_사용한_조회() {
    List<Station> stations = stationRepository.findAllByOrder(Pageable.ofSize(1000));

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();
    System.out.println("paging 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    @Test
    void 페이징을_사용하지_않고_조회() {
    List<Station> stations = stationRepository.findAllFetch();

    long total = Runtime.getRuntime().totalMemory();
    long free = Runtime.getRuntime().freeMemory();

    System.out.println("findAll() 사용 중인 메모리: " + ((total - free) / 1024 / 1024) + "MB");
    }

    findAll paging 확연히 차이가 나는 것을 확인할 수 있습니다.

    물론 테스트코드에서는 23만건의 API 요청은 같은 조건이니 배제하고 확인했습니다.

    이로써 하나의 문제가 또 해결된 것 같습니다.

    아직 배우는 단계라 혹시 틀린 점이 있다면 지적 부탁드리겠습니다.

    Reference

    - - + + \ No newline at end of file diff --git a/tags/trouble-shooting/page/2.html b/tags/trouble-shooting/page/2.html index 8e5d66b4..a710d3a1 100644 --- a/tags/trouble-shooting/page/2.html +++ b/tags/trouble-shooting/page/2.html @@ -5,12 +5,12 @@ "trouble-shooting" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "trouble-shooting" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

    ------------------------
    LATEST DETECTED DEADLock
    ------------------------
    2023-07-21 01:49:54 281472560787424
    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

    *** (1) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    *** (2) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


    실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

    Mysql Dead Lock이란

    그럼 Dead Lock은 왜 생기고 언제 생길까요? +

    "trouble-shooting" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    박스터

    이 글을 쓰는 이유

    먼저 이 글을 쓰는 이유는 저희 카페인 팀의 혼잡도 저장 및 충전기의 상태를 업데이트하는 로직에서 dead Lock이 발생하여 mysql과 connection을 잃는 에러가 발생했기 때문입니다.

    ------------------------
    LATEST DETECTED DEADLock
    ------------------------
    2023-07-21 01:49:54 281472560787424
    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap

    *** (1) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 718 n bits 280 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap waiting

    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    *** (2) WAITING FOR THIS Lock TO BE GRANTED:
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap waiting


    실제 개발 서버에서 발생한 데드락의 로그입니다. 해당 로그는 charger_status에 저장 시 서로 XLock을 획득하지 못하여 생기는 에러입니다.

    Mysql Dead Lock이란

    그럼 Dead Lock은 왜 생기고 언제 생길까요? 저는 이 Log를 직접 마주하기 전까지는 Dead Lock이 그냥 Lock의 시간이 오래 걸릴 때 생기는 줄 알았습니다. 하지만 그렇게 간단하게 발생하는 것은 아니였습니다.

    1. 상호 배제(Mutual Exclusion): MySQL은 기본적으로 트랜잭션 내에서 잠금(Lock)을 사용하여 데이터의 상호 배제를 제어합니다. 따라서 두 개 이상의 트랜잭션이 같은 데이터를 동시에 변경하려고 할 때, 해당 데이터에 대한 잠금이 설정되어 상호 배제 조건이 만족됩니다.

    2. 점유와 대기(Hold and Wait): 트랜잭션이 이미 하나 이상의 데이터를 잠근 상태에서 다른 데이터의 잠금을 얻기 위해 대기하고 있는 경우 점유와 대기 조건이 만족됩니다. 즉, 트랜잭션이 자신이 점유한 데이터를 유지한 상태에서 다른 데이터에 대한 잠금을 기다리고 있어야 합니다.

    3. 비선점(Non-Preemption): MySQL에서는 기본적으로 트랜잭션이 다른 트랜잭션이 점유한 데이터의 잠금을 강제로 해제할 수 없습니다. 따라서 비선점 조건이 만족됩니다.

    4. 순환 대기(Circular Wait): 두 개 이상의 트랜잭션이 각각 서로가 기다리는 데이터의 잠금을 보유해야 순환 대기 조건이 만족됩니다. 예를 들면, 트랜잭션 A가 데이터 X의 잠금을 기다리고, 트랜잭션 B는 데이터 Y의 잠금을 기다리며, 트랜잭션 C는 데이터 Z의 잠금을 기다리는 상태가 발생한다면 순환 대기 조건이 성립합니다.

    사실 기본 컴퓨터 시스템의 dead Lock과 유사한 조건입니다. 이 부분을 모두 만족해야 데드락이 발생합니다. 하나씩 알아보겠습니다. 먼저 개발 서버에서 발생한 데드락으로 살펴보겠습니다.

    *** (1) TRANSACTION:
    TRANSACTION 1000560, ACTIVE 373 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 3 Lock struct(s), heap size 1128, 9 row Lock(s), undo log entries 328
    MySQL thread id 860, OS thread handle 281472107409376, query id 2958720 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST414511', '01', '2023-07-21 08:27:43', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 08:27:43', charger_state = 'CHARGING_IN_PROGRESS'

    *** (1) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 742 n bits 424 index PRIMARY of table `charge`.`charger_status` trx id 1000560 Lock_mode X Locks rec but not gap


    -------------------------------------------------------------------------
    *** (2) TRANSACTION:
    TRANSACTION 946331, ACTIVE 507 sec inserting
    mysql tables in use 1, Locked 1
    Lock WAIT 4 Lock struct(s), heap size 1323, 12 row Lock(s), undo log entries 432
    MySQL thread id 859, OS thread handle 281472629186528, query id 3017483 update
    INSERT INTO charger_status (station_id, charger_id, latest_update_time, charger_state) VALUES ('ST412801', '11', '2023-07-21 10:48:20', 'CHARGING_IN_PROGRESS') ON DUPLICATE KEY UPDATE latest_update_time = '2023-07-21 10:48:20', charger_state = 'CHARGING_IN_PROGRESS'

    *** (2) HOLDS THE Lock(S):
    RECORD LockS space id 64 page no 718 n bits 208 index PRIMARY of table `charge`.`charger_status` trx id 946331 Lock_mode X Locks rec but not gap

    1번 트랜잭션 1000560이 charge_status 테이블에 insert ~ on duplicate key update ~ 쿼리를 발생시키기 위해 space id 64 page no 742 n bits 424 index PRIMARY of table 에 X Lock을 가지고 있습니다 그리고 2번 트랜잭션 946331 도 똑같은 테이블에 비슷한 쿼리를 발생시키려고 합니다. 그리고 해당 트랜잭션도 X Lock을 가지고 있습니다.

    저희 팀에 데드락이 발생한 이유

    먼저 저희 팀은 공공 API를 통해 전기차 충전소 정보를 cron으로 업데이트 해주고 있습니다. @@ -26,7 +26,7 @@ 트랜잭션을 오래 가지고 있으면 Lock을 가지고 있는 시간이 오래걸립니다. 그래서 트랜잭션을 작게 분리할 수 있습니다. 페이징을 통해 트랜잭션을 작게 분리하다보면 쿼리가 여러번 나가 성능상 문제가 생길 수 있을 것 같습니다.

  • INSERT ~~ ON DUPLICATE KEY UPDATE ~~ 사용하지 않기 해당 sql이 아닌 INSERT IGNORE을 사용하여 추가된 정보만 넣고, update는 다른 작업으로 분리하기
  • 이런 방법들을 사용하면 될 것 같았습니다. 그 중 저는 현재는 간단하게 2번째 방법이 제일 나을 것 같다는 생각에 쿼리를 수정했습니다.

    그리고 문제를 해결했습니다. 해당 문제가 발생하게 되어 좀 더 재밌는 것들을 고민하고 공부할 수 있는 저희 팀에게 감사하고 모르는 키워드를 많이 알려준 누누에게 감사합니다.

    아직 배우는 단계라 정확한 정보가 아닐 수 있습니다. 부족한 부분에 대해 많은 지적 부탁드립니다.

    - - + + \ No newline at end of file diff --git a/tags/use-sync-external-state.html b/tags/use-sync-external-state.html index db9df457..581f039b 100644 --- a/tags/use-sync-external-state.html +++ b/tags/use-sync-external-state.html @@ -5,13 +5,13 @@ "useSyncExternalState" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "useSyncExternalState" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    - - +

    "useSyncExternalState" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 13분
    센트

    1. 개요

    기존의 구조에서는 마커 하나를 렌더링하기 위해 다음과 같은 과정을 거쳤다.

    1. StationMarkersContainer 컴포넌트에서 충전소 정보 요청
    2. 충전소 정보를 props로 넘겨 Marker 컴포넌트 호출
    3. 지도에 부착될 DOM요소 생성
    4. createRoot를 통해 리액트 root 생성
    5. 2번에서 생성한 DOM 요소를 전달해 구글 지도 api의 Marker 생성자 함수 호출
    6. 3번에서 생성했던 root의 render 메서드 호출
    7. 마커 인스턴스 전역 상태에 새로 생성한 마커 추가

    위 과정을 거쳤을 때의 마커 렌더링 모습을 보면 다음과 같다.

    before

    마커들이 한번에 렌더링 되는 것이 아니라 산발적으로 렌더링 되는 모습을 확인할 수 있다.

    2. 문제 원인 분석

    마커를 렌더링 하기 위해 거치는 과정을 분석해 보았다.

    1 ~ 3 과정에서는 성능에 크게 영향을 끼칠 요소가 없지만 4번 과정은 일반적인 리액트 프로젝트를 개발할 때 겪는 과정이 아니다. 따라서 createRoot를 통해 많은 개수의 루트를 생성했을 때의 영향에 대해 알아보았다.

    image

    리액트 공식 문서를 보니 페이지의 일부에 리액트를 뿌려서 사용하는 경우에는 루트를 필요한 만큼 생성해도 된다는 이야기가 포함되어 있었다. 따라서 4번 과정 또한 문제의 원인이라고 볼 수 없었다.

    5번 과정은 구글 지도에 마커를 특정 위도 경도에 위치시키기 위해서 어쩔 수 없이 거쳐야 하는 과정이므로 이 과정은 문제가 있더라도 개선이 불가능해 일단 고려하지 않았다.

    6번 과정은 4번 과정에서 생성했던 리액트 루트의 render 메서드를 호출해 실제로 화면에 리액트 컴포넌트를 그리도록 하는 과정이다. 이 과정 또한 리액트 컴포넌트를 화면에 렌더링하기 위해선 어쩔 수 없이 거쳐야 하는 과정이므로 고려하지 않았다.

    하지만 6번 과정에서 리액트 컴포넌트를 직접 그리는 것이 아니라 구글 지도 api의 기본 마커를 사용하면 성능을 향상시킬 수 있지 않냐고 반문할 수도 있을 것이다. 이전에는 이러한 방식을 사용해 마커를 렌더링 했었다. 우리의 서비스는 현재 사용 가능한 충전소 개수를 마커를 통해서도 전달하기 때문에 이를 고려해 기본 마커를 사용할 때 다음의 두 가지 문제가 생긴다.

    1. 사용 가능한 충전소 개수를 기본 마커에 렌더링 할 때 성능이 매우 좋지 않다.
    2. 마커의 디자인을 바꾸고자 할 때 변경에 대응하기 어렵다.

    따라서 마커는 리액트 루트의 render 메서드를 호출해 리액트 컴포넌트를 렌더링하는 것으로 결정했다.

    마지막으로 남은 7번 과정에서는 useSyncExternalState 훅을 사용해 전역적으로 관리하고 있던 상태에 수정을 가하는 연산을 수행한다. 이 과정은 이전에도 성능 저하를 유발할 것으로 예상되던 부분이었다. (하단 링크 참고)

    useSyncExternalStore 훅을 통해 구독한 state가 한번에 업데이트 되는 이유

    요청의 결과로 받아온 마커 정보의 개수가 100개라고 가정해보자. 우리는 이제 마커를 렌더링 할 것이다. 첫 번째 마커의 렌더링을 위해 1번 ~ 6번의 과정을 거친 후 7번 과정을 수행한다. 그러면 리액트 입장에서는 리액트 루트의 render 메서드 호출에 대한 동작을 수행해야 하고, 새로운 마커 인스턴스에 대한 전역 상태를 변경시키는 동작을 수행해야 한다. 리액트가 이 과정을 100번 반복하고 나면 우리는 비로소 모든 마커가 화면에 렌더링 된 모습을 볼 수 있을 것이다.

    나는 이 부분에서 성능 저하의 요소가 있다고 생각했다. 리액트에서의 상태 변화는 곧 리액트 내부의 렌더링을 위한 로직이 수행되게 함을 의미하고, 이 과정을 개선 이전에는 마커의 개수만큼 반복하고 있었던 것이다. 여기까지 생각해보니 전역 상태 변화에 대해 리액트가 렌더링을 위한 연산을 진행할 동안에는 마커의 렌더링(render 메서드 호출)이 멈추는 것이 아닐까 하는 생각이 들었다.

    그래서 크롬 개발자 도구의 퍼포먼스 탭을 들어가 보니 산발적으로 발생하던 마커 렌더링의 문제 원인이 짐작했던 그 원인임을 확인할 수 있었다.

    image

    프레임 이미지 하단을 보면 산발적인 마커 렌더링이 수행될 때마다 수반되는 어떤 함수 호출이 있음을 확인할 수 있다.

    image

    이 부분이 문제의 함수 호출 부분이다. 자세히 살펴보면 상단에 performWorkUntilDeadline이란 함수가 호출됨을 볼 수 있다.

    image

    performWorkUntilDeadline 라는 함수를 조금 알아보니 해당 함수는 간단히 말해 리액트에서 state의 변경이 한번에 많이 발생할 때 5ms의 데드라인 시간을 줄 때 사용하는 함수라는 것을 알게 되었다. 문제의 원인이라고 생각했던 마커 개수 만큼의 전역 상태 변화가 실제로 마커 렌더링을 잠시 중단하게 만들고 있음을 알게 되었다.

    3. 문제 해결

    앞서 분석한 문제를 개선해보고자 마커 렌더링에 필요한 충전소 정보 배열을 부모 컴포넌트에서 받아와 각 충전소 정보를 자식 컴포넌트에 넘겨주고, 자식 컴포넌트에서 마커 생성과 렌더링 로직을 수행하던 기존의 방식을 부수고 부모 컴포넌트에서 모든 것을 일괄 처리하는 방식으로 고쳐보았다.

    고치는 과정에서 기존 방식에서는 리액트 생명 주기에 의존하여 화면에 보여지지 않는 마커를 지워주던 로직을 이제는 모두 직접 구현해야 했다.

    이전의 영역과 겹치는 부분에 있는 충전소는 다시 그리지 않고, 영역 밖의 충전소를 나타내는 마커는 지워주고, 이전의 영역과 겹치지 않는 새로 받아온 충전소는 그리도록 다음과 같이 메서드를 분리해보았다.

    • 기존과 겹치지 않는 새로운 영역에 대한 마커를 생성하는 메서드
    • 기존과 겹쳐지는 영역에 대한 마커들을 반환하는 메서드
    • 새로운 영역 밖에 있는 마커들을 지워주는 메서드
    • 새롭게 생성된 마커를 화면에 렌더링하는 메서드

    이 메서드들을 커스텀 훅으로 분리해 부모 컴포넌트에서 이를 활용하도록 하여 다소 복잡할 수 있는 마커 렌더링 로직을 선언적으로 구현할 수 있도록 했다.

    결과적으로 기존에 사용되던 기능들을 그대로 사용할 수 있으면서 화면에 마커가 산발적으로 렌더링 되던 문제가 해결 되었고, 부가적인 효과로 전체 마커의 렌더링 시점도 앞당길 수 있게 되었다. + 기존에는 구조적인 문제로 연산량이 너무 많아 클러스터링이 늦어져 이를 도입할 수 없었던 문제를 구조 수정으로 인해 적용할 수 있게 되었다.

    작업한 PR

    https://github.com/woowacourse-teams/2023-car-ffeine/pull/737

    결과 분석 (performance 탭 활용)

    before

    마커 조회 요청이 종료된 시점: 약 2499ms

    image

    첫 마커 렌더링 시점: 3093ms

    image

    모든 마커 렌더링 종료 시점: 약 3611ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 594ms

    모든 마커 렌더링에 소요된 시간: 1112ms

    after

    마커 조회 요청의 시작점: 약 1875ms

    image

    모든 마커 렌더링 종료 시점: 2395ms

    image

    처음으로 마커가 렌더링 될 때까지 소요된 시간: 519ms

    모든 마커 렌더링에 소요된 시간: 519ms

    개선 결과

    처음으로 마커가 렌더링 되는 시점은 두 방식 모두 비슷한 결과를 보인다. 하지만 개선 후 방식은 한번에 모든 마커가 렌더링 되는 방식이고, 개선 이전의 방식은 산발적으로 마커가 렌더링 되는 방식이므로 개선 후의 방식에서 전체 마커를 렌더링 하는 시점이 훨씬 빨라지게 되었다.

    결과적으로 전체 마커가 렌더링 되는 속도 약 55.6% 단축하게 되었다. 이 결과는 마커가 늘어날 수록 더욱 차이가 극적으로 벌어질 것으로 예상된다.

    before

    before

    after

    after

    + + \ No newline at end of file diff --git a/tags/use-sync-external-store.html b/tags/use-sync-external-store.html index ad986186..3406c755 100644 --- a/tags/use-sync-external-store.html +++ b/tags/use-sync-external-store.html @@ -5,12 +5,12 @@ "useSyncExternalStore" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "useSyncExternalStore" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. +

    "useSyncExternalStore" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. 생성된 StateManager 인스턴스가 반환되어 store가 곧 초기 값을 가지는 StateManager가 됩니다.

    no offset

    예를 들어, 다음과 같은 코드가 있다고 할 때

    export const countStore = store<number>(0);

    countStore는 곧 0을 초기값으로 가지는 StateManager 인스턴스이기도 하게 됩니다.

    그러면 StateManager에 대해서 알아보겠습니다.

    StateManager는 react 바깥에 있는 어떤 저장소이다.

    (근데 이게 그냥 저장소는 아니고 좀 특별한 저장소다.)

    export type SetStateCallbackType<T> = (prevState: T) => T;

    export interface DataObserver<T> {
    setState: (param: SetStateCallbackType<T> | T) => void;
    getState: () => T;
    subscribe: (listener: () => void) => () => void;
    emitChange: () => void;
    }

    class StateManager<T> implements DataObserver<T> {
    public state: T;
    private listeners: Array<() => void> = [];

    constructor(initialState: T) {
    this.state = initialState;
    }

    setState = (param: SetStateCallbackType<T> | T) => {
    if (param instanceof Function) {
    const newState = param(this.state);
    this.state = newState;
    } else {
    this.state = param;
    }

    this.emitChange();
    };

    getState = () => {
    return this.state;
    };

    subscribe = (listener: () => void) => {
    this.listeners = [...this.listeners, listener];

    return () => {
    this.listeners = this.listeners.filter((l) => l !== listener);
    };
    };

    emitChange = () => {
    for (const listener of this.listeners) {
    listener();
    }
    };
    }

    export default StateManager;

    StateManager 클래스는 외부에서 받아온 초기값을 상태로 가집니다. setState, getState, subscribe, emitChange를 메서드로 가집니다. 여기서 작성된 코드들은 react에서 외부 저장소와 소통하기 위한 최소한의 규격입니다.

    • subscribe: 단일 콜백 인수를 사용하여 스토어에 구독하는 함수입니다. 스토어가 변경되면 제공된 콜백을 호출해야 합니다. 그러면 구성 요소가 다시 렌더링 됩니다. 구독 기능은 구독을 정리하는 기능을 반환해야 합니다. (구독에 관련된 데이터는 리스너 배열 필드에 넣어서 관리합니다.)

    • emitChange: 리스너 배열 필드에 담겨있는 모든 리스너를 실행합니다. 즉, 구독된 어떤 것을 순차적으로 실행하게 합니다. 이는 리액트 DOM을 강제로 일깨워주는 옵저버 패턴의 역할을 하게 됩니다. 이 과정 때문에 react DOM이 정확한 재 렌더링 지점을 파악할 수 있게됩니다. (최적화 문제에서 자유로워짐)

    • setState: 상태를 업데이트합니다. 다만 상태가 업데이트 됐음을 알려야 하므로 emitChange를 실행시켜 react DOM을 강제로 동기화시킵니다.

    • getState: 호출되는 순간 현재 상태 값을 읽습니다.

    좀 어렵지만 리액트에서 이런 규격을 가져야 useSyncExternalStore훅을 쓸 수 있게 해 줍니다. @@ -24,7 +24,7 @@ 빨간색은 개발자가 직접 건들지 못하지만 간접적으로 사용할 수 있는 영역 노란색은 React 18 엔진의 영역입니다.

    이외에 제공되는 다른 커스텀 훅들도 거의 비슷한 구조를 띄고 있습니다.

    // 추가로 구현할 수 있는 함수들

    export const useSetExternalState = <T>(store: DataObserver<T>) => {
    const { setState } = store;

    return setState;
    };

    export const useExternalValue = <T>(store: DataObserver<T>) => {
    const { subscribe, getState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return state;
    };

    // 바닐라JS 영역에서 자연스러운 읽기를 지원하는 함수

    export const getStoreSnapshot = <T>(store: DataObserver<T>) => {
    return store.getState();
    };

    더 다양한 예제는 여기에서 확인할 수 있고 작성한 라이브러리 코드 전문은 여기에서 확인할 수 있습니다.

    겨우 파일 수십 줄로 만든 초경량 상태관리 라이브러리였습니다

    - - + + \ No newline at end of file diff --git a/tags/use-sync-external-store/page/2.html b/tags/use-sync-external-store/page/2.html index 453d103c..75fd6c09 100644 --- a/tags/use-sync-external-store/page/2.html +++ b/tags/use-sync-external-store/page/2.html @@ -5,13 +5,13 @@ "useSyncExternalStore" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "useSyncExternalStore" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    - - +

    "useSyncExternalStore" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    센트

    Untitled

    위 이미지는 현재까지 구현한 지도의 모습이다. 구현된 기능은 다음과 같다.

    • 충전소 정보를 서버에 요청해 받아온 충전소 정보를 바탕으로 화면에 마커를 표시하는 기능
    • 화면이 이동하거나 줌인, 줌 아웃을 할 시 화면의 마커 정보가 최신화 되는 기능
    • 마커 정보를 최신화 할 때 화면에서 사라진 마커를 dom에서 제거하는 기능
    • 마커 정보를 최신화 할 때 이전 화면에서도 있었던 마커를 재생성 하지 않는 기능
    • 마커를 클릭했을 시 해당 마커에 대한 간단 정보를 모달로 띄워주는 기능
    • 화면에 표시된 마커들에 대한 충전소 정보를 리스트로 보여주는 기능

    이번에 새로 추가하고자 한 기능은 다음과 같다.

    • 충전소 리스트에서 충전소를 선택하면 화면의 중심이 선택한 충전소 마커로 이동하고, 충전소의 간단 정보를 모달로 띄워주는 기능

    위 기능을 구현하기 위해선 google maps api의 InfoWindow객체를 이용해야 한다. 사용 방식은 다음과 같다.

    const infowindow = new google.maps.InfoWindow({
    content: contentString,
    ariaLabel: 'Uluru',
    });

    const marker = new google.maps.Marker({
    position: uluru,
    map,
    title: 'Uluru (Ayers Rock)',
    });

    infowindow.open({
    anchor: marker,
    map,
    });

    간단하게 요약하자면 다음과 같다.

    • InfoWindow 생성자 함수를 통해 infoWindow 인스턴스를 생성한다.
      • 생성시 dom 요소 혹은 string을 전달해 infoWindow가 생성될 dom위치를 지정해준다.
    • marker 인스턴스를 infoWindow 인스턴스의 open 메서드에 인자로 전달한다.
    • infoWindow 생성 시 전달했던 dom요소의 위치가 marker의 위치로 고정되면서 화면에 그려진다.

    Untitled

    충전소 정보를 보여주는 위 StationList 컴포넌트는 충전소 정보에 접근할 때 react-query를 통해 서버 상태를 직접 내려 받아 컴포넌트 내부 리스트를 렌더링 한다.

    또한, StationMarkersContainer에서도 충전소 정보를 react-query의 서버 상태에서 참조해 마커를 렌더링 하고 있다.

    따라서 StationList 컴포넌트와 StationMarkersContainer는 각각 따로 서버 상태에 접근해 렌더링을 수행하고 있으므로 둘 사이에는 어떠한 연결 고리가 없다.

    여기서 문제가 발생하게 되었다.


    현재까지의 코드에서는 infoWindow인스턴스를 StationMarkersContainer컴포넌트에서 생성한다. 이를 하위 컴포넌트인 StationMarker에 내려주고, 이 컴포넌트 내부에서 marker인스턴스를 생성한다.

    이번에 구현하기로 한 기능은 StationList의 항목 중 하나를 선택했을 시 선택된 충전소에 해당하는 마커에 간단 정보 모달이 뜨며 화면을 해당 마커가 중심으로 오도록 이동 시키는 것이었다.

    하지만 지금의 코드 구조상 StationListStationMarkersContainer사이에는 어떠한 연결 고리도 없으므로 infoWindowmarkerStationList는 접근할 수 없는 상태가 된다.

    이를 해결하기 위해서 다음과 같은 방법을 사용하기로 했다.

    • infoWindow인스턴스를 root 단에서 생성해 전역적으로 관리한다.
    • 생성될 marker 인스턴스들을 배열 형태의 전역 상태로 관리한다.

    위 내용을 말로만 본다면 별로 어려울 것 없어 보이지만 실제 구현을 진행해보니 내부적으로 큰 문제가 두 가지 존재했다.

    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.
    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    각각의 문제점을 살펴보자.


    1. 따로 모듈을 분리해 infoWindow를 생성할 수 없다.

    infoWinodw를 전역 상태로 만들어 사용하기 위해 처음으로 했던 생각은 infoWindowStore.ts로 모듈을 분리하여 infoWindow를 생성해 store의 초기값으로 지정하는 것이었다.

    위 생각을 가지고 그대로 구현해보았더니 google을 참조할 수 없다는 에러가 발생했다. InfoWindow생성자 함수는 google.maps.InfoWindow를 통해 접근할 수 있기 때문에 해당 에러는 infoWindow인스턴스를 생성할 수 없다는 것을 의미했다.

    google을 참조할 수 없는지 이유를 분석해보니 이유는 다음과 같았다.

    우리 팀이 구글 지도 로드를 위해 선택한 라이브러리는 @googlemaps/react-wrapper이다. 이 라이브러리의 동작을 살펴보면 다음과 같다.

    • Wrapper컴포넌트가 @googlemaps/js-loader라이브러리의 Loader생성자 함수를 호출한다.
    • 생성된 loader인스턴스의 load메서드를 실행시켜 지도의 로딩 작업을 시작한다.
      • load 메서드는 최종적으로 Promise<typeof google>을 반환하는데, 지도 로드에 성공하면 resolve(window.google) 을 실행시켜 google을 전역적으로 사용 가능하도록 만들어준다.
    • 지도의 로딩이 완료되면 Wrapperrender props를 통해 받은 콜백 함수를 실행시킨다.
      • render콜백 함수는 로딩 상태를 나타내는 Status를 파라미터로 넘겨 받아 호출된다.

    최종적으로 render를 실행 시켰을 때 반환 되는 컴포넌트에서는 google 로딩 되어 전역적으로 접근이 가능함을 보장할 수 있으므로 이때부터 google에 접근이 가능해진다. → 따라서 Wrapper를 통해 반환되는 컴포넌트의 하위 컴포넌트에서 google.maps.Map생성자 함수를 사용해 지도를 생성할 수 있게 된다.

    infoWindow를 생성하기 위해 만든 새로운 모듈은 첫 import시기에 평가될 것이기 때문에 Wrapper의 하위 컴포넌트에서 import를 수행한다면 로드가 완료된 이후 시점일 것이므로 window.google이 등록되어 google에 접근이 가능할 것으로 예상했다.

    하지만 웹팩을 통한 번들링 과정에서 모듈이 뒤섞여 파일의 평가 시기를 보장할 수 없어져 새로 만든 모듈에서는 google에 대한 접근이 불가능해지게 되었다. 웹팩을 좀 더 공부해본다면 이 문제를 해결할 수 있을 것 같았지만, 너무 지엽적인 부분에서 많은 시간을 들이기 보단 기존에 개발하던 방식을 통해 문제를 해결해보기로 결정했다.

    최종적으로 문제를 해결한 방식은 다음과 같다.

    • InfoWindow생성자 함수를 호출할 CarFfeineInfoWindowInitializer컴포넌트를 만든다.
    • Wrapper로 감싸진 컴포넌트 하위에 CarFfeineInfoWindowInitializer 컴포넌트를 추가한다.
    • google에 접근이 가능한 상태를 보장받은 CarFfeineInfoWindowInitializer내부에서 infoWindow인스턴스를 생성한다.
    • storeinfoWindow인스턴스를 set해주어 전역적으로 infoWindow를 사용 가능하도록 한다.

    2. marker인스턴스를 생성하는 주체가 StationMarkersContainer가 되어서는 안된다.

    이번 팀 프로젝트에서 지도를 구현하기 위해 google maps api를 사용하게 되었다. 뜬금없이 이 이야기를 한 이유는 다음과 같다.

    • google maps api는 바닐라 자바스크립트를 기반으로 동작한다.
    • 이번 팀 프로젝트는 리액트를 기반으로 개발을 진행할 것이다.
    • 지도를 그리기 위해서 바닐라 자바스크립트와 리액트의 적절한 조화가 필요하다.
    • 다소 혼란스러울 수 있는 지도의 조작 방식을 리액트와 조화롭게 사용하기 위해서 컴포넌트 설계시 컴포넌트의 책임을 확실하게 구분해야겠다는 생각을 하게 되었다.

    이 컴포넌트의 책임에 대한 문제로 인해 marker 인스턴스를 생성하는 주체에 대해 많은 고민을 하게 되었다.

    일단 원래 코드 구조에서 마커를 그리기 위해 컴포넌트를 다음과 같이 추상화 했다.

    • StationMarkersContainer 컴포넌트
      • 리액트 쿼리를 통해 받아온 서버 상태(충전소 정보 배열)로 StationMarker를 호출한다.
    • StationMarker 컴포넌트
      • 상위에서 내려받은 충전소 정보 props를 통해 marker 인스턴스를 생성한다. (google maps api에서는 인스턴스 생성이 곧 렌더링을 의미한다)
      • 생성한 marker 인스턴스에 infoWindow 인스턴스의 open 메서드를 트리거 하는 클릭 이벤트 리스너를 추가해준다.
      • useEffect의 클린업 함수를 이용해 충전소 정보가 최신화 되었을 때 마커가 더이상 화면에 보이지 않는다면 marker 인스턴스의 setMap(null) 메서드를 호출해 google maps api에서 마커를 지우도록 한다. (마커 렌더링 최적화)

    간략히 설명하자면 StationMarkersContainer 컴포넌트는 충전소 정보를 서버에서 받아 StationMarker를 호출하는 역할만을 수행하고, 마커에 대한 모든 세부 로직은 StationMarker가 수행하도록 컴포넌트를 추상화 해보았다.

    이름에서도 드러나듯 StationMarker 컴포넌트가 marker 인스턴스를 생성하는 주체가 되어야 바닐라 자바스크립트와 리액트의 혼종인 이 프로젝트의 코드를 추후 유지보수 할 때 문제가 없으리라 판단했다.

    하지만 이렇게 추상화 된 컴포넌트들은 marker 인스턴스를 배열 형식의 전역 상태에 담아 관리하고자 할 때 문제가 되었다.


    일단 먼저 서버에서 내려 받은 충전소 정보를 station이라고 하자, 우리는 이 station을 통해 marker 인스턴스를 생성하고자 한다.

    이때 생각 할 수 있는 가장 간단한 방법은 station에서 map 메서드를 통해 marker 인스턴스를 생성하여 이 marker 인스턴스를 하위 컴포넌트인 StationMarker에 넘겨주는 방식일 것이다.

    하지만 이 방식은 인스턴스를 생성하는 것이 곧 화면에 렌더링을 발생시키는 것을 의미하는 google maps api의 특성상 우리가 처음 설계한 컴포넌트의 책임을 반하는 구조를 만들어내게 된다.

    자세히 설명해보자면 마커의 렌더링은 StationMarkersContainer가 수행하고 있는데 화면에 보이지 않는 마커를 지우는 역할은 StationMarker컴포넌트가 수행하고 있고, 이벤트 핸들러의 추가 역시 마커가 생성된 이후에 하위 컴포넌트에서 이를 수행하는 괴상한 코드가 만들어지게 된다.

    추후 코드의 유지보수성을 위해선 피해야 할 방식임이 명확했다.

    해결 방식을 고민해보다가 다음과 같은 해결 방안을 생각하게 되었다.

    StationMarker 컴포넌트의 역할

    • marker 인스턴스를 생성한다.
    • marker 인스턴스의 이벤트 핸들러를 추가한다.
    • 생성된 marker 인스턴스를 배열 형식의 전역 상태에 추가한다.
    • 충전소 정보가 최신화 되었을 때 마커가 화면에 보이지 않는 상태가 되었다면 marker 인스턴스를 전역 상태에서 삭제한다.

    위와 같이 StationMarker 의 역할을 잡게 되면 기존의 컴포넌트 설계 구조를 해치지 않으면서 전역 상태에 marker인스턴스를 잘 추가할 수 있게 된다. 하지만 이렇게 되면 StationMarker 컴포넌트는 다음의 큰 문제들을 가지게 된다.

    1. marker들을 가지는 전역 상태를 구독하고 있는 컴포넌트가 새로 생성되는 마커의 개수만큼 리렌더링 된다.
    2. 현재 사용하고 있는 전역 상태 관리 도구의 특성상 이전 상태를 참조해와야 marker를 추가할 수 있게 되는데, 이 때 이전 상태가 최신의 상태임을 보장하지 못할 수 있다.

    이 두 문제를 해결할 방식을 고민해보았을 때 다음과 같은 결론에 도달하게 되었다.

    • 현재 사용하고 있는 전역 상태 관리 도구는 React 18에 새로 추가된 useSyncExternalState 훅을 기반으로 recoil과 비슷하게 사용할 수 있도록 계층을 분리하여 만든 도구이다.
    • 기존에 사용하던 전역 상태 관리 도구의 메서드 useExternalState, useExternalValue, useSetExternalState 이외에 store 인스턴스에 직접 접근하여 최신의 상태를 참조하는 getStoreSnapShot 메서드를 추가한다.
    • store에 직접 접근해 받아온 최신의 상태는 바닐라 자바스크립트 객체 이므로 리액트의 리렌더링을 발생 시키지 않는다.
    • 리렌더링으로 인한 문제점들을 getStoreSnapShot 메서드를 추가함으로써 해결할 수 있다.

    새로운 기능 추가를 위해 마주했던 앞선 두 가지의 문제와 해결 방식을 살펴 보았다. 그래서 최종적으로 이전까지 계속해서 고민해왔던 문제를 해결한 과정을 간추려보자면 다음과 같다.

    • 충전소 정보를 서버에서 받아와 렌더링 하는 StationList 컴포넌트에서 marker 인스턴스 배열을 저장하고 있는 store인스턴스에 직접 접근해 최신의 marker인스턴스들을 가져온다.
    • 충전소 목록에서 사용자가 충전소를 클릭했을 때 전역으로 관리되는 infoWindow 인스턴스의 open메서드에 marker 인스턴스들 중 선택된 marker를 전달해 간단 정보 모달을 띄워준다.
    + + \ No newline at end of file diff --git a/tags/vpc.html b/tags/vpc.html index 7a3e0761..88aec1d8 100644 --- a/tags/vpc.html +++ b/tags/vpc.html @@ -5,13 +5,13 @@ "vpc" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "vpc" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    - - +

    "vpc" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    어떤 문제가 있었나요?

    우아한테크코스에서 private 서브넷에 db 인스턴스를 두고, 보안을 위해 외부에서 접속을 차단하려고 했습니다.

    이 과정에서 총 2가지의 문제점이 있었습니다.

    1. private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었습니다.
    2. public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안되었습니다.

    이 부분을 어떻게 해결했는지 알아보도록 하겠습니다.

    아래의 모든 설명은 AWS 를 기준으로 합니다.

    private 서브넷에 인스턴스가 인터넷에서 mysql을 설치할 수 없었다.

    해결 방법

    public ip 자동할당을 해주지 않아서, 인터넷에 연결이 안 되었습니다.

    이를 해결하기 위해 public ip 자동할당을 해주었습니다.

    왜 public ip를 할당했더니 문제가 해결되었을까요?

    private 서브넷이란?

    정말 간단하게 설명했을 때

    private 서브넷은 인터넷에 연결되지 않은 서브넷입니다.

    조금 자세하게 들어가 보도록 하겠습니다

    private 서브넷은 인터넷 게이트웨이가 연결되지 않은 서브넷입니다.

    aws 공식문서에서 사진을 통해 보면 아래와 같이 되어있습니다

    private subnet

    public 서브넷에만 인터넷 게이트웨이가 연결되어 있고, private 서브넷에는 인터넷 게이트웨이가 연결되어있지 않습니다.

    private 서브넷에 인터넷 게이트웨이가 연결되어 있지 않다고 했을 때, 기본적으로 인터넷에 접속이 안됩니다.

    mysql을 설치할 때도, 인터넷에 접속을 해야하는데, 인터넷에 접속이 안되니 설치가 안되는 것입니다.

    어? 인터넷 자체가 접근이 안되면 어떻게 설치하나요?

    정말 원시적으로 해결하기 위해서는 public 서브넷에 인스턴스를 하나 더 만들어서, mysql 을 압축해서 scp를 통해 private 서브넷에 있는 인스턴스에 전송하고, 압축을 풀어서 설치하는 방법이 있습니다.

    하지만 이 방법은 너무 원시적이고, 비효율적입니다.

    그래서 인터넷으로 요청을 보낼 수 있도록 만드는 과정이 필요합니다.

    인터넷으로 요청을 보낼 수 있도록 만드는 과정

    인터넷으로 요청을 보낼 수 있도록 만드는 과정은 크게 2가지가 있습니다.

    private 서브넷을 public 서브넷으로 바꾸기

    보안을 위해서 private 서브넷에 두려고 했던 것을 public 서브넷으로 바꾼다는 부분은 매우 위험합니다.

    그래서 이 방법은 보통 사용하지 않습니다.

    NAT 인스턴스(Gateway) 만들기

    NAT 인스턴스는 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들어주는 인스턴스입니다.

    인터넷에 접속을 하기 위해서는 public ip 가 필요합니다.

    따라서 NAT 인스턴스, NAT 게이트웨이는 public 서브넷에 존재해야 합니다.

    어? NAT 인스턴스를 통해서 바로 통신이 가능하면 왜 private 서브넷이 필요한가요? 그냥 다 public 서브넷에 두면 되지 않나요?

    NAT 인스턴스, NAT Gateway는 내부에서 출발한 트래픽만 통과할 수 있도록 설정이 되어있습니다.

    예를 들면 private 서브넷에 인스턴스에 접속해서 직접 mysql download 요청을 했을 때만 허용이 됩니다.

    외부에서 바로 private 인스턴스로 접근할 수는 없습니다.

    NAT 인스턴스만 설정을 하면 바로 연결이 되나요?

    public ip도 자동 할당을 해줘야 합니다

    public ip 가 필요한 이유

    NAT 인스턴스를 통해서 private 서브넷에 있는 인스턴스가 인터넷에 접속할 수 있도록 만들었는데, 왜 public ip 가 필요할까요?

    외부 인터넷과 통신을 할 때 public ip 가 필요합니다.

    NAT 인스턴스 혹은 NAT 게이트웨이가 인터넷과 통신할 때, NAT 인스턴스의 public ip + private ip를 통해서 통신을 하지 않습니다.

    내부 인스턴스의 public ip 를 통해서 통신을 하게 되어있습니다.

    따라서 NAT 인스턴스와 내부 인스턴스 모두 public ip 가 필요합니다.

    이 과정을 통해서 1번 문제를 해결할 수 있었습니다.

    이제 2번째 문제를 해결해 보도록 하겠습니다.

    public 서브넷에 있는 인스턴스에서 private 서브넷에 있는 인스턴스에 접속이 안 되는 문제

    public 서브넷에 있는 서버가 private 서브넷에 있는 서버에 접속을 하려고 했는데, 접속이 안 되는 문제가 있었습니다.

    해결 방법

    해결 방법에는 2가지 과정이 있습니다.

    public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해 주기

    기본적으로 public 서브넷에 있는 인스턴스의 보안 그룹에는 private 서브넷에 있는 인스턴스의 보안 그룹이 추가되어있지 않습니다.

    따라서 public 서브넷에 있는 인스턴스의 보안 그룹에 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.

    private ip를 통해서 접속하기

    public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속할 때, public ip 를 통해서 접속을 하면 안 됩니다.

    public ip를 통해서 접속하는 과정을 자세하게 알아보겠습니다.

    1. public 서브넷에 있는 인스턴스가 public ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 public ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 통해서 외부 인터넷으로 나가게 됩니다.
    4. 트래픽이 NAT 인스턴스에 도착합니다.
    5. NAT 인스턴스는 내부에서 출발한 트래픽이 아니기 때문에, 트래픽을 거부합니다.

    이 과정이 일어나기에, public ip 를 통해서 접속을 하면 안 됩니다.

    private ip를 통해서 접근하면 어떻게 되는지 알아보겠습니다

    1. public 서브넷에 있는 인스턴스가 private ip 를 통해서 private 서브넷에 있는 인스턴스에 접속을 시도합니다.
    2. 라우팅 테이블에서 private ip 일 경우에 어떻게 처리할지에 대한 정보를 찾습니다.
    3. 라우터를 거쳐서 private 서브넷의 라우터로 이동합니다.
    4. private 서브넷의 라우터는 private 서브넷에 있는 인스턴스에게 트래픽을 전달합니다.
    5. private 서브넷에 있는 인스턴스는 트래픽을 받아서 처리합니다.

    이 과정을 통해서 2번 문제를 해결할 수 있었습니다.

    요약

    1. private 서브넷에 있는 인스턴스가 인터넷에 접속을 하려면 NAT 인스턴스 혹은 NAT 게이트웨이가 필요합니다.
    2. private 서브넷에 있는 인스턴스도 public ip 가 필요합니다.
    3. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 하려면 private 서브넷에 있는 인스턴스의 보안 그룹을 추가해주어야 합니다.
    4. public 서브넷에 있는 인스턴스가 private 서브넷에 있는 인스턴스에 접속을 할 때, private ip 를 통해서 접속을 해야 합니다.
    + + \ No newline at end of file diff --git a/tags/webpack.html b/tags/webpack.html index 61607836..8284bb4d 100644 --- a/tags/webpack.html +++ b/tags/webpack.html @@ -5,13 +5,13 @@ "webpack" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "webpack" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 5분
    센트

    웹팩에서 msw 설정

    이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

    팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

    다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

    eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

    왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

    약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

    이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

    new CopyWebpackPlugin({
    patterns: [
    { from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
    ],
    }),

    설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

    문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

    1. MSW를 적용해보려고 함.
    2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
    3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
    4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
    - - +

    "webpack" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 5분
    센트

    웹팩에서 msw 설정

    이번 팀 프로젝트는 CRA와 같은 보일러 플레이트 코드를 사용하지 못하게 제한이 있다. 또한 요즘 많이 사용된다는 Vite의 사용도 제한이 있고, 웹팩으로 프로젝트를 시작하도록 강제하고 있다.

    팀원 모두 한 번도 웹팩을 통해 프로젝트를 시작해본 경험이 없어 프론트엔드 팀원 각자 개인 레포에서 웹팩 공부를 진행한 후 어느정도 진척이 있을 때 팀 레포에 프로젝트를 시작하기로 했다.

    다행히 웹팩으로 시작하는 프로젝트에 대한 많은 참고 자료들이 있어 첫 리액트 프로젝트 화면을 띄우는데 까지는 그리 오랜 시간이 걸리지 않았다. 그렇게 모든 팀원이 첫 웹팩 프로젝트를 성공시킨 후 모여 팀 프로젝트 초기 설정을 시작해보았다.

    eslint, prettier, 웹팩 등등 여러 설정들을 하고 필요한 패키지를 설치하는데 문제가 발생했다. 큰 데이터를 다루는 백엔드의 개발 속도를 고려해 프론트엔드 개발을 진행하기 위해서 미션중에 배웠던 MSW 라이브러리를 사용하기로 결정했는데, 이 라이브러리가 우리 팀의 개발 환경에서 동작하지 않았다.

    왜 동작하지 않는지 원인을 찾아보니 MSW service worker 파일을 찾을 수 없다는 오류 메세지가 나오는 것을 확인할 수 있었다. 원인을 더 자세히 알아보니 public 폴더에 있는 파일들은 웹팩이 번들링을 진행할 때 포함이 되지 않는다는 것을 알 수 있었고, 이를 어떻게 해결할 지 팀원들과 방법을 찾아보았다.

    약 한시간쯤 지났을 무렵 copy-webpack-plugin 패키지를 통해 public 경로에 있는 파일들도 빌드 폴더에 포함시킬 수 있다는 것을 알게 되었다. 하지만 이 copy-webpack-plugin에 대한 사용법이 미숙해 public 폴더에 있는 mockServiceWorker.js 파일만 빌드 폴더로 옮겼어야 했는데 index.html과 같은 다른 파일들 까지 한꺼번에 빌드 폴더로 옮겨지게 되었다.

    이런 저런 방법들을 시도해보다 webpack.config.js 파일의 plugins에 아래와 같은 설정을 추가 해주어 MSW를 프로젝트에 적용할 수 있게 되었다.

    new CopyWebpackPlugin({
    patterns: [
    { from: 'public/mockServiceWorker.js', to: '.' }, // msw service worker
    ],
    }),

    설정을 간단히 보면 public 경로에 있는 mockServiceWorker.js 파일을 빌드 후 폴더의 루트 디렉토리에 추가해준다는 설정이다.

    문제 상황과 해결 방법을 간단하게 다시 정리해보면 다음과 같다.

    1. MSW를 적용해보려고 함.
    2. 웹팩에서 개발 서버를 열었을 때 MSW 실행을 위해 필요한 mockServiceWorker.js 파일을 찾을 수 없다는 오류가 발생함.
    3. 문제의 원인은 웹팩에서 번들링을 진행할 때 public 폴더 하위 경로에 있는 파일들을 무시하기 때문이었음.
    4. 문제를 해결하기 위해 public 경로에 있는 mockServiceWorker.js 파일을 번들링 후 폴더의 루트 디렉토리에 저장하도록 하는 설정을 추가해줌.
    + + \ No newline at end of file diff --git a/tags/world.html b/tags/world.html index 73cf1396..d3f67953 100644 --- a/tags/world.html +++ b/tags/world.html @@ -5,12 +5,12 @@ "world" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "world" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. +

    "world" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    박스터

    안녕하세요

    이 글을 쓰는 이유

    저희 팀은 flyway를 적용했습니다. 가장 큰 이유는 데이터베이스의 데이터를 drop 할 수 없기 때문입니다.

    데이터베이스를 drop하는 것과 flyway가 무슨 상관이 있길래 적용할까요.

    예시 상황

    제가 아래와 같이 Member라는 entity를 만들었습니다.

    class Member {

    private Long id;
    private String name;
    }

    지금의 entity는 두개의 필드 밖에 없습니다. 어느 날부터 Member에 email이라는 정보가 있어야한다는 요구사항이 생깁니다. 그래서 저희는 아래와 같이 email을 추가합니다.

    class Member {

    private Long id;
    private String name;
    private String email;
    }

    그리고 다시 jpa의 ddl-auto 속성 중 create를 사용해서 새로운 테이블을 만들었습니다. 기존의 테이블을 다 날리면서요.

    하지만 저희의 데이터베이스의 데이터들을 그냥 drop해도 되는 것일까요? 개발 서버라도 힘들게 쌓은 데이터들을 테이블이 조금 변경되었다고 날려버리는 것은 바보같은 일이라고 생각했습니다. 그러면 ddl-auto의 다른 조건인 update를 사용하면 될 것 같습니다. 그랬더니 jpa가 아래와 같이 쿼리를 이쁘게 만들어 줬습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    update를 사용하니 아주 편하게 칼럼이 추가되는 것을 볼 수 있습니다.

    하지만 여기서 또 아래와 같은 요구사항이 추가되었습니다.

    email의 제약조건으로 null이 되면 안되고, 길이는 20자가 되어야합니다. @@ -21,7 +21,7 @@ 거기에 file을 만듭니다. 파일 이름이 중요한데요 V1__init.sql 이러한 방식으로 V{version 숫자}__{어떠한 파일인지에 대한 이름}.sql 언더스코어 2개는 필수로 작성해야합니다.

    create table member(
    id bigint auto_increment primary key,
    name varchar(255) null,
    );

    이렇게 V1__init.sql에 대한 파일을 작성했습니다. 이제는 email을 추가한다는 요구사항을 반영해보겠습니다.

    ALTER TABLE member
    ADD COLUMN email varchar(255);

    이렇게 새로운 파일을 만들어서 해당 스크립트를 작성했습니다. 파일명이 중요한데요, 이전 파일의 숫자보다 +1 이 되는 숫자를 V 뒤에 붙입니다.

    따라서 이번 파일은 V2__add_column_email.sql 이라고 만들었습니다.

    그럼 이제 또 시간이 지나 회원이 많아졌습니다. 하지만 email이 없는 사용자도 많습니다. 이 상황에서 email을 not null로 변경해야한다는 요구사항이 생겼습니다.

    그러면 아래와 같이 반영할 수 있습니다.

    ALTER TABLE member
    MODIFY email VARCHAR(20) NOT NULL default 'default'

    이렇게 V3__add_constraints.sql 파일을 만들었습니다. 그러면 null이 있던 row들은 email이 default가 되고 not null 제약조건이 활성화 된 것을 볼 수 있습니다.

    그러면 주어진 요구사항은 모두 만족할 수 있습니다. 거기에다 v1, v2, v3 가 나뉘어져있어서 어느 커밋부터 해당 sql이 추가되었는지도 확인할 수 있습니다.

    그리고 ddl-auto update를 사용하면 반영되지 않았던 제약조건의 추가도 확인할 수 있습니다. 그러면 ddl-auto의 속성을 validate로 변경하여, db schema와 entity의 필드가 다르면 어플리케이션이 실행되지 않도록 해서 좀 더 안전한 개발을 할 수 있습니다.

    결론

    flyway는 roll back을 하는 것이 유료라서, production 서버에서 혹은 롤백을 해야하는 일이 있는 서버에서는 사용하는 것이 좋지 않지만, 이와 같이 데이터를 drop 할 수 없는 상황이라면, 사용하지 않을 이유가 없어보이는 좋은 도구입니다.

    짧은 글 읽어주셔서 감사합니다.

    - - + + \ No newline at end of file diff --git a/tags/world/page/2.html b/tags/world/page/2.html index 477c2501..88520bd0 100644 --- a/tags/world/page/2.html +++ b/tags/world/page/2.html @@ -5,13 +5,13 @@ "world" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -
    - - +
    + + \ No newline at end of file diff --git a/tags/zero-time.html b/tags/zero-time.html new file mode 100644 index 00000000..7ff9ef29 --- /dev/null +++ b/tags/zero-time.html @@ -0,0 +1,19 @@ + + + + + +"zero-time" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE + + + + + +
    +

    "zero-time" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 9분
    제이

    안녕하세요! 카페인팀의 제이입니다.

    저희 카페인 팀에서 무중단 배포를 진행했습니다. +어떤 과정으로 진행을 했는지 작성해보도록 하겠습니다!


    기존 배포 방식과 문제점

    먼저 카페인 팀의 기존 배포 방식은 다음과 같습니다.

    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근하여서 기존에 띄워진 서버를 다운 시킵니다.
    4. Docker Hub에 업로드한 Docker image를 pull해서 서버를 가동시킵니다.

    이런 과정으로 배포 스크립트가 작성되어 있습니다. 하지만 이 방법은 기존 서버를 다운 시키고 새로운 서버를 띄울 때 다운 타임이 존재한다는 문제점이 있습니다.

    사용자 입장에서는 잘 사용하고 있는데 갑자기 서비스가 작동되지 않는다면 서비스에 대한 신뢰성이 낮아질 수도 있고 이런 이유로 이탈할 수도 있습니다.

    기존 문제를 해결하기

    저희는 먼저 제한된 EC2 인스턴스로 인해 롤링 배포의 장점을 가져갈 수 없었고, 카나리 방식 또한 저희 서비스에서 필요로한 전략이 아니기 때문에 비교적 롤백도 빠른 Blue/Green 전략을 선택하였습니다.

    저희의 Blue/Green 무중단 배포 시나리오는 다음과 같습니다. +편의를 위해서 [기존 서버(기존 포트) / 새로운 서버(새로운 포트)] 라는 명칭을 사용하겠습니다.


    1. Target branch에 push가 되면 Github Actions가 작동합니다.
    2. Target branch의 소스 코드가 빌드되어서 Docker Hub 에 올라가게 됩니다.
    3. Github Actions의 self-hosted runner를 통해 infra 서버에서 prod 서버로 접근해서 Docker Hub에 업로드한 새로운 버전의 Image를 pull 해옵니다.
    4. 만약 8080 포트에 기존 서버가 띄워져 있으면 8081 포트를 새로운 서버가 띄워질 포트로 지정해주고, 반대로 8081 포트에 기존 서버가 띄워져 있으면 8080 포트에 새로운 서버가 띄워질 포트로 지정해줍니다.
    5. 미리 Docker Hub에 업로드한 Docker image를 [image+port]라는 네이밍으로 pull을 한 후 새로운 포트로 서버를 가동시킵니다.
    6. 새로운 서버가 제대로 가동 됐는지 확인하기 위해서 헬스 체크를 진행합니다. 20번 동안 서버가 정상 동작하는지 Spring Actuactor를 통해서 확인을 합니다.
    7. 정상 작동이 됐음을 확인하면 현재 인스턴스에는 2대의 서버가 띄워져있고 요청은 여전히 기존 서버로 들어가게 됩니다. 따라서 Nginx를 통해 포트포워딩을 새로운 서버의 포트로 지정해주고 기존 서버는 내려줍니다.

    여기까지가 카페인 팀의 시나리오입니다. 그렇다면 하나씩 스크립트를 확인해보겠습니다. 설명은 주석으로 달아두겠습니다 :)

    backend-deploy.yml

    (Github Actions에서 사용)

    name: deploy

    # 1. prod/backend branch에 push 작업이 일어나면 해당 작업을 수행한다
    on:
    push:
    branches:
    - prod/backend

    jobs:
    docker-build:
    runs-on: ubuntu-latest
    defaults:
    run:
    working-directory: ./backend

    steps:
    # 2. 도커 허브에 로그인
    - name: Log in to Docker Hub
    uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
    with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_PASSWORD }}
    - uses: actions/checkout@v3

    # 3. JDK 17 설치 및 빌드 (프로젝트 Java version)
    - name: Set up JDK 17
    uses: actions/setup-java@v3
    with:
    java-version: '17'
    distribution: 'adopt'

    - name: Gradle Caching
    uses: actions/cache@v3
    with:
    path: |
    ~/.gradle/caches
    ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: |
    ${{ runner.os }}-gradle-

    - name: Grant execute permission for gradlew
    run: chmod +x gradlew
    - name: Build for asciiDoc
    run: ./gradlew bootjar

    - name: Build with Gradle
    run: ./gradlew bootjar

    # 4. 산출물을 Image로 빌드 후 Docker Hub에 Image Push하기
    - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
    with:
    images: woowacarffeine/backend

    - name: Build and push Docker image
    uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
    with:
    context: .
    file: ./backend/Dockerfile
    push: true
    platforms: linux/arm64
    tags: woowacarffeine/backend:latest
    labels: ${{ steps.meta.outputs.labels }}


    deploy:
    # 5. Self-hosted 작동 -> infra 인스턴스에서 작동됨
    runs-on: self-hosted
    if: ${{ needs.docker-build.result == 'success' }}
    needs: [ docker-build ]
    steps:

    # 6. infra 인스턴스에서 prod 인스턴스로 접근 (아래부터는 prod 서버 내에서 작업)
    - name: Join EC2 prod server
    uses: appleboy/ssh-action@master
    env:
    JASYPT_KEY: ${{ secrets.JASYPT_KEY }}
    DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
    DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SERVER_KEY }}
    port: ${{ secrets.SERVER_PORT }}
    envs: JASYPT_KEY, DATABASE_USERNAME, DATABASE_PASSWORD

    script: |

    # 7. Docker Hub에서 Image를 pull해온다
    sudo docker pull woowacarffeine/backend:latest

    # 8. 만약 8080 포트가 켜져 있으면 새로운 서버의 포트는 8081로 설정
    if sudo docker ps | grep ":8080"; then
    export BEFORE_PORT=8080
    export NEW_PORT=8081
    export NEW_ACTUATOR_PORT=8089

    # 9. 만약 8081 포트가 켜져 있으면 새로운 서버의 포트는 8080로 설정
    else
    export BEFORE_PORT=8081
    export NEW_PORT=8080
    export NEW_ACTUATOR_PORT=8088
    fi

    # 10. Docker로 새로운 서버를 띄운다.
    sudo docker run -d -p $NEW_PORT:8080 -p $NEW_ACTUATOR_PORT:8088 \
    -e "SPRING_PROFILE=prod" \
    -e "ENCRYPT_KEY=${{secrets.JASYPT_KEY}}" \
    -e "DATABASE_USERNAME=${{secrets.DATABASE_USERNAME}}" \
    -e "DATABASE_PASSWORD=${{secrets.DATABASE_PASSWORD}}" \
    -e "REPLICA_DATABASE_USERNAME=${{secrets.REPLICA_DB_USER_NAME}}" \
    -e "REPLICA_DATABASE_PASSWORD=${{secrets.REPLICA_DB_USER_PASSWORD}}" \
    -e "SLACK_WEBHOOK_URL=${{secrets.SLACK_WEBHOOK_URL}}" \
    --name backend$NEW_PORT \
    woowacarffeine/backend:latest

    # 11. prod 인스턴스에 있는 bluegreen.sh 를 작동한다. (이 때 port 값을 같이 넣어준다.)
    sudo sh /home/ubuntu/bluegreen.sh $BEFORE_PORT $NEW_PORT $NEW_ACTUATOR_PORT



    bluegreen.sh

    (prod 인스턴스 내부에 존재)

    #!/bin/bash

    # 1. Github Actions를 통해 넘겨 받은 환경변수 값
    BEFORE_PORT=$1
    NEW_PORT=$2
    NEW_ACTUATOR_PORT=$3

    echo "기존 포트 : $BEFORE_PORT"
    echo "새로운 포트: $NEW_PORT"
    echo "새로운 ACTUATOR_PORT: $NEW_ACTUATOR_PORT"


    # 2. 20번 동안 헬스 체크를 진행
    count=0
    for count in {0..20}
    do
    echo "서버 상태 확인(${count}/20)";

    # 3. 새로운 서버가 작동되는지 Actuator를 통해 값을 받아옴
    STATUS=$(curl -s http://127.0.0.1:${NEW_ACTUATOR_PORT}/actuator/health-check)

    # 4. Actuator를 통해 성공적으로 서버가 띄워지지 않은 경우
    if [ "${STATUS}" != '{"status":"up"}' ]
    then
    # 5. 10초를 기다린 후 다시 헬스 체크를 진행한다.
    sleep 10
    continue
    else
    # 6. 헬스 체크를 통해 새로운 서버가 성공적으로 작동된다면 멈춘다.
    break
    fi
    done


    # 7. 20번의 헬스 체크를 하는 동안 새로운 서버가 제대로 작동되지 않은 경우 종료
    if [ $count -eq 20 ]
    then
    echo "새로운 서버 배포를 실패했습니다."
    exit 1
    fi


    # 8. 새로운 서버가 성공적으로 작동한 경우
    # Nginx를 통해 포트포워딩을 기존 포트에서 새로운 포트로 변경해준다.
    # 이 부분은 .inc 파일을 통해 Nginx에서 주입 받아서 포트만 변경해도 됩니다!
    export BACKEND_PORT=$NEW_PORT
    envsubst '${BACKEND_PORT}' < backend.template > backend.conf
    sudo mv backend.conf /etc/nginx/conf.d/
    sudo nginx -s reload


    # 9. 기존 서버를 내려주고, 도커 리소스를 정리해준다
    docker stop backend$BEFORE_PORT
    sudo docker container prune -f

    이렇게 카페인 팀에서는 무중단 배포를 도입할 수 있었습니다.

    긴 글 읽어주셔서 감사합니다 :)

    + + + + \ No newline at end of file diff --git "a/tags/\352\265\254\352\270\200-\354\247\200\353\217\204.html" "b/tags/\352\265\254\352\270\200-\354\247\200\353\217\204.html" index c82e6525..6992f383 100644 --- "a/tags/\352\265\254\352\270\200-\354\247\200\353\217\204.html" +++ "b/tags/\352\265\254\352\270\200-\354\247\200\353\217\204.html" @@ -5,13 +5,13 @@ "구글 지도" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "구글 지도" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    - - +

    "구글 지도" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 18분
    가브리엘

    안녕하세요? 카페인 팀에서 사용한 지도 시스템에 대해서 소개하려고 합니다.

    지도 기능에서 가장 핵심인 기능 두 가지를 뽑자면, 지도 그 자체와 지도 위에 그려지는 마커를 뽑을 수 있을 것입니다. 지도 위에 마커를 그리는 일은 그다지 어렵지 않고, documents 에 있는 예제들을 잘 따라하면 누구나 충분히 구현할 수 있을 것입니다.

    no offset

    하지만 마커의 갯수가 과도하게 많다면 어떤 전략을 세울 수 있을까요?

    카페인 팀에서는요 ...

    카페인 서비스에서 지도는 굉장히 중요한 요소 중 하나였습니다. 사용자들이 궁금한 장소의 주변에 있는 충전소를 시각적으로 제공해주기 위해서는 지도를 잘 제어할 수 있어야 했습니다. 특히 전국에 이미 수만 대의 충전소가 보급이 된 상황에서 충전소 마커를 모두 그려주기 위해서는 많은 제약이 있었고, 마커를 적당한 수준으로 렌더링 하려면 클라이언트와 서버 간에 특별한 작업이 필요했습니다.

    어떤 전략을 펼쳤는지 소개하기에 앞서 미리 말씀드리지만, 저희 팀에서 취한 지도 관리 전략은 모든 프로젝트에 유효하지 않을 것입니다. 지도 위에 한번에 표현할 마커의 갯수가 수백 개 이하라면, 서버에 데이터가 과도하게 많은 것이 아니라면 오히려 이러한 전략이 사용자 경험을 해칠 수 있을 것입니다. (환경이 원활하다면 데이터를 가능한 많이 보여주는 것이 좋을테니깐요.)

    또, 이 글에서는 Google Maps API를 기준으로 설명하고 있지만, 지원하는 기능이 일부 다르더라도 대부분의 지도 API에서 사용이 가능한 전략일 것입니다. 참고로 개인적으로 사용 해본 여러 벤더 사의 지도 API들은 모두 이와 유사한 기능을 제공했습니다.

    좌표란 무엇일까?

    아마 어린 시절부터 우리나라에는 특별히 38선이라는 것이 존재한다는 사실을 교육받기에 좌표계라는 것이 있다는 사실은 누구나 알 것입니다. 하지만 당장 위도와 경도를 구분지으라고 하면 어떤 선이 위선이고 경선인지 헷갈리기에 찍어야 할 것입니다. 따라서 이 선이 어떤 선인지, 어떤 값을 얘기하려는 것인지 사진과 함께 간단히 설명하겠습니다.

    no offset

    사진을 보시면 아시겠지만 위도란, 남북의 위치를 나타내는 데 사용됩니다. 경도는 동서의 위치를 나타내는 데 사용됩니다. 대부분의 공식 문서가 영어로 작성되어있고, 코드에서도 이를 나타내는 것이 중요하기에 영문 표기법까지 소개를 하자면 위도는 Latitude, 경도는 Longitude로 표기합니다. 이유는 모르겠지만 제공되는 변수나 메서드 명으로 lat, lng라고 줄여서 표기하기도 합니다.

    no offset

    위도와 경도만 알면, 지구 위의 어떤 위치를 나타낼 수 있습니다.

    따라서, 어떤 마커를 어떤 위치에 찍을 것인지는 위도와 경도 값으로 결정할 수 있게 되겠죠?

    사용자가 어딜 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어느 위치를 보고 있는지 알 수 있습니다.

    let map = /* 어디선가 생성된 구글 맵 객체 */
    const center = map.getCenter();
    console.log(center.lng()); // 디바이스 중심의 longitude
    console.log(center.lat()); // 디바이스 중심의 latitude

    지도 객체로 부터 중심점을 알게되면 해당 디바이스의 중심의 좌표를 알아낼 수 있게 됩니다.

    no offset

    사용자의 디바이스는 얼마나 넓게 보고 있을까?

    지도 api에서 제공해주는 메서드를 활용하면 사용자의 디바이스가 어떤 영역을 보고 있는지도 알게 됩니다. 지도 api 마다 제공하는 스펙이 다르지만, 대부분은 어떤 식으로든 알려줍니다.

    google maps API에서는 디스플레이의 북동쪽 끝 점의 좌표와, 남서쪽 끝 점의 좌표를 제공해줍니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    console.log(bounds.getNorthEast().lng(), bounds.getNorthEast().lat()); // 디바이스 1사분면 끝 점의 longitude와 latitude
    console.log(bounds.getSouthWest().lng(), bounds.getSouthWest().lat()); // 디바이스 3사분면 끝 점의 longitude와 latitude

    no offset

    편의상 좌표를 다음과 같이 정의해보겠습니다.

    • 중심 점 p0: (x0, y0)
    • 디바이스의 제 1사분면 끝점 p2: (x2, y2)
    • 디바이스의 제 3사분면 끝점 p1: (x1, y1)
    위 정의는 아래에서도 계속 설명 될 점과 좌표 입니다.

    이렇게 알아낸 값으로 사용자 디바이스의 영역을 알게 됐습니다.

    저희 카페인 팀에서는 이 값을 좀 더 효율적으로 다루기 위해 delta 개념을 도입했습니다.

    화면에서 보고 있는 영역을 확대/축소 하면 어떤 특징을 보일까?

    delta 설명을 앞서, 사용자의 디바이스 영역과 확대 수준에 따른 실제 좌표에 대해 알아보려고 합니다.

    사용자가 화면을 얼마나 넓게 보고 있는지를 쉽게 알기 위해서는 끝점들의 수치를 계산해줄 필요가 있었습니다.

    사진은 사용자가 디바이스를 통해 바라 보고 있는 중심 좌표와 그 끝 점을 의미합니다.

    no offset

    예를 들어 사용자가 지도를 많이 축소한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심 점 p0으로 부터 멀어질 것입니다.

    반면에 사용자가 지도를 많이 확대한 경우에는 중심 점 p0은 그대로지만 양 끝점 p1, p2의 위치가 점점 중심점과 가까워질 것입니다.

    no offset

    양 사진 모두 중심 점 p0는 그대로지만, 디바이스의 확대 수준으로 인해 양 끝점인 p1과 p2가 달라진 모습을 보인 것입니다.

    즉, 이런 결론을 내릴 수 있습니다.

    1. 양 끝점 p1, p2가 중심 점 p0으로 부터 멀어질 수록 지도를 축소한 것이다.
    2. 양 끝점 p1, p2가 중심 점 p0으로 부터 가까워 수록 지도를 확대한 것이다.

    이 때 디바이스의 디스플레이가 위도 경도 상으로 얼마나 멀어져있는지를 수치화하면 편하게 다룰 수 있습니다.

    확대 수준을 수치화 할 수 없을까?

    사용자의 디스플레이의 중심 점 p0을 기준으로 하여 양 끝점 p1, p2이 얼마나 멀어져있는지에 따라 지도의 영역 뿐만 아니라 얼마나 많이 확대 되었는지 여부를 알게 됐습니다.

    그렇다면 이를 좀 더 효율적인 방법으로 나타내려면 어떤 전략을 취할 수 있을까요?

    사용자 디스플레이를 조금 더 자세히 살펴보겠습니다.

    no offset

    중학교 시절 배웠던 좌표 평면계를 떠올려보면 화면에서 얻을 수 있는 좌표들은 위와 같습니다. 여기에서 각 점의 수직/수평의 변화량인 delta를 알아보면 어떨까요?

    경도 델타 (longitudeDelta)

    p2와 p0의 경도 거리, 그리고 p1과 p0의 경도 거리는 같습니다.

    즉, x2 - x0 === x0 - x1 이라는 결론을 얻을 수 있습니다.

    이를 longitudeDelta로 정의하겠습니다.

    위도 델타 (latitudeDelta)

    p2와 p0의 위도 거리, 그리고 p1과 p0의 위도 거리는 같습니다.

    즉, y2 - y0 === y0 - y1 이라는 결론을 얻을 수 있습니다.

    이를 latitudeDelta로 정의하겠습니다.

    no offset

    코드로 알아보면 다음과 같습니다.

    const map = /* 어디선가 생성된 구글 맵 객체 */
    const bounds = map.getBounds();
    const longitudeDelta = (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2; // 경도 변화량
    const latitudeDelta = (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2; // 위도 변화량

    드디어 클라이언트에서 델타 값을 생성할 수 있게 되었습니다.

    그렇다면 왜 이렇게 굳이 델타 값을 생성한 것일까요?

    delta의 유용한 점 1: 원래 의도한 값을 복원하기 쉽다.

    서버의 입장에서는 중심 좌표와 델타 값만 알면 정확한 영역만큼 데이터를 호출할 수 있게 됩니다.

    예를 들어 클라이언트에서 서버로 다음과 같은 파라미터를 넘겨줬다고 가정해보겠습니다.

    {
    "longitude": 127,
    "latitude": 37,
    "longitudeDelta": 0.1,
    "longitudeDelta": 0.2,
    }

    그렇다면 서버에서는 다음과 같이 해석할 수 있게 됩니다.

    const maxLongitude = longitude + longitudeDelta;
    const minLongitude = longitude - longitudeDelta;
    const maxLatitude = latitude + latitudeDelta;
    const minLatitude = latitude - latitudeDelta;

    (javascript 기준으로 작성했습니다.)

    이렇게 알아낸 경계 값을 가지고 다음과 같은 sql문을 작성할 수 있게 될 것입니다.

    SELECT * FROM stations WHERE latitude >= :minLatitude AND latitude <= :maxLatitude AND longitude >= :minLongitude AND longitude <= :maxLongitude;

    no offset

    즉, 위 그림처럼, 원하는 영역만큼만 정확하게 데이터를 호출할 수 있게 됩니다.

    delta의 유용한 점 2: 델타가 무분별하게 커지는 것을 막기 쉽다.

    예를 들어 사용자가 지도를 축소하여 한반도를 디스플레이에 가득 채운다면 서버가 어떻게 될까요?

    이러한 행위를 막는 가장 쉬운 방법은 지도 api에서 지원하는 줌 레벨을 제한 하는 것입니다. 후술하겠지만 줌 레벨은 디스플레이의 해상도를 고려하지 못합니다.

    따라서 근본적으로 델타가 일정 값 이상 요청되지 못하도록, 혹은 연산되지 못하도록 막게 할 수 있습니다.

    물론 델타가 없더라도 델타 값을 추정하여 연산할 수 있겠지만, 이를 수치화 해서 관리한다면 클라이언트와 서버 모두 지도를 손쉽게 통제하는 것이 가능하게 됩니다.

    예를 들어 다음과 같이 델타 값을 고정하여 요청 영역을 제한할(요청을 보내지 않거나 고정된 사이즈로만 요청을 보낼) 수 있습니다.

    {
    longitude,
    latitude,
    longitudeDelta: longitudeDelta < 0.008 ? longitudeDelta : 0.008,
    latitudeDelta: latitudeDelta < 0.004 ? latitudeDelta : 0.004,
    }

    특정 수치를 넘기지 못하게 처리할 때 눈에 보이는 변수로 취급하기 쉽습니다. (즉, 매번 계산하지 않아도 됩니다.)

    디바이스 크기 관련 문제도 있습니다.

    분명히 같은 줌 레벨이지만, 디바이스의 크기나 해상도에 따라 지도가 보여지는 정도가 다릅니다.

    no offset

    위 사진은 구글에서 제공하는 zoom 레벨을 동일하게 맞춘 후, 여러 디바이스에서 호출한 것입니다.

    줌 레벨을 통해서 요청을 제한하다보면 여러 해상도를 제어하기 어렵습니다.

    no offset

    실제로 카페인 팀에서는 고해상도 모니터를 대응하기 위해 델타 값이 너무 크게 되면 요청의 제한을 하고 있습니다. 사진에서 보시다시피 고해상도 모니터의 경우, 너무 넓은 범위를 요청한다 싶으면 중심점으로 부터 일정 거리만 보여주도록 하고 있습니다.

    (참고로 줌 레벨에 따른 요청도 덤으로 제한하고 있어서 멀리서 호출하는 행위도 금지하고 있습니다.)

    delta의 유용한 점 3: 적당한 범위를 정해주기 편하다

    위 예제에서는 정확한 범위만큼 요청하는 것을 예제로 하지만, 프로젝트에 따라서 조금 더 넓은 영역을 호출하고 싶을 때가 있을 것입니다.

    no offset

    예를 들어 현재 사용자의 디바이스 크기보다 살짝 큰 범위의 데이터를 미리 로드해 놓으면 사용자가 좁은 움직임을 보일 때 불필요한 재 렌더링을 줄여서 더 빠른 렌더링이 가능하게 됩니다.

    사실 이 기법은 프로젝트마다 다르겠지만, 카페인 팀에서는 한번 불러온 마커를 매번 해제 하지 않고 이전 요청 데이터와 다음 요청 데이터를 비교하여 달라진 마커만을 정확하게 탈부착하는 작업을 진행하고 있습니다.

    이런 기법을 활용하면 사용자가 좁은 범위에서 움직임을 보였을 때, 기존에 불러온 마커를 메모리에서 탈락시키지 않으므로 사용자 경험을 개선할 수도 있을 것입니다.

    마커를 상태에 연동하여 정확하게 메모리에서 탈부착 시키는 전략에 대한 글은 이후에 작성할 예정입니다.

    긴 글 읽어주셔서 감사합니다.

    + + \ No newline at end of file diff --git "a/tags/\353\260\251\353\254\270\354\236\220-\353\266\204\354\204\235.html" "b/tags/\353\260\251\353\254\270\354\236\220-\353\266\204\354\204\235.html" index 8e9ea712..c6501ab7 100644 --- "a/tags/\353\260\251\353\254\270\354\236\220-\353\266\204\354\204\235.html" +++ "b/tags/\353\260\251\353\254\270\354\236\220-\353\266\204\354\204\235.html" @@ -5,12 +5,12 @@ "방문자 분석" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "방문자 분석" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset +

    "방문자 분석" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset no offset

    네이버에 있는 전기자동차 동호회 카페 중 가장 큰 곳에 글을 올려 방문자를 모집하기로 했습니다.

    카페에 글을 올리는 것은 무료이며, 카페에 가입한 사람들은 전기차에 관심이 있는 사람들이기 때문에 저희가 원하는 방문자를 모집하기에 적합하다고 생각했습니다.

    카카오톡 오픈채팅

    no offset no offset

    카카오톡 오픈채팅에는 수많은 대화방이 존재합니다.

    특정 주제로 만들어진 대화방이 대부분이기에 전기차를 주제로 한 오픈채팅 대화방을 찾는 것은 전혀 어렵지 않았습니다.

    안타깝게도 일부 단톡방에서 강퇴를 당했지만, 차주들과 채팅하면서 피드백을 받아볼 수 있었습니다.

    기타 홍보 수단

    기타 홍보 수단은 아직 사용하지 않았습니다.

    네이버 밴드, 보배드림은 사용하는 크루가 없어서 홍보를 하기 어려웠고, 구글 애드센스와 같은 도구는 비용이 발생하기에 아직은 이르다고 판단했습니다.

    Google Analytics 4 통계 집계 결과

    단순 방문자

    no offset no offset @@ -20,7 +20,7 @@ no offset no offset no offset

    집계 된 자료처럼 방문자들이 단순 방문만 한 것이 아니라, 수 많은 이벤트를 발생시키고 평균 참여 시간도 상당 부분 확보했음을 확인할 수 있습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\353\260\260\355\217\254.html" "b/tags/\353\260\260\355\217\254.html" index 73e405b3..e0461492 100644 --- "a/tags/\353\260\260\355\217\254.html" +++ "b/tags/\353\260\260\355\217\254.html" @@ -5,13 +5,13 @@ "배포" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "배포" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    - - +

    "배포" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git "a/tags/\354\204\234\353\262\204-\353\266\200\355\225\230-\354\244\204\354\235\264\352\270\260.html" "b/tags/\354\204\234\353\262\204-\353\266\200\355\225\230-\354\244\204\354\235\264\352\270\260.html" index 182bb231..7bb829a1 100644 --- "a/tags/\354\204\234\353\262\204-\353\266\200\355\225\230-\354\244\204\354\235\264\352\270\260.html" +++ "b/tags/\354\204\234\353\262\204-\353\266\200\355\225\230-\354\244\204\354\235\264\352\270\260.html" @@ -5,12 +5,12 @@ "서버 부하 줄이기" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "서버 부하 줄이기" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. +

    "서버 부하 줄이기" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. 기존에는 충전소 간단 정보와 마커 정보를 한 번에 받아오도록 설계되어 있었지만, 백엔드와 프론트엔드가 협업하여 간단 정보와 마커 정보를 각각 필요한 만큼만 조회하도록 명세를 수정하였습니다.

    이 과정에서 먼저, 백엔드와 프론트엔드는 함께 모여 기능 요구사항과 성능 개선 목표를 논의하였습니다. 그리고 충전소 간단 정보와 마커 정보를 각각 조회하는 API 엔드포인트를 새로 설계하였습니다.

    다음으로, 백엔드에서 간단 정보 조회를 위한 API를 구현하였습니다. @@ -21,7 +21,7 @@ 이 정보를 제외하고 마커를 띄우기 위해 필요한 최소한의 정보를 조회하도록 수정해 서버의 부하를 낮췄습니다.

    이러한 변경으로 인해 충전소 조회 API의 성능이 개선되었습니다. 필요한 정보만을 조회하므로써 데이터베이스의 부하를 줄이고 응답 시간을 단축할 수 있게 되었습니다. 또한, 프론트엔드에서는 필요한 정보만을 호출하여 불필요한 데이터를 받아오지 않아도 되므로 클라이언트 측의 성능도 향상되었습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\204\234\353\262\204.html" "b/tags/\354\204\234\353\262\204.html" index 779c6417..223cd32a 100644 --- "a/tags/\354\204\234\353\262\204.html" +++ "b/tags/\354\204\234\353\262\204.html" @@ -5,12 +5,12 @@ "서버" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "서버" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 10분
    제이
    박스터

    안녕하세요~ +

    "서버" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 10분
    제이
    박스터

    안녕하세요~ 우테코 카페인 팀의 제이입니다.

    오늘은 카페인 팀의 프로젝트를 진행하면서 '박스터'와 함께 어떤 문제를 겪고 해결했는지 적어보도록 하겠습니다.

    • 배우는 단계이다 보니 틀린 부분이 있을 수 있는데, 피드백 부탁드립니다 :)

    먼저 글을 쓰기 전에 문제 상황에 대해 간단하게 말씀드리겠습니다.

    문제 상황

    카페인 팀에서는 전기차 충전소 공공 API를 활용하여 충전소의 혼잡도 제공 및 여러 서비스를 제공합니다.

    이런 서비스를 사용자들에게 제공하기 위해서 다음과 같은 작업들이 필요합니다.

    1. 첫 실행시 공공 API 데이터를 모두 불러서 데이터베이스에 삽입합니다.
    2. 혼잡도를 제공하기 위해서 주기적인 시간 (아직 정하진 않았지만 ex.12시간) 단위로 충전소와 충전기의 상태를 업데이트 하기 위해서 다시 데이터를 요청을 합니다.
    3. 새롭게 추가된 충전소와 충전기는 모두 Insert해주고, 기존에 있던 충전소 혹은 충전기가 업데이트 됐다면 변경된 데이터로 업데이트 해줍니다.

    저랑 박스터는 2~3번 과정을 진행하는 역할을 맡았습니다.

    테이블의 관계는 다음과 같습니다.

    charge_station <---1------N---> charger
    charger <---1------1---> charger_status

    저희는 이 문제를 어떻게 해결 했는지 보겠습니다.

    문제 해결 과정

    전제조건

    • 첫 실행 모든 테이블은 초기화 상태이다.
    • 데이터는 9999건을 기준으로 한다.
    • 메서드 첫 시행에서는 모든 데이터가 새롭게 insert 되고
    • 그 다음 메서드 시행에서는 일부 데이터는 추가되고, 일부는 업데이트 된다.

    Ver1. findAll() 조회 후 각각 save() 해주기 (약14초)

    저희가 처음에 생각한 방법입니다. 알아서 바뀐 것들은 업데이트 해주고, 새로운 건 저장해주기 때문에 간단한 방법으로 생각했습니다.

    실제로 해본 결과, 삽입의 경우는 SELECT 쿼리문 실행 후 INSERT 쿼리문을 발생 시켰고, 업데이트 시에도 SELECT 후 UPDATE 혹은 INSERT를 발생 시켰습니다. (변경 사항 없으면 SELECT만)

    이는 식별자에 따른 JPA 작동 방식 때문인데요. @@ -26,7 +26,7 @@ 이를 통해서 Ver2에 비해서 1초정도 줄었습니다.

    Ver4. 이전 방식 + Fetch Join 사용하기 (약 6초)

    마지막 방법은 조회 과정의 시간 단축입니다.

    처음에 Stations를 findAll()하는 쿼리를 확인해보니 N+1 문제가 발생하고 있었습니다. 그 이유는 Station에서 Chargers를 지연로딩으로 설정 했는데, 이를 그대로 get 메서드를 통해 조회해서 해당 문제가 발생했습니다.

    List<ChargeStation> findAll(); // 기존

    @Query("SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers"); // Fetch Join 적용
    List<ChargeStation> findAll();

    따라서 위에 코드와 같이 Fetch Join을 이용해서 처음에 데이터를 가져왔습니다. 이렇게 효율적인 조회로 변경하면서 시간을 많이 줄일 수 있었습니다.

    지금까지의 방법을 정리를 하자면

    Ver1 과 같은 방식에서는 업데이트 과정에서 JPA의 식별자에 따른 처리 방식으로 인해 [SELECT + UPDATE] or [SELECT + INSERT] 와 같이 쿼리가 두 번씩 나갔습니다.

    그래서 Ver3까지 개선을 하기 위해서 저장과 업데이트를 한 번에 JDBC를 이용해서 Batch로 처리해주는 방식을 선택했고,

    변경 감지 + 배치 데이터를 모으기 위해서 자료구조를 이용해서 시간을 조금씩 단축 했습니다.

    마지막으로 Ver4에서는 findAll()에서 발생하는 N+1의 문제를 해결하면서 시간을 단축했습니다.

    이런 과정을 통해서 동일 작업을 14초에서 6초 정도로 줄일 수 있었습니다!

    - - + + \ No newline at end of file diff --git "a/tags/\354\204\234\353\262\204/page/2.html" "b/tags/\354\204\234\353\262\204/page/2.html" index 2e33ad08..f0f0530a 100644 --- "a/tags/\354\204\234\353\262\204/page/2.html" +++ "b/tags/\354\204\234\353\262\204/page/2.html" @@ -5,13 +5,13 @@ "서버" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "서버" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    - - +

    "서버" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git "a/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230.html" "b/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230.html" index 1869e0e5..ce172919 100644 --- "a/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230.html" +++ "b/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230.html" @@ -5,15 +5,15 @@ "서비스 경험" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "서비스 경험" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset +

    "서비스 경험" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset no offset

    충전기를 꽂고 나서 알게된 것은 카페인 서비스에 나온 충전소 회사명과 방금 꽂은 충전기 회사명이 다르다는 것이었습니다.

    알고보니 음성 인식으로 네비에 검색한 충전소는 판교공영주차장이 아닌 판교역 환승 주차장이라 엉뚱한 곳으로 온 것이었습니다!!!

    다행인 점은 우리 서비스에서 제공하는 충전기 사용 여부 정보가 잘못된 것이 아니었다는 것이었습니다.

    그래서 애초에 가고자 했던 판교공영주자창에 대한 카페인 서비스의 정보가 실제와 동일한지 확인해보러 걸어서 이동했습니다. (바로 앞에 있었기 때문입니다.)

    no offset no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230/page/2.html" "b/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230/page/2.html" index 53036d64..ae9af0cc 100644 --- "a/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230/page/2.html" +++ "b/tags/\354\204\234\353\271\204\354\212\244-\352\262\275\355\227\230/page/2.html" @@ -5,12 +5,12 @@ "서비스 경험" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "서비스 경험" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. +

    "서비스 경험" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. 저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. 진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! 진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. @@ -27,7 +27,7 @@ 차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? 이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\225\204\355\202\244\355\205\215\354\262\230.html" "b/tags/\354\225\204\355\202\244\355\205\215\354\262\230.html" index 35e498ac..8bec484d 100644 --- "a/tags/\354\225\204\355\202\244\355\205\215\354\262\230.html" +++ "b/tags/\354\225\204\355\202\244\355\205\215\354\262\230.html" @@ -5,13 +5,13 @@ "아키텍처" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "아키텍처" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    - - +

    "아키텍처" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git "a/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244.html" "b/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244.html" index 111c9716..a8d2e0c1 100644 --- "a/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244.html" +++ "b/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244.html" @@ -5,12 +5,12 @@ "우아한테크코스" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "우아한테크코스" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 10분
    제이
    박스터

    안녕하세요~ +

    "우아한테크코스" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 10분
    제이
    박스터

    안녕하세요~ 우테코 카페인 팀의 제이입니다.

    오늘은 카페인 팀의 프로젝트를 진행하면서 '박스터'와 함께 어떤 문제를 겪고 해결했는지 적어보도록 하겠습니다.

    • 배우는 단계이다 보니 틀린 부분이 있을 수 있는데, 피드백 부탁드립니다 :)

    먼저 글을 쓰기 전에 문제 상황에 대해 간단하게 말씀드리겠습니다.

    문제 상황

    카페인 팀에서는 전기차 충전소 공공 API를 활용하여 충전소의 혼잡도 제공 및 여러 서비스를 제공합니다.

    이런 서비스를 사용자들에게 제공하기 위해서 다음과 같은 작업들이 필요합니다.

    1. 첫 실행시 공공 API 데이터를 모두 불러서 데이터베이스에 삽입합니다.
    2. 혼잡도를 제공하기 위해서 주기적인 시간 (아직 정하진 않았지만 ex.12시간) 단위로 충전소와 충전기의 상태를 업데이트 하기 위해서 다시 데이터를 요청을 합니다.
    3. 새롭게 추가된 충전소와 충전기는 모두 Insert해주고, 기존에 있던 충전소 혹은 충전기가 업데이트 됐다면 변경된 데이터로 업데이트 해줍니다.

    저랑 박스터는 2~3번 과정을 진행하는 역할을 맡았습니다.

    테이블의 관계는 다음과 같습니다.

    charge_station <---1------N---> charger
    charger <---1------1---> charger_status

    저희는 이 문제를 어떻게 해결 했는지 보겠습니다.

    문제 해결 과정

    전제조건

    • 첫 실행 모든 테이블은 초기화 상태이다.
    • 데이터는 9999건을 기준으로 한다.
    • 메서드 첫 시행에서는 모든 데이터가 새롭게 insert 되고
    • 그 다음 메서드 시행에서는 일부 데이터는 추가되고, 일부는 업데이트 된다.

    Ver1. findAll() 조회 후 각각 save() 해주기 (약14초)

    저희가 처음에 생각한 방법입니다. 알아서 바뀐 것들은 업데이트 해주고, 새로운 건 저장해주기 때문에 간단한 방법으로 생각했습니다.

    실제로 해본 결과, 삽입의 경우는 SELECT 쿼리문 실행 후 INSERT 쿼리문을 발생 시켰고, 업데이트 시에도 SELECT 후 UPDATE 혹은 INSERT를 발생 시켰습니다. (변경 사항 없으면 SELECT만)

    이는 식별자에 따른 JPA 작동 방식 때문인데요. @@ -26,7 +26,7 @@ 이를 통해서 Ver2에 비해서 1초정도 줄었습니다.

    Ver4. 이전 방식 + Fetch Join 사용하기 (약 6초)

    마지막 방법은 조회 과정의 시간 단축입니다.

    처음에 Stations를 findAll()하는 쿼리를 확인해보니 N+1 문제가 발생하고 있었습니다. 그 이유는 Station에서 Chargers를 지연로딩으로 설정 했는데, 이를 그대로 get 메서드를 통해 조회해서 해당 문제가 발생했습니다.

    List<ChargeStation> findAll(); // 기존

    @Query("SELECT DISTINCT c FROM ChargeStation c JOIN FETCH c.chargers"); // Fetch Join 적용
    List<ChargeStation> findAll();

    따라서 위에 코드와 같이 Fetch Join을 이용해서 처음에 데이터를 가져왔습니다. 이렇게 효율적인 조회로 변경하면서 시간을 많이 줄일 수 있었습니다.

    지금까지의 방법을 정리를 하자면

    Ver1 과 같은 방식에서는 업데이트 과정에서 JPA의 식별자에 따른 처리 방식으로 인해 [SELECT + UPDATE] or [SELECT + INSERT] 와 같이 쿼리가 두 번씩 나갔습니다.

    그래서 Ver3까지 개선을 하기 위해서 저장과 업데이트를 한 번에 JDBC를 이용해서 Batch로 처리해주는 방식을 선택했고,

    변경 감지 + 배치 데이터를 모으기 위해서 자료구조를 이용해서 시간을 조금씩 단축 했습니다.

    마지막으로 Ver4에서는 findAll()에서 발생하는 N+1의 문제를 해결하면서 시간을 단축했습니다.

    이런 과정을 통해서 동일 작업을 14초에서 6초 정도로 줄일 수 있었습니다!

    - - + + \ No newline at end of file diff --git "a/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244/page/2.html" "b/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244/page/2.html" index 929e74ac..7bb3bacb 100644 --- "a/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244/page/2.html" +++ "b/tags/\354\232\260\354\225\204\355\225\234\355\205\214\355\201\254\354\275\224\354\212\244/page/2.html" @@ -5,13 +5,13 @@ "우아한테크코스" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "우아한테크코스" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    - - +

    "우아한테크코스" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    누누

    안녕하세요 우아한테크코스 카페인팀 누누입니다

    이번에 카페인 팀에서 배포 아키텍처를 결정하게 되었던 과정에 대해서 정리를 해보고 싶어서 글을 쓰게 되었습니다.

    아키텍처와 서버가 배포되는 과정을 보여드리면서 시작하도록 하겠습니다

    배포 아키텍처

    서버가 배포되는 과정은 다음과 같습니다.

    server image

    우아한테크코스 인스턴스에 대한 소개

    우테코에서 선택할 수 있는 인스턴스는 총 2가지 종류입니다.

    1. 퍼블릭 서브넷에 있는 인스턴스
      • 캠퍼스에서만 SSH 접근이 가능한 인스턴스입니다.
      • 미리 열려있는 포트들만 허용이 되어 있습니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다
    2. 프라이빗 서브넷에 있는 인스턴스
      • 퍼블릭 서브넷에 있는 인스턴스를 통해서만 접근이 가능합니다.
      • 같은 서브넷에 있는 인스턴스끼리는 모든 포트가 허용되어 있습니다.

    1번 인스턴스를 2개 사용 가능하고, 2번 인스턴스를 1개 사용 가능합니다.

    권장되는 환경에서 1개는 db 서버로 사용하고, 나머지 2개는 자유롭게 사용이 가능했습니다.

    그전에 알면 좋아요

    여기서는 Self Hosted Runner를 사용했는데요.

    Self Hosted Runner에 대한 내용은 여기 에 잘 나와있습니다.

    외부 IP로부터 SSH 접근이 불가능하기에, Self Hosted Runner 나, Jenkins 같은 방법을 사용할 수 있었는데, 러닝 커브를 고려해서 Self Hosted Runner를 선택하게 되었습니다.

    배포 아키텍처에 대한 고민

    저희 팀이 이번 아키텍처를 만들기 위해서 고민했던 점들은 다음과 같습니다.

    1. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
    3. 2차 데모데이까지의 과제인 개발 서버를 어떻게 구성할 수 있을까?

    여기서 1번을 가장 먼저 생각한 아키텍처를 구성하게 되었는데, 다음과 같습니다.

    선택의 기준이 되었던 것은 총 3가지였습니다.

    1. DB는 프라이빗 서브넷에 위치시키고, 우리 인스턴스를 거쳐서만 접근이 가능하게 한다.
      • 이 부분은 보안을 위해서 어쩔 수 없이 선택하게 된 부분입니다.이 부분을 고려하다 보니, 최소한으로 구성할 수 있는 구조가 db 용 private 인스턴스 1개, 그리고 우리가 사용할 public 인스턴스 1개가 됩니다
    2. 운영 서버를 나중에 추가하게 되었을 때, 어떻게 중복으로 관리되는 부분을 최소화할 수 있을까?
      • 개발용 인스턴스에 CD 툴이나, 모니터링 툴을 설치하게 되면, 운영 서버에도 동일하게 작업을 해야 합니다.
      • 이 부분을 최소화하기 위해서, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 되었습니다.
    3. 어떻게 하면 장애의 영향을 최소화할 수 있을까?
      • 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리하게 않는다면 개발용 인스턴스에서 장애가 발생했을 때, CD 툴과 모니터링 툴에도 영향을 미치게 됩니다. 이 부분을 생각했을 때도, 개발용 인스턴스와, CD 툴, 모니터링 툴을 설치한 인스턴스를 분리해야 한다고 결정하게 되었습니다
      • 한 부분의 장애가 다른 툴까지 사용할 수 없게 만들게 되어서, 롤백이나, 상황 파악을 하기 힘들게 만들게 됩니다.

    이런 과정들을 생각했을 때, 인스턴스 1개를 개발 서버용으로, 인스턴스 1개를 CD 툴과 모니터링 툴을 설치한 인스턴스로 사용하게 되었습니다.

    실제 내부 구성은 어떻게 될까요?

    개발 서버

    이 인스턴스에는 총 2가지 기능이 들어가 있습니다.

    1. 프론트 서버
      • react로 되어있는 프론트엔드 코드를 사용자에게 전달해 주는 역할을 합니다.
    2. 백엔드 서버
      • spring으로 되어있는 api 서버입니다.

    물론, 이렇게 하면 두 곳 중 한 곳에 장애가 발생했을 때, 프론트 서버와 백엔드 서버가 모두 영향을 받게 됩니다.

    같이 관리하게 된 첫 번째 이유로 비용이 들기 때문에 비용의 문제를 고려하게 되었습니다. 개발 서버에서 프론트 서버와 백엔드 서버를 관리하게 되었습니다.

    두 번째 이유로는, 아직 프로젝트 초창기 이기 때문에, 백엔드에서 장애가 났을 때, 프론트에서 일정 이상의 에러 처리가 불가능했습니다.

    프로젝트가 많이 진행되었다면, 프론트엔드만으로 혹은 장애가 나지 않은 서버를 활용해 에러 처리를 할 수 있지만, 아직은 그런 기능을 구현하지 못했습니다.

    이와는 별개로 실행 시 편의를 위해서 도커를 사용해 개발 서버를 관리하고 있습니다.

    CD 툴과 모니터링 툴

    이 인스턴스에는 총 3가지 기능이 들어가 있습니다.

    1. CD 툴
      • 위에서 설명드린 것처럼, self hosted runner 가 동작하게 되어있습니다
    2. 보안을 위한 리버스 프록시
      • 저희 프로젝트에서 구글 지도를 사용하게 되는데, 이때 API 키를 사용하게 됩니다. 이렇게 하면, API 키를 노출시키지 않고, 사용할 수 있습니다.
      • 이 API 키를 노출시키지 않기 위해서, 리버스 프록시를 하나 두고, 여기서 API 키를 추가해 요청을 보내는 방식으로 구성하게 되었습니다.
    3. 모니터링 툴
      • 저희 프로젝트에서 아직 도입하지 않았지만, 현재 이슈로는 올라가 있는 상태입니다.
      • Actuator, 프로메테우스, 그라파나 이 3가지를 활용해서 모니터링 툴을 구성하게 될 예정입니다

    위 기능들이 한 인스턴스에 모여있기에, 위의 기능들은 추후에 운영 서버가 추가되었을 때, 중복으로 관리하지 않아도 됩니다.

    배포 과정 더 자세히 알아보기

    아래에 사진에서 보이는 과정을 통해서 배포를 진행하고 있는데요

    server image

    1. 사용자가 push를 하면, github actions에서 도커 빌드를 진행하고, 도커 허브에 이미지를 올립니다.
    2. 도커 허브에 이미지가 올라간 이후에, self hosted runner 가 작동을 시작합니다.
    3. 개발용 인스턴스에 접근해서, 이미지를 받고, 컨테이너를 실행합니다.

    이런 과정을 통해서, 개발용 인스턴스에 배포를 진행하고 있습니다.

    느낀 점

    좋은 아키텍처를 설계하기 위해서는 고려해야 할 점들이 정말 많다는 것을 다시 한번 느꼈습니다.

    운영 서버가 추가된다던가, 인스턴스가 늘어나고, 줄어드는 상황에 유연하게 대처할 수 있도록 설계를 해야 한다는 것을 다시 한번 느꼈습니다.

    중복으로 관리될 포인트를 줄여야 한다는 것도 다시 한번 느낄 수 있었고요

    긴 글을 읽어주셔서 감사합니다

    + + \ No newline at end of file diff --git "a/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260.html" "b/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260.html" index eb79c49e..5f5bbfbe 100644 --- "a/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260.html" +++ "b/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260.html" @@ -5,15 +5,15 @@ "전기차 사용기" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "전기차 사용기" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset +

    "전기차 사용기" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset no offset

    충전기를 꽂고 나서 알게된 것은 카페인 서비스에 나온 충전소 회사명과 방금 꽂은 충전기 회사명이 다르다는 것이었습니다.

    알고보니 음성 인식으로 네비에 검색한 충전소는 판교공영주차장이 아닌 판교역 환승 주차장이라 엉뚱한 곳으로 온 것이었습니다!!!

    다행인 점은 우리 서비스에서 제공하는 충전기 사용 여부 정보가 잘못된 것이 아니었다는 것이었습니다.

    그래서 애초에 가고자 했던 판교공영주자창에 대한 카페인 서비스의 정보가 실제와 동일한지 확인해보러 걸어서 이동했습니다. (바로 앞에 있었기 때문입니다.)

    no offset no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260/page/2.html" "b/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260/page/2.html" index f71043b2..b955f163 100644 --- "a/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260/page/2.html" +++ "b/tags/\354\240\204\352\270\260\354\260\250-\354\202\254\354\232\251\352\270\260/page/2.html" @@ -5,12 +5,12 @@ "전기차 사용기" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "전기차 사용기" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. +

    "전기차 사용기" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. 저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. 진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! 진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. @@ -27,7 +27,7 @@ 차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? 이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261.html" "b/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261.html" index a0bde88c..8f219228 100644 --- "a/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261.html" +++ "b/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261.html" @@ -5,15 +5,15 @@ "전기차 충전소 앱" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "전기차 충전소 앱" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset +

    "전기차 충전소 앱" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset no offset

    충전기를 꽂고 나서 알게된 것은 카페인 서비스에 나온 충전소 회사명과 방금 꽂은 충전기 회사명이 다르다는 것이었습니다.

    알고보니 음성 인식으로 네비에 검색한 충전소는 판교공영주차장이 아닌 판교역 환승 주차장이라 엉뚱한 곳으로 온 것이었습니다!!!

    다행인 점은 우리 서비스에서 제공하는 충전기 사용 여부 정보가 잘못된 것이 아니었다는 것이었습니다.

    그래서 애초에 가고자 했던 판교공영주자창에 대한 카페인 서비스의 정보가 실제와 동일한지 확인해보러 걸어서 이동했습니다. (바로 앞에 있었기 때문입니다.)

    no offset no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261/page/2.html" "b/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261/page/2.html" index 87ed2e1c..3b934cde 100644 --- "a/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261/page/2.html" +++ "b/tags/\354\240\204\352\270\260\354\260\250-\354\266\251\354\240\204\354\206\214-\354\225\261/page/2.html" @@ -5,12 +5,12 @@ "전기차 충전소 앱" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "전기차 충전소 앱" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. +

    "전기차 충전소 앱" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. 저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. 진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! 진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. @@ -27,7 +27,7 @@ 차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? 이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\240\204\354\227\255-\354\203\201\355\203\234-\352\264\200\353\246\254.html" "b/tags/\354\240\204\354\227\255-\354\203\201\355\203\234-\352\264\200\353\246\254.html" index 6d5a1101..d1534025 100644 --- "a/tags/\354\240\204\354\227\255-\354\203\201\355\203\234-\352\264\200\353\246\254.html" +++ "b/tags/\354\240\204\354\227\255-\354\203\201\355\203\234-\352\264\200\353\246\254.html" @@ -5,12 +5,12 @@ "전역 상태 관리" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "전역 상태 관리" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. +

    "전역 상태 관리" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. 생성된 StateManager 인스턴스가 반환되어 store가 곧 초기 값을 가지는 StateManager가 됩니다.

    no offset

    예를 들어, 다음과 같은 코드가 있다고 할 때

    export const countStore = store<number>(0);

    countStore는 곧 0을 초기값으로 가지는 StateManager 인스턴스이기도 하게 됩니다.

    그러면 StateManager에 대해서 알아보겠습니다.

    StateManager는 react 바깥에 있는 어떤 저장소이다.

    (근데 이게 그냥 저장소는 아니고 좀 특별한 저장소다.)

    export type SetStateCallbackType<T> = (prevState: T) => T;

    export interface DataObserver<T> {
    setState: (param: SetStateCallbackType<T> | T) => void;
    getState: () => T;
    subscribe: (listener: () => void) => () => void;
    emitChange: () => void;
    }

    class StateManager<T> implements DataObserver<T> {
    public state: T;
    private listeners: Array<() => void> = [];

    constructor(initialState: T) {
    this.state = initialState;
    }

    setState = (param: SetStateCallbackType<T> | T) => {
    if (param instanceof Function) {
    const newState = param(this.state);
    this.state = newState;
    } else {
    this.state = param;
    }

    this.emitChange();
    };

    getState = () => {
    return this.state;
    };

    subscribe = (listener: () => void) => {
    this.listeners = [...this.listeners, listener];

    return () => {
    this.listeners = this.listeners.filter((l) => l !== listener);
    };
    };

    emitChange = () => {
    for (const listener of this.listeners) {
    listener();
    }
    };
    }

    export default StateManager;

    StateManager 클래스는 외부에서 받아온 초기값을 상태로 가집니다. setState, getState, subscribe, emitChange를 메서드로 가집니다. 여기서 작성된 코드들은 react에서 외부 저장소와 소통하기 위한 최소한의 규격입니다.

    • subscribe: 단일 콜백 인수를 사용하여 스토어에 구독하는 함수입니다. 스토어가 변경되면 제공된 콜백을 호출해야 합니다. 그러면 구성 요소가 다시 렌더링 됩니다. 구독 기능은 구독을 정리하는 기능을 반환해야 합니다. (구독에 관련된 데이터는 리스너 배열 필드에 넣어서 관리합니다.)

    • emitChange: 리스너 배열 필드에 담겨있는 모든 리스너를 실행합니다. 즉, 구독된 어떤 것을 순차적으로 실행하게 합니다. 이는 리액트 DOM을 강제로 일깨워주는 옵저버 패턴의 역할을 하게 됩니다. 이 과정 때문에 react DOM이 정확한 재 렌더링 지점을 파악할 수 있게됩니다. (최적화 문제에서 자유로워짐)

    • setState: 상태를 업데이트합니다. 다만 상태가 업데이트 됐음을 알려야 하므로 emitChange를 실행시켜 react DOM을 강제로 동기화시킵니다.

    • getState: 호출되는 순간 현재 상태 값을 읽습니다.

    좀 어렵지만 리액트에서 이런 규격을 가져야 useSyncExternalStore훅을 쓸 수 있게 해 줍니다. @@ -24,7 +24,7 @@ 빨간색은 개발자가 직접 건들지 못하지만 간접적으로 사용할 수 있는 영역 노란색은 React 18 엔진의 영역입니다.

    이외에 제공되는 다른 커스텀 훅들도 거의 비슷한 구조를 띄고 있습니다.

    // 추가로 구현할 수 있는 함수들

    export const useSetExternalState = <T>(store: DataObserver<T>) => {
    const { setState } = store;

    return setState;
    };

    export const useExternalValue = <T>(store: DataObserver<T>) => {
    const { subscribe, getState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return state;
    };

    // 바닐라JS 영역에서 자연스러운 읽기를 지원하는 함수

    export const getStoreSnapshot = <T>(store: DataObserver<T>) => {
    return store.getState();
    };

    더 다양한 예제는 여기에서 확인할 수 있고 작성한 라이브러리 코드 전문은 여기에서 확인할 수 있습니다.

    겨우 파일 수십 줄로 만든 초경량 상태관리 라이브러리였습니다

    - - + + \ No newline at end of file diff --git "a/tags/\354\240\204\354\227\255\354\203\201\355\203\234.html" "b/tags/\354\240\204\354\227\255\354\203\201\355\203\234.html" index cb175c69..249a1302 100644 --- "a/tags/\354\240\204\354\227\255\354\203\201\355\203\234.html" +++ "b/tags/\354\240\204\354\227\255\354\203\201\355\203\234.html" @@ -5,12 +5,12 @@ "전역상태" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "전역상태" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. +

    "전역상태" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 11분
    가브리엘

    저희 카페인 팀에서는 지도와 React를 결합을 해야했습니다.

    프로젝트 초기에는 Google Maps API를 React DOM이 아닌, 바닐라 JS의 영역에서 다루기를 희망하였고, 여러 테스트 결과 두 영역을 분리하는 것은 성공적이었습니다.

    React는 그저 부착 당할 DOM을 외부(Google Maps API)로 내어주는 기능에 불과하였고, 지도와 React가 서로 협력 해야할 때만 연락을 하는 구조를 취하고자 했습니다.

    예를 들면, React UI는 UI대로 동작하고, 지도는 지도 대로 동작하다가 어느 순간에만 서로가 서로를 조작할 수 있으면 됐습니다.

    이를 가능하게 하는 기술로 useSyncExternalStore를 선정하게 됐습니다. 이 훅에 대한 자세한 내용은 제 블로그공식문서에 나와있으므로 설명을 간략히 하자면 useSyncExternalStore는 React DOM 내부가 아닌 외부 저장소(JS)에서 React DOM을 조작할 수 있도록 하는 커스텀 훅입니다.

    no offset

    이 훅은 React 18에 출시되었으며, 외부 저장소와 React의 소통을 원활하게 돕습니다. 따라서 저희 서비스에서 활용하기 적절하다고 판단했습니다. 이 기능을 어떻게 하면 더 효율적인 방법으로 재사용할 수 있을지 고민하였고, 여러 추상화 단계를 거쳐 라이브러리 수준으로 제작할 수 있게 되었습니다.

    하지만 이후에 TanStack Query를 도입하는 과정에서 각종 기능이 React Component 내에서만 사용이 가능하도록 강제되었고, 따라서 더이상 지도 API를 바닐라JS 영역에서 다룰 수 없어 React DOM으로 이식 하게 됐습니다.

    no offset

    이미 만들어 둔 기능이 붕 떠버린 상황이었지만 어찌 됐든 클라이언트 상태에 지도 인스턴스를 넣어야 하는 상황이라 useSyncExternalStore를 프로젝트 끝까지 클라이언트 상태 관리 도구로써 사용하게 됐습니다.

    저희 팀에서 사용한 상태 관리 훅의 추상화 과정은 다음과 같습니다.

    use-external-state 구성 및 동작 원리

    Store는 상태 관리 인스턴스를 생성한다

    바깥에서 주어진 초기 상태 값은 StateManager라는 클래스에 전달됩니다.

    no offset

    export const store = <T>(initialState: T) => {
    const stateManager = new StateManager<T>(initialState);
    return stateManager;
    };

    초기 상태 값을 전달받은 store 함수는 StateManager라는 어떤 상태 관리 인스턴스를 생성합니다. 생성된 StateManager 인스턴스가 반환되어 store가 곧 초기 값을 가지는 StateManager가 됩니다.

    no offset

    예를 들어, 다음과 같은 코드가 있다고 할 때

    export const countStore = store<number>(0);

    countStore는 곧 0을 초기값으로 가지는 StateManager 인스턴스이기도 하게 됩니다.

    그러면 StateManager에 대해서 알아보겠습니다.

    StateManager는 react 바깥에 있는 어떤 저장소이다.

    (근데 이게 그냥 저장소는 아니고 좀 특별한 저장소다.)

    export type SetStateCallbackType<T> = (prevState: T) => T;

    export interface DataObserver<T> {
    setState: (param: SetStateCallbackType<T> | T) => void;
    getState: () => T;
    subscribe: (listener: () => void) => () => void;
    emitChange: () => void;
    }

    class StateManager<T> implements DataObserver<T> {
    public state: T;
    private listeners: Array<() => void> = [];

    constructor(initialState: T) {
    this.state = initialState;
    }

    setState = (param: SetStateCallbackType<T> | T) => {
    if (param instanceof Function) {
    const newState = param(this.state);
    this.state = newState;
    } else {
    this.state = param;
    }

    this.emitChange();
    };

    getState = () => {
    return this.state;
    };

    subscribe = (listener: () => void) => {
    this.listeners = [...this.listeners, listener];

    return () => {
    this.listeners = this.listeners.filter((l) => l !== listener);
    };
    };

    emitChange = () => {
    for (const listener of this.listeners) {
    listener();
    }
    };
    }

    export default StateManager;

    StateManager 클래스는 외부에서 받아온 초기값을 상태로 가집니다. setState, getState, subscribe, emitChange를 메서드로 가집니다. 여기서 작성된 코드들은 react에서 외부 저장소와 소통하기 위한 최소한의 규격입니다.

    • subscribe: 단일 콜백 인수를 사용하여 스토어에 구독하는 함수입니다. 스토어가 변경되면 제공된 콜백을 호출해야 합니다. 그러면 구성 요소가 다시 렌더링 됩니다. 구독 기능은 구독을 정리하는 기능을 반환해야 합니다. (구독에 관련된 데이터는 리스너 배열 필드에 넣어서 관리합니다.)

    • emitChange: 리스너 배열 필드에 담겨있는 모든 리스너를 실행합니다. 즉, 구독된 어떤 것을 순차적으로 실행하게 합니다. 이는 리액트 DOM을 강제로 일깨워주는 옵저버 패턴의 역할을 하게 됩니다. 이 과정 때문에 react DOM이 정확한 재 렌더링 지점을 파악할 수 있게됩니다. (최적화 문제에서 자유로워짐)

    • setState: 상태를 업데이트합니다. 다만 상태가 업데이트 됐음을 알려야 하므로 emitChange를 실행시켜 react DOM을 강제로 동기화시킵니다.

    • getState: 호출되는 순간 현재 상태 값을 읽습니다.

    좀 어렵지만 리액트에서 이런 규격을 가져야 useSyncExternalStore훅을 쓸 수 있게 해 줍니다. @@ -24,7 +24,7 @@ 빨간색은 개발자가 직접 건들지 못하지만 간접적으로 사용할 수 있는 영역 노란색은 React 18 엔진의 영역입니다.

    이외에 제공되는 다른 커스텀 훅들도 거의 비슷한 구조를 띄고 있습니다.

    // 추가로 구현할 수 있는 함수들

    export const useSetExternalState = <T>(store: DataObserver<T>) => {
    const { setState } = store;

    return setState;
    };

    export const useExternalValue = <T>(store: DataObserver<T>) => {
    const { subscribe, getState } = store;
    const state = useSyncExternalStore(subscribe, getState);

    return state;
    };

    // 바닐라JS 영역에서 자연스러운 읽기를 지원하는 함수

    export const getStoreSnapshot = <T>(store: DataObserver<T>) => {
    return store.getState();
    };

    더 다양한 예제는 여기에서 확인할 수 있고 작성한 라이브러리 코드 전문은 여기에서 확인할 수 있습니다.

    겨우 파일 수십 줄로 만든 초경량 상태관리 라이브러리였습니다

    - - + + \ No newline at end of file diff --git "a/tags/\354\271\264\355\216\230\354\235\270.html" "b/tags/\354\271\264\355\216\230\354\235\270.html" index b06c3795..9dc4d977 100644 --- "a/tags/\354\271\264\355\216\230\354\235\270.html" +++ "b/tags/\354\271\264\355\216\230\354\235\270.html" @@ -5,15 +5,15 @@ "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset +

    "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset no offset

    충전기를 꽂고 나서 알게된 것은 카페인 서비스에 나온 충전소 회사명과 방금 꽂은 충전기 회사명이 다르다는 것이었습니다.

    알고보니 음성 인식으로 네비에 검색한 충전소는 판교공영주차장이 아닌 판교역 환승 주차장이라 엉뚱한 곳으로 온 것이었습니다!!!

    다행인 점은 우리 서비스에서 제공하는 충전기 사용 여부 정보가 잘못된 것이 아니었다는 것이었습니다.

    그래서 애초에 가고자 했던 판교공영주자창에 대한 카페인 서비스의 정보가 실제와 동일한지 확인해보러 걸어서 이동했습니다. (바로 앞에 있었기 때문입니다.)

    no offset no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\271\264\355\216\230\354\235\270/page/2.html" "b/tags/\354\271\264\355\216\230\354\235\270/page/2.html" index 027555e9..9131acc5 100644 --- "a/tags/\354\271\264\355\216\230\354\235\270/page/2.html" +++ "b/tags/\354\271\264\355\216\230\354\235\270/page/2.html" @@ -5,12 +5,12 @@ "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. +

    "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. 저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. 진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! 진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. @@ -27,7 +27,7 @@ 차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? 이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\354\271\264\355\216\230\354\235\270/page/3.html" "b/tags/\354\271\264\355\216\230\354\235\270/page/3.html" index 2207c25d..6f2ead0c 100644 --- "a/tags/\354\271\264\355\216\230\354\235\270/page/3.html" +++ "b/tags/\354\271\264\355\216\230\354\235\270/page/3.html" @@ -5,12 +5,12 @@ "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset +

    "카페인" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 4분
    가브리엘

    저희 팀은 단순 방문자 100명을 모아야하는 미션을 받았습니다.

    목표 달성을 위해 약 2주 전에 실행 계획을 제출해야 했는데요

    100명을 모집하기 위해 다음과 같은 계획을 세웠습니다.


    no offset


    이 당시 저희 팀의 가장 큰 고민은, 전기차가 여전히 소수의 운전자에게만 보급되었다는 점이었습니다.

    특히, 전기차 보급 관련 통계 자료를 찾아보면 대부분의 차주들은 40~60대에 압도적으로 몰려있어 젊은 연령 층에서는 거의 구매를 하지 않고 있다는 사실을 알 수 있습니다.

    no offset

    위 자료는 2021년 7월 기준이지만, 최신 자료에서도 마찬가지로 젊은 연령층에서는 전기차를 보유한 사람을 찾기 어렵다고 나옵니다. 실제로 주변 또래의 운전자를 찾아보면 대부분 가솔린 모델을 타고 다니고 있습니다.

    따라서 저희는 홍보 대상을 주변에서 찾지 않고 불특정 다수의 사람들을 모집하기 위해 다음과 같은 방법을 사용하기로 했습니다.

    홍보 방법

    카페

    no offset no offset

    네이버에 있는 전기자동차 동호회 카페 중 가장 큰 곳에 글을 올려 방문자를 모집하기로 했습니다.

    카페에 글을 올리는 것은 무료이며, 카페에 가입한 사람들은 전기차에 관심이 있는 사람들이기 때문에 저희가 원하는 방문자를 모집하기에 적합하다고 생각했습니다.

    카카오톡 오픈채팅

    no offset no offset

    카카오톡 오픈채팅에는 수많은 대화방이 존재합니다.

    특정 주제로 만들어진 대화방이 대부분이기에 전기차를 주제로 한 오픈채팅 대화방을 찾는 것은 전혀 어렵지 않았습니다.

    안타깝게도 일부 단톡방에서 강퇴를 당했지만, 차주들과 채팅하면서 피드백을 받아볼 수 있었습니다.

    기타 홍보 수단

    기타 홍보 수단은 아직 사용하지 않았습니다.

    네이버 밴드, 보배드림은 사용하는 크루가 없어서 홍보를 하기 어려웠고, 구글 애드센스와 같은 도구는 비용이 발생하기에 아직은 이르다고 판단했습니다.

    Google Analytics 4 통계 집계 결과

    단순 방문자

    no offset no offset @@ -20,7 +20,7 @@ no offset no offset no offset

    집계 된 자료처럼 방문자들이 단순 방문만 한 것이 아니라, 수 많은 이벤트를 발생시키고 평균 참여 시간도 상당 부분 확보했음을 확인할 수 있습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\355\205\214\354\212\244\355\212\270.html" "b/tags/\355\205\214\354\212\244\355\212\270.html" index 13c8ec31..e0bcb167 100644 --- "a/tags/\355\205\214\354\212\244\355\212\270.html" +++ "b/tags/\355\205\214\354\212\244\355\212\270.html" @@ -5,12 +5,12 @@ "테스트" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "테스트" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. +

    "테스트" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 8분
    가브리엘

    안녕하세요, 카페인 팀에서는 테스트를 어떻게 하고 있을까요?

    일반적으로 소프트웨어 테스트란 백엔드에서 그 중요성이 강조되곤 하지만, 프론트엔드에서도 그에 못지 않게 중요한 부분을 차지하고 있습니다.

    수많은 툴 중에서 어떤 테스트 라이브러리를 사용하는지 소개하겠습니다.

    카페인 팀에서는 다음과 같은 프론트엔드 테스트 라이브러리를 사용하고 있을 수 있습니다.

    Jest

    Jest는 JavaScript의 테스트를 위한 대표적인 라이브러리입니다. 기본 설정이 간편하고, 빠르게 테스트를 실행할 때 굉장히 유용합니다. 함수를 mocking하여 의존성이 강한 함수를 제거하여 원하는 테스트를 쉽게 구성할 수 있다는 특징이 있습니다.

    React Testing Library

    React Testing Library는 리액트 애플리케이션의 UI를 테스트하기 위한 라이브러리입니다. React 컴포넌트를 호출하여, 사용자의 의도대로 조작할 수 있는 행위를 정의할 수 있습니다. @@ -22,7 +22,7 @@ 하지만 Storybook을 이용하면 특정 컴포넌트를 Storybook 위에 올려놓고 테스트를 할 수 있어 빠르게 작업이 가능합니다. 인터렉션이나 웹접근성을 확인해주는 플러그인도 존재하여 프론트엔드 개발에서 굉장히 중요한 역할로 부상했습니다.

    저희 팀은 이외에 Cypress를 사용하는 것도 고려하였으나, 지도와 결합된 애플리케이션을 테스트하기에 다소 어려움이 있어 위 라이브러리들을 개발에 활용했습니다.

    저희는 위 테스팅 라이브러리들을 원활히 활용하기 위해 테스트 자동화를 구축했습니다.

    Jest와 React Testing Library 테스트 자동화

    name: frontend-test

    on:
    pull_request:
    branches:
    - main
    - develop
    paths:
    - frontend/**
    - .github/**

    permissions:
    contents: read

    jobs:
    test:
    name: test-when-pull-request
    runs-on: ubuntu-latest
    environment: test
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Checkout PR
    uses: actions/checkout@v2
    - name: Install dependencies
    run: npm install
    - name: Test
    run: npm run test

    이벤트 트리거 설정

    pull_request 이벤트가 발생하였을 때, 해당 이벤트가 main 브랜치와 develop 브랜치에서만 동작합니다.

    변경 사항 경로 제한

    테스트를 실행할 때는 frontend 디렉토리와 .github 디렉토리 내의 파일들을 고려하도록 했습니다. 백엔드와의 환경 분리를 위해 이러한 접근 제한을 했습니다.

    권한 설정

    permissions은 읽기 권한만 설정되어 있어 코드나 파일을 변경을 방지합니다.

    작업(Job) 설정

    test라는 이름의 작업을 정의하였고, 이 작업에서는 Ubuntu 환경에서 테스트를 실행합니다. test라는 이름의 환경 변수를 사용합니다. 테스트는 (카페인 팀 레포지토리의) frontend 디렉토리에서 작업하도록 하였습니다.

    스텝(Step) 설정

    코드를 체크아웃하고, 의존성을 설치하며, 테스트를 실행하는 세 가지 단계로 구성되어 있습니다.

    이러한 설정을 통해 PR에 코드가 올라올 때 자동으로 프론트엔드 테스트가 실행됩니다.

    이러한 테스트 자동화 전략은 프론트엔드 애플리케이션을 안정적이게 개발하고 유지할 수 있도록 도와줍니다.

    Storybook의 빌드 자동화

    name: storybook-deploy

    on:
    pull_request:
    branches:
    - develop
    paths:
    - frontend/**
    - .github/**

    jobs:
    build:
    runs-on: ubuntu-22.04
    defaults:
    run:
    working-directory: ./frontend
    steps:
    - name: Setup Repository
    uses: actions/checkout@v3

    - name: Set up Node
    uses: actions/setup-node@v3
    with:
    node-version: 18.16.0

    - name: Install dependencies
    run: npm install

    - name: Cache node_modules
    id: cache
    uses: actions/cache@v3
    with:
    path: '**/node_modules'
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
    ${{ runner.os }}-node-

    - name: storybook build
    run: npm run build-storybook

    - name: Upload storybook build files to temp artifact
    uses: actions/upload-artifact@v3
    with:
    name: Storybook
    path: frontend/storybook-static
    deploy:
    needs: build
    runs-on: self-hosted
    steps:
    - name: Remove previous version app
    working-directory: .
    run: rm -rf dist

    - name: Download the built file to AWS
    uses: actions/download-artifact@v3
    with:
    name: Storybook
    path: frontend/dev/dist

    - name: Move folder
    working-directory: frontend/dev/
    run: |
    rm -rf /home/ubuntu/dist/*
    cp -r ./dist /home/ubuntu

    - name: comment PR
    uses: thollander/actions-comment-pull-request@v1
    env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    with:
    message: '🚀storybook: https://storybook.carffe.in/'

    비슷한 코드이지만, 매번 PR이 열릴 때 마다 스토리북이 자동으로 빌드 및 배포됩니다. 배포가 완료되면 배포된 URL을 알려 코드 리뷰할 때 참고할 수 있도록 돕습니다.

    이상 카페인 팀에서 사용하고 있는 테스팅 라이브러리와 테스트 자동화 방법을 알아봤습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\355\224\274\353\223\234\353\260\261.html" "b/tags/\355\224\274\353\223\234\353\260\261.html" index 28711a7b..41f3e841 100644 --- "a/tags/\355\224\274\353\223\234\353\260\261.html" +++ "b/tags/\355\224\274\353\223\234\353\260\261.html" @@ -5,15 +5,15 @@ "피드백" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "피드백" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset +

    "피드백" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 15분
    가브리엘
    센트

    안녕하세요? 센트와 가브리엘 입니다.

    저희 카페인 팀에서는 지난번 카페인 서비스 1차 체험 진행 이후 일부 기능 개선이 있었습니다. 기능 개선의 유용성을 판별하고자 카페인 서비스 2차 체험을 다녀왔습니다.

    저희 팀에서 1차 체험 이후 개선한 사항은 다음과 같습니다.

    1. 지역검색

    no offset

    • 이제는 검색어를 입력하는 경우, 전국 도시의 주소가 같이 제공됩니다.

    2. 충전소 마커를 확인할 수 있는 지도 영역 확장

    no offset

    (기존에는 위 사진보다 좁은 영역만을 호출하는 것이 허용되었다.)

    • 모바일에서 좀 더 넓은 영역을 호출하는 것을 허용했습니다. 원래는 디바이스 너비를 고려하지 않고 줌 레벨 기준으로 요청을 제한했으나, 이제는 사용자 디바이스에 보이는 지도의 영역 크기를 기반으로 요청을 제한하는 방식을 도입했습니다.
    • 기존에 사용하던 마커의 단점은, 그 크기가 너무 크다는 것이었습니다. 이로인해 더 넓은 영역을 보여주는 경우에 마커들이 겹치는 현상이 있었는데요, 이를 수정하기 위해 특정 영역 크기 이상에서는 마커를 좀 더 간소화 된 디자인으로 보이도록 개선하였습니다.
    • 마커 사이즈가 작아지면서 사용 가능한 충전기 개수가 더이상 들어갈 공간이 없어졌습니다. 따라서 마커 색상은 그대로 유지를 하되, 인포 윈도우에 현재 사용 가능한 충전기 개수를 보여주는 방식으로 디자인을 개선하였습니다.

    체험 규칙 설정

    개선한 기능이 실제로 유용한지 확인해보기 위해 저희는 카페인 서비스 2차 체험의 규칙을 다음과 같이 설정했습니다.

    저희는 좀 더 의미있는 경험을 하기위해 1차 체험 때 정했던 규칙에 더해서 다음과 같은 추가 규칙을 설정하였습니다.

    중간에 목표 지점이 많이 변경된다

    지난 카페인 서비스 1차 체험에서는 지역 검색이 없어 목표 지점을 찾는 것이 불편했습니다. 1차 체험 이후 지역 검색이 추가 되었으므로 이 기능이 얼마나 유용한지 경험해보고자 이 규칙을 설정했습니다.

    추가로 목표 지점 주변의 충전소를 확인할 때 새로 추가된 지도 영역 확장이 얼마나 유용한지도 경험해보고자 했습니다.

    체험 개요

    no offset

    1. 잠실역 출발
    2. 하남 만두집
    3. 다음 목적지 설정
    4. 판교

    체험 후기

    잠실역 출발

    no offset

    쏘카에서 EV6를 대여해서 가브리엘, 센트, 키아라가 잠실역에서 출발하였습니다. 저녁 퇴근 이후에 남이섬을 가려고 목적지를 설정하였으나 배가 너무 고파서 가는 길에 식사를 하자고 얘기가 나왔습니다.

    하남 만두집

    따라서 진정한 처음 목적지는 스타필드였으나, 가브리엘은 동네 주민이라 스타필드를 너무 잘 알고 있었습니다. 따라서 스타필드에 전기차 충전소가 어디에 있는지도 알고있으므로 목적지를 급하게 변경하기로 했습니다. 이 때 목적지 변경을 위해 주변 식당을 둘러보던 중에 괜찮은 식당을 발견해서 해당 식당을 기준으로 주변 충전소를 확인해보기로 했습니다.

    no offset

    식당 주변을 가기 위해 지역 검색을 처음으로 사용하여 식당과 가까운 지역을 탐색할 수 있었습니다.

    이 과정에서 식당에는 충전소가 없다는 사실을 알게되어, 근처 충전소를 찾아보기 위해서 지도를 축소했더니 1차 체험때와는 달리 더 넓은 영역을 보여줬습니다. 이전에는 마커 자체가 보이지 않아 답답하였으나, 이제는 더 넓은 영역을 조회할 수 있게 되어 편리했습니다.

    지난 체험 이후로 피드백을 자체 수집하여 개발한 기능들이 편하다는 것을 식당에 가는 길에 느낄 수 있었습니다.

    다음 목적지 설정

    no offset

    하남 만두집에서 식사를 하다가 알게된 사실은, 남이섬은 생각보다 너무 멀다는 것이었습니다. 식사를 마치고 남이섬에 가면, 충전도 제대로 못하고 돌아올 판이었습니다.

    식사를 하면서 다른 목적지를 알아봤는데, 가브리엘이 예전에 가봤던 곳 중에서 남양주의 물의 정원이 시간을 떼우기 좋다는 소리를 하였습니다. 따라서 물의 정원을 검색해보았습니다.

    놀랍게도 물의정원은 검색결과에 없었습니다!

    어쩔 수 없이 카카오 지도로 물의 정원 위치를 확인하여 주소를 알아내었고, 이 주소를 카페인 검색창에 넣었습니다. 저희는 이 과정에서 카페인 서비스는 업체명 조회가 안된다는 것이 치명적인 단점이라고 생각했습니다. 다만, 이 기능은 검색 할 때마다 많은 비용이 청구되어 현실적으로 지금 당장 기능을 넣는 것은 어렵다고 판단했습니다.

    결국 주소 검색을 통해 물의 정원과 가장 가까운 충전소를 알아내었습니다.

    그런데! 지도를 축소해서 확인해 보니 해당 충전소는 물의 정원과 생각보다 멀었습니다.

    no offset

    no offset

    무려 걸어서 30분이나 걸리는 충전소였습니다!

    전기차 충전을 위해 왕복 1시간이나 걸리는 거리를 걸을 수 없다고 생각하였습니다.

    물론 지난 체험에서 전기차가 생각보다 배터리가 오래간다는 사실을 알고 있었지만, 만약 저희처럼 충전이 급한 사용자라면 목적지를 포기할 수 밖에 없겠구나 라는 생각이 들었습니다.

    마지막으로 정한 목적지는, 의외의 결정이었습니다.

    굉장히 발전된 첨단 도시로 알려진 판교였습니다!

    사실은 앞으로 갈지도 모르는 판교를 미리 구경이나 해보자는게 이유였지만 비밀입니다(?)

    일단 판교역은 IT서비스 회사들이 많이 몰려있는 곳이었습니다.

    따라서 저희는 판교역을 카페인 검색창에 검색했습니다.

    no offset

    지도를 판교역으로 이동하여 외부인 개방인 충전소를 찾았는데, 판교공영주차장이 보여서 해당 충전소를 목적지로 잡고 출발했습니다.

    판교

    하남에서 판교를 가기 위해서는 서하남IC를 지나야했습니다.

    가는 길에 우리 서비스에 나오는 정보와 실제 정보가 일치하는지 점검차 서하남 간이 휴게소를 들려봤습니다.

    이 휴게소에도 충전소가 있다고 검색이 되었기 때문입니다!

    no offset

    검색 당시에는 2대의 충전기가 있다고 나왔고, 둘다 사용이 가능하다고 되어있었는데 실제로 확인해보니 일치하는 것을 확인했습니다.

    먼 길을 달려 판교에 도착하였습니다.

    주차장에 들어오기 전, 카페인 서비스를 확인해보니 판교공영주차장의 충전기 총 12기 중 10기가 사용가능한 상태였습니다.

    정작 들어와서 보니 입구부터 너무 많은 전기차들이 충전기를 사용중이었습니다.

    뭔가 이상하다 싶었지만, 아직 서버에 반영이 안된건가? 하면서 비어있는 충전기를 찾았습니다.

    no offset no offset

    충전기를 꽂고 나서 알게된 것은 카페인 서비스에 나온 충전소 회사명과 방금 꽂은 충전기 회사명이 다르다는 것이었습니다.

    알고보니 음성 인식으로 네비에 검색한 충전소는 판교공영주차장이 아닌 판교역 환승 주차장이라 엉뚱한 곳으로 온 것이었습니다!!!

    다행인 점은 우리 서비스에서 제공하는 충전기 사용 여부 정보가 잘못된 것이 아니었다는 것이었습니다.

    그래서 애초에 가고자 했던 판교공영주자창에 대한 카페인 서비스의 정보가 실제와 동일한지 확인해보러 걸어서 이동했습니다. (바로 앞에 있었기 때문입니다.)

    no offset no offset

    도착해보니 1층의 충전기들이 모두 공사중이었고, 서비스의 정보가 실제로도 불일치 하는 줄 알았습니다. 다시 상세 정보를 보니 3~6층에 충전기들에 대한 정보라는 것이 명시되어 있었고, 실제로도 이와 동일한 것을 확인했습니다.

    no offset

    저희는 시간이 너무 흘러 다시 잠실로 돌아와 차를 반납하고 체험을 마무리 했습니다.

    결론

    불편했던 점

    • 디바이스에 보여지는 지도 영역 확장시에 원하는 정보를 볼 수 없는 것이 불편했다.
      • 지도를 확대해주세요 모달이 뜨고, 원래 있던 충전소 마커가 전부 사라진다.
    • 현재 나의 위치를 알아볼 수 있는 수단이 없어 불편했다.
      • 현위치를 나타내는 핀 (1차 체험기에서도 언급했던 부분)
      • 내 위치를 상대적으로 알 수 있는 랜드마크의 부족
    • 특정 장소(매장명) 검색이 안돼서 카페인 서비스만으로 목적지를 찾아가기 불편했다.
      • 카카오맵 등을 활용해 특정 장소 검색을 진행해야 했다.

    다음 목표

    앞선 불편했던점을 개선하기 위해 다음과 같은 기능 개선을 추가로 진행할 예정입니다.

    • 디바이스에 보여지는 지도 영역 확장에 제한이 생기지 않게 충전소 마커 클러스터링을 우선적으로 도입한다.
    • 현재 나의 위치를 알아볼 수 있도록 지하철 역과 같은 랜드마커를 지웠던 것을 롤백한다.

    카페인 서비스만으로 목적지를 찾아갈 수 있도록 하기 위해서 특정 장소 검색을 추가하고 싶지만, 해당 기능을 구현하기 위해선 검색당 비용이 많이 청구되는 장소 검색 API를 추가해야 했기에 현실적으로 지금 당장 구현하기 어렵다고 판단했습니다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\355\224\274\353\223\234\353\260\261/page/2.html" "b/tags/\355\224\274\353\223\234\353\260\261/page/2.html" index fabf029e..3b63cfb2 100644 --- "a/tags/\355\224\274\353\223\234\353\260\261/page/2.html" +++ "b/tags/\355\224\274\353\223\234\353\260\261/page/2.html" @@ -5,12 +5,12 @@ "피드백" 태그로 연결된 2개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "피드백" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. +

    "피드백" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 19분
    가브리엘

    카페인 서비스를 개발하면서 가장 많이 받은 피드백 중 하나는 사용자 경험이 반드시 필요하다는 것이었습니다.

    아무래도 전기자동차를 보유한 팀원들이 아무도 없다보니 실제 사용자들이 겪는 어려움을 예상할 수 밖에 없었습니다.

    따라서 전기 자동차 운전자들을 찾아내어 수차례 인터뷰를 진행하였는데 실제 차주들이 원하는 기능이 무엇인지, 어떤 어려움을 겪는지를 확인하여 이를 바탕으로 서비스를 개발하였습니다.

    서비스를 처음 개발하였을 때 가장 많이 받았던 피드백은 앱 로드 속도가 너무 느리다는 것이었습니다.

    서비스 초기에는 로딩 속도가 너무 느려서 사용자들에게 서비스 사용을 권장하기 미안한 상태였습니다.

    따라서 실사용자를 모집하는 것 보다 서비스 안정화에 집중하는 것이 최우선이라는 목표 아래에 서비스를 개선하는 시간을 가졌고, 지금은 로딩 속도가 빠르다는 피드백을 받고 있습니다.

    지난 한 달간 서비스 안정화에 집중을 했다면, 이제는 사용자 경험을 개선하는데 집중을 해야할 때가 왔습니다.

    따라서 사용자 유치를 위해 전기차 동호회 카페, 카카오톡 오픈채팅, 자동차 커뮤니티 등을 돌면서 홍보를 진행하였습니다.

    다행히도 불특정 다수의 익명 사용자들을 손쉽게 구할 수 있었습니다.

    사용자들로부터 많은 피드백을 받았고, 해당 피드백을 저희 서비스에 최대한 반영하고자 노력했습니다.

    하지만, 대부분의 사용자들이 저희 서비스에 단순히 방문하여 피드백을 준 것일 뿐 실제로 사용하면서 피드백을 준 것 같지는 않다는 느낌을 받았습니다.

    사용자들이 앱에 머무른 시간을 GA4를 통해 확인 하였을 때 평균 3분 이상이라는 긴 시간을 머물러서 앱을 꼼꼼하게 사용했을 것이라고 기대는 하였으나, 사용 중에 피드백을 준다거나 사용 후에 피드백을 준 것이 맞는지 확신하기 어려웠습니다.

    그렇다고 주변에서 전기차주들을 찾자니 문제가 발생하였습니다. 일단 전기자동차 보급률이 굉장히 낮았으며, 40~50대에 편중되어 있어 저희에게 협조해 줄 차주분들을 주변에서 찾기 어려웠습니다. (대부분 생업으로 인해 바쁘십니다 ㅠㅠ)

    따라서 저희는 그냥 직접 서비스를 사용하면서 사용자 경험을 하기로 하였습니다.

    카페인 서비스에서는요

    저희 서비스에서 지원하는 핵심 기능은 다음과 같았습니다.

    • 전국 충전소 조회
      • 지도 탐색을 통한 검색
      • 검색창을 통한 검색
    • 충전소의 운영 정보 확인
    • 충전소 별 충전기 상태 조회 (실시간)
    • 충전소 및 충전기 고장 신고
    • 충전소 별 충전기 사용량 통계 조회
    • 충전소 별 리뷰 조회

    이외에도 많은 기능들이 있었지만, 위의 기능들이 사용자들이 가장 주력으로 사용할 것 같은 기능들이었습니다.

    계획을 세워보자

    전기자동차 렌트에 앞서 어디에 방문할 지 부터 정해야 했습니다. 저희는 몇 가지 원칙을 가지고 방문지를 정하기로 했습니다.

    1. 잘 모르는 지역일 것
    2. 도착지에 충전소가 반드시 있을 것
    3. 타사 앱을 전혀 사용하지 말 것

    일단, 제가 처음 정했던 목표는 경상남도 진주시였습니다. 진주시에서 복귀해야하는 팀원이 있던 점, 방문해 본 적이 없는 도시인 점, 장거리라서 충전기 사용이 필연적인 점 등 여러 가지 이유로 진주시를 방문하기로 결정했습니다.

    카페인 서비스를 킨 순간 눈앞이 캄캄해졌습니다.

    "진주시가 어디에 있지?"

    no offset

    다행히 진주시를 검색하니 주소 기반으로 검색이 되었습니다! 진주시를 검색한 것은 아니지만 간접적이라도 검색이 되는 것을 보고 안심했습니다. @@ -27,7 +27,7 @@ 차주 분과 인터뷰 하고 싶었지만, 차 내부에서 너무 바빠보이셔서 그럴 수 없었습니다.

    전기차 충전을 기다리면서 무엇을 할 수 있을까요? 이 분은 다행히도 업무를 보고 계셨지만, 다른 차주들은 무엇을 하고 보낼지 궁금해졌습니다.

    no offset

    휴게소에는 충전소가 하나 더 있었습니다.

    한 곳은 사용중이지만, 다른 한 곳은 사용할 수 있었습니다.

    저희는 이 충전소를 사용해보기로 했습니다.

    no offset

    사용할 수 있으니깐 들어가봐야지! 하고 도착한 순간 아차 싶었습니다.

    "아, 충전소가 외부인 사용 금지일 수 있었지?"

    저희는 분명히 서비스를 직접 개발했으니깐 다 알고 있던 사항이었지만, 전혀 생각치 못했습니다.

    서비스를 개발하는 내내 외부인 개방 충전소에 대한 중요성을 간파하였고, 이 기능을 넣었으면서도 사용하지 않고 충전소를 방문한 것이었습니다.

    바로 앞에 있어서 다행이었지만, 어찌됐든 이 충전소를 사용할 수 없었습니다.

    따라서 저희는 휴게소를 떠나는 내내 이 문제에 대해서 토론을 할 수 밖에 없었습니다.

    분명 우리가 만든 서비스인데 왜 놓쳤을까?

    맛있는 점심

    no offset

    파주닭국수 본점에서 맛있는 식사를 했습니다.

    비록 식당에는 전기차 충전소가 없었지만, 인근에 충전소가 있어 실험을 하나 해볼 수 있었습니다.

    인근 충전소와 식당의 거리가 가까워 보이는데, 과연 걸어갈 수 있을까?

    실제로 걷지는 않았습니다만 차 타면서 지나가면서 확인해본 결과 직접 걸을 수 없는 거리였습니다. (굉장히 걷기 싫은 수준의 먼 거리였습니다.)

    집에 있는 PHEV를 탈 기회가 많아 전기차 충전소를 자주 방문했던 저는 이런 점을 잘 알고 있었습니다.

    다행히 이 부분을 잘 알고 있었기에 저희는 이 부분을 서비스에 반영하였고, 모든 데이터를 포기하지 않았던 것이 옳은 선택이었다는 것을 확인하게 되었습니다.

    no offset

    식사가 끝나고 드디어 마장호수로 출발하게 되었습니다.

    마장호수 도착

    마장호수에 도착하자마자 충전소에 방문했습니다.

    no offset

    통계에서는 사용률이 적을 것이라고 하였는데 저희만 있었습니다.

    no offset no offset

    2기 중 1곳을 저희가 사용하였고, 마장호수를 돌았습니다.

    no offset

    약 50분 간 산책을 하고, 돌아와보니 충전기 다 되어있었습니다.

    사실 마장호수 까지 오는 내내 든 생각이었지만, 전기차의 배터리가 생각보다 오래 간다는 생각이 들었습니다.

    일부러 회생제동 기능도 끄고, 에어컨을 강하게 틀어서 배터리를 소진하려고 하였으나, 85km를 주행하는 동안 겨우 20%를 소모하였습니다.

    충전기를 꽂을 때 50%였으나, 호수를 한바퀴 돌고 오니 이미 100%가 되어있었습니다.

    여담이지만, 저희가 돌아왔을 때 옆 자리에는 전기 화물차가 있어 충전소가 가득 찼습니다.

    또, 앱에서도 충전기 사용 여부가 업데이트 되는 것을 확인했습니다.

    no offset

    배터리 성능에는 좋지 않고 가격도 비싸서 이를 자주 사용하는 것은 좋지 않겠지만, 급한 사람들은 급속 충전기를 사용하면 되겠구나 싶었습니다.

    따라서 급속과 완속은 더더욱 다른 개념으로 봐야겠다는 생각이 들었습니다.

    제가 그동안 경험했던 전기차 충전소는 완속 기준이었기에 신선한 경험이었습니다.

    선릉으로 돌아오다

    no offset

    선릉으로 돌아와서 차량을 반납하였습니다.

    저희는 이번 여정을 통해 카페인 서비스에서 어떤 점을 개선해야할지 좀 더 명확하게 알게되었습니다.

    1. 현재 서비스에서 제공하는 기능들로 충전소를 검색하는 것은 가능하며, 충전소의 위치를 정확하게 파악하는 것도 가능하다.
    2. 하지만 충전소가 없는 목적지는 검색할 수 없고, 현 위치가 어디인지 가늠하기가 어려워진다.
    3. 충전소를 사용할 수 있다고 표기되어 있더라도 외부인 개방이 아닐 수 있다. 정보가 정확히 제공됨에도 불구하고 이를 단번에 눈치채기 어렵다.
    4. 이러한 문제를 예상하여 외부인 개방 여부를 필터링 할 수 있는 기능을 제공하고 있음에도 불구하고 사용하지 않았다.
    5. 충전소의 통계 자료의 적중률은 높았으나, 좀 더 많은 충전소를 들려 확인해봐야 할 것 같았다.
    6. 전기자동차는 생각보다 오래가고 상품성이 있었다. 주행 능력도 충분하고, 인프라가 잘 되어있다. 이걸 왜 욕하지? 라는 생각이 들었다.
    7. 지도 확대 허용 범위가 너무 좁아서 사용하는데 불편한건 실제 상황에서 더 불편했다.

    이상 카페인 사용기였습니다.

    - - + + \ No newline at end of file diff --git "a/tags/\355\230\221\354\227\205.html" "b/tags/\355\230\221\354\227\205.html" index 2bf9d2dd..1d38428f 100644 --- "a/tags/\355\230\221\354\227\205.html" +++ "b/tags/\355\230\221\354\227\205.html" @@ -5,12 +5,12 @@ "협업" 태그로 연결된 1개 게시물개의 게시물이 있습니다. | CAR-FFEINE - - + +
    -

    "협업" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. +

    "협업" 태그로 연결된 1개 게시물개의 게시물이 있습니다.

    모든 태그 보기

    · 약 3분
    센트

    성능 개선을 위해 충전소 조회 API의 설계를 변경하였습니다. 기존에는 충전소 간단 정보와 마커 정보를 한 번에 받아오도록 설계되어 있었지만, 백엔드와 프론트엔드가 협업하여 간단 정보와 마커 정보를 각각 필요한 만큼만 조회하도록 명세를 수정하였습니다.

    이 과정에서 먼저, 백엔드와 프론트엔드는 함께 모여 기능 요구사항과 성능 개선 목표를 논의하였습니다. 그리고 충전소 간단 정보와 마커 정보를 각각 조회하는 API 엔드포인트를 새로 설계하였습니다.

    다음으로, 백엔드에서 간단 정보 조회를 위한 API를 구현하였습니다. @@ -21,7 +21,7 @@ 이 정보를 제외하고 마커를 띄우기 위해 필요한 최소한의 정보를 조회하도록 수정해 서버의 부하를 낮췄습니다.

    이러한 변경으로 인해 충전소 조회 API의 성능이 개선되었습니다. 필요한 정보만을 조회하므로써 데이터베이스의 부하를 줄이고 응답 시간을 단축할 수 있게 되었습니다. 또한, 프론트엔드에서는 필요한 정보만을 호출하여 불필요한 데이터를 받아오지 않아도 되므로 클라이언트 측의 성능도 향상되었습니다.

    - - + + \ No newline at end of file