로딩중…

CS 지식

개발개발 ·

면접 준비 : 트랜잭션

Q. 스프링에서 트랜잭션을 왜 추상화 했나요?

A. 트랜잭션을 이루는 코드들이 데이터 접근 기술에 따라 달라지는 현상이 있었습니다. 때문에 JDBC를 사용하던 애플리케이션이 JPA로 바꾸기 위해 트랜잭션을 이루는 모든 코드를 고쳐야 하는 상황이 발생했습니다. 이를 해결하고자 스프링에서는 PlatformTransactionManager라는 인터페이스로 트랜잭션을 추상화 했습니다. 이를 통해 개발자는 서비스 계층에서 특정 기술에 종속적인 트랜잭션 코드를 사용하지 않아도 됩니다.

Q. @Transactional에 대해 설명해주세요

A. 스프링이 데코레이터 패턴을 이용해 만든 스프링 AOP중 하나입니다. @Transactional 어노테이션이 붙어있는 메서드에 한해서 트랜잭션 시작, 정상 로직일 때 커밋, 예외 상황일 때 롤백을 처리해 줍니다. @Transactional을 통해 개발자는 서비스 계층에서 트랜잭션과 관련된 코드와 비즈니스 로직을 분리하여 순수한 서비스계층을 유지할 수 있습니다.

https://coding-review.tistory.com/108

면접 준비 : 스프링 AOP

Q. 스프링 AOP에 대해 설명해주세요

A. AOP는 관점지향 프로그래밍의 약자입니다. 기존 OOP에서 기능별로 클래스를 분리했음에도 불구하고 로깅, 트랜잭션, 자원해재와 같은 중복코드들이 발생하는 단점이 생겼습니다. 이를 해결하기 위해 개발자는 비즈니스 로직에 집중하고 비즈니스 로직과 공통관심사를 분리하여 중복코드를 제거할 수 있는 방식이 바로 AOP방식입니다.

면접 준비 : OOP

Q. OOP가 뭐죠?

A. 데이터를 객체로 취급하여 프로그램에 반영하고, 순차적으로 프로그램이 동작하는 것과는 다르게 객체의 상호작용을 통해 프로그램이 동작하는 것을 말합니다. 객체를 연결시켜 프로그래밍하면, 상속, 캡슐화, 다형성을 이용해 코드 재사용을 증가시키고, 유지보수를 감소시켜주는 장점이 있습니다.

면접 준비 : 추상클래스와 인터페이스

Q. 추상 클래스와 인터페이스의 차이점에 대해 설명해주세요

A. 인터페이스는 다중상속이 가능하고, 내부 인스턴스를 둘 수 없으며 필드 선언 시 public static final로 정의해야 합니다. 그 이유는 인터페이스는 그 자체로 인스턴스화 할 수 없기 때문입니다. 따라서 메모리에 할당되는 시점 즉 초기화 시점에 할당해야 하는데, 해당 방식은 static으로만 가능합니다. 추상 클래스는 다중상속이 불가능하고, 내부 인스턴스 및 필드를 자유롭게 정의할 수 있습니다. 추상 메서드가 한개이상 존재하면 되기 때문에 메서드의 일부 구현이 가능합니다. 이러한 것을 Template Method 패턴이라고 합니다. 내부 인스턴스 및 필드를 가질 수 있고 protected와 private 메서드나 필드 등을 가질 수 있어 하위 클래스나 다른 클래스에 이를 숨길 수 있습니다.

https://coding-review.tistory.com/115

면접 준비 : 템플릿 메서드 패턴

Q. 템플릿 메서드 패턴이 뭔가요?

A. 템플릿 메서드 패턴은 특정 작업을 처리하는 일부분을 서브 클래스로 캡슐화하여 전체적인 구조는 바꾸지 않으면서 특정 단계에서 수행하는 내용을 바꾸는 패턴입니다.

Q. 템플릿 메서드 패턴은 왜 사용하나요?

A. 템플릿 메서드 패턴을 사용하면 중복코드를 줄일 수 있고, 자식 클래스의 역할을 줄여 핵심 로직의 관리가 용이하며 코드를 객체지향적으로 구성할 수 있다는 장점이 있습니다. 하지만 추상 메소드가 많아지면서 클래스 관리가 복잡해지고 클래스간의 관계와 코드가 꼬여버릴 염려가 있다는 단점이 있습니다. (상속을 강요한다는 단점도 있음)

Q. 템플릿 메서드 패턴은 언제 사용하나요?

A. 클라이언트가 알고리즘의 특정 단계만 제어하고 전체 알고리즘이나 구조를 변경할 수 없도록 하고 싶을 때나 특정 단계에서의 구현만 다르고 다른 대부분은 동일한 동작을 하는 경우에 사용하면 좋습니다.

https://coding-review.tistory.com/116

면접 준비 : 전략 패턴

Q. 전략 패턴이 뭔가요?

A. 객체들이 할 수 있는 행위 각각에 대해 전략 클래스를 생성하고, 유사한 행위들을 캡슐화 하는 인터페이스를 정의하여, 객체의 행위를 동적으로 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 방법을 말합니다.

Q. 전략 패턴을 왜 사용하나요?

A. 새로운 전략을 추가하더라도 기존 코드를 변경하지 않아도 되고, 상속 대신 위임을 사용한다는 장점이 있습니다. 위임을 사용한다는 것은 상속을 강요하지 않는다는 것이고 이는 상속이 정말 필요한 곳에 쓰일 수 있다는 뜻입니다.

Q. 전략 패턴을 언제 사용하나요?

A. 런타임에 전략을 바꾸고 싶을 때 사용합니다.

Q. 전략 패턴과 템플릿 메서드 패턴의 차이점이 뭔가요?

A. 템플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿을 두고, 변하는 부분을 자식 클래스에 두어서 상속으로 통해 문제를 해결한다면 전략 패턴은 변하지 않는 부분을 Context라는 곳에 두고, 변하는 부분을 Strategy라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 문제를 해결합니다. 템플릿 메서드 패턴은 상속으로, 전략 패턴은 위임으로 문제를 해결한다고 정리할 수 있겠습니다.

https://coding-review.tistory.com/117

면접 준비 : 데코레이터 패턴

Q. 데코레이터 패턴이 뭔가요? / 데코레이터 패턴에 대해 설명해주세요.

A. 데코레이터 패턴은 주어진 상황 및 용도에 따라 어떤 객체에 책임을 동적으로 추가하는 패턴을 말합니다.

Q. 데코레이터 패턴은 왜 사용하나

A. 데코레이터 패턴을 사용하면 기존 코드를 수정하지 않고도 행동을 확장시킬 수 있습니다. 또한 구성과 위임을 통해서 실행중에 새로운 행동을 추가할 수도 있습니다. 하지만 의미없는 객체들이 너무 많이 추가될 수 있다는 단점도 존재합니다.

Q. 데코레이터 패턴은 언제 사용하나요?

A. 클래스의 요소들을 계속해서 수정하면서 사용하는 구조인 경우나 여러 요소들을 조합해서 사용하는 클래스 구조인 경우에 사용합니다.

https://coding-review.tistory.com/119

면접 준비 : 프록시 패턴

Q. 프록시 패턴이 뭔가요?

A. 인터페이스를 사용하고 실행시킬 클래스에 대해 객체가 들어갈 자리에 대리자 객체를 대신 투입하여, 클라이언트는 실제 실행시킬 클래스에 대한 메소드를 반환하는지 대리 객체의 메소드를 반환하는지를 모르게 하는 것을 말합니다.

Q. 프록시 패턴은 왜 사용하나요?

A. 프록시 패턴은 전처리 및 후처리 사용에 용이하고, 특정 메서드에 대한 보안이 좋습니다. 하지만 프록시 패턴을 사용시 가독성이 떨어질 수 있다는 단점도 존재합니다.

Q. 프록시 패턴은 언제 사용하나요?

A. 지연 로딩을 사용하거나, 필요할 때 객체를 추가시키고 싶을 때 사용하면 좋습니다. 또한 보안 문제로 인하여 특정 메서드의 접근을 제어하고 싶지 않을 때도 사용할 수 있습니다.

https://coding-review.tistory.com/121

면접 준비 : JVM

Q. JVM이 뭔가요? / JVM에 대해서 설명해주세요

A. 자바 가방 머신의 약자로 JVM의 역할은 자바 애플리케이션을 클래스 로더를 통해 읽어 자바 API와 함께 실행하는 것입니다. 메모리 관리를 수행하며 스택 기반의 가상머신입니다.

Q. JVM의 구조를 설명해주세요

A1. JVM의 구조는 Class Loader, Execution Engine, Runtime Data Areas, JNI, Native Method Library로 이루어져 있습니다.

이 뒤는 자세하게 듣기를 원하면 대답하기

A1-1. Class Loader는 JVM내로 클래스들을 로드하고 링크를 통해 배치하는 작업을 수행합니다.

Execution Engine은 바이트 코드를 실행시키는 역할을 하며, 구성 요소로는 인터프리터, JIT 컴파일러, 가비지 컬렉터가 있습니다.인터프리터는 바이트 코드를 한줄씩 실행해주는 역할을 합니다. JIT 컴파일러는 인터프리터의 효율을 높이기 위한 컴파일러로 인터프리터가 반복되는 코드를 발견하면 JIT 컴파일러가 반복되는 코드인 Native 코드로 바꿔줍니다. 가비지 컬렉터는 힙영역에서 사용되지않는 객체를 제거하는 역할을 수행합니다.

Runtime Data Areas는 프로그램 실행 중에 사용되는 다양한 영역입니다. 구성요소로는 PC Register, Stack Area, Heap Area, Method Area가 있습니다. PC Register는 스레드가 실행될 때 생성되며 현재 수행중인 JVM 명령의 주소를 갖고있습니다. Stack Area는 지역변수, 파라미터등이 생성되는 영역으로 실제 객체는 Heap에 할당되고 레퍼런스만 Stack에 저장됩니다. Heap Area는 동적으로 할당된 오브젝트와 배열이 저장되는 공간으로 가비지 컬렉터의 영역입니다. Method Area는 클래스 멤버 변수, 메서드 정보, Type 정보, 상수 풀, static 변수, final 변수등이 생성됩니다.

JNI는 Java Native Interface의 약자로 자바 애플리케이션에서 C, C++, 어셈블리어로 작성된 함수를 사용할 수 있는 방법을 제공합니다. Native키워드를 사용하여 메소드를 호출합니다.

Native Method Library는 C, C++로 작성된 라이브러리 입니다.

Q. Java의 실행방식을 설명해주세요.

A. 자바의 실행방식은 먼저 컴파일러가 소스 코드를 읽어 바이트코드로 변환시킵니다. 그 후 Class Loader를 통해 class 파일들을 JVM으로 로딩합니다. 그리고 로딩된 class파일들은 Execution Engine을 통해 해석됩니다. 마지막으로 해석된 바이트코드는 Runtime Data Areas에 배치되어 실질적인 수행이 이루어집니다.

https://coding-review.tistory.com/123

면접 준비 : GC (Garbage Collection)

Q. GC가 무엇이고 필요한 이유는 뭔가요?

A. GC는 힙 영역에서 사용하지 않는 객체들을 제거하는 작업을 총칭합니다. 이 객체를 제거하는 작업이 필요한 이유는 자바는 개발자가 메모리를 직접 해제해줄 수 없는 언어이기 때문입니다. 따라서 객체를 사용하고 제거하는 기능이 필요하게 됩니다.

Q. GC의 동작방식에 대해 설명해주세요

A. GC의 동작방식은 가장 간단한 Serial GC 방식으로 설명합니다. GC는 Minor GC, Major GC로 구분할 수 있습니다. Minor GC는 young한 영역에서, Major GC는 old한 영역에서 일어난다고 정의합니다. GC를 수행할 때는 GC를 수행하는 스레드 이외의 스레드는 모두 정지합니다. 이를 Stop-the-world라고 합니다. Minor GC는 Eden 영역이 가득 차면, Eden 영역에서 참조가 남아있는 객체를 마킹하고 survivor 영역으로 복사합니다. 그리고 Eden 영역을 비웁니다. survivor영역도 가득 차면 같은 방식으로 다른 survivor 영역에 복사하고 비웁니다. 이를 반복하다보면 계속해서 살아남는 객체는 old 영역으로 이동하게 됩니다. Major GC는 old 영역에서 일어납니다. Minor GC와는 반대로 삭제되어야 하는 객체를 마킹합니다. 그리고 지웁니다. 메모리는 단편화 된 상태이므로 이를 한 군데에 모아주어야 합니다. 메모리를 한군데 모아주는 것을 Compaction이라 하며 Compact라고 합니다. 그래서 Mark-Sweep-Compact 알고리즘이라고 합니다. 이것이 중요한 이유는 GC 수행시 시스템이 멈추기 때문에 의도치 않은 장애의 원인이 될 수 있습니다. 따라서 이를 위해 힙 영역을 조정하는 것을 GC 튜닝이라고 하고 이때, JVM메모리는 절대 마음대로 조정해서는 안됩니다.

"GC의 동작방식에 대해 설명해주세요" 에서 이어지는 질문

Q1. GC의 알고리즘 중에 더 알고있는 것이 있나요?

A. Mark and Sweep 알고리즘과 Mark and Copy 알고리즘이 있습니다.

Q2. Mark-Sweep-Compact 알고리즘이 필요한 이유가 뭔가요? / 단편화 된 메모리를 한군데에 모아줘야하는 이유가 있나요?

A. GC를 수행 후 구멍이 뚫린것처럼 힙 영역에 빈 공간이 생기게 됩니다. 이때 GC가 삭제한 메모리의 크기 역시 제각각 이기 때문에 혹여나 빈 공간보다 더 큰 크기의 객체가 들어오려 하면 OutOfMemoryError가 생깁니다. Mark-Sweep-Compact 알고리즘을 사용하면 조각난 메모리 공간을 하나로 합쳐주어 에러가 생기지 않게 해줍니다.

"GC의 알고리즘 중에 더 알고있는 것이 있나요?" 에서 이어지는 질문

Q. 각각의 알고리즘이 어떤 역할을 하나요?

A. Mark and Sweep 알고리즘은 GC가 GC Root 그래프를 훑으면서 참조가 남아있는 객체에는 마킹을 참조가 남아있지 않은 객체는 지우는 역할을 합니다. Mark and Copy 알고리즘은 프로그램이 GC에 의해 중단되는 시간을 줄여주는 역할을 합니다.

https://coding-review.tistory.com/125

면접 준비 : 컬렉션 프레임워크

Q. 컬렉션 프레임워크에 대해 설명해주세요.

A. 자바 컬렉션은 널리 알려져 있는 자료구조를 바탕으로 객체, 데이터들을 효율적으로 관리할 수 있는 자료구조들이 있는 라이브러리를 컬렉션 프레임워크라고 합니다. List, Set은 컬렉션 인터페이스를 상속받지만 Map 인터페이스는 구조상의 차이라 별도로 정의합니다.

https://coding-review.tistory.com/127

면접 준비 : 제네릭

Q. 제네릭에 대해서 설명해주세요.

A. 제네릭은 자바의 타입 안전성을 맡고 있습니다. 컴파일 과정에서 타입체크를 해주는 기능으로 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안전성을 높이고 형변환의 번거로움을 줄여줍니다.

https://coding-review.tistory.com/129

면접 준비 : 어노테이션

Q. 어노테이션에 대해서 설명해주세요.

A. 어노테이션은 인터페이스를 기반으로 한 문법으로 주석처럼 코드에 달아 클래스에 특별한 의미를 부여하거나 기능을 주입할 수 있습니다.

더 자세히 물어보면 대답하기

어노테이션의 종류로는 내장어노테이션, 메타어노테이션, 사용자정의 어노테이션이 있습니다. 내장 어노테이션은 자바에서 기본적으로 제공해주는 어노테이션입니다. 메타어노테이션은 어노테이션을 위한 어노테이션으로 어노테이션이 적용될 위치나 어느시점까지 적용될 것인지를 결정합니다. 사용자정의 어노테이션은 메타어노테이션을 이용해 만든 커스텀 어노테이션입니다.

https://coding-review.tistory.com/131

면접 준비 : 정적 (static)

Q. 정적 (static) 이란 무엇인가요?

A. static은 클래스 변수라고 하고 정적 변수라고도 합니다. static은 클래스로더가 클래스를 로딩해서 메소드 메모리 영역에 적재할 때 클래스별로 관리됩니다. static 키워드를 사용해서 생성된 정적 멤버들은 PermGen이나 Metaspace에 저장되고 저장된 메모리는 모든 객체가 공유하면서 하나의 멤버를 어디서든지 참조할 수 있다는 장점을 가지고 있습니다. 하지만 GC의 관리 영역 밖에 있기 때문에 프로그램 종료시까지 메모리가 할당된 채로 남아있습니다. 따라서 너무 남발하면 시스템 성능에 악영향을 미칠 수 있습니다.

꼬리질문

Q. PermGen이나 Metaspace에 대해 설명해주세요.

A. PermGen이나 Metaspace모두 메타정보를 모아두는 공간입니다. PermGen은 자바7까지 사용하던 메모리 공간이고 Metaspace는 자바8부터 사용하는 메모리 공간입니다. 둘의 차이점은 PermGen은 힙영역에 포함되지만 Metaspace는 힙영역에 포함되지 않고 NativeMemory에 포함되는 메모리 공간입니다.

https://coding-review.tistory.com/135

https://coding-review.tistory.com/137?category=986612

면접 준비 : 객체지향의 5대 원칙 SOLID

Q. 객체지향의 5대원칙 (SOLID)에 대해서 설명해주세요.

A. 객체지향의 5대원칙은 SOLID라고 불리며 각각 SRP, OCP, LSP, ISP, DIP로 이루어져 있습니다. SRP는 단일책임 원칙으로 한 클래스에 하나의 책임만 가져야 한다는 원칙입니다. OCP는 개방-폐쇄 원칙으로 확장에는 열려 있으나 변경에는 닫혀 있어야 한다는 원칙입니다. 이는 다형성을 활용해야 합니다. LSP 리스코프 치환 원칙으로 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 하는 원칙입니다. 리스코프 치환 원칙에 따르면 상위 타입을 상속해서 재정의 했을 때 프로그램이 깨지지 않아야 합니다. ISP는 인터페이스 분리 원칙으로 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다는 원칙입니다. 특정 클라이언트를 위한 인터페이스 여러개가 범용 인터페이스 하나보다 더 낫습니다. 즉, 비대한 인터페이스보단 더 작고 구체적인 인터페이스로 분리해야 합니다. DIP는 의존관계 역전 원칙으로 추상적인 것은 자신보다 구체적인 것에 의존하지 않고, 변화하기 쉬운 것에 의존해서는 안된다는 원칙입니다. 구체적으론 구현 클래스에 의존하지 않고, 추상 클래스에 의존해야 한다는 것 입니다.

https://coding-review.tistory.com/138

1

면접 준비 : 동일성(identity)과 동등성(equality)

Q. 동일성(identity)과 동등성(equality)에 대해서 설명해주세요

A. 동일성은 객체의 주소를 비교하는 것이고, 동등성은 객체의 같음을 비교하는 것입니다. 기본적으로 자바에서는 Object 클래스에 정의된 equals() 메소드가 동등성을 비교합니다. 따라서, 개발자는 원한다면 equals() 메소드를 오버라이딩해서 동등성의 판단 기준을 정의해주면 됩니다.

https://coding-review.tistory.com/141

면접 준비 : 원시타입과 참조타입

Q. 원시타입과 참조타입의 차이에 대해 설명해주세요.

A. 원시타입은 Java에서 단 8개밖에 존재하지 않는 타입입니다. 나머지는 모두 참조타입이라고 볼 수 있고, 참조 타입은 Object클래스이거나 이를 상속하는 클래스들로 이루어져 있습니다. 원시타입은 항상 값이 존재해야 합니다. 반면 참조타입은 null을 가질 수 있습니다. 그리고 멤버변수가 초기화될 때 원시타입은 기본값을 가지지만, 참조타입은 null을 가지는 차이도 있습니다.

https://coding-review.tistory.com/143

면접 준비 : String, StringBuilder, StringBuffer

Q. String, StringBuilder, StringBuffer에 대해서 설명해주세요

A. String은 불변입니다. StringBuilder와 StringBuffer는 이런 String의 특징때문에 사용하는 가변타입라고 볼 수 있습니다. StringBuilder와 StringBuffer는 Thread-safe 여부의 차이가 있습니다. StringBuilder는 Thread-safe 하지 않습니다. 따라서 Multi-Thread 환경에서 사용할 때는 String Buffer를 사용합니다.

면접 준비 : CheckedException, UnchekedException

Q. CheckedException과 UncheckedException에 대해서 설명해주세요.

A. 둘의 차이는 RuntimeException을 상속하는가의 여부에 따라 다릅니다. RuntimeException을 상속하면 UncheckedException이 됩니다. CheckedException은 예외가 발생 했을 경우 반드시 try / catch로 해결해야 하는 예외입니다. 처리하지 못하는 경우에는 throws를 통해 밖으로 던져야 합니다. UncheckedException은 말 그대로 컴파일러가 예외를 체크하지 않겠다는 의미입니다. 체크 예외와는 다르게 예외를 반드시 처리하지 않아도 되고 throws를 통해 밖으로 던지는 것 또한 생략해도 됩니다.

꼬리질문

Q. CheckedException, UncheckedException 중 어느 것을 사용해야 하나요? 그 이유도 설명해주세요.

A. 결과적으로 말하자면 UncheckedException을 사용해야 합니다. 그 이유는 먼저 예외는 기본적으로 복구 불가능하기 때문에 예외를 처리할 수 없는 경우가 대부분입니다. 또한, 체크 예외를 사용하면 추후에 프로젝트의 확장으로 예외가 변경되면 해당 예외가 달려있는 모든 부분을 고쳐야 합니다. 이는 OCP에 위배되기 때문에 지양해야 합니다. 따라서 체크 예외를 사용하기 보단 로그를 통해 개발자가 예외를 빠르게 알아채는 것이 중요합니다.

https://coding-review.tistory.com/148

면접 준비 : Java8에 추가된 기능

Q. Java8에서 추가된 기능에 대해서 설명해주세요.

A. Java8에서는 람다식, Stream API, Optional, 날짜 시간 API, StringJoiner 등이 추가되었습니다.

이후 추가 질문 시 대답

Q. 자세히 설명해주세요.

A. 람다는 함수형 프로그래밍을 지원하기 위한 기능이고, Stream API는 고차함수를 지원합니다. Optional은 Null-Safety를 제공하며, Stream과 사용법이 유사합니다. 날짜 시간 API는 Joda-time 등의 라이브러리에서 영향을 받아 괜찮은 API가 되었으며, StringJoiner는 문자열을 간단하게 구분자로 합칠 수 있는 기능을 제공합니다.

https://coding-review.tistory.com/151

https://coding-review.tistory.com/152

면접 준비 : try-with-resources

Q. try-with-resources에 대해서 설명해주세요.

A. try 안에 사용할 리소스 객체를 명시적으로 선언하여 사용하고, try블록 안에서 로직이 정상적으로 완료되었는지, 갑작스럽게 완료되었는지 여부와 관계없이 JVM에서 자동으로 자원을 반납해주는 기능을 하도록 Java7부터 도입된 기능입니다.

https://coding-review.tistory.com/154

면접 준비 : 자바의 원시타입

Q. 자바의 원시타입들은 무엇이 있으며 각각 몇 바이트를 차지하나요?

A. boolean 1바이트, unsigned char 2바이트, byte 1바이트, short 2바이트, int 4바이트, long 8바이트, float 4바이트, double 8바이트입니다. 하지만 사실 JVM에 의존적이기 때문에 정확한 크기라기 보다는 대략적인 크기입니다.

면접 준비 : 접근 제어자

Q. 접근제어자의 종류와 이에 대해 설명해주세요.

A. private, default, protected, public이 있습니다. private는 해당 클래스 내에서만 접근 가능하고, default는 해당 패키지, protected는 상속한 클래스, public은 전체 영역에서 접근 가능합니다. 접근 제어자를 사용하는 이유는 외부에서 보여주고 싶은 정보들을 선택적으로 제공하기 위함이고, 객체지향에서 말하는 캡슐화와 통하는 면이 있습니다.

면접 준비 : 오버라이딩, 오버로딩

Q. 오버라이딩과 오버로딩이 무엇이며 어떤 차이가 있을까요?

A. 오버라이딩은 상위 클래스의 메소드를 재정의 하는 것을 의미합니다. 또, 런타임 다형성이기도 합니다. 오버로딩은 같은 클래스 내에서 동일한 메소드 이름을 가지지만 매개변수의 타입, 개수가 다르게 구현할 수 있는 것을 의미하며 컴파일 타임 다형성이기도 합니다.

면접 준비 : 강한 결합도, 느슨한 결합도

Q. 강한 결합과 느슨한 결합이 무엇인가요?

A. 결합도는 서로다른 두 모듈 간의 의존성 정도를 나타내며 다른 모듈에 대해 얼마나 많은 정보를 알고 있는지에 대한 척도입니다. 어떤 모듈이 다른 모듈에 너무 자세한 부분까지 알고 있을 경우 강한 결합도를 가진다고 합니다. 어떤 모듈이 다른 모듈에 대해 필요한 정보만 알고 있다면 두 모듈은 낮은 결합도를 가진다고 합니다. 객체지향 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타냅니다. 이러한 관점에서 강한 결합도는 반드시 지양해야 하며, 개발자는 적절한 결합도를 유지할 수 있도록 고민하고 설계해야 합니다.

면접 준비 : 직렬화 역직렬화

Q. 직렬화와 역직렬화에 대해서 설명해주세요

A. 직렬화란 자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에서도 사용할 수 있도록 바이트 형태로 데이터 변환하는 기술과 바이트로 변환된 데이터를 다시 변환하는 기술(역직렬화)을 아울러서 이야기 합니다.자바 직렬화는 JVM의 메모리에서만 상주되어있는 객체 데이터를 영속화(Persistence)가 필요할 때 사용됩니다. 시스템이 종료되더라도 없어지지 않는 장점을 가지며 영속화된 데이터이기 때문에 네트워크로 전송이 가능합니다.

면접 준비 : 동시성 문제

Q. 동시성 문제에 대해서 설명해주세요

A. 동시성 문제란 멀티스레드 환경에서 하나의 변수 혹은 메소드에 여러 스레드가 접근해 값이 중복되어 개발자가 원했던 로직대로 움직이지 않는 현상을 말합니다. 동시성 프로그래밍에서는 CPU와 RAM의 중간에 위치하는 CPU Cache Memory와 병렬성이라는 특징 때문에 다수의 스레드가 공유 자원에 접근할 때 가시성과 원자성이라는 두가지 문제가 발생합니다. 가시성을 해결하기 위해선 volatile 키워드를 원자성을 해결하기 위해선 synchronized 키워드를 사용해야 합니다.

Q. 가시성이랑 원자성이 정확히 어떤 문제인가요?

A. 여러 개의 스레드가 사용됨에 따라 CPU Cache Memory 와 RAM의 데이터가 서로 일치하지 않아 생기는 문제입니다.

면접 준비 : 스프링 DI

Q. DI가 뭔가요?

A. DI는 스프링 프레임워크에서 지원하는 IoC의 형태로 클래스 사이의 의존 관계를 빈 설정 정보를 바탕으로 컨테이너가 자동으로 연결해주는 것을 의미합니다. 스프링에서는 스프링 컨테이너 ApplicationContext를 이용하여 설정 정보를 생성, 등록하고 필요한 객체를 생성자 혹은 setter 를 통해 주입하거나 필드에서 주입합니다.

Q. DI의 종류는 어떤 것이 있고 이들의 차이는 무엇인가요? / 주입하는 방법들에 대해서 설명해주세요

A. DI를 통해 주입하는 방법에는 세 가지 방법이 있습니다. 생성자 주입, setter를 이용한 주입, 필드 주입이 있습니다. 생성자 주입은 생성자 호출 시점에 딱 한번만 호출되는 것을 보장하며 불변, 필수 의존관계에서 사용합니다. setter 주입은 선택, 변경 가능성이 있는 의존 관계에서 사용되며 스프링 빈을 선택적으로 등록이 가능합니다. 필드 주입은 필드에 선언한 객체를 @Autowired 어노테이션을 붙여 직접 주입 받는 것을 말합니다. 이렇게 필드로 주입하는 경우 외부에서 변경이 불가능하여 테스트 하기가 힘들고, DI 프레임워크 없이는 작동하기 힘들기 때문에 애플리케이션과 관계 없는 테스트 코드에서 주로 사용합니다.

면접 준비 : 스프링 빈

Q. 스프링 빈이란 무엇인가요?

A. 스프링 빈은 스프링 컨테이너에 의해 관리되는 자바 객체입니다.

(꼬리질문)

Q. 스프링 컨테이너는 무엇인가요?

A. 스프링 컨테이너는 스프링 빈의 생명 주기를 관리하며, 생성된 스프링 빈들에게 추가적인 기능을 제공하는 역할을 합니다. 개발자가 만든 객체는 new 연산자, 인터페이스 호출 등의 방식으로 생성되고 소멸되지만 스프링 컨테이너를 사용하면 해당 역할을 대신해줍니다. 즉, 제어 흐름을 외부에서 관리하게 됩니다. 또한, 객체들 간의 의존 관계를 스프링 컨테이너가 런타임 과정에서 알아서 만들어 줍니다.

면접 준비 : 스프링 빈 생명주기

Q. 스프링 Bean의 생성 과정을 설명해주세요

A. 객체 생성, 의존 설정, 초기화, 사용, 소멸의 과정의 생명주기를 가지고 있습니다. Bean은 스프링 컨테이너에 의해 생명주기가 관리되며 빈 초기화 방법은 @PostConstruct를 빈 소멸에는 @PreDestroy를 사용합니다. 생성한 스프링 빈을 등록할 때는 ComponentScan을 이용하거나 @Configuration의 @Bean을 사용하여 빈 설정파일을 직접 빈으로 등록할 수 있습니다.

면접 준비 : 빈 스코프

Q. 스프링 Bean의 Scope에 대해서 설명해주세요.

A. 빈 스코프는 빈이 존재할 수 있는 범위를 뜻하며 싱글톤, 프로토타입, request, session, application 등이 있습니다. 싱글톤은 기본 스코프로 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프입니다. 프로토타입은 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프입니다. request는 웹 요청이 들어오고 나갈때까지 유지하는 스코프, session은 웹 세션이 생성, 종료할때까지, application은 웹 서블릿 컨텍스트와 같은 범위로 유지하는 스코프입니다.

면접 준비 : IoC 컨테이너

Q. IoC 컨테이너의 역할은 무엇이 있을까요?

A. 애플리케이션 실행 시점에 빈 오프젝트를 인스턴스화 하고 DI 한 후에 최초로 애플리케이션을 기동할 빈 하나를 제공해줍니다.

면접 준비 : 스프링 MVC 동작과정

Q. Spring Web MVC 의 Dispatcher Servlet의 동작 원리에 대해 간단히 설명해주세요.

A. 먼저 디스패처 서블릿이 핸들러 매핑에서 핸들러를 조회합니다. 그리고 핸들러 어댑터 목록에서 핸들러를 처리할 수 있는 핸들러 어댑터를 조회합니다. 핸들러 어탭더가 실행되면서 실제 핸들러를 실행합니다. 핸들러 어댑터는 디스패처 서블릿에게 ModelAndView를 반환합니다. 디스패처 서블릿은 viewResolver를 호출합니다. viewResolver는 디스패처 서블릿에게 View를 반환합니다. 디스패처 서블릿이 View를 통해서 View를 랜더링합니다.

면접 준비 : Filter와 Interceptor

Q. Servlet Filter 와 Spring Interceptor 의 차이는 무엇인가요?

A. 차이점이 여러개 있는데 우선 스펙이 다릅니다. 필터는 서블릿 필터로써 javax.servlet 스펙에 포함되는 클래스이고 인터셉터는 Spring MVC 스펙에 포함되어있는 클래스입니다. 그리고 각자 실행시점이 다르다는 차이가 있습니다. 필터는 서블릿 전후처리를 담당하지만 인터셉터는 스프링에서 핸들러를 실행하기 전후나, ViewResolver를 통해 컨트롤러에서 리턴한 View Name 으로부터 렌더링을 담당한 View 오브젝트를 반환한 후 실제 View를 렌더링한 후에 처리를 담당합니다. 또한, 등록 위치도 다릅니다. 필터는 Web Application 에 등록합니다. 톰캣을 사용할 경우 web.xml에 등록됩니다. 인터셉터는 스프링의 ApplicationContext에 등록됩니다.

면접 준비 : CORS 에러

Q. CORS 에러가 무엇인가요?

A. Cross Origin Resource Sharing 의 약자로 동일한 출처가 아닌 다른 출처에 대한 접근을 막는 보안 정책입니다.

Q. CORS 에러를 스프링에서 해결하는 방법이 뭔가요?

A. Servlet Filter에 Access-Control-Allow-Origin 을 헤더에 담아 응답하거나, WebMvcConfiguer 를 구현한 Configuraion 클래스를 만들어서 빈으로 등록하거나, Controller 클래스에서 @CrossOrigin 어노테이션을 통해 접근을 허용할 수 있습니다. 제일 간단한 것은 어노테이션을 붙이는 것이지만 컨트롤러가 많을 때 붙여야하는 어노테이션도 많아진다는 단점이 있습니다.

면접 준비 : @Bean / @Component

Q. @Bean / @Component 어노테이션에 대해서 설명해주시고 둘의 차이점에 대해서 설명해주세요

A. 두 어노테이션 모두 싱글톤으로 빈을 등록할 때 사용하는 어노테이션입니다. @Bean은 메서드를 빈으로 등록하는 어노테이션이고 @Component는 클래스를 빈으로 등록하는 어노테이션입니다.

면접 준비 : POJO 클래스

Q. POJO 란 무엇인가요? 스프링 프레임워크에서 POJO는 무엇이 될 수 있을까요?

A. POJO란 인터페이스, 클래스를 구현하거나 확장하지 않은 단순한 클래스를 말합니다. POJO 클래스는 자바에서 제공하는 API 외에 종속되지 않습니다. 특정 환경에 종속되지 않아 코드가 간결하고 테스트 자동화에 유리합니다. 스프링에서는 도메인과 비즈니스 로직을 수행하는 대상이 POJO가 될 수 있습니다.

면접 준비 : JPA 영속성 컨텍스트

Q. JPA 영속성 컨텍스트의 이점을 설명해주세요.

A. 영속성 컨텍스트를 사용하는 이유는 1차캐시, 동일성 보장, 쓰기 지연, 변경 감지, 지연 로딩이 있습니다.

Q. 더 자세히 설명해주세요.

A. 1차 캐시는 조회가 가능하며 1차 캐시에 없으면 DB에서 조회하여 1차 캐시에 올려 놓습니다. 동일성 보장은 동일성 비교가 가능합니다. 쓰기 지연은 트랜잭션이 지원하는 쓰기 지연이 가능하며 트랜잭션 커밋하기 전까지 SQL을 바로 보내지 않고 모아서 보낼 수 있습니다. 변경 감지는 스냅샷을 1차 캐시에 들어온 데이터로 찍고 커밋 되는 시점에 엔티티와 스냅샷을 비교하여 update 쿼리를 생성합니다. 지연 로딩은 엔티티에서 해당 엔티티를 불러올 때 그 때 SQL을 날려 해당 데이터를 가져옵니다.

면접 준비 : JPA를 사용하는 이유

Q. JPA를 사용하는 이유에 대해서 설명해주세요.

A. JPA를 사용하는 이유는 객체지향 프레임워크이기 때문입니다. JPA를 사용하면 비즈니스 로직이 RDBMS에 의존하는 것이 아니라, 자바 코드로 표현될 수 있기 때문에 생산성이 올라갑니다. 또한, JPA는 JPQL로 SQL을 추상화하기 때문에 RDBMS에 관계없이 동일한 쿼리를 작성해서 같은 동작을 기대할 수 있다는 장점도 가지고 있습니다. 이는 Database Dialect를 지원하기 때문에 가지는 장점입니다.

Q. 자바 코드로 표현했을 때 생산성이 높아지는 이유는 무엇인가요?

A. 자바 컬렉션에 저장하듯이 JPA에게 저장할 객체를 전달하여 지루하고 반복적인 코드를 개발자가 직접 작성하지 않아도 되며, DDL 문도 자동으로 생성해주기 때문에 데이터베이스 설계 중심을 객체 설계 중심으로 변경할 수 있습니다.

면접 준비 : N + 1 문제

Q. N + 1 문제는 무엇이고 이것이 발생하는 이유와 해결 방법에 대해서 설명해주세요.

A. 하위 엔티티들이 존재하는 경우 한 쿼리에서 모두 가져오는 것이 아닌, 필요한 곳에서 각각 쿼리가 발생하는 경우를 말합니다. N + 1 문제는 즉시로딩과 지연로딩 둘 다 발생할 수 있는데 즉시 로딩에서 발생하는 이유는 전체 조회를 했을 때, 영속성 컨텍스트가 아닌 데이터베이스에서 직접 데이터를 조회한 다음 즉시로딩 전략이 동작하기 때문입니다. 지연 로딩에서 발생하는 이유는 지연로딩 전략을 사용한 하위 엔티티를 로드할 때 해당 엔티티를 조회하기 위한 추가적인 쿼리가 실행되어 발생합니다. 해결 방법으로는 fetch join이라고 불리는 JPQL의 join fetch를 사용하는 방법이 있으며, 또 다른 방법으로는 @EntityGraph를 사용하는 방법이 있습니다. 1 : 다 관계에서 다 쪽에서 하위 엔티티에 접근 할 때는 @BatchSize를 사용해 조절할 수 있습니다. 각각의 유의사항으로는 fetch join을 사용할 경우 on 절과 같이 사용하면 오류가 나기 때문에 where 절을 사용해야 합니다. @EntityGraph를 사용할 경우 복잡한 연관관계에서는 꼬여버릴 경우 참사가 일어날 수 있으니 조심해서 사용해야 합니다.

면접 준비 : 데이터베이스 인덱스

Q. 데이터베이스에서 인덱스를 사용하는 이유는 무엇인가요?

A. 데이터베이스에서 인덱스를 사용하는 이유는 검색 성능을 향상시키기 위함입니다. 하지만 검색 성능을 실질적으로 향상시키기 위해서는 해당 쿼리가 인덱스를 사용하는지, 카디널리티, Selectivity 같은 요소들이 고려된 인덱스인지 확인해야 합니다.

Q. 데이터베이스에서 인덱스를 사용하면 좋은점과 나쁜점은 무엇인가요?

A. 인덱스를 사용했을 때 장점으로는 빠른 검색 성능을 들 수 있습니다. 단점으로는 인덱스를 구성하는 비용 즉, INSERT, UPDATE, DELETE 연산시에 인덱스를 형성하기 위한 추가적인 연산이 수행됩니다. 또한, 인덱스를 관리하기 위해 데이터베이스의 10퍼센트에 해당하는 저장공간이 필요합니다.

https://coding-review.tistory.com/230

면접 준비 : 트랜잭션, 트랜잭션의 ACID

Q. 트랜잭션에 대해서 설명해주세요

A. 트랜잭션이란 데이터베이스의 상태를 변화시키는 논리적인 작업 단위라고 할 수 있으며, 트랜잭션에는 여러개의 연산이 수행될 수 있습니다.

Q. 트랜잭션의 ACID에 대해서 설명해주세요

A. ACID는 각각 원자성, 일관성, 고립성, 내구성의 약자입니다. 각각의 뜻으로는 원자성은 트랜잭션의 모든 연산이 완벽히 수행되어야 하며, 한 연산이라도 실패하면 트랜잭션의 전체 연산은 실패하게 된다는 것입니다. 일관성은 트랜잭션이 진행되는 동안 데이터베이스가 변경되더라도 업데이트된 데이터베이스로 트랜잭션이 진행되는 것이 아니라 기존에 트랜잭션을 진행하던 데이터베이스로 진행해야 한다는 것입니다. 고립성은 서로 다른 트랜잭션이 동시에 실행될 경우 다른 트랜잭션에 의해 영향을 받지 않고 독립적으로 실행되어야 한다는 의미입니다. 내구성은 트랜잭션이 커밋된 이후에는 시스템 오류가 발생하더라도 커밋된 상태로 유지되는 것을 보장해야 한다는 것입니다.

면접 준비 : 데이터베이스 격리수준

Q. 트랜잭션의 격리수준에 대해 설명해주세요

A. 여러 트랜잭션이 처리될 때, 특정 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것입니다. 격리 수준으로는 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 이렇게 네가지가 있습니다.

Q. 각각에 대해 설명해주세요 (꼬리질문)

A. READ UNCOMMITTED는 다른 트랜잭션에서 커밋되지 않은 내용도 참조할 수 있습니다. READ UNCOMMITTED에서는 한쪽에서 커밋을 하지 않고 롤백을 하더라도 다른 트랜잭션에서 이를 참조할 수 있기 때문에 가장 안전성이 낮은 격리수준으로 사용하지 않는 격리수준입니다. READ COMMITTED는 커밋된 내용만 참조할 수 있는 격리수준입니다. 오라클에서 사용하는 격리 수준으로 READ UNCOMMITTED와는 다르게 커밋이 되어야 확인할 수 있기때문에 UNCOMMITTED 보다는 높은 격리 수준을 가지고 있습니다. 하지만 READ COMMITTED는 하나의 트랜잭션내에서 동일한 SELECT 쿼리를 실행했을 때 항상 같은 결과를 보장해야 한다는 REPEATABLE READ 정합성에 어긋납니다. NON-REPEATABLE READ 부정합은 하나의 트랜잭션에서 동일한 데이터를 여러번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 될 수 있습니다. REPEATABLE READ는 MySQL에서 사용하는 격리수준으로 REPEATABLE READ는 READ COMMITTED에서 발생하는 부정합이 발생하지 않습니다. 하지만 REPEATABLE READ에서는 PHANTOM READ라는 부정합 문제가 발생합니다. PAHNTOM READ는 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안보였다 하는 현상을 PHANTOM READ 현상이라고 합니다. 만약 SELECT 문에서 FOR UPDATE 구문을 사용한다면 해당 문제가 발생합니다. 하지만 MySQL의 InnoDB 스토리지 엔진에서는 독특한 특성 때문에 REPEATABLE READ 격리 수준에서도 PHANTOM READ 현상이 발생하지 않습니다 PHANTOM READ 문제를 해결하기 위해 나온 가장 엄격한 격리수준이 바로 SERIALIZABLE 입니다. 단순한 연산에도 락이 걸리기 때문에 처리 성능이 다른 격리 수준보다 현저히 떨어집니다. 그래서 잘 사용하지 않는 격리수준입니다.

Q. InnoDB 스토리지 엔진의 독특한 특성이 뭔가요? (꼬리질문)

A. InnoDB에 gap locking 이라는 기술덕분에 PHANTOM READ를 피해갈 수 있었습니다. gap locking이란 실제 존재하지 않는 레코드 공간을 잠그는 것을 말합니다.

https://coding-review.tistory.com/196

면접 준비 : 데이터베이스 정규화

Q. 데이터베이스 정규화란 무엇인가요?

A. 정규화란 데이터의 중복방지, 무결성을 충족시키기 위해 데이터베이스를 설계하는 것입니다. 정규화에는 제 1 정규화, 제 2 정규화, 제 3 정규화, BCNF 정규화가 있습니다. 제 1 정규화는 테이블의 컬럼이 하나의 값을 갖도록 테이블을 분해하는 것입니다. 제 2 정규화는 제 1 정규화를 진행한 테이블에 대해 완전 함수 종속을 만족하도록 테이블을 분해하는 것입니다. 완전 함수 종속이라는 것은 기본키의 부분집합이 결정자가 되어선 안된다는 것을 의미합니다. 제 3 정규화는 제 2 정규화를 진행한 테이블에 대해 이행적 종속을 없애도록 테이블을 분해하는 것입니다. 여기서 이행적 종속은 A -> B, B -> C 가 성립할때 A -> C 가 성립되는 것을 의미합니다. BCNF 정규화란 제 3 정규화를 진행한 테이블에 대해 모든 결정자는 후보키가 되도록 테이블을 분해하는 것입니다.

https://coding-review.tistory.com/235

면접 준비 : www.google.com을 검색하면 어떤 일이 벌어질까?

Q. www.google.com (http://www.google.com) 을 검색하면 어떻게 되나요?

A. 웹 브라우저는 캐싱된 DNS 기록들을 통해 해당 도메인주소와 대응하는 IP주소를 확인합니다. 이후 웹브라우저가 HTTP를 사용하여 DNS에게 입력된 도메인 주소를 요청합니다. DNS는 웹브라우저에게 찾는 사이트의 IP주소를 응답합니다. 이후 웹브라우저가 웹서버에게 IP주소를 이용하여 html문서를 요청합니다. 이후 WAS와 데이터베이스에서 우선 웹페이지 작업을 처리합니다. 해당 작업처리 결과를 웹서버로 전송합니다. 이후 웹서버는 웹브라우저에게 html 문서결과를 응답합니다. 웹브라우저는 화면에 웹페이지 내용물을 출력합니다.

https://coding-review.tistory.com/237

면접 준비 : TCP와 UDP

Q. TCP와 UDP의 차이점에 대해서 설명해주세요

A. TCP는 연결 지향형 프로토콜이고 UDP는 데이터를 데이터그램 단위로 전송하는 프로토콜입니다. TCP는 연결형 서비스로 가상 회선 방식을 제공합니다. 따라서 전송이 양방향으로 동시에 일어날 수 있는 전이중 방식과 각 연결이 정확히 2개의 종단점을 가지고 있는 점대점 방식을 사용합니다. 또한 흐름 제어와 혼잡 제어와 같은 특징때문에 높은 신뢰성을 보장합니다. UDP는 데이터 수신 여부를 확인하지 않아 신뢰성이 낮지만 TCP보다 속도가 빠르고 1:1 & 1:N & N:N 통신이 가능합니다. 결론적으로 TCP는 파일전송과 같이 신뢰성이 중요한 서비스에 사용되고 UDP는 스트리밍과 같이 연속성이 더 중요한 서비스에서 사용됩니다.

https://coding-review.tistory.com/239

면접 준비 : CAP 정리

Q. CAP 정리에 대해서 설명해주세요.

A. CAP 정리는 분산시스템에서 Consistency, Availability, Partition tolerance 이 세가지 속성을 모두 만족시키는 데이터베이스는 없다는 정리입니다. Consistency는 같은 시간에 클라이언트가 같은 데이터를 봐야 한다는 정합성이고, Availability는 모든 노드가 요청에의한 응답을 할 수 있어야 한다는 가용성이고, Partition tolerance는 장애 상황에서도 애플이케이션이 작동해야 한다는 장애 허용입니다.

Q. CAP정리에서 RDBMS는 어디에 속하나요? (충분히 물어볼 수 있음)

A. RDBMS는 분산시스템이 아니고, CAP정리는 분산시스템을 기반으로한 정리이기 때문에 RDBMS를 CAP정리로 설명하는 것은 맞지않습니다. 하지만 CAP정리를 처음 공개한 Brewer는 RDBMS를 CA라고 정의했습니다. 그 이유는 RDBMS는 마스터 노드와 슬레이브 노드로 구성되어 있으며, 장애가 생겨서 마스터 노드가 죽어버리면 슬레이브 노드중 하나가 마스터 노드를 대체하기 때문입니다.

면접 준비 : Elasticsearch

Q. Elasticsearch에 대해서 설명해주세요

A. Elasticsearch는 자바로 만들어진 오픈소스 검색엔진으로서 독단적으로 사용되지는 않고 ELK 스택이라고하는 기술로 묶어서 사용됩니다. Elasticsearch는 데이터의 검색을 담당하고 Logstash는 데이터 집계를 담당하고 Kibana는 데이터 시각화를 담당합니다.

면접 준비 : JPA 전파단계

Q. 트랜잭션 전파단계에 대해서 설명해주세요

A. JPA Propagation은 트랜잭션 동작 도중 다른 트랜잭션을 호출하는 상황에 선택할 수 있는 옵션입니다. @Transactional의 Propagation 속서을 통해 피호출 트랜잭션의 입장에서는 호출한 쪽의 트랜잭션을 그대로 사용할 수도 있고, 새롭게 트랜잭션을 생성할 수도 있습니다.

Q. 전파단계의 종류에 대해서 설명해주세요

A. REQUIRED는 부모 트랜잭션 내에서 실행하며, 부모 트랜잭션이 없을 경우 새로운 트랜잭션을 생성합니다. 그 외에는 REQUIRES_NEW, SUPPORTS, NOT SUPPORT, MANDATORY, NESTED, NEVER등이 있습니다.

면접 준비 : NoSQL

Q. NoSQL이 뭔가요?

A. NoSQL은 비관계형 데이터베이스로서 기존 RDBMS가 엄격한 스키마에 따른 정형 데이터를 저장하는 것에 특화된 것과 다르게 NoSQL은 ACID를 엄격히 지키지 않고, 스키마에서도 자유롭기 때문에 비정형 데이터를 저장하고 RDBMS에 비해 수평적으로 확장하기 쉽다는 특징이 있습니다. NoSQL의 타입으로는 Key-Value Store, Document Store, Wide Column Store, Graph Store가 있습니다.

Q. NoSQL과 RDBMS의 차이에 대해서 설명해주세요

A. 우선 크게 확장성, 성능, 보안적으로 설명할 수 있습니다. 수직적인 확장만 가능하던 RDBMS와 다르게 NoSQL은 수평적으로 확장이 가능하기 때문에, NoSQL의 경우 빅데이터를 다루기 좀 더 안성맞춤입니다. 그리고 성능또한 RDBMS보다 10~20퍼센트정도 더 뛰어납니다. 하지만 NoSQL은 보안상으로는 encyprt를 자체적으로 지원하지 않는다던가, 인젝션에 취약하다던가, 인증과 인가에대한 적절한 클래스가 없다는 것이 문제가 됩니다. 따라서 상황에 맞게 RDBMS와 NoSQL을 선택해서 사용해야 합니다.

면접 준비 : 동기, 비동기 프로그래밍

Q. 동기, 비동기 프로그래밍에 대해서 설명해주세요.

A. 동기 프로그래밍은 싱글스레드, Blocking Architecture로 대표되는 프로그래밍으로 요청을 보내면 그에 상응하는 응답이 올때까지 기다린다는 특징이 있습니다. 따라서 속도가 좀 느리고, 확장성이 낮다는 단점이 있지만 개발자 친화적이고, 메모리를 적게 먹으며, 오버헤드가 발생하지 않는다는 장점이 있습니다. 비동기 프로그래밍은 멀티 스레드, Non-Blocking Architecture로 대표되는 프로그래밍으로 요청과 응답이 각각 다른 스레드에 할당되어 응답을 기다리지 않아도 된다는 특징이 있습니다. 그렇기 때문에 속도가 빠르고, 확장성이 높다는 장점이 있지만 구현하기 까다롭고, 메모리를 많이 잡아먹으며, 오버헤드가 발생한다는 단점이 있습니다.

면접 준비 : 로드 밸런싱

Q. 로드 밸런싱에 대해서 설명해주세요

A. 로드 밸런싱은 트래픽을 적절히 서버에 분담해주는 기술입니다. 구현한 알고리즘은 정적 로드 밸런싱 알고리즘과, 동적 로드 밸런싱 알고리즘이 있습니다. 정적 알고리즘은 옆 서버의 상태와 성능을 고려하지 않고 순서에 따라 트래픽을 분배한다는 특징이 있습니다. 비효율적으로 보이지만 개발이 단순하다는 장점이 있습니다. 정적 알고리즘에는 라운드 로빈, 가중치에 기반한 라운드 로빈, IP Hash 가 있습니다. 동적 알고리즘은 가용성, 상태, 작업상황에 맞춰 트래픽을 분배한다는 특징이 있습니다. 굉장히 효율적이지만 구현이 까다롭고, 오버헤드가 발생할 수 있다는 단점이 있습니다. 동적 알고리즘에는 Least Connection, Weighted Least Connection, Weighted Response Time, Resource-Based 알고리즘이 있습니다.

-----------이 이상은 안물어볼 가능성이 농후합니다----------

Q. 각각의 알고리즘에 대해서 설명해주세요 (사실 이거까지 물어보지 않을듯 싶지만 정리함)

A. 라운드 로빈은 DNS의 리스트 순서대로 트래픽을 분배합니다. 가중치 기반 라운드 로빈은 기존 라운드 로빈의 특성과 더불어 각각의 서버가 처리할 수 있는 양에 따라 트래픽을 분배합니다. IP Hash는 요청이 들어온 IP를 해싱하여 정확한 위치에 도달하는 것을 보장합니다. Least Connection은 로드 밸런서가 각 서버의 연결이 가장 적은 서버에 트래픽을 분배합니다. Weighted Least Connection은 기존 Least Connection의 특성과 더불어 가중치를 기반한 트래픽 분배를 합니다. Weighted Response Time은 로드 밸런서가 각 서버까지 응답시간을 체크해서 가장 빨리 응답하는 서버에 트래픽을 보냅니다. Resource-Based는 로드 밸런서가 각 서버의 CPU상황과 메모리 상황을 고려하여 가장 최적의 서버에 트래픽을 분배합니다.

Q. L4 로드 밸런싱과 L7 로드 밸런싱에 대해서 설명해주세요 (이것도 물어볼지 모르겠으나 정리함)

A. L4 로드 밸런싱은 OSI 7계층중에 4계층 전송 계층에서 작동하는 로드 밸런싱으로 메세지를 면밀히 관찰하지 않고, decrypt하지 않은 메세지를 분배하기 때문에 매우 빠르고, 안전성이 높다는 특징이 있습니다. L4 로드 밸런싱은 패킷 레벨의 트래픽을 밸런싱할때 효과적입니다. L7 로드 밸런싱은 OSI 7계층 중 7계층인 애플리케이션 층에서 동작하는 로드 밸런싱으로 메세지를 면밀히 관찰하고 decrypt를 진행하기 때문에 성능적으로 L4 로드 밸런싱보다 딸린다는 단점이 있습니다. 하지만 쿠키나, 세션, URL 등을 보고 각 특징에 맞는 서버로 보낼 수 있고 특별한 패턴을 보이는 바이러스를 사전에 예방할 수 있기 때문에 보안적으로는 훌륭하다는 장점이 있습니다.

면접 준비 : 클러스터링, 레플리케이션, 샤딩 (안물어볼 가능성이 매우 높습니다)

Q. 클러스터링, 레플리케이션, 샤딩에 대해서 설명해주세요 (이거 안물어볼 가능성이 매우 높습니다)

A. 클러스터링이란 하나의 DB 스토리지에 여러 DB 서버를 달아서 원래였다면 하나의 서버에서 감당해야할 부하를 여러 서버가 나눠 담당하기 때문에 성능적인 이점을 볼 수 있습니다. 하지만 DB 스토리지가 하나밖에 없기 때문에 DB스토리지에 문제가 생긴다면 전체 애플리케이션이 죽어버릴 수 있다는 단점이 있습니다. 이를 보완하기 위한 방식이 바로 레플리케이션인데, 레플리케이션은 복제된 DB 스토리지를 각각의 DB서버와 묶어서 DB 스토리지가 죽어버릴 경우에 대비할 수 있습니다. 하지만 슬레이어 DB서버가 많이 늘어난다는 단점이 있습니다. 이를 보완하기 위한 방식이 바로 샤딩입니다. 샤딩은 데이터를 특징에 맞춰 나누기 때문에 레플리케이션의 장점을 그대로 계승하지만 굉장히 관리가 힘들다는 단점이 있습니다.

Q. 샤딩의 종류에 대해서 설명해주세요 (이건 절대 안물어봄)

A. 샤딩의 종류로는 Hash Sharding, Dynamic Sharding, Entity Group Sharding이 있습니다. Hash Sharding은 각각의 레코드를 해싱하여 저장하는 방식입니다. 이 방식은 안전하지만 확장성이 낮다는 단점이 있습니다. DB 서버가 늘어나면 해시 함수를 바꿔야 하는데 이렇게 되면 정합성에 어긋납니다. Dynamic Sharding은 로케이터 서비스를 기반으로 구간마다 샤드 키를 부여하여 데이터베이스를 쪼개는 기술입니다. 하지만 로케이터 서비스가 장애가 나면 전체 샤드에 영향을 줄 수 있습니다. Entity Group Sharding은 관계를 맺고있는 데이터끼리 샤드키로 묶어서 관리하는 샤딩 기술입니다. 이는 관계형 데이터베이스에 어울리는 샤딩 방식입니다.

참고)

이 포스팅은 면접에서는 절대 안물어볼 것 같은데 제가 공부한 내용을 정리한 쪽에 더 가깝다고 생각해주시면 감사하겠습니다. 이 내용을 면접에서 물어본다면 몰라도 충분히 상관없을 가능성이 높습니다. 애초에 데이터베이스 튜닝같은건 신입에게 요구되지않는 기술이고 개념이기 때문에 대답하지 못해도 상관없을 것 같습니다.

하지만 만약 면접관이 작정하고 DBA를 뽑을 심산이었거나 혹은 면접관의 스타일에 따라 언어, 스프링 이런건 잘 모르더라도 데이터베이스는 기깔나게 아는 사람을 선호한다면 충분히 물어봄직한 질문들입니다.

데이터베이스를 깊게 공부한다면 반드시 거쳐가는 길 중 하나이며 알아둬서 나쁠건 없지만 시간 없으시다면 굳이 몰라도 괜찮습니다.

면접 준비 : Stateful, Stateless

Q. Stateless, Stateful에 대해서 설명해주세요.

A. Stateful은 하나의 세션에 다음 트랜잭션에 필요한 정보를 가지고 있는 프로토콜입니다. 때문에 클라이언트가 보내는 정보의 양은 적지만 서버가 가지고 있어야 하는 양은 많습니다. Stateful은 데이터가 하나의 세션에 연결되어야 하는 온라인 뱅킹, SMTP 프토콜에서 주로 사용합니다. Stateless는 Stateful과 반대로 다음 트랜잭션에 필요한 정보를 가지고 있지 않은 프로토콜입니다. 때문에 클라이언트가 보내는 양은 많지만 서버가 가지고 있어야 하는 양이 적습니다. Stateless는 확장성과 멀티태스킹에 능하고 이는 HTTP 프로토콜, SMS, DNS등에서 주로 사용됩니다.

면접 준비 : 공개키 암호화, 개인키 암호화

Q. 공개키 암호화, 개인키 암호화에 대해서 설명해주세요.

A. 기존 대칭키 암호화에서 해커가 둘 중 하나의 키만 갈취해도 데이터를 복호화할 수 있기 때문에 이를 보완하고자 비대칭키 암호화가 등장하였습니다. 비대칭키 암호화의 주된 개념은 암호화와 복호화에 서로 다른 키를 사용하다는 것이고, 데이터를 암호화하는 키의 종류에 따라 사용되는 분야가 다릅니다. 공개키로 암호화하면 데이터 보안에 초점을 맞춘 것이고, 개인키로 암호화 하면 사용자 인증에 초점을 맞춘 것입니다.

면접 준비 : OSI 7계층

Q. OSI 7계층에 대해서 설명해주세요.

A. OSI 7계층은 1계층부터 물리계층, 데이터 링크 계층, 네트워크 계층, 전송 계층, 세션 계층, 표현 계층, 응용 계층으로 나눠져 있습니다. OSI 모델은 네트워크가 어떻게 동작하는지에 대해서 시각화를 도와주는 역할을 합니다. 그리고 네트워크 문제에서 문제를 어떻게 해결해야 하는지에 대한 방법을 제공해줍니다.

면접 준비 : www.google.com을 검색했을 때 생기는 일

Q. 검색창에 google.com을 검색하면 벌어지는 일을 OSI 7계층 / TCP 4계층와 연관지어 설명해주세요

A. google.com을 검색하면 브라우저는 TCP 4계층인 애플리케이션 계층의 DNS 캐시 레코드에서 URL과 상응하는 IP 주소를 찾습니다. 만약 요청된 URL이 캐시에 없으면 ISP의 DNS서버는 google.com을 호스팅하고 있는 서버의 IP 주소를 찾기위한 DNS쿼리를 날립니다. 그리고 그 후 브라우저는 TCP 3계층인 전송계층의 TCP 연결을 시작합니다. 연결이 끝난 후 브라우저는 HTTP 요청을 보내고 서버는 요청과 돌아오는 응답을 핸들링합니다. 웹 서버는 HTTP응답을 보내고 브라우저는 HTML문서 결과를 보여줍니다.

여태까지 공부한 Java 면접질문 / 답변

4부터 시작하는 것은 넘어가주시길 바랍니다... 따로 정리하면서 공부한거라 Java 면접질문이 아닌 123번을 제외하고 4번부터 수록했습니다. 꼬리질문에 대한 답변도 수록했기 때문에 조금 길게 느껴질 수 있습니다.

이 글을 보고 면접질문을 공부하실 분들은 우선 질답을 외우고 (저와 똑같이 안외워도 됩니다) 따로 찾아서 공부하시고 꼬리질문도 생각해보시면 좋을 것 같습니다.

4. OOP가 뭔가요?

OOP는 객체지향 프로그래밍의 약자로서 데이터를 객체로 취급하여 프로그램에 반영하고, 순차적으로 프로그램이 동작하는 것이 아니라 객체와 상호작용을 통해 프로그램이 동작하는 것을 말합니다. 이렇게 객체를 연결시켜 프로그래밍하게 되면 캡슐화, 다형성, 상속을 통해 코드의 재사용성을 높이고 유지보수를 줄여준다는 장점이 있습니다.

5. 추상클래스와 인터페이스의 차이점이 뭔가요?

인터페이스는 다중 상속이 가능하고 필드에 인스턴스를 자유롭게 선언할 수 없으며 필드에 선언시 public static final로 선언해야 합니다. 그 이유는 인터페이스는 그 자체로 인스턴스화 할 수 없기 때문입니다. 그렇기 때문에 객체가 할당되는 시점 즉, 초기화 시점에 할당해야 하고 이는 static으로 밖에 할 수 없습니다. 추상클래스는 다중 상속이 불가능하고 필드에 인스턴스를 자유롭게 선언할 수 있습니다. 추상클래스는 추상 메서드가 하나 이상 존재해야 하기 때문에 메서드의 일부 구현이 가능합니다. 또한, 추상클래스는 필드에 인스턴스를 자유롭게 정의할 수 있기 때문에 private이나 protected와 같은 접근제어자로 외부에 이를 숨길 수도 있습니다.

6. JVM이 뭔가요?

JVM은 Java Vertual Machine의 약자로 자바 애플리케이션을 클래스 로더를 통해 자바 API와 함께 사용하는 것을 도와줍니다. 또한, 스택 기반의 가상 머신으로 메모리 관리를 도와줍니다.

7. JVM의 구조에 대해서 설명해주세요

JVM의 구조로는 ClassLoader, Execution Engine, RuntimeData Areas, JNI, Native Method Library 이렇게 존재합니다. ClassLoader는 JVM내에서 클래스를 로딩하여 링크를 통해 배치작업을 수행합니다. Execution Engine은 바이트 코드를 실행하는 역할을 하고, 구성 요소로는 인터프리터, 지트 컴파일러, 가비지 컬렉션이 있습니다. 인터프리터는 바이트 코드를 한줄씩 실행하는 역할을 합니다. 지트 컴파일러는 인터프리터의 효율을 높이기 위해 만든 컴파일러로 인터프리터가 반복되는 코드를 발견하면 반복되는 코드를 네이티브 코드로 바꿔주는 역할을 합니다. 가비지 컬렉션은 힙 영역에서 사용하지 않는 객체를 삭제하는 역할을 합니다. Runtime Data Areas는 프로그램이 동작중에 사용되는 다양한 영역이고 구성 요소로는 PC Register, Stack Area, Heap Area, Method Area가 있습니다. PC Register는 스레드가 실행될 때 실행되고 JVM내의 주소를 가지고 있습니다. Stack Area는 지역변수, 파라미터등이 저장되는 공간으로 실제 객체는 Heap 영역에 저장되지만 레퍼런스는 Stack영역에 저장됩니다. Heap Area는 동적으로 할당된 객체나 배열이 저장되는 공간으로 가비지 컬렉션의 영역입니다. Method Area는 메서드 정보, type 정보, static 변수, final 변수, 상수 풀 등이 저장되는 공간입니다.

8. GC에 대해서 설명해주세요.

가비지컬렉션은 힙 영역에서 참조가 남아있는 객체를 삭제하는 역할을 합니다. 이렇게 삭제를 해야하는 이유는 자바라는 언어는 개발자가 직접 메모리를 손댈 수 있는 언어가 아니기 때문입니다. 따라서 이렇게 삭제시켜주는 객체가 있어야 합니다.

9. GC의 동작방식에 대해서 설명해주세요.

GC의 동작방식은 가장 간단한 Serial GC로 설명할 수 있습니다. GC는 Minor GC, Major GC로 나눠집니다. Minor GC는 영한 영역에서 Major GC는 올드한 영역에서 일어난다고 정의합니다. Minor GC는 에덴영역이 가득 차면서부터 시작됩니다. 에덴 영역이 가득차면 참조가 남아있는 객체는 서바이벌 영역으로 복사되고 에덴영역이 비워집니다. 또 다시 에덴 영역이 가득차면 다시 참조가 남아있는 객체들이 서바이벌 영역에 복사되고 에덴 영역이 지워집니다. 이렇게 반복하다보면 올드한 영역으로 이동하게 됩니다. 올드한 영역은 Major GC가 활동하는 공간입니다. Major GC는 Minor GC와 다르게 삭제되어야 하는 객체들을 삭제시키는 역할을 합니다. 이렇게 객체를 지우고 나면 메모리는 단편화된 상태가 됩니다. 그렇기 때문에 이 공간을 한군데로 모아주어야하고 이를 Compaction, Compact라고 합니다. 따라서 이때 쓰이는 알고리즘이 Mark Sweep Compact 알고리즘입니다. 또한 GC를 수행하는 스레드 이외의 스레드는 모두 정지합니다. 이를 Stop The World라고 합니다. 개발자들은 이 STW를 줄이기 위해 많은 GC를 개발했고 Serial GC이후에 Parallel GC, CMS GC, G1 GC등을 개발했습니다.

10. Mark Sweep Compact 알고리즘이 필요한 이유가 뭔가요?

Mark Sweep Compact 알고리즘이 필요한 이유는 책이 가득찬 책장으로 비유할 수 있습니다. 책장에 책이 가득차서 더이상 들어갈 공간이 없을 때 읽지 않는 책을 정리하기 위해 버리는 작업을 수행합니다. 그렇게 되면 책장에는 버린 책의 크기만큼 공간이 생기게 됩니다. 이때 버린 책의 크기보다 더 큰 크기의 책을 저장하기 위해서 책장에 넣게 되면 들어가지 않고 이 때 프로그램은 OutOfMemorry Error가 발생합니다. 그렇기 때문에 이 책장을 한군데로 모아주는 작업이 필요하고 때문에 Mark Sweep Compact 알고리즘을 사용해 단편화된 메모리를 한군데 모아주어야 합니다.

11. Mark Sweep Compact 알고리즘 말고 다른 알고리즘을 아는게 있나요?

Mark and Sweep 알고리즘과 Mark and Copy 알고리즘을 알고있습니다. Mark and Sweep 알고리즘은 GC가 GC Root를 돌면서 참조가 남아있는 객체는 Mark하고 그렇지 않은 객체는 Sweep하는 알고리즘입니다. Mark and Copy 알고리즘은 GC가 수행될 때 프로그램이 정지하는 시간을 줄여주는 알고리즘입니다.

12. 컬렉션 프레임워크에 대해서 설명해주세요.

세상에서 널리 사용되는 자료구조를 바탕으로 데이터를 좀 더 쉽게 관리하기 위해 자료구조를 모아놓은 프레임워크가 바로 컬렉션 프레임워크입니다.

13. 제네릭에 대해서 설명해주세요.

제네릭은 자바에서 타입안정성을 맡고있습니다. 컴파일시에 타입을 체크해주기 때문에, 형 변환의 번거로움을 줄여줍니다.

14. 어노테이션에 대해서 설명해주세요.

어노테이션은 인터페이스를 기반으로 한 문법으로 주석처럼 코드에 달아 새로운 기능을 부여하거나 특별한 기능을 주입할 수 있습니다.

15. 오버라이딩, 오버로딩에 대해서 설명해주세요.

오버라이딩은 상위 타입의 메서드를 재정의 하는 것이라고 할 수 있습니다. 또한, 오버라이딩은 런타임 다형성입니다. 오버로딩은 같은 클래스 내에서 메서드 이름은 같지만 파라미터의 개수나 매개변수의 타입이 다르게 설계하는 방법입니다. 또한, 오버로딩은 컴파일타임 다형성입니다.

16. static (정적) 에 대해서 설명해주세요.

클래스 멤버변수, 정적 변수라고도 불리는 static 변수는 클래스로더를 통해 메서드 메모리 영역에 클래스별로 적재됩니다. static 키워드를 사용한 변수는 PermGen이나 MetaSpace에 저장되고 이렇게 저장된 객체는 어디서든지 참조가 가능하다는 장점이 있습니다. 하지만 메모리에 항상 상주하고 있기 때문에 GC의 영역밖에 있기 때문에 시스템이 종료되어도 메모리에 남아있게 됩니다. 따라서 너무 남발하면 프로그램에 악영향을 미칠 수도 있습니다.

17. 자바의 원시타입의 종류와 크기에 대해서 설명해주세요.

boolean 1바이트, unsigned char 2바이트, byte 1바이트, short 2바이트, int 4바이트, long 8바이트, float 4바이트, double 8바이트로 이렇게 여덟개가 있습니다. 하지만 이 크기는 JVM에 의존적이기 때문에 대략적인 수치입니다.

18. 접근 제어자의 종류

접근 제어자는 private, default, protected, public 이렇게 네가지가 있습니다. private은 같은 클래스 내에서 접근 가능하고, default는 같은 패키지 안에서, protected는 부모 자식간에서만, public은 어느곳에서나 접근이 가능합니다. 접근 제어자를 사용하는 이유는 외부 클래스에 보여주고 싶은 정보를 선택적으로 제공하기 위함이고 이는 객체지향에서 말하는 캡슐화와 일맥상통합니다.

19. 객체지향의 5대원칙 SOLID에 대해서 설명해주세요.

SOLID는 SRP, OCP, LSP, ISP, DIP의 앞글자를 따서 만든 단어입니다. 각각에 대해서 설명하자면 SRP는 단일 책임 원칙으로 하나의 객체는 하나의 책임만을 가져야 한다는 원칙입니다. OCP는 개방 폐쇄 원칙으로 확장에는 열려있으나 변경에는 닫혀있어야 한다는 원칙입니다. 이는 다형성을 이용해서 해결할 수 있습니다. LSP는 리스코프 치환원칙으로 객체는 프로그램의 정확성을 깨뜨려서는 안되고, 리스코프 치환원칙에 따르면 상위타입을 오버라이딩 했을 때 프로그램의 정확성을 깨뜨려서는 안됩니다. ISP는 인터페이스 분리 원칙으로 클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안된다는 원칙입니다. 특정 클라이언트를 위한 인터페이스 여러개가 범용 인터페이스보다 낫습니다. 따라서 인터페이스를 더 작게 분리하고 쪼개야합니다. DIP는 의존관계 역전 원칙으로 객체는 자신보다 구체적인 것에 의존해서는 안되고 변화하기 쉬운 것에 의존해서는 안된다는 원칙입니다. 즉, 구현클래스에 의존하지 말고 추상클래스에 의존해야 한다는 것입니다.

20. 동일성과 동등성에 대해서 설명해주세요.

동일성은 객체의 주소를 비교하는 것이고, 동등성은 객체의 같음을 비교하는 것입니다. 보통 Object 클래스의 equals 메서드로 동등성을 판단하지만 개발자가 원한다면 equals 메서드를 오버라이딩해서 동등성의 판단의 기준을 정할 수 있습니다.

21. 원시타입과 참조타입에 대해서 설명해주세요.

원시타입은 자바에서 8개밖에 존재하지 않는 타입입니다. 나머지는 전부 참조타입입니다. 참조타입은 Object 클래스이거나 이를 오버라이딩한 클래스이고 이 둘의 차이는 원시타입은 null값을 가질 수 없지만 참조타입은 가질 수 있다는 점과, 멤버 변수로 선언되었을 때 원시타입은 초기값을 가지지만 참조타입은 null값을 가진다는 차이점도 존재합니다.

22. CheckedException과 UnCheckedExcetion에 대해서 설명해주세요.

이 둘은 RuntimeException을 상속하는지 여부에 따라 달라집니다. 상속한다면 UnCheckedException 상속하지 않는다면 CheckedException입니다. CheckedException은 예외가 발생했을 때 반드시 try/catch문으로 예외를 해결해주거나 throws 를 통해 메서드 밖으로 던져버려야 합니다. UnCheckedException은 말 그대로 컴파일러가 예외를 체크하지 않겠다는 의미이고 따라서 try/catch문으로 반드시 예외를 처리해주지 않아도 되고 throws를 통해 메서들 밖으로 던져버리는 것 또한 생략할 수 있습니다.

23. 그럼 둘 중 어느것을 사용해야 할까요?

기본적으로는 UnCheckedException을 사용해야 합니다. 그 이유는 기본적으로 예외는 처리 불가능한 예외가 대부분이고 또한 프로그램의 확장으로 예외가 변경되었을 때 CheckedException을 사용하면 그 예외에 맞는 모든 예외를 바꿔야합니다. 이는 OCP에 위배되기 때문에 지양해야 합니다. 하지만 CheckedException은 개발자끼리 서로 협력할 때 이 예외는 반드시 처리해야 한다고 명시해줄 수 있습니다. 예를 들어서 회원가입시 아이디 중복체크를 반드시 하고 넘어가야 한다거나, 돈과 관련된 비즈니스 로직을 처리해야 할때는 CheckedException을 사용하면 좋습니다. 하지만 기본적으로는 UnCheckedException을 사용하고 로그를 통해 빠르게 개발자가 알아차리는 것이 중요합니다.

24. String, StringBuilder, StringBuffer에 대해서 설명해주세요.

String은 불변타입입니다. StringBuilder와 StringBuffer는 이러한 String의 특징때문에 나온 가변타입입니다. 이 둘의 차이는 스레드 세이프한지의 여부에 따라 다릅니다. StringBuilder는 스레드 세이프하지 않고, StringBuffer는 스레드 세이프합니다. 따라서 멀티스레드 환경에서는 StringBuffer를 사용해야 합니다.

25. Java8에 추가된 기능 (요건 사실 잘 안물어봅니다)

Optional, Lambda, Stream API, 시간날짜 API가 있습니다. Optional은 Null safety를 위해 나온 기능이고 Lambda는 함수형 프로그래밍을 지원하기 위해 나온 기능입니다. Stream API는 고차원 함수를 지원하고 시간날짜 API는 기존 Date 클래스를 이용해 만들던 날짜를 좀 더 쉽게 작성할 수 있게 도와줍니다.

26. try-with resources 에 대해서 설명해주세요.

try 블록 안에 개발자가 사용할 객체를 명시적으로 선언하고 try 블록안의 로직이 정상적으로 완료되던, 비정상적으로 완료되던간에 무조건 JVM에 자원을 반환하는 것이 바로 try-with resources입니다.

27. 결합도에 대해서 설명해주세요.

결합도는 서로다른 두 모듈간에 의존성 정도를 나타내고 어느 한 모듈에 대해 얼마나 많이 알고 있는지에 대한 척도입니다. 어떤 모듈이 한 모듈에 대해 너무 많은 정보를 알고 있는 경우 이 둘은 강한 결합도를 가진다고 말합니다. 어떤 모듈이 한 모듈에 대해 적당한 수준의 정보를 알고 있는 경우 이 둘은 느슨한 결합도를 가진다고 말합니다. 객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계를 유지하고 있는지를 나타냅니다. 따라서 개발자는 강한 결합을 지양해야하며 항상 적절한 수준의 결합도를 유지하기위해 노력해야합니다.

28. 직렬화, 역직렬화에 대해서 설명해주세요.

직렬화, 역직렬화는 자바시스템 내부에서 사용하고있는 객체 또는 데이터를 자바시스템 외부에서 사용할 수 있도록 바이트 형태로 변환하거나 이미 바이트 형태로 되어있는 것을 다시 변환하는 작업을 말합니다. 자바 직렬화에서 JVM에 상주하고 있는 객체 데이터를 영속화 하기 위해 사용됩니다. 시스템이 종료되더라도 삭제되지 않는 장점을 가지고 있으며 영속화된 데이터이기 때문에 네트워크로 전송이 가능합니다.

29. 동시성 문제

동시성 문제는 멀티 스레드 환경에서 여러개의 스레드가 하나의 변수 혹은 하나의 메서드에 접근할 때 값이 꼬여 개발자가 원하는대로 작동하지 않는 경우를 말합니다. 동시성 프로그래밍에서 CPU와 RAM 사이에 있는 CPU Cache Memory의 존재와 병렬성이라는 특징 때문에 가시성과 원자성 문제가 발생합니다. 가시성 문제는 volatile 키워드로, 원자성 문제는 synchronized 키워드로 해결할 수 있습니다.

여태까지 공부한 Spring 면접질문 / 답변

https://coding-review.tistory.com/343

위의 포스팅과 이어지는 내용입니다.

1. 스프링이 어떻게 트랜잭션을 추상화했나요? / 왜 추상화 했나요?

트랜잭션이 데이터 접근 기술마다 다른 현상이 발생했습니다. 예를 들어서 JDBC에서 JPA로 기술을 확장하기 위해서 그에 따른 모든 트랜잭션 코드들을 바꿔야했습니다. 이를 해결하고자 스프링에서는 플랫폼트랜잭션매니저 라는 것으로 모든 트랜잭션 코드들을 추상화 했습니다. 이를 통해 개발자는 특정 데이터베이스 접근기술에 종속적이지 않은 트랜잭션 코드를 작성할 수 있게 되었습니다.

2. @Transactional에 대해서 설명해주세요

@Transactional은 스프링이 만든 AOP 중 하나입니다. @Transactional이 붙어있는 메서드에 한해서 트랜잭션의 원자성이 보장되고, 정상 로직일 때 커밋, 예외 상황일 때 롤백이 발생합니다. 이 어노테이션을 통해서 개발자는 비즈니스 로직과 트랜잭션 코드를 분리함으로써 좀 더 순수한 비즈니스 로직을 구현할 수 있게 되었습니다.

3. AOP가 뭔가요?

AOP는 관점지향 프로그래밍의 약자로서 기존 OOP에서 기능별로 클래스를 분류했음에도 불구하고 로깅, 트랜잭션, 자원해제와 같은 공통관심사가 생기기 시작했습니다. 이를 해결하고자 개발자는 좀 더 비즈니스 로직에 집중하고 공통관심사를 분리하는 기술이 바로 AOP기술입니다.

30. 스프링 DI에 대해서 설명해주세요.

스프링 DI는 스프링 프레임워크에서 지원하는 IoC의 형태로, 클래스 사이의 의존 관계를 빈 설정정보를 바탕으로 자동으로 주입해주는 것을 말합니다. 스프링에서는 스프링 컨테이너인 ApplicationContext를 통해 빈 설정정보를 등록, 수정할 수 있으며 원하는 객체를 생성자, setter, 필드로 주입할 수 있습니다.

31. DI의 종류에 대해서 설명해주세요.

DI의 종류는 생성자 주입, setter 주입, 필드 주입이 있습니다. 생성자 주입은 생성자가 생성될 때 단 한번만 호출되는 것을 보장하고 필수, 불변의 의존관계에서 사용됩니다. setter 주입은 선택, 변경 가능한 의존관계에서 사용되고 스프링 빈을 선택적으로 등록할 수 있습니다. 필드 주입은 필드에 선언된 객체에 @Autowired를 붙여서 직접 주입하는 것입니다. 하지만 이렇게 필드 주입을 하게 되면 외부에서 코드를 변경할 수 없기 때문에 테스트 코드를 작성하는데 굉장히 까다로워지고 순환 참조가 발생할 수 있으며 DI 프레임워크가 작동하지 않으면 동작하지 않기 때문에 애플리케이션 코드와 전혀 관계없는 테스트 코드에서 주로 사용됩니다.

32. 스프링 빈에 대해서 설명해주세요.

스프링 빈은 스프링 컨테이너에서 관리하는 자바 객체를 의미합니다.

33. 스프링 컨테이너에 대해서 설명해주세요.

스프링 컨테이너는 스프링 빈의 생명주기를 관리하고 생성된 스프링 빈에 추가적인 기능을 제공하는 역할을 합니다. 개발자가 생성한 객체는 new 연산자나 인터페이스 호출등의 방식으로 생성, 소멸되지만 스프링 컨테이너를 사용하면 이를 자동으로 수행해줍니다. 즉, 제어의 흐름을 외부에서 관리한다는 의미입니다. 또한, 객체 또는 클래스 사이의 의존관계를 런티임시에 자동으로 주입해주는 역할도 합니다.

34. 스프링 빈의 생성과정에 대해서 설명해주세요.

스프링 빈은 의존설정, 객체 생성, 초기화, 사용, 소멸의 순서를 가지고 이는 스프링 컨테이너에 의해 관리됩니다. 빈 초기화는 @PostConstruct에 의해 수행될 수 있고, 빈 소멸은 @PreDestroy에 의해 수행될 수 있습니다. 이렇게 선언된 빈은 컴포넌트 스캔이나 Configuration 클래스를 빈으로 등록해서 사용할 수 있습니다.

35. 스프링 빈 스코프에 대해서 설명해주세요.

스코프는 스프링 빈이 존재할 수 있는 영역을 나타내고 싱글톤, 프로토타입, request, session, application이렇게 다섯가지가 있습니다. 싱글톤은 가장 기본이 되는 스코프로서 스프링 컨테이너의 생성과 소멸까지 관여하는 가장 긴 범위의 스코프입니다. 프로토타입은 빈 생성과 주입까지만 관여하고 그 이후는 관여하지 않는 가장 짧은 범위의 스코프입니다. request는 웹 요청이 들어오고 나올 때 까지 유지되는 스코프, session은 웹 세션이 생성되고 소멸될 때 까지 유지되는 스코프, application은 서블릿 컨테이너와 같은 방식으로 동작하는 스코프입니다.

36. 스프링 MVC의 동작과정에 대해서 설명해주세요.

핸들러 매핑에서 핸들러를 조회하고 핸들러 어댑터 목록에서 사용할 핸들러 어댑터를 조회한다음 핸들러 어댑터를 실행하면서 그 때 사용할 핸들러도 같이 실행합니다. 이 때 핸들러 어댑터는 디스패처 서블릿에세 ModelAndView를 반환합니다. 그럼 viewResolver는 핸들러 어댑터에게 뷰를 반환합니다. 그렇게 되면 디스패처 서블릿이 뷰를 랜더링합니다.

37. 필터와 인터셉터의 차이점에 대해서 설명해주세요.

우선 둘은 스펙부터가 다릅니다. 필터는 javax.servlet에 포함된 클래스이고 인터셉터는 스프링MVC에 포함된 클래스입니다. 또한, 이 둘은 사용 시점도 다릅니다. 필터는 서블릿이 수행되기 전후로 실행됩니다. 인터셉터는 핸들러가 실행되기 전후에 실행되거나 viewResolver에 의해 호출된 Controller에서 viewName을 가진 렌더링을 담당한 실제 view 오브젝트가 렌더링 되고 수행됩니다. 마지막으로 저장 위치가 다릅니다. 필터는 Web Application에 저장되고 톰캣을 사용한다면 web.xml에 저장됩니다. 인터셉터는 스프링 컨테이너인 ApplicationContext에 저장됩니다.

38. CORS 에러에 대해서 설명해주세요.

CORS에러는 Cross Origin Resource Sharing의 약자로 동일한 출처가 아닌 다른 출처에서의 접근을 막는 보안 정책입니다.

39. 스프링에서 CORS에러를 해결하기 위해서 어떻게 해야하나요?

스프링에서 CORS 에러를 해결하기 위해서는 필터에서 Access-Control-Allow-Origin을 헤더에 담아 응답하거나 WebMvcConfigurer를 구현한 Configuration 클래스를 빈으로 등록하거나 @CrossOrigin 어노테이션을 사용하면 됩니다. 이중 가장 간단한 것은 어노테이션을 사용하는 것이지만 문제는 컨트롤러마다 어노테이션을 붙여야해서 url이 바뀌게 되면 일일히 찾아가서 바꿔야 한다는 문제가 있습니다.

40. @Bean, @Component 어노테이션에 대해서 설명해주세요.

둘 다 빈을 싱글톤으로 등록하기 위해 사용하는 어노테이션입니다. 이 둘의 차이는 @Bean은 메서드를 빈으로 등록할 때 사용하고, @Component는 클래스를 빈으로 등록할 때 사용합니다.

41. POJO 클래스가 뭔가요?

POJO 클래스는 클래스를 구현하거나, 확장하지 않은 가장 순수한 자바 클래스입니다. POJO 클래스는 자바 API외에 어떠한 것에도 종속되지 않습니다. 어떠한 것에도 종속되지 않기 때문에 코드를 작성하기 굉장히 쉽고 테스트하기 굉장히 쉽습니다. 스프링에서 POJO 클래스에 해당하는 것은 도메인 클래스나 비즈니스 로직을 담고있는 Service 클래스입니다.

42. JPA 영속성 컨텍스트를 사용하면 좋은 이점이 뭔가요?

JPA 영속성 컨텍스트를 사용하면 좋은 이점은 1차캐시, 동일성 보장, 쓰기 지연, 변경 감지, 지연 로딩이 있습니다. 1차 캐시는 데이터베이스에서 조회가 가능하고 JPA가 데이터를 관리하는데 사용되는 데이터베이스입니다. 동일성 보장은 ==비교가 가능하다는 의미입니다. 쓰기 지연은 트랜잭션이 제공하는 쓰기 지연을 사용할 수 있고 커밋이 되기 전에 SQL문을 모아놨다가 커밋이 될 때 이를 한번에 적용시키는 것을 의미합니다. 변경 감지는 1차캐시에 데이터를 스냅샷에 찍고 이후 커밋이 될 때 엔티티와 스냅샷을 비교해서 다르면 update 쿼리가 날라가는 것을 말합니다. 지연 로딩은 객체가 선언될 때가 아닌 호출될 때 쿼리가 날라가는 것을 의미합니다.

43. JPA를 사용하는 이유에 대해서 설명해주세요.

JPA를 사용하는 이유는 객체지향 프레임워크이기 때문입니다. JPA를 사용하는 비즈니스 로직은 RDBMS에 의존적이지 않고 자바코드로 작성할 수 있습니다. 자바코드로 작성하면 생산성이 올라갑니다. 또한, JPA는 SQL문을 JPQL이라는 것으로 추상화하여 RDBMS에 의존하지 않고 동일한 쿼리로 동일한 결괏값을 기대할 수 있습니다. 이는 Database dialect를 지원하기 때문에 생기는 장점입니다.

44. 자바코드로 작성하면 왜 생산성이 올라가나요?

자바 컬렉션에 저장하듯이 데이터를 저장하고 개발자가 귀찮아할만한 DDL문을 자동으로 작성해주어 데이터베이스 설계 중심에서 객체 설계 중심으로 변경할 수도 있습니다.

45. N + 1 문제에 대해서 설명해주세요.

N + 1문제는 연관관계를 맺은 두 엔티티에서 데이터를 하나의 쿼리에 전부 가져오는 것이 아니라 필요할 때 그때그때 쿼리가 날아가는 것을 말합니다. N + 1문제는 즉시로딩과 지연로딩 모두에서 발생하는데 즉시로딩에서 발생하는 이유는 전체를 가져오는 쿼리를 작성하면 영속성 컨텍스트에서 데이터를 조회하는 것이 아니라 데이터베이스에서 데이터를 조회하고 즉시로딩 전략이 작동하기 때문입니다. 지연로딩에서 발생하는 이유는 지연로딩을 채택한 하위 엔티티를 로드할 때 조회에 필요한 쿼리가 추가적으로 나가기 때문에 발생합니다. 이러한 N + 1문제를 해결하기 위해서는 fetch join 이라고 불리는 JPQL의 join fetch를 사용하거나 @EntityGraph 어노테이션을 사용하거나 @BatchSize 어노테이션을 사용하면 해결할 수 있습니다. 이때 주의사항으로는 fetch join은 on절을 사용하면 에러가 발생하기 때문에 where절로 조건문을 작성해야 하고, @EntityGraph의 경우에는 너무 많은 연관관계가 맺어진 경우엔 자칫 잘못하다간 파국으로 치닫을 수 있기 때문에 조심해서 사용해야 합니다.

여태까지 공부한 데이터베이스 면접질문 / 답변

https://coding-review.tistory.com/344

위의 포스팅과 이어집니다.

46. 데이터베이스 인덱스에 대해서 설명해주세요.

데이터베이스 인덱스는 조회 성능을 향상시켜주는 데이터베이스 튜닝 방식 중 하나입니다. 하지만 실질적인 성능 향상을 위해서라면 인덱스가 사용된 컬럼인지 카디널리티나 셀렉티비티같은 구성 요소들이 고려된 인덱스인지 확인해야합니다.

47. 인덱스의 장점과 단점에 대해서 설명해주세요.

인덱스의 장점은 역시 검색 성능의 향상입니다. 단점은 인덱스를 구성하기 위한 비용 즉, UPDATE, INSERT, DELETE와 같은 연산을 하게 되면 추가적인 연산이 필요합니다. 또한, 인덱스를 유지하기 위한 저장공간인 전체 데이터베이스의 10퍼센트정도가 필요합니다.

48. 트랜잭션이 뭔가요?

트랜잭션은 데이터베이스의 상태를 변경하기위한 논리적인 단위입니다. 트랜잭션에는 여러개의 연산이 포함될 수 있습니다.

49. 트랜잭션의 ACID에 대해서 설명해주세요.

트랜잭션의 ACID는 객체지향의 SOLID처럼 원자성, 일관성, 고립성, 내구성의 앞글자를 따서 만든 단어입니다. 각각에 대해서 설명해보자면 원자성은 트랜잭션의 모든 연산은 완벽해야하고 하나의 연산이라도 실패하는 경우 모든 연산이 실패해야 한다는 특징입니다. 일관성은 트랜잭션이 진행중에 데이터베이스가 변경되더라도 변경된 데이터베이스가 아닌 진행하고 있던 데이터베이스로 마저 진행해야 한다는 특징입니다. 고립성은 서로다른 두 트랜잭션이 서로에게 영향을 받지않고 독립적으로 동작해야 한다는 특징입니다. 내구성은 시스템이 오류로 정지하더라도 커밋이 되었다면 커밋을 보장해야 한다는 특징입니다.

50. 트랜잭션의 격리수준에 대해서 설명해주세요.

트랜잭션의 격리수준은 서로 다른 두 트랜잭션이 얼마나 많은 정보를 볼 수 있는지를 선택하는 옵션입니다. 격리수준에는 READ UNCOMMITED, READ COMMITED, REPEATABLE READ, SERIEALIZABLE 이렇게 네가지가 있습니다. READ UNCOMMITED는 커밋되지 않은 읽기로서 말 그대로 커밋이 되지 않아도 읽을 수 있습니다. 만약 한 트랜잭션이 연산을 수행하다 롤백을 했어도 다른 트랜잭션이 롤백을 하기 이전의 데이터로 연산이 가능하다는 뜻입니다. 이를 더티 리드 부정합 문제라고 하고 이는 굉장히 치명적이기 때문에 아예 이 옵션을 지원하지조차도 않는 데이터베이스가 많으며 지원을 하더라도 권고하지 않는 데이터베이스 격리수준이며 정말 필요한 경우 속도 향상을 위해서 사용하는 경우가 종종 있습니다. READ COMMITED는 오라클, PostgreSQL이 기본적으로 사용하는 격리수준으로 앞서 설명했던 더티리드 부정합 문제가 발생하지 않습니다. 커밋이 되어야만 데이터를 읽을 수 있기 때문에 더티 리드 부정합 문제는 발생하지 않지만 같은 쿼리에서 같은 결괏값이 나와야 한다는 Non-Repeatable Read 부정합 문제가 발생합니다. 이 때 오라클이나 PostgreSQL의 경우 Non-Repeatable Read 부정합 문제를 해결하기 위해서 MVCC라는 방법론을 통해 해결했고 오라클의 경우 Undo 테이블을 도입함으로써, PostgreSQL의 경우 Xmin, Xmax를 도입하고 Vaccum이라는 것을 통해 이 부정합 문제를 해결했습니다. REPEATABLE READ 격리수준은 MySQL에서 기본적으로 선택하고 있는 격리수준으로 앞에서 설명한 Non-Repeatable Read 부정합 문제가 발생하지 않습니다. 하지만 쿼리를 재실행 했을 때 레코드가 보였다 안보였다 하는 Phantom Read 부정합 문제가 발생합니다. MySQL의 경우 Shared Lock, Exclusive Lock을 가지고 Record Lock, Gap Lock을 만든 후 이를 결합한 Next-Key Lock으로 이 Phantom Read 부정합 문제를 해결했습니다. SERIEALIZABLE 격리수준은 앞서 설명한 모든 부정합 문제가 발생하지 않지만 읽기작업에도 락을 획득해야하는 문제가 있기 때문에 성능상 다른 격리수준에 비해서 좋지 못합니다.

51. 데이터베이스 정규화에 대해서 설명해주세요.

데이터베이스 정규화는 데이터 무결성, 중복방지를 위해 데이터베이스를 설계하는 방법 중 하나입니다. 정규화에는 제 1정규화, 제 2정규화, 제 3정규화, BCNF 정규화가 있습니다. 제 1정규화는 하나의 컬럼에 하나의 값만 들어가도록 테이블을 쪼개는 것입니다. 제 2정규화는 제 1정규화가 끝난 테이블에 한해서 완전 함수 종속을 만족시키도록 테이블을 쪼개는 것입니다. 완전 함수 종속이란 기본키의 부분집합이 결정자가 되어서는 안된다는 것입니다. 제 3정규화는 제 2정규화가 끝난 테이블에 한해서 이행적 종속을 만족시키지 않도록 테이블을 쪼개는 것입니다. 이행적 종속이란 A->B, B->C, A->C를 하나의 테이블에서 만족하는 경우를 뜻합니다. BCNF 정규화는 제 3정규화가 끝난 테이블에 한해서 결정자는 모두 후보키여야한다를 만족시키도록 테이블을 쪼개는 것입니다.

52. NoSQL에 대해서 설명해주세요.

NoSQL은 비관계형 데이터베이스라고도 불리고 기존 RDBMS에서 저장하지 못했던 비정형 데이터를 저장할 수 있고 뛰어난 수평적인 확장성과 뛰어난 유연성으로 스마트폰에 적합한 2009년에 개발된 데이터베이스입니다. NoSQL은 Key-Value Store, Document Store, Wide Column Store, Graph Store가 있습니다.

53. RDBMS와 NoSQL의 차이에 대해서 설명해주세요.

우선 이 둘을 성능, 보안, 확장성, 유연성을 기준으로 구분할 수 있습니다. 우선 성능은 기본적으로 1대1 연산의 경우 NoSQL이 RDBMS보다 10~20%정도 속도가 더 빠릅니다. 가장 빠른 속도를 보여줬던 것은 Redis였으며 그 다음은 MongoDB입니다. RDBMS에서 그나마 속도가 빠른 것은 MySQL과 Oracle이었습니다. 그 다음 보안적으로 봤을 때는 RDBMS가 NoSQL보다 뛰어납니다. 기본적으로 RDBMS는 데이터를 자체적으로 encrypt할 수 있으며 NoSQL은 비정형 데이터를 encrypt하기 힘들다는 한계 때문에 자체적으로 encrypt를 지원하지 않습니다. 또한, NoSQL은 인증과 인가에 대한 정상적인 구현체를 지원하지 않기도 합니다. 하지만 NoSQL은 느슨한 데이터 스키마와 느슨한 ACID를 채택하여 높은 확장성을 보여줍니다. 또한, 데이터베이스 Sharding의 관점에서도 NoSQL은 뛰어난 확장성을 보여줍니다. 또한, 유연성에서도 기존 RDBMS는 문자형, 숫자형, 날짜형, 논리형등만을 저장할 수 있었지만 NoSQL은 이미지, 영상, 음성등 비정형 데이터를 저장할 수 있다는 데이터 저장의 관점에서 뛰어난 유연성을 가지고 있습니다.

54. CAP정리에 대해서 설명해주세요.

CAP정리는 Brewer가 2000년에 발표한 Consistency, Availability, Partition Tolerance 이 셋을 모두 만족하는 분산시스템은 없다는 정리입니다. 하지만 20년도 더 된 정리이기 때문에 보완해야 하는 점이 많고 그에 따라 등장한 이론이 PACELC정리입니다.

55. 어느것을 보완해야 하나요?

우선 C와 A는 분산시스템의 특징이지만 P는 네트워크의 특징입니다. 따라서 겉보기에는 P를 선택하지 않고 CA를 선택할 수도 있게끔 보이지만 P를 선택하지 않았다는 것은 우리 시스템은 절대로 무슨일이 있어도 장애가 나지 않는다고 말하는 격이기 때문에 이는 이세상에 존재할 수 없는 시스템입니다. 또한, C와 A가 동일선상에 위치하지 않습니다. CP로 알려진 HBase의 경우 장애상황에서만 A를 포기하고 정상상황에서는 A를 만족합니다. 따라서 CAP정리는 장애상황일때만 C와 A가 동일선상에 있습니다. 따라서 CAP의 내포된 뜻은 장애상황일 때 C와 A를 선택할 수 있다 라는 것입니다.

56. 그럼 RDBMS가 CA라고 알려져있는데 그럼 이게 틀리다는 의미인가요?

처음 2000년에 Brewer가 RDBMS는 CA라고 발표했습니다. 하지만 RDBMS는 분산시스템이 아니기 때문에 분산시스템에 대한 특징을 나타낸 CAP정리로는 설명하는 것은 옳지 않습니다. 하지만 Brewer가 RBDMS를 CA라고 말한 이유는 MySQL을 예로 들었을 때 마스터 노드와 슬레이브 노드가 서로 Consistency와 Availability를 만족하게 작동하고 장애상황에서는 마스터노드가 죽어버려도 그 다음 슬레이브노드가 마스터노드를 대체하기 때문에 P를 선택하지 않은 것처럼 보이기 때문입니다.

57. ElasticSearch에 대해서 설명해주세요.

ElasticSearch는 자바로 만들어진 오픈소스 검색엔진입니다. 하지만 기본적으로 ELK라고 하는 데이터 집계 툴인 Logstash와 데이터 시각화 툴인 Kabana를 합쳐 동작합니다.

여태까지 공부한 네트워크 면접질문 / 답변

https://coding-review.tistory.com/345

위의 포스팅과 이어집니다.

58. TCP와 UDP의 차이

TCP는 연결 지향형 프로토콜이고 UDP는 데이터를 데이터그램으로 전송하는 프로토콜입니다. TCP는 연결형 서비스를 지원하고 가상회선 연결을 지원하기 때문에 전송이 양방향으로 이루어질 수 있는 전이중 방식과 각각의 연결점이 종단점을 가지는 점대점 방식을 사용할 수 있습니다. 또한, TCP는 흐름제어, 혼잡제어와 같은 방식덕분에 높은 신뢰성을 가질 수 있습니다. UDP는 TCP와 다르게 데이터 수신여부를 체크하지 않기 때문에 낮은 신뢰성을 갖고 있습니다. 하지만 TCP에 비해 속도가 빠르고 1대1 1대다 다대다 통신이 가능합니다. 정리하자면 높은 신뢰성을 요구하는 파일전송과 같은 시스템에서는 TCP를 사용하고 그보다 연결성이 중요한 스트리밍같은 서비스를 사용할 때는 UDP를 사용합니다.

59. 3way handshake에 대해서 설명해주세요.

TCP 3way handshake는 가상회션 연결이 수립되는 단계입니다. 서버는 클라이언트에게 데이터를 전송할 수 있는지, 클라이언트는 서버에게 데이터를 전송 받을 수 있는지 여부를 확인하기 위한 절차입니다. 서버와 클라이언트는 서로 SYN, ACK 패킷을 주고받으며 연결을 수립합니다. SYN 패킷은 난수로 되어있으며 ACK 패킷은 이 난수에 1을 붙인 패킷입니다. 따라서 순서는 SYN / SYN + ACK / ACK 이렇게 세단계로 분류됩니다.

60. HTTPS의 특징에 대해서 설명해주세요.

HTTPS는 제3자 인증, 공개키 암호화, 비밀키 암호화의 특징을 가지고 있습니다. 제3자 인증은 등록된 인증기관만 신뢰하겠다는 것이고 공개키 암호화는 비밀키를 암호화하는데 사용되고 비밀키 암호화는 통신에 사용될 데이터를 암호화하는데 사용됩니다.

61. SSL handshake에 대해서 설명해주세요. (요거는 그림을 그리라고 말할 수도 있습니다.)

먼저 사이트는 인증기관에 사이트의 정보와 사이트의 공개키를 전송합니다. 그럼 인증기관은 인증기관의 개인키로 사이트의 정보와 공개키를 암호화해서 인증서형태로 사이트에 전송합니다. 클라이언트는 사이트에 ClientHello를 보냅니다. 그럼 사이트는 클라이언트의 CipherSuite중 하나를 선택하고 SSL 프로토콜을 선택한 뒤 이 인증서를 클라이언트에게 보냅니다. 클라이언트는 웹 브라우저에 내장된 인증기관의 공개키로 인증서를 복호화하여 사이트의 정보와 공개키를 얻습니다. 이 때 인증서에 공개키가 포함되지 않았다면 Server Key Exchange가 발생합니다. 만약 포함되어있다면 이 단계는 생략됩니다. 클라이언트는 사이트의 공개키로 클라이언트의 대칭키를 암호화하여 사이트에 보냅니다. 사이트는 본인의 개인키로 이 대칭키를 복호화하여 이후 데이터 통신에 사용합니다.

62. 동기 프로그래밍, 비동기 프로그래밍에 대해서 설명해주세요.

동기 프로그래밍은 Blocking Architecture, 싱글스레드로 대표되고 하나의 스레드에 request와 response를 모두 담은 프로그래밍입니다. 비동기 프로그래밍은 Non Blocking Architecture, 멀티 스레드로 대표되고 request와 response를 각각 다른 스레드에 나눠 할당합니다. 동기 프로그래밍은 싱글 스레드이기 때문에 속도가 느리고 확장성이 낮은 반면 단순하고 오버헤드가 적으며 메모리를 적게 먹는다는 장점이 있습니다. 비동기 프로그래밍은 멀티스레드이기 때문에 속도가 빠르고 수율이 좋지만 오버헤드가 생기고 메모리를 많이 잡아먹으며 구현하기 까다롭다는 단점이 있습니다.

63. 공개키 암호화에 대해서 설명해주세요.

기존 대칭키 암호화에서는 공격자가 둘 중 하나의 키만 얻게되면 암호화된 데이터를 복호화할 수 있습니다. 때문에 이런 방식을 보완하기 위해 암호화와 복호화에 서로 다른 키를 사용하여 데이터의 안전성을 더 높은 암호화 방식이라고 설명할 수 있습니다. 공개키 암호화의 경우에는 어떤 키로 암호화를 하느냐에 따라서 사용되는 분야가 다릅니다. 개인키로 데이터를 암호화하는 경우는 개인인증에 신경쓴 것이고, 공개키로 데이터를 암호화하는 경우는 데이터의 보안을 위한 목적으로 사용된 것입니다.

64. 로드 밸런싱에 대해서 설명해주세요.

로드 밸런싱은 트래픽을 여러 서버에 각각 나눠주어 서버의 부하를 줄이는 시스템적인 해결방식이고 로드밸런싱에는 정적 로드밸런싱, 동적 로드밸런싱이 있습니다. 정적 로드밸런싱은 특정 순서에 맞춰서 트래픽을 서버에 분배하는 것이고 이에 해당하는 알고리즘은 Round Robin, Weighted Round Robin, IP Hash 이렇게 세가지입니다. 정적 로드밸런싱은 구현하기가 쉬운대신 비효율적이라는 단점이 있습니다. 동적 로드밸런싱은 서버의 상태에 따라 트래픽을 맞춰 분배하는 것이고 이에 해당하는 알고리즘은 Least Connection, Weighted Least Connection, Weighted Response Time, Resource Based이렇게 네가지가 있습니다. 동적 로드밸런싱은 효율적이긴 하지만 구현하는데 까다롭고 로드밸런서가 오버헤드가 생긴다는 단점이 있습니다.

65. 만약 본인이 로드밸런싱을 담당한다면 어떤 로드 밸런싱을 선택할 것인가요?

저였다면 Weighted Response Time 알고리즘을 선택했을 것 같습니다. 왜냐하면 정적 로드밸런싱은 어떤 서버에 문제가 생기더라도 순서에 맞춰서 트래픽을 분배하는 것이기 때문에 문제가 생긴 서버로 간 클라이언트는 오랜 시간을 기다려야 할 것입니다. 이는 부정적인 고객경험으로 이어지기 때문에 지양해야 한다고 생각했습니다. 그래서 선택을 한다면 동적 로드밸런싱을 선택할텐데 그 중에서 가장 고객경험이 긍정적인 응답시간을 바탕으로 가중치를 둔 Weighted Response Time을 선택해야겠다고 생각했습니다.

66. Stateful, Stateless에 대해서 설명해주세요.

Stateful은 하나의 세션에서 다음 트랜잭션에 필요한 정보를 서버가 모두 가지고 있는 경우를 말합니다. 이 경우 전송할 데이터는 적지만 서버가 가지고 있어야 할 데이터가 많다는 특징이 있습니다. Stateless는 하나의 세션에서 다음 트랜잭션에 필요한 정보를 서버가 가지고 있지 않은 경우를 말합니다. 이 경우 전송할 데이터가 많고 서버는 데이터를 적게 갖고 있어도 된다는 특징이 있습니다.

67. www.google.com을 (http://www.google.com을) 검색했을 때 무슨 일이 발생하나요? OSI 7계층, TCP 4계층과 엮어서 설명해주세요.

google.com을 검색하면 브라우저는 TCP 4계층인 애플리케이션 계층의 DNS 캐시 레코드에서 URL과 상응하는 IP 주소를 찾습니다. 만약 요청된 URL이 캐시에 없으면 ISP의 DNS서버는 google.com을 호스팅하고 있는 서버의 IP 주소를 찾기위한 DNS쿼리를 날립니다. 그리고 그 후 브라우저는 TCP 3계층인 전송계층의 TCP 연결을 시작합니다. 연결이 끝난 후 브라우저는 HTTP 요청을 보내고 서버는 요청과 돌아오는 응답을 핸들링합니다. 웹 서버는 HTTP응답을 보내고 브라우저는 HTML문서 결과를 보여줍니다.

68. HTTP Method에 대해서 설명해주세요.

주로 사용되는 HTTP Method에는 GET, POST, PUT, PATCH, DELETE가 있습니다. GET은 서버에 존재하는 데이터를 요청하는 것입니다. POST는 서버에 데이터를 생성하는 것을 요청하는 것입니다. PUT은 서버에 존재하는 데이터를 수정하거나 존재하지 않으면 생성합니다. PATCH는 서버에 존재하는 데이터를 일부 수정하는 특징을 가지고 있습니다. DELETE는 서버에 데이터를 제거할 것을 요청하는 메서드입니다.

69. RESTful에 대해서 설명해주세요.

RESTful은 Resource Oriented Architecture (ROA) 를 목적으로 리소스를 중점으로 HTTP Method를 이용하여 자원에 대한 처리를 표현합니다. 사람이 읽을 수 있는 API라는 것이 특징입니다. HTTP를 사용하기 때문에 HTTP의 특성을 그대로 반영합니다. 또한 별도의 인프라 구축이 필요없습니다. 단점으로는 명확한 표준이 존재하지 않는다는점, RESTful을 완전히 만족하는 API를 만들기 매우 까다롭다는 점, REST API는 멱등성을 보장하기 힘들기 때문에 분산환경에 적합하지 않다는 점, endpoint마다 API를 생성해야 하기 때문에 유연하지 않다는 점이 있습니다.

면접 준비 : 프로레스와 스레드

Q. 프로세스와 스레드의 차이에 대해서 설명해주세요.

A. 프로세스는 컴퓨터에서 연속적으로 실행되고 있는 컴퓨터 프로그램이고 스레드는 프로세스 내에서 실행되는 여러 흐름의 단위입니다. 각각의 프로세스는 고유한 주소와 메모리 공간이 부여되고 독립적입니다. 반면에 스레드는 부모 프로세스에서 자원을 공유하기 때문에 논리적으로 연결되어 있습니다. 즉, 스레드는 프로세스 안에 포함된 개념이며 스레드는 경량화된 프로세스라고 볼 수 있습니다.

Q. Context Switching에 대해서 설명해주세요.

A. Context Switching은 동작 중인 프로세스가 대기하면서 해당 프로세스의 상태를 보관하고, 대기하고 있던 다음 순서의 프로세스가 동작하면서 이전에 보관했던 프로세스의 상태를 복구하는 작업을 말합니다. 보통의 경우 Context Switching은 멀티프로세싱에서 발생하며 오버헤드가 발생하기 때문에 멀티스레딩으로 작업할 수 있는 것들은 멀티스레딩으로 작업하는 것이 프로그램 성능상 이점을 가지고 있습니다. 하지만 Context Switching은 장애가 발생했을 때 장애를 해결하고 원상복구 하기 위해 주로 사용됩니다. 또한, 높은 우선순위의 프로세스가 생겨서 기존 프로세스가 ready queue로 떨어진다면 현재 실행중인 프로세스가 정지하게 되고 우선순위 프로세스가 완료되고 다시 원래대로 돌아갈 때 사용됩니다.

면접 준비 : 멀티스레딩

Q. 멀티스레드 프로그래밍에 대해서 설명해주세요.

A. 멀티스레딩은 하나의 프로세스에서 여러개의 스레드를 만들어 자원의 생성과 관리의 중복을 최소화하는 것을 말합니다. 멀티스레딩의 장점은 멀티프로세싱에 비해 메모리 자원소모가 적고 힙 영역을 통해서 스레드간 통신이 가능해서 프로세스간 통신보다 간단하다는 점이 있고 단점은 힙 영역에 있는 자원을 사용할 때는 동기화를 해야하고 이때 락을 과하게 사용하면 성능이 저하될 수 있다는 점입니다.

면접 준비 : Thread-safe의 정의와 구현방법

Q. Thread-safe에 대한 정의와 구현방법에 대해서 설명해주세요.

A. 두개 이상의 스레드가 같은 객체에 접근해도 연산결과의 정합성이 보장될 수 있게끔 메모리 가시성이 확보된 상태를 말합니다. Thread-safe를 구현하기 위해서는 불변 객체를 사용하거나, java.util.concurrent 클래스를 사용하거나, ThreadLocal을 사용하거나, synchronized 키워드를 사용하거나, final 변수를 사용해야 합니다.

면접 준비 : 프로세스 동기화

Q. 프로세스 동기화에 대해 설명해주세요.

A. 프로세스 동기화는 다중 프로세스 환경에서 공유자원에 하나의 프로세스만 접근가능하도록 하는 것입니다. 프로세스 동기화를 하지 않으면 데이터의 일관성이 깨지기 때문에 연산결과가 잘못 반환될 가능성이 존재하므로 주의해야합니다. 각 프로세스에는 Critical Section이라고 부르는 임계구역이 존재합니다. 한 프로세스가 자신의 임계구역에서 작업하는 동안에는 다른 프로세스가 그들의 임계구역에 들어갈 수 없습니다. 임계구역에 접근한는 것을 제어하기 위해 세마포어, 뮤텍스와 같은 매커니즘을 사용합니다. 이런 임계구역 문제는 여러 프로세스나 스레드가 동기화 매커니즘 없이 자원에 접근하려는 상황인 race condition을 막을 수 있는 해결책이 됩니다. 임계구역 문제를 해결하기 위한 조건은 한 프로세스가 임계구역에서 동작중이면 다른 프로세스는 접근할 수 없는 Mutual Exclusion, 임계구역에서 작업중인 프로세스가 없다면 임계구역으로 진입하려는 프로세스를 적절히 선택해서 진입할 수 있도록 하는 Progress, 한 프로세스가 임계영역으로 진입을 요청한 후 다른 프로세스는 진입이 유한한 횟수로 제한되어야 한다는 Bounded Waiting이 있습니다.

면접 준비 : 데이터베이스 인덱스

Q. 데이터베이스 인덱스에 대해서 설명해주세요.

A. 기존 DBMS는 검색을 하기 위해서는 풀 스캔을 통해 검색에 대한 결과를 반환했습니다. 이는 효율적인 방식이 아니기 때문에 고대 개발자들은 데이터를 정렬한 후 이진 탐색 트리를 활용하여 데이터를 쉽게 반환할 수 있었습니다. 이렇게 정렬된 데이터들을 인덱스라고 부릅니다. 인덱스의 장점은 검색성능의 압도적인 향상입니다. 이는 데이터 수가 많으면 많을 수록 좋은 성능을 보여줍니다. 단점으로는 삽입, 수정, 삭제 연산을 수행할 때 인덱스도 데이터 일관성을 위해 삽입, 수정, 삭제의 작업을 해줘야하고 이때 성능상 불이익을 받을 수 있습니다. 또한, 인덱스를 저장하기위해 데이터베이스의 10퍼센트 정도 되는 데이터 공간이 필요한 것이 단점이 될 수 있습니다.

면접 준비 : 교착상태와 기아상태

Q. 교착상태와 기아상태에 대해서 설명하고 해결방안에 대해서 설명해주세요.

A. 교착상태는 서로 다른 프로세스가 서로 점유하고 있는 자원의 반납을 대기하고 있는 상태를 말합니다. 교착상태가 발생하기 위해서는 한번에 한 프로세스만 자원을 사용할 수 있는 상호 배제, 할당된 자원을 가진 상태에서 다른 자원을 기다리는 점유 대기, 다른 프로세스가 자원의 사용을 끝날 때 까지 자원을 뺏을 수 없는 비선점, 각 프로세스가 순환적으로 다음 프로세스를 요구하는 자원을 가지고 있는 상태인 순환 대기가 모두 만족하는 경우 발생합니다. 이를 해결하기 위해서는 앞선 네가지 조건 중 하나라도 만족하지 않도록 설계하거나, 알고리즘을 통해 교착상태가 발생하지 않도록 하는 방법을 사용하거나, 교착상태를 감지하고 회복하거나, 시스템이 교착상태가 극히 드물게 발생한다면 무시하는 방법도 있습니다. 기아 상태는 여러 프로세스가 부족한 자원을 점유하기 위해 경쟁할 때, 특정 프로세스가 영원히 자원 할당이 되지 않는 경우를 말합니다. 기아 상태를 해결하기 위해서는 우선순위를 변경하거나, 오래 기다린 프로세스의 우선순위를 높여주거나, PriorityQueue와 같은 스케쥴링 알고리즘을 사용하는 방법이 있습니다.

면접 준비 : 뮤텍스와 세마포어

Q. 뮤텍스와 세마포어의 차이점에 대해서 설명해주세요.

A. 뮤텍스는 Locking 매커니즘이고 세마포어는 Signaling 매커니즘입니다. 이 둘은 식당에 있는 화장실 예시로 설명할 수 있습니다. 이 화장실 예제에서 사람은 프로세스 혹은 스레드로, 화장실은 자원으로 묘사됩니다. 뮤텍스는 식당에 화장실이 한개이고 열쇠도 한개인 경우입니다. 화장실에 가고싶으면 열쇠를 획득해야하고 만약 이미 화장실에 사람이 있다면 열쇠를 획득할 때 까지 기다려야합니다. 세마포어는 화장실이 여러개이고 화장실 밖에 전광판이 있는 경우입니다. 만약 화장실이 세개라면 전광판에는 3이라는 숫자가 적혀있고 누군가 화장실에 들어가면 전광판의 숫자가 2로 줄어듭니다. 화장실에 들어간 사람이 화장실을 나온 경우 숫자는 다시 3으로 바뀌게 됩니다. 모든 화장실에 사람이 들어가있어서 전광판의 숫자가 0으로 바뀌게 되면 화장실에 가기 위해서는 화장실에 있던 사람이 나올 때 까지 기다려야합니다.

여태까지 공부한 운영체제 질문 / 답변

https://coding-review.tistory.com/346

앞선 포스팅과 이어집니다.

70. 프로스세와 스레드에 대해서 설명해주세요.

프로세스는 컴퓨터가 연속적으로 실행하는 프로그램이고 스레드는 프로세스 안에서 실행되는 흐름의 단위입니다. 프로세스는 고유한 주소와 데이터 공간을 할당받고 독립적입니다. 하지만 스레드는 프로세스 안에서 자원을 공유하기 때문에 논리적으로 연결되어있습니다. 때문에 스레드는 프로세스에 포함된 개념이고 경량화된 프로세스라고 할 수 있습니다.

71. 멀티스레딩에 대해서 설명해주세요.

멀티스레딩은 하나의 프로세스에서 여러개의 스레드를 이용해 자원의 생성 혹은 관리의 중복을 최소화하기위한 프로그래밍 방식입니다. 멀티스레딩은 멀티프로세싱에 비해 소모되는 자원이 적고 힙 영역에서 스레드간 통신을 할 수 있어서 프로세스간 통신보다 저렴하고 간단하다는 장점이 있습니다. 하지만 힙 영역에서 공유된 자원에 접근할 때는 동기화 작업을 반드시 거쳐야하고 이때 락을 무분별하게 사용할 경우 성능상 악영향을 미칠 수 있습니다.

72. Thread-safe 하다는 것의 정의와 구현 방법에 대해서 설명해주세요.

스레드 세이프 하다는 것은 여러개의 스레드가 공유자원에 접근할 때 연산결과의 정합성을 보장하기위해 메모리 가시성이 확보된 상태를 말합니다. 스레드 세이프를 구현하기 위해서는 불변객체를 사용하거나, java.util.concurrent 클래스를 사용하거나, ThreadLocal을 사용하거나, synchronized 키워드를 사용하거나, final 변수를 사용하여서 구현할 수 있습니다.

73. 프로세스 동기화에 대해서 설명해주세요.

프로세스 동기화는 하나의 프로세스가 하나의 자원에 접근할 수 있도록 하는 것입니다. 프로세스에는 Critical Seciton이라고 부르는 임계영역이 하나씩 존재합니다. 하나의 프로세스가 임계구역에서 작업하고 있는 동안에는 다른 프로세스가 그들의 임계구역으로 접근할 수 없습니다. 임계구역으로 접근을 제어하기 위해서 뮤텍스와 세마포어와 같은 매커니즘을 사용합니다. 이런 임계구역 문제는 여러 프로세스 혹은 스레드가 동기화 매커니즘을 거치지 않고 공유자원에 접근하려는 race condition을 해결하기위한 해결책으로서 사용될 수 있습니다. 임계 구역 문제를 해결하기 위해서는 하나의 프로세스가 임계구역에서 작업을 하고있다면 다른 프로세스는 들어오지 못하게 막는 mutual exclusion, 만약 임계구역에 프로세스가 없다면 임계구역으로 진입하려는 프로세스를 적절히 선택해 진입을 하도록하는 progress, 어떤 프로세스가 임계구역으로 접근을 요청한 경우 다음 프로세스가 임계구역으로의 접근을 요청하는 횟수를 유한하게 제한하는 bounded wating이 있습니다.

74. 교착상태와 기아상태에 대해서 설명해주세요.

교착상태는 서로 다른 프로세스가 서로 점유하고 있는 자원의 반납을 대기하고있는 상태를 말합니다. 교착상태가 발생하기 위해서는 하나의 프로세스만 하나의 자원에 접근할 수 있는 상호배제, 이미 자원이 할당된 상태에서 다른 자원을 기다리고 있는 점유 대기, 다른 프로세스의 자원을 자원이 해제되기 전까지 뺏을 수 없는 비선점, 서로 다른 프로세스가 순환적으로 다른 프로세스가 요구하는 자원을 기다리고 있는 상태인 순환 대기가 있습니다. 교착상태를 해결하기 위해서는 앞서 설명한 네가지 조건 중 하나라도 만족하지 않도록 예방하는 방법과, 알고리즘을 이용해 회피하는 방법, 알고리즘으로 감지한 다음 회복하는 방법, 교착상태가 극히 드물게 일어나는 경우 무시하는 방법이 있습니다. 기아상태는 서로 다른 프로세스가 부족한 자원을 가지고 경쟁할 때 특정 프로세스가 자원을 영원히 할당받지 못하는 상태를 말합니다. 기아 상태를 해결하기 위해서는 프로세스의 우선순위를 바꿔주거나, 오래기다린 프로세스의 우선순위를 높여주거나, PriorityQueue와 같은 스케쥴링 알고리즘을 사용하는 방법이 있습니다.

75. 뮤텍스와 세마포어의 차이에 대해서 설명해주세요.

뮤텍스는 locking mechanism이고 세마포어는 signaling mechanism입니다. 뮤텍스와 세마포어는 식당에 있는 화장실로 설명할 수 있습니다. 뮤텍스의 경우 식당에 화장실이 한개만 존재하고 화장실에 가기 위해서는 열쇠가 필요한 경우입니다. 만약 어떤 사람이 화장실에 가고싶은 경우 키를 가지고 화장실에 들어갈 수 있으며 이후에 화장실에 들어가고 싶은 사람은 앞서 들어간 사람이 나올 때까지 기다려야합니다. 화장실에 가고싶은 사람이 많더라도 화장실에 가기위해 줄을 서야하고 먼저 선 사람이 화장실에 빨리 갈 수 있습니다. 세마포어는 화장실이 여러개이고 화장실 밖에 전광판이 있는 모습을 상상할 수 있습니다. 화장실이 만약 세개라면 전광판에는 3이라는 숫자가 적히게 됩니다. 만약 화장실에 한명이 들어가는 경우 전광판의 숫자가 2로 줄어들고 한명 더 들어가면 1로 줄어듭니다. 이때 화장실에서 한명이 나오게 되면 다시 2로 올라가게 됩니다. 화장실에 모두 사람이 들어가있어서 전광판에 숫자가 0이 되는 경우 화장실에 가기 위해서는 화장실 밖에서 기다려야합니다.

76. 동기, 비동기와 블로킹, 논블로킹의 차이

동기, 비동기 프로그래밍은 클라이언트와 서버가 서로 통신할 때 요청과 응답이 하나의 스레드에 묶여 요청을 보내면 그에 상응하는 응답이 올 때까지 기다려야한다는 특징입니다. 그리고 블로킹, 논블로킹은 여러개의 프로그램이나 프로세스 혹은 스레드가 어떤 자원에 접근할 때 자원의 접근을 blocking하냐 blocking하지 않느냐를 정하는 아키텍처입니다.

면접 준비 : 동기, 비동기와 블로킹, 논블로킹의 차이

Q. 비동기 프로그래밍과 Non-blocking (Architecture) 의 차이에 대해서 설명해주세요.

A. 동기 / 비동기 프로그래밍은 클라이언트와 서버간에 커뮤니케이션을 할 때 한번 request를 보내면 그에 상응하는 response가 올때까지 기다려야 한다는 것입니다. 반면에 블로킹 / 논블로킹 아키텍처는 여러개의 프로그램 혹은 스레드가 공유된 자원에 접근할 때 같은 자원에 하나의 스레드만 접근할 수 있도록 접근을 막는 것입니다.

면접 준비 : 낙관적 락, 비관적 락

Q. 낙관적 락과 비관적 락에 대해서 설명해주세요.

A. 둘 다 데이터에 락을 걸어서 동시성 문제를 해결하는 방법중에 하나입니다. 낙관적 락은 충돌이 빈번하지 않은 경우에 효과적입니다. 낙관적 락은 커밋과정에서 충돌을 감지하여 Exception을 던지는데 이 때 Exception은 OptimisticLockingFailureException입니다. 이 예외를 처리함으로써 동시성 문제를 해결할 수 있습니다. 비관적 락은 충돌이 빈번한 경우에 더 효과적입니다. 비관적 락은 반드시 동시성 문제가 발생한다는 가정을 하고 락을 걸기 때문에 데이터의 정합성이 보장되지만 낙관적 락에 비해 성능이 조금 부족할 수 있습니다.

면접 준비 : 스프링 시큐리티

Q. 스프링 시큐리티의 동작 방식에 대해서 설명해주세요.

A. 먼저 Proxy Chain Filter가 필터를 만듭니다. 그리고 프록시로 생성된 필터가 request를 인터셉트합니다. 인터셉트한 request로 인증을 진행하고 인증이 끝나면 Security Context를 업데이트합니다. 업데이트함과 동시에 인가가 진행되고 Exception을 핸들링 한 다음 사용자의 요청을 핸들링합니다.

Q. 스프링 시큐리티의 인증 방식에 대해서 설명해주세요.

A. 먼저 request와 걸맞는 필터가 작동합니다. form 형식의 request는 UsernamePassowrdAuthenticationFilter가 작동하고 OAuth 2.0의 형식은 OAuthLoginAuthenticationFilter가 작동합니다. 그리고 UsernamePasswordAuthenticationToken을 생성해서 이 토큰을 처리할 수 있는 AuthenticationManager (실제 구현체는 ProviderManager) 에게 토큰을 전달합니다. AuthenticationManager는 AuthenticationProvider에게 토큰을 넘기고 적절한 Provider를 선택해서 사용합니다. 이 때 Authentication 객체가 생성되고 이 Authentication 객체를 Security Context에 전달합니다. 마지막으로 Security Context Holder는 ThreadLocal을 사용해서 Thread safe하게 Authentication 객체를 관리합니다.

면접 준비 : (프로젝트 관련) Jenkins 사용 이유

Q. 왜 Jenkins를 사용했나요?

A. 비교 대상은 Github Action이었고 많은 고민이 있었습니다. 결과적으로 Jenkins를 사용하게 된 이유는 우선 넓은 커뮤니티로 인해 풍부한 레퍼런스가 그 이유였습니다. 처음 CI 자동화를 하는 것이기 때문에 트러블 슈팅이라던가 레퍼런스를 참고할 곳이 많은 곳이 큰 메리트가 있었습니다. 또한 Github Action이 가지고 있는 문제 중 하나인 잡 스케쥴링에 종종 이슈가 있다는 것을 알고 CI 자동화 툴의 치명적인 단점이라고 생각했습니다.

면접 준비 : (프로젝트 관련) JWT

Q. 왜 JWT를 사용했는가?

A. 우선 제 프로젝트의 종점이 MSA였다는 것을 먼저 피력하고 싶습니다. 때문에 격리된 애플리케이션 사이의 통신에서 인증을 위해 stateless한 인증 방식이 필요하다고 생각되었습니다. 기존 인증 방식은 스프링 시큐리티에 온전히 의존해 인증방식이 stateful하기 때문에 MSA와 맞지 않다고 생각했습니다.

면접 준비 : (프로젝트 관련) Redis

Q. 왜 Redis를 사용하셨나요?

A. 우선 대안책으로 EHcache와 Memcached가 있었습니다. 같은 글로벌 캐시인 Memcached는 Redis가 제공하는 다양한 자료구조와 장애가 발생해도 데이터를 유지할 수 있는 지속성, 수평적 확장이 용이하다는 특징 때문에 Redis를 선택했습니다. EHcache는 스프링과 통합이 쉽고 속도가 빠르다는 장점이 있어서 많은 고민이 있었지만 멀티 스레드 상황과 분산 시스템 상황에서 캐시 스토리지 동기화에 신경을 써야 해서 앞으로 분산 시스템으로 넘어갈 때 프로젝트의 복잡도를 올리고 싶지 않았습니다.

면접 준비 : (프로젝트 관련) 비관적 락

Q. 왜 비관적 락을 사용했나요?

A. 낙관적 락의 사용과 비관적 락의 사용 중 많은 고민이 있었습니다. 성능상 낙관적 락을 선택하고 싶었지만 이용자 수가 1000만명이나 된다는 컨셉 때문에 충돌이 빈번할 것으로 예측했습니다. 또한, 먼저 결제를 진행한 사용자에게 결제가 일어나고 그 뒤의 사용자는 결제가 일어나면 안된다는 생각 때문에 비관적 락을 선택했습니다.

면접 준비 : JPA 전파단계

Q. 트랜잭션 전파단계에 대해서 설명해주세요

A. 스프링은 여러개의 트랜잭션 연산을 하나로 통합하기위해 물리 트랜잭션과 논리 트랜잭션이라는 개념을 도입했고 이로인해 트랜잭션 연산들이 전파되면서 원자성을 보장할 수 있었습니다. JPA에서는 다양한 트랜잭션 전파 단계를 지원하는데 디폴트값인 REQUIRED부터 무조건 새로운 트랜잭션을 만드는 REQUIRES_NEW 그외에도 SUPPORTS, NOT_SUPPORTED, NESTED, NEVER, MANDATORY 등이 있습니다.

면접 준비 (흐름 제어와 혼잡 제어)

Q. TCP의 흐름 제어와 혼잡 제어에 대해서 설명해주세요.

A. 흐름 제어는 송신자와 수신자 사이에서 벌어지는 현상이고 송신자가 보내는 패킷의 크기가 수신자의 수용 용량보다 클 경우 수신자의 버퍼를 동적으로 늘리기 위해 잠시 시간을 벌어주는 용도로 사용됩니다. 이때 sliding window 방식으로 수신자가 ACK 패킷을 전달할 때마다 일정량의 데이터를 보내주는 것이 특징입니다. 혼잡 제어는 송신자와 수신자와의 통신뿐만 아니라 더 넓은 범위에서 일어나는 현상으로서 네트워크 트래픽이 너무 많아지는 경우 패킷이 로스되는 것을 막기 위해 트래픽 레벨의 모니터링을 통해 혼잡을 막기위해 window 크기와 전송 속도를 제어하는 역할을 합니다.

Q. window 크기가 뭔가요?

A. 단위 시간당 전송되는 패킷의 수입니다.

Q. 혼잡 제어의 알고리즘에 대해서 설명해주세요.

A. 혼잡 제어의 알고리즘에는 slow start, congestion avoidance, fast retransmit, fast recovery가 있습니다. slow start는 패킷이 도착할 때마다 window 크기를 2배로 늘리고 전송속도를 늘리는 알고리즘입니다. 이후 일정 수준에서 혼잡을 회피하기 위한 알고리즘은 congestion avoidance가 window 크기를 선형적으로 증가시킵니다. ACK 패킷의 순서가 꼬이는 경우 재전송을 하게 되는데 세번의 재전송이 일어나면 window 크기를 절반으로 줄이는 fast retransmit이 작동합니다. 마지막으로 window 크기를 다시 선형적으로 늘리면서 패킷을 받는 알고리즘은 fast recovery가 작동하면서 마무리됩니다.

데이터베이스 격리수준 (isolation level)

우선 데이터베이스 격리수준을 들어가기 전에 우리는 트랜잭션에 대해서 간단한 이해가 필요합니다.

트랜잭션

트랜잭션은 데이터의 정합성을 보장하기 위한 기능입니다. 트랜잭션은 꼭 여러개의 변경 작업을 수행하는 쿼리가 조합됐을때만 의미있는 개념은 아닙니다. 트랜잭션은 논리적인 작업 셋 자체가 100% 적용되거나 또는 아무것도 적용되지 않아야 함을 보장해 주는 것입니다.

이러한 트랜잭션의 특징을 잘 나타내는 4가지 키워드가 있습니다.

바로 ACID 라고 하는 원자성 (Atomicity), 일관성 (Consistency), 격리성 (isolation), 지속성 (Durability)를 보장해야 합니다. ACID에 대한 자세한 설명은 아래와 같습니다.

• 원자성 (Atomicity) : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 혹은 모두 실패해야 합니다.

• 일관성 (Consistency) : 모든 트랜잭션은 일관성있는 데이터베이스 상태를 유지해야 합니다. 예를 들면 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 합니다.

• 격리성 (Isolation) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리해야 합니다. 예를 들면 동시에 같은 데이터를 수정하지 못하도록 해야 합니다. 격리성은 동시성과 관련된 성능 이슈로 인해 격리 수준을 선택할 수 있습니다.

• 지속성 (Durability) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 합니다. 중간에 시스템에 문제가 발생하더라도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 합니다.

트랜잭션은 원자성, 일관성, 지속성을 보장하는데 문제는 격리성입니다. 트랜잭션간에 격리성을 완벽히 보장하려면 동시에 처리되는 트랜잭션을 거의 차례대로 실행을 해야 합니다. 하지만 이렇게 처리를 하면 처리 성능이 매우 나빠지게 됩니다. 이러한 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의하고 있습니다.

격리 수준

트랜잭션의 격리 수준이란 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것입니다.

격리 수준은 다음과 같이 4가지로 정의할 수 있습니다.

• READ UNCOMMITTED (커밋되지 않은 읽기)

• READ COMMITTED (커밋된 읽기)

• REPEATABLE READ (반복 가능한 읽기)

• SERIALIZABLE (직렬화 가능)

순서대로 READ UNCOMMITTED의 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높습니다. 격리 수준이 높아질수록 데이터베이스 서버의 처리 성능이 많이 떨어질 것으로 생각하는 사용자가 많은데, 사실 그렇지는 않습니다.

SERIALIZABLE 격리 수준이 아니라면 크게 성능의 개선이나 저하는 발생하지 않습니다.

"DIRTY READ"라고도 하는 "READ UNCOMMITTED" 는 일반적인 데이터베이스에서는 거의 사용하지 않고, SERIALIZABLE 역시 동시성이 중요한 데이터베이스에서는 거의 사용되지 않습니다.

격리 수준이 낮으면 낮을수록 더 많은 문제가 발생합니다.

이제 격리 수준에 대해 하나씩 알아보고 격리 수준에 따라 발생하는 문제점에 대해서 알아보겠습니다.

READ UNCOMMITTED

READ UNCOMMITTED 격리 수준에서는 위의 그림처럼 각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK여부에 상관 없이 다른 트랜잭션에서 보여지게 됩니다.

위 그림은 다른 트랜잭션이 사용자 B가 실행하는 select 쿼리의 결과에 어떠한 영향을 미치는지 보여주는 예시입니다.

위 그림에서 사용자 A는 emp_no = 50000, first_name = "JuBal" 인 새로운 사원을 INSERT 하고 있습니다. 그리고 사용자 B는 변경된 내용을 커밋하기도 전에 emp_no = 50000 인 사원을 검색하고 있습니다. 하지만 사용자 B는 사용자 A가 INSERT한 사원의 정보를 커밋되지 않은 상태에서도 조회를 할 수 있습니다.

여기서 문제는 만약 사용자 A가 작업 도중 문제가 발생하여 INSERT된 내용을 롤백해버린다 하더라도 사용자 B는 JuBal이 정상적인 사원이라고 판단하고 계속해서 처리하게 되는 것입니다.

이처럼 어떠한 트랜잭션에서 처리한 작업이 완료되지 않았음에도 불구하고 다른 트랜잭션에서 볼 수 있게 되는 현상을 더티 리드 (Dirty Read)라 하고, 더티 리드가 허용되는 격리 수준이 READ UNCOMMITTED 입니다. 더티 리드 현상은 데이터가 나타났다가 사라졌다 하는 현상을 초래할 수 있으므로 개발자와 사용자를 상당히 혼란스럽게 만들 것입니다.

또한 더티 리드를 유발하는 READ UNCOMMITTED 격리 수준은 RDBMS 표준에서는 트랜잭션의 격리 수준으로 인정하지 않을 정도로 정합성에 문제가 많은 격리 수준입니다. 따라서 RDBMS를 사용한다면 최소 READ COMMITTED 이상의 격리 수준을 사용할 것을 권장합니다.

READ COMMITTED

READ COMMITTED 격리 수준은 오라클 DBMS에서 기본적으로 사용되고 있으며 올나인 서비스에서 가장 많이 선택되는 격리 수준입니다. 이 레벨에서는 위 READ UNCOMMITTED 수준에서 발생할 수 있는 더티 리드와 같은 현상은 발생하지 않습니다.

어떠한 트랜잭션에서 데이터를 변경하더라도 COMMIT이 완료된 데이터만 다른 트랜잭션에서 조회할 수 있기 때문입니다.

위 그림을 통해 READ COMMITTED 격리 수준에서 사용자 A가 변경한 내용이 사용자 B에게 어떻게 조회되는 지 확인할 수 있습니다.

사용자 A는 emp_no = 50000인 사원의 first_name을 "JuBal"에서 "Toto"로 수정했는데, 이 때 새로운 값인 "Toto"는 employee 테이블에 즉시 기록되고 이전 값인 "JuBal"은 Undo 영역으로 백업이 됩니다.

만약 사용자 A가 이러한 변경 내역을 커밋하기전에 사용자 B가 emp_no = 50000인 사원을 조회하면 결과값은 "Toto"가 아닌 이전 값인 "JuBal"이 조회가 됩니다. 여기서 사용자 B의 SELECT 쿼리 결과는 employees 테이블이 아닌 Undo 영역의 백업된 레코드에서 가져온 결과입니다.

READ COMMITTED 격리 수준에서는 어떤 트랜잭션에서 변경한 내용이 커밋되기 전까지는 다른 트랜잭션에서 그러한 변경 내역을 조회할 수 없기 때문입니다.

최종적으로 사용자 A가 변경된 내용을 커밋하면 그때부터는 다른 트랜잭션에서도 백업된 Undo 영역의 데이터인 "JuBal"이 아닌 새롭게 변겨오딘 "Toto"라는 값을 참조할 수 있습니다.

READ COMMITTED 격리 수준에서 발생할 수 있는 문제점 (NON-REPEATABLE READ)

READ COMMITTED 격리 수준에서도 "NON-REPEATABLE READ" 라는 부정합 문제가 존재합니다. 아래 그림을 살펴보도록 하겠습니다.

위 그림에서 사용자 B가 BEGIN 명령으로 트랜잭션을 시작하고 first_name = "Toto"인 사원을 조회하면 일치하는 데이터 값이 존재하지 않습니다.

하지만 이후에 사용자 A가 emp_no = 50000인 사원의 이름을 "Toto"로 수정하고 커밋한 후 사용자 B는 동일한 쿼리로 조회하면 이번에는 결과가 1건이 조회가 됩니다.

이는 별다른 문제는 없어보이나 사용자 B가 하나의 트랜잭션내에서 동일한 SELECT 쿼리를 실행했을 때 항상 같은 결과를 보장해야 한다는 "REPEATABLE READ" 정합정에 어긋나게 됩니다.

이러한 부정합 현상은 일반적인 웹 어플리케이션에서는 크게 문제가 되지 않지만, 하나의 트랜잭션에서 동일한 데이터를 여러 번 읽고 변경하는 작업이 금전적인 처리와 연결되면 문제가 될 수 있습니다.

예를 들어, 다른 트랜잭션에서 입금과 출금 처리가 계속 진행되고 있을 때 다른 트랜잭션에서 오늘 입금된 금액의 총합을 조회한다고 가정해보겠습니다. "REPEATABLE READ" 가 보장되지 않기 때문에 총합을 계산하는 SELECT 쿼리를 실행할 때마다 다른 결과를 가져올 것입니다.

중요한 것은 사용중인 트랜잭션의 격리 수준에 의해 실행되는 SQL 문장이 어떠한 결과를 가져오게 되는지 정확히 예측할 수 있어야 합니다. 그리고 이를 위해서는 각 트랜잭션의 격리 수준이 어떻게 작동하는지 알고 있어야 합니다.

REPEATABLE READ

REPEATABLE READ는 MySQL의 InnoDB 스토리지 엔진에서 기본적으로 사용되는 격리 수준입니다. 이 격리 수준에서는 READ COMMITTED 격리 수준에서 발생하는 "NON-REPEATABLE READ" 부정합이 발생하지 않습니다.

InnoDB 스토리지 엔진은 트랜잭션이 ROLLBACK 될 가능성에 대비해 변경되기 전 레코드를 언두(Undo) 영역에 백업해두고 실제 레코드 값을 변경합니다. 이러한 변경 방식을 MVCC (Multi Version Concurrency Control)이라고 합니다.

REPEATABLE READ는 이 MVCC를 위해 언두 영역에 백업된 이전 데이터를 통해 동일한 트랜잭션 내에서는 동일한 결과를 보여줄 수 있도록 보장합니다.

READ COMMITTED 격리 수준 또한 MVCC를 이용해 COMMIT되기 전의 데이터를 보여주나 REPEATABLE READ와 READ COMMITTED의 차이는 언두 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 이전의 버전까지 찾아 들어가야 하는지에 있습니다.

모든 InnoDB의 트랜잭션은 고유한 트랜잭션 번호 (순차적으로 증가하는 값)을 가지며, 언두 영역에 백업된 모든 레코드에는 변경을 발생시킨 트랜잭션의 번호가 포함되어 있습니다. 언두 영역의 백업된 데이터는 InnoDB 스토리지 엔진이 불필요하다고 판단하는 시점에 주기적으로 삭제합니다.

REPEATABLE READ 격리 수준에서는 MVCC를 보장하기 위해 실행중인 트랜잭션 가운데 가장 오래된 트랜잭션 번호보다 트랜잭션 번호가 앞선 언두 영역의 데이터는 삭제할 수 없습니다.

위 그림은 REPEATABLE READ 격리 수준이 작동하는 방식을 나타내고 있습니다.

먼저 employees 테이블은 번호가 6인 트랜잭션에 의해 INSERT가 되었다고 가정하겠습니다.

사용자 A가 emp_no = 50000인 사원의 이름을 변경하는 과정에서 사용자 B가 emp_no = 50000인 사원을 SELECT 할 때 어떠한 과정을 거쳐서 처리되는지 보여줍니다.

사용자 A의 트랜잭션 번호는 12이고, 사용자 B의 트랜잭션 번호는 10입니다. 이때 사용자 A는 사원의 이름을 "Toto"로 변경하고 커밋을 수행합니다. 그런데 사용자 B는 emp_no = 50000인 사원을 A 트랜잭션이 변경을 실행하기 전과 실행한 후 각각 조회를 했지만, 데이터는 항상 동일한 "JuBal"이라는 값을 SELECT 합니다.

사용자 B가 BEGIN 명령으로 트랜잭션을 시작하면서 10번이라는 트랜잭션 번호를 부여받았는데, 그때부터 사용자 B의 10번 트랜잭션 안에서 실행되는 모든 SELECT 쿼리는 자신의 트랜잭션인 10번보다 작은 트랜잭션 번호에서 변경한 것만 보게 됩니다.

위 그림에서 언두 영역에 백업된 데이터가 하나만 있는 것으로 표현했지만, 실제로는 하나의 레코드에 대해 백업이 하나 이상 얼마든지 존재할 수 있습니다. 만약 한 사용자가 BEGIN 명령으로 트랜잭션을 시작한 후 장시간동안 트랜잭션을 종료하지 않으면 언두 영역이 백업된 데이터로 무한정 커질 수 있고, 이렇게 언두 영역에 백업된 레코드가 많아질수록 MySQL서버의 처리 성능이 떨어질 수 있습니다.

REPEATABLE READ 격리 수준에서 발생할 수 있는 문제점 (PHANTOM READ)

REPEATABLE 격리 수준에서도 다음과 같은 부정합이 발생할 수 있습니다.

아래의 그림은 사용자 A가 employees 테이블에 INSERT 를 실행하는 도중에 사용자 B가 SELECT ... FOR UPDATE 쿼리로 employess 테이블을 조회했을 때의 결과입니다.

위 그림에서 사용자 B는 트랜잭션 시작 후 SELECT 쿼리를 수행하고 있습니다. 따라서 이전 REPEATABLE READ 격리 수준에서 설명처럼 동일한 트랜잭션 내에서는 결과가 동일해야 합니다.

하지만 위 그림에서 사용자 B가 실행하는 두번의 SELECT ... FOR UPDATE 쿼리 결과는 서로 다릅니다. 이렇게 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다 안보였다 하는 현상을 PHANTOM READ 라고 합니다.

SELECT ... FOR UPDATE 쿼리는 SELECT하는 레코드에 쓰기 잠금을 걸어야 하는데, 언두 레코드에는 잠금을 걸 수가 없습니다.

따라서 위와 같은 쿼리는 언두 영역의 변경 전 데이터를 가져오는 것이 아니라 현재 레코드의 값을 가져오게 됩니다.

※ 하지만 InnoDB에서는 독특한 특성 때문에 REPEATABLE READ 격리 수준에서도 PHANTOM READ가 발생하지 않는다고 합니다. 따라서 MySQL의 InnoDB 스토리지 엔진에서는 주로 REPEATABLE READ격리 수준을 사용하는게 아닐까 하는 생각입니다.

여기서 말하는 독특한 특성이란 MVCC 다중 버전 제어입니다. 즉, MySQL-InnoDB는 MVCC 다중 버전 제어에 의해 PHANTOM READ를 해결하고 있습니다.

SERIALIZABLE

가장 단순한 격리 수준이면서 가장 엄격한 격리 수준입니다. 또한 동시 처리 성능도 다른 트랜잭션 격리 수준보다 현저히 떨어집니다.

트랜잭션의 격리 수준이 SERIALIZABLE로 설정되면 읽기 작업도 공유 잠금을 획득해야 하며, 동시에 다른 트랜잭션은 그러한 레코드를 변경할 수 없습니다. 즉, 한 트랜잭션에서 읽고 쓰는 레코드를 다른 트랜잭션에서는 절대 접근할 수 없습니다.

SERIALIZABLE 격리 수준에서는 일반적인 DBMS에서 발생하는 "PHANTOM READ" 문제가 발생하지 않지만, 위에서 설명드렸듯이 InnoDB 스토리지 엔진에서는 REPEATABLE READ격리 수준에서도 "PHANTOM READ" 가 발생하지 않기 때문에 굳이 SERIALIZABLE 격리 수준을 사용할 필요성은 없어 보입니다.

Reference

https://zzang9ha.tistory.com/381

책) 자바 ORM 표준 JPA 프로그래밍

데이터베이스 인덱스

DB를 사용하면서 데이터의 양에 다라 실행 결과의 속도가 차이가 납니다. JOIN이나 서브 쿼리 사용 시 곱 연산이 일어나 데이터 양이 증가하기 때문에 WHERE 조건에 필요한 데이터만 추출 후 사용하는 것이 좋다고 합니다.

쿼리의 성능을 높이는데 중요한 것은 인덱스를 적재적소에 사용하는 것입니다. 그렇다면 인덱스란 무엇인지 구조는 어떻게 되는지 사용시 장단점은 무엇인지 등에 대해 지금부터 포스팅 해보도록 하겠습니다.

인덱스

인덱스란 추가적인 쓰기 작업과 저장 공간을 활용하여 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조입니다.

여기서 중요한건 '검색 속도'를 향상시키기 위한 자료구조라는 것입니다. 그럼 다른 연산은 향상이 안되는건가? 꼭 그렇지만은 않습니다. 향상은 되지만 추가적인 연산이 필요하다는 점 외에는 다른 연산도 충분히 활용할 수 있습니다.

하지만 추가적인 연산이 들어가기 때문에 오버헤드가 발생할 수 있어서 웬만하면 INSERT, UPDATE, DELETE와 같은 DML을 사용할 때는 가급적 지양하는 것이 좋습니다. 인덱스는 SELECT 쿼리를 날릴 때의 효율을 극대화 한것이기 때문이죠

인덱스 사용 시 추가연산

• INSERT : 새로운 데이터에 대한 인덱스를 추가함

• DELETE : 삭제하는 데이터의 인덱스를 사용하지 않는다는 작업을 진행함

• UPDATE : 기존의 인덱스를 사용하지 않음 처리하고, 갱신된 데이터에 대해 인덱스를 추가함

이제 인덱스를 사용하면 어떠한 이점이 있고 단점은 어떤것인지에 대해서 알아보도록 하겠습니다.

장점

조건 검색 WHERE 절의 효율성

WHERE 절의 원리는 WHERE 절이 들어간 조건에 맞는 데이터를 검색하기 위해서 1행부터 마지막 행까지 모든 데이터를 확인하게 됩니다. 이것을 '풀 테이블 스캔' 줄여서 '풀 스캔' 이라고 부릅니다.

이 때 인덱스를 활용한다면 인덱스 테이블은 데이터들이 정렬되어 저장되어 있기 때문에 해당 조건에 맞는 데이터를 빠르게 찾아낼 수 있습니다.

정렬 ORDER BY 절의 효율성

인덱스를 사용하면 ORDER BY에 의한 정렬 과정을 피할 수 있습니다. ORDER BY는 부하가 굉장히 많이 걸리는 작업입니다. 정렬과 동시에 1차적으로 메모리에서 정렬이 이루어지고 메모리보다 큰 작업이 필요하다면 추가적인 I/O 도 발생되기 때문입니다.

하지만 인덱스를 사용하면 이러한 자원의 소모 없이도 정렬을 할 수 있습니다. 왜냐하면 이미 정렬이 되어있기 때문에 가져오기만 하면 되기 때문입니다.

MIN, MAX의 효율적인 처리 가능

이것 또한 데이터가 정렬되어 있기 때문에 가능한 장점입니다. MIN값과 MAX값을 레코드의 시작 값과 끝 값 한 건씩만 가져오면 되기 때문에 풀 스캔으로 테이블을 모두 뒤져서 작업하는 것보다 훨씬 효율적으로 찾을 수 있습니다.

단점

인덱스는 DML에 취약

위에서 언급했듯이 인덱스 테이블 내에 있는 값들에 대해 추가적인 연산이 필요합니다. 그렇기 때문에 조회가 자주 일어나는 테이블에 대해서만 인덱스를 사용하는 것이 좋습니다.

데이터베이스의 저장공간 차지

인덱스를 관리하기 위해 데이터베이스의 약 10퍼센트에 해당하는 저장공간이 필요합니다. 따라서 데이터가 많으면 많을수록 인덱스의 장점을 크게 받지만 데이터가 적은 경우 풀 스캔도 속도가 충분히 나오기 때문에 무조건 인덱스를 사용하는 것은 좋지 못합니다.

여기까지 데이터베이스 인덱스에 대해서 알아봤습니다. CS 공부는 처음이라 새로운것들 투성이네요. 하지만 처음부터 잘하는 사람 없다는 말에 힘입어 열심히 공부해보도록 하겠습니다. 그럼 좋은 하루 보내세요~

출처

https://mangkyu.tistory.com/96

https://choicode.tistory.com/27

데이터베이스 제약조건

테이블 작성시에 Constraint를 사용해서 입력하는 자료에 대해서 제약, 즉 규칙을 정해줄 수 있는데요. 이때 정해진 제약에 따라서 데이터가 입력이 됩니다. 제약에 배반된다면 자료 입력이 거부되면서 오류가 납니다.

즉, 데이터 무결성을 지키기 위한 데이터 무결성 제약조건에 대해서 알아볼까 합니다. 데이터 무결성이란 권한이 부여된 계정이나 사람만이 접근 가능하고, 정확하고 완전한 데이터들이 저장되어 있는 상태를 생각하시면 됩니다.

데이터의 무결성을 위해서 데이터베이스에서는 5가지의 제약조건을 제공하고 있습니다.

이제 각각에 대해서 알아보도록 하겠습니다.

1. NOT NULL

-구문 형식

• NOT NULL은 INSERT 시 즉, 데이터 입력시에 누락이 되어서는 안되는 부분이라는 것입니다. NULL값이 들어가게 되면 오류가 납니다.

• NULL이 기본 값이므로 NULL을 명시해주는 컬럼 2와 NULL을 명시해주지 않은 컬럼 3은 동일하다고 보시면 됩니다.

• INSERT 시에 컬럼 2와 컬럼 3에 NULL값을 넣기 위해서는 'NULL' 또는 빈칸없이 '' (작은 따옴표 두개) 로 해주시면 됩니다. 안에 공백을 넣게되면 NULL 값이 아닌 공백 값으로 들어갑니다.

2. UNIQUE

-구문 형식

• UNIQUE는 해당 테이블에 있어서 존재하는 값이 유일해야 합니다.

• 이때, NULL 값에 대해서는 UNIQUE 제약이 적용되지 않습니다.

• 테이블을 만들 때 제일 밑에 CONSTRAINTS 제약명 UNIQUE (컬럼2, 컬럼3) 과 같이 CONSTRAINTS의 이름을 지정이 가능합니다.

• 예를들어 컬럼2에 A라는 값이 컬럼3에 B라는 값이 들어가고 다른 튜플에는 컬럼2에 A라는 값이 컬럼3에 C라는 값이 들어간다면 (A, B), (A, C) 조합인 경우 유니크 제약조건이 깨지지 않습니다. 컬럼2가 같다고 하더라도 그 조합이 다르기 때문에 오류가 발생하지 않습니다.

3. PRIMARY KEY

-구문 형식

• PRIMARY KEY 즉, 기본키는 하나의 테이블에 있는 데이터들을 식별하기 위한 기준으로 인식되는 제약조건입니다.

• 그렇기 때문에 UNIQUE와 달리 한개의 테이블에 하나만 생성이 가능합니다.

• 기본키는 NOT NULL + UNIQUE의 속성을 가집니다. 즉, NULL값이 있으면 안되고 해당 컬럼의 데이터는 중복되지 않고 유일해야 합니다.

• 기본키를 명시하는 방법은 위와 같이 두가지가 있으며 동일합니다. 다만 아래쪽은 기본키의 이름을 지정해줄 수 있습니다.

4. FOREIGN KEY

-구문 형식

• FOREIGN KEY 즉, 외래키는 해당 컬러멩 참조하는 테이블로부터 존재하는 값들만 사용한다는 의미의 제약조건입니다. 만일 참조하는 테이블에 해당하는 값이 없을시에는 INSERT나 UPDATE시에 오류가 나게 됩니다.

• 외래키는 여러개의 컬럼에 중복적으로 적용 가능합니다.

• 외래키로 두 테이블이 부모 테이블과 자식 테이블로 관계를 맺고 있을시에, 자식 테이블이 참조하는 데이터는 부모 테이블에서 삭제가 불가능합니다. (무결성 제약조건)

• 외래키를 명시하는 방법은 위와 같이 두가지가 있으며 동일합니다. 다만 아래쪽은 외래키의 이름을 지정해줄 수 있습니다.

5. CHECK

-구문 형식

• CHECK는 조건에 부합하는 데이터만 입력이 가능하도록 하는 제약조건입니다.

• 조건에는 기본연산자나 비교연산자, IN, NOT IN 등이 사용 가능합니다.

이렇게 데이터베이스의 제약조건에 대해서 알아봤습니다. UNIQUE 제약조건에 대해서 궁금해서 알아보다가 기왕 이렇게 된거 한번 정리하는게 좋겠다는 생각으로 포스팅하게 되었습니다. 긴 글 읽어주셔서 감사합니다. 다음 포스팅에서 봬요!

출처

https://runtoyourdream.tistory.com/129

데이터베이스 정규화

이번 포스팅에선 이해하기 어렵기로 소문난 정규화에 대해서 포스팅해볼 생각입니다. 저도 정규화에 대해서 공부해야겠다고 마음 먹었지만 이렇게 이해하기 힘들었던 적은 없었던 것 같네요.

워낙 말이 베베꼬여져 있으니까 예제와 함께 제가 말로 풀어서 설명해보도록 하겠습니다.

정규화

정규화는 무엇일까요? 우선 정규화의 기본 목표는 테이블 간에 중복된 데이터를 허용하지 않는다는 것입니다. 중복된 데이터를 허용하지 않음으로써 무결성을 유지할 수 있으며, DB의 저장 용량 역시 줄일 수 있습니다.

저는 여기서 한가지 걸리더군요. 무결성은 또 뭔가?

정규화에 대해서 알아보기 전에 무결성을 유지할 수 있다고 하니 무결성에 대해서 조금만 알아보도록 하겠습니다.

무결성

무결성에는 크게 세가지가 있습니다. 개체 무결성, 참조 무결성, 도메인 무결성이 바로 그것이지요.

하나하나 살펴보도록 하겠습니다.

1. 개체 무결성

모든 테이블이 기본키로 선택된 필드를 가져야하고, 기볹키로 선택된 필드는 고유한 값을 가져야 하며, 빈 값은 허용하지 않는다.

개체 무결성은 간단하죠. 다음은 참조 무결성입니다. 이것도 우리가 흔히 보던 내용입니다.

2. 참조 무결성

관계형 데이터베이스 모델에서 참조 무결성은 참조 관계에 있는 두 테이블의 데이터가 항상 일관된 값을 갖도록 유지되는 것을 말합니다. 말이 좀 어려운데 예제와 함께 보시죠

위의 상황은 '전공' 테이블과 '학생' 테이블이 외래키로 참조되어있는 상황입니다. FK로 dept_id를 가지고 있는 '학생' 테이블이 부모 테이블처럼 보이네요.

우리는 잘 알다시피 외래키로 묶여 있는 테이블의 튜플중 삭제를 진행하려면 자식테이블부터 삭제해야 한다는 것을 알고 있습니다. 무결성 제약조건이죠? 참조 무결성이 바로 이런 의미입니다.

다음은 도메인 무결성입니다. 이것은 앞에것보다 더 쉽습니다.

3. 도메인 무결성

도메인 무결성은 테이블에 존재하는 필드의 무결성을 보장하기 위한 것으로 필드의 타입, NULL값의 허용 등에 대한 사항을 정의하고, 올바른 데이터의 입력되었는지를 확인하는 것입니다. 예를 들어서 주민등록번호 필드에 문자가 입력되는 경우는 도메인 무결성이 깨지는 경우라고 볼 수 있습니다.

이렇게 무결성에 대해서 잠깐 알아봤습니다. 우리가 다 알고있는 쉬운 내용이라 이해하는데 어렵지 않았을 것입니다.

그럼 이제 정규화로 돌아와볼까요? 정규화는 이런 '무결성을 유지하기 위해 하는 행위' 정도로 정리하면 될 것 같습니다.

정규화에는 크게 네가지가 있습니다. 제 1 정규화, 제 2 정규화, 제 3 정규화, BCNF 정규화 이렇게 넷이죠. 하나씩 알아볼까요?

제 1 정규화

제 1 정규화란 테이블의 컬럼이 하나의 값만 갖도록 테이블을 분해하는 것입니다. 예를 들어 아래와 같은 고객 취미 테이블이 존재한다고 생각해봅시다.

위의 테이블에서 추신수와 박세리는 여러개의 취미를 갖고 있기 때문에 제 1 정규형을 만족하지 못하고 있습니다. 때문에 이를 제 1 정규화를 통해 분해할 수 있습니다. 제 1 정규화를 진행한 테이블은 아래와 같습니다.

여기까지는 어렵지 않을 것이라고 생각됩니다. 정리하자면 제 1 정규화는 하나의 컬럼에 하나의 값만 가져야 한다는 것입니다.

제 2 정규화

제 2 정규화란 제 1 정규화를 진행한 테이블에 대해 완전 함수 종속을 만족하도록 테이블을 분해하는 것입니다. 여기서 완전 함수 종속이라는 것은 기본키의 부분집합이 결정자가 되어서는 안된다는 것을 의미합니다.

말이 정말 어렵습니다... 우선 우리는 이 부분을 집중해야합니다. '기본키의 부분집합이 결정자가 되어서는 안된다'

이것이 제 2 정규화의 전부입니다. 일단 잘라서 다시 볼까요?

1. 기본키의 부분집합이

2. 결정자가 되어서는 안된다.

이 부분집합이라는 것도 거슬립니다. 다시 풀어서 말하면 '기본키에 속하지만 기본키는 아닌 것이' 라고 풀어 쓸 수 있습니다. 기본키에 속한다..? 이것도 좀 애매하네요 기본키에 속하지만 기본키는 아니라는 의미는 복합키의 경우를 예시로 들 수 있습니다.

복합키는 기본키는 아니지만 기본키와 같은 속성을 가지고 있죠.

여기까지 1번에 대해서 설명했는데 2번에서 또 거슬리는 단어가 있습니다. 바로 '결정자'이지요.

예시와 함께 결정자가 무엇인지 보도록 하겠습니다.

이 테이블에서 기본키는 학생번호, 강좌이름으로 복합키입니다. 그리고 이 복합키는 성적을 결정합니다.

(학생번호, 강좌이름) --> (성적)

여기서 결정하는 것이 하나 더 있습니다.

(강좌이름) --> (강의실) 바로 이것이죠

즉, 기본키의 부분키인 강좌이름이 결정자이기 때문에 위의 테이블의 경우 다음과 같이 기존의 테이블에서 강의실을 분해하여 별도의 테이블로 관리하여 제 2 정규형을 만족할 수 있습니다.

즉 다시 정리해보면 다음과 같습니다.

1. 기본키의 부분집합이 (기본키에 속하지만 기본키는 아닌 것이)

2. 결정자가 되어서는 안된다 (기본키처럼 어떤 값을 결정지으면 안된다)

위의 예제에선 강좌이름이 부분키로 기본키에 속하지만 기본키가 아닌 것이 기본키처럼 어떤 값(강의실)을 결정지었기 때문에 제 2 정규화에 어긋났던 것입니다.

제 3 정규화

제 3 정규화란 제 2 정규화를 진행한 테이블에 대해 이행적 종속을 없애도록 테이블을 분해하는 것입니다. 여기서 이행적 종속이라는 것은 A -> B, B -> C가 성립할 때, A -> C가 성립되는 것을 의미합니다.

예를 들어서 아래의 테이블이 바로 그런 경우죠

학생 번호는 강좌 이름을 결정짓고 강좌 이름은 수강료를 결정짓습니다. 따라서 이 관계에서는 학생 번호가 수강료를 결정짓는 이상한 상황이 발생합니다. 따라서 제 3 정규화에 의해 이 관계는 두개의 테이블로 분해되어야 합니다.

개념은 이해하기 쉬웠을 것 같습니다. 그러니까 정리하자면 이러한 이행적 종속 관계가 하나의 테이블에서 일어나면 안된다는 의미입니다.

BCNF 정규화

BCNF 정규화란 제 3 정규화를 진행한 테이블에 대해 모든 결정자가 후보키가 되도록 테이블을 분해하는 것입니다. 우리는 앞에서 결정자가 무엇인지 정의했습니다. 그럼 후보키는 무엇일까요?

후보키는 데이터베이스의 여러 키 중 하나인데 기본키가 될 수 있는 말그대로의 '후보'들 입니다.

위의 테이블에서 기본키는 (학생번호, 특강이름) 입니다. 그리고 기본키는 교수를 결정하고 있는 결정자이지요. 또한 여기서 교수는 특강 이름을 결정하고 있습니다.

어 이건 제 3 정규화에서 봤던 이행적 종속에 해당하는게 아닌가요? 라고 하실 수 있습니다.

하지만 아닙니다! 위의 테이블에서는 학생번호가 특강이름을, 교수가 특강이름을 결정하기 때문에 굳이 따지자면 A -> B, C -> B 의 관계입니다.

자 그럼 학생번호는 PK이기 때문에 결정자라고 해둡시다. 그럼 교수는 어떤가요? 교수는 특강이름을 결정짓는 결정자이지만 PK가 아니기 때문에 아무것도 아닙니다.

이러한 상황에서 BCNF 정규화는 결정자로 존재하는 것들은 반드시 후보키가 되어야 한다는 의미입니다.

여기까지 데이터베이스의 정규화에 대해서 알아봤습니다. 저도 이해하는데 엄청 고생했는데 말로 풀어 쓰자니 더 어렵네요. 잘 이해가 되셨을지 모르겠습니다.

저도 아직 아리송한 부분이 많아서 면접준비로 다시 정리해보는 시간을 가져야 할 것 같습니다.

긴 글 읽어주셔서 감사합니다. 다음 포스팅에서 봬요!

출처

https://mangkyu.tistory.com/110

https://untitledtblog.tistory.com/123

https://coding-factory.tistory.com/220

커넥션 풀 (Connection Pool)

이번 포스팅에서는 커넥션 풀에 대해서 알아보도록 하겠습니다.

커넥션 풀링은 데이터 접근 패턴으로 많이 알려져있습니다. 커넥션 풀링은 데이터베이스 커넥션과 읽고/쓰는 데이터베이스 작업 성능을 포함되는 과부하를 줄이기위한 목적으로 사용됩니다.

기본적인 레벨에서 커넥션 풀은 데이터베이스 커넥션 캐싱 구현체입니다. 이것은 개발자들의 특별한 요구를 적절하게 구성할 수 있습니다.

커넥션 풀

왜 커넥션 풀을 사용해야 할까요?

이 질문에 대한 답을 얻기 위해서는 데이터베이스가 커넥션 작업을 하는 동안 벌어지는 일에 대해서 알면 답할 수 있습니다.

데이터베이스 커넥션은

• 데이터베이스 드라이버를 사용해서 커넥션을 엽니다.

• 읽기/쓰기에 의해 생성된 데이터를 위해 TCP 소켓을 엽니다.

• 읽기/쓰기에 의해 생성된 데이터를 소켓에 담습니다.

• 케녁선을 닫습니다.

• 소켓을 닫습니다.

이 작업만 놓고 봤을 때 데이터베이스 커넥션을 얻기 위해 굉장히 많은 작업이 필요한 것을 알 수 있습니다. 이리헌 작업이니만큼 데이터베이스 커넥션을 위해 과정을 최소화하는 것은 필수적으로 보이죠.

https://velog.io/@mstar228/Connection-Pool%EC%9D%B4%EB%9E%80
https://velog.io/@mstar228/Connection-Pool%EC%9D%B4%EB%9E%80

커넥션 풀의 특징

커넥션 풀의 특징으로는 다음과 같습니다.

• WAS가 실행되면서 커넥션 객체들을 미리 풀에 생성해 둡니다.

• HTTP 요청에 따라 풀에서 커넥션 객체를 가져다 쓰고 반환합니다.

• 이와 같은 방식으로 물리적인 데이터베이스 연결 부하를 줄이고 연결 관리를 합니다.

• 풀에 미리 커넥션 객체가 생성되어 있기 때문에 커넥션을 생성하는데 드는 요청마다 연결 시간이 소비되지 않습니다.

• 커넥션을 계속해서 재사용하기 때문에 생성되는 커넥션 수를 제한적으로 설정할 수 있습니다.

동시 접속자가 많을 경우

동시 접속자가 많을 경우 풀에서 미리 생성된 커넥션을 제공하고, 없을 경우에는 사용자는 커넥션이 반환될 때까지 번호순서대로 대기상태로 기다립니다.

여기서 WAS에서 커넥션 풀을 크게 설정하면 메모리 소모가 큰 대신 많은 사용자가 대기시간이 줄어들고, 반대로 커넥션 풀을 적게 설정하면 그만큼 대기시간이 길어진다.

Hikari Connection Pool

다양한 커넥션 풀이 사용되지만 그 중에서도 가장 뛰어난 성능을 보여주는 Hikari(이하 히카리) 커넥션 풀에 대해서 잠깐 알아보도록 하겠습니다.

히카리CP는 가장 가볍고 가장 빠른 JDBC 커넥션 풀링 프레임워크입니다.

얼마나 빠르고 어떻게 이런 성능을 보여주는걸까요?

아래 표는 히라키CP와 다른 커넥션 풀링 프레임워크와 비교한 데이터입니다.

히카리CP에서 낸 공식적인 벤치마크이긴 하지만 보통 본인회사 본인제품을 홍보할 때는 과장이 어느정도 있으니 그런것들을 염두에 두고 판단하시면 될 것같습니다.

히카리CP가 이런 성능을 낼 수 있는 것에는 아래와 같은 이유때문입니다.

• 바이트코드 레벨의 엔지니어링 : 몇몇개의 극도로 바이트코드 레벨로 엔지니어링 된 것이 그 첫번째 이유입니다. 극도로 바이트코드 레벨로 엔지니어링 되었다는 의미는 어셈브리코드 레벨의 코딩을 의미합니다.

• 미세한 부분까지 최적화 : 비록 정확히 측정할 수는 없을지라도 전체적인 성능에서 성능최적화를 구현해내고 있습니다.

• 컬렉션 프레임워크를 효율적으로 사용 : 히카리CP에서는 ArrayList<E>를 커스텀 클래스로 대체해서 사용하고 있습니다. FastList인데요, range checking을 제거했고 처음부터 끝까지 스캔하는 것을 제거했습니다.

이렇기 때문에 히카리CP는 많은 프레임워크에서 디폴트 커넥션 풀로 사용할만큼 잘 만들어진 프레임워크입니다.

여기까지 커넥션 풀에 대해서 알아봤습니다. 딱히 어려운 내용이 없어서 이번 포스팅을 준비하는데 어려움은 없었던 것 같네요 제 포스팅이 많은 도움이 되셨기를 기원하면서 이만 마치도록 하겠습니다. 오늘도 즐거운 하루 되세요~

출처

https://linked2ev.github.io/spring/2019/08/14/Spring-3-%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80%EC%9D%B4%EB%9E%80/

https://www.baeldung.com/java-connection-pooling

=> 커넥션 풀 공식문서

https://www.baeldung.com/hikaricp

=> 히카리CP 공식문서

트랜잭션과 ACID (자바에서 트랜잭션을 다루는 방법에 대한 관점으로)

이번 포스팅에서는 트랜잭션과 ACID 그리고 더 나아가서 자바에서 트랜잭션을 어떻게 다루는지에 대해서 알아보도록 하겠습니다.

굉장히 딥하게 들어가기 때문에 트랜잭션과 ACID에 대한 기본적인 내용에 대해서만 알고싶으신 분들은 앞부분만 읽으시고 뒷부분은 알아만 두시는 것을 추천드리겠습니다. 최대한 쉽게 풀어서 쓸거라 찬찬히 읽으시면 이해가 안되지는 않을겁니다!

그럼 시작해보죠

트랜잭션

트랜잭션을 간단하게 한마디로 설명하자면

"데이터베이스의 상태를 변경시키기 위한 논리적인 단위" 라고 할 수 있습니다.

여기서 상태를 변경시킨다는 것은 우리가 흔히 사용하는 SQL문을 이용한 것입니다. DDL, DML등을 말하는 것이지요.

트랜잭션에 대한 개념은 이게 다입니다. 조금 허무하시죠?

이제 트랜잭션의 특징이라고 말할 수 있는 ACID에 대해서 알아보죠

ACID

ACID는 객체지향의 5대원칙 SOLID처럼 영어의 앞글자만 따서 만든 단어입니다. 저는 ACID(에이씨드)라고 부르긴 하는데 어떤 분은 어씨드 라고도 부르시더라구요. 저는 에이씨드가 편해서 에이씨드라고 말합니다.

아무튼 ACID가 뭐의 앞글자를 딴거냐

원자성(Atomicity), 일관성(Consistency), 독립성(Isolation), 지속성 혹은 내구성(Durability) 이렇게 네가지입니다.

각각에 대해서 설명하겠지만 이 중 가장 중요한 것은 원자성입니다. 모든 트랜잭션이 반드시 갖춰야할 덕목이죠 (그렇다고 나머지가 안중요하다는 것은 아닙니다)

원자성

트랜잭션의 모든 연산은 완벽해야하고, 하나의 연산이라도 실패하는 경우 모든 연산이 전부 실패해야 합니다.

일관성

트랜잭션을 실행하고 있던 데이터베이스가 변경되더라도 변경된 데이터베이스의 트랜잭션으로 마저 실행하는 것이 아닌 기존에 사용하던 트랜잭션의 데이터베이스로 진행되어야 한다는 개념입니다.

독립성

서로 다른 두 트랜잭션이 서로에 대한 연산에 대해 연관되지 않고 독립적으로 실행되어야 한다는 것입니다. 이 독립성은 데이터베이스의 격리수준과 연결되는 내용입니다. 데이터베이스 격리수준에 대한 내용은 아래의 링크를 참조해주세요

https://coding-review.tistory.com/196

지속성

트랜잭션이 성공적을 완료되었다면, 시스템이 오류로 종료되더라도 성공한(커밋된) 내용이 보장되어야 한다는 것입니다.

여기까지 트랜잭션의 기본적인 내용과 ACID에 대한 내용입니다. 이 뒤부터는 조금 어려울 수 있습니다.

Local Transaction VS Global Transaction

트랜잭션에는 크게 Local Transaction(이하 로컬 트랜잭션)과 Global Transaction(이하 글로벌 트랜잭션)이 있습니다. 우선 정말 간단하게 두 개념에 대해서 설명하자면

로컬 트랜잭션 = 하나의 데이터베이스를 하나의 트랜잭션으로 여러 연산을 진행하는 것

글로벌 트랜잭션 = 여러개의 데이터베이스를 하나의 트랜잭션으로 여러 연산을 진행하는 것

사실 데이터베이스 말고도 프린터 드라이버라던가 메세지 서비스도 있지만 아직 저도 이 두가지에 대해 써본적도 없으며 취직을 하더라도 한동안 안쓸 것 같아서 데이터베이스만 가지고 얘기해보도록 하겠습니다.

우리는 이 뒤에서 자바에서 꽤 자주 사용하는 몇몇개의 라이브러리를 알아보고 어떻게 사용하는지에 대해서도 알아보도록 하겠습니다.

Local Transaciton

로컬 트랜잭션에는 우리가 알고있는 것들이 나옵니다. 바로 JDBC, JPA, JMS이죠 JMS는 좀 생소할지 몰라도 JDBC와 JPA는 우리에게 아주 익숙한 API 중 하나입니다.

JDBC, JPA, JMS가 로컬 트랜잭션이라는 것이 아니고 JDBC, JPA, JMS가 가진 API들이 로컬 트랜잭션을 사용할 수 있도록 도와준다 라고 해석해주시면 될 것 같습니다.

JDBC

JDBC(Java Database Connectivity)는 자바에서 데이터베이스에 어떻게 접근할 것인지에대해 정의해놓은 API입니다.

다른 데이터베이스 접근 기술과 다르게 JDBC 드라이버라는 것을 통해 데이터베이스 커넥션을 얻어오는 것이 특징입니다.

JDBC의 구조는 위 그림과 같습니다. JDBC는 트랜잭션 아래서 여러 상태들을 실행하기 위한 옵션들을 제공합니다. JDBC의 디폴트 실행은 auto-commit(이하 오토-커밋)입니다. 쉽게 말해서 오토-커밋은 SQL문을 날리는 그 단위 하나하나마다 커밋이 들어간다는 의미입니다.

JDBC의 순서는 다음과 같습니다.

• 드라이버 매니저로 커넥션을 얻고

• Statement 객체를 생성한 후에

• 쿼리를 날리고

• 커밋

쿼리를 한번 날리면 커밋이 됩니다.

그런데 이렇게 하면 트랜잭션의 원자성을 보장할 수 없지 않나요?

맞습니다. 그래서 JDBC에서는 setAutoCommit을 false로 설정해주면 원자성을 보장할 수 있습니다.

Connection connection = DriverManager.getConnection(CONNECTION_URL, USER, PASSWORD);

try {

connection.setAutoCommit(false);

PreparedStatement firstStatement = connection .prepareStatement("firstQuery");

firstStatement.executeUpdate();

PreparedStatement secondStatement = connection .prepareStatement("secondQuery");

secondStatement.executeUpdate();

connection.commit();

} catch (Exception e) {

connection.rollback();

}

JPA

JPA(Java Persistence API)는 자바에서 객체지향인 자바(언어)와 관계형 데이터베이스인 RDBMS를 이어주는 다리 역할을 합니다. 그래서 JPA에는 JPA를 구현한 여러개의 구현체를 가지고 있는데 대표적으로 Hibernate와 EclipseLink 그리고 iBatis가 있습니다.

JPA에서는 Entity라는 도메인 클래스를 생성하고 영원히 데이터를 보관하는 영속성컨텍스트의 1차캐시에 이 클래스를 저장해두고 지속적으로 사용합니다.

JPA의 구조는 아래와 같습니다.

영속성 컨텍스트에는 두가지 형태가 있는데 transaction-scoped와 extended-scoped입니다. 두 스코프의 차이는 싱글 트랜잭션이냐 멀티 트랜잭션이냐의 차이가 있습니다. 디폴트 설정은 transaction-scoped 즉 싱글 트랜잭션을 기본으로 작동합니다.

JPA는 EntityManager를 통해 트랜잭션의 원자성을 보장합니다.

EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("jpa-example");

EntityManager entityManager = entityManagerFactory.createEntityManager();

try {

entityManager.getTransaction().begin();

entityManager.persist(firstEntity);

entityManager.persist(secondEntity);

entityManager.getTransaction().commit();

} catch (Exception e) {

entityManager.getTransaction().rollback();

}

JMS

JMS(Java Messaging Service)는 자바에서 메시지를 사용해서 비동기성이라는 특징을 가지고 애플리케이션끼리 소통하는 특징이 있습니다. 다른 말로는 메세지 큐잉 시스템이라고도 부릅니다.

JMS가 가진 API로는 create, send, receive, read를 통해 메세지를 처리할 수 있습니다.

JMS의 API들은 싱글 트랜잭션에서 여러개의 주고 받는 동작들을 묶어서 지원합니다. 그런식으로 트랜잭션의 원자성을 지원하죠.

JMS는 특정한 vendor인 ConnectionFactory에서 얻은 커넥션을 세션으로 생성해야합니다.

ActiveMQConnectionFactory connectionFactory = new ActiveMQConnectionFactory(CONNECTION_URL);

Connection connection = = connectionFactory.createConnection();

connection.start();

try {

Session session = connection.createSession(true, 0);

Destination = destination = session.createTopic("TEST.FOO");

MessageProducer producer = session.createProducer(destination);

producer.send(firstMessage);

producer.send(secondMessage);

session.commit();

} catch (Exception e) {

session.rollback();

}

이런식으로 사용하면 되는데 사실 처음보는거라... 사용해본적도 없고 빠르게 넘어가도록 하겠습니다.

Global Transaction

글로벌 트랜잭션은 이제 진짜 처음보는 것들입니다. 개념만 집고 빠르게 넘어가겠습니다.

글로벌 트랜잭션은 위에서도 설명했듯이 여러개의 데이터베이스에서 하나의 트랜잭션으로 여러개의 연산을 처리하고 싶을 때 사용합니다. 글로벌 트랜잭션은 하나의 트랜잭션으로 여러개의 데이터베이스의 연산을 처리해야 하기 때문에 하나의 트랜잭션을 둘 혹은 그 이상으로 쪼갭니다. 그래서 글로벌 트랜잭션을 "분산 트랜잭션"이라고도 합니다.

분산 트랜잭션에는 XA Architecture라는 것이 있는데 2PC(2 Phase Commit)를 통한 분산 트랜잭션 처리를 위한 X-Open에서 명시한 표준입니다. 뭐... 이런 XA 아키텍쳐의 개념같은건 그냥 넘어가시고 우리가 볼 건 2PC입니다.

트랜잭션의 수행 단계는 1PC와 2PC가 있습니다.

2PC

begin → end → prepare → commit 의 단계를 거치며 글로벌 트랜잭션을 하려면 반드시 2PC를 해야합니다. 1PC에 비해 prepare의 과정이 들어갔기 때문에 성능상으로는 1PC에 비해 큰 이점을 노릴 수는 없으나 글로벌 트랜잭션을 위해서라면 반드시 거쳐야하는 수행 단계입니다.

1PC

begin → end → commit 의 단계를 거치며 트랜잭션에 참여한 리소스들이 서로 같은 리소스라고 확인되었을 경우에는 prepare가 필요없습니다. 이러한 처리 방식을 1PC라고 부릅니다.

이렇게 처리하게 되면 prepare를 안해도 되므로 트랜잭션의 처리 성능 향상에 도움을 줄 수 있습니다. 따라서 1PC는 트랜잭션 튜닝 방식 중 하나입니다.

cf) 1PC라고 하여 로컬 트랜잭션인 것은 아닙니다. 1PC도 엄연한 글로벌 트랜잭션입니다.

즉, 정리하자면 1PC는 글로벌 트랜잭션안에서 트랜잭션에 참여한 리소스들이 서로 같은 리소스라고 확인되었을 경우( = 같은 데이터베이스인 경우) 2PC는 트랜잭션에 참여한 리소스들이 서로 다른 리소스인 경우 사용하는 수행 단계입니다.

글로벌 트랜잭션을 사용하기 위해서 JTA, JTS의 API를 사용하면 됩니다. 아직 저와는 먼 얘기이니 간단하게 짚고 넘어가도록 하겠습니다.

JTA

JTA(Java Transaction API)는 자바 애플리케이션이나 애플리케이션 서버가 XA 리소스를 위해 분산트랜잭션 수행에서 사용합니다. 위에서 XA에 대해서 알아봤으니 넘어가겠습니다.

JTA는 분산트랜잭션안에서 다른 API와 트랜잭션 매니저 사이의 인터페이스중 스탠다드한 아키텍쳐를 가지고 있습니다.

JTS

JTS(Java Transaction Service)는 OMG OTS 설계를 분류하는 트랜잭션 매니저를 빌딩하기위해 한 분류입니다. JTS는 JTS 트랜잭션 매니저들 사이에서 트랜잭션 상황을 전파하기위한 스탠다드 CORBA ORB/TS 인터페이스와 Internet Inter-ORB Protocol(IIOP)를 사용합니다.

음...일단 개념에 대해서 공식문서에 있는걸 그대로 해석했는데 당최 뭔소린지 하나도 모르겠네요 OMG? OTS? CORBA ORB/TS...IIOP?

넘어가도록 하겠습니다...

공식문서 내용을 보더라도 그리고 구글링을 해보더라도 JTA에 비해서 JTS는 많이 사용되는 것은 아닌듯합니다.

스프링에서의 트랜잭션

스프링 플랫폼에서는 로컬 트랜잭션과 글로벌 트랜잭션 둘 다 트랜잭션을 깔끔하게 사용하는 방법을 제공합니다. 스프링의 다른 이점들과 함께 트랜잭션을 다루는 방법에 대해 흥미로운 방식인데요.

스프링은 PlatformTransactionManager라는 인터페이스로 다형성을 이용해 여러가지 로컬 트랜잭션과 글로벌 트랜잭션을 구현할 수 있습니다.

우리가 앞서 봤던 JDBCTransactionManager부터 JPATransactionManager, JMSTransactionManager 이런 다양한 구현체들을 만들었습니다.

즉, 위에서 복잡하게 트랜잭션을 구현하지 않아도 스프링은 이미 개발자가 사용하는 데이터 접근 기술에 맞는 적절한 트랜잭션 기술을 가지고 있다는 것입니다.

스프링에서 트랜잭션을 사용하는 두가지 방법에 대해 알아보고 마치도록 하겠습니다.

• 선언해서 사용하는 방법

• 프로그램적으로 사용하는 방법

선언해서 사용하는 방법

스프링에서 트랜잭션을 가장쉽고 빠르고 간단하게 사용하는 방법은 바로 선언해서 사용하는 방법입니다. 선언해서 사용한다니 뭔가 느낌이 안오지만 이 말은 어노테이션을 이용한 방법이라는 뜻입니다.

@PersistenceContext

EntityManager entityManager;

@Autowired

JmsTemplate jmsTemplate;

@Transactional(propagation = Propagation.REQUIRED)

public void process(ENTITY, MESSAGE) {

entityManager.persist(ENTITY);

jmsTemplate.convertAndSend(DESTINATION, MESSAGE);

}

사용하고싶은 메서드에 @Transactional을 붙여주면 끝입니다. 해당 어노테이션은 클래스에도 붙일 수 있으며 클래스에 붙이면 클래스에 속한 모든 메서드에 다 어노테이션이 붙습니다.

이 @Transactional을 사용하면 어노테이션이 붙은 메서드에 한해서 트랜잭션의 원자성을 보장해줍니다. 그리고 ACID 모두 지원해주죠 정말 간편합니다.

프로그램적으로 사용하는 방법

공식문서에서도 이 방법은 그다지 추천하지는 않습니다. 왜냐면 트랜잭션의 원자성을 위한 바운더리를 컨트롤하는 이점을 포기하고 사용해야 하기 때문이죠.

즉, 트랜잭션의 원자성을 이용할 수 없다는 것입니다.

마치며

이렇게 트랜잭션과 ACID 그리고 자바 나아가 스프링에서 트랜잭션을 어떤 것을 지원하는지 어떻게 사용하는지까지 확인해봤습니다. 이번 포스팅을 준비하면서 어려운 부분도 물론 있었지만 알게 된 내용이 많아서 정말 뿌듯하고 알찼던 것 같습니다. 로컬 트랜잭션이라던가 글로벌 트랜잭션이나 XA 아키텍쳐, 2PC, 1PC등 알게 된 내용이 정말 많네요

이 글을 보시는 여러분들도 도움이 되셨기를 기원하면서 저는 이만 들어가보도록 하겠습니다. 긴 글 읽어주셔서 정말 감사드리구요 오늘도 좋은 하루 보내세요~

출처

https://www.baeldung.com/java-transactions

=> 트랜잭션 in 자바 공식문서

https://heni.tistory.com/10

MVCC (Multiversion Concurrency Control)

오늘 포스팅에서는 흔히 Read Committed 격리 수준에서 생기는 부정합 문제인 Non-Repeatable Read 문제를 해결하기 위해 사용하는 방법이라고 알려져있는 MVCC에 대해서 알아보도록 하겠습니다.

이 포스팅에서는 MVCC가 무엇인지, 사용하면 어떤 이점이 있는지, 동작원리 등을 알아볼겁니다.

MVCC에 대해서 깊게 알고싶지 않으신 분들은 그냥 Non-Repeatable Read 문제를 해결하기 위해서 Undo영역 혹은 Xmin, Xmax를 이용해 해결하는 방법론이라고 알고 계시면 될 것같습니다.

그럼 시작해보죠

MVCC란?

MVCC란 Multiversion Concurrency Control의 약자로 해석해보면 "여러개의 버전으로 동시성문제를 해결하는 방법"이라고 할 수 있습니다. 왜 굳이 방법이라는 단어를 붙였냐면 MVCC는 딱히 대단한 기술이나 기능이 아니고 방법론이기 때문입니다.

쉽게 예를 들어보자면 알고리즘과 같습니다. 알고리즘 중에 Two Pointers Algorithm 이라는 알고리즘이 있는데 개념은 두개의 포인터를 사용하여 배열에 접근하여 시간복잡도를 획기적으로 줄여주는 알고리즘입니다.

이 알고리즘을 구현하기 위해서 다양한 방법으로 코드를 작성할 것이고 설령 다르게 작성하였다고해도 본질만 같으면 상관 없는 것과 비슷합니다.

동시에 여러 트랜잭션이 조회와 변경을 필요로 할 때 데이터를 어떻게 안전하게 보호할 것인지에 대한 방법론이라고 생각해주시면 좋습니다.

DBMS에 굉장히 필수적인 기능이기 때문에 많은 데이터베이스에서 구현했고 다양한 구현 방법이 있습니다.

MVCC의 이점

MVCC의 이점은 다음과 같습니다.

• 데이터베이스 락이 필요로한 부분을 줄일 수 있다.

• 데이터베이스에 접근하기위한 contention을 줄일 수 있다.

• 읽기 성능을 항상시킬 수 있다.

• 쓰기작업에 대한 고립성을 보장해준다.

• 데이터베이스 데드락을 줄여준다.

짚고 넘어가야할 단어들이 몇개 보이네요. contention과 데드락에 대해서 간단하게 알아보도록 하죠

contention

사전적인 의미는 "논쟁"이지만 컴퓨터 사이언스에서는 다른 뜻으로 쓰입니다. 위키피디아에선 contention을 다음과 같이 설명하고 있습니다.

In Computer science, resource contention is a conflict over access to a shared resource such as random access memory, disk storage, cache memory, internal buses or external network divices.

아주 쉽게 풀어쓰자면 "메모리에 접근할 때 충돌하는 것" 정도로 해석할 수 있습니다. 충돌이라는 표현이 좀 어색하긴 하지만 메모리에 접근할 때 드는 리소스 정도로 해석하면 좋을 것 같습니다. 정확한 뜻은 모르더라도 느낌만 알면 됩니다.

contention은 이쯤하면 될 것 같습니다. 다음은 데드락에 대해서 알아보시죠

deadlock

데드락은 말그대로 락이 죽어버리는 것입니다. 동시성 문제에는 크게 두가지 문제가 있습니다. 가시성과 원자성문제이죠 가시성 문제는 volatile 키워드로 해결하고 원자성 문제는 synchronized 키워드로 해결합니다.

우리가 집중해서 봐야할 것은 이 원자성 문제를 해결하는 synchronized입니다. 스레드가 하나이상 메서드로 들어오는 것을 막아주는 키워드이죠.

근데 만약 요청하는 스레드가 너무 많아서 줄을 서고 있다면 어떨까요? 아니면 하나의 메서드 안에서 스레드가 돌고 돌고 돌고 계속 도는 상황이면요?

그 뒤에 있는 스레드가 기다리다못해 팍 하고 죽어버리는 상황이 생깁니다. 그러면 죽어버린 그 스레드 뒤의 스레드들은 차례대로 전부다 죽어버리는것이죠.

이 문제는 참 심각하죠. 이것이 바로 데드락입니다.

다시 MVCC로 돌아와서 MVCC가 개발되기 전 데이터베이스들은 전부 이 락을 가지고 여러 트랜잭션의 고립성을 보장했습니다. 하지만 말씀드렸다시피 데드락이 발생하기 딱 좋은 상황이 펼쳐졌습니다.

이러한 여러가지 이유로 MVCC가 개발되었고 이러한 문제들이 많이 줄어들었습니다.

MVCC의 동작원리

MVCC의 구현 방법은 앞서 말했듯이 한가지만 있는 것이 아닙니다. 하지만 보통의 상황에서 어떻게 동작하는지 알려드리도록 하겠습니다.

• 모든 데이터베이스 레코드별로 version number가 부여된다.

• 동시에 읽는 작업은 제일 높은 version number의 레코드에서 일어난다.

• 쓰기 작업은 원본 레코드가 아니라 복사된 레코드에서 실행된다.

• 유저는 복사본이 업데이트 되는 동안 오래된 버전에서 읽는 것을 계속한다.

• 쓰기 작업이 성공적으로 마무리된 후에 version number가 하나 증가한다.

• 이후 동시에 읽는 작업은 업데이트된 버전을 사용한다.

• 새로운 업데이트가 일어나면 새로운 버전은 다시 생성되고 이 작업이 반복된다.

우선 이렇게 동작하긴 하지만 글로 적으니 이해가 잘 안되시죠. 그림으로 한번 알아보도록 하겠습니다.

1. 두개의 노드로 시작하고 두개의 노드는 꽉 차있습니다.
1. 두개의 노드로 시작하고 두개의 노드는 꽉 차있습니다.
2. 노란색의 새로운 노드가 등장했습니다.
2. 노란색의 새로운 노드가 등장했습니다.
3. root path의 새로운 가지가 생성됩니다.
3. root path의 새로운 가지가 생성됩니다.
4. 새로운 노드가 또 등장했습니다.
4. 새로운 노드가 또 등장했습니다.
5. 오래된 노트가 새로운 노드를 가져갑니다.
5. 오래된 노트가 새로운 노드를 가져갑니다.
6. 사용하지 않는 하늘색 노드는 없어집니다. 그리고 새로운 노드가 추가됐네요
6. 사용하지 않는 하늘색 노드는 없어집니다. 그리고 새로운 노드가 추가됐네요
7. 같은 방식으로 오래된 root path가 새로운 노드를 가져갑니다. 그러면서 숫자를 하나 증가시킵니다.
7. 같은 방식으로 오래된 root path가 새로운 노드를 가져갑니다. 그러면서 숫자를 하나 증가시킵니다.

그림으로 보니까 좀 이해하기 편하시죠?

여태까지 너무 MVCC의 장점에 대해서만 서술한 것 같아서 단점에 대해서도 몇개 짚고넘어가겠습니다.

MVCC의 단점

• 동시다발적인 업데이트를 제어하는 방법이 구현하기 어렵습니다.

• MVCC를 사용하다보면 데이터베이스가 점점 커지고 부풀어 오르게 됩니다. = 데이터베이스가 점점 무거워집니다.

이러한 MVCC는 여러 데이터베이스에서 이미 구현해놨고 우리는 원리만 이해하면 됩니다. 여기서 알아두면 좋을 MVCC의 상식에 대해서 잠깐 말씀드릴게요. Oracle과 Mysql 데이터베이스는 Undo 영역을 두어 MVCC를 구현했고, PostgreSQL은 Xmin, Xmax를 기반으로한 Vaccum을 이용해 MVCC를 구현했습니다.

여기까지 MVCC에 대해서 알아봤습니다. 워낙 딥한 내용이니만큼 이해하기 참 어려웠던 것 같습니다. MVCC는 데이터베이스의 심화과정이기 때문에 면접에서 이렇게까지 깊게 물어보진 않을 듯 합니다.

하지만 이런 경우가 있을 수 있죠.

Q. 데이터베이스 격리수준에 대해서 말씀해주세요.

A. 데이터베이스 격리수준에는 Read Uncommitted, Read Committed, Repeatable Read, Serializable 이 있는데 (Read Uncommitted 생략) Read Committed는 Oracle이 기본적으로 사용하는 격리수준이고 Non-Repeatable Read 부정합 문제가 발생합니다. Repeatable Read 격리수준은 Non-Repeatable Read 부정합 문제를 해결했지만 Phantom Read 부정합 문제가 발생할 수 있습니다.

라고 답변한 상황에서 (물론 이것보다 더 자세하게 말씀하셔야합니다)

이런 꼬리질문이 들어올 수 있죠

Q. Oracle은 Non-Repeatable Read 부정합 문제를 어떻게 해결했나요?

혹은

Q. Mysql은 Phantom Read 부정합 문제를 어떻게 해결했나요?

라는 질문에 당당하게

A. Non-Repeatable Read 부정합 문제는 MVCC를 이용해 해결했고 Phantom Read 부정합 문제는 gap-locking으로 해결했습니다.

라고 대답할 수 있습니다. 여기서 더 깊게 물어볼 수 있습니다. MVCC가 뭐냐

그럴땐 MVCC는 동시성문제를 해결하기 위해 사용하는 방법론으로서 Oracle과 Mysql에서는 Undo 영역을 추가함으로써 구현하였고 PostgreSQL에서는 Vaccum을 이용해 구현했습니다.

자 이렇게 대답한다면 제가 면접관이면 기분이 참 좋을 것 같습니다. 엄청 깊게 공부했네? 하고 생각할 것 같네요.

제가 존경하는 개발자인 향로님(이동욱님)께서 이런 말씀을 하셨었죠. "뭐든 하나 공부하면 깊게 공부하세요. 그게 여러분의 무기가 될거에요"

자 여기까지 긴 글 읽어주셔서 정말 감사합니다. 다음 포스팅은 Phantom Read 부정합 문제를 해결하기 위해 등장한 gap-locking에 대해서 알아보도록 하겠습니다. 오늘도 즐거운 하루 되세요~

출처

https://www.theserverside.com/blog/Coffee-Talk-Java-News-Stories-and-Opinions/What-is-MVCC-How-does-Multiversion-Concurrencty-Control-work

https://kousiknath.medium.com/how-mvcc-databases-work-internally-84a27a380283

Phantom Read 부정합문제 해결방안 In Mysql

이번 포스팅에서는 Mysql에서 Phantom Read 부정합 문제를 어떻게 해결하고 있는지에 대해서 알아보도록 하겠습니다.

굳이 Mysql에서 라고 글을 쓴 이유는 공식문서를 여러개 찾아보던 중에 PostgreSQL과 Oracle 에선 다른 방식으로 이 Phantom Read 부정합 문제를 해결했다는 듯이 쓴 글을 몇개 봤기 때문입니다.

이 부분이 저를 자극했습니다.
이 부분이 저를 자극했습니다.

PostgresSQL은 얼마나 smoothly 하게 이슈를 해결했는지 꼭 알고싶어서 말이죠

따라서 이번 포스팅에서는 우리가 흔히 접하게 되는 Mysql에서 Phantom Read를 어떻게 해결했는지에 대해서 알아보겠습니다.

Phantom Read란?

격리 수준에 대해서 포스팅할 때나, @Transactional 에 대한 포스팅을 할 때 잠깐잠깐 등장하고 아주 간략하게 정리했지만 이렇게 정식적으로 설명한적이 없어서 Phantom Read에 대해서 짚고 넘어가도록 하겠습니다.

Phantom Read는 말 그대로 Phantom 즉 유령처럼 레코드가 보였다 안보였다 하는 상황을 말합니다.

격리수준에 대해서 그리고 부정합 문제에 대해서 아시고 계신분은 조금 의아할 수도 있습니다.

왜냐하면 Phantom Read 와 Non-Repeatable Read 부정합 문제는 비슷하면서도 약간 다르거든요.

둘 다 같은 쿼리에서 같은 결괏값을 받지 못한다는 공통점이 있지만 Non-Repeatable Read는 re-query 즉 쿼리를 다시 실행했을 때이고 Phantom Read는 re-execute 즉 재실행했을 때입니다.

그러니까 "실행을 여러번 할때마다 레코드가 보였다 안보였다 하는 상황"으로 기억해주시면 좋을 것 같습니다.

Mysql에서 Phantom Read를 해결하는 방법

우선 Mysql에서 Phantom Read를 해결하기 위한 방법을 알기 전에 우리는 Mysql에서 구현하고 있는 Lock에 대해서 짚고 넘어가야 합니다.

공식문서를 찾아보면 뭐...여러가지 Lock이 보이는데 그 중 우리가 핵심적으로 알아야 하는 것은 이 네가지입니다.

• Shared Locks / Exclusive Locks

• Record Locks

• Gap Locks

• Next-Key Locks

차례대로 하나씩 설명해드리겠습니다.

Shared Locks / Exclusive Locks

해석해보면 공유된 락, 독점적인 락 이렇게 볼 수 있는데 이름이 어느정도 이해를 위해 도움이 됩니다. 머리속에 잘 기억하고 계시고 이해해보시면 좋을 것 같습니다.

InnoDB(=Mysql)에서는 기본적으로 row-level lock을 구현하고 있습니다. 그리고 두가지 기본적인 Lock이 있는데요 바로 Shared Lock, Exclusive Lock 입니다. 공식문서에서 나와있듯이 우리도 Shared Lock은 (s)Lock으로 Exclusive Lock은 (x)Lock으로 표현하도록 하겠습니다.

(s)Lock은 행을 읽기위한 Lock을 획득한 트랜잭션을 허용해줍니다. 이 말은 (s)Lock은 행을 읽기위해 사용되는 Lock입니다. 로 해석될 수 있습니다.

(x)Lock은 업데이트와 삭제를 위해 사용되는 Lock입니다.

만약 행 R에 대하여 트랜잭션 T1이 (s)Lock을 획득했다고 가정해봅시다. 그리고 나서 다른 트랜잭션인 T2가 행R에 대해 Lock을 요청하면 다음과 같은 절차를 따릅니다.

• T2의 요청이 (s)Lock인 경우 Lock을 즉시 획득할 수 있으며 행R에 대해 동시에 Lock을 잡고있을 수 있습니다.

• T2의 요청이 (x)Lock인 경우 Lock을 즉시 획득할 수 없습니다. = Lock이 풀릴때까지 기다려야 합니다.

이렇게 특징을 놓고보니 왜 Shared인지 Exclusive인지 대충 아실겁니다. (s)Lock인 경우 Lock을 두 트랜잭션이 공유할 수 있으며, (x)Lock인 경우 하나의 트랜잭션만이 독점적으로 Lock을 획득할 수 있기 때문입니다.

자 여기까지 이 글을 이해하기위한 기본적인 내용인 (s)Lock과 (x)Lock에 대한 내용입니다. 다음은 비교적 간단한 Record Locks에 대해서 알아보죠

Record Locks

Record Lock은 인덱스 레코드를 잠그는 것을 의미합니다. 예를 들어서 아래와 같은 쿼리가 날아갔다고 가정해보죠

SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE

이 경우 값이 t.c1 = 10인 행에 대한 삽입, 삭제, 수정에 대한 다른 트랜잭션의 접근을 막습니다. 참 쉽죠?

이 Record Lock은 한가지 특징이 있는데요. Record Lock은 항상 인덱스 레코드에서 Lock을 획득한다는 것입니다.

인덱스에 대해서 조금 생소하실 수도 있어서 잠깐 막간을 이용해 설명해보도록 하겠습니다.

인덱스

인덱스는 쉽게 말해서 데이터베이스 검색 성능을 향상시키기 위한 데이터베이스 튜닝 방법 중 하나입니다. 하지만 튜닝이 되는건 읽기작업에 한해서이고 삽입, 수정, 삭제에 대한 성능은 더 떨어집니다.

이렇게 성능 차이가 나는 이유는 인덱스를 구성하기 위한 비용 즉 UPDATE, DELETE, INSERT 쿼리를 날리기 위한 인덱스 구성 비용 즉 추가 연산이 들어갑니다.

그리고 추가적으로 인덱스는 인덱스를 유지하기위해 데이터베이스의 10퍼센트가량되는 저장공간을 추가적으로 확보해줘야 합니다.

음...말하다보니 단점만 얘기했는데 이 단점을 뒤집는 아주 강력한 무기가 있습니다.

바로 검색성능이 미친듯이 향상된다는 것이죠. 제가 아직 경험은 없지만 듣기로는 실무에서 검색이 80~90퍼센트정도를 차지하고 나머지 삽입, 수정, 삭제가 10~20퍼센트정도를 차지한다고 알고 있습니다.

자 그러니까 이 Record Lock은 이 인덱스가 적용된 행에 대해서만 Lock이 일어난다는 것입니다.

만약 인덱스로 설정하지 않았다면 Record Lock이 실행되지 않을까요?

아닙니다 인덱스로 설정하지 않았다면, hidden clustered index를 만들어서 인덱스가 있는것처럼 작동시킵니다. 인덱스가 적용되지 않은것도 인덱스가 적용된것처럼 만들고 작동한다니 이렇게 보니 인덱스를 무조건 써야될 것 같은 느낌도 듭니다.

다음은 핵심인 Gap Lock에 대해서 알아보도록 하죠

Gap Lock

Gap Lock 은 흔히 Gap Locking이라고도 많이 불리는데요. 이 Gap Lock역시 인덱스 레코드 사이의 gap에 대한 Lock을 진행하는 것입니다.

그냥 간단하게 가장 작은 값부터 가장 큰 값까지 모두 Lock을 걸어버린다는 것입니다. 예를 들어서 아래와 같은 쿼리를 날렸다고 가정해봅시다.

SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE

이런 쿼리가 날아갔다면 t.c1 = 15인 값은 Gap Lock에 의해 삽입이 불가능해집니다. Gap Lock은 잠궈놓은 gap만큼의 값중에서 해당 값이 존재하지 않던 존재하던 상관없이 그 지역을 모두 잠궈버립니다.

Gap Lock은 성능과 동시성문제 사이에서 등가교환의 한 부분입니다. 즉, 성능을 끌어올리려고 했더니 동시성 문제가 발생하고 동시성문제를 해결하자니 성능이 낮아지니 두개 개념 사이에서 알맞는 지점을 찾은 것이라고 해석하면 됩니다.

Gap Locking은 단 하나밖에 없는 행을 찾기위해 단 하나의 유니크한 인덱스를 사용하는 행을 잠그는 상태를 강요하지 않습니다. 즉, 행이 하나밖에 없는데 이걸 Gap Locking 할 필요가 없다는 것이죠.

하지만 유니크한 인덱스가 여러개의 컬럼을 포함하고있는 상황이라면 얘기가 다릅니다. 이런 경우엔 Gap Locking이 작동합니다.

Gap Locking은 서로다른 두 트랜잭션에 의해 gap을 잡고있는 Lock이 충돌하는 상황을 논할 필요가 없습니다. 위에서 설명했던 (s)Lock과 (x)Lock의 법칙을 그대로 따라가거든요.

충돌이 난 gap에서 두 트랜잭션이 (s)Lock이다? 그럼 공유가 가능합니다. (공식문서에서는 merged 라고 표현했습니다. 아마 이 두개의 트랜잭션 Lock이 하나가 되는 상황도 고려해봐야 할 것 같습니다.)

하지만 (x)Lock이다? 그럼 공유되지 않고 한쪽의 Lock이 풀릴 때 까지 기다립니다.

Gap Locking은 명시적으로 선언하지 않아도 뒤에서 알아서 작동됩니다. 하지만 만약 격리수준을 Read Committed로 바꿨다면 Gap Locking은 인덱스를 찾고 스캔하는 것은 할 수 없으며 오로지 외래키 제약조건을 체크하거나 중복되는 기본키를 체크하는 용도로밖에 쓸 수 없습니다.

그러니까 Mysql 쓸거면 격리수준 맘대로 바꾸지 마라 이런 뜻인 것 같네요.

그럼 이제 결정적인 Next-Key Locks에 대해서 알아보죠

Next-Key Locks

Next-Key Lock은 Record Lock과 Gap Lock을 결합시킨 것입니다. Record Lock의 특징과 Gap Lock의 특징이 모두 짬뽕된 완벽한 Lock이라는 것이죠. 음...Record Lock과 Gap Lock을 너무 자세히 써놔서 둘이 합쳤다는 말 말고는 딱히 할 말이 없네요.

예시만 보고 넘어가도록 하겠습니다.

만약 10, 11, 13, 20의 값이 삽입되었다고 가정해봅시다. 그럼 Next-Key Lock은 내부적으로 이 모두를 감싸는 gap을 만들고 만든 gap을 감쌉니다. 그리고 Lock을 걸어버리죠.

즉, 10부터 20까지의 레코드를 모두 잠궈버리는 것입니다. 그로인해 다른 트랜잭션이 접근해서 값을 삽입, 수정, 삭제하려고 한다면 이 접근을 막아버립니다. 하지만 앞서 설명했듯이 (s)Lock은 공유가 가능합니다.

이 글귀를 보려고 이 난리를 쳤네요. 결과적으로 Repeatable Read를 쓰는 InnoDB는 Repeatable Read에서 발생하는 부정합 문제인 Phantom Read를 Next-Key Lock을 이용해 막을 수 있습니다.

자 여기까지 Mysql에서 Phantom Read를 해결하는 방법인 Next-Key Lock을 알기위해 알아야하는 Lock에 대한 전반적인 내용을 알아봤습니다.

우리는 이번 포스팅에서 Phantom Read가 무엇인지 확인하였고, Mysql에서 구현한 Lock의 종류(Shared Lock, Exclusive Lock, Record Lock, Gap Lock, Next-Key Lock)에 대해서 알아봤습니다.

사실 이거 말고도 Intention Lock이라던가 AUTO-INC Lock이라던가 Insert Intention Lock이라던가 Predicate Lock도 있었지만... 읽어보지는 않았습니다. Phantom Read를 해결하기 위한 결정적인 Lock은 아닌것 같아서요.

저는 Phantom Read를 해결하기 위한 방법으로 gap-locking만 알고있었는데 이번 기회에 아주 빠삭하게 배운 것 같습니다. Mysql이 Phantom Read를 해결하는 방식만 하나의 포스팅이 나올 것 같아서 쪼갰는데 다행인 것 같네요.

면접에서 이렇게까지 자세하게 물어보지는 않을 것 같습니다. 데이터베이스마다 해결하는 방식이 다르기도 하고 이렇게까지 자세히 알 필요도 없다고 생각합니다. 하지만 Mysql을 사용하는 사람의 입장에서 기본 상식으로는 아주 적합한 내용이었습니다.

이렇게 글 마쳐보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html

Phantom Read 부정합문제 해결방안 In PostgreSQL, MSSQL Server

이번 포스팅에서는 Repeatable Read 격리수준에서 발생하는 부정합 문제인 Phantom Read 를 PostgreSQL과 MSSQL에서는 어떻게 해결했는지에 대해서 포스팅해보겠습니다.

우선 PostgreSQL과 MSSQL을 묶은 이유는 두개의 데이터베이스가 Phantom Read 부정합문제를 같은 방식으로 해결했기 때문입니다.

우선 저는 이번에 PostgreSQL에 대해서 자세히 알게 되었습니다. 얼마전까진 있었는지도 몰랐구요. 그래서 간단하게 PostgreSQL에 대해서 정리하고 본격적으로 해결방안에 대해서 말씀드리겠습니다.

PostgreSQL이란?

PostgreSQL은 RDBMS 중 하나입니다. 기존 Postgres에서 SQL을 지원하게 된 것이 지금의 PostgreSQL이라고 합니다. 초기 Postgres가 지금의 전신인것이죠.

PostgreSQL의 가장 두드러지는 특징은 바로 오픈소스 데이터베이스라는 것입니다. 따라서 데이터베이스를 사용하는데에 필요한 저작권료가 들지 않습니다.

또한 JSON을 핸들링하는데 탁월한 성능을 보인다고합니다. PostgreSQL은 JSON을 만드는 함수를 추상화해놔서 좀 더 효율적으로 사용할 수 있다고 하는군요. 다른 데이터베이스는 JSON을 만드는 함수가 복잡해서 그렇지 못하다고 합니다.

오픈소스라서 그런지 오픈소스의 특징 중 하나인 커스터마이징을 쉽게 할 수 있다는 점과, 트러블 슈팅을 관리하는 것도 유연하게 처리할 수 있다고 합니다.

위의 장점은 PostgreSQL을 실제로 쓰고 있는 분께서 나열한 장점이기 때문에 사실과 다를 수 있습니다.

제 개인적인 생각으로는 장점은 개발자 맘대로 모든 것을 할 수 있다는 점이고 단점은 그만큼 러닝커브가 있다는 것이겠죠.

PostgreSQL의 인지도

제가 PostgreSQL을 들어보지 못한 이유가 있습니다. 이유야 단순하죠 그렇게까지 많이 사용하지 않습니다. 제가 데이터베이스 기본 상식이 조금 부족하긴 했지만 Oracle, Mysql, MongoDB, MariaDB, MSSQL, NOSQL 이정도는 들어봤는데 PostgreSQL을 들어보지 못했습니다.

위의 장점들은 그렇다 치고 저렇게 대단하다면 많은 사람들이 썼을거라는 것이 저의 생각입니다.

사람들이 많이 선택하고 그것이 보편화 되는 이유는 그럴만한 이유가 있다고 생각합니다.

PostgreSQL의 인지도를 알기 위해서 조사를 좀 해보니 StackOverFlow의 실무에서 일하는 개발자를 대상으로 설문조사를 한 적이 있는데 거기서 PostgreSQL이 2등을 했다고 합니다.

하지만 이건 절대 신뢰할 수 없겠더군요. 왜냐면 Oracle이 8등이었기 때문입니다. 제가 아무리 몰라도 Oracle이 8등할 네임벨류는 아닌데말이죠.

흠...이건 좀..
흠...이건 좀..

도저히 신뢰할 수 없어서 좀 더 믿을만하고 현실적인 자료를 조사했습니다.

https://www.statista.com/statistics/1131568/worldwide-popularity-ranking-relational-database-management-systems/
https://www.statista.com/statistics/1131568/worldwide-popularity-ranking-relational-database-management-systems/

거의 대부분의 산업에 대하여 통계를 전문적으로 조사하는 statista라는 사이트를 발견해서 거기서 통계자료를 좀 가져와봤습니다.

이제 좀 현실적인 느낌이 드네요.

기존에 보편적으로 사용하는 3대 데이터베이스인 Oracle, Mysql, MSSQL과는 좀 차이가 있지만 오픈소스 데이터베이스치고는 많이 사용하는 것으로 확인됐습니다.

PostgreSQL을 사용하는 대표적인 회사는 초기 애플, 인스타그램, 레딧, 스카이프 정도가 있습니다. 여기서 초기 애플이라고 한 이유는 애플은 2010년까지 PostgreSQL을 사용하다 Mysql로 바꿨습니다. 왜냐하면 애플은 오라클의 데이터베이스 솔루션에 좀 더 집중을 했기 때문이죠.

자 이제 본격적으로 Phantom Read 에 대해서 알아보죠

PostgreSQL의 PhantomRead 해결방안

처음엔 구글에 Phantom Read solution In PostgreSQL official documentation 라고 쳤는데 안나오더군요 (Mysql은 나왔는데...) 우선적으로 영어로 작성된 블로그들을 살펴보고 크게 얻을만한게 없어서 결국 공식문서를 찾게 되었습니다.

아무래도 Phantom Read가 격리수준에 의한 side effect이기 때문에 isolation level이라는 키워드로 검색해봤더니 나왔습니다.

하지만 공식문서가 좀 부실하더군요... 오픈소스는 다 이런가? 이전에 Mysql 공식문서를 읽고 와서 그런지 확실히 알려주지 않더군요.

격리수준에 대한 내용에서도 Phantom Read에 대한 내용은 코빼기도 보이지 않더군요. 심지어 Repeatable Read는 학술적인 데이터베이스 문헌과 다른 데이터베이스에서 사용한 Snapshot Isolation을 이용해 구현했다. 라고 하더군요...

처음에 academic database literature에서 순간 architecture하고 헷갈려서 구글링해봤더니 무슨 논문이 쏟아져나오길래 다시 눈비벼서 확인해보니 학술적인 데이터베이스 문헌이었습니다.

그래도 하나 건진건 있었습니다. 바로 Snapshot Isolation이죠.

우선 Snapshot(이하 스냅샷)에 대해서 알아보죠.

위키피디아에서 나온 스냅샷은 다음과 같습니다.

In computer systems, a snapshot is the state of a system at a particular point in time.

컴퓨터 사이언스에서 스냅샷은 특정 시간에 특정 지점에대한 시스템의 상태이다. 즉, 특정한 지점에서의 그 상태 바로 그것이죠.

Snapshot Isolation

스냅샷 격리수준은 트랜잭션의 모든 쿼리가 트랜잭션이 시작되는 시점의 데이터베이스의 상태를 기반으로 작동하는 방식입니다. 앞서 봤던 스냅샷의 정의와 일치하네요.

스냅샷 트랜잭션의 기본 데이터 행이나 데이터 페이지에서는 잠금이 인식되지 않습니다. 따라서 이전에 완료되지 않은 트랜잭션에 의해 차단되지 않고 다른 트랜잭션을 실행할 수 있습니다.

데이터를 수정하는 트랜잭션은 데이터를 읽는 트랜잭션을 차단하지 않으며, 데이터를 읽는 트랜잭션은 일반적으로 데이터를 쓰는 트랜잭션을 차단하지 않습니다.

이러한 비차단 동작 덕분에 복잡한 트랜잭션에 대한 교착 상태의 가능성이 크게 줄어듭니다.

즉, 우리가 Mysql에서 Phantom Read를 해결할 때 Shared Lock과 Exclusive Lock에 대해서 확인한 내용과 비슷한 내용입니다. 하지만 Mysql의 (s)Lock과 (x)Lock과는 완전히 다릅니다.

Mysql에서의 (s)Lock은 같은 읽기 트랜잭션인 경우만 공유가 가능하고 (s)Lock과 (x)Lock이 만나면 (x)Lock은 락이 풀릴때까지 기다려야합니다.

스냅샷 격리수준은 낙관적 락을 채택하고 있습니다.

낙관적 락, 비관적 락

이 두개는 간단한 내용이니 빠르게 짚고 넘어가겠습니다.

낙관적 락은 트랜잭션의 동시성문제가 발생하지 않는다고 생각하고 두 트랜잭션의 충돌에 의해 동시성 문제가 일어나면 시스템적으로 오류가 발생했다고 판단하고 에러를 뱉으며 데이터베이스 서버가 죽어버립니다.

비관적 락은 반대로 트랜잭션의 동시성문제가 반드시 발생한다고 생각하고 작동하는 락입니다. 두 트랜잭션의 충돌에 의해 동시성 문제가 일어나면 무조건 해결해야하는 방식입니다. 흔히 Mysql의 방식이 있죠. 복습하자면 Mysql에서는 Record Lock과 Gap Lock을 합친 Next-Key Lock을 이용해 이러한 동시성문제에 대해 해결했습니다.

스냅샷 격리가 낙관적 락을 채택하고 있기 때문에 만약 두 트랜잭션에 의해 동시성문제가 발생하려고 하면 에러를 뱉습니다.

즉, PostgreSQL과 MSSQL은 스냅샷 격리를 이용해 Repeatable Read 격리수준에서 Phantom Read 부정합 문제를 해결했다는 결론이 나옵니다.

여기까지 PostgreSQL에 대해 알아봤고, 스냅샷 격리수준에 대해서 알아봤습니다. 흠... 뭔가 시원하게 문제가 해결되지 않은 느낌이 들긴 합니다. 하지만 스냅샷 격리라는 새로운 내용에 대해서 알게되었고, PostgreSQL의 전반적인 내용에 대해서 알게된 것은 나쁘지않은 성과라고 생각합니다.

다음은 데이터베이스들의 왕 Oracle입니다. 아마 가장 대중적으로 사용하는 데이터베이스가 아닐까 싶습니다. 다음 포스팅에서는 Oracle에서 Phantom Read를 어떻게 해결했는지에 대해서 알아보도록 하겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.postgresql.org/docs/current/transaction-iso.html

=> PostgreSQL 공식문서

https://learn.microsoft.com/ko-kr/dotnet/framework/data/adonet/sql/snapshot-isolation-in-sql-server

=> 스냅샷 격리수준 MS 공식문서

https://en.wikipedia.org/wiki/Snapshot_(computer_storage)

=> 스냅샷 In 위키피디아

https://www.statista.com/statistics/1131568/worldwide-popularity-ranking-relational-database-management-systems/

=> RDBMS 통계

https://learnsql.com/blog/companies-that-use-postgresql-in-business/

=> PostgreSQL을 사용하는 대표적인 회사

https://www.quora.com/What-differentiates-PostgreSQL-from-other-RDBMS-systems

=> PostgreSQL과 다른 데이터베이스의 차이점

Phantom Read 부정합문제 해결방안 In Oracle

이번 포스팅에서는 Repeatable Read 격리수준에서 발생하는 부정합 문제인 Phantom Read를 Oracle에선 어떤 방식으로 해결했을지에 대해서 알아보도록 하겠습니다.

Oracle은 전 세계에서 가장 많이 사용하는 데이터베이스 중 하나로 오랜시간 사랑받은 데이터베이스입니다.

Phantom Read 해결방안 In Oracle

데이터를 잠근다는 것은 같은 리소스에 접근하는 트랜잭션 사이에서 일어날 수 있는 해를 끼치는 상황들을 막아줍니다.

이러한 모든 상황에서 오라클은 SQL문이 처리될 때 필요한 락을 자동적으로 얻을 수 있습니다. 그래서 오라클을 사용하는 유저는 세세한 것까지 걱정할 필요가 없어지죠.

오라클은 자동적으로 가장 낮은 수준의 제한정도를 가장 높은 수준의 데이터 정합성으로 바꿔줍니다.

공식문서인데 뭔가 광고글을 보는 것 같은 느낌이 들긴 합니다. 이런 공식문서는 또 새로운 느낌이네요.

아무튼 오라클은 데이터를 잠그기위해 Exclusive Lock(이하 (x)Lock)과 Shared Lock(이하 (s)Lock)을 사용합니다. 이건 어디서 보던거죠? Mysql에서 일반적인 Lock을 구현한 것 중에 가장 기본이 되는 Lock입니다. (s)Lock은 읽기에 대한 락이고 (x)Lock은 삽입, 수정, 삭제에 대한 락이었습니다.

하지만 Mysql과 다른 점이 있는데 바로 Lock Duration을 사용한다는 점입니다.

Lock Duration

모든 락은 트랜잭션의 지속동안 동시성문제인 Dirty Read, Lost Update, 그리고 DDL 실행에의한 해를 끼치는 문제를 보호하는 상태를 획득합니다.

따라서 트랜잭션은 오리지널 트랜잭션이 커밋이나 롤백을 완전히 수행할 때까지 계속해서 기다립니다.

그러니까 트랜잭션이 전부 수행될 때까지 락을 걸어두고 접근하지 못하게 막는다는 의미인 것 같습니다.

오라클은 Lock Escalation을 사용하지는 않습니다. Lock Escalation은 수많은 Lock들이 다양한 상황에서 획득될 때, 만약 한명의 유저가 테이블에서 많은 행들을 잠궈놓았다면 몇몇 데이터베이스는 자동적으로 그 유저의 행에 걸린 Lock들을 한단계 증가시킵니다.

이렇게 증가시키게 되면 이 락들은 줄어드는게 아니라 오히려 이렇게 잠긴 락들이 증가하게 됩니다.

이런 경우는 데드락을 심각하게 초래할 수 있습니다.

데드락에 대해서는 아래의 포스팅에서 자세하게 설명이 되어있습니다.

https://coding-review.tistory.com/302

또한 Mysql의 Record Lock과 비슷한 DML Lock이 있고, 행을 잠그거나 테이블을 잠그는 것 또한 가능합니다. 이것은 모두 트랜잭션의 연산 동안에 이루어집니다.

여기까지 Oracle이 Phantom Read를 어떻게 해결했는지에 대해서 알아봤습니다.

Oracle은 대단히 뭔가 하지는 않네요. 뭔가 특별한 기능을 사용하는 것은 Mysql이 유일하고 Oracle, PostgreSQL, SQL Server는 단순한 방법으로 구현해 놓은 것 같습니다.

격리수준에서 발생하는 부정합 문제만을 놓고 봤을 때는 Mysql이 다른 RDBMS보다 더 나은 것 같습니다.

이번에는 짧게 마무리짓고 넘어가도록 하겠습니다. 이번 포스팅도 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://docs.oracle.com/cd/B13789_01/server.101/b10743/consist.htm

NoSQL 이란?

이번 포스팅에서는 흔히 NoSQL이라 불리우는 비관계형 데이터베이스에 대해서 알아보도록 하겠습니다.

원래는 NoSQL에 대해서 공부할 생각이 없었지만 트위터에서 개발자가 꼭 알아야할 내용중에 NoSQL이 있는 것을 보고 내가 생각했던것만큼 중요하지않은게 아니구나 싶어서 공부하게 되었습니다.

이번 포스팅에서는 NoSQL이 무엇인지, 기존 관계형 데이터베이스와 무엇이 다른지, NoSQL의 타입, 마지막으로 MicroService Architecture의 저변확대로인한 NoSQL의 입지에 대해서 알아보겠습니다.

DBMS의 역사

우리는 NoSQL을 알아보기에 앞서 NoSQL 전에 관계형 데이터베이스 그리고 더 이전인 과거 이야기부터 시작해야합니다.

관계형 데이터베이스 이전, 기업들은 데이터를 관리하는 방법으로 위계에따른 데이터베이스 시스템을 채택했습니다. 이 구조는 마치 트리구조와 같았죠. 이런 트리구조의 위계적인 DBMS는 유저로하여금 많은 양의 데이터를 조직할 수 있게 되었습니다.

하지만 이러한 구조는 매우 복잡했고, 특정한 애플리케이션에서 독점적으로 사용되었고, 많은 제한이 있었죠.

이러한 제한은 결국 많은 개발자들을 관계형 데이터베이스를 개발하도록 이끌었습니다. 그리고 이런 변화는 데이터를 테이블에 저장하여 관리하는 방향으로 진화하였죠. SQL문은 관계를 맺은 데이터의 상호작용을 위한 인터페이스를 제공했습니다.

하지만 시간이 지남에따라 데이터셋의 사용에 대한 더 빠르고, 더 많은 확장이 요구되었고 이러한 요구는 점점 더 기술의 병합이 중요하게 만들었습니다.

개발자들은 관계형 데이터베이스보다 더 유연한 것을 원하게 되었고 NoSQL이 그 대안이 되었습니다.

그렇다고 NoSQL이 RDBMS을 대체할 수 있다는 것은 아닙니다. 예를 들어서 회사에서 주문 데이터를 관리하고 있다고 가정해봅시다. 관계형 데이터베이스에선 각각의 테이블이 고객 데이터와 주문 데이터 그리고 상품 데이터를 분리해서 관리할 수 있습니다. 그리고 각각의 Key로 Join연산을 통해 데이터를 보기쉽게 정렬할 수도 있죠.

이렇게 쉽게 데이터를 저장하고 검색할 수 있는만큼 상당한 메모리를 잡아먹죠. 만약 메모리가 더 필요하면 관계형 데이터베이스에선 수직적인 확장밖에 할 수 없습니다. 수평적으로는 할 수 없죠. 이 의미는 하드웨어의 확장을 하지 않으면 확장이 되지 않는다는 것이죠.

NoSQL

NoSQL은 비관계형 데이터베이스라고도 불립니다. 테이블과의 연결을 필요로하지 않는다는 의미이기도합니다. 이러한 특징은 데이터베이스를 수평적으로 확장하기에 정말 유연한 구조를 가지고 있습니다.

뒤에서 더 얘기하겠지만 NoSQL을 단독적으로 사용하기에는 보안이나 여러가지 이유때문에 부담이 될 수 있지만 요즘 회사들은 MicroService Architecture를 적극적으로 도입해 관계형 데이터베이스와 비관계형 데이터베이스를 적절히 섞어서 사용합니다.

이 얘기를 본격적으로 하기 전에 NoSQL의 저장방식에 대해서 알아보도록 하겠습니다.

NoSQL의 데이터 저장방식에는 크게 네가지가 있습니다. Key-Value Store, Document Store, Wide-column Store, Graph Store 이렇게 네가지이죠.

Key-Value Store

NoSQL 데이터베이스에서 전형적으로 간단하게 고려되는 저장방식입니다. 스키마가 적은 데이터모델을 사전에서 사용하는 Key-Value 방식대로 조직화합니다. 각각의 아이템은 Key와 Value를 가지고 흔히 Key는 관계형 데이터베이스에서 기본키에 해당하는 값이 저장되고 Value에는 그 Key에 해당하는 정보들이 배열로 들어갑니다.

Key-Value 저장 방식은 데이터가 단순하면 단순할수록 효율이 올라가며 복잡한 데이터는 저장하기에 부담이 됩니다. 따라서 데이터가 단순한 캐싱이나 세션을 구현할 때 주로 사용합니다. 대표적인 오픈소스 Key-Value 데이터베이스로는 Redis와 Memcached가 있습니다.

https://en.wikipedia.org/wiki/Key-value_database
https://en.wikipedia.org/wiki/Key-value_database

Document Store

이름에서도 유추할 수 있듯이 이 저장방식은 데이터베이스에 데이터를 문서형태로 저장하는 방식입니다. 정형데이터와 비정형데이터의 사이 수준의 데이터를 관리하는데 도움을 주고 흔히 JSON이나 XML 혹은 BSON으로 데이터를 저장합니다.

개발자는 document를 통해 하나하나 매치할 필요가 없는 데이터 스키마 때문에 더 높은 유연성을 얻을 수 있습니다. 그러나 데이터 충돌엘 따르는 복잡한 트랜잭션 문제가 생길 수 있습니다.

흔히 사용되는 사용처는 컨텐츠 관리 시스템이나 유저프로필을 관리하는데에 사용됩니다. 가장 대표적인 데이터베이스는 MongoDB입니다.

https://inyl.github.io/programming/2017/05/09/database.html
https://inyl.github.io/programming/2017/05/09/database.html

Wide-column Store

이러한 데이터베이스 저장 방식은 Key-Value 저장방식과 Document 저장방식의 결점을 해결하기위해 나온 방식입니다. 그러나 관리하기위해 더 복잡한 시스템이 필요하기 때문에 새로운 팀이나 새로운 프로젝트에서는 추천하지않습니다.

Wide-column 저장방식은 말그대로 column을 유연하게 설계할 수 있습니다. 어떤 데이터는 아이디-패스워드-이름을 저장할 수 있고, 어떤 데이터는 아이디-패스워드-이름-주민등록번호 이런식으로 column을 확장할 수 있습니다.

Wide-column 저장 방식의 대표적인 데이터베이스는 Apache의 HBase와 Cassandra가 있습니다.

https://database.guide/what-is-a-column-store-database/
https://database.guide/what-is-a-column-store-database/

Graph Store

Graph 저장방식은 말 그대로 그래프의 형태로 데이터를 저장합니다. 데이터 하나하나는 노드에 저장되고 노드별로 연결되어 노드의 관계성을 표현할 수 있습니다.

Graph 저장방식은 흔히 네트워크 연결을 관리하거나 이러한 데이터를 저장하는데에 사용됩니다. Graph 저장방식의 대표적인 데이터베이스는 Neo4j가 있습니다.

https://database.guide/what-is-a-graph-database/#more-896
https://database.guide/what-is-a-graph-database/#more-896

NoSQL의 이점

NoSQL 데이터베이스는 특정한 상황에서 뛰어난 장점을 가지고 있습니다.

• Cost-effectiveness : RDBMS를 high-end로 유지하기 위해서는 값비싼 코스트가 들어갑니다. 라이센스의 구매를 요구할 수도 있고, 데이터베이스 매니저를 다뤄야하며 강력한 하드웨어를 요구합니다. NoSQL 데이터베이스는 코스트를 최소화하기위해 리소스를 할당하는 것을 더 잘 할 수 있습니다.

• Flexibility : NoSQL는 수평적인 확장과 데이터모델의 유연성을 가지고 빠르게 변화하는 데이터의 큰 볼륨을 관리할 수 있습니다. 에자일 개발을함에 있어서 큰 도움이 되고 자주 코드를 푸시할 수도 있습니다.

• Replication : 레플리케이션은 A데이터베이스에서 B데이터베이스로 데이터를 복사하는 것을 말합니다. NoSQL은 다중 서버에서 데이터를 저장하거나 복사하는 것을 효율적으로 수행할 수 있습니다. 만약 서버가 오프라인이 되면 데이터가 유실되는 것을 막을 수도 있습니다.

• Speed : NoSQL은 모든 유저로하여금 더 빠르게 그리고 더 에자일하게 데이터를 저장하고 수행할 수 있습니다. 현대사회에서 NoSQL데이터베이스를 일반적으로 선택하게하는 큰 요인 중 하나입니다. 복잡한 웹 애플리케이션이나 이커머스 사이트나 모바일 애플리케이션에서 큰 효과를 볼 수 있습니다.

NoSQL의 성능

위의 그림은 데이터베이스별 성능 테스트를 한 결과입니다. 물론 제가한 것은 아니구요... NoSQL에 대한 논문을 찾아보다가 재밌어보여서 읽고있었는데 흥미로운 결과가 있길래 가져와봤습니다.

이 논문의 저자는 흔히 데이터베이스의 성능을 테스트하는 것은 일반적인 real-world에서의 환경을 반영할 수 없었으며 때문에 이 저자는 실제로 사용되는 데이터를 기반으로 성능 테스트를 했다고 합니다.

위의 그림은 빨간색이 RDBMS, 초록색이 NoSQL, 노란색이 NewSQL입니다. 회색은 볼 필요 없습니다. NewSQL은 그냥 단순하게 RDBMS의 장점과 NoSQL의 장점을 한데 모은 데이터베이스계의 신기술이라고 할 수 있습니다. 동그라미의 크기가 크면 클수록 성능이 더 좋은 데이터베이스라는 의미입니다.

그림만 보면 위에서도 언급했지만 빠른 속도를 보여줍니다. 아무래도 NoSQL만의 수평적인 확장으로인해 데이터를 쉽고 빠르게 접근하고 읽고 쓸 수 있는 것 같습니다.

저자도 설명했지만 데이터베이스 성능같은 것은 많은 요인에 의해 변할 수 있습니다. CPU의 코어 개수에도 영향을 크게 받고, 스레드가 멀티스레드인지도 영향을 많이받고, 유저가 동시에 요청을 하는 경우에도 큰 영향을 보입니다.

하지만 사업을 새로 시작할 때나 우리 애플리케이션의 도메인에 새로운 데이터베이스를 고려하고 있다면 위의 결과를 충분히 검토하고 결정할 수도 있을 것 같습니다.

MicroService Architecture의 저변확대에 따른 NoSQL의 최신동향

구글 트렌드에서 검색한 NoSQL 검색어 개수 추이입니다. NoSQL은 1998년도에 SQL을 사용하지 않는 경량 데이터베이스 의미로 처음 등장했고, 2009년도 오픈소스 분산데이터의 논의 이벤트를 위한 이름으로 NoSQL이 지금과 같은 의미로 사용되었습니다.

분산 데이터베이스와 함께 2015년에 오픈소스로 등장한 Docker와 MicroService Architecture의 저변확대로 NoSQL을 더 많은 곳에서 사용할 수 있게 되었습니다.

NoSQL은 RDBMS에 비해 속도가 빠르고 수평적으로 확장하는데 큰 이점이 있지만 복잡한 트랜잭션을 사용해야 하거나 보안상의 문제로 사용하는데 큰 부담이 있었던 것은 사실입니다.

하지만 MSA의 등장으로 여러 도메인에서 데이터베이스 서버를 나눠 선택할 수 있었고, 기업들은 각각의 도메인에서 최대로 효율을 낼 수 있는 (서버비가 적게드는) 데이터베이스를 선택하였습니다.

전체적인 데이터베이스 사용추이에 대해서는 아직 NoSQL은 다른 RDBMS에 비해 발끝에도 못미치는 수준이긴 합니다만 NoSQL이 가진 파격적인 장점과 함께 MSA의 저변확대로인해 수많은 기업에서 NoSQL을 적절한 도메인과 맞춰 사용하는 추세인것은 확실해보입니다.

여기까지 NoSQL에 대해서 알아봤습니다. 어떠신가요? 저는 RDBMS만 다뤄왔던터라 NoSQL에 대해서는 금시초문이었는데 이번 기회에 좋은 공부가 되었던 것 같습니다. 왜 개발자가 꼭 알아야하는 데이터베이스 기본지식에 NoSQL이 있었는지 대충 감은 잡은 것 같습니다.

이후 NoSQL에서도 많이 사용하는 Redis나 Memcached, 그리고 MongoDB에 대해서도 포스팅 할 예정입니다. 많은 관심 부탁드리면서 여기서 글 마쳐보도록 하겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.ibm.com/topics/nosql-databases

=> IBM NoSQL 공식문서

https://www.mongodb.com/ko-kr/nosql-explained

=> MongoDB NoSQL 공식문서

https://code-lab1.tistory.com/53

=> 이미지 출처

https://arxiv.org/pdf/2301.01095.pdf

논문 제목 : Database management system performance comparisons : A systematic survey

저자 : Toni Taipalus

doi : https://doi.org/10.48550/arXiv.2301.01095

https://koreascience.kr/article/JAKO201530360959819.pdf

논문 제목 : NoSQL 데이터베이스 최신 동향

저자 : 권준호

doi : https://koreascience.kr/article/JAKO201530360959819.pdf

데이터베이스 튜닝 방법 (Clustering, Replication, Sharding)

오늘 포스팅에서는 데이터베이스 튜닝 방법인 Clustering, Replication, Sharding에 대해서 알아보도록 하겠습니다.

제가 설명할 이 세가지는 바로 분산시스템을 구현하는 방법들입니다. 분산시스템 혹은 분산환경이라고 불리는데, 여러개의 데이터베이스로 쪼개는 작업을 일컫습니다. 즉, MVCC와 마찬가지로 특별한 기술이 아닌 방법론으로 이 방법론들을 개발자들은 Architecture라고 하는 듯 합니다. 따라서 이 셋을 구현하는 방법은 정말 여러가지로 많고 우리는 그중에서 가장 대표적인 것들을 위주로 알아볼 예정입니다.

Clustering

Clustering (이하 클러스터링)은 하나의 데이터베이스 인스턴스 혹은 시스템 서버를 더많은 과정으로 쪼개는 작업입니다. 보통 데이터베이스 클러스터 안에서 다중 데이터베이스 인스턴스는 보통 마스터라고 불리는 단 하나의 데이터베이스에 의하여 관리됩니다.

클러스터링을 구현하는 것이 특히 큰 시스템에서 필요할지도 모릅니다. 데이터베이스 서버를 하나만 두는것은 모든 고객의 요청을 핸들링하기 쉽지않습니다. 이러한 이슈를 해결하기 위해 다중 데이터베이스 서버를 병렬로 사용하는 것이 시스템적으로 필요합니다.

이러한 기술을 사용하는 것은 더 많은 유저를 핸들링하거나 시스템적인 실패를 극복하는데 수많은 이점을 가져다줍니다. 이 아키텍처의 단 한가지 주요한 단점은 시스템을 구현하는 것이 복잡하다는 것입니다.

데이터베이스 클러스터링의 장점

• 시스템의 load balancing (이하 로드 밸런싱) 을 잘 잡아준다 : 로드 밸런싱은 여러개의 다른 리소스에 의한 수많은 주어진 일들을 분산처리 하는 것입니다. 이러한 작업의 목적은 전체적인 실행 과정에서 시스템을 더 효율적으로 만들기 위함입니다.

• 더 많은 고객을 유치할 수 있다 : 수많은 기업들이 데이터베이스 클러스터링에 투자하는 가장 주된 이유는 확장성 때문입니다. 더 많은 데이터베이스 서버를 늘림으로써, 회사는 전 세계의 수많은 유저들을 더 쉽게 핸들링을 할 수 있습니다.

• 시스템 전반의 데이터 여분을 남길 수 있다 : 데이터를 여분으로 남겨두면 유저가 한번에 많이 몰려도 여분의 데이터를 가지고 처리할 수 있기 때문에 병목 현상이 일어나지 않습니다.

• 애플리케이션 다운에 대한 리스크를 극복할 수 있다 : 만약 애플리케이션이 하나의 데이터베이스 서버만을 두고 서비스한다면, 그 서버가 죽어버렸을 때 전체 서비스가 죽어버려서 금전적으로 큰 손실을 일으킬 수 있습니다. 하지만 클러스터링을 통해 데이터베이스 서버를 많이 늘리면 하나가 죽더라도 옆에거 쓰면 되니까 안정적입니다.

데이터베이스 클러스팅의 단점

가장 큰 단점은 SAN이라 불리는 Storage area Network인 DB 스토리지가 하나밖에 없기때문에 이 DB스토리지가 장애가 나는 경우, 모든 데이터베이스 서버가 죽어버리면서 모든 데이터가 소실 혹은 유실되는 상황이 발생할 수 있습니다.

또한 모든 DB 서버를 active로 놓고 돌리는 경우 이전 하나의 서버가 부담하던 부하를 여러개의 서버가 나눠 감당하므로 CPU, Memory 자원의 부하도 적어지게 됩니다.

하지만 DB 스토리지가 여러개의 DB서버를 공유하기 때문에 병목현상이 일어날 수 있고, 이전보다 많은 비용이 투자되어야 한다는 단점이 있습니다.

따라서 이 경우를 대비하여 여러대의 DB스토리지를 구축해놓을 필요가 있습니다.

이러한 해결책으로 등장한 것이 바로 Replication입니다.

Replication

데이터베이스 레플리케이션은 단순히 얘기해서 DB스토리지를 잔뜩 카피해서 데이터 손실 혹은 유실을 막는 방법입니다. 그래서 모든 유저가 같은 레벨의 정보를 공유할 수 있죠.

사용자는 마스터 DB서버에 DML 작업을 하면 마스터 DB는 그 데이터를 Slave 쪽에 데이터 복제를 합니다. 이를 통해 DB스토리지가 한대여서 발생할 수 있는 데이터 손실을 방지할 수 있습니다.

하지만 이러한 방식의 단점은 Slave인 DB서버가 늘게 된다는 점입니다.

그래서 마스터 DB서버에는 INSERT, DELETE, UPDATE 작업을 하고, Slave DB서버에는 SELECT 를 함으로써 DB서버의 부하를 분산시킬 수 있습니다.

Replication의 장점

• 데이터베이스 부하를 줄여준다 : 복제된 데이터 덕분에 서버 전반에 걸쳐서 확장할 수 있습니다. 복제된 데이터는 어떤 하나의 DB서버가 데이터를 위해 쿼리를 사용하면 압도되는 상황을 제거할 수 있습니다.

• 효율성인 측면에서 우수하다 : 쿼리때매 생기는 부하가 적어진 서버는 향상된 성능을 기대할 수 있습니다.

• 높은 유용성 : 같은 데이터를 여러 서버에 분배하는 것은 높은 유용성을 가져다 줍니다. 이것은 만약 한 DB서버가 죽어버리면, 전체 시스템이 성능을 유지할 수 있습니다.

Replaction의 단점

• 데이터 로스가 생길 수 있다 : 데이터 로스는 데이터베이스의 올바르지 못한 데이터나, 업데이트가 복제될 때 레플리케이션 동안 일어날 수 있습니다. 결과적으로 중요한 데이터가 삭제될 수 있습니다. 이러한 일은 데이터의 퀄리티를 증명하는데 사용되는 primary key가 오작동하거나 올바르지 못한 경우에 일어날 수 있습니다.

• 데이터 부정합문제 : 위의 경우와 비슷하게 올바르지 못한 복제된 데이터는 다른 데이터와 일치하지 않는 경우가 생길 수 있습니다.

• 여러대의 서버가 필요하다는 점 : 여러대의 DB스토리지를 운영한다는 것은 유지하는데 코스트가 많이 드는 작업입니다. 또한 데이터가 너무 많은 경우에는 여러대의 Slave DB서버에서 데이터를 찾는 시간이 오래걸릴 수 있습니다.

이러한 문제를 해결하기 위해 나온 방법이 바로 Sharding입니다.

Sharding

Sharding (이하 샤딩) 은 여러대의 데이터베이스에 걸친 하나의 데이터셋을 분산하기위한 방법입니다. 그리고 샤딩은 여러대의 기기에 데이터를 저장할 수 있게 도와주죠. 샤딩은 큰 데이터셋을 더 작은 크기인 chunk (이하 청크)에 분리해서 담습니다. 그리고 여러개의 데이터 노드에 저장하죠.

여러 기기에 데이터들을 분산시키는 것과 비슷하게 샤드된 데이터베이스는 하나의 기기가 할 수 있는 것 보다 더 많은 요구를 처리할 수 있습니다.

샤딩은 수평적인 확장으로 알려진 확장의 형태입니다. 추가적인 노드들은 load를 분할하기 위해 채워집니다. 수평적인 확장은 빅데이터를 처리하기위해 거의 제한없는 확장성을 할 수 있게 해줍니다.

이러한 수평적인 확장은 수직적인 확장과 대조적인데, 사실 성능적인 부분에서 강력한 향상을 원한다면 수직적인 확장이 더 도움이 됩니다. 예를 들어 CPU를 더 늘린다거나, RAM을 더 늘린다거나 스토리지 저장용량을 더 늘린다거나 이런 작업이 성능적으로는 더 우위를 점합니다.

하지만 이런 수직적은 확장은 비용이 많이들고 무한히 늘릴 수 없기 때문에 한계가 있죠. 그런 한계를 맞이했을 때 샤딩을 고려해볼만 합니다.

왜냐하면 샤딩은 단점이 하나도 없는 혁신적인 아키텍처가 아닙니다. 샤드를 세팅하고 유지하는데 굉장히 복잡하고 오버헤드가 발생하기 쉽습니다. 따라서 샤딩을 고려하기 전에 다른 대안책을 고려한 후에 이후 그럼에도 추가적인 성능이 필요하다면 그때 고려해볼만 합니다.

먼저 샤딩을 고려하기전 대안책을 몇가지 소개해드리도록 하겠습니다.

• 수직적인 확장 : 애플리케이션을 가장 간단하고 강력하게 성능향상시킬 수 있는 것이 바로 수직적인 확장입니다. RAM을 더 늘리고, CPU를 더 늘리는것 말입니다. 이렇게 수직적으로 확장하게 되면 사용가능한 DB스토리지가 늘어나고, 데이터베이스가 감당할 수 있는 트래픽도 늘어납니다. 때문에 샤딩을 고려하기전 가장 간단하게 생각할 수 있는 부분입니다.

• 서비스별로 분화시키기 : 서비스별로 분화한다는 의미는 쉽게 설명해서 MicroService Architecture (이하 MSA)를 고려하라는 의미입니다. 혹은 DB스토리지를 클라우드 저장소로 옮겨버리는 작업도 이 카테고리에 포함됩니다.

• 클러스터링 / 레플리케이션 : 샤딩을 고려하기전 레플리케이션이 고려되지 않았다면 레플리케이션을 생각해볼만 합니다. DB스토리지와 DB서버를 복제함으로써 읽기성능만큼은 늘린 스토리지, 서버만큼 향상되게 되어있습니다.

위의 것들을 다 고려했음에도 추가적인 성능을 원한다면 그때서야 샤딩을 생각해볼만 합니다.

샤딩의 장점

• 읽기/쓰기 작업의 수율이 올라간다 : 저는 수율이라고 해석하는게 이해하기도 좋고 해석하기도 편한데 어떤 분들은 throughput이라고 단어 그대로 말하는것이 더 이해되시는 분들도 있을겁니다. 아무튼 여러개의 샤드를 통해 데이터셋을 분산시킴으로써 읽기와 쓰기실행에 필요한 capacity가 single shard로 구현한 것 보다 더 향상됩니다.

• 스토리지 capacity가 증가됩니다 : 앞서 설명했던 것과 비슷하게 많은 수의 샤드를 증가시킴으로써 전반적인 스토리지 capacity를 증가시킬 수 있습니다. 이렇게 하면 거의 제한없는 확장성을 맛볼 수 있습니다.

• 높은 유용성 : 샤드는 두가지 방면에서 높은 유용성을 제공합니다. 첫번째는 각각의 샤드가 복제된 세트이기 때문에 각각의 데이터는 복제된 상태가 유지됩니다. 두번째로는 만약 전체 샤드가 사용불가능이 된다면, 데이터는 분산되어있기 때문에 전체 데이터베이스는 다른 샤드에 대한 스키마의 일부분을 가지고 부분적으로 기능을 유지할 수 있습니다.

샤딩의 단점

• 쿼리 오버헤드가 발생한다 : 각각의 샤드화된 데이터베이스는 어떻게 적절한 샤드로 쿼리 실행이 유도되는지 알고있는 기기와 서비스로 분리되어야합니다. 이러한 작업은 모든 실행에 추가적인 레이턴시가 요구합니다. 게다가 데이터들은 다수의 샤드를 통해서 수평적으로 분할된 쿼리를 요구한다면 라우터는 결과물을 다같이 병합하거나 각각의 샤드들을 가져와야 하기때문에 하나하나 실행을 할 때마다 꽤 비싼 코스트가 들어가고 느린 응답시간을 초래합니다.

• 관리가 복잡하다 : 단하나의 샤드화되지않은 데이터베이스는 그 데이터베이스 하나만 유지하는데 코스트가 들어갑니다. 데이터베이스가 샤드화 되어있다면 각각의 샤드를 유지하기위해 적잖은 관리가 필요합니다. 거기다가 이 데이터베이스들이 레플리케이션까지 사용되고 있다면 각각의 데이터들을 업데이트 하는것이 복제된 노드들 전반에 걸친 중복된 작업을 하게 됩니다.

• 기반시설을 유지하기위한 코스트가 증가한다 : 샤딩은 싱글 데이터베이스 서버보다 기기나 계산하는 능력이 추가적으로 요구합니다. 각각의 추가적인 샤드들은 높은 코스트들을 가져옵니다. 분산된 데이터베이스 시스템의 비용이라는 것은 특히 최적화를 빼먹는다면 성능상 상당히 안좋은 결과를 초래할 수 있습니다.

샤딩을 구현하는 방법에는 크게 세가지가 있습니다. Hash Sharding, Dynamic Sharding, Entity Group Sharding 이렇게 세가지이죠. 각각에 대해 자세히 알 필요는 없을 것 같습니다. 간단하게 짚고 넘어가도록 하겠습니다.

Hash Sharding

샤드의 수 만큼 Hash 함수를 사용해서 나온 결과에 따라 DB 서버에 저장하는 방식으로 정말 구현이 간단하다는 것이 장점이 됩니다.

단점은 확장성이 낮다는 것인데 만약 DB서버가 추가될 경우 Hash 함수가 변경되어야 하므로 기존에 저장된 데이터의 정합성이 깨지게됩니다.

이러한 확장성을 해결하기 위해 나온 것이 바로 Dynamic Sharding입니다.

Dynamic Sharding

Dynamic Sharding은 로케이터 서비스를 이용하여 테이블 형식의 데이터를 바탕으로 샤드를 결정해서 적절히 저장하는 방식을 말합니다. 레코드를 기준으로 일정한 간격을 두고 각각의 간격을 Shard Key로 부여하여 저장하는 방식입니다.

장점으로는 Hash Sharding과 달리 단순히 레코드의 추가에 따른 키만 추가해주면 되므로 확장이 쉽다는 것이지만 단점으로는 로케이터 서비스가 단일 장애점이 되므로 로케이터 서비스에 장애가 발생하면 나머지 샤드 또한 문제가 발생하게 됩니다.

Entity Group Sharding

앞서 설명한 두개의 Sharding은 NoSQL에 최적화되어있지만 이 방식은 RDBMS와 잘 어울리는 방식입니다. 엔티티 그룹으로 샤딩을 하는 것은 연관성이 있는 (관계를 가지고 있는) 엔티티를 한 샤드에 두는 방식입니다.

장점으로는 같은 샤드에 있는 데이터를 조회할 때는 효과적이지만, 다른 샤드에 있는 데이터를 함께 조회할 때는 오히려 성능이 떨어지는 단점이 있습니다.

여기까지 Clustering, Replication, Sharding에 대해서 알아봤습니다. 논문을 읽다가 Sharding이라는 단어가 나와서 사전지식 공부겸해서 블로그 포스팅을 해봤습니다.

너무 깊은 곳까지 들어와버린 상황인데... 면접에서 이런건 당연히 안물어볼 것 같구요. 신입때는 안쓴다 뿐이지 샤딩이라는 이 튜닝 방식 그리고 앞서 설명했던 클러스터링과 레플리케이션은 실무에서 자주 볼 것 같은 예감이 듭니다. 지금 공부해둬서 나쁠 것 없지요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.harperdb.io/post/what-is-database-clustering

https://www.techtarget.com/searchdatamanagement/definition/database-replication

https://www.mongodb.com/features/database-sharding-explained

CAP 정리와 한계 PACELC 정리

이번 포스팅에서는 Brewer (브루어)의 CAP정리에 대해서 알아보도록 하겠습니다. CAP정리가 20년도 더 된 정리이기 때문에 그걸 보완한 PACELC 정리에 대해서도 알아보겠습니다.

CAP에 대해서 구글링을 해보면 많은 분들이 CAP이론이라고 하시는데 이론과 정리는 엄연히 다릅니다. 이론은 증명되지 않은 생각? 주장? 에 가깝고 (e.g 일반 상대성 이론) 정리는 증명이 완료된 이론을 정리라고 합니다 (페르마의 마지막 정리). 정확히 말하면 CAP는 이론이었다가 증명이 완료된 후 정리가 된것입니다. 정식 영문 명칭도 theory가 아닌 theorem으로 되어있습니다.

p.s 저도 CAP이론이라고 말하는게 더 멋있어서 이론이라고 부르고 싶은데... 이론이 아닌걸 이론이라고 부르면 아무래도 잘못된 정보를 전달하는 것이 되니 참 안타깝습니다...

서론이 좀 길었네요 이제 CAP 정리와 PACELC 정리에 대해서 알아보도록 하겠습니다.

CAP 정리

CAP정리는 Brewer의 정리라고도 불립니다. CAP정리는 분산시스템에서 Consistency, Availability, Partition tolerance 이 세가지 속성 중 세가지를 모두 만족하는 시스템은 없다라는 정리입니다. 최대 2개만 만족할 수 있다는 것이죠.

분산시스템이란 같은 시간대에 물리적 혹은 가상의 기기로 하나의 노드보다 더 많은 노드에 데이터를 저장하는 네트워크를 말합니다. 아래의 그림은 분산시스템을 설명하는 좋은 예시입니다.

모든 분산시스템에서 클라우드 애플리케이션을 설계할 때 CAP정리를 아는 것은 필수입니다.

우선 CAP에 대해서 알아보죠. CAP는 각각 Consistency, Availability, Partition Tolerance의 앞글자를 따서 만든 단어입니다. 각각에 대해서 설명해보겠습니다.

Consistency

ACID의 C와 같은 단어를 쓰지만 느낌이 좀 다릅니다. ACID의 Consistency는 일관성 즉 트랜잭션 진행중 데이터베이스가 중간에 바뀌더라도 진행하고있던 데이터베이스로 일관성있게 진행해야 한다는 것입니다.

CAP의 Consistency는 정합성입니다. 흔히 데이터 정합성을 말할 때 그 정합성이 맞습니다. Consistency는 모든 클라이언트가 같은 시간에 같은 데이터를 본다는 의미입니다.

데이터가 하나의 노드에 쓰여지면, 쓰기 작업이 성공하기 전에 모든 시스템안에 모든 다른 노드에 즉각적으로 복제되어야합니다.

Availability

Availability를 파파고에 돌려보면 유용성, 유효성이라고 나오는데 사실 한글인데 무슨 뜻인지 잘 안와닿습니다. 그래서 저는 Availability를 가용성 혹은 사용가능성이라고 해석합니다.

Availability는 어떤 클라이언트가 요청을 할 때 하나 혹은 더 많은 노드가 죽어버려도 응답을 할 수 있어야 한다는 의미입니다. 분산환경에서 모든 노드들은 요청에 대해 적절한 응답을 보여줘야 한다는 것입니다.

요청, 응답 이런 단어들이 헷갈리니 예시를 들자면 요청 = 쿼리, 응답 = 데이터 라고 생각하셔도 무방합니다.

Partition tolerance

Partition tolerance는 직역하면 분할 용인입니다. 정말 영어는 이래서 어려운 것 같습니다. 개발자들은 이 Partition이라는 단어를 장애 또는 오류 라고 해석하더라고요... 그렇게 해석하면 장애 용인 혹은 장애 허용 정도로 해석할 수 있는데 이래도 잘 이해가 안되시죠

Partition tolerance는 쉽게 풀어서 설명하면 금방 이해할 수 있습니다. Partition tolerance는 장애가 생기는 환경을 가정한 시스템 partition을 의미합니다.

CAP정리에 대한 IBM의 공식문서에서 Partition tolerance를 이렇게 설명하고 있습니다. Partition은 분산 시스템에서 연결 오류(장애)입니다. 즉, 두개의 노드 사이에 연결이 끊어지거나 일시적인 연결 지연을 의미하죠. Partition tolerance는 클러스터가 시스템안의 노드 사이에 수많은 연결 오류(장애)에도 불구하고 계속해서 동작해야한다는 것입니다.

Partition을 장애로 해석해야한다니 참 답답합니다. 이걸 진짜 나눈 것 이라는 파티션이라고 해석해야 하는지 장애라고 해석해야 하는지 해석하다보면 답답해질 때가 있습니다.

놀랍게도 사실입니다.
놀랍게도 사실입니다.

CAP에서는 세개를 모두 만족하는 시스템이 없다는 정리라고 말씀드렸죠? 그럼 CAP는 총 세개의 타입이 나옵니다. 바로 CP, AP, CA이렇게 세가지입니다. 각각의 타입에 대해서도 한번 알아보죠

CAP 정리에서 NoSQL의 타입

이 섹션에 들어가기 전에 그림을 가지고 설명할텐데 그 그림을 위한 사전 지식이 있어야합니다.

이렇게 여섯가지를 알고 계시면 됩니다. 네번째인 Network Partition은 앞서 설명했듯이 네트워크 장애라고 보시면 됩니다.

CP database

CP는 Consistency와 Partition tolerance 이렇게 두가지 속성을 가진 데이터베이스입니다. 그렇다는 얘기는 Availability는 만족하지 않다는 얘기이죠.

CP인 경우 시스템의 노드들이 나눠졌음에도 요청을 받는 것을 유지하기 때문에 파티션을 허용합니다. 이 데이터베이스 모델은 일관적입니다. 왜냐하면 오직 결과를 생성하는 노드들이 모든 쓰기 요청을 처리하는 마스터 노드와의 연결을 유지하기 때문입니다.

Availability는 왜 만족하지 못할까요? CP와 AP는 서로가 서로 물리고 물린다고 생각하시면 편합니다. CP는 정합성 때문에, AP는 가용성 때문에 맞은편에 있는 속성을 유지하기 힘들죠.

CP의 경우 Consistency 즉 정합성을 유지해야 하는데 위의 상황에서 Availability를 만족하려면 각각의 노드가 요청과 응답을 해야하는데 그럼 각각의 노드에 쓰기 작업이 가능해진다는 의미입니다. 그럼 A노드 B노드에 모두 쓰기가 가능해지면 둘의 데이터가 달라지는건 피할 수 없겠죠?

위의 그림을 설명하자면 맨 처음 마스터 노드와 일반적인 노드가 있는 상황에서는 쿼리가 들어오면 정상적으로 응답이 나가죠. 하지만 장애상황이 되면 마스터 노드가 죽어버리면서 쿼리만 들어오고 정상적으로 응답을 하지 못하는 모습을 보여줍니다.

AP database

AP는 Availability와 Partition tolerance를 만족하는 데이터베이스 모델입니다.

각각의 노드들이 어떻게 Availability를 가질 수 있을까요? 이 데이터베이스 모델의 경우 마스터 노드에 도달할지 안할지와 관계없이 요청을 응답하는 slave를 가지기 때문에, 그리고 다른 파티션에 있는 slave node가 새로운 마스터 노드를 선출하기 때문에, 그리고 우리는 마스터노드가 없는 클러스터를 가지고 있기 때문에 Availability를 만족합니다.

위의 그림을 1, 2, 3 이렇게 세부분으로 나눠서 보겠습니다.

첫번째는 마스터 노드에 도달할지 안할지와 관계없이 응답하는 slave 노드를 가지기 때문에 마스터노드가 죽어버렸음에도 요청에의한 응답이 가능합니다.

두번째는 장애 상황에서 마스터노드가 죽어버리고 다른 파티션에 있는 slave 노드가 새로운 마스터 노드를 선출한 경우입니다.

세번째는 마스터 노드가 없는 상황인데, 실제로 NoSQL 데이터베이스 중 Cassandra였나..? 자세히 기억이 안나는데 마스터 노드가 존재하지 않는 데이터베이스가 있습니다. RDBMS라면 상상도 못할 일이지만 NoSQL에서는 가능한가봅니다.

아무튼 세번째 상황에서도 당연히 가용성을 보장하기 때문에 가능합니다.

그럼 Consistency는 어째서 만족할 수 없을까요? 앞서 설명드렸다시피 Availability를 만족해야 하기 때문에 Consistency를 만족할 수 없습니다.

이건 앞선 이유와 같은 이유가 되겠네요. 각각의 노드가 쓰기 작업이 가능해야 할텐데 쓰기작업을 진행하는 순간 정합성이 깨져버리겠죠?

CA database

이제 마지막으로 CA인데 이건 할 얘기가 좀 많습니다.

CAP 정리에서 정의를 살펴보면 마치 C, A, P 각각이 동등한 선상에 있는 것처럼 느껴집니다. 이러한 연유로 C, A, P가 분산 시스템의 특정에 대한 것으로 여겨지지만 실상은 그렇지 않습니다.

왜냐하면 앞서 설명했듯이 P는 Partition tolerance, 장애를 허용하는 파티션을 만드는 작업인데 이 P를 허용하지 않는 순간

"우리 시스템은 죽었다 깨어나도 장애가 발생하지 않습니다" 라고 선언하는 것과 다르지 않습니다.

따라서 P를 만족하지않는 데이터베이스는 이 세상에 있을 수 없습니다.

그래서 마치 P를 포기하고 CA를 선택해도 되는 것처럼 느껴집니다. 하지만 앞서 말했듯이 그런 시스템은 존재하지 않고 그럼 C나 A 중에서 선택해야 한다는 의미입니다.

그렇다고 C나 A를 마냥 둘 중 선택하듯이 선택해도 되냐? 의 문제도 또 있습니다. 이 C나 A도 동일선상에 있지 않습니다. AP를 선택한 Cassandra의 경우 네트워크 장애 상황이던 정상 상황이던 C를 포기하도록 되어있습니다. HBase의 경우 장애상황일 때만 A를 포기하고 정상일때는 A를 만족합니다.

즉, CAP 정리는 장애 상황일때만 대칭성이 있고 정상일 때는 그렇지 않습니다. 그러므로 CAP정리의 내포된 의미는 분산시스템에서 네트워크 장애 상황일 때 정합성과 가용성 중 많아도 하나만 선택할 수 있다는 것입니다.

하지만 이런 식의 해석은 정상 상황일 때의 분산시스템 동작을 서술해주지 못합니다. 이에 대해 PACELC라는 기가막힌 표현에 대해서 알아봅시다.

PACELC

앞서 설명했듯이 CAP는 장애상황과 정상상황을 모두 설명할 수 없습니다. PACELC의 핵심은 장애상황과 정상상황을 모두 설명할 수 있다는 점에서 CAP의 업그레이드 버전이라고 볼 수 있습니다.

Partition은 제가 설명드렸듯이 "장애" 혹은 "오류" 라고 해석하시면 됩니다.

위의 그림에서 알 수 있듯이 장애상황에서는 A와 C를 선택하고 그밖에 상황(정상 상황)에서는 L과 C를 선택한다는 것입니다. L은 Latency이고 C는 앞선 Consistency입니다.

이 개념을 NoSQL에 적용해보면 다음과 같습니다.

• HBase는 PC/EC입니다. 장애 상황일 때 C를 위해서 A를 희생합니다. 그렇지 않은 경우에는 C를 위해 L을 희생합니다.

• Cassandra는 PA/EL이 가능하도록 설계되었습니다. 설정에 따라 Eventual Consistency의 특성을 가지게 되었는데, 이 경우 PA/EL이 됩니다. 장애 상황인 경우에는 가능한 노드에만 데이터를 반영하고 정상으로 복구되면 필요한 노드에 데이터를 모두 반영합니다. 정상 상황일때도 Latency를 위해 모든 노드에 데이터를 반영하지 않습니다.

• Brewer가 정의한 가상의 분산시스템은 PA/EC입니다. 정상적인 경우에는 모든 노드에서 같은 메시지를 볼 수 있도록 쓰기 연산이 일어나는데 P 상황인 경우 이를 판단하여 일단 접근 가능한 만큼만 데이터를 반영합니다. 결과적ㅇ느로 시스템은 다운되지 않지만 절단된 노드들끼리는 데이터를 주고 받지 못하게 됩니다. 장애상황이 복구되면 이를 감지하여 전달하지 못한 데이터를 반영합니다. 장애 상황일때만 C를 포기하고 보통의 상황에서는 강력한 C를 가져가는 것입니다.

참고)

RDBMS에도 CAP을 적용하여 CA로 분류하기도 합니다. Brewer가 처음 발표할 때에도 이렇게 표현하기도 했고, 하나의 인스턴스로 운영되는 RDBMS의 특징상 네트워크로 연결되어 있기 않으므로 네트워크 단절을 재제할 수 있다는 점에서 이렇게 분류하는게 어느정도 납득은 됩니다.

하지만 CAP는 분산시스템이 전제이고 따라서 RDBMS에 CAP를 적용하는 것은 맞지 않습니다.

라고 써있는 글을 참조하긴 했지만...

http://eincs.com/2013/07/misleading-and-truth-of-cap-theorem/

이 말은 RDBMS는 분산시스템이 아니라는 의미가 됩니다. RDBMS가 분산시스템이 왜 아닌지에 대해서 찾아봤습니다.

위의 그림은 RDBMS의 경우를 나타낸 그림입니다. 일반적인 마스터-슬레이브 관계인 경우 마스터 노드가 죽어버리면 남아있던 슬레이브 노드가 마스터가 되면서 다시 정상적인 운영을 합니다.

클러스터링을 구현한다면 active 노드 개수가 적으면 전체 클러스터가 종료될 수 있습니다. 하지만 이러한 상황은 클러스터링에서 언제든지 발생할 수 있습니다. 클러스터링이 가진 단점 중 하나이죠.

이러한 단점을 해결하기 위한 아키텍처가 바로 레플리케이션입니다. 클러스터링과 레플리케이션 그리고 나오진 않았지만 샤딩에 대해서 자세히 알고싶으신 분은 아래의 링크를 참고해주세요!

https://coding-review.tistory.com/309

위의 구조를 가지고 있기 때문에 RDBMS는 분산시스템을 사용할 일이 없죠. 애초에 분산시스템이 필요한 도메인에서 RDBMS를 사용하지 않습니다.

자 여기까지 CAP정리 그리고 그 업그레이드 버전인 PACELC에 대해서 알아봤습니다. 이번건 정말 어려웠습니다. 특히 CP와 AP를 보면서 왜 CP가 A를 만족하지 않지? 왜 AP가 C를 만족하지 않지? 에 대해서 정말 머리굴려가며 열심히 공부했던 것 같습니다.

NoSQL을 공부하다보니 별에별거까지 다 공부하고 있네요. CAP정리에 대해서는 면접에 충분히 나올 수 있는 내용입니다. PACELC까지 깊은 내용은 아니더라도 CAP정리에 대한 기본적인 내용은 충분히 나올만한 질문이라고 생각합니다. 잘 정리하셔서 면접에서 꼭 활용하시길 바라겠습니다.

여기까지 글 마쳐보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~~

출처

https://www.ibm.com/topics/cap-theorem

=> CAP 정리에 대한 IBM 공식문서

https://www.geeksforgeeks.org/what-is-a-distributed-system/

=> 분산시스템에 대한 정의

https://stackoverflow.com/questions/36404765/why-isnt-rdbms-partition-tolerant-in-cap-theorem-and-why-is-it-available

=> 스택 오버플로우 질문

http://eincs.com/2013/07/misleading-and-truth-of-cap-theorem/

=> CAP 정리의 오해와 진실 : 이재성님

http://groups.csail.mit.edu/tds/papers/Gilbert/Brewer2.pdf

=> CAP 정리 증명논문

논문 제목 : Perspectives on the CAP theorem

논문 저자 : Seth Gilbert, Nancy A. Lynch

수여 기관 : National University of Singapore, Massachusetts Institute of Technology

Elasticsearch

이번 포스팅에서는 Elasticsearch에 대해서 알아보겠습니다.

Elasticsearch가 뭐야? 라고 물으면 "index 아냐?", "검색 엔진이잖아", "데이터베이스 분석하는 툴이지", 구글같은거?" 이런 많은 대답이 오고 갈 수 있는 이유는 Elasticsearch가 위의 모든 내용을 포함하고 있기 때문입니다.

Elasticsearch는 웹사이트나 document에서 간단한 검색부터, 로그데이터를 분석하고 모으는 일이나, 데이터를 분석하고 시각화해주는 똑똑한 비즈니스 툴이죠.

이번 포스팅에서는 Elasticsearch가 어쩌다 가장 인기있는 엔터프라이즈 검색 엔진이 되었는지 그리고 가장 인기있는 DBMS 탑10에 이름을 올리게 되었는지에 대해서 설명해보겠습니다.

Elasticsearch가 뭐야?

Elasticsearch는 자바로 개발된 Apache의 Lucene(루씬)의 오픈소스 검색, 분석 엔진에서 떨어져나온 기술입니다. Elasticsearch는 Lucene의 오픈소스 프레임워크의 확장버전이죠.

Elasticsearch는 큰 볼륨의 데이터를 실시간으로 수밀리초안에 저장하고 검색하고 분석하도록 해줍니다. 이렇게 빠르게 작업을 수행할 수 있는 이유는 텍스트를 직접적으로 검색하는 것 대신 인덱스로 검색하기 때문인데요. Elasticsearch는 테이블이나 스키마 대신 document에 기반한 구조를 갖고 있고 REST API를 확장적으로 사용해 데이터를 저장하고 검색할 수 있습니다.

이 말은 NoSQL에서 가진 특징인 free schema 의 특징과 REST API가 가진 특징을 모두 담고 있어서 확장에도 유연하게 대처할 수 있고 대용량의 데이터를 담는데도 큰 무리가 없는 시스템이라는 의미입니다.

Elasticsearch의 구조

Document

Document는 JSON으로 표현된 Elasticsearch안에서 인덱스화될 수 있는 정보들의 가장 기본적인 단위입니다. RDBMS에서 row(행)에 해당하는 값이 바로 document입니다.

Elasticsearch에서 document는 단순히 텍스트 이상이 될 수 있습니다. document는 JSON으로 인코딩된 정형데이터인데요. 이 데이터들은 숫자, 문자, 날짜의 값을 가질 수 있습니다.

각각의 document는 유니크 ID를 가지고 있고 데이터 타입이 주어집니다. 이 데이터 타입은 document가 어떤 엔티티 타입인지를 지시해줍니다.

Index

인덱스는 Elasticsearch 안에서 쿼리할 수 있는 가장 높은 수준의 엔티티입니다. 인덱스는 우리가 RDBMS에서 보던 스키마와 비슷한 구조를 가지고 있죠.

인덱스 안에있는 document들은 굉장히 논리적으로 연관성이 있습니다. 예를 들어서 이커머스 웹사이트에서 우리는 인덱스로 고객, 상품, 주문 등등을 가질 수 있습니다.

이 인덱스는 document에서 인덱싱, 검색, 업데이트, 삭제 실행을 하는 동안 인덱스를 참조하기 위하여 사용되는 이름으로부터 식별하게 됩니다.

Inverted Index

Elasticsearch에서 인덱스는 Inverted index(이하 역색인) 으로 불리웁니다. 이 역색인은 검색엔진이 작동하는 매커니즘입니다.

index(색인)는 흔히 책앞에 있는 목차를 생각하시면 편하고, 역색인은 책 맨 뒤에 나오는 단어별로 몇페이지에 나오는지 수록되어있는 그것을 떠올리시면 됩니다.

이렇게 역색인으로 분리시킴으로써 Elasticsearch는 아주 큰 데이터세트에서 전문을 가장 빠른 방법으로 찾아낼 수 있게 되었습니다.

Cluster

Elasticsearch 클러스터는 다 같이 연결된 하나이상의 노드 인스턴스들의 그룹입니다. Elasticsearch에서 클러스터의 진정한 힘은 분산된 작업처리, 검색, 인덱싱 능력입니다.

어떤 노드가 어느 클러스터에 포함되기 위해서는 이름에 의해 클러스터의 구성원이 되도록 설정되기 때문에 이 이름은 매우 중요합니다.

Node

노드는 클러스터의 일부분인 싱글 서버입니다. 하나의 노드는 데이터를 저장하고, 클러스터의 인덱싱과 검색기능에 참여합니다. 노드는 크게 세가지로 분류할 수 있습니다. Master Node, Data Node, Client Node 이렇게 셋으로 나눌 수 있습니다.

Master Node

마스터 노드는 Elasticsearch 클러스터를 통제하고, 모든 클러스터에 걸친 인덱스 생성과 삭제, 노드의 생성과 삭제와 같은 작업에 대한 책임이 있습니다.

Data Node

데이터 노드는 데이터를 저장하고, 데이터와 연관된 예를 들어검색이나 집계같은 작업을 수행합니다.

Client Node

클라이언트 노드는 클러스터 앞에서 마스터 노드에 대한 요청과 데이터 노드와 연관된 요청을 수행합니다.

Shards

Elasticsearch는 샤드라고 부르는 인덱스를 다수의 조각으로 쪼개는 능력을 제공합니다. 각각의 샤드는 그 자체로 완전히 동작합니다. 다수의 샤드를 통해서 인덱스안에서 document로 쪼갬으로써 그리고 다수의 노드로부터 샤드로 쪼갬으로써 Elasticsearch는 충분한 데이터를 가지고 있을 수 있게 됩니다.

이런 샤드들은 하드웨어 트러블이나 클러스터에 노드를 추가하면서 생기는 증가된 쿼리 캐퍼시티를 보호해줄 수 있습니다.

Replica (복제)

Elasticsearch는 흔히 replica shard라고 불리는 인덱스의 샤드를 하나 이상의 복제된 인덱스로 만들 수 있게 도와줍니다.

기본적으로 replica shard는 primary shard를 복제한 샤드인데요. 이러한 복제는 충분한 데이터를 제공하고, 하드웨어 트러블이나 서버의 증가하는 검색이나 document를 탐색하는 읽기 요청에 의한 오버헤드를 막아줍니다.

이러한 Elasticsearch에 대한 전체 도식화를 살펴보면 다음과 같습니다.

Elasticsearch는 사실 Elasticsearch, Logstash, Kibana를 합친 ELK의 한 부분입니다. 물론 Elasticsearch가 이 셋중에 가장 핵심적인 역할을 수행하지만 나머지 두개도 ELK에서 꽤나 높은 수준의 서비스를 제공합니다.

이 다음은 ELK중 L, K에 해당하는 Logstash, Kibana에 대해서 알아보도록 하겠습니다.

• Logstash : 다양한 소스 (DB, csv파일 등)의 로그 또는 트랜잭션 데이터를 수집, 집계, 파싱하여 Elasticsearch로 전달하는 역할을 합니다.

• Elasticsearch : Logstash로부터 받은 데이터를 검색 및 집계하여 필요한 정보를 획득하는 역할을 합니다.

• Kibana : Elasticsearch의 빠른 검색을 통해 데이터를 시각화 및 모니터링 하는 툴입니다.

쉽게 얘기해서 Kibana는 데이터 시각화 툴, Logstash는 데이터 집계 툴, Elasticsearch는 이런 데이터들을 검색하고 집계하여 필요한 정보를 수집하는 툴 이라고 볼 수 있겠습니다.

여기까지 Elasticsearch에 대해서 알아봤습니다. 후..이번 포스팅은 정말 힘들었습니다. NoSQL은 이해하는데 그렇게 어렵지 않았는데 Elasticsearch는 굉장히 빡세네요. 처음보는 데이터 구조라 그런 것 같습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.knowi.com/blog/what-is-elastic-search/

NoSQL의 BASE속성으로 인한 단점은 단점이 아니다

블로그를 한번 쭉 보니 제가 데이터베이스 공부를 하면서 NoSQL을 굉장히 깊게 공부했는데 BASE에 대해서 다루지 않았더라구요.

이번 포스팅에서는 RDBMS에 ACID가 있다면 NoSQL에는 BASE가 있다! BASE에 대해서 알아보겠습니다. 그리고 BASE에서 가장 중요한 Eventual Consistency에 대해서 깊이있게 알아보도록 하겠습니다.

BASE

BASE는 ACID와 마찬가지로 NoSQL의 특징을 잘 알려주는 특징들의 앞글자를 따서 만든 단어입니다.

BASE에 들어가기 앞서 ACID에 대해서 베이스 지식이 없으시면 아래의 링크에서 참고해주세요!

https://coding-review.tistory.com/296

BASE는 Basically Available, Soft State, Eventual Consistensy의 앞글자를 따서 만든 단어입니다. ACID와 다른 점이라면 역시 ACID는 RDBMS의 특징을 보여주는 단어였다면 BASE는 한 문장으로 표현이 가능하다는 점이 있겠습니다.

BASE를 한 문장으로 정리해보자면 "기본적으로는 가용성을 유지하며 데이터가 결국 일관적이게 되는 소프트한 상태" 입니다.

각각의 특징에 대해서 잠깐 정리하고 넘어가도록 하겠습니다.

• Basically Available : 시스템은 장애가 나더라도 항상 작동해야 한다는 의미입니다. 클라이언트에 의해 요청이 들어오면 장애가 나는 한이 있더라도 응답을 해야한다는 의미이죠.

• Soft State : Soft State는 데이터 소스가 반드시 항상 일관성을 유지할 필요는 없다는 특징입니다. 이는 뒤에 이어지는 Eventually Consistency와 일맥상통하는 이야기입니다.

• Eventually Consistency : 간단하게 Eventually Consistency를 정리하자면 단어 그대로 결국 일관성을 유지하게 된다는 말입니다.

이제 BASE에서 가장 중요한 특징인 Eventually Consistency에 대해서 알아보도록 하죠

Eventual Consistency

Eventual Consistency는 분산환경인 NoSQL에서 결국 모든 노드에 일관적으로 반영된다는 데이터 모델링 컨셉입니다. 이 말은 쿼리를 날렸을 때 시간이 좀 지난 다음 결과적으로는 일관성을 유지한다는 의미입니다.

Eventual Consistency를 사용하는 이유에는 몇가지이유가 있는데 그 중에서 가장 핵심적인 이유는 5G 시대에 따라 네트워크 속도가 빨라졌는데 데이터를 운반하는 속도가 네트워크 속도에 비해 떨어지는 현상이 생겼기 때문에 높은 성능을 챙기기 위해서입니다.

뭐 그건 그렇다 치는데 저는 처음에 이 개념을 접했을 때 '과연 이게 정상적으로 돌아갈까?' 를 먼저 생각했던 것 같습니다. 그러니까 결국 일관성이 맞는다는 얘기는 트랜잭션 중간에 데이터가 바뀌더라도 바뀐 것을 트랜잭션이 인지하지 못하고(일관성이 보장되지 않으니) 바뀌기 전 데이터로 가공할 수 있다는 얘기입니다.

즉, 쉽게 말해서 Dirty Read 부정합 문제가 발생할 수 있다는 얘기이죠.

그럼에도 불구하고 Eventual Consistency가 필요한 이유

NoSQL 데이터베이스는 RDBMS과 다르게 비정형 데이터들을 수용할 수 있습니다. 비정형 데이터를 담는다는 얘기는 개발자로 하여금 많은 프로그래밍적인 도전과제가 생긴 것입니다.

왜냐하면 페이스북같은 애플리케이션에서는 ACID 표준이 필요가 없기 때문이죠. 때문에 페이스북은 Cassandra NoSQL 데이터베이스를 사용하기로 결정했습니다.

페이스북 입장에서는 더 빠르고 더 역동적인 서비스가 필요했습니다. 때문에 기존 ACID 대신 Eventual Consistency를 받아들일 수 밖에 없던 것이죠.

뭐가 그렇게 좋았길래 ACID특성을 버리고 Eventual Consistency를 선택했던걸까요? 이제 Eventual Consistency의 이점에 대해서 설명해보겠습니다.

Eventual Consistency 장점

많은 회사들이 기존 ACID를 버리고 NoSQL을 선택함으로써 더높은 가용성, 더높은 성능, 더높은 확장성을 가질 수 있게 되었습니다.

가용성부터 설명해볼까요?

기존 CAP 정리에서 C를 버리고 A를 선택한 AP 데이터베이스가 높은 가용성을 가진다는 것은 잘 알고 계실겁니다. 하지만 Eventual Consistency가 데이터 정합성을 버린 것은 아닙니다. 말 그대로 결국 정합성이 맞게 되니까요.

CAP 정리에 대한 베이스 지식이 없으신 분들은 아래의 링크를 참고해주세요!

https://coding-review.tistory.com/312

그러니 Eventual Consistency는 높은 가용성과 일관성을 모두 챙기게 된 것입니다.

성능적으로는 어떨까요? NoSQL은 RDBMS에 비해 분산환경에 적합한 데이터베이스입니다. 분산환경에서 사용할 수 있는 데이터베이스 튜닝 방식으로는 클러스터링, 레플리케이션, 샤딩이 있었죠?

NoSQL은 데이터베이스를 분산시켜 성능적으로 그리고 서버의 오버헤드 부분에서도 큰 성능적인 이점을 가지고 있습니다.

데이터베이스 튜닝 방식인 클러스터링, 레플리케이션, 샤딩에 대해서 궁금하신 분들은 아래의 링크를 참고해주세요!

https://coding-review.tistory.com/309

확장성은 어떨까요?

확장성은 기존의 RDBMS보다 NoSQL이 더 잘 확장하기로는 잘 알려진 사실입니다. 몇몇 논문에서는 NoSQL이 RDBMS보다 확장성에서 우위를 가져가게 된 요인으로 느슨한 ACID를 채택하였기 떄문이라고 말합니다.

기존 RDBMS는 Atomatic과 Consistency를 엄격하게 지켜야 했기 때문에 데이터 확장에 큰 제약이 있었다고 설명하죠.

자 이렇게 Eventual Consistency의 장점에 대해서 알아봤습니다. 하지만 장점이 있다면 단점도 있겠죠? 마냥 좋은 기술은 아닙니다.

Eventual Consistency의 단점

Eventual Consistency의 단점은 크게 레이턴시, 일관성, 낮은 신뢰성을 들 수 있습니다. 각각에 대해서 설명해보죠.

• 너무 긴 응답속도 : Eventual Consistency 모델에서는 다른 서버에 저장된 여러개의 데이터들이 노드를 타고 전파하여 결국 일관성을 유지합니다. 하지만 이 전파되는 시간동안은 일관성을 보장되지 않기 때문에 결과적으로 데이터 정합성이 모두 갖춰진 상황이 되려면 오랜 시간이 걸리게 됩니다. 5G 시대에서는 이러한 응답속도는 부정적인 유저 경험으로 이어질 수도 있습니다.

• 현재 데이터에 대한 보장이 없음 : Eventual Consistency를 채택한 애플리케이션은 항상 일정한 데이터를 보여줄 수 없습니다. 이는 뒤에 말할 낮은 신뢰성과도 연결되는 문제인데 그렇기 때문에 유저는 이 데이터가 일관적이라는 보장을 받을 수 없습니다. 결과적으로 유저는 이미 지나버린 데이터를 볼 가능성이 매우 농후합니다.

• 낮은 신뢰성 : 이 문제는 앞서 설명한 문제와 연결되는 문제인데, 현재 데이터에 대한 보장이 없으니 신뢰성이 낮아지는 것은 당연합니다. 현재의 Eventual Consistency의 모델에서는 데이터를 입력했을 때 새로운 데이터가 51% 정도만 전파되고 바로 응답해버리는 문제 때문에 신뢰성을 보장할 수 없습니다.

자 Eventual Consistency에 대한 단점에 대해서 설명해봤는데요. 이거... 온라인뱅킹이나 SMTP 프로토콜 이런데 쓸 수 있을까요? 절대 못쓰겠죠 일관성이 중요한 서비스에서 NoSQL은 그래서 절대 못씁니다.

어떻게 보면 이건 아주 큰 문제인데요. 근데 이게 정말 큰 문제일까요?

생각의 전환

제가 NoSQL에 대해서 포스팅할 때도 말씀드렸지만 RDBMS와 NoSQL은 서로가 서로를 대체할 수 있는 대안책이 아닙니다. RESTful API와 GraphQL같은 관계가 아니라는 말이죠.

위의 단점들을 살펴봤는데 만약 저기 위의 단점이 모두 신경쓰지 않아도 되는 애플리케이션에서는 어떨까요? 즉, 응답속도도 낮아도 되고, 현재 데이터를 보장받지 않아도 되며, 낮은 신뢰성을 가져도 상관없는 애플리케이션이라면요?

사진, 영상, 음성과 같은 비정형 데이터가 많이 저장되는 애플리케이션이고 높은 성능, 높은 확장성, 높은 가용성이 필요한 애플리케이션이라면요?

그런 애플리케이션이 우리 주변에는 많습니다. 페이스북, 인스타그램, 트위터와 같은 SNS는 위의 특징이 모두 부합합니다. 비정형 데이터가 많고, 높은 성능, 높은 확장성, 높은 가용성이 요구되면서 Eventual Consistency의 단점이 모두 해당되지 않는 애플리케이션이죠.

그럼 저 단점이 의미가 있을까요?

단점이 있는데... 단점이 의미없는 경우가 생겨버립니다. 그래도 온라인 뱅킹이나 SMTP에서는 못쓰잖아! 그럼요 당연하죠 그런 높은 신뢰성, 높은 일관성이 필요한 애플리케이션에서는 절대 못씁니다. 하지만 그게 뭐가 문제가 되나요? 그런 애플리케이션은 NoSQL말고 RDBMS 쓰면 되잖아요.

그러니 앞서 말했듯이 이 둘은 서로의 대안책이 아니라 상호보완하는 관계입니다. 반대로 생각해보면 비정형 데이터를 담을 수 없는 RDBMS는 SNS에서는 절대 못쓰는 데이터베이스입니다.

즉, 어떤 데이터베이스던 사용할 수 있는 곳이 따로 존재한다는 의미입니다.

자 이렇게 NoSQL의 특징인 BASE와 그 중 핵심인 Eventual Consistency에 대해서 알아봤습니다. 어떠신가요?

저는 공부를 하면 할수록 각각의 단점을 보기 보다는 그 기술이 가지고 있는 장점에 점점 더 신경을 쓰기 시작하게 된 것 같습니다. 이게 제 모토와도 일치하거든요. 제 모토는 "단점을 보완하지 말고 장점을 극대화 시켜서 단점을 무마시켜라" 인데 이것이 마치 RDBMS와 NoSQL 같지않나요?

여기까지 글 마쳐보도록 하고 다음에도 더 좋은 포스팅으로 찾아뵙도록 하겠습니다. 오늘도 즐거운 하루 되시고 다음에 또 봬요~

출처

https://www.voltactivedata.com/blog/2022/09/what-is-eventual-consistency/

https://www.freecodecamp.org/news/nosql-databases-5f6639ed9574/

"NoSQL Database : New Era of Databases for Big data Analyties - Classification, Characteristics and Comparison"

저자 : A B M Moniruzzaman, Syed Akhter Hossain

링크 : https://arxiv.org/ftp/arxiv/papers/1307/1307.0191.pdf

-> 확장성을 늘릴 수 있었던 기술적인 부분을 서술 page 3~4 (CAP 정리와 ACID를 인용하여 설명했음)

"Database Management Systems : A NoSQL Analysis"

저자 : Innocent Mapanga, Prudence Kadebu

링크 : https://d1wqtxts1xzle7.cloudfront.net/32373170/Database_Management_System_A_NoSQL_Analysis-libre.pdf?1391610082=&response-content-disposition=inline%3B+filename%3DDatabase_Management_Systems_A_NoSQL_Anal.pdf&Expires=1679015563&Signature=ANQwxCQtKCXg9NYGakJbSYJEh1pYTBamZLdRHYWdl4M8CwRxBztFLL-0FaaMVdKXDfnXGlozOBPgjBgEUet-Znf6xX2gNlPPN5qAKvrzMDDG972O1QzK8tLKsfwKrVbI9CndLAnEg1D7j1mAQM2nSUYQmMx0KnzHScmrmkuy~WgcuQBAl6qU1bPRRXEliN96ocCnqlGX~aVgbJi-fFjhl3D-BAmXAleKXKNNUkGNWNUyXrk9cSIz9ZaiT3wHkxE6jinhbn0kKEuVcwa39NRJaAor7xWq5Wm4fZApO8NmHrhydwf2JeRFGFmGWcaeQFPUH6LhZWTOENx2AmPE8SlGQg__&Key-Pair-Id=APKAJLOHF5GGSLRBV4ZA

-> Database Sharding의 관점에서 RDBMS보다 수평적 확장을 더 쉽게 할 수 있다고 서술 page 2

데이터베이스 인덱싱 (B- tree, B+ tree)

작년 12월쯤에 데이터베이스 인덱스에 관한 포스팅을 진행한 적이 있습니다. 하지만 그때 당시 제대로 된 문서를 찾아보고 포스팅을 한 것이 아니라 다른 분들이 블로그에 올리신걸 짜집기 해서 적은 것이라 내용이 많이 부실합니다.

이번 포스팅에서는 이전 인덱스에 관한 내용을 리뉴얼하는 과정을 거칠 것이고 그렇기 때문에 이번 포스팅은 꽤나 알차게 작성할 예정입니다.

각설하고 시작해보죠

인덱스

우선 이런 데이터가 있다고 가정해봅시다. 이 상태에서 점수가 30점인 학생을 찾고 싶습니다. SQL문 SELECT 어쩌구 해서 만들겠죠?

SQL문을 그렇게 만들면 데이터를 가져올겁니다. 하지만 기존 DBMS는 데이터를 검색할 때 모든 데이터를 다 뒤져서 가져옵니다. 점수가 30점인 학생을 찾기 위해서 다섯개의 레코드를 다 뒤져본다는 얘기죠.

그럼 만약 데이터가 다섯개가 아니고 1억개면 어떻게 할까요? 맞습니다. 30점인 학생을 찾기 위해 1억건의 데이터를 전부 확인할 것입니다.

이건 아무리 봐도 문제가 있죠. 여기서 인덱스라는게 등장합니다.

숫자 찾기

1부터 100까지 적혀있는 카드가 있고 제가 숫자 하나를 임의로 선택해 생각하고 있도록 하겠습니다. 여러분은 제가 생각한 숫자를 찾는 것입니다.

기존 컴퓨터가 제 숫자를 찾기 위해서는 이렇게 접근할 것입니다.

• 생각하신 숫자가 1입니까?

• 생각하신 숫자가 2입니까?

• 생각하신 숫자가 3입니까?

• 생각하신 숫자가 4입니까?

• (.....)

하지만 자료구조, 알고리즘을 조금 아시는 분들은 이렇게 대답할 것입니다. 그거 '이진 탐색 트리' 쓰면 간단하잖아?

이진 탐색 트리에 대해서 자세히 알고싶으신 분들은 아래의 링크를 확인해주세요. 이진 탐색 트리에 대한 내용은 본 포스팅과 어울리지 않아 생략하도록 하겠습니다.

https://coding-review.tistory.com/286

방금 위와 같은 방법으로 찾으면 운이 안좋은 경우 100번을 물어봐야 원하는 답을 얻을 수 있었지만, 이진 탐색 트리를 활용한다면 8번만에 원하는 답을 얻을 것입니다.

• 50보다 큽니까?

• 75보다 작습니까?

• 63보다 큽니까?

• (.....)

이렇게 반씩 줄여가다 보면 더 효율적으로 원하는 대답을 얻을 수 있겠죠. 이게 바로 인덱스가 사용하는 방식입니다.

하지만 앞선 숫자찾기 게임에서도 정렬이 된 카드들 중에서 반씩 나눠가면서 찾았죠? 그렇기 때문에 이렇게 효율적으로 찾기 위해서는 정렬이 필수입니다.

이렇게 정렬이 된 데이터만 갖고 있다면 이진 탐색 트리를 활용해 원하는 답을 빠르게 찾을 수 있을겁니다. 때문에 이렇게 정렬되어있는 데이터 값들을 '인덱스' 라고 부르는 것이죠.

여기서는 '점수' 컬럼이 인덱스가 되겠네요.

인덱스의 장점

인덱스의 장점은 뭐니뭐니해도 검색성능의 향상입니다. 저렇게 정렬을 시켜놓고 검색하면 시간복잡도가 O(n)에서 O(logn) 으로 획기적으로 줄일 수 있으니까요.

인덱스의 단점

이게 좀 치명적이라고 느끼실 수도 있습니다. 인덱스를 만들어 놓았을 때의 단점은 크게 두가지입니다.

우선 삽입, 수정, 삭제를 하고 싶은 경우 인덱스에도 똑같은 작업을 해줘야겠죠. 원본 데이터와의 일관성을 유지해야하니까요. 그렇기 때문에 검색 성능보다는 조금 떨어지는 성능을 보여줍니다. 하지만 요즘은 연산속도가 워낙 빨라져서 딱히 걱정할 필요는 아니라고 하네요.

다음으로 인덱스를 관리할 저장공간이 필요하다는 것입니다. 만약 인덱스를 사용하고싶다면 데이터베이스 전체 저장소의 10퍼센트 정도 되는 하드웨어 저장소가 필요합니다.

그렇기 때문에 우리는 인덱스를 좀 더 신경써서 만들 필요가 있습니다. 더 효율적이고 더 빠르게말이죠. 그렇게 고대의 개발자분들께서 획기적인 데이터 구조를 만들어내셨습니다. 바로 B- tree입니다.

B- tree

읽을 때는 B- tree (비 마이너스 트리) 라고 읽으면 됩니다. 이 B- tree는 데이터베이스 성능을 획기적으로 올려주는 데 탁월한 공헌을 한 데이터 구조입니다.

우선 어떻게 생겼는지부터 확인해보시죠.

우리가 알고있던 이진 탐색 트리와 조금 다르네요? 노드에 두개이상의 데이터가 들어가있습니다. 자 이렇게 두개 이상의 노드가 들어가게 되면 어떤일이 생길까요?

기존 이진 탐색 트리를 한번 보겠습니다.

데이터를 반씩 갈라가면서 찾는 방법이 바로 이진 탐색 트리였습니다. 때문에 이진 탐색 트리의 시간 복잡도는 O(log n) 더 정확히는 O(log2 n)입니다.

하지만 노드가 하나가 아닌 두개 이상의 데이터가 들어가면 어떻게 되죠? 만약 각 노드별로 두개씩 있다고 가정해봅시다.

그럼 두개씩 갈라가면서 찾는게 아니고 네개씩 없애면서 찾을 수 있겠네요? 이해하기 조금 까다로울테니 예시를 가지고 설명해보겠습니다.

기존 생각한 수 = 60

• 50보다 위입니까? => 네

• 75보다 아래입니까? => 네

• 63보다 아래입니까? => 네

• 56보다 아래입니까? => 아니요

• 59보다 위입니까? => 네

• 61.5보다 위입니까? => 아니요

• 60.5보다 위입니까? => 아니오

• 60

생각한 수 = 145

• 100보다 작습니까? => 아니요

• 100과 155 사이입니까? => 네

• 128보다 작습니까? => 아니요

• 128과 140 사이입니까? => 아니요 => 이때 140 이상인 것이 확정

• 145

이해를 돕기위해 간단한 예제로 선택했는데 이해가 되셨는지 모르겠네요. 아무튼 이런 방식으로 크게크게 자를 수 있기 때문에 O(log2 n) 보다 더 효율적인 O(log3 n) 혹은 O(log4 n) 이렇게 만들 수 있습니다.

자 여기까지만해도 이미 획기적으로 성능을 끌어올렸습니다. 하지만 고대의 개발자들은 여기서 멈추지 않았습니다. 성능이 더 뛰어나고 더 효율적인 데이터 구조를 만들기 이르렀습니다. 바로 B+ tree 입니다.

B+ tree

읽을 때는 B+ tree (비 플러스 트리) 라고 읽으시면 됩니다.

우선 B+ tree의 생김새부터 보고 가시죠.

기본적인 구조는 B- tree와 비슷한 것 같습니다. 뭔가 달라진게 있다면 맨 아래 화살표가 생겼다는 것 정도입니다.

위의 자료구조 Tree의 포스팅을 보시면 알겠지만 트리 구조는 크게 세가지로 나뉩니다.

맨위 = 루트 노드

중간 = 인터널 노드

맨아래 = 리프 노드

B+ tree의 가장 큰 특징은 모든 데이터가 리프 노드에 들어가있고 루트 노드와 인터널 노드에는 데이터가 직접 들어가지 않고 조건식만 들어가있습니다.

그렇기 때문에 트리의 높이를 B- tree 에서 한번 더 줄일 수 있었으며 모든 데이터가 리프 노드에 들어가 있기 때문에 검색 성능이 기존 B- tree 보다 더 빨라졌습니다.

위 그림은 B+ tree의 데이터 구조입니다. 조건문이 들어있는 인터널 노드에는 각각의 조건 사이에 자신이 어디위치로 가야하는지에 대한 레퍼런스가 적혀있습니다.

이를 통해 데이터베이스에서 데이터를 찾기 위해 어디로 가야하는지에 대한 방향을 알려주죠. 리프 노드끼리 화살표로 연결된 것은 모든 데이터가 리프 노드에 모여있기 때문에 다음 리프 노드로 넘어가기 위해서 부모 노드를 거치지 않고 바로 옆 리프 노드로 넘어가기 쉽게 하기 위해 만들어진 구조입니다.

이를 통해 범위를 설정했을 경우 뛰어난 성능을 보여줍니다. 만약 위의 예제에서 A 부터 E 까지를 원한다면 화살표를 따라 A서부터 E까지 흐름에 따라 이동하면 되기 때문에 성능상 더 뛰어난 강점을 가지고 있는 것입니다.

B+ tree에서의 검색

만약 위와 같은 B+ tree가 있다고 가정해보고 제가 원하는 데이터는 55라고 가정해보겠습니다. 제가 55에 가길 원한다면 결과적으로 50과 75 사이에 리다이렉트 되고 거기서 55를 얻을 수 있을 것입니다.

B+ tree에서의 삽입

방금과 같은 예제에서 제가 60이라는 데이터를 넣고 싶습니다. 하지만 리프 노드가 꽉 찼군요. B+ tree 는 이럴 때 리프를 쪼개는 작업을 진행합니다.

60이 들어옴으로써 50, 55, 60, 65, 70이라는 리프 노드가 가운뎃값을 기준으로 쪼개집니다. 즉 (50, 55) / (60, 65, 70) 이렇게 쪼개지는 것이지요.

B+ tree 에서의 삭제

만약 다시 60이라는 숫자를 삭제하고싶다고 가정해보겠습니다. 그럼 우리는 리프 노드를 합쳐야할 필요가 생깁니다. 물론 그대로 놔두는 것도 좋겠지만 그 위의 부모노드가 점점 더 비대해지게 될 것이고 이는 성능상 악영향을 미칠 것입니다.

그렇기 때문에 이렇게 합쳐진 B+ tree 를 볼 수 있습니다.

마치며

여기까지 데이터베이스 인덱스, 그리고 인덱스의 자료구조인 B- tree, B+ tree 까지 알아봤습니다. 인덱스는 이해하기 좀 쉬운데 자료구조는 이해하기 좀 까다로워서 문서를 보면서도 어떻게 정리해야할지 많이 고민했던 것 같습니다.

만약 인덱스와 B- tree, B+ tree를 알기위해 들어온 분들이 좀 더 쉽게 이해하셨으면 하는 마음에 깊은 내용보다는 간추려서 포스팅을 작성했습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

참조

https://dev.mysql.com/doc/refman/8.0/en/mysql-indexes.html

https://www.scaler.com/topics/b-tree-in-dbms/

https://byjus.com/gate/b-plus-tree-in-dbms-notes/

Redis의 기초적인 개념

다음 프로젝트를 기획하면서 Redis를 사용해봐야겠다는 생각을 하게 되었습니다. 기존 이론적으로 알고 있었던 내용으로는 Redis가 캐싱이나 세션 데이터 저장소로서 사용된다는 것은 알고 있었지만 실제 어떻게 사용하는지 그 이외의 사용처는 무엇이 있는지에 대해서 공부가 부족해 조금 알아보게 되었습니다.

이번 공부는 특별히 요즘 핫한 Open AI의 Chat GPT 3.5 버전을 이용해 공부를 진행했습니다. 하지만 어디까지나 생성형 대화 AI인만큼 정보가 정확한지에대한 더블체크를 진행했으니 정보의 신뢰성은 걱정하지 않으셔도 될 것 같습니다. 때문에 이번 포스팅의 출처에는 제가 정보를 얻은 출처가 아닌 더블체크에 사용된 출처가 기입될 예정이니 참고해주시면 감사하겠습니다.

또한, GPT를 이용해 얻은 정보들은 GPT를 이용해 얻었다고 표시를 해두겠습니다. 서론이 길었네요 바로 출발해보도록 하겠습니다.

NoSQL

NoSQL에 대한 소개를 잠시 진행하고 넘어가보도록하죠.

NoSQL은 2009년에 등장한 데이터베이스로 기존 RDBMS의 한계를 타파하고자 개발된 데이터베이스입니다. RDBMS에서는 저장할 수 없었던 비정형데이터를 저장할 수 있고, 느슨한 데이터 스키마를 가지고 있어서 수평적인 확장에 용이하고 유연한 특징을 가지고 있습니다.

후에 블로그 포스팅으로 정리할 수도 있지만 NoSQL은 보안적으로는 상당히 메롱한 부분이 있는데요. 때문에 NoSQL은 높은 데이터 정합성이나 높은 신뢰성을 요구하는 서비스에는 부적절한 데이터베이스로 알려져있습니다.

하지만 비정형 데이터를 저장해야하거나, 데이터의 총량이 매우 큰 경우에는 NoSQL을 선택하는 것이 나쁘지 않은 선택이 될 수 있습니다.

그럼 Redis에 대해서 본격적으로 알아보도록 하죠

Redis

우리가 이번에 알아볼 Redis는 NoSQL중에서 Key-Value Store로 잘 알려진 데이터베이스입니다. Key-Value Store는 말 그대로 Key와 Value 단 두가지 형태만을 가지고 데이터를 저장하는 방식입니다.

Redis는 다른 NoSQL 데이터베이스 중에서도 가장 뛰어난 성능을 보여줍니다. Redis가 성능을 극단적으로 향상시킬 수 있었던 이유에는 여러가지 이유가 있습니다. 그 이유에 대해서 한번 알아보죠

Redis의 성능향상 비결 (GPT 사용)

• Redis는 주로 in-memory 데이터 스토리지를 이용합니다. 이 말은 RAM을 사용한다는 말이고 RAM은 데이터 읽기 속도가 매우 빠른 것으로 알려져있죠. 이 RAM을 이용해 데이터를 읽고 쓰기 때문에 성능이 뛰어날 수 있었던 것입니다.

• Redis는 높은 성능을 이끌어낼 수 있는 여러개의 자료구조를 지원합니다. 예를 들어서 Sorted Set과 같은 자료구조는 가장 위에있거나 가장 아래있는 데이터를 빠르게 가져올 수 있도록 유도할 수 있습니다.

• Redis는 싱글 스레드 아키텍처를 사용했기 때문에 스레드 동기화와 Context Switching과 같은 오버헤드를 피할 수 있었습니다. 이러한 결과로 더 빠르고 더 효율적인 데이터를 처리하고 반환받을 수 있게 되었습니다.

• Redis는 경량화된 프로토콜을 가지고 네트워크 최적화를 진행했는데 이는 비동기 I/O를 지원한다는 의미입니다. 이러한 방식으로 Redis가 많은 수의 연결을 핸들링할 수 있게 해주었고 요청을 최소한의 레이턴시로 처리할 수 있게 되었습니다.

• Redis는 비동기 실행을 지원합니다. 예를 들어서 non-blocking I/O나 Pub/Sub 메세징이 바로 그것이죠. 이러한 실행들은 Redis가 최소한의 blocking과 waiting을 가지고 큰 볼륨의 요청이나 데이터를 핸들링할 수 있게 해줬습니다.

• 뒤에 설명할 Redis의 배포방식 중 하나인 Cluster 방식을 지원하기 때문에 여러개의 Redis 인스턴스를 늘려서 DB부하를 줄이고 병목현상을 최소화시켰습니다.

이런 다양한 방법으로 Redis는 NoSQL중에서도 파격적으로 성능을 이끌어낼 수 있었습니다. 이제 Redis가 지원하는 자료구조에 대해서 알아보도록 하죠.

Redis의 자료구조 (GPT 사용 후 더블체크)

1. String

첫 번째로 String입니다. String은 가장 기본이 되는 단순한 String value로 저장하는 간단한 key-value 쌍으로 저장합니다. String은 다양한 목적에서 사용되는데 예를 들면 세션 데이터를 저장하거나, 시스템의 성능을 올리거나, 애플리케이션 세팅을 할 때 사용합니다.

2. Set

Set은 순서없는 String을 모아둔 컬렉션입니다. Set은 태그나 프로덕트 카테고리등과 같은 유니크한 값을 저장하기위해 사용됩니다. 해당 타입은 속도가 정말 빠르지만 순서가 없습니다.

3. Sorted Set

Sorted Set은 Set과 비슷합니다. 하지만 각각의 요소들은 정렬된 상태로 List에 담기고 Sorted Set은 객체들을 정렬해야하는 리더보드나 time-series data와 같은 데이터를 저장할 때 사용됩니다.

4. List

List는 String 값을 모아둔 컬렉션입니다. List는 Queue나 Stack 혹은 유저 액션이나 애플리케이션 이벤트같은 time-series를 저장할 때 사용합니다. range메서드를 활용해 범위로 값을 가져올 수 있기 때문에 값이 아무리 커져도 실행 시간이 크게 차이나지 않는 괴물같은 성능을 보여줍니다.

5. Hash

Hash는 field-value 쌍으로 값을 저장하는 컬렉션입니다. key는 String이고 value는 String, number 혹은 다른 타입의 데이터가 저장될 수 있습니다. Hash는 유저 프로필같은 복잡한 객체를 저장할 때 용이합니다. 그리고 특정한 필드에 대한 효율적인 lookup이나 업데이트와 같은 곳에서도 사용됩니다. 메모리를 적게 차지하기 때문에 수백만개의 오브젝트를 넣을 수 있어서 엄청난 효율성을 가집니다.

이렇게 Redis의 자료구조에 대해서 알아봤습니다. Redis는 Key-Value를 저장할 수 있기 때문에 단순히 String만 저장하는 줄 알았는데 다양한 자료구조를 지원했습니다.

이러한 자료구조를 지원한다는 특징은 같은 Key-Value Store 인 Memcached 데이터베이스와 차별화되는 부분입니다. 이런 다양한 자료구조를 바탕으로 더 다양한 데이터를 저장할 수 있죠.

각각의 Redis 자료구조들은 데이터의 접근에 대한 높은 성능이나 가공을 위한 최적화가 되어있는 특별한 기능을 제공합니다. 예를 들어 List는 특정한 위치에 요소를 삽입하고 붙이고 popping하는 기능을 제공하는 반면에 Set은 추가하고 제거하고 유니크한 객체들을 querying하는 기능을 제공하죠.

전체적으로 Redis의 자료구조들은 효율적이고 유연한 방법으로 in-memory 데이터를 가공하고 저장하는 다양한 방법들을 제공하기위해 설계되었습니다.

Redis의 사용처 (GPT 사용 후 더블체크)

Redis의 사용처는 크게 캐싱, 세션 스토리지, Pub/Sub Messaging, Queue, 실시간 분석 등이 있습니다. 하나씩 알아보도록 하죠.

1. Cache

Redis는 메모리에서 데이터를 자주 꺼내야 하는 상황에서 쓰입니다. 예를 들면 세션 데이터나, 유저 선호도같은 상황에서 주로 사용됩니다.

Redis에 데이터를 저장함으로써 우리는 데이터베이스와 같은 데이터 스토리지에 저장하는 것보다 더 빠른 데이터 접근이 필요한 애플리케이션의 시간을 줄여줍니다.

결과적으로 응답 시간을 줄여주고 더 나은 성능을 보여줄 수 있죠.

2. Session Storage

Redis는 웹 애플리케이션에서 세션 데이터를 저장하는데 사용됩니다. Redis에 세션데이터를 저장함으로써 우리는 여러 서버나 여러 인스턴스를 통해 세션의 상태를 유지할 수 있습니다.

Redis에 세션 데이터를 저장하면 데이터의 확장성과 신뢰성을 유지할 수 있죠.

3. Pub/Sub Messaging

Redis는 애플리케이션 혹은 서비스 사이에 실시간 커뮤니케이션을 용이하게 메시지 브로커로서 역할을 충실히 잘 해냅니다.

Redis는 publish/subscribe 메시지 패러다임을 구현할 수 있는데 메시지 패러다임에서 메시지는 하나 혹은 그 이상의 채널에 보내지고 메시지를 받는 쪽인 subscribe에서 채널을 통해 메시지를 받을 수 있습니다.

Pub/Sub Messaging을 구현하기 위한 Overview를 소개해드리자면

• 대화를 구성하는 데이터들은 Set 자료구조에 저장한다.

• 최근 10개의 메시지는 List에 저장한다.

• 유저들의 기본적인 정보는 Hash에 저장한다.

• 최근 대화는 Sorted Set에 저장한다.

이렇게 구현할 수 있겠습니다.

4. Event Queue

Redis는 job queue나 task queue를 구현하기위해도 사용됩니다. queuing에 Redis를 이용함으로써 우리는 백그라운드에서 작동하거나 임계영역에서 작동하는 우리의 애플리케이션으로부터 시간이 절약되는 경험을 할 수 있습니다.

Event에 해당하는 유저의 클릭이나, 유저의 키보드 입력, 유저의 터치 등을 Redis에 저장하고 이를 알맞게 데이터를 처리할 수 있습니다.

Event Queue는 자바스크립트로 array를 이용해 구현이 가능합니다. 하지만 그럼에도 우리가 Redis를 이용해 Event Queue를 구현해야 하는이유는 다음과 같습니다.

• 확장성 : Redis는 많은 수의 동시다발적인 연결을 효율적으로 핸들링할 수 있도록 설계되었습니다. 높은 확장성을 요구하는 애플리케이션에서 좋은 선택이 될 수 있습니다.

• 지속성 : Redis는 데이터 지속성을 지원합니다. 이 말은 이벤트들이 설령 서버가 다시 실행될지라도 저장된 데이터를 다시 가져오는 것이 가능하다는 의미입니다.

• 향상된 기능 : Redis는 이벤트를 관리하는데 다양한 향상된 기능을 제공합니다. 예를 들어 pub/sub 메시징같은 것이 바로 그러한데, 이러한 기능들은 대화에 참여한 다수의 클라이언트들을 실시간으로 관리할 수 있다는 의미입니다.

• 통합 : Redis는 우리의 애플리케이션 스택의 다른 컴포넌트들을 쉽게 통합할 수 있습니다. 예를 들어서 웹 서버나 메시지 브로커같은 기능을 이용해 이를 구현할 수 있습니다.

• 성능 : Redis는 높은 성능을 보여주는데 이러한 성능들은 큰 볼륨의 이벤트들을 적은 레이턴시를 가지고 이상적으로 핸들링할 수 있도록 도와줍니다.

Event Queue와 Message Queue를 헷갈릴 수 있습니다. (제가 헷갈렸거든요..)

Event Queue는 앞서 설명한 것 처럼 유저의 특정 이벤트를 저장하고 관리하는데 초점을 맞춥니다. 하지만 Message Queue는 서로 다른 애플리케이션 사이에 메시지를 주고받으며 통신하는 것을 말합니다.

쉽게 설명해서 Message Queue는 MicroService Architecture에서 서로 다른 도메인끼리 통신을 할 때 사용하는 것이라고 생각하면 쉽습니다.

결제 도메인과 정산 도메인은 서로 컨테이너의 형태로 떨어져있죠. 이 둘이 통신하기 위해서 사용하는 것이 바로 Message Qeuuing System입니다. Message Queuing에는 Apache 의 Kafka나, RabbitMQ, AWS의 SQS 등이 있습니다.

제가 추후에 취직하고 나서 공부해보고 싶은 내용 중 하나이죠.

5. 실시간 분석

마지막으로 Redis를 이용한 실시간 분석입니다. Redis는 실시간 분석 데이터를 저장하는데 사용되기도 하는데요. 예를 들어 이벤트 데이터나 유저의 행동 패턴을 분석한 데이터들이 저장될 수 있는데 Redis에 데이터를 저장함으로써 우리는 실시간 분석 혹은 실시간 데이터 프로세싱을 수행할 수 있습니다.

Redis 배포

사실 여기까지는 잘 몰라도 될 것같아서 조금만 준비했습니다.

Redis를 배포하는 방식에는 Sentinel 과 Cluster 이렇게 두가지 방식이 있습니다. 이것을 구현하고 알아야 할 것이 원래 많아보이는데 지금 공부할 단계는 아니라고 판단해 선택과 집중을 했습니다.

Sentinel : Sentinel은 알람 기능, 마스터 조회, 자동으로 장애에 대해 failover (장애가 발생했을 때 자동으로 시스템이 내려가는 것), 다수결로 투표하여 마스터 노드를 선출하는 것을 제공합니다.

즉 Sentinel은 높은 가용성과 failover같은 Redis의 추가적인 기능을 제공하는 분산시스템입니다.

Cluster : Cluster는 Sentinel과 비슷하지만 다른 점은 sharding 기능이 있다는 것입니다. sharding을 통해 추가적으로 1000개의 노드를 선형적으로 더 생성할 수 있기 때문에 확장성면에서는 Cluster가 조금 더 우위에 있는 모습을 보여줍니다.

마치며

여기까지 Redis에 대한 전체적인 개념에 대해 알아봤습니다. 아무래도 기존 RDBMS는 데이터의 저장만 단순히 수행할 수 있었다면 Redis같은 NoSQL은 조금 더 다양한 분야에서 활용될 수 있다는 것을 알게되는 공부였습니다.

여러분도 기회가 된다면 Chat GPT로 공부해보시면 좋을 것 같습니다. 추가적으로 이번에 보안쪽 인증/인가에 대해서도 GPT로 공부했는데 정말 좋은 공부가 됐습니다. 추후에 이 내용도 블로그에 포스팅할 예정입니다.

참고로 Chat GPT를 사용하신다면 영어로 질답을 하시는 것이 좋습니다. 영어를 읽고 쓰는 것이 조금 부족하시다면 번역기를 사용해서라도 영어로 사용하시는게 조금 더 높은 정확성과 높은 퀄리티를 보장할 수 있습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 보내세요!

더블체크 출처

https://redis.com/redis-enterprise/data-structures/

https://severalnines.com/blog/introduction-redis-what-it-what-are-use-cases-etc/

https://redis.com/glossary/event-queue/

https://www.baeldung.com/redis-sentinel-vs-clustering

=> Sentinel 과 Cluster의 차이에 대한 Docs

Redis를 이용해 캐싱 구현하기 (with Spring)

저번 포스팅에선 Redis의 기본적인 개념에 대해서 알아봤습니다. Redis가 어떻게 NoSQL중에서 가장 빠른 성능을 보여줄 수 있었는지, 자료구조는 어떤 것을 지원하는지, 사용처는 어떤게 있는지, 배포방법까지 알아봤죠. 자세한 내용은 아래를 참고해주세요!

https://coding-review.tistory.com/364

이번 포스팅에선 Redis를 이용해 캐싱을 구현해보도록 하겠습니다.

우선 결론부터 말씀드리자면 페이지 전체를 캐싱할 수는 없습니다. 페이지에 필요한 데이터들만 캐싱이 가능한 것으로 추정되구요.

캐싱을 구현하는건 생각보다 매우 간단합니다. 하나하나 천천히 한번 알아보도록 하겠습니다.

우선 Redis를 시작해야겠죠. Redis를 설치하는 방법은 생략하도록 하겠습니다. 또한, Redis의 GUI 툴은 Medis를 사용했습니다.

Redis 시작하기

1. 의존관계를 설정한다

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

2. application.yml (or properties) 에 설정정보를 기입한다.

spring:

data:

redis:

host: localhost

port: 6379

3. Redis를 사용하기위한 Bean 설정정보 등록하기

Redis를 사용하는 방법에는 두가지가 있습니다. RedisTemplate를 사용하는 방법과 Spring Data Redis를 사용하는 방법이죠. 저는 Spring Data Redis를 사용하도록 하겠습니다.

RedisTemplate을 사용하는 방법도 어렵지 않으니까요 구글링해보시면 쉽게 접근하실 수 있으실겁니다.

참고로 RedisTemplate과 Spring Data Redis를 사용하는 것의 차이는 조금 더 복잡하거나 세밀한 설정을 원한다면 RedisTemplate을 사용하시는걸 추천드리구요, 간단한 데이터 조작만 하실거라면 Spring Data Redis를 사용하시는 것을 추천드리도록 하겠습니다.

3-1. Redis Config 만들기

import org.springframework.beans.factory.annotation.Value;

import org.springframework.cache.CacheManager;

import org.springframework.cache.annotation.EnableCaching;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.data.redis.cache.RedisCacheConfiguration;

import org.springframework.data.redis.cache.RedisCacheManager;

import org.springframework.data.redis.connection.RedisConnectionFactory;

import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

import org.springframework.data.redis.serializer.RedisSerializationContext;

import java.time.Duration;

@EnableCaching

@Configuration

public class CacheConfig {

@Value("${spring.data.redis.host}")

private String host;

@Value("${spring.data.redis.port}")

private int port;

@Bean

public RedisConnectionFactory redisConnectionFactory() {

return new LettuceConnectionFactory(host, port);

}

@SuppressWarnings("deprecation")

@Bean

public CacheManager cacheManager() {

RedisCacheManager.RedisCacheManagerBuilder builder =

RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());

RedisCacheConfiguration configuration =

RedisCacheConfiguration.defaultCacheConfig()

.serializeValuesWith(RedisSerializationContext

.SerializationPair

.fromSerializer(new GenericJackson2JsonRedisSerializer()))

.prefixCacheNameWith("Test")

.entryTtl(Duration.ofMinutes(30));

builder.cacheDefaults(configuration);

return builder.build();

}

@Bean

public RedisTemplate<String, Object> redisTemplate() {

RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

redisTemplate.setConnectionFactory(redisConnectionFactory());

return redisTemplate;

}

}

cacheManager를 빈으로 만들어 후에 스프링에게 해당 캐시매니저를 쓸거다 라고 지시해주기 위해 만드는건데요.

여기에는 어떤 Redis 구현체를 사용할건지, 직렬화 방식은 어떤 구현체를 사용할건지, 캐시의 이름은 뭔지, 얼마나 오래동안 캐싱할건지를 설정합니다.

참고로 Redis 구현체는 Jedis와 Lettuce가 있는데 기존에는 Jedis를 많이 사용했지만 요즘은 Jedis와 비교해서 Lettuce의 강력한 기능때문에 Lettuce를 사용한다고 합니다.

Lettuce는 비동기 요청을 처리하기 때문에 성능상 이점이 있고 Lettuce는 Jedis와 다르게 별도의 풀을 생성하지 않아도 되기 때문에 개발이 단순하다는 장점도 있죠. 또한 Jedis는 스레드세이프 하지 않아서 Spring boot 2.0부터는 deprecated 되었습니다.

또한, Redis는 캐싱을 진행할 때 어떤 방식으로 직렬화를 진행해야하는지 정해주지않으면 오류를 뱉습니다. 우리는 Json으로 직렬화를 할 것이기 떄문에 GenericJackson2JsonRedisSerializer 구현체를 사용할 것입니다.

4. Redis 객체 생성해주기

Redis 객체를 만들어주면 해당 포맷으로 Redis에 저장됩니다. 마치 DTO라고 생각하시면 편합니다.

import lombok.Getter;

import lombok.NoArgsConstructor;

import org.springframework.data.annotation.Id;

import org.springframework.data.redis.core.RedisHash;

@Getter

@RedisHash(value = "cache", timeToLive = 300)

@NoArgsConstructor

public class CacheData {

@Id

private String id;

private String name;

private int age;

public CacheData(String name, int age) {

this.name = name;

this.age = age;

}

}

클래스 레벨에 @RedisHash를 적어야 해당 클래스를 Redis에 정해진 포맷으로 저장합니다. value 속성으로는 Redis에 저장되는 이름을, 흔히 TTL이라고 부르는 timeToLive는 데이터베이스에 얼만큼의 시간동안 저장될 것인지를 정하는 설정입니다.

또한, 주의해야하는 점으로는 @Id인데요. JPA에서 사용하는 패키지가 아닌 org.springframework.data.annotation을 사용해야합니다.

@Id로 설정된 값은 생성자를통해 원하는 값으로 저장할 수 있지만 대부분 null 값으로 냅둬서 저절로 생성되는 랜덤값을 사용한다고 하더라구요.

추가적으로 html 폼에서 받아올 데이터를 바인딩할 DTO도 만들어보겠습니다.

import lombok.Data;

@Data

public class ResponseDto {

private String name;

private int age;

public ResponseDto(String name, int age) {

this.name = name;

this.age = age;

}

}

5. Redis Repository 생성하기

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;

import org.springframework.data.repository.CrudRepository;

public interface RedisCacheRepository extends CrudRepository<CacheData, String> {

}

참고로 Spring Data Redis는 JPA처럼 RedisRepository 구현체가 없습니다. 따라서 CrudRepository를 사용해야 하는데요. CrudRepository는 제공하는 기능이 JPA만큼 강력한 기능이 없습니다.

때문에 CrudRepository가 제공하는 메서드말고 다른 메서드를 만들기 위해서는 커스텀 인터페이스를 만들어서 사용해야 합니다.

또한, JPA처럼 쿼리메서드가 작동하지 않는것으로 확인됐습니다. 뭣모르고 findByName 같은거 썼다가 오류를 마구 뱉길래 알아보니 따로 쿼리메서드는 지원하지 않는 것으로 결론내렸습니다.

따라서 findByName같은 메서드를 만드시고 싶다면 커스텀 인터페이스를 만드시고 RedisTemplate을 이용해 구현하시면 됩니다. 이번 포스팅에서는 결이 맞지 않기 때문에 커스텀 인터페이스를 만드는 과정은 생략하도록 하겠습니다.

6. Redis Service 생성하기

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;

import com.project.configureSessionWithRedis.configureCacheWithRedis.repository.RedisCacheRepository;

import lombok.RequiredArgsConstructor;

import lombok.extern.slf4j.Slf4j;

import org.springframework.stereotype.Service;

import java.util.ArrayList;

@Service

@RequiredArgsConstructor

@Slf4j

public class CacheService {

private final RedisCacheRepository cacheRepository;

public ArrayList<CacheData> findAll() {

log.info("findAll Service 호출");

Iterable<CacheData> findAll = cacheRepository.findAll();

ArrayList<CacheData> list = new ArrayList<>();

findAll.forEach(x -> {

if (x != null) {

list.add(x);

}

});

return list;

}

public CacheData save(ResponseDto dto) {

return cacheRepository.save(new CacheData(dto.getName(), dto.getAge());

}

}

캐싱이 제대로 작동하는지 확인하기위해 로그를 위한 Slf4j를 선언했구요. Repository에서 findAll을 통해 모든 데이터를 캐싱할 예정입니다.

참고로 findAll을 선언하면 기존 JPA에선 ArrayList로 반환되는 것과 다르게 Iterable로 반환됩니다. Iterable을 List에 넣을 때는 위와같이 forEach를 사용해 람다를 가지고 넣어야합니다.

저는 캐싱에 들어오는 값을 null safety를 위해 null 체크를 한번 했습니다.

그리고 save 까지 만들었습니다.

7. Controller를 통해 캐싱데이터 만들기

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;

import com.project.configureSessionWithRedis.configureCacheWithRedis.service.CacheService;

import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Controller;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.ModelAttribute;

import org.springframework.web.bind.annotation.PostMapping;

@Controller

@RequiredArgsConstructor

public class CacheSaveController {

private final CacheService cacheService;

@GetMapping("/save_cache")

public String saveCache() {

return "save_cache";

}

@PostMapping("/save_cache")

public String saveCachePost(@ModelAttribute ResponseDto dto) {

cacheService.save(dto);

return "complete";

}

}

저는 html 폼으로 캐싱할 데이터를 받았구요. /save_cache 로 들어오는 값을 save 했습니다.

import com.project.configureSessionWithRedis.configureCacheWithRedis.dto.CacheData;

import com.project.configureSessionWithRedis.configureCacheWithRedis.service.CacheService;

import lombok.RequiredArgsConstructor;

import lombok.extern.slf4j.Slf4j;

import org.springframework.cache.annotation.Cacheable;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;

@RestController

@RequiredArgsConstructor

@Slf4j

public class CacheController {

private final CacheService cacheService;

@GetMapping("/getData")

@Cacheable(value = "CacheData", cacheManager = "cacheManager")

public ArrayList<CacheData> getData() {

return cacheService.findAll();

}

}

클래스를 하나 더만들어서 RestController로 만들었구요. 이 컨트롤러에선 findAll을 통해 데이터가 캐싱되는지 확인해볼겁니다.

중요한것은 메서드에 @Cacheable 어노테이션을 반드시 달아주셔야 한다는 것입니다. value 속성에는 우리가 방금 만들었던 Redis 객체 (@RedisHash 어노테이션 달았던 그것) 를 적어주고 cacheManager에는 config 클래스에 빈으로 등록했던 빈 이름을 적어주시면 됩니다.

자 모든 준비가 끝났습니다. 한번 캐싱이 잘 되는지 확인해볼까요?

html 폼으로 데이터를 입력하고 데이터베이스를 보겠습니다.

두개의 값을 저장했고 /getData로 GET 요청을 하니 캐시가 잘 생성된 것을 볼 수 있습니다.

이후 새로고침을 마구 눌러도 로그가 더이상 생성되지 않습니다. 이는 Service 클래스를 통과하지 않고 즉각적으로 Redis에서 캐싱하고 있다는 얘기이죠.

마치며

여기까지 Redis로 캐싱을 진행해봤습니다. 사이트에서 값이 자주 변하지 않는 데이터를 캐싱하면 애플리케이션의 전체적인 성능이 올라갈 것으로 예측됩니다.

긴글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

Redis를 이용해 세션 구현하기 (with Spring)

저번 포스팅에선 Redis를 이용해 캐싱을 구현해봤습니다.

https://coding-review.tistory.com/365

이번 포스팅에선 Redis를 이용해 세션을 구현해보도록 하겠습니다. 설정이나 사용하는 방법에 대해서는 위의 포스팅을 참고해주시면 감사하겠습니다.

사실 Redis로 세션을 구현하는 것은 정말정말 간단합니다. 하지만 너무 간단하기 때문에 재미가 없습니다. 따라서 이번 포스팅에선 먼저 간단한 구현을 알아보고 특정한 상황을 가정하고 진행해보도록 하겠습니다.

우선 의존성을 추가해줘야합니다.

implementation 'org.springframework.session:spring-session-data-redis'

그리고 컨트롤러를 만들어주면?

import jakarta.servlet.http.HttpSession;

import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RequestParam;

import org.springframework.web.bind.annotation.RestController;

@RestController

public class RedisSessionController {

@GetMapping("/create_session")

public String createSession(HttpSession session, @RequestParam("name") String name) {

session.setAttribute("name", name);

return "create success";

}

@GetMapping("/check_session")

public String checkSession(HttpSession session) {

String name = (String) session.getAttribute("name");

return name;

}

}

끝입니다.

이제 해당 URL로 접속해서 session을 만들고 조회를 해보면 session값이 잘 저장되는 것을 볼 수 있습니다.

http://localhost:8080/create_session?name=ks

http://localhost:8080/check_session

너무 간단하다!
너무 간단하다!

이렇게 값이 잘 나오고 데이터베이스에 가보면?

값도 잘 저장된 것을 볼 수 있습니다.

끝입니다!

정말 너무 간단하지 않나요? 저도 처음에 너무 간단해서 어안이 벙벙했습니다...

이렇게 Redis에 세션을 넣어서 구현해봤는데요. 그냥 HttpSession을 이용해 구현하는 것과 Redis를 이용해 세션을 구현하는 것에 차이가 있습니다.

1. 서버가 내려가더라도 세션이 살아있다.

우선 기존에는 WAS를 내려버리면 세션값이 사라지는 현상이 있었습니다. 때문에 서버를 내리고 올릴때마다 다시 로그인했어야했죠. (토이프로젝트 할 때 겁나 귀찮았습니다)

하지만 Redis를 이용해 세션을 구현하면 서버를 내리더라도 Redis에 세션값이 저장되어 있기 때문에 세션이 유지가 됩니다.

보통 세션을 처음 공부하면 이런 의문이 드는데요. 바로 session.getAttribute("name"); 이라고 구현하면 사용자A도 해당 코드를 탈 것이고, 사용자B도 해당 코드를 타는데 어떻게 둘이 다른 값을 가지고 있지? 라는 궁금증이 듭니다.

세션은 서버에 저장할 때 사용자ID, 언제 세션이 만들어졌는지, 사용자의 IP등을 같이 저장하기 때문에 다른 값을 반환받는 것입니다.

Redis에 세션을 저장하는 것도 비슷한 방식입니다. 잘 보시면 sessionAttr에 값이 저장되고, lastAccessTime과 creationTime을 저장해 사용자를 판별하는 모습을 보여주죠.

2. 로드밸런싱에서도 유용하다

또한 Redis를 이용하면 서버를 여러개 두고 트래픽을 관리하는 로드밸런싱에도 장점이 있습니다.

만약 8080으로 들어온 사용자와 8081로 들어온 사용자가 세션을 만들었다고 가정해보면 실제로 F12를 눌러서 확인해보면 이 둘의 세션아이디가 다릅니다. 이러면 잘못된 세션에 저장하나마나 계속 새로운 세션을 만드는겁니다.

이런 상황에서 Redis를 사용하면 같은 세션값을 보장해줍니다. 이를 Redis Clustering이라고 하더군요. 하지만 데이터베이스 튜닝 방식중 하나인 클러스터링과 헷갈려서 저는 썩 좋은 표현은 아닌 것 같습니다.

아무튼 Redis를 사용해 세션을 구현하면 장점이 꽤 많기 때문에 정말 많이 애용할 것 같습니다.

사용자 인증을 하는 방법에는 크게 Redis + Session / JWT 인증토큰 이렇게 두가지가 있다고합니다.

Redis + Session은 Stateful 프로토콜이구요. JWT 인증토큰은 Stateless 프로토콜이라고 합니다.

그렇기 때문에 Stateful 과 Stateless 에서 적용되는 장단점을 그대로 가지고 있습니다.

이번 포스팅이 너무 짧았기 때문에 막간을 이용해 Stateful 과 Stateless의 장단점을 보도록 하죠.

Stateful VS Stateless

*Stateful

Stateful은 현재 세션에 다음 트랜잭션에서 필요한 정보를 가지고 있는 경우를 말합니다. 때문에 클라이언트는 가지고 있어야할 데이터의 양이 적고 서버가 가지고 있어야할 데이터가 많죠.

보통 하나의 세션에 데이터가 이어져야하는 온라인뱅킹이나 SMTP에서 주로 사용합니다.

하지만 이런 Stateful의 특징 때문에 확장성이 떨어지고 멀티태스킹에 능하지 못하다는 단점이 있습니다. 이러한 단점은

MicroService Architecture(이하 MSA)를 채택한 프로젝트에서는 알맞지 못한 방식입니다.

하지만 방금 보셨다시피 구현하기가 굉장히 쉬워서 개발자 입장에서는 굉장히 좋습니다.

*Stateless

Stateless는 Stateful과 다르게 현재 세션에 다음 트랜잭션에서 필요한 정보를 가지고 있지 않는 경우를 말합니다. 때문에 클라이언트는 가지고 있어야할 데이터의 양이 많고, 서버가 가지고 있어야할 데이터의 양이 적은 프로토콜이죠.

Stateful과 다르게 확장성과 멀티태스킹에 굉장히 용이하고 때문에 많은 서비스가 Stateless 즉, 무상태로 개발되었습니다. 우리가 가장 많이 사용하는 HTTP부터 DNS, SMS가 바로 무상태로 설계된 프로토콜입니다.

JWT가 Stateless라고 말했었죠. 때문에 JWT는 확장성이 극대화된 MSA에서 알맞은 서비스입니다. 하지만 이런 확장성과 별개로 구현하기가 굉장히 까다롭다는 단점이 있는데요.

항상 refresh token과 access token을 관리해주어야하고, JWT 토큰을 대충 사용했다간 보안상으로 굉장히 취약해질 수 있습니다.

마치며

이번에 우리가 알아본 방식은 Stateful 프로토콜 중 하나인 Redis + Session을 이용해 사용자 인증을 구현해봤습니다. 매우 간단하니 여러분들도 자주 사용해보시면 좋은 경험을 얻을 수 있을 것 같습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

Redis 로 캐싱 구현하기 ver.2 (Low Level 코딩)

https://coding-review.tistory.com/365

우리는 저번 캐싱 구현에서 Spring Data Redis를 적극적으로 활용해 CrudRepository를 상속받아 사용했습니다. 하지만 이 방법은 high level 코딩이었기 때문에 문제가 몇가지 있습니다.

바로 Redis에서 제공해주는 다양한 자료구조 중 Hash 밖에 이용할 수 없다는 것이죠.

Redis에선 String, Set, Sorted Set, Hash, List 이렇게나 많은 자료구조를 지원하는데 그 중 Hash만 사용할 수 있다는 것은 확실히 제 기능을 다 쓰지 못하는 것이죠.

이번 시간에는 Redis를 low level로 사용하는 방법에 대해서 알아보도록 하겠습니다.

package com.hello.capston.config;

import org.springframework.beans.factory.annotation.Value;

import org.springframework.cache.CacheManager;

import org.springframework.cache.annotation.EnableCaching;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.data.redis.cache.RedisCacheConfiguration;

import org.springframework.data.redis.cache.RedisCacheManager;

import org.springframework.data.redis.connection.RedisConnectionFactory;

import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

import org.springframework.data.redis.serializer.RedisSerializationContext;

import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration

@EnableCaching

public class RedisConfig {

@Value("${spring.data.redis.host}")

private String host;

@Value("${spring.data.redis.port}")

private int port;

@Bean

public RedisConnectionFactory redisConnectionFactory() {

return new LettuceConnectionFactory(host, port);

}

@Bean

public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {

RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

redisTemplate.setConnectionFactory(redisConnectionFactory());

redisTemplate.setKeySerializer(new StringRedisSerializer());

redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());

return redisTemplate;

}

@SuppressWarnings("deprecation")

@Bean

public CacheManager cacheManager() {

RedisCacheManager.RedisCacheManagerBuilder builder =

RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());

RedisCacheConfiguration configuration =

RedisCacheConfiguration.defaultCacheConfig()

.serializeValuesWith(RedisSerializationContext

.SerializationPair

.fromSerializer(new GenericJackson2JsonRedisSerializer()))

.prefixCacheNameWith("cache")

.entryTtl(Duration.ofMinutes(30));

builder.cacheDefaults(configuration);

return builder.build();

}

}

이전에 만들었던 RedisConfig.java 입니다. cacheManager를 빈으로 등록해서 컨트롤러에 붙여서 사용했었죠.

이 방식은 프론트에서 사용자의 정보가 필요해 API를 요청하는 경우에 사용하면 좋은 방법입니다.

하지만 우리는 지금 서버 내부에서 캐싱해서 사용하고 싶은 상황입니다.

RedisTemplate 객체를 만들고 빈으로 등록한 다음 Key는 String으로 직렬화할 것이고, Value에 해당하는 값은 Json으로 직렬화하기 위해 GenericJackson2JsonRedisSerializer를 사용하겠습니다.

이제 Key를 만들어주는 Generator를 만들어보도록 하죠.

package com.hello.capston.repository.cache;

public class KeyGenerator {

private static final String MEMBER_KEY = "member";

private static final String USER_KEY = "user";

public static String memberKeyGenerate(String loginId) {

return MEMBER_KEY + " : " + loginId;

}

public static String userKeyGenerate(String email) {

return USER_KEY + " : " + email;

}

}

저는 세션에 저장한 Member 객체의 loginId와 User 객체의 email을 가지고 데이터를 가져올 것이기 때문에 파라미터로 loginId와 email을 넣어줬습니다.

이제 Repository를 만들면 됩니다.

package com.hello.capston.repository.cache;

import com.hello.capston.entity.Member;

import com.hello.capston.entity.User;

import com.hello.capston.repository.MemberRepository;

import com.hello.capston.repository.UserRepository;

import lombok.RequiredArgsConstructor;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.stereotype.Repository;

import java.util.concurrent.TimeUnit;

@Repository

@RequiredArgsConstructor

public class CacheRepository {

private final RedisTemplate<String, Object> redisTemplate;

private final MemberRepository memberRepository;

private final UserRepository userRepository;

public void addMember(Member member) {

String key = KeyGenerator.memberKeyGenerate(member.getUsername());

if (!isMemberWasFound(member.getUsername())) {

redisTemplate.opsForValue().set(key, member);

redisTemplate.expire(key, 60, TimeUnit.MINUTES);

}

}

public void addUser(Long userId) {

User findUser = userRepository.findById(userId).orElseThrow(

() -> new RuntimeException("Not Found User")

);

String key = KeyGenerator.userKeyGenerate(findUser.getEmail());

if (!isUserWasFound(findUser.getEmail())) {

redisTemplate.opsForValue().set(key, findUser);

redisTemplate.expire(key, 60, TimeUnit.MINUTES);

}

}

public Member findMemberAtCache(String loginId) {

String key = KeyGenerator.memberKeyGenerate(loginId);

return (Member) redisTemplate.opsForValue().get(key);

}

public User findUserAtCache(String email) {

String key = KeyGenerator.userKeyGenerate(email);

return (User) redisTemplate.opsForValue().get(key);

}

private boolean isMemberWasFound(String loginId) {

Member findMember = findMemberAtCache(loginId);

if (findMember != null) {

return true;

}

return false;

}

private boolean isUserWasFound(String email) {

User findUser = findUserAtCache(email);

if (findUser != null) {

return true;

}

return false;

}

}

RedisTemplate을 주입받아서 만들면 되는데 문법은 아주 간단합니다. 자주 쓰이는 문법만 빠르게 알려드릴게요.

• redisTemplate.opsForValue() : 이 메서드는 Value를 String으로 저장하는 방식입니다.

• redisTemplate.opsForSet() : 이 메서드는 Value를 Set으로 저장합니다.

• redisTemplate.opsForHash() : 이 메서드는 Value를 Hash로 저장합니다.

• redisTemplate.opsForList() : 이 메서드는 Value를 List로 저장합니다.

• set(String Key, Object Value) : 두번째 값이 Object인 이유는 우리가 redisTemplate을 처음 만들 때 Object로 만들었기 때문입니다. 저는 Member, User 객체 그 자체를 넣을 것이기 때문에 Object로 설정했습니다.

• redisTemplate.expire(String Key, int timeOut, TimeUnit timeUnit) : TTL을 설정하는 부분입니다. 원래 우리는 Redis 객체를 클래스로 만들고 TTL 설정 값을 부여했었죠?

이렇게 만들면 되겠습니다.

그리고 CacheRepository를 주입받아서 캐싱할 객체를 addMember혹은 addUser를 이용해 저장하고 cacheRepository.findMemberAtCache(String loginId) 를 사용하면 됩니다!

저는 캐싱할 데이터를 저장하는 곳을 OAuth 객체를 만들때와 Spring Security가 Principal을 만들 때 저장했습니다.

마치며

여기까지 Redis로 low level 코딩을 해봤습니다. 이전 시간에는 @Cacheable을 이용해서 캐싱하기 때문에 컨트롤러를 타지 않으면 캐싱이 안되는줄 알았습니다. 하지만 Redis의 속도가 워낙 빠르기 때문에 저장하고 가져오는 것이 캐싱하는 효과를 불러 일으킨 다는 것을 알게되었습니다.

주의하셔야할 점은 캐싱을 하면 성능이 좋아지긴 하지만 모든 데이터를 캐싱하지 않으셨으면 좋겠습니다. 값이 자주 바뀌는 경우에는 캐싱을 하는 것이 오히려 좋지 않으니 고정적인 데이터를 캐싱하는 것을 추천드립니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

Elasticsearch CRUD 해보기

이번 포스팅에선 Elasticsearch 의 테이블 (Elasticsearch에선 Index) 생성과 값을 집어넣고, 읽고, 변경하고, 지우는 방법을 알아보도록 하겠습니다.

우선 Elasticsearch에 대한 전반적인 이해가 있으면 정말 좋습니다. 아래의 링크에서 확인해주세요!

https://coding-review.tistory.com/315

그리고 하나 또 알아두셔야 하는 것이 바로 Elasticsearch는 NoSQL 카테고리에 들어갑니다. 때문에 SQL처럼 쿼리를 날려서 테이블을 만들거나 값을 저장하지 않는다는 의미이죠.

우선 Elasticsearch의 다운로드 주소는 아래입니다!

https://www.elastic.co/kr/downloads/past-releases#elasticsearch

압축을 해제하고 bin 폴더에 elasticsearch.bat 파일을 실행하면 실행이 된겁니다.

앞선 Redis에선 RedisTemplate으로 값을 저장하고 조회했습니다. 그럼 Elasticsearch는? 바로 HTTP 통신으로 값을 저장합니다.

저는 이 사실을 알고 정말 깜짝 놀랐습니다. 하다하다 HTTP 통신으로 데이터를 집어넣다니..

때문에 Elasticsearch에선 값을 저장하고 조회할 때 특정한 조건이 필요합니다. 때문에 준비물은 Postman입니다.

Postman의 다운로드 경로는 바로 아래로 가셔서 다운 받으시면 됩니다.

https://www.postman.com/downloads/

이제 본격적으로 시작해보죠!

테이블 (Index 생성)

Elasticsearch에선 SQL의 테이블을 Index (이하 인덱스) 라고 부릅니다.

Elasticsearch는 철처하게 RESTful API의 규칙을 따르고 있습니다. 때문에 HTTP 메서드에 대해서 알고 계시면 더 이해하기 편하실겁니다.

그럼 인덱스를 생성해볼까요?

• 먼저 HTTP 메서드를 PUT으로 바꾸고

• localhost:9200 (Elasticsearch는 9200포트를 사용합니다) 뒤에 자신이 만들고싶은 인덱스 이름을 적어줍니다. 저는 item이라고 적었습니다.

• 주소창 아래 Body를 클릭하고

• raw를 클릭한다음

• 기존 TEXT라고 되어있는 것을 JSON으로 바꿔줍니다. 이러면 자동적으로 application/json 으로 바꿔줍니다.

• 그리고 Body에 내용을 적으시면 됩니다.

Body에 적힌 내용을 하나씩 살폅보도록 하자면

• settings : 여기에 shard와 replica의 개수가 들어갑니다. 이 세팅으로 얼마나 큰 데이터 셋을 담을 것인지를 설정하는 것이죠.

• mappings : mappings는 어떤 데이터를 매핑시킬 것인지를 정하는 곳입니다.

properties : 이곳은 SQL로 치면 column을 설정하는 곳입니다.

type : 어떤 타입의 데이터를 담을 것인지 선택합니다. 저는 text를 담을 것이기 때문에 text로 설정했습니다.

저는 이렇게 간단하게 설정했지만 실전에선 더 다양한 정보들을 기입해야 합니다.

짧게 설명만 해드리자면

• type : 이 옵션은 데이터 타입을 설정합니다. text, keyword, date, long, double, boolean을 설정할 수 있습니다.

• index : 이 옵션은 찾을 수 있는 필드인지 설정하는 것입니다. true로 설정하면 검색가능하다는 것이고 false로 하면 검색할 수 없는 필드라는 의미입니다.

• analyzer : 이 옵션은 인덱스를 만들 때 필드에 대해 사용되어야 하는 애널라이저를 설정할 수 있습니다.

• search_analyzer : 이 옵션은 검색을 할 때 필드에 대해 사용되어야 하는 애널라이저를 설정할 수 있습니다. 위의 것과 비슷한 듯 조금 다르죠?

• fields : 이 옵션은 다른 방법으로 같은 필드를 찾거나 인덱스할 수 있게 해줍니다.

저도 아직 Hello world 수준이라 자세히는 알지 못하지만 언젠가 사용할 날이 오길 기대해봅니다.

CRUD

Create

먼저 데이터를 삽입해야겠죠?

이제 Postman의 대략적인 조작 방식은 알고 있다고 가정하고 빠르게 넘어가겠습니다.

먼저 POST로 바꾸고 localhost:9200 뒤에 인덱스 이름 그리고 _doc 을 붙여주고 / (슬래쉬) 하나를 붙여줍니다.

바디를 JSON으로 바꾸고 내가 설정한 인덱스의 필드와 일치하게 넣으면 됩니다.

Read

그냥 GET요청으로 바꾸면 데이터를 읽을 수 있지만...

우리는 이런식으로 안쓸거잖아요?

검색어가 있고 그 검색어를 바탕으로 검색할 것입니다.

먼저 GET요청으로 바꾸고 자신이 검색할 인덱스 이름 뒤에 _search 를 붙여서 내가 검색할 것이라는 것을 알려줍니다.

그리고 바디에 query, match를 연달아 적고 field 이름을 적고 검색어를 검색하면 됩니다.

Update

앞선 예제와 거의 비슷합니다. 조금 다른 점은 인덱스 뒤에 _update 를 붙인다는 것과 업데이트할 데이터를 적는 다는 것입니다.

Delete

Delete는 document 아이디를 알아야 삭제가 됩니다. document는 SQL로 치면 row에 해당하는 각각의 데이터를 말합니다.

그리고 내가 넣은 데이터를 눈으로 보고싶으신 분은 제가 사이트 하나를 추천해드릴게요. Elasticsearch GUI가 많이 있지만 저는 이걸 사용하고 있습니다.

https://chrome.google.com/webstore/detail/elasticvue/hkedbapjpblbodpgbajblpnlpenaebaa

크롬 확장 프로그램이고 공짜입니다. 사용법은 어렵지 않으니 몇번 만져보시면 바로 익숙해지실겁니다.

마치며

이렇게 Elasticsearch 인덱스 생성과 CRUD에 대해서 알아봤습니다. NoSQL은 진입장벽이 정말 어마어마하네요...

저도 필요에 의해 익힌 기술이지만 정말 까다롭기 그지없습니다. 왜 RDBMS가 현재 주류인지 알 것 같은 느낌이 드네요.

아무튼 Elasticsearch는 검색엔진인만큼 검색에 특화되어 있습니다. 그렇기 때문에 검색성능을 향상시키고 싶으신 분들은 한번쯤 해보시는 것을 추천합니다!

Elasticsearch 스프링에서 사용하기

이번 포스팅에선 이제 제가 공부하는 스프링에서 사용해봐야죠. 우리는 앞선 포스팅에서 Elassticsearch의 전반적인 내용과 CRUD를 해봤습니다.

스프링에선 Elasticsearch를 어떻게 사용하고있을까요?

한번 알아보죠!

스프링에서 Elasticsearch 사용하기

// elasticsearch

implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'

implementation 'org.elasticsearch.client:elasticsearch-rest-high-level-client'

우선 의존성을 추가해줍니다.

ElasticsearchConfig.java

package com.hello.capston.elasticsearch.config;

import org.apache.http.HttpHost;

import org.elasticsearch.client.RestClient;

import org.elasticsearch.client.RestClientBuilder;

import org.elasticsearch.client.RestHighLevelClient;

import org.springframework.beans.factory.annotation.Value;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

@Configuration

public class ElasticsearchConfig {

@Value("${spring.data.elasticsearch.host}")

private String host;

@Value("${spring.data.elasticsearch.port}")

private int port;

@Bean(destroyMethod = "close")

public RestHighLevelClient client() {

RestClientBuilder builder = RestClient.builder(new HttpHost(host, port));

RestHighLevelClient client = new RestHighLevelClient(builder);

return client;

}

}

Elasticsearch에서 사용될 Configuration 클래스입니다.

Elasticsearch에선 high-level 과 low-level을 지원하는데 둘의 차이는 low-level을 사용하기 쉽게 추상화한 것이 바로 high-level입니다. 둘의 성능적인 차이는 잘 모르겠지만 조금 변태같은 취미가 있으신 분들은 low-level을 추천드립니다.

또한 저는 spring-data-elasticsearch를 사용하지 않았습니다. 사용방법은 spring-data-jpa와 비슷하다고 합니다.

ElasticItem.java

package com.hello.capston.elasticsearch.entity;

import lombok.Getter;

import lombok.NoArgsConstructor;

import org.springframework.data.annotation.Id;

import org.springframework.data.annotation.PersistenceConstructor;

import org.springframework.data.elasticsearch.annotations.Document;

import org.springframework.data.elasticsearch.annotations.Field;

import org.springframework.data.elasticsearch.annotations.FieldType;

@Document(indexName = "item")

@Getter

@NoArgsConstructor

public class ElasticItem {

@Id

private String id;

@Field(type = FieldType.Text)

private String view_name;

@Field(type = FieldType.Text)

private String item_url;

@Field(type = FieldType.Text)

private String item_name;

@Field(type = FieldType.Integer)

private int price;

@PersistenceConstructor

public ElasticItem(String view_name, String item_url, String item_name, int price) {

this.view_name = view_name;

this.item_url = item_url;

this.item_name = item_name;

this.price = price;

}

}

이 Document 클래스가 은근히 중요합니다. 클래스 레벨에선 @Document 를 이용해 해당 클래스가 Elasticsearch의 Document와 대응되는 클래스라는 것을 명시합니다.

그리고 각각의 필드들은 실제 Elasticsearch에 저장된 필드명을 적어야하고

가장 중요한 @PersistenceConstructor 를 이용해 실제 Elasticsearch의 객체와 우리 @Document 클래스를 매핑해줍니다.

주의해야하는 점은 생성자에 파라미터로 들어간 내용으로 매핑하기 때문에 맘대로 바꾸시면 안됩니다. Elasticsearch에 있는 필드명과 동일하게 작성해주셔야 에러가 안뜹니다.

package com.hello.capston.elasticsearch.service;

import com.hello.capston.elasticsearch.entity.ElasticItem;

import lombok.RequiredArgsConstructor;

import org.elasticsearch.index.query.QueryBuilder;

import org.elasticsearch.index.query.QueryBuilders;

import org.springframework.data.domain.PageRequest;

import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;

import org.springframework.data.elasticsearch.core.SearchHit;

import org.springframework.data.elasticsearch.core.SearchHits;

import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;

import org.springframework.stereotype.Service;

import java.util.List;

import java.util.stream.Collectors;

@Service

@RequiredArgsConstructor

public class ElasticsearchService {

private final ElasticsearchRestTemplate template;

public List<ElasticItem> search(String name) {

QueryBuilder queryBuilder = QueryBuilders.matchQuery("viewName", name);

SearchHits<ElasticItem> searchHits = template.search(new NativeSearchQueryBuilder()

.withQuery(queryBuilder)

.withPageable(PageRequest.of(0, 10))

.build(), ElasticItem.class);

List<ElasticItem> collect = searchHits.get().map(SearchHit::getContent).collect(Collectors.toList());

return collect;

}

}

직접적으로 검색을 도와줄 Service클래스입니다. ElasticsearchRestTemplate을 이용해 쿼리문을 만들어서 HTTP로 쏴주는 역할을 합니다.

실제로 디버깅을 해보시면 쿼리문을 자동으로 생성해주는 역할을 하고 있다는 것을 알 수 있습니다.

이제 이걸 그대로 컨트롤러에서 가져다 쓰면 됩니다.

컨트롤러에서 사용하는건 너무 간단해서 포스팅에 작성하지는 않겠습니다.

마치며

이렇게 스프링에서 Elasticsearch를 사용하는 방법에 대해서 알아봤습니다.

스프링에서 사용하는 방법은 그렇게 어렵진 않았지만 이 방법을 찾기까지 수많은 삽질이 있었습니다. 뭐 별에별 방법들이 있었는데 저는 이 방법이 가장 깔끔하고 간단하면서 유지보수 하기도 쉬운 것 같습니다.

여기까지 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

Elasticsearch 에 MySQL 데이터 동기화하기 (데이터 마이그레이션)

이번 포스팅에선 Elasticsearch의 데이터와 MySQL 데이터를 동기화하는 방법에 대해서 포스팅해보도록 하겠습니다.

우선 제가 이 방법이 필요했던 이유는 기존 프로젝트에서 이미 수백개의 데이터가 있는데 이걸 Elasticsearch와 연동시켜야 제가 원하는 검색성능이 나올 것 같아서 알아보게 되었습니다.

마이그레이션 하느라 조금 고생했는데 한번 보시죠!

Elasticsearch 마이그레이션

Elasticsearch에서 데이터를 마이그레이션 하는 방법에는 몇가지가 있습니다.

• 일일이 집어넣기

• Logstash 사용하기

• go-mysql-elasticsearch 사용하기

저야 수백개가 되는 데이터니까 잘만하면 일일이 집어넣는게 가능하겠지만 현실에선 불가능하겠죠?

또 logstash를 사용하는 방법이 있는데 몇가지 장단점이 있습니다.

장점

• SQL문을 작성해서 내가 원하는 데이터를 뽑아올 수 있다.

• 때문에 진입장벽이 낮은 편

단점

• 메모리를 많이 잡아먹는다

• 때문에 오래걸림

go-mysql-elasticsearch는 go lang을 이용해야하고 터미널 기반인 Mac에서 유용하다고 합니다. 그리고 장단점이 정확히 logstash와 정반대입니다. 저는 속도가 빠른게 장땡이 아니라고 생각하기 때문에 별로 와닿지 못했습니다.

내가 빠른게 좋았으면 C언어를 했지

저는 데이터를 수백만행씩 마이그레이션 할 것이 아니기 때문에 logstash를 이용한 데이터 마이그레이션을 해볼겁니다.

우선 자신의 Elasticsearch와 맞는 버전의 Logstash를 다운받습니다.

https://www.elastic.co/kr/downloads/past-releases#logstash

그리고 ELK 스택이 전부 자바로 되어있기 때문에 우리는 JDBC Driver를 다운받아야합니다.

https://mvnrepository.com/artifact/mysql/mysql-connector-java

JAR파일로 다운 받으셔야 합니다.

이 JAR파일은 Logstash의 lib파일에 넣으시면 됩니다. 따로 법칙이 있는건 아닌 것 같고 국룰인 듯 합니다.

Logstash와 MySQL Driver를 다운받았다면 이제 conf 파일을 만들어야합니다.

input {

jdbc {

jdbc_driver_library => "D:\logstash-8.7.0-windows-x86_64\logstash-8.7.0\lib\mysql-connector-java-8.0.28.jar"

jdbc_driver_class => "com.mysql.jdbc.Driver"

jdbc_connection_string => "jdbc:mysql://localhost:3306/assignment?useSSL=false"

jdbc_user => "MySQL 루트 유저"

jdbc_password => "MySQL 루트 비밀번호"

schedule => "* * * * *"

statement => "SELECT item_id, item_url, view_name, item_name, price FROM item"

}

}

filter {

}

output {

elasticsearch {

index => "item"

document_id => "%{item_id}"

} stdout {

codec => rubydebug

}

}

하나씩 살펴보도록 하죠.

먼저 Logstash에 대해서 대충 말씀드리자면 ELK 스택에서 L에 해당하는 데이터 집계 툴입니다.

데이터 집계 툴이라는 의의답게 데이터를 마이그레이션해서 Elasticsearch에 옮기는 역할을 합니다.

Logstash는 input, filter, output 이렇게 세 가지 단계로 진행됩니다.

Input

• jdbc_driver_library : 아까 저장한 MySQL Driver의 저장위치를 적으시면 됩니다.

• jdbc_driver_class : 이건 자바에서 JDBC를 다뤄보셨으면 한번쯤 보셨을 겁니다.

• jdbc_connection_string : MySQL의 위치와 데이터베이스를 적으시면 됩니다. jdbc:mysql://데이터베이스 주인/데이터베이스 이름 이렇게 적으시면 됩니다.

• jdbc_user : 데이터베이스 루트 사용자를 적으시면 됩니다.

• jdbc_password : 데이터베이스 루트 비밀번호를 적으시면 됩니다.

• schedule : 크론 표현식으로 얼마의 주기로 동기화를 할지를 정합니다.

• statement : Logstash만의 강력한 무기 중하나인 SQL문을 적는 공간입니다. 이 데이터에 따라 자동으로 마이그레이션 해줍니다. 이 옵션 덕분에 진입장벽이 굉장히 낮아집니다.

Filter는 생략합니다. 원하시면 넣어보세요!

Output

• index : 우리가 만든 인덱스 이름을 적어주면 됩니다.

• document_id : SQL의 테이블에 있는 primary key를 document id로 마이그레이션하는 과정에 필요한 옵션입니다.

메모장에 적으시고 확장자를 conf 로 설정하시면 자동적으로 conf 파일이 됩니다.

여기서 한글자라도 오타가 나거나 빼먹으면 작동이 안됩니다.

저는 환경이 Windows였기 때문에 Windows를 기반으로 작성하도록 하겠습니다.

먼저 cmd 창을 열어줍니다.

Logstash가 있는 곳으로 이동합니다. bin으로 이동하면 됩니다.

Logstash를 실행하기 전에 한가지 알아두셔야 할 점이 있습니다.

저는 자꾸 conf 파일을 찾지 못하길래 절대경로로 때려박아서 해결했습니다. 때문에 저는 conf 파일이 있는 절대 경로를 얻어올겁니다.

D:\logstash-8.7.0-windows-x86_64\logstash-8.7.0\config\logstash.conf

이렇게 얻어왔으면 이제 Logstash를 작동시킵니다.

그러면 지가 알아서 작동합니다.

데이터가 이렇게 마이그레이션 되었습니다.

다만 여기서 주의해야 하는 점은 반드시 Elasticsearch가 실행중이어야 한다는 점입니다. 그 점 유의해주세요!

이렇게 데이터가 잘 들어왔습니다.

마치며

이렇게 MySQL에서 Elasticsearch로 데이터 마이그레이션을 마쳤습니다. 마이그레이션은 처음 해보는데 정말 놀랍네요.

진짜 개발자가 된 것 같아서 뭔가 멋있습니다.

이제 Elasticsearch를 마음대로 이용하면 됩니다. 여기까지 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

MySQL 에서 MySQL로 DBeaver 이용해서 데이터 마이그레이션 진행하기

이번 포스팅에서는 MySQL에서 MySQL로 데이터를 마이그레이션하는 방법에 대해서 포스팅하도록 하겠습니다.

이번 마이그레이션의 특징은 기존 데이터가 완전히 Overwrite 해버리기 때문에 데이터가 존재하는 상태에서 진행하는 마이그레이션은 더 나중에 해봐야할 것 같습니다.

그럼 마이그레이션 시작하도록 하겠습니다.

DBeaver로 마이그레이션

먼저 복제하고싶은 데이터베이스를 오른쪽 클릭해서 도구 > Dump database를 클릭합니다.

마이그레이션할 테이블을 선택해야합니다. 디폴트는 모든 테이블이 선택됩니다. 그리고 "다음"을 눌러서 내가 복제할 데이터가 어디에 저장되는지 알아야합니다.

저는 C드라이브에 Users에 user군요. start를 누르면 해당 경로에 sql파일이 있을겁니다.

하지만 에러가 뜨는 경우도 있는데 바로 mysqldump.exe 가 제대로 바인딩되지 않은 경우가 있습니다.

2023-06-06 20:23:53.439 - IO error: Utility 'mysqldump.exe' not found in client home 'MySQL Router 8.0' (C:\Program Files\MySQL\MySQL Router 8.0)

저의 경우는 기본적으로 데이터베이스가 MySQL Router 8.0을 바라보고 있었기 때문에 생기는 에러였습니다. MySQL Router 8.0에는 mysqldump.exe가 없기 때문이죠.

이제 문제를 해결해보죠. 자신의 데이터베이스를 오른쪽 클릭하고 Edit Connection을 클릭합니다.

Connection settings에 Local Client가 MySQL Router 8.0으로 되어있습니다.

이걸 MySQL Server 8.0으로 바꿔주시면 됩니다.

그리고 다시 dump database를 누르면 성공했다는 메시지와 함께 해당 위치에 sql파일이 생성됩니다.

그런 다음 마이그레이션 하고싶은 데이터베이스를 오른쪽 클릭 > 도구 > Restore database 를 클릭합니다.

그리고 start를 누르면?

정상적으로 마이그레이션이 됩니다.

마치며

이렇게 MySQL에서 MySQL로 데이터 마이그레이션을 마쳤습니다. 제가 Docker로 배포한 MySQL로 마이그레이션 하는 것이 오늘의 목표였는데 목표를 전부 달성해서 기분이 좋습니다.

여러분도 데이터 마이그레이션이 필요하다면 한번 도전해보시는게 어떨까요?

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

도커를 이용해 MySQL 레플리케이션 구조 구현해보기

이번 포스팅에선 도커를 활용해 MySQL 서버를 레플리케이션 (복제) 하여 DB 부하를 줄여보겠습니다.

제 프로젝트 컨셉상 레플리케이션이 필요한 구조이긴 합니다만... 조금 의구심이 들긴 합니다. 데이터베이스 서버를 여러대 두는게 아니고 한 인스턴스에서 MySQL 서버 여러개 둔다고 DB 부하가 줄어들지는 모르겠습니다.

그래도 그냥 새로운거 도전한다고 생각하고 한번 해보도록 하겠습니다.

레플리케이션

레플리케이션이 뭔지 먼저 짚고 넘어가도록 하죠.

데이터베이스 레플리케이션이란, 데이터베이스 클러스터링에서 발전된 형태로서 DB 스토리지와 DB 서버가 하나의 쌍을 이뤄 원래라면 하나의 DB 서버가 부담해야할 부하를 여러대로 나눠 부하를 줄여줌으로써 데이터베이스의 성능이 떨어지지 않게 유지하는 방법입니다.

클러스터링과 레플리케이션, 샤딩에 대해서 더 자세히 알고 싶으신 분들은 아래의 링크를 확인해주세요.

https://coding-review.tistory.com/309

보통 레플리케이션은 마스터 서버가 쓰기 작업 (INSERT, UPDATE, DELETE) 을 담당하고 슬레이브 서버가 읽기 작업 (SELECT) 을 담당합니다.

왜냐하면 쓰기 작업은 읽기 작업에 비해 상대적으로 데이터베이스의 자원을 많이 사용하기 때문입니다.

또한 쓰기 작업을 모든 DB 서버에서 하면 안되는 이유 중 하나가 바로 효율적인 문제에서 이슈가 있기 때문입니다.

마스터 서버에서 쓰기 작업을 진행한 뒤에 모든 슬레이브 서버에 동기화 작업을 마치는 것이 벌크 연산을 진행하기에도 더 편한 부분이 있기 때문이죠.

만약 모든 DB 서버가 쓰기 작업이 가능하다면 각각의 쓰기 작업을 모든 DB에 동기화 작업을 진행할 때 어마어마한 오버헤드가 발생할 것입니다.

레플리케이션에 대해서 알아봤으니 본격적으로 MySQL 서버를 나눠서 레플리케이션을 진행해봅시다.

MySQL 레플리케이션

먼저 고지하고 넘어가야 할 점은 이번 포스팅에서 사용할 도커에 관련한 사용방법은 기술하지 않고 넘어갑니다. 도커와 도커 컴포즈에 대해서 자세히 알고 싶으신 분들은 아래의 링크를 확인해주세요.

https://coding-review.tistory.com/category/%EB%B0%B0%ED%8F%AC/docker

이제 준비물을 준비해보겠습니다.

• MySQL configuration 파일

• 자신의 루트 주소

먼저 MySQL configuration 파일이 필요합니다. 이 파일은 추후 올라갈 MySQL 컨테이너와 마운트할 파일입니다.

그 전에 먼저 제 인스턴스 구조를 보여드리겠습니다.

mysql 바로 아래 있는 docker-compose 파일들은 잠시 무시하시구요. 주의깊게 봐야할 것은 config_file.cnf의 위치입니다. 이제 각각의 파일을 보여드리겠습니다.

1. MySQL Configuration 파일 작성

[mysqld]

log-bin=mysql-bin

server-id=1

default_authentication_plugin=mysql_native_password

위의 파일은 마스터 서버에 마운트될 설정 파일입니다. 파일 확장자는 반드시 cnf로 해주세요!

[mysqld]

server-id=2

log_bin=mysql-bin

relay_log=/var/lib/mysql/mysql-relay-bin

log_slave_updates=1

read_only=1

default_authentication_plugin=mysql_native_password

[mysqld]

server-id=3

log_bin=mysql-bin

relay_log=/var/lib/mysql/mysql-relay-bin

log_slave_updates=1

read_only=1

default_authentication_plugin=mysql_native_password

슬레이브 서버에 마운트 될 두개의 설정 파일입니다.

이렇게 준비물이 끝났다면 이제 자신의 루트 주소를 알아야합니다.

$ cd /

위의 명령어로 루트 주소로 들어가서 보시면 디렉토리들이 있을텐데 거기서 자신의 프로젝트가 있는 파일로 들어가주세요.

저는 /Users/capston/datadir이 제 프로젝트가 있는 파일입니다.

이제 본격적으로 도커 컴포즈를 작성해봅시다.

2. docker-compose 작성

version: "3.8"

services:

db-master:

image: mysql:8.0.17

container_name: capston-mysql-master

restart: always

environment:

MYSQL_USER: "user"

MYSQL_PASSWORD: "root"

MYSQL_ROOT_PASSWORD: "root"

ports:

- "3306:3306"

volumes:

- /Users/capston/datadir/master:/var/lib/mysql

- /Users/capston/datadir/master:/var/lib/mysql-files

- /home/ubuntu/docker/mysql/master/config_file.cnf:/etc/mysql/conf.fd/my.cnf

networks:

- net-mysql

db-slave1:

image: mysql:8.0.17

container_name: capston-mysql-slave1

restart: always

environment:

MYSQL_USER: "user"

MYSQL_PASSWORD: "root"

MYSQL_ROOT_PASSWORD: "root"

ports:

- "3307:3306"

volumes:

- /Users/capston/datadir/slave1:/var/lib/mysql

- /Users/capston/datadir/slave1:/var/lib/mysql-files

- /home/ubuntu/docker/mysql/slave1/config_file.cnf:/etc/mysql/conf.d/my.cnf

networks:

- net-mysql

db-slave2:

image: mysql:8.0.17

container_name: capston-mysql-slave2

restart: always

environment:

MYSQL_USER: "user"

MYSQL_PASSWORD: "root"

MYSQL_ROOT_PASSWORD: "root"

ports:

- "3308:3306"

volumes:

- /Users/capston/datadir/slave2:/var/lib/mysql

- /Users/capston/datadir/slave2:/var/lib/mysql-files

- /home/ubuntu/docker/mysql/slave2/config_file.cnf:/etc/mysql/conf.d/my.cnf

networks:

- net-mysql

networks:

net-mysql:

driver: bridge

조금 길지만 복붙이 거의 다입니다.

문법은 설명하지 않습니다만 중요한 부분 몇개를 소개해드리겠습니다.

image : 저는 이미지를 8.0.17 버전으로 했습니다. MySQL의 LTS 버전이기 때문에 사용했습니다.

volumes : 첫번째 줄과 두번째 줄은 제가 루트 주소를 알아야 한다고 했던 이유입니다. MySQL과 같은 데이터베이스를 컨테이너로 올릴 때는 컨테이너가 삭제되면 모든 데이터가 날아가기 때문에 외부 저장소에 저장해야 할 필요가 있습니다.

세번째 줄은 아까 만들었던 MySQL 설정 파일을 실제 올라갈 컨테이너의 설정 파일과 마운트하기 위해 적은 코드입니다. 이 부분으로 인해 다양한 설정을 할 수 있습니다. 예를 들어서 read-only로 설정한다든가 로그 파일의 이름을 정한다든가 하는 것을 설정할 수 있습니다.

networks : 우리는 데이터베이스를 여러대 띄울 것이기 때문에 각각의 데이터베이스끼리 통신할 무언가가 있어야합니다. 이 networks 설정으로 MySQL 서버들은 브릿지 형태로 연결될겁니다.

이제 컨테이너를 띄우시면 컨테이너가 잘 올라간 모습을 볼 수 있습니다.

$ docker-compose -f ./docker-compose-mysql.yml up -d

$ docker ps

이제 슬레이브 서버에 가서 동기화 작업을 진행해야 합니다.

3. 동기화 작업

동기화 작업은 다음과 같은 순서로 진행됩니다.

• 마스터 서버의 IP 주소 찾기

• 마스터 서버의 로그 파일 찾기

• 슬레이브 서버에서 명령어 입력

• 테스트

1. 마스터 서버의 IP 주소 찾기

마스터 서버의 IP 주소는 아래의 명령어를 이용해 찾을 수 있습니다.

$ docker network ls

우리가 필요한 건 저겁니다. 네트워크 아이디를 입력해야합니다.

jq 가 없으면 설치하시면 됩니다.

$ sudo apt-get install jq

저 마스터 서버의 IP 주소를 어디다 적어주세요.

2. 마스터 서버의 로그 파일 찾기

이제 bash 로 컨테이너에 접속한 뒤 MySQL에 접속해봅시다.

순서대로

$ docker exec -it ${자신의 MySQL 컨테이너 이름} bash

# mysql -u root -p

Enter password:

mysql> show binary logs;

이렇게 치면 로그 이름이 나옵니다. 이 로그 중 사이즈가 작은 두개를 선정합니다. 저는 2번하고 3번을 선정하겠습니다. 로그 이름은 binlog.000002, binlog.000003 이렇게 적어두시면 됩니다.

이제 빠져나와서 슬레이브 서버로 들어갑니다.

여기서 빠져나오는 방법은

mysql> quit

하시고 ctrl + P + Q 하시면 컨테이너 종료 없이 빠져나올 수 있습니다.

마스터 서버로 들어왔을 때와 똑같은 방법으로 슬레이브 서버로 들어옵니다.

3. 슬레이브 서버에서 명령어 입력하기

CHANGE MASTER TO MASTER_HOST='${아까 적어둔 마스터 서버의 IP주소}', MASTER_USER='root', MASTER_PASSWORD='${아까 docker-compose에서 만든 마스터 서버의 비밀번호}', MASTER_LOG_FILE='${아까 적어둔 마스터 서버의 로그 파일 이름}', MASTER_LOG_POS=0, GET_MASTER_PUBLIC_KEY=1;

start slave;

show slave status\G;

이렇게 하고 상태를 보면?

저 두개가 Yes 가 뜨면 정상적으로 완료된겁니다.

이제 테스트 해봅시다.

4. 테스트

마스터 서버로 다시 넘어와서 몇가지 명령어로 확인해보죠.

마스터 서버로 접속

mysql> show databases;

mysql> create database test_db;

mysql> show databases;

마스터 서버에 정상적으로 데이터베이스를 하나 만들고

슬레이브 서버로 접속

mysql> show databases;

슬레이브 서버에도 정상적으로 잘 들어간 모습을 확인할 수 있습니다.

마치며

사실 이 부분을 진행할까 말까 굉장히 고민했습니다. 데이터베이스 서버만 여러개 둔다고 DB 부하가 줄어들까..?

이 궁금증은 아직 해결해야 할 문제 중 하나입니다. 다음 목표는 프로메테우스라는 모니터링 툴을 이용해서 DB 서버의 상태를 확인해서 실제 서버가 하나일 때랑 여러개일 때를 비교해서 분석하는 포스팅을 진행하도록 하겠습니다.

출처

https://huisam.tistory.com/entry/mysql-replication

데이터베이스 프로시저 (Database Procedure)

이번 포스팅에선 데이터베이스 프로시저에 대해서 알아보도록 하겠습니다.

면접 봤는데 해당 내용이 나왔고 대답을 못했습니다. 다음 면접을 위해 정리해두는 느낌으로 포스팅 해보도록 하겠습니다.

데이터베이스 프로시저

프로시저란 무엇일까요?

간단하게 쿼리 묶음이라고 생각하시면 됩니다.

만약 자주 사용하는 SQL문이 있다면 그것들을 정리해둔 것이죠. 한번 간단한 예시를 상정해보고 코드 예제까지 보여드리겠습니다.

• "books"라는 테이블에 새로운 책을 입력할 것입니다.

• "books"테이블에 특정 책의 수량을 변경할 것입니다.

• 데이터가 바뀐 책의 상세 데이터를 요청해보겠습니다.

DELIMITER //

CREATE PROCEDURE ManageBook(IN bookTitle VARCHAR(255), IN bookAuthor VARCHAR(255), IN bookStock INT, OUT bookDetails TEXT)

BEGIN

DECLARE bookId INT;

-- 책이 존재하지 않는다면 새로운 책을 삽입합니다.

INSERT INTO books (title, author, stock)

SELECT * FROM (SELECT bookTitle, bookAuthor, bookStock) AS tmp

WHERE NOT EXISTS (

SELECT title FROM books WHERE title = bookTitle

) LIMIT 1;

-- 책의 ID를 가져옵니다.

SELECT id INTO bookId FROM books WHERE title = bookTitle;

-- 책의 수량을 업데이트합니다.

UPDATE books SET stock = bookStock WHERE id = bookId;

-- 책의 세부 사항을 가져옵니다.

SELECT CONCAT('Title: ', title, ', Author: ', author, ', Stock: ', stock)

INTO bookDetails FROM books WHERE id = bookId;

END //

DELIMITER ;

참고로 이 프로시저는 MySQL언어로 되어있습니다.

이렇게 프로시저를 만들었다면 프로시저를 호출해보겠습니다.

CALL ManageBook('The Great Gatsby', 'F. Scott Fitzgerald', 10, @bookDetails);

SELECT @bookDetails;

뭔가 프로그래밍 언어에서 함수를 만든 것 같지 않나요? 파라미터 넘겨주는 것도 그렇고 리턴 값을 받아오는 것도 비슷하네요.

프로시저의 장점

• 쿼리를 한방에 보낼 수 있어서 많이 사용하는 쿼리를 넣어둘 수 있다.

• 기본적으로 많은 쿼리를 한방에 처리하는 것보다 성능이 좋다.

• 네트워크 트래픽(데이터베이스 내부 네트워크 I/O를 말하는 것입니다.)을 줄일 수 있어서 오버헤드가 적게 발생한다.

• 파라미터도 넣을 수 있어서 함수처럼 사용할 수 있다. 리턴 값도 정해줄 수 있다.

• 모든 쿼리는 내부적으로 데이터베이스 엔진(MySQL의 경우 InnoDB 스토리지 엔진)을 통해 실행되기 때문에 트랜잭션 처리도 가능하다.

여기까지 프로시저에 대한 내용은 끝! 표면적인 얘기는 여기까지입니다. 여기서 뒤로 가기를 누르셔도 상관 없습니다. 이제부턴 조금 딥한 내용을 다뤄볼까합니다.

표면적인 내용만 공부하고 땡치는건 제 공부 스타일이 아니라서요 ㅎㅎ

프로시저 심화

프로시저를 JPA에서?

프로시저를 딱 보고 든 생각은 "저건 죽었다 깨어나도 저렇게는 사용 안할거다"라는 것이었습니다. 분명 개발자가 쉽게 쓰기위한 트리거가 있을 것이고 그것이 조금 궁금했습니다.

알아보니 JPA에서 프로시저를 사용할 수 있는 방법을 제공하고 있었습니다.

바로 @NamedStoredProcedureQuery라는 어노테이션입니다.

사용 예시만 보시면 어떻게 사용하는지 느낌이 오실겁니다.

@Entity

@NamedStoredProcedureQuery(

name = "UpdateUserStatus",

procedureName = "updateUserStatus",

parameters = {

@StoredProcedureParameter(mode = ParameterMode.IN, name = "userId", type = Long.class),

@StoredProcedureParameter(mode = ParameterMode.IN, name = "status", type = String.class)

}

)

public class User {

// ... fields, getters and setters

}

이렇게 프로시저 이름이랑 파라미터에 어떤 값이 들어올건지 미리 세팅을 해두면 EntityManager를 이용해서 호출할 수 있습니다.

StoredProcedureQuery query = entityManager.createNamedStoredProcedureQuery("UpdateUserStatus");

query.setParameter("userId", 1L);

query.setParameter("status", "active");

query.execute();

이렇게말이죠.

어? 겁나 편해보이는데?

이렇게 겁나 편한건 항상 고려사항이 있습니다.

주의사항!

보통 프로시저를 호출하는 작업의 경우 특정 ORM에 종속되는 경우가 많습니다. 자바에선 JPA의 구현체인 Hibernate와 EclipseLink같은 것들이 바로 그것이죠.

또한, JPA를 통해서 프로시저를 다루는 것은 에러 핸들링이 굉장히 중요합니다. 우리의 자바 코드 안에서 데이터베이스가 내뱉는 Exception들을 적절히 처리해줘야 한다는 특징이 있습니다.

프로시저는 가끔 성능에 있어서 병목현상이 있을 수 있습니다. 때문에, 최적화를 해야하는데요, 이 부분에 대해서는 뒤에서 더 자세히 다뤄보겠습니다.

프로시저의 단점

특정 DBMS에 종속

프로시저는 특정 SQL dialect나 DBMS에 종속되는 구조를 가지고 있습니다.

이는 데이터베이스가 바뀌면 프로시저도 같이 바꾸어야 한다는 것이죠. 자바식으로 말하면 OCP에 위배된다는 것입니다.

그리고 또 치명적인 문제가 있습니다.

유지보수

DELIMITER //

CREATE PROCEDURE ManageBook(IN bookTitle VARCHAR(255), IN bookAuthor VARCHAR(255), IN bookStock INT, OUT bookDetails TEXT)

BEGIN

DECLARE bookId INT;

INSERT INTO books (title, author, stock)

SELECT * FROM (SELECT bookTitle, bookAuthor, bookStock) AS tmp

WHERE NOT EXISTS (

SELECT title FROM books WHERE title = bookTitle

) LIMIT 1;

SELECT id INTO bookId FROM books WHERE title = bookTitle;

UPDATE books SET stock = bookStock WHERE id = bookId;

SELECT CONCAT('Title: ', title, ', Author: ', author, ', Stock: ', stock)

INTO bookDetails FROM books WHERE id = bookId;

END //

DELIMITER ;

위의 예제를 다시 가져왔습니다.

이 코드 읽기에 어떤가요? 일단 제 눈엔 굉장히 난잡하고 읽기 힘듭니다.

이 문제도 프로시저를 유지보수 하는데 큰 문제가 생깁니다.

차라리 코드였으면 직관적인 변수명, 직관적인 메서드 명으로 읽는 사람 눈에 편하게라도 할 수 있었을텐데 프로시저는 그런 기능따윈 존재하지 않기때문에 관리하기가 여간 까다로운 게 아닙니다.

테스트 (거의) 불가능

또한, 테스트하기 굉장히 힘들다는 문제도 있습니다.

하지만 척 보기엔 테스트가 가능해보이죠? 왜냐하면 JPA를 사용하면 통합 테스트는 할 수 있을 것 같거든요.

여기서 말하는 테스트는 통합 테스트가 아닌 단위 테스트입니다. 프로시저는 여러개의 연산을 하나로 묶어놓은 것인데 하나하나의 연산을 테스트 한다는 것 자체가 모순입니다.

때문에 단위 테스트는 (거의) 불가능합니다.

성능 이슈

프로시저는 다양한 성능 문제에 봉착할 수 있습니다. 쿼리 연산이 많기 때문이죠.

그럼 인덱싱으로 해결할 수 있나?

인덱싱은 부분적으로 해결할 수 있습니다. 추가적인 최적화가 필요합니다. 예를 들어서 올바른 데이터 타입을 정한다거나, 프로시저 안에서 쓸데없는 데이터 변환을 줄인다거나, 락킹 이슈를 피하기 위해서 사용한 트랜잭션을 잘 관리한다던가 등이 있습니다.

데이터베이스 자원 소모가 심하다

보통 실제 환경에선 애플리케이션 서버와 데이터베이스 서버가 분리되어있습니다.

프로시저는 여러개의 쿼리 연산이 들어가기 때문에 데이터베이스 서버의 CPU와 메모리를 많이 잡아먹습니다.

혹여 프로시저를 무분별하게 사용한다면 데이터베이스 서버가 죽어버릴 수도 있습니다.

여기까지! 데이터베이스 프로시저에 대해서 알아봤습니다. 이제 면접 질문으로 정리하고 마무리하면 될 것 같습니다.

데이터베이스에 대해서는 신입 기준 많이 알고 있다고 생각했는데 아직도 알아야 할 것이 많이 남아있는 듯 합니다. 더 열심히 공부해야겠네요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

Chat GPT 4.0 - OpenAI

Redis와 Memcached 동시성 문제

이번 포스팅에선 Redis와 Memcached의 동시성 문제에 대해서 궁금증이 생겨서 공유하고자 글을 쓰게 되었습니다.

구글링하다 Redis-Semaphore로 Mutex 해결하기 라는 제목의 포스팅을 발견했습니다. 처음엔 보고 "으잉? 이게 무슨말이야?" 하게 됐는데 Mutex는 동시성 문제를 해결하는 방법론인데 Mutex를 해결..?

아마 포스팅 쓰신 글쓴이분께서 Mutex의 뜻을 착각하고 계신게 아닌가 싶었습니다. 그래서 그 부분은 넘어가도록 하고 Redis-Semaphore라는게 걸려서 이번에도 역시 GPT로 공부해봤습니다.

Redis와 Memcached의 차이

우선 이 둘의 차이부터 짚고 넘어가도록 하겠습니다.

Redis

• 싱글스레드이다.

• 여러가지 자료형을 제공한다. String, Hash, List, Sorted List 등등..

• 배포 과정에서 레플리케이션 방식인 Santinel과 샤딩 방식인 Cluster를 제공해준다.

• Spring Data Redis로 스프링에서 사용하기 편하게 high-level의 구현체를 만들어뒀습니다.

Memcached

• 멀티스레드이다.

• String 자료형 하나만 제공해준다.

• 배포 과정에서 레플리케이션 방식만 제공해준다.

• 스프링에서 구현체를 만들어주지 않았다. (만들어주세요 제발 ㅜㅜ)

Redis와 Memcached의 동시성문제

Memcached는 멀티스레드이기 때문에 동시성 문제가 발생하는 것이 어떻게 보면 자명해보이지만 Redis는 싱글스레드인데 동시성 문제가 발생한다는 것이 쉽게 떠오르지 않았습니다.

Redis에서 어떤 동시성 문제가 있을까요?

만약 여러명의 사용자가 동시에 Redis의 데이터에 접근하려고 하는 상황을 가정해볼 수 있습니다.

그리고 이 데이터의 값을 변경하려고 한다면 어떻게 될까요?

10이라는 데이터를 두명이 동시에 접근해서 1을 올리려고 합니다. 싱글스레드이기 때문에 한번에 하나의 연산만을 보장하는 원자성때문에 둘 다 11을 리턴받을 것입니다. 우리가 예상한 12가 아니구요.

이 문제는 운영체제에서 흔히 말하는 race condition 이라고 부르는 동시성 문제 상황입니다.

Redis에서 동시성 문제를 해결하는 방법

Spring Data JPA에선 비관적 락과 낙관적 락을 @Lock, @Version 이 두개의 어노테이션을 통해 제공해줍니다. JPA의 락은 굉장히 사용하기 편하다는 느낌을 받지만...

Redis는 자체적으로 락킹매커니즘을 가지고 있지 않습니다. 우리가 직접 구현해줘야하죠!

Redis의 동시성 문제를 막기위한 방법은 어느정도 메뉴얼화 되어있습니다. 보통 낙관적 락으로 동시성 문제를 해결한다고 하네요.

예시를 한번 보겠습니다.

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.data.redis.core.RedisTemplate;

import org.springframework.data.redis.core.SessionCallback;

import org.springframework.stereotype.Service;

@Service

public class YourEntityService {

@Autowired

private RedisTemplate<String, YourEntity> redisTemplate;

public boolean updateEntityOptimistically(String key, YourEntity updatedEntity) {

return redisTemplate.execute(new SessionCallback<Boolean>() {

@SuppressWarnings("unchecked")

@Override

public <K, V> Boolean execute(RedisOperations<K, V> operations) {

operations.watch((K) key);

YourEntity currentEntity = (YourEntity) operations.opsForValue().get((K) key);

// 엔티티의 업데이트가 필요한지 여부를 확인하는 로직

if (currentEntity == null || !shouldUpdate(currentEntity, updatedEntity)) {

operations.unwatch();

return false;

}

operations.multi();

operations.opsForValue().set((K) key, updatedEntity);

List<Object> result = operations.exec();

return result != null && !result.isEmpty();

}

});

}

private boolean shouldUpdate(YourEntity currentEntity, YourEntity updatedEntity) {

// 우리의 로직을 업데이트할 것인지 하지 않을 것인지 결정하는 로직입니다.

return true;

}

}

주의해서 봐야할 키워드는 watch, multi, exec입니다.

watch는 JPA의 낙관적 락에서 @Version에 해당합니다.

JPA의 비관적 락과 낙관적 락에 대해서 자세히 알고싶으신 분들은 아래의 링크를 확인해주세요!

https://coding-review.tistory.com/421

watch로 version을 확인합니다. 그리고 multi에서 충돌을 확인하죠. 그리고 exec를 이용해서 동시성 문제를 처리합니다.

Memcached에서의 동시성 문제

Redis가 싱글스레드임에도 동시성 문제가 발생한다는 것이 조금 놀라웠습니다. 이젠 Memcached의 동시성 문제에 대해서 알아보죠.

Memcached는 멀티스레드이기 때문에 더 다양한 동시성 문제가 발생합니다.

• Cache Stampede (캐시의 무너짐) : 이 경우는 이미 만료되었거나 존재하지 않는 키를 여러명의 클라이언트가 지속적으로 요청하는 경우에 생기는 문제입니다. 이 경우 모든 프로세스가 동시에 값을 계산하고 캐시에 다시 쓰도록 유도합니다.

이 상황을 완화하기 위한 방법으로 "lock and compute" 방식을 사용할 수 있습니다. 말그대로 락을 걸고 계산하는거죠.

• Dirty Read : 격리수준에서 공부했던 Dirty Read 부정합문제 할 때의 그 더티리드 맞습니다. 하나의 스레드가 데이터를 변경하기 전에 다른 스레드가 이 값을 참조해서 변경하려고 한다면 일관성이 깨진다는 부정합 문제입니다.

이 상황을 완화하기 위해서 캐시 전체를 versioning하던가 더 정교한 캐싱 패턴을 적용해야합니다. 예를 들어서 원자적으로 읽고-작업하고-쓴다 라는 로직을 정립해야하죠.

• Race Condition : 위에서 설명했던 Redis의 상황과 마찬가지입니다. 넘어가도록 하겠습니다.

• Replication and Consistency : Memcached는 Redis와 마찬가지로 레플리케이션 배포 방식을 제공한다고 했습니다. 만약 여러개로 쪼개진 네트워크 상황에선 동시성 문제가 더 심각하게 발생할 수 있습니다.

읽어보면 조금 이상합니다. "해결" 방법이 아니고 "완화" 방법이라고요? 문제를 해결하는게 아니라 완화한다고요? 그럼 해결을 못한다는건가요?

"네"

Memcached는 동시성 문제를 해결하지 못해서 문제를 완화하는 방법만 존재합니다.

응? 그럼 동시성 문제가 얼마나 심각한 문제인데 방치하겠다는 것인가요?

음... 사실 Memcached는 동시성 문제가 그리 심각한 문제가 아닙니다.

여기서 CAP 정리를 짚고 넘어가야겠습니다.

CAP에 대한 자세한 내용은 아래의 링크에서 확인하세요!

https://coding-review.tistory.com/312

CAP 정리?

위의 링크에서 자세히 볼 수 있지만 한 번 더 설명드리겠습니다.

CAP 정리란 2000년에 Brewer가 발표한 "분산 시스템에서 C (Consistency), A (Availability), P (Partition Tolerance) 셋을 만족하는 데이터베이스는 없다!" 라는 정리입니다.

이 정리는 반은 맞고 반은 틀린 정리라 (오래된 정리라 그렇습니다) 다시 보완하자면

Partition (장애) 상황에서 C와 A중 선택해야한다! 가 올바른 설명입니다. 그럼 장애 상황이 아니면요?

그럼 C랑 A둘 다 만족하는거죠.

그래서 지금 이 얘기를 왜하냐

Memcached는 Consistency 즉 일관성을 포기한 AP 데이터베이스이기 때문입니다. 하지만 정상 상황일땐 (동시성 문제가 발생하지 않을 때) C를 만족합니다.

데이터베이스가 일관성이 안맞으면 어떡하냐? 떼잉...

이건 RDBMS적인 생각이고 NoSQL은 일관성을 포기한 대신 RDBMS보다 뛰어난 성능과 가용성을 챙겼습니다. 일관성은 BASE 속성 중 E에 해당하는 Eventual Consistency를 통해 맞추고 그 외에 상황에는 뛰어난 성능과 가용성으로 휘어잡는 것이죠.

사실 Memcached는 캐싱 데이터를 저장하는 데이터베이스입니다. 당연한데 왜 짚고 넘어가냐하면 캐싱 데이터는 일관성이 맞을 필요가 전혀 없습니다.

캐싱 데이터는 업데이트가 자주 일어나지 않습니다. 저도 캐싱 솔루션을 프로젝트에 적용해봤지만 캐싱 데이터는 한번 저장하면 TTL이 끝날때까지 변하지 않아도 상관 없는 경우가 대부분입니다.

그래서 제가 "Memcached는 동시성 문제가 큰 문제가 아닙니다" 라고 말한 것입니다.

정리

자! 여기까지 Redis와 Memcached의 동시성 문제에 대해서 알아봤습니다. 포스팅의 결론이 좀 이상하긴 합니다. 동시성 문제에 대해서 다뤘는데 결론이 동시성 문제가 별로 중요하지 않다는 것이니까요.

애초에 이 주제를 궁금해 했던 것이 제 뇌는 아직 RDBMS적인 사고를 하고 있기 때문입니다.

• Redis-Semaphore가 뭐지?

• Redis에서도 Semaphore로 동시성 문제를 해결하는구나...

• Redis에서 동시성문제가 발생하면 어떻게되지?

• Memcached는?

이런식으로 사고하다보니 동시성 문제가 발생해도 상관없다는 결론이 나오기까지 시간이 걸렸습니다.

RDBMS를 다루면서 동시성문제는 꽤 큰 문제이기 때문에 저도 모르게 NoSQL도 같은 논리로 적용한 것이 조금은 부끄럽네요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

Chat GPT 4.0 - Open AI

데이터베이스 동시성 제어

데이터베이스에는 동시성 문제를 해결하기 위해 시스템 내부적으로 다양한 방법들을 제공해줍니다. 오늘은 데이터베이스에서 사용할 수 있는 다양한 동시성 제어에 대해서 알아보도록 하겠습니다.

동시성 제어

이번 포스팅에서 중점적으로 다룰 동시성 제어 방식은

• 타임스탬프 기반 프로토콜 (Timestamp-Based Protocol)

• 낙관적 동시성 제어 (Optimistic Concurrency Control)

이렇게 두가지를 보도록 하겠습니다.

사실 저 두개 말고도 락 기반 프로토콜과 MVCC가 있는데 이 두가지는 이미 포스팅으로 다뤘기 때문에 이번엔 링크만 남기고 넘어가도록 하겠습니다.

락 기반 프로토콜

https://coding-review.tistory.com/302

MVCC

https://coding-review.tistory.com/301

그럼 본격적으로 시작해보도록 하겠습니다.

타임스탬프 기반 프로토콜

타임스탬프 기반 프로토콜은 여러개의 트랜잭션들이 동시다발적으로 발생하는 것에 대해서 일관성을 유지하기 위해 데이터베이스 시스템 내부에서 사용되는 방법입니다.

이 방법은 트랜잭션이 실행해야 하는 순서를 결정하기위해 각각의 트랜잭션이 유니크한 타임스탬프를 할당하는 것에 의존합니다.

이는 직렬성을 보장하는데 이 말은 트랜잭션이 각각의 타임스탬프 순서대로 실행되는 것을 의미합니다.

쉽게 말해 시간을 체크한다는 것입니다.

타임스탬프 기반 프로토콜의 특징

• 유니크한 식별자 : 각각의 트랜잭션은 트랜잭션이 시작할 때 유니크한 타임스탬프를 할당받습니다. 이 타임스탬프는 트랜잭션의 실행을 위해 사용됩니다.

• 실행 순서 : 트랜잭션의 실행 순서는 각각의 트랜잭션의 타임스탬프에 기반하여 결정됩니다. 더 빠른 타임스탬프를 가지고 있는 트랜잭션은 늦은 타임스탬프를 가지고 있는 트랜잭션보다 더 높은 우선순위를 부여받습니다.

기본적인 동작 방식

• Read and Write Timestamps : 데이터베이스 안에 있는 각각의 데이터들은 읽기 타임스탬프와 쓰기 타임스탬프를 가지고 있습니다. 이는 트랜잭션이 읽기 작업을 수행하거나 쓰기 작업을 수행하면 마킹이 되는 방식입니다.

즉, 트랜잭션이 어떤 데이터에 접근해서 읽기 혹은 쓰기 작업을 수행하면 "지나갑니다~" 하고 시간을 적어 놓는 것입니다.

• Read Operation : 트랜잭션이 데이터를 읽고 싶을 때 시스템은 데이터에 적혀있는 쓰기 타임스탬프와 트랜잭션의 타임스탬프를 비교합니다. 만약 트랜잭션의 타임스탬프가 데이터의 쓰기 타임스탬프보다 오래 되었으면 읽기작업이 거부당합니다.

왜냐하면 트랜잭션이 생성된 시기보다 쓰기 작업이 일어난 시기가 더 늦기 때문입니다. 이 상황은 read-write 충돌이라고 부릅니다.

• Write Operation : 트랜잭션이 데이터에 쓰기 작업을 하고 싶을 때 시스템은 데이터에 적혀있는 읽기 타임스탬프와 쓰기 타임스탬프를 트랜잭션의 타임스탬프와 비교합니다.

만약 트랜잭션의 타임스탬프가 데이터에 적혀있는 읽기, 쓰기 타임스탬프보다 늦게 생성된 것이라면 쓰기 작업이 취소됩니다. 이유는 위의 Read Operation에서와 같은 이유입니다. 이 상황을 write-read 충돌이라고 부릅니다.

타임스탬프 기반 프로토콜의 장점

타임스탬프 기반 프로토콜은 락킹 매커니즘이 아니기 때문에 데드락의 위험성이 없습니다. 그리고 트랜잭션이 각각의 타임스탬프대로 동작하는 것을 보장하며 직관적이라 구현하기 쉽다는 장점이 있습니다.

타임스탬프 기반 프로토콜의 단점

만약 어떤 트랜잭션의 타임스탬프가 읽기 혹은 쓰기 작업을 원하는데 데이터에 적혀있는 읽기, 쓰기 타임스탬프보다 오래된 것이라 작업이 중지되었다면 또 다른 트랜잭션이 와서 데이터의 타임스탬프를 변경해줄 때까지 해당 데이터에 접근조차 할 수 없습니다.

이는 기아상태를 유발하기 딱 좋은 상황입니다. 또한, 타임스탬프를 유지하고 타임스탬프 기반으로 동작하게 하는 것은 추가적인 오버헤드를 유발할 수도 있습니다.

타임스탬프 기반 프로토콜의 사용처

타임스탬프 기반의 동시성 제어는 특히 각각 노드들에 락을 거는 것이 힘든 분산 시스템에 주로 사용됩니다. 또한, 데이터 contention을 가진 애플리케이션에도 적합합니다.

낙관적 동시성 제어

Optimistic Concurrency Control 줄여서 OCC는 데이터에 락을 걸어서 동시성 제어를 하는 것이 아닙니다.

기존 데이터베이스에서 실제로 업데이트 되는 것이 아닌 가상의 데이터베이스 공간에서 데이터의 버전을 확인하여 충돌 여부를 확인하고 롤백하는 시스템입니다.

사실 OCC는 포스팅에서 한번 다룬 적이 있습니다. JPA의 낙관적 락과 비관적 락을 얘기할 때 등장했었죠. 하지만 이는 구현하는 방법이지 이론적인 방법은 아니었습니다. 이번 기회에 이론적으로 잡고 들어가기 위해서 다시 한번 포스팅 하는 것입니다.

아래의 링크에 짧게지만 설명이 나와있고 구현방법도 나와있으니 아래의 링크를 확인해주세요!

https://coding-review.tistory.com/421

OCC의 순서

• Read Phase (Begin Transaction) : 이 페이즈에서 트랜잭션은 데이터베이스에 실제로 업데이트 되지 않는 가상의 데이터베이스에서 데이터를 확인합니다. 그리고 가상의 데이터베이스를 관리하면서 읽기 작업을 수행합니다.

• Validation Phase (Check before Commit) : 이 단계에서 시스템은 다른 트랜잭션들이 현재 트랜잭션에 의해 데이터가 업데이트 되었는지 체크합니다.

이때 버저닝을 통해 데이터의 업데이트를 체크합니다. 만약 트랜잭션이 데이터를 변경하였다면 충돌이 일어나게 됩니다.

• Write Phase (Commit if Safe) : 만약 검증 단계에서 충돌이 없다고 판단하면 트랜잭션은 데이터베이스에 그대로 변경된 데이터를 적용시킵니다. 만약 충돌이 일어났다면 롤백이 일어나고 재시도를 거치게 됩니다.

쉽게 설명해서 다음과 같은 데이터가 있다고 가정해봅시다.

이 데이터베이스는 실제 데이터가 있는 데이터베이스입니다. 여기에 실제 데이터를 그대로 복사하고 가상의 데이터베이스를 만듭니다.

다른 점은 옆에 version이 붙었다는 것입니다.

A와 B 트랜잭션이 Tom의 나이를 변경한다고 가정해보겠습니다.

먼저 A 트랜잭션이 접근했습니다. A 트랜잭션이 Tom의 나이를 24로 변경합니다.

Update members m set m.age=24 where m.name = 'Tom' and m.version = 1

조건절에 모두 일치하는 데이터가 존재하네요. Tom의 나이가 24로 변경되면서 version을 1 올립니다.

하지만 동시에 접근한 트랜잭션 B가 Tom의 나이를 25로 변경하려고 하네요.

Update members m set m.age = 25 where m.name = 'Tom' and m.version = 1

A와 B 모두 같은 시간에 트랜잭션에 접근했기 때문에 모두 버전이 1인 것을 검색합니다.

하지만 이미 A가 지나간 뒤여서 version이 2가 되었습니다. 때문에 B의 저 쿼리는 충돌이 일어나게 됩니다.

충돌이 일어나면 롤백이 일어나고 재시도하게 됩니다.

OCC 장점

OCC는 락에 의존하는 동시성 제어가 아니기 때문에 데이터에 락을 거는 그 행위 자체를 피할 수 있습니다. 이렇게 락을 거는 행위 자체만 피하더라도 수많은 장점이 따라옵니다.

예를 들어서 락킹 시스템에서의 오버헤드도 피할 수 있고 락킹 시스템에서 가장 치명적인 데드락도 피할 수 있습니다.

또한, 충돌하지만 않으면 뛰어난 성능을 보여주기도 하죠. 때문에, read-only인 트랜잭션 연산에 아주 적합합니다.

OCC 단점

하지만 만약 충돌이 발생하는 것이 빈번한 경우라면 얘기가 달라집니다. 이 상황은 롤백이 많이 일어나게 되고 수많은 롤백은 수많은 재시도를 낳고 이는 네트워크 I/O가 폭발적으로 증가하여 병목현상이 발생할 수 있습니다.

또한, OCC의 경우 3단계중 2단계인 검증 단계에서 검증에 필요한 네트워크 I/O가 있기 때문에 네트워크 오버헤드가 발생할 수 있습니다. 그럼 병목현상이 두배로 늘어나겠네요?

마지막으로 트랜잭션 내부적으로 연산이 많아서 트랜잭션을 오래 유지해야하는 경우에는 충돌의 위험이 높아질 수 있기 때문에 이런 경우엔 OCC를 사용하는 것이 올바른 방법이 아닙니다.

OCC 정리

타임스탬프 기반 프로토콜과 마찬가지로 OCC는 각각의 노드에 락을 거는 것이 불가능에 가까운 분산 시스템에서 주로 사용됩니다.

만약 분산 시스템에서 각각의 노드에 락을 걸게 되면 복잡성이 엄청나게 올라가고 매우 비효율적이기 때문에 주로 분산 시스템에선 락킹 매커니즘을 사용하지 않는 것입니다.

정리

이렇게 데이터베이스 동시성 제어를 위한 타임스탬프 기반 프로토콜과 낙관적 동시성 제어를 알아봤습니다.

제가 알고 있던 동시성 제어는 MVCC와 Shared Lock과 Exclusive Lock을 이용한 락킹 매커니즘밖에 몰랐는데 이번 기회에 두가지 더 알게 되어서 굉장히 뿌듯합니다.

주로 MVCC와 흔히 Lock-Based Protocol이라고 부르는 Shared Lock과 Exclusive Lock은 RDBMS에 특화된 동시성 제어 방식입니다.

제가 주력으로 사용하던 데이터베이스가 MySQL이었기 때문에 RDBMS에 특화된 동시성 제어에 관심이 있었지만 이제 NoSQL도 제 기술스택으로 추가되었기 때문에 분산 시스템에 적합한 타임스탬프 기반 프로토콜과 낙관적 동시성 제어를 알게 되어서 다행입니다.

여기까지 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

Chat GPT 4.0 - Open AI

Redis 장애시 RDBMS의 연쇄적인 장애에 대응하기 위한 전략

만약 캐싱 솔루션으로 Redis를 사용하고 있다면 Redis가 장애상황으로 죽어버리는 경우 RDBMS에 부하가 심하게 발생하여 RDBMS까지 연쇄적으로 장애가 발생하는 상황이 충분히 있을 수 있습니다.

저는 프로젝트를 진행하면서 Redis를 이용해서 캐싱 솔루션을 도입했고 결과적으로 RDBMS의 부하를 30퍼센트 이상 줄이기도 하였습니다.

이 30퍼센트라는 수치는 결코 무시할 수 없기 때문에 Redis의 장애상황에 대한 대비책이 있어야합니다.

이번 포스팅에선 Redis가 장애시 RDBMS의 연쇄적인 장애를 대응하기 위해 어떤 전략을 사용하는지 알아보도록 하겠습니다.

Redis의 가용성을 높이자!

흔히 생각할 수 있는 방법으로 Redis를 죽지않게 관리하는 것입니다.

Redis에는 두가지 배포 방식이 있습니다. 바로 Sentinel과 Cluster입니다. 간단하게 설명해서 Sentinel은 가용성에 초점을 두고 있고 Cluster는 확장성에 초점을 두고 있는 배포 방식입니다.

Redis의 장애 상황에 대처하기 위해선 가용성을 높여주는 Sentinel 방식을 채택하면 됩니다.

Sentinel

Sentinel은 기본적으로 RDBMS에서 높은 가용성을 위해 사용하는 전략인 Replication (리플리케이션 혹은 레플리케이션) 전략을 도와주기 위한 설정 같은 것입니다.

Sentinel을 사용하면 Redis의 높은 가용성을 챙길 수 있게 됩니다.

Sentinel의 역할

Sentinel은 마스터 노드, 레플리카 노드에 대한 모니터링을 진행해주고 Redis를 활용해서 할 수 있는 Pub/Sub 메시징으로 알람도 보내줄 수 있습니다.

또한, 마스터 노드가 죽어버리면 다른 레플리카를 마스터 노드로 승격시키기 위한 투표가 진행됩니다. 이 방식을 의회 방식이라고 합니다.

쉽게 말해서 Sentinel은 마스터 노드의 상태를 계속 확인하고 있다가 죽으면 다른 레플리카를 마스터로 승격시키는 역할을 합니다.

Sentinel 설정하기

sentinel monitor mymaster 127.0.0.1 6379 2

sentinel down-after-milliseconds mymaster 60000

sentinel failover-timeout mymaster 180000

sentinel parallel-syncs mymaster 1

sentinel monitor resque 192.168.1.3 6380 4

sentinel down-after-milliseconds resque 10000

sentinel failover-timeout resque 180000

sentinel parallel-syncs resque 5

Redis를 설치하면 기본적으로 Sentinel.conf 라는 설정파일이 존재합니다.

안에는 Sentinel을 설정하기 위한 설정 정보들이 들어있습니다. 하나씩 살펴보도록 하죠.

sentinel monitor <master-name> <ip> <port> <quorum>

Sentinel이 모니터링할 마스터 노드를 설정하는 것입니다. Sentinel은 마스터 노드에 장애가 발생했을 때 Sentinel들이 투표해서 레플리카를 마스터로 승격시킨다고 하였습니다. quorum은 그 때 필요한 최소 투표 수입니다.

즉, quorum이 2이면 최소 2개의 Sentinel이 레플리카를 마스터 노드로 승격시키는 것을 동의해야 승격이 된다는 의미입니다.

sentinel down-after-milliseconds <master-name> <time>

Sentinel이 마스터 노드에 <time>만큼 통신이 두절되면 Sentinel이 레플리카를 마스터 노드로 승격시키겠다는 뜻입니다.

sentinel failover-timeout <master-name> <time>

Sentinel이 마스터 노드의 failover에 대한 타임아웃을 설정하는 설정입니다.

sentinel parallel-syncs <master-name> <number>

Sentinel이 마스터노드가 failover후 동기화 할 개수를 정하는 설정입니다.

이제 본격적으로 Sentinel에 대해 알아보도록 하죠.

Sentinel을 배포할 때 주의해야 하는 사항이 몇가지 있는데

• 적어도 세 개의 Sentinel 인스턴스 사용하기

• Sentinel 인스턴스는 독립적인 환경에서 실행하기

• Redis는 높은 성능을 위해 비동기 복제가 이루어지므로 데이터 유실 가능성이 존재

• Sentinel과 Docker, NAT Gateway의 사용시 포트 매핑에 대한 주의 사항 숙지하기

하나씩 알아보도록 하겠습니다.

Sentinel 사용시 주의 사항

1. 적어도 세 개의 Sentinel 인스턴스 사용하기

2. Sentinel 인스턴스는 독립적인 환경에서 실행하기

1번과 2번이 연결되는 내용이라 한번에 보겠습니다.

이번 챕터에서 사용하기 위해 유닉스 아트로 되어있는 그림을 준비해봤습니다. 여기서 등장하는 용어는 총 네개입니다.

• Master : 마스터 노드입니다. 우리는 이 챕터에서 마스터 노드는 M1, M2 이런 식으로 부를겁니다.

• Replica : 레플리카 노드입니다. 우리는 이 챕터에서 레플리카를 R1, R2 이런 식으로 부를겁니다.

• Sentinel : 우리가 생성한 Sentinel입니다. 여기선 S1, S2 이런 식으로 부릅니다.

• Client : 클라이언트는 Redis 서버가 될 수도 있고 우리의 애플리케이션 서버가 될 수도 있습니다. 스프링이라면 스프링을 올려놓은 서버가 되겠네요. 말 그대로 Redis를 사용하는 서버입니다. C1, C2 이런 식으로 부르겠습니다.

왜 Sentinel 인스턴스가 세 개 이상이어야 하는지 보여드리겠습니다.

우리는 마스터 노드 한개에 레플리카를 하나 두고 있습니다.

만약 마스터 노드가 죽어버리면 어떻게 되나요?

맞습니다. 레플리카가 마스터로 승격됩니다. M1이 죽고 R1이 M1이 되었네요.

이 때 S1과 S2가 투표를 진행해서 레플리카를 승격시켰습니다.

하지만 만약 마스터 노드뿐만 아니라 서버 전체가 다운되면 어떻게 될까요?

그럼 S2 혼자 독단으로 승격을 결정해야합니다. 이는 굉장히 위험한 방식입니다. 대통령 선거도 후보가 한명이면 의미가 없는 것과 같습니다.

때문에 quorum을 1로 설정하는 것은 굉장히 위험합니다.

그래서 quorum을 2로 설정하고 Sentinel을 각각 독립된 인스턴스에 배포해야 합니다.

그럼 안전해지겠죠?

이렇게 레플리카에 Sentienl을 배포해도 되지만 Client에 배포해도 괜찮습니다.

예를 들면 이런 그림이 될 수 있겠네요.

3. Redis는 높은 성능을 위해 비동기 복제가 이루어지므로 데이터 유실 가능성이 존재

만약 마스터 노드가 죽어버리면 Redis는 비동기 복제가 이루어지므로 데이터 유실 가능성이 존재합니다.

때문에 Redis에선 쓰기 작업을 기존 마스터 노드에 하지 않고 새롭게 마스터 노드가 된 레플리카에 쓰기 작업을 진행해야합니다.

때문에 우리는 Sentinel 설정파일에 아래와 같은 설정을 추가해줘야합니다.

이렇게 설정하면 클라이언트가 마스터 노드에 적어야 하는 쓰기 작업을 승격된 레플리카에 적기 시작합니다. 이렇게 데이터 유실을 완화할 수 있습니다.

만약 서비스가 정말 커서 Redis에 쓰는 작업이 초단위로 몇백개씩 이뤄진다면 데이터 유실을 피할 수는 없을 것입니다.

하지만 Redis는 RDBMS가 아니기 때문에 캐시 데이터 몇개 유실된다고 큰일이 나진 않습니다. 그냥 RDBMS에서 조회해서 다시 Redis에 올리면 되니까요. 사용자는 전혀 눈치채지 못할 것입니다.

그렇다고 유실되는 것을 막지 않으면 계속 죽어있는 마스터 노드에 데이터를 쓰고있을테니 이 부분은 확실히 막긴 막아야겠죠?

4. Sentinel과 Docker, NAT Gateway의사용시 포트 매핑에 대한 주의 사항 숙지하기

이 부분은 조금 내용이 어렵습니다. 하지만 중요하기 때문에 정리해보겠습니다.

만약 Redis를 도커에 올려서 사용하는 경우에 Sentinel을 사용한다면 문제가 발생할 수 있다는 얘기입니다.

보통 요즘 도커 컨테이너에 애플리케이션을 많이 올려 사용하기 때문에 개인적으로는 크리티컬한 문제라고 생각합니다.

Docker의 동작 방식

도커 같은 경우 도커 컨테이너 안에서 실행되는 프로그램들은 각각의 프로그램들이 사용하고 있는 포트들과 대응되는 다른 포트들에 노출되어 있습니다.

이렇게 포트들을 노출시킨다는 말입니다.

이렇게 포트들을 노출시키는 것은 같은 서버, 같은 시간, 같은 포트를 사용하는 여러개의 컨테이너들을 동작시킬 때 유용합니다. 또한, 포트뿐만 아니라 IP 주소를 리매핑하는 전략이라고 볼 수 있습니다.

즉, 노출되어 있는 포트, IP 주소를 다시 매핑시키는 것이 도커의 전략이라고 할 수 있습니다.

예를 들어서

MySQL을 도커로 배포해야한다면 Docker-Compose로 설정파일을 작성할 것입니다.

만약 MySQL을 3330포트로 매핑하고싶다면

0.0.0.0:3330 -> 3306/tcp

이렇게 리매핑을 시킬 것입니다.

도커는 이것을 허용한다는 의미입니다. 실제로 위의 예제처럼 3330 포트로 도커 컨테이너를 띄우면 컨테이너에 외부에서 접근하고 싶은 경우 3330 포트로 접근해야합니다.

도커 내부적으로는 3330 포트로 들어온 네트워크를 3306으로 "리매핑" 시킨다는 것을 이야기한 것입니다.

이런 포트 리매핑 전략은 두 가지 관점에서 Sentinelㅇ르 운영하는 데 문제가 될 수 있습니다.

• Sentinel은 다른 Sentinel이 더이상 동작하지 않는 것을 자동으로 감지하기 위해 각각의 Sentinel들이 연결을 위해 열어두고 있는 포트나 IP 주소에 hello 메시지를 보내면서 통신하고 있기 때문에 문제가 됩니다.

즉, Sentinel들은 마스터 노드의 상태를 확인한다던가 하는 내부적으로 메트릭을 보내기 위해 메시지를 보내면서 통신하고 있다는 얘기입니다.

하지만 포트를 리매핑하게 되면 Sentinel입장에선 전혀 감지할 수가 없습니다. 그러므로 Sentinel끼리 주고받는 메시지 혹은 정보가 올바르지 않게 될 수 있습니다.

• 레플리카들은 마스터노드와 비슷한 방식으로 INFO 정보들을 리스트업 해두고 있는데 이 주소들은 멀리 떨어진 (논리적 혹은 물리적으로 분리된) 동료들과 TCP 연결을 체크하는 마스터에 의해 감지됩니다.

이때 TCP 연결을 위해 handshake 과정이 있어야 하는데 만약 포트가 리매핑이 된다면 앞선 1번의 이유 때문에 TCP연결을 할 수 없는 상황이 발생합니다.

이러한 이유로 시스템의 관점에서 어떤 레플리카가 마스터로 승격되기 괜찮은지 모르기 때문에 (포트 리매핑이 되면 마스터고 레플리카고 어떤 상태인지 모르는 상태가 되기 때문에) 마스터 노드의 장애회복을 절대로 할 수 없는 상황이 생깁니다.

그러므로 "1대1로 매핑되는 도커"를 구성하지 않는 이상 도커를 사용하여 배포한 일련의 마스터 노드 혹은 레플리카 노드들은 Sentinel을 사용할 때 필요한 모니터링을 사용할 수 없습니다.

만약 도커를 사용해야 한다면 아래의 설정을 추가해줘야합니다.

이는 Sentinel이 어떤 IP 주소, 포트로 정보를 주고 받을 것인지 각 Sentinel끼리 정해놓는 설정입니다.

잉? NAT Gateway는 왜?

도커는 알겠고 NAT Gateway는 왜 그럴까요?

NAT Gateway 자체가 private하게 관리되는 서버를 public한 통신이 가능하도록 만들어주는 게이트웨이이기 때문입니다.

앞서 설명했던 포트 리매핑은 단순히 포트만 해당하는 것이 아닙니다. IP 주소를 리매핑하는 것도 포함이 됩니다.

NAT Gateway는 내부적으로 private하게 감싸져있는 IP주소를 가지고 있고 만약 외부 네트워크 통신이 필요하다면 이 private한 IP주소를 public하게 바꿔서 매핑시키는 역할을 하기 때문입니다.

이때 앞서 설명했듯이 Sentinel 내부적으로 메시지를 통해 정보를 주고 받는 과정이 IP 주소를 리매핑하는 NAT Gateway 때문에 불가능해진다는 특징이 있습니다.

때문에 NAT Gateway 또한, 도커와 마찬가지로 사용한다면 위의 설정을 추가해줘야합니다.

이외의 방법

Redis의 장애는 일시적이고 많이 발생하지 않기 때문에 가용성을 위해 인스턴스를 많이 만들어 두는 것은 자원 낭비라고 생각할 수 있습니다. 그럴땐 아래와 같은 방법으로 Redis를 운용할 수 있습니다.

• AWS의 Auto-Scaling같이 Redis 인스턴스를 스탠바이 시켜둔다

• AWS의 ElasticCache for Redis 혹은 Azure Cache for Redis 혹은 Google Cloud MemoryStore for Redis와 같이 자동적으로 인스턴스의 크기를 변경할 수 있는 클라우드 기반 캐싱 솔루션을 생각해볼 수 있습니다.

• 모니터링과 알람을 통해 수동적으로 캐시 서버를 관리하는 방법

마치며

장애회복이라는 카테고리로 처음 출범하는 포스팅이었는데 꽤나 의미있는 포스팅이 된 것 같습니다.

이 카테고리는 앞으로 실환경에서 장애상황에 어떻게 대처해야 하는지 이론적인 방법을 알아보기 위해 만들어졌습니다.

앞으로 주된 공부가 되지 않을까 싶네요.

이렇게 Redis를 이용해 가용성을 높이는 방법에 대해서 알아봤습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://redis.io/docs/management/sentinel/#sentinel-docker-nat-and-possible-issues

https://velog.io/@roycewon/Redis-Sentinel

JWT 인증에서 Redis에 장애가 발생했을 때 대비책에 대한 전략

이번에도 Redis에 관한 내용...은 아니고 JWT에 대한 내용입니다. Redis는 조연입니다.

많은 분들이 회원 인증에 JWT를 사용하곤 하십니다. 실제로 저도 프로젝트에 JWT와 Spring Security를 접목시켜 회원 인증을 진행했습니다.

프로젝트에 JWT로 인증을 진행하는건 좋은데 그 뒤가 궁금했습니다. 만약에 Redis에 장애가 발생하면 JWT인증은 어떻게 될까?

JWT와 Redis

구글링 한번이면 나오는 내용이기 때문에 자세히 다루지는 않겠습니다.

JWT는 stateless한 인증 방식이라고 알고들 계시겠지만 이는 반은 맞고 반은 틀린 이야기입니다. 보통 JWT로 인증을 진행할 때 Access Token은 쿠키에 Refresh Token은 Redis에 저장하곤 합니다.

쿠키에 HTTP only 태그를 붙이면 쿠키 갈취에 대해서 어느정도 방어할 수 있지만 쿠키는 언제든지 갈취당할 수 있는 자원입니다. 때문에 Refresh Token까지 쿠키에 저장하는 것은 굉장히 위험합니다.

이 때문에 Redis와 같은 Key-Value Store 인 NoSQL에 Refresh Token을 저장하곤 하죠.

JWT의 인증 순서는 다음과 같습니다.

• Access Token을 검증

• Access Token이 없다면 Refresh Token 검증

• Refresh Token을 검증하고 Access Token 재발급

여기서 1번 과정은 확실히 stateless합니다. 하지만 2번 과정은 stateful입니다.

서버쪽에서 Redis에 네트워크 I/O를 주고 받고 Refresh Token 값을 가져와야 하기 때문입니다.

그렇다는 말은 Redis에 장애가 발생하면 Refresh Token을 가져올 방법이 없다는 뜻입니다. 그럼 Redis의 장애시 회원 인증이 안되네요?

하지만 이렇게 말씀하실 수도 있습니다.

"저는 Access Token으로 Authentication 객체를 만들어서 Security Context Holder에 저장하고 있는데요? 그럼 적어도 사용자가 애플리케이션을 벗어나기 전까진 인증할 수 있는 것은 아닌가요?"

맞습니다. 하지만 보통 Authentication을 가져올 때 Access Token이 만료되는 것을 기준으로 Filter Chain에서 확인하기 때문에 Access Token이 만료되는 순간 인증되지 않습니다.

즉, Access Token이 살아있는 약 30분 (보통 30분으로 TTL을 설정한다고 하더라구요) 동안만 인증되고 그 후는 인증이 되지 않습니다.

그럼 어떡하라는 말인가...

Hybrid Authentication

Redis에 장애가 발생했을 때를 대비해 뒷배를 두는 것입니다. Redis에서 stateful하게 Refresh Token을 검증하고 애플리케이션 내부적으로 stateless하게 검증하는 방식을 따로 두는 방법입니다.

우선 jjwt와 같은 JWT를 안전하게 검증할 수 있는 라이브러리가 필요합니다.

<dependency>

<groupId>io.jsonwebtoken</groupId>

<artifactId>jjwt-api</artifactId>

<version>0.11.2</version>

</dependency>

<dependency>

<groupId>io.jsonwebtoken</groupId>

<artifactId>jjwt-impl</artifactId>

<version>0.11.2</version>

<scope>runtime</scope>

</dependency>

<dependency>

<groupId>io.jsonwebtoken</groupId>

<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->

<version>0.11.2</version>

<scope>runtime</scope>

</dependency>

그리고 아래와 같이 JWT를 검증합니다.

import io.jsonwebtoken.Claims;

import io.jsonwebtoken.Jws;

import io.jsonwebtoken.JwtException;

import io.jsonwebtoken.Jwts;

import io.jsonwebtoken.security.Keys;

import java.security.Key;

public class JwtTokenValidator {

private String secretKey = "your-very-secure-secret-key";

// JWT의 Refresh Token을 검증하기 위한 메서드입니다.

public boolean validateStatelessToken(String token) {

try {

Key key = Keys.hmacShaKeyFor(secretKey.getBytes());

// 토큰을 파싱하여 만약 매치되지 않는다면 예외를 터트립니다.

Jws<Claims> claimsJws = Jwts.parserBuilder()

.setSigningKey(key)

.build()

.parseClaimsJws(token);

Claims claims = claimsJws.getBody();

// 여기에 검증을 추가할 수도 있습니다. issuer나 audience, expiration 등을 검증하면 됩니다.

// e.g., claims.getIssuer().equals("expectedIssuer")

return true;

} catch (JwtException | IllegalArgumentException e) {

System.err.println("Invalid JWT token: " + e.getMessage());

return false;

}

}

}

하지만 저는 이런 생각이 들더군요. Redis가 정상작동하면 굳이 stateless 토큰을 검증하지 않아도 되잖아?

물론 stateless 토큰은 HS256알고리즘을 사용했기 때문에 성능적인 부분에서 뒤지지않습니다. 하지만 뭔가... 둘 다 사용하는 것은 효율적이지 않은 것 처럼 보여서 Redis가 살아있을 땐 Redis를, 죽었을 땐 stateless 토큰을 사용하도록 구현했습니다.

import org.slf4j.Logger;

import org.slf4j.LoggerFactory;

import org.springframework.stereotype.Service;

@Service

public class TokenValidationService {

private static final Logger logger = LoggerFactory.getLogger(TokenValidationService.class);

private final RedisService redisService; // 이건 Redis를 관리하는 Service 객체

private final JwtTokenService jwtTokenService; // 이건 JWT를 관리하는 Service 객체

@Autowired

public TokenValidationService(RedisService redisService, JwtTokenService jwtTokenService) {

this.redisService = redisService;

this.jwtTokenService = jwtTokenService;

}

public boolean validateRefreshToken(String token) {

try {

// Redis에 Refresh Token을 검증

return redisService.validateTokenWithRedis(token);

} catch (RedisConnectionException | RedisCommandExecutionException e) {

// Redis에 연결이 되지 않거나 값을 가져올 수 없을 때 에러 로그를 찍고 stateless한 토큰 검증

logger.error("Redis validation failed, falling back to stateless validation: {}", e.getMessage());

return jwtTokenService.validateStatelessToken(token);

} catch (Exception e) {

// 이외의 예외는 여기서 에러 메시지를 출력

logger.error("Token validation error: {}", e.getMessage());

return false;

}

}

}

이렇게 구현을 다 하고 보니까 뭔가 이상했습니다.

public boolean validateRefreshToken(String token) {

...

}

이 부분 파라미터로 넘기는 token이 Refresh Token이란 말이죠? 근데 지금 전제가 Redis에 장애가 발생한 상황입니다.

그럼 Refresh Token이 담겨있는 Redis에서 조회할 수 없고 그럼 검증할 수 없는데 어떻게 파라미터로 받아올 수 있다는 것이지?

구조를 바꿔야한다

Hybrid Authentication 방식은 어떻게든 Redis의 도움을 받지 않고 Refresh Token을 가져와야 합니다.

그럼 필연적으로 제가 사용했던 Access Token은 쿠키에 Refresh Token은 Redis에 저장하는 방식은 Hybrid Authentication 방식과 어울리지 않습니다.

방법은 세 가지가 떠올랐습니다.

• EHCache (JPA 2차캐시) 에 저장해두자

• Authorization 헤더에 JWT를 담자

• SESSIONID를 이용해서 쿠키에 Refresh Token을 저장하자

결국 저는 2번을 선택했습니다.

1번과 3번 모두 혹하는 생각이었지만 고민끝에 2번을 결정하게 되었습니다.

1번은 결국 캐시 동기화 문제 때문에 선택할 수 없었습니다. 물론 제 프로젝트는 분산 환경이 아니라 괜찮긴 합니다만... 분산 환경을 생각했을 때 캐시 동기화 문제가 생길 수 있기 때문에 문제가 될만한 것을 선택하기 꺼려졌습니다.

3번은 SESSIONID는 HTTP only 쿠키이고 HTTPS 프로토콜까지 사용되기 때문에 갈취에서 안전하다곤 하지만 그래도 결국 갈취될 수 있는 자원인 것은 분명하기 때문에 모험을 하진 않았습니다.

Authorization 헤더에 JWT를 저장함으로써 Redis를 사용하지 못할 땐 Authorization 헤더에서 Refresh Token을 꺼내서 검증하면 되는 것이었습니다.

1번과 3번보다 조금 더 깔끔한 방법이라 2번을 선택했습니다.

마치며

이런 뻘짓이 조금은 무색할정도로 간단한 방법이 있긴 있었습니다... 이전에 포스팅 했던 Sentinel을 사용해서 Redis의 가용성을 높이면 되는 것이거든요...

사실 Sentinel을 사용하면 Redis 장애시 RDBMS도 같이 뻗어버리는 문제와 JWT인증이 안되는 문제도 해결할 수 있습니다.

조금 무식한 방법이긴 하지만 이런 방법도 있다는 것을 경험하고 싶었습니다. 사실 모든 뻘짓 후에 머릿속에 '그냥 Sentinel 사용하면 되는거 아냐?' 가 떠올랐지만 말이죠...

뻘짓한게 아까워서 포스팅으로 작성하는 것이지만, 여러분 그냥 Sentinel 쓰세요 ㅎㅎ..

이렇게 JWT인증시 Redis에 장애가 발생하면 어떻게 해야하는지 전략에 대해서 알아봤습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

@Transactional로 분산 트랜잭션을 구현할 수 있을까?

약 2년전 시작했던 프로젝트인 온라인 쇼핑몰 프로젝트는 제가 취직을 함으로써 종료되었고 더이상 건드리지 않았습니다. 하지만 사이드프로젝트로서 나중에 이직할때 도움이 되고자 코드 리팩토링을 진행하게 되었습니다.

리팩토링을 진행하던 중에 조회수를 증가시킴과 동시에 쿠키에 조회했다는 정보를 넣어 조회수 중복 증가를 방지하는 로직을 발견했고 그 부분에서 리팩토링할 부분이 있었습니다.

바로 @Transcational 의 남용이었죠.

@Service

public class ClickDuplicationPreventService {

@Transactional

public void viewCountUp(Item item, HttpServletRequest request, HttpServletResponse response) {

Cookie oldCookie = null;

Cookie[] cookies = request.getCookies();

if (cookies != null) {

for (Cookie cookie : cookies) {

if (cookie.getName().equals("postView")) {

oldCookie = cookie;

}

}

}

if (oldCookie != null) {

if (!oldCookie.getValue().contains("[" + item.getId() + "]")) {

item.addClick(); // << 이 부분이 업데이트 쿼리가 날아가는 시점

oldCookie.setValue(oldCookie.getValue() + "_[" + item.getId() + "]");

oldCookie.setPath("/");

oldCookie.setMaxAge(60 * 10);

response.addCookie(oldCookie);

}

}

else {

item.addClick();

Cookie newCookie = new Cookie("postView", "[" + item.getId() + "]");

newCookie.setPath("/");

newCookie.setMaxAge(60 * 10);

response.addCookie(newCookie);

}

}

}

이 프로젝트를 작성할 때에는 스프링 내부적으로 @Transactional을 적었을 때 어떻게 작동하는지에 대한 이해가 적었기 때문에 "쿼리를 날린다 = @Transactional을 적는다" 라는 단순한 사고로 코딩을 했습니다.

하지만 약 6개월 전 이 글을 보고 큰 깨달음을 얻었습니다.

https://woonys.tistory.com/entry/Transactional%EC%9D%80-%EB%A7%8C%EB%8A%A5%EC%9D%B4-%EC%95%84%EB%8B%99%EB%8B%88%EB%8B%A4-1-Transaction%EC%9D%98-%EA%B0%9C%EB%85%90%EA%B3%BC-%ED%8A%B8%EB%A0%88%EC%9D%B4%EB%93%9C%EC%98%A4%ED%94%84

이 글의 요지는 @Transactional은 사용해야하는 순간이 있고 모든 순간에 @Transactional을 사용하는 것은 좋지 않다는 내용입니다.

자세한 내용은 위의 블로그를 참고해주세요!

결론은 @Transactional은 여러개의 데이터베이스 연산이 원자성을 가져야할 때 그것을 @Transactinal로 묶어줌으로써 각기 다른 연산에 대해 일관성을 유지시켜주는 것이 핵심입니다.

즉, 단순한 update 작업에 @Transactional을 사용하는 것은 옳지 못하다는 것입니다. 이는 단순한 insert 작업이나 단순한 delete 작업도 마찬가지입니다.

만약 Spring Data JPA를 사용하고 있다면 JpaRepository를 상속하는 것으로 Repository 계층에서 트랜잭션이 생성되기 때문에 여러개의 연산을 하나의 연산으로 묶을 필요가 없다면 굳이 Repository 계층에 있는 트랜잭션을 Service 계층으로 확장시킬 필요가 없는 것이죠.

그냥 쿼리 한줄 Service 계층으로 확장하는게 뭐 어때서? 라고 생각할 수도 있지만 만약 Service 로직을 실행하는 시간이 길어서 데이터배이스 커넥션이 해당 로직이 종료될 때까지 커넥션풀에 반환되지 않는다면 데이터베이스 커넥션 풀이 사용할 수 있는 커넥션이 없어질 수 있습니다.

@Transactional로 분산 트랜잭션까지 커버 가능?

이 포스팅의 요지는 리팩토링 했다는 얘기가 아닙니다. @Transactional이 트랜잭션을 스프링 내부적으로 관리하기위해 논리트랜잭션과 물리트랜잭션이라는 가상의 트랜잭션으로 개념을 추상화하여 트랜잭션의 원자성을 보장하고 있다는 것에 대한 포스팅을 한 적이 있습니다.

https://coding-review.tistory.com/425

이 포스팅의 내용은 물리트랜잭션은 실질적 데이터베이스 커밋과 롤백을 담당하고 논리트랜잭션은 논리적인 트랜잭션으로서 롤백상황을 체크하고 물리트랜잭션에게 "지금 이 트랜잭션 연산은 롤백해야해!" 라고 알려주는 역할을 합니다.

이 내용을 떠올리니 한가지 궁금한 것이 생겼습니다.

'어? 이거 개념을 조금만 확장하면 분산 시스템에서도 사용할 수 있는거 아냐?'

예를 들어서 각각의 논리트랜잭션이 서로 다른 데이터베이스에 연결된 마이크로서비스라면 이 개념을 확장할 경우 분산 트랜잭션을 흉내낼 수 있지않을까?

분산 시스템에서 트랜잭션 관리

이 궁금증을 해결하기 위해서 분산시스템에서 어떻게 트랜잭션을 관리하는지에 대해서 알아볼 필요가 있다고 판단하여 구글링을 했습니다.

이번엔 조금 지엽적인 내용을 공부할 예정이었기 때문에 GPT에게 물어보지 않고 구글링을 하였습니다.

분산 트랜잭션 : 2PC 알고리즘

2PC는 two phase commit 의 약자로서 쉽게 설명해서 각각의 트랜잭션을 커밋하기 전에 모든 트랜잭션이 준비완료를 외치기 전까지 커밋을 미루는 알고리즘입니다.

이 알고리즘을 사용하면 쉽게 분산 시스템에서 트랜잭션을 관리할 수 있기 때문에 제일 먼저 떠올리는 해결책이라고합니다. 하지만 2PC에는 치명적인 단점이 있습니다.

바로 준비완료를 외치는 단계인 1 페이즈에서 데이터베이스 lock을 이용해 대기하고 있다는 점입니다.

이 문제는 멀티스레드 환경에서 사용자 요청이 중복되는 경우 데드락을 유발할 수 있고 이 데드락을 해결하기 위해서는 분산 시스템에서 트랜잭션을 관리하는 것보다 더 어렵다고 합니다. (해당 포스팅의 저자 개인적인 생각)

SAGA 패턴

분산 시스템에선 2PC보단 SAGA패턴이라는 것을 이용해서 트랜잭션을 관리합니다.

SAGA의 철학은 각기 다른 서비스에서 생기는 커밋에 대해 예외상황이 발생할 경우 롤백 처리를 어떻게 쉽게 처리할 것인지 고민한 방법론입니다. 즉, 원자성을 분산 시스템에서 어떻게 유지할것이냐 그것이 문제인 것이죠.

해당 논문에서 저자는 SAGA 패턴에서 원자성을 유지하기 위해선 Backward Recovery, Forward Recovery (저는 후위회복, 전위회복이라고 번역하였습니다.) 가 필요하다고 설명했습니다.

Backward Recovery (후위 회복)

후위 회복은 만약 중간에서 예외가 발생했다면 커밋 이전 상태로 롤백하는 것을 말합니다. 후위 회복의 핵심은 모든 서비스에 대해 이전 상태를 보관하고 있어야한다는 것이죠.

Forward Recovery (전위 회복)

전위 회복은 예외가 발생한 지점에 포인트를 찍어 그 포인트로 되돌아가 트랜잭션을 재실행하는 것이 핵심 전략입니다. 전위 회복에서의 핵심은 트랜잭션을 재실행하는 로직이 들어가야한다는 것이죠.

Ochestration 방식과 Choreographed 방식

SAGA 패턴도 두 가지 종류가 있는데 하나는 Ochestration 방식, Choreographed 방식 이렇게 두가지 방식이 있습니다. 둘의 차이점은 각각의 서비스가 얼마나 디커플링되어있냐는 것입니다.

Ochestration 방식
Ochestration 방식

Ochestration 방식은 중앙에서 모든 서비스들의 로직을 관리하고 컨트롤하는 중앙집권방식으로서 각각의 서비스를 디버깅할 때 추적하기 쉽다는 장점이 있습니다.

Choreographed 방식
Choreographed 방식

Choreographed 방식은 각서비스들이 서로를 신뢰하고(?) 작업을 맡기는 것을 선택했습니다. 이 방식은 서비스들이 디커플링 되어있어 이상적인 분산 시스템의 형태라고 할 수 있습니다.

해당 포스팅의 저자는 Ochestration 방식과 Choreographed 방식 중 선택해야한다면 그건 우리 팀의 상황에 맞게 선택해야한다고 조심스럽게 추천합니다.

만약 하나의 팀에서 분산 시스템을 관리하고 있는 경우엔 Ochestration 방식을 추천하고, 팀이 여러개 있고 각각의 팀이 분산 시스템의 한 부분을 맡고있다면 Choreographed 방식을 사용하는 것이 일처리가 수월하다는 평가를 남겼습니다.

즉, 규모가 어느정도 있어 분산 시스템으로 서비스를 관리하지만 팀 규모가 대기업만큼 크지 않은 경우엔 Ochestration 방식을, 네카라쿠배처럼 서비스가 굉장히 커서 각기다른 팀이 분산 시스템을 관리해야 한다면 Choreographed 방식을 선택하면 좋을 것 같습니다.

그래서... @Transactional로 분산 시스템 구현 가능...?

결국 이 포스팅에서 하고싶었던 말...

가능하지만 추천하는 방법은 아니다...

@Transactional 어노테이션을 메서드에 적는 순간 로컬 트랜잭션과 글로벌 트랜잭션으로 생성됩니다. 글로벌 트랜잭션으로 생성된 것은 분산 시스템에서 트랜잭션을 하나로 모아주는 역할을 수행할 수도 있습니다.

하지만!

우리에겐 분산 시스템에 최적화된 프레임워크인 Spring Cloud 가 있습니다. Spring Cloud 는 분산 시스템에서 트랜잭션을 관리할 수 있는 복잡한 로직들을 추상화하여 개발자로 하여금 이를 쉽게 접근할 수 있게 잘 만들어져있다고 합니다. (출처 : ChatGPT 4.0)

그리고 @Transactional 로 분산 시스템의 트랜잭션을 관리하는 것은 굉장히 복잡하고 유지보수하기도 굉장히 굉장히 힘들다고 하니 굳이 사용할 이유는 없을 것 같습니다.

번외) NoSQL에서 트랜잭션관리

분산 시스템에서 트랜잭션 처리에 대한 궁금증이 해결되자마자 바로 떠오른 궁금증입니다. NoSQL에서도 트랜잭션처리가 될까?

제가 배운 이론상으로는 "NoSQL은 느슨한 ACID인 BASE속성으로 Eventual Consistency를 도입하였기 때문에 높은 가용성을 보이지만 강한 일관성을 기대하기 힘들다." 라고 배웠고 그렇게 알고있었습니다.

하지만 스택오버플로우에 올라온 답변을 통해 대충 짐작할 수는 있었습니다.

https://stackoverflow.com/questions/2212230/transactions-in-nosql

요컨대 "MongoDB같은 경우는 원한다면 선택적으로 원자성을 보장받을 수 있다." 라는 결론에 이릅니다.

데이터베이스의 세계는 결국 수렴진화하는 결과를 맞이했습니다.

ACID를 무기로 강한 일관성을 자랑하던 RDBMS 진영에선 수평확장에 대한 사용자(개발자)의 니즈를 받아들여 Postgres 에서 분산 데이터베이스에 특화된 샤딩을 지원하고, BASE를 무기로 높은 가용성과 높은 성능을 자랑하던 NoSQL 진영에선 MongoDB에서 데이터를 관계형으로 관리하고 싶은 사용자의 니즈를 받아들여 4.x에선 JOIN을 지원함과 더불어 국지적으로 원자성을 보장받을 수 있게 되었습니다.

마치며

트랜잭션을 아직 실무에서 깊이있게 다루진 못했습니다. 기회가 된다면 실무에서도 깊이있게 사용해보고 더 많은 상황을 겪어보고 싶은 것이 개인적인 바람입니다.

물론 분산 시스템에서 트랜잭션을 관리하는 것은 지금 당장은 어렵겠지만 후에 이직할 때 면접 질문으로 충분히 나올 수 있을 것 같습니다.

이번 포스팅은 여기서 마치도록 하겠습니다. 오늘도 즐거운 하루 되세요~

출처

https://medium.com/javarevisited/transactional-annotation-in-spring-framework-d571e91bf6bb

\https://medium.com/nerd-for-tech/transactions-in-distributed-systems-b5ceea869d7d

https://chat.openai.com/share/73118ea3-4d07-4392-9307-8d28145ea5c8

@Transactional과 PostgreSQL은 어울리지 않는다.

제목이 꽤나 자극적이지만 아무리 생각해도 이거만큼 좋은 제목은 떠오르지 않았습니다. 제가 오늘 포스팅을 하게된 계기가 되는 포스팅도 저자분이 "@Transactional의 해로움" 이라고 할 정도이니 말 다한 것이죠.

우선 원래의 글을 올려드릴테니 자세한 내용은 아래의 링크를 참고해주세요!

https://channel.io/ko/blog/bad-transactional?fbclid=IwZXh0bgNhZW0CMTEAAR0Atxir0WzSFcBMLmVjDXNLrUoiUl_qX93JPUPoo6EIRqP3irVoc22JEM4_aem_Gt65sDAFShcRmzIYKpla4w

데이터베이스 MVCC

데이터베이스에는 MVCC라는 것이 존재합니다. 아마 데이터베이스의 격리수준을 조금 깊이있게 공부하신 분들이라면 한번정도는 들어보셨을 법한 이 내용은 동시성 관리 (Concurrency Control) 과 관련있는 내용입니다.

우선 네가지나 되는 격리수준을 다 보진 않고 Repeatable 격리수준에 대해서 잠깐 짚어보고 넘어가도록 하겠습니다.

데이터베이스별 MVCC

데이터베이스는 동일한 쿼리를 재실행 했을 때 같은 결과가 나오는 것을 보장해야합니다. 이것이 Repeatable Read 격리수준의 알파이자 오메가이죠.

같은 결과를 보장하기위해 활용하는 방법론이 바로 MVCC이고 RDBMS에서는 이 MVCC를 각기 다른 방법으로 구현하고 있습니다.

우리는 RDBMS에서 오라클 진영인 Oracle과 MySQL, Postgres 진영인 PostgreSQL의 MVCC에 대해서 알아보려고 합니다.

오라클 진영에선 MVCC를 Undo 테이블로 관리하고 있습니다. 데이터들을 이 Undo 테이블에 저장하고 있음으로써 쿼리를 재실행 했을 때 이 테이블을 사용자에게 보여줌으로써 같은 결과를 보장해줍니다.

Postgres진영은 조금 복잡한 방식의 MVCC를 구현하고 있습니다.

Postgres진영에선 xmin, xmax라는 것을 이용해서 MVCC를 구현하는데요, 내용이 많지만 쉽게 설명해서 xmin에는 가장 최근의 트랜잭션 아이디 (이하 tid) 를 적어넣고 xmax에는 가장 오래된 tid를 적어 넣어서 언제 갱신된 레코드인지 식별할 수 있게 하는 것입니다.

이렇게 식별하기위한 하나하나의 레코드를 튜플 (tuple) 이라고 하고 결론적으로 Postgres에선 이 튜플을 이용해 MVCC를 구현했다고 볼 수 있죠.

하지만 이 튜플이 점점 쌓이게 되면 데이터베이스의 메모리를 많이 차지하기 때문에 이를 지워주는 vacuum이라는 엔진을 이용해서 튜플을 정리해주죠. GC와 비슷하다고 보시면 됩니다.

MultiXact

기본적으로 데이터베이스 락에는 두가지 종류의 락이 있는데 한번 락을 획득하면 해당 트랜잭션이 락을 놓아줄 때까지 다른 트랜잭션이 락을 획득할 수 없는 exclusive lock이 있고, 이것과 완전 반대되는 모든 트랜잭션이 락을 획득할 수 있는 shared lock이 있습니다.

보통 shared lock은 read level lock으로서 조회에서 주로 사용되고 exclusive lock은 UPDATE, DELETE, INSERT에서 주로 사용됩니다.

이번 포스팅에선 두가지 락이 모두 중요하게 언급되지만 MultiXact를 이해하기 위해선 shared lock을 조금 더 집중해서 볼 필요가 있습니다.

shared lock은 어떤 트랜잭션도 락을 획득할 수 있기 때문에 MVCC에서 xmax에 적어야하는 tid가 애매해집니다. 계속 tid를 업데이트할 수도 없는 노릇이고 그렇다고 업데이트해서 오버라이트 해버리면 이전 트랜잭션이 락을 획득했다는 정보가 없어지니 난감해지죠.

그래서 Postgres에선 Map<String, List<String>> 이런 자료구조를 이용해서 xmax에 tid를 넣어 관리하고 있습니다. 이를 그림으로 표현하면 다음과 같죠.

그리고 이 tid를 관리하는 자료구조에 있는 tid가 바로 MultiXactId입니다. 이 자료구조에 접근하기 위해 사용되는 것이 MultiXact라고 이해하셔도 무방합니다.

원글에서 저자분은 장애가 발생하고 AWS RDS의 메트릭을 보니 MultiXactMemberId, MultiXactMemberBuffer라는 두가지 메트릭이 평소보다 수백배 높았고 이를 통해 MultiXact의 존재를 인지했다고 하셨습니다.

하지만 MultiXact는 shared lock에서 tid를 관리할 때 사용되는 것이지만 저자분은 shared lock을 그 어느곳에서도 사용하고 있지 않아 당황했다고 하시더군요.

저자분은 Postgres에 대한 다양한 문서를 읽고 나서 MultiXact는 shared lock 뿐만 아니라 exclusive lock을 획득하고 나서 update문을 날릴 때에도 MultiXact를 사용한다고 알게 되었습니다.

즉, SELECT FOR UPDATE 를 통해 exclusive lock을 획득하고 그 후 UPDATE를 날리면 MultiXact가 발동되고 MultiXact가 많이 사용되면 사용될수록 vacuum이 정상적으로 작동되지 않을 때 cache miss를 일으켜 데이터베이스 성능이 떨어진다는 것이 본문의 내용이었습니다.

저자분의 애플리케이션 장애상황은 00:30 분에 진행되는 배치잡으로 인해 long transaction이 발생하였고 이로인해 vacuum이 정상적으로 작동할 수 없게 되고 이것이 장애를 발생시킨 원인이라고 하였습니다.

nested transaction

저자분의 말씀처럼 SELECT FOR UPDATE로 lock을 획득하고 UPDATE를 날리게 되는 상황을 다음과 같이 정의했습니다.

스프링에선 A라는 트랜잭션이 선언된 로직이 B라는 트랜잭션이 선언된 로직을 호출할 때 물리 트랜잭션과 논리 트랜잭션으로 트랜잭션을 나누고 전파하여 각각의 로직에 대한 롤백 정책을 펼칩니다.

해당 내용에 대한 내용은 아래의 내용에 자세히 담겨있습니다!

https://coding-review.tistory.com/484

이렇게 트랜잭션이 전파될 때 데이터베이스 내부에선 save point를 만들어서 롤백이 일어나면 전체 트랜잭션을 롤백하는것이 아니라 부분부분만 롤백해서 롤백에 대한 리소스를 최소화합니다.

이렇게 save point가 생성되면 exclusive lock을 획득하게 되고 이 상황에서 UPDATE 쿼리가 발생한다면 MultiXact가 많이 발생하게 되고 (tid에 접근하는 자료구조에 많이 접근하게 되고) 그 자료구조는 형태가 굉장히 복잡하여 vacuum이 데이터베이스 메모리를 지우는 속도보다 차오르는 속도가 더 빨라 vacuum이 정상적으로 작동하지 못하고 cache hit의 비율이 낮아지게 되면서 disk에서 직접 I/O를 유발하게 되고 이는 잠재적으로 성능의 저하로 이어집니다.

또한, 저자분은 MultiXact가 전역적으로 관리되는 global lock이기 때문에 전혀 관련 없는 테이블에 접근하는 쿼리도 다같이 느려질 수 있다고 판단하셨습니다.

즉, 이런 패턴은 Postgres에서 독이 되는 패턴이라고 할 수 있습니다.

ItemService.java

@Transactional

public void select() {

memberService.update();

}

MemberService.java

@Transactional

public void update() {

// business logic

}

이런 nested transaction이 많아지면 많아질수록 MultiXact가 많이 발생하고 그렇게 되면 1분 남짓되는 long transaction에도 큰 영향을 끼칠 수 있습니다.

왜냐하면 long transaction의 상황에선 vacuum이 정상적으로 작동하기 어렵고 vacuum에 의해 삭제되는 MultiXact가 많아지면 성능저하를 일으킬 수 있기 때문입니다.

마치며

이런 패턴이 데이터베이스 구조상 악영향을 미칠 것이라고 생각도 못했습니다. 왜냐하면 nested transaction은 꽤나 빈번하게 발생하는 것인데말이죠...

때문에, 저자는 Postgres에선 TransactionProvider에서 nested 속성을 false로 주어 해결할 수 있다고 하였습니다.

제가 자주 사용하는 MySQL진영에선 어떻게 대처하는지 궁금해져서 조금 알아보니 MySQL은 Undo방식으로 MVCC를 구현하기 때문에 xmax에 많은 tid가 적히고 이를 관리하기위한 자료구조가 딱히 없어 문제되지 않는다고 하더군요.

또한 MySQL에선 이때문인진 모르겠지만 기본적으로 nested 속성이 false로 설정되어있다고 하네요.

그렇기 때문에 Postgres의 이 문제가 꽤나 치명적으로 다가옵니다. @Transactional은 만능이 아니라는 것을 다시한번 깨닫게 되면서 이번 포스팅 마무리짓도록 하겠습니다.

출처

https://channel.io/ko/blog/bad-transactional?fbclid=IwZXh0bgNhZW0CMTEAAR0Atxir0WzSFcBMLmVjDXNLrUoiUl_qX93JPUPoo6EIRqP3irVoc22JEM4_aem_Gt65sDAFShcRmzIYKpla4w

RDBMS의 떠오르는 초신성 PostgreSQL

요즘 여러 회사들의 장애대응 회고를 보면서 드는 생각은 '요즘 PostgreSQL많이쓰네...' 였습니다. 어떤 회사들은 AWS RDS를 PostgreSQL로 한 회사도 있더라구요.

그걸 보면서 어떤 점이 다른 RDBMS를 대체할만큼 매력적이었을까 의문이 들어서 PostgreSQL에 대해서 간단한 개요수준으로 공부해봤습니다.

이번 포스팅에선 PostgreSQL이 다른 RDBMS와 차별점을 가지는 점에 대해서 포스팅을 할 생각입니다.

PostgreSQL이 기존 RDBMS와 뭐가 다를까?

제가 아는 PostgreSQL은 Postgres라는 프로젝트에서 시작해 QUEL ('큐엘'이라고 발음하는 것 같습니다) 이라는 언어를 지원한다는 의미에서 PostgreSQL이 되었다고 알고 있습니다.

때문에 발음이 포스트-그레스-큐엘인 것이라고 까지만 알고있었습니다. 그리고 잠깐잠깐 들리는 말로 RDBMS인데 분산 데이터베이스의 특징을 가지고도 있다고 들었습니다.

MySQL도 부분적으로 샤딩을 지원하는 것으로 알고 있지만 그것이 널리 사용되는 것은 아니라고 알고 있었죠.

하지만 제 개인적인 의견으로는 RDBMS를 분산 데이터베이스처럼 사용한다는 것은 엄청나게 많은 데이터셋을 가지고 있는데 NoSQL로 넘어가기엔 ACID나 다양한 제약조건을 포기할 수 없었던가 RDBMS를 NoSQL로 마이그레이션 하는 것이 매우 힘들기 때문에 PostgreSQL에서 샤딩을 선택하는 것이라고 생각하고 있습니다.

즉, 분산 데이터베이스처럼 PostgreSQL을 사용하는 회사들은 많지 않을 것이라는 것이 제 생각입니다. 그래서 저는 더더욱 궁금했습니다. PostgreSQL의 특징 중 기존 RDBMS와 눈에띄게 다른 점이라면 분산 데이터베이스를 쉽게 적용할 수 있다는 것인데 그런 회사는 많지 않음에도 왜 PostgreSQL을 선택할까?

본격적으로 한번 파헤쳐봤습니다.

오픈소스

PostgreSQL은 오픈소스입니다. 오잉? MySQL이랑 MariaDB도 오픈소스인데? 맞습니다. 하지만 PostgreSQL은PostgreSQL라이센스를 가지고 있습니다. 이는 이 오픈소스로 지지고 볶아도 전혀 문제가 없다는 얘기입니다.

MySQL은 GNU산하의 GPL (General Public License) 를 가지고 있어서 PostgreSQL에 비해 더 제한조건이 있는 라이센스입니다.

오픈소스라는 장점때문인지 PostgreSQL은 엄청나게 큰 커뮤니티를 가지고 있고 extension도 꽤나 잘 되어있습니다.

• Citus : PostgreSQL을 분산 데이터베이스로 만들어주는 멋진 녀석입니다. multiple node를 이용해서 sharding을 지원해준다는 특징이 있고 쿼리를 병렬처리 해준다는 특징도 있습니다.

• PostGIS : 기하학적인 데이터를 SQL로 관리할 수 있게 만들어주는 extension입니다.

• Patroni : PostgreSQL의 장애회복과 레플리카를 관리해줌으로써 높은 가용성을 챙길 수 있게 도와주는 extension입니다.

제가 봤을 때 괜찮아보이는 것들 세개정도만 추려봤는데 꽤나 괜찮은 extension이 많더군요.

하지만 PostgreSQL이 다른 데이터베이스에 비해 남다른 커뮤니티 크기와 다양한 extension을 가지고 있지만 사실 이게 여타 RDBMS를 대신해 PostgreSQL을 선택할만한 이유는 되지 않는 것 같습니다.

기하학적인 데이터를 RDBMS에서 관리할바엔 NoSQL로 관리하고 장애회복은 여타 데이터베이스에서도 기본적으로 가지고 있는 기능이기때문이죠.

확장성

제가 생각했을 때 여타 RDBMS와 다른 PostgreSQL만의 장점 중 하나가 아닐까싶습니다.

무엇보다 PostgreSQL은 레플리케이션을 동기방식과 비동기방식을 지원해줍니다. 이 방식을 스트리밍 레플리케이션이라고 하더군요. 이 방식은 데이터가 바뀌는 것을 실시간으로 다른 데이터베이스가 보고 즉시 자신의 테이블에 적용하는 방식으로서 다른 RDBMS에 비해 레플리카에서 발생하는 쓰기연산의 리소스 비용을 최대한 줄일 수 있다고합니다.

MySQL은 비동기방식의 레플리케이션과 세미동기방식의 그룹 레플리케이션을 지원합니다. 때문에 동기방식의 레플리케이션을 지원하지 않기 때문에 PostgreSQL에 비해 조금 덜 유연하다고 볼 수 있습니다.

하지만 확장성도 PostgreSQL이 MySQL보다 조금 더 우세하다 하여 MySQL대신 PostgreSQL을 선택할 이유는 아닌 것 같습니다.

인덱스

저였으면 이것때문에 PostgreSQL을 선택했을 것입니다. RDBMS에서 읽기 연산, 그것도 복잡한 조인 연산이 들어간 읽기 연산의 성능을 높이는 것이 항상 RDBMS 진영에선 큰 고민거리였습니다.

때문에 조인 연산에서 조건문에 자주 걸리는 FK나 일반적인 쿼리에서의 조건문인 where절에서 자주 걸리는 컬럼에 대해 인덱스를 설정하여 읽기 성능을 끌어올렸습니다.

인덱스는 데이터베이스 엔진이 데이터를 더 잘 찾을 수 있도록 컬럼을 정렬해주는 것이기 때문에 잘 설정하면 성능적인 부분에서 큰 이점을 얻을 수 있었습니다.

기본적으로 MySQL에서 제공해주던 인덱스 설정은 B- tree, 부분적으로 가능한 Hash Index, 일반 VARCHAR 컬럼에 걸었던 full-text Index가 있습니다.

Hash Index는 MySQL의 기본 엔진인 InnoDB 스토리지 엔진이 아닌 Memory 스토리지 엔진에서만 부분적으로 적용되던 문제가 있었습니다.

하지만 PostgreSQL은 인덱스의 측면에서 MySQL을 압도하는 능력을 가지고 있습니다.

• B- tree : 보통 많은 RDBMS에서 사용하는 인덱스 설정으로 = 연산자로 가져오는 쿼리인 point query와 between으로 가져오는 쿼리인 range query에 범용적으로 사용할 수 있는 인덱스 설정입니다.

• Hash Indexes : PostgreSQL은 기본적으로 Hash Index를 지원합니다. 이 Hash Index는 = 연산자로 가져오는 point query에 최적화 되어있다는 특징이 있습니다. 하지만 반대로 range query에는 적절하지 않습니다.

• Full-Text Indexex : 일반 VARCHAR에 해당하는 컬럼에 걸면 좋은 인덱스 설정입니다.

• GIN (Generalized Inverted Index) : GIN은 복잡한 JSONB같은 복잡한 타입의 데이터에 효과적인 인덱싱 방법입니다. 하지만 인덱스의 관리가 복잡해 UPDATE연산이 기존 인덱스보다 더 느려지는 경향이 있습니다.

• GiST (Generalized Search Tree) : 표기법이 정말 이상한 GiST 인덱싱 방법은 기하학적인 데이터 타입이나 Full-Text에 적합한 인덱싱 방법입니다.

• SP-GiST (Space-Partitioned Generalized Search Tree) : 이 인덱싱 방법은 봐도 이해가 안되더라구요... 잘 사용하지 않는 특정한 데이터 타입에 사용하는 것 같습니다.

• BRIN (Block Range INdex) : 얘도 표기법이 이상한데 이친구는 time-series 데이터 들이 엄청나게 많은 테이블에 주로 사용하는 인덱싱 방법이라고합니다. 아마 로그나 그런 곳에 사용하는 것 같네요.

정리하자면 다음과 같습니다.

• point query에는 B- tree, Hash Index를 사용하자

• range query에는 B- tree, GiST를 사용하자

• 복잡한 데이터 타입 (배열, JSONB)는 GIN을 사용하자

• time-series 데이터들은 BRIN을 사용하자

• Full-Text는 GIN, GiST, Full-Text 인덱싱을 사용하자

마치며

PostgreSQL은 오픈소스이니만큼 다른 RDBMS에 비해 공부할게 더 많은 것 같습니다. 마치 Jenkins나 QueryDSL처럼 공부할게 좀 많네요...

나중에 시간이 난다면 PostgreSQL을 이용해서 분산 데이터베이스를 사용한 사례나 동작방식을 포스팅해보고싶네요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

RDBMS의 레플리케이션 전략

이번 포스팅은 PostgreSQL을 공부하다가 나온 Streaming Replication이 흥미로워서 이참에 다른 RDBMS의 레플리케이션 전략에 대해서 공부해보고 정리해보려고합니다.

이 전략들을 공부하기 전엔 단순히 "RDBMS가 레플리케이션이라는 고가용성 전략이 있고 이걸 이용하면 데이터베이스 서버를 여러대 두면서 부하를 분산시킬 수 있다." 정도만 알고 있었습니다.

이번 기회에 RDBMS의 다양한 전략들을 보면서 조금 더 깊이있는 공부를 한 것 같아서 조금 뿌듯합니다. 한번 정리해보도록 하겠습니다.

Replication이란?

레플리케이션 (혹자는 리플리케이션이라고도 읽는 것 같습니다) 은 클러스터링에 비해 안정적으로 가용성을 높이면서 데이터베이스 한대로는 감당할 수 없었던 부하까지 분산시킬 수 있는 방법론입니다.

기존 클러스터링은 하나의 DB스토리지에 여러대의 DB서버가 붙어서 부하를 분산시키기에는 적합했지만 DB스토리지가 죽어버렸을 때 다른 DB서버가 전부 죽어버린다는 점에서 가용성을 챙길 수는 없었습니다.

데이터베이스 클러스터링, 레플리케이션에 대한 자세한 내용은 아래의 링크를 참고해주세요!

https://coding-review.tistory.com/309

레플리케이션은 하나의 DB스토리지와 DB서버가 짝을 이룬 것이 여러개 있어서 하나의 DB스토리지가 죽어버리더라도 남아있는 DB스토리지들이 역할을 이어받으면서 가용성을 챙길 수 있었습니다.

또한, 데이터베이스의 역할을 마스터노드, 슬레이브노드로 나누어서 마스터노드엔 쓰기작업을 진행하고 읽기작업은 슬레이브노드가 담당하면서 로드밸런싱까지 챙길 수 있었습니다.

RDBMS의 레플리케이션 전략 (동기 레플리케이션, 비동기 레플리케이션)

RDBMS는 레플리케이션 전략으로 동기, 비동기 레플리케이션을 기본 전략으로 가져갑니다. 이 둘의 차이는 동기, 비동기의 차이와 매우 비슷합니다.

동기 레플리케이션

동기 레플리케이션은 마스터 노드에 적힌 쓰기작업을 슬레이브 노드로 전파할 때 슬레이브 노드에 데이터가 적혔다는 것을 확인받은 다음에 마스터 노드를 재가동하는 방식입니다.

이를 그림으로 표현하면 다음과 같습니다.

비동기 레플리케이션

비동기 레플리케이션은 동기와 반대되는 특징을 가지고 있습니다.

슬레이브 노드에서 커밋된 것을 마스터 노드가 확인하지 않고 그냥 일방적으로 데이터를 보내면서 마스터노드는 슬레이브노드에 데이터가 잘 적히는지 확인하지 않고 작동합니다.

데이터베이스에서 둘의 차이점은 단순히 동기, 비동기의 차이처럼 속도가 빠르다거나 리소스를 많이 잡아먹는다거나 하는 문제를 조금 벗어난 새로운 차이점이 있습니다.

바로 장애상황에서 데이터 유실의 관점에 대한 차이입니다.

상황) 마스터 노드가 죽어버린 상황

• 동기 레플리케이션 : 동기 레플리케이션에서 마스터 노드가 죽어버리면 저절로 슬레이브 노드로 쓰기 연산을 전파하지 않아서 데이터 유실이 없습니다.

또한, 그렇기 때문에 마스터 노드가 죽었다는 것을 슬레이브 노드가 즉각적으로 인지할 수 있으며 추가적으로 다른 슬레이브 노드중 마스터 노드로 승격시킨다던가하는 장애회복 절차를 수행할 수 있습니다.

• 비동기 레플리케이션 : 반대로 비동기 레플리케이션에선 어차피 마스터 노드는 슬레이브 노드로 쓰기 연산을 전파하고 슬레이브 노드의 응답을 받지 않았기 때문에 마스터 노드가 죽어버리면 그대로 데이터가 유실됩니다.

또한, 경우에 따라선 비동기 레플리케이션의 경우 마스터 노드가 죽었다는 정보가 슬레이브노드로 전파되는 것이 늦어 장애회복 절차가 조금 늦을 수 있습니다.

때문에, 우리 서비스가 데이터 유실에 민감한 애플리케이션인지 아닌지 잘 따져보고 레플리케이션 전략을 선택해야 할 것같습니다.

RDBMS에선 어떤 방법으로 레플리케이션을 구현했을까?

레플리케이션이 방법론이라면 각 RDBMS가 어떻게 레플리케이션을 구현했을지 궁금해서 MySQL과 PostgreSQL이 어떻게 레플리케이션을 구현했을지 알아보았습니다.

MySQL

MySQL에선 비동기 레플리케이션, 세미동기 레플리케이션, 그룹 레플리케이션 이렇게 세가지가 구현되어있습니다.

• 비동기 레플리케이션 : MySQL에선 마스터 노드에 binlog (binary log) 라는 로그파일이 있고 이 로그 파일엔 어떤 데이터가 조작되었는지 low level로 적혀있습니다. 이 binlog를 다른 레플리카에 전달함으로써 레플리카는 마스터 노드에서 벌어진 데이터 흐름을 파악할 수 있습니다.

• 세미동기 레플리케이션 : 왜 동기 레플리케이션이 아니고 세미동기냐면 마스터노드가 슬레이브노드에 binlog를 전달하고 나서 단 하나의 슬레이브노드에서 응답이 온다면 그때 다시 마스터 노드를 동작하는 방식이기 때문입니다.

이렇게 함으로써 최소한의 지속가능성을 유지할 수 있고 최소한의 정합성을 지킬 수 있게 되었습니다.

• 그룹 레플리케이션 : MySQL이 가진 독특한 레플리케이션 방식으로서 제가 알고 있던 내용의 레플리케이션 전략이었습니다.

노드들이 그룹을 이루고 마스터 노드가 죽었을 경우 슬레이브 노드의 투표를 통해 가장 상태가 좋은 슬레이브 노드를 마스터 노드로 승격시키는 레플리케이션 전략입니다.

PostgreSQL

PostgreSQL에선 동기, 비동기 방식을 모두 지원하는데 Streaming Replication (이하 스트리밍 레플리케이션) 이라는 방식을 구현하고 있습니다.

PostgreSQL에서 스트리밍 레플리케이션은 MySQL의 binlog와 마찬가지로 WAL (Write Ahead Logging) 라는 low level 로그를 레플리카에 실시간에 가까운 레이턴시로 전송하여 데이터의 정합성을 가지게됩니다.

스트리밍 레플리케이션은 MySQL의 비동기 레플리케이션과 비슷하지만 조금 다른 성격을 가지고 있습니다. 비동기는 비슷하지만 동기 레플리케이션에서 거의 실시간에 가까운 데이터 동기화가 이루어지지만 단점은 마스터 노드에 과부하가 생긴다는 것입니다.

스트리밍 레플리케이션이라는 이름에 걸맞게 데이터를 스트리밍하듯이 전송하기 때문에 자연스럽게 중앙집중형 네트워크를 구성하게됩니다.

이런 단점 때문에 마스터 노드의 부하를 줄이기 위해 Cascade Replication을 구현하기도 하였습니다.

마치며

이렇게 레플리케이션의 전략에 대해서 알아봤습니다. 기존에 알고있던 내용들에 살점을 더 붙인 느낌이 되어서 좋은 것 같네요. PostgreSQL은 엄청나게 넓은 커뮤니티 규모를 가지고 있어서 PostgreSQL에서 공부할게 엄청나게 많다는게 답답하긴 하네요.

저는 이전에는 RDBMS = MySQL이라는 공식이 있었기 때문에 제가 알고있는 대부분의 지식들은 MySQL에 한정된 지식이라는 사실에 아직 제가 너무 부족하다는 것을 느끼게 되었습니다.

이번 기회에 MySQL과 PostgreSQL의 차이 중에 MVCC를 이어 레플리케이션 전략까지 배워서 정말 뿌듯하고 좋습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://blog.purestorage.com/purely-educational/synchronous-replication-vs-aynchronous-replication/

https://www.cockroachlabs.com/blog/data-loss-prevention-during-outages-you-might-be-losing-data-without-knowing-it/

https://bitnine.tistory.com/576

데이터베이스 Failure Mode

요즘 이 사이트를 보면서 공부하고있는데 정말 좋은 것 같습니다.

https://roadmap.sh/backend

제가 공부했던 내용도 있고 처음 보는 내용도 있는데 처음 본 내용 중에 요즘 공부하고있는 데이터베이스와 관련된 내용으로 골라서 공부해봤습니다.

데이터베이스 Failure Mode

저도 처음엔 Failure Mode..? 이게 뭐지? 하고 공부했었는데요. 별건 아니더라구요. 그냥 데이터베이스에서 일어날 수 있는 문제상황들에 대해서 정리해놓은 것 같습니다.

하나씩 살펴보면서 간단하게 개요만 알아보고 하나씩 깊이있게 공부해보겠습니다.

Read Contention

Contention이라는 단어가 조금 어색해서 찾아보니 "경합" 이라는 뜻이었습니다. 손바닥으로 박수를 짝! 하고 치듯이 동시에 일어나는 상황을 말하는 것 같습니다.

Read Contention은 데이터베이스에 존재하는 데이터에 여러개의 프로세스가 동시에 접근하는 상황을 일컫습니다. 보통 읽기 연산이 많이 요청되는 상황에서 발생하고 만약 발생한다면 응답시간이 길어지고 데이터베이스의 처리량이 감소합니다.

이를 완화하는 방법 중 가장 확실한 방법은 레플리카를 두어서 읽기 연산의 부하를 나눠서 부담하는 것입니다. 레플리카와 같이 쓰이는 것은 캐시 서버를 두어서 읽기 연산의 부담을 캐시서버와 같이 부담하는 방법도 있습니다.

또한, 쿼리의 성능 자체를 좋게 만들어서 데이터베이스의 처리량을 늘리는 방법도 있을 수 있겠습니다. 그리고 많은 곳에서 사용될 것 같지는 않지만 데이터베이스 샤딩을 통해 처리하는 방법도 있더군요.

Write Contention

Write Contention은 Read Contention과 마찬가지인 개념으로 데이터베이스에 존재하는 데이터에 여러 프로세스가 동시에 접근하여 쓰기 연산을 수행하려고 할 때 발생하는 현상입니다.

Read Contention과 마찬가지로 쓰기 수행에 대한 응답 시간이 늘어나고 처리량에 문제가 생길 수 있습니다. 또한, Write Contention은 Read Contention과 다르게 치명적인 문제가 발생할 수 있는데 바로 데드락입니다.

데드락이 발생하면 프로세스가 데이터베이스의 락을 잡고 서로의 락을 기다리게 되어 데이터베이스가 뻗어버릴 수 있습니다. 데드락은 업계에서도 종종 벌어지는 문제인 것 같습니다.

이를 완화하는 방법은 동시성 제어를 최적화하는 방법이 있습니다. 또한, 쓰기 작업을 배치작업을 통해 한번에 처리하면 이를 완화할 수 있습니다.

Contention을 고려할 때 추가적으로 고려해야하는 것이 데이터베이스의 종류 (NoSQL인지 RDBMS인지) 와 격리수준입니다.

높은 격리수준을 채택할수록 데이터간 정합성과 일관성이 보장되지만 이 격리수준을 유지하기위해 높은 Read Contention이 발생할 수 있습니다.

또한, RDBMS인지 NoSQL인지에 따라서 Contention에 대처하는 방법도 다르기 때문에 현재 우리가 사용하는 데이터베이스의 종류에 따라 완화 방법을 올바르게 사용해야할 것 같네요.

Thundering Herd

Read Contention과 Write Contention은 데이터베이스 문제 상황이라고 하기엔 조금 포괄적이라서 쉽게 와닿지 않았지만 개인적으로 Thundering Herd는 진짜 큰일나겠다 싶은 문제상황이었습니다.

Thundering Herd는 데이터베이스의 읽기 연산의 부하를 줄이기위해 캐싱 솔루션을 사용했다면 캐시에 적혀있는 데이터의 만료시간을 주의해야한다는 것입니다.

만약 캐시에 데이터가 만료되었는데 데이터를 요청 (Fetch) 하면 순식간에 엄청난 데이터 요청이 캐시 서버를 거치고 Cache Miss가 나기 때문에 엄청난 트래픽이 데이터베이스에 요청을 보낼 것입니다.

이렇게 되면 순간적으로 (Thundering이라는 말처럼 번개치듯이) 트래픽때문에 스파크가 팍! 하고 튀면서 데이터베이스 서버가 순간 죽어버릴 수 있습니다.

이런 장애상황은 많은 회사에서 캐시 서버가 데이터베이스의 부하를 담당하고 있는 경우 주로 발생하는 문제입니다. 저도 장애회고로 두세번정도 관련 포스팅을 본적이 있을 정도로 굉장히 유서깊은 장애상황인 것 같습니다.

이를 완화하기위한 다양한 방법들이 있는데 가장 마음에 들었던 해결방법 하나만 소개해보고자합니다.

Request Coalescing

Request Coalescing (발음은 [코-얼-레싱] 이라고 발음하더군요... 어려워라) 은 이렇게 Thundering Herd가 의심되는 데이터가 Cache Hit을 할때마다 만료시간을 갱신시켜주는 방법입니다.

Request Coalescing 에 대해 검색해보면 Go lang에서 제공해주는 기능이 있는 것 같습니다. 아마 Spring Boot로 개발한다면 RedisTemplate을 이용해서 일일히 업데이트 시켜주면 될 것 같습니다.

Database Cascade

만약 JPA를 공부해보신 분들이라면 친숙한 단어인 Cascade (발음은 [캐스-케이드] 라고 합니다) 문제입니다. 오잉? Cascade가 문제가 될게 있나? 싶지만 꽤나 큰 문제가 발생할 수 있습니다.

Cascade에 대해 잠깐 짚고 넘어가자면 연관관계로 묶여있는 데이터에서 상위 데이터가 업데이트되면 하위 데이터까지 같이 업데이트 시켜주는 것을 말합니다.

JPA에선 Cascade.ALL이라는 옵션으로 부모자식간 엔티티의 업데이트를 자동으로 해주는 기능이 있죠.

Cascade를 잘못 사용했을 경우 생기는 문제에 대해서 한번 공부해봤습니다.

1. 성능 문제

Cascade를 적용하면 데이터베이스의 부하를 증가시키는 문제가 발생할 수 있습니다. 생각해보면 그렇죠. UPDATE쿼리가 두번 혹은 그이상 날아가는 것이니까요.

2. Lock Contention

Cascade를 설정하면 UPDATE과정에서 exclusive lock을 획득해야하고 (데이터베이스 일관성 때문) 그 과정에서 데드락이 발생할 수 있습니다. 이 문제는 위에서 언급했다시피 꽤나 큰 문제로 번질 수 있습니다.

3. 예상치못한 쿼리

이 문제도 꽤나 심각한데 만약 의도치않은 UPDATE문이 발생하면 이를 디버깅하기가 굉장히 힘듭니다. 일단 의도치않은 쿼리가 날아간 것 부터 굉장히 치명적이지만 문제를 해결하기도 굉장히 까다로워진다는 것이 큰 문제로 번질 수 있습니다.

4. 트랜잭션 관리의 어려움

Cascade를 실행하는 과정에서 트랜잭션의 복잡성이 올라가고 트랜잭션이 쿼리를 실행하는 시간을 확장시켜 트랜잭션을 관리하기 더욱 어려워집니다. 또한, Cascade 도중 문제가 생겨서 롤백을 해야하는 상황에서도 트랜잭션을 관리하기가 굉장히 까다로워질 수 있습니다.

Deadlock

데드락 문제는 데이터베이스의 문제중 1, 2위를 다투지않을까싶을 정도로 굉장히 골치아프면서 자주 등장하는 문제입니다. 데드락에 대한 개념에 대해서는 아래의 링크에 자세히 설명해두었으니 참고해주시면 감사하겠습니다!

https://coding-review.tistory.com/359

간단하게 데드락은 프로세스가 데이터베이스 연산도중 락을 획득하고 서로가 서로의 락을 획득하기위해 대기하고 있는 상황을 말합니다.

처음에 제가 데드락에 대해서 공부할 땐 "뭐야 스프링부트는 스레드별로 트랜잭션이 생성되고 각 트랜잭션은 데이터베이스의 격리수준에 따라서 데이터가 격리되는데 스레드별로 데이터베이스 연산이 진행되는거 아냐? 데드락이 걸릴 일이 있어?" 라고 짧은 생각을 가지고 있었습니다.

하지만 분산 트랜잭션에 대해서 공부하면서 분산 트랜잭션을 적용하면 이 데드락 상황이 굉장히 빈번하게 발생할 수 있겠다는 생각을 했습니다.

재수가 없으면 이런 상황이 발생할 수 있기 때문입니다.

데드락이 발생하면 데이터베이스의 자원이 계속해서 병목현상을 일으키고 이는 데이터베이스 서버의 장애로 이어질 수 있는 큰 문제입니다.

데드락을 해결하기 위해서는 잡혀있는 락을 모두 풀어버리는 쿼리를 만들어서 손수 풀어주거나 (이것도 복잡하면 불가능한 방법입니다.) 데이터베이스 서버를 내렸다가 다시 올리는 방법이 있습니다.

Corruption

Corruption은 데이터베이스에서 일어날 수 있는 상황 중에서 앞서 얘기했던 데이터베이스 연산 중 발생할 수 있는 문제들과 다르게 외부적인 영향에 의해 발생할 수 있는 문제를 말합니다.

하나씩 살펴보도록 하죠.

1. 하드웨어 문제

데이터베이스 서버의 디스크나 메모리에 물리적인 손상이 가해졌을 때 발생할 수 있는 문제입니다.

2. 소프트웨어 문제

소프트웨어라함은 데이터베이스의 엔진 그자체에 버그가 있을 수 있고 (그런 일은 잘 벌어지지 않지 않을까 싶습니다만...) 우리의 애플리케이션에서 발생하는 버그로 인해 데이터베이스에 영향을 끼치는 문제 상황입니다.

3. 파워 문제

데이터베이스 서버에 전력을 공급해주는 파워에 문제가 발생했을 경우 생기는 문제입니다.

4. 휴먼 에러

말그대로 사람이 잘못해서 벌어진 상황이고 관리자 혹은 유저가 데이터베이스의 연산을 데이터베이스가 의도하지 않은 방식으로 실행하다가 발생하는 문제입니다.

5. 악성 코드

바이러스나 멀웨어같은 악성코드에 의해 데이터베이스 파일이 깨지거나 문제가 발생하는 상황을 말합니다. 혹은 해킹으로 직접 데이터베이스 서버에 침투하여 문제가 발생하는 경우도 포함됩니다.

Corruption을 완화하기 위한 방법으로서는 규칙적으로 백업을 하거나 DBMS를 업데이트 하거나, 코드 리뷰로 휴먼 에러를 방지하고, 보안 대책을 세우는 등 다양한 방법으로 데이터베이스 서버를 지켜야합니다.

마치며

이번 포스팅에선 데이터베이스 문제 상황에 대해 정의하고 간략하게 개요정도로 공부해봤습니다. 제가 잘 모르던 용어들도 나와서 흥미롭게 공부했던 것 같습니다.

그리고 공부를 하면 할수록 보는 시야가 넓어져서 공부하는 재미가 있는 요즘입니다. 백엔드 로드맵을 몇번 보면서 어떤 내용으로 포스팅을 해야할지 또 정해야겠네요.

재밌었습니다! 여러분도 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

Redis for Client Side Caching (이론)

이번 포스팅은 Redis 6부터 제공해주는 신기능 (무려 4년전 기술인 따끈따끈한 신기술입니다.) Redis for Client Side Caching에 대해서 공부해봤습니다.

Client Side Caching

사실 이 내용을 공부하게 된 계기가 Client Side Caching을 공부하려고 했다가 영어로 구글링한 탓일까요... 제일 상단에 뜬 Redis 공식문서를 보고 공부하게 되었습니다.

원래 우리가 흔히 말하는 Client Side Caching은 HTTP header인 Cache-Control과 ETag를 말합니다. 즉, 브라우저에 캐싱하는 것을 Client Side Caching이라고 하는 것입니다.

이번 포스팅은 그 내용은 아니기때문에 다루지는 않겠습니다. 브라우저에서 캐싱하는 것은 어렵지 않은 내용이라 구글링해보시면 쉽게 접하실 수 있을 겁니다.

Redis와 Client Side Caching

그럼 여기서 말하는 Client Side는 뭘까요? 바로 Redis Client입니다. 즉, Redis를 사용하는 곳이죠. Redis Server가 Client가 될 수도 있고, 흔히 생각하기로는 Spring Boot와 같이 Redis를 사용하는 애플리케이션이 될 것 같네요.

처음 Redis의 공식문서를 읽어보면 결론은 이겁니다.

"Redis를 사용하는 클라이언트의 로컬에 캐시를 동기화해준다."

그럼 일단 왜? 라는 질문을 하게 되고 어떻게? 라는 질문이 따라왔습니다.

왜?

그냥 Redis에서 요청하면 안되나?

왜 Redis가 클라이언트 (편의상 스프링이라고 하겠습니다.) 의 캐시를 업데이트 해줄까요? 그냥 Redis에 요청하면 안되나요?

공식문서에선 "그렇게 하면 응답속도가 빨라지니까!" 라고 말합니다. 일단 일반적인 Redis에서 요청보낼 때와 얼마나 차이가 나는지는 알려주지 않았습니다. 때문에 일단 넘어가도록 하죠.

응답속도가 빨라지면 좋긴 좋으니까요.

그럼 왜 동기화를 시켜야하지?

로컬 캐시의 좋은 점은 응답속도가 빠르다는 점입니다. 하지만 단점은 Redis Server에서 만약 값이 변경되면 로컬 캐시가 그걸 알아차릴 수 없습니다.

그럼 Redis Server에서 값이 바뀌더라도 로컬 캐시에 오래된 데이터가 남아있어 사용자에게 실제 데이터베이스와 다른 값을 보여주기 때문에 문제가 됩니다.

때문에 로컬 캐시와 Redis의 데이터들을 동기화시켜줘야합니다.

어떻게?

근데 Redis랑 스프링은 서로 떨어져있는데 어떻게 Redis와 스프링의 캐시가 동기화될까요? 공식문서에선 그 대답을 "Invalidate Message" 라고 말합니다.

https://www.linode.com/docs/guides/redis-client-side-caching/

이 포스팅에서 어떻게 Redis로 Client Side Caching을 할 수 있는지 나와있습니다.

직접 해보겠습니다.

우선 도커로 Redis를 띄웁니다. 저는 Docker Desktop을 이용해서 띄웠습니다. docker-compose.yml 파일을 작성해줍니다.

version: '3.8'

services:

redis:

image: redis:6

container_name: redis

ports:

- "6479:6379"

volumes:

- redis-data:/data

restart: unless-stopped

포트를 6379로 안한 이유는 제 컴퓨터에 이미 Redis for Windows 가 있어서 그렇습니다.

그리고 docker를 띄우면?

잘 떴네요. 이제 cli로 접속해봅시다. docker로 띄운 Redis의 cli로 들어가기 위해서는 아래의 명령어를 입력해야합니다.

docker exec -it <컨테이너이름> redis-cli

이걸 서로 다른 명령프롬프트로 들어가줘야합니다.

왼쪽이 데이터를 변경하는쪽 (Master) 오른쪽이 데이터를 읽는쪽 (Slave) 이라고 가정하겠습니다.

오른쪽 (읽는쪽) 에서 아래의 명령어를 입력합니다.

subscribe __redis__:invalidate

그럼 Redis 내부의 Pub/Sub 메세지를 받을 준비를 합니다.

그리고 왼쪽에선 아래의 명령어를 입력합니다.

client tracking on redirect <읽는쪽 client id>

한번 진행해보죠.

test라는 key로 "hello"라는 value를 저장했습니다.

자 그리고 만약 test라는 key의 value를 바꿔주면?

읽는 쪽에서 메세지가 도착했습니다. "test라는 key가 변경되었다." 라는 메세지입니다.

이렇게 Redis의 값이 변경되는 것을 확인할 수 있습니다.

이 방법은 기존의 Redis의 프로토콜인 RESP2를 이용한 방식입니다. Redis 6에서 새롭게 선보인 RESP3는 다른 방식을 사용한다고 합니다. 하지만 본질은 같습니다. Invalidate Message를 이용하는 것이죠.

Redis의 값을 변경해주는 것을 저는 Redis CLI에서 했지만 보통의 경우 Redis Client인 Spring Boot일 가능성이 높습니다. 그래서 이제 스프링과 한번 연계해서 보도록 하겠습니다.

스프링과 캐시

우리의 목적은 스프링에서 저 값을 사용하는 것인데요. 제가 처음에 생각했던 것은 이거였습니다.

아하 Pub/Sub 메세징이니까 스프링쪽에서 저 채널 (__redis__:invalidate) 을 subscribe로 열어두고 저 메세지가 오면 스프링에서 직접 설정해주는 거구나?

그 과정에서 @CachePut @CacheEvict 같은 어노테이션으로 로컬 캐시를 업데이트 하는거구나?

저는 캐시라고 하길래... 그냥 곧이곧대로 캐시인줄 알았죠... 하지만 아니었습니다.

무려... Map 자료구조와 Redis 데이터를 동기화하는 것이었습니다.

처음에 subscribe로 뻘짓을 엄청나게 해댔는데 아래의 링크에서 Redis 개발자로 추측되는 사람이 자바로 예시 코드를 적어줬는데 처음에 보고 물음표를 수백개 띄울 수밖에 없었습니다. 이게 된다고?

https://github.com/redis/lettuce/issues/1281

이게 최신기술이라 사람들이 많이 안써봐서 그런가 예제코드를 찾아보기 힘들었고 겨우겨우 찾았습니다. 다음 포스팅에서 직접 스프링과 연동해서 실습해보겠습니다.

출처

https://redis.io/docs/latest/develop/use/client-side-caching/

https://www.linode.com/docs/guides/redis-client-side-caching/

Redis for Client Side Caching (실습)

전 포스팅에 이어 바로 실습으로 들어가보겠습니다. 전 내용이 궁금하시면 아래의 링크를 참고해주세요!

https://coding-review.tistory.com/524

@Configuration

@EnableRedisHttpSession

@EnableRedisRepositories

@Slf4j

public class RedisConfig {

@Value("${spring.redis.host}")

private String host;

@Value("${spring.redis.port}")

private int port;

@Bean

public RedisConnectionFactory redisConnectionFactory() {

return new LettuceConnectionFactory(host, port);

}

@Bean

public RedisTemplate<String, Object> redisTemplate() {

RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

redisTemplate.setConnectionFactory(redisConnectionFactory());

redisTemplate.setKeySerializer(new StringRedisSerializer());

redisTemplate.setValueSerializer(new StringRedisSerializer());

return redisTemplate;

}

@Bean

public RedisClient redisClient() {

return RedisClient.create(String.format("redis://%s:%d", host, port));

}

@Bean

public StatefulRedisConnection<String, String> connection() {

return redisClient().connect();

}

@Bean

public RedisCommands<String, String> redisCommands() {

return connection().sync();

}

@Bean

public CacheFrontend<String, String> frontend(StatefulRedisConnection<String, ItemDto> connection) {

Map<String, String> clientCache = new ConcurrentHashMap<>();

return ClientSideCaching.enable(CacheAccessor.forMap(clientCache), connection, TrackingArgs.Builder.enabled());

}

@PostConstruct

public void init() {

RedisCommands<String, String> commands = redisCommands();

commands.set("test", "hello world!"));

}

}

우리의 핵심이라고 할 수 있는 RedisConfig.java입니다.

이제 모든 준비는 끝났습니다.

바로 사용해보죠.

@Controller

@RequiredArgsConstructor

@Slf4j

public class ItemController {

private final CacheFrontend<String, String> frontend;

@GetMapping("/get/local")

@ResponseBody

public String getMessage() {

return frontend.get("test");

}

}

정말 너무 간단하게 사용할 수 있지만 말도안되는 기능이지 않을 수가 없습니다.

• Redis에 데이터를 요청하는 네트워크 비용

• Redis와 커넥션 맺는 비용

• Redis에서 하드웨어에 있는 데이터를 읽는 비용

• 다시 스프링에 전달해주는 비용

이 모든게 생략되는 기술입니다.

한번 테스트해보겠습니다.

일단 저희가 생성한 텍스트가 잘 보입니다. 이제 Redis CLI에서 값을 변경해보겠습니다.

진짜로 동기화가 되어서 바뀌는 것을 확인할 수 있습니다.

그런데... 뭔가 아쉽습니다. 우리는 단순 텍스트를 캐싱하진 않거든요. JSON이 캐싱되면 좋겠습니다.

이를 위해서 몇가지 추가적인 작업이 필요합니다.

@Configuration

@EnableRedisHttpSession

@EnableRedisRepositories

@Slf4j

public class RedisConfig {

@Value("${spring.redis.host}")

private String host;

@Value("${spring.redis.port}")

private int port;

@Bean

public RedisConnectionFactory redisConnectionFactory() {

return new LettuceConnectionFactory(host, port);

}

@Bean

public RedisTemplate<String, Object> redisTemplate() {

RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

redisTemplate.setConnectionFactory(redisConnectionFactory());

redisTemplate.setKeySerializer(new StringRedisSerializer());

redisTemplate.setValueSerializer(new StringRedisSerializer());

return redisTemplate;

}

@Bean

public RedisClient redisClient() {

return RedisClient.create(String.format("redis://%s:%d", host, port));

}

@Bean

public StatefulRedisConnection<String, ItemDto> connection() {

return redisClient().connect(new ItemDtoCodec()); // 이부분 중요!

}

@Bean

public RedisCommands<String, ItemDto> redisCommands() {

return connection().sync();

}

@Bean

public CacheFrontend<String, ItemDto> frontend(StatefulRedisConnection<String, ItemDto> connection) {

Map<String, ItemDto> clientCache = new ConcurrentHashMap<>();

return ClientSideCaching.enable(CacheAccessor.forMap(clientCache), connection, TrackingArgs.Builder.enabled());

}

@PostConstruct

public void init() {

RedisCommands<String, ItemDto> commands = redisCommands();

commands.set("test", new ItemDto("itemName", 1000, "localhost:8080", 3L));

}

}

우선 ItemDto를 하나 만들어줍니다. 우리는 이포맷으로 JSON을 내려줄겁니다.

@Data

@AllArgsConstructor

@NoArgsConstructor

public class ItemDto {

private String itemName;

private int price;

private String url;

private Long itemId;

}

그리고 ItemCodec이라는 클래스를 하나 만들어줍니다.

public class ItemDtoCodec implements RedisCodec<String, ItemDto> {

private final StringCodec stringCodec = new StringCodec();

@Override

public String decodeKey(ByteBuffer bytes) {

return stringCodec.decodeKey(bytes);

}

@Override

public ItemDto decodeValue(ByteBuffer bytes) {

String decodedString = StandardCharsets.UTF_8.decode(bytes).toString();

String[] parts = decodedString.split(",");

return new ItemDto(parts[0], Integer.parseInt(parts[1]), parts[2], Long.parseLong(parts[3]));

}

@Override

public ByteBuffer encodeKey(String key) {

return stringCodec.encodeKey(key);

}

@Override

public ByteBuffer encodeValue(ItemDto value) {

String encodedString = String.join(",",

value.getItemName(),

String.valueOf(value.getPrice()),

value.getUrl(),

String.valueOf(value.getItemId()));

return StandardCharsets.UTF_8.encode(encodedString);

}

}

이러면 작업 끝입니다.

그리고 컨트롤러를 조금 손봐줄겁니다.

@Controller

@RequiredArgsConstructor

@Slf4j

public class ItemController {

private final CacheFrontend<String, ItemDto> frontend;

private final RedisCommands<String, ItemDto> redisCommands;

@GetMapping("/set/item")

@ResponseBody

public String setItem(@RequestParam("itemName") String itemName) {

redisCommands.set("test", new ItemDto(itemName, 1000, "localhost:8080", 10L));

return "ok";

}

@GetMapping("/get/local")

@ResponseBody

public ItemDto getMessage() {

return frontend.get("test");

}

}

redis command로 우리의 키값인 test의 값을 변경해줄겁니다. 우선 복잡도를 최대한 낮추기위해 itemName만 바꿔보겠습니다.

이제 다시 조회해보면?

와우... JSON이 캐싱된다는건 엄청나게 이점이 될 것 같습니다.

그럼... 다시 등장해주세요!

공식문서에서 발췌한 very small latency를 확인해보겠습니다.

사실 우리의 목적은 Redis에서 요청한 데이터와 Client Side Caching 의 성능차이이지만 극단적인 상황을 위해 RDB 100만행을 준비했습니다.

이게 테스트를 해보니까 맨처음 요청은 다음 요청에 비해 꽤 높게 응답속도가 나오더라구요. 두번 눌렀을 때 데이터를 내부적으로 가지고있나봅니다. 그래서 여러번 눌러서 가장 작게 나온 값으로 측정했습니다.

1번타자 RDB 100만행!

2번타자 Redis에 직접 요청!

3번타자 Redis for Client Side Caching!

처음에 Redis에 직접요청을 테스트해봤는데 4ms가 찍히는걸 보고 Redis가 괜히 캐시로 쓰는게 아니구나... 싶었습니다. 그리고 Client Side Caching을 테스트하기 전에 과연 얼마나 차이가 날까... 두근두근 하고 눌러봤는데?

2ms!!!

어디서 들은건데 우리가 키보드를 눌렀을 때 입력된 키가 OS를 거쳐 CPU에서 작업을 하고 다시 우리 화면에 보여지기까지 걸리는 시간이 대충 2ms라고 합니다.

진짜 미쳤다고밖에 할 말이 없네요. 하지만 그럴만합니다. 왜냐하면 Map에 있는 데이터를 가져오는건데 심지어 HashMap이라 Hashing 알고리즘으로 시간 복잡도가 O(1)이니까요.

마치며

Client Caching이 저를 여기로 이끌었지만 정말 엄청난 기술이라고밖에 할 말이 없네요. 제가 개발공부를 해온게 만으로 3년이 다됐는데 그전엔 계속 이전에 있던 기술들을 공부하곤 했는데 오늘 처음으로 신기술을 적용해봤네요. 트랜드를 따라가는 느낌을 들었습니다.

물론 4년도 그리 짧은 시간은 아니지만 구글링 래퍼런스로 미루어봤을 때 사람들이 많이 사용하진 않은 것 같습니다. 오늘도 정말 뿌듯하네요!

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

Redis Sentinel 도커 배포하기

대기열 만들기를 진행한게 벌써 10일 전이네요... Redis Sentinel 정말 골치아픈 녀석이었습니다.

이번 포스팅에선 Redis Sentinel을 도커로 배포하면서 삽질했던 부분들을 정리하고자 글을 쓰게 되었습니다.

한번 시작해보도록 하겠습니다.

Redis Sentinel

Redis Sentinel은 Redis에서 제공해주는 HA (High Availability) 전략 중 하나입니다. Sentinel이라는 Redis 서버를 모니터링 하는 Redis Client를 두고 지속적으로 모니터링 하면서 Redis의 상태를 확인하고 장애에 대응하는 기능들을 제공합니다.

Redis Sentinel에 대해서는 이번 포스팅에선 자세히 다루지 않도록 하겠습니다. 배포하는데 집중해보려고합니다. 자세한 내용은 아래의 링크에 설명이 되어있습니다.

https://coding-review.tistory.com/472

주의사항

제가 실습한 환경은 실제 상황이랑 많이 다른 환경입니다. 왜냐하면 Redis 서버와 Sentinel을 같은 서버에 둘 예정이기 때문이죠.

때문에 제가 배포한 방법은 실제 환경에서는 딱히 중요하지 않을 수도 있습니다. 하지만 저는 클라우드 비용을 아끼기 위해 이 방법을 택했고 실전에선 배포 자체는 조금 더 수월할 것으로 예상됩니다. (하지만 관리가 힘들어지겠죠?)

위의 링크에도 자세히 설명되어 있지만 도커는 포트포워딩을 사용하기 때문에 Sentinel간 통신이 어려워 도커를 사용하기 위해서는 announce-ip과 announce-port 라는 설정을 해주어야합니다.

하지만 저는 Redis 서버와 Redis Sentinel을 모두 같은 도커 네트워크로 묶어버렸기 때문에 넘어가는 부분이 몇개 있을 수 있습니다. 이 부분은 나중에 따로 포스팅을 하도록 하겠습니다.

Sentinel 배포

먼저 docker-compose.yml 파일을 만들어줍니다.

$ vim docker-compose.yml

version: '3.8'

services:

redis-master:

image: redis:latest

command: redis-server

container_name: "redis-master"

networks:

- redis-net

redis-slave-1:

image: redis:latest

command: redis-server --slaveof redis-master 6379

links:

- redis-master

container_name: "redis-slave1"

networks:

- redis-net

redis-slave-2:

image: redis:latest

command: redis-server --slaveof redis-master 6379

links:

- redis-master

container_name: "redis-slave2"

networks:

- redis-net

sentinel-1:

build: sentinel

ports:

- "5000:26379"

env_file:

- .env

depends_on:

- redis-master

- redis-slave-1

- redis-slave-2

container_name: "sentinel1"

networks:

- redis-net

sentinel-2:

build: sentinel

ports:

- "5001:26379"

env_file:

- .env

depends_on:

- redis-master

- redis-slave-1

- redis-slave-2

container_name: "sentinel2"

networks:

- redis-net

sentinel-3:

build: sentinel

ports:

- "5002:26379"

env_file:

- .env

depends_on:

- redis-master

- redis-slave-1

- redis-slave-2

container_name: "sentinel3"

networks:

- redis-net

networks:

redis-net:

driver: bridge

뭐가 굉장히 많은데 주요한 부분만 보면 다음과 같습니다.

• Redis 컨테이너는 redis-server 명령어를 입력해야합니다. 때문에 command 설정을 통해 Redis를 구동했습니다.

• --slaveof redis-master 6379 에서 redis-master는 마스터 노드의 컨테이너 이름입니다. 6379는 밖에 노출된 포트가 아닌 실제 구동되는 포트를 사용해야합니다.

도커 네트워크에서 bridge는 컨테이너들을 하나의 네트워크로 감싸는 역할을 해주고 이를 통해 컨테이너 이름을 DNS처럼 사용할 수 있습니다.

• links 설정을 통해 slave 노드와 master 노드를 연결시켜줘야합니다.

• Sentinel은 sentinel이라는 폴더에 들어있는 Dockerfile을 이용해서 만들어줄 것이기 때문에 build 명령어를 사용했습니다.

• 환경설정 파일은 후에 작성할 것이고 .env 파일을 기반으로 환경설정을 할 것이라는 것을 env_file 명령어를 통해 알려줘야합니다.

• depends_on 명령어로 Sentinel이 어떤 Redis 서버를 모니터링할지 명시해줘야합니다.

이제 순서대로 만들어보겠습니다.

$ touch .env

$ vi .env

-- .env 파일 내부 --

SENTINEL_DOWN_AFTER=5000

SENTINEL_FAILOVER=500

SENTINEL_QUORUM=2

sentinel이라는 폴더를 만들고 안에 Dockerfile, sentinel.conf, entrypoint 파일을 만들어줍니다.

$ mkdir sentinel

$ cd sentinel

$ touch Dockerfile

$ touch sentinel-entrypoint.sh

$ touch sentinel.conf

Dockerfile

FROM redis:latest

EXPOSE 26379

ADD sentinel.conf /etc/redis/sentinel.conf

RUN mkdir -p /var/lib/redis && \

chmod 777 /var/lib/redis && \

chown redis:redis /etc/redis/sentinel.conf

COPY sentinel-entrypoint.sh /usr/local/bin/

RUN chmod +x /usr/local/bin/sentinel-entrypoint.sh

ENTRYPOINT ["sentinel-entrypoint.sh"]

Dockerfile에선 Sentinel이 어떻게 빌드될 것인지를 결정하는데 Sentinel이 실행되기 위해 필요한 sentinel.conf를 이동시키고 권한을 부여해주고 entrypoint 파일을 실행시키는 것이 목표입니다.

ENTRYPOINT 명령어를 이용해 Sentinel이 빌드되고 컨테이너가 동작한 다음 entrypoint 파일이 실행되는 것을 반드시 보장해줍니다.

sentinel-entrypoint.sh

#!/bin/sh

sed -i "s/\$SENTINEL_QUORUM/$SENTINEL_QUORUM/g" /etc/redis/sentinel.conf

sed -i "s/\$SENTINEL_DOWN_AFTER/$SENTINEL_DOWN_AFTER/g" /etc/redis/sentinel.conf

sed -i "s/\$SENTINEL_FAILOVER/$SENTINEL_FAILOVER/g" /etc/redis/sentinel.conf

exec docker-entrypoint.sh redis-server /etc/redis/sentinel.conf --sentinel

entrypoint 파일에선 Sentinel의 설정을 적용하고 실행하는 역할을 합니다.

sentinel.conf

# Example sentinel.conf can be downloaded from http://download.redis.io/redis-stable/sentinel.conf

port 26379

dir /tmp

sentinel resolve-hostnames yes

sentinel monitor mymaster redis-master 6379 $SENTINEL_QUORUM

sentinel down-after-milliseconds mymaster $SENTINEL_DOWN_AFTER

sentinel parallel-syncs mymaster 1

sentinel failover-timeout mymaster $SENTINEL_FAILOVER

bind 0.0.0.0

굉장히 중요한 부분이 바로 sentinel resolve-hostnames yes 이부분입니다. 이게 없으면 그 다음 행에서 실행되는 sentinel monitor 명령어에서 redis-master를 찾을 수 없습니다. 여기서 redis-master는 마스터 노드의 컨테이너 이름입니다.

만약 redis-master를 찾을 수 없다면 마스터 노드와 레플리카가 서로 연동되지 않는 상황이 벌어져 절대 우리가 원하는 상황이 만들어지지 않습니다.

이 설정때문에 며칠을 소모했는지 모르겠네요.. ㅜㅜ

아래의 링크에 자세한 설명이 나와있습니다.

https://stackoverflow.com/questions/57464443/redis-sentinel-throws-error-cant-resolve-master-instance-hostname

모든 준비가 끝났습니다. docker-compose.yml을 실행해주고 이제 확인단계입니다.

꼭 확인해야 하는 세가지!

일단 모든 컨테이너가 떠있는건 당연하구요

1. 마스터 노드에 replication 설정이 잘 되어있는지 확인한다.

$ docker exec -it redis-master redis-cli -p 6379

$ info replication

이렇게 슬레이브 노드가 등록되었는지 확인이 필요합니다.

2. Sentinel에 마스터 노드와 슬레이브 노드, 그리고 다른 Sentinel들이 잘 설정되었는지 확인한다.

$ docker exec -it sentinel1 redis-cli -p 26379

$ sentinel masters

Sentinel 내부에서 마스터가 잘 등록되어있는지 슬레이브 노드가 잘 등록되어있는지, 다른 Sentinel이 등록되어있는지 확인합니다.

3. 슬레이브 노드의 로그에서 마스터와 연결이 잘 되었는지 메세지를 확인하기

$ docker container logs redis-slave1

검증

실제로 동작하는지 확인해보겠습니다.

우선 마스터 노드를 죽이고 Sentinel 내부 로그를 확인해보겠습니다.

이런식으로 투표 과정을 거치는 것을 볼 수 있습니다.

여기서 sdown은 subjectively down 즉, Sentinel이 주관적으로 마스터 노드가 다운되었다고 인지하고 마크를 남기는 것입니다. 이 sdown은 Sentinel이 마스터 노드에 ping을 보내고 pong이 오지 않으면 결정합니다.

또한, odown은 objectively down 즉, Sentinel이 객관적으로 봤을 때 마스터 노드가 죽었다고 판단한 경우입니다.

이 odown이 마킹되면 Sentinel이 Pub/Sub 메세징을 통해 투표를 진행하고 새로운 마스터를 선정합니다.

마치며

정말 오랫동안 골치를 썩었던 Redis Sentinel이 드디어 끝났습니다. Sentinel이 매력적이긴 하지만 정말 골치아프긴 하네요... 하지만 좋은 경험이 되었습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

Redis Sentinel 장애대응 Notification 구축하기

https://coding-review.tistory.com/533

이 포스팅에 대부분의 세팅이 담겨있습니다! 위의 링크를 참고해주세요.

이번 배포에서 달라진 것이라면 슬랙과 연동하는 것입니다. Sentinel에선 모니터링과 더불어 알람을 제공해줍니다. 특정 상황이 되면 쉘 스크립트 파일을 실행하는 것이죠.

바로 시작해보죠!

#!/bin/bash

EVENT_TYPE=$1

MASTER_NAME=$2

MASTER_IP=$3

MASTER_PORT=$4

echo "alert!!!"

if [ "$EVENT_TYPE" == "+sdown" ]; then

curl -X POST -H 'Content-type: application/json' --data '{"text":"redis master node is subjectively down"}' https://hooks.slack.com/services/xxxxxxxxx/ooooooooooooo/uuuuuuuuuuuu

elif [ "$EVENT_TYPE" == "+odown" ]; then

curl -X POST -H 'Content-type: application/json' --data '{"text":"redis master node is objectively down"}' https://hooks.slack.com/services/xxxxxxxxx/ooooooooooooo/uuuuuuuuuuuu

elif [ "$EVENT_TYPE" == "+switch-master" ]; then

curl -X POST -H 'Content-type: application/json' --data '{"text":"new redis master elect other slave"}' https://hooks.slack.com/services/xxxxxxxxx/ooooooooooooo/uuuuuuuuuuuu

fi

일단 sentinel 폴더에 alert.sh를 만들어줍니다.

version: '3.8'

services:

was:

image: was

container_name: "was"

ports:

- "8080:8080"

networks:

- redis-net

redis-master:

image: redis:latest

command: redis-server

container_name: "redis-master"

networks:

- redis-net

redis-slave-1:

image: redis:latest

command: redis-server --slaveof redis-master 6379

links:

- redis-master

container_name: "redis-slave1"

networks:

- redis-net

redis-slave-2:

image: redis:latest

command: redis-server --slaveof redis-master 6379

links:

- redis-master

container_name: "redis-slave2"

networks:

- redis-net

sentinel-1:

build: sentinel

ports:

- "5000:26379"

#image: sentinel

env_file:

- .env

depends_on:

- redis-master

- redis-slave-1

- redis-slave-2

container_name: "sentinel1"

volumes:

- "./sentinel/alert.sh:/etc/redis/alert.sh"

networks:

- redis-net

sentinel-2:

build: sentinel

ports:

- "5001:26379"

#image: sentinel

env_file:

- .env

depends_on:

- redis-master

- redis-slave-1

- redis-slave-2

container_name: "sentinel2"

volumes:

- "./sentinel/alert.sh:/etc/redis/alert.sh"

networks:

- redis-net

sentinel-3:

build: sentinel

ports:

- "5002:26379"

#image: sentinel

env_file:

- .env

depends_on:

- redis-master

- redis-slave-1

- redis-slave-2

container_name: "sentinel3"

volumes:

- "./sentinel/alert.sh:/etc/redis/alert.sh"

networks:

- redis-net

networks:

redis-net:

driver: bridge

ipam:

config:

- subnet: 172.20.0.0/16

docker-compose.yml 에 sentinel 컨테이너에 volumes로 sentinel 내부에 쉘스크립트 파일을 위치시켜줍니다.

그리고 Dockerfile을 수정해줍니다.

FROM redis:latest

EXPOSE 26379

ADD sentinel.conf /etc/redis/sentinel.conf

RUN apt-get update && apt-get install -y curl

RUN mkdir -p /var/lib/redis && \

chmod 777 /var/lib/redis && \

chown redis:redis /etc/redis/sentinel.conf

COPY sentinel-entrypoint.sh /usr/local/bin/

RUN chmod +x /usr/local/bin/sentinel-entrypoint.sh

ENTRYPOINT ["sentinel-entrypoint.sh"]

추가된건 apt-get update와 curl을 설치하는 부분입니다. 슬랙으로 보내는 부분이 curl로 json을 요청하기 때문이죠.

# Example sentinel.conf can be downloaded from http://download.redis.io/redis-stable/sentinel.conf

port 26379

dir /tmp

sentinel resolve-hostnames yes

sentinel notification-script mymaster /etc/redis/alert.sh

sentinel monitor mymaster redis-master 6379 $SENTINEL_QUORUM

sentinel down-after-milliseconds mymaster $SENTINEL_DOWN_AFTER

sentinel parallel-syncs mymaster 1

sentinel failover-timeout mymaster $SENTINEL_FAILOVER

bind 0.0.0.0

그리고 sentinel.conf에 sentinel notification-script 부분을 추가해줍니다.

그럼 이제 준비 끝!

이제 슬랙을 설치해줍니다.

https://slack.com/intl/ko-kr/downloads/windows

그리고 채널을 만들어줍니다.

저는 server_notification으로 만들었습니다.

쭉쭉 진행해주시면 됩니다.

https://api.slack.com/messaging/webhooks

그리고 위의 링크에서 슬랙 웹훅을 설정해줘야합니다.

안에 들어가시면 create a slack app 이라는 버튼이 있습니다. 그걸 눌러주시면?

워크 스페이스는 아까 슬랙에서 만든걸 선택해주면 됩니다.

그리고 Incoming Webhooks를 선택해줍니다.

Activate Incoming Webhooks를 On으로 바꿔주시면 아래와 같은 창이 나옵니다.

이제 이렇게 나온 화면에서 Copy를 누르면 링크가 복사됩니다.

그걸 alert.sh에 적어주면 됩니다.

그리고 마스터 노드를 죽여보면?

위의 사진은 sdown인 상태입니다.

이렇게 알람이 잘 오는 것을 확인할 수 있습니다.

마치며

알람이랑 연결한 것은 또 처음이네요. 정말 재밌는 프로젝트였습니다. 이제 진짜 개발자같이 보이긴 하네요! 뿌듯하게 마무리 지으면서 포스팅도 마무리 지어보겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

데이터베이스 조인 알고리즘과 쿼리 최적화

이번 포스팅은 데이터베이스의 조인 알고리즘과 쿼리를 최적화하는 방법에 대해서 공부해보고 정리해보겠습니다.

데이터베이스를 깊이있게 공부하면 공부할수록 왜 큰 기업들이 DBA라는 직군을 따로 뽑는지 알 수 있게 되는 대목이었습니다. 정말 데이터베이스 하나만으로도 공부해야될게 엄청나게 많네요.

그럼 시작해보겠습니다!

데이터베이스 조인 알고리즘

우리가 RDBMS에서 쿼리를 작성하면 우리가 원하는 데이터를 가져오기위해 조인 연산을 진행하게됩니다. 이 조인연산을 최적화하기위해 데이터베이스 내부에선 옵티마이저라는 엔진이 돌아갑니다.

옵티마이저는 데이터베이스의 상태를 파악하고 어떤 조인 알고리즘을 사용하면 좋을지 어떤 인덱스를 사용하면 좋을지 자체적으로 판단하여 조인연산을 최적화합니다.

보통 실행계획을 실행해보면 옵티마이저가 어떤 알고리즘을 사용했는지 알 수 있습니다.

이 쿼리에선 Nested Loop Join을 사용했네요.

이렇게 옵티마이저는 그때그때 최적화를 하는데 어떻게 옵티마이저를 잘 구워삶아먹을지에 대해서 한번 알아보겠습니다.

먼저 그 전에 조인 알고리즘이 어떤 것이 있는지 주요 알고리즘 세개를 공부했습니다.

Nested Loop Join

조인 연산을 바깥에서 안으로 루프를 돌면서 실행하는 조인 연산입니다. 밖에서 안으로 진행하며 기준이되는 테이블을 가장 밖으로 두고 안으로 진행합니다.

위의 예시로 들면 EMP 테이블을 기준으로 DEPT 테이블을 조인연산을 진행한 것에대한 예시입니다.

옵티마이저가 Nested Loop Join를 사용하기로 결정했다면 옵타마이저는 상대적으로 크기가 작은 테이블을 드라이빙 테이블 (기준이 되는 테이블) 으로 설정합니다. 이유는 기준이 되는 테이블은 풀스캔을 해야하는데 크기가 작을수록 효율이 좋기 때문입니다.

보통 Nested Loop Join는 부분부분 조금씩 데이터를 보여줘야하는 상황 (포털이나 이커머스) 에 효율적인 조인 알고리즘이며 대부분 Nested Loop Join을 사용하게 됩니다.

왜냐하면 보통 웹 애플리케이션들이 부분부분 조금씩 데이터를 보여주고 특정한 상황에서 추가적으로 데이터를 가져오니까요.

Nested Loop Join은 조인하는 테이블의 크기가 상대적으로 차이가 많이 나는 경우에 효율적입니다.

Hash Join

Hash Join은 데이터를 초기에 풀스캔해서 해시테이블을 만들고 이 해시값을 이용해서 데이터셋을 만드는 알고리즘이 추가됩니다. 때문에 테이블을 풀스캔하고 해시 테이블을 만드는 초기비용이 존재합니다.

Hash Join은 해싱 알고리즘을 사용하기 때문에 성능상 굉장히 유리합니다. 특히 등호 ( = ) 연산을 사용할 때 특히 성능상 좋습니다.

Hash Join은 테이블의 크기가 서로 비슷하거나 테이블의 크기가 서로 큰 상황에서 유리한 모습을 보여줍니다.

또한, 대량의 데이터를 통계하거나 복잡한 쿼리에서 성능상 유리합니다.

Sort Merge Join

Sort Merge Join은 잘 사용하지 않는 알고리즘으로 테이블을 서로 정렬하고 나서 데이터셋을 만든다는 특징이 있습니다. 하지만 이때 정렬하는 초기비용이 상당히 크기 때문에 잘 안쓰인다고 하네요.

옵티마이저의 조인 알고리즘 선택 조건

옵티마이저는 테이블의 통계 쿼리를 이용해서 테이블의 크기, 인덱스의 유무, 데이터의 타입등을 보고 그것에 알맞는 조인 알고리즘을 사용합니다.

이 과정에서 옵티마이저는 개발자가 의도하지 않은 조인 알고리즘을 사용할 수 있고, 인덱스를 설정했음에도 의도하지않게 인덱스를 사용하지 않을 수도 있습니다.

이를 해결하기위해 옵티마이저 힌트라는 것을 사용할 수도 있습니다. 힌트는 개발자가 직접 쿼리문에 어떤 조인 알고리즘을 사용해라, 어떤 인덱스를 사용해라와 같은 명령을 강제로 주입하는 방법입니다.

하지만 이 방법은 옵티마이저의 유연성을 제한하고, 힌트를 유지하기위한 리소스가 들어가며, 만약 인덱스의 변경으로 잘못된 알고리즘 혹은 잘못된 인덱스를 사용할 수도 있으므로 굉장히 주의해서 사용해야합니다.

이런 경우 옵티마이저가 왜 의도하지 않은 알고리즘과 인덱스를 사용했는지 파악하고 이를 해결하는 것이 더 건설적인 방향입니다.

데이터베이스와 인덱스

보통 데이터베이스도 하나의 서버 위에서 동작하고 이 서버에서 CPU는 데이터를 가져오기위해 디스크에 접근하고 I/O 연산이 일어납니다.

디스크가 데이터에 접근하는 방법은 크게 두 가지가 있는데 Sequential Access와 Random Access가 바로 그것입니다.

Sequential Access는 문자 그대로 순서대로 접근하는 방법이고 Random Access는 필요한 부분만 콕 찝어서 접근하는 방법입니다. 당연히 Random Access가 Sequential Access보다 월등히 빠르기 때문에 Rnadom Access를 지향해야하는데 보통 인덱스가 그 트리거 역할을 합니다.

하지만 인덱스를 잘못 설정하면 설정안하느니만 못하게 되는데요. 어떤 경우 인덱스를 설정하는 것이 좋을까요?

인덱스 선택도

알고리즘 중 Nested Loop Join과 Hash Join이 많이 사용되는데 Nested Loop Join에서 드라이빙 테이블은 어쩔 수 없이 풀스캔을 진행하지만 뒤의 내부 루프는 인덱스를 사용하면 성능을 높일 수 있습니다.

인덱스의 선택도는 카디널리티를 기반으로 계산됩니다. 카디널리티란 인덱스에서 얼마나 같은 값이 중복되는지에 대한 개수를 의미합니다.

예를 들어서 "부서"라는 컬럼으로 조인을 하는 경우 회사에 부서가 많지 않아 "고용"테이블 입장에서 중복되는 값이 많을 것입니다. 이런 경우 카디널리티가 작다고 표현합니다. 만약 "급여" 컬럼으로 조인하는 경우 급여는 중복되는 값이 적으니 이런 경우 카디널리티가 크다고 표현합니다.

즉, 이렇듯 인덱스 선택도는 카디널리티를 기반으로 카디널리티가 작은 경우 선택도가 높아지는 결과를 초래하고 높은 경우 낮아지는 결과를 초래합니다. 반비례 관계라는 것이죠.

인덱스의 선택도 공식은 다음과 같습니다.

특정 데이터 건수 / 전체 데이터 건수

즉, 만약 부서가 10개이고 사원 수가 100명이라면 10 / 100 으로 0.1이 됩니다. 보통 인덱스의 선택도가 10퍼센트에서 15퍼센트 (0.1 ~ 0.15)가 되면 인덱스를 설정하는 것이 효율적이라고 합니다.

왜 인덱스를 설정하면 성능이 좋아질까?

인덱스는 설정을 하게 되면 컬럼이 내부적으로 정렬이 됩니다. 이렇게 정렬된 값들은 자료구조에 저장하게 되는데 보통 RDBMS에서 B- 트리 구조로 저장합니다.

B- 트리는 각 노드에 여러개의 값이 들어갈 수 있어 일반적인 이진 트리에 비해 검색 성능이 더 좋습니다.

특히 MySQL의 InnoDB스토리지 엔진은 이보다 더 최적화된 B+ 트리 구조를 저장합니다. B+ 트리는 모든 값이 트리의 종단 노드 (Leaf Node)에 위치하고 있고 마지막 데이터가 그 다음 데이터와 논리적으로 연결되어있어 B- 트리보다 더 높은 검색 성능을 보여줍니다.

하지만 B+ 트리의 경우 모든 데이터들이 리프노드에 몰려있어 데이터의 삽입이나 삭제연산을 수행할 때 B- 트리보다 더 제한적인 성능을 가질 수밖에 없습니다. 즉, B+ 트리는 읽기 연산에 최적화된 자료구조라고할 수 있습니다.

마치며

데이터베이스를 공부하면 공부할수록 참 어려운 것 같네요... 좀 깊은 내용을 보려고 하면 이해하는데 참 오랜 시간이 걸리게 되는 것 같네요.

공부할수록 제가 더 작아지는 기분이네요... 공부해서 새로운 지식을 하나 얻었는데 그리 좋은 기분은 아니었습니다.

아무튼 오늘도 긴 글 읽어주셔서 감사합니다. 즐거운 하루 되세요!

데이터베이스 조인 연산은 성능에 영향을 줄까? 안줄까?

포스팅을 작성하게 된 계기는 해당 포스팅을 보고 나서입니다.

https://blog.naver.com/shino1025/223124936920

사실 6개월전에 봤던 게시글인데 오랜만에 북마크 정리하다가 보게 됐습니다.

6개월전에 볼 때와 크게 다르지 않을 것이라고 예상했던 것과 다르게 꽤 많이 다르게 보였습니다.

이전에 봤을 땐 그저 읽고 이해하기만 했는데 이젠 다른 생각이 들기 시작했습니다.

조인 연산, 성능에 영향을 줄까? 안줄까?

일단 직관상 영향을 줄 것처럼 생겼습니다. 테이블을 하나만 조회하는 것과 두개, 세개를 조합해서 조회하는 것은 단순히 하나의 테이블에서 조회하는 것 이외에 테이블을 엮는 과정이 추가적으로 필요하기 때문이죠.

하지만 이에 대해 찾아보면 많은 사람들이 "조인 연산은 성능에 영향을 주지 않는다" 라고 말합니다. 이 답에 대해 느려지는가? 느려지지 않는가?에 대한 고찰을 한 것이 위의 포스팅입니다.

위의 포스팅은 제 생각과 반대되는 글이지만 정말 잘 쓰여진 글입니다. 한번 읽어보시는 것을 추천드립니다.

간단하게 정리하자면 "수많은 사례에서 조인 연산을 없애는 비정규화 (Denormalization) 로 성능을 최적화 해왔다." 라는 논리를 사용하고 있습니다.

하지만 해당 포스팅에서 가져온 사례는 속된말로 미친듯한 데이터가 존재하는 트위터, 배달의민족 같은 서비스라는 것입니다. 이에 대한 상수가 있고 이 부분은 감안하고 보셔야될 것 같습니다. 또한, 이는 뒤에서 설명하겠지만 두 서비스 모두 NoSQL을 사용한다는 것을 염두해둬야합니다.

왜 제가 "조인 연산은 느려지지 않는다" 입장인지 제 논리를 펼쳐보도록 하겠습니다. 그러면서 어떤 부분이 직관적으로 느려질 것 같은지에 대해서 짚으면서 진행하겠습니다.

테이블 두개를 합치는 과정

테이블 두개를 합칠 때 어떤 과정이 내부적으로 일어나는지 알아야합니다.

우선 RDBMS에는 Optimizer (이하 옵티마이저) 라는 놈이 있습니다. 이놈은 테이블을 합칠 때 어떤 연산이 가장 적합한지 계산해내는 놈입니다.

일반적으로 테이블을 합치는 가장 합리적인 연산은 세가지로 추려낼 수 있고 이에 대해서는 아래의 링크에 자세하게 설명되어있습니다.

https://coding-review.tistory.com/537

옵티마이저가 테이블을 조인할 때 계산해야 하는 경우의 수는 팩토리얼연산입니다. 즉, 테이블이 3개 합치면 3! = 6 개의 경우의 수를 계산해야 한다는 것이죠.

이게 많아지면 오버헤드가 생길 수 있겠죠. 하지만 팩토리얼 연산이 아무리 위협적이라고 하더라도 현대의 컴퓨터는 1초에 수십억개의 연산을 처리하는 기계입니다.

또한, 옵티마이저는 모든 경우의 수를 계산하는 것이 아니라 허수는 제외하는 최적화 알고리즘을 가지고 있습니다.

비정규화로 읽기 성능 최적화

상당히 오해하기 좋은 워딩입니다. 처음엔 데이터 셋이 크고 읽기 성능을 최적화하고 싶으면 비정규화를 고민해봄직하다는 인상을 줬습니다. 하지만 그건 맞지않습니다.

일단 정규화에 비해 INSERT, UPDATE, DELETE에 대한 연산이 기하급수적으로 늘어나 해당 연산이 필요한 테이블은 쓸 수조차 없습니다.

특히, 비정규화를 고민할만큼 데이터 셋이 정말 큰 상황에서 앞선 세개의 연산이 필요하다면 절대 사용할 수 없을 것입니다.

또한, 비정규화는 조인에 대한 결과가 단순하거나 예측 가능한 테이블만 사용할 수 있습니다. 왜냐하면 비정규화는 그 특성상 데이터의 개수가 N x M이 되기 때문에 테이블을 비대하게 만들기 때문입니다.

그리고 위의 포스팅에서 언급한 배달의민족에서 비정규화로 읽기 성능 최적화의 부분은 MongoDB를 사용한 것으로 나옵니다. 관계라는 것이 정해져 있지 않은 NoSQL에서 JOIN을 쓰는 것은 당연히 RDBMS에 비해 비싼 것이 당연합니다.

배달의민족뿐만 아니라 트위터도 제가 알기로는 NoSQL을 사용한다고 알고 있습니다. 트위터같은 SNS는 데이터의 일관성이 크게 중요하지 않기 때문에 ACID대신 BASE속성을 따르는 것이 압도적으로 유리하기 때문입니다.

때문에, 위의 포스팅에서도 언급했듯이 MongoDB 진영에선 조인을 지양하라고 나오지만 RDBMS 진영에선 그런 얘기가 일절 없는 것을 알 수 있죠.

NoSQL의 조인과 RDBMS의 조인을 동일선상에 놓고 조인에 아무런 오버헤드도 없는건 이상하다! 라고 주장하기엔 조금 설득력이 부족합니다.

결국, "많은 기업이 조인연산을 이유로 비정규화를 이용해 읽기 성능을 최적화했다." 라는 것은 조금 성급한 일반화가 아닌가 생각이 드네요.

그럼 조인은 성능에 영향이 없냐?

마냥 그렇다고 답변하기도 좀 그럴 것 같습니다. 왜냐하면 LEFT JOIN은 확실히 성능에 영향을 줍니다. 아마 LEFT JOIN을 많이 사용하기 때문에 조인이 성능에 영향을 준다고 잘못 알려진 것이 아닐까 싶습니다.

때문에 RDBMS 진영에선 LEFT JOIN을 최적화하는 방법에 대해서 소개하고도 있죠.

https://dev.mysql.com/doc/refman/8.4/en/outer-join-optimization.html

왼쪽 테이블의 값을 기준으로 계속해서 값이 늘어나면 (가져오는 데이터 셋이 커지면) 성능에 악영향을 주는 것은 많이 알려진 사실입니다.

제가 기존에 알고있던 비정규화는 굉장히 제한적으로 사용할 수 있다는 것과 옵티마이저가 최적화된 조인 연산을 수행해준다는 것과 별개로 조인 성능에 대한 구체적인 수치가 있을까 싶어서 논문을 뒤져봤습니다.

해당 논문(책)에 잘 나와있습니다. 혹시나 읽으실 분들을 위해 쪽수를 알려드리자면 Chapter 3 Performance and Scalability 부분입니다.

때문에 LEFT JOIN에 대해서는 확실히 조인을 수행하면 속도가 느려지는 것은 사실이지만 이것이 조인 때문이 아니고 LEFT JOIN의 특성상 데이터가 계속 배수로 늘어나는 특징 때문에 데이터 셋이 커져 벌어지는 현상이라는 것을 알아주시면 좋을 것 같습니다.

그래서 조인이 성능에 영향을 준다는거냐 안준다는거냐

조인 자체는 성능에 영향을 주지 않습니다. 하지만 LEFT JOIN같은 특정 연산이 데이터 셋을 많이 가져오는 특징 때문에 성능이 안좋아질 수는 있다는 것입니다.

이 결론은 JWT자체는 stateless하지만 결국 구현하려면 stateful하게 구현해야한다는 것과 결론이 일맥상통합니다.

마치며

맨 처음 소개했던 포스팅에서도 언급했듯이 "조인은 오버헤드가 존재하니까 비정규화를 쓰라는 얘기가 아니다" 라는 것을 미루어 봤을 때 작성자분이 한 발 물러난 것 처럼 보이긴 합니다.

하지만 글을 읽는 내내 본인의 확신에 가득찬 워딩때문에 의구심을 가지게 되는 것은 사실이었습니다. 의도치않게 주장을 반박하게 되었지만 그 점을 제외하고서도 짜임새가 좋은 글이어서 한번쯤 보시는 것을 추천드립니다.

이렇게 조인이 성능에 영향을 주는지 안주는지에 대해서 정리해봤습니다. RDBMS를 쓴다면 조인에 대한 오버헤드에 대해서 맘놓고 쓰세요 ㅎㅎ.. 대신 인덱싱같은 작업이 필요하긴 하지만요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

PostgreSQL이 WAL (Write Ahead Log) 를 활용하는 방법

이번 포스팅은 PostgreSQL의 로드맵인 아래의 링크에서 영감을 받았습니다.

https://roadmap.sh/postgresql-dba

DBA를 위한 로드맵이지만 요즘 데이터베이스를 공부하고 있는 김에 공부하게 되었습니다. 위의 로드맵을 순서대로 쭉 따라가고 있는데 그중에 WAL이라는 개념이 나옵니다.

이번 포스팅에선 WAL에 대해서 깊이있게 공부한 내용을 공유하고자합니다.

WAL (Write Ahead Log)

WAL은 PostgreSQL에서 중요한 역할을합니다. WAL의 다른 이름은 트랜잭션 로그인데요, 말 그대로 트랜잭션 처리에 중요한 역할을 합니다.

그리고 트랜잭션을 진행할 때 사용하는 로그 뿐만아니라 백업을 위한 바이너리 로그이기도합니다. MySQL에선 binlog라는 바이너리 로그와 트랜잭션 로그가 분리되어 있지만 PostgreSQL에선 WAL이라는 파일 하나로 관리됩니다.

WAL은 기본 세팅으로 16MB의 크기를 부여받습니다. PostgreSQL은 WAL에 데이터베이스의 쓰기 연산과 관련된 모든 내용을 기록합니다.

만약 쓰기 연산이 일어나면 WAL에 먼저 쓰기 작업을 진행합니다. 이 작업들은 각각 식별자가 존재하는데 Log Sequence Number (이하 LSN) 으로 이를 관리합니다.

WAL의 진정한 파워는 충돌 때 발생한다.

WAL은 트랜잭션과 관련된 모든 로그를 기록하기 때문에 각기 다른 스레드가 트랜잭션을 부여받고 쓰기 연산을 시작할 때 충돌이 일어날 수 있습니다.

이 때 서로 WAL에 쓰기 작업을 하려고할 때 충돌하게 되고 조금이라도 늦게 쓴 트랜잭션이 잠시 롤백했다가 이전 트랜잭션이 완전히 커밋되는 것을 확인하고 WAL에 쓰게됩니다.

이는 PostgreSQL의 일관성을 보장해줍니다. RDBMS의 트랜잭션 ACID 중 C에 해당하는 작업을 이렇게 진행하고 있었군요... 처음 알게 되었습니다.

WAL의 설정

Shared Buffers

PostgreSQL은 데이터베이스의 Buffer를 공유합니다. Buffer는 데이터베이스의 요소중 하나이고 자주 사용되는 데이터를 메모리에 적재하여 읽기 연산을 최적화하는 것을 말합니다.

Shared Buffers는 말 그대로 이 Buffer들을 각각 스레드들이 공유한다는 것입니다. RDBMS에는 이러한 Buffer를 관리하는 Buffer Manager가 존재하는데 이 매니저들이 Buffer에 접근하는 스레드들을 관리해줍니다.

Dirty Pages

Page는 데이터베이스가 데이터를 저장하는 최소단위입니다. 데이터베이스는 한줄 한줄 데이터를 가져오는 것이 아니라 이 Page를 이용해서 뭉탱이로 데이터를 가져와 읽기 연산에 필요한 디스크 I/O를 최적화해 오버헤드를 줄여줍니다.

데이터베이스가 쓰기 연산을 진행하고 디스크에 저장되기까지 중간 단계인 WAL에 존재하는 페이지들을 Dirty Page라고 부르고 Checkpointer와 Background Writer 이 두 요소를 이용해 WAL에 있는 페이지들을 디스크에 밀어넣음으로써 데이터를 디스크에 저장하게 됩니다.

Checkpointer

PostgreSQL은 WAL에 체크포인트를 설정하고 이 체크포인트를 이용해 충돌이 일어났을 때 회복을 진행하게 됩니다.

만약 서로 다른 스레드 중 이전 스레드가 WAL에 기록하고 이를 디스크에 밀어넣기 전에 체크포인트를 설정합니다. 이후 다음 스레드가 WAL에 접근할 때 충돌이 일어나고 이 체크포인트를 기준으로 이후에 접근한 스레드의 트랜잭션이 롤백됩니다.

PostgreSQL은 checkpoint-segment와 checkpoint-timeout이라는 두가지 속성으로 Checkpointer를 튜닝할 수 있습니다.

checkpoint-segment는 체크포인트간 얼마나 많은 데이터가 적재될 수 있는지를 설정합니다. 기본값은 3개이고 원한다면 개발자가 이를 자유롭게 설정할 수 있습니다. 반면 checkpoint-timeout은 체크포인트를 얼마나 자주 기록할지 정하는 설정입니다.

checkpoint-segment와 checkpoint-timeout은 모두 작게 설정하면 설정할수록 더욱 자주 Dirty Page를 디스크에 밀어넣어 데이터의 일관성을 높일 수 있습니다. 하지만 그만큼 성능이 떨어지게 되죠.

Bakcground Writer

Background Writer는 Checkpointer를 도와주는 하위시스템으로 체크포인트 사이에 적재된 데이터를 디스크에 밀어넣는 역할을 수행합니다.

Background Writer가 작동할 때, 디스크 I/O가 발생하고 이 때 큰 오버헤드가 발생합니다. 때문에, 앞서 설명했듯이 Checkpointer를 어떻게 설정하느냐에 따라서 일관성과 성능 사이의 트레이드오프를 결정해야합니다.

PostgreSQL이 WAL를 활용하는 방법

이제 본문 내용이 나오네요. 이번 챕터에서 PostgreSQL이 WAL를 이용해 다양한 기능을 구현하는데 이를 한번 알아보도록 하겠습니다.

1. 트랜잭션 일관성

이는 앞서 언급했던 내용이니 짧게 짚고 넘어가겠습니다.

PostgreSQL은 데이터를 디스크에 적재하기 전 WAL에 기록하고 이를 Checkpointer와 Background Writer를 이용해서 적재합니다.

이 때, 트랜잭션간 충돌이 일어났을 때 WAL를 기반으로 롤백을 진행하고 트랜잭션의 일관성을 유지시켜줍니다.

2. 레플리케이션 데이터 동기화

만약 Master, Slave기반의 레플리케이션을 구현했다면 마스터노드에 쓰기연산이 일어나면 이를 복제본에도 똑같이 적용시켜줘야합니다.

이때 마스터 노드에 쓰기 연산이 일어나면 레플리카에게 WAL를 전달합니다. 그러면 레플리카는 WAL를 보고 어떤 데이터가 쓰였는지 알 수 있고, 이를 디스크에 반영합니다.

이런 특징 때문에 보통 RDBMS에서는 레플리케이션을 비동기만 지원하지만 PostgreSQL은 동기 레플리케이션이 가능합니다.

이를 통해 더욱 높은 일관성을 보장할 수 있게 되겠죠. 높은 일관성이 필요한 서비스에서는 꼭 필요한 기능이라고 할 수 있습니다.

여담으로 MySQL은 동기, 비동기 말고도 반동기 (semi-synchronous)를 지원합니다. 반도기는 일단 마스터 노드에 작성하고 하나라도 레플리카에서 작성이 완료되었다는 신호를 받으면 그 다음으로 프로세스를 진행시키는 방법입니다.

이에 대한 자세한 내용은 아래의 링크에 설명되어있습니다!

https://coding-review.tistory.com/522

3. RDBMS가 NoSQL의 자리를 넘보다

PostgreSQL은 훌륭한 오픈소스 커뮤니티와 라이센스 덕분에 RDBMS를 NoSQL의 특징을 갖게 만들어주는 마개조 버전 라이브러리가 존재합니다.

그 예로 Citus가 존재하는데 이는 RDBMS에서 불가능에 가까웠던 Sharding을 지원하여 RDBMS의 수평확장을 가능케 해주는 라이브러리입니다. 물론 MySQL에도 Sharding을 부분적으로 지원하지만 많이 사용하지는 않는 것 같았습니다.

하지만 이런 라이브러리 말고도 WAL를 이용해서 NoSQL의 BASE 속성을 흉내낼 수 있다는 것이 흥미롭습니다.

바로 비동기 커밋입니다.

WAL의 설정 중에는 동기 / 비동기를 껐다 켰다할 수 있는 설정이 존재하는데 이를 이용해서 커밋을 비동기로 처리할 수 있습니다.

비동기 커밋은 말 그대로 커밋이 된 것을 확인하지 않고 WAL에 데이터를 쓸 수 있게 설정하는 것인데 이게 성능 차이가 은근 있더라구요.

동기 커밋은 TPS (Transaction Per Second) 가 900인 반면 비동기 커밋은 TPS가 1500에 가깝게 나옵니다. 이는 166퍼센트 성능향상이 있는 것으로 꽤나 큰 차이라는 것을 알 수 있죠.

마치며

PostgreSQL은 WAL를 이용해 RDBMS의 기본 소양인 ACID를 지키면서 동시에 NoSQL의 특징을 가질 수도 있다는 것이 정말 놀라웠습니다.

이번에 공부하고 어떻게 RDBMS가 일관성을 지킬 수 있었는지 알게되었고 WAL를 이용해 어떤 전략을 사용하고 있는지 알 수 있었습니다.

PostgreSQL은 후발주자인만큼 RDBMS계의 거장들인 Oracle, MySQL, MariaDB 에게도 뒤지지 않는 특징들이 있어야 살아남을 수 있었을 것 같네요.

많은 개발자들이 RDBMS를 사용하면서 생기는 다양한 고민들 (NoSQL에 비해 낮은 성능, 낮은 확장성) 을 조금씩 해결해주면서 사용자를 점점 늘려가고 있는 것 같습니다.

이때문인지 PostgreSQL은 NoSQL에서만 볼 수 있었던 특징들이 몇가지 보입니다. JSON, BSON 등 유연한 데이터 타입, 엄청나게 많은 인덱스 전략들, Sharding과 비동기 커밋으로 BASE 속성까지말이죠.

처음엔 흥미로 시작했던 PostgreSQL의 매력들에 더 깊이있게 공부해보고 싶어지더군요. 앞으로 맨처음 언급했던 로드맵을 따라가면서 포스팅도 천천히 써볼 생각입니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://vipulvyas.medium.com/understanding-database-internals-how-tables-and-indexes-are-stored-on-disk-and-queried-7cf09a6a48a4

https://www.javatpoint.com/database-buffer

https://hevodata.com/learn/working-with-postgres-wal/

https://www.youtube.com/watch?v=haz2h7_xFDk&t=240s (https://www.youtube.com/watch?v=haz2h7_xFDk&amp;t=240s)

나는 왜 Redis Cluster 대신 Redis Sentinel을 사용하였는가.

이번 포스팅에선 Redis Cluster에 대해서 깊이있게 공부해볼겸 왜 제가 Redis Cluster 대신 Redis Sentinel을 사용하게 되었는지 정리해보는 시간을 가져볼까합니다.

Redis Cluster

Redis Cluster는 Redis Sentinel의 상위호환이라며 Redis의 고가용성을 위해서라면 Cluster를 사용해야한다는 포스팅을 어디선가 봤습니다. Cluster가 Sentinel과 어떻게 다르길래 이런말이 나오는지 자세히 알아봤습니다.

Sentinel에 대해서는 아래의 링크에 자세히 설명되어있으니 짚고넘어가지 않습니다!

https://coding-review.tistory.com/472

Cluster는 하나의 마스터노드와 최소 하나의 슬레이브노드가 쌍을 이뤄 클러스터를 이루고 이런 클러스터가 여러개 있는 형태를 말합니다.

Hash Slot

Redis Cluster는 Hash Slot이라고 부르는 슬롯에 키를 저장하는데요. Hash Slot은 총 16384개를 가질 수 있고 이론적으로는 이 슬롯 하나에 하나의 노드를 가질 수 있어 총 1만6천개가량의 노드를 가질 수 있습니다...만!

Redis 공식 문서에 따르면 노드는 최대 1000개를 넘기지 않는 것이 좋다고 하네요.

어떤 키가 해쉬 슬롯에 저장되는 순서를 한번 알아봤습니다.

• 키가 저장된다. (e.g. user:1000)

• 해당 키는 해쉬함수를 거쳐 어떤 값을 가지게 된다. (e.g. 123456789)

• 이 값을 16384로 나누고 나머지를 계산한다. (e.g. 1567)

• 이 키는 1567번째 해쉬 슬롯에 저장된다.

이때 해쉬 함수는 CRC16을 사용한다고 하네요.

그럼 키가 조회할 때는 어떤 순서로 조회되는지 알아봤습니다.

• user:1000라는 키가 조회된다.

• Cluster는 해당 키가 어떤 슬롯에 있는지 검사한다.

• 1567번 슬롯을 관리하는 클러스터를 호출한다.

• 해당 클러스터에서 값을 가져온다.

이렇게 하면 성능에 문제가 되지 않을까요? Redis 공식 문서에 따르면 Cluster로 구성한 것과 Standalone으로 구성한 것의 성능 차이는 미미하다고 하네요. (진짜..?)

아무리 해시함수가 빠르다고는 하지만 해싱을 하는 과정이 일반적인 과정보다는 latency가 있을 것 같은데 말이죠...

At the same time the query is usually performed in a single round trip, since clients usually retain persistent connections with the nodes, so latency figures are also the same as the single standalone Redis node case.

- Redis 공식문서 발췌 -

뭐 그렇다고 하니 넘어가도록 합시다.

Hash Tag

해시 슬롯과 따라다니는 개념이 해시태그입니다. 이 해시태그가 SNS에서 사용하는 해시태그와 의미가 같은건지 잘 모르겠네요. SNS에서 해시태그가 가지는 의미와 어느정도 일맥상통하는 느낌이 있긴 합니다.

Redis의 Hash Tag는 어떤 값을 해시슬롯에 넣을지 결정하는 일종의 라벨입니다. 이 태그가 붙어있는 것은 해시슬롯에 들어가며 Redis Cluster의 관리를 받습니다.

Redis Cluster에서의 해시태그는 중괄호 { } 안에 들어가는 값이 해싱함수를 거쳐 해시슬롯에 저장됩니다.

즉, {user:1000} 이렇게 키를 저장하면 user:1000이 해시슬롯에 들어간다는 의미입니다.

이 때, Redis에서는 키에 중괄호가 들어가는 상황을 대비해 여러가지 규칙이 있었습니다. 예를 들어서 {{user:1000}}이렇게 키를 설정하면 첫번째 열린 중괄호를 기준으로 두번째 열린 중괄호는 무시하고 첫번째로 나온 닫는 중괄호까지 해시슬롯에 저장합니다.

그러니까 이때는 {user:1000 이렇게 해시슬롯에 저장한다는 의미이죠.

또한, 키를 {user:1000}.publisher 와 {user:1000}.subscriber 는 키는 다르지만 해시슬롯에 들어가는 값은 똑같아진다고 하네요.

High Scalability and Automatic Sharding

이런 일련의 과정을 자동으로 처리해주고 이 과정을 오토샤딩이라고 부릅니다. 이런 강력한 능력 덕분에 높은 확장성을 보여주는데요. 그냥 클러스터를 옆으로 늘리기만 하면 데이터도 알아서 해시슬롯에 보관되고 성능까지 올라가게 됩니다.

"와... 이렇게 완벽할 수가 있나요? 그럼 당장 쓰자!"

샤딩은 디버깅을 힘들게하고 유지보수측면에서 최악인 기술중 하나입니다. 오죽했으면 MongoDB 공식 문서에서도 다음과 같은 문장이 있습니다.

"샤딩은 최후의 방법으로 고려해봐라"

컴퓨팅 파워도 늘려보고 Replication도 해보고 모든 짓을 다해보고도 그 이상의 확장성이 필요하다면 그 때 샤딩을 고려하라는 말이 적혀있습니다.

그만큼 샤딩은 데이터를 관리하는 것이 굉장히 까다롭다는 말이 됩니다.

Automatic Failover

확장까지 유연한데 자동으로 장애회복까지 가능하다는 것이 믿기힘들지만 사실입니다.

만약 마스터 노드가 다운된 상태에서 뒤에서 대기하고 있던 슬레이브 노드가 마스터로 승격되는 과정을 거침으로써 고가용성을 챙길 수 있습니다.

또한, Redis 공식문서에 따르면 슬레이브노드가 마스터로 승격되는 데에 1~2초밖에 소요되지 않는다고 하네요. (진짜..?)

진짜라고 하니까 넘어가겠습니다...
진짜라고 하니까 넘어가겠습니다...

이는 실습으로 한번 확인을 해보긴 해야겠네요.

Redis Cluster는 서로 ping pong을 주고받으면서 서로의 생사를 확인하는데 이를 심장박동에 비유해 Heartbeat message 라고 표현하더군요.

ping pong을 날려가면서 생사를 확인하고 생사가 불분명하다면 레플리카를 승격시키는 것이죠. 이때 생사가 불분명하지만 죽은 것 같아! 라는 의미의 PFAIL, 확실히 죽었어! 라는 의미의 FAIL 태그를 서버 이마에 붙여가면서 생사를 서로 판단하고 선출하는 과정을 거칩니다.

이는 Redis Sentinel의 sdown, odown과 일맥상통하네요.

Redis Cluster... 너무 완벽해...

사실 너무 완벽해서 공식문서를 읽는 중간부터는 이놈의 단점을 찾는데 혈안이 되었습니다. 이렇게 완벽한 솔루션은 있을 수 없어!!! 라는 고요속의 외침과 함께 단점을 찾아뒤지기 시작했습니다.

Sharding은 Sharding이다

앞서서도 언급했지만 샤딩은 결국 샤딩입니다. 샤딩은 디버깅이 힘들고 관리하기가 굉장히 까다로워집니다. 데이터들이 해시함수를 타고 여기저기 왔다갔다 하면서 어디로 데이터가 저장되는지도 모르고 찾기가 힘들어진다는게 가장 큰 단점이지 않을까 싶네요.

앞선 언급과 마찬가지로 샤딩은 최후의 최후까지 고려하지 않는 마지막 수단이어야할 것 같습니다.

기본으로 내야하는 서버비가 6대라고?

미쳤다고밖에 할 말이 없는 유지비가 두번째 단점입니다. Redis 공식문서에서 추천하는 Redis Cluster의 개수는 세개입니다. 즉, 하나의 마스터노드와 최소 한개의 슬레이브노드가 쌍을 이룬 것이 세개 있어야 하니 총 여섯개의 서버가 필요하죠.

그리고 뒤에서 더 자세히 설명하겠지만 사실 6대도 아니고 7대입니다. Redis Cluster를 구축할 때 고려해야할 사항 중 하나인 Split Brain 현상 때문에 노드를 홀수개로 유지해야하거든요. 그럼 7대가 Redis Cluster를 운영하기위한 최소 서버 대수입니다.

이런 상황이다보니 Redis서버를 일곱대나 운영할 정도로 대규모 서비스가 아니라면 고려대상조차 안됩니다. 배보다 배꼽이 더 커지는 효과가 있죠.

공식문서에서도 Sentinel은 소, 중규모의 서비스에서 Cluster는 대규모 서비스에서 사용하는 것을 추천한다고 나와있습니다.

완벽한 HA는 아니다.

만약 서버가 일곱대라고 가정하였을 때, 이 중 서버 두 대에 장애가 발생했습니다. 이때 이 두 대의 서버가 모두 살아날 확률은 얼마나 될까요?

엥? 확률? 그냥 두 대가 죽었으면 두대가 살아나는거 아니야?

정답은 90퍼센트입니다. 왜 10퍼센트의 확률로 두 대가 살아남지 못할까요?

왜냐하면 Redis Cluster는 마스터와 슬레이브가 쌍을 이루는 하나의 클러스터로 이루어졌기 때문입니다. 이를 이해하기 위해선 상황을 가정해야합니다.

총 노드가 7개이고 마스터가 3개 슬레이브가 4개인 상황을 가정해보도록 하겠습니다.

마스터 노드는 M1, M2, M3 / 슬레이브 노드는 S1a, S1b, S2a, S3a 이렇게 있다고 가정하겠습니다.

만약 M1, S2a가 죽었다면 어떻게 될까요?

M1을 대신할 슬레이브 둘 중 하나를 선출하고 S2a는 원래부터 슬레이브 노드였으니까 문제없이 돌아갑니다.

M1, M2가 죽은 상황은요?

마찬가지로 각각의 슬레이브에서 하나씩 선출해서 마스터로 승격시키면 아무문제없습니다.

하지만!

M3, S3a 이렇게 쌍으로 죽어버리면?

그럼 이 클러스터를 살릴 수 있는 것은 아무것도 없습니다. 이게 Redis Sentinel과 Redis Cluster의 가장 두드러지는 차이 중 하나입니다.

Redis Sentinel은 몇대가 죽던간에 남아있는 슬레이브 중에서 마스터로 승격시킵니다. 모든 노드가 다 죽어버리지 않는 이상 Redis Sentinel은 살아있습니다.

고려할 것이 또 있다. Split Brain 현상

네트워크가 불안정해 클러스터끼리의 통신이 불안정하다면 이 클러스터들이 쪼개지는 현상이 발생합니다.

이런 상황을 가정해보겠습니다.

이렇게 둘로 쪼개진 상황이라면 어떻게 될까요?

Replica1 입장에선 마스터가 없습니다. 그럼 본인이 마스터로 승격되죠. Replica2, Replica3도 마찬가지입니다. 그럼 이 상황은 이런 결과를 초래합니다.

이 상태를 냅두면 어떻게될까요?

이렇게 서로 다른 네트워크에서 마스터 노드들이 앞다투어 데이터를 쓰게 되고 이 네트워크가 정상적으로 돌아와서 다시 합쳐지는 순간 대환장파티가 벌어집니다.

Key_A가 abc야? def야? 이런 상황이 벌어지죠.

이것이 바로 Split Brain 상황입니다.

이 때문에 Redis Cluster는 홀수개의 노드를 구축함으로써 이 문제를 최소화하였습니다.

이렇게 홀수개로 노드를 구성하면 Redis Cluster는 노드의 개수가 어느쪽이 많은지 확인할 수 있습니다. 이때 더 많은 쪽에서는 슬레이브를 마스터로 승격시키지만 더 적은 쪽에서는 슬레이브를 마스터로 승격시키지 않는 전략을 사용할 수 있습니다.

그럼 슬레이브가 제멋대로 마스터로 승격되는 것을 방지할 수 있습니다.

마치며

이제 결론입니다. 나는 왜 프로젝트에 Redis Sentinel을 썼는가? 저는 샤딩도 샤딩이지만 조금 더 높은 가용성을 선택하는 것이 조금 더 합리적인 선택이지 않나 싶습니다. (절대 서버 7대 살 비용 없어서 그런거 아닙니다)

온라인 쇼핑몰의 특성상 Redis에 의존하는 부분이 많아 Redis가 죽으면 뒤에 있는 RDBMS까지 죽어버려서 서비스 전체 장애로 이어질 가능성이 높은 상황이라 Redis의 생존 여부는 제 프로젝트에서 큰 영향을 미칩니다.

또한, 본문에서는 설명하지 않았지만 Redis Sentinel은 자체적인 Notification을 가지고 있습니다. 물론 CloudWatch나 SNS를 이용하면 해결할 수 있지만 그에따른 추가적인 비용도 생각해봤을 때 Redis Sentinel이 적합한 판단이라고 생각합니다.

이렇게 Redis Cluster에 대해 포스팅 해보고 Redis Sentinel과 어떤 부분이 다른지 포스팅해봤습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://semaphoreci.com/blog/redis-architectures

https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#performance

https://s-core.co.kr/insight/view/redis-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0%EC%97%90%EC%84%9C-split-brain-%EC%83%81%ED%99%A9%EC%9D%98-%EB%8C%80%EB%B9%84%EC%B1%85/

Orchestrator를 이용한 MySQL 레플리케이션 그리고 장애회복

MySQL은 다양한 레플리케이션 전략을 가지고 있습니다. 동기, 반동기, 비동기 이렇게 세 가지 전략을 가지고 있죠. 그 중에서 Orchestrator는 비동기 레플리케이션을 구현할 수 있게 해주는 서드파티 툴입니다.

이번 포스팅에선 Orchestrator가 어떻게 자동으로 장애회복을 할 수 있는지, 어떻게 레플리케이션을 구축할 수 있는지에 대해서 공부해보고 정리해봤습니다.

Orchestrator가 뭐야?

Orchestrator는 MySQL진영의 레플리케이션을 구축하기위한 서드파티 툴입니다. Orchestrator는 GUI로 MySQL서버들을 관리할 수 있다는 것만으로도 쓸만한 이유가 되는 툴인데요.

이렇게 노드의 위치를 옮겨가면서 노드들을 관리할 수 있습니다.

노드를 끌어다 마스터노드로 놓으면 이 노드가 마스터 노드로 승격이 되고 트리구조의 레플리케이션으로 관리하기가 쉽다고 하네요. 제가 현업에서 사용한 것은 아니라서 진짜 사용하기 쉬운지는 모르겠습니다.

Orchestrator의 Automatic Failover

Orchestrator는 자동으로 장애를 회복할 수 있는 토폴로지를 제공하고 있습니다. 토폴로지란 전자기공학에서 논리회로를 이야기하는 것으로서 컴퓨터공학에선 논리 아키텍처를 매칭할 수 있습니다.

위에서 보여드렸던 대로 수동으로 장애를 회복하는 것은 기능으로서 제공하는 것이지만 이를 실전에서 사용하는 것은 상상하기 어렵습니다. 때문에 Orchestrator는 자동으로 장애회복을 하는 토폴로지가 자주 쓰일텐데 어떻게 자동으로 장애를 회복하는 것일까요?

우선 Orchestrator가 마스터 노드가 죽었는지 살았는지 확인하는 방법은 여러개가 있을 수 있습니다.

• 마스터 노드의 3306포트가 열려있는지 확인한다.

• 마스터 노드로 간단한 쿼리를 날려본다. (SELECT 1 FROM DUAL)

• 실제 데이터를 가져오는 쿼리를 날려본다.

1번에서 3번으로 갈 수록 지속적으로 수행하기엔 힘들지만 죽었는지 살았는지 확실하게 아는 방법입니다.

하지만 이런 방법들은 공통적인 문제를 가지고 있습니다.

바로 Split Brain 현상에 대한 대책이 없다는 것인데요. Split Brain은 아래의 링크에 자세히 설명되어있으니 참고해주시기 바랍니다!

https://coding-review.tistory.com/542

이 Split Brain 현상때문에 Orchestrator는 독특한 방식을 사용합니다. 바로 모든 MySQL노드들과 연결되어있다는 장점을 십분 발휘한 것인데요.

네트워크 단절로 발생한 Split Brain을 방지하기 위해서는 슬레이브 노드들이 마스터 노드와 정상적인 통신을 하고 있는지 확인하면 됩니다.

모든 슬레이브 노드 중 하나라도 마스터 노드와 통신하고 있다면 마스터 노드는 죽은 것이 아니기 때문에 슬레이브 노드를 마스터로 승격시키지 않습니다.

하지만 모든 슬레이브 노드가 마스터와 연결이 되어있지 않다면 그때 진짜 마스터 노드가 죽었다는 것이기 때문에 슬레이브 노드 중 하나를 승격시키게 됩니다.

그래도 이 방법이 완벽한 방법은 아니다.

이렇게 자동으로 승격시키는 과정이 확실히 Split Brain을 방지하는 대책으로서 작용하지만 완벽한 방법은 아닙니다.

왜냐하면 이 방식은 슬레이브 노드가 마스터로의 승격이 늦어지기 때문입니다. 마스터 노드와 연결된 모든 슬레이브 노드들의 상태를 확인해야 하기 때문에 마스터로의 승격이 늦어질 수 있습니다.

이는 데이터 유실로 이어질 수 있고 만약 데이터 쓰기 연산이 많은 애플리케이션일수록 이 상황은 큰 문제가 됩니다.

이를 해결하기 위해서는 Orchestrator의 Emergency Detect Failure 설정으로 해결할 수 있습니다. 하지만 이 방법은 앞서 언급했듯이 Split Brain을 야기할 수 있기 때문에 조심해서 사용해야 합니다.

또한, Automatic Failover도 만능은 아니다.

Orchestrator가 자동으로 장애를 회복할 수 있다고 하지만 Orchestrator는 모든 노드들에 대한 자세한 정보를 가지고 있지 않습니다. 이는 노드들의 메타데이터를 가지고 있지 않다고 해석할 수도 있는데 이점은 자동으로 장애를 회복하는 과정에서 문제가 발생할 수 있습니다.

자세한 정보를 가지고 있지 않기 때문에 마스터 노드의 장애를 인식하는 과정에서 확실한 정보가 없어 잘못된 마스터로의 승격을 야기할 수 있기 때문이죠.

이는 단순히 Orchestrator가 정확한 정보를 가지고 있지 않기 때문에 발생하는 문제입니다.

때문에 Orchestrator의 공식문서에서는 Orchestrator와 Vitess를 같이 사용할 것을 권고하고 있습니다.

그럼 Vitess는 뭔가요?

Orchestrator의 단점을 메워준다. Vitess

Vitess (비-테스 라고 발음합니다.) 는 MySQL노드들의 메타데이터를 가지고 MySQL을 조금 더 잘 사용할 수 있도록 제공해주는 서드파티 툴입니다.

Orchestrator는 MySQL의 정확한 메타데이터를 가지고 있지 않지만 Vitess는 정확한 MySQL노드의 메타데이터를 가지고 있습니다. 때문에 Orchestrator는 마스터노드로의 승격에 필요한 정보를 주고 받을 수 있습니다.

여기서 Vitess에 대해서 잠깐 정리하고 넘어가자면

Goroutine으로 경량화된 커넥션을 맺을 수 있음

Vitess는 Go로 개발된 MySQL서드파티 툴입니다. MySQL의 커넥션을 한번 맺으려면 최대 3MB의 메모리가 필요한데 이는 커넥션 풀을 사용한다고 하더라도 많은 리소스를 사용하는 것으로써 이 경량화된 스레드로 경량화된 커넥션을 맺을 수 있어 커넥션에 필요한 리소스를 최소화 할 수 있습니다.

쿼리 재사용 가능

MySQL은 같은 쿼리를 재사용할 때 최적화된 로직을 가지고 있지만 Vitess는 이보다 더 최적화된 SQL parser가 있어서 똑같은 쿼리를 재사용할 때 내부적으로 최적화하여 쿼리에 대한 성능을 끌어올렸습니다.

이는 동일한 쿼리가 반복적으로 발생되는 일반적인 애플리케이션 특성상 성능상 이점을 얻을 수 있죠.

샤딩 제공

일반적인 바닐라 MySQL에서는 제공하지 않는 샤딩을 제공하면서 데이터를 효과적으로 분리하여 분산시스템에서도 적절히 사용할 수 있는 솔루션을 제공해줍니다.

이런 샤딩은 수평으로 확장할 때 유용하게 사용된다는 장점이 있습니다. 하지만 복잡성이 올라가기 때문에 조심히 사용해야하는 기능 중 하나이죠.

클러스터링 제공

HA를 위해 클러스터링 방식으로 데이터베이스의 가용성을 높였으며 이런 특징은 MySQL노드를 안정적으로 운영할 수 있게 해줍니다.

하지만 치명적인 단점이 있다.

Vitess의 이런 장점에도 문제는 Multiple Writable Primary를 엄하게 금지하고 있다는 것입니다. 즉, 마스터-마스터 레플리케이션을 금지하고 있다는 것입니다.

수직적인 레플리케이션 토폴로지를 가지고 있어서 마스터-마스터간 레플리케이션이 불가능해 쓰기 부하가 심하게 걸린다는 단점이 있습니다.

그렇기 때문에! Orchestrator와 협업이 빛나는 퍼포먼스를 이뤄낼 수 있습니다.

Orchestrator, Vitess 크로스!!

Orchestrator는 MySQL노드의 자세한 메타데이터를 가지고 있지 않고, Vitess는 마스터-마스터 레플리케이션이 불가능하기 때문에 이 둘의 단점을 완벽하게 보완해주는 관계라고 할 수 있습니다.

Orchestrator는 마스터로의 승격을 위해 Vitess의 메타데이터를 기반으로 더 확실한 승격을 할 수 있고, Vitess는 마스터-마스터간 레플리케이션으로 쓰기 부하를 효과적으로 분산시킬 수 있습니다.

이런 특징 때문일까요? Vitess 공식 문서에서는 Orchestrator와 Vitess를 합친 VTOrc라는 기능을 소개하고 있습니다.

이 둘은 서로가 서로를 사용하긴 하지만 종속적인 관계는 아닙니다. 둘은 협력관계일 뿐이죠.

하지만 이 둘이 협업하면서 꼬이게 되는 상황도 발생하기도 합니다.

Orchestrator가 완전히 동작하고 있는 레플리케이션 토폴로지를 사용하고 있을 때 (쓰기 전용 마스터노드와 읽기 전용 레플리카), Orchestrator가 마스터노드로 인식하고 있는 것을 Vitess는 슬레이브노드로 인식하고 Orchestrator가 슬레이브노드로 인식하고 있는 것을 Vitess가 마스터노드로 인식하는 상황입니다.

이런 흥미로운 상황은 이전에 실패한 Automatic Failover 프로세스가 조기에 종료되었을 때 발생할 수 있습니다. 이 경우 Orchestrator는 실제로 올바른 서버를 새로운 마스터노드로 승격시키고, 가짜 마스터노드를 레플리카로 강등하기위해 Graceful-Takeover (Vitess에선 Planned-Reparent라는 용어를 쓰더군요) 를 수행합니다.

때문에, 이런 의도치않은 상황에서도 Orchestrator가 자신의 레플리케이션 토폴로지를 만족시키기위해 뒷단에서 열심히 일하고 있는 모습을 볼 수 있습니다.

마치며

MySQL의 생태계를 공부하기 전에 Redis, PostgreSQL의 생태계를 먼저 공부하다보니 신선한 경험을 할 수 있었습니다. 제 본진이 MySQL진영인데 MySQL에 대해서 아는게 별로 없었다는 생각때문에 지금이라도 열심히 공부해야겠다는 생각을 했습니다.

MySQL진영도 꽤나 흥미로운 기술들이 많네요. 역시 데이터베이스 공부는 재밌습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://planetscale.com/blog/orchestrator-failure-detection

https://vitess.io/docs/19.0/overview/whatisvitess/

https://vitess.io/blog/2022-09-21-vtorc-vitess-native-orchestrator/

MySQL에 사는 유령

MySQL의 InnoDB 스토리지 엔진의 기본 격리수준은 REPEATABLE READ입니다. 하지만 REPEATABLE READ에서 발생할 수 있는 문제 중 하나인 Phantom Read를 피할 수는 없는데요.

MySQL은 각종 locking 매커니즘을 이용해서 Phantom Read를 막을 수 있었다고 MySQL팀은 설명했습니다. 하지만 무조건 Phantom Read가 발생하지 않는 것은 아닙니다.

이번 포스팅에선 MySQL에 사는 유령에 대해서 공부해보고 정리해봤습니다.

Phantom Read

우선 Phantom Read에 대해서 간단하게 짚고 넘어가도록 하겠습니다.

어떤 상황이 Phantom Read를 발생시킬까요?

• A트랜잭션이 SELECT 쿼리를 날린다

• B트랜잭션이 이후 INSERT 쿼리를 날린다

• A트랜잭션이 다시 SELECT 쿼리를 날린다

• A트랜잭션에서 B트랜잭션이 삽입한 데이터가 보인다.

이 경우 우리는 Phantom Read가 발생했다. 라고 말합니다.

하지만 Phantom Read는 얼핏 보기엔 굉장히 자연스러워 보이기도 합니다.

왜냐하면 데이터가 추가되었으니까요.

하지만 트랜잭션의 관점에서 이는 위험할 수 있습니다. RDBMS는 트랜잭션의 ACID아래서 굉장히 높은 신뢰성을 가지고 군림해왔습니다. 그 중 I에 의하면 서로 다른 트랜잭션은 격리되어야하죠.

격리되지 않으면 일관성이 깨지게 되는데 예를 들어서 20개를 예상했는데 21개가 나온다거나 하는 상황이 발생할 수 있고 이것은 잠재적으로 문제가 될 수 있습니다.

MySQL의 record lock, gap lock, next key lock

하지만 MySQL에서는 record lock, gap lock과 그것을 발전시킨 next key lock을 이용해서 Phantom Read가 발생하지 않는다고 알려져있습니다.

https://coding-review.tistory.com/302

제가 예전에 정리한 글인데 여기에 자세히 나와있습니다.

이번 포스팅에선 간략하게만 짚고 넘어가겠습니다.

record lock은 row에 락을 거는 것이고 gap lock은 범위에 락을 거는 것입니다. 그리고 이 둘을 합친 것이 next key lock이죠. 합쳤다기 보단... 기본 골자는 next key lock이고 상황에 따라 record lock을 걸 것인지 gap lock을 걸 것인지 정하는 것으로 알고있습니다.

예를 들어서 where 조건으로 하나만 걸었으면 record lock이 되고 between 같은 조건으로 여러개를 걸었다면 gap lock이 되는 그런 식이죠.

정말 Phantom Record가 안보일까?

우선 두개의 터미널을 준비하고 데이터를 하나 넣어놨습니다.

그리고 양쪽 모두에 트랜잭션을 시작하고 실습해보겠습니다.

한쪽에서는 트랜잭션을 시작하고 Bob이라는 행을 추가했을 때 데이터가 두개 보이는 모습입니다.

하지만 다른 한쪽에선 데이터가 보이지 않네요?

이로써 Phantom Read가 발생하지 않는다는 것을 알 수 있습니다.

MySQL에 사는 유령

INSERT를 하지 않은 쪽에서 쿼리를 재실행했을 때 데이터가 보이지 않았습니다.

그럼 이건 어떨까요?

INSERT를 한 쪽에서 commit을 하고

오잉? 기존 쿼리는 안보이는데 for update 쿼리를 날리니까 데이터가 보이네요?

for update 쿼리는 흔히 알려지기로 락을 획득하는 쿼리라고 알려져있습니다. 보통 exclusive lock을 획득할 때 for update 쿼리를 사용하죠.

https://coding-review.tistory.com/421

JPA에선 비관적 락을 사용하면 자동으로 날아가는 쿼리가 for update 쿼리입니다.

for update 쿼리를 날리니까 Phantom Read가 발생했습니다. 방금 MySQL에서는 gap lock인지 뭐시긴지로 Phantom Read가 발생하지 않는다면서요? for update는 뭐가 다른걸까요?

FOR UPDATE 쿼리

exclusive lock을 획득하기위해 for update 쿼리를 날린다면 그 즉시 최근 데이터를 불러오게 설정되어있습니다.

MySQL팀에서는 이것이 역설적이게도 일관성을 유지하는 방법이라고 하는데요. Phantom Read가 발생하면 일관성이 깨질 수도 있다고 하지 않았나? 갑자기 이게 뜬금없이 무슨소리야?

이런 상황을 가정해보겠습니다.

• Tom이라는 데이터가 존재한다.

• A트랜잭션과 B트랜잭션이 시작한다.

• A트랜잭션에서 SELECT쿼리를 날린다

• B트랜잭션에서 INSERT쿼리를 날린다

• A트랜잭션에서 업데이트를 하고싶다.

만약 for update 쿼리에 최근 데이터가 조회되지 않는 상황이라면 다음과 같은 문제가 발생할 수 있습니다.

이름이 Tom이고 나이가 20살인 청년의 돈은 100원이 있다. B트랜잭션에서 Bob이라는 청년을 추가했다. 나이는 똑같이 20살에 돈은 100원.

나이가 20살인 사람의 돈을 1000원으로 올리고 싶은 상황

Tom의 돈은 1000원이 됐는데 Bob의 돈은 아직 100원입니다.

정말 일관성이 깨져버렸네요?

즉, 이런 상황 때문에라도 for update 쿼리는 최신 데이터를 가져와야합니다. 이것이 일관성을 유지하는 방법이었던 것이죠.

FOR UPDATE쿼리의 문제

for update 쿼리가 단순히 Phantom Read만 발생시킨다는 것이 문제가 아닙니다. gap lock의 특성 때문에 for update쿼리는 문제가 될 수 있습니다.

만약 for update 쿼리를 실행할 때 존재하지 않는 행을 for update로 lock을 걸어버리면 문제가 발생할 수 있습니다.

위의 예시의 경우 id가 1, 2 만 존재하는 상황인데 만약 제가 아래와 같은 쿼리를 날린다면?

SELECT * FROM USER u WHERE u.id = 3 FOR UPDATE;

id가 3인 행은 존재하지 않습니다. 이런 경우 gap 락의 특성상 조건문에 걸려있는 값 (3) 부터 무한행까지 락이 걸려버립니다.

이 말은 exclusive lock을 3~무한 까지 걸어버리겠다는 의미이고 다시 말해서 해당 트랜잭션이 커밋을 하기 전까지 어떤 값도 조회 혹은 삽입, 변경, 삭제가 불가능 하다는 말입니다.

이 상황은 서버의 굉장한 성능 저하가 발생할 수 있습니다. 또한, 데드락의 문제도 빈번하게 발생하겠지요.

그래서 이 상황을 해결하기 위해서 for update는 무작정 사용하면 안됩니다.

SELECT * FROM USER u WHERE u.id = 3;

if row {

SELECT * FROM USER u WHERE u.id = 3 FOR UPDATE;

// INSERT, UPDATE, DELETE 작업

}

else {

// 락을 획득하지 않고 바로 작업

}

이런식으로 단계를 거쳐서 사용해야합니다.

마치며

저도 제 프로젝트에서 단순히 비관적 락 (FOR UPDATE) 을 사용하는게 좋다고 생각했던 이유가 재고가 2개 남았는데 3명이 동시에 누른다면 한명은 주문이 되면 안되고 주문 데이터가 두개만 쌓여야해! 그 상황을 백퍼센트 막을 수 있는 비관적 락을 채택해야해!

라고 생각했지만 이번에 공부하면서 생각이 조금은 바뀌었습니다. 제가 참고한 글에서도 for update를 쓰려면 진짜 이곳에서 써야하는 것인지 생각해보고 위와 같은 뎁스를 두어서 안전하게 사용해야하는 조금은 위험한 쿼리인 것 같습니다.

저를 포함한 주니어 개발자분들을 낚기 딱 좋은 내용이었습니다. 단순히 MySQL은 next key lock으로 Phantom Read 발생 안해! 라고 알고 계셨던 분들은 이번 내용이 도움이 많이 되었을 것 같습니다.

이번 포스팅은 여기서 마무리지어보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://mysqlquicksand.wordpress.com/2019/12/20/select-for-update-on-non-existent-rows/

https://planetscale.com/blog/mysql-isolation-levels-and-how-they-work

MySQL 트랜잭션 로그 추적 with 스프링

약 20일전에 "메세지 브로커의 근심과 걱정" 이라는 포스팅을 작성하였습니다.

https://coding-review.tistory.com/566

위의 포스팅에서는 데이터베이스의 트랜잭션과 메세지 브로커의 통합을 위해서 어떤 부분을 고려해야 하는지에 대한 내용이었습니다.

정상적인 상황에서는 문제 없지만 만약 예외 상황에서 트랜잭션 롤백이 되었을 때 메세지 브로커도 그에 맞춰서 메세지가 전송되지 않아야하는데 트랜잭션과 메세지 브로커를 통합하는 것은 또 다른 복잡성을 유발하였습니다.

가장 매끄러운 상황은 트랜잭션 로그를 추적해서 커밋되는 경우에만 메세지를 전송하는 것이었습니다. 이 방법의 이름은 Transaction Trailing Pattern이고 위 포스팅에선 이를 쉽게 구현해주는 PGMQ에 대해서 짧게 알아봤습니다.

PGMQ는 PostgreSQL을 사용해야하고 만약 우리의 서비스가 Oracle계열을 사용한다면 쉽게 사용할 수 없을 것이고 그렇기에 저는 MySQL 환경에서 어떻게 트랜잭션 로그를 추적할 것인지에 대해서 조금 공부해봤습니다.

물론 혼자서 해본 토이프로젝트인만큼 완성도면에서는 조금 떨어질 수 있어도 간단한 개요정도라고 생각해주시면 좋을 것 같습니다.

MySQL 트랜잭션 로그

MySQL은 binlog라는 트랜잭션 로그가 존재합니다. 여기에는 어떤 쿼리가 실행되었는지 쿼리가 적혀있지만 트랜잭션 로그의 다른 이름인 바이너리 로그에서 알 수 있듯이 열어보면 우리가 읽을 수 없습니다.

그래서 우리가 읽을 수 있는 쿼리로 변경하기 위해서는 따로 작업을 진행해줘야합니다.

$ cd /var/lib/mysql

$ mysqlbinlog --base64-output=decode-rows -vv binlog.000001 > test.sql

mysql> show binary logs;

MySQL cli로 바이너리 로그 이름을 검색하고 해당하는 파일을 sql 파일로 변경해주시면 됩니다.

이름이 트랜잭션 로그인 만큼 SELECT 쿼리는 저장하지 않는다는 특징이 있습니다.

보통 binlog를 사용하는 일반적인 경우는 데이터베이스의 데이터가 모두 날아간 경우 사용할 수 있습니다. 사실 이 경우도 일반적이라고 말하기엔 좀 그렇지만... binlog를 사용하는 가장 대표적인 경우이긴 합니다..

cf) 실제 대형사고 썰

저도 개발바닥 유튜브 돌아다니다가 들은건데 JPA를 이용해서 개발하던 우아한형제들에서 로컬 환경에 운영 데이터베이스를 물려서 테스트해보고 싶었던 개발자분이 JPA의 속성 중 ddl-auto를 create로 하고 애플리케이션을 실행시키는 바람에 모든 데이터가 삭제되는 참사가 벌어졌다고 합니다.

ddl-auto를 create로 하겠다는 것은 모든 테이블을 지우고 다시 새로 생성해서 멱등성을 유지시키려고 설정하는 개발환경에 최적화된 설정입니다.

때문에 create로 하고 애플리케이션을 실행시키면 drop table ... drop table ... drop table ... 이런 무시무시한 로그가 계속 올라가는 것을 볼 수 있죠.

그리고 모든 데이터가 삭제되었고 이를 해결하기 위해서 매일 특정 시간에 돌아가던 데이터베이스 풀백업으로 어느정도는 복구했지만 특정 시간과 현재 시간 사이에 존재하던 데이터 공백이 존재했습니다.

이 공백시간의 데이터를 binlog를 이용해서 밀어넣었고 잘 마무리되었다는 후문이 있습니다.

binlog는 일정 시간이 지나면 삭제되기 때문에 이런 특별한 상황을 제외하고는 잘 사용할 일이 없는 파일이긴 합니다.

MySQL 트랜잭션 로그 추적

그래서 이번엔 이 binlog를 추적해서 메세지 브로커가 트랜잭션 커밋일 경우에만 메세지를 보내도록 설계해보겠습니다.

implementation 'com.zendesk:mysql-binlog-connector-java:0.30.1'

저는 빌드 도구로 gradle을 사용했습니다. 위와 같은 의존성을 추가해줍니다.

또한, 데몬 스레드를 이용해서 커밋 이벤트를 등록하고 이 스레드를 애플리케이션 시작시 빈으로 등록해서 스프링이 관리하도록 해보겠습니다.

@Entity

@Getter

@NoArgsConstructor

@AllArgsConstructor

@Builder

@Table(name = "USER")

public class User {

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)

@Column(name = "USER_ID")

private Long id;

private String name;

private int age;

private int money;

}

간단하게 USER 테이블을 만들어줍니다.

@Configuration

public class ExecutorConfig {

@Bean

public ExecutorService daemonExecutorService() {

return Executors.newSingleThreadExecutor((runnable) -> {

Thread thread = new Thread(runnable);

thread.setDaemon(true);

return thread;

});

}

}

그리고 데몬 스레드를 위한 ExecutorService를 빈으로 등록해줍니다. setDaemon을 true로 등록하는 것을 빼먹으면 안됩니다!

@Service

@RequiredArgsConstructor

@Slf4j

public class UserService {

private final ExecutorService daemonExecutorService;

@Value(value = "${spring.datasource.username}")

private String username;

@Value(value = "${spring.datasource.password}")

private String password;

@PostConstruct

public void startDaemonThread() {

String hostname = "localhost";

int port = 3306;

daemonExecutorService.submit(() -> {

log.info("daemon thread started");

try {

BinaryLogClient client = new BinaryLogClient(hostname, port, username, password);

client.registerEventListener((event) -> {

EventType eventType = event.getHeader().getEventType();

if (eventType == EventType.EXT_WRITE_ROWS) {

WriteRowsEventData data = event.getData();

log.info("write rows event: {}", data);

}

});

client.connect();

}

catch (Exception e) {

log.error("Error starting BinaryLogClient", e);

}

});

}

}

준비는 끝!

여기서 중요한 점은 BinaryLogClient를 만들고 EventListener를 등록하고 connect()를 호출하면 자동으로 해당 binlog event를 등록해줍니다.

데몬 스레드가 실행되었고 로그에 localhost:3306이 연결되었고 mysql-bin.00001에 포지션 3298번이 연결된 것입니다.

만약 본인이 원한다면 다른 binlog나 다른 포지션을 등록할 수 있지만 자동으로 해주니까 믿어보겠습니다.

그리고 이제 MySQL cli로 트랜잭션을 시작하고 커밋해보겠습니다.

그리고 이제 애플리케이션 로그를 확인해보면?

잘 나오네요.

마치며

저는 INSERT 이벤트만 잡았지만 INSERT 말고도 UPDATE, DELETE 이벤트도 잡을 수 있습니다.

EventType.EXT_UPDATE_ROWS

EventType.EXT_DELETE_ROWS

이렇게 트랜잭션을 사용하는 INSERT, UPDATE, DELETE 이벤트를 등록해서 사용할 수 있습니다.

binlog를 추적하는 것이 문서가 잘 안되어있어서 고생했는데 잘 마무리돼서 후련합니다. 이제 PGMQ를 해야하는데... 다음 시간에 한번 도전해보겠습니다..

긴 글 읽어주셔서 감사합니다! 오늘도 즐거운 하루 되세요~

왜 MongoDB인가?

NoSQL 중에서 가장 인기있는 데이터베이스를 꼽아보라고 한다면 당연 MongoDB이지않을까 싶습니다. 저도 NoSQL에 대해서 공부하기 전에도 MongoDB는 알고 있었을 정도니까 말 다한것이죠.

오늘 포스팅에선 NoSQL 그 중에서 MongoDB가 어느 부분이 특별해서 이렇게 많은 개발자들에게 사랑받고 있는지 정리해봤습니다.

MongoDB의 구성 요소

MongoDB의 구성요소는 RDBMS의 그것과 굉장히 닮아있습니다. 하나씩 살펴보겠습니다.

• Collection : Collection은 RDBMS에서 테이블에 해당하는 개념으로 문서들의 집합체입니다. Collection은 스키마가 따로 존재하지 않아서 다양한 구조의 문서를 저장할 수 있습니다.

• Document : MongoDB의 데이터 저장 단위로 RDBMS로 치면 Row에 해당합니다. JSON과 유사한 BSON으로 데이터가 저장됩니다.

{

“_id” : ObjectId(“1234”),

“name” : “John Doe”

}

• Field : RDBMS의 컬럼에 해당하는 개념으로 위의 예제에선 "_id", "name"이 해당됩니다.

• Index : 쿼리 성능을 향상시키기 위해 특정 필드에 인덱스를 설정할 수 있습니다.

• Shard : 샤딩을 통해 데이터를 여러 서버에 분산 저장해서 성능을 향상시킬 수 있습니다.

• Replica Set : 데이터의 고가용성과 장애 복구를 위해 동일한 데이터를 여러 서버에 복제해서 관리합니다. Primary 노드와 여러개의 Secondary 노드로 구성됩니다. 이는 RDBMS에서 Replication에 해당하는 개념입니다.

MongoDB의 주요 개념

• 쿼리 언어 : SQL대신 JSON기반의 문서 지향 쿼리 언어를 사용합니다. db.users.find({ age: { $gte: 25 } }) 쿼리 언어는 이런시긍로 생겼습니다.

• 집계 쿼리 : NoSQL에서는 보기 힘든 집계 쿼리를 지원합니다. 이런 점은 NoSQL보단 RDBMS와 흡사한 성격을 보여주죠.

db.sales.aggregate([

{ $match: { status: "complete" } },

{ $group: { _id: "$category", total: { $sum: "$amount" } } }

])

• 트랜잭션 : 이것또한 NoSQL이라기보단 RDBMS과 비슷한 개념입니다. 원자성을 보장해주죠. 하지만 4.0 이전에는 문서 하나에 대해서만 원자성을 보장했습니다. 4.0 이후 버전에서는 멀티 문서에 대한 트랜잭션도 보장해주죠. 이 때문에 생기는 이점이 많은데 이후에 다시 언급하도록 하겠습니다.

MongoDB를 사용할 때 주의사항

스키마리스? No!

MongoDB는 스키마리스이지만 무작정 아무 데이터를 저장하면 관리가 어려워집니다. 인덱스를 지원하는만큼 어느정도의 스키마는 필요합니다. 또한, 문서의 크기가 너무 크면 성능 저하가 직격탄으로 오기 때문에 중첩 문서와 참조를 적절히 사용해야합니다.

여기서 중첩 문서란 반정규화의 개념이고 참조란 정규화의 개념입니다. 반정규화, 정규화의 특징을 그대로 따릅니다. 중첩문서를 진행하면 읽기 성능이 대폭 증가하고 참조를 진행하면 쓰기 성능이 대폭 증가합니다.

MongoDB가 인덱스를 단순히 지원하는 것이 아니라 복합 인덱스도 지원하기 때문에 RDBMS처럼 어느정도 일관된 스키마를 가지고 있는 것이 성능에 큰 영향을 줍니다. 때문에 MongoDB를 스키마리스라고 말한다면 반은 맞고 반은 틀린말!

트랜잭션

MongoDB는 기본적으로 문서 단위의 트랜잭션을 지원합니다. 하지만 4.0 이후부터는 멀티 문서 트랜잭션을 지원하는데 RDBMS만큼 강력한 ACID는 보장하지 않습니다. 만약 강한 ACID를 원한다면 적절한 설계가 이루어져야합니다.

기본적으로 ACID를 지원하지 않던 데이터베이스여서 그런진 몰라도 RDBMS에 비해 트랜잭션을 많이 사용하면 성능 저하가 확실하기 때문에 트랜잭션을 적재적소에 사용해야합니다.

멀티 문서 트랜잭션을 지원함으로써 가능해진 것이 바로 분산 트랜잭션입니다. 기본적으로 샤딩을 지원하는 NoSQL의 특성상 확장성에 매우 용이한데 이렇게 물리적으로 떨어져있는 독립적인 데이터들을 다룰 때에는 분산 트랜잭션이 필수적입니다. 멀티 문서 트랜잭션을 지원하면서부터 분산 트랜잭션에 용이해 MSA에서도 자주 활용되는 데이터베이스가 되었습니다.

오토 샤딩

MongoDB는 Redis Cluster, HBase처럼 오토샤딩은 지원하지 않습니다. 기본값이 샤딩을 하지 않는 것이기 때문인데요. 이에 대해 MongoDB 공식 문서를 보면 웬만큼 규모가 큰 경우가 아니라면 (Scale Up, Scale Out 까지 다 해본 경우가 아니라면) 샤딩을 지양하고 있습니다.

하지만 설정을 통해서 오토 샤딩을 설정할 수는 있는데요. Dynamic Sharding처럼 설계할 수 있지만 Balancer라고 부르는 샤드들을 관리하는 객체와 데이터 마이그레이션 전략을 적절히 사용해야합니다.

Balancer는 데이터를 특정 샤드에서 다른 샤드로 이동시킬 수 있는데 Dynamic Sharding처럼 모든 데이터를 즉시 이동시키는 것은 아닙니다. 때문에 샤드가 추가되고 데이터를 마이그레이션하는 것을 고려해야하고 새로운 샤드가 추가되면 기존 데이터는 자동으로 샤드로 이동하지 않으므로 필요하다면 직접 moveChunk 명령어로 이동시켜야합니다. 이렇게 설정하지 않는다면 애플리케이션 레벨에서 새로운 샤드로 트래픽을 유도하는 방법을 고려해볼 수도 있습니다.

MongoDB는 왜 그렇게 유명할까?

이제 MongoDB는 NoSQL의 대표주자가 되었습니다. 왜 그렇게 많이 쓸까요? 한번 알아봤습니다.

• JSON (BSON) 타입의 문서형 데이터 : JSON 기반의 데이터가 있기 때문에 데이터를 읽을 때 직관적이고 필요한 필드만 저장이 가능해 만약 필드를 추가하고싶다면 해당 문서에만 추가해주면 됩니다.

• 스키마리스한 데이터 모델 : 위의 특징 때문에 MongoDB는 스키마리스한 테이블을 가지게 되었습니다. 이는 필드를 새롭게 넣었다가 빼더라도 에러가 발생하지 않아서 빠른 개발이 요구되는 환경에서 선호됩니다.

• SQL과 비슷한 쿼리 지원 : MongoDB는 SQL과 비슷한 풍부한 쿼리를 지원하는데 보통의 NoSQL에서도 쿼리 언어가 있기는 하지만 집계 쿼리까지 가능한 것은 NoSQL데이터베이스 중에서는 MongoDB가 유일합니다.

• 트랜잭션 지원 : 원래 MongoDB는 하나의 문서에서만 원자성을 보장했지만 4.0 이후부턴 멀티 문서에 대해 원자성을 보장해 RDBMS처럼 부분적이지만 ACID를 지원하게 되었습니다.

• 수평 확장에 능함 : RDBMS에서 따라가기엔 조금 부족한 NoSQL만의 수평 확장에 능하다는 것이 특징입니다. 기본적으로 샤딩과 레플리케이션을 모두 지원하기 때문에 대규모 분산 시스템에서 적합합니다. MongoDB의 샤딩은 개발자가 어떻게 개발하느냐에 따라서 Range Sharding, Hash Sharding, Geo-Based Sharding 등 다양한 샤딩 기법을 사용할 수 있습니다.

• 자동 인덱스, 복합 인덱스 : MongoDB는 기본적으로 PK에 해당하는 _id에 자동으로 인덱스가 설정됩니다. 또한, 다른 필드에 인덱스를 설정하고 싶다면 복합 인덱스를 사용하면 됩니다.

• 개발자 친화적인 생태계 : RDBMS의 ORM처럼 MongoDB도 ODM (Object Document Mapping) 이라고 불리는 Mongoose, PyMongo등을 지원합니다.

• 클라우드 서비스 지원 : AWS, Azure, GCP에서 모두 사용이 가능한 완전 관리형 클라우드 서비스인 Atlas를 지원합니다. 자동 백업, 모니터링, 확정성을 책임져주죠.

이게 뭐.. 대순가?

지원하는게 많긴 한데 처음엔 이게 뭐 대수인가..? 싶긴 했습니다. 왜냐하면 스키마리스라는 특징 말고는 RDBMS에서 다 보던 특징들이기 때문이죠. 근데 이건 RDBMS와 비교해서 그런것이지 다른 NoSQL 데이터베이스들과 비교하면 MongoDB는 팔방미인입니다.

이는 MongoDB와 다른 대표적인 NoSQL을 비교한 표입니다. 보면 NoSQL 중에서는 MongoDB가 가장 선녀입니다. 모든 것을 다 지원해주고 있죠.

마치며

요즘 RDBMS도 샤딩을 지원하고 있어서 샤딩이 NoSQL만의 전유물이 아니게 되어버린만큼 MongoDB가 특별히 RDBMS에 비해 뛰어나다! 이런 얘기를 하기엔 어려울 것 같습니다.

하지만 제가 생각하는 MongoDB를 사용하면 좋은 점은 규모가 있는 MSA에서 MongoDB를 올려두고 사용하기 좋을 것 같다는 점입니다. RDBMS는 그 특징이 거대한 상태 덩어리인만큼 아직까지는 쿠버네티스 클러스터에 RDBMS를 올리기 부담스러워하는 개발자분들이 많습니다만, MongoDB를 올려서 쓰는 것에 대해서는 부담스럽지 않게 사용하시곤 합니다.

또한, 빠른 개발이 필요한 상황에서는 MongoDB가 좋은 선택이 될 수 있어 팔방미인 NoSQL로 남아있는 것이 아닐까하는 생각이 듭니다.

오늘 포스팅도 여기서 마쳐보도록 하겠습니다. 오늘도 즐거운 하루 되세요!

NoSQL의 두 얼굴 : CP 데이터베이스와 AP 데이터베이스

이번 포스팅에서는 CAP정리를 간단하게 복기해보고 CP, AP 데이터베이스의 특징에 대해서 정리해보도록 하겠습니다.

CAP 정리

CAP 정리는 2000년에 Brewer가 발표한 이론으로서 "분산 시스템에서 C, A, P를 모두 만족하는 데이터베이스는 없다" 라는 이론입니다. 이 이론에 대한 증명이 끝났기 때문에 더이상 이론이 아니라 정리라고 불러야하는 것이죠.

CAP 정리에 대해 포스팅한 것이 있어서 그걸 좀 가져와봤습니다.

https://coding-review.tistory.com/312

여기에 CAP 정리와 한계, 그리고 그것을 보완한 PACELC 정리도 함께 정리되어있으니 위의 글을 참고해주시면 좋겠습니다! 이번 포스팅에선 CAP 정리에 대한 내용보다는 CP데이터베이스, AP데이터베이스들의 종류와 특징에 대해서 다룰 예정입니다.

때문에 CAP 정리에 대한 사전지식이 있다는 가정 하에 적는 글이니 그 점 참고해주시면 감사하겠습니다.

CP / AP 데이터베이스

분산 데이터베이스는 기본적으로 P를 만족하고 P인 상황 즉, 장애 상황에서 C와 A를 모두 만족할 수 없습니다. 둘 중 하나를 포기해야하죠.

물론 그렇다고 CP, AP로 딱딱 나눌 수는 없습니다. 왜냐하면 대표적인 CP 데이터베이스인 HBase는 장애 상황에서 A를 포기하지만 대표적인 AP 데이터베이스인 Cassandra는 장애 상황이던 아니던 C를 포기하도록 되어있기 때문이죠.

그래서 제가 앞으로 표현할 CP, AP 데이터베이스는 편의상 그렇게 부르는 것이니 이 점 또한 참고해주시면 감사하겠습니다.

CP 데이터베이스

CP 데이터베이스는 장애 상황에서 A 즉, 가용성을 포기한 데이터베이스 모델입니다. 가용성을 포기한다는 말은 장애 상황에서 동작하지 않는 노드들도 존재한다는 말이고 그만큼 C (일관성) 를 중요하게 생각한다는 뜻입니다.

그런 의미에서 모든 데이터베이스는 상태를 가지고 있지만 굳이 따지자면 조금 더 stateful 한 쪽으로 기울어져 있는 데이터베이스들입니다.

이제 앞으로 CP 데이터베이스들을 소개하겠지만 CP 데이터베이스들은 앞서 언급했다시피 일관성을 위한 상태를 더 소중히 (?) 생각하는 데이터베이스들입니다. 각 데이터베이스들이 왜 장애상황에서 어찌보면 데이터베이스의 중요한 덕목 중 하나인 A를 포기하게 되었는지 이해하게 되실겁니다.

AP 데이터베이스

AP 데이터베이스들은 장애 상황에서 C 즉, 일관성을 포기한 데이터베이스 모델입니다. 일관성을 포기한다는 말은 장애 상황에서 데이터의 정합성이 깨지는 상황이 존재한다는 것이고 그만큼 A (가용성) 를 중요하게 생각한다는 뜻입니다.

CP 데이터베이스와는 다르게 그런 의미에서 stateless에 조금 더 가깝다고 할 수 있는 데이터베이스들입니다.

CP 데이터베이스와 마찬가지로 앞으로 소개할 AP 데이터베이스를 보면 이 친구들이 데이터베이스에서 중요한 덕목 중 하나인 C를 포기하게 되었는지 이해하게 되실겁니다.

CP 데이터베이스

CP 데이터베이스들의 중요한 특징은 장애 상황에서 C, 일관성을 중요하게 생각하는 데이터베이스들입니다. 그래서 이 CP 데이터베이스를 보실 때 "아, 가용성보다는 일관성이 더 중요한 서비스에서 잘 어울리겠구나" 하는 마음으로 봐주시면 좋을 것 같습니다.

MongoDB

MongoDB는 대표적인 CP 데이터베이스입니다. 보통 MongoDB가 여러군데에서 쓰이지만 주로 웹 애플리케이션의 데이터베이스로서 사용되기 때문에 그런건가? 싶은 생각이 들었습니다.

MongoDB가 NoSQL의 사파같은 느낌이라 RDBMS에서 제공해주는 다양한 개념들을 차용하는만큼 일관성을 중요하게 생각하고 있습니다. 그 중에서도 트랜잭션을 지원하는 몇 안되는 NoSQL이니만큼 확장성이 매우 중요하고 원자성이 어느정도 보장되는 애플리케이션에서 주로 사용됩니다.

HBase

HBase는 HDFS위에서 동작하는 데이터베이스로 NoSQL에선 꽤 흔한 컬럼형 데이터베이스이고 각 Row에는 키만 가지고 있는 형태를 띄고 있습니다.

때문에 키가 새롭게 삽입되면 데이터베이스 노드에 해당하는 Region이 늘어나게 되고 어느정도 늘어나면 오토 샤딩이 되어 Region Server의 관리를 받게 됩니다.

또 HBase의 특징이라면 Column Family라는 개념을 가지고 있는데 이것이 물리적으로 데이터들을 가까운곳에 위치시켜서 같은 Column Family인 경우 I / O 성능을 대폭 향상시키고 있습니다.

레플리케이션과 샤딩에 능해 주로 빅데이터 분석에 사용되는 데이터베이스입니다.

저는 빅데이터..? 라고 했을 때 곧 바로 떠오르는 것이 "가용성은 별로 중요하지 않겠는데..?" 였습니다.

빅데이터를 담아야하는데 서비스가 안정적으로 돌아가야하는 것이 아니고 오히려 일관성이 매우 중요해지기 때문이죠. 데이터를 분석하려면 일관적이긴 해야하니까요.

ZooKeeper

ZooKeeper는 우리가 흔히 생각하는 데이터베이스라고 부르기엔 애매하지만 그래도 Elasticsearch와 같이 NoSQL로 분류되고 그렇게 불려지고 있으니 넣어봤습니다.

ZooKeeper는 서비스 디스커버리라고도 불리는데 우리가 사용하는 애플리케이션의 상태를 지속적으로 체크하고 알려주는 역할로 자주 쓰입니다. 위에서 언급한 HBase와 같은 재단인 Apache 재단에서 만든 서비스여서 HBase는 기본적으로 ZooKeeper가 내장되어있죠.

ZooKeeper는 이런 특징 때문에 가용성보단 일관성이 더 중요해졌습니다. 서비스 하나하나의 상태를 보관하고 있어야하고 안정적으로 ZooKeeper가 동작하는 것도 중요하지만 네트워크 파티션 상황 (장애 상황) 에서 이런 서비스들의 상태를 일관적으로 보관해야하기 때문이죠.

대표적인 CP 데이터베이스 세개를 정리해봤는데 이 세개의 데이터베이스가 가용성이 필요없는 경우도 있었고 가용성보단 일관성이 더 중요한 경우도 있었습니다. 물론 MongoDB처럼 애매한 경우가 있긴 했지만 MongoDB 개발진들이 A를 포기하도록 선택했다는 것이니까 나름의 이유가 있었겠죠.

다음으로 살펴볼 데이터베이스들은 AP입니다. 이전 CP와 다른 관전 포인트는 C가 필요없거나 C보단 A가 더 중요하거나 이런 경우에 사용되겠구나 하고 봐주시면 좋을 것 같습니다.

AP 데이터베이스

Cassandra

Cassandra는 대표적인 AP 데이터베이스로 이 친구는 장애 상황이던 정상 상황이던 C를 포기하는 상남자식 데이터베이스입니다. 이렇게 선택하게 된 배경에는 Cassandra의 정신나간 가용성을 보면 좋을 것 같은데요.

Cassandra는 수평 확장으로 P2P를 선택했습니다. 이게 무슨말이냐하면 복제를 하지 않고 어느 노드에 붙더라도 전혀 다른 데이터베이스처럼 사용할 수 있다는 것입니다.

엄청난 트레이드오프인데 일관성이란 개나줘버린 이런 데이터베이스를 어디에 쓸까요?

특징을 떠올려보면

• 확장성이 매우 중요하다

• 일관성이 필요없다

바로 SNS입니다.

SNS에서는 일관성이란게 딱히 필요가 없고 대신 엄청난 확장성이 필요한 애플리케이션입니다. 왜냐하면 내 피드에 뜨는 게시글이랑 다른 사람 피드에 뜨는 게시글이 달라도 전혀 문제되지 않고 오히려 같은게 이상한 상황입니다.

또한, SNS에선 업로드되는 그 엄청난 수의 게시글과 댓글들을 생각해보면 확장성이 매우 중요하다고 볼 수 있죠.

DynamoDB

DynamoDB는 AWS에서 제공해주는 Key-Value 데이터베이스입니다. 이 친구는 이해하는데 어렵지 않은게 클라우드에서 운영된다는게 핵심입니다.

클라우드에서 제공해주는 서비스들을 보면 하나같이 가용성에 미쳐있습니다. 서비스가 다운되는 꼴을 못보죠.

AWS만 하더라도 ASG, EKS, Lambda, SQS, SNS 그리고 각종 데이터베이스들 (Elasticache, Aurora, DynamoDB) 이 친구들 가용성에 목숨을 건 친구들이죠.

그래서 일관성은 잠시 버려두고 돌아가는 것이 핵심인 데이터베이스입니다.

대표적인 AP 데이터베이스인 Cassandra와 DynamoDB를 소개해드렸는데요. 이 친구들 역시 앞에서 예상했던대로 C가 딱히 필요없거나 C보단 A가 더 중요했습니다.

만약 NoSQL을 선택할 때 우리 서비스가 어떤 것에 초점을 두었는지 잘 고려하면서 선택하시면 좋을 것 같습니다.

마치며

이렇게 대표적인 CP 데이터베이스와 AP 데이터베이스를 소개했습니다. 제가 데이터베이스 카테고리의 포스팅을 작성할 때 너무 RDBMS에 치우쳐져있다고 생각해서 이번 기회에 NoSQL에 대해서 좀 공부를 해보려고합니다.

일단은 개요정도만 공부했는데 실습도 해보면서 샤딩도 직접 해보고 어떤 부분이 RDBMS와 다르고 어떻게 운용하는게 좋은지 생각해보면서 공부해보면 좋은 시간이 될 것 같습니다.

오늘 포스팅은 여기서 마치도록 하겠습니다. 오늘도 즐거운 하루 되세요~

PostgreSQL의 1순위 장애 이유 Auto Vacuum Bloating

PostgreSQL은 이제 명실상부 RDBMS의 대표주자로서 자리를 잡았습니다. 다양한 인덱스 설정으로 RDBMS 중에서도 읽기 성능은 상위권에 있으면서, 개발자가 원한다면 CP 데이터베이스 AP 데이터베이스로 스왑이 가능하고, 오픈소스라는 특징 때문에 엄청나게 방대한 커뮤니티를 가지고 있다는 것이 바로 그 이유이죠.

이런 팔방미인 PostgreSQL에게 약점이 있으니 바로 Vacuum입니다. PostgreSQL은 튜닝할 수 있는 설정이 굉장히 많아서 잘만 사용하면 뛰어난 성능의 데이터베이스로 만들 수 있지만 자칫 잘못 사용하면 겉잡을 수 없이 커지는 장애가 발생하게 되는데요.

오늘 포스팅에서는 PostgreSQL의 1순위 장애 이유인 Auto Vacuum Bloating에 대해서 공부해본 내용을 정리해보도록 하겠습니다.

Vacuum

Auto Vacuum Bloating에 들어가기 전에 Vacuum에 대해서 먼저 짚고 넘어가겠습니다.

Vacuum은 PostgreSQL이 가지는 독특한 특징 중 하나인데요. Oracle과 MySQL이 Read Committed 격리 수준에서 발생하는 Non Repeatable Read 부정합 문제를 해결하기 위해 Undo 테이블을 도입한 것과 달리 PostgreSQL은 튜플을 가장 오래 잡고있는 트랜잭션 ID를 나타내는 Xmax와 가장 최근에 잡고있던 트랜잭션 ID인 Xmin을 이용해서 MVCC를 구현했습니다.

이 과정에서 트랜잭션 ID가 Xmax, Xmin에 기록되는데 트랜잭션은 시작과 끝이 있기에 끝나면 Xmax, Xmin에 적혀있는 데이터가 의미없는 쓰레기 데이터가 되고 이 쓰레기 데이터를 청소해주기 위해 Vacuum이 동작하게됩니다.

PostgreSQL은 Oracle이나 MySQL과 같이 Undo 테이블로 MVCC를 관리하는게 마음에 안들었나봅니다. Undo 테이블로 MVCC를 관리하겠다는 말은 데이터 정합성을 위해 락킹 매커니즘이 실행될 때 테이블 락이 걸려 전체적인 성능이 떨어지게 됩니다. 그래서 PostgreSQL은 테이블 락이 아닌 Vacuum을 이용한 행간 락이기 때문에 성능상 큰 이점을 가져올 수 있었습니다.

하지만 사이드 이펙트가 없는 완벽한 기술은 없다는 것을 또 한번 증명해버립니다. PostgreSQL이 Vacuum을 이용해 성능을 잡았지만 아이러니하게도 이 Vacuum을 제대로 다루지 못하면 성능이 떨어지게 됩니다.

이게 바로 Auto Vacuum Bloating을 유발하는 문제이고 다음 챕터에서 본격적으로 서술해보겠습니다.

Auto Vacuum Bloating

Auto Vacuum이란 Vacuum이 특정 주기를 가지고 트랜잭션 ID들을 청소하는 것을 말합니다. 쉽게 생각해서 다른 고수준 언어들의 GC (Garbage Collector) 와 비슷하다고 생각하시면 됩니다.

Auto Vacuum Bloating을 CRUD에서 CUD의 관점에서와 R의 관점, 이렇게 두개의 관점으로 바라보고 정리해보겠습니다.

Exclusive Lock, Shared Lock

트랜잭션, 특히 RDBMS의 트랜잭션은 ACID로 강한 일관성을 보장하는데요. 여기서 C에 해당하는 Consistency를 보장하기 위해 다양한 락킹 매커니즘을 사용하게 됩니다.

트랜잭션 A가 쓰고 있는 데이터를 트랜잭션 B가 마음대로 접근해서 값을 변경하면 안되니까요. RDBMS에는 전통적으로 Exclusive Lock과 Shared Lock으로 행간 락을 걸어서 데이터의 일관성을 지켜냅니다.

Exclusive Lock은 주로 쓰기 작업에서 어떤 데이터에 '독점적인' 락을 부여해서 해당 데이터를 쓸 수 없게 하는 Lock이고 Shared Lock은 주로 읽기 작업에서 작동하는 Lock입니다.

데이터를 쓸 때는 락을 부여한다는게 납득이 가지만 읽을 때도 굳이 락을 획득해야하는 이유가 뭘까요? 그건 단순히 Shared Lock은 '읽을 수 없다!'라는 느낌보단 '내가 읽고 있을 때 아무도 건드리지마!'에 가깝습니다.

왜냐하면 읽고 있을 때 누가 데이터를 변경해버리면 내가 읽는 작업 뒤에 있을 데이터 변경에 대해서 일관성이 깨질 수도 있으니까요.

이제 Exclusive Lock은 X Lock, Shared Lock은 S Lock라는 약칭으로 부르도록 하겠습니다.

Create, Update, Delete

쓰기 작업을 할 때는 X Lock을 획득해야하고 이 과정에서 트랜잭션은 이 락이 해제될 때까지 작업을 기다려야합니다. 이 작업이 늘어지게 되면 Vacuum이 제때 쓰레기 데이터들을 지워주지 못해 데이터가 쌓이고 쌓여 결국 폭발하게 되는 것입니다.

보통 배치 작업을 진행할 때 CUD 쿼리가 많이 발생하게 되어 이런 상황이 발생하곤합니다. 일반적인 상황에선 읽기 쿼리가 90퍼센트 가깝기 때문에 자주 발생하는 문제는 아니지만 하루에 한번 혹은 한달에 한번 발생하는 배치 쿼리에서 문제가 발생할 여지가 있습니다.

이를 해결하기 위해서 배치 작업을 돌릴 땐 작은 청크 단위로 꼬박꼬박 커밋을 해주면서 Long Term 트랜잭션이 발생하지 않도록 관리해주는 것이 핵심입니다.

Read

Read에선 중첩 트랜잭션과 MultiXAct를 기준으로 Auto Vacuum Bloating을 설명할 수 있습니다. 이에 대한 자세한 내용은 아래의 링크에 정리되어있으니 참고해주세요!

https://coding-review.tistory.com/519

간단하게 다시 정리하자면 MultiXAct는 멀티 트랜잭션 상황에서 트랜잭션 ID를 관리하기 위한 자료구조로써 S Lock을 관리할 때 어떤 트랜잭션이 어떤 행에 묶여있는지를 나타냅니다.

보통 Map<String, List<String>> 의 형태를 띄고 있으며 앞에 String에는 행에 대한 정보가 들어가있고 List에는 트랜잭션에 대한 정보가 있어서 어떤 행에 어떤 트랜잭션이 묶여있는지 직관적으로 알 수 있습니다.

하지만 이런 S Lock이 늘어나게 되면 MultiXAct가 늘어나게 되는데 이 때 상황을 더 악화시키는 것이 바로 중첩 트랜잭션입니다.

트랜잭션이라는 작업 자체만으로도 Vacuum 입장에선 난감해집니다. 왜냐하면 트랜잭션이 걸리게 되면 Vacuum은 어떤 데이터는 지우면 안되고, 어떤 데이터는 롤백할 대상이고, 어떤 데이터는 그대로 가게 두어야하기 떄문에 굉장히 복잡해지는 것이죠.

이 상황에서 트랜잭션 안에서 또 트랜잭션을 열어버리면 Vacuum은 더 혼란에 빠지게 되고 MultiXAct가 쌓이게됩니다. 이런 과정에서 Auto Vacuum이 정상적으로 동작하지 않게 되고 데이터가 쌓이면 I/O 오버헤드가 발생하고 성능이 떨어지게 됩니다.

하지만 PostgreSQL 진영에서도 이러한 문제를 파악하고 MultiXAct Wraparound 라는 개념으로 이를 해결하고자 노력하고 있는데요.

PostgreSQL은 MultiXAct를 unsigned integer로 2의 32승 빼기 1만큼 즉, 43억 비트 (= 530MB) 의 데이터를 저장할 수 있습니다. 개발자가 autovacuum_multixact_freeze_max_age 라는 설정을 조절하면 Auto Vacuum에서 강제 Vacuum으로 넘어가게 만들 수 있는 것인데요.

예를 들어서 50MB로 설정했다면 MultiXAct가 50MB를 넘어가면 바로 Vacuum을 진행하는 것이죠.

만약 Auto Vacuum으로 해결하지 못한다면 강제로 읽기 전용 모드로 전환하거나 심한 경우 PostgreSQL의 프로세스를 종료시켜 데이터 일관성을 지킵니다.

즉, PostgreSQL은 장애상황에서 일관성을 반드시 지키려고 하는 쪽이고 CAP 정리에 따르면 CP 데이터베이스가 되는 것입니다.

보통의 RDBMS는 강한 일관성이 장점이기에 기본적으로 CP 데이터베이스의 형태를 띄고 있죠.

해결책

Auto Vacuum Bloating이 뭔지는 대강 알게 되었고 이제 이를 해결해야겠죠? 크게 두 가지 방향성이 있을 것 같은데요. 한번 살펴보죠!

Auto Vacuum 민감도를 높이기

기본적으로 PostgreSQL은 Auto Vacuum에 대해 굉장히 보수적입니다. 왜냐하면 앞서 설명드렸다시피 PostgreSQL은 성능에 진심이기에 부하를 최소화하기 위해 보수적으로 동작하는 것이죠.

실서비스에선 PostgreSQL의 튜닝이 자유롭다는 장점을 십분 활용하여 Auto Vacuum Bloating을 방지해야합니다.

파라미터 | 의미 | GPT 추천

autovacuum_vacuum_threshold | 몇 건 이상 쓰레기 데이터가 생겨야 Vacuum을 작동시킬지 | 디폴트는 50이고 이를 낮춰서 튜닝한다

autovacuum_vacuum_scale_factor | 테이블 크기에 대한 비율로서 테이블 크기가 크면 더 자주 작동하게 할 수 있습니다. | 디폴트는 0.2이고 0.05~0.02로 낮춘다

autovacuum_vacuum_cost_limit | Auto Vacuum 시 한 번에 허용하는 I/O | 디폴트는 200이고 1000이상으로 높인다

autovacuum_vacuum_cost_delay | Auto Vacuum 작업 후 쉬는 시간 (ms) | 디폴트는 20ms이고 0으로 줄이거나 5ms로 줄인다.

이렇게 설정을 해주면 Auto Vacuum Bloating을 미연에 방지할 수 있게 됩니다.

중첩 트랜잭션 줄이기

프로그래밍이 고수준으로 가면 갈수록 성능이 나빠진다는 사실은 누구나 알고 있는 사실입니다. 사실 중첩 트랜잭션은 요즘 프레임워크를 사용하다보면 숨쉬듯이 자주 나오게 되는 것 중에 하나입니다.

앞서 언급했듯이 트랜잭션을 열고 그 안에서 트랜잭션을 또 진행하게 되면 Auto Vacuum Bloating이 자주 발생하게 되어 중첩 트랜잭션을 줄이는 방향으로 프로그래밍 해야합니다.

사실 중첩 트랜잭션을 사용하는게 코딩 자체는 굉장히 편해집니다. 사실 별거 생각할거 없이 논리적으로만 작성하면 되기에 크게 어렵지 않거든요.

하지만 이렇게 중첩 트랜잭션을 많이 열어버리면 Auto Vacuum Bloating을 많이 유발할 수 있어 너무 고수준으로 프로그래밍하지 않도록 주의해야할 것 같습니다.

마치며

PostgreSQL은 잘 사용하면 정말 멋진 무기가 되지만 자칫 잘못 사용하게 된다면 큰 장애로 이어지는 양날의 검인 것 같습니다. 하지만 그만큼 개발자가 자체적으로 튜닝할 수 있는 수 많은 여건들 덕분에 지금의 PostgreSQL이 큰 사랑을 받고 있는 것이겠지요.

이번 포스팅에선 PostgreSQL의 장애 요인 1순위 Auto Vacuum Bloating에 대해서 공부해보고 정리해봤습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

깔끔쟁이 MySQL (InnoDB) vs 게으름뱅이 PostgreSQL

데이터베이스에서 가장 중요한 작업은 뭘까요? Create? Read? 어느것 하나 중요하지 않은게 없죠.

그럼 데이터베이스에서 가장 리소스가 많이 드는 작업은 뭘까요? HDD에선 헤드를 여러번 움직여야하고 SSD에선 erase 후에 write 를 해야하는 CUD가 아닐까 싶습니다.

이번 포스팅에선 데이터베이스에서 리소스가 가장 많이 드는 작업인 CUD를 데이터베이스들이 어떻게 최적화하는지에 대해서 공부해본 내용을 정리해볼까합니다.

Dirty Page

데이터베이스는 삽입, 변경, 삭제 쿼리 (이하 쓰기 쿼리) 가 들어오면 곧장 이를 물리적인 저장소에 저장하는 것이 아니라 Page라고 부르는 메모리에 저장해뒀다가 한번에 flush라는 명령어를 통해 밀어넣습니다.

이렇게 쿼리가 실행되고 메모리에 올라와있지만 아직 물리 저장소엔 적용되지 않은 Page를 Dirty Page라고 부릅니다. 이 Dirty Page를 한번에 flush를 하는 이유는 리소스가 많이 드는 쓰기 작업을 한번에 처리함으로써 쓰기 작업을 최적화해야하기 때문입니다.

이번에 포스팅할 주된 내용이 바로 이 flush에 대한 내용입니다. Dirty Page, 말 그대로 더러워진 페이지를 얼마나 깔끔하게 청소하는지에 대한 내용이거든요. 다다음 섹션에서 MySQL, MariaDB 진영인 InnoDB 스토리지 엔진과 PostgreSQL이 어떻게 flush를 하는지에 대해서 자세히 다뤄보도록 하겠습니다.

Checkpoint

RDBMS에는 서로 다른 트랜잭션이 충돌했을 때 먼저 도착한 쪽은 정상적으로 진행시키고 충돌된 트랜잭션은 롤백을 시켰다가 진행시킨 트랜잭션이 성공하면 그 때 재실행을 위한 로직이 포함되어있습니다.

그런데! 어디까지 롤백하고 어디서부터 재실행할건지에 대한 정보가 있어야 재실행을 하던지 말던지 하겠죠? 그 정보가 바로 Checkpoint입니다.

여기서 중요하게 등장하는 개념이 바로 LSN인데요. LSN은 쉽게 얘기해서 트랜잭션의 실행 순번이고 트랜잭션이 커밋될 때마다 하나씩 증가합니다.

이 Checkpoint라는 개념은 추후 소개할 내용에서 이해하는데 중요한 개념이니 앞으로 나올 내용에서 계속 상기해야할 내용입니다.

깔끔쟁이 MySQL (InnoDB)

InnoDB가 왜 깔끔쟁이냐하면 flush 속도를 동적으로 계산해서 Dirty Page가 쌓이면 flush 속도를 빠르게 조정하고 덜 쌓이면 flush 속도를 느리게 조정하고 이런식으로 Dirty Page가 쌓여있지 않게 지속적으로 청소를 해주기 때문입니다.

그럼 InnoDB가 어떤 기준으로 flush 속도를 조정하는지에 대해서 봐야겠죠

Redo Log

결론을 먼저 말하자면 Redo Log에서 측정한 Checkpoint Age라는 것을 기준으로 flush의 속도를 조절합니다. 물론 Checkpoint Age값만 가지고 속도를 조절하는건 아니지만 이 값이 중요하기 때문에 다른 값들은 넘어가도록 하겠습니다.

Checkpoint Age란 Redo Log에서 현재 LSN과 물리 저장소에 flush된 LSN의 차이를 비교한 것 입니다. Redo Log는 순환형 버퍼이고 현재 LSN을 Head, 물리 저장소에 flush된 LSN은 Tail이라고 부릅니다.

이를 그림으로 나타내면 다음과 같습니다.

이 Head와 Tail의 간격을 Checkpoint Age라고 부릅니다. Checkpoint Age의 크기가 전체 중 몇 퍼센트냐에 따라 flush 속도를 동적으로 조절하게 됩니다.

여기서 의문이 들더군요.

Tail을 앞으로 땡기는 트리거가 뭘까?

우선 Tail을 앞으로 땡기는 트리거는 Redo 로그를 물리 저장소에 적었다는 신호인 log_io_complete를 보고 Tail을 앞으로 땡기게 됩니다. 연속적으로 드는 의문은 왜 flush할 때가 아니라 I/O가 끝났을 때를 기준으로 Tail을 땡길까? 였습니다.

I/O가 끝났을 때 Tail을 땡기는 이유는 아무리 Dirty Page를 flush해서 물리 저장소에 저장했다고 하더라도 Redo Log가 정상적으로 저장되지 않으면 추후에 장애로 회복해야할 때 혹은 트랜잭션 충돌로 롤백하고 회복해야할 때 Redo Log를 기준으로 진행하기 때문입니다.

flush가 아무리 되었다고 하더라도 Redo Log가 저장되지 않으면 아무 의미 없다는 것입니다. Redo Log의 의미는 "이 시점까지는 복구할 수 있다" 라는 의미이므로 Redo Log를 기준으로 Tail을 앞으로 땡기는 것이죠.

이렇게 flush를 동적으로 조절하면 어떤 이득이 있길래 이런 정책을 채택한 것일까요?

Checkpoint Storm

Dirty Page를 제때제때 치워주지 않으면 flush를 한번 할 때 엄청나게 많은 양을 물리 저장소에 써야하고 리소스가 많이 드는 쓰기 작업은 곧 I/O 오버헤드를 일으키고 이것이 CPU 오버헤드를 야기하면서 서버 자체의 성능을 떨어뜨리게 됩니다.

깔끔쟁이인 InnoDB는 지속적으로 Checkpoint Age를 계산하면서 flush 속도를 동적으로 조절해 Dirty Page를 제때제때 물리 저장소에 밀어 넣어 Checkpoint Storm이 발생하지 않게 노력하는 것이죠.

그럼 PostgreSQL은 어떨까요?

게으름뱅이 PostgreSQL

PostgreSQL은 InnoDB처럼 동적으로 flush속도를 조절하는 기능은 존재하지 않습니다. 대신 PostgreSQL은 Checkpoint Timeout과 WAL의 크기에 따라 flush를 호출하는 식으로 Dirty Page를 청소합니다.

즉, 이런 것이죠. Checkpoint Timeout이 5분이면 5분동안 Dirty Page를 쌓아두고 있다가 5분이 지나면 자동으로 flush를 진행시키는 것입니다. 혹은, WAL의 크기가 특정 크기를 넘어가면 flush를 하는 것이죠.

그럼 갑자기 쓰기 작업이 많이 요청되었고 5분동안 Dirty Page가 엄청나게 많이 쌓이면 어떡하죠?

뭐.. 그럼 Checkpoint Storm이 생기는죠..

하지만 PostgreSQL도 이런 문제에 대해 인지하고 Checkpoint Storm을 대비하기위해 completion target이라는 개념을 도입합니다.

completion target이라는 개념은 특정 시간동안 flush를 일정하게 나눠서 처리하겠다는 의미로 예를 들어 Checkpoint Timeout이 5분이고 completion target의 0.5라면 (기본값) 2분30초동안 flush를 일정하게 나눠서 처리하겠다는 의미입니다.

이에 대한 공식은 다음과 같습니다.

Dirty Page의 수 / Checkpoint Timeout * Completion Target

예를 들어 Checkpoint Timeout이 5분이고 completion target이 0.5, Dirty Page의 수가 1만개라면

2분30초동안 초당 67개정도 되는 Dirty Page를 처리하는 것입니다. 이렇게 I/O를 분산해서 처리하면 Checkpoint Storm이 발생하지 않게 되는 것이죠.

그럼 여기서 의문이 들더군요. 그럼 2분30초동안 1만개를 처리하는데 나머지 2분30초동안 또 몇천개가 쌓여있으면 그 몇천개를 한번에 flush해야하는건 똑같지 않나? 라는 의문이요.

그래서 completion target을 0.8이나 0.9까지 올리는 것이 일반적이라고 합니다. Checkpoint Timeout이 5분인 경우 0.9라면 4분30초동안 나눠서 처리하고 30초동안 쌓이는 것은 한번에 flush 하는 것이죠.

하지만! 또 다시 의문이 들었습니다.

completion target으로인해 계산한 수치는 맨처음인데 만약 그 이후에 그 수치를 상회하는 쓰기 작업이 들어오게 되면 completion target이 0.9인 경우 마지막 30초에 한번에 flush해야하면 Checkpoint Storm이 발생할 수 있는 것이 아닌가 하는 의문이요.

이를 통해 저는 "아, Checkpoint Storm이 사실 실환경에선 큰 문제는 아니구나" 라는 결론에 도달했습니다.

왜냐하면 진짜 이 completion target으로도 해결할 수 없다면 PostgreSQL진영에서 InnoDB처럼 adaptive flush를 적용했거나 PostgreSQL을 사용하는 많은 개발자들이 InnoDB로 넘어갔을 것이기 때문이죠.

실제로 PostgreSQL에서 장애를 유발하는 것들 중엔 Checkpoint Storm이 최상위에 위치해있지는 않습니다.

대부분의 것들은 completion target으로 커버가 가능하고 이를 상회하는 쓰기 작업은 실환경에서 성능에 큰 영향을 미치지 않는다는 것이죠.

마치며

이번 포스팅에선 깔끔쟁이 MySQL과 게으름뱅이 PostgreSQL이라는 주제로 Checkpoint Storm에 대한 내용을 공부해보고 정리해봤습니다. 사실 PostgreSQL이 게으름뱅이 정도는 아닌 것 같긴 한데 워낙 깔끔쟁이인 InnoDB 때문에 제목을 한번 자극적으로 지어봤습니다.

데이터베이스 관련 이론 공부를 할 때면 너무 깊이있게 들어가지 않으려고 노력하는 편인데 요즘 백엔드 개발자와 DBA사이에 있는 무언가를 공부하는 느낌이라 조금 주의하고 있습니다.

제 목표에 DBA는 없기 때문에 적당히 개요부분만 알고 넘어가는 방향으로 공부하고있습니다. 사실 관련 문서를 찾아보면서 어떤 속성값을 어떻게 조절하면 어떤 효과가 있다와 같은 것들이 나오는데 너무 지엽적이라서 조금 당황스러웠습니다.

그래도 데이터베이스 관련된 공부는 언제나 재밌네요. 다음 포스팅에서도 재밌게 공부하고 정리해보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

전통적인 ACID와 분산 시스템에서의 ACID

데이터베이스 포스팅이 벌써 50개를 넘어섰는데요. 단일 카테고리로는 두번째 양인데요. 첫번째는 Spring인데 조금 허수가 있는게 영한님 영상을 보면서 복사 붙여넣기 했던 포스팅이 15개정도 그리고 본격적으로 다시 보면서 공부한게 또 20몇개정도 되니 허수가 있죠.

하지만 데이터베이스 카테고리는 그런 허수없이 50개를 넘어섰으니 정말 많이 썼습니다. 그만큼 제가 데이터베이스 공부를 정말 좋아합니다.

데이터베이스에 대한 내용을 공부하면서 트랜잭션에 대한 이야기는 정말 많이 썼는데요. "스프링 내부적인 트랜잭션 동작 방식"이나 "PostgreSQL이 @Transactional과 어울리지 않는다"나 "@Transactional로 분산 트랜잭션을 구현할 수 있을까?" 같은 내용이 바로 그러하죠.

근데 이 때 ACID를 빼놓고 이야기하기 정말 힘들어집니다. 트랜잭션과 ACID는 정말 뗄래야 뗄 수 없는 짝꿍같은 느낌이기에 이 둘을 떼고 얘기할 수 없어지는 것이죠.

서론이 길었습니다. 이런 전통적인 ACID가 분산 시스템에선 조금 달라진다는 사실, 알고 계셨나요? 물론 개념적인 부분은 똑같이 돌아가지만 전통적인 ACID는 가만히 냅둬도 알아서 잘 돌아가지만 분산 시스템에선 이 ACID를 직접 구현해줘야 한다는 것이 큰 차이입니다.

그럼 본격적으로 어떤 부분이 다른지 정리해보겠습니다.

전통적인 ACID

ACID를 하나하나 다시 상기하면서 전통적인 ACID부터 시작해보도록 하겠습니다.

• Atomicity : "어떤 작업에 대해 쿼리 하나하나에 대응되는 것이 아닌 하나의 전체적인 작업으로서 모든 작업이 모두 성공하거나 모두 실패해야한다."

원자성의 정의는 위와 같고 여기서는 쿼리 하나하나에 초점이 맞춰진 것이 아닌 쿼리가 모인 하나의 "작업"으로서의 원자적인 동작을 의미합니다.

예를 들어서 주문 -> 결제 -> 알람 이렇게 세 단계의 걸친 "작업"이 동시에 성공하던가 동시에 실패해야한다는 것이죠. 만약 성공했다면 모두 성공하고 중간에 실패했다면 모든 작업이 실패해야한다는 것입니다.

이런 상황을 대비해 대부분의 프레임워크에선 트랜잭션에 대한 처리를 도와주고 있고 스프링에서도 @Transactional 어노테이션을 이용해서 원자성을 보장받을 수 있습니다.

• Consistency : "데이터를 조작하는 과정에서 충돌이 일어나는 경우 데이터가 꼬이지 않도록 일관성을 보장해야한다"

일관성의 초점은 데이터를 조작하는 "쓰기" 작업에 대한 내용입니다. 만약 A트랜잭션이 1번 행에 접근해서 데이터를 가져와 가공하는 도중 B트랜잭션이 똑같이 1번 행에 접근해 데이터를 가공하려고 한다면 충돌이 일어나고 이 때 값이 꼬여 일관성이 무너질 수 있기 때문에 이것을 적절히 처리해야한다는 것이죠.

전통적인 ACID에선 PCC (Pesimistic Concurrency Control) 와 OCC (Optimistic Concurrency Control) 이렇게 두가지 방법으로 일관성을 보장합니다.

PCC는 트랜잭션이 반드시 충돌한다고 가정하기 때문에 충돌 여부를 읽기단계에서 처리하는 높은 수준의 일관성을 보장합니다. 쉽게 얘기해서 읽기 작업이 들어가면 해당 행에 락을 걸어서 뒤에 있는 트랜잭션이 접근하지 못하게 막는다는 의미입니다. 보통 높은 일관성을 유지해야하는 경우에 PCC를 사용합니다.

OCC는 이와 반대로 트랜잭션이 충돌하지 않는다고 가정하기 때문에 충돌 여부를 커밋 단계에서 처리하는 낮은 수준의 일관성입니다. 커밋 시점에 충돌 여부를 확인하기 때문에 트랜잭션이 하나의 행을 잡고 동시에 처리할 수는 있지만 커밋 단계에서 충돌이 일어나는 경우 뒤에 있는 트랜잭션에게 예외를 던지면서 작업이 실패하게 만들죠. 때문에 OCC의 경우 예외 상황에 대한 재시도 로직이 반드시 필요하다는 점이 있어 복잡도가 올라가지만 PCC에 비해 성능이 잘 나온다는 장점이 있죠.

때문에 PCC는 강한 일관성이 보장되어야하는 결제로직과 같은 중요한 연산에서 주로 사용되고 OCC의 경우 그정도의 강한 일관성은 필요 없고 성능이 더 중요한 경우 사용하기도 합니다.

• Isolation : "각각의 작업들은 서로 독립적으로 동작하는 것을 보장해야한다"

격리성은 작업들에 대해 서로 독립적으로 동작하여야하고 각자의 작업에 영향을 주어서는 안된다는 원칙인데요. 보통 데이터베이스 수준에서 격리수준을 제공하고 단계는 Read Uncommited, Read Commited, Repeatable Read, Serializable 이렇게 네 단계가 있습니다.

각각의 격리수준마다 생기는 부정합 문제가 있어 정합성을 맞추는 것이 중요하지만 뒤로 갈수록 성능이 떨어져 보통의 데이터베이스의 경우 Read Commited를 채택합니다. 하지만 InnoDB의 경우 Repeatable Read를 채택한다는 특징이 있습니다.

• Durability : "만약 작업이 완료 되었다면 그것이 설령 데이터베이스가 내려가더라도 완료된 것을 보장해야한다"

내구성이 사실 앞선 세개의 원칙에 비해 추상적이어서 이해하기 조금 까다로운 부분이 있습니다. 저도 처음에 ACID를 공부하면서도 이 내구성이 가장 이해가 안되더라구요.

내구성은 쉽게 이야기해서 작업이 완료되는 경우 그 작업이 완료된 것을 보장해야한다는 원칙입니다. 만약 데이터베이스에 쓰기 작업을 하던 도중 데이터베이스가 다운됐다고 가정해봅시다. 그럼 두개의 상황을 가정해볼 수 있습니다.

1. 데이터베이스에 쓰기 작업이 적용된 경우

2. 데이터베이스에 쓰기 작업이 적용되지 않은 경우

쓰기 작업이 적용되지 않았다는 것은 커밋이 되기 전에 데이터베이스가 내려갔다는 의미이겠죠? 그럼 애플리케이션 레벨에서 예외가 발생했을 것이고 재시도를 했을 것입니다.

쓰기 작업이 적용되는 경우 커밋이 되었다는 이야기고 커밋이 됐다는 것은 데이터베이스에 쓰기 작업이 적용되었다는 이야기입니다.

즉, 내구성의 경우 "커밋"을 기점으로 데이터베이스에 적용이 되었다는 것을 보장한다는 것입니다.

전통적인 ACID를 일부러 길게 설명했습니다. 뒤에 언급할 분산 시스템에서의 ACID는 위의 내용과 본질적으로는 같지만 복잡하기 때문에 한번 상기시키면서 정리할겸 일부러 길게 썼습니다.

이제 분산 시스템에서 ACID를 다룰텐데 선행 지식이 있는 경우 더 이해하기 편하다는 점을 말씀드리고 들어가도록 하겠습니다.

• 분산 시스템은 어떻게 생긴 놈인가?

• 카프카에 대한 기본적인 지식

• 분산 트랜잭션과 보상 트랜잭션에 대한 이해 (Saga 패턴)

분산 시스템에서의 ACID

분산 시스템에서 고려해야하는 가장 중요한 부분은 각각의 서비스들이 물리적으로 분리되어있다는 것입니다. 물리적으로 분리되어있기 때문에 원자성, 일관성을 지키기 굉장히 어려워졌고 때문에 분산 시스템에서 가장 중요하게 봐야할 ACID 속성은 A와 C입니다.

이번 섹션에선 A와 C에 초점을 두고 깊이있게 이야기해보도록 하겠습니다.

분산 시스템에서의 Atomicity, Consistency

아까의 예시를 그대로 가져와서 사용해보겠습니다. 주문 -> 결제 -> 알람 이렇게 세개의 로직을 하나의 원자적인 "작업"으로 묶어야합니다. 그런데 문제가 있습니다. 지금 분산 시스템에선 주문, 결제, 알람이 각각 다른 서버로 이루어져있습니다.

보통 이 로직의 경우 카프카를 사용해서 비동기로 처리하는데요. 만약 동기방식으로 처리했다면 물론 하나의 작업으로 묶기는 편했겠지만 지금처럼 세단계의 뎁스로 이루어져있는게 아니라 여러개의 뎁스로 이어져있는 경우 끝까지 갔다가 돌아오는 시간동안 애플리케이션이 아무것도 못하고 기다려야하기 때문에 비동기로 처리하곤합니다.

그럼 카프카를 이용해서 비동기로 처리해야하는데 비동기로 처리하겠다는 의미는 던져놓고 자기가 할일 한다는 의미잖아요? 그럼 주문 처리하고 결제로 던지고 주문 테이블에 데이터는 커밋되어 처리되죠.

이 때 카프카가 커밋된 것을 확인하고 이어서 결제 서버에 메세지를 보냈습니다. 근데 만약 카프카의 메세지가 처리되지 않는다면 어떻게 해야할까요? 메세지가 처리되지 않는다는 것은 두가지 관점에서 바라볼 수 있습니다.

• 데이터베이스에는 작업이 완료되었지만 네트워크 전송 문제로 카프카 메세지가 전송되지 않은 경우

• 데이터베이스에서 작업이 실패하여 예외가 터진 경우

1번이 Automicity와 연관있는 내용이고 2번은 Automicity, Consistency와 모두 관련있는 내용입니다.

데이터베이스 작업은 완료, 카프카는 실패

이 경우 트랜잭션의 실패로 롤백이 되어야하는 경우는 아닙니다. 데이터베이스는 완료되었고 뒤로 이어져야할 로직이 이어지지 않은 것이죠. 카프카에선 여러개의 메세지를 원자적으로 묶는 솔루션인 EOS를 지원하긴 하지만 조금 부족한 부분이 있습니다.

바로 데이터베이스의 연산과는 묶을 수 없다는 것입니다. 그래서 나온 개념이 바로 Outbox Pattern입니다. Outbox Pattern은 데이터베이스 연산과 카프카의 메세지를 하나의 연산으로 묶어서 둘 다 안전하게 성공하는 것을 목표로합니다.

Outbox Pattern의 흐름을 정리하면 다음과 같습니다. 이 상황은 데이터베이스 작업은 성공이고 카프카는 실패했다는 것을 가정합니다.

• 데이터베이스 연산은 성공

• Outbox 테이블에 카프카 연산에 대한 성공 실패 여부를 저장

• 정해둔 시간에 따라 지속적으로 스케줄링해서 Outbox 테이블의 내용이 실패했다면 재시도하는 로직을 추가

• 성공하면 성공을, 실패하면 실패를 적고 이후 반복

이에 대해서는 제가 쓴 포스팅 글이 있으니 해당 포스팅을 참고해주세요!

https://coding-review.tistory.com/566

물론 Outbot Pattern도 테이블에 적는 방법과 트랜잭션 로그를 추적하는 것 이렇게 두가지 방법이 있지만 일단 가장 간단한 테이블에 적는 방식을 소개했습니다.

데이터베이스 연산 자체가 실패

이 경우에는 롤백까지 처리해줘야하기 때문에 조금 복잡할 수 있습니다. 이 경우 보상 트랜잭션으로 롤백을 진행해야하기 때문에 카프카를 이용합니다.

이 경우 흐름도를 확인해보겠습니다.

• 주문 서버에서 주문을 처리하고 결제 서버로 이관

• 결제 서버에서 메세지를 받고 데이터를 처리하다가 에러 발생

• 어떤 데이터를 넣다가 실패했는지 상태에 대한 메세지를 작성해서 다시 주문 서버로 전송

• 주문 서버에서 이 값을 보고 데이터를 다시 가공

이 경우는 롤백이라고 부르기에는 조금 애매하긴 합니다. 우리가 흔히 롤백이라고 부르는건 데이터베이스가 롤백을 진행해주는 것인데 이 경우는 롤백인 것 "처럼" 보이는 것이죠.

이런 것 처럼 실패한 것에 대한 보상을 주는 것을 보상 트랜잭션이라고 하고 이를 Saga패턴중 Choreographed 방식이죠. 이것 말고 Orchestration 방식도 있지만 일단 Choreographed만 소개했습니다.

Saga패턴에 대한 자세한 내용은 아래의 포스팅에서 자세히 다루고 있으니 아래의 포스팅을 참고해주세요!

https://coding-review.tistory.com/484

그런데 뭔가 이상하죠. 그럼 어떻게 된다는 얘기인가요?

1번에서 커밋이 된 상황에서 2번이 실패하기 전에 만약 어떤 사람이 주문에 대한 정보를 열람하면 어떻게 되나요?

결국 실패 중간에 롤백되기 전에 일관성이 깨진 데이터를 볼 수 있는 가능성이 생깁니다. 하지만 결국 일관성이 맞게 되는데 실패해서 카프카에 메세지를 실어서 주문서버에 되돌려보내 롤백된 것 처럼 보이면 이내 다시 조회하면 일관성이 맞을테니까요.

그래서 이걸 BASE 속성 중 E에 해당하는 Eventual Consistency입니다. 결국 일관성이 맞는다는 이야기죠.

이렇게 분산 시스템에서는 조금 더 복잡하고 느슨한 ACID가 동작하고 이런 느슨한 ACID를 BASE라고 따로 이름붙여 부르기도 합니다.

Basically Available, Soft state, Eventual consistency 이렇게 BASE이죠.

보통 NoSQL에서 다루던 이야기이지만 NoSQL는 분산 시스템에 최적화된 데이터베이스이기 때문에 분산 시스템에서의 RDBMS도 이를 피해갈 수 없었습니다.

마치며

전통적인 ACID와 분산 시스템에서의 ACID는 뭔가 달랐습니다. 공부를 하면서 제가 공부했던 내용들을 상기하면서 쭉 정리하다보니 이런 차이가 있었고 이걸 한번 더 나만의 언어로 정리하기 위해서 포스팅을 작성했습니다.

분산 시스템에서 운영 복잡도, 개발 복잡도가 올라간다는 사실은 누구나 알고 있었지만 이렇게 체감될 정도로 올라가는게 현실을 부정하고 싶을 정도더군요.

이번 포스팅을 계기로 분산 시스템을 설계할 때는 조금 더 신중하게 설계해야할 것 같은, 그리고 개발할 때 조금 더 꼼꼼하게 개발해야할 것 같은 느낌이 들었습니다.

이번 포스팅은 여기서 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

벡터 데이터베이스 톺아보기

안녕하세요! 이번 포스팅은 오랜만에 데이터베이스와 관련된 포스팅이네요. 이번엔 제가 주로 공부하는 RDBMS가 아닌 벡터 데이터베이스에 대해서 공부해보고 정리해보는 시간을 가져볼까합니다.

sLLM을 이용해서 개발하고있지만 벡터 데이터베이스를 깊이있게 사용하지 않아서 잠시 옆으로 두고 있었는데 이제 조만간 대학원을 가야하는데 졸업 논문을 편하게 쓰려면 1학기부터 논문 주제를 슬슬 정하는게 좋겠다고 생각해서 이런저런 주제를 생각하다가 벡터 데이터베이스와 관련된 내용을 쓰면 좋겠다싶어 미리 공부할겸 제반지식을 쌓기 위해 공부하고 정리하려고 합니다.

이번 포스팅에서는 다음과 같은 순서로 진행됩니다.

• RDBMS에서의 인덱스 : 이 챕터에선 기존 RDBMS가 채택했던 인덱스 알고리즘인 B+Tree에 대해서 간단하게 리뷰해보는 시간을 가져볼까합니다.

• 벡터 데이터베이스에서의 인덱스 : 해당 챕터에서는 RDBMS에 B+Tree가 있다면 벡터 데이터베이스에는 HNSW가 있습니다. 이 HNSW가 무엇이고 어떤 점에서 RDBMS와 차별점이 있는지 알아봅니다.

• HNSW의 동작 방식 : 앞에서 다뤘던 HNSW에서 조금 더 깊이있게 탐구해보는 시간을 가질건데 여기서는 어떻게 벡터 데이터베이스가 목적지까지 이동하는지에 대한 흐름으로 이야기를 풀어볼까합니다.

• 벡터 데이터베이스의 종류와 각각의 특징들 : RDBMS도 Oracle, MySQL, MariaDB, PostgreSQL, MSSQL, SQLite 이렇게 여러개인 것과 마찬가지로 대표적으로 Pinecone, Milvus, Qdrant, Chroma, Weaviate 등이 있습니다. 각각의 벡터 데이터베이스들의 특징을 살펴보고 어떤 점에서 강점을 가지며 실무에서는 어떤 기준으로 데이터베이스를 선택해야하는지 장점과 단점의 트레이드오프 관점에서 이야기를 풀어봅니다.

• Pre-filtering vs Post-filtering : 필터링 작업은 RDBMS에서 WHERE절을 이용해서 필터링하듯이 걸러내는 작업을 할 수 있는데 검색 전에 필터링하냐 검색 후에 필터링을 하느냐에 따라서 추구하는 목표도 다르고 결과물도 완전 달라지게 되는데요 이 부분을 조금 집중적으로 다룰 예정입니다.

• 하이브리드 검색과 RRF : 전통적인 키워드 검색 방식과 벡터 기반 검색은 각각이 잘하는 분야가 완전히 다른데요. 키워드 검색과 벡터 기반 검색을 합쳤을 때의 강력함이 묻어나오는데 암호화 알고리즘인 Argon2i와 Argon2d를 합쳐 장점은 돋보이게 하고 단점을 희석시키는 Argon2id와 같은 방식으로 서로의 장점만 쏙 빼먹은 하이브리드 검색에 대해서 알아봅니다.

• Product Quantization (PQ) : 벡터 데이터베이스의 단점 중 하나는 노드들의 의미적인 연결을 메모리에 다 올리고 있어야 한다는 것인데 간단한 꼼수를 활용해 메모리를 최소 4배 최대 64배까지 최적화할 수 있는 방법론인 PQ에 대해서 알아봅니다.

• 실무 관점에서의 벡터 데이터베이스 튜닝 : 실무에서는 어떤 부분을 어떻게 조정해야 우리 서비스에 핏하게 맞는 벡터 데이터베이스를 만들 수 있을지를 고민해봅니다.

오랜만에 볼륨이 큰 포스팅으로 찾아뵙게 되었네요. 조금 무겁지만 최대한 단순하게 정리해보겠습니다.

RDBMS에서의 인덱스

RDBMS에서는 크기 기반 인덱싱을 주로 사용하는데요. 주로 사용되는 B+Tree의 경우 숫자 혹은 알파벳의 정렬 순섣재로 이진 트리 형식위에 노드를 위치시킵니다. 또한, B+Tree의 특징 덕분에 Depth가 3이상으로 가는 일이 거의 없어 RDBMS는 이 구조 덕분에 읽기 성능에 대한 성능을 보장받을 수 있게 되었습니다.

다만, 이 때문에 RDBMS에서 읽기를 할 때는 완전히 키워드가 일치하지 않으면 검색할 수 없다는 단점이 생겼는데요. WHERE절에서 정확히 맞는 문자열을 검색해야하고 이보다 조금 나은 LIKE를 사용하더라도 "의미"기반의 검색은 할 수 없게 되었습니다.

또한, 이런 인덱스 구조 때문에 쓰기 연산에서 큰 단점이 생기게 되었습니다. 쓰기 연산을 하게 되면 데이터가 이진 트리 사이를 비집고 들어와야 했기에 노드를 찢거나 다시 합치는 등의 추가 연산이 필요해 인덱스를 재정렬할 시간이 있어야했고 이는 자연스럽게 쓰기 연산에 대한 부하로 이어졌습니다.

뿐만 아니라, 일관성을 보장받아야 했기에 MVCC를 위해 데이터간 락을 걸어 쓰기 연산에 더 큰 부하가 생긴 것은 덤이었죠.

이런 결과로 읽기 연산이 압도적으로 많은 서비스나 강한 일관성이 보장받아야하는 서비스에서 RDBMS를 사용하게 되었고 쓰기 연산이 많은 SNS같은 서비스에서는 자연스럽게 NoSQL을 채택하게 되었습니다.

벡터 데이터베이스에서의 인덱스

기존에도 벡터 데이터베이스에 대한 연구와 개발이 이루어졌지만 웬만한 서비스는 RDBMS나 NoSQL로 커버가 되었기 때문에 벡터 데이터베이스는 그대로 잊혀지는 듯 했습니다. 하지만 최근 LLM의 급부상과 RAG의 도입으로 인해 벡터 데이터베이스는 날개를 달았는데요.

확률적으로 다음 단어를 생성하는 Transformer의 특성상 할루시네이션이라는 엄청난 단점이 생기게 되었고 개발자들은 이를 해결하기 위한 방법으로 벡터 데이터베이스를 선택하게 되었습니다.

기존 RDBMS와 NoSQL은 정확히 일치하는 검색만 가능했지만 LLM 서비스 특성상 사용자가 애매모호한 질의를 심지어 자연어로 날리는 경우가 많아 의미기반의 검색이 가능한 벡터 데이터베이스가 훌륭한 선택지가 되었던 것이죠. 또한, 벡터 데이터베이스를 도입한 것만으로도 LLM에게 정답지를 쥐어주는 효과가 발생해 할루시네이션을 낮출 수 있게 되었습니다.

그럼 벡터 데이터베이스는 어떻게 의미 기반의 검색이 가능했던 것일까요?

바로 HNSW (Hierarchical Network Small World) 덕분입니다.

HNSW 란?

HNSW는 NSW라는 알고리즘에서 출발합니다. NSW는 클러스터링 된 군집에서 각각의 오브젝트들과 선으로 연결해 그래프로 표현하는 알고리즘인데요. 이는 마치 Graph DB와 비슷한 맥락인데 Graph DB와 다른 점은 노드간 관계에 집중한 Graph DB와 다르게 벡터 데이터베이스는 각 노드간 물리적 근접도만을 위한 점이 가장 크게 다른 점입니다.

이는 NSW가 생성되는 과정을 보여주는 그림인데 점이 하나 찍힐 때마다 2개씩 인접 노드와 선으로 연결되는 것을 보실 수 있습니다. 그것이 NSW의 핵심이고 이 군집을 이용해서 가까운 거리를 찾아내는 것이 기본 골자입니다.

이렇게 연결된 노드들은 실제 질의와 관련된 노드와 가장 가까운 노드를 선별해서 검색할 수 있게 되었는데 시작점에서 들어와 가장 가까운 노드로 이동하는 Greedy Search를 기반으로 질문과 가까운 노드를 찾을 수 있습니다.

가까운 거리를 구하는 것은 일반적으로 하나의 노드에 연결된 모든 노드의 거리를 검색하는 것이 일반적이지만 그렇게 하면 노드의 개수가 조금만 늘어나도 바로 문제가 되기 때문에 이보다는 단계적인 레이어 계층을 두어 거리를 최소화하는 방법을 선택하는 방향으로 진화하게 되었습니다. 그것이 바로 여기에 수직 계층을 추가한 HNSW이죠.

HNSW의 동작 방식

HNSW는 스무고개와 굉장히 비슷합니다. 우선 큰 틀에서부터 점점 작은 틀로 좁혀나가 결국 원하는 목적지에 도달하는 것인점이 비슷한데 예를 들어서 "L2캐시"라고 질문이 온다면 HNSW 인덱스는 "가전제품", "PC", "CPU", "L2캐시" 이렇게 단계적으로 목적지에 찾아가는 것이죠.

벡터 데이터베이스는 클러스터링을 할 때 다음과 같은 동작 방식을 따릅니다.

• 레이어 결정 : 새로운 벡터가 들어오면 이 노드가 어느 층까지 올라갈지를 먼저 결정합니다. 이는 지수 확률 분포를 따르기 때문에 대부분의 노드는 바닥 레이어에 머물지만 아주 작은 활률로 상위 레이어까지 배치됩니다.

• 진입 및 탐색 : 레이어가 결정되면 최상위 레이어의 진입점부터 시작해서 자신이 배치될 레이어까지 Greedy Search로 내려옵니다. 여기서 Greedy Search는 현재 노드와 연결된 모든 이웃 노드들의 좌표를 가져와 이웃 누드간 거리를 코사인 유사도로 계산한 뒤 만약 이웃 중 하나라도 질문과 가깝다면 미련 없이 그 노드로 자리를 옮겨버립니다. 만약 모든 이웃을 다 뒤져봤는데 나보다 더 가까운 노드가 없다면 그 지점에서 탐색을 멈춥니다.

• 이웃 선택과 연결 : HNSW에서는 단순히 가장 가까운 M(최대 연결 수)개의 노드와 연결하는 것이 아닙니다. 벡터 데이터베이스는 다양성을 고려한 휴리스틱 알고리즘을 사용하는데, 가장 가까운 놈들끼리만 연결이 되면 특정 영역에서 노드들이 뭉쳐버리는 "클러스터 현상" 이 발생하기 때문입니다. 그렇기 때문에 일단 가장 가까운 노드를 연결하고 그 다음 노드를 연결할 때는 이미 연결된 노드보다 새로운 노드가 나와 가까운가를 체크합니다. 만약 이미 연결된 노드와 너무 가까운 방향에 있는 노드라면 버리고 조금 더 멀더라도 새로운 방향에 있는 노드를 선택해서 연결합니다. 이를 통해 고차원 공간 구석구석으로 뻗어 나가는 사방팔방의 인맥이 형성됩니다.

• 양방향 연결 및 가지치기 : 새 노드가 기존 노드에 선을 연결하면 기존 노드 입장에서는 연결된 선이 M을 초과할 수 있는데 이 경우 기존 노드도 다시 휴리스틱 알고리즘을 돌려 가장 효율적인 M개의 선만 남기고 나머지는 끊어버리는 가지치기를 수행합니다.

이런 과정을 거치면 HNSW가 동작하게 되는데 HNSW가 이런 동작 방식을 가지게 되면서 Greedy Search에서 생기는 단점이 발생하지 않게 되었습니다. Greedy Search는 가장 가까운 노드만 돌다가 결국 한 군데에서 빙빙 도는 현상이 생기게 되는데 HNSW는 이미 상위 레이어에서 질문과 가까운 답변이 있는 곳에 위치하기 때문에 Greedy Search의 단점이 발생하지 않게 되는 것이죠.

벡터 데이터베이스의 종류와 특징

기본적으로 벡터 데이터베이스들은 HNSW를 채택하고 있습니다. 다만 조금씩 성격이 다른데 한번 정리해보겠습니다.

• Pinecone : Pinecone은 인프라 설정이 전혀 필요없는 완전 관리형 SaaS입니다. 초기 구축 속도가 압도적으로 빠르며 서비스 규모에 따라 알아서 스케일링이 된다는 장점이 있습니다. 다만 추가 비용이 생기고 블랙박스 구조로 비용 예측이 어렵다는 단점이 있습니다.

• Milvus : 벡터 데이터베이스계의 쿠버네티스이고 이런 이명답게 가장 복잡하지만 가장 강력한 확장성을 가집니다. 데이터를 저장하는 노드, 쿼리를 실행하는 노드, 인덱싱을 하는 노드가 모두 분리된 분산 시스템이기 때문에 분산 처리에 특화되어있고 수평 확장에 매우 능합니다. Milvus는 엔터프라이즈급 시스템에 가장 잘 어울리는 벡터 데이터베이스입니다. 다만 아키텍처가 매우 복잡해 관리 포인트가 한두개가 아니라는 문제가 있고 쿠버네티스에 대한 이해도가 없으면 잘 활용하기 힘든 데이터베이스입니다.

• Qdrant : Rust로 작성된 데이터베이스여서 메모리 효율과 연산 속도가 매우 빠르다는 특징이 있습니다. Rust 특유의 메모리 안전성과 속도 덕분에 동일한 사야에서 높은 처리량을 보여줍니다. API가 직관적이고 성능 튜닝 옵션이 세밀하다는 특징이 있습니다만 Rust로 작성되어 커뮤니티가 상대적으로 작아 성숙하지 못하다는 단점이 있습니다.

• Chroma : Python기반의 매우 단순한 인터페이스를 제공하고 내부적으로는 SQLite를 사용한다는 특징이 있습니다. 복잡한 설정 없이 바로 RAG 환경을 구축할 수 있어 빠른 검증용으로 유용합니다만 조금만 규모가 있는 서비스에서는 사용할 수 없다는 단점이 있습니다.

• Weaviate : Go로 작성되어 성능이 준수하고 GraphQL 지원 등 백엔드 개발자가 선호하는 인터페이스를 갖추고 있습니다. 또한, 하이브리드 검색이 가능하고 백엔드 친화적이지만 메모리 점유율이 높아 PQ를 적용해야하는 경우가 많습니다. 왜 Weaviate가 메모리를 많이 점유하게 되었는지는 추후에 서술합니다.

Pre-filtering vs Post-filtering

벡터 검색도 RDBMS의 WHERE절처럼 필터링을 진행할 수 있는데 이 필터링을 검색 전에 하느냐 검색 후에 하느냐에 따라 추구하는 방향도 다르고 검색 결과도 완전히 다르게 되는데요. 한번 자세히 알아보겠습니다.

Pre-filtering

Pre-filtering은 벡터 검색을 수행하기 전에 메타데이터 조건을 만족하는 데이터들만 먼저 골라내는 방식입니다.

• 동작 방식 : 먼저 SQL의 WHERE절처럼 조건을 만족하는 문서들의 ID를 필터링한 후, 그 결과 집합 안에서 유사도 검색을 수행합니다.

• 장점 : 필터링된 결과 내에서 가장 유사한 K개를 반드시 찾습니다. 필터 조건에 맞는 데이터가 존재하기만한다면 결과가 누락될 일이 없습니다.

• 단점 : Pre-filtering의 가장 큰 단점은 HNSW 인덱스가 무력화될 위험이 있다는 것입니다. HNSW처럼 그래프 기반의 인덱스에서는 그래프의 노드 중 상당수가 필터링으로 삭제된 것처럼 처리되면, 검색 경로가 끊겨버려 성능이 급격히 떨어지거나 검색이 실패할 수도 있습니다. 또한, 필터링된 결과가 너무 많으면 전체를 스캔해야하므로 속도가 느려진다는 단점도 있습니다.

Post-filtering

벡터 검색을 먼저 수행하여 상위 K개를 뽑고 그 결과에 메타데이터 필터를 적용하는 방식입니다.

• 동작 방식 : 전체 데이터셋에서 유사도가 높은 K개를 먼저 가져온 후, 그 중 특정 조건에 맞는 데이터를 버립니다.

• 장점 : 기존의 벡터 인덱스를 그대로 활용하므로 매우 빠르면서도 인덱스 구조를 건드리지 않아도 된다는 장점이 있습니다.

• 단점 : 예를 들어서 100개를 뽑았는데 필터 조건에 맞는 데이터가 2개뿐이라면, 사용자는 100개를 요청했더라도 2개만 받게 됩니다. 최악의 경우 검색 결과가 0개가 될 수도 있습니다.

실무적 의사결정

HNSW 그래프가 Pre-filtering 시 끊기는 문제는 벡터 데이터베이스의 성능과 정확도를 갉아먹는 고질적인 병목 중 하나입니다.

문제의 핵심은 Pre-filtering으로 벡터 검색을 시작하기 전에 메타데이터 조건에 맞지 않는 노드들을 검색 대상에서 제거하기 때문에 만약 필터링 조건이 매우 까다로워서 데이터의 90퍼센트가 제거된다면, 그래프의 연결 고리들이 대거 사라집니다. 노드간 연결로 목적지를 찾아가는 HNSW에서는 치명적이죠. Pre-filtering의 결과 그래프가 여러개의 섬으로 쪼개지고 검색 시작점에 있는 섬에서 목적지가 있는 섬으로 가는 길이 끊어지면 목적지가 분명 존재하는데도 불구하고 검색 엔진은 "결과 없음"을 반환합니다.

주요 벡터 데이터베이스들의 해결 방식

이 문제를 데이터베이스를 만드는 개발자들이 가만히 냅두지 않았겠죠? 어떻게 해결했을까요?

Milvus의 경우 그래프에서 노드를 물리적으로 제거하지 않고, 대신 모든 노드의 필터링 여부를 0과 1로 표시한 비트마스크를 메모리에 띄웁니다. 탐색시에는 필터링된 노드도 브릿지 역할로 활용하여 길을 찾고 최종 결과 리스트에 담을 때만 비트마스크를 확인해 필터링된 노드를 제거하는 방식입니다. 결과가 부족하면 탐색 범위를 넓혀가며 반복합니다.

Weaviate의 경우 ACRON-HNSW 알고리즘을 도입했는데 이 방식은 인덱스를 빌드할 때부터 필터링을 염두해두는 방식입니다. 일반적인 HNSW는 그래프를 가볍게 만들기 위해 불필요한 연결을 쳐내는데 반해 ACRON은 의도적으로 연결을 더 많이 남겨둡니다. 이로인해 필터링으로 많은 노드가 가려져도 우회로가 존재할 확률을 극대화합니다. 또한, 필터링 후 남은 데이터가 너무 적으면 아예 인덱스를 무시하고 Flat Search로 스위칭하여 100% 정확도를 보장합니다.

Qdrant는 필터 가능한 HNSW라는 독자적인 알고리즘을 사용합니다. 이 방식은 탐색 과정에서 "필터 조건에 맞는 노드"와 "그냥 길만 빌려주는 노드"를 구분합니다. 핵심은 HNSW의 탐색 알고리즘을 직접 수정해서 조건에 맞지 않는 노드라도 탐색 경로 상에 있다면 이를 통과할 수 있도록 허용해줍니다. 이를 통해 그래프의 단절 문제를 원천적으로 차단하죠.

하이브리드 검색

하이브리드 검색은 벡터 데이터베이스의 꽃이죠. 전통적인 키워드 검색(BM25A)과 최신 벡터 기반 검색은 서로 잘하는 분야가 완전히 다릅니다. BM25는 키워드 검색에 능해 "IPhone 15 Pro"와 같은 고유명사, 제품 코드, 약어를 찾을 때 압도적으로 좋습니다. 의미가 아니라 글자 그 자체의 일치 여부를 따지기 때문인데요.

반면 벡터 검색은 "어제 먹은 음식 때문에 배가 아플 때 먹는 약"과 같은 맥락과 의도를 파악하는데 아주 강합니다. "배가 아프다"와 "복통"의 유사성을 이해하기 때문이죠.

하이브리드 검색은 이 두개를 적절히 잘 섞어서 이 두개의 장점을 모두 흡수하는 방향으로 진화했습니다. 여기에서 가장 널리 사용되는 기법이 바로 RRF입니다.

RRF (Reciprocal Rank Fusion)

서로 다른 기준의 점수를 가진 두 리스트를 "순위"만을 이용해서 통합하는 알고리즘입니다. 점수 체계가 다른 두 엔진 (BM25 vs 코사인 유사도)을 공정하게 합칠 수 있다는 특징이 있습니다.

대부분의 벡터 데이터베이스들은 하이브리드 검색 시 알파라는 가중치 파라미터를 제공합니다. 이를 통해 어떤 엔진에 더 힘을 실어줄지 결정합니다.

• 알파 = 1 : 100% 순수 벡터 검색

• 알파 = 0 : 100% 순수 키워드 검색

• 알파 = 0.5 : 두 방식의 결과를 균등하게 반영

실무적 의사결정

실무에서는 보통 사용자의 쿼리가 짧고 명확한 단어 위주라면 알파 값을 낮추고, 서술형 문장이라면 알파 값을 높이는 식의 동적 튜닝을 고민해야합니다.

법률, 의료, 혹은 회사 내부 시스템처럼 특정 코드나 용어가 중요한 도메인의 경우에는 하이브리드 검색이 유용하고 임베딩 모델이 학습하지 못한 최신 유행어나 신제품명이 검색어인 경우에도 하이브리드 검색이 용이합니다.

Product Quantization (PQ)

벡터 데이터베이스의 고질적인 문제는 노드간의 연결들을 모두 메모리에 올려야한다는 문제가 있습니다. 이를 해결하기 위해 PQ라는 고차원 벡터를 여러개의 작은 부분 벡터로 쪼개고 각 부분 벡터를 대표값의 인덱스로 치환하여 압축하는 기술을 이용합니다.

예를 들어서 128차원 벡터를 8바이트로 압축하는 경우는 다음과 같습니다.

• 분할 : 128차원 벡터를 16차원짜리 부분 벡터 8개로 나눕니다.

• 학습 : 각 16차원 공간에서 K-Means 클러스터링을 수행해 대표적인 점 256개를 뽑습니다. 이 256개의 좌표 묶음을 코드북이라고 부릅니다.

• 부호화 : 이제 각 부분 벡터를 가장 가까운 대표값의 번호로 바꿉니다.

• 압축완료 : 원래 128개의 실수 (float32, 512바이트)였던 벡터가 8개의 인덱스 (8바이트)로 줄어드니 64배의 압축이 일어난겁니다.

PQ는 단순히 값을 뭉뚱그리는 일반적인 양자화와는 다르게 성능 손실이 최소화된다는 특징이 있는데 이유는 다음과 같습니다.

만약 128차원 전체를 한꺼번에 256개로 양자화했다면 표현할 수 있는 벡터의 가짓수는 딱 256개뿐입니다. 하지만 PQ로 8개 구역을 나누어 각각 256개씩 할당해준다면 표현 가능한 전체 조합수는 256의 8승개로 이는 10의 19승개나 됩니다. 이 원리가 부분으로 나누어 근사하더라도 전체 조합으로 정확도를 복원하는 PQ의 핵심 원리이죠.

PQ는 검색시에도 영리하게 동작하는데 사용자 질의는 압축하지 않고 원본 그대로를 유지한 채 DB에 저장된 압축된 벡터들과 거리를 계산합니다. 이 방식은 쿼리와 코드북 사이의 거리를 미리 계산해 테이블로 만든 후 실제 거리를 계산할 때에는 이 테이블에서 값만 읽어서 더한다는 특징이 있습니다. 이는 원본 쿼리를 보존하기 때문에 쿼리와 테이블을 모두 압축했을 때보다 오차가 훨씩 적습니다.

실무 관점에서의 벡터 데이터베이스 튜닝

그럼 실무에서는 어떻게 벡터 데이터베이스를 다뤄야할까요? 실무에서는 아래와 같은 조건을 보고 기술적 의사결정을 해야합니다.

• M 설정 : M이 클수록 그래프가 촘촘해져서 군집이 섬으로 나뉘는 현상이 줄어들고 검색 정확도가 올라가지만 인덱스 크기가 M에 비례해 커진다는 특징이 있습니다. M의 기본 값은 대부분 16이고 일반적인 텍스트 검색이나 RAG 시스템이라면 건드리지 않아도 됩니다. 하지만 임베딩 모델의 차원이 크거나 데이터 간의 경계가 모호하여 고도의 정밀도가 필요한 도메인에서는 32~64정도로 튜닝합니다. 만약 M을 2배 늘리면 차지하는 메모리도 2배 늘어나기 때문에 신중히 결정해야합니다.

• efConstruction : efConstruction은 인덱스를 생성할 때 얼마나 깊게 탐색하면서 연결 고리를 만들지 결정하는 값입니다. 이 값이 클수록 인덱스 구축 시간은 오래걸리지만, 그래프의 품질이 좋아져 Pre-filtering시 그래프가 끊기는 문제를 완화하기 위해 이 값을 높이기도 합니다. 보통 128이 표준적인 밸런스이고 데이터가 자주 바뀌지 않고 한 번 구축할 때 최상의 검색 품질을 뽑아내야하는 도메인에서는 256~512까지도 튜닝합니다. 이 값은 검색 속도에는 영향을 주지 않고 오직 인덱스 생성 속도와 최종 검색 정확도에만 관여합니다.

• efSearch : 검색시 후보군을 얼마나 샅샅이 뒤질지 결정합니다. 검색시에 limit보다 크게 설정해야하는데 예를 들어서 10개를 찾고싶다면 efSearch를 64이상으로 설정해야합니다. 평소에는 낮게 유지하다가 정확도가 필요한 특정 쿼리에만 높여서 동적으로 대응할 수도 있습니다.

• Product Quantization : 고차원 벡터를 몇 개의 묶음으로 나눌지 결정합니다. 이때 이 값은 차원수의 약수여야 에러가 발생하지 않습니다. 세그먼트 수가 많을수록 정확도는 원본에 가까워지지만 압축률은 떨어집니다. 보통 8차원당 1세그먼트가 적정값이고 PQ를 적용하면 메모리 사용량을 원본 대비 4배에서 64배까지 줄일 수 있고 CPU 캐시 효율이 좋아져 검색 속도가 오히려 빨라지기도 합니다. 다만 데이터가 5만건 이하라면 굳이 사용하지 않아도 되기 때문에 신중히 결정해야합니다.

마치며

쓰기 전에도 꽤 큰 볼륨이라고 생각했는데 쓰고 나니까 두개로 나눠서 쓸걸 그랬나 살짝 후회가됩니다. 이번 포스팅에서는 벡터 데이터베이스와 관련된 제반 지식들을 공부해보고 정리해봤습니다.

개인적으로 새로운 분야를 공부하는 것을 별로 좋아하진 않습니다만 이건 나름대로 흥미가 가는 부분들이 있어 공부하면서 꽤나 재밌게 공부했던 것 같습니다.

특히 Pre-filtering에서 생기는 단점들을 벡터 데이터베이스마다 다르게 해결하는 방식을 공부한 부분이 개인적으로 가장 재밌었습니다. 저는 문제 상황을 비슷한 기술들끼리 어떻게 해결했는지를 공부할 때 제일 흥미로워하는 것 같습니다.

일례로 CAP이론을 NoSQL이 임하는 방식을 보면 어떤 데이터베이스는 CP를 더 중요하게 생각하고 어떤 데이터베이스는 AP를 더 중요하게 생각하고 왜 그렇게 생각하고 어떤 결과로 이어지고 어떤 상황에서 어떤 데이터베이스를 선택해야하는지 등등 꼬리에 꼬리를 물고 사고가 뻗어나가게 되는 부분이 재밌는 것 같습니다.

이제 슬슬 포스팅을 줄여보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

실무에서 곧바로 적용 가능한 캐싱 전략 6가지

안녕하세요! 오랜만에 공부할 시간이 생겨서 공부하고 포스팅을 적게 되었습니다. 요즘 X(구 트위터)에 저한테 굉장히 도움되는 패치가 이루어졌습니다. 바로 '자동 번역 기능'! 누군가는 굉장히 거슬리는 패치일 수 있지만 저는 X를 개발 인사이트를 얻는 용도로 쓰다보니 해외에 있는 개발자들이 올리는 양질의 게시글들을 통해 많은 인사이트를 얻고 있습니다.

많은 인사이트를 얻음과 동시에 어떤걸 공부할까에 대한 소스도 제공해주고 있어서 개인적으로는 굉장히 쓸만한 패치가 된 것 같아서 기분이 좋습니다.

한 개발자의 게시글로 시작된 이 포스팅은 다음과 같은 순서로 진행됩니다.

• 읽기 전략

가장 기본적인 Cache Aside

• 캐싱 전략계의 전략패턴 Read Through

• TTL의 함정 Refresh Ahead

• 쓰기 전략

우린 일관성이 가장 중요해 Write Through

• DB의 쓰기 부하를 줄여보자 Write Behind

• 이건 캐시에 적으면 안돼 Write Around

이제 하나씩 알아보겠습니다!

읽기 정책

Cache Aside

캐시를 사용한다고 하면 가장 일반적으로 볼 수 있는 형태인 Cache Aside입니다. 요청이 들어오면 먼저 애플리케이션이 반응하고 캐시를 조회한 다음 캐시에 있으면 캐시에 있는 값을 보내주고 없으면 DB를 조회해서 반환한 뒤 캐시에 등록하는 방법이죠.

이 방식은 가장 구현하기 간단한데 단순히 분기를 추가해주기만 하면 됩니다.

if (cache에 있으면)

return cache.value

else

DB조회

cache 등록

return DB.value

이 방식은 가장 간단한만큼 두 가지 문제가 발생합니다.

도메인이 늘어날 때마다 같은 코드가 반복된다.

모든 코드에 if else 문이 들어가야하고 만약 도메인 이수십개가 된다면 모든 조회 로직에 캐시 조회 로직을 넣어햐하는게 가장 큰 문제인데요.

이것은 객체지향 5대 원칙인 SOLID에서 OCP를 위배하게 됩니다. 확장해도 코드의 변경이 있으면 안된다는 OCP의 원칙에서 가장 중요한건 코드의 변경이 없다는 것입니다.

만약 도메인이 하나 추가된다거나 캐시의 버전이 바뀌어서 구현 방법이 달라진다거나 캐시를 바꿔야한다거나 이런 변경에 모든 분기문을 고쳐야한다는 문제는 이 애플리케이션의 확장성을 크게 해칩니다.

Thundering Herd의 문제

특정 키에 요청이 많이 몰리는 경우 이를 핫 키(Hot Key) 현상이라고 부릅니다. 만약 어떤 캐시 값이 TTL로 사라지는 찰나에 순간적으로 해당 키에 대한 요청이 들어오는 경우 순간적으로 DB에 조회 요청이 들어가게 되고 DB에 스파이크성 부하가 생기게 됩니다. 이는 심각한 경우 DB 서버의 다운으로 이어질 수 있는 문제입니다.

이러한 문제를 Thundering Herd 문제라고 부르며 이 문제는 조회연산이 많이 필요한 이커머스 서비스나 하루에도 수백만건의 거래가 일어나는 증권사 앱같은 경우에 종종 발생하는 문제입니다.

이제 다음 소개드릴 두 가지 캐싱 전략은 앞서 Cache Aside가 갖고 있던 문제를 해결하기 위해 나온 방법론입니다.

Read Through

Read Through는 캐시에 데이터가 떨어질 것 같은 데이터를 DB에 요청해서 필요한 데이터를 관리하는 방법인데요. 이렇게 되면 애플리케이션에서 캐시에 데이터가 있는지 확인하는 분기문이 사라지게 됩니다.

하지만 Redis나 Memcached 같은 인메모리 캐싱 솔루션에서는 기본적으로 제공해주지 않는 기능입니다.

이 기능은 보통 라이브러리/프레임워크 레벨에서 지원하는데 일례로 Spring Boot의 경우 @Cacheable 어노테이션을 이용하면 프레임워크가 Read Through 처럼 동작하게 할 수 있습니다.

Read Through를 이용하면 서비스 레이어에서 캐시와의 의존성을 제거할 수 있고 만약 변경이 생기면 어댑터 부분만 바꾸면 전체 서비스 레이어에 적용되어 코드의 변경 없이 확장할 수 있게 됩니다.

Refresh Ahead

Refresh Ahead는 Thundering Herd를 해결하기 위해 나온 방법론인데 실제로 어떤 키가 핫키인지 확인한 뒤 TTL이 끝나기 전에 TTL을 갱신시키는 방법입니다.

캐시에 데이터가 사라지지 않고 남아있기 때문에 Thundering Herd 문제가 절대로 발생하지 않는다는 장점이 있지만 중요한 것은 어떤 키가 핫키인지 파악하는 것입니다. 만약 의미없는 키를 핫키로 생각하고 등록했다가는 자원만 낭비할테니까요.

cf) 만약 핫키에 요청이 캐시서버가 버틸 수 없는 정도로 (초당 100만번) 요청이 들어오면 어떻게 할까?

이 문제에 대한 해결에는 크게 세 가지 방법이 있습니다.

• 키를 샤딩한다

• 애플리케이션에 매우 짧은 TTL(100ms)의 로컬 캐시를 만든다

• 캐시 앞에 존재하는 CDN등에 데이터를 올린다.

보통 이런 경우 가장 관리 포인트(Toil)가 적은 쪽을 선택하게 되는데 그것이 바로 2번입니다.

애플리케이션에 매우 짧은 TTL(100ms)로 로컬 캐시를 만드는 것만으로도 부하의 90퍼센트를 줄일 수 있습니다.

쓰기 정책

Write Through

Write Through는 쓰기 작업을 할 때 캐시와 DB에 동시에 씀으로써 두 데이터베이스에 일관성을 맞추기 위해 고려된 방법론입니다. 보통 이 경우 일관성이 중요한 금융권에서 필요한 방법론인데 이 방법은 일관성이 정확하게 맞춰지긴 하지만 두번의 쓰기 작업이 생긴다는 문제가 발생합니다.

일부 NoSQL을 제외한 대부분의 데이터베이스는 인덱스로 데이터를 관리하고 있기 때문에 보통 읽기 연산에 특화되어있고 쓰기 연산에 취약한 모습을 보이기 때문에 데이터베이스에 쓰기 작업이 많이 생기는건 썩 좋은 상황은 아닙니다.

그렇기 때문에 쓰기 연산이 두번 일어나는 상황은 꺼려지게 되고 보통 이런 경우 먼저 DB에 쓰고 캐시에 있는 키를 삭제해서 Cache Aside를 유발하는 쪽으로 사용하곤 합니다.

Write Behind

Write Behind는 쓰기 작업을 할 때 DB의 부하를 줄여주기 위해서 나온 방법론입니다. 의의는 쓰기작업을 할 때마다 DB에 요청하지 말고 캐시에 한번 쓰고 일괄로 배치작업을 돌려서 밀어넣자는 것이 이 방법론의 핵심입니다.

이 방법의 장점은 DB의 쓰기 부하를 줄여준다는 것과 쓰자마자 바로 화면에서 조회할 수 있기 때문에 UX측면에서 굉장한 이득이 되는겁니다.

하지만 이 방법의 가장 큰 문제는 Redis가 모종의 이유로 장애가 생긴다면 모든 데이터가 유실된다는 문제가 생깁니다.

또한, 단순히 DB의 쓰기 부하를 줄이기 위해 쓰기 연산을 뒤로 미루는건 MQ(Message Queue)를 이용하면 문제를 더 깔끔하게 해결할 수 있습니다.

보통의 MQ는 메세지가 발행되는 시점에서 반드시 하나의 브로커에 전달되도록 설계되어있고 만약 브로커에 문제가 생겨 메세지가 전달되지 못하더라도 바로 리밸런싱이 되면서 다른 브로커에 전달되는 것이 일반적입니다. 또한, 메세지가 실패하는 경우 DLQ를 이용해서 얼마든지 재시도를 할 수 있습니다.

다만, MQ를 사용한다면 MQ에 대한 이해가 있어야되고 메세지와 트랜잭션을 원자적으로 묶기 위해서는 Outbox Pattern이나 Log Tailling Pattern을 추가적으로 적용해야해서 관리포인트가 늘어난다는 단점이 있습니다.

Write Around

Write Around는 쓰기 작업에서 캐시와 DB에 모두 써야하는 상황인데 캐시에 올리기에는 의미없는 데이터를 써야할 때 사용하는 방법론입니다. 보통 대용량의 로그 데이터가 해당되는데 이때 Write Around라는 이름에 걸맞게 캐시를 우회해서 곧바로 DB에 쓰는 방법을 사용합니다.

하지만 이 방법 또한 더 나은 방법이 존재하는데 보통 이런 경우 ELK 스택을 사용하거나 GCP의 BigQuery등을 사용하면 더 깔끔하게 문제를 해결할 수 있어 범용적으로 많이 사용되는 방법론은 아닙니다.

마치며

아무래도 캐싱이 읽기 부하를 줄이기 위해 쓰이는 만큼 쓰기 정책에서는 꽤나 다양한 대안책이 있어서 취사선택해서 사용하시면 될 것 같습니다. 모든 개발이 그렇지만 장/단이 존재하는 상황에서 최적지점을 찾아내는 과정이 중요하고 캐싱 전략의 경우 속도와 일관성 사이에서 선택해서 적절히 우리 서비스에 맞는 정책을 사용해야할 것 같습니다.

이번 포스팅에서는 대부분의 서비스가 캐싱을 많이 사용하고 그렇기에 다양한 방향으로 발전해온 만큼 알아두면 좋을 6가지 캐싱 전략을 알아보았습니다.

데이터베이스 공부는 역시 언제해도 재밌는 것 같습니다. 여러분들에게 좋은 인사이트가 되었길 바라면서 이번 포스팅은 여기서 마무리짓도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

Redis는 싱글스레드인데 어떻게 초당 수십만건의 요청을 처리할까? (feat. epoll)

안녕하세요! 이번 포스팅은 Redis에 대한 포스팅으로 찾아뵙게 되었습니다.

Redis는 업계 표준이라고 해도 될 정도로 많은 기업, 많은 프로젝트에서 사용되고 있는데요. 아무래도 캐싱, 메세지 큐, 실시간 데이터 처리 등 다양한 방면으로 사용되고 HA 방법론도 여러가지 제공해주고 커뮤니티도 많이 형성되어있어서 인기가 좋은 것 같습니다.

흔히 Redis와 Memcached의 차이를 면접 때 물어보면 이런 대답이 100퍼센트 나옵니다.

"Redis는 싱글 스레드이고 Memcached는 멀티 스레드여서 Redis가 성능상 이점이 있습니다."

그럼 면접관이 다시 "싱글 스레드는 처리량이 안좋은거 아닌가요?"

저도 신입 때 이 둘의 차이에 대해서 공부했지만 한번도 이런 생각까지 사고가 확장되진 않았던 것 같습니다.

"그러게.. Redis가 싱글 스레드인데 어째서 Memcached 보다 성능이 좋다고 말하는걸까..?"

이번 포스팅에선 아래와 같은 절차를 통해 이 궁금증을 해결해 보겠습니다.

• 전통적인 I/O

• 멀티플렉싱 I/O

• Redis와 epoll

• epoll의 다양한 사례

그럼 본격적으로 시작해보죠!

전통적인 I/O

Blocking I/O

전통적인 I/O에서 가장 먼저 등장한 I/O가 바로 Blocking I/O입니다. Blocking I/O의 경우 천개의 요청이 들어오면 천개의 스레드를 만들어서 동시다발적으로 처리하는 방법론을 사용합니다.

이 방식은 스레드를 생성하는 비용이 어마어마했고 스레드를 무한정 많이 만들 수 없어서 동시에 처리할 수 있는 요청이 정해져있다는 문제가 있었습니다. 또한, 엄청나게 많은 스레드간 컨텍스트 스위칭 비용도 무시할 수 없는 수준이었죠.

전통적인 웹서버인 Apache가 Blocking I/O를 사용해서 처리를 했었는데 과거에는 Apache도 문제없이 동작을 했습니다. 그때는 웹이 이렇게까지 큰 규모가 아니었기에 적당한 요청이 들어왔고 적당한 처리를 진행하면 됐습니다.

하지만 이후 동시 요청이 1만개가 넘어가는 순간 Apache은 더이상 사용할 수 없는 지경에 이르렀습니다. 이것이 그 유명한 10k 문제입니다. 그러면서 Nginx가 등장했는데 이 이야기는 조금 뒤에 마저 풀도록 하겠습니다.

Non-Blocking I/O

Non Blocking I/O는 싱글스레드가 각 연결마다 돌아다니면서 요청이 있는지 확인하는 방식이었습니다. 이 방식의 장점은 많은 스레드간 컨텍스트 스위칭 비용이 없어졌고 스레드 생성 비용에 대한 오버헤드가 사라졌습니다.

하지만 Non Blocking I/O도 문제가 없던 것은 아닌데 스레드가 모든 연결을 돌아다녀야했기 때문에 요청이 없는 연결도 돌아다녀야했고 이로인해 의미없는 CPU 사용이 생겼습니다.

멀티플렉싱 I/O

멀티플렉싱 I/O는 기존의 전통적인 I/O의 문제를 해결하기 위해 등장했습니다. 이번 섹션에서는 멀티플렉싱 I/O 중에서 가장 진보한 방식인 epoll 방식을 알아볼겁니다.

epoll은 멀티플렉싱 I/O의 구현체로서 연결이 아무리 많아도 요청이 들어온 것만 처리하는 로직을 사용한다는 특징이 있습니다.

epoll의 동작방식을 이해하면 조금 더 이해하기가 쉬운데 동작방식은 아래의 동작방식을 따릅니다.

• NIC을 타고 들어온 요청을 커널이 리스트의 형태로 만든다. 이 리스트는 Red Black Tree (RBT)의 형태를 따른다.

• epoll이 이벤트 루프를 돌면서 요청이 발생했는지 여부를 판단한다.

• 이벤트가 발생하면 커널이 만든 리스트를 받아서 요청을 처리한다.

• 리스트에는 어떤 소켓이 요청했는지와 데이터가 어디에 저장되어있는지 포인터가 같이 들어있다.

• 요청을 받으면 포인터를 타고 들어가서 응답 버퍼에 데이터를 담아서 요청한 소켓한테 데이터를 돌려준다.

※ 잠깐! 여기서 연결과 요청의 차이가 뭘까?

• 연결 : 연결은 4계층에서 일어나는 것으로 한번 연결되면 연결이 유지된다는 특징이 있습니다. (HTTP 1.1 이후 기준)

• 요청 : 요청은 7계층에서 일어나는 것으로 연결된 통로를 따라 패킷이 이동하는 것을 의미합니다.

이 둘을 예시로 이야기하면 전화통화할 때 내가 전화를 걸면 상대방이 받고 이 상태를 연결이라고 하고 서로 말을 주고 받는 상황이 요청이라고 생각하시면 됩니다.

이 때문에 연결이 유지된 상태 (최초 1회)에서 여러번의 요청이 이동하는 것입니다.

---

이 과정에서 만약 연결이 천개면 Blocking I/O는 스레드를 천개 만들어야했고 Non Blocking I/O는 천개의 연결을 계속 돌아다녀야 했기 때문에 연결이 늘어나면 늘어날 수록 성능이 그만큼 떨어지는 현상이 발생했습니다. 이를 시간복잡도로 표현하면 O(n)이 됩니다.

하지만 epoll은 요청이 온 리스트만 가지고 있어서 연결이 아무리 늘어나도 요청온 것만 처리하면 되기 때문에 시간복잡도는 O(1)이 됩니다.

저는 여기서 의문이 들었던 것이 연결이 천개일 때 천개를 다 둘러보는 것이 O(n)인데 epoll 입장에서 요청 온 리스트를 전부 읽어봐야하는 것은 마찬가지이니 이것도 O(n)이 아닐까 했는데, 여기서 말하는 O(1)의 의미는 연결이 천개던 만개던 동일하게 요청된 리스트만큼을 가져오기 때문에 연결과 성능의 상관관계가 y = x가 아닌 y = 상수 로 고정되어있다는 것을 의미합니다.

Redis와 epoll

Redis는 epoll을 적극적으로 사용하는데, 한 가지 상황을 가정하고 Redis가 어떻게 epoll을 사용하는지 알아보겠습니다.

e.g. 어떤 클라이언트가 Redis에서 mykey라는 키를 get 요청으로 조회하길 원합니다.

• 클라이언트가 Redis에 mykey라는 키를 저장한다.

• 커널이 mykey를 요청 리스트에 넣는다.

• Redis의 epoll이 이벤트 루프를 돌면서 이벤트를 감지한다.

• mykey에 대한 요청이 들어온다.

• 커널이 요청 리스트를 Redis에 던진다

• Redis가 요청 리스트를 보고 어디에 데이터가 적혀있는지 포인터를 타고 들어가서 데이터를 가져온다. 이때 데이터는 RAM에 있기 때문에 이 과정이 수ns면 끝난다. 이후 어떤 소켓이 보냈는지에 대한 정보를 읽고 응다 버퍼에 데이터를 실어서 전달해준다.

• 사용자가 mykey에 대한 데이터를 받는다.

즉, Redis는 싱글스레드로 돌지만 epoll의 특성과 RAM에 저장된다는 특성 때문에 매우 빠르게 데이터를 가져올 수 있게 되는겁니다. 또한, 싱글스레드이니 각 스레드마다 컨텍스트 스위칭 비용이 들지 않는다는 것은 덤이죠.

epoll의 다양한 사례

이대로 끝내기는 아쉬우니 epoll의 다양한 사례를 알아보고 마무리 짓겠습니다.

Nginx

epoll은 전통적인 I/O 이후에 등장한 가장 진보한 I/O인 만큼 다양한 곳에서 epoll을 사용합니다. 가장 유명한 것이 Nginx인데 Nginx가 Tomcat을 대체하고 새로운 표준이 된 데에는 epoll의 역할이 매우 컸습니다.

전통적인 웹서버는 요청을 동시에 처리하기 위해 요청마다 스레드를 잔뜩 만들어서 동시 처리를 했기 때문에 일정 수준 이상의 요청을 동시에 처리할 수 없었습니다. 하지만 epoll을 적용한 Nginx는 100만개의 연결을 하고도 요청을 잘 처리하게 되었습니다.

R2DBC

전통적인 JDBC는 Blocking I/O의 대표주자였고 커넥션 풀의 개수가 곧 데이터베이스의 처리량의 지표가 되었습니다. 하지만 요즘 나오는 R2DBC는 커넥션 풀이 DB에 요청을 던지기만 하고 대기하고 있다가 DB가 처리 후 응답하면 다시 받아서 처리하는 epoll 방식을 사용합니다.

이 때문에 Blocking I/O와 동일한 커넥션 풀을 가지고 있어도 더 많은 요청을 처리할 수 있게 되었습니다.

gRPC

보통 MSA에서 많이 사용하는 gRPC는 헤더 압축, protobuf, 멀티플렉싱 I/O를 무기로 엄청나게 많은 요청을 처리할 수 있습니다. 단순 레이턴시만 놓고 보면 성능이 좋은건 아니지만 gRPC의 힘은 어마어마하게 많은 양의 요청을 처리할 수 있다는 것입니다.

gRPC도 역시 epoll을 사용해서 I/O를 처리하고 있는데 epoll 덕분에 엄청나게 많은 요청을 처리하고 있습니다.

마치며

개발 공부란건 알고 있다고 생각해도 모르는게 계속 튀어나오는 매력을 가지고 있는 것 같습니다. 이번 포스팅에서는 Redis의 내부 동작을 뜯어보면서 어떻게 싱글스레드임에도 성능이 잘 나올 수 밖에 없는지에 대해서 알아봤습니다.

요즘 X(구 트위터)에서 개발 인사이트를 많이 얻고있는데 처음으로 머스크한테 뽀뽀하고싶은 정도입니다. 자동 번역 최고!

이번 포스팅은 여기서 마무리짓도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

인터넷 네트워크

*인터넷 네트워크

*IP (인터넷 프로토콜) 역할

1. 지정한 IP 주소에 데이터 전달

2. 패킷이라는 통신 단위로 데이터 전달

처음에 내가 원하는 정보를 상대방에게 전달하고 싶으면 IP패킷이라는 곳에 출발IP주소 도착IP주소를 적고 안에 메시지를 적어 넣고 인터넷에 던진다. 그럼 인터넷에서

도착 IP주소를 갈 수 있는 곳을 서로 물어가며 전달이 된다.

IP프로토콜의 한계

1. 비연결성 : 패킷을 받을 대상이 없거나 서비스 불능 상태여도 패킷 전송

2. 비신뢰성 : 중간에 패킷이 사라지면? 패킷이 순서대로 안오면?

3. 프로그램 구분 : 같은 IP를 사용하는 서버에서 통신하는 애클리케이션이 둘 이상이면?

*TCP

IP에서 생긴 한계점을 해결하기 위해 생겨난 개념이다.

IP패킷에선 출발지IP, 목적지IP, 기타등등을 가지고 메시지를 담는다. TCP패킷은 IP패킷 안에 담는 구조이고 구조적으로 보면 메시지를 TCP패킷이 감싸고 TCP패킷을 IP패킷이

감싸는 구조이다. TCP패킷에는 출발지PORT, 목적지PORT, 전송제어, 순서, 검증정보 등의 정보가 담긴다.

TCP 특징

1. 연결지향 3way handshake (연결이 됐나 안됐나를 확인하고 보낸다)

2. 데이터 전달 보증 (메시지가 누락이 되면 메시지를 못받았다고 인식할 수 있다)

3. 순서보장

4. 신뢰할 수 있는 프로토콜

5. 현재는 대부분 TCP를 사용

1. 연결지향 TCP 3way handshake (가상연결) = 이를 가상연결이라 하는 이유는 논리적으로만 연결이 된것이지 물리적으로 연결이 된 것은 아니기 때문

메시지를 클라이언트가 서버로 SYN을 보내고 (SYN : 접속요청 (싱크로나이즈의 줄임말) ) 서버도 클라이언트에 SYN+ACK를 보낸다. (ACK : 요청수락) 그럼 다시 클라이언트가

서버에 ACK를 보냄으로써 3번 정보를 보낸다. 이렇게 하면 클라이언트와 서버간 서로를 신뢰할 수 있게 된다. 이렇게 연결이 되고 나면 그제서야 데이터를 전송한다.

요즘은 기술의 발전으로 클라이언트가 마지막 서버에 ACK를 보낼때 데이터도 전송할 수 있다.

2. 데이터 전달 보증

클라이언트가 서버에 데이터를 전송하면 서버는 클라이언트에게 데이터 잘 받았다고 또 전송함

3. 순서보장

패킷1, 2, 3 순서로 전송했다고 가정하고 서버에서 패킷1, 3, 2 순서로 받았다고 가정하면 서버는 클라이언트에게 패킷2부터 다시 보내라고 메시지를 보낸다.

*UDP 특징

1. 하얀 도화지에 비유

2. 연결지향 X

3. 데이터 전달 보증 X

4. 순서 보장 X

5. 데이터 전달 및 순서가 보장되지 않지만 단순하고 빠름

6. 정리 : IP와 거의 같다. +PORT+체크섬 정도만 추가, 애플리케이션에서 추가 작업 필요하다

*PORT

한번에 여러개의 서버와 통신해야 한다면 서버에서 클라이언트에 패킷이 여러개가 갈건데 이걸 어떻게 구분할것이냐

TCP프로토콜에 출발지 PORT와 도착지 PORT를 같이 보내기 때문에 서버딴에서는 어디서 온 패킷인지 바로 알 수 있고 다시 정보를 보낼 때도 출발지PORT를 같이 적어 보냈기

때문에 바로 보내줄 수 있다. IP는 아파트로 비유되고 PORT는 몇동몇호인지로 비유된다.

*DNS

IP의 단점은 기억하기가 어렵다. 또한 IP는 언제든지 바뀔수있다.

이를 해결하기 위해 DNS(도메인 네임 시스템)이 생겨났다. DNS서버에 예를들어 google.com 의 IP가 200.200.200.2라면 클라이언트가 DNS서버에 google.com을 요청하면

응답으로 200.200.200.2의 IP를 응답해주는 것이다. 그렇게 응답받은 200.200.200.2로 서버에 접속한다.

HTTP 기본

*HTTP (HyperText Transfer Protocol)

원래 는 html을 전송하기위해 만들어졌지만 요즘은 html, text, image, 음성, 영상, 파일, json, xml 거의 모든 형태의 데이터 전송 가능

HTTP 특징

1. 클라이언트 서버 구조

2. 무상태 프로토콜, 비연결성

3. HTTP 메시지로 통신

4. 단순함, 확장 가능

1. 클라이언트 서버 구조

1-1. Request Response 구조

1-2. 클라이언트는 서버에 요청을 보내고, 응답을 대기

1-3. 서버가 요청에 대한 결과를 만들어서 응답

2. 무상태 프로토콜, 비연결성

무상태 = stateless

2-1. 서버가 클라이언트의 상태를 보존X

2-2. 장점 : 서버 확장성 높음

2-3. 단점 : 클라이언트가 추가 데이터 전송

2. 무상태 프로토콜 (stateful, stateless)

1. stateful

손님A : 이 노트북 얼마에요?

점원A : 100만원입니다. (노트북 상태유지)

손님A : 2개 구입하겠습니다

점원A : 200만원입니다. 결제는 신용카드로 하시겠습니까? 아니면 현금으로 결제하시겠습니까? (노트북, 2개 상태유지)

손님A : 신용카드로 결제하겠습니다.

점원A : 결제완료했습니다. (노트북, 2개, 신용카드 상태유지)

2. stateful상태에서 점원이 바뀌면

손님A : 이 노트북 얼마에요?

점원A : 100만원입니다.

손님A : 2개 구입하겠습니다.

점원B : 뭘 2개 구입하겠다는거지?

손님A : 신용카드로 결제하겠습니다.

점원C : 뭘 몇개 신용카드로 결제하겠다는거지?

3. stateless

손님A : 이 노트북 얼마에요?

점원A : 100만원입니다.

손님A : 노트북 2개 구입하겠습니다.

점원B : 200만원입니다. 결제는 어떤것으로 하시겠습니까?

손님A : 노트북 2개 신용카드로 결제하겠습니다.

점원C : 결제되었습니다.

stateful : 중간에 다른 점원으로 바뀌면 안된다. (중간에 다른 점원으로 바뀔 때 상태 정보를 다른 점원에게 미리 알려줘야한다.)

stateless : 중간에 다른 점원으로 바뀌어도 된다. (갑자기 고객이 증가해도 점원을 대거 투입할 수 있다. = 갑자기 클라이언트 요청이 증가해도 서버를 대거 투입할 수 있다.)

stateless 실무 한계

1. 모든 것을 무상태로 설계할 수 있는 경우도 있고 없는 경우도 있다.

2. 로그인 한 사용자의 경우 로그인 했다는 상태를 유지 해야한다.

3. 일반적으로 브라우저 쿠키와 서버 세션등을 사용해서 상태 유지

2. 비연결성

1. HTTP는 기본이 연결을 유지하지 않는 모델

2. 일반적으로 초 단위의 이하의 빠른 속도로 응답

3. 1시간 동안 수천명이 서비스를 사용해도 실제 서버에서 동시에 처리하는 요청은 수십개 이하로 매우 작음

4. 서버 자원을 매우 효율적으로 사용할 수 있음

비연결성 한계와 극복

1. TCP/IP 연결을 새로 맺어야함 = 3way handshake 시간 추가

2. 웹 브라우저로 사이트를 요청하면 html뿐만 아니라 자바스크립트, css, 추가 이미지등 수 많은 자원이 함께 다운로드

3. 지금은 HTTP지속연결로 문제 해결

4. HTTP/2, HTTP/3에서 더 많은 최적화

HTTP 메서드

*HTTP 메서드

HTTP API URI 설계

회원 목록 조회 / read-member-list

회원 조회 / read-member-by-id

회원 등록 / create-member

회원 수정 / update-member

회원 삭제 / delete-member

이렇게 설계하는게 좋은 설계일까?

가장 중요한 것은 리소스 식별!

리소스의 의미는 뭘까?

-회원을 등록하고 수정하고 조회하는게 리소스가 아니다

-ex) 미네랄을 캐라 -> 미네랄이 리소스

-회원을 조회해라 = 리소스? (X) / 회원 자체가 바로 리소스

리소스를 어떻게 식별하는게 좋을까?

-회원을 등록하고 수정하고 조회하는 것을 모두 배제

-회원이라는 리소스만 식별하면 된다. -> 회원 리소스를 URI에 매핑

다시 API URI 설계

회원 목록 조회 / members

회원 조회 / members / {id = 리소스} ->어떻게 구분하지?

회원 등록 / members / {id = 리소스} ->어떻게 구분하지?

회원 수정 / members / {id = 리소스} ->어떻게 구분하지?

회원 삭제 / members / {id = 리소스} ->어떻게 구분하지?

리소스와 행위를 분리 > 가장 중요한 것은 리소스를 식별하는 것

-URI는 리소스만 식별!

-리소스와 해당 리소스를 대상으로 하는 행위를 분리

리소스 = 회원

행위 = 조회, 등록, 삭제, 변경

-리소스는 명사, 행위는 동사 (회원을 조회해라)

-행위(메서드)는 어떻게 구분?

HTTP 메서드 종류

주요 메서드

1. GET : 리소스 조회

2. POST : 요청 데이터 처리, 주로 등록에 사용

3. PUT : 리소스를 대체, 해당 리소스가 없으면 생성

4. PATCH : 리소스 부분 변경

5. DELETE : 리소스 삭제

기타메서드

6. HEAD : GET과 동일하지만 메시지 부분을 제외하고, 상태 줄과 헤더만 반환

7. OPTIONS : 대상 리소스에 대한 통신 가능 옵션을 설명

8. CONNECT : 대상 자원으로 식별되는 서버에 대한 터널을 설정

9. TRACE : 대상 리소스에 대한 경로를 따라 메시지 루프백 테스트를 수행

1. GET메서드

-리소스 조회

-서버에 전달하고 싶은 데이터는 쿼리스트링을 통해서 전달

-메시지 바디를 사용해서 데이터를 전달할 수 있지만, 지원하지 않는 곳이 많아서 권장하지 않음

2. POST메서드

-요청 데이터 처리

-메시지 바디를 통해 서버로 요청 데이터 전달

-서버는 요청 데이터를 처리

-메시지 바디를 통해 들어온 데이터를 처리하는 모든 기능을 수행한다.

-주로 전달된 데이터로 신규 리소스 등록, 프로세스 처리에 사용

POST 정리

1. 새 리소스 생성(등록)

-서버가 아직 식별하지 않은 새 리소스 생성

2. 요청 데이터 처리

-단순히 데이터를 생성하거나, 변경하는 것을 넘어서 프로세스를 처리해야 하는 겨웅

-EX) 주문에서 결제완료 -> 배달 시작 -> 배달 완료처럼 단순히 값 변경을 넘어 프로세스의 상태가 변경되는 경우

-POST의 결과로 새로운 리소스가 생성되지 않을 수도 있음

3. 다른 메서드로 처리하기 애매한 경우

-EX) JSON으로 조회 데이터를 넘겨야 하는데, GET메서드를 사용하기 어려운 경우

-애매하면 POST

3. PUT 메서드

1. 리소스를 대체

1-1. 리소스가 있으면 대체 (리소스를 완전히 대체한다.)

1-2. 리소스가 없으면 생성

1-3. 쉽게 이야기해서 덮어버림

2. 중요! 클라이언트가 리소스를 식별

2-1. 클라이언트가 리소스 위치를 알고 URI지정

2-2. POST와 차이점

4. PATCH 메서드

PUT메서드 1-1번 리소르르 완전히 대체해 버리면 리소스를 수정하는 것에 엄청난 진입장벽이 생겨버린다. 그렇기 때문에 나온 것이 바로 PATCH, 리소스를 부분 변경한다.

5. DELETE 메서드

리소스 제거

*HTTP 메서드 속성

1. 안전

2. 멱등

3. 캐시가능

1. 안전

-호출해도 리소스를 변경하지 않는다.

-Q : 그래도 계속 호출해서, 로그 같은게 쌓여서 장애가 발생하면요? A : 안전은 해당 리소스만 고려한다. 그런 부분까지 고려하지 않는다.

2. 멱등

f( f(x) ) = f(x)

-한번 호출하든 두번 호출하든 100번 호출하든 결과가 똑같다.

-멱등 메서드

1. GET : 한번 조회하든, 두번 조회하든 같은 결과가 조회된다.

2. PUT : 결과를 대체한다. 따라서 같은 요청을 여러번 해도 최종 결과는 같다.

3. DELETE : 결과를 삭제한다. 같은 요청을 여러번 해도 삭제된 결과는 똑같다.

4. POST(X) : 멱등이 아니다. 두번 호출하면 같은 결제가 중복해서 발생할 수 있다.

멱등의 활용

-자동 복구 매커니즘

-서버가 TIMEOUT등으로 정상 응답을 못주었을 때, 클라이언트가 같은 요청을 다시 해도 되는가? 로 판단

멱등은 재요청 중간에 다른 곳에서 리소스를 변경해버린다고해서 그런 것 까지 고려하지는 않는다.

3. 캐시가능

1. 응답 결과 리소스를 캐시해서 사용해도 되는가?

2. GET, HEAD, POST, PATCH 캐시가능

3. 실제로는 GET, HEAD 정도만 캐시로 사용

4. POST, PATCH는 본문 내용까지 캐시 키로 고려해야 하는데, 구현이 쉽지않음

HTTP 상태코드

*HTTP 상태코드

클라이언트가 보낸 요청의 처리 상태를 응답에서 알려주는 기능

1. 1xx : 요청이 수신되어 처리중

2. 2xx : 요청 정상 처리

3. 3xx : 요청을 완료하려면 추가 해동이 필요

4. 4xx : 클라이언트 오류, 잘못된 문법등으로 서버가 요청을 수행할 수 없음

5. 5xx : 서버 오류, 서버가 정상 요청을 처리하지 못함

만약 모르는 상태코드가 나타나면?

-클라이언트가 인식할 수 없는 상태코드를 서버가 반환하면?

-클라이언트는 상위 상태코드로 해석해서 처리

-미래에 새로운 상태코드가 추가되어도 클라이언트를 변경하지 않아도 됨

ex) 299 = 2xx = 요청이 정상처리 되었구나

ex) 451 = 4xx = 클라이언트 오류구나

1. 1xx 요청이 수신되어 처리중

넘어가도 됨

2. 2xx 클라이언트의 요청을 성공적으로 처리

2-1. 200 OK

요청성공

2-2. 201 Created

요청이 성공해서 새로운 리소스가 생성됨

2-3. 202 Accepted

요청이 접수되었으나 처리가 완료되지 않음 (잘 사용하진 않음)

2-4. 204 No Content

서버가 요청을 성공적으로 수행했지만, 응답 페이로드 본문에 보낼 데이터가 없음 (잘 사용하진 않음)

3. 3xx 리다이렉션 요청을 완료하기 위해 유저 에이전트의 추가 조치 필요

리다이렉션이란?

웹 브라우저는 3xx응답의 결과에 Location 헤더가 있으면, Location 위치로 자동 이동 = 리다이렉트

리다이렉션의 종류

3-1. 영구 리다이렉션 - 특정 리소스의 URI가 영구적으로 이동

3-2. 일시 리다이렉션 - 일시적인 변경

3-3. 특수 리다이렉션 - 결과 대신 캐시를 사용

3-1. 영구 리다이렉션 (301, 308)

-리소스의 URI가 영구적으로 이동

-원래의 URL을 사용X, 검색 엔진 등에서도 변경 인지

3-1-1. 301 Moved Permanently : 리다이렉트시 요청 메서드가 GET으로 변하고, 본문이 제거될 수 있음

3-1-2. 308 Permanent Redirect : 301과 기능은 같음 / 리다이렉트시 요청 메서드와 본문 유지

3-1-1. 301 Moved Permanently

POST방식으로 /event로 보내면 서버에선 301 Moved Permanently로 보내고 Location을 /new-event로 보내준다. 클라이언트는 받은 /new-event로 재요청을 하게되고 /new-event 에 GET방식으로 서버로 다시 요청하게 된다. 그럼 서버 입장에선 /new-event를 GET이니까 보여달라고 하는거네? 하고 원래 있던 POST내용을 다 없애버리고 GET방식으로 조회만 해준다. (현업에선 이걸 많이 쓴다.)

3-1-2. 308 Permanent Redirect

POST방식으로 /event로 보내면 서버에선 308 Permanent Redirect로 보내고 Location을 /new-event로 보내준다. 클라이언트는 받은 /new-event로 재요청을 하게되고 /new-event 에 POST방식으로 서버로 다시 요청한다. (현업에선 잘 안쓴다.)

3-2. 일시적 리다이렉션 (302, 307, 303) (실무에서 많이 사용함)

-리소스의 URI가 일시적으로 변경

-따라서 검색 엔진 등에서 URL을 변경하면 안됨

3-2-1. 302 Found : 리다이렉트시 요청 메서드가 GET으로 변하고, 본문이 제거될 수 있음 (may)

3-2-2. 307 Temporary Redirect : 302와 기능은 같음 / 리다이렉트시 요청 메서드와 본문 유지 (must)

3-2-3. 303 See Other : 302와 기능은 같음 / 리다이렉트시 요청 메서드가 GET으로 변경 (must)

PRG : Post / Redirect / Get

ex) POST로 주문후에 웹 브라우저를 새로고침하면? -> 새로고침은 다시 요청 -> 중복 주문이 될 수 있다.

-POST로 주문후에 새로 고침으로 인한 중복 주문 방지

-POST로 주문후에 주문 결과 화면을 GET메서드로 리다이렉트

-새로고침해도 결과 화면을 GET으로 조회

-중복 주문 대신에 결과 화면만 GET으로 다시 요청

3-3. 기타 리다이렉션 (300, 304)

3-3-1. 300 Multiple Choices : 안쓴다

3-3-2. 304 Not Modified

-캐시 목적으로 사용

-클라이언트에게 리소스가 수정되지 않았음을 알려준다. 따라서 클라이언트는 로컬 PC에 저장된 캐시를 재사용한다.

-304응답은 응답에 메시지 바디를 포함하면 안된다.

-조건부 GET, HEAD요청시 사용

4. 4xx (Client Error) 클라이언트 오류

-클라이언트의 요청에 잘못된 문법등으로 서버가 요청을 수행할 수 없음

-오류의 원인이 클라이언트에 있음

-중요! 클라이언트가 이미 잘못된 요청, 데이터를 보내고 있기 때문에 똑같은 재시도가 실패함

4-1. 400 Bad Request : 클라이언트가 잘못된 요청을 해서 서버가 요청을 처리할 수 없음

-요청 구문, 메시지 등등 오류

-클라이언트는 요청 내용을 다시 검토하고, 보내야함

4-2. 401 Unauthorized 클라이언트가 해당 리소스에 대한 인증이 필요함

-인증되지 않음

-401 오류 발생시 응답에 WWW-Authenticate 헤더와 함께 인증 방법을 설명

-참고

인증 : 본인이 누구인지 확인 (로그인)

인가 : 권한부여 (ADMIN권한처럼 특정 리소스에 접근할 수 있는 권한, 인증이 있어야 인가가 있음)

오류 메시지가 Unauthorized 이지만 인증되지 않음

4-3. 403 Forbidden 서버가 요청을 이해했지만 승인을 거부함

-주로 인증 자격 증명은 있찌만, 접근 권한이 불충분한 경우

-ex) 어드민 등급이 아닌 사용자가 로그인은 했지만, 어드민 등급의 리소스에 접근하는 경우

4-4. 404 Not Found 요청 리소스를 찾을 수 없음

-요청 리소스가 서버에 없음

-또는 클라이언트가 권한이 부족한 리소스에 접근할 때 해당 리소스를 숨기고 싶을 때

5. 5xx (Server Error)

-서버 문제로 오류 발생

-서버에 문제가 있기 때문에 재시도 하면 성공할 수도 있음

5-1. Internal Server Error 서버 문제로 오류 발생, 애매하면 500오류

-서버 내부 문제로 오류 발생

-애매하면 500오류

5-2. 503 Service Unavailable 서비스 이용 불가

-서버가 일시적인 과부하 또는 예정된 작업으로 잠시 요청을 처리할 수 없음

-Retry-After 헤더 필드로 얼마뒤에 복구되는지 보낼 수도 있음

HTTP 일반 헤더

*HTTP 일반 헤더

HTTP헤더 용도

-HTTP 전송에 필요한 모든 부가정보

-ex) 메시지 바디의 내용, 메시지 바디의 크기, 압축, 인증, 요청 클라이언트, 서버 정보, 캐시 관리 정보...등등

-표준 헤더가 너무 많음

-필요시 임의의 헤더 추가 가능

HTTP헤더 분류 (과거)

-General 헤더 : 메시지 전체에 적용되는 정보

-Request 헤더 : 요청 정보

-Response 헤더 : 응답 정보

-Entity 헤더 : 엔티티 바디 정보

HTTP바디 (과거)

-메시지 본문은 엔티티 본문을 전달하는데 사용

-엔티티 본문은 요청이나 응답에서 전달할 실제 데이터

-엔티티 헤더는 엔티티 본문의 데이터를 해석할 수 있는 정보 제공 (데이터 유형(html, json), 데이터 길이, 압축 정보 등등)

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

RFC723x (현재) 변화

-엔티티 -> 표현(Representation)

-표현 = 표현 메타데이터 + 표현 데이터

HTTP바디 (현재)

-메시지 본문을 통해 표현 데이터 전달

-메시지 본문 = 페이로드

-표현은 요청이나 응답에서 전달할 실제 데이터

-표현 헤더는 효현 데이터를 해석할 수 있는 정보 제공 (데이터 유형(html, json), 데이터 길이, 압축정보 등등)

-참고 : 표현 헤더는 표현 메타데이터와, 페이로드 메시지를 구분해야 한다.

*표현 헤더

1. Content-Type : 표현 데이터의 형식

2. Content-Encoding : 표현 데이터의 압축 방식

3. Content-Language : 표현 데이터의 자연 언어

4. Content-Length : 표현 데이터의 길이

1. Content-Type 표현 데이터의 형식 설명

-미디어 타입, 문자 인코딩

-ex) text/html; charset=utf-8

-ex) application/json

-ex) image/png

2. Content-Encoding 표현 데이터 인코딩

-표현 데이터를 압축하기 위해 사용

-데이터를 전달하는 곳에서 압축 후 인코딩 헤더 추가

-데이터를 읽는 쪽에서 인코딩 헤더의 정보로 압축 해제

-ex) gzip

-ex) deflate

-ex) identity

3. Content-Language 표현 데이터의 자연 언어

-표현 데이터의 자연 언어를 표현

-ex) ko

-ex) en

-ex) en-US

4. Content-Length 표현 데이터의 길이

-바이트 단위

-Transfer-Encoding을 사용하면 Content-Length를 사용하면 안됨

*협상 헤더 (클라이언트가 선호하는 표현 요청)

1. Accept : 클라이언트가 선호하는 미디어 타입 전달

2. Accept-Charset : 클라이언트가 선호하는 문자 인코딩

3. Accept-Encoding : 클라이언으탁 선호하는 압축 인코딩

4. Accept-Language : 클라이언트가 선호하는 자연 언어

Accept-Language를 쓸 때 서버가 내장하고있는 기본언어는 영어인데 한국어를 지원하면 Accept-Language: ko라고 하면 ko가 출력된다. 하지만 기본 언어가 독일어이고 영어를 지원하면 Accept-Language: ko라고 하면 독일어를 출력해준다. 이럴때 필요한 것이 바로 우선순위이다.

Quality Values 협상과 우선순위 1

-Quality Vlaues (q) 값 사용

-0~1, 클수록 높은 우선순위

-생략하면 1

-Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7

Quality Values 협상과 우선순위 2

-구체적인 것이 우선한다.

Quality Values 협상과 우선순위 3

-구체적인 것을 기준으로 미디어 타입을 맞춘다

*전송 방식 설명

1. 단순 전송

2. 압축 전송

3. 분할 전송

4. 범위 전송

1. 단순 전송

Content-Length를 알 수 있을 때 단순 전송을 사용한다.

2. 압축 전송

내용을 압축해서 전송할 때 압축전송을 사용한다. Content-Encoding: gzip 이런식으로 인코딩을 추가해준다

3. 분할 전송

쪼개서 보내고 싶을때 분할 전송을 사용한다. Transfer-Encoding: chunked를 추가해준다. Content-Length를 넣으면 안된다.

4. 범위 전송

예를들어서 이미지를 받는데 중간에 절반까지 받았는데 끊기는 경우 처음부터 다시 전송하면 낭비가 되니 범위를 정해서 보낼 수 있다. Content-Range: bytes 1001-2000 / 2000이런식으로 보내면 된다.

*일반 정보

1. From : 유저 에이전트의 이메일 정보

2. Referer : 이전 웹 페이지 주소

3. User-Agent : 유저 에이전트 애플리케이션 정보

4. Server : 요청을 처리하는 오리진 서버의 소프트웨어 정보

5. Date : 메시지가 생성된 날짜

1. From

-일반적으로 잘 사용되지 않음

-검색 엔진 같은 곳에서 주로 사용

-요청에서 사용

2. Referer

-현재 요청된 페이지의 이전 웹 페이지 주소

-A -> B로 이동하는 경우 B를 요청할 때 Referer: A를 포함해서 요청

-Referer를 사용해서 유입 경로 분석 가능

-요청에서 사용

-참고 : referer는 단어 referrer의 오타

3. User-Agent

-클라이언트의 애플리케이션 정보(웹 브라우저 정보, 등등)

-통계 정보

-어떤 종류의 브라우저에서 장애가 발생하는지 파악 가능

-요청에서 사용

4. Server

-server: nginx

-응답에서 사용

-ORIGIN서버는 진짜 나의 요청이 있는 마지막 서버를 의미한다. 중간의 프록시 서버나 캐시 서버같은 것들이 아니다.

5. Date

-응답에서만 사용

*특별한 정보

1. Host : 요청한 호스트 정보 (도메인)

2. Location : 페이지 리다이렉션

3. Allow : 허용 가능한 HTTP 메서드

4. Retry-After : 유저 에이전트가 다음 요청을 하기까지 기다려야 하는 시간

1. Host

-요청에서 사용

-필수

-하나의 서버가 여러 도메인을 처리해야 할 때

-하나의 IP주소에 여러 도메인이 적용되어 있을 때

2. Location

-웹 브라우저는 3xx응답의 결과에 Location 헤더가 있으면, Location위치로 자동 이동

-201 : Location값은 요청에 의해 생성된 리소스 URI

3. Allow

-405에서 응답에 포함해야함

-Allow : GET, HEAD, PUT

4. Retry-After

-503 : 서비스가 언제까지 불능인지 알려줄 수 있음

-Retry-After : Fri, 31 Dec 1999 23:59:59 GMT (날짜 표기)

-Retry-After : 120 (초 단위 표기)

*인증 헤더

1. Authorization : 클라이언트 인증 정보를 서버에 전달

-Authorization : Basic xxxxxxxxxxxxxxxx

2. WWW-Authenticate : 리소스 접근시 필요한 인증 방법 정의

-리소스 접근시 필요한 인증 방법 정의

-401 Unauthorized 응답과 함께 사용

*쿠키

1. Set-Cookie : 서버에서 클라이언트로 쿠키 전달(응답)

2. Cookie : 클라이언트가 서버에서 받은 쿠키를 저장하고, HTTP 요청시 서버로 전달

홍길동이 어떤 서버에 로그인 하려고 한다고 가정해보자 로그인을 진행하고 로그인 정보를 서버에 넘겼는데 다음 페이지에서 홍길동이 로그인 했다는 것을 증명할만한 무언가가 없다. 그 이유가 뭘까?

Stateless

-HTTP는 무상태 프로토콜이다.

-클라이언트와 서버가 요청과 응답을 주고 받으면 연결이 끊어진다

-클라이언트가 다시 요청하면 서버는 이전 요청을 기억하지 못한다.

-클라이언트와 서버는 서로 상태를 유지하지 않는다.

해결 방안 - 모든 요청에 사용자 정보 포함

문제 - 모든 요청에 사용자 정보가 포함되도록 개발해야함, 브라우저를 완전히 종료하고 다시 열면?

해결 방안 - 쿠키 사용

홍길동이 로그인 하면 쿠키에 홍길동의 정보를 넣어놓는다. Set-Cookie: user=홍길동 쿠키 저장소에 user=홍길동이라는 쿠키를 저장해놓는다. 로그인 이후에 홍길동이 웰컴페이지에 접근하면 서버는 쿠키저장소를 무조건 뒤져서 쿠키값을 찾는다. 이후 홍길동의 쿠키가 발견되면 로그인 했다고 인식한다.

*쿠키

1. 사용처

-사용자 로그인 세션 관리

-광고 정보 트래킹

2. 쿠키 정보는 항상 서버에 전송됨

-네트워크 트래픽 추가 유발

-최소한의 정보만 사용 (세션 id, 인증 토큰)

-서버에 전송하지 않고, 웹 브라우저 내부에 데이터를 저장하고 싶으면 웹 스토리지 참고

-주의! 보안에 민감한 데이터는 저장하면 안됨 (주민번호, 신용카드 번호 등등)

*쿠키의 생명주기

Expires, max-age

-Set-Cookie: expires=Sat, 26-Dec-2020 04:39:21 GMT = 만료일이 되면 쿠키 삭제

-Set-Cookie: max-age=3600 (3600초) = 0이나 음수를 지정하면 쿠키 삭제

-세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

-영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지

*쿠키 - 도메인

ex)domain=example.org

-도메인을 명시하게 되면 명시한 문서 기준 도메인 + 서브 도메인 포함해서 쿠키 접근이 가능하다.

-domain을 생략하면 현재 문서 기준 도메인만 적용되고 다른 도메인에선 접근이 불가능하다.

*쿠키 - 경로

ex) path=/home

-이 경로를 포함한 하위 경로 페이지만 쿠키 접근

-일반적으로 path=/루트로 지정

*쿠키 - 보안

-Secure : 쿠키는 http, https를 구분하지 않고 전송 / Secure를 적용하면 https인 경우에만 전송

-HttpOnly : XSS공격 방지 / 자바스크립트에서 접근 불가 / HTTP전송에만 사용

-SameSite : XSRF공격 방지 / 요청 도메인과 쿠키에 설정된 도메인이 같은 경우만 쿠키 전송

HTTP 캐시와 조건부 동작

*캐시와 조건부 동작

캐시가 없을 때 첫번째 요청

예를 들어서 클라이언트가 GET방식으로 star.jpg를 요청하면 서버는 클라이언트에게 star.jpg를 보내준다.

캐시가 없을 때 두번째 요청

두번째 클라이언트가 GET방식으로 star.jpg를 요청하면 서버는 또 클라이언트에게 star.jpg를 보내준다.

캐시가 없을 때는 데이터가 변경되지 않아도 계속 네트워크를 통해서 데이터를 다운로드 받아야한다. 인터넷 네트워크는 매우 느리고 비싸다. 브라우저는 로딩 속도가 느리다 때문에 느린 사용자 경험이 생긴다.

캐시를 적용할 때

cache-control: max-age=60(초)을 설정하면 캐시가 유효한 시간이 60초이다. 클라이언트가 최초로 요청하면 브라우저 캐시에 60초 유효한 star.jpg를 저장하고 두번째부터 요청이 들어올 때는 캐시를 뒤져서 해당하는 파일이 있으면 해당 파일을 보여줌으로써 캐시에서 조회해서 바로 가져온다. 캐시 덕분에 캐시 가능 시간동안 네트워크를 사용하지 않아도 된다. 비싼 네트워크 사용량을 줄일 수 있고, 브라우저 로딩 속도가 매우 빠르다.

캐시 시간 초과

캐시 유효 시간이 초과하면, 서버를 통해 데이터를 다시 조회하고, 캐시를 갱신한다. 이때 다시 네트워크 다운로드가 발생한다. 만약에 이 star.jpg가 안바뀌고 그대로 남아있는데 단지 캐시시간이 초과되었다는 이유 하나만으로 다시 네트워크를 동작하고 다운받고 해야할까? 클라이언트와 서버가 가지고 있는 데이터가 같으면 다시 내보내줄 수는 없을까? 그 해결책이 바로 다음에 있다.

*검증 헤더와 조건부 요청1

캐시 유효시간이 초과해서 서버에 다시 요청하면 다음 두가지 상황이 나타난다.

1. 서버에서 기존 데이터를 변경함

2. 서버에서 기존 데이터를 변경하지 않음

서버에서 기존 데이터를 변경하지 않았다면 데이터를 전송하는 대신에 저장해 두었던 캐시를 재사용 할 수 있다. 단, 클라이언트의 데이터와 서버의 데이터가 같다는 사실을 확일할 수 있는 방법이 필요하다. 그것이 바로 검증헤더이다.

검증 헤더 추가

첫 번째 요청에 헤더에 Last-Modified: 2020년 11월 10일 10:00 이런식으로 선언해놓으면 이는 데이터가 마지막에 수정된 날짜가 2020년 11월 10일 10시라는 것이 명시된 것이다. 응답 결과를 캐시에 저장하고 60초 유효하다는 것을 적고 데이터 최종 수정일도 같이 기록을 해놓는다.

두 번째 요청에 같은 것을 요청하면 먼저 캐시를 뒤져본다. 뒤져서 같은 데이터가 있다면 최종 수정일을 확인한다. 확인해서 HTTP 문법인 if-modified-since를 확인해보고 데이터 최종 수정일이 캐시에 저장된 최정 수정일과 비교검증을 통해 같으면 서버가 304 Not Modified를 보내면서 HTTP Body를 빼고 헤더만 보낸다. 브라우저 캐시에서는 이를 캐치해서 응답 결과를 재사용한다. 재사용 하면서 헤더 데이터를 갱신해서 다시 60초 유효로 캐시를 갱신한다.

검증 헤더(Last-Modified)와 조건부 요청(if-modified-since)

-캐시 유효 시간이 초과해도, 서버의 데이터가 갱신되지 않으면 304 Not-Modified + 헤더 메타 정보만 응답(바디X)해서 클라이언트는 서버가 보낸 응답 헤더 정보로 캐시의 메타 정보를 갱신한다.

-클라이언트는 캐시에 저장되어있는 데이터 재활용

-결과적으로 네트워크 다운로드가 발생하지만 용량이 적은 헤더 정보만 다운로드한다.

*검증 헤더와 조건부 요청2

1. 검증 헤더

-캐시 데이터와 서버 데이터가 같은지 검증하는 데이터

-Last-Modified, ETag

2. 조건부 요청

-검증 헤더로 조건에 따른 분기

-if-modified-since : Last-Modified 사용

-if-None-Match : ETag 사용

-조건이 만족하면 200 OK

-조건이 만족하지 않으면 304 Not-Modified

if-modified-since 이후에 데이터가 수정되었으면? 데이터 미변경시 304 Not-Modified, 헤더 데이터만 전송 / 데이터가 변경 되었으면? 200 OK 모든데이터 전송

Last-Modified, if-modified-since의 단점

1. 1초 미만 단위로 캐시 조정이 불가능하다

2. 날짜 기반의 로직 사용

3. 데이터를 수정해서 날짜가 다르지만, 같은 데이터를 수정해서 데이터 결과가 똑같은 경우 (A -> B / B -> A)

4. 서버에서 별도의 캐시 로직을 관리하고 싶은 경우

ETag, if-none-match를 사용하면 된다

1. ETag(Entity Tag)

2. 캐시용 데이터에 임의의 고유한 버전 이름을 달아둠

3. 데이터가 변경되면 이 이름을 바꾸어서 변경함 (Hash를 다시 생성)

4. 진짜 단순하게 ETag만 보내서 같으면 유지, 다르면 다시 받기

첫 번째 요청에 ETag: "aaaaa" 를 전송해서 브라우저캐시에 저장 해놓는다.

두 번째 요청에 if-none-match: "aaaaa" 를 보내서 같으면 바뀌지 않은것이니 304 Not-Modified를 출력하고 캐시를 뒤져서 찾는다.

정리

-진짜 단순하게 ETag만 서버에 보내서 같으면 유지, 다르면 다시 받는다.

-캐시 제어 로직을 서버에서 완전히 관리

-클라이언트는 단순히 이 값을 서버에 제공 (클라이언트는 캐시 매커니즘을 모름)

*캐시관련 헤더

1. Cache-Control

2. Pragma (Cache-Control 하위호환)

3. Expires (Cache-Control 하위호환)

1. Cache-Control 캐시 지시어

-Cache-Control: max-age -> 캐시 유효시간, 초단위

-Cache-Control: no-cache -> 데이터는 캐시해도 되지만, 항상 원(origin) 서버에 검증하고 사용

-Cache-Control: no-store -> 데이터에 민감한 정보가 있으므로 저장하면 안됨

*원 서버에 접근

원 서버에 접근할 때 만약 미국에 있는 원서버에 접근한다고 가정 했을 때 0.5초가 걸린다고 가정해보자 그때 중간에 프록시 캐시 서버에다 미리 업로드를 해놓고 한국에 있는 사용자들이 프록시 캐시 서버에 접근해서 0.1초가 걸리게 한다. 프록시 서버에 있는 캐시를 public 캐시 웹 브라우저에 저장되는 캐시는 private 캐시라고 한다.

1. Cache-Control 캐시 지시어 - 기타

-Cache-Control: public -> 응답이 public 캐시에 저장되어도 됨

-Cache-Control: private -> 응답이 해당 사용자만을 위한것임, private 캐시에 저장해야함 (기본값)

-Cache-Control: s-maxage : 프록시 캐시에만 적용되는 max-age

-Age: 60 : 오리진 서버에서 응답 후 프록시 캐시 내에 머문 시간

*캐시 무효화

Cache-Control에 확실한 캐시 무효화 응답이 있다.

Cache-Control: no-cache, no-store, must-revalidate

Pragma : no-cache

이렇게 네개를 넣으면 확실한 캐시 무효화 응답이 된다.

TCP와 UDP의 특징과 차이점

이번 포스팅은 TCP와 UDP가 무엇인지 각각의 특징은 어떤것이 있는지 차이점은 어떤게 차이점인지 그래서 이 둘은 어느상황에서 쓰는지에 대한 내용을 적어보도록 하겠습니다.

우선 TCP와 UDP는 TCP/IP의 전송 계층에서 사용되는 프로토콜입니다.

각각의 정의에 대해서 먼저 알아보도록 하겠습니다.

TCP

TCP를 해석하면 전송을 제어하는 프로토콜이라는 뜻입니다.

일반적으로 TCP와 IP를 함께 사용하는데 IP가 데이터의 배달을 처리한다면 TCP는 패킷을 추적 및 관리하게 됩니다.

여기서 패킷이란 인터넷 내에서 데이터를 보내기 위한 경로배정을 효율적으로 하기 위해서 데이터를 여러 개의 조각들로 나누어 전송을 하는데 이때, 이 조각을 패킷이라고 합니다.

TCP는 연결형 서비스를 지원하는 프로토콜로 인터넷 환경에서 기본으로 사용합니다. 이 말을 들으면 다음과 같은 생각이 떠오를 수 있습니다. 엥? 비연결형인 서비스가 존재하는건가? 그리고 TCP와 달리 UDP는 왜 기본으로 사용되지 않지?

이러한 질문에 대한 대답은 TCP와 UDP가 각각 다른 특성을 가지고 있기 때문인데요, TCP는 아래와 같은 특징을 지닙니다.

TCP의 특징

1. 연결형 서비스로 가상 회선 방식을 제공한다.

• 3-way handshaking 과정을 통해 연결을 설정하고,

• 4-way handshaking 과정을 통해 연결을 해제합니다.

2. 흐름 제어

• 데이터 처리 속도를 조절하여 수신자의 버퍼 오버플로우를 방지합니다.

3. 혼잡 제어

• 네트워크 내의 패킷 수가 과도하게 증가하지 않도록 방지

4. 높은 신뢰성을 보장

• 신뢰성이 높은 전송을 하기 때문에 UDP보다 속도가 느림

5. 전이중, 점대점 방식

• 전이중 : 전송이 양방향으로 동시에 일어날 수 있습니다.

• 점대점 : 각 연결이 정확히 2개의 종단점을 가지고 있습니다.

가상 회선 방식을 제공한다는 것은 발신지와 수신지를 연결하여 패킷을 전송하기 위한 논리적 경로를 배정한다는 의미입니다. 3-way handshaking 과정은 발신지와 수신지 사이에 논리적인 접속을 성립하는 과정을 의미합니다.

TCP가 위와 같은 특징을 지니는 이유는 간단합니다. TCP는 연결형 서비스로, 신뢰성을 보장하기 때문이다. 그래서 3-way handshaking의 과정도 사용하는 것이고, 데이터의 흐름제어나 혼잡제어와 같은 기능도 하는것입니다.

하지만 이러한 기능 때문에 UDP보다 속도가 느리게 됩니다.

그렇기에 TCP는 연송성보다 신뢰성 있는 전송이 중요할 때 사용하는 프로토콜입니다. 예를 들면 파일전송이 바로 그것이지요

TCP의 연결과정돠 연결해제 과정은 다음과 같습니다.

TCP의 연결과정

연결 과정은 3-way handshaking으로 진행됩니다.

#1. Client => Server : 내 말 들려?

#2. Server => Client : 어 잘 들려! 내말은 들려?

#3. Client => Server : 잘 들려!

SYN (synchronize sequence numbers) : 연결 확인을 보내는 무작위 숫자 값

ACK (acknowledgements) : Client 혹은 Server로부터 받은 SYN에 1을 더해 SYN을 잘 받았다는 ACK

TCP 연결 해제 과정

해제 과정은 4-way handshaking으로 진행됩니다.

#1. Client => Server : 나는 다 보냈어! 이제 끊자!

#2. Server => Client : 알겠어! 잠시만~

#3. Serer => Client : 나도 끊을게!

#4. Client => Server : 알겠어!

이렇게 TCP에 대해서 알아봤습니다. 다음은 UDP에 대해서 알아보죠

UDP

UDP는 비연결형 프로토콜입니다.

UDP의 특징으로는 다음과 같습니다.

1. 비연결형 서비스로 데이터그램 방식을 제공합니다.

• 데이터의 전송 순서가 바뀔 수 있습니다.

2. 데이터 수신 여부를 확인하지 않습니다.

• TCP의 3-way handshaking 과 같은 과정이 없습니다.

3. 신뢰성이 낮습니다.

• 흐름 제어와같은 특징이 없어서 제대로 전송되었는지, 오류가 없는지 확인할 수 없습니다.

4. TCP보다 속도가 빠릅니다.

5. 1:1 & 1:N & N:N 통신이 가능합니다.

UDP는 비연결형 서비스이기 때문에 연결을 설정하고 해제하는 과정이 존재하지 않습니다. 서로 다른 경로로 독립적으로 처리하며 흐름 제어 또는 혼잡 제어와 같은 기능을 처리하지 않기에 TCP보다 속도가 빠르며 네트워크 부하가 적다는 장점이 있지만, 신뢰성 있는 데이터 전송을 보장하지 못합니다.

UDP는 신뢰성보다는 연속성이 있는 전송이 필요할 때 사용하는 프로토콜로 예를 들면 스트리밍 서비스에 자주 사용됩니다.

각 프로토콜의 특징을 표로 비교해보면 다음과 같습니다.

정리하자면,

TCP는 연속성보다 신뢰성 있는 전송이 중요할 때 사용되는 프로토콜이며, UDP는 TCP보다 빠르고 네트워크 부하가 적다는 장점이 있지만, 신뢰성 있는 데이터 전송을 보장하지는 않는다. 그렇기 때문에 신뢰성보다는 연속성이 중요한 실시간 스트리밍과 같은 서비스에 자주 사용된다.

여기까지 TCP와 UDP에 대해서 알아봤습니다. 이번 포스팅은 이해하는데 무리가 없었어서 다행이네요. 여러분은 어떠셨나요?

긴 글 읽어주셔서 감사합니다. 오늘도 보람찬 하루 되세요!

출처

https://dev-coco.tistory.com/144

https://mangkyu.tistory.com/15

RESTful API

이번 포스팅에서는 애플리케이션을 설계할 때 한번쯤은 들어봤을 법한 단어인 RESTful에 대해서 알아보도록 하겠습니다.

RESTful API는 현재 많은 애플리케이션에서 사용하고있는 설계방식으로서 이번 시간에는 왜 RESTful이 이렇게 인기가 좋은지, 단점은 뭐가 있는지에 대해서 알아볼겁니다.

RESTful이라는 단어는 직역하면 "REST한" 정도로 해석할 수 있을 것 같네요 즉 REST한 API라고 볼 수 있습니다. 걸리는게 두가지 있네요 REST와 API, 이 두가지에 대해서 설명해야겠죠?

먼저 간단한 API에 대해 알아보고 REST에 대해 자세히 알아보겠습니다.

API

API란 데이터와 기능의 집합을 제공하여 컴퓨터 프로그램간 상호작용을 촉진하며, 서로 정보를 교환 가능하도록 하는 것입니다.

위의 정의는 너무 사전적인 의미라 잘 이해하기 힘드니까 좀 풀어서 설명하자면

"상호간 데이터를 가공하기위해 보내는 요청" 정도로 해석할 수 있습니다. 예시를 보여드리자면

이런 각각의 요청들을 API라고 할 수 있겠습니다.

API는 뭐 간단하니까요 설명은 이정도만 해도 충분할 것 같습니다.

REST

REST에 들어가기 앞서 우리는 한가지 요구사항을 들어줄겁니다.

요구사항 : 회원 정보 관리 API를 만들어라

• 회원 목록 조회

• 회원 조회

• 회원 등록

• 회원 수정

• 회원 삭제

우리는 이 다섯가지 API를 만들어야합니다.

가장 간단한 방법은 이렇게 만드는거죠

• 회원 목록 조회 = /read-member-list

• 회원 조회 = /read-member-by-id

• 회원 등록 = /create-member

• 회원 수정 = /update-member

• 회원 삭제 = /delete member

자 원하던 API를 만들었습니다.

흠... 이게 과연 좋은 URI 설계일까요?

URI를 설계할 때 가장 중요한 것은 바로 리소스 식별 입니다.

리소스의 의미는 뭘까요?

회원을 등록하고 수정하고 조회하는 것이 리소스가 아닙니다. 예를 들어서 미네랄을 캐라! 라고 하면 미네랄이 바로 리소스가 되는 것입니다. 캐라! 가 리소스가 아니란 말이죠

그럼 위의 예제에서는 "회원"이 리소스가 될 것입니다.

URI에서는 리소스만 식별하면 되기 때문에 "회원"이라는 리소스를 URI에 매핑해야 합니다.

그럼 이제 제대로 설계해보죠

• 회원 목록 조회 = /members

• 회원 조회 = /members/{id}

• 회원 등록 = /members/{id}

• 회원 수정 = /members/{id}

• 회원 삭제 = /members/{id}

음... 이렇게 리소스에 집중해서 URI에 매핑했더니 문제가 발생했습니다. 회원 조회, 등록, 수정, 삭제를 어떻게 구분할지 모르겠습니다.

위에서 URI를 설계할 때 가장 중요한 것은 리소스를 식별하는 것이라고 했습니다. URI는 리소스만 식별하고 해당 리소스를 대상으로 하는 행위를 분리하면 됩니다.

그럼 행위는 어떻게 구분할까요?

이때 등장하는게 바로 HTTP Method입니다.

RESTful에서 자주 사용하는 HTTP 메서드가 있습니다. 바로 GET, POST, PUT, PATCH, DELETE 이렇게 다섯가지입니다.

GET과 POST는 익숙하시죠? 애플리케이션을 설계할 때 데이터를 요청, 데이터를 삽입할 때 많이 사용하던 메서드입니다. 그래서 스프링에서 @GetMapping, @PostMapping 이렇게 지원하고 있구요.

간단하니까 빠르게 정리하고 넘어가도록 하겠습니다.

• GET : 리소스 조회 (CRUD 에서 R)

• POST : 요청 데이터 처리, 주로 등록에 사용 (CRUD에서 C)

• PUT : 리소스를 대체, 해당 리소스가 없으면 생성 (CRUD에서 C, U)

• PATCH : 리소스 부분 변경 (CRUD에서 U)

• DELETE : 리소스 삭제 (CRUD에서 D)

GET과 POST는 익숙하고 DELETE는 대충 어떤 느낌인지 알겠는데 PUT과 PATCH가 좀 신경쓰이시죠?

짤막하게 설명하고 넘어가보겠습니다.

PUT은 리소스를 대체하는 것입니다. 그리고 리소스가 없으면 생성하죠

만약 username, age 라는 필드가 두개 있는 user 라는 객체가 있다고 가정해봅시다.

*기존

username : 철수

age : 20

*PUT 요청으로 username을 영희로 변경

username : 영희

PUT은 리소스가 없으면 생성하는 것은 둘째치고 만약 있으면 해당 값을 덮어씌워버립니다. 때문에 나는 username만 바꾸고 싶은데? 하면 PUT을 사용할 수 없습니다.

이런 문제를 해결하기위해 나온 메서드가 바로 PATCH입니다.

*기존

username : 철수

age : 20

*PATCH 요청으로 username을 영희로 변경

username : 영희

age : 20

이렇게 부분적으로만 바꿀 수 있게 도와주는 메서드가 바로 PATCH입니다.

자 그럼 이제 URI를 다시 설계해볼까요?

• 회원 목록 조회 = GET : /members

• 회원 조회 = GET : /members/{id}

• 회원 등록 = POST : /members/{id}

• 회원 수정 = PUT 혹은 PATCH : /members/{id}

• 회원 삭제 = DELETE : /members/{id}

이제 행위와 리소스를 분리함으로써 완벽하게 설계했습니다.

근데 우리 REST 설명하려던거 아니었나요?

제가 지금까지 HTTP 메서드를 설명하면서 설계한 방식이 바로 RESTful입니다.

별로 어렵지 않으시죠? 정리하면 "행위(HTTP Method)와 리소스로 URI를 설계하여 상호간 데이터를 가공하기 위한 요청"이 바로 RESTful API인 것이죠

이런 RESTful API가 왜 인기가 많을까요?

RESTful API의 장/단점

장점

• HTTP 프로토콜의 인프라를 그대로 사용하므로 RESTful API를 위한 별도의 인프라를 구축할 필요가 없다.

• HTTP 프로토콜의 표준을 그대로 따르기 때문에 HTTP가 가지는 장점을 그대로 가진다.

• HTTP 표준 프로토콜을 따르는 모든 플랫폼에서 사용이 가능하다.

• REST API 메세지가 의도하는 바를 명확하게 나타내므로 의도하는 바를 쉽게 파악할 수 있다.

• 서버와 클라이언ㅇ트의 역할을 명확하게 분리한다.

오우...장점이 하나같이 강력하네요 이래서 RESTful을 많이 사용하나봅니다. 하지만 장점이 있으면 단점도 있겠죠? 단점도 한번 알아보죠

단점

• 표준 자체가 존재하지 않아서 정의가 필요하다.

• HTTP 메서드 형태가 제한적이다.

• 구형 브라우저에서 호환이 되지 않아 지원해주지 못하는 동작이 많다.

• 유연하지 못한 특징이 있다.

마지막 단점이 좀 크리티컬합니다. 사실 앞에 세가지는 단점이긴 하지만 그렇게 중요하지는 않습니다.

RESTful의 큰 단점이라고 꼽히는 유연하지 못한 특징은 이런 느낌입니다.

ex) A팀에 속한 "철수"라는 멤버가 구매한 옷

자...위에 있는 데이터를 가지고 오기 위해 API를 설계해봅시다.

/team/A/members/철수/buy/shirt

와... 엄청 기네요? 자 이제 이 API를 날리면 뭐가 나오죠? 철수가 구매한 옷 정보가 나오겠죠?

근데 갑자기 요구사항이 추가됩니다. "철수가 구매한 신발에 대한 정보가 필요할 것 같아요"

그럼 이렇게 해야죠

/team/A/members/철수/buy/shoes

자 이제 이러면 되는거죠?

근데 또 요구사항이 늘어납니다. 그렇게 또...또...또...또...

요구사항이 늘어날때마다 API가 계속해서 추가되어야 합니다. 이거 유지보수 하기 쉽지않겠죠...

이 문제는 RESTful API에서는 해결할 수 없는 문제입니다.

하지만 이런 문제가 있더라도 RESTful만의 강력한 장점으로 인하여 단점이 좀 묻히는 경향이 있습니다. 이 문제를 해결하기 위해 GraphQL 이라는 API가 나오긴 했는데 이건 또 포스팅 하나 분량이기 때문에 다음 포스팅으로 미루도록 하겠습니다.

자 이렇게 RESTful API에 대해서 알아봤습니다. 어떤가요? 저는 공부하면서 그렇게 크게 어려운 부분을 못느꼈던 것 같습니다. 쉽지만 중요한 개념이다보니 이렇게 정리해봤네요.

다음엔 GraphQL에 대해 포스팅해볼 예정입니다. 방문해주셔서 감사하고 오늘도 즐거운 하루 보내세요!

GraphQL

우리는 이전 시간 RESTful API에 대해서 알아봤습니다. 그리고 크리티컬한 단점인 유연하지 못하다는 특징에 대해서도 잠깐 알아봤죠.

RESTful이 유연하지 못하다는 것은 앞서 설명했듯이 요구사항이 계속 추가될 때마다 새로운 API를 만들어야 한다는 것도 있지만 Over Fetching, Under Fetching 이 두가지 문제도 큰 문제 중 하나입니다. 이번 포스팅에서는 RESTful API가 가진 한계에 대해 자세히 알아보고 그 대안책으로 나온 GraphQL에 대해 자세히 알아보도록 하겠습니다.

RESTful API의 한계

REST API의 한계는 너무 명확합니다. 요구사항이 추가될 때마다 새로운 API를 만들어야 하기 때문에 유지보수가 장난아니게 힘들다는 점이 크리티컬했죠.

REST API는 간단한 애플리케이션에선 빛을 발하지만 애플리케이션이 점점 커지면서 대규모 서비스로 발전해나가면 점점 힘들어집니다. 관리해야 하는 API가 한두개가 아니기 때문이죠

또한 Over-Fetching과 Under-Fetching이라는 문제도 있습니다. 생소하니 바로 알아보시죠

Over-Fetching

예를 들어, 사용자의 데이터를 조회하는 /user API가 있다고 가정해봅시다. 이 때, userId : 1에 해당하는 데이터를 조회한다면 아래와 같은 형태가 됩니다.

request : GET /user/1

response body

{

"user_id" : 1,

"user_name" : "철수",

"user_grade" : "VIP",

"last_login_timestamp" : "2023-02-25 12:11:45"

...

}

여기서 클라이언트는 1번에 해당하는 유저의 이름만을 사용하고 싶습니다. 이름만 반환하는 API가 따로 없다면 같은 /user/1 API를 호출한 다음 user_name에 해당하는 필드만 가져와서 사용해야 합니다.

이 때, user_grade나 last_login_timestamp와 같은 필드도 같이 반환됩니다. 이는 곧 리소스 낭비라고 볼 수 있고 이와 같은 낭비를 Over-Fetching이라고 합니다.

Under-Fetching

만약 쇼핑몰의 서비스인 경우, 로그인한 사용자의 장바구니 정보를 보여준다고 가정하면 여러 API를 호출하게 됩니다.

ex) /user/1/cart/notification/wish/.....

요청에 맞게 유효한 데이터를 보여주기 위해 여러 API를 호출하게 되는 경우를 Under-Fetching이라고 합니다.

GraphQL

GraphQL은 위에서 설명한 것처럼 REST API의 한계를 극복하고자 나왔습니다. Endpoint는 통상 1개만 생성하고 클라이언트에게 필요한 데이터는 클아이너트가 직접 쿼리를 작성, 호출하여 반환받습니다.

위의 문제인 Over-Fetching과 Under-Fetching은 GraphQL을 사용하면 손쉽게 해결할 수 있습니다.

request

query {

user(user_id: 1) {

user_name

}

}

response body

{

"data": {

"user": {

"user_name": "철수"

}

}

}

이렇게 함으로써 Under-Fetching이 해결됩니다.

또한, Over-Fetching 문제도 해결할 수 있습니다. 아래와 같이 말이죠

query {

cart {

product_name

price

}

notification {

is_read

}

user(user_id: 1) {

user_name

user_grade

}

}

response body

{

"cart": [{

"product_name": "shoes",

"price": 12000

}, ...

],

"user": {

"user_name": "철수",

"user_grade": "VIP"

}

}

정말 너무 좋아보이는 GraphQL의 장점과 단점에 대해 알아보죠

장점

• 클라이언트가 필요한 데이터만 반환할 수 있음

• 1번의 호출로 원하는 데이터를 한번에 가져올 수 있음

• REST API의 N + 1 문제를 해결할 수 있음

• 확장이 용이함

장점중에 좀 이상한게 섞여있는 것 같습니다. N + 1 문제? 이거 ORM에서만 일어나는 문제 아니었나? API에서도 N + 1 문제가 발생하나?

N + 1 문제는 ORM만의 특별한 문제가 아닙니다. N + 1 문제는 REST API와 같은 web API에서도 발생할 수 있습니다.

우리가 흔히 알고있는 N + 1 문제와 같이 자식 리소스를 N 번 컬렉션 리소스로 가져오기 위해 N + 1 번 서버로 응답이 요청되는 상황을 말합니다.

이런 경우 대게 컬렉션 리소스가 자식리소스에 대해 충분한 정보를 제공하지 않았기 때문에 발생합니다.

한가지 예시를 들어보죠

<books uri="/books" size="100">

<book uri="/books/1" id="1">

<isbn>3434253561</isbn>

</book>

<book uri="/books/2" id="2">

<isbn>3423423534</isbn>

</book>

<book uri="/books/3" id="3">

<isbn>5352342344</isbn>

</book>

...

...

</books>

위의 예제는 book의 id와 isbn만을 포함한 정보를 가진 book list를 반환하는 /books API입니다. 이 API는 뭔가 부실하죠? 보통 우리가 책에 대한 정보를 가져오기 위해 ISBN 말고 책 이름이나 저자를 주로 찾아올텐데 위와 같은 API에는 해당 정보가 들어있지 않습니다.

위와 같은 시나리오에서 클라이언트 애플리에키션은 반드시 /books/{id}에 있는 각각의 book에 대한 리소스를 N개만큼 더 요청해야 합니다. 결과적으로 REST API는 N + 1 번의 요청을 수행하게 되죠

위의 예제는 단 하나의 예제만을 보여주는 것이고 이렇듯이 REST API에서도 불충분한 정보로 인한 N + 1 문제가 발생할 수 있습니다.

단점

• 백엔드, 프론트엔드 개발자 양쪽 다 러닝커브가 있음

• 단순한 서비스에서는 사용하기가 복잡함

• 캐싱 기능의 구현이 복잡함

• 요청이 text로 날아가기 때문에 File 전송 등을 구현하기가 까다로움

• 접근성과 호환성이 아직 부족함

이번 GraphQL도 REST와 마찬가지로 마지막 단점이 크리티컬한데요. REST는 HTTP의 규약을 그대로 따르기 때문에 HTTP를 사용하는 서비스에 그냥 아무렇게나 갖다 붙여도 돌아가기 마련입니다. 하지만 GraphQL은 그렇지 않죠...

REST API는 전 세계에서 오랫동안 사랑받았으며, 지금도 여전히 유용한 패러다임입니다. 현재 구현된 대부분의 API 서비스가 REST를 따른다고 해도 과언이 아니죠. 모두가 사용하고 따르는 기술이든 무엇이든 그럴만한 이유가 있는 것 같습니다.

GraphQL이 신기술이며 새로운 패러다임을 제공하긴 했지만 REST를 대체할만한 기술인 것 같지는 않은것 같습니다.

REST는 REST만의 특징이 있고 GraphQL은 GraphQL만의 특징이 있으니 상황에 맞게 적절히 골라 사용하면 좋을 것 같습니다.

여기까지 GraphQL에 대해 알아봤습니다. 처음에 이해하는데 조금 애먹었던 주제였습니다. 제가 사용해보지 않았던 기술이기 때문에 어떤 느낌인지 처음엔 와닿지않았는데 계속 보다보니 이해가 된 것 같습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 좋은 하루 보내세요!

출처

https://hwasurr.io/api/rest-graphql-differences/

https://velopert.com/2318

https://brownbears.tistory.com/450

https://restfulapi.net/rest-api-n-1-problem/

OSI 7 계층, TCP / IP 4 계층

네트워크를 공부할 때 OSI 7계층이나 TCP 4계층에 대해서 들어본적이 있을겁니다.

우리는 이번 포스팅에서 OSI 7계층, TCP 4계층이 무엇인지, 이 둘의 차이는 무엇인지, 이 계층을 알면 뭐가 좋은지에 대해서 알아보도록 하겠습니다.

우선 포스팅에 들어가기 앞서 OSI 7계층과 TCP 4계층은 크게 다르지 않다는 것을 말씀드리고 시작해보겠습니다.

OSI 7계층

OSI 7계층과 TCP 4계층을 그림으로 나타내면 다음과 같습니다. 우선 OSI 7계층이 뭔지 알아야겠죠?

OSI는 Open System Interconnection의 약자입니다. 이름만 봐도 대충 어떤 느낌인지 알것 같습니다.

OSI 모델은 컴퓨터 시스템이 네트워크위에서 의사소통을 위해 사용되는 일곱개의 층입니다. 즉 OSI 7계층은 네트워크 커뮤니케이션을 위한 스탠다드한 모델이라는 것이죠.

OSI 모델은 네트워크가 어떻게 동작하는지에 대해서 시각화를 도와주는 역할을 합니다. 그리고 네트워크 문제에서 문제를 어떻게 해결해야 하는지에 대한 방법을 제공해줍니다.

그럼 각각의 계층에 대해서 알아보죠

1계층 - 물리 계층 (Physical Layer)

이 계층에서는 주로 전기적, 기계적, 기능적인 특성을 이용해서 통신 케이블로 데이터를 전송하게 됩니다. 이 계층에서 사용되는 통신 단위는 비트이며 1 또는 0으로 나타내어지는 즉, 전기적인 On, Off 상태라고 생각하면 됩니다.

이 계층에서는 단지 데이터를 전달만 할뿐 전송하려는 데이터가 무엇인지, 어떤 에러가 있는지에 대해서는 전혀 신경쓰지 않습니다.

단지 데이터를 전기적인 신호로 변환해서 주고받는 기능만 할 뿐입니다. 이 계층에 속하는 대표적인 장비는 통신 케이블, 리피터, 허브등이 있습니다.

2계층 - 데이터 링크계층 (DataLink Layer)

물리 계층을 통해 송수신되는 정보의 오류와 흐름을 관리하여 안전한 정보의 전달을 수행할 수 있도록 도와주는 역할을 합니다. 따라서 통신에서의 오류도 찾아주고 재전송도 하능 기능을 가지고 있죠.

이 계층에서는 맥 주소를 가지고 통신하게 됩니다. 이 계층에서 전송되는 단위를 프레임이라고 하고, 대표적인 장비로는 브릿지, 스위치 등이 있습니다.

데이터 링크 계층은 점대점 방식으로 신뢰성 있는 전송을 보장하기 위한 계층으로 CRC기반의 오류 제어와 흐름 제어가 필요합니다. 네트워크 위의 개체들 간 데이터를 전달하고, 물리 계층에서 발생할 수 있는 오류를 찾아내고, 수정하는데에 필요한 기능적, 절차적 수단을 제공합니다.

3계층 - 네트워크 계층 (Network Layer)

이 계층에서 가장 중요한 기능은 데이터를 목적까지 가장 안전하고 빠르게 전달하는 기능 즉, 라우팅 기능입니다. 여기에 사용되는 프로토콜의 종류도 다양하고 라우팅하는 기술도 다양합니다.

이 계층은 경로를 선택하고 주소를 정하고 경로에 따라 패킷을 전달해주는 것이 이 계층의 역할입니다. 이 계층의 대표적인 장비는 라우터입니다.

네트워크 계층은 여러개의 노드를 거칠때마다 경로를 찾아주는 역할을 하는 계층으로 다양한 길이의 데이터를 네트워크들을 통해 전달하고, 그 과정에서 전송 계층이 요구하는 서비스 품질을 제공하기 위한 기능적, 절차적 수단을 제공합니다.

네트워크 계층은 라우팅, 흐름 제어, 세그멘테이션, 오류제어, 인터네트워킹 등을 수행합니다.

라우터가 이 계층에서 동작하고 이 계층에서 동작하는 스위치도 있습니다. 데이터를 연결하는 다른 네트워크를 통해 전달함으로써 인터넷이 가능하게 만드는 계층입니다.

논리적인 주소 구조 (IP) , 곧 네트워크 관리자가 직접 주소를 할당하는 구조를 가지며, 계층적입니다.

4계층 - 전송 계층 (Transport Layer)

전송 계층은 통신을 활성화하기 위한 계층입니다. 보통 TCP 프로토콜을 이용하며, 포트를 열어서 응용프로그램들이 전송을 할 수 있게해줍니다. 만약 데이터가 왔다면 4계층에서 해당 데이터를 하나로 합쳐서 5계층에 던져줍니다. 오류제어 및 흐름제어는 이 계층까지 물리적인 계층에 속합니다.

전송 계층은 양 끝단의 사용자들이 신뢰성있는 데이터를 주고 받을 수 있도록 해주어 상위 계층들이 데이터 전달의 유효성이나 효율성을 생각하지 않도록 해줍니다.

전송계층은 특정 연결의 유효성을 제어하고, 일부 프로토콜은 상태 개념이 있고(stateful, stateless) 연결 기반입니다.

5계층 - 세션 계층 (Session Layer)

데이터가 통신하기 위한 논리적인 연결을 말합니다. 통신을 하기위한 대문이라고 보면 됩니다. 하지만 4계층에서도 연결을 맺고 종료할 수 있기 때문에 우리가 어느 계층에서 통신이 끊어졌나 판단하기에는 한계가 있습니다.

그러므로 세션 계층은 4계층과 무관하게 응용 프로그램 관점에서 봐야합니다. 세션 설정, 유지, 종류, 전송 중단시 복구등의 기능이 있습니다.

세션 계층은 양 끝단의 응용프로세스가 통신을 관리하기 위한 방법을 제공합니다. 동시 송수신 방식, 반이중 방식, 전이중 방식의 통신과 함께, 체크 포인팅과 유휴, 종료, 다시 시작 과정등을 수행합니다. 이 계층은 TCP / IP 세션을 만들고 없애는 책임을 집니다.

6계층 - 표현 계층 (Presentation Layer)

데이터 표현이 상이한 응용 프로세스의 독립성을 제공하고, 암호화 합니다.

표현 계층은 코드 간의 번역을 담당하여 사용자 시스템에서 데이터의 형식상 차이를 다루는 부담을 응용계층으로부터 덜어줍니다. MIME 인코딩이나 암호화등의 동작이 이 계층에서 이루어집니다.

7계층 - 응용 계층 (Application Layer)

최종 목적지로서 HTTP, FTP, SMTP, POP, IMAP, Telnet 등과 같은 프로토콜이 있습니다.

해당 통신 패킷들은 방금 나열한 프로토콜에 의해 모두 처리되며 우리가 사용하는 브라우저나, 메일 프로그램은 프로토콜을 보다 쉽게 사용하게 해주는 응용프로그램입니다. 한마디로 모든 통신의 양 끝단은 HTTP와 같은 프로콜이지 응용프로그램이 아닙니다.

응용 계층은 은용 프로세스와 직접 관계하여 일반적인 응용 서비스를 수행합니다. 일반적인 응용 서비스는 관련된 응용 프로세스들 사이의 전환을 제공합니다.

자 이렇게 7개의 계층에 대해서 알아봤습니다.

그럼 OSI 7계층과 TCP 4계층에 대해서 차이점을 알아볼까요?

OSI 7계층 vs TCP 4계층

TCP 모델은 OSI 모델보다 오래된 모델입니다. 가장 큰 차이점은 TCP는 OSI보다 단순하다는 것입니다.

OSI의 5, 6, 7계층이 TCP에선 Application Layer로 결합되어있고, OSI 1, 2계층이 TCP에서 Network Access Layer로 결합되어 있습니다.

TCP는 특정한 커뮤니케이션 문제를 해결하기위해 디자인된 기능적인 모델이고 OSI는 모든 네트워크 커뮤니테이션을 묘하하기위해 의도된 좀 더 일반적이고 프로토콜에 의존적인 모델입니다.

둘의 차이는 없지만 상황에 맞춰서 다른 모델을 사용한다 정도로만 알아두시면 좋을 것 같습니다.

그래서 OSI 7계층과, TCP 4계층이 뭔지도 알았고 차이점에 대해서도 알았는데 이거 알아서 어디다 써?

우리가 OSI 7계층을 알아야 하는 이유

IT에 종사하고 있는 대부분의 사람들은 이 각각의 층에 대해서 알아야합니다. 마치 사회학과 출신이 정부의 개념에 대해서 알아야 하는 것과 마찬가지로 말이죠.

OSI 모델의 목적에 대해 말한 Quora 포스팅에서 Vikram Kumar는 이렇게 말했습니다.

"OSI의 레퍼런스 모델의 목적은 각각의 벤더나 개발자들이 응용해서 만든 그들의 디지털 커뮤니케이션 프로덕트나 소프트웨어 프로그램을 가이드하기 위해 만들어졌습니다. 그리고 커뮤니케이션 도구로써 용이하게 사용되기 위해 만들어졌죠"

몇몇 사람들은 이렇게 주장합니다.

"OSI 모델은 진부하다"

"OSI 모델은 TCP 모델에 비해서 덜 중요하다"

하지만 Kumar는 이렇게 말했죠. 오늘날 우리는 OSI모델이나 OSI모델의 계층을 말하지 않고는 네트워크 기술에 대해서 말하는 것이 대단히 힘들어졌다고요.

OSI모델과 그것들의 계층에 대해서 이해할 수 있다면, 우리는 각각의 프로토콜과 디바이스가 새로운 기술과 새로운 방법으로 개발되어도 잘 이해할 수 있게 될 것입니다.

마치며

OSI 7계층... 참 어렵네요 여러번 다시 봐야할 것 같습니다. 하지만 네트워크의 기본이 되는 지식이니만큼 꼭 집고 넘어가야겠습니다.

여기까지 OSI 7계층 TCP 4계층, 둘의 차이점 그리고 왜 OSI 7계층을 알아야 하는지에 대해서 알아봤습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.imperva.com/learn/application-security/osi-model/

https://www.networkworld.com/article/3239677/the-osi-model-explained-and-how-to-easily-remember-its-7-layers.html

https://shlee0882.tistory.com/110

동기, 비동기 프로그래밍

이번 포스팅에선 동기화 프로그래밍, 비동기화 프로그래밍에 대해서 알아보도록 하겠습니다.

동기화 프로그래밍과 비동기화 프로그래밍은 네트워크 공부를 하다보면 쉽게 접할 수 있는 주제입니다. 그만큼 중요한 주제이기도 하죠. 이번 포스팅에선 동기화, 비동기화 프로그래밍이 무엇인지, 둘의 차이는 무엇인지, 둘의 장단점은 무엇인지, 둘의 사용처는 어디인지에 대해서 자세히 알아보도록 하겠습니다.

동기화, 비동기화의 특징

동기화

동기는 Synchronous 줄여서는 sync라고도 부릅니다. 동기는 Blocking Architecture라고도 불리고 반응형 시스템을 위한 프로그래밍을 할 때 사용하면 좋습니다.

동기는 굉장히 엄격한 순서를 가지고 동작하는데 request한번 response한번 이렇게 한 세트로 동작하는데 request를 보낸 스레드가 response를 받을 때까지 무한정 기다리는 특징이 있습니다.

비동기화

비동기는 Asynchronous 줄여서 async라고도 부릅니다. 비동기는 Non-Blocking Architecture라고도 불리고 동기와 다르게 멀티스레드로 동작한다는 특징이 있습니다. Non-Blocking이라는 의미는 통신이 진행되는 동안 여러개의 실행이 동시다발적으로 일어난다는 의미입니다.

동기 프로그래밍은 여러개의 연관된 실행이 다른 일이 끝날 때 까지 가디리지 않고 동시에 수행됩니다. 여러명의 개발자가 같은 시간에 프로젝트를 동시에 수행할 수도 있고 이는 애플리케이션의 확장을 도와주는 역할을 합니다.

Javascript

자바스크립트는 동기와 비동기의 특징을 모두 담고 있는 언어입니다. 자바스크립트는 비동기 언어 동시 프로그래밍 언어라고도 불리며 이는 많은 유연성을 제공한다는 의미입니다.

자바스크립트는 동기 프로그래밍처럼 싱글 스레드로 동작하지만 비동기처럼 Non-Blocking으로 동작합니다.

자바스크립트를 생각해보시면 carousel이 움직이고 있는 도중에 다른 버튼이 동작하지 않으면 안되겠죠? 그런 특성 때문에 자바스크립트는 비동기의 특징을 담고 있는 언어 중 하나입니다.

자바스크립트의 멋있는 점은 동기 그리고 비동기의 특징을 모두 가지고 있다는 것인데 이는 싱글스레드와 멀티스레드, 동기화와 비동기화의 특징을 모두 담고 있다고 볼 수 있습니다.

이런 유연함을 가지고 개발자들은 동기화의 특징인 개발자 친화적이라는 장점과 비동기화의 특징인 유저 친화적이라는 장점을 모두 가지고 있습니다. 이 개발자 친화, 유저 친화에 대해서는 뒤에서 자세히 설명하도록 하겠습니다.

동기 vs 비동기 차이점과 각각의 장단점

비동기는 Non-Blocking Architecture이고 그렇기 때문에 각각의 실행이 다른 실행에 영향을 받지 않고 동시다발적으로 실행됩니다. 동기는 Blocking Architecture이고 그렇기 때문에 각각의 실행이 다른 실행에 의존적인 모습을 보여줍니다.

이 둘의 특징에 대해서 좀 더 자세히 알아보죠

동기 vs 비동기 차이점

• 비동기는 멀티스레드이고 각각의 실행이나 프로그램이 병렬적으로 실행됩니다. 동기는 싱글스레드이고 각각의 실행이나 프로그램이 한번에 하나씩 실행됩니다.

• 비동기는 Non-Blocking입니다. 그렇기때문에 서버에 다수의 요청을 보낼 수 있습니다. 막히지 않으니까요 동기는 Blocking이기 때문에 한번에 하나씩만 서버에 요청을 보낼 수 있습니다. 그리고 서버에서 응답을 보낼 때까지 기다려야하죠.

• 비동기는 다수의 실행을 한번에 처리할 수 있기 때문에 수율(throughput)이 좋습니다. 비동기는 그렇지 않기 때문에 속도가 느리죠.

동기 vs 비동기 장단점

동기 프로그래밍 이점

• 예측 가능한 결과 : 동기 프로그램은 예측가능한 실행을 보여줍니다. 선형적인 동작이라고도 할 수 있습니다. 코드의 흐름을 파악하는 디버깅을 진행할 때 유용한 모습을 보여줍니다.

• 더 적은 메모리 사용 : 동기 프로그래밍은 한번에 하나의 일만 처리하기 때문에 여러개의 작업에 대한 상태를 많이 저장해둘 필요가 없습니다. 이는 더 적은 리소스가 필요하다는 의미이고 이는 곧 메모리의 사용이 적어진다는 의미입니다.

• 이해하기 쉬운 코드 : 동기 프로그래밍은 따라가기 쉬운 코드를 작성할 수 있습니다. 작업들은 특정한 순서를 따라 움직이고 그렇기 때문에 우리는 너무 복잡하지 않게 될 것입니다.

• 더 효율적이다 : 동기 프로그램은 예측 불가능한 이슈나 에러를 만나기 쉽지 않기 때문에 더 효율적입니다.

• 더 적은 오버헤드 : 동기 프로그램은 더 적은 오버헤드를 가지고 있습니다. 동기 프로그램은 동시에 다수의 작업을 관리하는 짐이 없기 때문에 오버헤드가 적습니다.

동기 프로그래밍의 단점

• 느린 실행속도 : 동기 프로그램은 비동기 프로그램에 비해 느린 속도를 가지고 있습니다. 왜냐하면 앞선 작업이 끝날 때까지 기다려야하기 때문입니다.

• 확장하기 어려움 : 동기 프로그램은 확장하기 꽤나 까다롭습니다. 새로운 작업을 하나 추가하는 것은 곧 그만큼의 성능적인 디메리트를 가지고 간다는 것이기 때문입니다.

• 동시다발적으로 작업하는 것에 제한이 있다 : 동기 프로그래밍은 동시에 여러 작업을 처리할 수 없기 때문에 동시다발적으로 작업하는 일에 대해서는 적합한 방법이 아닐 수 있습니다.

비동기 프로그래밍 이점

• 빠른 실행속도 : 비동기 프로그램은 동기 프로그램에 비해 더 빠른 속도를 보여줍니다. 작업들은 병렬적으로 실행되고 각각의 작업들은 서로의 작업을 기다릴 필요가 없기 때문입니다.

• 확장하기 쉬움 : 비동기 프로그램은 확장하기 쉽습니다. 여러개의 작업들이 동시에 실행될 수 있기 때문입니다.

• 동시에 처리하기 쉽다 : 비동기 프로그램은 동시에 처리하는 것이 가능합니다. 많은 작업들이 동시에 실행되는 것에 제한이 없습니다.

• 높은 수율(throughput) : 비동기 프로그램은 높은 수율을 가지고 있습니다. 더 짧은 시간에 더 어려운 작업을 수행할 수 있습니다.

비동기 프로그래밍의 단점

• 더 복잡하다 : 비동기 프로그램은 동기 프로그램에 비해 더 복잡합니다. 다수의 작업이 동시에 실행되어야 하기 때문에 디버그하기 힘들고 이해하기도 힘듭니다.

• 더 많은 메모리를 소모한다 : 비동기 프로그램은 동시에 다수의 작업을 관리하기 위한 더 많은 메모리를 요구합니다.

• 높은 오버헤드 : 비동기 프로그램은 같은 시간에 다수의 작업을 처리해야 하기 때문에 높은 오버헤드가 발생할 수 있습니다.

• 에러의 위험을 가지고있다 : 비동기 프로그래밍은 더 많은 에러를 야기합니다. 왜냐하면 예측 불가능한 에러로 인한 데드락이 야기될 수도 있습니다.

동기 vs 비동기 사용처

만약 동기 프로그래밍은 사용한다면 single task에 집중하는 경우 사용하면 좋습니다. 혹은 일이 매우 의존적이고 병렬적으로 일을 수행하지 않는 곳에서 사용하면 좋습니다.

동기 프로그래밍 사용처

• Web Page : 웹 페이지를 동기적으로 로딩함으로써 우리는 해당 페이지의 내용물을 찾고 카테고리화하는 Search Engine Optimization 엔진을 작동하기 더 쉬워집니다.

• Video Rendering : 비디오를 렌더링하는 작업은 CPU에 큰 부하를 가져다줍니다. 그러므로 병렬적으로 이를 수행하게 되면 시스템이 죽어버릴 수도 있습니다.

비동기 프로그래밍은 각자에 의존하지 않으면서 많은 양의 작업을 해야하는 경우나 작업들이 시간이 오래걸리는 경우에 사용하면 좋습니다.

• Database Manipulation : 특히 많은 정보를 가지고 있는 데이터베이스의 경우 데이터베이스에서 정보를 검색할 때 많은 양의 쿼리가 수행되는 경우에 사용하면 좋습니다. 각각의 작업에 대해 다른 스레드를 부여함으로써 우리는 우리의 애플리케이션 동작이 결과를 기다리는 동안 안정을 취할 수 있게 해줍니다.

• Dashboards : 만약 우리가 이전에 많은 정보를 가진 복잡한 대쉬보드를 가지고 있다면 우리는 실제 시간으로 업데이트된 각각의 파트를 유지하기위해 다른 스레드를 부여할 수 있습니다. 이렇게 함으로써 시스템을 핸들링하기 더 쉬워집니다.

결론적으로 만약 우리의 애플리케이션이 비교적 단순하다면 비동기 프로그래밍을 사용하는 것은 일반적으로 추천되지 않습니다. 비동기 프로그래밍은 속도를 높여주지만 그만큼 복잡하고 에러가 증가할 수도 있습니다.

여기까지 동기, 비동기 프로그래밍에 대해서 알아봤습니다. 요즘 너무 딥한 내용을 공부해서 환기를 시킬겸 조금 쉬운 주제를 선택했는데요. 기본적인 개념에 해당하는 내용인만큼 이해하는데 크게 어려움을 느끼진 않은 것 같습니다.

동기 프로그래밍, 비동기 프로그래밍은 면접에서 자주 물어보는 질문 중 하나니까요. 잘 준비하셔서 면접에서 좋은 결과 있기를 바라겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.mendix.com/blog/asynchronous-vs-synchronous-programming/

=> 동기, 비동기 프로그래밍에 대한 docs

https://datamyte.com/synchronous-vs-asynchronous/

=> 동기, 비동기 프로그래밍에 대한 장단점

https://distantjob.com/blog/synchronous-vs-asynchronous-programming/

=> 동기, 비동기 프로그래밍의 사용처

로드 밸런싱전략 (동적 로드밸런싱, 정적 로드밸런싱)

이번 포스팅에서는 로드 밸런싱에 대해서 알아볼 예정입니다.

로드 밸런싱이란 쉽게 말해서 트래픽을 분산 처리 해주는 시스템인데요. 이름 그대로 짐 (Load) 을 나눠주는 (Balancing) 시스템이죠.

요즘 IT 대기업을 필두로 엄청난 트래픽이 유발되고 있죠? 점심시간에 폭주하는 트래픽을 감당해야 하는 배달의 민족이나 명절 때 엄청난 트래픽을 감당해야 하는 쿠팡까지 로드 밸런싱을 사용하지 않는 기업은 없을 것 같은데요. 우리는 이번 포스팅에서 로드 밸런싱이 무엇인지, 로드 밸런싱을 구현한 알고리즘은 뭐가 있는지, 로드 밸런싱에는 어떤 것들이 있는지에 대해서 알아보도록 하겠습니다.

로드 밸런싱 (Load Balancing)

로드 밸런싱은 둘 이상의 컴퓨터 사이에서 작업을 분산시켜주는 작업을 뜻합니다. 인터넷 세상에서 로드 밸런싱은 서버들 사이에서 네트워크 트래픽을 분산하기위해 사용하는 방법입니다.

이 로드 밸런싱을 사용하게 되면 각각의 서버가 부담해야하는 일이 줄어들고 서버가 더 효율적으로 굴러가며 성능이 올라가고 레이턴시가 줄어듭니다. 이렇기 때문에 로드 밸런싱은 인터넷 애플리케이션이 잘 구동되기 위해서 필수적인 요소가 되었습니다.

이해를 돕기 위해 뒤에서도 사용할 예시를 들어보겠습니다.

편의점에 8개의 줄이 있고 하나만 활성화 되어있다면 줄이 길어지고 처리가 느려지겠죠? 반대로 8개가 모두 활성화 되어있다면 줄을 한줄로 서서 처리해서 8배나 빨라지는겁니다.

이렇듯 로드 밸런싱은 효율이 참 정직해서 로드 밸런싱을 많이하면 많이 좋아지고 적게하면 적게 좋아지는 시스템입니다.

로드 밸런싱에는 하드웨어 로드 밸런싱과 소프트웨어 로드 밸런싱이 있습니다. 각각의 장단점에 대해서 알아보고 넘어가죠

소프트웨어 로드 밸런싱 장점

• 클라이언트의 요구에 따라 유연하게 처리할 수 있다

• 소프트웨어 인스턴스를 추가함으로써 초기용량를 확장하고 시작할 수 있다

• 물리적인 기기를 사고, 유지하는 것보다 비용이 싸다

• 클라우드 로드 밸런서에 백업을 할 수 있다

소프트웨어 로드 밸런싱 단점

• 초기용량을 확장할 때 로드 밸런서 소프트웨어가 작동할 동안 딜레이가 생길 수 있다

• 업그레이드 함에 있어서 비용이 지속적으로 든다

하드웨어 로드 밸런싱 장점

• 특정 프로세서때문에 높은 수율(throughput)을 보장할 수 있다

• 회사가 물리적으로 서버를 늘리게 되면 보안적으로 안전해진다

• 비용이 지속적으로 들지는 않는다. (고정 비용이 발생하기는 함)

하드웨어 로드 밸런싱 단점

• 물리적인 기기를 작동하기위한 더 많은 전문가가 필요하다

• 서버를 두기위한 물리적인 공간이 필요하고 유지보수가 필요하다

• 물리적인 네트워크 로드 밸런서를 유지하고 구매하는데 높은 가격이 들어간다

이렇게 로드 밸런싱에 대한 개략적인 내용과 하드웨어, 소프트웨어 로드 밸런싱에 대한 특징까지 알아봤습니다. 다음은 로드 밸런싱을 구현한 알고리즘을 알아보도록 하겠습니다.

로드 밸런싱 알고리즘

로드 밸런싱 알고리즘은 크게 Static Load Balancing Algorithm과 Dynamic Load Balancing Algorithm이 있습니다. 우선 각각에 대한 설명과 어떤 것이 포함되어 있는지 알아보죠

Static Load Balancing Algorithm

정적인 로드 밸런싱 알고리즘은 말 그대로 정적입니다. 현재 시스템의 상태를 체크하는 것 없이 작업을 분할하는 특징이 있습니다. 작업을 순서에 맞춰 균등 분배한다고 생각하시면 됩니다.

정적 로드 밸런싱 알고리즘에서는 옆에있는 서버가 작업을 느리게하는지 잘 운용되고 있는지 신경을 안씁니다. 그냥 순서에 맞춰서 일을 분배할 뿐이죠. 그래서 어찌보면 굉장히 비효율적이라고 느낄 수도 있습니다.

위의 편의점 예시를 다시 가져와서 설명해드리자면, 8개의 카운터에 줄 서는 것을 지휘하는 로드 밸런서가 있고 이 사람은 각각의 계산대의 상황과 관계없이 매뉴얼대로 사람을 분배합니다.

이러한 알고리즘은 모든 창구가 정상적으로 돌아가고 있으면 정말 효율적이겠지만 만약 어떤 계산대에서 문제가 생겨서 한쪽 줄이 길게 늘어진 상황에서도 로드 밸런서가 이 문제있는 계산대에 사람을 보낼 수도 있다는 얘기입니다.

정적인 로드 밸런싱 알고리즘에 어떤 알고리즘이 있는지 알아보도록 하죠

Round Robin Algorithm

라운드 로빈 알고리즘은 DNS 순서에 맞게 트래픽을 서버에 보냅니다. DNS에 적혀있는 리스트 목록대로 서버에 보내기 때문에 구현이 굉장히 간단하다는 특징이 있습니다. 라운드 로빈 알고리즘은 여러대의 서버가 동일한 스펙을 갖고 있고, 서버와의 연결이 오래 지속되지 않는 경우에 활용하기 적합합니다.

Weighted Round Robin Algorithm

가중치가 있는 라운드 로빈 알고리즘은 각 서버의 가중치를 부여하고 그 가중치에 기반하여 트래픽을 서버에 보낸다는 특징이 있습니다. 더 많은 트래픽을 처리할 수 있는 서버에는 더 많은 트래픽을 보내고 조금 성능이 딸리는 서버에는 조금만 보냅니다.

IP Hash Algorithm

클라이언트의 IP주소를 특정 서버로 매핑하여 요청을 처리하는 방식입니다. 사용자의 IP를 해싱해서 로드를 분배하기 때문에 사용자가 항상 동일한 서버로 연결되는 것을 보장한다는 특징이 있습니다.

Dynamic Load Balancing Algorithm

역동적인 로드 밸런싱 알고리즘은 말 그대로 역동적입니다. 위의 정적인 상황이랑 반대되는 알고리즘을 가지고 있습니다. 역동적인 이 알고리즘은 현재 가용성, 작업상황, 각각의 서버의 상태등을 체크합니다.

이렇게 체크하고 너무 줄이 긴 곳이나 문제가 생긴 창구에는 사람을 보내지 않고 안정적인 창구로 사람을 보냅니다. 이런 알고리즘이 위의 정적인 알고리즘보다 더 효율적인 것 처럼 보이긴합니다.

하지만 이러한 알고리즘은 구성하기 매우 복잡합니다. 서버의 가용성은 많은 요인에 의해서 바뀌기 때문에 그때그때 상황을 봐가면서 트래픽(사람)을 보내야하기 때문에 오버헤드도 꽤 있는 편입니다.

위의 편의점 예시에서 계산대로 사람을 보내기 전 로드 밸런서가 각각의 계산대의 상황을 고려해서 사람을 계산대로 보낸다면 이 로드 밸런서는 계산 양이 많아지겠죠? 그리고 굉장히 빠릿빠릿하게 일을 해야 하겠죠.

하지만 구현할 수만 있다면 그리고 관리를 잘 할 수 있다면 정말 효율적으로 굴러가는 알고리즘입니다.

역동적인 로드 밸런싱 알고리즘에는 어떤 알고리즘이 있는지 알아보도록 하죠

Least Connection Algorithm

이 알고리즘은 말 그대로 적은 연결을 맺고있는 서버 (널널한 서버) 에 트래픽을 보낸다는 특징이 있습니다. 서버에 분배된 트래픽들이 일정하거나 자주 세션이 길어지는 경우에 사용하면 적합한 방식입니다.

Weighted Least Connection Algorithm

위의 최소 연결 알고리즘에서 가중치를 부여한 알고리즘입니다. 위의 가중치 기반 라운드 로빈 알고리즘과 비슷한 방식을 채택하고 있습니다. Least Connection Algorithm의 특징에 더 많은 처리가 가능한 서버에 더 많은 트래픽을 보냅니다.

Weighted Response Time Algorithm

가중치 기반의 응답시간을 고려한 알고리즘입니다. 가운데 로드 밸런서는 각각의 서버까지 응답시간을 체크해서 응답시간이 짧은 서버로 트래픽을 보냅니다. 응답시간이 길다는건 그만큼 처리하고 있는 열결이 많다고 해석될 수 있기 때문에 위의 Least Connection과 비슷해 보이지만 다른 알고리즘으로 구현되어 있습니다.

Resource-Based Algorithm

이 알고리즘은 각각의 서버가 얼마나 리소스를 많이 가지고 있는지에 따라 트래픽을 분배하는 방식을 사용하고 있습니다. 이 알고리즘을 처리하는 전문적인 소프트웨어 (docs 에선 agent라고 표현하고 있습니다) 가 각각의 서버의 CPU와 메모리 상황을 체크해서 트래픽을 보낸다는 특징이 있습니다.

L4 / L7 Load Balancing

이렇게 로드 밸런싱을 구현한 알고리즘에 대해서 알아봤습니다. 로드 밸런싱은 OSI 7계층에서 로드 밸런서가 일하고 있는 위치에 따라 이름을 달리 부르고 있습니다. 요즘 가장 많이 쓰이는 로드 밸런서은 L4 로드 밸런싱과 L7 로드 밸런싱이 있습니다. 이름에서 알 수 있듯이 Layer 4인 전송계층과 Layer 7인 애플리케이션 계층에서 작동하는 로드 밸런싱입니다. 이 둘에 대해서 알아보도록 하죠

OSI 7계층에 대한 개념적인 이해가 필요하시다면 아래의 링크를 확인해주세요!

https://coding-review.tistory.com/298

Layer 4 Load Balancing

Layer 4 로드 밸런싱은 OSI 7계층에서 4번째에 위치한 전송 계층에서 애플리케이션 포트나 프로토콜과 같은 네트워크 정보를 기반으로 트래픽을 관리합니다.

전송 계층에서 작동되기 때문에 메세지의 실제 컨텐츠를 볼 수 없다는 특징을 가지고 있습니다. 이러한 로드 밸런싱은 간단한 패킷 레벨의 로드 밸런싱에서 사용하면 효율적입니다.

메시지(패킷)은 면밀히 관찰되지도 않고, encrypt 되어있는 메시지를 decrypt하지도 않기 때문에 매우 빠른 속도로, 효율적으로, 안정적으로 트래픽을 분배할 수 있다는 특징이 있습니다.

하지만 L4 로드 밸런싱은 메세지를 확실히 볼 수 없기 때문에 미디어의 타입이나, 위치에 기반한 정보, 라운드 로빈처럼 DNS를 기반으로 하는 알고리즘과 같은 라우팅 방식을 사용할 수 없다는 단점이 있습니다.

Layer 7 Load Balancing

Layer 7 로드 밸런싱은 OSI 7계층에서 마지막 계층인 애플리케이션 계층에서 HTTP나 SMTP 프로토콜을 기반으로 한 정보를 기반으로 트래픽을 관리합니다. 때문에 메세지를 모두 확인할 수 있다는 특징이 있습니다.

메세지를 읽지 않고 트래픽을 관리하는 4계층 로드 밸런싱과는 다르게 encrypt되어있는 메세지를 decrypt하여 메세지를 면멸히 관찰합니다. 그리고 이를 기반으로 라우팅을 시도하죠.

7계층에서는 메세지를 확실히 봐야하기 때문에 성능적인 패널티를 초래하기도 합니다. 하지만 이는 큰 문제는 아니긴 합니다. SSL offload 기능을 사용하면 어느정도 커버가 됩니다.

URL에 따라 부하를 분산시키거나, HTTP 헤더의 쿠키값에 따라 부하를 분산하는 등 클라이언트의 요청을 보다 세분화해서 서버에 전달할 수 있습니다.

또한 L7 로드 밸런싱의 경우 특정 패턴을 지닌 바이러스를 감지해 네트워크를 보호할 수 있으며, Dos / DDos와 같은 비정상적인 트래픽을 필터링할 수 있어 네트워크 보안 분야에서도 활용되고 있습니다.

https://tecoble.techcourse.co.kr/post/2021-11-07-load-balancing/
https://tecoble.techcourse.co.kr/post/2021-11-07-load-balancing/

여기까지 로드 밸런싱에 대해서 알아봤습니다. 네트워크 기본 지식이라고 해서 굉장히 방심했는데 큰코 다쳤습니다... 정말 심오한 주제였다는 것을 새삼 느끼고 있습니다.

또한, 어느정도 트래픽이 나오는 회사에서는 로드 밸런싱을 적극적으로 사용할 것이 분명하기 때문에 면접에서도 충분히 물어볼 수 있는 내용인 것 같습니다.

정보들이 한군데에 있지 않고 퍼져있어서 이번엔 구글링에 시간을 꽤나 쏟았던 것 같습니다. 하지만 늘 그렇듯 시간이 오래걸리면 완성도가 올라가기 때문에 이번 포스팅도 알차게 끝낼 수 있었던 것 같습니다.

이렇게 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.cloudflare.com/ko-kr/learning/performance/what-is-load-balancing/

=> 로드 밸런싱에 대한 개념, Static / Dynamic 알고리즘에 대한 내용

https://avinetworks.com/what-is-load-balancing/

=> 하드웨어 로드 밸런싱, 소프트웨어 로드 밸런싱에 대한 내용

https://www.cloudflare.com/ko-kr/learning/performance/types-of-load-balancing-algorithms/

=> 로드 밸런싱의 알고리즘에 대한 내용

https://www.a10networks.com/glossary/how-do-layer-4-and-layer-7-load-balancing-differ/

=> L4 로드 밸런싱과 L7 로드 밸런싱에 대한 차이와 각각의 특징

Stateless, Stateful 프로토콜

인터넷에 있는 모든 것들은 프로토콜에 의하여 컨트롤됩니다. 프로토콜이란 인터넷을 통해 모든 데이터가 운반될 때 직접, 혹은 간접적으로 따라야 하는 규약이나 가이드라인이죠.

이 네트워크 프로토콜에도 두가지 프로토콜이 대표적인데, 바로 이번 포스팅에서 알아볼 Stateless, Stateful 프로토콜입니다. 이 둘을 프로토콜 혹은 아키텍처라고도 부르는데요. 아키텍처는 말이 어렵지만 그냥 방법론이라고 생각하시면 됩니다. 즉, Stateless, Stateful을 구현할 수 있는 다양한 방법이 있다는 얘기이죠.

이번 포스팅에선 그런 방법론적인 얘기보다는 Stateless, Stateful이 각각 무엇인지, 그리고 이 둘의 차이는 무엇인지에 대해서 알아볼까합니다.

Stateful Architecture

Stateful 애플리케이션은 인터넷 전반에 걸친 정보나 요청을 미리 수립하기위해 클라이언트의 데이터를 세션에 저장하는 구조를 가지고 있다고 묘사합니다.

대표적인 Stateful 프로토콜로 TCP 3 way handshake 와 FTP 프로토콜이 있습니다. FTP에 대해서는 제가 잘 모르기 때문에... TCP 3 way handshake로 설명하자면

TCP는 가상회선 연결이 수립되는 과정이라고 생각하시면 됩니다. 클라이언트는 서버한테 요청을 할 수 있는지, 서버는 클라이언트에게 응답해줄 수 있는지 확인하는 과정이라고 생각하시면 되는데요.

이때 클라이언트와 서버는 SYN, ACK 이라고 부르는 패킷으로 통신을 합니다. SYN 패킷은 난수로 되어있고 ACK 패킷은 이 난수에 +1 한 값을 가지고 있습니다.

이 SYN, ACK 패킷으로 3번의 과정을 통해 연결을 수립하는데요. 아래의 그림으로 보시면 이해하는데 도움이 되실겁니다.

Stateful 애플리케이션은 세션에 담겨있는 정보들을 유지해야 하기 때문에 온라인 뱅킹이나 이메일 프로토콜인 SMTP에서도 주로 사용하고 있습니다.

이제 Stateful 의 장단점에 대해 알아볼까요?

Stateful 의 장점

• Stateful 프로토콜은 하나의 세션에 저장해놓은 정보가 다음 트랜잭션에서 사용될 수 있기 때문에 성능적으로 이점을 가지고 있습니다.

• Stateful 아키텍처는 추자겅니 보안계층을 가지고 있어서 보안적으로 훌륭히 수행할 수 있습니다. 때문에 온라인 뱅킹이나 재무적인 비즈니스 네트워크 전송에도 사용할 수 있습니다.

• Stateful 프로토콜은 Stateful이 가진 메모리 때문에 굉장히 직관적입니다.

음... docs에서 가져온 내용이긴 하지만 이 세번째 장점은 무슨 소리인지 잘 이해가 안가네요. 관련해서 구글링을 해봤지만 딱히 원하는 결과를 얻을 수 없었습니다.

Stateful 의 단점

• 서버가 가지고 있어야하는 정보(데이터)가 많기 때문에 서버의 오버헤드가 우려될 수 있습니다.

• 성능이 네트워크 메모리에 의존적이기 때문에 자체적으로 확장하기 힘듭니다.

Stateless Architecture

Stateless 아키텍처는 Stateful과 반대로 세션에 정보를 저장하고 있지 않기 때문에 각각의 요청에 대해 독립적입니다. 이 프로토콜은 클라이언트와 서버가 요청왁 응답을 주고받을 때 현재의 상태대로 만들어집니다.

게다가 현재 세션의 상태가 다음 트랜잭션에 유지되지 않고 독립적으로 수행됩니다.

Stateless 애플리케이션은 짧은 텀을 가지고 요청을 주고 받으며 관리합니다. Stateless의 가장 대표적인 예시로는 SMS, HTTP 프로토콜, DNS 등이 있습니다. 이제 Stateless의 장단점에 대해서 알아볼까요?

Stateless 의 장점

• Stateless 프로토콜은 상태를 유지하거나 보존할 필요가 없기 때문에 시스템의 오작동에 대해서 빠르게 피드백할 수 있습니다.

• 서버입장에서 자원과 저장공간을 최소화할 수 있고, 트랜잭션을 유지할 필요가 없습니다.

• Stateless 아키텍처는 쉽게 Scale out, Scale down 할 수 있습니다.

Stateless 의 단점

• 네트워크 성능이 각각의 패킷에 많은 정보를 담아야 하기 때문에 성능적으로 결점을 가지고 있을 수 있습니다.

• Stateless 아키텍처는 데이터 저장소의 부족으로 인해 일부 기능을 수행할 수 없습니다.

Stateless와 Stateful에 대한 예시는 다른 블로그에 많이 있으니 그것을 참고하시면 더 좋을 듯 합니다. 개인적으로 보고 깔끔했다고 생각드는 블로그는 아래 링크를 걸어둘테니 참고하시길 바랍니다.

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Stateful-Stateless-%EC%A0%95%EB%A6%AC

또한 해당 내용은 김영한님의 인프런 강의 "모든 개발자를 위한 HTTP 웹 기본 지식"에 수록되어있는 내용이기도합니다.

저는 기존에 많이 알려져있는 내용 보다는 아래서 설명할 왜 Stateless 프로토콜을 선택해야 하는지에 대해서 말씀드리고자 합니다.

우리가 Stateless 프로토콜을 선택해야 하는 이유

1. 서버에 데이터를 저장하는 관점에서

Stateless 프로토콜은 데이터를 저장하는 것이 최우선이 아닙니다. 그러므로 서버는 애플리케이션 라이프 사이클에 필요한 모든 데이터를 저장할 필요가 없다는 것이죠. 반면에 Stateful 프로토콜에서는 애플리케이션의 라이프 사이클에 해당하는 모든 데이터를 가지고 있어야 합니다.

이 말은 Stateless는 보내는 데이터는 많지만 서버가 감당해야하는 저장공간은 줄어들고, Stateful은 보내는 데이터는 적지만 서버가 감당해야 하는 저장공간은 많아진다는 얘기입니다.

2. 구현하기 쉬운 관점에서

Stateless 프로토콜은 요청을 처리하기 위해 더 적은 용량과 더 적은 쿼리를 요구하는데 이는 Stateless 애플리케이션이 더 적은 컴퓨터 프로세싱 파워가 요규된다는 의미이고 이는 더 쉽게 구현할 수 있다는 의미입니다.

Stateful 프로토콜은 Stateless 프로토콜보다 더 많은 컴퓨터 프로세싱 파워와 저장공간을 필요로합니다. 그래서 Stateful 프로토콜은 논리적으로 무겁고 구현하기가 힘들다.

3. 클라이언트-서버와 의존정도를 관점으로

Stateless 프로토콜은 서버와 클라이언트간의 의존정도가 낮습니다. 서버 입장에서 요청을 보낼 때 더 적은 짐을 질 수 있습니다. 반면에 Stateful 프로토콜에서는 서버와 클라이언트간에 높은 수준의 상호 의존관계가 형성됩니다.

요청을 서버로 보내기 위해서는 유저가 연결을 수립하기 전에 응답할 수 있어야 합니다.

4. 서버 장애를 관리하는 관점에서

시스템 장애에 관련해서 Stateless와 Stateful 프로토콜은 서로 차이를 보여주는데, Stateless 프로토콜의 경우 서버 입장에서 현재 세션에 대한 많은 정보를 가지고 있지 않기 때문에 서버 한두개 정도 내려간다고 하더라도 데이터를 잃는 관점에서 큰 손실을 입지는 않습니다.

하지만 Stateful 프로토콜에서는 현재 세션에 대한 정보를 가지고 있어야 하기 때문에 서버 한두개 내려가기 시작하면 해당 정보가 통째로 날아가는 것이기 때문에 데이터 손실적인 측면에서 봤을 때 Stateful이 좀 더 불리합니다.

5. 서버 복잡도의 관점에서

최근에 서버는 연결된 기기의 클라이언트의 벌크 요청에 대한 처리를 하기위해 만들어졌고, 때문에 서버는 더 많은 처리, 더 많은 저장공간을 위해 복잡해졌습니다.

서버의 복잡도 측면에서 Stateless 프로토콜은 서버가 매우 간단한 편입니다. 하지만 Stateful 프로토콜은 클라이언트의 장치를 자유롭게 하는 동시에 대부분의 책임을 서버에 맡기는 방식을 유지하기 때문에 굉장히 복잡한 편입니다.

6. 확장성의 측면에서

애플리케이션이 성장함에 따라 엄청나게 많은 트래픽을 서버가 감당하게 되고 그에따른 혼잡도 때문에 현재 캐퍼시티를 확장시킬 필요가 생길것입니다.

그럴 때 Stateless 프로토콜 기반 애플리케이션은 Scale up 과 Scale down이 굉장히 쉽습니다. 백엔드 서버하나 늘려주고 프론트엔드에 로드 밸런서한테 새로 추가된 서버만 부여해주면 쉽게 확장할 수 있습니다.

반면에 Stateful 프로토콜은 확장에 대한 접근을 다르게 받아들여야합니다. Stateful 아키텍처를 확장하기 위해서는 서비스에 존재하는 Stateful 서버나 서비스가 추가적으로 포함되어야 합니다. 이는 Scale down도 마찬가지입니다.

7. 트랜잭션의 속도 측면에서

애플리케이션의 스피드를 고려한다는 것은 어찌보면 당연한데, Stateless 프로토콜의 경우 세션의 정보를 유지하고 있지 않기 때문에 더 많은 정보를 위한 저장 플랫폼을 컨설팅 없이 동시에 다수의 세션을 처리할 수 있다는 것입니다.

이는 클라이언트 입장에서 더 빠른 응답속도를 기대할 수 있습니다. Stateful 프로토콜은 이와 반대로 세션에 정보를 많이 담고 있기 때문에 하나의 요청이 끝날 때까지 스레드가 그 요청을 물고 있어야 하고 이는 더 낮은 응답속도로 이어집니다.

8. 멀티태스킹의 측면에서

멀티태스킹 측면에서 Stateless는 각각의 요청이 독립적이기 때문에 멀티태스킹에 능합니다. 하지만 Stateful 프로토콜은 각각의 요청이 하나의 세션에서 굴러가기 때문에 하나의 서버가 멀티태스킹 하기가 굉장히 힘듭니다.

여기까지 Stateful, Stateless에 대해서 알아봤습니다. 이번엔 크게 어렵지 않았는데 완벽히 이해가 되지는 않았던 것 같습니다. 따로 한번 다시 정리해봐야 할 듯한데... 면접준비로 다시 한번 정리해보도록 하겠습니다.

기존 구글링으로 Stateless, Stateful에 대해서 검색하면 김영한님의 예시를 인용한 블로그들을 많이 볼 수 있는데 저는 그렇기 때문에 그 예시를 인용하지 않아서 글로만 봐서는 잘 이해가 안됐을 수도 있습니다. 하지만 저는 저만의 차별점을 위해 다른 방식으로 다른 관점에서 포스팅한 것이기 때문에 그 점 참고해주시기 바랍니다.

이렇게 글 마무리지어보도록 하겠습니다. 오늘 하루도 즐거운 하루 되세요~

출처

https://www.spiceworks.com/tech/cloud/articles/stateful-vs-stateless/

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Stateful-Stateless-%EC%A0%95%EB%A6%AC

검색창에 www.google.com 을 검색했을 때

이번 포스팅에서는 검색창에 google.com 을 입력했을 때 내부적으로 어떤 일이 벌어지는지 알아보도록 하겠습니다.

이 내용이 면접 단골질문이라고 하더라고요. 알아두시면 잘 써먹을 수 있을 것 같습니다. 특히 OSI 7계층 / TCP 4계층과 엮어서 잘 물어본다고하니 해당 개념에 대해서도 잘 알아두시면 좋을 것 같습니다. OSI 7계층에 대해서는 아래의 링크에서 확인해보세요!

https://coding-review.tistory.com/298

자 그럼 시작해보도록 하겠습니다.

google.com을 검색하면 생기는 일

1. 브라우저에 google.com을 검색한다

2. 브라우저는 DNS 캐시 저장소에서 google.com에 대응되는 IP 주소를 찾는다.

DNS에 대한 포스팅은 아직 하지 않았지만 간단하게 설명하자면 DNS는 특정한 IP 주소와 연결된 URL에대한 정보를 저장하고 있는 데이터베이스입니다. TCP 4계층인 애플리케이션 계층에 해당하기도합니다.

모든 URL은 그에 상응하는 IP 주소를 가지고 있지요. 예를 들어서 google.com의 IP주소는 209.85.227.104입니다. 즉 우리가 원한다면 google.com을 검색하는것 대신에 http://209.85.227.104를 검색해도 똑같이 구글로 들어가집니다.

DNS의 주된 목적은 유저 친화적으로 제공해주는 네비게이션같은 역할입니다. 우리는 DNS를 이용해서 좀 더 쉽게 올바른 IP주소에 접근할 수 있습니다. 뭐 생각해봐도 위에 있는 저 숫자들을 외우는 것보다 google.com을 외우는게 더 쉬우니까요.

브라우저는 이 DNS에 저장된 IP주소를 찾기위해 네가지 캐시를 뒤져봅니다.

• 첫번째로 브라우저 캐시를 뒤져봅니다. 브라우저는 우리가 이전에 방문했던 웹사이트를 일정 기간동안 DNS 저장소에 저장하고 있습니다. 그래서 첫번째 탐색은 DNS 쿼리를 날려보는 것이죠.

• 두번째로 브라우저는 OS 캐시를 뒤져봅니다. 만약 브라우저 캐시에 존재하지 않는다면 브라우저는 우리 컴퓨터에 잠들고있는 OS에 레코드를 가져오기 위해 시스템 콜을 날립니다. 왜냐하면 OS에서도 DNS 레코드에 대한 캐시를 저장하고 있거든요.

• 세번째로 라우터 캐시를 체크해봅니다. 우리 컴퓨터 내부(OS)에도 없으면 브라우저는 고유의 DNS레코드를 캐싱하고 있는 라우터와 연결을 시도합니다.

• 네번재로 ISP 캐시를 체크합니다. 앞에 모든 캐싱을 했음에도 존재하지 않으면 ISP 캐시로 이동하게 되는데 ISP는 DNS의 고유 서버를 가지고 있습니다. 그리고 ISP는 DNS 레코드에 대한 캐시를 가지고 있죠.

왜 이렇게 많은 캐시를 뒤져보는지 조금 이해가 안되실겁니다. 사실 프라이버시적으로 썩 좋은 유저 경험은 아닙니다. 하지만 그럼에도 이렇게 많은 캐시를 브라우저가 뒤져보는 이유는 유저들은 프라이버시보다도 응답속도에 더 예민하기 때문에 더 나은 응답속도를 위해 캐싱한다고 보시면 될 것 같습니다.

3. 만약 요청된 URL이 캐시에 없으면 ISP의 DNS서버는 google.com을 호스팅하고 있는 서버의 IP주소를 찾기 위해 DNS쿼리를 날린다

우리의 컴퓨터는 google.com을 호스팅하고 있는 서버와 연결하기위해 google.com 의 IP주소가 필요합니다. DNS 쿼리의 목적은 우베사이트에 대한 올바른 IP주소를 찾기까지 인터넷에서 여러 DNS서버를 탐색하는 것입니다.

이러한 형태의 탐색은 순환탐색이라고 부르는데 그 이유는 탐색이 DNS서버와 DNS서버를 우리가 원하는 IP주소를 찾을때까지 빙글빙글 돌면서 탐색하기 때문입니다.

이러한 상황속에서 DNS recursor라고 부르는 이놈은 응답을 위해 인터넷에 대한 다른 DNS서버에 물어봄으로써 도메인 이름을 포함하고 있는 적절한 IP주소를 찾는 책임이 있습니다.

더 나은 이해를 위해 다이어그램을 가져와봤습니다.

https://webhostinggeeks.com/guides/dns/
https://webhostinggeeks.com/guides/dns/

많은 웹사이트의 URL들이 세가지 레벨을 가지고 있는데 각각의 레벨은 그들만의 고유 네임서버를 가지고 있습니다.

google.com의 경우 첫번째로 DNS recursor가 루트 네임서버에 연결합니다. 루트 네임서버는 .com 도메인 네임 서버로 리다이렉트합니다.

.com 네임서버는 또 다시 google.com에 리다이렉트합니다. 그리고 google.com에 매칭하는 IP 주소를 웹브라우저에게 리턴해줍니다.

이러한 요청은 IP 주소나 요청에의한 컨텐츠와같은 정보들을 담고있는 작은 데이터 패킷을 사용해서 전달됩니다.

이러한 패킷들은 클라이언트와 서버사이를 올바른 DNS서버에 도착할 때까지 여러 네트워크 장비들을 돌아다닙니다. 그리고 이러한 장비들은 이 데이터들의 목적지에 도착하기위해 패킷들을 가장빠른 방법으로 찾기 위해 라우터 테이블을 이용합니다.

만약 이 패킷을 도중 잃어버리게 되면 우리는 failed error를 만나게 됩니다. 그렇지 않으면 우리는 올바른 DNS서버에 도달할 수 있고 올바른 IP주소를 가지고 브라우저로 돌아올 수 있습니다.

4. 브라우저가 서버와 TCP 통신을 시작한다.

여기서 TCP 4계층인 전송계층이 등장하는데요. 브라우저가 올바른 IP 주소를 받았을 때 정보를 전송하기위해 IP주소와 일치하는 서버와 통신을 시작합니다.

브라우저는 인터넷 프로토콜을 사용해서 연결을 구축하는데 여기에는 몇몇개의 다른 인터넷 프로토콜이 사용되기도합니다만 HTTP요청을 위해 사용되는 TCP가 가장 일반적입니다.

우리의 컴퓨터와 서버 사이에서 데이터 패킷을 전송하기위해 TCP 연결이 필수적으로 수립되어야합니다. 이 연결은 흔히 TCP 3 way handshaking 이라고 불리기도 하는데, 이 부분에 대해서도 아직 포스팅 하지 않았지만 간단하게 설명해드리자면

• 클라이언트는 난수인 SYN 패킷을 서버에 보냅니다.

• 만약 서버에 새로운 연결을 시작하고 수용할 수 있는 포트가 열려있다면, Acknowledment의 앞글자 세개인 ACK 패킷을 만들어서 SYN/ACK 패킷을 보냅니다.

• 클라이언트는 SYN/ACK 패킷을 서버로부터 받고 ACK 패킷을 서버에 보냅니다.

SYN 패킷은 무엇의 줄임말인지 모르겠지만 ACK패킷은 "알겠다" 정도로 해석하시면 됩니다. 아마 Synchronize의 약자가 아닐까 조심스럽게 추측해보겠습니다.

자 이제 TCP 통신이 연결되었습니다.

5. 브라우저는 웹브라우저에 HTTP 요청을 보낸다.

TCP 연결이 수립되었을 대 드디어 데이터를 주고받기 시작합니다. 브라우저는 google.com에 필요한 GET 요청을 보냅니다.

TCP 연결이 수립되고 나면 SSL handshake (요즘은 TLS handshake라고 하네요) 를 마치고 인증서를 가지고 해당 인증기관이 신뢰할 수 있는지 공개키와 개인키를 가지고 확인합니다.

그 이후 이 요청에는 추가적인 정보들을 가지고 있는데 예를 들어서 브라우저의 인증서 (User-Agent header), 수용할 수 있는 요청에 대한 정보들 (Accept header), 그리고 추가적인 요청을 위한 TCP 연결을 유지할 것인지에 대한 요청들이 있습니다.

실제로 GET 요청을 날리게 되었을 때 패킷을 한번 보여드리겠습니다.

6. 서버는 요청과 돌아오는 응답을 핸들링한다

서버는 Apache나 IIS와 같은 브라우저로부터 요청을 받을 수 있는 웹서버를 가지고 있습니다. 그리고 이러한 서버는 응답을 읽고 생성하기위한 request handler를 보냅니다.

이 request handler는 ASP.NET이나 PHP, Ruby와 같은 프로그램인데 이 프로그램들은 쵸엉이 어떤 것인지 체크하기위한 쿠키와 헤더들을 읽을 수 있습니다.

그러고 나면 웹서버는 응답을 특정한 포맷인 JSON이나 XML혹은 HTML의 형태로 응답을 만들어 재조립합니다.

7. 서버는 HTTP응답을 보낸다.

서버 응답은 상태코드나 Encoding에 대한 정보, 페이지를 어떻게 캐싱할건지, 쿠키를 세팅할건지 프라이버시에 대한 정보 등등 우리의 요청에대한 웹페이지를 갖고있습니다.

예를 들어 HTTP 서버 응답은 다음과 같습니다.

만약 우리가 위의 응답을 받았다면 첫번째로 보이는 것은 상태코드입니다. 이 상태코드는 꽤 중요한데 우리가 응답을 잘 보냈나 에러가 났나를 확인하는 중요한 지표가 됩니다. 상태코드에는 다섯가지 종류가 있는데 한번 짚고 넘어가도록 하겠습니다.

• 1xx : 정보에 해당하는 메시지만 지시하고 있음

• 2xx : 성공

• 3xx : 다른 URL로 리다이렉트됨

• 4xx : 클라이언트 측 오류

• 5xx : 서버 측 오류

8. 브라우저는 HTML 문서를 보여줌

브라우저가 HTML 문서를 페이지에 보여주는 단계입니다. 첫번째로 HTML의 뼈대에 해당하는 몸체를 렌더링하고 그 다음 HTML 태그와 이미지나, CSS stylesheet, JavaScript 파일과 같은 GET요청을 체크합니다.

이러한 정정 파일들은 브라우저에의해 캐싱됩니다. 이렇게 캐싱되면 다음에 다시 방문했을 때 다시 서버에서 이 데이터들을 가져오지 않아도 되기 때문에 굉장히 빠른 속도를 체감할 수 있습니다.

자 여기까지 검색창에 google.com을 검색했을 때 일어나는 일에 대해서 알아봤습니다. 이게 면접 단골 질문이라고는 알고 있었지만 따로 공부는 안해봤는데 이번 기회에 아주 제대로 공부한 것 같습니다.

시간이 많이 없으신 분들을 위해 빠르게 정리해드리겠습니다.

"google.com을 검색하면 브라우저는 DNS 캐시 레코드에서 URL과 상응하는 IP 주소를 찾습니다. 만약 요청된 URL이 캐시에 없으면 ISP의 DNS서버는 google.com을 호스팅하고 있는 서버의 IP 주소를 찾기위한 DNS쿼리를 날립니다. 그리고 그 후 브라우저는 서버와 TCP 연결을 시작합니다. 연결이 끝난 후 브라우저는 HTTP 요청을 보내고 서버는 요청과 돌아오는 응답을 핸들링합니다. 웹 서버는 HTTP응답을 보내고 브라우저는 HTML문서 결과를 보여줍니다."

이렇게 대답함과 동시에 TCP 4계층과 같이 엮어서 대답하시면 좋을 것 같습니다. OSI 7계층 / TCP 4계층에 대한 내용은 아래 링크에서 확인해주세요!

https://coding-review.tistory.com/298

그럼 여기서 포스팅 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://medium.com/@maneesa/what-happens-when-you-type-an-url-in-the-browser-and-press-enter-bb0aa2449c1a

https://www.quora.com/If-DNS-is-a-layer-7-service-How-does-layer-3-find-out-the-IP-address

TCP 3 way handshake

이번 포스팅에서는 TCP 3 way handshaking에 대해서 알아보도록 하겠습니다. TCP 3 way handshake는 두개의 장치가 연결을 수립하고 해제하는 단계인데요. 이렇게 통신 하는 것이 통신에대한 신뢰성이 올라가기 때문입니다.

이름이 handshake인 이유는 두 사람이 지인으로 발전하게 되는 첫 단계가 악수이기 때문이지 않을까 하는 개인적인 생각입니다.

그럼 시작해보죠!

TCP 3 way handshake

3 way handshake는 서버와 클라이언트가 연결하는 TCP / IP 네트워크 매커니즘입니다. 실제 데이터가 오가기 전에 클라이언트와 서버가 Synchronization과 Acknowledgment 패킷을 주고받는 과정이라고 생각하시면 됩니다.

TCP Segment 구조

모든 TCP Segment는 헤더필드와 데이터필드로 나눠져 있습니다.

헤더 필드는 20에서 60바이트의 크기로 설정할 수 있고 기본값은 20바이트입니다. 하지만 원한다면 60바이트로 설정할 수 있죠.

이제 각 요소에 대해서 자세히 알아보도록하죠

Header Fields

• Source port : Source port는 16비트 필드이고 이 필드는 데이터를 보내는 애플리케이션의 포트주소가 적혀있습니다.

• Detination Port : Destination Port는 Source port와 다르게 데이터를 받는 애플리케이션의 포트주소가 적혀있습니다.

• Sequence Number : Sequence Number 는 바이트의 트랙을 유지하기위해 사용됩니다. TCP 스트림 안에서 각각의 바이트는 TCP sequence number에 의해 유니크하게 식별됩니다.

• Acknowlegment Number : 이 필드는 acknowledgment number나 받는 사람이 다음으로 받기로 예측되는 숫자를 포함하고 있습니다. 이 필드는 이전 데이터를 성공적으로 받았다는 것에 대한 인지로서 작동합니다.

• Header Length (HLEN) : header length는 TCP header의 길이가 들어있습니다. 이렇게 헤더의 길이를 넣음으로서 어디가 실제 데이터의 크기인지 인지할 수 있게 해줍니다.

• Flags : 플래그는 여섯가지의 요소가 있는데요

URG : URG는 urgent pointer를 나타내고 만약 URG가 세팅되어있으면, 이 데이터는 굉장히 긴급하게 보내야하는 데이터라는 뜻입니다.

• ACK : 이게 흔히 TCP 3 way handshake를 공부하면 나오는 ACK 플래그입니다. ACK 플래그는 acknowledgment 필드를 나타내고 만약 0으로 세팅되어있으면 이 데이터 패킷은 acknowledgment를 포함하고 있지 않는 것입니다.

• SYN : 이것도 흔히 알려진 플래그이죠. SYN 플래그는 호스트 사이에 연결을 시작하는 혹은 수립하기위한 플래그입니다. 만약 SYN 플래그가 세팅되어있다면 이 디바이스는 안전한 연결을 수립하기를 원하는 것입니다.

• RST : RST 플래그는 연결을 재시작하기위한 플래그입니다. 이 플래그가 세팅되어있으면 연결을 재시작 하고싶다는 의미입니다.

• PSH : 만약 PSH 필드가 세팅되어있으면 데이터를 받는 장치는 버퍼링 없이 받는 애플리케이션에 다이렉트로 push하기를 요청됩니다.

• FIN : 이 FIN 플래그는 연결을 해제하고 싶을 때 사용합니다. 만약 FIN 플래그가 1로 세팅되어있으면 이 장치는 연결을 해제하기를 원하는 것입니다.

• Checksum : checksun은 숫자들의 순서이거나 데이터에서 에러를 감지하기위해 사용되는 문자들입니다. 16비트로 설정되어있고 UDP 통신에서는 필수가 아니지만 TCP / IP 통신에서는 필수적으로 세팅해야하는 플래그입니다.

• Window size : window size는 16비트인 필드이고 이 필드는 받는 사람이 수용할 수 있는 데이터의 크기를 나타냅니다.

• Urgent pointer : 위의 URG 플래그가 가리키는 필드입니다. 만약 URG 플래그가 1로 세팅되어 있다면 해당되는 필드이고 받는 사람이 가능하면 빨리 받아야하는 경우에 이 필드를 활성화시킵니다.

TCP 3 way handshake의 과정

설명할 이 세가지 단계는 TcP에서 연결을 수립하기위해 사용되는 순서입니다.

1. 클라이언트가 SYN 메시지를 서버에 보낸다 : 클라이언트가 서버와 연결되기위해 요청할 때 SYN 플래그를 1로 세팅합니다. 이 SYN 메시지에는 아래의 요소들이 포합되어있습니다.

• sequence number (이 sequence number는 난수로 세팅됩니다. 그 이유는 기존 요청과 구분하기 위해서입니다)

• ACK 패킷

• window size

• 최대 segment size => 예를 들어서 window size (받는 사람이 수용할 수 있는 크기) 가 3000비트이고 최대 segment size가 300이면 연결은 최대 10개의 segment로 만들어져 보낼 수 있습니다. segment는 "조각" 정도로 해석하시면 됩니다.

2. 서버가 SYN 메시지에 대한 응답으로 SYN과 ACK 패킷을 클라이언트에 보낸다 : SYN요청을 받은 후에 서버는 클라이언트에게 ACK 플래그를 1로 바꿈으로써 인지했다는 패킷을 보냅니다. 이때 ACK 패킷은 기존에 받았던 SYN 메시지의 sequence number에 1을 더한 숫자를 부여합니다.

예를 들어서 SYN 패킷의 sequence number가 2000이었다면 ACK 패킷은 20001을 전달해줍니다. 그리고 서버는 클라이언트에게 서버의 window size와 최대 segment size를 알려줍니다.

이 단계가 모두 끝나면 클라이언트와 서버는 연결이 수립된 단계라고 볼 수 있습니다.

3. 클라이언트가 ACK 패킷을 서버에 보냄 : 클라이언트는 ACK 플래그를 1로 세팅하고 서버의 SYN 메시지 안에 있는 sequence number에 1을 더한 값을 보냅니다. 이미 SYN 패킷으로 연결이 수립되었기 때문에 SYN 플래그는 0으로 세팅하고 ACK 패킷을 서버로 보냅니다.

앞선 세가지 단계를 도식화하면 다음과 같습니다.

정리하자면 SYN 패킷은 "우리 연결하자~" 라는 의미이고 ACK 패킷은 "어 연결 됐어~" 라는 의미라고 생각하시면 되겠습니다.

3 way handshake 연결 해제

대부분의 오늘날 연결 해제는 3 way 혹은 4 way로 이루어집니다. 제가 소개해 드리는 연결 해제 방식은 3 way handshake입니다.

1. 클라이언트는 FIN 메시지를 서버에 보낸다 : 클라이언트가 네트워크와 연결을 해제해야겠다고 결정할 때, 클라이언트는 서버에 난수를 FIN 메시지에 담아 보내고 FIN 플래그를 1로 ACK 플래그를 0으로 세팅해서 보냅니다.

2. 서버는 응답으로 FIN 패킷과 ACK 패킷을 클라이언트에 보낸다 : 서버가 요청을 받은 후에 클라이언트의 해제 요청에 인지했다는 뜻으로 ACK 플래그를 1로 세팅하고 ACK 패킷을 보냅니다. 연결을 수립할 때와 마찬가지로 ACK 패킷에는 FIN 메시지에 있는 시퀀스 넘버에 1을 더한 값으로 ACK 패킷을 보내게 됩니다.

만약 서버가 연결을 해제해야겠다고 결정했으면 FIN 플래그를 1로 세팅하고 클라이언트에 보냅니다. 이 단계가 끝나고 나면 클라이언트와 서버는 연결을 해제가 됩니다.

3. 클라이언트가 ACK 패킷을 서버에 보낸다 : 클라이언트는 ACK 플래그를 1로 세팅하고 ACK 패킷을 보냅니다. 이 ACK 패킷역시 앞에서 보낸 FIN 메시지에 있는 시퀀스 넘버에 1을 더한 값을 보냅니다.

앞선 세 단계를 도식화하면 다음과 같습니다.

연결 해제의 단계를 3 way 가 아닌 4 way로 알고 계신 분들이 계신데 3 way와 4 way는 FIN + ACK을 두개로 쪼개냐 안쪼개냐 차이이기때문에 둘이 같은 연결 해제 방식이라고 생각하시면 됩니다.

3 way 를 4 way 로 해제하면 다음과 같습니다.

여기까지 TCP 3 way handshake에 대해서 알아봤습니다. 저는 기존에 3 way handshake에 대해서 알고 있었는데 이렇게까지 자세히 알지는 못했던 것 같습니다. SYN 패킷과 ACK 패킷을 주고 받는다는 것 정도까지만 알고 있었지 header와 flag에 대해서는 전혀 알지 못했던 것 같네요.

글은 여기서 마치도록 하고 오늘도 즐거운 하루 보내시길 바라겠습니다. 저는 다음 포스팅에서 찾아뵙겠습니다.

출처

https://www.scaler.com/topics/computer-network/tcp-3-way-handshake/

https://www.geeksforgeeks.org/why-tcp-connect-termination-need-4-way-handshake/

네트워크 흐름제어와 혼잡제어 (Flow Control, Congestion Control)

우리가 흔히 말하는 네트워크 통신은 TCP 3way handshaking에 의해 일어납니다. 그리고 TCP에 대해 조금 공부해보신 분들은 UDP와의 차이도 말할 수 있죠.

UDP와 다르게 TCP는 전이중 방식과 점대점 방식 그리고 흐름제어와 혼잡제어를 통해 높은 신뢰성을 줄 수 있다고 말이죠.

잠시 설명하자면 전이중 방식은 전송이 양방향으로 이어진다는 뜻이고 점대점 방식은 각 연결점이 종단점을 가진다는 것입니다. 하지만 흐름제어와 혼잡제어는 그냥 외우기만해서 잘 모르더라구요. 이 부분에 대해서 본격적으로 공부해봤습니다.

흐름 제어

흐름 제어는 송신자와 수신자 사이에 버퍼(데이터를 받을 수 있는 용량)를 관리하기 위해 나온 방법론입니다.

만약 송신자가 수신자에게 엄청나게 큰 데이터를 넘겨준다고 생각해봅시다.

우리가 클라우드에 사진을 올리던 메일로 사진이나 동영상같은 큰 파일을 보낼 때 수신자쪽의 용량이 파일보다 작은 경우 데이터의 유실이 발생할 수 있습니다.

그래서 수신자는 파일을 잘 받을 수 있게 송신자와 지속적으로 대화하면서 버퍼의 크기를 늘립니다.

송신자 : 10GB짜리 영상 보낼게~

수신자 : 나는 2GB밖에 못받는데 잠시만 기다려. 파일을 조금씩 보내봐

송신자 : 100MB 갔어?

수신자 : 어 갔어 나 지금 용량 두배 늘렸어 좀 더보내봐

송신자 : 500MB 보낸다~

수신자 : 오케이 나 두배 더늘렸어~

송신자 : 이번엔 2GB보낸다~

수신자 : 어~ 넉넉해~ 또 두배 늘렸어~

송신자 : 버퍼가 16GB면 내 파일 다 보내도 되겠다 다보낸다~!

이런식으로 흐름을 제어해서 버퍼가 꽉 차지 않게 조절하는 것을 의미합니다.

이 방식은 기존 흐름 제어에 사용되던 알고리즘은 Stop and Wait에서 조금 더 업그레이드된 Sliding Window 방식입니다.

Stop and Wait은 만약 10GB를 보내야하는데 수신자의 버퍼가 1GB밖에 안되면 1GB씩 열번 주고 받는것이죠.

송신자 : 10GB짜리 영상 보낸다~

수신자 : 보내~

송신자 : 1GB, 1GB, 1GB, 1GB, 1GB .....

이렇게 보내면 조금 느리겠죠?

그래서 Sliding Window 방식이 도입되게 된 것입니다. 순차적으로 보내면서 용량을 조금 씩 늘리게 되면 더 빠르게 데이터 유실을 겪지 않고도 보낼 수 있으니까요.

혼잡 제어

혼잡 제어는 예를 들어서 배달의 민족에서 이번주 토요일 12시에 50퍼센트 할인쿠폰 선착순 1000명! 이렇게 큰 이벤트를 하게 되면 과연 서버에 몇명이 몰리게 될까요?

엄청난 혼잡을 예상할 수 있습니다.

이렇게 혼잡하게 되면 데이터도 유실될 뿐만 아니라 서버가 엄청나게 부담이 될 것입니다.

이 그래프를 알기 전 하나만 짚고 넘어가겠습니다. 바로 AIMD (Additive Increase / Mulitiplicative Decrease) 방식입니다.

처음 패킷을 하나씩 보내보고 문제없이 도착하면 window 크기 (단위 시간 내에 보내는 패킷의 수) 를 1씩 증가시켜가며 전송하는 방법입니다.

이 window의 크기를 바탕으로 패킷을 보내는 속도를 제어하는 방법입니다. window는 쉽게 말하자면 서버에 얼마나 패킷이 몰리는지 감시하는 감시자의 역할을 하고 있다고 볼 수 있죠.

AIMD 방식은 패킷 전송에 실패하거나 일정 시간을 넘으면 패킷의 보내는 속도를 절반으로 줄이면서 관리하는데 이 방식은 굉장히 공평하지만 네트워크가 혼잡해지고 나서야 대역폭을 줄이는 방식이라는 문제가 있습니다.

이제 그래프를 해석해보죠.

• Slow Start (느린 시작) : AIMD와 마찬가지로 패킷을 하나씩 보내고 패킷이 문제없이 도착하면 각각의 ACK 패킷마다 window 크기를 1씩 늘려줍니다. 1씩 늘린다는 것은 한 주기가 지나면 window 사이즈가 2배가 된다는 것이죠.

window 크기가 2배가 됨에 따라 전송 속도도 가파르게 올라 지수함수 모양이 됩니다. 대신 혼잡 현상이 발생하면 window 크기를 바닥으로 떨어뜨리게 됩니다.

• Fast Retransmit (빠른 재전송) : 패킷을 받는 쪽에서 순서대로 패킷을 받는게 아니라 나중에 도착한 패킷이 먼저 도착하게 되면 패킷을 재전송합니다.

이런 재전송이 세번 일어나게 되면 window 크기를 절반으로 줄입니다. 이는 약간의 혼잡 상태를 의미합니다.

• Fast Recovery (빠른 회복) : 재전송이 세번 발생했다는 것은 혼잡상태를 의미합니다. 혼잡한 상태가 되면 window 크기를 1로 바로 줄이지 않고 선형적으로 window 크기를 늘립니다.

• 안정기 : 패킷이 이제 안정적으로 들어와 더이상 패킷이 혼잡하지 않으면 다시 window 크기를 1로 줄여버립니다. 그리고 이후엔 AIMD방식으로 계속 진행됩니다.

여기서 하나 헷갈릴만한 부분이 window 크기와 패킷의 전송속도와의 상관관계입니다.

한번 정리하고 넘어가면 좋을 것 같네요.

window 크기 : 단위 시간당 전송되는 패킷의 "수"

즉, window 크기는 패킷의 수를 조절하는 것이고 전송속도는 window 크기와 일대일 대응되지 않는다는 의미입니다.

이를 그래프로 쉽게 설명해보겠습니다.

전송 속도는 지수함수 형태로 증가하기 때문에 어느정도 선에선 전송 속도를 유지하면서 단위 시간당 들어오는 패킷의 수만 조절하는 것이죠.

흐름 제어와 혼잡 제어가 신뢰성에 미치는 영향

흐름 제어와 혼잡 제어를 통해 TCP가 신뢰성을 높일 수 있었던 이유는 무엇일까요?

흐름 제어와 혼잡 제어로 나눠서 간단하게 알아보겠습니다.

흐름 제어

• 수신자의 과부하를 피할 수 있다 : 흐름 제어는 수신자가 수행할 수 있는 용량보다 더 빠르게 데이터가 전송되는 것을 방지하여서 수신자의 버퍼를 안정화시켜 데이터 로스를 줄입니다.

• 역동적인 조절 : TCP는 역동적으로 수신자의 버퍼 사이즈와 수행 능력에 기반한 데이터 전송 비율을 조절할 수 있습니다. 이는 위에서 설명한 sliding window를 이용해 수행할 수 있죠.

• 데이터 무결성 보장 : 수신자의 수용량에 따라 송신자의 비율을 맞춤으로써 네트워크 인프라의 과부하를 피할 수 있게 되고 네트워크 과부하는 패킷 로스로 이어질 수 있습니다. 이렇게 패킷이 로스되면 지연시간이 길어지고 신뢰성을 잃게 되겠죠?

혼잡 제어

• 네트워크 오버헤드를 막아준다 : TCP에서 혼잡 제어 매커니즘은 네트워크 인프라의 과부하를 막아주고 패킷 로스를 줄여줍니다.

• 전송 비율 조절 : TCP는 위에서 설명한 다양한 알고리즘 (slow start, congestion avoidance, fast recovery, fast retransmit) 등을 이용해서 데이터 전송 비율을 조절합니다. 이는 네트워크를 깔끔하게 만들어주는 역할을 수행합니다.

• 패킷 로스 핸들링 : 혼잡 상태여서 패킷이 로스가 되면 TCP는 이를 감지해냅니다. 그리고 재전송하죠. 이 과정에서 데이터는 정상적으로 도착지에 도착하는 것을 보장할 수 있습니다.

정리

이렇게 TCP의 신뢰성을 담당하는 흐름 제어와 혼잡 제어에 대해서 알아봤습니다. 나중에 기회가 된다면 전이중 방식과 점대점 방식에 대해서 조금 깊이있게 공부해보고싶네요.

TCP의 이런 신뢰성 덕분에 파일 전송과 같은 신뢰성이 필요한 서비스에서 TCP 연결이 수행됩니다. 뿐만 아니라 일반적인 server to server, server to client 관계에서도 TCP 통신을 사용하죠.

반면 UDP는 TCP와 다르게 데이터 송수신간 여부를 확인하지 않기때문에 패킷 로스가 발생할 수 있어 신뢰성 측면에서 많이 부족하지만 그렇기 때문에 속도가 굉장히 빠르고 다대다 통신이 가능해서 스트리밍 서비스에서 주로 사용됩니다.

여기까지 TCP의 흐름 제어와 혼잡 제어에 대해서 알아봤습니다. 이번엔 이해하는데 꽤 시간이 걸렸던 것 같습니다. 특히 window 크기와 전송 속도를 헷갈려서 더 시간이 걸렸네요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://gyoogle.dev/blog/computer-science/network/%ED%9D%90%EB%A6%84%EC%A0%9C%EC%96%B4%20&%20%ED%98%BC%EC%9E%A1%EC%A0%9C%EC%96%B4.html (https://gyoogle.dev/blog/computer-science/network/%ED%9D%90%EB%A6%84%EC%A0%9C%EC%96%B4%20&amp;%20%ED%98%BC%EC%9E%A1%EC%A0%9C%EC%96%B4.html)

And

Chat GPT 4.0 - Open AI

패킷 한 조각의 모험 : 네트워크 속 비밀 이야기

우리가 파일을 다운로드 하는 경우는 정말 흔하게 일어나죠. 그 중에서 이번 포스팅에선 TCP프로토콜을 이용해서 파일을 다운로드 하는 경우에 패킷이 어떤 흐름으로 이동하는지에 대해서 공부해보고 정리해봤습니다!

패킷 한 조각의 모험, 바로 시작해보겠습니다!

패킷의 여행

보통 대부분의 네트워크는 TCP/IP 프로토콜을 이용하는 것으로 알고 있습니다. 때문에 이번 포스팅에서도 TCP프로토콜에 한정하여 이야기해보도록 하겠습니다.

서버는 우리 컴퓨터와 네트워크로 연결하기 위해 TCP/IP 프로토콜을 사용합니다. 서버는 TCP연결을 위해 소켓을 여는데 이 소켓은 유닉스 체계에서 파일로 이루어져 있습니다.

파일을 들고 있는 서버는 웹 서버라고 한다면 이 웹 서버는 프로세스 위에서 동작합니다. 즉, 추상화 해보면 프로세스가 파일을 만든다고 해석할 수 있겠네요.

이것과 관련된 포스팅 내용을 최근에 작성했습니다! 한번 보고 오셔도 괜찮을 것 같네요.

https://coding-review.tistory.com/568

프로세스가 파일에 접근해서 할 수 있는건 RWX (Read, Write, Execute) 이렇게 세 가지가 일반적일겁니다.

그 중에서 소켓 통신의 경우 R과 W를 주로 하는데 소켓 통신에서 R은 Read가 아닌 Receive라고 표현하고 W는 Write가 아닌 Send라고 표현합니다.

1. 파일 입출력

서버에 존재하는 파일은 분명 물리적인 저장소에 존재하고 있을 것입니다. HDD나 SSD에 저장되어 있겠죠?

운영체제는 HDD나 SSD에 존재하는 실제 파일을 파일 입출력을 이용해서 메모리에 적재합니다. 이 과정을 담은 포스팅 또한 준비되어 있습니다.

https://coding-review.tistory.com/563

데이터베이스의 내용이 조금 들어가있긴 하지만 주 내용은 운영체제에 대한 내용이니 보고 오셔도 좋을 것 같습니다!

우리는 2MB짜리 파일을 다운받는다고 가정해보죠. 그럼 운영체제가 메모리를 2MB를 준비하느냐?

그건 아닙니다.

만약 그랬다간 서버에 메모리가 금방 부족해질 것입니다.

2MB보다 작은 64KB로 준비해놓는다고 가정해봅시다. (이 값은 프로그래머가 적절히 개발해놓는 값입니다.)

2MB짜리 파일이 64KB의 메모리 위에 잘개 쪼개져서 운반되었습니다. 이제 다음으로 넘어가보죠.

2. TCP/IP 프로토콜의 Encapsulation

프로세스의 메모리 위에 올라와있는 64KB의 파일 조각들은 TCP 프로토콜에 존재하는 메모리에 복사가 됩니다. 이 포스팅에서는 TCP메모리라고 칭할 것입니다.

이렇게 메모리에서 메모리로 데이터가 이동하는 것을 Buffered I/O라고 부릅니다. 자바에서는 BufferedInputStream, BuffredOutputStream으로 유명하지요. 이 두가지의 동작 방식이 바로 Buffered I/O 입니다.

TCP메모리 위에 올라와있는 64KB의 파일을 Segment라는 가장 작은 단위로 쪼개서 프레임이라는 곳에 담아냅니다. 이 때 Segment가 바로 우리가 잘 알고 있는 "패킷"입니다. 앞으로 Segment를 패킷이라고 칭하겠습니다.

이 패킷은 프레임이라는 곳에 담겨서 이제 본격적으로 이동하게 됩니다.

이를 보통 택배와 택배기사로 비유를 많이 드는데요.

출처 : 본인
출처 : 본인

이렇게 프레임이 택배차에 담겨서 이동하게 됩니다.

이 택배차의 용도는 택배를 전달하는데 있습니다. 여기서 중요한 것은 택배기사한테 택배를 전달했다고 해서 이 택배가 택배기사의 것이 되는 것이 아니라는 것입니다.

택배기사는 이 택배물을 자신이 이동시켜야할 위치까지 이동시키는 것에 목적을 둡니다.

여기서 잠깐!

이 택배가 우리집까지 오기까지 택배차가 바뀔까요? 안바뀔까요?

바뀔수도, 안바뀔수도 있습니다.

현실에 빗대어보면 택배차가 바뀌는 것이 조금 더 일반적인 것인데, 우리가 시킨 택배는 생산지로부터 우리집 근처의 허브까지 이동하게 되고 이 허브에서 다시 우리집으로 오게 되는 과정을 생각해보면 택배 차가 바뀌게 되는게 조금 더 일반적이죠.

네트워크의 흐름에서도 패킷이 프레임에 담겼다가 풀어졌다가 다시 담겼다가를 반복합니다.

패킷의 흐름은 논리적으로는 엔드 투 엔드로 이동하게 되지만 그 과정에서 끝에 있는 엔드포인트가 최종 목적지가 아닐 수도 있다는 것입니다.

3. 택배가 우리집에 오기까지

이 택배는 L7계층인 서버의 애플리케이션에서부터 L4계층인 TCP를 타고 캡슐화가 진행되었습니다.

그리고 이 패킷이 네트워크를 타고 우리집에 오게 되는데 L4에서 흐르던 패킷은 L3인 라우터에 도달합니다. 라우터로 가장 유명한 것은 바로 공유기이죠.

공유기에 우리가 받을 패킷이 도착하게 되면 이제 L2계층인 스위치로 이동합니다. 집에 보통 스위치가 있는 경우도 있고 없는 경우도 있지만 만약 있다면 스위치는 이 패킷에 적혀있는 MAC주소를 보고 알맞은 기기에 전달해줍니다.

PC에는 NIC이라는 것이 있습니다. Network Interface Card의 약자이죠. NIC도 우리가 잘 알고 있습니다. 바로 메인보드에 박혀있는 LAN카드가 바로 NIC입니다. 이 랜카드가 바로 L1계층입니다. 이제 Decapsulation이 일어나게 됩니다.

이를 도식화하면 다음과 같습니다.

출처 : 본인
출처 : 본인

4. Decapsulation

패킷이 클라이언트의 PC에 도착하면 Decapsulation이 일어나고 이 패킷들이 클라이언트의 TCP메모리에 적재됩니다. 그리고 아까와 마찬가지로 TCP메모리에 있는 패킷이 클라이언트의 파일시스템에 할당된 메모리에 적재됩니다.

이 파일 시스템의 메모리는 패킷으로 받은 데이터를 실제 물리 저장소에 저장합니다.

이렇게 TCP메모리에 패킷을 잘 적재하면 클라이언트는 서버에게 패킷을 잘 받았다는 의미로 ACK 신호를 보내게 됩니다. 만약 1번 2번 패킷을 받았다면 ACK 신호를 보내면서 다음 패킷 번호를 보내줍니다. 여기선 3번이 되겠네요.

이 말은 다시 말해서 1, 2번 잘 받았으니까 다음은 3번 내놓으라는 의미입니다.

이 과정에서 서버는 뭘 하고 있을까요?

5. 형님 한 번, 아우 한 번

서버는 클라이언트에게 ACK 신호를 받을때까지 무한정 대기하고 있습니다.

엥..? 진짜 무한정 대기..?

이것이 TCP가 UDP보다 속도가 느리다고 말하는 수 많은 이유 중 하나이며 이 뒤에 또 한가지 이유가 더 나옵니다.

클라이언트가 ACK신호를 보낼 때 전송하는 값 중에서 다음 패킷 번호도 있지만 클라이언트 자신의 TCP메모리의 크기도 같이 보내줍니다. 이 크기를 같이 보내주는 이유는 서버가 다음 패킷을 보내야하는데 클라이언트의 TCP메모리가 꽉차있으면 다음 패킷을 보낼 수 없기 때문입니다.

만약 꽉 차있지 않으면 서버는 다음 패킷을 보냅니다. 하지만 만약 꽉 차있다면요?

서버는 클라이언트에게 패킷을 보낼 수 없다는 신호를 다시 보내게됩니다.

지금 패킷은 이동조차 하지 못했습니다. 서버는 ACK신호를 보낼 때까지 기다리고 있었고 기다리다가 ACK신호를 받았는데 이놈의 클라이언트 TCP메모리가 꽉차서 패킷을 못보내니까요.

이제 클라이언트는 TCP메모리를 동적으로 늘리게 되고 이제 보내도 좋다는 신호를 자신의 TCP메모리의 크기와 함께 다시 보내줍니다.

그 상태에서 서버가 클라이언트의 TCP메모리를 확인하고 패킷을 보내게 됩니다.

이거 어디서 많이 본 그림인데...

맞습니다. 바로 TCP/IP하면 조건반사로 튀어나오는 바로 그것! "흐름제어"입니다. 그리고 흐름제어에서 나오는 Sliding Window라고 표현하는 그것에서 알 수 있듯이 클라이언트의 TCP메모리를 "Window"라고 부릅니다.

이것에 대해서 깊이있게 공부하고 정리한 포스팅이 있습니다!

https://coding-review.tistory.com/466

이 글에 자세히 정리되어 있습니다.

6. 해치웠나..?

클라이언트에게 계속 패킷이 들어오긴 하는데 만약 파일 시스템에 할당된 메모리로 적재되는 속도보다 패킷이 TCP메모리에 적재되는 속도가 더 빠르다면 어떻게 될까요?

즉, 파일 시스템에 저장하는 속도보다 내 PC로 들어오는 네트워크 속도가 더 빠르면 말이죠.

이렇게 되면 또 다시 네트워크 지연이 발생할 수 있습니다. 정말 끝도 없네요. 왜 TCP가 UDP보다 느리다고 하는지 이제서야 알 것 같습니다.

때문에, TCP프로토콜에서는 보내는 쪽의 컴퓨팅 성능이 네트워크 지연을 야기하는 것이 아니라 받는 쪽의 컴퓨팅 성능이 네트워크의 지연을 야기합니다.

보통의 경우 받는 쪽의 CPU에 부하가 생겨서 메모리를 빠르게 비우지 못하는 경우이거나 아니면 클라이언트의 애플리케이션이 파일 입출력을 하는 속도가 느린 경우가 대부분입니다.

마치며

이렇게 패킷의 여행에 대해서 알아봤습니다. 뭔가 세상의 이치를 알게된 느낌이라 정말 머리가 개운하고 좋습니다. 요즘은 이런 공부가 좀 재밌는 것 같아요.

CS공부가 실전에서 아직은 크게 도움되지는 않습니다. 하지만 혹시 모르죠! 나중에 정말 중요한 곳에 쓰이게 될지말이에요. 그 날을 기대하면서 CS공부를 꾸준히 해야겠네요.

긴 글 읽어주셔서 감사합니다! 오늘도 즐거운 하루 되세요~

서버간 동기 통신을 더 빠르게! gRPC의 무기들 (+실증테스트)

요즘 사이드프로젝트가 끝나고 어떤 공부를 해야할지 고민중이었는데 평소에 궁금했던 gRPC에 대해서 공부해보았습니다. 이번 포스팅에선 서버간 통신에서 REST API의 대안이 된 gRPC에 대해서 정리해봤습니다.

gRPC의 세가지 무기

gRPC는 세개의 무기를 가지고 있습니다. 바로 헤더 압축, Protobuf, 멀티플렉싱이죠. 각각의 특징이 gRPC가 왜 서버간 동기 통신에서 선택받았는지 자세히 정리해보겠습니다.

헤더 압축

저도 gRPC를 공부하면서 깜짝 놀랐습니다. 헤더 압축? 헤더가 뭐 얼마나 된다고 헤더를 압축해?

근데 엄청나게 차이가 많이납니다. 이번 섹션에선 기존 HTTP/1.1을 사용하는 REST API와 HTTP/2를 사용하는 gRPC에 각각 요청을 직접 날려보면서 패킷을 뜯어보고 헤더 압축의 차이가 얼마나 큰지 알아보려고합니다.

우선 준비물이 있습니다.

• python3, pip

• go

• JWT 토큰

• wireshark

하나하나 진행해보겠습니다.

우선 필요한 언어들을 쭉 깔아줍니다. 저는 맥으로 실습했기에 맥 명령어를 정리해봤습니다.

brew install python3

brew install go

python3 -m pip install PyJWT

이렇게 세개를 설치하면 우선 준비 끝입니다.

그리고 파이썬 파일을 하나 만듭니다.

import jwt

import datetime

# 시크릿 키

secret = "my-secret-key"

# 페이로드

payload = {

"sub": "kyoungsuk",

"role": "admin",

"iat": datetime.datetime.utcnow(),

"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=5)

}

# JWT 생성

token = jwt.encode(payload, secret, algorithm="HS256")

print(token)

그리고 python3로 이 파일을 실행하면 JWT토큰이 튀어나옵니다.

python3 generate_jwt.py

저는 generate_jwt.py로 이름을 지었고 이를 실행해서 JWT토큰을 얻었습니다. 이걸 따로 적어두시면 됩니다.

그리고 wireshark를 설치합니다. wireshark 설치방법은 생략하도록 하겠습니다.

Wi-Fi를 사용하고 있다면 Wi-Fi: en0를 선택하고 바로 위에 있는 필터에 다음과 같이 입력합니다.

tcp port 50051

그리고 gRPC서버를 실행시키겠습니다.

git clone https://github.com/grpc/grpc-go.git

cd grpc-go/examples/helloworld

go run greeter_server/main.go

brew install grpcurl

grpcurl -plaintext \

-H "Authorization: Bearer <JWT토큰을 넣어주세요>" \

-d '{"name": "경석"}' \

localhost:50051 \

helloworld.Greeter/SayHello

위에서부터 순서대로 실행시키면 됩니다.

그리고 제대로 헤더가 압축되는 것을 보고싶으시다면 grpcurl을 여러번 실행하는 것을 추천드립니다.

그럼 wireshark에 어떻게 뜨는지 확인해보겠습니다.

보시면 숫자가 세개 있는데 맨 위에서부터 실제 네트워크를 탄 헤더크기, 압축된 헤더 크기, 압축을 해제한 헤더 크기 이렇게입니다.

그럼 뭐가 진짜인가하면 첫번째가 실제로 네트워크로 전송되는 헤더 크기입니다. 즉, 116Bytes가 실제 네트워크로 전송된 헤더 크기입니다.

이제 wireshark의 포트를 8060으로 해줬습니다. 남는 포트 아무거나 쓰시면 됩니다. 저는 8080을 쓰고있어서 적당히 8060을 사용했습니다.

tcp port 8060

그리고 아래와 같은 명령어를 입력합니다.

python3 -m http.server 8060

이러면 간단한 http server가 생성됩니다. 그리고 아래와 같은 명령어로 해당 서버로 요청을 보내봅니다.

curl -v \

-H "Authorization: Bearer <JWT토큰을 넣어주세요>" \

http://localhost:8060

이것도 여러번 실행한 뒤 wireshark를 보면?

약간 연두색으로 되어있는게 실제 패킷이 이동한 것이고 첫번째가 요청, 두번째가 응답입니다. 즉, 요청헤더로 349Bytes를 보내고 응답으로 body 포함 1546Bytes를 받은겁니다.

그럼 헤더는 349Bytes네요.

아까 gRPC는 116Bytes였죠? REST API는 349Bytes입니다.

오우... 세배정도 차이가 나네요. 이게 쌓이고 쌓이면 정말 큰 차이가 벌어집니다. 네트워크 레이턴시에 큰 영향을 주죠.

보통 요청헤더에 큰 용량을 차지하는 JWT토큰을 추가하긴 했지만 확실히 큰 차이가 납니다.

Protobuf

위에서 헤더를 압축했다면 Protobuf는 굳이 따지자면 body를 압축한 것으로서 기존 HTTP/1.1은 JSON기반의 텍스트를 응답했다면 Protobuf는 바이너리를 응답하기에 컴퓨터가 더 잘 읽을 수 있어서 성능상 이점을 보입니다.

Protobuf의 주된 특징은 성능도 있지만 추상화가 장점인데요. 이 추상화가 어느정도의 추상화냐면 언어가 다른 두개의 서버가 통신할 때도 같은 포맷으로 통신할 수 있을 정도입니다.

아닌 동기통신에서 사용한다면 아마 로그인이 있을 것 같은데 억지로 예시를 만들어서 정리해보겠습니다. 주문, 결제, 상품, 알람으로 이어지는 비동기 통신은 Kafka로 진행한다고 가정하겠습니다.

만약 요청을 보내는 곳은 spring boot이고 응답을 보내주는 인증 서버는 node.js로 만들어졌다고 가정하겠습니다.

그럼 이 두개의 프로젝트에 똑같은 protobuf가 만들어져야합니다.

syntax = "proto3";

message LoginRequest {

string username = 1;

string password = 2;

}

message LoginResponse {

bool success = 1;

string token = 2;

}

파일 이름은 auth.proto로 하고 예제 코드는 생략하겠습니다.

그럼 spring boot에서 protobuf에 대한 구현체를 만들고 node.js에서 protobuf에 대한 구현체를 만들면 서로 다른 언어로 되어있지만 통신이 가능하게됩니다.

이정도의 추상화 덕분에 서로 언어가 달라도 서버간 통신이 용이해진다는 것이 큰 장점입니다. 거기에 요청과 응답을 바이너리로 받아서 성능이 올라간다는 것은 덤이구요.

멀티플렉싱

헤더와 바디를 압축했으니 이제 성능에 대해 본격적으로 이야기해볼까합니다.

멀티플렉싱이란 하나의 커넥션에서 여러개의 처리를 할 수 있는지 여부에 대한 것으로서 보통 HTTP/1.1에선 하나의 커넥션이 하나의 처리밖에 하지 못해 앞에 있는 요청이 끝나기 전까지 뒤의 요청이 처리를 할 수 없습니다.

하지만 HTTP/2에선 하나의 커넥션이 여러개의 처리를 동시에 할 수 있어서 속도가 굉장히 빠르다는 특징이 있습니다.

이를 예시로 들자면 카페에서 종업원에게 주문을 넣을 때 줄을 서서 주문을 넣는 것이 HTTP/1.1이라면 HTTP/2는 손님들이 종이에 자기가 원하는 음료를 적고 종업원에게 전달해주면 종업원이 빨리 끝나는 음료를 먼저 만들어서 만들어주는 것과 같습니다.

멀티플렉싱은 REST API와 gRPC의 차이이기도 하지만 HTTP/1.1과 HTTP/2의 차이이기 때문에 HTTP/1.1과 HTTP/2 서버를 두 대 띄워서 똑같이 실습을 해보겠습니다.

먼저 HTTP/1.1은 위에서 실습했던 서버를 그대로 재활용할 것입니다. 그리고 HTTP/2를 위한 서버를 만들어줘야합니다.

적당한 폴더를 하나 만들어주고 node.js로 간단한 HTTP/2서버를 만들어보겠습니다.

npm init -y

npm install http2-server

vim server.js

const http2 = require('http2');

const fs = require('fs');

const server = http2.createSecureServer({

key: fs.readFileSync('./cert/key.pem'),

cert: fs.readFileSync('./cert/cert.pem')

});

server.on('stream', (stream, headers) => {

stream.respond({

'content-type': 'text/plain',

':status': 200

});

stream.end('Hello over HTTP/2\n');

});

server.listen(8050, () => {

console.log('✅ HTTP/2 server running at https://localhost:8050');

});

이렇게 하면 서버는 준비 끝입니다. 그리고 보통 서버 성능 테스트는 wrk로 하는 것이 일반적이지만 wrk는 HTTP/2를 지원하지 않아서 h2load라는 것을 사용해서 HTTP/2를 실습해보겠습니다.

brew install wrk

brew install nghttp2

이렇게 적으면 우선 준비는 끝입니다. wrk로는 HTTP/1.1에 요청을, h2load로는 HTTP/2에 요청을 보내볼겁니다.

wrk -t4 -c1000 -d10s http://localhost:8060

먼저 HTTP/1.1의 결과입니다. 스레드는 4개 커넥션은 1000개를 10초동안 진행하여 약 8800개의 요청을 처리했네요.

평균 처리 시간은 14.66ms 최대 63.14ms를 기록했고 총 처리 시간은 10.06초입니다.

굳이 HTTP/1.1을 먼저 실습해본 이유가 있는데요. 초당 몇개정도의 요청이 가능한지 알아야 HTTP/2의 실습이 용이하기 때문입니다. 적당히 비슷한 8500개를 HTTP/2로 요청해보겠습니다.

h2load -n8500 -c100 -m100 https://localhost:8050

요청 8500개에 커넥션 100개 그리고 멀티플렉싱에 사용할 스트림 수를 100개로 만들어서 실행해보았습니다.

finished in에 보면 총 걸린 시간은 0.37초, 초당 2만2천개의 처리를 할 수 있는 성능이 나왔습니다. HTTP/1.1은 초당 880개정도의 요청을 처리할 수 있었던 것과 비교하면 엄청난 성능입니다.

레이턴시는 최소 1.19ms, 최대 254m.s로 HTTP/1.1에 비해 최대 레이턴시가 높은 것을 확인할 수 있었습니다. 중간 값은 81.29ms 평균 값은 47.69ms로 나왔습니다.

이번 실습으로 알 수 있었던 것은 HTTP/2는 요청이 많아지면 많아질수록 엄청난 장점을 보이지만 각각의 요청에 대한 응답은 느린 것으로 보여집니다. 즉, 사용자는 0.2초 정도 되는 레이턴시를 겪었지만 서버 입장에선 초당 2만2천개의 처리를 할 수 있기에 서버의 성능이 올라갔지만 클라이언트는 조금 느릴 수도 있다는 것이 단점이라면 단점이겠네요.

반면 HTTP/1.1은 서버가 처리할 수 있는 요청은 초당 880개정도로 HTTP/2에 비해 25배 낮은 성능을 보여줬지만 각각의 요청은 HTTP/2에 비해 빨랐습니다.

하지만 아무리 각각에 대한 요청이 HTTP/1.1이 빨랐다지만 서버의 성능을 판단할 때 중요한 것은 레이턴시뿐만 아니라 throughput이라고 부르는 처리량도 매우 중요한 지표이기에 확실히 HTTP/2가 월등히 성능이 좋은 것으로 보입니다.

마치며

성능에서 많이 후달리는 HTTP/1.1을 아직도 많이 사용하고 있는데 아마 그 이유가 바디에 바이너리로된 데이터를 보낸다는 것이 한 몫할 것같네요.

왜냐하면 서버간 통신이 아닌 클라이언트-서버간 통신이라면 클라이언트 즉, 브라우저가 읽을 수 있는 JSON같은 형태여야할텐데 바이너리는 클라이언트가 읽을 수 없기 때문이죠.

이번 포스팅에선 gRPC를 실습 위주로 테스트해보면서 왜 서버간 통신에서 gRPC를 사용할 수 밖에 없었는지에 대해서 정리해봤습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

시간복잡도

시간복잡도

시간복잡도란 내가 만든 코드가 얼마나 효율적인지를 나타내는 척도입니다. 시간복잡도를 계산하면 코드의 어느 부분에서 시간을 많이 잡아먹고 있는지 대략적으로 알 수 있으며 코드를 리팩토링 할 때도 좋은 영향을 미칠 수 있습니다.

문제를 해결하기 위한 알고리즘의 로직을 코드로 구현할 때 시간 복잡도를 고려한다는 것은 무슨 의미일까요?

바로 '입력값의 변화에 따라 연산을 실행할 때, 연산 횟수에 비해 시간이 얼마나 걸리는가?'를 고려한다는 의미입니다.

효율적인 알고리즘을 구현한다는 것은 바꾸어 말해 입력값이 커짐에 따라 증가하는 시간의 비율을 최소화한 알고리즘을 구성했다는 의미입니다.

그리고 이 시간복잡도는 빅-오 표기법을 사용해 나타냅니다.

빅-오 표기법

빅-오 표기법 말고도 빅-오메가 표기법, 빅-세타 표기법이 있지만 많이 사용하지 않습니다.

왜냐하면 빅-오 표기법은 최악의 상황을 가정하고 계산한 시간이고 빅-오메가는 최선의 상황을 가정하고 계산한 시간, 빅-세타는 평균값을 가정하고 계산한 시간이기 때문입니다.

최선의 상황과 평균의 상황을 가정하게 되면 자칫 계산한 값과 결과가 다르게 나왔을 경우 어디에서 문제가 발생했는지 쉽게 발견하지 못할 수도 있습니다.

따라서 최악의 상황을 가정하는 빅-오 표기법이 대중적으로 많이 사용되는 표기법입니다.

그럼 빅-오 표기법에 대해서 바로 알아보도록 하죠

빅-오 표기법의 종류는 다음과 같습니다.

• O(1)

• O(log n), O(nlog n)

• O(n)

• O(n^2)

• O(2^n)

• O(n!)

n을 무한대로 보냈을 때 그래프는 다음과 같습니다.

이제 각각의 경우를 코드와 함께 살펴보면서 대충 어떤 느낌인지 보도록 하겠습니다.

O(1)

O(1)은 일정한 복잡도라고 하며, 입력값이 증가하더라도 시간이 늘어나지 않습니다.

function O_1_algorithm(arr, index) {

return arr[index];

}

let arr = [1, 2, 3, 4, 5];

let index = 1;

let result = O_1_algorithm(arr, index);

console.log(result); // 2

이 알고리즘에선 입력값이 크기가 아무리 커져도 즉시 출력값을 얻어낼 수 있습니다.

예를 들어서 arr의 길이가 100만이더라도, 즉시 해당 index에 접근해 값을 반환할 수 있습니다. 가장 시간이 적게 드는 가장 효율적인 알고리즘으로서 이렇게 만들수만 있다면 가장 지향해야하는 방식입니다.

O(logn), O(nlogn)

O(log n)은 로그 복잡도라고 부르며, 빅-오 표기법중 O(1) 다음으로 빠른 시간 복잡도를 가집니다.

주로 입력 크기에 따라 처리 시간이 증가하는 정렬알고리즘에서 많이 사용됩니다.

알고리즘중에 유명한 BST (Binary Search Tree / 이진 검색 트리) 또한 O(log n) 의 시간 복잡도를 가지고 있습니다.

간단하게 BST에 대해서 예시를 통해 설명하자면,

이런 트리가 있다고 가정하겠습니다. 우리는 이 트리에서 5를 찾아야합니다.

만약 이걸 for 문으로 돌리기 시작하면 최악의 경우 O(2^n)의 시간복잡도를 가집니다. (정확히는 O(2^(n-1)))

2진 탐색 트리에서는

1. 루트 노드의 키와 찾고자 하는 값을 비교한다. 찾고자 하는 값이라면 탐색을 종료한다.

2. 찾고자 하는 값이 루트 노드의 키보다 작다면 왼쪽 서브 트리로 탐색을 진행한다.

3. 찾고자 하는 값이 루트노드의 키보다 크다면 오른쪽 서브트리로 탐색을 진행한다.

이러한 알고리즘에 의해 값을 찾게됩니다.

그렇게 해서 결국

이러한 방식으로 5를 찾아낼 수 있게 되는 것이죠.

이처럼 입력 값이 점점 커지게 되는 상황에서 이진탐색트리 알고리즘을 사용하게 되고 위와 같은 상황에서 최악의 경우 입력값이 7개인 상황에서 단 세번의 계산만으로 원하는 값을 찾아낼 수 있습니다.

그런데 여기서 의문이 들 수 있습니다.

원하는 값이 5고 첫번째 값이 7인데 5가 왼쪽이 아니고 오른쪽 두번째에 있으면 그게 더 빠른건데 오른쪽으로 가지않고 굳이 값이 작으면 왼쪽으로 가는 이유는 무엇일까요?

그것은 제가 생각했을 때 물론 5가 오른쪽 두번째에 있을 때 오른쪽으로 가는게 가장 빠르겠지요 하지만 그건 오른쪽이 아니고 왼쪽에 5가 있는 경우에도 마찬가지입니다.

따라서 노드를 전부 뒤지는 최악의 상황을 피할 수 있다는 것 만으로도 이진탐색트리는 훌륭한 해결책이 될 수 있습니다.

O(n)

O(n)은 선형 복잡도라고 부르며, 입력값이 증가함에따라 시간 또한 같은 비율로 증가하는 것을 말합니다.

예를 들어서 입력값이 1일 때 1초가 걸리고, 입력값을 100배 증가시켰을 때 1초의 100배인 100초가 걸리는 알고리즘을 구현했다면, 그 알고리즘은 O(n)의 시간복잡도를 가진다고 할 수 있습니다.

function O_n_algorithm(n) {

for (let i = 0; i < n; i++) {

// do something for 1 second

}

}

function another_O_n_algorithm(n) {

for (let i = 0; i < 2n; i++) {

// do something for 1 second

}

}

입력값이 1 증가할때마다 코드의 실행시간이 2초씩 증가합니다.

그럼 이것을 보고 O(2n) 이라고 볼 수도 있지만 시간복잡도를 계산할때는 n이 무한대로 커진다고 (최악의 상황이라고) 가정하기 때문에 2n 과 n 은 무한대로 가면 결국 의미가 퇴색되게 됩니다.

따라서 위의 경우에는 O(n) 이라고 표기하는 것이 올바른 표기입니다.

O(n^2)

O(n^2)은 2차 복잡도라고 부르며, 입력값이 증가함에 따라 시간이 n의 제곱수의 비율로 증가하는 것을 의미합니다.

예를 들어 입력값이 1일 경우 1초가 걸리던 알고리즘에 5라는 값을 주었더니 25초가 걸리게 된다면, 이 알고리즘의 시간 복잡도는 O(n^2) 라고 표기합니다.

대표적으로 2중 for문을 들 수 있겠는데요

function O_quadratic_algorithm(n) {

for (let i = 0; i < n; i++) {

for (let j = 0; j < n; j++) {

// do something for 1 second

}

}

}

function another_O_quadratic_algorithm(n) {

for (let i = 0; i < n; i++) {

for (let j = 0; j < n; j++) {

for (let k = 0; k < n; k++) {

// do something for 1 second

}

}

}

}

이렇게 2중으로 for문이 돌면 시간 복잡도가 O(n^2)만큼 소요가 됩니다.

O(2^n)

O(2^n)은 기하급수적 복잡도라고 부르며, 빅-오 표기법 중 가장 느린 시간 복잡도를 가집니다. (물론 이론상 O(n!) 이 가장 느리긴 하지만 n! 은 현실세계에서 존재하지 않는다고 봐도 될정도로 적은 시간복잡도입니다.)

만약 구현한 알고리즘이 O(2^n)의 시간복잡도를 가진다면 다른 방법을 고민해보는 것이 좋을 것 같습니다.

function fibonacci(n) {

if (n <= 1) {

return 1;

}

return fibonacci(n - 1) + fibonacci(n - 2);

}

대표적인 O(2^n) 시간복잡도를 가진 알고리즘은 바로 피보나치 수열을 재귀로 구현하는 방식입니다. 해당 알고리즘을 브라우저 개발창에서 n을 40으로 두어도 수초가 걸리는 것을 확인할 수 있으며, n이 100이상이면 평생 결과를 반환받지 못할 수도 있습니다.

여기서 포스팅을 마쳐도 좋겠지만 저는 개인적으로 여기까지 구글링을 통해 알아봤을 때 뭔가 아쉬웠습니다.

시간 복잡도를 직접 계산하는 방법은 없나?

그리고 아주 간단한 구글링으로 시간 복잡도를 직접 계산할 수 있는 방법을 찾았습니다.

만약 저처럼 학생이거나 신입으로 회사에 들어가는 경우 코딩테스트를 본 뒤에 코드리뷰를 하면서 시간복잡도를 구해보라는 질문을 받을 수 있습니다. (면접 후기를 몇개 봤을 때 해당 질문이 많았습니다.)

그래서 시간 복잡도를 직접 계산해 봅시다.

int sum = 0;

for (int i = 0; i < n; i++) {

sum += i;

}

-----------------------------

int sum = (n + 1) * n / 2;

위 아래로 나뉜 이 코드는 입력받은 n 만큼 합을 구하는 코드입니다.

위의 방식은 for 문을 통해 하나하나 다 더한 방식이고 아래의 코드는 흔히 알려진 n까지 합을 구하는 공식을 사용한 모습입니다.

먼저 위의 코드에서 sum = 0이 한번 계산되고 i = 0이 한번, i++가 n번, sum += i 가 n번 계산되어 총 2n + 2 번만큼 계산되었습니다. 이것을 빅-오 표기법에 넣어보면 O(2n-2)가 되는데 위에서도 말했듯이 n이 무한대로 가면 의미가 퇴색되기 때문에 이는 O(n)으로 표기 가능합니다.

아래의 방식도 살펴볼까요?

n + 1이 한번, * n 이 한번, / 2 가 한번 총 세번 연산이 들어갔습니다. 이는 빅-오 표기법으로 O(3)이며 이는 O(1)과 같습니다.

어떤가요? 같은 합을 구하는 공식이지만 한쪽은 O(n)으로 빠른편은 아니지만 O(1)은 빅-오 표기법에서 사용할 수 있는 모든 시간복잡도중에 가장 빠른 속도로 구현 가능합니다.

이렇듯 알고리즘을 조금씩 수정하는 것으로 성능향상을 불러일으킬 수 있습니다.

조금 복잡한 예제로 확인해보겠습니다.

int sum = 0;

for (int i = 0; i < n; i++) {

for (int j = 0; j < i; j++) {

sum += j;

}

}

위의 코드는 2중 for 문으로써 직접 계산해보면 sum = 0이 한번, i++이 n번 j++이 i번(n번), sum += j 가 0, 1, 2, 3,....(n-1)번 반복되어 총 1 + n + n(n-1) 번 반복되어 총 O(n^2)의 시간복잡도를 가집니다.

여기까지 알고리즘, 자료구조에서 가장 기초적인 시간복잡도에 대해서 알아봤습니다. 이번에 알고리즘 공부를 하면서 처음 보게된 단어인데 막상 공부해보니 그렇게까지 어렵지는 않네요

여기까지 긴 글 읽어주셔서 감사합니다. 좋은하루 보내세요~

출처

시간 복잡도

https://hanamon.kr/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-time-complexity-%EC%8B%9C%EA%B0%84-%EB%B3%B5%EC%9E%A1%EB%8F%84/

이진 검색 트리 예제코드

https://blog.chulgil.me/algorithm/

이진 검색 트리

https://code-lab1.tistory.com/10

시간 복잡도 직접 계산하기

https://medium.com/humanscape-tech/%EC%BD%94%EB%93%9C%EC%9D%98-%EC%8B%9C%EA%B0%84-%EB%B3%B5%EC%9E%A1%EB%8F%84-%EA%B3%84%EC%82%B0%ED%95%98%EA%B8%B0-b67dd8625966

에라토스테네스의 체 (소수 구하기)

이번 시간에는 코딩테스트 준비를 하면서 문제를 풀다가 흥미로운 알고리즘을 발견해서 해당 내용을 포스팅하려고 합니다. 바로바로 '에라토스테네스의 체' 라는건데요. 이번에도 흥미로운 내용이 있으니 한번 둘러보고 가세요!

에라토스테네스의 체란?

에라토스테네스의 체는 말 그대로 어떤 물질을 걸러주는 '체'를 의미합니다. 여기서는 소수를 걸러주는 체가 되겠네요.

소수에 대해서 아주아주 간단하게 설명하고 넘어가도록 하겠습니다.

소수는 1과 자기자신으로밖에 나눠지지 않는 수를 말합니다. 2 3 5 7 ... 이런 숫자들을 말하죠

1과 자기자신으로밖에 나눠지지 않는 수라는 것은 소수를 공부했던 사람이라면 누구나 떠올릴 수 있는 개념입니다.

이것을 코딩으로 구현하려면 어떻게 해야할까요?

정말 우리가 흔히 알고있는 개념으로 접근하려면 이렇게 해야합니다.

나눠지는 수 ÷ 나누는 수 -> 나머지가 0이면서 나눠지는 수 == 나누는 수 인경우를 체크하면 됩니다.

코드로 구현하면 다음과 같겠죠

import java.util.Scanner;

public class Memo {

public static void main(String[] args) {

Scanner kb = new Scanner(System.in);

int input = kb.nextInt();

System.out.println(solution(input));

}

private static String solution(int input) {

String answer = "";

for (int i = 2; i < input; i++) {

if (isPrime(i)) {

answer += i + " ";

}

}

return answer;

}

private static boolean isPrime(int num) {

if (num == 1) {

return false;

}

for (int i = 2; i < num; i++) {

if (num % i == 0) {

return false;

}

}

return true;

}

}

소수의 개수를 찾던간에 직접 찾던간에 2중 for 문을 돌려가면서 찾아야합니다. 하지만 위의 코드는 시간 복잡도가 O(n^2) 으로 값이 조금만 커지면 문제에서 제시하는 시간초안에 해결하지 못해 타임오버가 됩니다.

시간 복잡도에 대한 내용은 이전 포스팅에서 다뤘으니 시간복잡도에 대해서 알고싶으신 분들은 아래의 링크를 확인해주세요.

https://coding-review.tistory.com/267

위의 방식은 정답이긴 하지만 시간초과로 오답이고... 어떻게 해야할까요?

바로 나누는 수와 그 배수만큼을 지워버리면 됩니다.

코드로 바로 보시죠

import java.util.Scanner;

public class Memo2 {

/**

* ex) 입력값이 10이면

* *초기화

* 2 3 4 5 6 7 8 9 10

* 0 0 0 0 0 0 0 0 0

*

* * i = 2 일때

* 2 3 4 5 6 7 8 9 10

* 1 0 1 0 1 0 1 0 1

* 2와 2의 배수를 1로 바꾸고 1 증가

*

* * i = 3 일때

* 2 3 4 5 6 7 8 9 10

* 1 1 1 0 1 0 1 1 1

* 3과 3의 배수를 1로 바꾸고 1 증가

*

* ... 이런식으로 계속 나아가는 방식

*

* for (int i = 2 ; i <= n ; i++) {

* if (ch[i] == 0) {

* answer++;

* for (int j = i ; j <= n ; j = j + i) {

* ch[j] = 1;

* }

* }

* }

*/

public static void main(String[] args) {

Scanner kb = new Scanner(System.in);

int input = kb.nextInt();

System.out.println(solution(input));

}

private static String solution(int num) {

String answer = "";

int cnt = 0;

int[] arr = new int[num + 1];

for (int i = 2; i <= num; i++) {

if (arr[i] == 0) {

cnt++;

answer += i + " ";

for (int j = i; j <= num; j = j + i) {

arr[j] = 1;

}

}

}

return answer;

}

}

주석에도 써있지만 다시 한번 설명하자면

예를 들어서 입력값이 10이라고 가정했을 때

각각의 숫자를 index로 하는 배열을 만듭니다. 그리고 초기값은 0으로 초기화합니다. 배열의 크기는 입력값 + 1로 설정합니다.

그럼 이런 그림이겠죠

0 1 2 3 4 5 6 7 8 9 10 -> index

0 0 0 0 0 0 0 0 0 0 0 -> 실제 값

어차피 0 이랑 1은 필요없으니까 빼고 보면

↓

2 3 4 5 6 7 8 9 10

0 0 0 0 0 0 0 0 0

현재 인덱스는 2를 가리키고 있습니다. 여기서 인덱스를 1씩 올리면서 값을 확인합니다. 이때 해당 값이 0이면 그 인덱스를 누적합니다.

그리고 그 다음에 2의 배수들을 전부 1로 바꿔줍니다.

2 3 4 5 6 7 8 9 10

1 0 1 0 1 0 1 0 1

그리고 인덱스를 하나 증가시킵니다.

↓

2 3 4 5 6 7 8 9 10

1 0 1 0 1 0 1 0 1

현재 인덱스는 3을 가리키고 있습니다. 해당 인덱스 값이 0이니 값을 누적하고 3의 배수를 전부 1로 바꿉니다.

2 3 4 5 6 7 8 9 10

1 1 1 0 1 0 1 1 1

한번만 더해보죠 다음 인덱스는 4를 가리키고 있습니다.

↓

2 3 4 5 6 7 8 9 10

1 1 1 0 1 0 1 1 1

이번엔 값이 1이네요 그럼 그냥 넘어갑니다.

이런식으로 하나하나 1로 만들다보면 값이 전부 1로 바뀌게 됩니다. 그럼 반복문이 종료됩니다.

이러한 방식을 사용하게 되면 기존에 2중 for 문으로 돌렸을 때의 단점이 완벽히 메꿔집니다. 기존 방식대로였으면 121을 확인하기 위해서는 for 문을 11 * 11 번 즉 121번만큼 돌았어야 했는데 에라토스테네스의 체 알고리즘을 사용하게 되면 인덱스 번호가 11까지만 가도 11의 배수는 전부 사라지기 때문에 121까지 for 문이 가지 않아도 됩니다.

이렇게 소수를 찾아내는 방식이 바로 에라토스테네스의 체 알고리즘 방식입니다.

에라토스테네스의 체 알고리즘을 사용하게 된다면 기존 시간복잡도 O(n^2)에서 O(n^1/2) (n의 제곱근 / 루트 n)으로 확실히 줄일 수 있습니다. 참고로 루트n은 log n과 비슷한 시간복잡도를 갖고있습니다. 그래프 모양에서도 이를 확인할 수 있습니다.

어떤가요? 흥미롭지않나요?

이렇게 공부하고 나니 뭔가... 기분이 오묘하네요. 정말 신기한 광경을 봤을 때의 경외심과 동시에 이걸 모르면 못푼다는 얘기 아닌가? 하는 의구심이 들어서 얼마나 많은 알고리즘을 알아야 하는거지 싶은 걱정도 드네요

아무튼 여기까지 '에라토스테네스의 체'에 대해서 알아봤습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 좋은 하루 보내세요~

Two-Pointers-Algorithm

Two-Pointers-Algorithm

two pointers algorithm (이하 투 포인터 알고리즘) 알고리즘은 말 그대로 포인터를 사용해 배열에 쉽게 접근할 수 있게 해줍니다.

자바에서도 배열에 쉽게 접근하기 위해 포인터를 사용하는 경우가 종종 있습니다.

보통 lt (left), rt (right) 혹은 s (start), e (end) 혹은 p1, p2 이런식으로 포인터를 정해서 값을 하나씩 증가시키면서 배열을 참조합니다.

투 포인터 알고리즘을 사용하는 문제는 비교적 자주 만나볼 수 있으며 한번 익혀두면 나중에 써먹기 정말 편하다고 합니다. 물론 저는 세번밖에 안써봤습니다.

먼저 문제를 확인해보시죠

문제를 해석하자면 N개로 된 수열에서 N개의 부분집합 중에서 M이 되는 개수가 몇개냐라고 물어보는 문제입니다.

이 문제를 모든 경우의 수를 확인하기 위해 2중 for 문을 이용해 풀기 시작하면 시간 복잡도가 O(n^2)이 걸리게 되어 시간초과로 오답이 나오는 경우입니다.

이 문제 풀이의 핵심은 왼쪽에 존재하는 s (start) 그리고 오른쪽에 존재하는 e (end) 를 이용해 인덱스를 하나씩 이동시키면서 확인하는 방식입니다.

즉, 맨 처음에는 s 와 e 가 0이며 항상 s <= e 를 만족해야 합니다. 2개의 포인터는 현재 부분 배열의 시작과 끝을 가리키는 역할을 합니다.

이것을 해결하기 위한 알고리즘은 아주 간단합니다.

• 만약 현재 부분합이 M 이상이거나 이미 e = N (e 가 끝에 닿으면) s를 하나 증가시킵니다.

• 그렇지 않다면 e 를 하나 증가시킵니다.

• 현재 부분합이 M과 같으면 결괏값을 하나 증가시킵니다. 그러면서 s 를 하나 증가시킵니다.

• s = e 인경우에는 sum (M) = 0 이라 정의합니다. 이 경우에는 e 를 하나 증가시킵니다.

이제 그림과 함께 풀이해보도록 하겠습니다.

현재 상태는 초기상태입니다. s 와 e 가 인덱스 0에 위치하고 있습니다.

s 와 e 가 같기때문에 e 를 하나 증가시켰습니다. 이렇게 되니 부분합이 1이 됐네요. 부분합이 목표값인 M보다 작기 때문에 e 를 하나 더 증가시킵니다.

e 를 하나 증가시켰더니 부분합이 3이 되었습니다. 여전히 M 보다 작기 때문에 오른쪽으로 한 칸 더 이동합니다.

e 가 2를 가리키고 s는 0을 가리키고 있는 상태입니다. 부분합은 6이네요 우리가 원하는 숫자인 5보다 크기때문에 s를 한칸 앞으로 이동시킵니다.

s 가 한칸 이동해서 인덱스 1을 가리키게 되었습니다. 이제 부분합이 5가 되었습니다. 그러니 3번 알고리즘에 의해 결괏값을 하나 증가시키고 s 를 한칸 앞으로 이동시키겠습니다.

어떤 느낌인지 이제 슬슬 아시겠죠?

이 알고리즘을 다 따라가다보면 결국 e 는 종점에 도착하게 됩니다.

이제 남은건 s 가 e 와 같아질 때까지 앞으로 증가시키면 됩니다.

이렇게 s 가 배열의 끝에 다다르면 반복문을 종료합니다. 이렇게 answer = 3 을 찾아내는 과정이 끝났습니다.

이처럼 투 포인터 알고리즘을 사용하면 O(n) 의 시간복잡도로 문제를 해결할 수 있습니다. 어떻게 O(n) 일까요? s 와 e 는 매 과정에서 무조건 1씩 증가하게 됩니다. s 와 e 는 최대 N 까지 증가할 수 있으며 s <= e 이기 때문에 둘이 증가하는 과정은 최대 N 번만 반복될 것입니다.

더 자세히 말하면 O(2n)이지만 우리는 앞서 시간복잡도에서 n이 무한대로 가면 의미가 퇴색되기 때문에 가장 기본이되는 표기법을 사용합니다.

따라서 투 포인터 알고리즘을 사용하게 되면 O(n^2)가 걸리는 문제를 O(n)에 해결할 수 있습니다.

하지만 투 포인터 알고리즘이 모든 정답을 찾아내는가에 대한 의문을 가질 수 있습니다. 이것에 대해서는 아래와 같이 증명할 수 있습니다.

S 부터 E 까지의 구간합이 M인 구간이 있습니다. 우리는 이 구간이 몇 개 있는지 찾아야합니다. 이때 투 포인터 알고리즘을 사용하면 저 구간을 놓칠 수 없습니다. 만약 저 구간을 놓치는 경우를 가정해볼까요?

1. s = S 가 되기 전에 e 가 E 를 넘어가는 경우

위와 같은 상황은 벌어지지 않습니다.

s 와 e 는 한 칸씩 오른쪽으로 이동합니다. 이때 e 는 무조건 E 를 한 번은 지나가게 되는데, 만약 s 가 S 보다 왼쪽에 있다면, s 부터 e 까지의 구간합은 무조건 M 보다 클 수밖에 없습니다. 물론 이것은 배열이 모두 자연수라는 가정에서입니다. 배열에 음수가 있다면 성립하지 않습니다. 하지만 위의 경우에서는 e 가 E 보다 더 오른쪽으로 이동하는 일은 벌어지지 않습니다.

2. e 가 E 가 되기전에 s 가 S 를 넘어가는 경우

이 경우도 1번과 같은 방식으로 증명할 수 있기 때문에 넘어가도록 하겠습니다.

결론

투 포인터 알고리즘은 모든 경우에수를 고려한다.

여기까지 투 포인터 알고리즘에 대해서 알아봤습니다. 알고리즘은 재밌는듯 하면서도 어려워서 쉽지않네요. 제가 모르는게 많아서 앞으로 더 재밌는 알고리즘이 나올듯 하네요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 보내세요~

출처

https://github.com/WooVictory/Ready-For-Tech-Interview/blob/master/Algorithm/%ED%88%AC%ED%8F%AC%EC%9D%B8%ED%84%B0%20%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98.md

https://code-lab1.tistory.com/276

자료구조 : Map

오늘은 알고리즘 문제에서도 많이 사용하고 실전에서도 많이 사용한다고 알려져있는 Map에 대해서 알아보도록 하겠습니다.

Map

맵은 사전과 비슷합니다. people이란 단어에 "사람", baseball이란 단어에 "야구" 라는 뜻이 부합되듯이 Map은 Key와 Value라는 것을 한 쌍으로 갖는 자료구조입니다. Map은 리스트나 배열처럼 순차적으로 해당 요소 값을 구하지 않고 Key를 통해서 Value를 얻습니다.

Map의 가장 큰 특징은 Key로 Value를 얻어낸다는 것입니다. people이라는 단어의 뜻을 찾기 위해서 사전의 내용을 순차적으로 검색하는 것이 아니라 해당 단어가 있는 곳만을 펼쳐보는 것이죠.

우리가 잘 아는 Map은 List와 마찬가지로 인터페이스입니다. Map인터페이스를 구현한 HashMap, LinkedHashMap, TreeMap 등이 있습니다.

기본적으로 put 메서드를 이용해 데이터를 집어넣고 get 메서드를 이용해 데이터를 조회합니다.

또한 다양한 메서드로 Map 객체에 접근할 수 있습니다. contains 라는 메서드는 파라미터로 넘긴 키값이 존재하는지를 빠르게 찾을 수 있습니다. 위에서도 설명했지만 Map은 순차적으로 모든 키-값을 조회하는 것이 아니라 키가 존재하면 바로 값을 리턴해주는 빠른 자료구조입니다.

값이 더이상 필요 없어지만 remove를 이용해 키-값을 삭제할수도 있습니다. 혹은 size 메서드를 통해 키의 개수를 세어볼 수도 있습니다.

키값만 따로 모아서 Set 자료구조에 넣을수도 있습니다. 바로 entrySet 메서드를 이용하면 됩니다.

이처럼 다양한 메서드로 key-value에 접근할 수 있고 이러한 방식은 key를 입력해서 value를 빠르게 구하고 싶으면 사용해볼만한 자료구조입니다.

HashMap

Map 인터페이스의 가장 기본적인 구현체입니다. 대중적으로 많이 사용되는 구현체로 많은 개발자들에게 사랑을 받고 있습니다.

Map인터페이스에서 제공하는 기본적인 메서드를 그대로 가져왔으며 특별히 다른 메서드는 존재하지않습니다.

TreeMap

TreeMap은 기존 Map과 다르게 정렬을 제공합니다. 기본적으로 선언할 때 오름차순을 디폴트 값으로 지정됩니다. 혹시 내림차순으로 값을 정렬하고싶다면 Collections.reverseOrder() 메서드를 사용하면 간단하게 뒤집을 수 있습니다.

Map<String, String> map = new TreeMap<>(); // 기본적인 초기화 (오른차순)

Map<String, Stirng> map = new TreeMap<>(Collections.reverseOrder()); // 내림차순으로 초기화

HashMap은 데이터 정렬이 없습니다. key 를 기준으로 정렬할 필요성이 있다면 유용하게 사용할 수 있는것이 TreeMap 클래스입니다.

LinkedHashMap

LinkedHashMap은 입력순서대로 정렬하는 기능을 제공합니다.

즉, 입력 순서가

map.put("a", "A");

map.put("b", "B");

map.put("c", "C");

이렇게 되었다면 다시 출력했을 때 A, B, C 순으로 출력됩니다. 그럼 HashMap은 그렇게 안될까요?

직접 해보시면 뒤죽박죽으로 출력되는 것을 확인하실 수 있습니다.

HashMap vs TreeMap vs LinkedHashMap

정리를 해보자면 HashMap은 기본적인 Map과 같고 TreeMap은 키값으로 정렬하고싶을 때 LinkedHashMap은 입력순서대로 정렬하고싶을 때 사용한다고 할 수 있겠습니다.

참고로 TreeMap, 그리고 이후 포스팅에서 설명할 TreeSet은 이름에서도 알 수 있듯이 이진트리로 데이터를 관리하기 때문에 TreeMap, TreeSet을 사용한다면 값을 찾을 때 시간복잡도가 O(log n)으로 굉장히 빠른축에 속합니다.

웬만한 알고리즘 문제에서 타임오버되는 문제들의 시간복잡도를 O(n)으로 해주면 대부분 풀리는데 O(log n)은 O(n)보다 훨씬 빠른 결과를 도출해냅니다.

하지만 정말 중요한 개념이 하나 남아있습니다. 바로 ConcurrentHashMap인데요, 멀티스레드 환경인 실무환경에서 제일 많이 사용하는 구현체로서 기존 HashMap에서 해결하지 못했던 동시성문제를 센스있게 해결한 것으로 유명합니다.

ConcurrentMap, ConcurrentHashMap은 바로 다음 포스팅에서 알아보도록 하겠습니다.

그럼 이번 포스팅은 여기까지 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 좋은 하루 되세요~

자료구조 : Hash

컴퓨터 사이언스에서 대표적인 자료구조인 Hash는 key-value로 값을 찾는 알고리즘에 있어서 가장 빠른 속도를 보여줍니다.

때문에 많은 언어에서 Hash 자료구조를 구현해서 사용하고 있습니다.

이번 포스팅에서는 Hash는 무엇이고 어떻게 값을 찾는지, 자바에서는 어떻게 활용하고 있는지에 대해서 알아보도록 하겠습니다.

Hash

Hash란 무엇일까요? Hash는 특정한 숫자 혹은 문자 혹은 둘의 혼합으로 이루어진 key라고 생각하시면 됩니다.

보통 어떤 값은 Hash Function에 의하여 해쉬로 바뀝니다. 그리고 그 Hash를 key로 하는 저장 공간을 만들고 나중에 해당 값을 찾으려면 그 값의 Hash값을 대응해서 찾습니다.

Hash Function은 key를 고정된 길이의 해쉬로 변경해주는 역할을 합니다. 이 과정을 hashing 이라고 합니다.

간단하게 정리하자면 key를 해시함수라는 함수에 input으로 넣어서 output으로 나오는 것이 Hash(해시)라고 생각하시면 되고, 이 Hash가 저장위치가 된다고 생가갛면 됩니다.

이러한 Hash를 사용했을 때의 장단점을 무엇일까요?

장점

해시테이블은 key-value가 1:1로 매칭되어 있기 때문에 삽입, 삭제, 검색의 과정에서 모두 O(1)의 시간복잡도를 가지고 있습니다. 시간복잡도에서 O(1)이면 아주아주 빠른 속도이고 가장 빠른 속도입니다.

단점

단점에는 여러가지가 있는데

• 해시 충돌이 발생

• 순서 / 관계가 있는 배열에는 어울리지 않는다.

• 공간 효율성이 떨어진다. 데이터가 저장되기 전에 저장공간을 미리 만들어놔야한다. 공간을 만들었지만 공간에 채워지지 않는 경우가 발생한다.

• 해시함수의 의존도가 높다. 해시함수가 복잡하다면 해시를 만들어내는데 오래 걸릴 것이다.

이 단점들 중에서 가장 핵심적인 해시충돌에 대해서 알아보도록 하겠습니다.

해시충돌이란 어떤 key 값을 해시함수에 의해 해시값으로 만드는 과정에서 동일한 해시값이 나오는 경우를 말합니다.

이러한 해시충돌을 해결하기 위해 개방주소법을 이용합니다.

위의 그림에서 John과 Sandra의 해시가 152로 동일해서 충돌이 일어났습니다. 이때 Sandra는 바로 그 다음 비어있던 153 해시에 값을 저장합니다. 그 다음 Ted가 테이블에 저장하려 했으나 본인의 해시에 이미 Sandra로 채워져 있어 Ted도 Sandra처럼 다음 비어있던 154 해시에 저장합니다.

이런식으로 충돌이 발생할 경우 비어잇는 해시를 찾아 저장하는 방법이 개방주소법입니다. 이때 비어있는 해시를 찾는 방법은 두가지가 있습니다.

• 선형탐색 : 위와 같은 예제가 바로 선형탐색입니다. 해시가 충돌나면 그다음 또 충돌나면 그 다음 이렇게 값을 1씩 증가시켜 탐색하는 과정입니다.

• 제곱 탐색 : 말 그대로 제곱에 해당하는 해시에 저장하는 방식입니다. 선형탐색과 다르게 공간을 많이 확보해 놔야 한다는 단점이 있습니다.

이러한 해시를 자바에서는 어떻게 사용하고 있을까요?

HashMap과 HashSet

자바에서는 이러한 Hash자료구조를 이용해 HashMap과 HashSet을 지원하고 있습니다. 둘 다 해시를 사용하기 때문에 삽입, 삭제, 검색의 과정에서 빠른 성능을 보여주며 대표적으로 사용되는 클래스입니다.

하지만 자바에서는 삽입 순서를 보장해야 한다거나, 동시성 문제를 해결해야 한다거나 하는 이유로 확장된 클래스를 사용하기도 합니다.

LinkedHashMap, LinkedHashSet

삽입 순서를 보장해야 하는 경우 LinkedHashMap, LinkedHashSet을 사용하는데 둘 다 삽입에 대한 순서를 보장하지 않는 Hash의 특징을 보완하기 위해 나온 클래스입니다.

Linked 한 List를 제공해야 하기 때문에 순수한 HashMap, HashSet 보다는 무겁지만 이렇게 무거워도 기존 Hash의 특징인 시간복잡도가 O(1)이라는 특징은 변하지 않습니다. 따라서 성능적으로 사용하는데에는 문제가 없습니다.

ConcurrentHashMap

우리가 HashMap을 이용한다는 것은 자바 내부적으로 엄청나게 많은 일들이 일어납니다. 간단하게 두 상황을 가정해보도록 하겠습니다.

Map<String, String> map = new HashMap<>();

Thread 1

map.put("hi", "hi");

Thread 2

map.get("hi");

이 상황에서 동시성 문제가 발생할 수 있습니다. 바로 Map이 정상적으로 put 하기 전에 get을 요청하는 경우이죠. Map 입장에서는 put으로 객체를 만들지도 않았는데 갑자기 get을 요청해버리니 값이 꼬여버리거나 이상한 값이 리턴되는 상황이 발생합니다.

ConcurrentHashMap에서는 이러한 상황을 방지하기 위해 다양한 노력을 기울이고 있습니다.

ConcurrentHashMap은 조회(get)에는 동기화를 하지않고 삽입과 삭제에 대해서는 동기화를 synchronized를 통해 진행하여 동시성 문제를 막습니다.

또 이런 경우가 있을 수도 있겠죠

Map<String, String> map = new HashMap<>();

Thread 1

map.put("A", "A");

Thread 2

map.put("B", "B");

위와 같은 상황에서 스레드1과 스레드2가 거의 동시에 일어난다면 어떻게 될까요?

위에서 설명했듯이 동기화가 되어있기 때문에 문제없을 것 같네요. 하지만 단순히 synchronized로 동기화 했기 때문에 동시성 문제가 발생하지 않는 것이 아닙니다.

단순히 synchronized로 Wrapping해서 동기화 하였다면 성능적인 부분에서 문제가 발생할 수 있습니다. 스레드가 lock 을 획득하고 다음 스레드는 lock을 획득하기까지 해당 메서드에 접근할 수 없기 때문에 기다려야 하는 상황이 발생합니다.

ConcurrentHashMap은 Segment라는 개념을 도입해 각각의 스레드가 해당 스레드에 맞는 Segment를 부여받고 그 Segment에 해당하는 일만 수행하기 때문에 동기화 작업과 별개로 동시성 문제가 발생하지 않습니다.

즉, 스레드1은 1번 Segment를 부여받고 스레드2는 2번 Segment를 부여받기 때문에 스레드가 동시에 실행되어도 부여받은 Segment에 의해 작업이 분리되기 때문에 동시성문제가 해결됩니다.

아무튼 HashMap은 ConcurrentHashMap이라는 강력한 동시성 문제 해결 방안 덕분에 실무에서 안전하게 HashMap을 사용할 수 있게 해줍니다.

더 자세한 내용은 아래의 링크를 타고 들어가시면 알 수 있습니다.

https://coding-review.tistory.com/278

그럼 HashSet은 어떨까요?

안타깝게도 HashSet도 Thread-safe 하지 않지만 HashMap처럼 따로 동기화 작업을 한 클래스가 없습니다.

따라서 HashSet의 동시성 문제를 해결하기 위해서는 따로 동기화 작업을 거쳐야 합니다.

이렇게 Hash에 대해서 알아보고 자바에서는 Hash를 어떻게 이용하는지까지 알아봤습니다. 크게 어려운 부분이 없어서 이해하는데 큰 어려움이 있지는 않았던 것 같습니다.

긴 글 읽어주셔서 감사합니다. 저는 다음 포스팅에서 뵙도록 하겠습니다.

출처

https://www.inflearn.com/questions/347336/threadlocal-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-arraylist-hashmap-hashset

https://www.baeldung.com/java-linkedhashset

=> 자바 공식 가이드 사이트

https://go-coding.tistory.com/30

자료구조 : Tree

이번 시간에는 전 시간에 이어서 컴퓨터 사이언스에서 가장 사랑받는 자료구조 중 하나인 Tree에 대해서 알아보도록 하겠습니다.

이번 포스팅에서는 Tree의 개념, Tree 구조에서 가장 많이 사용하는 구조인 이진 트리, 이진 탐색 트리에 대해서 알아보도록 하겠습니다.

Tree

트리의 구조는 위의 사진과 같습니다. 나무가 가지를 뻗듯이 데이터를 저장한다고 하여 붙여진 이름이 바로 트리입니다.

트리의 이러한 구조는 데이터의 저장보다는 저장된 데이터를 효과적으로 탐색하기 위해서 사용합니다. 따라서 Heap Space가 트리로 구현되어 있고, 컴퓨터에서 디렉토리를 분류할 때에도 트리 구조를 사용합니다.

트리의 개념에 대해 알아봤으니 용어를 알아보도록 하겠습니다.

• 루트 노드 (root node) : 부모가 없는 최상위 노드

• 단말 노드 (leaf node) : 자식이 없는 최하위 노드(들)

• 크기 (size) : 트리에 포함된 모든 노드의 개수

• 깊이 (depth) : 루트 노드로부터의 거리

• 높이 (height) : 깊이 중 최댓값

용어도 크게 어려운게 없으니 빠르게 넘어가도록 하겠습니다.

트리 구조를 가장 효율적으로 사용하는 방법에는 이진 트리가 있습니다. 이제 이진 트리에 대해 알아볼까요?

이진 트리 (Binary Tree)

트리를 구성하는 노드의 가지 개수는 0개, 1개, 2개, 3개... 등 여러개가 될 수 있지만 이진 트리는 다릅니다.

이진 트리는 모든 노드가 최대 2개의 서브 트리를 가지고 있는 트리로, 서브 트리 또한 모두 이진트리입니다. 즉, 가지가 최대 2개인 노드로만 구서오디는 트리라는 뜻입니다.

이진 트리의 종류로는 편향 이진 트리 (Skewed Binary Tree), 포화 이진 트리 (Full Binary Tree), 완전 이진 트리 (Compelete Binary Tree) 이렇게 세가지 입니다. 각각에 대해서 알아볼까요?

편향 이진 트리 (Skewed Binary Tree)

편향 이진 트리는 하나의 차수로만 이루어져 있는 경우를 의미합니다. 이러한 구조는 배열(리스트)과 같은 선형 구조이므로 가장 아래쪽에 위치한 노드를 탐색하기 위해 모든 데이터를 전부 탐색해야 한다는 단점이 있어서 효율적이지 못합니다.

포화 이진 트리 (Full Binary Tree)

포화 이진 트리는 Leaf Node를 제외한 모든 노드의 차수가 2개로 이루어져 있는 경우를 의미합ㄴ디ㅏ. 이 경우 해당 차수에 몇 개의 노드가 존재하는지 바로 알 수 있으므로 노드의 개수를 파악할 때 용이한 장점이 있습니다.

완전 이진 트리 (Compelete Binary Tree)

포화 이진 트리와 같은 개념으로 트리를 생성하지만, 모든 노드가 왼쪽부터 차근차근 생성되는 이진 트리를 의미합니다. Heap Space가 완전 이진 트리의 일종입니다.

이진 탐색 트리

이진 탐색 트리는 이진 트리의 일종이지만 조건이 붙어있는 트리입니다.

입력된 값과 노드를 비교하여 작은 값이면 왼쪽으로 큰 값이면 오른쪽으로 가서 저장됩니다.

위와 같이 이진 탐색 트리를 만들었습니다. 그런데 왜 이진 탐색 트리 형태로 만들까요? 바로 데이터를 효율적으로 탐색할 수 있기 때문입니다.

원하는 값을 찾을 때까지 현재의 노드값보다 찾고자하는 값이 작으면 왼쪽으로 움직이고, 크면 오른쪽으로 움직입니다. 이렇게하면 원하는 값을 더 빠르게 찾을 수 있습니다.

위의 예제는 위쪽은 이진 탐색 트리로 27을 찾는 과정이고 아래는 무작정 처음부터 27을 구하는 과정입니다. 이진 탐색 트리를 활용하면 3번만에 끝나는 과정이 배열이나 리스트로 찾게 되면 10번이나 됩니다.

이진 탐색 트리가 탐색에 용이하다는 것은 이제 알겠습니다. 하지만 이진 탐색 트리의 또 하나의 장점이 있죠 바로 정렬이 가능하다는 것입니다.

바로 위의 예제를 확인해보면

왼쪽 맨 아래서부터 ↗↘이렇게 값을 읽어나가면 다음과 같습니다.

5 11 12 14 15 18 19 21 23 ....

그래서 자바에서 Tree 자료구조를 이용한 대표적인 클래스인 TreeMap과 TreeSet이 이러한 방식으로 값을 정렬합니다. 트리 구조를 이용한 두개의 클래스는 들어오는 값이 어떤 값이던 간에 이진 탐색 트리의 조건에 맞게 배치하면 저절로 정렬이 되기 때문에 딱히 정렬에 특별한 로직을 사용하지 않아도 됩니다.

이진 탐색 트리의 시간 복잡도는 탐색, 저장 모두 O(log n)을 가집니다. Hash와 비교하면 성능상 이점을 보긴 힘들지만 정렬에 대한 이점과 key 값을 모를 때 값을 처음부터 찾아나가야 한다면 Tree 구조를 사용하는 클래스들이 나쁘지만은 않습니다.

또한 시간 복잡도가 O(log n)은 코딩테스트에서 웬만하면 시간 초과가 뜨지 않는 O(n)보다도 빠른 속도를 가집니다.

하지만 우리 변태같은 개발자들... O(log n)의 시간 복잡도는 성에 차지 않았는지 더 효율적인 자료구조를 만들기 시작합니다. 그것이 바로 레드-블랙 트리입니다.

레드 블랙 트리에 대해 잠깐 알아보도록 하죠

레드 블랙 트리

레드 블랙 트리는 이진 탐색 트리의 변형된 모델로 앞서 말했듯이 탐색시간을 2배가까이 줄여주는 자료구조입니다.

레드 블랙 트리는 이진 탐색 트리에서 조건이 몇개 더 추가됩니다.

• Root Property : 루트 노드의 색깔은 검정이다.

• External Property : 모든 external node 들은 검정이다.

• Internal Property : 빨강 노드의 자식은 검정이다. == No Double Red(빨간색 노드가 연속으로 나올 수 없다.)

• Depth Property : 모든 리프노드에서 Black Depth는 같다. == 리프노드에서 루트노드까지 가는 경로에서 만나는 블랙 노드의 개수는 같다.

이런 조건에 의해 이진 탐색 트리를 색칠해나가면 됩니다.

간단하게 예제를 통해 알아볼까요?

루트 노드의 색깔은 검정을 줬습니다.

이제 값들을 삽입해보겠습니다.

참고로, 삽입되는 노드의 색은 무조건 빨간색 입니다.

아니 그럼 세번째 삽입되는 노드도 빨간색이면 3번 조건에 위배될 가능성이 있겠네요?

그것에 대한 해결책이 뒤에 나옵니다. 우선 무작정 넣어보도록 하죠

2와 8을 넣어봤습니다. 2는 4보다 작으니까 왼쪽 8은 4보다 크니까 오른쪽. 잘 들어갔습니다. 값을 하나 더 넣어보겠습니다.

3은 4보다 작고 2보다는 크니 해당 위치로 이동하겠죠?

하지만, 이렇게 되는 순간 3번 조건을 위반하게 됩니다. Red의 자식은 Black이어야 하는데 Red가 왔네요.

이러한 상황을 Double Red 라고 하며 레드 블랙 트리에서는 이러한 Double Red를 해결하기 위한 두가지 방법을 제시합니다.

바로 Restructuring과 Recoloring이죠

우선 그림으로 정리해봤는데요.

Double Red를 해결하는 방법은 현재 삽입된 노드의 uncle node의 색깔에 따라 수행하는 프로시저가 다릅니다.

말 그대로 삼촌 노드 즉, 부모의 형제 노드 여기서는 w가 되겠네요

w가 검정일땐 Restructuring

w가 빨강일 땐 Recoloring을 진행합니다.

Restructuring을 먼저 알아보겠습니다.

Restructuring

Restructuring은 현재 삽입된 노드와 내 부모노드와 내 부모의 부모를 가지고 Restructuring을 진행합니다.

Restructuring은 다음과 같은 순서로 진행됩니다.

• 삽입된 노드를 '나'라고 하면 나와 내 부모, 내 부모의 부모를 오름차순으로 정렬

• 무조건 가운데 있는 값을 부모로 만들고 나머지 둘을 자식으로 만든다.

• 올라간 가운데 있는 값을 검정으로 만들고 두 자식을 빨강으로 만든다.

현재 삽입된 노드와 부모노드 부모의 부모노드를 잡았습니다. 그리고 이제 순서대로 하면 됩니다.

1번 오름차순이었죠?

오름차순으로 정렬이니 이런 식으로 되겠네요. 이제 가운데 있는 값을 부모로 만들고 나머지 둘을 자식으로 만듭니다.

6보다 작은 4를 왼쪽에 두고 6보다 큰 7을 오른쪽으로 두는 것은 이진 탐색 트리이니 당연하겠죠?

마지막 단계로 부모로 올라간 6을 검정으로 그 자식들을 빨강으로 만듭니다.

바로 이렇게요. 그리고 이제 원래 4의 자식이었던 2를 추가하면 됩니다.

자 이제 Restructuring이 끝났습니다. 정말 Double Red가 없어졌죠?

Restructuring은 다른 서브트리에 영향을 끼치지 않기 때문에 한번의 Restructuring이면 끝납니다. 여기서 말하는 영향이란 Black Depth에요. 위에서 4번 조건 기억나시죠?

모든 리프노드에서 Black Depth는 같다인데 Double Red를 해결하기 전과 해결한 후의 Black 노드의 개수에 변화가 없기 때문에 다른 서브트리에 영향을 끼치지 않게 됩니다.

또한, Restructuring 자체의 시간 복잡도는 O(1)에서 끝나지만 Restructuring은 어떤 노드를 삽입해야만 일어나기 때문에 결과적으로 총 수행시간은 O(log n)입인다. 현재 노드가 들어갈 위치를 먼저 찾아야 하기 때문이죠.

자 이제 Restructuring은 끝났습니다. 그럼 다음엔 Recoloring을 알아볼까요?

Recoloring

Recoloring의 과정은 다음과 같습니다.

• 현재 삽입된 노드의 부모와 그 형제를 검정으로 만들고 부모의 부모를 빨강으로 한다.

• 부모의 부모가 루트노드가 아니었을 시 Double Red가 발생할 수 있다.

이번에도 예제를 통해 알아보도록 하죠

현재 삽입된 노드의 부모(v)와 그 형제(w)의 색깔을 검정으로 만들어줍니다.

그리고 부모의 부모 노드를 빨강으로 만들어줍니다.

하지만 만약 부모의 부모가 루트 노드였다면, 레드 블랙 트리의 1번 조건에 의해 검정으로 바뀌게 됩니다.

어? 이거 맞게 된건가요? 검빨검빨 이런식으로 번갈아가면서 나와야 되는거 아닌가요? 라고 의심이 들 수 있습니다.

하지만 위와 같은 상황은 레드 블랙 트리의 네가지 조건에 전부 부합합니다. 따라서 위의 상황은 올바른 상황입니다.

하지만 이런 경우는 그렇다 치더라도 만약 우리가 Recoloring 한 트리가 엄청 큰 트리의 서브트리였다면 어떨까요?

그럼 이 상황일텐데 이 위에 부모 노드가 빨강이면 어떡하죠? 어떡하긴요... 또 Recoloring을 하던가 Restructuring을 진행합니다.

계속 Recoloring을 진행하다가 삼촌 노드의 색이 검정이면 Restructuring을 진행하고 Restructuring은 다른 서브트리에 영향을 끼치지 않으므로 여기서 종료가 됩니다.

이렇게 레드 블랙 트리에 대해서 알아봤습니다. 이렇게 네가지 조건에 만족하게 이진 탐색 트리를 구성하게 되면 이진 탐색 트리임에도 불구하고 높이가 log n에 바운드 됩니다.

자 여기까지 Tree 자료구조 그리고 탐색에 가장 많이 사용되는 이진 트리, 이진 탐색 트리에 대해서 알아봤습니다. 이번엔 좀 복잡한 내용들이 많아서 이해하기위해 긴 시간을 들였습니다. 여러분은 어떠셨나요?

긴 글 읽어주셔서 감사합니다. 다음에도 더 좋은 포스팅으로 찾아뵙도록 하겠습니다.

출처

https://velog.io/@kimdukbae/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%ED%8A%B8%EB%A6%AC-Tree

https://jud00.tistory.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-%ED%8A%B8%EB%A6%ACTree%EB%9E%80

https://zeddios.tistory.com/237

자료구조 : Set

이번 포스팅에서는 자바의 컬렉션 프레임워크 중 하나인 Set에 대해서 알아보는 시간을 가져보도록 하겠습니다.

우선 자료구조 Set은 뭘까요?

음... 깊게 알고싶지 않다면 Set은 중복을 허용하지 않는 자료구조라고만 알고 계셔도 무방할 것 같습니다.

하지만 이번 포스팅에선 좀 더 깊은 내용을 다룰 예정입니다. HashSet을 사용하면 Hash값을 가지고 값을 찾을텐데 어떻게 중복을 방지하는 것인지, TreeSet은 어떤 알고리즘으로 정렬을 지원하는지. 이런 내용에 대해서 다룹니다.

들어가기 전에 말씀드리자면 자료구조 Set에 대한 전반적인 내용을 다루는 포스팅이기 때문에 사용방법에 대해서는 다루지 않습니다. 메서드에 대한 내용은 따로 알아보시는 것을 추천드립니다.

Set

Set은 데이터를 비순차적으로 저장할 수 있는 자료구조입니다. 비순차적이기 때문에 데이터 삽입 순서대로 저장되지 않아서 특정한 순서를 기대할 수 없습니다.

ArrayList와 마찬가지로 값의 수정이 가능하고 중복을 허용하지 않아서 같은 값을 삽입하면 마지막 삽입한 값 하나만 저장됩니다.

Set 과 ArrayList는 닮은 구석이 많습니다. 그도 그럴것이 양쪽 다 AbstractCollection을 상속하고 있기 때문이죠

여기서 잠깐 Set과 List의 차이에 대해서 집고 넘어가도록 하겠습니다.

Set vs List

• List는 중복을 허용합니다. Set은 중복을 허용하지 않습니다.

• List는 삽입에의한 순서를 보장합니다. Set은 보장하지 않습니다.

• Set은 List처럼 인덱스에 기반한 접근을 허용하지 않습니다.

Set은 빠른 데이터 탐색이 필요할 때 주로 사용되는 자료구조입니다.

Set은 인터페이스입니다. 자바는 이러한 인터페이스에서 다형성을 이용해 여러가지 구현체들을 구현해놓았습니다.

대표적인 Set의 구현체로는 HashSet, TreeSet, LinkedHashSet 등이 있습니다. 하나씩 특징을 알아볼까요?

HashSet

HashSet은 자바 컬렉션 API들 중 가장 근본적인 자료구조 중 하나입니다.

HashSet의 특징으로는

• 유니크한 값을 저장할 수 있다.

• 저장시 null을 허용한다.

• 해시맵에 의해 구동됩니다.

• 삽입순서를 유지하지 않습니다.

• thread-safe 하지 않습니다.

이정도로 간추릴 수 있겠습니다. Hash 알고리즘을 사용한 자료구조이기 때문에 해시가 가지고 있는 특징들이 그대로 드러납니다.

때문에 해시의 특징과 Set의 특징이 모두 두드러지게 나타납니다.

그런데 여기서 살짝 의문이 들 수 있습니다.

해시는 저장할 때 유일한 값을 가지는데... 중복은 허용하지 않는다? 이게 말이 되나?

그러니까 HashSet에 값을 넣는데 1이라는 값을 두번 넣게되면 해시의 특징대로면 1이라는 해시(주소)가 부여되고 그에 해당하는 bucket이 할당됩니다. 그리고 1이라는 값이 두번 들어오게 되면 각각은 다른 해시값을 가지고 다른 bucket에 부여됩니다.

그런데 중복을 어떻게 허용하지 않을까요?

HashSet이 어떻게 유니크함을 유지할까?

Set의 공식문서에는 다음과 같이 나와있습니다.

해석해보면 다음과 같습니다.

"HashSet에 객체를 삽입할 때 삽입되는 객체는 그 객체의 해시코드 값을 이미 해당 값이 저장되어 있는지 아닌지 확인하는데에 사용합니다."

"각각의 해시코드 값들은 다양한 요소들이 들어있을 수 있는 특정한 bucket의 장소에 상응됩니다. 그렇기 때문에 계산된 해시값들은 모두 같습니다. 하지만, 두개의 객체의 해시코드는 같지 않습니다."

즉 두 값의 해시코드는 같지 않지만 그 값의 해시코드로 값이 저장되어있는지 아닌지 확인한 후에 저장되어있다면 값을 버리고 마지막 값을 저장한다는 의미인 것 같습니다.

이러한 HashSet의 특징 때문에 equals 메서드로 두개의 객체를 비교할 수 있습니다.

HashSet의 성능

HashSet의 성능은 크게 두가지 요인에 의해 결정됩니다. 바로 초기용량과 Load Factor입니다. 뭐 초기 용량은 그렇다 치는데 Load Factor는 뭘까요?

Load Factor

Load Factor에 대한 내용은 HashSet 클래스를 직접 까보면 알 수 있습니다. HashSet의 클래스를 까보면 HashSet은 HashMap을 이용하는 것을 알 수 있습니다.

왜 HashSet의 특징에서 HashMap에 의해 구동된다고 하는지 알 수 있습니다. 우리가 눈여겨 봐야 하는 것은 바로 이 부분이죠

HashSet의 기본 생성자는 단순히 HashMap을 생성하지만 Capacity와 Load Factor를 파라미터로 받는 생성자인 경우 HashMap에서 추가적인 지원을 받을 수 있습니다.

간단하게 설명하자면

저장된 element의 수 = 저장용량 X Load Factor 입니다.

즉, 저장용량이 고정되어 있을 때 element를 증가시키면 load factor도 커지겠죠. 커지다가 0.75에 도달하게 되면 저장용량을 약 두배로 늘립니다. 이때 저장용량을 늘리는 과정이 애플리케이션에 부하를 많이 주게 됩니다.

그래서 저장될 데이터의 수가 대충 짐작이 가능하다면 처음에 객체를 생성할 때 저장용량을 데이터 수에 맞게 설정해주면 좋습니다.

Load Factor는 0.75값이 가장 이상적인 값이라고 가이드에 나와있습니다.

물론 개발하려는 애플리케이션의 목적에 따라 값을 수정할 수 있지만 대부분의 상황에선 기본값을 쓰는게 좋다고 합니다.

아무튼 Load Factor에 대해 대충 알아봤습니다.

HashSet의 성능은 기본적으로 O(1)이지만 가장 최악의 상황에는 O(n)까지 늘어날 수 있습니다.

TreeSet

TreeSet은 Set 인터페이스에서 정렬을 담당하는 클래스입니다.

TreeSet의 특징

• TreeSet은 유니크한 값을 저장합니다.

• 삽입에의한 순서를 보장하지 않습니다.

• 기본 생성자로 TreeSet을 생성하면 오름차순으로 정렬됩니다. (물론 reverseOrder() 라는 메서드를 이용해 내림차순으로 역정렬할 수 있습니다.)

• tread-safe 하지 않습니다.

TreeSet의 객체들은 수학적으로 보장된 순서대로 오름차순으로 정렬됩니다. 여기서 수학적으로 보장된 순서란 0 1 2 3 4 이런 식의 순서를 의미하고 문자들은 아스키코드로 변환된 수를 오름차순으로 정렬합니다.

TreeSet과 이진 트리

TreeSet은 균형잡힌 이진 탐색 트리 즉, 레드 블랙 트리로 데이터를 저장합니다.

이진 탐색 트리, 레드 블랙 트리에 대해서는 아래의 링크에 자세히 설명되어있습니다.

https://coding-review.tistory.com/286

TreeSet은 자바7이전에는 빈 TreeSet에 null을 저장하는 것이 가능했습니다. 하지만 그렇게 하게 되면 수많은 버그들이 고려되기 때문에 TreeSet은 자바8부터 null을 저장하는 것이 불가능해졌습니다.

TreeSet은 이진 탐색 트리로 값을 정렬하고 저장하지만 null이 들어오게 되면 이진 탐색 트리에 조건에 맞는 저장방식을 사용할 수 없기 때문입니다.

TreeSet의 성능

TreeSet의 성능은 HashSet은 O(1)에서 최대 O(n)이었던 것과 다르게 O(log n)을 따릅니다. 시간 복잡도에서 O(log n)은 성능이 좋은 축에 속합니다. 따라서 값을 오름차순 혹은 내림차순으로 정렬해야 하거나 값의 중복을 피하고 싶다면 TreeSet을 사용하는 것이 좋은 선택이 될 수 있습니다.

참고로 성능상으로는 오름차순이 내림차순보다 성능상 이점이 더 많습니다. 내림차순이 필요한게 아니라면 굳이 내림차순을 사용할 필요는 없겠네요

LinkedHashSet

LinkedHashSet은 HashSet의 자식클래스입니다. 그러므로 HashSet의 특징을 그대로 따르고 있습니다. 중복을 허용하지 않는다거나 Hash를 사용한다거나 하는 특징들 말이죠

하지만 LinkedHashSet의 특징은 단순합니다.

바로 삽입되는 순서를 보장하지 않던 HashSet의 단점을 보완하기위해 나온 클래스입니다.

TreeSet에서 복잡하게 삽입된 순서를 정렬하고 할 것 없이 LinkedHashSet을 사용하면 삽입된 순서를 깔끔하게 보장할 수 있습니다.

하지만 그렇기 때문에 HashSet보다는 성능적으로 좀 뒤쳐지는 것이 사실입니다. 하지만 정말 미미한 수준이고 시간 복잡도는 HashSet과 마찬가지로 O(1)입니다.

사실 LinkedHashSet은 HashSet과 동일하고 순서만 보장해주기 때문에 더 말할 것이 없습니다.

여기까지 Set 자료구조에 대해서 알아봤습니다. 그리고 그 구현체들, 구현체들의 특징에 대해서 알아봤죠.

이번 공부는 구글링 했을 때 결과가 만족스럽지 못해서 공식문서를 직접 다 뒤져보면서 찾아봤기 때문에 더 힘들면서 더 보람차고 뿌듯하네요.

이런식으로 공부하는 것이 더 머릿속에 남긴 하겠지만서도 너무 힘들어서 또 할 수 있을까싶네요.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://www.baeldung.com/java-hashset

=> HashSet 공식문서

https://www.baeldung.com/java-set-vs-list

=> Set vs List 공식문서

https://www.baeldung.com/java-tree-set

=> TreeSet 공식문서

https://www.baeldung.com/java-linkedhashset

=> LinkedHashSet 공식문서

자료구조 : Stack

이번 포스팅에서는 자료구조 중 가장 유명하다고 해도 과언이 아닌 스택에 대해서 알아보도록 하겠습니다.

Stack

스택은 가장 전형적인 LIFO (Last In First Out) 구조로서 개발자들에게 사랑받는 자료구조 중 하나입니다.

스택이 사용되는 것은 가장 대표적으로 홈페이지의 뒤로가기 버튼과 ctrl + z인 undo 기능 계산기의 계산알고리즘 등이 있습니다.

하지만 요즘은 스택을 사용하기 보단 Deque (이하 데크)를 사용하는 것을 더 추천하긴 합니다. Deque는 Double Ended Queue의 약자로 상황에따라 해당 컬렉션을 스택으로 쓸 수도 큐로 쓸 수도 있는 인터페이스입니다.

데크는 LIFO 실행에 있어서 더 완벽하고 일관성있기 때문에 최근에는 스택 대신 사용하게 되었습니다. 하지만 레거시 프로젝트와 같은 곳에서 스택이 사용될 수도 있기 때문에 이번 포스팅에선 스택을 위주로 다뤄보도록 하겠습니다. 데크는 다음 포스팅인 큐에서 좀 더 자세히 다뤄보겠습니다.

출처 : https://roi-data.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-4-%EC%8A%A4%ED%83%9DStack%EC%9D%B4%EB%9E%80-%EC%97%B0%EC%82%B0-%EA%B5%AC%ED%98%84%EB%B0%A9%EB%B2%95
출처 : https://roi-data.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-4-%EC%8A%A4%ED%83%9DStack%EC%9D%B4%EB%9E%80-%EC%97%B0%EC%82%B0-%EA%B5%AC%ED%98%84%EB%B0%A9%EB%B2%95

스택의 구조는 지겹도록 보셨겠지만 워낙 유명하니 저도 한번 첨부하고 넘어가겠습니다.

Stack 생성

스택을 선언할 때는 아무런 인자값 없이 기본 생성자로 생성해주면 됩니다.

Stack<Integer> stack = new Stack<>();

이렇게 생성된 스택은 디폴트 저장용량이 10으로 설정됩니다. 만약 삽입되는 element의 수가 10을 넘어가면 그 순간 용량을 두배로 늘립니다. 그렇게 두배, 두배 늘려가면서 스택의 용량을 뻥튀기 시키는 것이죠

하지만 저장 공간이 두배 늘어나서 20이 되었더라도 값이 제거됐을 때 다시 10으로 돌아가지는 않습니다.

Stack의 동기화

스택은 Vector의 직계자손이기 때문에 Vector의 특성을 그대로 가지고 있습니다. Vector는 동기화가 되어있는 구현체로 유명하죠. 따라서 스택도 Vector와 마찬가지로 동기화가 되어있습니다.

하지만 동기화가 항상 필요한것은 아닐테지요. 만약 필요하다면 위에서 언급했듯이 데크를 사용하면 됩니다.

Stack의 메서드

스택의 대표적인 메서드는 push() 와 pop()이 있죠 push는 말 그대로 배열에 밀어넣는 것이고 pop은 가장 위에있는 값을 하나 꺼내는 것입니다.

이외에도 해당 값을 찾아주는 search(), 해당 값의 인덱스 위치를 알려주는 indexOf(), 제일 위에서 가장 가까이 있는 값의 인덱스를 알려주는 lastIndexOf(), 제일 위의 값을 꺼내지않고 반환만 해주는 peek() 등이 있습니다.

Stack과 반복자

스택은 Iterator를 지원합니다. 따라서 enhanced for문에서 사용할 수도 있습니다. 또한 Iterator에서 지원하는 메서드인 hasNext() 또한 사용할 수 있습니다.

@Test

public void whenAnotherStackCreatedWhileTraversingStack_thenStacksAreEqual() {

Stack<Integer> intStack = new Stack<>();

List<Integer> intList = Arrays.asList(1, 2, 3, 4, 5, 6, 7);

intStack.addAll(intList);

ListIterator<Integer> it = intStack.listIterator();

Stack<Integer> result = new Stack<>();

while(it.hasNext()) {

result.push(it.next());

}

assertThat(result, equalTo(intStack));

}

Stack과 Stream API

스택은 컬렉션입니다. 그리고 이것은 자바 8에서 지원하는 Stream API를 사용할 수 있다는 것인데요. Stream API를 이용해서 다른 컬렉션과 같이 map, flatmap, filter 등을 사용할 수 있습니다.

@Test

public void whenStackIsFiltered_allElementsNotSatisfyingFilterConditionAreDiscarded() {

Stack<Integer> intStack = new Stack<>();

List<Integer> inputIntList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 9, 10);

intStack.addAll(inputIntList);

List<Integer> filtered = intStack

.stream()

.filter(element -> element <= 3)

.collect(Collectors.toList());

assertEquals(3, filtered.size());

}

자 여기까지 스택에 대해서 알아봤습니다. 가장 기본이 되는 자료구조이기도 하고 개념도 쉬워서 슥슥 보면서 이해했네요. 다음 포스팅은 스택의 오랜 친구인 큐에 대해서 알아보도록 하겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 보내세요!

출처

https://www.baeldung.com/java-stack

=> 스택 공식 문서

자료구조 : Queue

이번 포스팅에선 스택의 오랜 친구인 Queue (이하 큐)에 대해서 알아보는 시간을 가져보겠습니다.

우선 이번 포스팅에서는 큐에 대한 개념과 핵심 메서드에 대해서 알아보고, 그 이후에는 자바에서 제공하고 있는 구현체에 대해서 알아보고 마지막으로 큐가 thread-safe한지에 대해서 알아보겠습니다.

Queue

자바에서 큐는 일반적인 도로와 같은 느낌입니다. 제일 먼저 들어온 것이 제일 먼저 나가는 구조이지요. 이것을 FIFO (First In First Out)라고 부릅니다.

https://roi-data.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-4-%EC%8A%A4%ED%83%9DStack%EC%9D%B4%EB%9E%80-%EC%97%B0%EC%82%B0-%EA%B5%AC%ED%98%84%EB%B0%A9%EB%B2%95
https://roi-data.com/entry/%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0-4-%EC%8A%A4%ED%83%9DStack%EC%9D%B4%EB%9E%80-%EC%97%B0%EC%82%B0-%EA%B5%AC%ED%98%84%EB%B0%A9%EB%B2%95

큐의 일반적인 느낌은 위와 같습니다.

핵심 메서드

큐는 인터페이스인데요. 큐를 구현하는 모든 구현체들은 아래의 세가지 핵심 메서드를 포함해야합니다.

• offer() : 큐의 뒤쪽에 새로운 값을 삽입합니다.

• poll() : 큐의 앞쪽에 값을 삭제합니다. 그리고 반환합니다.

• peek() : 큐의 맨 앞에있는 값을 반환하고 삭제하지는 않습니다.

핵심 자식 인터페이스

일반적으로 큐 인터페이스는 3개의 자식 인터페이스를 가지고 있습니다. Blocking Queue, Transfer Queue, Deque

각각 하나씩 살펴보도록 하겠습니다.

Blocking Queues

Blocking Queue 인터페이스는 큐안에서 스레드가 기다립니다. 스레드는 큐안에서 검색을 시도할 때나, 전부 비어있게 될 때나, 새로운 요소를 추가할때까지 기다립니다.

Blocking Queue는 java.util.concurrent에 소속된 인터페이스이기 때문에 멀티스레드 환경에서 사용하기 아주 적합합니다.

Blocking Queue의 구현체로는 LinkedBlockingQueue, SynchronousQueue, ArrayBlockingQueue 가 있습니다.

Transfer Queues

Transfer Queue는 Blocking Queue 인터페이스를 상속한 인터페이스입니다. 기본적으로는 Blocking Queue와 같지만 producer-consumer의 패턴을 추가적으로 가지고 있습니다.

여기서 producer-consumer 패턴이란 작업 목록을 가운데 두고, 작업을 생산해내는 주체와 작업을 처리하는 주체를 분리시키는 설계방법입니다. 작업을 생산하는 부분(producer)과 처리하는 부분(consumer)이 각각 감당할 수 있는 부하를 조절할 수 있다는 장점이 있습니다.

producer는 어떤 consumer가 몇 개나 동작하고 있는지에 대해 전혀 신경 쓰지 않을 수 있습니다. 단지 새로운 작업 내용을 만들어 큐에 쌓아두기만 하면 됩니다. 반대로 consumer역시 producer에 대해서 뭔가 알고 있어야 할 필요가 없습니다.

https://coding-food-court.tistory.com/81
https://coding-food-court.tistory.com/81

이러한 관계는 느슨한 결합도를 가지기 때문에 좀 더 객체지향스러운 것 같습니다.

Deques

위의 두개와 비교했을 때 가장 중요한 인터페이스입니다. Deque란 Double-Ended-Queue의 약자로 카드의 덱으로 비유됩니다. 데크에서는 앞쪽과 뒤쪽 모두 값이 들어갔다 나올 수 있습니다.

즉, 하나의 공간에서 상황에따라 스택처럼 쓸 수도, 큐처럼 쓸 수도 있다는 것입니다. 하지만 성질은 큐쪽에 좀 더 가깝습니다.

자바에서 데크는 인터페이스로 구현되어 있습니다. 데크 자료구조의 여러 연산들을 정의한 데크 인터페이스가 있고 이를 구현한 ArrayDeque, LinkedBlockingDeque, ConcurrentLinkedDeque, LinkedList 등이 있습니다.

Deque에서의 데이터 삽입

• addFirst() : 데크의 앞쪽 element를 삽입합니다. 용량 제한이 있는 데크인 경우 용량을 초과하면 예외가 발생합니다.

• offerFirst() : 데크 앞쪽에 element를 삽입합니다. 정상적으로 element가 삽입되면 true가 리턴되며 용량 제한에 걸리는 경우 false를 리턴합니다.

• addLast() : 데크의 마지막 쪽에 element를 삽입합니다.용량 제한이 있는 경우 용량 초과시 예외가 발생합니다.

• add() : addLast()와 동일

• offerLast() : 데크의 마지막 쪽에 element를 삽입합니다. 정상적으로 삽입된 경우 true가 리턴괴며, 용량 제한에 걸리는 경우 false를 리턴합니다.

Deque의 데이터 삭제

• removeFirst() : 데크의 앞쪽에서 element를 하나 뽑아서 제거한 다음 해당 element를 리턴한다. 데크가 비어있으면 예외를 발생한다.

• pollFirst() : 데크의 앞쪽에서 element를 하나 뽑아서 해당 element를 리턴한다. 데크가 비어있으면 null이 리턴된다.

• removeLast() : 데크의 마지막 쪽에서 element를 하나 뽑아서 제거한 다음 해당 element를 리턴한다. 데크가 비어있으면 예외가 발생한다.

• pollLast() : 데크의 마지막 쪽에서 element를 하나 뽑아서 제거한 다음 해당 element를 리턴한다. 데크가 비어있으면 null을 반환한다.

• remove() : removeFirst()와 동일

• poll() : pollFirst()와 동일

Deque의 데이터 탐색

• getFirst() : 데크의 앞쪽 element를 하나 제거하지 않은 채 리턴한다. 데크가 비어있으면 예외가 발생한다.

• peekFirst() : 데크의 앞쪽 element를 하나 제거하지 않은 채 리턴한다. 데크가 비어있으면 null이 리턴된다.

• getLast() : 데크의 마지막쪽 element를 하나 제거하지 않은 채 리턴한다. 데크가 비어있으면 예외가 발생한다.

• peelLast() : 데크의 마지막 element를 하나 제거하지 않은 채 리턴한다. 데크가 비어있으면 null이 리턴된다.

• peek() : peekFirst()와 동일

Deque의 데이터 가공

• removeFirstOccurrence(Object o) : 데크의 앞쪽에서부터 탐색하여 입력한 Object o와 동일한 첫 element를 제거한다. Object o와 같은 element가 없으면 데크에 변경이 발생하지 않는다.

• removeLastOccurrence(Object o) : 데크의 뒤쪽에서부터 탐색하여 입력한 Object o와 동일한 첫 element를 제거한다. Object o와 같은 element가 없으면 데크에 변경이 발생하지 않는다.

• addAll(Collection <? extends E>) : 입력 받은 Collection의 모든 데이터를 덱의 뒤쪽에 삽입한다.

• push() : addFirst()와 동일, 데크를 스택으로 사용할 때 쓰임

• pop() : removeFirst()와 동일, 데크를 스택으로 사용할 때 쓰임

큐에서의 정렬

우리는 앞서 자바에서 FIFO원리를 따르는 큐에 대해서 알아봤는데 이 클래스는 예외가 있습니다. 바로 Priority Queues인데요. Priority Queue로 선언된 큐에서는 삽입되는 element들이 자연적인 순서에 맞게 정렬됩니다.

PriorityQueue<Integer> integerQueue = new PriorityQueue<>();

integerQueue.add(9);

integerQueue.add(2);

integerQueue.add(4);

int first = integerQueue.poll();

int second = integerQueue.poll();

int third = integerQueue.poll();

assertEquals(2, first);

assertEquals(4, second);

assertEquals(9, third);

Thread-safe 여부

큐는 특히 멀티스레드 환경에서 사용하기 적합합니다. 큐는 스레드들 사이에서 공유되기 때문에 큐 안에서의 공간이 사용가능할 때까지 lock으로 잠궈집니다. 따라서 흔히 일어나는 멀티스레드 환경에서의 문제를 완벽히 해결할 수 있습니다.

큐에 값을 삽입할 때에는 오직 하나의 스레드만이 큐에 접근할 수 있습니다. 이런식으로 하나의 스레드만 큐에 값을 적을 수 있는 상황은 멀티스레드에서의 문제를 해결해주고 쓰기속도도 엄청난 향상을 불러일으키죠.

자 여기까지 큐에 대해서 알아봤습니다. 우리는 먼저 큐가 뭔지 알아봤고, 자바에서 제공하는 구현체에 대해서 알아봤었죠. 그리고 보통의 FIFO방식과 다른 형태의 큐인 Priority Queue에 대해서 알아봤습니다. 마지막으로 thread-safe 여부까지 확인했죠.

큐는 생각보다 많이 사용되는 인터페이스인가 싶었습니다. 스택에 비해 구현체의 양도 다양하고 멀티스레드 환경에서 고려해야하는 점들도 다 대비가 되어있었으니까요.

그래도 크게 어렵지 않게 이해하고 정리할 수 있었던 것 같습니다.

오늘도 긴 글 읽어주셔서 감사합니다. 즐거운 하루 되세요~

출처

https://www.baeldung.com/java-queue

=> Queue 공식 문서

https://soft.plusblog.co.kr/24

https://coding-food-court.tistory.com/81

프로세스와 스레드

프로세스와 스레드의 차이에 대한 면접질문은 신입 개발자에게 단골 질문이라고 알려져 있습니다. 이 둘은 다른 듯 비슷해 보여서 헷갈리기 쉬운데 이번 포스팅에선 이 둘의 차이와 더 나아가 멀티프로세싱, 멀티스레딩과 더불어 이 둘을 언제 사용하면 좋을지까지 포스팅합니다.

Process

프로세스는 실행된 프로그램의 인스턴스입니다. 우리가 프로그램을 실행할 때 이 프로그램이 직접적으로 실행되는 것이 아니라 프로그램을 실행하기 위해 필요한 스텝들을 거친 뒤 실행이 됩니다. 이렇게 실행을 위해 필요한 스텝들이 바로 프로세스 즉 과정입니다.

프로세스는 한번에 여러개의 일을 수행하기위해 다른 프로세스를 만들 수 있습니다. 이렇게 만들어진 프로세스들은 클론 프로세스 혹은 자식 프로세스라고 부르죠. 메인 프로세스는 부모 프로세스라고 부릅니다.

각각의 프로세스들은 고유한 메모리 공간을 부여받고 다른 프로세스와 이를 일체 공유하지 않습니다.

프로세스의 특징

• 매번 우리는 프로세스를 만들고 OS에서 각각의 프로세스를 요구하는 분리된 시스템을 만들 필요가 있습니다. 이 때 fork() 작업이 프로세스를 만듭니다.

• 각각의 프로세스는 고유한 주소와 메모리 공간안에서 존재합니다.

• 각각의 프로세스는 독립적이고 OS에 의해 분리된 과정으로서 여겨집니다.

• 프로세스들은 서로 통신하기 위해 IPC (Inter-Process Communication)가 필요합니다.

• 프로세스들 사이에서 동기화가 필요없습니다.

Thread

스레드는 프로세스 아래 위치하고 알려지기로는 '경량화된 프로세스' 라고 알려져있습니다. 프로세스들은 하나 이상의 스레드를 가지고 있고 각각의 스레드들은 스케쥴러에 의해 독립적으로 관리됩니다.

하나의 프로세스 안에 있는 모든 스레드들은 서로 상호연관되어 움직입니다. 또한, 스레드는 data segment, code segment, files 와 같은 흔한 정보들을 가지고 있는데 각각의 스레드들이 이를 모두 공유합니다. 하지만 register와 stack memory, counter들은 독립적으로 가지고 있습니다.

여기서 register와 stack memory, counter에 대해서 잠깐 짚고 넘어가도록 하겠습니다.

Register는 빠르게 가져오거나 빠르게 저장하거나 빠르게 데이터를 이송하는데에 필요한 컴퓨터 메모리의 한 종류 혹은 CPU에 의해 즉각적으로 사용되는 구조를 말합니다.

Stack Memory는 JVM을 공부해보신 분이라면 알고 계시겠지만 메모리 공간인데 Stack memory는 동작에 의해서 생기는 일시적인 변수들을 저장하는 컴퓨터 메모리의 특별한 공간입니다. 컴퓨터의 테스트(task)가 끝날 때 변수를 담고 있는 메모리들은 자동적으로 지워진다는 특징을 가지고 있습니다. 스택에는 대게 메서드, 지역변수, 참조변수들이 저장됩니다.

Heap Memory 는 프로그래밍 언어에 의해서 생성되는 메모리이고 주로 전역적인 변수들이 저장됩니다. Heap memory는 스택과 다르게 자동적으로 관리되지 않고 CPU에 의해 타이트하게 관리되지 않는 좀 더 프리하게 떠다니는 공간입니다. 주로 Heap memory에는 동적으로 할당된 변수나 배열등이 저장됩니다.

(Program) Counter는현재 실행되는 일련의 코드들 (Instruction)의 주소를 가지고 있는 컴퓨터 프로세서 안에서의 Register를 의미합니다.

스레드의 특징

• 프로세스들은 다른 프로세스와 독립적으로 움직이지만 스레드들은 논리적으로 모두 연결되어있습니다.

• 프로세스는 많이 무거운 반면에 스레드들은 경량화되어있습니다.

• 프로세스는 고유한 메모리와 주소를 가지고 있지만 스레드는 개인적으로 존재하지 않습니다.

• 프로세스는 동기화가 필요하지 않았지만 스레드는 공유하고 있는 자원이 있기 때문에 충돌이 날 수 있어 적절한 동기화가 필요합니다.

Multi-Processing VS Multi-Threading

보통 실무에서 사용한다면 Multi-Processing (이하 멀티프로세싱) 혹은 Multi-Threading (이하 멀티스레딩) 을 사용하겠죠? 이번 섹션에서는 이 둘의 차이에 대해서 설명해보도록 하겠습니다.

멀티스레딩

멀티스레딩은 싱글 프로세스에서 여러개의 코드를 각각 할당하는 프로그래밍 기술입니다. 스레드들은 서로 동시에, 병렬적으로 실행되는데 이러한 스레드들은 부모 프로세스 안에서 같은 메모리공간을 공유합니다. 이렇게 하면 시스템 메모리를 줄일 수 있고 속도와 성능을 끌어올릴 수 있습니다.

예를들어서 우리가 컴퓨터를 할 때 여러개의 브라우저를 띄워놓고 한쪽에선 이메일을 작성하고 한쪽에선 음악을 듣는 경우 두개의 브라우저가 끊기지 않고 동시에 실행되는 상황을 멀티스레딩이 사용되었다고 말합니다.

멀티프로세싱

멀티프로세싱은 2개 이상의 CPU가 설치된 시스템을 말합니다. 모든 추가적인 CPU는 시스템의 속도와 파워를 올려주고 더 많은 메모리를 사용할 수 있게 해줍니다. 이는 동시에 여러개의 프로세스를 실행하도록 해주죠.

각각의 CPU들은 독립적으로 실행되고 몇개의 CPU가 시스템 오류로 죽어버려도 그 옆의 CPU가 제대로 작동한다면 애플리케이션이 돌아가는데에는 문제가 없습니다.

멀티 프로세싱에는 두가지 방식이 있는데 동기화 멀티프로세싱과 비동기화 멀티프로세싱입니다.

• 동기화 멀티프로세싱 (Symmetric Multiprocessing) : 동기화 멀티프로세싱은 각각의 프로세스가 하나의 메모리공간으로 연결된 두개이상의 프로세서로 통합된 하드웨어와 소프트웨어를 사용합니다. 이러한 프로세서들은 모두 Input/Output (이하 I/O) 을 담당하여 사용할 수있습니다.

• 비동기화 멀티프로세싱 (Asymmetric Multiprocessing) : 비동기화 멀티프로세싱은 서로 다른 CPU가 분리된 I/O 작업을 수행할 수 있습니다. 예를 들어서 어떤 CPU는 I/O 실행을 담당하고 어떤 CPU는 OS를 담당할 수 있다는 얘기입니다.

멀티스레딩과 멀티프로세싱의 차이

• 멀티프로세싱은 두개이상의 CPU를 가지고 성능을 끌어올리는 것인 반면에 멀티스레딩은 하나의 프로세스에서 여러개의 코드를 부여받음으로서 성능을 끌어올립니다.

• 멀티스레딩은 하나의 프로세스에서 스레드를 늘리는데 초점을 맞췄다면 멀티프로세싱은 CPU를 늘리는 것에 초점을 맞췄습니다.

• 멀티프로세싱은 좀 더 신뢰할만한 시스템을 만드는데 사용되고 멀티스레딩은 서로 병렬적인 작업을 수행하는데 사용됩니다.

• 멀티스레딩은 적은 리소스를 요구하고 빠르게 생성되는 반면에 멀티프로세싱은 상당한 양의 시간과 특별한 리소스가 필요합니다.

• 멀티프로세싱은 많은 프로세스들이 동시에 실행되는 반면에 멀티스레딩은 프로세스는 하나지만 여러개의 스레드가 동시에 실행됩니다.

• 멀티스레딩은 모든 스레드가 공통의 메모리와 주소를 가지고 있지만 멀티프로세싱은 각각 고유한 메모리와 주소를 가지고 있습니다.

멀티스레딩의 이점

• 적은 메모리 스토리지가 요구됩니다.

• 모든 스레드가 부모 프로세스를 공유하기 때문에 메모리에 접근하는 것이 더 쉽습니다.

• 스레드사이에 스위칭이 빠르고 효율적입니다.

• 새로운 프로세스를 만드는 것보다 새로운 스레드를 만드는 것이 훨씬 빠르고 쉽습니다.

• 모든 스레드들이 하나의 프로세스 메모리 풀을 공유하고 같은 주소를 가지고 있습니다.

• 스레드들은 더 경량화 되어있고 때문에 낮은 오버헤드를 가집니다.

• 스레드들 사이에서 커뮤니케이션하는 비용이 비교적 저렴합니다.

• 반응형 UI를 만드는데 쉽습니다.

멀티스레딩의 단점

• 멀티스레딩은 interrupt 될 수 없습니다. (cf. interrupt는 인터럽트는 컴퓨터를 장착한 디바이스로부터 혹은 컴퓨터안에 프로그램에 의해 방출된 신호입니다.)

• 코드들이 이해하는데 굉장히 힘듭니다.

• 간단한 일을 할 때는 오히려 오버헤드가 발생할 수 있습니다.

• 코드가 복잡해서 디버깅이나 트러블슈팅을 할 때 어려움을 겪을 수 있습니다.

멀티프로세싱의 이점

• 코드를 이해하기 굉장히 쉽습니다.

• 자식 프로세스들이 interrupt 할 수 있습니다.

• 테스트(task)를 빠르게 처리하거나 많은 양의 데이터를 분석하는 것이 좀 더 용이합니다.

• CPU를 새로 다는 것이기 때문에 시스템의 전반적인 성능을 끌어올리는데 쉽습니다.

• 동기화를 할 필요가 없습니다.

• 싱글 프로세서 시스템보다 더 효율적입니다.

멀티프로세싱의 단점

• 멀티프로세싱은 많은 메모리 스토리지를 요구하고 프로세스 사이의 데이터를 이동시키는데 스레드에 비해 오버헤드가 발생합니다. 조금 어려운 표현으로 Context Switching에 의한 오버헤드가 발생한다 라고 할 수 있습니다.

• 스레드를 만드는 것 보다 프로세스를 만드는 것이 더 오래걸리고 비쌉니다.

Context Switching이란 동작 중인 프로세스가 대기하면서 해당 프로세스의 상태를 보관하고, 대기하고 있던 다음 순서의 프로세스가 동작하면서 이전에 보관했던 프로세스의 상태를 복구하는 작업을 말합니다.

이때 발생하는 오버헤드가 꽤나 크기 때문에 보통의 경우 멀티프로세싱으로 할 수 있는 작업을 멀티스레딩으로 하곤 합니다. 좀 더 자세히 말씀드리면 멀티프로세스로 실행되는 작업을 멀티스레드로 실행할 경우, 프로세스를 생성하여 자원을 할당하는 시스템 콜이 줄어들어 자원을 효율적으로 관리할 수 있습니다.

뿐만 아니라 프로세스 간의 통신보다 스레드 간의 통신의 비용이 적으므로 작업들 간의 통신의 부담이 줄어들게 됩니다.

하지만 멀티프로세싱으로 할 수 있는 작업을 멀티스레딩으로 작업했을 때의 얘기이지 멀티프로세싱이 필요한 작업도 분명히 존재합니다. Context Switching은 장애가 났을 때 기존에 진행되던 프로세스의 정보를 기억하고 있다가 장애가 해결되고 나서 다시 프로세스를 원상복구 시킬 때 굉장히 유용하게 사용됩니다.

혹은 높은 우선순위의 프로세스가 생겨서 ready queue로 떨어진다면 현재 실행중인 프로세스가 정지되게 되고 위에서와 마찬가지로 다시 원상복구 시킬 때 사용됩니다.

멀티스레딩, 멀티프로세싱을 선택하는 기준

만약 반응형 UI를 만든다면 멀티스레딩을 사용하는 것이 좋습니다. 이런 경우 유저 입장에서 이벤트가 발생하는 와중 시스템이 정지해버리면 안되기 떄문에 이때 멀티스레딩을 사용하는 것이 올바른 선택입니다.

I/O bound 나 네트워크 bound 애플리케이션을 만들 때도 멀티스레딩을 활용하면 좋습니다. 쉽게 말해서 네트워크가 왔다갔다 하는 도중에 시스템이 정지해버리면 안되기 때문에 이때 멀티스레딩을 사용하는 것이 올바른 선택입니다. 예를 들어서 파일을 다운로드 하고 있는데 시스템이 멈춰버리면 안되겠죠?

빅데이터를 분석하는데에는 멀티프로세싱을 사용하면 좋습니다. 멀티프로세싱이 멀티스레드에 비해 빅데이터를 분석하는데 탁월하기 때문에 이떄는 멀티프로세싱을 사용하는 것이 올바른 선택입니다.

또한 멀티프로세싱은 CPU를 강력하게 사용해야 하는 프로그램을 개발할 때 사용하면 좋습니다. 대부분의 대규모 애플리케이션이 그렇겠지만 보통 이렇게 많은 CPU를 요구하는 애플리케이션에선 멀티프로세싱이 굉장히 유용합니다.

여기까지 프로세스와 스레드에 대해서 알아봤습니다. 운영체제에 대해서 처음 공부하는건데 좋은 공부가 됐습니다. 하지만 네트워크나 데이터베이스나 Java, Spring에 비해 굉장히 추상적이고 이해하기가 까다로웠습니다.

아마 데이터베이스나 네트워크같은 CS 지식들은 아무래도 눈에 보이는 경우가 대부분이고 우리가 보통 사용하는 기능들에 대해서 설명하기 떄문에 좀 더 직관적인 것 같습니다.

프로세스, 스레드에 대한 포스팅은 여기서 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 보내세요!

참조

https://www.javatpoint.com/process-vs-thread

=> 프로세스에 대한 개념과 특징

https://www.javatpoint.com/computer-registers

=> Register에 대한 개념

https://www.guru99.com/stack-vs-heap.html

=> Stack, Heap 메모리에 대한 개념

https://www.techtarget.com/whatis/definition/program-counter

=> Counter에 대한 개념

https://www.indeed.com/career-advice/career-development/multithreading-vs-multiprocessing

=> 멀티스레딩과 멀티프로세싱에 대한 개념과 차이 그리고 선택기준

https://brunch.co.kr/@kd4/3

=> 멀티프로세싱을 멀티스레딩으로 바꿔야하는 이유

https://www.javatpoint.com/what-is-the-context-switching-in-the-operating-system

=> Context Switching에 대한 개념과 필요한 이유

동시성문제와 Thread-safe

동시성 문제와 Thread-safe를 고민하는 것은 개발자로서 정말 중요한 과제라고 생각됩니다. 서비스가 커지면 커질수록 멀티스레딩이 절실히 필요해질텐데 성능을 위해 멀티스레딩을 강요받았지만 동시성문제로 버그가 생겨버리면 참 골치아플 것 같습니다.

이번 시간에는 동시성문제와 Thread-safe 그리고 자바에선 어떻게하면 동시성문제를 해결할 수 있는지까지 알아봅니다.

동시성문제

동시성문제가 발생하는 코드는 한눈에 봐서는 알아차리기 쉽지 않습니다. 차라리 동시성문제가 발생하는 패턴을 외워두고 있다가 해당 패턴이 나오면 해결하는 것이 더 타당해보일 정도로 정말 알기 쉽지않습니다.

저는 프로젝트를 할 때 동시성문제에 대해 진지하게 고민하고 코드를 짜본 적은 없지만 어떤 상황이 동시성 문제가 발생하는지는 대강 알고있습니다.

해당 챕터에서는 어떤 상황이 동시성문제가 발생할 수 있는지 타임라인을 따라가보며 자세히 알아보도록 하겠습니다.

public class BasicObservableClass {

public interface Observer {

void onObservableChanged();

}

private Set<Observer> mObservers;

public void registerObserver(Observer observer) {

if (observer == null) {

return;

}

if (mObservers == null) {

mObservers = new HashSet<>(1);

}

mObservers.add(observer);

}

public void unregisterObserver(Observer observer) {

if (mObservers != null && observer != null) {

mObservers.remove(observer);

}

}

private void notifyObservers() {

if (mObservers == null) {

return;

}

for (Observer observer : mObservers) {

observer.onObservableChanged();

}

}

}

코드가 적당히 길긴 한데 우리가 자세히 봐야할 것은 멤버변수로 Set을 선언했다는 것과 registerObserver메서드를 통해 HashSet구현체로 동적할당했다는 것을 주의깊게 보셔야 합니다.

unregisterObserver와 notifyObservers 코드는 우리가 알아볼 동시성문제와 직접적인 연관은 없으니 위에서 언급한 두개만 주의깊게 보시면 됩니다.

위와 같은 코드를 만든 상황에서 말그대로 동시에 A, B 두개의 스레드가 registerObserver에 접근했다고 가정해봅시다. 그럼 어떤 일이 일어날까요?

• 스레드 A가 registerObserver에 접근합니다.

• 스레드 A가 파라미터로 넘어온 observer가 null인지 확인하고 우리가 선언한 mObservers도 null인 경우 HashSet으로 동적할당했습니다.

• new 연산자가 실행되고 있던 도중 스레드 B가 접근하면 OS는 스레드 A의 작업을 잠시 중지하고 스레드 B를 실행시킵니다.

• 스레드 B가 1번, 2번의 과정을 거쳐서 mObservers를 HashSet으로 만듭니다.

• 스레드 A가 mObservers를 아직 만들지 않은 상태에서 스레드 B가 새로운 Set을 만드는데 성공하고 mObservers에 레퍼런스들을 저장합니다.

• 스레드 B에 Set이 새롭게 만들어집니다.

• OS는 스레드 A의 작업을 다시 실행합니다.

• 스레드 A가 새로운 Set을 만들고 mObservers에 레퍼런스들을 오버라이드합니다.

• 스레드 A가 다시 Set을 새롭게 만들었습니다.

이렇게 타임라인으로 알아봤는데요. 이런 상황이면 어떤 문제가 발생할까요?

바로 스레드 B가 만든 Set은 스레드 A가 만든 Set에 덮어씌워집니다. 이런 상황을 OS용어로는 'race condition에 돌입했다' 라고 말합니다. race condition에 대해서 잠깐 설명하고 넘어가자면

race condition

race condition은 서로 다른 두 사람이 모든 형광등이 연결된 스위치를 켰다 껐다 하는 것으로 설명할 수 있을 것 같습니다.

모든 형광등이 스위치에 연결되면 스위치를 키면 모든 형광등이 켜지고 스위치를 내리면 모든 형광등이 꺼지겠죠?

위의 예제 코드는 한명은 스위치를 내리려고하고 한명은 스위치를 올릴려고 하는 상황입니다. 그럼 꺼지던 켜지던 한가지 상황만 발생하겠죠. 그럼 그 상황을 원치 않았던 한명이 생기는 것입니다.

두 사람이 순서대로 읽고 저장하는 작업을 실행한 과정을 위의 예시에서 확인할 수 있습니다. 프로세스 1이 읽고 쓰고 얼마 있지않아 프로세스 2가 읽고 쓴 상황입니다. 정상적으로 잘 작동 하네요.

이 상황은 어떤가요? 프로세스 1과 2가 서로 겹쳐서 작업이 꼬여버린 상황입니다. 이런 상황을 race condition이라고 합니다.

동시성 문제를 해결하는 방법

먼저 동시성 문제가 어떤 상황에서 발생하는지 패턴을 몇개 알아두시면 좋습니다. 제가 알고있는 패턴을 소개해드리겠습니다.

• 멤버변수 (전역변수) 를 선언한 경우

• Thread-safe 하지 않은 클래스를 사용한 경우

• ++, -- 와 같은 증감연산자를 사용한 경우

• 동시에 여러개의 스레드가 하나의 메서드에 접근하는 경우

대충 이정도로 정리할 수 있습니다. 그럼 이제 자바에서 어떻게 동시성문제를 해결하는지 알아봅시다.

1. 불변객체를 사용한다.

자바에서 불변객체를 사용하면 동시성 문제를 해결할 수 있습니다. 불변 객체는 한번 만들어지면 이후에 절대 변경되지 않는 특성을 가지고 있는데요. 예를 들어서 불변 객체인 String은 동시성 문제가 발생하지 않습니다.

2. final 변수를 사용한다.

final 변수는 자바에서 Thread-safe로 굉장히 유명합니다. final 변수에 대해서 잘 모르시는 분들은 아래의 링크를 확인해주세요.

https://coding-review.tistory.com/156

3. Read-only를 사용한다.

단순히 읽는 작업만 필요한 경우에 Read-only를 사용하는 것도 좋은 방법입니다.

4. synchronized 키워드를 사용한다.

synchronized 키워드는 접근할 때 오직 하나의 스레드만 접근할 수 있도록하는 키워드입니다. synchronized 블록을 사용하면 Lock을 획득한 스레드만 접근이 가능해서 해당 스레드가 나가기 전까지 스레드가 뒤에서 대기할 수 밖에 없습니다.

5. static 변수를 사용하지 않는다.

static변수는 동기화가 되기 힘든 변수입니다. 만약 여러 스레드가 static 변수에 접근한다면 엄청난 버그가 생길 수 있으니 정말 변하지 않는 값만 static 변수로 설정하는 것이 좋습니다.

6. java.util.concurrent 클래스를 사용한다.

concurrent 클래스는 Thread-safe를 보장하는 클래스입니다. concurrent 클래스는 내부적으로 Segment라고 하는 개념으로 Reentrant Lock을 걸어 Thread-safe를 보장합니다. 쉽게 설명하면 각각의 스레드에 Segment를 부여하고 Segment별로 병렬적으로 변수를 관리하기 때문에 동시성 문제가 발생하지 않는다고 보시면 됩니다.

Concurrent 클래스에 대해서 자세히 알고 싶으신 분들은 아래의 링크를 확인해주세요!

https://coding-review.tistory.com/278

7. 멤버변수(지역변수)를 사용하지 않는다.

멤버변수를 굳이 사용하지 않아도 되는 경우에는 사용하지 않는 것 또한 동시성 문제를 해결하는 방법 중 하나입니다. 그럼 지역변수를 Thread-safe할까요?

지역변수는 각각의 스레드가 Stack 저장소에 지역변수를 저장하고 이 Stack 메모리는 스레드별로 가지고 있는 저장소이기 때문에 다른 스레드가 접근할 수 없습니다.

8. 멤버변수를 사용해야 한다면 ThreadLocal을 사용한다.

ThreadLocal은 내부적으로 Map을 사용함으로써 변수마다 값을 고유하게 유지시켜주는 역할을 합니다. ThreadLocal에 대한 자세한 내용은 아래의 링크에서 확인해주세요!

https://coding-review.tistory.com/92

9. Volatile 키워드는 제한적으로 동시성문제를 해결해줍니다.

모든 상황에서 Volatile 키워드가 Thread-safe 하지는 않습니다. 하지만 아주 제한적으로는 동시성 문제를 해결해주곤 합니다. 예를 들어서 두개의 스레드중 하나는 읽기 작업만 진행하고 하나는 쓰기 작업만 진행하는 경우 동시성 문제를 해결해줍니다.

마치며

여기까지 동시성문제와 Thread-safe 를 알아봤고 해결방법까지 알아봤습니다. 동시성 문제는 개발자라면 항상 생각해야 할 문제이기 때문에 한번 알아두면 두고두고 쓸 일이 생기니 기억해두시면 좋습니다.

저도 언젠간 동시성 문제를 진지하게 고민해볼 날이 왔으면 좋겠네요. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://gowthamy.medium.com/concurrent-programming-fundamentals-thread-safety-6b44c026bd2a

=> Thread-safe에 대한 전반적인 이해와 해결방안

https://www.techtarget.com/searchstorage/definition/race-condition

=> race condition에 대한 전반적인 개념

https://www.baeldung.com/java-volatile-variables-thread-safety

=> volatile 키워드의 Thread-safe 여부에 대한 docs

프로세스 동기화

이번 시간에는 프로세스 동기화에 대해서 알아보도록 하겠습니다.

프로세스 동기화란 OS에서 같은 메모리 공간을 공유하고 있는 프로세스들을 관리하기위한 방법입니다. 프로세스 동기화를 진행하게 되면 변수를 사용함으로써 데이터의 일관성을 유지하는데 도움이 됩니다. 프로세스 동기화를 하기위해 여러가지 방법이 있는데 이번 시간에 semaphore (이하 세마포어), mutex lock (이하 뮤텍스 락), hardware synchronization (이하 하드웨어 동기화)를 알아봅니다.

프로세스 동기화란?

OS는 컴퓨터에서 모든 애플리케이션을 관리하는 소프트웨어이고 기본적으로 우리 컴퓨터에서 부드럽게 동작하는 것을 도와줍니다.

이러한 이유때문에 OS는 많은 일을 수행할 수 밖에 없습니다. 때로는 동시에 일을 수행해야 하기도 하죠. 동시에 일을 수행할 때 공유 자원에 접근하는 것이 아니면 보통 문제가 일어나지 않지만 공유 자원에 접근해야 하는 경우가 많기 때문에 문제가 발생할 수 밖에 없습니다.

예를 들어서 온라인 뱅킹에서 같은 데이터베이스에 각각의 고객들에대한 계좌 잔액이 저장되어있다고 가정해봅시다. 우리는 현재 x원의 잔액을 가지고 있습니다.

이때 우리는 우리 계좌에서 돈을 조금 빼고싶습니다. 그리고 마침 같은 시간에 어떤 사람이 내 계좌의 잔액을 보고싶어합니다.

우선 우리는 계좌에서 돈을 조금 뺐기 때문에 x원보다 낮은 금액을 잔액으로 가지고 있을겁니다. 하지만 동시간에 내 계좌 잔액을 보고싶어한 사람은 어떨까요? 아마 잔액이 x원으로 찍혀있을것입니다.

이렇듯 데이터 부정합은 하나의 시스템에서 여러 프로세스가 공유자원에 접근했을 때 생깁니다. 그리고 이것이 우리가 프로세스 동기화를 알아야 하는 이유이기도 하죠.

프로세스 동기화에 필요한 Section

우리는 앞에서 프로세스 동기화에 대한 전반적인 내용과 어떤 상황에서 프로세스 동기화가 필요한지에 대해서 알아봤습니다. 프로세스 동기화를 하기 위해서는 다양한 Section에 접근해야 하는데 가장 기본적인 네개의 Section에 대해서 알아보죠

• Entry Section : Entry Section은 프로세스의 순서를 보여줍니다.

• Critical Section : Critical Section은 공유자원에 오직 하나의 프로세스만 접근할 수 있도록 하는 구역입니다. 우리말로 임계 구역이라고도 해석되는데, 하나의 프로세스가 Critical Section에 있을 때 다른 프로세서가 못들어오게 막아야 합니다.

• Exit Section : 하나의 프로세스 실행 후에 공유자원에서 다른 프로세스의 순서를 Exit Section이 관리합니다.

• Remainder Section : 위에 세개의 Section을 제외한 나머지 코드를 Remainder Section이라고 합니다.

우리가 이중에서 가장 눈여겨 봐야할 Section은 바로 Critical Section입니다.

Critical Section은 race condition을 막기위해 사용되는 방법으로도 많이 알려져 있습니다. race condition에 대한 자세한 내용은 아래의 링크를 확인해주세요. 그런데 이 Critical Section이 없으면 어떻게 될까요? 어떤 문제가 생길까요?

https://coding-review.tistory.com/351

어떤 상황에서 싱글 프로세스에 의해 접근될 수 있는 일련의 코드들을 우리는 Critical Section이라고 부릅니다. 이 의미는 많은 수의 프로세스가 공유 자원을 변경하기위해 접근할 때 오직 하나의 프로세스만 공유된 자원에 접근할 수 있도록 한다는 의미이죠.

wail() 함수가 주로 Critical Section에서 순서를 관리하는데 사용되고 signal() 함수는 Critical Section으로부터 빠져나오는 것을 관리합니다.

만약 Critical Section을 제거한다면 우리는 동시에 실행되는 프로세스가 끝났을 때 데이터의 정합성을 보장받지 못할 것입니다.

이러한 문제를 Critical Section Problem 이라고 합니다.

동기화에 필요한 요구사항

아래 소개하는 세개의 요구사항이 Critical Section Problem을 해결할 수 있는 방법입니다.

• Mutual Exclusion (상호 배제) : 만약 하나의 프로세스가 Critical Section에서 실행중이라면 다른 프로세스는 동시에 Seciton에 접근할 수 없습니다.

• Progress (진행) : 만약 Critical Section에 프로세스가 존재하지 않으면 프로세스는 Critical Section에 들어가기 위해 기다려야 하고 기다린 뒤엔 하나의 스레드만 Critical Section에 접근할 수 있습니다.

• No Starvation (기아상태 방지) / Bounded Waiting : 기아상태는 프로세스가 Critical Section에 접근하기위해 평생 기다리는 것을 이야기합니다. 그리고 영원히 기회가 주어지지 않습니다. 이러한 기아상태를 방지를 보통 Bounded Waiting이라고 부릅니다.

프로세스가 Critical Section에 들어가기위해 영원히 기다려서는 안된다.

• 프로세스가 Critical Section으로의 접근이 허용되었을 때 반드시 제한되거나 경계가 있어야한다.

• 이 경계에 도달했을 때, 프로세스는 Critical Section에 접근하는 것이 허용될 수 있어야한다.

Mutual Exclusion 구현 방법

이 구현 방법에 대해서는 자세히 알 필요까지는 없습니다. 그냥 이런게 있구나 싶을정도만 알고 계시면 됩니다.

상호 배제를 구현하는 방법에는 여러가지가 있지만 가장 대표적으로 알려진 것 두개만 소개해드리도록 하겠습니다. 바로 Dekker 알고리즘과 Peterson 알고리즘입니다.

이 둘은 flag와 turn으로 프로세스의 접근을 막는 것이 비슷해보이지만 약간 다른 차이점을 가지고 있는데요. 우선 flag와 turn에 대해서 알아보죠

• boolean flag[] : boolean 배열인 flag 변수는 우선 모두 false로 초기화합니다. 이 flag 배열들은 Critical Section으로 들어갈 수 있는지를 체크하는 배열입니다.

• int turn : Integer 형 turn 변수는 Critical Section에 들어가기위해 준비중인 프로세스의 수를 나타냅니다.

Dekker 알고리즘은 동시성 프로그래밍에서 상호 배제를 해결하기위한 첫번째 방법으로 알려져있습니다. 이 알고리즘은 충돌 없이 공유된 자원을 두개의 스레드가 사용할 수 있도록하는 방법입니다.

package algorithm.dekker;

class Main {

static boolean[] flag = {false, false};

static int turn = 0;

static int N = 4;

// flag: 어떤 프로세스가 Critical Section에 접근하기를 원하는지

// turn: Critical Section에 들어갈 순서

// N: 루프를 돌 횟수

static Thread process(int i) {

return new Thread(() -> {

int j = 1 - i;

for (int n=0; n<N; n++) {

log(i+": want CS"); // LOCK

flag[i] = true; // 1 Critical Section에 들어가길 원하는 프로세스

while (flag[j]) { // 2 만약 Critical Section에 들어갈 수 있다면

if (turn == i) { Thread.yield(); continue; } // 3a 만약 내 차례이면

flag[i] = false; // 4a 만약 내차례가 아니면 난 들어가지 않겠다

while (turn == j) Thread.yield(); // 4b 너의 차례가 끝날때가지 기다리겠다

flag[i] = true; // 4c 너의 차례가 다 끝났으니 내가 들어가겠다

}

log(i+": in CS"+n);

sleep(1000 * Math.random()); // 5 Critical Section에 들어가서 수행중

log(i+": done CS"); // UNLOCK

turn = j; // 6 이제 네차례야

flag[i] = false; // 7 난 Critical Section에 이미 들어갔어

}

});

}

public static void main(String[] args) {

try {

log("Starting 2 processes (threads) ...");

Thread p0 = process(0);

Thread p1 = process(1);

p0.start();

p1.start();

p0.join();

p1.join();

}

catch (InterruptedException e) {}

}

static void sleep(double t) {

try { Thread.sleep((long)t); }

catch (InterruptedException e) {}

}

static void log(String x) {

System.out.println(x);

}

}

Peterson 알고리즘은 Dekker 알고리즘과 마찬가지로 동시성 프로그래밍에서 상호 배제를 해결하기위한 알고리즘입니다. 다만 차이점은 Peterson 알고리즘은 프로세스가 두개인 경우에만 사용할 수 있다는 점이죠. Dekker 와 process 부분만 다르니까 그부분만 설명하도록 하겠습니다.

static Thread process(int i) {

return new Thread(() -> {

int j = 1 - i;

for (int n=0; n<N; n++) {

log(i+": want CS"); // LOCK

flag[i] = true; // 1 Critical Section에 들어가길 원해

turn = j; // 2 j는 다른 프로세스의 턴이야

while (flag[j] && turn == j) Thread.yield(); // 3 만약 네가 들어가고 싶다면 양보해줄게

log(i+": in CS"+n);

sleep(1000 * Math.random()); // 4 Critical Section에 들어가서 수행중

log(i+": done CS"); // UNLOCK

flag[i] = false; // 5 나 이제 끝났어

}

});

}

Peterson 알고리즘은 상대방 프로세스에게 자신의 순서를 양보한다는 특징이 있습니다.

Hardware Synchronization (하드웨어 동기화)

하드웨어는 가끔 Critical Section 문제를 해결할 때 도움을 줍니다. 몇몇 OS는 Lock (이하 락) 을 제공하기 떄문이죠.

하나의 프로세스가 Critical Section에 들어갈 때, 락이 주어지고 Critical Section에서 나오기 전에 프로세스는 반드시 실행되어야 합니다.

그 결과 추가적인 프로세스는 만약 달느 프로세스가 이미 Section을 사용했다면 Critical Section에 접근할 수 없습니다.

Mutex Locks (뮤텍스 락)

하드웨어 동기화를 구현하는 것은 매우 어려운 작업입니다. 그래서 뮤텍스 락이 대안책으로 떠오르게 되었습니다.

뮤텍스는 중요 섹션의 리소스에 대한 액세스를 동기화하는 데 사용되는 잠금 메커니즘입니다. 이 방법에서는 중요 섹션에 락을 사용합니다. 프로세스가 진입 섹션에서 진입할 때 락이 설정되고, 프로세스가 종료 섹션에서 종료할 때 설정이 해제됩니다.

Semaphores (세마포어)

세마포어는 신호를 주고받는 방버입니다. 프로세스는 세마포어에서 기다리고있는 프로세스에 신호를 줄 수 있습니다. 세마포어는 뮤텍스와 다른데 뮤텍스는 shared lock를 세팅한 프로세스에 의하여 인지될 수 있기 때문입니다.

세마포어는 wait() 과 signal() 함수를 이용해서 프로세스 사이에 동기화를 이끌어냅니다.

마치며

여기까지 프로세스 동기화에 대해서 알아봤습니다. 알아야 할 내용도 많고 복잡해서 솔직히 이해를 했는지도 잘 모르겠네요. 면접용으로 다시 정리하면서 되새김질 해야할 것 같습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://www.scaler.com/topics/operating-system/process-synchronization-in-os/

https://github.com/javaf/peterson-algorithm/blob/master/Main.java

https://github.com/javaf/dekker-algorithm/blob/master/Main.java

교착상태 (DeadLock) 와 기아상태 (Starvation)

잘 기억은 안나지만 2022년 여름 주말이었는데 지인들끼리 다같이 모여서 쿠팡이츠로 점심을 시켜먹으려고 했는데 서버가 내려가 주문이 안된 사태가 있었습니다.

여자친구가 당시 쿠팡이츠에서 일하고 있었고 사내톡으로 해당 문제가 교착상태 (DeadLock) 때문에 발생한 문제라고 판단하고 이내 두시간정도에 걸쳐서 문제를 해결했던 일이 생각납니다.

가장 매출이 잘 나오는 주말 점심에 이런 일이 터졌기 때문에 고객들은 부정적인 고객경험과 경쟁사에 고객을 뺏기는 상황이 발생했죠.

이처럼 데드락, 교착상태는 서비스를 운영함에 있어서 큰 문제가 될 수 있는 상황입니다. 오늘 시간에는 교착상태에 대해서 알아보고 왜 교착상태가 일어나는지, 해결하려면 어떻게 해야하는지에 대해서 알아보도록 하겠습니다.

교착상태

교착상태 (이하 데드락) 은 하나 이상의 프로세스가 자원을 잡고 있는 상태에서 다른 자원을 획득하기위해 기다리고 있는 상황때문에 생기는 블락현상입니다.

프로세스가 한번 실행되고 나면 잡고있던 자원을 해제하게 되는데 많은 프로세스가 한번에 실행되게 되면 실행에 필요한 자원을 갖기위해 끊임없이 경쟁하는 상태에 돌입하게 됩니다.

데드락을 위한 네가지 상황

• 상호 배제 (Mutual Exclusion) : 상호 배제는 프로세스 동기화 포스팅에서도 소개해드린 개념입니다. 오직 하나의 프로세스만 임계구역에서 작업할 수 있고 나머지 프로세스는 임계구역에 들어가기 위해서는 기다려야 한다는 개념이었습니다.

• 점유 대기 (Hold ans Wait) : 하나의 프로세스가 한번에 적어도 하나의 자원을 잡고있는 중에 다른 프로세스가 잡고있는 다른 자원을 획득하기위해 기다리는 상태를 말합니다.

• 비선점 (No Preemption) : 다른 프로세스가 자원의 사용을 끝낼 때 까지 자원을 뺏을 수 없습니다.

• 순환 대기 (Circular Wait) : 일련의 프로세스가 각각의 다른 순환참조에의해 기다리고 있는 상황을 말합니다. 예를 들어서 일련의 프로세스 P0, P1, P2, P3가 있다고 가정해봅시다. P0는 P1에 의존하고 P1은 P2에 의존하고 P2는 P3에 의존하고 P3는 다시 P0에 의존하는 상황을 말합니다.

위와같은 그림이 순환 대기를 보여주는 예시입니다. 프로세스 1이 자원 1을 쥐고있고 자원 2를 요구합니다. 프로세스 2는 자원2를 쥐고있고 자원 1을 필요로합니다.

이런 상황이 바로 데드락을 유발하는 상황입니다. 이 때 두개의 프로세스 모두 실행이 되지 않는 상황이 생겨버리죠. 상호배제에 의해 자원은 공유되지 않기 때문에 이러한 상황이 생깁니다.

데드락을 피하기 위한 네가지 방법

• 예방 : 데드락이 발생하려면 앞서 설명한 네가지 조건을 모두 만족해야 합니다. 그렇다는 얘기는 하나라도 만족하지 않으면 데드락이 발생하지 않는다는 얘기인데요. 때문에 데드락을 예방하기 위해서는 넷을 모두 만족하지 않은 시스템을 만들면 됩니다.

• 회피 : 데드락이 발생하려는 조짐이 보이더라도 데드락을 회피하는 방법이 있습니다. 바로 알고리즘을 만들어서 데드락을 회피하는 방법입니다.

• 감지 그리고 회복 : 데드락이 발생했을 때 데드락을 감지하는 알고리즘에 의해 데드락을 감지하고 회복하기 위해 뒤이어 설명할 세가지 방법을 통해 데드락에서 시스템을 회복시킬 수 있습니다.

데드락에 걸린 모든 프로세스를 중지시킨다.

• 데드락으로부터 시스템 회복까지 한번에 하나씩 프로세스를 중지시킨다.

• 자원의 비선점 때문에 발생한 문제를 자원을 선점시킴으로써 데드락 문제를 회복시킨다. 이때 데드락을 회복할 때 까지 우선순위가 높은 프로세스에 자원을 할당하는 방법입니다.

• 무시 : 이 방법은 시스템이 데드락이 절대 발생하지 않는다고 가정한 상태에서 시작합니다. 데드락 상황이 자주 발생하지 않기 때문에 몇몇 시스템들은 아예 무시하는 방법을 선택하기도 합니다. OS 중 Unix와 Windows같은 것들이 선택하는 방법이죠. 만약 이런 상황에서 데드락이 발생한다면 재시작을 통해 데드락 상황을 자동적으로 해결하도록 설계되어있습니다.

무시와 같은 예제는 Ostrich 알고리즘의 한 예시입니다. 이 알고리즘은 극도록 드문 잠재겅니 문제를 무시하기위한 전략 중 하나입니다.

기아상태

많은 분들이 교착상태와 기아상태에 대해 헷갈려 하시는 분들이 많은 것 같습니다. 제 주변인들도 이를 많이 헷갈려 하시더라구요. 둘은 비슷한듯 보이지만 약간 다른 차이점을 가지고 있습니다.

기아 상태는 높은 우선순위가 계속 실행이 되서 낮은 우선순위가 실행되지 않는 상황을 말합니다. 군대 은어로 잼걸렸다고 하는 표현이 이와 같습니다. 실제로 영어로 된 문서에서 이와같은 현상을 Jammed 라고 표현하더라구요.

기아상태는 보통 무한한 시간동안 프로세스가 딜레이되는 상황에서 주로 발생합니다.

프로세스의 요청을 핸들링하기 위해 OS에서 필요한 자원들은 다음과 같습니다.

• 메모리

• CPU 시간

• 디스크 저장공간

• Bandwidth of network

• I/O access to disk or network

이러한 자원들이 밀리고 밀리면서 우선순위가 낮은 프로세스가 실행되지 않는 상황이 바로 기아상태입니다.

기아상태의 특징

• 낮은 우선순위의 프로세스가 영원히 기다리고 높은 우선순위의 프로세스가 끊임없이 동작하는 상황

• 낮은 우선순위의 프로세스는 자원과 영원히 상호작용하지 않기 때문에 데드락이 발생하지는 않습니다. 그러나 낮은 우선순위의 프로세스가 영원히 실행이 안된다는 문제가 발생합니다.

• 그러므로 기아상태는 정확히 실패한 방법입니다. 기아상태는 데드락을 일시적으로 해결해주는 방법이긴합니다.

• 기아상태를 유발하는 가장 중요한 요인은 모든 자원들을 공급하는 충분한 리소스가 없기 때문입니다.

• 기아상태는 자원이 자원 분배에 실패 때문에 프로세스의 실행을 위해 절대 공급되지 않는 상태에서도 발생할 수 있습니다.

이러한 기아상태를 해결하기위한 방법도 존재하겠죠? 해결하는 방법에 대해서 한번 알아보도록 하겠습니다.

기아상태 해결방법

• CPU에 자원분배를 맡긴다.

• 프로세스를 선택하는 것을 랜덤 선택으로 바꾼다.

• PriorityQueue와 같은 스케쥴링 알고리즘을 사용한다.

이렇게 기아상태에 대해서 알아봤습니다. 그럼 교착상태와 기아상태의 차이에 대해서 한번 쭉 정리하고 마무리 짓도록 하겠습니다.

마치며

여기까지 교착상태와 기아상태에 대해서 알아봤습니다. 오랜만에 쉬우면서 중요한 개념을 공부해보니 마음이 뿌듯하니 좋습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

참조

https://www.scaler.com/topics/operating-system/deadlock-in-os/

https://www.geeksforgeeks.org/introduction-of-deadlock-in-operating-system/

https://www.scaler.com/topics/starvation-in-os-operating-system/

세마포어와 뮤텍스

프로세스 동기화에 대한 면접질문을 외우다가 문득 생각이 들었습니다. "Critical Section (이하 임계구역) 에 접근하는 것을 제어하기 위해 세마포어나 뮤텍스를 사용합니다." 라는 문장에서 세마포어가 뭐지? 뮤텍스는?

원래 이런 궁금증이 생기진 않았습니다. 하지만 문득 들더군요. 저것에 대한 꼬리질문이 나올 수도 있겠다는 생각이말이죠.

때문에 오늘 포스팅에선 세마포어와 뮤텍스에 대해서 알아보도록 하겠습니다.

세마포어와 뮤텍스

우선 결론부터 말해보겠습니다. 뮤텍스는 락을 걸어서 임계구역으로의 접근을 막는 매커니즘을 가지고 있습니다. 우리가 자바에서 흔히 사용하던 synchronized 키워드가 바로 뮤텍스입니다.

세마포어는 신호를 주고받아서 임계구역으로의 접근을 막는 매커니즘을 가지고 있습니다. 이러한 방식을 시그널링 매커니즘을 사용한다. 라고 표현하는데요. 이는 좀 낯선 개념이네요. 한번 알아보죠.

• 뮤텍스는 프로세스가 자원을 사용할 때 자원에 락을 걸고, 사용하고, 마지막으로 반환합니다. 반면에 세마포어는 wait(), signal() 신호를 주고받으면서 프로세스가 자원을 얻을 수 있는지 반환해야 하는지를 지시합니다.

• 뮤텍스는 synchronized 키워드를 사용해보셨으면 아시겠지만 Object 클래스의 객체가 필요하듯 객체입니다. 반면 세마포어는 Integer 형의 변수로 신호를 주고받습니다.

• 뮤텍스 객체는 여러개의 프로세스의 스레드가 공유 자원을 얻을 수 있습니다. 하지만 하나의 프로세스가 락을 획득했다면 다른 프로세스는 기다려야하죠. 세마포어는 유한한 자원의 인스턴스가 사용가능할 때까지 여러개의 프로세스가 유한한 자원을 동시에 얻을 수 있습니다.

• 뮤텍스는 두가지 상태만을 가지고 있습니다. lock 과 unlock이 바로 그것인데요. 세마포어는 자원을 얻고 반환하는 과정에서 여러개의 변수의 상태를 가질 수 있습니다.

일단 말로 쭉 풀어 썼는데 이해가 되실런지 모르겠네요. 그래서 제가 아주 기가막힌 예시를 가져왔습니다.

뮤텍스와 세마포어는 임계구역으로의 접근을 제어하는 상호배제를 구현하는 매커니즘입니다. 상호배제, 임계구역같은 개념이 좀 부족하신 것 같으시면 아래의 링크에 자세하게 설명되어있으니 보고 오시는 것을 추천드립니다.

https://coding-review.tistory.com/354

상호배제를 구현하는 매커니즘이기 때문에 우리는 식당에서 화장실을 가는 것으로 설명할 수 있습니다.

뮤텍스

뮤텍스는 식당에 화장실 키가 하나 있고 화장실도 하나밖에 없는 경우입니다. 우리가 일반적으로 식당에 가서 볼 수 있는 상황이죠.

화장실에 가기위해선 키가 있어야하고 만약 화장실에 누가 들어갔다면 아무리 급하더라도 다음 사람이 키를 가지고 나올 때 까지 기다려야합니다.

그리고 만약 화장실에 가고싶은 사람이 많은 경우 맨 앞에 있는 사람이 다음 순서로 들어가는 것까지 뮤텍스와 우리가 생각하는 일반 상식이 정확히 일치합니다.

이런 상황이 바로 뮤텍스를 설명할 수 있는 아주 좋은 예시입니다.

세마포어

세마포어는 식당에 화장실이 여러개 있고 들어가고 화장실 밖에는 전광판이 있습니다. 화장실에 가고싶은 사람은 화장실에 들어가고 나올때마다 스위치를 켰다 껐다 하면 됩니다.

스위치를 키면 화장실 안에 누군가 있다는 얘기이고 스위치를 키는 순간 전광판에 숫자가 줄어듭니다. 화장실에서 모든 일을 다 보고 나오면서 스위치를 끄면 숫자가 다시 하나 올라갑니다.

이렇게 화장실에 아무도 없을 때는 전광판에 숫자 3이 적혀있습니다.

그러다 누군가 들어가면서 스위치를 누르고 들어가면 전광판의 숫자가 2로 줄어듭니다. 마찬가지로 이 사람이 모든 일을 끝냈을 때 스위치를 다시 끄고 나오면 전광판의 숫자가 다시 3으로 돌아옵니다.

어떤가요? 예시를 들어보니 이해가 빠르게 되시죠? 위의 예시에서 사람은 프로세스 혹은 스레드이고 화장실은 자원입니다.

뮤텍스는 제가 초반에 자원에 락을 거는 매커니즘이라고 했습니다. 우리는 화장실 예제에서 화장실 키로 화장실(자원)에 락을 걸어 한명만 들어가는 것을 보장했죠.

세마포어는 반면에 신호를 주고받는 매커니즘이라고 했습니다. 화장실 예제에서 우리는 스위치를 켰다 껐다 하면서 전광판에 밖에 있는 사람(프로세스, 스레드)에게 신호를 보냈습니다.

각각의 장단점

이제 뮤텍스와 세마포어에 대한 장점과 단점에 대해서 알아보겠습니다. 뚜렷하게 보이는 장점과 단점이 있는데 제가 뮤텍스와 세마포어에 대한 공부를 시작했을 때 느낀점은 뮤텍스는 구현이 쉬울 것 같고 세마포어는 까다로울 것 같더군요. 실제로도 그렇습니다.

이제 본격적으로 장점과 단점에 대해서 알아보죠.

뮤텍스의 장점

• 뮤텍스는 구현이 아주 간단합니다. syncrhonized 키워드로 상호배제를 구현해보신 분들은 생각보다 간단하다는 생각을 가지셨을 것입니다.

• 뮤텍스는 여러개의 다른 스레드가 공유자원에 동시에 접근하는 것을 막는 배리어를 치는 것과 같습니다. 이렇게 하면 서로 다른 스레드가 자원을 필요로할 때 이미 누군가가 들고 있는 자원을 효과적으로 지킬 수 있습니다.

• 뮤텍스는 높은 코드 신뢰성을 가지고 있습니다. 다른 스레드에의해 접근한 자원들은 만약 CPU의 메모리 관리에 장애가 발생하게 되면 사용할 수 없습니다. 한번에 자원에 접근할 수 있는 스레드를 막음으로써 시스템은 메모리 관리에 장애가 발생함으로써 야기되는 문제들로부터 회복하는 것이 조금 더 수월해집니다.

뮤텍스의 단점

• 자원을 획득한 스레드가 아닌 다른 스레드에서는 자원을 잠구거나 해제할 수 없습니다.

• 전형적인 구현방법이 하나의 자원에 하나의 스레드만 접근할 수 있기 때문에 CPU의 시간을 낭비하는 상황을 불러일으킬 수 있습니다.

• 만약 하나의 스레드가 락을 획득하고 잠수를 타버리거나 선점을 해버리면 뒤에 잇따르는 스레드들은 무한정 대기하는 기아상태에 빠질 수 있습니다.

세마포어의 장점

• 세마포어는 시스템의 자원을 효율적으로 할당할 수 있습니다. 이 의미는 메모리를 조금 더 효율적으로 쓸 수 있다는 얘기이죠.

• 세마포어는 여러개의 프로세스를 컨트롤할 수 있습니다. 이 말은 메모리를 특정한 일에 할당할 수 있다는 의미이기도 합니다.

• 세마포어를 기반으로한 메모리관리는 시스템의 전반적인 성능을 향상시킬 수 있습니다.

세마포어의 단점

• 세마포어는 프로그래밍 에러를 야기할 수 있습니다.

• 세마포어를 구현하기위한 복잡한 프로그래밍 방식 때문에 상호배제를 완벽하게 구현할 수 없습니다.

• 세마포어를 구현하는 것은 CPU 메모리 관점에서 굉장히 비쌉니다.

• 우선순위에 대한 관리를 할 수 없어서 만약 낮은 우선순위의 프로세스가 임계구역에 들어가버리면 높은 우선순위의 프로세스가 나올 때까지 기다려야합니다.

마치며

여기까지 뮤텍스와 세마포어에 대한 기본적인 개념을 알아봤습니다. 저는 뮤텍스와 세마포어를 영어로된 docs로 보고있었는데 처음엔 이해가 되지 않다가 저 화장실 예시를 보고 무릎을 쳤습니다. 역시 어려운 것을 예시로 풀어 설명하면 이해가 잘 되는 것 같습니다.

이렇게 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://www.shiksha.com/online-courses/articles/mutex-vs-semaphore-what-are-the-differences/

https://www.geeksforgeeks.org/mutex-vs-semaphore/

https://worthpreading.tistory.com/90

리눅스 사용자, 그룹 추가 (부제 : 리눅스 카테고리 출범!)

저는 현재 소규모 스타트업에서 일하고 있는데요. 회사에서 큼직하지 않은 잡다한 인프라 담당을 겸하고 있습니다. 사용자 권한 관리하고 서버 내리고 올리고 라우팅이나 포트 관리하고 이런 서버관리 업무가 제 업무에 포함되어있습니다.

그러다보니 평소에 안그래도 부족했던 리눅스관련 지식이 더 티가 잘나더군요... 그래서 이번 기회에 리눅스 명령어부터 리눅스 커널에 대해서 좀 공부를 할 생각입니다.

평소의 제 방식처럼 일단 사용법부터 들이박아보고 이론은 조금 시간이 지나고 배워보도록 하겠습니다.

리눅스 사용자 추가

서버 컴퓨터는 대부분 리눅스로 되어있습니다. 보통 제가 프로젝트를 할 때는 AWS에서 제공해주는 기본 사용자를 사용하곤 했었죠. 회사에선 개발전용 사용자가 있고 루트 사용자가 있더군요.

이 사용자를 생성하고 비밀번호를 만든 뒤 sudo를 이용해서 루트 사용자 권한을 부여하는 것까지 해보겠습니다.

일단 AWS의 EC2를 한대 빌리겠습니다.

구글링하면서 돌아다니다가 알게된 내용인데 t2 컴퓨터보다 t3 컴퓨터가 성능도 더 좋고 싸다고 하더라구요? 이걸 왜 지금알았는지...

t3 중에서도 t3a가 좋다고해서 t3a.micro를 한대 빌렸습니다. 비용적인 측면으로 봤을 때 t3a가 t2보다 25퍼센트정도 더 저렴합니다.

일단 실습하기 전에 간단한 개요부터 보고 넘어가도록 하겠습니다.

개요

보통 서버에 들어갈 땐 ppk 혹은 pem 키를 들고 들어갑니다. ppk는 퍼블릭키, pem은 프라이빗키입니다. 리눅스는 사용자가 해당 키에 대한 정보가 없으면 서버로 들어가지 못합니다.

그래서 사용자를 만들어주고 그 사용자에 키를 등록해주어야만 외부에서 접속할 때 사용자로 인식할 수 있습니다.

이정도 배경지식을 알고나서 본격적으로 실습해보도록 하겠습니다.

실습

일단 AWS에 등록한 pem키를 서버로 가져와줍니다. 이 pem키를 가져오는 다양한 방법이 있는데 가장 단순한 방법이 FTP를 이용하는 방법입니다.

FTP는 SSL적용을 해야 조금 안심이 되긴 하지만 실습의 복잡도를 낮추기위해 SSL은 적용하지 않겠습니다. 저는 SFTP를 이용해서 파일을 서버로 옮겼습니다.

번외)

안전한 방법은 AWS의 S3에서 파일을 다운로드 하는 방법이 개인적으로 안전한 방법인 것 같습니다. 하지만 보안과 효율성 사이에서 고민하면 항상 효율성을 찾게 되더라구요...

AWS S3에서 파일을 리눅스 서버로 옮기는 방법은 아래의 링크를 확인해주세요!

https://coding-review.tistory.com/454

pem키를 서버로 옮겼다면 아래의 명령어를 입력합니다.

ssh-keygen -y -f ./{키이름}.pem

이 명령어를 쳤는데 WARNING!! 이렇게 뜬다면 앞에 sudo를 붙여서 실행해주세요.

sudo ssh-keygen -y -f ./key-pair-name.pem

이 명령어를 치면 pem키를 퍼블릭키로 바꿔줍니다.

아래와 같은 결괏값을 받을 수 있습니다.

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQClKsfkNkuSevGj3eYhCe53pc

jqP3maAhDFcvBS7O6Vhz2ItxCih+PnDSUaw+WNQn/mZphTk/a/gU8jEzoOWbkM

4yxyb/wB96xbiFveSFJuOp/d6RJhJOI0iBXrlsLnBItntckiJ7FbtxJMXLvvwJ

ryDUilBMTjYtwB+QhYXUMOzce5Pjz5/i8SeJtjnV3iAoG/cQk+0FzZqaeJAAHc

o+CY/5WrUBkrHmFJr6HcXkvJdWPkYQS3xqC0+FmUZofz221CBt5IMucxXPkX4r

Wi+z7wB3RbBQoQzd8v7yeb7OzlPnWOyN0qFU0XA246RA8QFYiCNYwI3f05p6KL

xEXAMPLE

이 값을 잘 복사해서 가지고 있으면 됩니다.

그리고 adduser 명령어를 이용해서 사용자를 만들어줍니다.

sudo adduser {user이름}

번외)

사용자가 sudo 명령어를 실행할 때 비밀번호를 입력하게 하고 싶으면

sudo passwd {user이름}

이렇게 명령어를 날리면 비밀번호를 설정할 수 있습니다.

이제 이 유저로 강제 로그인을 해줍니다.

sudo su - {user이름}

이제 우리가 만든 사용자로 접속했습니다.

아래의 명령어를 순서대로 치면 됩니다.

mkdir .ssh

chmod 700 .ssh

touch .ssh/authorized_keys

chmod 600 .ssh/authorized_keys

chmod는 다음 포스팅에서 다룰 예정이라 자세한 설명은 생략하도록 하겠습니다. 파일이나 폴더에 권한을 부여한다 정도만 파악하고 계시면 됩니다.

700은 해당 파일 혹은 폴더를 가지고 있는 사용자만 읽기(r), 쓰기(w), 실행(x) 권한을 부여하는 것입니다.

즉, 해석하면 .ssh 폴더에 .ssh 폴더를 소유한 사용자 (우리가 만든 사용자) 에 한해서만 읽기, 쓰기, 실행 권한을 부여하고 그 아레 authorized_keys라는 빈 파일을 만든 뒤 해당 파일을 이 파일을 소유한 사용자만 읽고, 쓸 수 있는 권한을 부여하는 것입니다.

700은 rwx 모두를, 600은 rw만을 부여하는 명령어입니다.

번외)

• 파일에 r 권한을 부여받지 못한 경우엔 vim같은 파일 편집기로 읽을 수 없습니다. 만약 폴더에 r 권한을 부여받지 못하면 ls같은 읽기 명령을 실행할 수 없습니다.

• 파일에 w 권한을 부여받지 못한 경우엔 파일을 수정하고 저장하는 :wq 같은 명령어를 실행할 수 없습니다. 만약 폴더에 w 권한을 부여받지 못하면 mkdir, rm, mv, cp 같은 쓰기 명령을 실행할 수 없습니다.

• 파일에 x 권한을 부여받지 못하면 쉘 스크립트 실행 명령어인 sh를 사용할 수 없습니다. 만약 폴더에 x 권한을 받지 못한다면 ls -a, ls -l 같은 실행 명령어를 실행할 수 없습니다.

이제 authorized_keys를 열어서 아까 복사해둔 우리의 퍼블릭 키를 붙여넣기 하면 됩니다.

vi .ssh/authorized_keys

그리고 이제 다시 나갔다가 우리가 만든 사용자로 로그인하면?

짜잔! 로그인이 성공합니다.

여기서 만약 sudo 명령어를 실행하게되면 실행이 됩니다.

그룹추가

이제 그룹을 추가해보도록 하겠습니다. 상황을 한번 가정해볼까요?

상황)

개발자들만 사용하는 폴더를 만들고싶습니다. 그래서 cat, dog를 개발자 그룹으로 넣고싶다고 가정해보죠.

사용자는 위의 사용자 만들기에서 소개했으니 그룹을 만들어보겠습니다.

sudo groupadd developer

그룹이 잘 만들어졌는지는 아래의 명령어로 확인해보면 됩니다.

groups

or

vi /etc/group 안되면 sudo vi /etc/group

이제 그룹을 만들었으니 그룹 안에 사용자를 넣어보죠.

sudo usermod -aG {그룹이름} {user이름}

우리의 예시에선

sudo usermod -aG developer cat

sudo usermod -aG developer dog

자 이제 아래의 명령어로 잘 들어갔나 확인해보죠.

vi /etc/group 안되면 sudo vi /etc/group

그룹에 추가하는건 이것 이외에도 여러가지 방법이 있는데 그 중에서 잘 쓰일 것 같은 것을 소개해드리겠습니다.

바로 gpasswd입니다.

간단하게 사용법을 알아보죠.

사용자 그룹에 추가

sudo gpasswd -a cat developer

사용자 그룹에서 삭제

sudo gpasswd -d cat developer

이것 말고도 그냥 /etc/group에서 직접 추가하고 삭제해도 됩니다.

번외)

다음 포스팅에서 다룰 내용이지만 제가 미리 예습한 것을 적용해볼까합니다.

바로 개발자들 폴더를 만들어서 해당 폴더는 개발자만 사용할 수 있게 권한을 부여하는 것입니다.

먼저 /home으로 가보죠.

cd /home

그리고 여기다 developer라는 폴더를 만들어줍니다.

sudo mkdir developer

그리고 권한을 확인해보면?

d 뒤에 세개는 이 폴더를 가지고 있는 사람의 권한, 그 뒤 세개는 이 폴더를 가진 그룹의 권한, 그 뒤 세개는 그외의 그룹의 권한입니다.

그리고 맨 앞에 있는 root는 해당 폴더를 소유한 사용자, 그 뒤에 있는 root는 해당 폴더를 포함한 그룹입니다.

즉, 해석하자면 developer를 소유한 사용자는 rwx가 가능하고 그룹 (root)는 r-x, 그 외는 r-x입니다.

자 이제 이 권한을 바꿔보겠습니다.

먼저 developer를 소유한 사용자를 개발자 리더인 cat에게 부여하겠습니다.

sudo chown cat developer

이제 그룹을 developer로 바꿔보죠.

sudo chgrp developer developer

그리고 ls -l 명령어를 치면 이렇게 되어있을 것입니다.

이제 권한을 바꿔보죠.

sudo chmod 770 developer

그럼 이렇게 변합니다.

그리고 cat으로 들어가보면?

잘 들어가지는 것을 확인할 수 있습니다.

마치며

이렇게 리눅스에서 사용자를 만들고 sudo 권한까지 부여했습니다. 일단 리눅스 첫 공부로 사용자 등록을 마무리했는데 엄청 기본기치곤 꽤 헤맸습니다.

다음 포스팅에선 파일, 폴더에 권한을 부여하고 더 나아가 ACL을 이용해서 관리해보도록 하겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

https://docs.aws.amazon.com/ko_kr/AWSEC2/latest/UserGuide/managing-users.html

리눅스 커널

리눅스를 공부하다보니 리눅스 커널이라는 것을 공부하면 좋다고 하더군요. 원래 이론공부는 나중에 하는 편이지만 이론공부도 좋아하기 때문에 한번 공부해봤습니다.

리눅스 커널

우선, 커널이 뭔지 개념을 잡고 넘어가면 좋을 것 같습니다. 커널은 컴퓨터에 장착되어 있는 하드웨어 제품들 (하드디스크, 메모리, CPU, 키보드, 마우스, 모니터 등등...) 을 소프트웨어로 추상화하여 OS가 사용할 수 있도록 하는 것입니다.

즉, 쉽게 말해 커널은 OS가 하드웨어 디바이스를 제어하기위한 중계자 역할을 한다는 것입니다.

우리는 그 중에서 리눅스 커널에 대해서 알아볼 것입니다. 윈도우도 커널이 있습니다! 하지만 우리는 윈도우로 개발할 것은 아니기 때문에 리눅스로 한번 알아봤습니다.

리눅스 커널에는 크게 다섯가지 관리 체계가 있습니다.

• 프로세스 관리

• 메모리 관리

• 디바이스 관리 (키보드, 모니터 등등)

• 파일시스템 관리

• 네트워크 관리

이제 하나씩 한번 살펴보죠!

프로세스 관리

• 프로세스 관리 시스템은 주로 CPU에 존재하는 프로세스를 관리하는 역할을 합니다. 리눅스는 CPU의 프로세스를 생성하고 폐기하는데 이때 PID라는 것으로 관리를 합니다. 보통 프로세스가 생성되는 것을 fork라고 합니다.

• 프로세스 관리 시스템은 프로세스를 스케줄링을 이용해 관리합니다. 이때 우선순위를 정하기도 하고 컨텍스트 스위칭을 이용해 프로세스의 상태를 관리합니다.

• 리눅스는 프로세스를 네가지 상태로 관리합니다.

Running : 프로세스가 현재 CPU에 의해 실행되는 상태 혹은 실행을 기다리는 상태입니다.

• Waiting : 프로세스가 이벤트로 인해 잠시 대기하고 있는 상태입니다.

• Stopped : 프로세스가 멈춰있는 상태입니다. 보통 시그널을 받으면 멈추게됩니다.

• Zombie : 프로세스가 실행이 끝났지만 멈춰있지 않고 계속 실행이 되고 있는 상태입니다

• 리눅스 커널이 관리하는 프로세스는 서로 통신을 할 수 있습니다. 프로세스간 통신을 위해 내부적으로 메세지 큐를 이용하여 메모리 공간을 공유합니다. 이때 메모리 자원을 공유하면 race condition이 발생할 수 있기 때문에 세마포어나 뮤텍스와 같은 방법론을 이용해 race condition을 방지합니다.

프로세스간 통신이 필요한 경우는 클라이언트에서 서버로 동시다발적인 요청이 들어올 때 프로세스에게 역할을 분담시켜 요청을 처리하는 경우입니다. 이 때문에 프로세스간 통신이 필요하죠.

• 프로세스는 init process부터 시작해 위계 (hierarchy) 를 가집니다. init process를 제외한 모든 프로세스는 부모 프로세스를 가지고 이렇게 함으로써 프로세스를 관리하기 더 쉬워지게 됩니다.

• 프로세스는 프로세스 내부적으로 스레드라는 개념으로 경량화를 합니다. 스레드는 메모리의 모든 자원을 공유하기 때문에 컨텍스트 스위칭에 대한 비용이 낮고 속도가 빠르다는 장점이 있습니다. 그렇다고 스레드가 컨텍스트 스위칭에 대한 오버헤드가 전혀 없다는 것은 아니고 프로세스에 비해 낮다는 이야기입니다.

메모리 관리

• 각각의 프로세스는 고유한 가상 메모리 (virtual memory) 공간을 부여받는데 가상 메모리 공간은 물리 메모리 공간을 추상화한 것입니다. 리눅스 커널은 페이지 테이블이라는 것을 이용해 가상 메모리와 물리 메모리 공간을 매핑합니다. 이 매핑은 메모리를 격리할 수 있고 이렇게 함으로써 각각의 프로세스들은 다른 프로세스에 접근할 수 없게됩니다. 이는 보안과 안정성을 높일 수 있는 방법이기 때문에 리눅스 커널에서 주로 사용하는 방법입니다.

• 메모리는 정해진 사이즈의 블록인 페이지 (page) 로 나누어져 있습니다. 커널은 이 페이지를 이용해서 메모리를 관리하죠.

프로세스가 현재 물리 메모리가 부족한 상태에서 메모리를 할당받으려 한다면 메모리에 장애가 발생할 수 있습니다. 이를 대비해서 커널은 페이지를 위한 메모리를 하드디스크에서 떼와서 물리메모리처럼 사용합니다.

페이징이라는 기술을 사용함으로써 실제로 물리 메모리만큼 사용할 수 있었던 것 보다 더 많은 메모리를 사용할 수 있게 됩니다. 이때 페이징은 스와핑이라는 기술을 이용해 위에서 말했던 메모리의 공간을 확보합니다.

• 커널은 프로세스가 사용해야하는만큼 메모리를 할당해주는 능력이 있습니다. 프로세스가 메모리를 많이 잡아먹으면 메모리를 더 많이 할당해주고 적게 잡아먹으면 할당했던 것을 돌려주는 능력이죠.

• 커널은 인가되지 않은 접근으로 메모리를 사용하는 것을 막을 수 있습니다. 보통 메모리를 사용하는 것은 프로세스인데 프로세스가 인가되지 않은 접근으로 생성된 것이라면 메모리의 사용을 막아서 실행을 막을 수 있는 것이죠.

• 메모리를 페이징과 스와핑으로 실제 물리 메모리보다 더 많이 사용하기 때문에 웬만한 OOM (Out Of Memory) 을 막을 수 있습니다.

디바이스 관리

• 디바이스 드라이버는 하드웨어와 OS를 연결해주기 위한 드라이버입니다. 우리가 흔히 아는 그래픽 드라이버가 여기에 포함됩니다.

• 리눅스 커널은 키보드, 하드디스크, 네트워크 인터페이스 (NIC), USB와 같은 하드웨어 디바이스를 OS와 연결해주는 디바이스 드라이버를 가지고 있습니다.

파일 관리

• 리눅스 커널은 파일을 관리하고 폴더에 저장공간을 할당해 데이터를 저장합니다.

• 파일을 읽고, 쓰고, 실행하는 역할을 리눅스 커널이 도맡아서 하게됩니다.

네트워크 관리

• 리눅스 안에 있는 네트워킹은 포괄적으로 OS와 다른 네트워크에 존재하는 시스템과 통합하기위해 존재합니다.

• 리눅스에는 실제 하드웨어 디바이스에 장착되어 있는 pysical interface인 이더넷 카드 (LAN카드, NIC 이라고도 불립니다.) 혹은 wireless interface (wlan)을 연결할 수 있고, virtual interface인 virtual ethernet interface (veth0) 와 bridge interface (br0)를 가지고 있습니다.

• TCP/IP 프로토콜에 잘 어울리는 구현체를 가지고 있습니다. 예를 들어 IP, TCP, UDP, ICMP등 프로토콜에 최적화되어있습니다. 또한, 소켓 통신에도 유연하게 대처할 수 있습니다. 그냥 웬만한 통신은 다 된다고 보면 될 것 같네요.

• IPv4, IPv6를 지원하고 정적 라우팅, 동적 라우팅을 지원합니다. 또한, iptable을 이용한 방화벽과 4계층인 TCP, UDP 7계층인 HTTP, FTP, SMTP등을 구현하고 있습니다.

• 네트워크 트러블슈팅을 위해 제공하는 다양한 명령어가 있습니다.

ping : ping을 이용해서 도메인과 연결된 IP를 확인할 수 있습니다.

• traceroute : traceroute는 해당 도메인으로 들어가는 패킷을 분석할 수 있습니다.

• netstat : netstat는 네트워크 커넥션, 라우팅 테이블, 인터페이스를 분석할 수 있습니다.

• tcpdump : tcpdump 명령어는 특정 이더넷 카드로 들어노는 네트워크 패킷을 분석할 수 있습니다.

ping google.com

traceroute google.com

netstat -tuln

sudo tcpdump -i eth0

마치며

제가 아는 내용도 있고 모르는 내용도 있었던 것 같습니다. 리눅스 커널에 대해서 공부하니 리눅스와 좀 더 잘 소통(?)할 수 있겠다 싶은 내용인 것 같습니다.

이제 리눅스 명령어에 대해서 본격적으로 공부할 수 있을 것 같습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

리눅스 파일, 폴더 권한 부여

리눅스에는 파일 혹은 폴더에 권한을 부여하고 관리할 수 있습니다. 정확히는 유닉스라고 해야겠네요.

이번 포스팅에선 파일 혹은 폴더에 권한을 부여하고 관리하는 방법에 대해서 공부한 내용을 정리해보도록 하겠습니다.

파일, 폴더 권한 부여

파일, 폴더에 권한을 부여하는 방법에는 두가지 방법이 있습니다. 777과 같이 숫자로 부여하는 방법과 u+x와같이 문자로 부여하는 방법입니다.

먼저 숫자로 부여하는 방법에 대해서 알아보도록 하겠습니다.

기본적으로 파일이나 폴더를 만들면 상태는 rwxr-xr-x입니다. 폴더를 소유한 ec2-user와 폴더를 소유한 그룹인 ec2-user는 이 파일에 대한 권한을 나눠받습니다.

소유자인 ec2-user는 읽고, 쓰고, 실행할 권한이 있고, 그룹인 ec2-user는 읽고, 실행할 권한을 부여받습니다. 그리고 그 외의 사용자는 읽고, 실행할 권한을 가지고 있죠.

실행할 권한은 chmod로 변경할 수 있습니다.

sudo chmod 777 test

폴더에 슈퍼권한을 부여하는 방법입니다.

이처럼 숫자를 이용하면 간편하게 사용자, 그룹, 그 외 사용자를 관리할 수 있습니다.

숫자의 의미는 다음과 같습니다.

• chmod 7 = rwx

• chmod 6 = rw-

• chmod 5 = r-x

• chmod 4 = r--

• chmod 3 = -wx

• chmod 2 = -w-

• chmod 1 = --x

• chmod 0 = ---

이제 상황을 가정해보고 실습해보도록하죠!

상황1)

test라는 폴더를 보유한 사용자에겐 읽고, 쓰고, 실행할 권한을 주고 그룹에겐 읽고, 쓸 권한, 그 외의 사용자에겐 아무 권한도 부여하지 않을 것입니다.

그럼 chmod는?

맞습니다. 760입니다.

상황2)

사용자에겐 읽고, 쓰고, 실행할 권한을 그룹에겐 읽을 권한만, 그외의 사용자에게도 읽을 권한만을 부여하고싶습니다.

그럼 chmod는 뭘까요?

맞습니다. 744로 설정하면됩니다.

이렇게 숫자로 설정하면 사용자, 그룹, 그 외 사용자를 편하게 바꿀 수 있습니다. 제 개인적인 생각으로는 4 아래로는 잘 사용하지 않을 것 같습니다. 그래서 4, 5, 6, 7 만 집중적으로 공부했습니다.

이제 문자로 부여하는 방법입니다. 이건 더 직관적이라 더 이해하기 간단했습니다.

먼저 사용자에게 읽는 권한을 빼보겠습니다.

chmod u-r test

이런식으로 직관적으로 권한을 설정하는 것입니다.

맨 앞에는 u (사용자), g (그룹), o (그 외) 이고 가운데는 + (추가), - (빼기) 마지막은 r (읽고), w (쓰고), x (실행) 입니다.

즉, 그룹에게 w권한을 빼고싶다?

chmod g-w test

그 외 사용자에게서 모든 권한을 뺏고싶다?

chmod o-rwx test

어때요 간단하죠?

이론은 끝났습니다. 이제 본격적으로 파일 혹은 폴더를 권한변경을 해가면서 어떤것이 막히는지 어떤것이 되는지 알아보겠습니다.

파일, 폴더를 따로보도록 하겠습니다.

파일

우선 파일을 하나 만들어보겠습니다.

touch deploy.sh

vi deploy.sh

그 파일 안에 echo "hello" 를 입력합니다.

자 이제 본격적으로 권한을 설정해보죠.

먼저 r 권한을 뺏어보겠습니다.

현재 권한 상태는 이러하구요. 한번 vi 편집기를 이용해서 들어가보겠습니다.

읽기 권한이 거부당했습니다.

이제 쓰기 권한을 빼앗아보죠. chmod 5로 설정해줄겁니다.

sudo chmod 500 deploy.sh

vi 편집기로 들어가면 들어가는 것 까지는 성공이지만 쓰려고하면?

readonly 파일이라고 뜨네요.

이제 실행 권한을 없애보겠습니다. 6으로 설정하면 되겠죠?

sudo chmod 600 deploy.sh

vi 편집기로 들어가는 것도 가능하고 쓰는 것 까지 가능합니다.

하지만 실행하려고 하면?

실행 권한이 막혀버린 것을 볼 수 있습니다.

파일은 나름 권한이 직관적이라 이해하기 쉬웠던 것 같습니다. 이제 폴더로 넘어가보죠.

폴더

폴더의 읽기 권한을 먼저 없애보겠습니다.

이렇게 하고 cd로 안으로 들어간 뒤 ls 명령어를 치면 막힙니다.

이제 쓰기 권한을 없애보죠.

이렇게 폴더 안에 무언가를 쓰려는 모든 행동이 막힙니다.

이제 실행 권한입니다.

이렇게 폴더에 접근하려는 것이 불가능하고 설령 접근한다고 하더라도 ls -l 과 같은 실행 명령이 불가능합니다.

자 여기까지 권한을 부여하고 실습까지 해봤습니다. 그런데 저는 여기서 의문이 들더군요.

sudo

생각해보면 우리는 개발자에게 sudo 권한을 부여합니다. sudo는 루트권한을 부여하는 것인데 굳이 이렇게 권한을 부여하고 난리법석하는 의미가 뭘까요?

권한을 부여하는 것은 다음과 같은 이점을 얻을 수 있습니다.

• 순간적으로 실수해서 파일을 지워버리려고 해도 권한 관리를 한다면 아차! 하고 실수를 미연에 방지할 수 있습니다.

• 만약 공격자가 서버에 침투하더라도 인가되지 않은 사용자 혹은 그룹의 권한을 획득하지 않는이상 파일 혹은 폴더를 보호할 수 있습니다.

• 권한을 관리하면 시스템의 무결성을 지킬 수 있습니다. 신뢰성이 올라간다는 이야기죠.

원래대로라면 sudo라는 명령어는 남발해선 안됩니다. 이상적인 애플리케이션은 sudo를 쓰지 않아도 사용이 가능 해야하고 sudo를 쓰는 순간은 매우매우 중요한 순간이라는 것을 명시해야합니다.

시스템의 설정을 바꿔야한다거나, 시스템을 재시작한다거나 배포하는 과정에서 손 부들부들 떨면서 sudo를 입력하는 것이지 sudo를 너무 남용하면 권한에 대한 인식이 흐려지고 실수가 나올 수 있다는 것입니다.

저도 이제 권한을 적극적으로 사용해야겠네요.

마치며

이번 포스팅에서 리눅스 파일, 폴더에 권한을 부여하고 관리하는 방법에 대해서 알아봤습니다. 개발자는 어쩔 수 없이 sudo를 사용하게 되기 때문에 sudo에 막중한 무게감을 가진 회사가 얼마나 될까 잘 모르겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

리눅스 alias로 파일, 폴더 휴지통으로 이동시키기

리눅스에선 rm 명령어를 이용해서 파일이나 폴더를 삭제합니다.

rm -f deploy.sh

파일 삭제

rm -rf deploy

폴더 삭제

근데 문제는 이제 진짜 컴퓨터에서 완전히 삭제된다는 것입니다.

개인적으로 너무 위험해보여서 윈도우처럼 휴지통으로 이동하는 기능이 있으면 좋겠다싶었습니다.

찾아보니까 alias라고 별칭을 만들어주는 기능이 있어서 그 기능을 이용해서 그 기능을 한번 적용해서 안전장치를 하나 둬보겠습니다.

Alias

리눅스에선 자신이 많이 사용하는 명령어를 alias로 만들 수 있습니다. alias를 사용하는 방법은 두가지 방법이 있습니다.

• 현재 사용자만 alias 등록

• 모든 사용자의 alias 등록

먼저 현재 사용자의 alias를 등록해보겠습니다.

현재 사용자 alias 등록

현재 사용자가 cat이라는 사용자이고 이 사용자는 developer라는 그룹에 속해있습니다. 이 사용자로 들어가봅시다.

사용자를 만들고 권한을 부여하는 것은 앞선 포스팅에서 했으니 넘어가도록 하겠습니다.

아래의 링크에서 자세히 볼 수 있습니다!

https://coding-review.tistory.com/507

https://coding-review.tistory.com/509

먼저 현재 사용자로 로그인하고 /home/{user 이름} 폴더로 이동합니다.

그리고 아래의 명령어를 입력합니다.

ls -a

이 명령어를 치면 .bashrc 라는 파일을 발견할 수 있습니다.

vi ~/.bashrc

vim 편집기로 .bashrc를 열어서 alias를 등록합니다.

alias rm='mv -t /home/cat/.local/trash/files'

그리고 아직 폴더가 없어서 작동을 안할겁니다. 폴더를 만들어줍시다.

mkdir .local

cd .local

mkdir trash

cd trash

mkdir files

그리고 마지막으로 우리가 등록한 alias를 등록해주면 됩니다.

cd /home/cat

source ~/.bashrc

자 이제 사용해볼까요?

그리고 휴지통에 가보면?

그런데 문제가 있습니다. 휴지통을 비워야할 땐 진짜 삭제해줘야하는데 이미 rm이 alias로 mv명령이 되거든요. 진짜 삭제는 어떻게 해야할까요?

저는 새로운 alias를 만들어줬습니다. 바로 rr 이라는 alias입니다. 제가 만든겁니다. real remove의 약자입니다. 자 이제 alias를 추가해봅시다!

cd /home/cat

vi ~/.bashrc

추가) ~/ 이 명령어는 현재 로그인한 사용자의 폴더로 가라는 얘기입니다.

alias rr='rm -i'

이렇게 하면 문제가 생깁니다. rr 에서 사용한 rm은 실제 리눅스의 rm이 아니고 우리가 설정했던 alias입니다. 그래서 mv 명령어가 호출되는거죠.

그럼 rm은 못쓴다...가 아닙니다.

리눅스에는 이런 방법이 있습니다. /usr/bin 폴더에 들어가면 리눅스의 명령어가 들어가있습니다. 이걸 직접 호출해줌으로써 명령어를 사용할 수 있습니다. 즉, 이렇게요.

$ /usr/bin/rm -rf {파일,폴더 이름}

이렇게 바로 호출하면 rm 명령어를 사용할 수 있습니다. 이제 우리의 alias인 rr을 이렇게 변경하면 됩니다.

alias rr='/usr/bin/rm -i'

이렇게 정하고 다시 등록해주면 됩니다.

source ~/.bashrc

그리고 rr을 사용해보면?

이렇게 사용할 수 있습니다.

하지만 이 방법의 문제는 현재 cat 사용자만 이 alias를 사용할 수 있다는 것입니다. 모든 사용자에게서 사용하게 하려면 어떻게 해야할까요?

모든 사용자의 alias 등록

방법은 동일합니다. 다만 ~/.bashrc 에서 /etc/bash 파일로 바뀔 뿐입니다.

ec2-user로 들어가서 /etc/bash파일을 열어줍니다.

alias rm='mv -t /home/developer/.local/trash/files'

alias rr='/usr/bin/rm -i'

이렇게 alias를 등록해준 다음 source 명령어로 다시 초기화해주면 끝!

마치며

리눅스는 정말 위험하면서도 매력적인 운영체제인 것 같네요. 위험한만큼 안정방지책을 두면 더 안전하게 사용할 수 있을 것 같네요. 단점을 약화시키고 장점만 돋보이게 할 수 있겠습니다.

이렇게 alias를 이용해서 휴지통 만들기 포스팅은 여기까지 해보겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

데이터베이스 쿼리를 실행하면 내부적으로는 어떤 일이 벌어질까?

오랜만에 포스팅을 쓰는 것 같습니다. 거의 열흘만인 것 같은데 요즘 쿠버네티스를 공부하느라고 실습을 위주로 공부하고 있느라 공부할 시간이 마땅히 나지 않았네요...

이번 포스팅은 평소에도 너무나도 궁금했던 데이터베이스의 연산과 그 속사정입니다. 이번 포스팅에선 OS레벨에서의 속사정과 데이터베이스 내부의 속사정 두가지 관점에서 톺아보도록 하겠습니다.

쿼리를 실행하면 OS레벨에선 어떤 일이 벌어질까?

우리는 아래와 같은 쿼리를 날렸습니다.

SELECT * FROM USER u LEFT JOIN ORDER o ON u.user_id = o.user_id WHERE u.user_id = 20;

그럼 데이터가 어떻게 우리 눈에 보일 수 있게 될까요?

이 질문이 머리속에서 떠오르고 난 뒤 곰곰히 생각해봤을 때 정말 알 수 없었습니다.

"그러게..? 어떻게 가져오는거지?"

데이터베이스는 한낱 소프트웨어... 하지만 실제 저장되어 있는 데이터는 하드웨어에 저장되어있을 터.. 분명 OS랑 짜고치고 내가 모르는 일이 뒤에서 벌어진다고 생각했죠.

이 부분을 깊이있게 공부해봤습니다.

1. System Call 호출

소프트웨어인 데이터베이스는 컴퓨터의 하드웨어에 접근하기 위해 System Call을 호출합니다. 그럼 이 System Call이라는 놈을 분석해볼 필요가 있죠.

System Call이란?

System Call은 일반적인 방법으로는 접근할 수 없는 OS 자원들 (하드웨어나 파일 시스템 등) 에 접근하는 방법을 제공해줍니다. 듣기에도 매우 강력한 기능이기에 보안과 안정성이 매우 중요한데요. 이 부분은 뒤에서 더 자세히 다루도록 하겠습니다.

System Call은 애플리케이션이 로우 레벨 동작들 (예를 들면 파일 접근, 프로세스 할당, 메모리 할당 등) 을 하드웨어와 직접적인 소통하지 않고 추상화한 계층입니다.

System Call의 동작 원리

System Call이 호출되면 CPU를 OS의 유저모드에서 커널모드로 변경시킵니다. 이 전환이 필요한 이유는 본질적으로 프로그램에게 시스템 자원에 접근할 수 있는 권한을 부여하기 위해서입니다.

CPU가 커널모드에 들어가게 되면 이제부터 시스템 자원에 접근할 수 있게 되는데 첫 번째로 System Call 테이블에 접근해서 어떤 시스템과 연결되어야 하는지 확인하고 그에 상응하는 핸들러를 호출하게 됩니다. 이 작업을 통해 시스템의 자원에 본격적으로 접근할 수 있게 됩니다.

System Call에 대한 요청이 모두 마무리 되고 나면 CPU를 다시 유저모드로 변경해서 다시 원래 상태로 돌려보냅니다.

System Call의 존재의의

왜 OS는 애플리케이션이 시스템 자원에 직접적으로 접근하는 것 대신 System Call이라는 중간 단계를 넣어놓은 것일까요?

이에는 두가지 이유가 있습니다.

첫 번째로, 이 처럼 시스템 자원에 접근해서 실행하는 동작들은 로우레벨의 동작들이기 때문에 개발자가 애플리케이션을 개발함에 있어서 큰 난관이 될 수 있습니다. 때문에 추상화를 통해 애플리케이션이 OS에 접근해서 시스템 자원을 사용해야하는 경우 쉽게 시스템 자원에 접근할 수 있도록 하기 위함입니다.

두 번째 이유가 제일 중요한데요, 애플리케이션이 민감한 자원에 접근할 때 (메모리에 접근하는 등) 절대 실수하면 안되기 때문입니다. 특히 메모리의 경우 애플리케이션은 각각 다른 메모리 자원에 접근할 수 없고 시스템의 안정성과 보안을 위해 직접적으로 하드웨어를 조작할 수 없기 때문입니다.

때문에 OS 커널은 권한이 다른 두가지 모드 (유저모드, 커널모드) 를 사용함으로써 System Call이 시스템의 민감한 자원에 접근하고 통제할 수 있도록 비인가 접근을 막는 것입니다.

System Call을 사용할 때의 고려사항

각각의 System Call은 유저모드와 커널모드를 오가면서 컨텍스트 스위칭 비용이 발생하게 됩니다. 이것은 오버헤드를 불러일으키고 이를 최소화하기 위해서는 최소한의 System Call만을 호출해야합니다.

또한, 컨텍스트 스위칭 비용 뿐만 아니라 System Call 그 자체에도 오버헤드가 존재하기 때문에 System Call을 남발하면 프로그램 성능에 악영향을 미칠 수 있습니다.

뒤에서 언급할 내용이지만 System Call이 이런 오버헤드를 가지고 있기 때문에 프로그램의 성능을 위해서는 반드시 최소한의 System Call을 요구하고 이를 위해 데이터베이스는 다양한 장치들을 두고 있습니다.

특히 데이터베이스에서 가장 많이 호출되는 System Call로 예상되는 read()와 write() (읽기와 쓰기) 를 최소화하는 것이 데이터베이스의 성능에 직접적인 영향을 주는 것이죠.

이 내용에 대해서는 조금 뒤 언급할 데이터베이스의 속사정에서 확인해보도록 하겠습니다.

2. VFS 계층

VFS는 Virtual File System의 약자로서 OS와 파일시스템 사이에서 애플리케이션이 다양한 파일시스템들을 사용할 때 스토리지 타입의 변경 없이 파일시스템을 동작할 수 있도록 추상화한 계층입니다.

만약 애플리케이션이 System Call을 호출하면 VFS는 이 System Call을 중간에서 인터셉트하고 사용하려는 파일시스템에 일치하는 동작으로 재해석해서 파일시스템에 접근합니다.

VFS를 지나면 파일시스템에 접근해서 디스크에 적혀있는 데이터 덩어리의 정확한 위치와 파일시스템 (리눅스의 경우 inodes, 윈도우의 경우 file allocation tables) 을 매핑하면서 파일에 저장되어있는 위치를 가져옵니다.

3. I/O 스케줄러

VFS를 지난 데이터베이스 쿼리는 OS에 의해 디스크 I/O를 최적화하기 위해 스케줄러를 편성시키는데 이 스케줄러는 스토리지 디바이스 (HDD나 SSD) 에 효율적으로 접근할 수 있도록 도와줍니다.

예를 들어서 HDD의 경우 헤드가 움직이면서 데이터를 가져와야 하기 때문에 서로 멀리 떨어져 있는 데이터를 가져오기 위해 많은 물리적인 이동이 있어야하고 이 때문에 성능이 느려지기도 합니다. (이렇게 헤드가 움직이면서 파일시스템의 read/write를 실행시키는 것을 seek라고 합니다.)

이런 성능 이슈를 해결하기 위해 스케줄러는 물리적으로 가까이 붙어있는 데이터를 뭉탱이로 가져오는 것이 성능상 이점이기에 가까이 붙어있는 데이터를 위주로 갖오게 됩니다.

하지만 SSD의 경우 전기신호로 데이터를 가져오기 때문에 seek에 해당하는 움직임이 없어 이런 스케줄러가 필요없을 것 같지만 SSD에서도 성능이 향상되는 부분이 있다고는 하네요.

4. 스토리지 디바이스에 접근

스케줄러는 스케줄링을 통해 스토리지 디바이스에 접근하고 최종적으로 HDD/SSD에 접근하게 됩니다. 만약 데이터가 파일시스템의 캐시에 존재하지 않는다면 OS는 스토리지 컨트롤러에게 물리적인 저장소로부터 데이터를 가져오라고 명령합니다.

이 때 HDD와 SSD의 동작이 조금 다릅니다.

HDD : HDD의 경우 스토리지 컨트롤러가 디스크의 헤드를 움직여서 실제 데이터가 위치한 곳으로 이동하도록 하고 버퍼에 이 데이터를 적재합니다. 여기서 버퍼란 애플리케이션에 의해 접근되는 디스크의 데이터를 OS가 임시로 저장해놓는 공간입니다.

SSD : SSD의 경우 스토리지 컨트롤러에게 정확한 셀의 위치로 접근하게 하는 전기적인 신호를 보내고 이 데이터를 버퍼에 담아냅니다.

이 과정 속에서 디스크와 메모리 (버퍼) 사이에서 데이터를 전송하는 것은 Direct Memory Access에 의해 관리되는데 이 DMA라는 것은 데이터를 가져올 때마다 CPU에게 일일히 명령받지 않아도 효율적으로 데이터를 가져올 수 있도록 해주는 개념입니다.

5. 데이터베이스에게 전달

이렇게 버퍼에 담겨져 있는 데이터를 OS는 데이터베이스에게 System Call을 이용해서 건네줍니다. 만약 데이터가 한번에 전달하기에 너무 큰 경우 OS는 모든 요청된 데이터를 전달해주기 위해 여러번 읽기 작업을 진행하기도 합니다.

데이터를 데이터베이스에게 건네준 뒤로는 데이터베이스 엔진이 where 조건이나 join 연산등을 실행하게 되죠.

여기까지가 OS레벨에서의 데이터베이스 쿼리 실행이었습니다. 이제 이 가져온 데이터를 어떻게 데이터베이스가 활용하는지 정리해봤습니다!

쿼리를 실행하면 데이터베이스 내부에선 어떤 일이 벌어질까?

0. 사전지식

• Page (페이지) : 페이지는 데이터베이스의 데이터가 행단위로 저장되어 있는 데이터베이스의 가장 작은 단위입니다. 보통 16KB로 되어있습니다.

• Buffer Pool : 자주 사용되는 쿼리를 메모리에 올려두고 캐시처럼 사용하는 공간입니다.

1. Buffer Pool 확인

쿼리가 실행되고 데이터가 이미 버퍼에 담겨져 있는 것인지 확인하는 작업이 들어갑니다. 만약 이미 버퍼가 가지고 있는 데이터라면 그 정보를 바로 메모리에서 가져와서 성능적으로 이점을 볼 수 있습니다.

2. 쿼리에 인덱스가 포함되어 있는지 확인

만약 쿼리에 인덱스가 포함되어 있다면 스토리지 엔진은 연관된 데이터 페이지를 통해서 찾아냅니다. 이 작업은 디스크 I/O를 최소한으로 실행하여 성능을 최적화시킵니다.

인덱스 알고리즘으로 B- tree나 hash index와 같은 알고리즘이 사용되어 불필요한 데이터를 조회하기 않고 효율적으로 데이터를 조회할 수 있게 됩니다.

3. 행 단위로 락을 걸고 동시성 컨트롤을 진행한다.

여러개의 쿼리가 동시에 데이터를 요청한다면 스토리지 엔진은 행에 락을 걸어서 동시성을 보장합니다. 동시성 컨트롤의 대표적인 방법론은 MVCC로 스토리지 엔진별 다양한 방법으로 MVCC를 구현하고 있습니다.

MySQL의 InnoDB 스토리지 엔진은 Undo 테이블을 이용해서 MVCC를 구현하였고 PostgreSQL은 Xmin (가장 최근에 접근한 트랜잭션 ID), Xmax (가장 마지막에 접근한 트랜잭션 ID) 를 이용해 Vaccum 이라는 개념으로 MVCC를 구현했습니다.

이 MVCC에 대한 자세한 내용은 아래의 링크에 잘 정리되어있습니다!

https://coding-review.tistory.com/301

또한, MySQL의 경우 gap lock과 record lock을 조합한 next key lock을 이용해서 행간 락을 이용해 동시성 컨트롤을 진행합니다.

4. 데이터베이스 조회 및 필터링

스토리지 엔진이 연관된 데이터 페이지를 찾으면 그 행에 매칭되는 데이터를 가져옵니다. 만약 쿼리가 where 조건을 포함하고 있다면 스토리지 엔진은 초기 데이터에서 필터링을 거쳐서 데이터를 리턴해줍니다.

5. 트랜잭션 로그

만약 쿼리가 트랜잭션을 포함하고 있다면 스토리지 엔진은 먼저 메모리에 있는 데이터를 업데이트하고 recovery 프로세스를 위해 redo 로그를 작성합니다.

redo 로그는 데이터 정합성을 지키기 위해 만약 트랜잭션 두개가 충돌하는 경우 재실행하기위해 사용됩니다. 이와 비슷하게 undo 로그는 롤백과 MVCC를 위해 데이터를 스냅샷으로 찍어두고 유지, 관리합니다.

즉, redo로그는 트랜잭션이 충돌하는 경우 재실행하기 위해, undo로그는 트랜잭션이 충돌하는 경우 롤백을 위해 사용되는 로그입니다.

6. 디스크에 데이터 쓰기

만약 쿼리가 쓰기 연산이라면 메모리에 적혀있는 데이터 (반영되기 전의 데이터 : Buffer Pool에 있는 데이터) 는 곧장 디스크에 데이터를 쓰지 않습니다.

스토리지 엔진은 디스크 I/O를 최소화하여 성능을 향상하기 위해 텀을 두고 flush연산을 진행합니다.

이 과정을 체크포인팅 연산이라고 하며 체크포닝트를 두고 이 텀동안은 디스크에 쓰기 작업을 진행하지 않습니다. 이 체크포인터는 트랜잭션 롤백 상황에서 롤백을 위해 사용되기도 합니다.

체크포인터에 대한 자세한 설명은 아래의 링크에 정리되어 있습니다!

https://coding-review.tistory.com/539

마치며

이번 포스팅은 데이터베이스 쿼리가 실행될 때 OS레벨에서 어떤 일이 벌어지는지 궁금해서 공부하고 정리한 포스팅입니다.

이로 인해 데이터베이스가 저도 모르게 뒤에서 무슨 짓을 하고 있는지 속 시원하게 까발려졌습니다. 데이터베이스 쿼리를 날릴 때마다 오늘 공부한 내용을 상기시키면서 날려야겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

리눅스 기본 명령어 동작 원리 : 디렉토리 구조편 (cd, ls, rm, mv, cp)

이번 포스팅은 이전 포스팅에서 영감을 얻었습니다.

https://coding-review.tistory.com/563

제 공부 방향은 항상 문득 떠오르는 주제이거나 이전 주제에서 깊이있게 공부해볼만한 주제를 따로 선정해서 더 공부하는 편입니다. 이전에 데이터베이스 쿼리를 실행하면 OS레벨에서 어떤 일이 벌어지는지 공부했는데 이때 System Call이나 파일시스템에 대해서 많은 공부가 되었고 이 내용을 잘 활용하면 기본 명령어도 설명할 수 있겠다 싶어서 공부해봤습니다.

이번 포스팅에선 기본 명령어 동작 원리, 그 중에서도 디렉토리 구조와 관련된 기본 명령어 cd, ls, rm, mv, cp에 대해서 공부해보고 정리한 내용을 다루려합니다.

리눅스와 디렉토리 구조

리눅스는 참 재밌는 디렉토리 구조를 가지고 있는데 inode와 directory entry (이하 디렉토리 엔트리) 라는 것을 이용합니다.

• inode : inode는 파일의 메타데이터를 저장하고 있는 노드로서 파일 이름, 권한, 생성일 등이 적혀있습니다.

• directory entry : 우리가 조작하려는 파일이 어떤 디렉토리에 속해있는지 적혀있습니다.

inode야 그렇다쳐도 디렉토리 엔트리는 어디에 쓰는 것일까요?

예를 들어서 한가지 상황을 가정해보겠습니다.

만약 A라는 파일이 /pollpoll 이라는 디렉토리에 존재하고 있다면 cd pollpoll을 입력하는 순간 현재 디렉토리 엔트리를 /pollpoll로 바꿔서 그 곳을 바라보게 됩니다. 이 상태에서 ls 명령어를 입력한다면 /pollpoll 의 디렉토리 엔트리를 가지고 있는 목록을 보여주게됩니다.

즉, 파일과 디렉토리 엔트리가 서로 매핑되어 있다는 것이죠. 이렇게 하면 무슨 이점이 있을까요?

바로 파일의 직접적인 접근을 최소화할 수 있습니다.

직접 파일을 조작해야하는 rm, cp와 같은 명령어를 제외하면 실제 데이터를 조작하지 않고도 파일을 변경할 수 있는 것이죠.

여기서 눈치채신 분도 있으시겠네요. 맞습니다. mv의 경우 디렉토리 엔트리만 변경해 실제 데이터의 이동 없이 디렉토리를 변경할 수 있습니다. 이렇게 함으로써 데이터의 실제 이동 없이 마빡에 붙어있는 자신의 위치만 변경해주게 됩니다.

사실 mv를 쓰면서도 굉장히 빠른 성능에 의아했는데 윈도우를 생각해보면 대용량 파일의 디렉토리 이동은 거의 재앙과 같기에 의아했었지만 이런 숨겨진 동작이 있었을줄은 몰랐습니다. 이 부분은 조금 뒤에 더 자세히 풀어보겠습니다.

리눅스 기본 명령어 동작 방식

cd

리눅스 터미널에서 cd명령어가 입력되면 리눅스 커널은 chdir()이라는 System Call을 호출하는데 이름에서부터 알 수 있듯이 디렉토리 엔트리를 변경하는 System Call입니다.

System Call에 대한 자세한 내용은 아래의 링크에 자세히 설명되어있으니 한번 들러보시는 것도 좋을 것 같습니다!

https://coding-review.tistory.com/563

System Call과 함께 리눅스 커널은 CPU를 커널모드로 진입시키고 커널모드 상태에선 시스템 자원에 접근할 수 있는데 이 때 프로세스에 접근합니다. 커널모드에서 현재 이 디렉토리에 접근해도 되는 사용자인지 권한을 체크하고 만약 허가되면 프로세스의 타겟을 현재 디렉토리로 변경합니다.

프로세스가 변경되면서 프로세스의 메타데이터도 업데이트하게 되고 이 때 프로세스가 타겟을 현재 디렉토리로 변경하지만 실제 디스크에 존재하는 파일이 변경되거나 하진 않습니다.

즉, 정말 디렉토리 엔트리만 변경하는 것이죠.

ls

ls 명령어가 입력되면 open()과 getdents()라는 System Call을 호출합니다. 이 순간 cd와 마찬가지로 커널모드로 진입하고 커널은 현재 파일의 inodes를 읽습니다.

리눅스 커널은 커널모드로 진입하면서 디렉토리의 내용물들을 조회하고 이 때 시스템 자원인 파일시스템의 메타데이터에서 디렉토리 엔트리를 읽어옵니다.

디렉토리 엔트리를 가져오고 나서 커널은 디렉토리에 있는 정보들을 사용자 화면에 뿌려주기 위해 유저모드로 전환합니다. 이 때 유저가 -l이나 -a같은 옵션을 넣었다면 그것에 맞는 데이터를 보여줍니다.

ls의 재밌는 특징 중 하나는 사용자가 여러번 ls 명령어를 썼을 때를 대비해 현재 디렉토리 엔트리를 캐싱해놓습니다. 개발자들이 습관적으로 ls를 입력한다는 사실을 아는 것 같은 재밌는 특징이네요.

rm

rm 명령어를 사용하면 unlink()라는 System Call을 호출합니다. 해당 System Call이 호출되면 서로 연결되어있던 파일과 디렉토리 엔트리의 관계가 끊어지게 되고 inode도 이때 삭제해버립니다.

inode가 삭제되면서 커널은 해당 파일에 "지워짐" 이라는 마크를 달아놓습니다.

커널은 이후 파일시스템의 메타데이터를 업데이트하면서 해당 파일을 완벽하게 지울 준비를 합니다. 또한, 캐시를 즉시 업데이트 파일을 조회할 수 없게 바꿔버리는데 실제 파일을 디스크에서 삭제하는 것 보다 캐시를 먼저 삭제하는 것은 혹시라도 파일이 삭제되는 과정에서 조회하는 경우 파일이 보이는 것을 방지하기 위함입니다.

캐시를 삭제한 뒤 파일시스템에 접근하고 CPU는 Storage Controller에게 실제 데이터를 지우라고 명령합니다. 그렇게 되면 HDD의 경우 스토리지 컨트롤러에 의해 헤드가 물리적으로 이동하게 되고 실제 데이터가 있는 곳을 찾아가 파일을 지워버립니다. SSD의 경우 해당 파일을 지우러나느 전기신호를 받고 파일을 스토리지 디바이스에서 지워버립니다.

mv

mv 명령어를 실행하면 rename()이라는 System Call을 호출하는데 이 rename()이라는 System Call은 굉장히 중의적입니다.

실제로 mv 명령어를 사용하는 경우는 파일의 이름을 변경할 때나 파일의 위치를 변경하는 경우인데 이 경우 둘 다 rename이라는 이름이 잘 어울리기 때문이죠.

먼저 파일의 이름을 변경하는 경우 커널이 rename()을 호출함과 동시에 커널모드로 진입하고 파일시스템에 접근해 메타데이터에 존재하는 파일 이름을 변경해버립니다. 진짜 파일의 이름을 변경하는 것이기에 rename이라는 이름이 잘 어룰리죠.

또한, 파일의 위치가 변경되는 경우 마찬가지로 커널모드로 진입해 파일시스템의 메타데이터에서 디렉토리 엔트리를 읽어와 디렉토리 엔트리를 변경해버립니다. 디렉토리 엔트리를 바꾸는 것이기에 이 것 또한 잘 어울리네요.

mv의 특이한 점이라면 파일의 실제 변경 없이 이루어지기 때문에 성능상 엄청난 이점을 가지고 있다는 것이죠.

cp

cp 명령어는 꽤나 많은 System Call이 호출됩니다.

• open() : 파일을 여는 System Call입니다.

• read() : 복제할 파일의 바이트를 읽어오고 이후 write()를 호출합니다.

• write() : 명령어에 적힌 디렉토리 파일로 파일을 씁니다. read()와 write()는 짝을 이루어 실행된다는 특징이 있습니다.

• chmod() : 파일이 복제되고 난 뒤 파일의 권한도 복제하기 위해 chmod()를 호출합니다. 이 System Call에 대해서는 다음 포스팅에서 자세히 다룰 예정입니다.

• utime() or utimes() : 파일의 메타데이터에 생성일을 적기 위해 필요한 System Call인 utime혹은 utimes를 호출합니다.

• close() : open()을 이용해서 파일을 열 때 가져왔던 자원들을 다시 시스템에게 돌려주기위해 close()를 호출합니다.

마치며

이번 포스팅에선 리눅스의 기본 명령어인 cd, ls, rm, mv, cp가 어떻게 커널에서 실행되는지 알아봤습니다. 30년 전 고대 개발자들이 쌓아놓은 결과물들을 보자니 정말 입이 떡벌어지는 것 같네요.

이 내용을 공부하기 위해서 찾아보다가 흥미로운 내용이 있었는데요 1997년 토르발드에 의해 만들어진 리눅스는 초기엔 1만줄정도의 코드를 가지고 있었다가 현재는 330만줄에 달하는 엄청난 규모로 성장했다고 합니다.

이렇게 성장하고 업계 표준이 될 수 있었던 이유 중 가장 큰 이유로 오픈소스를 꼽는데요. 지금의 AI산업에서 후에 Meta의 라마가 30년 뒤 업계 표준이 될지 한번 지켜보도록 하겠습니다.

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

리눅스의 파일 입출력 (feat. 파일시스템)

이번 포스팅에선 리눅스가 어떻게 파일을 읽고 쓰는지에 대해서 공부해본 내용을 정리하려합니다.

요즘 리눅스 커널에 대해서 공부하고있는데요. 아직 배경지식이 전무하다시피해서 공부하는데 애먹고있습니다. 바로 시작해보죠!

cf) 이 포스팅 전 읽고 오시면 좋은 글이 있습니다! 파일시스템에 대한 간략한 정보들이 정리되어있습니다.

https://coding-review.tistory.com/564

파일 입력

0. 사전 지식

• directory entry : 하나의 디렉토리안에 있는 파일이름, inode 번호같은 간단한 정보들이 적혀있는 메타데이터입니다.

• inode : 파일에 대한 조금 더 자세한 정보가 적혀있는 노드 (얘도 결국 메타데이터입니다) 입니다.

• data block : 데이터들이 저장되어있는 공간입니다. 이 공간을 이용해서 데이터를 이곳저곳 나르기도 하고 데이터들이 담겨서 이동하는 박스라고 생각하시면 됩니다.

1. 명령어 입력

다음과 같은 명령어가 입력되었다고 가정해보겠습니다.

$ echo "hello world" > hello.txt

이렇게 입력되는 순간 리눅스 커널은 System Call을 호출하는데 open()과 write()를 순서대로 호출합니다.

open()이 직관적으로는 파일을 연다는 느낌인데 파일을 저장할 때는 여는 것이 하나도 없어서 저는 그냥 파일시스템의 자원을 획득한다고 이해하고 있습니다. 이게 만약 잘못된 이해라면 댓글로 알려주시면 감사하겠습니다.

그렇게 이해하게된 이유가 파일을 출력할 때 read()라는 System Call을 호출하는데 그 전에도 open()을 호출하더라구요. 그리고 항상 파일시스템의 자원을 돌려주는 역할인 close()를 호출하는 것을 보면 자바에서도 파일 입출력을 할 때 스트림을 열고 닫는 것과 비슷해서 그렇게 이해했습니다.

아무튼!

System Call이 호출되면 커널 모드로 진입하게 되고 커널 모드에서 시스템 자원에 접근하게 됩니다. 이 때 시스템 자원인 파일시스템에 접근하게 되는데요, 파일 시스템은 먼저 프로세스에 접근해 현재 프로세스가 어떤 디렉토리에 들어와있는지 확인합니다.

2. directory entry, inode 생성

현재 디렉토리에 있는 directory entry에 지금 저장할 파일에 대한 directory entry를 적어놓습니다.

directory entry에는 파일에 대한 간략한 정보들이 적혀있는데요. 파일 이름이나 inode의 번호등이 적혀있습니다. inode의 번호는 어떤 inode를 바라보면 좋을지 포인터 형태로 되어있습니다.

directory entry는 간단하게 얘기하자면 inode들이 모여있는 배열이라고 할 수 있겠네요. 물론 진짜 배열인지는 모르겠지만요...

지금 저장하려는 파일의 이름을 적고 새로운 inode 번호를 부여받습니다. 그리고 이후 inode에 정보를 작성하죠.

inode에는 권한, 타임스탬프, UID, GID, 파일 크기 등등 파일에 대한 자세한 정보가 적혀있습니다. 이런 메타데이터들을 먼저 저장하고 실제 데이터를 저장할 준비를 합니다.

3. data block 생성 그리고 저장

파일시스템은 hello.txt의 실제 데이터인 "hello world"를 저장하기 위해 파일 크기만큼의 데이터 블록을 할당시켜놓습니다. 이렇게 할당함과 동시에 inode는 hello.txt의 실제 데이터가 저장될 공간을 포인터로 가리킵니다.

이후 hello.txt의 실제 데이터들이 데이터 블록에 담겨서 메모리에게 전달됩니다. 그리고 메모리는 이 데이터 블록에 담긴 실제 데이터를 그대로 스토리지 디바이스로 가져갑니다.

파일시스템이 storage controller를 호출해서 물리적으로 저장할 공간을 찾아 그곳에 실제 데이터를 저장합니다.

4. 사후처리

이후 inode에 최종적으로 저장된 파일 크기, 변경된 타임스탬프 등을 업데이트합니다.

또한, 파일시스템에 캐시를 업데이트해서 파일시스템이 관리하는 파일이라는 것을 명시하죠. 이 작업이 끝나고 나서야 우리 눈에 ls 명령어로 파일이 보이게 됩니다.

파일 출력

단순히 ls 명령어를 입력한다고 해서 파일이 출력되는 것은 아닙니다.

ls 명령어는 단지 현재 디렉토리의 directory entry를 가져와서 파일 이름만 뿌려줄 뿐이죠. 그럼 실제 데이터를 가져오는 명령어는 뭐가 있을까요?

vim을 이용해서 파일을 여는 작업이 실제 데이터를 가져오는 명령어입니다.

우리는 vim 명령어를 날려보고 이 vim 명령이 어떻게 파일을 읽는지 알아보려합니다.

1. 명령어 입력

만약 이런 명령어를 날린다고 가정해보겠습니다.

$ vim hello.txt

그럼 리눅스 커널은 open(), read()라는 System Call을 호출합니다. 그럼 이후 커널모드로 진입해서 실제 데이터를 가져오기위한 작업을 진행합니다.

2. 파일시스템 호출

커널모드로 진입한 프로세스는 파일시스템에 접근해서 해당 파일의 directory entry 안에 존재하는 파일 이름과 inode 번호를 조회합니다.

그리고 파일 이름과 매칭되는 inode 번호를 찾아서 inode를 바라보죠. 그럼 거기에 각종 메타데이터들이 적혀있는데 그 중에서 실제 데이터가 위치한 곳을 가리키는 포인터를 찾아냅니다.

이 포인터를 이용해서 실제 데이터가 어디에 위치하고 있는지 알 수 있는데요. 즉 리눅스는 이런 구조를 가지고 있는 것이죠.

그럼 실제 데이터가 어딨는지 아니까 그 정보를 그대로 I/O Scheduler에게 전달합니다.

3. I/O Scheduler 호출

I/O Scheduler는 스토리지 디바이스에서 파일을 직접 가져올 때 사용됩니다. 파일시스템에게 전달받은 실제 데이터의 위치를 이용해서 storage controller를 호출하기 때문이죠.

만약 데이터베이스와 같이 많은 양의 데이터를 가져와야할 때는 스케줄러가 큰 의미가 있지만 단순 파일 입출력에서의 스케줄러는 그냥 storage controller에게 명령하는 존재일 뿐이죠.

스케줄러가 storage controller에게 데이터를 가져오라는 명령을 주면 storage controller는 HDD의 경우 헤드를 물리적으로 움직이는 seek를, SSD의 경우 실제 데이터를 가져오는 전기적 신호를 전달합니다.

4. 메모리에 적재 후 전달

스토리지 디바이스와 메모리 사이에는 DMA라는 친구가 숨어있습니다. Direct Memory Accessd의 약자로 이름에서 유추할 수 있듯이 스토리지 디바이스가 직접 메모리에 접근할 수 있도록 도와주는 친구입니다.

이 DMA의 존재 덕분에 CPU가 일일히 스토리지와 메모리를 컨트롤하지 않아도 되기 때문에 성능이 최적화되어 있습니다.

storage controller가 가져온 데이터를 메모리에 적재하고 이 메모리에 적재된 데이터를 커널에게 전달합니다. 그럼 이 커널은 커널모드에서 유저모드로 전환한 뒤 우리에게 실제 데이터를 보여주는 것이죠.

마치며

이번 포스팅에선 리눅스의 파일 입출력이 어떤 과정을 가지고 있는지 공부해보고 정리해봤습니다. 리눅스 커널에 대한 공부를 하면 할 수록 컴퓨터를 더 잘 이해하게 되는 것 같아서 뿌듯하고 좋네요.

항상 공부를 할 때 느끼는 벅차오르는 뿌듯함이 저로하여금 계속 공부할 수 있는 원동력을 주는 것 같네요. 이런 변태같은 지식들이 저를 더 뿌듯하게 만드니 더욱 더 공부해야겠다는 생각입니다.

여기까지 포스팅 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

TCP 프로토콜을 OS레벨에서 뜯어보자

TCP프로토콜은 전세계적으로 가장 많이 사용되는 프로토콜 중 하나인데요. 이 TCP프로토콜이 OS레벨에서는 어떻게 움직이는지 살짝 궁금해져서 공부해보고 정리해봤습니다!

OS레벨에서 개요를 살펴보고 여기에 Nginx, Tomcat이 들어가고 데이터베이스가 어떻게 데이터를 가져오는지 흐름에 대해서 정리해봤습니다.

해당 포스팅은 TCP/IP에 대한 내용이 생략되어있습니다. 아래의 링크에 부족하지만 어느정도 정리가 되어있으니 참고해주시면 감사하겠습니다.

https://coding-review.tistory.com/466

OS레벨에서 TCP

TCP를 연결하기 위해선 3 way handshake가 필요한데, 이 과정을 OS레벨에서 어떻게 이뤄냈는지에 대해서 정리해보겠습니다.

UNIX체계에서 socket

유닉스 체계 (편하게 리눅스라고 부르겠습니다) 에서는 모든 동작을 파일로 관리한다는 사실, 알고 계셨나요? 저도 이번에 공부하면서 알게 되었는데요.

이 파일들을 관리하기위해 file descriptor라는 것을 가지고 있으며 아마도 엄청나게 많을 것으로 예상되는 파일들을 관리하기 위해 epoll이라는 것도 가지고 있습니다. 이번 포스팅에선 자세히 다루지 않습니다.

그런데 윈도우도 그렇고 dll처럼 대부분 파일로 관리하지 않나? 라는 생각이 들었지만 다음 기회에 더 자세히 공부해보도록 하겠습니다.

아무튼 리눅스에선 네트워크 연결을 위한 소켓 또한 파일로 관리하고 있습니다. 이 파일을 가지고 네트워크 연결을 할 것인지 말것인지 충돌이 나면 어떻게 행동할 것인지 정의하고 있죠.

먼저 리눅스에 네트워크 요청이 들어오면 socket이라는 시스템 콜을 호출합니다.

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3

이 요청으로 하나의 소켓 파일이 만들어지는데 첫 번째 인자로 AF_INET은 해당 네트워크 요청이 IPv4로 들어왔다는 것을 알려주는 것이고 뒤이어 SOCK_STREAM은 TCP통신을 하겠다는 의미입니다.

| 기호 뒤에 있는 것들은 해당 파일이 어떤 방식으로 동작해야하는지 가이드라인을 주는 것입니다. 세 번째 인자로 IPPOROTO_IP라는 값을 넘겨주면 우리가 소켓 파일을 만들 때 정의했던 동작 방식에 따라 TCP프로토콜을 자동적으로 선택합니다.

그리고 유니크한 아이디를 부여받죠. 위의 예제에선 3이라는 값을 받았네요.

여담이지만 SOCK_STREAM대신 SOCK_DGRAM이라는 것을 넘겨주면 UDP통신을 하겠다는 것으로 간주하고 준비합니다.

뭐 다른건 다 필요없고 그냥 소켓이라는 파일을 하나 만들었으며 이게 TCP프로토콜을 자동으로 선택하도록 동작 방식을 설정했고 해당 파일에 유니크한 값인 3을 부여받았다는 것이 중요합니다. 인자는 이번 포스팅에서 중요하지 않습니다.

네트워크 드라이버와 바인드

소켓을 만들었다고 해서 바로 네트워크 연결이 이루어진 것은 아닙니다. 네트워크 드라이버와 소켓이 바인딩이 되어야 준비가 완료된 것인데요.

리눅스는 소켓 파일을 기반으로 아래의 시스템 콜을 호출하여 바인딩을 합니다.

bind(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("192.168.1.10")}, 16) = 0

굳이 해석하자면 IPv4로 80포트에 192.168.1.10인 IP를 바인딩하겠다는 의미입니다.

이 과정에서 성공했다면 0을, 실패했다면 소켓 아이디를 넘겨줍니다.

실패한 경우는 동시에 TCP요청을 한 경우가 대부분이고 보통 실패한 경우 소켓을 닫아줍니다. 이 때 close 시스템 콜이 호출됩니다.

close(3)

만약 성공했다면 이제 준비가 완료된 것입니다.

아직까지 네트워크를 받지 않은 상황이고 준비의 준비를 거쳐 준비가 완료되었다고 볼 수 있습니다.

네트워크 요청 허용

리눅스는 네트워크 드라이버와 소켓을 바인딩하고 이어서 직접 요청을 받을 준비를 진행합니다.

listen(3, 4096)= 0

epoll_ctl(5, EPOLL_CTL_ADD, 3, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data={u32=4271898626, u64=9220810217688662018}}) = 0

3번 소켓을 열어두고 epoll에게 해당 소켓을 관리하라고 명령을 내립니다.

그리고 해당 동작이 완료되면 본격적으로 네트워크 요청을 받을 수 있도록 허용해주는 작업이 들어갑니다.

accept4(3, {sa_family=AF_INET, sin_port=htons(55402), sin_addr=inet_addr("192.168.1.10")}, [112 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 4

epoll_ctl(5, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data={u32=4255645697, u64=9220810217672409089}}) = 0

setsockopt(4, SOL_TCP, TCP_NODELAY, [1], 4) = 0

setsockopt(4, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0

setsockopt(4, SOL_TCP, TCP_KEEPINTVL, [15], 4 <unfinished ...>

시스템 콜은 별로 중요하지 않습니다. 무슨소리인지 하나도 모르겠네요. 아무튼 우리는 accept를 한다는 것이 중요한 것입니다.

사용자의 요청

사용자는 GET혹은 POST와 같은 다양한 요청들을 진행합니다. 그 때 알맞는 데이터를 가져오게 됩니다.

만약 사용자가 GET요청을 했다면 다음과 같은 시스템 콜이 호출됩니다.

read(4, "GET / HTTP/1.1\r\nHost: 192.168.1.10\r\nUser-Agent: curl/7.87.0\r\nAccept: */*\r\n\r\n", 4096) = 76

이 read의 의미는 우리가 만든 소켓 파일을 읽겠다는 의미입니다. 그럼 write도 있겠네요. 이 둘은 쌍으로 붙어다니니까요.

write(4, "HTTP/1.1 200 OK\r\nDate: Tue, 26 Sep 2023 11:43:47 GMT\r\nContent-Length: 12\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello kitty!", 129 <unfinished ...>

근데.. 소켓 파일에서 뭘 읽고 뭘 쓴다는 걸까요? 파일이니까 뭔가 쓰여있긴 할텐데..

소켓파일을 읽을 때 다양한 메타데이터들이 적혀있는데요. 요청이 들어온 HTTP header같은 것들이 적혀있죠. 그리고 소켓 파일에 데이터를 쓸 때는 HTTP status나 HTTP response header, HTTP body 등을 파일에 적습니다.

연결 해제

만약 TCP연결이 끊어지면 close 시스템 콜을 호출하여 TCP연결을 해제합니다.

close(4)

이 때 살짝 궁금했던게 어떻게 TCP연결이 끊어진 것을 알 수 있을까? 싶었는데 OS내부적으로 설정해둔 시간이 있어서 이 시간이 지나면 저절로 close를 한다더군요.

아니면 개발자가 직접 TCP연결이 종료되었다고 시스템 콜을 호출해주는 경우도 있을 수 있겠죠.

위의 상황은 일반적인 상황이 아니다

우리가 GET, POST요청을 보통 어디에 보내는지 생각해보면 위의 상황은 잘 와닿지 않습니다.

우리는 보통 Nginx와 같은 Web Server에 요청을 보내고 그 Web Server가 WAS에 그 요청을 위임하고 WAS서버는 데이터베이스와 통신하면서 데이터를 가져다 줍니다.

이 과정을 위의 상황과 결합해서 다시 한 번 정리해보도록 하겠습니다.

• 사용자가 POST요청을 보내면서 데이터를 요청합니다.

• OS가 socket 시스템 콜로 소켓 파일을 만든다.

• Nginx가 bind 시스템 콜을 이용해서 소켓 파일을 바인딩한다. 동시에 listen 시스템 콜로 연결을 받을 준비를 하고 accpet 시스템 콜로 네트워크를 허용한다. 그리고 이후 과정을 WAS에 위임한다.

• 이 과정에서 소켓 파일에는 어떤 IP주소로 요청이 왔는지 어떤 포트로 요청이 왔는지 어떤 HTTP method가 요청됐는지에 대한 정보가 있습니다.

이 정보를 토대로 어떤 WAS에 가야하는지 어떤 포트로 가야하는지 어떤 URI로 매핑되어야 하는지 어떤 HTTP method인지 어떤 데이터가 넘어오는지를 확인합니다.

• 예를 들어서 localhost:8080에 POST요청이 들어왔다면 해당 정보를 매핑하고 데이터베이스와 연결을 시도합니다.

• 데이터베이스 엔진은 read() 시스템 콜을 호출합니다. 데이터베이스는 버퍼 풀에 데이터가 있는지 확인하고 없다면 VFS를 이용해서 파일 시스템에 접근합니다.

• VFS는 하드디스크에 접근하기 위해 I/O Scheduler를 이용해서 스케줄링을 하고 이를 스토리지 컨트롤러에 전달합니다.

• 스토리지 컨트롤러는 HDD의 경우 직접 헤드를 움직이고 SSD의 경우 데이터가 있는 위치를 가리키는 포인터를 이용해서 데이터를 가져오라는 전기적 신호를 보냅니다.

• MDA는 스토리지에 있는 데이터를 버퍼에 담아 메모리에 전달합니다.

• 메모리는 데이터베이스 버퍼 풀에 데이터를 담고 이를 WAS에 전달합니다.

• WAS에서 데이터 가공을 마치고 HTTP response header나 body를 작성한대로 리턴하게 되고 이 값을 소켓 파일에 적습니다.

• 이 값이 프론트로 넘어갑니다. 이하 생략..

이렇게 TCP 요청이 OS -> Web Server -> WAS -> DB의 흐름을 가지며 이동하게 됩니다.

마치며

이번 포스팅에서는 TCP프로토콜을 OS레벨에서 공부해보고 정리해봤습니다. 리눅스 커널은 정말 쉽지 않네요... 단순히 흐름만 따라가는 정도라 머리로는 이해하지만 가슴으로 와닿지는 않는 경우가 많은 것 같네요.

OS커널을 직접 뜯어보는 사람이 엄청나게 대단해보이는 오늘입니다...

이렇게 긴 포스팅을 마치도록 하겠습니다. 오늘도 즐거운 하루되세요!

출처

https://deploy.equinix.com/blog/tcp-and-the-os-kernel-networking-basics-for-developers/

CPU는 어떻게 여러가지 일을 한번에 처리할까?

최근 스레드 풀에 대해서 공부하기 전에 스레드가 어떻게 운영체제에서 관리되는지 공부하기 위해 찾아보다 "스레드 생성 비용과 스레드 풀과의 관계" 포스팅을 작성했던 기억이 있어서 다시 찾아보게 되었습니다.

PCB와 TCB에 대한 내용이었는데 겉핥기 식으로 이 둘에 대해 알고 있었다보니 이번 기회에 제대로 공부하자고 마음먹고 공부하게 되었습니다.

이번 포스팅은 PCB에 대한 내용이 주된 내용이지만 PCB와 연관이 깊은 컨텍스트 스위칭도 같이 정리해보도록 하겠습니다.

PCB (Process Control Block)

PCB는 운영체제가 프로세스를 관리하기위해 사용하는 자료구조입니다. 프로세스를 관리하기 위한 메타데이터가 저장되어 있고 주요 메타 데이터로는 PID, Program Counter, Register, 메모리 관련 정보, CPU 스케줄링 정보 등이 있습니다.

이번 섹션에선 Program Counter (이하 PC), Register (이하 레지스터)에 대한 내용에 대해서 깊이있게 공부한 내용을 정리해보았습니다.

PC (Program Counter)

PC는 프로세스가 다음에 실행해야할 명령어가 담긴 메모리 주소를 가지고 있습니다. 보통 PC는 차례대로 명령어를 실행하면서 값을 하나씩 증가시켜 다음 명령어를 순차적으로 실행하게 됩니다.

PC는 다음과 같은 동작 원리를 가지고 있습니다.

• CPU는 PC가 가지고 있는 다음 명령어가 담긴 메모리 주소를 가져옵니다.

• 명령어를 가져오자마자 PC의 값이 하나 증가합니다. 다음 명령어 주소를 가리킵니다.

• 가져온 명령어를 실행합니다.

만약 이 때 if 문 같은 분기가 생기게 되면 명령어가 순서대로 실행되지 않는데 if 문을 만나면 분기에 맞는 명령어 주소로 덮어 씌워 순차적인 명령어 실행을 건너 뜁니다.

Register

레지스터는 프로세스를 실행하기 위해 사용되는 재료같은 요소입니다.

예를 들어서 프로세스가 과제라면 레지스터는 그 과제를 수행하는데 필요한 재료입니다. 수학 과제라면 계산기, 노트, 펜이 될 것이고 과학 과제라면 현미경, 돋보기, 플라스크 같은 것이 될 것이죠.

때문에 레지스터는 프로세스에 종속적이고 프로세스와 레지스터가 일치하지 않는다면 프로세스에 오류가 발생하게 됩니다.

수학 과제를 풀려는데 현미경, 돋보기, 플라스크가 눈 앞에 있으면 수학 과제를 하지 못하는 것과 마찬가지이죠.

PC가 분기를 만나면 다음 실행할 명령어가 아닌 분기에 맞는 명령어 주소로 덮어 씌워진다고 했는데 현실에서는 분기 뿐만 아니라 프로세스가 변경되는 상황에서도 다음 명령어가 순차적이지 않습니다.

네, 바로 컨텍스트 스위칭 상황입니다.

컨텍스트 스위칭

컨텍스트 스위칭은 운영체제가 프로세스 여러개를 동시에 실행하는 것처럼 보이게 하기 위해서 프로세스를 번갈아가면서 실행하는 것을 의미합니다.

동시에 실행시키는 것처럼 보이게 한다는 의미는 실제로는 동시에 실행되는게 아니라는 말입니다.

보통 10~100ms 정도의 텀을 두고 프로세스를 교체하는데 요즘 리눅스는 우선순위에 따라 우선순위가 높은 프로세스를 먼저 실행시키는 스케줄러를 가지고 있기도 합니다. 이 내용은 너무 딥한 내용이라 겉핥기만 공부했기 때문에 포스팅에 작성하진 않겠습니다.

프로세스를 스위칭하는 과정에서 각기 다른 컨텍스트를 유지하면서 스위칭하려면 해당 프로세스에 대한 문맥이 완벽하게 관리가 되어야하고 이 때 PCB의 위력이 발휘됩니다.

PCB에는 프로그램 카운터와 레지스터가 있으니 컨텍스트 스위칭을 유려하게 관리하게 되는데 지금부터 PCB가 컨텍스트 스위칭을 어떻게 도와주는지 정리해보겠습니다.

• 프로세스 A가 실행되다가 A가 점유한 시간이 다 되었습니다.

• CPU가 빠르게 현재의 프로세스가 가지고 있던 레지스터를 A의 PCB에 레지스터를 복제하고 다음 실행할 명령어에 대한 주소를 PC에 저장합니다.

• 프로세스 B가 실행되기 위해서 B의 PCB에 넣어놨던 레지스터를 CPU 레지스터를 싹 정리한 뒤에 오버라이트합니다.

• CPU가 B의 PCB 안에 PC에 적힌 명령어 주소를 가져와 실행합니다.

그런데 저는 이 내용을 공부할 때 이런 의문이 들더군요. CPU의 레지스터는 프로세스의 레지스터를 위한 발사대인가? 라는 생각이 들더군요.

이걸 앞선 예시를 이용해서 설명하면 다음과 같습니다.

CPU는 책상 프로세스는 과제, 레지스터는 재료였죠. 책상 위에서 수학 과제를 할 땐 계산기, 노트, 펜을 이용해서 과제를 하다가 갑자기 과학 과제를 해야하면 어떻게 될까요? 그럼 책상 위에 있는 모든 재료들을 싹 정리하고 현미경, 돋보기, 플라스크를 가져와야죠.

CPU의 공간은 하나밖에 없기 때문에 모든 프로세스들이 이 한 공간을 동시에 사용해야하다보니 책상이 비워지고 다른 재료를 가져오는게 합리적이죠.

번외 (JVM의 PC Register)

JVM을 공부하다보면 JVM 메모리 영역에 PC Register라는 것이 있습니다. 얘도 네이밍을 풀어보면 Program Counter Register이기 때문에 분명 연관이 있을 것이라고 생각했습니다.

JVM은 자바 코드로 되어있는 것들을 클래스 파일로 컴파일해서 바이트코드로 변환하는데 JVM은 이 때 내가 다음에 실행시켜야할 바이트코드를 적어놓습니다.

이 때의 개념이 PCB에서의 Program Counter와 일맥상통합니다.

하지만 JVM 또한 운영체제 입장에서 실행해야할 프로세스입니다. 즉, 이중 구조로 되는 것이죠.

운영체제는 JVM을 관리하기위한 프로세스를 관리하고 JVM의 다음 명령을 위해 PC가 존재합니다. 이어서 JVM 내부에서도 바이트 코드를 실행하기 위한 PC가 존재하죠.

번외2 (Thread Control Block)

운영체제를 개발한 개발자들이 PCB가 마음에 들었는지 스레드를 관리할 때도 TCB라는 것을 이용해서 스레드를 관리하고 있습니다. 이에 대한 내용은 아래의 링크에 자세히 설명이 되어있으니 참고해주시면 감사하겠습니다.

https://coding-review.tistory.com/572

마치며

요즘 클라우드 자격증을 공부하느라 블로그 포스팅을 쓸 시간이 없었는데 이미 한달 가까이 안쓰게 되어서 이건 안되겠다 싶어 주말에라도 써야겠다는 생각을 했습니다.

다음 포스팅은 "스레드 풀은 어떻게 동작할까"에 대한 내용을 쓸 예정이니 많은 관심 부탁드리겠습니다.

이번 포스팅을 작성하기위해 공부했던 내용들이 운영체제를 공부했던 것 중에서 가장 재밌어서 공부하는데도 즐겁게 했던 것 같습니다.

이번 포스팅은 여기서 마치도록 하겠습니다. 오늘도 즐거운 하루 되세요!

공개키 암호화

이번 포스팅에서는 공개키 암호화에 대해서 알아보도록 하겠습니다. 공개키 암호화는 개인키 암호화와 묶여서 설명이 가능한데요. 보통 공개키와 개인키는 SSL handshake에서 사용됩니다. 아직 SSL handshake에 대한 포스팅이 없어서 자세히 설명은 못해드리지만 요약하자면 이겁니다.

"인증기관에 인증서를 보내고 인증서는 서버의 공개키로 암호화 되어있습니다. 클라이언트는 본인의 개인키로 인증서를 decrypt할 수 있고 내용을 확인할 수 있다."

이번 시간에는 SSL handshake 의 과정에 해당하는 공개키 암호화, 개인키 암호화에 대해서 알아볼까합니다. 그럼 시작해보죠

공개키 암호화

공개키와 개인키로 암호화하고 이를가지고 통신하는 것은 비대칭키 암호화라고 부릅니다. 맞습니다. 대칭키 암호화도 있죠.

비대칭키 암호화는 비교적 최신 기술이고 이전에는 대칭키 암호화로 암호화된 데이터를 주고 받았습니다.

대칭키 암호화는 이름에서도 알 수 있듯이 서로 같은 키로 데이터를 주고 받는 것입니다.

하지만 이는 공격자 입장에서는 땡큐죠. 데이터를 탈취하고 싶을 때 서버 혹은 클라이언트 둘 중 아무나 키를 갈취해서 열어보면 되니까요.

개발자들은 이에 대응하는 비대칭키를 만들어 보안을 강화했습니다. 암호화와 복호화에 필요한 키를 다르게 만들어서 공격자 입장에서 두배로 수고스럽게 만든다는 작전이었죠.

이는 꽤나 잘 먹혀들었습니다. 클라이언트 (개인) 의 키를 뺏는건 그렇다 쳐도 서버 (기업)의 키를 갈취하는건 쉬운 일이 아니니까요.

심지어 클라이언트의 키도 뺏는것이 만만치않았습니다. 클라이언트는 악성코드가 담겨있는 파일에 손만 안대면 갈취당할 일이 없으니까요. 서버의 키도 마찬가지죠 서버에는 보안을 신경쓰기 때문에 뚫는것이 쉽지 않았죠.

공개키 암호화에 대한 중요한 특징은 다음과 같습니다.

• 암호화와 복호화에 다른 키를 사용한다

• 각각의 개인키는 서로 다릅니다. 보통의 경우 개인키는 OS에 붙엉닜거나, 하드웨어마다 다릅니다.

• 암호화 알고리즘은 굉장히 강력하다

• 개인키, 공개키는 수학적으로 설계되어 있다.

개인키나 공개키는 RSA 암호화에 따라 암호화가 실행됩니다. 간단하게 RSA의 알고리즘에 대해서 알아보죠

RSA Algorithm

RSA는 세명의 학자에 의해 탄생했습니다. Rivest, Shamir, Adleman 이 셋의 앞글자를 따서 만든 것이 바로 RSA 알고리즘이죠.

RSA 알고리즘은 공개키와 개인키를 암호화하고 복호화 하는데 사용됩니다. 이제 RSA에 대해서 설명해보겠습니다.

RSA 모듈을 만든다

두개의 아주 큰 소수 p 와 q 를 만듭니다. 그리고 그 둘을 곱한 n 을 생성하죠. 그리고 아주 강력한 암호화를 진행합니다. 이 n은 전형적으로는 512비트를 넘지 않습니다.

파생된 숫자를 찾는다

파생된 숫자 e는 1과 (p - 1)(q - 1) 사이에서 만들어집니다. 1은 포함 안됩니다.

공개키를 만든다

숫자 n 과 e 는 RSA 공개키를 만드는데 사용됩니다. 흥미롭게도 이 n 은 공개키의 일부분이 됩니다. 이 n 은 엄청나게 큰 수이기 때문에 공격자가 쉽제 찾을 수 없습니다. 찾기 위해서는 특별한 알고리즘을 사용하지 않는다면 무한에 가까운 시간이 듭니다. 이 부분이 바로 RSA가 강력한 부분입니다.

개인키를 만든다

개인키 d 는 p 와 q 로부터 계산됩니다. 주어진 숫자 n 과 e 를 가지고 유니크한 숫자인 d 를 만듭니다. 숫자 d 는 e 가 만들어낸 모듈을 뒤집은 것입니다.

이렇게 RSA가 만들어지고 이 공개키와 개인키는 통신에 사용됩니다.

여기까지 공개키 암호화, 개인키 암호화에 대해서 알아봤습니다. 이번 내용은 쉽고 짧은 내용이었습니다. 요즘 길고 어려운 내용들을 공부해서 그런지 기분이 새롭긴 합니다.

이 개인키, 공개키에 대한 내용은 사전지식에 해당하구요 이 뒤에 SSL handshake에 대해서 다뤄볼 예정입니다. 그럼 글은 여기서 마쳐보도록 하겠습니다. 오늘도 즐거운 하루 되세요~

출처

https://www.tutorialspoint.com/cryptography/public_key_encryption.htm

https://www.ibm.com/docs/en/ztpf/1.1.0.15?topic=concepts-public-key-cryptography

https://www.globalsign.com/en/ssl-information-center/what-is-public-key-cryptography

SSL handshake / TLS handshake

이번 포스팅에선 흔히 SSL handshake로 알려져있는 프로토콜에 대해서 알아보도록 하겠습니다.

여러분이 HTTPS URL을 사용한 적이 있으시다면 SSL handshake를 반드시 진행했을겁니다. SSL handshake의 목적은 서버와 클라이언트간 통신에서 데이터 무결성과 클라이언트의 프라이버시를 지켜주기 위한 것입니다.

포스팅에 진행하기 앞서 공개키 암호화에 대한 내용을 알고 계시면 더 나은 이해가 될 것 같습니다. 아래의 링크를 통해 공개키 암호화에 대한 내용을 확인하시면 됩니다!

https://coding-review.tistory.com/329

그럼 지금부터 본격적으로 SSL handshake에 대해서 알아보도록 하겠습니다.

SSL handshake / TLS handshake

SSL handshake는 one-way SSL 과 two-way SSL 이렇게 두가지로 나뉘는데요.

one-way SSL 은 서버의 신원을 클라이언트만 검증합니다. two-way SSL 은 각각의 신원을 서버와 클라이언트 모두가 검증하는 방식입니다. HTTPS 웹사이트에서 보통 사용되는 것은 two-way SSL이라고만 알아두시면 됩니다.

그럼 TLS는 뭐냐?

TLS는 Transport Layer Security의 약자로 기존 SSL이 낙후된 기술이기 때문에 새롭게 나온 기술입니다. 그래서 요즘은 SSL이라고 말하기 보다는 TLS라고 말한다고 하네요.

그럼 이제 본격적으로 handshaking에 대해서 알아보죠

1. Client Hello

클라이언트는 HTTPS 연결을 시작하기위해서 서버에 요구되는 정보를 보내는 것입니다.

*** ClientHello, TLSv1.2

RandomCookie: *** ClientHello, TLSv1.2

RandomCookie: GMT: -1892413556 bytes = { GMT: -351008774 bytes = { 169, 131, 204, 213, 154, 96, 7, 136, 43, 142, 232, 138, 148, 171, 52, 226, 155, 202, 145, 57, 210, 132, 227, 182, 67, 222, 161, 28, 20 }

Session ID: 239, 10, 92, 143, 185, {}

93, Cipher Suites: [Unknown 0x8a:0x8a, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, Unknown 0xcc:0xa9, Unknown 0xcc:0xa8, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_RSA_WITH_AES_256_CBC_SHA, SSL_RSA_WITH_3DES_EDE_CBC_SHA]

………………………………………………

위의 로그에서 우리는 TLS v1.2라는 것을 볼 수 있습니다. 이것은 클라이언트가 TLS version 1.2를 지원하는 서버라는 것을 알리기위해 서버측에 보내는 메세지라고 보시면 되겠습니다.

여기서 좀 자세히 보셔야 하는 것이 바로 Cipher Suites 인데요. 일단 설명은 잠시 뒤에 하고 이 Cipher Suites 의 리스트중에서 서버가 하나를 선택해야 합니다.

만약 이 부분이 누락되어 있다면 서버는 이 Cipher 들을 무시하게 되고 연결은 끊어지게 됩니다.

참고) Cipher Suites 란?

Cipher Suites란 SSL 혹은 TLS를 통해 네트워크를 어떻게 안전하게 할지에 대한 구조들의 집합입니다.

Cipher Suites는 HTTPS, FTPS, SMTP나 달느 네트워크 프로토콜을 사용할 때 데이터를 어떻게 안전하게 커뮤니케이션 할것인지에 대한 본질적인 정보를 제공하고 있습니다.

이 정보들에는 어떻게 웹서버가 클라이언트의 트래픽을 안전하게 할것인지 결정하는 것을 도와주는 알고리즘이나 프로토콜에 대해서 적혀있습니다.

또한 Cipher Suites에는 이러한 알고리즘으로 서버가 신뢰성있는 연결을 제공하기위한 방법들이 제시되어있습니다.

2. Server Hello

서버는 handshake를 위해 진행하기위한 정보들이 담겨있는 Client Hello 로부터 선택된 설정들을 응답합니다.

*** ServerHello, TLSv1.2

RandomCookie: GMT: 1518335451 bytes = { 19, 150, 56, 42, 168, 202, 151, 43, 174, 226, 187, 53, 135, 67, 244, 170, 59, 176, 105, 150, 50, 112, 167, 83, 192, 48, 171, 64 }

Session ID: {91, 128, 246, 219, 26, 93, 46, 172, 85, 212, 221, 79, 20, 186, 108, 134, 200, 239, 150, 102, 172, 24, 125, 171, 137, 53, 5, 130, 53, 228, 2, 195}

Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA

Compression Method: 0

Extension renegotiation_info, renegotiated_connection: <empty>

**

서버는 클라이언트가 보내온 Client Hello 패킷을 받아서 Cipher Suite 중 하나를 선택한 다음 클라이언트에게 이를 알립니다 또한 자신의 SSL 프로토콜 버전 등도 같이 보냅니다.

또한 Server Hello 패킷 안에서 서버는 certificate chain과 함께 서버의 인증서를 같이 보냅니다. 이 certificate chain은 클라이언트가 신뢰하는 기관 안에 있는 인증서로 검증됩니다.

** Certificate chain

chain [0] = [

[

Version: V3

Subject: CN=server, OU=ID, O=IBM, L=Hursley, ST=Hants, C=GB

Signature Algorithm: SHA256withRSA, OID = 1.2.840.113549.1.1.11

Key: Sun RSA public key, 2048 bits

modulus: 17397562757879783124811507432494159361243533796048391161052829821122241422546147907779583980536886743765496985274573369668302996974964349098220930856827026983442125212118340479175872257401994146576001849503528844021528618702289642320157895787705650513758990004411195572445830613057931701313142946380207623174021376881040589912594451781207558263630010509870784494298596448861811827669869221033031956053367890915692918086795954628465637743034777850129818069833958463245749899713073673871721233098662285260745282530972322499603703844901496085490388703767606182211892402117158287170480970610942364235511256363933850852797

public exponent: 65537

Validity: [From: Sat Jul 28 09:08:48 IST 2018,

To: Sun Jul 28 09:08:48 IST 2019]

Issuer: CN=server, OU=ID, O=IBM, L=Hursley, ST=Hants, C=GB

SerialNumber: [ 7d834874]

Certificate Extensions: 1

[1]: ObjectId: 2.5.29.14 Criticality=false

SubjectKeyIdentifier [

KeyIdentifier [

0000: C7 24 EA C3 1E 3E 58 8E BD E3 AE A9 17 01 00 51 .$…>X……..Q

0010: B0 83 D4 68 …h

]

]

]

Algorithm: [SHA256withRSA]

Signature:

0000: 4B FA 93 D8 56 78 05 D8 89 BC 2A 3A B6 C3 7F A5 K…Vx….*:….

0010: 03 D8 56 3B E6 9F 0B 17 B5 A2 61 AE 43 46 A4 85 ..V;……a.CF..

0020: 3F 60 2E 04 41 0D C2 7A 35 0D 56 F5 FE 9D 05 51 ?`..A..z5.V….Q

0030: 4A 0B BB 5B 30 ED 85 AF 1C 95 13 15 7D A0 2C DE J..[0………,.

0040: B4 A1 7A D0 5D E4 C0 91 37 C2 ED 37 39 65 7D 02 ..z.]…7..79e..

0050: B5 A4 72 FF EB 6C D5 F4 FD BC 32 FD 9F C8 3A 49 ..r..l….2…:I

0060: 53 64 F8 C6 A0 D6 DB 89 2C 36 60 97 8B 33 8F 95 Sd……,6`..3..

0070: 18 9C 1A F2 F8 F1 66 5E F3 0B 76 54 08 AB A9 88 ……f^..vT….

0080: 5D E9 2D 6B AE 6D 98 09 57 A0 2A 9E C6 6B 89 53 ].-k.m..W.*..k.S

0090: 8E B3 58 C3 8D 73 C5 D3 58 2F 68 F0 4E 0A EF 29 ..X..s..X/h.N..)

00A0: 54 95 00 34 6A C4 D2 56 22 64 05 F9 9F A4 81 44 T..4j..V"d…..D

00B0: 44 77 95 A7 86 5A D3 EE EA 8E 06 19 ED 94 F1 83 Dw…Z……….

00C0: F4 A1 F4 A0 76 94 02 40 30 C0 95 6A F2 4F 32 BB …[email protected].

00D0: 79 A2 1B 40 F5 EB 37 5B B7 0C FA 18 DE 04 18 7D [email protected][……..

00E0: 5A 1A 95 D7 E7 00 4C 7F 3C 71 CE 8E 03 07 BD 50 Z…..L.<q…..P

00F0: 6D 49 52 75 66 D2 CA 45 AB B8 EE B1 C2 C9 72 8A mIRuf..E……r.

]

***

3. Server Key Exchange Message

이 메세지는 pre-master secret을 만들기위해 서버에대한 세부사항을 요청하는 클라이언트에게 서버가 보내는 메세지입니다. 이 메세지는 RSA 키 교환 알고리즘이나 다른 키 교환 알고리즘이 아니라면 보내지지 않는데요.

즉, 서버의 공개 키가 SSL 인증서 내부에 없는 경우, 서버가 직접 전달한다는 얘기입니다. 공개 키가 SSL 인증서 내부에 있을 경우 Server Key Exchange는 생략됩니다.

인증서 내부에 서버의 공개 키가 있다면, 클라이언트가 인증기관의 공개 키를 통해서 인증서를 복호환 한 후 서버의 공개 키를 확보할 수 있습니다. 그리고 서버가 행동을 마쳤음을 전달하죠.

4. Certificate

서버가 자신의 SSL 인증서를 클라이언트에게 전달합니다. 인증서 내부에는 서버가 발행한 공개 키가 들어있습니다. 클라이언트는 서버가 보낸 인증기관의 개인키로 암호화된 이 SSL 인증서를 이미 모두에게 공개된 인증기관의 공개키를 사용하여 복호화합니다.

복호화에 성공하면 인증서는 인증기관이 서명한 것이 맞으니 진짜임을 검증할 수 있습니다.

** CertificateRequest

Cert Types: RSA, DSS, ECDSA

Supported Signature Algorithms: SHA512withECDSA, SHA512withRSA, SHA384withECDSA, SHA384withRSA, SHA256withECDSA, SHA256withRSA, SHA256withDSA, SHA224withECDSA, SHA224withRSA, SHA224withDSA, SHA1withECDSA, SHA1withRSA, SHA1withDSA

Cert Authorities:

<CN=server, OU=ID, O=IBM, L=Hursley, ST=Hants, C=GB>

<CN=client, OU=ID, O=IBM, L=Hursley, ST=Hants, C=GB>

*** ServerHelloDone

위에서 보면 SSL 인증서가 무슨 알고리즘으로 암호화되어있고, 무슨 Hash 알고리즘으로 서명되었는지 확인할 수 있습니다.

5. Client Key Exchange

클라이언트는 데이터 암호화에 사용할 대칭 키를 생성한 후 SSL 인증서 내부에서 추출한 서버의 공개키를 암호화한 후 서버에게 전달합니다.

여기서 전달된 대칭 키가 SSL handshake의 목적이자 가장 중요한 수단인 데이터를 실제로 암호화할 대칭키입니다. 이제 키를 통해 클라이언트와 서버가 실제로 교환하고자 하는 데이터를 암호화합니다.

6. ChangeCipherSpec / Finished

ChangeCipherSpec 패킷은 클라이언트와 서버 모두가 서로에게 보내는 패킷으로 교환할 정보를 모두 교환한 뒤 통신할 준비가 다 되었음을 알리는 패킷입니다. 그리고 Finished 패킷을 보내어 SSL handshake를 종료하게 됩니다.

이제 모든 handshaking 과정을 알아봤습니다. 이제 정리할겸 그림으로 정리해보도록 하겠습니다.

그림으로 보는 SSL handshake

1. 사이트는 인증기관에 사이트의 정보와 공개 키를 보낸다

2. 인증기관은 자신의 개인키로 사이트의 정보와 공개 키에 대해 암호화하여 인증서를 생성한 뒤 인증서를 사이트에게 전달한다

3. 사용자는 인증기관의 공개 키가 브라우저에 내장되어 있다고 가정한다.

4. 사용자는 사이트에 접속을 요청한다 (Client Hello)

5. 사이트는 사용자의 Cipher Suite중 하나를 고르고, 자신의 SSL 프로토콜 버전을 사용자에게 알린다. (Server Hello)

6. 사이트는 자신의 사이트 인증서를 사용자게에 전송한다 (Certificate)

7. 사용자는 브라우저에 내장된 인증기관의 공개키를 이용하여 사이트 인증서를 복호화하면서 인증서가 유효한지 검증하고, 사이트의 공개 키를 가져온다. 참고로 현재 사이트의 공개 키가 있으므로 Server Key Exchange 과정은 생략된다.

만약 맨처음 사이트가 인증기관에게 인증서를 받을 때 공개키를 주지 않았다면 이 떄 Server Key Exchange가 일어납니다.

8. 사용자는 자신이 전달한 데이터를 암호화 할 대칭키를 만들고, 그 대칭 키를 사이트의 공개키로 암호화한다.

9. 암호화한 대칭 키를 사이트에게 전달한다.

10. 사이트는 자신의 개인 키를 사용하여 사용자의 대칭 키를 복호화하여 사용자 대칭 키를 얻어낸다.

11. 이렇게 얻은 대칭 키를 활용하여 서로가 서로의 데이터를 안전하게 복호화 하면서 통신할 수 있다.

여기까지 SSL handshake에 대해서 알아봤습니다. 공개키, 개인키, 암호화, 복호화, 인증서 엄청 복잡하실겁니다. 저도 지금 엄청 복잡한데요... ㅜㅜ

조금 쉽게나마 이해를 위해 첨언하자면

참고)

위의 방법은 대칭키 암호화와 비대칭키 암호화를 섞어서 사용하는 방식입니다. 대칭키는 서로 같은 키로 암호화와 복호화를 진행하고 그렇기 때문에 속도가 빠르다는 장점이 있는데요. 그래서 이해하기도 좀 쉬운 편입니다.

하짐나 비대칭키는 서로 다른 키로 암호화와 복호화를 진행하기 때문에 어떤 키로 암호화를 하느냐에 따라서 목적이 달라집니다.

위의 링크에서 공개키 암호화에 대해 어느정도 알고 계시다는 전제하에 말씀드리겠습니다.

개인키로 암호화 = 공개키로 복호화가 가능하다는 얘기 = 공개키는 모두에게 공개되어있다. = 보안적으로는 썩 달가운 상황은 아님

즉, 개인키로 암호화는 이 데이터를 보안을위해 암호화하는 의미가 없습니다. 때문에 개인키로 암호화를 한다는 것은 인증에 좀 더 신경쓴 방식이라는 얘기입니다.

공개키로 암호화 = 개인키로 복호화가 가능하다는 얘기 = 개인키는 개인에게 귀속된 키라 폐쇄적 = 보안적으로 굉장히 강력하다는 의미

그래서 공개키로 암호화를 한다는 얘기는 이 데이터를 짝이되는 개인키가 아니고서는 절대 열어볼 수 없도록 설계된 것입니다.

때문에 위에서 사이트가 본인의 사이트 정보와 공개 키를 인증기관에 넘기면 인증기관이 "개인키"로 암호화하여 인증서를 보낸다. 라는 의미는 "인증"을 위한 "인증서"이기 때문에 "개인키"로 암호화 한것입니다.

마지막 사용자가 대칭키를 본인의 공개키로 암호화하여 사이트에 보내는 장면이 있습니다. 그리고 이를 받은 사이트는 본인의 개인키로 복호화하여 대칭키를 획득하죠. 이는 대칭키가 유출되면 곤란한 상황이기 떄문에 "공개키"로 암호화를 한것입니다.

이렇게 설명하면 이해가 되실런지 모르겠네요. 아무튼 여기까지 글 마쳐보도록 하고 오늘도 즐거운 하루 되세요~ 저는 다음 포스팅에서 뵙겠습니다.

출처

https://steady-coding.tistory.com/512

https://medium.com/@kasunpdh/ssl-handshake-explained-4dabb87cdce

https://venafi.com/blog/what-are-cipher-suites/

인증과 인가

평소에 인증과 인가에 대해서 자세히 알고있지는 않았습니다. 사실 인가가 뭔지도 잘 몰랐습니다. 이참에 공부를 해야겠다고 생각이 들더군요.

때문에 이번 GPT에게 물어본 내용은 인증과 인가입니다. 거기에 제가 알고있던 부분을 첨가해서 포스팅 작성해보도록 하겠습니다.

※ GPT야 이것좀 알려줘 카테고리는 원래 제가 작성하는 주제 (자바, 스프링, CS, 알고리즘) 와 다른 하지만 평소에 궁금했던 내용을 작성합니다. GPT가 답변한 내용은 ~이다 체로, 제가 작성한 부분은 ~습니다 체로 작성됩니다.

인증과 인가

포스팅을 시작하기 전에 인증과 인가에 대한 정의를 먼저 말씀드리고 가야겠군요.

• 인증 : 말그대로 신뢰할 수 있는 사용자라고 인증하는 것

• 인가 : 사용자가 할 수 있는 행위를 제한하는 것

인증과 인가에 대한 인터세 세상 보안의 중요성

인증과 인가가 중요한 이유는 다음과 같다.

• 민감한 데이터를 보호해야하기 위해 : 많은 온라인 서비스나 애플리케이션은 개인정보다 재정적인 데이터와 같이 민감한 데이터를 저장하고 있다. 적절한 인증과 인가는 오직 인가된 유저들만이 데이터에 접근할 수 있도록 보장하는 것을 도와주면서 유저의 프라이버시를 위협하는 위험을 줄여준다.

• 인가된 접근을 보호하기위해 : 적절한 인증과 인가가 없으면 누구나 온라인 서비스나 애플리케이션에 접근할 수 있고 이는 공격자나 악성 코드들의 접근을 허용할 수 있다. Multi-Factor Authenticaiton (MFA) 이나유저의 역할에 기반에 접근을 통제하는 것과 같은 강력한 인증과 인가를 구현함으로써 조직은 비인가된 접근을 막을 수 있고 보안 사고의 위험을 줄여줄 수 있다.

• 규정 준수의 필요 : 많은 산업과 나라들은 민감한 데이터를 보호하고 접근이 적절히 컨트롤되는 것을 보호하는 것에 대한 일반적인 필요성을 가지고 있다. 이러한 요규들의 규정을 준수하는 것은 종종 강력한 인증과 인가를 구현하는 것을 포함한다. 그리고 이러한 규정 준수를 실패하게 되면 재정적인 패널티를 얻거나, 불법적인 행동을 하거나, 평판이 떨어지는 결과를 야기한다.

• 신뢰를 유지하기 위해 : 적절한 인증과 인가를 구현하지 않은 온라인 서비스나 애플리케이션은 다양한 공격에 취약하고 이는 유저나 고객들의 신뢰에 악영향을 미칠 수 있다. 강력한 통제를 구현함으로써 회사는 보안에 대한 그들의 약속을 입증할 수 있고 그들의 유저나 고개들에 대한 신뢰를 구축할 수 있다.

인증과 인가를 구현하는 방법에는 다양한 방법이 있습니다. 인증을 구현하는 대표적인 방법으로는 세션과 JWT 인증 방식이 있고 인가를 구현하는 대표적인 방법으로는 Roll-based access control (RBAC), Attribute-based access control (ABAC), Mandatory access control (MAC) 가 있습니다.

우리는 그중 먼저 인증을 구현하는 방법에 대해서 알아봅니다.

유저 인증을 구현하기 위한 최적의 방법

유저 인증을 구현하기 위한 방법에는 기본적으로 세션을 사용하는 방법과 JWT 인증을 이용하는 방법이 있습니다. 세션을 이용하는 방법은 서버에 특정한 값을 세션으로 저장해 두고 필요할 때마다 꺼내와서 유저를 인증하는 방법입니다.

기본적으로 세션을 그냥 쓰지는 않고 Redis라는 데이터베이스와 같이 사용하는 것이 일반적입니다. 이렇게 Redis + Sesion으로 구현하면 구현하기는 간단하지만 stateful 프로토콜로 동작하는 Redis + Session의 경우 사용자가 몇천만, 혹은 몇억명이 되는 경우 계속해서 데이터베이스에 데이터를 보내야하기 때문에 서버에 무리가 갈 수 있습니다.

때문에 회원수가 엄청나게 많거나 MSA를 채택한 서비스에선 JWT 인증을 사용하는 것이 바람직할 수 있습니다. 하지만 JWT 인증 같은 경우는 보안상 취약한 이슈가 있기 때문에 이를 커버해줘야만 합니다.

JWT 인증의 보안 이슈

• JWT 헤더에 알고리즘을 none으로 만들어서 하는 공격에 대한 대비를 해야합니다. 가끔 알고리즘을 SHA가 아닌 none으로 해도 인증이 되는 경우가 있기 때문에 조심해야 합니다.

• JWT는 디코딩이 매우 쉽기 때문에 중요한 정보를 JWT에 넣으면 안됩니다.

• JWT를 인증하기 위해 시크릿키를 적어 넣어야하는데 시크릿키를 대충 적어서 만들면 무차별 대입 공격에 취약하기 때문에 시크릿키가 털리게되면 인증을 마음대로 할 수 있어서 매우 조심해야 합니다.

• 만약 JWT가 탈취당했을 경우 문제가 발생하는데 사실 이는 개발자 책임이 아니긴 합니다. 만약 카드를 분실하면 카드를 분실한 사람이 책임이 있기 때문입니다. 하지만 카드의 경우 정지를 해달라고 요청할 수 있지만 JWT의 경우 JWT를 정지하거나 회수하는 것이 구조적으로 불가능합니다.

1, 2, 3번은 개발자가 적당히 조심하면 되는 부분이지만 4번의 경우는 JWT가 가지고 있는 취약한 부분중에 하나입니다. 이러한 문제가 발생했을 때를 대비한 몇가지 솔루션이 있습니다.

JWT를 잘 뺏기지 않도록 특정한 저장소에 저장해둔다거나 (HttpOnly cookie), JWT 블랙리스트를 만들어서 JWT가 올때마다 블랙리스트랑 검사해야 한다거나 (하지만 이렇게 하면 session과 큰 차이가 없습니다.), 유효기간을 짧게 설정한다거나 (ex. 5분) 하는 방법이 있습니다.

하지만 개인적으로 JWT의 유효기간을 짧게 설정한 사이트를 이용해봤는데 (OKKY ver.2 초창기) 들어갈때마다 로그인이 풀려서 귀찮은걸 넘어서 커뮤니티를 이용하고 있다가도 로그인이 풀려버려서 작성하고 있던 게시글 혹은 댓글이 다 날아가버리는 그지같은 상황이 속출했습니다.

이러한 불편때문에 사람들이 호소하자 서버장은 다시 JWT가 아닌 쿠키 (세션) 방식으로 바꾸는 해프닝이 발생했습니다. 이렇게 자주 풀려버리는 문제 때문에 refresh token과 access token을 발금해서 언제든지 재발급할 수 있게 마련하는 방법도 있습니다.

인가를 구현하는 여러가지 방법

인가를 구현하는 방법에는 앞서 설명했다시피 Roll-based access control (RBAC), Attribute-based access control (ABAC), Mandatory access control (MAC)가 있습니다. 이제 각각에 대해 설명해보고 장점과 단점에 대해서 알아보도록 하죠.

RBAC

Roll-based access control 줄여서 RBAC는 접근할 수 있는 컨텐츠를 역할에 기반하여 접근을 통제하는 방법이다. 유저들은 할당된 다양한 역할이 있고 각각의 역할은 역할이 접근을 허용하는 유저들의 자원이나 액션의 접근을 결정하는 일련의 허가이다.

이러한 허가들은 관리자에의해 결정되고 역할들은 그들의 일의 기능이나 책임에 근거해 유저에게 할당된다. RBAC는 관리자 입장에서 간단하고 확장하기 쉬운 접근 통제 모델이다. RBAC는 주로 엔터프라이즈 환경에서, 예를 들어 이커머스같은 곳에서 사용자, 판매자, 관리자와 같은 방식으로 접근을 제어하는 것으로 많이 사용된다.

RBAC의 장점

• RBAC는 관리자 입장에서 간단하고 쉽다. 그리고 이런 간단함은 다른 모델에 비해 더 효율적이고 비용적인 측면에서 값 싸게 이용할 수 있다.

• RBAC는 위험성을 줄일 수 있다. 그들의 일을 행하기 위해 필요한 자원을 접근할 때 유저들의 역할을 가지고 확신할 수 있기 때문에 보안상 이점을 가지고 있다.

• RBAC는 크고 복잡한 시스템에서 접근의 통제를 관리하는데 더 쉽다. 그 이유는 역할이 일의 기능과 일의 책임에 근거해 할당되기 때문이다.

RBAC의 단점

• RBAC는 그들의 일의 기능과 책임에 근거해 역할이 할당되기 때문에 그들의 역할을 변경하거나 업데이트 하기가 힘들다.

• RBAC는 유저들이 자원에 걸쳐 세분화된 컨트롤이 가능한 상황에서 효과적이지 않을 수 있다. 혹운 접근 통제 정책이 복잡해진다.

ABAC

Attribute-based access control 줄여서 ABAC는 유저, 자원 혹은 환경과 연관된 속성에 근거해 접근을 정의하는 접근 통제 모델이다. 이 모델에서 접근 통제 정책은 사용자의 속성 (ex. 부서) 이나 자원의 속성 (ex. 민감한 자원) 그리고 환경적인 속성 (ex. 날짜)에 근거해 정의된다.

이러한 접근 통제 방식은 속성을 평가하는 정책에 의해 만들어지고 접근을 허락해야 하는지 혹은 통제해야 하는지를 결정한다. ABAC는 복잡한 정책들을 구현하기위해 사용될 수 있는 유연하고 역동적인 접근 통제 모델이다.

ABAC의 장점

• ABAC는 RBAC에 비해 접근을 통제하기 더 유연하고 역동적인 접근 방법을 제공한다. 접근 통제 정책이 유저의 속성이나 자원의 속성, 환경의 속성과 같이 다양한 속성에 기반할 수 있기 때문이다.

• ABAC는 유저들이 자원에 걸친 세분화된 컨트롤을 필요로하는 상황이나 접근 제어 정책이 복잡한 상황에서 더 효율적일 수 있다.

• ABAC는 접근 통제 정책이 단지 유저의 역할이나 일의 기능보다 더 다양한 것에 기반하고 있는 것을 보장함으로써 다른 모델에 비해 더 보안상 이점이 있다.

ABAC의 단점

• ABAC는 RBAC보다 관리자 입장에서 더 관리하기 복잡하고 어렵다. 이는 접근 통제 정책이 다양한 속성에 기반하고 있기 때문이다.

• ABAC는 다른 모델에 비해 더 자원이 집중되어 있다. 이는 접근 제어 정책이 유저들과 연관된 속성을 평가하는 정책에 의해 만들어지기 때문이다.

• ABAC는 다른 모델에 비해 사용하는 유저에게 덜 직관적이다. 왜냐하면 접근 제어 정책이 특정한 역할이나 일의 기능에 기반하고 있지 않기 때문이다.

MAC

Mandatory access control 줄여서 MAC는 시스템 안에서 객체 혹은 주제가 할당되는 일련의 규칙이나 라벨을 근거해 접근을 통제하는 모델이다.

이 모델에서 자원에 접근하는 것은 일련의 보안 수준에 근거해 제한되고 유저들은 그들이 접근할 수 있는 수준을 결정하는 보안상 청결함이 할당된다.

보안 수준의 청결함은 관리자에 의해 결정되고 접근 통제 모델은 시스템안에서 결정된 규칙에 근거한다. MAC는 보통 정부의 보안 관련 컨텐츠나 군대에서 사용된다. 이곳은 민감한 정보를 다루는 기관들이기 때문에 인가 정책을 고수해야 한다.

MAC의 장점

• MAC는 라벨이나 규칙에 기반한 강제적으로 제한된 접근 제어 정책을 가짐으로써 매우 강력한 보안을 제공한다.

• MAC는 정부나 군대와 같이 내부자에 의한 공격에 취약한 경우 효과적일 수 있다.

• MAC는 높은 수준의 민감한 정보에 대한 무결성을 자랑한다.

MAC의 단점

• MAC는 관리자 입장에서 관리하기가 복잡하고 어려울 수 있는데 그 이유는 접근 통제 정책이 정의가 필요하거나 관리가 필요한 라벨이나 규칙에 의해 기반하기 때문이다.

• MAC는 접근 통제에 대한 요구에 의해 정책이 변결될 때 적철하지 못하다.

• MAC는 모든 환경에서 부적절하다. 그 이유는 자원이 밀집되어 있고 시스템 성능에 영향을 미치기 때문이다.

흔히 일어날 수 있는 보안상 취약점

약한 수준의 보안 패스워드

인증 시스템에서 가장 흔한 취약점 중 하나는 바로 쉽게 추측 가능한 패스워드를 사용하는 것이다. 이렇게 취약한 패스워드는 공격자로 하여금 유저의 패스워드를 추측함으로써 시스템에 쉽게 접근할 수 있게끔 한다. 이러한 위험을 줄이기 위해서는 강력한 패스워드 정책이 필요하고 유저들은 추측하기 어려운 복잡한 패스워드를 사용하는 것이 필요하다.

부족한 제 3자 인증

인증에서 또 다른 취약점은 바로 Multi-Factor Authentication (MFA)의 부족이다. MFA는 패스워드 말고 추가적인 보안 계층인데 예를 들어 지문을 인증하거나 토큰을 가지고 유저의 신원을 입증하는 것이다. MFA가 없으면 시스템은 무차별 대입 공격에 취약해지고 다른 타입의 공격에 취약해질 수 있다.

불충분한 접근 통제

불충분한 접근 통제는 인가 시스템에서의 취약점 중 하나이다. 이것은 유저가 그들이 원하는 것 보다 더 많은 허가를 받는 경우에 발생하거나, 접근 통제가 적절히 인가될 수 없을 때 발생한다. 이러한 상황은 다양한 보안 사고를 야기한다.

Injection 공격

Injection은 인증과 인가 시스템에서 모두 발생할 수 있는 취약점이다. Injection 공격은 공격자가 악성 코드나 악성 뎅치터를 주입하기 위해 시스템 속에서 취약점을 관찰하는 상황에서 발생할 수 있다. 이러한 상황은 비인가 접근이나 자신이 인가받은 능력보다 더 높은 지위를 얻을 수 있는 상황을 야기한다. 이러한 상황속에서 공격자는 그들이 접근할 수 없는 자원이나 행동에 접근할 수 있게 된다.

수준 낮은 세션 관리

수준 낮은 세션 관리는 또한 인증 시스템에서 취약점 중 하나이다. 이 상황은 유저 세션이 부적절하게 관리되거나 다른 타입의 공격에 의해 세션이 갈취되는 상황에서 발생한다. 이러한 위험을 줄이기 위해 시스템은 세션 타입아웃이나 암호화된 쿠키를 핸들링하는 것과 같은 적절한 세션 관리 통제를 구축해야한다.

마치며

여기까지 인증과 인가에 대해서 GPT에게 물어본 내용을 정리했습니다. 너무 글만 쭉 쓴거같아서 가독성이 떨어지는 느낌도 없지않아 있네요.

구글에서 구글링을 하는 경우에는 적절한 표나 적절한 사진으로 검색하는 사람으로 하여금 이해를 많이 도와준다고 생각했는데 GPT는 그런건 얄짤없군요.

아무튼 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

출처

Chat GPT - Open AI

JWT는 정말 stateless인가?

보통 많은 애플리케이션이 JWT를 이용해서 인증을 하고있다고합니다. JWT를 한글이던 영어던 구글링을 해보면 모두같이 JWT를 stateless라고 소개하고 있습니다.

JWT를 쓰고 있던 와중 갑자기 문득 생각이 들었습니다. 이게 stateless야?

JWT는 정말 stateless인가?

왜 이런 의문이 들었냐면 access token (이하 AT)이 만료되는 순간 refresh token (이하 RT)를 확인해서 다시 AT를 재발급 하는 과정에서 의문이 들었습니다.

RT를 확인할 때 우리는 이 사용자와 관련된 즉, 이 사용자가 로그인할 때 만들었던 RT를 찾기위해 결국 사용자의 정보를 가지고 RT를 검색합니다.

이 RT는 데이터베이스에 오랜시간 저장되어있으면서 사용자에게 계속 AT를 발급해주죠.

그럼 결국 RT를 확인하기 위해서는 쿠키던 세션이던 stateful한 무언가가 있어야한다는 말입니다.

이런 관점 말고도 또 다른 관점에서 JWT는 stateful한 성격을 가지고 있습니다.

바로 데이터베이스입니다.

데이터베이스에서 RT를 계속 가지고 있다는 것은 사용자 인증을 stateful하게 유지하고 있다는 말이 되는 것이죠.

엥? 그럼 JWT는 stateful하다는거야?

JWT 그 자체는 stateless할 수 있죠. 하지만 구현을 하고 사용자 경험을 생각하면 결국 JWT는 stateful하게 구현할 수 밖에 없습니다.

그럼 쿠키베이스인 세션이랑 뭐가달라?

세션과 궁극적으로 다른 점은 인증을 서버에서 하냐 안하냐 그차이인 것 같습니다.

서버에서 인증을 해버리면 서버가 여러대면 정말 골치아파지니말이죠.

"쿠키에 아주 간단한 정보를 저장해두고 이 정보를 바탕으로 RT를 확인하여 AT를 재발급 해준다." 이렇게 함으로써 서버는 인증에서 분리를 시킬 수 있습니다.

말 그대로 인증을 서버에서 격리시키는거죠. 클라이언트는 하나뿐이고 서버는 여러대일테니 하나뿐인 클라이언트 (브라우저) 에 인증을 위한 정보를 담아두고 최종적인 인증인 RT를 위해서 서버는 중간다리 역할만 하는 것이죠.

마치며

JWT 중복로그인 방지를 구현하는 과정에서 문득 든 의문을 GPT와 가볍게 커피챗을 하면서 풀었습니다. 오랜만에 GPT한테 이론적인 내용을 물어보고 해결을 했네요. 뿌듯한 하루였습니다.

짧지만 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

JWT, 함부로 사용하면 다친다?

요즘 많은 곳에서 JWT를 인증에 사용하시는 것 같습니다. 크게는 MSA를 사용하는 곳부터 세션 스토리지를 둘 여력이 없는 스타트업까지 다양하게 사용하고 계시는 것 같습니다.

물론 이런 흐름에 맞춰 저를 비롯한 많은 취준생분들도 포트폴리오에 인증 레이어를 JWT로 사용했다는 포트폴리오를 많이 봤습니다.

하지만 이런 상황은 면접관들에게 좋은 먹잇감이 되기 쉽다는 것을 취준할 때 면접보면서 깨달았죠.

지금은 현재 회사에서도 JWT를 이용해서 인증을 처리하고 있지만 고려해야 할 사항들이 세션보다 많아 제가 현업에서 겪었던 경험 + 다른 사람이 겪었던 경험 이렇게 혼합해서 정리를 해보려고합니다.

JWT 사용시 주의사항

세션을 이용해서 인증을 구현하면 서버가 여러대일 경우 문제가 발생할 수 있습니다. 그 이유는 세션의 stateful하다는 특징 때문인데, 세션 데이터는 서버에 저장되고 사용자의 요청이 다른 서버로 들어가는 경우 (이 경우는 MSA뿐만 아니라 로드밸런싱을 이용한 모놀리식 구조에 해당합니다.) 이 사용자가 어떤 사용자인지 모르는 서버도 존재하기 때문에 문제가 됩니다.

물론, 세션 계층을 Redis로 따로 빼어내면 이런 문제가 해결되지만 인증을 위해 Redis서버와 통신해야 하기 때문에 네트워크 레이턴시와 서버의 자원 (CPU, Memory) 을 사용하기 때문에 이 또한 다른 문제로 이어질 수 있습니다.

이런 Redis라는 세션 스토리지를 두어야하는 입장에서 스타트업의 경우 서버의 비용을 최대한 아껴 고정비를 줄이는 것이 큰 도움이 되기 때문에 JWT가 큰 도움이 될 수 있습니다.

또한, 도메인별 서버가 따로 존재하고 수많은 서버가 있는 MSA의 경우도 마찬가지가 될 수 있겠죠.

토큰의 사이즈

JWT는 유저의 데이터를 얼마나 많이 가지고 있느냐에 따라서 토큰의 크기가 커질 수 있습니다.

이렇게 토큰의 크기가 커지면 네트워크 트래픽을 많이 잡아먹기 때문에 비용적인 측면에서도, 레이턴시의 측면에서도 지양해야하는 상황입니다.

때문에, 유저를 식별할 수 있는 간단한 정보를 JWT에 담음으로써 JWT의 크기에 대한 고민을 해야합니다.

JWT토큰은 불변이다.

JWT토큰은 한번 발급되면 만료될 때까지 남아있고 제어하기 굉장히 까다롭다는 것이 특징입니다. 이렇게 JWT토큰이 불변이기 때문에 겪을 수 있는 문제들이 몇 가지 있는데 그 중에서 대표적인 것을 정리해봤습니다.

* 사용자가 로그아웃 하는 경우 *

만약 사용자가 로그아웃을 하는 경우 이미 발급된 토큰은 방치되고 이는 로그아웃을 했음에도 로그인이 된 상태로 돌아다닐 수 있다는 문제가 발생합니다.

이를 위해 로그아웃을 위한 엔드포인트에서 Access Token을 서버로 보낸 뒤 이 토큰을 Redis같은 데이터베이스에 적고 이를 만료될 때까지 가지고 있음으로써 해당 토큰을 재사용할 수 없도록 해야합니다.

이러한 방식을 "토큰을 블랙리스트에 등록한다" 라고 하며 이 방법은 한번 발급된 토큰을 재사용할 수 없도록 매번 Request 필터에서 확인해야한다는 단점이 존재합니다.

* 탈취된 토큰에 대한 대처 *

만약 사용자의 토큰이 탈취되는 경우 해커는 Access Token이 살아있는 동안 정상적인 유저인척 애플리케이션을 돌아다닐 수 있습니다.

이는 Access Token의 만료시간을 짧게 설정함으로써 해결할 수 있지만 그렇다고 굉장히 짧게 설정할 수는 없는 노릇이기 때문에 보통 15분에서 30분 사이로 Access Token을 설정하는데, 이 시간은 해커가 Access Token을 탈취하면 꽤 널널하게 사용자의 개인정보를 빼낼 수 있는 시간입니다.

이에 대한 대처 방법으로 Access Token에 사용자의 IP를 적는 방법이 있습니다. 토큰에 IP주소가 적혀있고 이를 지속적으로 바꼈는지 확인한다면 토큰이 탈취되더라도 문제를 해결할 수 있습니다.

기존에 발급된 IP주소와 다른 IP주소가 탐지되었다면 해당 토큰을 블랙리스트에 등록하고 사용자에게 다시 로그인을 유도하는 방법으로 이를 해결할 수 있습니다.

* 사용자의 정보가 업데이트 되는 경우 *

만약 Access Token에 사용자의 권한이나 역할이 적혀있는 상황에서 이 사용자의 역할 혹은 권한이 변경되면, JWT의 불변속성 때문에 재로그인하기 전까지는 서버 입장에서 이 사용자가 업데이트된 것을 확인할 수 없습니다.

이는 JWT토큰에 버저닝을 하는 방법이 해결방안이 될 수 있습니다. 사용자의 정보가 업데이트 되면서 버전을 높여 이 사용자의 토큰이 업데이트 됐음을 서버에 알려주는 것입니다.

하지만 이런 경우 사용자의 토큰의 버전이 이전 버전과 달라졌는지 달라지지 않았는지 지속적으로 확인해야하기 때문에 구현의 복잡성이 올라간다는 단점이 있을 수 있습니다.

손상된 Secret Key로 발급된 JWT토큰

만약 해커가 손상된 Secret Key로 토큰을 만들어서 행동한다면 문제가 발생할 수 있습니다. 만약 Secret Key가 손상되었다면 해커가 위조된 토큰을 발급할 수 있기 때문입니다.

이 때, 위조된 토큰으로 애플리케이션을 돌아다니면 치명적인 보안 문제로 이어질 수 있습니다.

이를 해결하기 위해서는 Secret Key에 대한 전략을 세워야하는데 먼저 MSA의 경우와 Monolithic의 경우를 살펴보죠.

* MSA인 경우 *

만약 MSA에서 JWT토큰을 발급하여 인증을 구현했다면 각 서버별 다른 Secret Key를 이용해서 JWT토큰을 만들면 해결할 수 있습니다. 이 해결 방법은 완전한 해결 방법은 아니고 문제를 전체 애플리케이션으로 확장하지 않게 하기위한 방지책정도인데요. 다른 도메인에 해당 문제로 인해 장애가 전파되지 않게 하기 위함입니다.

때문에, 모든 서버가 같은 Secret Key를 이용하는 것은 꽤나 문제가 되고 이런 경우 애플리케이션 전체 장애로 이어질 수 있습니다.

* Monolithic인 경우 *

아마 대부분의 경우가 이 경우일 것 같은데, 만약 Monolithic에서 JWT토큰을 이용해서 인증을 구현했다면 Secret Key를 지속적으로 바꿔주는 로직이 필요합니다.

항상 같은 Secret Key를 사용하면 Secret Key가 손상되는 경우에 대처하기 힘들기 때문입니다.

마치며

저희 회사에서도 JWT를 이용해서 인증을 구현하고 있고, 아마 대부분의 회사들이 JWT를 이용해서 인증을 구현하고 있을 것으로 예측됩니다.

JWT는 세션에 비해 생각해야 하는 것이 많고 애플리케이션의 복잡도가 많이 올라가기 때문에 신중하게 사용해야할 것 같습니다.

만약 자신의 회사에서도 JWT를 사용한다면 위의 문제에 어떻게 대응하고 있는지 알려주세요!

혹은 취준생이라면 자칫 단순히 JWT가 Stateless하고 세션에 비해 서버의 부하가 줄어들고 속도가 빠르고 이런 점만 면접관에게 어필한다면 좋은 먹잇감이 될 수 있으니 대비하는 것이 좋을 것 같습니다.

이번 포스팅은 여기서 마치도록 하겠습니다! 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요!

출처

https://pragmaticwebsecurity.com/articles/apisecurity/hard-parts-of-jwt.html

https://medium.com/@codealfi/understanding-jwt-authentication-benefits-and-limitations-3c388dba172e

??? : 비밀번호를 암호화 해달라고요? base64로 하면 되죠? (feat. Argon2)

죄송합니다.. 블로그 인생 처음으로 어그로좀 끌어봤습니다... 이번 포스팅에선 비밀번호 암호화, 암호화 된 해시값을 추적하는 해킹 기법들, 그리고 어떻게 웹 개발자로서 방지할 수 있는지에 대해서 공부한 내용을 정리해보도록 하겠습니다.

비밀번호 암호화

보통 비밀번호는 평문으로 저장하는건 위험하다고 알려져있습니다. 데이터베이스가 털렸을 때를 대비해서 비밀번호만큼은 지켜낸다는 일념인 것이죠.

데이터베이스를 털릴걸 가정하고 암호화를 한다니?

데이터베이스가 털릴걸 가정하고 암호화를 하는게 아니라 데이터베이스가 '털리더라도' 안전하게 지키는 것이 목적인 것이죠.

그런 의미에서 base64로 인코딩하는건 말도안되는 소리지요. base64는 역연산으로 얼마든지 비밀번호를 얻어낼 수 있으니까요.

BCrypt

BCrypt는 단방향 해시로서 비밀번호 암호화에 자주 쓰이는 알고리즘입니다. 암호화 알고리즘하면 빠지지 않는 SHA256와는 어떻게 다를까요?

이 표를 처음 봤을 때 살짝 애매했던 개념이 일부러 느리게 설정되어있다? 반복 연산으로 강도를 조절한다?

이 부분을 짚고 넘어가겠습니다.

Brute-Force

무차별 공격이라고 부르는 부르트포스는 임의의 비밀번호를 무차별적으로 10만개 100만개씩 집어넣는 해킹 기법을 이야기합니다. 그래서 보통 비밀번호를 암호화하는 알고리즘 (BCrypt) 에서는 일부러 연산을 느리게 만듭니다.

CPU, 메모리를 '일부러' 많이 잡아먹게 만들어서 이 브루트 포스에 면역을 가지게 하는 것이죠. 심지어 이 연산을 더 느리게 만들어서 브루트포스에 더 면역을 가지게 할 수도 있습니다.

이게 일부러 BCrypt 알고리즘이 일부러 느리게 설정된 이유이고 강도를 조절해서 이 브루트포스를 방지한다는 이야기입니다.

이런 내용을 공부하니 이 물음이 떠오르더라구요.

이게 공격자의 컴퓨터를 느리게 한다는 얘기인가? 서버가 느려진다는 말인가?

둘 다입니다.

공격자의 컴퓨터가 느려지면서 서버 또한 이 단방향 해시값을 처리하기위해 서버가 처리하는 연산이 많아집니다.

CPU와 메모리를 일부러 많이 잡아먹게 한다고 언급했습니다. 만약 공격자가 비밀번호를 해시값만 탈취했을 때 이걸 다시 비밀번호 원문으로 돌리는 과정에서 CPU와 메모리를 많이 잡아먹게 한다는 이야기입니다.

그럼 서버가 느려지는건 뭐냐? 만약 강도를 조절해서 강도를 강하게 해 역연산을 하기 힘들게 하는 순간 서버가 데이터베이스에서 해싱된 값을 가져와서 사용자가 넣은 평문을 '같은 해싱'을 거쳐서 비교하는 과정, 이 과정이 오래 걸리게 됩니다.

그럼 BCrypt는 무적이군요?

최신 브루트포스 해킹 기법

BCrypt는 1999년에 나온 암호화 기법으로 지금까지도 많이 사랑받는 암호화 기법입니다. 하지만 이 알고리즘이 나온지 25년이 지난만큼 해킹 기법도 많이 발전하게 되었습니다.

CPU 캐시 히트 모니터링 (사이드 채널 공격)

브루트포스도 그냥 무차별로 때려박는건 구식 트렌드입니다. CPU 캐시를 모니터링 하면서 히트했는지 안했는지 하나하나 따져가면서 브루트포스를 하는 방식으로 진화했습니다.

이를 예시로 들면 기존에는 보물섬에 가서 보물이 있는지 없는지 모든 땅을 다 파고 다니던 것이 금속탐지기를 가지고 금속함유량이 높은 곳만 파는 곳으로 바뀌었다는 이야기입니다.

그럼 설령 철조각이 나오더라도 보물일 '확률'이 올라가겠죠.

근데 CPU 캐시를 모니터링 한다고 어떻게 비밀번호를 유추할 수 있다는걸까요?

일단 공격자는 서버의 캐시를 깨끗하게 비웁니다. 이 단계를 Prime 단계라고 부릅니다. 그리고 평문으로 브루트포스를 날려봅니다. 그리고 메모리를 확인해보죠. 메모리를 확인하는 단계를 Probe 단계라고 부릅니다.

이 Prime, Probe 단계를 통해 어떤 메모리 블록이 활성화 되어있는지 확인합니다.

• 예를 들어서 내 비밀번호는 1098입니다. 근데 공격자가 1234, 1034, 1094 이렇게 세개의 값을 브루트포스합니다.

• 이렇게 비밀번호를 브루트포스 하면서 생기는 메모리버스의 패턴을 추적합니다.

• 그러면서 갑자기 팍! 하고 튀는 부분을 모니터링합니다. 그럼 공격자는 '아 이 비밀번호가 의미있는 처리를 유발했구나' 하고 추측하는 것이죠.

근데 좀 이상합니다? 그럼 지금 공격자가 내 서버에 들어와있다는 얘기인가요?

그럼 더 공격자에게 의미있는 데이터를 가져가면 되는데 쓸데없이 비밀번호만 깨작깨작 거리고 있나요?

예를 들어서 파일 업로드 취약점을 이용해서 내 악성 코드를 서버에 집어넣었다고 가정해봅시다. 근데 이 애플리케이션이 도커 컨테이너로 띄워져있네?

컨테이너는 호스트머신과 논리적으로 분리된 공간입니다. 그래서 실제 호스트 머신에 접근하는 것이 막혀있거나 권한이 없게 되죠. 해봐야 docker socket을 열어두는 정도려나요?

그 상황이지만 사실 물리적으로 봤을 때 도커 컨테이너는 호스트 머신과 다르지 않습니다. 그래서 L3 캐시나, 메모리 버스같은 시스템 자원에는 접근이 가능하죠.

예로 들자면 지금 공격자는 우리 아파트에 침입했습니다. 근데 옆집에 침입한거죠. 들리는 소리는 발소리와 비밀번호 도어락 누르는 소리뿐.. 어떤 정보도 얻을 수 없습니다.

이 때 공격자는 '아 옆집 사람이 평일에는 늘 오후7시에 집에 돌아오고 비밀번호는 4자리구나' '아 옆집에서 된장찌개를 먹고있구나' 이런 간접적인 정보만 가지게 되는 것이죠.

GPU와 병렬성

요즘 브루트 포스는 하나하나 집어넣지 않는다고 말씀드렸죠. 요즘은 GPU를 이용해서 병렬로 브루트포스를 진행해버립니다. GPU는 CPU와 비슷하지만 다른 것이 수십만개의 병렬 코어로 연속적인 처리를 할 수 있다는 것이 특징이죠.

이런 특징 때문에 디자인, 게임, AI 등 널리 사용되는 하드웨어입니다. 하지만 이걸 해커도 주로 사용합니다. 10만개의 병렬 코어로 병렬로 데이터를 집어넣어서 브루트포스에 걸리는 시간을 획기적으로 줄여버립니다.

성능 좋은 GPU를 사용한다면 공격에 성공할 확률도 매우 높아지게 됩니다.

Argon 2

보안 전문가들도 호구는 아니죠. 2015년 Argon2라는 암호화 알고리즘이 등장하였습니다. Argon2에는 세가지 알고리즘이 있는데요. Argon2i, Argon2d, Argon2id 입니다. 각 특징에 대해서 알아보겠습니다.

• Argon2i : Argon2i는 메모리를 순차적으로 접근해서 앞서 설명했던 메모리 버스의 모니터링 (사이드채널) 을 의미없게 만들어버립니다. 왜냐하면 메모리 접근이 일정하면 패턴을 파악하기 힘들기 때문이죠.

마치 이것은 보물섬에서 금속탐지기를 들고 여기저기 탐색중인데 모든 곳에서 신호가 일정하게 흘러나오는 것과 같습니다. 그럼 어디를 파봐야하는지 알 수 없게 되죠.

하지만 병렬 공격에 대해서는 취약하다는 단점을 가지고 있습니다.

• Agron2d : Argon2d는 메모리는 랜덤으로 접근합니다. 그래서 사이드채널이 가능하게 만들죠. 하지만 메모리 블록을 여러개 만들고 각각의 메모리 블록이 서로를 의존하게 만들어 병렬 공격에 면역을 가지게 합니다.

일반적으로 64MB의 메모리 블록으로 암호화를 진행하는데 하나의 메모리 블록은 보통 1KB입니다. 그럼 6만5천개 가량의 메모리 블록이 서로를 의존하게 되죠.

메모리 블록이 하나 만들어질 때 이전 메모리 블록 + 랜덤한 메모리 블록 이렇게 두 개를 조합해서 새로운 메모리 블록을 만들어냅니다.

이렇게 메모리 블록간 의존성이 강해지면 브루트포스를 진행할 때 병렬로 공격하기가 매우 힘들어집니다. 왜냐면 병렬로 공격한다는 얘기는 논블로킹에 가까운데 지금 상황은 블로킹에 가까워지니까요.

• Argon2id : Argon2i와 Argon2d는 서로가 서로의 장점과 단점이 엇갈리는 기술들입니다. 그래서 이 둘을 하이브리드로 합쳐놓은게 바로 Argon2id입니다.

Argon2id는 초반에는 메모리 접근을 일정하게 해 사이드 채널 공격으로부터 안전하게 지키다가 후에는 메모리 접근을 랜덤하게 바꾸고 메모리를 서로 의존하게 만들어 의존성을 높입니다.

이렇게 되는 경우 메모리 접근이 일정해서 사이드 채널 공격으로부터 안전해지고 GPU를 이용한 병렬 공격에서도 안전해지는 것이죠.

2015년 Argon2 암호화 알고리즘이 등장한 이후 Password Hashing Competition (PHC) 에서 1등을 차지해서 보안전문가들도 인정하는 업계 표준이 되었습니다. 누가누가 더 암호화 잘하나 대회에서 일등한 것 같습니다.

그래서 권장하는 알고리즘은 Argon2id를 사용하는 것이 권장된다고 하네요.

Spring Boot와 Argon2

스프링 부트에서는 Spring Security Starter를 사용한다면 BCryptPasswordEncoder를 사용할 수 있었죠. Argon2도 사용할 수 있게 되었는데 2018년 Spring Security 5.05 버전에서부터 사용할 수 있게 되었습니다.

기존과 마찬가지로 BCryptPasswordEncoder를 주입받듯이 Argon2PasswordEncoder를 주입받아서 사용하면 Argon2로 비밀번호가 암호화됩니다.

저도 지금 운영하는 프로젝트가 BCryptPasswordEncoder로 되어있는데 제 프로젝트는 인증된 소수의 사용자만 사용가능 하도록 해야해서 이참에 Argon2로 암호화 알고리즘을 바꿔야겠습니다.

마치며

제목에서 어그로가 강하게 느껴지지만 BCrypt 암호화 알고리즘이 단방향 해시이고 SHA256과 어떤 부분이 다른지 챗지피티와 커피챗을 하다가 지피티가 요즘은 Argon2라는 알고리즘이 표준이라는 얘기를 듣고 공부해봤습니다.

요즘 지피티와 이론 공부하는 것이 너무 재밌네요. 질문에 대한 답이 끝나면 "이런저런것도 있어 어떤게 궁금해?" 이런식으로 사고를 확장해주는게 너무 마음에 듭니다.

지피티와 이론 공부는 예전부터 많이 했었지만 항상 평소에 궁금한 것에 대해 답변을 받으면 그 이후에 뭘 물어봐야할지 몰라서 막막했었는데 요즘엔 다음 질문을 추천해줘서 너무 편합니다.

여러분들도 지피티를 이용해서 이론 공부를 해보시면 만족스러울 것이라고 확신합니다.

이번 포스팅은 여기서 마치도록 하겠습니다. 오늘도 즐거운 하루 되세요~