AWS Certified Developer Associate (DVA-C02) 을 취득한지 어언 1년 후, 

이번에는 AWS Certified Advanced Networking - Specialty (ANS-C01) 을 취득하게 되었다.

 

다른 자격증을 두고 이걸 도전하게 된 이유는,

회사에서 대부분의 Cloud 아키텍처가 온전히 Cloud Native가 아닌, Hybrid 아키텍처가 대부분이었기 때문이었다.

 

대다수의 서비스들이 Transit Gateway (TGW) 을 통해 Multi-VPC 를 통합하여 east-west communication 및

Direct Connect 전용선으로 기존 data center와의 bidrectional communication을 수행하고 있었다.

 

처음에 아키텍처를 봤을 땐 대체 뭐가 뭔지 알 수 없었고 각 서비스들이 뭔지 모르니

traffic의 대체 왜 여기서 저기로 흐르는 지도 알 수 없었다. 

 

그래서 결국 AWS의 Networking Service들을 공부할 겸 ANS-C01를 취득해보기로 했다.

 

 

공부 방법

처음에는 무작정 dump로 풀려고 했다. DVA-C01을 그런 방식으로도 충분히 취득했었기 때문이었다. 

근데 VPC, ELB, Route53 까지는 그럭저럭 document를 참고하면서 풀 수 있었는데

 

Transit Gateway

Site-to-Site VPN

Direct Connect

 

이 3개는 진짜 document를 몇 번이고 참고해도 쉽사리 문제를 풀 수 없었다.

게다가 저 3개는 문제에서 한 개만 나오는 게 아니라 최소 2개씩은 결합된 아키텍처에 관해 묻는 경우가 많아서 제대로 알아둬야 했다.

 

그래서 일단 dump를 푸는 건 잠시 중단하고 바로 udemy에서 관련 강의를 검색해서 들었다.

 

 

AWS 자격증을 취득하려는 사람들이 많이 보는 Stephane Maarek 선생님의 강의.

근데 ANS-C01 강의는 Stephane 선생님이 처음에 인사만 해주실 뿐 나머지 강의는 죄다 Chetan Agrawal 분이 다 해주신다;

 

인도 출신이셔서 그런지 처음에 영어 발음을 알아듣기 힘들어서 허들이 좀 있었지만 영어 자막 켜놓고 듣다 보면 상당히 익숙해진다. 

 

어쨌거나 가격은 거의 10만원 가까이 하는 고가이지만 허구한날 세일을 때리는 Udemy 덕에 14000원을 주고 결제했다.

 

모든 강의를 다 듣지는 않았고, 위의 저 3가지 (Transit Gateway, Site-to-Site VPN, Direct Connect) 만 들었다. 시간만 더 있었으면 Route 53 쪽의 DNS 관련 부분 (Private Hosted Zone, IB/OB endpoint) 들도 듣고 싶었는데, 일단 저 3개만 들어도 어지간한 덤프는 다 풀 수 있었다.

 

덤프는 examtopics에서 7만원 넘게 주고 263개의 문제를 전부 구매했지만 실질적으로 140개밖에 못 보고 시험장에 들어가긴 했다;

 

 

시험 후기

DVA-C01도 그렇고 이번 시험도 어김없이 실제 시험장에 가서 시험을 쳤다. 

 

내가 시험을 친 곳은 강남역 바로 인근의 HWG Testing Center.

 

 

시험시간은 16:45 ~ 17:45 였는데 실제로 시험시간은 한 2시간 넘게 준다.

45분보다 일찍 도착해도 그냥 시험자 신원 확인하고 난 뒤 바로 시험을 칠 수 있게 해준다.

 

신분증이랑 신용카드(체크카드) 둘 다 준비해서 가면 감독관님께서 신원 확인하시고 시험장으로 안내해주신다.

그리고 준비된 컴퓨터에서 바로 문제를 풀고 다 풀고 나면 CCTV 쪽으로 손을 들면 감독관님께서 시험 종료 확인해주시고 밖으로 보내주신다.

 

시험은 한국어로 응시했는데, 한국어로 해도 영어 원문을 볼 수 있게 해주니 기계 번역의 어색함으로 인해 문제를 해석하기 힘들다면 그냥 영어 원문을 참고하면 된다. 나는 덤프를 영어로 푸는 연습을 해서 그런지 한국어 문제를 빠르게 읽을 수 있었다.

 

총 65문제였고, 좀 미심쩍은 문제는 플래그(flag) 표시를 해두고 나중에 다시 그 문제로 돌아와서 검토할 수 있다.

 

 

결과

총 65문제였고, 문제를 다 풀고 시험을 치고 나오니 배고픔이 밀려들어서 결과는 잊어버리고 그냥 밥 먹고 자유를 만끽하며 노는 데만 집중했던 것 같다. 그러다가 지금쯤 결과 나왔으려나? 하고 20시쯤 메일함을 열어봤는데,

 

합격이었다! 

사실 풀면서 대부분의 문제가 덤프랑 똑같아서 (제시문 순서도 안 바꾸고 그대로 낸 문제들이 많았다;) 어지간한 이상은 대부분 다 풀었던 것 같다. 다만 점수는 내 예상보다 좀 많이 낮았다.

 

 

 

800점은 넘었을거라 생각했는데.. 여전히 제대로 이해하지 못하고 헷갈려한 부분들이 있었나 보다... 어쨌거나 합격했으니 됬다. 

어쨌거나 공부하면서 aws hybrid architecture를 봐도 더 이상 아무것도 몰라서 헤매지는 않을 것 같다.

 

+) Certified Developer Associate (DVA-C02) 취득후기는 여기

https://velog.io/@devhslee02/AWS-DVA-C02-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0

 

[AWS] Certified Developer (DVA-C02) 합격 후기

백엔드 커리어를 시작한 지 만 1년,회사에서 AWS 서비스를 좀 만지다 보니 더 공부를 해야겠다 싶었고, 내친김에 자격증도 따자 싶어서DVA-C02 (Certified Developer) 공부를 시작했었다.공부 기간은 2월

velog.io

 

 

 

ETCD 란

etcd는 Kubernetes Cluster의 모든 상태 정보를 키-값 형태로 저장하는 데이터베이스이다.

etcd에 장애가 생기면 모든 컴포넌트가 미아가 되기 때문에 가용성이 매우 중요하다. 그래서 보통 이중화, 삼중화 이상을 해 놓는다.


etcd는 클러스터링하여 분산 실행하는 RSM (Replicated State Machine) 구조로 되어있다.

간단히 말해 Leader-Follower 구조인데, Leader node가 클라이언트의 요청을 처리하다가 문제가 생기면 새로운 Leader node가 선출되어 계속해서 가용성을 유지하는 구조이다.  (Follower는 Leader의 상태를 복제해둔 거라고 보면 된다)


etcd는 서버 하나당 프로세스 한 개당 사용할 수 있기 때문에 보통통 etcd 자체를 클러스터링 한 다음 마스터 서버에 분산을 하여 안전성을 보장하는 방식으로 아키텍처를 구성한다.

 

 

minikube를 통해 etcd 살펴보기

minikube를 통해 간단히 etcd의 구성을 살펴보겠다.

 

minikube start

kubectl describe pod etcd-minikube -n kube-system

 

 

ETCDCTL


etcdctl은 etcd를 다루기 위한 유틸리티 (cli) 이다. v2와 v3를 사용할 수 있는데, 기본적으로 v2를 사용한다.

이 명령어를 사용할 때는 etcd api server에 접근하기 위해 인증서 파일 경로를 지정해야 한다.

 

 

etcdctl를 사용하여 etcd 데이터베이스에서 '/registry/pods/' (Kubernetes의 모든 pod가 저장된 곳) 경로에 저장된 키를 가져오는 예시를 수행해보겠다.

kubectl exec -it \
-n kube-system etcd-minikube \
-- sh -c 'ETCDCTL_CACERT=/var/lib/minikube/certs/etcd/ca.crt \
ETCDCTL_CERT=/var/lib/minikube/certs/etcd/peer.crt \
ETCDCTL_KEY=/var/lib/minikube/certs/etcd/peer.key \
ETCDCTL_API=3 \
etcdctl \
get \
--keys-only \
--prefix=true \
"/registry/pods/" '

 

위의 명령어를 수행하면 아래와 같이 '/registry/pods/'로 시작하는 모든 경로를 가져오는 것을 확인할 수 있다.

 

위의 명령어를 좀 더 자세히 설명해보자면 다음과 같다.

kubectl exec -it \ # etcd-minikube 파드에 접속하여 명령을 실행하기 위해 'kubectl exec' 사용, -it 옵션은 인터랙티브 모드 설정
-n kube-system etcd-minikube \ # 'kube-system' 네임스페이스의 'etcd-minikube' pod를 지정
-- sh -c 'ETCDCTL_CACERT=/var/lib/minikube/certs/etcd/ca.crt \ # CA 인증서 경로 설정, etcd 서버 인증서 검증용
ETCDCTL_CERT=/var/lib/minikube/certs/etcd/peer.crt \ # 클라이언트 인증서 경로 설정, etcd 서버와의 통신용
ETCDCTL_KEY=/var/lib/minikube/certs/etcd/peer.key \ # 클라이언트 인증서에 대응하는 프라이빗 키 경로 설정
ETCDCTL_API=3 \ # etcd API 버전을 v3로 설정
etcdctl \ # etcd와 상호작용하기 위한 CLI 도구 실행
get \ # etcd 데이터베이스에서 데이터를 조회하는 명령
--keys-only \ # 키만 가져오도록 설정, 값은 포함하지 않음
--prefix=true \ # 지정된 prefix와 일치하는 모든 키를 검색
"/registry/pods/" ' # Kubernetes의 모든 pod 정보가 저장된 경로 prefix

 

 

+) etcd 에 값 저장하고 읽기

etcd는 데이터베이스니까 값을 저장하고 읽는 것도 가능하다.

etcd pod의 container shell에 접속해서 키-값을 저장하고 읽는 명령을 실행해보도록 하겠다.

kubectl -n kube-system exec -it etcd-minikube -- /bin/sh

 

아래와 같이 etcd shell에 접속이 된 걸 확인할 수 있다.

 

 

간단히 'Name'이라는 키를 저장해보도록 하겠다.

ETCDCTL_API=3 etcdctl \ 
 --cacert /var/lib/minikube/certs/etcd/ca.crt \
 --cert /var/lib/minikube/certs/etcd/peer.crt \
 --key /var/lib/minikube/certs/etcd/peer.key \
 put Name kubernetes

 

키 저장에 성공하면 아래와 같이 'OK'라는 문장이 뜬다.

 

 

위에서 저장한 키를 다시 읽어보도록 하겠다.

ETCDCTL_API=3 etcdctl \ 
--cacert /var/lib/minikube/certs/etcd/ca.crt \
--cert /var/lib/minikube/certs/etcd/peer.crt \
--key /var/lib/minikube/certs/etcd/peer.key \
get Name

 

위에서 넣은 값이 그대로 출력되는 것을 확인할 수 있다.

 

 

모든 클러스터 정보 조회

클러스터의 모든 리소스 정보를 조회하려면 get 명령어의 프리픽스를 클러스터의 모든 리소스가 저장된 최상위 경로인 "/"로 설정해주면 된다.

ETCDCTL_API=3 etcdctl \ 
--cacert /var/lib/minikube/certs/etcd/ca.crt \
--cert /var/lib/minikube/certs/etcd/peer.crt \
--key /var/lib/minikube/certs/etcd/peer.key \
get / --prefix --keys-only

 

그러면 아래와 같이 모든 구성 정보가 출력되는 것을 확인할 수 있다.

'Kubernetes' 카테고리의 다른 글

[Kubernetes] 쿠버네티스를 사용해야 하는 이유  (0) 2024.08.01

오늘날 많은 서비스들이 Container를 통해 운영되고 있다.

 

개발과 운영환경 간의 동일 서비스 보장, 대규모 시스템에서의 마이크로서비스 등 Container가 쓰이고 있는 이유는 정말 다양하다.

하지만 단순히 Container 기술뿐만 아니라 이렇게 다량으로 운영되고 있는 Container를 관리해줄 중앙 구심점 역시도 필요하다.

 

Kubernetes 란

 

 

 

Kubernetes, 줄여서 K8S라고도 부르는 쿠버네티스는 Google에서 개발한 오픈소스 Container Orchestration 플랫폼이다.

 

Container Orchestration 도구는 Kubernetes 말고도 RedHat 사의 OpenShift 등 여러가지가 있지만 

 

완전 오픈소스인데다 어디서든 Reference를 구할 수 있을 만큼 생태계가 풍부하고, On-premise나 Cloud Native 등 환경에 구애받지 않고 어디서든 구동할 수 있다는 점 때문에 현재 Container Orchestration의 표준이 되었다.

 

 

Container Orchestration 이란

 

간단히 말하자면 그냥 수많은 Container들을 관리해주는 것인데, 아래의 Action들을 포함한다

 

자동화된 스케일링

- 트래픽의 변동에 따라 Container를 자동으로 증가시키거나 감소

 

자동화된 Roll-out, Roll-back

- Roll-out: 업데이트 시, 점진적으로 새 버전을 배포하고 모니터링하여 문제가 없으면 점차적으로 전체 시스템에 배포

- Roll-back: 문제가 발생하면 자동으로 이전 버전으로 롤백하여 서비스 중단을 최소화

 

자동화된 복구
- 컨테이너 상태를 계속 감시하고 있는 control tower 필요

- 문제가 발생한 Container를 재시작하고 가용성을 높이고 다운타임을 감소

 

자동화된 Bin Packing

- Bin packing: 리소스를 최대한 효율적으로 사용하기 위해 컨테이너를 클러스터 노드에 자동으로 배치하는 기능

 

시크릿과 구성 관리

- 애플리케이션에서 사용하는 secret을 안전하게 저장

- 환경 설정이나 구성 파일을 중앙에서 관리하여 운영의 일관성을 유지


Service Discovery와 로드 밸런싱

- 각 서비스가 서로 통신할 수 있도록 네트워크를 설정

- 로드 밸런서를 사용하여 Container 간에 트래픽을 균등하게 분배하여 특정 Container에 과부하가 걸리지 않도록 관리

 

Storage Orchestration
- 클라우드 기반의 스토리지 서비스나 로컬 디스크를 Container에 mount

 

IaC를 사용한 운영

- 인프라 설정을 코드로 정의하고 자동으로 provisioning

 

 

Kubernetes의 구성

출처: Kubernetes 공식 페이지

 

Kubernetes는 크게 Control PlaneData plane으로 나뉜다

 

Control plane (Master node) 은 Kubernetes 전체를 통제/관리하는 역할을 담당하며, 아래의 4가지를 포함한다.

  • kube-apiserver
  • etcd
  • kube-scheduler
  • kube-controller-manager

 

Data plane (Worker node) 은 실제 Container Runtime이 있는 부분이며, 실제 애플리케이션이 배포되는 곳이다.

아래 4가지를 포함한다.

  • kubelet
  • kube-proxy
  • continer runtime

 

사용자가 Kubernetes를 사용하기 위해 kubectl (보통 '쿠베시티엘' 이라고 발음) 이라는 명령어를 사용하는데, 이 명령어는 Control plane 으로 전달되며 Container 워크로드에 대한 작업을 수행하게 된다.

 

 

 

 

'Kubernetes' 카테고리의 다른 글

[Kubernetes] ETCD  (0) 2024.08.01

RTM에서 가상머신(CentOS 7)을 접속하여 스크롤 사용을 위한 패키지를 설치하려고 했는데 

아래와 같은 에러가 발생했다.

 

 

?? 이게 뭔 소리지

 

혹시나 네트워크 설정이 안되어 있나 싶어서 ping도 쏴보고 DNS 설정까지 확인했는데 네트워크 문제는 아닌 것 같았다.

Yum 리포지토리 설정이 잘못되어 있는 듯 했다.

 

 

Base 리포지토리 설정을 우선 확인해봤다.

sudo vi /etc/yum.repos.d/CentOS-Base.repo

 

그리고 모든 섹션의 mirrorlist 부분을 주석처리하고 baseurl을 활성화해주고

 

yum 캐시를 모두 지워줬다.

sudo yum clean all

 

그리고 다시 캐시를 생성해주려는데

sudo yum makecache

 

 

 

이번엔 baseurl이 유효하지 않아서 생긴 문제인듯했다. 

 

열심히 구글링을 해서 찾아낸 다른 baseurl로 교체해주고 다시 yum 캐시를 생성해줬다.

 

baseurl=http://vault.centos.org/altarch/7/os/$basearch/

 

 

sudo yum clean all
sudo yum makecache

 

 

대충 패키지 아무거나 깔아봤다. 어쨌든 해결!

 

UTM에서 초기에 CentOS 7 (또는 다른 운영체제)를 설치하기 위해

이미지 파일(iso)을 등록하고, 메모리와 디스크까지 할당해준 후 

 

 

위와 같이 'install CentOS 7'을 눌러 네트워크 설정, 파티셔닝, 루트 계정 비밀번호 설정, 사용자 계정까지 생성해 준 후에 

reboot를 눌러줬음에도 불구하고 다시 install 화면이 뜬다면,

 

우선 해당 가상 머신을 잠시 stop하고 설정에 들어간다 (빨간색 네모박스)

 

그러면 왼쪽 카테고리에서 아래쪽을 보면 Drives 부분이 있을텐데,

 

거기서 연결된 ISO 이미지를 제거하기 위해 해당 드라이브를 Delete 해줘야 한다.

 

그리고 나서 다시 그 가상 머신을 실행해보면,

비로소 CentOS 에 로그인할 수 있게 된다....

 

설치가 완료된 후엔 설치 디스크를 제거하여 더 이상 ISO 파일에서 부팅이 되는 것을 방지해줘야 하는 것 같다.

'Linux' 카테고리의 다른 글

[Linux/CentOS 7] yum: Could not retrieve mirrorlist 에러  (0) 2024.07.02

application bootstrap 전이 아니라 그 이후, 즉 런타임에서 환경변수를 읽어와야 할 때가 있다.

 

가령 TypeOrmModule의 경우, DataSource 초기화를 위해 필요한 모든 커넥션 정보들이 환경변수로부터 다 읽어져와야 해서 데이터베이스 관련 환경변수들이 빠졌는지 아닌지는 쉽게 확인할 수 있다.

 

그러나 모듈 초기화에서 체크하지 않는, 메서드 내부에서 그 때 그 때 읽어서 활용하는 환경변수의 경우 env에 없어도 애플리케이션 실행에 문제가 되지 않는다. 그 변수를 호출하는 쪽에서 에러를 내면 그제서야 알아차리는 식이다.

 

Joi 라는 잘 만들어진 라이브러리를 사용하여 validation schema를 만들고, 이를 ConfigModule에 등록해둔다면 런타임에서 환경변수가 없다거나 유효하지 않다는 에러를 맞닥뜨리지 않아도 될 것이다.

 

Joi 설치

 

Joi 라이브러리를 설치해준다.

npm i joi

 

혹시 @nestjs/config가 없다면 이 역시 설치해준다

npm i @nestjs/config

 

Schema 작성

 

애플리케이션에서 사용해야 하는 모든 환경변수들을 schema에 다 정의해준다.

 

schema 이름은 대충 아무거나 짓고,

변수의 이름과 타입 (필요하다면 세부 타입까지)을 Joi.object 안에다 적어준다.

// env-validation.ts

import Joi from 'joi';

const JoiValidationSchema = Joi.object({
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'local')
    .default('local'),
  PORT: Joi.number().port().default(3000),

  DB_HOST: Joi.string(),
  DB_PORT: Joi.number().port(),
  DB_USER: Joi.string(),
  DB_NAME: Joi.string(),
  DB_PASS: Joi.string(),

  ADMIN_MAIL: Joi.string().email(),
  ADMIN_AUTH_URI: Joi.string().uri()

  REDIS_HOST: Joi.string().ip(),
  REDIS_PORT: Joi.number().port().default(6379),
  REDIS_SESSION_TTL: Joi.number().default(3600),
  REDIS_PASSWORD: Joi.string(),
});

 

Schema 적용

 

AppModule 내부에 ConfigModule를 import해준 후, 아까 작성한 schema를 등록해준다.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { JoiValidationSchema } from './config/env-validation';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: JoiValidationSchema
    }),
    ...
  ],
  controllers: [...],
  providers: [...],
})
export class AppModule {}

 

환경변수는 앞으로도 계속 추가되거나 변경될 여지가 있다. 그럴 경우엔 일일히 개발자가 Joi Schema 역시 업데이트해줘야 한다.

 

하지만

본인이구현해놓고

 Joi schema를 업데이트해야 한다는 사실을 까먹을 수도 있으니 환경변수 파일을 읽어서 schema와 싱크를 맞추도록 조치를 취해줘야 한다.

 

우선 위의 JoiValidationSchema 밑에 환경변수를 읽어오는 함수를 추가해줬다.

오로지 키(변수 이름)만 가져오도록 했고, 주석('#')은 제외했다.

function parseEnvContent(content) {
  const lines = content.split('\n');
  const envObject = {};
  lines.forEach((line) => {
    if (line && !line.startsWith('#')) {
      const [key, value] = line.split('=');
      envObject[key.trim()] = value.trim();
    }
  });
  return envObject;
}

 

환경(NODE_ENV)별로 변수를 각각 검사해서 세부 에러를 잡아내기 위해 아래의 함수도 추가해줬다.

function validateEnvSchema(nodeEnv: string, envFilePath: string, schema: Joi.ObjectSchema) {
  const envFileContent = fs.readFileSync(envFilePath, 'utf8');
  const envObject = parseEnvContent(envFileContent);

  const envKeys = Object.keys(envObject);
  const schemaKeys = Object.keys(schema.describe().keys);

  const missingKeys = envKeys.filter(key => !schemaKeys.includes(key));

  if (missingKeys.length > 0) {
    console.error(`[${nodeEnv}] Missing keys in Joi schema:\n${missingKeys.join('\n')}`);
    process.exit(1);
  }
}

 

그리고 바로 위의 함수들을 호출하게끔 해주면 된다.

// env-validation.ts
import fs from 'fs';
import path from 'path'

...

validateEnvSchema('local', path.join('.env.local'), JoiValidationSchema);
validateEnvSchema('development', path.join('.env.development'), JoiValidationSchema);
validateEnvSchema('production', path.join('.env.production'), JoiValidationSchema);

 

+) 환경별로 Schema 분리

 

만약 로컬과 배포환경 간에 필수로 요구해야 하는 변수들이 조금씩 다르다면 공통 부분만 따로 빼내어 각각 별도의 schema를 만드는 것도 가능할 것 같다.

const JoiValidationSchema = {
  /* 로컬/배포 환경에서 공통으로 쓰이는 변수들 */
};

export const DeployValidationSchema = Joi.object({
  ...JoiValidationSchema,
  /* 배포(개발계/운영계) 환경에서만 쓰이는 변수들 */
})

export const LocalValidationSchema = Joi.object({
  ...JoiValidationSchema,
  /* 로컬 환경에서만 쓰이는 변수들 */
})

...

validateEnvSchema('local', path.join('.env.local'), LocalValidationSchema);
validateEnvSchema('development', path.join('.env.development'), DeployValidationSchema);
validateEnvSchema('production', path.join('.env.production'), DeployValidationSchema);

 

이렇게 할 경우 AppModule도 수정해주면 된다.

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: process.env.NODE_ENV === 'local' ? LocalValidationSchema : DeployValidationSchema,
    }),
  ...
})
export class AppModule {}

 

NODE_ENV에 따라 schema를 달리 적용하도록 수정해줬다.

'NestJS' 카테고리의 다른 글

[NestJS] Guard와 Decorator로 Oauth 로그인 구현하기  (1) 2024.06.14

소위 '소셜로그인'이라 부르는 타사 플랫폼의 개인정보를 이용해 서비스에 로그인할 수 있게 하는 걸 구현하기 위해선

Oauth 2.0 표준에 맞게 백엔드를 구현해야 한다.

 

Oauth 의 구성요소

 

Oauth 인증의 구성요소, 즉 역할은 4가지가 있다.

 

  • Client: 리소스를 요청하는 주체
    • '리소스'는 단순 유저의 개인 정보뿐만이 아니라 구글 캘린더, 네이버 블로그 포스트 등 다양한 정보가 될 수 있음
  • Resource Owner: 리소스에 대한 접근을 제어하고 권한을 부여하는 주체.
    • e.g.) Google, Naver와 같은 플랫폼
  • Authorization Server: Access Token을 발급해주는 서버
  • Resource Server: Access Token을 가진 요청을 검사하여 리소스를 반환하는 서버

 

Oauth 플로우

 

(출처) RFC 6749

 

간략히 정리하면 이러하다.

 

1. Client가 Access Token을 발급받기 위한 인가(Authorization)을 요청한다.

2. 1에서 받은 인가를 통해 리소스를 발급받기 위한 Access Token을 요청한다.

3. Access Token을 통해 리소스를 요청한다.

 

하지만 아무 Client나 인가나 리소스를 요청할 수 있는 건 아니고, 어떤 Client가 인가와 리소스를 요청할 것인지Authoriation Server에 등록해둬야 한다.

 

즉, 구글이나 네이버의 소셜로그인을 구현하고자 하는 경우, 해당 제공자들의 Oauth 콘솔 같은 데 들어가서 Client를 등록하고 그 Client임을 식별할 Credentials(client id, secret)를 취득해 둬야 한다.

 

그리고 이 Client가 접근할 수 있는 리소스의 종류와 범위(scope, e.g. 이메일은 필수 동의, 성별은 이용자 선택 제공 등)를 정해준다.

 

이 때, 웹 페이지에서 인가를 얻고자 하는 경우, 정상적인 endpoint에서 요청을 보내는지 검사하기 위해 redirection URI 라는 것을 별도로 등록해야 한다. redirection URI를 등록해두고 해당 Client가 추후 올바른 Credential과 redirection URI를 가지고 인가를 요청한 경우, Authorization Server가 해당 redirection URI 뒤에 쿼리스트링으로 인가 코드를 포함시켜서 리턴해준다.

 

그렇게 얻은 인가 코드를 가지고 Credential(client id, client secret)과 redirection URI과 함께 다시 Authorization Server에 보내어 Access Token을 요청한다. 그러면 Authorization Server가 Credential을 검사하고 해당 인가코드와 그 인가코드가 발급된 uri와 redirection URI가 일치하는지 확인한 후에 Access Token을 발급해 준다.

(리소스 제공자에 따라 추가 프로퍼티가 필요할 수 있다)

 

그리고 나서 발급받은 Access Token을 가지고 리소스를 요청하면 지정해놓은 scope에 해당하는 리소스들을 Resource Server가 반환하는 식이다.

 

구글, 네이버, 카카오 통합하기

 

소셜로그인은 딱 3개 구현해 봤다. 구글, 네이버, 카카오.

 

이전에는 각 제공자별로 Guard를 따로 구현해 줬는데 잘 생각해보니 셋 다 Access Token과 user profile을 조회하는 url만 좀 다를 뿐이지 쿼리스트링으로 포함시켜줘야 할 프로퍼티는 똑같았다.

 

(네이버의 경우 state 라는 프로퍼티가 추가로 필요하긴 하다)

// google
토큰 발급 url: 'https://oauth2.googleapis.com/token',
프로필 조회 url: 'https://www.googleapis.com/oauth2/v3/userinfo',

// kakao
토큰 발급 url: 'https://kauth.kakao.com/oauth/token',
프로필 조회 url: 'https://kapi.kakao.com/v2/user/me',

// naver
토큰 발급 url: 'https://nid.naver.com/oauth2.0/token',
프로필 조회 url: 'https://openapi.naver.com/v1/nid/me',

 

 

그래서 이 세 개를 하나의 Guard로 통합해서 구현할 수 있을 것 같다는 생각이 들었다.

일단 하드코딩을 피하기 위해 제공자 타입을 enum으로 만들고 위의 url들을 일반 전역변수로 저장해 뒀다.

export enum LoginProvider {
  GOOGLE = 'google',
  KAKAO = 'kakao',
  NAVER = 'naver',
}

export const SocialLogin = {
  google: {
    tokenUrl: 'https://oauth2.googleapis.com/token',
    infoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
  },
  kakao: {
    tokenUrl: 'https://kauth.kakao.com/oauth/token',
    infoUrl: 'https://kapi.kakao.com/v2/user/me',
  },
  naver: {
    tokenUrl: 'https://nid.naver.com/oauth2.0/token',
    infoUrl: 'https://openapi.naver.com/v1/nid/me',
  },
};

 

OauthGuard 구현

 

인가코드는 프론트 쪽에서 받아서 백엔드로 넘겨주고 있다고 가정하고 Guard에서 Access Token을 받아 바로 프로필 데이터를 요청하는 것까지 구현해 둘 것이다.

 

Guard 하나로 구글, 네이버, 카카오를 다 커버하려면 Guard에 어떤 제공자에게 요청할건지 메타데이터를 넘겨줘야 한다.

로그인을 처리하는 라우트에 @SetMetadata 로 간단히 정보를 넘겨주면 될 것 같았다.

@UseGuards(OAuthGuard)
@SetMetadata('provider', LoginProvider.GOOGLE)
@Post('login/google')
async googleLogin(...) {}

@UseGuards(OAuthGuard)
@SetMetadata('provider', LoginProvider.NAVER)
@Post('login/naver')
async naverLogin(...) {}

@UseGuards(OAuthGuard)
@SetMetadata('provider', LoginProvider.KAKAO)
@Post('login/kakao')
async kakaoLogin(...) {}

 

 

이제 OauthGuard를 구현해보겠다. 우선 간단히 Guard의 뼈대를 정의해준다.

@Injectable()
export class OAuthGuard implements CanActivate {
  private logger = new Logger(OAuthGuard.name);
  private loginTokenUrl: string;
  private loginInfoUrl: string;

  constructor(
    private reflector: Reflector,
    private config: ConfigService,
  ) {}
}

 

 

라우트에 정의한 메타데이터 값을 읽기 위해 Reflector를 생성자에 주입해준다. ConfigService는 환경변수에서 Credentials를 읽어오기 위해 주입했다.

 

이제 canActivate를 구현해준다.

async canActivate(context: ExecutionContext) {
    const req = context.switchToHttp().getRequest();
    const provider = this.reflector.get('provider', context.getHandler());
    this.loginTokenUrl = SocialLogin[provider].tokenUrl;
    this.loginInfoUrl = SocialLogin[provider].infoUrl;

    const { code } = req.body;

    const client_id = this.config.get(`${provider}.clientId`);
    const client_secret = this.config.get(`${provider}.clientSecret`);
    const redirect_uri = this.config.get(`${provider}.redirectUri`);
}

 

 

라우트에 정의한 메타데이터 값을 Reflector를 이용해서 읽어온다.

그렇게 읽은 리소스 제공자를 가지고 Access Token을 요청할 url과 프로필을 요청할 url을 미리 얻어뒀다.

 

프론트 쪽에서 보내온 code(인가 코드)를 꺼내고 Client Credentials를 환경변수로부터 읽어온다.

 

Access Token을 요청하는 함수는 canActivate 밖으로 빼내어 구현했다. (url의 state는 네이버 로그인 때문에 고정해뒀다)

async getAccessToken(
    code: string,
    clientId: string,
    clientSecret: string,
    redirectUri: string,
): Promise<string> {
    const url = `${this.loginTokenUrl}?code=${code}&client_id=${clientId}&client_secret=${clientSecret}&redirect_uri=${redirectUri}&grant_type=authorization_code&state=RANDOM`;

    try {
      const result = await axios.post(url);
      return result.data.access_token;
    } catch (error) {
      console.error('get access token::', error);
      throw new InternalServerErrorException(error.message);
    }
}

 

 

 

Access Token을 가지고 user profile을 요청하는 함수도 구현해준다.

 async retrieveProfile(
    accessToken: string,
    provider: string,
): Promise<IGoogleUser | IKakaoUser | INaverUser> {
    try {
      const result = await axios.get(this.loginInfoUrl, {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Authorization: `Bearer ${accessToken}`,
        },
      });

      if (provider === LoginProvider.GOOGLE) return result.data;
      else if (provider === LoginProvider.NAVER) return result.data.response;
      else if (provider === LoginProvider.KAKAO)
        return result.data.kakao_account;
      else throw new InternalServerErrorException('undefined login provider');
    } catch (error) {
      console.error('get userinfo error::', error);
      throw new InternalServerErrorException(error.message);
    }
}

 

프로필 데이터는 타입으로 써먹어야 되서 interface로 따로 만들어줬다.

export interface IGoogleUser {
  id: string;
  email: string;
  verified_email: boolean;
  picture?: string;
  hd?: string;
  name: string;
  given_name?: string;
  family_name?: string;
}

export interface INaverUser {
  id: string;
  name: string;
  nickname?: string;
  gender?: string;
  email: string;
}

export interface IKakaoUser {
  has_email?: boolean;
  email_needs_agreement?: boolean;
  is_email_valid?: boolean;
  is_email_verified?: boolean;
  email: string;
  name: string;
}

 

위에서 만든 함수들을 canActivate에서 호출시켜주면 Guard 구현은 끝난다.

async canActivate(context: ExecutionContext) {    
    ...
    const accessToken = await this.getAccessToken(
      code,
      client_id,
      client_secret,
      redirect_uri,
    );

    const user = await this.retrieveProfile(accessToken, provider);
    req.user = user;

    return true;
}

 

@OauthUser 구현

 

Guard에서 프로필 데이터를 'user'라는 프로퍼티에 추가했으니 이걸 읽어서 Controller 라우트에서 바로 써먹을 수 있게끔

decorator를 만들어줬다.

export const OauthUser = createParamDecorator(
  (
    data: unknown,
    ctx: ExecutionContext,
  ): IGoogleUser | IKakaoUser | INaverUser => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);

 

위에서 구현한 decoratorController에서 써주면 된다.

@UseGuards(OAuthGuard)
@SetMetadata('provider', LoginProvider.GOOGLE)
@Post('login/google')
async googleLogin(
  @Body() payload: OauthLoginDto,
  @OauthUser() user: IGoogleUser,
) {
  return await this.authUserService.oauthLogin(payload, user);
}

 

'NestJS' 카테고리의 다른 글

[NestJS] Joi로 환경변수 validation  (1) 2024.06.15

+ Recent posts