diff --git a/content/posts/en/payment-system-redesign-sync-api.md b/content/posts/en/payment-system-redesign-sync-api.md index 92bf54b..1dee9b5 100644 --- a/content/posts/en/payment-system-redesign-sync-api.md +++ b/content/posts/en/payment-system-redesign-sync-api.md @@ -311,8 +311,8 @@ The key is to **filter cases that should not be refunded first**. catch (BaseException e) { // Cases that do not need refund: already cancelled, not paid, duplicate processing if (Set.of(CANCELLED_PAYMENT, PAYMENT_NOT_PAID, ALREADY_PROCESSING) - .contains(e.getPodoStatusCode())) { - log.warn("[{}] Skip refund. paymentId: {}", e.getPodoStatusCode(), paymentId); + .contains(e.getErrorCode())) { + log.warn("[{}] Skip refund. paymentId: {}", e.getErrorCode(), paymentId); return; } diff --git a/content/posts/ko/api-response-error-standardization.md b/content/posts/ko/api-response-error-standardization.md index 1d97752..181cd50 100644 --- a/content/posts/ko/api-response-error-standardization.md +++ b/content/posts/ko/api-response-error-standardization.md @@ -8,7 +8,7 @@ draft: false ## 배경 -서비스 초기부터 쌓여온 API 응답 코드에는 독특한 관례가 있었다. **모든 응답이 HTTP 200이었다.** 에러가 발생해도 200을 반환하고, 실제 에러 정보는 JSON body 안에 문자열로 담았다. +서비스 초기부터 쌓여온 API 응답 코드에는 독특한 관례가 있었다. **모든 응답이 HTTP 200이었다.** 에러가 발생해도 200을 반환하고 실제 에러 정보는 JSON body 안에 문자열로 담았다. ```java // 데이터를 못 찾아도 HTTP 200 @@ -18,7 +18,7 @@ return ResponseEntity.ok(CommonClass.ResponseResult("404", "데이터를 찾을 return ResponseEntity.ok(CommonClass.ResponseResult("200", result)); ``` -빠르게 개발하던 시기에 자연스럽게 자리 잡은 패턴이었다. 문제는 서비스가 성장하면서 드러났다. 모니터링 시스템을 붙이고, 프론트엔드 팀과 API 스펙을 맞추고, 장애 대응 프로세스를 정비하는 과정에서 이 구조가 곳곳에서 발목을 잡기 시작했다. +빠르게 개발하던 시기에 자연스럽게 자리 잡은 패턴이었다. 문제는 서비스가 성장하면서 드러났다. 모니터링 시스템을 붙이고 프론트엔드 팀과 API 스펙을 맞추고 장애 대응 프로세스를 정비하는 과정에서 이 구조가 곳곳에서 발목을 잡기 시작했다. --- @@ -69,7 +69,7 @@ return ResponseEntity.ok(resultMap); return ResponseEntity.ok(CommonClass.ok(result)); ``` -정해진 방식이 없으니 각자 편한 방법을 쓰게 되고, 코드베이스 전체로 보면 같은 일을 하는 코드가 여러 형태로 흩어져 있었다. +정해진 방식이 없으니 각자 편한 방법을 쓰게 되고 코드베이스 전체로 보면 같은 일을 하는 코드가 여러 형태로 흩어져 있었다. **3. 에러 코드의 의미가 모호하다** @@ -98,7 +98,7 @@ public ResponseEntity getItemList(@RequestParam(required = false) Integer lim } ``` -컨트롤러마다 `null` 체크, 에러 응답 생성, 성공 응답 포맷팅을 직접 하고 있었다. 비슷한 코드가 모든 컨트롤러에 복사되어 있었고, 응답 포맷을 바꾸려면 모든 컨트롤러를 찾아서 수정해야 했다. +컨트롤러마다 `null` 체크, 에러 응답 생성, 성공 응답 포맷팅을 직접 하고 있었다. 비슷한 코드가 모든 컨트롤러에 복사되어 있었고 응답 포맷을 바꾸려면 모든 컨트롤러를 찾아서 수정해야 했다. --- @@ -121,7 +121,7 @@ return ResponseEntity.ok(resultMap); return ResponseEntity.ok(CommonClass.ResponseResult("200", result)); ``` -반복 코드를 줄인 것은 의미가 있었지만, 근본적인 문제 - HTTP 상태코드를 제대로 쓰지 않는 것 - 는 그대로였다. 여전히 모든 응답이 HTTP 200이었고, `resultCd`는 문자열이었다. +반복 코드를 줄인 것은 의미가 있었지만 근본적인 문제 - HTTP 상태코드를 제대로 쓰지 않는 것 - 는 그대로였다. 여전히 모든 응답이 HTTP 200이었고 `resultCd`는 문자열이었다. ### Phase 2 - 인프라만 만들고 켜지 못했던 시간 @@ -140,7 +140,7 @@ public class GlobalExceptionHandler { } ``` -`@RestControllerAdvice`가 주석 처리되어 있었다. 전역 예외 핸들러를 켜면 기존에 컨트롤러마다 `try-catch`로 처리하던 에러 흐름이 깨질 수 있었고, 운영 중인 서비스에서 그 영향 범위를 확신할 수 없었다. 한동안 비활성 상태로 남았다. +`@RestControllerAdvice`가 주석 처리되어 있었다. 전역 예외 핸들러를 켜면 기존에 컨트롤러마다 `try-catch`로 처리하던 에러 흐름이 깨질 수 있었고 운영 중인 서비스에서 그 영향 범위를 확신할 수 없었다. 한동안 비활성 상태로 남았다. ### Phase 3 - 공통 에러 정의와 활성화 @@ -150,7 +150,7 @@ public class GlobalExceptionHandler { 2. **Slack 연동** - `BaseException` 발생 시 AOP로 Slack 알림을 보내는 기능이 추가됐다. 장애 감지의 첫 자동화. -3. **`ApiResponse` 통합** - 기존의 `ErrorResponse`(에러 전용 DTO)를 삭제하고, 성공과 에러 모두 동일한 `ApiResponse` 구조로 통합했다. **이 시점에서 현재의 응답 포맷이 확정됐다.** +3. **`ApiResponse` 통합** - 기존의 `ErrorResponse`(에러 전용 DTO)를 삭제하고 성공과 에러 모두 동일한 `ApiResponse` 구조로 통합했다. **이 시점에서 현재의 응답 포맷이 확정됐다.** ``` ErrorResponse (에러 전용) → 삭제 @@ -158,9 +158,9 @@ CommonClass (성공 전용) → 레거시, 점진적 교체 대상 → ApiResponse (성공 + 에러 통합) ``` -이후 `ApiErrorCode`는 2개에서 **120개 이상**으로 확장됐고, 새로 만드는 모든 API는 `ApiResponse` 기반으로 작성됐다. +이후 `ApiErrorCode`는 2개에서 **120개 이상**으로 확장됐고 새로 만드는 모든 API는 `ApiResponse` 기반으로 작성됐다. -참고로 2단계에서 추가했던 Slack 연동은 이후 제거했다. Grafana, Loki 같은 모니터링 시스템이 도입되면서 예외 발생 시 자동 알림이 인프라 레벨에서 처리됐고, 애플리케이션 코드에서 직접 Slack을 호출할 이유가 없어졌다. +참고로 2단계에서 추가했던 Slack 연동은 이후 제거했다. Grafana, Loki 같은 모니터링 시스템이 도입되면서 예외 발생 시 자동 알림이 인프라 레벨에서 처리됐고 애플리케이션 코드에서 직접 Slack을 호출할 이유가 없어졌다. --- @@ -188,7 +188,7 @@ flowchart TB end ``` -개발자가 할 일은 두 가지뿐이다. 성공이면 `ApiResponse.success()`로 감싸고, 실패면 예외를 던진다. 에러 응답 포맷은 전역 예외 핸들러가 알아서 만든다. +개발자가 할 일은 두 가지뿐이다. 성공이면 `ApiResponse.success()`로 감싸고 실패면 예외를 던진다. 에러 응답 포맷은 전역 예외 핸들러가 알아서 만든다. --- @@ -301,7 +301,7 @@ public enum ApiErrorCode { } ``` -`INVALID_PAYMENT`, `INTERNAL_SERVER_ERROR` 딱 2개로 시작한 enum이 120개 이상으로 확장됐다. 새로운 에러가 필요하면 여기에 한 줄만 추가하면 된다. 도메인별로 정리되어 있어서, "결제에서 어떤 에러가 발생할 수 있는지"를 enum 하나만 보면 파악할 수 있다. +`INVALID_PAYMENT`, `INTERNAL_SERVER_ERROR` 딱 2개로 시작한 enum이 120개 이상으로 확장됐다. 새로운 에러가 필요하면 여기에 한 줄만 추가하면 된다. 도메인별로 정리되어 있어서 "결제에서 어떤 에러가 발생할 수 있는지"를 enum 하나만 보면 파악할 수 있다. ### 커스텀 예외 - BaseException @@ -375,7 +375,7 @@ public class GlobalExceptionHandler { `BaseException`은 개발자가 의도적으로 던진 비즈니스 에러이므로, `ApiErrorCode`에 정의된 메시지가 **그대로 클라이언트에 전달**된다. "이미 등록된 카드입니다", "쿠폰을 찾을 수 없습니다" 같은 사용자 친화적 메시지다. -반면 `RuntimeException`은 예상하지 못한 에러 - `NullPointerException`, `ArrayIndexOutOfBoundsException` 같은 것이다. 이런 에러의 내부 메시지를 클라이언트에 노출하면 보안 문제가 될 수 있다. 초기에는 `e.getMessage()`를 그대로 클라이언트에 반환했는데, 내부 스택 정보나 DB 쿼리 같은 민감한 정보가 노출될 수 있었다. 이를 인지한 뒤 `createSafeMessage()`로 안전한 일반 메시지로 치환하도록 개선했다: +반면 `RuntimeException`은 예상하지 못한 에러 - `NullPointerException`, `ArrayIndexOutOfBoundsException` 같은 종류다. 이런 에러의 내부 메시지를 클라이언트에 노출하면 보안 문제가 될 수 있다. 초기에는 `e.getMessage()`를 그대로 클라이언트에 반환했는데, 내부 스택 정보나 DB 쿼리 같은 민감한 정보가 노출될 수 있었다. 이를 인지한 뒤 `createSafeMessage()`로 안전한 일반 메시지로 치환하도록 개선했다: ```java private String createSafeMessage(HttpStatus status) { @@ -436,7 +436,7 @@ private void log(HttpStatus status, HttpServletRequest request, Exception e) { `ApiErrorCode` 중 일부는 HTTP 2xx를 사용한다 - 예를 들어 `ALREADY_PROCESSING(HttpStatus.ACCEPTED)`은 "이미 진행 중"이라는 비즈니스 상태를 202로 표현한다. 이런 경우는 에러가 아니므로 INFO로 기록한다. -4xx는 클라이언트의 잘못이므로 WARN, 5xx는 서버의 잘못이므로 ERROR로 기록한다. Grafana Loki에서 `level=error`로 필터링하면 **서버 문제만 바로 볼 수 있다.** 4xx는 무시해도 되는 로그가 아니지만, 5xx와 섞여 있으면 진짜 중요한 에러를 놓치기 쉽다. +4xx는 클라이언트의 잘못이므로 WARN, 5xx는 서버의 잘못이므로 ERROR로 기록한다. Grafana Loki에서 `level=error`로 필터링하면 **서버 문제만 바로 볼 수 있다.** 4xx는 무시해도 되는 로그가 아니지만 5xx와 섞여 있으면 진짜 중요한 에러를 놓치기 쉽다. 5xx의 경우 `findApplicationErrorSource()`로 자사 패키지 내 에러 발생 위치를 최대 5단계까지 추적하여 로그에 남긴다. Spring 프레임워크의 수십 줄짜리 스택 트레이스를 뒤질 필요 없이, 애플리케이션 코드의 에러 지점만 바로 확인할 수 있다. @@ -448,7 +448,7 @@ OpenTelemetry Span에도 `error.message`를 기록하기 때문에, Tempo 같은 ### Before -가장 많은 코드가 변한 곳은 예약 관련 컨트롤러였다. 예약/변경/취소 API는 발생 가능한 에러 유형이 많아서, 컨트롤러 메서드 하나가 수십 줄에 달했다. +가장 많은 코드가 변한 곳은 예약 관련 컨트롤러였다. 예약/변경/취소 API는 발생 가능한 에러 유형이 많아서 컨트롤러 메서드 하나가 수십 줄에 달했다. ```java @PostMapping("/reserve") @@ -479,7 +479,7 @@ public ResponseEntity reserve(...) { } ``` -에러 유형별로 catch 블록이 늘어나고, 각 블록에서 응답 포맷을 직접 만들고, HTTP 상태코드도 직접 매핑했다. 새로운 에러 유형이 추가될 때마다 catch 블록이 하나 더 생겼다. +에러 유형별로 catch 블록이 늘어나고 각 블록에서 응답 포맷을 직접 만들고 HTTP 상태코드도 직접 매핑했다. 새로운 에러 유형이 추가될 때마다 catch 블록이 하나 더 생겼다. ### After @@ -493,7 +493,7 @@ public ResponseEntity> reserve(...) { 컨트롤러는 성공 케이스만 다룬다. Service에서 `throw new BaseException(ApiErrorCode.CUSTOMER_NOT_FOUND)`이 던져지면 `GlobalExceptionHandler`가 HTTP 404 응답을 만든다. `throw new BaseException(ApiErrorCode.ORDER_ALREADY_PROCESSING)`이면 HTTP 429가 나간다. -에러 처리 코드가 컨트롤러에서 완전히 사라졌다. 컨트롤러 메서드가 짧아지니 코드 리뷰도 빨라졌고, "에러 응답 포맷을 맞춰주세요"라는 리뷰 코멘트도 사라졌다. +에러 처리 코드가 컨트롤러에서 완전히 사라졌다. 컨트롤러 메서드가 짧아지니 코드 리뷰도 빨라졌고 "에러 응답 포맷을 맞춰주세요"라는 리뷰 코멘트도 사라졌다. --- @@ -515,9 +515,9 @@ public ResponseEntity> reserve(...) { ![신규 API의 트레이스 - HTTP 상태코드가 실제 상태를 반영한다](/images/grafana-after.png) -`ApiResponse` 기반으로 전환된 API의 트레이스다. `200`, `400`, `404`, `500`이 섞여 있고, 에러 요청이 어떤 상태코드로 처리됐는지 한눈에 보인다. 모니터링 도구에서 `status_code >= 400`으로 필터링하면 문제가 있는 요청만 바로 추려낼 수 있다. +`ApiResponse` 기반으로 전환된 API의 트레이스다. `200`, `400`, `404`, `500`이 섞여 있고 에러 요청이 어떤 상태코드로 처리됐는지 한눈에 보인다. 모니터링 도구에서 `status_code >= 400`으로 필터링하면 문제가 있는 요청만 바로 추려낼 수 있다. -가장 체감이 컸던 건 모니터링이다. 이전에는 장애가 나면 Grafana에서 아무 이상이 없어서, Slack 알림이나 CS 접수로 먼저 장애를 인지하는 경우가 있었다. 지금은 5xx가 급증하면 Grafana 알림이 먼저 온다. 대응 속도가 근본적으로 달라졌다. +가장 체감이 컸던 건 모니터링이다. 이전에는 장애가 나면 Grafana에서 아무 이상이 없어서 Slack 알림이나 CS 접수로 먼저 장애를 인지하는 경우가 있었다. 지금은 5xx가 급증하면 Grafana 알림이 먼저 온다. 대응 속도가 근본적으로 달라졌다. --- @@ -525,7 +525,7 @@ public ResponseEntity> reserve(...) { 돌이켜보면, "모든 응답을 200으로 보내는" 방식이 틀렸다기보다는 서비스 규모에 맞지 않게 된 것이었다. 팀이 2~3명일 때는 body 안의 `resultCd`만으로 충분히 소통이 됐다. 모니터링 도구 없이도 Slack 알림과 로그 검색으로 장애를 대응할 수 있었다. -하지만 팀이 커지고, 모니터링 체계를 갖추고, 프론트엔드와 백엔드가 API 스펙을 명확히 맞춰야 하는 단계에 오면 이야기가 달라진다. HTTP 표준을 따르는 것만으로도 모니터링 도구가 그대로 동작하고, 프론트엔드 라이브러리의 에러 핸들링이 자연스럽게 작동하고, 새로 합류한 개발자가 별도 설명 없이 API를 이해할 수 있다. +하지만 팀이 커지고 모니터링 체계를 갖추고 프론트엔드와 백엔드가 API 스펙을 명확히 맞춰야 하는 단계에 오면 이야기가 달라진다. HTTP 표준을 따르는 것만으로도 모니터링 도구가 그대로 동작하고 프론트엔드 라이브러리의 에러 핸들링이 자연스럽게 작동하고 새로 합류한 개발자가 별도 설명 없이 API를 이해할 수 있다. 전환은 아직 진행 중이다. 현재 코드베이스에는 세 가지 응답 패턴이 공존하고 있다: @@ -535,8 +535,8 @@ public ResponseEntity> reserve(...) { | `CommonClass.ResponseResult()` | 60회 | 9개 | 레거시 래퍼 | | `resultMap.put("resultCd", ...)` | 104회 | 19개 | 가장 오래된 패턴 | -한 번에 전부 바꿀 수는 없었다. 운영 중인 API의 응답 포맷이 바뀌면 프론트엔드도 함께 수정해야 하기 때문이다. 새로 만드는 API부터 `ApiResponse`를 적용하고, 기존 API도 기능 수정이나 리팩토링 시점에 점진적으로 전환하고 있다. 한 파일 안에 세 가지 패턴이 공존하는 컨트롤러도 아직 있다. +한 번에 전부 바꿀 수는 없었다. 운영 중인 API의 응답 포맷이 바뀌면 프론트엔드도 함께 수정해야 하기 때문이다. 새로 만드는 API부터 `ApiResponse`를 적용하고 기존 API도 기능 수정이나 리팩토링 시점에 점진적으로 전환하고 있다. 한 파일 안에 세 가지 패턴이 공존하는 컨트롤러도 아직 있다. 같은 맥락에서, 초기에 만들어졌던 개별 예외 클래스들(`ItemNotFoundException`, `InvalidTimeSlotException` 등)도 `BaseException(ApiErrorCode)`으로 점진적으로 대체하고 있다. 예외 하나를 마이그레이션할 때마다 해당 예외를 던지는 모든 코드와 잡는 모든 catch 블록을 확인해야 해서, 기능 작업과 병행하면서 진행 중이다. -방법이 하나면 선택의 여지가 없고, 선택의 여지가 없으면 커뮤니케이션 비용이 사라진다. 이번 작업에서 가장 크게 느낀 점이다. +방법이 하나면 선택의 여지가 없고 선택의 여지가 없으면 커뮤니케이션 비용이 사라진다. 이번 작업에서 가장 크게 느낀 점이다. diff --git a/content/posts/ko/auth-authorize-callback-flow.md b/content/posts/ko/auth-authorize-callback-flow.md index 879c9d7..e00e8a9 100644 --- a/content/posts/ko/auth-authorize-callback-flow.md +++ b/content/posts/ko/auth-authorize-callback-flow.md @@ -14,7 +14,7 @@ draft: false 핵심은 간단하다. -프론트엔드는 더 이상 로그인 상태를 직접 만들지 않는다. 보호된 페이지에 접근한 사용자를 백엔드 인증 시작점으로 보내고, 백엔드가 돌려준 콜백 결과를 확인한 뒤 받은 토큰을 브라우저 쿠키로 내려준다. +프론트엔드는 더 이상 로그인 상태를 직접 만들지 않는다. 보호된 페이지에 접근한 사용자를 백엔드 인증 시작점으로 보내고 백엔드가 돌려준 콜백 결과를 확인한 뒤 받은 토큰을 브라우저 쿠키로 내려준다. ```mermaid sequenceDiagram @@ -69,7 +69,7 @@ function redirectToAuthorize(request: Request) { 여기서 중요한 값은 `destination`이다. 사용자가 원래 가려던 경로를 잃지 않기 위해서다. -다만 이 값은 그대로 믿으면 안 된다. callback에서 다시 사용할 때 반드시 내부 경로인지 확인해야 한다. 외부 URL을 허용하면 open redirect 문제가 된다. +다만 이 값은 그대로 믿으면 안 된다. callback에서 다시 사용할 때 반드시 내부 경로인지 확인한다. 외부 URL을 허용하면 open redirect 문제가 된다. --- @@ -129,7 +129,7 @@ public ResponseEntity authorize( } ``` -서비스 계층에서는 provider를 해석하고, callback 이후 확인할 `state`를 만들고, 인증 제공자로 보낼 authorize URL을 구성한다. +서비스 계층에서는 provider를 해석하고 callback 이후 확인할 `state`를 만들고 인증 제공자로 보낼 authorize URL을 구성한다. ```java public AuthorizeContext authorize(String providerValue, Map queryParams) { @@ -150,7 +150,7 @@ public AuthorizeContext authorize(String providerValue, Map quer } ``` -여기서 `state`는 단순한 문자열이 아니다. provider, redirect URI, 원래 가려던 경로 같은 인증 흐름의 맥락을 이어주는 값이다. 그래서 보호 페이지에서 바로 로그인 화면으로 보내지 않고, 반드시 백엔드 authorize를 거치게 했다. +여기서 `state`는 단순한 문자열이 아니다. provider, redirect URI, 원래 가려던 경로 같은 인증 흐름의 맥락을 이어주는 값이다. 그래서 보호 페이지에서 바로 로그인 화면으로 보내지 않고 반드시 백엔드 authorize를 거치게 했다. --- @@ -158,7 +158,7 @@ public AuthorizeContext authorize(String providerValue, Map quer 백엔드가 callback으로 돌려준 값이 있다고 해서 바로 로그인 성공으로 처리하지 않았다. -callback은 "인증 결과를 확인해도 된다"는 신호다. 실제 구현에서는 백엔드가 인증 결과를 일회성 id로 저장하고, 프론트엔드는 그 id를 다시 백엔드에 전달해 서비스 토큰으로 교환했다. +callback은 "인증 결과를 확인해도 된다"는 신호다. 실제 구현에서는 백엔드가 인증 결과를 일회성 id로 저장하고 프론트엔드는 그 id를 다시 백엔드에 전달해 서비스 토큰으로 교환했다. ```ts export async function callbackHandler(request: Request) { @@ -193,15 +193,15 @@ export async function callbackHandler(request: Request) { - 백엔드 exchange가 실패하면 로그인 화면으로 보낸다. - destination이 외부 URL이면 기본 페이지로 보낸다. -인증 흐름에서는 실패를 대충 넘기면 안 된다. 실패를 성공처럼 처리하면 인증 우회가 되고, 실패마다 다른 화면으로 보내면 사용자는 흐름을 이해하기 어렵다. +인증 흐름에서는 실패를 대충 넘기면 안 된다. 실패를 성공처럼 처리하면 인증 우회가 되고 실패마다 다른 화면으로 보내면 사용자는 흐름을 이해하기 어렵다. --- ## 5. 백엔드는 callback 결과를 일회성 교환 값으로 바꾼다 -백엔드 callback은 인증 제공자가 넘겨준 authorization code와 state를 검증하고, 서비스에서 사용할 토큰을 발급한다. +백엔드 callback은 인증 제공자가 넘겨준 authorization code와 state를 검증하고 서비스에서 사용할 토큰을 발급한다. -다만 브라우저를 바로 백엔드 응답으로 로그인시키지 않았다. 프론트엔드 callback으로 다시 돌아가야 했기 때문이다. 그래서 백엔드는 인증 결과를 Redis 같은 저장소에 짧게 보관하고, 프론트엔드에는 일회성 id만 넘겼다. +다만 브라우저를 바로 백엔드 응답으로 로그인시키지 않았다. 프론트엔드 callback으로 다시 돌아가야 했기 때문이다. 그래서 백엔드는 인증 결과를 Redis 같은 저장소에 짧게 보관하고 프론트엔드에는 일회성 id만 넘겼다. ```java @GetMapping("/{provider}/callback") @@ -250,7 +250,7 @@ public TokenResponse exchange(@RequestParam String sid) { } ``` -이 구조에서 `sid`는 로그인 토큰이 아니다. callback 결과를 한 번만 꺼내기 위한 교환 키다. 만료 시간이 짧고, 한 번 소비하면 삭제된다. 그래서 콜백 URL에 실제 access token이나 refresh token을 직접 싣지 않아도 된다. +이 구조에서 `sid`는 로그인 토큰이 아니다. callback 결과를 한 번만 꺼내기 위한 교환 키다. 만료 시간이 짧고 한 번 소비하면 삭제된다. 그래서 콜백 URL에 실제 access token이나 refresh token을 직접 싣지 않아도 된다. --- @@ -310,6 +310,6 @@ authorize/callback 흐름을 프론트엔드에 붙일 때 중요한 것은 코 - 토큰 쿠키의 만료 시간은 백엔드 응답을 따른다. - destination은 내부 경로만 허용한다. -이렇게 나누면 프론트엔드는 로그인 흐름을 이어주지만, 로그인 상태를 직접 만들지는 않는다. +이렇게 나누면 프론트엔드는 로그인 흐름을 이어주지만 로그인 상태를 직접 만들지는 않는다. -다음 글에서는 기존 토큰과 새 백엔드 토큰을 함께 받아야 할 때의 문제를 다룬다. 어떤 순서로 검증하고, redirect chain을 어떻게 확인했는지 정리한다. +다음 글에서는 기존 토큰과 새 백엔드 토큰을 함께 받아야 할 때의 문제를 다룬다. 어떤 순서로 검증하고 redirect chain을 어떻게 확인했는지 정리한다. diff --git a/content/posts/ko/auth-token-verification-migration.md b/content/posts/ko/auth-token-verification-migration.md index 5ed1afc..e12923e 100644 --- a/content/posts/ko/auth-token-verification-migration.md +++ b/content/posts/ko/auth-token-verification-migration.md @@ -1,7 +1,7 @@ --- title: "로그인 책임 분리 (3) - 기존 토큰과 새 백엔드 토큰을 함께 검증하기" date: "2026-04-17" -description: "브라우저에는 토큰이 쿠키로 전달되지만, 기존 토큰과 새 백엔드 토큰의 검증 기준이 다를 때 순서와 실패 처리를 어떻게 나눴는지 정리합니다." +description: "브라우저에는 토큰이 쿠키로 전달되지만 기존 토큰과 새 백엔드 토큰의 검증 기준이 다를 때 순서와 실패 처리를 어떻게 나눴는지 정리합니다." tags: ["authentication", "oauth", "jwt", "testing", "migration", "java", "spring"] series: "로그인 책임 분리" seriesOrder: 3 @@ -12,7 +12,7 @@ draft: false 인증 책임을 백엔드로 옮긴다고 해서 기존 로그인 토큰이 한 번에 사라지지는 않는다. -이미 로그인한 사용자가 있고, 프론트엔드가 발급한 토큰이 브라우저 쿠키에 남아 있고, 일부 API는 여전히 그 쿠키에서 토큰을 읽는다. 동시에 새 로그인 흐름으로 들어온 사용자는 백엔드가 발급한 토큰을 같은 쿠키 이름으로 받게 된다. +이미 로그인한 사용자가 있고 프론트엔드가 발급한 토큰이 브라우저 쿠키에 남아 있고 일부 API는 여전히 그 쿠키에서 토큰을 읽는다. 동시에 새 로그인 흐름으로 들어온 사용자는 백엔드가 발급한 토큰을 같은 쿠키 이름으로 받게 된다. 즉 한동안은 두 종류의 토큰 발급 경로가 같이 존재한다. @@ -25,9 +25,9 @@ draft: false ## 기존 토큰은 서명만으로 검증되지 않았다 -기존 프론트엔드 토큰은 JWT였지만, JWT 서명만 맞으면 끝나는 구조가 아니었다. +기존 프론트엔드 토큰은 JWT였지만 JWT 서명만 맞으면 끝나는 구조가 아니었다. -토큰을 만들 때 Redis에도 저장했고, 검증할 때는 두 가지를 함께 확인했다. +토큰을 만들 때 Redis에도 저장했고 검증할 때는 두 가지를 함께 확인했다. ```ts async function verifyLegacyToken(token: string) { @@ -44,9 +44,9 @@ async function verifyLegacyToken(token: string) { } ``` -이 구조에서는 토큰 문자열 자체가 Redis key 역할을 한다. 로그아웃하면 Redis에서 토큰을 지우고, 이후 같은 JWT가 들어와도 유효하지 않다. +이 구조에서는 토큰 문자열 자체가 Redis key 역할을 한다. 로그아웃하면 Redis에서 토큰을 지우고 이후 같은 JWT가 들어와도 유효하지 않다. -따라서 쿠키에서 읽은 기존 토큰을 처리할 때도 토큰을 단순히 decode하거나 서명만 확인하면 안 된다. 기존 토큰은 기존 방식으로 검증해야 한다. +쿠키에서 읽은 기존 토큰을 처리할 때도 토큰을 단순히 decode하거나 서명만 확인하면 안 된다. 기존 토큰은 기존 방식으로 검증한다. --- @@ -91,7 +91,7 @@ async function verifyBackendToken(token: string, refreshToken?: string) { 프론트엔드가 백엔드 토큰을 직접 decode하지 않게 하려면, 백엔드에는 토큰 확인용 API가 필요하다. -여기서 중요한 것은 응답을 작게 유지하는 것이다. 프론트엔드는 토큰이 active인지, 어떤 사용자로 볼 수 있는지만 알면 된다. 서명 방식, key rotation, 저장 방식은 백엔드 내부 사정이다. +여기서 응답은 작게 유지한다. 프론트엔드는 토큰이 active인지, 어떤 사용자로 볼 수 있는지만 알면 된다. 서명 방식, key rotation, 저장 방식은 백엔드 내부 사정이다. ```java @PostMapping("/introspect") @@ -131,7 +131,7 @@ public Map introspect(@RequestBody TokenRequest request) { ## refresh token의 기준은 Redis에 둔다 -access token은 짧게 만료되고, refresh token은 다시 access token을 발급받기 위해 사용된다. 이때 refresh token을 단순히 JWT처럼 해석하지 않고 Redis에 저장된 상태를 확인했다. +access token은 짧게 만료되고 refresh token은 다시 access token을 발급받기 위해 사용된다. 이때 refresh token을 단순히 JWT처럼 해석하지 않고 Redis에 저장된 상태를 확인했다. ```java @PostMapping("/refresh") @@ -148,7 +148,7 @@ public TokenResponse refresh(@RequestBody RefreshRequest request) { } ``` -저장소 역할은 단순하다. 발급할 때 저장하고, 갱신할 때 조회하고, 로그아웃이나 만료 처리에서는 삭제한다. +저장소 역할은 단순하다. 발급할 때 저장하고 갱신할 때 조회하고 로그아웃이나 만료 처리에서는 삭제한다. ```java @Service @@ -179,13 +179,13 @@ class RefreshTokenStore { } ``` -브라우저에는 refresh token이 쿠키로 실려오지만, 유효성의 기준은 쿠키가 아니다. Redis에 남아 있어야 하고, 저장된 사용자와 연결되어 있어야 한다. 그래서 토큰을 지우는 것은 단순히 브라우저 쿠키를 지우는 일과 다르다. 서버 저장소에서도 더 이상 갱신할 수 없게 만들어야 한다. +브라우저에는 refresh token이 쿠키로 실려오지만 유효성의 기준은 쿠키가 아니다. Redis에 남아 있어야 하고 저장된 사용자와 연결되어 있어야 한다. 그래서 토큰을 지우는 것은 단순히 브라우저 쿠키를 지우는 일과 다르다. 서버 저장소에서도 더 이상 갱신할 수 없게 만들어야 한다. --- ## 어떤 토큰부터 확인할지 정한다 -두 종류의 토큰을 함께 받아야 하는 동안에는 검증 순서를 명확히 정해야 한다. +두 종류의 토큰을 함께 받는 동안에는 검증 순서를 명확히 정한다. ```ts async function verifyToken(accessToken: string, refreshToken?: string) { @@ -201,13 +201,13 @@ async function verifyToken(accessToken: string, refreshToken?: string) { 기존 토큰을 먼저 검증하면 기존 사용자의 세션을 유지하기 쉽다. 새 토큰은 기존 검증에서 실패한 뒤 백엔드 검증으로 넘어간다. -다만 이 방식은 오래 유지할 구조가 아니다. 기존 토큰을 새 인증 흐름과 함께 처리하기 위한 호환 레이어다. 새 인증 흐름이 충분히 안정화되면 기존 토큰 검증 경로는 제거해야 한다. 그렇지 않으면 프론트엔드는 계속 두 인증 체계를 알고 있어야 한다. +다만 이 방식은 오래 유지할 구조가 아니다. 기존 토큰을 새 인증 흐름과 함께 처리하기 위한 호환 레이어다. 새 인증 흐름이 충분히 안정화되면 기존 토큰 검증 경로는 제거한다. 그렇지 않으면 프론트엔드는 계속 두 인증 체계를 알고 있어야 한다. --- ## 페이지 흐름에서는 실패하면 토큰 쿠키를 지우고 인증 시작점으로 보낸다 -토큰 검증이 실패했을 때 가장 중요한 것은 애매한 상태를 남기지 않는 것이다. API 검증 계층에서는 토큰 쿠키를 지우고 오류를 반환하고, 페이지 미들웨어는 그 실패 응답을 보고 인증 시작점으로 보낸다. +토큰 검증이 실패했을 때 가장 중요한 것은 애매한 상태를 남기지 않는 일이다. API 검증 계층에서는 토큰 쿠키를 지우고 오류를 반환하고 페이지 미들웨어는 그 실패 응답을 보고 인증 시작점으로 보낸다. ```ts async function authMiddleware(request: Request) { @@ -232,17 +232,17 @@ async function authMiddleware(request: Request) { 검증이 실패했는데 토큰 쿠키를 그대로 두면 다음 요청에서도 같은 실패가 반복된다. 사용자는 로그인 페이지와 보호 페이지 사이를 계속 오가거나, 화면은 열렸는데 API만 401이 나는 상태를 만날 수 있다. -그래서 실패 시에는 토큰 쿠키를 지우고, 같은 인증 시작점으로 보냈다. +그래서 실패 시에는 토큰 쿠키를 지우고 같은 인증 시작점으로 보냈다. --- -## redirect chain을 검증해야 한다 +## redirect chain 검증 이 작업에서 가장 중요한 검증은 단위 테스트보다 redirect chain 확인이었다. 코드상으로는 인증 시작점으로 redirect한다고 작성했더라도, 실제 응답이 예전 로그인 페이지나 다른 경로로 떨어질 수 있다. 인증 흐름은 브라우저가 여러 응답을 따라가며 완성되기 때문이다. -확인해야 할 흐름은 단순하다. +확인할 흐름은 단순하다. ```text 보호 페이지 @@ -254,13 +254,13 @@ async function authMiddleware(request: Request) { -> 원래 페이지 ``` -로컬에서는 `curl -I`로 첫 redirect 위치를 확인하고, 필요하면 브라우저에서 실제 쿠키와 Location 헤더를 함께 봤다. +로컬에서는 `curl -I`로 첫 redirect 위치를 확인하고 필요하면 브라우저에서 실제 쿠키와 Location 헤더를 함께 봤다. ```bash curl -I http://localhost:3000/protected ``` -기대하는 것은 보호 페이지가 로그인 화면으로 바로 떨어지는 것이 아니다. 프론트엔드 인증 시작 라우트로 이동하고, 그 라우트가 다시 백엔드 authorize로 이동해야 한다. +기대하는 것은 보호 페이지가 로그인 화면으로 바로 떨어지는 상태가 아니다. 프론트엔드 인증 시작 라우트로 이동하고 그 라우트가 다시 백엔드 authorize로 이어져야 한다. --- @@ -275,7 +275,7 @@ curl -I http://localhost:3000/protected | 기존 토큰 검증 실패, 백엔드 access token 유효 | 백엔드 검증 성공 | | 백엔드 access token 비활성, refresh token 유효 | 백엔드 refresh 후 새 토큰 저장 | -이 테스트의 목적은 "로그인이 된다"를 확인하는 것이 아니다. 어떤 검증 경로를 탔는지 확인하는 것이다. +이 테스트의 목적은 "로그인이 된다"를 확인하는 데 있지 않다. 어떤 검증 경로를 탔는지 확인하는 데 있다. 기존 검증이 성공했다면 백엔드 introspection이 호출되면 안 된다. 백엔드 access token이 유효하다면 refresh API가 호출되면 안 된다. 이런 호출 순서가 맞아야 두 검증 경로를 함께 둔 코드를 신뢰할 수 있다. @@ -285,8 +285,8 @@ curl -I http://localhost:3000/protected 어려운 부분은 새 흐름을 만드는 것보다 기존 토큰과 새 백엔드 토큰을 한동안 함께 받아야 하는 쪽이었다. -기존 토큰은 기존 방식으로 검증해야 하고, 백엔드 토큰은 백엔드에 물어봐야 한다. 실패하면 토큰 쿠키를 정리하고, 모든 미인증 진입은 같은 인증 시작점으로 보내야 한다. +기존 토큰은 기존 방식으로 검증해야 하고 백엔드 토큰은 백엔드에 물어봐야 한다. 실패하면 토큰 쿠키를 정리하고 모든 미인증 진입은 같은 인증 시작점으로 보내야 한다. -그리고 마지막에는 반드시 redirect chain을 확인해야 한다. +마지막에는 반드시 redirect chain을 확인한다. 인증은 함수 하나의 결과가 아니라 여러 HTTP 응답이 이어진 흐름이다. 테스트가 통과해도 실제 브라우저가 예전 로그인 경로를 타고 있으면 전환은 끝난 게 아니다. diff --git a/content/posts/ko/aws-to-gcp-migration.md b/content/posts/ko/aws-to-gcp-migration.md index 48077a5..1f0ed87 100644 --- a/content/posts/ko/aws-to-gcp-migration.md +++ b/content/posts/ko/aws-to-gcp-migration.md @@ -18,15 +18,15 @@ draft: false 처음에는 단순히 이렇게 생각하기 쉽다. -> EKS를 GKE로 바꾸고, ECR을 Artifact Registry로 바꾸고, AWS Secrets Manager를 GCP Secret Manager로 바꾸면 되는 거 아닌가? +> EKS를 GKE로 바꾸고 ECR을 Artifact Registry로 바꾸고 AWS Secrets Manager를 GCP Secret Manager로 바꾸면 되는 거 아닌가? -실제로는 그렇게 단순하지 않았다. 이름이 비슷한 관리형 서비스는 많지만, 권한 모델, 네트워크 연결 방식, 배포 검증 기준, 장애를 확인하는 방법이 전부 달랐다. +실제로는 그렇게 단순하지 않았다. 이름이 비슷한 관리형 서비스는 많지만 권한 모델, 네트워크 연결 방식, 배포 검증 기준, 장애를 확인하는 방법이 전부 달랐다. 현재는 당시 리소스가 대부분 정리되어 콘솔 화면이나 live 상태를 그대로 남기기 어렵다. 그래서 이 글은 화면 캡처 대신 남아 있는 Git 이력, 배포 이력, rollback 기록을 기준으로 이관 흐름을 복기한다. 다만 공개 글인 만큼 내부 repository 이름, branch 이름, commit hash, 서비스 고유명은 제외하고 운영 관점에서 의미 있는 패턴만 정리한다. ## 작성 기준 -이관 작업은 한 번의 큰 PR로 끝나지 않았다. backend, web, notification, cluster manifest, E2E, 배포 자동화가 각자 다른 속도로 움직였고, 작성자도 여러 명이었다. 그래서 특정 작성자나 특정 repo만 기준으로 보면 실제 흐름을 놓치기 쉽다. +이관 작업은 한 번의 큰 PR로 끝나지 않았다. backend, web, notification, cluster manifest, E2E, 배포 자동화가 각자 다른 속도로 움직였고 작성자도 여러 명이었다. 그래서 특정 작성자나 특정 repo만 기준으로 보면 실제 흐름을 놓치기 쉽다. 확인한 증거는 크게 네 종류였다. @@ -94,7 +94,7 @@ flowchart TB Argo --> GKE ``` -이 구조에서 CI는 이미지를 만들고 manifest를 갱신한다. 실제 클러스터 상태는 ArgoCD가 Git 상태로 수렴시킨다. 그래서 이관 후에는 "CI가 성공했는가"보다 "manifest가 바뀌었고, ArgoCD가 어떤 image tag를 GKE에 적용했는가"가 더 중요해졌다. +이 구조에서 CI는 이미지를 만들고 manifest를 갱신한다. 실제 클러스터 상태는 ArgoCD가 Git 상태로 수렴시킨다. 그래서 이관 후에는 "CI가 성공했는가"보다 "manifest가 바뀌었고 ArgoCD가 어떤 image tag를 GKE에 적용했는가"가 더 중요해졌다. ## 2단계 - backend 코드에서 AWS 의존 줄이기 @@ -110,7 +110,7 @@ backend에서는 먼저 AWS 의존을 줄이는 작업이 들어갔다. 이때 | storage URL을 GCS 기준으로 이동 | 정적/업로드 리소스의 공개 URL 기준을 GCP 쪽으로 이동 | | GCP 배포 workflow 추가 | application repo에서 GCP image build/deploy 흐름을 직접 실행할 수 있게 함 | -여기서 주의할 점은 S3가 바로 사라지지 않았다는 것이다. 초기 전환에서는 SQS, datasource, secret, 일부 storage URL이 GCP 기준으로 이동했지만, S3/CloudFront는 bridge 기간 동안 일부 남아 있었다. +여기서 주의할 점은 S3가 바로 사라지지 않았다는 사실이다. 초기 전환에서는 SQS, datasource, secret, 일부 storage URL이 GCP 기준으로 이동했지만 S3/CloudFront는 bridge 기간 동안 일부 남아 있었다. 이건 실패라기보다 현실적인 전환 방식이었다. asset, language pack, external integration처럼 배포 runtime과 독립적인 경계는 한 번에 옮기기보다 별도 검증과 rollback 가능성을 남겨두는 편이 안전했다. @@ -137,7 +137,7 @@ env: config bundle 경유 후 Secret Manager로 재정리 static/language assets: 한동안 S3/CloudFront 유지 ``` -즉 "web도 GCP로 옮겼다"는 말 안에는 여러 단계가 들어 있다. 서버 runtime은 GKE로 옮겼지만, 정적 파일이나 일부 언어 리소스는 bridge 기간 동안 AWS 경로를 유지했다. 나중에 runtime env가 Secret Manager 기준으로 정리되고, E2E GCP 인증도 정적 service account key 대신 WIF로 넘어가면서 운영 기준이 더 깔끔해졌다. +즉 "web도 GCP로 옮겼다"는 말 안에는 여러 단계가 들어 있다. 서버 runtime은 GKE로 옮겼지만 정적 파일이나 일부 언어 리소스는 bridge 기간 동안 AWS 경로를 유지했다. 나중에 runtime env가 Secret Manager 기준으로 정리되고 E2E GCP 인증도 정적 service account key 대신 WIF로 넘어가면서 운영 기준이 더 깔끔해졌다. ## 4단계 - 실제 cutover 흔적은 manifest에 남는다 @@ -152,7 +152,7 @@ GCP production 배포가 시작된 뒤에는 image tag 자동 업데이트 이 - rollback이 실제로 발생했다. - tag naming이 이관 초기의 임시 이름에서 일반 production 이름으로 정리됐다. -이 과정을 보면서 다시 확인한 것은 명확했다. 배포 성공은 image push 성공이 아니다. manifest가 업데이트되고, ArgoCD가 sync하고, GKE workload가 새 image를 pull하고, readiness와 실제 의존성 호출까지 통과해야 서비스 성공이다. +이 과정을 보면서 기준이 더 명확해졌다. 배포 성공은 image push 성공이 아니다. manifest가 업데이트되고 ArgoCD가 sync하고 GKE workload가 새 image를 pull하고 readiness와 실제 의존성 호출까지 통과해야 서비스 성공이다. ## 5단계 - notification은 별도 속도로 움직였다 @@ -168,9 +168,9 @@ queue 쪽은 바로 AWS를 지우는 방식이 아니라 provider-neutral adapte - legacy S3 적재 제거 - Slack token 같은 운영 secret도 Secret Manager 외부 주입으로 이동 -여기서 중요한 것은 "코드상 지원"과 "운영 cutover"를 분리하는 것이다. 어떤 저장소나 queue의 GCP 구현이 codebase에 들어갔다고 해서 곧바로 production source of truth가 바뀐 것은 아니다. +여기서 중요한 것은 "코드상 지원"과 "운영 cutover"를 분리하는 일이다. 어떤 저장소나 queue의 GCP 구현이 codebase에 들어갔다고 해서 곧바로 production source of truth가 바뀐 것은 아니다. -특히 NoSQL 저장소 이관은 더 조심해야 한다. DynamoDB와 Firestore는 둘 다 NoSQL이라고 묶을 수 있지만 access pattern, index, transaction, TTL 모델이 다르다. 그래서 Firestore 전환처럼 데이터 source of truth가 바뀌는 작업은 별도 글감으로 분리하는 편이 맞다. +특히 NoSQL 저장소 이관은 더 조심한다. DynamoDB와 Firestore는 둘 다 NoSQL이라고 묶을 수 있지만 access pattern, index, transaction, TTL 모델이 다르다. 그래서 Firestore 전환처럼 데이터 source of truth가 바뀌는 작업은 별도 글감으로 분리하는 편이 맞다. ## 6단계 - Valkey와 Workload Identity @@ -186,7 +186,7 @@ Redis 계열 저장소는 단순 cache처럼 보이지만 실제로는 인증이 그래서 Memorystore/Valkey로 옮길 때는 단순히 host만 바꾸면 끝나지 않았다. TLS, IAM auth, CA mount, token refresh, connection pool, 장애 시 fail-fast 여부까지 함께 봐야 했다. -GKE에서는 Workload Identity를 통해 Pod가 Google Service Account 권한을 사용한다. 이 모델이 안정적으로 동작해야 Secret Manager, Cloud SQL, Pub/Sub, Valkey 같은 GCP 리소스 접근도 안전해진다. +GKE에서는 Workload Identity로 Pod가 Google Service Account 권한을 사용한다. 이 모델이 안정적으로 동작해야 Secret Manager, Cloud SQL, Pub/Sub, Valkey 같은 GCP 리소스 접근도 안전해진다. ```mermaid flowchart LR @@ -204,7 +204,7 @@ flowchart LR GCP로 옮기면서 보안과 관측성도 별도 작업으로 남았다. -OTel Collector sidecar는 여러 workload로 확산됐다. logs, traces, metrics를 같은 방식으로 수집하려면 애플리케이션마다 collector 설정을 맞춰야 했고, 환경별로 exporter와 label도 정리해야 했다. +OTel Collector sidecar는 여러 workload로 확산됐다. logs, traces, metrics를 같은 방식으로 수집하려면 애플리케이션마다 collector 설정을 맞춰야 했고 환경별로 exporter와 label도 정리해야 했다. Cloud Armor WAF도 이후 단계에서 붙었다. @@ -219,7 +219,7 @@ Cloud Armor WAF도 이후 단계에서 붙었다. 초기 GCP 배포는 GitHub Actions 중심이었다. -application repo마다 GCP 배포 workflow가 있었고, GitHub Actions에서 GCP 인증을 받아 Artifact Registry, GKE, manifest repo를 건드리는 구조였다. 이 방식은 빠르게 시작하기에는 좋지만, 시간이 지나면 배포 주체와 권한 경계가 복잡해진다. +application repo마다 GCP 배포 workflow가 있었고 GitHub Actions에서 GCP 인증을 받아 Artifact Registry, GKE, manifest repo를 건드리는 구조였다. 이 방식은 빠르게 시작하기에는 좋지만 시간이 지나면 배포 주체와 권한 경계가 복잡해진다. 후반부에는 배포 경로가 Cloud Build 쪽으로 정리됐다. @@ -229,11 +229,11 @@ application repo마다 GCP 배포 workflow가 있었고, GitHub Actions에서 GC | web | Cloud Build deploy config 추가 후 기존 GCP deploy workflow 제거 | | notification | Cloud Build deploy config 추가 후 기존 GCP deploy workflow 제거 | -이 단계는 "GCP에서 배포된다"를 넘어서 "GCP 운영에 맞는 배포 주체로 정리한다"에 가깝다. +이 단계는 "GCP에서 배포된다"를 넘어서 "GCP 운영에 맞는 배포 주체로 정리한다"는 의미에 가깝다. -처음에는 GitHub Actions에서 GCP 인증을 받아 배포했고, 나중에는 Cloud Build가 배포 실행 주체가 됐다. E2E도 정적 service account key 대신 WIF로 전환하는 작업이 이어졌다. +처음에는 GitHub Actions에서 GCP 인증을 받아 배포했고 나중에는 Cloud Build가 배포 실행 주체가 됐다. E2E도 정적 service account key 대신 WIF로 전환하는 작업이 이어졌다. -정적 credential을 줄이고, workload identity 또는 cloud-native deploy actor로 옮기는 것이 후반부의 중요한 정리 작업이었다. +정적 credential을 줄이고 workload identity 또는 cloud-native deploy actor로 옮기는 것이 후반부의 중요한 정리 작업이었다. ## 실제로 바뀐 것들 @@ -278,11 +278,11 @@ Cloud SQL, Pub/Sub, Valkey 연결 실패가 로그와 metric에 보이는가? ### 1. GCP로 옮겼지만 S3는 한동안 남아 있었다 -commit 제목만 보면 `S3 -> GCS`가 보이지만, 전체 흐름을 보면 S3가 바로 사라진 것은 아니다. +commit 제목만 보면 `S3 -> GCS`가 보이지만 전체 흐름을 보면 S3가 바로 사라진 것은 아니다. -runtime은 GCP로 넘어갔지만, 정적 파일이나 언어팩 같은 일부 리소스는 bridge 기간 동안 S3/CloudFront를 유지했다. GKE에서 AWS 리소스를 읽기 위한 keyless 접근도 별도로 다뤄야 했다. +runtime은 GCP로 넘어갔지만 정적 파일이나 언어팩 같은 일부 리소스는 bridge 기간 동안 S3/CloudFront를 유지했다. GKE에서 AWS 리소스를 읽기 위한 keyless 접근도 별도로 다뤄야 했다. -따라서 이 글에서는 "AWS 의존을 모두 제거했다"고 쓰지 않는다. 정확한 표현은 "runtime과 주요 운영 경계는 GCP로 이동했지만, 일부 asset/storage 경계는 bridge 기간 동안 AWS를 유지했다"에 가깝다. +그래서 이 글에서는 "AWS 의존을 모두 제거했다"고 쓰지 않는다. 정확한 표현은 "runtime과 주요 운영 경계는 GCP로 이동했지만 일부 asset/storage 경계는 bridge 기간 동안 AWS를 유지했다"에 가깝다. ### 2. Secret Manager는 값 저장소가 아니라 runtime 계약이었다 @@ -290,7 +290,7 @@ Secret Manager 전환에서 중요한 것은 secret 값 자체가 아니다. 핵심은 어느 runtime이 어떤 identity로 어떤 project의 secret을 읽는가다. 실제로 project id, profile, env name, secret payload 형식 때문에 여러 보정이 필요했다. -운영에서는 Workload Identity/WIF로 secret을 읽는 것이 맞지만, 로컬이나 E2E에서는 다른 인증 경로가 섞이기 쉽다. 그래서 이관 중에는 "어디서 어떤 project의 secret을 읽는가"를 계속 확인해야 했다. +운영에서는 Workload Identity/WIF로 secret을 읽는 것이 맞지만 로컬이나 E2E에서는 다른 인증 경로가 섞이기 쉽다. 그래서 이관 중에는 "어디서 어떤 project의 secret을 읽는가"를 계속 확인해야 했다. ### 3. 배포 성공과 서비스 성공은 다르다 @@ -309,7 +309,7 @@ Secret Manager 전환에서 중요한 것은 secret 값 자체가 아니다. GKE에서 replica를 늘리거나 production nodepool로 옮기면서 기존에는 잘 보이지 않던 문제가 드러난다. -예를 들어 OAuth authorization이나 session 상태가 process memory에 가까운 곳에 있으면 multi-pod 환경에서 `invalid_grant`나 session mismatch가 날 수 있다. 이런 상태는 Redis 같은 공유 저장소로 옮기거나, sticky하지 않은 runtime에서도 안전하게 동작하도록 다시 설계해야 한다. +예를 들어 OAuth authorization이나 session 상태가 process memory에 가까운 곳에 있으면 multi-pod 환경에서 `invalid_grant`나 session mismatch가 날 수 있다. 이런 상태는 Redis 같은 공유 저장소로 옮기거나, sticky하지 않은 runtime에서도 안전하게 동작하도록 다시 설계한다. 이런 작업까지 포함해야 진짜 이관 회고가 된다. 클라우드만 바뀐 것이 아니라 runtime topology가 바뀌었기 때문이다. @@ -337,11 +337,11 @@ S3/CloudFront, 일부 static sync, keyless AWS access 흔적이 남아 있었다 ## 정리 -이번 이관을 한 문장으로 정리하면 이렇다. +이번 이관은 이렇게 정리할 수 있다. > AWS 리소스를 GCP 리소스로 치환한 작업이 아니라, 운영 상태의 기준을 Git, GKE, Workload Identity, Secret Manager, Cloud Build 쪽으로 옮긴 작업이었다. -처음에는 GKE manifest와 deploy workflow를 만들었다. 그 다음 backend와 web에서 AWS 의존을 줄이고, Pub/Sub, Cloud SQL, Secret Manager, Artifact Registry를 연결했다. 이후 QA/temporary/production overlay와 ArgoCD가 붙었고, image tag 자동 업데이트 이력이 실제 cutover 흔적으로 남았다. 마지막에는 Valkey IAM TLS, Cloud Armor, OTel, Cloud Build, WIF처럼 운영 기준을 정리하는 작업이 이어졌다. +처음에는 GKE manifest와 deploy workflow를 만들었다. 그 다음 backend와 web에서 AWS 의존을 줄이고 Pub/Sub, Cloud SQL, Secret Manager, Artifact Registry를 연결했다. 이후 QA/temporary/production overlay와 ArgoCD가 붙었고 image tag 자동 업데이트 이력이 실제 cutover 흔적으로 남았다. 마지막에는 Valkey IAM TLS, Cloud Armor, OTel, Cloud Build, WIF처럼 운영 기준을 정리하는 작업이 이어졌다. 그래서 이관 완료의 기준도 단순히 "Pod가 떴다"가 아니었다. @@ -354,7 +354,7 @@ S3/CloudFront, 일부 static sync, keyless AWS access 흔적이 남아 있었다 6. reject/rollback까지 포함해 운영자가 상태를 추적할 수 있다. ``` -리소스는 이미 정리되어 현재 캡처는 남아 있지 않지만, Git 이력은 충분히 많은 것을 말해준다. 특히 image update, reject/rollback, overlay 추가, Secret Manager 전환, Cloud Build 전환 이력은 당시 운영 판단을 복기하기에 좋은 근거였다. +리소스는 이미 정리되어 현재 캡처는 남아 있지 않지만 Git 이력은 충분히 많은 것을 말해준다. 특히 image update, reject/rollback, overlay 추가, Secret Manager 전환, Cloud Build 전환 이력은 당시 운영 판단을 복기하기에 좋은 근거였다. 결국 클라우드 migration에서 가장 중요한 것은 서비스 이름의 매핑표가 아니었다. diff --git a/content/posts/ko/backend-owned-login-flow.md b/content/posts/ko/backend-owned-login-flow.md index 724d1e6..e10ce75 100644 --- a/content/posts/ko/backend-owned-login-flow.md +++ b/content/posts/ko/backend-owned-login-flow.md @@ -12,9 +12,9 @@ draft: false 서비스가 커지면 로그인은 단순한 폼 제출 기능이 아니게 된다. -처음에는 프론트엔드에서 이메일과 비밀번호를 받아 자체 토큰을 만들고, 브라우저에는 그 토큰을 쿠키로 내려주는 방식으로 충분했다. 보호된 페이지에 접근하면 프론트엔드 미들웨어가 쿠키에서 토큰을 읽고 로그인 페이지로 보내고, 로그인에 성공하면 다시 원래 페이지로 돌려보내는 구조였다. +처음에는 프론트엔드에서 이메일과 비밀번호를 받아 자체 토큰을 만들고 브라우저에는 그 토큰을 쿠키로 내려주는 방식으로 충분했다. 보호된 페이지에 접근하면 프론트엔드 미들웨어가 쿠키에서 토큰을 읽고 로그인 페이지로 보내고 로그인에 성공하면 다시 원래 페이지로 돌려보내는 구조였다. -문제는 이 프론트엔드가 더 이상 독립적인 로그인 시스템으로 남기 어려워지면서 시작됐다. 백엔드에 이미 OAuth 기반 인증 흐름이 있었고, 같은 사용자를 다루는 서비스라면 로그인 시작, 콜백 처리, 토큰 발급 기준도 백엔드 인증 흐름에 맞춰야 했다. +문제는 이 프론트엔드가 더 이상 독립적인 로그인 시스템으로 남기 어려워지면서 시작됐다. 백엔드에 이미 OAuth 기반 인증 흐름이 있었고 같은 사용자를 다루는 서비스라면 로그인 시작, 콜백 처리, 토큰 발급 기준도 백엔드 인증 흐름에 맞춰야 했다. 그때부터 "로그인 화면을 어디서 보여줄 것인가"보다 더 중요한 질문이 생겼다. @@ -44,9 +44,9 @@ sequenceDiagram F-->>U: 페이지 응답 ``` -프론트엔드가 로그인 요청을 받고, 사용자 정보를 조회하고, 토큰을 만들고, Redis에 저장하고, 브라우저 쿠키로 내려줬다. 페이지 접근 제어도 같은 프론트엔드의 미들웨어에서 처리했다. +프론트엔드가 로그인 요청을 받고 사용자 정보를 조회하고 토큰을 만들고 Redis에 저장하고 브라우저 쿠키로 내려줬다. 페이지 접근 제어도 같은 프론트엔드의 미들웨어에서 처리했다. -작은 서비스라면 나쁘지 않은 구조다. 요청과 응답이 한 앱 안에서 끝나고, 문제가 생겨도 확인할 곳이 많지 않다. +작은 서비스라면 나쁘지 않은 구조다. 요청과 응답이 한 앱 안에서 끝나고 문제가 생겨도 확인할 곳이 많지 않다. 하지만 인증 체계가 커질수록 이 구조는 애매해졌다. @@ -58,25 +58,25 @@ sequenceDiagram 가장 먼저 확인한 것은 기존 로그인 구조였다. -프론트엔드가 이메일과 비밀번호를 받아 사용자 정보를 조회하고, 자체 JWT를 만들고, Redis에 저장한 뒤 브라우저 쿠키로 내려주고 있었다. 보호 페이지 접근 제어도 같은 프론트엔드 미들웨어에서 처리했다. +프론트엔드가 이메일과 비밀번호를 받아 사용자 정보를 조회하고 자체 JWT를 만들고 Redis에 저장한 뒤 브라우저 쿠키로 내려주고 있었다. 보호 페이지 접근 제어도 같은 프론트엔드 미들웨어에서 처리했다. -이 구조 자체가 항상 잘못된 것은 아니다. 다만 백엔드에 별도의 인증 흐름이 있고, 앞으로 그 흐름을 기준으로 로그인 상태를 맞춰야 한다면 이야기가 달라진다. +이 구조 자체가 항상 잘못된 것은 아니다. 다만 백엔드에 별도의 인증 흐름이 있고 앞으로 그 흐름을 기준으로 로그인 상태를 맞춰야 한다면 이야기가 달라진다. -프론트엔드가 계속 자체 토큰을 만들면 백엔드 인증 흐름은 별도로 존재하고, 프론트엔드 로그인은 또 다른 출처가 된다. 같은 사용자를 다루면서도 로그인 상태를 만드는 곳이 둘로 나뉘는 것이다. +프론트엔드가 계속 자체 토큰을 만들면 백엔드 인증 흐름은 별도로 존재하고 프론트엔드 로그인은 또 다른 출처가 된다. 같은 사용자를 다루면서도 로그인 상태를 만드는 곳이 둘로 나뉜다. ### 2. 로그인 화면과 인증 시작점이 같지 않았다 처음에는 보호된 페이지에 접근하면 프론트엔드가 로그인 페이지로 보내는 구조였다. 사용자가 폼을 제출하면 프론트엔드의 로그인 API가 토큰을 발급했다. -하지만 백엔드가 OAuth 흐름을 관리한다면, 단순히 로그인 화면을 보여주는 것만으로는 부족하다. 백엔드는 사용자가 원래 가려던 경로, 콜백 이후 돌아갈 위치, 로그인 과정에서 유지해야 하는 상태를 알고 있어야 한다. +하지만 백엔드가 OAuth 흐름을 관리한다면, 단순히 로그인 화면을 보여주는 것만으로는 부족하다. 백엔드는 사용자가 원래 가려던 경로, 콜백 이후 돌아갈 위치, 로그인 과정에서 유지할 상태를 알고 있어야 한다. -프론트엔드가 로그인 화면이나 백엔드 로그인 API로 바로 보내면, 백엔드의 authorize 단계가 빠진다. 겉으로는 로그인처럼 보이지만, 백엔드 입장에서는 인증 흐름이 중간부터 시작된 셈이다. +프론트엔드가 로그인 화면이나 백엔드 로그인 API로 바로 보내면, 백엔드의 authorize 단계가 빠진다. 겉으로는 로그인처럼 보이지만 백엔드 입장에서는 인증 흐름이 중간부터 시작된 셈이다. ### 3. 만료와 갱신의 기준이 달라진다 같은 쿠키 이름으로 들어오는 기존 프론트엔드 토큰과 새 백엔드 토큰을 한동안 함께 다뤄야 했다. -기존 토큰은 JWT 서명만 맞으면 되는 구조가 아니었다. Redis에 토큰이 남아 있고, 토큰의 `sub`와 저장된 사용자 ID가 일치해야 유효했다. 반면 백엔드 토큰은 백엔드에 유효성 확인을 요청하고, 필요하면 refresh token으로 새 토큰을 받아야 했다. +기존 토큰은 JWT 서명만 맞으면 되는 구조가 아니었다. Redis에 토큰이 남아 있고 토큰의 `sub`와 저장된 사용자 ID가 일치해야 유효했다. 반면 백엔드 토큰은 백엔드에 유효성 확인을 요청하고 필요하면 refresh token으로 새 토큰을 받아야 했다. 그래서 프론트엔드 미들웨어가 직접 토큰의 의미를 판단하기보다, 기존 토큰은 기존 검증 경로로, 백엔드 토큰은 백엔드 검증 경로로 보내는 식으로 책임을 나눠야 했다. @@ -84,9 +84,9 @@ sequenceDiagram 전환 중에는 기존 경로를 잠시 남겨둘 수 있다. 문제는 새 흐름을 만들었다고 생각했는데, 실제 보호 페이지가 여전히 기존 로그인 경로를 타는 경우다. -실제로 이 작업에서도 처음에는 방향을 잘못 잡을 여지가 있었다. 백엔드 로그인 API를 직접 호출하는 방식은 authorize 단계에서 관리해야 할 상태를 건너뛴다. 그래서 보호 페이지에서 시작되는 redirect가 반드시 백엔드 인증 시작점으로 향하는지 확인해야 했다. +실제로 이 작업에서도 처음에는 방향을 잘못 잡을 여지가 있었다. 백엔드 로그인 API를 직접 호출하는 방식은 authorize 단계에서 관리할 상태를 건너뛴다. 그래서 보호 페이지에서 시작되는 redirect가 반드시 백엔드 인증 시작점으로 향하는지 확인했다. -인증 전환에서 "로그인이 된다"는 말만으로는 부족하다. 어떤 경로로 로그인됐는지까지 확인해야 한다. +인증 전환에서 "로그인이 된다"는 말만으로는 부족하다. 어떤 경로로 로그인됐는지까지 확인한다. --- @@ -94,9 +94,9 @@ sequenceDiagram 목표는 프론트엔드에서 인증을 모두 제거하는 것이 아니었다. -프론트엔드는 여전히 쿠키를 읽고, 보호 페이지 접근을 막고, 콜백 결과를 받아 사용자 경험을 이어줘야 한다. 다만 **로그인 상태를 만들고 검증하는 기준은 백엔드가 갖는 구조**로 옮기고 싶었다. +프론트엔드는 여전히 쿠키를 읽고 보호 페이지 접근을 막고 콜백 결과를 받아 사용자 경험을 이어줘야 한다. 다만 **로그인 상태를 만들고 검증하는 기준은 백엔드가 갖는 구조**로 옮기고 싶었다. -정리하면 다음과 같다. +책임은 다음처럼 나눴다. | 영역 | 책임 | |------|------| @@ -132,11 +132,11 @@ sequenceDiagram F-->>U: 토큰을 쿠키로 전달 후 원래 페이지로 redirect ``` -사용자가 보호된 페이지에 접근하면 프론트엔드는 자체 로그인 화면으로 바로 보내지 않는다. 먼저 백엔드의 인증 시작점으로 보낸다. 백엔드는 필요한 상태를 만들고, 로그인 페이지나 인증 제공자로 사용자를 보낸다. +사용자가 보호된 페이지에 접근하면 프론트엔드는 자체 로그인 화면으로 바로 보내지 않는다. 먼저 백엔드의 인증 시작점으로 보낸다. 백엔드는 필요한 상태를 만들고 로그인 페이지나 인증 제공자로 사용자를 보낸다. -인증이 끝나면 백엔드는 프론트엔드의 콜백 주소로 사용자를 돌려보낸다. 이때 프론트엔드는 콜백에 담긴 결과만 믿고 바로 로그인 처리하지 않는다. 백엔드에 다시 확인하고, 백엔드가 발급한 서비스 토큰을 받아 브라우저 쿠키로 내려준다. +인증이 끝나면 백엔드는 프론트엔드의 콜백 주소로 사용자를 돌려보낸다. 이때 프론트엔드는 콜백에 담긴 결과만 믿고 바로 로그인 처리하지 않는다. 백엔드에 다시 확인하고 백엔드가 발급한 서비스 토큰을 받아 브라우저 쿠키로 내려준다. -이렇게 하면 프론트엔드가 직접 사용자 신원을 판단하지 않아도 된다. 프론트엔드는 흐름을 이어주는 역할에 집중하고, 백엔드는 인증 상태의 출처가 된다. +이렇게 하면 프론트엔드가 직접 사용자 신원을 판단하지 않아도 된다. 프론트엔드는 흐름을 이어주는 역할에 집중하고 백엔드는 인증 상태의 출처가 된다. --- @@ -159,7 +159,7 @@ sequenceDiagram → 원래 페이지 ``` -이 흐름을 벗어나면 아직 기존 경로가 남아 있거나, 프론트엔드가 인증 책임을 다시 가져가고 있는 것이다. +이 흐름을 벗어나면 아직 기존 경로가 남아 있거나, 프론트엔드가 인증 책임을 다시 가져가고 있다는 뜻이다. ### 2. 콜백은 로그인 성공을 의미하지 않는다 @@ -173,9 +173,9 @@ sequenceDiagram 같은 쿠키 이름으로 들어오는 기존 토큰과 새 백엔드 토큰을 같이 받아야 하는 동안에는 검증 로직이 복잡해진다. -기존 토큰은 기존 저장소를 확인해야 하고, 새 토큰은 백엔드 인증 서버에 물어봐야 한다. 이때 중요한 것은 프론트엔드가 토큰의 내부 구조를 해석하려고 하지 않는 것이다. +기존 토큰은 기존 저장소를 확인하고 새 토큰은 백엔드 인증 서버에 물어본다. 이때 중요한 것은 프론트엔드가 토큰의 내부 구조를 해석하지 않는 일이다. -프론트엔드가 해야 할 일은 단순하다. +프론트엔드의 일은 단순하다. - access token이 유효한지 확인한다. - 만료됐다면 refresh token으로 갱신 가능한지 확인한다. @@ -202,7 +202,7 @@ sequenceDiagram ## 검증 방법 -인증 변경은 코드만 봐서는 확신하기 어렵다. 실제 브라우저가 어떤 redirect를 따라가는지, 토큰 쿠키가 언제 생기고 언제 삭제되는지 확인해야 한다. +인증 변경은 코드만 봐서는 확신하기 어렵다. 실제 브라우저가 어떤 redirect를 따라가는지, 토큰 쿠키가 언제 생기고 언제 삭제되는지 확인한다. 그래서 검증은 기능 단위가 아니라 흐름 단위로 했다. @@ -215,7 +215,7 @@ sequenceDiagram | access token과 refresh token 모두 만료 | 토큰 쿠키 삭제 후 인증 시작점으로 이동 | | 원래 가려던 경로가 외부 URL | 외부 redirect 차단 후 기본 페이지로 이동 | -특히 redirect chain은 반드시 확인해야 한다. +특히 redirect chain은 반드시 확인한다. 프론트엔드 코드에서 "인증 시작점으로 보낸다"고 작성했더라도, 실제 응답이 예전 로그인 페이지로 떨어지면 전환은 끝난 게 아니다. 로그인은 화면 하나가 아니라 여러 HTTP 응답이 이어진 결과이기 때문이다. @@ -225,7 +225,7 @@ sequenceDiagram 이번 작업을 하면서 로그인은 UI 문제가 아니라 상태의 출처 문제라는 걸 다시 확인했다. -프론트엔드는 사용자 경험을 책임진다. 보호 페이지 접근을 막고, 콜백을 받고, 토큰을 브라우저 쿠키로 내려주고, 실패 시 적절한 화면으로 돌려보낸다. 하지만 사용자가 누구인지, 토큰이 유효한지, refresh token으로 갱신할 수 있는지는 백엔드가 판단해야 한다. +프론트엔드는 사용자 경험을 책임진다. 보호 페이지 접근을 막고 콜백을 받고 토큰을 브라우저 쿠키로 내려주고 실패 시 적절한 화면으로 돌려보낸다. 하지만 사용자가 누구인지, 토큰이 유효한지, refresh token으로 갱신할 수 있는지는 백엔드가 판단한다. 인증 책임을 백엔드로 옮긴다는 말은 프론트엔드에서 인증 관련 코드를 모두 없앤다는 뜻이 아니다. 오히려 프론트엔드에는 더 명확한 역할이 생긴다. @@ -233,6 +233,6 @@ sequenceDiagram 이 기준이 있어야 로그인 경로가 늘어나도 구조가 무너지지 않는다. 일반 로그인, OAuth 인증, 자동 로그인, 만료 토큰 갱신까지 같은 원칙으로 설명할 수 있다. -로그인은 "어디서 화면을 보여줄 것인가"보다 "누가 로그인 상태를 책임질 것인가"를 먼저 정해야 한다. +로그인은 "어디서 화면을 보여줄 것인가"보다 "누가 로그인 상태를 책임질 것인가"를 먼저 정하는 일이다. 다음 글에서는 이 기준을 실제 코드로 옮기는 과정을 다룬다. 보호 페이지에서 인증 시작점으로 보내는 미들웨어, 콜백 핸들러, 일회성 인증 결과를 서비스 토큰으로 바꾸는 흐름을 순서대로 정리한다. diff --git a/content/posts/ko/blog-dev-01-intro.md b/content/posts/ko/blog-dev-01-intro.md index 5c64228..13762ca 100644 --- a/content/posts/ko/blog-dev-01-intro.md +++ b/content/posts/ko/blog-dev-01-intro.md @@ -9,9 +9,9 @@ seriesOrder: 1 ## 왜 새로 만들었나 -기존에 Hugo로 블로그를 운영하고 있었습니다. Hugo는 빠르고 간편하지만, 커스터마이징에 한계가 있었습니다. 테마를 수정하려면 Go 템플릿 문법을 알아야 하고, 원하는 인터랙션을 추가하기 어려웠습니다. +기존에 Hugo로 블로그를 운영하고 있었습니다. Hugo는 빠르고 간편하지만 커스터마이징에 한계가 있었습니다. 테마를 수정하려면 Go 템플릿 문법을 알아야 하고 원하는 인터랙션을 추가하기 어려웠습니다. -React로 직접 만들면 원하는 기능을 자유롭게 구현할 수 있고, 프론트엔드 기술을 실습하는 좋은 프로젝트가 될 것이라 판단했습니다. +React로 직접 만들면 원하는 기능을 자유롭게 구현할 수 있고 프론트엔드 기술을 실습하는 좋은 프로젝트가 될 것이라 판단했습니다. ## 기술 스택 @@ -55,7 +55,7 @@ const modules = import.meta.glob("/content/posts/*/*.md", { ## 사이드바 레이아웃 -shadcn/ui의 `Sidebar` 컴포넌트를 활용해 접을 수 있는 사이드바를 구현했습니다. 메뉴 구성은 Home, Posts, Search, Tags, About이고, 하단에는 다크모드 토글과 컬러 테마 선택이 있습니다. +shadcn/ui의 `Sidebar` 컴포넌트를 활용해 접을 수 있는 사이드바를 구현했습니다. 메뉴 구성은 Home, Posts, Search, Tags, About이고 하단에는 다크모드 토글과 컬러 테마 선택이 있습니다. --- diff --git a/content/posts/ko/blog-dev-02-search-transitions.md b/content/posts/ko/blog-dev-02-search-transitions.md index 5fff431..0aee0ad 100644 --- a/content/posts/ko/blog-dev-02-search-transitions.md +++ b/content/posts/ko/blog-dev-02-search-transitions.md @@ -1,7 +1,7 @@ --- title: "블로그 만들기 #2 - Command Palette 검색 & View Transitions" date: "2026-02-26" -description: "cmdk로 Cmd+K 검색을 구현하고, View Transitions API로 페이지 전환 애니메이션을 적용한 과정입니다." +description: "cmdk로 Cmd+K 검색을 구현하고 View Transitions API로 페이지 전환 애니메이션을 적용한 과정입니다." tags: ["react", "cmdk", "view-transitions", "blog"] series: "React 블로그 만들기" seriesOrder: 2 @@ -31,11 +31,11 @@ export function searchPosts(query: string): PostMeta[] { } ``` -### 삽질: cmdk의 내장 필터 +### 삽질 - cmdk의 내장 필터 처음에는 검색어를 입력해도 결과가 표시되지 않는 문제가 있었습니다. 원인은 cmdk의 내장 필터가 `CommandItem`의 `value` 속성(slug)으로 매칭하기 때문이었습니다. -자체 검색 로직을 사용할 때는 반드시 `shouldFilter={false}`를 설정해야 합니다. +자체 검색 로직을 사용할 때는 반드시 `shouldFilter={false}`를 설정합니다. ```tsx @@ -103,7 +103,7 @@ export function App() { 기본 View Transition은 old 페이지와 new 페이지가 크로스페이드됩니다. 이때 아주 잠깐 두 페이지가 겹쳐 보이는 현상이 있었습니다. -old 페이지는 즉시 사라지고, new 페이지만 페이드인하도록 수정했습니다. +old 페이지는 즉시 사라지고 new 페이지만 페이드인하도록 수정했습니다. ```css ::view-transition-old(root) { diff --git a/content/posts/ko/blog-dev-03-theme-series.md b/content/posts/ko/blog-dev-03-theme-series.md index 3a34594..1bad1a9 100644 --- a/content/posts/ko/blog-dev-03-theme-series.md +++ b/content/posts/ko/blog-dev-03-theme-series.md @@ -1,7 +1,7 @@ --- title: "블로그 만들기 #3 - 컬러 테마 프리셋 & 포스트 시리즈" date: "2026-02-26" -description: "shadcn/ui의 oklch 색상 시스템으로 6가지 컬러 프리셋을 만들고, 포스트 시리즈 기능을 구현한 과정입니다." +description: "shadcn/ui의 oklch 색상 시스템으로 6가지 컬러 프리셋을 만들고 포스트 시리즈 기능을 구현한 과정입니다." tags: ["react", "shadcn-ui", "css", "blog"] series: "React 블로그 만들기" seriesOrder: 3 @@ -31,7 +31,7 @@ Neutral, Blue, Green, Rose, Orange, Violet - 각 프리셋은 `--primary`, `--ri ### 구현 방식 -`document.documentElement`에 `data-color` 속성을 설정하고, `localStorage`에 저장합니다. 사이드바에 팔레트 아이콘 드롭다운으로 선택할 수 있습니다. +`document.documentElement`에 `data-color` 속성을 설정하고 `localStorage`에 저장합니다. 사이드바에 팔레트 아이콘 드롭다운으로 선택할 수 있습니다. ```tsx function applyColor(value: string) { @@ -83,7 +83,7 @@ seriesOrder: 1 ### 시리즈 네비게이터 -포스트 상단에 접었다 펼 수 있는 시리즈 목록이 표시됩니다. 현재 글이 하이라이트되고, 다른 글을 클릭하면 이동합니다. +포스트 상단에 접었다 펼 수 있는 시리즈 목록이 표시됩니다. 현재 글이 하이라이트되고 다른 글을 클릭하면 이동합니다. ```tsx export function SeriesNavigator({ series, currentSlug }) { @@ -103,9 +103,9 @@ export function SeriesNavigator({ series, currentSlug }) { } ``` -### 삽질: 목차(TOC)가 갱신되지 않는 문제 +### 삽질 - 목차(TOC)가 갱신되지 않는 문제 -시리즈 내 다른 글을 클릭하면 URL이 `/posts/slug-a`에서 `/posts/slug-b`로 바뀌지만, 같은 라우트 패턴(`/posts/:slug`)이라 React가 컴포넌트를 리마운트하지 않습니다. +시리즈 내 다른 글을 클릭하면 URL이 `/posts/slug-a`에서 `/posts/slug-b`로 바뀌지만 같은 라우트 패턴(`/posts/:slug`)이라 React가 컴포넌트를 리마운트하지 않습니다. 이로 인해 목차(TableOfContents)가 이전 글의 헤딩을 보여주는 문제가 있었습니다. `key` prop으로 slug가 바뀔 때마다 강제 리마운트하여 해결했습니다. diff --git a/content/posts/ko/blog-dev-04-search-seo.md b/content/posts/ko/blog-dev-04-search-seo.md index 3852279..b177f2f 100644 --- a/content/posts/ko/blog-dev-04-search-seo.md +++ b/content/posts/ko/blog-dev-04-search-seo.md @@ -1,7 +1,7 @@ --- title: "블로그 만들기 #4 - 고급 검색 & SEO 최적화" date: "2026-02-26" -description: "날짜/태그 필터가 포함된 검색 페이지를 만들고, 메타 태그, sitemap, RSS 피드로 SEO를 최적화한 과정입니다." +description: "날짜/태그 필터가 포함된 검색 페이지를 만들고 메타 태그, sitemap, RSS 피드로 SEO를 최적화한 과정입니다." tags: ["react", "seo", "shadcn-ui", "blog"] series: "React 블로그 만들기" seriesOrder: 4 @@ -19,7 +19,7 @@ Cmd+K Command Palette 외에 별도 검색 페이지를 추가했습니다. 키 ### DatePicker 구현 -shadcn/ui의 `Calendar` + `Popover` 컴포넌트를 조합해 DatePicker를 만들었습니다. 처음에는 기본 ``를 사용했지만, 디자인 일관성을 위해 shadcn 컴포넌트로 교체했습니다. +shadcn/ui의 `Calendar` + `Popover` 컴포넌트를 조합해 DatePicker를 만들었습니다. 처음에는 기본 ``를 사용했지만 디자인 일관성을 위해 shadcn 컴포넌트로 교체했습니다. ```tsx export function DatePicker({ value, onChange, placeholder }) { @@ -51,7 +51,7 @@ export function DatePicker({ value, onChange, placeholder }) { ### advancedSearch 함수 -모든 필터를 조합하는 검색 함수입니다. 각 필터는 선택사항이며, 입력된 필터만 적용됩니다. +모든 필터를 조합하는 검색 함수입니다. 각 필터는 선택사항이며 입력된 필터만 적용됩니다. ```tsx export function advancedSearch({ query, dateFrom, dateTo, tag }) { diff --git a/content/posts/ko/blog-dev-05-analytics.md b/content/posts/ko/blog-dev-05-analytics.md index 0a7825d..e7587c7 100644 --- a/content/posts/ko/blog-dev-05-analytics.md +++ b/content/posts/ko/blog-dev-05-analytics.md @@ -7,7 +7,7 @@ series: "React 블로그 만들기" seriesOrder: 5 --- -## 문제: 정적 사이트에서 GA 데이터 보여주기 +## 정적 사이트에서 GA 데이터 보여주기 Google Analytics를 추가하는 것 자체는 간단합니다. `index.html`에 스크립트 태그를 넣으면 끝입니다. @@ -23,9 +23,9 @@ Google Analytics를 추가하는 것 자체는 간단합니다. `index.html`에 하지만 수집된 데이터를 **블로그에 직접 표시**하려면 이야기가 달라집니다. GA4 Data API는 서비스 계정 인증이 필요해서, 클라이언트 JavaScript에서 직접 호출할 수 없습니다. -## 해결: Google Apps Script 프록시 +## Google Apps Script 프록시 -**Google Apps Script**를 중간 프록시로 사용했습니다. 무료이고, 별도 서버 없이 GA4 Data API를 호출할 수 있습니다. +**Google Apps Script**를 중간 프록시로 사용했습니다. 무료이고 별도 서버 없이 GA4 Data API를 호출할 수 있습니다. ### 구조 @@ -74,7 +74,7 @@ function getDailyViews(propertyId) { ### Apps Script 설정 시 주의사항 -1. **고급 서비스 활성화** - Apps Script 에디터에서 `Google Analytics Data API` 서비스를 추가해야 합니다. 이걸 빠뜨리면 `AnalyticsData is not defined` 에러가 발생합니다. +1. **고급 서비스 활성화** - Apps Script 에디터에서 `Google Analytics Data API` 서비스를 추가합니다. 이걸 빠뜨리면 `AnalyticsData is not defined` 에러가 발생합니다. 2. **Property ID vs Measurement ID** - API에는 숫자로 된 Property ID가 필요합니다. `G-XXXXXXXXXX` 형태의 Measurement ID가 아닙니다. GA 관리 > 속성 설정에서 확인할 수 있습니다. @@ -82,7 +82,7 @@ function getDailyViews(propertyId) { ## 블로그 측 구현 -### 데이터 공유: usePageViews 훅 +### usePageViews 훅으로 데이터 공유 API를 한 번만 호출하고 여러 컴포넌트에서 공유하기 위해 모듈 레벨 캐싱 + `sessionStorage` 캐싱을 적용했습니다. diff --git a/content/posts/ko/blog-dev-06-ux-seo.md b/content/posts/ko/blog-dev-06-ux-seo.md index cbfd3da..40f4f7e 100644 --- a/content/posts/ko/blog-dev-06-ux-seo.md +++ b/content/posts/ko/blog-dev-06-ux-seo.md @@ -13,7 +13,7 @@ seriesOrder: 6 ### 문제 -글이 많아지면서 PostsPage의 전체 리스트가 길어졌습니다. 날짜순으로 정렬되어 있긴 하지만, 시각적으로 "이 글이 언제 쓴 건지" 한눈에 파악하기 어려웠습니다. +글이 많아지면서 PostsPage의 전체 리스트가 길어졌습니다. 날짜순으로 정렬되어 있긴 하지만 시각적으로 "이 글이 언제 쓴 건지" 한눈에 파악하기 어려웠습니다. ### 해결 @@ -35,7 +35,7 @@ const groupedByYear = useMemo(() => { }, [posts]) ``` -`useMemo`로 캐싱하고, `Map`의 삽입 순서가 보장되는 특성을 활용합니다. 이미 날짜순으로 정렬된 데이터이므로 연도도 자연스럽게 내림차순이 됩니다. +`useMemo`로 캐싱하고 `Map`의 삽입 순서가 보장되는 특성을 활용합니다. 이미 날짜순으로 정렬된 데이터이므로 연도도 자연스럽게 내림차순이 됩니다. 렌더링은 기존 `PostList` 컴포넌트를 연도별로 반복 호출하는 방식입니다. @@ -121,11 +121,11 @@ Google이 색인하는 데 며칠~몇 주 걸릴 수 있으므로 바로 검색 ### 원인 -`useMetaTags` 훅에서 `og:title`, `og:description` 등은 설정하고 있었지만, `og:image` 메타태그가 빠져 있었습니다. +`useMetaTags` 훅에서 `og:title`, `og:description` 등은 설정하고 있었지만 `og:image` 메타태그가 빠져 있었습니다. ### 해결 -1200x630px 크기의 대표 이미지를 `public/og-image.png`에 추가하고, `useMetaTags`에 `og:image` 메타태그를 설정합니다. +1200x630px 크기의 대표 이미지를 `public/og-image.png`에 추가하고 `useMetaTags`에 `og:image` 메타태그를 설정합니다. ```tsx setMeta("og:image", `${BASE_URL}/og-image.png`) @@ -141,7 +141,7 @@ setMeta("og:image:height", "630") ## 고려했지만 넘어간 것들 -블로그의 완성도를 높이기 위해 여러 개선 사항을 검토했지만, 현재 규모에서는 과한 것들이 있었습니다. +블로그의 완성도를 높이기 위해 여러 개선 사항을 검토했지만 현재 규모에서는 과한 것들이 있었습니다. | 항목 | 판단 | 이유 | |------|------|------| @@ -156,7 +156,7 @@ setMeta("og:image:height", "630") ## 마치며 -이번 글에서 다룬 개선들은 각각 코드 변경량이 적지만, 합치면 사용자 경험이 체감될 정도로 달라집니다. +이번 글에서 다룬 개선들은 각각 코드 변경량이 적지만 합치면 사용자 경험이 체감될 정도로 달라집니다. - 연도별 그룹핑 → 글 목록의 가독성 향상 - 모바일 사이드바 자동 닫기 → 네비게이션 UX 개선 diff --git a/content/posts/ko/ecs-to-eks-migration.md b/content/posts/ko/ecs-to-eks-migration.md index f9246fb..1ad06eb 100644 --- a/content/posts/ko/ecs-to-eks-migration.md +++ b/content/posts/ko/ecs-to-eks-migration.md @@ -10,7 +10,7 @@ draft: false ## 배경 -기존에는 AWS ECS 위에서 서비스를 운영하고 있었다. GitHub Actions에서 Docker 이미지를 빌드하고, `aws ecs update-service --force-new-deployment`로 배포하는 단순한 구조였다. +기존에는 AWS ECS 위에서 서비스를 운영하고 있었다. GitHub Actions에서 Docker 이미지를 빌드하고 `aws ecs update-service --force-new-deployment`로 배포하는 단순한 구조였다. ```mermaid flowchart LR @@ -20,18 +20,18 @@ flowchart LR D --> E[Task Definition 교체] ``` -이 구조가 동작은 했지만, 서비스가 커지면서 불편한 점이 쌓여갔다. +이 구조가 동작은 했지만 서비스가 커지면서 불편한 점이 쌓여갔다. ### ECS에서 느꼈던 한계 -- **환경별 설정 관리가 귀찮았다** - dev, stage, prod마다 별도의 Task Definition을 관리해야 했고, 환경 변수 하나 바꾸려면 AWS 콘솔을 들여다보거나 별도 스크립트를 돌려야 했다 -- **인프라 상태를 코드로 추적하기 어려웠다** - Task Definition 리비전이 쌓이긴 하지만, "현재 어떤 설정으로 돌아가고 있는지"를 Git에서 확인할 수 없었다 -- **로그 확인이 비싸고 불편했다** - CloudWatch Logs 자체 비용도 만만치 않았다. 서비스 수가 늘면서 로그 그룹도 늘어나고, 특히 dev나 stage 같은 개발 환경에서까지 CloudWatch 비용을 내면서 로그를 보는 건 비효율적이었다. Datadog을 붙이면 편해지지만 비용이 더 올라가고, CloudWatch만으로는 여러 서비스의 로그를 크로스로 추적하거나 트레이스와 연동하기가 한계가 있었다 +- **환경별 설정 관리가 귀찮았다** - dev, stage, prod마다 별도의 Task Definition을 관리해야 했고 환경 변수 하나 바꾸려면 AWS 콘솔을 들여다보거나 별도 스크립트를 돌려야 했다 +- **인프라 상태를 코드로 추적하기 어려웠다** - Task Definition 리비전이 쌓이긴 하지만 "현재 어떤 설정으로 돌아가고 있는지"를 Git에서 확인할 수 없었다 +- **로그 확인이 비싸고 불편했다** - CloudWatch Logs 자체 비용도 만만치 않았다. 서비스 수가 늘면서 로그 그룹도 늘어나고 특히 dev나 stage 같은 개발 환경에서까지 CloudWatch 비용을 내면서 로그를 보는 건 비효율적이었다. Datadog을 붙이면 편해지지만 비용이 더 올라가고 CloudWatch만으로는 여러 서비스의 로그를 크로스로 추적하거나 트레이스와 연동하기가 한계가 있었다 - **Blue/Green 배포가 복잡했다** - 가장 큰 고통이었다. 아래에서 자세히 설명한다 ### ECS Blue/Green - 왜 복잡했나 -ECS에서 Blue/Green 배포를 하려면 ECS 서비스를 두 개(Blue, Green) 띄우고, ALB Target Group을 각각 연결한 뒤, 배포할 때마다 ALB 라우팅 룰을 조작해야 했다. 이걸 GitHub Actions 워크플로우에서 셸 스크립트로 관리하고 있었다. +ECS에서 Blue/Green 배포를 하려면 ECS 서비스를 두 개(Blue, Green) 띄우고 ALB Target Group을 각각 연결한 뒤, 배포할 때마다 ALB 라우팅 룰을 조작해야 했다. 이걸 GitHub Actions 워크플로우에서 셸 스크립트로 관리하고 있었다. 실제 `deploy-blue.yml`의 핵심 로직이다: @@ -65,7 +65,7 @@ ECS에서 Blue/Green 배포를 하려면 ECS 서비스를 두 개(Blue, Green) 1. **배포 워크플로우가 두 개** - `deploy-blue.yml`과 `deploy-green.yml`을 따로 관리해야 했다. 빌드 로직이 바뀌면 두 파일을 동시에 수정해야 했다 2. **ALB 상태 조회에 의존** - 배포 시점에 ELB API를 호출해서 현재 라우팅 상태를 판단한다. API 응답이 느리거나 실패하면 배포가 막힌다 -3. **트래픽 전환이 별도 작업** - 이미지를 배포하는 것과 트래픽을 전환하는 것이 분리되어 있어서, 배포 후 ALB 라우팅 룰을 수동으로 바꿔야 했다 +3. **트래픽 전환이 별도 작업** - 이미지를 배포하는 것과 트래픽을 전환하는 것이 분리되어 있어서 배포 후 ALB 라우팅 룰을 수동으로 바꿔야 했다 4. **상태 추적 불가** - "지금 Blue가 활성이야, Green이 활성이야?"를 ALB API를 호출하지 않으면 알 수 없었다. Git에는 이 상태가 기록되지 않는다 ### 왜 EKS인가 @@ -81,7 +81,7 @@ EKS를 선택한 건 결국 **선언적 인프라 관리**가 핵심이었다. | **사이드카** | Task Definition에 컨테이너 추가 | Pod spec에 컨테이너 추가 | | **상태 추적** | AWS 콘솔 | Git + ArgoCD | -인프라 설정 전체를 Git으로 관리하고, ArgoCD로 클러스터와 동기화하면 "현재 운영 상태 = Git 저장소 상태"가 된다. 이게 GitOps의 핵심이고, EKS 마이그레이션의 가장 큰 동기였다. +인프라 설정 전체를 Git으로 관리하고 ArgoCD로 클러스터와 동기화하면 "현재 운영 상태 = Git 저장소 상태"가 된다. 이게 GitOps의 핵심이고 EKS 마이그레이션의 가장 큰 동기였다. ## 전체 아키텍처 @@ -183,7 +183,7 @@ managedNodeGroups: node-type: system ``` -4개 AZ에 private/public 서브넷을 배치하고, OIDC Provider를 활성화해서 IRSA(IAM Roles for Service Accounts)를 쓸 수 있게 했다. 시스템 노드그룹은 CoreDNS, Karpenter 같은 클러스터 컴포넌트용이고, 실제 워크로드는 Karpenter가 관리하는 노드에서 돌아간다. +4개 AZ에 private/public 서브넷을 배치하고 OIDC Provider를 활성화해서 IRSA(IAM Roles for Service Accounts)를 쓸 수 있게 했다. 시스템 노드그룹은 CoreDNS, Karpenter 같은 클러스터 컴포넌트용이고 실제 워크로드는 Karpenter가 관리하는 노드에서 돌아간다. ### IRSA - Pod별 최소 권한 @@ -196,10 +196,10 @@ kind: ServiceAccount metadata: name: my-sa annotations: - eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/PodoBackendRole + eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/BackendServiceRole ``` -서비스마다 전용 ServiceAccount를 만들어서, my-backend는 S3/SQS/Secrets Manager 접근 권한만, my-web은 필요한 최소 권한만 갖도록 분리했다. ECS Task Role과 개념은 같지만, K8s 네이티브하게 관리할 수 있어서 더 깔끔하다. +서비스마다 전용 ServiceAccount를 만들어서 my-backend는 S3/SQS/Secrets Manager 접근 권한만, my-web은 필요한 최소 권한만 갖도록 분리했다. ECS Task Role과 개념은 같지만 K8s 네이티브하게 관리할 수 있어서 더 깔끔하다. ## Kustomize - 환경별 설정 관리 @@ -207,7 +207,7 @@ metadata: ### 왜 Kustomize인가 -Helm도 고려했지만 Kustomize를 선택했다. 이유는 단순하다 - **우리 서비스에 범용 차트가 필요 없었다**. Helm은 범용 패키지 배포에 강점이 있지만, 자체 서비스는 환경별 "차이점"만 관리하면 되기 때문에 Kustomize의 base/overlay 패턴이 더 직관적이었다. +Helm도 고려했지만 Kustomize를 선택했다. 이유는 단순하다 - **우리 서비스에 범용 차트가 필요 없었다**. Helm은 범용 패키지 배포에 강점이 있지만 자체 서비스는 환경별 "차이점"만 관리하면 되기 때문에 Kustomize의 base/overlay 패턴이 더 직관적이었다. ### 디렉토리 구조 @@ -293,11 +293,11 @@ spec: memory: 256Mi ``` -base에서 주목할 점은 **OTel Collector 사이드카**가 기본으로 포함된다는 것이다. 모든 환경에서 로그·트레이스·메트릭을 수집하되, 전송 대상(Loki, Tempo 등)은 overlay에서 환경별로 설정한다. +base에서 주목할 점은 **OTel Collector 사이드카**가 기본으로 포함된다는 점이다. 모든 환경에서 로그·트레이스·메트릭을 수집하되, 전송 대상(Loki, Tempo 등)은 overlay에서 환경별로 설정한다. ### Overlay - 환경별 차이만 패치 -overlay에서는 환경별로 달라지는 부분만 정의한다. kustomization.yaml에서 base를 참조하고, 패치 파일로 차이점을 덮어쓴다. +overlay에서는 환경별로 달라지는 부분만 정의한다. kustomization.yaml에서 base를 참조하고 패치 파일로 차이점을 덮어쓴다. ```yaml # my-backend/overlays/prod/kustomization.yaml @@ -371,7 +371,7 @@ spec: memory: 4096Mi ``` -prod에서만 replicas를 3으로 올리고, 전용 nodepool에 스케줄링하고, 리소스 제한을 걸고, Prometheus 스크래핑을 활성화한다. dev overlay는 replicas 1에 리소스도 작게 잡는다. +prod에서만 replicas를 3으로 올리고 전용 nodepool에 스케줄링하고 리소스 제한을 걸고 Prometheus 스크래핑을 활성화한다. dev overlay는 replicas 1에 리소스도 작게 잡는다. ### 환경별 비교 @@ -400,12 +400,12 @@ kubectl apply -k my-backend/overlays/prod ### 왜 Ingress가 아닌 Gateway API인가 -EKS에서 외부 트래픽을 받는 방법은 여러 가지다. NGINX Ingress Controller를 쓸 수도 있고, AWS ALB Ingress Controller를 쓸 수도 있다. 실제로 다른 클러스터에서는 NGINX Ingress의 VirtualServer CRD를 사용하고 있었다. +EKS에서 외부 트래픽을 받는 방법은 여러 가지다. NGINX Ingress Controller를 쓸 수도 있고 AWS ALB Ingress Controller를 쓸 수도 있다. 실제로 다른 클러스터에서는 NGINX Ingress의 VirtualServer CRD를 사용하고 있었다. 이번에는 **K8s Gateway API**를 선택했다. 이유: - **K8s 공식 표준** - Ingress API는 v1 이후 새로운 기능 추가 없이 사실상 유지보수 모드에 들어갔다. Gateway API가 공식 후속 사양으로, K8s 커뮤니티에서 적극적으로 개발하고 있다. 새로 구축하는데 Ingress를 선택할 이유가 없었다 -- **역할 분리가 명확하다** - GatewayClass(인프라) → Gateway(클러스터 운영) → HTTPRoute(서비스 개발) 세 계층으로 나뉘어서, 인프라 팀과 서비스 팀이 각자 관리할 영역이 명확하다 +- **역할 분리가 명확하다** - GatewayClass(인프라) → Gateway(클러스터 운영) → HTTPRoute(서비스 개발) 세 계층으로 나뉘어서 인프라 팀과 서비스 팀이 각자 관리할 영역이 명확하다 - **weight 기반 트래픽 분할이 네이티브** - Blue/Green 배포에 필수인 트래픽 가중치가 HTTPRoute 스펙에 내장되어 있다 ```mermaid @@ -432,7 +432,7 @@ flowchart TB ### AWS Load Balancer Controller + Gateway API -Gateway API는 스펙일 뿐이고, 실제로 ALB를 생성하고 관리하는 건 **AWS Load Balancer Controller**다. v2.16부터 Gateway API를 네이티브로 지원한다. GatewayClass, Gateway, HTTPRoute 리소스를 감시하다가, 변경이 생기면 ALB를 자동으로 생성/수정한다. +Gateway API는 스펙일 뿐이고 실제로 ALB를 생성하고 관리하는 건 **AWS Load Balancer Controller**다. v2.16부터 Gateway API를 네이티브로 지원한다. GatewayClass, Gateway, HTTPRoute 리소스를 감시하다가, 변경이 생기면 ALB를 자동으로 생성/수정한다. 설치는 Helm으로 한다: @@ -448,7 +448,7 @@ helm upgrade --install aws-load-balancer-controller eks/aws-load-balancer-contro 컨트롤러가 ALB를 관리하려면 EC2, ELBv2, ACM, WAF 등 광범위한 AWS 권한이 필요하다. IRSA로 전용 ServiceAccount에 IAM Role을 바인딩해서 최소 권한을 부여한다. -핵심은 **Gateway API 매니페스트만 관리하면 ALB가 알아서 따라온다**는 것이다. ECS에서는 Terraform으로 ALB를 따로 만들고, Target Group을 등록하고, Listener Rule을 설정하고, WAF를 연결하는 게 전부 별도 작업이었다. 이제는 YAML 파일 몇 개로 끝난다. +핵심은 **Gateway API 매니페스트만 관리하면 ALB가 알아서 따라온다**는 점이다. ECS에서는 Terraform으로 ALB를 따로 만들고 Target Group을 등록하고 Listener Rule을 설정하고 WAF를 연결하는 게 전부 별도 작업이었다. 이제는 YAML 파일 몇 개로 끝난다. ### GatewayClass - ALB 타입 선언 @@ -517,7 +517,7 @@ spec: mode: Terminate ``` -`allowedRoutes.namespaces.from: All`이 핵심이다. dev, stage, prod 어느 namespace에서든 이 Gateway를 참조할 수 있어서, ALB 하나로 모든 환경의 트래픽을 받는다. 비용 절감에도 도움이 된다. +`allowedRoutes.namespaces.from: All`이 핵심이다. dev, stage, prod 어느 namespace에서든 이 Gateway를 참조할 수 있어서 ALB 하나로 모든 환경의 트래픽을 받는다. 비용 절감에도 도움이 된다. ### HTTPRoute - 서비스별 라우팅 @@ -545,7 +545,7 @@ spec: port: 9285 ``` -base에서 hostnames를 비워두고, overlay에서 환경별 도메인을 넣는다: +base에서 hostnames를 비워두고 overlay에서 환경별 도메인을 넣는다: ```yaml # overlays/dev/route.patch.yaml @@ -650,7 +650,7 @@ spec: consolidateAfter: 30s # 30초 후 빈 노드 정리 ``` -ARM64 인스턴스를 기본으로 쓴다. x86 대비 약 20% 저렴하고, Java/Node.js 워크로드에서 성능 차이가 거의 없다. Docker 이미지만 멀티 아키텍처로 빌드하면 된다. 실제로 my-backend의 CI에서 `--platform linux/amd64,linux/arm64`로 빌드하고 있다. +ARM64 인스턴스를 기본으로 쓴다. x86 대비 약 20% 저렴하고 Java/Node.js 워크로드에서 성능 차이가 거의 없다. Docker 이미지만 멀티 아키텍처로 빌드하면 된다. 실제로 my-backend의 CI에서 `--platform linux/amd64,linux/arm64`로 빌드하고 있다. Taint/Toleration으로 NodePool 간 격리를 보장한다. `dedicated=prod-backend:NoSchedule` taint가 걸린 노드에는 해당 toleration이 있는 Pod만 스케줄링된다. @@ -781,7 +781,7 @@ flowchart TB ### dev/stage - Image Updater 자동 배포 -dev, stage 브랜치에 push하면 GitHub Actions가 이미지를 빌드하고 ECR에 mutable 태그(`dev`, `stage`)로 push한다. 여기까지가 CI의 역할이고, CD는 ArgoCD Image Updater가 처리한다. +dev, stage 브랜치에 push하면 GitHub Actions가 이미지를 빌드하고 ECR에 mutable 태그(`dev`, `stage`)로 push한다. 여기까지가 CI의 역할이고 CD는 ArgoCD Image Updater가 처리한다. Image Updater는 ECR을 1분마다 폴링하면서 이미지 digest 변경을 감지한다. 같은 `dev` 태그라도 실제 이미지가 바뀌면 ArgoCD가 자동으로 sync한다. @@ -821,7 +821,7 @@ spec: selfHeal: false ``` -ApplicationSet의 list generator로 서비스 목록을 정의하고, template에서 Image Updater 어노테이션을 붙인다. 서비스가 추가되면 elements에 한 항목만 추가하면 된다. +ApplicationSet의 list generator로 서비스 목록을 정의하고 template에서 Image Updater 어노테이션을 붙인다. 서비스가 추가되면 elements에 한 항목만 추가하면 된다. Image Updater의 핵심 설정: @@ -843,7 +843,7 @@ ECR 인증에 IRSA를 사용한다. Image Updater의 ServiceAccount에 IAM Role ### prod - Git 커밋 + Slack 승인 배포 -prod 배포는 `workflow_dispatch`로 시작한다. 배포 내용(Jira 티켓 등)을 입력받고, 여러 단계를 거친다. +prod 배포는 `workflow_dispatch`로 시작한다. 배포 내용(Jira 티켓 등)을 입력받고 여러 단계를 거친다. **1단계: immutable 태그 생성 + ECR Push** @@ -862,7 +862,7 @@ prod 배포는 `workflow_dispatch`로 시작한다. 배포 내용(Jira 티켓 --push . ``` -prod는 mutable 태그가 아닌 `prod-YYMMDD-HHMM` 형식의 immutable 태그를 사용한다. 어떤 시점에 어떤 이미지가 배포됐는지 추적이 가능하고, 롤백 시 이전 태그를 그대로 쓸 수 있다. +prod는 mutable 태그가 아닌 `prod-YYMMDD-HHMM` 형식의 immutable 태그를 사용한다. 어떤 시점에 어떤 이미지가 배포됐는지 추적이 가능하고 롤백 시 이전 태그를 그대로 쓸 수 있다. **2단계: my-cluster 매니페스트 업데이트** @@ -882,7 +882,7 @@ prod는 mutable 태그가 아닌 `prod-YYMMDD-HHMM` 형식의 immutable 태그 git push ``` -여기서 핵심은 **이전 태그를 캡처**하는 것이다. 문제가 생기면 이 태그로 즉시 롤백할 수 있다. +여기서 핵심은 **이전 태그를 캡처**하는 점이다. 문제가 생기면 이 태그로 즉시 롤백할 수 있다. **3단계: GitHub Release 생성** @@ -916,7 +916,7 @@ prod는 mutable 태그가 아닌 `prod-YYMMDD-HHMM` 형식의 immutable 태그 ### readiness probe 타이밍 -Spring Boot 앱이 완전히 뜨기까지 40~60초 정도 걸리는데, 처음에 `initialDelaySeconds`를 20초로 잡았더니 ArgoCD가 "Degraded"로 판단하고 자동 롤백을 했다. Pod이 뜨자마자 readiness check가 실패하니 "이 배포는 문제가 있다"고 판단한 것이다. +Spring Boot 앱이 완전히 뜨기까지 40~60초 정도 걸리는데, 처음에 `initialDelaySeconds`를 20초로 잡았더니 ArgoCD가 "Degraded"로 판단하고 자동 롤백을 했다. Pod이 뜨자마자 readiness check가 실패하니 "이 배포는 문제가 있다"고 판단했다. ```yaml # Before - 실패 @@ -935,7 +935,7 @@ readinessProbe: ### Gateway API allowedRoutes 설정 -처음에 Gateway를 networking namespace에 만들고, HTTPRoute는 각 환경 namespace(dev, prod)에 만들었더니 라우팅이 안 됐다. Gateway의 `allowedRoutes.namespaces.from`이 기본값인 `Same`이라서 같은 namespace의 HTTPRoute만 연결됐기 때문이다. +처음에 Gateway를 networking namespace에 만들고 HTTPRoute는 각 환경 namespace(dev, prod)에 만들었더니 라우팅이 안 됐다. Gateway의 `allowedRoutes.namespaces.from`이 기본값인 `Same`이라서 같은 namespace의 HTTPRoute만 연결됐기 때문이다. ```yaml # 반드시 All로 설정해야 다른 namespace의 HTTPRoute가 연결됨 @@ -1001,10 +1001,10 @@ flowchart LR ## 마무리 -ECS에서 EKS로의 마이그레이션은 단순히 컨테이너 오케스트레이터를 바꾼 게 아니었다. **인프라를 코드로 선언적으로 관리하는 체계**를 만든 것이다. +ECS에서 EKS로의 마이그레이션은 단순히 컨테이너 오케스트레이터를 바꾼 일이 아니었다. **인프라를 코드로 선언적으로 관리하는 체계**를 만든 작업이었다. -Kustomize의 base/overlay 패턴으로 환경별 설정을 깔끔하게 분리하고, Gateway API로 트래픽 라우팅을 표준화하고, Karpenter로 노드 관리를 자동화했다. 이 위에 ArgoCD를 얹어서 Git = 클러스터 상태가 되도록 만들었다. +Kustomize의 base/overlay 패턴으로 환경별 설정을 깔끔하게 분리하고 Gateway API로 트래픽 라우팅을 표준화하고 Karpenter로 노드 관리를 자동화했다. 이 위에 ArgoCD를 얹어서 Git = 클러스터 상태가 되도록 만들었다. -우리 팀에는 전담 인프라 담당자가 없다. 개발자가 인프라를 직접 구성하고 운영해야 하는 상황이기 때문에, 이번 마이그레이션에서 가장 신경 쓴 건 **개발자 경험(DX)** 이었다. 인프라를 몰라도 배포할 수 있고, Git만 보면 현재 상태를 알 수 있고, 문제가 생기면 YAML 한 줄로 롤백할 수 있는 구조. Kustomize overlay, HTTPRoute weight, ArgoCD Image Updater 같은 선택들이 전부 이 방향에서 나온 결정이었다. +우리 팀에는 전담 인프라 담당자가 없다. 개발자가 인프라를 직접 구성하고 운영하는 상황이기 때문에, 이번 마이그레이션에서 가장 신경 쓴 건 **개발자 경험(DX)** 이었다. 인프라를 몰라도 배포할 수 있고 Git만 보면 현재 상태를 알 수 있고 문제가 생기면 YAML 한 줄로 롤백할 수 있는 구조. Kustomize overlay, HTTPRoute weight, ArgoCD Image Updater 같은 선택들이 전부 이 방향에서 나온 결정이었다. 돌이켜보면, 가장 중요했던 건 **한 번에 전환하지 않은 것**이다. dev → stage → prod 순서로 단계적으로 이전하면서 각 단계에서 문제를 잡았다. prod 병행 운영 기간에 실제 트래픽을 받으면서 검증한 게 안전한 전환에 결정적이었다. diff --git a/content/posts/ko/gitops-argocd-n8n-deployment.md b/content/posts/ko/gitops-argocd-n8n-deployment.md index 1b72816..8c67aa5 100644 --- a/content/posts/ko/gitops-argocd-n8n-deployment.md +++ b/content/posts/ko/gitops-argocd-n8n-deployment.md @@ -8,7 +8,7 @@ draft: false ## 배경 -기존에는 AWS ECS 위에서 서비스를 운영하고 있었다. GitHub Actions에서 Docker 이미지 빌드하고, ECS 서비스를 직접 업데이트하는 단순한 구조였는데, 쓰다 보니 불편한 점이 꽤 있었다. +기존에는 AWS ECS 위에서 서비스를 운영하고 있었다. GitHub Actions에서 Docker 이미지 빌드하고 ECS 서비스를 직접 업데이트하는 단순한 구조였는데, 쓰다 보니 불편한 점이 꽤 있었다. - **배포 상태를 바로 알기 어려웠다** - ECS 배포가 실패해도 확인하려면 콘솔을 직접 들여다봐야 했다 - **롤백이 귀찮았다** - 이전 Task Definition 리비전을 찾아서 수동으로 되돌리는 게 꽤 번거로운 작업이었다 @@ -36,7 +36,7 @@ flowchart TB subgraph PROD["prod - 승인 후 배포"] D --> H[ECR\nimmutable tag] - D --> I[podo-cluster\nmanifest 업데이트] + D --> I[application-cluster\nmanifest 업데이트] I --> J[n8n Webhook 호출] J --> K[Slack 승인 요청] K -->|승인| L[ArgoCD Sync] @@ -53,7 +53,7 @@ flowchart TB ### prod - Slack 승인 후 배포 -프로덕션은 다르다. 코드가 main에 머지되면 immutable tag로 이미지를 빌드하고, manifest를 업데이트한 다음, n8n을 통해 Slack으로 승인 요청이 간다. 담당자가 버튼 누르면 그때 배포. +프로덕션은 다르다. 코드가 main에 머지되면 immutable tag로 이미지를 빌드하고 manifest를 업데이트한 다음, n8n을 통해 Slack으로 승인 요청이 간다. 담당자가 버튼 누르면 그때 배포. ## 핵심 컴포넌트 상세 @@ -66,22 +66,22 @@ flowchart LR A[Push Event] --> B{Branch} B -->|dev, stage| C[mutable tag로\nECR push] B -->|main| D[immutable tag 생성\n예: v1.2.3-abc1234] - D --> E[podo-cluster\nkustomization.yaml\nnewTag 업데이트] + D --> E[application-cluster\nkustomization.yaml\nnewTag 업데이트] E --> F[n8n webhook 호출] ``` dev/stage는 단순하다. mutable tag(`dev`, `stage`)로 ECR에 push하면 끝. Image Updater가 digest 변경을 알아서 감지한다. -prod은 좀 더 단계가 있다. git SHA 기반으로 immutable tag를 만들고, `podo-cluster` 저장소의 kustomization.yaml에 새 태그를 커밋한 뒤, n8n webhook을 호출해서 Slack 승인을 요청한다. +prod은 좀 더 단계가 있다. git SHA 기반으로 immutable tag를 만들고 `application-cluster` 저장소의 kustomization.yaml에 새 태그를 커밋한 뒤, n8n webhook을 호출해서 Slack 승인을 요청한다. ```yaml # deploy.yml - prod 배포 핵심 부분 - name: Generate immutable tag # git SHA 기반 immutable tag 생성 (예: v1.2.3-abc1234) -- name: Update podo-cluster manifest +- name: Update application-cluster manifest # kustomization.yaml의 newTag를 immutable tag로 업데이트 - # podo-cluster 저장소에 자동 커밋 + # application-cluster 저장소에 자동 커밋 - name: Request Slack deploy approval # n8n webhook 호출 → Slack 승인 요청 전송 @@ -89,7 +89,7 @@ prod은 좀 더 단계가 있다. git SHA 기반으로 immutable tag를 만들 ### 2. ArgoCD - 클러스터 상태 관리 -ArgoCD는 Git 저장소(podo-cluster)의 manifest와 실제 클러스터 상태를 계속 비교하면서 동기화해준다. 이게 GitOps의 핵심이다. +ArgoCD는 Git 저장소(application-cluster)의 manifest와 실제 클러스터 상태를 계속 비교하면서 동기화해준다. 이게 GitOps의 핵심이다. **ApplicationSet으로 환경별 관리:** @@ -97,13 +97,13 @@ ArgoCD는 Git 저장소(podo-cluster)의 manifest와 실제 클러스터 상태 apiVersion: argoproj.io/v1alpha1 kind: ApplicationSet metadata: - name: podo-apps-stage + name: service-apps-stage spec: generators: - list: elements: - - appName: podo-backend - - appName: podo-web + - appName: backend-api + - appName: web-app # ... 다른 서비스들 template: spec: @@ -143,7 +143,7 @@ sequenceDiagram n8n을 선택한 이유가 몇 가지 있다: -- **시각적 워크플로우 빌더** - 노드를 끌어다 놓으면서 흐름을 만들 수 있어서, 나중에 다른 팀원이 봐도 바로 이해할 수 있다 +- **시각적 워크플로우 빌더** - 노드를 끌어다 놓으면서 흐름을 만들 수 있어서 나중에 다른 팀원이 봐도 바로 이해할 수 있다 - **셀프호스팅** - EKS 클러스터 안에서 돌리고 있어서 외부 의존성이 없다 - **재사용성** - 환경 변수만 바꾸면 같은 워크플로우를 여러 서비스에 적용할 수 있다 @@ -154,8 +154,8 @@ n8n을 선택한 이유가 몇 가지 있다: ### 4. kustomize - 환경별 설정 분리 ``` -podo-cluster/ -├── podo-backend/ +application-cluster/ +├── backend-api/ │ ├── base/ │ │ ├── deployment.yaml │ │ ├── service.yaml @@ -169,7 +169,7 @@ podo-cluster/ │ └── kustomization.yaml ← images.newTag 포함 ``` -base에 공통 리소스를 두고, overlay에서 환경별 차이만 덮어쓴다. prod overlay에만 `images` 섹션에 immutable tag가 들어간다. +base에 공통 리소스를 두고 overlay에서 환경별 차이만 덮어쓴다. prod overlay에만 `images` 섹션에 immutable tag가 들어간다. ## 환경별 전략 비교 @@ -185,7 +185,7 @@ base에 공통 리소스를 두고, overlay에서 환경별 차이만 덮어쓴 ### ArgoCD prod 도입을 일단 미뤘던 이야기 -처음에는 ArgoCD를 바로 prod에도 적용하려고 했다. 그런데 배포 과정에서 롤백 이슈가 터졌고, dev/stage에서 충분히 검증하지 않은 상태에서 prod을 건드리는 건 위험하다고 판단했다. 그래서 dev/stage에서 먼저 안정화하고, 그 사이에 n8n 기반 승인 게이트를 만들어서 prod에는 좀 더 조심스럽게 적용했다. +처음에는 ArgoCD를 바로 prod에도 적용하려고 했다. 그런데 배포 과정에서 롤백 이슈가 터졌고 dev/stage에서 충분히 검증하지 않은 상태에서 prod을 건드리는 건 위험하다고 판단했다. 그래서 dev/stage에서 먼저 안정화하고 그 사이에 n8n 기반 승인 게이트를 만들어서 prod에는 좀 더 조심스럽게 적용했다. ### stage 자동배포로 전환한 이유 @@ -203,7 +203,7 @@ flowchart LR end ``` -ApplicationSet에 서비스를 다시 추가하고, standalone Application을 삭제하는 방식으로 전환했다. `preserveResourcesOnDeletion: true` 덕분에 Application을 삭제해도 실제 k8s 리소스는 그대로 유지돼서, 서비스 중단 없이 전환할 수 있었다. +ApplicationSet에 서비스를 다시 추가하고 standalone Application을 삭제하는 방식으로 전환했다. `preserveResourcesOnDeletion: true` 덕분에 Application을 삭제해도 실제 k8s 리소스는 그대로 유지돼서, 서비스 중단 없이 전환할 수 있었다. ### readiness probe 때문에 자동 롤백되던 문제 @@ -220,7 +220,7 @@ ApplicationSet에 서비스를 다시 추가하고, standalone Application을 ## 마무리 -GitOps는 "Git으로 배포한다"가 아니라, Git을 클러스터 상태의 유일한 진실 공급원으로 삼아서 **선언적**이고 **추적 가능**하며 **되돌릴 수 있는** 배포 체계를 만드는 것이다. +GitOps는 "Git으로 배포한다"가 아니라, Git을 클러스터 상태의 유일한 진실 공급원으로 삼아 **선언적**이고 **추적 가능**하며 **되돌릴 수 있는** 배포 체계를 만드는 방식이다. ArgoCD, n8n, GitHub Actions 각각의 역할이 명확하다: diff --git a/content/posts/ko/i18n-01-foundation.md b/content/posts/ko/i18n-01-foundation.md index ed23614..8e9d5f0 100644 --- a/content/posts/ko/i18n-01-foundation.md +++ b/content/posts/ko/i18n-01-foundation.md @@ -1,7 +1,7 @@ --- title: "서비스 국제화 적용기 (1) - 하드코딩된 한국어를 걷어내며" date: "2026-05-19" -description: "일본 진출을 앞두고 시작한 프론트엔드 국제화. 로케일 모델을 어떻게 정하고, 메시지를 어떻게 관리하고, 로케일을 온 시스템에 어떻게 흘려보냈는지 정리한다." +description: "일본 진출을 앞두고 시작한 프론트엔드 국제화. 로케일 모델을 어떻게 정하고 메시지를 어떻게 관리하고 로케일을 온 시스템에 어떻게 흘려보냈는지 정리한다." tags: ["i18n", "nextjs", "react", "typescript"] series: "서비스 국제화 적용기" seriesOrder: 1 @@ -10,18 +10,18 @@ draft: false ## 배경 -일본 진출이 로드맵에 잡히면서, 그동안 미뤄왔던 국제화(i18n)가 발등에 떨어졌다. 문제는 서비스가 "한국어로만 돌아가는 걸 전제로" 몇 년을 자라왔다는 점이다. 화면 문구는 컴포넌트 곳곳에 문자열로 박혀 있었고, 날짜는 한국 포맷, 가격은 원화 고정, 서버가 내려준 한국어 메시지를 그대로 화면에 뿌리는 곳도 있었다. +일본 진출이 로드맵에 잡히면서 그동안 미뤄왔던 국제화(i18n)가 발등에 떨어졌다. 문제는 서비스가 "한국어로만 돌아가는 걸 전제로" 몇 년을 자라왔다는 점이다. 화면 문구는 컴포넌트 곳곳에 문자열로 박혀 있었고 날짜는 한국 포맷, 가격은 원화 고정, 서버가 내려준 한국어 메시지를 그대로 화면에 뿌리는 곳도 있었다. -"영어·일본어만 추가하면 되는 거 아냐?"로 시작했지만, 실제로는 **하드코딩된 한국어를 전부 찾아내 걷어내는 것**부터가 일이었다. 이 글은 그 첫 단계 - 로케일 모델을 정하고, 메시지를 관리 체계로 옮기고, 로케일을 시스템 전체에 흘려보내기까지 - 를 정리한다. +"영어·일본어만 추가하면 되는 거 아냐?"로 시작했지만 실제로는 **하드코딩된 한국어를 전부 찾아내 걷어내는 것**부터가 일이었다. 이 글은 그 첫 단계 - 로케일 모델을 정하고 메시지를 관리 체계로 옮기고 로케일을 시스템 전체에 흘려보내기까지 - 를 정리한다. -## 무엇부터: 로케일을 어떻게 정할 것인가 +## 로케일을 먼저 정한다 가장 먼저 정한 건 "이 사용자의 언어를 무엇으로 볼 것인가"였다. 우리는 사용자 설정값(`LANGUAGE_CODE`)으로 네 가지를 뒀다. - `ko` / `en` / `ja` - 사용자가 명시적으로 고른 언어 - `system` - 기기·브라우저 언어를 따라감 -`system`일 때는 브라우저(또는 네이티브 WebView)의 시스템 언어를 읽고, 지원하지 않는 언어면 `en`으로 폴백한다. 이렇게 **저장값과 실제 화면에 쓸 언어를 분리**했다. 저장값은 `system | ko | en | ja`로 두고, 화면용으로는 항상 확정된 `effectiveLocale`(ko/en/ja 중 하나)을 계산해서 쓴다. +`system`일 때는 브라우저(또는 네이티브 WebView)의 시스템 언어를 읽고 지원하지 않는 언어면 `en`으로 폴백한다. 이렇게 **저장값과 실제 화면에 쓸 언어를 분리**했다. 저장값은 `system | ko | en | ja`로 두고 화면용으로는 항상 확정된 `effectiveLocale`(ko/en/ja 중 하나)을 계산해서 쓴다. ```mermaid flowchart TB @@ -35,11 +35,11 @@ flowchart TB F --> G ``` -저장값과 표시값을 나눈 덕분에, "시스템 따라가기"를 고른 사용자는 기기 언어를 바꾸면 앱도 따라 바뀌고, 특정 언어를 명시적으로 고른 사용자는 그 선택이 유지된다. +저장값과 표시값을 나눈 덕분에, "시스템 따라가기"를 고른 사용자는 기기 언어를 바꾸면 앱도 따라 바뀌고 특정 언어를 명시적으로 고른 사용자는 그 선택이 유지된다. ## 메시지는 코드가 아니라 데이터다 -두 번째로 한 일은 화면에 박힌 문자열을 **메시지 카탈로그**로 옮기는 것이었다. 로케일별 JSON(`ko.json` / `en.json` / `ja.json`)을 두고, 컴포넌트는 문자열 대신 키를 참조한다. +두 번째로 한 일은 화면에 박힌 문자열을 **메시지 카탈로그**로 옮기는 것이었다. 로케일별 JSON(`ko.json` / `en.json` / `ja.json`)을 두고 컴포넌트는 문자열 대신 키를 참조한다. ```tsx // Before @@ -56,9 +56,9 @@ flowchart TB { "booking": { "submit": "予約する" } } ``` -여기서 중요한 원칙 하나. **git의 JSON이 단일 진실 원천**(SSOT)이다. 번역 문구는 코드 저장소 안의 JSON에서 관리하고, 배포 채널(다음 편에서 다룰 GCS)은 그걸 실어 나르는 전송 계층일 뿐이다. +여기서 중요한 원칙 하나. **git의 JSON이 단일 진실 원천**(SSOT)이다. 번역 문구는 코드 저장소 안의 JSON에서 관리하고 배포 채널(다음 편에서 다룰 GCS)은 그걸 실어 나르는 전송 계층일 뿐이다. -문제는 키가 수백, 수천 개로 늘면 **로케일 간 키 누락**이 반드시 생긴다는 것이다. `ko.json`에만 있고 `ja.json`엔 빠진 키가 있으면 그 자리에 한국어가 튀어나오거나 키 문자열이 그대로 노출된다. 그래서 세 로케일의 키 집합이 어긋나면 걸러내는 **검증 스크립트**를 CI에 붙였다. +문제는 키가 수백, 수천 개로 늘면 **로케일 간 키 누락**이 반드시 생긴다는 점이다. `ko.json`에만 있고 `ja.json`엔 빠진 키가 있으면 그 자리에 한국어가 튀어나오거나 키 문자열이 그대로 노출된다. 그래서 세 로케일의 키 집합이 어긋나면 걸러내는 **검증 스크립트**를 CI에 붙였다. ```bash node scripts/validate-i18n-messages.mjs @@ -81,7 +81,7 @@ node scripts/validate-i18n-messages.mjs 국제화의 1단계는 화려한 기능이 아니라 **정리**였다. - 저장값(`system | ko | en | ja`)과 표시값(`effectiveLocale`)을 분리 -- 문자열을 git JSON 메시지 카탈로그로 이관하고, 키 누락을 CI로 방어 +- 문자열을 git JSON 메시지 카탈로그로 이관하고 키 누락을 CI로 방어 - 로케일을 html·dayjs·Accept-Language·네이티브 WebView까지 일관 전파 문구를 JSON으로 옮기고 나니 다음 질문이 남았다. **이 언어팩을 앱과 함께 배포할 것인가, 런타임에 따로 받아올 것인가?** 오탈자 한 줄 고칠 때마다 앱을 새로 배포할 순 없으니까. 다음 편에서 언어팩을 번들·GCS 런타임 로드·폴백으로 서빙한 이야기를 다룬다. diff --git a/content/posts/ko/i18n-02-language-pack-gcs.md b/content/posts/ko/i18n-02-language-pack-gcs.md index fe76f5d..953cc4c 100644 --- a/content/posts/ko/i18n-02-language-pack-gcs.md +++ b/content/posts/ko/i18n-02-language-pack-gcs.md @@ -1,20 +1,20 @@ --- title: "서비스 국제화 적용기 (2) - 언어팩 서빙: 번들, GCS 런타임 로드, 그리고 캐시" date: "2026-06-03" -description: "번역을 앱과 함께 배포하면 문구 한 줄 고칠 때마다 배포해야 한다. 언어팩을 GCS에서 런타임 로드하고 번들로 폴백한 구조, 그리고 배포해도 안 바뀌던 캐시 문제를 정리한다." +description: "번역을 앱과 함께 배포하면 문구 한 줄 고칠 때마다 배포가 필요하다. 언어팩을 GCS에서 런타임 로드하고 번들로 폴백한 구조, 그리고 배포해도 안 바뀌던 캐시 문제를 정리한다." tags: ["i18n", "nextjs", "gcs", "typescript"] series: "서비스 국제화 적용기" seriesOrder: 2 draft: false --- -## 문제: 번역을 앱에 넣으면 문구 수정마다 배포다 +## 번역을 앱에 넣으면 문구 수정마다 배포다 -[1편](/posts/i18n-01-foundation)에서 화면 문구를 로케일별 JSON으로 옮겼다. 그럼 이 JSON을 언제 로드할까? 가장 쉬운 건 **빌드 번들에 포함**하는 것이다. 하지만 그러면 오탈자 하나, 문구 한 줄을 고칠 때마다 앱을 다시 빌드·배포해야 한다. 번역은 운영 중에 가장 자주 바뀌는 것 중 하나인데 말이다. +[1편](/posts/i18n-01-foundation)에서 화면 문구를 로케일별 JSON으로 옮겼다. 그럼 이 JSON을 언제 로드할까? 가장 쉬운 방법은 **빌드 번들에 포함**하는 방식이다. 하지만 그러면 오탈자 하나, 문구 한 줄을 고칠 때마다 앱을 다시 빌드·배포하게 된다. 번역은 운영 중에 가장 자주 바뀌는 것 중 하나인데 말이다. 그래서 목표를 이렇게 잡았다. **문구는 배포 없이 바꾸되, 무슨 일이 있어도 화면은 깨지지 않게.** -## 선택: 런타임 로드 + 번들 폴백 +## 런타임 로드 + 번들 폴백 번들과 런타임 로드는 트레이드오프가 있다. @@ -43,9 +43,9 @@ await bucket.file('intl/metadata.json') 타임스탬프 버전드 팩 + 포인터(`metadata.json`) 구조라, 롤백도 metadata만 되돌리면 된다. 인증은 키파일 없이 Workload Identity/ADC를 쓴다. -## 런타임 로더: metadata를 읽고, 실패는 번들로 +## metadata를 읽고 실패하면 번들로 돌아간다 -로드는 서버(App Router 서버 컴포넌트)에서 한다. `metadata.json`을 읽어 각 로케일 팩을 받아오고, **로케일 단위 실패는 그 로케일만 번들로 폴백**한다. 버킷 미설정이나 metadata 자체 실패 같은 전체 실패는 통째로 번들 카탈로그로 폴백한다. +로드는 서버(App Router 서버 컴포넌트)에서 한다. `metadata.json`을 읽어 각 로케일 팩을 받아오고 **로케일 단위 실패는 그 로케일만 번들로 폴백**한다. 버킷 미설정이나 metadata 자체 실패 같은 전체 실패는 통째로 번들 카탈로그로 폴백한다. ```ts const loadFromGcs = async () => { @@ -88,7 +88,7 @@ export const loadLanguagePacks = () => 처음엔 `revalidate: false`로 뒀다. "언어팩은 발행할 때 태그로 revalidate하면 되지"라는 생각이었다. 그런데 **배포를 해도 옛날 문구가 계속 나왔다.** -원인은 캐시 저장소였다. 우리 배포는 `cache-handler`로 Next 캐시를 **Redis에 영구 저장**한다. `revalidate: false`는 사실상 "무기한 캐시"라, 앱을 새로 배포해도 Redis에 남아 있는 구(舊) 카탈로그가 그대로 서빙됐다. 빌드가 바뀌어도 캐시 키가 같으니 안 풀린 것이다. +원인은 캐시 저장소였다. 우리 배포는 `cache-handler`로 Next 캐시를 **Redis에 영구 저장**한다. `revalidate: false`는 사실상 "무기한 캐시"라, 앱을 새로 배포해도 Redis에 남아 있는 구(舊) 카탈로그가 그대로 서빙됐다. 빌드가 바뀌어도 캐시 키가 같으니 풀리지 않았다. 해결은 단순했다. **TTL을 부여**했다(`revalidate: 600`). 발행 시 태그 revalidate로 즉시 갱신하되, 혹시 그게 누락돼도 최대 10분이면 자연히 새 팩으로 넘어간다. 영구 캐시에 "만료"라는 안전망을 하나 더 둔 셈이다. @@ -100,4 +100,4 @@ export const loadLanguagePacks = () => - 런타임 로드하되 로케일 단위·전체 단위로 번들 폴백 - 영구 캐시에는 TTL이라는 안전망을 둬서 "배포해도 안 바뀌는" 사고를 막음 -이제 문구는 배포 없이 바꿀 수 있고, 무슨 일이 있어도 화면은 뜬다. 남은 건 **이 국제화를 실제 사용자에게 어떻게 안전하게 켜느냐**다. 다음 편에서 GrowthBook 킬스위치로 i18n을 무중단 점진 롤아웃한 이야기를 다룬다. +이제 문구는 배포 없이 바꿀 수 있고 무슨 일이 있어도 화면은 뜬다. 남은 건 **이 국제화를 실제 사용자에게 어떻게 안전하게 켜느냐**다. 다음 편에서 GrowthBook 킬스위치로 i18n을 무중단 점진 롤아웃한 이야기를 다룬다. diff --git a/content/posts/ko/i18n-03-rollout-killswitch.md b/content/posts/ko/i18n-03-rollout-killswitch.md index 4172eeb..00fa79c 100644 --- a/content/posts/ko/i18n-03-rollout-killswitch.md +++ b/content/posts/ko/i18n-03-rollout-killswitch.md @@ -1,7 +1,7 @@ --- title: "서비스 국제화 적용기 (3) - GrowthBook 킬스위치로 무중단 점진 롤아웃" date: "2026-06-17" -description: "번역을 다 만들었어도 전원에게 한 번에 켜면 위험하다. 로케일 결정을 단일 차단점으로 모으고, GrowthBook 킬스위치로 i18n을 무중단·점진 롤아웃한 이야기." +description: "번역을 다 만들었어도 전원에게 한 번에 켜면 위험하다. 로케일 결정을 단일 차단점으로 모으고 GrowthBook 킬스위치로 i18n을 무중단·점진 롤아웃한 이야기." tags: ["i18n", "nextjs", "growthbook", "feature-flag"] series: "서비스 국제화 적용기" seriesOrder: 3 @@ -10,7 +10,7 @@ draft: false ## 배경 -1·2편에서 문구를 메시지 카탈로그로 옮기고, 언어팩을 GCS에서 서빙하는 것까지 만들었다. 그럼 이제 전 사용자에게 켜면 될까? 아니다. i18n을 한 번에 전면 오픈하는 건 위험하다. +1·2편에서 문구를 메시지 카탈로그로 옮기고 언어팩을 GCS에서 서빙하는 것까지 만들었다. 그럼 이제 전 사용자에게 켜면 될까? 아니다. i18n을 한 번에 전면 오픈하는 건 위험하다. - 번역이 빠진 화면이 어딘가 남아 한국어가 튀어나올 수 있고 - 일본어·영어는 같은 문장도 길이가 달라 레이아웃이 깨질 수 있고 @@ -18,11 +18,11 @@ draft: false 그래서 목표는 하나였다. **점진적으로 켜되, 문제가 생기면 배포 없이 즉시 되돌린다.** 그 장치가 GrowthBook 킬스위치다. -## 로케일 결정을 한 곳으로: 단일 차단점 +## 로케일 결정을 한 곳으로 모은다 킬스위치가 제대로 동작하려면, "이 요청의 로케일은 무엇인가"를 결정하는 지점이 **딱 한 곳**이어야 한다. 여기저기서 각자 로케일을 정하면, 킬스위치를 꺼도 어딘가는 여전히 일본어로 나온다. -그래서 `resolveEffectiveLocale` 하나로 결정을 모으고, 여기에 플래그(`i18nEnabled`)를 주입했다. +그래서 `resolveEffectiveLocale` 하나로 결정을 모으고 여기에 플래그(`i18nEnabled`)를 주입했다. ```ts export const resolveEffectiveLocale = ({ @@ -53,7 +53,7 @@ const i18nEnabled = flagResult.values[FEATURE_FLAG_KEYS.I18N_ENABLED]?.enabled ? ``` -서버는 플래그를 읽어 `html lang`과 첫 렌더를 정하고, 클라이언트 프로바이더는 같은 플래그로 같은 `effectiveLocale`을 계산한다. 플래그가 꺼져 있으면 서버·클라이언트 모두 `ko`라, 렌더 결과가 어긋나지 않는다. +서버는 플래그를 읽어 `html lang`과 첫 렌더를 정하고 클라이언트 프로바이더는 같은 플래그로 같은 `effectiveLocale`을 계산한다. 플래그가 꺼져 있으면 서버·클라이언트 모두 `ko`라, 렌더 결과가 어긋나지 않는다. ## 되돌릴 수 있어야 진짜 무중단 @@ -62,7 +62,7 @@ const i18nEnabled = flagResult.values[FEATURE_FLAG_KEYS.I18N_ENABLED]?.enabled ? - **점진 롤아웃** - GrowthBook에서 특정 비율·특정 사용자 그룹에만 먼저 켠다 - **즉시 차단** - 번역 사고가 발견되면 플래그를 끄는 순간 전원이 `ko`로 안전 복귀한다. 롤백 배포를 기다릴 필요가 없다 -"기능을 못 되돌리면 못 켠다"가 원칙이었고, 킬스위치가 그 원칙을 지켜줬다. +"기능을 못 되돌리면 못 켠다"가 원칙이었고 킬스위치가 그 원칙을 지켜줬다. ## 테스트는 ko로 고정 diff --git a/content/posts/ko/i18n-04-backend-locale.md b/content/posts/ko/i18n-04-backend-locale.md index 4db08b2..4e8509c 100644 --- a/content/posts/ko/i18n-04-backend-locale.md +++ b/content/posts/ko/i18n-04-backend-locale.md @@ -1,7 +1,7 @@ --- title: "서비스 국제화 적용기 (4) - 백엔드가 할 몫: 사용자 언어·시간대와 문서 로케일" date: "2026-06-26" -description: "프론트만 번역하면 서버가 한국어 응답·문서로 다시 한국어를 섞는다. 백엔드가 사용자 언어·시간대를 저장하고, 인증 컨텍스트로 들고 다니며, 응답과 문서까지 로케일을 흘려보낸 이야기." +description: "프론트만 번역하면 서버가 한국어 응답·문서로 다시 한국어를 섞는다. 백엔드가 사용자 언어·시간대를 저장하고 인증 컨텍스트로 들고 다니며 응답과 문서까지 로케일을 흘려보낸 이야기." tags: ["i18n", "spring-boot", "java", "backend"] series: "서비스 국제화 적용기" seriesOrder: 4 @@ -61,14 +61,14 @@ public class AuthenticatedUserDto { } ``` -이제 어떤 서비스든 `@AuthenticationPrincipal`로 받은 사용자에서 곧바로 언어를 알 수 있다. 값이 없으면 `"system"`으로 두고, 실제 로케일은 프론트와 같은 규칙(시스템 언어 → 미지원 시 en)으로 해석한다. +이제 어떤 서비스든 `@AuthenticationPrincipal`로 받은 사용자에서 곧바로 언어를 알 수 있다. 값이 없으면 `"system"`으로 두고 실제 로케일은 프론트와 같은 규칙(시스템 언어 → 미지원 시 en)으로 해석한다. ## 응답과 문서까지 로케일을 흘려보낸다 사용자 언어를 알게 됐으니, 이제 그걸 실제 출력에 반영한다. - **API 응답** - 1편에서 프론트가 실어 보낸 `Accept-Language`를 서버가 읽어 같은 언어로 응답한다 -- **발급 문서** - 수강확인증·레벨테스트 리포트처럼 PDF로 나가는 문서에도 언어를 전파한다. 예를 들어 레벨테스트 리포트 링크에는 `?lang=`으로 언어를 붙이고, 수강확인증 발급 요청 페이로드에도 언어 값을 실어 보낸다 +- **발급 문서** - 수강확인증·레벨테스트 리포트처럼 PDF로 나가는 문서에도 언어를 전파한다. 예를 들어 레벨테스트 리포트 링크에는 `?lang=`으로 언어를 붙이고 수강확인증 발급 요청 페이로드에도 언어 값을 실어 보낸다 ```java // 레벨테스트 리포트 링크에 언어 전파 @@ -79,8 +79,8 @@ extras.put("reportLink", appUrl + "/level-test/report?lang=" + dto.getLanguage() ## 시간은 UTC로 통일 -로케일에는 언어뿐 아니라 시간대도 걸려 있다. 그동안 서버가 KST 기준으로 동작하던 부분이 있었는데, 해외 사용자가 들어오면 예약·수업 시간이 어긋나기 시작한다. 그래서 저장·연산은 **UTC 기준으로 통일**하고, 시간대 변환은 표시 계층(사용자 `timezoneCode`)에서만 하도록 정리했다. 시차 버그는 대개 "어디선가 로컬 시간으로 계산"에서 나오는데, 기준을 UTC 한 곳으로 모으니 그런 함정이 줄었다. +로케일에는 언어뿐 아니라 시간대도 걸려 있다. 그동안 서버가 KST 기준으로 동작하던 부분이 있었는데, 해외 사용자가 들어오면 예약·수업 시간이 어긋나기 시작한다. 그래서 저장·연산은 **UTC 기준으로 통일**하고 시간대 변환은 표시 계층(사용자 `timezoneCode`)에서만 하도록 정리했다. 시차 버그는 대개 "어디선가 로컬 시간으로 계산"에서 나오는데, 기준을 UTC 한 곳으로 모으니 그런 함정이 줄었다. ## 마치며 -이제 백엔드도 사용자 언어를 알고, API 응답과 발급 문서 경로에 로케일을 실어 보낸다. 그런데 정작 그 **문서 자체는 어떻게 다국어로 렌더링**할까? 수강확인증·리포트는 오랫동안 언어가 이미지에 박혀 있어서, 영어·일본어판을 만들려면 배경 이미지를 다시 그려야 했다. 마지막 편에서 이미지로 박아두던 문서를 HTML 렌더링으로 옮겨 다국어를 지원한 이야기를 다룬다. +이제 백엔드도 사용자 언어를 알고 API 응답과 발급 문서 경로에 로케일을 실어 보낸다. 그런데 정작 그 **문서 자체는 어떻게 다국어로 렌더링**할까? 수강확인증·리포트는 오랫동안 언어가 이미지에 박혀 있어서 영어·일본어판을 만들려면 배경 이미지를 다시 그려야 했다. 마지막 편에서 이미지로 박아두던 문서를 HTML 렌더링으로 옮겨 다국어를 지원한 이야기를 다룬다. diff --git a/content/posts/ko/i18n-05-document-render.md b/content/posts/ko/i18n-05-document-render.md index e2b929a..fd6e0a3 100644 --- a/content/posts/ko/i18n-05-document-render.md +++ b/content/posts/ko/i18n-05-document-render.md @@ -8,7 +8,7 @@ seriesOrder: 5 draft: false --- -## 배경: 언어가 이미지에 구워져 있었다 +## 언어가 이미지에 구워져 있었다 [4편](/posts/i18n-04-backend-locale)에서 백엔드가 문서 발급 경로에 로케일을 실어 보내는 것까지 했다. 그런데 정작 문서가 그 로케일을 살리지 못했다. 수강확인증·리포트 같은 발급 문서가 오랫동안 **이미지 기반**이었기 때문이다. @@ -22,11 +22,11 @@ flowchart LR N["새 언어 추가"] -.->|"배경 PNG
통째로 재디자인"| L ``` -이러니 다국어가 될 리 없다. 문서의 **"내용"과 "표현"이 이미지 안에 한 덩어리로 붙어** 있어서, 언어(내용)를 바꾸려면 그림(표현)을 통째로 다시 그려야 했다. 영어·일본어판은 디자이너가 언어별 배경 PNG를 새로 만들어야 했고, 오탈자 하나 고치는 것조차 이미지 재작업이었다. +이러니 다국어가 될 리 없다. 문서의 **"내용"과 "표현"이 이미지 안에 한 덩어리로 붙어** 있어서 언어(내용)를 바꾸려면 그림(표현)을 통째로 다시 그려야 했다. 영어·일본어판은 디자이너가 언어별 배경 PNG를 새로 만들어야 했고 오탈자 하나 고치는 것조차 이미지 재작업이었다. -## HTML로 다시 짜기: 내용과 표현을 가른다 +## HTML로 다시 짜고 내용과 표현을 가른다 -방향은 하나였다. **이미지에 뭉쳐 있던 내용과 표현을 갈라내는 것.** 표현(레이아웃)은 HTML/CSS 템플릿에, 내용(문구)은 언어별 JSON에 두고, 이미지는 **언어와 무관한 에셋(로고·직인·일러스트)만** 남긴다. +방향은 하나였다. **이미지에 뭉쳐 있던 내용과 표현을 갈라내는 것.** 표현(레이아웃)은 HTML/CSS 템플릿에, 내용(문구)은 언어별 JSON에 두고 이미지는 **언어와 무관한 에셋(로고·직인·일러스트)만** 남긴다. ```mermaid flowchart LR @@ -46,7 +46,7 @@ assets/ # 로고·직인 (언어 무 fonts/ # Pretendard (+ 일본어는 Noto CJK) ``` -그 결과 남은 이미지는 로고와 직인 정도뿐이고, 제목·표·유의사항·증명 문구·날짜·회사명은 전부 텍스트가 됐다. 이미지 10장으로 붙이던 막대그래프조차 CSS `height: N%` div 하나로 바뀌었다. 표현이 코드로 내려오니, 값도 템플릿 변수로 주입된다. +그 결과 남은 이미지는 로고와 직인 정도뿐이고 제목·표·유의사항·증명 문구·날짜·회사명은 전부 텍스트가 됐다. 이미지 10장으로 붙이던 막대그래프조차 CSS `height: N%` div 하나로 바뀌었다. 표현이 코드로 내려오니, 값도 템플릿 변수로 주입된다. 가장 큰 소득은 이거다. **새 언어를 추가하는 일 = JSON 파일 하나 추가.** 디자이너 왕복도, 배경 PNG 재작업도 없다. @@ -60,7 +60,7 @@ fonts/ # Pretendard (+ 일본어는 N } ``` -## 렌더러 선택: WeasyPrint vs 헤드리스 브라우저 +## WeasyPrint와 헤드리스 브라우저 사이에서 HTML을 PDF로 굽는 방법은 크게 둘이었다. @@ -69,9 +69,9 @@ HTML을 PDF로 굽는 방법은 크게 둘이었다. | **WeasyPrint** (Python) | 페이지 나눔·머리글·페이지번호 지원, 브라우저 불필요(컨테이너 가벼움) | 최신 CSS 일부 미지원 | | **Puppeteer/Playwright** (Chromium) | 모던 CSS 완벽, 웹과 100% 동일 렌더링 | Chromium을 통째로 담아 컨테이너가 무거워짐 | -우리 문서는 A4 고정 레이아웃이라 화려한 CSS가 필요 없었고, 기존 생성기가 이미 Python이었다. 그래서 **전환 비용이 가장 낮은 WeasyPrint**를 골랐다. 한 가지 함정은 폰트였다. WeasyPrint는 **컨테이너에 설치된 폰트로 렌더링**하기 때문에, 한글용 Pretendard를 번들하고 일본어 글리프는 Docker 이미지에 Noto CJK를 심어 해결했다. 폰트가 없으면 글자가 두부(□)로 깨진다. +우리 문서는 A4 고정 레이아웃이라 화려한 CSS가 필요 없었고 기존 생성기가 이미 Python이었다. 그래서 **전환 비용이 가장 낮은 WeasyPrint**를 골랐다. 한 가지 함정은 폰트였다. WeasyPrint는 **컨테이너에 설치된 폰트로 렌더링**하기 때문에, 한글용 Pretendard를 번들하고 일본어 글리프는 Docker 이미지에 Noto CJK를 심어 해결했다. 폰트가 없으면 글자가 두부(□)로 깨진다. -## 렌더링 파이프라인: 백엔드는 이벤트만 쏜다 +## 백엔드는 이벤트만 쏜다 렌더링은 백엔드(Spring)가 아니라 별도 Cloud Run 서비스(`document-render`)가 맡는다. PDF 렌더링은 폰트·레이아웃 계산으로 무겁고 Python(WeasyPrint) 생태계가 유리해서, Java 프로세스에 끼워 넣지 않고 별도 서비스로 뒀다. 문서 발급은 즉시 응답을 기다리는 작업도 아니라, 백엔드는 발급 이벤트만 발행하고 블로킹 없이 다음 일을 한다. @@ -85,19 +85,19 @@ flowchart TB BE -->|"⑤ locale별 메일 템플릿
+ PDF 링크 발송"| MAIL["사용자 메일"] ``` -핵심은 렌더 → 업로드 → 콜백 → 메일이 한 줄로 이어진다는 것이고, 그 위로 로케일이 파이프라인 전체를 관통한다. +핵심은 렌더 → 업로드 → 콜백 → 메일이 한 줄로 이어진다는 것이고 그 위로 로케일이 파이프라인 전체를 관통한다. 1. 백엔드가 발급 이벤트를 Pub/Sub에 발행한다 - 이때 payload에 `locale`을 함께 싣는다 2. Eventarc가 이벤트를 받아 렌더 서비스로 `POST /` 한다 3. 렌더 서비스가 `locale`로 템플릿과 문자열을 골라 WeasyPrint로 PDF를 렌더한다 -4. 완성된 PDF를 GCS에 업로드하고, **업로드가 끝나면 백엔드로 콜백**한다 - 이때 PDF의 URL과 `locale`을 함께 넘긴다 -5. 백엔드는 echo된 `locale`로 언어별 메일 템플릿(`..._en`, `..._ja`)을 고르고, GCS에 올라간 PDF 링크를 담아 메일을 발송한다 +4. 완성된 PDF를 GCS에 업로드하고 **업로드가 끝나면 백엔드로 콜백**한다 - 이때 PDF의 URL과 `locale`을 함께 넘긴다 +5. 백엔드는 echo된 `locale`로 언어별 메일 템플릿(`..._en`, `..._ja`)을 고르고 GCS에 올라간 PDF 링크를 담아 메일을 발송한다 한 가지 신경 쓴 건 하위호환이다. payload에 `locale`이 없거나 미지원 값이면 렌더 서비스가 **`ko`로 폴백**한다. 로케일을 실어 보내지 않던 구버전 이벤트도 그대로 한국어 문서로 동작한다는 뜻이다. 그리고 dev에서는 `GET /preview?locale=ja` 같은 프리뷰 엔드포인트로 브라우저에서 바로 결과를 확인해, 디자인 반복 주기를 짧게 가져갔다. ## 흩어진 생성기를 하나로 -문서 생성기는 원래 여러 개로 흩어져 있었다. 전부 소규모 Python이라, **하나의 렌더링 서비스로 통합**했다. 폰트·locale 문자열·프리뷰 하니스·CI를 한 벌로 관리하기 위해서다. 문서가 늘어날수록 "폰트 세팅과 로케일 처리를 매번 새로 하는" 비용이 커지는데, 그걸 한곳에 모은 것이다. +문서 생성기는 원래 여러 개로 흩어져 있었다. 전부 소규모 Python이라, **하나의 렌더링 서비스로 통합**했다. 폰트·locale 문자열·프리뷰 하니스·CI를 한 벌로 관리하기 위해서다. 문서가 늘어날수록 "폰트 세팅과 로케일 처리를 매번 새로 하는" 비용이 커지기 때문에 그 작업을 한곳에 모았다. 전환도 부드러웠다. 백엔드는 Pub/Sub 토픽에만 발행하니, 컷오버는 **Eventarc 트리거를 새 서비스로 재지정**하기만 하면 된다. 백엔드 배포도 필요 없다. 문제가 생기면 트리거를 기존 함수로 원복하면 그대로 롤백이다. "되돌릴 수 있어야 켠다"는 [3편](/posts/i18n-03-rollout-killswitch) 킬스위치의 원칙이 인프라 레벨에서도 그대로 적용된 셈이다. @@ -111,4 +111,4 @@ flowchart TB 4. **백엔드** - 사용자 언어·시간대, 인증 컨텍스트 전파, 응답·문서 로케일 5. **문서 렌더링** - 이미지 기반 → HTML(WeasyPrint), 생성기 통합, "새 언어 = JSON 하나" -사실 일본 진출은 아직 진행 중이다. 번역을 채우고, 롤아웃 범위를 넓히고, 남은 문서를 HTML로 옮기는 일은 지금도 계속되고 있다. 다만 방향은 분명해졌다. 언어를 코드에 박아두지 않고 **데이터로 다룰 수 있게** 바꾸는 것. 문구는 JSON으로, 문서는 이미지에서 HTML로 옮기고 나니 새 언어를 하나 붙이는 비용이 확 줄었다. 예전엔 디자이너가 배경 이미지를 다시 그려야 했지만, 이제는 문자열 파일 하나면 된다. 이 뼈대 위에서 다음 언어를 얹는 일은 처음보다 훨씬 가볍다. +사실 일본 진출은 아직 진행 중이다. 번역을 채우고 롤아웃 범위를 넓히고 남은 문서를 HTML로 옮기는 일은 지금도 계속되고 있다. 다만 방향은 분명해졌다. 언어를 코드에 박아두지 않고 **데이터로 다룰 수 있게** 바꾸는 것. 문구는 JSON으로, 문서는 이미지에서 HTML로 옮기고 나니 새 언어를 하나 붙이는 비용이 확 줄었다. 예전엔 디자이너가 배경 이미지를 다시 그려야 했지만 이제는 문자열 파일 하나면 된다. 이 뼈대 위에서 다음 언어를 얹는 일은 처음보다 훨씬 가볍다. diff --git a/content/posts/ko/jdk-migration-strategy-11-to-25.md b/content/posts/ko/jdk-migration-strategy-11-to-25.md index 06916e2..a3001dc 100644 --- a/content/posts/ko/jdk-migration-strategy-11-to-25.md +++ b/content/posts/ko/jdk-migration-strategy-11-to-25.md @@ -16,11 +16,11 @@ draft: false 미루고 미뤘지만 더 이상 버틸 수 없는 시점이 왔다. -가장 직접적인 문제는 **새 기능 개발이 막히기 시작한 것**이다. 신규 라이브러리들이 JDK 17+, Jakarta EE를 최소 요구사항으로 내걸면서, 도입하고 싶은 라이브러리가 있어도 호환성 때문에 포기하는 일이 반복됐다. 기존 라이브러리의 최신 버전도 마찬가지였다. 버그 픽스나 성능 개선이 담긴 업데이트를 적용하지 못하고 구버전에 머물러 있는 상황이 점점 잦아졌다. +가장 직접적인 문제는 **새 기능 개발이 막히기 시작한 것**이다. 신규 라이브러리들이 JDK 17+, Jakarta EE를 최소 요구사항으로 내걸면서 도입하고 싶은 라이브러리가 있어도 호환성 때문에 포기하는 일이 반복됐다. 기존 라이브러리의 최신 버전도 마찬가지였다. 버그 픽스나 성능 개선이 담긴 업데이트를 적용하지 못하고 구버전에 머물러 있는 상황이 점점 잦아졌다. -개발 생산성 측면에서도 JDK 11은 답답한 부분이 많았다. JDK 14+의 **Text Block**을 쓸 수 없어서 복잡한 SQL이나 JSON 문자열을 `+`로 이어붙이고 있었고, **Record** 클래스가 없으니 단순 데이터 전달용 DTO마다 getter/setter/equals/hashCode를 Lombok으로 달아야 했다. **Pattern Matching instanceof**도 없어서 타입 캐스팅할 때마다 `if (obj instanceof String) { String s = (String) obj; ... }` 같은 반복 코드가 쌓였다. 특히 네이티브 쿼리를 아직 많이 사용하고 있는 프로젝트 특성상, Text Block 없이 여러 줄 SQL을 관리하는 건 가독성 면에서 고통이었다. ~~네이티브 쿼리에 `+`와 `\n`이 난무하는 코드를 볼 때마다 JDK 올리고 싶었다.~~ +개발 생산성 측면에서도 JDK 11은 답답한 부분이 많았다. JDK 14+의 **Text Block**을 쓸 수 없어서 복잡한 SQL이나 JSON 문자열을 `+`로 이어붙이고 있었고 **Record** 클래스가 없으니 단순 데이터 전달용 DTO마다 getter/setter/equals/hashCode를 Lombok으로 달아야 했다. **Pattern Matching instanceof**도 없어서 타입 캐스팅할 때마다 `if (obj instanceof String) { String s = (String) obj; ... }` 같은 반복 코드가 쌓였다. 특히 네이티브 쿼리를 아직 많이 사용하고 있는 프로젝트 특성상, Text Block 없이 여러 줄 SQL을 관리하는 건 가독성 면에서 고통이었다. ~~네이티브 쿼리에 `+`와 `\n`이 난무하는 코드를 볼 때마다 JDK 올리고 싶었다.~~ -**Spring Boot 2.7.x OSS 지원 종료**도 트리거였다. 보안 패치가 끊기면 프로덕션에서 운영할 명분이 없다. Spring Boot 3.x로 올리려면 JDK 17 이상이 필수이고, 어차피 올릴 거면 LTS인 21까지 한 번에 가기로 했다. +**Spring Boot 2.7.x OSS 지원 종료**도 트리거였다. 보안 패치가 끊기면 프로덕션에서 운영할 명분이 없다. Spring Boot 3.x로 올리려면 JDK 17 이상이 필수이고 어차피 올릴 거면 LTS인 21까지 한 번에 가기로 했다. **AWS SDK v1의 유지보수 모드 전환** 역시 무시할 수 없었다. SQS, S3, Lambda를 적극적으로 사용하는 서비스 특성상 새로운 AWS 기능이 SDK v1에 추가되지 않는 건 실질적인 제약이었다. 특히 SQS의 비동기 처리 개선이나 S3의 새로운 API를 활용하려면 SDK v3가 필요했다. @@ -81,7 +81,7 @@ import jakarta.annotation.PostConstruct; > **주의**: `javax.crypto`, `javax.net.ssl` 같은 **Java SE 표준 패키지**는 변경하면 안 된다. 대상은 `javax.persistence`, `javax.annotation`, `javax.validation` 등 Jakarta EE 패키지만이다. -실제로 변경된 파일 수만 해도 엔티티와 컨버터 관련 파일이 100개 이상이었다. IDE의 전체 치환 기능을 사용하되, 반드시 변경 대상을 필터링해서 적용해야 한다. +실제로 변경된 파일 수만 해도 엔티티와 컨버터 관련 파일이 100개 이상이었다. IDE의 전체 치환 기능을 사용하되, 반드시 변경 대상을 필터링해서 적용한다. ## QueryDSL Jakarta 호환 @@ -197,17 +197,17 @@ runtimeOnly 'com.mysql:mysql-connector-j' ## Spring Security 마이그레이션 -Spring Boot 3.x에서 `WebSecurityConfigurerAdapter`가 완전히 제거되었다. `SecurityFilterChain` 빈 기반으로 전환해야 한다. 프로젝트마다 설정이 다르므로 상세 코드는 생략하지만, 핵심은 **상속 기반 → 빈 등록 기반** 패턴 전환이다. +Spring Boot 3.x에서 `WebSecurityConfigurerAdapter`가 완전히 제거되었다. `SecurityFilterChain` 빈 기반으로 전환한다. 프로젝트마다 설정이 다르므로 상세 코드는 생략하지만 핵심은 **상속 기반 → 빈 등록 기반** 패턴 전환이다. ## 삽질한 것들 ### QueryDSL jakarta classifier 누락 -가장 찾기 어려웠던 버그다. Q클래스 생성도 정상, 컴파일도 정상인데 런타임에 `ClassNotFoundException`이 터진다. `:jakarta`를 빠뜨리면 QueryDSL이 내부적으로 `javax.persistence`를 참조하기 때문이다. 컴파일 타임에 잡히지 않으니 CI에서도 통과하고, 로컬 테스트에서도 통과하고, 실제 기동할 때 터진다. +가장 찾기 어려웠던 버그다. Q클래스 생성도 정상, 컴파일도 정상인데 런타임에 `ClassNotFoundException`이 터진다. `:jakarta`를 빠뜨리면 QueryDSL이 내부적으로 `javax.persistence`를 참조하기 때문이다. 컴파일 타임에 잡히지 않으니 CI에서도 통과하고 로컬 테스트에서도 통과하고 실제 기동할 때 터진다. ### AWS SDK v3 비동기 전환 -SDK v1의 동기 API에 익숙해져 있으면 v3의 `CompletableFuture` 기반 비동기 패턴이 낯설 수 있다. 기존 동기 코드를 그대로 `.join()`으로 감싸는 것도 방법이지만, 장기적으로는 비동기 패턴을 도입하는 것이 좋다. +SDK v1의 동기 API에 익숙해져 있으면 v3의 `CompletableFuture` 기반 비동기 패턴이 낯설 수 있다. 기존 동기 코드를 그대로 `.join()`으로 감싸는 것도 방법이지만 장기적으로는 비동기 패턴을 도입하는 것이 좋다. ### 219개 파일을 한 번에 바꾼 것 @@ -219,4 +219,4 @@ JDK 11 → 21은 단순한 버전업이 아니라 **Java 생태계 전체의 패 219개 파일을 한 번에 바꿨지만 프로덕션에 무사히 올라갔을 때의 안도감은 아직도 생생하다. -다음 편에서는 JDK 21에서 25로의 업그레이드를 다룬다. Phase 1에 비하면 훨씬 가벼운 작업이었지만, `sun.misc.Unsafe` 제거라는 런타임 수준의 변경 때문에 예상치 못한 곳에서 문제가 터졌다. +다음 편에서는 JDK 21에서 25로의 업그레이드를 다룬다. Phase 1에 비하면 훨씬 가벼운 작업이었지만 `sun.misc.Unsafe` 제거라는 런타임 수준의 변경 때문에 예상치 못한 곳에서 문제가 터졌다. diff --git a/content/posts/ko/jdk-migration-strategy-21-to-25.md b/content/posts/ko/jdk-migration-strategy-21-to-25.md index 48bed54..7269a86 100644 --- a/content/posts/ko/jdk-migration-strategy-21-to-25.md +++ b/content/posts/ko/jdk-migration-strategy-21-to-25.md @@ -16,9 +16,9 @@ draft: false Phase 1에서 JDK 21까지 올린 뒤 한동안은 안정적으로 운영했다. 굳이 더 올릴 이유가 없었다. 그런데 몇 가지 상황이 겹치면서 25까지 가게 됐다. -**Virtual Threads**가 가장 큰 동기였다. 우리 서비스는 SQS 리스너, 외부 API 호출, DB 쿼리 등 I/O 바운드 작업이 많다. 특히 [Spring AI 기반 진단 파이프라인](/posts/spring-ai-pipeline-real-world)에서 청크별로 LLM을 병렬 호출하는 구조를 도입하면서, `CompletableFuture.allOf()`로 동시에 수십 개의 외부 API 호출이 발생하는 상황이 생겼다. 기존에는 `ThreadPoolTaskExecutor`의 corePoolSize, maxPoolSize, queueCapacity를 직접 튜닝하면서 동시 처리량을 관리했는데, 트래픽 패턴이 바뀔 때마다 설정을 재조정하는 게 번거로웠다. Virtual Threads를 적용하면 스레드 풀 관리 자체가 불필요해진다. +**Virtual Threads**가 가장 큰 동기였다. 우리 서비스는 SQS 리스너, 외부 API 호출, DB 쿼리 등 I/O 바운드 작업이 많다. 특히 [Spring AI 기반 진단 파이프라인](/posts/spring-ai-pipeline-real-world)에서 청크별로 LLM을 병렬 호출하는 구조를 도입하면서 `CompletableFuture.allOf()`로 동시에 수십 개의 외부 API 호출이 발생하는 상황이 생겼다. 기존에는 `ThreadPoolTaskExecutor`의 corePoolSize, maxPoolSize, queueCapacity를 직접 튜닝하면서 동시 처리량을 관리했는데, 트래픽 패턴이 바뀔 때마다 설정을 재조정하는 게 번거로웠다. Virtual Threads를 적용하면 스레드 풀 관리 자체가 불필요해진다. -Virtual Threads 자체는 JDK 21에서 정식 도입되었지만, 실제로 프로덕션에 적용하기엔 **pinning 문제**가 걸렸다. `synchronized` 블록 안에서 I/O가 발생하면 Virtual Thread가 carrier thread에 고정(pin)되어 플랫폼 스레드를 점유하게 되는데, 이렇게 되면 Virtual Threads의 이점이 사라진다. Hibernate, JDBC 드라이버 등 내부적으로 `synchronized`를 쓰는 라이브러리가 많아서 JPA 기반 서비스에서는 이 문제를 피하기 어려웠다. JDK 24(JEP 491)에서 `synchronized` 블록에서도 Virtual Thread가 unmount될 수 있도록 개선되면서 pinning 문제가 근본적으로 해소됐고, JDK 25에서는 이 개선이 안정화된 상태다. Spring Boot 3.5+에서 설정 한 줄로 전체 적용이 가능해지면서 타이밍이 맞았다. +Virtual Threads 자체는 JDK 21에서 정식 도입되었지만 실제로 프로덕션에 적용하기엔 **pinning 문제**가 걸렸다. `synchronized` 블록 안에서 I/O가 발생하면 Virtual Thread가 carrier thread에 고정(pin)되어 플랫폼 스레드를 점유하게 되는데, 이렇게 되면 Virtual Threads의 이점이 사라진다. Hibernate, JDBC 드라이버 등 내부적으로 `synchronized`를 쓰는 라이브러리가 많아서 JPA 기반 서비스에서는 이 문제를 피하기 어려웠다. JDK 24(JEP 491)에서 `synchronized` 블록에서도 Virtual Thread가 unmount될 수 있도록 개선되면서 pinning 문제가 근본적으로 해소됐고 JDK 25에서는 이 개선이 안정화된 상태다. Spring Boot 3.5+에서 설정 한 줄로 전체 적용이 가능해지면서 타이밍이 맞았다. **보안 강화 작업과 맞물린 것**도 있었다. AWS 인프라 보안 설정을 전면 재검토하면서 런타임 환경도 함께 최신화하자는 방향이 잡혔다. @@ -44,7 +44,7 @@ FROM amazoncorretto:21-alpine FROM amazoncorretto:25-alpine ``` -JDK 25에서 FFM(Foreign Function & Memory) API 전환이 완료되기 전까지 네이티브 접근 경고를 억제해야 한다: +JDK 25에서 FFM(Foreign Function & Memory) API 전환이 완료되기 전까지 네이티브 접근 경고를 억제한다: ```dockerfile ENV JAVA_TOOL_OPTIONS="--enable-native-access=ALL-UNNAMED" @@ -150,7 +150,7 @@ log.info("is virtual: {}", Thread.currentThread().isVirtual()); ## JDK 25 언어 기능 적용 -버전업의 부가적인 즐거움이다. 꼭 바꿔야 하는 건 아니지만, 새 문법을 적용하면 코드가 깔끔해진다. +버전업의 부가적인 즐거움이다. 꼭 바꿔야 하는 건 아니지만 새 문법을 적용하면 코드가 깔끔해진다. ### Pattern Matching for switch @@ -201,13 +201,13 @@ JDK 25에서는 `main` 메서드의 `public static` 수식어가 선택사항이 // before public static void main(String[] args) { TimeZone.setDefault(TimeZone.getTimeZone("UTC")); - SpringApplication.run(PodoApplication.class, args); + SpringApplication.run(BackendApplication.class, args); } // after void main(String[] args) { TimeZone.setDefault(TimeZone.getTimeZone("UTC")); - SpringApplication.run(PodoApplication.class, args); + SpringApplication.run(BackendApplication.class, args); } ``` @@ -253,6 +253,6 @@ Kotlin DSL로 바꾸면 IDE 자동완성이 된다는 게 가장 크다. Groovy ## 마무리 -JDK 11에서 25까지 올리는 데 약 8개월이 걸렸다. Phase 1(11→21)에서 219개 파일을 건드렸지만, Phase 2(21→25)는 16개 파일로 끝났다. 메이저 업그레이드를 한 번 넘기면 이후는 훨씬 수월해진다. +JDK 11에서 25까지 올리는 데 약 8개월이 걸렸다. Phase 1(11→21)에서 219개 파일을 건드렸지만 Phase 2(21→25)는 16개 파일로 끝났다. 메이저 업그레이드를 한 번 넘기면 이후는 훨씬 수월해진다. 한 번에 올리면 디버깅이 불가능하다. 어떤 문제가 JDK 때문인지, Spring Boot 때문인지, 라이브러리 호환성 때문인지 구분할 수 없기 때문이다. **단계적 접근**이 핵심이다. diff --git a/content/posts/ko/jwt-hs256-to-rs256-jwks-kms.md b/content/posts/ko/jwt-hs256-to-rs256-jwks-kms.md index 2530aa8..db10ff6 100644 --- a/content/posts/ko/jwt-hs256-to-rs256-jwks-kms.md +++ b/content/posts/ko/jwt-hs256-to-rs256-jwks-kms.md @@ -10,13 +10,13 @@ draft: false JWT를 처음 붙일 때는 HS256만으로도 충분해 보인다. -서명과 검증에 같은 secret을 쓴다. 구현이 단순하고, Spring Security나 JWT 라이브러리에서도 바로 지원한다. 서비스가 하나이고 토큰 발급자와 검증자가 같은 애플리케이션 안에 있으면 크게 불편하지 않다. +서명과 검증에 같은 secret을 쓴다. 구현이 단순하고 Spring Security나 JWT 라이브러리에서도 바로 지원한다. 서비스가 하나이고 토큰 발급자와 검증자가 같은 애플리케이션 안에 있으면 크게 불편하지 않다. 문제는 인증을 여러 서비스가 함께 쓰기 시작하면서 생긴다. -토큰을 검증해야 하는 서비스가 늘어나면 secret도 여러 곳에 배포해야 한다. 그런데 HS256에서는 검증할 수 있는 주체가 곧 서명할 수 있는 주체다. 검증만 맡기고 싶은 서비스에도 토큰을 만들 수 있는 secret이 들어간다. +토큰 검증 서비스가 늘어나면 secret도 여러 곳에 배포된다. 그런데 HS256에서는 검증할 수 있는 주체가 곧 서명할 수 있는 주체다. 검증만 맡기고 싶은 서비스에도 토큰을 만들 수 있는 secret이 들어간다. -이 구조가 당장 취약점이라는 뜻은 아니다. 다만 인증 서버를 중심으로 로그인 책임을 모으고, 다른 서비스는 토큰을 검증만 하게 만들고 싶다면 경계가 애매하다. +이 구조가 당장 취약점이라는 뜻은 아니다. 다만 인증 서버를 중심으로 로그인 책임을 모으고 다른 서비스는 토큰을 검증만 하게 만들고 싶다면 경계가 애매하다. 그래서 JWT 서명 방식을 다음 방향으로 바꾸고 싶었다. @@ -45,7 +45,7 @@ HS256과 RS256의 가장 큰 차이는 키의 역할이다. HS256은 단순하다. 하지만 단순한 만큼 키가 퍼지기 쉽다. -반대로 RS256은 구조가 조금 복잡하다. 서명에는 개인키가 필요하고, 검증에는 공개키가 필요하다. 대신 검증자에게 공개키만 주면 되기 때문에 역할을 나누기 쉽다. +반대로 RS256은 구조가 조금 복잡하다. 서명에는 개인키가 필요하고 검증에는 공개키가 필요하다. 대신 검증자에게 공개키만 주면 되기 때문에 역할을 나누기 쉽다. 흐름으로 보면 차이가 더 분명하다. @@ -94,9 +94,9 @@ sequenceDiagram V-->>C: API 응답 ``` -여기서 JWKS endpoint는 별도 서버라기보다 인증 서버가 외부에 공개하는 공개키 조회 경로다. 검증 서비스는 인증 서버의 JWKS endpoint에서 공개키만 받아오고, 서명 권한은 갖지 않는다. +여기서 JWKS endpoint는 별도 서버라기보다 인증 서버가 외부에 공개하는 공개키 조회 경로다. 검증 서비스는 인증 서버의 JWKS endpoint에서 공개키만 받아오고 서명 권한은 갖지 않는다. -HS256에서는 검증 서비스가 secret을 알아야 한다. RS256에서는 검증 서비스가 공개키만 알고, 서명은 KMS 권한이 있는 인증 서버만 한다. 인증 서버가 access token을 발급하고 여러 서비스가 그 토큰을 검증해야 한다면 RS256 쪽이 더 자연스럽다. +HS256에서는 검증 서비스가 secret을 알아야 한다. RS256에서는 검증 서비스가 공개키만 알고 서명은 KMS 권한이 있는 인증 서버만 한다. 인증 서버가 access token을 발급하고 여러 서비스가 그 토큰을 검증하는 구조라면 RS256 쪽이 더 자연스럽다. --- @@ -104,7 +104,7 @@ HS256에서는 검증 서비스가 secret을 알아야 한다. RS256에서는 RS256으로 바꾼다고 해서 개인키를 애플리케이션 설정 파일에 넣으면 이점이 줄어든다. -개인키가 환경 변수나 secret 파일로 내려오면, 결국 애플리케이션 프로세스가 개인키 원문을 갖게 된다. 키 권한과 배포 범위가 줄어들긴 하지만, "개인키가 애플리케이션 밖에 있다"는 상태는 아니다. +개인키가 환경 변수나 secret 파일로 내려오면, 결국 애플리케이션 프로세스가 개인키 원문을 갖게 된다. 키 권한과 배포 범위가 줄어들긴 하지만 "개인키가 애플리케이션 밖에 있다"는 상태는 아니다. 다만 KMS만 답이라는 뜻은 아니다. RS256으로 바꿀 때 개인키를 다루는 선택지는 몇 가지가 있다. @@ -115,11 +115,11 @@ RS256으로 바꾼다고 해서 개인키를 애플리케이션 설정 파일에 | 인증 서버에만 개인키 배포 | 토큰 발급 서버만 개인키를 갖고 다른 서비스는 공개키로 검증 | 서명 권한을 인증 서버로 좁힐 수 있음 | 인증 서버 침해 시 개인키 원문이 노출될 수 있음 | | KMS 또는 HSM 서명 | 애플리케이션은 서명 대상만 보내고 외부 키 관리 시스템이 서명 | 개인키를 내보내지 않고 권한과 감사 로그를 분리하기 쉬움 | 호출 지연, 비용, 로컬 개발 대체 전략을 고려해야 함 | -어떤 방식을 고르든 JWKS와 `kid`는 여전히 필요하다. 검증 서비스는 공개키를 받아야 하고, key rotation 시점에는 어떤 키로 서명한 토큰인지 알아야 하기 때문이다. +어떤 방식을 고르든 JWKS와 `kid`는 여전히 필요하다. 검증 서비스는 공개키를 받아야 하고 key rotation 시점에는 어떤 키로 서명한 토큰인지 알아야 하기 때문이다. -이 작업에서는 access token signing key를 장기적으로 가져갈 인증 경계로 봤다. 그래서 개인키 원문이 애플리케이션 프로세스에 들어오는 방식을 피하고, 서명은 KMS에 맡기는 구조로 잡았다. +이 작업에서는 access token signing key를 장기적으로 가져갈 인증 경계로 봤다. 그래서 개인키 원문이 애플리케이션 프로세스에 들어오는 방식을 피하고 서명은 KMS에 맡기는 구조로 잡았다. -애플리케이션은 JWT header와 claim을 만들고, 서명 대상 바이트를 KMS에 보낸다. KMS는 개인키로 서명한 결과만 돌려준다. 애플리케이션은 개인키를 직접 읽지 않는다. +애플리케이션은 JWT header와 claim을 만들고 서명 대상 바이트를 KMS에 보낸다. KMS는 개인키로 서명한 결과만 돌려준다. 애플리케이션은 개인키를 직접 읽지 않는다. 실제 구현에서 핵심만 줄이면 다음과 같다. @@ -145,7 +145,7 @@ public String createToken(String userId) { } ``` -KMS signer는 JWT 라이브러리 입장에서는 `JWSSigner`처럼 보이지만, 실제 서명은 KMS API를 호출한다. +KMS signer는 JWT 라이브러리 입장에서는 `JWSSigner`처럼 보이지만 실제 서명은 KMS API를 호출한다. ```java class KmsRs256Signer implements JWSSigner { @@ -161,7 +161,7 @@ class KmsRs256Signer implements JWSSigner { } ``` -KMS 클라이언트에서는 서명 대상 바이트의 SHA-256 digest를 만들고, 비대칭 서명 API를 호출한다. +KMS 클라이언트에서는 서명 대상 바이트의 SHA-256 digest를 만들고 비대칭 서명 API를 호출한다. ```java public byte[] signRs256(String keyVersionName, byte[] signingInput) { @@ -173,7 +173,7 @@ public byte[] signRs256(String keyVersionName, byte[] signingInput) { } ``` -이렇게 하면 애플리케이션은 "어떤 키 버전으로 서명할지"만 알고, 개인키 자체는 모른다. +이렇게 하면 애플리케이션은 "어떤 키 버전으로 서명할지"만 알고 개인키 자체는 모른다. --- @@ -189,9 +189,9 @@ RS256 구조에서는 검증자가 공개키를 선택할 수 있어야 한다. } ``` -검증자는 토큰 header의 `kid`를 보고 JWKS에서 같은 `kid`를 가진 공개키를 찾는다. 이 값이 없으면 key rotation 시점에 어떤 키로 검증해야 하는지 알기 어렵다. +검증자는 토큰 header의 `kid`를 보고 JWKS에서 같은 `kid`를 가진 공개키를 찾는다. 이 값이 없으면 key rotation 시점에 어떤 키로 검증할지 알기 어렵다. -`kid`는 KMS key version 이름을 그대로 노출하지 않는 편이 낫다. 내부 인프라 경로가 드러나고, 운영 환경이 바뀔 때 외부 계약도 같이 흔들릴 수 있다. +`kid`는 KMS key version 이름을 그대로 노출하지 않는 편이 낫다. 내부 인프라 경로가 드러나고 운영 환경이 바뀔 때 외부 계약도 같이 흔들릴 수 있다. 그래서 내부 key version과 외부 `kid`를 분리했다. @@ -210,7 +210,7 @@ public String resolveKid(String keyVersionName) { } ``` -핵심은 `kid`가 토큰과 JWKS 사이의 공개 계약이라는 점이다. KMS key version은 내부 구현이고, `kid`는 검증자가 보는 식별자다. +핵심은 `kid`가 토큰과 JWKS 사이의 공개 계약이라는 점이다. KMS key version은 내부 구현이고 `kid`는 검증자가 보는 식별자다. --- @@ -235,7 +235,7 @@ JWKS는 공개키 목록이다. 각 키는 `kid`, `kty`, `use`, `alg`, `n`, `e` } ``` -JWKS를 제공하는 쪽에서는 KMS에서 공개키 PEM을 읽고, RSA public key로 변환한 뒤 JWK 형태로 내려준다. +JWKS를 제공하는 쪽에서는 KMS에서 공개키 PEM을 읽고 RSA public key로 변환한 뒤 JWK 형태로 내려준다. ```java public JWKSet getJwkSet() { @@ -260,9 +260,9 @@ private RSAKey toRsaJwk(String keyVersionName) { } ``` -JWKS는 매 요청마다 KMS를 조회하면 안 된다. 공개키 조회도 외부 API 호출이고, 인증 검증 경로는 트래픽이 많다. 그래서 짧은 TTL을 둔 캐시가 필요하다. +JWKS는 매 요청마다 KMS를 조회하면 안 된다. 공개키 조회도 외부 API 호출이고 인증 검증 경로는 트래픽이 많다. 그래서 짧은 TTL을 둔 캐시가 필요하다. -여기서 주의할 점은 cache TTL과 key rotation 절차가 연결된다는 것이다. 새 공개키를 JWKS에 추가하고, 검증자 캐시가 갱신될 시간을 기다린 뒤, 새 `kid`로 서명해야 한다. +여기서 cache TTL과 key rotation 절차가 연결된다. 새 공개키를 JWKS에 추가하고 검증자 캐시가 갱신될 시간을 기다린 뒤, 새 `kid`로 서명한다. --- @@ -297,9 +297,9 @@ private OAuth2TokenValidator jwtValidator() { } ``` -서명 방식만 바꾸고 claim 검증을 그대로 두면 반쪽짜리 전환이 된다. 서명은 "누가 만들었는가"를 확인하고, claim은 "어디에 쓸 수 있는 토큰인가"를 확인한다. +서명 방식만 바꾸고 claim 검증을 그대로 두면 반쪽짜리 전환이 된다. 서명은 "누가 만들었는가"를 확인하고 claim은 "어디에 쓸 수 있는 토큰인가"를 확인한다. -특히 여러 클라이언트나 서비스가 같은 인증 서버를 바라보면 audience가 중요해진다. 토큰이 유효하더라도 이 API를 위한 토큰인지 확인해야 한다. +특히 여러 클라이언트나 서비스가 같은 인증 서버를 바라보면 audience가 중요해진다. 토큰이 유효하더라도 이 API를 위한 토큰인지 확인한다. --- @@ -307,7 +307,7 @@ private OAuth2TokenValidator jwtValidator() { 운영 중인 서비스에서는 새 서명 방식으로 바꿨다고 기존 토큰을 바로 무효화하기 어렵다. -이미 로그인한 사용자가 있고, 기존 access token의 만료 시간이 남아 있다. 배포 순간부터 HS256 검증을 끊으면 사용자는 갑자기 로그아웃되거나 API가 401을 반환받을 수 있다. +이미 로그인한 사용자가 있고 기존 access token의 만료 시간이 남아 있다. 배포 순간부터 HS256 검증을 끊으면 사용자는 갑자기 로그아웃되거나 API가 401을 반환받을 수 있다. 그래서 전환 기간에는 두 decoder를 같이 둔다. @@ -337,7 +337,7 @@ private JwtDecoder legacyAwareJwtDecoder( } ``` -여기서 중요한 것은 "실패하면 다른 decoder를 시도한다"가 아니라, header의 `alg`를 보고 검증 경로를 고르는 것이다. +여기서 중요한 기준은 "실패하면 다른 decoder를 시도한다"가 아니라, header의 `alg`를 보고 검증 경로를 고르는 방식이다. ```java private boolean isHs256(String token) { @@ -346,15 +346,15 @@ private boolean isHs256(String token) { } ``` -무작정 RS256 검증에 실패하면 HS256으로 다시 시도하는 방식은 좋지 않다. 실패의 의미가 흐려지고, 예상하지 못한 토큰이 legacy 경로를 타는지 판단하기 어려워진다. +무작정 RS256 검증에 실패하면 HS256으로 다시 시도하는 방식은 좋지 않다. 실패의 의미가 흐려지고 예상하지 못한 토큰이 legacy 경로를 타는지 판단하기 어려워진다. -전환이 안정화되면 legacy HS256 허용 설정을 끄고, HS256 secret 없이도 decoder가 생성되는지 확인해야 한다. +전환이 안정화되면 legacy HS256 허용 설정을 끄고 HS256 secret 없이도 decoder가 생성되는지 확인한다. --- -## key rotation은 발급과 검증을 분리해야 한다 +## key rotation은 발급과 검증을 분리한다 -비대칭키로 바꿨다고 key rotation이 자동으로 안전해지지는 않는다. 새 키로 서명하는 시점과 새 공개키를 검증자가 알게 되는 시점을 분리해야 한다. +비대칭키로 바꿨다고 key rotation이 자동으로 안전해지지는 않는다. 새 키로 서명하는 시점과 새 공개키를 검증자가 알게 되는 시점을 분리한다. 잘못된 순서는 다음과 같다. @@ -396,7 +396,7 @@ jwt: ## KMS를 붙이면 생기는 비용과 이점 -KMS를 붙이면 보안 경계는 좋아지지만, 운영상 고려할 것도 늘어난다. +KMS를 붙이면 보안 경계는 좋아지지만 운영상 고려할 것도 늘어난다. | 항목 | 장점 | 주의할 점 | |------|------|-----------| @@ -405,9 +405,9 @@ KMS를 붙이면 보안 경계는 좋아지지만, 운영상 고려할 것도 | rotation | key version 단위로 교체 가능 | JWKS cache와 순서 조율 필요 | | 권한 분리 | 서명 권한과 검증 권한을 분리 가능 | 로컬/테스트 환경 대체 전략 필요 | -특히 JWT 발급량이 많은 서비스라면 KMS 호출 비용과 지연 시간을 봐야 한다. 모든 API 요청마다 KMS를 호출하는 구조는 아니지만, 로그인이나 refresh처럼 토큰을 새로 발급하는 경로에서는 호출이 발생한다. +특히 JWT 발급량이 많은 서비스라면 KMS 호출 비용과 지연 시간을 봐야 한다. 모든 API 요청마다 KMS를 호출하는 구조는 아니지만 로그인이나 refresh처럼 토큰을 새로 발급하는 경로에서는 호출이 발생한다. -반대로 검증 경로에서는 KMS를 호출하지 않아야 한다. JWKS를 캐시하고 공개키로 로컬 검증해야 한다. +반대로 검증 경로에서는 KMS를 호출하지 않는다. JWKS를 캐시하고 공개키로 로컬 검증한다. --- @@ -415,7 +415,7 @@ KMS를 붙이면 보안 경계는 좋아지지만, 운영상 고려할 것도 이 전환에서 테스트는 단순히 "토큰이 발급된다"로 끝나면 부족하다. -확인해야 할 것은 경로별 책임이다. +확인할 것은 경로별 책임이다. | 케이스 | 기대 결과 | |--------|-----------| @@ -428,19 +428,19 @@ KMS를 붙이면 보안 경계는 좋아지지만, 운영상 고려할 것도 | legacy 비활성 | HS256 토큰 검증 실패 | | issuer/audience 불일치 | RS256 토큰이라도 검증 실패 | -특히 legacy 허용 테스트는 중요하다. 전환 기간에는 의도적으로 HS256을 받아야 하지만, 영구적으로 열어두면 안 된다. 설정을 끄면 HS256 토큰이 거부되는지까지 확인해야 전환 완료 조건이 생긴다. +특히 legacy 허용 테스트는 중요하다. 전환 기간에는 의도적으로 HS256을 받아야 하지만 영구적으로 열어두면 안 된다. 설정을 끄면 HS256 토큰이 거부되는지까지 확인해야 전환 완료 조건이 생긴다. --- ## 전환할 때 지킨 순서 -글로 쓰면 체크리스트가 길어지지만, 실제 기준은 하나였다. 발급보다 검증을 먼저 준비한다. +글로 쓰면 체크리스트가 길어지지만 실제 기준은 하나였다. 발급보다 검증을 먼저 준비한다. -먼저 issuer와 audience 정책을 맞추고, KMS key와 signing 권한을 준비했다. 그 다음 KMS 공개키를 JWKS로 노출하고, `kid`, `alg`, `use`가 올바르게 내려오는지 확인했다. RS256 발급기는 이때 만들어 두되, 바로 production 발급을 바꾸지는 않았다. +먼저 issuer와 audience 정책을 맞추고 KMS key와 signing 권한을 준비했다. 그 다음 KMS 공개키를 JWKS로 노출하고 `kid`, `alg`, `use`가 올바르게 내려오는지 확인했다. RS256 발급기는 이때 만들어 두되, 바로 production 발급을 바꾸지는 않았다. -검증자에는 RS256 decoder를 먼저 추가했다. 기존 HS256 access token이 남아 있으므로 legacy decoder도 제한적으로 열어두었다. 그 상태에서 새 토큰 발급을 RS256으로 전환하고, access token 만료 시간보다 긴 시간 동안 실패율과 인증 오류를 봤다. +검증자에는 RS256 decoder를 먼저 추가했다. 기존 HS256 access token이 남아 있으므로 legacy decoder도 제한적으로 열어두었다. 그 상태에서 새 토큰 발급을 RS256으로 전환하고 access token 만료 시간보다 긴 시간 동안 실패율과 인증 오류를 봤다. -마지막에는 legacy HS256 허용을 끄고, HS256 secret 없이도 서비스가 뜨는지 확인했다. 이전 공개키는 더 이상 해당 `kid`의 토큰이 남지 않을 때 JWKS에서 제거했다. +마지막에는 legacy HS256 허용을 끄고 HS256 secret 없이도 서비스가 뜨는지 확인했다. 이전 공개키는 더 이상 해당 `kid`의 토큰이 남지 않을 때 JWKS에서 제거했다. --- @@ -452,8 +452,8 @@ HS256에서 RS256으로 바꾸면 키의 책임이 나뉜다. KMS를 붙이면 결국 핵심은 이것이었다. -**서명 권한은 좁히고, 검증 권한은 안전하게 넓힌다.** +**서명 권한은 좁히고 검증 권한은 안전하게 넓힌다.** -인증 서버는 KMS를 통해서만 서명하고, 다른 서비스는 JWKS의 공개키로 검증한다. 그리고 전환 기간에는 기존 토큰을 무리하게 끊지 않되, 제거 시점을 명확히 둔다. +인증 서버는 KMS를 통해서만 서명하고 다른 서비스는 JWKS의 공개키로 검증한다. 그리고 전환 기간에는 기존 토큰을 무리하게 끊지 않되, 제거 시점을 명확히 둔다. 이 정도 기준이 있어야 JWT 전환이 보안 설정 변경에서 끝나지 않고 운영 가능한 인증 구조로 남는다. diff --git a/content/posts/ko/lgtm-stack-observability.md b/content/posts/ko/lgtm-stack-observability.md index 9052164..d85b4c9 100644 --- a/content/posts/ko/lgtm-stack-observability.md +++ b/content/posts/ko/lgtm-stack-observability.md @@ -14,7 +14,7 @@ draft: false ### Datadog의 문제 -Datadog 자체는 좋은 도구다. APM, 로그, 메트릭, 대시보드까지 올인원으로 제공하고, 에이전트 하나만 붙이면 바로 동작한다. 하지만 우리 상황에서는 비용이 문제였다. +Datadog 자체는 좋은 도구다. APM, 로그, 메트릭, 대시보드까지 올인원으로 제공하고 에이전트 하나만 붙이면 바로 동작한다. 하지만 우리 상황에서는 비용이 문제였다. - **호스트 기반 과금** - 서비스가 늘어날수록 비용이 선형으로 증가한다. dev, stage, prod 환경을 전부 모니터링하면 호스트 수가 금방 늘어난다 - **로그 과금이 특히 부담** - 로그 수집량 기반으로 과금되는데, Spring Boot 앱의 로그량이 만만치 않다. 로그 레벨을 내리면 장애 시 원인 추적이 어려워진다 @@ -70,11 +70,11 @@ flowchart LR ### 데이터 흐름 -모니터링 데이터의 흐름을 한 문장으로 요약하면 이렇다: +모니터링 데이터의 흐름은 이렇게 이어진다: > **Spring Boot** → OTel Java Agent가 자동 계측 → **OTel Collector 사이드카**가 수신 → 로그/트레이스/메트릭을 **Loki/Tempo/Mimir**로 전송 → **S3**에 장기 저장 → **Grafana**에서 조회 -핵심은 **OTel Collector를 사이드카로 배치**한 것이다. DaemonSet이 아닌 사이드카를 선택한 이유: +핵심은 **OTel Collector를 사이드카로 배치**한 점이다. DaemonSet이 아닌 사이드카를 선택한 이유: - **격리** - 서비스별 OTel 설정을 독립적으로 관리할 수 있다. 노이즈 필터링 같은 설정이 서비스마다 다르다 - **리소스 제어** - 서비스별로 Collector의 CPU/메모리를 독립적으로 조절할 수 있다 @@ -82,7 +82,7 @@ flowchart LR ### 인프라 구성 -LGTM 스택 전체를 `assist-tools` namespace에 배포했다. 전용 Karpenter NodePool로 격리하고, Taint/Toleration으로 다른 워크로드가 섞이지 않게 했다. +LGTM 스택 전체를 `assist-tools` namespace에 배포했다. 전용 Karpenter NodePool로 격리하고 Taint/Toleration으로 다른 워크로드가 섞이지 않게 했다. ```mermaid flowchart TB @@ -192,7 +192,7 @@ prod에서 100% 샘플링은 Tempo 저장 비용과 성능 양쪽에서 부담 ## OTel Collector 사이드카 - 수집/가공/전송 -OTel Collector는 모든 Pod에 사이드카로 배포된다. base Deployment에 기본 컨테이너로 포함되어 있고, 환경별 설정은 overlay에서 ConfigMap 패치로 관리한다. +OTel Collector는 모든 Pod에 사이드카로 배포된다. base Deployment에 기본 컨테이너로 포함되어 있고 환경별 설정은 overlay에서 ConfigMap 패치로 관리한다. ```yaml # my-backend/base/deployment.yaml (사이드카 부분) @@ -237,7 +237,7 @@ receivers: 첫 번째는 OTLP 프로토콜로 Java Agent가 보내는 트레이스와 로그를 수신한다. 두 번째는 Prometheus 방식으로 Spring Boot Actuator의 `/status/prometheus` 엔드포인트를 15초마다 스크래핑해서 메트릭을 수집한다. -Kubernetes Service Discovery(`kubernetes_sd_configs`)로 Pod을 자동 발견하고, `relabel_configs`로 필터링한다. `prometheus.io/scrape: "true"` 어노테이션이 있는 Pod만 스크래핑 대상이 된다. +Kubernetes Service Discovery(`kubernetes_sd_configs`)로 Pod을 자동 발견하고 `relabel_configs`로 필터링한다. `prometheus.io/scrape: "true"` 어노테이션이 있는 Pod만 스크래핑 대상이 된다. ### Processor - 노이즈 필터링 @@ -285,9 +285,9 @@ processors: **왜 이게 중요한가?** -OTel Java Agent는 Redis 명령어 하나하나를 전부 span으로 기록한다. `PING`, `SET`, `DEL`, `HGET` 같은 단순 명령어가 초당 수백 건씩 쌓이면 Tempo 저장 비용이 급격히 늘어나고, 실제 비즈니스 트레이스를 찾기도 어려워진다. +OTel Java Agent는 Redis 명령어 하나하나를 전부 span으로 기록한다. `PING`, `SET`, `DEL`, `HGET` 같은 단순 명령어가 초당 수백 건씩 쌓이면 Tempo 저장 비용이 급격히 늘어나고 실제 비즈니스 트레이스를 찾기도 어려워진다. -헬스체크도 마찬가지다. ALB가 `/status/health/liveness`를 30초마다 호출하고, Prometheus가 `/status/prometheus`를 15초마다 스크래핑한다. 이런 시스템 요청까지 전부 트레이싱하면 노이즈만 쌓인다. +헬스체크도 마찬가지다. ALB가 `/status/health/liveness`를 30초마다 호출하고 Prometheus가 `/status/prometheus`를 15초마다 스크래핑한다. 이런 시스템 요청까지 전부 트레이싱하면 노이즈만 쌓인다. `filter/drop-noise` 프로세서로 이런 불필요한 span을 Collector 단에서 드롭하면, 네트워크 전송과 Tempo 저장 모두 절약된다. **실제로 노이즈 필터링 적용 후 Tempo에 저장되는 스팬 수가 체감상 절반 이하로 줄었다.** @@ -385,7 +385,7 @@ public class MDCFilter extends OncePerRequestFilter { } ``` -핵심은 **MDC와 OTel Span을 동시에 설정**하는 것이다: +핵심은 **MDC와 OTel Span을 동시에 설정**하는 점이다: 1. `MDC.put("app.userId", userId)` - Logback 로그에 사용자 ID가 찍힌다 2. `span.setAttribute("user.id", userId)` - Tempo 트레이스에서 사용자 ID로 검색할 수 있다 @@ -427,7 +427,7 @@ public class AsyncMdcConfig implements AsyncConfigurer { } ``` -`@Async`가 호출되는 시점에 현재 스레드의 MDC를 캡처하고, 새 스레드에서 복원한다. Virtual Threads를 쓰고 있기 때문에 `SimpleAsyncTaskExecutor`에 `setVirtualThreads(true)`를 설정한 부분도 중요하다. +`@Async`가 호출되는 시점에 현재 스레드의 MDC를 캡처하고 새 스레드에서 복원한다. Virtual Threads를 쓰고 있기 때문에 `SimpleAsyncTaskExecutor`에 `setVirtualThreads(true)`를 설정한 부분도 중요하다. ### Logback 로그 포맷 @@ -463,7 +463,7 @@ metadata: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/lgtmRole ``` -IRSA(IAM Roles for Service Accounts)로 S3 접근 권한을 부여한다. Access Key를 Helm values에 하드코딩하지 않아도 되고, Pod이 AWS API를 호출할 때 해당 IAM Role의 권한만 사용한다. +IRSA(IAM Roles for Service Accounts)로 S3 접근 권한을 부여한다. Access Key를 Helm values에 하드코딩하지 않아도 되고 Pod이 AWS API를 호출할 때 해당 IAM Role의 권한만 사용한다. 전용 Karpenter NodePool로 LGTM 워크로드를 격리한다: @@ -684,7 +684,7 @@ spec: ## Kustomize 환경별 관리 -OTel Collector 설정은 base에서 빈 ConfigMap을 두고, overlay에서 환경별로 패치한다: +OTel Collector 설정은 base에서 빈 ConfigMap을 두고 overlay에서 환경별로 패치한다: ``` my-backend/ @@ -700,9 +700,9 @@ my-backend/ └── otel-collector-config.patch.yaml # prod용 (노이즈 필터 포함) ``` -prod overlay에서만 `filter/drop-noise` 프로세서를 넣는다. dev/stage에서는 디버깅을 위해 전체 span을 수집하고, prod에서만 비용 최적화를 위해 노이즈를 걸러낸다. +prod overlay에서만 `filter/drop-noise` 프로세서를 넣는다. dev/stage에서는 디버깅을 위해 전체 span을 수집하고 prod에서만 비용 최적화를 위해 노이즈를 걸러낸다. -이 구조의 장점은 **모니터링 설정도 GitOps로 관리**된다는 것이다. 노이즈 필터에 새 패턴을 추가하면 PR → 리뷰 → 머지 → ArgoCD 자동 배포로 반영된다. 콘솔에서 설정을 바꾸고 문서화를 깜빡하는 일이 없다. +이 구조의 장점은 **모니터링 설정도 GitOps로 관리**된다는 점이다. 노이즈 필터에 새 패턴을 추가하면 PR → 리뷰 → 머지 → ArgoCD 자동 배포로 반영된다. 콘솔에서 설정을 바꾸고 문서화를 깜빡하는 일이 없다. ## 삽질했던 것들 @@ -738,7 +738,7 @@ Mimir가 있으니 Prometheus를 별도로 운영할 필요가 없었다. 이 ### Authorization 헤더 노출 -OTel Java Agent가 HTTP 요청의 헤더를 전부 span attribute로 기록하는데, 여기에 `Authorization` 헤더도 포함되어 있었다. Tempo에 JWT 토큰이 그대로 저장되는 것이다. +OTel Java Agent가 HTTP 요청의 헤더를 전부 span attribute로 기록하는데, 여기에 `Authorization` 헤더도 포함되어 있었다. 그 결과 Tempo에 JWT 토큰이 그대로 저장됐다. ```yaml # attributes 프로세서로 제거 @@ -767,8 +767,8 @@ attributes: Datadog에서 LGTM 스택으로의 전환은 단순히 비용을 줄이기 위한 선택이 아니었다. **모니터링 설정을 코드로 관리**할 수 있게 된 것이 가장 큰 변화다. -OTel Collector 사이드카의 파이프라인 설정, 노이즈 필터링 룰, 환경별 exporter 구성이 전부 Kustomize overlay에 YAML로 들어있다. PR 리뷰로 변경을 검증하고, ArgoCD로 자동 배포된다. "Datadog 콘솔에서 누가 뭘 바꿨는지 모르겠다" 같은 상황이 사라졌다. +OTel Collector 사이드카의 파이프라인 설정, 노이즈 필터링 룰, 환경별 exporter 구성이 전부 Kustomize overlay에 YAML로 들어있다. PR 리뷰로 변경을 검증하고 ArgoCD로 자동 배포된다. "Datadog 콘솔에서 누가 뭘 바꿨는지 모르겠다" 같은 상황이 사라졌다. -물론 트레이드오프는 있다. Datadog은 에이전트 하나만 붙이면 모든 게 자동으로 돌아가는데, LGTM 스택은 직접 운영해야 한다. Helm 업그레이드, 디스크 관리, S3 비용 모니터링 등이 추가 업무로 생긴다. 전담 인프라 팀이 없는 상황에서 이건 부담이 될 수 있다. +물론 트레이드오프는 있다. Datadog은 에이전트 하나만 붙이면 모든 게 자동으로 돌아가는데, LGTM 스택은 직접 운영한다. Helm 업그레이드, 디스크 관리, S3 비용 모니터링 등이 추가 업무로 생긴다. 전담 인프라 팀이 없는 상황에서는 부담이 될 수 있다. 그래도 OTel이라는 벤더 중립 표준 위에 구축한 덕분에, 나중에 Grafana Cloud나 다른 백엔드로 바꾸고 싶으면 Collector의 exporter만 변경하면 된다. 애플리케이션 코드는 건드릴 필요가 없다. 이게 OTel을 쓰는 가장 큰 이유다. diff --git a/content/posts/ko/lgtm-stack-setup-guide.md b/content/posts/ko/lgtm-stack-setup-guide.md index ab30283..6af0188 100644 --- a/content/posts/ko/lgtm-stack-setup-guide.md +++ b/content/posts/ko/lgtm-stack-setup-guide.md @@ -1,18 +1,18 @@ --- title: "EKS에 LGTM 스택 구축하기 - Loki, Grafana, Tempo, Mimir 실전 셋업 가이드" date: "2026-03-17" -description: "EKS 클러스터에 Grafana LGTM 스택을 Helm으로 배포하고, OTel Collector 사이드카로 Spring Boot 앱의 로그·트레이스·메트릭을 수집하는 전체 과정을 다룹니다." +description: "EKS 클러스터에 Grafana LGTM 스택을 Helm으로 배포하고 OTel Collector 사이드카로 Spring Boot 앱의 로그·트레이스·메트릭을 수집하는 전체 과정을 다룹니다." tags: ["grafana", "loki", "tempo", "mimir", "opentelemetry", "kubernetes", "helm", "observability"] draft: false --- ## 배경 -EKS 클러스터에서 서비스를 운영하다 보면 로그, 트레이스, 메트릭을 한 곳에서 볼 수 있는 모니터링 환경이 필요해진다. Datadog이나 New Relic 같은 SaaS를 쓰면 편하지만, 호스트와 로그량 기반 과금이 서비스가 커질수록 부담이 된다. +EKS 클러스터에서 서비스를 운영하다 보면 로그, 트레이스, 메트릭을 한 곳에서 볼 수 있는 모니터링 환경이 필요해진다. Datadog이나 New Relic 같은 SaaS를 쓰면 편하지만 호스트와 로그량 기반 과금이 서비스가 커질수록 부담이 된다. -LGTM 스택(Loki, Grafana, Tempo, Mimir)은 Grafana Labs의 오픈소스 조합으로, EKS에 Helm으로 배포하면 인프라 비용만으로 운영할 수 있다. OTel(OpenTelemetry) 표준 기반이라 벤더 종속 없이 수집 파이프라인을 구성할 수 있고, 나중에 백엔드를 바꿔도 애플리케이션 코드를 수정할 필요가 없다. +LGTM 스택(Loki, Grafana, Tempo, Mimir)은 Grafana Labs의 오픈소스 조합으로, EKS에 Helm으로 배포하면 인프라 비용만으로 운영할 수 있다. OTel(OpenTelemetry) 표준 기반이라 벤더 종속 없이 수집 파이프라인을 구성할 수 있고 나중에 백엔드를 바꿔도 애플리케이션 코드를 수정할 필요가 없다. -이 글에서는 LGTM 스택을 EKS에 배포하고, Spring Boot 앱에 OTel Collector 사이드카를 붙여 로그·트레이스·메트릭을 수집하는 전체 과정을 단계별로 다룬다. +이 글에서는 LGTM 스택을 EKS에 배포하고 Spring Boot 앱에 OTel Collector 사이드카를 붙여 로그·트레이스·메트릭을 수집하는 전체 과정을 단계별로 다룬다. 최종 구성: @@ -48,7 +48,7 @@ flowchart LR kubectl create namespace observability ``` -## 1단계: IRSA - S3 접근 권한 설정 +## 1단계 - IRSA로 S3 접근 권한 설정 Loki, Tempo, Mimir 모두 S3에 데이터를 저장한다. IRSA(IAM Roles for Service Accounts)로 Pod에 S3 접근 권한을 부여하면 Access Key를 하드코딩할 필요가 없다. @@ -94,7 +94,7 @@ kubectl apply -f lgtm-serviceaccount.yaml 모든 LGTM 컴포넌트가 이 ServiceAccount를 공유한다. Helm values에서 `serviceAccount.create: false`, `serviceAccount.name: lgtm`으로 설정하면 된다. -## 2단계: Loki 배포 - 로그 저장소 +## 2단계 - Loki 로그 저장소 배포 Helm repo 추가: @@ -178,7 +178,7 @@ helm upgrade --install loki grafana/loki \ > **주의**: `auth_enabled: true`로 두면 Grafana 데이터소스 설정에서 `X-Scope-OrgID` 헤더를 직접 넣어야 한다. 단일 테넌트에서는 `false`가 편하다. -## 3단계: Tempo 배포 - 분산 트레이싱 +## 3단계 - Tempo 분산 트레이싱 배포 ### Tempo values 작성 @@ -277,7 +277,7 @@ helm upgrade --install tempo grafana/tempo-distributed \ `metricsGenerator`를 켜면 Grafana에서 서비스 간 호출 관계를 시각적으로 볼 수 있다. 트레이스를 수동 분석하지 않아도 서비스 토폴로지가 자동으로 그려진다. -## 4단계: Mimir 배포 - 메트릭 장기 저장 +## 4단계 - Mimir 메트릭 장기 저장소 배포 ### Mimir values 작성 @@ -401,9 +401,9 @@ helm upgrade --install mimir grafana/mimir-distributed \ | `retention_period` | `2160h` | 90일 보관. 필요에 따라 조정 | | `zoneAwareReplication` | `false` | 단일 레플리카에서는 비활성 필수 | -> **주의**: Mimir 6.x (3.0)부터 기본이 Kafka 기반 ingest로 바뀌었다. classic 모드로 쓰려면 `ingest_storage.enabled: false`를 명시해야 한다. +> **주의**: Mimir 6.x (3.0)부터 기본이 Kafka 기반 ingest로 바뀌었다. classic 모드로 쓰려면 `ingest_storage.enabled: false`를 명시한다. -## 5단계: Grafana 배포 - 대시보드 +## 5단계 - Grafana 대시보드 배포 ### Grafana values 작성 @@ -459,7 +459,7 @@ Mimir는 Prometheus 호환 API를 제공하므로 데이터소스 타입을 **Pr > **Tip**: Tempo 데이터소스 설정에서 "Trace to logs" → Loki 데이터소스를 연결하면, 트레이스에서 원클릭으로 해당 시점의 로그를 볼 수 있다. "Trace to metrics" → Mimir를 연결하면 메트릭까지 연동된다. -## 6단계: OTel Collector 사이드카 - 데이터 수집 +## 6단계 - OTel Collector 사이드카로 데이터 수집 ### RBAC 설정 @@ -654,10 +654,10 @@ spec: 핵심 포인트: - `OTEL_EXPORTER_OTLP_ENDPOINT: http://localhost:4317` - 사이드카는 같은 Pod이라 localhost로 통신한다 -- `OTEL_SERVICE_NAME` - Tempo에서 서비스를 구분하는 이름. 반드시 설정해야 한다 +- `OTEL_SERVICE_NAME` - Tempo에서 서비스를 구분하는 이름. 반드시 설정한다 - Prometheus 어노테이션으로 메트릭 스크래핑을 활성화한다 -## 7단계: OTel Java Agent 적용 +## 7단계 - OTel Java Agent 적용 ### Dockerfile @@ -716,7 +716,7 @@ dependencies { OTel Java Agent는 바이트코드 계측 방식이라 코드 수정 없이 HTTP, JDBC, Redis, gRPC 등을 자동 추적한다. `opentelemetry-api` 의존성은 수동으로 span을 만들거나 attribute를 추가할 때만 필요하다. -## 8단계: MDC로 사용자 컨텍스트 연결 +## 8단계 - MDC로 사용자 컨텍스트 연결 로그에 사용자 ID를 넣으면 "이 에러가 어떤 사용자의 요청에서 발생했는지" 바로 추적할 수 있다. @@ -839,7 +839,7 @@ filter/drop-noise: 노이즈 필터링을 적용하면 Tempo에 저장되는 스팬 수가 절반 이하로 줄어든다. 네트워크 전송, 스토리지 비용 모두 절약된다. -### 보안: 민감 정보 제거 +### 보안 민감 정보 제거 OTel Agent가 HTTP 헤더를 span attribute로 기록하므로, Authorization 헤더 같은 민감 정보가 Tempo에 저장될 수 있다: diff --git a/content/posts/ko/multi-channel-notification-server.md b/content/posts/ko/multi-channel-notification-server.md index d81ad12..2704179 100644 --- a/content/posts/ko/multi-channel-notification-server.md +++ b/content/posts/ko/multi-channel-notification-server.md @@ -10,14 +10,14 @@ draft: false 이 프로젝트의 시작은 앱푸시 기능이 없는 우리 서비스에 앱 푸시를 추가하면서 시작되었다. 현재 우리 서비스는 다양한 채널로 알림을 보낸다. 수업 예약 확인은 카카오 알림톡으로, 결제 실패는 SMS로, 내부 운영 알림은 Slack으로 보내고 있다. -문제는 이 알림들이 **시스템 곳곳에 흩어져** 있었다는 것이다. +문제는 이 알림들이 **시스템 곳곳에 흩어져** 있었다는 점이다. 이 상태에서 앱 푸시를 php로 구현하기엔 원하지 않았고 무엇보다 내부적으로 php 서비스를 점점 없애고 있는 추세이다 보니 갑자기 큰 프로젝트르 시작하게 되었다. ![알림 서버 구축 논의 - 다른 사업부에서도 사용할 수 있는 구조로 설계하자는 Slack 대화](/images/payment.png) ~~이 쓰레드로 인해 멀티 테넌트까지 고려하게 되었다.~~ -## PHP 시절: 파일마다 다른 알림 코드 +## PHP 시절 파일마다 달랐던 알림 코드 PHP 레거시에서 알림은 채널별로 완전히 다른 파일에서 처리됐다. @@ -66,9 +66,9 @@ cron_email_batch.php → 이메일 배치 발송 ### DB 기반 자체 큐 - 그리고 Lock 장애 -DB 테이블을 큐처럼 써서 발송을 관리했다. `SEND_YN = 'N'`인 레코드를 크론이 주기적으로 폴링하고, 발송 후 `'Y'`로 업데이트하는 방식이다. 예약 발송은 `RESERVED_SEND_DATETIME` 컬럼으로 처리했다. +DB 테이블을 큐처럼 써서 발송을 관리했다. `SEND_YN = 'N'`인 레코드를 크론이 주기적으로 폴링하고 발송 후 `'Y'`로 업데이트하는 방식이다. 예약 발송은 `RESERVED_SEND_DATETIME` 컬럼으로 처리했다. -동작은 했지만, 어느 날 이 구조가 터졌다. 크론이 미발송 레코드를 `SELECT ... FOR UPDATE`로 잠그고 외부 API를 호출하는데, NHN Cloud 쪽 응답이 평소보다 느려지면서 트랜잭션이 길어졌다. 그 사이 같은 테이블에 INSERT하려는 다른 요청들 - 수업 예약 알림 등록, 결제 완료 알림 등록 - 이 줄줄이 Lock 대기에 걸렸다. 알림 발송 하나가 느려졌을 뿐인데 서비스 전체가 먹통이 된 것이다. +동작은 했지만 어느 날 이 구조가 터졌다. 크론이 미발송 레코드를 `SELECT ... FOR UPDATE`로 잠그고 외부 API를 호출하는데, NHN Cloud 쪽 응답이 평소보다 느려지면서 트랜잭션이 길어졌다. 그 사이 같은 테이블에 INSERT하려는 다른 요청들 - 수업 예약 알림 등록, 결제 완료 알림 등록 - 이 줄줄이 Lock 대기에 걸렸다. 알림 발송 하나가 느려졌을 뿐인데 서비스 전체가 먹통이 됐다. ![DB Lock 장애 상황](/images/noti-db-lock.png) @@ -76,9 +76,9 @@ DB 테이블을 큐처럼 써서 발송을 관리했다. `SEND_YN = 'N'`인 레 ## 독립 알림 서버 구축 -채널별로 흩어진 코드, 크론 지옥, DB Lock 장애까지 - PHP 알림 시스템의 문제는 명확했다. 이걸 메인 백엔드(Java/Spring)에 그대로 옮겨도 근본적인 문제는 달라지지 않는다. 알림은 외부 API 호출이 많아서 트래픽이 몰리는 시간대에 메인 서버까지 느려질 수 있고, 외부 API 장애가 결제나 수업 예약 같은 핵심 기능을 끌어내릴 위험이 있다. +채널별로 흩어진 코드, 크론 지옥, DB Lock 장애까지 - PHP 알림 시스템의 문제는 명확했다. 이걸 메인 백엔드(Java/Spring)에 그대로 옮겨도 근본적인 문제는 달라지지 않는다. 알림은 외부 API 호출이 많아서 트래픽이 몰리는 시간대에 메인 서버까지 느려질 수 있고 외부 API 장애가 결제나 수업 예약 같은 핵심 기능을 끌어내릴 위험이 있다. -알림은 비즈니스 로직과 분리해도 되는 영역이다. **메인 서버는 "무엇을 보낼지"만 결정하고, "어떻게 보낼지"는 별도 서버가 담당**하는 구조로 가기로 했다. 설계 초기에 [Duolingo가 5초 안에 600만 건의 알림을 발송하는 아키텍처](https://medium.com/@dmosyan/duolingo-sending-6m-notifications-within-5-seconds-c630145038c3)를 참고했는데, SQS 기반 비동기 처리와 채널별 분리라는 핵심 아이디어를 많이 차용했다. +알림은 비즈니스 로직과 분리해도 되는 영역이다. **메인 서버는 "무엇을 보낼지"만 결정하고 "어떻게 보낼지"는 별도 서버가 담당**하는 구조로 가기로 했다. 설계 초기에 [Duolingo가 5초 안에 600만 건의 알림을 발송하는 아키텍처](https://medium.com/@dmosyan/duolingo-sending-6m-notifications-within-5-seconds-c630145038c3)를 참고했는데, SQS 기반 비동기 처리와 채널별 분리라는 핵심 아이디어를 많이 차용했다. ### 기술 스택 @@ -181,9 +181,9 @@ flowchart LR 새 채널을 추가할 때 Service 구현체 하나만 만들면 된다. -### 멀티 테넌트: API Key 기반 +### API Key 기반 멀티 테넌트 -서비스마다 독립된 Application을 등록하고, API Key를 발급받아 사용한다. +서비스마다 독립된 Application을 등록하고 API Key를 발급받아 사용한다. ``` Application (서비스 A) @@ -205,7 +205,7 @@ Application (서비스 B) ### 예약 발송 -예약 알림은 DynamoDB에 저장하고, 1분 주기 스케줄러가 처리한다. +예약 알림은 DynamoDB에 저장하고 1분 주기 스케줄러가 처리한다. ```bash # 예약 등록 @@ -222,7 +222,7 @@ DELETE /api/notification/reservation/class-remind-12345 PHP 시절에는 크론 파일을 하나 더 만들어야 했던 예약 발송이, API 호출 한 번으로 끝난다. 취소도 가능하다. -Redis 분산 락으로 중복 처리를 방지하고, 스케줄러가 누락한 건이 있으면 다음 주기에 자동으로 복구한다. +Redis 분산 락으로 중복 처리를 방지하고 스케줄러가 누락한 건이 있으면 다음 주기에 자동으로 복구한다. ### 발송 이력 추적 @@ -241,17 +241,17 @@ POST /api/notification/history 발송 성공/실패 여부, 에러 메시지, 발송 시각을 조회할 수 있다. 기존에 "알림 안 왔어요" CS가 들어오면 SQS 로그를 뒤져야 했던 것에서, API 한 번으로 확인 가능해졌다. -## 메시지 생성: 템플릿 엔진과 RecipientResolver +## 템플릿 엔진과 RecipientResolver로 메시지 만들기 독립 알림 서버가 "어떻게 보낼지"를 담당한다면, 메인 백엔드의 `NotificationService`는 **"무엇을 보낼지"를 결정**하는 역할이다. 메시지 코드와 DTO 객체만 넘기면, 템플릿 치환부터 수신자 결정, SQS 발행까지 자동으로 처리된다. -### PHP 시절: 수동 파라미터 매핑 +### PHP 시절의 수동 파라미터 매핑 PHP에서는 알림을 보낼 때마다 템플릿 변수를 수동으로 매핑해야 했다. ```php // PHP: 변수 하나하나 직접 매핑 -ToastSender::send_single_message("PODO", "SUBSCRIBE_COMPLETE", [ +ToastSender::send_single_message("SERVICE", "SUBSCRIBE_COMPLETE", [ 'recipientNo' => $phone, 'studentName' => $user['real_name'], 'className' => $subscribe['sub_name'], @@ -260,7 +260,7 @@ ToastSender::send_single_message("PODO", "SUBSCRIBE_COMPLETE", [ ]); ``` -알림 종류마다 이런 매핑 코드가 있었고, 필드명이 바뀌거나 새 변수가 추가되면 발송하는 쪽 코드를 전부 수정해야 했다. +알림 종류마다 이런 매핑 코드가 있었고 필드명이 바뀌거나 새 변수가 추가되면 발송하는 쪽 코드를 전부 수정해야 했다. ### 객체만 넘기면 끝 @@ -322,7 +322,7 @@ public class PortoneDto { snake_case와 camelCase도 자동 변환되므로, 템플릿에서 `${student_name}`을 쓰든 `${studentName}`을 쓰든 같은 값이 들어간다. -### RecipientResolver: 채널별 수신자 결정 +### RecipientResolver로 채널별 수신자 결정 템플릿 변수가 치환된 후, **누구에게 보낼지**는 `RecipientResolver`가 결정한다. 채널마다 수신자 포맷이 다르기 때문에 - 카카오는 전화번호, 푸시는 디바이스 토큰, Slack은 채널명 - 각 채널별로 Resolver를 분리했다. @@ -388,7 +388,6 @@ flowchart TB ## 마치며 -알림이라는 기능을 일상에서 매우 쉽게 받아볼 수 있고 접할 수 있는 기능이지만 그걸 구현하는건 결코 쉬운일이 아니라는걸 다시금 깨달았다. -쉽게 접하는 이 기능이 유저에게 많은 액션을 줄 수 있고 때로는 유저의 피로도를 쌓을 수도 있기도 하면서 정말 많은 경험이 다시금 되었다. -알림 도메인은 트래픽을 쉽게 접할 수 있고 경험해볼수있는 좋은 도메인인것 같고 이번에 좋은 기회를 얻게 되어 경험해보게 되어 도움이 많이 된거 같다. -또한, 다른 비즈니스 기능을 개발하면서 알림 보내는 클라이언트 코드가 복잡한걸 느끼고 어떻게 개선해야될까 생각하면서 풀어나가고 실무에서 알림을 편하게 연동하고 써주는 모습을 보고 많이 뿌듯함을 느낀 프로젝트이기도 한다. \ No newline at end of file +알림은 사용자가 일상에서 쉽게 접하는 기능이지만 구현은 전혀 단순하지 않았다. 작은 메시지 하나가 사용자의 행동을 만들기도 하고 때로는 피로도를 쌓기도 한다. 그래서 알림 도메인은 트래픽과 사용자 반응을 함께 다루기 좋은 영역이었다. + +다른 비즈니스 기능을 개발할 때마다 알림 클라이언트 코드가 복잡해지는 문제도 계속 보였다. 그 문제를 직접 풀어내고 실무에서 편하게 연동되는 모습을 보면서 많이 배운 프로젝트였다. diff --git a/content/posts/ko/payment-system-migration-php-to-spring.md b/content/posts/ko/payment-system-migration-php-to-spring.md index 6de74ba..7741345 100644 --- a/content/posts/ko/payment-system-migration-php-to-spring.md +++ b/content/posts/ko/payment-system-migration-php-to-spring.md @@ -10,7 +10,7 @@ draft: false ## 배경 -우리 서비스의 결제 시스템은 PHP로 구현되어 있었다. 서비스 초기부터 쌓여온 코드라 기능은 동작했지만, 구조적인 한계가 뚜렷했다. +우리 서비스의 결제 시스템은 PHP로 구현되어 있었다. 서비스 초기부터 쌓여온 코드라 기능은 동작했지만 구조적인 한계가 뚜렷했다. ### PHP 결제 시스템의 구조 @@ -35,13 +35,13 @@ flowchart TB E -->|"실패"| G["ROLLBACK + Slack 에러 알림"] ``` -결제 유형마다 완전히 다른 PHP 파일이 진입점이었고, 각 파일 안에서 포트원 API 호출부터 DB 저장까지 모든 로직을 직접 구현하고 있었다. 가상계좌 웹훅은 결제 실행 없이 후처리만 담당했지만, 트랜잭션 관리와 DB 조작 코드는 동일하게 반복되고 있었다. +결제 유형마다 완전히 다른 PHP 파일이 진입점이었고 각 파일 안에서 포트원 API 호출부터 DB 저장까지 모든 로직을 직접 구현하고 있었다. 가상계좌 웹훅은 결제 실행 없이 후처리만 담당했지만 트랜잭션 관리와 DB 조작 코드는 동일하게 반복되고 있었다. 핵심 문제들: - **결제 로직이 여러 파일에 분산**: 수강권 구매, 수강권 연장, 구독 결제, 가상계좌 웹훅 등 결제 유형별로 완전히 다른 파일에 로직이 흩어져 있었다. 각 파일마다 포트원(Iamport) API 키를 하드코딩으로 넣고 있었다 -- **포트원 API 추상화 부재**: 포트원 API 호출 코드가 각 파일에 직접 박혀 있어서, API 버전을 올리려면 모든 파일을 수정해야 했다. 추상화 계층 없이 포트원 V1 API에 강하게 결합된 구조였다 -- **트랜잭션 관리가 수동**: 매 결제마다 직접 트랜잭션을 열고, 쿼리를 실행하고, 성공 시 커밋, 실패 시 롤백을 호출했다. 중간에 에러가 나면 정합성이 깨질 수 있는 구간이 있었다 +- **포트원 API 추상화 부재**: 포트원 API 호출 코드가 각 파일에 직접 박혀 있어서 API 버전을 올리려면 모든 파일을 수정해야 했다. 추상화 계층 없이 포트원 V1 API에 강하게 결합된 구조였다 +- **트랜잭션 관리가 수동**: 매 결제마다 직접 트랜잭션을 열고 쿼리를 실행하고 성공 시 커밋, 실패 시 롤백을 호출했다. 중간에 에러가 나면 정합성이 깨질 수 있는 구간이 있었다 - **멱등성 미보장**: 동일 webhook이 두 번 들어오면 중복 결제가 발생할 수 있었다 ### 결제 유형의 복잡성 @@ -57,14 +57,14 @@ flowchart TB | TRIAL_FREE | 무료 체험 | PG 결제 없이 체험 티켓 생성 | | BEHIND | 미납 결제 | BILLING과 동일 로직으로 미납분 처리 | -각 유형마다 후처리가 다르다 - 티켓 생성 방식, 구독 매핑, 다음 결제일 계산, 쿠폰 적용 등이 전부 다르다. PHP에서는 이 분기가 `if-else` 체인으로 처리되고 있었고, 새로운 결제 유형을 추가할 때마다 여러 파일을 수정해야 했다. +각 유형마다 후처리가 다르다 - 티켓 생성 방식, 구독 매핑, 다음 결제일 계산, 쿠폰 적용 등이 전부 다르다. PHP에서는 이 분기가 `if-else` 체인으로 처리되고 있었고 새로운 결제 유형을 추가할 때마다 여러 파일을 수정해야 했다. ### 왜 전면 재설계인가 부분 개선이 아닌 전면 재설계를 결정한 이유는 명확했다. 1. **기술 스택 통일**: 백엔드가 Java/Spring으로 전환되는 과정에서 결제만 PHP로 남아 있었다. 배포·모니터링·온콜 모두 이중 운영 -2. **포트원 API 추상화 부재**: PHP 코드에 포트원(Iamport) V1 API 호출이 직접 박혀 있어서, API 버전 업그레이드에 대응하기 어려운 구조였다 +2. **포트원 API 추상화 부재**: PHP 코드에 포트원(Iamport) V1 API 호출이 직접 박혀 있어서 API 버전 업그레이드에 대응하기 어려운 구조였다 3. **품질 문제**: 중복 결제, 환불 누락, 구독 갱신 오류 등 결제 관련 CS가 꾸준히 발생 | | PHP 레거시 | Java/Spring (목표) | @@ -76,9 +76,9 @@ flowchart TB | 후처리 | 동기 처리 (결제 응답 지연) | SQS 비동기 분리 | | 타입별 분기 | if-else 체인 | Strategy 패턴 | -## 설계: SQS 기반 이벤트 드리븐 +## SQS 기반 이벤트 드리븐 설계 -첫 번째 설계 방향은 **SQS 기반 이벤트 드리븐 아키텍처**였다. 결제 프로세스를 단계별로 나누고, 각 단계를 SQS 메시지로 연결하는 구조다. +첫 번째 설계 방향은 **SQS 기반 이벤트 드리븐 아키텍처**였다. 결제 프로세스를 단계별로 나누고 각 단계를 SQS 메시지로 연결하는 구조다. ### 왜 SQS인가 @@ -119,7 +119,7 @@ flowchart TB | PAYMENT_SCHEDULE | 다음 정기결제 예약 (Portone Schedule API) | | PAYMENT_FAILED | 결제 실패 처리 (재시도 or 구독 해지) | -첫 결제·체험·일시불은 클라이언트에서 결제가 완료되면 BFF(Next.js 서버)를 통해 SQS `PAYMENT` 메시지를 발행한다. 정기결제는 Portone Schedule API가 예약일에 결제를 실행하고, 그 결과가 웹훅으로 백엔드에 들어오면 마찬가지로 SQS `PAYMENT`를 발행한다. 이후 흐름은 동일하다 - 결제 검증과 정보 저장을 거친 뒤, `PAYMENT_DATA`와 `PAYMENT_SCHEDULE`을 각각 발행한다. 실패하면 `PAYMENT_FAILED`로 분기한다. +첫 결제·체험·일시불은 클라이언트에서 결제가 완료되면 BFF(Next.js 서버)를 통해 SQS `PAYMENT` 메시지를 발행한다. 정기결제는 Portone Schedule API가 예약일에 결제를 실행하고 그 결과가 웹훅으로 백엔드에 들어오면 마찬가지로 SQS `PAYMENT`를 발행한다. 이후 흐름은 동일하다 - 결제 검증과 정보 저장을 거친 뒤, `PAYMENT_DATA`와 `PAYMENT_SCHEDULE`을 각각 발행한다. 실패하면 `PAYMENT_FAILED`로 분기한다. ### Handler 기반 라우팅 @@ -131,17 +131,17 @@ SQS Queue → PaymentListener (@SqsListener) → PaymentGateway (실제 비즈니스 로직) ``` -`PaymentListener`가 SQS 큐를 구독하고, 메시지를 `PaymentActionHandler`에 전달하면, Handler가 `message.getAction()` 값에 따라 Gateway의 적절한 메서드를 호출하는 구조였다. Gateway가 결제 검증부터 후처리까지 모든 비즈니스 로직을 담당했고, Handler는 순수하게 라우팅 역할만 했다. +`PaymentListener`가 SQS 큐를 구독하고 메시지를 `PaymentActionHandler`에 전달하면, Handler가 `message.getAction()` 값에 따라 Gateway의 적절한 메서드를 호출하는 구조였다. Gateway가 결제 검증부터 후처리까지 모든 비즈니스 로직을 담당했고 Handler는 순수하게 라우팅 역할만 했다. -이 구조는 SQS 기반 비동기 흐름을 단순하게 유지하는 데는 좋았지만, Gateway 한 클래스가 모든 결제 유형의 검증·후처리·스케줄링 로직을 담으면서 빠르게 비대해지는 문제가 있었다. +이 구조는 SQS 기반 비동기 흐름을 단순하게 유지하는 데는 좋았지만 Gateway 한 클래스가 모든 결제 유형의 검증·후처리·스케줄링 로직을 담으면서 빠르게 비대해지는 문제가 있었다. -### 멱등성: 이중 방어 +### 멱등성을 이중으로 방어한다 동일한 결제 요청이 중복으로 들어오는 건 실제로 자주 발생한다. Webhook 재전송, 사용자 더블 클릭, 네트워크 재시도 등. **1단계 - SQS 메시지 레벨 멱등성 (AOP)** -SQS 메시지 수신 자체에 대한 중복 처리를 AOP로 차단했다. `@SqsListener` 메서드에 자동 적용되는 Aspect가 Redis에 `messageId`를 300초 TTL로 저장하고, 동일 메시지가 다시 들어오면 처리하지 않는다. +SQS 메시지 수신 자체에 대한 중복 처리를 AOP로 차단했다. `@SqsListener` 메서드에 자동 적용되는 Aspect가 Redis에 `messageId`를 300초 TTL로 저장하고 동일 메시지가 다시 들어오면 처리하지 않는다. ```java // SqsIdempotentAspect.java @@ -220,7 +220,7 @@ SQS 메시지 레벨 + 결제 건 레벨 이중 방어로, 어떤 경로로 중 9회차: 10/31 (말일 복원) ``` -홀딩이 누적되면서 결제일이 계속 바뀌고, 거기에 월말 보정까지 겹친다. 이걸 정확하게 계산하려면 **최초 결제일부터 전체 결제 이력을 재구성**해야 했다. 결제 횟수만큼 루프를 돌면서 각 회차의 예상 결제일을 구하고, 홀딩 이력을 순회하면서 홀딩 시작일 이후의 결제일을 전부 밀어주는 방식이었다. +홀딩이 누적되면서 결제일이 계속 바뀌고 거기에 월말 보정까지 겹친다. 이걸 정확하게 계산하려면 **최초 결제일부터 전체 결제 이력을 재구성**해야 했다. 결제 횟수만큼 루프를 돌면서 각 회차의 예상 결제일을 구하고 홀딩 이력을 순회하면서 홀딩 시작일 이후의 결제일을 전부 밀어주는 방식이었다. ```java // 전체 결제 이력 재구성 @@ -262,7 +262,7 @@ for (HoldDTO hold : holdList) { | 극단 케이스 | 31일 시작 + 4회 홀딩 (14일 + 15일 + 1일 + 29일) | | 결제 실패 | 결제 실패 후 재시도 | -테스트 코드는 결제 루프를 시뮬레이션하는 구조다. 각 회차에서 결제를 실행하고, `getNextPaymentDate`로 다음 결제일을 계산하고, 홀딩이 있으면 해당 회차에서 일수를 밀어준다. +테스트 코드는 결제 루프를 시뮬레이션하는 구조다. 각 회차에서 결제를 실행하고 `getNextPaymentDate`로 다음 결제일을 계산하고 홀딩이 있으면 해당 회차에서 일수를 밀어준다. ```java @Test @@ -320,7 +320,7 @@ public void test_complexHolding() { 13회: 3/28 ``` -월말 보정, 홀딩 밀림, 월 경계 넘김이 동시에 적용되는 케이스다. 처음에는 "초기 결제일 + 마지막 결제일" 두 개의 파라미터로 계산을 시도했지만, 홀딩이 끼면 이전 결제일과의 관계도 고려해야 해서 결국 네 개의 파라미터를 받는 구조로 재설계했다. +월말 보정, 홀딩 밀림, 월 경계 넘김이 동시에 적용되는 케이스다. 처음에는 "초기 결제일 + 마지막 결제일" 두 개의 파라미터로 계산을 시도했지만 홀딩이 끼면 이전 결제일과의 관계도 고려해야 해서 결국 네 개의 파라미터를 받는 구조로 재설계했다. ```java public static LocalDate calculateNextDate( @@ -380,8 +380,8 @@ flowchart TD 하지만 SQS 기반 이벤트 드리븐 구조에서 예상치 못한 문제들이 드러났다. - **사용자 이벤트 추적의 어려움**: 결제 → 티켓 발급 → 알림이 각각 다른 SQS 메시지로 처리되다 보니, 하나의 결제 건에 대한 전체 흐름을 추적하기가 어려웠다 -- **트랜잭션 경계 문제**: SQS 메시지 발행과 DB 트랜잭션이 분리되면서, "DB는 커밋됐는데 SQS 발행이 실패"하거나 그 반대 상황이 발생 +- **트랜잭션 경계 문제**: SQS 메시지 발행과 DB 트랜잭션이 분리되면서 "DB는 커밋됐는데 SQS 발행이 실패"하거나 그 반대 상황이 발생 - **디버깅 복잡도**: 문제가 생겼을 때 어느 단계에서 실패했는지 파악하려면 SQS 로그, 애플리케이션 로그, DB 상태를 모두 크로스 체크해야 했다 -- **Gateway 비대화**: Handler는 라우팅만 했지만, Gateway 한 클래스가 모든 결제 유형의 검증·후처리·스케줄링을 담당하면서 코드가 계속 커졌다 +- **Gateway 비대화**: Handler는 라우팅만 했지만 Gateway 한 클래스가 모든 결제 유형의 검증·후처리·스케줄링을 담당하면서 코드가 계속 커졌다 이 문제들을 어떻게 해결했는지는 다음 글에서 다룬다. diff --git a/content/posts/ko/payment-system-redesign-sync-api.md b/content/posts/ko/payment-system-redesign-sync-api.md index b5e864d..703b407 100644 --- a/content/posts/ko/payment-system-redesign-sync-api.md +++ b/content/posts/ko/payment-system-redesign-sync-api.md @@ -1,7 +1,7 @@ --- title: "결제 시스템 재설계 (2) - SQS를 걷어내고 동기 API로" date: "2026-03-06" -description: "이벤트 드리븐의 매력에 빠져 도입한 SQS였지만, 결제 도메인에서는 오히려 복잡도만 높였다. SQS를 제거하고 동기 API + Portone 웹훅 기반으로 전환하면서 결제 프로세스를 안정화한 이야기." +description: "이벤트 드리븐의 매력에 빠져 도입한 SQS였지만 결제 도메인에서는 오히려 복잡도만 높였다. SQS를 제거하고 동기 API + Portone 웹훅 기반으로 전환하면서 결제 프로세스를 안정화한 이야기." tags: ["java", "spring-boot", "payment", "portone", "refactoring"] series: "결제 시스템 재설계" seriesOrder: 2 @@ -10,7 +10,7 @@ draft: false ## 1편 요약 -[1편](/posts/payment-system-migration-php-to-spring)에서는 PHP 레거시 결제 시스템을 Java/Spring + SQS 기반 이벤트 드리븐 아키텍처로 전환한 과정을 다뤘다. 결과적으로 PHP 탈출과 중복 결제 방지에는 성공했지만, SQS 기반 구조에서 예상치 못한 문제들이 드러났다. +[1편](/posts/payment-system-migration-php-to-spring)에서는 PHP 레거시 결제 시스템을 Java/Spring + SQS 기반 이벤트 드리븐 아키텍처로 전환한 과정을 다뤘다. 결과적으로 PHP 탈출과 중복 결제 방지에는 성공했지만 SQS 기반 구조에서 예상치 못한 문제들이 드러났다. - 결제 → 티켓 발급 → 알림이 각각 다른 SQS 메시지로 처리되어 전체 흐름 추적이 어려움 - SQS 발행과 DB 트랜잭션이 분리되면서 정합성 문제 발생 @@ -28,7 +28,7 @@ SQS 기반 구조의 가장 큰 문제는 **트랜잭션 경계**였다. 결제 PAYMENT → 결제 검증 + 저장 → PAYMENT_DATA 발행 + PAYMENT_SCHEDULE 발행 ``` -DB에 결제 정보를 저장하고, SQS에 다음 단계 메시지를 발행하는데 - 이 두 작업이 하나의 트랜잭션이 아니다. DB 커밋은 성공했는데 SQS 발행이 실패하면? 결제 정보는 저장됐지만 티켓 발급은 영원히 안 된다. 반대로 SQS 발행은 성공했는데 DB 롤백이 일어나면? 존재하지 않는 결제에 대해 후처리가 실행된다. +DB에 결제 정보를 저장하고 SQS에 다음 단계 메시지를 발행하는데 - 이 두 작업이 하나의 트랜잭션이 아니다. DB 커밋은 성공했는데 SQS 발행이 실패하면? 결제 정보는 저장됐지만 티켓 발급은 영원히 안 된다. 반대로 SQS 발행은 성공했는데 DB 롤백이 일어나면? 존재하지 않는 결제에 대해 후처리가 실행된다. ### 디버깅의 악몽 @@ -42,11 +42,11 @@ DB에 결제 정보를 저장하고, SQS에 다음 단계 메시지를 발행하 하나의 결제 건을 추적하는 데 여러 시스템을 넘나들어야 했다. **결제 도메인에서 비동기는 오버 엔지니어링이었다.** -## 설계: 동기 API + Portone 웹훅 +## 동기 API와 Portone 웹훅으로 다시 설계 ### 핵심 결정 -SQS를 제거하고, **Portone 웹훅이 백엔드를 직접 호출**하는 구조로 전환했다. +SQS를 제거하고 **Portone 웹훅이 백엔드를 직접 호출**하는 구조로 전환했다. ```mermaid flowchart TB @@ -76,11 +76,11 @@ flowchart TB | **디버깅** | 멀티 시스템 로그 | 하나의 콜스택 | | **결제 타입 분기** | if-else 체인 | Strategy 패턴 | -가장 큰 변화는 **모든 결제 처리가 하나의 트랜잭션 안에서 완결**된다는 점이다. 결제 검증, 티켓 생성, 구독 매핑, 알림 발송이 전부 하나의 HTTP 요청 안에서 처리되고, 하나라도 실패하면 전부 롤백된다. +가장 큰 변화는 **모든 결제 처리가 하나의 트랜잭션 안에서 완결**된다는 점이다. 결제 검증, 티켓 생성, 구독 매핑, 알림 발송이 전부 하나의 HTTP 요청 안에서 처리되고 하나라도 실패하면 전부 롤백된다. ## 웹훅 기반 결제 흐름 -### 프론트엔드: 웹훅 URL 생성 +### 프론트엔드에서 웹훅 URL 생성 클라이언트에서 결제를 시작할 때, **백엔드 웹훅 URL을 미리 구성**해서 Portone SDK에 전달한다. @@ -99,7 +99,7 @@ const createNotificationWebhookUrl = (): URL => { 결제 유형, 사용자 ID, 구독 ID, 쿠폰 ID 등 후처리에 필요한 정보를 **웹훅 URL의 쿼리 파라미터**에 실어 보낸다. Portone이 결제를 완료하면 이 URL로 웹훅을 쏜다. -### 프론트엔드: 폴링으로 결과 확인 +### 프론트엔드에서 폴링으로 결과 확인 웹훅은 백엔드로 직접 가기 때문에, 프론트엔드는 결제 결과를 **폴링**으로 확인한다. @@ -121,9 +121,9 @@ usePolling({ }) ``` -이전에는 BFF가 SQS에 메시지를 보내고, SQS가 백엔드로 전달하는 구조였다. 지금은 Portone이 백엔드에 직접 웹훅을 보내니까, BFF의 역할이 대폭 줄었다. +이전에는 BFF가 SQS에 메시지를 보내고 SQS가 백엔드로 전달하는 구조였다. 지금은 Portone이 백엔드에 직접 웹훅을 보내니까, BFF의 역할이 대폭 줄었다. -### 백엔드: 웹훅 수신 및 처리 +### 백엔드에서 웹훅 수신 및 처리 웹훅 엔드포인트에서는 먼저 **웹훅 타입을 필터링**한다. @@ -166,9 +166,9 @@ if (type == FIRST_BILLING) { } ``` -타입이 추가될 때마다 이 메서드가 비대해졌고, 하나의 타입을 수정할 때 다른 타입에 영향을 줄 위험이 있었다. +타입이 추가될 때마다 이 메서드가 비대해졌고 하나의 타입을 수정할 때 다른 타입에 영향을 줄 위험이 있었다. -### 2차: 타입별 Processor 분리 +### 2차 분리 - 타입별 Processor 각 결제 타입을 독립된 Processor로 분리했다. @@ -240,11 +240,11 @@ public interface PaymentValidator { ### 왜 V2인가 -V1(구 아임포트)은 REST API가 일부 비직관적이고, 웹훅 포맷이 단순해서 결제 상태를 정확히 파악하기 어려웠다. V2로 전환한 이유는 API 개선뿐 아니라, **개발 생산성** 측면이 컸다. +V1(구 아임포트)은 REST API가 일부 비직관적이고 웹훅 포맷이 단순해서 결제 상태를 정확히 파악하기 어려웠다. V2로 전환한 이유는 API 개선뿐 아니라, **개발 생산성** 측면이 컸다. **공식 Java SDK (`io.portone.sdk.server`)** -V1에서는 `HttpClient`로 직접 HTTP 요청을 보내고, `ObjectMapper`로 JSON을 수동 파싱하고, 결과를 `Map`로 다루고 있었다. 토큰 발급·갱신도 직접 구현해야 했다. V2 SDK는 이 모든 걸 타입 안전한 객체로 제공한다. +V1에서는 `HttpClient`로 직접 HTTP 요청을 보내고 `ObjectMapper`로 JSON을 수동 파싱하고 결과를 `Map`로 다루고 있었다. 토큰 발급·갱신도 직접 구현해야 했다. V2 SDK는 이 모든 걸 타입 안전한 객체로 제공한다. ```java // V1: 수동 HTTP + JSON 파싱 @@ -258,11 +258,11 @@ Map result = objectMapper.convertValue(dto.getResponse(), new Ty Payment payment = portOneClient.getPayment().getPayment(paymentId).get(); ``` -`Payment.Recognized`, `PaidPayment`, `FailedPayment` 같은 sealed interface 덕분에 패턴 매칭으로 결제 상태를 분기할 수 있고, 컴파일 타임에 누락된 케이스를 잡을 수 있다. +`Payment.Recognized`, `PaidPayment`, `FailedPayment` 같은 sealed interface 덕분에 패턴 매칭으로 결제 상태를 분기할 수 있고 컴파일 타임에 누락된 케이스를 잡을 수 있다. **포트원 MCP (Model Context Protocol)** -포트원이 제공하는 MCP 서버를 통해 Claude Code에서 V2 API 문서와 SDK 사용법을 직접 조회할 수 있다. V1 시절에는 문서를 브라우저에서 찾아봐야 했지만, V2는 개발 중에 MCP로 정확한 스펙을 바로 확인하면서 코드를 작성할 수 있다. 실제로 `PortoneV2Service`의 상당 부분을 MCP 기반으로 작성했다. +포트원이 제공하는 MCP 서버를 통해 Claude Code에서 V2 API 문서와 SDK 사용법을 직접 조회할 수 있다. V1 시절에는 문서를 브라우저에서 찾아봐야 했지만 V2는 개발 중에 MCP로 정확한 스펙을 바로 확인하면서 코드를 작성할 수 있다. 실제로 `PortoneV2Service`의 상당 부분을 MCP 기반으로 작성했다. **V2 API 자체의 개선** @@ -276,7 +276,7 @@ Payment payment = portOneClient.getPayment().getPayment(paymentId).get(); ## 결제 실패 재시도 -정기결제가 실패하면 자동으로 재시도한다. 최대 14일간 재시도하며, 횟수에 따라 알림 방식을 달리한다. +정기결제가 실패하면 자동으로 재시도한다. 최대 14일간 재시도하며 횟수에 따라 알림 방식을 달리한다. ```mermaid flowchart TB @@ -291,7 +291,7 @@ flowchart TB ## 자동 환불 시스템 -동기 API의 장점 중 하나는 **결제 처리 중 오류가 발생하면 즉시 환불**할 수 있다는 점이다. SQS 기반에서는 메시지 소비 실패 시 재시도 큐에 쌓이기만 했지만, 동기 구조에서는 try-catch 한 번으로 환불까지 처리된다. +동기 API의 장점 중 하나는 **결제 처리 중 오류가 발생하면 즉시 환불**할 수 있다는 점이다. SQS 기반에서는 메시지 소비 실패 시 재시도 큐에 쌓이기만 했지만 동기 구조에서는 try-catch 한 번으로 환불까지 처리된다. ```mermaid flowchart TB @@ -311,8 +311,8 @@ flowchart TB catch (BaseException e) { // 환불이 불필요한 케이스: 이미 취소됨, 미결제 상태, 중복 처리 중 if (Set.of(CANCELLED_PAYMENT, PAYMENT_NOT_PAID, ALREADY_PROCESSING) - .contains(e.getPodoStatusCode())) { - log.warn("[{}] 환불 스킵. paymentId: {}", e.getPodoStatusCode(), paymentId); + .contains(e.getErrorCode())) { + log.warn("[{}] 환불 스킵. paymentId: {}", e.getErrorCode(), paymentId); return; } @@ -335,11 +335,11 @@ catch (BaseException e) { `CANCELLED_PAYMENT`이나 `PAYMENT_NOT_PAID`는 사용자가 결제를 취소했거나 결제가 완료되지 않은 상태에서 웹훅이 온 경우다. 이미 돈이 빠져나가지 않았으니 환불할 필요가 없다. `ALREADY_PROCESSING`은 동일 결제에 대해 웹훅이 중복으로 들어온 경우로, 다른 스레드가 이미 처리 중이므로 스킵한다. -이 세 가지를 제외한 나머지 예외 - 검증 실패, 티켓 생성 오류, DB 에러 등 - 에서는 포트원 V2 API로 즉시 결제를 취소하고, 실패 정보를 `REFUNDED` 상태로 기록한 뒤, Slack으로 운영팀에 알린다. 예외를 다시 던져서 트랜잭션을 롤백하므로, **환불된 결제에 대한 티켓이나 구독이 남지 않는다.** +이 세 가지를 제외한 나머지 예외 - 검증 실패, 티켓 생성 오류, DB 에러 등 - 에서는 포트원 V2 API로 즉시 결제를 취소하고 실패 정보를 `REFUNDED` 상태로 기록한 뒤, Slack으로 운영팀에 알린다. 예외를 다시 던져서 트랜잭션을 롤백하므로, **환불된 결제에 대한 티켓이나 구독이 남지 않는다.** ## 전체 아키텍처 비교 -### Before: SQS 기반 이벤트 드리븐 +### Before - SQS 기반 이벤트 드리븐 ```mermaid flowchart TB @@ -367,7 +367,7 @@ flowchart TB E -->|"SQS"| G ``` -### After: 동기 API + Portone 웹훅 +### After - 동기 API + Portone 웹훅 ```mermaid flowchart TB @@ -411,8 +411,8 @@ flowchart TB ### 배운 점 -이벤트 드리븐 아키텍처는 강력한 패턴이지만, **모든 도메인에 적합한 건 아니다**. 결제처럼 **순차적이고, 트랜잭션 정합성이 중요하고, 실패 시 즉각 대응이 필요한** 도메인에서는 동기 처리가 더 적합했다. +이벤트 드리븐 아키텍처는 강력한 패턴이지만 **모든 도메인에 적합한 건 아니다**. 결제처럼 **순차적이고 트랜잭션 정합성이 중요하고 실패 시 즉각 대응이 필요한** 도메인에서는 동기 처리가 더 적합했다. -SQS가 빛을 발하는 건 알림 발송, 로그 적재, 이미지 처리처럼 **실패해도 재시도하면 되고, 순서가 중요하지 않은** 작업이다. 실제로 우리 시스템에서도 Slack 알림이나 리플레이 생성 같은 비결제 영역에서는 여전히 SQS를 쓰고 있다. +SQS가 빛을 발하는 건 알림 발송, 로그 적재, 이미지 처리처럼 **실패해도 재시도하면 되고 순서가 중요하지 않은** 작업이다. 실제로 우리 시스템에서도 Slack 알림이나 리플레이 생성 같은 비결제 영역에서는 여전히 SQS를 쓰고 있다. 기술 선택은 항상 도메인의 특성에 맞춰야 한다. diff --git a/content/posts/ko/react-hooks-deep-dive.md b/content/posts/ko/react-hooks-deep-dive.md index 29f6ff9..36c3bc0 100644 --- a/content/posts/ko/react-hooks-deep-dive.md +++ b/content/posts/ko/react-hooks-deep-dive.md @@ -12,7 +12,7 @@ React Hooks는 함수형 컴포넌트에서 상태 관리와 사이드 이펙트 ## useState 완벽 이해 -`useState`는 가장 기본적인 Hook입니다. 컴포넌트에 상태를 추가하고, 상태가 변경되면 리렌더링을 트리거합니다. +`useState`는 가장 기본적인 Hook입니다. 컴포넌트에 상태를 추가하고 상태가 변경되면 리렌더링을 트리거합니다. ```tsx const [count, setCount] = useState(0) @@ -30,7 +30,7 @@ const [data, setData] = useState(() => { ### 함수형 업데이트 -이전 상태를 기반으로 업데이트할 때는 함수형 업데이트를 사용해야 합니다. 특히 비동기 상황에서 stale closure 문제를 방지할 수 있습니다. +이전 상태를 기반으로 업데이트할 때는 함수형 업데이트를 사용합니다. 특히 비동기 상황에서 stale closure 문제를 방지할 수 있습니다. ```tsx setCount(prev => prev + 1) @@ -206,4 +206,4 @@ function useMediaQuery(query: string): boolean { ## 마치며 -React Hooks는 단순해 보이지만, 올바르게 사용하려면 클로저, 참조 동일성, 렌더링 사이클에 대한 이해가 필요합니다. 각 Hook의 특성을 정확히 파악하고 적절한 상황에서 사용하는 것이 중요합니다. +React Hooks는 단순해 보이지만 올바르게 사용하려면 클로저, 참조 동일성, 렌더링 사이클에 대한 이해가 필요합니다. 각 Hook의 특성을 정확히 파악하고 적절한 상황에서 사용하는 것이 중요합니다. diff --git a/content/posts/ko/react-typescript-tips.md b/content/posts/ko/react-typescript-tips.md index d0ed80a..b2b4c57 100644 --- a/content/posts/ko/react-typescript-tips.md +++ b/content/posts/ko/react-typescript-tips.md @@ -12,7 +12,7 @@ React와 TypeScript를 함께 사용하면 코드의 안전성과 가독성이 ## Props 타입 정의 -컴포넌트의 Props를 명확하게 타입으로 정의하는 것이 기본입니다. `interface`를 사용하면 IDE의 자동완성 지원을 최대한 활용할 수 있고, 잘못된 Props 전달을 컴파일 타임에 잡아낼 수 있습니다. +컴포넌트의 Props를 명확하게 타입으로 정의하는 것이 기본입니다. `interface`를 사용하면 IDE의 자동완성 지원을 최대한 활용할 수 있고 잘못된 Props 전달을 컴파일 타임에 잡아낼 수 있습니다. ```tsx interface ButtonProps { @@ -61,4 +61,4 @@ function List({ items, renderItem, keyExtractor }: ListProps) { 커스텀 훅을 작성할 때 반환 타입을 명시적으로 지정하면 사용하는 측에서 타입 추론이 정확해지고 코드 의도가 명확해집니다. -TypeScript를 처음 도입할 때는 `any` 타입을 피하고, 점진적으로 타입을 구체화해 나가는 방식을 권장합니다. 처음부터 완벽한 타입을 작성하려 하기보다, 코드가 동작하면서 타입을 다듬어 가는 것이 현실적인 접근법입니다. +TypeScript를 처음 도입할 때는 `any` 타입을 피하고 점진적으로 타입을 구체화해 나가는 방식을 권장합니다. 처음부터 완벽한 타입을 작성하려 하기보다, 코드가 동작하면서 타입을 다듬어 가는 것이 현실적인 접근법입니다. diff --git a/content/posts/ko/spring-ai-cs-automation.md b/content/posts/ko/spring-ai-cs-automation.md index 7eb4986..4510aef 100644 --- a/content/posts/ko/spring-ai-cs-automation.md +++ b/content/posts/ko/spring-ai-cs-automation.md @@ -1,7 +1,7 @@ --- title: "Spring AI로 CS 자동화 시스템 구축하기 - Function Calling과 FAQ 지침서" date: "2026-07-06" -description: "반복되는 고객 문의를 Spring AI 기반으로 자동화한 경험. Function Calling으로 사용자 맥락을 조회하고, FAQ를 지침서로 등록해 자동 답변과 상담 초안 추천을 함께 구성한 이야기." +description: "반복되는 고객 문의를 Spring AI 기반으로 자동화한 경험. Function Calling으로 사용자 맥락을 조회하고 FAQ를 지침서로 등록해 자동 답변과 상담 초안 추천을 함께 구성한 이야기." tags: ["spring-ai", "spring-boot", "ai", "llm", "cs"] draft: false --- @@ -15,18 +15,18 @@ draft: false - "환불하면 언제 돌아오나요?" - "쿠폰이 적용이 안 돼요" -이런 문의는 답변이 거의 정해져 있고, 필요한 정보도 대부분 우리 DB 안에 있다. 그런데도 상담사가 매번 관리자 페이지를 열어 사용자 상태를 확인하고, 비슷한 답변을 손으로 다시 작성하고 있었다. 반복 문의가 상담 리소스를 잡아먹으니 정작 사람이 판단해야 하는 민감한 문의의 응대 속도까지 느려졌다. +이런 문의는 답변이 거의 정해져 있고 필요한 정보도 대부분 우리 DB 안에 있다. 그런데도 상담사가 매번 관리자 페이지를 열어 사용자 상태를 확인하고 비슷한 답변을 손으로 다시 작성하고 있었다. 반복 문의가 상담 리소스를 잡아먹으니 정작 사람이 판단해야 하는 민감한 문의의 응대 속도까지 느려졌다. -그래서 **반복 문의를 자동으로 처리하고, 사람이 봐야 하는 문의는 초안까지 만들어 주는** CS 자동화 시스템을 Java/Spring 위에 새로 구축하기로 했다. +그래서 **반복 문의를 자동으로 처리하고 사람이 봐야 하는 문의는 초안까지 만들어 주는** CS 자동화 시스템을 Java/Spring 위에 새로 구축하기로 했다. -## 목표: "다 자동화"가 아니라 "잘 나누기" +## 목표는 다 자동화가 아니라 잘 나누기 -CS 자동화라고 하면 흔히 "AI가 모든 문의에 알아서 답한다"를 떠올리지만, 실제로 그렇게 하면 사고가 난다. 결제·환불처럼 돈이 걸린 문의에 AI가 틀린 답을 자동 발송하면 그게 곧 2차 CS다. +CS 자동화라고 하면 흔히 "AI가 모든 문의에 알아서 답한다"를 떠올리지만 실제로 그렇게 하면 사고가 난다. 결제·환불처럼 돈이 걸린 문의에 AI가 틀린 답을 자동 발송하면 그게 곧 2차 CS다. 그래서 처음부터 목표를 이렇게 잡았다. 1. **단순하고 확실한 문의** → AI가 사용자 맥락까지 조회해서 **자동 답변 발송** -2. **애매하거나 민감한 문의** → AI가 **답변 초안을 추천**하고, 운영자가 검토 후 발송 +2. **애매하거나 민감한 문의** → AI가 **답변 초안을 추천**하고 운영자가 검토 후 발송 즉 자동화의 핵심은 "얼마나 많이 자동으로 보내느냐"가 아니라 **자동으로 보내도 되는 것과 사람이 봐야 하는 것을 얼마나 잘 가르느냐**에 있었다. @@ -43,16 +43,16 @@ flowchart TB ## 왜 Spring AI인가 -우리 팀은 이미 Spring Boot 위에서 일하고 있었고, CS 자동화에 필요한 데이터(주문·결제·수강 상태)도 전부 기존 Spring 서비스 안에 있었다. 그렇다면 굳이 별도 파이썬 서버를 띄워 우리 API를 다시 호출하게 만들 이유가 없었다. +우리 팀은 이미 Spring Boot 위에서 일하고 있었고 CS 자동화에 필요한 데이터(주문·결제·수강 상태)도 전부 기존 Spring 서비스 안에 있었다. 그렇다면 굳이 별도 파이썬 서버를 띄워 우리 API를 다시 호출하게 만들 이유가 없었다. Spring AI를 선택한 이유는 두 가지였다. - **Function Calling(Tool)** - LLM이 "이 사용자의 결제 내역을 조회해야겠다"고 판단하면, 우리가 등록해 둔 Java 메서드를 직접 호출하게 할 수 있다. 사용자 맥락을 프롬프트에 미리 다 욱여넣지 않아도 된다. -- **`ChatClient` 추상화** - 프로바이더가 바뀌어도 동일한 코드로 호출할 수 있고, 시스템 지침·구조화 출력 같은 걸 Spring 스타일로 깔끔하게 붙일 수 있다. +- **`ChatClient` 추상화** - 프로바이더가 바뀌어도 동일한 코드로 호출할 수 있고 시스템 지침·구조화 출력 같은 걸 Spring 스타일로 깔끔하게 붙일 수 있다. ## 설계 1 - FAQ를 "지침서"로 등록 -가장 먼저 한 일은 답변의 기준을 만드는 것이었다. 흩어져 있던 FAQ와 상담 가이드를 정리해 **시스템 지침(system prompt)** 형태로 등록했다. 모델이 자유롭게 지어내지 않고, 우리가 정한 답변 규칙과 톤 안에서만 답하도록 가두는 역할이다. +가장 먼저 한 일은 답변의 기준을 만드는 것이었다. 흩어져 있던 FAQ와 상담 가이드를 정리해 **시스템 지침(system prompt)** 형태로 등록했다. 모델이 자유롭게 지어내지 않고 우리가 정한 답변 규칙과 톤 안에서만 답하도록 가두는 역할이다. ```java String faqInstruction = """ @@ -69,7 +69,7 @@ String faqInstruction = """ """.formatted(serviceName, faqDocument); ``` -우리 FAQ는 규모가 크지 않아서, 벡터 스토어 기반 RAG까지 갈 필요 없이 **정리된 FAQ 문서를 시스템 프롬프트에 통째로 주입**하는 방식으로 충분했다. RAG는 지식베이스가 컨텍스트에 다 넣기 부담스러울 만큼 커지거나 자주 바뀔 때, 관련 조각만 검색해 넣어 토큰 비용과 환각을 줄이는 카드다. 문서가 감당 가능한 크기면 굳이 검색 파이프라인을 얹을 이유가 없다. +우리 FAQ는 규모가 크지 않아서 벡터 스토어 기반 RAG까지 갈 필요 없이 **정리된 FAQ 문서를 시스템 프롬프트에 통째로 주입**하는 방식으로 충분했다. RAG는 지식베이스가 컨텍스트에 다 넣기 부담스러울 만큼 커지거나 자주 바뀔 때, 관련 조각만 검색해 넣어 토큰 비용과 환각을 줄이는 카드다. 문서가 감당 가능한 크기면 굳이 검색 파이프라인을 얹을 이유가 없다. ## 설계 2 - Function Calling으로 사용자 맥락 조회 @@ -107,11 +107,11 @@ CsAnswer answer = chatClient.prompt() .entity(CsAnswer.class); // 구조화 출력 ``` -이제 LLM은 "만료일을 물었으니 `getEnrollmentStatus`를 불러야겠다"고 스스로 판단하고, 실제 DB 값을 근거로 답변을 만든다. 프롬프트에 사용자 정보를 미리 다 넣어둘 필요가 없으니 컨텍스트도 가벼워진다. +이제 LLM은 "만료일을 물었으니 `getEnrollmentStatus`를 불러야겠다"고 스스로 판단하고 실제 DB 값을 근거로 답변을 만든다. 프롬프트에 사용자 정보를 미리 다 넣어둘 필요가 없으니 컨텍스트도 가벼워진다. ## 설계 3 - 자동 발송 vs 초안 추천 -답변을 그냥 텍스트로 받지 않고, **"자동으로 보내도 되는지"까지 모델이 함께 판단**하도록 구조화 출력으로 받았다. +답변을 그냥 텍스트로 받지 않고 **"자동으로 보내도 되는지"까지 모델이 함께 판단**하도록 구조화 출력으로 받았다. ```java record CsAnswer( @@ -122,7 +122,7 @@ record CsAnswer( ) {} ``` -그리고 애플리케이션에서 최종 분기를 건다. 모델의 판단을 그대로 믿지 않고, **민감 카테고리는 확신도와 무관하게 사람에게 넘기는** 안전장치를 둔다. +그리고 애플리케이션에서 최종 분기를 건다. 모델의 판단을 그대로 믿지 않고 **민감 카테고리는 확신도와 무관하게 사람에게 넘기는** 안전장치를 둔다. ```java if (answer.autoSendable() @@ -136,11 +136,11 @@ if (answer.autoSendable() 기준은 보수적으로 잡았다. 확신도가 임계값(예: `0.9`) 이상이면서 결제·환불처럼 **돈이나 계정이 걸린 민감 카테고리가 아닌** 경우에만 자동 발송한다. 결제·환불·개인정보·계정 변경은 카테고리 자체를 민감으로 분류해, 모델이 아무리 확신해도 반드시 사람이 검토하게 했다. 새로 생기는 카테고리는 한동안 자동 발송을 끄고 초안만 쌓아 보며 품질을 확인한 뒤 자동화 범위에 넣었다. -결과적으로 상담사는 빈 화면에서 답변을 처음부터 쓰는 게 아니라, **이미 채워진 초안을 검토·수정해 바로 보내는** 방식으로 일하게 됐다. 확실한 문의는 아예 손을 안 대도 되고, 애매한 문의도 시작점이 있으니 응대 속도가 빨라진다. +결과적으로 상담사는 빈 화면에서 답변을 처음부터 쓰는 게 아니라, **이미 채워진 초안을 검토·수정해 바로 보내는** 방식으로 일하게 됐다. 확실한 문의는 아예 손을 안 대도 되고 애매한 문의도 시작점이 있으니 응대 속도가 빨라진다. ## 겪은 시행착오 -**1. 모델이 정책에 없는 걸 그럴듯하게 지어냈다.** 초기엔 "환불은 영업일 기준 3일" 같은, 우리 정책에 없는 숫자를 자신 있게 답하는 경우가 있었다. 그래서 지침서에 "근거가 없으면 추측하지 말고 확인이 필요하다고 답하라"를 명시하고, 금액·기간처럼 사실이 걸린 항목은 반드시 Function Calling으로 조회한 실제 값에만 근거하도록 강제했다. 구조화 출력으로 받은 답변도 발송 전에 형식과 필수값을 검증해 깨진 응답을 걸러냈다. +**1. 모델이 정책에 없는 걸 그럴듯하게 지어냈다.** 초기엔 "환불은 영업일 기준 3일" 같은, 우리 정책에 없는 숫자를 자신 있게 답하는 경우가 있었다. 그래서 지침서에 "근거가 없으면 추측하지 말고 확인이 필요하다고 답하라"를 명시하고 금액·기간처럼 사실이 걸린 항목은 반드시 Function Calling으로 조회한 실제 값에만 근거하도록 강제했다. 구조화 출력으로 받은 답변도 발송 전에 형식과 필수값을 검증해 깨진 응답을 걸러냈다. **2. 자동 발송을 너무 공격적으로 열었다가 좁혔다.** 처음엔 임계값을 낮게 잡았더니 애매한 답변까지 자동으로 나가려 했다. 오발송은 그 자체로 2차 CS이므로, 임계값을 보수적으로 올리고 민감 카테고리는 아예 자동 발송에서 제외했다. "많이 자동화"보다 "틀린 걸 안 보내는" 쪽으로 방향을 확실히 잡았다. @@ -160,7 +160,7 @@ if (answer.autoSendable() 자동 처리로 환산된 두 유형을 합치면 전체 문의의 **약 43%** 규모다. 이만큼을 자동 응답·초안 자동화로 흡수하면, 사람이 직접 응대하던 CS 처리량도 약 43% 줄어드는 셈이다. 나머지 약 57%는 금전·조사·민감 이슈라 사람이 최종 판단과 발송을 맡는다. -핵심은 "모든 문의를 자동화"가 아니라, 데이터로 확인한 자동화 가능 영역(약 43%)을 먼저 걷어내 상담사가 정말 판단이 필요한 문의에 집중하게 만드는 것이다. 상담사가 빈 화면에서 답을 새로 쓰는 일이 줄고, 확실한 문의는 손대지 않아도 처리되는 방향으로 간다. +핵심은 "모든 문의를 자동화"가 아니라, 데이터로 확인한 자동화 가능 영역(약 43%)을 먼저 걷어내 상담사가 정말 판단이 필요한 문의에 집중하게 만드는 데 있다. 상담사가 빈 화면에서 답을 새로 쓰는 일이 줄고 확실한 문의는 손대지 않아도 처리되는 방향으로 간다. ## 마치며 diff --git a/content/posts/ko/spring-ai-guide-01-setup.md b/content/posts/ko/spring-ai-guide-01-setup.md index d625c16..6e2e9c3 100644 --- a/content/posts/ko/spring-ai-guide-01-setup.md +++ b/content/posts/ko/spring-ai-guide-01-setup.md @@ -16,9 +16,9 @@ Spring AI를 처음 도입하려는 분들이 빠르게 시작할 수 있도록, ## Spring AI란 -[Spring AI](https://spring.io/projects/spring-ai)는 Spring 팀이 공식으로 개발하는 AI 통합 프레임워크다. Python 생태계의 LangChain과 비슷한 위치에 있지만, 접근 방식이 다르다. +[Spring AI](https://spring.io/projects/spring-ai)는 Spring 팀이 공식으로 개발하는 AI 통합 프레임워크다. Python 생태계의 LangChain과 비슷한 위치에 있지만 접근 방식이 다르다. -Spring AI의 핵심 철학은 **"기존 Spring 개발 경험을 그대로 쓸 수 있게"** 하는 것이다. 의존성 주입, 자동 설정, 프로퍼티 바인딩 같은 Spring Boot의 장점을 AI 영역에서도 동일하게 활용할 수 있다. +Spring AI의 핵심 철학은 **"기존 Spring 개발 경험을 그대로 쓸 수 있게"** 하는 데 있다. 의존성 주입, 자동 설정, 프로퍼티 바인딩 같은 Spring Boot의 장점을 AI 영역에서도 동일하게 활용할 수 있다. ```mermaid flowchart LR @@ -61,7 +61,7 @@ dependencies { > **참고**: Spring AI 1.0부터 artifact 이름이 변경됐다. 이전의 `spring-ai-openai-spring-boot-starter`가 `spring-ai-starter-model-openai`로 바뀌었으니 주의하자. -한 프로바이더만 쓴다면 해당 의존성 하나만 추가하면 된다. 우리는 세 개를 모두 사용하고 있지만, 처음 시작한다면 OpenAI 하나부터 시작하는 걸 추천한다. +한 프로바이더만 쓴다면 해당 의존성 하나만 추가하면 된다. 우리는 세 개를 모두 사용하고 있지만 처음 시작한다면 OpenAI 하나부터 시작하는 걸 추천한다. ### Maven @@ -125,7 +125,7 @@ BedrockProxyChatModel // AWS Bedrock GoogleGenAiChatModel // Google Gemini ``` -Spring Boot 자동 설정으로 해당 프로바이더의 `ChatModel` 빈이 자동 등록된다. 직접 사용할 수도 있지만, 일반적으로는 `ChatClient`를 통해 사용한다. +Spring Boot 자동 설정으로 해당 프로바이더의 `ChatModel` 빈이 자동 등록된다. 직접 사용할 수도 있지만 일반적으로는 `ChatClient`를 통해 사용한다. ### ChatClient @@ -153,9 +153,9 @@ public class ChatClientConfig { } ``` -Spring Boot가 `OpenAiChatModel`을 자동 생성하고, 우리는 그걸로 `ChatClient`를 만든다. +Spring Boot가 `OpenAiChatModel`을 자동 생성하고 우리는 그걸로 `ChatClient`를 만든다. -> **`spring.ai.chat.client.enabled`** 를 `false`로 설정하면 Spring AI가 자동으로 ChatClient 빈을 만들지 않는다. 여러 프로바이더를 사용할 때는 이렇게 자동 생성을 끄고, 위처럼 직접 Bean으로 등록하는 게 깔끔하다. +> **`spring.ai.chat.client.enabled`** 를 `false`로 설정하면 Spring AI가 자동으로 ChatClient 빈을 만들지 않는다. 여러 프로바이더를 사용할 때는 이렇게 자동 생성을 끄고 위처럼 직접 Bean으로 등록하는 게 깔끔하다. ```yaml spring: @@ -184,7 +184,7 @@ public class AiService { } ``` -`chatClient.prompt()`로 요청을 시작하고, `.user()`로 사용자 메시지를 설정하고, `.call()`로 호출하고, `.content()`로 응답 텍스트를 꺼낸다. +`chatClient.prompt()`로 요청을 시작하고 `.user()`로 사용자 메시지를 설정하고 `.call()`로 호출하고 `.content()`로 응답 텍스트를 꺼낸다. ### 3. System Prompt 추가 @@ -252,7 +252,7 @@ src/main/java/ ## 정리 -이번 편에서는 Spring AI 프로젝트 설정과 기본적인 ChatClient 사용법을 다뤘다. 핵심을 요약하면: +이번 편에서는 Spring AI 프로젝트 설정과 기본적인 ChatClient 사용법을 다뤘다. 핵심은 다음과 같다: - Spring AI는 Spring Boot 자동 설정과 완전히 통합된다 - 의존성 추가 + `application.yml` 설정만으로 바로 시작할 수 있다 diff --git a/content/posts/ko/spring-ai-guide-02-multi-provider.md b/content/posts/ko/spring-ai-guide-02-multi-provider.md index 40660ab..dbc7921 100644 --- a/content/posts/ko/spring-ai-guide-02-multi-provider.md +++ b/content/posts/ko/spring-ai-guide-02-multi-provider.md @@ -1,7 +1,7 @@ --- title: "Spring AI 적용 가이드 (2) - 멀티 프로바이더 전략" date: "2026-03-17" -description: "OpenAI, AWS Bedrock, Google Gemini를 하나의 ChatClient 인터페이스로 추상화하고, 런타임에 동적으로 전환하는 방법을 다룹니다." +description: "OpenAI, AWS Bedrock, Google Gemini를 하나의 ChatClient 인터페이스로 추상화하고 런타임에 동적으로 전환하는 방법을 다룹니다." tags: ["spring-ai", "spring-boot", "ai", "openai", "bedrock", "gemini"] series: "Spring AI 적용 가이드" seriesOrder: 2 @@ -74,7 +74,7 @@ spring: enabled: false # 자동 ChatClient 생성 비활성화 ``` -`chat.client.enabled: false`가 핵심이다. Spring AI는 기본적으로 하나의 `ChatClient` 빈을 자동 생성하는데, 멀티 프로바이더 환경에서는 이를 끄고 직접 등록해야 한다. +`chat.client.enabled: false`가 핵심이다. Spring AI는 기본적으로 하나의 `ChatClient` 빈을 자동 생성하는데, 멀티 프로바이더 환경에서는 이를 끄고 직접 등록한다. ### ChatClient Bean 등록 @@ -103,7 +103,7 @@ public class ChatClientConfig { ### Bedrock 별도 설정 -AWS Bedrock은 다른 프로바이더와 달리 `BedrockProxyChatModel`을 직접 빌드해야 한다. AWS 인증과 리전 설정이 필요하기 때문이다. +AWS Bedrock은 다른 프로바이더와 달리 `BedrockProxyChatModel`을 직접 빌드한다. AWS 인증과 리전 설정이 필요하기 때문이다. ```java @Bean @@ -147,7 +147,7 @@ public ChatClient.ChatClientRequestSpec getAi(String provider, String model, Str 각 프로바이더의 `ChatOptions` 구현체(`OpenAiChatOptions`, `GoogleGenAiChatOptions`, `BedrockChatOptions`)를 통해 모델명, temperature, maxTokens, responseFormat 등을 호출 시점에 동적으로 설정한다. -여기서 주목할 점은 `application.yml`에서 설정한 기본값을 **호출 시점에 오버라이드**할 수 있다는 것이다. 기본 모델은 `gpt-4o-mini`지만, 특정 호출에서는 `gpt-4o`를 쓰고 싶다면 `.options()`에서 모델만 바꿔주면 된다. +여기서 주목할 점은 `application.yml`에서 설정한 기본값을 **호출 시점에 오버라이드**할 수 있다는 점이다. 기본 모델은 `gpt-4o-mini`지만 특정 호출에서는 `gpt-4o`를 쓰고 싶다면 `.options()`에서 모델만 바꿔주면 된다. ## Bedrock Cross-Region Inference @@ -201,7 +201,7 @@ public Optional resolveProviderByModelId(String modelId) { } ``` -각 프로바이더의 모델 목록을 캐싱해두고, 모델 ID로 어떤 프로바이더에 속하는지 탐색한다. 덕분에 프롬프트 설정에서 `modelId`만 지정하면 프로바이더는 자동으로 결정된다. +각 프로바이더의 모델 목록을 캐싱해두고 모델 ID로 어떤 프로바이더에 속하는지 탐색한다. 덕분에 프롬프트 설정에서 `modelId`만 지정하면 프로바이더는 자동으로 결정된다. ### 모델 목록 캐싱 @@ -233,7 +233,7 @@ private void refreshModelCache() { ## Gemini 인증 설정 -Google Gemini(Vertex AI)는 인증 설정이 조금 복잡하다. 서비스 계정 키 파일을 사용하고, 토큰이 만료되면 자동으로 갱신해야 한다. +Google Gemini(Vertex AI)는 인증 설정이 조금 복잡하다. 서비스 계정 키 파일을 사용하고 토큰이 만료되면 자동으로 갱신한다. ```java @Bean("geminiRestClient") diff --git a/content/posts/ko/spring-ai-guide-03-prompt-structured-output.md b/content/posts/ko/spring-ai-guide-03-prompt-structured-output.md index af54bb7..2c8fd15 100644 --- a/content/posts/ko/spring-ai-guide-03-prompt-structured-output.md +++ b/content/posts/ko/spring-ai-guide-03-prompt-structured-output.md @@ -12,7 +12,7 @@ draft: false LLM을 서비스에 적용할 때, 코드보다 **프롬프트 품질**이 결과를 더 크게 좌우한다. 같은 모델이라도 프롬프트를 어떻게 작성하느냐에 따라 응답 품질이 천차만별이다. -문제는 프롬프트 엔지니어링이 반복적인 실험이 필요한 작업이라는 점이다. 프롬프트를 수정할 때마다 코드를 고치고 배포하는 건 비효율적이다. 그래서 우리는 **프롬프트를 DB에서 관리하고, 배포 없이 실시간으로 변경할 수 있는 구조**를 만들었다. +문제는 프롬프트 엔지니어링이 반복적인 실험이 필요한 작업이라는 점이다. 프롬프트를 수정할 때마다 코드를 고치고 배포하는 건 비효율적이다. 그래서 우리는 **프롬프트를 DB에서 관리하고 배포 없이 실시간으로 변경할 수 있는 구조**를 만들었다. 이번 편에서는 Spring AI의 메시지 모델부터, 프롬프트 관리 시스템과 Structured Output까지 다룬다. @@ -84,7 +84,7 @@ chatClient.prompt() 프롬프트에 동적 데이터를 삽입해야 하는 경우가 많다. 학습자 이름, 수업 내용, 레벨 등을 프롬프트에 넣어야 하는데, 매번 문자열을 직접 조합하면 코드가 지저분해진다. -우리는 `{변수명}` 패턴으로 프롬프트 템플릿을 작성하고, 런타임에 치환하는 방식을 사용한다. +우리는 `{변수명}` 패턴으로 프롬프트 템플릿을 작성하고 런타임에 치환하는 방식을 사용한다. ### 프롬프트 템플릿 예시 @@ -168,9 +168,9 @@ private String stringify(Object value) { ## DB 기반 프롬프트 관리 -프로덕션에서 프롬프트를 코드에 하드코딩하면 수정할 때마다 배포해야 한다. AI 서비스 운영에서 이건 큰 병목이다. +프로덕션에서 프롬프트를 코드에 하드코딩하면 수정할 때마다 배포가 필요하다. AI 서비스 운영에서 이건 큰 병목이다. -우리는 프롬프트를 DB 테이블에 저장하고, 백오피스에서 관리하는 구조를 만들었다. +우리는 프롬프트를 DB 테이블에 저장하고 백오피스에서 관리하는 구조를 만들었다. ### 프롬프트 엔티티 @@ -291,7 +291,7 @@ case "openai" -> { } ``` -이 스키마를 DB의 `responseSchema` 필드에 저장하고, `ChatRequest`에 담아서 전달하면 LLM이 이 구조를 따르는 JSON을 반환한다. +이 스키마를 DB의 `responseSchema` 필드에 저장하고 `ChatRequest`에 담아서 전달하면 LLM이 이 구조를 따르는 JSON을 반환한다. ### 프로바이더별 지원 현황 @@ -307,7 +307,7 @@ OpenAI 외의 프로바이더에서는 JSON Schema가 완벽하게 동작하지 ## 메시지 기반 요청 -단순한 system + user 구조를 넘어서, 여러 턴의 대화를 배열로 전달해야 할 때가 있다. Few-shot learning이나 대화 이력 기반 호출이 그런 경우다. +단순한 system + user 구조를 넘어서 여러 턴의 대화를 배열로 전달해야 할 때가 있다. Few-shot learning이나 대화 이력 기반 호출이 그런 경우다. ### ChatRequest 설계 @@ -330,7 +330,7 @@ public record ChatRequest( } ``` -`messages` 필드로 멀티 턴 대화를 지원한다. 각 메시지는 개별적으로 변수를 가질 수 있어서, 메시지마다 다른 데이터를 주입할 수 있다. +`messages` 필드로 멀티 턴 대화를 지원한다. 각 메시지는 개별적으로 변수를 가질 수 있어서 메시지마다 다른 데이터를 주입할 수 있다. ### 메시지 빌드 @@ -381,11 +381,11 @@ private void applyPrompts(ChatClientRequestSpec ai, ChatRequest chatRequest) { } ``` -`messages` 배열이 있으면 그걸 사용하고, 없으면 `systemPrompt` + `userPrompt` 조합을 사용한다. 두 방식이 혼재하지 않도록 명확히 분기했다. +`messages` 배열이 있으면 그걸 사용하고 없으면 `systemPrompt` + `userPrompt` 조합을 사용한다. 두 방식이 혼재하지 않도록 명확히 분기했다. ## 정리 -이번 편에서 다룬 내용을 요약하면: +이번 편에서 다룬 내용은 다음과 같다: - **Message 모델**: `SystemMessage`, `UserMessage`, `AssistantMessage`로 역할 기반 대화 구성 - **변수 템플릿링**: `{var}`, `{{var}}` 등 다양한 포맷을 자동 정규화해서 치환. 길이 역순 정렬로 부분 매칭 방지 diff --git a/content/posts/ko/spring-ai-pipeline-real-world.md b/content/posts/ko/spring-ai-pipeline-real-world.md index 0e79b9b..d0dee54 100644 --- a/content/posts/ko/spring-ai-pipeline-real-world.md +++ b/content/posts/ko/spring-ai-pipeline-real-world.md @@ -8,28 +8,29 @@ draft: false ## 배경 -우리 서비스에는 사용자의 음성 데이터를 AI로 분석해서 진단 리포트를 생성하는 기능이 있다. 초기 구현도 Java/Spring 기반이었지만, 구조가 단순했다. +우리 서비스에는 사용자의 음성 데이터를 AI로 분석해서 진단 리포트를 생성하는 기능이 있다. 초기 구현도 Java/Spring 기반이었지만 구조가 단순했다. - HTTP 클라이언트로 외부 AI API를 직접 호출하는 방식 -- 하나의 프롬프트에 모든 걸 담아서 LLM에 던지고, 피드백 → 문제 생성을 for 루프로 순차 처리 +- 하나의 프롬프트에 모든 걸 담아서 LLM에 던지고 피드백 → 문제 생성을 for 루프로 순차 처리 - 청킹이나 STT 보정 없이 raw 텍스트를 그대로 사용 - 결과물의 품질이 불안정 - 할루시네이션, 포맷 깨짐, 누락 등 - CS 인입이 꾸준히 발생 -특히 LLM 호출을 동기적으로 순차 처리하다 보니 전체 처리 시간이 길었고, 모델을 교체하려면 HTTP 클라이언트 코드를 직접 수정해야 했다. 프로바이더마다 다른 API 스펙, 인증 방식, 요청/응답 포맷을 각각 대응하는 것도 부담이었다. +특히 LLM 호출을 동기적으로 순차 처리하다 보니 전체 처리 시간이 길었고 모델을 교체하려면 HTTP 클라이언트 코드를 직접 수정해야 했다. 프로바이더마다 다른 API 스펙, 인증 방식, 요청/응답 포맷을 각각 대응하는 것도 부담이었다. ## 왜 Spring AI인가 기존 HTTP 직접 호출 방식의 한계를 해결하기 위해 선택지를 검토했다. -1. **HTTP 클라이언트 유지 + 구조 개선** - 기존 방식을 그대로 쓰되 파이프라인만 분리. 하지만 모델별 API 차이를 계속 직접 대응해야 한다 +1. **HTTP 클라이언트 유지 + 구조 개선** - 기존 방식을 그대로 쓰되 파이프라인만 분리. 하지만 모델별 API 차이를 계속 직접 대응한다 2. **LangChain4j** - Java용 LangChain 포팅. 기능은 많지만 Spring 생태계와의 통합이 아직 어색한 부분이 있었다 3. **Spring AI** - Spring 팀이 공식으로 만드는 프로젝트. Spring Boot 자동설정, 의존성 주입, 프로퍼티 바인딩 등 기존 Spring 개발 경험을 그대로 가져갈 수 있다 -**Spring AI를 선택한 이유는 명확했다.** 우리 팀이 이미 Spring Boot 위에서 일하고 있었고, 새로운 프레임워크를 배우는 데 시간을 쓰기보다 익숙한 패턴 위에서 빠르게 구현하고 싶었다. +**Spring AI를 선택한 이유는 명확했다.** 우리 팀이 이미 Spring Boot 위에서 일하고 있었고 새로운 프레임워크를 배우는 데 시간을 쓰기보다 익숙한 패턴 위에서 빠르게 구현하고 싶었다. + ~~솔직히 Spring 생태계에서 새로 나온 기술을 실제 프로덕션에 적용해보고 싶다는 개인적인 욕심도 있었다.~~ -무엇보다 Gemini, OpenAI, Amazon Bedrock 같은 서로 다른 LLM 프로바이더를 `ChatModel`이라는 하나의 인터페이스로 추상화해준다는 점이 결정적이었다. 기존에는 프로바이더마다 다른 API 스펙, 인증 방식, 요청/응답 포맷을 HTTP 클라이언트 레벨에서 각각 대응해야 했지만, Spring AI를 도입하면 동일한 `ChatClient` 코드로 어떤 모델이든 호출할 수 있다. 덕분에 스텝별로 다른 모델을 배치하더라도 코드의 일관성이 유지되고, 모델 교체가 설정 변경만으로 가능했다. +무엇보다 Gemini, OpenAI, Amazon Bedrock 같은 서로 다른 LLM 프로바이더를 `ChatModel`이라는 하나의 인터페이스로 추상화해준다는 점이 결정적이었다. 기존에는 프로바이더마다 다른 API 스펙, 인증 방식, 요청/응답 포맷을 HTTP 클라이언트 레벨에서 각각 대응해야 했지만 Spring AI를 도입하면 동일한 `ChatClient` 코드로 어떤 모델이든 호출할 수 있다. 덕분에 스텝별로 다른 모델을 배치하더라도 코드의 일관성이 유지되고 모델 교체가 설정 변경만으로 가능했다. ```java @Bean("openaiChatClient") @@ -58,7 +59,7 @@ public BedrockProxyChatModel bedrockChatModel(AiTimeoutProperties timeoutPropert } ``` -기존 Spring 프로젝트에 의존성 추가하고, `application.yml`에 모델 설정만 넣으면 바로 쓸 수 있었다. +기존 Spring 프로젝트에 의존성 추가하고 `application.yml`에 모델 설정만 넣으면 바로 쓸 수 있었다. ## 파이프라인 설계 @@ -83,15 +84,15 @@ flowchart TB QSJoin --> H["7. NotificationStep
완료 알림 발송"] ``` -### Step 1: LoadSrtStep - STT 자막 로드 +### Step 1 - LoadSrtStep - STT 자막 로드 -S3에서 STT(음성인식) 결과인 SRT 자막 파일을 가져오는 단계다. `S3AsyncClient`를 사용해 비동기로 파일을 조회하고, 보정된 파일이 있으면 우선 선택한다. LLM을 사용하지 않는 순수 I/O 스텝이다. +S3에서 STT(음성인식) 결과인 SRT 자막 파일을 가져오는 단계다. `S3AsyncClient`를 사용해 비동기로 파일을 조회하고 보정된 파일이 있으면 우선 선택한다. LLM을 사용하지 않는 순수 I/O 스텝이다. -### Step 2: ChunkStep - Semantic Chunking +### Step 2 - ChunkStep - Semantic Chunking 긴 텍스트를 의미 단위로 분할한다. 단순한 토큰 수 기반 분할이 아니라, LLM을 호출해 주제 전환 지점을 감지하고 논리적으로 끊는다. WPM, MLR, 문장 복잡도 같은 강의 메트릭도 함께 계산해서 프롬프트 컨텍스트로 활용한다. 이후 모든 스텝이 청크 단위로 병렬 처리되기 때문에, 파이프라인 전체의 품질과 성능을 좌우하는 중요한 스텝이다. -여기서 비용 최적화 포인트가 있다. LLM에 각 블록을 인덱스와 함께 입력으로 넘기고, 응답으로는 **블록 인덱스만** 돌려받는다. +여기서 비용 최적화 포인트가 있다. LLM에 각 블록을 인덱스와 함께 입력으로 넘기고 응답으로는 **블록 인덱스만** 돌려받는다. ```json // LLM 입력 - 블록에 인덱스 부여 @@ -114,11 +115,11 @@ S3에서 STT(음성인식) 결과인 SRT 자막 파일을 가져오는 단계다 LLM이 전체 텍스트를 다시 출력할 필요 없이 인덱스 번호(1~3 토큰)만 반환하면 되므로, 블록당 출력 토큰이 **약 80% 이상 절감**된다. 애플리케이션에서 인덱스를 원본 텍스트에 매핑하는 건 간단한 `subList()` 호출이다. -### Step 3: CorrectedStep - STT 오류 보정 (조건부) +### Step 3 - CorrectedStep - STT 오류 보정 (조건부) -Raw STT인 경우에만 실행되는 조건부 스텝이다. 각 청크 그룹을 `CompletableFuture.allOf()`로 병렬 처리하며, LLM이 STT 인식 오류(발음 유사 오타, 단어 누락 등)를 보정한다. 이미 보정된 SRT 파일이면 이 스텝을 건너뛴다. +Raw STT인 경우에만 실행되는 조건부 스텝이다. 각 청크 그룹을 `CompletableFuture.allOf()`로 병렬 처리하며 LLM이 STT 인식 오류(발음 유사 오타, 단어 누락 등)를 보정한다. 이미 보정된 SRT 파일이면 이 스텝을 건너뛴다. -여기서도 블록 인덱스 패턴을 활용한다. LLM은 **변경이 필요한 블록만** 인덱스와 보정 텍스트를 반환하고, 변경이 없는 블록은 아예 응답에 포함하지 않는다. +여기서도 블록 인덱스 패턴을 활용한다. LLM은 **변경이 필요한 블록만** 인덱스와 보정 텍스트를 반환하고 변경이 없는 블록은 아예 응답에 포함하지 않는다. ```json // LLM 입력 - 10개 블록 @@ -142,21 +143,21 @@ Raw STT인 경우에만 실행되는 조건부 스텝이다. 각 청크 그룹 전체 10개 블록 중 수정이 필요한 건 2개뿐이다. 나머지 8개는 응답에 포함하지 않으므로, **출력 토큰이 약 80% 절감**된다. 애플리케이션에서는 반환된 인덱스만 원본에 덮어쓰면 되니 로직도 단순하다. -### Step 4: AnalyzeMetricsStep - 지표 산출 +### Step 4 - AnalyzeMetricsStep - 지표 산출 청크를 다시 하나로 합쳐서 전체 강의에 대한 메트릭(WPM, 평균 발화 길이, 턴 수, 복잡도, 어휘 다양성)을 산출하고 DB에 진단 레코드를 생성한다. LLM을 사용하지 않는 분석 전용 스텝이다. -### Step 5: FeedbackStep - 청크별 LLM 피드백 생성 +### Step 5 - FeedbackStep - 청크별 LLM 피드백 생성 -파이프라인의 핵심. 각 청크에 대해 `CompletableFuture.allOf()`로 병렬 LLM 호출을 수행한다. 학습자 레벨에 따라 피드백 종류가 달라진다 - 초급은 **어휘(VOCAB)** 피드백, 중급 이상은 **문장(SENTENCE)** 교정 피드백을 생성한다. LLM 응답이 깨진 JSON일 경우 자체 repair 로직으로 복구를 시도하고, 실패하면 최대 3회 재시도한다. 생성된 피드백은 메시지 큐를 통해 비동기로 DB에 적재한다. +파이프라인의 핵심. 각 청크에 대해 `CompletableFuture.allOf()`로 병렬 LLM 호출을 수행한다. 학습자 레벨에 따라 피드백 종류가 달라진다 - 초급은 **어휘(VOCAB)** 피드백, 중급 이상은 **문장(SENTENCE)** 교정 피드백을 생성한다. LLM 응답이 깨진 JSON일 경우 자체 repair 로직으로 복구를 시도하고 실패하면 최대 3회 재시도한다. 생성된 피드백은 메시지 큐로 비동기 적재한다. -### Step 6: QuestionStep - 피드백 기반 문제 생성 +### Step 6 - QuestionStep - 피드백 기반 문제 생성 -이전 스텝에서 집계된 피드백 결과를 받아서, 각 피드백 아이템에 대해 병렬로 연습 문제를 생성한다. 문장 만들기, 빈칸 채우기, 객관식 등 다양한 유형의 문제가 만들어진다. 프롬프트 선택은 수업·피드백 식별자의 해시 기반으로 결정되어, 재시도 시에도 동일한 프롬프트가 선택되는 결정적(deterministic) 구조다. +이전 스텝에서 집계된 피드백 결과를 받아 각 피드백 아이템별로 연습 문제를 병렬 생성한다. 문장 만들기, 빈칸 채우기, 객관식 등 다양한 유형의 문제가 만들어진다. 프롬프트 선택은 수업·피드백 식별자의 해시 기반으로 결정되어, 재시도 시에도 동일한 프롬프트가 선택되는 결정적(deterministic) 구조다. -### Step 7: NotificationStep - 완료 알림 발송 +### Step 7 - NotificationStep - 완료 알림 발송 -진단 상태를 COMPLETED로 업데이트하고, 학습자에게 푸시 알림을 발송한다. 실패 시 Slack 알림을 보내 운영팀이 즉시 인지할 수 있도록 했다. +진단 상태를 COMPLETED로 업데이트하고 학습자에게 푸시 알림을 발송한다. 실패 시 Slack 알림을 보내 운영팀이 즉시 인지할 수 있도록 했다. ## 멀티 모델 배치 전략 @@ -172,9 +173,9 @@ Raw STT인 경우에만 실행되는 조건부 스텝이다. 각 청크 그룹 | QuestionStep | 프롬프트별 설정 | 문제 유형별 최적 모델 배치 | | NotificationStep | - | LLM 불필요, 알림 발송만 수행 | -Spring AI의 장점은 여기서 빛난다. `ChatModel` 인터페이스가 통일되어 있어서, 프로바이더만 Bean으로 등록해두면 런타임에 어떤 모델이든 동적으로 선택할 수 있다. +Spring AI의 장점은 여기서 빛난다. `ChatModel` 인터페이스가 통일되어 있어서 프로바이더만 Bean으로 등록해두면 런타임에 어떤 모델이든 동적으로 선택할 수 있다. -각 스텝은 DB에 저장된 프롬프트 설정에서 `modelId`를 읽어오고, `resolveProviderByModelId()`로 적절한 프로바이더(OpenAI, Bedrock, Gemini)를 선택한 뒤 요청을 보낸다. +각 스텝은 DB에 저장된 프롬프트 설정에서 `modelId`를 읽어오고 `resolveProviderByModelId()`로 적절한 프로바이더(OpenAI, Bedrock, Gemini)를 선택한 뒤 요청을 보낸다. ```java // 스텝 내부 - DB에서 읽어온 modelId로 프로바이더와 모델을 동적으로 결정 @@ -205,9 +206,9 @@ LLM을 프로덕션에서 쓸 때 가장 신경 쓰이는 부분이다. 두 가 LLM이 깨진 JSON을 반환하는 경우가 종종 있다. 특히 응답이 길어질수록 콤마 누락, 닫히지 않은 괄호, markdown 코드블록 래핑 같은 문제가 빈번하게 발생한다. -이에 대해 **2단계 복구 전략**을 적용했다. +여기에 **2단계 복구 전략**을 적용했다. -#### Phase 1: repairJson - 구문 레벨 복구 +#### Phase 1 - repairJson 구문 레벨 복구 파싱 실패 시 가장 먼저 `repairJson`으로 복구를 시도한다. 핵심은 **누락된 콤마 자동 삽입**이다. @@ -262,7 +263,7 @@ try { } ``` -#### Phase 2: LLM 재호출 (MAX_RETRY = 3) +#### Phase 2 - LLM 재호출 (MAX_RETRY = 3) `repairJson`으로도 복구가 안 되면, LLM을 다시 호출한다. 모든 스텝이 동일한 재시도 루프를 갖는다. @@ -300,9 +301,9 @@ JsonNode correctedNode = firstNonNull( ); ``` -스키마가 미묘하게 달라도 파이프라인이 깨지지 않도록 방어하는 것이다. +스키마가 미묘하게 달라도 파이프라인이 깨지지 않도록 방어한다. -최종적으로 모든 재시도가 실패하면 스텝별 실패 상태(FAILED_CHUNKING, FAILED_FEEDBACK 등)를 DB에 기록하고, Slack 알림으로 운영팀에 즉시 전파된다. +최종적으로 모든 재시도가 실패하면 스텝별 실패 상태(FAILED_CHUNKING, FAILED_FEEDBACK 등)를 DB에 기록하고 Slack 알림으로 운영팀에 즉시 전파된다. ## 무중단 모델·프롬프트 변경 @@ -318,7 +319,7 @@ AI 서비스 운영에서 빠질 수 없는 요구사항이다. 모델을 바꾸 ## 비동기 처리 -7단계를 순차적으로 실행하면 전체 처리 시간이 너무 길어진다. `DiagnosisGateway`에서 각 스텝을 `thenComposeAsync()`로 체이닝하고, 스텝 내부에서는 청크/피드백 단위로 병렬 실행해서 처리 시간을 줄였다. +7단계를 순차적으로 실행하면 전체 처리 시간이 너무 길어진다. `DiagnosisGateway`에서 각 스텝을 `thenComposeAsync()`로 체이닝하고 스텝 내부에서는 청크/피드백 단위로 병렬 실행해서 처리 시간을 줄였다. ```java // DiagnosisGateway - 스텝 간 순차 체이닝 @@ -336,7 +337,7 @@ CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) .thenApply(_ -> futures.stream().map(CompletableFuture::join).flatMap(List::stream).toList()); ``` -스텝 간에는 순서를 보장하되, 스텝 내부에서는 `CompletableFuture.allOf()`로 N개 청크를 동시에 처리한다. 비동기 경계를 넘을 때 MDC 컨텍스트(요청 추적 ID 등)가 유실되지 않도록 래퍼 유틸로 감싸고, DB 적재도 메시지 큐를 통해 비동기로 처리해서 파이프라인이 I/O에 블로킹되지 않도록 했다. 전체 처리 시간이 **6-7분에서 1-2분 내외로 약 75% 단축**됐다. +스텝 간에는 순서를 보장하되, 스텝 내부에서는 `CompletableFuture.allOf()`로 N개 청크를 동시에 처리한다. 비동기 경계를 넘을 때 MDC 컨텍스트(요청 추적 ID 등)가 유실되지 않도록 래퍼 유틸로 감싸고 DB 적재도 메시지 큐로 비동기 처리해서 파이프라인이 I/O에 블로킹되지 않도록 했다. 전체 처리 시간이 **6-7분에서 1-2분 내외로 약 75% 단축**됐다. ## 성과 @@ -371,4 +372,4 @@ Spring AI를 프로덕션에 적용하면서 느낀 점을 정리한다. ~~스스로 뿌듯한 마음에 셀프 고생했다고 slack에 올린 나..~~ 단일 프롬프트로 모든 걸 해결하려는 접근에서 벗어나, **작업을 명확히 분리하고 각 스텝에 최적화된 모델을 배치하는 전략**이 품질과 비용 모두에서 효과적이었다. -LLM을 프로덕션에 적용할 때 "어떤 모델을 쓸까"보다 "어떻게 파이프라인을 설계할까"가 더 중요한 질문이라는 걸 다시 한 번 확인한 프로젝트였고, java/spring 으로 개발하는 사람으로서 Spring AI를 직접 써볼 수 있는 기회가 있어서 의미있는 프로젝트로 기억에 많이 남는다. +LLM을 프로덕션에 적용할 때 "어떤 모델을 쓸까"보다 "어떻게 파이프라인을 설계할까"가 더 중요한 질문이라는 걸 다시 한 번 확인한 프로젝트였고 java/spring 으로 개발하는 사람으로서 Spring AI를 직접 써볼 수 있는 기회가 있어서 의미있는 프로젝트로 기억에 많이 남는다. diff --git a/content/posts/ko/spring-facade-pattern-layered-architecture.md b/content/posts/ko/spring-facade-pattern-layered-architecture.md index a3c671f..93604f5 100644 --- a/content/posts/ko/spring-facade-pattern-layered-architecture.md +++ b/content/posts/ko/spring-facade-pattern-layered-architecture.md @@ -10,7 +10,7 @@ draft: false 프로젝트 초기에는 전통적인 Spring MVC 패턴인 **Controller → Service → Repository** 구조를 사용하고 있었다. -서비스 규모가 작을 때는 문제가 없었지만, 결제·인증·알림·구독 등 도메인이 늘어나고 비즈니스 로직이 복잡해지면서 구조적인 문제가 하나둘 드러나기 시작했다. +서비스 규모가 작을 때는 문제가 없었지만 결제·인증·알림·구독 등 도메인이 늘어나고 비즈니스 로직이 복잡해지면서 구조적인 문제가 하나둘 드러나기 시작했다. ### 기존 구조의 문제점 @@ -53,7 +53,7 @@ if (!user.isActive()) { // SubscriptionService.java - 여기에도... ``` -이런 식으로 유저 검증 로직이 5~6곳에 복사되어 있었다. 유저 상태 체크 조건이 하나 추가되면 모든 곳을 찾아서 동일하게 수정해야 했고, 하나라도 빠뜨리면 버그가 됐다. 각 Service가 `UserRepository`를 직접 들고 있으니, 도메인 경계를 넘는 접근이 자연스럽게 퍼져나갔다. +이런 식으로 유저 검증 로직이 5~6곳에 복사되어 있었다. 유저 상태 체크 조건이 하나 추가되면 모든 곳을 찾아서 동일하게 수정해야 했고 하나라도 빠뜨리면 버그가 됐다. 각 Service가 `UserRepository`를 직접 들고 있으니, 도메인 경계를 넘는 접근이 자연스럽게 퍼져나갔다. **4. 테스트의 어려움** @@ -63,7 +63,7 @@ Service 하나를 테스트하려면 의존하는 다른 Service를 전부 모 ## Facade 패턴 도입 - Gateway 레이어 -이 문제를 해결하기 위해 **Controller와 Service 사이에 Gateway 레이어**를 도입했다. 디자인 패턴으로는 Facade 패턴에 해당하지만, 우리 팀에서는 여러 Service를 조합하여 API의 진입점 역할을 한다는 의미에서 **Gateway**라고 명명했다. +이 문제를 해결하기 위해 **Controller와 Service 사이에 Gateway 레이어**를 도입했다. 디자인 패턴으로는 Facade 패턴에 해당하지만 우리 팀에서는 여러 Service를 조합하여 API의 진입점 역할을 한다는 의미에서 **Gateway**라고 명명했다. ### 변경된 구조 @@ -206,7 +206,7 @@ public class AuthGateway { } ``` -기존에는 이 흐름이 `AuthService` 하나에 뭉쳐 있었고, OAuth 클라이언트 로직과 JWT 로직과 유저 조회 로직이 한 클래스에 섞여 있었다. Gateway를 도입하면서 각 Service는 자기 역할만 담당하고, Gateway가 흐름을 조합한다. +기존에는 이 흐름이 `AuthService` 하나에 뭉쳐 있었고 OAuth 클라이언트 로직과 JWT 로직과 유저 조회 로직이 한 클래스에 섞여 있었다. Gateway를 도입하면서 각 Service는 자기 역할만 담당하고 Gateway가 흐름을 조합한다. ### 유저 - UserGateway @@ -231,14 +231,14 @@ public class UserGateway { ... } - public PodoUserDto updateUserInfo(UpdateUserDTO dto) { + public UserDto updateUserInfo(UpdateUserDTO dto) { // 유저 정보 업데이트 + 관련 서비스 동기화 ... } } ``` -`UserGateway`는 8개 이상의 Service를 조합한다. 만약 이 의존성이 전부 `UserService`에 들어 있었다면, `UserService`는 수천 줄의 "God Class"가 됐을 것이다. +`UserGateway`는 8개 이상의 Service를 조합한다. 만약 이 의존성이 전부 `UserService`에 들어 있었다면, `UserService`는 수천 줄의 "God Class"가 됐을 가능성이 높다. ### 결제 - PaymentGateway @@ -269,7 +269,7 @@ public class PaymentGateway { } ``` -`PaymentService`는 이제 결제 도메인만 담당한다. 유저 조회, 쿠폰 적용, 구독 매핑은 각각의 Service가 책임지고, Gateway가 이들을 하나의 트랜잭션으로 엮는다. +`PaymentService`는 이제 결제 도메인만 담당한다. 유저 조회, 쿠폰 적용, 구독 매핑은 각각의 Service가 책임지고 Gateway가 이들을 하나의 트랜잭션으로 엮는다. --- @@ -303,11 +303,11 @@ private final CouponService couponService; // X // PaymentGateway에서 AuthGateway 직접 호출 금지 private final AuthGateway authGateway; // X -// Gateway는 Service만 의존해야 한다 +// Gateway는 Service만 의존한다 // 공통 로직이 필요하면 Service 레벨에서 해결 ``` -Gateway가 다른 Gateway를 호출하기 시작하면 레이어 간 의존 관계가 복잡해지고, 결국 기존 Service 간 순환 참조 문제가 Gateway 레벨에서 재현된다. Gateway는 항상 Service만 바라보도록 했다. +Gateway가 다른 Gateway를 호출하기 시작하면 레이어 간 의존 관계가 복잡해지고 결국 기존 Service 간 순환 참조 문제가 Gateway 레벨에서 재현된다. Gateway는 항상 Service만 바라보도록 했다. ### 4. Controller는 Gateway를 호출한다 (단, 단순 조회는 예외) @@ -340,15 +340,15 @@ public class PaymentController { ### 단일 책임 확보 -이 패턴을 도입한 가장 큰 목적이다. 각 Service가 자기 도메인에만 집중하는 구조를 만들고 싶었다. `PaymentService`는 결제만, `CouponService`는 쿠폰만, `UserInfoService`는 유저만 담당한다. 현재 17개 도메인이 각각의 Service로 분리되어 운영되고 있고, 원래 의도했던 대로 도메인 경계가 명확해졌다. +이 패턴을 도입한 가장 큰 목적이다. 각 Service가 자기 도메인에만 집중하는 구조를 만들고 싶었다. `PaymentService`는 결제만, `CouponService`는 쿠폰만, `UserInfoService`는 유저만 담당한다. 현재 17개 도메인이 각각의 Service로 분리되어 운영되고 있고 원래 의도했던 대로 도메인 경계가 명확해졌다. ### 재사용성 증가 -단일 책임을 확보하면 자연스럽게 따라오는 효과인데, 이것도 처음부터 노린 부분이다. `CouponService.applyCoupon()`은 `PaymentGateway`뿐 아니라 `SubscribeGateway`, `MarketingGateway`에서도 재사용할 수 있게 되었다. `UserInfoService.getUser()`도 마찬가지다. 이전에는 비슷한 로직이 각 Service에 복사되어 있었지만, 이제는 하나의 Service 메서드를 여러 Gateway에서 호출한다. Service가 도메인별로 하나의 책임만 갖게 만들면, 재사용은 설계하지 않아도 자연스럽게 발생한다. +단일 책임을 확보하면 자연스럽게 따라오는 효과인데, 이것도 처음부터 노린 부분이다. `CouponService.applyCoupon()`은 `PaymentGateway`뿐 아니라 `SubscribeGateway`, `MarketingGateway`에서도 재사용할 수 있게 되었다. `UserInfoService.getUser()`도 마찬가지다. 이전에는 비슷한 로직이 각 Service에 복사되어 있었지만 이제는 하나의 Service 메서드를 여러 Gateway에서 호출한다. Service가 도메인별로 하나의 책임만 갖게 만들면, 재사용은 설계하지 않아도 자연스럽게 발생한다. ### 온보딩 시간 단축 -새로운 팀원이 합류했을 때 "Controller에서 Gateway를 찾고, Gateway에서 흐름을 파악하면 된다"는 단순한 규칙 덕분에 코드를 이해하는 데 걸리는 시간이 줄었다. `@Gateway` 어노테이션 덕분에 IDE에서 검색해서 찾는 것도 쉽다. +새로운 팀원이 합류했을 때 "Controller에서 Gateway를 찾고 Gateway에서 흐름을 파악하면 된다"는 단순한 규칙 덕분에 코드를 이해하는 데 걸리는 시간이 줄었다. `@Gateway` 어노테이션 덕분에 IDE에서 검색해서 찾는 것도 쉽다. ### 테스트 용이성 @@ -360,7 +360,7 @@ Service 테스트 시 자기 도메인의 Repository만 모킹하면 되므로 ### Gateway가 비대해지는 문제 -Gateway에 너무 많은 로직을 넣으면 기존의 Service 비대화 문제가 Gateway로 옮겨갈 뿐이다. 실제로 `UserGateway`는 8개 이상의 Service에 의존하고 있는데, 이런 경우 Gateway 내부에서도 메서드를 역할별로 명확히 나누고, 비즈니스 판단은 Service나 도메인 객체에 위임해야 한다. +Gateway에 너무 많은 로직을 넣으면 기존의 Service 비대화 문제가 Gateway로 옮겨갈 뿐이다. 실제로 `UserGateway`는 8개 이상의 Service에 의존하고 있는데, 이런 경우 Gateway 내부에서도 메서드를 역할별로 명확히 나누고 비즈니스 판단은 Service나 도메인 객체에 위임한다. ```java // Bad - Gateway에 비즈니스 로직이 들어감 @@ -392,13 +392,13 @@ Gateway 간 직접 호출을 금지했기 때문에, 비슷한 흐름을 가진 아키텍처 개선을 고민할 때 헥사고날 아키텍처(포트 & 어댑터)도 검토했다. 도메인 로직을 외부 의존성으로부터 완전히 격리한다는 점에서 이상적인 구조이긴 하다. -하지만 결국 **트레이드오프**였다. 헥사고날 아키텍처를 도입하려면 Port 인터페이스 정의, Adapter 구현, 도메인 모델과 영속성 모델 분리 등 구조적 변경이 크다. 이미 운영 중인 서비스에 적용하려면 전체 코드베이스를 뒤집어야 하고, 팀원 전원이 새로운 개념을 학습해야 한다. 당장 해결해야 할 문제는 "Service 간 결합도를 낮추고 책임을 분리하는 것"이었는데, 그 목적에 비해 변경 범위가 과도했다. +하지만 결국 **트레이드오프**였다. 헥사고날 아키텍처를 도입하려면 Port 인터페이스 정의, Adapter 구현, 도메인 모델과 영속성 모델 분리 등 구조적 변경이 크다. 이미 운영 중인 서비스에 적용하려면 전체 코드베이스를 뒤집고 팀원 전원이 새로운 개념을 학습한다. 당장 해결할 문제는 "Service 간 결합도를 낮추고 책임을 분리하는 것"이었는데, 그 목적에 비해 변경 범위가 과도했다. Facade(Gateway) 패턴을 선택한 이유는 명확하다. -- **기존 구조를 유지하면서 개선할 수 있다** - Controller-Service-Repository라는 팀이 이미 익숙한 구조 위에 레이어 하나를 추가하는 것이다. 기존 코드를 전면 재작성할 필요가 없다. +- **기존 구조를 유지하면서 개선할 수 있다** - Controller-Service-Repository라는 팀이 이미 익숙한 구조 위에 레이어 하나를 추가한다. 기존 코드를 전면 재작성할 필요가 없다. - **학습 비용이 거의 없다** - `@Service`, `@Repository`와 같은 맥락의 `@Gateway` 어노테이션이기 때문에 새로운 개념이 아니라 기존 관례의 확장이다. -- **점진적 적용이 가능하다** - 한 번에 전체를 바꾸지 않고, 새로운 기능부터 Gateway를 적용하면서 기존 코드도 리팩토링 시 자연스럽게 전환할 수 있었다. +- **점진적 적용이 가능하다** - 한 번에 전체를 바꾸지 않고 새로운 기능부터 Gateway를 적용하면서 기존 코드도 리팩토링 시 자연스럽게 전환할 수 있었다. 완벽한 아키텍처보다 **팀이 실제로 지킬 수 있는 구조**가 더 중요하다고 판단했다. 아무리 좋은 아키텍처도 팀원들이 이해하지 못하거나 따르지 않으면 의미가 없다. @@ -408,4 +408,4 @@ Facade(Gateway) 패턴을 선택한 이유는 명확하다. 돌이켜보면 Gateway 레이어 자체보다 **팀이 규칙을 합의하고 지켜나가는 과정**이 더 어려웠다. 코드 리뷰에서 "이건 Service에서 처리해야 하지 않나요?", "Gateway에서 직접 Repository를 호출하고 있는데요"라는 피드백이 반복되면서 규칙이 체화되기까지 시간이 걸렸다. -현재 17개 도메인에 걸쳐 Gateway가 운영되고 있다. 새로운 기능을 개발할 때 기존 Service를 자연스럽게 재사용하게 됐고, "이 로직은 어디에 넣어야 하지?"라는 고민이 줄었다. 구조가 명확하면 코드를 작성할 위치도 자연스럽게 결정된다. +현재 17개 도메인에 걸쳐 Gateway가 운영되고 있다. 새로운 기능을 개발할 때 기존 Service를 자연스럽게 재사용하게 됐고 "이 로직은 어디에 넣어야 하지?"라는 고민이 줄었다. 구조가 명확하면 코드를 작성할 위치도 자연스럽게 결정된다. diff --git a/content/posts/ko/sync-virtual-coroutine-benchmark.md b/content/posts/ko/sync-virtual-coroutine-benchmark.md index c12c248..0c8d8e6 100644 --- a/content/posts/ko/sync-virtual-coroutine-benchmark.md +++ b/content/posts/ko/sync-virtual-coroutine-benchmark.md @@ -8,11 +8,11 @@ draft: false ## 왜 비교하는가 -서버 애플리케이션에서 동시성 모델 선택은 처리량과 응답 시간을 결정짓는 핵심 설계 요소다. 전통적인 Platform Thread 기반 스레드 풀은 오랫동안 표준이었지만, JDK 21에서 Virtual Thread가 정식 도입되고, Kotlin Coroutine이 JVM 생태계에서 존재감을 키우면서 선택지가 넓어졌다. +서버 애플리케이션에서 동시성 모델 선택은 처리량과 응답 시간을 결정짓는 핵심 설계 요소다. 전통적인 Platform Thread 기반 스레드 풀은 오랫동안 표준이었지만 JDK 21에서 Virtual Thread가 정식 도입되고 Kotlin Coroutine이 JVM 생태계에서 존재감을 키우면서 선택지가 넓어졌다. -이전에 [JDK 11에서 21로 마이그레이션](/posts/jdk-migration-strategy-11-to-25)하고, 이어서 [JDK 21에서 25로 올리면서 Virtual Thread를 도입](/posts/jdk-migration-strategy-21-to-25)했다. [결제 시스템을 SQS 비동기에서 동기 API로 전환](/posts/payment-system-redesign-sync-api)하는 과정에서 동기 코드의 동시성 확보가 관심사가 되었고, [멀티채널 알림 서버](/posts/multi-channel-notification-server)처럼 대량 동시 발송이 필요한 서비스에서는 동시성 모델 선택이 곧 처리량을 결정짓는다. +이전에 [JDK 11에서 21로 마이그레이션](/posts/jdk-migration-strategy-11-to-25)하고 이어서 [JDK 21에서 25로 올리면서 Virtual Thread를 도입](/posts/jdk-migration-strategy-21-to-25)했다. [결제 시스템을 SQS 비동기에서 동기 API로 전환](/posts/payment-system-redesign-sync-api)하는 과정에서 동기 코드의 동시성 확보가 관심사가 되었고 [멀티채널 알림 서버](/posts/multi-channel-notification-server)처럼 대량 동시 발송이 필요한 서비스에서는 동시성 모델 선택이 곧 처리량을 결정짓는다. -문제는 "**어떤 모델이 더 좋은가?**"에 대한 답이 워크로드에 따라 완전히 달라진다는 것이다. I/O 대기가 많은 서비스, 연산 집약적인 배치 처리, 수만 개의 동시 요청을 처리해야 하는 경우 각각 최적의 모델이 다르다. +문제는 "**어떤 모델이 더 좋은가?**"라는 질문의 답이 워크로드에 따라 완전히 달라진다는 점이다. I/O 대기가 많은 서비스, 연산 집약적인 배치 처리, 수만 개의 동시 요청을 처리하는 경우 각각 최적의 모델이 다르다. 직접 벤치마크를 만들어서 **10,000개 태스크 × 100회 반복** 조건으로 세 모델을 비교했다. 모니터링은 [이전에 구축한 LGTM 스택](/posts/lgtm-stack-observability)의 Prometheus + Grafana 조합을 활용했다. @@ -20,7 +20,7 @@ draft: false ### Platform Thread - 전통적 스레드 풀 -OS 커널 스레드와 1:1로 매핑되는 전통적 방식이다. `Executors.newFixedThreadPool(N)`으로 스레드 풀을 만들고, 풀 크기만큼만 동시에 실행된다. +OS 커널 스레드와 1:1로 매핑되는 전통적 방식이다. `Executors.newFixedThreadPool(N)`으로 스레드 풀을 만들고 풀 크기만큼만 동시에 실행된다. ```mermaid graph TB @@ -59,7 +59,7 @@ try (var executor = Executors.newFixedThreadPool(poolSize)) { ### Virtual Thread - JDK 21+의 경량 스레드 -JVM이 관리하는 경량 스레드다. OS 스레드 위에 N:M 매핑으로 동작하며, I/O 블로킹 시 자동으로 carrier thread에서 unmount된다. 코드는 동기식으로 작성하되 런타임이 비동기 최적화를 처리한다. +JVM이 관리하는 경량 스레드다. OS 스레드 위에 N:M 매핑으로 동작하며 I/O 블로킹 시 자동으로 carrier thread에서 unmount된다. 코드는 동기식으로 작성하되 런타임이 비동기 최적화를 처리한다. ```mermaid graph TB @@ -97,7 +97,7 @@ try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { ### Kotlin Coroutine - 언어 레벨 비동기 -컴파일러가 `suspend` 함수를 상태 머신(Continuation)으로 변환하는 방식이다. 스레드보다 가벼운 코루틴 객체를 힙에 생성하고, Dispatcher가 적절한 스레드 풀에 스케줄링한다. +컴파일러가 `suspend` 함수를 상태 머신(Continuation)으로 변환하는 방식이다. 스레드보다 가벼운 코루틴 객체를 힙에 생성하고 Dispatcher가 적절한 스레드 풀에 스케줄링한다. ```mermaid graph TB @@ -199,7 +199,7 @@ fun simulateCpu(iterations: Int): Long { #### 측정 방식 -각 태스크는 `System.nanoTime()`으로 개별 지연 시간을 측정하고, `Collections.synchronizedList()`에 수집한다. 메모리는 `Runtime.freeMemory()` 차이로, 처리량은 `taskCount * 1000 / totalMs`로 계산한다. +각 태스크는 `System.nanoTime()`으로 개별 지연 시간을 측정하고 `Collections.synchronizedList()`에 수집한다. 메모리는 `Runtime.freeMemory()` 차이로, 처리량은 `taskCount * 1000 / totalMs`로 계산한다. ### 공통 인터페이스 @@ -214,11 +214,11 @@ interface ConcurrencyScenario { } ``` -각 태스크는 `System.nanoTime()`으로 개별 지연 시간을 측정하고, `Collections.synchronizedList()`에 수집한다. 메모리는 실행 전후 `Runtime.freeMemory()` 차이로 계산한다. +각 태스크는 `System.nanoTime()`으로 개별 지연 시간을 측정하고 `Collections.synchronizedList()`에 수집한다. 메모리는 실행 전후 `Runtime.freeMemory()` 차이로 계산한다. ### 메트릭 수집 -Spring Boot Actuator + Micrometer로 Prometheus에 메트릭을 전송하고, Grafana 대시보드로 시각화한다. +Spring Boot Actuator + Micrometer로 Prometheus에 메트릭을 전송하고 Grafana 대시보드로 시각화한다. ```yaml # application.yml @@ -301,9 +301,9 @@ management: - Virtual Thread: 내부적으로 carrier thread에 스케줄링 → 결국 코어 수만큼 동시 실행 - Coroutine: `Dispatchers.Default` → 코어 수로 바운드된 풀이지만 코루틴 스케줄링 오버헤드 발생 -**Coroutine이 약 1.8배 느린 이유**: `Dispatchers.Default`는 코어 수만큼의 스레드 풀을 사용하지만, 10,000개 코루틴 간의 컨텍스트 스위칭과 `runBlocking` + `async` 조합의 스케줄링 비용이 순수 연산 시간에 비해 무시할 수 없는 수준으로 누적된다. +**Coroutine이 약 1.8배 느린 이유**: `Dispatchers.Default`는 코어 수만큼의 스레드 풀을 사용하지만 10,000개 코루틴 간의 컨텍스트 스위칭과 `runBlocking` + `async` 조합의 스케줄링 비용이 순수 연산 시간에 비해 무시할 수 없는 수준으로 누적된다. -**결론: CPU 바운드에서는 Platform Thread와 Virtual Thread가 동일하며, Coroutine은 스케줄링 오버헤드로 다소 불리하다.** +**결론: CPU 바운드에서는 Platform Thread와 Virtual Thread가 동일하며 Coroutine은 스케줄링 오버헤드로 다소 불리하다.** --- @@ -329,7 +329,7 @@ management: **Platform Thread의 한계**: 12개 스레드로 10,000개의 1ms 태스크를 처리하면 `10,000 × 1ms / 12 ≈ 833ms`. 컨텍스트 스위칭 오버헤드까지 더해 1,013ms가 걸린다. -**Virtual Thread vs Coroutine**: Virtual Thread는 태스크당 하나의 경량 스레드를 생성한다. 가볍긴 하지만 여전히 JVM 스레드 객체다. 반면 Coroutine은 `delay(1)`이 비블로킹이고, 컨텍스트 스위칭이 스레드 수준이 아닌 **힙의 Continuation 객체 교체**로 이루어진다. 스레드를 전혀 생성하지 않고도 10,000개의 동시 태스크를 처리할 수 있다. +**Virtual Thread vs Coroutine**: Virtual Thread는 태스크당 하나의 경량 스레드를 생성한다. 가볍긴 하지만 여전히 JVM 스레드 객체다. 반면 Coroutine은 `delay(1)`이 비블로킹이고 컨텍스트 스위칭이 스레드 수준이 아닌 **힙의 Continuation 객체 교체**로 이루어진다. 스레드를 전혀 생성하지 않고도 10,000개의 동시 태스크를 처리할 수 있다. 처리량, 지연 시간, 메모리 사용량 모두에서 Coroutine이 가장 우수한 결과를 보였다. @@ -341,7 +341,7 @@ management: ![시계열 대시보드](/images/trend.png) -시계열 그래프에서 Virtual Thread가 초반에 요동치는 구간이 보인다. 이는 JVM 웜업 과정으로, JIT 컴파일러가 핫 코드를 네이티브로 컴파일하고, `ForkJoinPool` 기반 carrier thread 풀이 work-stealing 균형을 잡는 과정에서 발생한다. Platform Thread는 고정 풀이라 초반부터 안정적이고, Coroutine은 힙 객체(Continuation)가 가벼워서 영향이 적다. +시계열 그래프에서 Virtual Thread가 초반에 요동치는 구간이 보인다. 이는 JVM 웜업 과정으로, JIT 컴파일러가 핫 코드를 네이티브로 컴파일하고 `ForkJoinPool` 기반 carrier thread 풀이 work-stealing 균형을 잡는 과정에서 발생한다. Platform Thread는 고정 풀이라 초반부터 안정적이고 Coroutine은 힙 객체(Continuation)가 가벼워서 영향이 적다. #### I/O Bound 처리량 추이 @@ -395,14 +395,14 @@ graph LR ## 어떤 모델을 선택할 것인가 -### Virtual Thread를 선택해야 할 때 +### Virtual Thread가 맞는 경우 - **기존 Java 코드베이스**에 최소한의 변경으로 성능을 개선하고 싶을 때 - **I/O 바운드 워크로드**가 지배적일 때 (DB 쿼리, HTTP 호출, 메시지 큐) - Spring Boot에서 `spring.threads.virtual.enabled=true` 한 줄로 적용하고 싶을 때 - 동기 코드 스타일을 유지하면서 스레드 풀 튜닝에서 벗어나고 싶을 때 -### Coroutine을 선택해야 할 때 +### Coroutine이 맞는 경우 - **Kotlin 프로젝트**이거나 Kotlin 도입이 가능한 환경일 때 - **높은 동시성** 시나리오 (수만~수십만 동시 태스크)에서 최대 성능이 필요할 때 @@ -417,7 +417,7 @@ graph LR ## WebFlux는 어떤가 -이 벤치마크에 WebFlux(Reactor)는 포함하지 않았지만, 비교 대상으로 자주 언급되므로 짚고 넘어간다. +이 벤치마크에 WebFlux(Reactor)는 포함하지 않았지만 비교 대상으로 자주 언급되므로 짚고 넘어간다. Virtual Thread와 WebFlux는 "I/O 대기 중 스레드를 점유하지 않는다"는 같은 문제를 풀지만 접근 방식이 다르다. @@ -450,7 +450,7 @@ Virtual Thread가 등장하면서 "I/O 성능을 위해 WebFlux를 써야 한다 ## 마무리 -세 모델의 성능 차이는 "어떤 것이 우월한가"가 아니라 "**어떤 워크로드에 적합한가**"로 해석해야 한다. +세 모델의 성능 차이는 "어떤 것이 우월한가"가 아니라 "**어떤 워크로드에 적합한가**"로 해석한다. - **I/O Bound** → Virtual Thread (기존 동기 코드 그대로, 설정만으로 처리량 대폭 개선) - **CPU Bound** → Platform Thread / Virtual Thread (Coroutine은 스케줄링 오버헤드로 다소 불리) @@ -458,8 +458,8 @@ Virtual Thread가 등장하면서 "I/O 성능을 위해 WebFlux를 써야 한다 일반적인 웹 서비스는 DB 쿼리, 외부 API 호출, 캐시 조회 등 I/O 대기가 요청 처리 시간의 대부분을 차지한다. 이런 환경에서 Virtual Thread는 JDK 버전업과 설정 몇 줄만으로 기존 동기 코드를 그대로 유지하면서 성능을 비약적으로 개선할 수 있다는 점이 가장 큰 장점이다. -Kotlin을 사용 중이고 높은 동시성이 필요하다면, Coroutine이 최고의 성능과 메모리 효율을 제공한다. 다만 코드 패러다임 전환 비용을 감안해야 한다. +Kotlin을 사용 중이고 높은 동시성이 필요하다면, Coroutine이 최고의 성능과 메모리 효율을 제공한다. 다만 코드 패러다임 전환 비용은 감안한다. -이 벤치마크는 `Thread.sleep()`과 단순 반복 연산으로 워크로드를 시뮬레이션한 것이다. 실무에서는 DB 커넥션 풀, 네트워크 레이턴시, GC 튜닝, 서드파티 라이브러리의 `synchronized` 사용 여부 등 훨씬 다양한 변수가 개입한다. 환경에 따라 여기서 본 성능 차이보다 효과가 미비할 수도 있고, 오히려 더 큰 차이가 날 수도 있다. 벤치마크 수치 자체보다는 각 모델의 특성과 트레이드오프를 이해하고, 자신의 워크로드에 맞게 판단하는 것이 중요하다. +이 벤치마크는 `Thread.sleep()`과 단순 반복 연산으로 워크로드를 시뮬레이션했다. 실무에서는 DB 커넥션 풀, 네트워크 레이턴시, GC 튜닝, 서드파티 라이브러리의 `synchronized` 사용 여부 등 훨씬 다양한 변수가 개입한다. 환경에 따라 여기서 본 성능 차이보다 효과가 미비할 수도 있고 오히려 더 큰 차이가 날 수도 있다. 벤치마크 수치 자체보다는 각 모델의 특성과 트레이드오프를 이해하고 자신의 워크로드에 맞게 판단하는 것이 중요하다. > **벤치마크 코드**: [jvm-concurrency-benchmark GitHub Repository](https://github.com/devy1540/jvm-concurrency-benchmark) diff --git a/content/posts/ko/tailwind-css-guide.md b/content/posts/ko/tailwind-css-guide.md index 38c89cd..00c98a3 100644 --- a/content/posts/ko/tailwind-css-guide.md +++ b/content/posts/ko/tailwind-css-guide.md @@ -47,4 +47,4 @@ Tailwind는 `dark:` 접두사를 통해 다크 모드 스타일을 손쉽게 적 ``` -Tailwind를 처음 사용할 때는 클래스가 많아 보여 가독성이 걱정될 수 있지만, 익숙해지면 오히려 스타일 파악이 더 빠르고 컴포넌트 단위로 스타일이 캡슐화되어 유지보수가 편리합니다. +Tailwind를 처음 사용할 때는 클래스가 많아 보여 가독성이 걱정될 수 있지만 익숙해지면 오히려 스타일 파악이 더 빠르고 컴포넌트 단위로 스타일이 캡슐화되어 유지보수가 편리합니다.