-
화면 응답 개선하기네트워크 & 인프라 2022. 4. 14. 19:31
이번엔 화면응답속도를 개선하는 방법에 대해 알아보자.
화면 응답 속도를 개선하는 방법에는 인터넷구간 성능 개선을 통한 방법과 데이터 조회 성능 개선의 방법이 존재한다.
인터넷 구간 성능 개선하기
인터넷 구간 성능을 개선하기 위해서는 캐싱설정, CDN 사용, keep-alive 설정, gzip 압축, 이미지 압축, 불필요한 다운로드 제거, 불필요한 작업 지연로딩, 스크립트 병합하여 요청수 최소화, HTTP 프로토콜 개선 등의 방법이 존재한다.
- Reverse Proxy
- 캐싱설정
- keep-alive 설정
- gzip 압축
- HTTP 프로토콜 개선 (HTTP2를 사용하여 하나의 TCP 연결로 다수의 클라이언트 요청 처리)
- JS
- 불필요한 다운로드 제거
- 불필요한 작업을 지연로딩
- 스크립트 병합하여 요청수 최소화
Application Host와 WebServer
우리는 역할을 분리하기 위해 Web Server와 Application Host를 구성한다.
- Tomcat (Application Host) : 실제 비지니스 로직, 복잡한 연산, 동영상 데이터, 데이터베이스 처리 ( scale out이 필요할 수 있음)
- Nginx (WEB SERVER) : 작은 데이터를 대량 전송, 하드디스크 읽기 쓰기 발생 하지 않을 때 (ex)TLS 암호화, 커넥션 관리, 압축역할 위임, 캐싱(정적리소스)위임, 로드밸런서 역할 (WAS 여러개))
- Reverse Proxy
- Load Balancer
1) Servlet ( Tomcat )
웹 서버는 클라이언트 요청에 따라 소켓을 생성하여 리소스에 접근한 후 응답을 반환하는데 동적으로 데이터 가공이 필요한 경우 다음과 같이 Servlet 컨테이너에게 요청을 위임한 후 결과를 받아 응답하게 된다.
- 사용자가 브라우저를 통해 서버에 HTTP 요청
- 요청받은 서블릿 컨테이너는 request, response 두 객체를 생성
- 서블릿 컨테이너는 요청을 처리할 수 있는 서블릿을 찾아 쓰레드를 할당하고 request, response 객체 전달
- HTTP 메서드에 따라 doGet(), doPost() 메서드 호출
- 요청결과를 응답한 후 request, response 객체를 제거하고 자원을 반납
여기서 서블릿 컨테이너는 각 서블릿객체 하나씩만 생성하므로 이곳에 공유 가변필드를 작성하게 되면 Thread safe 하지 않아 요청필드를 건드리면서 blocking 이슈, 동시성 이슈가 발생할 수 있으므로 불변객체를 최대한 사용해야 한다.
기존의 queue 보관하였던 방법과 다르게 최근의 서블릿 컨테이너(스프링 NIO)는 쓰레드 수보다 많은 요청이 올 경우 다른 채널에서 대기하다가 콜링 이벤트가 발생하면 처리를 받는데 maxConnection(기본 8192)수까지 연결을 수락한다.
2) Nginx
Nginx는 queue에 두고 하나씩 처리하는 싱글 프로세스 쓰레드로 비동기 non-blocking 방식으로 처리되어 다수의 커넥션을 처리할 때 용이하며 커넥션만 처리하고 나머지 동작은 스프링에 위임한다. 실제 데이터를 사용하는 것은 별도의 Thread pool을 사용하는데 I/O 시간이 길어지면 성능이 저하된다.
즉 성능 개선 시 Reverse Proxy에서는 클라이언트와의 커넥션 관리, Tomcat에서는 비지니스 로직 개선, 조회 성능개선, 비동기처리, 데이터 캐싱에 집중한다.
정적 파일 경량화
크롬 브라우저 도구 Network 탭을 사용하여 리소스와 리소스의 속성 (HTTP 헤더, 컨텐츠, 크기 등)을 확인하고 네트워크 대역폭 제한, 브라우저 캐시 비활성화 등을 설정할 수 있다. 정적 파일 경량화는 다음의 방법을 통해 적용할 수 있다.
- 번들 크기 줄이기 : 불필요한 라이브러리 제거 또는 용량 작은 라이브러리로 교체
- Code Splitting
- Dynamic import
- 웹 폰트 최적화
Reverse Proxy 개선
1) gzip 압축
gzip 압축을 적용하는 방식에는 Apache나 Nginx등 웹 서버에서 적용하는 방법, Tomcat 등 WAS에서 적용하는 방법, Servlet Filter를 등록하여 처리하는 방법, 정적인 파일을 미리 gzip으로 압축하는 방법이 존재한다. 여기서는 Nginx (Reverse proxy)를 이용해서 gzip을 적용시켜 보자.
http { gzip on; ## http 블록 수준에서 gzip 압축 활성화 gzip_comp_level 9; gzip_vary on; gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/rss+xml text/javascript image/svg+xml application/vnd.ms-fontobject application/x-font-ttf font/opentype; }
2) Cache
네트워크 상에서 매번 크기가 큰 응답을 전달하는 것은 많은 데이터 비용을 초래하는데 이전 자원을 재사용 캐싱을 통해 이러한 비용을 최적화 할 수 있다.
서버는 ETag 을 HTTP 헤더에 담아 유효 토큰으로 통신하고 이를 통해서 효율적인 자원 업데이트 체크가 가능하도록 한다. 또한 각 자원은 HTTP 헤더의 Cache-Control 을 통해서 캐싱 정책이 정의 되는데 이는 응답을 캐시할 수 있는 사용자, 해당 조건, 기간을 제어한다.
'no-cache' , 'no-store'
Cache-Control: no-cache, no-store
- no-cache : 요청할 떄마다 ETag를 검사 ( max-age = 0 ) -> 최신상태로 유지
- no-store : 캐시를 하지 않음 -> 데이터 유출 방지, 재활용 방지 (ex) 은행 데이터 개인정보))
'public', 'private'
Cache-Control: private, max-age=600
- public : 중간 매체를 포함한 모든 서버에 캐시 가능
- private : 중간 매체들은 캐시 불가능 (ex)최종 유저의 브라우저는 개인정보 페이지 캐싱 가능, CDN은 불가능) -> 개인정보를 보호한다는 의미는 아님
최적의 Cache-Control 정책
캐싱을 최대한 많이, 오래, 가까이에 하도록 하고 각 자원에 대해 최적화된 캐시 설정을 하는 것이 중요하다. 캐시 주기는 이유가 없다면 1년 정도로 설정한다. 캐시 만료기간 전 자원이 업데이트 된다면 파일의 식별자 혹은 버전 숫자를 파일이름에 삽입하여 리소스의 URL을 바꾸고 컨텐츠가 바뀌었다면 새로 다운로드 하도록 강제할 수 있다. 파일 명을 변경하지 않으려면 캐시 무효화 방식으로 해당 이미지만 업데이트할 수도 있다.
CDN
CDN(Content Delivery Network)은 여러노드를 지닌 네크워크에 컨텐츠를 저장하여 제공하는 일종의 프록시 기능을 한다. CDN Edge 서버는 리버스 프록시에 비해 사용자와 가까운 곳에 위치하여 컨텐츠 캐시를 전달하여 RTT(Round Trip Time)을 줄여 컨텐츠를 빠르게 전달할 수 있다. 또한 리버스 프록시 처럼 캐시된 컨텐츠를 전송하여 원 서버의 부하를 줄일 수 있고 인프라 확충에 인력과 경비를 줄일 수 있다. Edge 서버간에 캐시를 공유하기 때문에 롱테일 컨텐츠의 경우에도 CDN을 사용하여 캐시 적중률을 높일 수 있다.
여기서는 css, js, gif등의 자원의 캐시 기간을 한달로 설정하여 저장하였다.
http { ## Proxy 캐시 파일 경로, 메모리상 점유할 크기, 캐시 유지기간, 전체 캐시의 최대 크기 등 설정 proxy_cache_path /tmp/nginx levels=1:2 keys_zone=mycache:10m inactive=10m max_size=200M; ## 캐시를 구분하기 위한 Key 규칙 proxy_cache_key "$scheme$host$request_uri $cookie_user"; server { location ~* \.(?:css|js|gif|png|jpg|jpeg)$ { proxy_pass http://app; ## 캐시 설정 적용 및 헤더에 추가 # 캐시 존을 설정 (캐시 이름) proxy_cache mycache; # X-Proxy-Cache 헤더에 HIT, MISS, BYPASS와 같은 캐시 적중 상태정보가 설정 add_header X-Proxy-Cache $upstream_cache_status; # 200 302 코드는 20분간 캐싱 proxy_cache_valid 200 302 10m; # 만료기간을 1 달로 설정 expires 1M; # access log 를 찍지 않는다. access_log off; } } }
3) 부하분산
리버스 프록시에서 부하분산은 서버 설정을 추가해줌으로서 만들 수 있는데 여기서 scale out하여 추가할 서버에서도 보안 규칙에서 포트를 열어 주어야 한다.
http { upstream app { least_conn; ## 현재 connections이 가장 적은 server로 reqeust를 분배 server 172.17.0.1:8080 max_fails=3 fail_timeout=3s; server [scale-out 서버의 public ip]:8081 max_fails=3 fail_timeout=3s; } server { location / { proxy_pass http://app; } } }
4) TLS, HTTP/2 설정
HTTP2를 사용하여 하나의 TCP 연결로 다수의 클라이언트 요청 처리함으로서 성능을 개선할 수 있다. HTTP 2는 SSL 계층 위에서만 동작한다.
http { server { listen 80; return 301 https://$host$request_uri; } server { listen 443 ssl http2; ssl_certificate /etc/letsencrypt/live/[도메인주소]/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/[도메인주소]/privkey.pem; # Disable SSL ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # 통신과정에서 사용할 암호화 알고리즘 ssl_prefer_server_ciphers on; ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5; # Enable HSTS # client의 browser에게 http로 어떠한 것도 load 하지 말라고 규제합니다. # 이를 통해 http에서 https로 redirect 되는 request를 minimize 할 수 있습니다. add_header Strict-Transport-Security "max-age=31536000" always; # SSL sessions ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; } }
모두 적용된 모습은 다음과 같다.
events {} http { upstream app { least_conn; ## 현재 connections이 가장 적은 server로 reqeust를 분배 server 172.17.0.1:8080 max_fails=3 fail_timeout=3s; server [부하 분산 서버의 public ip]:[부하 분산 서버의 실행 포트] max_fails=3 fail_timeout=3s; } # Redirect all traffic to HTTPS server { listen 80; return 301 https://$host$request_uri; } ## Proxy 캐시 파일 경로, 메모리상 점유할 크기, 캐시 유지기간, 전체 캐시의 최대 크기 등 설정 proxy_cache_path /tmp/nginx levels=1:2 keys_zone=mycache:10m inactive=10m max_size=200M; ## 캐시를 구분하기 위한 Key 규칙 proxy_cache_key "$scheme$host$request_uri $cookie_user"; server { listen 443 ssl http2; ssl off; ssl_certificate /etc/letsencrypt/live/[도메인 주소]/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/[도메인 주소]/privkey.pem; # Disable SSL ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # 통신과정에서 사용할 암호화 알고리즘 ssl_prefer_server_ciphers on; ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5; # Enable HSTS # client의 browser에게 http로 어떠한 것도 load 하지 말라고 규제합니다. # 이를 통해 http에서 https로 redirect 되는 request를 minimize 할 수 있습니다. add_header Strict-Transport-Security "max-age=31536000" always; # SSL sessions ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; gzip on; ## http 블록 수준에서 gzip 압축 활성화 gzip_comp_level 9; gzip_vary on; gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/rss+xml text/javascript image/svg+xml application/vnd.ms-fontobject application/x-font-ttf font/opentype; location / { proxy_pass http://app; } location ~* \.(?:css|js|gif|png|jpg|jpeg)$ { proxy_pass http://app; ## 캐시 설정 적용 및 헤더에 추가 # 캐시 존을 설정 (캐시 이름) proxy_cache mycache; # X-Proxy-Cache 헤더에 HIT, MISS, BYPASS와 같은 캐시 적중 상태정보가 설정 add_header X-Proxy-Cache $upstream_cache_status; # 200 302 코드는 20분간 캐싱 proxy_cache_valid 200 302 10m; # 만료기간을 1 달로 설정 expires 1M; # access log 를 찍지 않는다. access_log off; } } }
WAS 성능 개선하기
어플리케이션 캐시를 이용해서 기존 작업 결과를 저장하고 동일 작업 요청되었을 때 결과를 재사용하여 성능개선을 할 수 있다. 또한 병렬처리등을 사용하여 제한된 쓰레드 수 안에서 자원을 재사용하여 성능을 개선시킬 수 있다.
1 ) Spring Data Cache
Redis 를 이용해서 캐시를 이용할 수 있다.
## Redis Server 실행 $ docker pull redis $ docker run -d -p 6379:6379 redis ## application.properties에 redis 정보 추가 spring.cache.type=redis spring.redis.host=localhost spring.redis.port=6379 ## build.gradle에 의존성 추가 implementation('org.springframework.boot:spring-boot-starter-data-redis')
적용은 다음과 같이 할 수 있다.
먼저 CacheConfig를 추가해준다.
@EnableCaching @Configuration public class CacheConfig extends CachingConfigurerSupport { @Autowired RedisConnectionFactory connectionFactory; @Bean public CacheManager redisCacheManager() { RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder. fromConnectionFactory(connectionFactory).cacheDefaults(redisCacheConfiguration).build(); return redisCacheManager; } }
다음과 같이 @Cacheable을 이용하여 적용해준다. 여기서 중요한 점은 캐시로 저장할 때 ResponseEntity는 Deserialize 되지 않아 도메인 객체를 직접 반환하거나 Service 계층에서 적용해야 하고 LocalDateTime또한 Deserialize 되지 않으므로 String으로 저장해야하며 AOP의 제약사항을 가져 public method에서만 사용해야 한다는 것이다.
@Service @Transactional public class MapService { private static final Logger jsonLogger = LoggerFactory.getLogger("json"); @Cacheable(value = "path", key = "{#source, #target}") public PathResponse findPath(Long source, Long target) { //... } }
public class StationResponse { private String createdDate; private String modifiedDate; public StationResponse(Long id, String name, LocalDateTime createdDate, LocalDateTime modifiedDate) { this.id = id; this.name = name; this.createdDate = createdDate.format(DateTimeFormatter.ISO_DATE_TIME); //저장시 String 타입으로 저장되도록 변환 this.modifiedDate = modifiedDate.format(DateTimeFormatter.ISO_DATE_TIME); } }
2) 비동기 처리
외부 API의 경우 비동기처리를 통해 병목을 피하고 Thread Pool을 사용하여 쓰레드를 재사용 할 수 있다.
- 동기 : 작업을 요청한 후 작업의 결과가 나올 때까지 기다린 후 처리 (프로세스는 커널에 지속적으로 I/O 준비사항을 체크)
- 비동기 : 직전 시스템 호출의 종료가 발생하면 그에 따른 처리를 진행
- Blocking : 유저 프로세스가 시스템 호출을 하고나서 결과가 반환되기까지 다음 처리로 넘어가지 않음
- Non-Blocking : 호출한 직후에 프로그램으로 제어가 돌아와서 시스템 호출의 종료를 기다리지 않고 다음 처리로 넘어갈 수 있음
비동기 처리
비동기 처리는 다음과 같이 적용할 수 있다.
@Async public void sendMail(String to, String subject, String contents) {
Thread Pool
먼저 적절한 쓰레드 수는 사용 가능한 코어 수 * (1 + 대기 시간/서비스시간)으로 계산할 수 있다.
## CPU 모델명 $ cat /proc/cpuinfo | grep "model name" | uniq -c | awk '{print $5 $6, $7,$8, $9, $10 $11}' ## CPU당 물리 코어 수 $ cat /proc/cpuinfo | grep "cpu cores" | tail -1 | awk '{print $4}' ## 물리 CPU 수 $ cat /proc/cpuinfo | grep "physical id" | sort -u | wc -l ## 리눅스 전체 코어(프로세스)개수 $ grep -c processor /proc/cpuinfo
설정은 TaskExecutionProperties.Pool(Thread pool 생성)에 정의된 설정을 기본으로 따르는데 어플리케이션 구동 상황에 따라 다르게 설정하도록 한다.public static class Pool { private int queueCapacity = 2147483647; private int coreSize = 8; private int maxSize = 2147483647; private boolean allowCoreThreadTimeout = true; private Duration keepAlive = Duration.ofSeconds(60L);
@Configuration @EnableAsync public class AsyncThreadConfig { @Bean public Executor asyncThreadTaskExecutor() { ThreadPoolTaskExecutor exexcutor = new ThreadPoolTaskExecutor(); /* 기본 Thread 사이즈 */ exexcutor.setCorePoolSize(2); /* 최대 Thread 사이즈 */ exexcutor.setMaxPoolSize(4); /* MaxThread가 동작하는 경우 대기하는 Queue 사이즈 */ exexcutor.setQueueCapacity(100) exexcutor.setThreadNamePrefix("subway-async-"); return exexcutor; } }
(참고한 사이트)
NextStep 프로젝트 공방
https://pjh3749.tistory.com/264
'네트워크 & 인프라' 카테고리의 다른 글
그림으로 공부하는 IT 인프라 구조 정리 (0) 2022.04.15 AWS Auto Scaling 적용하기 (Load balancer를 이용한 부하분산) (0) 2022.04.14 서버 진단하기 ( + 로깅, 모니터링) (0) 2022.04.14 부하 테스트 ( + k6, grafana + influxdb, ngrinder) (0) 2022.04.14 웹 성능 진단하기 (0) 2022.04.14 - Reverse Proxy