본문으로 바로가기

🚀 1단계 - 화면 응답 개선하기

  • 부하테스트 각 시나리오의 요청시간을 50ms 이하로 개선
    • 개선 전 / 후를 직접 계측하여 확인

1. 정적 파일 경량화

2. Reverse Proxy 개선하기

## CPU Core에 맞는 적절한 Worker 프로세스를 할당
worker_processes auto;

events { worker_connections 10240; } ## Worker Process가 수용할 수 있는 Connection 수

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;

  ## 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";


  upstream app {
    least_conn; ## 현재 connections이 가장 적은 server로 reqeust를 분배
    server 172.17.0.1:8080 max_fails=3 fail_timeout=3s;
    server 172.17.0.1:8081 max_fails=3 fail_timeout=3s; 
  }
  
    # Redirect all traffic to HTTPS
  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;         
    
    ## proxy_set_header :  뒷단 서버로 전송될 헤더 값을 다시 정의해주는 지시어
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;    
    
    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;
    }

    location / {
      proxy_pass http://app;
    }
  }
}

* HTTP/2 설정 (HTTP2.0은 SSL 계층 위에서만 동작합니다.)

...
  server {
    listen 443 ssl http2;
...

 

3. WAS 성능 개선하기

 

* application server의 경우

  • 작업 스레드풀을 필요 이상으로 크게 설정하면 DB 부하가 증가할 수 있습니다.
  • 애플리케이션 서버가 낼 수 있는 최대 성능을 넘어서는 동시처리 요청이 들어오면 TPS가 증가하지 않은 채 응답시간만 증가하다가 큐가 쌓여 서비스 멈춤현상이 발생할 수 있습니다.

성능 튜닝의 한 축은 서비스 간이나 서비스 내에서 반복되는 로직을 제거하는 것입니다. 기존에 작업한 결과를 저장해두었다가 이후에 다시 동일한 작업이 수행되었을 때 결과를 재사용하면 반복되는 로직을 제거할 수 있습니다.

애플리케이션 캐시를 활용하여 기존에 작업한 결과를 저장해두었다가 이후에 다시 동일한 작업이 수행되었을 때 결과를 재사용하면 반복되는 로직을 제거할 수 있습니다. 또는 병렬 처리 등을 활용하여 

 

제한된 스레드 수 내에서 자원을 재사용하여 성능을 개선할 수 있습니다.

 

Thread pool 설정

@Async에 대한 별도 설정이 없더라도 TaskExecutionAutoConfiguration에 의해 Thread pool이 생성됩니다. 다만, 이 때 설정은 TaskExecutionProperties.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; 
    } 
}

 

 

A. Spring Data Cache

* Redis Server

$ docker pull redis
$ docker run -d -p 6379:6379 redis

* application.properties

spring.cache.type=redis
spring.redis.host=localhost
spring.redis.port=6379

* build.gradle

implementation('org.springframework.boot:spring-boot-starter-data-redis')
@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;
    }
}
# 메서드 실행 전에 캐시를 확인하여 최소 하나의 캐시가 존재한다면 값을 반환한다.
# SpEL 표현식을 활용하여 조건부 캐싱이 가능하다. 
@Cacheable(value = "line", key = "#id")
public Line getLine(Long id) {

# 메서드 실행에 영향을 주지 않고 캐시를 갱신해야 하는 경우 사용한다.
@CachePut(value = "line", key = "#id")
public void updateLine(Long id, LineRequest lineUpdateRequest) {

# 캐시를 제거할 때 사용한다.
@CacheEvict(value = "line", key = "#id")
public void deleteLineById(Long id) {

Redis에 자세한 내용정리를 아래의 링크를 참조하시면 됩니다.

Spring Redis Cache

 

  • ResponseEntity는 Deserialize 되지 않으니 도메인 객체를 직접 반환하거나, Service Layer에 적용하여야 합니다.
  • LocalDateTime은 Deserialize 되지 않으니 String으로 변환하여야 합니다.
  • Spring AOP의 제약사항을 가집니다.
    • pulbic method에만 사용가능 합니다.
    • 같은 객체내의 method끼리 호출시 AOP가 동작하지 않습니다.
    • Runtime Weaving으로 처리 되기 때문에 약간의 성능저하가 있습니다.

 

B. 비동기 처리

외부 API를 활용할 경우 비동기처리를 하여 병목을 피할 수 있습니다. 또한, Thread pool을 활용하여 Thread를 재사용할 수 있습니다.

* blocking vs non-blocking / synchronous vs asynchronous

  • Synchronous I/O와 Asynchronous I/O
    • 동기 : 작업을 요청한 후 작업의 결과가 나올 때까지 기다린 후 처리 (프로세스는 커널에 지속적으로 I/O 준비사항을 체크)
    • 비동기 : 직전 시스템 호출의 종료가 발생하면 그에 따른 처리를 진행

 

  • Blocking I/O과 Non-Blocking I/O
    • Blocking : 유저 프로세스가 시스템 호출을 하고나서 결과가 반환되기까지 다음 처리로 넘어가지 않음
    • Non-Blocking : 호출한 직후에 프로그램으로 제어가 돌아와서 시스템 호출의 종료를 기다리지 않고 다음 처리 넘어갈 수 있음

 

🚀 2단계 - 조회 성능 개선하기

1. 요구사항

  • 인덱스 적용해보기 실습을 진행해본 과정을 공유해주세요
  • 즐겨찾기 페이지에 페이징 쿼리를 적용
    • 로그인한 사용자는 최근에 추가한 즐겨찾기만 관심이 있기에 한번에 5개의 즐겨찾기만 보고 싶다.
  • 데이터베이스 이중화

적용 방식

1. 페이징 쿼리

웹 애플리케이션에서는 테이블의 내용을 1~20건 단위로 나눠서 보여주는 것이 일반적입니다. 테이블의 레코드를 일정 단위로 잘라서 조회하는 것을 페이징 쿼리라고 합니다. 일반적으로는 아래와 같이 작성합니다.

SELECT * FROM subway.programmer ORDER BY id LIMIT 20, 10;

이렇게 작성할 경우에 10개의 레코드만 읽는게 아니라, 첫번째 레코드부터 20번째 레코드까지 읽어서 버리고 10개의 레코드를 읽어 반환합니다. 이에 뒷 페이지로 갈수록 성능이 급격히 저하됩니다.
따라서 아래와 같이, 테이블의 PK를 WHERE 조건절에 넣어주는 것이 좋습니다.

SELECT * FROM subway.programmer
    WHERE subway.programmer.id >= 20000
        ORDER BY id LIMIT 0, 10;

Spring Data JPQL은 LIMIT 명령어를 지원하지 않으므로, Pageable 객체를 활용해야 합니다.

@Query("SELECT * FROM subway.programmer WHERE subway.programmer.id >= ?1")
List<User> findAll(Pageable pg);

 

예시 적용 (즐겨 찾기)

// Controller 영역

@GetMapping("/favorites")
 public ResponseEntity<List<FavoriteResponse>> getFavorites(
         @AuthenticationPrincipal LoginMember loginMember,
         @PageableDefault(size=5, sort = "id", direction = Sort.Direction.DESC) Pageable pageable) {
        List<FavoriteResponse> favorites = favoriteService.findFavorites(loginMember, pageable);
        return ResponseEntity.ok().body(favorites);
 }
// service 영역
 @Transactional(readOnly = true)
 public List<FavoriteResponse> findFavorites(LoginMember loginMember, Pageable pageable) {
   List<Favorite> favorites = favoriteRepository.findByMemberId(loginMember.getId(), pageable);
    Map<Long, Station> stations = extractStations(favorites);

    return favorites.stream()
        .map(it -> FavoriteResponse.of(
            it,
            StationResponse.of(stations.get(it.getSourceStationId())),
            StationResponse.of(stations.get(it.getTargetStationId()))))
        .collect(Collectors.toList());
}
public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
    @Query("select f from Favorite f where f.id >= ?1")
    List<Favorite> findByMemberId(Long memberId, Pageable pageable);
}

 

 

2. MySQL Replication with JPA

  • MySQL Replication의 master/slave는 1:n관계입니다.
    master는 갱신쿼리를 바이너리 로그파일로 기록하고, 이 로그파일의 내용이 slave로 전송되어 순차적으로 실행함으로써 복제됩니다. 따라서 MySQL Replication은 준동시성입니다. I/O 스레드가 비동기로 동작하기에 마스터에서 생성한 바이너리 로그가 슬레이브에 수신되기 전에 장애가 날 경우 손실이 발생할 수 있습니다.

 

  • 데이터조작쿼리(INSERT, UPDATE, DELETE)는 마스터로, 데이터조회쿼리(SELECT)는 슬레이브로 받아서 부하를 분산할 수 있습니다.

※ Nginx에 DB 이중화 작업하기

master 서버 설정

$ docker run --name mysql-master -p 13306:3306 -v ~/mysql/master:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD=masterpw -d mysql

$ docker exec -it mysql-master /bin/bash
$ mysql -u root -p  
mysql> CREATE USER 'replication_user'@'%' IDENTIFIED WITH mysql_native_password by 'replication_pw';  
mysql> GRANT REPLICATION SLAVE ON *.* TO 'replication_user'@'%'; 

mysql> SHOW MASTER STATUS\G  
*************************** 1. row ***************************
             File: binlog.000002
         Position: 683
     Binlog_Do_DB: 
 Binlog_Ignore_DB: 
Executed_Gtid_Set: 
1 row in set (0.00 sec)

 

slave 서버 설정

$ docker run --name mysql-slave -p 13307:3306 -v ~/mysql/slave:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD=slavepw -d mysql

$ docker exec -it mysql-slave /bin/bash
$ mysql -u root -p  

mysql> SET GLOBAL server_id = 2;
mysql> CHANGE MASTER TO MASTER_HOST='172.17.0.1', MASTER_PORT = 13306, MASTER_USER='replication_user', MASTER_PASSWORD='replication_pw', MASTER_LOG_FILE='binlog.000002', MASTER_LOG_POS=683;  

mysql> START SLAVE;  
mysql> SHOW SLAVE STATUS\G
...
            Slave_IO_Running: Yes
            Slave_SQL_Running: Yes

 

애플리케이션 설정

spring.datasource.hikari.master.username=root
spring.datasource.hikari.master.password=masterpw
spring.datasource.hikari.master.jdbc-url=jdbc:mysql://localhost:13306/subway?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&serverTimezone=UTC&allowPublicKeyRetrieval=true

spring.datasource.hikari.slave.username=root
spring.datasource.hikari.slave.password=slavepw
spring.datasource.hikari.slave.jdbc-url=jdbc:mysql://localhost:13307/subway?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&serverTimezone=UTC&allowPublicKeyRetrieval=true
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
    public static final String DATASOURCE_KEY_MASTER = "master";
    public static final String DATASOURCE_KEY_SLAVE = "slave";

    @Override
    protected Object determineCurrentLookupKey() {
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        return (isReadOnly)
            ? DATASOURCE_KEY_SLAVE
            : DATASOURCE_KEY_MASTER;
    }
}
        @Qualifier("slaveDataSource") DataSource slave) {
        ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();

        HashMap<Object, Object> sources = new HashMap<>();
        sources.put(DATASOURCE_KEY_MASTER, master);
        sources.put(DATASOURCE_KEY_SLAVE, slave);

        routingDataSource.setTargetDataSources(sources);
        routingDataSource.setDefaultTargetDataSource(master);

        return routingDataSource;
    }

    @Primary
    @Bean
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}
public List<Line> findLines() {    
    ...
    
    @Transactional(readOnly = true)
    public List<StationResponse> findAllStations() {
  • findLines() 메서드는 master에서 findAllStations() 메서드는 slave에서 조회합니다. @Transactional(readOnly = true)를 사용할 경우 slave를 활용합니다.

 

이번 미션을 통해 배운점

  1. db 이중화 작업
  2. 즐겨찾기 구현
  3. 페이징 처리
  4. DB 인덱스를 통한 속도 개선 (전과 후를 비교했을 때 확연한 차이를 느낌)
  5. flyway 버전 관리

 

후기

이번 수업은 마지막 파트 인프라에서는

이전에 배웠던 성능과 모니터링 k6 이슈에 대해 레디스를 통해 속도가 개선되었고, 실무에서도 적용되고 있어

아주 유용한 기술이었다.

 

이후에, nginx에서 db이중화 작업, 인덱스설정을통한 쿼리속도 개선 등을 통해

 

마지막 인프라 수업까지 마음에 들었다.

 

현업에서도 이런점들을 잘 고려하여 적용했으면 바램이다.

 

꼼꼼한 리뷰 주신 준일 리뷰어님께 감사드립니다.

 

이번 미션에 도움을 준 동기분들 (병호님, 병학님, 충선님, 지수님, 철원님) 감사드립니다.