내가 아는 파이썬의 특징 중 GIL만큼 신기한 특징도 없다.

 

 파이썬에서 하나의 스레드에 모든 자원을 할당하고 다른 스레드에서 접근할 수 없도록 Lock을 건다. OS 공부하며 본 그 뮤텍스와 똑같다. 

이번 포스팅은 Python, Java 두가지 언어로 Single, Multi Thread 각각의 상황에서 무거운 연산을 진행하며 시간을 측정하고 시간 차이를 통해 GIL을 확인해보고 GIL의 존재 이유를 설명하려 한다. 

 

들어가기에 앞서 현재 내 컴퓨터 환경은 아래와 같다.

JDK 8.0, pypy 3.10 을 사용했다. 

 

멀티 스레드 연산 실험 

실험은 1 ~ 2000000000(20억)의 값을 더하는 속도를 비교할 것이다. 

멀티스레드 환경, 단일 스레드 환경의 연산속도를 Java와 Python에서 각각 비교해보겠다. 

 

Java에서 단일스레드

비교를 위한 자바에서의 연산 실험이다.

먼저 단일 스레드에서의 연산을 실험할 코드는 아래와 같다. 

/**
 * 단일 스레드에서의 연산
 * sum : 1~20억을 더한 결과값  
 */
long start = System.currentTimeMillis();
long sum = 0;
for(int i =0; i < 2000000000; i++) {
	sum+=i;
}
long end = System.currentTimeMillis();
System.out.println("Thread");
System.out.println("연산 시간 : " + (end-start)/1000.0 +"초");
System.out.println("result : " + sum);

 

내 훌륭한 노트북은 자바, 단일스레드로 1부터 20억까지 더하는데 0.68초가 걸리는걸 확인했다.

단일 스레드

Java에서의 멀티스레드 

다음은 멀티 스레드에서의 연산을 실험할 코드이다. 

/*
* sum  (long)  : 1부터 20억을 더한 결과
* start(int)   : 나누어진 구간의 시작
* end  (int)   : 나누어진 구간의 끝
*/
public class MyThread extends Thread{
	public static long sum; 
	private int start;
	private int end;
	
	public MyThread(int start, int end) {
		this.start = start;
		this.end = end;
	}
	/**
    	* start ~ end의 값을 모두 더함 
    	*/
	@Override
	public void run() {
		long temp = 0; 
		for(int i = start; i <= end ; i ++) {
			temp+=i;
		}
		sum+=temp; 
	}

}
/**
 * 멀티스레드에서의 연산 
 * target(int) 	  : 1부터 더할 범위의 수 == 20억 
 * tharedCnt(int) : 사용될 스레드의 수 
 * MyThread[]     : 스레드를 담을 변수. 
 * Thread.start() : Thread에서 Thread.run()을 호출함 -> 각 스레드에 할당된 범위를 더
 * Thread.join()  : Thread가 종료될때까지 기다릴때 사용
 *
 */
start = System.currentTimeMillis();
int threadCnt = 8;
int target = 2000000000;
MyThread[] threads = new MyThread[threadCnt];
for(int i = 0 ; i < threads.length; i++) {
	threads[i] = new MyThread(i*(target/threadCnt), (i+1)*(target / threadCnt)-1);
	threads[i].start();
}
for(int i = 0 ; i < threads.length; i++) {
	threads[i].join(); 
}
end = System.currentTimeMillis();
System.out.println("Multi Thread");
System.out.println("연산 시간 : " + (end-start)/1000.0 +"초");
System.out.println("result : " + MyThread.sum);

각 쓰레드는 20억에서 threadCnt개의 범위를 나누어 할당받은 후 static으로 선언된 MyThead.sum의 값에 더하게 된다. 중요한건 결과니깐 결과를 보자.

멀티 스레드

0.68초 -> 0.232초로 빨라졌다.  

 

전체 코드

더보기
JAVA
public class MultiThread {
	public static void main(String[] args) throws InterruptedException {
		/**
		 * 단일 스레드에서의 연산
		 * sum : 1~20억을 더한 결과값  
		 */
		long start = System.currentTimeMillis();
		long sum = 0;
		for(int i =0; i < 2000000000; i++) {
			sum+=i;
		}
		long end = System.currentTimeMillis();
		System.out.println("Thread");
		System.out.println("연산 시간 : " + (end-start)/1000.0 +"초");
		System.out.println("result : " + sum); 
		
		/**
		 * 멀티스레드에서의 연산 
		 * target(int) 	  : 1부터 더할 범위의 수 == 20억 
		 * tharedCnt(int) : 사용될 스레드의 수 
		 * MyThread[]     : 스레드를 담을 변수. 
		 * Thread.start() : Thread에서 Thread.run()을 호출함 -> 각 스레드에 할당된 범위를 더
		 * Thread.join()  : Thread가 종료될때까지 기다릴때 사용
		 *
		 */
		start = System.currentTimeMillis();
		int threadCnt = 8;
		int target = 2000000000;
		MyThread[] threads = new MyThread[threadCnt];
		for(int i = 0 ; i < threads.length; i++) {
			threads[i] = new MyThread(i*(target/threadCnt), (i+1)*(target / threadCnt)-1);
			threads[i].start();
		}
		for(int i = 0 ; i < threads.length; i++) {
			threads[i].join(); 
		}
		end = System.currentTimeMillis();
		System.out.println("Multi Thread");
		System.out.println("연산 시간 : " + (end-start)/1000.0 +"초");
		System.out.println("result : " + MyThread.sum); 
		
	}
}

 

/*
* sum  (long)  : 1부터 20억을 더한 결과
* start(int)   : 나누어진 구간의 시작
* end  (int)   : 나누어진 구간의 끝
*/
public class MyThread extends Thread{
	public static long sum; 
	private int start;
	private int end;
	
	public MyThread(int start, int end) {
		this.start = start;
		this.end = end;
	}
	
	@Override
	public void run() {
		long temp = 0; 
		for(int i = start; i <= end ; i ++) {
			temp+=i;
		}
		sum+=temp; 
	}

}

 

Python에서의 단일스레드 

 정확히는 Pypy를 사용했다. 파이썬의 경우 20억까지 더하는데 예상보다 더 걸린다... pypy는 JIT 컴파일러를 사용하는데 jvm도 JIT 컴파일러를 쓴다. JIT 컴파일러에 대해서는 다음에 자세히 포스팅하기로 하고 아무튼 반복문, 함수 등에 대해 최적화 해주는 친구라고 생각하자.

# start부터 end까지 result에 더해주는 함수
def work(start: int,end: int) -> None:
    global result
    for i in range(start,end):
        result += i

멀티 스레드, 단일스레드도 이 함수를 사용해서 실험한다. 

단일 스레드의 코드는

result = 0
start_time = time.time()
work(1,2000000000)
end_time = time.time() 
print("Single-Thread")
print(f"연산 시간: {(end_time-start_time)/1000}")
print(f"result: {result}")

 

파이썬의 단일 스레드

결과는 위와 같다. 

Python에서의 멀티스레드 

똑같이 작성해놓은 work 함수를 사용할 것이다.

result = 0
start_time = time.time()
threads = []
thread_cnt = 8
target = 2000000000
for i in range(thread_cnt):
    threads.append(threading.Thread(target=work(i * (target // thread_cnt), (i + 1) * (target // thread_cnt))))
    threads[-1].start()

for t in threads:
    t.join()
end_time = time.time()
print("Multi-Thread")
print(f"연산 시간: {(end_time-start_time)/1000}")
print(f"result: {result}")

자바에서 멀티스레드와 동일한 로직으로 동작한다. 

파이썬의 멀티 스레드

결과는 위와 같다. 

 

3.9 -> 4.0초로 더 느려졌다. 자바에서의 결과와 반대로 더 느려졌다.

 

결과

왼쪽 : Java의 단일 스레드(상), 멀티스레드(하)  오른쪽 : Python의 단일 스레드(상), 멀티스레드(하)

자바는 빨라졌지만, 파이썬은 멀티스레드 환경에서 오히려 더 느려졌다. 

 

GIL - Global Interpreter Lock

앞에서 말했지만 파이썬의 경우 하나의 스레드에 자원을 할당하고 Lock을건다. 즉 파이썬 인터프리터는 하나의 스레드만 하나의 바이트 코드를 실행 시킬 수 있다.

 

코어가 8개인 환경에서 스레드 8개가 작업을 한다고 가정해보자. 

파이썬의 멀티 스레드 동작 흐름

000,000,000~250,000,000 까지 더하는 스레드

250,000,001~500,000,000 까지 더하는 스레드

500,000,001~750,000,000 까지 더하는 스레드 

 ... 

1,750,000,000 ~ 2,000,000,000 까지 더하는 스레드

파이썬의 경우 각 스레드는 위 그림과 같이 동작한다. 실험에 빗대어 보면 단일스레드가 3.8초 동안 연산한걸, 8개의 스레드가 4.06초동안 번갈아가며 연산한다. 오히려 context switch에 비용이 추가적으로 발생하며 싱글스레드보다 느린 결과를 보여준다. 자바의 경우엔 이 모든 스레드가 0.232초 사이에 동시에 동작하며 단일 스레드 연산 시간인 0.677초보다 빠르게 해결할 수 있다. 

 

왜 귀도 반 로섬씨는 이런 비효율적으로 보이는 락을 만들었을까. 

 

파이썬은 문자열부터 정수, 실수, 뭐 죄다 객체다. 심지어 함수도 객체다. 

이게 진짜 객체지향이지 ㅋㅋ..

그래서 위 사진처럼 함수도 변수에 저장할 수 있고, 매개변수로도 넘길 수 있다. (이걸 어따쓰냐 궁금하다면.. Decorator 등 한 번 알아봐라)이렇게 모든게 객체인 파이썬은 문제가 조금 있다. 바로 메모리 관리다.

 

 Call By Value랑 Call By Reference도 헷갈려했던 기억이 있는데 파이썬은 Call By Object-reference라는 호출 방식을 사용하고, 알아보니 또 헷갈려 죽으려하는 내가 보인다. 이렇게 헷갈리는데 모든게 객체다. 이때 사람이 메모리 할당과 해제를 직접 한다면 파이썬이 이렇게까지 인기를 끌 수 없었을거다. 아무튼 귀도반로섬씨께서는 메모리 관리 방법으로 reference counting을 사용했다. 예를들면 a = 999라는 코드라면 999라는 객체의 reference counting은 1이다. 이때, 특정 객체를 참조하는 변수의 개수가 0이라면 메모리에서 해제하면 되는데 몹시 간단하고 직관적이다. 물론 단일스레드에서의 얘기다.

 

멀티스레드 환경에서 reference counting은 critical section으로 여러 스레드가 이 영역에 진입하게 되면 Race Condition으로 동기화 문제가 발생한다. 그 결과 값을 참조하고 있음에도 불구하고 reference counting이 0이 되어 메모리에서 삭제될 수도 있고, 아무도 참조를 안하고 있음에도 메모리에 남아있을수도 있다!(파이썬에선 GIL때매 있을 수 없는 상황이으로 극단적인 예시이며 순환참조의 경우는 GC가 세대별로 확인하며 열심히 지울거다.)

 

 어떻게 reference counting의 동기화 문제를 해결할까 고민하던 귀도반로섬은 문제 자체를 뿌리뽑았다. 바로 Race Condition이 일어날 환경자체를 막았다. 파이썬 전체적으로 적용될 뮤텍스를 하나 만들었고 이름은 GIL이라 붙였다! 물론 "아 머리 아프다. 그냥 막아 ㄱㄱ." 이런 판단은 아니다. 동기화를 위해 파이썬 코드의 수 많은 객체와 객체들 간 연산에서 일일히 Lock을 걸어주는것보다 GIL을 사용하는 방법이 훨씬 적은 비용으로 처리할 수 있다. 만약 각각의 객체가 Lock을 갖고 thread-safe하게 만든다면 그 어떤 천재가 와서 코딩해도 DeadLock을 마주하고 "이게 왜 안돼" 하다가 GIL을 만들었을거다. 

 

 

그럼 파이썬을 쓰면 멀티 스레드 환경을 고려 안해도 될까?

 물론 아니다. 기본적으로 GIL은 CPU-bound 작업에 한해 Lock을 건다. Sleep, I/O-bound(입출력, File, DB, Network)에서는  GIL이 적용되지 않는다. 따라서 I/O작업( 네트워크 요청 DB액세스 등)을 수행할때는 멀티 스레딩이 성능적으로 이점을 얻을 수 있다. 그러나 일반적인 I/O 이벤트를 보면 이벤트를 처리하는 비용보다는 이벤트가 발생하기까지 기다리는 비용이 크기때문에 멀티 스레드 보다는 비동기 프로그래밍을 사용하는게 성능적으로 이점을 얻는 경우가 많다. 

 

 CPU-bound의 작업을 수행하는 경우에는 멀티스레드 보다는 멀티프로세스 혹은 다른 병렬처리 방식을 고려해야 한다. 이때 파이썬은 multiprocessing이나, 비동기 작업을 처리하기위한 asyncio와 같은 라이브러리를 제공한다. 싱글 스레드에서 비동기로 작업하기 는 다음에 포스팅하겠다 !.

 

Java는 GIL이 없는데..

Java는 GIL없이 어떻게 메모리를 자동으로 관리하고 있을까? 

파이썬은 Reference Counting을 사용하는 것과 달리 자바에서는 기본적으로 Garbage Collection을 Mark and Sweep 방식으로 관리한다. (JDK 8기준)

 간단하게 설명하면 jvm의 heap 메모리에는 Eden, Survive0, Survive1, Old 영역이 존재한다. 가장 처음 객체가 할당되면 Eden으로, Eden 영역에 메모리가 다 차면 모든 스레드를 일시 정지(Stop-the-world)한 뒤 root객체로 부터 접근 가능한 객체들을 마킹(Mark)하고, 마킹되지 않은 객체는 전부 메모리를 해제(Sweep)한다. 이게 Mark-and-sweep이다. 여기서 마킹된 객체는 살아 남아 survive0 영역으로 이동되며 해당영역까지 가득 찰 경우 다시 한 번 Mark-and-sweep을 진행하고 살아남은 객체는 다른 survive영역으로 이동한다.  일정 횟수의 mark-and-sweep에서 살아남으면 Old 영역으로 넘어가게 된다. 

 Reference Counting 방식은 메모리를 해제하기 위해 객체의 참조가 변경 될 때마다 Critical Section에 진입을 막으며 Atomic한 연산을 수행해야하지만, Mark-and-sweep은 메모리가 가득 찼을때 메모리를 해제하기 때문에 Lock이 필요가 없다. 내비뒀다가 한 번에 해제하기 때문이다. 하지만 마킹을 위해 모든 스레드를 일시정지(Stop-The-world)하는 과정이 존재하고, 객체 소멸 시점을 예측하기도 어렵다는 단점이 있다.(모든 스레드가 일시정지 되는 시점을 예측하기 힘들다.)

 

 

 

 

 


 

 

실험하다가 만난 문제가 많다..

pypy가 실제 동작하는거에 비해 연산시간이 짧게 나오는가 하면 Python은 그냥 하염없이 기다려야했다. 

 

- python의 time.time은 sec를 반환하고 java는 milisec을 반환하기때문에 단위를 맞췄다. "파이썬 빠르네 !" 하고 좋아했다가 아차 싶었다. ㅎㅎ;

 

 

Integer Interning과 Integer Caching

 

x,y에 대해 주소값이 달라질때까지 더하고, 빼봤다.

 

"파이썬은 모든게 객체다." 라고 말하면서 -5~256의 값을 미리 할당해놓고 쓴다는걸 보여주고, 자바와 차이를 보여주려했다. 자바는 미리 할당하지 않는다고 생각했기 때문이다.

그런데 이게 왠걸 자바도 -128~127의 값은 같은 주소값을 바라보고 있었다. 

 

얘넨 이런짓을 왜할까 - 정수 인터닝과 정수 캐싱

 

파이썬의 경우에는 "정수 인터닝 (Integer Interning)" 이라는 최적화 기법이며 작은 정수값은 자주 사용된다는 가정하에 정수객체를 미리 할당하고 지속적으로 재사용하는 방식을 채택했다. 미리 생성하여 캐시에 저장해두고, 동일한 정수 값에 대해 해당 정수 객체를 가리키도록 하며 재사용한다. 이는 정수 객체를 생성하고 해제하는 비용을 줄이고 메모리를 절약할 수 있다. 

 

자바의 경우에는 정수 인터닝과는 조금 다른 "정수 캐싱 (Integer Caching)"을 수행한다. 자세히 말하자면 자바는 Byte, Short, Integer, Long 등의 정수 래퍼 클래스에서, 범위가 -128~127까지인 정수 값에 대해 "정수 캐싱"을 수행한다.  이또한 작은 정수값에 대해 객체를 재사용함으로써 메모리를 절약하고 성능을 향상시키는 것을 목적으로 한다. 

 

 

 

 

 

blue green 배포 방식

실제로 우리팀에서 사용했던 무중단 배포 방식이다.

Spring을 예로 들면 전체적인 흐름은 jenkins를 통해 빌드 후, 빌드 된 jar파일을 배포를 관리하는 폴더로 복사한다. sh 스크립트를 통해 관리하기 되는데 docker compose를 통해 새로운 버전의 컨테이너를 띄우고 이전 버전을 내린다.. 말로는 참 쉽다. ;-; 

블루 그린 배포 - 개념 

블루 그린 배포는 새로운 버전의 인스턴스를 띄운 뒤, 로드밸런서를 통해 새로운 버전의 인스턴스로 트래픽을 요청한다. 

 

블루 그린 배포를 이용하면 기존에 인스턴스를 유지하던 비용의 2배가 들어가는 시점이 존재한다, 또한 새로운 버전에 치명적인 오류가 있다면 전환된 서버는 모두 내려갈 것이다. 하지만 구버전의 인스턴스가 그대로 남아있기 때문에 롤백이 손쉽고, 차마 테스트하지 못한 부분에서 치명적인 오류가 발생했다면 구버전으로 다시 트래픽을 돌리기 간편하다. 

 

블루 그린 배포 - 실습 

1. jenkins pipe line 

		# 스토리 관련 스테이지
	stage('Building story image'){
            steps{
                script {
                  dockerImage = docker.build("jodong2/story-zero-downtime", "/var/jenkins_home/workspace/deploy_test/StoryModule")
                  withCredentials([usernamePassword(credentialsId: 'jodong2', usernameVariable: 'DOCKER_HUB_USERNAME', passwordVariable: 'DOCKER_HUB_PASSWORD')]) {
                     sh 'docker login -u $DOCKER_HUB_USERNAME -p $DOCKER_HUB_PASSWORD'
                     sh 'docker push jodong2/story-zero-downtime'
                  }
                }
            }
        }
       stage('Run docker story image') {
          steps {
              dir('/var/jenkins_home/workspace/deploy/StoryModule'){
                  sh 'chmod +x deploy.sh'
                  sh './deploy.sh'
               }
            }
        }

이미지를 빌드하고 deploy.sh를 실행한다. 

#!/bin/bash

#story deploy

export DOCKER_REGISTRY=jodong2 DOCKER_APP_NAME=story-zero-downtime IMAGE_TAG=latest

EXIST_BLUE=$(docker compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yaml ps | grep Up)
# 블루가 떠있는지 확인 

if [ -z "$EXIST_BLUE" ]; then
    echo "blueis is not exist. so make blue container"
    echo "blue up"
    docker compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yaml up -d
    BEFORE_COMPOSE_COLOR="green"
    AFTER_COMPOSE_COLOR="blue"
    echo "end"
else
    echo "blue is exist. so make green container"
    echo "green up"
    docker compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yaml up -d
    BEFORE_COMPOSE_COLOR="blue"
    AFTER_COMPOSE_COLOR="green"
fi

sleep 10

EXIST_AFTER=$(docker compose -p ${DOCKER_APP_NAME}-${AFTER_COMPOSE_COLOR} -f docker-compose.${AFTER_COMPOSE_COLOR}.yaml ps | grep Up)
if [ -n "$EXIST_AFTER" ]; then

    docker compose -p ${DOCKER_APP_NAME}-${BEFORE_COMPOSE_COLOR} -f docker-compose.${BEFORE_COMPOSE_COLOR}.yaml down
    echo "$BEFORE_COMPOSE_COLOR down"
fi

deploy.sh파일은 떠있는 blue, green에 따라 각 docker-compose.yaml파일을 실행하게 된다. 

컨테이너가 띄워지는 시간으로 sleep 10초를 걸어놨는데, spring 메인 서버 같은 경우엔 애플리케이션을 시작하는데 약 20초를 필요로 한다. (사실 무중단임에도 팀원이 잠깐 내려가는거 같은데? 하길래 부랴부랴 수정했다..) 따라서 해당 메인 서버의 sh파일은 sleep이 25초로 설정했다. 

 

이후 다른 색의 컨테이너가 성공적으로 띄워졌다면 이전 컨테이너를 중지한다.

version: '3.7'

services:

  api:
    image: ${DOCKER_REGISTRY}/${DOCKER_APP_NAME}:${IMAGE_TAG}

    container_name: ${DOCKER_APP_NAME}-blue

    environment:
      - LANG=ko_KR.UTF-8

    ports:
      - '{port1}:8080'



    volumes:
      - story-logs:/var/log/app

volumes:
  story-logs:

port의 경우 blue와 green을 다르게 두고 해당 포트를 기준으로 로드밸런서가 구분할 수 있도록 하였다. 

예를들어 blue는 8081, green은 8082를 열어둔다. aws에서 보안설정도 잘 확인하자.... ;-; 

# 문제가 좀 있는.. upstream 구성.. 
upstream story {
    least_conn;
    server {ip}:{blue port1};
    server {ip}:{green port2};
    server {ip}:{blue port1};
    server {ip}:{green port2};
}

Nginx에서 upstream을 통해 blue green 각 서버로 연결한다. 

 이외에도 헬스체크 기능을 활용해서 죽은 서버에는 요청을 할 수 있지만 연결을 실패할 경우 nginx는 정해진 알고리즘에따라 다음 서버로 요청을 보낸다. 따라서 별도의 설정 없이도 올라가있는 컨테이너에 요청을 보낼 수 있다. 가장 처음 시도한 방식이고,,, 죽은 서버에 요청을 보내고 실패를 돌려받은 뒤 다음 서버에 요청을 보내는건 문제가 있는 방식이다.

 

Nginx - Health Check 

죽은 서버로 요청을 보내지 않기 위해 health check하는 방법이 있다. 서버가 정상 상태인지 실시간으로 계속 확인하는 방법으로 정상서버의 서버에게만 트래픽을 분배한다. 

upstream story {
    least_conn;
    server {ip}:{blue port1};
    server {ip}:{blue port1};
    check interval=3000 rise=2 fall=5 timeout=4000 type=http;
    check_http_send "HEAD / HTTP/1.0\r\n\r\n";
    check_http_expect_alive http_2xx http_3xx;
}

추가된 내용을 보면 3초단위로 2번 성공하면 정상, 5번 실패하면 비정상으로 판단한다. health check는 http프로토콜을 사용하고 HTTP1.0을 사용하여 보낸다. HTTP1.1~2를 주로 사용하지만 1.0을 사용한 이유는 연결에 성공해도 지속하지 않고 바로 끊는 특징을 갖고 있어서 1.0을 사용했다. 또한 응답코드가 2xx, 3xx라면 정상상태로 판단한다.

죽은서버에 요청을 보내며 네트워크에 지연이 발생할 수 있었던 설정을 위와 같이 죽은 서버에는 요청을 보내지않도록하는 Nginx의 health check기능을 사용하는 방법이 있었다.

 

 

2개의 nginx 설정파일과 deploy.sh 변경

docker compose로 컨테이너를 삭삭(팀원말투인데 머리속에 떠오르 짧고 간결한 표현이 이거밖에 안떠오른다ㅂㄷㅂㄷ; ) 바꾸는데 Nginx도 같이 갈아끼우고 reload하면 되지 않을까? reload하는 순간 짧은 down time이 있지만 health check한다고 자원을 계속 소모하는 방법보단 짧은 down time으로 자원을 아낄 수도 있다. 

 

변경된 deploy.sh이다.

#!/bin/bash

#story deploy

export DOCKER_REGISTRY=jodong2 DOCKER_APP_NAME=story-zero-downtime IMAGE_TAG=latest

EXIST_BLUE=$(docker compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yaml ps | grep Up)
# 블루가 떠있는지 확인 

if [ -z "$EXIST_BLUE" ]; then
    echo "blueis is not exist. so make blue container"
    echo "blue up"
    docker compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yaml up -d
    BEFORE_COMPOSE_COLOR="green"
    AFTER_COMPOSE_COLOR="blue"
    echo "end"
else
    echo "blue is exist. so make green container"
    echo "green up"
    docker compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yaml up -d
    BEFORE_COMPOSE_COLOR="blue"
    AFTER_COMPOSE_COLOR="green"
fi

sleep 10

EXIST_AFTER=$(docker compose -p ${DOCKER_APP_NAME}-${AFTER_COMPOSE_COLOR} -f docker-compose.${AFTER_COMPOSE_COLOR}.yaml ps | grep Up)
if [ -n "$EXIST_AFTER" ]; then
	sudo ln -sf /etc/nginx/sites-available/nginx.${AFTER_COMPOSE_COLOR}.conf /etc/nginx/sites-enabled/nginx.conf
        sudo service nginx reload
    docker compose -p ${DOCKER_APP_NAME}-${BEFORE_COMPOSE_COLOR} -f docker-compose.${BEFORE_COMPOSE_COLOR}.yaml down
    echo "$BEFORE_COMPOSE_COLOR down"
fi

다음 색깔의 컨테이너가 제대로 떴다면 다음색깔에 맞는 nginx 설정파일로 변경한뒤 reload 한다. 

reload하는 과정에서 짧은 down time이 발생할 수 있어서 무중단배포라기엔 칭호가 조금 아쉽지만 처음 생각한 방식은 이렇다.. 

 

nginx의 설정파일은 

upstream story {
    least_conn;
    server {ip}:{port1};
}

이렇게 간결해졌고 그에맞는 ip:port만 다른 파일을 설정해뒀다. 

server가 한대라면 location에서 바로 쏴도 될 듯 싶다 ! 

 

 


 

저번주에 올렸어야됐는데.. 할 게 많았다. 

프로젝트하랴 CS공부하랴..싸피 마지막 플젝이 끝났다 ! 

끝나도 시키는게 뭔가 많다. 서버 관련 설정파일은 나중에 다시 보기위해 저장해놔야되는데.. 아이고. .언제다하지.. 

다음주 주말에는 별일이 없다면 자바나 파이썬에 관련된 글을 올릴듯 싶다. 

 왜냐면.. 저번주에 자바에 SOLID에 대해 공부했었다. 옛날옛적에 면접준비한다고 부랴부랴 공부했을때는 이해가 잘 안됐는데 이번엔 "아이고 내 코드는 객체지향을 개떡같이 사용하고 있구나!" 를 느낄 수 있었다. 그래서 다음에 진행하게 될 프로젝트나 기회가 된다면, 지금까지 해왔던 프로젝트 하나를 잡고 수정해볼 생각이다. 

 파이썬을 포스팅한다면... 파이썬의 GIL에 대해 시간을 길게 잡고 자세하게 포스팅해볼 생각이다..

 

 

 

문제

제한사항

입출력 예 

ret : 3

풀이

import java.util.*;
class Solution {
	/*
    * 시간을 모두 분단위로 바꿈 
    * 종료시간은 청소해야되니까 +10분 
    */
    public int[] HtoM(String[] hour){
        int[] ret = new int[2]; 
        StringTokenizer stk ;
        for(int i =0 ; i <2; i ++){
            stk = new StringTokenizer(hour[i],":");
            ret[i] = 60*Integer.parseInt(stk.nextToken()); 
            ret[i] += Integer.parseInt(stk.nextToken()) + i*10; 
        }
        return ret;
    }
    
    public int solution(String[][] book_time) {
        int answer = 0;
               
        int[][] timeLine = new int[book_time.length][2];
        int i = 0; 
        for(String[] book : book_time){
            timeLine[i++] = HtoM(book);
        }
        
        // 입실 시간을 기준으로 정렬 
        Arrays.sort(timeLine, new Comparator<int[]>(){
            @Override
            public int compare(int[] a1, int[] a2){
                return a1[0] == a2[0] ? a1[1]-a2[1] : a1[0]-a2[0];
            }
        });
        
        // 퇴실시간이 빠른것부터 퇴실
        PriorityQueue<int[]> pq = new PriorityQueue<>((o1,o2)->{
            return o1[1] == o2[1] ? o1[0]-o2[0] : o1[1] - o2[1];
        });
        
        int cnt = 0; // 현재 방 갯수 
        int maxCnt = 0; // 최대였던 방 갯수 
        
        for(int[] time : timeLine){
            int[] last;
            while(!pq.isEmpty()){
                last = pq.poll(); 
                cnt -=1;
                if(time[0] < last[1]){
                // 가장 빠른 퇴실시간이 지금 손님 입실시간보다 느리면 방 추가.
                    pq.add(last);
                    cnt+=1;
                    break; 
                }
            }
            pq.add(time); 
            cnt+=1;
            maxCnt = Math.max(cnt,maxCnt);
        }
        return maxCnt;
    }
    
    /**
    * 디버깅용 print function
    */
    public void sysout(int[] a){
        System.out.println(""+a[0] + " "+ a[1]);
    }
}

 

 

느낀점 - "파이썬 VS 자바"

자바로 알고리즘을 안푼지 오래돼서 세미콜론때매 자꾸 오류가 났다;

프로그래머스는 자동완성도 안되니까 죽을맛이다.

 

알고리즘 문제를 풀 때 파이썬이 훨씬 편하다. 

 

예를들어 위 문제에서 2차원 배열을 0번 index기준으로 정렬할때

자바는 아래와 같이 정렬할 수 있다.

Arrays.sort(arr, new Comparator<int[]>({

    @Override
    public int compare(int[] o1, int[] o2){
    	return o1[0]==o2[0] ? o1[1]-o2[1] : o1[0]-o2[1];
    }
    
}));

파이썬은 아래와 같이 정렬할 수 있다. 

arr.sort(key= lambda x : x[0])

 

 

자바에서 저거보다 쉽게 2차원 배열을 정렬하는 법 있으면 알려주세여,.

 

 

우선순위큐도 역시 파이썬이 간단하다

위에 문제처럼 timeline 배열의 1번인덱스 변수를 기준으로 최소힙을 사용하고 싶다면

자바는 아래와 같이 pq를 만든 후 add를 진행하면 된다. 

PriorityQueue<int[]> pq = new PriorityQueue<>((o1,o2)->{
	return o1[1] == o2[1] ? o1[0] - o2[0] : o1[1] - o2[1]; 
});

pq.add(new int[]{1,2});
pq.add(new int[]{2,3});

 

 

 

 

파이썬은 heapq 모듈을 아래와 같이 사용하면 된다. 

arr =[] 
heapq.heappush(arr, (a[1],a))
heapq.heappush(arr, (b[1],b))

_, first = heapq.heappop(arr)
_, second = heapq.heappop(arr)

 

 

번외로 최소힙이 아닌 최대힙을 만들고 싶다면 자바는 lambda 식의 파라미터로 들어온 o1, o2중 뒤의 값에서 앞의 값을 빼면 된다. 

PriorityQueue<int[]> pq = new PriorityQueue<>((o1,o2)->{
	return o1[1] == o2[1] ? o2[0] - o1[0] : o2[1] - o1[1]; 
});

 

파이썬은 최대힙을 만들기 위해 음수를 한 번 곱해준다. 

arr =[] 
heapq.heappush(arr, (-a[1],a))
heapq.heappush(arr, (-b[1],b))

_, first = heapq.heappop(arr)
_, second = heapq.heappop(arr)

 

 


 

다음은 자바 익명객체하고 무중단배포 포스팅해야쥐ㅣㅇ

 

 

Consistency 

분산 환경을 공부하다보면 CAP를 마주하게 된다. 

 

우선, 분산처리환경의 CAP는 Consistency, Availability, Partition tolerance이고, 트랜잭션의 ACID는 Atomicity, Consistency, Reliability, Isolation, Durability이다. 이글은 각 개념보다는 C에따른 A?.. 위주로 정리하고자 한다. 

 

 

ACID의 C와 CAP의 C는 같은 Consistency지만, 의미가 다른 Consistency이다. 

 

ACID의 일관성(Consistency)는 트랜잭션이 완료되었을 때 기존의 제약을 위반하지 않아야한다는 일관성이고, 

CAP의 일관성(Consistency)는 모든 요청에대해 최신 데이터, 또는 에러를 응답받아야한다는 일관성이다. 

 

ACID의 일관성을 예로 들어보면, 은행에서 출금 후 잔고가 음수이면 안된다. 음수일 경우 트랜잭션은 실패해야하고, rollback되어야 한다. 즉 기존의 제약을 위반하지 않는 데이터로 일관돼야한다. 

 

CAP의 일관성을 예로 들어보면, 부산에서 돈을 출금했을때, 일산에서 잔고를 확인하나, 서울대입구에서 잔고를 확인하나 최신에 업데이트된 값을 보여주어야한다. 

 

 

먼저 CAP이론은 한계가 명확하다.

가장 아래에 참고한 영상 링크를 걸어두었는데 그 영상의 제목은 You don't need CP, you don't want AP, and you can't have CA다.

극단적으로 CAP 중 2개를 만족하는 구조는 불가능, 혹은 장점이 없다. 따라서 더나은 CAP이론의 해석은 "가용성과 일관성은 어느정도 상충관계에 있지만 극단적으로 둘 중 하나만 선택해야하는건 아니다." 이다. 

 

CP와 AP 그 사이 어디쯤으로 선택해야하는데 CP가 얼마나 우선될지, AP가 얼마나 우선될지 그 정도를 선택하는게 중요하다.

 

 또한 파티션이 없는 상황을 설명하지 못한다는 것이다. 파티션이 없는 상황에서도 분산 시스템은 상충하는 특성들이 있고, 장애 상황만큼이나 정상 상황에서 시스템이 어떻게 동작하는지도 중요하다.

 

CAP의 이러한 한계로 PACELC 이론이 나왔는데 마지막에 간단하게 정리해보자.. 

 

 

Eventual Consistency 

분산 시스템을 구축하다보면 C와 A 중 우선순위를 선택할때가 온다.

Eventual Consistency는 고가용성을 보장하는 일관성 모델이다. 

 

jodong2라는 서버와, dongineer라는 서버가 있다고 가정하고, 그 두개는 별도의 데이터베이스를 사용한다고 가정하자. 

 

클라이언트가 요청을 보내고, jodong2라는 서버에 데이터베이스의 데이터가 수정되었다고 하자. 이때 dongieer의 데이터베이스에 해당 데이터와는 내용이 다를 수 있다. 

이때 다른 클라이언트들이 해당 데이터를 읽기위한 요청을 보낸다면 

 

  1. 모든 서버가 동일한 데이터를 갖도록 동기화하는 동안 클라이언트의 접근을 막는 경우 - 가용성 X
  2. 어떤 클라이언트는 최신화된 jodong2의 데이터를, 어떤 클라이언트는 최신화되지 않은 dongineer의 데이터를 보여주는 경우 - 일관성 X

두가지 경우가 있다. 이때 2번이 Eventual Consistency 방식이다. 2번의 경우에는 언젠가 동기화 작업이 완료되면, 모든 클라이언트가 동일한 데이터를 볼 수 있다. 단기적으로는 전체적인 일관성을 잃을지 몰라도, 결과적으로 전체적인 일관성을 보여주는 모델이 Eventual Consistency 모델이다. 

출처 :&nbsp;https://cloud.google.com/datastore/docs/articles/balancing-strong-and-eventual-consistency-with-google-cloud-datastore?hl=ko#what-is-eventual-consistency

위의 그림에서 Node A는 상위노드이고, B,C는 복제본이다. Node A에서 X 데이터에 대해 쓰기 작업이 이뤄지는 중이라도, 혹은 동기화가 되지 않아도 항상 X 데이터를 읽을 수 있다. 이때 B는 동기화되고, C는 동기화 되지 않았을 경우에도, 각자 갖고 있는 데이터중 최신 데이터를 보여준다. 이때 각 데이터는 차이가 발생할 수 있지만, 언제나 접근 가능하다는걸 보여준다. 

인터넷 DNS(도메인 이름 시스템)는 eventual consistency 모델이 사용된 시스템의 예로 잘 알려져 있습니다. DNS 서버가 항상 최신의 값을 반영하는 것은 아니며, 이러한 값들은 인터넷상의 수많은 디렉터리에서 캐싱되고 복제됩니다. 수정된 값을 모든 DNS 클라이언트와 서버에 복제하려면 어느 정도의 시간이 소요됩니다. 하지만 DNS 시스템은 인터넷의 근간을 이루는 요소로 자리잡은 매우 성공적인 시스템입니다. DNS는 가용성이 매우 높으며 엄청난 확장성이 증명되었고, 인터넷 전체에서 수천만 대 기기의 이름 조회를 가능하게 하고 있습니다.

 

 

Strong Consistency 

반대되는(?) 개념으로 Strong Consistency가 있다. 

출처 :&nbsp;https://cloud.google.com/datastore/docs/articles/balancing-strong-and-eventual-consistency-with-google-cloud-datastore?hl=ko#what-is-eventual-consistency

  1. 모든 서버가 동일한 데이터를 갖도록 동기화하는 동안 클라이언트의 접근을 막는 경우 - 가용성 X

 위에서 보여준 예시중 1번과 관련있는 Consistency 모델로 기본적인 관계형 데이터베이스가 그 대표적인 모델이다. 

Strong Consistency 모델은 모든 클라이언트에게 동일한 데이터(일관성)를 보여주기 위해 가용성을 어느정도 포기한 모델인데, 관계형 데이터베이스는 데이터베이스의 여러 인스턴스가 항상 동일한 데이터를 갖도록 한다. 이때 동기화 작업 중 lock이 걸릴 수 있으며 이는 가용성에 문제가 생긴다. 따라서 설계하기 나름이지만, 기본적인 관계형 데이터베이스는 일관성을 우선시한 대표적인 모델이다. 

 

 

 

 

PACELC

CAP이론은 위에서 말한 한계점이 있다. 

PACELC는 CAP이론을 대체하기 위해 나온 이론이다. CAP이론은 정상 상황을 서술하지 못한다는 문제를 해결하기 위해 나왔다. 서버가 Partition 된 상황이라면 A(Availability) 와 C(Consistency)를 골라야하고, E(Else) L(latency) 와 (Consistency)를 골라야한다. 

 

 분산 DB에서 파티션(Partition)일때 Availability를 선택하여 모든 DB에 반영하기보다는, 접근 가능한 노드에만 반영하면 PA, 반대로 모든 DB 반영되는걸 기다리며 Consistency를 선택한다면 PC이다. 

 정상상황 (Else) 일 때는 빠르게 처리하기 위해 몇몇 DB에만 반영하며 Latency를 선택하면 EL, 모든 DB에 적용하여 Consistency를 선택하면 EC이다. 이때 Latency를 선택한다는 것은 지연시간을 줄이는것을 선택하는 것이다. 

 


 프로젝트를 진행하며 MSA를 공부하고 있다. 내가 담당한 부분 중 하나는 인스타그램의 스토리와 유사한 기능이다. 

 알림과 유사한듯 유사하지 않은 스토리 기능은 일관성이 크게 중요하지 않은 기능이다 ! 

 

 

참고 

 

https://cloud.google.com/datastore/docs/articles/balancing-strong-and-eventual-consistency-with-google-cloud-datastore?hl=ko#what-is-eventual-consistency 

 

Datastore로 strong consistency와 eventual consistency 간에 균형 유지  |  Cloud Datastore 문서  |  Google Cloud

Google Cloud Platform을 사용하면 Google의 확장 가능한 인프라에서 애플리케이션과 웹사이트를 빌드 및 호스팅할 수 있으며, 데이터를 저장 및 분석할 수 있습니다.

cloud.google.com

https://www.oracle.com/kr/database/what-is-a-relational-database/

 

관계형 데이터베이스란?

관계형 데이터베이스의 정의와 이를 비즈니스에 이용하는 방법을 알아봅니다.

www.oracle.com

https://velog.io/@soongjamm/Eventual-Consistency-%EB%9E%80

 

Eventual Consistency 란?

분산 시스템을 구성하려면 CAP 이론에 의해서 일관성과 가용성 중 하나를 포기해야하는 상황이 올 수 있습니다.클라이언트의 요청을 받았을 때, A서버의 데이터가 변경되면 즉시 다른 서버에 반

velog.io

https://www.youtube.com/watch?v=hUd_9FENShA

 

'CS > DB' 카테고리의 다른 글

SQL Injection이란?  (5) 2022.10.03
DB 데드락이란?  (3) 2022.08.30
DB Index 란  (7) 2022.08.15

사진은 자주 찍으면서 왜 일기는 작성하지 않을까? - 기획

 어떤 블로그에서 봤던 글귀인데 다시 못찾겠다..

 이미지를 통해 시각적으로 제공하는 정보가 텍스트에 비해 훨씬 많고 편리하다. 누구나 들고 다니는 핸드폰으로 터치 한 번에 고화질 사진이 찍히는 시대에서 시간을 내고 일기를 작성한다는 것은 어떻게 보면 시간 낭비라고 생각할 수도 있다. 

 

 하지만 일기는 당시의 내 생각을 구체적으로 적을 수 있다. 동시에 글쓰기 능력이 향상되고, 표현능력을 향상시킬 수 있다. 그리고, 아무에게도 말못할 사정과 심정을 표현하며 스트레스를 해소할 수 있다. (내 군대에서의 일기를 보면 간부, 선임 욕이 반이다.) 아무튼 그날의 감정과 생각을 구체적으로 회상할 수 있는 일기를 사람들이 왜 안쓸까? 책상 앞에서 작성을 하던가, 폰으로 작성을 하던가 양손이 묶여있어야 한다. 책상앞에서 종이에 작성한다고 하면 침대에서 거기까지 가기 얼마나 힘든일인지.. 또한 시간을 많이 소비해야하며, 일기와 같이 경험했던걸 다시 정리하기보단 유튜브나 넷플릭스 같이 새로운 컨텐츠를 경험하는것이 자극적이고 즉각적인 보상과 재미가 있다.

 

 침대에 누워서, 혹은 시간에 구애받지 않고 편리하게 일기를 재미있게 작성할 수 있는 방법을 생각해봤다. 

 바로 음성녹음이다. 핸드폰이 내 목소리가 들리는 위치에 있다면 작성할 수 있다. 침대에 누워서도, 변기에 앉아서도, 샤워하면서도 작성할 수 있다. 그럼 음성녹음으로 일기를 작성할 때 발생하는 다른 불편한 점은 없을까? 그 날의 내용을 한 눈에 파악하기 힘들며 그날 기분이 어땠는지 음성을 들어봐야 알 수 있다는 단점이 있었다. 글로 작성된 경우 해당 내용을 문단별로 넘어가며 파악하기 쉬우나 음성은 넘겨 듣는다해도 비교적 파악하기 힘들다.

 

 이러한 단점들을 해결하기 위해 노력했다. 이 회고글은 그 방법들에대한 내용, 아쉬운 점, 좋았던 점, github에 코드로 작성되지 않은 부분을 위주로 정리하는 글이다. 

 


달력을 통한 그날의 대표 감정 표시 

ㅎㅎ 캐릭터도 팀원분이 직접 그렸다.

 달력을 통해 녹음된 일기에서 그날의 대표 감정을 분류하여 한 눈에 보여주고자 했다. 

 이를 통해 어떤 감정으로 이 달을 살았는지, 굳이 음성을 다 들어보지 않아도 어떤 감정이었는지 사용자에게 보여주고자 했다. 물론 말은 쉬웠으나 기술적으로 많은 문제가 있었다. 

 

 STT(Google api 활용)를 활용하여 음성기반으로 작성된 일기를 텍스트기반으로 작성된 일기와 동일하게 보여주고자 했다. 

 이를 통해 꼭 음성일기를 꼭 들어봐야한다는 단점을 해소하고 그 날의 일기 내용을 조금 더 수월하게 파악할 수 있도록 했다. Google api를 활용한건 사실 무엇보다 50건정도가 무료라는 점이 가장 컸고 외국기업임에도 불구하고 높은 정확도를 보여주었다.

 

 

감정 분류 - AI 

감정을 분류하는데 중요한 정보는 발화 내용과 목소리라고 생각했다. 

예를 들어 "미친거 아니야??" 라는 글이 있다고 했을 때 "ㅋㅋㅋ미친거 아니야?" "와..미친거 아니야?" 는 다른 감정을 표현하고 있고 

이걸 분류하기 위해서는 내용뿐만아닌 목소리에서 나오는 정보가 중요하다고 생각했다. 

 

 따라서 Text기반 감정분류 모델, 음성기반 감정분류 모델을 학습시킨 뒤, 각 모델의 결과를 앙상블하여 사용하고자 했다. 

 이때 Text기반 감정분류는 위에서 말한 STT를 통해 만들어진 Text를 기반으로 감정을 분류했다. 음성기반 감정분류는 팀원분께서 직접 모델을 설계하고 학습시켜 활용했다. 약 5주 정도의 기간에 f1 score기준 약 0.62를 보여주었다. 인공지능을 처음 접하신 분이였음에도 불구하고 짧은 시간동안 잘해주셨다. Text는 KoBERT를 활용했는데 f1 score로 약 0.81를 보여주었다. 약 2주 동안 진행했는데 NLP모델은 처음 경험해봐서 모델을 공부해가며 직접 설계하고 학습시키기에는 내 할 일이 너무 많았다. 따라서 데이터 위주로 변경하여 정확도를 향상시키고자 했다. 

 

 데이터셋은 이곳저곳에서 많은 데이터를 가져왔다. 그리고 pandas의 stratified split을 통해 test data set을 나누어 따로 저장했다.

 데이터의 불균형은 예상된 문제였고 이를 해결하기위해 augmentation 기법을 찾아봤다. Image의 경우 상하좌우 반전하고 잘라붙이고 섞고 노이즈 넣고 난리부르스를 쳤었는데 Text는 어떻게 해야할지 감이 안잡혔다. 여러 방법을 찾아봤는데 주어 동사 앞뒤를 바꾼다던가, 단어의 위치를 치환한다던가 여러 방법이 있었다... 

 

감정의 애매함

 일반적인 감정분류 데이터셋을 보면 공포, 혐오, 놀람, 분노, 중립, 행복, 슬픔 으로 7가지 클래스가 존재했다. 

 근데 감정이란게 너무 애매했다. 예를 들어 "직장 상사가 일을 끝도 없이 줘" 라는 데이터에 분노라고 라벨링되었으나, 내가 저런 말을 할때면 슬픔을 느꼈을것이다. 이런 애매함을 해결하기 위해 음성 분류 모델을 추가한거지만 일단 학습자체가 잘 안됐다. 직장 상사라는 말만 나오면 죄다 분노로 분류했다. 예를 들어 "직장 상사랑 밥먹었어." 라는 문장도 분노로 표시했고, 당시 test data set에 대한 f1 score는 0.6~7 정도였다. 결과에 비해 높은 f1 score를 보여주었다고 생각됐고, 데이터 자체가 애매하다는 생각이 커졌다. 

 

감정을 줄이기로 했다.

 예를 들어 공포와 놀람, 혐오와 분노는 어느정도 비슷한 감정이라 생각했다. 

 "엘레베이터에 갇혔어" 라는 문장은 공포라고 라벨링 되어있었으나 놀람이라해도 무방했다. 또한 "룸메이트가 술만 마시면 맨날 바닥에 토해" 라는 문장은 혐오라고 라벨링 되어있었으나 우리 팀원 모두가 분노라고 생각했다. 공포와 혐오에 대한 데이터셋을 100개씩 추출해서 읽어보았을때 십중팔구 합쳐질 수 있는 감정이었다. 심지어 분노, 놀람 등은 다른 감정들에 비해 적은 수의 데이터가 있었고 이를 합친다면 데이터셋도 비교적 균형잡힌 데이터로 바뀌었다.

 다른 팀원들과 상의 끝에 감정의 갯수를 줄이기로 결정했고 최종적으로 놀람, 분노, 중립, 행복, 슬픔 다섯가지 감정으로 나누었다. 그 결과 새롭게 만든 test data set에 대해 f1 score는 0.81을 보여주었다. test data set도 변경되고 class도 줄어들어 정확한 정확도 비교는 아니였지만, 눈에 띄는 정확도 향상과 실제 새로운 텍스트를 입력했을때 보여주는 결과는 아래와 같았고, 만족스러웠다. 

 


 

DevOps 환경 구축

 7주 정도의 기간동안 진행된 프로젝트로 완성도 높은 서비스를 만들려면 빠른 기획과 빠른 개발이 필수적이었다. 

 빠른 기획과 빠른 개발은 아무리 꼼꼼하게 진행하려 노력하더라도 빈틈이 보이기 마련이고, 그때마다 다시 수정하고, 개발, 배포하는 과정이 필요했다. 따라서 폭포수보다는 애자일방식을 필요로 했다. API명세서는 초기에 꼼꼼하게 작성한다고 했지만 일정부분 변경이 이뤄졌고, 추가된 기능, 삭제된 기능도 있었다. 매일 스크럼을 진행하고 수정사항과 개선사항, 문제점등을 공유했다. 프론트엔드와 백엔드, 인공지능을 담당한 팀원들이 서로 협업하며 개발, 배포를 반복했는데 이 과정에서 프론트엔드는 빠르게 배포된 백엔드 서버에 테스트하며 개발을 진행했고, 백엔드는 프론트엔드와 소통하며 response를 수정해나가고, 분류 모델은 정확도가 올라가면 해당 모델로 변경했다. 

 

 Jenkins와 Gitlab을 연동하여 Develop 브랜치의 내용이 변경될때마다 자동으로 빌드하고 Docker를 활용하여 배포했다. 해당 develop 브랜치는 개발서버로 개발속도가 느리던 프론트엔드가 실제 서비스 환경과 같은 백엔드 서버에 직접 요청하며 테스트할 수 있도록 하기 위함이었고, 프론트엔드의 특정 부분이 완성되기까지 백엔드 서버가 미처 발견하지 못했던 에러나, 수정사항을 찾고 반영하며 업데이트가 계속 이뤄졌다. 그리고 특정 기능이 완성되면 release 브랜치에 머지하여 빌드하고 배포했다.(를 계획했으나 결국 마지막 한 번의 release 브랜치 배포로 끝났다 ㅎㅎ;; ) Spring 서버의 경우 1~2분으로 짧은 다운타임이 있었으나,  FastAPI서버의 경우 음성모델은 TF, 텍스트모델은 Torch로 구현되어 두개의 라이브러리를 설치하는데 약 13분의 긴 다운타임이 있었고, 해당 다운타임은 일기를 작성중이던 사용자들에게 소중한 일기 한 편 한 편이 날아가는 치명적인 문제점이었다. 따라서 Blue-Green 방식의 무중단 배포환경을 구축하여 서비스의 다운타임을 해결하였다. 

 

모델 가중치 파일의 흐름... 이거보다 자세하게 그릴수가 없는데 팀원은 왜 뭐라하지..

 인공지능 모델의 경우 처음 생각한 방법은 MLFlow를 도입하여 실험을 추적관리하고 API를 통해 활용하고자 했다. 

 하지만 MLFlow를 도입하고 사용해보는 것은 순전히 내 욕심이었다. 팀원의 진행사항이나 문제점을 계속 듣고 살펴본 결과, MLflow까지 적용하면 벅찰듯했다. 무엇보다 나도 정확하게 아는것이 아니며 사용해본 경험이 없었으므로 사용하자는 말을 꺼내기 망설여졌다. 그 결과 나는 TensorBoard나 WandB를 팀원에게 추천해주었고, TensorBoard를 통해 실험내용을 공유했다. 그리고 결국에는 학습된 모델을 활용하기 위해 gpu서버에서 학습하여 저장된 가중치 파일을 구글 클라우드에 올린 뒤 EC2 혹은 로컬에서 다운받아 사용했다.

 

 


 

 아쉬운 점 ;-; 

일단 가장 아쉬운 점은 하나를 깊게 가져가지 못했다는 점이다. 정신차려보니 BE, Infra, AI를 담당했는데 그 결과 인공지능의 경우 기존에 알던 내용을 기반을 위주로 진행했다. 즉 별도로 깊게 공부하지 않고 NLP에 손을 댔다.. 

 

 내 멋대로 Ensemble..

 일기는 여러개의 문장으로 구성되어있다.  문장이 쌓이고 쌓일수록 긴 글이되고 이는 NLP모델이 글을 해석하기 힘들어진다고 알고있다. 이를 해결하기 위해 LSTM, Transformer 등 다양한 기법이 나왔는데 KoBERT는 Transformer 기반 모델이다. 아무튼 여기서 조금 아쉬운 점은 별도의 실험 없이 느낌으로... 전체적인 글을 한 번에 예측하기보다 문장별로 나누어 분류하고 합치는게 더 정확도가 높지 않을까 싶어서, 나는 문장별로 감정을 분류한 다음 가장 많이 나온 감정을 대표 감정으로 설정했다. 여기서 추가된 api가 감정 변화 그래프이다. 대표감정만을 그래프로 보여주기보다 더 많은 정보를 보여주기 위해 각 문장별 감정을 세고, 그래프로 보여주었다. 그리고 대표 감정을 분류할때 음성기반의 감정 분류 결과를 Weight Voting Ensemble을 진행했다. 이때 음성에 대한 weight를 고정값으로 주었는데 이는 음성과 텍스트 분류의 정확도를 비교하여 단순 감으로 때려잡은 값이었다...ㅎㅎㅎ;; 이 값을 차라리 전체 문장의 개수와 길이, 각 모델의 confidence까지 고려하여 Soft voting으로 했다면 더 좋은 정확도를 보여줄 수 있었다고 생각한다. 

 

 MLOps를 제대로 경험하지 못한 점은 여전히 아쉽다. 

 인공지능 모델의 경우 수동으로 서버에 저장했다. 프로젝트의 볼륨이 작아 실험이 많지 않았기에 불편하진 않았지만 나중에 실험하는 양도 많아지고 볼륨이 커지면 분명 번거로운 작업이라 생각된다. 이를 자동화하고 싶었는데 그러지 못한 점이 아쉽다. 그래도 무중단 배포 환경은 정말 만족할만한 경험이었다. 또한 사용자로부터 감정이 수정되었을때 해당 데이터를 저장하여 나중에 추가로 학습한 뒤 모델의 성능을 향상시키고자 했는데 이또한 적용하지 못했다.. 가장 아쉬운 점이다. 

 

 Backend 역할에서 새롭게 배우고 적용해본것이 적다. 

 거의 없다고 봐도 무방하다.. Java에서는 stream을 공부하고 구분하여 적용하였는데,,,, 아직도 언제 뭘 써야지! 라는 확신은 없다. 그리고 이전에는 동작원리도 자세히 모르고 사용했던 JPA에 대해 공부했다. 공부하고 보니 이전 프로젝트에서는 정말 개판으로 사용했다는걸 알았다. ;-;..  FastAPI에서는 인공지능 모델을 싱글톤으로 사용했다. 서버가 재실행됐을때 바로 모델을 불러온 뒤 이후에는 계속 해당 객체를 불러와 사용하였다. 이때 여러 요청을 보내도 이미 할당된 객체를 불러왔다 !  이외에는 뭐.. 딱히 새롭게 배우고 적용해봤다 ! 개선했다 ! 라고 할만한 경험이 없어 아쉬웠다. 

 

 


 

부캠의 회고와 싸피의 회고

 

 부캠 AI Tech에서는 대회가 끝날때마다 회고글을 제출해야해서 작성한 경험이 있다.

 형식도 정해져있지 않았고 순전히 내 멋대로 작성해서 제출해야했다. 내가 프로젝트를 하며 생각났던 짤이나 문구를 붙여넣으며 회상하기 쉽도록 작성했다. 

내 첫 회고글.

 아직도 저 EDA,,, 그게뭔데,, 하던 감정이 생생하다. 당시 팀원들이 pd, plt, seaborn 뭐 다 잘쓰고 그러니까 위축돼서 팀원들의 코드를 참고하며 열심히 데이터를 파악하려 노력했다. 아무튼 저런 회고글을 올리며 부족한점, 아쉬웠던 점들을 정리하고 기록하며 다음 대회, 프로젝트에서는 발판삼아 빠르게 발전할 수 있었다. 

 

 싸피에서는 회고를 강제하지 않는다. 때문에 많은 교육생이 프로젝트를 회고하지 않는듯하다. 

 블로그 포스팅을 자주하는 팀원이 아니였으면 나도 싸피가 끝날때까지 회고를 미뤘을거 같다..

 

 싸피와 부캠 AI Tech를 비교했을때 각자 장점이 있지만 회고, 공유 문화는 부캠이 압도적으로 좋은듯 하다. 싸피의 경우 프로젝트를 엄청나게 잘만들어도 프로젝트 발표를 못하면 그만이라는 생각이 크다. 때문에 발표에 있어 사용된 기술을 공유하고 소개하며 아쉬운 점, 어려운점을 공유하기엔 청자의 관심을 끌기 힘들며 발표에서 좋은 점수를 받기 힘들다. 너무 다양한 프로젝트가 존재하고, 분야가 다른걸 개발하는데서 오는 한계라고 생각된다. 반대로 부캠의 경우 CV, NLP를 나누어 대회를 진행하고, 각 대회에서 모든 팀원들을 섞어 기술이나 문제점을 공유하고, 성능이 오른 방법을 공유하며 모두가 함께 최고 점수의 벽을 뚫기 위해 노력했다. 

 

 실제 프로젝트를 소개하고 발표하는 자리라면, 싸피만큼 좋은 문화가 또 없다. 부캠이 기술에 집중했다면, 싸피의 경우 프로젝트에 집중했다고 생각한다. 기술 말고도 발표 자체에 대한 피드백도 받을 수 있다. 개발외적으로도 신경을 많이 써주는게 싸피라고 생각된다. (돈도 많이 주고 밥도 맛있음.)

 

 

 

프로젝트 repo

 

GitHub - JODONG2/VODA

Contribute to JODONG2/VODA development by creating an account on GitHub.

github.com

 

JPA (Java Persistence API)

JPA는 JAVA의 객체지향과 관계형 DB를 매핑해주는 ORM(Object Relational Mapping) 기술이다.

 

장점으로는 

1. 특정 데이터베이스에 종속되지 않는다.

2. 객체지향적이다.

 

단점으로는

1. 복잡한 쿼리는 처리하기 힘들다.

2. 자동으로 생성되는 쿼리로 인해 개발자가 의도하지 않은 쿼리를 날리면서 성능저하가 있을 수 있다. 

3. 쓰긴 쉬운데 잘쓰긴 어렵다.

++ 특정 데이터베이스에 종속되지 않는다.

#Oracle의 현재 시간 
SYSDATE

#MySQL의 현재 시간
NOW()

위처럼 DB마다 쿼리가 조금씩 다르다. 하지만 JPA는 추상화된 데이터 접근 계층을 제공하고 이를통해 같은 함수라면 같은 결과를 기대할 수 있다. 

 

++ 객체지향적이다. 

@Entity
@Table(name = "A")
public class A{
	. . . 
    @NToM(~~)
    @JoinColumn(name="b_id")
    private B b;
}

테이블 하나와 매핑되는 클래스가 뚝딱 만들어지는데 이는 몹시 객체지향적이다. 내부에서 다양한 annotation을 활용하여 연관관계(N:M, 1:N, N:1, 1:1)를 매핑할 수 있다.  


Entity

JPA Entity Manager

Entity

entity란 DB의 테이블에 대응하는 클래스이다. 

@Entity annotation을 통해 Entity Manager가 관리한다.

 

Persistence Context (영속성 컨텍스트)

entity를 영구 저장하도록 지원하는 환경으로 Entity manager를 통해 접근한다. 

Application과 DB 중간사이의 계층의 영속성 컨텍스트는 버퍼링과 캐싱 등에서 이점을 갖는다. 

Entity Manager

Persistence Context 에 접근하여 DB작업을 제공하는 객체이다.

따라서 Entity Manager를 통해 DB Connection을 이용하고 DB에 접근한다.

Transaction단위를 수행할때마다 생성된다. 즉 사용자의 요청이 올때마다 생성되고 끝나면 닫는다.

Transaction 수행후에는 반드시 Entity Manager를 닫으면 내부적으로 DB Connection을 반환한다.

thread간 공유하지 않는다. 

Entity Manager Factory 

entity manager instance를 관리하는 주체이다.

Application 실행 시 한개의 Entity Manager Factory가 생성된다.

사용자로부터 요청이 오면 Entity manager를 생성한다. 

 


Entity Lifecycle 


new : 비영속 상태로 new 키워드를 통해 생성된 상태이다. 아직 영속성 컨텍스트에 저장되지 않았다.

Managed : 영속 상태로 엔티티가 영속성 컨텍스트에 저장되어 관리되는 상태이다. Application과 DB사이 계층에 존재하여 DB의 값과 차이가 있을 수 있다. 트랜잭션 commit 후 DB와 값을 일치시킨다.

Detached : 준영속 상태로 엔티티가 저장되었다가 분리된 상태이다. commit 전에 detached됐다면 엔티티가 변경되어도 쿼리가 날아가지 않는다. 

Removed : 삭제 상태로 영속성 컨텍스트에서 삭제되고 flush()혹은 commit()이 실행되면 DB에서도 삭제된다. 

 

 

 

 

* commit()과 flush()

flush는 jpa의 영속성 컨텍스트와 DB를 동기화하는 작업을 말한다. flush()가 실행되면 DB에 쿼리를 날린다.

이 때 변경감지(Dirty Checking)를 통해 수정된 entity를 update하는 쿼리를 날린다. 또한 *쓰기 지연된 쿼리들을 한 번에 보낸다. 

 

commit은 트랜잭션을 begin()으로 실행한 뒤 commit()하여 트랜잭션을 종료한다. 이때 commit()은 내부적으로 flush()를 호출하며 트랜잭션을 끝내는 역할을 한다. 

 

가장 큰 차이는 flush를 통해 전송된 쿼리는 rollback될 수 있지만 commit은 트랜잭션을 종료하므로 rollback될 수 없다. 

 


Persistence Context 이점

Persistence Context는 Application과 DB사이의 계층에서 여러가지 작업을 수행할 수 있다. 

1차 캐시

영속성 컨텍스트 내에서 저장되는 캐시로 Map<Key, Value>로 저장된다.

entityManager.find() 메소드 호출 시 1차 캐시에서 조회하고, 없으면 DB에서 조회 후 1차 캐시에 저장하고 반환한다.

즉, 처음 find 호출 시 1차 캐시에 존재하지 않는다면 SELECT query가 날아가고, 다음 같은 key값을 find할 경우 SELECT query는 날아가지 않는다.

이를 통해 DB에 접근하는 횟수를 줄이며 성능을 향상시킬 수 있다. 

 

동일성 보장

하나의 트랜잭션에서 같은 Key값은 같은 Entity 조회를 보장받을 수 있다. 즉, == 연산에서 결과값이 true이다. 

반대로 MyBatis는 조회 결과를 다시 인스턴스화하여 return하기때문에 == 연산에서 결과값이 false이다. 

 

변경감지 (Dirty Checking)

1차 캐시에 DB에서 처음 불러온 entity의 스냅샷을 저장한다. 

이후 스냅샷과 비교하여 다른점이 있다면 UPDATE쿼리를 사용한다. 

find 메소드를 호출하여 entity를 불러오고, 값을 변경한 뒤 save메소드를 호출하면 insert가 아닌 update 쿼리가 날아가도록 해준다. 

반대로 entity의 ID와 1차 캐시에 같은 ID를 찾을 수 없다면 insert 쿼리가 날아가도록 해준다. 

*쓰기 지연

영속성 컨텍스트는 트랜잭션 처리를 도와주는 쓰기지연을 지원한다.

 한 트랜잭션안에서 이뤄지는 UPDATE나 SAVE의 쿼리는 쓰기지연 저장소에 저장되었다가 트랜잭션이 commit(내부적으로는 flush)되는 순간 DB에 날린다. 이를통해 DB Connection 시간을 줄일 수 있고 트랜잭션이 테이블에 접근하는 시간을 줄여 교착상태 등을 방지할 수 있다. 

 

** 하지만 특정 INSERT 쿼리는 즉시 날아간다. 

Entity가 영속(Managed)상태가 되려면 식별자(PK)가 필요하다. 이때 Entity의 ID 생성전략을  IDENTITY로 사용한다면, 이는 데이터베이스에 실제로 저장한 뒤 다른 식별자들과 구분한다. 따라서 Insert쿼리는 즉시 날아가고 이런 경우에는 쓰기지연을 통한 성능 최적화를 얻기 힘들다. 

생성전략을 Sequence로 사용한다면, entityManager가 entity를 영속화하기전에 DB Sequence를 먼저 조회하고 조회한 식별자를 통해 Entity를 생성한뒤 영속화한다. 그 후 flush를 통해 Entity를 DB에 저장한다.

 

 


이전에 JPA를 사용해보기전에도 공부를 목적으로 포스팅을 한 번 했었다. 지금 그 글을 보니 너무 뜬구름 잡는 내용이고 개념도 정확하게 작성되지 않은 게시글을 포스팅했다. 지금은 삭제했다. 수정하는거보다 이렇게 새로 적는게 빠를 정도였으니까 :-)

발그림ㅎㅎ;

롤링 배포 - 개념

 롤링 배포는 현재 사용 중인 인스턴스 내에서 새 버전으로 하나씩 교체해나가는 것이다.

 롤링 배포를 이용하게 된다면 기존에 유지하고 있는 인스턴스로만 진행하기 때문에 추가적으로 비용이 발생하지않는다. 또한 점진적으로 업데이트하기때문에 롤백이 비교적 쉽다. 하지만 새로운 버전으로 교체하는 과정에서 일부 인스턴스가 연결되어 있지 않기 때문에 트래픽을 감당할 수 있는 선에서 진행해야하며 이전 버전과 새로운 버전의 호환성 문제도 생각해야 한다. 

 

롤링 배포 - 실습

 

1. spirng actuator 설정

build.gradle

다음과 같이 build.gradle에 spring actuator 의존성을 추가한다. 

spring actuator는 서버의 여러가지 상태를 확인할 수 있는 기능이다. 하지만 민감함 정보도 포함될 수 있으므로 우리가 필요한 health만 사용하도록 yml(properties)파일을 수정해주자. 

application.properties

설정 후 /actuator/health 를 확인하면 {"start":"UP"} 을 확인할 수 있다.

 

2. nginx 설정

nginx pro버전에서는 health-check 기능을 지원하지만, 내 지갑은 휑하므로 무료 health-check 모듈이 포함된 nginx를 사용할 것이다. 

먼저 nginx 설정 파일을 만들어보자 

vi nginx.conf

events {}

http {
  upstream market {
	# 배포 중인 서버들
	server ip:port;
	server ip:port;

	#health-check
	# interval - 3초씩에 한 번씩 확인
	# rise - 2번 이상 응답에 성공하면 서버가 살은 것으로 판단
	# fall - 5번 이상 응답에 실패할 경우 서버가 죽은 것으로 판단
	# timeout - 응답 시간 초과 1초
	check interval =3000 rise=2 fall=5 timeout=1000 type=http;
	# GET /actuator/health으로 healthcheck 요청
	check_http_send "GET /actuator/health HTTP/1.0\r\n\r\n";
	check_http_expect_alive http_2xx http_3xx;
  }

  server {
    listen 80;
    location / {
       proxy_pass http://market;
    }
    # 서버 상태 확인 url
     location /status {
     check_status;
   }
  }
}

다음 nginx image를 빌드하기 위해 Dockerfile을 작성한다. 

vi Dockerfile

# mrlioncub/nginx_upstream_check_module - health-check 모듈이 포함된 nginx image
FROM mrlioncub/nginx_upstream_check_module
COPY nginx.conf /etc/nginx/nginx.conf

Docker file을 작성했으니 이미지를 빌드하고 실행하자 

docker build --tag nginx:test-nginx .
docker run -d --name webserver -p 80:80 [tag | image id]

 

3. Shell script 작성

  • jenkins에서 배포 후에 실행할 shell script를 서버에 작성해두자
  • rollingdeploy.sh의 전체적인 흐름은 다음과 같다.
    • 현재 배포하려는 서버 외에 트래픽을 받을 수 있는 서버, health-check 없다면 배포하지 않음
    • 현재 tomcat server 종료 gracefully shut down -> force shut down
    • 배포 시작
    • 배포 후 자가 health-check

 

더보기
BASE_PATH=/home/app/
JAR_NAME=xxx.jar
echo "> build 파일명: $JAR_NAME"

#배포중인 server ip
IP1=# 
IP2=# 
#배포 port
DEPLOYED_PORT=8080


#private ip
MY_IP=$(hostname -i)

loop=1
limitLoop=30
flag='false'


if [ $MY_IP == $IP1 ]; then
  OTHER_IP=$IP2
elif [ $MY_IP == $IP2 ]; then
  OTHER_IP=$IP1
else
  echo "> 일치하는 IP가 없습니다. "
fi

#==========================살아있는 서버가 존재하는지 확인==============================
echo "> 서버 체크 시작"
for retry_count in {1..10};
do
  response=$(sudo curl -s http://$OTHER_IP:$DEPLOYED_PORT/actuator/health)
  up_count=$(echo $response | grep 'UP' | wc -l)
  echo "> $retry_count : $response  : $up_count"
  if [ $up_count -ge 1 ]; then
    echo "> 서버 health 체크 성공"
    break
  fi
  if [ $retry_count -eq 10 ]; then
    echo "> 서버 health 체크 실패"
    exit 1
  fi
  echo "> 실패 10초후 재시도"
  sleep 10
done

#===================================프로세스 종료======================================
# tomcat gracefully shutdown
echo "> 구동중인 애플리케이션 pid 확인"
IDLE_PID=(`ps -ef | grep  $JAR_NAME | grep -v 'grep' | awk '{ print $2 }'`)
 if [ ${#IDLE_PID[@]} = 0 ]
then
  echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다."
  flag='true'
else
  for pid in "${IDLE_PID[@]}"
  do
      echo "> [$pid] gracefully shutdown"
      kill -15 $pid
  done
  while [ $loop -le $limitLoop ]
  do
      PID_LIST=(`ps -ef | grep  $JAR_NAME | grep -v 'grep' | awk '{ print $2 }'`)
      if [ ${#PID_LIST[@]} = 0 ]
      then
          echo "> gracefully shutdown success "
          flag='true'
          break
      else
          for pid in "${PID_LIST[@]}"
          do
              echo "> [$loop/$limitLoop] $pid 프로세스 종료를 기다리는중입니다."
          done
          loop=$(( $loop + 1 ))
          sleep 1
          continue
      fi
  done
fi
if [ $flag == 'false' ];
then
    echo "> 프로세스 강제종료 시도"
    sudo ps -ef | grep $JAR_NAME | grep -v 'grep' |  awk '{ print $2 }' | \
    while read PID
    do
        echo "> [$PID] forced shutdown"
        kill -9 $PID
    done
fi

#===================================배포======================================

echo "> 배포"
echo "> 파일명" $BASE_PATH$JAR_NAME
sudo nohup java -jar -Dspring.profiles.active=prod $BASE_PATH$JAR_NAME & 
sudo sleep 10

echo "> 10초 후 Health check 시작"
echo "> curl -s http://$MY_IP:$DEPLOYED_PORT/actuator/health"

#==========================현재 서버 Health check============================
for retry_count in {1..10}; do
  response=$(sudo curl -s http://$MY_IP:$DEPLOYED_PORT/actuator/health)
  up_count=$(echo $response | grep 'UP' | wc -l)
  if [ $up_count -ge 1 ]; then
    echo "> Health check 성공"
    break
  else
    echo "> Health check의 응답을 알 수 없거나 혹은 status가 UP이 아닙니다."
    echo "> Health check: ${response}"
  fi

  if [ $retry_count -eq 10 ]; then
    echo "> Health check 실패. "
    echo "> Nginx에 연결하지 않고 배포를 종료합니다."
    exit 1
  fi

  echo "> Health check 연결 실패. 재시도..."
  sudo sleep 10
done

sleep 60 # 다음 배포 서버를 위한 지연

 

4. Jenkins에서 배포 시 deploy.sh 실행.

 

 

무중단 배포 - 등장

 나는 귀여운 주니어 개발자로 옛날과 비교하는게 좀 웃기지만, 옛날에 비해 요즘의 서비스는 릴리즈 주기가 몹시 짧다. 

 

 대부분의 SW 개발 방법론이 폭포수 방식에서 애자일 방식으로 바뀌며 릴리즈 주기가 긴 소프트웨어는 적어졌다. 그만큼 제품은 더 빨리 출시되고, 기능 추가 등의 업데이트 등을 위해 배포가 되는 주기가 짧아졌다. 

 

 또한 대부분의 큰 소프트웨어에서는 하나의 거대한 아키텍처로 서비스하는 모놀리식 아키텍처에서 서비스를 모듈화하여 개발하고, 모듈별로 배포할 수 있는 마이크로 서비스 아키텍처로 바뀌며 배포하는 크기와 빈도가 달라졌다. 

 

 폭포수에서 애자일, 모놀리식에서 마이크로서비스 등의 변화를 통해 얻은 이점은 보다 잦은 배포를 통해 시장과 사용자의 요구를 빠르게 충족시키고 서비스의 가치를 높이기 위함이다. 하지만 이런 변화는 운영 안정성 측면에서 부정적인 영향을 미칠 수 있다.

 

 예를 들어 개발 결과물을 제공하기위해 서버에 배포한다고 가정하자. 최신 어플리케이션은 클라우드 기반으로 구성되어 트래픽에 따라 탄력적인 확장과 고가용성을 보장하지만, 배포 시에는 서비스를 멈춰야 하는 중단 배포 방식의 경우 다운타임이 발생한다. 물론 다운타임이 계획적으로 허용될 수 있다. 은행과 같은 서비스에 유연함에 제한이 있는 서비스가 그렇다. 하지만 전 세계의 사용자를 대상으로 하거나, 24시간 운영이 필요한 서비스라면 항상 가동되어야하는게 좋다. 일반적으로 중단 배포 방식은 사용자에게 불편함과 손실을 야기할 수 있다. 새로운 배포로 인해 문제가 발생하는 경우엔.. 야근해야지 뭐...

 

 야근이 좋은게 아니라면, 혹은 팀원에게 형 지금 배포중인거 맞지? 소리 듣기 싫은 개발자들은 무중단 배포 방식을 공부하자. 안정적인 배포 체계를 갖추는 것은 다양한 요구사항에도 개발에 집중할 수 있으며 이는 곧 경쟁력이 되고 내 수면시간이 된다. ㅎㅎ;

 

무중단 배포 - 개념

 무중단 배포는 서비스 장애와 배포의 부담을 최소화 하기 위해 운영중인 서비스를 중단하지 않고, 신규 소프트웨어를 배포하는 기술이다. 

 핵심은 *로드밸런서(Load Balancer)를 통해 연결된 두 개 이상의 (서로 다른 IP 혹은 포트를 가진)인스턴스에 트래픽을 제어해 배포하는 것이다. 배포 작업이 서비스에 영향을 주지 않도록 하기 위해 사용자의 이용량에 따라 인스턴스는 물론, 로드밸런서도 다중화를 고려해야한다. 

 

 다양한 무중단 배포 방식이 있는데 대표적으로 3개의 방식을 앞으로 3번에 걸쳐 포스팅하려한다.

  •  제한된 자원에서 하나씩 배포하여 변경해 나가는 롤링 배포
  •  현재 사용중인 버전의 인스턴스 수만큼 새 버전의 인스턴스를 준비해 로드밸런서가 스위칭해주는 블루-그린 배포,
  • 새로운 버전 소프트웨어의 모니터링과 검증에 초점을 맞춘 카나리 배포

로드밸런서가 오래된 버전의 서버와 새로운 버전의 서버를 구분하여 요청을 처리하는 것이 가장 큰 동작개념이다.

 

 

롤링, 블루-그린, 카나리 맛보기.

더보기
출처 : 구글에 업데이트 중 검색결과

 윈도우 업데이트를 기다리는게 싫다.

(컴퓨터는 2대를 사용중이다.)

롤링 배포 : 1번 컴퓨터 업데이트하는 중엔 2번 컴퓨터를 쓰고, 1번 컴퓨터 업데이트가 끝나면 2번 컴퓨터를 업데이트하며 1번 컴퓨터를 사용

블루 그린 배포 : 누나 컴퓨터 2대에 업데이트 진행하면서 내꺼 쓰고 누나 컴퓨터에 업데이트 끝나면, 내꺼 2대랑 바꾸기.

카나리 배포 : 1번 컴퓨터 업데이트하고 잘되나 안되나 확인 후에 2번 컴퓨터도 업데이트.

 


 

 

이미지 출처 : https://tecoble.techcourse.co.kr/post/2021-11-07-load-balancing/

로드밸런싱

 로드밸런싱은 서버가 처리해야 할 요청(Load)을 여러대의 서버로 나누어(Balancing) 처리하는 것을 의미한다. 한 대의 서버로 부하가 집중되지 않도록 트래픽을 관리해 각각의 서버가 최적의 퍼포먼스를 보일 수 있도록 하는 것이 목적이다. 

 

  서비스 초기, 하나의 서버로 모든 클라이언트의 요청을 처리할 수 있다면 로드밸런싱은 필요없다. 하지만 사용자가 많아져 현재의 서버에서 모든 요청을 처리할 수 없다면 *scale-up과 scale-out을 고려해야하고, scale-out을 선택한다면 로드밸런서는 필수적이다. 

 

 

 

scale-up VS scale-out

내가 두 배 똑똑해지기 VS 내가 두 명

 scale-up의 경우 서버자체의 성능을 향상시키는 것을 뜻한다. 예를들어 CPU가 i3인 컴퓨터에서 i7으로, m1에서 m2로 업그레이드하는 것과 같다. 주로 하나의 서버에서 모든 데이터가 관리되는 데이터베이스 서버(==RDB)에 적합한 방식이다. 데이터 정합성이나, 이슈관리가 쉽지만 성능향상에 한계가 존재하고, 서버 교체나 업그레이시 다운타임이 발생하며 서버 한대가 부담하는 요청이 많으므로 문제가 생기면 더 큰 타격을 입게 된다. 

 

 scale-out의 경우 서버를 증설하는 것을 뜻한다. 기존 서버와 동일하거나 낮은 성능의 서버를 추가로 운영하는 것으로, i3인 컴퓨터 추가로 구매하는 것과 같다. 서버 증설은 서버 성능 향상보다 비교적 비용 부담이 적으며, 분산처리에 적합하다. 일반적으로 모든 서버가 동일한 데이터를 갖고 있어야 하므로, 데이터의 변화가 빈번하지 않은 웹 서버에 적합한 방식이다. 

 

 

 

 

현재 ec2 상황은 아래의 모든게 만족됐다고 가정하자.

더보기

 

목표는 다음과 같다. 

ㅋㅋ발그림ㅋㅋㅋ엌ㅋㅋㅋㅋ..

젠킨스는 나중에 따로 정리하겠다. (여기서 칠 명령어를 젠킨스에 입력해놓으면 해주니까..)

 

backend나 frontend나 큰 흐름은 아래와 같다.

(window나 맥 등 로컬에 도커데스크탑을 설치하자... 어렵지 않으니 생략. )

1. 로컬에서 도커 이미지 빌드

2. 로컬에서 도커 이미지를 도커 허브에 푸시

3. ec2에서 도커이미지를 풀

4. ec2에서 도커이미지 실행

 

frontend를 도커를 통해 ec2에 띄워보자

위의 그림을 보듯 frontend를 띄운 도커는 nginx가 필요하다. nginx를 base image로 받아오고, 받아온 nginx의 기존 설정을 내가 원하는대로 바꿔야한다. nginx 설정파일부터 만들어보자. 

어디다 만들든 크게 상관은 없지만 밑에 작성한 Dockerfile을 그대로 복사해서 사용할거라면 프로젝트의 프론트엔드 폴더 최상단에 만들자.

1. 로컬에서 프로젝트 프론트엔드 폴더의 최상단에 default.conf 작성 (nginx 설정파일)

#default.conf
server{
    listen 80;
    location / { 
        root /app/build;
	index index.html index.htm;
        try_files $uri $uri/ /index.html;
    }
}



2. 로컬에서 프로젝트 프론트엔드 폴더의 최상단에 Dockerfile을 작성한다. 

FROM nginx #base이미지로 nginx를 받아온다.
RUN mkdir /app #컨테이너 내부에 app이라는 폴더를 만든다.
WORKDIR /app #워크디렉토리를 설정한다.
RUN mkdir ./build  #컨테이너 내부에 build라는 폴더를 생성한다.
ADD ./build ./build #프로젝트 폴더에 있는 build라는 폴더를 컨테이너 내부에 생성한 build 폴더에 복사한다.
RUN rm /etc/nginx/conf.d/default.conf #받아온 nginx 내부에 기존 설정값을 제거한다. 
COPY ./default.conf /etc/nginx/conf.d #만들어둔 nginx 설정값을 받아온 nginx의 설정값으로 대체한다.
EXPOSE 80 #80포트 사용
CMD ["nginx","-g", "daemon off;"] #nginx 실행

3. npm run build를 통해 css, html등 정적인 파일을 생성한다. 

프론트엔드 폴더 상단에서 

npm run build

4. docker image 빌드하기 

docker build --tag frontend .

뒤에 . 은 현재 폴더의 dockerfile을 빌드하겠다는 뜻으로 빌드하려는 dockerfile의 경로를 입력하자.

tag는 생성되는 이미지의 이름과 태그를 설정할것이다. 

이름만 설정할 경우 자동으로 tag는 latest로 설정된다.

frontend 라는 이미지가 빌드 된걸 확인해보려면

$ docker images

명령어로 확인할 수 있다.

 

5. docker 이미지 푸시하기 

docker tag [ image name or Tag ] [ docker hub ID 혹은 private registry ip:port ]/[ push image이름 ]

ex) docker tag frontend jodong2/frontend

를 통해 태그를 달고, 아래와 같은 명령어로 docker hub에 푸시하자.

docker push [이미지 이름:태그]

ex) docker push jodong2/frontend

 

6. ec2에서 docker image pull 받기

이전에 mysql을 설치했던 과정과 똑같다.

ec2의 ubuntu환경에서 다음 명령어를 실행하자.

$ docker pull [ docker hub ID 혹은 private registry ip:port ]/[ push image이름 ]

ex) docker pull jodong2/frontend

 

7.ec2에서 docker image 실행하기 

위의 목표 예시 발 그림을 보면 frontend는 host 의 xxxx port를 사용할 것이다. 

frontend의 nginx 설정 파일을 보면 80 포트를 받고 있다.

그러므로 -p옵션을 통해 xxxx port와 컨테이너의 80포트를 연결해야한다. 

$ docker run -d --name {container name} -p xxxx:80 [ docker hub ID 혹은 private registry ip:port ]/[ push image이름 ]

ex) docker run -d --name front -p xxxx:80 jodong2/front

 

8. ec2에서 도커 컨테이너 확인하기 

docker ps

 

 

 

Backend는 다음 포스팅에 :)

 


 

 

왜 Front는 nginx가 하나 더 필요할까?

spring의 경우 was로 tomcat이 있기 때문에 상관없지만 프론트의 경우 build된 정적파일을 처리할 수 서버가 따로 없다. ec2에 nginx가 있지만 도커를 활용해 띄운다면 내부에 별도의 서버가 필요하고 이때 proxy server로 nginx를 활용한 것이다.

 

 

왜 도커를 사용하여 띄울까?

docker 안쓰고 서버에다가 버전에 맞는 jdk설치하고 jar 파일 가져와서 실행하면 되는거 아니야?

된다. 불편할 뿐이다. 많고 많은 불편한 이유가 있겠지만 나에게 와닿은 예시는 수평확장에 대한 예시였다. 

꼭 수평확장 문제뿐만이 아니니 공부한다는 생각으로라도 도커를 사용해보자.

 

이전에 부스트캠프 ai tech에서 했던 프로젝트 중에 팀원들 모두 합쳐서 6대의 서버가 팀에 부여됐다. 

모두 동일한 작업을 처음부터 진행해가며 환경이 얼추 비슷했고 마지막엔 거의 비슷한 환경이 구성됐다. 근데 만약 도커를 사용하지 않았고, 하나의 서버에 openCV 설치가 제대로 되지 않았으며, 해당 library를 사용하는 함수를 호출한다면, 그 서버는 에러를 보여줄 것이다. 16.6%확률로 실패하는 기능이 있다면 가용성 측면에서 말도 안되는 서비스고 이걸 테스트하기 위해서 6대의 서버를 전부 테스트해야한다. 불편할게 확실하니 이거 확인하는 툴도 이미 만들어져있겠지만.... 7번째 서버가 추가되면 모든 환경을 처음부터 다시 구성해야한다. 

하지만 도커는 같은 환경을 보장한다. 7번째 8번째 서버가 추가된다해도, 손쉽게 똑같은 환경에서 서비스를 동작시킬 수 있다. 

 

금속활자로 책을 순식간에 동일한 컨디션의 동일한 모양으로 찍어낼 수 있는데 한 글자씩 붓으로 적고 있진 않은가 생각해보자 ㅠ

 

 

아래 블로그에 정리가 너무 잘돼있다. 저장해놓고 정독해야지.

https://www.44bits.io/ko/post/why-should-i-use-docker-container

 

왜 굳이 도커(컨테이너)를 써야 하나요? - 컨테이너를 사용해야 하는 이유

컨테이너는 서버 애플리케이션을 배포하고 서버를 운영하는 표준적인 기술이 되어가고 있습니다. 하지만 처음 사용해본다면 그 장점이 잘 와닿지 않을 수도 있습니다. 왜 굳이 도커 컨테이너를

www.44bits.io

 

2023.02.11 - [Web/Dev(ML)Ops] - ubuntu에서 도커 설치하기

 

ubuntu에서 도커 설치하기

$ sudo apt-get update apt-get update는 설치된 패키지를 업데이트하는 것이 아닌 설치 가능한 패키지 리스트를 업데이트 하는 것이다. repository 설정 $ sudo apt-get install ca-certificates curl gnupg lsb-release $ curl -f

dongineer.tistory.com

docker는 설치 됐다고 가정.

 

mysql 설치

원하는 버전을 8.0.30부분에 넣어준다. 기입하지 않으면 최신버전 이미지 가져옴.

$ sudo docker pull mysql:8.0.30

mysql 실행

$ sudo docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD={비밀번호} --name {컨테이너 이름} mysql:8.0.30 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
  • d : (detach) daemon으로 실행
    • 백그라운드에서 컨테이너 실행, 
  • p : HOST OS의 3306포트 : 컨테이너 내부 3306 포트
    • 호스트와 컨테이너 포트포워딩
  • e : MYSQL_ROOT_PASSWORD 설정.
  • name : 컨테이너 이름
  • mysql:8.0.30 : 이미지 이름

mysql 컨테이너 확인

$ sudo docker exec -it {컨테이너 이름} bash 
$ mysql -u root -p 

password : {설정한 MYSQL_ROOT_PASSWORD 입력}
  • -i, --interactive
    • 표준 입력(stdin)을 활성화하며, 컨테이너와 연결되어 있지 않더라도 표준 입력을 유지
    • 보통 이 옵션을 사용하여 Bash 에 명령을 입력
  • -t, --tty
    • TTY 모드(pseudo-TTY)를 사용
    • Bash를 사용하려면 이 옵션을 설정
    • 이 옵션을 설정하지 않으면 명령을 입력할 수는 있지만, 셸이 표시되지 않음

 

+ Recent posts