프로젝트 전체 관점으로 봤을 때
소프트웨어 공학이 가장 중요하다고 생각합니다. 전체적인 프로젝트 진행과정, 프로젝트 기획의 중요성 등의 이해가 없다면 분명히 그 프로젝트 진행중에 문제가 발생할 것입니다. 소프트웨어공학은 프로젝트 기획과 진행 단계에서 어떤 것을 지양하고 지향해야될지 길을 잡아줍니다. 예시로 하나 들자면, 기획단계에서 기능 정의가 명확하게 되어있지 않다면, A랑 B가 이해한 기능이 다르고, 서로 다르게 개발해오고, 결국 다시 만들어서 Time과 Cost가 낭비될 가능성이 커집니다.
아직 모든 시리즈를 다 작성하진 않았지만, 제 블로그에 프로젝트를 어떤식으로 진행했는지 과정을 써둔 글이 있습니다. 혹시라도 프로젝트를 준비하시는 후배님들에게 도움이 될 수 있지 않을까해서 첨부합니다.
백엔드 관점으로 봤을 때
백엔드 관점으로 봤을 때는 소프트웨어 디자인패턴, 정보보호와 시스템보안,운영체제 세가지가 가장 중요하다고 생각합니다.
디자인패턴은 백엔드 개발하다보면 적용하거나 이해가 필요한 경우가 꽤 있습니다. 예를 들어, 싱글톤, 팩토리, 빌더 패턴은 꽤 많이 사용되었습니다. 뿐만 아니라, 기존에 구현된 기능이 어떻게 작동하는지 이해하기 위해서 프록시, 데코레이터, 옵서버 패턴 등을 알아야 합니다. 아래 사이트에 자세히 잘 나와있으니 참고하면 좋을 것 같습니다.
정보보호와 시스템보안도 역시 백엔드 기능 구현과 밀접한 관련이 있습니다. 예를 들어서 회원가입 기능을 개발했다고 해봅시다. User의 ID와 Password를 이제 DB에 저장해야되는데 암호화 하지 않고 절대 그대로 저장해서는 안됩니다. 일반적으로 Salt 문자열 + 비밀번호를 SHA256같은 Hash 함수를 돌려 암호화하여 저장합니다. 만약, 정보보안에 대한 지식이 없었으면 구현하기 힘들었을 것입니다. 또한, 웹 서비스를 만든다고 가정해봅시다. 그러면 높은 확률로 HTTPS를 적용할텐데, 이게 왜 필요하고 작동되는 원리를 알기 위해서 TLS/SSL 프로토콜, 공개키&개인키, 대칭키, CA 등의 개념을 알아야됩니다. 정보보호와 시스템보안은 프론트/백엔드/AI 모두 기본적으로 알아야될 보안에 대한 상식을 알려주는 과목입니다. 그래서 반드시 듣는 것을 추천합니다.
운영체제와 관련된 것은 2번에서 자세히 설명하겠습니다.
기말고사 내용에서 스레드, Race Condition, Lock 부분이 개발에 직접적으로 관련이 있습니다. 아래는 어느 부분이 관련 있는지 정리한 문서입니다.
기술스택의 사용과 이해
외국 프로젝트 백엔드에서 사용한 기술 스택은 아래와 같습니다.
이중에서 일부 기술스택들을 이해하고, 사용하기 위해서는 운영체제에에 대한 이해가 필요합니다. 대표적으로 Spring, Nginx, Redis에 대한 이야기를 해보고자 합니다.
Redis를 왜 써야 하는가?
운영체제와 컴퓨터구조를 배우셨다면 I/O 작업이 굉장히 느리다는 것을 아실 것입니다. 그래서 우리가 자주 접근해야되는 데이터를 물리적 디스크에 저장하면 처리가 느려서 병목현상이 발생할 것입니다. 그런데 만약에 데이터를 메모리에서 읽어오면 어떨까요? 훨씬 빠르게 읽어올 수 있고, 병목현상이 크게 줄을 것입니다.
Redis는 MySQL, PostgreSQL, MongoDB 등과 다르게 In-Memory 방식의 데이터베이스입니다. 그래서 Redis를 이용하면 굉장히 빠르게 접근이 가능합니다. 따라서, 자주 접근해야되는 RefreshToken, Session Storage, 캐싱 등에 쓰면 매우 효과적이죠. Redis를 개발에 이용할 수 있는 방법은 아래 제 블로그에 정리해둔 글이 있습니다. 더 궁금하다면 아래 글을 참고해주세요.
[개발 탐구] Redis, 실제 개발에서 어떻게 활용할 수 있을까?
실제로 저희 프로젝트에서도 Redis를 이용하여 캐싱을 적용했더니 굉장히 효과적인 성능을 냈습니다.
AOP를 이용해서 메서드별 시간 측정을 만들었는데, 캐싱 적용전에는 414ms가 걸리는 모습입니다.
반면에 Redis를 이용하여 캐싱을 적용하니 12ms만에 메서드가 실행되었습니다. 거의 1/40 가까이 실행시간이 줄어들었습니다. 기존에는 Apache Jmeter로 공지사항 받아오기에서 부하 테스트를 걸어봤을 때 1200 RPS 정도 감당이 가능했습니다. 그런데 캐싱을 적용한 덕분에, 3000 RPS 정도는 감당이 가능하게 되었습니다. (이 이상은 안걸어보긴함) 만약에, 캐싱에 관련해서 더 궁금하다면 제가 쓴 아래 글도 있으니 참고해주세요.
또한, Redis를 쓰는 주된 이유는 Redis가 싱글 스레드로 작동하기 때문입니다. 따라서 Race Condition을 예방하는데 굉장히 효과적입니다. 이에 관한 내용은 아래에서 자세히 설명하겠습니다.
Nginx vs Apache
웹서버로서 흔히 쓰이는 두가지가 Apache랑 Nginx입니다. Nginx와 Apache는 스레드 사용 측면에서 큰 차이가 있습니다.
Apache 같은 경우는 위에 그림처럼, 하나의 커넥션 당 하나의 스레드를 잡아먹습니다.
Nginx는 위 사진처럼, 하나의 Master Process와 여러개의 Worker Process로 나누어져 있습니다. 이때, master process는 worker process를 관리하고, worker process에서 실질적인 사용자 응답을 처리합니다. worker process는 Nginx 설정에 따라서 바꿀 수 있습니다.
이때, 이 Worker Process가 싱글 스레드 방식으로 작동합니다. 이때, 비동기 Event-driven 방식을 통해 동시에 많은 요청을 처리할 수 있어 Apache보다 훨씬 더 효율적입니다. 훨씬 적은 양의 스레드가 사용되기 때문에 CPU 자원 소모가 월등히 적으며, 싱글 스레드이기 때문에 context switching 비용이 적습니다. 그래서 Apahce보단 Nginx를 대부분 사용하는 추세입니다. Nginx에 대해서 더 궁금하다면 아래 글을 참고해주세요.
Netty VS Tomcat
일반적으로 SpringMVC에서 많이 사용하는 WAS는 Tomcat입니다. Tomcat은 요청이 들어오면 ThreadPool에서 Thread를 하나 배정해줍니다. 또한, 각 요청을 동기적으로 처리하고, 동기적으로 처리하는 동안 해당 스레드는 블로킹 됩니다. (비동기 처리가 불가능한 것은 아님. 하지만 비동기 처리를 하더라도 어찌됐던 간에 ThreadPool을 소모하니깐, ThreadPool 고갈 문제는 여전) 그래서 동시에 많은 요청이 들어올 경우 스레드풀이 고갈되어, 성능이 저하될 수 있습니다. 하지만, 설정이 간편하고, 코드도 훨씬 간결하며, 다른 기술과 연동이 쉽습니다. 그래서 일반적인 웹 어플리케이션이 Spring MVC로 개발되는 것입니다. 다만, 앞서 말한 스레드풀 고갈 문제가 있습니다. 그래서 monolithic 아키텍처가 거의 안쓰이고, 요즘 거의 업계 표준이라고 할 수 있는 MSA가 대두되는 것입니다.
위 스택에서 Spring Cloud Gateway로 API Gateway를 구축했는데, 여기서 사용되는 WAS가 바로 Netty입니다. Netty는 비동기 Event-driven 구조입니다. 앞서 말한 Nginx의 Worker Process와 거의 유사합니다. 그래서 빠르게 대규모 트래픽을 처리해줘야하는 API Gateway 구조에서 Netty가 적합합니다. 고성능 네트워크 애플리케이션을 가발하는 경우, 비동기 및 논블로킹을 I/O를 적극 활용해서 높은 동시성을 달성해야되는 경우 Webflux나 Cloud Gateway처럼 Netty를 사용하는 것입니다. 다만 문제는 매우 복잡하고, 고수준의 기능을 제공하는 다른 기술 스택과 호완성 문제가 발생하는 경우가 많습니다. 따라서, 상황에 맞게 골라서 써야됩니다.
Race Condition의 예방
개발을 하다보면 Race Condition에 대해서 신경써야되는 상황이 많이 발생합니다. 예시로, 외국민 프로젝트에서 게시글의 조회수/추천수를 증가시키는 기능이 있다고 해봅시다. 이 부분은 단지 “DB로 부터 값 읽기 → 값 증가” 이 과정이다보니 Race Condition이 발생할 가능성이 매우 큽니다. 따라서 이 부분을 신경써서 개발해야됩니다. 그렇다면 어떻게 Spring에서 이를 예방할 수 있을까요?
synchronized를 이용한다.
해당 방식은 문제가 몇 있습니다.
우선 위 그림처럼, 메서드 자체를 synchroized를 건다고 해봅시다. 그러면, 메서드에 대해서는 lock이 걸립니다. 하지만, 메서드가 종료되는 시점에서 이제 Transaction commit을 날려서 반영을 하는데, 그 사이에 짧은 텀이 있습니다. 저 짧은 구간에서 race condition이 또다시 발생하게 됩니다.
그러면, Transaction 시작 전에 synchronized를 묶어버리면 안될까요? 이럼 예방이 되긴 합니다. 하지만, 우리가 로드 밸런싱을 한다고 서버를 여러개 두면 문제가 생깁니다.
서버가 두대 이상일 경우, synchroized는 하나의 프로세서 안에서만 Race Condition이 예방되기 때문에 결국에는 Race Condition이 발생합니다. 따라서, 이는 좋은 방법이 아닙니다.
DB Lock
그래서 위 방식을 해결하기 위해서 DB에 락을 거는 방법이 있습니다.
비관적 락
위 사진처럼, 직관적으로 이미 다른 트랜젝션이 DB에서 Lock을 잡고 있으면 접근 못하는 형태입니다. 다만, 위 방식의 문제점은 하나의 트랜잭션의 작업이 완료될 때까지 lock을 걸고 있기 때문에 다른 트랜잭션은 대기해야되서 성능적 문제가 있습니다. 또한 단일 DB가 아닌 환경에서는 문제가 발생할 수 있습니다.
낙관적락
위 사진 처럼, 각 데이터에 버전 정보가 있습니다. t1이 version 정보가지고 업데이트해서 version 2정보로 바뀌었죠. 그런데 이 상태에서 t2가 version1의 정보를 가지고 역시 업데이트를 한다고 가정해봅시다. 그런데 이미 DB에 반영된건 version2 정보이기 때문에 반영되지 않습니다. 그래서 분산 DB 환경에서도 문제없이 사용이 가능합니다. 다만, 실패했을 경우 다시 시도하는 로직을 개발자가 직접 작성해줘야됩니다.
Named Lock
DB 자체에 Lock이라는 분산락을 만들고, 이걸 이걸로 lock을 제어하는 방법입니다. 그런데 이 방식을 사용하면, Lock을 가져오는 스레드와 트랜잭션을 유지하는 스레드까지 두개를 사용해야되기 때문에 좋은 방법은 아닙니다. 더 자세한 내용이 궁금하다면 아리 우아한 형제들 기술 블로그를 참조 바랍니다.
트랜잭션 Isolation 레벨 조정
앞서 말한 Lock은 쓰기 단계에서 정합성을 지키기 위한 방법이었습니다. 그렇다면, 읽기 단계에서 정합성을 어떻게 지킬 수 있을까요? 바로 DB의 Isolation Level을 엄격하게 하는 것입니다. 동시에 여러 트랜잭션이 실행될 때 트랜잭션 간의 간섭을 제어하고 데이터 일관성을 보장하는 방식을 결정합니다.
예를 들어, Read Uncommitted 격리 수준은 커밋되지 않은 다른 트랜잭션에서 변경된 데이터를 읽을 수 있지만, Serializable 격리 수준은 트랜잭션이 완전히 완료될 때까지 다른 트랜잭션에서 해당 데이터를 읽을 수 없습니다. 따라서 격리 수준은 주로 읽기 작업에 대한 데이터의 일관성을 보장하기 위해 사용됩니다.
격리 수준에 대한 자세한 내용은 이 곳에서 다루기 어려우니 아래 링크를 참조 바랍니다.
Redis 분산 락
비관적 락의 문제는 분산 DB 환경에서, lock이 단일 DB에 잡히기 때문에 발생한 문제입니다. 그러면 락을 잡는 것을 다른 DB에 분산하면 되지 않을까?라고 생각할 수 있습니다. 그래서 나온 것이 Redis를 이용한 분산 락입니다. 앞서 말했다 싶이, Redis는 단일 스레드에서 실행됩니다. 따라서, 이러한 특성을 이용하여 Redis에 lock 여부를 저장해두는 것입니다.
Spring에서 Redis 활용을 위한 라이브러리로 대표적인 것이 Lettuce와 Redisiion이 있습니다. 이 둘의 Lock 작동 과정이 다릅니다.
Lettuce를 사용한 Redis Lock
Lettuce는 Spin Lock 방식으로, 위 코드 처럼 lock 휙득을 실패했을때 재시도하는 로직을 개발자가 작성해줘야 됩니다. 또한, 지속적으로 계속 락을 얻을 수 있는지 확인해야되다 보니 비효율적일 수 있습니다.
Reddision을 이용한 Redis Lock
Redission은 pub/sub 기반으로 락이 구현되어 있습니다. 따라서, 별도로 재시도하는 로직을 작성하지 않고, 훨씬 효율적입니다. (Redis에 부하를 덜 줌) 하지만, Lettuce에 비해서 별도로 설정해야 될 것이 좀 많습니다.
저희 외국민팀도 Redis Lock 방법을 이용하였습니다. 추가적으로 어느 IT 오픈 채팅방에서 보았던 Redis Lock에 대한 좋은 내용이 있어서 캡처해서 공유합니다.
Redis Transaction
앞서 언급한 방법들은 실제 RDB에서 정보를 읽어오고 저장할 때 정합성과 관련된 문제들이었습니다. 그런데 예를 들어, Cache나 Refresh Token 같은 것들은 RDB가 아니라 Redis에서 읽어올텐데, 이때 정합성을 어떻게 보장할까요?
Redis는 싱글스레드에서 실행되기 때문에 당연히 Race Condition이 보장되는 것이 맞습니다. 하지만, 위에 코드를 봐봅시다. 위 그림과 같이 두 명의 사용자가 동시에 increaseViewCount 메서드에 접근했다 해봅시다. 이때, t1은 아직 레디스에 값을 저장하지 않은 상태에서, 갑자기 t2가 들어와서 값을 읽었다면, 값이 하나 누락되고 Race condition이 발생하게 됩니다.
이는, Redis 자체는 싱글 스레드에서 구동되는 것은 맞지만, 값을 읽어오고 증가시키는 저 일련의 로직이 Atomic하게 보장되있지 않기 때문입니다. 따라서, 저 묶음을 Transaction 처리해서 Redis 내부에서 실행시키면 어떨까요? 그러면 Atomic이 보장될 것입니다. (참고로 Redis Lock으로 해도 됩니다.)
Redis Transaction을 이용해서 Race Condition 예방하는 과정은 아래 여기어때 기술블로그 글을 참고해주시기 바랍니다.
Redis 내부에서 Lua Script 실행
외국민 서비스에서 Race Condition을 예방한 부분
프로젝트 기술문서
추가적으로 백엔드 관련 배웠으면 하는 기술들