Introduction
각 문서의 순서는 그다지 상관이 없습니다.
웹 성능 최적화 기법
웹 성능 최적화 기법 도서를 읽고 내용을 정리합니다.
웹 성능이란 무엇인가?
1. 웹
웹의 대표적인 요소
- URL
- 네트워크 프로토콜 (대개는 HTTP)
- HTML
2. 웹 성능이 중요한 이유
웹 성능이란 콘텐츠가 신속하게 전달되어 사용자가 원하는 서비스를 빠르게 전달받을 수 있도록 하는 시스템들의 성능을 의미한다. (≒ 웹 로딩 시간)
3초의 법칙 ~ 3초 안에 웹 사이트에 접속한 사용자의 관심을 끄는 것이 필요하다. (그렇지 않으면 사용자가 이탈한다.)
3. 웹 성능 측정 방법
대표적인 서비스
- 브라우저 개발자 도구
- WebPageTest
- 구글 PageSpeed
4. 웹 성능을 만드는 지표
- 스티브 사우더스의 14가지 웹 성능 최적화 기법
최적화 | 내용 | |||
---|---|---|---|---|
백엔드 | 1.Expires 헤더를 추가한다. 2. gzip으로 압축한다. 3. redirect를 피한다. 4. ETag를 설정한다. 5. 캐시를 지원하는 AJAX를 만든다. |
|||
프런트엔드 | 1. HTTP 요청을 줄인다. 2. 스타일 시트는 상단에 넣는다. 3. 스크립트는 하단에 넣는다. 4. CSS 표현식은 피한다. 5. 자바스크립트와 CSS는 외부 파일에 넣는다. 6. 자바스크립트는 작게 한다. 7. 중복 스크립트는 제거한다. |
|||
네트워크 | 1. 콘텐츠 전송 네트워크(CDN)을 사용한다. 2. DNS 조회를 줄인다. |
5. 웹 성능과 프런트엔드
대다수 웹 사이트의 웹 성능 측정 시 가장 주요한 것은 프론트엔드 영역이며, 이는 웹 성능의 측정 기준이 사용자 관점에서 원하는 콘텐츠를 전달받았는지가 되기 때문이다.
브라우저 렌더링
- FCP (First Contentful Paint) : 첫 번째 텍스트 또는 이미지가 표시되는 데 걸린 시간
- SI (Speed Index) : 페이지 콘텐츠가 얾마나 빨리 표시되는지에 대한 정보
- LCP (Largest Contentful Paint) : 가장 큰 텍스트 또는 이미지가 표시된 시간
- TTI (Time to Interactive) : 사용자와 페이지가 상호 작용할 수 있게 된 시간
- TBT (Total Blocking Time) : FCP와 TTI 사이 모든 시간의 합
- CLS (Cumulative Layout Shift) : 표시 영역 안에 보이는 요소들이 얼마나 이동하는지에 대한 정보
6. 웹 성능 예산
웹 성능 예산(web performance budget)이란, 웹 성능에 영향을 미치는 다양한 요소를 제어하는 한계값을 의미한다. 웹 성능 지표를 계량할 수 있도록 수치화하여 최적화의 목표치로 삼기 위해 사용한다.
정량 기반 지표 (quantity based metrics)
웹 페이지 구성 요소에 대한 한계값
ex.) 이미지 파일의 최대 크기, JS 파일 크기 합...
시간 기반 지표 (timing based metrics ~ milestone timing)
실제로 브라우저에서 측정 가능한 시간적 수치에 대한 시간에 대한 한계값
ex.) FCP, TTI
규칙 기반 지표 (rule based metrics)
성능 측정 도구들을 통해 측정된 점수에 대한 한계값
ex.) WebPageTest의 성능 점수, 구글 Lighthouse의 성능 점수
웹 최적화
1. 웹 최적화란
1 - 1. 프런트엔드 최적화
- 스크립트를 병합하여 브라우저의 호출 개수를 줄임
- 스크립트 크기를 최소화하여 바이트 자체를 줄임
- 스크립트를 gzip 등으로 압축하여 전달
- WebP 등으로 브라우저 이미지 형식을 최적화
- 이미지 손실, 무손실 압축
- Cache-Control 응답 헤더를 통해 브라우저 캐시를 충실히 사용
- 도메인 수를 줄여 DNS 조회를 최소화
- DNS 정보 미리 읽어 오기
- CSS를 HTML 상단에, 자바스크립트를 HTML 하단에 위치시키기
- 페이지 미리 읽어오기 (page prefetching)
- 타사 스크립트가 웹 성능을 방해하지 않도록 조정
1 - 2. 백엔드 최적화
- DNS 응답이 빨라지도록 서버 증설
- DNS 응답을 빠르게 할 수 있도록 DNS 정보를 최대한 캐싱
- 웹 서버가 있는 데이터 센터의 네트워크 출력(throughput)/대역폭(bandwidth) 증설
- 웹 서버, 웹 애플리케이션 서버의 CPU/RAM 증설
- 프록시 서버를 설정하여 웹 콘텐츠를 캐싱
- CDN(Content Delivery Network)을 사용해 인터넷 상에 콘텐츠 캐싱
- 데이터베이스 정규화로 디스크 I/O 최적화
- 데이터베이스 캐싱으로 응답을 빠르게
- 로드 밸런싱을 통해 가장 성능이 좋은 웹 서버로 요청을 연결
- 웹 애플리케이션 로직을 가볍고 빠르게 개발
1 - 3. 프로토콜 최적화
- 웹 콘텐츠를 전달하는 HTTP/HTTPS 프로토콜 자체의 효과를 극대화
2. TCP/IP 프로토콜
TCP와 웹 성능도 밀접한 관련이 있어 TCP 성능이 나빠지는 웹 성능 역시 영향을 받는다.
- TCP 네트워크의 대표적인 성능 지표
- 대역폭(bandwidth): 특정 시간 동안 얼마나 많은 네트워크 트래픽을 보낼 수 있는지?
- 지연 시간(latency): 클라이언트 - 서버 간 콘텐츠 전달에 얼마만큼의 시간이 걸리는지?
2 - 1. TCP 혼잡 제어 (TCP congestion control)
TCP 혼잡 붕괴 (TCP congestion collapse): TCP 통신량이 실제 처리량보다 많아 문제가 발생하는 것
패킷을 보내는 쪽에서 네트워크에서 수용 가능한 양을 파악해 그 만큼의 패킷을 보내는 약속으로 TCP혼잡을 해결한다.
받는 쪽에서 패킷이 정상적으로 송신되었음을 알리는 ACK 패킷을 보내고, ACK 패킷을 받은 호스트에서는 지속적으로 패킷을 보낼 수 있다.
느린 시작 (slow start)
TCP 연결 시작 시 전송 가능한 버퍼 양인 혼잡 윈도우(Congestion Window, CWND)의 초깃값을 작게 설정하여 전송하고, 해당 패킷에 대한 ACK를 받으면, 처음 보낸 패킷의 2배에 해당하는 패킷을 전송한다. 이런 식으로 패킷 유실(packet drop)이 발생하여 적절한 혼잡 윈도우의 크기를 파악하기 전까지 반복한다.
빠른 재전송 (fast retransmit)
먼저 도착해야 할 패킷이 도착하지 않고 다음 패킷이 도착한 경우에도 수신자가 일단은 ACK 패킷을 보낸다. 중간에 패킷이 하나 손실되는 경우, 송신자가 중복된 ACK 패킷을 통해 이를 감지하고 전송되지 않은 패킷을 재전송한다. 중복된 패킷을 3개 받으면 반드시 손실된 패킷을 재전송하고, 동시에 혼잡 제어가 필요한 상황임을 인식해 혼잡 윈도우의 크기를 줄이는 작업도 수행한다.
흐름 제어 (flow control)
TCP 송신자가 데이터를 너무 빠르게 혹은 너무 많이 전송하여 수신자의 버퍼가 오버플로되는 현상을 방지하는 기술이다. 송신자가 데이터를 전송하는 속도를 애플리케이션 프로세스를 읽는 속도와 유사한 수준으로 만들어 트래픽 수신 속도를 송신 속도와 일치시킨다.
3. HTTP 프로토콜
웹은 HTTP 프로토콜을 통해 전달되므로, HTTP 성능의 개선 역시 웹 성능의 향상에 도움을 줄 수 있다.
3 - 1. HTTP 최적화 기술
HTTP/0.9 : 클라이언트 ~ 서버 간 인터넷 통신 정상화, 가용성, 신뢰성 등 기능에 초점
HTTP/1.0 : 클라이언트 ~ 서버 간 요청과 응답을 빠르게 할 수 있는 연구
HTTP/1.1 이후 : 멀티 호스트 기능과 클라이언트 ~ 서버 간 사이에서 TCP/IP 연결을 재사용하능 기능을 추가
3 - 2. HTTP 지속적 연결
3-way handshake: TCP 통신을 연결하는 방식으로, SYN, SYN-ACK, ACK
의 3번의 요청/응답으로 이루어짐.
HTTP 초기에서는 요청과 응답을 위해 이와 같은 방식으로 TCP 연결을 수행했으나, 매 요청과 응답 간에 TCP 연결을 맺고 끊는 것을 반복해야 했는데, 점차 웹 페이지에서는 많은 양의 웹 콘텐츠를 전달해야 했으므로, 이러한 방식에는 번거로움이 따랐다.
keep-alive 혹은 연결 재사용이라는 용어로 불리는 지속적 연결 방식은 클라이언트와 서버가 TCP 상에서 한번 연결되면 연결이 완전히 끊어지기 전까지 맺어진 연결을 지속적으로 재사용하는 기술이다. HTTP/1.0 기반에서 지속적 연결을 원하는 클아이언트가 해당 기능을 지원하는 웹 서버에 아래의 요청 헤더를 이용하여 지속적 연결을 요청할 수 있게 되었다.
Connection: keep-alive
HTTP/1.1 버전에서는 Connection 헤더를 사용하지 않아도 모든 요청과 응답이 이러한 지속적 연결을 기본으로 지원하며, HTTP 응답이 완료되거나 TCP 연결을 끊어야 하는 경우에만 이 Connection 헤더를 사용했다.
HTTP/2 버전은 단일 TCP 연결로 클라이어늩와 서버 사이 응답 지연 없이 스트림(stream) 형태로 다수의 HTTP 요청과 응답을 주고받을 수 있는 멀티플렉싱 기술의 토대를 만들었다. 즉, HTTP/2를 사용한다면 더 이상 지속적 연결에 대해 고민을 필요가 없어졌다.
3 - 3. HTTP 파이프라이닝
HTTP 파이프라이닝은 먼저 보낸 요청의 응답이 없어도 다음 요청을 병렬적으로 수신자 측에 전송하는 기술이다. 이는 기존의 선입선출(FIFO) 방식의 단점을 극복하기 위한 것으로, 중간에 응답 지연이 발생하더라도 클라이언트는 먼저 서버 측의 응답을 받을 수 있어 전반적으로 빠른 웹 로딩을 구현할 수 있다.
4. DNS
4 - 1. DNS 작동 원리
DNS는 인터넷 호스트명을 클라이언트와 서버가 이해할 수 있는 IP 주소로 변환해주는 시스템이다. DNS의 질의와 응답 성능이 나쁘면 웹 사이트 로딩에 영향을 줄 수 있다.
로컬 DNS 서버 -> 루트 DNS 서버 -> .com DNS 서버 -> example.com DNS 서버
4 - 2. 사용 중인 다양한 도메인 확인 방법
오픈소스 등으로 다양한 서비스들을 사용하게 되면서, 자신이 운영 중인 웹 서비스 도메인의 성능이 빠르다고 해서 DNS 조회 시에는 웹 성능에 문제가 업삳고 판단하기 어려워졌다. 이에 따라 특정 모듈 서비스의 DNS 조회가 불가능하거나 느리다면 해당 모듈을 자체 웹 서버에 업로드 후 제공하는 방법을 고려해야 한다.
4 - 3. 웹 성능을 최적화하는 도메인 운용 방법
직접 개발한 내부 서비스에 도메인 분할을 하고자 한다면 상위 도메인을 동일하게 해 DNS 질의를 최대한 적게 만들자.
HTML의 DNS 프리패치(prefetch) 기능을 사용하면 웹 페이지에 사용된 도메인들의 DNS를 조회하는 시간이 좀 더 빨라진다. 이는 웹 페이지를 여는 시점에 멀티스레드 방식으로 미리 DNS를 조회해 빠르게 IP 주소를 불러오도록 하는 기술이다.
<link rel="dns-prefetch" href="//img.feoorea.com" />
5. 브라우저
5 - 1. 네비게이션 타이밍 API
웹 사이트의 성능을 측정하는 데 사용할 수 있는 데이터를 Web API에서 제공한다. -> window.performance
5 - 2. 네비게이션 타이밍 속성
performance.timing은 페이지 요청 등의 탐색 이벤트 시간이나 DOM 로딩 시작 등의 페이지 로드 이벤트 시간을 파악하기 위해 사용할 수 있지만, 현 시점에서는 deprecated되고 있으므로, 이는 PerformanceNavigationTiming 인터페이스를 사용하는 것으로 대체되는 것이 좋겠다.
웹 사이트 성능을 개선하는 기본적인 방법
1. HTTP 요청 수 줄이기
1 - 1. 스크립트 파일 병합
여러 개로 나뉜 JS 파일을 하나로 병합하여 사용한다. 이는 요청을 줄일 뿐더러, 인터넷 상 프록시나 브라우저에 캐시될 확률을 좀 더 높인다.
1 - 2. 인라인 이미지
이미지 파일을 따로 호출하여 받아오는 대신, base64 등을 이용해 이미지를 인라인으로 삽입하는 방식이다. 다만, 이 경우 이미지 파일 자체는 인터넷이나 브라우저를 통해 캐시되지 않으므로, 무조건적인 성능 향상을 보장하진 않기 때문에, 선택적으로 사용해야 한다.
1- 3. CSS 스프라이트
여러 이미지를 하나의 이미지로 결합해, 필요한 이미지가 위치한 픽셀 좌표 정보를 사용해 필요한 이미지만 가져다 사용하는 방식이다. 주로 아이콘이나 버튼 같은 작은 이미지를 사용할 때 유용하다.
2. 콘텐츠 파일 크기 줄이기
2 - 1. 스크립트 파일 압축 전달
HTTP 프로토콜은 Accept-Encoding
, Content-Encoding
헤더를 사용해 파일 압축 방식의 정보 교환을 지원한다. 요청 헤더는 Accept-Encoding
, 응답 헤더는 Content-Encoding
을 사용한다.
// 클라이언트 요청 헤더
Accept-Encoding: gzip, deflate, sdch
// 웹 서버의 응답 헤더
Content-Encoding: gzip
2 - 2. 스크립트 파일 최소화
HTML, CSS, JS 파일 내 실제 로직에는 아무 영향을 주지 않는 부분을 제거하거나, 간소화시켜 파일을 최소화하는 방법이다.
2 - 3. 이미지 파일 압축
tinyPNG 등의 서비스를 사용하여 손실 압축 방식으로 이미지 파일을 압축할 수 있다.
2 - 4. 브라우저가 선호하는 이미지 포맷 사용
- 구글 - WebP
- 마이크로소프트 - JPEG XR
2 - 5. 큰 파일은 작게 나누어 전송
몇 GB에 해당하는 동영상 파일을 웹 사이트에 삽입했다고 하는 경우, 해당 파일을 한꺼번에 가져오는 것은 버퍼링을 유발할 수도 있고, 실제로 보지 않을 부분까지 가져올 가능성도 있어 자원 낭비가 이루어진다.
이에 부분 요청/응답을 수행할 수 있는데, 해당 기능의 지원 여부는 아래와 같이 웹 서버의 응답 헤더를 통해 확인할 수 있다.
curl -I http://www.example.com/bigfile.jpg
HTTP/1.1
// ...
Accept-Ranges: bytes
Content-Length: 50000000
Accept-Ranges
: byte 단위로 파일의 부분 지원 기능을 수락한다는 의미.Content-Length
: 해당 파일의 전체 크기가 50MB라는 정보를 전달.
이 응답의 내용에 기반하여 클라이언트가 특정 부분을 요청할 수 있다.
curl -v http://www.example.com/bigfile.jpg -H "Range: bytes=0-1023"
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/50000000
Content-Length: 1024
Range: bytes=0-1023
: 파일의 처음(0)부터 1023바이트까지만 요청Content-Range: bytes 0-1023/50000000
: 전체 파일 범위(50000000
중 처음부터 1023 바이트 까지만 전달한다는 의미Content-Length: 1024
: 현재 전달한 부분 파일의 전체 용량이 시작 위치와 끝 위치를 알려주는 데이터를 포함하여 1024 바이트임을 명시
이러한 요청을 여러 개의 범위를 갖는 것도 가능하다.
curl -v http://www.example.com/bigfile.jpg -H "Range: bytes=0-50, 100-150"
3. 캐시 최적화하기
3 - 1. 인터넷 캐시 사용
프록시 서버 - 서버와 클라이언트 간에 통신을 대신해주는 역할을 하는 서버
3 - 2. 브라우저 캐시 사용
웹 콘텐츠 중 일부를 클라이언트 측에 저장해 인터넷 상의 요청을 아예 수행하지 않도록 할 수 있다.
특정 콘첸츠를 브라우저에서 캐시하도록 하고, 얼마나 오랫동안 할 것인지에 대해 웹 서버는 Cache-Control
응답 헤더에 캐시 기간을 설정하여 클라이언트에게 전달한다. 해당 기간을 캐시의 생존 기간이라는 의미에서 TTL(Time To Live)이라고도 한다.
Cache-Control: max-age=3600 // 1시간
Cache-Control의 설정값에는 다음과 같은 것들이 있다.
no-store
: 브라우저가 캐시하지 않도록 설정함(ex. 민감 정보 등 절대 캐시해선 안되는 콘텐츠)no-cache
: 브라우저 캐시를 사용하되, 원본 서버의 콘텐츠 갱신 여부를 미리 조사해 변경이 없을 때만 캐시 콘텐츠를 사용must-revalidate
: 캐시 사용 전 웹 서버에서 설정한 캐시 가능 주기를 먼저 확인하여 해당 시간 범위 내에서만 캐시를 사용public
: 해당 콘텐츠를 명확히 캐시할 수 있음
반면, 콘텐츠를 특정 날짜의 특정 시간까지, datetime의 형태로 설정하고자 하는 경우에는 Expires
응답 헤더를 사용한다.
Expires: Mon, 30 Nov 2020 07:00:00 GMT
4. CDN 사용하기
CDN(Content Delivery Network): 인터넷 상에서 생산/소비되는 웹 콘텐츠를 사용자에게 빠르게 전달하기 위해 대용량 인터넷 캐시 영역에 콘텐츠를 저장해 사용하는 네트워크 방식이다. 여러 개의 분산된 서버로 이루어져 있고, 원본 서버라 불리는 콘텐츠 서버와 사용자 사이에서 프록시 역할을 한다.
CDN을 사용하면 다음과 같은 장점을 얻을 수 있다.
- 인터넷 상 원거리에 있는 콘텐츠를 전달받는 과정에서 발생할 수 있는 네트워크 지연(network latency)와 패킷 손실(packet loss) 현상을 줄일 수 있다.
- 사용자는 가까운 에지 서버에 캐시된 콘텐츠를 전달받으므로, 전송에 필요한 RTT(Rount Trip Time)이 줄어들어 빠르게 콘텐츠를 받을 수 있다.
- CDN의 에지 서버가 캐시된 콘텐츠를 전송하므로 원본 서버의 부하를 줄일 수 있다.
- 콘텐츠가 에지 서버와 주변 에지 서버 사이에 ICP(Internet Cache Protocol)를 이용한 서버 전파를 할 수 있어 캐시 콘텐츠의 재사용률이 매우 높다.
- CDN 서비스들은 사용자 요청 트래픽이나 기술적 특이 사항을 모니터링하는 시스템을 갖추고 있어 인터넷 전송이 필요한 콘텐츠의 시스템과 인적 관리 비용이 절감된다.
JavaScript
실제 코드를 작성하다보면 놓치기 쉬운 JS의 개념들에 대해 작성합니다.
거의 모든 문서는 ko.javascript.info를 참고했습니다. (제 생각엔 진짜 최고의 JS 참고서입니다.)
여기의 문서를 번역하여, 임의로 정리한 내용입니다.
실행 컨텍스트 (Execution Context)
JS에서의 호스팅, 스코프, 클로저와 같은 개념들을 이해하기 위해서는 실행 컨텍스트(Execution Context)와 실행 스택(Execution Stack)에 대해서 이해해야 합니다.
실행 컨텍스트란?
단순히 말해서, 실행 컨텍스트는 JS 코드가 평가되고 실행되는 환경의 추상적인 개념입니다. 어떤 코드가 JS에서 실행될 때, 이는 실행 컨텍스트 내에서 실행됩니다.
실행 컨텍스트의 종류
다음 세 종류의 실행 컨텍스트가 있습니다.
-
전역 실행 컨텍스트 : 기본 실행 컨텍스트로서, 어떤 함수에도 포함되어 있지 않은 코드의 경우, 전역 실행 컨텍스트에 포함됩니다. 브라우저를 기준으로,
window
에 해당하는 글로벌 객체를 생성하고, 해당 글로벌 객체를this
로 설정합니다. 하나의 프로그램에는 하나의 전역 실행 컨텍스트만 존재할 수 있습니다. -
함수 실행 컨텍스트 : 함수가 실행될 때마다, 해당 함수에 대한 새로운 실행 컨텍스트가 생성됩니다. 각 함수들은 자신의 실행 컨텍스트를 보유하지만, 이는 해당 함수가 실행될 때에 생성됩니다. 함수 실행 컨텍스트는 여러개가 될 수 있습니다.
-
Eval 함수 실행 컨텍스트 :
eval
함수 내에서 실행되는 코드들도 자신의 실행 컨텍스트를 보유합니다. 다만,eval
은 JS 개발자들 사이에 자주 사용되지 않으므로, 여기에서 언급하지 않겠습니다.
실행 스택
호출 스택이라고도 불리는 실행 스택은, LIFO(후입선출) 스택 구조로 이루어진 하나의 스택입니다. 실행 스택은 코드 실행 중에 생성되는 모든 실행 컨텍스트를 담고 있습니다.
JS 엔진이 처음으로 스크립트에 마주치면, 전역 실행 객체를 생성한 후, 이를 현재 실행 스택에 추가(push)합니다. 이후 JS 엔진이 함수 실행을 발견할 때마다, 새로운 실행 컨텍스트를 생성하여 스택의 최상단에 추가합니다.
엔진은 실행 컨텍스트가 스택의 최상단에 있는 함수를 실행합니다. 해당 함수가 완료되면, 실행 스택은 스택으로부터 제거(pop)되며, 그 다음 컨텍스트로 넘어가게 됩니다.
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');

실행 컨텍스트의 생성
이제 JS에서 실행 컨텍스트가 어떻게 생성도는지에 다루어보겠습니다.
실행 컨텍스트는 두 단계를 통해 생성됩니다. 첫째는 생성 단계(Creation Phase)이고, 두번째는 실행 단계(Execution Phase)입니다.
생성 단계 (Creation Phase)
실행 컨텍스트는 생성 단계에서 생성됩니다. 해당 단계에서는 아래와 같은 일들이 일어납니다.
- 렉시컬 환경(Lexical Environment)이 생성됩니다.
- 변수 환경(Variable Environment)이 생성됩니다.
따라서, 실행 컨텍스트는 개념적으로 아래와 같이 나타낼 수 있습니다.
ExecutionContext = {
LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
VariableEnvironment = <ref. to VariableEnvironment in memory>,
}
렉시컬 환경(Lexical Environment)
렉시컬 환경은, 식별자-변수(Identifier-Variable) 매핑을 하는 자료구조입니다. (여기서, 식별자란 변수 또는 함수의 이름을 가리키며, 변수는 실제 객체에 대한 참조 또는 원시값을 가리킵니다.)
예를 들어, 아래의 코드를 살펴봅시다.
var a = 20;
var b = 40;
function foo() {
console.log('bar');
}
위의 코드에서 렉시컬 환경은 다음과 같아질 것입니다.
lexicalEnvironment = {
a: 20,
b: 40,
foo: <ref. to foo function>
}
각각의 렉시컬 환경은 다음의 셋으로 구성되어 있습니다.
- 환경 레코드 (Environment Record)
- 외부 환경에 대한 참조 (Reference to the outer environment)
- This 바인딩
환경 레코드
환경 레코드는 렉시컬 환경 내에서 변수와 함수의 선언이 보관되는 장소입니다. 환경 레코드에는 두가지 타입이 있습니다.
-
선언 환경 레코드(Declaration Environment Record) : 변수 및 함수 선언을 저장합니다. 함수 코드에 대한 렉시컬 환경은 선언 환경 레코드를 포함합니다.
-
객체 환경 레코드 (Object Environment Record) : 전역 코드에 대한 렉시컬 환경은 객체 환경 레코드를 포함합니다. 이는 변수와 함수 선언 외에도, 전역 바인딩 객체(Global binding object: 브라우저 상에서는
window
)도 담고 있습니다. 따라서 레코드 내에 해당 바인딩 객체의 각 프로퍼티에 대한 새로운 항목이 생성됩니다.
참고로, 함수 코드를 위해, 환경 레코드는 arguments
객체를 포함하고 있습니다. 이 arguments
객체에는 인덱스와 함수에 전달되는 인수(arguments) 간의 매핑과, 함수에 넘겨지는 인수의 갯수(length)가 담겨있습니다. 예를 들어, 아래 함수에서 argument
객체는 다음과 같은 형태일 것입니다.
function foo(a, b) {
var c = a + b;
}
foo(2, 3);
// argument object
Arguments: {0: 2, 1: 3, length: 2},
외부 환경에 대한 참조
"외부 환경에 대한 참조"는 외부 렉시컬 환경에 대한 접근을 의미합니다. 이는 JS 엔진이 현재 렉시컬 환경에서 원하는 변수를 찾지 못하면, 외부 환경으로 뻗어나가 해당 변수를 찾을 수 있다는 것을 의미합니다.
This 바인딩
전역 실행 컨텍스트에서, this
의 값은 전역 객체에 바인딩됩니다. (브라우저 상에서 window
객체)
함수 실행 컨텍스트에서, this
의 값은 어떻게 해당 함수가 호출되느냐에 따라 달라집니다. 객체 참조에 의해서 호출되는 경우 this
의 값은 해당 객체가 됩니다. 그렇지 않은 경우에 this
는 전역 객체가 되며, strict 모드 상에서는 undefined
가 됩니다.
const person = {
name: 'peter',
birthYear: 1994,
calcAge: function () {
console.log(2018 - this.birthYear);
},
};
person.calcAge();
// `this`는 `person`이 됩니다. `calcAge`가 `person` 객체 참조에 의해 호출되었기 때문입니다.
const calculateAge = person.calcAge;
calculateAge();
// `this`는 전역 객체에 해당하는 `window`입니다. 별도로 넘겨받은 객체 참조가 없기 때문입니다. strict 모드라면, `undefined`가 됩니다.
지금까지의 내용을 되짚어보자면, 렉시컬 환경은 추상적으로 다음과 같이 생겼을겁니다.
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
}
outer: <null>,
this: <global object>
}
}
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
}
outer: <Global or outer function environment reference>,
this: <depends on how function is called>
}
}
변수 환경 (Variable Environment)
이 또한 실행 컨텍스트 내에서 VariableStatements
를 통해 생성된 바인딩을 환경 레코드에 보관하는 렉시컬 환경입니다.
위에서 말했듯, 변수 환경 또한 렉시컬 환경입니다. 따라서 앞서 말했던 렉시컬 환경이 갖고 있는 모든 구성요소와 프로퍼티를 갖고 있습니다.
ES6 상에서, 렉시컬 환경과 변수 환경의 한가지 차이는, 렉시컬 환경이 함수 선언과 let
, const
변수 바인딩에 사용되는 반면, 변수 환경은 오직 var
변수의 바인딩에만 사용된다는 것입니다.
실행 단계 (Execution Phase)
이 단계에서 모든 변수에 대한 할당이 완료되고, 코드가 마침내 실행됩니다.
아래 예시 코드를 살펴봅시다.
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
위의 코드가 실행될 때, JS 엔진은 전역 코드를 실행하기 위해 전역 실행 컨텍스트를 먼저 생성합니다. 따라서 전역 실행 컨텍스트는 생성 단계를 거쳐 아래와 같아질 것입니다.
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>,
ThisBinding: <Global Object>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
c: undefined,
}
outer: <null>,
ThisBinding: <Global Object>
}
}
실행 단계에서는 변수 할당이 수행됩니다. 따라서 전역 실행 컨텍스트 실행 단계를 거쳐 아래와 같아질 것입니다.
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
a: 20,
b: 30,
multiply: < func >
}
outer: <null>,
ThisBinding: <Global Object>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
c: undefined,
}
outer: <null>,
ThisBinding: <Global Object>
}
}
이후, multiply(20, 30)
함수의 호출을 마주치면, 해당 함수 코드를 실행하기 위해 새로운 함수 실행 컨텍스트가 생성됩니다. 따라서, 생성 단계를 거쳐 다음과 같이 새로운 함수 실행 컨텍스트가 만들어집니다.
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
g: undefined
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>
}
}
그 다음, 실행 컨텍스트는 실행 단계를 거쳐 아래와 같이 변수 할당이 완수됩니다. 따라서, 함수 실행 컨텍스트는 실행 단계를 거쳐 다음과 같은 형태가 됩니다.
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
g: 20
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>
}
}
이후 함수의 실행이 완료되면, 반환 값이 c
에 보관됩니다. 따라서 전역 렉시컬 환경이 갱신됩니다. 이후, 전역 코드가 모두 완료되고, 프로그램은 종료됩니다.
참고 - 아마 위의 과정을 따라가면서, 최초에 let
과 const
에는 아무런 값도 할당되지 않은 것을 확인했을 것입니다. 반면에 var
에는 undefined
로 값이 할당됩니다.
이런 일이 발생하는 이유는, 생성 단계를 통해 코드의 변수 및 함수 선언에 대해 스캔하는 동안, 함수 선언은 환경에 전체적으로 저장되는 반면, 각 변수는 undefined
로 설정되거나(var
의 경우), 초기화되지 않은 상태(initialized)로 설정되기 때문입니다.(let
또는 const
의 경우)
이것이 var
를 사용할 때, var
가 선언되기도 전에 상단에서 접근할 수 있게되는 이유입니다. 반면에 let
또는 const
의 경우 이들이 선언되기 전에는 참조 에러를 발생시킬 것입니다.
그리고, var
에 나타나는 이러한 현상을 우리는 호이스팅이라고 합니다.
참고 - 실행 단계에서, JS 엔진이 let
키워드로 선언된 변수의 값을 찾지 못하는 경우, undefined
로 이를 할당합니다.
Class
클래스와 기본 문법
클래스는 다음과 같은 문법을 통해 만들 수 있다.
class MyClass {
constructor() {}
method1() {}
method2() {}
...
}
이렇게 클래스를 만들고, new MyClass()
를 호출하면 내부에서 정의한 메서드가 들어 있는 객체가 생성된다.
객체의 기본 상태를 설정해주는 생성자 메서드 constructor()
는 new
에 의해 자동으로 호출되므로, 별다른 절차 없이 객체를 초기화할 수 있다.
class User {
constructor(name) {
this.name = name;
}
sayHi() {
alert(this.name);
}
}
// 사용법:
let user = new User('John');
user.sayHi();
위에서 new User("John")
을 호출하면 다음과 같은 일이 일어난다.
- 새로운 객체가 생성된다.
- 넘겨받은 인수와 함께
constructor
가 자동으로 실행된다. 이 때, 인수"John"
이this.name
에 할당된다.
이런 과정을 거친 후에 user.sayHi()
같은 객체 메서드를 호출할 수 있다.
그래서 클래스란??
class User {...}
문법 구조가 진짜 하는 일은 다음과 같다.
User
라는 이름을 가진 함수를 만든다. 함수 본문은 생성자 메서드constructor
에서 가져온다. 생성자 메서드가 없으면 비워진 채로 함수가 만들어진다.sayHi
같은 클래스 내에서 정의한 메서드를User.prototype
에 저장한다.
new User
를 호출해 객체를 만들고, 이후 객체의 메서드가 호출되면 메서드를 프로토타입에서 가져온다. 이 덕분에 객체에서 클래스 메서드에 접근할 수 있다.
class User {
constructor(name) {
this.name = name;
}
sayHi() {
alert(this.name);
}
}
// 클래스는 함수입니다.
alert(typeof User); // function
// 정확히는 생성자 메서드와 동일합니다.
alert(User === User.prototype.constructor); // true
// 클래스 내부에서 정의한 메서드는 User.prototype에 저장됩니다.
alert(User.prototype.sayHi); // alert(this.name);
// 현재 프로토타입에는 메서드가 두 개입니다.
alert(Object.getOwnPropertyNames(User.prototype)); // constructor, sayHi
클래스는 단순 Syntactic Sugar가 아니다.
이건 나도 잘못 알고있었던 내용이다. 아래처럼 prototype
을 이용하면 순수 함수로도 클래스 역할을 하는 함수를 만들 수 있다. 때문에 class
를 단순한 Syntactic Sugar로 여기고 있었다.
// class User와 동일한 기능을 하는 순수 함수를 만들어보겠습니다.
// 1. 생성자 함수를 만듭니다.
function User(name) {
this.name = name;
}
// 모든 함수의 프로토타입은 'constructor' 프로퍼티를 기본으로 갖고 있기 때문에
// constructor 프로퍼티를 명시적으로 만들 필요가 없습니다.
// 2. prototype에 메서드를 추가합니다.
User.prototype.sayHi = function () {
alert(this.name);
};
// 사용법:
let user = new User('John');
user.sayHi();
근데 둘 사이에는 중요한 차이가 몇 가지 있다.
class
로 만든 함수엔 특수 내부 프로퍼티인[[FunctionKind]]: "classConstructor"
가 이름표처럼 붙는다. 이것만으로도 두 방법엔 차이가 있다. 이런 검증 과정 때문에, 클래스 생성자는new
와 함께 호출하지 않으면 에러가 발생한다.
더불어, 대부분의 JS엔진에서 클래스 생성자를 문자열로 표현할 때 class ...
로 시작하게 된다는 차이도 생긴다.
class User {
constructor() {}
}
alert(typeof User); // function
User(); // TypeError: Class constructor User cannot be invoked without 'new'
class User {
constructor() {}
}
alert(User); // class User { ... }
-
클래스 메서드는 열거할 수 없다.(non-enumerable) 클래스의
prototype
프로퍼티에 추가된 메서드 전체의enumerable
플래그는false
이고,for..in
으로 객체를 순회할 때, 이는 순회 대상에서 제외된다. 보통 메서드는 순회 대상에서 제외하고자 하므로, 이 특징은 제법 유용하다. -
클래스는 항상 엄격모드로 실행된다.(
use strict
) 클래스 생성자 안 코드 전체엔 자동으로 엄격모드가 적용된다.
클래스 표현식
함수처럼 클래스도 다른 표현식 내부에서 정의, 전달, 반환, 할당할 수 있다. 먼저 클래스 표현식을 만들어보자.
let User = class {
sayHi() {
alert('Hello');
}
};
기명 함수 표현식(Named Function Expression)과 유사하게 클래스 표현식에도 이름을 붙일 수 있다. 클래스 표현식에 이름을 붙이면, 이 이름은 오직 클래스 내부에서만 사용할 수 있다.
// 기명 클래스 표현식(Named Class Expression)
// (명세서엔 없는 용어이지만, 기명 함수 표현식과 유사하게 동작합니다.)
let User = class MyClass {
sayHi() {
alert(MyClass); // MyClass라는 이름은 오직 클래스 안에서만 사용할 수 있습니다.
}
};
new User().sayHi(); // 제대로 동작합니다(MyClass의 정의를 보여줌).
alert(MyClass); // ReferenceError: MyClass is not defined, MyClass는 클래스 밖에서 사용할 수 없습니다.
필요에 따라 동적인 생성 역시 가능하다.
function makeClass(phrase) {
// 클래스를 선언하고 이를 반환함
return class {
sayHi() {
alert(phrase);
}
};
}
// 새로운 클래스를 만듦
let User = makeClass('Hello');
new User().sayHi(); // Hello
getter와 setter
리터럴을 사용해 만든 객체처럼 클래스도 getter나 setter, 계산된 프로퍼티(computed property)를 포함할 수 있다.
class User {
constructor(name) {
// setter를 활성화합니다.
this.name = name;
}
get name() {
return this._name;
}
set name(value) {
if (value.length < 4) {
alert('이름이 너무 짧습니다.');
return;
}
this._name = value;
}
}
let user = new User('John');
alert(user.name); // John
user = new User(''); // 이름이 너무 짧습니다.
이런 방법으로 클래스를 선언하면 User.prototype
에 getter와 setter가 만들어지는 것과 동일하다.
계산된 메서드명 [...]
대괄호 [...]
을 이용해 계산된 메서드 이름(computed method name)을 만드는 예시도 있다.
class User {
['say' + 'Hi']() {
alert('Hello');
}
}
new User().sayHi();
클래스 필드
이는 비교적 최근에 생겨난 기능으로, 구식 브라우저에서는 폴리필이 필요할 수도 있다.
지금까지 살펴본 예시엔 메서드가 하나만 있었다.
'클래스 필드(Class Field)'라는 문법을 사용하면 어떤 종류의 프로퍼티도 클래스에 추가할 수 있다.
클래스 User
에 name
프로퍼티를 추가해보자.
class User {
name = 'John';
sayHi() {
alert(`Hello, {this.name}!`);
}
}
new User().sayHi(); // Hello, John!
클래스를 정의할 때 <프로퍼티명> = <값>
을 써주면 간단히 클래스 필드를 만들 수 있다.]
클래스 필드의 중요한 특징 중 하나는 User.prototype
이 아닌 개별 객체에만 클래스 필드가 설정된다는 점이다.
class User {
name = 'John';
}
let user = new User();
alert(user.name); // John
alert(User.prototype.name); // undefined
클래스 필드는 생성자가 그 역할을 다 한 이후에 처리된다. 따라서 복잡한 표현식이나 함수 호출 결과를 사용할 수 있다.
class User {
name = prompt('이름을 알려주세요.', '보라');
}
let user = new User();
alert(user.name); // 보라
클래스 필드로 바인딩 된 메서드 만들기
JS의 함수는 알다시피 동적인 this
를 갖는다.
따라서 객체 메서드를 여기저기 전달해 다른 컨텍스트에서 호출하게 되면 this
는 원래 객체를 참조하지 않는다.
관련 예시를 살펴보자. 예시를 실행하면 undefined
가 출력된다.
class Button {
constructor(value) {
this.value = value;
}
click() {
alert(this.value);
}
}
let button = new Button('hello');
setTimeout(button.click, 1000); // undefined
이렇게 this
의 컨텍스트를 알수 없게 되어버리는 문제를 Losing this라고 한다.
문제를 해결하기 위해선 두 개의 방법이 있다.
setTimeout(() => button.click(), 1000)
같이 래퍼 함수를 전달- 생성자 안 등에서 메서드를 객체에 바인딩
여기에 더해, 클래스 필드는 또 다른 훌륭한 방법을 제공한다.
class Button {
constructor(value) {
this.value = value;
}
click = () => {
alert(this.value);
};
}
let button = new Button('hello');
setTimeout(button.click, 1000); // hello
클래스 필드 click = () => {...}
는 각 Button
객체마다 독립적인 함수를 만들고 함수의 this
를 해당 객체에 바인딩시켜준다. 따라서 개발자는 button.click
과 같은 메서드를 아무 곳에나 전달할 수 있고, this
에는 항상 의도한 값이 들어가게 된다.
대체로 클래스 필드의 이런 기능은 브라우저 환경에서 메서드를 이벤트 리스너로 설정해야 할 때 특히 유용하다.
화살표 함수 다시보기
화살표 함수(Arrow Function)에 대해 다시 생각해보자.
화살표 함수는 단순히 함수를 짧게 쓰기 위해 쓰지 않는다. 화살표 함수는 몇 가지 독특하고 유용한 기능을 제공한다.
화살표 함수에는 this
가 없다.
화살표 함수 본문에서 this
에 접근하면 외부에서 값을 가져온다.
이런 특징은 객체의 메서드(showList()
)안에서 동일 객체의 프로퍼티(students
)를 대상으로 순회를 하는 데 사용할 수 있다.
let group = {
title: '1모둠',
students: ['보라', '호진', '지민'],
showList() {
this.students.forEach((student) => alert(this.title + ': ' + student));
},
};
group.showList();
예시의 forEach
에서 화살표 함수를 사용했기 때문에 화살표 함수 본문에 있는 this.title
은 화살표 함수 바깥에 있는 메서드인 showList
가 가리키는 대상과 동일해진다. 즉 this.title
은 group.title
과 같다.
위 예시에서 화살표 함수 대신 일반 함수를 사용했다면 에러가 발생했을 것이다.
let group = {
title: '1모둠',
students: ['보라', '호진', '지민'],
showList() {
this.students.forEach(function (student) {
// TypeError: Cannot read property 'title' of undefined
alert(this.title + ': ' + student);
});
},
};
group.showList();
이는 forEach
에 전달되는 함수의 this
가 undefined
이기 때문에 발생한다. alert
함수에서 undefined.title
에 접근하려 했기 때문에 에러가 발생한다.
반면, 화살표 함수에는 this
라는 개념 자체가 없기 때문에 이런 에러가 발생하지 않는다.
또한, this
가 없기 때문에 new
와 함께 사용할 수 없기도 하다.
화살표 함수에는 arguments
가 없다.
화살표 함수는 일반 함수와 다르게 모든 인수에 접근할 수 있게 해주는 유사 배열(Array) 객체 arguments
를 지원하지 않는다.
이런 특징은 현재 this
값과 arguments
정보를 함께 실어 호출을 포워딩해주는 데코레이터를 만들 때 유용하게 사용된다.
function defer(f, ms) {
return function () {
setTimeout(() => f.apply(this, arguments), ms);
};
}
function sayHi(who) {
alert('안녕, ' + who);
}
let sayHiDeferred = defer(sayHi, 2000);
sayHiDeferred('철수'); // 2초 후 "안녕, 철수"가 출력됩니다.
이것을 화살표 함수 없이 구현하려 했다면 아래와 같이 가독성이 떨어지는 형태가 된다.
function defer(f, ms) {
return function (...args) {
let ctx = this;
setTimeout(function () {
return f.apply(ctx, args);
}, ms);
};
}
함수 바인딩
setTimeout
에 메서드를 전달할 때처럼, 객체 메서드를 콜백으로 전달할 때는 this
가 사라지는 문제가 생긴다.
사라진 this
앞서 다양한 예제를 통해 this
정보가 사라지는 문제를 경험했다. 객체 메서드가 객체 내부가 아닌 다른 곳에 전달되어 호출되면 this
가 사라진다.
대표적인 예는 setTimeout
등에서 콜백함수로 넘겨지는 경우에 발생하는 것이다.
let user = {
firstName: 'John',
sayHi() {
alert(`Hello, {this.firstName}!`);
},
};
setTimeout(user.sayHi, 1000); // Hello, undefined!
이런 문제는 콜백함수를 전달 할 때, 객체에서 메서드가 분리(user.sayHi
)되어 하나의 함수로써 전달되기 때문이다.
해결 1 : 래퍼(Wrapper) 함수
가장 간단한 해결책은 래퍼 함수를 사용하는 것이다.
let user = {
firstName: 'John',
sayHi() {
alert(`Hello, {this.firstName}!`);
},
};
setTimeout(function () {
user.sayHi(); // Hello, John!
}, 1000);
위 예시가 의도대로 동작하는 이유는, 외부 렉시컬 환경에서 user
를 받아 보통 때와 똑같이 메서드를 호출하기 때문이다.
단, 이 경우 약간의 취약성이 생기는데, setTimeout
이 트리거 되기 전, user
에 변경이 가해지면, 변경된 상태의 객체 메서드를 호출한다는 점이다.
let user = {
firstName: 'John',
sayHi() {
alert(`Hello, {this.firstName}!`);
},
};
setTimeout(() => user.sayHi(), 1000);
// 1초가 지나기 전에 user의 값이 바뀜
user = {
sayHi() {
alert('또 다른 사용자!');
},
};
// setTimeout에 또 다른 사용자!
이런 문제는 두 번째 방법을 사용함으로써 방지할 수 있다.
방법 2 : bind
모든 함수는 this
를 수정하게 해주는 내장 메서드 bind
를 제공한다.
기본 문법은 다음과 같다.
// 더 복잡한 문법은 뒤에 나옵니다.
let boundFunc = func.bind(context);
func.bind(context)
는 함수처럼 호출 가능한 '특수 객체(exotic object)'를 반환한다. 이 객체를 호출하면 this
가 context
로 고정된 함수 func
이 반환된다.
이제 boundFunc
를 호출하면 this
가 고정된 func
를 호출하는 것과 동일한 효과를 본다.
아래 funcUser
에는 this
가 user
로 고정된 func
이 할당된다.
이제 객체 메서드에 bind
를 적용해보자.
let user = {
firstName: 'John',
sayHi() {
alert(`Hello, {this.firstName}!`);
},
};
let sayHi = user.sayHi.bind(user); // (*)
// 이제 객체 없이도 객체 메서드를 호출할 수 있습니다.
sayHi(); // Hello, John!
setTimeout(sayHi, 1000); // Hello, John!
// 1초 이내에 user 값이 변화해도
// sayHi는 기존 값을 사용합니다.
user = {
sayHi() {
alert('또 다른 사용자!');
},
};
부분 적용 (Partial Application)
지금껏 this
에 대해서만 이야기했지만, this
뿐만 아니라 인수에 대해서도 바인딩이 가능하다. 이는 자주 쓰이진 않지만 가끔 유용하다.
```bind`의 전체 문법은 다음과 같다.
let bound = func.bind(context, [arg1], [arg2], ...);
bind
는 컨텍스트를 this
로 고정하는 것 뿐만 아니라 함수의 인수도 고정해준다.
곱셈을 해주는 함수 mul(a, b)
를 예시로 들어보고, bind
를 통해 새로운 함수 double
을 만들어본다.
function mul(a, b) {
return a * b;
}
let double = mul.bind(null, 2);
alert(double(3)); // = mul(2, 3) = 6
alert(double(4)); // = mul(2, 4) = 8
alert(double(5)); // = mul(2, 5) = 10
이처럼, context
는 따로 넘겨주지 않고 인수에 대해서만 값을 전달해 고정해주는 방식을 **부분 적용(Partial Application)**이라고 한다.
이는 매우 포괄적인 함수를 기반으로 덜 포괄적인 변형 함수를 만들어 낼 수 있다는 점에서 유용하다.
call, apply, decorator, call forwarding
데코레이터
데코레이터(Decorator)는 특정 함수를 인수로 받아, 반환하는 값 자체는 동일하지만 그 외의 로직을 추가적으로 커스텀하는 함수를 의미한다.
로직을 함수와 함수로 분리시킬 수 있기 때문에 한결 보기 편하다는 장점이 있다.
function slow(x) {
// CPU 집약적인 작업이 여기에 올 수 있습니다.
alert(`slow({x})을/를 호출함`);
return x;
}
function cachingDecorator(func) {
let cache = new Map();
return function (x) {
if (cache.has(x)) {
// cache에 해당 키가 있으면
return cache.get(x); // 대응하는 값을 cache에서 읽어옵니다.
}
let result = func(x); // 그렇지 않은 경우엔 func를 호출하고,
cache.set(x, result); // 그 결과를 캐싱(저장)합니다.
return result;
};
}
slow = cachingDecorator(slow);
alert(slow(1)); // slow(1)이 저장되었습니다.
alert('다시 호출: ' + slow(1)); // 동일한 결과
alert(slow(2)); // slow(2)가 저장되었습니다.
alert('다시 호출: ' + slow(2)); // 윗줄과 동일한 결과
func.call
로 컨텍스트 지정하기
이러한 데코레이터는, 객체 메서드에서 사용하기에 적합하지 않다는 문제점이 있는데, 메서드가 기존 객체를 가리키던 this
에 대한 컨텍스트를 잃어버리기 때문이다.
// worker.slow에 캐싱 기능을 추가해봅시다.
let worker = {
someMethod() {
return 1;
},
slow(x) {
// CPU 집약적인 작업이라 가정
alert(`slow({x})을/를 호출함`);
return x * this.someMethod(); // (*)
},
};
// 이전과 동일한 코드
function cachingDecorator(func) {
let cache = new Map();
return function (x) {
if (cache.has(x)) {
return cache.get(x);
}
let result = func(x); // (**)
cache.set(x, result);
return result;
};
}
alert(worker.slow(1)); // 기존 메서드는 잘 동작합니다.
worker.slow = cachingDecorator(worker.slow); // 캐싱 데코레이터 적용
alert(worker.slow(2)); // 에러 발생!, Error: Cannot read property 'someMethod' of undefined
func.call
은 이때 사용할 수 있는데, 이는 this
를 명시적으로 고정해 함수를 호출할 수 있게 해주는 내장 함수 메서드다. 기본적으로는 아래와 같은 형태다.
func.call(context, arg1, arg2, ...)
실제로 적용한다면 아래와 같아진다.
func(1, 2, 3);
func.call(obj, 1, 2, 3);
위의 두 실행은 거의 동일하다. 유일한 차이점은 func.call
에서의 this
는 obj
로 고정된다는 점이다.
func.apply도 동일한 역할을 한다.
func.apply
를 사용해도 된다. 이는 아래와 같은 형태다.
func.apply(context, args);
apply
는 func
의 this
를 context
로 고정시켜주고, 유사 배열 객체 args
를 인수로 사용할 수 있게 해준다. call
과의 문법적 차이는 call
이 여러 개의 인수들을 따로따로 받는 대신 apply
는 배열을 인수로 받는 다는 점 뿐이다.
따라서 아래의 두 코드는 거의 같은 역할을 한다.
func.call(context, ...args);
func.apply(context, args);
이렇게 인수 전체를 다른 함수에 전달하는 것을 콜 포워딩(Call Forwarding) 이라고 한다.
Closure와 변수의 유효범위
렉시컬 환경 (Lexical Environment)
렉시컬 환경은 JS가 어떻게 동작하는지 설명하기 위한 이론상의 객체이다. 이를 이해하는 것이 클로저에 대한 명확한 이해를 돕기 때문에, 먼저 이에 대해 익혀보자.
이는 두 부분으로 구성된다.
- 환경 레코드(Environment Record) : 모든 지역 변수를 프로퍼티로 저장하고 있는 객체.
this
값과 같은 기타 정보도 여기에 저장된다. - 외부 렉시컬 환경(Outer Lexical Environment)에 대한 참조(Reference) : 외부 코드와 연관
즉, 변수는 특수 내부 객체인 환경 레코드의 프로퍼티일 뿐이다. 때문에 변수를 가져오거나 변경하는 것은 곧 환경 레코드의 프로퍼티를 가져오거나 변경하는 것을 의미한다.
// phrase: <uninitialized>
let phrase = 'Hello';
// phrase: undefined
phrase = 'Hello';
// phrase: 'Hello'
phrase = 'Bye';
// phrase: 'Bye'
함수는 변수와 마찬가지로 값인데, 다만 함수 선언문(Function Declaration)으로 선언한 함수는 일반 변수와는 달리 바로 초기화된다는 점에서 차이가 있다. 즉, 변수는 선언 전까지 사용할 수 없지만, 함수 선언문으로 선언한 함수는 렉시컬 환경이 만들어지는 즉시 사용할 수 있다. 물론, 함수 표현식의 경우는 변수를 함수에 할당한 것이므로 변수와 동일하게 취급된다.
let phrase = 'Hello';
function say(name) {
alert(`<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="mord mathnormal">h</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">a</span><span class="mord mathnormal">se</span></span><span class="mpunct">,</span></span></span></span>{name}`);
}
함수를 호출해 실행하면 새로운 렉시컬 환경이 자동으로 만들어진다. 이 렉시컬 환경엔 함수 호출 시에 넘겨받은 매개변수와 함수의 지역 변수가 저장된다. 즉, 위에서 say('John')
을 호출하면 함수를 위한 내부 렉시컬 환경과 내부 렉시컬 환경에서 가리키는 외부(여기서는 전역) 렉시컬 환경으로 두개를 갖게 된다.
내부 렉시컬 환경은 외부 렉시컬 환경에 대한 참조를 갖는다.
코드 내에서 변수에 접근하는 경우, 먼저 내부 렉시컬 환경을 검색하며, 여기서 원하는 변수를 찾지 못하는 경우 검색 범위를 확장하여 외부 렉시컬 환경을 참조한다. 이는 검색 범위가 전역 렉시컬 환경에 도달할 때까지 반복된다.
반환함수
자, 이제 아래의 예시를 보자.
function makeCounter() {
let count = 0;
return function () {
return count++;
};
}
let counter = makeCounter();
makeCounter()
를 호출하면 호출할 때마다 새로운 렉시컬 환경 객체가 만들어진다. 그리고 이 렉시컬 환경 객체에는 makeCounter
를 실행하는데 필요한 변수들이 저장된다.
단, 위의 경우는 makeCounter
가 실행되는 도중에 별도의 중첩 함수가 생성되었다. 현재는 생성까지만 하고, 실행은 되지 않았다.
이제 중요한 사실이 하나 더 있다. 모든 함수는 본인이 생성된 곳의 렉시컬 환경을 기억한다는 점이다. 함수는 [[Environment]]
라는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 위치의 렉시컬 환경에 대한 참조가 저장된다.
따라서, 위의 변수 counter
에 저장된 함수의 [[Environment]]
에는 {count: 0}
이 있는 렉시컬 환경에 대한 참조가 저장된다. 이를 통해 어디에서 호출되던 상관없이 자신이 어디에서 생성되었는지를 알 수 있으며, [[Environment]]
는 함수가 생성될 때 처음 딱 한번 그 값이 생성되고, 이는 영원히 변하지 않는다.
counter()
를 호출하면 각 호출마다 새로운 렉시컬 환경이 만들어진다. 그리고 각 렉시컬 환경은 [[Environment]]
에 저장된 렉시컬 환경을 외부 렉시컬 환경으로서 참조하게 되는데, 이는 모두 똑같이 {count}
이 있는 렉시컬 환경이다.
따라서, counter()
를 여러번 호출하게 되면, count
변수가 1, 2, 3...
으로 점차 증가하게 된다.
클로저
클로저(Closure)는 외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미한다.
설명하기 용이하게는, 선언될 당시의 환경을 기억했다가 나중에 호출되었을 때에 기억했던 환경에 따라 실행되는 함수가 되겠다.
JS에서는 모든 함수가 자연스럽게 클로저가 되는데, JS에서의 함수는 숨김 프로퍼티인 [[Environment]]
를 통해서 본인이 어디서 생성되었는지를 기억하고 있으며, 함수 내부의 코드는 이 [[Environment]]
를 향해 확장하며 외부 변수에 접근하기 때문이다.
가비지 컬렉션
함수의 호출이 끝나면, 렉시컬 환경은 메모리에서 제거되며, 함수와 관련된 변수들은 이 시점에서 모두 사라진다.
다시 가비지 컬렉션을 떠올려보자. JS에서의 모든 객체는 도달 가능한 상태라면 메모리에 유지된다.
위와 같은 형태의 중첩 함수를 구현하게 되면, [[Environment]]
프로퍼티에 생성 당시의 외부 함수 렉시컬 환경에 대한 정보가 저장되고, 따라서 도달 가능한 상태가 되는데, 이 때문에 함수 호출 자체가 끝났어도 렉시컬 환경이 메모리에 여전히 유지될 수 있는 것이다.
JS엔진에 의한 최적화
앞서 말했듯, 이론 상으로는 중첩함수를 통해 함수가 살아있는 경우에 모든 외부 변수들 역시 메모리에 유지된다.
그러나 실제로는 JS 엔진(특히 V8)이 이를 지속해서 최적화하게 되는데, 엔진이 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 이를 메모리에서 제거하기 때문이다.
함수 표현식 vs. 함수 선언문 (Function Expression vs. Function Declaration)
1. 문법에서의 차이
다음은 함수 선언문이다.
// 함수 선언문은 독자적인 구문 형태로 존재한다.
function sum(a, b) {
return a + b;
}
그리고 다음은 함수 표현식이다.
// 함수 표현식은 표현식(Expression)이나 구문 구성(Syntax construct) 내부에 생성된다.
let sum = function (a, b) {
return a + b;
};
2. 언제 함수를 생성하는지의 차이
- 함수 표현식은 실제 실행 흐름이 해당 함수에 도달했을 때 함수를 생성한다. 따라서 실행 흐름이 함수에 도달하고 나서야 해당 함수를 사용할 수 있다.
- 함수 선언문은 선언문이 정의되기 전에도 호출할 수 있다.
3. 스코프에서의 차이
strict
모드에서 함수 선언문이 코드 블록 내에 위치하면 해당 함수는 블록 내 어디서든 접근할 수 있다. 하지만 블록 밖에서는 함수에 접근하지 못한다.
그래서 둘 중에 무엇을 써야하는가???
일반적으로는 함수 선언문을 통해 함수를 만드는 것이 먼저 고려된다. 다음과 같은 장점이 있다.
- 함수가 선언되기 이전에 호출할 수 있어 코드 구성을 좀 더 자유롭게 할 수 있다.
- 가독성 측면에서 조금 더 유리하다.
하지만 무조건적인 정답은 아니므로, 경우에 따라 필요한 경우 함수 표현식을 사용해야 한다.
JS의 비동기성에 관하여
JS는 싱글 스레드 언어입니다. 이는 다시 말해, JS는 하나의 콜 스택만을 활용하기 때문에, 한번에 하나의 코드만을 실행시킬 수 있다는 뜻입니다.
실제로, JS 엔진은 어떤 작업을 수행하고 있는 중에는 렌더링이나 새로운 이벤트에 대한 핸들링이 즉각적으로 일어나지 않습니다.
근데 이상하죠? 우리는 이미 JS의 콜백 함수나, 프라미스, async를 통해 비동기 함수들을 다루어왔는데 말이죠.
이것이 가능한 이유는 브라우저가 단순한 JS 런타임 그 이상을 갖추고 있기 때문입니다. JS 런타임은 실제로 싱글 스레드 언어이지만, 브라우저가 Web API와 같은 것들을 제공합니다. 이들은 JS에서 호출할 수 있는 스레드를 효과적으로 지원합니다.

Web API
Web API는 브라우저가 제공하는 JS 런타임 내 별도의 API입니다.
여기에는 setTimeout
이나, AJAX를 활용하는 fetch
등이 포함됩니다.
Web API에서 제공하는 이들 함수를 활용할 때, JS 엔진은, 해당 함수의 호출이 일어나면, Web API가 내부적으로 이들을 처리하도록 맡기고, 계속해서 코드를 진행해나갑니다.
태스크 큐
이후, Web API는 해당 함수를 처리하고, (예를 들어, setTimeout
을 실행하면, 정해놓은 시간이 지날때까지 기다리고, AJAX의 경우에는 적절한 응답을 받을때까지 기다립니다.) 이후 해당 함수에 전달했던 콜백을 태스크 큐로 넘깁니다.
이벤트 루프
이제 이벤트 루프가 활약할 차례입니다. 사실 이벤트 루프가 하는 일은 굉장히 간단합니다. JS 엔진의 콜 스택이 빌 때까지 기다렸다가, 비고 나면, 태스크 큐의 태스크들을 먼저 들어온 순서대로 콜 스택에 넘깁니다.
결국 여기에는 "콜 스택이 빌때까지" 기다리는 시간도 포함되기 때문에, setTimeout(cb, 1000)
은 결코 해당 cb
가 정확히 1초 이후에 실행된다는 것을 의미하지 않습니다.
매크로태스크와 마이크로태스크

기본적으로 setTimeout
, setInterval
, 그리고 이벤트 핸들러 등의 함수들은 매크로태스크에 해당됩니다.
반면, 마이크로태스크는 우리가 종종 사용하는 프로미스와 같은 것들이 해당합니다.
마이크로태스크와 매크로태스크의 차이는 해당 태스크들의 실행 시점에서 발생한다고 할 수 있습니다.
브라우저는, 마이크로태스크 -> 렌더링 -> 매크로태스크의 순서로 실행되며, 마이크로태스크는 결국, 브라우저의 렌더링이나 이벤트 핸들러의 처리 이전에 여러 마이크로태스크들이 실행되는 것을 보장합니다.
이것이 중요한 이유는, 마이크로태스크들이 모두 동일한 환경 내에서 처리되도록 보장하기 위해서입니다. 이를테면, 마우스 클릭 등의 다른 이벤트 핸들링에 의해서 마이크로태스크들을 처리하던 와중에 데이터의 변경이 일어나면, 여러 마이크로태스크들이 제각기 다른 환경에서 실행될 가능성이 있기 때문입니다.
이벤트 루프의 활용
이러한 이벤트 루프의 동작 방식을 실제 업무에서는 어떻게 활용할 수 있을까요?
1. 무거운 작업을 쪼개서 수행
let i = 0;
let start = Date.now();
function count() {
// 스케줄링 코드를 함수 앞부분으로 옮김
if (i < 1e9 - 1e6) {
setTimeout(count); // 새로운 호출을 스케줄링함
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert('처리에 걸린 시간: ' + (Date.now() - start) + 'ms');
}
}
count();
기본적으로, JS 엔진은 싱글 스레드 기반이기 때문에, 동기적인 방식으로 동작하는 함수가 처리에 너무 오랜 시간이 걸린다면 그 동안에 새로운 이벤트나 렌더링 자체를 막아버리면서 유저와의 상호 작용을 무시하는 문제가 발생합니다.
이 경우, 이러한 작업들을 setTimeout
등을 통해 여러 태스크들로 쪼개서 처리한다면, 그 동안에 처리해야 할 이벤트 핸들링 및 렌더링을 막지 않으면서 거대한 작업을 수행할 수 있습니다.
이를 통해서 게임 로딩 등에서 활용되는 프로그레스 바와 같은 것도 구현할 수 있습니다.
2. 이벤트 처리가 끝난 이후에 작업하기
menu.onclick = function () {
// ...
// 클릭한 메뉴 내 항목 정보가 담긴 커스텀 이벤트 생성
let customEvent = new CustomEvent('menu-open', {
bubbles: true,
});
// 비동기로 커스텀 이벤트를 디스패칭
setTimeout(() => menu.dispatchEvent(customEvent));
};
이벤트 핸들러 내에 이벤트 버블링이 끝난 이후에 작동해야만 하는 액션이 존재하는 경우, 이를 setTimout(cb, 0)
과 같이 콜백함수로 넘길 수 있습니다. 이 경우, 해당 이벤트의 버블링이 모두 완수된 이후에야 특정 콜백을 실행할 수 있게끔 할 수 있습니다.
Web Worker

setTimout
을 통해 여러 개의 태스크로 쪼개지 않더라도, 이벤트 루프를 막지 않아야 하는 거대한 작업의 경우에는 Web Worker를 사용할 수 있습니다.
이는 브라우저가 별도의 쓰레드를 통해, 백그라운드 상에서 코드를 실행할 수 있게끔 하는 Web API 스펙입니다.
Web Worker는 메인 쓰레드와 메시지를 교환하는 방식으로 소통할 수 있지만, 자신만의 변수와 이벤트 루프를 갖습니다.
또한, Web Worker는 DOM에 접근할 방법이 없기 때문에, 주로 여러 CPU 코어를 동시에 활용해야 하는, 계산적으로 버거운 작업을 처리해야 할때 유용합니다.
new와 생성자 함수
1. 생성자 함수
생성자 함수 (Contructor function)와 일반 함수 사이의 기술적인 차이는 없다. 다만 생성자 함수는 아래의 두 관례를 따른다.
- 함수 이름 첫 글자는 대문자
- 반드시
new
연산자를 붙여 사용한다.
function User(name) {
this.name = name;
this.isAdmin = false;
}
let user = new User('Jack');
위와 같은 관례를 따라 new User(...)
를 써서 함수를 실행하면 아래와 같은 알고리즘이 동작한다.
- 빈 객체를 만들어
this
에 할당한다. - 함수 본문을 실행하고
this
에 새로운 프로퍼티를 추가해this
를 수정한다. this
를 반환한다.
즉, 내부적으로는 아래와 같은 일이 동작하는 것이다.
function User(name) {
// this = {}; (빈 객체가 암시적으로 만들어짐)
// 새로운 프로퍼티를 this에 추가함
this.name = name;
this.isAdmin = false;
// return this; (this가 암시적으로 반환됨)
}
결국 생성자의 의의는 재사용할 수 있는 객체 생성 코드를 구현하기 위한 것이다.
모든 함수는 생성자 함수가 될 수 있다. new
를 붙여 실행한다면 어떤 함수라도 위와 같은 일이 벌어진다. 첫 글자가 대문자인 함수는 new
를 붙여 실행하는 것이 일종의 관례라는 점도 기억하자.
new function() {...}
생성자를 이용해 함수에 캡슐화를 적용할 수도 있다.
let user = new (function () {
this.name = 'John';
this.isAdmin = false;
// ...
})();
위의 생성자 함수는 익명함수이기 때문에, 어디에도 저장되지 않으며, 단 한번만 호출될 목적으로 만들어져 재사용이 불가능하다.
()이 없어도 된다.
다만 좋은 코드 스타일은 아니다.
let user = new User(); // <-- 괄호가 없음
// 아래 코드는 위 코드와 똑같이 동작합니다.
let user = new User();
2. new Function()
함수 표현식과 함수 선언문 이외에 함수를 만들 수 있는 방법이 하나 더 있다.
이는 자주 사용하는 방법은 아니지만, 마땅한 대안이 없을 때 사용될 수 있다.
new Function
을 이용은 다음과 같다.
let func = new Function([arg1, arg2, ...argN], functionBody);
new Function
을 이용하는 방법의 가장 큰 차이는 런타임 시점에 받는 문자열을 사용해 함수를 만들 수 있다는 점이다.
let sum = new Function('a', 'b', 'return a + b');
alert(sum(1, 2)); // 3
클로저와의 미묘한 관계
클로저를 떠올려보자, 반환받은 중첩함수는 [[Environment]]
프로퍼티 덕분에 본인이 생성된 렉시컬 외부 환경을 기억할 수 있었다.
그런데, new Function
을 이용해 함수를 만들게 되면 함수의 [[Environment]]
프로퍼티가 현재의 렉시컬 환경이 아닌 전역 렉시컬 환경을 참조하게 된다.
따라서, new Function
을 통해 만든 함수는 외부 블록의 변수에 접근할 수 없고, 오직 전역 변수에만 접근할 수 있다.
function getFunc() {
let value = 'test';
let func = new Function('alert(value)');
return func;
}
getFunc()(); // ReferenceError: value is not defined
이러한 특징은, 특정 함수 내부에서 이름이 겹치는 변수들을 사용해도 충돌을 하지 않는다는 이점이 있다.
Properties(프로퍼티)
알다시피, 객체에는 프로퍼티가 저장된다. 지금까지는 단순히 'key-value'의 관점에서 보일 수 있었겠지만, 사실 프로퍼티는 생각보다 더 유연하고 강력한 자료구조다.
프로퍼티 플래그를 사용하면 손쉽게 getter
나 setter
함수를 구현할 수 있다.
프로퍼티 플래그 (Property flag)
- 객체 프로퍼티는 값(value) 뿐만 아니라, **플래그(flag)**라 불리는 특별한 속성 세 가지를 갖는다.
writable -
true
라면 값을 수정할 수 있다. 그렇지 않다면 읽기 전용이 된다. enumarable -true
라면 반복문을 통해 나열될 수 있다. 그렇지 않다면 나열되지 않는다. configurable -true
라면 프로퍼티 삭제나 플래그 수정이 가능하다. 그렇지 않다면 프로퍼티 삭제와 플래그 수정이 불가능하다.
프로퍼티 플래그는 특별한 경우가 아니라면 쓰이지 않는다. 평범한 방식으로 프로퍼티를 만들면 해당 프로퍼티 플래그는 모두 true
가 되고, 이렇게 설정된 플래그는 언제든 수정할 수 있다.
먼저 플래그를 얻는 방법에 대해 알아보자.
Object.getOwnPropertyDescriptor
메서드는 특정 프로퍼티에 대한 정보를 모두 얻을 수 있게 해준다.
let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);
해당 메서드를 호출하면 프로퍼티 설명자(descriptor)라고 불리는 객체가 반환되며, 여기에는 프로퍼티 값과 세 플래그에 대한 정보가 모두 담겨있다.
let user = {
name: 'John',
};
let descriptor = Object.getOwnPropertyDescriptor(user, 'name');
alert(JSON.stringify(descriptor, null, 2));
/* property descriptor:
{
"value": "John",
"writable": true,
"enumerable": true,
"configurable": true
}
*/
Object.defineProperty
를 사용하면 플래그를 변경할 수 있다.
Object.defineProperty(obj, propertyName, descriptor);
이는 해당 프로퍼티가 이미 존재한다면, 해당 프로퍼티를 인자로 넘긴 플래그에 따라 변경해주고, 프로퍼티가 없으면 인수로 넘겨받은 정보를 통해 새로운 프로퍼티를 만든다. 플래그 정보가 따로 없는 경우는 자동으로 false
가 된다.
Object.defineProperties
는 앞선 프로퍼티 정의 여러개를 한꺼번에 할 수 있다.
Object.defineProperties(user, {
name: { value: 'John', writable: false },
surname: { value: 'Smith', writable: false },
// ...
});
Object.getOwnPropertyDescriptors
는 프로퍼티 설명자를 전부 한꺼번에 가져올 수 있게 한다. Object.defineProperties
와 함께 사용하면 객체 복사 시 플래그도 함께 복사할 수 있다.
let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj));
접근자 프로퍼티 (Getter & Setter)
객체 프로퍼티는 두 종류로 나뉜다.
- 데이터 프로퍼티(data property) - 지금껏 사용한 모든 프로퍼티는 데이터 프로퍼티다.
- 접근자 프로퍼티(accessor property) - 접근자 프로퍼티의 본질은 함수인데, 이 함수는 값을 획득(get)하고, 설정(set)하는 역할을 담당한다. 그러나 외부 코드에서는 함수가 아닌 일반적인 프로퍼티처럼 보인다.
객체 리터럴 안에서 getter
와 setter
메서드는 get
과 set
으로 나타낼 수 있다.
let obj = {
get propName() {
// getter, obj.propName을 실행할 때 실행되는 코드
},
set propName(value) {
// setter, obj.propName = value를 실행할 때 실행되는 코드
},
};
getter
를 구현하면, 마치 일반 프로퍼티인것처럼 동작한다. 이는 함수처럼 호출하지 않으며, 일반 프로퍼티에 접근하듯 평범히 user.fullName
을 통해 값을 얻어올 수 있다. 실질적으로는 메서드를 호출하는 것이지만.
let user = {
name: 'John',
surname: 'Smith',
get fullName() {
return `<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord"><span class="mord mathnormal">t</span><span class="mord mathnormal">hi</span><span class="mord mathnormal">s</span><span class="mord">.</span><span class="mord mathnormal">nam</span><span class="mord mathnormal">e</span></span></span></span></span>{this.surname}`;
},
};
alert(user.fullName); // John Smith
user.fullName = 'Test'; // Error (프로퍼티에 getter 메서드만 있어서 에러가 발생합니다.)
또한, getter
만 있는 경우는 값을 직접 할당할 수 없어 위와 같은 에러가 발생한다.
여기에 setter
도 추가로 구현한다면 다음과 같아진다.
let user = {
name: 'John',
surname: 'Smith',
get fullName() {
return `<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord"><span class="mord mathnormal">t</span><span class="mord mathnormal">hi</span><span class="mord mathnormal">s</span><span class="mord">.</span><span class="mord mathnormal">nam</span><span class="mord mathnormal">e</span></span></span></span></span>{this.surname}`;
},
set fullName(value) {
[this.name, this.surname] = value.split(' ');
},
};
// 주어진 값을 사용해 set fullName이 실행됩니다.
user.fullName = 'Alice Cooper';
alert(user.name); // Alice
alert(user.surname); // Cooper
getter
와 setter
메서드를 구현하면 객체에는 fullName
이라는 가상 프로피터가 생기며, 이는 읽고 쓸수는 있지만 실제로 존재하진 않는다.
접근자 프로퍼티의 설명자(descriptor)
데이터 프로퍼티의 설명자와 접근자 프로퍼티의 설명자는 다르다.
접근자 프로퍼티에는 value
와 writable
대신에 get
과 set
이 있다.
get – 인수가 없는 함수로, 프로퍼티를 읽을 때 동작함 set – 인수가 하나인 함수로, 프로퍼티에 값을 쓸 때 호출됨 enumerable – 데이터 프로퍼티와 동일함 configurable – 데이터 프로퍼티와 동일함
이는 앞서 defineProperty
메서드 등을 사용할 때도 똑같이 적용된다.
let user = {
name: 'John',
surname: 'Smith',
};
Object.defineProperty(user, 'fullName', {
get() {
return `<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord"><span class="mord mathnormal">t</span><span class="mord mathnormal">hi</span><span class="mord mathnormal">s</span><span class="mord">.</span><span class="mord mathnormal">nam</span><span class="mord mathnormal">e</span></span></span></span></span>{this.surname}`;
},
set(value) {
[this.name, this.surname] = value.split(' ');
},
});
alert(user.fullName); // John Smith
for (let key in user) alert(key); // name, surname
프로퍼티는 접근자 프로퍼티나 데이터 프로퍼티 중 한 종류에만 속하고, 둘 다에 속할 수는 없다는 점을 유의하자.
getter와 setter 똑똑하게 써먹기
getter
와 setter
를 실제 프로퍼티 값을 감싸 래퍼(wrapper)처럼 활용하면 프로퍼티값을 원하는대로 통제할 수 있다.
let user = {
get name() {
return this._name;
},
set name(value) {
if (value.length < 4) {
alert(
'입력하신 값이 너무 짧습니다. 네 글자 이상으로 구성된 이름을 입력하세요.',
);
return;
}
this._name = value;
},
};
user.name = 'Pete';
alert(user.name); // Pete
user.name = ''; // 너무 짧은 이름을 할당하려 함
위에서 user
의 이름은 _name
에 저장되고, 프로퍼티에 접근하는 것은 getter와 setter를 통해 이루어진다. _name
과 같이 밑줄 _
로 시작하는 프로퍼티는 관습 상 외부에서 건드리지 않는다.
프로토타입
1. 프로토타입 상속
개발을 하다보면 기존에 있는 기능을 가져와 확장을 해야하는 경우가 생긴다.
이는 자바스크립트의 고유 기능인 포로토타입 상속(Prototypal Inheritance)를 이용하면 실현할 수 있다.
[[Prototype]]
자바스크립트의 객체는 [[Prototype]]
이라는 숨김 프로퍼티를 갖는다. 이 숨김 프로퍼티는 null
이거나, 다른 객체에 대한 참조가 되는데 (그 외의 자료형은 무시된다.), 이것이 참조하는 대상을 **프로토타입(prototype)**이라고 부른다.
JS는 객체에서 프로퍼티를 찾다가, 해당 프로퍼티가 없으면 자동으로 프로토타입에서 프로퍼티를 찾는다. 프로그래밍에서는 이런 동작 방식을 프로토타입 상속이라 부른다.
[[Prototype]]
프로퍼티는 내부 프로퍼티면서 숨김 프로퍼티지만, 다양한 방법을 사용해 개발자가 값을 설정할 수 있다.
그 중 하나는, __proto__
를 사용해 값을 설정하는 것이다.
참고로,
__proto__
는[[Prototype]]
용 getter / setter라는 점을 이해하자. 요즘에는__proto__
를 직접 쓰는 경우는 드물고,Object.getPrototypeOf
나Object.setPrototypeOf
를 써서 프로토타입을 획득 혹은 설정한다. 왜__proto__
를 요즘은 쓰지 않는지에 대해서는 추후에 다루자.
obj.hasOwnProperty(key)
는 해당 객체의 key
에 해당하는 프로퍼티가 상속받은 것이 아닌, 직접 구현된 프로퍼티일 경우 true
를 반환한다. 프로토타입으로 부터의 프로퍼티인지를 체크하는 역할을 한다고 보면 되겠다.
2. prototype
프로퍼티
new F()
와 같은 생성자 함수를 사용하면 새로운 객체를 만들 수 있다는 것을 앞서 배웠다. 그런데, 이 F.prototype
이 객체라면, new
연산자는 F.prototype
을 사용해 새롭게 생성된 객체의 [[Prototype]]
을 설정한다.
과거엔 프로토타입에 직접 접근할 방법이 없었고, 그나마 믿고 사용할 수 있는 방법이 해당 방법 뿐이었다. 여전히 이 문법이 남아있는 이유다.
여기서 F.prototype
은 그저 일반 프로퍼티라는 점에 주의해야 한다.
let animal = {
eats: true,
};
function Rabbit(name) {
this.name = name;
}
Rabbit.prototype = animal;
let rabbit = new Rabbit('White Rabbit'); // rabbit.__proto__ == animal
alert(rabbit.eats); // true
contructor
프로퍼티
사실, 개발자가 따로 할당하지 않더라도, 모든 함수는 prototype
프로퍼티를 갖는다. 기본 프로퍼티인 prototype
은 constructor
프로퍼티 하나만 있는 객체를 가리키는데, 이 constructor
프로퍼티는 함수 자신을 가리킨다. 이 관계를 코드로 나타내면 다음과 같다.
function Rabbit() {}
/* 기본 prototype
Rabbit.prototype = { constructor: Rabbit };
*/
function Rabbit() {}
// 기본 prototype:
// Rabbit.prototype = { constructor: Rabbit }
let rabbit = new Rabbit(); // {constructor: Rabbit}을 상속받음
alert(rabbit.constructor == Rabbit); // true (프로토타입을 거쳐 접근함)
constructor
프로퍼티를 사용하면, 기존에 있던 객체의 constructor
를 사용해서 새로운 객체를 만들 수 있다.
function Rabbit(name) {
this.name = name;
alert(name);
}
let rabbit = new Rabbit('White Rabbit');
let rabbit2 = new rabbit.constructor('Black Rabbit');
이 constructor
는 객체가 있는데, 이 객체를 만드는데 어떤 생성자가 사용되었는지 알수 없는 경우에 사용된다. 단, 가장 중요한점은 JS가 알맞은 constructor
값을 보장하진 않는다는 점이다. 함수에 기본으로 prototype
값이 설정되지만 그것이 전부다. constructor
에 벌어지는 모든 일은 전적으로 개발자에게 맡겨지며, 만약 함수의 기본 prototype
값을 다른 객체로 바꾼다면 이 객체엔 constructor
가 없어진다.
이를 방지하고 알맞은 constructor
를 유지하기 위해서는 prototype
전체를 덮어쓰지 말고 기본 prototype
에 원하는 프로퍼티를 추가/제거해야 한다. (참조 관계를 끊지 않기 위해서)
function Rabbit() {}
// Rabbit.prototype 전체를 덮어쓰지 말고
// 원하는 프로퍼티는 그냥 추가하세요.
Rabbit.prototype.jumps = true;
// 이렇게 하면 기본 Rabbit.prototype.constructor가 유지됩니다.
수동으로 constructor
프로퍼티를 다시 만들어주는 것도 대안이 된다.
Rabbit.prototype = {
jumps: true,
constructor: Rabbit,
};
// 수동으로 추가해 주었기 때문에 알맞은 constructor가 유지됩니다.
3. 네이티브 프로토타입
prototype
프로퍼티는 JS 내부에서도 광범위하게 사용되는데, 모든 내장 생성자 함수에서 prototype
프로퍼티를 사용한다.
Object.prototype
let obj = {};
alert(obj); // "[object Object]" ?
여기서 "[object Object]"
를 생성하는 코드는 대체 어디에 있을까?? obj
는 비어있는데.
참고로 obj = {}
는 obj = new Object()
를 줄인 것이다. 여기서 Object
는 내장 객체 생성자 함수인데, 이 객체의 prototype
은 toString
을 비롯해 다양한 메서드들이 구현된 거대한 객체를 참조한다. 따라서 obj.toString()
을 호출하면 Object.prototype
에서 해당 메서드를 찾아 가져오게 된다.
let obj = {};
alert(obj.__proto__ === Object.prototype); // true
alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true
단, 이때 Object.prototype
위에는 그 이상의 [[Prototype]]
이 존재하지 않는다는 점을 주의하자.
alert(Object.prototype.__proto__); // null
모든 것은 객체를 상속받는다
Array
, Date
, Function
을 비롯한 내장 객체들 역시 프로토타입에 메서드를 저장해놓는다.
명세서 상에서는 모든 내장 프로토타입의 꼭대기에는 Object.prototype
이 있어야 한다고 규정한다. 이 때문에 모든 것은 객체를 상속받는다는 말을 하기도 한다.
체인 상의 프로토타입에는 중복 메서드가 있을 수도 있는데, 이 경우, 체인 상에서 가까운 메서드를 사용하며, Array
의 경우, Array.prototype
의 메서드가 Object.prototype
의 메서드보다 가깝기 때문에 해당 메서드가 사용된다.
원시값(Primitive Value)
그럼 원시값은요?? 이들을 프로토타입을 통해 다루는 것은 상당히 까다롭다.
문자열과 숫자, 불린은 객체가 아니다. 그런데 이런 원시값들의 프로퍼티에 접근하려고 하면 내장 생성자 String
, Number
, Boolean
을 사용하는 임시 래퍼(Wrapper) 객체가 생성된다. 이 래퍼 객체는 해당 메서드만 제공하고 나면 사라진다.
래퍼 객체는 보이지 않는 곳에서 만들어지고, 엔진에 의해 최적화된다.
참고로 null
과 undefined
에 대응하는 래퍼 객체는 없다. 떄문에 메서드와 프로퍼티는 물론, 당연히 프로토타입도 사용할 수 없다.
네이티브 프로토타입 변경
이런 네이티브 프로토타입을 직접 변경할 수도 있다.
String.prototype.show = function () {
alert(this);
};
'BOOM!'.show(); // BOOM!
다만, 이는 좋은 생각이 아닌데, 기본적으로 네이티브 프로토타입은 전역으로 영향을 미치기 때문이다. 때문에 이런식으로 네이티브 프로토타입을 수정하게 되면 다른 라이브러리의 메서드와 충돌할 가능성이 크다.
네이티브 프로토타입 변경이 허용되는 유일한 경우는 딱 하나인데, 바로 폴리필을 만들 때다.
폴리필은 JS 명세서에는 정의되어 있으나 특정 JS 엔진에서 해당 기능이 구현되지 않았을 경우 만들어 사용한다.
프로토타입에서 빌려오기
네이티브 프로토타입에 구현된 메서드를 빌려와서 사용할 수도 있다.
다음은 객체 obj
에 Array
의 join
메서드를 구현하는 내용이다.
let obj = {
0: 'Hello',
1: 'world!',
length: 2,
};
obj.join = Array.prototype.join;
alert(obj.join(',')); // Hello,world!
모던하게 프로토타입을 다루기
__proto__
는 브라우저를 대상으로 개발한다면 구식의 방법이기에 더는 사용하지 않는다.
이를 대체할 아래의 모던한 메서드들이 있다.
Object.create(proto, [descriptors]) – [[Prototype]]이 proto를 참조하는 빈 객체를 만든다. 이때 프로퍼티 설명자({ value, enumarable, ...})를 추가로 넘길 수 있다. Object.getPrototypeOf(obj) – obj의 [[Prototype]]을 반환한다. Object.setPrototypeOf(obj, proto) – obj의 [[Prototype]]이 proto가 되도록 설정한다.
let animal = {
eats: true,
};
// 프로토타입이 animal인 새로운 객체를 생성합니다.
let rabbit = Object.create(animal);
alert(rabbit.eats); // true
alert(Object.getPrototypeOf(rabbit) === animal); // true
Object.setPrototypeOf(rabbit, {}); // rabbit의 프로토타입을 {}으로 바꿉니다.
앞서 말한것처럼 프로퍼티 설명자를 선택적으로 전달할 수도 있다.
let animal = {
eats: true,
};
let rabbit = Object.create(animal, {
jumps: {
value: true,
},
});
alert(rabbit.jumps); // true
Object.create
를 통해 객체를 효율적으로 (얕게) 복제할 수도 있다.
아래 코드는 obj
의 모든 프로퍼티를 포함한 완벽한 사본을 만든다.
let clone = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj),
);
주의해야 할 점은, 앞선 메서드들로 객체의 [[Prototype]]
을 수정하는데 기술적인 문제는 전혀 없으나, 이는 권장되는 사항이 아니다. 이는 객체 프로퍼티 접근 관련 최적화를 망치기 때문에, JS 엔진의 속도를 매우 느리게 한다. 때문에 [[Prototype]]
은 객체를 처음 생성할 때만 설정하는 것이 일반적이다.
아주 단순한(Very plain) 객체
Object.create()
는 인자의 [[Prototype]]
을 상속받은 객체를 생성한다. 이 때, 상속받는 객체 자체가 없다면 어떻게 될까??
Object.create(null)
은 __proto__
를 상속받지 않는다. 때문에 __proto__
가 키 값이 되어도 일반 데이터 프로퍼티처럼 처리하므로 버그가 발생하지 않는다.
이런 객체는 아주 단순한(Very plain), 혹은 순수 사전식(Pure dictionary) 객체라고 부른다. 일반 객체 {...}
보다도 훨씬 단순하기 때문이다.
단, 이 단순한 객체는 프로토타입 자체가 없기 때문에 내장 메서드조차 없다.
let obj = Object.create(null);
alert(obj); // Error: Cannot convert object to primitive value (toString이 없음)
this와 메서드
what is this
?
JS에서 this
는 이따금씩 개발자를 헷갈리게 만드는 존재다. 이는 쓰이는 상황에 따라 각각 다른 것을 가리키게 되는데, 일반적으로는 다음과 같은 상황들이 있다.
1. 메서드에서
메서드 내부에서 사용하는 this
는 해당 메서드가 선언된 객체를 가리킨다.
let user = {
name: 'John',
age: 30,
sayHi() {
alert(this.name);
},
};
user.sayHi(); // John
2. 일반 함수에서
다른 언어와 달리 JS는 모든 함수에서 this
를 사용할 수 있는데, 이 경우는 런타임 시점에 this
가 가리키는 것이 결정된다. 즉, 컨텍스트에 따라 달라진다.
// 같은 함수라도 다른 객체에서 호출한다면 `this`가 달라진다.
let user = { name: 'John' };
let admin = { name: 'Admin' };
function sayHi() {
alert(this.name);
}
// 별개의 객체에서 동일한 함수를 사용함
user.f = sayHi;
admin.f = sayHi;
// 'this'는 '점(.) 앞의' 객체를 참조하기 때문에
// this 값이 달라짐
user.f(); // John (this == user)
admin.f(); // Admin (this == admin)
admin['f'](); // Admin (점과 대괄호는 동일하게 동작함)
3. 화살표 함수에서
화살표 함수는 일반 함수와 달리 고유한 this
를 가지지 않는다. 화살표에서 this
를 사용하면 외부 컨텍스트를 통해 this
를 가져온다. 때문에 별도로 this
를 만들기는 원치 않은 반면, 외부 컨텍스트의 this
를 이용하고자 하는 경우는 화살표 함수를 이용하면 된다.
let user = {
firstName: '보라',
sayHi() {
let arrow = () => alert(this.firstName);
arrow();
},
};
user.sayHi(); // 보라
var
를 쓰지 않는 이유
var
에는 블록 스코프가 없다.
var
로 선언한 변수의 스코프는 function-scoped
혹은 global-scoped
다. 블록을 기준으로 스코프가 생기지 않기 때문에, 혼동을 일으키기 매우 좋다. 아래는 그 덕분에 작성가능한 괴상망측한 코드다.
for (var i = 0; i < 10; i++) {
// ...
}
alert(i);
코드 블럭이 함수 안에 있다면, var
는 함수 스코프만 적용된 변수가 된다.
function sayHi() {
if (true) {
var phrase = 'Hello';
}
alert(phrase); // 제대로 출력됩니다.
}
sayHi();
alert(phrase); // Error
var
는 재선언이 되는 척을 한다
아래는 실행 상 아무 문제가 없다. 다만 중간의 John
값으로 user
를 다시 선언/할당한 내용은 무시된다.
var
는 선언도 하기 전에 사용할 수 있다. (호이스팅: Hoisting)
var
선언은 함수가 시작될 때 처리된다. 전역에서 선언한 변수라면 스크립트가 시작될 때 처리된다.
때문에, var
로 변수를 선언한다면, 그 위치랑 상관없이 함수 본문이 시작되는 지점에서 정의가 된다.
이렇게 var
로 인하여 변수의 선언이 함수 최상위로 끌어올려지는 현상을 호이스팅(Hoisting)이라고 한다.
아래 코드에서 if
블럭 안의 코드는 절대 실행되지 않겠지만, 이는 호이스팅 자체에 전혀 영향을 주지 않기 때문에, 에러가 발생하지 않는다.
function sayHi() {
phrase = 'Hello'; // (*)
if (false) {
var phrase;
}
alert(phrase);
}
sayHi();
다만, 선언만 호이스팅되고 할당은 호이스팅 되지 않는다.
function sayHi() {
alert(phrase);
var phrase = 'Hello';
}
sayHi();
위 예시는 var
를 이용했기 때문에 사실상 아래와 같이 실행된다.
function sayHi() {
var phrase; // 선언은 함수 시작 시 처리됩니다.
alert(phrase); // undefined
phrase = 'Hello'; // 할당은 실행 흐름이 해당 코드에 도달했을 때 처리됩니다.
}
sayHi();
IIFE(즉시 실행 함수 표현식) : var
가 남긴 폐해의 잔재
과거에는 var
만 쓸 수 있었고, 이를 쓰기 위해서 과거의 개발자들은 블록 레벨 스코프를 구현하기 위해 여러 방안을 고려했다. 그 결과 만들어진 것이 IIFE(Immediately Invoked Function Expressions)다.
요즘에는 쓰지 않으나, 오래된 스크립트에서 만나볼 수 있다.
(function () {
let message = 'Hello';
alert(message); // Hello
})();
결론
결국, 두가지 끔찍한 이유 때문에 var
를 사용하지 않는다.
var
는 함수 스코프를 갖는다.var
는 호이스팅을 유발한다.
공식 문서를 재구성한 내용입니다.
Babel이 뭔가요?
Babel은 JS 컴파일러다. 이는 주로 ECMAScript6+ 코드를 이전 버전의 JS로 변환하여 구형 브라우저 및 환경에서 동작하도록 해준다. 아래는 Babel이 해주는 주된 역할이다.
- Syntax 변환
- Target 환경에 존재하지 않는 기능에 대한 폴리필 추가 (
@babel/polyfill
) - 소스 코드 변경 (codemods)
- 그 외 등등...
// Babel Input: ES2015 arrow function
[1, 2, 3].map((n) => n + 1);
// Babel Output: ES5 equivalent
[1, 2, 3].map(function (n) {
return n + 1;
});
어떻게 쓸 수 있을까?
ES6+ 사양의 프로젝트
Babel은 최신 버전의 JS 사용을 구문 변환을 통해 지원해준다. Babel이 제공하는 플러그인들을 통해, 브라우저가 지원하지 않는 사양의 문법까지 사용할 수 있도록 한다.
JSX와 React
Babel은 마찬가지로 JSX 구문도 변환할 수 있다.
npm install --save-dev @babel/preset-react
Type Annotations (Flow & TypeScript)
Babel은 타입 주석(Type Annotation)을 제거할 수 있다. 사실, Babel 자체는 타입체킹을 수행하지 않으며, 타입 체킹을 위해서는 별도로 Flow나 TypeScript를 사용해야 함을 명심하자.
npm install --save-dev @babel/preset-flow
npm install --save-dev @babel/preset-typescript
Pluggable
Babel은 여러 플러그인들로 구성되어 있다. 존재하는 플러그인 혹은 본인이 직접 작성한 플러그인을 통해 자신만의 구문 변환 파이프라인을 구성할 수 있다. 더 쉽게는 preset을 만들거나 사용해서 일련의 플러그인들을 사용해도 된다.
// 사실, 플러그인은 그냥 함수일 뿐이다.
export default function ({types: t}) {
return {
visitor: {
Identifier(path) {
let name = path.node.name; // reverse the name: JavaScript -> tpircSavaJ
path.node.name = name.split('').reverse().join('');
}
}
};
}
Debuggable
Souce map은 컴파일된 코드를 쉽게 디버깅할 수 있도록 도와준다.
Spec Compliant (규격 준수)
Babel은 가능한 ECMAScript 표준을 준수하려고 한다. 성능이 좀 떨어지더라도 표준 준수를 위해 더 구체적인 옵션을 가질 수 있다.
Compact (압축)
Babel은 용량이 큰 런타임에 의존하지 않고 최대한 작은 양의 코드를 사용하려고 한다.
이는 상황에 따라 이루어지기 어려울 수도 있으며, 가독성, 파일 크기, 속도에 대한 규격을 준수하도록 하는 구체적인 변환에 대한 "느슨한" 옵션들이 존재한다.
Usage Guide
일반적으로 Babel을 사용하는 케이스처럼, ES2015+ 문법들을 현재 브라우저에 적합한 사양으로 변환하고자 한다.
이러한 작업은 문법을 새로운 형태로 작성하고, 없는 기능에 대한 폴리필을 추가함으로써 이루어질 수 있다.
이에 대한 전반적인 과정은 아래와 같다.
일단 훑어보기
- 패키지 인스톨
npm install --save-dev @babel/core @babel/cli @babel/preset-env
npm install --save @babel/polyfill
- 프로젝트 루트에
babel.config.json
(v7.8.0
이상 요구) config 파일 생성
{
"presets": [
[
"@babel/env",
{
"targets": {
"edge": "17",
"firefox": "60",
"chrome": "67",
"safari": "11.1"
},
"useBuiltIns": "usage",
"corejs": "3.6.5"
}
]
]
}
위의 설정은 하나의 예시일 뿐이고, 본인이 지원하고자 하는 브라우저 스펙에 따라 이를 적절히 설정해주어야 한다.
여기에서 @babel/preset-env
가 보유한 옵션들을 확인하자.
v7.8.0
미만 버전의 Babel에서는 대신에 babel.config.js
를 사용할 수 있다.
const presets = [
[
'@babel/env',
{
targets: {
edge: '17',
firefox: '60',
chrome: '67',
safari: '11.1',
},
useBuiltIns: 'usage',
corejs: '3.6.4',
},
],
];
module.exports = { presets };
- 이후
src
디렉토리에 있는 모든 코드에 대해 컴파일링을 수행하여lib
에 작성하고자 하는 경우 아래와 같이 cli를 이용할 수 있다.
./node_modules/.bin/babel src --out-dir lib
npm@5.2.0 이후부터 ./node_modules/.bin/babel
은 npx babel
로 대체될 수 있다.
CLI 기초
모든 Babel모듈들은 @babel
이라는 이름 하에 여러 개의 npm 패키지들로 나누어져 있다. (v7 이후)
이렇게 각각의 모듈로 디자인되어 있는 덕분에에, 각각의 상황에 적절하게 사용할 수 있다.
Core Library
Babel의 핵심 기능들은 @babel/core
모듈에 위치해 있다.
npm install --save-dev @babel/core
이를 직접 JS 상에서 사용할 수 있다.
const babel = require('@babel/core');
babel.transformSync('code', optionsObject);
CLI 툴
@babel/cli
는 터미널을 통해서 babel을 사용할 수 있게 해주는 툴이다. 아래와 같이 설치한다.
npm install --save-dev @babel/core @babel/cli
그리고 아래와 같이 사용한다.
./node_modules/.bin/babel src --out-dir lib
위 명령어는 src
디렉토리 내에 위치한 모든 JS 파일들을 파싱하여 지정된 모든 변환 작업들을 수행한다. 이후 각각의 파일들은 lib
디렉토리에 위치한다.
현재까지는 아무런 변환 작업을 지정해주지 않았기 때문에, 출력된 코드는 입력과 동일하게 될 것이다.
CLI 툴이 어떤 옵션들을 보유하고 있는지에 대해 알고 싶다면 --help
를 이용하자.
Plugins & Presets
Babel에서의 모든 변환들은 Plugin(이하 플러그인)을 통해서 이루어집니다. 이는 하나의 작은 JS 프로그램인데, Babel에게 코드를 어떤 식으로 변환해야 하는지 지시해주는 역할을 한다.
심지어 플러그인은 본인이 직접 작성할 수도 있다.
@babel/plugin-transform-arrow-functions
플러그인을 적용하는 간단한 예시를 확인해보자.
npm install --save-dev @babel/plugin-transform-arrow-functions
./node_modules/.bin/babel src --out-dir lib --plugins=@babel/plugin-transform-arrow-functions
이후, 변환된 코드는 아래와 같아진다.
const fn = () => 1;
// converted to
var fn = function fn() {
return 1;
};
기본적으로는 이렇게 하나의 플러그인을 통해 변환을 수행할 수 있지만, 이렇게 플러그인을 하나하나씩 추가하는 것은 다소 귀찮아 보인다.
이런 경우에 사용할 수 있는 것이 Preset(이하 프리셋)이며, 이는 플러그인의 묶음이라고 이해할 수 있다.
플러그인과 마찬가지로, 이러한 프리셋 역시 자신이 원하는 플러그인들을 임의로 지정해 만들어낼 수 있다.
Babel에서 자주 사용되는 preset으로는 env
가 있다.
npm install --save-dev @babel/preset-env
./node_modules/.bin/babel src --out-dir lib --presets=@babel/env
별 다른 설정을 하지 않더라도, preset-env
는 모던 JS를 지원하기 위한 모든 플러그인들을 추가한다.
물론 프리셋 역시 옵션을 지정할 수 있으며, 이는 CLI 상에서 지정하는 것이 번거롭기에 아래처럼 config 파일을 생성하는 방식을 많이 이용한다.
Plugins & Presets
CLI를 통해 모든 옵션을 지정하기보다는 별도의 설정 파일을 만드는 방식이 종종 사용된다.
본인이 원하는 형태에 따라 작성해야 할 Configuration 파일 형태들이 조금씩 달라질 수 있다.
상세 설정에 관련해서는 여기 문서를 참고하도록 하자.
v7.8.0 이상에서는 일반적으로 babel.config.json
을 생성한다.
{
"presets": [
[
"@babel/env",
{
"targets": {
"edge": "17",
"firefox": "60",
"chrome": "67",
"safari": "11.1"
}
}
]
]
}
이제 env
프리셋은 위에서 우리가 지정한 target 브라우저들에서 지원하지 않는 기능들에 대한 플러그인들만을 사용할 것이다.
이로써 문법 변환에 대해서는 모두 알아봤다.
v7.4.0
미만의 버전에 대해서는 @babel/polyfill
모듈이 별도로 사용되지만, 이들 내용이 core-js/stable
과 regenerator-runtime/runtime
플러그인에 각각 추가되었으므로 별도로 사용할 필요가 없게 되었다.
혹시나 v.7.4.0
미만의 Babel을 사용해야 하는데, polyfill이 필요한 경우에는 여기 문서를 따로 찾아보자.
Intersection Observer
Intersection Observer는 브라우저의 뷰포트와 요소의 교차점을 관찰하여, 요소가 현재 뷰포트 상으로 보이는 상태인지를 체크하는 기능을 제공한다.
대표적인 사용은 무한 스크롤, 레이지 로딩 등이다.
앞선 예처럼, 종종 scroll
이벤트를 대체하는 용도로 사용되는데 큰 이유는 두가지에서다.
-
scroll
이벤트의 경우, 말그대로 스크롤을 하는 내내 이벤트가 발생하기 때문에, 핸들러가 무수하게 많이 호출된다. 이는 결국 불필요한 호출들을 일으키고, 이 때문에 Debouncing, Throttling과 같은 호출 제한 테크닉이 요구되어 왔다. Intersection Observer는 특정 요소가 화면에 보이는 시점에만 한번 이벤트가 동작하기 때문에, 이벤트 호출 빈도를 확실히 줄일 수 있다. -
scroll
이벤트에서는 현재 높이 값을 얻기 위해offsetTop
값을 확인하는데, 이를 얻기 위해선 매번 layout을 새로 그리게 된다. 이를 reflow라고 하며, 해당 과정을 반복함에 따라 렌더링 상의 성능 이슈가 발생할 수 있다.
사용법
기본적으로는 MutationObserver와 얼추 비슷한 듯 다르다.
const observer = new IntersectionObserver(callback, options);
observer.observe(element);
callback
감시 타겟이 등록되거나, 가시성(visibility)에 변화가 생기면, 옵저버는 콜백을 실행한다. 이때 해당 콜백은 2개의 인수(entries
, observer
)를 갖는다.
entries
entries
는 IntersectionObserverEntry
인스턴스의 배열이며, 각각의 인스턴스는 다음 일기 전용 프로퍼티들을 포함한다.
boundingClientRect
: target의 사각형 정보intersectionRect
: target이 보여지는(교차한) 영역의 정보intersectionRatio
: 뷰포트 기준 target영역의 백분율(교차한 영역의 백분율)0.0 ~ 1.0
isIntersecting
: target이 보여진 상태(교차한 상태)에 대한 booleanrootBounds
: 지정 루트 요소의 사각형 정보target
: targettime
: 변경이 발생한 시간 정보
observer
observer
는 콜백을 실행시킨 해당 옵저버 자체다.
options
root
target이 보여지는지를 검사할 때, 기본 설정인 뷰포트 대신 사용할 요소(루트 요소)를 지정한다. target보다 상위 요소여야 하고, 기본값은 null
이다.
rootMargin
바깥의 margin을 이용해 Root 범위를 확장하거나 축소할 수 있다. CSS margin값과 똑같은 형태로 값을 받으며, 반드시 px
혹은 %
의 단위를 입력해줘야 한다.
threshold
옵저버가 콜백을 실행시키려면 target 요소가 어느정도의 가시성(visibility)를 가져야하는지에 대한 설정이다. 0 ~ 1
사이의 Number
배열값을 받는다. 기본값으로는 [0]
이지만, Number
타입의 단일 값으로도 작성할 수 있다. 배열로 값을 받는 경우, 배열 각각의 가시성에 대해서 매번 콜백을 호출한다.
메서드
.observe(element)
target 요소의 감시를 시작한다.
.unobserve(element)
target 요소의 감시를 중지한다. 애초에 감시하던 요소가 아닌 경우 아무 일도 일어나지 않는다.
기본적으로 콜백 실행 시에 두번째 인수로 observer
자체를 가져오므로, 이를 이용해, 한번 콜백을 실행한 후에 감시를 중지하도록 할수도 있다.
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
// ...
observer.unobserve(entry.target);
});
}, options);
.disconnect()
해당 observer
가 감시하고 있는 모든 요소의 감시를 중지한다.
.takeRecords()
이는 MutationObserver에서도 있는 메서드와 비슷한데, 도중에 작동이 중지된 경우에 처리되지 않은 IntersectionObserverEntry 객체 배열을 가져온다.
Mutation Observer
MutationObserver
는 DOM 요소를 감시하다가 변화를 감지하면 콜백을 호출하는 내장 객체이다.
문법
MutationObserver
를 사용하는 것은 간단하다.
먼저, 콜백함수를 인자로 넘기는 옵저버를 만든다.
let observer = new MutationObserver(callback);
그리고, 이를 DOM 노드에 덧붙인다.
observer.observe(node, config);
config
은 boolean 옵션들을 갖고 있는 객체인데, 이는 어떤 종류의 변화에 반응할 것인가를 나타낸다.
childList
-node
본인의 바로 아래 자식 요소에서의 변화subtree
-node
본인의 모든 자손attributes
-node
의 속성attributeFilter
- 속성 이름들이 담긴 배열을 받는다. 오직 여기에 포함된 속성들만 감시한다.characterData
-node.data
(text content)를 감시할지에 대한 boolean
그 밖에 다른 옵션들도 있다.
attributeOldValue
- 만약true
라면, 콜백 함수 호출 시 '변경 전'과 '변경 후'의 값을 모두 넘겨준다.false
라면 변경 후의 값만 넘긴다. (attribute
옵션이 필요하다.)characterDataOldValue
- 만약true
라면,node.data
의 '변경 전'과 '변경 후'의 값을 모두 넘겨준다.false
라면 변경 후의 값만 넘겨준다. (characterData
옵션이 필요하다.)
이후 어떤 변화라도 감지된다면, callback
이 실행된다. 변경된 내용은 MutationRecord
객체의 배열로 첫번째 인자로 넘겨진다. 그리고 옵저버 자체는 두번째 인자가 된다.
MutaitonRecord
객체는 다음과 같은 프로퍼티들을 갖는다.
type
- 뮤테이션 타입이다. 다음 중 하나다.attributes
: 수정된 속성characterData
: 수정된node.data
, 텍스트 노드로 쓰인다.childList
: 추가/삭제된 자식 요소들
target
- 변화가 감지된 곳의 요소addedNodes/removedNodes
- 추가/삭제된 노드들previousSibling/nextSibling
- 추가/삭제된 노드들의 이전/다음 형제 노드attributeName/attributeNamespace
- 변경된 속성의 이름/네임스페이스(XML에서 사용)oldValue
- 속성이나 텍스트가 변경되기 전의 값.attributeOldValue
/characterDataOldValue
이true
여야한다.
다음은 간단한 예시다.
<div contenteditable id="elem">Click and <b>edit</b>, please</div>
<script>
let observer = new MutationObserver((mutationRecords) => {
console.log(mutationRecords); // console.log(the changes)
});
// observe everything except attributes
observer.observe(elem, {
childList: true, // observe direct children
subtree: true, // and lower descendants too
characterDataOldValue: true, // pass old data to callback
});
</script>
그리고 위에서 변화를 감지할 때마다 콜백함수에서 넘겨받는 mutationRecords
는 아래와 같다.
[{
type: "characterData",
oldValue: "edit",
target: <text node>,
// other properties empty
}];
만약, <b>edit</b>
를 한번에 지우는 것과 같이 여러 작업이 동시에 일어나면, mutationRecords
에도 여러 객체가 담긴다.
[{
type: "childList",
target: <div#elem>,
removedNodes: [<b>],
nextSibling: <text node>,
previousSibling: <text node>
// other properties empty
}, {
type: "characterData"
target: <text node>
// ...mutation details depend on how the browser handles such removal
// it may coalesce two adjacent text nodes "edit " and ", please" into one node
// or it may leave them separate text nodes
}];
즉, MutationObserver
는 DOM subtree에 발생하는 어떤 변화든지 대응할 수 있다.
활용 사례
그래서, 언제 이를 활용할 수 있을까?
만약, 서드파티 라이브러리를 사용하는데, 원치않는 광고가 포함되어 있다고 해보자. 이를테면 <div class='ads'>...</ads>
와 같이.
MutationObserver
를 사용하면, DOM에 생겨난 원치 않는 요소를 감지하여 제거할 수 있다.
그 밖에도, 여러가지를 감지하여 동적인 변화를 줄 수 있다. 이를 테면 어떤 요소의 사이즈를 변경한다던가.
아키텍쳐에 활용
MutationObserver
가 구조적인 부분에서 유용하게 쓰이는 상황이 있다.
웹 프로그래밍과 관련한 웹사이트를 만들고자 한다고 하자. 각각의 문서들이 소스 코드 조각들을 담고 있을 것이다.
이때, 이 코드 조각들이 다음과 같은 모양을 띈다고 하자.
...
<pre class="language-javascript"><code>
// here's the code
let hello = "world";
</code></pre>
...
이를 더 가독성이 좋게 하기 위해서, 꾸미고 싶다고 하자. 이 경우 우리는 Prism.js와 같은 문장 하이라이팅 라이브러리를 사용할 수 있다. 이는 Prism.highlightElem(pre)
와 같은 식으로 특정 요소에 대해 하이라이트를 적용해준다.
그래서, 이제 이 메서드를 정확히 언제 사용해야 할까? DOMContentLoaded
이벤트 발생 시에 사용하는 것을 고려해볼 수 있겠다. 이후 각각의 코드 조각들에 대해 다음과 같이 하이라이팅을 적용시킬 수 있다.
document
.querySelectorAll('pre[class*="language"]')
.forEach(Prism.highlightElem);
지금까지는 수월해보인다. 근데, 만약에 서버를 통해 또 다른 코드 조각들을 가져와서 화면에 띄워주어야 한다면, 이는 어떻게 해결할 수 있을까?
let article =
/* fetch new content from server */
(articleElem.innerHTML = article);
let snippets = articleElem.querySelectorAll('pre[class*="language-"]');
snippets.forEach(Prism.highlightElem);
위의 방법처럼, 해당 코드조각을 가져올 때 마다 다시 각각의 코드조각들에 대해 하이라이팅을 적용시켜줄 수 있다. 근데 이는 다소 비효율적인데, 코드 조각들이 있을만한 모든 요소들에 대해 위와 같은 코드를 추가해야 하기 때문이다.
결국, 이 역시 MutationObserver
를 통해 페이지 내에 삽입되는 코드 조각들을 감지하여 처리할 수 있다.
let observer = new MutationObserver((mutations) => {
for (let mutation of mutations) {
// examine new nodes, is there anything to highlight?
for (let node of mutation.addedNodes) {
// we track only elements, skip other nodes (e.g. text nodes)
if (!(node instanceof HTMLElement)) continue;
// check the inserted element for being a code snippet
if (node.matches('pre[class*="language-"]')) {
Prism.highlightElement(node);
}
// or maybe there's a code snippet somewhere in its subtree?
for (let elem of node.querySelectorAll('pre[class*="language-"]')) {
Prism.highlightElement(elem);
}
}
}
});
let demoElem = document.getElementById('highlight-demo');
observer.observe(demoElem, { childList: true, subtree: true });
추가적인 메서드
노드를 감시하는 것을 멈추는 메서드가 있다.
observer.disconnect()
- 감시를 멈춘다.
감시가 멈출 때, 해당 옵저버가 특정 작업을 처리하던 중이었을 수도 있다. 이런 경우에는 아래 메서드를 통해 확인할 수 있다.
observer.takeRecords()
- 처리되지 않은MutationRecord
배열들을 가져온다. (변경은 감지했으나, 콜백이 호출되지 않은 경우를 말한다.)
이 메서드들은 함께 쓰일 수 있다.
// get a list of unprocessed mutations
// should be called before disconnecting,
// if you care about possibly unhandled recent mutations
let mutationRecords = observer.takeRecords();
// stop tracking changes
observer.disconnect();
...
Template Element
내장 <template>
요소는 HTML 마크업 템플릿에 대한 저장요소로 취급된다. 브라우저는 이를 무시하고, 오로지 문법 상 온전한지만을 체크하는데, 우리가 우너한다면, 이를 이용해서 다른 요소들을 만들어낼 수도 있다.
사실, 우리는 이미 이론 상 보이지 않는 요소들을 어디서든 만들어 낼 수 있다. 대체 이 <template>
가 특별한 점은 무엇일까?
먼저, 이들의 내용(content)는 문법상 올바른 HTML이면 무엇이든 가능하다. 다시 말해, 적절히 태그만 열고닫았으면 문제가 없다.
무슨 소리냐고? 단순히 우리가 <tr>
과 <td>
만을 이용해서 테이블을 만들면, 브라우저는 부적절한 DOM 구조를 감지하고, 알아서 <table>
을 추가해 DOM 구조를 수정해준다. 반면 <template>
의 경우는 우리가 작성한 내용 그대로를 유지시켜준다.
<template>
<tr>
<td>Contents</td>
</tr>
</template>
마찬가지로, <template>
에는 스타일과 스크립트 태그가 포함될 수 있다.
<template>
<style>
p { font-weight: bold; }
</style>
<script>
alert("Hello");
</script>
</template>
브라우저는 <template>
의 내용들을 문서와 상관없는 것으로 간주한다. 때문에 스타일은 적용되지 않고, 스크립트 역시 마찬가지다.
템플릿 삽입하기
템플릿의 내용은 content
프로퍼티를 통해 이용할 수 있는데, 이는 DocumentFragment라는 특수한 DOM 노드 타입이다.
이는 다른 DOM 노드들과 거의 동일하게 다룰 수 있으나, 유일한 차이점은, 어딘가에 삽입되는 경우, 노드 본인이 아닌 자식들이 대신 삽입된다는 점이다.
<template id="tmpl">
<script>
alert("Hello");
</script>
<div class="message">Hello, world!</div>
</template>
<script>
let elem = document.createElement('div');
// 재사용을 위해 템플릿 컨텐츠를 복사하여 사용한다.
elem.append(tmpl.content.cloneNode(true));
document.body.append(elem);
// append에 의해 body에 요소가 추가되고 나서야 위의 script가 동작한다.
</script>
Shadow DOM과 함께 사용해보자.
<template id="tmpl">
<style> p { font-weight: bold; } </style>
<p id="message"></p>
</template>
<div id="elem">Click me</div>
<script>
elem.onclick = function() {
elem.attachShadow({mode: 'open'});
elem.shadowRoot.append(tmpl.content.cloneNode(true)); // (*)
elem.shadowRoot.getElementById('message').innerHTML = "Hello from the shadows!";
};
</script>
Shadow DOM
섀도우 DOM은 캡슐화를 위해 제공된다. 이는 하나의 컴포넌트가 스스로의 shadow DOM tree를 가질 수 있게 하며, 메인 문서에서 접근 할 수 없으며, 로컬 스타일 규칙을 보유할 수 있다.
빌트인 섀도우 DOM
<input type="range">
과 같은 것들은 브라우저마다 다른 자체적인 스타일을 보유한다. 이는 섀도우 DOM을 통해 브라우저 자체적으로 스타일링을 하고 있기 때문인데, 기본적으로 이는 이용자들로부터 숨겨져있다. 이를 보고자 한다면 개발자 도구의 옵션을 건드려야 한다.
섀도우 트리 (Shadow Tree)
하나의 DOM 요소에는 두가지 유형의 DOM 서브트리가 존재한다.
- Light Tree - 일반적인 DOM 서브트리. 우리가 기존에 알고 쓰던 모든 서브트리는 이에 해당한다.
- Shadow Tree - 숨겨진 DOM 서브트리. HTML 상에 보여지지 않는다.
만약, 두 가지 유형의 서브트리를 모두 갖는 요소가 있다면, 브라우저는 오직 섀도우 트리만을 렌더링한다. 물론 각각을 적절히 조합하도록 설정할 수도 있는데, 이에 대해선 추후에 설명한다.
섀도우 트리는 커스텀 요소 내에서 컴포넌트 내부 요소들을 숨기고, 컴포넌트 자체적인 로컬 스타일링을 위해 사용된다.
예를 들어, 아래와 같이 <show-hello>
라는 커스텀 요소를 만들어낼 수 있다.
<script>
customElements.define(
'show-hello',
class extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<p>
Hello, {this.getAttribute('name')}
</p>`;
}
},
);
</script>
<show-hello name="John"></show-hello>
커스텀 요소를 만들기 위해서는 먼저 elem.attachShadow({mode: ...})
를 호출해야 한다. 여기엔 두 가지 제한이 있다.
- 각 요소 당 하나의 섀도우 루트(shadow-root)만 가질 수 있다.
elem
은 반드시 커스텀 요소이거나, 다음 중 하나여야 한다. (“article”, “aside”, “blockquote”, “body”, “div”, “footer”, “h1…h6”, “header”, “main” “nav”, “p”, “section”, “span”)
mode
옵션은 캡슐화 레벨을 설정한다. 다음의 둘 중 하나여야 한다.
open
: 어디서든 해당 요소의 섀도우 루트에elem.shadowRoot
로 접근할 수 있다.closed
:elem.shadowRoot
가 항상null
이 된다.
대부분의 브라우저 자체적인 섀도우 트리들은 closed
상태이며, 때문에 이들의 섀도우 트리에 접근할 방법이 없다.
attachShadow
를 통해 반환되는 **섀도우 루트(shadow root)**는 요소와 같다. innerHTML
이나 append
같은 DOM 프로퍼티 및 메서드를 사용할 수 있다.
- 섀도우 루트가 있는 요소는 **섀도우 트리 호스트(shadow tree host)**라고 불리며, 이는 섀도우 루트의
host
프로퍼티는 통해 접근할 수 있다.
// assuming {mode: "open"}, otherwise elem.shadowRoot is null
alert(elem.shadowRoot.host === elem); // true
캡슐화 (Encapsulation)
섀도우 DOM은 메인 문서(document)으로부터 완전히 구분된다.
-
light DOM에서의
querySelector
에 의해 탐색되지 않는다. 때문에 light DOM에 동일한 id가 존재하더라도 섀도우 트리 내에서만 고유하다면 상관없다. -
섀도우 DOM은 스스로의 스타일시트를 보유한다. 그 외의 스타일 규칙은 이에 적용되지 않는다.
이에 따라, 아래 예시를 보자.
<style>
/* document style won't apply to the shadow tree inside #elem (1) */
p {
color: red;
}
</style>
<div id="elem"></div>
<script>
elem.attachShadow({ mode: 'open' });
// shadow tree has its own style (2)
elem.shadowRoot.innerHTML = `
<style> p { font-weight: bold; } </style>
<p>Hello, John!</p>
`;
// <p> is only visible from queries inside the shadow tree (3)
alert(document.querySelectorAll('p').length); // 0
alert(elem.shadowRoot.querySelectorAll('p').length); // 1
</script>
- 문서 자체에서 적용한
style
은 섀도우 트리에 아무 영향도 미치지 않는다. - 허나,
elem.shadowRoot.innerHTML
에서 직접 지정한 스타일링은 적용된다. - 섀도우 트리에서 요소를 가져오고자 하는 경우, 반드시 섀도우 트리 내에서
querySelector
와 같은 메서드를 사용해야 한다.
Shadow DOM - slots, composition
탭, 갤러리 등 많은 종류의 컴포넌트들은 렌더링할 내용들이 필요하다.
내장 <select>
태그가 <option>
태그들을 요구하는 것처럼, 우리가 임의로 만든 태그 역시 임의의 태그를 요구할 수 있다.
<custom-menu>
<title>Candy menu</title>
<item>Lollipop</item>
<item>Fruit Toast</item>
<item>Cup Cake</item>
</custom-menu>
우리는 이것을 동적으로 요소들의 내용을 분석하고, DOM 노드들을 조작해서 구현할 수 있다. 하지만, shadow DOM의 경우, 문서에서의 스타일링이 적용되지 않으며, 때문에 어느 정도의 추가 코드을 요구한다.
다행히도 Shadow DOM은 여기서 <slot>
요소를 제공한다. 이는 light DOM으로부터 가져온 내용들로 Shadow DOM의 내용을 채울 수 있게 해준다.
Named slots
간단한 예시로부터 살펴보자. 여기 <user-card>
shadow DOM은 두개의 슬롯(slot)을 사용하며, 이는 light DOM으로부터 아래와 같이 채워질 수 있다.
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
`;
}
});
</script>
<user-card>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
섀도우 DOM에서, <slot name='X'>
은 삽입 지점을 의미하며, 여기에는 추후 slot='X'
를 light DOM에서 지정한 요소가 위치하게 된다.
이후 브라우저는 합성_composition을 수행하는데, 이는 light DOM에서 요소를 가져와 이에 대응하는 shadow DOM의 slot에 렌더링시키는 과정이다.
스크립트가 동작한 후, 아직 합성_composition이 동작하지 않은 상태의 DOM 구조는 아래와 같다.
<user-card>
#shadow-root
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
현 시점에서는, shadow DOM까지는 생성되었으나(이에 따라 #shadow-root
가 보인다), 현재 요소는 light와 shadow DOM 모두를 갖고 있다.
렌더링을 하기 위해, shadow DOM에서의 각 <slot name="...">
에서 브라우저는 light DOM에서 동일한 이름을 가진 slot="..."
을 찾는다. 이후 이 요소들은 각 slot 안에 렌더링된다.
그 결과로 만들어진 아래의 DOM 구조를 flatten DOM 이라고 한다.
<user-card>
#shadow-root
<div>
Name:
<slot name="username">
<!-- slotted element is inserted into the slot -->
<span slot="username">John Smith</span>
</slot>
</div>
<div>
Birthday:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
</user-card>
다만, 유의해야 할 점이 있다. flatten DOM은 오직 렌더링과 이벤트 핸들링의 목적으로 존재한다. 이는 어떤 식으로 동작하는지를 보여주기 위한 것이며, 실제 문서의 노드들은 어디로도 이동하지 않는다.
이는 단순히 querySelectorAll
를 통해 확인해볼 수 있다.
// light DOM <span> nodes are still at the same place, under `<user-card>`
alert(document.querySelectorAll('user-card span').length); // 2
결국, flatten DOM은 shadow DOM에서 slot에 대한 삽입을 통해 만들어진다. 브라우저는 이를 렌더링, 스타일 상속, 이벤트 전파의 목적으로 활용한다. 하지만, JS는 여전히 flatten이 이루어지기 전의 문서만을 볼 수 있다.
유의!
<user-card>
<span slot="username">John Smith</span>
<div>
<!-- invalid slot, must be direct child of user-card -->
<span slot="birthday">01.01.2001</span>
</div>
</user-card>
slot-'...'
속성을 가진 태그는 최상위의 자식 노드여야 한다. 보다 깊은 곳에 위치한 노드들은 무시된다.
한편, 똑같은 slot에 지정된 여러개의 요소들이 light DOM에 존재한다면, 이들은 갱신되는 것이 아니라, 순서대로 slot에 추가된다.
예를 들어, 앞선 예시에 대해 아래와 같이 light DOM을 구성했다고 가정하자.
<user-card>
<span slot="username">John</span>
<span slot="username">Smith</span>
</user-card>
그렇다면, 그 결과 생겨난 flatten DOM의 결과는 아래와 같다.
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">John</span>
<span slot="username">Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
</user-card>
Slot fallback content
만약, <slot>
태그에 어떤 값이 존재한다면, 이는 fallback(대비책)이 된다. 다시말해, default값이 된다. 브라우저는 light DOM에서 상응하는 slot='...'
요소를 찾지 못하는 경우 해당 기본값을 렌더링한다.
<div>
Name:
<slot name="username">Anonymous</slot>
</div>
Default slot: first unnamed
shadow DOM에서 name
이 존재하지 않는 첫번째 <slot>
은 default slot이 된다. 여기에는 light DOM에서부터 slot 처리가 되지 않은 모든 요소들이 추가된다.
예를 들어, 아래처럼 <user-card>
에 default slot을 추가해보면, 별도로 slot을 지정해주지 않은 모든 요소들을 자동으로 default slot에 추가시킨다.
<script>
customElements.define(
'user-card',
class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<fieldset>
<legend>Other information</legend>
<slot></slot>
</fieldset>
`;
}
},
);
</script>
<user-card>
<div>I like to swim.</div>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
<div>...And play volleyball too!</div>
</user-card>
이 역시 기존의 slot과 마찬가지로 갱신을 하는 것이 아니라, 추가하는 방식으로 동작한다. 따라서 그 결과인 flatten DOM은 아래와 같아진다.
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">John Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
<fieldset>
<legend>About me</legend>
<slot>
<div>Hello</div>
<div>I am John!</div>
</slot>
</fieldset>
</user-card>
Updating slots
만약 외부의 코드를 통해 slot에 들어간 item들을 동적으로 추가/삭제하고 싶다면 어떻게 하면 좋을까?
기본적으로, 브라우저가 slot들을 모니터링하며, 이에 따라 slot 처리된 요소들을 알아서 추가/삭제하여 렌더링해준다.
또한, light DOM 노드들은 복제된 것이 아니라, 단순히 slot 안에 렌더링된 것이다. 때문에 변화가 즉시 가시적으로 반영된다.
따라서, 우리는 렌더링 업데이트에 대해 신경 쓸 필요 없다. 단, 만약 slot이 업데이트되는 특정 시점에 대해 이벤트를 적용하고 싶다면 slotchange
이벤트를 활용하면 된다.
slotchange
이벤트는, 최초에 1) 초기화 할 때 발생하고, 이후 2) slot에 변경이 생길 때마다 발생한다.
보다 상세한 처리가 요구되는 경우, MutationObserver를 사용할 수도 있다.
Slot API
마지막으로, slot과 관련된 JS 메서드들을 살펴보자.
앞서 말했듯, JS는 오직 실제 DOM만을 바라본다. flatten DOM에 대해선 신경쓰지 않는다.
하지만, **만약 shadow tree가 `
Shadow DOM styling
shadow DOM은 <style>
태그와 <link rel='stylesheet' href='...'>
태그를 모두 포함할 수 있다. 그 중 <link>
태그의 경우, HTTP 캐싱이 되며, 여러번 다운로드 되지 않는다.
일반적인 스타일 규칙으로는, shadow DOM은 오직 shadow tree 내의 로컬 스타일 규칙에만 영향을 받는다. 하지만 몇가지 예외가 존재한다.
:host
:host
선택자는 shadow 호스트(shadow tree를 보유한 요소)를 선택하는 것을 허용한다.
예를 들어, <custom-dialog>
요소가 가운데에 위치하길 원한다면, 아래와 같은 방법으로 스타일을 추가할 수 있다.
<template id="tmpl">
<style>
/* the style will be applied from inside to the custom-dialog element */
:host {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: inline-block;
border: 1px solid red;
padding: 10px;
}
</style>
<slot></slot>
</template>
<script>
customElements.define(
'custom-dialog',
class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).append(
tmpl.content.cloneNode(true),
);
}
},
);
</script>
<custom-dialog> Hello! </custom-dialog>
Cascading
shadow 호스트(<custom-dialog>
태그 그 자체)는 light DOM에 위치한다. 따라서, 이는 문서 자체의 CSS 규칙에 영향을 받는다.
만약, shadow tree에 로컬로 :host
스타일이 존재함과 동시에, 문서 자체에도 스타일이 존재한다면, 문서의 스타일링이 더 우선시된다.
따라서, 위의 코드에서 아래와 같이 문서에 스타일링을 추가하는 경우
<style>
custom-dialog {
padding: 0;
}
</style>
<custom-dialog>
는 더 이상 padding을 갖지 않는다.
이는 제법 편리한데, 이를 통해 :host
에는 기본(default) 컴포넌트 스타일을 지정하고, 문서를 통해서 스타일링을 쉽게 덮어씌울 수 있기 때문이다.
예외는 로컬 스타일링에 !important
를 적용하는 경우다.
:host(selector)
:host
와 동일하되, 주어진 선택자(selector
)에 해당하는 경우에만 적용된다.
예를 들어, 앞선 <custom-dialog>
에서, center
속성(attribute)를 보유한 경우에만 가운데 정렬을 하고싶다면, 아래와 같이 활용할 수 있다.
<template id="tmpl">
<style>
:host([centered]) {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border-color: blue;
}
:host {
display: inline-block;
border: 1px solid red;
padding: 10px;
}
</style>
<slot></slot>
</template>
<script>
customElements.define(
'custom-dialog',
class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).append(
tmpl.content.cloneNode(true),
);
}
},
);
</script>
<custom-dialog centered> Centered! </custom-dialog>
<custom-dialog> Not centered. </custom-dialog>
:host-context(selector)
:host
와 동일하되, shadow 호스트 자신, 혹은 그 상위에 있는 요소 중 해당 selector
에 해당하는 경우에만 적용된다.
예를 들어, :host-context(.dark-theme)
를 사용한 아래 예시에서, <custom-dialog>
에 dark-theme
클래스가 존재하는 경우에만 스타일링이 적용된다.
<body class="dark-theme">
<!--
:host-context(.dark-theme) applies to custom-dialogs inside .dark-theme
-->
<custom-dialog>...</custom-dialog>
</body>
요약하자면, :host
종류들은 컴포넌트의 메인 요소들을 스타일링 하기 위해 활용할 수 있는 선택자이다. 이를 활용해 적용한 스타일들은 문서 자체에서의 스타일링에 덮어씌여질 수 있다.
slotted content 스타일링
이제, slot
을 사용하는 경우를 보자.
slot 처리 된 요소 자체는 light DOM에서 온다. 따라서, 그들 요소는 문서의 스타일링을 따르며, shadow tree 측에서의 로컬 스타일링은 여기에 영향을 미치지 않는다.
예를 들어보자. 아래에 slot으로 삽입된 <span>
은 문서 스타일링에 따라 bold
폰트 굵기를 갖는다. 하지만 shadow Root에서의 스타일링에 영향 받지 않기 때문에 붉은 바탕(background: red
)이 아니다.
<style>
span {
font-weight: bold;
}
</style>
<user-card>
<div slot="username"><span>John Smith</span></div>
</user-card>
<script>
customElements.define(
'user-card',
class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
span { background: red; }
</style>
Name: <slot name="username"></slot>
`;
}
},
);
</script>
만약, slot 처리된 요소들에 대해 컴포넌트 안에서 스타일링하고 싶다면, 두가지 선택지가 있다.
첫번째는, 컴포넌트 내에서 CSS 상속에 기반해 <slot>
그 자체를 스타일링하는 것이다.
<user-card>
<div slot="username"><span>John Smith</span></div>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<style>
slot[name="username"] { font-weight: bold; }
</style>
Name: <slot name="username"></slot>
`;
}
});
</script>
이제 <p>John Smith</p>
는 bold 굵기가 된다. 왜냐하면 CSS 상속에 의해 하위 요소들에게도 영향을 미치기 때문이다. 단, CSS 자체의 속성들이 상속되지는 않는다.
두번째 선택지는 바로 ::slotted(selector)
의사 클래스를 사용하는 것이다. 이 때는 두가지 조건에 따라 해당하는 요소를 구분한다.
- light DOM를 통해서 전달된 slot 처리된 요소(
slot='...'
를 포함)여야 한다.name
자체는 중요하지 않다. 단, 오직 그 요소 자체에만 해당하며, 하위 요소들은 해당하지 않는다. - 요소가
selector
선택자에 해당해야 한다.
예를 들어, ::slotted(div)
는 정확히 <div slot='username'>
에만 적용되며, 하위 요소들에는 적용되지 않는다.
<user-card>
<div slot="username">
<div>John Smith</div>
</div>
</user-card>
<script>
customElements.define(
'user-card',
class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
::slotted(div) { border: 1px solid red; }
</style>
Name: <slot name="username"></slot>
`;
}
},
);
</script>
기억하자. ::slotted
선택자는 하위 요소들을 확인하지 않는다.
::slotted(div span) {
/* our slotted <div> does not match this */
}
::slotted(div) p {
/* can't go inside light DOM */
}
커스텀 프로퍼티를 이용한 CSS Hook
메인 문서를 통해 shadow DOM 컴포넌트 내부의 요소들을 스타일링하려면, 어떻게 해야할까?
:host
선택자는 <custom-dialog>
자체에 대해서 스타일링을 적용할 수 있다. 그런데, 그것보다 깊숙히 위치한 요소들에 스타일링을 적용하고 싶다면 어떻게 할까?
사실, 문서에서 shadow DOM의 스타일에 직접 영향을 줄 수 있는 선택자는 없다. 그러나, 원한다면, CSS 변수(custom CSS properties)를 활용해 이를 구현할 수 있다.
왜냐하면, 커스텀 CSS 프로퍼티는 light와 shadow 모두에 존재하기 때문이다.(공유한다)
예를 들어, 먼저 아래처럼 --user-card-field-color
라는 CSS 변수를 사용해 .field
를 기본 스타일링할 수 있다.
<style>
.field {
color: var(--user-card-field-color, black);
/* if --user-card-field-color is not defined, use black color */
}
</style>
<div class="field">Name: <slot name="username"></slot></div>
<div class="field">Birthday: <slot name="birthday"></slot></div>
이후, 문서에서 <user-card>
에 대해 앞서 만든 property를 활용하여 스타일링을 변경할 수 있다.
user-card {
--user-card-field-color: green;
}
커스텀 CSS 프로퍼티는 shadow DOM 전반에 유효하기 때문에, 어디서든 활용할 수 있다.
<style>
user-card {
--user-card-field-color: green;
}
</style>
<template id="tmpl">
<style>
.field {
color: var(--user-card-field-color, black);
}
</style>
<div class="field">Name: <slot name="username"></slot></div>
<div class="field">Birthday: <slot name="birthday"></slot></div>
</template>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.append(document.getElementById('tmpl').content.cloneNode(true));
}
});
</script>
<user-card>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
Shadow DOM and events
shadow tree에 담긴 기본 아이디어는 컴포넌트 내부적인 실행 세부사항에 대해 캡슐화를 적용하는 것이다.
<user-card>
의 shadow DOM 내부에 클릭 이벤트가 발생했다고 가정하자. 이 때, 메인 문서는 shadow DOM 내부에 대해 알 수 있는 방법이 없다. 이는 특히 서드파티 라이브러리로부터의 컴포넌트를 활용할 때 더 두드러진다.
따라서, 캡슐화를 유지하기 위해, 브라우저는 이벤트를 리타겟팅(retarget)한다.
shadow DOM 내부에서 일어나는 이벤트들은 컴포넌트 외부에서 볼 때, 그들의 host 요소를 target
으로 삼는다.
말이 좀 헷갈릴 수 있는데, 다시 말해, shadow DOM 측에서 봤을 때는 div
, span
등에서 이벤트가 발생했더라도, 메인 문서 측에서는 해당 이벤트의 타겟을 항상 user-card
와 같은 host로 본다.
아래는 간단한 예시다.
<user-card></user-card>
<script>
customElements.define(
'user-card',
class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<p>
<button>Click me</button>
</p>`;
this.shadowRoot.firstElementChild.onclick = (e) =>
alert('Inner target: ' + e.target.tagName);
}
},
);
document.onclick = (e) => alert('Outer target: ' + e.target.tagName);
</script>
위 예시에서는, shadow DOM과 light DOM에서 바라보는 target이 달라지게 된다.
-
내부 타겟:
BUTTON
- shadow DOM을 통한 컴포넌트 내부의 이벤트 핸들러는 올바른target
을 가져온다. -
외부 타겟:
USER-CARD
- 문서에서 사용하는 이벤트 핸들러는 shadow host(user-card
)를 타겟으로 가져온다.
이벤트 리타겟팅은 마땅히 존재해야 하는데, 컴포넌트 내부에서 발생하는 일들에 대해 외부의 문서가 신경을 쓸 필요가 없기 떄문이다. 때문에, 문서의 관점에서는 단순히 <user-card>
에서 발생한 이벤트라고만 인식하는 것이다.
리타겟팅은 slot 처리된 요소에서는 발생하지 않는데, 왜냐하면 애초에 해당 요소는 light DOM에서부터 온 것이기 때문이다.
예를 들어, 아래 예시에서 <span slot='username'>
의 이벤트 타겟은 정확히 span
이 된다. 이는 shadow와 light 이벤트 핸들러 양측이 동일하다.
<user-card id="userCard">
<span slot="username">John Smith</span>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div>
<b>Name:</b> <slot name="username"></slot>
</div>`;
this.shadowRoot.firstElementChild.onclick =
e => alert("Inner target: " + e.target.tagName);
}
});
userCard.onclick = e => alert(`Outer target: {e.target.tagName}`);
</script>
단, 위 예시에서 <b>Name:</b>
과 같이 slot에 해당하지 않는 요소로부터 발생한 이벤트는 마찬가지로 host를 타겟으로 삼는다.
버블링, event.composedPath()
flattened DOM은 이벤트 버블링을 위해서 사용된다.
따라서, 만약 slot 처리된 요소가 존재한다면, 그 안에서 발생한 이벤트는 <slot>
을 거쳐 상위로 버블링된다.
shadow 요소들을 포함한 원래 이벤트 타겟에 대한 전체 경로(full-path)는 event.composedPath()
메서드를 통해 확인할 수 있다. 메서드의 이름에서부터 알 수 있듯이, 여기서 반환받는 경로는 composition 단계 이후의 path이다.
예를 들어, 아래의 flatten DOM이 있다고 가정하자.
<user-card id="userCard">
#shadow-root
<div>
<b>Name:</b>
<slot name="username">
<span slot="username">John Smith</span>
</slot>
</div>
</user-card>
이제, <span slot='username'>
을 클릭했을 때, event.composedPath()
를 확인한다면, 다음 배열을 반환한다.
[span, slot, div, shadow-root, user-card, body, html, document, window]
이는 composition 이후 생성된 flatten DOM에서의 타겟 요소로부터 부모로 뻗어나가는 체이닝이다.
주의
shadow tree의 세부사항들은 오직
{mode: 'open'}
옵션이 있을 때만 제공된다. 만약, 그렇지 않다면event.composedPath()
역시user-card
에서부터 시작한다. 이는 shadow DOM이 동작하는 다른 메서드의 원칙과 유사한데, 닫힌(closed) 트리는 내부적으로 완전히 숨겨진다.
event.composed
대부분의 이벤트들은 shadow DOM 경계를 거쳐 완전히 버블링된다. 하지만 몇개의 예외가 존재한다.
이 경우 composed
이벤트 객체 프로퍼티에 의해 제어될 수 있는데, 만약 true
에 해당하는 경우, 해당 이벤트는 shadow DOM의 경계를 넘어간다. false
인 경우, 이벤트는 오직 shadow DOM 내부에서만 탐색된다.
아래 대부분의 이벤트는 composed: true
이다.
blur
,focus
,focusin
,focusout
click
,dblclick
mousedown
,mouseup
,mousemove
,mouseout
,mouseover
wheel
beforeinput
,input
,keydown
,keyup
모든 터치 이벤트와 포인터 이벤트 역시 composed: true
로 설정된다.
아래는 composed: false
에 해당하는 일부 이벤트들이다.
mouseenter
,mouseleave
(얘넨 애초에 버블링이 없다.)load
,unload
,abort
,error
select
slotchange
해당 이벤트들은 오직 해당 요소가 동일하게 위치한 DOM 내에서만 확인될 수 있다.
커스텀 이벤트
임의로 작성한 커스텀 이벤트를 발생시킬 때, bubble
과 composed
프로퍼티를 설정할 수 있다.
예를 들어, 아래에서 div#outer
의 shadow DOM에 div#inner
을 만들고 거기에 두 이벤트를 트리거하자. 그러면, 오직 composed: true
로 설정한 이벤트만이 DOM 경계를 넘어 문서 바깥으로 나올 수 있다.
<div id="outer"></div>
<script>
outer.attachShadow({mode: 'open'});
let inner = document.createElement('div');
outer.shadowRoot.append(inner);
/*
div(id=outer)
#shadow-dom
div(id=inner)
*/
document.addEventListener('test', event => alert(event.detail));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: true,
detail: "composed"
}));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: false,
detail: "not composed"
}));
</script>
Introduction
Canvas API에 대한 정리는 MDN의 문서를 쭉 따라갈 예정입니다.
먼저 <canvas>
태그의 형태에 대해 살펴봅시다.
<canvas id="tutorial" width="150" height="150"></canvas>
width
와 height
어트리뷰트를 지정하지 않는 경우, 캔버스의 최초 너비는 300px이고, 높이는 150px이 됩니다. 해당 요소는 CSS에 의해 임의로 크기가 변경될 수 있으나, 비율이 고려되지 않는 경우 왜곡되어 보입니다.
노트: 만약 렌더링이 왜곡된 것처럼 보인다면, CSS를 사용하지 않고, 직접
<canvas>
태그의width
와height
어트리뷰트를 지정하는 것이 좋습니다.
대체 콘텐츠
<canvas>
태그 안에 콘텐츠가 삽입된 경우, <canvas>
태그를 지원하지 않는 브라우저에 대해서는 해당 콘텐츠를 보여줍니다. 브라우저가 <canvas>
태그를 지원하는 경우, 이는 무시됩니다. 참고로, <canvas>
는, 이러한 방식으로 인해 반드시 닫는 태그가 필요합니다.
<canvas id="stockGraph" width="150" height="150">
current stock price: 3.15 +0.15
</canvas>
<canvas id="clock" width="150" height="150">
<img src="images/clock.png" width="150" height="150" alt="" />
</canvas>
Rendering Context
캔버스는 최초에 비어있으며, 어떤 것을 표시하기 위해 스크립트를 통해 렌더링 컨텍스트에 접근하여, 이를 그려내야 합니다.
const canvas = document.getElementById('tutorial');
const ctx = canvas.getContext('2d');
기본 예제
간단한 직사각형 두개를 그려낸 예제를 살펴보겠습니다. 현재는 아래를 통해 대략적인 형태에 대해서만 이해하면 됩니다.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script type="application/javascript">
function draw() {
const canvas = document.getElementById('canvas');
if (canvas.getContext) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgb(200,0,0)';
ctx.fillRect(10, 10, 50, 50);
ctx.fillStyle = 'rgba(0, 0, 200, 0.5)';
ctx.fillRect(30, 30, 50, 50);
}
}
</script>
</head>
<body onload="draw();">
<canvas id="canvas" width="150" height="150"></canvas>
</body>
</html>
도형 그리기
기본적으로 캔버스 상의 좌표 공간은 아래의 형태를 따릅니다. 좌상단을 기준으로 x, y 좌표를 판단합니다.

직사각형 그리기
캔버스 상에서 직사각형을 그리기는 데에는 세가지 함수가 있습니다.
fillRect(x, y, width, height)
- 색칠된 직사각형을 그립니다.
strokeRect(x, y, width, height)
- 직사각형 윤곽선을 그립니다.
clearRect(x, y, width, height)
- 직사각형 모양으로 해당 부분들을 완전히 지웁니다.
rect(x, y, width, height)
- 직사각형 모양으로 경로를 추가합니다. 이후 좌표가 해당 경로로 이동합니다.
이들을 이용해 하나의 예제를 살펴봅시다.
ctx.fillRect(25, 25, 100, 100);
ctx.clearRect(45, 45, 60, 60);
ctx.strokeRect(50, 50, 50, 50);
경로 그리기
경로는 직사각형 외의 유일한 원시형(primitive) 도형입니다. 경로를 통해 도형을 그리기 위해서는 다음의 단계를 거치게 됩니다.
- 경로를 생성
- 그리기 명령들을 통해 경로 상에 그립니다.
- 그린 경로에 대한 윤곽선을 그리거나 도형 내부를 채웁니다.
이러한 단계들을 수행하게 아래의 함수들이 사용됩니다.
beginPath()
- 새로운 경로를 만듭니다.
- 이후에 계속 살펴보겠지만, 여러 경로들을 설정하기 위해 사용됩니다.
closePath()
- 현재 경로의 시작점과 연결되는 직선을 추가합니다. 이는 옵션 사항입니다.
stroke()
- 윤곽선을 통해 도형을 그립니다.
fill()
- 경로 내부를 채워 색칠된 도형을 그립니다. 이 경우 별도로
closePath()
를 해줄 필요가 없습니다.
이를 통해 간단한 삼각형을 그려봅시다.
ctx.beginPath();
ctx.moveTo(75, 50);
ctx.lineTo(100, 75);
ctx.lineTo(100, 25);
ctx.fill();
펜 이동하기
moveTo(x, y)
- 이를 이용하면, 펜을 해당 좌표로 옮기기만 하고, 그리진 않습니다.
ctx.beginPath();
ctx.arc(75, 75, 50, 0, Math.PI * 2, true); // Outer circle
ctx.moveTo(110, 75);
ctx.arc(75, 75, 35, 0, Math.PI, false); // Mouth (clockwise)
ctx.moveTo(65, 65);
ctx.arc(60, 65, 5, 0, Math.PI * 2, true); // Left eye
ctx.moveTo(95, 65);
ctx.arc(90, 65, 5, 0, Math.PI * 2, true); // Right eye
ctx.stroke();
선 그리기
lineTo(x, y)
- 이는 현재 위치에서 해당 좌표 위치까지 선을 그려냅니다.
// Filled triangle
ctx.beginPath();
ctx.moveTo(25, 25);
ctx.lineTo(105, 25);
ctx.lineTo(25, 105);
ctx.fill();
// Stroked triangle
ctx.beginPath();
ctx.moveTo(125, 125);
ctx.lineTo(125, 45);
ctx.lineTo(45, 125);
ctx.closePath();
ctx.stroke();
호(Arc) 또는 원 그리기
arc(x, y, radius, startAngle, endAngle, anticlockwise)
- 해당 좌표에, 반지름
radius
를 갖도록startAngle
각도에서endAngle
각도까지anticlockwise
방향으로 호를 그려냅니다.
arcTo(x1, y1, x2, y2, radius)
- 주어진 각 제어점과 반지름으로 호를 그리고, 이전 점과 직선으로 연결합니다.
- 이에 대해서는 여기를 살펴봅시다.
<img src=">
주의!:
arc
함수에서의 각도는 degree가 아닌 radian 단위를 사용합니다. 따라서 degree 단위를 사용하려면 별도의 변환이 요구됩니다.
radians = (Math.PI/180)*degrees
const startAngle = 0;
const endAngle = (Math.PI / 180) * 90;
ctx.beginPath();
ctx.arc(120, 120, 100, startAngle, endAngle, true);
ctx.stroke();
베지어(Bezier) 곡선과 이차(Quadratic) 곡선

베지어 곡선은 주로 복잡한 형태를 그려내는데 사용됩니다.
quadraticCurveTo(cp1x, cp1y, x, y)
cp1x
및cp1y
로 지정된 제어점을 통해 현재 펜 위치에서x
,y
로 지정된 끝점까지 이차 베지어 곡선을 그립니다.
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
- 각 제어점을 통해
x
,y
로 지정된 끝점까지 삼차 베지어 곡선을 그립니다.
ctx.beginPath();
ctx.moveTo(75, 25);
ctx.quadraticCurveTo(25, 25, 25, 62.5);
ctx.quadraticCurveTo(25, 100, 50, 100);
ctx.quadraticCurveTo(50, 120, 30, 125);
ctx.quadraticCurveTo(60, 120, 65, 100);
ctx.quadraticCurveTo(125, 100, 125, 62.5);
ctx.quadraticCurveTo(125, 25, 75, 25);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(75, 40);
ctx.bezierCurveTo(75, 37, 70, 25, 50, 25);
ctx.bezierCurveTo(20, 25, 20, 62.5, 20, 62.5);
ctx.bezierCurveTo(20, 80, 40, 102, 75, 120);
ctx.bezierCurveTo(110, 102, 130, 80, 130, 62.5);
ctx.bezierCurveTo(130, 62.5, 130, 25, 100, 25);
ctx.bezierCurveTo(85, 25, 75, 37, 75, 40);
ctx.fill();
Path2D 오브젝트
Path2D()
new
키워드와 함께 사용되어 새로운Path2D
객체를 반환합니다. 기존 경로 혹은 SVG 경로를 인자로 받을 수도 있습니다.
SVG 경로 데이터를 활용하는 경우, 아래와 같은 형태가 됩니다.
const p = new Path2D('M10 10 h 80 v 80 h -80 Z');
이를 활용하면, 하나의 컨텍스트로 이리저리 옮겨가며 그리던 방식에서 벗어나, 객체의 형태로 각 경로를 변수에 저장할 수 있습니다.
const rectangle = new Path2D();
rectangle.rect(10, 10, 50, 50);
const circle = new Path2D();
circle.moveTo(125, 35);
circle.arc(100, 35, 25, 0, 2 * Math.PI);
ctx.stroke(rectangle);
ctx.fill(circle);
- 해당 md는 여기의 글을 재구성한 것입니다.
렌더링 최적화
픽셀 파이프라인

위 그림은 작업 시 유의해야하는 5가지 주요 영역이며, 픽셀 - 화면 파이프라인의 핵심 요소이다.
- JS / CSS : JS 및 CSS를 통해 이루어지는 시각적 변화의 트리거를 가리킨다.
- Style :
.headline
과 같은 선택자에 따라 어떤 CSS 규칙을 어떤 요소에 적용할지 계산하는 프로세스이다. - Layout : 브라우저가 각 요소에 어떤 규칙을 적용할 지 알고난 후, 실제로 어디에, 어느 정도의 공간을 차지하며 위치할지를 계산하는 과정. 한 요소가 다른 요소에 영향을 줄 수 있기 때문에 해당 과정이 필요하다.
- Paint : 실제로 화면의 픽셀을 채우는 과정. 텍스트 / 색 / 경계 및 그림자 등 요소의 모든 시각적 부분을 그려낸다. 일반적으로 레이어라고 하는 여러 개의 표면에서 수행된다.
- Composition : 페이지의 각 부분들이 여러 레이어를 통해 그려졌기 때문에, 페이지가 정확히 렌더링되기 위해 정확한 순서대로 화면에 그려내는 과정.
JS / CSS를 통해 시각적인 변경이 이루어졌을 때, 파이프라인이 동작하는 세가지 형태가 존재한다.
1. JS / CSS -> Style -> Layout -> Paint -> Composition

- 너비 / 높이 / 위치 등 요소의 기하학적 형태에 영향을 주는 Layout 속성들을 변경하면 브라우저가 다른 요소들을 확인하고 페이지에 대해 리플로우(Reflow) 작업을 수행해야 한다. 이후 영향을 받은 영역이 있다면 다시 페인트해야 하고, 최종적으로 페인트한 요소는 다시 합성이 이루어져야 한다.
2. JS / CSS -> Style -> Paint -> Composition

- 페이지의 레이아웃에 영향을 주지 않는 배경 이미지, 텍스트 색상 또는 그림자 등 Paint Only 속성을 변경하면, 브라우저가 레이아웃 작업을 건너뛰고 페인트 작업부터 수행한다.
3. JS / CSS -> Style -> Composition

- 레이아웃과 페인트 모두 필요없는 속성을 변경하게 되면 브라우저가 바로 합성 단계로 건너뛴다.
각 속성을 변경함에 있어 위 중 어떤 과정을 거치게 되는지에 대해 알고 싶다면 CSS Triggers를 참조하자.
아래부터는 파이프라인의 각 부분에 있어서 발생할 수 있는 일반적인 문제와 그 진단 / 해결방법에 대해 살펴보자.
JS 실행 최적화
실행 타이밍이 안좋거나, 실행 시간이 긴 JS는 렌더링 성능에 영향을 미칠 수 있다.
시각적 업데이트에 setTimeout 또는 setInterval을 피하고 대신 항상 requestAnimationFrame을 사용하라.
setTimeout
에 의해 특정 시점에 콜백이 실행되는 경우, 종종 프레임이 누락되어 버벅거리는 현상이 발생할 수 있다. requestAnimationFrame
을 이용한 방법은 JS가 프레임 시작 시에 실행되도록 보장한다.

/**
* If run as a requestAnimationFrame callback, this
* will be run at the start of the frame.
*/
function updateScreen(time) {
// Make visual updates here.
}
requestAnimationFrame(updateScreen);
메인 스레드를 벗어나 오래 실행되는 자바스크립트를 Web Workers로 이전하라.
원하는 작업에 DOM 액세스가 필요하지 않은 경우에는 Web Worker의 사용을 고려해볼 수 있다. 정렬 / 검색 또는 순회(traversal)는 대개 이 모델에 적합하며, 로드 및 모델 생성도 마찬가지다.
const dataSortWorker = new Worker('sort-worker.js');
dataSortWorker.postMesssage(dataToSort);
// The main thread is now free to continue working on other things...
dataSortWorker.addEventListener('message', function (evt) {
const sortedData = evt.data;
// Update data on screen...
});
마이크로 작업을 사용하여 여러 프레임을 통해 DOM을 변경하라.
단, 반대로 말해서 DOM 액세스를 요구하는 작업의 경우 이런 방식이 적합하지 않다. 이와 같이 작업이 메인 스레드에 있어야 한다면, 큰 작업을 몇 개의 마이크로 작업으로 세분화하여, 각각의 프레임에서 requestAnimationFrame
핸들러를 통해 실행하는 방식을 고려해볼 수 있다.
const var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function processTaskList(taskStartTime) {
const taskFinishTime;
do {
// Assume the next task is pushed onto a stack.
const nextTask = taskList.pop();
// Process nextTask.
processTask(nextTask);
// Go again if there’s enough time to do the next task.
taskFinishTime = window.performance.now();
} while (taskFinishTime - taskStartTime < 3);
if (taskList.length > 0) requestAnimationFrame(processTaskList);
}
이러한 접근 방식을 활용하는 경우, UX/UI를 통해 특정 작업을 계속 수행하고 있음을 이용자에게 나타내는 것이 중요하다. 또한 앱의 메인 스레드를 계속해서 사용 가능한 상태를 유지하여 사용자의 상호작용에 계속 반응할 수 있도록 해야한다.
Chrome DevTools의 Timeline 및 자바스크립트 프로파일러를 사용하여 자바스크립트의 영향을 평가한다.
프레임별로 JS 코드의 실행 비용을 평가하는 것 역시 중요한데, 이는 특히 트랜지션이나 스크롤처럼 성능이 중요한 애니메이션 작업 시에 더욱 중요하다.
JS 비용 및 성능 프로필을 측정하기 위한 가장 좋은 방법은 DevTools를 사용하는 것이다. (Timeline, Profiler)

JS 미세 최적화(Micro Optimization)를 피하라.
offsetTop
이 getBoundingClientRect()
계산보다 빠른 것처럼, 브라우저는 일부 작업을 다른 작업보다 100배 가까이 빨리 처리할 수 있다. 하지만 실제로 함수 호출 시의 프레임 당 시간은 거의 항상 짧기 때문에, JS의 성능적인 측면에 중점을 두는 것은 일반적으로 시간 낭비에 가깝다. 이러한 노고를 통해 절약되는 시간이 거의 밀리초의 일부에 불과하기 때문이다. 단, 게임이나 컴퓨팅 비용이 비싼 앱의 경우엔 예외인데, 일반적으로 많은 계산이 단일 프레임에 적용되고, 이 경우에는 모든 것이 도움이 되기 때문이다. 거꾸로 말하면, 그렇지 않은 경우(게임 등을 개발하는 것이 아닌 경우)에는 적절하지 않으므로 피해야 한다.
Style 계산의 스코프 / 복잡성 최적화
요소의 스타일링 규칙을 정하는 단계에서, 더 간단한 규칙을 지닌 더 작은 트리가 큰 트리나 복잡한 규칙보다 더 효율적으로 처리된다.
다음의 각각은 동일한 요소를 대상으로 하기 위해 지정한 선택자지만, 브라우저가 이를 계산하는데에 드는 시간 비용에는 차이가 생긴다.
.box:nth-last-child(-n + 1) .title {
/* styles */
}
.final-box-title {
/* styles */
}
BEM과 같은 CSS 아키텍처 역시 이러한 선택기 매칭의 성능 이점에서 구현된다.
.list { }
.list__list-item { }
.list__list-item--last-child {}
레이아웃 최적화
레이아웃은 브라우저가 요소의 기하학적인 정보를 파악하는 장소이며, 각 요소는 사용한 CSS, 요소의 컨텐츠 또는 상위 요소에 따라 명시적 / 암시적인 크기 지정 정보를 갖게된다. 해당 프로세스를 Chrome, Opera, Safari 및 IE에서는 레이아웃이라고 하며, Firefox에서는 리플로우(Reflow)라고 한다.
레이아웃의 범위는 거의 항상 전체 문서로 지정된다.
요소가 많은 경우 모든 요소의 위치와 크기를 파악하는데 오랜 시간이 걸린다. 레이아웃을 피할 수 없는 경우, DevTools의 Timeline을 통해 해당 레이아웃에 시간이 얼마나 걸리는지에 대한 파악이 필요하다.

위의 예에서는 레이아웃 내부에서 20ms 이상 소요된 것을 확인할 수 있는데, 애니메이션 화면에서 프레임당 16ms가 필요한 경우 이에 비해 훨씬 높은 값이다. 또한 트리 크기(위에서는 1,618 요소) 및 레이아웃에 필요한 노드 수도 확인할 수 있다.
Flexbox는 동일한 수의 요소에 대해 레이아웃 시간을 훨씬 덜 소요한다.
브라우저에 따라 Flexbox를 지원하지 않는 경우도 있겠지만.. 결국 Flexbox의 사용 여부 이전에 레이아웃 트리거 자체를 완전히 피하려고 노력하는 것이 좋다. 아래는 float를 사용하는 레이아웃과 flex를 사용한 레이아웃 간의 처리시간 차이를 나타내는 결과다.


강제 동기식 레이아웃을 피하라
화면에 프레임을 추가하는 순서는 다음과 같다.

JS를 실행한 후 -> 스타일 계산을 수행한 후에 -> 레이아웃을 실행한다.
하지만, JS를 사용해 브라우저가 레이아웃을 더 일찍 수행하도록 하는 것도 가능한데, 이를 **강제 동기식 레이아웃(forced synchronous layouts)**이라고 한다.
JS가 실행될 때, 이전 프레임의 모든 레이아웃 값은 알려져 있고, 이를 쿼리에 사용할 수 있다. 이를테면 프레임 시작 시 요소의 높이를 기록하려면 다음과 같이 작성할 수 있다.
// Schedule our function to run at the start of the frame.
requestAnimationFrame(logBoxHeight);
function logBoxHeight() {
// Gets the height of the box in pixels and logs it out.
console.log(box.offsetHeight);
}
헌데, 높이를 요청하기 전에 스타일을 먼저 변경한 경우 문제가 발생할 수 있다.
function logBoxHeight() {
box.classList.add('super-big');
// Gets the height of the box in pixels
// and logs it out.
console.log(box.offsetHeight);
}
이 경우, 정확한 높이를 구하기 위해 브라우저는 먼저 스타일을 변경한 후(super-big
이 클래스에 추가되었기 때문에), 레이아웃을 실행해야 한다. 이는 불필요하고, 비용도 많이 드는 작업이다.
때문에, 항상 스타일 읽기를 일괄적으로 처리하여 먼저 수행한 다음, 스타일 쓰기를 작성해야 한다.
결국, 위의 코드를 올바르게 수정하자면 아래와 같아진다.
function logBoxHeight() {
// Gets the height of the box in pixels
// and logs it out.
console.log(box.offsetHeight);
box.classList.add('super-big');
}
대부분의 경우 스타일을 적용한 다음에 그 값을 쿼리할 필요가 없다. 이전의 프레임 값을 사용하면 충분하기 때문이다. 브라우저가 원하는 시간보다 일찍 스타일 계산과 레이아웃을 동시에 실행하지 않도록 하자.
레이아웃 스래싱을 피하라
많은 레이아웃을 연속적으로 빠르게 실행한다면 강제 동기식 레이아웃이 더 악화된다.
function resizeAllParagraphsToMatchBlockWidth() {
// Puts the browser into a read-write-read-write cycle.
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
위 코드는 매 루프마다 스타일 값(box.offsetWidth
)을 읽은 다음 이 값을 즉시 사용해 너비(paragraphs[i].style.width
)를 업데이트한다.
스타일링의 변경을 일으킨 직후에 box.offsetWidth
를 요구하였기 때문에, 이 시점에서 강제 동기식 레이아웃이 발생한다.
이 경우, 바로 루프의 바로 다음부터 시작해 매 반복마다 스타일이 변경되었음을 확인하고, 이에 따라 스타일 변경을 적용하고, 레이아웃을 실행하게 된다.
이를 수정하려면, 기존 프레임의 하나의 값을 읽은 다음 계속해서 사용해야 한다.
// Read.
const width = box.offsetWidth;
function resizeAllParagraphsToMatchBlockWidth() {
for (let i = 0; i < paragraphs.length; i++) {
// Now write.
paragraphs[i].style.width = width + 'px';
}
}
이런 레이아웃 스레싱(Layout thrashing)을 없애기 위해 fastDOM이라는 라이브러리도 존재한다.
페인트 최적화
페인트 과정은 최종적으로 사용자의 화면에 픽셀을 채우는 과정이며, 파이프라인의 모든 작업 중 대체로 실행시간이 가장 긴 작업이기 때문에 가급적 피해야 한다.
언제 페인팅이 이루어지는가??
페인트가 트리거되는 경우는 다음의 두가지이다.

- 레이아웃이 트리거되면, 항상 페인트 역시 트리거된다.

- 레이아웃이 필요없는 비기하학적 속성(배경 / 텍스트 색상, 그림자)을 변경하는 경우에도 페인트가 트리거된다.
이동되거나 페이드되는 요소를 승격(Promote)해라
페인트가 항상 메모리 상에 단일 이미지로 수행되는 것은 아닌데, 실제로 필요에 따라서 브라우저가 다중 이미지 혹은 컴포지터 레이어로 페인트를 할 수 있다.

이러한 접근방식의 이점은, 정기적으로 페인트하거나 변형에 의해 화면에서 움직이는 요소를 다른 요소에 영향을 주지 않으면서 처리할 수 있다는 것이다.
이는 Photoshop과 같은 툴에서 볼 수 있는 레이어의 개념과 유사한데, 최상위에서 개별 레이어를 처리 / 합성하여 최종적인 이미지를 생성할 수 있다.
새로운 레이어를 생성하는 가장 좋은 방법은 will-change
CSS 속성을 사용하는 것이다. 이는 Chrome, Opera 및 Firefox에서 작동하며, transform
값으로 새 컴포지터 레이어를 생성한다.
.moving-element {
will-change: transform;
}
Safari처럼 will-change
를 지원하지는 않으나, 레이어 생성은 활용하는 브라우저의 경우 3D 변형을 사용해 새 레이어를 강제적으로 적용해야 한다.
.moving-element {
transform: translateZ(0);
}
단, 각 레이어는 메모리와 관리가 요구되기 때문에 너무 많은 레이어를 생성하는 것은 오히려 독이 될 수 있다. 또한 요소를 새 레이어로 승격시키는 경우, 이렇게 하는 것이 성능 상으로 이점이 있는지부터 먼저 확인해야 한다.
페인트 영역을 줄여라
앞선 설명처럼 요소를 승격시켰음에도 불구하고 페인팅 작업이 여전히 요구되는 경우가 있다. 페인트의 커다란 문제점은 브라우저가 페인팅이 필요한 두 영역을 합치고 나면, 전체 스크린에 대해 다시 페인팅 작업을 수행할 수도 있다는 점이다. 예를 들어, 페이지 상단에 고정된(fixed) 헤더를 갖고 있더라도, 스크린 아래쪽에서 페인팅이 이루어진다면 그냥 스크린 전체가 리페인팅될 수 있다.
참고: 높은 DPI를 가진 디바이스의 경우
fixed
position을 가진 요소는 자동으로 컴포지터 레이어(Compositor layer)로 승격된다. 반면, 낮은 DPI를 가진 경우는 해당하지 않는데, 이 경우 승격은 텍스트 렌더링을 서브픽셀(subfixel)에서 그레이스케일로 변경하고, 레이어 승격은 수동적으로 이루어져야 하기 때문이다.
페인트 영역을 줄이는 것은 일반적으로 다음과 같은 방법을 통해 이루어질 수 있다.
- 애니메이션과 트랜지션이 가능한 겹치지 않도록 조율하는 것
- 한 페이지의 특정 부분에 애니메이션을 적용하는 것을 피하는 것
페인트 복잡성 단순화

페인트는 작업에 따라 그 비용에 차이가 있다. 다만, 이 기준이 CSS 관점에서 항상 명확한 것은 아니다. 페인트 프로파일러를 사용하면 현재 페인트 작업에 어느 정도의 비용이 드는지를 확인할 수 있고, 이를 대체하기 위한 스타일링에 대해 생각해볼 수 있다.
프레임 당 10ms는 일반적으로, 특히 모바일 디바이스에서는 페인팅 작업을 수행할 만큼 긴 시간이 아니다. 때문에 애니메이션 도중에는 항상 페인팅 작업을 피하기를 원할 수 있다.
합성 (Composition) 최적화
합성은 화면에 표시하기 위해 페이지에서 페인트된 부분들을 합치는 과정이다. 이 영역에서 페이지 성능에 영향을 주는 두 가지 핵심 요소가 있다.
- 관리가 필요한 컴포지터 레이어 수
- 애니메이션에 사용하는 속성
애니메이션에 변형(transform
) 또는 불투명도(opacity
) 변경을 사용하라
앞서 레이아웃과 페인트를 모두 피하고 합성에 대한 변경만 요구하는 픽셀 파이프라인이 최고의 성능을 제공한다고 살펴봤었다.

이를 위해서는 컴포지터가 혼자서 처리할 수 있는 변경 속성을 사용해야 하는데, 현재로서 이에 해당하는 것은 transform
과 opacity
두 가지 속성 뿐이다.

해당 속성들을 사용할 시에 주의할 점은 이러한 속성을 변경하는 요소가 자체적인 컴포지터 레이어에 있어야 하는데, 즉, 레이어를 만들기 위해 요소를 승격해야 한다.
참고 : 애니메이션을 이 속성들로만 제한할 수 없을 것 같다고 생각되면 FLIP 원칙을 참조하라. 이는 비용이 많이 드는 속성을
transform
과opacity
를 이용한 방법으로 다시 작성하도록 도와준다.
애니메이션 적용 요소를 승격해라
위의 페인트 최적화 섹션에서 언급한 것처럼, 애니메이션 적용 요소에 대해 자체 레이어로 승격해야 한다.
.moving-element {
will-change: transform;
}
또는 이전 브라우저나 will-change
를 지원하지 않는 브라우저에 대해서는 다음을 사용한다.
.moving-element {
transform: translateZ(0);
}
레이어 관리 및 레이어 급증 피하기 : 요소를 불필요하게 레이어 승격하지 마라
레이어 승격이 성능 개선에 도움이 된다고 해서 페이지 모든 요소를 승격시켜버리는 것이 자칫 이상적으로 들릴지 모른다.
* {
will-change: transform;
transform: translateZ(0);
}
이 경우의 문제는, 생성하는 모든 레이어가 메모리 및 관리가 요구되며, 이는 공짜로 생겨나는 것이 아니라는 점이다. 결국, 무작정 레이어를 생성하는 경우 오히려 안하느니만 못하다.
입력 핸들러 디바운싱
입력 핸들러는 프레임 완성을 차단시킬 수 있기 때문에 불필요한 추가 레이아웃 작업을 유발할 수 있다.
오래 걸리는 입력 핸들러를 피하라
가장 빠른 경우의 예시를 먼저 들자면, 이용자가 페이지와 상호작용할 때, 페이지의 컴포지터 쓰레드가 이용자의 터치 입력을 감지하고, 컨텐츠를 단순히 이동시킨다. 이 경우, 메인 쓰레드에서 요구되는 동작(JS, 레이아웃, 스타일, 페인트)이 없다.

그런데, 만약 touchstart
, touchmove
, touchend
와 같은 입력 핸들러를 추가한다면, 컴포지터 쓰레드는 해당 핸들러의 처리과정이 끝날때까지 기다려야 한다.
왜냐하면 preventDefault()
가 호출될지도 모르기 때문인데, 만약 호출되었다면 컴포지터 쓰레드는 기본 스크롤 동작을 멈춰야만 한다.
심지어 preventDefault()
를 호출하지 않았더라도, 컴포지터는 기다려야만 한다. 이처럼 컴포지터가 기다리는 동안에 이용자의 스크롤 동작을 막게 되며, 이에 따라 버벅거리거나 프레임이 손실되는 결과가 나타난다.

쉽게 말해, 입력 핸들러는 빠르게 처리되어야 한다. 그래야 컴포지터가 원래 해야하는 일을 할 수 있으니까.
입력 핸들러 내에서의 스타일 변경을 피하라
스크롤과 터치 같은 입력 핸들러들은 requestAnimationFrame
콜백 이전에 실행되도록 되어있다.
만약 이러한 핸들러 내부에서 스타일 변경을 시도한다면, requestAnimationFrame
이 시작될 때 스타일 변경이 보류된다.
만약 requestAnimationFrame
콜백이 시작할 때 스타일 정보들을 읽어오고자 한다면, 위쪽에서 언급했던 **강제 동기 레이아웃(Forced synchronous layout)**이 발생한다.

스크롤 핸들러 디바운스
위의 두가지 문제(입력 핸들러 간소화 + 핸들러 내 스타일 변경 회피)를 해결하는 방법은 동일한데, 시각적 변경에 대해 항상 다음 requestAnimationFrame
콜백으로 디바운스 하는 것이다.
function onScroll(evt) {
// Store the scroll value for laterz.
lastScrollY = window.scrollY;
// Prevent multiple rAF callbacks.
if (scheduledAnimationFrame) return;
scheduledAnimationFrame = true;
requestAnimationFrame(readAndUpdatePage);
}
window.addEventListener('scroll', onScroll);
이 경우, 컴퓨팅 비용이 많이 드는 코드에서도 스크롤이나 터치를 차단하지 않으므로 입력 핸들러를 가볍게 유지할 수 있다는 이점이 있다.
Custom Element CheckList
출처 : Google Developers
커스텀 요소들은 HTML을 확장하여 본인 스스로의 태그를 갖게 해준다. 해당 기능은 어마어마하지만, 저수준의 기능이기도 해서, 어떻게 활용하는 것이 제일 좋은지 불명확한 경우가 많다.
커스텀 요소를 최적으로 활용하기 위해 해당 체크리스트를 확인하자. 제대로 동작하는 커스텀 요소들을 구성하는 내용들을 나눈 것이다.
체크리스트
Shadow DOM
-
스타일을 캡슐화하기 위해 섀도우 루트를 생성하라
섀도우 루트에 스타일링을 캡슐화 시키는 것은 어디에서 해당 요소가 사용되든 해당 스타일링이 적용될 것을 보장한다.
-
섀도우 루트는
constructor
내에서 생성하라constuctor
는 요소 본인에 관한 지식들을 보관하는 곳이다. 따라서 여기에는 다른 요소들을 활용하지 않는 구현 디테일들을 설정하기 적절하다.
만약,connectedCallback
에서 이런 내용들을 수행하면, 요소가 분리/연결되는 경우의 상황을 고려해야 한다. -
커스텀 요소가 생성하는 하위 요소들은 섀도우 루트 안에 넣어라
커스텀 요소에 의해 생성된 자식들은
private
해야한다. 만약 섀도우 루트의 보호가 없다면, 해당 자식 요소들은 외부 JS에 의해 간섭받을 수 있다. -
light DOM에서의 자식들을 반영하기 위해
<slot>
을 사용해라<slot>
을 활용하면 커스텀 요소가 담고 있는 요소들을 이용자들이 지정하기 편하게 만들 수 있다. -
기본적으로
inline
으로 설정된 스타일링을 원하는 게 아니라면,:host
의display
스타일을 변경하라.
기본적으로 커스텀요소는
display: inline
설정을 갖는다. 따라서 단순히width
나height
를 설정하는 것은 아무 영향도 없다. 만약, 애초에inline
디스플레이 설정을 의도하는 게 아니라면, 적절히 변경하라.
hidden
속성에 대응하기 위한:host
디스플레이 스타일을 추가하라섀도우 루트에서
:host
로 스타일링을 하게되면 이는 HTML 자체적인hidden
속성을 덮어씌우게 된다. 때문에,:host([hidden]) { display: none }
와 같은 식으로,hidden
속성을 갖고 있는 경우에 대해 적절한 스타일링 처리가 필요하다.
속성(Attributes)와 프로퍼티
- 글로벌 속성(global attributes)들을 덮어쓰지(override) 말아라
글로벌 속성들은 모든 HTML 요소들에 존재하는 것이다. 예를 들면
tabindex
와role
이 이에 해당한다. 커스텀 요소가 기본적으로tabindex
를 0으로 초기화하게끔 설정하고 싶을 수도 있다. 그러나 항상 해당 커스텀 요소를 사용하는 개발자가 이를 다른 값으로 설정할 수 있음을 유의해라. 때문에 아래와 같은 체크가 필요하다.
connectedCallback() {
if (!this.hasAttribute('role'))
this.setAttribute('role', 'checkbox');
if (!this.hasAttribute('tabindex'))
this.setAttribute('tabindex', 0);
-
항상 원시(primitive) 데이터들을 속성 혹은 프로퍼티 모두로 가져올 수 있게 하라.
커스텀 요소들은 수정가능해야 한다. 그리고 이러한 수정은 속성 혹은 프로퍼티 어느쪽으로든 적절히 이루어질 수 있어야 한다. 결국, 이상적으로 모든 원시 속성들은 프로퍼티와 연결되어 있어야 한다.
-
원시 데이터 속성과 프로퍼티들을 항상 동기화(sync)시키도록 해라. 프로퍼티는 속성에 반영되어야하고, 반대도 마찬가지다.
요소를 활용하는 사람들이 어떤 식으로 해당 요소와 상호작용 할지는 알 수 없다. 때문에 속성과 프로퍼티가 서로를 항상 반영하도록 해야한다. 물론 예외도 존재한다. 비디오 플레이어의
currentTime
과 같은 너무 변경 빈도가 잦은 프로퍼티는 매번 속성에 반영하는 것이 부적절하다. -
Object, Array와 같은 리치 데이터(rich data)들은 프로퍼티로만 받아와라
사실,애초에 내장 HTML 요소에서 속성을 통해 이러한 류의 데이터를 받아들이는 예시 자체가 없다. 대신에 이런 데이터들은 메서드 호출이나 프로퍼티를 통해서 전달된다. 만약, 굳이 이들을 속성으로 전달하고자 하는 경우, 명확한 단점들이 몇가지 있다. 1) 거대한 객체를 문자열로 직렬화(Serialize)하는데에 너무 많은 비용이 들고, 2) 또한 이 문자열화(Stringify) 과정에서 객체에 대한 참조가 사라질 수도 있다.
-
요소를 업그레이드하기 이전에, 이미 설정되었을지도 모르는 프로퍼티를 체크해봐라
커스텀 요소를 활용하는 개발자들이 해당 요소를 불러오기 이전에 먼저 프로퍼티를 설정할지도 모른다. 이런 상황은 종종 로딩 컴포넌트를 핸들링하거나, 해당 컴포넌트를 페이지에 찍어내거나, 해당 프로퍼티를 모델에 바인딩하는 프레임워크를 사용하거나 할 때 종종 발생한다.
-
클래스를 자동으로 적용시키지 마라
요소들은 본인의 상태를 속성을 통해서 나타내야 한다.
class
속성을 해당 요소를 사용하는 개발자들에 의한 것으로 간주되어야 하며, 이를 임의로 자동으로 설정하는 경우 개발자들의class
관리를 망쳐버릴 수 있다.
Events
- 내부 컴포넌트 활동에 따라 적절히 이벤트를 디스패치하라
오직 컴포넌트 본인만 알 수 있는 활동이 있을 수 있다. 이를테면 타이머나 애니메이션 완료, 혹은 로딩이 완료되는 시점과 같은 것들이다. 이러한 변화에 따라, 호스트에게 해당 컴포넌트의 상태가 변경되었음을 알려주게끔 이벤트를 전달하는 것이 좋다.
- 프로퍼티 설정에 대해서는 별도로 이벤트를 디스패치할 필요없다.
호스트가 프로퍼티를 설정한 내용에 대해 이벤트를 전달하는 것은 불필요하다. 호스트가 직접 설정한 내용이기 때문에 현재 상태를 직접 인지할 수 있기 때문이다. 또한, 호스트가 프로퍼티를 설정한 것에 대한 반응으로 이벤트를 전달하는 경우, 데이터 바인딩과 함께 무한 루프를 유발할 수 있다.
Explainers
프로퍼티를 Lazy하게 만들어라
개발자가 커스텀 요소를 불러오기 전에 먼저 프로퍼티를 설정하고자 할 수도 있다. 이는 로딩 컴포넌트를 다루는 프레임워크 등에서 특히 이루어진다.
아래 예시에서는, Angular가 isChecked
프로퍼티를 체크박스의 checked
프로퍼티에 바인딩하려고 한다. 만약 해당 커스텀 요소가 lazy-load된다면 Angular는 요소가 업그레이드되기 이전에 먼저 checked
프로퍼티를 설정할 수 있을 것이다.
<howto-checkbox [checked]="defaults.isChecked"></howto-checkbox>
커스텀 요소는 본인의 인스턴스에 어떤 요소가 이미 설정되어 있는지에 대해 확인함으로써 이러한 경우를 다룰 수 있다. 아래에서 _upgradeProperty()
메서드가 그러한 역할을 한다.
connectedCallback() {
...
this._upgradeProperty('checked');
}
_upgradeProperty(prop) {
if (this.hasOwnProperty(prop)) {
let value = this[prop];
delete this[prop];
this[prop] = value;
}
}
_upgradeProperty()
는 업그레이드되지 않은 인스턴스로부터 값을 가져온 후, 프로퍼티를 삭제하여 커스텀 요소가 자체적인 프로퍼티 setter
를 사용하지 않도록 만든다. 이를 통해, 커스텀 요소가 최종적으로 로드되었을 때, 곧바로 수정된 상태를 반영할 수 있도록 만든다.
재방문 이슈(reentrancy issues)를 피해라
attributeChangeCallback()
을 사용하여 상태를 기본 프로퍼티에 반영되도록 하자.
// When the [checked] attribute changes, set the checked property to match.
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'checked')
this.checked = newValue;
}
헌데, 프로퍼티 설정자가 속성에도 반영되는 경우 무한 루프를 만들어내는 문제가 발생한다.
set checked(value) {
const isChecked = Boolean(value);
if (isChecked)
// OOPS! This will cause an infinite loop because it triggers the
// attributeChangedCallback() which then sets this property again.
this.setAttribute('checked', '');
else
this.removeAttribute('checked');
}
이에 대한 대안으로, 프로퍼티에 대한 setter와 getter를 모두 만들어 getter가 속성에 따라 값을 결정하도록 할 수 있다.
set checked(value) {
const isChecked = Boolean(value);
if (isChecked)
this.setAttribute('checked', '');
else
this.removeAttribute('checked');
}
get checked() {
return this.hasAttribute('checked');
}
이제, 속성을 삭제하거나 추가하는 작업은 프로퍼티에도 영향을 미칠 것이다.
끝으로, attributeChangedCallback()
는 ARIA 상태를 적용하는 것과 같은 사이드 이펙트를 처리하는 데에 사용해라.
attributeChangedCallback(name, oldValue, newValue) {
const hasValue = newValue !== null;
switch (name) {
case 'checked':
// Note the attributeChangedCallback is only handling the *side effects*
// of setting the attribute.
this.setAttribute('aria-checked', hasValue);
break;
...
}
}
TypeScript
해당 문서에서는 이펙티브 타입스크립트를 읽고 스스로 정리해보도록 합니다. 제 개인적인 학습과 스터디를 목적으로 하고 있으며, 당연히 책의 모든 내용을 다루고 있지는 않습니다.
타입스크립트 알아보기
해당 챕터에서는 타입스크립트의 큰 그림을 이해하는데 도움이 될 내용을 다룹니다.
- 타입스크립트는 무엇인지?
- 타입스크립트를 어떻게 여겨야 하는지?
- 자바스크립트와는 무슨 관계인지?
- 등등...
TS와 JS의 관계
많은 문서와, 웹 상의 여러 글들에서 타입스크립트는 다음과 같은 말로 정의됩니다.
타입스크립트는 자바스크립트의 슈퍼셋(Superset = 상위집합)이다.
이는 정확히 무엇을 의미할까요?
실제로 TS는 문법적으로 JS의 상위집합입니다.
기존에 JS였던 파일의 확장자를 TS로 바꾼다고 해서 동작이 불가능하다거나 하지 않습니다. 하지만, 거꾸로 TS였던 파일들에 대해서는 JS로 확장자를 바꿀 때 모든 경우에 동작한다고 보장할 수 없습니다.
타입스크립트는 정적 타입 시스템입니다.
기존 JS의 타입 체계는 동적입니다. 이러한 JS의 특징이 갖는 문제점은 바로 런타임 에러를 잡아내는 것이 쉽지않다는 것이죠. 타입 시스템의 목표 중 하나는 이러한 런타임 에러를 사전에 찾아내는 것입니다. 타입 스크립트가 정적 타입 시스템이라는 것은 바로 이 특징을 말하는 것인데, 그렇다고 해서 타입 체커가 모든 오류를 발견해낼 것이라고 보장할 수는 없죠.
기본적으로 타입스크립트에는 **타입 추론 (Type Inference)**이라는 것이 있어, 별도로 타입을 지정해주지 않더라도, 특정 변수의 타입이 명확한 경우에는 스스로 해당 타입을 정의해나갑니다. 이 덕분에 별도의 타입 구문 없이도 쓸만하지만, 직접 타입 구문을 추가해나간다면 훨씬 더 많은 오류를 사전에 잡아내고, 또 코드의 "의도"에 대해 타입스크립트에게 더 잘 전달해줄 수 있습니다.
'얼마나 엄격하게 작성할 것인가?'는 온전히 개발자의 몫입니다.
확장자가 .ts
인 파일을 작성하더라도, 편의를 위해 any
타입을 반복적으로 사용하고, 그저 JS를 쓰듯이 문법을 작성해나간다면, 굳이 TS를 쓸 이유가 없습니다.
거꾸로 완전 엄격한 형태로 TS를 작성하고자 하는 것도 쉬운 일이 아니죠. 그만큼 작성해야 할 타입 구문의 양이 많아지게 됩니다.
결국 얼마나 엄격한 형태로 개발을 해나갈 것이냐에 대한 문제는 온전히 취향의 차이이며, 우열을 가릴 수도 없는 문제라고 할 수 있습니다.
TS 설정 이해하기
아래의 코드는 오류 없이 타입 체커를 통과할 수 있을까요?
function add(a, b) {
return a + b;
}
add(10, null);
사실, 이건 설정이 어떻게 되어있으냐에 따라서 정답이 달라지는 문제입니다. TS 컴파일러는 무수한 설정을 갖고있습니다. 이는 CLI를 통해서, 또는 tsconfig.json
파일을 통해서 이루어질 수 있죠.
최초의 설정파일은 아래 CLI 명령으로 간단하게 생성할 수 있습니다.
tsc --init
여기서는 100개가 넘는 TS 컴파일러의 설정들을 모두 짚고 넘어가지는 않을 겁니다. 대부분은 어디서 소스파일을 찾을지, 어떤 종류의 출력을 생성할지에 대해 제어하는 내용입니다.
반면, 언어 자체의 핵심 요소들을 제어하는 설정도 있는데, 대부분의 언어에서는 이를 허용하지 않는 고수준 설계의 설정입니다. 이를 어떻게 설정하느냐에 따라 완전히 다른 언어처럼 느낄 수도 있죠.
설정을 제대로 이해하려면 noImplicitAny
와 strictNullChecks
설정에 대해 이해해야 합니다.
noImplicitAny
noImplicitAny
는 변수들이 미리 정의된 타입을 가져야 하는지에 대한 여부를 제어합니다. 즉, 이를 설정할 경우 모든 변수들에 대해 직접 지정해주지 않는 한, any
타입을 허용하지 않습니다.
가급적 해당 설정은 기본적으로 가져가는 것이 좋습니다. 타입스크립트는 타입 정보를 가질 때 가장 효과적이기 때문이죠. 한 가지 예외라면, 기존에 JS로 작성되어 있던 프로젝트를 TS로 마이그레이션 해나가는 과정에서는 필요할 수도 있죠.
이 부분에 대해서는 추후에 다시 다뤄보도록 하겠습니다.
strictNullChecks
strictNullChecks
는 null
과 undefined
가 모든 타입에서 허용되는지 확인하는 설정입니다. 이 경우, string | null
과 같이 명시적으로 해당 변수가 null
타입이 될 수 있음을 알려주지 않으면, 에러가 발생합니다.
이러한 경우, 다음과 같은 Null Checking이나 Assertion이 필요하게 됩니다.
// Null Check
if (el) {
el.textContent = 'Ready';
}
// Type Assertion
el!.textContent = 'Ready';
해당 설정은 null
과 undefined
에 관련된 오류를 잡아 내는 데에 많은 도움을 주지만, 코드 작성이 비교적 어려워집니다. 프로젝트를 처음 생성한다면 이를 설정하는 것이 좋지만, JS 코드를 마이그레이션 해나가는 과정이라면 설정하지 않아도 괜찮습니다.
strictNullCheck
를 설정하려면 noImplicitAny
를 먼저 설정해야 합니다.
해당 설정이 필요한 이유는, 이것이 없을 경우 "undefined
가 객체가 아닙니다"라는 끔찍한 런타임 오류를 매번 마주할 수 있기 때문입니다. 프로젝트가 커질 수록 이러한 부분들이 훨씬 까다로워지기 때문에, 가능한 초기에 설정하는 것이 좋습니다.
그 밖에 언어에 의미적으로 영향을 미치는 설정(noImplicitThis
, strictFunctionTypes
)이 많지만, 앞의 두 설정만큼이나 중요한 것은 없습니다. 이 모든 타입 체크들을 설정하여 엄격한 환경 내에서 개발을 하고싶다면, strict
를 설정하면 됩니다. 이 경우 대부분의 에러를 잡아냅니다.
코드 생성과 타입이 관계없음을 이해하기
큰 그림에서, 타입스크립트 컴파일러는 다음의 두 가지 역할을 수행합니다.
- 최신 TS/JS를 브라우저에서 동작할 수 있도록 구버전의 JS로 트랜스파일합니다.
- 코드의 타입 에러를 체크합니다.
여기서 놀라운 점은, 위의 두가지는 완벽히 별개의 일이라는 겁니다. 즉 어느 한쪽이 제대로 이루어지지 않더라도 다른 한쪽이 이루어지는데는 문제가 없습니다.
타입 에러가 있어도 컴파일이 가능합니다
타입 체크와 컴파일이 동시에 이루어지는 자바나 C 같은 언어에서는 이것이 굉장히 황당할 겁니다. TS에서의 타입 에러는 C나 자바에서의 경고(WARNING)에 가깝습니다. 즉, 문제가 될 부분을 알려주지만, 그렇다고 해서 빌드하는 것을 멈추지는 않습니다.
이런 부분 떄문에, 얼핏 TS가 엉성한 언어처럼 보일 수 있지만, 오히려 이런 특징은 도움이 됩니다. TS는 타입 에러가 발생하더라도 여전히 컴파일링을 진행할 수 있기 때문에, 해당 부분 외의 애플리케이션은 여전히 테스트할 수 있는 상태가 됩니다.
만약, 에러가 발생했을 때 컴파일을 진행하지 않고자 한다면, noEmitOnError
를 설정해주면 됩니다.
런타임에는 타입 체크가 불가능합니다
자바스크립트로 컴파일되는 과정을 거치게 되면, 그 과정에서 모든 인터페이스, 타입, 그 외의 타입 구문들은 모두 제거됩니다.
만약 런타임 시에도 타입 정보를 유지하고자 한다면 몇 가지 방법이 있습니다.
1. "태그" 기법
interface Square {
kind: 'square';
width: number;
}
interface Rectangle {
kind: 'rectangle';
width: number;
height: number;
}
type Shape = Square | Rectangle;
위와 같이 인터페이스에 kind
값을 지정해서 런타임에서도 타입 정보를 손쉽게 유지할 수 있습니다. 런타임 시에 객체의 kind
가 어떤 값인지를 체크하는 방식으로 이를 활용할 수 있죠. 이는 타입스크립트에서 실제로 흔하게 볼 수 있는 기법입니다.
2. 클래스
class Square {
constructor(public width: number) {}
}
class Rectangle extends Square {
constructor(public width: number, public height: number) {
super(width);
}
}
type Shape = Square | Rectangle
클래스를 사용한다면 타입(런타임 접근 불가)와 값(런타임 접근 가능)을 동시에 사용할 수 있습니다. 위와 같이 선언된 클래스는 타입과 값 모두로 사용할 수 있게되므로 shape instanceof Rectangle
과 같은 형태로 런타임 타입 체크를 할 수 있습니다.
타입 연산은 런타임에 영향을 주지 않습니다
value as number
와 같은 타입 연산은 실제로 컴파일된 이후의 런타임에는 아무런 역할도 하지 않습니다. 단순히 타입 체커에게 해당 value
를 어떤 타입으로 고려하라고 알려줄 뿐입니다.
런타임 타입은 선언된 타입과 다를 수 있습니다
타입스크립트에서는 런타임 타입과 선언된 타입이 매치되지 않는 상황이 생길 수도 있습니다. 이러한 상황은 가능한 피하는게 좋지만요.
타입스크립트 타입으로는 함수를 오버로드할 수 없습니다
타입스크립트에도 함수 오버로딩 기능이 있긴 하지만, 그것은 온전히 타입 수준에서 동작하는 것입니다. 실제로 아래의 예시는 오직 하나의 함수만을 생성하죠.
function add(a: number, b: number): number;
function add(a: string, b: string): string;
타입스크립트 타입은 런타임 성능에 영향을 주지 않습니다
여러 타입 구문은 JS로 변환되면서 전부 제거되기 떄문에, 실제 런타임의 성능에는 아무런 영향도 주지 않습니다. 따라서 타입스크립트의 정적 타입은 비용이 전혀 들지 않죠.
대신, 타입스크립트 컴파일러는 "빌드타임" 오버헤드가 있습니다. 다만 기본적으로 TS 컴파일러는 상당히 빠른 편이며, 특히 증분(Incremental) 빌드 시에 더욱 두드러집니다. 너무 오버헤드가 커진다면, 빌드 도구에서 트랜스파일만 진행(transpile only)하도록 설정하여 타입 체크를 건너뛸 수 있습니다.
타입스크립트가 컴파일하는 코드는 호환성을 높이고 성능 오버헤드를 감안할지, 아니면 호환성을 포기하고 성능 중심의 네이티브 구현체를 선택할지의 문제에 맞닥뜨릴 수도 있습니다. 어떤 경우든지 이러한 호환성과 성능 간의 선택은 컴파일 타깃과 언어 레벨의 문제이며, 여전히 타입과는 전혀 무관합니다.
구조적 타이핑에 익숙해지기
JS는 본질적으로 덕 타이핑(Duck Typing) 기반입니다. 어떤 함수의 매개변수 값이 모두 제대로 주어진다면, 그 값이 어떻게 만들어졌는지 신경 쓰지 않고 사용합니다.
TS도 이러한 특징을 그대로 고려하고 있습니다.
interface Vector2D {
x: number;
y: number;
}
interface NamedVector {
name: string;
x: number;
y: number;
}
function calculateLength(v: Vector2D) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
이와 같이 정의를 한 상황에서, 아래와 같이 작성을 하더라도 아무런 문제가 없습니다.
const v: NamedVector = { x: 3, y: 4, name: 'Zee' };
calculateLength(v); // 5
여기서 유의할 점은, NamedVector
와 Vector2D
의 관계에 대해서는 전혀 선언한 바가 없다는 것입니다. 기본적으로 TS의 타입 시스템은 JS의 런타임 동작을 모델링합니다. NamedVector
의 구조가 Vector2D
와 호환되기 때문에, 위의 코드는 정상으로 간주됩니다.
이렇듯 JS의 덕 타이핑을 모델링하기 위해 TS가 활용하는 타이핑 체계를 **구조적 타이핑(Structural Typing)**이라고 합니다.
이러한 특징이 오히려 문제를 일으키는 경우도 있을 수 있습니다. 하지만 이것이 좋든, 싫든 간에 TS에서 모든 타입은 열려(Open)있고, 봉인(Sealed)되어 있지 않습니다.
다만, 이러한 특징은 테스트에서는 오히려 도움이 됩니다. 특정 함수가 받는 매개변수의 인터페이스 규격에 맞기만 한다면, 이를 작성하고 테스트하는데 문제가 없기 때문이죠.
any 타입 지양하기
타입스크립트의 타입 시스템은 다음과 같습니다.
- 점진적(Gradual) - 프로젝트 전체에 타입을 추가할 필요 없이, 일부에 대해서만 사용할 수 있습니다.
- 선택적(Optional) - 언제든지 타입 체커를 해제할 수 있습니다.
위 기능들의 핵심은 any
타입입니다.
타입스크립트가 발견해내는 대부분의 오류들은 사실 any
타입을 사용한다면 거의 대부분 넘어갈 수 있습니다.
하지만, 일부 특별한 경우를 제외하고는 any
타입의 사용은 TS의 수많은 장점을 누릴 수 없게 만듭니다.
any 타입에는 타입 안전성이 없습니다
let age: number;
age = "12" as any; // 문제 없음
분명 age
는 number
타입이지만, any
타입을 통해 string
을 할당했습니다. 이 경우 타입을 지정한 의미 자체가 없어진 셈입니다.
any는 함수 시그니처를 무시해버립니다
함수 작성 시에는 시그니처를 명시해야 합니다. 즉, 함수의 호출과 출력에는 각각 약속된 타입이 정해져있어야 합니다. any
는 이 자체를 무시합니다. JS에서는 암묵적인 Type Coercion이 빈번하게 일어나기 때문에, 이런 상황에서 특히 문제가 될 수 있습니다.
any 타입은 IDE 상의 피드백을 받을 수 없습니다
기본적으로 적절한 타입을 지정해준다면 에디터는 상황에 따라 적절한 자동완성 기능과 도움말을 제공합니다. 그런데 any
타입의 사용은 이러한 피드백을 전혀 받아볼 수 없게 만듭니다.
any 타입은 코드 리팩토링 시 버그를 감춥니다
리팩토링 시 적절한 타입의 유추가 어렵다는 이유로 any
를 사용하게 되면, 리팩토링을 진행하는 도중에 발견해야할 에러를 알아내기 어렵습니다. 리팩토링에 앞서 구체적인 타입의 지정이 요구되는 이유입니다.
any는 타입 설계를 감춰버립니다
애플리케이션의 상태 객체의 정의는 상당히 복잡합니다. 상태 객체 안의 수많은 프로퍼티 타입을 일일이 작성해야 하는데, 이는 사실 any
하나로 뚝딱 해결해버릴 수도 있습니다.
물론 이 때도 any
를 사용해선 안 됩니다. 상태 객체가 어떻게 구성되어 있는지에 대한 인터페이스 자체를 감춰버리기 때문이죠.
any는 타입 시스템의 신뢰도를 떨어뜨립니다.
사람은 누구나 실수를 합니다. 그런 상황에서 타입스크립트를 도입한다는 것은 곧 실수를 줄이기 위하여 신뢰할 만한 타입 체커를 구축해나간다는 것입니다.
헌데, any
타입의 사용은 이러한 타입 시스템 자체를 신뢰할 수 없게 만들어, TS를 쓰는 의미 자체를 잃어버리게 만듭니다.
코드에 존재하는 수많은 any
타입은 오히려 JS보다도 개발을 어렵게 만들 수 있습니다.
다만, 어쩔 수 없이 any
를 써야만 하는 상황도 있습니다. 이에 대해서는 추후에 다뤄보도록 합시다.
타입스크립트의 타입 시스템
타입스크립트의 가장 중요한 역할은 타입 시스템에 있습니다. 해당 챕터에서는 타입 시스템의 기초를 살펴봅니다.
- 타입 시스템이란 무엇인지?
- 어떻게 사용해야 하는지?
- 무엇을 결정해야 하는지?
- 가급적 사용하지 말아야 할 기능은 무엇인지?
편집기를 사용하여 타입 시스템 탐색하기
타입스크립트를 설치하고 나면 다음의 두 가지를 실행할 수 있습니다.
- 타입스크립트 컴파일러(
tsc
) : 일반적으로 사용하는 것 - 타입스크립트 서버(
tsserver
) : 백그라운드 상에서 타입스크립트 컴파일러를 동작시킬 수 있는 일종의 툴- 여기에 따르면, VS Code는 자체적으로
tsserver
를 통한 TypeScript의 언어 서비스를 지원하고 있습니다.
- 여기에 따르면, VS Code는 자체적으로
우리는 타입스크립트의 "언어 서비스"를 사용할 수 있는데, 이는 보통 에디터를 통해서 이루어지며, 별도로 타입스크립트 서버를 구축해서 이를 제공할 수도 있습니다. "언어 서비스"에는 코드 자동완성, 명세(사양, Specification) 검사, 검색, 리팩토링이 포함됩니다.
타입스크립트를 제대로 활용하기 위해서는 이러한 언어 서비스를 적극적으로 활용하는 것이 좋습니다. VS Code 상에서 이를 활용하는 방법에 있어서는 여기를 살펴봅시다.
에디터를 통하여 타입스크립트의 타입 시스템에 익숙해지기 위해 다음과 같은 방법들을 활용하는 것이 좋습니다.
- 변수 위에 마우스 커서를 대면 TS가 해당 타입을 어떻게 판단하고 있는지 확인할 수 있습니다.
- 에디터 상에서 발생하는 타입 에러를 살펴볼 수 있습니다.
- TS가 동작을 어떻게 모델링하는지 파악하기 위해선 타입 선언 파일을 찾아보는 것이 좋습니다.
타입이 값들의 집합이라고 생각하기
타입스크립트에서의 타입은 할당 가능한 값들의 집합이라고 생각하면 이해가 쉽습니다.
never
가장 작은 집합은 아무 값도 포함하지 않는 공집합이며, TS 상에서는 never
가 됩니다. 여기에는 아무런 값도 할당할 수 없습니다.
const x: never = 12; // ERROR: '12' 형식은 never 타입에 할당할 수 없습니다.
literal
그 다음 작은 집합은 한 가지 값만 포함하는 타입입니다. 이들은 TS 상에서 유닛(unit) 타입이라고도 불리는 리터럴(literal) 타입입니다.
type A = 'A';
type B = 'B';
type Twelve = 12;
union
가능한 타입을 여러 개로 묶은 것을 유니온(union) |
타입이라고 합니다.
type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;
intersection
인터섹션(intersection) &
타입은 각 인터페이스에 해당하는 모든 프로퍼티를 갖고 있어야 함을 의미합니다.
interface Person {
name: string;
}
interface Lifespan {
birth: Date;
death?: Date;
}
type PersonSpan = Person & Lifespan;
extends
다만 좀 더 일반적으로는 extends
키워드를 사용합니다. 타입은 일종의 집합이라는 관점에서, extends
는 곧 ~의 부분집합 이라는 의미로 이해할 수 있습니다.
interface Person {
name: string;
}
interface PersonSpan extends Person {
birth: Date;
death?: Date;
}
extends
키워드를 제네릭 타입에서 한정자로 쓰이기도 하는데, 이 때도 ~의 부분집합이라는 의미가 됩니다.
// 제네릭 타입 K는 string의 부분집합에 해당해야 합니다.
function getKey<K extends string>(val: any, key: K) {
// ...
}
getKey({}, 'x'); // 정상
getKey({}, 12); // 12는 number이기 때문에 에러
타입스크립트에서의 타입은 상속보다는 집합으로 이해하는 것이 편합니다.
결국, "TS 상에서 어떤 값을 할당할 수 있느냐?"라는 것은 해당 변수가 요구하는 타입 집합에 할당하고자 하는 값의 타입 집합이 부분 집합으로 속하느냐를 판단하는 것입니다. 이건 앞선 아이템4인 "구조적 타이핑"에서 설명했던 바와 유사합니다. 타입스크립트의 타입은 엄격한 상속 관계가 아니라, 겹쳐지는 집합의 형태로 표현될 수 있습니다.
집합의 관점에서 타입 시스템들을 이해한다면 아래와 같은 코드들도 쉽게 이해하고 작성할 수 있을겁니다.
interface Point {
x: number;
y: number;
}
type PointKeys = keyof Point; // 'x' | 'y'
// 제네릭 타입을 사용한 함수의 경우, 이를 사용하는 시점에야 구체적인 타입이 확정됩니다.
function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
// ...
}
const points = [{x: 1, y: 1}, {x: 2, y: -2}];
// type T = { x: number; y: number };
// type K = 'x' | 'y';
sortBy(points, 'y');
타입 공간과 값 공간의 심벌 구분하기
타입스크립트의 심벌(symbol)은 타입 공간이나 값 공간 중 한 곳에 존재합니다. 이름이 같은 심벌이더라도 속하는 공간에 따라 서로 다른 것을 의미할 수 있기 때문에 혼란스러울 수 있습니다.
interface Cylinder {
radius: number;
height: number;
}
const Cylinder = (radius: number, height: number) => ({radius, height});
작성한 코드에 따라, 이후 상황에 따라서 Cylinder
란 심벌은 값으로 쓰일 수도, 타입으로 쓰일 수도 있습니다.
이런 경우, 추후에 혼란을 일으킬 여지가 많습니다.
초기에 타입 공간과 값 공간, 각각에 대한 개념을 잡고자 한다면 TS Playground를 활용해보세요. TS가 JS로 컴파일링된 이후에도 심벌이 남아있다면 값일테고, 그렇지 않다면 타입일 겁니다.
타입과 값 구분하기
TS 코드 상에서 타입과 값 심벌은 번갈아 나올 수 있습니다.
일반적으로 타입선언(:
) 또는 단언문(as
) 다음 나오는 심벌은 타입인 반면, =
다음 나오는 모든 심벌은 값이 됩니다.
타입과 값 모두로 사용될 수 있는 경우
class
한편, class
는 타입과 값 모두로 사용될 수 있습니다.
class Cylinder {
radius = 1;
height = 1;
}
// 여기서 Cylinder는 인터페이스로 사용되었습니다.
interface NamedCylinder extends Cylinder {
name: string;
}
const namedCylinder: NamedCylinder = {
radius: 1,
height: 2,
name: 'alan',
}
// 여기서 Cylinder는 생성자로 사용되었습니다.
const cylinder = new Cylinder();
클래스는 타입으로 쓰일 때 인터페이스로 사용되는 반면, 값으로 쓰일 때 생성자로 사용됩니다.
typeof
연산자 typeof
도 타입과 값 모두에서 사용될 수 있는데, 이는 비슷하면서도 상당히 다릅니다.
- 타입의 관점에서
typeof
는 TS 상에서의 타입을 반환합니다. - 값의 관점에서
typeof
는 JS 런타임의 연산자가 됩니다.
위쪽의 예시의 연장선을 통해 이를 살펴보면 아래와 같습니다.
const v = typeof Cylinder; // v는 'function' string value입니다.
type T = typeof Cylinder; // T는 Cylinder 생성자 함수의 Type입니다.
여기서 흥미로운 것은, 아래에 있는 타입 관점의 typeof Cylinder
는 인스턴스의 타입이 아닌, 생성자 함수의 타입이 된다는 점입니다.
만약 이것을 인스턴스 타입으로 활용하고자 한다면 아래와 같이 전환해야 합니다.
type T = typeof InstanceType<typeof Cylindar>;
타입 프로퍼티 접근자 []
프로퍼티 접근자인 []
는 타입으로 쓰일 때에도 동일하게 동작합니다.
하지만, obj['field']
와 obj.field
는 값이 동일하더라도 다른 타입을 가질 수 있기 때문에, 타입의 프로퍼티를 얻고자 한다면 반드시 첫 번째 방법을 사용해야 합니다.
const myName: NamedCylinder['name'] = 'alan';
이에 대한 내용은 아이템 14에서 더 자세히 다룹니다.
그 외에 두 공간 사이에서 다른 의미를 가지는 코드 패턴
this
- 값으로 쓰일 때는 JS의
this
키워드 - 타입으로 쓰일 때는 다형성 this라고 불리는 TS 타입. 서브클래스의 메서드 체인을 구현할 때 유용합니다.
- 값으로 쓰일 때는 JS의
&
,|
- 값으로 쓰일 때는 AND와 OR 비트연산
- 타입으로 쓰일 때는 intersection과 union입니다.
const
const
는 새 변수를 선언하는 키워드이지만,as const
는 리터럴 또는 리터럴 표현식의 추론된 타입을 바꿉니다.
extends
- JS에서 그렇듯 서브클래스를 정의하는 데 사용되거나
- TS 상에서 서브타입(
interface A extends B
) 또는 제너릭 타입의 한정자(Generic<T extends number>
)를 정의할 수 있습니다.
이렇듯, 타입 공간과 값 공간에서 동일한 키워드로 사용되는 심벌들이 여럿 존재합니다. 그렇기 때문에 TS 코드가 본인이 의도한 대로 동작하지 않는다면, 타입 공간과 값 공간을 혼동하여 잘못 작성했을 가능성이 큽니다.
타입 단언보다는 타입 선언을 사용하기
타입스크립트에서 변수에 값을 할당하고 타입을 부여하는 방법은 두 가지 입니다.
interface Person { name: string };
const alice: Person = { name: 'Alice' }; // Type Declaration -> 타입 선언
const bob = { name: 'Bob' } as Person; // Type Assertion -> 타입 단언
위의 둘은 비슷하면서도 다릅니다.
결론부터 말하자면, 우리는 Assertion 보다는 Declaration을 사용하는 편이 좋습니다. Assertion은 타입을 강제로 지정하여 타입 체커가 이로부터 비롯된 오류를 무시하게끔 만들기 때문입니다. 대부분의 상황에서는 안전성 체크까지 되는 Declaration을 사용하는 것이 맞습니다.
언제 Assertion을 쓸까요?
Assertion은 타입 체커가 추론한 타입보다 우리가 생각하는 타입이 더 정확하다고 판단되는 경우에 사용되어야 합니다.
가령, 다음과 같은 상황입니다.
document.querySelector('#myButton').addEventListener('click', e => {
e.currentTarget; // EventTarget
const button = e.currentTarget as HTMLButtonElement;
button; // HTMLButtonElement
})
TS는 DOM에 접근할 수 없기 떄문에, #myButton
id를 가진 요소가 버튼에 해당할 것이라는 것을 예상하지 못합니다.
이러한 상황에서는 직접 Assertion을 통해 타입을 지정해주는 것이 필요합니다.
이와 유사한 것으로, !
를 통한 Assertion도 활용할 수 있습니다.
const elNull = document.getElementById('foo'); // HTMLElement | null
const el = document.getElementById('foo')!; // HTMLElement
!
는 null
이 아님을 확신하는 단언문입니다.
이 역시 특정 상황에서 해당 값은 null
이 아니라고 확신을 할 수 있을 때 사용하여야 합니다.
덧붙여, Assertion은 단언하고자 하는 타입이 타입체커가 추론한 타입의 서브타입에 해당하지 않는다면 사용할 수 없습니다.
이를 무시하고 타입을 변환하고자 한다면 unknown
타입을 활용하면 됩니다.
interface Person { name: string; }
const body = document.body;
const el = body as unknown as Person;
unknown
은 모든 타입의 서브타입이기 때문에, 어떤 타입으로도 변환할 수 있습니다.
하지만 주의해야 합니다. unknown
타입을 사용한 이상 무엇인가 위험한 동작을 하고 있다는 뜻이니까요.
객체 래퍼 타입 피하기
JS에는 객체 외에 일곱가지 기본형 값(Primitives)들이 있습니다.
string
number
boolean
null
undefined
symbol
bigint
->number
의 최대치인 2^53-1보다 큰 값을 표현할 때 사용
기본형 값들의 특징
- 불변(immutable)합니다.
- 메서드를 가지지 않습니다.
이를 듣고 봤을 때, 언뜻 아래 코드는 앞서 설명한 바와 말이 다른 것처럼 보입니다.
'primitive'.charAt(3) // m
사실, charAt
은 string
의 메서드가 아닙니다.
Primitive에 해당하는 string
에는 메서드가 없는 대신, 이 string
과 관련된 메서드를 지닌 String
객체 타입이 별도로 정의되어 있습니다.
여러모로 서로 간에 깊이 연관된 탓에, string
과 String
을 언뜻 동일한 것으로 이해하기 쉽지만, 둘 사이에는 분명한 차이가 있습니다.
대부분의 경우 사실 래퍼 객체들은 직접 사용할 일이 드뭅니다. 우리가 직접 사용하지 않더라도 내부적으로 필요한 경우에 사용되기 때문입니다.
이를테면 위에서 string
에 charAt
을 쓴 경우, 우리가 보지 못하는 다음의 일들이 일어납니다.
- 기본값인
string
을String
객체로 래핑합니다. - 이후 래핑한
String
객체에서 메서드를 호출합니다. - 그리고나서 래핑한 객체를 버립니다.
이러한 과정을 거치는 탓에, 아래와 같이 이상한 코드를 작성했을 때 에러는 발생하지 않지만, 그렇다고 의도대로 동작하지도 않습니다.
const x = 'hello';
x.language = 'English'
console.log(x.language) // undefined
래퍼 객체는 오직 자기 자신하고만 동일합니다.
그렇기 때문에 아래의 코드는 틀린 내용입니다.
'hello' === new String('hello') // false
new String('hello') === new String('hello') // false
주의 : 단,
new
없이 호출된 객체 래퍼들은 기본형을 생성합니다.
String('abc') === 'abc' // true
BigInt(1) === BigInt(1) // true
래퍼 객체 타입보다는 기본형 타입을 사용하세요.
- string / String
- number / Number
- boolean / Boolean
- symbol / Symbol
- bigint / BigInt
위처럼 TS에서도 기본형과 객체 래퍼 타입은 별도로 모델링되며, 엄연히 다른 타입입니다.
TS에서 타입을 다룰 때는 모든 경우에 기본형 타입을 사용하는 것이 옳습니다. 기본형 -> 래퍼 객체
는 할당이 가능하지만, 래퍼 객체 -> 기본형
은 할당이 불가능하기 때문입니다.
이 탓에 에러가 발생할 가능성이 높습니다.
잉여 속성 체크의 한계 인지하기
잉여 속성 체크 (Excess property checking)
타입이 명시된 변수에 객체 리터럴을 할당할 때, TS는 해당 타입의 속성이 제대로 존재하는지, 그리고 그 외의 속성은 없는지 확인합니다. 이 그 외의 속성이 없는지 판단하는 과정을 **잉여 속성 체크(Excess property checking)**라고 합니다.
interface Room {
numDoors: number;
ceilingHeightFn: number;
}
const r: Room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present', // 에러 발생
}
잉여 속성 체크는 객체 리터럴에서만 발생합니다.
여기서 포인트는 객체 리터럴입니다. 다른 임시 변수를 통해 우회적으로 할당을 하는 경우에 이는 문제가 발생하지 않습니다.
const obj = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present',
}
const room: Room = obj; // 문제 없음
여기서 우리가 앞서 살펴봤던 덕 타이핑과 이에 따른 TS의 구조적 타이핑 체계를 떠올려봅시다. 그러한 관점에서 생각해보면, 앞선 코드에서 문제가 발생하지 않는 것이 이상한 일은 아닙니다.
결국 중요한 포인트는, 일반적인 구조적 할당 가능성 체크와 잉여 속성 체크는 엄연히 별도의 과정으로써 동작한다는 점입니다. 그리고, 잉여 속성 체크는 객체 리터럴을 통해 할당 또는 함수에 값을 넘겨줄 경우에 발생한다는 것을 기억해야 합니다.
잉여 속성 체크는 단언문(Assertion)에서는 발생하지 않습니다.
우리가 단언문(Assertion)보다는 선언문(Declaration)을 우선시 해야하는 단적인 이유 중 하나입니다. 객체 리터럴을 사용하더라도, 단언을 이용한 경우에는 잉여 속성 체크가 일어나지 않습니다.
const room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present',
} as Room; // 문제 없음
잉여 속성 체크를 원치 않는다면 인덱스 시그니처를 사용하세요.
잉여 속성 체크를 원치 않는 상황, 다시 말해 추가적인 속성을 가질 수 있다고 판단되는 경우에는 인덱스 시그니처를 사용하면 됩니다. 이 인덱스 시그니처에 대해서는 아이템15에서 상세하게 다루겁니다.
interface Options {
darkMode?: boolean;
[others: string]: unknown;
}
const options: Options = { darkmode: true }; // 문제 없음
약한 타입에 대한 공통 속성 체크
선택적 속성만 가지는 약한(weak) 타입에도 유사한 체크가 동작하는데, 이는 잉여 속성이 아닌 공통 속성이 있는지를 확인한다는 점에서 조금 다릅니다.
interface LineChartOptions {
logscale?: boolean;
inverteedYAxis?: boolean;
areaChart?: boolean;
}
const opts = { logScale: true };
const options: LineChartOptions = opts; // 에러 발생
약한 타입에 대한 공통 속성 체크는 값과 타입 간에 공통된 속성이 있는지에 대해 확인하는 과정입니다. 그러나 잉여 속성 체크와는 다르게, 약한 타입과 관련된 할당마다 수행된다는 차이가 있습니다.
함수 표현식에 타입 적용하기
JS 및 TS에서는 다음의 방법들로 함수를 나타낼 수 있습니다. 이는 큰 관점에서 Statement냐 Expression이냐로 나뉩니다.
function rollDice1(sides: number): number { ... } // Statement
const rollDice2 = (sides: number): number => { ... } // Expression
const rollDice3 = function(sides: number): number { ... } // Expression
TS에서는 함수 표현식(Function Expression)을 사용하세요.
결론부터 말하면, TS에서는 함수 표현식을 사용하는 것이 좋습니다. 함수 전체를 하나의 함수 타입으로 선언하여 여러 곳에 재사용할수 있다는 장점이 있기 때문입니다.
type DiceRollFn = (sides: number) => number;
const rollDice: DiceRollFn = sides => { ... };
위의 예시가 짧아서 장점이 와닿지 않는다면 다음의 코드도 참고하세요.
type BinaryFn = (a: number, b: number) => number;
const add: BinaryFn = (a, b) => a + b;
const sub: BinaryFn = (a, b) => a - b;
const mul: BinaryFn = (a, b) => a * b;
const div: BinaryFn = (a, b) => a / b;
함수 타입에도 typeof를 사용할 수 있습니다.
함수 타입에도 typeof
를 사용할 수 있는데, 이는 기존에 이미 작성되어 있던 네이티브 및 라이브러리 함수들의 타입을 재차 활용하고자 할 때 유용합니다.
아래 예시는 네이티브 fetch
함수에 대한 에러 핸들링을 추가하는 예시입니다.
const checkedFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error('Request failed: ' + response.status);
}
return response;
}
타입과 인터페이스의 차이점 알기
타입스크립트에서는 명명된 타입(named type)을 정의하기 위해 타입 별칭(Type Alias, 이하 타입)과 인터페이스(Interface)라는 두 가지 방법을 사용할 수 있습니다.
// type
type TState = {
name: string;
capital: string;
}
// interface
interface IState {
name: string;
capital: string;
}
타입과 인터페이스의 유사점
인덱스 시그니처를 사용할 수 있습니다.
type TDict = { [key: string]: string };
interface IDict {
[key: string]: string;
}
함수 타입을 정의할 수 있습니다.
type TFn = (x: number) => string;
interface ifN {
(X: number): string;
}
JS에서의 함수는 곧 객체라는 점을 떠올려보면, 아래처럼 추가로 프로퍼티를 정의할 수도 있습니다.
type TFnWithProperties = {
(x: number): number;
prop: string;
}
interface IFnWithProperties {
(x: number): number;
prop: string;
}
제네릭을 사용할 수 있습니다.
type TPair<T> = {
first: T;
second: T;
}
interface IPair<T> {
first: T;
second: T;
}
타입과 인터페이스는 서로 간에 확장이 가능합니다.
interface IStateWithPop extends TState {
population: number;
}
type TStateWithPop = IState & { population: number; };
클래스의 구현(implements)에 사용할 수 있습니다.
class StateT implements TState {
name: string = '';
capital: string = '';
}
class StateI implements IState {
name: string = '';
capital: string = '';
}
타입과 인터페이스의 차이점
유니온(|
)은 타입으로만 표현할 수 있습니다.
type AorB = 'a' | 'b';
유니온을 인터페이스로 표현할 방법은 없습니다. 이러한 이유로 타입은 일반적으로 인터페이스보다 더 쓰임새가 많습니다.
type Input = { /* ... */ };
type Output = { /* ... */ };
interface VariableMap {
[name: string]: Input | Output;
}
type NamedVariable = (Input | Output) & { name: string };
튜플, 배열 타입은 type 키워드를 이용해야 합니다.
type Pair = [number, number];
type Stringlist = string[];
type NamedNums = [string, ...number[]];
인터페이스로도 유사하게 구현할 수는 있으나, 이 경우엔 .map
, .concat
등의 배열 메서드를 사용할 수 없게 됩니다.
interface Tuple {
0: number;
1: number;
length: 2;
}
const t: Tuple = [10, 20]; // 정상
t.concat([30, 40]); // ERROR : Property 'concat' does not exist on type 'Tuple'.
인터페이스는 보강(augment)이 가능합니다.
interface IState {
name: string;
capital: string;
}
interface IState {
population: number;
}
const wyoming: IState = {
name: 'Wyoming',
capital: 'Cheyenne',
population: 500_000,
}
위의 예제처럼 프로퍼티를 확장하는 것을 선언 병합(Declaration merging)이라고 합니다. 이는 주로 타입 선언 파일에서 사용됩니다. (일반 코드에서 쓰지 못하는 것은 아닙니다.) 다시 말해, 타입 선언 파일을 작성할 때는 선언 병합을 지원하기 위해 반드시 인터페이스를 사용해야 하며, 표준을 따라야 합니다.
결론 : 그래서 둘 중 무엇을 써야 할까요?
- 복잡한 타입의 정의가 필요한 경우 => 타입
- 보강의 가능성이 있는 경우 (ex. API) => 인터페이스
그 외에 프로젝트의 일관된 스타일을 유지하게끔 일관적으로 타입 또는 인터페이스를 사용하면 됩니다.
타입 연산과 제너릭 사용으로 반복 줄이기
같은 코드를 반복해서 작성하지 말라는 DRY(Don't Repeat Yourself) 원칙은 타입에 대해서도 유효합니다.
타입에 이름 붙이기 (Named Type)
function distanc(a: {x: number, y: number}, b: {x: number, y: number}) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
이를 별도의 이름을 가진 타입으로 쪼개어 작성하면 훨씬 보기 편해지고, 반복도 줄어듭니다.
interface Point2D {
x: number;
y: number;
}
function distance(a: Point2D, b: point2D) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
이는 함수에 있어서도 마찬가지입니다.
type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
const get: HTTPFunction = (url, opts) => { /* ... */ };
const post: HTTPFunction = (url, opts) => { /* ... */ };
확장
이미 작성된 타입을 활용한 확장도 반복을 줄이는 방법 중 하나입니다. 이 중 &
을 이용하는 방법은 유니온 타입에서 확장을 하고자 하는 경우에 특히 유용한 패턴입니다.
interface Person {
firstName: string;
lastName: string;
}
interface PersonWithBirthDate extends Person {
birth: Date;
}
// 또는
interface PersonWithBirthDate = Person & { birth: Date };
타입 인덱싱
기존에 존재하던 타입의 일부를 인덱싱으로 사용할 수도 있습니다. Pick
, Partial
등 아래서 추가로 설명할 제너릭 타입들을 사용하면 더 쉽습니다.
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
type TopNavState = {
userId: State['userId'];
pageTitle: State['pageTitle'];
recentFiles: State['recentFiles'];
};
단, 여전히 State[...]
와 같이 반복되는 코드가 남아있는 데, 이부분에서 매핑된 타입을 사용하면 더 나아집니다.
type TopNavState = {
[k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
};
유니온 타입에서 인덱싱을 사용하는 경우에도 유연하게 동작합니다.
interface SaveAction {
type: 'save';
// ...
}
interface LoadAction {
type: 'load';
// ...
}
type Action = SaveAction | LoadAction;
type ActionType = Action['type']; // Type is "save" | "load"
typeof
와 keyof
keyof
는 특정 타입이 가진 프로퍼티 key
들의 유니온을 반환합니다.
interface Options {
width: number;
height: number;
color: string;
label: string;
}
type OptionsKeys = keyof Options;
// Type is "width" | "height" | "color" | "label"
반면, typeof
는 특정 값(value)의 형태에 대한 타입들을 가져올 수 있습니다. 여기서 typeof
뒤에 오는 것은 타입이 아닌 값임에 주의하세요.
const INIT_OPTIONS = {
width: 640,
height: 480,
color: '#00FF00',
label: 'VGA',
};
type Options = typeof INIT_OPTIONS;
// interface Options {
// width: number;
// height: number;
// color: string;
// label: string;
// }
표준 라이브러리의 제너릭을 활용하세요.
제너릭은 타입의 관점에서 사용하는 함수에 가깝습니다. 정의 시점에는 해당 타입이 명확하지 않지만, 이를 사용할 때 결과 타입을 반환받아 사용할 수 있게 됩니다. TS 표준 라이브러리에서는 Utility Types라는 이름으로 여러 제너릭을 제공하고 있습니다.
Partial
Partial
는 기존 타입들의 프로퍼티를 선택적인 속성으로 만들어줍니다.
interface Todo {
title: string;
description: string;
}
type UpdateTodoFields = Partial<Todo>;
// interface UpdateTodoFields {
// title?: string | undefined;
// description?: string | undefined;
// }
Pick
Pick
은 기존에 존재하던 타입 프로퍼티의 일부만을 가져와 새로 정의할 수 있도록 해줍니다.
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;
// interface State {
// userId: string;
// pageTitle: string;
// recentFiles: string[];
// }
ReturnType
ReturnType
은 기존의 함수 타입의 반환 타입을 가져올 수 있게 해주는 제너릭입니다.
// 아래 getUserInfo는 함수 값(value)입니다.
type UserInfo = ReturnType<typeof getUserInfo>;
그 외에도 여러 유틸리티 타입들이 존재하는데, 여기선 모두 다루진 않도록 하겠습니다. 다른 유틸리티 함수에 대해서는 여기를 참조하세요.
제너릭의 매개변수를 extends
를 통해 제한하세요.
TS 함수에서 매개변수의 값을 타입을 통해 제한하는 것처럼, TS의 제너릭에 있어서도 extends
를 통해 타입을 제한할 수 있습니다.
interface Name {
first: string;
last: string;
}
type DancingDuo<T extends Name> = [T, T];
const couple1: DancingDuo<Name> = [
{first: 'Fred', last: 'Astaire'},
{first: 'Ginger', last: 'Rogers'}
]; // OK
const couple2: DancingDuo<{first: string}> = [
// ~~~~~~~~~~~~~~~
// Property 'last' is missing in type
// '{ first: string; }' but required in type 'Name'
{first: 'Sonny'},
{first: 'Cher'}
];
동적 데이터에 인덱스 시그니처 사용하기
JS의 장점을 객체 생성 문법이 간단하다는 것입니다. TS에서도 이렇게 유연한 형태로 객체 타입을 정의하고 싶다면 다음과 같이 사용하면 됩니다.
type Rocket = {[property: string]: string};
const rocket: Rocket = {
name: 'Falcon 9',
variant: 'v1.0',
thrust: '4,940 kN',
};
여기서 사용된 [property: string]: string
이 인덱스 시그니처가 되며, 다음의 세 가지 의미를 담고 있습니다.
- 키 이름(
property
): 키의 위치만 표시하며, 타입 체커에서는 실질적으로 사용하지 않습니다. - 키 타입(
string
):string
,number
또는symbol
이어야하는데, 보통은string
을 사용합니다. - 값 타입(
string
): 무엇이든 될 수 있습니다.
다만, 위와 같은 방식으로 타입 체크를 수행하는 경우 다음의 문제들이 발생합니다.
- 모든 키를 포함합니다. 즉, 의도와 다르게 키를 잘못 작성하더라도 에러가 발생하지 않습니다.
- 특정 키가 필요하지 않습니다.
{}
도 위의Rocket
타입에 유효합니다. - 키마다 다른 타입을 가질 수 없습니다. 예를 들어
thrust
는number
로도 표현될 여지가 있습니다. - TS 언어 서비스가 아무런 도움도 주지 못합니다. (자동 완성, 도움말 등..) 무엇이든 가능하기 때문입니다.
결국, 일반적인 상황에서는 인덱스 시그니처보다 그냥 인터페이스로 타입을 정의하는 것이 더 좋습니다.
interface Rocket {
name: string;
variant: string;
thrust_kN: number;
}
const falconHeavy: Rocket = {
name: 'Falcon Heavy',
variant: 'v1',
thrust_kN: 15_200
};
인덱스 시그니처는 런타임 이전에 알 수 없는 동적인 데이터를 표현할 때 사용합니다.
아래 코드는 CSV 파일의 string을 받아 여러 개의 Row들을 가진 배열을 반환하는 함수입니다. 이 시점에서 우리는 어떤 CSV 파일이 사용될지 알 수 없기 때문에, 이러한 경우에는 인덱스 시그니처를 사용해야 합니다.
// 현 시점에서 우리는 CSV 파일의 컬럼명이 무엇이 될지 알 수 없습니다.
function parseCSV(input: string): {[columnName: string]: string}[] {
const lines = input.split('\n');
const [header, ...rows] = lines;
return rows.map(rowStr => {
const row: {[columnName: string]: string} = {};
rowStr.split(',').forEach((cell, i) => {
row[header[i]] = cell;
});
return row;
});
}
물론, 모든 열의 값들에 대해서 주어지지 않을 가능성이 있습니다. 이러한 부분을 염려하여 보다 엄격한 형태로 작성하고자 한다면 다음과 같이 활용해야 합니다. 물론 그만큼 추후 타입체킹이 더 번거로워 질 수는 있습니다.
function safeParseCSV(
input: string
): {[columnName: string]: string | undefined}[] {
return parseCSV(input);
}
만약, 반환받은 값의 형태에 대해 명확히 알고 있는 경우라면 다음과 같이 Assertion을 활용하여 강제로 타입을 변환시킬 수 있습니다.
interface ProductRow {
productId: string;
name: string;
price: string;
}
let csvData: string;
const products = parseCSV(csvData) as unknown as ProductRow[];
가능한 필드가 제한적이라면 인덱스 시그니처를 쓰지 마세요.
동적인 데이터를 다루더라도, 사용될 수 있는 필드가 제한되어 있는 경우라면 인덱스 시그니처를 쓰지 말아야 합니다. 너무 광범위하기 때문입니다. 이 때는 선택적 프로퍼티(Optional Property)나 유니온 타입(|
)을 사용하는 편이 좋습니다.
interface Row1 { [column: string]: number } // 너무 광범위
interface Row2 { a: number; b?: number; c?: number; d?: number } // 최선
type Row3 =
| { a: number; }
| { a: number; b: number; }
| { a: number; b: number; c: number; }
| { a: number; b: number; c: number; d: number }; // 가장 정확하지만 번거로움
키 타입에 제한두기
어떤 객체의 키 타입을 인덱스 시그니처를 통해 모두 string
으로 정의해버리기 보다는, 더 명확하게 정의하고 사용하는 편이 좋습니다. 이에 대해 두 가지 대안을 고려해볼 수 있습니다.
Record 제너릭
Record는 키 타입에 유연성을 제공하는 제너릭 타입으로, string
의 부분 집합을 사용할 수 있습니다.
type Vec3D = Record<'x' | 'y' | 'z', number>;
// Type Vec3D = {
// x: number;
// y: number;
// z: number;
// }
Mapped Types (매핑된 타입)
매핑된 타입은 Record 제네릭과 동일하게 사용할 수 있고, 조건부 타입(?
)를 통해 키마다 별도의 타입을 사용하게 할 수도 있습니다. 조건부 타입에 대해서는 아이템 50에서 다룰 예정입니다.
type Vec3D = {[k in 'x' | 'y' | 'z']: number};
// Type Vec3D = {
// x: number;
// y: number;
// z: number;
// }
type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number};
// Type ABC = {
// a: number;
// b: string;
// c: number;
// }
number 인덱스 시그니처보다는 Array, 튜플, ArrayLike를 사용하기
JS에서 배열은 하나의 객체입니다. number
타입의 인덱스를 사용하지만, JS 내부적으로 사실 이는 문자열로 변환되어 사용된다는 특이점이 있습니다.
실제로도 배열에서의 인덱싱을 문자열로 하더라도 아무런 문제 없이 동작합니다.
const arr = ['a', 'b', 'c'];
console.log(arr[0]) // 'a'
console.log(arr['1']) // 'b'
TS의 경우, 이와 같은 코드를 작성했을 때 에러를 발생해야 한다라고 책에는 적혀있는데, 현 시점의 v4.4
이상의 TS에서는 이러한 에러가 출력되지 않습니다.
const xs = [1, 2, 3];
const x0 = xs[0]; // OK
const x1 = xs['1'];
// 책(< v4.4)에서는 아래와 같은 에러가 출력된다고 이야기하지만, v4.4 이상에서는 그렇지 않습니다.
// ~~~ Element implicitly has an 'any' type
// because index expression is not of type 'number'
function get<T>(array: T[], k: string): T {
return array[k];
// 이 경우는 현 시점에서도 에러가 발생합니다.
// ~ Element implicitly has an 'any' type
// because index expression is not of type 'number'
}
인덱스 시그니처에 number를 사용하지 마세요.
다시 본론으로 돌아와서, number
타입을 통해 인덱싱을 해야하는 상황이라면, 굳이 객체에 인덱스 시그니처를 사용하기보다, Array 또는 Tuple 타입을 사용하세요.
// 굳이 이렇게 만들지 말고
type MyArray = {
[index: number]: string;
}
const myArr: MyArray = {
1: 'a',
2: 'b',
}
// 그냥 Array나 Tuple을 쓰세요.
const arr: Array<string> = ['a', 'b'];
const tup: [string, string] = ['a', 'b'];
만약 Array 프로토타입의 프로퍼티들을 갖는 것을 원치 않는 상황이라면, ArrayLike<T>
타입을 사용하면 됩니다.
const tupleLike: ArrayLike<string> = {
0: 'A',
1: 'B',
length: 2,
};
tupleLike[0] // 'A'
변경 관련된 오류 방지를 위해 readonly 사용하기
readonly
Array 또는 Tuple 타입을 readonly
로 선언하면 다음과 같은 일이 생깁니다.
length
를 포함한 해당 배열 요소들을 참조할 수는 있지만, 추가로 작성하거나 수정할 수는 없습니다.- 배열에 변경을 가하는
pop
,push
등의 메서드를 사용할 수 없습니다. (한편map
,concat
등은 가능합니다.)
이는 함수의 매개변수 또는 새로운 값을 선언할 때 배열의 불변성(Immutability)을 명시적으로 유지하고자 하는 경우에 활용될 수 있습니다. 이 경우, 어떤 동작에서 해당 배열을 변경하려고 하는 경우 즉각적으로 피드백을 받을 수 있어 즉각적으로 대처가 가능합니다.
function arraySum(arr: readonly number[]) {
let sum = 0, num;
while ((num = arr.pop()) !== undefined) {
// ~~~ 'pop' does not exist on type 'readonly number[]'
sum += num;
}
return sum;
}
readonly는 얕게(shallow) 동작한다는 점을 유의하세요.
readonly
는 얕게 동작합니다. 다시 말해, 아래 예시와 같이 보다 깊게 위치한 배열에 대해서는 불변성을 보장할 수 없습니다.
const arrInArr: readonly string[][] = [[], ['a', 'b']];
arrInArr[1]?.pop(); // 문제 없음
인덱스 시그니처에서도 사용할 수 있습니다.
인덱스 시그니처에서도 readonly
를 사용할 수 있는데, 이 경우 객체의 프로퍼티가 변경되는 것을 방지할 수 있습니다.
let obj: {readonly [k: string]: number} = {};
// Or Readonly<{[k: string]: number}
obj.hi = 45;
// ~~ Index signature in type ... only permits reading
obj = {...obj, hi: 12}; // OK
obj = {...obj, bye: 34}; // OK
매핑된 타입을 사용하여 값을 동기화하기
매핑된 타입(Mapped Type)은 기존에 작성한 다른 타입에 기반하여 새로운 타입을 쉽게 작성할 수 있는 방법입니다. 주로 keyof
키워드와 함께 사용됩니다.
type FeatureFlags = {
darkMode: () => void;
newUserProfile: () => void;
};
type OptionsFlags<Type> = {
[Property in keyof Type]: boolean;
};
type FeatureOptions = OptionsFlags<FeatureFlags>;
// type FeatureOptions = {
// darkMode: boolean;
// newUserProfile: boolean;
// }
이는 꼭 제네릭으로 사용되어야 하는 것은 아닙니다. 인덱스 시그니처에서도 매핑된 타입을 사용할 수 있습니다.
const options: {[k in keyof FeatureFlags]: boolean} = {
darkMode: false,
newUserProfile: true,
}
타입 추론
3장에서는 타입 추론에서 발생할 수 있는 몇 가지 문제와 그 해법을 안내합니다.
추론 가능한 타입을 사용해 장황한 코드 방지하기
TS의 타입 추론은 생각보다 훨씬 정확해서, 일반적으로는 명시적인 타입 구문 자체가 필요하지 않습니다.
그렇기 때문에, 이러한 "불필요한 타입 구문"들은 가능한 줄이는 것이 좋습니다.
tslint를 사용하고 있다면, no-inferrable-types
옵션을 통해 작성된 모든 타입 구문이 정말로 필요한지에 대해 확인할 수 있습니다.
const person = {
name: 'Sojourner Truth',
born: {
where: 'Swartekill, NY',
when: 'c.1797',
},
died: {
where: 'Battle Creek, MI',
when: 'Nov. 26, 1883'
}
};
// typeof person: {
// name: string;
// born: {
// where: string;
// when: string;
// };
// died: {
// where: string;
// when: string;
// };
// }
함수와 메서드의 시그니처에는 타입 구문을 쓰세요.
타입스크립트에게 직접 타입을 명시적으로 지정해주어야 하는 경우는, 말 그대로 타입스크립트가 스스로 타입을 판단하기 어려운 경우입니다. 함수가 그 대표적인 예시가 되는데, 이상적인 TS 코드는 함수 및 메서드 시그니처에 타입 구문을 포함하지만, 그 내부의 지역 변수들에서는 타입 구문을 넣지 않습니다.
interface Product {
id: string;
name: string;
price: number;
}
function logProduct(product: Product) {
const {id, name, price} = product;
console.log(id, name, price);
}
물론 함수임에도 이러한 타입 구문이 필요하지 않은 경우도 있습니다.
매개변수에 대한 기본값을 통해 타입 추론이 가능한 경우
// `base`는 타입 추론에 따라 `number` 타입이 됩니다.
function parseNumber(str: string, base = 10) {
// ...
}
콜백함수로 넘겨짐에 따라 타입 추론이 가능한 경우
// axios.get에 넘겨지는 콜백함수는 이미 본인의 매개변수 타입을 알고 있습니다.
app.get('/health', (request, response) => {
response.send('OK');
});
타입 추론이 가능하더라도, 타입을 명시해야 하는 경우
타입이 추론 가능하더라도, 여전히 직접 타입을 명시하는게 좋은 상황이 있습니다.
객체 리터럴을 정의할 때
객체 리터럴을 정의할 때, 타입 구문이 없다면 앞선 아이템 11에서 살펴봤던 잉여 속성 체크가 동작하지 않고, 이 경우 속성에 대한 오타를 해당 시점에 잡아내지 못합니다. 이 경우, 추후 해당 객체가 직접 사용될 때 이르러서야 해당 객체를 사용한 곳에서 타입 에러가 발생하기에 혼동을 주기 쉽습니다.
interface Product {
id: string;
name: string;
price: number;
}
function logProduct(product: Product) {
const id: string = product.id;
const name: string = product.name;
const price: number = product.price;
console.log(id, name, price);
}
const furby = {
name: 'Furby',
id: 630509430963,
price: 35,
};
logProduct(furby);
// ~~~~~ Argument .. is not assignable to parameter of type 'Product'
// Types of property 'id' are incompatible
// Type 'number' is not assignable to type 'string'
함수의 반환 타입
함수의 반환 타입 역시 알아서 추론이 가능하지만, 본인이 의도한 반환 타입과 다른 경우가 발생할 수 있으므로, 이를 미리 명시하고 제때 잡아내는 것이 필요합니다. 그렇지 않다면 구현 상의 문제가 해당 함수를 사용하는 시점에서야 발견됩니다.
const cache: {[ticker: string]: number} = {};
function getQuote(ticker: string): Promise<number> {
if (ticker in cache) {
return cache[ticker];
// ~~~~~~~~~~~~~ Type 'number' is not assignable to 'Promise<number>'
}
// COMPRESS
return Promise.resolve(0);
// END
}
함수의 반환 타입을 명시해야 하는 두 번째 이유는 명명된 타입(Named Type)을 사용하기 위해서입니다. 구조 상으로는 동일하더라도, 타입이 일관적이지 않으면 당황스러울 수 있기 때문이죠.
interface Vector2D { x: number; y: number; }
function add(a: Vector2D, b: Vector2D) {
return { x: a.x + b.x, y: a.y + b.y };
}
// function add(a: Vector2D, b: Vector2D): {
// x: number;
// y: number;
// }
다른 타입에는 다른 변수 사용하기
JS는 let
을 통해 하나의 변수를 여러 타입으로 수번에 걸쳐 재할당하여 사용해도 무방합니다.
타입스크립트 상에서도 이를 시스템 상으로 막고있지는 않지만, 에러가 발생할 여지가 많습니다.
function fetchProduct(id: string) {}
function fetchProductBySerialNumber(id: number) {}
let id = "12-34-56"; // 이건 string
fetchProduct(id);
id = 123456; // number로 재할당하려니 에러가 발생
// ~~ '123456' is not assignable to type 'string'.
fetchProductBySerialNumber(id);
// ~~ Argument of type 'string' is not assignable to
// parameter of type 'number'
근본적인 이유는 값은 재할당되지만, 타입은 바뀌지 않기 때문인데, 이는 타입체커는 물론, 협업을 하는 동료에게도 혼란을 주기 쉽습니다. 기본적으로 TS에서는 하나의 타입에 대해 하나의 변수를 사용하는 것이 이상적인데, 그 이유는 다음과 같습니다.
- 서로 관련 없는 두 개의 값을 분리합니다.
- 변수명을 더 구체적으로 지을 수 있습니다.
- 타입 추론을 향상시키며, 불필요한 타입 구문을 줄일 수 있습니다..
- 타입이 더 간결해집니다. (
string | number
를 쪼개서string
,number
로 따로 쓰는 쪽을 권장) let
대신에const
변수를 선언하게 됩니다.const
로 변수를 선언하면 코드가 간결해지고, 타입 체커가 타입을 추론하기에도 좋습니다.
앞선 예시의 "재사용되는 변수"와 아래 예시의 "가려지는(shadowed) 변수"는 엄연히 다릅니다. 아래의 경우는 TS 상에서 문제없이 동작하겠지만, 여전히 다른 동료 개발자들에게 혼란을 주기 쉽습니다. 실제 이러한 이유로 별도의 린팅 규칙을 통해 스타일 규칙으로 이를 막는 개발팀도 많습니다.
function fetchProduct(id: string) {}
function fetchProductBySerialNumber(id: number) {}
const id = "12-34-56";
fetchProduct(id);
{
const id = 123456; // OK
fetchProductBySerialNumber(id); // OK
}
타입 넓히기
TS에서의 각 변수들은 정적 분석 시점에 "가능한 값"들의 집합에 해당하는 타입을 갖게 됩니다. 변수를 초기화할 때 타입을 직접 명시하지 않는 경우, 타입 체커가 스스로 타입을 결정하게 되죠. 다시 말해, 지정된 타입 값들을 바탕으로 할당 가능한 값들의 집합을 유출해야 한다는 의미로, TS에서는 이를 넓히기(widening)이라는 명칭으로 부릅니다.
타입스크립트는 명확성과 유연성 사이의 균형을 유지하려고 합니다. 그래서 별도로 타입을 명시하지 않은 경우에는, 충분히 구체적으로 타입을 추론하려 하지만, 잘못된 추론(false positive)을 할 정도로 구체적으로 수행하지는 않습니다. 가령 아래와 같은 예시를 들 수 있습니다.
interface Vector3 { x: number; y: number; z: number; }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
return vector[axis];
}
let x = 'x'; // 이 경우 `x` 변수는 string 타입이 됩니다.
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x);
// ~ Argument of type 'string' is not assignable to
// parameter of type '"x" | "y" | "z"'
위 예시에서, x
는 추후 재할당될 가능성이 있으므로, string
타입으로 추론되며, 이에 따라, 'x' | 'y' | 'z'
타입만 할당 가능한 axis
매개변수에 할당할 수 없습니다.
이를 해결할 가장 간단한 방법은 let
이 아닌 const
를 사용하는 것입니다.
x
변수는 "재할당할 수 없음"이라는 정보를 전달해줌에 따라 TS가 확신을 갖고 x
리터럴 타입으로 추론할 수 있게 됩니다.
타입 추론의 강도를 직접 제어하기
타입 추론의 강도를 직접 제어하기 위해서는 TS의 기본 동작을 재정의해야 하는데, 여기에는 세 가지 방법이 있습니다.
타입 명시
첫번째는 직접 변수의 타입을 명시해주는 방법입니다.
const v: { x: 1|3|5 } = {
x: 1, // type v.x = 1|3|5
};
추가적인 문맥 제공하기
두번째는 추가적인 문맥을 제공하는 방법입니다. 아래에서 매개변수로 넘겨지는 객체의 형태는 동일하지만, 문맥에 따라 에러의 발생 여부가 다릅니다.
type User = {
type: 'customer' | 'guest';
name: string;
age: number;
}
const printUser = (user: User) => console.log(user);
const user = {
type: 'customer', // string
name: 'alan',
age: 21,
};
printUser(user);
// Argument of type '{ type: string; name: string; age: number; }' is not assignable to parameter of type 'User'.
// Types of property 'type' are incompatible.
// Type 'string' is not assignable to type '"customer" | "guest"'.(2345)
printUser({
type: 'customer',
name: 'alan',
age: 21,
}) // 문제 없음
const 단언문 사용
const
단언문은 변수 선언에 쓰이는 let
과 const
와는 별개의 것이므로 혼동해서는 안됩니다.
as const
단언을 사용하면 TS는 최대한 좁은 타입으로 이를 추론하고자 합니다.
interface Vector3 { x: number; y: number; z: number; }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
return vector[axis];
}
const v1 = {
x: 1,
y: 2,
}; // type = { x: number; y: number; }
const v2 = {
x: 1 as const,
y: 2,
}; // type = { x: 1; y: number; }
const v3 = {
x: 1,
y: 2,
} as const; // type = { readonly x: 1; readonly y: 2; }
이를 배열을 튜플 타입으로 만들고자 할 때도 사용할 수 있습니다.
interface Vector3 { x: number; y: number; z: number; }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
return vector[axis];
}
const a1 = [1, 2, 3]; // Type is number[]
const a2 = [1, 2, 3] as const; // Type is readonly [1, 2, 3]
타입 좁히기
타입 넓히기의 반대는 타입 좁히기(Type narrowing)입니다. 타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말합니다.
null
체킹
가장 일반적인 예시는 null
체킹입니다.
TS가 문맥 상 해당 변수가 null
이 아님을 확신할 수 있을 경우, 타입에서 null
이 제거됩니다.
const el = document.getElementById('foo'); // Type is HTMLElement | null
if (el) {
el // Type is HTMLElement
el.innerHTML = 'Party Time'.blink();
} else {
el // Type is null
alert('No element #foo');
}
const el = document.getElementById('foo'); // Type is HTMLElement | null
if (!el) throw new Error('Unable to find #foo');
el; // Now type is HTMLElement
el.innerHTML = 'Party Time'.blink();
instanceof
function contains(text: string, search: string|RegExp) {
if (search instanceof RegExp) {
search // Type is RegExp
return !!search.exec(text);
}
search // Type is string
return text.includes(search);
}
프로퍼티 체크
interface A { a: number }
interface B { b: number }
function pickAB(ab: A | B) {
if ('a' in ab) {
ab // Type is A
} else {
ab // Type is B
}
ab // Type is A | B
}
내장함수 사용
function contains(text: string, terms: string|string[]) {
const termList = Array.isArray(terms) ? terms : [terms];
termList // Type is string[]
// ...
}
Tagged Union (Discriminated Union)
interface UploadEvent { type: 'upload'; filename: string; contents: string }
interface DownloadEvent { type: 'download'; filename: string; }
type AppEvent = UploadEvent | DownloadEvent;
function handleEvent(e: AppEvent) {
switch (e.type) {
case 'download':
e // Type is DownloadEvent
break;
case 'upload':
e; // Type is UploadEvent
break;
}
}
User-Defined Type Guards (사용자 정의 타입 가드)
TS가 타입을 적절히 식별하도록 하기 위해, 커스텀 함수를 직접 작성하여 타입 좁히기에 관여할 수 있습니다.
// 해당 함수가 true를 반환한다면 `el`은 HTMLInputElement 타입으로 좁혀집니다.
function isInputElement(el: HTMLElement): el is HTMLInputElement {
return 'value' in el;
}
function getElementContent(el: HTMLElement) {
if (isInputElement(el)) {
el; // Type is HTMLInputElement
return el.value;
}
el; // Type is HTMLElement
return el.textContent;
}
const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael'];
function isDefined<T>(x: T | undefined): x is T {
return x !== undefined;
}
const members = ['Janet', 'Michael'].map(
who => jackson5.find(n => n === who) // Type is (string | undefined)[]
).filter(isDefined); // Type is string[]
한꺼번에 객체 생성하기
타입스크립트의 타입은 일반적으로 변경되지 않기 때문에, 객체를 생성할 때는 속성을 하나씩 추가하기 보다는 여러 프로퍼티를 포함해 한꺼번에 생성해야 타입 추론에 유용합니다.
interface Point { x: number; y: number; }
// Don't
const pt = {};
pt.x = 3; // Property 'x' does not exist on type '{}'.
pt.y = 4; // Property 'y' does not exist on type '{}'.
// Don't
const pt: Point = {}; // Type '{}' is missing the following properties from type 'Point': x, y
pt.x = 3;
pt.y = 4;
// Do
const pt: Point = {
x: 3,
y: 4,
};
객체 전개 연산자(Spread operator) ...
를 사용하면 여러 객체들을 통해 하나의 새로운 객체를 만들어내기에 용이합니다.
interface Point { x: number; y: number; }
const pt = {x: 3, y: 4};
const id = {name: 'Pythagoras'};
const namedPoint = {...pt, ...id};
// type {
// name: string;
// x: number;
// y: number;
// }
이를 통해 별도로 타입 명시를 하지 않고도 조건부 속성을 추론하게끔 할 수도 있습니다. (아래 예시는 책에서 이야기한 것과는 다르게 의도한대로 추론됩니다.)
declare let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
const pharaoh = {
...nameTitle,
...(hasDates ? {start: -2589, end: -2566}: {}),
}
// type {
// start?: number | undefined;
// end?: number | undefined;
// name: string;
// title: string;
// }
일관성있는 별칭 사용하기
별칭(alias)을 남발해서 사용하면 제어 흐름을 분석하기 어렵습니다. TS에서도 마찬가지로 별칭을 신중하게 사용해야합니다. 그래야 코드를 잘 이해할 수 있고, 오류도 쉽게 찾을 수 있기 때문입니다.
interface Coordinate {
x: number;
y: number;
}
interface BoundingBox {
x: [number, number];
y: [number, number];
}
interface Polygon {
exterior: Coordinate[];
holes: Coordinate[][];
bbox?: BoundingBox;
}
위와 같은 자료 구조가 있고, 이에 대해 아래와 같은 함수가 있다고 가정합시다. 현 시점에서 이는 타입에러도 없고, 잘 동작하지만 코드가 반복되는 부분이 존재합니다.
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
if (polygon.bbox) {
if (pt.x < polygon.bbox.x[0] || pt.x > polygon.bbox.x[1] ||
pt.y < polygon.bbox.y[1] || pt.y > polygon.bbox.y[1]) {
return false;
}
}
// ... more complex check
}
여기서 중복되는 부분들을 없애기 위해 별도로 box
라는 이름의 별칭으로 polygon.bbox
를 참조하도록 하는 방법을 사용할 수 있습니다.
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const box = polygon.bbox;
if (box) {
if (pt.x < box.x[0] || pt.x > box.x[1] ||
pt.y < box.y[1] || pt.y > box.y[1]) { // OK
return false;
}
}
// ...
}
사실 제일 이상적인 방법은 Destructuring(비구조화) 문법을 통해 bbox
라는 일관된 이름을 사용하도록 하는 것입니다. 이를 적용하면 아래와 같아집니다.
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
// Destructuring을 통해 일관된 이름을 사용할 수 있도록 합니다.
const { bbox } = polygon;
if (bbox) {
const { x, y } = bbox;
if (pt.x < x[0] || pt.x > x[1] ||
pt.y < x[0] || pt.y > y[1]) {
return false;
}
}
// ...
}
객체 프로퍼티에 직접 접근하지 않고 별도의 지역변수로 분리해낸다는 점은 타입 관점에서 더 안전합니다. 아래와 같이 프로퍼티를 직접 참조하는 경우 기존에 좁혀졌던 타입이 함수 호출 등으로 신뢰할 수 없는 상태가 될 수 있기 때문입니다.
const deletePolygonBox = (polygon: Polygon) => {
polygon.bbox = undefined;
}
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
polygon.bbox // Type is BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox // Type is BoundingBox
deletePolygonBox(polygon); // polygon.bbox = undefined;
polygon.bbox // Type is BoundingBox
}
}
단, 지역변수로 분리한 경우 기존 프로퍼티 polygon.bbox
와 bbox
가 항상 같음을 보장할 수 없다는 점에 주의해야합니다.
const resetPolygonBox = (polygon: Polygon) => {
polygon.bbox = {
x: [0, 0],
y: [0, 0],
};
}
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const { bbox } = polygon;
if (bbox) {
resetPolygonBox(polygon);
// 이제 bbox와 polygon.bbox는 동일하지 않습니다.
// ...
}
}
비동기 코드에는 콜백 대신 async 함수 사용하기
비동기 동작을 다룰 때에, 콜백보다는 프로미스를 사용해야 합니다. 이유는 다음과 같습니다.
- 콜백보다는 프로미스가 코드를 작성하기 쉽습니다.
- 콜백보다는 프로미스가 타입을 추론하기 쉽습니다.
function fetchPagesCB() {
let numDone = 0;
const responses: string[] = [];
const done = () => {
const [response1, response2, response3] = responses;
// ...
};
const urls = [url1, url2, url3];
urls.forEach((url, i) => {
fetchURL(url, r => {
responses[i] = url;
numDone++;
if (numDone === urls.length) done();
});
});
}
위와 같은 콜백 기반의 비동기 함수는 프로미스를 통해 아래와 같은 형태가 될 수 있습니다.
async function fetchPages() {
const [response1, response2, response3] = await Promise.all([
fetch(url1), fetch(url2), fetch(url3)
]);
// ...
}
그리고 프로미스보다는 async/await를 사용하는 편이 좋습니다. 이유는 아래와 같습니다.
- 일반적으로 더 간결하고 직관적인 코드가 됩니다.
- async 함수는 항상 프로미스를 반환하도록 강제합니다.
"함수가 항상 프로미스를 반환하도록 강제"하는 것은 각 함수가 항상 동기 또는 비동기로 실행되어야 한다는 원칙을 쉽게 지키도록 해줍니다. 콜백이나 프로미스를 사용하면 실수로 반(half)동기 코드를 작성할 수 있지만, async 함수에 기반하는 경우 항상 비동기 코드로 작성되기 때문입니다.
아래의 콜백 기반의 함수 fetchWithCache
는 얼핏 제대로 만들어진 반동기 함수인 듯 하지만, 실제로 사용할 때 문제를 일으킬 가능성이 있습니다. 캐시가 되어있는 경우에는 callback(..)
가 동기적으로 동작할 것이기 때문입니다.
const _cache: {[url: string]: string} = {};
function fetchWithCache(url: string, callback: (text: string) => void) {
if (url in _cache) {
callback(_cache[url]);
} else {
fetchURL(url, text => {
_cache[url] = text;
callback(text);
});
}
}
let requestStatus: 'loading' | 'success' | 'error';
// 캐시가 있는 경우 => requestStatus는 'loading'
// 캐시가 없는 경우 => requestStatus는 'success'
function getUser(userId: string) {
fetchWithCache(`/user/{userId}`, profile => {
requestStatus = 'success';
});
requestStatus = 'loading';
}
이를 async 함수로 대체하면 보다 간결하고, 일관적인 형태로 사용할 수 있게 됩니다.
const _cache: {[url: string]: string} = {};
async function fetchWithCache(url: string) {
if (url in _cache) {
return _cache[url];
}
const response = await fetch(url);
const text = await response.text();
_cache[url] = text;
return text;
}
let requestStatus: 'loading' | 'success' | 'error';
async function getUser(userId: string) {
requestStatus = 'loading';
const profile = await fetchWithCache(`/user/{userId}`);
requestStatus = 'success';
}
한가지 유의점으로, async 함수 내에서 프로미스를 반환한다고 해서 Promise<Promise<T>>
반환타입이 되지는 않습니다. 이 경우에도 동일하게 Promise<T>
가 됩니다. 타입 체커를 통해서도 이를 확인할 수 있습니다.
// Function getJSON(url: string): Promise<any>
async function getJSON(url: string) {
const response = await fetch(url);
const jsonPromise = response.json(); // Type is Promise<any>
return jsonPromise;
}
타입 추론에 문맥이 어떻게 사용되는지 이해하기
TS는 타입을 추론할 때 값 뿐만 아니라 문맥도 고려합니다. 함수로 값을 넘기는 경우가 대표적입니다.
string 리터럴의 경우
type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { /* ... */ }
setLanguage('JavaScript'); // OK
// 'JavaScript' 리터럴은 `Language` 타입에 부합합니다.
let language = 'JavaScript'; // string으로 추론됩니다.
setLanguage(language);
// ~~~~~~~~ Argument of type 'string' is not assignable
// to parameter of type 'Language'
이러한 문제를 해결하기 위해서는 크게 두가지 방법이 있습니다. 이는 당장의 string 리터럴 외에도 범용적으로 활용될 수 있습니다.
타입 선언
하나는 직접 타입을 선언해서 해당 language
변수에 가능한 값을 제한시키는 방법입니다.
let language: Language = 'JavaScript'; // type = Language
상수로 만들기
다른 하나는 const
키워드를 통해 language
변수가 변경 가능성이 없음을 타입체커에게 알려줘 더 정확한 타입을 유추할 수 있도록 해주는 방법입니다.
const language = 'JavaScript'; // type = `JavaScript`
튜플의 경우
튜플의 경우에도 이러한 문제가 발생할 수 있어 주의해야 합니다.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { /* ... */ }
// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) { /* ... */ }
panTo([10, 20]); // OK
// [10, 20]은 [number, number] 타입에 부합합니다.
const loc = [10, 20]; // number[]로 추론됩니다.
panTo(loc);
// ~~~ Argument of type 'number[]' is not assignable to
// parameter of type '[number, number]'
이 경우에도 마찬가지로 타입 선언을 통해 해결할 수 있습니다.
const loc: [number, number] = [10, 20];
panTo(loc); // OK
또는 해당 매개변수가 정말로 상수인 경우에는 const
단언을 사용할 수 있습니다. const
단언을 사용하면 해당 참조가 깊은(deeply) 상수라는 정보를 TS에 전달할 수 있습니다. 다만, 이 경우 해당 값이 readonly
가 되어 전혀 변경할 수 없는 상태가 되기 때문에, 해당 값을 매개변수로 사용하는 함수 측에도 readonly
타입 정보를 추가해야 합니다.
function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10, 20] as const; // type = readonly [number, number]
panTo(loc); // OK
다만, 해당 방식으로 문제를 해결하는 경우, loc
에서 값을 할당하는 시점에 실수가 있었더라도, 정작 에러는 함수를 호출하는 곳에서 발생하기 때문에, 추후 혼란을 줄 수 있다는 문제가 있습니다.
function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10, 20, 30] as const; // error is really here.
panTo(loc);
// ~~~ Argument of type 'readonly [10, 20, 30]' is not assignable to
// parameter of type 'readonly [number, number]'
// Types of property 'length' are incompatible
// Type '3' is not assignable to type '2'
객체의 경우
객체의 경우에도 이러한 문제가 동일하게 발생할 수 있습니다.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
language: Language;
organization: string;
}
function complain(language: GovernedLanguage) { /* ... */ }
complain({ language: 'TypeScript', organization: 'Microsoft' }); // OK
const ts = {
language: 'TypeScript', // type = string
organization: 'Microsoft', // type = string
};
complain(ts);
// ~~ Argument of type '{ language: string; organization: string; }'
// is not assignable to parameter of type 'GovernedLanguage'
// Types of property 'language' are incompatible
// Type 'string' is not assignable to type 'Language'
이를 해결하고자 하는 경우에도 앞선 경우들과 마찬가지로 1)타입 선언을 추가하거나, 2)상수로 만들어주는 방법이 있습니다.
// 1) 타입 선언을 추가하거나
const ts: GovernedLanguage = {
language: 'TypeScript',
organization: 'Microsoft',
};
// 2) 상수로 만드세요.
const ts = {
language: 'TypeScript' as const,
organization: 'Microsoft',
};
콜백의 경우
TS는 콜백 함수의 매개변수를 유추하는 경우에도 문맥이 고려됩니다. 따라서 해당 콜백 함수를 따로 분리하는 경우에도 문제가 발생합니다.
function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
fn(Math.random(), Math.random());
}
// 콜백함수의 매개변수에 타입 명시를 하지 않더라도, 문맥으로 타입을 유추해냅니다.
callWithRandomNumbers((a, b) => {
a; // number
b; // number
});
// 하지만 아래의 경우는 문맥이 유실되어 타입 추론이 불가능합니다.
const fn = (a, b) => {
// ~ Parameter 'a' implicitly has an 'any' type
// ~ Parameter 'b' implicitly has an 'any' type
}
callWithRandomNumbers(fn);
이 경우에도 타입 선언을 통해 해결해줄 수 있습니다.
// 1) 매개변수에 타입을 명시하거나
const fn = (a: number, b: number) => {
// ...
}
// 2) 함수 자체에 타입을 명시하세요.
type CallbackFn = (n1: number, n2: number) => void;
const fn: CallbackFn = (a, b) => {
a // number
b // number
}
함수형 기법과 라이브러리로 타입 흐름 유지하기
타입스크립트 상에서는 절차형(imperative) 프로그래밍의 형태로 구현하기 보다는, 내장된 함수형 기법이나 로대시 같은 유틸리티 라이브러리를 활용하는 것이 좋습니다.
- 타입 흐름을 개선됩니다.
- 가독성이 높아집니다.
- 명시적인 타입 구문의 필요성이 줄어듭니다.
타입 설계
유효한 상태만 표현하는 타입을 지향하기
효과적으로 타입을 설계하려면, 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요합니다. 좋지 않은 예시를 하나 들어봅시다.
interface State {
pageText: string;
isLoading: boolean;
error?: string;
}
위와 같은 상태 구성은 다음과 같은 문제를 갖습니다.
- 로딩 중이면서 동시에 에러가 발생할 수 있습니다.
- 상태 변경에 실수를 할 여지가 있습니다. (
error
는 선택 프로퍼티이며,isLoading
은 단순한boolean
이기 때문에, 타입 관점에서 실수를 줄일 방법이 없습니다.)
이 경우 상태를 좀 더 적절하게 표현하는 방법은 다음과 같습니다.
interface RequestPending {
state: 'pending';
}
interface RequestError {
state: 'error';
error: string;
}
interface RequestSuccess {
state: 'ok';
pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;
interface State {
currentPage: string;
requests: {[page: string]: RequestState};
}
작성해야 하는 코드의 양 자체는 늘어났지만, 이는 유효한 상태만을 다루고 있습니다. 이 덕분에, 당장에 의미없는 상태 프로퍼티를 갖게되는 경우는 없게 되어, 이를 다루기가 훨씬 편해졌습니다. 이는 코드가 길어지고, 또 표현하기 어려운 작업이지만, 결국은 시간을 절약하고 고통을 줄일 수 있는 방법입니다.
사용할 때는 너그럽게, 생성할 때는 엄격하게
보통 매개변수 타입은 반환 타입에 비해 범위가 넓은 경향이 있으며, 실제로도 이 쪽이 사용하기 용이합니다. 이를 테면 다음과 같은 함수의 예를 들 수 있습니다.
interface User {
id: number;
username?: string;
age?: number;
}
const updateUser = (option: User) => {
// ...
};
한편, 반환 타입의 경우에는 선택적인 프로퍼티 없이 더 명확하고 엄격해야 합니다. 실제로 넓은 타입 범위를 갖는 반환 타입은 사용하기가 굉장히 불편합니다. 값을 반환받은 이후에도 타입 체킹을 해주어야하는 일이 다분하기 때문입니다.
const createUser = (option: User): User => {
return {
...option,
}
}
const { username } = createUser({ id: 1, username: '김앨런', age: 27 });
// type username = string | undefined
const firstName = username.charAt(0);
// Object is possibly 'undefined'.
결국 이러한 문제를 해결하려면 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)으로 각각의 상황에 대한 타입을 별도로 두는 것이 좋습니다.
// 기본 형태
interface User {
id: number;
name: string;
age: number;
}
// 느슨한 형태
// type UserOptions = {
// name?: string | undefined;
// age?: number | undefined;
// }
type UserOptions = Partial<Omit<User, 'id'>>
const updateUser = (id: number, options: UserOptions) => {
// ...
}
let id = 1;
const createUser = (options: UserOptions): User => ({
id: id++,
name: '이름없음',
age: 1,
...options,
})
const user = createUser({ name: '김앨런', age: 27 });
문서에 타입 정보를 쓰지 않기
기본적으로 주석은 코드와 동기화되지 않습니다. 다시 말해, 열심히 주석을 작성하더라도, 그것이 최신화된 것이며, 실제로 일치할 것이라는 보장이 없습니다. 또, TS의 타입 체커가 일일이 주석을 다는 것보다 훨씬 정교하기 때문에 때문에 주석을 일일이 작성하는 것은 의미가 없습니다. 결국, 한눈에 이해가 어려운 함수에 대한 설명을 위한 용도로만 주석을 간단하게 활용하는 것이 좋습니다.
/** 애플리케이션 또는 특정 페이지의 배경색을 가져옵니다. */
function getForegroundColor(page?: string): Color {
return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0};
}
불변성(Immutability)의 경우에도, 직접 주석으로 언급하기 보다는, 애초에 readonly
타입 선언으로 타입스크립트가 규칙을 강제하도록 하는 편이 더 좋습니다.
// 이건 좋은 방법이 아닙니다.
/** nums를 변경하지 않습니다! XD */
function sort(nums: number[]) { /* ... */ }
// 주석보다는 타입의 관점에서 강제하세요.
function sort(nums: readonly number[]) { /* ... */ }
입출력에 대한 설명을 덧붙이고 싶다면 JSDoc을 활용하세요.
/**
* @param {string} [page] optional.
*/
function getForegroundColor(page?: string) {
return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0};
}
주석 뿐만 아니라 변수명에 대해서도 이를 그대로 적용할 수 있습니다. 변수명에 굳이 타입 정보를 넣을 필요는 없습니다.
let ageNum; // 이러지 말고
let age: number; // 이렇게 하세요.
단, 단위가 존재하는 숫자들은 예외입니다. 단위가 무엇인지 확실하지 않은 경우에는 이를 변수명에 포함하여 명확하게 해주는 것이 좋습니다.
let time = 1000 // 이것 보다는
let timeMs = 10000 // 이게 좋고
let temperature = 36.5 // 이것보다는
let temperatureC = 36.5 // 이게 좋습니다.
타입 주변에 null값 배치하기
다음의 최대, 최솟값을 계산하는 extent
함수가 있다고 생각해봅시다.
// WARNING : 실제로는 타입 에러가 발생합니다!
function extent(nums: number[]) {
let min, max; // undefined, undefined
for (const num of nums) {
if (min === undefined) { // 최초에 min이 undefined라면 min, max 모두에 첫번째 값을 할당합니다.
min = num;
max = num;
} else {
// min에 대해서만 undefined 체킹이 이루어졌습니다.
min = Math.min(min, num); // type min = number
max = Math.max(max, num); // type max = number | undefined
}
}
// 로직 만을 생각해본다면 반환 타입은 [number, number] | [undefined, undefined] 여야 하지만,
return [min, max]; // 실제로는 (number | undefined)[] 입니다.
}
위 함수는 로직 상 min
, max
는 둘 다 undefined
이거나, 둘 다 undefined
가 아니어야 하지만, 이를 타입 체커가 인지하지 못 한다는 문제점을 갖고 있습니다.
그래서 실제로 strictNullChecks
환경에서 에러가 발생합니다.
함수의 반환 타입을 null
이거나, null
이 아니게 만드세요
이걸 해결하기 위해서는 해당 값들을 하나의 객체 또는 배열에 넣어 처리하면 됩니다.
반환값이 nullish하다면 반환 타입을 하나의 객체로 만들고 반환 타입 전체가 null
이거나, 또는 전체가 null
이 아니도록 만드는 편이 사람과 타입체커 모두에게 명료한 코드가 됩니다.
function extent(nums: number[]) {
let result: [number, number] | null = null;
for (const num of nums) {
if (!result) {
result = [num, num];
} else {
result = [Math.min(num, result[0]), Math.max(num, result[1])]
}
}
return result; // [number, number] | null
}
const [min, max] = extent([0, 1, 2])!;
클래스 프로퍼티에는 null
이 존재하지 않게 하세요
다음과 같이 프로퍼티에 nullish한 값이 존재한다면, 인스턴스를 생성하고 난 이후와 해당 클래스의 모든 메서드를 사용하기 어렵게 만듭니다.
class UserPosts {
user: UserInfo | null;
posts: Post[] | null;
constructor() {
// 최초 인스턴스 생성 시에 각 프로퍼티가 null 입니다.
this.user = null;
this.posts = null;
}
async init(userId: string) {
// 인스턴스 생성 이후에야 데이터를 가져옵니다.
return Promise.all([
async () => this.user = await fetchUser(userId),
async () => this.posts = await fetchPostsForUser(userId)
]);
}
getUserName() {
// 각 메서드에서 매번 프로퍼티에 대한 null 체킹을 해주어야 합니다.
if (this.user) return this.user.name;
else return null;
}
}
const userPost = new UserPosts();
// 인스턴스 생성 이후에도 계속 null 체킹이 필요합니다.
userPost.user // type UserInfo | null;
이것을 개선하려면, 인스턴스 생성 이후 프로퍼티를 채워넣는 것이 아니라, 정적 메서드를 통해 애초에 완성된 인스턴스를 생성하도록 해야합니다.
class UserPosts {
user: UserInfo;
posts: Post[];
constructor(user: UserInfo, posts: Post[]) {
// 최초 인스턴스 생성 시부터 모든 프로퍼티를 갖고 있습니다.
this.user = user;
this.posts = posts;
}
static async init(userId: string): Promise<UserPosts> {
// 필요한 데이터들을 애초에 다 가져온 이후에
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPostsForUser(userId)
]);
// 인스턴스를 생성합니다.
return new UserPosts(user, posts);
}
getUserName() {
// 이제 null 체킹이 필요 없습니다!
return this.user.name;
}
}
유니온의 인터페이스보다는 인터페이스의 유니온 사용하기
다음 형태의 유니온 프로퍼티 타입을 갖는 인터페이스가 있다고 가정해봅시다.
interface Family {
parent: KimsParents | LeesParents | ParksParents;
child: Kim | Lee | Park;
}
얼핏 문제가 없는 듯 보이지만, 해당 인터페이스는 추후에 사용하기가 어렵고, 에러가 발생할 여지가 많습니다.
타입 시스템 상으로 KimsParent
가 부모일 때 child
가 Park
이거나 Lee
인 경우를 허용하기 때문입니다.
만약 이것을 더 나은 방법으로 모델링하려면 각각의 타입 계층을 분리된 인터페이스로 두어야 합니다.
interface KimsFamily {
parent: KimsParent;
child: Kim;
}
interface LeesFamily {
parent: LeesParent;
child: Lee;
}
interface ParksFamily {
parent: ParksParent;
child: Park;
}
// 이제 부모 자식 간의 관계가 꼬일 일이 없습니다!
type Family = KimsFamily | LeesFamily | ParksFamily;
Tagged Union
이러한 패턴을 활용하는 가장 일반적인 예시는 Tagged Union 입니다.
interface KimsFamily {
lastName: 'kim';
parent: KimsParent;
child: Kim;
}
interface LeesFamily {
lastName: 'lee';
parent: LeesParent;
child: Lee;
}
interface ParksFamily {
lastName: 'park';
parent: ParksParent;
child: Park;
}
type Family = KimsFamily | LeesFamily | ParksFamily;
위의 각 인터페이스에서 쓰인 lastName
(일반적으로는 type
과 같은 이름)이 곧 태그가 됩니다.
이 태그는 런타임에 어떤 타입의 인터페이스가 쓰이는지 판단되어 타입 좁히기에 활용됩니다.
function getChild = (family: Family) => {
if (family.lastName === 'kim') {
// type family = KimsFamily
} else if (family.lastName === 'lee') {
// type family = LeesFamily
} else {
// type family = ParksFamily
}
}
관련된 Optional 프로퍼티는 하나로 묶으세요
다음과 같이 관련이 깊은 두 속성의 경우는 하나의 객체로 묶는 것이 더 나은 설계입니다. 앞선 아이템에서 말한 내용과 유사합니다.
// 이것보다는
interface Person {
name: string;
// 아래는 둘 다 존재하거나, 둘 다 없어야 합니다.
placeOfBirth?: string;
dateOfBirth?: Date;
}
// 이게 낫습니다.
interface Person {
name: string;
// 이제 두 프로퍼티 중 하나만 존재하는 일은 없습니다.
birth?: {
place: string;
date: Date;
}
}
하지만, 타입 구조를 직접 손댈 수 없는 경우(ex. API의 결과)라면, 앞서 말한 인터페이스의 유니온을 사용해 관계를 모델링할 수 있습니다.
interface Name {
name: string;
}
interface PersonWithBirth extends Name {
placeOfBirth: string;
dateOfBirth: Date;
}
type Person = Name | PersonWithBirth;
function eulogize(p: Person) {
// placeOfBirth 프로퍼티가 존재한다면 PersonWithBirth 입니다.
if ('placeOfBirth' in p) {
p // type p = PersonWithBirth
const { dateOfBirth } = p // type dateOfBirth = Date
}
}
string 타입보다 더 구체적인 타입 사용하기
string
타입은 any
와 유사한 문제를 갖고 있습니다. 잘못 사용하게 되는 경우 무효한 값을 허용하며, 타입 간의 관계도 감추어 버립니다.
리터럴 타입과 유니온을 통해 string
의 부분 집합을 정의하여 타입 안정성과 가독성을 크게 높일 수 있습니다.
가능하다면 더 구체적인 타입을 사용하세요
// 이는 너무 광범위합니다.
interface Album {
artist: string;
title: string;
releaseDate: string; // YYYY-MM-DD
recordingType: string; // E.g., "live" or "studio"
}
// 이렇게 쓰세요.
type RecordingType = 'live' | 'studio';
interface Album {
artist: string;
title: string;
releaseDate: Date; // 굳이 string일 이유가 없습니다.
recordingType: RecordingType;
}
객체 프로퍼티명을 매개변수로 가져와야 할 때는 keyof
를 사용하세요
다음은 underscore 라이브러리에 존재하는 pluck
유틸함수입니다.
특정 타입의 배열에서 원하는 키의 값들만 가져온 하나의 배열을 반환합니다.
function pluck<T, K extends keyof T>(record: T[], key: K): T[K][] {
return record.map(r => r[key]);
}
interface User {
name: string;
}
const users: User[] = [{ name: '짱구' }, { name: '철수' }];
pluck(users, 'name'); // ['짱구', '철수']
이것이 만약 단순히 key
매개변수를 string
타입으로 가져오는 형태였다면 아래와 같았을겁니다.
function pluck(record: any[], key: string): any[] {
return record.map(r => r[key]);
}
이 경우 해당 함수의 반환값은 any[]
타입이기 때문에 타입 체킹에 크게 방해가 됩니다.
따라서 객체의 프로퍼티명을 매개변수로 가져와야 하는 경우에는 타입 공간에서의 keyof
를 적절히 사용해 더 명확한 타입을 지정해주어야 합니다.
부정확한 타입보다는 미완성 타입을 사용하기
타입이 없는 것보다, 타입이 잘못된 것이 더 나쁩니다. 정확하게 타입을 모델링할 수 없는 상황이라면, 굳이 부정확하게 모델링하지 말아야 합니다.
일반적으로 any
와 같이 매우 추상적인 타입은 정제하는 것이 좋지만, 매번 타입이 구체적일수록 정확도가 무조건적으로 올라가지는 않습니다.
any
와 unknown
은 다릅니다
any
와 unknown
은 둘 다 어떤 값이든 될 수 있다는 특징을 갖지만, 주요한 차이점이 하나 있습니다.
먼저, any
는 어떤 값이든 될 수 있고, 동시에 어디에든 할당되고, 어떤 방식으로든 사용 가능합니다.
물론 이러한 특징들이 오히려 독이 되기 때문에, 앞선 아이템들에서도 줄곧 말해왔듯 지양하는 것이 좋습니다.
any를 사용하지 말아야 하는 이유에 대해서는 앞서 아이템 5에서 다룬 바가 있습니다.
let anyVal: any = 'abc';
anyVal.theresNoMethodLikeThis(); // 문제 없음
const num: number = anyVal; // 문제 없음
반면, unknown
은 어떤 값이든 될 수는 있지만, 단언이나 타입 체킹 없이는 다른 타입에 할당하거나 메서드 및 프로퍼티를 참조할 수 없습니다.
let unknownVal: unknown = 'abc';
unknownVal.toUpperCase(); // ERROR: Object is of type 'unknown'.(2571)
// unknown 타입인 변수를 이용하려면
// 1. 타입을 좁히거나
if (typeof unknownVal === 'string') unknownVal.toUpperCase();
// 2. 단언을 사용해야 합니다.
const str: string = unknownVal as string;
데이터가 아닌, API와 명세를 보고 타입 만들기
프로젝트를 진행하다보면, 프로젝트 외부에서 비롯된 데이터와 API를 다루게 됩니다.
이 경우 기본적으로 해당 API에서 제공되는 타입 선언(@types/...
)을 추가하거나, 이로부터 자동 생성되는 타입들을 사용하는 것이 좋습니다.
직접 본인의 경험에 기반하여 타입을 작성할 수도 있지만, 이 경우 모든 예외 케이스를 커버한다고 보장할 수 없습니다. 만약 직접 타입을 작성해야 한다면, 해당 API의 명세 정보를 확인하여 이에 기반하여 작성하는 것이 좋습니다.
해당 분야의 용어로 타입 이름 짓기
코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 이미 존재합니다. 자체적으로 용어를 만들어 내려고 하지 말고, 해당 분야에 이미 존재하는 용어를 사용해야 합니다. 이를 통해 보다 소통에 유리하게끔 하며, 타입의 명확성을 올릴 수 있습니다.
interface Animal {
name: string;
endangered: boolean;
habitat: string;
}
// 변수명은 leopard지만, 프로퍼티 `name` 값은 `Snow Leopard`로, 별도의 의미를 지니는 것인지 모호함
const leopard: Animal = {
name: 'Snow Leopard', // 동물의 학명인지? 일반적인 명칭인지?
endangered: false, // 멸종 위기종 여부인지? 멸종 여부인지?
habitat: 'tundra', // habitat라는 뜻 자체도 모호하고, 범위도 string으로 넓음
};
각 프로퍼티에 대한 의미가 모호하기 때문에, 이를 작성한 사람을 찾아 의도를 물어봐야만 하는 상황이 생깁니다. 이를 개선하자면 다음과 같이 변경할 수 있습니다.
interface Animal {
commonName: string;
genus: string;
species: string;
status: ConservationStatus;
climates: KoppenClimate[];
}
type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC';
type KoppenClimate = |
'Af' | 'Am' | 'As' | 'Aw' |
'BSh' | 'BSk' | 'BWh' | 'BWk' |
'Cfa' | 'Cfb' | 'Cfc' | 'Csa' | 'Csb' | 'Csc' | 'Cwa' | 'Cwb' | 'Cwc' |
'Dfa' | 'Dfb' | 'Dfc' | 'Dfd' |
'Dsa' | 'Dsb' | 'Dsc' | 'Dwa' | 'Dwb' | 'Dwc' | 'Dwd' |
'EF' | 'ET';
// 각 프로퍼티를 보다 구체적인 용어로 대체
const snowLeopard: Animal = {
commonName: 'Snow Leopard',
genus: 'Panthera',
species: 'Uncia',
status: 'VU', // vulnerable => IUCN의 표준 분류 체계를 따르도록 함
climates: ['ET', 'EF', 'Dfd'], // alpine or subalpine => 쾨펜 기후 분류를 따르도록 함
};
IUCN 및 쾨펜 기후 분류에 대한 도메인 지식이 없다면 당황스러울 수는 있지만, 이 경우 정보를 찾기 위해 코드 작성자에 의존할 필요가 없습니다. 애초에 해당 분류 체계에 대한 정보를 습득하거나, 온라인에 있는 무수한 내용들을 바탕으로 이를 찾을 수 있을 겁니다.
이름을 정할 때 명심해야 할 세가지 규칙
- 동일한 의미를 나타낼 때는 같은 용어를 사용합니다. 정말로 의미적으로 구분이 되어야 하는 경우에만 다른 용어를 사용합니다.
data
,info
,thing
,item
,object
,entity
같은 모호하고 의미 없는 이름은 피해야 합니다. 만약entity
라는 용어가 해당 분야에서 특별한 의미를 지니다면 문제가 없겠지만, 귀찮다고 무심코 의미 없는 이름을 붙여서는 안됩니다.- 이름을 지을 때는 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지를 고려해야 합니다. 예를 들어,
INodeList
보다는Directory
가 더 의미있는 이름입니다. 구현의 측면이 아니라 개념적인 측면에서 이름을 고려하세요.
공식 명칭에는 상표를 붙이기
C++, Java, Swift와 같은 언어들에서는 Nominal Typing(명시적 타이핑, 명목적 타이핑) 체계를 활용합니다. 이 말인 즉슨 똑같은 구조를 가진다고 해도 동일한 타입으로 간주되지 않는 것을 의미합니다.
// 의사 코드입니다!
class Foo {
method(input: string): number { ... }
}
class Bar {
method(input: string): number { ... }
}
let foo: Foo = new Bar(); // ERROR!!
한편 앞선 아이템 4에서도 말한 것 처럼, TS는 구조적 타이핑(Structural Typing) 체계를 활용합니다. 이는 다시 말해, 해당 타입의 이름이 달라도 타입이 호환되기만 한다면 이용에 아무런 문제가 없음을 의미합니다.
class Foo {
method(input: string): number { return 2; }
}
class Bar {
method(input: string): number { return 4; }
}
let foo: Foo = new Bar(); // Okay.
이러한 특징은 기본적으로 JS의 덕 타이핑을 모델링하기 위해 존재하는 것입니다. 하지만 때로 이러한 특성이 문제를 일으킬 수도 있죠.
interface Vector2D {
x: number;
y: number;
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
calculateNorm({x: 3, y: 4}); // 정상입니다.
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D); // 놀랍게도 이 역시 정상입니다.
위의 코드는 구조적 타이핑 관점에서 아무런 문제가 없지만, 우리가 개념적으로 생각했을 때는 분명 문제가 있는 코드입니다. 3D 벡터는 2D 벡터 매개변수에 전달되어서는 안된다는 것이죠.
TS에서 Nominal Typing을 흉내내는 법
아이러니하게도, TS에서 Nominal Typing 체계를 흉내내기 위해선 하나의 프로퍼티를 추가적으로 사용해야 합니다.
이를 상표 기법(Branding)이라고 하는데, _brand
(일종의 컨벤션)라는 프로퍼티로 타입이 아닌 값의 관점에서 해당 타입이 Vector2D
임을 나타내는 것이죠.
type Vector2D = {
_brand: '2d';
x: number;
y: number;
}
function vec2D(x: number, y: number): Vector2D {
return {x, y, _brand: '2d'};
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
calculateNorm(vec2D(3, 4)); // OK, returns 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);
// ~~~~~ Property '_brand' is missing in type...
원시 타입에도 적용할 수 있습니다
해당 기법이 재미있는 이유는, 객체가 아닌 어느 타입이든 활용할 수 있다는 점 때문입니다.
이를테면, string
이나 number
같은 기본적으로 프로퍼티를 가질 수 없는 타입에도 적용할 수 있습니다.
type AbsolutePath = string & {_brand: 'abs'};
function listAbsolutePath(path: AbsolutePath) {
// ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
return path.startsWith('/');
}
function f(path: string) {
if (isAbsolutePath(path)) {
// 이제 `path`는 AbsolutePath로 간주됩니다. 실제론 `_brand` 프로퍼티가 없지만요.
listAbsolutePath(path);
}
listAbsolutePath(path);
// ~~~~ Argument of type 'string' is not assignable
// to parameter of type 'AbsolutePath'
}
number
타입의 경우에도 상표를 붙일 수는 있으나, 추가적인 연산이 이루어지게 되면 상표가 사라지기 때문에 실제 이용은 어렵습니다.
type Meters = number & {_brand: 'meters'};
type Seconds = number & {_brand: 'seconds'};
const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;
const oneKm = meters(1000); // Type is Meters
const oneMin = seconds(60); // Type is Seconds
const tenKm = oneKm * 10; // Type is number
const v = oneKm / oneMin; // Type is number
표현하기 어려운 속성을 모델링할 수 있습니다
이를테면, Array 타입 자체만으로 해당 Array가 정렬 처리되었는지에 대한 여부를 나타내는 것은 상당히 어렵습니다.
type SortedList<T> = T[];
Array가 정렬되었음을 나타내려면 마찬가지로 상표 기법을 사용하면 됩니다.
type SortedList<T> = T[] & {_brand: 'sorted'};
function isSorted<T>(xs: T[]): xs is SortedList<T> {
for (let i = 1; i < xs.length; i++) {
if (xs[i] > xs[i - 1]) {
return false;
}
}
return true;
}
const list = [1, 2, 3];
if (isSorted(list)) {
// 이제 `list`는 `SortedList` 타입입니다.
// ...
}
any 다루기
any
타입은 기본적으로는 지양해야하는 타입이지만, 프로그램의 일부분에만 타입 시스템을 적용할 수 있다는 TS의 특성 때문에 점진적인 마이그레이션에 큰 역할을 합니다.
any
는 매우 강력한 권한을 갖고 있어 여기저기 남용될 소지가 높은데, 해당 장에서는 any
를 현명하게 사용하는 방법에 대해 살펴보겠습니다.
any 타입은 가능한 한 좁은 범위에서만 사용하기
any는 선언보다 단언을 활용하기
// 이것보다는
const x: any = expressionReturningFoo();
processBar(x);
// 이게 낫습니다.
const x = expressionReturningFoo();
processBar(x as any);
위에서 앞선 예시의 문제는 any
타입으로 선언된 x
를 이용함에 따라 다른 코드에도 지속적으로 영향을 미치기 때문입니다.
반면, 뒤쪽 예시의 경우는 processBar
의 호출에 대해서만 x를 any
타입으로 단언한 것이므로 다른 코드에는 영향을 미치지 않습니다.
가능한 좁게 any 타입을 사용하기
특정 객체의 한 프로퍼티가 타입 에러를 갖는 상황에 any
를 사용해야만 한다면, 객체 전체를 any
로 단언하기 보다는 해당 속성만 any
로 단언하는 것이 좋습니다.
// 이것보다는
// Type is any
const config: Config = {
a: 1,
b: 2,
c: {
key: value
}
} as any;
// 이게 낫습니다.
// Type is { a: number, b: number, c: { key: any } }
const config: Config = {
a: 1,
b: 2,
c: {
key: value as any
}
};
any를 구체적으로 변형해서 사용하기
any
는 말그대로 만능 타입입니다. 무엇이든 될 수 있습니다. any
타입을 사용할 때는 정말로 모든 값이 허용되어야 하는지에 대해 검토해보아야 합니다.
거꾸로 말해, 대부분의 상황에서는 any
보다 더 구체적으로 표현할 수 있는 타입이 존재할 가능성이 높기 때문에 가능한 더 구체적인 타입을 찾아 타입 안정성을 높이도록 해야합니다.
배열의 경우
// 이것보다는
function getLengthBad(array: any) {
return array.length;
}
// 이게 낫습니다
function getLength(array: any[]) {
return array.length;
}
객체의 경우
// 이것보다는
function hasTwelveLetterKey(o: any) {
// ...
}
// 이게 낫고
function hasTwelveLetterKey(o: {[key: string]: any}) {
// ...
}
// 이것도 괜찮습니다
function hasTwelveLetterKey(o: object) {
// ...
}
위 예시에서 {[key: string]: any}
와 object
의 차이는 프로퍼티의 접근 가능 여부에 있습니다.
const obj: {[key: string]: any} = { a: 1 };
const obj2: object = { a: 1 };
obj.a
obj2.a // ERROR: Property 'a' does not exist on type 'object'.
함수의 경우
함수에 있어서도 단순히 any
를 사용해서는 안 됩니다. 최소한으로나마 구체화할 수 있는 아래의 세가지 방법이 있습니다.
type Fn0 = () => any;
type Fn1 = (arg: any) => any;
type FnN = (...args: any[]) => any;
함수 안으로 타입 단언문 감추기
함수의 모든 부분을 안전한 타입으로 구현하는 것이 이상적이긴 하지만, 불필요한 예외 상황까지 전부 고려해가며 타입 정보를 힘들게 구성할 필요는 없습니다. 함수 내부에는 유연하게 타입 단언을 사용하고, 함수 외부로 드러나는 타입 정의를 명확히 명시하는 정도로 끝내는 것이 낫습니다.
프로젝트 전반에 걸쳐 위험한 타입 단언이 드러나 있는 것보다는, 제대로 타입이 정의된 함수 안으로 타입 단언문이 감추어지는 쪽이 더 좋은 설계입니다.
any의 진화를 이해하기
TS 상에서 일반적인 타입들은 모두 정제되기만 하는 반면,
noImplicitAny
가 설정된 상태에서 암시적으로 any
또는 any[]
타입으로 추정되는 변수는 진화(evolve)합니다.
다시 말해, 이는 문맥에 따라 다른 타입으로 확장해나갑니다.
const result = []; // any[]
result.push('a'); // string[]
result.push(1); // (string | number)[]
let val = null; // any -> null인 경우도 암시적 any입니다.
if (Math.random() < 0.5) {
val = /hello/; // RegExp
} else {
val = 12; // number
}
val // number | RegExp
명시적인 any에서는 진화하지 않습니다
다만, 명시적으로 직접 any
타입으로 선언한 경우에는 진화가 일어나지 않습니다.
let val: any;
if (Math.random() < 0.5) {
val = /hello/; // any
} else {
val = 12; // any
}
val // any
any를 진화시키기 보다는, 명시적 타입 구문을 사용하세요
원래 number[]
타입이어야 하는 변수에 실수로 string
이 섞여 잘못 진화했을 경우에도 타입 상 문제가 없습니다.
하지만 타입을 안전하게 지키기 위해서는 암시적인 any
를 진화시켜나가는 방식보다 명시적인 타입 구문을 사용하는 것이 더 좋은 설계입니다.
모르는 타입의 값에는 any 대신 unknown을 사용하기
any
, unknown
, never
타입은 각각 다음의 특징을 갖고 있습니다.
any
는- 어떠한 타입이든
any
타입에 할당 가능하고 any
타입 본인도 어떤 타입에든 할당 가능합니다.
- 어떠한 타입이든
unknown
은- 어떠한 타입이든
unknown
타입에 할당 가능하지만 unknown
타입은 오직any
와unknown
에만 할당 가능합니다.
- 어떠한 타입이든
never
는- 어떠한 타입도
never
타입에 할당할 수 없지만 never
타입은 어디에든 할당할 수 있습니다.
- 어떠한 타입도
이를 코드로 간단하게 살펴보면 다음과 같습니다.
let anyVal: any;
let unknownVal: unknown;
let neverVal: never;
anyVal = '123'; // 무엇이든 할당 가능하고
const stringVal: string = anyVal; // 어디에든 할당 가능합니다.
unknownVal = 123; // 무엇이든 할당 가능하지만
const numVal: number = unknownVal; // 어디든 할당할 수는 없습니다. (any와 unknown에만 가능)
// ERROR: Type 'unknown' is not assignable to type 'number'.(2322)
// ERROR: Type 'string' is not assignable to type 'never'.(2322)
neverVal = '123'; // 어떤 타입도 할당할 수 없지만
const stringVal2: string = neverVal; // 어디든 할당할 수는 있습니다.
unknown 타입으로 타입 변환을 강제하세요
기본적으로 unknown
타입은 그대로 사용할 수가 없는 상태이기 때문에, 별도의 타입 단언이나 타입 좁히기가 이루어져야 합니다.
// 타입 단언
interface Book {
name: string;
author: string;
}
function safeParseYAML(yaml: string): unknown {
return parseYAML(yaml);
}
const book = safeParseYAML(`
name: Villette
author: Charlotte Brontë
`) as Book; // 이제 이건 Book 타입입니다.
// 타입 좁히기
function isBook(val: unknown): val is Book {
return (
typeof(val) === 'object' && val !== null &&
'name' in val && 'author' in val
);
}
function processValue(val: unknown) {
if (isBook(val)) {
val; // Type is Book
}
}
제너릭 대신에 unknown
을 이용할 수 있는 경우, unknown
이 낫습니다
제너릭을 사용한 스타일은 타입 단언문과 달라 보이지만, 기능은 동일합니다.
제너릭보다는 unknown
을 반환하고 사용자가 직접 단언문을 사용하거나 원하는 대로 타입을 좁히도록 강제하는 것이 좋습니다.
// 이것보다는
function safeParseYAML<T>(yaml: string): T {
return parseYAML(yaml);
}
safeParseYAML<Book>(`
name: Villette
author: Charlotte Brontë`
);
// unknown이 낫습니다.
function safeParseYAML(yaml: string): unknown {
return parseYAML(yaml);
}
const book = safeParseYAML(`
name: Villette
author: Charlotte Brontë
`) as Book;
단언의 경우에도 any보다는 unknown을 사용하세요
declare const foo: Foo;
let barAny = foo as any as Bar;
let barUnk = foo as unknown as Bar;
기능적으로 위의 barAny
와 barUnk
는 동일하지만, 추후 리팩토링을 염두한다면 unknown
을 사용하는 쪽이 더 안전합니다.
any
의 경우 분리되는 순간 그 영향력이 널리 퍼지게되지만, unknown
의 경우 그 시점에 즉시 에러를 발생시키므로 더 안전합니다.
{}와 object
unknown
타입과 유사한 두 가지 타입이 있습니다.
{}
타입은null
과undefined
를 제외한 모든 값이 될 수 있습니다.object
타입은 모든 비기본형(non-primitive) 타입으로 이루어집니다.
unknown
타입이 생겨나기 전에는 {}
가 일반적으로 사용되었으나, 최근에는 이를 이용하는 경우가 드뭅니다.
정말로 null
과 undefined
가 불가능하다고 판단되는 경우에만 unknown
대신 {}
를 사용하면 됩니다.
let bracket: {};
bracket = [];
bracket = {
a: 1,
b: 2,
}
bracket = 1;
bracket = undefined;
bracket = null;
let obj: object;
obj = [];
obj = {
a: 1,
b: 2,
}
obj = 1;
obj = 'a';
obj = undefined;
obj = null;
몽키 패치보다는 안전한 타입을 사용하기
JS는 이미 생성된 객체와 클래스에 임의의 속성을 추가할 수 있습니다.
이러한 패턴을 통해 window
나 document
에 값을 할당하여 전역 변수를 만드는데 사용할 수 있죠.
이렇듯 런타임 시점에서 사용되는 프로토타입이나 글로벌 객체 등에 직접 변경을 가하는 것을 몽키 패치라고 합니다.
그러나 이는 일반적으로 좋은 생각이 아닙니다.
- 서로 멀리 떨어진 부분들 간에 의존성이 생기고, 예상치 못한 사이드 이펙트를 유발합니다.
- TS의 경우, 기본적으로 이렇게 임의로 추가된 속성에 대해서는 알 방법이 없습니다.
이 중 두번째 문제의 경우, 해결하기 위한 가장 쉬운 방법은 해당 객체를 any
로 두는 것입니다.
하지만, 이 때는 타입 안정성을 상실하고, 언어 서비스를 사용할 수 없게 됩니다.
(document as any).monky = 'Tamarin';
(document as any).monkey = /Tamarin/;
가장 좋은 해결책은 애초에 글로벌 객체로부터 데이터를 분리하는 것입니다. 하지만, 분리할 수 없는 상황인 경우 두 가지 차선책이 존재합니다.
1. 인터페이스의 보강(augmentation) 기능을 사용
export {};
// 모듈 관점에서 제대로 동작하려면 `global` 선언이 필요합니다.
declare global {
interface Document {
monkey: string;
}
}
document.monkey = 'Tamarin'; // OK
이 방법은 any
보다 타입 안전성 측면에서도 더 안전하고, 에디터 상에서 제대로 된 피드백도 전달 받을 수 있습니다.
하지만, 보강은 전역적으로 적용되기 떄문에, 코드의 다른 부분이나 라이브러리로부터 분리할 수 없다는 단점이 있습니다.
또, 런타임 시점에서 이러한 보강을 적용할 방법이 없습니다. (런타임 도중에 프로퍼티가 추가되는 경우)
이러한 문제 때문에 프로퍼티를 optional하게 두게 되는데, 이 경우 더 정확할 수는 있으나 다루기에는 더 어려워집니다.
2. 더 구체적인 타입 단언문을 사용
interface MonkeyDocument extends Document {
monkey: string;
}
(document as MonkeyDocument).monkey = 'Macaque';
이 방법은 직접적으로 Document
타입을 건드리지 않고 새로운 타입을 도입했기 때문에 앞선 방법에서의 모듈 영역의 문제를 해결할 수 있습니다.
이 경우 몽키 패치된 프로퍼티를 참조하는 경우에만 해당 단언을 사용하거나, 새로운 변수를 도입하면 됩니다.
하지만, 기본적으로 몽키 패치는 남용해서는 안 되며, 궁극적으로 더 잘 설계된 구조로 리팩토링하는 것이 올바른 방향입니다.
타입 커버리지를 추적하여 타입 안정성 유지하기
noImplicitAny
를 설정하더라도, 여전히 any
타입은 프로그램 내에 존재할 수 있습니다.
- 명시적
any
타입 - 서드파티 타입 선언 (
@types
)
any
타입은 프로그램 전반에 부정적 영향을 끼칠 수 있으므로 개수를 추적하는 것이 좋습니다.
다음의 type-coverage
패키지를 활용하면 any
를 추적할 수 있습니다.
npx type-coverage
--detail
플래그를 붙이면, any
타입이 있는 곳을 전부 출력해줍니다.
npx type-coverage --detail
타입 선언과 @types
devDependencies에 typescript와 @types 추가하기
npm은 3가지 종류의 의존성을 구분해서 관리하며, 각각의 의존성은 package.json
파일 내의 별도 영역에 들어 있습니다.
- dependencies: 현재 프로젝트 실행에 필수적인 라이브러리
- devDependencies: 현재 프로젝트의 개발/테스트에 사용되지만, 런타임에 필요없는 라이브러리
- peerDependencies: 런타임에 필요하긴 하지만, 의존성을 직접 관리하지 않는 라이브러리
TS와 관련된 대부분의 라이브러리는 일반적으로 런타임에는 영향을 미치지 않기 때문에 devDependencies에 속합니다.
모든 타입스크립트 프로젝트에서 공통적으로 고려해야 할 의존성 두 가지를 살펴보겠습니다.
1. 타입스크립트 자체 의존성을 고려해야 합니다
TS를 시스템 레벨로 설치할 수도 있지만, 다음의 두 이유 때문에 추천하지 않습니다.
- 팀원들 모두가 동일한 버전을 설치한다는 보장이 없습니다.
- 프로젝트 셋업 시 별도의 단계가 추가됩니다.
결국, 따로 시스템 레벨로 설치를 하기 보다는, devDependencies에 포함시켜 단순히 npm install
명령만으로 모두 동일한 버전의 타입스크립트를 쉽게 설치하도록 하는 편이 좋습니다.
2. 타입 의존성(@types)를 고려해야 합니다
사용하는 라이브러리 자체적으로 @types
가 포함되어 있지 않더라도, DefinitelyTypes에서 타입 정보를 얻을 수 있습니다.
이 경우, 원본 라이브러리 자체는 dependencies에 있더라도 @types
의존성은 devDependencies에 위치해야 합니다.
예를 들어, React의 경우에는 다음과 같습니다.
npm install react
npm install --save-dev @types/react
타입 선언과 관련된 세 가지 버전 이해하기
TS를 사용하게 되면 의존성 관리가 더 복잡해집니다. 왜냐하면 다음 세 가지 사항을 추가로 고려해야 하기 때문입니다.
- 라이브러리의 버전
- 타입 선언(
@types
)의 버전 - 타입스크립트의 버전
이 셋 중 하나라도 맞지 않으면, 의존성과 상관없어 보이는 곳에서 엉뚱한 오류가 발생할 수 있습니다.
라이브러리와 타입 정보의 버전이 별도로 관리되는 경우
일반적으로는 특정 라이브러리를 dependencies로 설치하고, 타입 정보를 devDependencies로 설치하게 됩니다.
npm install react
+ react16.8.6
npm install --save-dev @types/react
+ @types/react@16.8.19
이 때, 메이저 버전과 마이너 버전이 일치하지만 패치 버전이 일치하지 않는다는 점에 주목합시다.
@types/react
의 16.8.19
는 타입 선언들이 React 16.8 버전의 API를 나타냄을 의미합니다.
React 모듈이 시맨틱 버전 규칙을 제대로 지킨다고 가정하면 패치 버전들은 공개 API의 사양을 변경하지 않습니다.
여기서 패치(patch) 버전인 .19
의 경우는 타입 선언 자체의 버그나 누락에 따른 수정과 추가에 따른 것입니다.
앞서 React와 해당 타입 선언의 버전에 차이가 생긴 것은 라이브러리 자체보다 타입 선언에 더 많은 업데이트가 있었기 떄문입니다. (19
vs. 6
)
다만, 별도로 버전을 관리하는 경우 다음의 네 가지 문제점이 있습니다.
- 라이브러리를 업데이트했지만 실수로 타입 선언은 업데이트하지 않는 경우
- 타입 선언도 업데이트하여 버전을 맞추어줘야 합니다.
- 단, 해당 버전이 준비되지 않은 경우, 임시적으로 보강을 활용할 수 있습니다.
- 라이브러리보다 타입 선언의 버전이 최신인 경우
- 라이브러리를 업데이트하거나, 타입 버전을 낮추어야 합니다.
- 프로젝트에서 사용하는 TS 버전보다 라이브러리에서 필요로 하는 TS 버전이 최신인 경우
- 프로젝트의 타입스크립트의 버전을 업데이트하거나
- 라이브러리의 타입 선언 버전을 낮추거나
declare module
선언으로 라이브러리의 타입 정보를 없애 버릴 수 있습니다.- 라이브러리에서
typesVersions
를 통해 TS 버전 별로 다른 타입 선언을 제공하는 방법도 있으나, 실제로 이 경우는 매우 드뭅니다. - 특정 버전에 대한 타입 정보를 설치하려면
npm install --save-dev @types/lodash@ts.31
와 같이 실행하면 됩니다.
- 라이브러리 간
@types
의존성이 중복되는 경우npm ls @types/foo
와 같은 실행으로 타입 선언 중복이 어디서 발생했는지 추적합니다.- 해당 라이브러리들을 업데이트하여 서로 버전이 호환되게끔 합니다.
- 단, 애초에
@types
가 전이(transitive) 의존성을 갖지 않도록 설계되는 것이 좋습니다. 이에 대해서는 아이템 51에서 다룰 예정입니다.
타입 정보가 라이브러리 자체적으로 관리되는 경우
일부 라이브러리, 특히 TS로 작성된 라이브러리들은 자체적으로 타입 선언을 포함(bundling)하게 됩니다.
자체 타입 선언은 보통 package.json
의 types
필드에서 .d.ts
파일을 가리키도록 되어 있습니다.
{
// ...
"types": "index.d.ts",
// ...
}
타입 정보를 라이브러리 자체적으로 관리하는 경우 라이브러리와 타입 선언 간의 버전 불일치 문제를 해결하긴 합니다. 그러나 번들링 방식은 부수적인 네 가지 문제점을 갖고 있습니다.
- 번들된 타입 선언에 보강 기법으로 해결할 수 없는 오류가 있는 경우, 또는 공개 시점에는 잘 동작했으나 TS 버전이 올라가며 오류가 발생하는 경우
- 번들된 타입에서는 별도로
@types
의 버전 선택이 불가능하다는 문제점이 있습니다.
- 번들된 타입에서는 별도로
- 프로젝트 내 타입 선언이 다른 라이브러리의 타입 선언에 의존하는 경우
- 해당 프로젝트를 다른 이용자가 설치하여 사용하게 되는 경우 별도로
devDependencies
가 설치되지 않으므로 타입 에러가 발생합니다. - 또, JS 사용자 입장에서는
@types
를 설치할 이유가 없기 때문에 dependencies에 포함하고 싶지 않을 것입니다. - 이러한 상황에 대한 해결책에 대해서는 아이템 51에서 다룰 예정입니다.
- 해당 프로젝트를 다른 이용자가 설치하여 사용하게 되는 경우 별도로
- 프로젝트의 과거 버전에 있는 타입 선언에 문제가 있는 경우
- 라이브러리 자체의 과거 버전으로 돌아가서 패치 업데이트를 해야합니다.
- 타입 선언의 패치 업데이트를 자주 하기가 어렵습니다.
- 라이브러리 자체보다 타입 선언에 대한 패치 업데이트가 훨씬 많을 수 있는데, @types의 경우 커뮤니티에서 관리되기 때문에 이러한 작업량이 감당될 수 있으나, 직접 프로젝트를 처리하려면 시간이 많이 소요되는 작업입니다.
어느 쪽을 선택해야 할까요?
공식적인 권장 사항은 라이브러리가 TS로 작성된 경우에만 타입 선언을 라이브러리에 포함하는 것입니다. 실제로 TS 컴파일러가 타입 선언을 대신 생성해 주기 때문에, TS로 작성된 라이브러리에 타입 선언을 포함하는 방식은 잘 동작합니다.
JS로 작성된 라이브러리라면 손수 작성한 타입 선언은 오류가 있을 가능성이 높고, 잦은 업데이트가 필요하게 됩니다. 이 경우에는 타입 선언을 DefinitelyTyped에 공개하여 커뮤니티에서 관리하고 유지보수하도록 맡기는 것이 좋습니다.
공개 API에 등장하는 모든 타입을 export하기
공개된 메서드에 등장한 어떤 형태의 타입이든 익스포트를 하는 것이 좋습니다. 어차피 메서드 자체가 공개된 이상, 라이브러리 사용자가 추출이 가능하므로, 애초에 익스포트하여 이용하기 쉬운 형태로 만드는 편이 좋습니다.
interface SecretName {
first: string;
last: string;
}
interface SecretSanta {
name: SecretName;
gift: string;
}
export function getGift(name: SecretName, gift: string): SecretSanta {
// COMPRESS
return {
name: {
first: 'Dan',
last: 'Van',
},
gift: 'MacBook Pro',
};
// END
}
이를테면, 위의 경우에 타입을 숨기기 위해서 일부러 각 인터페이스에 대해 export
를 하지 않았다고 하더라도, 어차피 사용자는 해당 인터페이스의 타입을 추출할 수 있습니다.
type MySanta = ReturnType<typeof getGift>; // type SecretSanta
type MyName = Parameters<typeof getGift>[0]; // type SecretName
다시 말해, 공개 API에 해당 타입들이 이용되는 순간, 어차피 해당 타입들은 노출된 상태이기 때문에 굳이 숨기려 하지 말고 라이브러리 이용자들을 위해 명시적으로 export하는 것이 좋습니다.
API 주석에 TSDoc 사용하기
공개 API 이용자를 위한 주석을 덧붙이는 경우 JSDoc 형태로 작성해야 합니다. TS 언어 서비스는 JSDoc 스타일을 지원하기 때문에 적극적으로 활용하는 것이 좋습니다. 이는 타입스크립트 관점에서 TSDoc이라 부르기도 합니다.
@param
와 @returns
를 추가하면 함수를 호출하는 부분에서 각 매개변수와 관련된 설명을 보여줍니다.
/**
* Generate a greeting.
* @param name Name of the person to greet
* @param salutation The person's title
* @returns A greeting formatted for human consumption.
*/
function greetFullTSDoc(name: string, title: string) {
return `Hello <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord"><span class="mord mathnormal">t</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.01968em;">tl</span><span class="mord mathnormal">e</span></span></span></span></span>{name}`;
}
타입 정의에 TSDoc을 사용할 수도 있습니다. 이 경우 객체의 각 필드에 커서를 올려보면 필드 별로 설명을 볼 수 있게 됩니다.
interface Vector3D {}
/** A measurement performed at a time and place. */
interface Measurement {
/** Where was the measurement made? */
position: Vector3D;
/** When was the measurement made? In seconds since epoch. */
time: number;
/** Observed momentum */
momentum: Vector3D;
}
또, TSDoc 주석은 마크다운 형식으로 꾸며지므로 마크다운 문법을 사용할 수 있습니다.
/**
* This _interface_ has **three** properties:
* 1. x
* 2. y
* 3. z
*/
interface Vector3D {
x: number;
y: number;
z: number;
}
주의해야 할 점으로, JSDoc의 경우 타입 정보를 명시하는 규칙(@param {string} name
이 있지만, TS에서는 타입 정보를 코드를 통해 확인할 수 있으므로 이를 TSDoc 상에서 명시해선 안 됩니다.
콜백에서 this에 대한 타입 제공하기
JS에서의 this
키워드는 매우 혼란스러운 기능입니다.
let
이나 const
로 선언된 변수가 렉시컬 스코프(Lexical Scope)인 반면, this
는 다이나믹 스코프(Dynamic Scope)입니다.
이는 정의된 방식이 아니라, 호출된 방식에 따라 가리키는 값이 달라집니다.
this
는 전형적으로 객체의 현재 인스턴스를 참조하는 클래스에서 가장 많이 쓰입니다.
class C {
vals = [1, 2, 3];
logSquares() {
for (const val of this.vals) {
console.log(val * val);
}
}
}
const c = new C();
c.logSquares();
// 1
// 4
// 9
위 상황에서 logSquares
에서 사용된 this
는 변수 c
를 가리키게 됩니다.
한편, 이것을 외부 변수에 넣고 호출하면 어떻게 되는지 살펴봅시다.
const c = new C();
const method = c.logSquares; // losing this
method(); // ERROR
이러한 에러가 발생하는 이유는, 사실 c.logSquares
의 호출이 실제로는 두 가지 작업을 수행하기 떄문입니다.
this
의 값을 바인딩합니다.C.prototype.logSquares
를 호출합니다.
이를 JS 상에서 온전히 제어하기 위해서는 명시적으로 this
를 바인딩해주어야 하는데, 이를 위해 call
, apply
, bind
와 같은 메서드들이 존재합니다.
이러한 this
바인딩은 종종 콜백함수에서 쓰입니다. React의 예시를 봅시다.
다음 예시에서 바인딩을 하지 않는다면 render
메서드 실행 시 this
가 undefined
가 되는 문제가 생깁니다.
class ResetButton {
constructor() {
this.onClick = this.onClick.bind(this);
}
render() {
return makeButton({text: 'Reset', onClick: this.onClick});
}
onClick() {
alert(`Reset {this}`);
}
화살표 함수를 사용하면 더 쉽게 이를 해결할 수 있습니다.
화살표 함수로 메서드를 변경하면 해당 클래스의 인스턴스의 생성할 때마다 제대로 바인딩된 this
를 가진 새 함수를 생성합니다.
class ResetButton {
render() {
return makeButton({text: 'Reset', onClick: this.onClick});
}
onClick = () => {
alert(`Reset {this}`); // "this"가 항상 인스턴스를 참조합니다.
}
}
이는 실제로는 다음과 같이 동작합니다.
class ResetButton {
constructor() {
var _this = this;
this.onClick = function () { // 인스턴스의 생성 시점에 `onClick` 함수를 선언합니다.
alert("Reset " + _this);
};
}
render() {
return makeButton({text: 'Reset', onClick: this.onClick});
}
}
TS에서 this
가 사용되는 콜백 함수를 다루기
콜백 함수 상에서 this
가 사용된다면 그 자체가 API의 일부가 되는 것이기 때문에 반드시 타입 선언에 포함되어야 합니다.
// 콜백함수인 `fn` 내부에서 `this`를 사용한다고 가정합시다.
function addKeyListener(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', e => {
fn.call(el, e);
});
}
이 때 해당 콜백 함수 타입의 첫 번째 매개변수에 있는 this
는 실제론 사용 시점에는 매개변수로 여겨지지 않으며, 특별하게 처리됩니다.
만약 해당 콜백 함수를 this
바인딩 없이 그냥 실행하려고 하는 경우에 에러를 출력하게끔 하여, 바인딩을 강제하도록 합니다.
function addKeyListener(
el: HTMLElement,
fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
el.addEventListener('keydown', e => {
fn(e); // The 'this' context of type 'void' is not assignable to method's 'this' of type 'HTMLElement'.(2684)
fn(el, e); // Expected 1 arguments, but got 2.(2554)
fn.call(el, e); // OK.
});
}
또, this
를 사용하는 해당 콜백함수를 작성하는 시점에 this
에 대한 타입이 명확하게 추론되기 때문에 타입 안정성을 확보할 수 있습니다.
const el = document.getElementById('div')!;
addKeyListener(el, function(e) {
// 앞선 타입 선언에 덕분에 알아서 `this`에 대한 타입을 추론합니다.
this.innerHTML; // this는 HTMLElement 입니다.
});
만약 화살표 함수를 사용하여 this
를 참조하려고 하는 경우, 해당 this
는 다른 것을 가리킬 것이기 때문에 적절히 에러를 출력해냅니다.
class Foo {
registerHandler(el: HTMLElement) {
addKeyListener(el, e => {
// 여기서의 `this`는 Foo의 인스턴스가 됩니다.
this.innerHTML;
// ~~~~~~~~~ Property 'innerHTML' does not exist on type 'Foo'
});
}
}
오버로딩 타입보다는 조건부 타입을 사용하기
오버로딩 타입을 사용하면 하나의 함수가 여러 타입에 대해 동작하는 경우에 대한 타입을 명시할 수 있습니다. (아이템 3) 허나 경우에 따라 해결하기가 까다로운 타입 선언 문제가 있을 수 있습니다.
function double(x: any) { return x + x; }
// 1. 유니온 타입으로 한번에 선언하는 방법
// 선언이 모호합니다. string => number 또는 number => string의 경우도 인정합니다.
function double(x: number | string): number | string;
const num = double(12); // string | number
const str = double('x'); // string | number
// 2. 제너릭을 사용하는 방법
// 타입이 구체적이긴 하나, 틀린 타입 선언입니다.
function double<T extends number | string>(x: T): T;
const num = double(12); // Type is 12 => WRONG: 144여야 합니다.
const str = double('x'); // Type is "x" => WRONG: "xx"여야 합니다.
// 3. 여러 번에 걸쳐 타입 선언을 하는 방법
// 타입이 비교적 명확하나, 유니온 타입과 관련해서 문제가 발생합니다.
function double(x: number): number;
function double(x: string): string;
const num = double(12); // Type is number
const str = double('x'); // Type is string
const f = (x: number | string) => double(x); // ERROR: Argument of type 'string | number' is not assignable to parameter of type 'string'.
이를 해결하기 위한 가장 좋은 해결책은 조건부 타입(Conditional Type)을 사용하는 것입니다.
조건부 타입은 타입 공간의 if
구문과 같습니다.
이 경우, 이전에 문제가 발생했던 모든 경우들에 대해 정상적으로 동작합니다.
function double<T extends number | string>(
x: T
): T extends string ? string : number;
const num = double(12); // Type is number
const str = double('x'); // Type is string
const f = (x: number | string) => double(x); // Type is (x: string | number) => string | number;
조건부 타입은 개별 타입의 유니온으로 일반화하는 과정을 거치기 때문에 타입이 보다 정확해집니다. 각각의 오버로딩 타입이 독립적으로 처리되는 반면, 조건부 타입은 타입 체커가 단일 표현식으로 받아들이기 때문에 유니온 문제를 해결할 수 있습니다.
이러한 장점으로 인해, 오버로딩 타입을 작성 중이라면, 조건부 타입을 사용해서 개선할 수 있을지 검토해 보는 것이 좋습니다.
의존성 분리를 위해 미러 타입 사용하기
직접 TS로 라이브러리를 작성하여 공개할 때는 필요 이상으로 의존성을 갖는 것을 피해야 합니다. 이를테면, 아래처럼 CSV 파일을 파싱하는 함수를 만든다고 할 때, NodeJS 사용자를 위해 매개변수에 Buffer 타입을 허용하였다고 가정합시다.
function parseCSV(contents: string | Buffer): {[column: string]: string}[] {
if (typeof contents === 'object') {
// It's a buffer
return parseCSV(contents.toString('utf8'));
}
// ...
}
여기서 쓰인 Buffer
타입은 NodeJS에 대한 타입 선언을 설치하여 얻을 수 있는데, 이 경우 작성한 라이브러리에 대한 타입 선언도 포함됩니다.
이는 @types/node
에 의존하기 때문에, 결국 devDependencies
로 포함하게 됩니다.
결국 이에 따라 @types
와 무관한 JS 개발자나, NodeJS를 프로젝트에 이용하지 않는 TS 개발자의 경우 사용하지 않는 모듈을 포함해야 하는 문제가 생겨납니다.
구조적 타이핑을 활용하세요
이 경우에 저희가 초기에 다루었던 구조적 타이핑을 적용할 수 있습니다. 사용할 타입을 완전히 가져다 쓰는 대신, 필요한 메서드와 속성에 대해서만 별도로 타입을 작성하는 방법을 이용할 수 있습니다.
예를 들어, 위의 예시에서 Buffer
의 경우는 다음과 같이 간략하게 타입을 선언하여 대체할 수 있습니다.
interface CsvBuffer {
// parseCSV에서 쓰이는 함수에 대해서만 타입 선언을 합니다.
toString(encoding: string): string;
}
구조적 타이핑의 관점에 따라, 해당 타입은 Buffer
와도 호환되기 때문에 실제로 NodeJS 프로젝트에서 Buffer
인스턴스로도 parseCSV
를 호출할 수 있습니다.
한편 다른 라이브러리에서의 타입 선언 대부분을 추출해야 하는 상황이라면, 차라리 명시적으로 @types
의존성을 추가하는 게 낫습니다.
테스팅 타입의 함정에 주의하기
프로젝트를 공개하려면 테스트 코드를 작성하는 것이 필수적이며, 타입 선언 역시 이러한 테스트를 거쳐야 합니다. 그러나 타입 선언을 테스트하는 것은 실제로 상당히 어렵습니다.
다음과 같은 함수가 있다고 가정합시다.
const square = (x: number) => x * x;
이를 테스트하는 가장 쉬운 방법은 이를 단순히 실행해보는 것입니다.
test('square a number', () => {
square(1);
square(2);
});
이러한 테스트의 문제점은, 오직 "실행"에 대해서 오류가 발생하는지 아닌지에 대해서만 체크를 한다는 것입니다. 반환값에 대해서는 체크하지 않기 때문에, 그 결과에 대해서는 관심이 없는 셈입니다.
이러한 문제를 해결하고자 반환 결과를 체크하기 위해 별도의 변수를 둘 수도 있습니다.
declare function map<U, V>(array: U[], fn: (u: U) => V): V[];
const lengths: number[] = map(['john', 'paul'], name => name.length);
다만 이 경우 두 가지 문제가 발생합니다.
- 불필요한 변수(ex.
lengths
)를 만듭니다. 이는 린팅과 관련한 경고를 유발할 수 있습니다. - 타입이 동일한지가 아니라, 할당가능한지에 대해서만 체크가 이루어집니다.
헬퍼 함수와 유틸 타입을 활용하세요
이 문제들을 해결하기 위한 일반적인 선택은 헬퍼 함수를 정의한 후, 유틸 타입과 함께 사용하여 Input과 Output에 대한 체크를 수행하는 것입니다.
// Helper func.
function assertType<T>(x: T) {}
type CustomMap<U, V> = (array: U[], fn: (u: U) => V) => V[];
// 매개변수 타입에 대한 테스트
const params: [number[], (n: number) => number] = null!;
assertType<Parameters<CustomMap<number, number>>>(params);
// 반환 타입에 대한 테스트
const result: number[] = null!;
assertType<ReturnType<CustomMap<number, number>>>(result);
콜백에서의 this 역시 고려되어야 합니다
콜백 함수에서 this
를 사용하는 경우 역시 타입을 가질 수 있으므로, 이 역시 테스트 시에 체크해주어야 합니다.
declare function map<U, V>(
array: U[],
fn: (u: U, i: number, array: U[]) => V
): V[];
const beatles = ['john', 'paul', 'george', 'ringo'];
// ma
assertType<number[]>(map(
beatles,
function(name, i, array) {
assertType<string>(name);
assertType<number>(i);
assertType<string[]>(array);
assertType<string[]>(this); // this에 대해서도 타입을 체크합니다.
return name.length;
}
));
테스트에서 any를 주의하세요
테스트에서도 any
는 여전히 나쁜 영향을 끼칩니다.
any
타입을 사용하는 경우 테스트는 전부 통과하겠지만, 타입 안정성을 포기하게 됩니다.
noImplicitAny
를 설정하더라도, 타입 선언을 통해 여전히 any
타입은 생겨나게 되며, 테스트를 하는 것이 매우 어려워집니다.
이러한 어려움 때문에 타입 체커와 독립적으로 동작하는 도구를 사용해 타입 선언을 테스트하는 방법이 권장되는데, dtslint가 좋은 예시가 됩니다.
map(beatles, function(
name, // $ExpectType string
i, // $ExpectType number
array // $ExpectType string[]
) {
this // $ExpectType string[]
return name.length;
}); // $ExpectType number[]
dtslint는 특별한 형태의 주석을 통해 동작하며, 이는 할당 가능성 체크가 아닌, 각 심벌의 타입을 추출하여 글자가 일치하는지를 비교합니다.
우리가 에디터 상에서 변수에 커서를 올려 타입을 확인하는 과정을 자동화하는 것과 유사합니다.
물론 이 경우 미묘한 단점은 있는데, string | number
와 number | string
과 같은 경우 사실 상 같은 타입이지만 다른 타입으로 인식해버립니다.
코드를 작성하고 실행하기
7장에서는 타입과 관계는 없지만 코드를 작성하고 실행하면서 실제로 겪을 수 있는 문제들을 다룹니다.
타입스크립트 기능보다는 ECMASCript 기능을 사용하기
JS는 본래 결함이 많고 개선해야 할 부분이 많은 언어였습니다. 이 때문에 각종 기능들을 프레임워크나 트랜스파일러로 보완하는 것이 일반적인 모습이었고, TS 또한 초기 버전에는 독립적으로 개발한 시스템을 포함한 형태였습니다.
하지만 시간이 지남에 따라 JS는 부족했던 부분들을 내장 기능으로 추가해나갔고, 기존에 독립적으로 개발된 기능들과 호환성 문제를 일으켰습니다. 현재 TS는 런타임 기능을 배제하고 오직 타입 기능만 발전시킨다는 명확한 원칙을 세우고 이에 따라 개발해나가고 있습니다.
헌데, 이러한 원칙 이전에, 기존에 존재하던 몇 가지 기능들이 있었으며, 이러한 기능들은 타입 공간과 값 공간 간의 경계를 혼란스럽게 만드므로 사용하지 않는 것이 좋습니다.
열거형 (enum)
enum Flavor {
VANILLA = 0,
CHOCOLATE = 1,
STRAWBERRY = 2,
}
let flavor = Flavor.CHOCOLATE; // Type is Flavor
Flavor // Autocomplete shows: VANILLA, CHOCOLATE, STRAWBERRY
Flavor[0] // Value is "VANILLA"
TS에서의 enum은 JS와 TS 간의 동작이 다르기 때문에 사용하지 않는 것이 좋습니다. 대신에 리터럴 타입의 유니온을 사용하면 됩니다.
type Flavor = 'vanilla' | 'chocolate' | 'strawberry';
매개변수 속성 (Parameter Properties)
TS의 매개변수 속성을 사용하면, 일반적으로 클래스를 초기화할 때 사용하는 경우의 문법을 보다 간결하게 작성할 수 있습니다.
// 일반적인 경우
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
// 매개변수 속성을 사용하는 경우
class Person {
constructor(public name: string) {}
}
매개변수 속성의 사용이 좋은지 나쁜지에 대해서는 찬반이 갈리는 문제입니다. 다만 기존 JS 문법과는 이질적이고 생소하다는 점과, 일반 속성과 같이 사용하는 경우 설계가 혼란스러울 수 있습니다.
네임스페이스와 트리플 슬래시 임포트(///
)
기존의 JS에는 모듈 시스템이 존재하지 않았고, 이 때문에 TS 역시 독자적인 모듈 시스템의 마련이 필요했습니다.
그 결과가 트리플 슬래시 임포트와 namespace
키워드이며, 이는 호환성을 유지하기 위해 남아 있을 뿐, 이제는 ES6+ 스타일의 모듈을 사용하는 것이 좋습니다.
namespace foo {
function bar() {}
}
/// <reference path="other.ts">
foo.bar();
데코레이터
데코레이터는 클래스, 메서드, 속성에 애너테이션(annotation)을 붙이거나 기능을 추가하는데 사용할 수 있습니다.
아래 예시는 클래스의 메서드 호출 시마다 logged
함수를 실행하는 경우입니다.
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
@logged
greet() {
return "Hello, " + this.greeting;
}
}
function logged(target: any, name: string, descriptor: PropertyDescriptor) {
const fn = target[name];
descriptor.value = function() {
console.log(`Calling {name}`);
return fn.apply(this, arguments);
};
}
console.log(new Greeter('Dave').greet());
// Logs:
// Calling greet
// Hello, Dave
이는 처음에 앵귤러 프레임워크를 지원하기 위해 추가되었으며, 실제로도 experimentalDecorators
속성을 설정하고 사용해야 합니다.
현재까지도 표준화가 완료되지 않은 기능이기 때문에, 호환성이 깨질 가능성이 있습니다.
객체를 순회하는 노하우
TS는 특정 타입에 할당 가능한 모든 경우에 대해 고려되기 때문에, 이 때 단순히 for
키워드를 통해 객체 타입을 순회하려고 하면 타입이 예상치 못하게 추론되는 경우가 있을 수 있습니다.
interface ABC {
a: string;
b: string;
c: number;
}
function foo(abc: ABC) {
for (const k in abc) { // const k: string
// 'a' | 'b' | 'c' 가 아닙니다.
const v = abc[k];
// ~~~~~~ Element implicitly has an 'any' type
// because type 'ABC' has no index signature
}
}
가령, 위의 매개변수 abc
에는 인터페이스 ABC
의 타입만 충족한다면 그 외에 추가적으로 어떤 속성을 갖더라도 타입 상으로 문제가 없기 때문에, 이는 정상적인 오류의 출력입니다.
만약, 이러한 타입을 좁히려면 keyof
키워드를 사용하면 됩니다.
단, 이 경우 실제로 해당 객체가 추가적인 키와 값을 가질 가능성이 없는지 판단해야할 필요가 있습니다.
function foo(abc: ABC) {
let k: keyof ABC;
for (k in abc) { // let k: "a" | "b" | "c"
const v = abc[k]; // Type is string | number
}
}
타입에 신경쓰지 않고 단순히 객체의 키와 값을 순회하고자 한다면 Object.entries
의 사용이 보다 일반적입니다.
물론 이 경우 키와 값의 타입이 추상적이기 때문에, 타입을 다루기에 까다롭습니다.
function foo(abc: ABC) {
for (const [k, v] of Object.entries(abc)) {
k // Type is string
v // Type is any
}
}
DOM 계층 구조 이해하기
TS에서는 이벤트를 다룰 때, EventTarget
에 달린 Node
의 구체적인 타입을 안다면 개발 및 디버깅이 용이하며, 언제 타입 단언을 사용해야 하는지 판단하기 쉽습니다.
한편, Event
역시 MouseEvent
, UIEvent
등 보다 구체적인 타입들이 존재합니다.
실제 개발 중에 이벤트 핸들러를 다룰 때에 Event
및 EventTarget
보다는 HTMLDivElement
, PointerEvent
등 구체적인 타입을 선언 및 단언하여 개발을 해나가는 것이 좋습니다.
function addDragHandler(el: HTMLElement) {
el.addEventListener('mousedown', eDown => {
const dragStart = [eDown.clientX, eDown.clientY];
const handleUp = (eUp: MouseEvent) => {
el.classList.remove('dragging');
el.removeEventListener('mouseup', handleUp);
const dragEnd = [eUp.clientX, eUp.clientY];
console.log('dx, dy = ', [0, 1].map(i => dragEnd[i] - dragStart[i]));
}
el.addEventListener('mouseup', handleUp);
});
}
const div = document.getElementById('surface');
if (div) {
addDragHandler(div);
}
특히 DOM을 다룰 때에는 TS 타입 체커보다 개발자인 우리가 더 타입에 대해 정확히 알고 있는 경우가 많으므로 타입 단언을 사용해도 좋습니다.
document.getElementById('my-div') as HTMLDivElement;
정보를 감추는 목적으로 private 사용하지 않기
JS는 클래스에 비공개 속성을 만들 수 없습니다. 비공개 속성임을 나타내기 위해 언더스코어(_
)로 접두사를 붙이는 것이 관례로 인정될 뿐, 실제로는 일반적인 속성일 뿐입니다.
헌데 TS에는 public
, protected
, private
접근 제어자가 있기 때문에, 이를 통해 공개 규칙을 강제할 수 있는 것으로 오해할 수 있습니다.
class Diary {
private secret = 'cheated on my English test';
}
const diary = new Diary();
diary.secret
// ~~~~~~ Property 'secret' is private and only
// accessible within class 'Diary'
헌데 이러한 접근 제어자들은 TS 키워드일 뿐, 컴파일 이후에는 제거되며, 그 결과 다음처럼 일반적인 JS 속성이 됩니다.
class Diary {
constructor() {
this.secret = 'cheated on my English test';
}
}
const diary = new Diary();
diary.secret;
TS 접근 제어자들은 단지 컴파일 시점에만 오류를 표시해줄 뿐, 언더스코어 관례와 마찬가지로 런타임에는 아무런 효력이 없습니다.
JS 및 TS에서 정보를 숨기기 위해 가장 효과적인 방법은 클로저를 사용하는 것입니다. 해당 챕터에서 클로저에 대해 깊게 다루기엔 범주를 벗어나므로, 여기를 참조하도록 합시다.
또 다른 선택지로는 비공개 필드 기능을 사용할 수 있습니다. 비공개 필드는 접두사로 #
를 붙여서 타입 체크와 런타임 모두에서 비공개로 만드는 역할을 합니다.
class PasswordChecker {
#passwordHash: number;
constructor(passwordHash: number) {
this.#passwordHash = passwordHash;
}
checkPassword(password: string) {
return hash(password) === this.#passwordHash;
}
}
해당 기능은 기존에(해당 책이 작성될 시점에도) 표준화가 진행 중인 단계였으나, ES2022에 접어들면서 공식적인 스펙이 되었습니다.
소스맵을 사용하여 타입스크립트 디버깅하기
TS는 런타임에서 직접 실행되지 않습니다. 엄밀히 말하면 TS 뿐 아니라 여러 압축 및 전처리 도구들 모두에 해당하는 내용입니다. 디버거는 런타임 시점에 동작하며, 현재 런타임에 실행 중인 코드가 어떤 과정을 거쳐 만들어졌는지 알지 못합니다. 이렇게 변환된 자바스크립트 코드는 복잡해서 디버깅하기 매우 어렵습니다.
소스맵 (source map)
소스맵은 변환된 코드의 위치와 심벌들을 원본 코드의 원래 위치와 심벌들로 매핑합니다. 대부분의 브라우저와 많은 IDE가 소스맵을 지원합니다.
TS 역시 이러한 소스맵에 대한 옵션이 존재합니다.
{
"compilerOptions": {
"sourceMap": true
}
}
이후 컴파일을 실행하면 각 .ts
파일에 대해 .js
와 .js.map
두 개의 파일을 생성하며, 이 중 .js.map
이 바로 소스맵에 해당합니다.
소스맵이 .js
파일과 함께 있으면 디버거에서 기존에 작성한 .ts
파일이 나타납니다.
이제 원하는 대로 브레이크포인트를 설정할 수 있고, 변수를 조사할 수 있습니다.
디버거 좌측의 app.ts
가 이탤릭 글꼴로 나오는 것은 곧 이것이 웹페이지에 포함된 (컴파일 된) 실제 파일이 아니라는 것을 의미합니다. 컴파일된 내용이 소스맵을 통해 TS처럼 보이는 것 뿐입니다.
TS 타입체커는 코드 실행 전에 많은 오류를 잡을 수 있지만, 디버거를 대체할 수는 없습니다. 소스맵을 통해 제대로 된 TS 디버깅 환경을 구축하는 것이 필요합니다.
주의사항
- TS와 함께 여러 번들러 및 압축기를 사용하고 있다면, 이것이 각자의 소스맵을 생성하게 됩니다. 이상적인 디버깅 환경을 위해선 이들이 원본 TS 소스로 매핑되도록 해야하며, 번들러가 기본적으로 TS를 지원한다면 문제 없겠지만, 그렇지 않다면 번들러가 소스맵을 인식할 수 있도록 추가적인 설정이 필요합니다.
- 프로덕션 환경에 소스맵이 유출되고 있지는 않은지 확인해야 합니다. 소스맵을 통해 공개해서는 안될 내용이 들어 있을 수 있습니다.
타입스크립트로 마이그레이션하기
모던 자바스크립트로 작성하기
TS는 타입 체크 기능 이외에 TS 코드를 특정 버전의 JS로 컴파일하는 기능도 갖고 있습니다. 예를 들어, 최신 TS 및 JS 코드를 ES3 스펙의 JS 코드로 컴파일할 수도 있습니다.
다시 말해, TS 컴파일러를 JS 트랜스파일러로써 사용할 수 있습니다.
만약, 기존에 JS로 작성된 프로젝트를 TS 기반으로 마이그레이션 하고자 한다면, 먼저 최신 버전의 JS로 코드를 수정해나가는 작업부터 해나가는 것이 좋습니다.
모던 JS에 대한 내용들은 다른 부분에서 더욱 알차게 다루고 있으니, 여기에서는 굳이 깊게 언급하지 않도록 하겠습니다.
- ES6 모듈 (
import
,export
) - Class
let
/const
for-of
또는forEach
등 배열 메서드- 화살표 함수
- 단축 객체 표현(Compact object literal) / 구조 분해 할당(Object destructuring)
- 매개변수 기본값
async
/await
타입스크립트 도입 전에 @ts-check와 JsDoc으로 시험해 보기
@ts-check
TS로 전환하기에 앞서, @ts-check
지시자를 사용하면 TS 전환 시에 어떤 문제가 발생하는지 JS 상에서 미리 시험해 볼 수 있습니다.
@ts-check
지시자를 통해 타입 체커가 파일을 분석하고, 발견된 오류를 보고하도록 지시합니다.
// @ts-check
const person = {first: 'Grace', last: 'Hopper'};
2 * person.first
// ~~~~~~~~~~~~ The right-hand side of an arithmetic operation must be of type
// 'any', 'number', 'bigint', or an enum type
그러나 @ts-check
지시자는 매우 느슨한 수준의 타입 체크를 수행합니다. 심지어는 noImplicitAny
설정을 해제한 것보다 느슨하므로 이에 주의해야 합니다.
만약 기존에 JSDoc 스타일의 주석을 사용 중이었다면, @ts-check
지시자 설정 시 기존 주석에 대한 타입 체크가 동작합니다.
// @ts-check
/**
* Gets the size (in pixels) of an element.
* @param {Node} el The element
* @return {{w: number, h: number}} The size
*/
function getSize(el) {
const bounds = el.getBoundingClientRect();
// ~~~~~~~~~~~~~~~~~~~~~ Property 'getBoundingClientRect'
// does not exist on type 'Node'
return {width: bounds.width, height: bounds.height};
// ~~~~~~~~~~~~~~~~~~~ Type '{ width: any; height: any; }' is not
// assignable to type '{ w: number; h: number; }'
}
@ts-check
지시자와 JsDoc
주석을 통해 JS 환경에서도 TS와 유사한 경험으로 개발이 가능하기 때문에 마이그레이션 과정에 도움이 됩니다.
하지만, 장기적인 관점에서는 주석의 양이 늘어나 로직의 해석을 방해한다는 문제가 있습니다. 때문에 궁극적으로는 모든 코드가 TS 기반으로 전환될 수 있는 것을 목표로 삼아야 합니다.
allowJs로 타입스크립트와 자바스크립트 같이 사용하기
프로젝트의 규모가 큰 경우, 한꺼번에 모든 JS 코드를 TS로 전환하는 것이 불가능하므로, 점진적인 전환 과정이 필요합니다. 그러러면 마이그레이션 기간 중에 TS와 JS가 동시에 동작할 수 있도록 하는 것이 필요합니다.
이것의 핵심은 allowJs
컴파일러 옵션인데, 이는 TS와 JS 파일을 서로 임포트할 수 있게 해줍니다.
번들러에 TS가 통합되어 있거나, 플러그인 방식으로 통합이 가능하다면 이를 쉽게 적용할 수 있습니다.
예를 들어, tsify
는 TS를 컴파일하기 위한 browserify
플러그인이며, 이를 다음과 같은 형태로 사용할 수 있습니다.
browserify index.ts -p [ tsify --allowJs ] > bundle.js
대부분의 유닛 테스트 도구 역시 동일한 역할을 하는 옵션이 있습니다.
예를 들어 jest
를 사용할 때 ts-jest
를 설치하고 jest.config.js
에 전달할 TS 소스를 지정할 수 있습니다.
module.exports = {
transform: {
'^.+\\.tsx?': 'ts-jest',
},
};
만약, 프레임워크 없이 빌드 체인을 직접 구성했다면, 다소 복잡하긴 하더라도 TS 컴파일링 후 outDir
에 지정한 디렉토리를 기반으로 기존의 빌드 체인을 실행하면 됩니다.
이 경우, TS가 생성한 코드가 기존의 JS 룰을 따르도록 출력 옵션을 조정해야 할 필요는 있습니다. (ex. target, module)
의존성 관계에 따라 모듈 단위로 전환하기
점진적으로 마이그레이션을 할 때는 모듈 단위로 해나가는 것이 이상적입니다. 허나 어떤 모듈을 골라 타입 정보를 추가하면, 해당 모듈이 의존하는 모듈에서 비롯되는 타입 오류가 발생하게 됩니다. 그렇기 떄문에, 의존성 관련 오류 없이 작업하기 위해서는 다른 모듈에 의존하지 않는 최하단 모듈부터 작업을 시작해 최상단에 있는 모듈을 마지막에 완성할 수 있도록 해야합니다.
서드파티 라이브러리
프로젝트 내 모듈들이 서드파티 라이브러리를 의존할 수 있어도, 그 역은 그렇지 않기 때문에 우선 서드파티 라이브러리의 타입 정보를 해결해야 합니다.
일반적으로 @types
모듈을 설치하면 됩니다.
npm install --save-dev @types/lodash
외부 API
외부 API를 호출하는 경우도 있기 때문에, 외부 API의 타입 정보도 추가해야 합니다. 서드파티 라이브러리와 마찬가지로, 프로젝트 내 모듈은 API에 의존하지만 API는 해당 모듈에 의존하지 않기 때문에 먼저 해결하는 것이 좋습니다.
특히, 외부 API에 대한 타입 정보는 특별한 문맥이 없어 TS가 추론하기 어렵습니다. 그렇기 때문에 API 사양을 기반으로 타입 정보를 생성해나가야 합니다.
리팩터링은 미루세요
TS로의 마이그레이션 작업을 하던 도중, 설계 상으로 이상한 점을 발견하더라도 당장에 리팩터링을 하기보다는 마이그레이션 작업에 집중하는 것이 좋습니다. 개선할 부분을 기록해 두고, 리팩터링은 TS 전환 작업이 완료된 이후에 생각해야 합니다.
마이그레이션의 완성을 위해 noImplicitAny 설정하기
프로젝트 전체를 .ts
로 전환했다면 매우 큰 진척을 이룬 것이지만, 마지막 단계로 noImplicitAny
를 설정하는 것이 필요합니다.
noImplicitAny
가 설정되지 않은 상태에서는 타입 선언에서 비롯된 실제 오류가 숨어있기 때문에 마이그레이션이 완료되었다고 할 수 없습니다.
예를 들어, 아래와 같이 indices
라는 속성을 가진 클래스 Chart
가 있다고 했을 때, noImplicitAny
가 설정되어 있지 않다면 다음과 같이 작성되더라도 문제가 없습니다.
class Chart {
indices: number[];
// ...
getRanges() {
for (const r of this.indices) {
const low = r[0]; // Type is any
const high = r[1]; // Type is any
// 에러가 출력되지 않습니다.
// ...
}
}
}
만약, noImplicitAny
가 설정되어 있다면 다음과 같이 any
에 대한 에러가 제대로 발생합니다.
class Chart {
indices: number[];
// ...
getRanges() {
for (const r of this.indices) {
const low = r[0];
// ~~~~ Element implicitly has an 'any' type because
// type 'Number' has no index signature
const high = r[1];
// ~~~~ Element implicitly has an 'any' type because
// type 'Number' has no index signature
// ...
}
}
}
처음에는 noImplicitAny
를 로컬에만 설정하고 작업하는 것이 좋습니다.
원격 상에서는 설정에 변화가 없어 빌드에 실패하지 않을 것이기 때문입니다.
그 외에도 타입 체크의 강도를 높이는 설정에는 여러 가지가 있습니다.
지금껏 이야기한 noImplicitAny
는 상당히 엄격한 설정이며, strictNullChecks
같은 설정을 적용하지 않더라도 대부분의 타입 체크를 적용한 것으로 볼 수 있습니다.
최종적으로 강력한 설정은 "strict": true
이며, 타입 체크의 강도는 팀 내의 모든 사람이 TS에 익숙해진 다음에 조금씩 높여가는 것이 좋습니다.
왜 React인가?
여기의 내용을 의역 및 일부 편집한 내용입니다.
컴포넌트는 비즈니스 로직, 애플리케이션 상태, 네트워크와 무관하게 있을 때 가장 이상적입니다. 동일한 props가 있다면, 동일한 형태로 렌더링되어야 하죠.
다른 프레임워크들이 MVC, MVVM 과 같은 패턴을 따를 때, React는 View에 대한 렌더링을 Model과 완전히 떼어놓으려는 시도를 했습니다. 그 노력이 바로 Flux 패턴입니다.
그렇다면 왜 이것이 MVC보다도 낫다고 여겨졌을까요?
2013년에 페이스북은 채팅 기능을 통합하기 위해 많은 노력을 기울였습니다. 애플리케이션 환경 전반에 걸쳐 라이브가 가능하고, 사이트의 모든 페이지에 통합된 기능이었죠. 이미 복잡한 애플리케이션 내에서의 새로운 복잡한 앱이었고, DOM의 비제어(uncontrolled) 변경과 더불어 수많은 이용자들의 병렬적이고 비동기적인 I/O도 페이스북 팀에게 어려운 과제였습니다.
예를 들어, 그 무엇이든지 간에 DOM을 멋대로 조작하고, 그것을 마음대로 변형할 수 있다면, 과연 적절한 화면이 렌더링된 것인지 어떻게 알 방도가 없습니다.
React 이전에는 이러한 "올바른 화면"에 대한 보장을 그 어떤 프레임워크도 할 수 없었습니다. DOM의 경쟁 상태는 이전 웹 애플리케이션의 가장 흔한 버그 중 하나였습니다.
비결정적 = 병행 처리 + 변형가능한 상태
React 팀이 가장 먼저 하고자 했던 것은 이러한 문제를 고치는 것이었고, 그러기 위해 두 가지 혁신이 필요했습니다.
- Flux 구조를 이용한 단방향 데이터 바인딩
- Immutable한 컴포넌트 상태 : 일단 설정 되고 나면, 컴포넌트의 상태는 변하지 않습니다. 상태의 변화는 현재 View의 상태를 변경하는 것이 아니라, 새로운 상태에 대한 새로운 View 렌더링을 유발합니다.
Flux 패턴을 통해, React는 통제 불가능한 변형의 문제를 다룰 수 있었습니다. 수많은 DOM의 업데이트를 위해 수많은 이벤트 리스너를 추가하는 대신에, React는 컴포넌트의 상태 조작을 위해서 유일한 방법을 사용합니다. 바로 액션을 Dispatch하는 것입니다. 이를 통해 Store의 상태가 변경되면, Store는 해당 컴포넌트를 리렌더링합니다.

그래서, "React를 왜 써야 하나요?"에 대한 대답은 심플합니다. 바로 결정론적인(deterministic) View를 손쉽게 렌더링할 수 있기 때문입니다.
주의 : 따라서, 우리가 VanillaJS를 다루는 것 처럼, DOM에 데이터를 보관하거나 조작하는 것은 안티패턴입니다. 이 경우 React를 쓰는 의미가 없어집니다.
결정론적인 렌더링 방식은 React의 유일한 트릭이었음에도, 이는 이미 엄청난 혁신이었습니다. 그럼에도 불구하고, React는 계속해서 더 뛰어난 기능들을 선보이고 있습니다.
JSX
JSX는 선언적인 형태로 커스텀 UI 컴포넌트를 생성할 수 있는 JS의 확장입니다. JSX는 다음과 같은 장점을 갖습니다.
- 쉽고, 선언적인 마크업
- 컴포넌트와 함께 배치
- 관심사를 구분할 수 있음 (ex. UI vs 상태 로직 vs 사이드이펙트)
- DOM의 차이를 추상화
- 내부적인 기술에 대한 추상화
단, JSX에서는 명심해야할 부분이 몇가지 있습니다.
class
어트리뷰트는 JSX에서className
이 됩니다.- List item 형태의 요소들은 반드시
key
어트리뷰트를 가져야 합니다. (여기에 대해서는 이 문서를 읽어보세요!)
Synthetic Events (합성 이벤트)
React에서는 DOM 이벤트에 대한 래퍼를 제공하는데, 이는 Synthetic Events라고 합니다. 이는 다음과 같은 장점을 갖습니다.
- 이벤트 핸들링 시에 플랫폼 간의 차이를 완화해줍니다.
- 자체적으로 메모리 관리가 자동으로 이루어집니다. 이를테면, 무한 스크롤 리스트를 만들 때, 메모리 누수를 방지하기 위해 이벤트 위임이 필요할 것입니다. 한편, Synthetic Event는 자동으로 최상단 부모 노드에 이벤트 위임을 적용하기 때문에, 이벤트 메모리 관리에 신경쓰지 않아도 됩니다.
컴포넌트 생명주기 (Component Lifecycle)
React의 컴포넌트 생명주기는 컴포넌트의 상태를 보호하기 위해 존재합니다. 컴포넌트 상태는 React가 컴포넌트를 그려내는 동안에는 변경되지 않아야 하기 때문입니다.
생명주기에 대한 이해는 곧 React가 동작하는 방식에 대한 이해입니다.
React의 컴포넌트 생명주기는 다음과 같이 나누어 볼 수 있습니다.

그리고, Update 시점은 다음과 같은 형태로 이루어집니다.

-
Render -
render
함수는 결정론적이며, 사이드이펙트를 포함해서는 안됩니다. 이것을 props를 가져와 JSX를 반환하는 순수 함수로 이해할 수 있습니다. -
Pre-Commit -
getSnapShotBeforeUpdate
생명주기 메서드를 이용해 DOM으로부터 데이터를 가져올 수 있습니다. 만약 스크롤 위치나 렌더링된 요소의 크기를 파악하고자 할 때 유용합니다. -
Commit - DOM과 ref들에 대한 갱신을 수행합니다.
componentDidUpdate
또는useEffect
훅을 통해 이를 이용할 수 있습니다. 여기에서는 사이드 이펙트를 유발하거나 DOM을 조작해도 괜찮습니다.
다음 그림이 React 컴포넌트의 전반적인 흐름을 파악할 수 있게끔 도와 줄 겁니다.

말했다시피, React에서의 컴포넌트는, "변형"을 가하는 것이 아니라, 상태의 변화에 따라 리렌더링 단계를 거쳐 새롭게 "대체"를 한다고 보는 것이 맞습니다. 이러한 단계를 통해 React의 "결정론적인 View 렌더링"을 쉽게 해줍니다.
다시 말해, React 컴포넌트의 대부분은 앞서 말한 것 처럼, props를 받아 JSX를 반환하는 순수함수로 생각될 수 있습니다.
Hooks
React Hooks는 클래스형 컴포넌트가 아닌 경우에도 React 컴포넌트 생명주기를 활용하기 위한 함수들입니다. Hooks의 사용은 일반적으로 사이드 이펙트의 유발을 일으킵니다. 여기서 사이드 이펙트란 함수의 반환값 외에 발생하는 함수 외부 값의 상태 변화를 의미힙니다.
Hooks는 결국 다음과 같은 것들을 가능하게 합니다.
- 클래스형 컴포넌트가 아니더라도 함수형 컴포넌트에서 생명주기 로직을 처리할 수 있습니다.
- 코드를 더 잘 정리할 수 있습니다.
- 다른 컴포넌트 간에 재사용할 수 있는 로직을 공유할 수 있습니다.
- 스스로 임의의 커스텀 훅을 만들 수 있습니다.
컨테이너 vs 프레젠테이션 컴포넌트
컴포넌트의 모듈화와 더 나은 재사용성을 위해 대체로 다음의 두 형태로 컴포넌트를 구분지을 수 있습니다.
- 컨테이너 컴포넌트는 데이터 스토어와 연결되어, 여러 사이드이펙트를 유발할 수 있습니다.
- 프레젠테이션 컴포넌트는 대부분 순수 컴포넌트이며, 동일한 컨텍스트 내 동일한 props에 대해서는 항상 동일한 JSX를 반환합니다.
프레젠테이션 컴포넌트는 다음과 같은 특징을 지닙니다.
- 네트워크와 접촉하지 않습니다.
- 로컬 스토리지에 저장 또는 불러오지 않습니다.
- 랜덤 데이터를 생성하지 않습니다.
- 현재 시스템 시간을 가져오지 않습니다.(
Date.now()
) - 데이터 스토어에 직접 접근하지 않습니다.
- 한편, form input과 같은 로컬 컴포넌트 상태를 사용할 수는 있습니다. 물론 이 경우, 최초 상태에서부터 결정론적인 유닛 테스트가 이루어져야 합니다.
컨테이너 컴포넌트는 다음과 같은 특징을 지닙니다.
- 상태 관리, I/O, 그 외의 사이드 이펙트를 유발합니다.
- 스스로에 대한 마크업을 렌더링하지 않아야 합니다.
- 프레젠테이션 컴포넌트의 래퍼로서 사용됩니다.
왜 Reducer는 순수해야 하는가?
여기의 글을 참조했습니다.
Redux는 상태의 변경에 있어서 Reducer를 사용합니다. 그런데 이 Reducer는 아시다시피 기본적으로 순수함수입니다. 동일한 Input이 있다면, 항상 동일한 Output을 반환해야 합니다. 이는 다시 말해, 해당 함수가 항상 Immutable하게 동작해야 한다는 뜻이죠.

다만, 단순히 생각해볼 때, 얼핏 이는 비효율적으로 보이기도 하죠. 왜 해당 상태를 직접 변경하지 않는 걸까요? 훨씬 더 간편할텐데요.
이는 기본적으로, Redux가 상태의 변경을 감지할 때, "얕은 비교"를 하기 때문입니다. 만약에 어떤 변화가 생겼다면, 해당 리듀서로부터 아예 새로운 객체를 반환받을 것이라 생각하는 것이죠.
그래서, Redux는 왜 이런 식으로 구현되어 있을까요?
사실, 답은 간단합니다. 이는 객체의 변화를 깊은 비교를 통해 감지하는 것보다 훨씬 빠르기 때문입니다. 만약 JS의 두 객체가 같은 프로퍼티를 가졌는지에 대해 판단하기 위해서는, 깊은 비교를 통해서 각각의 프로퍼티를 일일이 비교해나가야 합니다. 그런데 이 과정은 실제 애플리케이션에서는 굉장히 비용이 많이 드는 과정이죠. 이는 특히 애플리케이션의 규모가 커져 관리해야할 상태가 늘어나게 되면 더더욱 두드러집니다.
그래서 이를 대체하기 위한 방법으로, **개발자는 상태 변화가 생기면 매번 새로운 객체를 만들어 반환해줘야 한다.**라는 하나의 규칙이 Redux에 생겨난 것입니다. 만약 변화가 없다면, 기존의 객체를 반환하면 되는 것이죠. 다시 말해, 새로운 객체는 곧 새로운 상태를 의미합니다.
이러한 규칙이 존재한다면, Redux가 상태의 변화를 감지하는 것이 굉장히 쉬워집니다. 아시다시피 객체의 참조에 대한 비교는 직접 새로운 비교 알고리즘을 구현하지 않더라도 !==
만으로 쉽게 처리할 수 있거든요.
Introduction
Svelte란?
Svelte는 React, Vue와 같이 유연하게 상호작용 가능한 UI를 구성하기 위한 JS 프레임워크다.
단, 한가지 중요한 차이가 있는데, Svelte가 런타임 시점에 코드를 해석하는 것이 아니라 빌드 과정을 거친다는 점이다. 즉, 프레임워크의 추상화에 따른 성능적인 비용이 없고, 최초 로딩에 있어 부담이 덜하다.
Svelte에서 애플리케이션은 하나 이상의 컴포넌트들로 구성된다. 컴포넌트는 HTML / CSS / JS를 하나로 재사용 가능하게 묶은 코드 블럭이며, 이는 .svelte
확장자 파일로 관리된다.
Data 추가
<script>
let name = 'world';
</script>
<h1>Hello {name}!</h1>
동적 어트리뷰트
<script>
let src = 'some-image.png';
</script>
<img src={src} alt="A man dances.">
스타일링
<style>
p {
color: purple;
font-family: 'Comic Sans MS', cursive;
font-size: 2em;
}
</style>
<p>This is a paragraph.</p>
중첩 (Nested) 컴포넌트
<script>
import Nested from './Nested.svelte';
</script>
<p>This is a paragraph.</p>
<Nested/>
HTML 태그
JS에서 일반적인 string을 HTML 태그로서 삽입하고자 할 때 사용한다.
<script>
let string = `this string contains some <strong>HTML!!!</strong>`;
</script>
<p>{@html string}</p>
주의!
Svelte는
{@html ...}
내에 작성된 내용을 DOM에 추가하기 전에 그 어떤 처리도 하지 않는다. 다시 말해, 신뢰할 수 없는 출처를 통해 해당 기능을 적용하고자 하는 경우, 이에 대한 이스케이프를 직접 처리해주는 것이 매우 중요하다. 그렇지 않은 경우 XSS 공격의 위험이 있다.
프로젝트 세팅
Svelte는 Rollup이나 Webpack과 같은 빌드 툴과 함께 사용할 수 있다.
또, VS Code 상에서 Svelte 익스텐션을 설치하여 IDE 상의 피드백을 받을 수 있다.
Webpack을 통한 세팅이 완료가 되면, svelte-loader
가 각각의 컴포넌트들을 JS 클래스로 변환한다. 해당 컴포넌트들은 new
키워드로 아래와 같이 사용할 수 있다.
import App from './App.svelte';
const app = new App({
target: document.body,
props: {
// we'll learn about props later
answer: 42,
},
});
Reactivity
Assignments
기본적인 이벤트 핸들링은 다음과 같이 할 수 있다.
<script>
let count = 0;
function handleClick() {
count += 1;
}
</script>
<button on:click={handleClick}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
Declarations
Svelte는 컴포넌트의 상태가 변하면 자동으로 DOM을 업데이트한다. 종종 일부 상황에서, 특정 상태에 의존적인 다른 상태가 존재할 수 있는데, 이 경우 Reactive Declaration 기능을 통해 이를 처리할 수 있다. 정확한 원리는 JS의 [Label]을 참조하자. 다시 말해, 아래와 같이 `: doubled = count * 2;
function handleClick() {
count += 1;
}
{count} doubled is {doubled}
```물론, 단순히 doubled
를 새로 선언하지 않고 {count * 2}
와 같이 사용해도 된다.
Statements
Declaration 뿐 아니라 Statement를 처리할 수도 있다.
: {
console.log(`the count is {count}`);
alert(`I SAID THE COUNT IS {count}`);
}
if 문을 적용할 수도 있다.
: if (count >= 10) {
alert(`count is dangerously high!`);
count = 9;
}
Updating arrays and objects
React와 마찬가지로, 상태가 array 및 objects인 경우 push
나 splice
같은 메서드들은 업데이트를 유발하지 않는다. 이를 해결하기 위한 방안 역시 동일하다.
let numbers = [1, 2, 3, 4];
function addNumber() {
numbers = [...numbers, numbers.length + 1];
}
Props
Declaring props
특정 컴포넌트에서 하위 컴포넌트로 데이터를 전달해야할 때, 프로퍼티(props)를 지정해줄 필요가 있다.
Svelte에서는 해당 작업이 export
키워드를 통해서 이루어질 수 있다.
<script>
export let answer;
</script>
이를 상위 컴포넌트에서 사용하려면, 아래와 같은 식이다.
<Nested answer={42}/>
Default Values
아래와 같이 props가 전달되지 않은 경우에 대한 기본값을 지정해줄 수 있다.
<script>
export let answer = 'a mystery';
</script>
Spread Props
별도로 objects로 props들을 전달하려는 경우, spread 연산자로 이를 처리할 수 있다. React랑 똑같다.
<script>
import Info from './Info.svelte';
const pkg = {
name: 'svelte',
version: 3,
speed: 'blazing',
website: 'https://svelte.dev'
};
</script>
<Info {...pkg}/>
만약, 별도로 export
키워드를 통해 props를 지정하지 않았음에도, 전달받는 값을 사용해야 하는 경우, `
Logic
Svelte는 조건 혹은 루프와 같은 로직을 처리할 수 있다.
if
조건에 따라 특정 컴포넌트를 렌더링하고자 하는 경우, 아래와 같이 if
를 통해 감싸주자.
{#if user.loggedIn}
<button on:click={toggle}>
Log out
</button>
{/if}
{#if !user.loggedIn}
<button on:click={toggle}>
Log in
</button>
{/if}
else
else에 대해서는 아래와 같이 처리할 수 있다.
{#if user.loggedIn}
<button on:click={toggle}>
Log out
</button>
{:else}
<button on:click={toggle}>
Log in
</button>
{/if}
#
: 블럭을 여는 태그/
: 블럭을 닫는 태그:
: 블럭 내에서 사용되는 태그
else-if
{#if x > 10}
<p>{x} is greater than 10</p>
{:else if 5 > x}
<p>{x} is less than 5</p>
{:else}
<p>{x} is between 5 and 10</p>
{/if}
each
여러 개의 데이터가 Array 형태로 있는 경우, each
를 사용해 각각에 대한 순회 로직을 처리할 수 있다.
여기서의 Array는 배열 혹은 유사배열 객체라면 뭐든 가능하다.
또한, 두번째 인자로 index
가 전달되기 때문에, 추가적으로 이를 이용할 수 있다.
{#each cats as cat, i}
<li><a target="_blank" href="https://www.youtube.com/watch?v={cat.id}">
{i + 1}: {cat.name}
</a></li>
{/each}
Keyed each
기본으로, each
의 값에 대한 수정이 이루어지는 경우, 이들은 아예 처음부터 리렌더링 된다.
이 경우, 성능 상으로도 우려가 있을 뿐더러, 의도대로 동작하지 않을 가능성이 다분하다.
때문에, each
블럭에 별도의 고유 id
를 지정해줌으로써 변경되지 않는 item에 대해서는 리렌더링을 방지해줄 수 있다.
{#each things as thing (thing.id)}
<Thing current={thing.color}/>
{/each}
사실 Svelte는 내부적으로 key 관리에 Map
을 사용하기 때문에, 무엇이든지 key
로써 사용할 수 있다. 다시말해, 위의 경우에는 굳이 thing.id
가 아닌 thing
을 사용해도 된다. 하지만, 일반적으로는 string 혹은 number를 사용하는 것이 안전하다. key 변경 감지에 단순히 참조에 대한 동일성을 확인하기 때문.
Await
대부분의 웹 애플리케이션은 비동기적으로 데이터를 다루어야만 하는 경우가 발생한다. Svelte는 마크업 내에서 직접적으로 promise를 다룰 수 있게끔 한다.
{#await promise}
<p>...waiting</p>
{:then number}
<p>The number is {number}</p>
{:catch error}
<p style="color: red">{error.message}</p>
{/await}
오직 가장 최신의 promise에 대해서만 고려되며, 다시 말해 race condition에 대해 신경 쓸 필요가 없다.
사용되는 promise가 rejected 상태가 될 염려가 없는 경우에는, catch
블럭을 제거할 수도 있으며, 심지어 resolved 상태 이전에 별도로 보여주고자 하는 내용이 없는 경우에는 이조차 없애도 상관없다.
{#await promise then value}
<p>the value is {value}</p>
{/await}
Events
DOM events
이미 앞서 살펴본 것 처럼, on:
명령어를 통해 특정 요소에 이벤트 핸들러를 부여할 수 있다.
<script>
let m = { x: 0, y: 0 };
function handleMousemove(event) {
m.x = event.clientX;
m.y = event.clientY;
}
</script>
<div on:mousemove={handleMousemove}>
The mouse position is {m.x} x {m.y}
</div>
<style>
div { width: 100%; height: 100%; }
</style>
Inline handler
인라인으로 직접 부여할 수도 있다.
<script>
let m = { x: 0, y: 0 };
function handleMousemove(event) {
m.x = event.clientX;
m.y = event.clientY;
}
</script>
<div on:mousemove="{e => m = { x: e.clientX, y: e.clientY }}">
The mouse position is {m.x} x {m.y}
</div>
<style>
div { width: 100%; height: 100%; }
</style>
"
따옴표는 선택사항이다. 없어도 상관 없다.
일부 프레임워크에서는 이러한 인라인 이벤트 핸들러를 사용하는 것을 지양하지만, Svelte의 경우 컴파일링 단계에서 성능 상의 최적화가 진행되므로 문제가 없다.
Event modifiers
DOM 이벤트 핸들러들은 그들 동작을 변경해주는 Event modifier를 가질 수 있다. 이를테면, 아래처럼 once
를 이벤트 핸들러에 덧붙인 경우 해당 이벤트 핸들러는 딱 한번만 동작한다.
<button on:click|once={handleClick}>
Click me
</button>
아래는 Event modifier의 전체 목록이다.
preventDefault
- 핸들러를 동작시키기 전에event.preventDefault()
를 먼저 수행한다.stopPropagation
-event.stopPropagation()
을 호출한다. 다시 말해, 이벤트 버블링/캡처링을 막는다.passive
- 터치 및 마우스 휠 이벤트에 대한 스크롤 성능을 향상시킨다. [참조]nonpassive
-passive: false
를 의미한다.capture
- 이벤트 핸들러의 동작에 있어 버블링이 아닌 캡처링 단계를 사용한다.once
- 이벤트 핸들러를 딱 한번만 동작시키고 제거한다.self
- 오직 해당 요소 본인에서 이벤트가 발생했을 때만 이벤트 핸들러를 동작시킨다. (event.target === currentTarget)
이러한 Event modifier들은 on:click|once|capture={...}
와 같이 여러 개를 한번에 체이닝할 수 있다.
Component events
컴포넌트 역시 이벤트를 디스패치할 수 있다. 이를 위해서는 이벤트 디스패쳐를 만들어야한다.
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function sayHello() {
dispatch('message', {
text: 'Hello!'
});
}
</script>
위에서의 text
는 event.detail.text
를 통해 접근할 수 있다.
Event forwarding
DOM 이벤트와 다르게, 컴포넌트 이벤트는 버블링되지 않는다. 따라서, 상위 컴포넌트에서 이벤트를 전달받기 위해선 각 층의 컴포넌트마다 이벤트를 디스패치 해주어야 한다.
<script>
import Inner from './Inner.svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function forward(event) {
dispatch('message', event.detail);
}
</script>
<Inner on:message={forward}/>
단, 이러한 과정 자체가 너무 많은 코드를 작성하게 만드므로, Svelte에서는 이러한 내용을 다음과 같이 짧게 처리할 수 있다.
<script>
import Inner from './Inner.svelte';
</script>
<Inner on:message/>
DOM event forwarding
DOM 이벤트 역시 이벤트 포워딩을 처리할 수 있다. 해당 요소 본인이 처리할 이벤트 핸들러가 존재하지 않더라도, 상위 컴포넌트로 이를 전달해주는 역할을 한다.
<button on:click>
Click me
</button>
Bindings
Text inputs
일반적으로, Svelte에서의 데이터 흐름은 탑-다운 형식이다. 부모 컴포넌트에서 자식 컴포넌트에 props를 전달할 수 있고, 컴포넌트는 보유한 요소들에 대해 어트리뷰트를 설정할 수 있다.
가끔, 이러한 규칙을 깨야하는 경우가 있는데, 대표적인 경우가 컴포넌트 내에 <input>
태그를 보유한 경우다. 우리는 on:input
에 대한 이벤트 핸들러를 추가하여 event.target.name
에 따라 별도의 상태값을 변경하도록 구성할 수 있다. 다만, 이러한 과정 자체를 매번 반복하게 되면 너무 번거롭다.
대신에, 이러한 상황에 bind:value
명령을 사용할 수 있다.
<script>
let name = 'world';
</script>
<input bind:value={name}>
<h1>Hello {name}!</h1>
bind
를 사용하는 경우, input
의 value
값 변경에 따라 name
의 값을 변경할 뿐만 아니라, name
의 값이 변경됨에 따라 value
값 역시 변경된다.
Numeric inputs
DOM 상에서, 모든 값들은 string 으로 다루어진다. 이런 경우, type="number"
혹은 type="range"
인 상황에서, 값을 다루기에 다소 까다로워지며, 별도로 데이터 타입을 변환해주어야 한다.
Svelte에서는 bind:value
를 사용하면, 알아서 이러한 과정을 처리해준다.
<input type=number bind:value={a} min=0 max=10>
<input type=range bind:value={a} min=0 max=10>
Checkbox inputs
체크박스들은 상태 값들을 토글링(toggling)하기 위해 사용된다. 이 경우, 이들의 상태값은 value
가 아닌 checked
가 되므로, 다음과 같이 사용해야 한다.
<input type=checkbox bind:checked={yes}>
Group inputs
동일한 값에 대한 여러 input들이 존재한다면, bind:group
을 사용할 수 있다. 대표적으로 radio
혹은 checkbox
태그가 있다.
<script>
let scoops = 1;
let flavours = ['Mint choc chip'];
let menu = [
'Cookies and cream',
'Mint choc chip',
'Raspberry ripple'
];
function join(flavours) {
if (flavours.length === 1) return flavours[0];
return `<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.001892em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.03588em;">v</span><span class="mord mathnormal">o</span><span class="mord mathnormal">u</span><span class="mord mathnormal">rs</span><span class="mord">.</span><span class="mord mathnormal">s</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">i</span><span class="mord mathnormal">ce</span><span class="mopen">(</span><span class="mord">0</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">−</span><span class="mord">1</span><span class="mclose">)</span><span class="mord">.</span><span class="mord mathnormal" style="margin-right:0.05724em;">j</span><span class="mord mathnormal">o</span><span class="mord mathnormal">in</span><span class="mopen"><span class="mopen">(</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.751892em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span></span></span></span></span><span class="mpunct"><span class="mpunct">,</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.751892em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mclose">)</span></span><span class="mord mathnormal">an</span><span class="mord mathnormal">d</span></span></span></span>{flavours[flavours.length - 1]}`;
}
</script>
<h2>Size</h2>
<label>
<input type=radio bind:group={scoops} value={1}>
One scoop
</label>
<label>
<input type=radio bind:group={scoops} value={2}>
Two scoops
</label>
<label>
<input type=radio bind:group={scoops} value={3}>
Three scoops
</label>
<h2>Flavours</h2>
{#each menu as flavour}
<label>
<input type=checkbox bind:group={flavours} value={flavour}>
{flavour}
</label>
{/each}
{#if flavours.length === 0}
<p>Please select at least one flavour</p>
{:else if flavours.length > scoops}
<p>Can't order more flavours than scoops!</p>
{:else}
<p>
You ordered {scoops} {scoops === 1 ? 'scoop' : 'scoops'}
of {join(flavours)}
</p>
{/if}
Textarea inputs
<textarea>
요소는 <text>
와 거의 동일하게 동작하며, bind:value
를 사용하면 된다.
<textarea bind:value={value}></textarea>
만약, 변경할 상태의 변수명이 똑같이 value
로 일치한다면, 아래와 같이 약식으로 작성할 수 있다.
<textarea bind:value></textarea>
Select bindings
<select>
요소에도 bind:value
를 사용할 수 있다.
<select bind:value={selected} on:change="{() => answer = ''}">
<select>
하위에 있는 <option>
값들이 string이 아닌 object임을 주의하자.
selected
에 대한 기본값을 설정하지 않았기 때문에, binding 시에 기본적으로 select
는 리스트의 첫번째 값을 selected
로 설정해준다.
Select multiple
<select>
는 multiple
어트리뷰트를 사용할 수 있으며, 이 경우 하나 이상의 값들을 Array 형태로 받아올 수 있게 된다.
<h2>Flavours</h2>
<select multiple bind:value={flavours}>
{#each menu as flavour}
<option value={flavour}>
{flavour}
</option>
{/each}
</select>
Contenteditable bindings
contenteditable="true"
어트리뷰트를 보유한 요소들은 textContent
와 innerHTML
에 대해서도 바인딩을 할 수 있다.
<div
contenteditable="true"
bind:innerHTML={html}
></div>
Each block bindings
each
문 내에서도 바인딩을 할 수 있다.
<script>
let todos = [
{ done: false, text: 'finish Svelte tutorial' },
{ done: false, text: 'build an app' },
{ done: false, text: 'world domination' }
];
function add() {
todos = todos.concat({ done: false, text: '' });
}
function clear() {
todos = todos.filter(t => !t.done);
}
: remaining = todos.filter(t => !t.done).length;
</script>
<h1>Todos</h1>
{#each todos as todo}
<div class:done={todo.done}>
<input
type=checkbox
bind:checked={todo.done}
>
<input
placeholder="What needs to be done?"
bind:value={todo.text}
>
</div>
{/each}
<p>{remaining} remaining</p>
<button on:click={add}>
Add new
</button>
<button on:click={clear}>
Clear completed
</button>
<style>
.done {
opacity: 0.4;
}
</style>
이 경우 바인딩은 todos
Array를 직접 변경하게 된다. 본인이 불변성을 유지하는 형태를 선호한다면, 이 경우엔 바인딩을 사용하지 말고 이벤트 핸들러를 직접 작성하는 편이 좋다.
Media elements
<audio>
와 <video>
요소는 바인딩 할 수 있는 수많은 프로퍼티들이 존재한다.
아래는 바인딩 가능한 읽기 전용 프로퍼티다.
duration
(readonly) — 비디오 및 오디오의 전체 시간, 초 단위buffered
(readonly) — {start, end} 객체 Arrayseekable
(readonly) — {start, end} 객체 Arrayplayed
(readonly) — {start, end} 객체 Arrayseeking
(readonly) — booleanended
(readonly) — boolean
비디오의 경우는 videoWidth
및 videoHeight
읽기 전용 속성이 추가적으로 존재한다.
아래는 쌍방향으로 바인딩 가능한 프로퍼티들이다.
currentTime
- 비디오 및 오디오의 현재 위치playbackRate
- 비디오 및 오디오의 재생 속도,1
이 기본.paused
- 정지 여부volume
-0
부터1
사이의 값muted
- 음소거 여부, boolean
Dimensions
모든 블록 요소들은 clientWidth
, clientHeight
, offsetWidth
, 그리고 offsetHeight
에 대한 바인딩을 할 수 있다.
<div bind:clientWidth={w} bind:clientHeight={h}>
<span style="font-size: {size}px">{text}</span>
</div>
이들 바인딩은 읽기 전용이며, 이들 값을 직접 변경하는 것은 아무 의미가 없다.
주의 : 요소의 크기 등을 측정하기 위해서는 내부적으로 이런 테크닉을 사용한다. 이 경우, 오버헤드에 대한 우려 때문에 너무 많은 수에 대해 해당 바인딩을 사용하지 않는 것을 추천한다.
This
this
에 대한 바인딩은 읽기 전용이며, 모든 요소 및 컴포넌트에 사용할 수 있다.
이는 React에서의 Ref와 유사하며, 요소 및 컴포넌트에 대한 참조값을 얻을 수 있다.
<canvas
bind:this={canvas}
width={32}
height={32}
></canvas>
위 예시에서 canvas
는 컴포넌트가 마운트되기 전까지는 undefined
임에 유의하자. 때문에, 마운트 시에 해당 컴포넌트 및 요소에 특정 로직을 적용하려면 onMount
등의 라이프사이클 메서드를 사용해야 한다. 이에 대해선 추후 살펴본다.
Component bindings
DOM 요소들의 프로퍼티에 대해 바인딩을 할 수 있는 것처럼, 컴포넌트의 props에 대해서도 바인딩을 할 수 있다.
<Keypad bind:value={pin} on:submit={handleSubmit}/>
이 경우, 하위 컴포넌트에서 상태 변화가 일어나면, 바인딩을 적용한 부모 요소에 대해서도 이에 따른 변경이 일어난다.
주의 : 가능하다면 컴포넌트 바인딩은 사용하지 않는 편이 좋다. 애플리케이션 규모가 커지면서, 데이터 흐름이 너무 많아지는 경우 이에 대한 추적이 어려울 수 있다. 이러한 문제는 single souce of truth가 없는 경우에 더욱 심각해진다.
Lifecycle
onMount
모든 컴포넌트는 생성되는 시점에서부터 사라질 때까지 생명주기가 존재한다. Svelte 역시 이를 다루기 위한 몇가지 함수들을 제공한다.
onMount
는 그중 가장 자주 사용하게 될 생명주기 함수로, 최초로 DOM에 렌더링된 이후에 실행된다.
<script>
import { onMount } from 'svelte';
let photos = [];
onMount(async () => {
const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
photos = await res.json();
});
</script>
주의 : SSR에 의해,
fetch
메서드는script
태그의 최상위 보다onMount
에 위치하는 것이 좋다.onDestroy
를 제외하면, SSR 중에는 생명주기 함수가 실행되지 않으며, 다시말해 DOM에 마운트 된 이후에야 실행되어야 하는 데이터를 페칭하는 경우를 방지할 수 있다.
onMount
의 콜백함수에서 함수를 반환할 수 있는데, 이 경우 해당 함수는 컴포넌트가 사라질 때 호출된다. (clean up)
onDestroy
함수에 사라질 때 특정 로직을 처리하고자 할 때 onDestroy
함수를 사용할 수 있다.
<script>
import { onDestroy } from 'svelte';
let seconds = 0;
const interval = setInterval(() => seconds += 1, 1000);
onDestroy(() => clearInterval(interval));
</script>
이러한 생명주기 함수들을 React의 커스텀 Hook 처럼 사용할 수도 있다.
import { onDestroy } from 'svelte';
export function onInterval(callback, milliseconds) {
const interval = setInterval(callback, milliseconds);
onDestroy(() => {
clearInterval(interval);
});
}
beforeUpdate & afterUpdate
beforeUpdate
함수는 컴포넌트의 상태에 따라 DOM이 업데이트 되기 전마다 실행되며, 반대로 afterUpdate
는 DOM이 업데이트 된 이후에 실행된다.
단순히 상태 중심적인 방식으로는 처리하기 어려운 로직을 처리하기 위한 경우에 종종 유용하다. (ex. 스크롤 위치 변경 등)
let div;
let autoscroll;
beforeUpdate(() => {
autoscroll = div && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20);
});
afterUpdate(() => {
if (autoscroll) div.scrollTo(0, div.scrollHeight);
});
tick
tick
함수는 다른 생명주기 함수들과 다르게, 최초에 함수가 초기화되는 시점이 아닌, 어디서는 호출할 수 있다. 해당 함수는 보류 중인 상태 변경 사항이 DOM에 적용된 이후 즉시 resolved 되는 promise를 반환한다.
Svelte는 컴포넌트의 상태가 업데이트될 때, DOM을 바로 업데이트하지 않고, 적용할 다른 변경 사항이 없는지를 판단하기 위해 다음 마이크로태스트까지 대기한다. 이렇게 함으로써 불필요한 동작을 피하고, 브라우저가 더 효율적으로 작업을 일괄 처리할 수 있도록 도와준다.
<script>
import { tick } from 'svelte';
let text = `Select some text and hit the tab key to toggle uppercase`;
async function handleKeydown(event) {
if (event.key !== 'Tab') return;
event.preventDefault();
const { selectionStart, selectionEnd, value } = this;
const selection = value.slice(selectionStart, selectionEnd);
const replacement = /[a-z]/.test(selection)
? selection.toUpperCase()
: selection.toLowerCase();
text = (
value.slice(0, selectionStart) +
replacement +
value.slice(selectionEnd)
);
await tick();
this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;
}
</script>
<style>
textarea {
width: 100%;
height: 200px;
}
</style>
<textarea value={text} on:keydown={handleKeydown}></textarea>
위와 같이 사용하는 경우, await tick();
의 이전까지의 내용들이 모두 적용되고, DOM이 업데이트 된 이후, 그 다음의 로직들이 처리된다. 즉, DOM이 업데이트되고 나서야 await tick()
이후의 코드가 실행된다.
Stores
Writable stores
Svelte에서는 store를 통해 상태 로직을 컴포넌트와 분리할 수 있다. store는 상태값이 변경되었을 때 관련 컴포넌트들에게 알려주는 subscribe
메서드를 가진 단순한 객체다.
store 중에는 writable store가 있으며, 이는 set
과 update
메서드를 갖는다.
<script>
import { count } from './stores.js';
import Incrementer from './Incrementer.svelte';
import Decrementer from './Decrementer.svelte';
import Resetter from './Resetter.svelte';
let count_value;
const unsubscribe = count.subscribe(value => {
count_value = value;
});
</script>
update
는 인수를 콜백함수로 받으며, set
는 직접적인 값을 받는다.
function increment() {
count.update(n => n + 1);
}
function reset() {
count.set(0);
}
Auto-subscriptions
만약, 컴포넌트가 초기화와 제거를 여러번 반복하게 되는 경우, unsubscribe
함수를 실행하지 않게되면 메모리 누수의 위험이 있다.
따라서, 이를 방지하기 위해선 onDestroy
생명주기 메서드에서 이를 호출해주어야 한다.
<script>
import { onDestroy } from 'svelte';
import { count } from './stores.js';
import Incrementer from './Incrementer.svelte';
import Decrementer from './Decrementer.svelte';
import Resetter from './Resetter.svelte';
let count_value;
const unsubscribe = count.subscribe(value => {
count_value = value;
});
onDestroy(unsubscribe);
</script>
<h1>The count is {count_value}</h1>
헌데, 여러 컴포넌트에 대해 동일한 작업을 해주어야 하는 상황이라면 이런 방법이 다소 번거롭게 느껴질 수 있다.(boilerplatey) Svelte는 ``만 덧붙이면 store 값에 대해 앞선 작업들을 알아서 처리해준다.
<script>
import { count } from './stores.js';
import Incrementer from './Incrementer.svelte';
import Decrementer from './Decrementer.svelte';
import Resetter from './Resetter.svelte';
</script>
<h1>The count is {count}</h1>
이러한 Auto-subscription은 store의 변수들이 컴포넌트 최상위 스코프에서 선언 및 import 되었을 때만 제대로 동작한다.
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord">‘</span><span class="mord hangul_fallback">로변수명을시작하는것은</span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal">ore</span><span class="mord hangul_fallback">값을참조하겠다는것으로간주되며</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord hangul_fallback">그렇지않은경우에대해</span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord mathnormal" style="margin-right:0.03588em;">v</span><span class="mord mathnormal">e</span><span class="mord mathnormal">lt</span><span class="mord mathnormal">e</span><span class="mord hangul_fallback">는</span><span class="mord">‘</span></span></span></span>
로 임의의 변수를 선언하는 것을 방지한다.
Readable stores
모든 경우에 store를 참조하는 컴포넌트들에게 쓰기 권한을 부여할 필요는 없다. 이를테면, 시간, 마우스 위치, 지리적 위치 등을 다루는 경우가 그렇다.
이러한 경우에 readable store를 사용할 수 있다.
export const time = readable(new Date(), function start(set) {
const interval = setInterval(() => {
set(new Date());
}, 1000);
return function stop() {
clearInterval(interval);
};
});
readable
의 첫번째 인수는 초기값이며, 설정할 필요가 없다면 null
혹은 undefined
로 두면 된다.
두번째 인수는 start
콜백함수이며, 이는 set
콜백함수를 파라미터로 받고 stop
함수를 반환하는 함수다. start
함수는 최초의 subscriber에 의해 상태값이 참조되는 경우에 호출되며, stop
은 마지막 subscriber가 unscribe를 했을 때에 실행되는 cleanup 함수다.
Derived stores
특정 store의 값에 의존하는 다른 값이 있는 경우, derived
를 통해 새로운 store를 만들어 줄 수 있다. 아래는 특정 페이지가 열리고 나서의 시간을 측정한 상태값을 보유한 derived store다.
export const elapsed = derived(
time,
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.69862em;vertical-align:-0.0391em;"></span><span class="mord mathnormal">t</span><span class="mord mathnormal">im</span><span class="mord mathnormal">e</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">=></span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.10903em;">M</span><span class="mord mathnormal">a</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord">.</span><span class="mord mathnormal">ro</span><span class="mord mathnormal">u</span><span class="mord mathnormal">n</span><span class="mord mathnormal">d</span><span class="mopen">((</span></span></span></span>time - start) / 1000)
);
여러 inputs들로부터 derive store를 갖추고, 값을 반환하는 대신 명시적으로 set
를 통해 값을 변경해줄 수도 있다. 이에 대해서는 여기를 참조하자.
Custom stores
어떤 객체든 subscribe
메서드가 적절하게 실행되기만 하면, 이는 store로 취급된다. 이를 통해 유저가 임의로 커스텀 store를 만들어 로직을 처리할 수 있다.
해당 문서의 앞쪽에서 writable
을 통해 store를 다루었던 내용을 커스텀 store를 통해 리팩토링해보자.
function createCount() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0)
};
}
Store bindings
writable store를 사용하는 경우, 로컬 컴포넌트의 상태값과 store의 값을 binding 해줄 수 있다.
아래 예시에서는 name
store 값을 input
의 value
값에 바인딩을 해주는 예시다.
<input bind:value={name}>
이후, input의 value
에 변경이 있다면 name
store값 역시 자동으로 업데이트된다.
컴포넌트 내부에서 값을 직접 할당해줄 수도 있다.
<button on:click="{() => name += '!'}">
Add exclamation mark!
</button>
위에서의 name += '!'
은 `name.set(
Motion
Tweened
Svelte는 상호작용에 따른 매끄러운 UI 애니메이션을 구성하기 위한 툴을 제공한다.
tweened
를 통해 progress
store를 변경해보자.
<script>
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
const progress = tweened(0, {
duration: 400,
easing: cubicOut
});
</script>
tweened
의 첫번째 인수에는 초기값이, 두번째 인수에는 options
가 전달된다. 가능한 option
에는 다음과 같은 것들이 있다.
delay
- tween이 시작되기 전의 딜레이.ms
단위.duration
- tween의 동작 시간,ms
단위 혹은(from, to) => ms
함수easing
-p => t
함수interplate
- 커스텀(from, to) => t => value
함수. 기본적으로 Svelte는 number, date, 동일한 모양의 object, array에 대해서만 지원하기 때문에, 컬러스트링 등에 대해 적용하기 위해서는 별도로 커스텀 interpolator를 전달해야한다.
해당 옵션들을 progress.set
혹은 progress.update
의 두번째 인수로 전달할 수 있으며, 이 경우 기본 옵션에 오버라이딩된다. set
와 update
메서드는 tween이 완료될 때 resolved 되는 프라미스를 반환한다.
Spring
spring
함수는 tweened
보다 좀 더 자주 변경되는 값에 더 최적화된 함수이다.
<script>
import { spring } from 'svelte/motion';
let coords = spring({ x: 50, y: 50 });
let size = spring(10);
</script>
추가적으로 각각 0
과 1
사이의 {stiffness, damping}
옵션을 넘겨줄 수 있다.
let coords = spring({ x: 50, y: 50 }, {
stiffness: 0.1,
damping: 0.25
});
Transitions
The transition directive
Svelte는 transition
선언을 통해 트랜지션을 매우 쉽게 구현할 수 있다.
<script>
import { fade } from 'svelte/transition';
let visible = true;
</script>
<label>
<input type="checkbox" bind:checked={visible}>
visible
</label>
<p transition:fade>Fades in and out</p>
Adding parameters
트랜지션 함수들은 추가적으로 매개변수를 가질 수도 있다. 이번엔 fade
가 아닌 fly
의 예를 보자.
<script>
import { fly } from 'svelte/transition';
let visible = true;
</script>
<p transition:fly="{{ y: 200, duration: 2000 }}">
Flies in and out
</p>
트랜지션이 reversible하다는 점을 눈여겨보자. Svelte에서 제공하는 함수를 통해 구현된 트랜지션은 진행되는 도중에도 다시 되돌아올 수 있다.
In and out
요소가 나타날 때와, 없어질 때 각각의 Transition을 다르게 구현하고자 하는 경우, transition
명령 대신, 요소에 in
과 out
명령을 따로 지정할 수 있다.
import { fade, fly } from 'svelte/transition';
<p in:fly="{{ y: 200, duration: 2000 }}" out:fade>
Flies in, fades out
</p>
Custom CSS transitions
svelte/transition
모듈에는 자체적으로 유용한 빌트인 트랜지션들이 많이 있으나, 직접 트랜지션을 구성하는 것도 쉽다.
아래는 fade
함수의 소스코드다.
function fade(node, { delay = 0, duration = 400 }) {
const o = +getComputedStyle(node).opacity;
return {
delay,
duration,
css: (t) => `opacity: {t * o}`,
};
}
이 함수는 두 개의 매개변수를 받는다. 하나는 트랜지션이 적용될 노트이고, 다른 하나는 아래의 옵션들이다.
delay
- 트랜지션이 시작되기 전의 딜레이, ms 단위duration
- 트랜지션의 전체 길이, ms 단위easing
-p => t
easing 함수css
-(t, u) => css
함수, 여기서u === 1 - t
이다.tick
- 노드에 효과를 적용하는(t, u) => {...}
함수
t
는 인트로의 시작 또는 아웃트로의 끝에서 0이고, 인트로의 끝 또는 아웃트로의 시작에서 1이다.
가능하다면 대부분은 tick
프로퍼티가 아닌 css
프로퍼티를 반환해야 하는데, CSS 애니메이션은 가능하다면 브라우저의 버벅거림을 방지하기 위해 메인 스레드에서 실행되지 않기 때문이다. Svelte는 트랜지션을 시뮬레이션하고, CSS 애니메이션을 구성한 뒤 이를 실행한다.
이를테면, fade
트랜지션은 아래와 같은 CSS 애니메이션을 생성한다.
0% {
opacity: 0;
}
10% {
opacity: 0.1;
}
20% {
opacity: 0.2;
}
/* ... */
100% {
opacity: 1;
}
좀 더 창의적이고 쓸데없는 애니메이션을 하나 만들어보자.
<script>
import { fade } from 'svelte/transition';
import { elasticOut } from 'svelte/easing';
let visible = true;
function spin(node, { duration }) {
return {
duration,
css: t => {
const eased = elasticOut(t);
return `
transform: scale({eased}) rotate(<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathnormal">e</span><span class="mord mathnormal">a</span><span class="mord mathnormal">se</span><span class="mord mathnormal">d</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">∗</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mord">1080</span></span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mclose">)</span><span class="mpunct">;</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord mathnormal">co</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal" style="margin-right:0.02778em;">or</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">h</span><span class="mord mathnormal">s</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mopen">(</span></span></span></span>{~~(t * 360)},
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.10903em;">M</span><span class="mord mathnormal">a</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord">.</span><span class="mord mathnormal">min</span><span class="mopen">(</span><span class="mord">100</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">1000</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mord">1000</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">∗</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mord mathnormal">t</span><span class="mclose">)</span></span></span></span></span>{Math.min(50, 500 - 500 * t)}%
);`
}
};
}
</script>
Custom JS transitions
일반적으로는 가능하다면 CSS를 이용한 트랜지션을 사용하는 것이 좋지만, 일부 경우에는 JS 없이 구현하기 어려운 효과가 있을 수 있다. 대표적인 것이 타자기 효과다.
<script>
let visible = false;
function typewriter(node, { speed = 50 }) {
const valid = (
node.childNodes.length === 1 &&
node.childNodes[0].nodeType === Node.TEXT_NODE
);
if (!valid) {
throw new Error(`This transition only works on elements with a single text node child`);
}
const text = node.textContent;
const duration = text.length * speed;
return {
duration,
tick: t => {
const i = ~~(text.length * t);
node.textContent = text.slice(0, i);
}
};
}
</script>
<label>
<input type="checkbox" bind:checked={visible}>
visible
</label>
{#if visible}
<p in:typewriter>
The quick brown fox jumps over the lazy dog
</p>
{/if}
Transition events
트랜지션의 시작과 끝이 언제인지를 아는 것이 유용할 때가 있다. Svelte는 다른 DOM 이벤트들과 마찬가지로 해당 시점에 이벤트를 디스패치해준다.
<p
transition:fly="{{ y: 200, duration: 2000 }}"
on:introstart="{() => status = 'intro started'}"
on:outrostart="{() => status = 'outro started'}"
on:introend="{() => status = 'intro ended'}"
on:outroend="{() => status = 'outro ended'}"
>
Flies in and out
</p>
Local transitions
일반적으로 트랜지션은 컨테이너 블록이 추가되거나 없어지는 모든 경우에 실행된다.
만약, 모든 경우가 아니라, 요소 본인에 대한 직접적인 추가/삭제에 대해서만 트랜지션 효과를 주고자 한다면, local transition을 사용할 수 있다.
<div transition:slide|local>
{item}
</div>
Deferred transitions
Svelte의 트랜지션 엔진이 갖는 강력한 특징은 트랜지션을 지연시킬 수 있다는 점이다. 따라서, 여러 개의 요소 간에도 이를 조정할 수 있다.
crossfade
함수는 send
와 receive
라는 두 쌍의 트랜지션을 만들어낸다. 어떤 요소가 send
될 때, 해당 요소는 여기에 상응하는 received
요소를 찾고나서, 찾아낸 요소의 위치로 이동하며 트랜지션 효과를 실행한다.
<script>
import { quintOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
const [send, receive] = crossfade({
duration: d => Math.sqrt(d * 200),
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;
return {
duration: 600,
easing: quintOut,
css: t => `
transform: <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">an</span><span class="mord mathnormal">s</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal" style="margin-right:0.02778em;">or</span><span class="mord mathnormal">m</span></span><span class="mord mathnormal">sc</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">e</span><span class="mopen">(</span></span></span></span>{t});
opacity: {t}
`
};
}
});
// ...
<script>
이후 아래와 주고 받게 될 각각의 요소에서 사용한다.
<label
in:receive="{{key: todo.id}}"
out:send="{{key: todo.id}}"
>
<label
class="done"
in:receive="{{key: todo.id}}"
out:send="{{key: todo.id}}"
>
Animations
The animate directive
트랜지션이 적용되지 않는 컴포넌트들에 대해서도 애니메이션을 적용해야하는 경우가 있다. 이를테면, 리스트의 아이템 하나가 삭제됨에 따라 다른 아이템들을 서서히 이동하는 것을 구현해야 하는 경우다.
이를 위해서는 flip
함수를 사용한다. 이는 'First, Last, Invert, Play'의 준말이다.
import { flip } from 'svelte/animate';
이후, 트랜지션 외에 애니메이션이 적용되어야 하는 컴포넌트 및 요소에 animation을 추가해준다.
<label
in:receive="{{key: todo.id}}"
out:send="{{key: todo.id}}"
animate:flip="{{duration: 200}}"
>
위에서의 duration
은 d => ms
형태의 함수일수도 있다. 여기서의 d
는 요소가 움직여야 할 픽셀의 갯수에 해당한다.
다시 한번, 모든 트랜지션과 애니메이션은 JS보다는 CSS 상에서 구현되어야 한다는 점을 기억하자. 이는 메인 스레드의 진행을 막는 것을 방지하기 위해서다.
Actions
The use directive
Action은 기본적으로 요소 수준의 생명주기 함수다. 다음과 같은 내용들을 구현할 때 유용하다.
- 서드파티 라이브러리를 사용할 때
- 이미지에 대한 레이지 로딩
- 툴팁
- 커스텀 이벤트 핸들러 추가
여러 컴포넌트에 다양하게 사용될 로직들을 미리 모듈화 시켜놓고, use
를 통해 사용하는 방식이라고 이해하면 편하다.
export function pannable(node) {
// setup work goes here...
return {
destroy() {
// ...cleanup goes here
}
};
}
import { pannable } from './pannable.js';
<div class="box"
use:pannable
on:panstart={handlePanStart}
on:panmove={handlePanMove}
on:panend={handlePanEnd}
style="transform:
translate({coords.x}px,{coords.y}px)
rotate({coords.x * 0.2}deg)"
></div>
Adding parameters
트랜지션 및 애니메이션처럼, 액션 역시 인자를 전달받아서 로직 구성에 활용할 수 있다. 해당 매개변수는 node
다음의 두번째 매개변수로 전달받는다.
만약, 전달받는 매개변수가 변경될 수 있는 경우라면, destroy
외에도 update
함수를 추가적으로 반환해주어야 한다.
export function longpress(node, duration) {
// ...
const handleMousedown = () => {
timer = setTimeout(() => {
node.dispatchEvent(
new CustomEvent('longpress')
);
}, duration);
};
// ...
return {
update(newDuration) {
duration = newDuration;
},
destroy() {
// ...
}
};
}
이 후, 아래와 같이 인수를 전달한다.
<button use:longpress={duration}>
만약 여러개의 인자를 전달해야 하는 상황이라면, object를 전달하면 된다.
<button use:longpress={{duration, spiciness}}>
Classes
The class directive
다른 어트리뷰트와 마찬가지로, JS 어트리뷰트를 통해 class를 지정할 수 있다.
<button
class="{current === 'foo' ? 'selected' : ''}"
on:click="{() => current = 'foo'}"
>foo</button>
위와 같은 것은 UI 개발 상에서 일반적인 패턴으로, Svelte에서는 이를 단순화하기 위한 특별한 명령어를 갖고 있다. 아래 코드는 위와 동일하다.
<button
class:selected="{current === 'foo'}"
on:click="{() => current = 'foo'}"
>foo</button>
Shorthand class directive
종종, 클래스 이름은 그것이 의존하는 변수명과 동일한 경우가 많다.
<div class:big={big}>
<!-- ... -->
</div>
이 경우, 아래와 같이 짧게 작성할 수 있다.
<div class:big>
<!-- ... -->
</div>
Component composition
Slot
일반적으로 요소가 자식 요소들을 가질 수 있는 것처럼, 컴포넌트 역시 똑같이 적용될 수 있다.
이는 <slot>
요소를 통해 구현할 수 있는데, React에서의 {children}
과 매우 유사하다.
<div class="box">
<slot></slot>
</div>
Slot fallbacks
컴포넌트는 slot이 비어있을 경우에 제공할 fallback을 지정할 수 있다.
<div class="box">
<slot>
<em>no content was provided</em>
</slot>
</div>
Named slots
앞선 예시들은 모두 default slot을 사용했으나, 좀 더 구체적으로 어떤 slot에 위치해야 하는지에 대해 지정해주어야 하는 경우가 있을 수 있다.
이러한 경우에 named slot을 사용할 수 있다.
<article class="contact-card">
<h2>
<slot name="name">
<span class="missing">Unknown name</span>
</slot>
</h2>
<div class="address">
<slot name="address">
<span class="missing">Unknown address</span>
</slot>
</div>
<div class="email">
<slot name="email">
<span class="missing">Unknown email</span>
</slot>
</div>
</article>
이후, 각각의 slot에 요소를 추가하기 위해서는 slot
어트리뷰트를 추가적으로 작성해야 한다.
<ContactCard>
<span slot="name">
P. Sherman
</span>
<span slot="address">
42 Wallaby Way<br>
Sydney
</span>
</ContactCard>
Checking for slot content
`slots = { default: false, comments: true }
따라서, 컴포넌트에서는 이를 통해 다음과 같이 조건부로 UI를 구성할 수 있다.
```svelte
<article class:has-discussion={slots.comments}>
{#if slots.comments}
<div class="discussion">
<h3>Comments</h3>
<slot name="comments"></slot>
</div>
{/if}
Slot props
일부 상황에서는, 하위 컴포넌트에서 부모 컴포넌트로 데이터를 전달하여, 이에 따라 slot으로 전달할 내용을 변경해주어야 하는 경우가 생긴다.
이러한 상황에서 slot props를 활용할 수 있다. 가령, 아래의 Hoverable
컴포넌트는, slot에 hovering
값을 전달한다.
<div on:mouseenter={enter} on:mouseleave={leave}>
<slot hovering={hovering}></slot>
</div>
이후, hovering
을 <Hoverable>
컴포넌트의 내용에 전달하기 위해, let
명령어를 사용한다.
<Hoverable let:hovering={hovering}>
<div class:active={hovering}>
{#if hovering}
<p>I am being hovered upon.</p>
{:else}
<p>Hover over me!</p>
{/if}
</div>
</Hoverable>
별도로 변수명을 다시 지어도 된다. 아래의 경우에는 hovering
을 active
라는 이름으로 부모 컴포넌트 측에서 다시 이름지었다.
<Hoverable let:hovering={active}>
<div class:active>
{#if active}
<p>I am being hovered upon.</p>
{:else}
<p>Hover over me!</p>
{/if}
</div>
</Hoverable>
Named slot에서도 props를 가질 수 있는데, 이 경우 컴포넌트 자체보다는 slot="..."
어트리뷰트를 보유한 요소에서 let
명령어를 사용하자.
Context API
setContext and getContext
context API는 데이터를 props로 전달하거나, store를 통해 디스패칭하지 않고도 컴포넌트 간에 소통할 수 있는 매커니즘이다.
이는 React에서의 context와도 흡사하게 이해해도 좋다.
한 컴포넌트에서 setContext(key, context)
를 호출하면, 컴포넌트 본인을 포함한 하위 컴포넌트 모두에서 const context = getContext(key)
로 해당 값을 가져올 수 있다.
import { onMount, setContext } from 'svelte';
import { mapbox, key } from './mapbox.js';
setContext(key, {
getMap: () => map
});
context는 어떤 타입이든지 가능하며, 생명주기 함수와 마찬가지로 setContext
와 getContext
는 반드시 컴포넌트 초기화 시점에 호출되어야 한다. 컴포넌트가 마운트되기 전까지 위에서 반환하는 map
은 undefined
가 되므로, getMap
함수를 통해 우회적으로 가져오는 방법을 사용한다.
이제, 하위 컴포넌트에서는 아래와 같이 map
을 가져올 수 있다.
import { getContext } from 'svelte';
import { mapbox, key } from './mapbox.js';
const { getMap } = getContext(key);
const map = getMap();
Context keys
사실, 위에서의 key
는 아래와 같다.
const key = {};
key
로는 어떤 것이든 사용 가능하며, 그냥 setContext('mapbox', ...)
와 같이 string을 전달해도 된다.
단, key
로 string을 사용할 경우, 여러 컴포넌트 라이브러리들 간에 서로 충돌되는 key
를 사용할지도 모른다는 문제가 발생한다. 이를 방지하기 위해 위와 같이 객체 리터럴을 사용하면, 해당 key가 고유함을 보장받을 수 있다.
Context vs. Stores
Context는 store와 비슷해 보일 수 있다. 둘 사이의 차이점은, store가 애플리케이션의 전역에서 접근할 수 있는 반면, context는 해당 컴포넌트와 그 하위 컴포넌트들에서만 접근할 수 있다는 것이다.
상황에 따라 둘을 적절히 사용할 수도 있다. context는 반응적이지(reactive) 않으므로, 실시간으로 값이 변화하는 값들에 대해서는 아래와 같은 형태로 store를 사용하는 것이 더 적합하다.
const { these, are, stores } = getContext(...);
Special elements
svelte:self
Svelte는 여러 빌트인 요소들을 제공한다. 먼저, <svelte:self>
는 컴포넌트를 재귀적으로 활용할 수 있게 해준다. 기본적으로 모듈은 스스로를 import 하는 것이 불가능하기 때문에, <svelte:self>
가 필요하다.
이는 폴더 안에 다른 폴더가 포함될 수 있는 폴더 트리 뷰와 같은 것들을 구성할 때 유용하다.
{#if file.type === 'folder'}
<svelte:self {...file}/>
{:else}
<File {...file}/>
{/if}
svelte:component
<svelte:component>
는 동적으로 특정 컴포넌트가 해당 요소의 위치가 올 수 있을 경우에 활용할 수 있다.
이를테면, 아래처럼 조건부로 컴포넌트를 렌더링하고자 할 때, <svelte:component>
를 통해 하나의 동적 컴포넌트로 처리해줄 수 있다.
{#if selected.color === 'red'}
<RedThing/>
{:else if selected.color === 'green'}
<GreenThing/>
{:else if selected.color === 'blue'}
<BlueThing/>
{/if}
위의 코드는 아래의 한줄로 대체될 수 있다.
<svelte:component this={selected.component}/>
svelte:window
바닐라 JS 상에서 어떤 DOM 요소에든 이벤트 리스너를 추가할 수 있듯, Svelte에서 window
오브젝트에 이벤트 리스너를 추가하고자 한다면 <svelte:window>
를 이용하면 된다.
<svelte:window on:keydown={handleKeydown}/>
svelte:window bindings
window
의 특정 프로퍼티에 바인딩을 해줄 수도 있다.
<svelte:window bind:scrollY={y}/>
아래는 바인딩 가능한 프로퍼티의 리스트다.
innerWidth
innerHeight
outerWidth
outerHeight
scrollX
scrollY
online
-window.navigator.onLine
과 동일
scrollX
와 scrollY
을 제외한 모두는 읽기 전용 프로퍼티다.
svelte:body
<svelte:window>
와 비슷하게, <svelte:body>
요소 역시 document.body
에 직접적으로 이벤트 리스너를 추가해야 하는 경우에 사용할 수 있다.
window
에서는 발생하지 않는 mouseenter
혹은 mouseleave
이벤트를 사용해야 하는 경우에 유용하다.
<svelte:body
on:mouseenter={handleMouseenter}
on:mouseleave={handleMouseleave}
/>
svelte:head
<svelte:head>
요소는 문서의 <head>
내에 요소를 추가해야할 때 사용할 수 있다.
<svelte:head>
<link rel="stylesheet" href="tutorial/dark-theme.css">
</svelte:head>
SSR 모드의 경우, <svelte:head>
의 내용은 HTML의 나머지와 별도로 반환된다.
svelte:options
<svelte:options>
는 컴파일링 옵션을 설정할 수 있게 해준다.
예를 들어, 해당 컴포넌트에서 설정할 수 있는 immutable
옵션이 존재하는데, 이는 해당 컴포넌트가 immutable 데이터를 기반으로 동작함을 의미한다.
이를테면, 아래와 같이 Todo
컴포넌트가 존재한다고 하자.
<script>
import { afterUpdate } from 'svelte';
import flash from './flash.js';
export let todo;
let div;
afterUpdate(() => {
// 아래의 flash는 애니메이션 효과.
flash(div);
});
</script>
<!-- the text will flash red whenever
the `todo` object changes -->
<div bind:this={div} on:click>
{todo.done ? '👍': ''} {todo.text}
</div>
<style>
div {
cursor: pointer;
line-height: 1.5;
}
</style>
헌데, 아래처럼 여러 개의 Todo
를 갖는 리스트를 구현하려고 하는 상황을 생각해보자.
toggle
이 실행될 때, map
메서드에 의해서 새로운 todos
가 반환되며, 이에 따라 하위의 일부 Todo
에 대해서는 결론적으로는 변경된 데이터가 없어 굳이 새로 리렌더링할 필요가 없음에도 모든 Todo
컴포넌트가 리렌더링 과정을 거치게 된다.
let todos = [
{ id: 1, done: true, text: 'wash the car' },
{ id: 2, done: false, text: 'take the dog for a walk' },
{ id: 3, done: false, text: 'mow the lawn' },
];
function toggle(toggled) {
todos = todos.map((todo) => {
if (todo === toggled) {
// return a new object
return {
id: todo.id,
text: todo.text,
done: !todo.done,
};
}
// return the same object
return todo;
});
}
이러한 상황을 방지하기 위해서, immutable 옵션을 통해 props 값의 참조가 유지된다면 리렌더링을 수행하지 않도록 한다.
// <svelte:options immutable/>을 써도 된다.
<svelte:options immutable={true}/>
아래는 <svelte:options>
에서 설정할 수 있는 옵션들이다.
immutable={true}
- mutable 데이터를 사용하지 않겠다는 뜻으로, 컴포넌트는 값의 변경 여부를 확인하기 위해 단순 참조 비교(simple referential quality check)를 거친다.immutable={false}
- 기본값. 값이 변경되었는지의 여부에 대해 더 엄격하게 체크한다.accessors={true}
- 컴포넌트의 props에 대한 getter와 setter를 추가한다.accessors={false}
- 기본값.namespace="..."
- 컴포넌트가 사용될 네임스페이스. 일반적으로svg
에서 사용된다.tag="..."
- 컴포넌트를 커스텀 요소로 컴파일링할 때 사용할 이름.
svelte:fragment
<svelte:fragment>
요소는 named slot에 요소를 전달하고자 할 때, 굳이 별도의 컨테이너(ex. div
)를 통해 요소를 묶어주지 않아도 곧바로 전달할 수 있게끔 해준다. React에서의 Fragment와 동일하다.(<></>
)
<svelte:fragment slot="footer">
<p>All rights reserved.</p>
<p>Copyright (c) 2019 Svelte Industries</p>
</svelte:fragment>
Module context
Sharing code
지금껏 사용한 것 처럼, <script>
블록은 컴포넌트 초기화 시 각각의 컴포넌트 내에서 실행되는 로직들을 담고 있다. 그리고 대부분의 경우에는 이것으로 충분하다.
헌데, 아주 가끔 컴포넌트 외부에서 로직을 처리하여, 여러 컴포넌트에 해당 코드를 "공유"해야하는 경우가 생긴다. 이를테면, 음악 플레이어 컴포넌트를 만든 이후, 한 플레이어가 재생 중일 때 다른 플레이어를 중지시키는 로직을 구현하고자 하는 경우가 그렇다.
이 경우, <script context="module">
블록을 통해 처리할 수 있다. 해당 블록 내에서 실행되는 코드는 컴포넌트의 초기화 시점이 아닌, 최초로 evaluate되는 시점에 딱 한번만 실행된다.
<script context="module">
let current;
</script>
이제, 별도로 부모 컴포넌트에서 상태를 관리하지 않더라도, 동일한 컴포넌트의 인스턴스들끼리 소통하여 로직을 처리할 수 있다.
function stopOthers() {
if (current && current !== audio) current.pause();
current = audio;
}
Exports
context="module"
블록 내에서 export
되는 변수들은 실제 모듈 자체에서 export한 것처럼 다루어진다.
다시 말해, 아래와 같이 stopAll
함수를 export
한 경우,
// AudioPlayer.svelte
<script context="module">
const elements = new Set();
export function stopAll() {
elements.forEach(element => {
element.pause();
});
}
</script>
이는 일반적인 JS 모듈처럼 import
해서 사용할 수 있다.
<script>
import AudioPlayer, { stopAll } from './AudioPlayer.svelte';
</script>
<button on:click={stopAll}>
stop all audio
</button>
// ...
Svelte에서는 컴포넌트가 default export로 다루어지기 때문에, default export
를 사용할 수 없음에 주의하자.
Debugging
The @debug tag
때때로, 애플리케이션을 이용하는 중에 데이터 흐름을 체크하는 과정이 필요하다.
일반적으로 이는 console.log(...)
을 통해 처리되곤 하는데, 만약 실행을 멈추고 해당 값을 확인하고자 한다면, {@debug value1, value2, ...}
태그를 사용할 수 있다.
{@debug user}
<h1>Hello {user.firstname}!</h1>
이제 user
의 값이 변경될 떄마다 debugger가 동작할 것이다.
Introspection
여기의 내용을 Github Graphql API로 따라가보자.
타입 시스템을 사용하기 때문에, 우리는 현재 유효한 타입이 무엇인지 알 수 있으나, 그렇지 않은 경우 Query의 루트에서 사용할 수 있는 __schema
필드를 쿼리하여 GraphQL에 요청할 수 있다.
# query
{
__schema {
types {
name
}
}
}
// result
{
"data": {
"__schema": {
"types": [
{
"name": "AcceptEnterpriseAdministratorInvitationInput"
},
{
"name": "AcceptEnterpriseAdministratorInvitationPayload"
},
{
"name": "AcceptTopicSuggestionInput"
},
// ...
{
"name": "__Schema"
},
{
"name": "__Type"
},
{
"name": "__TypeKind"
}
]
}
}
}
직접 해보면 알겠지만, 엄청 많이 뜬다. 이를 몇개로 그룹화해볼 수 있다.
Query
,User
등 : 타입 시스템을 통해 정의한 것String
,Boolean
등 : 타입 시스템이 제공하는 내장 스칼라__Schema
,__Type
등 : 이들 앞에는__
가 붙어있는데, 이는 이것이 Introspection 시스템의 일부임을 나타낸다.
# query
query {
__schema {
queryType {
name
}
}
}
// result
{
"data": {
"__schema": {
"queryType": {
"name": "Query"
}
}
}
}
최상단의 Query
타입에서 위와 같이 요청하면, 다음과 같이 queryType
을 통해 우리가 __schema
를 요청한 지점이 Query
타입에 해당함을 확인할 수 있다.
보통은 특정 타입 내에서 검사하는 작업이 유용한 경우가 많으며, 아래에서 User
타입에 대해 살펴보자.
query {
__type(name: "User") {
name
description
kind
}
}
// result
{
"data": {
"__type": {
"name": "User",
"description": "A user is an individual's account on GitHub that owns repositories and can make new content.",
"kind": "OBJECT"
}
}
}
위와 같은 식으로 특정 타입(위에서는 User
)에 대한 상세한 정보를 얻을 수 있다. 여기에 더 나아가 해당 타입이 보유한 필드들에 어떤 것들이 있는지 찾아보자.
query {
__type(name: "User") {
name
description
kind
fields {
name
type {
name
kind
ofType {
name
kind
}
}
}
}
}
{
"data": {
"__type": {
"name": "User",
"description": "A user is an individual's account on GitHub that owns repositories and can make new content.",
"kind": "OBJECT",
"fields": [
{
"name": "anyPinnableItems",
"type": {
"name": null,
"kind": "NON_NULL",
"ofType": {
"name": "Boolean",
"kind": "SCALAR"
}
}
},
{
"name": "avatarUrl",
"type": {
"name": null,
"kind": "NON_NULL",
"ofType": {
"name": "URI",
"kind": "SCALAR"
}
}
},
{
"name": "bio",
"type": {
"name": "String",
"kind": "SCALAR",
"ofType": null
}
},
// ...
{
"name": "watching",
"type": {
"name": null,
"kind": "NON_NULL",
"ofType": {
"name": "RepositoryConnection",
"kind": "OBJECT"
}
}
},
{
"name": "websiteUrl",
"type": {
"name": "URI",
"kind": "SCALAR",
"ofType": null
}
}
]
}
}
}
(어지럽다..)
유의할만한 내용으로는 위에서 볼수 있는 anyPinnableItems
와 같은 필드의 경우에는 NON_NULL wrapper
타입에 해당하기 때문에, 타입에 대한 이름(name
)이 존재하지 않는다.
이 경우, 해당 필드에서 ofType
을 쿼리해 추가로 정보를 얻어보면, 해당 타입이 Boolean!
에 해당함을 확인할 수 있다. (아래 일부)
// fields에 반환되는 내용 중 일부
{
"name": "anyPinnableItems",
"type": {
"name": null,
"kind": "NON_NULL",
"ofType": {
"name": "Boolean",
"kind": "SCALAR"
}
}
},
이는 LIST wrapper
타입의 경우도 마찬가지이며, 아래와 같이 깊숙한 정보를 요구할 수도 있다.
query {
__type(name: "Repository") {
name
description
kind
fields {
name
type {
name
kind
ofType {
name
kind
ofType {
name
kind
}
}
}
}
}
}
// fields에 반환되는 내용 일부
{
"name": "viewerPossibleCommitEmails",
"type": {
"name": null,
"kind": "LIST",
"ofType": {
"name": null,
"kind": "NON_NULL",
"ofType": {
"name": "String",
"kind": "SCALAR"
}
}
}
}
위의 viewerPossibleCommitEmails
는 [String!]
에 해당함을 확인할 수 있다.
앞서 봤듯이, Introspection 기능을 통해 타입 시스템의 문서에 접근할 수 있고, 문서 탐색기 및 풍부한 IDE 환경을 만들 수 있다.
이는 Introspection 시스템의 극히 일부에 해당하며, 여기에 GraphQL의 Introspection 시스템을 구현하는 코드가 있으니 추후에 따로 확인해보자.
이 문서의 내용을 실습하며 따라가보려고 한다. (TypeScript를 사용)
GraphQL 쿼리의 각 필드는 특정한 타입의 값을 반환하는 함수로 생각할 수 있으며, 이는 사실 실제 GraphQL의 작동방식이기도 하다.
타입의 각 필드는 GraphQL 서버 측의 resolver
함수에 대응되며, 해당 필드가 string
이나 number
같은 스칼라 값을 반환하게 되면 실행이 완료된다.
반면, 필드가 객체를 반환하는 경우, 쿼리는 해당 객체에 적용되는 다른 필드들을 포함하게 되며, 이는 스칼라 값에 도달할 때까지 반복된다.
즉, GraphQL 쿼리의 끝은 항상 스칼라 값이어야 한다.
Root fields & resolvers
모든 GraphQL 서버의 최상위 레벨은 GraphQL API에서 사용 가능한 모든 진입점을 나타내는 타입이며, 이는 Root
타입 혹은 Query
타입으로 불린다.
// resolver
const resolvers = {
Query: {
game: (obj: any, { id }: GameArgs, context: Context) => {
return context.db.games.find(({ id: gameId }) => id === gameId);
},
// ...
},
// ...
};
각 필드의 resolver
함수는 네 개의 매개변수를 받는데, 다음과 같다.
obj
: 부모 객체, 위에서는 이것이Query
Type에 해당하므로 거의 쓰일 일이 없다.args
: GraphQL 쿼리의 필드에 제공된 인수. 이를테면game(id: '1') {...}
과 같은 경우에는 args가{ id: '1' }
이 된다.context
: 모든resolver
함수들에 동일하게 전달되며, 데이터베이스 접근이나 로그인 세션 등에 활용될 수 있다.info
: 현재의 쿼리, 스키마 정보와 관련된 필드별 정보를 보유하며, 자세한 내용은 여기를 참조하자.
Async Resolvers
// 임의로 작성됨
const resolver = async (obj, args, context) {
const result = await context.db.gameInfo(args.id);
return result.data;
};
위와 같이 임의로 작성된 비동기 resolver의 경우에도 정상적으로 동작한다.
하지만, 여기서는 실제 DB에 접근하지 않고 임의의 객체로 만든 Mocking DB를 활용할 것이므로, 편의상 일반적인 함수를 통해 resolver를 구현하겠다.
Trivial resolvers
앞서 Game
객체에 대해 접근하는 resolver를 작성했으므로, 이제 이 Game
객체 내 각 필드를 구체화해보자.
const resolvers = {
Query: {
game: (obj: any, { id }: GameArgs, context: Context) => {
return context.db.games.find(({ id: gameId }) => id === gameId);
},
},
Game: {
// id의 resolver 첫번째 파라미터는 이제 Game 객체가 된다.
id: (game: Game) => game.id,
// ...
},
};
아래와 같은 구성으로 타입을 지정했다고 하자.
const typeDefs = gql`
enum Score {
good
normal
bad
}
type Query {
game(id: ID!): Game
developer(id: ID!): Developer
}
type Game {
id: ID!
title: String!
developer: Developer!
score: Score!
}
type Developer {
id: ID!
name: String!
games: [Game]!
}
`;
이에 대해 객체 타입에 대한 resolver 작성을 한꺼번에 해보면 이런 식이다.
const resolvers = {
Query: {
game: (_: any, { id }: GameArgs, context: Context) => {
return context.db.games.find(({ id: gameId }) => id === gameId);
},
developer: (_: any, { id }: DeveloperArgs, context: Context) => {
return context.db.developers.find(
({ id: developerId }) => id === developerId,
);
},
},
Game: {
id: (game: Game) => game.id,
title: (game: Game) => game.title,
developer: ({ developer: id }: Game, _: any, context: Context) => {
return context.db.developers.find(
({ id: developerId }) => id === developerId,
);
},
score: (game: Game) => {
return game.score;
},
},
Developer: {
id: (developer: Developer) => developer.id,
name: (developer: Developer) => developer.name,
games: ({ games }: Developer, _: any, context: Context) => {
return games.map((gameId) =>
context.db.games.find(({ id }) => id === gameId),
);
},
},
};
여기 문서에 따라, GraphQL의 쿼리를 직접 실습해보려고 한다.
여기서는 실습을 위해 Github의 GraphQL API를 활용했다.
Fields
GraphQL의 핵심은 쿼리와 결과가 거의 동일한 형태를 보인다는 것이다. 덕분에 항상 클라이언트가 기대한 결과값을 얻을 수 있다.
# query
{
viewer {
email
interactionAbility {
origin
}
}
}
// result
{
"data": {
"viewer": {
"email": "",
"interactionAbility": {
"origin": "USER"
}
}
}
}
아래 예제에서 licenses
는 배열을 반환하며, 배열 안 각각의 Item에 대해 name
만을 가져온다.
쿼리문 자체는 모두 동일해보이지만, GraphQL 스키마를 기반으로 예상되는 결과를 알 수 있다.
# query
{
licenses {
name
}
}
// result
{
"data": {
"licenses": [
{
"name": "GNU Affero General Public License v3.0"
},
{
"name": "Apache License 2.0"
},
{
"name": "BSD 2-Clause \"Simplified\" License"
},
...
]
}
}
Arguments
필드에 인자를 전달할 수도 있다.
# query
{
user(login: "Shubidumdu") {
name
location
}
}
// result
{
"data": {
"user": {
"name": "Won Gyo Seo",
"location": "Seoul, South Korea"
}
}
}
Aliases
만약, 여러 결과 객체 필드가 동일한 이름을 갖는 경우(위에서는 user
), 충돌이 일어난다. 아래는 닉네임을 통해 여러 유저의 정보를 가져오는 예시인데, 아래대로라면 에러가 발생한다.
# query
{
user(login: "Shubidumdu") {
name
location
}
user(login: "adam-p") {
name
location
}
}
// result
// 에러 발생!
{
"errors": [
{
"path": [],
"extensions": {
"code": "fieldConflict",
"fieldName": "user",
"conflicts": "{login:\"\\\"Shubidumdu\\\"\"} or {login:\"\\\"adam-p\\\"\"}"
},
"locations": [
{
"line": 2,
"column": 2
},
{
"line": 6,
"column": 3
}
],
"message": "Field 'user' has an argument conflict: {login:\"\\\"Shubidumdu\\\"\"} or {login:\"\\\"adam-p\\\"\"}?"
}
]
}
이러한 상황에서 Alias를 사용할 수 있다. 각각의 user
결과에 대해 이름을 지정해주자.
# query
{
me: user(login: "Shubidumdu") {
name
location
}
not_me: user(login: "adam-p") {
name
location
}
}
// result
{
"data": {
"me": {
"name": "Won Gyo Seo",
"location": "Seoul, South Korea"
},
"not_me": {
"name": "Adam Pritchard",
"location": "Toronto, Canada"
}
}
}
Fragments
상대적으로 복잡한 페이지의 경우, Fragment라는 재사용 가능한 단위가 사용될 수 있다. 이를 사용하면 미리 필드셋을 구성한 다음 쿼리에 포함시킬 수 있다.
앞서 여러 유저들의 정보를 가져오는 쿼리를 Fragment를 통해 다시 만들어보면 아래와 같아진다.
# query
{
me: user(login: "Shubidumdu") {
...userInfo
}
not_me: user(login: "adam-p") {
...userInfo
}
}
fragment userInfo on User {
name
location
}
// result
{
"data": {
"me": {
"name": "Won Gyo Seo",
"location": "Seoul, South Korea"
},
"not_me": {
"name": "Adam Pritchard",
"location": "Toronto, Canada"
}
}
}
결과는 동일하지만, 쿼리 시에 일일이 객체 필드를 작성해줄 필요가 없게 되었다.
Fragment 안에서 매개변수(variables) 사용하기
쿼리 및 뮤테이션에다 선언한 변수는 Fragment를 통해서도 접근할 수 있다.
아래는 기존의 userInfo
에서 avatarSize
변수를 통해 임의의 사이즈를 가진 avatar 이미지를 추가로 쿼리한 것이다.
# query
query UserInfos(avatarSize: Int = 100) {
me: user(login: "Shubidumdu") {
...userInfo
}
not_me: user(login: "adam-p") {
...userInfo
}
}
fragment userInfo on User {
name
location
avatarUrl(size: avatarSize)
}
// result
{
"data": {
"me": {
"name": "Won Gyo Seo",
"location": "Seoul, South Korea",
"avatarUrl": "https://avatars.githubusercontent.com/u/54790378?s=100&u=9fa9c08aa2c952a873633a1baf3ea342a4c45855&v=4"
},
"not_me": {
"name": "Adam Pritchard",
"location": "Toronto, Canada",
"avatarUrl": "https://avatars.githubusercontent.com/u/425687?s=100&v=4"
}
}
}
Operation name (작업명)
지금껏 query
키워드와 이름을 모두 생략한 채 { ... }
와 같은 형태로 쿼리를 요청했다.
하지만 실제로 애플리케이션에 GraphQL을 적용하고자 할 때는 코드를 최대한 덜 헷갈리게 만드는 편이 좋다.
바로 위의 쿼리에서는 UserInfos
와 같은 식으로 이름을 지정했다.
작업 타입은 query
, mutation
, subscription
이 될 수 있으며, 해당 작업이 어떤 형태의 작업인지를 나타낸다.
작업명은 명시적인 작업의 이름인데, 디버깅 및 로깅에 있어 매우 유용하다. 임의의 쿼리 결과를 찾아내는 것보다, 직접 쿼리명을 찾아내는 것이 훨씬 쉽기 때문이다.
Variables (변수)
지금껏 앞의 모든 예시에서 인자들은 쿼리 문자열에 함께 작성되었다. 허나, 대부분 필드에 대한 인자는 동적이다.
클라이언트 측에서는 쿼리 문자열을 런타임 시점에 동적으로 조작하고, 이를 GraphQL의 특정 포맷으로 Serialize해야 한다.
그렇기 때문에 동적 인자들을 쿼리 문자열에 직접 전달하는 것은 좋은 방법이 아니다. 그래서 GraphQL은 동적 값을 쿼리에서 없애고 이를 별도로 전달하는 방법을 제공하는데 이를 Variables(변수)라고 한다.
{
user(login: "Shubidumdu") {
name
location
}
}
위의 쿼리를 Variables를 활용한 형태로 바꾸려면 다음과 같은 작업들이 필요하다.
- 쿼리 내의 정적인 값을
variableName
형태로 변경한다. variableName
를 쿼리에서 받아오는 변수의 타입으로 선언한다.- 별도의 전송규약(일반적으로 JSON) 변수에
variableName: value
를 전달한다.
변수를 이용해 위의 쿼리를 재작성하면 아래와 같은 형태가 된다.
# query
query MyInfo(nickname: String!) {
user(login: nickname) {
name
location
}
}
// variables
{ "nickname": "Shubidumdu" }
// result
{
"data": {
"user": {
"name": "Won Gyo Seo",
"location": "Seoul, South Korea"
}
}
}
이제, 클라이언트 측에서는 완전히 새로운 쿼리를 작성하지 않고 손쉽게 다른 변수를 전달할 수 있다.
한편, 이런 방식은 쿼리의 어떤 Argument가 동적인 형태를 띠는지 나타내는 좋은 방법이기도 하다.
변수 정의
변수 정의는 위 예시 쿼리에서 (nickname: String!)
에 해당하는 부분이다. 정적타입 언어의 함수에 대한 인자 정의와 동일하다.
모든 변수는 scalars, enum, 또는 input object type 이어야 한다. 복잡한 객체를 필드에 전달하려면 서버에서 일치하는 입력 타입을 알아야 하며, 이에 대해서는 문서를 통해 더 알아보자.
변수 정의는 required 혹은 optional일 수 있다. 위에서는 String!
으로 !
가 붙었으므로 required scalar type에 해당한다. 반대로, !
가 붙지 않았다면 이는 optional한 값이 된다.
변수 기본값
타입 선언 다음에 기본값을 할당할 수도 있다. 이 경우에는 별도로 Variable을 전달하지 않더라도 올바르게 동작한다.
# query
query MyInfo(nickname: String = "Shubidumdu") {
user(login: nickname) {
name
location
}
}
여기에, nickname: String!
과 같이 required 변수를 요구하는 경우에는 기본값을 가질 수 없다는 점을 유의하자.
Directives (지시어)
Directives는 GraphQL의 기능으로, 필드나 프래그먼트 안에 삽입되어, 쿼리 실행에 영향을 줄 수 있다.
@include(if: Boolean)
: 인자가true
인 경우에만 이 필드를 결과에 포함한다.@skip(if: Boolean)
: 인자가true
인 경우에만 이 필드를 건너뛴다.
이를 이용해 앞서 작성한 유저 정보 쿼리에서 withAvatar
변수가 true
인 경우에만 이미지를 함께 가져오게끔 해보자.
# query
query MyInfo(<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord mathnormal">ni</span><span class="mord mathnormal">c</span><span class="mord mathnormal">knam</span><span class="mord mathnormal">e</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">St</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mclose">!</span><span class="mpunct">,</span></span></span></span>withAvatar: Boolean = false) {
user(login: nickname) {
name
location
avatarUrl @include(if: withAvatar)
}
}
// variables
{
"nickname": "Shubidumdu",
"withAvatar": true
}
{
"data": {
"user": {
"name": "Won Gyo Seo",
"location": "Seoul, South Korea",
"avatarUrl": "https://avatars.githubusercontent.com/u/54790378?u=9fa9c08aa2c952a873633a1baf3ea342a4c45855&v=4"
}
}
}
물론, 필드가 객체를 참조하는 경우에도 활용할 수 있다.
# query
query MyInfo(
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord mathnormal">ni</span><span class="mord mathnormal">c</span><span class="mord mathnormal">knam</span><span class="mord mathnormal">e</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.8777699999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">St</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord">"</span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord mathnormal">h</span><span class="mord mathnormal">u</span><span class="mord mathnormal">bi</span><span class="mord mathnormal">d</span><span class="mord mathnormal">u</span><span class="mord mathnormal">m</span><span class="mord mathnormal">d</span><span class="mord mathnormal">u</span><span class="mord">"</span></span></span></span>withItemShowcase: Boolean = false
) {
user(login: nickname) {
name
location
company
itemShowcase @include(if: withItemShowcase) {
items {
totalCount
}
}
}
}
// variables
{
"withItemShowcase": true
}
// result
{
"data": {
"user": {
"name": "Won Gyo Seo",
"location": "Seoul, South Korea",
"company": "The Mong, Inc.",
"itemShowcase": {
"items": {
"totalCount": 6
}
}
}
}
}
Mutation
지금까지는 전부 데이터 가져오기(fetch
)에만 초점을 뒀다.
REST의 경우, 사실 상 모든 요청이 사이드 이펙트를 일으킬 수 있지만, 데이터 수정에 있어서는 GET
을 사용하지 않는다는 규칙이 정해져 있다.
이는 GraphQL 역시 마찬가지다. 기술적으로는 어떤 형태의 쿼리든 데이터에 수정을 가할 수 있으나, 사이드 이펙트를 유발하는 작업의 경우에는 Mutation을 통해 전송되어야 한다는 규칙이 있다.
아래는 내 shubi-docs
repo에 star를 추가하는 예시 Mutation이다.
# mutation
mutation MyMutation(repoId: ID!) {
__typename
addStar(input: { starrableId: repoId, clientMutationId: "Star added!" }) {
clientMutationId
}
}
// variables
{ "repoId": "MDEwOlJlcG9zaXRvcnkzMDYzNjgwMDY" }
// result
// 실제로 repo에 star가 추가된다.
{
"data": {
"__typename": "Mutation",
"addStar": {
"clientMutationId": "Star added!"
}
}
}
다중 필드 Mutation
Mutation은 쿼리와 마찬가지로 여러 필드를 포함할 수 있는데, 둘 사이에 중요한 차이점이 있다.
쿼리 필드는 병렬로 실행되지만 뮤테이션 필드는 하나씩 차례대로 실행된다는 점이다.
덕분에, 아래와 같이 여러 개의 뮤테이션을 요청하면, 순서가 보장되기 때문에 결국 추가한 star는 다시 사라진다.
mutation MyMutation(repoId: ID!) {
__typename
addStar(input: { starrableId: repoId, clientMutationId: "Star added!" }) {
clientMutationId
}
removeStar(
input: { starrableId: repoId, clientMutationId: "Star removed!" }
) {
clientMutationId
}
}
// variables
{ "repoId": "MDEwOlJlcG9zaXRvcnkzMDYzNjgwMDY" }
// result
{
"data": {
"__typename": "Mutation",
"addStar": {
"clientMutationId": "Star added!"
},
"removeStar": {
"clientMutationId": "Star removed!"
}
}
}
Inline Fragments
다른 여러 타입과 마찬가지로 GraphQL 스키마에는 인터페이스와 유니온 타입을 정의하는 기능이 포함되어 있다.
만약, 인터페이스나 유니언 타입을 반환하는 필드를 쿼리하는 경우, Inline Fragement를 사용할 수 있는데, 다음과 같은 형태다.
# query
{
node(id: "MDQ6VXNlcjU0NzkwMzc4") {
id
... on User {
name
}
... on Organization {
email
}
}
}
// result
{
"data": {
"node": {
"id": "MDQ6VXNlcjU0NzkwMzc4",
"name": "Won Gyo Seo"
}
}
}
위의 인자로 입력한 id
를 통해 반환되는 값은 Node이자 User 타입이다.
User를 반환받는 경우, id
와 name
필드를 가져오도록 Inline Fragment (... on User
)를 활용했기 때문에, ... on Organization {...}
은 완전히 무시된다.
Meta fields
만약, GraphQL 상에서 리턴될 타입을 모르는 상황인 경우, 클라이언트에서 해당 데이터를 처리할 방법을 결정하기 위해 타입이 요구되는 경우가 있다.
GraphQL은 쿼리의 어느 지점에서건 메타 필드인 __typename
을 요청해 그 시점에서의 객체 타입의 이름을 가져올 수 있다.
# query
{
node(id: "MDQ6VXNlcjU0NzkwMzc4") {
__typename
id
... on User {
name
}
... on Organization {
email
}
}
}
// result
{
"data": {
"node": {
"__typename": "User",
"id": "MDQ6VXNlcjU0NzkwMzc4",
"name": "Won Gyo Seo"
}
}
}
위 쿼리에서 __typename
을 추가해 클라이언트 측에서 타입을 구분할 수 있게끔 해주었다.
GraphQL은 이 외에도 몇 가지 메타필드를 제공하며, 이들은 introspection의 일부다. 이에 대해서는 다른 문서를 통해 설명하겠다.
여기 문서에 따라, GraphQL 스키마 및 타입에 관해 직접 실습해보려고 한다.
실습을 위해 Typescript 기반으로 Apollo를 이용한 임의의 GraphQL 서버를 생성했다.
Schema & Type
다음과 같은 쿼리를 받았다고 생각해보자.
# query
{
game {
title
genre
tags
}
}
// result
// 임의로 작성됨
{
"data": {
"title": "Super Mario Bros",
"genre": "adventure",
"developer": "Nintendo",
"publisher": "Nintendo",
"tags": ["2d", "famicom"]
}
}
기본적으로 GraphQL 쿼리의 형태가 결과와 거의 일치하기 때문에, 서버에 대해 모르는 상태에서도 쿼리가 어떤 형태의 값을 반활할지에 대해 어느 정도 예측할 수 있다.
하지만, 어떤 필드를 선택할 수 있는지, 어떤 종류의 객체를 반환할 수 있는지, 하위 객체에서 사용할 수 있는 필드가 무엇인지 등에 대한 정보를 얻기 위해 스키마가 필요하다.
모든 GraphQL 서비스는 해당 서비스에서 쿼리 가능한 데이터들을 완벽하게 설명하는 타입들을 정의하고, 쿼리가 들어오면 해당 스키마에 대한 유효성이 검사된 후에 실행된다.
Type language
GraphQL은 어떤 언어로든 작성될 수 있으며, 해당 문서에서는 TypeScript에 기반하여 내용을 따라갈 예정이다.
Object types and fields
GraphQL 스키마의 가장 기본적인 구성 요소는 객체 타입으로, 이는 서비스에서 가져올 수 있는 객체 종류와 그 객체의 필드를 나타낸다.
type Game {
title: String!
developer: Developer!
tags: [Tag]!
}
type Developer {
name: String!
games: [Game]!
}
Game
,Developer
등은 GraphQL Object 타입이다. 다시 말해, 필드가 존재하는 타입이란 의미이며, 스키마에서 대부분의 타입은 여기에 해당한다.title
,name
등은Character
타입 내에 존재하는 Field이다. 즉, 쿼리에서title
는Game
타입 내에서,name
은Developer
타입 내에서 어디서든 사용할 수 있는 필드이다.String
은 내장된 스칼라(scalar) 타입 중 하나다. 이는 단일 스칼라 객체로 해석된다.String!
은 필드가 non-nullable함을 의미한다. 즉, 해당 필드를 쿼리하는 경우 항상 GraphQL 서비스는 해당 값을 반환한다는 것을 의미한다.[Game]!
은Game
객체의 배열을 나타내는데, 이 또한 non-nullable하여 무조건 배열을 반환함을 의미한다. (배열 자체는 길이가 0이어도 상관이 없다.)
Arguments(인자)
GraphQL 객체 타입의 모든 필드는 0개 이상의 인수를 가질 수 있다.
type Game {
title(language: Language = KOREAN): String!
developer: Developer!
tags: [Tag]!
}
모든 인자에는 이름이 있다. 위의 예시에서는 title
필드가 language
라는 매개변수를 갖는다.
인자는 required일수도, optional할수도 있다. 인자가 optional인 경우 기본값을 정의할 수 있으며, 이에 대해서는 쿼리에 관한 문서에서도 설명한 바가 있다.
Query Type & Mutation Type
스키마 대부분의 타입은 일반 객체 타입이지만, 두 가지 특수한 타입이 존재한다.
schema {
query: Query
mutation: Mutation
}
모든 GraphQL 서비스는 query
타입을 가지며, mutation
타입은 가질 수도, 가지지 않을 수도 있다. 전반적인 취급은 동일하지만, 모든 GraphQL 쿼리의 진입점(Entry Point)를 정의하는 것이므로 이는 특별하다.
Scalar Type
GraphQL 객체 타입은 이름과 필드를 가지지만, 결국 그 끝에는 구체적인 데이터로 해석되어야 하는데, 이것이 스칼라 타입이 필요한 이유다.
쿼리를 요청할 때, 필드에 하위 필드가 존재하지 않는 경우 그것이 스칼라 타입임을 알 수 있다. 기본적으로 존재하는 스칼라 타입에는 다음과 같은 것들이 있다.
Int
: 부호가 있는(Signed) 32비트 정수Float
: 부호가 있는 부동소수점(double-precision floating-point) 값String
: UTF-8 문자열Boolean
:true
또는false
ID
: ID 스칼라 타입은 객체를 다시 요청하거나 캐시 키로써 종종 사용되는 고유 식별자다. String과 같은 형태로 Serialized 되지만,ID
로 정의하는 것은 사람들이 읽기 위한 용도가 아님을 의미한다.
별도로 커스텀 스칼라 타입을 지정할 수도 있는데, 이는 어떤 언어와 라이브러리를 활용하느냐에 따라 조금씩 다른 형태가 될 것이다. 아래는 JS와 apollo를 활용한 기준.
const { ApolloServer, gql } = require('apollo-server');
const { GraphQLScalarType, Kind } = require('graphql');
const typeDefs = gql`
scalar Date
type Event {
id: ID!
date: Date!
}
type Query {
events: [Event!]
}
`;
const dateScalar = new GraphQLScalarType({
name: 'Date',
description: 'Date custom scalar type',
serialize(value) {
return value.getTime(); // Convert outgoing Date to integer for JSON
},
parseValue(value) {
return new Date(value); // Convert incoming integer to Date
},
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return new Date(parseInt(ast.value, 10)); // Convert hard-coded AST string to integer and then to Date
}
return null; // Invalid hard-coded value (not an integer)
},
});
const resolvers = {
Date: dateScalar,
// ...other resolver definitions...
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
Enum Type
Enum 타입은 특정 값들로 제한되는 특별한 종류의 스칼라다.
enum Genre {
action
puzzle
adventure
}
위의 예시에서 Genre
타입을 사용하면, 이는 정확히 action
, puzzle
, adventure
중에 하나일 것임을 보장한다.
Lists & Non-Null
object, scalar, enum 타입은 GraphQL에서 정의할 수 있는 타입의 전부다.
하지만, 스키마의 다른 부분이나 쿼리 변수 선언에서 타입을 사용해 해당 값의 유효성 검사를 할 수 있는 타입 수정자를 적용할 수 있다.
type Game {
title: String!
tags: [String]!
}
String
타입의 뒤에 느낌표 !
를 추가해 Non-Null임을 나타냈다. 이제 서버는 해당 필드에 대해 항상 null
이 아닐 것이라 기대하며, 만약 null
이 반환되면 오류를 발생시킨다.
Non-null 타입 수정자는 매개 변수를 정의할 때도 사용할 수 있는데, 이를 쿼리 시에 충족시키지 않는 경우 유효성 검사 오류를 반환하게끔 한다.
# query
query GameInfo(id: ID!) {
game(id: id) {
title
tags
}
}
// variables
{
// 아무것도 넘기지 않는다.
}
// result
// variables에 아무것도 넘기지 않아 에러가 발생.
{
"errors": [
{
"message": "Variable \"id\" of required type \"ID!\" was not provided.",
"locations": [
{
"line": 1,
"column": 17
}
]
}
]
}
Interface
여러가지 타입 시스템과 마찬가지로 GraphQL 역시 인터페이스를 지원한다.
예를 들면, 다음과 같은 식으로 작성할 수 있다.
interface Game {
id: ID!
title: String!
tags: [Tag]!
}
type ActionGame implements Game {
id: ID!
title: String!
tags: [Tag]!
genre: ActionGenre!
}
enum ActionGenre {
Fighting
Platformer
ARPG
}
이런 식으로 Game
을 implement하는 모든 타입이 해당 인자와 리턴 타입을 가진 정확한 필드를 가져야함을 명시해줄 수 있다.
만약, 앞선 쿼리의 형태에서 특정 타입에만 존재하는 필드를 가져오고자 하는 경우, 단순히 아래와 같은 형태는 에러가 발생한다.
query GameInfo(id: ID!) {
game(id: id) {
title
genre # ActionGame 타입에만 존재
}
}
{
"id": "id1234"
}
{
"errors": [
{
"message": "Cannot query field \"genre\" on type \"Game\". Did you mean to use an inline fragment on \"ActionGame\"?",
"locations": [
{
"line": 4,
"column": 5
}
]
}
]
}
이러한 경우에 아래와 같은 형태로 인라인 프래그먼트를 사용하여 특정 객체 타입일 경우의 필드를 요청할 수 있다.
query GameInfo(id: ID!) {
game(id: id) {
title
... on ActionGame {
genre
}
}
}
// variables
{
"id": "id1234"
}
// result
// 임의로 작성됨
{
"data": {
"title": "Super Mario Bros",
"genre": "Platformer"
}
}
Union Type
유니온 타입은 인터페이스와 유사하지만, 타입 간의 공통 필드를 정의하지 않는다는 차이점이 있다.
union SearchResult = ActionGame | PuzzleGame
이런 경우, SearchResult의 결과가 어떤 타입이더라도 쿼리할 수 있도록 조건부 프래그먼트를 사용해야 한다.
search(text: "ma") {
... on ActionGame {
# ActionGame 타입에서 존재하는 필드
}
... on PuzzleGame {
# PuzzleGame 타입에서 존재하는 필드
}
}
Input Type
지금껏 매개변수에 전달하는 인자가 간단한 스칼라 값인 경우에 대해서만 나타냈는데, 좀 더 복잡한 객체도 쉽게 전달할 수 있다. 이는 뮤테이션 타입에서 특히 유용하다.
이 때 활용하는 타입이 Input Type이며, 일반 객체 타입과 완전히 동일하지만, type
대신에 input
을 사용한다는 차이점이 있다.
input ReviewInput {
stars: Int!
comment: String
}
# mutation
mutation CreateReviewForGame(gameId: ID!, review: ReviewInput!) {
createReview(gameId: gameId, review: review) {
stars
commentary
}
}
// variables
{
"gameId": "mario1234",
"review": {
"stars": 5,
"comment": "The begin of legend. :)"
}
}
// result
{
"data": {
"createReview": {
"stars": 5,
"commentary": "The begin of legend. :)"
}
}
}
여기의 내용을 Github GraphQL API로 따라가보자.
Pagination
Plurals
여러 개의 객체를 가져오기 위한 가장 간단한 방법은 Plurals(복수형) 타입을 반환하는 필드를 사용하는 것이다.
licenses {
name
}
// result
{
"data": {
"licenses": [
{
"name": "GNU Affero General Public License v3.0"
},
{
"name": "Apache License 2.0"
},
{
"name": "BSD 2-Clause \"Simplified\" License"
},
{
"name": "BSD 3-Clause \"New\" or \"Revised\" License"
},
{
"name": "Boost Software License 1.0"
},
{
"name": "Creative Commons Zero v1.0 Universal"
},
{
"name": "Eclipse Public License 2.0"
},
{
"name": "GNU General Public License v2.0"
},
{
"name": "GNU General Public License v3.0"
},
{
"name": "GNU Lesser General Public License v2.1"
},
{
"name": "MIT License"
},
{
"name": "Mozilla Public License 2.0"
},
{
"name": "The Unlicense"
}
]
}
}
Slicing
헌데, 여기에 클라이언트가 가장 앞의 둘, 혹은 가장 뒤의 둘과 같은 식으로 Slicing을 원한다면, 아래와 같은 형태가 이루어질 수 있다.
{
search(query: "react", type: REPOSITORY, first: 2) {
nodes {
... on Repository {
name
owner {
... on User {
name
}
... on Organization {
name
}
}
}
}
}
}
// result
{
"data": {
"search": {
"nodes": [
{
"name": "react",
"owner": {
"name": "Facebook"
}
},
{
"name": "react",
"owner": {
"name": "TypeScript Cheatsheets"
}
}
]
}
}
}
Pagination and Edges
페이지네이션을 할 수 있는 방법은 여러 가지가 있다.
-
field(first: 2, offset: 2)
: 리스트로 다음 두 개를 요청 -
field(first: 2, after: <span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span><span class="mclose">)</span><span class="mord">‘</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.77777em;vertical-align:-0.08333em;"></span><span class="mord hangul_fallback">앞서가져온마지막</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord mathnormal">m</span><span class="mord hangul_fallback">의</span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span><span class="mord hangul_fallback">값을통해그다음두개를요청</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">‘</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">i</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">d</span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">i</span><span class="mord mathnormal">rs</span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord">2</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.02778em;">er</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span></span></span></span>fieldCursor)
: 마지막 항목으로부터 커서를 가져와 사용
이 중 가장 기능이 강력한 것은 마지막의 **커서 기반 페이지네이션(cursor-based pagination)**이며, 커서를 사용하면 향후 페이지네이션 모델이 변경될 경우에 추가적인 유연성이 제공된다.
다만, 또 여기서 문제가 발생하는데, 객체에서 어떻게 커서를 가져오느냐 하는 것이다.
기본적으로, 커서는 연결(connection
)을 위한 필드이므로 이것이 객체 속성에 포함되는 것은 부적절해보인다.
때문에 edge
라고 하는 별도의 필드를 가지며, 이는 객체와 관련된 정보가 아닌 엣지와 관련된 자체 정보가 있는 경우에 유용하다.
# query
{
search(query: "react", type: REPOSITORY, first: 3) {
edges {
cursor
}
nodes {
... on Repository {
id
name
owner {
id
... on User {
name
}
... on Organization {
name
}
}
}
}
}
}
// result
{
"data": {
"search": {
"edges": [
{
"cursor": "Y3Vyc29yOjE="
},
{
"cursor": "Y3Vyc29yOjI="
},
{
"cursor": "Y3Vyc29yOjM="
}
],
"nodes": [
{
"id": "MDEwOlJlcG9zaXRvcnkxMDI3MDI1MA==",
"name": "react",
"owner": {
"id": "MDEyOk9yZ2FuaXphdGlvbjY5NjMx",
"name": "Facebook"
}
},
{
"id": "MDEwOlJlcG9zaXRvcnkxMzU3ODYwOTM=",
"name": "react",
"owner": {
"id": "MDEyOk9yZ2FuaXphdGlvbjUwMTg4MjY0",
"name": "TypeScript Cheatsheets"
}
},
{
"id": "MDEwOlJlcG9zaXRvcnk3NTM5NjU3NQ==",
"name": "react",
"owner": {
"id": "MDQ6VXNlcjMyNDk2NTM=",
"name": "肚皮"
}
}
]
}
}
}
End-of-list, counts, and Connections
그렇다면 이런 식으로 pagination을 반복하다가 언제 connection
이 끝났는지를 알 수 있을까?? 또한, 총 몇 개의 item이 존재하는지 어떻게 알 수 있을까??
이를 위해 필드는 connection
객체를 반환할 수 있다.
connection
객체에는 엣지에 대한 필드 뿐만 아니라 다른 정보(ex. item 갯수, 다음 페이지 존재 여부)등을 담고 있다.
이를 활용한다면, 다음과 같은 형태로 이용할 수 있다.
{
search(query: "react", type: REPOSITORY, first: 3) {
nodes {
... on Repository {
id
name
owner {
id
... on User {
name
}
... on Organization {
name
}
}
}
}
repositoryCount
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
}
}
// result
{
"data": {
"search": {
"nodes": [
{
"id": "MDEwOlJlcG9zaXRvcnkxMDI3MDI1MA==",
"name": "react",
"owner": {
"id": "MDEyOk9yZ2FuaXphdGlvbjY5NjMx",
"name": "Facebook"
}
},
{
"id": "MDEwOlJlcG9zaXRvcnkxMzU3ODYwOTM=",
"name": "react",
"owner": {
"id": "MDEyOk9yZ2FuaXphdGlvbjUwMTg4MjY0",
"name": "TypeScript Cheatsheets"
}
},
{
"id": "MDEwOlJlcG9zaXRvcnk3NTM5NjU3NQ==",
"name": "react",
"owner": {
"id": "MDQ6VXNlcjMyNDk2NTM=",
"name": "肚皮"
}
}
],
"repositoryCount": 1979845,
"pageInfo": {
"startCursor": "Y3Vyc29yOjE=",
"endCursor": "Y3Vyc29yOjM=",
"hasPreviousPage": false,
"hasNextPage": true
}
}
}
}
pageInfo
내의 startCursor
, endCursor
를 통해 페이지네이션에 필요한 커서를 얻을 수 있으며, 더 이상 edge
를 쿼리할 필요가 없어졌다.
Complete Connection Model
이는 별도로 ~Connection
과 같은 필드를 추가하는 방식이다.
단순히 복수 타입을 갖도록 하는 형태보다 훨씬 더 복잡하지만, 이러한 디자인을 채택함으로써 클라이언트를 위한 다양한 기능을 사용할 수 있게 된다.
- 리스트의 페이지네이션 기능
totalCount
또는pageInfo
와 같은 연결 자체에 대한 정보를 요청하는 기능cursor
등 엣지 자체에 대한 정보를 요청하는 기능- 백엔드 측에서 페이지네이션 방식 변경이 가능 (사용자가 불투명(
opaque
) 커서만을 사용하기 때문에)
아래는 예시.
{
hero {
name
friends {
name
}
friendsConnection(first: 3) {
totalCount
edges {
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
// result
{
"data": {
"hero": {
"name": "R2-D2",
"friends": [
{
"name": "Luke Skywalker"
},
{
"name": "Han Solo"
},
{
"name": "Leia Organa"
}
],
"friendsConnection": {
"totalCount": 3,
"edges": [
{
"cursor": "Y3Vyc29yMQ=="
},
{
"cursor": "Y3Vyc29yMg=="
},
{
"cursor": "Y3Vyc29yMw=="
}
],
"pageInfo": {
"endCursor": "Y3Vyc29yMw==",
"hasNextPage": false
}
}
}
}
}
Serving Over HTTP
여기의 내용을 따라가보자.
URIs, Routes
HTTP는 일반적으로 리소스를 핵심 개념으로 여기는 REST와 관련이 있다.
이와 반대로, GraphQL의 개념 모델은 엔티티 그래프로, 이는 URL로 식별되지 않는다.
GraphQL 서버는 단일 엔드포인트(일반적으로 /graphql
)에서 작동하며, 주어진 서비스에 대한 모든 요청은 해당 엔드포인트에서 수행된다.
HTTP Methods, Headers, and Body
GraphQL HTTP 서버는 HTTP GET / POST 메서드를 처리해야 한다.
GET 요청
만약 다음과 같은 GraphQL 쿼리를 실행하려고 한다면,
{
me {
name
}
}
다음과 같이 HTTP GET을 통해 전송할 수 있다.
/graphql?query={me{name}}
여기에 더해 다음과 같은 추가 쿼리 파라미터를 가질 수 있다.
variables
: 쿼리 변수들을 넘기는 객체를 JSON Stringified 처리한 문자열operationName
: 쿼리에 여러 개의 명명된 작업이 포함된 경우에, 어떤 쿼리를 실행하는지 제어
즉, variables
과 함께 좀 더 복잡한 쿼리를 전달해보자면, 가령 아래와 같은 쿼리가 있다고 할 때,
query gameInfo(id: ID!) {
game(id: id) {
title
}
}
// variables
{
"id": "1"
}
// result
{
"data": {
"game": {
"title": "Super Mario Bros"
}
}
}
이를 (굳이) HTTP GET 메서드로 요청해보겠다고 하면 아래와 같아진다. 만약 나머지 특수문자들도 인코딩한다면 훨씬 지저분해질 것이다.
/graphql?variables={"id":"1"}&query=query%20gameInfo(id:ID!){game(id:id){title}}
operationName
의 경우, 앞서 말했듯 여러 개의 쿼리 작업을 보유한 경우, 실행하길 원하는 작업명을 의미한다.
이를테면 아래와 같이 사용한다. 다음과 같은 쿼리가 있다고 하자.
query query1 {
game(id: "1") {
title
}
}
query query2 {
game(id: "2") {
title
}
}
여기서, (굳이 또) HTTP GET 메서드로 query2
에 해당하는 작업을 요구하려는 경우에는 다음과 같이 할 수 있다.
/graphql?operationName=query2&query=query%20query1{game(id:"1"){title}}%20query%20query2{game(id:"2"){title}}
POST 요청
표준 GraphQL POST 요청은 application/json
content-type을 사용해야하며, 아래 형식의 JSON 인코딩 처리된 Body를 포함해야한다.
{
"query": "...",
"operationName": "...",
"variables": { "myVariable": "someValue", ... }
}
operationName
과 variables
는 앞선 GET 메서드의 경우와 똑같은 역할을 한다.
위 내용 외에도 추가로 다음 두 가지 경우에 대해 지원하는 것이 좋다.
- 위의 GET 요청과 같은 방식으로 쿼리스트링 파라미터가 존재하는 경우, HTTP GET의 경우와 동일한 형식으로 처리
application/graphql
Content-Type header가 있는 경우, HTTP POST body의 내용을 GraphQL 쿼리스트링으로 처리 (Body에 넘겨진 텍스트 자체를 쿼리문으로 여겨야 한다는 듯)
Response
쿼리와 변수가 전송된 방식과는 관계없이, 응답은 Body에 JSON 형태로 반환되어야 한다. 쿼리는 데이터 뿐만 아니라 오류 또한 유발할 수 있기 때문에, 다음과 같은 형태로 반환되어야 한다.
{
"data": { ... },
"errors": [ ... ]
}
오류가 없는 경우에는 errors
필드가 없어야 한다.
반면 데이터가 반환되지 않는 경우에는 실행 도중에 에러가 발생한 경우에 대해서만 data
필드가 포함된다.
GraphiQL
GraphiQL이나 GraphQL Playground는 테스트 및 개발 중에 유용하게 쓰일 수 있지만, 기본적으로 프로덕션 환경에서는 사용하지 않도록 되어야 한다.
express-graphql
의 경우는 다음과 같이 이를 구현할 수 있다.
app.use(
'/graphql',
graphqlHTTP({
schema: MySessionAwareGraphQLSchema,
graphiql: process.env.NODE_ENV === 'development',
}),
);
시작하기
docker run -d -p 80:80 docker/getting-started
-d
: 컨테이너를detached
모드로 실행한다. (백그라운드에서)-p 80:80
: 호스트의80
포트를 컨테이너의80
포트로 연결시킨다.docker/getting-started
: 사용할 이미지
위의 단일 문자 플래그들은 합쳐서 사용할 수 있다. 이를테면 아래와 같이 작성할 수 있다.
docker run -dp 80:80 docker/getting-started
대쉬보드
컨테이너를 실행하고나면 이를 대쉬보드 상에서 확인할 수 있다.

컨테이너란?
컨테이너란 호스트 머신의 다른 프로세스로부터 격리된 또 하나의 프로세스다. 이러한 분리는 Linux에서 오랫동안 사용되어 온 기능인 kernel namespaces와 cgroups를 활용한다. 그리고 Docker는 이런 기능들은 접근 가능하고 사용하기 쉽게 만들고자 한 것이다.
컨테이너 이미지란?
컨테이너가 실행될 때, 해당 컨테이너는 격리된 파일시스템을 사용한다. 이 격리된 파일시스템이 바로 컨테이너 이미지로부터 제공된다. 각 이미지들은 컨테이너의 파일시스템을 내포하고 있으며, 애플리케이션의 실행에 필요한 모든 것들을 담고 있어야 한다. (dependencies / configurations / scripts / binaries / etc.) 또한 환경변수, 기초 실행 명령 / 그 외의 메타 데이터 등 컨테이너에 대한 다른 설정들 또한 갖고있다.
이미지에 대해서는 Layering, Best practices 등 추후 더 깊게 다루어보도록 하겠다.
애플리케이션 구성

위와 같은 애플리케이션이 있다고 하자.
컨테이너 이미지 빌드
애플리케이션을 빌드하기 위해서는 Dockerfile
을 사용해야 한다. Dockerfile
은 컨테이너 이미지를 생성하기 위해 사용되는 간단한 텍스트 스크립트다.
- 먼저
Dockerfile
을package.json
이 위치한 폴더와 같은 곳에 생성한다.
# syntax=docker/dockerfile:1
FROM node:12-alpine
RUN apk add --no-cache python g++ make
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]
Dockerfile
에는 별도로 .txt
와 같은 확장자가 붙어있지 않음을 유의하자.
Dockerfile
이 위치한 디렉토리로 이동하여docker build
명령을 통해 컨테이너 이미지를 빌드한다.
docker build -t getting-started .
빌드 과정에서, 수많은 layer들이 다운로드되는 것을 확인할 수 있는데, 이는 Dockerfile
의 처음에 node:12-alpine
이미지에서부터 시작된다고 빌더에게 명령했기 때문이다. 현재의 호스트 머신에는 이 이미지가 존재하지 않고, 따라서 해당 이미지를 다운로드하는 과정이 필요한 것이다.
해당 이미지가 다운로드되면, 애플리케이션을 복사하고 yarn
으로 애플리케이션의 dependencies를 설치한다. CMD
명령에는 이미지로부터 컨테이너를 가동할 때 실행할 기본 명령어를 지정한다.
마지막으로, -t
플래그는 이미지에 대한 태그를 의미한다. 생성한 이미지에 대한 읽기 쉬운 이름이라고 이해하면 된다. getting-started
라는 이름으로 이미지를 이름지었기 때문에, 컨테이너를 실행할 때마다 해당 이름을 참조할 수 있다.
docker build
의 마지막에 있는 .
은 Docker에게 현재 디렉토리에서 Dockerfile
을 찾아야한다고 명령하는 것이다.
앱 컨테이너 실행
자, 이제 이미지를 만들었으니, 이를 실행해보자. docker run
명령을 사용하면 된다.
- 앞서 만든 이미지를
docker run
명령을 통해 컨테이너로 실행한다.
docker run -dp 3000:3000 getting-started
앞선 챕터에서 -d
와 -p
플래그에 대해 설명했던 것이 기억나는가? -dp
플래그를 통해, 호스트의 3000 포트를 컨테이너의 3000 포트와 매핑하고, 컨테이너를 "detached" 모드(백그라운드에서) 실행했다. 만약 별도로 포트를 지정해주지 않는다면, 애플리케이션에 접근할 수 없다.
- 잠시 후,
http://localhost:3000
에 접근하면, 애플리케이션을 확인할 수 있다.
애플리케이션 업데이트
docker build -t getting-started .
docker run -dp 3000:3000 getting-started
애플리케이션에 어떤 변경사항이 생겼을 때, 이를 적용하고 이전 챕터에서 했던 것과 동일하게 빌드 / 실행하게되면 아래와 같은 에러가 발생한다.
docker: Error response from daemon: driver failed programming external connectivity on endpoint laughing_burnell
(bb242b2ca4d67eba76e79474fb36bb5125708ebdabd7f45c8eaf16caaabde9dd): Bind for 0.0.0.0:3000 failed: port is already allocated.
해당 문제는 컨테이너가 호스트의 3000 포트를 사용하고 있고, 호스트 머신에서는 하나의 프로세스만이 특정 포트를 수신할 수 있기 때문이다. 따라서, 이를 해결하려면 실행 중인 이전의 컨테이너를 제거해야 한다.
컨테이너 교체
컨테이너를 제거하려면, 먼저 컨테이너를 멈추어야 한다. 두 가지 방법이 있는데, 어느 쪽을 사용해도 상관없다.
1. CLI로 컨테이너 제거
docker ps
명령으로 컨테이너의 ID를 가져온다.
docker ps
docker stop
명령으로 컨테이너를 멈춘다.
# <the-container-id>를 앞선 과정에서 얻은 ID로 교체
docker stop <the-container-id>
- 컨테이너가 멈추고 난 후,
docker rm
명령으로 제거한다.
docker rm <the-container-id>
만약 "force" 플래그를 추가한다면 docker rm
명령 하나만으로 컨테이너를 정지하고 삭제할 수 있다.
docker rm -f <the-container-id>
2. Docker 대쉬보드를 통해 컨테이너 삭제

Docker 대쉬보드를 이용한다면 몇 번의 클릭을 통해 앞선 과정을 해결할 수 있다.
컨테이너 재실행
이제, 다시 업데이트된 컨테이너를 실행해보자.
docker run -dp 3000:3000 getting-started
저장소(Repository) 생성
이미지를 푸쉬하기 위해서는 먼저 DockerHub에 저장소를 생성해야 한다.
- Docker Hub에 가입하고, 로그인
- Create Repository 버튼을 클릭
- 저장소 이름을 설정하고, Visibility를
Public
으로 지정기본적으로 Private 저장소는 개인 당 1개만 주어진다. 추가로 이용하거나 팀 별로 이용하고자 하는 경우엔 Pricing을 참조하자.
- Create 버튼을 클릭
해당 과정을 마쳤으면, 아래와 같이 Docker commands가 나타난다. 이는 현재 저장소에 푸쉬하기 위한 예시 명령이다.

이미지 푸쉬
- 다음과 같이 푸쉬 명령을 작성한다.
docker
가 아니라, 본인의 네임 스페이스로 작성해주어야 함을 주의하자.
docker push docker/getting-started
The push refers to repository [docker.io/docker/getting-started]
An image does not exist locally with the tag: docker/getting-started
뭐가 문제일까? 푸쉬 명령이 docker/getting-started
라는 이름의 이미지를 찾아봤지만, 알 수 없었다. docker image ls
를 실행해보면 알겠지만, 아무 것도 존재하지 않는다.
-
먼저,
docker login -u <USER-NAME>
명령으로 Docker Hub에 로그인한다. -
docker tag
명령으로getting-started
이미지에 새로운 이름을 부여한다.
docker tag getting-started <USER-NAME>/getting-started
- 이제, 앞선 과정을 다시 해보자. 현재 따로 태그네임을 추가하지 않았으므로
:tagname
부분은 없어도 된다. 별도로 태그를 지정하지 않는 경우, Docker는latest
라는 이름의 태그를 사용한다.
docker push YOUR-USER-NAME/getting-started
새 인스턴스에 이미지 실행
이제, 우리가 빌드한 컨테이너 이미지를 전혀 다른 새로운 환경에서 사용해보자. 여기서는 Play with Docker를 사용한다.
-
브라우저로 Play with Docker에 접속한다.
-
Login을 클릭하고, docker를 선택한다.
-
본인의 Docker Hub 계정으로 접속한다.
-
로그인 한 후, ADD NEW INSTANCE 옵션을 클릭한다. 이 후, 브라우저 상에서 터미널을 확인할 수 있다.

- 해당 터미널에서 우리가 푸쉬했던 애플리케이션을 실행하자.
docker run -dp 3000:3000 YOUR-USER-NAME/getting-started
- 위쪽에
3000
포트를 클릭하면, 실행한 애플리케이션을 확인할 수 있다.
컨테이너 파일시스템
컨테이너가 실행될 때, 파일시스템을 구축하기 위해 이미지로부터 여러 개의 레이어를 사용한다. 각각의 컨테이너는 파일을 생성/업데이트/제거하기 위한 "Scratch space"를 갖는다. 한 컨테이너 내의 어떤 변화는 다른 컨테이너에 영향을 주지 않으며, 심지어 그것이 같은 이미지로부터 만들어진 컨테이너라도 마찬가지다.
실전
직접 두 개의 컨테이너를 실행시키고 각각 하나의 파일을 만들게끔 해보자. 이로부터 하나의 컨테이너에서 생긴 파일은 다른 컨테이너에서 활용할 수 없음을 확인할 수 있을 것이다.
ubuntu
컨테이너를 실행하고 1에서 10000 사이의 난수를 갖는/data.txt
라는 이름의 파일을 만든다.
docker run -d ubuntu bash -c "shuf -i 1-10000 -n 1 -o /data.txt && tail -f /dev/null"
위에서는 &&
를 통해 두 가지 명령을 실행했는데, 첫번째는 무작위 번호를 추출하여 /data.txt
라는 파일로 작성한 것이고, 두번째는 컨테이너를 실행 상태로 유지하기 위해 파일을 확인하는 것이다.
- 컨테이너에
exec
을 해줌으로써 결과를 확인할 수 있다. 그렇게 하기 위해서, 대쉬보드를 열고 실행 중인ubuntu
이미지가 실행 중인 컨테이너를 클릭하자.

그러면 현재 실행 중인 ubuntu
컨테이너 내에서 동작하고 있는 터미널을 확인할 수 있다. 작성한 /data.txt
파일을 확인하기 위해 아래의 명령을 실행하고, 터미널을 다시 종료하자.
cat /data.txt
만약, 대쉬보드보다 CLI 방식을 더 선호한다면, 아래와 같이 docker exec
명령을 실행하여 똑같이 진행할 수 있다. 이 경우 컨테이너의 ID를 알고 있어야 한다.(docker ps
를 사용하자.)
docker exec <container-id> cat /data.txt
그러면 /data.txt
에 작성된 난수를 확인할 수 있을 것이다!
- 이제, 똑같은 이미지를 통해 다른
ubuntu
컨테이너를 실행시켜보자. 동일한 파일이 존재하지 않음을 알 수 있다.
docker run -it ubuntu ls /
보시다시피 data.txt
가 존재하지 않는다. 말했다시피 해당 파일은 첫번째 컨테이너에 대한 "scratch space"에 작성되었기 때문이다.
컨테이너 볼륨
앞선 실험에서, 동일한 이미지에서 실행한 각각의 컨테이너는 매번 새롭게 실행되는 것임을 확인했다. 각각의 컨테이너 내에서 일어나는 일련의 CRUD 작업들은 해당 컨테이너 내에서만 영향을 준다. 단, **볼륨(Volume)**을 사용한다면, 이를 바꿀 수 있다.
볼륨은 컨테이너의 특정 파일 시스템 경로를 호스트 머신에 연결시켜줄 수 있게 해준다. 컨테이너의 디렉토리가 마운트되면, 디렉토리 내의 변경사항들은 호스트 머신에도 적용된다. 덕분에, 컨테이너가 여러번 재실행되더라도, 동일한 디렉토리를 마운트하게 되면 매번 동일한 파일을 유지할 수 있다.
볼륨에는 두 가지 종류가 있는데, 하나는 Named volumes이다.
데이터 유지하기
앞선 챕터에서, 기본적으로 우리의 TODO 앱은 SQLite를 통해 /etc/todos/todo.db
에 데이터를 저장한다. SQLite는 하나의 파일에 데이터를 저장하는 간단한 형태의 관계형 DB다. 이는 대규모의 애플리케이션에 적합하진 않지만, 작은 데모에서는 잘 동작한다. DB 엔진을 변경하는 방법에 대해서는 추후에 따로 다루어보자.
DB가 하나의 파일이기 때문에, 해당 파일을 유지하기만 하면 다음 컨테이너의 실행에서도 DB에 저장된 내용을 유지할 수 있다. 볼륨을 만들고, 디렉토리에 첨부(일반적으로, mouting이라고 함)하면, 데이터가 저장, 유지된다. 현재 컨테이너는 todo.db
파일을 작성하기 때문에, 볼륨을 통해 해당 파일이 호스트에 지속될 것이다.
앞서 말했듯, 먼저 named volume을 사용해보겠다. named volume은 간단한 데이터 버킷이다. Docker가 디스크의 물리적인 로케이션을 유지하고, 우리는 해당 볼륨의 이름을 기억하기만 하면 된다. 해당 볼륨을 사용할 때마다, Docker가 적절한 데이터가 제공됨을 보장해줄 것이다.
docker volume create
명령으로 볼륨을 만든다.
docker volume create todo-db
-
작동 중인 TODO 앱 컨테이너를 멈추고, 삭제한다. (대쉬보드, 혹은
docker rm -f <id>
) -
새로 컨테이너를 실행하되,
-v
플래그를 통해 마운트할 볼륨을 지정해준다. 여기선 named volume을 사용하고, 이를/etc/todos
에 마운트 해주었다. 이를 통해 해당 경로에 있는 모든 파일들이 캡처된다.
docker run -dp 3000:3000 -v todo-db:/etc/todos getting-started
- 애플리케이션을 실행하여 적절히 데이터가 생성 / 변경 / 유지되는지 확인한다.

- 2 ~ 4번을 다시 수행하면서, 컨테이너를 재실행시키더라도 데이터가 여전히 유지되는지 확인한다.
참고 : Docker에서는 기본적으로 named volume과 bind mounts(추후 설명)를 볼륨으로 제공하지만, NFS, SFTP, NetApp 등 수많은 볼륨 드라이버 플러그인들이 존재한다. 이는 Swarm, Kubernetes 등의 클러스터 환경을 통해 여러 호스트에서 컨테이너를 실행한다면 특히 중요하다.
볼륨 파헤치기
종종, "Named volume을 사용할 때, Docker는 실제로 어디에 데이터를 저장하는 걸까?"하는 물음이 들 수 있다. 이를 확인하고 싶다면, docker volume inspect
명령을 사용해보자.
docker volume inspect todo-db
[
{
"CreatedAt": "2019-09-26T02:18:36Z",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/todo-db/_data",
"Name": "todo-db",
"Options": {},
"Scope": "local"
}
]
Mountpoint
가 바로 데이터가 저장되는 디스크의 실제 위치다. 대부분의 머신에서는 해당 디렉토리에 접근하기 위해 루트 엑세스 권한이 요구된다.
Docker Desktop의 경우 : Docker Desktop을 실행하는 동안, Docker 명령은 실제로는 호스트 머신 내의 작은 VM 내에서 실행된다. 이 경우
Mountpoint
디렉토리 내의 실제 파일들을 확인하려고 한다면, 먼저 VM 내부로 들어가야 한다.
이전 챕터에서, DB 내의 데이터를 보존하기 위한 named volume에 대해서 이야기했다. Named volume은 어디에 데이터를 저장하는지에 대해서는 신경쓰지 않기 때문에, 단순히 데이터를 저장하기 위한 용도로는 충분하다.
Bind mounts를 사용한다면, 우리가 직접 호스트의 정확한 "mountpoint"를 조작할 수 있다. Bind Mounts는 데이터를 보존하기 위해 사용할 수도 있지만, 컨테이너에 추가적인 데이터를 제공해야 하는 경우에 쓰이는 경우가 많다. 애플리케이션 개발 단계에서 Bind Mounts를 사용하면 소스 코드를 컨테이너에 마운트하여 코드 변경 사항을 확인하고, 즉각적인 변경 사항을 확인할 수 있다.
nodemon은 NodeJS 애플리케이션에서 변경 사항을 파악하고, 재실행 시켜주는 툴이다. NodeJS 외의 언어 및 프레임워크에서는 다른 적합한 툴들이 존재할 것이다.
볼륨 타입 비교
Bind mounts 와 Named volumes는 Docker 엔진에서 제공되는 두가지 타입의 볼륨이다. 다른 경우에 제공되는 추가적인 볼륨 드라이버를 사용할 수도 있다.
Named Volumes | Bind Mounts | |
---|---|---|
호스트 위치 | Docker가 정함 | 직접 정함 |
마운트 예시 (-v 플래그) | my-volume:/usr/local/data | /path/to/data:/usr/local/data |
새 볼륨을 컨테이너 컨텐츠로 채움 | 예 | 아니오 |
볼륨 드라이버 지원 | 예 | 아니오 |
Dev 모드 컨테이너 실행
컨테이너가 개발 워크플로우를 지원하도록 하기 위해서, 아래의 사항을 수행해야 한다.
- 컨테이너에 소스 코드를 마운트시킨다.
- 모든 종속성을 설치한다. (
dev
종속성 포함) nodemon
을 실행하여 파일시스템 변경을 감시한다.
- 이전에 실행했던
getting-started
컨테이너를 종료, 제거한다. - 아래 명령을 입력한다. 아래쪽에서 해당 명령에 대해 상세히 설명하겠다.
docker run -dp 3000:3000 \
-w /app -v "(pwd):/app" \
node:12-alpine \
sh -c "yarn install && yarn run dev"
PowerShell을 사용한다면 아래 명령을 사용해야 한다.
docker run -dp 3000:3000 `
-w /app -v "(pwd):/app" `
node:12-alpine `
sh -c "yarn install && yarn run dev"
-dp 3000:3000
- 포트 매핑 및 백그라운드 모드 (이전에 언급한 것과 같다.)-w /app
- 작업 디렉토리, 혹은 명령이 실행될 디렉토리를 지정-v "(pwd):/app"
- 호스트의 현재 디렉토리(pwd
)를 컨테이너의/app
디렉토리와 bind mount시킴node:12-alpine
- 사용할 이미지. Dockerfile 내의 Base 이미지를 사용.sh -c "yarn install && yarn run dev"
- 명령어.sh
를 통해서 셸을 실행하고(alpine은bash
를 갖고있지 않다.)yarn install
을 실행하여 모든 종속성을 설치한 뒤yarn run dev
로 개발 모드로 실행한다.
docker logs -f <container-id>
를 통해 로그를 확인할 수 있다.
docker logs -f <container-id>
nodemon src/index.js
[nodemon] 1.19.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] starting `node src/index.js`
Using sqlite database at /etc/todos/todo.db
Listening on port 3000
- 이제, 애플리케이션에 변경을 적용해보자.
src/static/js/app.js
파일에서 텍스트를 간단하게 변경해보겠다.
- {submitting ? 'Adding...' : 'Add Item'}
+ {submitting ? 'Adding...' : 'Add'}
- 브라우저가 변경을 감지하고 페이지를 새로고침하는 것을 확인할 수 있다.

- 모든 작업이 끝났다면, 컨테이너를 정지시키고
docker build -t getting-started .
명령을 통해 새로운 이미지를 빌드한다.
bind mounts의 이용은 로컬 개발 환경에서 매우 일반적으로 사용된다. 호스트 머신에서 별도로 빌드 툴과 환경을 설치하지 않아도 된다는 장점이 있다. 덕분에 단순히 docker run
커맨드를 실행함으로써 개발 환경이 구축되고, 곧바로 개발에 돌입할 수 있다.
추후에 Docker Compose에 대해 이야기할텐데, 이를 사용하면 앞서 사용했던 명령들을 훨씬 간단하게 처리할 수 있다.
이전 챕터까지는 모두 단일 컨테이너 애플리케이션을 다루었다. 자, 이제 MySQL과 같이 별도의 애플리케이션 스택을 추가하려고 한다. 종종 다음과 같은 물음이 생긴다. - "어디서 MySQL을 구동해야 할까? 똑같은 컨테이너 내에서 설치되어야 하나, 아니면 따로 실행되어야 하나?" 일반적으로, 각각의 컨테이너는 한 가지 일만을 수행하고, 그것이 잘 이루어져야 한다. 이유는 다음과 같다.
- DB 외에 API 및 프론트엔드를 확장해야 할 가능성이 높다.
- 별도의 컨테이너를 통한 버저닝이 수월하다.
- 로컬에서 데이터베이스에 대한 컨테이너를 사용할 수도 있는 한편, 프로덕션 상에 운영 중인 데이터베이스에 대해 관리되는 서비스를 사용하고 싶을수도 있다.
- 여러 프로세스를 실행하려면 별도의 프로세스 매니저가 요구된다. (컨테이너는 하나의 프로세스만을 실행한다.) 따라서 컨테이너의 시작 / 종료에 복잡성을 가중시킨다.
그 외에도 여러 이유가 있으며, 결국 우리는 아래와 같은 형태로 애플리케이션을 업데이트하려고 한다.

컨테이너 네트워킹
기본적으로, 컨테이너는 격리된 환경에서 실행되며, 동일한 머신 내의 다른 프로세스나 컨테이너에 대해서는 아무것도 알지 못한다는 점을 기억하라. 그렇다면, 어떻게 컨테이너 상호 간의 소통을 주도할 수 있을까? 정답은 Networking이다. 두 컨테이너가 같은 네트워크 상에 있다면, 컨테이너 간에 상호작용을 할 수 있다. 그렇지 않다면, 불가능하다.
MySQL 실행
컨테이너를 네트워크에 포함시키기 위한 두가지 방법이 있다. 1) 실행 시점에 할당시키거나, 2) 기존 컨테이너에 연결한다. 먼저, 네트워크를 생성하고 MySQL 컨테이너를 해당 네트워크에 첨부해보자.
- 네트워크를 생성한다.
docker network create todo-app
- MySQL 컨테이너를 실행하고 네트워크에 첨부한다. 데이터베이스를 초기화하기 위해서 환경 변수를 지정해주어야 한다.
docker run -d \
--network todo-app --network-alias mysql \
-v todo-mysql-data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=secret \
-e MYSQL_DATABASE=todos \
mysql:5.7
PowerShell을 이용한다면 아래 명령을 사용하자.
docker run -d `
--network todo-app --network-alias mysql `
-v todo-mysql-data:/var/lib/mysql `
-e MYSQL_ROOT_PASSWORD=secret `
-e MYSQL_DATABASE=todos `
mysql:5.7
위에서 --network-alias
플래그를 지정한 것을 볼 수 있는데, 이에 대해서는 아래쪽에서 다루도록 하자.
유의 : 위에서
todo-mysql-data
로 볼륨명을 지정하고,/var/lib/mysql
에 마운트한 것을 확인할 수 있는데, 이는 MySQL이 데이터를 저장하는 디렉토리이다. 헌데, 이 후docker volume create
명령을 수행하지는 않는다. Docker가 Named volume의 사용을 인지하고 자동으로 볼륨을 생성해주기 때문이다.
- 이후 데이터베이스를 실행하고, 연결한 후에 제대로 연결되었는지를 확인해보자.
docker exec -it <mysql-container-id> mysql -u root -p
패스워드 프롬프트가 뜨면, 패스워드를 입력한다. 이후 MySQL 셸에서 데이터베이스를 리스트하고 todos
데이터베이스를 확인하자.
mysql> SHOW DATABASES;
이제 아래와 같은 결과가 보일 것이다.
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
| todos |
+--------------------+
5 rows in set (0.00 sec)
여기까지가 todos
데이터베이스를 만드는 과정이었다.
MySQL에 연결
이제 MySQL에 DB를 구성했고, 이제 사용하기만 하면 된다. 문제는 이를 어떻게 사용하느냐인데, 동일한 네트워크에서 다른 컨테이너들을 어떻게 찾아낼 수 있을까?
이를 확인하기 위해서 nicolaka/netshoot 컨테이너를 사용한다. 해당 컨테이너는 네트워킹 이슈에 대한 트러블 슈팅 혹은 디버깅에 유용한 수많은 툴을 제공한다.
- nicolaka/netshoot 이미지를 사용하여 새로운 컨테이너를 실행한다. 기존에 생성한 것과 동일한 네트워크에 연결하는 것임을 확인하자.
docker run -it --network todo-app nicolaka/netshoot
- 컨테이너 내에서
dig
명령을 사용하는데, 이는 유용한 DNS 툴이다. 아래 명령으로mysql
이라는 호스트네임에 대한 IP 주소를 찾을 수 있다.
dig mysql
그리고 그 결과는 아래처럼 나타난다.
; <<>> DiG 9.14.1 <<>> mysql
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 32162
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;mysql. IN A
;; ANSWER SECTION:
mysql. 600 IN A 172.23.0.2
;; Query time: 0 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Tue Oct 01 23:47:24 UTC 2019
;; MSG SIZE rcvd: 44
ANSWER SECTION
부분에서 mysql
의 A
레코드가 172.23.0.2
로 지정되어 있음을 확인할 수 있다. (환경에 따라 IP 주소는 달라질 수 있다.) mysql
은 일반적으로 타당한 호스트네임이 아니지만, Docker는 network alias를 보유한 컨테이너의 IP 주소를 사용함으로써 이를 처리했다. (앞서 --network-alias
플래그를 사용했던 것을 기억하자.)
그 결과, 이제 애플리케이션은 mysql
이라는 이름의 호스트에 연결하기만 하면, 데이터베이스와 상호작용할 수 있게 된다.
MySQL과 함께 애플리케이션 구동
이제 TODO 앱은 MySQL과 연결하기 위해 몇가지 환경 변수 설정이 필요하다.
MYSQL_HOST
MYSQL_USER
MYSQL_PASSWORD
MYSQL_DDB
환경 변수에 대해 : 개발 단계에서 환경 변수를 사용하는 것은 괜찮지만, 애플리케이션이 프로덕션 단계에서 실행되는 경우 환경 변수는 대부분의 경우 사용하지 말아야 한다. 그 이유에 대해서는 Docker의 보안 리드 Diogo Monica의 블로그 포스트를 참조하자.
보다 안전한 방법은, 컨테이너 오케스트레이션 프레임워크에서 제공하는 Secret Support 기능을 사용하는 것이다. 대부분의 경우 이러한 Secret들은 실행 중인 컨테이너에 파일로 마운트된다. 많은 애플리케이션들이 이를 위해
_FILE
접미사가 붙은 변수들을 지원하는 것을 확인할 수 있다.예를 들어, 우리가 사용하는 예시 애플리케이션에서는
MYSQL_PASSWORD_FILE
변수를 설정하여 DB를 연결하기 위한 환경변수가 담긴 파일을 참조하도록 할 수 있다. Docker 자체는 별도로 환경 변수를 지원하지 않는다. 애플리케이션 자체적으로 사용할 환경 변수에 대한 파일을 찾아서 사용하도록 구현해야 한다.
당장에는 환경 변수를 통해서 애플리케이션을 DB에 연결해보자.
- 각각의 환경 변수들을 작성해주고, 컨테이너를 네트워크에 연결해준다.
docker run -dp 3000:3000 \
-w /app -v "(pwd):/app" \
--network todo-app \
-e MYSQL_HOST=mysql \
-e MYSQL_USER=root \
-e MYSQL_PASSWORD=secret \
-e MYSQL_DB=todos \
node:12-alpine \
sh -c "yarn install && yarn run dev"
PowerShell을 사용한다면 아래와 같이 작성한다.
docker run -dp 3000:3000 `
-w /app -v "(pwd):/app" `
--network todo-app `
-e MYSQL_HOST=mysql `
-e MYSQL_USER=root `
-e MYSQL_PASSWORD=secret `
-e MYSQL_DB=todos `
node:12-alpine `
sh -c "yarn install && yarn run dev"
- 컨테이너 로그를 살펴보면(
docker logs <container-id>
), 다음과 같이 DB 연결에 성공했음을 나타내는 메시지를 확인할 수 있다.
# Previous log messages omitted
<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">m</span><span class="mord mathnormal">o</span><span class="mord mathnormal">n</span><span class="mord mathnormal">src</span><span class="mord">/</span><span class="mord mathnormal">in</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">x</span><span class="mord">.</span><span class="mord mathnormal" style="margin-right:0.05724em;">j</span><span class="mord mathnormal">s</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">m</span><span class="mord mathnormal">o</span><span class="mord mathnormal">n</span><span class="mclose">]</span><span class="mord">1.19.2</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">m</span><span class="mord mathnormal">o</span><span class="mord mathnormal">n</span><span class="mclose">]</span><span class="mord mathnormal">t</span><span class="mord mathnormal">ores</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mord mathnormal">t</span><span class="mord mathnormal">an</span><span class="mord mathnormal" style="margin-right:0.03588em;">y</span><span class="mord mathnormal">t</span><span class="mord mathnormal">im</span><span class="mord mathnormal">e</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.02778em;">er</span><span class="mord">‘</span><span class="mord mathnormal">rs</span><span class="mord">‘</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">m</span><span class="mord mathnormal">o</span><span class="mord mathnormal">n</span><span class="mclose">]</span><span class="mord mathnormal" style="margin-right:0.02691em;">w</span><span class="mord mathnormal">a</span><span class="mord mathnormal">t</span><span class="mord mathnormal">c</span><span class="mord mathnormal">hin</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">d</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mopen">(</span><span class="mord mathnormal">s</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.46528em;vertical-align:0em;"></span><span class="mord">∗</span><span class="mord">.</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">∗</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">m</span><span class="mord mathnormal">o</span><span class="mord mathnormal">n</span><span class="mclose">]</span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">t</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord">‘</span><span class="mord mathnormal">n</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">esrc</span><span class="mord">/</span><span class="mord mathnormal">in</span><span class="mord mathnormal">d</span><span class="mord mathnormal">e</span><span class="mord mathnormal">x</span><span class="mord">.</span><span class="mord mathnormal" style="margin-right:0.05724em;">j</span><span class="mord mathnormal">s</span><span class="mord">‘</span><span class="mord mathnormal" style="margin-right:0.07153em;">C</span><span class="mord mathnormal">o</span><span class="mord mathnormal">nn</span><span class="mord mathnormal">ec</span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord mathnormal">t</span><span class="mord mathnormal">o</span><span class="mord mathnormal">m</span><span class="mord mathnormal">ys</span><span class="mord mathnormal" style="margin-right:0.01968em;">ql</span><span class="mord mathnormal">d</span><span class="mord mathnormal">ba</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord mathnormal">os</span><span class="mord mathnormal">t</span><span class="mord mathnormal">m</span><span class="mord mathnormal">ys</span><span class="mord mathnormal" style="margin-right:0.01968em;">ql</span><span class="mord mathnormal">L</span><span class="mord mathnormal">i</span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord mathnormal">nin</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">o</span><span class="mord mathnormal">n</span><span class="mord mathnormal">p</span><span class="mord mathnormal" style="margin-right:0.02778em;">or</span><span class="mord mathnormal">t</span><span class="mord">3000‘‘‘3.</span><span class="mord hangul_fallback">브라우저에서애플리케이션을실행하고</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord mathnormal" style="margin-right:0.02778em;">TO</span><span class="mord mathnormal" style="margin-right:0.02778em;">D</span><span class="mord mathnormal" style="margin-right:0.02778em;">O</span><span class="mord hangul_fallback">앱을테스트해본다</span><span class="mord">.4.</span><span class="mord hangul_fallback">아래명령을통해</span><span class="mord mathnormal">m</span><span class="mord mathnormal">ys</span><span class="mord mathnormal" style="margin-right:0.01968em;">ql</span><span class="mord hangul_fallback">데이터베이스에연결하고</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord hangul_fallback">데이터가적절하게저장되었는지를확인하자</span><span class="mord">.‘‘‘</span><span class="mord mathnormal">ba</span><span class="mord mathnormal">s</span><span class="mord mathnormal">h</span><span class="mord mathnormal">d</span><span class="mord mathnormal">oc</span><span class="mord mathnormal" style="margin-right:0.03148em;">k</span><span class="mord mathnormal">ere</span><span class="mord mathnormal">x</span><span class="mord mathnormal">ec</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.69862em;vertical-align:-0.0391em;"></span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel"><</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">m</span><span class="mord mathnormal">ys</span><span class="mord mathnormal" style="margin-right:0.01968em;">ql</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.74285em;vertical-align:-0.08333em;"></span><span class="mord mathnormal">co</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord mathnormal">ain</span><span class="mord mathnormal" style="margin-right:0.02778em;">er</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.73354em;vertical-align:-0.0391em;"></span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">></span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">m</span><span class="mord mathnormal">ys</span><span class="mord mathnormal" style="margin-right:0.01968em;">ql</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">pt</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord mathnormal">os</span><span class="mord">‘‘‘</span><span class="mord mathnormal">m</span><span class="mord mathnormal">ys</span><span class="mord mathnormal" style="margin-right:0.01968em;">ql</span><span class="mord hangul_fallback">셸상에서아래와같이작성하고</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord hangul_fallback">결과를확인한다</span><span class="mord">.‘‘‘</span><span class="mord mathnormal">ba</span><span class="mord mathnormal">s</span><span class="mord mathnormal">hm</span><span class="mord mathnormal">ys</span><span class="mord mathnormal" style="margin-right:0.01968em;">ql</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">></span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.69444em;vertical-align:0em;"></span><span class="mord mathnormal">se</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">ec</span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">∗</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord mathnormal">ro</span><span class="mord mathnormal">m</span><span class="mord mathnormal">t</span><span class="mord mathnormal">o</span><span class="mord mathnormal">d</span><span class="mord"><span class="mord mathnormal">o</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.31166399999999994em;"><span style="top:-2.5500000000000003em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord mathnormal">m</span><span class="mord mathnormal">s</span><span class="mpunct">;</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">+</span><span class="mord">∣</span><span class="mord mathnormal">i</span><span class="mord mathnormal">d</span><span class="mord">∣</span><span class="mord mathnormal">nam</span><span class="mord mathnormal">e</span><span class="mord">∣</span><span class="mord mathnormal">co</span><span class="mord mathnormal">m</span><span class="mord mathnormal" style="margin-right:0.01968em;">pl</span><span class="mord mathnormal">e</span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">∣</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">∣</span><span class="mord mathnormal">c</span><span class="mord">906</span><span class="mord mathnormal" style="margin-right:0.10764em;">ff</span><span class="mord">08</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.72777em;vertical-align:-0.08333em;"></span><span class="mord">60</span><span class="mord mathnormal">e</span><span class="mord">6</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.72777em;vertical-align:-0.08333em;"></span><span class="mord">44</span><span class="mord mathnormal">e</span><span class="mord">6</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord">8</span><span class="mord mathnormal" style="margin-right:0.10764em;">f</span><span class="mord">49</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">56</span><span class="mord mathnormal">a</span><span class="mord">0853</span><span class="mord mathnormal">e</span><span class="mord">85∣</span><span class="mord mathnormal">Do</span><span class="mord mathnormal">ama</span><span class="mord mathnormal" style="margin-right:0.04398em;">z</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">t</span><span class="mord mathnormal">hin</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">s</span><span class="mclose">!</span><span class="mord">∣0∣∣2912</span><span class="mord mathnormal">a</span><span class="mord">79</span><span class="mord mathnormal">e</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.72777em;vertical-align:-0.08333em;"></span><span class="mord">8486</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.77777em;vertical-align:-0.08333em;"></span><span class="mord">4</span><span class="mord mathnormal">b</span><span class="mord mathnormal">c</span><span class="mord">3</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.72777em;vertical-align:-0.08333em;"></span><span class="mord mathnormal">a</span><span class="mord">4</span><span class="mord mathnormal">c</span><span class="mord">5</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">460793</span><span class="mord mathnormal">a</span><span class="mord">575</span><span class="mord mathnormal">ab</span><span class="mord">∣</span><span class="mord mathnormal" style="margin-right:0.05017em;">B</span><span class="mord mathnormal">e</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.02691em;">w</span><span class="mord mathnormal">eso</span><span class="mord mathnormal">m</span><span class="mord mathnormal">e</span><span class="mclose">!</span><span class="mord">∣0∣</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.66666em;vertical-align:-0.08333em;"></span><span class="mord">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord">‘‘‘</span><span class="mord hangul_fallback">현재</span><span class="mord mathnormal">Doc</span><span class="mord mathnormal" style="margin-right:0.03148em;">k</span><span class="mord mathnormal" style="margin-right:0.02778em;">er</span><span class="mord hangul_fallback">대쉬보드를확인해본다면</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord hangul_fallback">두개의앱컨테이너가작동하고있음을확인할수있다</span><span class="mord">.</span><span class="mord hangul_fallback">하지만</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.16666666666666666em;"></span><span class="mord hangul_fallback">두컨테이너가하나의애플리케이션을위해그룹화되어있음을확인할수는없다</span><span class="mord">.</span><span class="mord hangul_fallback">이를개선할방법에에대해서는이후챕터에서알아보겠다</span><span class="mord">.</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel"><</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.85396em;vertical-align:-0.19444em;"></span><span class="mord mathnormal">im</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">src</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:0.8888799999999999em;vertical-align:-0.19444em;"></span><span class="mord">"</span><span class="mord mathnormal">h</span><span class="mord mathnormal">ttp</span><span class="mord mathnormal">s</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">//</span><span class="mord mathnormal">d</span><span class="mord mathnormal">ocs</span><span class="mord">.</span><span class="mord mathnormal">d</span><span class="mord mathnormal">oc</span><span class="mord mathnormal" style="margin-right:0.03148em;">k</span><span class="mord mathnormal" style="margin-right:0.02778em;">er</span><span class="mord">.</span><span class="mord mathnormal">co</span><span class="mord mathnormal">m</span><span class="mord">/</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">e</span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/</span><span class="mord mathnormal">ima</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">es</span><span class="mord">/</span><span class="mord mathnormal">d</span><span class="mord mathnormal">a</span><span class="mord mathnormal">s</span><span class="mord mathnormal">hb</span><span class="mord mathnormal">o</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">d</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.77777em;vertical-align:-0.08333em;"></span><span class="mord mathnormal">m</span><span class="mord mathnormal">u</span><span class="mord mathnormal">lt</span><span class="mord mathnormal">i</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:0.74285em;vertical-align:-0.08333em;"></span><span class="mord mathnormal">co</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord mathnormal">ain</span><span class="mord mathnormal" style="margin-right:0.02778em;">er</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">a</span><span class="mord mathnormal">pp</span><span class="mord">.</span><span class="mord mathnormal">p</span><span class="mord mathnormal">n</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord">"/</span><span class="mspace" style="margin-right:0.2777777777777778em;"></span><span class="mrel">></span></span></span></span>
Docker Compose는 멀티 컨테이너 애플리케이션을 정의하고 공유하는 것을 돕고자 개발된 툴이다. Compose를 사용하면, YAML 파일을 생성하여 서비스를 정의한 후, 단일 명령을 통해 애플리케이션을 구축하거나 분해할 수 있다.
Compose를 사용할 경우의 큰 이점은 바로 애플리케이션 스택을 하나의 파일로 정의할 수 있다는 점이다. 해당 파일은 프로젝트 저장소의 루트에 저장되며, 다른 이들이 이를 보고 프로젝트에 쉽게 기여할 수 있게 해준다. 그런 경우가 아니더라도, 단순히 repo를 클론하여 애플리케이션을 실행하기만 하면 된다는 점에서 편리하다. 이는 Github/GitLab 등에서 이루어지는 것과 유사하다.
Docker Compose 설치
Windows나 Mac에서 Docker Desktop/Toolbox를 설치했다면, 이미 Docker Compose가 포함되어 있다. "Play-with-Docker" 인스턴스 역시 Docker Compose가 설치되어 있으며, Linux 머신을 사용하는 상황이라면, 별도로 설치해주어야 한다.
설치 이후, 아래 명령을 통해 버전 정보를 확인해보자.
docker-compose version
Compose 파일 생성
-
프로젝트 루트에서,
docker-compose.yml
이라는 이름의 파일을 생성한다. -
해당 파일에서, 우선 schema 버전을 정의하는 것부터 시작하자. 대부분의 경우에는 최신 지원 버전을 사용하는 것이 좋다. 현재 schema 버전에서 가능한 Compose file의 레퍼런스에 대해 확인하자.
version: '3.7'
- 이후, 애플리케이션에 사용할 각각의 서비스(컨테이너)를 정의해준다.
version: '3.7'
services:
앱 서비스 정의
이전 챕터에서 우리는 앱 컨테이너를 정의하기 위해 아래와 같은 명령을 실행했다.
docker run -dp 3000:3000 \
-w /app -v "(pwd):/app" \
--network todo-app \
-e MYSQL_HOST=mysql \
-e MYSQL_USER=root \
-e MYSQL_PASSWORD=secret \
-e MYSQL_DB=todos \
node:12-alpine \
sh -c "yarn install && yarn run dev"
PowerShell의 경우
docker run -dp 3000:3000 `
-w /app -v "(pwd):/app" `
--network todo-app `
-e MYSQL_HOST=mysql `
-e MYSQL_USER=root `
-e MYSQL_PASSWORD=secret `
-e MYSQL_DB=todos `
node:12-alpine `
sh -c "yarn install && yarn run dev"
- 먼저, 컨테이너에 사용할 서비스 엔트리와 이미지를 정의한다. 서비스에는 어떤 이름이든 쓰여도 된다(여기에서는
app
). 여기서의 이름은 자동으로 network alias가 되며, 이후 다른 서비스에서 해당 서비스를 활용할 때 유용하다.
version: '3.7'
services:
app:
image: node:12-alpine
- 일반적으로
command
항목을image
가까이에 정의하긴 하지만, 별도로 순서에 대한 요구 사항은 없다.
version: '3.7'
services:
app:
image: node:12-alpine
command: sh -c "yarn install && yarn run dev"
version: '3.7'
services:
app:
image: node:12-alpine
command: sh -c "yarn install && yarn run dev"
ports:
- 3000:3000
- 이제
working_dir
과volumes
항목을 통해 작업 디렉토리(-w /app
)와 볼륨 매핑(-v "(pwd):/app"
)를 정의한다. 볼륨 역시 짧게 혹은 길게 작성될 수 있다.
Docker Compose를 통해 볼륨을 정의하는 경우, 현재 디렉토리에서의 상대 경로를 사용할 수 있다는 장점이 있다.
version: '3.7'
services:
app:
image: node:12-alpine
command: sh -c "yarn install && yarn run dev"
ports:
- 3000:3000
working_dir: /app
volumes:
- ./:/app
- 마지막으로, 사용하는 환경 변수를
environment
항목에서 지정해준다.
version: '3.7'
services:
app:
image: node:12-alpine
command: sh -c "yarn install && yarn run dev"
ports:
- 3000:3000
working_dir: /app
volumes:
- ./:/app
environment:
MYSQL_HOST: mysql
MYSQL_USER: root
MYSQL_PASSWORD: secret
MYSQL_DB: todos
MySQL 서비스 정의
자, 이제 MySQL 서비스에 대해 정의해보자. 앞서 아래와 같은 명령을 통해 MySQL 컨테이너를 실행했다.
docker run -d \
--network todo-app --network-alias mysql \
-v todo-mysql-data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=secret \
-e MYSQL_DATABASE=todos \
mysql:5.7
PowerShell을 사용한다면
docker run -d `
--network todo-app --network-alias mysql `
-v todo-mysql-data:/var/lib/mysql `
-e MYSQL_ROOT_PASSWORD=secret `
-e MYSQL_DATABASE=todos `
mysql:5.7
- 먼저 새로운 서비스를 정의하고
mysql
로 이름짓는다. 이를 통해 자동으로 network alias를 갖게 된다.
version: '3.7'
services:
app:
# The app service definition
mysql:
image: mysql:5.7
- 다음으로, 볼륨 매핑을 정의한다. 기존에는
docker run
을 통해 컨테이너를 실행하게 되면, named volume이 알아서 생성되었지만, Compose를 사용하는 경우에는 그렇지 않다. 최상단의volumes:
항목에서 직접 볼륨을 정의하고,services:
의 각 항목 내에서 mountpoint를 지정해주어야 한다. 단순히 볼륨 네임만을 정의한다면 기본 옵션값들이 사용된다. 추가 옵션들에 대해서는 여기를 참조하자.
version: '3.7'
services:
app:
# The app service definition
mysql:
image: mysql:5.7
volumes:
- todo-mysql-data:/var/lib/mysql
volumes:
todo-mysql-data:
- 마지막으로, 환경 변수들을 정의해준다.
version: '3.7'
services:
app:
# The app service definition
mysql:
image: mysql:5.7
volumes:
- todo-mysql-data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: todos
volumes:
todo-mysql-data:
최종적으로, 작성한 docker-compose.yml
파일은 아래와 같은 형태가 된다.
version: '3.7'
services:
app:
image: node:12-alpine
command: sh -c "yarn install && yarn run dev"
ports:
- 3000:3000
working_dir: /app
volumes:
- ./:/app
environment:
MYSQL_HOST: mysql
MYSQL_USER: root
MYSQL_PASSWORD: secret
MYSQL_DB: todos
mysql:
image: mysql:5.7
volumes:
- todo-mysql-data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: todos
volumes:
todo-mysql-data:
애플리케이션 스택 실행
docker-compose.yml
파일을 갖고 있다면, 이제 애플리케이션들을 실행할 수 있다.
-
기존에 실행 중인 app 혹은 db가 없는지 확인한다. (
docker ps
와docker rm -f <ids>
사용) -
docker-compose up
을 통해 애플리케이션 스택을 실행한다.-d
플래그를 통해 모든 스택들을 백그라운드 모드에서 실행한다.
docker-compose up -d
명령 이후 아래와 같은 진행사항들이 출력된다.
Creating network "app_default" with the default driver
Creating volume "app_todo-mysql-data" with default driver
Creating app_app_1 ... done
Creating app_mysql_1 ... done
네트워크와 볼륨들이 생성되는 것을 확인할 수 있다. 기본적으로, Docker Compose는 애플리케이션 스택 상에 정의된 네트워크를 생성한다. (별도로 compose 파일 내에서 네트워크를 정의하지 않은 이유다.)
docker-compose logs -f
명령을 통해 로그를 확인할 수 있다. 각각의 서비스에서의 로그가 단일 스트림으로 인터리빙된것을 볼 수 있다. 이는 타이밍 관련 이슈들을 탐지하고자 하는 경우에 굉장히 유용하다.-f
플래그는 로그를 "팔로우"할 것을 의미하며, 실시간으로 재생되는 로그들을 제공받는다.
mysql_1 | 2019-10-03T03:07:16.083639Z 0 [Note] mysqld: ready for connections.
mysql_1 | Version: '5.7.27' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server (GPL)
app_1 | Connected to mysql db at host mysql
app_1 | Listening on port 3000
서비스 네임이 각 줄의 |
좌측에 출력되며, 만약 특정 서비스에 대한 로그만을 확인하고자 한다면, 로그 명령 끝에 원하는 서비스네임을 추가해주면 된다.
docker-compose logs -f app
DB 실행 이후 App이 실행되도록 하기 : Docker는 다른 컴포넌트가 완전히 구동되고 준비될 때까지 기다리도록 하는 내장 지원 기능이 별도로 없다. Node 기반의 프로젝트에서는 이를 위해 wait-port 라이브러리를 사용할 수 있다. 다른 프레임워크 및 언어에 대해서도 유사한 것들이 존재한다.
- 이제 애플리케이션을 직접 열어 확인해보자.
대쉬보드에서 애플리케이션 스택 확인
Docker 대쉬보드를 살펴보면 app
이라는 이름으로 그룹이 생성되었음을 확인할 수 있다. 이는 Docker Compose에서 작성된 프로젝트 네임으로, 기본값으로 docker-compose.yml
이 위치한 디렉토리의 이름이 사용된다.

애플리케이션 스택을 살펴보면, 앞서 compose 파일을 통해 정의한 두 컨테이너를 확인할 수 있다. 이들은 <project-name>_<service-name>_<replica-number>
의 패턴을 따른다.

종료 및 제거
docker-compose down
을 명령하거나 대쉬보드 상에서 휴지통 아이콘을 클릭하면 앱 스택을 종료할 수 있다. 관련된 모든 컨테이너들이 중지되고, 네트워크는 삭제된다.
주의 : 기본적으로 compose 파일에서 정의된 named volume들은
docker-compose down
명령으로 제거되지 않는다. 볼륨들도 제거하고자 하는 경우,--volumes
플래그를 추가해주어야 한다.Docker 대쉬보드에서도 마찬가지로 애플리케이션 스택을 제거할 때 볼륨은 제거하지 않는다.
앱 스택을 제거 한 이후, 다른 프로젝트를 작업할 때도 단순히 docker-compose up
를 실행하기만 하면 된다.
보안 스캐닝
이미지를 빌드하고 나서, docker scan
명령으로 보안 취약점을 탐색하는 것이 좋다. Docker는 Snyk과 파트너쉽을 보유하여 취약점 탐색 서비스를 제공한다.
예를 들어, getting-started
이미지에 대한 스캐닝을 진행해보자.
docker scan getting-started
스캔은 지속적으로 업데이트되는 취약점 데이터베이스를 활용하기 때문에, 아래와 같이 표시되는 출력은 상황에 따라 달라질 수 있다.
✗ Low severity vulnerability found in freetype/freetype
Description: CVE-2020-15999
Info: https://snyk.io/vuln/SNYK-ALPINE310-FREETYPE-1019641
Introduced through: freetype/freetype@2.10.0-r0, gd/libgd@2.2.5-r2
From: freetype/freetype@2.10.0-r0
From: gd/libgd@2.2.5-r2 > freetype/freetype@2.10.0-r0
Fixed in: 2.10.0-r1
✗ Medium severity vulnerability found in libxml2/libxml2
Description: Out-of-bounds Read
Info: https://snyk.io/vuln/SNYK-ALPINE310-LIBXML2-674791
Introduced through: libxml2/libxml2@2.9.9-r3, libxslt/libxslt@1.1.33-r3, nginx-module-xslt/nginx-module-xslt@1.17.9-r1
From: libxml2/libxml2@2.9.9-r3
From: libxslt/libxslt@1.1.33-r3 > libxml2/libxml2@2.9.9-r3
From: nginx-module-xslt/nginx-module-xslt@1.17.9-r1 > libxml2/libxml2@2.9.9-r3
Fixed in: 2.9.9-r4
출력에는 취약점의 타입을 나열하고, 이와 관련된 URL을 보여주며, 취약점을 고치기 위한 최근 라이브러리 버전 등을 제공해준다.
Docker scan 문서에서 더 많은 옵션들에 대한 사항을 살펴볼 수 있다.
CLI 외에도, Docker Hub 설정을 통해 새롭게 푸쉬된 이미지들에 대해서 스캐닝을 진행할 수도 있다.

이미지 레이어링
한 이미지가 어떻게 구성되어 있는지 확인하려면 어떻게 해야할까? docker image history
명령을 사용하면, 하나의 이미지를 구성하기 위해 생성된 여러 레이어들에 대한 명령어들을 확인할 수 있다.
docker image history
명령을 사용하면 이전 챕터에서 만들었던getting-started
이미지에 대한 레이어들을 확인할 수 있다.
docker image history getting-started
결과는 아래와 같은 형태일 것이다.
IMAGE CREATED CREATED BY SIZE COMMENT
a78a40cbf866 18 seconds ago /bin/sh -c #(nop) CMD ["node" "src/index.j… 0B
f1d1808565d6 19 seconds ago /bin/sh -c yarn install --production 85.4MB
a2c054d14948 36 seconds ago /bin/sh -c #(nop) COPY dir:5dc710ad87c789593… 198kB
9577ae713121 37 seconds ago /bin/sh -c #(nop) WORKDIR /app 0B
b95baba1cfdb 13 days ago /bin/sh -c #(nop) CMD ["node"] 0B
<missing> 13 days ago /bin/sh -c #(nop) ENTRYPOINT ["docker-entry… 0B
<missing> 13 days ago /bin/sh -c #(nop) COPY file:238737301d473041… 116B
<missing> 13 days ago /bin/sh -c apk add --no-cache --virtual .bui… 5.35MB
<missing> 13 days ago /bin/sh -c #(nop) ENV YARN_VERSION=1.21.1 0B
<missing> 13 days ago /bin/sh -c addgroup -g 1000 node && addu… 74.3MB
<missing> 13 days ago /bin/sh -c #(nop) ENV NODE_VERSION=12.14.1 0B
<missing> 13 days ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 13 days ago /bin/sh -c #(nop) ADD file:e69d441d729412d24… 5.59MB
각각의 한줄은 이미지 내 하나의 레이어들을 나타낸다. 최근의 레이어일수록 상단에 위치한다. 이를 통해, 각각의 레이어 사이즈를 찾아볼 수 있고, 큰 이미지들을 진단하는데에 도움이 된다.
- 위에서, 여러 줄들이 결과 출력에서 잘린 것을 볼 수 있는데,
--no-trunc
플래그를 추가해서 전체 출력을 표시할 수 있다.
docker image history --no-trunc getting-started
레이어 캐싱
레이어가 동작하는 방식을 살펴봤으니, 이제 컨테이너 이미지를 빌드하는데에 걸리는 시간을 줄이기 위한 방법에 대해 살펴보자.
일단 하나의 레이어가 변경된다면, 해당 레이어의 다운스트림 레이어들도 모두 재생성되어야 한다.
기존에 작성했던 Dockerfile을 다시 살펴보자.
# syntax=docker/dockerfile:1
FROM node:12-alpine
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]
이미지 히스토리에서 살펴본 것처럼, Dockerfile 내의 각각의 명령어들이 이미지 내 레이어가 되는 것을 볼 수 있다. 아마, 이미지에 어떤 변화가 발생하면 모든 yarn 종속성들이 전부 새로 설치되는 것을 확인할 수 있을 것이다. 빌드할 때마다 매번 동일한 종속성들을 설치하는게 적절해보이진 않는다. 이를 보완하려면 어떻게 해야할까?
이를 보완하기 위해서는, Dockerfile이 종속성 캐싱을 지원하도록 수정해야한다. Node 기반의 애플리케이션의 경우, 종속성들은 package.json
파일에 정의된다. 만약 우리가 처음 한번만 package.json
파일을 복사하고, 종속성을 설치한 후, 그 다음에 다른 것들을 복사하는 형태로 진행한다면 어떨까? 그렇다면 package.json
에 변경이 있을 때에만 yarn 종속성이 재설치되도록 할 수 있을 것이다.
- Dockerfile에 가장 먼저
package.json
을 복사하도록 수정한다. 이후 종속성을 설치하고, 그 다음에 나머지 모두를 복사한다.
# syntax=docker/dockerfile:1
FROM node:12-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production
COPY . .
CMD ["node", "src/index.js"]
- 동일한 폴더에
.dockerignore
라는 이름의 파일을 생성하고 아래의 내용을 작성한다.
node_modules
.dockerignore
파일은 이미지와 관련된 파일만을 선택적으로 복사하기 위한 쉬운 방법이다. 여기에서 더 많은 정보를 찾아볼 수 있다. 위 경우에서는, node_modules
폴더가 이미지에 추가될 필요가 없으므로 제외시켰다. 어차피 RUN
명령 단계를 통해 종속성이 설치되기 때문이다. 여기 문서를 통해 NodeJS 애플리케이션을 도커라이징하는데 있어서 추천되는 디테일 사항에 대해 살펴볼 수 있다.
docker build
를 통해 새로운 이미지를 빌드한다.
docker build -t getting-started .
그러면 아래와 같은 결과가 나타날 것이다.
Sending build context to Docker daemon 219.1kB
Step 1/6 : FROM node:12-alpine
---> b0dc3a5e5e9e
Step 2/6 : WORKDIR /app
---> Using cache
---> 9577ae713121
Step 3/6 : COPY package.json yarn.lock ./
---> bd5306f49fc8
Step 4/6 : RUN yarn install --production
---> Running in d53a06c9e4c2
yarn install v1.17.3
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.9: The platform "linux" is incompatible with this module.
info "fsevents@1.2.9" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...
Done in 10.89s.
Removing intermediate container d53a06c9e4c2
---> 4e68fbc2d704
Step 5/6 : COPY . .
---> a239a11f68d8
Step 6/6 : CMD ["node", "src/index.js"]
---> Running in 49999f68df8f
Removing intermediate container 49999f68df8f
---> e709c03bc597
Successfully built e709c03bc597
Successfully tagged getting-started:latest
Dockerfile을 수정했으므로 모든 레이어들이 다시 빌드됨을 확인할 수 있다.
-
이제,
src/static/index.html
상에서 약간의 변경을 해보자. -
다시
docker build -t getting-started .
를 통해 도커 이미지를 빌드한다. 이번에는 결과가 조금 다르게 출력될 것이다.
Sending build context to Docker daemon 219.1kB
Step 1/6 : FROM node:12-alpine
---> b0dc3a5e5e9e
Step 2/6 : WORKDIR /app
---> Using cache
---> 9577ae713121
Step 3/6 : COPY package.json yarn.lock ./
---> Using cache
---> bd5306f49fc8
Step 4/6 : RUN yarn install --production
---> Using cache
---> 4e68fbc2d704
Step 5/6 : COPY . .
---> cccde25a3d9a
Step 6/6 : CMD ["node", "src/index.js"]
---> Running in 2be75662c150
Removing intermediate container 2be75662c150
---> 458e5c6f080c
Successfully built 458e5c6f080c
Successfully tagged getting-started:latest
빌드 타임이 훨씬 짧아졌음을 확인할 수 있을 것이다. 그리고 위의 1 ~ 4 단계들이 전부 Using cache
로 처리된 것을 볼 수 있다.
멀티스테이지 빌드
현재 튜토리얼에서 너무 깊은 내용을 다루진 않겠지만, 멀티스테이지 빌드는 한 이미지를 생성하기 위해 여러 스테이지를 사용하도록 도와주는 유용한 툴이다. 이는 다음과 같은 이점을 제공한다.
- 런타임 종속성과 빌드타임 종속성을 분리한다.
- 애플리케이션 구동에 오직 필요한 내용만 빌드하여 전반적인 이미지 사이즈를 줄인다.
React 예시
React 애플리케이션을 빌드할 때, JS 코드, SASS, 그 외 정적 파일들을 컴파일하기 위한 Node 환경이 필요하다. (특히 JSX) 만약 SSR을 적용하는 것이 아니라면, 프로덕션 빌드 상에는 굳이 Node 환경이 필요하지 않다. 때문에 아래 예시에서는 정적인 nginx 컨테이너에 정적 리소스들만을 복사해준다.
# syntax=docker/dockerfile:1
FROM node:12 AS build
WORKDIR /app
COPY package* yarn.lock ./
RUN yarn install
COPY public ./public
COPY src ./src
RUN yarn run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
위에서는 node:12
이미지를 사용해 빌드를 수행한 후, nginx 컨테이너에 그 결과를 복사해넣게끔 해주었다.
SSH : Secure Shell / Secure Socket Shell
- 참고문서 : Understanding SSH workflow
SSH란 ?
SSH는 관리자들이 원격 컴퓨터에 안전한 방법으로 접근할 수 있게끔 해주는 네트워크 프로토콜이다. SSH는 클라이언트와 서버 간에 보안된 연결을 형성하며, 서로 간에 증명하며 명령어를 전달할 수 있다.
SSH는 어떻게 동작하는가?
SSH 프로토콜은 정보의 전송을 안전하게 하기 위해 대칭 / 비대칭 암호화와 해싱을 사용한다. 클라이언트와 서버 간의 SSH 연결은 다음의 세 단계를 거친다.
- 클라이언트에 의한 서버의 증명
- 모든 통신을 암호화하기 위한 세션 키 생성
- 클라이언트의 인증
위의 세 단계에 대해서는 아래에서 살펴보자.
1. 서버의 증명
클라이언트가 처음 서버와 SSH 연결을 실행한다. 서버는 기본적으로는 SSH 연결에 대해서 22번 포트(바뀔 수 있다)를 수신한다. 이 시점에서, 서버의 신원이 확인되는데, 두 가지 경우가 있다.
- 만약 클라이언트가 서버에 처음으로 접속하는 것이라면, 클라이언트는 서버의 공개 키(퍼블릭 키 : Public key)를 검증함으로써 직접 인증해야 한다. 서버의 퍼블릭 키는
ssh-keyscan
커맨드를 통해서 확인하거나, 구글 등을 통해 살펴볼 수 있다. 일단 키가 증명되면, 클라이언트 측의~/.ssh
디렉토리에known_hosts
파일에 해당 서버를 추가한다. 이known_hosts
파일은 클라이언트에 의해 검증된 서버들에 대한 정보들을 담고 있다. - 만약 클라이언트가 처음으로 서버에 접속하는 게 아니라면, 앞서 생성한
known_hosts
파일에 기록된 정보들로 서버의 신원을 확인하며, 이를 인증에 사용한다.
2. 세션 키 생성
일단 서버가 인증되고 나면, 클라이언트와 서버 양측에서 Diffie-Hellman 알고리즘이라는 버전을 사용하여 세션 키를 협상한다. 해당 알고리즘은 세션 키 생성 시에 양측이 동등하게 기여할 수 있는 방식으로 설계된다. 생성된 세션키는 공유 대칭 키(Shared symmetirc key)이며, 다시 말해 동일한 키가 암호화와 복호화에 같이 사용된다.
TDD : Test Driven Development
참고문서
TDD ?
프로세스 자체는 굉장히 심플하다.
-
직접 코딩하기 전에, 실패하는 테스트 코드를 작성해둔다. 이는 '정답'이 나오는 테스트들을 '양산하는' 것을 방지하기 위해서다.
-
코드를 작성하고, 테스트 코드를 통과하는지 확인한다.
-
필요하다면 리팩토링한다. 든든한 테스트코드를 작성했다면, '어디가 망가졌는지'를 파악할 수 있으므로, 리팩토링에 자신감을 갖자.
비효율적?
나도 최근에 테스트 코드를 직접 작성해보면서, 이상과의 괴리감을 좀 느꼈다. 생각만큼 그 '테스트 코드'를 작성하는 것이 쉽지 않았다.
표면 상으로도, 이 또한 코드를 작성하는 것이기 때문에, 추가적인 코드를 위한, 추가적인 시간 소요가 필요하다. 코드를 테스트하기 위한 테스트 코드를 테스트 하기 위한 테스트... 같은 느낌으로 코드를 만들고 있는 내 모습을 볼 수 있었다.
틀린 말은 아니다. 처음 테스트 코드를 작성할 때, 몇가지 노고를 겪는다.
- 테스트 코드를 작성하는 법 자체
- 어떤 테스트를 추가해야 하는지
결국, TDD도 러닝커브가 있다. 위 그림처럼 이상적인 프로세스로 보이는 것도, 실제로 해보려면 익숙해지기 위한 시간이 제법 되는 것이다. 한 작성자는 대략 15%-35% 정도 시간이 더 늘어난다고 말했다.
다만, 이것에 익숙해지기 시작하면, 마법같은 일이 일어나는데, 유닛테스트에 익숙해지면서 이전과는 비교가 안될 속도로 코드를 작성하는 것이다.
수동 테스트
TDD라는 개념이고 뭐고, 테스트 코드란게 있는지 조차 몰랐을 적의 내 모습을 떠올려보자.
HTML/CSS, JS를 작성하면서, 제대로 됐는지 확인을 해야한다. 당시에 내가 할 수 있는 유일한 '테스트'는 새로고침을 하고, 직접 버튼을 눌러보고, console.log
로 직접 상태를 확인하는 것이었다.
코드 바꾸고, 저장하고, 새로고침하고, 클릭하고, 기다렸다가, 어 안되네,
console.log
달고, 바꾸고, 저장하고, 아 이것도 안되네.... 콤보
내가 건들고 있는 부분이 비록 한 두 페이지에 불과하고, 변경과 컴파일에 얼마 걸리지 않는다면, 이는 전혀 문제될 게 없다. 근데, 앞으로 우리가 건드리게 될 것들이 그렇게 쉬운 문제들일까?? 버튼 한두개 만들고, 아이콘 한두개 띄우고...??
결국, 이런 테스트의 대상이 많아지면 많아질수록, 저런 식의 테스트는 비효율 그 자체가 되어버리고, 심지어는 리팩토링을 하거나 기능을 추가하는 과정에서 기존에 됐던 것이 안되거나 하는 일이 일어나도 전혀 인지하지 못하는 일이 생긴다.
테스트코드의 마법
describe('clipReducer/setClipStopTime', async (assert) => {
const stopTime = 5;
const clipState = {
startTime: 2,
stopTime: Infinity,
};
assert({
given: 'clip stop time',
should: 'set clip stop time in state',
actual: clipReducer(clipState, setClipStopTime(stopTime)),
expected: { ...clipState, stopTime },
});
});
이 코드를 보면 무슨 생각이 드는가? 테스트 한번 하겠다고 너무 코드를 길게 써야하는 것 아닌가?? 싶을 수도 있겠다. 근데 그게 바로 요점이다. 이 테스트 코드는 일종의 명세서같은 개념이다. 이렇게 문서화되어있는 대로 코드를 동작시키겠다는 일종의 증명이고, 그것이 있는 한, 내가 제대로 테스트했는지에 대한 염려 자체를 할 필요가 없어진다.
결국 테스트 코드를 쓰는데 얼마나 오래걸렸는지가 중요한 것이 아니다. 뭔가 잘못되었을때, 그것을 디버깅하기 위해 얼마나 걸리는지가 중요한 것이지. 위 코드가 제대로 동작하지 않으면, 그 테스트 자체가 훌륭한 버그 리포트가 된다. 테스트 코드를 살펴보는 것만으로 어디가 문제인지를 알 수 있게 된다.
TDD는 더 나은 코드 작성법을 알려준다
유닛테스트를 작성할 때 중요한 것은, 테스트하고자 하는 부분 이외의 것들과는 완전히 독립적이어야한다는 점이다. 예를 들어, 상태 관리에 대해 테스트를 하고자 한다면 별도로 스크린을 띄우거나 데이터베이스에 어떤 동작을 하지 않고 테스트를 할 수 있어야 한다. UI를 테스트한다면 브라우저에 페이지를 로딩하거나 네트워크를 건들지 않고도 테스트할 수 있어야 한다.
TDD는 가능한한 UI 컴포넌트들을 작게 유지함으로써 훨씬 간편해진다는 점을 알려준다. UI와 비즈니스 로직, 사이드이펙트들을 구분지어 생각하자. 이는 React 등의 라이브러리를 사용할 때도, 디스플레이를 담당하는 컴포넌트와 컨테이너 컴포넌트들을 구분지어 사용하기 쉽게 만들어준다.
모든 소프트웨어 개발은 **구성(Composition)**이다. 커다란 문제들을 작고 해결이 쉬운 많은 문제들로 분해하고, 그 문제들에 대한 해결책들을 만들어 애플리케이션을 만들어간다.
유닛테스트에서의 모킹은 이렇게 구성된 애플리케이션의 구성이 실제로 그렇게 범접 못할 정도로 긴밀하게 뭉쳐져있지 않다는 것을 알려주며, 어떻게 하면 그 '분리점'을 발견해낼 수 있는지를 알려준다.
TDD에 대한 5가지 오해
-
TDD는 시간 낭비다. 경영팀에서 절대 허용해줄 리가 없다.
-
디자인을 알기 전에는 테스트를 작성할 수 없다 & 코드를 실행시켜보기 전까진 그 디자인을 알 수 없다.
-
코드를 실행하기 전에 모든 테스트를 작성해야만 한다.
-
Red, Green 이후 항상 리팩토링한다.
-
모든 것에 유닛테스트가 필요하다.
모든 유닛테스트가 갖춰야 하는 5가지 질문
- 무엇을 테스트하는 중인가?(모듈 / 함수 / 클래스 / 뭐든)
- 그것이 무엇을 해야 하는가? (설명: description)
- 실제로 나온 결과가 무엇인가?
- 예상했던 결과는 무엇인가?
- 어떻게 '실패'를 만들어 낼 수 있는가?
Functional Programming
함수형 프로그래밍(Functional Programming)은 예전부터 들어왔지만, 그 자체로 되게 애매한 지식으로 내게 자리잡고 있었다.
여기의 문서를 살펴보면서 함수형 프로그래밍에 대한 개념을 잡고 가려고 한다.
FP는 다음과 같은 방식을 통해 소프트웨어를 만들어나가는 과정이다.
- 순수 함수의 합성(compose) 기반
- 공유 상태 / mutable 데이터 / 사이드 이펙트를 모두 회피
- 명령형 보다는 선언형
- 웹의 측면에서 보자면 순수함수들을 통한 상태 관리를 지향
이는 일반적으로 앱의 상태가 공유되고, 객체 메서드와 동일시되는 OOP와는 대조적인 성향을 띤다.
함수형 코드는 OOP 및 명령형 코드보다 명확하고, 예측가능하고, 테스트하기 쉽다.
사실, JS를 통해 프로그래밍을 해왔다면, 함수형 프로그래밍의 컨셉과 기능들을 본인도 모르는 사이에 적용해왔을 가능성이 높다.
왜 어렵게 들릴까?
함수형 프로그래밍은 뭔가 거창하게 들린다. 생소한 단어들이 여기저기 있기 때문이다.
- 순수 함수 (Pure func.)
- 합성 함수 (Function Composition)
- 공유 상태 방지
- Mutationg 상태 방지
- 사이드 이펙트 방지
순수 함수
동일한 입력이 있으면, 결과도 항상 동일하다
사이드 이펙트가 없다
합성 함수
둘 이상의 함수들을 합쳐 새로운 작업을 수행하는 함수다.
공유 상태
공유 상태는 공유 영역에 존재하는 변수, 객체, 메모리 공간들을 의미한다.
공유 상태가 갖는 문제점은, 단순히 함수의 실행 순서가 달라졌음에도 그 결과가 달라질 수 있다는 점에서 온다.
// With shared state, the order in which function calls are made
// changes the result of the function calls.
const x = {
val: 2,
};
const x1 = () => (x.val += 1);
const x2 = () => (x.val *= 2);
x1();
x2();
console.log(x.val); // 6
// This example is exactly equivalent to the above, except...
const y = {
val: 2,
};
const y1 = () => (y.val += 1);
const y2 = () => (y.val *= 2);
// ...the order of the function calls is reversed...
y2();
y1();
// ... which changes the resulting value:
console.log(y.val); // 5
이를 회피하기 위해, 앞서 나온 개념들인 순수함수, 합성함수들을 활용함과 더불어 공유 상태를 회피한다.
const x = {
val: 2,
};
const x1 = (x) => Object.assign({}, x, { val: x.val + 1 });
const x2 = (x) => Object.assign({}, x, { val: x.val * 2 });
console.log(x1(x2(x)).val); // 5
const y = {
val: 2,
};
// Since there are no dependencies on outside variables,
// we don't need different functions to operate on different
// variables.
// this space intentionally left blank
// Because the functions don't mutate, you can call these
// functions as many times as you want, in any order,
// without changing the result of other function calls.
x2(y);
x1(y);
console.log(x1(x2(y)).val); // 5
Immutability
불변성은 일단 생성되고 난 후에는 수정될 수 없는 성질을 의미한다.
단순히 const
로 변수를 선언하는 것과 헷갈리지 말아야 한다.
Side Effects
사이드 이펙트는 함수의 실행 이후 반환되는 값이 아닌 다른 부분에서 상태 변화가 생기는 경우다.
사이드 이펙트를 유발하는 동작들은 소프트웨어에서 별도로 관리되어야 하며, 프로그래밍 로직에 직접적으로 관련되어선 안된다.
앞선 과정이 이루어지면, 소프트웨어의 확장, 리팩토링, 디버깅, 테스트 및 유지보수가 훨씬 수월해진다.
때문에 많은 프론트엔드 프레임워크들이 느슨하게 결합된 모듈을 통해 상태를 관리하고, 컴포넌트를 렌더링하게끔 유도한다.
고차 함수를 통한 재사용성
함수형 프로그래밍에서는 어떤 종류의 데이터든 동일하게 취급된다.
map()
메서드는 매개변수로 함수를 사용하기 때문에, 어떤 종류의 값이든 매핑을 할 수 있다.
JS는 1등급 함수를 갖는다. 이는 함수를 데이터로 다룰 수 있게 하며, 매개변수로 지정할 수도 있고, 인수로 전달될 수도 있고, 함수 자체가 반환될 수도 있다.
결국, 고차 함수란 함수를 매개변수로 받거나, 함수를 반환하는 함수로 여겨질 수 있다. 이들을 종종 다음과 같은 역할을 할 수 있다.
- 콜백, 프로미스 등을 통해 이벤트 핸들링이나 비동기 로직을 처리한다.
- 다양한 데이터 타입에 폭넓게 적용할 수 있는 유틸 함수를 만든다
- Partial Application 및 Currying
- 함수의 리스트를 받아 이들 함수들의 합성함수를 반환
선언형 vs 명령형
함수형 프로그래밍은 선언형이며, 이는 프로그램 로직이 내부적인 흐름을 설명하지 않고 표현되는 하나의 패러다임이다.
명령형 프로그래밍은 원하는 결과를 얻기 위한 순차적인 방법을 코드에 작성하며, 어떻게 처리하는가를 주로 코드에 작성한다.
선언형 프로그래밍은 어떻게 이를 얻는지보다는 무엇을 하는가를 명시하는 코드를 주로 작성한다. 짧은 예시를 보자.
아래는 명령형 프로그래밍으로, 함수 실행을 통해 각 배열에 2를 곱한 결과를 얻고자 한다.
const doubleMap = (numbers) => {
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
return doubled;
};
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
아래는 선언형 프로그래밍으로, 앞의 코드와 동일한 역할을 하지만, map
메서드를 통해 각각의 값에 2를 곱하는 과정을 추상화하여 훨씬 데이터의 흐름을 명확하게 나타낸다.
결국, 명령형 프로그래밍은 Statement를 위주로 작성되는 코드에 해당한다. Statement는 일정한 액션을 수행하는 코드 조각들이며, for
, if
, switch
, throw
, 등이 이에 해당한다고 볼 수 있다.
반면, 선언형 프로그래밍은 Expression에 더 많이 의존한다. Expression은 어떤 값을 나타내는 코드 조각들이다. 일반적으로 결과 값을 생성하기 위해 함수 호출, 값 또는 연산자들의 조합들이다.
결론
함수형 프로그래밍은 다음을 선호하는 패러다임이다.
- 공유 상태 & 사이드 이펙트 대신에 순수함수
- 불변(Immutable) 데이터를 활용
- 명령형 흐름보다는 합성 함수
- 고차 함수를 통해 여러 종류의 데이터 타입에 폭넓게 재사용될 수 있는 유틸함수
- 명령형보다 선언형 (어떻게 하는지보다 무엇을 하는지에 초점)
- Statement보다 Expression
- 다형성(Polymorphism)보다는 컨테이너와 고차함수
Reactive Programming
해당 문서는 여기의 내용을 번역 및 참조한 것입니다.
왜 우리는 비동기적인 작업을 필요로 할까요?
간단한 대답은 바로 이용자 경험을 향상시키기 위해서입니다. 우리들은 애플리케이션을 더 반응성이 뛰어나게 만들고 싶어합니다. 다시 말해 메인 쓰레드를 멈추도록 하지 않고 더 부드러운 이용자 경험을 제공하고 싶어하죠.
메인 쓰레드를 자유롭게 유지하기 위해서는, 비용 소모가 많은 작업들에 대해서는 백그라운드를 통해 처리하도록 할 필요가 있습니다. 또, 무겁고 연산이 복잡한 작업들에 대해서는 서버를 통해 처리해야할 필요가 있죠. 이런 경우에도 네트워크 작업을 위해서 비동기적인 처리가 요구됩니다.
그래서 반응형 프로그래밍은 뭘까요?
반응형 프로그래밍은 데이터의 흐름과 변경의 전파를 중심으로 하는 프로그래밍 패러다임입니다. 이는 정적 또는 동적인 데이터 흐름을 쉽게 표현할 수 있고, 그에 따라 그 아래 놓인 실행 모델이 데이터 흐름을 통해 변경 사항을 자동으로 전파한다는 것을 의미합니다.
쉽게 말해서, 반응형 프로그래밍에서는 한 컴포넌트에 의해 데이터 흐름이 실행되고, 실행 모델이 해당 데이터 변화를 받게끔 등록한 다른 컴포넌트로 변경사항을 전파하도록 합니다.
아래에서는 Rx에서 사용되는 3가지 포인트에 대해 이야기해봅시다.
RX = OBSERVABLE + OBSERVER + SCHEDULERS
-
Observable : Observable은 대단할게 없는 데이터 스트림입니다. 이는 데이터를 한 쓰레드에서 다른 쓰레드로 넘겨질 수 있도록 데이터를 감쌉니다. 이들은 기본적으로 구성에 따라 주기적으로 또는 수명 주기에 한번 데이터를 내보냅니다. 당장에는, 해당 Observable을 일종의 "공급자"라고 생각하시면 됩니다. 이들은 데이터를 다른 컴포넌트에 처리하고 공급합니다.
-
Observers : Observer는 Observable에서 내보낸 데이터 스트림을 소비합니다. Observer는 observer를
subscribeOn()
을 통해 구독하며, Observable가 내보낸 데이터를 전달받습니다. Observable이 데이터 스트림을 내보낼 때마다, 등록된 모든 Observer가onNext()
콜백을 통해 데이터를 넘겨받습니다. 이 시점에서 JSON 응답을 파싱하거나, UI를 업데이트 하는 등의 다양한 작업을 수행할 수 있습니다. 반면 에러가 발생하는 경우에는onError()
콜백을 통해 처리할 수 있습니다. -
Schedulers : 기본적으로 반응형 프로그래밍은 비동기적인 프로그래밍을 위한 것이고, 우리는 쓰레드 관리가 필요합니다. Rx 상에서 Scheduler란 Observable과 Observer에게 어떤 쓰레드를 실행해야 하는지에 대해 알려주는 역할을 합니다.
observeOn()
메서드를 통해 어떤 쓰레드가 observe를 수행해야 하는지에 대해 처리해줄 수 있습니다.
애플리케이션에서 Rx를 사용하는 간단한 3단계

- 데이터 스트림을 내보낼 Observable을 생성
- 해당 데이터를 소비할 Observer를 생성
- 동시성 관리
Machine Learning
해당 문서는 여기의 내용을 따라가기 위한 과정입니다.
이는 번역 작업에 중점을 둔 것이 아니라, 어디까지나 제 개인적인 학습을 위한 것으로, 중간중간에 생략 및 의역된 내용들이 다수 포함될 확률이 높습니다. 이에 참고바랍니다.
Problem Framing
해당 섹션의 목표는 다음과 같습니다.
- 일반적인 ML 용어의 정의
- ML을 사용하는 상품과 각각에서 쓰이는 ML 문제 해결의 일반적인 방식
- ML로 특정 문제를 해결할 수 있는지를 구분
- 다른 프로그래밍 방식과 ML의 비교 및 대조
- ML 문제들에 대한 가설 검증과 과학적 방법
- ML 문제 해결에 대한 이야기
Common ML Problems
일반적인 관점에서, 머신 러닝이란 모델이라 불리는 하나의 소프트웨어를 훈련시키는 과정입니다. 모델은 데이터 셋을 사용해 유용한 예측을 수행합니다.
종종, 사람들은 머신러닝을 지도학습과 비지도학습이라는 두가지 패러다임으로 구분합니다. 하지만, 실제로 머신러닝은 이 두가지 학습 방식이 복합적으로 어우러진다고 보는 편이 더 정확합니다. 여기서는 간단함을 위해, 좀 더 양쪽에 극단적인 케이스를 다루고자 합니다.
지도 학습
지도 학습은 label처리된 트레이닝 데이터들을 제공받는 머신러닝의 유형입니다.
예를 들어, 아래와 같은 식물들의 데이터를 통해, 각 식물이 어떤 종에 속하는지를 맞추는 것과 같죠.
Leaf Width | Leaf Length | Species |
---|---|---|
2.7 | 4.9 | small-leaf |
3.2 | 5.5 | big-leaf |
2.9 | 5.1 | small-leaf |
3.4 | 6.8 | big-leaf |
여기서 잎의 너비와 길이는 Features입니다. 그리고 우리가 맞추어야 하는 종(Species)는 곧 Label이 되죠. 아마 실제로는 훨씬 더 많은 Feature들이 존재할테지만, 여전히 Label은 한개일겁니다. 그리고, 이 Label이란 개념은 반드시 "정답"과 같은 느낌으로, 명확해야 합니다.
위에서는 단순히 4개의 예시만 다루었지만, 실제로는 훨씬 더 많은 데이터가 존재할 겁니다. 만약 데이터셋이 다음과 같은 그래프를 그려낸다고 생각해봅시다.
지도학습에서는, 각 데이터셋의 케이스에 대한 Feature와 Label들을 알고리즘에 전달하는 Training이라 불리는 과정을 거칩니다. 이러한 과정 속에서, 알고리즘은 점차적으로 Feature들과 Label 간의 상관관계를 파악하게 되며, 이러한 상관관계가 곧 모델이 됩니다. 머신러닝에서 대부분 이러한 모델은 굉장히 복잡한 형태지만, 당장에는 다음과 같이 선 하나로 표현될 수 있는 모델이 있다고 하겠습니다.
이제 모델은 난생 처음보는 케이스들에 대해서 나름의 예측을 할 수 있게 됩니다.
비지도 학습
비지도 학습에서의 목표는 데이터 내에 존재하는 의미있는 패턴을 발견하는 것입니다. 이를 위해서 기계는 별도의 Label이 존재하지 않는 데이터로부터 배워야합니다. 다시 말해, 모델은 각 데이터 조각들을 어떻게 분류해나가고, 어떻게 처리되어야 하는지에 대한 별도의 힌트를 전달받지 못합니다.
아래 그래프에는 별도의 Label이 없습니다. 그렇기 때문에 모두 초록색 동그라미로 표기됩니다.
이처럼 Label이 없는 데이터셋에 아래와 같이 선을 긋는 것은 무의미합니다. 여전히 줄의 양쪽에는 동일한 초록 동그라미들이 있을 뿐입니다.
한번 다르게 접근해봅시다. 여기 두 Cluster(군집)이 있습니다. 이 군집들은 무엇을 나타낼까요? 아마 대답하기는 어려울겁니다. 때때로 모델은 우리가 배우지 않기를 바라는 데이터에 대한 패턴을 찾아내기도 합니다. 스테레오타입(Stereotypes)과 편향(Bias)같은 것들이죠.
그럼에도 부룩하고, 새로운 데이터가 나타났을 때, 그것이 현재 알고 있는 군집에 적합한 데이터라면 우리는 쉽게 이를 분류할 수 있습니다. 그런데, 만약 우리가 난생 처음 보는 군집에 해당하는 데이터라면 어떨까요?
참고 : 사실, 클러스터링(군집화)는 비지도 학습의 유일한 형태가 아닙니다. 비지도 학습에는 여러 유형이 있고, 군집화는 그 중 가장 일반적인 방식일 뿐입니다.
강화 학습
머신 러닝의 추가적인 갈래로 강화 학습(Reinforcement Learning)이 있습니다. 이는 머신러닝의 다른 유형들과 구분됩니다. 기본적으로 이는 Label을 가진 데이터를 수집하지 않습니다. 만약 기계에게 정말 간단한 비디오게임을 플레이하게 만들고, 절대 지지 않게끔 가르치려고 한다고 해봅시다. 이는 기계에게 게임을 하되, 게임오버에 도달하지 말라고 말하는 것과 같습니다. 기계는 학습 중에 reward 함수라 불리는 것을 통하여 각 태스크에 대한 보상을 전달받습니다. 강화 학습을 거치면서, 이들은 실제로 사람을 뛰어넘는 수준으로 빠르게 학습할 수도 있습니다.
기본적으로 강화 학습은 수많은 데이터를 요구하지 않습니다. 그러나 좋은 reward 함수를 디자인하는 것은 어려우며, 그렇기 때문에 지도 학습보다 덜 안정적이고, 예측이 어렵습니다. 추가적으로, 기계가 게임과 같은 것들을 플레이 해나가며 상호작용을 통해 데이터를 생산해나갈 방법 자체도 고려해야합니다. 만약, 우리가 실 생활에서나, 혹은 VR 세계에서 강화학습을 적용해나가고자 한다면, 대단히 어려운 과정이 될 것입니다. 강화 학습도 머신러닝 분야에서 굉장히 활발하게 연구되고 있으나, 여기에서는 별도로 자세히 알아보지는 않도록 하겠습니다.
머신러닝 문제의 유형
머신러닝 문제는 예측이 어떤 식으로 이루어지느냐에 따라 여러가지로 구분됩니다. 아래는 일반적인 지도/비지도 학습의 예시들을 다루고 있습니다.
Type of ML Problem | Description | Example |
---|---|---|
Classification | Pick one of N labels | Cat, dog, horse, or bear |
Regression | Predict numerical values | Click-through rate |
Clustering | Group similar examples | Most relevant documents (unsupervised) |
Association rule learning | Infer likely association patterns in data | If you buy hamburger buns, you're likely to buy hamburgers (unsupervised) |
Structured output | Create complex output | Natural language parse trees, image recognition bounding boxes |
Ranking | Identify position on a scale or status | Search result ranking |
The ML Mindset
머신러닝은 당신이 문제에 대해 생각하는 방법을 바꿉니다. 문제의 초점이 수학에서 자연과학으로 옮겨가면서 논리가 아닌, 실험과 통계에 기반하게 됩니다. - Perter Norvig
전통적인 방식의 소프트웨어 엔지니어링은 요구사항으로부터 실행가능한 디자인까지 추론해낼 수 있습니다. 그러나 머신러닝에서는, 실행가능한 모델을 찾기 위해서 실험이 필수적입니다.
많은 머신러닝 시스템은 인간과 다르게 신호를 해석함으로서 지식과 지능을 인코딩하는 모델을 만듭니다. Neural Network(인공신경망, 이하 NN)는 임베딩을 통해 단어를 해석할 수도 있을 겁니다. 예를들어, "tree"는 [0.37, 0.24, 0.2]
, "car"는 [0.1, 0.78, 0.9]
와 같은 식으로 해석될 것입니다. NN은 이러한 결과를 번역이나 감정 분석등에 사용할 수 있겠지만, 이는 사람이 봤을 때에는 도무지 이해할 수 없는 형태일 것입니다. 이러한 부분들이 기계 입장에서의 지능을 인간 입장에서 상당히 이해하고 평가하기 어렵게 만듭니다. 아예 불가능하지는 않겠지만요.
모델은 왜곡된 교육 데이터부터 시작해, 예상치 못한 해석에 이르기까지, 여러가지 디버깅하기 어려운 실수들을 범합니다. 게다가, 이를 실제 제품에 적용하려면, 상호작용이 훨씬 복잡해질테고, 이런 이유로 가능한 모든 상황을 예측하고 테스트하는 것이 어려울 수 있습니다. 이러한 문제들을 해결하기 위해 제품을 담당하는 팀이 머신러닝 시스템의 기능과 개선 방법을 이해하는 데에 많은 시간을 투자하는 것이 필요합니다.
Experimental Design Primer
어느 정도의 불확실성에 대해서는 익숙해지세요.
단순히 문제를 다르게 생각하는 것을 넘어, ML의 구현은 기존의 프로그래밍 방식과는 다릅니다. 전통적인 방식에서의 프로그래밍은 매개변수를 설정하고 모든 것이 어떻게 동작하는지에 대해 개발자가 이해할 수 있습니다. 허나 ML에서는, 일반적으로 훨씬 더 적은 양의 코드를 작성하지만, 코딩 이외의 작업이 매우 복잡할 수 있습니다.
사용가능한 모델만 만들고 끝내고 싶다고요? 이런 생각은 처음부터 글러먹었습니다.
과학적 방법론
ML로 전환하는 문제를 해결하기 위해서, ML 프로세스는 테스트에 테스트를 거듭하여 실행 가능한 모델에 수렴해나가기 위한 실험 과정으로 이해하면 도움이 됩니다. 마치 실험과도 같이, 그 과정은 흥미롭고 도전적이고, 궁극적으로 가치있을겁니다.
Step | Example |
---|---|
1. 연구 목표 설정 | 주어진 날짜에 교통 체증이 얼마나 발생할지를 예측하고 싶다. |
2. 가설 세우기 | 아마 일기 예보가 좋은 정보가 될 것 같은데? |
3. 데이터 수집 | 각 날짜의 교통과 날씨 정보를 수집한다. |
4. 가설을 테스트한다. | 수집한 데이터로 모델을 훈련시킨다. |
5. 결과를 분석한다. | 지금보다 더 좋은 모델이 나올 수 있을까? |
6. 결론을 도출한다. | X, Y, Z 때문에, 이 모델을 사용해야(하지 말아야) 한다. |
7. 새로운 가설을 세우고 다시 반복한다. | 1년을 기준으로 본 시간이 의미가 있을 수도 있어! |
Identifying Good Problems for ML
해당 섹션에서는 좋은 ML 문제들의 특징에 대해서 살펴봅니다.
명확한 유스 케이스 (Use Case)
해답이 아니라, 문제부터 시작하세요. ML 하나로 모든 문제들을 뚝딱 해결할거라 생각하지 마세요.
전통적인 프로그래밍 방식으로 해결하기 어렵다고 생각하는 문제를 들여다봅시다. 예를 들어, Smart Reply같은 것이죠.
이러한 시스템들은 기존의 프로그래밍으로는 명확한 접근 방식을 생각해낼 수 없습니다. 반면에 ML을 통해서 여러 데이터들 간의 패턴을 파악하고 적용하면서 이런 문제들을 해결해낼 수 있죠. ML은 도구함 속 한가지 도구로 생각하는 것이 맞습니다. 오직 적절한 상황에서만 사용하는 그런 도구일 뿐이죠.
ML이 필요한지에 대해 파악하기 위해서는 다음과 같은 질문들에 대해 스스로 생각해볼 필요가 있습니다.
- 현재 내가 무엇인가 만들던 도중 마주친 문제가 무엇인가?
- 그것이 ML로 해결하기에 적합한 문제인가?
데이터에 집중하기 이전에 문제부터 파악하라
본인이 세운 가정이 틀릴 상황에 대해 준비하세요.
어떤 문제를 명확하게 이해했다면, 최상의 모델을 만들기 위해 몇가지 잠재적인 솔루션을 세워볼 수 있을겁니다. 제대로 동작하는 모델을 만들기 전에는 몇가지 솔루션들을 시도해봐야 할 것입니다.
탐색적 데이터 분석(EDA)은 데이터를 이해하는 데는 도움이 될 수 있지만, 이전에 보지 못했던 데이터에 대해서도 동일한 패턴이 일반적으로 발생한다는 것을 직접 확인하기 전에는 이에 대해 확신할 수 없습니다. 이러한 부분을 정확하게 체크하지 않으면, 스테레오타입(고정관념)과 바이어스(편향)에 빠질 수 있다는 점을 주의하세요.
본인 팀의 로그에 의존하세요.
ML은 수많은 데이터들을 필요로 합니다.
애초에 ML 작업을 위해 특별한 형태로 수집된 데이터가 가장 유용합니다. 하지만, 실제 상황에서는 이러한 형태로 이루어지기 어려울 수 있으며, 그런 경우 가깝게 위치한 모든 데이터에 의존해야 합니다. 비용과 제품 로그 등에 대해 알고 있다면, 이를 통해 더 적합할 모델을 구축할 수 있습니다.
얼마나 많이요? 그것은 해결하고자 하는 문제에 따라 다릅니다. 그러나 일반적으로 모델은 더 많은 데이터를 활용할 수록 더 좋은 예측을 할 수 있습니다. 기본적인 선형 모델의 경우 적어도 수천 개의 예시를, 신경망의 경우에는 수십 만개의 예시를 갖는 것이 일반적으로 좋습니다. 이 정도의 데이터를 모을 수 없는 상황이라면, ML이 아닌 솔루션을 우선적으로 고려할 필요가 있습니다.
예측력
Feature들은 예측력을 포함하고 있습니다.
경마에서 어떤 말이 좋은 성적을 보일지 예측하는 모델을 만들고 싶다고 합시다. 여기서 우리는 말의 눈 색깔을 Feature로 사용하기로 결정했다고 합시다. 우리의 가설은, "눈 색깔을 통해 말이 눈병에 걸리기 쉬운지를 예측하고, 이것이 말의 신체능력에 영향이 줄 것"이라는 것이죠. 이러한 가설은 틀릴지도 모르고, 추후 증거에 기초하여 가설을 기각할 수도 있습니다. 즉, 눈 색깔이 모델의 향상에 도움을 주는 Feature가 되지 않을지도 모른다는거죠.
그렇다고 해서 ML이 자신과 관련된 Feature가 무엇인지 찾는 어려운 작업을 수행하게 만들어서는 안됩니다. 모델에게 관련된 데이터를 모두 던져주고, 어떤 것이 유용한지 확인하려고 한다면, 우리가 만든 모델을 지나치게 복잡하고, 비싸고, 중요하지 않은 Feature들로 가득차게 될 가능성이 높습니다. 작은 데이터 셋 내에서 Feature는 샘플 데이터 내에서 우연한 상관관계를 띄게 될 가능성이 큽니다. 가설을 세우지 않고 무작정 많은 Feature를 적용하려고 하면, 그것이 모델과 관련된 신호라고 착각하게 될 가능성이 높습니다. 이러한 사실은 모형을 사용해 예측을 시도하고, 그것이 일반화되지 않았다는 것을 깨닫기 전까지는 알 수 없습니다.
예측(Predictions) vs. 결정(Decisions)
예측이 아닌, 결정에 초점을 맞추세요.
여기서 말하는 결정이란, 모델의 출력 결과에 따라 제품이 수행하게 될 작업을 말합니다. ML은 우리에게 문제에 대한 통찰력을 제안하기 보다는, 이에 기반한 결정을 내리는 것에 더 초점이 맞추어져 있습니다. 만약, 우리가 가진 수많은 데이터에 대해 어떤 흥미로운 분석 결과에 대해 알고 싶다면 ML보다는 통계학이 더 올바른 접근 방법입니다.
예측이 유용한 액션을 취할 수 있도록 하는지 확신하세요. 예를 들어, 특정 동영상을 클릭할 가능성을 예측하는 모델을 통해 시스템이 클릭할 가능성이 가장 높은 동영상을 미리 가져오도록 할 수 있습니다.
가끔 예측과 결정은 밀집하게 연관된 경우도 있지만, 그와 다르게 관계가 명확하지 않은 경우도 많습니다. 아래의 예측/결정 간의 차이에 다룬 테이블을 확인해봅시다.
Prediction | Decision |
---|---|
이용자가 다음에 어떤 동영상을 보고자 할까? | 추천 목록에 해당 동영상들을 띄워주자. |
누군가 검색 결과에 대해 클릭할 확률 | `P(click) > 0.12` 이라면, 해당 웹페이지를 미리 가져오자. |
이용자가 시청할 비디오 광고의 비율 | 적은 비율이라면, 광고를 보여주지 않도록 하자. |
Hard ML Problems
해당 섹션에서는 ML에서 특히 다루기 어려운 문제들에 대해 이야기합니다.
Clustering (클러스터링 = 군집화)
비지도 학습에서 각 클러스터는 무엇을 의미할까요? 예를 들어, 우리가 만든 모델이 아래의 사진에서 파란 클러스터내에 특정 유저가 포함된다고 이야기한다면, 결국 우리는 파란 클러스터 자체가 무엇을 의미하는지를 알아야할 필요가 있습니다.
종종 클러스터에 따라 액션을 취해야 할 수 있습니다. 예를 들어, Google Photo에서는 동일한 사람들이 모인 사진들을 동일한 사진 그룹에 모아놓습니다. 그 밖의 경우, 클러스터에 기반하여 어떤 액션을 수행하는 것은 도전적인 일이 될 겁니다. 클러스터에 의미를 부여하려고 할 수는 있지만, 생각보다 쉽지는 않습니다. 왜냐하면 모델이 직관적인 기준에 따라 그룹화되어 있지 않을 수도 있기 때문입니다.
이에 대한 한가지 대안은 클러스터링을 하기 이전에 미리 일부 표본들에 대해 label(레이블)을 수행한 다음 해당 레이블들을 전체 클러스터에 전파하는 것입니다. 예를 들어, 레이블이 X인 모든 항목이 하나의 클러스터에 모여있다면, 해당 레이블 X를 다른 예시들에도 전파시켜볼 수 있습니다.
Anomaly Detection
종종, 사람들은 이상징후를 식별하기 위해서 ML을 사용하려고 합니다. 관건은 "어떤 것이 이상치인지" 어떻게 결정하느냐 입니다. 한 가지 선택지는 휴리스틱을 정의하고 이를 이상치의 레이블링에 사용하는 것입니다. 그러나, 이 휴리스틱을 정의하였을 때, 이를 학습 단계에서 사용하게 될 경우 애초에 ML은 해당 휴리스틱을 능가할 수 없는 상황에 처하기 때문에, 생산 단계에서 해당 휴리스틱을 사용하는 것이 좋습니다.
참고 : 떄때로 고정밀 저호출 휴리스틱(High-precision Low-recall heuristic)을 정의한 다음, 준지도적인(semi-superviesed) 방식을 사용해 모델이 시드 예측 집합(seed set of predictions)에서 성장하게끔 훈련시켜 레이블이 없는 더 큰 데이터 집합을 분류할 수 있게끔 할 수도 있습니다. (아직은 뭔 소린지 모르겠다.)
만약, 휴리스틱이 충분히 복잡하다면 ML을 통해 이를 대체하는 것을 고려해볼 수도 있습니다. 그러나 일반적으로 휴리스틱을 다시 정의하는 것만큼 모델은 쉽게 수정하기 어렵기 때문에 주의할 필요가 있습니다.
Causation
ML은 둘 이상의 상호 관계 및 연관성에 대해 식별할 수 있습니다. 반면, 인과 관게를 판단하는 것은 훨씬 어려운 일입니다. 다시 말해, **무슨 일들이 일어났는가?**에 대해서는 알기 쉽지만, **왜 그것이 일어났는가?**에 대해서는 훨씬 판악하기 어렵습니다.
예를 들어, 소비자들이 어떤 책들을 구매한 것은, 바로 지난 주에 해당 책에 대한 긍정적인 리뷰가 있었기 때문일까? 아니면 그 리뷰는 전혀 상관 없는 것이었을까?
오직 관측 데이터를 통해서는 인과 관계에 대해 파악할 수 없습니다. 위 사례처럼, 지난 일들에 대한 기록만 보고서는 지난 주의 리뷰가 구매를 유발했는지의 여부를 판단할 수 없습니다. 이를 파악하려면, 리뷰를 보지 못한 이용자와, 리뷰를 본 이용자 간을 비교하는 실험이 추가적으로 필요합니다. 결국, 일반적으로 어떤 일들의 인과 관계를 확인하려면 추가적인 실험이 필요하고, 이는 순수 관측 데이터를 통해서는 불가능한 일입니다.
No Existing Data
앞서 언급한 것처럼, 모델을 훈련시킬 데이터가 없다면, ML은 여러분에게 아무런 도움을 줄 수가 없습니다. 데이터가 없다면, 단순한 휴리스틱 기반의 규칙 기반 시스템을 사용하세요. 많은 신규 서비스들은 휴리스틱 기반의 시스템으로 시작하여, 이용자들이 해당 서비스와 상호작용하기 시작하면서부터 새로운 훈련 데이터들을 얻습니다. 일단 훈련 데이터들이 갖추어지기 시작한다면, 그 속에서 패턴을 찾으려고 노력하세요. 패턴이 존재하지 않거나, 자질구레한 패턴만이 존재한다면, ML은 아마 별다른 쓸모가 없을 겁니다. 결국, 수많은 패턴이 존재하고, 그 속에서 정확한 예측을 하는 것이 중요한 상황 속에서야 ML은 비로소 올바른 접근이 될 겁니다.
Deciding on ML
데이터를 본격적으로 다루기 전에, 준비되었는지 확인해보는 과정을 거치도록 하자. 아래에서는 머신 러닝 문제들을 구성하기 전에 생각해야 할 몇가지 사항에 대해 간략하게 설명한다.
명확하고 간단하게 시작하라.
말 그대로, ML 모델이 어떤 일을 했으면 좋겠는가?
예시 |
---|
ML 모델으로 방금 업로드된 동영상이 추후에 얼마나 인기를 끌지 예측하고 싶다. |
현 시점에서, 이에 대해 답하는 것은 다소 정성적인 목표가 될 수 있으나, 간접적인 목표가 아니라, 실제 목표를 지니고 있는지 확인하라.
어떤 결과가 이상적인가?
시스템에 ML 모델을 추가하는 것은 이상적인 결과를 생산해야 한다. 이는 단순히 모델과 그 품질을 평가하는 것과는 다른 의미를 갖는다.
예시 | |
---|---|
트랜스코딩 |
이상적인 목표는 덜 인기있는 영상에 대해 트랜스코딩을 거쳐 이들이 잡아먹는 리소스를 줄이는 것이다.
|
영상 추천 | 이상적인 목표는 사람들이 유용하고, 흥미로워하고, 가치가 있다고 판단하는 영상을 제안하는 것이다. |
주의: 똑같은 모델로도 다른 이상적인 결과를 목표로 할 수 있다. 이것이 이상적인 결과를 명확하게 두어야 하는 이유다.
이미 최적화를 하고 있는 서비스의 매트릭스에 갇혀있지 마라. 제품 및 서비스의 더 큰 목표에 집중해야 한다.
성공/실패 매트릭스(지표)
정량화하라
시스템이 성공적인지 아닌지 어떻게 판단할 수 있을까?
성공 및 실패 매트릭스는 정밀도, 재현율 또는 AUC와 같은 평가 매트릭스와 독립적으로 표현되어야 합니다. 대신, 예상 결과를 구체화시켜야 합니다. 시작하기 전에 미리 성공 매트릭스를 설정하세요. 이는 매몰 비용으로 우리가 평범한 모델로부터 시작하게끔 하는 것을 막기 위해서 입니다.
예시 | |
---|---|
트랜스코딩 | 성공 매트릭스는 CPU 리소스 사용률이다. 성공은 트랜스코딩을 위한 CPU 비용을 35% 줄이는 것을 의미한다. 반면 실패는 비용 절감이 모델 학습 및 제공을 위한 CPU 비용보다 적은 경우다. |
영상 추천 | 성공 매트릭스는 모델을 통해 적절하게 예측한 영상의 개수가 된다. 성공은 업로드된 이후 28일 이내에 시청 시간을 기반으로 가장 인기있는 동영상의 95%를 예측하는 것을 의미한다. 반면 실패는 적절하게 예측된 인기 동영상의 수가 현재의 휴리스틱을 통한 예측보다 성능이 나쁜 경우다. |
측정 가능한 매트릭스인가?
측정가능한 지표는 실제로 적용하였을 때의 평가에 있어 충분한 정보를 제공한다. 예를 들어, 과수원의 건강 상태를 모니터링하는 시스템은 병든 나무들의 비율을 줄이고자 한다. 한편, "어떤 나무가 병든 나무인가?"에 대한 측정이 불가능하다면, 그다지 유용한 지표는 아니다.
아래와 같은 질문들을 스스로 해보자.
- 어떻게 지표를 측정하는가?
- 언제 지표를 측정할 수 있는가?
- 새로운 ML 시스템이 성공적인지 아닌지를 판단하는 데에 얼마나 많은 시간이 걸릴 것인가?
이상적으로는, 빨리 실패하고자 할 것이다. 데이터의 신호(Signal)가 너무 적거나, 예측할 수 없는 데이터가 있는지를 확인하여 가설이 틀릴 수 있는지에 대해 판단하라. 빠른 실패는 프로세스의 초기에 가설을 수정하고 시간 손실을 방지할 수 있다.
예시 |
---|
이용자의 위치에 따라 어떤 영상이 시청되었지에 대해 판단하고 싶다. 이는 가능한 것처럼 보이지만, 막상 시도해보면, 신호가 너무 약하거나, 너무 많은 노이즈가 껴있어 제대로 이루어지기 어렵다. 얼마나 오래 해당 가설을 고수할 것인가? |
보다 장기적인 관점에서 엔지니어링 및 유지 비용을 고려하라.
다른 실패 시나리오
성공 지표와 관련이 없는 실패에 대해서도 확인하라. 예를 들어, 영상 추천 시스템이 항상 "어그로 영상"들을 추천한다면 양질의 서비스를 제공해야한다는 관점에서 성공적이라고 할 수 없을 것이다.
ML 모델이 어떤 형태의 결과를 만들어냈으면 하는가?
아래 테이블을 기반으로, 어떤 형태의 결과를 출력하는 것이 가장 적절할지에 대해 생각해보라.
Type of ML Problem | Description | Example |
---|---|---|
Classification (분류) | N개의 라벨 중 하나를 선택 | cat, dog, horse, or bear |
Regression (회귀) | 수치에 대한 예측 | 클릭율 |
Clustering (군집화) | 유사한 예시들을 그룹화시키기 | 가장 관련이 있는 문서 (비지도) |
Association rule learning (연관 규칙 학습) | 데이터에서 가능한 연관 패턴 추론 | 만약 햄버거 빵을 샀으면, 햄버거도 샀을 것이다. (비지도) |
Structured output (구조적인 결과) | 복잡한 출력 생성 | 자연어 파싱 트리, 이미지 인식 경계 박스 |
주의 : 특정 ML 문제는 어떤 타입에서는 실패하지만, 어떤 타입에서는 성공적일 수 있습니다.
좋은 출력의 특징
모델이 만들어낸 출력은 명확한 정의가 가능한 정량적 결과여야 합니다.
Example |
---|
이용자가 영상을 재밌게 보았는가? vs. 이용자가 영상을 공유하였는가? |
이용자에게 직접 물어보지 않는 한, 이용자가 정말로 해당 영상을 즐겼는지에 대해 알 방도가 없다. 만약 이를 판단하려면, 별도의 **프록시 레이블**이 필요할 것이다. 프록시 레이블은 실제를 대신할 대체 레이블을 의미하는데, 이용자의 공유 여부는 좋은 프록시 레이블이 된다. 물론, 공유는 이용자가 영상을 재밌게 보았는지에 대한 완벽한 측정치가 될 수 없다. 그러나, 공유는 정량적이고, 추적 가능하고, 수많은 예측 시그널을 제공한다는 점에서 더 적절하다.
출력은 앞서 정한 이상적인 결과와 관련이 있어야 한다.
모델은 출력을 올바른 방향으로 최적화해 나갈 것이다. 그러므로, 출력은 우리가 신경쓰고자 하는 실질적인 이상적인 목표와 관련이 있어야 한다. 프록시 레이블이 종종 필수적인데, 항상 이상적인 결과를 직접 측정할 수 없기 때문이다. 그러나, 레이블과 실제 결과 간의 연관성이 강할수록, 제대로 된 것을 최적화시켜 나가고 있다는 자신을 갖기도 용이하다.
Output | Ideal Outcome | |
---|---|---|
이용자가 기사를 공유할지 아닌지에 대한 예측 | 이용자들이 좋아하는 기사를 보여줌 | |
비디오가 인기있을 것인지 아닌지에 대한 예측 | 이용자가 유용하고 가치있다고 판단하는 영상을 제공 | |
이용자들이 앱 스토어에서 어떤 앱을 설치할지 아닌지에 대한 예측 | 이용자들이 좋아할만한 앱을 찾아줌 | |
둘 사이에 강한 연관성이 있는지 확인해야 한다! |
연습 데이터로 사용할 예시 데이터들을 얻을 수 있는가?
어떻게, 어디서 데이터들을 가져올 것인가?
지도 학습은 레이블 처리된 데이터에 의존한다. 만약 연습에 사용할 데이터를 얻는 것이 어려운 상황이라면, 앞선 단계를 다시 밟으면서 문제와 목표를 재정의하는 것이 필요할 것이다. 앞서 시청 시간을 퍼센트 단위로 변환한 것처럼, 출력 예시는 엔지니어링 되어야 할 수도 있다.
출력 활용
모델은 다음의 두 부분에서 예측을 수행할 수 있다.
- 실제 사례, 이용자 활동에 따른 반응 (online)
- 배치 또는 캐시로써 사용
서비스에서 어떻게 예측을 활용할 것인가? 명심하라, ML은 단순히 예측을 하는 것이 아니라, 그것에 기반해 결정을 수행해야 한다. 어떻게 모델의 예측 결과를 의사 결정으로 바꿀 수 있을까?
Example |
---|
모델은 새 영상이 업로드될 때 영상의 인기도를 예측할 것이다. 그 결과에 기반하여 영상에 대한 트랜스코딩 알고리즘을 결정할 것이다. |
아래 예시처럼, 이용자의 클릭율을 예측하여 이를 통해 이용자에게 알림을 전달할 수도 있다.
click_probability = call_model(user)
if click_probability > 0.05:
send_notification(user)
다음으로, 아키텍처의 어디에 이러한 의사 결정 코드들이 위치할 지에 대해 판단하라. 아래의 질문들이 중요한 도움이 될 것이다.
- 모델을 호출할 때 어떤 데이터들이 필요한가?
- 대기시간(latency) 요구사항이 있는가? UX를 위해 빠르게 실행해야 하는가, 아니면 이용자가 기다리지 않아도 되는 경우인가?
위의 질문들은 모델의 실제 feature를 구성하는 데에도 영향을 준다. 모델을 호출할 때 사용할 수 있는 데이터들에 대해서만 feature를 결정하고 훈련시킬 수 있기 떄문이다. 요구 대기시간 역시 feature 구성에 영향을 줄 수 있는데, 이를테면 카메라 앱에서 현재 날씨 데이터를 feature로 사용하고자 하는 경우, 해당 데이터를 다른 api 등을 통해 가져오는 데 비용이 너무 크게 소모되기 때문이다.
마지막으로, 너무 오래된 데이터를 사용하지 않도록 해야한다. 훈련 데이터가 며칠이 지난 경우가 있을 수 있다. 실시간 트래픽에서 동작하는 경우, 최신 데이터에 대해 당장에 접근할 수 없는 경우가 발생할 수도 있다.
좋지 않은 목표
적절하게 설정되기만 한다면, ML 시스템은 목표를 추구하기에 정말 유용하다. 반대로, 적절하지 않은 목표에 기반을 둔다면, ML 시스템은 의도치 않은 결과를 생산해낼 것이다. 그러므로, 어떤 문제를 해결하기 위하여 해당 시스템의 목표에 대해서 주의깊게 고려할 필요가 있다.
유튜브에서 어떤 이용자가 다음에 볼 영상을 예측하는 모델의 경우를 생각해보자. 아래는 모두 적절하지 않은 목표에 대한 예시이다.
클릭율을 최대한 높이기
- 이용자가 어떤 것을 클릭할 순 있어도 그렇게 오래 머물러있지 않는 경우가 발생할 수 있다.
- 이는 낚시 기사 내지는 어그로성 게시물들에 최적화될 것이며, 그렇기 때문에 다른 목표가 필요하다.
시청 시간 최대한 늘리기
- 이용자가 오래동안 시청을 하더라도, 금방 세션에서 벗어날 수 있다.
- 예를 들어, 마인크래프트의 0.1% 시청자만이 3시간 동안 영상을 보고, 8%는 5분 동안 영상을 본다. 그 외의 나머지는 시청을 완전히 중단한다. 시스템은 시청 시간을 최대화할 것이므로, 다음 시청 목록은 긴 마인크래프트 비디오로만 구성될 것이다.
- 단순히 영상을 오래 보는 것 보다, 여러 짧은 동영상들을 많이 보게끔 하는 것이 더 중요하며, 전반적인 세션을 길게 유지하는 것 역시 중요하다.
세션 시청 시간을 최대한 늘리기
- 앞선 예시처럼, 여전히 긴 영상을 선호하게 되는데, 이는 여전히 문제가 된다.
- 이 경우, 특정 관심사에 대해서만 편향되기 매우 쉽다.
- 예를 들어, 한 이용자가 르브론 제임스의 덩크 영상을 봤따면, 시스템은 해당 이용자에게 르브론 제임스의 모든 덩크 영상을 보여줄 것이다.
- 영상 추천 시스템은 이를 정말 잘 수행하겠지만, 이용자 경험은 그다지 좋지 않을 것이다.
- 이 경우 많은 이들이 유튜브를 단순히 특정 영상만 가득한 곳으로 판단할 것이며, 다양성이 사라지게 된다.
다양성 증가 & 세션 시청 시간을 최대한 늘리기
- 해당 목표에서는 어떤 문제가 발생할까?
- 여기서는 굿하트의 법칙을 떠올리자. "측정이 곧 목표가 된다면, 올바른 측정은 불가능하다."
휴리스틱
어떻게 하면 ML 없이도 문제를 해결할 수 있을까?
어떤 서비스를 당장 내일 제공해야 한다고 가정하자. 그렇다면 ML 보다는 당장에 비즈니스 로직을 하드코딩할 시간 정도밖에 없을 것이다. 이 때는 아래 예시처럼 ML 없이 휴리스틱을 시도하고자 할 수 있다.
Example |
---|
과거에 인기 있었던 영상들을 올린 사람이 새로 업로드하는 영상들 역시 인기있을 것이라고 가정한다. |
이러한 예시는 세상에서 제일 완벽한 휴리스틱은 아니겠지만, 토대를 마련하기에는 충분할 것이다. 굳이 팬시한 ML 모델에 집착할 필요 없습니다. 굳이 휴리스틱을 이기지 못하는 ML 모델을 서비스로 제공할 필요가 없습니다. 휴리스틱을 만드는 연습은 ML 모델에서 좋은 신호를 판단하는 데에 도움이 됩니다.
ML이 아닌 해답은 대부분 ML을 통한 해답보다 유지보수가 더 쉽습니다.
Formulate Your Problem as an ML Problem
해당 섹션에서는 ML 문제를 구성하기 위한 적절한 접근에 대한 가이드를 제공합니다.
- 문제를 분명히 하라.
- 간단하게 시작하라.
- 데이터 소스를 정의하라.
- 모델에 사용할 데이터를 디자인하라.
- 어디서 데이터가 올지 결정하라.
- 쉽게 얻을 수 있는 입력을 결정하라.
- 학습 능력
- 잠재적인 편향(bias)에 대해 생각하라.
문제를 분명히 하라.
분류와 회귀에도 여러 세부유형들이 존재합니다. 아래 플로우차트를 따라 정확히 어떤 유형의 문제인지를 정의하세요.
우리 문제는 다음과 같이 잘 구성됩니다.
- Binary classification
- Unidimensional regression
- Multi-class single-label classification
- Multi-class multi-label classification
- Multidimensional regression
- Clustering (unsupervised)
- Other (translation, parsing, bounding box id, etc.)
문제를 정확히 한 이후에, 모델이 예측할 내용이 정확히 무엇인지 설명하세요.
이러한 각 요소들을 합쳐서 정리해보면, 문제에 대한 간결한 설명이 됩니다.
Example |
---|
어떤 영상에 대해 다음 3개의 클래스 중 어떤 것에 해당할지 예측하는 단일 레이블 분류 문제를 정의해볼 수 있다.—{very popular, — 업로드 후 28일이 된 시점에 대한 예측이다.
|
간단하게 시작하라.
먼저, 모델링 작업을 간단하게 만들어봅시다. 주어진 문제에 대해 이진 분류 또는 단일 회귀 문제(아니면 둘다)로 정의해보세요. 두 문제들은 도움이 되는 많은 도구와 전문가들의 지원이 있는, 제일 간단한 접근 방식입니다.
그 다음, 작업을 위해서 최대한 간단한 모델을 사용하세요. 간단한 모델은 실행하기도, 이해하기도 쉽습니다. 일단 전체 ML 파이프라인을 갖추고 나면, 간단한 모델에서부터 더 쉽게 발전시켜 나갈 수 있습니다.
Examples |
---|
업로드된 영상이 유명해질 것 같은지 아닌지를 예측한다. (Binary Classification) |
업로드된 영상의 인기도를 28일 이내에 받을 조회수를 기준으로 에측한다. (Regression). |
실제로 그것을 서비스에 곧장 이용하지 않더라도, 간단한 모델로부터 출발하는 것은 좋은 베이스라인이 됩니다. 사실, 간단한 모델은 오히려 생각보다 더 잘 동작할 겁니다. 단순한 모델은 모델이 제대로 정의되었는지 판단하기 훨씬 쉽습니다. 반면, 복잡한 모델은 훈련시키기도, 이해하기도 훨씬 더 어렵고 느립니다. 그러니 성능을 충분히 향상시킬 정도로 트레이드오프를 갖추지 않는 이상은 이를 단순하게 유지하는 편이 더 좋습니다.
주의: 대부분의 ML은 데이터 쪽에 있습니다. 복잡한 모델에 대한 전체 파이프라인을 실행하는 것은 모델 자체를 반복하는 것보다 어렵습니다.
ML 도입 시에 가장 이득이 큰 시점은 데이터를 처음으로 활용하여 모델을 구축해냈을 때입니다. 추가적인 조정은 여전히 성능의 향상을 일으키지만, 일반적으로 가장 큰 이득을 얻는 시점은 시작점에 있으므로 프로세스를 더 쉽게 만들기 위해 잘 테스트된 방법을 고르는 것이 좋습니다.
데이터 소스를 정의하라.
우리의 데이터 레이블에 대해 다음의 질문에 답해봅시다.
- 레이블링된 데이터를 얼마나 많이 갖고 있나?
- 레이블의 출처가 어디인가?
- 해당 레이블은 내리고자 하는 결정과 긴밀하게 연관되어 있는가?
Example |
---|
우리의 데이터 셋은 인기도 데이터 및 영상 설명과 함께 과거에 업로드 된 영상에 대한 100,000 개의 예제로 구성되어 있다. |
모델에 사용할 데이터를 디자인하라.
ML 시스템이 의사 결정을 내리기 위해 사용할 데이터를 정의해야 합니다. (input => output)
Title | Channel | Upload Time | Uploader's Recent Videos | Output (label) |
---|---|---|---|---|
My silly cat | Alice | 2018-03-21 08:00 | Another cat video, yet another cat | Very popular |
A snake video | Bob | 2018-04-03 12:00 | None | Not popular |
각각의 행(row)은 하나의 예측이 수행될 하나의 데이터가 됩니다. 예측이 이루어지는 순간에 사용할 수 있는 정보만을 포함합니다. 각 input은 스칼라 또는 정수/실수/바이트/문자열 등으로 구성된 1차원 리스트가 될 수 있습니다.
만약, input이 1차원 리스트가 아닌 경우, 해당 데이터를 가장 잘 나타낼 방법에 대해 고려해야 합니다. 예를 들자면 :
- 한 input이 사실 상 서로 다른 둘 이상의 항목을 나타내는 경우, 이를 별도의 input으로 분할할 수 있습니다.
- 한 input이 중첩된 프로토콜 버퍼를 나타내는 경우, 이들의 각 field를 쪼갤 수 있습니다.
- 예외 : 오디오, 이미지, 영상 데이터.. (blob of bytes)
Tips for audio/image/video data | Examples |
---|---|
There may not be explicit inputs. | The only inputs may be the bytes for the audio/image/video. |
There may be metadata accompanying the image. | Compression format, object bounding boxes, source |
Your outputs may be simplified for an initial implementation. | Rather than doing bounding-box object detection, you may create a simple binary classifier that learns whether one type of object is present in the image or not. |
어디서 데이터가 올지 결정하라.
하나의 행(row)를 구성하기 위해 각 열(column)에 들어갈 값을 만들어줄 데이터 파이프라인을 개발하는 데 얼마나 많은 작업이 필요할지 평가해보세요. 예제 출력을 얻기 어려운 경우, 출력을 다시 고려하여 모델에 다른 출력을 사용할 수 있는지의 여부를 조사해볼 수 있습니다.
모든 입력이 예측 시에 정확히 작성한 형태로 사용할 수 있는지 확인하세요. 예측 시점에 특정한 feature 값을 얻는 것이 어렵다고 판단된다면 해당 특성을 생략해야 합니다.
Example |
---|
We applied the labels {very popular, to each video that fell within a determined range of
views and "thumbs ups" and determined keyword descriptions for each video.
Hand-generating descriptions is not sustainable, so we are considering
adding a keyword description to the upload form. |
쉽게 얻을 수 있는 입력을 결정하라.
얻기 쉽고, 합리적인 초기 결과를 얻을 수 있을 거라고 판단되는 1-3개의 입력을 선택하세요.
이전에 언급했던 휴리스틱을 구현하는 데 어떤 입력이 유용할까요?
input들을 준비하기 위해 데이터 파이프라인을 개발하는데 드는 엔지니어링 비용과, 모델에 각 input들을 가짐에 따라 예상되는 이득을 고려하세요. 간단한 파이프라인으로 단일 시스템에서 손쉽게 얻을 수 있는 입력에 중점을 두세요. 가능한 최소의 인프라로 시작하세요.
학습 능력
우리 ML 모델이 과연 학습할 수 있을까요? 학습에 어려움을 줄 수 있을 것 같은 문제들을 나열해보세요. 예를 들어:
- 데이터 셋에 충분한 양의 레이블이 포함되어 있지 않을 수 있습니다.
- 훈련 데이터에 충분한 예제가 없을 수 있습니다.
- 레이블에 노이즈가 너무 많이 끼어있을 수 있습니다.
- 시스템이 훈련 데이터들을 기억하는 바람에, 새로운 케이스에 일반화시키는 것에 어려움을 겪을 수 있습니다.
Example |
---|
The measure "popular" is subjective based on the audience and inconsistent across video genres. Tastes change over time, so today's "popular" video might be tomorrow's "not popular" video. |
잠재적인 편향에 대해 생각하세요.
많은 데이터 셋들은 어떤 방식으로는 편향(bias)를 갖습니다. 이러한 편향은 훈련 및 예측에 부정적인 영향을 끼칠 수 있습니다. 예를 들어:
- 편향된 데이터 소스가 여러 문맥 간에 적절하게 해석되지 않을 수 있습니다.
- 훈련 데이터 셋이 모델의 최종 이용자를 대표하지 못할 수 있으며, 이에 따라 부정적인 이용자 경험을 유발할 수 있습니다.
Example |
---|
Since the measure "popular" is subjective, it is possible that the model will serve popular videos that reinforce unfair or biased societal views. |
Crash Course
ML 크래쉬 코스
Framing
지도 학습(Supervised Learning)이란 무엇일까요? 결론적으로는, 아래를 의미합니다.
- 이전에 보지 못한 데이터에 대해 유용한 예측을 생성하는 방법을 배우는 ML 시스템
Labels (레이블)
레이블은 우리가 예측하려는 것입니다. 단순 선형 회귀에서 y
변수에 해당하는 것이죠. 레이블에는 다음과 같은 것들이 될 수 있습니다.
- 쌀의 추후 가격 예측
- 사진에 찍힌 동물의 종류
- 오디오 클립의 의미
Features (피쳐)
Feature는 입력 변수입니다. 단순 선형 회귀에서 x
변수에 해당하는 것입니다.
가장 단순한 형태의 ML 프로젝트는 단 하나의 피쳐만 사용함으로써 이루어집니다.
반면, 엄청 섬세한 형태의 ML 프로젝트는 수십만개의 피쳐가 존재할수도 있죠.
스팸 메일 분류기를 예시로 들자면, 피쳐는 아래와 같은 것들이 될 수 있습니다.
- 이메일 텍스트에 들어간 단어들
- 발신자 주소
- 이메일이 보내진 시각
- "one weird trick" 이라는 문구가 포함되었는지의 여부
Examples (예시)
Example은 데이터의 구체적인 한 예시입니다. x로 표현되기도 합니다. (굵은 글씨로 되었다는 점을 유의하세요.) 이러한 Example은 또 두개로 분류될 수 있습니다.
- Labeled examples
- Unlabeled examples
레이블 처리된 예시의 경우 Feature들과 Label을 모두 갖습니다.
labeled examples: {features, label}: (x, y)
이러한 레이블 처리된 예시들은 모델을 Train(훈련) 시키기 위해 사용됩니다. 우리의 스팸 분류기 예시에서, "스팸인지 아닌지"에 대해 명시되어있는 레이블 처리된 예시들이 곧 레이블 처리된 예시들이 됩니다.
예를 들어, 아래 5줄의 예시로 구성된 테이블은 켈리포니아의 집값 정보를 담고있는 데이터셋입니다.
housingMedianAge (feature) |
totalRooms (feature) |
totalBedrooms (feature) |
medianHouseValue (label) |
---|---|---|---|
15 | 5612 | 1283 | 66900 |
19 | 7650 | 1901 | 80100 |
17 | 720 | 174 | 85700 |
14 | 1501 | 337 | 73400 |
20 | 1454 | 326 | 65500 |
반면 레이블이 없는 예시의 경우 Feature는 갖고 있지만 Label은 없습니다.
unlabeled examples: {features, ?}: (x, ?)
아래는 위와 동일한 집값 데이터이지만, Label에 해당하는 medianHouseValue
가 존재하지 않습니다.
housingMedianAge (feature) |
totalRooms (feature) |
totalBedrooms (feature) |
---|---|---|
42 | 1686 | 361 |
34 | 1226 | 180 |
33 | 1077 | 271 |
일단 우리가 레이블 처리된 모델을 훈련시키고 나면, 레이블이 없는 예시들에 대해서도 예측을 수행할 수 있습니다. 스팸 분류기에서 레이블이 없는 데이터는 사람들이 아직 레이블을 처리하지 않은 새로운 이메일이 되겠죠.
Models
모델은 Feature와 Label 간의 관계를 의미합니다. 예를 들어, 스팸 분류기 모델은 일부 Feature들은 스팸과 강하게 연관이 있다고 판단할 것입니다. 모델이 사용되는 두 단계에 대해서 살펴봅시다.
- Training(훈련)은 모델을 만들고, 가르치는 것을 의미합니다. 다시 말해, 모델에게 레이블처리된 예시를 보여주고, 점차 모델에게 Feature와 Label 간의 관계를 가르치는 것입니다.
- Inference(추론)는 훈련이 완료된 모델을 레이블이 되어있지 않은 예시에 적용함을 의미합니다. 다시 말해, 유용한 예측(
y'
)을 수행할 수 있는 모델을 사용하는 것이죠. 앞선 경우를 예로 들자면, 레이블이 없는 예시에 대해 집값에 해당하는 Label인medianHouseValue
를 예측하는 것을 의미합니다.
Regression vs. Classification
Regression(회귀)는 모델이 연속성을 띄는 값을 예측하는 것을 의미합니다. 예를 들어, 회귀 모델의 경우 아래와 같은 문제들에 대한 답을 예측합니다.
- 켈리포니아의 집값은 얼마일까?
- 이 광고를 이용자가 클릭할 가능성은 얼마나 될까?
Classification(분류)는 모델이 분리된 값에 대한 예측을 하는 것을 의미합니다. 예를 들어, 분류 모델은 아래와 같은 문제들에 대한 답을 예측합니다.
- 수신한 이메일이 스팸일까 아닐까?
- 사진의 동물은 개일까 고양이일까?
Descending into ML
Linear Regression
귀뚜라미가 추운 날보다 더운 날에 더 자주 운다는 것은 익히 알려진 사실입니다. 지난 몇년간, 전문가들과 과학자들은 온도와 귀뚜라미의 울음 주기에 대한 데이터를 수집해왔습니다. 해당 데이터를 통해 둘 간의 상관 관계를 파악해봅시다.
먼저, 플롯을 통해 데이터에 대해 시각적으로 살펴봅시다.
그림 1. 분당 울음횟수 vs. 섭씨 온도
예상한 대로, 온도가 올라갈 수록 귀뚜라미의 울음 주기도 많아집니다. 이것이 둘 사이의 상관관계 일까요? 맞습니다. 여기에 하나의 선을 그어 보면 그 관계를 더 뚜렷하게 알 수 있습니다.
그림 2. 선형 관계
네, 맞습니다. 선이 모든 점들을 꿰뚫지는 않고 있죠. 하지만 위에 그은 선은 두 변수 간의 상관관계를 뚜렷하게 보여줍니다. 이걸 방정식으로 나타내본다면, 아래와 같은 형태겠죠.
- 는 섭씨 온도입니다. -> 예측하고자 하는 값이죠.
- 는 선의 기울기입니다.
- 는 귀뚜라미의 분당 울음 횟수입니다. -> 입력 Feature로 주어지는 값이죠.
- 는 y-절편(intercept)입니다.
이걸 머신러닝 컨벤션에 따라 작성한다면 약간 다르게 아래와 같은 방정식이 됩니다.
- 은 예측된 레이블입니다. (예상되는 결과)
- 는 bias(편향)입니다. 종종 로 불리기도 합니다.
- 는 Feature1의 weight(가중치)입니다.
- 은 Feature(피쳐)입니다. (알려진 입력)
새로운 분당 귀뚜라미 울음 횟수값인 에 대한 온도 예측 을 알기 위해서는 단순히 값을 위 모델에 집어넣기만 하면 됩니다.
반면, 우리가 지금껏 살펴본 모델은 하나의 Feature만을 갖는 매우 단순한 모델입니다. 실제로 더 복잡한 모델은 더 많은 Feature들에 의존합니다. (, , ...) 예를 들어, Feature가 3개로 늘어난다면 아래와 같은 모델이 만들어지게 되는거죠.
Training and Loss
모델을 훈련시키는 것은 레이블 처리된 예시들로부터 적절한 가중치(Weight)와 편향(Bias)를 배우게 하는 것을 의미합니다. 지도 학습에서, 머신러닝 알고리즘은 수많은 예시들을 살펴보고 loss(손실)을 줄여나가면서 모델을 완성하게 됩니다. 이러한 과정을 Empirical Risk Minimization(경험적 위험 최소화)라고 합니다.
loss는 좋지 않은 예측을 한 경우에 대한 페널티와 같은 개념입니다. 즉, loss는 하나의 예시에 대한 모델의 예측이 얼마나 별로였나?를 나타내는 값이죠. 만약 모델의 예측이 완벽했다면, loss는 0이 됩니다. 모델을 훈련시키는 것의 목적은 모든 예시에서 평균적으로 loss를 갖도록 하는 여러 가중치들과 편향을 찾아나가는 것입니다. 예를 들어, 아래의 그림3은 좌측에 loss가 큰 모델과 우측의 loss가 적은 모델을 각각 그래프로 나타내고 있습니다.
- 화살표선은 loss를 나타냅니다.
- 파란색 선은 prediction을 의미합니다.

그림3. loss가 큰 왼쪽 모델; loss가 작은 오른쪽 모델.
왼쪽의 각 화살표선들이 오른쪽의 각 화살표선보다 훨씬 길다는 것을 유의하세요. 이런 경우, 우측 모델이 명백히 더 나은 모델이 됩니다.
이쯤 되면 수학적으로 어떻게 loss를 계산하는지에 대한 Loss Function(손실 함수)가 궁금할 겁니다.
Squared Loss: 가장 인기있는 손실 함수
우리가 살펴볼 손실 함수는 loss 라고도 알려진 Squared Loss입니다. 이건 식으로 나타내자면 아래와 같습니다. 는 실제 Label의 값을, 은 에 대한 예측값 을 의미합니다.
평균제곱오차(Mean Square Error: MSE)는 전체 데이터셋의 각각에 대한 Squared Loss의 평균입니다. MSE를 계산하려면, 각각의 예시에 대한 Squared Loss를 전부 더하고, 예시의 갯수만큼 나누어주면 되죠.
- 는 아래를 의미합니다.
- 는 모델의 예측에 사용될 Feature들의 집합입니다. (ex. 분당 귀뚜라미 울음 횟수, 나이, 성별..)
- 는 예시의 Label입니다. (ex. 온도)
- 는 여러 개의 Feature 와 가중치(), 편향()의 조합으로 이루어진 함수입니다.
- 는 레이블 처리 된 많은 예시들을 담고 있는 데이터셋입니다. 이는 의 쌍으로도 나타낼 수 있습니다.
- 은 에 속하는 데이터 예시들의 갯수입니다.
MSE는 머신러닝에서 일반적으로 사용되기는 하지만, 모든 상황에서 유일하게 사용되는 최상의 손실 함수는 아닙니다.
Reducing Loss
An Iterative Approach
이전 챕터에서는 loss에 대한 개념을 살펴봤습니다. 그렇다면 이번에는 어떻게 ML 모델이 loss를 반복적으로 줄여나갈 수 있는지에 대해 배워봅시다.
반복 학습(Iterative learning)은 흔히 하는 업앤다운 게임과 유사합니다. 여기서 정답이 되는 숫자를 찾는게 곧 최고의 모델이 되는거죠. 우리가 임의로 가중치()값을 추측하면 모델 시스템이 이에 따른 loss값이 얼마인지 알려줄겁니다. 그러면 이제 또다른 가중치()값을 추측하고, 또 이에 따른 loss값을 전달받죠. 이런식으로 진행해나가면서 가장 최적의 가중치를 찾는 것이 곧 최선의 모델을 찾는 방법입니다.
그림 1. 모델 학습에 대한 반복적 접근(Iterative approach) 방식
우리는 이번 ML코스 내내 이러한 접근 방식을 사용할 겁니다. 반복 전략은 대규모 데이터셋에 적합하도록 확장되기 때문에 머신러닝에서 널리 사용됩니다.
모델은 하나 이상의 Feature를 입력으로 받아, 하나의 예측()을 결과로 반환합니다. 간단히 말해, 하나의 Feature를 받아 하나의 Prediction을 만들어내는 모델은 아래와 같은 형태죠.
위의 식에서 와 에는 무엇이 들어가야 할까요? 선형 회귀 문제에 있어서 사실 이는 그다지 중요하지 않은 것으로 나타났습니다. 임의의 값을 선택해도 되지만, 아래와 같은 값을 사용해보겠습니다.
처음으로 Feature에 대한 가중치 을 10이라고 해봅시다. 그러면 아래와 같은 예측이 나옵니다.
위 다이어그램에서 "loss를 계산한다"는 것은 곧 해당 모델이 사용할 손실함수를 통한 계산값입니다. 우리가 만약 Squared loss 함수(제곱 손실함수)를 사용하기로 정했다면, 아래와 같은 두가지 입력을 손실함수가 요구할겁니다.
- : Feature x에 대한 모델의 예측
- : Feature x에 대한 실제 label
마지막으로 위 다이어그램에서 "파라미터 갱신값을 계산한다"는 부분을 살펴봅시다. 이 부분에서 ML 시스템이 손실 함수의 값을 검사하고, 와 에 대한 새로운 값을 생성해냅니다. 지금은 이 신비한 상자가 새로운 값을 고안한 다음 ML 시스템이 모든 레이블에 대한 모든 Feature들을 재평가하여 손실 함수에 대한 새로운 값을 산출해내고, 새로운 매개변수 값을 산출해낸다는 것만 이해하세요. 그리고 학습은 알고리즘이 가능한 최소의 loss값을 갖게끔 하는 모델에 대한 파라미터를 발견해낼 때까지 계속됩니다. 일반적으로, 이는 전체 loss가 변경되지 않거나, 극적으로 느리게 변경될 때까지 반복됩니다. 여기에 도달한 경우, 우리는 모델이 수렴했다(converged)고 말합니다.
요약! : 머신러닝 모델은 임의의 가중치()와 편향()으로부터 시작하여 최대한 낮은 loss를 갖게될 때까지 반복적으로 가중치와 편향을 조정해나가며 학습합니다.
Gradient Descent
위쪽에서 ML 시스템이 loss를 줄이기 위해 가중치와 편향을 조정해나간다는 것은 이해했지만, 구체적으로 그것이 어떻게 이루어지는지에 대해서 아직 우리는 다루지 않았습니다.
가능한 모든 값에 대한 손실을 계산할 시간과 컴퓨팅 자원이 충분하다고 가정합시다. 우리가 다루는 회귀 문제의 경우 loss와 에 대해 그림을 그려보면 이는 항상 볼록합니다. 다시 말해, 이는 항상 그릇과 같은 모양이 되죠.
그림 2. 회귀 문제에 대한 loss vs. 가중치 플롯
이러한 경우에는 항상 유일한 최솟값을 갖게됩닌다. 즉, 어디 한 군데는 정확히 기울기가 0인 지점이 존재하죠. 그리고 그 최솟값이 되는 지점이 손실 함수가 수렴하는 부분이 됩니다.
전체 데이터셋에 대해 생각할 수 있는 모든 값에 대해 손실함수를 계산하는 것은 수렴하는 지점을 찾는 비효율적인 방법입니다. 그것보다는, 머신러닝에서 매우 널리 사용되는 경사하강법(Gradient Descent)이라는 매커니즘을 배워봅시다.
경사하강법은 처음에 에 대한 시작점에서부터 출발합니다. 시작점은 사실 중요하지 않습니다. 그렇기 때문에 많은 알고리즘은 이를 0 또는 랜덤한 숫자로부터 시작합니다. 아래 플롯에서는 0보다 약간 더 큰 값으로부터 출발해보겠습니다.
그림 3. 경사하강법에 대한 시작점
경사하강법 알고리즘은 시작점에 대한 loss 곡선의 경사를 계산합니다. 그림 3에서, loss의 경사는 곧 곡선의 기울기를 의미하고, 이 기울기가 값을 얼마나 조정해야하는지에 대한 정보가 됩니다. 만약 가중치가 여러 개가 된다면, 기울기는 가중치에 대한 도함수의 벡터가 됩니다.
경사가 하나의 벡터를 이룬다는 점을 기억하세요. 이는 곧 다음의 특징을 갖습니다.
- 방향(direction)
- 크기(magnitude)
경사는 항상 손실 함수 상에서 가장 가파른 증가 방향을 가리킵니다. 경사하강 알고리즘은 loss를 최대한 빨리 줄이기 위해 음의 기울기 방향으로 단계를 진행해나갑니다.
그림 4. 경사 하강은 음의 기울기에 의존합니다.
손실 함수 곡선을 따라 다음 지점을 결정하기 위해, 경사 하강 알고리즘은 아래 그림과 같이 시작점에서 기울기 크기의 일부만큼 더합니다.
그림 5. 기울기 step은 손실 곡선의 다음 지점으로 이동합니다.
경사 하강법은 이러한 과정을 반복해 나가면서 최소값에 점점 더 가까워지는 과정입니다.
노트: 경사하강법을 수행할 때는, 위의 과정을 일반화하여 모든 모델의 파라미터를 동시에 조정합니다. 예를 들어, 과 편향 의 최적값을 찾기 위해 과 모두에 대한 기울기를 계산하죠. 그 다음 각각의 기울기에 따라 와 의 값을 수정합니다. 그 다음 최소 손실에 도달할 때까지 이 단계를 반복해나갑니다.
Learning Rate
앞서 살핀대로, 경사 벡터는 방향과 크기를 모두 갖습니다. 경사 하강 알고리즘은 기울기에 학습률(Learning rate)라는 스칼라를 곱해서 다음 지점을 결정합니다. 예를 들어, 경사의 크기가 2.5고, 학습률이 0.01이라면, 경사 하강 알고리즘은 이전 지점에서 0.025만큼 떨어진 다음 지점으로 진행합니다.
하이퍼 파라미터(Hyperparameter)는 프로그래머가 직접 머신러닝 알고리즘 상에서 조정하는 값입니다. 대부분의 머신러닝 프로그래머는 이 학습률을 조정하는데에 대부분의 시간을 할애합니다.
만약 너무 작은 학습률을 선택하게 되면 시간이 너무 오래 걸리죠.
그림 6. 학습률이 너무 작은 경우
반대로, 학습률을 너무 크게 정했을 경우, 다음 지점은 저만치 멀리 가버리기 때문에 수렴하기가 어렵습니다.
그림 7. 학습률이 너무 큰 경우
모든 회귀 문제에는 골디락스(Goldilocks) 학습률이 존재합니다. 골디락스 값은 손실 함수가 얼마나 평평한지와 관련됩니다. 만약 손실 함수의 기울기가 작다는 것을 알고 있다면, 더 큰 학습률을 안전하게 시도하여 더 빠른 시간 내에 최소값에 도달할 수 있게 됩니다.
그림 8. 학습률이 적절한 경우
학습률에 대해 : 1차원에서 이상적인 학습률은 (x에서 f(x)의 2차 도함수의 역)입니다. 2차원 이상에 대한 이상적인 학습률은 Hessian(2차 편도함수의 행렬)의 역입니다. 일반적인 볼록 함수에 대해서는 훨씬 더 복잡합니다.
Stochastic Gradient Descent (확률적 경사하강법)
경사하강법에서, 배치(Batch)란 한 번의 반복에서 경사를 계산하기 위해 사용하는 예시(example)들의 갯수입니다. 지금껏 우리는 이 배치가 전체 데이터셋이 되는 것 처럼 이야기했지만, 구글의 경우 이 "전체 데이터셋"은 말도 안되게 큰 수치입니다. 게다가 구글 데이터는 Feature의 개수도 엄청나게 많죠. 결국, 한 배치가 너무 과대할 수 있습니다. 배치가 너무 크다면 한번의 반복이라고 해도 매우 오랜 시간이 걸릴 수 있습니다.
예시들 사이에서 랜덤하게 샘플링된 대규모 데이터셋에는 중복 데이터가 포함될 수 있습니다. 실제로 배치 크기가 그다면 중복 가능성이 커집니다. 일부 중복성은 노이즈가 심한 경사를 좀 더 부드럽게 만드는데 유용하지만, 너무 과대한 배치에서는 그다지 유용하지 않습니다.
훨씬 적은 계산을 통해 평균적으로 올바른 경사를 얻을 수 있다면 어떨까요? 데이터셋에서 무작위로 예시를 고르는 것은 훨씬 작은 데이터셋으로부터 큰 평균을 예측할 수 있도록 합니다. 확률적 경사하강법(SGD)는 이 아이디어를 극단적으로 활용합니다. 한 반복 당 하나의 예시만 사용하는 것이죠.(배치 크기가 1) 충분한 반복이 주어진다면야 SGD는 올바르게 작동하지만, 매우 노이즈가 많다는 문제가 있습니다. "확률적"이라는 말은 각 배치를 구성하는 하나의 예시가 무작위로 선택됨을 나타내죠.
미니배치 확률적 경사 하강법(mini-batch SGD)는 전체 데이터셋에 대한 반복과 SGD 간의 절충안입니다. 미니 배치는 일반적으로 10개에서 1000개 사이의 예시를 가지며, 이는 무작위로 선택됩니다. 미니 배치 SGD는 SGD의 노이즈 양을 줄이지만, 여전히 전체 배치에 대한 반복보다는 효율적으로 동작합니다.
설명을 쉽게 하기 위해서, 우리는 단일 Feature에 대한 경사 하강법만을 언급했습니다. 경사 하강법은 여러 Feature들을 갖는 경우에도 제대로 동작하니 안심하셔도 됩니다.
IAM
- IAM = Identity and Access Management
- 글로벌 서비스에 해당함. (별도로 지역이 정의되어 있지 않다.)
- 기본적으로는 계정 생성 시에 **루트 계정(Root account)**이 생성된다.
- 루트 계정은 AWS 리소스에 대한 모든 권한을 갖는다.
- 하지만, 모든 권한을 갖는 만큼, 위험하기 때문에 루트 계정은 당장에 계정 세팅이 필요한 경우에만 사용하고, 그 이후에는 이용하거나 공유하지 말아야 한다.
- 루트 계정을 사용하는 대신, **유저(User)**를 만들어 주어야 하는데, 이는 한 조직 내 하나의 이용자를 가리키며, 그룹에 속할 수 있다.
- **그룹(Group)**은 오직 유저만 포함할 수 있으며, 다른 그룹은 포함할 수 없다.
- 유저가 꼭 하나의 그룹에 속해야 할 필요는 없으며, 동시에 여러 개의 그룹에 속하는 것도 가능하다.
// example
Group: Developers
- Alice
- Bob
- Charles
Group: Operations
- David
- Edward
Group: Audit Team
- Charles
- David
User: (아무 그룹에도 속하지 않음)
- Fred
권한 (Permissions)
- 유저와 그룹은 **정책(Policy)**이라고 불리는 JSON 문서로 할당될 수 있다.
- **정책(Policy)**은 곧 유저들이 갖춘 권한을 의미한다.
- AWS에서는 Least Privilege Principle (최소 권한의 원칙)을 적용하는데, 이는 곧 "이용자가 필요한 것 이상으로 권한을 부여하지 않음"을 의미한다.
정책 (Policy) 구조
// EC2 정책에 대한 예시
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:AttachVolume",
"ec2:DetachVolume"
],
"Resource": [
"arn:aws:ec2:*:*:volume/*",
"arn:aws:ec2:*:*:instance/*"
],
"Condition": {
"ArnEquals": {"ec2:SourceInstanceARN": "arn:aws:ec2:*:*:instance/instance-id"}
}
}
]
}
- Version : 정책 언어의 버전, 거의 대부분
2012-10-17
이 된다. - Id : 정책에 대한 구분 명칭 (선택)
- Statement : 하나 이상의 독립적인 Statement (필수)
- Sid : Statement에 대한 구분 명칭 (선택)
- Effect : 특정 api 접근에 대한 허용/거부에 대한 여부 (Allow/Deny)
- Principal : 해당 정책이 적용될 계정(account)/유저(user)/역할(role)
- Action : 해당 정책이 허용하거나 거절할 액션 목록
- Resource : 액션이 적용될 리소스의 목록
- Condition : 해당 정책이 적용되는 조건 (선택)
Password Policy (패스워드 정책)
- 강력한 패스워드 = 계정에 대해 더 강력한 보안
- AWS에서는 패스워드 정책을 갖추도록 할 수 있다.
- 패스워드 최소 길이
- 특정 타입의 문자 요구 ~ 대/소문자, 숫자, 특수문자 등...
- IAM 유저가 패스워드를 본인 임의로 수정할 수 있도록 허용
- 유저가 특정 기간 이후 패스워드를 변경하도록 요구 (password expiration)
- 동일한 패스워드를 재사용할 수 없도록 함
MFA ~ Multi Factor Authentication
- MFA = 알고 있는 패스워드 + 보유한 특정 보안 기기
- 패스워드와 MFA 토큰을 조합하여 로그인하도록 함
- 패스워드를 도용당하더라도, 계정은 보호할 수 있음
- 이용 가능한 MFA 디바이스 옵션
- Virtual MFA devices ~ Google Authenticator, Authy, 등...
- 하나의 디바이스에 여러 토큰을 사용
- Universal 2nd Factor (U2F) Security Key ~ YubiKey
- 하나의 보안 키로 여러 루트 계정과 IAM 이용자들을 지원
- Hardware Key Fob MFA Device
- Hardware Key Fob MFA Device for AWS GovCloud
- Virtual MFA devices ~ Google Authenticator, Authy, 등...
AWS에 접근하는 여러가지 방법
- AWS Management Console ~ 패스워드 및 MFA로 보호
- AWS CLI (Command Line Interface) - Access Key로 보호
- AWS SDK (Software Developer Kit) - Access Key로 보호
Access Key
- 액세스 키는 AWS Console을 통해 생성
- 유저는 각자 본인의 Access key를 관리하게 됨
- Access Key는 패스워드와 마찬가지로, 비밀리에 관리되어야 하며, 공유해선 안된다.
- Access Key ID ~= username
- Secret Access Key ~= password
AWS CLI ?
- AWS 서비스를 커맨드라인 셸을 통해 상호작용할 수 있게끔 하는 툴
- AWS 서비스의 퍼블릭 api에 직접 접근할 수 있으며, 리소스들을 관리하는 별도의 스크립트를 작성할 수도 있다.
- 오픈 소스이며, AWS 관리 콘솔 대신 사용할 수도 있다.
AWS SDK ?
- 특정 언어로 사용할 수 있는 API
- AWS 서비스를 프로그래밍적인 방식으로 접근 및 관리할 수 있음
- 애플리케이션 자체에 포함될 수 있다.
- 지원
- SDKs, Mobile SDKs, IoT Device SDKs...
- Ex.) AWS CLI 자체가 AWS SDK for Python으로 작성된 것이다.
AWS CloudShell
- AWS CLI가 탑재된 브라우저 기반의 셸
- 아직은 지역에 따라 지원 여부가 다름 (서울은 안 됨)
IAM Roles for Services
- 일부 AWS 서비스는 특정한 동작을 수행할 필요가 있다.
- 이를 위해서, 이러한 AWS 서비스들이 이에 대한 권한을 가져야 하는데, 이것을 IAM Roles를 통해서 부여할 수 있다.
- 일반적인 Roles:
- EC2 Instance Roles
- Lambda Function Roles
- Roles for CloudFormation
IAM Security Tools
- IAM Credentials Report (account-level)
- 루트 계정에 속한 모든 유저와 그들의 증명에 대한 다양한 상태에 대한 리포트
- IAM Access Advisor (user-level)
- 한 유저에게 부여된 서비스 권한 및 그 유저가 접근한 서비스에 대해 보여줌
- 정책을 개정하는 데 있어 해당 정보를 활용할 수 있음 (권한을 늘리거나 줄임)
IAM Guidelines & Best Practices
- AWS 계정 설정을 위한 경우를 제외하고는 루트 계정을 사용하지 말 것
- 한 명의 실제 이용자 = 하나의 AWS User
- 여러 유저를 Group에 할당하고, 해당 그룹에 Permission을 부여함
- 강력한 패스워드 정책을 사용
- MFA를 사용
- AWS 서비스에 권한을 부여하기 위해 Role을 사용
- 프로그래밍적인 방식의 접근을 위해서는 Access Key를 사용 (CLI / SDK)
- IAM Credentials Report를 사용해 계정 권한을 점검
- IAM 유저 및 Access Key는 절대 공유되어선 안됨
Summary
- Users: AWS 콘솔에 접근할 수 있는 이용자, password 부여
- Groups: 여러 User들이 포함
- Policies: 유저 및 그룹에 대한 권한에 대해 설명하는 JSON 문서
- Roles: EC2와 같은 AWS 서비스에 부여하는 권한
- Security: MFA + Password Policy
- Access Keys: AWS CLI or SDK에 사용되는 접근 키
- Audit: IAM Credential Reports & IAM Access Advisor
EC2
- EC2 = Elastic Compute Cloud = Infrastructure as a Service (IaaS)
- EC2는 단순한 하나의 서비스일 뿐 아니라, 더 많은 서비스로 조합된다.
- Virtual machine 대여 (EC2)
- Virtual drive 내 데이터 저장 (EBS)
- Virtual machin 간의 로드 분산 (ELB)
- Auto-scaling group을 사용하는 서비스의 스케일링 (ASG)
- EC2의 이해는 곧 클라우드의 동작 방식을 이해하는 것의 기초가 된다.
Sizing & Configuration
- 운영 체제 (OS): Linux, Windows or MacOS
- CPU
- RAM
- 저장 공간
- 네트워크 결합 스토리지 : EBS & EFS
- 하드웨어 : EC2 Instance Store
- Network card: 카드 속도 및 퍼블릭 IP 주소
- Firewall Rules : Security group
- Bootstrap script (최초 실행 시의 설정): EC2 User Data
EC2 User Data
- EC2 User data 스크립트를 통해 인스턴스를 부트스트랩(bootstrap)할 수 있다.
- 스크립트는 인스턴스가 최초 실행될 때 단 한번만 실행된다.
- 다음과 같은 부트 시의 작업을 자동화할 수 있다.
- 업데이트 설치
- 소프트웨어 설치
- 인터넷으로부터 파일 설치
- 생각하는 뭐든지!
- EC2 User Data는 root user로 실행된다. (즉, 모든 권한을 갖는다.)
EC2 Instance Types
- EC2에는 여러 유스케이스에 적용될 수 있는 다양한 타입의 EC2 인스턴스가 존재한다.
- 네이밍 컨벤션이 존재한다.
m5.2xlarge
- m : 인스턴스 클래스
- 5 : 세대 (AWS 측에서 시간이 지남에 따라 이를 향상시킴)
- 2xlarge : 인스턴스 클래스 내 사이즈
Instance Types - General Purpose (Txx, Mxx)
- 웹 서버 또는 코드 저장소와 같이 다양한 작업 상황에서 활용할 수 있음
- 다음의 각각을 적절히 고려한 밸런스형 인스턴스 타입
- Compute
- Memory
- Network
Instance Types - Compute Optimized (Cxx)
- 높은 성능의 프로세서를 요구하는 연산 위주의 작업에 유용함.
- Batch processing workloads
- Media transcoding
- High performance web servers
- High performance computing (HPC)
- Dedicated gaming servers
Instance Types - Memory Optimized (Rxx, Xxx)
- 메모리 내에서 거대한 데이터 셋을 처리해야 하는 경우에 유용함.
- High performance, relational/non-relational databases
- Distributed web scale cache stores
- In-memory databases optimized for BI (Business Intelligence)
- Applications performing real-time processing of big unstructured data
Instance Types - Storage Optimized (Ixx, Gxx, Hxx)
- 로컬 스토리지 내 거대한 데이터 셋에 접근해서 순차적인 읽기/쓰기를 처리해야 하는 상황에 유용함.
- High frequency online transaction processing (OLTP) systems
- Relational & NoSQL databases
- Cache for in-memory databases (for example, Redis)
- Data warehousing applications
- Distributed file systems
Security Groups
- Security Groups는 AWS의 네트워크 보안의 기초가 된다.
- EC2 인스턴스의 인/아웃바운드 트래픽을 어떻게 처리할지를 다룸.
- 오직 allow rule만 포함한다.
- Security Groups의 rule은 IP 주소 혹은 Security Group에 의해 참조될 수 있다.
Security Groups Deep Dive
- Security Group은 곧 EC2 인스턴스의 방화벽처럼 동작한다.
- 다음을 다룰 수 있다.
- 특정 포트에 대한 엑세스
- 허용하는 IP의 범위 ~ IPv4 / IPv6
- 인바운드 네트워크에 대한 통제 (from other to the instance)
- 아웃바운드 네트워크에 대한 통제 (from the instance to other)
- 그 밖에 알면 좋은 것들
- 여러 인스턴스들에 대해 적용될 수 있다.
- 하나의 리전(region) / VPC에 격리된다.
- EC2 밖에 존재하는 것이다. ~ 즉, Security Group 쪽에서 트래픽이 막히는 경우, EC2 측에서는 이에 대해 알 수 없다.
- SSH 엑세스만을 위한 별도의 Security group을 구축하는 편이 좋다.
- 만약 앱에서 타임 아웃이 발생한다면, 아마도 Security group 이슈일 가능성이 크다.
- 만약 앱에서 "connection refused" 에러가 발생한다면, 애플리케이션 자체의 에러이거나, 그것이 실행되지 않았을 가능성이 크다.
- 인바운드 트래픽은 기본적으로 막혀있다.(blocked)
- 아웃바운드 트래픽은 기본적으로 권한을 갖는다.(authorized)
Classic Ports to know
- 22 = SSH (Secure Shell) - 리눅스 인스턴스로 로그인
- 21 = FTP (File Transfer Protocol) - 파일 공유를 위한 파일 업로드
- 22 = SFTP (Secure File Transfer Protocol) - SSH를 통한 파일 업로드
- 80 = HTTP - 안전하지 않은 웹사이트 접근
- 443 - HTTPS - 안전한 웹사이트 접근
- 3389 = RDP (Remote Desktop Protocol) - 윈도우즈 인스턴스로 로그인
EC2 Instances Purchasing Options
- On-Demand Instances - short workload, predictable pricing, pay by second
- Reserved (1 & 3 years)
- Reserved Instances - long workloads
- Convertible Reserved Instances - long workloads with flexible instances
- Savings Plans (1 & 3years) - commitment to an amount of usage, long workload
- Spot Instances - short workloads, cheap, can lose instances (less reliable)
- Dedicated Hosts - book an entire physical server, control instance placement
- Dedicated Instances - no other customers will share your hardware
- Capacity Reservations - reserve capacity in a specific AZ(availability zone) for any duration
On-Demand
- 쓴 만큼 지불
- Linux or Windows : 첫 1분 이후 초당 비용
- 그 외의 운영체제 : 시간 당 비용
- 가장 비용이 높으나, 선불(upfront) 금액이 없음
- 장기 계약(long-term commitment)이 없음
- 즉, 짧은 기간(short-term) 동안, **중단되어선 안되는 작업(un-interrupted workloads)**을 수행해야 하는 경우에 추천됨 ~ 앱이 어떻게 동작할지 예측할 수 없는 경우.
Reserved Instances
- On-demand 대비 72% 만큼 비용 절감
- 특정 인스턴스 속성에 대해 예약 (Instance type, Region, Tenancy, OS)
- 예약 기간 - 1년 또는 3년 (길수록 더 크게 절감)
- 결제 옵션 - No Upfront -> Partial Upfront -> All Upfront 순으로 비용 절감
- 예약 인스턴스 범위(Scope) - Regional or Zonal (하나의 AZ 내에 종속)
- 꾸준히 사용되어야 하는 앱에 추천됨 (Ex. DB)
- Reserved Instance Marketplace에서 사거나 팔 수 있음
- Convertible Reserved Instance
- 인스턴스 type, family, OS, scope, tenancy 등을 변경할 수 있음
Savings Plans
- 장 기간의 사용에 대한 할인 (최대 72% ~ Reserved Instances와 동일)
- 특정량 만큼의 사용을 약속 (Ex. 1년 또는 3년 동안 시간 당 10를 쓸 것임)
- On-Demand로 청구된 가격을 지불할 때 적용됨.
- 특정 Instance family와 AWS region에 제한됨. (Ex. M5 in us-east-1)
- 단, 다음에 대해선 유연하게 변경 가능
- 인스턴스 사이즈 (Ex. m5.xlarge, m5.2xlarge)
- OS (Ex. Linux, Windows)
- Tenancy (Host, Dedicated, Default)
- 단, 다음에 대해선 유연하게 변경 가능
Spot Instances
- On-demand 대비 최대 90%까지 절감
- 단, 설정한 최대 비용보다 현재 spot price가 더 비싸지는 경우에 언제든지 인스턴스가 사라질 수 있음.
- AWS에서 가장 비용 절감이 많이 되는 형태의 인스턴스
- 실패하는 경우에도 문제없는 탄력적인(resilient) 작업에 유용함.
- Batch jobs
- Data analysis
- Image processing
- Any distributed workloads
- Workloads with a flexible start and end time
- 중요한 작업이나 DB의 경우엔 적합하지 않음.
Dedicated Hosts
- 특정 이용자만 사용할 수 있는 전용 EC2 인스턴스에 대한 물리적 서버를 제공
- Compliance requirements를 준수하고, 기존에 보유한 Server-bound software licenses(소켓 당, 코어 당, VM 당)를 사용할 수 있도록 허용.
- 구매 옵션
- On-demand : 활성화된 Dedicated Host에 대해 초 당 비용
- Reserved : 1년 또는 3년 (No Upfront, Partial Upfront, All Upfront)
- 가장 비싼 인스턴스 옵션임.
- 복잡한 라이센싱 모델(Complicated licensing model)을 갖춘 소프트웨어의 경우에 유용함
- 또는, 강한 규제 및 법률을 보유한 기업의 경우에 사용함.
Dedicated Instances
- 이용자에게 종속된 하드웨어에서 동작하는 인스턴스
- 동일한 계정 내 다른 인스턴스들과도 하드웨어를 공유할 수 있음
- 인스턴스의 위치에 대한 통제권은 없음 (인스턴스의 실행/종료마다 하드웨어가 변경될 수 있음)
- Dedicated host와 비교했을 때, dedicated host는 말 그대로 물리적 서버 자체에 대한 접근권을 갖기 때문에 보다 저수준(low-level)의 하드웨어에 대한 가시성(visibility)을 갖는 반면, Dedicated Instance의 경우는 그렇지 못한다는 점에서 차이가 생김.
Capacity Reservations
- 특정 AZ의 특정 기간 동안에 On-demand 인스턴스 용량(capacity)를 예약
- 필요한 경우 언제든 EC2 가용량에 접근할 수 있음
- 별도의 계약이 없으며(언제든 생성/취소할 수 있음), 별도의 비용 절감도 없음
- Reserved Instance와 Saving Plans와 결합하여 비용 절감을 취할 수 있음
- 인스턴스가 실제로 실행되든 아니든 간에 비용이 On-demand로 청구됨.
- 짧은 기간 동안, 중단되어선 안되는 작업(uninterrupted workload)이 특정 AZ 내에서 수행되어야 하는 경우에 적합.
Spot Instance
- On-demand 대비 90%까지 비용 절감 가능한 인스턴스 옵션
- max spot price를 지정 후, 해당 인스턴스의 현재 spot price가 지정한 max spot price보다 작은 경우에 해당 인스턴스를 보유할 수 있게 됨.
- 매 시간마다 spot price는 수요/공급(offer/capacity)을 고려해서 달라짐.
- 만약, 보유할 수 없는 상황이 된 경우, 2분 동안의 유예 기간(grace period)를 부여받으며, 해당 인스턴스를 중지(stop)할 것인지, 종료(terminate)할 것인지 선택할 수 있음.
- Spot Block ~ 특정 시간대에 대해 적용하는 블록(block) spot instance. 지금은 없어짐.
- 실패해도 무방한 작업들, 즉 내결함성(resilient)을 갖춘 작업들의 경우에 적합함. 반대로, 중요한 작업이나 DB에는 부적합.
Spot instance request를 종료하고자 하는 경우
- open, active, disabled 상태에서만 Spot instance request을 취소할 수 있음
- Spot instance request의 취소가 곧 인스턴스의 종료(terminate)를 의미하진 않음
- 따라서, Spot instance를 종료하고 싶은 경우, 반드시 먼저 spot request를 취소한 다음, 그 다음에 관련된 Spot instance를 종료해야 함.
Spot Fleets
- Spot Fleets = set of Spot Instances + (선택) On-Demand Instances
- 주어진 비용 제한(price constraints)에 맞춰 목표 용량(target capacity)을 충족하려고 시도함.
- 가능한 launch pools 정의 : 인스턴스 타입, OS, AZ
- 여러 개의 launch pools를 가질 수 있으며, 이렇게 하면 fleet이 선택할 수 있게 됨
- Spot Fleet은 용량 또는 최대 비용에 도달하는 경우 인스턴스 실행을 멈춤
- Spot instances 할당 전략
- lowerPrice: 가장 비용이 저렴한 pool부터 시도 (cost optimization, short workload)
- diversified: 모든 pool에 대해 광범위하게 적용 (great for availability, long workloads)
- capacityOptimized: 인스턴스 개수 대비 최적의 용량을 가진 pool부터 시도
- Spot Fleet은 Spot Instance에 대한 요청을 최소화된 비용으로 자동으로 처리할 수 있도록 해줌.
Elastic IPs
-
EC2 인스턴스를 중지/실행할 때마다, 퍼블릭 IP가 변경될 수 있다.
-
만약, 인스턴스의 퍼블릭 IP를 고정시키고자 한다면, Elastic IP가 필요하다.
-
Elastic IP는 퍼블릭 IPv4 IP에 해당하며, 삭제하지 않는 이상 유지된다.
-
한번에 하나의 인스턴스에 대해서만 부여할 수 있다.
-
Elastic IP 주소를 사용하면, 인스턴스 또는 소프트웨어에 문제가 발생했을 때, 해당 주소를 다른 인스턴스에 부여해서 문제에 대처할 수 있다.
-
단, Elastic IP 주소는 한 계정 당 오직 5개까지만 가질 수 있다. (AWS에 더 달라고 요청할 수는 있다.)
-
결론적으로, Elastic IP를 쓰는 것을 피하는 것이 좋다.
- 일반적으로는 좋지 않은 결정이다.
- 대신에, 랜덤 퍼블릭 IP를 사용하고, 그것에 대한 DNS 네임을 등록하는 편이 좋다.
- 또는, 로드 밸런서를 사용하면서 퍼블릭 IP 자체를 사용하지 않는 방법도 있는데, 이 방법이 가장 최적이다.
Private vs Public IP in AWS EC2
-
기본적으로, EC2 머신에서
- 프라이빗 IP는 AWS 내부 네트워크를 위해서,
- 퍼블릭 IP는 WWW를 위해서 사용된다.
-
만약, EC2 머신에 SSH 연결을 시도한다면
- (VPN이 구축되어 있지 않다면) 프라이빗 IP가 아닌, 퍼블릭 IP를 써야한다. 동일한 네트워크에 놓여있는 것이 아니기 때문.
-
머신이 중지/재실행된다면, 퍼블릭 IP는 바뀔 수 있다.
Placement Groups
- EC2 인스턴스의 배치 전략(placement strategy)을 조정하고 싶을 때, Placement group을 정의할 수 있다.
- Placement group은 다음의 전략 중 하나를 선택할 수 있다.
- Cluster - 하나의 AZ 내에 low-latency group으로 인스턴스들을 밀집
- Spread - 소규모 인스턴스 그룹을 다른 기본 하드웨어(underlying hardware)로 분산하여 오류를 줄임 (각 AZ의 그룹 당 최대 7개 인스턴스), 중요한 애플리케이션에서 활용.
- Partition - 인스턴스들을 하나의 AZ 내의 파티션 별로 다른 기본 하드웨어로 분산하여 오류를 줄임.
Placement Groups - Cluster
- 장점 : 높은 네트워크 성능 (인스턴스 간 latency가 작음)
- 단점 : 하드웨어 상 결함이 발생한다면, 모든 인스턴스가 다 같이 실패함.
- 사례
- 빠르게 수행되어야 하는 거대한 데이터 기반의 작업
- 극도로 낮은 latency와 높은 네트워크 throughput을 보장해야하는 애플리케이션
Placement Groups - Spread
- 장점
- 동일한 지역의 여러 AZ로 확장할 수 있음
- 동시에 인스턴스들이 실패할 위험을 줄일 수 있음
- 단점
- 각 Placement group 내 하나의 AZ에 최대 7개의 인스턴스로 제한됨.
- 사례
- 최대한 높은 가용성(availability)을 확보해야 하는 애플리케이션
- 인스턴스 서로가 각자의 동작 실패로부터 격리되어야 하는 중요한 애플리케이션
Placement Groups - Partition
- AZ 당 최대 7개의 파티션
- 동일한 지역 내 여러 AZ로 확장 가능
- 최대 100개의 인스턴스를 파티션 설정에 사용할 수 있음
- 하나의 파티션 내에 있는 인스턴스들은 다른 파티션의 인스턴스들과 rack을 공유하지 않음
- 하나의 파티션 내에서 발생한 문제는 해당 파티션에 속한 인스턴스들에는 영향을 끼칠 수 있으나, 그 외의 파티션에는 영향을 끼치지 않음.
- EC2 인스턴스들은 메타데이터를 통해 파티션 정보에 접근할 수 있음
- 사례 : HDFS, HBase, Cassandra, Kafka
Elastic Network Interfaces (ENI)
- 가상의 네트워크 카드(virtual network card)를 나타내는 VPC 내 논리적 네트워킹 컴포넌트
- ENI는 다음과 같은 속성을 갖출 수 있음
- 기본(primary) 프라이빗 IPv4, 하나 또는 그 이상의 보조(secondary) IPv4
- 하나의 프라이빗 IPv4 당 하나의 Elastic IP (IPv4)
- 하나의 퍼블릭 IPv4
- 하나 이상의 Security groups
- Mac address
- ENI를 독립적으로 생성하고, EC2 인스턴스가 실패했을 때에 대비하여 인스턴스에 적용(attach)한다.
- 하나의 특정한 AZ에 격리된다.
EC2 Hibernate
- 인스턴스의 중지(stop) / 종료(terminate)
- Stop - 디스크에 저장된 데이터(EBS)가 다음 인스턴스의 실행까지 유지됨
- Termninate - 삭제 될 것으로 설정된 특정 EBS 볼륨(root)은 사라짐
- 인스턴스의 실행 시, 다음과 같은 일이 일어난다.
- 최초 실행 시 : OS 부팅 & EC2 User Data Script가 실행됨
- 그 이후의 실행 시 : OS 부팅
- 위의 각 단계 이후에 애플리케이션이 실행되고, 캐시가 웜업(warm-up) 된다.
EC2 Hibernate 란?
- in-memory(RAM) 상태를 보존할 수 있음
- 인스턴스 부팅이 훨씬 빨라짐 (OS가 중지되거나 재실행되지 않음)
- 원리 : RAM의 상태가 루트 EBS 볼륨에 하나의 파일로 작성되어 보존.
- 즉, 루트 EBS 볼륨이 반드시 암호화되어야 함.
- 사례 :
- 오래 동안 진행되어야 하는 프로세싱
- RAM 상태를 저장해야 하는 경우
- 초기화에 시간이 많이 걸리는 서비스
- 그 밖의 특징
- 지원하는 인스턴스 패밀리 - C3, C4, C5, I3, M3, M4, R3, R4, T2, T3, ...
- 인스턴스 RAM 사이즈 - 150GB 미만 이어야 함
- 인스턴스 사이즈 - 베어 메탈 인스턴스(bare metal instance)에는 지원하지 않음
- AMI - Amazon Linux 2, Linux AMI, Ubuntu, RHEL, CentOS & Windows, ...
- 루트 볼륨 - 반드시 다음의 속성을 갖춘 EBS여야 함
- encrypted
- not instance store
- large
- On-Demand, Reserved, Spot 인스턴스 모두에 적용 가능.
- 인스턴스는 60일을 초과해서 hibernate 할 수 없음.
EC2 Instance Store
- EBS 볼륨은 네트워크 드라이브에 해당하여 좋은 점도 있지만, 제한된 성능을 갖는다.
- 만약, 고성능의 하드웨어 디스크가 필요한 상황이라면, EC2 Instance Store의 사용을 고려해야 한다.
- 더 나은 I/O 성능을 제공
- EC2 Instance Store는 인스턴스가 멈추게 되면 데이터가 사라진다. (ephemeral = 일시적임)
- buffer / cache / scratch data / temporary content에 유용함
- 하드웨어 문제가 발생하는 경우 데이터가 사라질 수 있음
- 백업 및 복제(replication)는 이용자가 직접 처리해야 함
AMI
-
AMI = Amazon Machine Image
-
AMI는 EC2 인스턴스의 customization다.
- 나만의 소프트웨어, 설정, OS, 모니터링 등을 추가할 수 있음
- 모든 내 소프트웨어가 pre-package 되어 있으므로, 더 빠른 부트/설정 시간을 가질 수 있다.
-
AMI는 하나의 리전에 대해서만 만들어질 수 있으며, 다른 리전에서 사용하고자 한다면 별도로 복사를 해야한다.
-
EC2 인스턴스를 다음과 같은 AMI들에서 실행할 수 있다.
- 공용(Public) AMI: AWS가 제공
- 직접 만든 AMI: 직접 만들고 유지보수
- AWS Marketplace AMI: 다른 누군가가 만든 AMI (판매도 할 수 있음)
AMI Process (from an EC2 instance)
- EC2 인스턴스를 시작하여 커스터마이징함
- 인스턴스를 중지 (데이터 무결성 ~ data integrity를 위하여)
- AMI를 빌드 - 해당 작업은 EBS 스냅샷도 마찬가지로 생성함
- 다른 AMI들로부터 인스턴스를 실행
EBS
- EBS (Elastic Block Store) 볼륨은 인스턴스가 돌아가는 동안에 부착할 수 있는 네트워크 드라이브이다.
- 인스턴스가 종료된 이후에도 데이터를 보존할 수 있도록 해준다.
- 기본적으로는 한번에 하나의 인스턴스에만 이용될 수 있으나, 일부 EBS에는 multi-attach 기능이 있다.
- 특정 AZ에 격리된다.
- 하나의 네트워크 USB 스틱으로 생각해도 좋다.
- 프리티어의 경우 General Purpose (SSD) 또는 Magenetic으로 한달에 30GB의 무료 EBS 스토리지를 제공받는다.
EBS Volume
-
네트워크 드라이브에 해당 (즉, 물리적(physical) 드라이브가 아님.)
- 인스턴스와 소통하기 위해 네트워크를 사용하며, 이는 즉 약간의 latency가 발생할 수 있음을 의미한다.
- 하나의 EC2 인스턴스로부터 분리(detach)되어, 다른 것에 부착(attach)될 수 있다.
-
하나의 AZ에 갇혀있다.
- 예를 들어, us-east-1a의 EBS볼륨은 us-east-1b에 부착될 수 없다.
- 볼륨을 AZ 너머로 이동시키려면, 먼저 그것에 대한 스냅샷(snapshot)을 사용해야 한다.
-
프로비전(provision)된 가용량(capacity)을 갖고 있다. (size in GBs, and IOPS)
- 모든 프로비전 가용량에는 비용이 지불된다.
- 추후에도 드라이브 용량을 증가시킬 수 있다.
Delete on Termination attribute
- EC2 인스턴스가 종료될 때 EBS의 동작을 컨트롤할 수 있다.
- 기본적으로, 루트 EBS 볼륨(root EBS volume)은 삭제된다. (= enabled)
- 기본적으로, 그 외의 EBS 볼륨들은 삭제되지 않는다. (= disabled)
- 이는 AWS console / AWS CLI를 통해 이루어질 수 있으며, 인스턴스가 종료(terminate)되더라도 루트 볼륨을 보존하고자 할 때 사용할 수 있다.
Snapshots
- 특정한 시점에 보유한 EBS 볼륨에 대한 백업(=스냅샷)을 만들 수 있음
- 스냅샷을 위해 볼륨을 분리(detach)할 필요는 없지만, 권장되는 사항임.
- 이를 통해 다른 AZ 또는 지역 너머로 스냅샷을 복사하여 볼륨을 복구(restore)할 수 있음.
EBS Snapshot Features
-
EBS Snapshot Archive
- 스냅샷을 75% 싼 스토리지 티어인 archive tier로 이동함.
- 아카이브한 내용을 복구하는데에 24 ~ 72 시간이 소요됨.
-
Recycle Bin for EBS Snapshots
- 삭제된 스냅샷을 보존하기 위한 것으로, 일시적인 삭제 이후에도 이를 복구할 수 있도록 Rule을 생성할 수 있음
- 보존기간을 명시할 수 있음 (1일부터 1년까지)
-
Fast Snapshot Restore (FSR)
- 첫 사용에도 latency가 존재하지 않도록 스냅샷의 완전한 초기화를 강제함.
EBS Volume Types
-
EBS 볼륨은 6가지 종류가 있음
- gp2 / gp3 (SSD): 폭넓은 종류의 작업을 처리할 수 있는 가격과 성능 간 밸런스를 갖춘 일반적인 목적의 SSD
- io1 / io2 (SSD): 낮은 latency와 높은 throughput이 요구되는 상황에서 사용하는 높은 성능의 SSD
- st1 (HDD): 자주 액세스 해야하고, throughput이 중요한 작업에 사용하기 위한 용도의 낮은 비용의 HDD
- sc (HDD): 비교적 액세스 빈도가 낮은 작업을 처리하기 위한 용도의 가장 저렴한 HDD
-
EBS 볼륨은 사이즈 / throughput / IOPS (I/O Ops Per Sec)에 따라 특징이 나뉨.
-
헷갈린다면 AWS 문서를 참조할 것.
-
부트 볼륨(root OS가 실행되는 볼륨)으로는 오직 gp2/gp3와 io1/io2만 사용할 수 있음.
EBS Volume Types - General Purpose SSD
- 비용 효율적인 스토리지와 낮은 latency가 요구되는 상황에 사용
- Ex.) System boot volumes, Virtual desktops, Development and test environments
- 1GB ~ 16TB
- gp3
- 기본 3,000 IOPS와 125MB/s의 throughput
- 각각 16,000 IOPS 및 1000MB/s throughput 까지 높일 수 있음
- gp2
- 3,000 IOPS 까지 높일 수 있는 작은 gp2 볼륨
- 볼륨의 사이즈에 따라 IOPS도 달라지며, 최대 IOPS는 16,000
- GB 당 3 IOPS, 즉 5,334GB에서 최대 IOPS에 도달할 수 있음.
EBS Volume Types - Provisioned IOPS (PIOPS) SSD
- IOPS 성능을 유지해야 하는 중요한 비즈니스 애플리케이션에 사용
- 또는 16,000 IOPS 이상을 필요로 하는 애플레케이션에 사용
- 데이터베이스 작업에 유용 (스토리지 성능 및 consistency가 중요하기 때문)
- io1/io2 (4GB - 16TB)
- 최대 PIOPS: Nitro EC2 인스턴스의 경우 64,000 & 그 외에는 32,000
- 스토리지 사이즈와 별개로 PIOPS를 높일 수 있음
- io2가 더 높은 내구성(durability)를 갖고 있으며, 각 GB 당 더 많은 IOPS 성능을 가짐 (io1와 동일한 비용인 경우)
- io2 Block Express (4GB - 64TB)
- ms(밀리초) 미만의 latency가 요구되는 경우
- 최대 PIOPS: 1,000:1의 IOPS:GB 비율을 가지며, 최대 256,000
- EBS Multi-attach를 지원
EBS Volume Types - Hard Disk Drives (HDD)
- 부트 볼륨으로 사용될 수 없음
- 125MB - 16TB
- Throughput Optimized HDD (st1)
- Big Data, Data Warehouses, Log Processing
- 최대 throuput 500MB/s - 최대 IOPS 500
- Cold HDD (sc1)
- 액세스 빈도가 낮은 데이터
- 비용을 낮추는 것이 중요한 상황에서 사용
- 최대 throughput 250MB/s - 최대 IOPS 250
EBS Multi-Attach - io1/io2 family
- 동일한 AZ 내에 여러 EC2 인스턴스에 동일한 EBS 볼륨을 부착하는 것.
- 각각의 인스턴스는 고성능 볼륨에 모든 읽기/쓰기 권한을 갖게 됨
- 사례 :
- Clustered linux application 내에서 높은 애플리케이션 가용성(higher application availability)을 보존해야 할 때
- 단, 애플리케이션 자체가 동시 쓰기 작업을 다룰 수 있어야 함
- 한꺼번에 최대 16개의 인스턴스에만 적용할 수 있음
- 클러스터에 대해 인지하는(cluster-aware) 파일 시스템을 사용해야 함. (즉, XFS, EX4 등을 사용할 수 없음)
EBS Encryption
- 암호화된 EBS 볼륨을 생성하면
- 볼륨 내에 저장된 데이터가 암호화됨
- 인스턴스와 볼륨 사이를 이동하는 모든 데이터가 암호화됨
- 모든 스냅샷이 암호화됨
- 스냅샷으로부터 생성되는 모든 볼륨이 암호화됨
- 암호화/복호화(encryption/decryption)는 투명하게 처리됨 (따로 처리해야 할 일이 없음)
- 암호화는 latency에 거의 영향을 끼치지 않음
- EBS 암호화는 KMS(AES-256) 암호화를 사용
- 암호화되지 않은 스냅샷을 복사하는 경우 암호화가 가능
- 암호화된 볼륨의 스냅샷은 암호화됨
Encryption: encrypt an unencrypted EBS Volume
- 볼륨의 EBS 스냅샷을 생성
- EBS 스냅샷을 암호화 (using copy)
- 스냅샷을 통해 새 EBS 볼륨을 생성 (새로 생성된 볼륨은 암호화됨)
- 기존 인스턴스에 암호화된 볼륨을 부착
EFS
- 여러 EC2 인스턴스에 마운트 될 수 있는 관리형 NFS (Managed NFS ~ network file system)
- EFS는 여러 AZ에 있는 EC2 인스턴스와 함께 동작할 수 있음
- 높은 가용성이 있고(highly available), 확장 가능하며(scalable), 비쌈(expensive ~ gp2의 3배), 사용한 만큼 지불 (pay per use)
- 사례 :
- content management
- web serving
- data sharing
- wordpress
- NFSv4.I 프로토콜을 사용
- EFS에 대한 엑세스를 관리하기 위해 Security Group을 사용
- Linux based AMI의 경우에만 호환됨 (Windows는 안됨)
- KMS를 이용해 암호화 (encryption at rest using KMS)
- 표준 파일 API를 가진 POSIX 파일 시스템 (~LINUX)
- 파일 시스템은 자동으로 크기가 변하며, 사용량에 따라 비용이 청구됨. 별도로 capacity plan이 없음.
EFS - Performance & Storage Classes
-
EFS Scale
- 동시에 1000 개의 NFS client를 가질 수 있으며, 10GB/s 이상의 throughput
- 페타바이트(Petabyte)급의 네트워크 파일 시스템으로 자동으로 확장될 수 있음
-
Performance mode (EFS 생성 시에 설정됨)
- General purpose (default): latency에 민감한 사례에 사용 (웹서버, CMS, 등등..)
- Max I/O - 더 높은 latency을 갖춘 반면, 더 높은 throughput과 더 높은 병렬 처리(parallel) ~ 빅데이터, 미디어 프로세싱 사례에 사용
-
Throughput mode
- Bursting (1TB = 50MB/s + burst of up to 100MB/s)
- Provisioned (= Enhanced로 명칭 변경): 스토리지 사이즈와 무관하게 throughput을 설정 (Ex. 1TB 스토리지에 1GB/s으로 설정)
-
Storage Tiers (lifecycle management feature - N일 이후에 파일 이동)
- Standard: 자주 엑세스되는 파일들에 사용
- Infrequent access (EFS-IA): 파일 검색에 비용 부과하며, 저장에는 더 낮은 비용. Lifecycle Policy로 EFS-IA를 활성화.
-
Availability and Durability (= Storage class로 명칭 변경)
- Standard: Multi-AZ, 프로덕션 환경에서 유용
- One Zone: One AZ, 개발 환경에서 유용, 기본적으로 백업이 활성화 되어 있으며, IA(Infrequent Access)와 호환. (EFS One Zone-IA)
-
최대 90%까지 비용 절감 가능
EBS vs. EFS
EBS Volumes
-
특징
- 기본적으로는 하나의 인스턴스에 하나씩 부착
- AZ 레벨에서 격리됨
- gp2: 디스크 사이즈가 늘어남에 따라 IO 성능도 늘어남
- io1: 디스크 사이즈와 무관하게 IO 성능을 별개로 늘릴 수 있음
-
EBS 볼륨을 AZ 너머로 마이그레이션하려면
- 스냅샷을 찍고
- 다른 AZ에서 해당 스냅샷을 복구
- EBS 백업은 IO를 사용하며, 현재 애플리케이션이 많은 트래픽을 다루는 중인 경우에는 이를 작동시키지 말아야 한다.
-
EC2 인스턴스가 종료되면, 기본적으로 해당 인스턴스의 EBS 볼륨도 종료된다. (해당 동작은 disable할 수 있음.)
EFS - Elastic File System
- 특징
- AZ와 무관하게 100개 까지 인스턴스를 마운팅 할 수 있음
- 웹사이트 파일 공유에 활용할 수 있음 (Ex. WordPress)
- 리눅스 인스턴스(POSIX)에만 사용할 수 있음
- EBS보다 비용이 더 높지만, 비용 절감을 위해 EFS-IA를 활성화할 수 있음
EC2 Instance Store
- 극도로 높은 I/O 성능이 요구되는 상황에서 사용
- 기본적으로 인스턴스가 중지되면 데이터가 사라짐 (저장이 일시적임)
ELB
Scalability & High Availability
-
Scalability는 상황에 따라 애플리케이션 / 시스템의 규모가 더 크게 조정할 수 있음을 의미함.
-
두 종류의 Scalability
- Vertical Scalability
- Horizontal Scalability (= elasticity)
-
Scalability는 High Availability와 관련이 있긴 하나, 엄연히 다른 개념이다.
Vertical Scalability
- 인스턴스 사이즈의 크기를 늘리는 것을 의미한다. (Ex. t2.micro -> t2.large)
- DB와 같은 비분산 시스템에 적용하는 것이 일반적이다. (Ex. RDS, ElastiCach)
- 확장에 있어 한계점이 있다. (하드웨어 제한)
Horizontal Scalability
- 애플리케이션에 대한 인스턴스 또는 시스템의 갯수를 늘리는 것을 의미한다.
- 분산 시스템에 적용할 수 있다.
- 웹 애플리케이션 / 모던 애플리케이션에 매우 일반적으로 사용된다.
High Availability
- High Availability는 주로 horizontal scaling과 일반적으로 관련이 있는 개념이다.
- 최소 2개의 데이터 센터(== AZ)에서 애플리케이션 / 시스템이 가동되고 있음을 의미한다.
- High Availability의 목표는 데이터 센터의 소실에서 살아남기 위함이다.
- High Availability는 수동적(passive)일 수도 있고(RDS Multi AZ), 능동적(active)일 수도 있다(Horizontal Scaling).
High Availability & Scalability For EC2
- Vertical Scaling: 인스턴스 사이즈 증가 (= scale up / down)
- Horizontal Scaling: 인스턴스의 개수 증가 (= scale out / in)
- Auto Scaling Group
- Load Balancer
- High Availability: 여러 AZ를 통해 동일한 애플리케이션에 대한 인스턴스를 실행
- Auto Scaling Group multi AZ
- Load Balancer multi AZ
Load Balancing
- Load Balances는 트래픽을 여러 downstream 서버(Ex. EC2 인스턴스)로 분산시켜주는 서버이다.
Why use a load balancer?
- 부하(load)를 여러 다운스트림 인스턴스(downstream instance)로 분산시킬 수 있다.
- 애플리케이션에 대한 하나의 접근 지점(a single of access)를 노출시킬 수 있다.
- 다운스트림 인스턴스의 실패에 대해 유연하게(seamlessly) 대처할 수 있다.
- 인스턴스에 대한 정기 검진(regular health check)를 수행할 수 있다.
- 웹사이트에 SSL termination (HTTPS)을 제공할 수 있다.
- 쿠키를 통해 Session stickiness를 강화한다.
- 여러 AZ 너머로 High Availability를 갖는다.
- 퍼블릭 트래픽(Public traffic)을 프라이빗 트래픽(Private traffic)과 분리한다.
Why use an Elastic Load Balancer?
- ELB(Elastic Load Balancer)는 **managed load balancer(관리된 로드 밸런서)**다.
- AWS가 동작을 보장함
- AWS가 업그레이드, 유지보수, 고가용성(High availability)를 관리한다.
- AWS는 오직 소수의 configuration knob를 제공한다.
- 자체적인 로드 밸런서를 구축하는 경우, 비용은 더 적게 들지라도, 훨씬 더 많은 노력이 필요하다.
- AWS에서 제공하는 다양한 서비스들과 함께 통합되어 있다.
- EC2, EC2 Auto Scaling Groups, Amazon ECS
- AWS Certificate Manager (ACM), CloudWatch
- Route 53, AWS WAF, AWS Global Accelerator
Health Checks
- 헬스 체크는 로드 밸런서에 중요함.
- 로드 밸런서가 "트래픽을 전달받을 인스턴스가 요청에 응답할 수 있는지"에 대한 여부를 알 수 있도록 함.
- 헬스 체크는 하나의 포트와 하나의 루트에서 이루어짐. (일반적으로
/health
)
Types of load balancer on AWS
-
AWS에는 4가지 종류의 managed load balancer가 있다.
- Classic Load Balancer (v1 - old generation, 사라질 예정) - 2009 - CLB
- HTTP, HTTPS, TCP, SSL (secure TCP)
- Application Load Balancer (v2 - new generation) - 2016 - ALB
- HTTP, HTTPS, WebSocket
- Network Load Balancer (v2 - new generation) - 2017 - NLB
- TCP, TLS (secure TCP), UDP
- Gateway Load Balancer - 2020 - GWLB
- Operates at layer 3 (Network layer) - IP Protocol
- Classic Load Balancer (v1 - old generation, 사라질 예정) - 2009 - CLB
-
대체로는 새로운 세대의 로드 밸런서를 이용하는 것이 추천된다. 더 많은 기능을 보유하기 때문이다.
-
일부 로드 밸런서는 internal(private) 또는 external (public) ELB로써 설정될 수 있다.
Application Load Balancer (v2)
- Application load balancer는 레이어 7(Application Layer)에 해당
- 여러 머신들로 여러 HTTP 애플리케이션에 대한 로드 밸런싱 (target groups)
- 동일한 머신 내 여러 애플리케이션에 대한 로드 밸런싱 (Ex: containers)
- HTTP/2와 웹 소켓을 지원
- 리다이렉트 지원 (from HTTP to HTTPS for example)
- 다른 타겟 그룹(target group)에 대한 라우팅 테이블
- URL 내 path에 기반한 라우팅 (example.com
/users
& example.com/posts
) - URL 내 hostname에 기반한 라우팅 (
one.example.com
&other.example.com
) - 쿼리스트링, 헤더에 기반한 라우팅 (example.com/users?
id=123&order=false
)
- URL 내 path에 기반한 라우팅 (example.com
- ALB는 마이크로 서비스 & 컨테이너 기반 애플리케이션에 매우 적합함. (ex. Docker & Amazon ECS)
- ECS의 다이나믹 포트로 리다이렉트 해주는 포트 매핑(port mapping) 기능이 있음.
- CLB(Classic Load Balancer)로 치면, 각각의 애플리케이션에 여러 개의 CLB를 두는 것과 유사함.
Application Load Balancer (v2) ~ Target Groups
- 어떤 것들이 Target group이 될 수 있는가?
- EC2 인스턴스 (Auto Scaling Group으로 관리될 수 있음) - HTTP
- ECS tasks (ECS 자체적으로 관리됨) - HTTP
- Lambda functions - HTTP 요청이 JSON 이벤트로 변환
- IP Addresses - 반드시 private IP들이어야 함
- ALB는 여러 개의 target group에 라우팅을 할 수 있음
- 헬스 체크(health check)는 target group level에서 수행됨
Application Load Balancer (v2) ~ Good to Know
- 호스트네임(hostname)이 고정됨 ~ (XXX.region.elb.amazonaws.com)
- 애플리케이션 서버는 클라이언트의 IP를 직접 볼 수 없음
- 클라이언트의 진짜 IP는
X-Forwarded-For
헤더에 삽입됨 - 포트(
X-Forwarded-Port
)와 프로토콜(X-Forwarded-Proto
)도 알 수 있음
- 클라이언트의 진짜 IP는
Network Load Balancer (v2)
- Network load balancers (Layer 4)는
- 인스턴스들에 TCP & UDP 트래픽을 포워딩
- 매초 수백만(million)의 요청을 처리할 수 있음
- ALB(400ms)보다 더 낮은 레이턴시 (~100ms)
- 각 AZ마다 하나의 정적 IP를 가지며, Elastic IP 할당을 지원 (특정 IP에 대한 화이트리스팅(whitelisting)에 유용함)
- NLB는 극도의 성능과 함께 TCP 또는 UDP 트래픽을 다루어야 하는 경우에 사용됨.
- AWS 프리티어에 해당하지 않음.
Network Load Balancer (v2) - Target Groups
- EC2 instances
- IP Addresses - 반드시 private IPs
- Application Load Balancer (ALB)
- TCP, HTTP, HTTPS 프로토콜에 대한 헬스 체크를 지원
Gateway Load Balancer
- 3rd party network virtual appliance들을 배포, 확장, 관리할 수 있음
- Ex.) Firewalls, Intrusion Detection and Prevention systems, Deep Packet Inspection Systems, payload manipulation
- Layer 3(Network Layer)에서 동작 - IP 패킷
- 아래 기능들을 합친 것
- Transparent Network Gateway - 모든 트래픽에 대한 단일 entry/exit
- Load Balancer - virtual appliance에 대한 트래픽 분산
- 6081 포트에 GENEVE 프로토콜을 사용
Gateway Load Balancer - Target Groups
- EC2 instances
- IP Addresses - must be private IPs
Sticky Sessions (Session Affinity)
- stickiness를 구현하여 동일한 클라이언트는 로드 밸런서를 거치더라도 항상 동일한 인스턴스로 리다이렉트되도록 할 수 있음
- Classic Load Balancer & Application Load Balancer에서 사용 가능
- stickiness를 위해 사용되는 쿠키는 임의로 조정할 수 있는 expiration date가 존재
- 사례 : 이용자가 세션 데이터를 잃어버리지 않도록 해야하는 경우
- stickiness의 적용은 EC2 인스턴스의 부하에 불균형을 일으킬 가능성도 있음.
Sticky Sessions - Cookie Names
- Application-based Cookies
- Custom cookie
- 타겟에 의해 생성
- 애플리케이션에서 요구되는 커스텀 속성들을 추가할 수 있음
- 각 타겟 그룹에 대해 독립적으로 정의되어야 함
- AWSALB, AWSALBAPP, AWSALBTG는 사용할 수 없음 (ELB 자체적으로 사용됨)
- Application cookie
- 로드 밸런서에 의해 생성
- AWSALBAPP이 쿠키명이 됨
- Custom cookie
- Duration-based Cookies
- 로드 밸런서에 의해 생성되는 쿠키
- ALB의 경우 AWSALB, CLB의 경우 AWSELB라는 쿠키명이 됨.
Cross-Zone Load Balancing
- Cross Zone Load Balancing을 이용하는 경우:
- 각각의 로드 밸런서 인스턴스가 모든 AZ에 있는 모든 등록 인스턴스 너머로 균일하게 로드를 분산시킴
-
Cross Zone Load Balancing을 이용하지 않는 경우:
- Elastic Load Balancer의 노드 내에 있는 인스턴스들 내에서만 균일하게 분산시킴
-
Application Load Balancer
- 기본적으로 활성화되어 있음 (타겟 그룹 단위로 비활성화 가능)
- AZ 간 데이터(inter AZ data)에 부과되는 비용 없음
-
Network Load Balancer & Gateway Load Balancer
- 기본적으로 비활성화
- 활성화 시에는 AZ 간 데이터(inter AZ data)에 비용이 부과됨
-
Classic Load Balancer
- 기본적으로 비활성화
- 활성화 시에는 AZ 간 데이터(inter AZ data)에 비용이 부과됨
SSL/TLS - Basics
- SSL 인증서는 클라이언트와 로드밸런서 간의 트래픽을 전송 중에(in transit) 암호화해주는 역할을 한다. (in-flight encryption)
- SSL : Secure Socket Layer, 암호화 연결을 위해 사용
- TLS : Transport Layer Security, SSL의 새 버전
- 요즘은 TLS 인증서가 주로 사용되지만, 사람들은 여전히 이걸 SSL이라는 명칭으로 부른다.
- 공공 SSL 인증서는 Certificate Authorities(CA)에서 발급함
- ex.) Comodo, Symantec, GoDaddy, GlobalSign, Digicert, Letsencrypt, etc...
- SSL 인증서는 (직접 정하는) 만료일이 존재하며, 반드시 정기적으로 갱신되어야 한다.
Load Balancer - SSL Certificates
- 로드 밸런서는 X.509 인증서를 사용함 (SSL/TLS server certificate)
- ACM(AWS Certificate Manager)를 통해서 인증서를 관리할 수 있음
- 원한다면 직접 본인이 소유한 인증서를 업로드할 수도 있음
- HTTPS listener:
- 기본 인증서를 지정해야 함
- 다중 도메인(multiple domains)을 지원하기 위해 선택적 인증서 목록(optional list of certs)을 추가할 수 있음
- 클라이언트는 본인이 도달한 호스트 네임을 지정하기 위해 SNI (Server Name Indication) 을 사용할 수 있음
- 구 버전의 SSL/TLS을 지원하고자 하는 경우, HTTPS에 특정 보안 정책을 설정할 수 있음 (legacy clients)
SSL - Server Name Indication (SNI)
- SNI는 하나의 웹 서버에서 여러 SSL 인증서들이 로드되는 문제를 해결함 (여러 웹사이트를 서빙하기 위해)
- 이는 새로운 프로토콜이며, 클라이언트에게 처음 SSL 핸드셰이크를 할 때에 타겟 서버의 호스트네임을 알려주는(indicate) 역할을 함
- 클라이언트는 이를 바탕으로 올바른 인증서를 찾거나, 또는 기본 인증서를 반환함
- 유의점:
- ALB와 NLB, 그리고 CloudFront에서만 동작 (신세대)
- CLB에는 적용할 수 없음 (구세대)
Elastic Load Balancers - SSL Certificates
-
Classic Load Balancer (v1)
- 오직 하나의 SSL 인증서만 지원
- 여러개의 SSL 인증서를 가진 여러 개의 호스트네임을 위해서는 반드시 여러 개의 CLB를 사용해야만 함
-
Application Load Balancer (v2)
- 여러 개의 SSL 인증서를 가진 여러 개의 리스너를 지원함
- 이것이 가능하게 하도록 SNI (Server Name Indication)을 사용함
-
Network Load Balancer (v2)
- 여러 개의 SSL 인증서를 가진 여러 개의 리스너를 지원함
- 이것이 가능하게 하도록 SNI (Server Name Indication)을 사용함
Connection Draining
-
명칭
- Connection Draining - CLB의 경우
- Deregistration Delay - ALB & NLB의 경우
-
인스턴스가 de-registering(등록 해제) 또는 unhealthy 상태인 동안에 이루어진 "in-flight requests"를 완수하는 시점
-
de-registering 상태인 EC2 인스턴스에 대한 새 요청들을 멈춤
-
1 ~ 3600초 사이 (기본 300초)
-
비활성화 가능 (0초로 설정할 경우)
-
요청들이 짧게 이루어지는 경우라면 낮은 값으로 설정
Auto Scaling Group - ASG
-
현실에서, 웹사이트와 애플리케이션에 대한 부하(load)는 변할 수 있음
-
클라우드에서는 서버를 매우 빠르게 자유롭게 생성하고 앲앨 수 없음
-
Auto Scaling Group (ASG)의 목표는
- Scale out (EC2 인스턴스의 추가) ~ 더 높은 부하에 대응
- Scale in (EC2 인스턴스 제거) ~ 더 낮은 부하에 대응
- 작동 중인 EC2 인스턴스의 최소/최대 개수를 보장
- 하나의 로드 밸런서에 새 인스턴스들을 자동으로 등록(register)
- 이전의 인스턴스가 종료되는 경우(ex. unhealthy인 경우), EC2 인스턴스를 재생성
-
ASG는 무료 (EC2 인스턴스에 대한 값만 지불)
Auto Scaling Group Attributes
- Launch Template (구 Launch Configurations ~ deprecated)
- AMI + Instance Type
- EC2 User Data
- EBS Volumes
- Security Groups
- SSH Key Pair
- IAM Roles for your EC2 Instances
- Network + Subnets Information
- Load Balancer Information
- Min Size / Max Size / Initial Capacity
- Scaling Policies
Auto Scaling - CloudWatch Alarms & Scaling
- CloudWatch 알람에 기반하여 ASG를 스케일링할 수 있음
- 알람은 metric을 모니터함 (Average CPU, 또는 커스텀 metric)
- Average CPU와 같은 Metric들은 ASG 인스턴스 전체에 대하여 계산됨
- 이러한 알람에 기반해서
- scale-out 정책을 생성 (인스턴스 개수 증가)
- scale-in 정책을 생성 (인스턴스 개수 감소)
Auto Scaling Groups - Dynamic Scaling Policies
- Target Tracking Scaling
- 제일 간단하고 셋업하기 쉬움
- 예시: 평균 ASG CPU를 40% 정도에 머무르게 하고 싶음
- Simple / Step Scaling
- CloudWatch 알람이 트리거 될 때 (ex. CPU > 70%), 유닛을 2개 추가
- CloudWatch 알람이 트리거 될 때 (ex. CPU < 30%), 유닛을 하나 제거
Auto Scaling Groups - Scheduled Actions
- 알려진 사용 패턴(known usage patterns)에 기반하여 스케일링을 기대(anticipate)하는 것
- 예시: 금요일 10시부터 5시에는 최소 가용량을 증가시킴
Auto Scaling Groups - Predictive Scaling
- Predictive scaling: 지속적으로 부하를 예측하고, 스케일링을 미리 스케줄함 (머신러닝 기반)
Good metrics to scale on
- CPUUtilization: 인스턴스 전반적인 평균 CPU 활성량
- RequestCountPerTarget: 각 EC2 인스턴스 별 안정적인 요청의 개수를 보장
- Average Network In / Out (애플리케이션이 네트워크 영역(bound)에 있는 경우)
- Any custom metric (CloudWatch를 통해 push 가능)
Auto Scaling Groups - Scaling Cooldowns
- 스케일링 활동이 일어난 이후에는, cooldown period를 갖는다. (기본 300초)
- 이 cooldown period 동안에는, ASG가 추가로 인스턴스를 실행하거나 종료하지 않음 (metrics가 안정화되는 시간을 갖게하기 위해서)
- 조언: ready-to-use AMI를 사용하여 인스턴스의 설정 시간 줄여 요청에 대한 처리를 빠르게 하고, cooldown period를 줄일 수 있도록 하자.
AWS RDS
- RDS : Relational Database Service
- SQL을 쿼리 언어로 사용하는 DB를 위한 관리형 DB 서비스
- AWS를 통해 관리되는 클라우드에 데이터베이스를 생성할 수 있도록 해줌
- Postgres
- MySql
- MariaDB
- Oracle
- Microsoft SQL Server
- Aurora (AWS Proprietary database)
Advantage over using RDS versus deploying DB on EC2
- RDS는 관리형 서비스
- 자동화된 프로비저닝(provisioning), OS 패칭(patching)
- 지속적인 백업과 구체적인 타임스탬프로 복구 (Point in Time Restore)
- 대쉬보드 모니터링
- Read replica -> 읽기 성능 향상
- Multi AZ setup for DR (Disaster Recovery)
- Maintenance windows for upgrades
- Scaling capability (vertical and horizontal)
- 저장소를 EBS로 백업 (gp2 or io1)
- 인스턴스로 SSH 접속을 할 수는 없음
RDS - Storage Auto Scaling
- RDS DB 인스턴스의 가용량(storage)를 동적으로 상승시킬 수 있도록 도와줌
- RDS가 남은 데이터베이스의 가용량이 고갈됨을 확인할 때, 자동으로 스케일을 확장
- DB 스토리지를 수동으로 확장하지 않아도 됨
- Maximum Storage Threshold (DB 스토리지의 최대 한계값)을 설정해야 함
- 다음의 경우들에 스토리지는 자동으로 수정됨
- 할당된 스토리지의 10%보다 남은 가용량이 적은 경우
- 낮은 가용량이 최소 5분 동안 지속되는 경우
- 최근의 수정으로부터 6시간이 지난 이후
- 예측 불가능한 워크로드(unpredictable workloads) 를 가진 애플리케이션에 유용함.
- 모든 RDS DB 엔진에 대해 지원 (MariaDB, MySQL, PostgreSQL, SQL Server, Oracle)
RDS Read Replicas for read scalability
- 5개의 Read Replicas 까지 지원
- Within AZ, Cross AZ 또는 Cross Region (세가지 옵션)
- Replication은 비동기적(ASYNC), 따라서 읽기 작업이 결국 일관적(consistent)이다.
- Replica는 그들 자신의 DB로 승격(promote)될 수 있다.
- 애플리케이션들은 read replica들을 사용하고자 하는 경우 connection string을 업데이트해야만 한다.
RDS Read Replicas - Use Cases
- 일반적인 load를 처리하는 프로덕션 DB를 보유한 경우
- 분석을 위해 리포팅을 하는 애플리케이션을 실행하고 싶다면
- 새로운 workload를 처리하는 Read Replica를 만듬
- 이 때, 프로덕션 애플리케이션에는 영향이 가지 않음
- Read Replica는 오직 SELECT(=read) 관련문만 처리할 수 있음 (not INSERT, UPDATE, DELETE)
RDS Read Replicas - Network Cost
- AWS에서는 하나의 AZ에서 다른 곳으로 전달되는 데이터의 경우에는 network cost가 발생함
- 동일한 region 내에 있는 RDS Read Replica의 경우에는 비용이 부과되지 않음
RDS Multi AZ (Disaster Recovery)
- SYNC replication
- 하나의 DNS 네임 -> automatic app failover 대비
- availability 향상
- AZ의 손실, Network 손실, 인스턴스 또는 스토리지의 실패에 대한 장애조치
- 앱에 대한 어떤 수동적인 개입(intervention)을 하지 않음
- 확장을 위해 사용되지 않음
- 중요: Read Replica는 Disaster Recovery(DR)을 위해 Multi AZ로 셋업될 수 있다.
RDS - From Single-AZ to Multi-AZ
- Zero downtime operation (DB를 멈출 필요 없음)
- 단순히 DB에 대한 "수정(modify)" 버튼을 클릭하기만 하면 됨
- 그러면 아래와 같은 작업들이 내부적으로 일어남
- 스냅샷이 찍힘
- 새 DB가 새로운 AZ에서 앞서 찍은 스냅샷을 통해 복구됨.
- 두 DB 사이에 동기화(synchronization) 작업이 이루어짐.
RDS Custom
- OS와 DB에 대한 커스터마이징이 가능한 관리형 Oracle 또는 Microsoft SQL Server 데이터베이스
- RDS: AWS 내 DB의 설정, 작업, 스케일링의 자동화
- Custom: 그 아래 놓인 DB와 OS에 대한 접근으로, 다음과 같은 일들을 할 수 있음.
- Configure settings
- Install patches
- Enable native features
- SSH 또는 SSM Session Manager를 통해 RDS 아래의 EC2 인스턴스에 접근
- 커스터마이징을 하려면 Automation Mode를 비활성화 해야함 -> 그 전에 DB 스냅샷을 찍어두는 것을 권장
- RDS vs. RDS Custom
- RDS: AWS로부터 관리되는 DB와 OS 전체
- RDS Custom: 그 아래 놓인 OS와 DB에 대해 완전한 어드민 접근
Amazon Aurora
- Aurora는 AWS가 소유한(proprietary) 자체적인 기술 (오픈 소스가 아님)
- Postgres와 MySQL 모두 Aurora DB와 호환됨 (= Aurora를 Postgres나 MySQL DB처럼 사용할 수 있음)
- Aurora는 AWS 클라우드에 최적화되어 있어, RDS의 MySQL보다 5배, RDS의 Postgres보다 3배 더 높은 성능 향상을 기대할 수 있음
- Aurora 스토리지는 자동으로 10GB까지 증가될 수 있으며, 최대 128TB까지.
- Aurora는 15개의 replica를 가질 수 있음. (MySQL은 5개까지) 또한 이러한 replication 과정이 빠름 (10ms 미만의 replica lag)
- Aurora의 장애조치(Failover)는 즉각적(instantaneous)이며, High-Availity(HA) native임.
- Aurora는 RDS보다 높은 가격(20% 이상)이지만, 더 효율적임.
Aurora High Availability and Read Scaling
- 3개의 AZ에 걸쳐 6개의 데이터 사본이 생성됨
- 쓰기 시 6개 중 4개 필요
- 읽기 시 6개 중 3개 필요
- peer-to-peer 복제를 통한 자가 복구
- 스토리지가 100개의 볼륨에 걸쳐 스트라이프 처리
- 하나의 Aurora 인스턴스가 쓰기 작업을 처리 (master)
- 마스터에 대한 장애조치는 30초 이내로 처리됨
- master에 더해, 최대 15개의 Aurora Read Replicas가 읽기 작업을 처리
- Cross Region Replication을 지원함
Aurora DB Cluster
Features of Aurora
- 자동 장애조치
- 백업 및 복구
- 격리(isolation) 및 보안
- 산업 규정 준수 (industry compliance)
- Push-button scaling
- Zero downtime으로 자동화된 패칭(Patching)
- 고급 모니터링(Advanced Monitoring)
- Routine maintenace
- 백트래킹(Backtrack): 백업 없이 특정한 시점으로 데이터를 복구
Aurora Replicas - Auto Scaling
Aurora - Custom Endpoints
- Aurora Instance의 부분 집합을 커스텀 엔드포인트로 정의
- Ex. 특정 replica(사본)들에서만 분석 쿼리를 실행
- Custom Endpoint의 정의 이후에 일반적으로 Reader Endpoint는 사용되지 않음.
Aurora Serverless
- 실제 사용에 기반한 자동화된 데이터베이스 인스턴스화(Instantiation)와 오토 스케일링
- 비주기적(infrequent)이고, 간헐적(intermittent)이며, 예측 불가능한(unpredictable) 워크로드의 경우에 유용함
- capacity planning(특정 capacity를 선택해야 할 필요)가없음
- 매초마다 비용을 지불하며, 더 비용 효율적임(cost-effective).
Aurora Multi-Master
- write node 에 대한 즉각적인 failover를 원할 때 (high availability)
- 모든 노드가 R/W를 수행 - vs. 하나의 Read replica를 새로운 master로 승격
Global Aurora
- Aurora Cross Region Read Replicas:
- disaster recovery(장애 조치)에 유용
- 설치가 간편함
- Aurora Global Database (recommended):
- 하나의 주요 리전 (Read / Write)
- 5개 까지의 보조 리전(Read 전용), 복제에 걸리는 지연 시간이 1초 미만임
- 각 보조 리전 당 16개까지의 Read Replica
- latency 감소에 도움
- 또다른 리전 승격(장애 조치 목적)은 RTO(= Recovery Time Objective)가 1분 미만
- 일반적인 cross-region 복제는 1초 미만이 소요됨
Aurora Machine Learning
- SQL을 통해 애플리케이션에 대한 ML 기반 예측 활성화
- 간단하고, 최적화되어 있으며, 안전하게 통합된 Aurora와 AWS ML 서비스
- 지원되는 서비스
- Amazon SageMaker (어떤 ML 모델도 사용 가능)
- Amazon Comprehend (감성 분석 용도)
- 굳이 ML 경험을 필요로 하지 않음
- 이용 사례: 사기 감지(fraud detection), 광고 타겟팅(ads targeting), 감성 분석(sentiment analysis), 제품 추천(product recommendation)
Backups
RDS Backups
- Automated backups:
- 매일 데이터베이스 전체를 백업 (window 백업 도중)
- 매 5분마다 Transaction log가 RDS에 의해 백업됨
- 백업된 시점의 어디로든 복구가 가능함 (가장 오래된 백업부터, 최소 5분 전까지)
- 1일에서 35일까지 보존(retention), 0으로 설정하면 automated backup을 비활성화
- Manual DB Snapshots
- 말 그대로 수동으로 백업
- 원하는 기간만큼 백업 보존
- 요령: RDS 데이터베이스를 중지하더라도, 여전히 storage에 대한 비용은 청구됨. 따라서, DB를 오랜기간 동안 중지하고자 한다면, 중지하는 대신에 스냅샷을 찍어놓고, 복구를 시키는 편이 더 좋음.
Aurora Backups (RDS와 유사)
-
Automated backups:
- 1일에서 35일까지 (비활성화 불가능)
- 위 해당 기간의 특정 시점으로 복구 가능
-
Manual DB Snapshots
- 수동으로 백업
- 원하는 기간만큼 백업 보존
RDS & Aurora Restore Options
- RDS / Aurora 백업 또는 스냅샷의 복구는 새로운 DB를 생성함
- S3로부터 MySQL RDS DB를 복구
- 온-프로미스 DB의 백업을 생성
- AWS S3에 이를 저장
- MySQL을 실행하는 새로운 RDS 인스턴스로 백업 파일을 복구
- S3로부터 MySQL Aurora cluster를 복구
- Percona XtraBackup을 사용하는 온-프로미스 DB의 백업 생성
- AWS S3에 이를 저장
- MySQL을 실행하는 새로운 Aurora cluster로 백업 파일을 복구
Aurora Database Cloning
- 기존에 갖고있던 Aurora DB 클러스터를 새로 복제하여 생성
- 스냅샷을 찍고 이를 복구하는 것보다 빠름
- copy-on-write 프로토콜을 사용
- 최초에 새로 생성된 DB 클러스터는 기존의 DB클러스터와 동일한 데이터 볼륨을 사용함 (빠르고 효율적임 -> 데이터를 따로 복제할 필요가 없음)
- 새로운 DB 클러스터 데이터에 업데이트가 이루어지면, 그제서야 새로운 스토리지가 할당되고, 데이터가 복제 및 분리됨
- 매우 빠르고 비용 효율적
- 프로덕션 DB에는 영향을 주지 않으면서 프로덕션 DB로부터 staging DB를 새로 생성하기에 유용함
RDS & Aurora Security
-
At-rest encryption
- DB master & replica는 AWS KMS를 통해 암호화됨 -> 실행 시점에 정의되어야 함
- master가 암호화되지 않았다면, read replica도 암호화될 수 없음
- 암호화되지 않은 DB를 암호화하려면, DB 스냅샷을 찍은 후, 암호화된 형태로 복구해야 함
-
In-flight encryption
- 기본적으로 TLS-ready, 클라이언트 측에서는 AWS TLS 루트 인증서를 사용
-
IAM Authentication
- DB에 접속하기 위한 IAM role (username/pw 대신)
-
Security Groups
- RDS / Aurora DB에 대한 네트워크 접근을 통제
-
RDS Custom이 아니라면 SSH 접근은 허용하지 않음
-
Audit Logs를 활성화한다면 더 장기적인 보관을 위해 Cloudwatch Log로 전송할 수 있음
RDS Proxy
- RDS를 위한 완전 관리형(Fully managed) DB 프록시
- 앱들이 pooling 하거나, DB에 연결된 DB 커넥션들을 공유할 수 있도록 해줌
- DB 리소스들에 대한 부하를 줄이고, open connection들을 최소화(+ timeout 추가) 함으로써 DB 효율성을 높임
- 서버리스, 오토스케일링, High available (multi-AZ)
- RDS & Aurora의 failover 시간을 최대 66%까지 단축
- RDS(MySQL, PostgreSQL, MariaDB)와 Aurora(MySQL, PostgreSQL)에 대해 지원
- 대부분 앱의 경우, 별도로 요구되는 코드 변경이 없음
- DB에 대한 IAM 인증을 강화하며, AWS Secrets Manager 내에 안전하게 credential들을 저장
- RDS Proxy는 절대 공개적으로 접근할 수 없음 (반드시 VPC를 통해 접근되어야 함)
ElastiCache
ElastiCache Overview
- ElastiCache는 관리형 Redis 또는 Memcached라고 할 수 있다.
- Cache는 인-메모리(in-memory) DB로, 매우 높은 성능과 낮은 레이턴시를 보유한다.
- 읽기 집약적인 워크로드에 대한 데이터베이스 부하를 줄이는 데에 도움을 준다.
- 애플리케이션을 무상태성(stateless)으로 만드는데에 도움을 준다.
- AWS는 OS 유지 보수 / 패칭(patch), 최적화, 설정, 구성, 모니터링, 장애 조치 및 백업을 관리해준다.
- ElastiCache의 사용은 애플리케이션에 많은 코드 변경을 요구한다.
- 과정
- 애플리케이션이 ElastiCache에 쿼리를 보내고, 그것이 처리가 불가능하다면, 그 결과를 RDS로부터 가져와 ElastiCache에 저장
- 이로부터 RDS에 대한 부하를 완화할 수 있음
- 가장 최신의 데이터가 사용될 수 있도록, 캐시는 반드시 비활성화 전략을 갖추어야 함
ElastiCache - User Session Store
- 과정
- 이용자가 애플리케이션의 어디로든 로그인
- 애플리케이션이 ElastiCache에 세션 데이터를 작성
- 이용자가 애플리케이션의 다른 인스턴스로 접근
- 해당 인스턴스는 앞서 작성한 세션 데이터를 검색하여 사용하고, 이에 따라 이용자는 로그인 상태를 유지할 수 있음
ElastiCache - Redis vs Memcached
-
Redis
- Auto-failover와 함께 Multi AZ
- 읽기 확장(scale read)를 위한 Read Replica와 함께, High availability를 보유
- AOF persistence를 통한 Data Durability(데이터 내구성)
- 백업과 복구 기능
- Sets와 Sorted Sets 기능 지원
-
Memcached
- 데이터의 파티셔닝을 위한 멀티 노드 (sharding)
- High availability (replication) 없음
- 비영구적 (Non persistent)
- 백업 및 복구 기능 없음
- 멀티 쓰레드 기반의 아키텍처
ElastiCache - Cache Security
- ElastiCache는 Redis에 대한 IAM 인증을 지원
- ElastiCache에 대한 IAM 정책은 오직 AWS API 레벨의 보안을 위해서만 사용됨
- Redis AUTH
- Redis 클러스터 생성 시 "password/token"을 설정할 수 있음
- 이는 캐시에 대한 추가 보안 수준에 해당함 (보안 그룹에 더해서)
- flight encryption 내 SSL 지원
- Memcached
- SASL 기반의 인증 지원
Patterns for ElastiCache
- Lazy Loading: 모든 읽기 데이터가 캐시되며, 이에 따라 데이터가 캐시 내에서 stale한 상태가 될 수 있음
- Write Through: DB 내에 쓰기 작업이 이루어 질때, 캐시에 데이터를 추가하거나 업데이트 (stale data가 없음)
- Session Store: 캐시 내에 일시적인 세션 데이터를 저장 (TTL 기능을 사용)
There are only two hard things in Computer Science: cache invalidation and naming things
ElastiCache - Redis Use Case
- 게임 리더보드 -> 계산하기 복잡함
- Redis Sorted Sets는 요소의 고유함(uniqueness)와 순서를 보장함
- 새로운 요소가 추가될 때마다, 랭킹이 실시간으로 반영되고, 이에 따라 올바른 순서로 추가됨
List of Ports to be familiar with
-
아래는 최소한 한번쯤 봤을 법한, 일반적인 포트 번호의 목록이며, 이를 외울 필요는 없지만, 주요한 포트 번호들과 DB 포트를 각각 구분할 줄은 아는 것이 좋다.
-
Important ports:
- FTP: 21
- SSH: 22
- SFTP: 22 (SSH와 동일)
- HTTP: 80
- HTTPS: 443
-
RDS Databases ports:
- PostgreSQL: 5432
- MySQL: 3306
- Oracle RDS: 1521
- MSSQL Server: 1433
- MariaDB: 3306 (MySQL과 동일)
- Aurora: 5432(postgreSQL 호환의 경우) 또는 3306 (MySQL 호환의 경우)
Route 53
What is DNS?
- DNS은 IP 주소를 인간 친화적인 형태의 호스트네임으로 변환해주는 역할을 한다.
- ex.)
www.google.com
->172.217.18.36
- DNS는 인터넷의 백본(backbone)이다.
- DNS는 계층적 네이밍 구조를 사용한다.
DNS Terminologies
- Domain Registrar: Amazon Route 53, GoDaddy, ...
- DNS Records: A, AAAA, CNAME, NS, ...
- Zone File: DNS 레코드를 포함
- Name Server: DNS 쿼리를 처리 (Authoritative or Non-Authoritative)
- Top Level Domain (TLD): .com, .us, .in, .gov, .org, ...
- Second Level Domain (SLD): amazon.com, google.com, ...
How DNS Works
Amazon Route 53
- highly available하고, scalable한, 완전 관리형 권한 보유(authoritative) DNS
- authoritative = 고객(나)이 DNS 레코드를 갱신할 수 있음
- Route53은 또한 Domain Registrar이기도 함.
- 보유한 리소스에 대한 health check가 가능
- 100% availability SLA를 제공하는 유일한 AWS 서비스
- 왜 Route53이란 이름일까? -> 53은 전통적인 DNS 포트넘버
Route 53 - Records
-
레코드 -> 도메인에 대한 트래픽을 라우팅하는 방법
-
각각의 record는 다음을 보유함
- Domain/subdomain Name -> ex. example.com
- Record Type -> ex. A or AAAA
- Value -> ex. 12.34.56.78
- Route Policy -> Route53이 쿼리에 대응하는 방식
- TTL -> DNS resolver들에 레코드가 캐시되는 시간
-
Route53은 아래의 DNS 레코드 타입들을 지원함
- (필수) A / AAAA / CNAME / NS
- (고급) CAA / DS / MX / NAPTR/ PTR / SOA / TXT / SPF/ SRV
Route 53 - Record Types
- A - 호스트네임을 IPv4로 매핑
- AAAA - 호스트네임을 IPv6로 매핑
- CNAME - 호스트네임을 또 다른 호스트네임으로 매핑
- 매핑한 대상은 반드시 A 또는 AAAA 레코드를 갖고 있어야 함
- CNAME을 DNS 네임스페이스의 최상위 노드에서 생성할 수는 없음 (Zone Apex)
- ex.
example.com
에서는 만들수 없지만,www.example.com
에서는 만들 수 있음
- NS - Hosted Zone에 대한 네임 서버
- 한 도메인에 대한 트래픽을 어떻게 라우트할지 통제함
Route 53 - Hosted Zones
- 하나의 도메인과 그것의 서브도메인에 대한 트래픽을 어떻게 라우팅할 것인지 정의하는 레코드들의 컨테이너
- Public Hosted Zones - 인터넷(public domain names)에서 트래픽을 어떻게 라우팅할지 명시하는 레코드들을 보유 -> ex.
application1.mypublicdomain.com
- Private Hosted Zones - 하나 또는 그 이상의 VPC(private domain names)에서 트래픽을 어떻게 라우팅할지 명시하는 레코드들을 보유 -> ex.
application1.company.internal
- 각 hosted zone마다 한달에 0.50 만큼 지불
Route 53 - Records TTL (Time To Live)
- High TTL - ex. 24hr
- Route 53에 낮은 트래픽을 전달
- Record가 최신의 것이 아닐 가능성이 있음
- Low TTL - ex. 60sec
- Route53에 더 많은 트래픽 (그에 따라 더 많은 비용)
- Record를 최신으로 유지하기 쉬움 -> Record 변경이 쉬움
- Alias 레코드를 제외하면, TTL은 각각의 DNS 레코드에 의무적으로 필요함
CNAME vs Alias
- AWS 리소스(Load Balancer, CloudFront...)들은 AWS 호스트 네임을 갖고 있음
lbl-1234.us-east-2.elb.amazonaws.com
- CNAME:
- 하나의 호스트네임을 다른 호스트네임을 가리키도록 함 (
app.mydomain.com
->blabla.anything.com
) - 오직 루트가 아닌 도메인에 대해서만 사용 가능 (aka. somthing.mydomain.com)
- 하나의 호스트네임을 다른 호스트네임을 가리키도록 함 (
- Alias:
- 하나의 호스트네임을 AWS 리소스를 가리키도록 함 (
app.mydomain.com
->blabla.amazonaws.com
) - 루트 도메인 여부와 상관없이 사용 가능 (aka. mydomain.com)
- 비용 청구 없음
- 자체적인 헬스 체크
- 하나의 호스트네임을 AWS 리소스를 가리키도록 함 (
Route 53 - Alias Records
- AWS 리소스에 대한 호스트네임을 매핑
- DNS 기능에 대한 확장
- 자동으로 AWS 리소스의 IP 주소 변경을 감지
- CNAME과 다르게, DNS 네임스페이스의 최상위 노드 (Zone Apex)에서도 사용할 수 있음 -> e.g:
example.com
- Alias 레코드는 항상 AWS 리소스를 가리키는 A/AAAA 타입이어야 함 (IPv4 / IPv6)
- TTL을 설정할 수 없음 ~ Route 53 자체적으로 알아서 처리
Route 53 - Alias Records Targets
- Elastic Load Balancers
- CloudFront Distributions
- API Gateway
- Elastic Beanstalk environment
- S3 Websites
- VPC Interface Endpoints
- Global Accelerator accelerator
- 동일한 hosted zone 내의 Route53 record
- EC2 DNS 네임을 ALIAS 레코드로 지정할 수는 없음
Route 53 - Routing Policies
- Route 53이 DNS 쿼리에 어떻게 응답할지 정의
- "Routing"이라는 명칭으로 인해 혼동하지 말 것
- LB 관점에서의 트래픽 라우팅과는 다름
- DNS는 어떤 트래픽도 라우트하지 않음, 단지 DNS 쿼리에 응답을 해줄 뿐임
- Route 53은 아래의 Routing 정책들을 지원함
- Simple
- Weighted
- Failover
- Latency based
- Geolocation
- Multi-Value Answer
- Geoproximity (using Route 53 Traffic Flow feature)
Routing Policies - Simple
- 일반적으로, 트래픽을 하나의 리소스로 라우팅
- 동일한 레코드에 여러 값을 정의할 수도 있음
- 만약, 여러 값이 정의된 경우, 클라이언트에 의해 무작위 값 하나가 선택됨
- Alias가 활성화된 경우, 하나의 AWS만 정의할 수 있음
- 헬스 체크에 연결할 수 없음
Routing Policies - Weighted
- 각각의 특정 리소스마다 요청 비율을 관리
- 각각의 레코드에 상대적 가중치를 할당 (합계가 꼭 100이어야 할 필요 없음)
- DNS 레코드들은 반드시 동일한 이름과 타입을 지녀야 함
- 헬스 체크에 연결할 수 있음
- 유스 케이스: 리전 간의 로드 밸런싱, 새로운 애플리케이션 버전에 대한 테스트
- 가중치를 0으로 할당하는 경우, 레코드는 해당 리소스로는 트래픽을 전송하지 않음
- 만약 모든 레코드의 가중치가 0이라면, 모든 레코드들이 동일한 가중치로 반환됨
Routing Policies - Latency-based
- 이용자에게 가장 적은 레이턴시를 제공할 수 있는(아마도 가장 가까운) 리소스로 리다이렉트
- 이용자들에 대한 레이턴시 보장이 최우선인 경우 매우 유용
- 레이턴시는 이용자와 AWS 리전 사이의 트래픽에 근거하여 결정됨
- e.g.) 독일인 이용자는 아마 US로 리다이렉트될 것 (만약 그쪽이 제일 레이턴시가 낮다면)
- 헬스 체크에 연결될 수 있음 (failover capability ~ 장애조치 기능)
Routing Policies - Failover (Active-Passive)
- 먼저 주 인스턴스(primary)에 헬스체크를 시도한 이후, 그것이 실패하면 장애 조치 목적의 보조 인스턴스(secondary)로 라우팅
Routing Policies - Geolocation
- Latency-based와는 차이가 있음
- 이용자의 위치 자체에 근거하여 결정됨
- 대륙, 국가, 또는 주(미국)으로 위치를 정의 (겹치는 상황이라면, 가장 좁은 범위로 선택됨)
- 기본 레코드를 생성하는 편이 좋음 (해당하는 위치가 없는 경우)
- 사례 - 웹사이트 localization, 컨텐츠 배포를 제한, 로드 밸런싱, ...
- 헬스 체크와 연결 가능
Geoproximity Routing Policies - Geoproximity
-
이용자와 리소스의 지리적인 위치에 기반하여 트래픽을 라우팅
-
정의된 바이어스(bias)에 기반하여 더 많은 트래픽을 각 리소스에 할당
-
지리학적 지역의 크기를 변경하려면, 바이어스의 값을 정의
- 확장(expand)하고자 하는 경우 (1 ~ 99): 해당 리소스에 더 많은 트래픽
- 축소(shrink)하고자 하는 경우 (-1 ~ -99): 해당 리소스에 더 적은 트래픽
-
리소스는 아래와 같은 것이 될 수 있음
- AWS 리소스 (AWS 리전 명시)
- Non-AWS 리소스 (위도, 경도 명시)
-
해당 기능을 사용하기 위해서는 반드시 Route 53 Traffic Flow(고급)를 사용해야 함
Routing Policies - IP-based Routing
- 클라이언트의 IP 주소에 기반하여 라우팅
- 클라이언트의 CIDR와 엔드포인트/로케이션 목록을 제공 (이용자 IP-엔드포인트 매핑)
- 사례: 성능 최적화, 네트워크 비용 감축
- e.g., 특정 ISP로부터의 엔드 유저를 특정 엔드포인트로 라우팅
Routing Policies - Multi-Value
- 트래픽을 여러 개의 리소스에 라우팅하고자 할 때 사용
- Route 53이 여러 개의 값/리소스를 반환
- 헬스 체크 연결 가능 (healthy resources에 대한 값들만 반환해줌)
- 각각의 Multi-Value 쿼리에 대해 최대 8개까지의 healthy record만 반환됨
- Multi-Value가 ELB를 대체할 수는 없음
Route 53 - Health Checks
-
HTTP 헬스 체크는 오직 public 리소스 들에만 적용 가능
-
헬스 체크 -> 자동화된 DNS 장애 조치:
- 엔드포인트에 대한 모니터링 (애플리케이션, 서버, 그 외 AWS 리소스)
- 다른 헬스 체크에 대한 모니터링 (Calculated Health Checks)
- CloudWatch 알람(완전 제어)에 대한 모니터링 - e.g., DynamoDB 쓰로틀링, RDS 알람, 커스텀 메트릭스, ... (private 리소스들의 경우에 유용)
-
헬스체크는 CloudWatch metrics와 함께 사용될 수 있음
Health Checks - Monitor an Endpoint
- 약 15개의 글로벌 헬스 체커가 엔드포인트 헬스 상태를 체크
- Healthy/Unhealthy Threshold - 3 (default)
- Interval - 30초 (10초로도 지정 가능 - 더 높은 비용)
- 지원 프로토콜: HTTP, HTTPS, TCP
- 약 18% 이상의 헬스 체커가 엔드포인트가 healthy 하다고 리포트한다면, Route 53은 이를 Healthy하다고 간주, 그렇지 않다면, Unhealthy
- Route 53이 헬스 체크에 사용할 위치(location)이 어디인지 선택할 수 있음
- 헬스 체크는 오직 엔드포인트가 2xx, 3xx 상태 코드를 응답할 때만 통과
- 헬스 체크는 응답의 첫 5120 bytes의 텍스트에 기반하여 pass / fail 상태를 설정 (특정 텍스트를 체크)
- Route 53 헬스 체커로부터 오는 요청은 라우터/방화벽이 허용하도록 설정해야 함
Health Checks - Calculated Health Checks
- 여러 개의 헬스 체크 결과를 하나의 헬스 체크로 통합
- OR, AND, NOT 조건을 사용 가능
- 최대 256개의 자식 헬스 체크(child health check)를 모니터링할 수 있음
- 부모의 헬스 체크를 통과시키려면 얼마나 많은 자식의 헬스 체크가 필요한지 정의 가능
- e.g., 모든 헬스 체크가 실패하도록 하지 않으면서 웹사이트를 유지보수하고자 할 때
Health Checks - Private Hosted Zones
- Route 53 헬스체커는 VPC 외부에 존재
- 따라서 private 엔드포인트에 접근할 수 없음 (private VPC 또는 온-프로미스 리소스)
- 이에 대처하기 위해, CloudWatch Metric을 만들고, 여기에 CloudWatch Alarm을 연결 후 해당 알람 자체를 체크하는 헬스 체크를 만들면 됨
Domain Registar vs. DNS Service
- Domain Registrar != DNS Service
- 일반적으로 매년 비용을 청구하며 Domain Registrar로부터 도메인 네임을 구매하거나 등록
- Domain Registrar는 보통 DNS 레코드 관리를 위해 DNS 서비스도 함께 제공하는 경우가 많음
- 하지만, DNS 레코드 관리를 위해 다른 DNS 서비스를 사용할 수도 있음
- e.g.) GoDaddy에서 구매한 도메인을 Route 53에서 DNS 레코드 관리
- 어떻게?
- Route 53에 Hosted Zone을 생성
- 써드파티 웹사이트(도메인 제공 업체 ~ ex. GoDaddy)에서 NS 레코드를 Route 53의 네임 서버로 변경
Elastic Beanstalk
Instantiating Applications quickly
- EC2 인스턴스
- Golden AMI 사용: 애플리케이션 설치, OS 의존성 등을 미리 처리한 Golden AMI로부터 바로 EC2 인스턴스를 실행
- User Data를 사용한 부트스트랩: 동적인 환경 설정에 대해서는 User Data 스크립트를 사용
- Hybrid: Golden AMI와 User Data를 함께 사용 (Elastic Beanstalk)
- RDS 데이터베이스
- 스냅샷으로부터 복구: 동일한 스키마와 데이터를 가진 데이터베이스를 만들 수 있음
- EBS 볼륨
- 스냅샷으로부터 복구: 이미 디스크가 포맷되고, 데이터를 가진 상태로 시작
Developer problems on AWS
-
인프라(infrastructure) 관리
-
코드 배포
-
데이터베이스, 로드 밸런서 등의 설정
-
스케일링
-
대부분의 웹앱은 동일한 아키텍처를 가짐 (ALB + ASG)
-
모든 개발자들이 원하는 것은 단순히 직접 작성한 코드가 실행되길 원함
-
또, 여러 다른 애플리케이션과 환경을 오고가는 상황에서, 내 애플리케이션을 한가지 방법으로 쉽게 배포하고자 할 수 있음
Elastic Beanstalk - Overview
- Elastic Beanstalk는 AWS에서의 애플리케이션 배포에 있어 개발자를 위한 서비스
- 앞서 봤던 모든 컴포넌트들을 사용함: EC2, ASG, ELB, RDS, ...
- 관리형 서비스
- 다음의 것들을 자동으로 처리해줌 - 용량 프로비저닝(capacity provisioning), 로드 밸런싱, 스케일링, 애플리케이션 헬스 모니터링, 인스턴스 설정, ...
- 개발자가 신경쓸 것은 단순히 애플리케이션 코드 뿐임
- 여전히 설정에 대한 모든 통제는 갖고 있음
- Beanstalk 자체는 무료지만, 여기서 사용되는 인스턴스들에 대한 가격은 지불함
Elastic Beanstalk - Components
- Application: Elastic Beanstalk 컴포넌트의 집합 (환경, 버전, 설정, ...)
- Application Version: 애플리케이션 코드의 iteration
- Environment
- 애플리케이션 버전을 작동시키는 AWS 리소스들의 집합 (오직 한번에 하나의 애플리케이션 버전만)
- Tiers: 웹 서버 환경 티어 & 워커 환경 티어
- 여러 개의 환경을 만들 수 있음 (dev, test, prod, ...)
Elastic Beanstalk - Worker environments
Elastic Beanstalk - Supported Platforms
- Go, Java, .NET, Node.js 등등등..
- Packer Builder
- Single Container Docker
- Multi Container Docker
- Preconfigured Docker
- 만약, 해당하는 게 없다면 별도로 커스텀 플랫폼을 작성할 수도 있음 (고급)
Elastic Beanstalk - Deployment Modes
- Single Instance - dev 환경에 좋음
- High Availability with Load Balancer - prod 환경에 좋음
S3
- Amazon S3는 AWS의 주요한 서비스로, "무한하게 확장하는" 스토리지로 광고됨.
- 많은 웹사이트들이 S3를 백본으로 사용
- AWS 서비스들도 S3를 통합(integration)으로 사용함
S3 - Use cases
- 백업 및 저장소
- 장애 복구
- 아카이빙
- 하이브리드 클라우드 저장소
- 애플리케이션 호스팅
- 미디어 호스팅
- 데이터 레이크 & 빅데이터 분석
- 소프트웨어 배포
- 정적 웹사이트
S3 - Buckets
- AWS S3는 사람들이 objects(파일)들을 버킷(directories) 안에 저장할 수 있도록 해줌
- 버킷은 반드시 전역적으로 고유한 이름을 가져야 함 (모든 리전과 모든 계정 통틀어서)
- 버킷은 리전 수준에서 정의됨
- S3는 마치 글로벌 서비스처럼 보이지만 사실 리전 내에 생성됨
- 네이밍 컨벤션
- uppercase 안됨, underscore 안됨
- 3-63자
- IP 불가
- 반드시 숫자 또는 lowercase 글자로 시작되어야 함
- **xn--**으로 시작하는 prefix(접두사)를 붙일 수 없음
- -s3alias로 끝나는 suffix(접미사)를 붙일 수 없음
S3 - Objects
- objects(파일)은 하나의 key를 가짐
- key는 FULL path
s3://my-bucket/my_file.txt
s3://my-bucket/my_folder1/another_folder/my_file.txt
- key는 prefix + object name 의 조합
s3://my-bucket/my_folder1/another_folder/my_file.txt
my_folder1/another_folder/
-> prefixmy_file.txt
-> object name
- 버킷에는 "디렉토리" 라는 개념은 없음 (UI가 마치 디렉토리처럼 느껴지도록 할 수는 있지만)
- 단순히 슬래시(
/
)를 포함한 매우 긴 key가 있을 뿐임
- 단순히 슬래시(
S3 - Objects content
- Object value -> body의 content
- 최대 Object 사이즈는 5TB (5000GB)
- 만약 5GB 이상의 것을 업로드 한다면, 반드시 "multi-part upload"를 사용해야 함
- Metadata (텍스트 key-value 페어 목록 - 시스템 또는 이용자 메타데이터)
- Tags (유니코드 key-value 페어 - 10개까지) - 보안 / 라이프사이클에 유용
- Version ID (버저닝이 활성화된 상태에서만 존재)
S3 - Security
- User-Based
- IAM Policies - IAM으로부터 특정 이용자에 대한 API 호출이 허용되어야 함
- Resource-Based
- Bucket Policies - S3 콘솔을 통해 버킷 전반에 적용되는 룰(bucket wide rules)를 정의 -> 다른 계정으로부터의 호출을 허용
- Object Access Control List (ACL) - 더 미세함 (비활성화 가능)
- Bucket Access Control List (ACL) - 덜 일반적임 (비활성화 가능)
- 중요: IAM 책임자(principal)은 다음과 같은 경우라면 S3 오브젝트에 접근할 수 있음
- 해당 이용자의 IAM 권한이 접근을 허용하거나 또는 리소스 규칙(resouce policy)가 접근을 허용
- 그리고 명시적인 거부(DENY)가 없어야 함
- Encryption: S3 내 오브젝트를 encryption key를 통해 암호화
S3 - Bucket Policies
- JSON based policies
- Resources: 버킷과 오브젝트
- Effect: Allow / Deny
- Actions: 각 API에 대한 Allow / Deny 목록
- Principal: 규칙을 적용할 계정 또는 이용자
- 다음과 같은 경우에 S3 버킷에 대한 policy를 사용
- 버킷에 대한 퍼블릭 액세스를 허용하고자 하는 경우
- 업로드 시점에 오브젝트가 암호화되도록 강제하고자 할 경우
- 다른 계정의 액세스를 허용하고자 하는 경우 (Cross Account)
S3 - Bucket settings for Block Public Access
- 위 옵션들은 기업의 데이터 유출을 막기 위해 생성됨
- 따라서, 절대 퍼블릭 액세스를 허용해선 안되는 버킷의 경우, 위의 옵션을 유지해둘 것
- 계정(account) 수준에서 적용 가능
S3 - Static Website Hosting
-
S3으로는 정적 웹사이트 호스팅을 할 수 있으며, 그것을 인터넷의 누구나 접근할 수 있도록 만들 수 있음
-
웹사이트 URL은 다음과 같음 (리전마다 차이) ~ 외울 필요는 없다
-
만약 위의 웹사이트에서 403 Forbidden 에러가 뜬다면, 버킷의 policy가 퍼블릭 읽기 권한을 허용하고 있는지 확인할 것
S3 - Versioning
- S3에 있는 파일들에 버저닝(versioning)을 할 수 있음
- 버킷 레벨에서 활성화하는 것도 가능
- 동일한 key에 대한 덮어쓰기 작업은 key의 버전을 바꾸게 됨 ~
"version": 1, 2, 3...
- 버킷에다 버저닝을 하는 것이 베스트 프랙티스
- 의도치 않은 삭제 작업으로부터 보호 가능 (특정 버전으로 복구할 수 있음)
- 이전 버전으로 롤백하기가 쉬움
- 중요:
- 버전 활성화를 할 때, 이전에 버저닝이 되지 않았던 파일들은
null
버전을 갖게 됨 - 버전의 일시중단(suspend) 작업이 이전 버전들을 삭제하지는 않음
- 버전 활성화를 할 때, 이전에 버저닝이 되지 않았던 파일들은
S3 - Replication (CRR & SRR)
- source 버킷과 destination 버킷들에 대해 버저닝이 활성화되어 있어야 함
- Cross-Region Replication (CRR)
- Same-Region Replication (SRR)
- 다른 AWS 계정에 있는 버킷도 가능
- 복사 작업은 비동기적
- 반드시 S3에게 적절한 IAM 권한을 줘야함
- 사례
- CRR - 규정 준수, 낮은 레이턴시 접근, 계정이 다른 상황에서의 복제
- SRR - 로그 집계, 프로덕션 및 테스트 계정 간의 실시간 복제
S3 - Replications (Notes)
- 복제를 활성화한 이후, 오직 새로운 오브젝트만 복제됨
- 선택적으로, S3 Batch Replication을 사용하면 기존에 존재하던 오브젝트에 대해서도 복제를 수행할 수 있음
- 기존에 존재하던 오브젝트나 복제에 실패한 오브젝트를 복제
- DELETE 작업의 경우
- source로부터 target에 delete marker들을 복제할 수 있음 (선택 사항)
- version ID로 삭제한 내용은 복제되지 않음 (malicious delete를 막기 위해)
- 복제에 "체이닝"은 없음
- 만약 버킷1을 버킷2에 복제하고, 이 버킷2를 버킷3로 복제했을 때
- 버킷1에 있는 오브젝트들이 버킷3에 복제되진 않음
- 만약 버킷1을 버킷2에 복제하고, 이 버킷2를 버킷3로 복제했을 때
S3 - Storage Classes
-
Amazon S3 Standard - General Purpose
-
Amazon S3 Standard-Infrequent Access (IA)
-
Amazon S3 One Zone-Infrequent Access
-
Amazon S3 Glacier Instant Retrieval
-
Amazon S3 Glacier Flexible Retrieval
-
Amazon S3 Glacier Deep Archive
-
Amazon S3 Intelligent Tiering
-
각 클래스를 수동으로 변경하거나, S3 라이프사이클 설정을 통해 변경할 수 있음
S3 - Durability and Availability
-
Durability:
- 여러 AZ 간에 오브젝트의 높은 durability (99.999999999%) 보장
- 만약 S3에 10,000,000 개의 오브젝트를 저장한다고 할 때, 평균적으로 10,000년에 한 번씩 하나의 오브젝트가 손실될 것으로 예상 가능함 (아무튼 드물다는 뜻)
- 모든 스토리지 클래스에 대해 동일
-
Availability (가용성)
- 서비스가 말그대로 이용 가능한 상태인지의 여부
- 스토리지 클래스에 따라 달라짐
- ex. S3 Standard는 99.99%의 availability를 가짐 => 즉, 1년에 53분 정도는 not available함
S3 Standard - General Purpose
- 99.99% Availability
- 자주 액세스되는 데이터에 사용
- 낮은 latency와 높은 throughput
- 2개의 concurrent facility failure를 가짐 => 2개의 데이터 센터에 문제가 생겨도, 다른 데이터 센터에서 대처가 가능
- 사례: 빅데이터 분석, 모바일 & 게임 앱, 컨텐츠 전송(content distribution), ...
S3 Storage Classes - Infrequent Access
-
액세스 빈도가 덜하지만, 필요하다면 빠르게 액세스 되어야 하는 데이터에 사용
-
S3 Standard보다 낮은 비용
-
Amazon S3 Standard-Infrequent Access (S3 Standard-IA)
- 99.9% Availability
- 사례: 장애 복구, 백업
-
Amazon S3 One Zone-Infrequent Access (S3 One Zone-IA)
- 하나의 AZ 내에서 High durability(99.999999999%, 9가 11개), AZ가 파괴되면 데이터가 사라짐
- 99.5% Availability
- 사례: 온-프레미스(on-premise) 데이터의 보조 백업 복사본에 대한 저장, 또는 재생성 가능한 데이터
S3 Glacier Storage Classes
-
아카이빙 / 백업 의도의 낮은 비용 오브젝트 저장소
-
비용: 스토리지 비용 + 오브젝트 조회(retrieval) 비용
-
Amazon S3 Glacier Instant Retrieval
- 밀리세컨드(ms) 단위 조회, 분기 별로 한번 액세스되는 데이터의 경우에 좋음
- 저장소의 최소 지속시간 90일
-
Amazon S3 Glacier Flexible Retrieval (Amazon S3 Glacier 이전 버전)
- 데이터 조회에 걸리는 시간
- Expedited (1 ~ 5분), Standard (3 ~ 5시간), Bulk (5 ~ 12시간) - 무료
- 저장소의 최소 지속시간 90일
- 데이터 조회에 걸리는 시간
-
Amazon S3 Glacier Deep Archive - 장기간 보관에 사용:
- 데이터 조회에 걸리는 시간
- Standard (12 시간), Bulk (48 시간)
- 저장소의 최소 지속시간 180일
- 데이터 조회에 걸리는 시간
S3 Intelligent-Tiering
- 매달 약간의 모니터링 및 auto-tiering 비용을 지불
- 사용 패턴에 따라 Access Tier 간에 오브젝트들을 자동으로 이동시킴
- 별도로 조회(retrieval) 비용이 존재하지 않음
- 티어 종류
- Frequent Access tier (자동): 기본 티어
- Infrequent Access tier (자동): 30일 동안 액세스하지 않은 오브젝트
- Archive Instant Access tier (자동): 90일 동안 액세스하지 않은 오브젝트
- Archive Access tier (선택): 90일에서 700+일 사이로 설정 가능
- Deep Archive Access tier (선택): 180일에서 700+일 사이로 설정 가능
S3 - Moving between Storage Classes
- 오브젝트들을 각 스토리지 클래스 간에 전환할 수 있음
- 자주 액세스되지 않는 오브젝트의 경우, Standard IA로 이동
- 빠른 액세스가 요구되지 않는, 아카이빙 오브젝트의 경우 Glacier 또는 Glacier Deep Archive로 이동
- 오브젝트의 이동은 Lifecycle Rules를 통해 자동화 될 수 있음
S3 - Lifecycle Rules
-
Transition Actions - 오브젝트를 다른 스토리지 클래스로 전환하도록 설정
- 생성 이후 60일이 지나면 Standard IA 클래스로 오브젝트 이동
- 6개월이 지나면 Glacier로 이동
-
Expiration Actions - 일부 시간이 지나면 오브젝트를 만료(삭제)하도록 설정
- 액세스 로그 파일들은 365일이 지난 이후에는 삭제되도록 설정
- 오래된 버전의 파일들은 삭제되도록 설정 (버저닝이 활성화되어 있는 경우)
- 불완전한 Multi-Part 업로드들에 대해서는 삭제하도록 설정
-
특정한 prefix를 가진 경우에 대해서만 적용할 수도 있음 (ex.
s3://mybucket/mp3/*
) -
특정한 오브젝트 태그를 가진 경우에만 적용할 수도 있음 (ex.
Department: Finance
)
S3 - Lifecycle Rules (Scenario 1)
내 EC2 애플리케이션은 프로필 사진을 썸네일로 만들어 업로드한다. 이 썸네일들은 쉽게 재생성될 수 있고, 60일 동안 보존되어야 한다. 소스 이미지들은 60일 동안 즉각적으로 조회될 수 있어야하고, 60일이 지난 이후에는 6시간까지 기다릴 수 있다. 이를 어떻게 구현할 수 있을까?
- S3 소스 이미지는 Standard로 두고, 60일이 지나면 Glacier로 전환하도록 라이프사이클 설정을 할 수 있다.
- S3 썸네일 이미지는 One-Zone IA로 두고, 60일이 지나면 이것이 만료되도록(삭제하도록) 설정할 수 있다.
S3 - Lifecycle Rules (Scenario 2)
내 회사에는 삭제된 S3 오브젝트를 30일 동안은 드물긴 하겠지만 즉각적으로 복구할 수 있어야 한다는 룰이 성명되어 있다. 또 30일이 지난 이후, 최대 365일 동안은 삭제된 오브젝트들이 48시간 내에는 복구 가능(recoverable)한 상태에 있어야 한다.
- 오브젝트들이 버전을 갖도록 S3 버저닝을 활성화한다. 이를 통해, "삭제된 오브젝트"는 실제로 삭제된 것이 아니라, "delete marker"를 가짐으로써, 사실을 숨김 처리가 되었을 뿐, 복구 가능한 상태가 된다.
- 오브젝트의 비현재 버전(noncurrent version)들을 Standard IA로 전환한다.
- 시간이 지난 이후에는 비현재 버전(noncurrent version)들을 Glacier Deep Archive로 전환한다.
S3 - Storage Class Analysis
- 오브젝트를 적절한 스토리지 클래스로 전환할 때 결정을 도와줌
- Standard와 Standard IA의 경우 추천
- One-Zone IA나 Glacier에는 적용되지 않음
- 1일 단위로 리포트가 업데이트됨
- 데이터 분석 시작까지 24 ~ 48시간
- 라이프사이클 룰을 적용(또는 향상)하기 위한 좋은 시작
S3 - Requester Pays
- 일반적으로 버킷 소유자는 S3 스토리지와 해당 스토리지와 관련된 모든 데이터 전송 비용을 지불함
- 하지만 Requester Pays 버킷을 사용하면, 버킷 소유자가 아닌 요청자(requester)가 버킷으로부터의 데이터 다운로드 및 요청 비용을 지불
- 다른 계정과 거대한 데이터셋을 공유하고자 할 때 유용함
- requester는 반드시 AWS 상에서 인증되어야 함 (익명 이용자가 될 수 없음)
S3 - Event Notifications
- S3:ObjectCreated, S3:ObjectRemoved, S3:ObjectRestore, S3:Replication...
- 오브젝트 명칭에 대한 필터링 가능 (ex.
*.jpg
) - 사례: S3로 업로드되는 이미지에 대한 썸네일 생성
- 원하는 만큼의 "S3 이벤트"를 생성할 수 있음
- AWS SNS, SQS, Lambda와 연동 (EventBridge를 통해 더 많은 Destination과 연동 가능)
- S3 event notifications는 이벤트 전달에 일반적으로 몇 초 정도가 소요되지만, 가끔 1분, 또는 그 이상이 소요될 수 있음
S3 - Event Notifications with Amazon EventBridge
- JSON 룰을 이용한 고급 필터링 (메타 데이터, 오브젝트 크기, 명칭...)
- Multiple Destinations - ex. Step functions, Kinesis Streams / Firehose...
- EventBridge Capabilities - Archive, Replay Event, Reliable delivery
S3 - Baseline Performance
- S3는 많은 요청 횟수를 처리하고 100-200ms의 레이턴시를 보유하도록 자동으로 스케일링됨
- 하나의 버킷에 각 prefix 당 매초 최소 3,500 개의 PUT/COPY/POST/DELETE 또는 5,500 GET/HEAD 요청 작업을 수행할 수 있음
- 하나의 버킷에 가질 수 있는 prefix 갯수의 제한은 없음
- 예시 (object path => prefix):
- bucket/folder1/sub1/file => /folder1/sub1/
- bucket/folder1/sub2/file => /folder1/sub2/
- bucket/1/file => /1/
- bucket/2/file => /2/
- 위 4개의 prefix 모두에 균등하게 읽기를 분산시키면, GET과 HEAD에 대해 초당 22,000건의 요청을 달성할 수 있음
S3 - Performance
- Multi-Part upload:
- 100MB 이상의 파일에 추천
- 5GB 이상의 파일에는 필수
- 업로드를 병렬화(parallelize)할 수 있음 (전송 속도 상승)
- S3 Transfer Acceleration
- 타깃 리전에 해당하는 S3 버킷으로 데이터를 전달하는 AWS 엣지 로케이션으로 파일을 전송하여 전송 속도를 향상시킴
- Multi-part upload와 호환 가능함
S3 - Performance ~ S3 Byte-Range Fetches
- 특정 바이트 범위(byte range)에 대한 요청을 보내서 GET 요청을 병렬화
- 장애 발생 시 복원력(resilience) 향상
- 다운로드 속도를 향상시킬 수 있음
- 데이터의 일부만 조회하는데에 사용할 수 있음 (ex. 파일 헤드)
S3 - Select & Glacier Select
- SQL을 사용하는 서버사이드 필터링을 적용하여 적은 데이터를 조회할 수 있음
- row & column으로 필터링 가능 (간단한 SQL 문)
- 더 적은 양의 네트워크 전송이 이루어지고, 또 더 적은 클라이언트 측 CPU 비용이 발생
S3 - Batch Operations
-
존재하는 S3 오브젝트들에 하나의 요청을 통해 대량 작업을 수행
- 오브젝트의 메타데이터와 프로퍼티를 수정
- S3 버킷 간에 오브젝트 복사
- 비암호화된 오브젝트를 암호화
- ACL과 태그를 수정
- S3 Glacier로부터 오브젝트들을 복구
- 각 오브젝트에 커스텀 액션을 수행하는 람다 함수를 실행
-
대상 오브젝트 목록, 수행할 액션, 옵션 파라미터로 하나의 작업이 구성됨
-
S3 Batch Operation로 재시도를 관리하고, 진행 상황을 추적하고, 완료 알림을 보내고, 리포트를 생성할 수 있음
-
대상 오브젝트 목록을 얻기위해 S3 Inventory를 사용할 수 있고, 그 목록을 필터링하기 위해 S3 Select를 사용할 수 있음
S3 - Object Encryption
-
S3 버킷 내 오브젝트들은 아래 4가지 방법 중 하나로 암호화할 수 있음 ~ 어떤 상황에서 어떤 암호화를 적용할 지 이해하는 것이 중요함
-
Server-Side Encryption (SSE)
- Server-Side Encryption with Amazon S3-Managed Keys (SSE-S3) - 기본 활성화
- AWS로부터 소유/관리되는 키를 통해 S3 오브젝트를 암호화
- Server-Side Encryption with KMS Keys stored in AWS KMS (SSE-KMS)
- 암호화 키 관리를 위해 AWS Key Management Service (AWS KMS)를 활용
- Server-Side Encryption with Customer-Provided Keys (SSE-C)
- 본인의 커스텀 암호화 키로 관리하고자 할 때
- Server-Side Encryption with Amazon S3-Managed Keys (SSE-S3) - 기본 활성화
-
Client-Side Encryption
S3 Encryption - SSE-S3
- AWS가 다루고, 관리하고, 소유한 키를 통해 암호화
- 오브젝트는 서버사이드에서 암호화됨
- 암호화 타입은 AES-256
- 반드시 헤더에 다음을 포함 => "x-amz-server-side-encryption": "AES256"
- 새로 만든 버킷 & 새로운 오브젝트들에는 기본적으로 활성화되어 있음
S3 Encryption - SSE-KMS
- AWS KMS(Key Management Service)를 통해 관리되는 키로 암호화
- KMS 이점: 이용자 통제 + CloudTrail을 사용한 키 사용 감사(audit)
- 오브젝트는 서버사이드에서 암호화됨
- 반드시 헤더에 다음을 포함 => "x-amz-server-side-encryption": "aws:kms"
SSE-KMS Limitation
- SSE-KMS를 사용하는 경우, KMS의 한계에 부딫힐 수도 있음
- 업로드 시에, GenerateDataKey KMS API를 호출함
- 다운로드 시에, Decrypt KMS API를 호출함
- 초당 KMS 할당량(quota)에 포함됨 (리전에 따라 5500, 10000, 30000 req/s)
- Service Quotas Console을 사용해서 quota 증량을 요청할 수 있음
S3 Encryption - SSE-C
- AWS 외부의, 이용 고객으로부터 완전히 관리되는 키를 통한 서버사이드 암호화
- S3는 관리자(나)가 제공하는 암호화 키를 보관하지 않음
- HTTPS 필수
- 모든 HTTP 요청의 HTTP 헤더에 암호화 키가 제공되어야 함
SE Encryption - Client-Side Encryption
- Amazon S3 클라이언트 사이드 암호화 라이브러리같은 클라이언트 라이브러리를 사용
- 클라이언트 쪽에서 S3로 데이터를 전송하기 전에 반드시 스스로 암호화를 해서 보내야 함
- 클라이언트 쪽에서 S3로부터 데이터를 조회할 때도 반드시 스스로 복호화를 해야함
- AWS 이용고객(나)가 key와 암호화 사이클을 모두 관리해야함
S3 - Encryption in transit (SSL/TLS)
-
전송 중 암호화(Encryption in flight)는 SSL/TLS로도 불림
-
S3는 다음의 두가지 엔드포인트를 노출
- HTTP Endpoint - 비암호화
- HTTPS Endpoint - encryption in flight
-
HTTPS가 권장됨
-
HTTPS는 SSE-C를 사용하고자 하는 경우 필수
-
대부분의 클라이언트들은 기본적으로 HTTPS 엔드포인트를 사용
S3 - Force encryption in Transit
aws:SecureTransport
를 사용
S3 - Default Encryption vs. Bucket Policies
- SSE-S3 암호화는 S3 버킷에 저장되는 새 오브젝트들에 자동으로 적용됨
- 선택적으로, bucket policy를 활용하여 암호화를 강제하거나, ecnryption header(SSE-KMS 또는 SSE-C)가 없는 경우는 PutObject API 호출을 거부할수도 있음
- 중요: Bucker Policy는 Default Encryption보다 우선하여 적용됨
S3 - CORS
What is CORS?
- Cross-Origin Resource Sharing (CORS)
- Origin = scheme (protocol) + host (domain) + port
- ex.)
https://www.example.com
(포트 - 443 for HTTPS, 80 for HTTP)
- ex.)
- 웹 브라우저는 기본 오리진을 방문하는 동안, 다른 오리진에 대한 요청을 허용
- 동일 오리진:
http://example.com/app1
&http://example.com/app2
- 서로 다른 오리진:
http://www.example.com
&http://other.example.com
- 동일 오리진:
- 다른 오리진에서 일어난 요청은 CORS 헤더를 사용하여 요청을 허용하지 않는 한 처리되지 않음 (ex. Access-Control-Allow-Origin)
S3 - CORS ~ allow origin
- 만약 클라이언트가 cross-origin 요청을 S3 버킷에 보낸다면, 적절한 CORS 헤더를 활성화해야 함
- 시험에 잘 나옴
- 특정 오리진 또는 *(모든 오리진)을 허용할 수 있음
S3 - MFA Delete
- MFA (Multi-Factor Authentication) - 이용자가 S3에서 중요한 작업을 수행하기 전에, 디바이스(주로 모바일 또는 하드웨어)를 통해 코드를 생성하여 인증하도록 강제
- MFA는 다음과 같은 경우에 요구됨
- 오브젝트 버전을 영구적으로 삭제
- 버킷의 버저닝을 중지
- MFA가 요구되지 않는 경우
- 버저닝의 활성화
- 삭제된 버전의 리스팅
- MFA Delete를 사용하려면, 버킷에 반드시 버저닝이 활성화되어야 함
- 버킷 소유자(루트 계정)만이 오직 MFA Delete를 활성화/비활성화할 수 있음
S3 - Access Logs
- 감사(audit) 목적으로, S3에 대한 모든 액세스를 로그하고 싶을 수 있음
- 인증 유무에 상관없이 어떤 계정이든 S3에 이루어진 어떤 요청이 다른 S3 버킷에 로깅됨
- 데이터 분석 툴을 통해 분석될 수 있음
- 타깃이 되는 로그 버킷(로그가 저장되는 버킷)은 동일한 AWS 리전 내에 있어야 함
로그 포맷
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be DOC-EXAMPLE-BUCKET1 [06/Feb/2019:00:00:38 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be 3E57427F3EXAMPLE REST.GET.VERSIONING - "GET /DOC-EXAMPLE-BUCKET1?versioning HTTP/1.1" 200 - 113 - 7 - "-" "S3Console/0.4" - s9lzHYrFp76ZVxRcpX9+5cjAnEH2ROuNkd2BHfIa6UkFVdtjf5mKR3/eTPFvsiP/XV/VLi31234= SigV4 ECDHE-RSA-AES128-GCM-SHA256 AuthHeader DOC-EXAMPLE-BUCKET1.s3.us-west-1.amazonaws.com TLSV1.2 arn:aws:s3:us-west-1:123456789012:accesspoint/example-AP Yes
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be DOC-EXAMPLE-BUCKET1 [06/Feb/2019:00:00:38 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be 891CE47D2EXAMPLE REST.GET.LOGGING_STATUS - "GET /DOC-EXAMPLE-BUCKET1?logging HTTP/1.1" 200 - 242 - 11 - "-" "S3Console/0.4" - 9vKBE6vMhrNiWHZmb2L0mXOcqPGzQOI5XLnCtZNPxev+Hf+7tpT6sxDwDty4LHBUOZJG96N1234= SigV4 ECDHE-RSA-AES128-GCM-SHA256 AuthHeader DOC-EXAMPLE-BUCKET1.s3.us-west-1.amazonaws.com TLSV1.2 - -
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be DOC-EXAMPLE-BUCKET1 [06/Feb/2019:00:00:38 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be A1206F460EXAMPLE REST.GET.BUCKETPOLICY - "GET /DOC-EXAMPLE-BUCKET1?policy HTTP/1.1" 404 NoSuchBucketPolicy 297 - 38 - "-" "S3Console/0.4" - BNaBsXZQQDbssi6xMBdBU2sLt+Yf5kZDmeBUP35sFoKa3sLLeMC78iwEIWxs99CRUrbS4n11234= SigV4 ECDHE-RSA-AES128-GCM-SHA256 AuthHeader DOC-EXAMPLE-BUCKET1.s3.us-west-1.amazonaws.com TLSV1.2 - Yes
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be DOC-EXAMPLE-BUCKET1 [06/Feb/2019:00:01:00 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be 7B4A0FABBEXAMPLE REST.GET.VERSIONING - "GET /DOC-EXAMPLE-BUCKET1?versioning HTTP/1.1" 200 - 113 - 33 - "-" "S3Console/0.4" - Ke1bUcazaN1jWuUlPJaxF64cQVpUEhoZKEG/hmy/gijN/I1DeWqDfFvnpybfEseEME/u7ME1234= SigV4 ECDHE-RSA-AES128-GCM-SHA256 AuthHeader DOC-EXAMPLE-BUCKET1.s3.us-west-1.amazonaws.com TLSV1.2 - -
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be DOC-EXAMPLE-BUCKET1 [06/Feb/2019:00:01:57 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be DD6CC733AEXAMPLE REST.PUT.OBJECT s3-dg.pdf "PUT /DOC-EXAMPLE-BUCKET1/s3-dg.pdf HTTP/1.1" 200 - - 4406583 41754 28 "-" "S3Console/0.4" - 10S62Zv81kBW7BB6SX4XJ48o6kpcl6LPwEoizZQQxJd5qDSCTLX0TgS37kYUBKQW3+bPdrg1234= SigV4 ECDHE-RSA-AES128-SHA AuthHeader DOC-EXAMPLE-BUCKET1.s3.us-west-1.amazonaws.com TLSV1.2 - Yes
S3 - Access Logs: Warning
- 모니터링 버킷(감시 대상이 되는 버킷)을 로깅 버킷으로 설정해서는 안됨
- 이 경우, 로깅 루프를 유발하여, 버킷이 무한정 커지게 됨
S3 - Pre-Signed URLs
- S3 콘솔, AWS CLI 또는 SDK를 통해, pre-signed URL을 생성
- URL Expiration
- S3 Console - 1분에서 720분(12시간)까지
- AWS CLI -
--expires-in
파라미터로 초 단위 유효기간 설정 가능 (기본 3600초, 최대 604800초 ~ 168시간)
- pre-signed URL를 전달받은 이용자는 GET/PUT에 대한 URL을 생성한 이용자의 권한을 상속받음
- 예시:
- 로그인을 한 유저에게만 S3 버킷으로부터 프리미엄 비디오를 다운로드할 수 있도록 하는 경우
- 끊임없이 변화하는 이용자 목록들에게만 파일 다운로드 경로를 동적으로 제공하고자 하는 경우
- S3 버킷의 특정한 위치에 이용자가 파일을 업로드하도록 일시적으로 허용하고자 하는 경우
S3 - Glacier Vault Lock
- WORM(Write Once Read Many) 모델 적용
- Vault Lock Policy를 생성
- 추후의 변경에 대해 policy를 잠금 (더 이상 변경되거나 삭제될 수 없음)
- 규정 준수(compliance)와 데이터 보관(data retention)에 유용
S3 - Object Lock (versioning must be enabled)
- WORM(Write Once Read Many) 모델 적용
- 특정 시간 동안 오브젝트 버전의 삭제를 막음
- Retention mode - Compliance:
- 루트 이용자를 포함해 그 누구도 버전을 덮어씌우거나 삭제할 수 없음
- 해당 모드는 변경될 수 없고, 정해진 기간도 더 줄일 수 없음
- Retention mode - Governance:
- 대부분의 이용자들은 오브젝트 버전을 덮어씌우거나 삭제하거나, 잠겨진 세팅을 변경할 수 없음
- 일부 이용자들에게 보관된 내용을 변경하거나, 오브젝트를 삭제할 수 있는 특별한 권한을 부여할 수 있음
- Retention Period:
- 정해진 기간 동안 특정한 오브젝트를 보호하며, 연장될 수 없음
- Legal Hold:
- 보관 기간과 무관하게 오브젝트를 무기한 보호
s3:PutObjectLegalHold
IAM 권한을 통해 보호를 해제하거나, 오브젝트를 삭제할 수 있음
S3 - Access Points
- Access Point는 S3 버킷에 대한 보안 관리를 쉽게 만들어줌
- 각각의 Access Point는 다음을 가짐:
- 본인의 DNS 네임 (인터넷 오리진 or VPC 오리진)
- 액세스 포인트 정책 (버킷 정책과 유사함) - 규모에 따라 보안을 관리
S3 - Access Points ~ VPC Origin
- VPC 내에서만 액세스할 수 있도록 액세스 포인트를 정의할 수도 있음
- 반드시 액세스 포인트로 액세스하는 VPC 엔드포인트를 만들어야 함 (Gateway 또는 Interface 엔드포인트)
- 반드시 타깃 버킷과 액세스 포인트에 대한 액세스를 VPC 엔드포인트 정책 상으로 허용하고 있어야 함
S3 Object Lambda
- 호출 애플리케이션(caller application)에 의해 오브젝트가 조회되기 전에, AWS 람다 함수를 오브젝트에 적용할 수 있음
- S3 Access Point와 S3 Object Lambda Access Points를 갖고 있는 하나의 S3 버킷만 필요함
- 사례:
- 분석 또는 비프로덕션 환경을 위한 개인 식별 정보 삭제
- 데이터 포맷 간의 변환 (ex. XML to JSON)
- 오브젝트를 요청한 사용자 등 호출 세부 정보(caller-specific detail)를 사용하여 즉석에서 이미지 크기를 조정하거나, 워터마킹을 할 수 있음
CloudFront
- Content Delivery Network (CDN) - 컨텐츠 전송 네트워크
- 컨텐츠를 엣지에 캐시하여 읽기 성능을 향상
- 이용자 경험 향상
- 전 세계 216개 지점 (엣지 로케이션)
- DDoS 보호 (전 세계적으로 애플리케이션이 배포되기 때문), AWS Shield, AWS Web Application Firewall와 통합
CloudFront - Origins
-
S3 버킷
- 엣지에 파일을 배포하고 캐시하는 용도로 사용
- CloudFront **Origin Access Control(OAC)**를 사용하여 보안 강화
- OAC는 Origin Access Indentitiy (OAI)의 대체
- CloudFront는 S3에 파일을 업로드하기 위한 ingress로 사용될 수 있음
-
Custom Origin (HTTP)
- Application Load Balancer
- EC2 Instance
- S3 웹사이트 (먼저 반드시 버킷을 정적 S3 웹사이트로 활성화해야함)
- 원하는 HTTP 백엔드 뭐든지
CloudFront vs Cross Region Replication
- CloudFront
- 글로벌 엣지 네트워크
- TTL 기반으로 파일이 캐시됨 (아마 1일)
- 어디서든 사용 가능해야 하는 정적 컨텐츠에 유용함
- S3 Cross Region Replication
- 복제가 일어나길 원하는 각 리전마다 직접 세팅해주어야 함
- 거의 실시간으로 파일이 업데이트됨
- 읽기 전용
- 일부 지역에서 낮은 레이턴시로 사용해야 하는 동적 컨텐츠에 유용함
CloudFront - ALB or EC2 as an origin
CloudFront - Geo Restriction
- 내 배포에 액세스할 수 있는 대상을 제한할 수 있음
- Allowlist: 허용한 나라 목록에 포함된 이용자들만 컨텐츠에 액세스 가능
- Blocklist: 금지된 나라 목록에 포함되지 않은 이용자들만 컨텐츠에 액세스 가능
- 대상 국가의 경우 써드파티 Geo-IP 데이터베이스를 통해 결정됨
- 사례: 컨텐츠 액세스를 제어하는 저작권법
CloudFront - Pricing
- CloudFront 엣지 로케이션은 전 세계에 분포
- 각 엣지 로케이션 마다 데이터 비용이 다름
CloudFront - Price Classes
- 비용 감축을 위해 엣지 로케이션의 갯수를 줄일 수 있음
- 3개의 Price class가 존재
- Price Class All: 모든 리전 - 최고 성능
- Price Class 200: 대부분의 리전, 제일 비싼 리전들은 제외
- Price Class 100: 가장 저렴한 리전만
CloudFront - Cache Invalidations
- 내가 백엔드 오리진을 업데이트했을 경우, CloudFront는 그것을 알 방도가 없기 때문에, 오직 TTL이 만료되고 나서야 갱신된 컨텐츠를 제공하게 됨
- 다만, CloudFront Invalidation을 통해서 강제로 전체 또는 일부 캐시를 갱신(refresh)할 수 있음 (TTL을 우회)
- 모든 파일(
*
), 또는 특정 경로(/images/*
)에 대해 무효화 할 수 있음
Global Accelerator
- 배포된 애플리케이션에 직접 접근하고자 하는 글로벌 이용자들은, 많은 홉(hop)으로 인해 레이턴시가 길어질 수 있는 공용 인터넷을 통해 전송됨.
- 이 때, 레이턴시를 최소화하기 위해 AWS 네트워크를 거쳐 최대한 빠르게 이동하고자 함
Unicast IP vs Anycast IP
- Unicast IP: 하나의 서버는 하나의 IP를 가짐
- Anycast IP: 모든 서버가 동일한 IP를 가지며, 클라이언트는 가장 가까운 서버로 라우트됨
Global Accelerator - Overview
- AWS 내부 네트워크를 활용하여 애플리케이션으로 라우팅
- 애플리케이션에 대한 2개의 Anycast IP를 생성
- Anycast IP는 엣지 로케이션으로 트래픽을 직접 전송
- 엣지 로케이션은 애플리케이션으로 트래픽을 전송
Global Accelerator - Description
- 퍼블릭/프라이빗 상관없이 Elastic IP, EC2 인스턴스, ALB, NLB과 함께 사용할 수 있음
- 일관된 성능
- 지능적인 라우팅을 통한 낮은 레이턴시와 빠른 리전 내 장애 복구
- 클라이언트 캐시와 관련한 문제 없음
- 내부적으로 AWS 네트워크 사용
- 헬스 체크
- Global Accelerator는 애플리케이션에 대한 헬스체크를 수행함
- 애플리케이션이 글로벌화 될 수 있도록 도와줌 (unhealthy 상태인 경우 1분 이하로 장애 복구)
- 이러한 헬스체크 덕에 재해 복구(disaster recovery)에 유용함
- 보안
- 오직 2개의 외부 IP만 화이트리스트에 추가하면 됨
- AWS Shield 덕에 DDoS 보호
Global Accelerator vs CloudFront
-
둘다 AWS 글로벌 네트워크과 그것의 엣지 로케이션을 사용함
-
두 서비스 모두 AWS Shield를 통한 DDoS 보호를 할 수 있음
-
CloudFront
- 캐시 가능한 컨텐츠 모두에 대한 성능 향상 (이미지나 비디오)
- 동적 컨텐츠 (API 가속 및 동적 사이트 전송)
- 컨텐츠가 엣지를 통해 제공됨
-
Global Accelerator
-
TCP나 UDP를 거치는 넓은 범위의 애플리케이션
-
하나 이상의 AWS 리전에 실행되는 애플리케이션으로 엣지에서의 패킷을 프록시해줌
-
non-HTTP 사례에 유용함 - 게이밍 (UDP), IoT (MQTT), 또는 Voice over IP(VoIP)
-
정적인 IP 주소들을 필요로 하는 사례에 적합함
-
결정론적(deterministic)이고 빠른 지역 장애 복구가 필요한 사례에 적합함
-
Storage Extras
AWS Snow Family
-
엣지에서 데이터를 수집, 처리하며 AWS의 안팎으로 데이터를 마이그레이션하게 해주는 높은 보안성을 지니고 휴대가능한 디바이스
-
Data migration: Snowcone, Snowball Edge, Snowmobile
-
Edge computing: Snowcone, Snowball Edge
Data Migrations with AWS Snow Family
-
각 데이터 크기 별 전송에 걸리는 시간
-
온라인 데이터 전송에서의 문제점
- 제한된 연결성
- 제한된 대역폭(bandwidth)
- 높은 네트워크 비용
- 공유되는 대역폭(회선을 최대화 할 수 없음)
- 연결 안정성
-
AWS Snow Family: 데이터 마이그레이션을 수행하기 위한 오프라인 디바이스
- 만약, 네트워크를 통한 데이터 전송에 1주 이상 걸린다면, Snowball 디바이스를 사용하자!
Snow Family - Snowball Edge
- 물리적인 데이터 전송 솔루션: AWS에서의 TB, PB 단위의 데이터를 안팎으로 이동
- 네트워크를 통한 데이터 이동(+ 네트워크 요금 지불)에 대한 대안
- 각 데이터 전송 작업에 대해 요금 지불
- 블록 스토리지와 S3 호환 가능한 오브젝트 스토리지를 제공
- Snowball Edge Storage Optimized
- 80TB HDD 저장소로, 블록 볼륨과 S3 호환 가능한 오브젝트 스토리지
- Snowball Edge Compute Optimized
- 42TB HDD 또는 28TB NVMe 저장소로, 블록 볼륨과 S3 호환 가능한 오브젝트 스토리지
- 사례: 큰 데이터에 대한 클라우드 마이그레이션, 데이터센터(DC) 폐기, 장애 복구
Snow Family - AWS Snowcone & Snowcone SSD
- 견고하고 안전한, 열악한 환경도 견뎌낼 수 있는 작고 휴대 가능한 컴퓨터
- 가벼움 (4.5파운드, 2.1kg)
- Snowcone - 8TB HDD 저장소
- Snowcone SSD - 14TB SSD 저장소
- Snowball을 쓰기 적합하지 않은 경우라면 Snowcone을 사용 (공간 제약적인 환경)
- 자체적인 배터리 / 케이블을 준비해야함
- 오프라인으로 AWS에 재전송할 수 있고, 데이터 전송을 위해 인터넷에 연결하여 AWS DataSync를 사용할 수도 있음
Snow Family - AWS Snowmobile
- EB(엑사바이트) 단위의 데이터를 전송 (1EB = 1,000PB = 1,000,000TBs)
- 각 Snowmobile은 100PB의 용량을 가짐 (병렬로 여러개 사용)
- 높은 보안: 온도 관리, GPS, 24/7 비디오 감시
- 10PB 이상의 데이터 전송이 필요할때 Snowball보다 유용
Snow Family - Usage Process
- AWS 콘솔로부터 Snowball 기기 배송을 요청
- 서버에서 Snowball 클라이언트 / AWS OpsHub를 설치
- Snowball을 서버에 연결하고 클라이언트를 사용하여 파일을 복사
- 완료된 이후에는 기기를 반납 (적절한 AWS 시설로 배송)
- S3 버킷으로 데이터가 로드
- Snowball은 완전히 지워짐.
Snow Family - What is Edge Computing?
- 엣지 로케이션에서 생성되는 데이터를 처리
- 엣지 로케이션: 클라우드 환경으로부터 멀리 떨어지거나, 인터넷 환경이 갖추어지지 않은 어디든
- 이러한 로케이션들은 다음과 같은 문제로 제약을 가짐
- 인터넷 액세스가 존재하지 않거나
- 컴퓨팅 파워(computing power)에 액세스하기 쉽지 않음
- 엣지 컴퓨팅을 수행하기 위해 Snowball Edge / Snowcone 디바이스를 설치
- 엣지 컴퓨팅 사례:
- 데이터 전처리
- 엣지에서의 머신러닝
- 미디어 스트림 트랜스코딩
- 결국 (필요한 경우) AWS에 디바이스를 반송할 수 있음 (ex. 데이터 전송 목적)
Snow Family - Edge Computing
- Snowcone & Snowcone SSD (smaller)
- 2 CPUs, 4GB 메모리, 유선 또는 무선 액세스
- 코드를 통한 USB-C 파워 또는 배터리 옵션
- Snowball Edge - Compute Optimized
- 104 vCPUs, 416GB 램
- GPU 옵션 (비디오 프로세싱 또는 머신 러닝에 유용)
- 28TB NVMe 또는 42TB HDD usable storage
- Snowball Edge - Storage Optimized
- 최대 40 vCPUs, 80GB 램, 80TB 저장소
- 객체 스토리지 클러스터링 이용 가능
- All: EC2 인스턴스 & AWS 람다 함수 실행 가능 (AWS IoT Greengrass 사용)
- 장기 배포 옵션: 1-3년 할인된 가격
Snow Family - AWS OpsHub
- 역사적으로, Snow Family 디바이스들을 사용하기 위해서는 CLI를 다루어야 했음
- 오늘날에는, AWS OpsHub(컴퓨터/랩탑에 설치하는 소프트웨어)로 Snow Family 디바이스를 관리할 수 있음
- 단일 또는 클러스터링된 디바이스들을 잠금해제 및 설정
- Snow Family 디바이스들에서 인스턴스들을 실행하거나 관리
- 디바이스 지표(metrics)를 모니터링 (스토리지 용량, 디바이스 내 활성된 인스턴스)
- 디바이스에서 호환가능한 AWS 서비스 실행 (ex. EC2 인스턴스, AWS DataSync, Network File System(NFS))
Snow Family - Snowball into Glacier
- Snowball 그 자체만으로는 Glacier에 직접 임포트할 수 없음
- 반드시 S3를 먼저 사용하고, S3 라이프사이클 정책을 조합해야함
Amazon FSx
Amazon FSx - Overview
- AWS에서 높은 성능의 써드파티 파일 시스템을 실행
- 완전 관리형 서비스
- FSx for Lustre
- FSx for Windows File Server
- FSx for NetApp ONTAP
- FSx for OpenZFS
Amazon FSx - for Windows (File Server)
- FSx for Windows는 완전관리형 Windows 파일 시스템 공유 드라이브
- SMB 프로토콜 & Windows NTFS 지원
- Microsoft Active Directory Integration, ACLs(접근 제어 목록), user quotas
- 리눅스 EC2 인스턴스에 마운트
- **Microsoft의 Distrubted File System (DFS) 네임스페이스 지원 (여러 FS 간에 파일들을 그룹화)
- 최대 10GB/s, 수백만의 IOPS, 100PB 규모의 데이터까지 확장
- 스토리지 옵션
- SSD - 레이턴시 중점의 워크로드 (데이터베이스, 미디어 프로세싱, 데이터 분석, ...)
- HDD - 넓은 스펙트럼의 워크로드 (홈 디렉토리, CMS, ...)
- 온-프리미스 인프라에 액세스 가능 (VPN or Direct Connect)
- Multi-AZ로 설정 가능 (High availability)
- 매일 S3로 데이터 백업 - 재해 복구
Amazon FSx - for Lustre
- Lustre는 병렬 분산형 파일 시스템의 한 종류로, 대규모 컴퓨팅에 사용됨
- Lustre = linux + cluster
- 머신러닝, 고성능 컴퓨팅 (HPC - High Performance Computing)
- 비디오 프로세싱, 재무 모델링(Financial Modeling), 전자 설계 자동화(Electronic Design Automation)
- 최대 100GB/s, 수백만의 IOPS, ms 미만의 레이턴시
- 스토리지 옵션
- SSD - 낮은 레이턴시, IOPS 중점의 워크로드, 작고 무작위성인 파일 작업
- HDD - 처리량에 집중적인(throughput-intensive) 워크로드, 크고 순차적인 파일 작업
- S3와의 원활한 통합
- 파일 시스템으로서 S3를 읽을 수 있음 (FSx를 통해서)
- 컴퓨팅 결과를 S3에 다시 작성할 수 있음 (FSx를 통해서)
- 온-프리미스 서버로부터 사용될 수 있음 (VPN or Direct Connect)
Amazon FSx - File System Deployment Options
- Scratch File System
- 임시 저장소
- 데이터가 복제되지 않음 (파일 서버가 디운되면 사라짐)
- High burst (6배 빠름, 200MBps per TiB)
- 사례: 단기간 프로세싱, 비용 최적화
- Persistent File System
- 장기간 스토리지
- 동일 AZ 내에 데이터가 복제됨
- 몇분 안에 failed file들을 교체
- 사례: 장기간 프로세싱, 민감 데이터
Amazon FSx - for NetApp ONTAP
- AWS에서 관리되는 NetApp ONTAP
- NFS, SMB, iSCSI 프로토콜과 호환되는 파일 시스템
- ONTAP 또는 NAS에서 실행되는 워크로드를 AWS로 이동
- 다음과 호환
- Linux
- Windows
- MacOS
- VMware Cloud on AWS
- Amazon Workspaces & AppStream 2.0
- Amazon EC2, ECS and EKS
- 스토리지 자동 축소/확장
- 스냅샷, 복제, 저비용, 압축 및 데이터 중복 제거
- 특정 시점 순간 복제(새로운 워크로드에 대한 테스트에 유용함)
Amazon FSx - for OpenZFS
- AWS에서 관리되는 OpenZFS 파일 시스템
- 오직 NFS와 호환되는 파일 시스템 (v3, v4, v4.1, v4.2)
- ZFS에서 실행중인 워크로드를 AWS로 이동
- 다음과 호환
- Linux
- Windows
- MacOS
- VMware Cloud on AWS
- Amazon Workspaces & AppStream 2.0
- Amazon EC2, ECS and EKS
- 0.5ms 미만의 레이턴시로 최대 1,000,000 IOPS
- 스냅샷, 압축, 저비용
- 특정 시점 순간 복제(새로운 워크로드에 대한 테스트에 유용함)
Storage Gateway
Storage Gateway - Hybrid Cloud for Storage
- AWS는 "하이브리드 클라우드"를 추진함
- 인프라의 일부는 클라우드로
- 인프라의 일부는 온-프레미스로
- 이렇게 하는 이유는
- 장기적인 클라우드 마이그레이션
- 보안 요구사항 준수
- 규정 준수
- IT 전략
- S3는 (EFS/NFS와 다르게) 독점적인 스토리지 기술인데, 어떻게 S3 데이터를 온-프레미스로 내보낼 수 있을까?
- 그 역할을 해주는 것이 바로 AWS Storage Gateway
Storage Gateway - AWS Storage Cloud Native Options
- Block - AWS EBS, EC2 Instance Store
- File - AWS EFS, AWS FSx
- Object - AWS S3, AWS Glacier
Storage Gateway - Overview
-
온-프레미스 데이터와 클라우드 데이터 간의 브릿지
-
사례:
- 재해 복구
- 백업 & 복원
- 계층형 스토리지 (tiered storage)
- 온-프레미스 캐시 & 낮은 레이턴시의 파일 액세스
-
Storage Gateway 종류:
- S3 File Gateway
- FSx File Gateway
- Volume Gateway
- Tape Gateway
Storage Gateway - S3 File Gateway
- NFS와 SMB 프로토콜을 사용하여 접근 가능한 설정된 S3 버킷
- 가장 최근에 사용된 데이터는 file gateway 내에 캐시됨
- S3 Standard, S3 Standard IA, S3 One Zone A, S3 Intelligent Tiering 지원 (Glacier 제외)
- 라이프사이클 정책을 통해 S3 Glacier로 전환 가능
- 각각의 File Gateway에 IAM 역할을 정의하여 버킷에 액세스
- 이용자 인증을 위해 SMB 프로토콜은 Active Directory(AD)와 연동됨
Storage Gateway - FSx File Gateway
- FSx for Window File Server에 대한 네이티브 액세스
- 자주 액세스되는 데이터에 대한 로컬 캐시 (사실 상 이를 사용하는 주된 이유)
- Windows 네이티브 호환성 (SMB, NTFS, Active Directory, ...)
- 그룹 파일 공유와 홈 디렉토리에 유용함
Storage Gateway - Volume Gateway
- iSCSI 프로토콜을 사용하는 S3 지원 블록 스토리지
- EBS 스냅샷을 지원하여 온-프레미스 볼륨을 복원하는데 도움을 줌
- Cached volumes: 가장 최근에 액세스된 데이터에 대해 낮은 레이턴시를 보장
- Stored volumes: 전체 데이터셋이 온-프레미스이며, S3로 스케줄된 백업(scheduled backup)
Storage Gateway - Tape Gateway
- 일부 회사는 놀랍게도(?) 실물 테이프를 사용하는 백업 프로세스를 보유함
- Tape Gateway는 동일한 프로세스를 클라우드에서 수행할 수 있도록 함
- S3와 Glacier가 지원되는 Virtual Tape Library(VTL)
- 기존에 존재하는 tape-based 프로세스를 사용하여 데이터를 백업 (+ iSCSI 인터페이스)
- 주요 백업 소프트웨어 업체와 협력
Storage Gateway - Hardware appliance
- Storage Gateway를 사용한다는 것은 온-프레미스 가상화가 필요하다는 것을 의미함
- 그렇지 않은 경우, Storage Gateway Hardware Appliance를 사용할 수도 있음
- amazon.com에서 구매
- File Gateway, Volume Gateway, Tape Gateway와 함께 사용
- 요구되는 CPU, 메모리, 네트워크, SSD 캐시 리소스를 보유하고 있음
- 소규모 데이터 센터를 두고 매일 NFS 백업을 하기에 유용함
Storage Gateway - Summary
AWS Transfer Family
- S3 또는 FTP 프로토콜을 사용하는 EFS 안팎으로 데이터를 전송할 수 있는 완전 관리형 서비스
- 지원 프로토콜
- **AWS Transfer for FTP (File Transfer Protocol (FTP))
- **AWS Transfer for FTPS (File Transfer Protocol over SSL (FTPS))
- **AWS Transfer for SFTP (Secure File Transfer Protocol (SFTP))
- 관리형 인프라, 확장 가능, 신뢰 가능, 고가용성 ~ High Available (multi-AZ)
- 시간 별 프로비전된 엔드포인트마다 + 데이터 전송 GB 단위에 따라 비용 지불
- 기존에 존재하는 인증 시스템과 호환됨 (Microsoft Active Directory, LDAP, Okta, Amazon Cognito, custom)
- 사례: 파일 공유, 공용 데이터셋, CRM, ERP, ...
AWS DataSync
- 많은 양의 데이터를 넘기거나, 받는 경우
- 온-프레미스 또는 다른 클라우드로부터 AWS로 이동 (NFS, SMB, HDFS, S3 API...) - 연결을 수행하기 위한 에이전트 필요
- AWS에서 AWS (다른 스토리지 서비스로) - 에이전트 필요 없음
- 다음과 동기화 가능
- S3 (어떤 storage class든 - Glacier 포함)
- EFS
- FSx (Windows, Lustre, NetApp, OpenZFS...)
- 복제 작업을 시간마다/일마다/주마다 수행할 수 있음
- 파일 권한과 메타데이터가 보존됨 (NFS POSIX, SMB...)
- 하나의 에이전트 작업은 10Gbps를 사용할 수 있으며, 대역폭 한계(bandwidth limit)를 설정할 수 있음
AWS DataSync - NFS / SMB to AWS (S3, EFS, FSx...)
AWS DataSync - Transfer between AWS storage services
Storage Comparison
- S3: 객체 스토리지
- S3 Glacier: 객체 아카이빙
- EBS volumes: 한번에 하나의 EC2 인스턴스에만 존재하는 네트워크 스토리지
- Instance Storge: EC2 인스턴스에 대한 실물 스토리지 (High IOPS)
- EFS: 리눅스 인스턴스, POSIX 파일시스템을 위한 네트워크 파일 시스템
- FSx for Windows: Windows servers를 위한 네트워크 파일 시스템
- FSx for Lustre: 고성능 컴퓨팅 리눅스 파일 시스템
- FSx for NetApp ONTAP: 높은 OS 호환성
- FSx for OpenZFS: 관리형 ZFS 파일 시스템
- Storage Gateway: S3 & FSx File Gateway, Volume Gateway (캐시 & 보관), Tape Gateway
- Transfer Family: S3 또는 EFS 상위의 FTP, FTPS, SFTP 인터페이스
- DataSync: 온-프레미스 to AWS 또는 AWS to AWS 데이터 싱크를 스케줄
- Snowcone / Snowball / Snowmobile: 거대한 양의 물리적인 데이터를 클라우드로 이동
- Database: 구체적인 워크로드 수행을 위함, 인덱싱 또는 쿼링
Decoupling Applications
- 여러 개의 애플리케이션을 배포하기 시작할 때, 필연적으로 애플리케이션들이 서로 소통해야할 필요가 생김
- 이 때, 애플리케이션 간의 커뮤니케이션을 구현하는데 있어 두 가지 패턴이 있음
- 동기 커뮤니케이션 (Synchronous communications ~ application to application)
- 비동기 / 이벤트 기반 커뮤니케이션 (Asynchronous / Event based ~ application to queue to application)
- 애플리케이션 간의 동기적인 커뮤니케이션은 갑작스러운 트래픽 급증이 있는 상황에서 문제가 될 수 있음
- 만약, 일반적으로 10개 정도로 비디오 인코딩 작업을 처리하던 애플리케이션에서 갑자기 1000개의 요청을 처리하게 되면?
- 이러한 경우에 대비하기 위해, 애플리케이션은 **디커플링(decouple)**을 하는 것이 좋음
- SQS: queue 모델
- SNS: pub/sub 모델
- Kinesis: 실시간 스트리밍 모델
- 위의 서비스들은 애플리케이션과는 독립적으로 스케일링을 수행할 수 있음
SQS
What's a queue?
SQS - Standard Queue
- 오래된 서비스 (거의 10년 가까이)
- 주로 애플리케이션 디커플링에 사용되는 완전 관리형 서비스
- 속성:
- 무제한 처리량(throughput), 큐 내 메시지 갯수에 제한이 없음
- 메시지의 기본 보관 기관: 4일, 최대 14일
- 낮은 레이턴시 (publish / receive에 10ms 미만)
- 각 메시지 전송에 256kb 제한
- 메시지 중복이 발생할 수 있음 (At-least-once delivery에 의해, 가끔 발생할 수 있음)
- 메시지 순서가 올바르지 않을 수 있음 (Best-effort message ~ 메시지 순서를 최대한 지키려 노력하긴 하지만, 보장하진 않음)
SQS - Producing Messages
- SDK를 사용하여 SQS로 메시지 생산 (SendMessage API)
- 해당 메시지는 consumer가 삭제하기 전까지 SQS 내에 지속됨
- 메시지 보관 기간: 기본 4일, 최대 14일
- 예시: 처리해야할 주문을 보내는 경우
- Order id
- Customer id
- Any attributes
- SQS standard: 무제한 처리량(throughput)
SQS - Consuming Messages
- Consumers (EC2 인스턴스, 서버 또는 AWS Lambda)
- 메시지를 받기 위한 SQS 폴링(Poll) - 한번에 최대 10개의 메시지 수신
- 메시지 처리 (ex. 받은 메시지를 RDS DB로 insert)
- DeleteMessage API를 사용하여 메시지 삭제
SQS - Multiple EC2 Instances Consumers
- Consumer들은 메시지 수신/처리를 병렬적으로 수행
- At-least-once delivery
- Best-effort message ordering
- Consumer들은 메시지를 처리한 이후에는 삭제함
- 메시지 처리량(throughput)을 향상시키기 위해 Consumer를 수평적으로 스케일링할 수 있음
SQS - with Auto Scaling Group (ASG)
- SQS 큐에 있는 메시지 갯수에 따라 CloudWatch 알람을 통해 ASG로 적절히 스케일링 할 수 있음
- 애플리케이션에 과부하가 온다면 일부 트랜잭션이 손실될 수 있음
- SQS 큐를 버퍼로 둘 때, 이는 무한하게 확장 가능하기 때문에, 어떻게든 트랜잭션이 결국 처리된다는 것을 보장할 수 있음
- 다만, 클라이언트에게 트랜잭션 결과를 바로 보여주어야 하는 경우엔 부적합할 수 있음
SQS - decouple between application tiers
SQS - Security
- Encryption:
- HTTPS API를 이용한 in-flight 암호화
- KMS 키를 이용한 At-rest 암호화
- 클라이언트가 자체적인 암호화/복호화를 수행하고자 원한다면 클라이언트 측 암호화도 가능
- Access Controls: SQS API에 대한 액세스를 조정하는 IAM 정책
- SQS Access Policies (S3 버킷 정책과 유사)
- 서로 다른 계정 간의 SQS 큐에 액세스해야 하는 경우 유용함
- 다른 서비스 (SNS, S3...)가 SQS 큐에 작성해야 하는 경우 유용함
SQS - Message Visibility Timeout
- 메시지가 consumer에 의해 폴링된 이후에는 다른 consumer들에게 보이지 않는 상태가 됨
- 기본적으로, "message visibility timeout"은 30초
- 이는 즉, 메시지 처리를 30초 내에 해야한다는 의미
- message visibility timeout이 지난 이후에야, 메시지는 SQS에서 보이는 상태가 됨
- 만약, visibility timeout 내에 메시지가 처리되지 않는다면, 위의 사진 예시의 경우 해당 메시지는 두번씩 처리가 됨
- consumer는 이에 따라, 만약 visibility timeout보다 메시지 처리 시간이 더 걸린다면, ChangeMessageVisibility API를 호출하여 더 시간을 가질 수 있음
- visibility timeout이 너무 크다면 (hours), consumer에 문제가 발생했을 때, 재처리(re-processing)에 많은 시간이 걸림
- visibility timeout이 너무 작다면 (seconds), 중복 처리가 발생할 수 있음
SQS - Long Polling
- consumer가 큐(queue ~ 대기열)로부터 메시지 요청을 보낼 때, 아직 큐에 있지 않은 메시지가 도착할 때까지 선택적으로 기다릴 수 있음
- 이를 Long Polling이라고 함
- Long Polling은 SQS에 이루어지는 API 호출 횟수를 감소시켜, 애플리케이션의 효율성과 레이턴시를 개선함
- wait time은 1초에서 20초 사이로 설정 가능 (20초 권장)
- Long Polling은 Short Polling보다 선호됨
- Long Polling은 큐 레벨에서 활성화되거나, WaitTimeSeconds를 통해 API 레벨에서 활성화될 수 있음
SQS - FIFO Queue
- FIFO = First In First Out (큐 내 메시지의 순서)
- 제한적인 처리량(throughput): 배칭(batching)없이 300msg/s, 배칭있는 경우 3000msg/s
- Exactly-once send capability (중복 제거)
- Consumer의 처리 순서를 보장할 수 있음
SNS
- 만약 하나의 메시지를 많은 receiver들에게 전달하고자 한다면 어떻게 해야 할까?
- 하나의 애플리케이션이 각각의 receiver들에게 직접 메시지를 전달하는 방법도 있겠지만, 이 경우, receiver가 추가될 때마다 일일이 애플리케이션 코드를 수정해야 하는 문제가 발생
- 이를 해결하기 위한 방법이 Pub/Sub(게시/구독) 패턴
SNS - Overview
- event producer는 오직 하나의 SNS topic에만 메시지를 보냄
- event receiver(구독)는 원하는 만큼 많이 둘 수 있음
- 해당 topic을 구독하는 각 subscriber(= receiver)는 모든 메시지를 수신함 (메시지 필터 기능을 쓰지 않는다면)
- 각 topic 별로 최대 12,500,000 구독을 할 수 있음
- 최대 100,000 topic
SNS - SNS integrates with a lot of AWS services
- 여러 AWS 서비스들이 알림(notification)을 위해 직접 SNS에 데이터를 전송할 수 있음
SNS - How to publish
-
Topic Publish (SDK 사용)
- 토픽 생성
- (하나 혹은 여러 개의) 구독 생성
- 토픽을 구독
-
Direct Publish (모바일 앱 SDK 사용)
- 플랫폼 애플리케이션 생성
- 플랫폼 엔드포인트 생성
- 플랫폼 엔드포인트를 구독
- Google GCM, Apple APNS, Amazon ADM 등과 호환
SNS - Security
-
Encryption:
- HTTPS API를 통한 In-flight encryption
- KMS 키를 통한 At-rest encryption
- 클라이언트가 자체적으로 암호화/복호화를 원하는 경우 클라이언트 측 암호화 가능
-
Access Controls: SNS API로의 액세스를 조정하는 IAM 정책
-
SNS Access Policies (S3 버킷 정책과 유사)
- 서로 다른 계정 간의 SNS 토픽에 액세스해야 하는 경우 유용함
- 다른 서비스(SNS, S3...)가 SQS 토픽에 작성해야 하는 경우 유용함
SNS + SQS
Fan Out
- SNS에 한번 푸쉬하고, 모든 SQS 큐를 구독자로 두어 이를 수신하도록 하는 방법
- 완전히 디커플링할 수 있음 / 데이터 손실 없음
- SQS는 다음과 같은 것들을 처리: Data Persistence(데이터 지속성), 지연 시간이 존재하는 처리 & 동일 작업의 수행
- 시간이 지남에 따라 더 많은 SQS 구독자를 추가할 수 있음
- SQS 큐의 Access Policy가 SNS에게 쓰기 권한을 허용하는지 확인할 것
- Cross-Region Delivery: SQS 큐는 다른 리전에서도 동작함
Fan Out - Application: S3 Events to multiple queues
- event type(e.g., object create) 과 prefix(e.g., images/)
- 오직 하나의 S3 Event rule만 가질 수 있음
- 만약 여러 개의 SQS 큐에 동일한 S3 이벤트를 전달하고자 한다면, fan-out을 사용
- SQS 큐 뿐만 아니라, Lambda로도 가능
Application: SNS to Amazon S3 through Kinesis Data Firehose
- SNS는 Kinesis로도 전송할 수 있으며, 이를 통해 아래와 같은 솔루션 아키텍처를 가질 수도 있음
SNS - FIFO Topic
- FIFO = First In First Out (토픽 내 메시지의 순서)
- SQS FIFO와 유사한 기능
- 메시지 그룹 ID 기반으로 정렬 (동일한 그룹 내 모든 메시지가 정렬됨)
- Deduplication ID 또는 Content Based Deduplication를 통한 중복제거
- 오직 SQS FIFO 큐만을 구독자로 둘 수 있음
- 제한된 처리량 (SQS FIFO와 동일한 처리량)
SNS - SNS FIFO + SQS FIFO: Fan Out
- Fan Out + 정렬 + 중복 제거가 모두 필요한 상황에서 사용
SNS - Message Filtering
- SNS 토픽의 구독 대상들에게 전달되는 메시지를 필터링 하는 데에 사용하는 JSON 정책
- 별도로 필터 정책이 없는 경우, 모든 메시지를 받게 됨
Kinesis
Kinesis - Overview
- 실시간으로 데이터를 수집, 처리, 분석하기 쉽게 만들어 줌
- 실시간으로 다음과 같은 데이터들을 수집:
- 애플리케이션 로그
- 통계, 지표
- 웹사이트 클릭스트림
- IoT 원격 측정 데이터
- Kinesis Data Streams: 데이터 스트림을 캡처, 처리, 보관
- Kinesis Data Firehose: 데이터 스트림을 AWS 데이터 스토어로 불러옴
- Kinesis Data Analytics: SQL 또는 Apache Flink로 데이터 스트림 분석
- Kinesis Video Streams: 비디오 스트림을 캡처, 처리, 보관
Kinesis Data Streams
- 1일 ~ 365일 동안 보관
- 데이터를 재처리(재실행)할 수 있음
- 일단 데이터가 Kinesis로 삽입되고 나면, 삭제될 수 없음 (immutability)
- 동일한 파티션을 공유하는 데이터는 동일한 샤드로 이동 (ordering)
- Producers: AWS SDK, Kinesis Producer Library (KPL), Kinesis Agent
- Consumers:
- 직접 작성: Kinesis Client Library (KCL), AWS SDK
- 관리형: AWS Lambda, Kinesis Data Firehose, Kinesis Data Analytics
Kinesis Data Streams - Capacity Modes
- Provisioned mode:
- 프로비저닝 및 스케일링될 샤드의 개수를 직접 또는 API를 통해 선택
- 각 샤드는 입력에 1MB/s (또는 매초 1000 레코드)
- 갹 사드는 출력에 2MB/s (classic 또는 enhanced fan-out consumer의 경우)
- 매 시간 프로비전된 샤드 마다 비용 지불
- On-demand mode:
- 프로비저닝이나 용량을 관리할 필요가 없음
- 기본 용량으로 프로비전됨 (4MB/s 또는 매초 4000 레코드 입력)
- 지난 30일 동안 관측한 최대 처리량에 기반하여 자동 스케일링
- 시간 당 스트림 & GB 당 데이터 in/out에 따라 비용 지불
Kinesis Data Streams - Security
- IAM 정책을 통한 Control Access / Authorization
- HTTPS 엔드포인트를 통한 in-flight encryption
- KMS를 통한 at-rest encryption
- 클라이언트 측에서 데이터 암호화/복호화 가능 (더 어렵긴 함)
- Kinesis가 VPC에 액세스할 수 있도록 하는 VPC Endpoint
- CloudTrail을 통한 모니터링 API 호출
Kinesis Data Firehose
Kinesis Data Firehose - Overview
- 완전 관리형 서비스, 관리할 필요 없음, 자동 스케일링, 서버리스
- AWS: Redshift / S3 / OpenSearch
- 3rd party partner: Splunk / MongoDB / DataDog / NewRelic / ...
- Custom: 어떤 HTTP 엔드포인트로든 전송 가능
- Firehose를 통해 전송된 데이터 당 비용 지불
- 거의 실시간 (= 실시간 아님)
- 전체 배치(full batch)가 아닌 경우 최소 60초의 레이턴시 가짐
- 또는 한번에 최소 1MB가 넘는 경우 약간 기다려야 함
- 여러 데이터 포맷, conversion, transformation, compression을 지원
- AWS 람다를 통해 커스텀 데이터 변환 가능
- 백업 S3 버킷으로 failed 또는 모든 데이터를 전송 가능
Kinesis Data Stream vs Firehose
- Kinesis Data Streams
- 대규모 수집을 위한 스트리밍 서비스
- 커스텀 코드 작성 (producer / consumer)
- 실시간 (~200ms)
- 스케일링 관리 (샤드 스플리팅 / 머징 ~ merging)
- 1일 ~ 365일 동안 보관되는 데이터 스토리지
- 리플레이 기능 지원
- Kinesis Data Firehose
- S3 / Redshift / OpenSearch / 써드파티 / 커스텀 HTTP로 스트리밍 데이터 로드
- 완전 관리형
- 거의 실시간 (버퍼 시간 최소 60초)
- 자동 스케일링
- 데이터 스토리지 없음
- 리플레이 기능 지원하지 않음
Ordering Data
Ordering Data - Kinesis
- 만약 AWS에 길 위 100대 트럭(
truck_1
,truck_2
, ...)의 GPS 위치를 주기적으로 전송해야 한다고 생각해보자 - 여기서 트럭의 위치를 추적하기 위해, 각 트럭마다 순서대로 데이터를 consume하고자 하고자 한다
- 어떻게 Kinesis에 데이터를 전송해야 할까?
- 답은
truck_id
를 "Partion Key" 값으로 사용하는 것이다. - 똑같은 key는 항상 똑같은 shard에 저장되기 때문
- 답은
Ordering Data - into SQS
- SQS standard에서는 정렬이 없음
- SQS FIFO의 경우, Group ID를 사용하지 않는다면 메시지는 도착한 순서대로 consume되며, 이는 오직 하나의 consumer에 의해 이루어짐
- consumer의 개수를 늘리면서, 또 메시지가 서로 관련되어 있을 때 그룹화 하는 것도 원한다면
- Group ID를 사용해야 함 (Kinesis에서의 Partition Key와 유사함)
Ordering Data - Kinesis vs SQS Ordering
- 100개의 트럭, 5 Kinesis shards, 1 SQS FIFO가 있다고 가정
- *Kinesis Data Streams:
- 평균적으로 각 샤드 당 20개의 트럭 데이터
- 각 샤드 내에서 데이터를 정렬하게 됨
- 5개의 샤드가 있으므로, 병렬로 처리할 수 있는 최대 consumer 수는 5개
- 이에 따라, 최대 5MB/s를 수신할 수 있음
- SQS FIFO
- 오직 하나의 SQS FIFO 큐
- 100개의 Group ID
- 최대 100개의 consumer 가질 수 있음 (Group ID가 100개이기 때문)
- 매초 최대 300개의 메시지를 가질 수 있음 (또는, 배치를 사용할 경우 3000)
SQS vs SNS vs Kinesis
-
SQS
- consumer가 데이터를 pull해야함
- 데이터는 consume되고 나면 삭제되어야 함
- 원하는 만큼 많은 워커(= consumer)들을 가질 수 있음
- 처리량을 프로비전 할 필요 없음
- 오직 FIFO 큐를 통해서 정렬을 보장할 수 있음
- 개별 메시지 딜레이 기능
-
SNS
- 여러 subscriber들에게 데이터를 push
- 최대 12,500,000 subscriber
- 데이터는 지속(persist)되지 않음 (전송되지 않으면 사라짐)
- Pub/Sub (게시/구독)
- 최대 100,000 topic
- 처리량을 프로비전 할 필요 없음
- SQS와 함께 사용하여 fan-out 아키텍처 패턴을 구성할 수 있음
- SQS FIFO를 위한 FIFO 기능
-
Kinesis
- Standard: 데이터 pull
- 샤드마다 2MB
- Enhanced-fan out: 데이터 push
- consumer별 샤드마다 2MB
- 데이터 리플레이 가능
- 실시간 빅데이터 분석과 ETL(Extract, Transform, Load) 목적
- 샤드 레벨에서 정렬됨
- X일 이후 데이터가 만료됨
- Provisioned mode 또는 On-demand capacity mode 선택
- Standard: 데이터 pull
Amazon MQ
- SQS, SNS는 클라우드 네이티브 서비스: AWS 독점(proprietary) 프로토콜
- 온-프레미스를 통해 동작하는 기존 애플리케이션들은 다음과 같은 프로토콜을 사용했을 것임
- MQTT, AMQP, STOMP, Openwire, WSS
- 클라우드로 마이그레이팅 할 때, 애플리케이션을 SQS, SNS로 아예 다시 엔지니어링 하는 대신, Amazon MQ를 사용할 수 있음
- Amazon MQ는 RabbitMQ, ActiveMQ에 대한 관리형 메시지 브로커 서비스
- Amazon MQ은 SQS/SNS만큼 스케일링 할 수 없음
- Amazon MQ는 서버 위에서 동작하며, 장애 대처를 위해 Multi-AZ로 실행할 수 있음
- Amazon MQ queue 기능(~SQS)과, topic 기능(~SNS)를 모두 갖추고 있음
Amazon MQ - High Availability
Containers
Docker
Docker - What is Docker?
- Docker는 앱을 배포하기 위한 소프트웨어 개발 플랫폼
- 앱이 컨테이너에 패키지되어 어떤 OS에서든지 실행 가능함
- 앱은 어디서 실행하든 동일하게 실행됨
- 어떤 머신이든 동작
- 호환성 이슈 없음
- 예측 가능한 동작
- 작업 감소
- 유지 및 배포가 쉬움
- 어떤 언어/OS/기술과도 호환됨
- 사례:
- 마이크로서비스 아키텍처
- 온-프레미스에서 AWS 클라우드로 앱을 리프트-앤-시프트(lift-and-shift)
Docker - Where are Docker images stored?
- 도커 이미지들은 도커 저장소(repositories)에 저장됨
- Docker Hub (https://hub.docker.com)
- Public repo
- 여러 기술 또는 OS를 위한 베이스 이미지들을 사용 (e.g., Ubuntu, MySQL, ...)
- Amazon ECR (Amazon Elastic Container Registry)
- Private repo
- Public repo (**Amazon ECR Public Gallery - https://gallery.ecr.aws)
- Docker Hub (https://hub.docker.com)
Docker - Docker vs. Virtual Machines
- Docker는 "일종의" 가상화 기술이긴 하지만, 정확하게는 다름
- Resources가 호스트와 공유됨 => 하나의 서버에 여러 컨테이너가 존재
Docker - Docker Containers Management os AWS
- Amazon Elastic Container Service (Amazon ECS)
- 아마존의 자체적인 컨테이너 플랫폼
- Amazon Elastic Kubernetes Services (Amazon EKS)
- 아마존의 관리형 쿠버네티스 (오픈 소스)
- AWS Fargate
- 아마존의 자체적인 서버리스 컨테이너 플랫폼
- ECS, EKS와 호환
- Amazon ECR
- 컨테이너 이미지를 저장
Amazon ECS
ECS - EC2 Launch Type
- ECS = Elastic Container Service
- AWS에 도커 컨테이너를 실행 = ECS 클러스터에서 ECS Task를 실행
- EC2 Launch Type: 반드시 인프라(EC2 인스턴스)를 프로비전 & 유지 해야함
- 각 EC2 인스턴스는 반드시 ECS Agent를 실행하여 ECS 클러스터에 등록해야 함
- AWS가 컨테이너의 시작/중지를 맡아서 함
ECS - Fargate Launch Type
- AWS에 도커 컨테이너를 실행
- 인프라를 프로비전하지 않음 (EC2 인스턴스를 관리할 필요 없음)
- 서버리스!
- task를 정의하기만 하면 됨
- 내가 필요한 CPU / RAM에 따라 AWS는 단순히 ECS Task를 실행해줄 뿐임
- 스케일링을 위해서는 그냥 task의 갯수를 늘리면 됨, 쉬움 -> EC2 인스턴스 필요 없음
ECS - IAM Roles for ECS
-
EC2 Instance Profile (EC2 Launch Type only):
- ECS agent가 사용함
- ECS 서비스를 위한 API 호출
- CloudWatch 로그에 컨테이너 로그를 전송
- ECR로부터 도커 이미지를 가져옴
- Secret Manager 또는 SSM Parameter Store에 있는 민감한 데이터를 참조
-
ECS Task Role:
- 각 task에 특정한 role이 부여되도록 허용
- 각기 다른 ECS 서비스에 다른 role들을 사용
- Task Role은 task definition을 통해 정의됨
ECS - Load Balancer Integrations
- **Application Load Balancer(ALB)**가 지원되며, 거의 대부분의 상황에 적합함
- **Network Load Balancer(NLB)**는 높은 처리량 / 높은 성능이 요구되는 사례에 추천, 또는 AWS Private Link와 함께 쓰기 위해 사용
- Elastic Load Balancer 역시 지원되지만, 추천되지는 않음 (고급 기능을 사용할 수 없음 - Fargate 불가)
ECS - Data Volumes (EFS)
- ECS task에 EFS 파일 시스템을 마운트
- EC2와 Fargate launch type 모두와 호환
- 어떤 AZ에서 task가 실행되든 간에 EFS 파일 시스템 내에서 동일한 데이터를 공유함
- Fargate + EFS = Serverless
- 사례: 내 컨테이너에 대해 multi-AZ 간에 공유되는 스토리지를 지속해야 하는 경우
- 노트: S3는 파일 시스템으로 마운트될 수 없음
ECS - Auto Scaling
- 자동으로 ECS task의 desired number를 증가/감소
- Amazon ECS Auto Scaling은 AWS Application Auto Scaling을 사용
- ECS Service 평균 CPU 사용률
- ECS Service 평균 메모리 사용률 - RAM에 따른 스케일링
- 각 타겟 별 ALB 요청 수 - ALB로부터 통계 전달받음
- Target Tracking - 특정 CloudWatch 통계의 target value에 기반하여 스케일링
- Step Scaling - 특정 CloudWatch Alarm에 기반하여 스케일링
- Scheduled Scaling - 특정 날짜/시간에 기반하여 스케일링 (예측 가능한 변경의 경우)
- ECS Service Auto Scaling (task 레벨) != EC2 Auto Scaling (EC2 인스턴스 레벨)
- Fargate Auto Scaling은 훨씬 설정이 쉬움 (서버리스이기 때문)
ECS - Launch Type ~ Auto Scaling EC2 Instances
- 기반에 놓인 EC2 인스턴스를 추가함으로써 ECS Service Scaling을 수용
- Auto Scaling Group Scaling
- CPU 사용량에 기반하여 ASG를 스케일링
- 시간이 지남에 따라 EC2 인스턴스 추가
- ECS Cluster Capacity Provider
- ECS task를 위한 인프라에 대한 프로비저닝과 스케일링을 자동으로 처리하기 위해 사용
- Auto Scaling Group과 짝지어서 사용
- 용량이 부족할 때 EC2 인스턴스를 추가 (CPU, RAM...)
ECS - Solutions Architectures
ECR
- ECR = Elastic Container Registry
- AWS에 도커 이미지를 저장 및 관리
- Private 또는 Public repo (Amazon ECR Public Gallery ~ https://gallery.ecr.aws)
- ECS와 완전히 호환됨, S3 지원
- IAM을 통해 액세스 관리 (권한 에러 => policy 문제)
- 이미지 취약성 스캐닝(image vulnerability scanning), 버저닝, 이미지 태그, 이미지 라이프사이클 지원
Amazon EKS
EKS - Overview
- EKS = Elastic Kubernetes Service
- AWS에서의 관리형 쿠버네티스 클러스터를 실행하는 방법
- 쿠버네티스?
- 컨테이너형(보통은 Docker) 애플리케이션을 자동 배포, 스케일링, 관리해주기 위한 오픈소스 시스템
- ECS의 대체제로 쓰일 수 있고, 유사한 목표를 지녔으나 API가 다름
- EKS는
- EC2로 워커 노드를 배포할 수도 있고
- Fargate로 서버리스 컨테이너 배포를 할 수도 있음
- 사례:
- 온-프레미스 또는 다른 클라우드를 통해서 이미 쿠버네티스를 사용하고 있어 이를 AWS에 마이그레이션하고자 하는 경우
- 쿠버네티스는 클라우드에 구애받지 않음(cloud-agnostic) (어떤 클라우드에서건 사용될 수 있음 - Azure, GCP, ...)
EKS - Diagram
EKS - Node Types
- Managed Node Groups
- 노드(EC2 인스턴스)를 생성 및 관리해줌
- 노드는 EKS가 관리하는 ASG의 일부
- On-Demand 또는 Spot Instance 지원
- Self-Managed Nodes
- 노드를 직접 생성하여, EKS 클러스터에 등록하고, ASG가 이를 관리하도록 함
- prebuilt AMI를 사용할 수 있음 - Amazon EKS Optimized AMI
- On-Demand 또는 Spot Instance 지원
- AWS Fargate
- 별도의 유지관리 필요 없음; 노드 관리가 필요 없음
EKS - Data Volumes
- EKS 클러스터에 StorageClass manifest를 정의할 필요 있음
- Container Storage Interface(CSI) 호환 드라이버를 활용함
- 다음을 지원
- Amazon EBS
- Amazon EFS (Fargate 지원)
- Amazon FSx for Lustre
- Amazon FSx for NetApp ONTAP
AWS App Runner
- 웹 앱과 API 규모에 맞게 쉡개 배포해주는 완전 관리형 서비스
- 별도의 인프라 경험이 없어도 됨
- 소스 코드 또는 컨테이너 이미지만 있으면 됨
- 자동으로 웹 앱을 빌드 & 배포
- 다음 내용들을 자동으로 처리
- 스케일링
- 고가용성(HA)
- 로드 밸런서
- 암호화
- VPC 액세스 지원
- DB, 캐시, 메시지 큐 서비스와 연결됨
- 사례: 웹앱, API, 마이크로서비스, 빠른 프로덕션 배포
Serverless
What's serverless?
- 서버리스는 개발자들이 더 이상 서버를 관리할 필요가 없다는 새로운 패러다임
- 그냥 코드 / 함수를 배포
- 초기에는 Serverless == FaaS (Fucntion as a Service)
- 서버리스는 AWS Lambda로부터 시작되었으나, 이제는 그 외의 것들도 포함하기 시작함: 데이터베이스, 메시징, 스토리지 등등...
- 서버리스가 말그대로 서버가 없음을 의미하지는 않음
- 서버를 관리 / 프로비저닝할 필요가 없다는 의미
Serverless in AWS
- AWS Lambda
- DynamoDB
- AWS Cognito
- AWS API Gateway
- Amazon S3
- AWS SNS & SQS
- AWS Kinesis Data Firehose
- Aurora Serverless
- Step Functions
- Fargate
AWS Lambda
AWS Lambda - Why AWS Lambda
- Amazon EC2
- 클라우드에 있는 가상 서버
- RAM / CPU에 의해 제한됨
- 끊임없이 실행됨
- 스케일링 -> 서버의 추가/제거를 중재
- Amazon Lambda
- 가상 함수 - 관리할 서버가 따로 없음
- 시간에 제약 - 짧게 실행
- 온-디맨드로 실행됨
- 스케일링이 자동으로 이루어짐
AWS Lambda - Benefits of AWS Lambda
- 간단한 요금 책정
- 각 요청과 컴퓨팅 시간을 기반으로 지불
- 프리티어 - 1,000,000 AWS Lambda 요청 & 400,000 GB의 컴퓨팅 시간
- 전체 AWS 서비스들와 호환
- 여러 프로그래밍 언어와 호환
- AWS CloudWatch를 통해 쉽게 모니터링
- 각 함수 별로 리소스 할당을 쉽게 할 수 있음 (최대 10GB RAM)
- RAM을 상승시키면 CPU와 네트워크 성능도 향상됨
AWS Lambda - Language support
- Node.js (JS)
- Python
- Java (Java 8 compatible)
- C# (.NET Core)
- Golang
- C# / Powershell
- Ruby
- Custom Runtime API (커뮤니티 지원, ex. Rust)
- Lambda Container Image
- 컨테이너 이미지가 반드시 Lambda Runtime API를 구현해야 함
- 그 외의 임의의 Docker 이미지를 실행하는 데에는 ECS / Fargate가 선호됨
AWS Lambda - Integrations Main ones
- API Gateway
- Kinesis
- DynamoDB
- S3
- CloudFront
- CloudWatch Events EventBridge
- CloudWatch Logs
- SNS
- SQS
- Cognito
AWS Lambda - Pricing Example
- 각 호출마다:
- 첫 1,000,000개의 요청은 무료
- 그 이후의 1,000,000 요청마다 0.0000002)
- 각 이용시간마다: (1ms 단위로)
- 매 달 400,000GB-seconds의 컴퓨팅 시간에 대해선 무료
- == 1GB RAM인 함수로 400,000초
- == 128MB RAM인 함수로 3,200,000초
- 그 이후의 사용량에 대해서는 600,000GB-seconds에 대해 1.00
- 매 달 400,000GB-seconds의 컴퓨팅 시간에 대해선 무료
- 결론적으로, 매우 싸기 때문에 인기가 많음
AWS Lambda - Limits to Know ~ per region
- Execution:
- 메모리 할당: 128MB ~ 10GB (1MB 단위)
- 최대 실행 시간: 900s (15분)
- 환경 변수 (4KB)
- "함수 컨테이너(function container)"의 디스크 할당량 (in /tmp): 512MB ~ 10GB
- 동시 실행: 1000 (증가 가능)
- Deployment
- 람다 함수 배포 사이즈 (.zip으로 압축): 50MB
- 비압축 배포 사이즈 (code + dependencies): 250MB
- 시작 시 다른 파일들을 불러오고자 한다면
/tmp
디렉토리를 사용할 수 있음 - 환경 변수 사이즈: 4KB
AWS Lambda - Customization At The Edge
- 많은 최신 애플리케이션은 엣지에서 어떤 형태의 로직을 실행함
- Edge Function:
- CloudFront 배포에 작성 및 연결된 코드
- 레이턴시를 줄이기 위해 이용자에게 가까운 위치에서 실행됨
- CloudFront는 두 가지 타입의 방법을 제시
- CloudFront Functions
- Lambda@Edge
- 별도로 서버를 관리할 필요 없고, 글로벌로 배포됨
- 사례: CDN 컨텐츠를 커스터마이징
- 사용할 때에만 비용 지불
- 완전한 서버리스
AWS Lambda - CloudFront Functions & Lambda@Edge ~ Use Cases
- 웹사이트 보안 & 프라이버시
- 엣지에 동적 웹 앱 배포
- SEO
- 오리진 및 데이터 센터 간에 지능적인 라우팅
- 엣지에서 봇 차단(bot mitigation)
- 실시간 이미지 변환
- A/B 테스팅
- 이용자 인증(authentication) 및 권한 부여(authorization)
- 이용자 우선순위 지정
- 이용자 트래킹 & 분석
CloudFront Functions
- JS로 작성되는 가벼운 함수
- 지연 시간에 민감한, 대규모 CDN 커스터마이징에 적합
- ms 미만의 startup 시간, 초당 million(백만개) 요청
- viewer 요청과 응답을 변경하는 데에 사용
- Viewer Request: CloudFront가 viewer로부터 요청을 받은 후
- Viewer Response: CloudFront가 viewer로 응답을 보내기 전
Lambda@Edge
- NodeJS 또는 Python을 통해 작성되는 람다 함수
- 초당 1000개의 요청 수준으로 스케일링 가능
- CloudFront 요청/응답을 변경하기 위해 사용:
- Viewer Request - CloudFront가 viewer로부터 요청을 받은 이후
- Origin Request - CloudFront가 origin으로 요청을 보내기 전
- Origin Response - CloudFront가 origin으로부터 응답을 받은 이후
- Viewer Response - CloudFront가 viewer로 응답을 보내기 전
- 함수를 AWS 리전(us-east-1)에서 생성한 다음, CloudFront가 해당 위치로 복제
CloudFront Functions vs. Lambda@Edge
- CloudFront Functions
- Cache key normalization
- 요청 attributes들을 변환(헤더, 쿠키, 쿼리스트링, URL)하여 최적의 캐시 키를 생성
- Header manipulation
- 요청 또는 응답에서 HTTP 헤더를 삽입/수정/삭제
- URL 재작성 또는 리다이렉트
- 요청 인증 & 권한 부여
- 요청 허용/거부를 위해 이용자 생성 토큰(ex. JWT)을 생성 및 검증
- Cache key normalization
- Lambda@Edge
- 더 긴 실행 시간 (몇 ms)
- 조정 가능한 CPU 또는 메모리
- 써드파티 라이브러리에 의존 (ex. 다른 AWS 서비스에 접근하기 위해 AWS SDK 사용)
- 네트워크 액세스를 통해 처리에 필요한 외부 서비스를 사용
- 파일 시스템 액세스 또는 HTTP 요청의 바디에 액세스
AWS Lambda - by default
- 기본적으로, 람다 함수는 내가 보유한 VPC의 외부에서 실행됨 (AWS가 소유한 VPC에서)
- 그래서 내 VPC에 있는 리소스들에 액세스할 수 없음 (RDS, ElastiCache, internal ELB...)
AWS Lambda - in VPC
- 반드시 VPC ID, 서브넷, Security Group을 정의해야 함
- Lambda가 내 서브넷 내에 ENI(Elastic Network Interface)를 생성함
AWS Lambda - with RDS Proxy
- 만약 Lambda 함수가 데이터베이스에 직접 연결된다면, 부하가 높은 상태에서 너무 많은 DB open이 일어날 것임
- RDS Proxy
- DB 연결을 풀링 및 공유함으로써 scalability 향상
- failover 시간을 66% 감소시키고, 연결을 보존함으로써 availability 향상
- IAM 인증을 적용하고 Secret Manager에 자격증명을 보관함으로써 security 향상
- 람다 함수는 반드시 VPC에 배포되어야 함 -> *RDS Proxy는 절대로 public하게 접근가능해서는 안되기 때문
AWS Lambda - Invoking Lambda from RDS & Aurora
- DB 인스턴스 내에서 람다 함수를 호출
- DB 내에서 데이터 이벤트를 처리할 수 있음
- RDS for PostgreSQL과 Aurora MySQL을 지원
- DB 인스턴스에서 Lambda 함수로 향하는 아웃바운드 트래픽을 반드시 허용해야 함 (Public, NAT GW, VPC Endpoints)
- DB 인스턴스는 반드시 Lambda 함수를 실행하기 위해 요구되는 권한을 갖고 있어야 함 (Lambda Resource-based Policy & IAM Policy)
RDS Event Notifications
- DB 인스턴스 자체에 대한 정보(생성, 중지, 시작, ..)를 알려주는 Notification
- 데이터 자체에 대한 정보는 얻을 수 없음
- 다음의 이벤트 카테고리들을 구독:
- DB instance, DB snapsho, DB Parameter Group, DB Security Group, RDS Proxy, Custom Engine Version
- 거의 실시간 이벤트 (최대 5분)
- SNS로 알림을 전달하거나 EventBridge를 통해 이벤트를 구독
Amazon DynamoDB
- multi AZ로 replication을 함으로써 고가용성(high availability)을 지닌 완전 관리형 DB
- NoSQL DB (Not RDB) - transaction 지원
- 많은 양의 워크로드에 대해서도 스케일링 가능, 분산형 DB
- 매초 1 million 요청, 1 trillion 행, 100TB 스토리지
- 빠르고, 일관적인 성능 (single-digit ms)
- 보안, 인증 및 관리를 위해 IAM 지원
- 낮은 비용, 자동 스케일링 기능
- 유지보수하거나 패칭(patch)할 필요 없음, 항상 available함
- Standard & Infrequent Access (IA) Table Class
DynamoDB - Basics
- DynamoDB는 Table들로 구성
- 각 테이블은 Primary Key를 가짐 (생성 시점에 반드시 결정되어야 함)
- 각 테이블은 무한한 개수의 Item(= row)를 가질 수 있음
- 각 아이템은 attribute를 가짐 (추후에도 추가될 수 있음 - null일 수 있음)
- 한 아이템의 최대 크기는 400KB
- 다음의 데이터 타입들이 지원됨:
- Scalar Types - String, Number, Binary, Boolean, Null
- Document Types - List, Map
- Set Types - String Set, Number Set, Binary Set
- 결국, DynamoDB에서는 빠르게 스키마를 발전시킬 수 있음
DynamoDB - Read/Write Capacity Modes
-
테이블 가용량(capcity)을 어떻게 관리할 것인지를 설정 (read/write throughput)
-
Provisioned Mode (기본)
- 초당 read/write 개수를 정의
- 미리 capacity를 계획해야 함
- 프로비전된 Read Capacity Unit(RCU)와 Write Capacity Units(WC)만큼 비용 지불
- RCU & WCU에 대한 오토 스케일링 모드를 추가할 수 있음
-
On-Demand Mode
- 워크로드에 따라 자동으로 R/W가 스케일링됨
- 따로 capacity를 계획하지 않아도 됨
- 사용한 만큼 더 많은 비용 지불
- 예측이 불가능한 워크로드, steep sudden spikes에 유용함,
DynamoDB - DynamoDB Accelerator (DAX)
- DynamoDB를 위한 fully-managed, highly available, seamless 인-메모리 캐시
- 캐싱을 통해 읽기 혼잡도를 해결하는데 도움
- 캐시된 데이터에 대해 ms 단위 레이턴시
- 별도의 애플리케이션 로직 수정을 요구하지 않음 (기존 DynamoDB API들과 호환됨)
- 캐시에 5분의 TTL (기본)
DynamoDB - DynamoDB Accelerator (DAX) vs. ElastiCache
- Amazon ElasticCache
- aggregatio 결과를 저장
- DynamoDB Accelerator (DAX)
- 개별 오브젝트 캐시
- 캐시를 쿼리 & 스캔
DynamoDB - Stream Processing
- 테이블에서의 item 수정(create/update/delete)의 ordered stream
- 사례:
- 실시간 변화에 대한 반응 (이용자들에게 welcome email 전송)
- 실시간 사용 분석
- 파생(derivative) 테이블에 insert
- cross-region replication 수행
- DynamoDB 테이블의 변경에 따른 AWS Lambda 실행
- DynamoDB Streams
- 24시간 보존
- 제한된 수의 consumer
- AWS Lambda Trigger 또는 DynamoDB Stream Kinesis adapter를 통해 처리
- Kinesis Data Streams (newer)
- 1년 보존
- 많은 수의 consumer
- AWS Lambda, Kinesis Data Analytics, Kinesis Data Firehose, AWS Glue Streaming ETL 등을 통해 처리
DynamoDB - Global Tables
- 여러 리전 내에 DynamoDB 테이블을 낮은 레이턴시로 액세스 가능한 상태로 만들어 줌
- Active-Active replication
- 어떤 리전에서든 READ, WRTIE작업을 수행할 수 있음
- 전제조건(pre-requisite)으로, DynamoDB Stream이 활성화되어 있어야 함
DynamoDB - Time To Live (TTL)
- 만료(expiry) 타임스탬프가 지나면 아이템을 삭제
- 사례:
- 오직 현재 item들만 보유함으로써 저장된 데이터 줄이기
- 규제 의무(regulatory obligations) 준수
- 웹 세션 핸들링
DynamoDB - Backups for disaster recovery
- point-in-time recovery(PITR)를 통한 지속적인 백업
- 지난 35일 동안 선택적으로 활성화
- Point-in-time recovery -> 백업 기간 내라면 어느 시점이건 가능
- recovery 작업은 새로운 테이블을 생성함
- On-demand backups
- 명시적으로 삭제하기 전까지 장기 보존을 위해 전체 백업
- 성능이나 레이턴시에 영향을 끼치지 않음
- AWS Backup 내에서 설정하거나 관리할 수 있음 (리전 간 복사 가능)
- recovery 작업은 새로운 테이블을 생성함
DynamoDB - Inregration with Amazon S3
-
S3로 내보내기 (PITR 활성화 필수) ~ export
- 최근 35일 동안의 어느 시점이건 실행 가능
- 테이블의 읽기 가용량에 영향을 끼치지 않음
- DynamoDB의 상위에서 데이터 분석을 수행
- 감사(audit)를 위해 스냅샷을 유지
- DynamoDB로 다시 가져오기 전에 S3 데이터 위에 ETL(Extract, Load, Transfer)을 수행
- Dynamo JSON 또는 ION 포맷으로 내보내기
-
S3로부터 가져오기 ~ import
- CSV, Dynamo JSON 또는 ION 포맷으로 임포트
- 어떤 write capacity도 소비하지 않음
- 새로운 테이블을 생성함
- 임포트 에러 발생 시 CloudWatch 로그로 로깅됨
AWS API Gateway
API Gateway - Overview
- AWS Lambda + API Gateway: 관리가 필요한 인프라가 없음
- WebSocker 프로토콜 지원
- API 버저닝 (v1, v2, ...)
- 저마다 다른 환경(dev, test, prod..) 처리
- 보안 처리 - 인증 및 권한 부여
- API 키 생성, 요청 쓰로틀링 처리
- 빠른 API 정의를 위한 Swagger / Open API 가져오기
- 요청/응답 검증 및 변환
- SDK 및 API 사양(specification) 생성
- API 응답 캐시
API Gateway - Integrations High Level
- Lambda Function
- 람다 함수 실행
- AWS 람다를 통한 REST API 백엔드를 노출(expose)시키는 쉬운 방법
- HTTP
- 백엔드에 HTTP 엔드포인트를 노출
- 사례: 온-프레미스에서의 내부 HTTP API, ALB...
- 왜? - 속도 제한, 캐싱, 이용자 인증, API 키, 등등..
- AWS Service
- API Gateway를 통한 어떤 AWS API든 노출
- 사례: AWS Step Function workflow 실행, SQS에 메시지 전송
- 왜? - 인증 추가, 공개 배포, 속도 제한...
API Gateway - Endpoint Types
- Edge-Optimized (기본):
- 글로벌 클라이언트 대상
- CloudFront 엣지 로케이션을 통해 요청이 라우트됨 (latency 상승)
- API Gateway 자체는 여전히 하나의 리전에 존재함
- Regional:
- 동일한 리전 내에 있는 클라이언트 대상
- CloudFront와 매뉴얼하게 조합될 수 있음 (캐싱 전략 및 배포에 대해 더 많은 제어 가능)
- Private:
- 인터페이스 VPC 엔드포인트(ENI)를 통해, 오직 내 VPC로부터만 액세스가 가능함
- 액세스를 정의하는 리소스 정책(resource policy)을 사용
API Gateway - Security
- User Authentication ~ 아래의 것들을 사용
- IAM Roles (내부적인 애플리케이션에 유용)
- Cognito (외부 이용자들을 식별 ~ ex. mobile users)
- Custom Authorizer (직접 로직 작성)
- Custom Domain Name HTTPS ~ AWS Certificate Manager(ACM)과 함께 사용
- Edge-Optimized 엔드포인트를 사용하는 경우, 반드시 인증서가 us-east-1에 있어야 함
- Regional 엔드포인트를 사용하는 경우, 반드시 API Gateway가 위치한 리전에 인증서가 있어야 함
- Route 53 내에 CNAME 또는 A 레코드가 반드시 설정되어야 함
AWS Step Functions
- 람다 함수를 오케스트레이션하기 위해 서버리스의 시각적인 워크플로를 구축
- 기능: sequence, parallel, conditions, timeouts, error handling, ...
- EC2, ECS, 온-프레미스 서버, API 게이트웨이, SQS 큐 등과 호환
- human approval한 기능을 만들 수 있음
- 사례: 주문 처리, 데이터 프로세싱, 웹 애플리케이션, 어떤 워크플로든지
Amazon Cognito
- 웹/모바일 애플리케이션과 상호작용하는 이용자들을 식별하기 위함
- Cognito User Pools:
- 애플리케이션 이용자들에 대한 로그인(sign in) 기능
- API Gateway & ALB와 호환
- Cognito Identity Pools (Federated Identity):
- 이용자들에게 AWS credential을 부여하여 AWS 리소스들에 직접 액세스할 수 있도록 함
- Cognito User pool을 ID 공급자로서 활용
- Cognito vs IAM: "hundreds of users", "mobile users", "authenticate with SAML"
Cognito - Cognito User Pools(CUP) ~ User Features
- 웹/모바일 앱 이용자에 대한 서버리스 DB 생성
- 간단하게 로그인: Username (or email) / password
- 패스워드 리셋 기능
- 이메일 & 휴대폰 번호 인증
- MFA 인증
- Federated Identities: 페이스북 이용자, Google 이용자, SAML...
Cognito - Cognito User Pools(CUP) ~ Integrations
- CUP는 API Gateway와 Application Load Balancer와 호환됨
Cognito - Cognito Identity Pools (Federated Identities)
- 이용자가 임시 AWS 자격 증명을 얻을 수 있도록 이용자들의 ID를 가져옴
- 이용자 소스는 Cognito User Pools나 그외의 써드 파티 로그인 등이 될 수 있음
- 이용자들은 직접 또는 API Gateway를 통해 AWS 서비스들에 액세스할 수 있음
- IAM 정책은 Cognito 내에 정의된 credential에 적용됨
- 세밀한 컨트롤(fine grained control)을 위해 user_id에 기반하여 커스터마이징 할 수 있음
- 따로 정의해두지 않으면 인증 이용자, 게스트 이용자들에게는 Default IAM role이 적용됨
Cognito - Cognito Identity Pools ~ Row Level Security in DynamoDB
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:DeleteItem",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
"dynamodb:UpdateItem"
],
"Resource": ["arn:aws:dynamodb:*:*:table/MyTable"],
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["{cognito-identity.amazonaws.com:sub}"] // <-- HERE
}
}
}
]
}
Databases
Choosing the right batabase
- AWS에는 선택 가능한 수많은 종류의 관리형 데이터베이스가 있음
- 아키텍처에 기반하여 올바른 DB를 선택:
- 어떤 형태의 워크로드를 수행하는가? - Read-heavy / write-heavy / balanced?
- Throughput이 요구 되는가?
- 하루 동안에 스케일링 또는 변동(fluctuate)가 요구되는가?
- 데이터가 얼마나 많이, 얼마나 오래 보관되는가? 더 커질 예정인가? 평균 오브젝트 사이즈는? 액세스 처리는 어떻게 할 것인가?
- 데이터 지속성(durability)은 어떤가? 데이터의 신뢰할 수 있는 출처는 어디인가?
- 레이턴시 요구사항은 어떤가? 여러 이용자가 동시에 이용할 수 있는가?
- 데이터 모델은? 어떻게 데이터를 쿼리할 것인가? joins? structured? semi-structured?
- 강한 스키마를 갖는가? 더 유연해야 하는가? 리포팅과 검색은? RDBMS / NoSQL?
- 라이센스 비용은? Aurora 같은 클라우드 네이티브 DB로 옮길 가능성이 있나?
Database Types
- RDBMS (= SQL / OLTP): RDS, Aurora - join 작업에 유용
- NoSQL - no joins, no SQL: DynamoDB(~JSON), ElastiCache (key/value pairs), Neptune (graphs), DocumentDB (for MongoDB), Keyspaces (for Apache Cassandra)
- Object Store: S3 (큰 오브젝트의 경우) / Glacier (백업 / 아카이빙 용도)
- Data Warehouse (= SQL Analytics / BI): Redshift (OLAP), Athena, EMR
- Search: OpenSearch (JSON) - 자유 텍스트, 비정형 검색(unstructured searches)
- Graph: Amazon Neptune - 데이터 간의 관계 표시
- Ledger: Amazon Quantum Ledger Database
- Time series: Amazon Timestream
Amazon RDS - Summary
- 관리형 PostgreSQL / MySQL / Oracle / SQL Server / MariaDB / 커스텀
- 프로비전된 RDS 인스턴스 사이즈와 EBS Volume 타입 & 사이즈
- 스토리지에 대한 가용량(capacity)를 오토 스케일링
- Read Replica와 Multi AZ 지원
- IAM, Security Groups, KMS, SSL in transit을 통한 보안
- Point-in-time 복구 기능으로 자동 백업 (최대 35일까지)
- 장기 복구를 위한 수동 DB 스냅샷
- 유지보수 관리 및 예약 (downtime 포함)
- IAM 인증 및 Secret Manager와의 호환
- RDS 인스턴스에 접근 및 커스터마이징하기 위하여 RDS Custom 사용 가능 (Oracle & SQL Server)
사례: 관계형 데이터셋 저장(RDBMS / OLTP), SQL 쿼리와 트랜잭션을 수행해야 하는 경우
Amazon Aurora - Summary
- PostgreSQL / MySQL과 호환가능한 API, 스토리지와 컴퓨팅 분리
- Storage: 데이터가 3개의 AZ에 걸쳐 6개의 replica에 저장됨 - high available, self-healing, auto-scaling
- Compute: multi-AZ를 통한 DB 인스턴스 클러스터, Read Replica에 대한 오토 스케일링
- Cluster: writer와 reader DB 인스턴스에 대한 커스텀 엔드포인트
- RDS와 동일한 보안 / 모니터링 / 유지보수 기능
- Aurora의 백업 및 복원 옵션
- Aurora Serverless - 예측 불가능한 / 간헐적(intermittent) 워크로드, capacity planning 필요없음
- Aurora Multi-Master - 지속적인 write 장애 조치를 위함 (high write availability)
- Aurora Global: 각 리전마다 최대 16개의 DB 읽기 인스턴스, 1초 이하로 걸리는 스토리지 복제
- Aurora Machine Learning: SageMaker와 Comprehend를 통해 Aurora에서 ML을 수행
- Aurora Database Cloning: 기존에 존재하는 클러스터를 통해 새 크러스터를 만듬, 스냅샷 복구보다 빠름
사례: RDS와 동일하지만, 유지보수에 대한 신경이 덜하고 / 더 유연하며 / 더 성능 좋고 / 더 많은 기능을 보유
Amazon ElastiCache - Summary
- 관리형 Redis / Memcached (RDS와 유사하지만, 캐시를 위한 것임)
- 인-메모리 데이터 스토어, ms 미만의 레이턴시
- 반드시 EC2 인스턴스 타입을 프로비전
- 클러스터링(Redis)과, Multi AZ, Read Replica (sharding)을 지원
- IAM, Security Groups, KMS, Redis Auth를 통한 보안
- 백업 / 스냅샷 / Point-in-time 복구 기능
- 유지보수 관리 및 예약
- 활용하기 위해서는 애플리케이션 코드에 대한 변경이 필요함
사례: Key/Value 스토어, 빈번한 read / 적은 write, DB 쿼리 결과 캐싱, 웹사이트 세션 데이터 저장, SQL은 쓸 수 없음
Amazon DynamoDB - Summary
- AWS 독점의 관리형 서버리스 NoSQL 데이터베이스, ms 단위 레이턴시
- capacity mode:
- provisioned capacity (오토 스케일링 옵션 있음)
- on-demand capacity
- key/value 스토어로서 ElastiCache 대체 가능 (ex. TTL 기능을 사용하여 세션 데이터 저장)
- 기본적으로 High Available, Multi AZ이며, 읽기/쓰기 작업이 분리(decoupled)되어 있고, 트랜잭션 기능 있음
- 읽기 작업 캐싱을 위한 DAX cluster - ms 단위의 읽기 레이턴시
- IAM을 통해 보안, 인증, 권한 부여 처리
- 이벤트 처리: DynamoDB Stream은 AWS Lambda 또는 Kinesis Data Stream과 호환됨
- Global Table 기능: active-active 셋업
- 최대 35일까지 PITR(point-in-time recovery)를 통한 자동 백업, 또는 on-demand 백업
- Read Capacity Unit(RCU) 없이도 PITR window 내에서 S3로 내보내기 가능, WCU(Write Capacity Unit) 없이도 S3로부터 가져오기 가능
- 빠르게 발전하는 스키마에 유용함
사례: 서버리스 애플리케이션 개발 (데이터가 작을 때, < 100KB), 분산된 서버리스 캐시
Amazon S3 - Summary
- 오브젝트에 대한 key/value 스토어
- 오브젝트가 큰 경우에 유용, 여러 개의 작은 오브젝트들에는 썩 좋지 않음
- 서버리스이고, 무한하게 확장하며, 최대 오브젝트 사이즈는 5TB, 버저닝 기능 있음
- Tiers: S3 Standard, S3 Infrequent Access, S3 Intelligent, S3 Glacier + lifecycle policy
- Features: Versioning, Encryption, Replication, MFA-Delete, Access Logs, ...
- Security: IAM, Bucket Policies, ACL, Access Points, Object Lambda, CORS, Object/Vault Lock
- Encryption: SSE-S3, SSE-KMS, SSE-C, client-side, TLS in transit, default encryption
- Batch operations: S3 Batch를 이용해 오브젝트들에 대한 배치 작업, S3 Inventory를 사용해 파일 리스팅
- Performance: Multi-part upload, S3 Transfer Acceleration, S3 Select
- Automation: S3 Event Notifications (SNS, SQS, Lambda, EventBridge)
사례: 정적 파일, 거대한 파일들에 대한 key-value 스토어, 웹사이트 호스팅
DocumentDB
- Aurora가 PostgreSQL / MySQL의 AWS 구현 버전이었다면, DocumentDB는 MongoDB를 위한 AWS 구현 버전 (이것도 NoSQL)
- MongoDB는 JSON 데이터를 저장, 쿼리, 인덱싱하기 위해 사용됨
- Aurora의 배포 컨셉(deployment concept)과 유사함
- 완전 관리형이며, 3개의 AZ에 복제함으로써 highly available
- DocumentDB 스토리지는 10GB씩 자동으로 확장됨, 최대 64TB
- 초당 수백만(million) 건의 요청이 발생하는 워크로드에 맞게 자동으로 확장됨
Amazon Neptune
- 완전 관리형 그래프(graph) 데이터베이스
- 소셜 네트워크와 같은 graph dataset에 사용됨
- user는 friend들을 가짐
- post는 comments들을 가짐
- comment는 user로부터 like를 가짐
- user는 post를 share하거나 like를 함
- 3개의 AZ에 걸쳐있고, 최대 15개의 read replica를 통해 highly available함
- 서로 강하게 연결되어 있는 데이터셋들을 사용하는 애플리케이션을 빌드 및 실행
- 이러한 복잡하고 어려운 쿼리들에 최적화됨
- 최대 10억(billion)개의 관계(relation)을 저장하며, 그래프 쿼리를 ms단위로 수행할 수 있음
- multi AZ에 replication을 두어 Highly available함
- 지식 그래프(knowledge graphs ~ ex. Wikepedia), 사기 감지(fraud detaction), 추천 엔진, 소셜 네트워킹에 유용함
Amazon Keyspaces (for Apache Cassandra)
- Apache Cassandra - 오픈소스 NoSQL 분산형 데이터베이스
- 관리형 Apache Cassandra 호환 데이터베이스 서비스
- 서버리스, scalable, high available, fully managed by AWS
- 애플리케이션 트래픽에 기반하여 테이블 개수를 자동으로 scale up/down
- 테이블은 multi AZ로 3번 복제됨
- CQL(Cassandra Query Language) 사용
- 어떤 스케일링이든 한자릿수 ms 레이턴시, 매초 1000개의 요청
- Capacity: on-demand mode 또는 provision mode(오토 스케일링 가능)
- 암호화, 백업, 최대 35일까지 Point-In-Time Recovery 가능
사례: IoT 디바이스 정보 저장, 시계열(time-series) 데이터
Amazon QLDB
-
QLDB - Quantum Ledger Database
-
ledger - 금융 거래(financial transactions)를 기록하는 장부
-
완전 관리형, 서버리스, HA, Replication across 3 AZ
-
시간이 지나도 애플리케이션 데이터에 이루어진 모든 변화의 히스토리를 리뷰해야하는 경우에 사용
-
Immutable 시스템: 어떤 항목도 삭제되거나 수정될 수 없음, 암호화 검증 가능(cryptographically verifiable)
-
일반적인 ledger 블록체인 프레임워크보다 2-3배 더 좋은 성능, SQL로 데이터 조작
-
Amazon Managed Blockchain과의 차이: 탈중앙화(decentralization) 컴포넌트가 아님, 금융 규체 규칙(financial regulation rules)에 따름
Amazon Timestream
- 시계열(time series) 데이터베이스
- 완전 관리형, 빠름, scalable, 서버리스
- 자동으로 capacity를 scale up/down
- 매일 1조(trillion)개의 이벤트를 저장 및 분석
- 1000배 빠름 & 관계형 DB보다 1/10의 비용
- 예약 쿼리(Scheduled queries), 다중 측정 레코드(multi-measure records), SQL 호환성
- Data storage tiering: 최근의 데이터는 메모리에 저장되고, 과거 데이터는 비용 효율적인 스토리지에 저장됨
- 빌트인 시계열 분석 함수 (실시간으로 데이터에 대한 패턴을 파악하는데 도움을 줌)
- in-transit 암호화, at-rest 암호화
사례: IoT 앱, 운영 앱, 실시간 분석, ...
Data & Analytics
Amazon Athena
- S3에 저장된 데이터를 분석하기 위한 서버리스 쿼리 서비스
- 파일을 쿼리하기 위해 표준 SQL 언어를 사용 (Presto)
- CSV, JSON, ORC, Avro, Parquest 지원
- 비용: 스캔된 데이터 1TB당 5.00
- 일반적으로 Amazon Quicksight와 함께 사용하여 리포트/대쉬보드 생성
- 사례: 비즈니스 인텔리전스 / 분석 / VPC Flow Logs, ELB Logs, CloudTrail trails 등에 대한 리포팅 & 분석 & 쿼리
- 팁: 서버리스 SQL로 S3 내 데이터를 분석하고 싶다? -> Athena를 쓴다!
Athena - Performance Improvement
- 비용 절감을 위해서는 columnar data를 사용할 것 (스캔이 덜 일어남)
- Apache Parquet 또는 ORC 추천
- 높은 성능 향상
- Parquet 또는 ORC로 데이터를 변환하려면 Glue를 사용
- 더 작은 검색을 위해 데이터 압축 (bzip2, gzip, lz4, snappy, zlip, zstd...)
- 가상 컬럼을 쉽게 쿼리하기 위해 S3 내 데이터셋을 파티션
- s3://yourBucket/pathToTable
- /<PARTITION_COLUMN_NAME>=<VALUE>
- /<PARTITION_COLUMN_NAME>=<VALUE>
- ...
- /<PARTITION_COLUMN_NAME>=<VALUE>
- /<PARTITION_COLUMN_NAME>=<VALUE>
- ex.
s3://athena-examples/flight/parquet/year=1991/month=1/day=1/
- s3://yourBucket/pathToTable
- 오버헤드를 줄이기 위해서는 큰 파일(> 128MB)을 사용
Athena - Federated Query
- 관계형/비관계형/오브젝트/커스텀 데이터 소스(AWS든 온-프레미스든)에 저장된 데이터 전반에 SQL 쿼리를 실행할 수 있음
- Data Source Connector를 사용하여 AWS Lambda가 Federated Queries를 실행하도록 할 수 있음 (e.g., CloudWatch logs, DynamoDB, RDS, ...)
- 그 결과도 S3에 다시 저장
Redshift
- Redshift는 PostgreSQL 기반이지만, OLTP(online transaction processing)를 위해 사용되는 것이 아님
- OLAP임 - online analytical processing (분석 및 데이터 웨어하우싱)
- 다른 데이터 웨어하우스보다 10배의 성능 & PB(페타바이트) 단위로 확장
- Columnar storage of data (row 기반 대신) & 병렬 쿼리 엔진
- 프로비전된 인스턴스에 기반하여 비용 지불
- 쿼리 수행에 SQL 인터페이스 사용 가능
- Amazon Quicksight 또는 Tableau와 같은 BI(Business intelligence) 툴과 호환
- vs Athena: 인덱싱 덕분에 쿼리 / 조인 / 병합(aggregation)이 더 빠름
Redshift - Cluster
- Leader node: 쿼리 플래닝, 결과 병합 목적
- Compute node: 쿼리 수행, leader node에게 결과 전달
- 미리 노드 사이즈를 프로비전해야함
- 비용 절감을 위해 Reserved Instance 사용할 수 있음
Redshift - Snapshots & DR
- Redshift는 대부분의 클러스터에 단일 AZ이지만, 일부 클러스터에 Multi-AZ 모드를 보유함
- 스냅샷은 클러스터 하나의 point-in-time 백업이며, 내부적으로 S3에 저장됨
- 스냅샷은 incremental함 (변경이 이루어진 것만 저장됨)
- 새 클러스터로 스냅샷을 복구할 수 있음
- Automated: 매 8시간, 매 5GB, 또는 스케줄에 따라, retention을 설정
- Manual: 스냅샷을 직접 삭제하기 전까지 보존됨
- Amazon Redshift가 자동으로 클러스터의 스냅샷(automated or manual)을 다른 AWS 리전으로 복사하도록 설정할 수 있음 (disaster recovery)
Redshift - Loading data into Redshift
- insert 작업은 거대하게 처리하는 편이 훨씬 좋음
- 다음을 통해 데이터를 가져올 수 있음
- Amazon Kinesis Data Firehose - S3 copy를 통해
- S3 Bucket - 인터넷 또는 VPC를 통해서
- EC2 Instance (/w JDBC driver) ~ 배치 단위로 데이터 write를 하는게 더 좋음
Redshift - Spectrum
- 이미 S3에 있는 데이터를 따로 가져오지 않고도 쿼리
- 쿼리를 시작하기 위해 사용가능한 Redshift 클러스터를 보유해야함
- 쿼리는 수천개의 Redshift Spectrum 노드에 전달됨
Amazon OpenSearch Service
- Amazon OpenSearch = Amazon ElasticSearch의 후속 서비스 (이름이 바뀜)
- DynamoDB에서, 쿼리 작업은 반드시 primary key나 index를 포함해야 함
- OpenSearch로, 어떤 필드든 검색 가능하고, 심지어 부분 일치에 대한 검색도 가능함
- 다른 DB를 보완하는 역할로 OpenSearch를 사용하는 것이 일반적임
- 인스턴스들의 클러스터가 필요함 (서버리스가 아님)
- 기본적으로는 SQL을 지원하지 않음 (플러그인을 통해 활성화할 수는 있음)
- Kinesis Data Firehose, AWS IoT, CloudWatch Log와 호환됨
- Cognito & IAM, KMS 암호화, TLS를 통해 보안
- OpenSearch Dashboards(시각화)가 함께 제공됨
Amazon EMR
- EMR - Elastic MapReduce
- EMR은 **Hadoop 클러스터(빅데이터)**를 만드는 것을 도와 방대한 양의 데이터를 분석/처리하는 것에 도움을 줌
- 클러스터는 몇 백개의 EC2 인스턴스들로 이루어질 수 있음
- EMR은 Apache Spark, HBase, Presto, Flink와 함께 번들로 제공됨
- EMR이 모든 프로비저닝과 설정을 처리해줌
- 오토 스케일링 및 Spot instance와 호환됨
사례: 데이터 처리, 머신 러닝, 웹 인덱싱, 빅 데이터..
EMR - Node types & purchasing
- Master Node: 클러스터 관리, 좌표계(coordinate), health 관리 - 장기적으로 실행
- Core Node: task 실행 및 데이터 저장 - 장기적으로 실행
- Task Node (선택적): 오직 task 실행 목적 - 주로 Spot
- Purchasing options:
- On-demand: reliable, predictable ~ 종료되지 않음
- Reserved (최소 1년): 비용 절감 (가능한 상황이라면 EMR은 이 쪽을 자동으로 사용)
- Spot Instances: 가격이 쌈, 종료될 가능성 있음, less reliable
- long-running 클러스터 또는 transient(일시적인) 클러스터를 가질 수 있음
Amazon QuickSight
- 인터렉티브 대시보드를 만들기 위한 서버리스 머신러닝 기반의 business intelligence 서비스
- 빠름, 자동 확장 가능, 임베디드 가능, 세션 당 비용 지불
- 사례:
- 비즈니스 분석
- 시각화 구축
- ad-hoc 분석 수행
- 데이터를 통한 비즈니스 인사이트 획득
- RDS, Aurora, Athena, Redshift, S3... 등과 호환
- QuickSight로 데이터를 가져올 경우 SPICE 엔진을 통한 인-메모리 계산
- Enterprise edition: Column-Level security(CLS) 설정 가능
QuickSight - 대시보드 & 분석
- 이용자 정의(standard version), 그룹 정의(enterprise version)
- 여기서 정의된 이용자 & 그룹들은 오직 QuickSight 내에서만 존재, IAM이 아님!
- 대쉬보드?
- 공유가 가능한 읽기 전용 스냅샷
- 분석에 대한 설정이 보존됨 (필터링, 파라미터, 컨트롤, 정렬)
- 이용자나 그룹에게 분석이나 대시보드를 공유할 수 있음
- 대시보드를 공유하려면, 먼저 반드시 게시(publish)를 해야함
- 대시보드를 보는 이용자들은 그 아래 놓인 데이터도 확인할 수 있음
AWS Glue
- 관리형 ETL (extract, transform, load) 서비스
- 분석을 위해 데이터를 준비/변형하는데 유용함
- 완전한 서버리스 서비스
Glue - Data Catalog
Glue - thinks to know at a high-level
- Glue Job Bookmarks: 기존 데이터의 재처리(re-processing)을 방지
- Glue Elastic Views:
- SQL을 통해 여러 데이터 스토어들 간에 데이터를 결합/복제
- 커스텀 코드 없음, Glue가 소스 데이터 내 변화를 감지함, 서버리스
- "virtual table" 활용 (materialized view)
- Glue DataBrew: pre-built transformation으로 데이터를 정리 및 정규화
- Glue Studio: Glue에서의 ETL 작업들을 생성/실행/모니터링하기 위한 GUI
- Glue Streaming ETL(built on Apache Spark Structured Streaming): Kinesis Data Streaming, Kafka, MSK(관리형 Kafka)와 호환
AWS Lake Formation
- Data lake = 분석 목적으로 모든 데이터들을 모아두는 중앙 위치(central place)
- 며칠 만에 data lake를 쉽게 설정할 수 있도록 해주는 완전 관리형 서비스
- Data Lake에다 데이터를 발견, 정화, 변형, 수집
- 직접 처리하기 복잡한 여러 작업들(collecting, cleansing, moving, catalog data, ...)을 자동화시켜줌, 중복 제거(ML Transform을 사용)
- data lake 내에서 구조화/비구조화 데이터를 결합
- out-of-the-box source blueprints: S3, RDS, Relational & NoSQL DB...
- 애플리케이션에 대한 Fine-grained Access Control(row/column level)
- AWS Glue를 기반으로 구축
Kinesis Data Analytics
Kinesis Data Analytics - for SQL applications
- SQL로 Kinesis Data Streams & Firehose에서 실시간 분석
- Amazon S3에서 스트리밍 데이터를 풍부하게 하기 위해 레퍼런스 데이터를 추가
- 완전 관리형, 프로비전해야하는 서버 없음
- 자동 스케일링
- 실제 사용률에 기반하여 비용 지불
- Output:
- Kinesis Data Streams: 실시간 분석 쿼리의 결과에 대한 stream을 생성
- Kinesis Data Firehose: 목적지(destination)에 분석 쿼리 결과를 전송
- 사례:
- 시계열 분석
- 실시간 대시보드
- 실시간 통계
Kinesis Data Analytics - for Apache Flink
- 스트리밍 데이터 처리 및 분석을 위해 Flink(Java, Scala or SQL)를 사용
- AWS 내에서 관리되는 클러스터에 Apach Flink 애플리케이션을 실행
- 프로비전된 컴퓨팅 리소스, 병렬 컴퓨팅, 자동 스케일링
- 애플리케이션 백업 (체크포인트와 스냅샷으로 구현)
- 어떤 Apache Flink 프로그래밍 기능이든 사용 가능
- Flink는 Firehose로부터 결과를 읽어올 수 없음 (이 경우 Kinesis Analytics for SQL을 사용해야 함)
Amazon Managed Streaming for Apache Kafka (Amazon MSK)
- Amazon Kinesis의 대체제
- AWS에서의 완전 관리형 Apache Kafka
- 클러스터를 생성/수정/삭제하도록 해줌
- MSK는 Kafka broker 노드와 Zookeeper 노드를 생성 & 관리해줌
- 내 VPC 내에 MSK 클러스터를 배포, multi-AZ (최대 3개 ~ HA)
- 일반적인 Apache Kafka 장애로부터 자동 복구
- 원하는 기간만큼 EBS 볼륨에 데이터를 저장
- MSK Serverless
- capacity를 관리하지 않고도 MSK에 Apache Kafka를 실행
- MSK가 자동으로 리소스 프로비저닝 / 컴퓨팅 및 스토리지 스케일링을 처리해줌
Kinesis Data Streams vs. Amazon MSK
- Kinesis Data Streams
- 메시지 사이즈가 1MB로 제한
- 샤드로 데이터 스트리밍
- 샤드 스플리팅 & 머징
- TLS in-flight 암호화
- KMS at-rest 암호화
- Amazon MSK
- 기본 1MB, 더 높게 설정 가능 (ex. 10MB)
- Kafka Topics with Partition
- Topic에만 파티션 추가 가능
- PLAINTEXT 또는 TLS in-flight 암호화
- KMS at-rest 암호화
Amazon MSK Consumers
- Kinesis Data Analytics for Apache Flink
- AWS Glue - Apache Spark streaming으로 ETL 작업 스트리밍
- Lambda
- 직접 구축한 애플리케이션 (ex. EC2/ECS/EKS)
Big Data Ingestion Pipeline
- 완전한 서버리스로 수집 파이프라인을 구축하고 싶을 때
- 실시간으로 데이터를 수집하고 싶을 때
- 데이터 변형을 하고 싶을 때
- SQL로 변형된 데이터를 쿼리하고 싶을 때
- 쿼리를 통해 생성된 리포트를 S3에 두고 싶을 때
- 데이터를 웨어하우스로 불러와 대시보드를 만들고 싶을 때
Big Data Ingestion Pipeline discussion
- IoT Core는 IoT 디바이스로부터 데이터를 수확(harvest)할 수 있게 해줌
- Kinesis는 실시간 데이터 수집에 유용
- Firehose는 거의 실시간(1분) 내로 S3에 데이터를 전송하도록 해줌
- Lambda는 Firehose와 함께 사용하여 데이터 변형을 할 수 있게 해줌
- S3는 SQS로 notification을 트리거할 수 있음
- Lambda는 SQS를 구독할 수 있음 (S3 -> Lambda로 connecter를 둘 수 있음)
- Athena는 서버리스 SQL 서비스로, 분석 결과를 S3에 보관할 수 있음
- 리포팅 버킷(reporting bucket)은 분석된 데이터들을 포함하게 되며, 이는 AWS QuickSight, Redshift 같은 리포팅 툴과 함께 사용할 수 있음
Machine Learning
Amazon Rekognition
- ML을 통해 이미지와 비디오에서 오브젝트, 사람, 텍스트, 장면을 인식
- 얼굴 분석 및 얼굴 검색 - 이용자 검증, 사람 수 카운팅
- familiar face 데이터베이스 생성 또는 유명인사와 비교
- 사례:
- labeling
- 컨텐츠 관리
- 텍스트 감지
- 얼굴 감지 및 분석 (성별, 나이 범위, 감정...)
- 얼굴 검색 및 검증
- 유명인사 인식
- pathing (ex. 스포츠 게임 분석)
Amazon Rekognition - Content Moderation
- 부적절하거나, 원치 않는, 혹은 공격적인 컨텐츠를 감지 (이미지, 비디오)
- 더 안전한 이용자 경험을 보장하기 위해 소셜미디어, 방송 미디어, 광고, e-커머스에서 사용
- 플래그될 항목에 대한 최소 신뢰도 임계값(minimum confidence threshold)를 설정
- Amazon Augmented AI(A2I) 내에서
- 수동 검토를 위해 민감한 컨텐츠에 플래그 지정
- 규정 준수를 도와줌
Amazon Transcribe
- 자동으로 speech-to-text 변환을 해줌
- **automatic speech recognition(ASR)**이라고 불리는 딥러닝 처리를 통해 speech-to-text를 빠르고 정확하게 수행할 수 있음
- Redaction를 통해 자동으로 개인 식별 정보(Personally Identifiable Information ~ PII)를 제거
- multi-lingual audio를 통해 언어 자동 감지
- 사례:
- 이용자 서비스 전화 트랜스크립트(transcribe)
- 선택 캡션 및 자막 자동화
- 완전 검색 가능한 아카이브를 위해 미디어 에셋에 대한 메타데이터 생성
Amazon Polly
- 딥러닝을 통해 text-to-speech 변환
- 애플리케이션이 말을 할 수 있도록 할 수 있음
Amazon Polly - Lexicon & SSML
- Pronunciation lexicons으로 특정 단어의 발음을 커스터마이징 할 수 있음
- Stylized words: St3ph4ne => "Stephane"
- Acronyms: AWS => "Amazon Web Services"
- Speech Synthesis Markup Language(SSML)로 마크업 가능 - 더 많은 커스터마이징
- 특정 단어나 문장을 강조
- 음성 기호대로 발음
- 숨쉬기, 속삭이기 추가
- 뉴스 캐스터 화법 사용
Amazon Translate
- 자연스럽고 정확한 언어 번역
- 컨텐츠의 로컬라이징을 하도록 도와줌 - 웹사이트 또는 애플리케이션
- 국제 이용자를 위해, 또는 거대한 양의 텍스트를 효율적으로 번역하기 위해 사용
Amazon Lex & Connect
- Amazon Lex: Alexa에 사용되는 것과 동일한 기술
- Automatic Speech Recognition(ASR)을 사용하여 speech-to-text
- 텍스트와 호출자(caller)의 의도를 인식하는 Natural Language Understanding
- 챗봇, 콜센터 봇 생성에 유용
- Amazon Connect:
- 전화를 받고, 응대 플로우를 만들 수 있는 가상 문의 센터
- 다른 CRM 시스템이나 AWS와 호환될 수 있음
- 선불 결제 없음, 전통적인 문의 센터 솔루션들보다 80% 저렴함
Amazon Comprehend
- **자연어 처리(NLP ~ Natural Language Processing)**을 위해 사용
- 완전 관리형 서버리스 서비스
- 텍스트에서 인사이트 및 관계 파악
- 텍스트에서 사용하는 언어
- 핵심 문구, 장소, 사람, 브랜드, 이벤트 등을 추출
- 텍스트의 긍정/부정 인식
- tokenization와 parts of speech(품사)를 통해 텍스트 분석
- 주제 별로 텍스트 파일 모음을 자동으로 구성
- 사례:
- 이용자 반응(이메일)을 분석하여 긍정/부정적 경험 여부 판단
- Comprehend가 발견할 topic을 기반으로 아티클들을 생성 및 그룹화
Amazon Comprehend Medical
- 비정형 clinical 텍스트 내에서 유용한 정보를 감지하고 반환해줌
- 의사 소견
- 퇴원 요약
- 테스트 결과
- 케이스 노트
- Protected Health Information(PHI)를 감지하기 위해 NLP를 사용 - DetectPHI API
- S3 내에 문서 보관, Kinesis Data Firehose로 실시간 데이터 분석, 또는 Amazon Transcribe로 환자 나레이션을 텍스트로 변환하여 Amazon Comprehend Medical이 분석할 수 있도록 함
Amazon SageMaker
- 개발자 / 데이터 사이언티스트가 ML 모델을 만들 수 있도록 해주는 완전 관리형 서비스
- 일반적으로 ML 모델링을 하는 모든 과정을 한 곳에서 수행하고, 또 서버를 프로비저닝하는 것은 상당히 어렵기 때문
Amazon Forecast
- ML을 기반으로 매우 정확한 기상 예측을 전달하는 완전 관리형 서비스
- 예시: 우의 판매량 예측
- 데이터 자체를 살펴보는 것보다 50% 정확
- 예측 시간을 몇 달에서 몇 시간으로 단축
- 사례: 제품 수요 예측, 재무 설계, 리소스 계획
Amazon Kendra
- 머신러닝 기반의 완전 관리형 문서 검색 서비스
- 문서 내에서 답을 추출 (text, pdf, HTML, PowerPoint, MS Word, FAQs...)
- 자연어 검색 기능
- 이용자 인터랙션/피드백을 통해 학습하여 선호하는 결과가 나오도록 촉진 (Incremental Learning ~ 점진 학습)
- 검색 결과를 수동으로 미세 조정하는 기능 (데이터 중요도, 신선도, 커스텀...)
Amazon Personalize
- 실시간으로 개인화된 추천을 전달하는 앱을 구성하기 위한 완전 관리형 ML 서비스
- 사례: 개인화된 상품 추천/re-ranking, 맞춤형 다이렉트 마케팅
- 사례: 이용자가 정원관리 툴을 샀을 때, 다음에 살만한 제품을 추천
- Amazon.com에도 동일한 기술이 사용됨
- 기존 웹사이트, 애플리케이션, SMS, 이메일 마케팅 시스템과도 호환
- 며칠 내로 구현 가능, 몇 달까지도 안걸림(ML 솔루션을 빌드, 학습, 배포할 필요 없음)
- 사례: 소매점, 미디어 및 엔터테인먼트
Amazon Textract
- AI와 ML을 통해 스캔된 문서로부터 텍스트, 손글씨, 데이터를 자동으로 추출
- form과 테이블로부터 데이터 추출
- 어떤 타입의 문서든 읽기 및 처리 가능 (PDFs, images, ...)
- 사례:
- 금융 서비스 (e.g., 청구서, 금융 리포트)
- 헬스케어 (e.g., 진료 기록, 보험 청구)
- 공공 부문 (e.g., 세금 신고서, ID 문서, 여권)
AWS Machine Learning - Summary
- Rekognition: 얼굴 감지, 라벨링, 유명인사 인식
- Transcribe: speech-to-text (ex. subtitles)
- Polly: text-to-speech
- Translate: 번역
- Lex: 챗봇 빌드
- Connect: 클라우드 문의 센터
- Comprehend: 자연어 처리
- SageMaker: 모든 개발자/데이터 사이언티스트를 위한 머신 러닝
- Forecast: 높은 정확도의 기상 예측
- Kendra: ML 기반의 검색 엔진
- Personalize 실시간 개인 추천
- Textract: 문서 내 텍스트 및 데이터 감지
Monitoring & Audit
Amazon CloudWatch Metrics
- CloudWatch는 AWS의 모든 서비스에 대한 metric를 제공함
- Metric은 모니터링할 변수를 의미 (CPUUtilization, Networkln...)
- metric은 namespace에 속하게 됨
- Dimension은 metric의 attribute (instance id, envrionment, etc...)
- 각 metric 당 최대 30 dimension
- metric은 timestamps를 가짐
- metric의 CloudWatch 대시보드를 만들 수 있음
- CloudWatch Custom Metrics를 만들 수 있음 (ex. RAM)
CloudWatch Metric Streams
- 낮은 레이턴시로 원하는 목적지(destination)로 CloudWatch metric을 지속적으로 스트리밍할 수 있음 (거의 실시간 전송, 낮은 레이턴시)
- Amazon Kinesis Data Firehose (=> 이후 목적지로)
- 써드파티 서비스 제공자: Datadog, Dynatrace, New Relic, Splunk, Sumo Logic...
- 옵션을 통해 metric의 하위 집합만 스트리밍하도록 metric을 필터링할 수 있음
CloudWatch Logs
- Log groups: 임의의 이름, 주로 애플리케이션
- Log stream: 애플리케이션 / 로그 파일 / 컨테이너 내 인스턴스
- 로그 만료 정책 (만기 X, 30일 등등)
- CloudWatch Log는 다음으로 로그를 전송할 수 있음:
- Amazon S3 (내보내기)
- Kinesis Data Streams
- Kinesis Data Firehose
- AWS Lambda
- OpenSearch
CloudWatch Logs - Sources
- SDK, CloudWatch Logs Agent, CloudWatch Unified Agent
- Elastic Beanstalk: 애플리케이션으로부터의 log 컬렉션
- ECS: 컨테이너로부터의 컬렉션
- AWS Lambda: 함수 로그로부터의 컬렉션
- VP Flow Logs: VPC 별 로그
- API Gateway
- 필터 기반의 CloudTrail
- Route53: DNS 쿼리 로그
CloudWatch Logs - Metric Filters & Insights
- CloudWatch Logs는 필터 표현식(filter expressions)을 사용할 수 있음
- ex. 한 로그 내 특정 IP를 찾아내기
- ex. 로그 내에서 "ERROR" 발생 카운팅하기
- CloudWatch alarm을 트리거할 수 있도록 metric filter 사용
- CloudWatch Logs Insights는 로그를 쿼리하거나, CloudWatch Dashboard에 쿼리를 추가하는 데 사용될 수 있음
CloudWatch Logs - S3 Export
- 로그 데이터는 내보내기가 가능해지기까지 최대 12시간까지 걸릴 수 있음
- API 호출은 CreateExportTask
- 실시간과는 거리가 멀기 때문에, 실시간 처리가 필요하다면 대신 Logs Subscription을 쓸 것
CloudWatch Logs - Subscriptions
CloudWatch Logs - for EC2
- 기본적으로, 로그는 EC2 머신에서 CloudWatch로 이동하지 않음
- EC2가 로그 파일을 전송하길 원한다면 CloudWatch agent를 써야함
- IAM 권한이 적절한지 확인 필요함
- CloudWatch log agent는 온-프레미스에서도 설정 가능
CloudWatch Logs Agent & Unified Agent
- 가상 서버를 위한 것 (EC2 인스턴스, 온-프레미스 서버)
- CloudWatch Logs Agent
- 구버전 agent
- 오직 CloudWatch Log에 전송 가능
- CloudWatch Unified Agent
- RAM, 프로세스 등 추가적인 시스템 레벨 metric도 수집
- 로그를 수집하여 CloudWatch Log에 전송
- SSM Parameter Store를 통해 중앙화된 설정
CloudWatch Unified Agent - Metrics
- 리눅스 서버 / EC2 인스턴스에서 직접 수집
- CPU (active, ugest, idle, system, user, steal)
- Disk metrics (free, used, total), Disk IO (writes, reads, bytes, iops)
- RAM (free, inactive, used, total, cached)
- Netstat (TCP/UDP 연결 수, net packets, bytes)
- Processes (total, dead, bloqued, idle, running, sleep)
- Swap Space (free, used, used %)
- 기억할 것: EC2에서의 기본 제공 metric - disk, CPU, network (high level)
CloudWatch Alarms
- Alarm은 어떤 metric에 대한 알림을 트리거하기 위해 사용
- 다양한 옵션 (샘플링, %, max, min, etc...)
- Alarm States:
OK
INSUFFICIENT_DATA
ALARM
- Period:
- metric을 평가하는데 걸리는 초단위 시간
- 고해상도 커스텀 metric: 10초, 30초, 60초의 배수
CloudWatch Alarm - Targets
- EC2 인스턴스를 중지 / 종료 / 재부팅 / 복구
- 오토 스케일링 동작을 트리거
- SNS에 알림 전송 (lambda를 통해 거의 뭐든 처리 가능)
CloudWatch Alarm - Composition Alarm
- CloudWatch Alarm은 하나의 metric에서 동작
- Composition Alarm은 여러 개의 alarm들에 기반한 상태를 모니터링
- AND 또는 OR 조건
- 복잡한 composite alarm을 생성하여 alarm noise를 줄이는데 도움을 줌
CloudWatch Alarm - EC2 Instance Recovery
- Status Check:
- Instance status = EC2 VM 체크
- System status = 기반 하드웨어 체크
- Recovery: 동일한 private/public/elastic IP, 동일한 메타데이터, 동일한 placement group
CloudWatch Alarm: good to know
- Alarm은 CloudWatch Logs Metrics Filter에 기반하여 생성될 수 있음
- alarm과 notification을 테스트해보기 위해 CLI를 사용해서 alarm state를 변경할 수 있음
aws cloudwatch set-alarm-state --alarm-name "myalarm" --state-value ALARM --state-reason "testing purposes"
Amazon EventBridge (formerly CloudWatch Events)
- Schedule: 크론잡 (Cron Jobs - scheduled scripts)
- Event Pattern: 이벤트 규칙을 통해 서비스가 수행하는 작업에 반응
- Lambda 함수 트리거, SQS/SNS 메시지 전송
Amazon EventBridge Rules
- Source에서 이벤트가 발생 (ex. EC2 인스턴스 시작, S3 오브젝트 업로드 등..)
- 위의 이벤트들이 필터링(선택 사항)되어 EventBridge로 전달
- 전달된 이벤트는 JSON 형태가 되어 destination으로 전달
{
"version": "0",
"id": "6a7e8feb-b491",
"detail-type": "EC2 Instance State-change Notification",
// ...
}
Amazon EventBridge - Event Bus
- 몇가지 종류의 Event Bus
- Default Event Bus - AWS Services
- Partner Event Bus - AWS Saas Partners (ex. Zendesk, Datadog)
- Custom Event Bus - Custom Apps
- event bus들은 Resource-based Policy를 통해 다른 AWS 계정들로부터도 액세스 가능함
- event bus에 전달된 이벤트들(전체 또는 필터)은 아카이빙할 수 있음 (무기한 또는 기간 설정)
- 아카이빙된 이벤트를 재실행할 수 있는 기능
Amazon EventBridge - Schema Registry
- EventBridge는 event bus 내에서 이벤트를 분석하여 **스키마(schema)**를 유추할 수 있음
- Schema Registry는 애플리케이션을 위해, 이벤트 버스 내에서 데이터가 어떻게 구성될지 미리 알 수 있게 해주는 코드를 생성할 수 있도록 함
- Schema는 버저닝될 수 있음
Amazon EventBridge - Resource-based Policy
- 특정 Event Bus 내 권한을 관리
- 예시: 다른 AWS 계정 또는 AWS 리전으로부터의 이벤트를 허용/거부
- 사례: 내 AWS Organization에서 일어나는 모든 이벤트들을 하나의 AWS 계정 또는 AWS 리전으로 통합
CloudWatch Insights
CloudWatch Insights - Container
- 컨테이너로부터 메트릭과 로그를 수집/병합/요약
- 다음 서비스에서의 컨테이너에 적용 가능
- Amazon ECS
- Amazon EKS
- Kubernetes platforms on EC2
- Fargate (both for ECS and EKS)
- Amazon EKS와 Kubernetes의 경우, CloudWatch Insights는 컨테이너 검색을 위해 CloudWatch Agent의 컨테이너화 버전을 사용함
CloudWatch Insights - Lambda
- AWS Lambda에서 동작하는 서버리스 애플리케이션에 대한 모니터링 / 트러블슈팅 솔루션
- CPU 시간, 메모리, 디스크, 네트워크를 포함한 시스템 레벨 메트릭을 수집/병합/요약
- 콜드 스타트, lambda 워커 종료와 같은 진단 정보(diagnostic information)도 수집/병합/요약
- Lambda Insights가 Lambda Layer로 제공됨
CloudWatch Insights - Contributor
- 로그 데이터를 분석하여 contributor 데이터를 표시하는 시계열 데이터를 생성
- top-N contributor에 대한 메트릭을 볼 수 있음
- unique contributor의 전체 수와 그들의 사용 내역
- top talker를 찾고, 누가/어떤 것이 시스템 성능에 영향을 미치는지 이해하는데에 도움
- 어떤 AWS 생성 로그와도 호환 (VPC, DNS, etc...)
- 사례:
- bad host 찾기
- 제일 네트워크 사용량이 많은 이용자 찾기
- 가장 많은 에러가 발생하는 URL 찾기
- 룰을 처음부터 구축할 수도 있고, 혹은 AWS에서 만들어진 샘플 룰을 사용할 수도 있음 - 내 CloudWatch Log를 활용
- CloudWatch는 또한 다른 AWS 서비스로부터의 메트릭을 분석하는데 사용할 수 있는 빌트인 룰을 제공함
CloudWatch Insights - Application
- 모니터되고 있는 애플리케이션에서 예측되는 잠재적인 문제를 보여주고, 진행중인 문제를 격리하기 위한 도움을 주는 자동화된 대시보드를 제공
- 일부 기술만 적용된 EC2 인스턴스에서 애플리케이션을 실행할 수 있음 (Java, .NET, Microsoft IIS Web Server, databases...)
- 또, 다른 AWS를 사용할 수 있음 (ex. Amazon EBS, RDS, ELB, ASG, Lambda, SQS, DynamoDB, S3 bucket, ECS, EKS, SNS, API Gateway...)
- SageMaker를 내부적으로 사용
- 애플리케이션 health에 대한 가시성을 향상시켜 트러블슈팅 및 애플리케이션 오류 수정에 걸리는 시간을 줄여줌
- Amazon EventBridge와 SSM OpsCenter에 발견된 사항과 경고를 전달할 수 있음
CloudWatch Insights and Operatonal Visibility
- CloudWatch Container Insights
- ECS, EKS, Kubernetes on EC2, Fargate, 쿠버네티스의 경우 agent 필요
- Metrics와 logs
- CloudWatch Lambda Insights
- 서버리스 애플리케이션을 트러블슈팅하기 위한 구체적인 메트릭
- CloudWatch Contributors Insights
- CloudWatch Logs를 통해 "Top-N" 기여자를 발견
- CloudWatch Application Insights
- 애플리케이션과, 그와 관련된 AWS 서비스를 트러블슈팅하기 위한 자동화된 대시보드
AWS CloudTrail
- AWS 계정에 대한 governance, compliance, audit을 제공
- CloudTrail은 기본적으로 활성화
- 아래로부터 이루어진 내 AWS 계정으로 이루어진 이벤트 / API 호출 내역을 가져올 수 있음
- 콘솔
- SDK
- CLI
- AWS Services
- CloudTrail로부터 CloudWatch Log 또는 S3로 로그를 전달할 수 있음
- 모든 리전 (기본) 또는 단일 리전에 적용 가능
- AWS에서 리소스가 삭제된 경우, 먼저 CloudTrail을 살펴볼 것!
AWS CloudTrail - Events
- Management Events:
- AWS 계정 내 리소스에 이루어진 작업
- 예시:
- 보안 설정 (IAM
AttachRolePolicy
) - 라우팅 데이터에 대한 룰 설정 (Amazon EC2
CreateSubnet
) - 로깅 설정 (AWS CloudTrail
CreateTrail
)
- 보안 설정 (IAM
- 기본적으로, CloudTrail은 Management Events들을 로그하도록 설정되어 있음
- Write Events(리소스에 대한 수정)과 Read Events(리소스를 수정하지 않음)를 구분할 수 있음
- Data Events:
- 기본적으로, Data Events들은 로그되지 않음 (대용량 작업이기 때문)
- Amazon S3의 오브젝트 레벨 활동 (ex.
GetObject
,DeleteObject
,PutObject
): Read Events와 Write Event 구분 가능 - AWS Lambda 함수 실행 활동 (
Invoke
API)
- CloudTrail Insights Events: 아래에서 설명
AWS CloudTrail - Insights
- CloudTrail Insight의 활성화로 계정 내 일어난 일반적이지 않은 활동을 감지할 수 있음
- 부정확한 리소스 프로비저닝
- 서비스 한도 초과
- AWS IAM 액션의 폭발적인 증가
- 정기적인 유지보수 활동의 부재
- CloudTrail은 기준점을 잡기 위해 일반적인 management event들을 분석
- 이후, 일반적이지 않은 패턴을 탐색하기 위해 지속적으로 write event를 분석
- 이상 징후는 CloudTrail 콘솔 내에 나타남
- 이벤트는 S3에 전달됨
- EventBridge 이벤트가 생성됨 (자동화가 필요한 경우)
AWS CloudTrail - Retention
- Event들은 CloudTrail 내에 90일 동안 보관됨
- 이벤트를 그 이상 보관하려면, S3에 이를 로깅 (이후 Athena를 통해 분석 가능)
AWS Config
- AWS 리소스의 **compliance(규정)**를 준수하고 기록하도록 도와줌
- 시간에 따른 설정과 변경을 기록하는데 도움
- AWS Config으로 해결할 수 있는 것들
- 내 Security group에 제한이 없는 SSH 액세스가 존재하는가?
- 내 버킷에 public access가 존재하는가?
- 시간이 지나면서 내 ALB 설정이 바뀐 적이 있는가?
- 어떤 변경이든 경고(SNS notifications)를 받도록 할 수 있음
- AWS Config은 리전 별 서비스
- 리전과 계정 간에 통합할 수도 있음
- 설정 데이터를 S3에 보관할 수 있음 (Athena로 분석될 수 있음)
AWS Config - Config Rules
- AWS에 의해 관리되는 config rule을 사용할 수 있음 (75개 이상)
- 커스텀 config rule을 사용할 수 있음 (반드시 AWS Lambda로 정의되어야 함)
- ex. 모든 EBS 디스크가 gp2 타입이 맞는지 체크
- ex. 모든 EC2 인스턴스가 t2.micro가 맞는지 체크
- Rule 자체도 평가 및 트리거될 수 있음
- config이 변경될 때마다
- And / Or: 일정 시간 간격으로
- AWS Config Rules는 어떤 동작이 발생하는 것 자체를 막진 않음 (no deny)
- Pricing: 프리티어 없음, 리전 별로 기록된 configuration item마다 0.001
AWS Config - Resource
- 시간 경과에 따른 리소스의 규정 확인
- 시간 경과에 따른 리소스 설정 확인
- 시간 경과에 따른 CloudTrail API 호출 확인
AWS Config - Remediations
- SSM Automation Documents을 통해 비규격(non-compliant) 리소스에 대한 수정을 자동화
- AWS-Managed Automation Documents를 사용하거나 커스텀 Automation Documents를 생성
- 팁: Lambda 함수를 실행하는 커스텀 Automation Documents를 만들 수 있음
- 만약 리소스가 auto-remediation이 수행된 이후에도 여전히 규정을 준수하지 않는 상태라면, Remediation Retries를 설정할 수 있음
AWS Config - Notifications
- AWS 리소스가 비규격 상태일 때, EventBridge를 사용하여 notification을 트리거
- 설정 변화와 규정 준수에 대한 상태 알림을 SNS로 전송할 수 있음
- 모든 이벤트가 전달되며, SNS 필터링 또는 클라이언트 사이드 필터링을 적용할 수 있음
CloudWatch vs. CloudTrail vs. Config
- CloudWatch
- 퍼포먼스 모니터링 (메트릭, CPU, 네트워크 등) & 대시보드
- Events & Alerting
- 로그 병합 & 분석
- CloudTrail
- 계정 내 일어난 모든 API 호출을 기록
- 특정 리소스에 대한 trail을 정의
- 글로벌 서비스
- Config
- 설정 변경을 기록
- 리소스들의 규정(compliance) 준수 여부를 판단
- 변경과 규정 준수에 대한 타임라인 제공
CloudWatch vs. CloudTrail vs. Config - For an Elastic Load Balancer
- CloudWatch:
- 들어오는(incoming) 연결 metric을 모니터링
- 시간 경과에 따른 에러 코드 비율을 %로 시각화
- 로드 밸런서 성능에 대한 아이디어를 얻기 위해 대시보드를 구축
- Config:
- 로드 밸런서의 Security Group 룰을 트래킹
- 로드 밸런서의 설정 변경을 트래킹
- SSL 인증서가 항상 로드 밸런서에 할당되도록 보장 (compliance)
- CloudTrail:
- API 호출을 통해 누가 로드 밸런서를 변경했는지 트래킹
IAM Advanced
AWS Organizations
- 글로벌 서비스
- 여러 AWS 계정을 관리할 수 있도록 해줌
- 메인 계정 = 관리 계정 (Management account)
- 그 외의 계정 = 멤버 계정 (Member account)
- 멤버 계정들은 한 organization의 일부가 될 수 있음
- 모든 계정 간에 청구서를 통합(consolidate)할 수 있음 - single payment method
- 통합 이용을 통한 비용적 이득 (EC2, S3에 대한 볼륨 할인)
- 계정 간에 Reserved Instances나 Savings Plans 할인을 공유할 수 있음
- AWS 계정 생성을 자동화하기 위해 API를 활용할 수 있음
AWS Organizations - Organizational Units (OU) ~ Examples
AWS Organizations - Pros
- Advantages
- 다중 계정 vs 한 계정 내 여러 VPC ~ 계정을 여러개 둠으로써 보안 측면에서 더 나음
- 비용 청구 목적으로 태깅(tagging) 표준을 둘 수 있음
- 모든 계정에 대해 CloudTrail을 활성화하고, 중앙 S3 계정에 로그들을 보낼 수 있음
- 중앙 로깅 계정에 CloudWatch 로그를 전송
- 관리 목적으로 Cross Account Role을 구축할 수 있음
- Security: Service Control Policies (SCP)
- User 또는 Role 역할을 제한하기 위해 IAM 정책을 OU 또는 계정 단위로 적용할 수 있음
- SCP는 관리 계정에 적용할 수 없음 (full admin power)
- 반드시 explicit allow를 가져야 함 (기본적으로는 아무것도 허용하지 않음 - IAM처럼)
AWS Organiazations - SCP Examples ~ Blocklist and Allowlist strategies
- 기본적으로 모든 액션을 허용하고(ex.
AllowsAllActions
), 일부 권한에 대해 Deny 시키는 방법을 쓰거나 ~ Blocklist - 기본적으로 모든 액션을 막아두고(별도로 권한 부여하지 않음), 일부 권한을 Allow 시키는 방법을 쓸 수 있음 ~ Allowlist
IAM Conditions
- aws:SourceIp: API 호출이 이루어지는 클라이언트 IP를 제한
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"NotIpAddress": {
"aws:SourceIp": ["192.0.2.0/24"]
}
}
}
]
}
- aws:RequestedRegion: API 호출이 이루어지는 지역을 제한
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": ["ec2:*", "rds:*", "dynamodb:*"],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestedRegion": ["eu-central-1", "eu-west-1"]
}
}
}
]
}
- ec2:ResourceTag: 태그를 기반으로 제한
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ec2:StartInstances", "ec2:StopInstances"],
"Resource": "arn:aws:ec2:us-east-1:123456789012:instance/*",
"Condition": {
"StringEquals": {
"ec2:ResourceTag/Project": "DataAnalytics",
"aws:PrincipalTag/Department": "Data"
}
}
}
]
}
- aws:MultiFactorAuthPresent: MFA를 강제
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ec2:*",
"Resource": "*",
},
{
"Effect": "Deny",
"Action": ["ec2:StartInstances", "ec2:TerminateInstances"],
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:MultiFactorAuthPresent": false
}
}
}
]
}
IAM for S3
- s3:ListBucket 권한은 arn:aws:s3:::test에 적용됨
- 버킷 레벨의 권한
- s3:GetObject, s3:PutObject, s3:DeleteObject는 arn:awn:s3:::test/*에 적용됨
- 오브젝트 레벨의 권한
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::test",
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::test/*",
}
]
}
Resource Policies & aws:PrincipalOrgID
- aws:PrincipalOrgID는 특정 AWS Organization 멤버 계정의 액세스 권한을 제한하기 위하여 리소스 정책 내에 사용됨
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::2022-financial-data/*",
"Condition": {
"StringEquals": {
"aws:PrincipalOrgID": ["o-yyyyyyyyyy"]
}
}
}
]
}
IAM Roles vs. Resource Based Policies
-
Cross account의 경우:
- 하나의 리소스에 resource-based policy를 부여할 수 있음 (ex. S3 bucket policy)
- 또는 IAM Role을 proxy로 사용할 수 있음
-
Role(이용자, 애플리케이션 또는 서비스)을 가정할 때는, 기존의 권한을 포기하고 role에 할당된 권한을 가져오게 됨
-
Resource-based policy를 사용할 때는, 기존의 권한을 포기하지 않아도 됨
- 사례:
- accountA에 속한 이용자가 accountA에 있는 DynamoDB 테이블을 스캔하여 accountB에 있는 S3 버킷에 덤프해야 할 때는 Resource-based policy를 사용해야 함
- 지원 대상: Amazon S3 버킷, SNS 토픽, SQS 큐 등등...
- 사례:
IAM Roles vs. Resource Based Policies - Amazon EventBridge ~ Security
- rule이 동작할 때는, 타겟에 대한 권한이 필요하며 이는 다음의 두가지 형태가 될 수 있음
- Resource-based policy: Lambda, SNS, SQS, CloudWatch Logs, API Gateway...
- IAM Role: Kinesis stream, Systems Manager Run Command, ECS task...
IAM Permission Boundaries
- IAM Permission Boundaries는 User 또는 Role에 의해 지원됨 (Group은 아님)
- 이는 IAM 엔티티가 가질 수 있는 최대 권한을 설정하도록 managed policy를 사용할 수 있게 해주는 고급 기능
- 예시: 아래처럼 S3, CloudWatch, EC2에 대한 권한을 허용하는 IAM Permission Boundary를 설정해둔 상태에서, 추가로 아래의
iam:CreateUser
를 허용하도록 하더라도, 이는 효과가 없음 (추가 권한을 가질 수 없음)
// IAM Permission Boundary
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*", "cloudwatch:*", "ec2:*"],
"Resource": "*"
}
]
}
// IAM Permissions Through IAM Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iam:CreateUser",
"Resource": "*"
}
]
}
IAM Permission Boundaries - Use Cases
- permission boundary 내에 있는 비관리자(non-admin)에게 권한을 위임 (ex. 새 IAM user 생성)
- 모든 개발자들에게 직접 정책을 할당하도록 허용하여 권한을 관리할 수 있도록 하면서도, 권한을 에스컬레이션(escalate)할 수는 없도록 막을 수 있음 (ex. 스스로 어드민을 부여해버린다던가)
- 한 특정 이용자에게 제한을 걸기에 유용함 (Organizations & SCP를 사용하여 전체 계정을 다루는 대신)
IAM Policy Evaluation Logic
Example IAM Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "sqs:*",
"Effect": "Deny",
"Resource": "*"
},
{
"Action": [
"sqs:DeleteQueue"
],
"Effect": "Allow",
"Resource": "*"
}
]
}
sqs:CreateQueue
를 수행할 수 있는가?- 아니오
sqs:DeleteQueue
를 수행할 수 있는가?- 아니오
ec2:DescribeInstances
를 수행할 수 있는가?- 아니오
AWS IAM Identity Center (Successor to AWS Single Sign-On)
- 아래 모든 것들을 하나의 로그인(single sign-on)으로 통일할 수 있음
- AWS Organization 내에 있는 AWS 계정
- 비즈니스 클라우드 애플리케이션 (e.g., Salesforce, Box, Microsoft 365, ...)
- SAML2.0 사용 애플리케이션
- EC2 Windows Instances
- Identity Providers (ID 공급자)
- IAM Identity Center 내 빌트인 Identity Store
- 써드파티: Active Directory(AD), OneLogin, Okta...
AWS IAM Identity Center - Fine-grained Permissions and Assignments
-
Multi-Account Permissions
- AWS Organization 내에 있는 여러 AWS 계정 간의 액세스 관리
- Permission Sets - 이용자 및 그룹의 AWS 액세스 권한을 정의하기 위한 하나 이상의 IAM Policy 컬렉션
-
Application Assignments
- 여러 SAML 2.0 비즈니스 애플리케이션에 대한 SSO 액세스 (Salesforce, Box, Microsoft 365...)
- URL, 인증서, 메타데이터 입력이 필수적임
-
Attribute-Based Access Control (ABAC)
- IAM Identity Center Identity Store에 저장된 이용자의 속성(attribute)에 기반한 Fine-grained permission
- 예시: cost center, title, locale, ...
- 사례: 일단 한번만 권한을 정의한 다음에는 속성이 변경될 때마다 AWS 액세스가 수정되도록 할 수 있음
AWS Directory Services
What is Microsoft Active Directory (AD)?
- AD Domain Services가 있는 모든 Windows Server에서 볼 수 있음
- objects에 대한 데이터베이스: 이용가 계정, 컴퓨터, 프린터, 파일 공유, 보안 그룹
- 보안 관리, 계정 생성, 권한 할당을 중앙에서 관리
- Object들은 tree안에 구성됨
- 여러 tree의 그룹은 forest가 됨
AWS Directory Services - Types
- AWS Managed Microsoft AD
- AWS 내에 자체적인 AD를 생성, 로컬에서 이용자들을 관리, MFA 지원
- 온-프레미스 AD와 "신뢰 가능한" 연결을 구성할 수 있음
- AD Connector
- 온-프레미스 AD로 리다이렉트하는 Directory Gateway (proxy)로, MFA 지원
- 온-프레미스 AD에서 이용자들을 관리
- Simple AD
- AWS 내 AD와 호환되는 관리형 디렉토리
- 온-프레미스 AD와는 합칠 수 없음
AWS Identity Center - Active Directory Setup
- AWS Managed Microsoft AD (Directory Service)에 연결
- 즉시 사용 가능한 통합
- 직접 관리하는 Directory에 연결
- AWS Managed Microsoft AD를 사용해 Two-way Trust Relationship을 구축
- 또는 AD Connector를 생성하여 프록시 구성
AWS Control Tower
-
베스트 프랙티스에 맞게 multi-account AWS 환경에서 안전하고 규정을 준수하도록 설정 및 관리를 할 수 있도록 해주는 쉬운 방법
-
AWS Control Tower는 계정 생성을 위해 AWS Organization을 사용
-
이점:
- 몇 번의 클릭으로 환경을 구축할 수 있도록 자동화
- guardrail을 통해 사용 중인 정책 관리를 자동화
- 정책 위반을 감지하고 이를 수정
- 인터랙티브 대시보드를 통해 규정 준수 내용을 모니터링
AWS Control Tower - Guardrails
- 내 ControlTower 환경에 현재 진행중인 거버넌스(governance)를 전달 (모든 AWS Accounts에)
- Preventive Guardrail - SCP(Service Control Policies)를 사용 (e.g., 모든 계정 내 리전을 제한)
- Detective Guardrail - AWS Config을 사용 (e.g., 태그가 되지 않은 리소스들을 파악)
Security & Encryption
Why encryption?
Encryption in flight (SSL)
- 데이터가 전송되기 전에 암호화되고, 받은 이후에 복호화
- SSL 인증서가 암호화를 도움 (HTTPS)
- Encryption in flight는 MITM (man in the middle attack)이 일어나지 않음을 보장해줌
- MITM? - 공격자가 이용자의 인터넷 서버와 트래픽의 목적지 사이에 끼어들어 전송을 가로채는 공격
Server side encryption at rest
- 서버가 데이터를 전송받은 이후 이를 암호화
- 서버측에서 데이터를 전송하기 전에 복호화
- 하나의 키(보통 data key)를 통해 암호화된 형태로 저장
- 암호화/복호화 키는 반드시 어딘가에서 관리되는 오브젝트여야 하며, 서버가 여기에 반드시 접근 할 수 있어야 함
Client side encryption
- 데이터가 클라이언트를 통해 암호화되고, 서버 측에서는 이를 복호화할 일이 없음
- 복호화 역시 데이터를 넘겨받은 클라이언트 측에서 이루어짐
- 서버 쪽에서는 데이터를 복호화할 수 없어야 함
- Envelope Encryption을 활용할 수 있음
AWS KMS (Key Management Service)
- AWS 서비스에서 "암호화"와 관련된 내용을 들었다면 대부분은 KMS와 관련된 것
- AWS가 암호화 키를 관리해 줌
- 권한 처리를 위해 IAM과 호환
- 데이터에 대한 액세스를 통제하는 쉬운 방법
- CloudTrail을 통해 KMS 키 사용을 감시할 수 있음
- 대부분의 AWS 서비스에서 원활하게 호환됨 (EBS, S3, RDS, SSM...)
- 절대로 secret을 plaintext로 저장하지 말 것, 특히 코드에서!
- KMS 키 암호화는 API 호출을 통해서도 가능 (SDK, CLI)
- 암호화된 secret은 코드 또는 환경 변수에 저장될 수 있음
KMS Keys Types
-
KMS Keys는 KMS Customer Master Key의 새로운 이름
-
대칭키 (AES-256 Keys)
- 하나의 키로 암호화/복호화 가능
- KMS를 이용하는 AWS 서비스는 대칭 CMKs(Customer Managed Keys)를 이용함
- 절대로 복호화된 KMS 키에 액세스할 수는 없음 (사용하려면 반드시 KMS API를 호출해야 함)
-
비대칭키 (RSA & ECC key pairs)
- Public Key(암호화)와 Private Key(복호화)가 한쌍
- 암호화/복호화에 사용하거나, 서명/확인 작업에 사용
- Public Key는 다운로드 가능하지만, 복호화된 Private Key에는 액세스할 수 없음
- 사례: KMS API를 호출할 수 없는 AWS 외부 이용자를 통해 이루어진 암호화
-
KMS 키의 종류
- AWS Owned Key (무료): SS3-S3, SSE-SQS, SSE-DDB (기본 키)
- AWS Managed Key: 무료 (aws/service-name ~ ex. aws/rds 또는 aws/ebs)
- Customer managed keys created in KMS: 한달에 1
- + KMS에 대한 API 호출에 따라 비용 지불 (0.03 / 10000 호출당)
-
Automatic Key rotation:
- AWS-managed KMS Key: 매년마다 자동으로 이루어짐
- Customer-managed KMS Key: (반드시 활성화됨) 매년마다 자동으로 이루어짐
- Imported KMS Key: alias를 통해 가능할 때마다 수동으로만 로테이션
Copying Snapshots across regions
KMS Key Policies
-
KMS 키에 대한 액세스를 통제, S3로 치자면 bucket policies와 유사함
-
차이점: Key Policy 없이는 액세스를 관리할 수 없음
-
Default KMS Key Policy:
- 구체적인 KMS Key Policy를 전달하지 않았을 때 생성
- 루트 User(전체 AWS 계정)에게 키에 대한 완전한 액세스 권한을 부여
-
Custom KMS Key Policy:
- KMS Key에 액세스할 수 있는 User, Role을 정의
- 키를 관리할 수 있는 대상을 정의
- 내 KMS 키에 대해 계정 간 액세스를 구현하는 데에 유용
Copying Snapshots across accounts
- 스냅샷을 생성하여 직접 보유한 KMS 키(Customer Managed Key)로 암호화
- cross-account 액세스 권한을 허용하도록 KMS Key Policy를 부여 (하단 참조)
- 암호화된 스냅샷을 공유
- (타겟에서) 스냅샷의 복제본을 생성하고, 이를 계정 내 CMK(Customer Managed Key)로 암호화
- 스냅샷으로부터 볼륨을 생성
{
"Sid": "Allow use of the key with destination account",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::TARGET-ACCOUNT-ID:role/ROLENAME"
},
"Action": [
"kms:Decrypt",
"kms:CreateGrant"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"kms:ViaService": "ec2.REGION.amazonaws.com",
"kms:CallerAccount": "TARGET-ACCOUNT-ID"
}
}
}
KMS Multi-Region Keys
- 다른 AWS 리전에서도 상호 교환이 가능한 동일 KMS Key
- Multi-Region key는 동일한 key ID, key material, automatic rotation을 가짐
- 한 리전에서 암호화하고, 다른 리전에서 복호화할 수 있음
- 다시 암호화하거나, 크로스 리전 API 호출을 수행할 필요 없음
- KMS Multi-Region은 글로벌하지 않음 (Primary + Replicas)
- 각각의 Multi-Region Key는 독립적으로 관리됨
- 사례: 글로벌 클라이언트 사이드 암호화, 글로벌 DynamoDB 또는 글로벌 Aurora 암호화
DynamoDB Global Tables and KMS Multi-Region Keys - Client-Side encryption
- Amazon DynamoDB Encryption Client를 사용하는 DynamoDB 테이블 내에 클라이언트 측에서 특정 attribute만 암호화할 수 있음
- GlobalTables와 함께 사용되어, 클라이언트 측에서 암호화된 데이터는 다른 리전으로도 복제됨
- 만약 Multi-Region Key를 사용한다면, 이를 DynamoDB Global 테이블과 동일한 리전에 복제하여 해당 리전 내 클라이언트들이 클라이언트 복호화를 수행하기 위해 KMS API을 낮은 레이턴시로 호출할 수 있게됨
- 클라이언트 측 암호화를 통해 특정한 필드만 보호할 수 있고, 오직 API 키에 액세스 권한을 보유한 클라이언트만 복호화를 수행하도록 보장할 수 있음
Global Aurora and KMS Multi-Region Keys - Client-Side encryption
- AWS Encryption SDK를 통해 Aurora 테이블 내 특정 attribute를 클라이언트 측 암호화 할 수 있음
- Aurora Global Table과 함께 사용하여, 클라이언트 측 암호화된 데이터를 다른 리전에 복제
- 만약 Multi-Region Key를 사용한다면, Global Aurora DB와 동일한 리전에 복제하여 해당 리전 내 클라이언트들이 클라이언트 복호화를 수행하기 위해 KMS API을 낮은 레이턴시로 호출할 수 있게됨
- 클라이언트 측 암호화를 통해 특정한 필드만 보호할 수 있고, 오직 API 키에 액세스 권한을 보유한 클라이언트만 복호화를 수행하도록 보장할 수 있으며, 심지어 DB 어드민으로부터도 특정 필드를 보호할 수 있음
S3 Replication Encryption Considerations
- 기본적으로 암호화되지 않은 오브젝트와 SSE-S3로 암호회된 오브젝트가 복제됨
- SSE-C(Customer provided key)로 암호화된 오브젝트는 복제될 수 없음
- SSE-KMS로 암호화된 오브젝트들의 경우, 옵션을 활성화해야함
- 타겟 버킷 내에 오브젝트를 암호화하기 위해 어떤 KMS 키를 쓸 것인지 명시
- 타겟 키를 위한 KMS Key Policy를 적용
- 소스 KMS Key에 대한 복호화를 위해
kms:Decrypt
와 타겟 KMS Key에 대한 암호화를 위해kms:Encrypt
를 가진 IAM Role이 필요함 - KMS 쓰로틀링 에러가 발생할 수 있으며, 이 경우 Service Quota의 증가를 요청할 수 있음
- Multi-Region AWS KMS Keys를 사용할 수는 있으나, 아직까지는 Amazon S3에서 이를 independent key로 다루고 있음 (오브젝트들은 여전히 복호화/암호화 처리가 될 것임)
AMI Sharing Process Encrypted via KMS
- AMI in Source Account는 Source Account로부터 KMS Key로 암호화됨
- 이미지 속성을 수정하여, 타겟 AWS 계정에 Launch Permission을 반드시 추가
- AMI 참조 스냅샷을 암호화하는 데 사용한 KMS Key를 타겟 계정 또는 IAM Role와 반드시 공유
- IAM Role 또는 타겟 계정 내 User는 반드시 DescribeKey, ReEcrypted, CreateGrant, Decrypt에 대한 권한을 보유해야 함
- AMI로부터 EC2 인스턴스를 실행할 때, 원한다면 타겟 계정은 본인 계정 내 새로운 KMS Key를 정의해서 볼륨을 새로 암호화할 수 있음
SSM Parameter Store
- configuration 및 secrets를 위한 안전한 스토리지
- KMS를 통한 암호화 (선택 사항)
- serverless, scalable, durable, easy SDK
- configuration / secrets에 대한 버전 트래킹
- IAM을 통해 보안
- Amazon EventBridge를 통한 알림
- CloudFormation과 호환
SSM Parameter Store Hierarchy
- /my-department/
- my-app/
- dev/ ~ Dev Lambda 함수 내
GetParameters
또는GetParametersByPath
API 사용- db-url
- db-password
- prod/ ~ Prod Lambda 함수 내
GetParameters
또는GetParametersByPath
API 사용- db-url
- db-password
- dev/ ~ Dev Lambda 함수 내
- other-app/
- my-app/
- /other-department/
- /aws/reference/secretsmanager/secret_ID_in_Secrets_Manager
- /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2 (public)
Standard | Advanced | |
---|---|---|
Total number of parameters allowed (per AWS account and AWS Region) |
10,000 |
100,000 |
Maximum size of a parameter value |
4 KB |
8 KB |
Parameter policies available |
No |
Yes |
Cost |
No additional charge |
Charges apply |
Parameters Policies (for advanced parameters)
-
파라미터에 TTL을 할당(만료일자)하여 패스워드와 같은 민감 데이터를 수정 또는 삭제하도록 강제할 수 있음
-
동시에 여러 개의 정책 할당 가능
-
Expiration (to delete a parameter)
{
"Type": "Expiration",
"Version": "1.0",
"Attributes": {
"Timestamp": "2020-12-02T21:34:33.000Z"
}
}
- ExpirationNotification (EventBridge)
{
"Type": "ExpirationNotification",
"Version": "1.0",
"Attributes": {
"Before": "15",
"Unit": "Days"
}
}
- NoChangeNotification (EventBridge)
{
"Type": "NoChangeNotification",
"Version": "1.0",
"Attributes": {
"After": "20",
"Unit": "Days"
}
}
AWS Secrets Manager
- secrets들을 저장하기 위한 새로운 서비스
- 매 X일 마다 secret의 rotation을 강제하는 기능
- rotation 작업에 있어 secret의 생성을 자동화 (Lambda 사용)
- Amazon RDS(MySQL, PostgreSQL, Aurora)와 호환
- KMS를 통해 secret 암호화
- 대부분은 RDS와 함께 사용
AWS Secrets Manager - Multi-Region Secrets
- 여러 AWS 리전 간에 secret을 복제
- Secrets Manager는 primary Secret에 싱크를 맞춘 read replica를 보유
- read replica Secret을 standalone Secret으로 승격시킬 수 있음
- 사례: Multi-region 앱, 재해 복구 전략, multi-region DB...
AWS Certificate Manager (ACM)
- **TLS Certificates(인증서)**들을 쉽게 프로비저닝, 관리, 배포
- 웹사이트에서의 in-flight 암호화를 제공 (HTTPS)
- public/private TLS 인증서를 모두 지원
- public TLS 인증서의 경우 무료
- 자동으로 TLS 인증서 갱신
- 다음과 호환 (TLS 인증서 가져오기)
- Elastic Load Balancers (CLB, ALB, NLB)
- CloudFront Distributions
- APIs on API Gateway
- EC2에는 ACM을 사용할 수 없음 (can't be extracted)
ACM - Requesting Public Certificates
-
인증서에 도메인 네임들을 추가
- Fully Qualified Domain Name (FQDN):
corp.example.com
- Wildcard Domain:
*.example.com
- Fully Qualified Domain Name (FQDN):
-
Validation 방식 선택: DNS Validation 또는 Email Validation
- DNS Validation이 자동화 목적으로 선호됨
- Email Validation은 WHOIS DB 내에 있는 contact 주소로 이메일이 전송됨
- DNS Validation은 DNS 설정 내 CNAME 레코드를 활용함 (ex. Route53)
-
검증에 몇 시간 정도가 소요됨
-
Public 인증서는 자동 갱신에 등록됨
- ACM에서 생성한 모든 인증서는 만료 60일 전에 자동으로 갱신됨
ACM - Importing Public Certificates
- ACM 외부에서 인증서를 생성하고, 이를 import하여 사용할 수 있는 옵션
- 자동 갱신 불가, 반드시 만료 전에 새 인증서를 import 해야함
- 만료 45일 전부터 ACM이 매일 만료 이벤트를 전송함
- 시작 일자는 변경 가능
- 해당 이벤트는 EventBridge에 나타남
- AWS Config은 인증서 만료를 체크하기 위해
acm-certificate-expiration-check
란 이름으로 관리되는 rule을 가짐 (날짜는 설정 가능)
API Gateway - Endpoint Types
- Edge-Optimized (기본): 글로벌 클라이언트용
- CloudFront Edge location을 통해 요청이 라우트됨 (레이턴시 향상)
- API Gateway는 여전히 오직 하나의 리전에만 있음
- Regional:
- 동일한 리전 내 클라이언트들을 위한 것
- CloudFront와 매뉴얼하게 조합할 수 있음 (캐싱 전략 및 배포를 더 많이 통제할 수 있음)
- Private:
- 인터페이스 VPC 엔드포인트(ENI)를 통해 내 VPC를 통해서만 액세스 할 수 있음
- 액세스를 정의하기 위해 Resource Policy를 사용
ACM - Integration with API Gateway
- API Gateway 내에 Custom Domain Name을 생성
- Edge-Optimized (기본): 글로벌 클라이언트용
- CloudFront Edge location을 통해 요청이 라우트됨 (레이턴시 향상)
- API Gateway는 여전히 오직 하나의 리전에만 있음
- TLS 인증서는 반드시 CloudFront와 동일한 리전에 있어야 함 (=
us-east-1
) - 이후 CNAME 또는 A-Alias 레코드를 설정 (A-Alias 쪽이 선호)
- Regional:
- 동일한 리전 내 클라이언트들을 위한 것
- TLS 인증서는 반드시 API Stage와 동일한 리전 내 API Gateway에서 import되어야 함
- 이후 CNAME 또는 A-Alias 레코드를 설정 (A-Alias 쪽이 선호)
AWS WAF - Web Application Firewall
- 일반적인 웹 취약점(exploit)으로부터 웹 애플리케이션을 보호 (layer 7)
- layer 7은 HTTP (vs layer 4는 TCP/UDP)
- 다음에 배포 가능
- Application Load Balancer
- API Gateway
- CloudFront
- AppSync GraphQL API
- Cognito User Pool
AWS WAF - Web ACL
- Web ACL (Web Access Control List) Rules:
- IP Set: 최대 10,000 IP 주소 - 더 많은 IP를 할당하려 한다면 여러 Rule을 추가하면 됨
- SQL injection과 Cross-Site Scripting(XSS)같은 일반적인 공격으로부터 HTTP 헤더/바디, 또는 URI 스트링을 보호
- 크기 제한, geo-match (block countries)
- Rate-based rules (이벤트 발생 횟수 카운팅) - DDoS 보호
- Web ACL은 CloudFront를 제외하고는 Regional하게 적용
- Rule Group = Web ACL에 추가될 수 있는 Rule의 재사용 가능한 집합
AWS WAF - Fixed IP while using WAF with a Load Balancer
- WAF는 NLB(Network Load Balancer)를 지원하지 않음 (layer 4)
- ALB에 WAF를 활성화하려면 고정 IP(fixed IP)가 요구되며, 이를 위해 Global Accelerator가 필요
AWS Shield: protect from DDoS attack
- DDoS: Distributed Denial of Service - 동시에 많은 요청을 보내는 것
- AWS Shield Standard:
- 모든 AWS 고객들에 대해 무료로 활성화되는 서비스
- SYN/UDP Floods, Reflection attacks 또는 그 외의 layer 3/layer 4 공격에 대한 보호
- AWS Shield Advanced:
- 선택적인 DDoS 완화(mitigation) 서비스 (organization별 매달 3,000)
- AC2, ELB, CloudFront, Global Accelerator, Route 53에 대한 보다 정교한 공격에 대한 보호
- AWS DDoS response team(DRP)과 항상 연결
- DDoS로 인한 usage spike 동안에 발생하는 높은 비용으로부터 보호
- Shield Advanced의 automatic application layer DDoS mitigation이 자동으로 AWS WAF Rule을 생성/평가/배포하여 layer 7 공격을 완화
AWS Firewall Manager
- AWS Organization 내 모든 계정에 대한 Rule을 관리
- Security Policy: security rule에 대한 common set
- WAF Rule (ALB, API Gateway, CloudFront)
- AWS Shield Advanced (ALB, CLB, NLB, Elastic IP, CloudFront)
- Security Groups for EC2, ALB and ENI resources in VPC
- AWS Network Firewall (VPC 레벨)
- Amazon Route 53 Resolver DNS Firewall
- 리전 레벨에서 생성된 Policy
- 내 Organization 내에 존재하거나 추후 추가될 모든 계정에서 생성되는 새 리소스들에 Rule이 적용됨(compliance에 유용함)
WAF vs. Firewall Manager vs. Shield
- WAF, Sheild, Firewall Manager은 모두 보안을 위해 함께 사용됨
- WAF에서는 Web ACL Rule을 정의함
- 리소스에 대한 세분화된 보호를 원한다면 WAF의 단독 사용이 적절한 선택임
- 만약, AWS WAF가 계정 간에 사용되어야 하거나, WAF 설정을 빠르게 하고 싶거나, 새 리소스에 대한 보호를 자동화하고 싶으면, AWS WAF에 Firewall Manager를 사용
- Shield Advanced는 AWS WAF에 추가로 Shield Response Team(SRT)로부터의 전담(dedicated) 지원과 고급 리포팅 같은 추가적인 기능을 제공함
- 반복적인 DDoS 공격에 취약한 상황이라면, Shield Advanced의 구매를 고려할 것
AWS Best Practices for DDoS Resiliency
- Edge Location Mitigation (BP1, BP3)
- BP1 - CloudFront
- 웹 애플리케이션이 엣지에서 전달됨
- 일반적인 DDoS 공격으로부터 보호 (SYN floods, UDP reflections...)
- BP1 - Global Accelerator
- 엣지로부터 애플리케이션 액세스
- DDoS 보호를 위한 Shield와 연동됨
- 만약 CloudFront와 백엔드가 호환되지 않는 경우 유용함
- BP3 - Route 53
- 엣지에서 Domain Name Resolution
- DNS에서 DDoS 보호 매커니즘이 적용되어 있음
- BP1 - CloudFront
- Infrastructure layer defense (BP1, BP3, BP6)
- EC2를 높은 트래픽으로부터 보호
- Global Accelerator, Route 53, CloudFront, Elastic Load Balancing을 포함
- Amazon EC2 with Auto Scaling (BP7)
- flash crowd 또는 DDoS 공격을 포함한 갑작스러운 트래픽 급증에 따른 스케일링을 도와줌
- Elastic Load Balancing (BP6)
- ELB가 급증하는 트래픽에 맞춰 스케일링을 수행하고, 이에 따라 트래픽을 여러 EC2 인스턴스로 분산시킴
- Application Layer Defense
- 악성 웹 요청에 대한 감지 및 필터링(BP1, BP2)
- CloudFront는 정적 컨텐츠를 캐시하고 이를 엣지 로케이션에서 전달하도록 하여 백엔드를 보호
- AWS WAF는 CloudFront와 ALB에 사용되어 요청 시그니처에 기반하여 요청을 필터링하거나 블록할 수 있음
- WAF의 rate-based rule은 악성 행위자(bad actor)에 대한 IP를 자동으로 블록
- CloudFront는 특정 지역을 블록할 수 있음
- Shield Advanced (BP1, BP2, BP6)
- Shield Advanced automatic application layer DDos mitigation은 layer 7 공격을 완화하기 위해 자동으로 AWS WAF Rule을 생성/평가/배포
- 악성 웹 요청에 대한 감지 및 필터링(BP1, BP2)
- Attack surface reduction
- AWS 리소스 난독화(obfuscating) (BP1, BP4, BP6)
- CloudFront, API Gateway, Elastic Load Balancing을 사용하여 백엔드 리소스(Lambda 함수, EC2 인스턴스)를 감춤
- Security Groups and Network ACLs (BP5)
- Security Group과 NACL을 통해 서브넷이나 ENI레벨에서 특정 IP에서 일어나는 트래픽을 필터링
- Elastic IP도 AWS Shield Advanced를 통해 보호됨
- API 엔드포인트를 보호 (BP4)
- EC2, Lambda를 다른 곳에 숨김
- Edge-optimized mode 또는 CloudFront + regional mode (DDoS에 대한 더 많은 통제)
- WAF + API Gateway: burst limit, 헤더 필터링, API key 사용을 강제
- AWS 리소스 난독화(obfuscating) (BP1, BP4, BP6)
Amazon GuardDuty
- AWS 계정 보호를 위해 인공지능으로 위협 감지
- 머신러닝 알고리즘, 이상징후 감지(anomaly detection), 써드파티 데이터 사용
- One Click 활성화 (30 days trial), 소프트웨어 설치 필요 없음
- Input Data들에는 다음이 포함:
- CloudTrail Events Logs - 비정상적인 API 호출, 무단 배포
- CloudTrail Management Events - VPC 서브넷 생성, trail 생성..
- CloudTrail S3 Data Events - 오브젝트 get/list/delete...
- VPC Flow Logs - 비정상적인 내부 트래픽, 비정상적인 IP 주소
- DNS Logs - DNS 쿼리 내에서 인코딩된 데이터를 전송하는 손상된 EC2 인스턴스
- Kubernetes Audit Logs - 의심스러운 활동 및 잠재적인 EKS 클러스터 손상
- CloudTrail Events Logs - 비정상적인 API 호출, 무단 배포
- 발견 시 알림이 전달될 수 있도록 EventBridge Rule을 설정 가능
- EventBridge rule을 AWS Lambda 또는 SNS로 향하게 할 수 있음
- CryptoCurrency 공격을 보호할 수 있음 (이를 위한 전용 검색 보유)
Amazon Inspector
- 자동화된 보안 평가(automated security assessments)
- For EC2 Instances
- AWS System Manager (SSM) 에이전트 활용
- 의도치 않은 네트워크 접근 가능성에 대한 분석
- 실행 중인 OS에 대한 known vulnerabilities을 파악
- For Container Images push to Amazon ECR
- 푸시된 컨테이너 이미지에 대한 평가
- For Lambda Functions
- 함수 코드와 패키지 의존성의 취약성을 파악
- 배포된 함수에 대한 평가
- AWS Security Hub에 리포팅 & 호환 가능
- Amazon Event Bridge로 결과 전송
What does Amazon Inspector evaluate?
- 중요: 오직 EC2 인스턴스, 컨테이너 이미지 & Lambda 함수에 대해서만 적용 가능
- 필요하다면 인프라에 대해 지속적인 스캐닝 가능
- 패키지 취약점 (EC2, ECR & Lambda) - database of CVE(Common Vulnerabilities and Exposures)
- 네트워크 도달 범위 ~ Network Reachability (EC2)
- 우선 순위에 따라 모든 취약점과 관련한 risk score를 제공
Amazon Macie
- Amazon Macie는 완전 관리형 데이터 보안 및 데이터 프라이버시 서비스
- 머신러닝과 패턴 매칭을 통해 AWS 내 민감한 데이터를 발견 및 보호
- Macie는 개인 식별 정보(PII ~ Personally Identifiable Information)과 같은 민감한 데이터를 파악하고 경고해줌
Networking - VPC
Understanding CIDR - IPv4
-
CIDR(Classless Inter-Domain Routing) - IP 주소 할당 방법
-
Security Groups Rule과 AWS 네트워킹에서 일반적으로 사용됨
-
IP 주소의 범위를 정의하는 것을 도와줌
WW.XX.YY.ZZ/32
-> 단일 IP0.0.0.0/0
-> 모든 IP192.168.0.0/26
->192.168.0.0
~192.168.0.63
(64개의 IP 주소)
-
CIDR은 두 부분으로 나뉨
- Base IP
- 범위에 포함될 IP (
XX.XX.XX.XX
) - ex.
10.0.0.0
,192.168.0.0
, ...
- 범위에 포함될 IP (
- Subnet Mask
- IP 내에서 얼마나 많은 bit가 바뀔 수 있는지를 정의
- ex.
/0
,/24
,/32
- 두 가지 형태가 될 수 있음
/8
<->255.0.0.0
/16
<-> `255.255.0.0/24
<->255.255.255.0
/32
<->255.255.255.255
- Base IP
-
Subnet Mask는 기본적으로 기본 IP로부터 얼마나 추가적인 값들을 허용할지 나타내는 부분이다.
- 예시
192.168.0.0/32
=> 1개의 IP 허용 (2^0) =>192.168.0.0
192.168.0.0/31
=> 2개의 IP 허용 (2^1) =>192.168.0.0
~192.168.0.1
192.168.0.0/24
=> 256개의 IP 허용 (2^8) =>192.168.0.0
~192.168.0.255
0.0.0.0/0
=> 모든 IP 허용 (2^32) =>0.0.0.0
~255.255.255.255
- 예시
-
주로 사용되는 경우
/32
- 어떤 octet도 바뀔 수 없음/24
- 마지막 octet이 바뀔 수 있음/16
- 뒤의 두 octet이 바뀔 수 있음/8
- 뒤의 세 octet이 바뀔 수 있음/0
- 모든 octet이 바뀔 수 있음
- 헷갈린다면 CIDR를 IP 주소 범위로 변환해주는 사이트도 있으니 참고
Public vs. Private IP (IPv4)
- IANA(Internet Assigned Numbers Authority)가 public / private 주소 사용을 위해 특정 IPv4 주소 블록을 설정해뒀음
- Private IP는 다음의 특정 값들만 허용됨:
10.0.0.0
~10.255.255.255
(10.0.0.0/8
) -> 대규모 네트워크172.16.0.0
~172.31.255.255
(172.16.0.0/12
) -> AWS 기본 VPC의 범위192.168.0.0
~192.168.255.255
(192.168.0.0/16
) -> ex. 홈 네트워크
- 인터넷 상에서 그 외의 나머지 IP 주소들은 모두 Public IP
Default VPC Walkthrough
- 모든 새 AWS 계정들은 기본 VPC를 가짐
- 새로운 EC2 인스턴스들은 별도로 서브넷을 지정하지 않는다면 기본 VPC로 실행됨
- 기본 VPC는 인터넷 연결이 되어있고, 그 안의 모든 EC2 인스턴스들은 public IPv4 주소를 갖게 됨
- public/private IPv4 DNS 네임을 가질 수 있음
VPC in AWS - IPv4
- VPC - Virtual Private Cloud
- 하나의 AWS 리전 내에 여러 VPC들을 둘 수 있음 (리전 별 최대 5개 ~ soft limit)
- VPC 별 CIDR은 최대 5개이며, 각 CIDR의 경우:
- 최소 사이즈
/28
(16개의 IP 주소) - 최대 사이즈
/16
(65536개의 IP 주소)
- 최소 사이즈
- VPC는 private이기 때문에, 오직 Private IPv4 주소만 허용됨:
10.0.0.0
~10.255.255.255
(10.0.0.0/8
)172.16.0.0
~172.31.255.255
(172.16.0.0/12
)192.168.0.0
~192.168.255.255
(192.168.0.0/16
)
- 내 VPC CIDR은 내 다른 네트워크와 겹쳐선 안됨 (ex. 회사의 IP 주소)
VPC - Subnet (IPv4)
- AWS 리소스들은 각 서브넷 별로 **5개의 IP 주소 (first 4 & last 1)**를 예약해둠
- 이 5개의 IP 주소는 사용할 수 없고, EC2 인스턴스에 할당될 수도 없음
- 예시: 만약 CIDR 블록이
10.0.0.0/24
라면, 예약되는 IP 주소들은 다음과 같음10.0.0.0
- Network Address10.0.0.1
- AWS에 의해 예약, VPC 라우터 용도10.0.0.2
- AWS에 의해 예약, Amazon에서 제공하는 DNS에 매핑10.0.0.3
- AWS에 의해 예약, 추후 사용 용도10.0.0.255
- Network Broadcast Address로, AWS에서는 VPC에서 broadcast를 지원하지 않기 때문에, 예약되어 있음
- 시험 팁: 만약 EC2 인스턴스에 29개의 IP 주소가 필요하다면?
/27
사이즈의 서브넷을 둘 수 없음 (32개 IP 주소 ~ 32 - 5 = 27 < 29)/26
사이즈의 서브넷을 골라야 함 (64개 IP 주소 ~ 64 - 5 = 59 > 29)
Internet Gateway (IGW)
- VPC 내에 있는 리소스(e.g., EC2 인스턴스)들의 인터넷 연결을 허용
- 수평적 확장 & highly available & 중복(redundant)
- 반드시 VPC와는 별도로 생성되어야 함
- 하나의 VPC는 오직 하나의 IGW에 연결될 수 있으며, 반대의 경우도 마찬가지
- Internet Gateway 그 자체만으로는 인터넷 액세스를 허용하지 않음
- 반드시 Route table도 함께 수정되어야 함!
Bastion Hosts
- private EC2 인스턴스에 SSH 연결을 사용하고 싶을 때 Bastion Host를 사용할 수 있음
- bastion은 다른 private 서브넷들에 연결되어 있는 public 서브넷
- Bastion Host security group은 반드시 제한된 CIDR의 22번 포트로부터 인바운드만 허용해야 함
- ex. 회사의 public CIDR
- EC2 인스턴스의 Security Group은 반드시 Bastion Host의 Security Group을 허용하거나, Bastion host의 private IP를 허용해야 함
NAT Instance (outdated, but still at the exam)
- NAT = Network Address Translation
- private 서브넷 내의 EC2 인스턴스들이 인터넷에 연결될 수 있도록 해줌
- 반드시 public 서브넷 내에서 실행되어야 함
- 다음 EC2 설정을 반드시 비활성화 해야함: Source / destination Check
- 연결할 Elastic IP가 반드시 필요함
- private 서브넷으로부터의 트래픽을 NAT 인스턴스로 라우팅하도록 Route Table이 반드시 설정되어야 함
NAT Instance - Comments
- 사전에 설정되어 있는(pre-configured) Amazon Linux AMI를 사용할 수 있음
- 공식 지원은 2020/12/31에 끝났음
- 기본적으로는 Highly Available 및 resilient setup이 제공되지 않음
- multi-AZ에 ASG를 만들고, resilient user-data script를 생성할 필요가 있음
- 인터넷 트래픽 대역폭(bandwidth)는 EC2 인스턴스 유형에 따라 다름
- 반드시 Security group & Rule을 관리해야함:
- 인바운드:
- private 서브넷으로부터 오는 HTTP/HTTPS 트래픽을 허용
- 홈 네트워크로부터의 SSH 연결(Internet Gateway를 통한 액세스)을 허용
- 아웃바운드:
- 인터넷으로의 HTTP/HTTPS 트래픽을 허용
- 인바운드:
NAT Gateway
- AWS가 관리하는 NAT, 높은 대역폭, high availability, 관리 불필요
- 사용한 시간과 대역폭에 따라 비용 지불
- NAT Gateway는 특정 AZ에 생성되며, Elastic IP를 사용
- 동일한 서브넷 내 EC2 인스턴스에서 사용될 수는 없음 (다른 서브넷을 통해서만 가능)
- IGW(Internet Gateway)가 필요함 (Private Subnet => NATGW => IGW)
- 5Gbps의 대역폭, 최대 45Gbps로 스케일 업
- 따로 Security Group을 관리해야 하거나 필요하지 않음
NAT Gateway with High Availability
- NAT Gateway는 단일 AZ 내에서 resilient함
- 내결함성(fault-tolerance)를 위해서는 반드시 여러 AZ 내에 여러 NAT Gateway들을 생성해야함
- cross-AZ failover가 필요 없음 -> AZ가 다운되면 NAT가 필요하지 않기 때문
NAT Gateway vs. NAT Instance
Network Access Control List (NACL)
- NACL은 서브넷 안팎으로 오가는 트래픽을 관리하는 일종의 방화벽
- 서브넷 별로 하나의 NACL이 있고, 새로운 서브넷에는 Default NACL이 할당됨
- NACL Rules를 정의:
- Rule에는 번호가 지정(1~32766), 낮은 번호일 수록 더 높은 우선순위
- 첫번째로 일치하는 규칙을 기반으로 결정을 내림
- ex. 만약 #100번 규칙으로
10.0.0.10/32
를 허용했다면, #200번 규칙으로10.0.0.10/32
에 대해 거부했다면, 해당 IP 주소에 대해서는 허용될 것임 - 마지막 Rule은 아스터리스크(
*
)이며, 아무런 규칙도 매칭되지 않을 경우 요청을 거부 - AWS에서는 100 단위로 규칙을 추가할 것을 추천함
- 새로 생성된 NACL의 경우 모든 경우에 대해 거부
- NACL은 서브넷 레벨에서 특정 IP 주소를 블록하기에 아주 좋은 방법
Default NACL
- 할당된 서브넷에 대한 모든 인바운드/아웃바운드를 허용함
- Default NACL을 수정하지 말 것, 대신에 커스텀 NACL을 새로 생성해야함
Ephemeral Ports (임시 포트)
- 두 엔드포인트 간에 연결이 이루어지려면, 반드시 포트를 사용해야 함
- 클라이언트가 defined port(= fixed port)로 연결하고, ephemeral port(임시 포트)에서 응답을 내보냄
- 운영체제가 다르다면, 포트의 범위도 다르게 사용함
- 예시
- IANA & MS Windows 10 -> 49152 ~ 65535
- Many Linux Kernels -. 32768 ~ 60999
- 예시
NACL with Ephemeral Ports
Securiy Group vs. NACLs
VPC Peering
- AWS 네트워크를 통해 두 VPC 간에 private하게 연결을 할 수 있게 해줌
- 마치 두 VPC가 동일한 네트워크에 있는 것처럼 동작하게 만듬
- 중복되는 CIDR을 가져선 안됨
- VPC Peering 연결은 transitive(전이적)하지 않음 (반드시 VPC 간에 서로 직접 연결되어야 함)
- EC2 인스턴스들이 서로 상호작용할 수 있도록 하려면 각각의 VPC 서브넷 안에 있는 Route Table을 업데이트 해야만 함
VPC Peering - Goot to know
- 서로 다른 AWS 계정/리전 내에서도 VPC 간에 VPC Peering 연결을 생성할 수 있음
- 피어링된 VPC 내에서는 Security Group을 서로 참조할 수 있음 (동일한 리전 내 cross account 가능)
VPC Endpoints (AWS PrivateLink)
- 모든 AWS 서비스는 공개적으로 노출 (public URL)
- VPC Endpoints (powered by AWS PrivateLink)는 public 인터넷 대신 private network를 사용하여 AWS 서비스들을 연결할 수 있도록 해줌
- redundant, 수평적 확장
- AWS 서비스들에 액세스 하기 위해 IGW, NATGW 같은 것들을 사용할 필요가 없어짐
- 문제가 발생했을 경우:
- VPC에서 DNS 설정을 확인
- Route Table을 확인
Types of Endpoints
- Interface Endpoints (powered by PrivateLink)
- ENI(private IP 주소)를 엔트리 포인트(반드시 Security Group 연결 필요)로 프로비저닝
- 대부분의 AWS 서비스들을 지원
- 시간 당 비용 + 처리되는 데이터 GB당 비용
- Gateway Endpoints
- 게이트웨이를 프로비저닝하며, 반드시 이를 Route Table 내에서 타겟으로 사용해야 함 (Security Group 사용이 필요 없음)
- S3와 DynamoDB를 지원
- 무료
Gateway or Interface Endpoint for S3?
- Gateway가 거의 대부분의 경우 더 선호됨
- 비용 측면: Gateway Interface는 무료인 반면 Interface Endpoint는 비용 필요
- Interface Endpoint는 온-프레미스 혹은 다른 VPC/리전으로부터의 액세스가 필요한 경우에 선호됨
VPC Flow Logs
- 내 인터페이스로 이동하는 IP 트래픽들에 대한 정보를 캡처:
- VPC Flow Logs
- Subnet Flow Logs
- Elastic Network Interface (ENI) Flow Logs
- 모니터링 & 연결 이슈에 대한 트러블슈팅에 도움
- Flow Logs 데이터는 S3, CloudWatch Logs, Kinesis Data Firehose로 보낼 수 있음
- AWS에서 관리되는 인터페이스로부터의 네트워크 정보도 캡처:
- ELB, RDS, ElastiCache, Redshift, WorkSpaces, NATGW, Transit Gateway..
VPC Flow Logs Syntax
- srcaddr & dstaddr - 문제가 있는 IP를 파악하는 데에 도움
- srcport & dstport - 문제가 있는 포트를 파악하는 데에 도움
- Action - Security Group / NACL에 따른 요청 성공/실패 여부
- 사용 패턴 / 악의적 행동에 대한 분석을 위해 사용할 수 있음
- S3의 Athena 또는 CloudWatch Log Insights를 통해 VPC Flow Log를 쿼리할 수 있음
- Flow Log 예시
VPC Flow Logs - Troubleshoot SG & NACL Issues
- ACTION 필드를 확인하자!
- Incoming Request의 경우
- 인바운드 REJECT => NACL 또는 SG
- 인바운드 ACCEPT, 아웃바운드 REJECT => NACL
- Outgoing Requests의 경우
- 아웃바운드 REJECT => NACL 또는 SG
- 아웃바운드 ACCEPT, 인바운드 REJECT => NACL
- Incoming Request의 경우
VPC Flow Logs - Architectures
- VPC Flow Logs -> CloudWatch Logs -> CloudWatch Contributor Insights
- VPC Flow Logs -> CloudWatch Logs -> CloudWatch Alarm -> Amazon SNS
- VPC Flow Logs -> S3 Bucket -> Amazon Athena -> Amazon QuickSight
AWS Site-to-Site VPN
- Virtual Private Gateway (VGW)
- VPN 연결 시 AWS 측의 VPN concentrator
- VGW를 생성 후 Site-to-Site VPN 연결을 생성하고 싶은 VPC에 연결
- ASN(Autonomous System Number)를 커스터마이징 할 수 있음
- Customer Gateway (CGW)
- VPN 연결의 customer 측 소프트웨어 애플리케이션 또는 실제 디바이스
Site-to-Site VPN Connections
- Customer Gateway Devices (On-Premises)
- 어떤 IP 주소를 사용하는가?
- Customer Gateway 디바이스에 대한 Public Internet-routable IP 주소를 사용
- 만약 NAT traversal(NAT-T)가 활성화된 NAT 디바이스라면, NAT 디바이스의 public IP 주소를 사용
- 어떤 IP 주소를 사용하는가?
- 중요한 과정: 내 서브넷과 연결된 Route Table 내 Virtual Private Gateway에 대한 Route Propagation을 활성화해야 함
- 온-프레미스로부터 EC2 인스턴스를 ping해야 한다면, Security Group의 인바운드로 ICMP 프로토콜을 추가하였는지 확인해야 함
AWS VPN CloudHub
- 여러 VPN 연결을 보유한 경우, 여러 사이트 간의 보안 연결을 제공
- 다른 로케이션 간의 주요 또는 보조 네트워크 연결을 위한 저비용 hub-and-spoke 모델 (VPN Only)
- VPN 연결이므로, public 인터넷을 통해 연결됨
- 설정하려면 동일한 VGW에 여러 VPN을 연결하고, dynamic routing을 설정하고 Route Table을 구성
Direct Connect (DX)
- 원격 네트워크에서 내 VPC로 전용 private 연결을 할 수 있게 해줌
- 전용 연결은 반드시 내 Direct Connect와 AWS Direct Connection 로케이션 사이에 구성되어야 함
- VPC에 Virtual Private Gateway를 설정해야 함
- 하나의 연결로 public 리소스(S3)와 private 리소스(EC2)에 액세스할 수 있음
- 사례:
- 대역폭 처리량 증가 - 거대한 데이터 셋을 다루는 경우 ~ 더 낮은 비용
- 보다 일관적인 네트워크 경험 - 실시간 데이터 피드를 사용하는 애플리케이션
- 하이브리드 환경 (온-프레미스 + 클라우드)
- IPv4와 IPv6 모두 지원
Direct Connect Diagram
Direct Connect Gateway
- 여러 리전(동일한 계정) 간에 하나 이상의 VPC로 Direct Connect를 설정하고자 한다면 반드시 Direct Connect Gateway를 사용해야 함
Direct Connect - Connection Types
-
Dedicated Connections: 1Gbps, 10Gbps and 100Gbps 가용량
- customer 전용의 물리적 이더넷 포트
- AWS에 먼저 요청을 하고, 이후 AWS Direct Connect Partner로부터 완료됨
-
Hosted Connections: 50Mbps, 500Mbps, 최대 10Gbps
- AWS Direct Connect Partner를 통해 연결 요청이 이루어짐
- 가용량(capacity)이 on-demand로 추가/삭제될 수 있음
- AWS Direct Connect Partner 선택에 따라 1, 2, 5, 10Gbps
-
새로운 연결을 구축하는데는 주로 1달 이상 소요됨
Direct Connect - Encryption
- 데이터는 전송 중(in transit) 암호화 되지 않지만, private함
- AWS Direct Connect + VPN은 IPsec-encrypted된 private 연결을 제공함
- 더 추가적인 보안을 챙길 수 있으나, 구축에 있어 좀 더 복잡함
Direct Connect - Resiliency
- 주요 워크로드에 대한 High Resiliency
- 여러 location들에 대해 하나로 연결
- 주요 워크로드에 대한 Maximum Resiliency
- 하나 이상의 location 내 별도의 장치에서 종료되는 연결들을 분리함으로써 Maximum resilience를 지킬 수 있음
Site-to-Site VPN connection as a backup
- Direct Connect가 실패할 경우를 대비하고자 하는 경우
- 백업 Direct Connect 연결을 구축하거나 (비쌈)
- Site-to-Site VPN 연결을 구축할 수 있음
Transit Gateway
- 수천 개의 VPC와 온-프레미스 사이에 transitive(전이적) peering을 구축함으로써 hub-and-spoke(star) 연결을 만듬
- 리전 별 리소스, cross-region으로 동작할 수 있음
- RAM(Resource Access Manager)를 통해 계정 간에 공유 가능
- 리전 간에 Transit Gateway들을 피어링할 수 있음
- Route Tables: 다른 VPC와 소통할 수 있는 VPC들을 제한할 수 있음
- Direct Connect 게이트웨이 및 VPC 연결과 호환 가능
- IP Multicast 지원 (다른 어떤 AWS 서비스에서도 지원하지 않음)
Transit Gateway - Site-to-Site VPN ECMP
- **ECMP = Equal-Cost Multiple-Path routing
- 여러 최적의 경로를 통해 패킷을 넘기도록 하는 라우팅 전략
- 사례: AWS로의 연결 대역폭을 상승시키기 위해 여러 개의 Site-to-Site VPN 연결을 구축
Transit Gateway - throughput with ECMP
- VPN to Virtual Private Gateway
- 하나의 터널로 하나의 VPC에 연결
- 하나의 터널 -> 1.25Gbps
- VPN to Transit Gateway
- 하나의 site-to-site VPN 연결로 여러 개의 VPC에 연결
- 하나의 site-to-site VPN 연결은 2.5Gbps (ECMP ~ 해당 전략에 두개의 터널이 사용됨)
- 더 많은 site-to-site VPN 연결을 추가할수록 처리량이 증가
- 2개 ~ 5.0Gbps (ECMP)
- 3개 ~ 7.5Gbps (ECMP)
- 더 많은 site-to-site VPN 연결을 추가할수록 처리량이 증가
- Transit Gateway를 통해 처리되는 데이터 GB 당 비용 지불
Transit Gateway - Share Direct Connect between multiple accounts
VPC - Traffic Mirroring
- 내 VPC 내에서의 네트워크 트래픽을 캡처 및 검사하도록 해줌
- 내가 관리하는 보안 appliance로 트래픽을 라우팅
- 트래픽 캡처
- From (Source) - 여러 ENI
- To (Targets) - 하나의 ENI 또는 하나의 NLB
- 모든 패킷을 캡처 또는 내가 관심있는 패킷만 캡처 (선택적으로 패킷 잘라내기 가능 ~ truncate)
- Source와 Target은 동일한 VPC에 있을수도, 다른 VPC에 있을 수도 있음 (VPC Peering)
- 사례: 컨텐츠 검사, 위협 모니터링, 트러블슈팅...
IPv6 for VPC
- IPv4는 4.3 billon(43억)개의 주소를 제공 (곧 소진될 것)
- IPv6는 IPv4의 후속
- IPv6는 3.4 x 10^38 개의 고유 IP 주소를 제공
- 모든 IPv6 주소는 public이며, internet-routable함 (= private 범위가 없음)
- 형태 =>
x.x.x.x.x.x.x.x
(x는 16진수, 범위는 0000 ~ ffff) - 예시:
2001:db8:3333:4444:5555:6666:7777:8888
2001:db8:3333:4444:cccc:dddd:eeee:ffff
::
=> 모든 8 segments가 모두 02001:db8::
=> 뒤의 6 segments가 모두 0::1234:5678
=> 첫 6 segments가 모두 02001:db8::1234:5678
=> 가운데 4 segments가 모두 0
IPv6 in VPC
- IPv4는 VPC와 서브넷에서 비활성화될 수는 없음
- dual-stack 모드로 작동시키기 위해 IPv6를 활성화할 수는 있음 (모두 public IP 주소)
- 내 EC2 인스턴스들에서는 최소한 내부 private IPv4와 public IPv6를 갖게 됨
- Internet Gateway으로 인터넷과 IPv4 또는 IPv6 양측을 통해 소통할 수 있게됨
IPv6 Troubleshooting
- IPv4는 VPC와 서브넷에서 비활성화될 수는 없음
- 따라서, 내 서브넷에서 EC2 인스턴스를 실행할 수 없다면
- 이는 IPv6를 획득할 수 없기 때문이 아니라 (IPv6 공간은 매우 크기 때문)
- 서브넷 내에서 이용가능한 IPv4이 없기 때문임
- 해결책: 내 서브넷에서 새로운 IPv4 CIDR을 생성
Egress-only Internet Gateway
- IPv6만을 사용하고 하고자 할 때 적용
- NAT Gateway와 유사하나, IPv6 전용
- 내 VPC 안에 있는 인스턴스들이 IPv6로 아웃바운드 연결을 할 수 있도록 해줌
- 반면, 인터넷에서는 인스턴스에 IPv6 연결을 할 수 없도록 차단함
- 반드시 Route Table을 업데이트 해야함
IPv6 Routing
VPC Section Summary
- CIDR - IP 범위
- VPC - Virtual Private Cloud => IPv4 & IPv6 CIDR의 목록을 정의
- Subnets - 하나의 AZ에 묶임, 하나의 CIDR을 정의
- Internet Gateway - VPC 레벨에서 IPv4 & IPv6 인터넷 액세스를 제공
- Route Tables - 서브넷에서 IGW, VPC Peering 연결, VPC Endpoint 등으로의 라우트를 추가하기 위해서는 반드시 수정
- Bastion Host - private 서브넷 내의 EC2 인스턴스와도 SSH 연결을 수행하기 위해, SSH로 접속할 수 있는 public EC2 인스턴스
- NAT Instances - private 서브넷 내의 EC2 인스턴스에 인터넷 액세스를 부여(구식), 반드시 public 서브넷 내에서 설정되어야 하며, Source/Destination 체크 옵션을 반드시 비활성화 해야함
- NAT Gateway - private EC2 인스턴스에 scalable한 인터넷 액세스를 제공, AWS에 의해 관리되며, IPv4 전용
- Private DNS + Route 53 - DNS Resolution + DNS Hostnames (VPC)를 활성화
- NACL - stateless, 인바운드/아웃바운드 서브넷 룰, 임시 포트(Emphemeral Ports)
- Security Groups - stateful, EC2 인스턴스 레벨에서 작업
- Reachability Analyzer - AWS 리소스 간의 네트워크 연결성 테스트를 수행
- VPC Peering - 중복되지 않는 CIDR을 갖는 두 VPC를 연결, 비전이적(non-transitive)
- VPC Endpoints - VPC 내에서 AWS 서비스에 대한 private 액세스를 제공 (S3, DynamoDB, CloudFormation, SSM)
- VPC Flow Logs - VPC / 서브넷 / ENI 레벨에서 설정될 수 있음, 트래픽의 ACCEPT/REJECT 여부 판단, Athena 또는 CloudWatch Log로 공격을 파악하거나 분석을 수행할 수 있음
- Site-to-Site VPN - 데이터센터(DC)에 Customer Gateway, VPC에 Virtual Private Gateway, public 인터넷 간에 Site-to-Site VPN을 설정
- AWS VPN CloudHub - 사이트에 연결하기 위한 hub-and-spoke VPN 모델
- Direct Connect - VPC에 Virtual Private Gateway 설정, AWS Direct Connection Location에 direct private connection 구성
- Direct Connect Gateway - 다른 AWS 리전들 내에 여러 VPC들에 대한 Direct Connect 설정
- AWS PrivateLink / VPC Endpoint Services:
- 내 service VPC에서 customer VPC로 private하게 서비스를 연결
- VPC Peering, pulbic Internet, NAT Gateway, Route Table 같은 것이 필요 없음
- Network Load Balancer & ENI를 사용해야 함
- ClassicLink - 내 VPC에 EC2와 Classic EC2 인스턴스를 private하게 연결
- Transit Gateway - VPC, VPN & DX에 전이적인(transitive) peering 연결
- Traffic Mirroring - 자세한 분석을 위해 ENI로부터의 네트워크 트래픽을 복사
- Egress-only Internet Gateway - NAT Gateway와 유사하나, IPv6 전용
Networking Costs in AWS per GB - Simplified
- 비용 절약과 더 좋은 네트워크 성능을 위해서는 Public IP 보다 Private IP를 사용하라
- 최대의 비용 절약을 위해서는 동일한 AZ를 사용 (대신, High Availability와 트레이드 오프)
Minimizing egress traffic network cost
- Egress traffic(송신 트래픽): 아웃바운드 트래픽 (AWS -> 외부)
- Ingress traffic(유입 트래픽): 인바운드 트래픽 (외부 -> AWS, 일반적으로 무료)
- 비용 절감을 위해서는 가능한 많은 인터넷 트래픽을 AWS 내부에 유지시키는 편이 좋음
S3 Data Transfer Pricing - Analysis for USA
- S3 Ingress: 무료
- S3 to Internet: GB 당 0.04 ~ 0.00
- CloudFront to Internet: GB 당 0.02
Pricing: NAT Gateway vs. Gateway VPC Endpoint
- NAT Gateway를 이용하여 Private subnet과 소통하는 것보다, VPC Endpoint를 두는 편이 비용이 훨씬 더 절감
Network Protection on AWS
- AWS에서의 네트워크를 보호하기 위해 지금껏 아래와 같은 서비스가 있었음
- Network Access Control Lists (NACLs)
- Amazon VPC security groups
- AWS WAF (악성 요청을 보호)
- AWS Shield & AWS Shield Advanced
- AWS Firewall Manager (cross account로 관리)
- VPC 전체를 정교한 방식으로 보호하고자 한다면 어떻게 할까?
AWS Network Firewall
- Amazon VPC 전체를 보호
- layer 3부터 layer 7까지 보호
- 다음의 어떤 형태의 전송이든 검사 가능
- VPC to VPC 트래픽
- 인터넷으로의 아웃바운드
- 인터넷으로부터의 인바운드
- Direct Connect 또는 Site-to-Site VPN의 인바운드/아웃바운드
- 내부적으로, AWS Network Firewall은 AWS Gateway Load Balancer를 사용함
- Rule들은 여러 VPC에 적용하기 위해 AWS Firewall Manager에 의해 cross-account로 중앙 집중식(centrally) 관리될 수 있음
Network Firewall - Fine Grained Controls
- 1000개의 Rule 지원
- IP & Port - ex. 10,000개의 IP 필터링
- Protocol - ex. 아웃바운드 커뮤니케이션에 대해 SMB 프로토콜만 블록
- Stateful domain list rule groups:
*.mycorp.com
또는 써드파티 소프트웨어 repo로 향하는 아웃바운드 트래픽을 허용 - Regex를 통한 정규표현식 매칭
- Traffic Filtering: Rule과 매칭되는 트래픽을 허용/드롭/경고
- Active flow inspection: 침입 차단 기능으로 네트워크 위협으로부터 보호 (Gateway Load Balancer와 유사하나, AWS에 의해 완전 관리됨)
- Rule과 매치된 로그들을 Amazon S3, CloudWatch Logs, Kinesis Data Firehose로 전송
Disaster Recovery & Migrations
- 기업의 사업 지속성이나 재정에 부정적인 영향을 끼치는 어떤 상황을 disaster라고 함
- Disaster recovery (DR)은 disaster에 대비 및 복구를 하는 것을 의미
- Disaster recovery의 종류?
- 온-프레미스 => 온-프레미스: 전통적인 방식의 DR, 매우 비쌈
- 온-프레미스 => AWS 클라우드: 하이브리드 방식
- AWS Cloud Region A => AWS Cloud Region B
- 복구 목표에 대한 두 용어:
- RPO: Recovery Point Objective
- RTO: Recovery Time Objective
RPO and RTO
Disaster Recovery Strategies
- Backup & Restore
- Pilot Light
- Warm StandBy
- Hot Site / Multi Site Approach
Backup and Restore (High RPO)
Pilot Light
- 앱의 작은 버전이 항상 클라우드에서 실행되도록 함
- 핵심 코어에 유용함 (pilot light)
- Backup and Restore와 매우 유사
- Backup and Restore보다 빠름 ~ 주요 critical system이 항상 가동 중이기 때문
Warm Standby
- 전체 시스템을 가동 및 실행시키지만, 최소한의 사이즈로 유지함
- disaster 발생 시에는 프로덕션의 부하에 맞추어 스케일 업을 수행할 수 있음
Multi Site / Hot Site Approach
- 매우 낮은 RTO (몇분 또는 몇초 단위) - 매우 비쌈
- Full Production Scale이 AWS와 온-프레미스로 실행됨
Disaster Recovery Tips
-
Backup
- EBS 스냅샷, RDS 자동화 백업 / 스냅샷 등..
- 정기적인 푸쉬 ~ S3 / S3 IA / Glacier, Lifecycle Policy, Cross Region Replication
- 온-프레미스의 경우: Snowball 똔느 Storage Gateway
-
High Availability
- 리전에서 리전으로 DNS를 마이그레이션하는 경우 Route53을 사용
- RDS Multi-AZ, ElastiCache Multi-AZ, EFS, S3
- Direct Connect에서는 Site-to-Site VPN을 복구(recovery) 용도로 사용
-
Replication
- RDS Replication (Cross Region), AWS Aurora + Global Databases
- 온-프레미스에서 RDS로 Database replication
- Storage Gateway
-
Automation
- CloudFormation / Elastic Beanstalk ~ 새로운 환경에서 새로 생성
- CloudWatch로 alarm이 fail인 경우 EC2 인스턴스를 복구/재부팅
- 커스터마이징된 자동화를 위해 AWS Lambda 함수를 사용
-
Chaos
- Netflix는 무작위로 EC2를 종료시켜버리는 Chaos Monkey를 보유하고 있음
DMS - Database Migration Service
- 빠르고 안전하게 데이터베이스를 AWS로 마이그레이션
- resilient
- self healing
- source 데이터베이스는 마이그레이션 중에도 사용가능한 상태가 유지됨
- 지원:
- Homogeneous migrations(동종 마이그레이션) ~ ex. Oracle to Oracle
- Heterogeneous migrations(이기종 마이그레이션) ~ ex. Microsoft SQL Server to Aurora
- CDC를 통해 지속적인 Data Replication
- 반드시 replication 작업을 수행할 EC2 인스턴스를 생성해야 함
DMS Sources and Targets
- Sources:
- 온-프레미스 & EC2 인스턴스 DB
- Oracle, MS SQL Server, MySQL, MariaDB, PostgreSQL, MongoDB, SAP, DB2
- Azure: Azure SQL Database
- Amazon RDS: Aurora 포함해서 전부
- Amazon S3
- DocumentDB
- 온-프레미스 & EC2 인스턴스 DB
- Targets:
- 온-프레미스 & EC2 인스턴스 DB
- Oracle, MS SQL Server, MySQL, MariaDB, PostgreSQL, MongoDB, SAP
- Amazon RDS
- Redshift, DynamoDB, S3
- OpenSearch Service
- Kinesis Data Streams
- Apache Kafka
- DocumentDB & Amazon Neptune
- Redis & Babelfish
- 온-프레미스 & EC2 인스턴스 DB
AWS Schema Conversion Tool (SCT)
- 한 엔진에서 다른 종류의 엔진으로 데이터베이스 스키마를 변환
- ex. OLTP: (SQL Server or Oracle) to MySQL, PostgreSQL, Aurora
- ex. OLAP: (Teradata or Orcale) to Amazon Redshift
- 동일한 DB 엔진으로 마이그레이션을 해야하는 상황이라면 SCT를 쓸 이유가 없음
- ex. 온-프레미스 PostgreSQL => RDS PostgreSQL
- RDS는 플랫폼일 뿐, DB 엔진은 여전히 PostgreSQL
DMS - Continuous Replication
RDS & Aurora MySQL Migrations
- RDS MySQL to Aurora MySQL
- 옵션 1: RDS MySQL로부터 DB 스냅샷을 만들고 MySQL Aurora DB에서 복구
- 옵션 2: RDS MySQL로부터 Aurora Read Replica를 만들고, replication lag이 0이 되었을 때, 이를 자체 DB 클러스터로 승격시킴 (시간, 비용 추가)
- 외부의 MySQL to Aurora MySQL
- 옵션 1:
- Percona XtraBackup으로 S3에서 파일 백업을 생성
- S3로부터 Aurora MySQL DB를 생성
- 옵션 2:
- Aurora MySQL DB를 생성
- mysqldump 유틸을 사용하여 MySQL을 Aurora로 마이그레이션 (S3를 통한 방법보다 느림)
- 옵션 1:
- 만약 양측의 DB가 모두 실행 및 구동 중이라면 DMS를 사용
RDS & Aurora postgreSQL Migrations
- RDS PostgreSQL to Aurora PostgreSQL
- 옵션 1: RDS PostgreSQL로부터 DB 스냅샷을 만들고 PostgreSQL Aurora DB에서 복구
- 옵션 2: RDS PostgreSQL로부터 Aurora Read Replica를 만들고, replication lag이 0이 되었을 때, 이를 자체 DB 클러스터로 승격시킴 (시간, 비용 추가)
- 외부의 PostgreSQL to Aurora PostgreSQL
- 백업을 만들고 S3에 넣음
- aws_s3 Aurora 익스텐션으로 이를 가져옴
- 만약 양측의 DB가 모두 실행 및 구동 중이라면 DMS를 사용
On-Premise Strategy with AWS
- Amazon Linux 2 AMI를 VM으로 다운로드 할 수 있음 (.iso 포맷)
- VMWare, KVM, VirtualBox (Oracle VM), Microsoft Hyper-V
- VM Import / Export
- 기존 애플리케이션을 EC2로 마이그레이션
- 온-프레미스 VM에 사용하기 위해 DR(Disaster Recovery) repo를 생성하는 전략
- EC2에서 온-프레미스로 VM을 export할 수도 있음
- AWS Application Discovery Service
- 마이그레이션 계획을 위해 온-프레미스 서버에 대한 정보를 수집
- 서버 사용률 및 종속성 매핑
- AWS Migration Hub로 트래킹
- AWS Database Migration Service (DMS)
- 온-프레미스 => AWS, AWS => AWS, AWS => 온-프레미스 복제
- 다양한 DB 엔진과 호환 (Oracle, MySQL, DynamoDB, etc...)
- AWS Server Migration Service (SMS)
- 온-프레미스의 라이브 서버에서 AWS로의 증분 복제 (incremental replication)
AWS Backup
- 완전 관리형 서비스
- AWS 서비스들의 백업을 중앙 관리 및 자동화
- 커스텀 스크립트를 생성하거나 수동으로 처리해야할 필요가 없음
- 지원 서비스:
- EC2 / EBS
- S3
- RDS (모든 엔진 지원) / Aurora / DynamoDB
- DocumentDB / Neptune
- EFS / FSx (Lustre & Windows File Server)
- AWS Storage Gateway (Volume Gateway)
- cross-region 백업 지원
- cross-account 백업 지원
- 지원 서비스에 대한 PITR(Point-In-Time Recovery) 지원
- On-Demand & Scheduled backup
- Tag-based backup 정책
- Backup Plan이라는 이름의 백업 정책 생성
- 백업 주기 (매 12시간마다 / 매일 / 매주 / 매월 / cron 표현식 사용)
- 백업 윈도우
- Cold Storage로 전환 (Never / Days / Weeks / Months / Years)
- 보존 기간 (Always / Days / Weeks / Months / Years)
AWS Backup Vault Lock
- AWS Backup Vault에 저장한 백업들에 대해 WORM (Write Once Read Many) 상태를 강제
- 백업한 내용을 다음으로부터 보호하기 위한 추가 레이어:
- 의도치 않거나, 악의적인 삭제 작업
- 보존 기간을 더 짧게 하거나 변경하는 업데이트
- 이것이 활성화된 경우, 심지어 루트 이용자도 백업을 삭제할 수 없게 됨
AWS Application Discovery Service
-
온-프레미스 데이터 센터에 대한 정보를 수집함으로써 프로젝트 마이그레이션을 계획
-
서버 사용률(server utilization)과 종속성 매핑은 마이그레이션에 있어 중요함
-
Agentless Discovery (AWS Agentless Discovery Connector)
- VM 인벤토리, 설정, 성능 히스토리 (CPU / 메모리 / 디스크 사용량)
-
Agent-based Discovery (AWS Application Discovery Agent)
- 시스템 구성, 시스템 성능, 실행 프로세스, 시스템 간의 네트워크 연결 세부사항
-
결과 데이터는 AWS Migration Hub로 볼 수 있음
AWS Application Migration Service (MGN)
- AWS SMS(Server Migration Service)를 대체하는 ClodEndure Migration의 AWS 버전
- 애플리케이션을 AWS로 마이그레이션하는 작업을 간소화하는 Lift-and-Shift(rehost) 솔루션 서비스
- 물리적/가상/클라우드 서버들을 AWS에서 네이티브하게 실행되도록 변환해줌
- 넓은 범위의 플랫폼, OS, 데이터베이스를 지원
- 최소한의 downtime, 낮은 비용
Transferring large amount of data into AWS
- 사례:
- 클라우드로 200TB의 데이터를 전송하려고 함
- 현재 100Mbps의 인터넷 연결을 보유
- Over the internet / Site-to-Site VPN:
- 즉시 설정 가능
- 200(TB)*1000(GB)*1000(MB)*8(Mb)/100Mbps = 16,000,000s = 185일
- Over direct connect 1Gbps:
- 한번 설정에 많은 시간이 걸림 (1달 이상)
- 200(TB)*1000(GB)*8(GB)/1Gbps = 1,600,000s = 18.5일
- Over Snowball:
- 2 ~ 3개의 Snowball을 병렬적으로 받음
- E2E 전송에 약 1주일 소요 (제일 빠름)
- DMS와 함께 사용할 수 있음
- 지속적인(on-going) 복제 / 전송의 경우: Site-to-Site VPN 또는 DX 또는 DMS 또는 DataSync
VMware Cloud on AWS
- 일부 고객들은 VMware Cloud를 사용해서 본인들의 온-프레미스 데이터 센터를 관리하고 싶어함
- 데이터 센터 용량을 AWS로 확장하려고 하지만, 여전히 VMware Cloud 소프트웨어의 사용은 유지하고 싶은 경우?
- VMware Cloud on AWS를 사용하면 됨!
- 사례
- VMware vSphere 기반의 워크로드를 AWS로 마이그레이션하고 싶은 경우
- 프로덕션 워크로드를 VMware vSphere 기반의 private/public/hybrid 클라우드 환경으로 실행하고 싶은 경우
- 재해 복구 전략을 갖고자 하는 경우
More Solution Architectures
Event Processing in AWS
Lambda, SNS & SQS
- SQS + Lambda
- SQS에서 lambda로 poll을 시도하다가, 지속적으로 실패할 경우 무한 루프에 빠질 수 있음
- 이에 따라 DLQ(Dead Letter Queue)를 설정할 수 있음 (ex. 5번 retry 후 DLQ로 이동)
- SQS FIFO + Lambda
- SQS FIFO의 경우, 순서를 보장하기 때문에, 한번 실패할 경우, 그 다음의 메시지로 넘어가지 않고 블록된 상태가 되버림
- 이때도 마찬가지로 DLQ를 설정하여 다음 메시지로 넘어갈 수 있도록 대처할 수 있음
- SNS + Lambda
- SNS에서 Lambda로 비동기적으로 메시지를 전송하였으나 Lambda 함수에서의 처리가 이루어지지 않을 때
- 마찬가지로 몇번의 retry를 시도한 다음, 버리거나(discard), DLQ를 설정하여 SQS로 전달할 수 있음 (이 때는, Lambda 서비스 레벨에서 DLQ를 설정했다는 점에서 앞의 두개와는 차이가 있음)
Fan Out Pattern: deliver to multiple SQS
- 여러 개의 SQS를 둔 경우, 각각의 SQS에다 직접 Message를 PUT할 수도 있으나, 이 경우 아키덱처를 신뢰하기 어려워짐
- 왜? -> x번째 메시지를 처리하던 중 애플리케이션에 문제가 발생한다면, 그 이후의 메시지는 처리되지 않아 끝내 못받게되는 경우가 발생할 수 있음
- 이 때는 Fan Out 패턴을 쓰자!
- 하나의 SNS를 두고, 이를 여러 SQS가 구독하는 형태
S3 Event Notifications
S3:ObjectCreated
,S3:ObjectRemoved
,S3:ObjectRestore
,S3:Replication
...- SQS, SNS, Lambda와 연동하여 사용
- 오브젝트 명칭 필터링이 가능 (ex.
*.jpg
) - 사례: S3에 업로드된 이미지의 썸네일 생성
- 원하는 만큼 많이 "S3 이벤트"를 만들 수 있음
- S3 Event Notifications는 일반적으로 몇 초 안에 전달되지만, 경우에 따라서는 몇 분 이상 소요될 경우도 있음
S3 Event Notifications with Amazon EventBridge
- JSON Rule을 통한 고급 필터링 옵션 (메타데이터, 오브젝트 사이즈, 이름...)
- Multiple Destinations - ex. Step Functions, Kinesis Streams / Firehose...
- EventBridge Capabilities - Archive, Replay Events, Reliable delivery
Amazon EventBridge - Intercept API Calls
- DynamoDB -> CloudTrail -> EventBridge -> SNS
API Gateway - AWS Service Integration Kinesis Data Streams Example
- Client -> API Gateway -> Kinesis Data Streams -> Kinesis Data Firehose -> Amazon S3
Caching Strategies
Blocking an IP address
- 가장 쉬운 방법은 NACL(Network Access Control List)을 통해 VPC 레벨에서 원하는 IP 주소에 대해 Deny Rule을 설정 하는 것
- 여기에 EC2 내에 선택적으로 Firewall 소프트웨어를 사용할 수도 있음
- ALB의 경우:
- NACL + ALB 측에서 Security Group을 설정 -> Connection Termination
- NLB의 경우:
- Connection Termination이 없음 -> 곧바로 EC2 인스턴스로 넘어감
- ALB + WAF:
- WAF에서, 좀 더 비싸지만 복잡한 필터링을 구축할 수 있음
- WAF는 ALB에다 구축하는 것
- ALB + CloudFront WAF:
- CloudFront 쪽에서 WAF를 이용해 IP 주소를 먼저 필터링
- 이후, ALB로 넘기는데, 이때는 CloudFront의 Public IP를 통해 접속하는 것이라, 클라이언트의 IP를 알 수가 없어 NACL이 도움이 되지 않음
High Performance Computing (HPC)
- 클라우드는 HPC를 수행하기에 완벽한 곳임
- 언제든지 엄청 많은 수의 리소스들을 생성할 수 있음
- 더 많은 리소스들을 추가함으로써 결과물의 속도를 높일 수 있음
- 사용한 시스템 만큼만 값을 지불
- Perform genomics, computational chemistry, financial risk modeling, weather prediction, machine learning, deep learning, autonomous driving
- HPC를 수행하려면 어떤 서비스를 사용해야 할까?
Data Management & Transfer
- AWS Direct Connect
- private 보안 네트워크를 통해 클라우드에 GB/s 수준의 데이터를 이동
- Snowball & Snowmobile
- 클라우드에 PB 단위의 데이터 이동
- AWS DataSync
- 온-프레미스와 S3, EFS, FSx for Windows 간에 많은 양의 데이터를 이동
Compute and Networking
- EC2 Instances:
- CPU optimized, GPU optimized
- Spot Instances / Spot Fleets for cost savings + Auto Scaling
- EC2 Placement Groups: 더 좋은 네트워크 성능을 위한 클러스터
- EC2 Enhanced Networking (SR-IOV)
- 높은 대역폭, 높은 PPS (Packer Per Second), 낮은 레이턴시
- 옵션 1: Elastic Network Adapter (ENA) ~ 최대 100Gbps
- 옵션 2: Intel 82599 VF ~ 최대 10Gbps (레거시)
- Elastic Fabric Adapter (EFA)
- HPC 목적의 향상된 ENA, Linux에서만 동작
- 노드 간의 커뮤니케이션, 강하게 커플링된 워크로드에 유용함
- MPI(Message Passing Interface) 표준 활용
- 기본 Linux OS를 우회하여, 낮은 레이턴시와 안정적인 전송을 제공
Storage
- Instance-attached storage:
- EBS: io2 Block Express 사용 시 최대 256,000 IOPS까지 스케일 업
- Instance Store: million(백만) 단위 IOPS까지 스케일링, EC2 인스턴스에 연결, 낮은 레이턴시
- Network Storage:
- Amazon S3: large blob, 파일 시스템이 아님
- Amazon EFS: 전체 사이즈에 기반하여 IOPS가 스케일링, 또는 IOPS 프로비저닝
- Amazon FSx for Lustre:
- HPC 최적화된 분산형 파일 시스템, millions of IOPS
- Backed by S3
Automation and Orchestration
- AWS Batch
- AWS Batch는 멀티 노트 병렬 작업(여러 EC2 인스턴스에 걸쳐 단일 작업을 처리)을 지원
- 작업을 스케줄링하고, 이에 따른 EC2 인스턴스 실행을 쉽게 할 수 있음
- AWS ParallelCluster
- AWS에서 HPC를 배포하기 위한 오픈소스 클러스터 관리 툴
- 텍스트 파일로 설정
- VPC, 서브넷, 클러스터 타입 및 인스턴스 타입 생성을 자동화
- 클러스터에서 EFA를 활성화하는 기능 (네트워크 성능 향상)
High Availability
Creating a highly Available EC2 Instance
- Highly Available한 인스턴스를 직접 구성하려면
- Elastic IP
- 두 개의 EC2 인스턴스
- Public EC2 (기본)
- Standby EC2 (대기 용도)
- CloudWatch Event (인스턴스 상태 모니터링 ~ metric에 따라 알람 전달)
- Lambda (알람이 일어나면, 대기하던 인스턴스를 실행하고, Elastic IP를 할당)
Creating a highly available EC2 instance With an Auto Scaling Group
- ASG에서 스케일링 설정
- ex. 1min, 1max, 1desired, >= 2AZ
- Elastic IP를 (Tag에 기반하여) 새 인스턴스에 할당하는 EC2 user data를 세팅
- EC2 인스턴스 Role이 Elastic IP를 할당하는 API 호출을 허용하고 있어야 함
- 기존 인스턴스는 제거
Creating a highly available EC2 instance with ASG + EBS
- ASG에서 Terminate lifecycle hook으로 EC2 인스턴스가 종료되면서 EBS를 스냅샷 (+ 태깅)
- 이후 새 인스턴스를 실행하면서 (Tag에 기반한) EC2 user data로 EBS 볼륨을 생성하고 ASG Launch lifecycle hook에서 생성한 EBS 볼륨을 인스턴스에 연결
Other Services
CloudFormation
- 어떤 리소스들(거의 대부분 지원)에 대해 AWS 인프라를 개괄적으로(declarative) 설명하는 선언적인 방법
- 예를 들어, CloudFormation 템플릿에서는 다음과 같은 것을 정의할 수 있음
- Security group
- Two EC2 instances using this security group
- S3 Bucket
- Load Balancer in front of these machines
- 그러면 CloudFormation에서는 이들을 올바른 순서대로, 내가 정의한 설정과 똑같이 리소스들을 생성해줌
Benefits of AWS CloudFormation
- 코드로 인프라를 작성할 수 있음
- 어떤 리소스도 수동으로 생성할 필요 없음 -> 관리하기 편함
- 인프라에 대한 변경사항이 코드를 통해 리뷰될 수 있음
- 비용
- 스택에 포함된 각각의 리소스들은 별도의 식별자로 태그되며, 각 스택의 비용이 어떻게 되는지 쉽게 파악할 수 있음
- CloudFormation 템플릿으로 리소스들의 비용을 추정할 수 있음
- Savings Strategy:
- ex. Dev 환경에서, 5PM에 템플릿을 자동 삭제 / 8AM에 재생성
- 생산성
- 클라우드에서 즉시 사용 가능한 인프라를 생성 / 삭제 가능
- 템플릿에 대한 다이어그램 자동 생성
- 선언적 프로그래밍 (순서 / 오케스트레이션을 파악할 필요 없음)
- Don't re-invent the wheel
- 웹에 존재하는 템플릿을 활용
- document를 활용
- (거의) 모든 AWS 리소스들을 지원:
- (적어도 여기에 정리했던) 모든 리소스들을 지원함
- 지원되지 않는 리소스들을 위해서 "커스텀 리소스"를 사용할 수 있음
CloudFormation Stack Designer
- 예시: WordPress CloudFormation Stack
- 모든 리소스들을 볼 수 있음
- 각 컴포넌트 간 관계를 파악할 수 있음
Amazon SES (Simple Email Service)
- 규모에 따라 이메일을 안전하게, 글로벌로 보낼 수 있는 완전 관리형 서비스
- 인바운드/아웃바운드 이메일 허용
- 평판 대시보드, 성능 인사이트, 안티-스팸 피드백
- 이메일 전송, 이메일 바운스, 피드백 루프 결과, 이메일 확인 여부 등의 통계 제공
- DKIM(DomainKeys Identified Mail)과 SPF(Sender Policy Framework) 지원
- Flexible IP 배포: shared / dedicated / customer-owned IP
- AWS 콘솔/API 또는 SMTP를 이용해 애플리케이션에서 메일을 전송할 수 있음
- 사례: 트랜잭션, 마케팅 및 대량 이메일 커뮤니케이션
Amazon Pinpoint
- 확장 가능한(scalable) 2-way (아웃바운드/인바운드) 마케팅 커뮤니케이션 서비스
- 이메일, SMS, 푸시, 보이스, 인앱 마케팅 지원
- 고객들에게 올바른 컨텐츠를 제공하기 위해 메시지를 세분화 및 개인화할 수 있음
- 응답 받기 가능
- 매일 최대 10억(billion)개의 메시지까지 스케일링 가능
- 사례: 마케팅/대량/거래 SMS 메시지 전송을 통한 캠페인 진행
- Amazon SNS나 Amazon SES와의 차이점
- SNS & SES에서는 각 메시지의 수신자, 컨텐츠, 전달 스케줄을 관리
- Amazon Pinpoint에서는 메시지 템플릿, 전달 스케줄, 고도로 타겟팅된(highly-targeted) 세그먼트 및 전체 캠페인을 생성할 수 있음
System Manager - SSM Session Manager
-
내 EC2와 온-프레미스 서버에 대한 Secure Shell 연결을 할 수 있도록 해줌
-
SSH 액세스나 Bastion Host, SSH Key 필요 없음
-
22번 포트 필요 없음 (높은 보안)
-
Linux, macOS, Windows 지원
-
S3나 CloudWatch Logs에 세션 로그 데이터를 전송할 수 있음
System Manager - Run Command
- document(= script)를 실행하거나 그냥 커맨드를 실행할 수 있음
- 여러 인스턴스 간에 커맨드를 실행 (리소스 그룹 사용 시)
- SSH 필요 없음
- AWS 콘솔을 통해 커맨드 결과를 볼 수 있고, S3 버킷 또는 CloudWatch Log로 전송
- 커맨드의 상태(실행 중, 완료, 실패)에 대해 SNS에 알림으로 보낼 수 있음
- IAM & CloudTrail과 호환됨
- EventBridge를 통해서도 실행될 수 있음
System Manager - Patch Manager
- 관리 중인 인스턴스에 대한 패치(patch) 과정을 자동화
- OS 업데이트, 애플리케이션 업데이트, 보안 업데이트 등
- EC2 인스턴스 및 온-프레미스 서버 지원
- Linux, macOS, Windows 지원
- 온-디맨드로 패치하거나, Maintenance Windows를 통해 패치를 스케줄링
- 인스턴스 스캔하여 패치 compliance 리포트를 생성 (missing patches)
System Manager - Maintenance Windows
- 내 인스턴스에 액션을 처리할 시점에 대한 스케줄을 정의
- ex. OS 패칭, 드라이버 업데이트, 소프트웨어 설치 등..
- Maintenance Window는 다음을 포함
- Schedule
- Duration
- Set of registered instances
- Set of registered tasks
System Manager - Automation
- EC2 인스턴스와 다른 AWS 리소스들의 일반적인 유지보수와 배포 작업을 단순화시켜줌
- ex. 인스턴스 재시작, AMI 생성, EBS 스냅샷
- Automation Runbook - 내 EC2 인스턴스나 AWS 리소스에 수행될 액션을 정의하는 SSM Document (pre-defined 또는 custom)
- 다음과 같은 것들을 통해 트리거될 수 있음
- AWS 콘솔/CLI/SDK로 수동 트리거
- Amazon EventBridge
- Maintenance Window로 스케줄링
- AWS Config (Rule을 수정하고자 할 때)
Cost Explorer
- AWS 비용과 사용 시간에 대한 시각화 / 이해 / 관리
- 비용 및 사용 데이터에 대해 분석하는 커스텀 리포트를 생성할 수 있음
- 고수준(high-level)에서 데이터를 분석: 전체 계정에서의 전체 비용 및 사용
- 월/시간/리소스 단위로 세분화
- 최적의 Savings Plan 선택 (요금 청구 가격을 더 낮추고자 할 때)
- 이전 사용량을 기준으로 최대 12개월까지의 사용량 예측
Amazon Elastic Transcoder
- Elastic Transcoder는 S3에 저장된 미디어 파일들을 소비자들의 재생 디바이스(ex. 휴대폰)에서 요구하는 포맷으로 변환해줌
- 이점:
- 사용하기 쉬움
- Highly scalable - 많은 양의 미디어 파일 & 큰 사이즈의 파일을 다룰 수 있음
- 비용 효율적 - duration 기반의 비용 모델
- 완전 관리형 & 안전함, 쓴만큼 비용 지불 (온-디맨드)
AWS Batch
- 규모에 관계 없는 완전 관리형 배치 프로세싱
- AWS에서 100,000개까지의 batch job들을 효율적으로 실행
- batch job이란?
- 시작과 끝이 있는 작업 (continous와 반대됨)
- Batch는 EC2 인스턴스 또는 Spot Instances를 동적으로 실행함
- AWS Batch는 적절한 양의 컴퓨팅과 메모리를 프로비저닝함
- batch job을 제출 또는 스케줄링하기만 하면, 나머지는 AWS Batch가 알아서 처리함
- Batch job은 Docker images로 정의되고, ECS에서 실행
- 비용 최적화 & 인프라에 신경을 덜 써도 되도록 도와줌
Batch vs Lambda
- Lambda:
- 시간 제한 있음
- 제한된 런타임
- 제한된 임시 디스크 공간
- 서버리스
- Batch:
- 시간 제한 없음
- Docker image로 패키징되기만 하면 어떤 런타임이든 가능
- 디스크 공간의 경우 EBS / instance store에 의존
- EC2에 의존 (AWS에 의해 관리될 수 있음)
Amazon AppFlow
- Software-as-a-Service (SaaS) 애플리케이션과 AWS 사이의 안전한 데이터 전송을 할 수 있게 해주는 완전 관리형 통합 서비스
- Sources: Salesforce, SAP, Zendesk, Slack, ServiceNow
- Destinations: Amazon S3, Amazon Redshift와 같은 AWS 서비스 또는 SnowFlak, Salesforce 같은 non-AWS 서비스
- Frequency:
- 스케줄을 만들거나
- 이벤트에 대한 응답으로 처리되거나
- 온-디맨드로 직접 수행
- Data transformation: 필터링 또는 validation과 같은 기능
- public 인터넷 상으로 암호화 또는 AWS PrivateLink를 통해 private하게 암호화
- integration 작성에 시간을 소요하지 않고, 바로 API를 활용
WhitePapers and Architectures
Well Architectured Framework General Guiding Principles
- https://aws.amazon.com/architecture/well-architected
- 얼마나 capacity가 필요할지 예측하지 마라 (대신 오토 스케일링을 사용하라)
- 프로덕션 스케일에서도 시스템을 테스트 하라
- 자동화를 통해 architectural experimentation을 더 쉽게 하라
- 진화하는(evolutionary) 아키텍처를 허용하라
- 변화하는 요구사항에 맞추어 디자인
- 데이터를 활용하여 아키텍처를 드라이빙하라
- Game Day를 통해 향상
- ex. 플레이 세일 기간 동안의 애플리케이션 시뮬레이션
Well Architected Framework 6 Pillars
- Operational Excellence - 운영 우수성
- Security - 보안
- Reliability - 신뢰성
- Performance Efficiency - 성능 효율성
- Cost Optimization - 비용 최적화
- Sustainability - 지속 가능성
- 위의 원칙들은 밸런스를 맞추거나 트레이드-오프의 관계가 아니라, 시너지를 이룸
AWS Well-Architectured Tool
- Well-Architected Framework의 6 원칙에 기반하여 내 아키텍처를 리뷰하고, 베스트 프랙티스를 채택하기 위한 무료 툴
- 어떻게 이루어지는가?
- 내 워크로드를 선택하고 질문에 답변
- 6 원칙에 기반하여 답변들을 리뷰
- Obtain advice:
- 문서 또는 비디오
- 리포트 생성
- 대시보드 내에서 결과 확인
- https://console.aws.amazon.com/wellarchitected
Trusted Advisor
- 고수준의 AWS 계정 평가 - 따로 뭘 설치할 필요 없음
- AWS 계정을 분석하고, 다음의 5개 카테고리에 따라 추천을 제안
- Cost Optimization
- Performance
- Security
- Fault tolerance
- Service limits
Trusted Advisor - Support Plans
- 7 CORE CHECKS (Basic & Developer Support plan)
- S3 버킷 권한
- Security groups - 제한이 걸려있지 않은 특정 포트
- IAM 사용 (최소 하나의 IAM 이용자)
- 루트 계정에 MFA 적용
- EBS Public Snapshots
- RDS Public Snapshots
- Service Limits
- FULL CHECKS (Business & Enterprise Support plan)
- 5개 카테고리에 대한 완전한 체크를 수행
- 문제가 발견(limit reached)되었을 때 CloudWatch alarm을 보내도록 설정할 수 있음
- AWS Support API를 통해 프로그래밍적인 방식으로 액세스 가능
More Architecture Examples
- 지금껏 제일 중요한 아키텍처 패턴들을 살펴봤음:
- Classic: EC2, ELB, RDS, ElastiCache, etc...
- Serverless: S3, Lambda, DynamoDB, CloudFront, API Gateway, etc...
- 더 많은 아키텍처에 대해 살펴보고 싶다면
OSI Model
**OSI(Open Systems Interconnection)**은 다양한 통신 시스템이 표준 프로토콜을 사용하여 통신할 수 있도록 하는 국제 표준화 기구에 의해 만들어진 개념적인 모델(conceptual model)이다.
OSI 모델은 컴퓨터 네트워킹에 있어 공용 언어로 볼 수 있으며, 이는 커뮤니케이션 체계를 7개의 추상화된 레이어로 쪼개서 보는 개념에서 출발한다.
왜 OSI 모델이 중요할까?
현대의 인터넷은 사실 엄격하게 OSI 모델을 따르지 않음에도 불구하고, OSI 모델은 여전히 네트워크 문제에 대한 트러블슈팅에 매우 용이하다.
노트북에서 인터넷 연결이 안되는 문제, 수천명의 이용자가 사용하는 웹사이트가 다운되는 문제 등등, 이러한 네트워크와 관련된 문제를 쪼개서 문제의 원인을 정확히 파악하는 데에 OSI 모델이 도움을 줄 수 있다. 만약, 어떤 문제가 모델 내 하나의 구체적인 레이어에 국한된 점이라는 것을 파악한다면, 그 외의 불필요한 작업들은 피할 수 있다.
7. The application layer
이용자와 직접적으로 데이터를 통해 상호작용하는 유일한 레이어다. 웹 브라우저나 이메일 클라이언트와 같은 소프트웨어 애플리케이션들이 이 애플리케이션 레이어에 의존하여 통신을 시작한다.
단, 클라이언트 소프트웨어 애플리케이션이 애플리케이션 레이어의 일부에 해당하는 것은 아니라는 점을 기억하자. 오히려 애플리케이션 레이어는 소프트웨어가 사용자에게 의미있는 데이터를 전달하기 위해 의존하는 프로토콜과 데이터 조작에 대한 책임을 갖는다.
애플리케이션 레이어 프로토콜은 HTTP 뿐만 아니라 SMTP(Simple Mail Transfer Protocol ~ 이메일 커뮤니케이션을 가능하게 하는 프로토콜 중 하나)를 포함한다.
6. The Presentation Layer
애플리케이션 레이어에서 사용될 데이터를 준비하는 역할을 하는 레이어다. 즉, 프레젠테이션 레이어는 애플리케이션이 소비할 수 있는 데이터를 제공한다. 해당 레이어는 변환(translation), 암호화(encryption), 데이터 압축(compression of data)를 담당한다.
translation - 디바이스마다 다른 인코딩 방식으로 인코딩된 데이터를 변환
encryption - 암호화된 연결을 통해 통신하는 경우, 암호화/복호화를 담당
compression - 애플리케이션 레이어를 통해 넘겨받은 데이터를 아래 층 레이어로 전달하기 전에 압축
5. The Session Layer
두 디바이스 간의 통신(communication)을 열고 닫는 역할을 하는 레이어다. "세션"은 통신이 열리고 닫히는 시간을 의미한다. 세션 레이어는 세션이 교환되는 모든 데이터를 전송할 수 있을 정도로 세션이 열려있는지 확인하고, 리소스 낭비를 방지하기 위해 세션을 빠르게 닫는다.
또한, 데이터 전송을 체크 포인트를 통해 동기화해주는 역할도 수행한다.
4. The Transport Layer
두 디바이스 간의 E2E 통신을 담당하는 레이어다. 세션 레이어로부터 데이터를 넘겨 받아 이것을 세그먼트(segment)라고 하는 덩어리들로 쪼갠 다음 아래 층의 레이어로 전송한다. (Segmentation)
넘겨받는 디바이스 쪽의 해당 레이어에서는 이렇게 세그먼트로 쪼개진 데이터를 다시 소비가 가능한 형태로 재조립(reassembly)하는 역할을 수행한다.
트랜스포트 레이어는 Flow Control과 Error Control의 역할도 수행한다.
Flow Control - 빠른 연결을 가진 송신자가 느린 연결을 가진 수신자를 압도하지 않도록 하기 위한 최적의 전송 속도를 결정하는 것
Error Control - 데이터 수신이 완료되었는지에 대해 확인하고, 그렇지 못한 경우 재전송을 요청
3. The Network Layer
두 개의 다른 네트워크 간의 데이터 전송을 용이하게 하는 역할을 하는 레이어다. 만약 두 개의 디바이스가 동일한 네트워크 상에서 통신한다면, 네트워크 레이어는 필요없다.
네트워크 레이어는 트랜스포트 레이어로부터 넘겨 받은 세그먼트를 패킷(packet)이라고 하는 더 작은 단위로 쪼갠다. 수신하는 측에서는 거꾸로 이러한 패킷을 재조립해준다.
네트워크 레이어는 또한 데이터가 대상에 도달하기 위한 최적의 물리적 경로를 찾는 역할도 수행하는데, 이를 라우팅(routing)이라고 한다.
2. The Data Link Layer
데이터 링크 레이어는 네트워크 레이어랑 매우 유사하다. 동일한 네트워크의 두 디바이스 간 데이터 전송을 용이하게 한다는 점만 빼면.
데이터 링크 레이어는 네트워크 레이어로부터 패킷을 받아 이것을 프레임(frame)이라고 불리는 더 작은 조각들로 쪼갠다.
데이터 링크 레이어도 인트라 네트워크(intra-network) 통신 내에서의 flow control과 error control의 처리를 담당한다.
1. The Physical Layer
케이블, 스위치와 같은 데이터 전송과 관련된 물리적 장비가 포함되는 레이어다.
데이터가 비트스트림(bit stream ~ 1과 0의 스트링)으로 변환되는 레이어로, 두 디바이스 간 피지컬 레이어는 신호 규칙(signal convention)이 일치해야 양 디바이스 에서의 1과 0을 구별할 수 있게 된다.
Refactoring
리팩터링이란 코드를 개선하는 체계적인 프로세스를 의미한다.
새로운 기능을 만들지 않고도 깔끔한 코드와 단순한 디자인으로 변환할 수 있는 체계적인 프로세스를 의미한다.
해당 섹션은 리팩터링 구루 사이트의 내용을 따라가본다.
리팩터링이란?
리팩터링의 주된 목적은 기술 부채를 없애는 것이다. 복잡한 코드를 클린 코드와 단순한 디자인으로 바꿔주기 위함이다.
클린 코드
그래서, 클린 코드란 무엇일까? 이는 다음의 특징을 갖고 있는 코드다.
클린 코드는 다른 개발자에게도 분명하게 코드를 설명할 수 있다.
정교한 알고리즘에 대해 이야기 하는 것이 아니라, 잘못된 변수 명칭, 부풀려진 클래스 및 메서드, 매직넘버 등과 같은 것들은 코드를 엉성하고 이해하기 어렵게 만든다.
클린 코드는 중복을 포함하지 않는다.
코드에 중복이 있는 경우, 이 중복 코드를 변경해야 할 때마다, 모든 인스턴스에 동일한 변경 사항을 적용해야 한다는 점을 기억해야 한다. 이는 인지 부하(cognitive load)를 증가시키고 작업의 속도를 늦춘다.
클린 코드는 클래스 및 기타 동작하는 부분(moving part)들을 최소한의 수로 유지한다.
적은 코드는 머릿 속에 저장해야 할 내용 자체가 적다. 적은 코드는 유지 관리가 쉽고, 버그도 줄어든다. 코드는 책임이므로, 짧고 단순하게 작성되어야 한다.
클린 코드는 모든 테스트를 통과한다.
테스트의 95%만 통과하는 경우, 코드가 더럽다는 것을 알 수 있다. 테스트 커버리지가 0%라면, 문제가 있다는 것을 알 수 있다.
기술적 부채 (Technical debt)
누구든 처음부터 훌륭한 코드를 작성하기 위해 최선을 다한다. 애초에 프로젝트에 해를 끼칠 목적으로 의도적으로 언클린(unclean) 코드를 작성하는 프로그래머는 아마 없을 것이다. 하지만, 언제부터 클린 코드가 언클린 코드로 변해가는 것일까?
불결한 코드와 관련하여 기술적 부채라는 은유는 본래 워드 커닝햄이 제안한 것이다.
은행에서 대출을 받으면, 빠르게 물건을 구매할 수 있지만, 이후 원금 뿐 아니라 대출에 대한 추가 이자도 갚아야 한다. 말할 것도 없이, 이러한 이자가 너무 많이 쌓이게 되면, 그 금액이 총 수입을 초과하여 전액 상환 자체가 어려워질 수 있다.
이는 코드에서도 동일하다. 새로운 기능에 대한 테스트를 작성하지 않고도 이를 진행하여 일시적으로 속도를 높일 수는 있으나, 결국에는 테스트를 작성하여 기술적 부채를 청산하지 않는 한, 매일 조금씩 진행 속도가 더뎌지게 된다.
기술적 부채의 원인
비즈니스 압박
때로는 비즈니스적인 상황으로 인하여 기능이 완전히 완성도 되기 전에 출시해야 할 수 있다. 이 경우, 프로젝트의 미완성 부분을 숨기기 위해 코드에 패치와 편법(kludge)이 나타나게 된다.
기술 부채의 결과에 대한 이해 부족
때때로 고용주들은 이러한 기술적 부채의 늘어나는 "이자"로 인해 개발 속도가 저하된다는 사실을 이해하지 못한다. 경영진들이 리팩터링의 가치를 인식하지 못하면, 팀이 리팩터링에 시간을 할애하기가 상당히 어려워질 수 있다.
컴포넌트의 엄격한 일관성에 대처하지 못함
이는 프로젝트가 개별적인 모듈이라기보다는, 하나의 모놀리스와 같은 형태를 띠는 경우 발생한다. 이 경우, 프로젝트의 한 부분을 변경하면 다른 부분에도 영향을 끼치게 된다. 이 경우 개별 구성원의 작업을 분리하기 어렵기 때문에 팀단위 개발이 더욱 어려워진다.
테스트 부족
즉각적인 피드백의 부족은 위험한 해결책(workaround)나, 편법(kludge)을 사용하게 된다. 최악의 경우, 이러한 변경 사항은 사전 테스트 없이 바로 프로덕션에 구현 및 배포되고, 그 결과는 치명적일 수 있다. 이를테면, 아무 문제가 없어보이는 핫픽스가 수천명의 고객에게 이상한 테스트 메일을 전송하거나, 심각하게는 전체 데이터베이스를 플러시(flush)하거나 손상시킬 수 있다.
문서화 부족
이로 인해 프로젝트에 새 인력이 유입되는 속도가 느려지고, 핵심 인력이 프로젝트를 떠날 경우 개발이 중단될 수 있다.
팀원 간의 소통 부족
지식이 회사 전체에 배포되지 않으면, 사람들은 프로젝트에 대한 프로세스 및 정보를 구식으로 이해한 상태로 작업에 들어가게 된다. 이러한 상호아은 주니어 개발자가 멘토로부터 잘못된 교육을 받으면 더더욱 악화될 수 있다.
여러 브랜치에서부터의 장기간 동시적인 개발
이로 인해 기술 부채가 누적될 수 있으며, 변경 사항이 머지될 때 부채는 더욱 증가한다. 개별적으로 변경된 사항이 많을수록 총 기술 부채는 더욱 커진다.
지연된 리팩터링
프로젝트의 요구 사항은 지속적으로 변화하고 있으며, 어느 시점에서 코드 일부가 더 이상 사용되지 않거나 번거로워져 새로 설계되어야 할 수 있다.
한편, 프로젝트의 프로그래머는 더 이상 사용되지 않는 부분과 함께 작동하는 새로운 코드를 매일 작성하게 된다. 따라서 리팩터링이 지연되는 경우, 더 많은 종속 코드를 재작업해야 한다.
규정준수 모니터링 부족
이는 모든 프로젝트 참여 인원이 적합해보인다고 생각되는 대로 코드를 작성할 때 발생한다. (ex. 지난 프로젝트에서 작성했던 것과 동일한 방식으로 작성)
무능력
개발자가 제대로 된 코드를 작성하는 방법을 모르는 경우이다.
언제 리팩터링되어야 하는가?
리팩터링의 3원칙
- 처음으로 무엇인가를 해야할 때는, 그냥 되도록 만든다.
- 비슷한 일을 두번째로 할 때는, 반복해야 한다는 사실에 움찔하겠지만, 어쨌든 똑같이 처리한다.
- 비슷한 일을 세번째로 해야할 때는, 리팩터링을 시작한다.
기능을 추가해야 할 때
- 리팩터링은 다른 사람의 코드를 이해하는 데에 도움이 된다. 다른 사람의 지저분한 코드를 다루어야 한다면, 먼저 리팩터링을 시도해보자. 클린 코드는 훨씬 이해하기 쉽다. 내 자신을 위해서뿐만 아니라, 이후 코드를 사용하는 사람들을 위해서도 코드를 개선할 수 있다.
- 리팩터링을 하면 새로운 기능을 더 쉽게 추가할 수 있다. 클린 코드에서 변경 사항을 추가하는 것이 훨씬 쉽기 때문이다.
버그를 고쳐야 할 때
- 코드 내 버그(bug)는 마치 실생활의 벌레와 마찬가지로 가장 어둡고 더러운 곳에 존재한다. 코드를 리팩터링하다보면 오류는 거의 스스로 발견된다.
- 관리자는 사전 리팩터링 덕분에 나중에 특별한 리팩터링 작업을 할 필요가 없으므로 이를 높게 평가한다.
코드 리뷰 도중
- 코드 리뷰는 코드에 퍼블릭 상태에 놓이기 전에 코드를 정리할 수 있는 마지막 기회가 될 수 있다.
- 이러한 리뷰는 코드 작성자와 함께 수행하는 것이 좋다. 이를 통해 간단한 문제는 빠르게 해결하고, 더 어려운 문제를 수정하는데에 걸리는 시간을 측정할 수 있다.
리팩터링을 어떻게 해야 하는가?
리팩터링은 일련의 작은 변경을 통해 이루어져야 하며, 각 변경은 프로그램이 작동하는 상태는 여전히 유지하면서 기존 코드를 약간 개선하는 방식으로 이루어져야 한다.
올바른 리팩터링을 위한 체크리스트
리팩터링한 코드는 더 깔끔해져야 한다
만약, 리팩터링을 진행한 이후에도 코드가 지저분하다면, 시간 낭비를 한 것이다.
위와 같은 일은, 작은 변경 사항으로 리팩터링하는 것이 아니라, 여러 개의 리팩터링을 하나의 큰 변경사항으로 혼합하려고 할 때 자주 발생한다. 특히, 시간 제한이 있는 경우에는 더더욱 정신을 잃기 쉽다.
하지만, 애초에 매우 엉성한 코드를 리팩터링할 때도 이런 일이 발생할 수 있다. 이 때는 무엇을 개선하든 간에, 코드 전체가 여전히 엉망인 상태가 된다.
이 경우, 코드 일부를 완전히 다시 작성하는 방법을 생각해보는 것이 좋다. 하지만 그 전에 테스트 작성과 함께 충분한 시간을 할애하는 것이 좋다. 그렇지 않다면, 앞선 첫번째 단락에서 말한 것과 동일한 결과가 발생할 가능성이 크다.
리팩터링 이후 새로운 기능이 추가되어선 안된다
리팩터링과 함께 새로운 기능의 개발을 함께 진행하지 마라. 적어도, 각각의 커밋으로 이들 프로세스를 분리하라.
모든 기존의 테스트는 리팩터링 후 여전히 통과해야 한다
리팩터링 이후 테스트가 깨질 수 있는 두 경우가 존재한다.
- 리팩터링 도중 에러가 생긴 경우 - 이 경우는, 말그대로 에러를 해결하면 된다.
- 테스트가 너무 로우-레벨인 경우 - ex.) 클래스의 프라이빗 메서드를 테스팅하고 있는 경우
- 이 경우는 테스트 자체에 책임이 있다고 볼 수 있다. 이런 상황에서는 테스트 자체를 리팩터링하거나, 더 높은 레벨의 새로운 테스트를 작성할 수 있다. 이런 상황을 피하는 가장 좋은 방법은 BDD 스타일의 테스트를 작성하는 것이다.
Code Smells
코드 스멜(code smell)이란, 프로그래밍 코드에서 더 심오한 문제를 일으킬 가능성이 있는 프로그램 소스 코드의 특징을 말한다.
Bloaters
블로터(Bloater)는 작업하기 어려울 정도로 엄청난 비율로 늘어난 코드, 메서드 및 클래스를 의미한다. 일반적으로 이러한 류의 냄새는 성능 저하를 초래한다. 일반적으로 이러한 냄새는 곧바로 나타나지 않고, 프로그램이 커지면서 시간에 따라 누적되는 경향이 크다. 특히, 아무도 이를 없애려고 하지 않을 때, 문제는 더 심해진다.
긴 메서드 (Long Method)
하나의 메서드에 너무 많은 코드 라인이 존재하는 경우. 일반적으로 어떤 메서드건 10줄이 넘어간다면 의심을 해보아야 한다.
거대한 클래스 (Large Class)
하나의 클래스가 너무 많은 필드/메서드/코드 라인을 보유한 경우.
Primitive 집착 (Primitive Obsession)
- 간단한 작업(ex. 통화, 범위, 전화번호의 특수문자열 등)에 있어 작은 객체를 사용하는 대신 primitive를 사용하는 경우
- 정보 코딩에 상수를 사용(ex. 관리자 권한이 있는 사용자를 참조하는 데 있어
USER_ADMIN_ROLE = 1
과 같이 사용)하는 경우- 데이터 배열에 사용할 필드 명칭으로 string 상수를 사용하는 경우
긴 매개변수 목록 (Long Parameter List)
하나의 메서드에 3 ~ 4개 이상의 파라미터를 사용하는 경우
데이터 덩어리 (Data Clumps)
때로는 코드의 다른 부분에 동일한 변수 그룹(ex. 데이터베이스에 연결하는데 필요한 파라미터들)이 포함되어 있을 수 있다. 이러한 경우, 이러한 변수 덩어리들은 자체 클래스로 전환되어야 한다.
Object-Orientation Abusers
이러한 류의 코드 스멜은 객체 지향 프로그래밍(OOP)의 불완전하거나 잘못된 적용에 대한 것이다.
스위치 문(Switch Statements)
복잡한 switch 문 또는 if 문 시퀀스가 존재하는 경우
임시 필드(Temporary Field)
임시 필드는 오직 특정한 상황에서만 값을 가져오고, 그 외의 경우에는 항상 비어있다.
거부된 유증(Refused Bequest)
만약 하나의 서브클래스가 부모로부터 상속받은 일부 메서드와 프로퍼티만을 사용한다면, 계층 구조가 엉망이 된다. 불필요한 메서드는 단순히 사용되지 않거나, 재정의됨에 따라 예외를 발생시킬 수 있다.
인터페이스가 다른 대체 클래스 (Alternative Classes with Different Interfaces)
두 클래스가 동일한 기능을 수행하지만, 메서드 이름만 다른 경우
Change Preventers
이러한 종류의 냄새는 한 곳에서 어떤 변경이 일어날 경우 다른 곳에서도 많은 변경을 해야한다는 것을 의미한다. 결과적으로 프로그램 개발이 훨씬 더 복잡해지고 비용이 많이 들게 된다.
일치하지 않는 변경 (Divergent Change)
클래스에 변경이 생길 때 관련없는 여러 메서드들에도 변경이 요구되는 경우가 있을 수 있다. 예를 들어, 새로운 프로덕트 타입을 추가하게 되면 제품을 찾고/표시하고/주문하는 메서드에도 변경이 요구된다.
샷건 수술 (Shotgun Surgery)
어떤 하나의 변경이 여러 다른 클래스에 여러 작은 변경들을 요구하는 경우.
병렬 상속 구조 (Parellel Inheritance Hierarchies)
클래스의 서브클래스를 만들 때마다, 다른 클래스에 대해서도 서브클래스를 만들어야 하는 경우
Dispensables
Dispensable은 무의미하고 불필요한 것으로, 이를 제거하면 코드가 더 깔끔하고 효율적이며, 이해하기 쉬워진다.
주석 (Comments)
메서드에 각종 설명이 담긴 주석으로 가득한 경우
중복 코드 (Duplicate Code)
두 코드 조각이 거의 동일하게 보이는 경우
레이지 클래스 (Lazy Class)
클래스에 대해 이해하고 유지보수하는 것은 항상 시간과 돈을 소모한다. 그러므로, 클래스가 더 이상 불필요하다고 판단하면, 제거해야 한다.
데이터 클래스 (Data Class)
데이터 클래스는 오직 필드와, 필드에 액세스하기 위한 조잡한 메서드(Get/Set)만이 존재하는 클래스를 의미한다. 이는 다른 클래스에서 사용하는 데이터를 담기 위한 컨테이너로 쓰일 뿐이며, 이러한 클래스들은 추가적인 기능을 포함하지 않고, 소유한 데이터에 대해 독립적으로 작업할 수 없다.
죽은 코드 (Dead Code)
(기존에는 사용되었던) 어떤 변수, 파라미터, 필드, 메서드 또는 클래스가 변경 이후에 더 이상 사용되지 않는 경우
추론적 일반성 (Speculative Generality)
(미래를 위해서) 만약을 대비해 생성했으나, 사용하지 않는 클래스, 메서드, 필드 또는 매개변수가 존재하는 경우
Couplers
해당 범주의 냄새들은 모두 클래스 간의 과한 커플링에 기여하거나, 커플링이 과도한 위임으로 대체될 때 어떤 일들이 발생하는지 보여준다.
기능 질투 (Feature Envy)
어떤 메서드가 본인의 데이터보다도 다른 객체의 데이터에 더 액세스하는 경우
부적절한 친밀감 (Inappropriate Intimacy)
어떤 클래스가 다른 클래스의 내부 필드와 메서드를 사용하는 경우
메시지 체인 (Message Chains)
코드에서
a -> b() -> c() -> d()
와 같은 형태의 일련의 호출이 보이는 경우
미들 맨 (Middle Man)
하나의 클래스가 단 하나의 작업만 수행하고, 그 외의 모든 작업은 다른 클래스에 위임하고 있는 경우
Other Smells
이들은 별도의 카테고리에 속하지 않는 냄새이다.
불완전한 라이브러리 클래스 (Imcomplete Library Class)
조만간 라이브러리가 사용자의 요구를 충족할 수 없게 되었고, 이를 위해 라이브러리를 수정해야만 하지만, (대부분) 읽기 전용이라 그것이 불가능한 경우
Refactoring Techniques
메서드 구성하기 (Composing Methods)
객체 간 기능 이동하기 (Moving Features between Objects)
데이터 체계화하기 (Organizing Data)
조건식 단순화하기 (Simplifying Conditional Expressions)
메서드 호출 단순화하기 (Simplifying Method Calls)
일반화 다루기 (Dealing with Generalization)
Composing Methods
- Composing Methods
- 메서드 추출하기 (Extract Method)
- 인라인 메서드 (Inline Method)
- 변수 추출 (Extract Variable)
- 인라인 임시변수 (Inline Temp)
- 임시변수를 쿼리로 전환 (Replace Temp with Query)
- 임시변수 쪼개기 (Split Temporary Variable)
- 매개변수에 대한 할당 제거하기 (Remove Assignments to Parameters)
- 메서드를 메서드 객체로 바꾸기 (Replace Method with Method Object)
- 알고리즘 대체하기 (Substitute Algorithm)
리팩터링의 대부분은 메서드를 올바르게 구성하는 데에 할애하게 된다. 대부분의 경우 지나치게 긴 메서드는 만악의 근원이다. 이러한 메서드 내부의 모호한 코드는 실행 로직을 숨기고, 메서드를 이해하기 매우 어렵게 만들며 변경하기는 더욱 어렵게 만든다.
이 그룹의 리팩터링 기법은 메서드를 간소화하고 코드 중복을 제거하며, 향후 개선을 위한 기반을 마련한다.
메서드 추출하기 (Extract Method)
하나로 그룹화될 수 있는 코드조각을 별도의 메서드로 추출해낸다. 이는 다른 여러 리팩터링의 기초가 되기도 한다.
- Before
printOwing(): void {
printBanner();
// Print details.
console.log("name: " + name);
console.log("amount: " + getOutstanding());
}
- After
printOwing(): void {
printBanner();
printDetails(getOutstanding());
}
printDetails(outstanding: number): void {
console.log("name: " + name);
console.log("amount: " + outstanding);
}
인라인 메서드 (Inline Method)
메서드의 코드 내용 자체가 메서드의 이름보다 오히려 더 명확한 경우는 차라리 해당 메서드를 없애는 쪽이 더 분명하다.
- Before
class PizzaDelivery {
getRating(): number {
return moreThanFiveLateDeliveries() ? 2 : 1;
}
moreThanFiveLateDeliveries(): boolean {
return numberOfLateDeliveries > 5;
}
}
- After
class PizzaDelivery {
// ...
getRating(): number {
return numberOfLateDeliveries > 5 ? 2 : 1;
}
}
변수 추출 (Extract Variable)
이해하기에 어려운 표현식(expression)이 있을때는, 해당 표현식의 결과 또는 그 일부를 별도의 변수로 추출하여 이를 잘 설명하는 적절한 이름을 붙이자.
- Before
renderBanner(): void {
if ((platform.toUpperCase().indexOf("MAC") > -1) &&
(browser.toUpperCase().indexOf("IE") > -1) &&
wasInitialized() && resize > 0 )
{
// do something
}
}
- After
renderBanner(): void {
const isMacOs = platform.toUpperCase().indexOf("MAC") > -1;
const isIE = browser.toUpperCase().indexOf("IE") > -1;
const wasResized = resize > 0;
if (isMacOs && isIE && wasInitialized() && wasResized) {
// do something
}
}
인라인 임시변수 (Inline Temp)
오직 간단한 표현식의 결과를 할당하기만 할 뿐, 그 외에 아무 일도 하지 않는 임시변수가 있을 때는, 해당 임시변수를 없애고 인라인으로 대체한다.
해당 리팩터링 테크닉은 그 자체로는 거의 이점이 없다. 하지만 불필요한 변수를 제거하여 가독성을 약간 향상시킬 수 있다.
단, 이러한 임시변수가 캐싱의 역할을 하고 있는 상황이라면 해당 리팩터링이 부적절할 수 있으니, 성능에 영향을 주지 않는지 확인이 필요하다.
- Before
hasDiscount(order: Order): boolean {
let basePrice: number = order.basePrice();
return basePrice > 1000;
}
- After
hasDiscount(order: Order): boolean {
return order.basePrice() > 1000;
}
임시변수를 쿼리로 전환 (Replace Temp with Query)
표현식의 결과를 코드에서 사용하기 위해 로컬 변수로 두고있는 경우, 그 표현식 전체를 쿼리 메서드로 전환하여, 임시변수를 제거하고 해당 메서드 호출로 대체한다.
- Before
calculateTotal(): number {
let basePrice = quantity * itemPrice;
if (basePrice > 1000) {
return basePrice * 0.95;
}
else {
return basePrice * 0.98;
}
}
- After
calculateTotal(): number {
if (basePrice() > 1000) {
return basePrice() * 0.95;
}
else {
return basePrice() * 0.98;
}
}
basePrice(): number {
return quantity * itemPrice;
}
임시변수 쪼개기 (Split Temporary Variable)
메서드 내부에 여러개의 중간값을 저장하기 위해 사용하는 로컬 변수가 있는 경우, 이를 아예 다른 값으로 분리한다. 각각의 변수는 오직 하나의 역할만 수행해야 한다.
- Before
let temp = 2 * (height + width);
temp = height * width;
- After
const perimeter = 2 * (height + width);
const area = height * width;
매개변수에 대한 할당 제거하기 (Remove Assignments to Parameters)
메서드 내부에서 파라미터를 직접 변경하지 말고, 별도의 임시변수를 두어야한다.
- Before
discount(inputVal: number, quantity: number): number {
if (quantity > 50) {
inputVal -= 2;
}
// ...
}
- After
discount(inputVal: number, quantity: number): number {
let result = inputVal;
if (quantity > 50) {
result -= 2;
}
// ...
}
메서드를 메서드 객체로 바꾸기 (Replace Method with Method Object)
로컬 변수가 너무 복잡하게 얽혀 있어 메서드 추출을 하기 어려울 때는, 메서드를 별도의 클래스로 전환하여 로컬 변수가 클래스의 필드가 될 수 있도록 처리한다. 이후 전환한 클래스 내 여러 메서드로 작업을 쪼갤 수 있다.
이를 통해 긴 메서드 로직을 자체 클래스로부터 분리하여 크기가 커지는 것을 막을 수 있고, 유틸리티 메서드로 기존 클래스를 오염시키지 않고 별도로 분리할 수 있다.
다만 이 경우, 새로운 클래스를 새로 추가하는 것이기 때문에, 프로그램의 전반적인 복잡성은 증가한다.
- Before
class Order {
// ...
price(): number {
let primaryBasePrice;
let secondaryBasePrice;
let tertiaryBasePrice;
// Perform long computation.
}
}
- After
class Order {
// ...
price(): number {
return new PriceCalculator(this).compute();
}
}
class PriceCalculator {
private _primaryBasePrice: number;
private _secondaryBasePrice: number;
private _tertiaryBasePrice: number;
constructor(order: Order) {
// Copy relevant information from the
// order object.
}
compute(): number {
// Perform long computation.
}
}
알고리즘 대체하기 (Substitute Algorithm)
기존의 알고리즘이 부적절하다고 생각된다면, 새로운 알고리즘으로 대체한다.
점진적 리팩터링(Gradual Refactoring)만이 프로그램을 개선하는 유일한 방법은 아니다.
-
때로는 메서드에 너무 많은 문제점이 있는 경우, 아예 새로 시작하는 것이 더 좋을 수도 있다. 또한 훨씬 더 간단하고 효율적인 알고리즘을 발견한 경우에도 이를 대체할 수 있다.
-
시간이 지나면서 잘 구축된 라이브러리나 프레임워크를 사용하여 유지관리를 간소화하도록 할 수 있다.
-
프로그램의 요구사항이 너무 많이 변경되어 기존 알고리즘으로 작업을 처리할 수 없을 수도 있다.
- Before
foundPerson(people: string[]): string{
for (let person of people) {
if (person.equals("Don")){
return "Don";
}
if (person.equals("John")){
return "John";
}
if (person.equals("Kent")){
return "Kent";
}
}
return "";
}
- After
foundPerson(people: string[]): string{
let candidates = ["Don", "John", "Kent"];
for (let person of people) {
if (candidates.includes(person)) {
return person;
}
}
return "";
}
Moving Features Between Objects
완벽하지 않은 방식으로 여러 클래스에 기능을 분산시킨 상황이어도, 여전히 희망은 있다.
이 범주의 리팩터링 기법은 클래스 간에 기능을 안전하게 이동시키고, 새 클래스를 만들고, 공개 액세스에서 구현 디테일을 숨기는 방법을 보여준다.
메서드 이동 (Move Method)
어떤 메서드가 본인 클래스보다도 다른 클래스에서 더 많이 사용되는 경우
해당 메서드를 가장 많이 사용하는 클래스에 새 메서드를 만들고 기존 코드를 이동시킨다. 기존 메서드의 코드는 다른 클래스의 새 메서드에 대한 참조로 바꾸거나, 완전히 제거한다.
필드 이동 (Move Field)
어떤 필드가 본인 클래스보다도 다른 클래스에서 더 많이 사용되는 경우
이를 해당 클래스의 필드로 옮기고, 기존의 필드를 사용하던 코드를 새 필드를 사용하도록 수정한다.
이는 클래스 추출의 일부로 활용되는 경우가 많으며, 종종 필드를 어느 클래스에 두어야하는지 결정하는 것이 꽤 어려울 수 있다. 하나의 룰을 정하자면, 필드를 사용하는 메서드와 같은 위치에 필드를 두는 것이 좋다.
클래스 추출 (Extract Class)
하나의 클래스가 두 클래스의 작업을 모두 수행함에 따라 어색함이 생기는 경우
대신에 새 클래스를 만들고 그 안에 관련 기능을 담당하는 필드와 메서드를 배치하자.
클래스는 항상 처음에는 명확하고 이해하기 쉽게 시작하지만, 프로그램의 확장에 따라 점점 메서드와 필드가 추가되면서 일부 클래스가 필요 이상으로 많은 책임을 갖게된다.
해당 리팩터링은 단일 책임 원칙(Single Responsible Principle)을 준수하는데 도움이 된다.
인라인 클래스 (Inline Class)
클래스가 거의 아무 일도 하고 있지 않고, 어떤 것에도 책임이 없으며, 어떤 추가적인 책임도 계획된 바가 없는 경우
해당 클래스는 따로 있을 필요가 없는 것이다. 이 경우 클래스의 모든 기능을 다른 클래스에 합쳐 넣고, 원래의 빈 클래스는 삭제한다.
이는 종종 한 클래스의 기능이 다른 클래스로 이전되면서, 기존 클래스가 하는 일이 거의 없어진 경우에 발생한다.
위임 숨기기 (Hide Delegate)
클라이언트가 객체 A의 필드 또는 메서드를 통해 객체 B를 가져와, 이후 객체 B의 메서드를 호출하는 경우
이 경우, 클래스 A에 객체 B에 대한 호출을 위임하는 새 메서드를 생성한다. 이를 통해 클라이언트가 클래스 B에 의존하지 않는 형태로 만들 수 있다.
클라이언트로부터 위임을 숨기면, 클라이언트 코드가 객체 간의 관계에 대해 알아야할 이유가 없어진다. 이러한 세부 정보에 대해 알 필요가 없어지면, 프로그램을 더 쉽게 변경할 수 있다.
미들맨 제거하기 (Remove Middle Man)
클래스 내에 단순히 다른 객체에게 위임하는 메서드가 너무 많은 경우
해당 클래스 내 이러한 메서드들을 삭제하고 클라이언트가 직접 해당 객체를 사용하도록 수정한다.
서버 클래스가 자체적으로는 아무 일도 수행하지 않고 불필요하게 복잡성만 유발하는 경우에는, 이 클래스가 꼭 필요한지 먼저 생각해보아야 한다. 또, 위임에 새로운 기능이 추가될 때마다 서버 클래스에서 해당 기능에 대한 메서드를 추가해야 하기 때문에, 변경사항이 발생할 때마다 번거로울 수 있다.
외래 메서드 도입 (Introduce Foreign Method)
유틸리티 클래스에 필요한 메서드가 포함되어 있지 않은데, 해당 메서드를 클래스에 추가하는 것도 불가능한 상황인 경우
클라이언트 클래스에 메서드를 추가하고, 유틸리티 클래스의 객체를 해당 메서드의 argument로 넘긴다.
이를테면, 아래의 nextDay
의 경우, 유틸리티인 Date 클래스에 포함되는 것이 이상적이지만, 네이티브 클래스인 Date에 이를 추가하는 것이 어려우므로 클라이언트 클래스인 Report
에 이 nextDay
메서드를 추가하고, 매개변수 및 반환값으로 Date
객체를 사용한다.
이는 메서드가 적절한 위치에 있는 것이 아니더라도, 그를 통한 중복 제거가 더 유용하다고 보는 관점으로 적용하는 리팩터링이다.
다만, 기본적으로 이는 메서드를 적절한 클래스 위치에 두는 것이 아니므로, 아래의 로컬 확장 도입을 사용하는 편이 일반적으로는 더 좋다.
- Before
class Report {
// ...
sendReport(): void {
let nextDay: Date = new Date(previousEnd.getYear(),
previousEnd.getMonth(), previousEnd.getDate() + 1);
// ...
}
}
- After
class Report {
// ...
sendReport() {
let newStart: Date = nextDay(previousEnd);
// ...
}
private static nextDay(arg: Date): Date {
return new Date(arg.getFullYear(), arg.getMonth(), arg.getDate() + 1);
}
}
로컬 확장 도입 (Introduce Local Extension)
유틸리티 클래스에 필요한 메서드가 포함되어 있지 않은데, 해당 메서드를 클래스에 추가하는 것도 불가능한 상황인 경우
메서드를 포함하는 새 클래스를 만들고, 유틸리티 클래스의 서브클래스 또는 래퍼로 해당 클래스를 사용한다.
-
서브 클래스로 사용하는 경우, 부모로부터의 모든 것을 상속받기 때문에 쉬운 방법이지만, 유틸리티 클래스 자체적으로 이것이 차단되어 있는 경우에는 적용이 어렵다.
-
래퍼 클래스로 사용하는 경우, 모든 새 메서드를 포함하는 래퍼 클래스를 만들고, 그 외의 기본 사양은 원본 객체에 위임한다. 이는 객체 간 관계를 유지하기 위한 코드를 작성해야 할 뿐만 아니라, 유틸리티 클래스로 위임하는 여러 메서드들을 추가해야하기 때문에 보다 번거로운 작업이 된다.
- Before
class ClientClass {
// ...
private static nextDay(arg: Date): Date {
return new Date(arg.getFullYear(), arg.getMonth(), arg.getDate() + 1);
}
}
- After
class MfDate extends Date {
// ...
nextDay(): Date {
return new Date(this.getFullYear(), this.getMonth(), this.getDate() + 1);
}
}
Organizing Data
- Organizing Data
- 자체적으로 필드 캡슐화하기 (Self Encapsulate Field)
- 데이터 값을 객체로 전환하기 (Replace Data Value with Object)
- 값을 참조로 바꾸기 (Change Value to Reference)
- 참조를 객체로 바꾸기 (Change Reference to Value)
- 배열을 객체로 대체하기 (Replace Array with Object)
- 관찰된 데이터 중복 (Duplicate Observed Data)
- 단방향 연결을 양방향으로 변경 (Change Unidirectional Association to Bidirectional)
- 양방향 연결을 단방향으로 변경 (Change Bidirectional Association to Unidirectional)
- 매직 넘버를 기호 상수로 대체 (Replace Magic Number with Symbolic Constant)
- 필드 캡슐화 (Encapsulate Field)
- 컬렉션 캡슐화 (Encapsulate Collection)
- 타입 코드를 클래스로 대체 (Replace Type Code with Class)
- 타입 코드를 서브클래스로 대체 (Replace Type Code with Subclasses)
- 타입 코드를 상태/전략 패턴으로 대체 (Replace Type Code with State/Strategy)
- 서브클래스를 필드로 대체 (Replace Subclass with Fields)
해당 범주의 리팩터링 기술은 데이터 처리를 도와주며, primitive 타입을 풍부한 클래스 기능으로 대체한다.
또 다른 중요한 결과는 클래스 간의 연결을 끊어 클래스의 이식성(portable)과 재사용성을 향상시킬 수 있다는 것이다.
자체적으로 필드 캡슐화하기 (Self Encapsulate Field)
어떤 클래스 내의 프라이빗 필드에 직접 접근하고 있는 경우
해당 필드에 대한 getter/setter를 생성하여, 해당 필드에 접근할 때는 오직 이들만을 사용하도록 한다.
클래스 내 프라이빗 필드에 직접 액세스하는 것으로는 유연성이 충분하지 않을 수 있다. 쿼리가 수행될 때 필드값을 초기화하거나, 필드에 새로운 값을 할당할 때 부가적인 작업을 처리할 수 있는 등, getter/setter 사용 시 많은 경우에 더 유연한 대처가 가능해진다.
또한, 서브클래스에서 getter/setter를 재정의하는 것이 가능하다는 점도 큰 이점이다.
- Before
class Range {
private low: number
private high: number;
includes(arg: number): boolean {
return arg >= this.low && arg <= this.high;
}
}
- After
class Range {
private _low: number;
private _high: number;
includes(arg: number): boolean {
return arg >= this.low && arg <= this.high;
}
get low(): number {
return this._low;
}
get high(): number {
return this._high;
}
}
데이터 값을 객체로 전환하기 (Replace Data Value with Object)
클래스(또는 여러 클래스)에 어떤 데이터 필드가 있고, 해당 필드가 고유한 역할을 수행하고 관련 데이터를 보유하는 경우
새로운 클래스를 만들어, 기존 필드와 그것의 동작을 새 클래스로 옮긴 다음, 기존의 클래스에는 새로 만든 클래스의 객체를 보관한다.
이러한 리팩터링은 기본적으로 클래스 추출로부터 확장되는 특수한 케이스다. 이것의 차이점은 리팩터링의 원인에 있다.
"클래스 추출"의 경우 서로 다른 작업을 담당하는 단일 클래스가 있어 그 책임을 분리하고자 하는 것이 목적이다.
반면, "데이터 값을 객체로 전환하기"의 경우, 데이터 값이 primitive 필드로 존재함에 따라, 여러 클래스에서 해당 필드를 이용하고, 그에 대해 유사한 작업을 요구할 가능성이 있기 때문에, 중복 코드가 발생할 가능성이 생겨 이를 방지하고자 하는 것이다.
- Before
class Order {
customer: string;
// ...
}
- After
class Order {
customer: Customer;
// ...
}
class Customer {
name: string;
// ...
}
값을 참조로 바꾸기 (Change Value to Reference)
하나의 객체로 대체해야 하는 하나의 클래스에 동일한 인스턴스가 여러 개 있는 경우
매번 생성할 필요가 없는 동일한 객체 여러개 생성하고 있는 경우, 이를 하나의 참조 객체로 대체한다.
이 경우 참조 객체에서 변경이 일어나면, 이를 참조하는 다른 곳에서도 이러한 변경 사항에 액세스할 수 있게 된다.
- Before
const customer = new Customer(customerData);
- After
const customer = customerRepository.get(customerData.id);
참조를 객체로 바꾸기 (Change Reference to Value)
참조 객체가 너무 작고 자주 변경되지 않아 라이프사이클 관리가 불필요하다고 느끼는 경우
해당 참조 객체(Reference Object)를 값 객체(Value Object)로 변경한다.
보통 참조에서 객체로 전환하고자 하는 생각은 참조를 사용하는 작업에서 불편함을 느끼는 경우에서 온다. 참조를 사용하는 경우 다음에 대한 관리가 필요하다.
- 항상 저장소로부터 필수 객체를 요청해야 한다.
- 메모리 내 참조는 작업에 불편할 수 있다.
- 분산 및 병렬 시스템에서는 참조를 다루는 것이 값에 비해 특히나 더 어렵다.
값 객체는 수명 동안에 자주 변경되는 객체보다는, 변경할 수 없는 객체를 다루는 경우에 특히나 더 유용하다. 객체 값을 반환하는 각 쿼리의 결과가 매번 동일하다면, 동일한 것을 나타내는 객체가 여러개 있어도 문제가 발생하진 않는다.
- Before
class Product {
applyDiscount(val: number) {
this._price.amount -= val;
}
}
- After
class Product {
applyDiscount(val: number) {
this._price = new Money(this._price.amount - val, this._price.currency);
}
}
배열을 객체로 대체하기 (Replace Array with Object)
여러 타입의 데이터를 담기 위한 용도로 배열을 사용하고 있는 경우
각 요소를 따로 필드로 분리하도록 하여 이를 객체로 대체한다.
배열은 단일한 유형의 데이터와 컬렉션을 저장하는 데에 탁월한 자료구조인 반면, 저마다 다른 타입의 데이터를 보관하는 경우에는 치명적인 오류로 이어질 수 있다.
클래스의 필드는 배열의 요소보다 문서화하기가 훨씬 쉽고, 결과 클래스에는 메인 클래스 및 다른 곳에 저장되어 있던 모든 관련된 동작을 배치할 수 있다.
- Before
let row = new Array(2);
row[0] = "Liverpool";
row[1] = "15";
- After
let row = new Performance();
row.setName("Liverpool");
row.setWins("15");
관찰된 데이터 중복 (Duplicate Observed Data)
클래스에 저장된 도메인 데이터가 GUI를 담당하고 있는 경우
데이터를 별도의 클래스로 구분하고, 도메인 클래스와 GUI 간 연결을 구축하여 동기화가 이루어질 수 있도록 보장하는 것이 좋다.
동일한 데이터에 대한 여러 형태의 인터페이스(ex. 데스크톱/모바일)를 갖추고자 하는 경우, GUI를 도메인으로부터 분리하지 않으면 코드 중복 및 여러 실수를 피하기가 매우 어렵다.
-
비즈니스 로직 클래스와 프레젠테이션 클래스 간에 책임을 분담(단일 책임 원칙)하여 프로그램을 더 읽기 쉽고, 이해하기 쉽게 만들 수 있다.
-
새로운 인터페이스 뷰를 추가해야 하는 경우, 새로운 프레젠테이션 클래스를 생성하기만 하면 되기 때문에, 비즈니스 로직 코드를 건들 필요가 없다. (개방/폐쇄 원칙)
-
여러 사람이 비즈니스 로직과 사용자 인터페이스를 작업할 수 있다.
-
Before
class IntervalWindow {
startField: TextField;
endField: TextField;
lengthField: TextField;
startFieldFocusLost() {
// ...
}
endFieldFocusLost() {
// ...
}
lengthFieldFocusLost() {
// ...
}
calculateLength() {
// ...
}
calculateEnd() {
// ...
}
}
- After
class IntervalWindow {
startField: TextField;
endField: TextField;
lengthField: TextField;
interval: Interval;
startFieldFocusLost() {
// ...
}
endFieldFocusLost() {
// ...
}
lengthFieldFocusLost() {
// ...
}
}
class Interval {
start: Date;
end: Date;
length: number;
calculateLength() {
// ...
}
calculateEnd() {
// ...
}
}
단방향 연결을 양방향으로 변경 (Change Unidirectional Association to Bidirectional)
서로의 기능을 필요로 하는 두 클래스가 있지만, 두 클래스 간의 연결은 단방향으로 이루어져 있는 경우
클래스가 필요로 하는 연결을 추가해준다.
원래 클래스는 단방향의 연결을 가지지만, 시간이 지남에 따라 클라이언트 코드가 연결의 양쪽 모두에 액세스 가능해야 할 수 있다.
단, 양방향 연결을 단방향보다 구현 및 유지관리가 훨씬 더 어렵고, 클래스를 상호 의존적으로 만든다는 문제가 있다. 단방향 연결은 둘 중 하나를 다른 클래스로부터 독립적으로 사용할 수 있게 해준다.
- Before
class Customer {
name: string;
order: Order;
}
class Order {}
- After
class Customer {
name: string;
order: Order;
}
class Order {
customer: Customer;
}
양방향 연결을 단방향으로 변경 (Change Bidirectional Association to Unidirectional)
두 클래스 간의 양방향 연결이 구성되어 있으나, 둘 중 하나의 클래스가 다른 클래스의 기능을 필요로 하지 않는 경우
사용하지 않는 쪽의 연결을 없앤다.
양방향 연결을 일반적으로 단방향 연결보다 유지 관리가 어렵고, 관련된 객체를 올바르게 생성 및 삭제하기 위한 추가적인 코드가 필요하다. 이에 따라 프로그램이 더 복잡해진다. 또한 다음과 같은 문제점이 생길 수 있다.
또한, 양방향 연결을 잘못 구현하는 경우 가비지 컬렉션에 문제가 발생할 수 있으며, 결국 사용하지 않는 객체로 인해 메모리가 팽창할 수 있다.
또, 클래스의 상호 의존성 문제로, 클래스가 서로에 대해 알고있어야 하므로 분리되어 사용할 수 없고, 이러한 연결이 많아질 경우 프로그램의 여러 부분이 서로 지나치게 의존하게 됨에 따라 한 컴포넌트의 변경 사항이 다른 컴포넌트에 영향을 미칠 수 있다.
- Before
class Customer {
name: string;
order: Order;
}
class Order {
customer: Customer;
}
- After
class Customer {
name: string;
order: Order;
}
class Order {}
매직 넘버를 기호 상수로 대체 (Replace Magic Number with Symbolic Constant)
코드에 특정한 의미를 담고있는 수가 사용되고 있는 경우
해당 수가 어떤 의미인지에 대해 설명해주는 인간 친화적인 이름을 부여한 상수에 이를 할당한다.
매직넘버는 소스에서 발견되지만 명확한 의미를 알 수 없는 숫자값으로, 이러한 안티 패턴은 프로그램의 이해와 리팩터링을 어렵게 만든다.
무엇보다도, 이 매직넘버를 변경해야 하는 상황에서 문제는 더 심각해지는데, 같은 숫자가 다른 위치에서 다른 용도로 사용될 수 있으므로, 이 숫자를 사용하는 모든 코드 라인을 확인해야만 한다.
- Before
potentialEnergy(mass: number, height: number): number {
return mass * height * 9.81;
}
- After
static const GRAVITATIONAL_CONSTANT = 9.81;
potentialEnergy(mass: number, height: number): number {
return mass * height * GRAVITATIONAL_CONSTANT;
}
필드 캡슐화 (Encapsulate Field)
퍼블릭 필드를 보유하고 있는 경우
필드를 프라이빗으로 만들고, 필드를 읽고 쓰는 접근자 메서드를 만든다.
OOP의 이점 중 하나는 캡슐화로, 객체의 데이터를 외부에게서 숨길 수 있다는 것이다. 모든 객체 데이터가 공개되는 경우, 객체가 서로 직접 데이터를 참조 및 수정할수 있게 되어 프로그램의 모듈성이 손상되고 유지 관리가 복잡해진다.
단, 경우에 따라서는 성능에 대한 고려사항으로 인해 캡슐화를 적용하는 것이 적절하지 않을 수도 있다. 이를테면, x/y 좌표축을 갖는 객체들이 무수하게 많이 포함된 그래픽 편집기가 있다고 가정하자. 이들 좌표에 액세스하는 별도의 메서드들을 각각 두기보다는, 좌표 필드에 직접 액세스할 수 있도록 구성한다면, 액세스 메서드를 호출할 때 차지할 상당한 CPU 사이클을 절약할 수 있다. (ex. Java의 Point 클래스)
- Before
class Person {
name: string;
}
- After
class Person {
private _name: string;
get name() {
return this._name;
}
setName(name: string): void {
this._name = name;
}
}
컬렉션 캡슐화 (Encapsulate Collection)
클래스에 컬렉션 필드와 컬렉션 작업을 위한 간단한 getter/setter가 존재하는 경우
getter가 반환하는 값을 읽기 전용으로 만들고 컬렉션 요소를 추가/삭제하는 메서드를 만든다.
클래스 내에 컬렉션을 포함하는 필드가 존재하는 경우, 이 때는 일반적인 필드와는 다르게 다루어야 한다. 만약 getter를 통해 컬렉션 자체가 직접 전달되는 경우, 클라이언트가 클래스도 모르게 임의로 컬렉션의 내용을 수정할 수도 있고, 필요 이상으로 많은 데이터가 클라이언트에게 노출되기 때문이다.
따라서, 컬렉션 요소를 가져오는 getter 메서드의 경우, 컬렉션을 변경할 수 없는 형태로 반환하거나, 컬렉션 구조에 대한 과도한 데이터를 공개하지 말아야 한다. 또한, 컬렉션 값을 할당하는 메서드 대신, 컬렉션 내에 요소를 추가/삭제할 수 있는 메서드를 제공해야 한다. 이를 통해 클라이언트가 아닌, 클래스 본인이 요소의 추가 및 삭제에 대한 제어권을 갖도록 할 수 있다.
- Before
class Person {
private _courses: Set<Course>;
getCourses() {
return this._courses;
}
setCourses(courses: Set<Course>) {
this._courses = courses;
}
}
class Course {}
- After
class Person {
private _courses: Set<Course>;
getCourses() {
return [...this._courses];
}
addCourse(course: Course) {
this._courses.add(course);
}
removeCourse(course: Course) {
this._courses.delete(course);
}
}
class Course {}
타입 코드를 클래스로 대체 (Replace Type Code with Class)
어떤 클래스가 타입 코드를 포함하는 필드를 갖추었으나, 해당 타입 코드들은 연산자 조건에 사용되지 않고, 프로그램 동작에도 영향을 주지 않는 경우
새로운 클래스를 생성하여, 타입 코드 대신 해당 클래스의 객체를 사용한다.
타입 코드가 필요한 일반적인 이유 중 하나는 숫자나 문자열로 코딩된 복잡한 개념이 있는 필드가 있는 데이터베이스 작업을 해야하는 경우다.
필드 설정자는 보통 어떤 값이 전달되는지에 대해서는 체크하지 않으므로, 누군가 의도치 않은 값이나 잘못된 값을 이들 필드에 전달하게 되면 커다란 문제가 발생할 수 있다.
타입 코드가 숫자/문자열 등 primitive 타입인 경우, 적절한 타입 체크가 이루어지지 않는다는 문제점도 있다.
- Before
class Person {
static O = 0;
static A = 1;
static B = 2;
static AB = 3;
private _bloodGroup: number;
get bloodGroup() {
return this._bloodGroup;
}
set bloodGroup(bloodGroup: string) {
this._bloodGroup = bloodGroup;
}
}
- After
class Person {
private _bloodGroup: BloodGroup;
get bloodGroup() {
return this._bloodGroup;
}
set bloodGroup(bloodGroup: BloodGroup) {
this._bloodGroup = bloodGroup;
}
}
class BloodGroup {
static O: BloodGroup;
static A: BloodGroup;
static B: BloodGroup;
static AB: BloodGroup;
}
타입 코드를 서브클래스로 대체 (Replace Type Code with Subclasses)
프로그램 동작에 직접적인 영향을 미치는 타입 코드가 존재하는 경우 (해당 필드의 값이 조건부로 다양한 코드를 트리거하는 경우)
코딩된 타입의 각 값에 대한 하위 클래스를 만든다. 이후 기존 클래스에서 새로 만든 하위 클래스로 관련 동작을 추출해낸다. 그리고 제어 흐름 코드를 다형성으로 대체한다.
- Before
class Employee {
static ENGINEER: number = 0;
static SALESMAN: number = 1;
private _type: number;
get employeeType() {
return this._type;
}
set employeeType(type: number) {
this._type = type;
}
}
- After
class Employee {
// ...
}
class Engineer extends Employee {
// ...
}
class Salesman extends Employee {
// ...
}
타입 코드를 상태/전략 패턴으로 대체 (Replace Type Code with State/Strategy)
동작에 영향을 주는 타입 코드가 있지만, 서브클래스를 사용하여 타입 코드를 대체할 수 없는 경우
타입 코드를 상태 객체로 대체한다. 만약 필드 값을 타입 코드로 바꾸는 것이 필수적인 경우 다른 상태 객체가 연결되도록 한다. 이는 아래와 같은 경우에 적절하다.
-
타입 코드가 있고, 클래스의 동작에 영향을 미치고 있어 타입 코드를 클래스로 바꿀 수 없는 경우
-
타입 코드가 클래스의 동작에 영향을 주지만 기존 클래스 계층 구조 또는 다른 이유로 인해 타입 코드에 대한 서브클래스를 만들 수 없는 경우
해당 리팩터링 테크닉은 타입 코드의 필드값이 객체의 라이프타임 동안에 변경되는 것을 막아준다. 이 때 값의 교체는 원래 클래스가 참조하는 상태 객체를 교체하여 이루어져야 한다.
코드 타입에 새로운 값을 추가해야 하는 경우, 기존 코드를 변경하지 않고 새로운 상태 하위 클래스를 추가하기만 하면 된다. (OCP ~ Open/Closed Principle)
다만 해당 리팩터링 테크닉은 불필요한 클래스가 많이 추가된다.
- Before
class Employee {
static ENGINEER: number = 0;
static SALESMAN: number = 1;
private _type: number;
get employeeType() {
return this._type;
}
set employeeType(type: number) {
this._type = type;
}
}
- After
class Employee {
private _type: EmployeeType;
get employeeType() {
return this._type;
}
set employeeType(type: EmployeeType) {
this._type = type;
}
}
class EmployeeType {
}
class Engineer extends EmployeeType {
}
class Salesman extends EmployeeType {
}
서브클래스를 필드로 대체 (Replace Subclass with Fields)
서브클래스들이 단순히 상수 데이터(반환이 항상 같음)를 반환하는 메서드만을 가지고 있을 때
해당 메서드를 상위 클래스의 필드로 바꾸고, 하위 클래스를 삭제한다.
때때로 리팩터링은 타입 코드를 피하기 위한 단순한 티켓이 된다.
이러한 경우, 서브클래스의 계층 구조는 오직 특정한 메서드의 반환값에 대한 차이만 갖게된다. 이러한 메서드들은 어떤 연산의 결과도 아니며, 오직 메서드 자체적으로 엄격하게 설정된 값일 뿐이다. 클래스 구조를 단순화하기 위해서는 구조를 하나의 클래스로 압축하고 상황에 따라 필요한 값을 가진 하나 또는 여러 개의 필드를 추가한다.
이러한 테크닉은 클래스 계층 구조에서 다른 곳으로 많은 기능들을 이동하고 난 이후에 필요할 수 있다. 이 경우 계층 구조가 그다지 가치가 없어지고, 하위 클래스가 쓸모없어지는 경우가 생길 수 있기 때문이다.
- Before
type Code = 'M' | 'F';
abstract class Person {
abstract getCode: () => Code;
}
class Male extends Person {
getCode = (): Code => 'M';
}
class Female extends Person {
getCode = (): Code => 'F';
}
- After
type Code = 'M' | 'F';
class Person {
private _code: Code;
getCode(): Code {
return this._code;
};
}
Simplifying Conditional Expressions
- Simplifying Conditional Expressions
- 조건문 분해하기 (Decompose Conditional Expressions)
- 조건식 통합하기 (Consolidate Conditional Expression)
- 중복 조건부 조각 통합하기 (Consolidate Duplicate Conditional Fragments)
- 제어 플래그 제거 (Remove Control Flag)
- 중첩 조건문을 보호 구문으로 바꾸기 (Replace Nested Conditional with Guard Clauses)
- 조건을 다형성으로 바꾸기 (Replace Conditional with Polymorphism)
- Null 객체 도입 (Introduce Null Object)
- 단언 도입 (Introduce Assertion)
조건문은 시간이 지남에 따라 점점 더 논리가 복잡해지는 경향이 있으며, 이를 해결하기 위한 기법도 많이 개발되어 왔다.
조건문 분해하기 (Decompose Conditional Expressions)
복잡한 조건문이 있는 경우 (
if-then
,else
,switch
)
- Before
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * winterRate + winterServiceCharge;
} else {
charge = quantity * summerRate;
}
- After
if (isSummer(date)) {
charge = summerCharge(quantity);
} else {
charge = winterCharge(quantity);
}
조건식 통합하기 (Consolidate Conditional Expression)
동일한 동작을 수행하거나, 동일한 결과를 반환하는 조건문이 여러 개로 나뉘어져 있는 경우
해당 조건문을 하나의 표현식으로 통일한다.
- Before
disabilityAmount(): number {
if (seniority < 2) {
return 0;
}
if (monthsDisabled > 12) {
return 0;
}
if (isPartTime) {
return 0;
}
// Compute the disability amount.
// ...
}
- After
isNotEligibleForDisability(): boolean {
return seniority < 2 || monthsDisabled > 12 || isPartTime;
}
disabilityAmount(): number {
if (isNotEligibleForDisability()) {
return 0;
}
// Compute the disability amount.
// ...
}
중복 조건부 조각 통합하기 (Consolidate Duplicate Conditional Fragments)
조건부의 각 줄기에서 똑같은 코드가 사용되고 있는 경우
조건문의 바깥으로 해당 코드를 빼낸다. 이를 통해 코드 중복을 제거한다.
- Before
if (isSpecialDeal()) {
total = price * 0.95;
send();
}
else {
total = price * 0.98;
send();
}
- After
if (isSpecialDeal()) {
total = price * 0.95;
}
else {
total = price * 0.98;
}
send();
제어 플래그 제거 (Remove Control Flag)
여러 bool 표현식에 대한 제어 플래그 역할을 수행하는 bool 변수가 존재하는 경우
따로 만든 변수 대신에 break
, continue
, return
을 사용한다.
- Before
let found = false;
for (const p of people) {
if (!found) {
if (p === "Don") {
sendAlert();
found = true;
}
}
}
- After
for (const p of people) {
if (p === "Don") {
sendAlert();
break;
}
}
중첩 조건문을 보호 구문으로 바꾸기 (Replace Nested Conditional with Guard Clauses)
중첩된 조건문들이 있으나, 코드 실행의 일반적인 흐름을 파악하기 어려운 경우
모든 특수한 경우와 엣지 케이스들을 주된 흐름 앞쪽으로 위치시킨다. 이상적인 조건문은 차례로 나열된 "평평한" 목록이다.
- Before
getPayAmount(): number {
let result: number;
if (isDead){
result = deadAmount();
}
else {
if (isSeparated){
result = separatedAmount();
}
else {
if (isRetired){
result = retiredAmount();
}
else{
result = normalPayAmount();
}
}
}
return result;
}
- After
getPayAmount(): number {
if (isDead){
return deadAmount();
}
if (isSeparated){
return separatedAmount();
}
if (isRetired){
return retiredAmount();
}
return normalPayAmount();
}
조건을 다형성으로 바꾸기 (Replace Conditional with Polymorphism)
객체의 타입 또는 속성에 따라 다른 동작을 수행하는 조건문이 있는 경우
조건 분기와 일치하는 서브클래스를 만들고, 그 안에 공유 메서드를 생성하여 조건문 내의 코드를 해당 메서드로 옮긴다. 이후 해당 조건을 관련 메서드 호출로 변경한다. 이를 통해 객체 클래스에 따른 다형성으로 적절한 구현을 얻을 수 있다.
새로운 객체 속성이나 유형이 나타나면, 유사한 조건문을 모두 검색하여 코드를 추가해야 하는데, 이 경우 모든 메서드에 여러 조건문이 흩어져 있는 경우 상당히 까다로워진다. 이런 상황에서 해당 테크닉이 빛을 발휘할 수 있다.
이 테크닉은 객체의 상태를 물어보고 그를 기반으로 동작을 수행하는 대신, 객체가 수행해야 할 작업을 알려주고 그 방법은 스스로 처리하도록 하는 Tell-Don't-Ask(TDA) 원칙을 준수한다.
- Before
class Bird {
// ...
getSpeed(): number {
switch (type) {
case EUROPEAN:
return getBaseSpeed();
case AFRICAN:
return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
case NORWEGIAN_BLUE:
return (isNailed) ? 0 : getBaseSpeed(voltage);
}
throw new Error("Should be unreachable");
}
}
- After
abstract class Bird {
// ...
abstract getSpeed(): number;
}
class European extends Bird {
getSpeed(): number {
return getBaseSpeed();
}
}
class African extends Bird {
getSpeed(): number {
return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
}
}
class NorwegianBlue extends Bird {
getSpeed(): number {
return (isNailed) ? 0 : getBaseSpeed(voltage);
}
}
// Somewhere in client code
let speed = bird.getSpeed();
Null 객체 도입 (Introduce Null Object)
실제 객체 대신 null을 반환하게 되어 코드의 많은 부분에서 null 체킹을 수행해야 하는 경우
실제 null 대신, 기본적인 동작을 수행하는 null 객체를 반환하도록 한다.
수십번이고 null을 체크하다보면 코드가 길고 지저분해진다.
- Before
if (customer === null) {
plan = BillingPlan.basic();
}
else {
plan = customer.getPlan();
}
- After
class NullCustomer extends Customer {
isNull(): boolean {
return true;
}
getPlan(): Plan {
return new NullPlan();
}
// Some other NULL functionality.
}
// Replace null values with Null-object.
let customer = (order.customer !== null) ?
order.customer : new NullCustomer();
// Use Null-object as if it's normal subclass.
plan = customer.getPlan();
단언 도입 (Introduce Assertion)
코드의 일부가 올바르게 동작하기 위해 특정 조건이나 값이 반드시 참이어야 하는 경우
이러한 가정을 구체적인 단언 체크(assertion check)로 대체한다. 단, JS/TS에는 빌트인 assertion이 없으므로, console.error()
등을 이용하여 assertion을 구현해야 한다.
- Before
getExpenseLimit(): number {
// Should have either expense limit or
// a primary project.
return (expenseLimit != NULL_EXPENSE) ?
expenseLimit:
primaryProject.getMemberExpenseLimit();
}
- After
getExpenseLimit(): number {
// TypeScript and JS doesn't have built-in assertions, so we'll use
// good-old console.error(). You can always extract this into a
// designated assertion function.
if (!(expenseLimit !== NULL_EXPENSE ||
(typeof primaryProject !== 'undefined' && primaryProject))) {
console.error("Assertion failed: getExpenseLimit()");
}
return (expenseLimit !== NULL_EXPENSE) ?
expenseLimit:
primaryProject.getMemberExpenseLimit();
}
Simplifying Method Calls
- Simplifying Method Calls
- 메서드 이름 바꾸기 (Rename Method)
- 매개변수 추가하기 (Add Parameter)
- 매개변수 제거하기 (Remove Parameter)
- 수정자와 쿼리를 분리하기 (Separate Query from Modifier)
- 메서드를 매개변수화하기 (Parameterize Method)
- 매개변수를 명시적인 메서드로 바꾸기 (Replace Parameter with Explicit Methods)
- 객체 통째로 넘기기 (Preserve Whole Object)
- 매개변수를 메서드으로 바꾸기 (Replace Parameter with Methods)
- 매개변수 객체 도입 (Introduce Parameter Object)
- 세팅 메서드 제거하기 (Remove Setting Method)
- 메서드 숨기기 (Hide Method)
- 생성자를 팩토리 메서드로 대체 (Replace Constructor with Factory Method)
- 에러 코드를 예외로 대체 (Replace Error Code with Exception)
- 예외를 테스트로 대체 (Replace Exception with Test)
이러한 종류의 테크닉들은 메서드 호출을 단순화하고 코드를 더 이해하기 쉽게 만들어준다. 이는 결국 클래스 간의 상호작용을 위한 인터페이스를 단순화한다.
메서드 이름 바꾸기 (Rename Method)
메서드 이름이 메서드가 어떤 작업을 하는지 설명하지 못하는 경우
메서드 이름을 새로 짓자.
메서드는 애초에 잘못된 이름이었을 수도 있고, 시간이 지나면서 이름이 충분한 설명이 되지 못하게 되었을 수도 있다.
- Before
class Customer {
getsnm() {
// ...
}
}
- After
class Customer {
getSecondName() {
// ...
}
}
매개변수 추가하기 (Add Parameter)
메서드가 특정 작업을 수행하는 데에 충분한 데이터를 갖고 있지 않는 경우
새로운 매개변수를 추가하여 필요한 데이터를 전달하자.
이러한 상황에서는 앞서 설명한 것처럼 매개변수를 추가하거나, 새로운 프라이빗 필드를 추가하는 방법을 고려할 수 있다.
일반적으로 매개변수를 추가하는 것은 데이터가 가끔씩만 필요하거나, 데이터가 자주 바뀌어 객체에 항상 보유할 필요가 없는 경우에 효과적이다. 그렇지 않은 상황이라면 비공개 필드를 추가하는 방법이 더 좋을 수 있다.
- Before
class Customer {
getContact() {
// ...
}
}
- After
class Customer {
getContact(date: Date) {
// ...
}
}
매개변수 제거하기 (Remove Parameter)
매개변수가 더 이상 메서드 바디에서 사용되지 않을 때
사용되지 않는 매개변수는 제거한다.
- Before
class Customer {
getContact(date: Date) {
// ...
}
}
- After
class Customer {
getContact() {
// ...
}
}
수정자와 쿼리를 분리하기 (Separate Query from Modifier)
값을 반환하면서 동시에 객체 내부의 무엇인가를 변경하는 메서드가 있는 경우
메서드를 별개의 동작을 수행하는 각각의 메서드로 분할한다. 각 메서드는 하나의 역할만 수행해야 한다.
다만, 어떤 경우에는 특정한 수정 명령을 수행한 후, 데이터를 가져오는 것이 편리할 때도 있다. (ex. DB에서 무엇인가를 삭제할 때, 얼마나 많은 행이 삭제되었는지 알고 싶은 경우)
- Before
class Customer {
getTotalOutstandingAndSetReadyForSummaries() {
// ...
}
}
- After
class Customer {
getTotalOutstanding() {
// ...
}
setReadyForSummaries() {
// ...
}
}
메서드를 매개변수화하기 (Parameterize Method)
여러 메서드가 내부적으로 사용되는 값, 숫자 또는 연산만 다른 유사한 작업을 수행하는 경우
필요한 특정 값을 매개변수로 넘겨받는 형태의 메서드를 만들어 기존의 메서드를 대체한다.
유사한 메서드가 있다면 코드 중복의 가능성이 높으며, 이는 코드를 수정할 때 불필요한 작업을 유발한다.
- Before
class Employee {
tenPercentRaise() {
this.salary *= 1.1;
}
fivePercentRaise() {
this.salary *= 1.05;
}
}
- After
class Employee {
raise(percentage: number) {
this.salary *= 1 + percentage;
}
}
매개변수를 명시적인 메서드로 바꾸기 (Replace Parameter with Explicit Methods)
메서드가 여러 부분으로 나뉘어 있고, 각 부분의 실행이 매개변수의 값에 의존하고 있는 경우
메서드의 각 부분들을 별개의 메서드로 나눈다.
단, 메서드가 거의 변경되지 않고, 그 안에서 새로운 변형이 추가되지 않는 경우에는 이 리팩터링을 적용하지 않는다.
- Before
setValue(name: string, value: number): void {
if (name.equals("height")) {
height = value;
return;
}
if (name.equals("width")) {
width = value;
return;
}
}
- After
setHeight(value: number): void {
height = value;
}
setWidth(value: number): number {
width = value;
}
객체 통째로 넘기기 (Preserve Whole Object)
객체에서 여러 개의 값을 따로 가져와 메서드의 매개변수로 전달하고 있는 경우
그냥 객체 전체를 통째로 넘기자.
반면, 이 경우 특정 인터페이스를 갖춘 객체만 사용할 수 있도록 제한되기 때문에 메서드의 유연성이 다소 떨어지게 되는 경우가 있다.
- Before
let low = daysTempRange.getLow();
let high = daysTempRange.getHigh();
let withinPlan = plan.withinRange(low, high);
- After
let withinPlan = plan.withinRange(daysTempRange);
매개변수를 메서드으로 바꾸기 (Replace Parameter with Methods)
(메서드 내에서 호출되어도 상관없는) 쿼리 메서드를 사용해 그 결과를 그대로 메서드의 매개변수로 전달하고 있는 경우
매개변수로 해당 값을 전달하는 대신, 메서드 내부에서 직접 해당 쿼리를 호출하도록 하자.
- Before
let basePrice = quantity * itemPrice;
const seasonDiscount = this.getSeasonalDiscount();
const fees = this.getFees();
const finalPrice = discountedPrice(basePrice, seasonDiscount, fees);
- After
let basePrice = quantity * itemPrice;
let finalPrice = discountedPrice(basePrice);
매개변수 객체 도입 (Introduce Parameter Object)
매개변수의 특정 그룹이 반복적으로 메서드에 전달되는 경우
해당 매개변수들을 하나의 객체로 묶어 전달한다.
다만, 단순히 객체를 위해 새로운 클래스에 데이터만을 옮겨놓고 별다른 동작이나 관련 작업을 추가할 계획이 없다면, 데이터 클래스 냄새가 나기 시작할 것이다.
amountInvoiced(startDate: Date, endDate: Date) {
// ...
}
amountReceived(startDate: Date, endDate: Date) {
// ...
}
amountOverdue(startDate: Date, endDate: Date) {
// ...
}
- After
amountInvoiced(date: DateRange) {
// ...
}
amountReceived(date: DateRange) {
// ...
}
amountOverdue(date: DateRange) {
// ...
}
세팅 메서드 제거하기 (Remove Setting Method)
필드의 값은 필드를 생성할 때만 설정되어야 하고, 이후에는 변경하지 않아야 한다.
따라서, 필드값을 설정하는 메서드를 삭제한다.
이를 통해 필드 값의 변경을 방지해야 한다.
- Before
class Customer {
setImmutableValue() {
// ...
}
}
- After
class Customer {
// ...
}
메서드 숨기기 (Hide Method)
다른 클래스에서 사용하지 않으며, 오직 클래스 구조 내부에서만 사용되는 메서드가 있는 경우
메서드를 private 또는 protected로 설정한다.
메서드를 은닉하면 코드의 확장이 더 쉬워진다. private 메서드의 코드를 변경할 때는, 어차피 클래스 외부에서는 사용할수 없다는 것을 알기 떄문에, 현재 클래스에 대한 영향만 걱정하면 되기 때문이다.
- Before
class Employee {
aMethod() {
// ...
}
}
- After
class Employee {
private aMethod() {
// ...
}
}
생성자를 팩토리 메서드로 대체 (Replace Constructor with Factory Method)
객체 필드에 단순히 값을 설정하는 것 이상으로 복잡한 로직을 가진 생성자가 있는 경우
생성자를 팩토리 메서드로 대체하여, 생성자 호출을 대신한다.
해당 테크닉은 타입 코드를 서브클래스로 대체하는 상황과도 관련이 있다. 리팩터링을 적용한 이후 생겨난 여러 서브클래스 객체들을 타입 코드에 따라 적절하게 반환하는 생성자가 필요한데, 이것을 기존의 생성자를 통해 구현하는 것은 불가능하므로, 이를 수행하는 정적 팩토리 메서드를 생성하여 기존 생성자의 역할을 대신하게 할 수 있다.
해당 리팩터링은 생성자를 사용하기 적합하지 않은 많은 상황에서 유용하다. 이를테면, 값을 참조로 변경하고자 할 때, 또한 매개변수의 수와 유형을 넘어서는 다양한 생성 모드를 갖추고자 할 때도 사용할 수 있다.
- Before
class Employee {
constructor(type: number) {
this.type = type;
}
// ...
}
- After
class Employee {
static create(type: number): Employee {
switch (type) {
case 1:
return new Engineer();
case 2:
return new Salesman();
default:
throw new Error("Incorrect type code value");
}
}
// ...
}
에러 코드를 예외로 대체 (Replace Error Code with Exception)
메서드가 에러를 의미하는 임의의 특별한 값을 반환하고 있는 경우
그냥 예외를 던지자.
오류 코드의 반환은 절차적 프로그래밍에서 더 이상 사용되지 않는 방식이다. 모던 프로그래밍에서 오류 처리는 예외라는 이름의 특수 클래스에 의해 수행된다.
- Before
withdraw(amount: number): number {
if (amount > _balance) {
return -1;
}
else {
balance -= amount;
return 0;
}
}
- After
withdraw(amount: number): void {
if (amount > _balance) {
throw new Error();
}
balance -= amount;
}
예외를 테스트로 대체 (Replace Exception with Test)
간단한 테스트로도 충분한 상황에 예외를 던지고 있는 경우
예외를 조건부 테스트로 수정한다.
예외는 예상치 못한 오류와 관련된 불규칙한 동작을 처리하는 데에 사용되어야 하지, 테스트를 대체해서는 안 된다. 실행하기 전에 조건을 간단히 확인하여 예외를 피할 수 있는 상황이라면, 그렇게 처리하라. 예외는 "진짜 오류"를 위해 남겨두어야 한다.
- Before
getValueForPeriod(periodNumber: number): number {
try {
return values[periodNumber];
} catch (ArrayIndexOutOfBoundsException e) {
return 0;
}
}
- After
getValueForPeriod(periodNumber: number): number {
if (periodNumber >= values.length) {
return 0;
}
return values[periodNumber];
}
Dealing with Generalization
- Dealing with Generalization
- 필드 끌어올리기 (Pull Up Field)
- 메서드 끌어올리기 (Pull Up Method)
- 생성자 바디 끌어올리기 (Pull Up Constructor Body)
- 메서드 밀어내리기 (Push Down Method)
- 필드 밀어내리기 (Push Down Field)
- 서브클래스 추출하기 (Extract Subclass)
- 상위클래스 추출하기 (Extract Superclass)
- 인터페이스 추출하기 (Extract Interface)
- 계층 구조 축소하기 (Collapse Hierarchy)
- 템플릿 메서드 생성 (Form Template Method)
- 상속을 위임으로 바꾸기 (Replace Inheritance with Delegation)
- 위임을 상속으로 바꾸기 (Replace Delegation with Inheritance)
추상화는 주로 클래스 상속 계층 구조를 따라 기능을 이동시키고, 새로운 클래스 및 인터페이스를 생성하고, 상속을 위임으로 대체하거나 혹은 그 반대로 대체하는 것과 관련된 자체적인 리팩터링 테크닉들을 담고 있다.
필드 끌어올리기 (Pull Up Field)
두 클래스가 똑같은 필드를 갖고 있는 경우
서브클래스로부터 필드를 제거하고 부모 클래스로 필드를 옮기자.
서브클래스가 개별적으로 성장 및 발전하면서 동일한 필드가 메서드가 나타나게 된 경우이다.
- Before
class Soldier {
health: number;
}
class Tank {
health: number;
}
- After
class Unit {
health: number;
}
class Soldier extends Unit {
// ...
}
class Tank extends Unit {
// ...
}
메서드 끌어올리기 (Pull Up Method)
여러 서브클래스들에 비슷한 동작을 수행하는 메서드가 있는 경우
하나의 메서드를 만들어 이를 상위 클래스로 옮긴다.
이는 서브클래스가 상위 클래스의 작업을 재정의하더라도, 본질적으로는 동일한 작업을 수행하는 경우에도 적용할 수 있다.
대부분의 언어에서 서브클래스 생성자는 상위 클래스의 매개변수와 다른 그들 본인만의 매개변수를 가질수 있기 때문에, 상위클래스의 생성자에서는 실제로 필요한 매개변수들만 사용하도록 해야한다.
- Before
class Soldier {
getHealth() {
// ...
}
}
class Tank {
getHealth() {
// ...
}
}
- After
class Unit {
getHealth() {
// ...
}
}
class Soldier extends Unit {
// ...
}
class Tank extends Unit {
// ...
}
생성자 바디 끌어올리기 (Pull Up Constructor Body)
서브클래스들에 거의 동일한 코드를 가진 생성자가 있는 경우
상위 클래스의 생성자를 생성한 후, 서브클래스에서 상위 클래스의 생성자를 그대로 호출하여 사용하도록 한다.
- Before
class Manager extends Employee {
constructor(name: string, id: string, grade: number) {
this.name = name;
this.id = id;
this.grade = grade;
}
// ...
}
- After
class Manager extends Employee {
constructor(name: string, id: string, grade: number) {
super(name, id);
this.grade = grade;
}
// ...
}
메서드 밀어내리기 (Push Down Method)
상위 클래스에 구현한 동작을 오직 하나(또는 일부)의 서브클래스에서만 사용하고 있는 경우
서브클래스로 해당 동작을 옮긴다.
메서드가 하나 이상의 하위 클래스에서 필요하지만, 모든 하위 클래스에서 필요한 것은 또 아닌 경우에는 중간 하위 클래스를 만들어 메서드를 해당 클래스로 옮기는 것이 유용할 수 있다.
- Before
class Unit {
getFuel() {
// ...
}
}
class Soldier extends Unit {
// ...
}
class Tank extends Unit {
// ...
}
- After
class Unit {
// ...
}
class Soldier extends Unit {
// ...
}
class Tank extends Unit {
getFuel() {
// ...
}
}
필드 밀어내리기 (Push Down Field)
오직 일부 서브클래스에서만 어떤 필드를 사용하는 경우
서브클래스로 해당 필드를 옮긴다.
- Before
class Unit {
fuel: number;
// ...
}
class Soldier extends Unit {
// ...
}
class Tank extends Unit {
// ...
}
- After
class Unit {
// ...
}
class Soldier extends Unit {
// ...
}
class Tank extends Unit {
fuel: number;
// ...
}
서브클래스 추출하기 (Extract Subclass)
특정한 상황에서만 사용되는 기능이 있는 경우
서브클래스를 따로 생성하여 그러한 "특정 상황"에서 이 서브클래스를 사용하도록 한다.
클래스에 특정 드문 사례를 구현하기 위한 메서드와 필드가 있을 때, 드물기야 하지만 클래스가 해당 사례를 담당하고 있는 것은 맞으므로 아예 다른 클래스를 만들어 해당 사례를 담당하는 것은 잘못되었다. 이런 경우에는 서브클래스를 만들어 해당 사례를 담당하도록 한다.
- Before
class JobItem {
getTotalPrice() {
// ...
}
getUnitPrice() {
// ...
}
getEmployee() {
// ...
}
}
- After
class JobItem {
getTotalPrice() {
// ...
}
getUnitPrice() {
// ...
}
getEmployee() {
// ...
}
}
class LaborItem extends JobItem {
getUnitPrice() {
// ...
}
getEmployee() {
// ...
}
}
상위클래스 추출하기 (Extract Superclass)
다른 두 클래스에 유사한 형태의 필드와 메서드가 있는 경우
둘 사이에 동일한 필드와 메서드를 가진 상위 클래스를 생성한다.
다만, 이미 상위클래스가 있는 경우에는 해당 테크닉을 적용할 수 없다.
- Before
class Employee {
getAnnualCost() {
// ...
}
getName() {
// ...
}
getId() {
// ...
}
}
class Department {
getTotalAnnualCost() {
// ...
}
getName() {
// ...
}
getHeadCount() {
// ...
}
}
- After
class Party {
getAnnualCost() {
// ...
}
getName() {
// ...
}
}
class Employee extends Party {
getAnnualCost() {
// ...
}
getId() {
// ...
}
}
class Department extends Party {
getAnnualCost() {
// ...
}
getHeadCount() {
}
}
인터페이스 추출하기 (Extract Interface)
클래스 인터페이스의 동일한 부분을 여러 클라이언트에서 사용하는 경우
또는, 두 클래스의 인터페이스 일부가 똑같은 경우
동일한 부분을 자체적인 인터페이스로 추출한다.
인터페이스는 클래스가 서로 다른 상황에서 특별할 역할을 수행해야 하는 경우에 매우 적절하다. 이 경우 인터페이스를 사용하면 어떤 역할을 하는지 명시적으로 나타낼 수 있다.
또한, 인터페이스는 클래스가 서버에서 수행하는 작업을 설명해야 할 때 편리하다. 최종적으로는 여러 타입의 서버를 사용하도록 계획하고 있는 경우, 모든 서버들이 해당 인터페이스를 구현해야 한다.
이는 상위클래스 추출과 비슷하지만, 인터페이스를 추출하면 공통 코드가 아닌 공통 인터페이스만을 분리할 수 있다. 즉, 클래스에 중복 코드가 포함되어 있는 경우는 인터페이스를 추출해도 중복 제거에는 도움이 되지 않는다.
반면, 클래스 추출을 적용하여 중복을 포함한 동작을 별도의 컴포넌트로 이동시키고 모든 작업을 위임하면 이 문제를 해결할 수 있다. 공통 동작의 크기가 큰 경우, 언제든 상위클래스 추출을 적용할 수 있다. 물론 이 방법이 훨씬 더 쉽지만, 부모클래스는 하나만 생성할 수 있다는 점을 기억하자.
- Before
class Employee {
getRate() {
// ...
}
getName() {
// ...
}
getDepartment() {
// ...
}
hasSpecialSkill() {
// ...
}
}
- After
interface Billable {
getRate: () => number;
hasSpecialSkill: () => string;
}
class Employee implements Billable {
getRate() {
// ...
}
getName() {
// ...
}
getDepartment() {
// ...
}
hasSpecialSkill() {
// ...
}
}
계층 구조 축소하기 (Collapse Hierarchy)
서브클래스가 상위클래스와 실질적으로 동일한 클래스 계층 구조가 되어버린 경우
서브클래스와 상위클래스를 하나로 합친다.
시간이 지나면서 프로그램이 성장하고, 서브클래스와 슈퍼클래스가 거의 동일해지는 경우에 이를 적용할 수 있다.
단, 이 경우 리스코프 대체 원칙을 위반할 수도 있다는 점에 주의하라. 예를 들어, 실수로 Transport
슈퍼클래스를 Car
서브클래스로 합쳐버리면, Plane
클래스가 Car
의 자식 클래스가 되어버린다.
- Before
class Employee {
// ...
}
class Salesman extends Employee {
// ...
}
- After
class Employee {
// ...
}
템플릿 메서드 생성 (Form Template Method)
내 서브클래스들이 모두 동일한 순서로 유사하게 진행되는 과정을 포함하는 알고리즘을 구현하고 있는 경우
알고리즘 구조와 그 동일한 과정을 슈퍼클래스로 옮기고, 서로 다른 방식의 구현에 대해서는 서브클래스에 따로 구축시킨다.
서브클래스는 여러 사람들이 동시에 개발하는 경우가 많기 때문에, 코드가 중복되고 오류가 발생하며, 변경할 때마다 모든 서브클래스에 적용함에 따라 유지 관리에 어려움이 발생할 수 있다.
중복 제거가 항상 코드의 복사/붙여넣기를 없애는 것을 의미하는 것은 아니다. 보다 상위 수준의 중복을 제거하는 것 역시 포함된다.
템플릿 메서드 생성은 개방/폐쇄 원칙이 실제로 작동하는 예다. 새로운 알고리즘 버전이 나타나면 기존 코드를 변경할 필요 없이 새로운 서브클래스를 만들기만 하면 된다.
- Before
class Site {
// ...
}
class ResidentialSite extends Site {
getBillableAmount() {
const base = this.units * this.rate;
const tax = base * this.taxRate;
return base + tax;
}
}
class LifelineSite extends Site {
getBillableAmount() {
const base = this.units * this.rate * 0.5;
const tax = basse * this.taxRate * 0.2;
return base + tax;
}
}
- After
class Site {
getBillableAmount() {
return this.getBaseAmount() + this.getTaxAmount();
}
getBaseAmount() {
return this.units * this.rate;
}
getTaxAmount() {
return this.getBaseAmount() * this.taxRate;
}
}
class ResidentialSite extends Site {
getBaseAmount() {
return this.units * this.rate;
}
getTaxAmount() {
return this.getBaseAmount() * this.taxRate;
}
}
class LifelineSite extends Site {
getBaseAmount() {
return this.units * this.rate * 0.5;
}
getTaxAmount() {
return this.getBaseAmount() * this.taxRate * 0.2;
}
}
상속을 위임으로 바꾸기 (Replace Inheritance with Delegation)
서브클래스가 슈퍼클래스 메서드 중 일부만을 사용하거나, 슈퍼클래스의 데이터를 상속할 수 없는 경우
필드를 생성하여 그 안에 슈퍼클래스 객체를 넣고, 메서드는 슈퍼클래스 객체에게 위임한 다음, 상속을 제거한다.
상속을 조합(composition)으로 대체하면 클래스 디자인을 크게 개선할 수 있다.
- 서브클래스가 리스코프 치환 원칙을 위반하는 경우, 즉 상속이 공통 코드를 결합하기 위한 목적으로만 구현되었으나 서브클래스가 슈퍼클래스의 확장이기 때문에 상속을 구현할 수 없는 경우
- 서브클래스가 슈퍼클래스의 메서드 중 오직 일부만을 사용하는 경우, 이 때는 해당 서브클래스 객체를 통해 누군가 호출해선 안되는 슈퍼클래스의 메서드를 실행하는 것은 시간 문제다.
본질적으로, 이 테크닉은 두 클래스를 분할하고 슈퍼클래스를 부모가 아닌, 서브클래스의 도우미의 역할로 바꾼다. 모든 슈퍼클래스의 메서드를 상속하는 대신, 서브클래스는 슈퍼클래스의 메서드 중 필요한 것만을 호출한다.
- Before
class Vector {
isEmpty() {
// ...
}
}
class Stack extends Vector {
// ...
}
- After
class Vector {
isEmpty() {
// ...
}
}
class Stack {
private _vector: Vector;
isEmpty() {
return this._vector.isEmpty();
}
}
위임을 상속으로 바꾸기 (Replace Delegation with Inheritance)
한 클래스가 간단한 여러 메서드들을 모두 다른 클래스의 모든 메서드들에 위임하고 있는 경우
클래스를 현재 위임하고 있는 대상에 대한 상속자로 만들면, 이러한 메서드 위임이 불필요해진다.
위임은 위임이 구현되는 방식을 변경할 수 있고, 다른 클래스 또한 배치할 수 있기 때문에 상속보다 더 유연한 접근 방식이다. 하지만 하나의 클래스와 그 클래스의 모든 공용 메서드에만 작업을 위임한다면, 위임의 이점이 사라진다.
이 경우, 위임을 상속으로 대체하면 수많은 위임 메서드를 정리할 수 있고, 위임 클래스에 새로운 메서드가 추가될 때마다 이에 따라 메서드를 추가로 생성하지 않아도 된다. 이에 따라 결국 코드의 길이도 줄어든다.
다만, 클래스가 위임 클래스의 공용 메서드 중 일부에 대해서만 위임을 하고 있는 경우에는 이 테크닉을 사용할 수 없다. (= 리스코프 치환 원칙 위반) 또한, 부모 클래스가 없는 경우에 대해서만 적용 가능하다.
- Before
class Employee {
private _person: Person;
getName() {
return this._person.getName();
}
}
class Person {
getName() {
// ...
}
}
- After
class Person {
getName() {
// ...
}
}
class Employee extends Person {
// ...
}
What is GLTF
Overview
glTF는 Khronos Group에 의해 디자인 및 정의된 것으로, 네트워크를 통한 3D 컨텐츠의 효율적인 전송을 위한 것이다. glTF의 코어는 JSON 파일이며, 3D 모델들을 포함한 하나의 씬 구조와 구성을 나타낸다.
scenes, nodes - 씬의 기본 구조
cameras - 씬의 뷰 설정
meshes - 3D 오브젝트의 기하학적 구조 (=Geometry)
buffers, bufferViews, accessors - 데이터 레퍼런스와 데이터 레이아웃 설명
materials - 어떻게 오브젝트가 렌더링되어야 하는지에 대한 정의
textures, images, samplers - 오브젝트의 표면 모양
skins - 버텍스 스키닝을 위한 정보
animations - 시간 경과에 따른 프로퍼티 변경
이러한 요소들은 배열에 포함되어 있으며, 오브젝트 간의 참조는 인덱스를 통해 이루어진다.
또한, 모든 에셋을 하나의 단일 바이너리 glTF 파일로 저장하는 것이 가능하다.(=glb) 이 경우, JSON 데이터는 버퍼 또는 이미지의 바이너리 데이터에 해당하는 문자열로 저장된다.
Concepts
glTF 에셋의 탑-레벨 요소 간 관계는 아래와 같다.
바이너리 데이터 참조
glTF 에셋의 이미지와 버퍼는 외부 파일을 참조할 수 있다.
"buffers": [
{
"uri": "buffer01.bin",
"byteLength": 102040,
},
],
"images": [
{
"uri": "image01.png"
}
]
buffers는 지오메트리 또는 애니메이션 데이터를 포함하는 바이너리 파일을 가리킨다.
images는 모델의 텍스쳐 데이터를 담고있는 이미지 파일을 가리킨다.
데이터는 URI를 통해 참조될 수 있으나, 데이터 URI를 통해 JSON 내에서 직접 추가될 수도 있다. 데이터 URI는 아래의 형태로 MIME 타입을 정의하며, base64 인코딩된 데이터를 포함한다.
// Buffer data:
"data:application/gltf-buffer;base64,AAABAAIAAgA..."
// Image data:
"data:image/png;base64,iVBORw0K..."
Scenes, Nodes
glTF JSON은 씬(scene)들을 포함할 수 있다. (+ 선택적으로, 기본 씬을 설정)
각각의 씬은 노드의 인덱스 목록을 포함할 수 있다. 각각의 노드는 자식(children)의 인덱스 목록을 포함할 수 있다.
"scene": 0,
"scenes": [
{
"nodes": [0, 1, 2] // 해당 씬이 루트가 됨
}
],
"nodes": [
{
"children": [3, 4], // 첫번째 씬에 의해 참조
...
}
{
...
},
{
...
},
{
... // 첫번째 노드에 의해 참조
},
{
... // 첫번째 노드에 의해 참조
}
]
하나의 노드는 하나의 로컬 트랜스폼을 포함할 수 있다. 이는 하나의 열 우선(column-major) 행렬 배열로 주어지거나, 각각의 변환(translation), 회전(rotation), 스케일(scale)로 전달될 수 있다.
이 때, rotation은 쿼터니언의 형태로 전달된다.
이로부터, 한 노드의 전역 트랜스폼은 루트에서 각 노드까지의 경로에 있는 모든 로컬 트랜스폼의 곱을 통해 구할 수 있다.
"nodes": [
{
// 하나의 행렬로 주어지거나
"matrix": [
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
1.0, 2.0, 3.0, 1.0
],
// ...
},
{ // 또는 TRS를 각각 따로 전달
"translation": [1.0, 2.0, 3.0],
"rotation": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0],
// ...
}
]
각 노드는 하나의 메시 또는 카메라를 가리킬 수 있다. 이는 각각의 메시(mesh)와 카메라(camera)의 인덱스로 전달된다.
해당 요소들은 이 노드에 연결되며, 이 요소의 인스턴스들은 생성된 이후 노드의 전역 트랜스폼에 기반하여 트랜스폼된다.
"nodes": [
{
"mesh": 4,
// ...
},
{
"camera": 2,
// ...
}
]
노드의 TRS 프로퍼티는 한 애니메이션의 타겟이 될 수도 있다. 이 경우 애니메이션은 시간의 경과에 따라 한 속성이 어떻게 변화하는지에 대해 정의한다. 연결된 오브젝트들은 이를 따라, 움직이는 오브젝트 또는 카메라 비행을 모델링할 수 있다.
노드들은 버텍스 스키닝(Vertex Skinning)에 사용될 수도 있다. 한 노드 구조는 한 애니메이션 캐릭터의 스켈레톤을 정의할 수 있다. 이 경우, 노드는 하나의 메쉬와 하나의 스킨을 참조하게 되며, 참조된 스킨은 현재의 스켈레톤 포즈에 기반하여 메쉬가 어떻게 디폼(deform)되는지에 대한 추가적인 정보를 담게 된다.
Meshes
메쉬(mesh)는 하나 이상의 프리미티브(primitive)를 포함한다. 프리미티브는 메쉬를 렌더링하는데에 필요한 기하학적 데이터를 참조한다.
"meshes": [
{
"primitives": [
{
"mode": 4,
"indices": 3,
"attributes": {
"POSITION": 0,
"NORMAL": 2,
},
"material": 0
}
]
}
]
각각의 메쉬 프리미티브는 렌더링 모드를 갖는다. 이는 렌더링이 POINTS
, LINES
, TRIANGLES
등 어떤 방식으로 이루어지는지를 정의한다.
프리미티브는 또한 이 데이터에 대한 액세서의 인덱스를 사용하여 인덱스(index, indices)와 버텍스의 어트리뷰트(attributes)을 참조한다.
렌더링에 사용될 머테리얼(material) 역시 주어져야 하며, 이는 머테리얼의 인덱스로 전달된다.
각 어트리뷰트는 어트리뷰트 데이터를 포함하는 액세서의 인덱스와 어트리뷰트명을 매핑하여 정의된다. (ex. POSITION
은 0
번 액세서를 참조한다.)
이 데이터는 메쉬를 렌더링할 때 버텍스 어트리뷰트로 사용된다.
예를 들어, 버텍스들의 POSITION
과 NORMAL
을 정의하는 어트리뷰트는 다음의 형태가 된다.
메쉬는 여러 개의 모프 타겟(Morph Target)을 정의할 수 있다. 이러한 모프 타겟은 원본 메쉬의 디포메이션을 정의한다.
{
"primitives": [
{
...
"targets": [
{
"POSITION": 11,
"NORMAL": 13,
},
{
"POSITION": 21,
"NORMAL": 23
}
]
}
],
"weights": [0, 0.5]
}
메쉬에 모프 타깃을 정의하기 위해, 각각의 메쉬 프리미티브는 타겟(target)의 배열을 가질 수 있다.
이는 어트리뷰트의 명칭을 대상 지오메트리의 변위(displacement)를 포함하는 액세서의 인덱스에 매핑하는 딕셔너리다.
메쉬는 또한 가중치(weight)의 배열을 가질 수 있고, 이는 각 모프 타깃이 최종적으로 렌더링되는 메시의 상태에 어느 정도 기여할 것인지를 정의한다.
다른 가중치를 가진 여러 개의 모프 타깃을 조합하는 것도 가능하다. 예를 들어, 한 캐릭터의 다양한 표정에 대한 모델링을 하는 경우가 이에 해당한다. 이 때는 애니메이션으로 가중치를 수정하여 지오메트리의 여러 상태 간에 보간할 수 있다.
Buffers, BufferViews, Accessors
버퍼(buffer)는 3D 모델, 애니메이션, 스키닝에서 기하학적으로 사용되는 데이터를 담고 있다.
버퍼 뷰(bufferView)는 이 데이터의 구조적인 정보를 포함한다.
액세서(accessor)란 데이터의 정확한 타입과 레이아웃을 정의한다.
"buffers": [
{
"byteLength": 35,
"uri": "buffer01.bin"
}
],
"bufferViews": [
{
"buffer": 0, // 위쪽 buffers 배열의 첫번째를 참조
"byteOffset": 4,
"byteLength": 28,
"byteStride": 12,
"target": 34963,
}
],
"accessors": [
{
"bufferView": 0, // 위쪽 bufferViews 배열의 첫번째를 참조
"byteOffset": 0,
"type": "VEC2",
"componentType": 5126,
"count": 2,
"max": [0.1, 0.2],
"min": [0.9, 0.8]
}
]
각각의 버퍼는 URI를 통해 바이너리 데이터 파일을 참조한다. 이는 주어지는 byteLength의 길이를 가진 raw data 한 블록의 소스에 해당한다.
각각의 버퍼 뷰는 하나의 버퍼를 참조한다. 여기에는 byteOffest과 byteLength가 포함되어 있으며, 이는 해당 버퍼 뷰에 해당되는 버퍼의 부분을 정의한다. 또, 옵셔널한 OpenGL 버퍼 타겟(target)을 가질 수 있다.
액세서는 버퍼 뷰의 데이터가 어떻게 해석되어야 하는지를 정의한다. 여기서 버퍼 뷰의 시작을 나타내는 추가적인 byteOffset을 가질 수 있으며, 버퍼 뷰 데이터의 타입과 레이아웃에 대한 정보를 포함한다.
위의 예시의 경우, type이 VEC2
이고, componentType
이 5126
(= GL_FLOAT
)이므로, 부동소수점 값의 2D 벡터가 정의된다. 모든 값의 범위는 min
과 max
속성을 통해 정의된다.
여러 개의 액세서를 가진 데이터는 버퍼뷰 내부에서 인터리빙(interleave)될 수 있다. 이 경우, 버퍼 뷰는 byteStride 속성을 가지며, 이는 액세서의 한 요소의 시작점과 다음 요소의 시작점 만큼의 바이트 간격을 의미한다.
위의 경우를 예시로 들면, 해당 데이터는 2D 텍스쳐 좌표에 엑세스하기 위한 메쉬 프리미티브로써 사용될 수 있다.
bufferView 데이터는 glBindBuffer
를 사용하여 OpenGL buffer로 바운드할 수 있다.
이 경우, accessor의 프로퍼티들은 glVertexAttribPointer
에 전달하여 bufferView의 buffer가 바운딩 되었을 때, 이 buffer를 버텍스 어트리뷰트 데이터로 정의하기 위해 사용될 수 있다.
Sparse Accessors
액세서의 일부 요소만이 기본값과는 다른 경우, (주로 모프 타겟에서) sparse
를 통해 매우 컴팩트하게 데이터를 전달할 수 있다.
액세서는 데이터의 타입(여기서는 스칼라 float값)과 전체 요소의 count
를 정의한다.
sparse 데이터는 sparse 데이터 블록이 포함하는 sparse 데이터 요소의 count
를 포함한다.
values는 sparse 데이터 값을 포함하는 bufferView를 참조한다.
sparse 데이터 값에 대한 타겟 indices는 bufferView와 componentType에 대한 참조로 정의된다.
위의 예시 이미지 처럼, values
는 주어진 indices
의 위치에 따라 최종 액세서 데이터를 덮어씌운다.
materials
각 메쉬 프리미티브는 glTF 에셋 내에 포함된 머테리얼(material) 중 하나를 참조할 수 있다. 머테리얼은 오브젝트가 어떻게 렌더링 되어야 하는지에 대해 물리적 머테리얼 속성들에 기반하여 정의한다. 이를 통해 PBR(Physically Based Rendering) 테크닉을 적용할 수 있으며, 저마다 다른 렌더러들 간에도 렌더된 오브젝트의 형상이 일정하도록 보장해준다.
기본 머테리얼 모델은 Metallic-Roughness-Model이며, 이는 각각 0
에서 1
사이의 값으로 오브젝트의 표면이 얼마나 금속같은지, 얼마나 거친지에 대한 재질 특성을 나타낸다.
이 속성들은 오브젝트 전체에 단일한 값으로 적용될 수도 있고, 텍스처에 의해 참조될 수도 있다.
"materials": [
{
"pbrMetallicRoughness": {
"baseColorTexture": {
"index": 1,
"texCoord": 1
},
"baseColorFactor": [1.0, 0.75, 0.35, 1.0],
"metallicRoughnessTexture": {
"index": 5,
"texCoord": 1,
},
"metallicFactor": 1.0,
"roughnessFactor": 0.0,
},
"normalTexture": {
"scale": 0.8,
"index": 2,
"texCoord": 1
},
"occlusionTexture": {
"strength": 0.9,
"index": 4,
"texCoord": 1
},
"emissiveTexture": {
"index": 3,
"texCoord": 1
},
"emissiveFactor": [0.4, 0.8, 0.6]
}
]
Metallic-Roughness-Model 머테리얼은 pbrMetallicRoughness 객체에 정의된다.
baseColorTexture는 오브젝트에 적용될 메인 텍스처다. baseColorFactor는 색의 RGBA에 대한 스케일링 팩터를 담고 있다. 따로 텍스처가 사용되지 않는다면, 오브젝트 전체가 해당 색이 적용될 것이다.
metallicRoughnessTexture는 "blue" 컬러 채널에 metalness 값을, "green" 컬러 채널에 roughness 값을 담고 있는 텍스처를 의미한다. metallicFactor와 roughnessFactor는 이러한 값에 해당 수치 만큼 곱셈을 적용한다. 따로 텍스처가 주어지지 않는다면, 오브젝트 전체에 해당 reflection 특성이 적용된다.
Metallic-Roughness-Model을 통해 정의된 프로퍼티들에 더해, 머테리얼은 오브젝트 형상에 적용될 다른 프로퍼티들도 포함할 수 있다.
- normalTexture는 접선공간(tangent-space)의 법선 정보를 담고 있는 텍스처를 참조하며, 이러한 노말에 적용될 scale 팩터가 같이 사용된다.
- occlusionTexture는 빛으로부터 차단되어(occluded) 더 어둡게 렌더되어야 하는 표면 영역에 대한 텍스처를 참조한다. 해당 정보는 텍스처의 "red" 컬러 채널을 사용한다. 오클루전의 strengh는 해당 값에 적용될 스케일링 팩터의 역할을 한다.
- emessiveTexture는 오브젝트 표면에서 발광(illuminate)을 일으키는 부분을 나타내는 텍스처를 참조하며, 해당 텍스처에서는 표면에서 빛나게 될 부분과, 그 빛의 색을 정의한다. emissiveFactor는 이 텍스처의 RGB에 곱해지는 스케일링 팩터에 해당한다.
Material Properties in textures
"meshes": [
{
"primitives": [
{
"material": 2, // 아래의 index 2에 해당하는 `brushed gold` 머테리얼을 참조
"attributes": {
"NORMAL": 3,
"POSITION": 1,
"TEXCOORD_0": 2,
"TEXCOORD_1": 5 // 아래 머테리얼의 `pbrMetallicRoughness.baseColorTexture.texCoord`에 의해 참조
},
}
]
}
]
"materials": [
...
{
"name": "brushed gold",
"pbrMetallicRoughness": {
"baseColorFactor": [1, 1, 1, 1],
"baseColorTexture": {
"index": 1, // 아래 쪽의 index 1의 텍스처를 참조
"texCoord": 1 // 위의 `TEXCOORD_1`을 참조
},
"metallicFactor": 1.0,
"roughnessFactor": 1.0
}
}
]
"textures": [
...
{ // 해당 텍스처는 위쪽의 `material.baseColorTexture.index`에 의해 참조됨
"source": 4,
"sampler": 2
}
]
하나의 머테리얼 내 텍스처 참조는 항상 텍스처의 index를 포함해야 한다.
또, 특정 index가 설정된 texCoord를 포함하기도 한다.
이는 해당 텍스처의 텍스처 좌표(coordinate)를 포함하는 메쉬 프리미티브의 어트리뷰트(TEXCOORD_<n>
)를 결정하는 값이며, 이는 기본값으로는 0
이다.
Cameras
각 노드에 glTF 에셋에 정의된 카메라 중 하나를 참조할 수 있다.
"cameras": [
{
"type": "perspective",
"perspective": {
"aspectRatio": 1.5,
"yfov": 0.65,
"zfar": 100,
"znear": 0.01
}
},
{
"type": "orthographic",
"orthographic": {
"xmag": 1.0,
"ymag": 1.0,
"zfar": 100,
"znear": 0.01
}
}
]
카메라에는 두가지 타입, perspective와 orthographic이 있으며, 이는 프로젝션 매트릭스(projection matrix)를 정의한다.
zfar는 perspective 카메라의 far clipping plane 거리의 값에 해당하며, 이는 옵셔널하다. 해당 값이 없다면, 카메라가 무한한 프로젝션에 사용되는 특별한 프로젝션 매트릭스를 사용된다.
한 노드가 한 카메라를 참조할 때, 카메라 인스턴스가 생성된다. 이 인스턴스의 카메라 매트릭스는 곧 노드의 전역 트랜스폼 매트릭스에 전달된다.
Textures, Images, Samplers
textures는 렌더될 오브젝트에 적용될 텍스처에 대한 정보를 담는다. 머테리얼은 오브젝트의 기본 컬러 뿐만 아니라 오브젝트 형상에 영향을 미칠 물리적 속성을 정의하기 위해서 텍스처를 참조한다.
"textures": [
{
"source": 4, // 아래의 index 4에 해당하는 `file01.png` 이미지를 참조
"sampler": 2 // 아래의 index 2에 해당하는 `samplers`를 참조
}
...
]
"images": [
...
{
"uri": "file01.png"
},
{
"bufferView": 3,
"mimeType": "image/jpeg"
}
]
"samplers": [
...
{
"magFilter": 9729,
"minFilter": 9987,
"wrapS": 10497,
"wrapT": 10497
}
]
텍스처는 에셋 이미지 중 하나를 가리키는 텍스처 소스(source)에 대한 참조와, 샘플러(sampler)에 대한 참조로 구성된다.
images는 텍스처에 사용될 이미지 데이터를 정의한다. 이 데이터는 이미지 파일 위치를 가리키는 uri 속성을 통해 참조할 수도 있고, bufferView에 대한 참조와 버퍼 뷰에 저장된 이미지 데이터의 타입을 정의하는 MIME Type 속성을 통해 참조할 수도 있다.
samplers는 텍스처의 스케일링과 텍스처 좌표의 래핑을 정의한다. (= glTexParameter
에 넘겨지는 OpenGL 상수값)
Skins
glTF 에셋은 버텍스 스키닝을 수행하는 데에 있어 필수적인 정보들을 담을 수 있다. 버텍스 스키닝은 현재 pose에 기반한 스켈레톤의 bones에 따라 메쉬의 버텍스를 변형할 수 있도록 해준다.
"nodes": [
{
"name": "Skinned mesh node",
"mesh": 0,
"skin": 0,
},
...
{
"name": "Torso",
"children": [2, 3, 4, 5, 6],
"rotation": [...],
"scale": [...],
"translation": [...]
},
...
{
"name": "LegL",
"children": [7],
},
...
{
"name": "FootL",
...
},
...
],
"meshes": [
"primitives": [
{ // 위쪽 첫번째 노드인 "Skinned mesh node"가 참조하는 mesh
"attributes": {
"POSITION": 0,
"JOINTS_0": 1,
"WEIGHTS_0": 2
...
}
}
]
]
"skins": [
{ // 위쪽 첫번째 노드인 "Skinned mesh node"가 참조하는 skin
"inverseBindMatrices": 12,
"joints": [1, 2, 3 ...],
}
]
하나의 노드는 mesh를 참조할 수 있고, 또한 skin을 참조할 수도 있다.
skins는 joints의 배열을 담고 있으며, 이는 스켈레톤 구조를 정의하는 노드들의 인덱스를 의미한다. 또, inverseBindMatrices는 각 joint 당 하나의 매트릭스를 포함하는 액세서에 대한 참조를 의미한다.
스켈레톤 구조는 씬 구조와 마찬가지로 노드에 의해 구성된다. 각 joint 노드는 로컬 트랜스폼과 자식 배열, 그리고 joint 간의 연결로서 스켈레톤의 "뼈대(bones)"를 간접적으로 정의한다.
스키닝된 메쉬의 메쉬 프리미티브는 POSITION
어트리뷰트를 가지는데, 이는 버텍스 위치에 대한 액세서를 나타낸다.
그 외에 두가지 특별한 어트리뷰트가 스키닝에 요구되는데, 이는 JOINTS_0
와 WEIGHTS_0
이다. 이는 각각 하나의 액세서를 참조한다.
JOINT_0
어트리뷰트 데이터는 버텍스에 영향을 주어야 하는 joint의 인덱스를 나타내는 값이다.
WEIGHT_0
어트리뷰트 데이터는 버텍스에 영향을 주어야 하는 joint의 가중치를 나타내는 값열이다.
이러한 정보들을 바탕으로, 스키닝 매트릭스(skinning matrix)가 계산될 수 있다. 이에 대한 디테일은 아래의 Computing the skinning matrix 섹션에서 설명한다.
Computing the skinning matrix
스키닝 매트릭스는 스켈레톤의 현재 포즈에 기반하여 메쉬의 버텍스가 어떻게 변형될지를 나타내는 매트릭스이다. 스키닝 매트릭스는 조인트 매트릭스(joint matrix)들의 가중치를 적용한 합에 해당한다.
Computing the joint matrices
스킨은 inverseBindMatrices를 참조한다. 이는 각 joint에 대한 역행렬을 포함하는 액세서를 나타낸다. 이 매트릭스들은 메쉬를 조인트의 로컬 공간으로 변환시키는 데 사용된다.
스킨의 joints에 포함된 인덱스에 해당하는 각각의 노드에 대해, 전역 트랜스폼 매트릭스를 계산할 수 있다. 이는 조인트의 현재 글로벌 트랜스폼을 기반으로 조인트의 로컬 공간으로부터 메쉬를 변형하며, 이를 globalJointTransform이라고 한다.
이 매트릭스들로부터, jointMatrix는 각 조인트에 대해 다음과 같이 계산될 수 있다.
jointMatrix[j] = inverse(globalTransform) * globalJointTransform[j] * inverseBindMatrix[j]
메쉬와 스킨을 포함하는 노드의 모든 글로벌 트랜스폼은 조인트 행렬에 이 트랜스폼의 역을 미리 곱하여 상쇄한다.
OpenGL 또는 WebGL에 기반한 구현의 경우, jointMatrix 배열은 버텍스 셰이더에 유니폼으로서 전달된다.
Combining the joint matrices to create the skinning matrix
스키닝된 메쉬의 프리미티브는 POSITION
, JOINT
, WEIGHT
어트리뷰트들을 포함하며, 이들은 모두 액세서들을 참조한다.
이 액세서들은 각 버텍스마다 하나의 요소를 포함한다.
이 액세서들의 데이터는 jointMatrix
배열과 함께 버텍스 셰이더에 어트리뷰트로 전달된다.
버텍스 셰이더에서는 skinMatrix
가 계산된다.
이는 인덱스가 JOINTS_0
어트리뷰트에 포함된 조인트 행렬의 선형 조합(linear combination)으로, WEIGHTS_0
값으로 가중치가 부여된다.
// Vertex Shader
uniform mat4 u_jointMatrix[12];
attribute vec4 a_position;
attribute vec4 a_joint;
attribute vec4 a_weight;
...
void main(void) {
...
mat4 skinMatrix =
a_weight.x * u_jointMatrix[int(a_joint.x)] +
a_weight.y * u_jointMatrix[int(a_joint.y)] +
a_weight.z * u_jointMatrix[int(a_joint.z)] +
a_weight.w * u_jointMatrix[int(a_joint.w)];
gl_Position = modelViewProjection * skinMatrix * position;
}
model-view-perspective 매트릭스로 변형하기 전에, skinMatrix
가 먼저 스켈레톤 포즈에 기반하여 버텍스를 변형한다.
Animations
glTF 에셋은 애니메이션(animations)을 포함할 수 있다. 애니메이션은 노드의 로컬 트랜스폼이 정의된 노드의 프로퍼티 또는 모프 깃의 가중치에 적용될 수 있다.
"animations": [
{
"channels": [
{
"target": {
"node": 1,
"path": "translation"
},
"sampler": 0 // 아래의 samplers[0]을 참조
}
],
"samplers": [
{
"input": 4,
"interpolation": "LINEAR",
"output": 5
}
]
}
]
각각의 애니메이션은 두 요소를 갖는다. 하나의 채널(channels) 배열과 하나의 샘플러(samplers) 배열이다.
각 채널은 애니메이션의 타겟(target)을 정의한다. 이 타겟은 대부분 인덱스를 통해 하나의 노드(node)를 참조하고, 패스(path)는 해당 노드의 어떤 프로퍼티가 애니메이션의 대상인지를 정의한다.
패스는 노드에 로컬 트랜스폼을 적용하는 translation
, rotation
, 또는 scale
이 되거나, 또는 노드에 의해 참조되는 메쉬의 모프 타겟의 가중치에 애니메이션을 적용하기 위해선 weights
가 된다.
채널은 또한 하나의 샘플러(sampler)를 참조하며, 이는 실제 애니메이션 데이터를 요약한다.
샘플러는 입력(input)과 출력(output)을 가지며, 데이터를 제공하는 액세서의 인덱스를 통해 참조한다.
input은 스칼라 부동소수점을 가진 액세서를 참조하며, 애니메이션의 키프레임에 해당하는 시간들에 해당한다.
output은 각 키프레임에서 애니메이션이 적용되는 프로퍼티 값을 담고 있는 액세서를 참조한다.
샘플러는 또한 애니메이션의 보간(interpolation) 모드를 정의하며, 여기서는 LINEAR
, STEP
, CUBICSPLINE
이 될 수 있다.
Animation samplers
애니메이션이 진행되는 동안에, "전역(global)" 애니메이션 시간이 초 단위로 증가한다.
애니메이션 샘플러의 input
액세서의 데이터는 키 프레임 시간을 담고있다.
애니메이션 샘플러의 output
액세서의 데이터는 애니메이션이 적용된 프로퍼티의 키 프레임 값을 담고있다.
sampler는 input 데이터에서 현재 시간에 대한 키프레임을 탐색한다.
output 데이터에서 해당 키프레임에 상응하는 데이터를 읽고, 샘플러의 interpolation 모드에 따라 적절한 방식으로 보간한다.
보간된 값은 애니메이션 채널의 타겟에 적용된다.
Animation channel targets
애니메이션 샘플러로부터 전달되는 보간된 값은 다른 형태의 애니메이션 채널 타겟에 따라 다른 형태로 적용된다.
노드에 연결된 메쉬의 프리미티브에 정의된 모프 타깃에 적용된 weights 애니메이션은 다음과 같은 형태로 동작한다.
Binary glTF files
일반적인 glTF 포맷의 경우, 버퍼 데이터와 텍스처 같은 외부 바이너리 리소스를 포함하는 두 가지 방법이 있다.
하나는 URI를 통해 참조하는 방법이고, 다른 하나는 데이터 URI를 통해 인라인으로 포함하는 방법이다.
URI를 통해 참조하는 경우, 각각의 외부 리소스에 대한 다운로드 요청이 이루어져야 한다.
데이터 URI로 첨부되는 경우, 바이너리 데이터에 대한 base64 인코딩이 필요하며, 이 경우 파일 사이즈가 상당히 커질 수 있다.
이러한 단점을 극복하기 위해, glTF JSON과 바이너리 데이터를 하나의 단일 바이너리 glTF 파일로 합치는 방법이 있다.
이는 리틀-엔디언(little-endian) 파일이며, .glb
확장자를 가진다.
여기엔 헤더(header)가 포함되는데, 이는 데이터의 버전과 구조에 대한 기본 정보를 제공한다.
또, 하나 이상의 청크(chunks)가 있으며, 여기에 실질적인 데이터가 포함된다.
첫번째 청크는 항상 JSON 데이터를 포함하며, 그 외의 나머지 청크에는 바이너리 데이터가 포함된다.
Extensions
glTF 포맷은 새로운 기능의 추가 또는 일반적으로 사용되는 프로퍼티의 정의를 단순화하기 위해 익스텐션을 허용한다.
"extensionsUsed": [
"KHR_lights_common",
"CUSTOM_EXTENSION"
],
"extensionsRequired": [
"KHR_lights_common"
],
"textures": [
{
...
"extensions": {
"KHR_lights_common": {
"lightSource": true,
},
"CUSTOM_EXTENSION": {
"customProperty": "customValue"
}
}
}
]
익스텐션이 glTF 에셋에서 사용되는 경우, 최상위에 extensionsUsed
배열에 익스텐션의 이름이 추가되어야 한다.
extensionsRequired
배열에 추가된 익스텐션의 경우 에셋을 적절하게 불러오기 위해 엄격하게 요구된다.
익스텐션은 다른 오브젝트의 extensions
프로퍼티 내에서 임의의 오브젝트를 추가하는 것을 허용한다.
해당 오브젝트의 명칭은 익스텐션의 이름과 동일하며, 익스텐션에서 임의로 사용하는 프로퍼티가 추가로 있을 수 있다.
Khronos Group에 의해 유지보수되는 익스텐션 목록
전체 목록은 여기를 확인하자.
- Specular-Glossiness Materials
기본 Metallic-Roughness 머테리얼 모델의 대체재로, specular와 glossiness 값에 기반하여 머테리얼 속성을 정의한다.
- Unlit Materials
물리적인 빛 연산이 필요없는 머테리얼을 정의할 수 있도록 해준다.
- Punctual Lights
씬 구조에 다양한 형태의 라이트를 추가할 수 있도록 해준다. Point/Spot/Directional 이 있으며, 이러한 라이트들은 씬 구조의 노드에 첨부될 수 있다.
- Texture transforms
텍스처에 대한 오프셋, 회전, 스케일을 정의할 수 있도록 해준다. 이를 이용하여 텍스처 아틀라스(texture atlas)를 생성하기 위해 여러 텍스처를 결합할 수 있다.