경계콘텍스트는 용어의 의미가 변하거나, 관심사가 완전히 달라지는 구역으로 EDA의 '이벤트'를 정의하는데 사용된다.
하지만 책에 서술된 내용은 모호하게 느껴졌고, 이렇게 나누는 것이 과연 맞는건지에 대한 의문도 있었다.
비즈니스의 전체 흐름을 시각화한 뒤 자연스럽게 경계를 찾기 위해 이벤트 스토밍을 진행했다.
관련된 자료를 리서치하고 실습을 진행하며 아쉬웠던 점을 복기하면서 "그래서 왜 EDA가 필요한가"에 대한 의문까지 조금 더 다가가게 되었다.
과정
miro 를 사용하여 각자 이벤트 포스트잇을 생성하고 시간순에 따라 정렬하며 액터, 커맨드, 외부시스템, 정책 등을 정해진 규칙(색상, 위치)에 따라 추가했다. 이벤트 까지는 비교적 순조롭게 진행이 되었지만, 외부시스템에 내부의 다른 서버, 외부 솔루션의 어떤 범위까지 포함해야 하는지, 액터에 유저는 기업회원, 일반 회원을 분리할 것인지 여부에 대한 논의가 명확하게 결론지어지지 않았다.
아쉬웠던 점
정책 포스트잇을 붙이는 데 어려움을 겪음. 코드로만 유추 가능
백엔드 코드, 용어에 아직 갇혀있다는 느낌을 받음. 사용자 여정 관점에서 분석하기 어려움
그래서 PM, 기획팀, 마케팅 팀도 같이 하면 좋겠다는 생각이 듬.
그러면 그 분들의 시간을 쓰는건데, 어떻게 설득하면 좋을지 고민함.
이 때 EDA의 필요성을 개발팀 뿐 아니라 전사적으로 생각하게 바뀌게 됨.
Discussion
EDA, DDD, MSA, 왜 필요한가?
결국엔 KPI 인 것 같음.
개발팀은 CQRS를 통해 성능 이슈를 해결하고 시스템의 복잡도를 낮추어(분할정복 알고리즘 관점) 이득.
기획팀은 언어가 통일되어 프로젝트 진행 속도가 빨라져서 이득
마케팅 팀은 이벤트로 구성된 OLAP을 활용하여 개발팀 공수 없이 다양한 데이터를 자유롭게 활용하게 되어 이득
더 나아가서..
목적 조직 필요성을 체감함. 모든 팀원이 같은 목적을 가지고 비슷한 비즈니스 이해도를 가지면 서비스 운영이 원활할 것 같음.
우리 팀에서는 검색 기능에 Elasticsearch를 사용한다. 배포 중 새 인덱스가 추가되거나 맵핑 변경이 포함된 경우에는 MySQL 데이터와의 동기화하는 작업이 필요한데, 시간이 오래 걸리는 문제가 있었다. 기존 작업은 새 인덱스를 만든 다음 모든 공고에 대해서 각 MySQL을 조회하여 특정 개수의 데이터를 모은 뒤 Elasticsearch 서버에 bulk API를 요청하고 있다. 마지막에 고정된 인덱스 별칭만 바꿔치기 한다.
주된 병목 원인인 MySQL 조회 쿼리 N+1 문제를 해결하여 실행시간을 33분에서 13분대로 개선했는데, Elasticsearch 서버의 지표를 모니터링하던 중 특이 사항이 있었다.
CPU 스로틀
Cgroup CFS Throttle Count가 벌크 인덱스 실행시간 내내 7~8을 유지했다.
cgroup(리소스 제한 정책)에 의해 CPU 사용이 강제로 차단된 횟수
예를 들어 이번 주기(100ms)가 시작된 지 겨우 30ms(물리적 시간) 만에, 모든 코어가 열일해서 할당된 Quota(200ms, CPU가 일한 시간, 여러 코어의 Sum)를 다 써버리면 커널은 이 컨테이너는 이번 주기 할당량을 다 썼다고 판단하고, 남은 70ms 동안 해당 컨테이너의 모든 프로세스를 Ready Queue에서 빼버린다. (할당량을 관리하는 것은 다른 컨테이너가 영향을 받지 않도록 하는 것 같다)
데이터 저장인데 왜 CPU가 문제일까 ?
Bulk 요청이 들어오면 CPU는 다음의 일을 한다.
JSON 파싱 및 역직렬화
텍스트를 토큰화하고, 형태소 분석을 하고, 역색인구조를 만듬
세그먼트 파일로 저장하기 위해 데이터를 압축
이 때, bulk 옵션으로 refresh(즉시 검색 결과에 반영)를 설정하면 아래 부하가 추가된다.
CPU는 메모리에 임시로 모아둔 데이터(In-memory buffer)를 꺼내어 리눅스 시스템의 파일 형태인Lucene Segment로 만듬
이 과정에서 데이터 구조를 재배열하고, 검색에 최적화된 포맷으로 변경하는 연산이 필요
해결 방법
기존 bulk API 요청마다 refresh를 하고 있었지만 굳이 그럴 필요가 없었기에, 비활성화 설정한 후 모든 bulk가 완료되고 나서 1회 Refresh 및 설정을 원상복구했다. 이로써 인덱싱 진행 중 CPU 스로틀은 1 정도로 감소했으며, CPU사용량도 100% 에서 74%로 줄었다.
Refreshes are resource-intensive. To ensure good cluster performance, it's recommended to wait for Elasticsearch's periodic refresh rather than performing an explicit refresh when possible.
스키마 드리프트를 감지하기 위해 쉐도우 데이터베이스에서 마이그레이션 히스토리, 즉 _prisma_migration 테이블에서 applied로 마킹된 항목을 로컬의 prisma/migrations 에서 찾아 순서대로 실행함
실행 완료 후 쉐도우 데이터베이스와 연결된 데이터베이스의 스키마가 다르면(Drift) 오류가 발생함
(적용되었다고 마킹된) 마이그레이션 파일을 수정(*)하거나 삭제(**)한 경우, 스키마를 수동으로 변경(***)한 경우 발생 가능함
마이그레이션 파일이 비정상적이라면 실행 중에 오류가 발생할 수도 있음(****)
보류 중인 마이그레이션을 쉐도우 데이터베이스에 적용
schema.prisma의 모든 변경 사항으로부터 새로운 마이그레이션을 생성
적용되지 않은 모든 마이그레이션을 개발 데이터베이스에 적용하고 _prisma_migrations 테이블을 업데이트함
마이그레이션 파일을 수정(*)한 경우 발생하는 오류
직면한 문제와 해결 시도
migrate dev를 실행하면 (경고 / 확인 메시지에 yes를 선택할 경우) database reset이 필요하여 로컬 데이터가 모두 삭제된다.
Case 1: 여러 작업을 병렬로 진행해야 함
이 상황은 다른 프로젝트에도 보편적으로 발생 가능한 조건이다. main 브랜치에서 딴 브랜치 A에서 Post 테이블을 추가하는 스키마 변경을 로컬 DB에 적용하여 기능을 테스트 하다가 (상용배포 X), 우선 순위가 높은 다른 작업을 요청받아 동일한 상태의 main 브랜치에서 생성한 B 브랜치에서 스키마 변경을 하면서 migrate dev를 실행하면 쉐도우 DB와 로컬 DB 간 차이, 즉 drift가 감지된다. migrate dev 동작 문단의 (**)에 해당하는 케이스이다.
쉐도우 데이터베이스와 로컬 데이터베이스와의 스키마가 불일치하고 마이그레이션 히스토리가 불일치한다는 두 가지 오류가 발생한다.
에러메시지 해석
예상 스키마(쉐도우데이터베이스의 스키마)가 실제 스키마가 되기 위해 변화가 필요하다
[+] Added tables: Post 테이블이 실제 DB에(만) 추가되어 있다
_prisma_migrations에 applied로 마킹되어있는 마이그레이션이 로컬 prisma/migrations 폴더에 존재하지 않는다
Solution 1: db push (제한이 있음) 또는 migrate diff 를 활용
db push
위의 문제 상황에서, main브랜치에서 checkout한 직후의 브랜치 B에서 npx prisma db push 를 실행하면 현재 schema.prisma 와 로컬 DB가 동기화되어 A 브랜치에서 적용된 변경사항과 연관 데이터가 제거된다. 그러나 _prisma_migration 테이블의 기록은 그대로기에 오류가 발생한다.
스키마 불일치 오류는 없어졌지만 여전히 마이그레이션 히스토리가 불일치한다는 오류는 발생한다.
이 오류는 브랜치 A에서 migrate dev를 실행할 때 --create-only를 설정하여 새 마이그레이션 파일을 생성하되 _prisma_migrations 테이블에는 적용하지 않고 db push 를 통해 스키마를 반영하고 테스트 한다면 해결 가능하다. 다만 직접 작성한 DML이 포함된 마이그레이션이라면 db push 를 사용할 수 없다는 예외가 있다.
참고로, db push 에 --force-reset 옵션을 활성화하면 _prisma_migrations 테이블의 모든 로우가 삭제되므로 적절하지 않다.
migrate diff
두 스키마 파일만을 비교하여 마이그레이션 파일을 생성하는 migrate diff 커맨드도 있다(이외에도 from 과 to 에 다양한 옵션이 가능하다). 이 기능을 사용하면 운영환경에 배포 후 롤백할 때 실행할 Down 마이그레이션 파일도 자동생성 가능하다.
이는 우리 프로젝트에서 발생하는 특수한 상황이다. 한 테이블 C 에 데이터를 생성하는 DML이 포함된 마이그레이션 파일이 있는데, 다른 테이블 D 의 컬럼을 외래키로 참조하는 컬럼을 포함하여 생성하므로 테이블 D에 해당 로우가 존재하지 않으면 DB 엔진에서 외래키 제약 오류가 발생한다. 이 때, 쉐도우 DB에는 기본적으로 데이터가 없으므로 오류가 발생한다. 운영환경에서는 데이터가 존재하기에 문제가 발생하지 않았지만 스키마와 무관한, 데이터의 유무에 따라 오류가 발생하는 SQL은 마이그레이션에 포함하기에 부적절하다고 판단하고 있다.
쉐도우 데이터베이스에 기존 마이그레이션 파일을 실행하는 도중 발생한다.
Solution 2: 마이그레이션 파일을 임의로 수정
테이블 D에 데이터를 생성하는 Insert문을 추가하거나, 테이블 C에 데이터를 생성하는 Insert문을 주석처리하는 임시방편이 있다. _prisma_migration 테이블의 로우가 비어있어야 가능하며, 운영환경의 데이터를 덤프했다면 체크섬 불일치로 오류가 발생할 수 있다. 이는 migrate dev동작 문단의 (*) 에 해당하는 케이스이다.
로컬 DB 관리에 대한 고민
문제를 경험하면서, 처음에는 어떻게 하면 스키마 drift를 피할 수 있을지 위주로 고민했다. 그러던 중 다른 팀에서는 로컬 DB를 어떻게 관리하는지가 궁금해졌고, 관련해서 조사하던 중 12 factors app 의 운영-개발 간 동등성 을 읽어보았다 주 내용은 운영환경에서의 예상치 못한 치명적인 버그를 방지하기 위해서는로컬 개발의 편의성보다 운영환경과 동일한 환경을 선택해야 한다는 것인데, main 브랜치의 마이그레이션 파일과의 차이가 있다면, 이것을 베이스로 하여 로컬 DB를 reset 하는 prisma migrate dev 커맨드의 동작이 이 원칙을 지키기 위한 목적으로 설계된 것일 수도 있겠다는 생각이 들었다.
오픈소스 활동
이러한 고민을 바탕으로 개발 생산성을 높일 수 있는 도구를 만들어보고자 한다.
prisma-migrator (개발 중단)
prisma migrate dev로 자동 실행된 마이그레이션 파일이 아니라 수동으로 생성한 파일은 실행 시 SQL 오류가 발생할 수도 있다. 이 때, 파일에 여러 SQL문이 있다면, 이 오류를 수정하여 다시 마이그레이션을 실행하려면 오류가 발생하기 전까지 정상 실행된 SQL 문을 수동으로 되돌려야 하는 점이 불편하여 롤백 파일이 있으면 마이그레이션이 실패했을 때 롤백 파일이 실행되도록 하는 라이브러리를 만들어 npm 에 배포해보았다. migrate dev는 스크립트로 실행되지 않도록 하는 제한이 있어 deploy를 대체로 사용하는 등 한계가 있고 롤백 파일도 직접 만들어야 하는 등 완성도가 떨어진다고 판단하여, 개발을 중단하고 새 프로젝트를 시작할 예정이다
lazy prisma 기여
lazy prisma는 lazy git, lazy docker 와 유사한 터미널 ui로 prisma의 커맨드를 편리하게 사용하고 마이그레이션 파일을 확인하기 좋은 오픈소스 프로젝트이다. 이 곳에 down 마이그레이션 파일 생성 기능을 요청하고 가능하다면 개발해보려고 한다.
checkout db (가칭) - 개발 브랜치마다 별도의 데이터베이스를 관리
일련의 조사와 고민을 통해 prisma를 사용하면서 로컬(개발) 데이터베이스를 관리하는 이상적인 방법은 작업 브랜치마다 별도의 데이터베이스를 관리하는 것이라는 결론을 내렸다. 이 아이디어를 토대로 git checkout -b {새 브랜치} 를 실행했을때 아래의 작업을 자동화하는 도구를 개발해보려고 한다.
AWS 서비스를 활용해서 서비스를 운영하면서 경험을 통해서 배운 지식도 많았지만, 모든 서비스를 훑어보고 싶다는 생각이 들어 준비하게 되었다. 가용성과 확장성, 응답 시간, 규정준수, 재해복구 수준과 같은 다양한 요구사항과 운영 오버헤드, 비용, 네트워크 대역폭 등의 제한조건에 맞게 AWS의 서비스를 적절히 구성하는 케이스를 스터디하는 중이다.
동기식 마이크로서비스 아키텍처 서비스를 운영하면서 여러 어려움이 있던 와중에, CTO님의 제안으로 팀 스터디를 진행하고 있다. 아직은 분산로그, 통신구조와 같은 소프트웨어 공학의 기초 지식을 쌓으며 우리 서비스에 어떻게 적용할 수 있을지 대략적인 구상만 하고 있다. 스터디에서 의견을 나누며 메시지큐나 서비스 메시와 같은 다른 대안들도 논의되었다.
클로드코드, 제미나이(대화, 코드 리뷰 봇), 크롬 개발자도구의 ai assistant를 사용하여 생산성을 높일 수 있었다.
개인 프로젝트의 플러터 개발은 클로드코드를 사용해서 진행하는데, claude.md 를 사용하여 컨텍스트가 늘어나도 잃어버리면 안되는 공통 요구사항을 관리했다.
제미나이를 쓸 때에는 ~ 이렇게 하는거 맞지? 보다는 이렇게 하는 것의 기술적 타당성과 비즈니스 관점에서의 장, 단점과 가능한 대안을 출처와 함께 요청하고, 방금 네 답변을 반박한다면? 과 같은 형태로 내 주장을 확인받는 것이 아니라 논리를 발전 시키는 데 활용했다.
이 밖에 google sheet에서도 ai 어시스턴트를 활용하여 기획팀과의 협업 시 데이터 변환 업무를 손쉽게 수행할 수 있었다.
그래서 앞으로..
잘한점, 앞으로도 이렇게 하자
런타임과 프레임워크, 프로그래밍 패턴과 아키텍처를 스터디하고, 업무에 적용해보자
AI를 능동적으로 활용하자
개발 생산성을 높이는 방안(깃헙액션, 공통로직 데코레이터 구현, 운영업무 스크립트화) 고민하기
못한점
이렇게는 하지말자 (DO NOT)
기술적 완성도가 가장 중요하다
배포 후 문제 없으면 끝
저 사람(주로 상사)이 맞겠지
이렇게 해보자 (DO)
기술은 비즈니스를 하기 위한 도구임. 현재 우선순위와 팀의 KPI를 파악
배포 후 모니터링 지표를 시각화 자료로 공유하며 운영 안정성을 모두가 확인 => 개발팀의 신뢰도와 투명성을 높임
이 방법을 적용했을 때 이슈가 없을까? 더 좋은 방법은 뭘까? 그 근거는? 혹은 의견에 동의하는 피드백을 전달
결론
필요한 기능을 개발하고 운영 이슈의 원인을 파악하여 타당한 방법으로 해결할 수 있는 단계를 지나서, 기술적 깊이와 탐구에 대한 목표를 달성한 1년이었다고 생각한다. 이제는 서비스의 안정성 측면에서의 더 나은 대안을 찾고, 업무 프로세스와 리스크 관리를 고민하는 과정을 통해 주니어에서 중니어로 넘어가보려고 노력해보려고 한다. 여러 내용을 썼지만 결국에는 비판적 사고 + 논리적 근거로 귀결되는 것 같기도 하다.
일반 유저가 검증여부를 설정한 경우 서비스 로직에서 조용히 무시하는 방법은 직관적이지 않고 디버깅의 어려움을 유발할 수 있기에, 이 경우에 오류를 던지는 방향으로 설계를 시작했다.
그러러면 요청의 특정 인풋 필드가 유효한 값을 가지는 경우 유저의 권한을 체크해야 하는데, 이 로직을 리졸버(GraphQL의 컨트롤러와 비슷하다) 또는 서비스에 직접 구현하기에는 비슷한 코드가 중복되는 분산으로 인하여 관리가 어렵고 비효율적이라고 판단했다. 따라서 Nest.js의 Enhancer을 활용하여 AOP(Aspect Oriented Programming) 를 적용해보았다.
작업하기
이를 구현하는 여러 방법 중 한 가지를 선택할 때의 근거는 과도한 패치보다는 제공되는 기능의 본 목적에 부합하게 사용하는 것이었다. 그리고 두 곳 이상에서 설정(예: DTO / 리졸버 등)할 필요가 없도록 하여 간단하게 사용가능하게 만들어서 휴먼 에러를 줄이려고 했다.
Nest.js에는 예외필터, 파이프, 가드, 인터셉터, 커스텀 데코레이터를 포함하는 Enhancer 기능을 제공한다.
예외가 발생하기 전 사전 처리이기에, Exception Filter는 제외했다.
권한을 체크하기 위해서는 요청 컨텍스트에서 유저 정보를 추출해야 했기에, guard 와 interceptor(before 단계)가 적합하다고 판단했다.
API 는 일반 유저도 요청은 가능하지만, 인풋 필드 일부에 대해서 보다 세밀하게 권한을 제어하는 AOP 를 구현하기 위해서 guard보다는 interceptor(before단계)가 적합하다고 판단했다.
Nest.js 요청의 수명주기
인터셉터는 정적으로 정의가능 할 경우 미리 정의된 하나의 클래스로 구현할 수도 있다. 이번에는 인터셉터에 커스텀 데이터를 전달하기 위해 팩토리 함수를 정의하고 이 함수(AdminOnlyInputFields)가 호출될 때마다 인터셉터 클래스가 만들어지도록 했다. 단 mixin을 통해 함수가 아닌 클래스를 반환함으로써 Nest.js의 DI 를 추후에 활용가능하도록 했다.
// foo.resolver.ts
@UseInterceptors(AdminOnlyInputFields(['isVerified'])) // 이 부분!
@Mutation(() => fooPayloadDto)
async foo(
@Args('input') input: fooInputDto,
): Promise<fooPayloadDto> {
return await this.barService.baz(input);
}
// admin-only-input.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, mixin, NestInterceptor, Type } from '@nestjs/common'
import { GqlExecutionContext } from '@nestjs/graphql'
import { GraphQLError } from 'graphql'
import { Observable } from 'rxjs'
export const AdminOnlyInputFields = (
fields: string[],
): Type<NestInterceptor> => {
@Injectable()
class AdminFieldSecurityInterceptor implements NestInterceptor {
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const gqlContext = GqlExecutionContext.create(context)
const { input } = gqlContext.getArgs()
const user = gqlContext.getContext().currentUser
if (!input) return next.handle()
if (fields.length > 0 && !user.isAdmin) {
for (const field of fields) {
if (input[field] !== undefined && input[field] !== null) {
throw new GraphQLError(
`'${field}' 필드는 관리자만 설정할 수 있습니다.`,
)
}
}
}
return next.handle()
}
}
return mixin(AdminFieldSecurityInterceptor)
}
한계
필드명을 문자열로 넘겨주기 때문에, 오타가 있으면 의도대로 동작하지 않는다.
다른 시도들
실패한 방법과 그 이유도 정리해보았다. 인풋 DTO 클래스 필드에 데코레이터를 달아 관리자전용 필드임을 나타내는 메타데이터를 설정하고, 인터셉터에서 해당 메타데이터가 설정된 필드를 찾아서 권한을 체크하는 방식을 시도했었다. 다음은 코드 스니펫이다.
// foo-input.dto.ts
import { Field, InputType } from '@nestjs/graphql'
@InputType('fooInput')
export class fooDto {
@Field(() => String)
name: string
@AdminOnlyInput() // 이 데코레이터를 사용하여 관리자가 아닌 유저가 isVerified를 요청하면 오류 발생
@Field(() => Boolean, {
nullable: true,
defaultValue: false,
})
isVerified: boolean
}
// admin-only-input.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ADMIN_ONLY_FIELDS_KEY = 'admin_only_fields';
export function AdminOnly() {
return (target: any, propertyKey: string) => {
Reflect.defineMetadata(ADMIN_ONLY_FIELDS_KEY, true, target, propertyKey);
};
}
// admin-only-input.interceptor.ts
@Injectable()
export class AdminOnlyInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = GqlExecutionContext.create(context);
const args = ctx.getArgs(); // 실제 유저가 보낸 데이터 { name: "...", isVerified: true }
const inputData = args.input || args;
const adminOnlyFields = Reflect.getMetadata(
ADMIN_ONLY_FIELDS_KEY,
Object.getPrototypeOf(inputData)
);
console.log(typeof inputData); // Object
console.log(adminOnlyFields); // [](빈 배열)
// 따라서 이 블록은 절대 실행되지 않는다.
if (adminOnlyFields) {
// ... 권한 체크 로직 ...
}
return next.handle();
}
}
이 코드는 의도대로 동작하지 않았는데, 근본적인 이유는 adminOnlyFields가 항상 빈 배열이기 때문이다. typeof로 inputData 의 타입을 확인해보면 fooInputDto 가 아니라 Object 타입이었다. 즉 데코레이터를 달아놓은 fooDto 클래스와의 연결고리가 없기 때문에 Reflect.getMetadata는 를 통해 메타데이터를 얻을 수 없었다. 참고했던 작업은 rediscacheable 데코레이터인데, 이 때는 메소드에 메타데이터를 설정했기에, 런타임에도 동작할 수 있었다.
Object 타입인 이유는 인터셉터의 before 단계는 class-transformer의 PlainToClass가 실행되어 객체가 DTO 인스턴스로 변환되는 시점인 Pipe(파이프) 단계의 이전이기 때문이다.
채팅방 순위 업데이트 배치 작업 중 DB CPU 사용량 급증하는 문제가 있었다. 성능개선 도우미를 확인하니 IO:xactsync 가 Average Activie Session 중 많은 비중을 차지하여, 데이터베이스 부하에 큰 영향을 주는 것을 확인했다. 코드를 보니 단일 update 쿼리를 100개씩 잘라 Promise all 로 처리하고 있었다. 만약 배치 사이즈를 줄이면 해결할 수 있을까?
본론
우선 개념과 정의를 정리해보았다.
PostgreSQL은 WAL(Write Ahead Logging) 을 사용하여 데이터 무결성을 보장한다. 즉 디스크에 로그를 기록한 뒤 커밋을 처리하는 것이다.
Aurora PostgreSQL은 WAL 에 로컬 디스크가 아니라 네트워크로 연결된 스토리지를 사용한다.
IO:xactsync 이벤트는 데이터베이스가 Aurora 스토리지 하위 시스템이 트랜잭션의 커밋을 승인할 때까지 대기 중일 때 발생한다고 한다.
위 정리한 항목을 참고하여, 최근 채팅방이 늘어난 것으로 인한 단일 업데이트문이 커밋이 증가하였고, 이를 기록하기 위한 네트워크를 통한 스토리지 쓰기 요청이 병목이라고 판단하였다. 즉 배치 사이즈를 줄이더라도 전체 커밋 개수가 줄어들지는 않기에 해결될 수 없었다.
1차 해결: Bulk Update로 커밋 수 감소
커밋 횟수 자체를 줄이기 위해 PostgreSQL의 bulk update 문법(UPDATE ... FROM VALUES 패턴)을 적용했다. 기존에는 row마다 개별 UPDATE = 개별 커밋이었지만, FROM VALUES로 500개 row를 하나의 문으로 처리하면 커밋 1회로 줄어든다. 기존 대비 커밋 수가 대폭 감소한 셈이다.
500개 단위 chunk로 나눈 이유는 배치 서버와 데이터베이스의 OOM 방지를 위해서다. 하나의 explicit transaction으로 전체를 묶는 방법도 있었지만, 트랜잭션 타임아웃 리스크를 고려하여 chunk 단위 auto-commit 방식을 선택했다. 또한 대량 UPDATE로 인한 dead tuple 증가에 대비하여 autovacuum 관련 파라미터도 조정했다.
쿼리 길이 제한에 대한 공식 문서는 없지만, 링크를 통해서 쿼리를 파싱할 때 사용 가능한 버퍼 크기가 최대 1GB임을 유추해볼 수도 있을 것이다.
2차 분석: 병목이 이동했다
Bulk update 적용 후 Performance Insights를 다시 확인했다. IO:xactsync의 비중은 줄어들었지만, CPU 사용량 90%는 여전했다. AAS의 주요 원인이 IO:xactsync에서 CPU로 바뀌어 있었다.
커밋 병목은 해소되었지만, 5만 건 전체를 매번 재계산하고 UPDATE하는 연산 자체의 CPU 부하가 남아 있는 것이다. 네트워크 I/O 병목을 제거하니 CPU 연산이 드러난 셈이다.
다음 단계 (검토 중)
의심중인 유력한 원인은 순위 컬럼의 인덱스 재구성이다.
PostgreSQL에서 UPDATE는 내부적으로 "기존 row를 dead tuple로 마킹 + 새 row 삽입"인데, 이때 해당 row가 포함된 모든 인덱스의 엔트리도 삭제 + 재삽입되는데, 5만 건을 전부 UPDATE하면 5만 건의 인덱스 B-tree 재구성이 일어나는 것이고, 이건 순수 CPU 연산이기 때문이다.
회사에서 서버를 호스팅하는데 AWS를 사용하고 있다. AWS 의 각 제품에 대한 지식을 활용하여 우리 팀에 특화된 다양한 요구사항(비용, 운영 오버헤드, 작업시간)에 최적화된 의사결정을 하고, 규정준수 요청에 대응할 수 있을 것이라는 기대를 가지고 있다. Amazon Q와 (준비하며 알게 된) Trusted Advisor 와 같은 지원 기능을 함께 활용하면 더 큰 효과를 볼 수 있을 것 같다.
준비 방법
서버 개발자로 일하면서 네트워크와 컴퓨터 과학 지식은 가지고 있다고 판단하였다. 또한 AWS의 일부 제품(EC2, Lambda Aurora, S3, Cloud Front, Event Bridge, SQS, VPC, Secrets Manager) 을 사용하고 연동하며 퍼블릭 클라우드 개념과 큰 그림은 알고 있다고 판단하였다.
그래서 나는 별도의 개념 강의 없이 덤프 문제를 풀며 준비를 하는 중이다. 다만 시험 통과만이 목적이 아니라 지식을 얻기 위한 목적도 있기 때문에, 답 뿐 아니라 오답 선지에 있는 제품의 사용 목적과 왜 주어진 요구사항의 답이 되기에 부적절하거나 덜 적절한지를 정리했다. 이렇게 하니 다른 문제를 풀 때 간접적으로 도움이 되기도 한다. 이렇게 1~200, 500~800번까지 약 500문제 정도를 반복해서 학습했다.
채점 스크립트를 만들어서 추가 확인이 필요하거나 틀린 문제를 모아서 다시보기도 했다.
HTML과 JavaScript로 만든 채점도구
결과와 후기
실제 시험은 덤프와 비교하여 답/오답이 명확하게 갈리는 선지가 제공되어 수월하게 풀었고, 확실하지 않은 문제 수가 10개 정도 되었다.
비영어권 지원자를 위핸 30분 추가 제공을 신청하지 못해서 걱정했는데, 3번정도 검토를 할 수 있었기에 꼭 신청하지는 않아도 될 것 같다.
오히려 검토하다가 ecs 전용 config 서비스 등의 공부하며 못들어본 선지를 정답으로 선택해서 틀린 것도 있을 것 같아, 조금 아쉽다.
강남 센터에서 시험을 보았는데, 하필 시위가 있어 소음이 매우 심했으므로 다음 번에는 다른 센터를 이용할 것 같다.
Node.js 는 V8과 libuv 위에 얹힌 C++ 런타임이지만 레포를 보면 JS 파일(lib/*.js, lib/internal/*)도 가지고 있다. 이 JS 코드들은 Node의 모듈- JS 코드 간 인터페이스 역할을 하는데, 유저 코드와 동일한 환경에서 함께 실행되기 때문에 악의 또는 실수에 의해 오염될 가능성도 있다! Node.js가 JS 객체를 보호하기 위한 방법을 알아보았다.
worker_threads.js. process._eval을 덮어쓴다면..?
1. Symbol로 내부 상태 은닉
Node 내부 객체는 Symbol을 이용해 비공개 상태를 저장한다.
Symbol 키는 열거 불가능하고 충돌 위험이 없다.
_http_client.js
2. 클로저로 모듈 스코프 격리
Node.js는 각 모듈을 로드할 때, 해당 코드를 자동으로 함수 스코프로 감싸 실행한다.
이 함수는 즉시 실행되어 모듈의 내용을 독립된 클로저(closure) 로 감싼다.덕분에 파일 단위로 독립된 렉시컬 환경(lexical environment) 이 형성되며,각 모듈의 내부 변수나 헬퍼 함수는 외부에서 직접 접근할 수 없다.