프로젝트 전체 관점으로 봤을 때
소프트웨어 공학이 가장 중요하다고 생각합니다. 전체적인 프로젝트 진행과정, 프로젝트 기획의 중요성 등의 이해가 없다면 분명히 그 프로젝트 진행중에 문제가 발생할 것입니다. 소프트웨어공학은 프로젝트 기획과 진행 단계에서 어떤 것을 지양하고 지향해야될지 길을 잡아줍니다. 예시로 하나 들자면, 기획단계에서 기능 정의가 명확하게 되어있지 않다면, 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 레벨 조정
Redis 분산 락
Redis Transaction
Redis 내부에서 Lua Script 실행
외국민 서비스에서 Race Condition을 예방한 부분
프로젝트 기술문서
추가적으로 백엔드 관련 배웠으면 하는 기술들