DVA-C02 자격증 공부하며 정리한 지식을 공유합니다.
글 마지막에 시험 대비용 요약을 정리해두었습니다.

 

1. ELB 개요

ELB는 AWS에서 제공하는 로드 밸런서 서비스이다.

여러 가용영역에 걸친 대상에 트래픽을 분산시켜준다. 대상 그룹(=타겟 그룹)으로 지정한다.

 

동작 계층에 따라 ALB(L7), NLB(L4), GWLB(L3)로 분류된다.

+ 이전 세대의 Deprecated 된 Classic LB도 있다.

 

1-1. 고정형 세션(Sticky Session)

라운드로빈 방식으로 동작하는 LB의 경우를 가정한다. 같은 클라에서 2번 요청시 대상 그룹 내 다른 백엔드로 요청할 수 있다.
기본적으로 대상그룹에 속한 인스턴스간에는 동일한 기능을 보장해야 하지만, 상황에 따라 같은 세션 내에 동일한 백엔드로만 요청하는 것에 이점이 있을 수 있다. 이럴 때 사용하는 것이 고정형 세션으로 같은 클라는 쿠키 통해 같은 백엔드에 요청하게 한다. 이 쿠키는 LB에서 만들어준다. (AWSALB, AWSALBCORS 등)

 

1-2. 대상 그룹에 균등하게 라우팅 하도록 구성했는데도 한 쪽에만 더 많은 트래픽이 갈 때

  • 고정 세션(sticky session) 활성화를 의심해볼만 하다.
  • 더 좋은 스펙에 더 많은 요청 보낼 수 있다.
  • 가용영역이나 AMI에 따라도 좀 다르다.
  • 수명이 긴 TCP 연결

(참고) 연결된 대상 그룹이 없으면 503 Service Unavailable

 

1-3. 크로스존 로드밸런싱

가용영역 2개에 각각 대상그룹을 만들고 3개, 7개의 EC2를 구성했다고 하자. 가용영역에 따라 트래픽을 50: 50으로 나누면 3개가 50개 받고 7개가 50개 받을 것 같지만, 크로스존 로드밸런싱을 하면 전체 10개가 골고루 10개씩 받는다. 필수 기능이 아닐수 있고 그냥 이렇게 할 수도 있다 정도.. 만약 크로스존 로드밸런싱 없이 구성하면 AZ마다 있는 ALB가 균등하게 분배받으므로 3개가 50개 7개가 50개 처리한다.

AWS는 보통 여러 가용영역에 걸친 작업에 추가 비용이 붙는데, ALB 기준으로는 기본 활성화가 되어있어서 설정 무료로 가능하다. NLB랑 GWLB는안된다.

 

1-4. SSL/TLS 관련

LB에 기본 SSL/TLS 인증서(X.509 인증서, 보통 ACM)을 적용하고, 이후 통신은 http를 사용한다.

 

1-4-1. SNI(Server Name Indication)

TLS(SSL)의 확장 기능 중 하나로, 하나의 IP 주소에서 여러 도메인(웹사이트)을 HTTPS로 서비스할 수 있도록 만든 기술.

LB가 여러 대상그룹을 가지고 있는데 각 대상그룹 도메인 주소가 다른 경우 사용. 클라이언트는 초기 SSL 핸드셰이크에서 대상 서버의 호스트 이름을 표기해야한다. ALB, NLB, CloudFront 등 AWS 서비스에서도 이를 활용하여 도메인별 인증서를 설정할 수 있다.

 

1-5. Connection Draning(또는 Deregistration Delay)

오토 스케일링 설정을 해둔 그룹이 정책에 맞춰 EC2 개수를 줄이려고한다. EC2 내에 배포된 서비스는 한 번 요청에 10초 이상의 대용량 파일을 내려받는 서비스라고 가정했을 때 바로 종료하게되면, 현재 내려받는 클라이언트의 요청은 중도 실패할 것이다. 이를 방지하기 위해 일정 시간(디폴트 5분)동안 새 요청만 받지 않고 현재 요청이 끝날 때까지 커넥션을 유지해주는 기능이다. 롤링 배포, 클라이언트 요청 실패에서 유용하다.

 

2. ASG(오토 스케일링 그룹)

현재 트래픽에 따라 인스턴스 개수를 유연하게 조정해준다. 설정 자체는 무료이다.

  • 원하는 스케일 인/아웃에 따라 EC2 개수를 자동 조절한다.
  • healthCheck 통해 언헬시 인스턴스 내리고 새로운 인스턴스 실행하여 개수 유지 가능
  • 무슨 이미지 쓸 건지, EBS, 키페어 등등 사전에 EC2 스펙을 정해주어야 한다.
  • Cloud Watch의 메트릭을 기준으로 하여 알람을 트리거로 늘리거나 줄이기 가능

2-1. 오토 스케일링 정책

타겟 트래킹 스케일

  • 구성 쉬움. 모든 CPU를 40% 정도로 유지할 수 있게 하여 알아서 늘어나는 형태

심플 스텝 스케일링

  • 구성 어려움. Cloud Watch 트리거 명령형으로 추가하거나 삭제

스케줄 액션

  • ex) 월요일 9시에 이벤트가 있으니 이 때는 늘린다.

예측 스케일링

  • 이전 데이터를 보고 예측해서 늘려준다.

 

2-2. 정책에 도움되는 메트릭

  • CPU 사용량
  • 타겟별 요청카운트
  • 평균 네트워크 IO (사진 큰거 받고 등등)
  • 클라우드와치에서 커스텀 메트릭 설정

2-3. 클라우드 쿨다운

잦은 메트릭 변화에 따른 너무 빠르게 개수가 조절되는 것 방지 하는 스케일링 쿨타임

한 번 스케일 업/다운이 되고 이 시간동안은 어떤 변화에도 스케일 업/다운되지 않는다.

 

3. ALB

http / https / ws 처리용 로드밸런서이다.

  • 대상 그룹에 주로 EC2, ECS, Lambda 맵핑되어 있다.
  • 여러 가용영역에 걸쳐 고가용성으로 생성 되지만 단일 도메인 진입점을 제공한다.

3-1. ALB vs Classic Load Balancer 이해

모놀리식 아키텍처로 운영하는 서비스에서 로드밸런서는 단일 대상그룹에 부하 분산만 하면 됐다. 서비스 규모가 작아지며 쿼리스트링, 헤더, 패스베리어블, 엔드포인트, 도메인 주소와 같은 조건에 기반하여 라우팅 대상을 분기할 필요가 생겼다. 이러한 이해를 바탕으로 실무에서 단순 서비스의 부하분산의 경우 가장 저렴한 클래식 로드밸런서 도입을 고려할 수 있다.

반면 ALB의 경우 부하분산 뿐만 아니라 라우팅 경로 제공도 가능하다.(리스너의 rule을 통해 제어)

  • 앞의 쿼리스트링, 헤더, 패스베리어블, 엔드포인트, 도메인 주소 모두 가능하다.
    • 여러개 대상그룹으로 포워딩
    • 다른 URL로 리다이렉트
    • 고정된 응답 - 개발단계 테스트용 등에서 활용
    • rule 을 정하여 우선순위 설정 가능

 

4. NLB

TCP, TLS(secure TCP), UDP 처리용 로드밸런서

  • 굉장히 높은 사양이 필요할 때 사용. 초당 수백만 처리가능
  • 4계층에서 동작하기 때문에 프로토콜 및 포트로만 라우팅을 분기할 수 있다.
  • 가용영역마다 하나의 고정 IP를 제공(EIP(Elastic IP) 또는 AWS에서 생성)
    • 초 저지연 및 가용성을 위한 NLB의 특징으로 클라이언트는 NLB로 부터 가장 적절한 IP를 부여받아 연결한다.
    • 이를 통해 특정한 ip로만 접근가능한 화이트리스트 구성 가능

5. GWLB

대상 그룹이 AWS 서비스 뿐만 아닌 서드파티 정책 또는 프라이빗 온프레미스 장비가 될 수 있다.

6081 포트 GENEBE 프로토콜을 사용하여 연결한다.

 

6. DVA-C02 시험 관련

1. 고정 ip or 화이트리스트 ip 구성 NLB

2. 네트워크 레벨의 고성능 → NLB

3. 6081 포트 GENEBE 프로토콜 → GWLB

4. 일반적인 웹 기반 서비스 → ALB

DVA-C02 자격증 공부 내용을 주제별로 정리합니다.
글 마지막에 시험 대비용 요약을 정리해두었습니다.

 

1. EC2 인스턴스

EC2는 AWS에서 제공하는 가상 머신 서비스이다.

보안그룹, EBS, 로드밸런서(LB) 등 EC2 인스턴스 운영을 위한 연관 서비스들과 함께 동작한다.

 

1-1. 인스턴스 유형

EC2 비용 대부분을 결정한다. 인스턴스 유형은 `t2.micro` 와 비슷한 이름으로 되어있다.

`{목적}{세대}.{하드웨어 스펙}` 으로 구성된다.

목적 세대 하드웨어 스펙
t - 일반적인 서버 최적화
c - 배치, 머신러닝 등 고사양
r - 메모리 최적화, 인메모리 DB 캐시, 실시간 처리
등의 머신 목적에 따라 최적화된 유형 제공
2,3,4, ... 세대 구분 micro, small, medium, large 등에 따라 
vCPU, Mem 스펙 결정

 

1-2. Application 및 OS 이미지

초기 가상 머신에 설치될 운영체제(OS) 및 애플리케이션 환경을 결정하는 항목이다.
EC2에 최적화된 Amazon Linux가 대표적으로, 보안 업데이트가 빠르고 AWS 기능과의 통합성이 뛰어나다.

  • Ubuntu, Red Hat, SUSE, Windows Server 등 사용 가능하다.
  • 대부분의 이미지는 AWS Marketplace를 통해 제공되며, 커스텀 이미지(AMI)를 만들 수도 있다.
  • 웹 서버나 DB 같은 애플리케이션이 미리 설치된 AMI를 선택할 수도 있다.

AMI 를 기준으로 인스턴스가 생성되므로 OS, 애플리케이션, 환경설정 등을 빠르게 복제 가능하다.
→ 반복적인 환경 세팅 시간을 줄일 수 있다.

 

1-3. 인스턴스 구매 옵션

사용자 워크로드에 따라 어떤 구매 전략을 사용할 지 판단 가능해야함

  • 온디멘드 -일반적인 방식으로 사용한만큼 지불. 리눅스는 초단위 지불.
  • 예약 - 긴 시간동안 쓰기로 결정된 머신 적합(장기 워크로드) 온디멘드보다 저렴한 것이 특징
    • 데이터베이스 등에서 좋음.
    • 1년 or 3년 플랜 중 한가지
  • 예약 + 전환형 - 인스턴스 스펙을 유연하게 해야할 때 전환할 수 있는 옵션
  • 세이빙 플랜 - 지불할 금액을 고정시키고, 그 안에서 스펙을 자유롭게 사용하는 옵션. 장기 워크로드에 어울린다.
  • 스팟 - 언제든 없어질수도 있는 머신. 제일 저렴 (대략 90% 비용 절감)
    • 이런 특성 때문에 배치작업, 데이터분석, 이미지 처리, 분산 워크로드, 디비 같은 곳엔 적절치 않음
    • ‘스팟 가격’이 더 비쌀때 멈춰버림
  • 전용(데디케이트) 호스트 - 업무 특성상 다양한 규정을 준수해야하는 요구사항에서 AWS의 전용 물리 서버 사용 가능 내부에서 VM 구축 가능. 제일 비싼 옵션
  • 전용 호스트 인스턴스 - 역시 전용으로 사용되는 인스턴스, 위와 차이는 물리서버(저수준 가시성)인지 인스턴스인지 차이

 

1-4. EC2 사용자 데이터(User Data)

  • EC2 최초 부팅 시 루트권한으로 자동 실행되는 초기화 스크립트를 설정(리눅스의 경우: 쉘스크립트)
  • 일관된 초기화 가능

2. 보안그룹

AWS 트래픽 허용 정책(자체 방화벽)으로 allow 규칙만 있음. IP 및 다른 보안그룹 참조 가능

→ 들어오고(inbound) 나가는(outbound) 트래픽 제어하는 규칙

→ 하나의 보안그룹은 여러개 EC2 인스턴스에 적용가능하며, 하나의 EC2 인스턴스에 여러개 보안그룹 적용 가능 (M:N)

→ VPC나 리전 옮기면 새로 만들어야 한다.

→ 실무에서 SSH 전용 보안그룹을 설정해두는 것을 강추

→ timeout문제는 보통 sg 문제가 많다. connection refused 는 sg 는 통과했다는 의미.

 

2-1. inbound 설정 기준으로 예시

유형: 사용자 지정 TCP
프로토콜 : TCP

소스 : 0.0.0.0/0
포트 : 22
유형 ICMP - IPv4
소스 : 0.0.0.0/0
유형: 사용자 지정 TCP
프로토콜 : TCP

소스 : 다른보안그룹1
→ 모든 ip 주소로부터 오는 22번 포트 TCP 요청을 허용하겠다.
→ ssh 접속을 위한 인바운드 규칙을 열어둔 것으로 알 수 있음.
→ ping 테스트를 위해 해당 머신에 ICMP를 열어둠 → 해당 보안그룹을 가진 EC2인스턴스의 요청을 허락한다는 뜻
→ 보통 LB에서 많이 사용.(LB 로 부터 온 http 요청은 허용 같은 방식)

3. EC2 인스턴스 스토리지(데이터 관리)

EC2 인스턴스는 블록 스토리지를 마운트해서 사용한다. 일반적인 데스크톱 구성에서 메인보드(EC2)와 SSD(스토리지) 관계에 해당한다.

 

3-1. EBS

네트워크상의 드라이브. 인스턴스 다른 거 새로 시작해도 EBS연결해서 기존 데이터 연결가능 (USB, SSD 등 다른 컴퓨터 연결하는 것과 같음)

  • DVA-C02 레벨에서는 1개 인스턴스가 m개 EBS에만 연결시키는 거만 이해하면 된다 (유에스비 여러개 꼽듯)
  • 물론 하나의 EBS를 여러개 인스턴스에 할수도있다. 특별한 경우 아니면 안 쓴다. (USB를 여러 컴퓨터에는 못 연결하듯)
  • 같은 AZ 안에서만 연결가능하다. 물론 스냅샷떠서 다른 리전이나 다른 AZ옮기기 가능하다.
  • EC2인스턴스 종료시 삭제 옵션 가능
  • 리사이클 빈 - 휴지통 개념으로 삭제된 인스턴스나 EBS 모두 하루부터 1년까지 보관가능 리스토어 가능하다.

3-1-1. EBS 종류

gp2/3, io1/2, st/sc가 각각 어느 상황에 좋은지 정도 파악한다.

gp2/gp3 io1/io2 st 1 / sc 1
일반 목적의 스펙으로 숫자는 세대.
크기는 1기가에서 16테라

gp3 3,000 IOPS 쓰루풋 125MiB/s

IOPS와 쓰루풋을 독립적으로 스펙업가능하다.

gp2는 비교적 옛날.
IOPS 랑 쓰루풋이 연결되어 같이 스펙업되어야함
대용량 워크로드 하이퍼포먼스
IOPS를 유지해야 하거나, 16,000 IOFS 같은 고사양에 어울림 (데이터베이스 워크로드)

성능과 일관성에 민감하다. 4기가에서 16테라
동일 AZ내 ec2 여러개 연결가능
25만 IOPS 까지 가능
st 1 저렴
잦은 접근 -
빅데이터, 로그

sc 1 제일 저렴

아카이브 정도 사용

 

3-2. EC2 instance store

인스턴스와, EBS 연결에도 결국 네트워크 비용이 있다. instance store 는 실제 가상머신이 동작하는 하드웨어의 저장공간을 사용하여 I/O 성능에 좋다. EBS처럼 독립이 아니라서 EC2 끄면 없어진다. 캐시, 버퍼, 스크래치 데이터, 임시데이터 등등 저장에 적합하다.

 

3-3. EFS

AWS에서 제공하는 파일 스토리지이다. 여러 EC2 인스턴스에 마운트해서 사용할 때 적절하다.

  • 클라우드 NAS 서비스와 같다.
  • 다른 AZ에도 마운트가 가능하다.
  • 가격이 상대적으로 비싸다.
  • NFSv4.1 프로토콜 사용하여 마운트한다.
  • 프로비저닝 모드 선택 가능하나 탄력적 요금제가 권장된다.

3-3-1. EFS Scaling

EFS는 아래 3가지의 스케일링 옵션이 있다.

 

EFS 성능 모드(Performance mode)

범용 모드(General purpose) 최대 I/O모드(Max I/O)
웹서버 or 컨텐츠 관리 서비스 빅데이터 병렬처리

 

EFS 쓰루풋 모드(처리량 모드)

버스팅 프로비전드 엘라스틱
파일 시스템 사용량에 따라 성능 변화 현재 사용량과 무관하게 지속적인 성능 보장
성능을 스토리지 사이즈에 맞게 설정 가능하다.
불규칙한 워크로드에 사용 (스파이크 등)

 

EFS 스토리지 클래스

→ 엑세스 잘 안되는 데이터는 정책에 따라 저렴한 곳으로 옮겨버린다.

표준 IA(Infrequent Access) 아카이브(Archive)
SSD 스토리지 사용하여 최저 수준 지연 분기에 몇 번 엑세스되는 데이터 대상 1년에 몇 번 엑세스되는 데이터 대상

 


4. DVA-C02 시험 관련

1. EC2 사용자 워크로드에 따라 어떤 구매 전략을 사용할 지 판단 가능해야함

ex) 최소 3년간 서비스 보장 → 예약 인스턴스

ex) 언제 사라져도 상관없고, 가장 저렴한 모드로 사용하길 원함 → spot 인스턴스

 

2. EBS 사용자 워크로드에 따라 어떤 구매 전략을 사용할 지 판단 가능해야함

ex) 대용량 IOPS → io1/io2

ex) 가장 저렴 → st1

 

3. 문제 요구사항에 다중 사용자, 다중 클라이언트 등 키워드 → EFS 적합

 

4. EC2 인스턴스 기본으로 마운트된 EBS는 삭제, 추가된 EBS는 삭제되지 않는다.

 

5. timeout 문제는 보통 보안그룹이 문제일 가능성이 크다.

서버 애플리케이션에서 DB 성능을 개선할 때, 크게 애플리케이션 레벨DB 레벨로 나누어 접근합니다.
경험해온 DB 성능 개선 포인트를 정리해보려고 합니다.

 

테스트에 사용한 소스코드는 링크에 있습니다.

 

1. 애플리케이션 레벨 개선

1-1. 불필요 DB I/O 검토

대표적인 예로 N+1 쿼리 문제는 IN 절을 활용한 단일 쿼리로 개선하고, 결과는 애플리케이션에서 재조합하도록 합니다.

다양한 이유로 연관 테이블과의 JOIN이 어려운 경우, 관련된 ID(PK)를 배열 형태로 전달받아 한 번에 조회할 수 있는 인터페이스를 제공해야 합니다. 이렇게 수집한 데이터를 조합하는 작업은 주로 API Gateway, 또는 Mashup API에서 처리하게 됩니다.

또한 ORM 구현체에서 제공하는 메서드나 로직이 실제 쿼리와 일치하는지, 쿼리 로그를 분석해 의도치 않은 I/O 발생하는 지점을 검토해야 합니다.

 

1-2. native sql 검토

ORM 지원하지 않거나 성능상 이점이 있는 경우, native sql 작성해 쿼리를 실행합니다.
대표적으로 PostgreSQL json 또는 jsonb 타입을 조건으로 필터링할 아래와 같은 쿼리를 사용합니다.

native sql은 DBMS 종속성이 생기기 때문에 애플리케이션 코드에서 인터페이스로 분리하여 의존성을 최소화합니다.

SELECT * FROM tb_json WHERE data->>'user' = 'user_500';

 

또한 특수한 요구사항에서 native sql을 작성하곤 했습니다. 쿼리문은 100줄이 넘는 복잡한 구조가 되기도 하며, 조인, 집계, 서브쿼리, 윈도우 함수 등이 혼합되어 있는 경우가 많습니다. 이로 인해 쿼리 성능 저하나 유지보수 어려움이 발생할 수 있어, 다음과 같은 컨벤션을 도입하여 개선 작업을 진행해왔습니다.

  • SELECT 절 내 서브쿼리 사용 금지합니다.
  • WHERE 절과 ON 절에서 함수 사용 지양합니다. (인덱스 무력화 방지)
  • 재귀, 복잡한 쿼리는 CTE 또는 뷰 테이블로 분할합니다.

이러한 컨벤션은 코드 리뷰 체크리스트로 활용되며, 전체의 쿼리 품질을 일관성 있게 유지하고자합니다.

더미 데이터 세팅 후 이어서 테스트해보겠습니다.


2. 데이터 세팅

제품(product) 테이블을 생성하였습니다. 리스트 및 상세 페이지를 제공하고 주문과 같은 다양한 기능과 연계된 테이블로, 실제 서비스에서의 요구사항과 유사한 시나리오로 테스트를 진행했습니다.

  • 테스트 데이터는 총 500만건으로 진행했습니다.
  • 인덱스와 extension(다양한 기능이 있는 PostgreSQL 플러그인)은 상황에 맞추어 생성하고 사용했습니다.
  • 각 필드의 데이터 타입은 인덱스 테스트에 적합하도록 설정했습니다.
  • DB 캐시 영향을 최소화하기 위해 매 테스트마다 PostgreSQL을 재시작했습니다. (Buffers: shared hit 에 따른 변수 최소화)
CREATE TABLE product (
  id SERIAL PRIMARY KEY,

  -- 일반 컬럼
  code TEXT NOT NULL,
  category TEXT NOT NULL,
  is_special boolean NOT NULL,
  created_at Date,

  -- JSON / JSONB 컬럼
  attributes_json JSON,
  attributes_jsonb JSONB
);

 

 

각 컬럼의 카디널리티입니다.

code: 5,000,004
category: 5
created_at : 9,242
is_special: 2

3. DB 레벨 개선

DB레벨 개선의 주요 포인트는 쿼리플랜을 해석하여 적절한 인덱스 적용입니다. PostgreSQL에는 B-TREE, Hash 인덱스와 더불어 Gist, GIN 등 다양한 인덱스를 지원합니다. PostgreSQL 에서는 오라클의 hint 처럼 직접적으로 인덱스를 강제하는 방법은 지원하지 않습니다. pg_hint_plan 확장을 지원하지만, 이 글에서는 아래와 같이 간접 방식으로 제어합니다.

--옵티마이저에게 특정 스캔 방식을 비활성화하여 실행 계획을 유도하는 방법
set enable_bitmapscan = off; 
set enable_indexscan = off;
set enable_seqscan = off;

 

Case 1. 제품 상세 조회

code 값 하나를 필터 조건에 두고 쿼리합니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM stuff WHERE code = '05QnmQAa0WR';

 

시퀀스 스캔

인덱스 옵션에서 seqscan을 강제하여 실행모드의 실행 계획을 얻었습니다.

Gather  (cost=1000.00..261043.16 rows=1 width=157) (actual time=619.769..624.877 rows=1 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  Buffers: shared hit=1 read=234080
  ->  Parallel Seq Scan on product  (cost=0.00..260043.06 rows=1 width=157) (actual time=566.260..596.760 rows=0 loops=3)
        Filter: (code = '05QnmQAa0WR'::text)
        Rows Removed by Filter: 1666668
        Buffers: shared hit=1 read=234080
Planning Time: 0.121 ms
JIT:
  Functions: 6
  Options: Inlining false, Optimization false, Expressions true, Deforming true
  Timing: Generation 0.767 ms, Inlining 0.000 ms, Optimization 2.698 ms, Emission 16.558 ms, Total 20.022 ms
Execution Time: 688.598 ms

주요 지표만 살펴보면

Workers Launched: 2, Parallel Seq Scan : 2개 워커에서 병렬 시퀀스 스캔을 수행했습니다.

Buffers: shared hit, read : 대부분의 데이터는 디스크에서 읽어오고 있습니다.

Execution Time : 쿼리 실행에 688.598 ms 가 걸렸습니다.

 

인덱스 스캔

이어서 인덱스 스캔으로 테스트해보겠습니다.
저는 code 필드가 첫번째 필드인 복합인덱스를 미리 만들어두었습니다. 기본적으로 B-tree 인덱스입니다.

CREATE INDEX idx_cardinality_desc ON stuff(code, category, is_special);

 

Index Scan using idx_cardinality_desc on product  (cost=0.43..8.45 rows=1 width=157) (actual time=0.254..0.255 rows=1 loops=1)
  Index Cond: (code = '05QnmQAa0WR'::text)
  Buffers: shared read=4
Planning:
  Buffers: shared hit=137 read=16
Planning Time: 3.198 ms
Execution Time: 0.322 ms

쿼리 실행에는 0.322ms 가 걸렸습니다. 단순 계산으로 500만개 데이터에 2000배 이상 개선된 결과를 나타냅니다.
여러번 반복테스트 해보아도 큰 차이는 없습니다.

 

복합인덱스

한편 해당 인덱스를 탈 수 있었던 이유는 복합인덱스의 첫번째 필드가 code기 때문입니다. 필터 조건에 category만 있었다면 해당 인덱스는 작동하지 않습니다. 이 인덱스는 code, category, is_special 순으로 인덱스가 걸려있으므로 이는 code 단일 필드로도 인덱싱이 되어있다고 볼 수 있습니다.

 

커버링인덱스

한편 B-tree 인덱스 역시 데이터 원본으로 정렬된 테이블 구조로 저장됩니다. 따라서 위에서 사용된 인덱스에 적용된 필드만 조회한다면, 굳이 실제 테이블에 접근할 필요 없이 인덱스만 가지고 조회 결과를 응답할 수 있습니다. 이를 커버링 인덱스라고 합니다. PostgreSQL에서는 INCLUDE 절을 이용해 정렬 대상이 아닌 컬럼도 인덱스에 저장할 수 있습니다.

CREATE INDEX idx_code_cover ON product(code) INCLUDE (created_at, category);

 

--쿼리
EXPLAIN (ANALYZE, BUFFERS)
select code,category FROM product where code = 'Q72uCuoCBE';

--결과
Index Only Scan using idx_code_cover on product
...생략
Execution Time: 0.204 ms

현재는 0.322ms에서 0.204ms로 개선되었으며 절대적인 차이가 크지 않지만, 충분한 크기의 데이터에서 유의미해집니다. 따라서 습관적으로 전체 조회 (select *...)하기보단, 명확한 인덱스 설계가 필요합니다.

 

해시인덱스

현재 적용된 인덱스는 B-Tree 인덱스입니다. B-Tree인덱스는 동등성(=) 뿐만 아니라 비교연산자(<,>) 에서도 인덱스 조회가 가능합니다.  Hash 인덱스가 있을 때 동등성(=) 조회에 한하여 옵티마이저는 Hash 인덱스를 고려합니다.

 

Case 1 결과.

  • 데이터가 많을수록 단건 조회에서 인덱스는 사실상 필수입니다.
  • 복합인덱스는 적당히 단일 또는 적당한 필드에 적용될 수 있습니다.
  • 커버링인덱스 활용이 가능하면 하는게 좋습니다.
  • 일치하는 값만 필터할 경우 해시인덱스를 고려할 수 있습니다.

Case 2. 제품 검색 최적화

단건 검색이 아닌 키워드에 해당하는 리스트 검색의 경우입니다.

 

like 검색

키워드 기반 검색을 RDBMS로 구현할 경우, 일반적으로 LIKE 문을 사용하게 됩니다.

일반적인 B-tree 인덱스는 정렬된 원본 값을 기반으로 하므로, 전체 값을 기준으로 한 LIKE 'keyword%'는 인덱스 활용이 가능하지만,

LIKE '%keyword' 같이 접두어가 없는 검색은 인덱스를 사용할 없어 시퀀스 스캔이 발생합니다.

아래 예시처럼 시퀀스 스캔을 사용하고 있습니다.

--쿼리
EXPLAIN (ANALYZE, BUFFERS)
select * FROM product where code like '%NV4';

--결과
Gather  (cost=1000.00..261172.69 rows=500 width=157) (actual time=275.735..657.139 rows=20 loops=1)
  ->  Parallel Seq Scan on product  (cost=0.00..260122.69 rows=208 width=157) (actual time=247.712..606.308 rows=7 loops=3)
Execution Time: 657.658 ms

 

code 값의 일부를 별도 인덱스를 구축한다면 like 검색 최적화가 가능합니다. PostgreSQL 에서는 데이터 토큰별 인덱스 엔트리를 구성해주는 GIN 인덱스를 지원합니다. GIN 인덱스는 Elasticsearch의 역색인 방식과 유사하게, 각 데이터를 구성하는 요소(토큰)를 기반으로 색인을 구성합니다. GIN 인덱스는 값을 어떤 키워드나 요소로 색인을 만들 것인지를 Operator 로 정의할 수 있습니다. '배열 포함 여부' 같은 목적에 따라 내장 연산자(built-in operator) 를 지원하지만, Like 문 인덱스 쿼리는 pg_trgm 확장을 사용합니다. pg_trgm 은 트라이그램(gin_trgm_ops)매칭을 통한 텍스트 유사성을 판단하는 확장으로 여러가지 함수를 제공하며 토큰별 인덱스를 생성하므로 Like문에도 인덱스 적용 가능합니다.

별도 설정하지 않는 한 트라이그램은 3글자를 기준으로 인덱스를 구성하여 2글자까지의 like문에서는 seq scan이 발생합니다. 요구사항에 맞게 조절하여 사용가능합니다.

 

날짜 범위 검색

특정일자 이후에 생성된 제품만 필터할 수 있습니다.

B-tree 인덱스는 범위 조건에도 적용되므로, 캐스팅과 같은 함수를 사용하지 않는다면 인덱스 적용이 가능합니다.

Date 타입은 특히 DB에서 묵시적 캐스팅되는 경우가 많아 주의가 필요합니다.

--쿼리 
EXPLAIN (ANALYZE, BUFFERS)
select code, created_at FROM product where created_at >= '2012-01-01';

--결과 
Index Only Scan using idx_created_at on product  (cost=0.43..87087.33 rows=2644280 width=15) (actual time=0.114..421.699 rows=2627813 loops=1)
...생략
Execution Time: 495.523 ms

 

Case 2 결과.

  • like 문 인덱싱은 토큰기반 색인(역색인)으로 적용합니다.
  • 날짜 조회시 묵시적 캐스팅을 주의합니다.

4. 회고

실제 시나리오에 기반하여 인덱스 작동에 대한 테스트를 해보는 것은 모호할 수 있던 개념 정리에 많은 도움이 되었습니다.

특히 PostgreSQL 에 특화된 내용들에서 조금 더 알아갈 수 있던 시간이 되었습니다. 다음 글에서  Case 3. JSONB 필드의 GIN 인덱스 적용  Case4. Join에서 실행계획 검토 와 같은 케이스들도 살펴보도록 하겠습니다.

 

웹 서비스에서 사용자 인증을 구현할 때, 인증 정보를 요청 컨텍스트에 객체(보통 User)로 담아 컨트롤러 같은 api 핸들러에서 쉽게 꺼내쓸 수 있도록 구현하곤 했습니다. NestJS의 경우 @nestjs/passport 에서 AuthGuard와 PassportStrategy 함수를 제공해주어 개발 과정에서 검증 로직만 구현하면 됩니다. 이번 글에서는 Passport 기반 jwt 인증이 구현되는 원리를 상세히 알아보고자 합니다.

 

예제 소스코드는 링크에 있습니다.

1. 필요한 라이브러리 및 모듈 정리

passport

Node.js에서 전략 기반 인증(및 인가) 처리를 지원하는 Passport.js 는 npm 저장소 위클리 다운로드 수백만에 이르는 라이브러리입니다. 보통 유저의 인증은 아이디/패스워드뿐만 아닌 jwt, OAuth2 프레임워크 등 다양한 방식으로 인증하게 됩니다. 이같은 인증 방식들을 인증 전략(strategy)이라고 하며 passport는 하나 이상의 인증 전략 패키지와 함께 설치되어 동작합니다. 가장 대표적인 인증 전략은 아래와 같습니다.

전략명(npm package 명) 설명
passport-local userId, password 기반 인증
passport-jwt jwt 토큰 기반 인증
passport-oauth2 Oauth2 기반 인증

* jwt기반의 인증을 사용할 것이므로 passport-jwt 전략을 선택했습니다.

 

passport는 애플리케이션이 초기화될 때 인증 전략을 등록하고 이후 인가가 필요한 시점에 등록된 인증 전략으로 인증(authenticate())을 수행합니다. 호출 시점에 해당 전략을 찾지 못하면 에러를 발생시키고 프로세스가 종료됩니다.

 

passport/lib/middleware/authenticate.js

function attempt(i) {
      var layer = name[i]; 
      if (!layer) { return allFailed(); }
    
      var strategy, prototype;
      if (typeof layer.authenticate == 'function') {
        strategy = layer;
      } else {            
        prototype = passport._strategy(layer);
        //name 과 일치하는 (passport에서 사용중인) strategy가 없다면 에러발생        
        if (!prototype) { return next(new Error('Unknown authentication strategy "' + layer + '"')); }
        
        strategy = Object.create(prototype);
      }
      ...하략
  }

 

* passport는 express 에서 동작하는 미들웨어로 설계되어 NestJS에서 직접 사용될 일은 없습니다.

 

@nestjs/passport

express와 대비되는 특징으로 NestJS는 DI 컨테이너와 데코레이터 패턴을 기반으로하는 구조화된 시스템으로 설계되었습니다. 이 패키지는 express로 설계된 passport를 NestJS의 구조에서 쉽게 사용할 수 있도록합니다. 두가지 함수가 대표적입니다.

 

1. PassportStrategy

NestJS에서 Passport의 전략을 쉽게 등록할 수 있도록 도와줍니다. PassportStrategy 함수는 믹스인 패턴으로 동적 클래스(StrategyWithMixin)를 만들어 주는데, 이 클래스의 생성자에는 passport에서 인증 전략으로 등록하는 과정이 포함되어 있습니다. 말그대로 전략을 등록해줍니다.

 

@nestjs/passport/dist/passport/passport.strategy.js

function PassportStrategy(Strategy, name, callbackArity) {
    class StrategyWithMixin extends Strategy {
        constructor(...args) {
            ...중략
            //전략을 등록
            if (name) {
                passportInstance.use(name, this);
            }
            else {
                passportInstance.use(this);
            }
        }
        getPassportInstance() {
            return passport;
        }
    }
    return StrategyWithMixin;
}

2. AuthGuard

NestJS에는 인증&인가의 책임을 가진 Guards가 있습니다. passport 전략을 이 Guards와 통합해서 사용할 수 있도록 AuthGuard 를 제공합니다. 동적인 MixinAuthGuard 클래스를 생성하면서 canActive() 메서드를 구현하는데 메서드명에서 알 수 있듯 등록된 전략을 찾아 해당 전략으로 자격증명을 검증합니다.

 

passport-jwt(및 @types/passport-jwt)

PassportStrategy 함수에 인자로 넘겨주어 실제 등록될 인증 전략 인스턴스를 제공합니다. 그리고 http request로부터 jwt를 추출할 수 있도록 ExtractJwt 에서 여러가지 함수를 제공합니다.

 

passport-jwt/lib/extract_jwt.js

//http로부터 토큰을 추출하기 위한 함수 
extractors.fromAuthHeaderWithScheme = function (auth_scheme) {
    var auth_scheme_lower = auth_scheme.toLowerCase();
    return function (request) {

        var token = null;
        if (request.headers[AUTH_HEADER]) {
            var auth_params = auth_hdr.parse(request.headers[AUTH_HEADER]);
            if (auth_params && auth_scheme_lower === auth_params.scheme.toLowerCase()) {
                token = auth_params.value;
            }
        }
        return token;
    };
};

 

정리하면 이렇습니다.

 

@nestjs/jwt

위 소개된 패키지는 모두 유저 요청시 인증하는 것과 관련된 패키지입니다. 이 패키지는 로그인시 jwt 를 발행하기 위한 역할입니다. 따라서 jwt의 만료시간, 시크릿 등 설정을하고 토큰 생성(sign())메서드가 있습니다.

2. 인증 흐름

2-1. NestJS 앱 초기화

AppModule을 시작으로 모듈, 프로바이더 등 데코레이터 패턴으로 등록됩니다. 인증 예제 관련하여 주로 다음 3가지가 있습니다.

2-1-1. JwtStrategy 인스턴스 생성

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
...
}

이때 JwtStrategy가 상속받은 PassportStrategy 함수가 호출되어 전략을 등록(use())하게 됩니다. 앞서 살펴본 @nestjs/passport 의 코드블럭에 해당하는 부분입니다.

 

2-1-2.  JwtAuthGuard 인스턴스가 생성

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

canActivate() 는 AuthGuard에서 제공하는 것을 그대로 사용합니다.

 

2-1-3. @UseGuard()

@UseGuard() 데코레이터는 가변인자로 받은 Guard 들을 검증하고 Reflect로 이 메서드의 메타데이터에 AuthGuard를 등록합니다.

 

2-2. 사용자 로그인

@nestjs/jwt 에서 제공하는 JwtService 로 jwt 토큰을 발행합니다.

 

2-3. 사용자 인증이 필요한 API 호출

사용자가 Authorization 헤더에 JWT를 담아 /users/me GET 요청을 보냅니다. 요청은 ExpressAdapter가 받아 GuardsConsumer 를 통해 이 API에 등록된 가드들의 canActivate() 를 호출하게 됩니다.

 

@nestjs/core/guards/guards-consumer.js

class GuardsConsumer {
    async tryActivate(guards, args, instance, callback, type) {
        if (!guards || (0, shared_utils_1.isEmpty)(guards)) {
            return true;
        }
        const context = this.createContext(args, instance, callback);
        context.setType(type);
        for (const guard of guards) {
            const result = guard.canActivate(context);
            if (await this.pickResult(result)) {
                continue;
            }
            return false;
        }
        return true;
    }
    ...하략
}

 

3. JwtAuthGuard의 canActivate() 메서드 실행

AuthGuard의 canActivate() 를 그대로 호출합니다.

 

@nestjs/passport/dist/auth.guard.js

canActivate(context) {
    return __awaiter(this, void 0, void 0, function* () {
    	//passport 기본옵션과 사용자 옵션을 병합한 options
        const options = Object.assign(Object.assign(Object.assign({}, options_1.defaultOptions), this.options), (yield this.getAuthenticateOptions(context)));
        const [request, response] = [
            this.getRequest(context),
            this.getResponse(context)
        ];
        const passportFn = createPassportContext(request, response);
        const user = yield passportFn(type || this.options.defaultStrategy, options, (err, user, info, status) => this.handleRequest(err, user, info, context, status));
        request[options.property || options_1.defaultOptions.property] = user;
        return true;
    });
}

실행 컨텍스트로 부터 request, response를 가지고와 createPassportContext()를 통해 인증 함수를 생성합니다.

 

3-1. createPassportContext

제가 사용중인 @nestjs/passport:9.0.3 버전에서 createPassportContext 함수는 다음과 같습니다.😂

const createPassportContext = (request, response) => (type, options, callback) => new Promise((resolve, reject) => passport.authenticate(type, options, (err, user, info, status) => {
    try {
        request.authInfo = info;
        return resolve(callback(err, user, info, status));
    }
    catch (err) {
        reject(err);
    }
})(request, response, (err) => (err ? reject(err) : resolve())));

한눈에 들어오지 않아서 다음과 같이 변환했습니다.

function createPassportContext(request, response) {
    return function lambda(type, options, callback) {
        return new Promise((resolve, reject) => passport.authenticate(type, options, (err, user, info, status) => {
            try {
                request.authInfo = info;
                return resolve(callback(err, user, info, status));
            }
            catch (err) {
                reject(err);
            }
        })(request, response, (err) => (err ? reject(err) : resolve())));        
    };
}

createPassportContext 는 lambda 라는 함수를 리턴합니다. lambda는 제가 임의로 넣은 함수명입니다.

lambda 함수는 프로미스를 리턴합니다. 이 함수 호출시에는 yield 키워드로 resolve 이후 동작을 보장합니다.

프로미스 내부에서 passport.authenticate 를 호출하는데 이는 커링이 적용된 비동기 함수입니다. 호출시 passport.authenticate 내에서 next()로 호출할 callback 도 같이 넘겨줍니다.

 

3-1-1. passport.authenticate

옵셔널 인자 처리, name 여부를 체크하고 인증을 수행합니다.

 

passport/lib/middleware/authenticate.js

module.exports = function authenticate(passport, name, options, callback) {
  //인자 개수에 따라서 조정
  if (typeof options == 'function') {
    callback = options;
    options = {};
  }
  options = options || {};
  
  var multi = true;
  if (!Array.isArray(name)) {
    name = [ name ];
    multi = false;
  }
  
  return function authenticate(req, res, next) { //인증 수행... }
}

등록된 인증 전략을 찾아 인증을 수행합니다. 여기에서는 passport-jwt에 정의된 Strategy로 수행하며, 이 로직에는 JwtStrategy 의 validate() 함수를 호출하여 인증 성공을 판단하는 부분이 있습니다.

 

promise resolve 되면, lambda 함수를 호출할 때 넘겨준 callback이 호출됩니다. 이는 AuthGuard에서 제공하는 handleRequest 로 passport 결과가 err거나 user객체가 없을 때 UnauthorizedException을 발생시킵니다.

canActivate(context) {
...
const user = yield passportFn(type || this.options.defaultStrategy, options,
//resolve 되면 호출될 콜백
(err, user, info, status) => this.handleRequest(err, user, info, context, status));
...
}

handleRequest(err, user, info, context, status) {
    if (err || !user) {
        throw err || new common_1.UnauthorizedException();
    }
    return user;
}

getRequest(context) {
    return context.switchToHttp().getRequest();
}
...

*handleRequest() 를 JwtStrategy 클래스에서 재정의하여 에러 분기를 상세하게 할 필요가 있습니다. 예를들어 인증 전략인 jwt는 보통 refresh token 방식으로 구성하기 때문에 클라이언트에서는 인증 실패 에러가 토큰 만료에 의한 에러인지, 페이로드 조작 등 토큰자체의 검증 실패인지 구분해야할 필요가 있습니다.

*함께 정의된 getRequest() 도 마찬가지로, RESTAPI를 기본으로 하지만, GraphQL 등 타 프로토콜의 경우 재정의하여 사용하기도 합니다.

 

3-2. request 의 프로퍼티로 저장

passportFn()이 실행되어 user 객체를 가져오고, 최종적으로 request에 저장됩니다. 보통 User 이지만 실무에서 서비스상의 User와 혼동되는 경우 다르게도 사용했던 것 같습니다.

 

4. 컨트롤러에서 user 정보 가져오기

파라미터 데코레이터 @Request() 등으로 실행 컨텍스트의 request를 쉽게 가져올 수 있습니다. 이 안의 user로 비즈니스 로직을 수행합니다.

3. 회고

NestJS에서 Passport를 활용한 인증은 모듈화 및 DI 컨테이너를 활용한 확장성이 뛰어납니다. 기본적인 JWT 인증에서부터 세션, OAuth, RBAC까지 다양한 방식으로 확장할 수 있고, 내부적으로 Passport의 authenticate()를 NestJS의 Guard 시스템과 통합하여 효율적인 인증을 제공하는 세세한 과정을 보게되어 흥미로웠습니다.

 

또한 인증과 별개로 npm 패키지 소스를 분석하며 NestJS의 데코레이터 패턴을 사용하는 동작 방식과 그 구현의 핵심인 리플랙션, 함수형 프로그래밍, 프로토타입 기반의 상속 등 좋은 경험이 된 것 같습니다. 

 

AWS 공인 개발자 어소시에이트에 합격하여 간단한 후기를 남깁니다.
AWS 서비스에 대한 내용보다 시험 자체적인 정보 위주의 후기입니다.

 


서비스별 내용 정리 및 시험 팁
1. EC2(+EBS, EFS, SG )

2. ELB (+ASG)

 

 

 

0. 보유 중인 자격증끼리 주관적인 난이도 비교

DVA-C02 >> 컴활 1급 > 정보처리기사 >> ADSP >> SQLD

 

1.DVA-C02 자격증을 공부하게된 동기

지금까지 클라우드 서비스들을 다룰 땐 어떤 서비스를 어떻게 사용할 것인지 정해지고나서 구현을 담당했습니다. 최근 AWS 관리자 권한을 받게 되면서 그동안 비즈니스 요구사항만으로 알맞는 클라우드 서비스 도입하거나 구축하는 고민은 부족했던 것 같습니다. 이번 기회에 AWS 서비스들의 목적, 다양한 상황에서의 워크로드를 공부해 보며 자격증을 취득하고자 했습니다.

 

AWS 자격증 목록

어느정도 AWS 구축/운영을 경험해 본 개발자분들이 적당한 AWS 자격증을 준비할 때 SAA-C03(솔루션즈 아키텍트) 또는 DVA-C02(디벨로퍼) 중 하나를 추천드립니다. 둘은 비슷한 난이도에 상당 내용이 겹치지만 저는 아래와 같은 항목에서 차이를 고려하여 DVA-C02로 결정하였습니다. 다만 개발자분들도 SAA-C03를 더 많이 취득하시는 것 같습니다.

 

초점

  • SAA-C03은 전체적인 클라우드 아키텍처 설계와 최적화에 중점을 둡니다.
  • DVA-C02는 애플리케이션 개발과 AWS 서비스 통합에 중점을 둡니다.

대상 직무 

  • SAA-C03은 솔루션 아키텍트나 클라우드 엔지니어를 대상으로 합니다.
  • DVA-C02는 소프트웨어 개발자를 대상으로 합니다.

시험 내용 

  • SAA-C03은 아키텍처 설계와 시스템 최적화에 관한 시나리오 문제를 포함합니다.
  • DVA-C02는 코드 예제와 애플리케이션 개발 관련 문제를 포함합니다.

 

공부 기간은 6주로 계획했습니다. 실무에서 사용할 주요 서비스들은 시간을 가지고 실습하고 이해하면서 준비했습니다.

 

2. 공부 방식 및 준비 과정

이미 여러 블로그에 있는 것처럼 인강 & 덤프로 준비하였습니다.

인강

- 가장 유명한 유데미 강의 로 하였습니다. 상시 할인 중이니 만원대로 결제하면 좋을 것 같습니다. 이미 아는 서비스들은 제외하여 최종 70% 정도 수강하였으며 모든 챕터의 문제와 모의고사까지 3회씩 풀었습니다.

- 시험 범위 내 모든 서비스들의 이론+실습 구조로 실습하기 어려운 환경에서도 이해될 수 있게 잘 구성되어 있었습니다. 강의가 꽤 길지만 추후 언제든지 레퍼런스로 참고할 수 있어서 추천합니다.

 

덤프

- examtopics 80문제 정도는 무료지만 이후 덤프들은 결제를 해야 했습니다. 총 2회 풀고 2회 차 때 틀린 문제는 3번 봤습니다.

- 문제의 형태와 특유의 번역체에 익숙해지는데 도움이 되었습니다. 조금 비싼 감이 있지만 이 또한 강력 추천합니다. 문제를 반복해서 풀다 보니 문제에 특정 키워드로 정답의 핵심 키워드를 유추하게 되었습니다.

 

자격증 자체의 난이도와 더불어 시험환경과 같은 외부요인 때문에 더 난이도가 올라가는 것 같습니다.

 

시험 난이도

이미 알고 있던 AWS서비스들은 10개 내외였습니다. 웹 개발 위주의 EC2, lambda, S3, Cloudfront, Route53,... 등 입니다. 이 자격증에서는 이외에도 서버리스, 암호화, 메트릭 수집/로깅, 형상관리 CI/CD, IaC, 스트리밍, AI분석 서비스 등 총 30여 개 서비스에 대한 실습 수준의 지식이 필요합니다. 따라서 아직 관심이 크지 않은 서비스더라도 실습해보아야 했습니다. 게다가 이 익숙하지 않은 서비스들이 비슷한 기능을 가지고 있다면 더욱 헷갈리는데, 차이점과 목적으로 나누어 정리하며 구분했습니다. (대표적으로 SAM과 CDK 그리고 CloudFormation 🙃)

 

외부요인

클라우드 자격증시험이 처음이다 보니 환경 자체에도 어려움이 많았습니다.

1. 온라인으로 시험 보게 되었는데, 감독관과 어느 정도의 영어 의사소통을 해야 합니다. 시험 응시가 가능한 환경을 만들고 실시간으로 대화하며 요구사항에 응해야 합니다. 음질이 좋지는 않았지만 다행히 챗으로도 가능했습니다.

 

2. 부자연스러운 번역입니다. 문제 푸는 속도차이가 있어서 한국어로 신청했지만 번역이 모호했던 부분이 있었습니다. 문제는 원문이 같이 제공되어 중간중간 원문으로 풀었습니다.

 

3. 덤프의 답이 틀린 것이 종종 있었습니다. 제일 많은 유저들이 선택한 답이 정답인 경우가 많았으며 심지어 그 유저끼리도 갈리는 문제가 있었습니다. 문항 수는 수백 개인데 모두 문서 하나하나 찾아가며 검증할 수 없어서 유저끼리 갈리는 문제나 아예 풀지 못 한 문제 위주로만 문서를 찾아보았습니다.

 

4. 시험 일정이 까다롭습니다. 외국 주관 자격증이고 스케줄 된 시험일정에 응시자가 선택해서 예약합니다. 저는 운이 좋게도 이른 오전 시험이었는데, 다른 후기들을 보면 새벽 5시 시험이나 자정즈음 등 다양했습니다.

 

3. 회고

꽤 느슨한 기간을 가지고 준비했고 시험 자체의 긴장만큼 시험을 보는 환경에도 부담이 있었습니다. 그동안 국내 자격증 시험을 정말 편하게 봐왔구나 싶었습니다. 😂 다행히 대부분 익숙했던 문제들이었습니다. 생각보단 준수한 점수로 합격하게 되었습니다. (871/1000)

 

주위 동료분들께 AWS 자격증을 준비한다고 했을 때 유독 다른 주제보다 부정적인 반응이 많았습니다. 시험 자체의 비용 뿐만 아니라 준비 과정에서의 부대비용도 만만치 않다 보니 다양한 의견이 있는 것 같기도 합니다. 그러나 AWS 지식 필요성 자체를 부정하는 사람은 한 명도 없었습니다. 그래서 처음 취득 동기와 같이 서비스 공부를 목적으로 하고, 자격증은 따라온다는 생각으로 공부하였습니다. 자격증 이전 제 AWS 지식수준과 현재를 비교해 보면, 잘 도전했고 잘 취득했다고 생각합니다.

 

+ 저는 정가(20만 원...)에 시험 봤지만 때때로 있는 AWS 교육 이수 하면 50% 할인 쿠폰이 나온다고 합니다.

자바에서 특정 타입 E의 컬렉션을 인자로 받아 최댓값을 반환하는 max 라는 제네릭 메서드를 만든다면, 선언부는 아래와 같이 설계할 수 있습니다.
public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) { ... }
관련 내용을 정리해보았습니다.

 

Collection

객체나 값 여러개를 한 곳에 담아서 연산할 때가 많습니다. 자바에서는 연산의 특성에 따라 다양한 컬렉션 인터페이스(List, Queue, Set, Map 등)들을 제공합니다. 이 인터페이스들은 공통적으로 Collection 인터페이스를 확장합니다. 다음은 일부입니다.

public interface Collection<E> extends Iterable<E> {
	int size();
	//... 생략
}

이 인터페이스의 구현체들은 크기를 재는 기능이 필수입니다. 전체 컬렉션을 순회하기 위해 몇번을 반복해야 할 지, 현재 컬렉션이 비어있는지 체크 등 크기를 아는 것 외에도 다양한 기능의 기본 기능으로 필요할 것 같습니다. 그래서 Collection의 구현체는 항상 size() 메서드를 구현해야 합니다. 마찬가지 이유로 Collection의 구현체들은 size() 말고도 다양한 기능들을 구현해야 합니다.

 

Collection<E> - 제네릭

Collection 인터페이스가 Collection<E> 와 같은 모양을 갖는 이유는 타입안정성 때문입니다.

Collection 에는 여러 객체를 담고 싶지만 의도한 타입의 객체가 들어오지 않을 수도 있습니다.

아래 예시는 정수 배열의 총합을 출력하는 addAll() 메서드 예시입니다.

private static int addAll() {
    List list = new ArrayList();
    list.add(1);
    list.add(2);
    list.add("3");

    int sum = 0;
    for (int i = 0; i < list.size(); i++) {
        int o = (int) list.get(i);
        sum += o;
    }
    return sum;
}

어떤 실수로 인해 "3"이라는 문자열이 리스트에 추가되었습니다. 코드는 정상적으로 컴파일되겠지만, int 캐스팅과정에서 런타임 에러가 날 것입니다.

컴파일 단계에서 이 오류를 잡기 위해 integer 타입만을 담는 IntegerList 같은 새로운 Collection 구현체를 만들어 해결이 가능합니다. 아마 IntegerList의 add() 메서드는 파라미터로 Integer 타입을 받을 것입니다.

하지만 이렇게 하면 리스트에 담길 타입별로 리스트를 각각 따로 만들어주어야 합니다. 문자열을 담는 StringList, 정수만 담는 IntegerList 등등입니다.

그래서 타입을 강제하면서 여러 타입에 대해 적용가능한 클래스와 메서드를 만들기 위해 Generics가 등장하였습니다.

 

Collection 인터페이스의 구현체는 선언할 때 Collection에 담길 요소들의 타입을 명시해 줍니다.

아래 List<Integer> 와 같이 사용합니다. 제네릭 타입에는 원시타입이 사용될 수 없기 때문에 int 대신 Integer 래퍼타입으로 명시하였습니다.

아래 코드는 Collection에 선언된 타입과 다른 요소가 추가되어 컴파일 에러가 발생합니다. 개발자는 자신의 실수를 최대한 빨리 알아챌 수 있습니다.

private static int addAll() {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add("3");//compile error 배포 전 수정 가능

    int sum = 0;
    for (int i = 0; i < list.size(); i++) {
        Integer i1 = list.get(i);
        sum += i1;
    }
    return sum;
}

 

Comparable

직역하면 '비교가능한'이란 인터페이스입니다. 이 인터페이스에는 compareTo라는 단 하나의 메서드만 선언되어 있습니다. 이 인터페이스의 compareTo 메서드를 통해 이 인스턴스와 제네릭으로 들어온 T 타입의 인스턴스간 우위를 결정할 수 있는 로직을 구현합니다.

public interface Comparable<T> {
    int compareTo(T var1);
}

 

이 인터페이스를 구현하게 되면, 구현한 타입 인스턴스는 T타입의 인스턴스와 비교가능해야 합니다.

다음은 Integer 클래스에서 관련 부분입니다.

public final class Integer extends Number implements Comparable<Integer> {
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }

    public static int compare(int x, int y) {
        return x < y ? -1 : (x == y ? 0 : 1);
    }
}

Integer 클래스는 Comparable<Integer> 를 구현했습니다. 즉 Integer 타입인스턴스 Integer 타입의 인스턴스와 비교가능합니다. 아래 예시와 같습니다.

Integer integer = 1;
integer.compareTo(2);

 

 

와일드카드 '?'

Generic을 활용하여 다양한 타입을 지원하고 한 번 정해진 타입을 강제할 수 있게 되었지만 아직 고민할 부분이 남아있습니다. 아래와 같은 경우입니다.

import java.util.ArrayList;
import java.util.List;

interface Animal {
    void makeFriends(List<Animal> animalList);
}

class Dog implements Animal {
    @Override
    public void makeFriends(List<Animal> animalList) {
    }
}

public class Main {
    public static void main(String[] args) {
        Animal animal = new Dog();
        List<Dog> dogList = new ArrayList<>();
        dogList.add(new Dog());
        animal.makeFriends(dogList);
    }
}

Animal 끼리 친구를 맺을 수 있는 makeFriends() 메서드가 있습니다. Animal은 같은 Animal 타입의 리스트를 받아 친구를 사귈 수 있습니다. 19라인의 animal.makeFriends(dogList); 는 언뜻 보면 문제가 없어 보입니다. Animal의 하위타입 Dog 리스트를 인자로 하여 makeFriends() 메서드를 호출했습니다.

그러나 이 코드는 List<Animal> 이 들어갈 자리에 List<Dog>가 들어왔다는 IDE 경고와 함께 컴파일 에러가 납니다.

간단히 말하면 Dog는 Animal의 하위타입이지만 List<Dog> 는 List<Animal>의 하위타입이 아닌 완전 별개의 타입이기 때문입니다.

주어진 요구사항은 Animal과 그 하위타입까지 받을 수 있는 List를 인자로 받고 싶습니다. 이를 위해 와일드카드 문자 '?'가 있습니다.

'?'는 상/하위 타입을 유연하게 처리하기 위한 특수한 매개변수화 타입으로, makeFriends() 메서드의 매개변수 부분을 다음과 같이 바꾸어 의도한 대로 동작하게 변경하였습니다.

interface Animal {
    void makeFriends(List<? extends Animal> animalList);
}

이로써 Animal 하위타입의 List를 인자로 받는 메서드로 변경되었고 잘 작동하게 되었습니다.

'? extends Animal'은 Animal 및 하위타입이란 뜻이고 '? super Animal'은 Animal 및 상위타입이라는 뜻입니다.

 

PECS

위 같은 상황을 피하기 위해서 파라미터를 유연하게 만들자는 취지의 PECS 원칙이 있습니다.

PECS(Producer-Extends, Consumer-Super)는 매개변수화 타입 T가 생산자면 extends를 쓰고 소비자면 super를 사용하는 원칙입니다.

 

외부로부터 컬렉션 매개변수를 받아 로직을 수행하는 메서드가 있을 때 이 매개변수를 생산자(producer)라고 합니다. 

생산자의 매개변수화 타입은 그냥 <E>가 아닌 <? extends E>와 같이 사용합니다.

 

반대로 이 클래스의 멤버에서 또는 새로 인스턴스를 생성해서 매개변수 컬렉션으로 소비할 때, 이 매개변수를 소비자(consumer)라고 합니다.

소비자의 매개변수화 타입은 그냥 <E>가 아닌 <? super E>와 같이 사용합니다.

아래 print()와 addDefaultValuesToList()가 각각의 예시 메서드입니다.

import java.util.List;

public class ListProcessor<E> {

    void print(Iterable<? extends E> producer) {
        producer.forEach(el-> System.out.println(el));
    }

    void addDefaultValuesToList(List<? super String> consumer) {
        consumer.addAll(List.of("init1","init2"));
    }
}

 

메서드에 선언된 매개변수화 타입

간략하게 짚고 넘어가겠습니다. 매개변수화 타입은 메서드에도 선언될 수 있습니다. 문법은 다음과 같습니다. 

import java.util.List;

public class MyClass {
    <V> V method(V v) { //접근제어자(생략) <매개변수화타입> 리턴타입 메서드명 (매개변수) {
        return v;
    }
}

 

max() 메서드 이해하기

드디어 이해를 위한 기반지식을 모두 갖추었습니다. 전체 max() 메서드는 아래와 같습니다.

구현부는 간단합니다. 비어있지 않은 <? extends E> 타입 컬렉션의 각 요소들에서 최우선 순위의 요소 E를 리턴합니다.

public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) {
    if (c.isEmpty()) throw new IllegalArgumentException("empty collection");
    E result = null;
    for (E e : c) {
        if (result == null || e.compareTo(result) > 0) {
            result = Objects.requireNonNull(e);
        }
    }
    return result;
}

 

 

메서드에 선언된 매개변수화 타입부터 보겠습니다. <E extends Comparable<? super E>> 입니다.

하나씩 해석하기 위해 우선 PECS 원칙에 의한 와일드카드를 제거해 보겠습니다. 'E extends Comparable <E>'이 형태는 Comparable 단락에서 Integer 클래스에서 본 형태와 비슷합니다.

이 매개변수화 타입 E는 자기 자신 E타입의 Comparable을 구현한 타입, 즉 자기와 같은 타입의 다른 인스턴스를 비교할 수 있는 타입입니다. max라는 의미를 구현하기 위해서는 컬렉션의 각 요소끼리 비교가 가능해야 하므로 자기 자신의 Comparable 구현이 필수적입니다.

E 들어갈 수 있는 클래스는 아래와 같은 모습일 것입니다. EChild 클래스와 EParents 클래스 모두 E가 될 수 있습니다.

(참고 1) Comparable의 매개변수화 타입은 Comparable<? super E>를 나타내고자 Object로 하였습니다.

(참고 2) compareTo()의 구현부는 임시로 작성되었습니다. 이 부분에 들어가는 로직은 '최댓값'의 정의에 따라 달라집니다.

public class EParents implements Comparable<Object>{
    @Override
    public int compareTo(Object o) {
        return 0;
    }
}

class EChild extends EParents {}

 

이제 max() 메서드의 매개변수 부분을 보겠습니다. 

Collection<? extends E>입니다. PECS 원칙을 지켜 작성되어 있습니다. 위 Collection<E> 및 와일드카드 단락에서 본 내용과 같습니다. E 타입의 하위타입 컬렉션을 모두 매개변수로 받을 수 있습니다.

 

리턴타입은 E입니다. 결국 E 컬렉션을 받아 E 타입 요소를 하나만 리턴하면 해당 메서드는 모두 구현된 것이라고 할 수 있습니다.

적용 예시는 다음과 같습니다. Collection으로 사용된 List에 EChild, EParents 둘 다 적용이 가능하며 두 타입이 섞여있어도 가능합니다.

개발자는 compareTo()에 적당한 비교 로직을 담아 'max'라는 의미에 맞는 하나의 E를 리턴하도록 하면 됩니다.

public class Main {

    public static void main(String[] args) throws Exception {
        List<EParents> eParents = List.of(new EParents(), new EParents());
        List<EChild> eChildren = List.of(new EChild(), new EChild());
        EParents max = max(eParents);
        EChild max1 = max(eChildren);
    }
    
    public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) {
        if (c.isEmpty()) throw new IllegalArgumentException("empty collection");
        E result = null;
        for (E e : c) {
            if (result == null || e.compareTo(result) > 0) {
                result = Objects.requireNonNull(e);
            }
        }
        return result;
    }
}

 

 

회고

public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) { ... }

를 풀어서 정리하면 아래와 같습니다.

max 메서드의 매개변수화 타입은 E타입이다. E는 E 또는 그 상위타입의 Comparable을 구현했다. 그러므로 CompareTo() 메서드를 통해 서로 비교가 가능한 타입이다. max 메서드는 매개변수로 이 E 또는 E의 하위타입의 컬렉션을 매개변수로 받고 리턴으로 E타입을 반환한다.

PECS 원칙을 비롯한 매개변수화 타입은 자바 API 등에서 매우 자주 보이는 형태로 이해하고 문서를 읽는 습관을 갖겠습니다

+ Recent posts