[책 공부] 주니어 백엔드 개발자가 반드시 알아야 할 실무 지식
책 소개

공부한 책 내용을 그대로 적는 것이 아닌 용어와 간단한 개념 설명 정도로만 정리하였습니다. 필요할 때 모르는게 있다면 그 때 검색하기 위함입니다. 주니어 개발자로써 다방면으로 시니어 개발자가 조언해주는 느낌입니다. 보면 생각의 전환으로 좋습니다.
뭐든 성능을 높이겠다고 처음부터 어렵게 설계해놓으면 오버 엔지니어링이 될 수 있다. 일단 사용자를 불러들이는 것이 가장 중요하고, 그 후에 대처하는 것이다. 대처하기 위해서는 당연히 공부를 해야 한다. 초반에 어느정도는 대처할 수 있을 것이다.
느려진 서비스
처리량과 응답시간
구글에 따르면 검색 지연 시간이 길어질수록 사용자당 검색 횟수가 줄어드는 경향이 있다.
- 100ms 지연 시: 검색 횟수 0.2% 감소
- 400ms 지연 시: 검색 횟수 0.6% 감소
서버가 처리할 수 있는 요청수보다 많으면 응답시간은 길어진다. 이를 방지하기 위한 2가지 방식
- 서버가 동시에 처리할 수 있는 요청 수를 늘려 대기 시간 줄이기
- 처리 시간 자체를 줄여 대기 시간 줄이기
응답시간과 비슷한 타임아웃을 5초 정도(예시)로 짧게 잡는 것이 좋다. 30초 무한정 기다리는 것보다는 빠르게 에러 화면을 보여주는 것이다.
커넥션 풀
커넥션 풀 크기를 늘리면 처리량을 높일 수 있다. 그러나 무턱대고 늘리다보면 DB 서버의 CPU 사용률이 80%에 육박하는 상황에서 DB에 가해지는 부하가 더 커져 쿼리 실행 시간이 급격히 증가한다. 또한 대기시간을 설정할 수 있는데 기본값인 30초로 설정하는 것보다 짧은 시간으로 설정하여 빠르게 에러를 반환하는 것이 더 낫다.
서버 캐시
DB 서버를 확장하지 않고도 응답 시간과 처리량을 개선하고 싶다면 캐시(Cache) 사용을 고려할 수 있다.
서버가 사용하는 캐시에는 두 종류가 있다.
- 로컬 캐시: 서버 프로세스와 동일한 메모리를 캐시 저장소로 사용
- Caffeine(자바)
- 리모트 캐시: 별도 프로세스를 캐시 젖장소로 사용
- 레디스(Redis)
캐시에 보관할 데이터 규모가 작고 변경 빈도가 매우 낮다면 로컬 캐시, 데이터 규모가 크다면 리모트 캐시
캐시 적중률이 낮아지고, 단시간에 150만명이 동시에 접속시 트래픽이 감당 안될 수 있다. 이를 방지하기 위해 사전에 캐시에 데이터를 넣어두는 것이다. 그러면 사용자가 접속시 캐시 적중률이 99%에 가깝게 유지되어 트래픽이 안정될 수 있다.
하지만 가격 정보, 게시글 내용처럼 민감한 데이터는 변경되는 즉시 캐시를 무효화해야 한다. 변경에 민감한 데이터는 로컬 캐시가 아닌 리모트 캐시에 보관해야 한다. 로컬 캐시는 자신의 데이터만 변경하지 다른 서버의 로컬 캐시는 변경하지 않기 때문이다.
- 최근 인기 목록 같은 경우 캐시의 유효시간을 설정하여 주기적으로 갱신하는 방식을 사용해도 ok
메모리
대량의 데이터를 한번에 메모리에 올리면 안된다. 서버가 뻗을 수 있다. 파일 다운로드와 같은 기능을 구현할 때는 스트림을 활용해야 한다.
대기처리
사용자가 순간적으로 폭증할 때가 있다.
- 예시) 콘서트 예매
여기서 생각할 수 있는 트래픽 대처는 먼저 사전에 서버를 증설하는 것이다. 다만 순간적인 짧은 시간을 버티기 위해 서버를 증설하는 것은 부담이 될 수 밖에 없다. 따라서 수용할 수 있는 수준의 트래픽만 받아들이고 나머지는 대기처리 하는 것이다.
- 예시) 은행창구
대규모 트래픽을 반드시 처리해야할 필요는 없다. 트래픽과 비용을 같이 생각해야 한다. 적절한 제약은 좋은 것이다.
DB 설계와 쿼리
DB 성능 문제 원인 - 풀스캔
인덱스 설계
- 단일 인덱스: userId만 인덱스로 사용
- 복합 인덱스: (userId, activityDate)를 인덱스로 사용
단일 인덱스를 사용해도 되지만 회원들의 활동성이 좋다면 (userId, activityDate) 컬럼을 ㅈ조합한 복합 인덱스 사용을 고려해야 한다.
인덱스를 생성할 때는 선택도가 높은 컬럼을 골라야 한다. 다만 너무 많이 만들면서 CUD 작업시 성능이 악화되 필요한 만큼만 만들어야한다.
전체 개수 세지 않기
목록을 표시하는 기능은 전체 개수를 함께 표시하는 경우가 많다. 데이터가 적을 때는 count 쿼리를 사용해도 무방하지만, 문제는 데이터가 급격히 증가하기 시작할 때이다. 데이터가 많아질수록 count 실행 시간도 증가하는데, 그 이유는 조건에 해당하는 모든 데이터를 탐색해야 하기 때문이다. 커버링 인덱스를 사용하더라도 전체 인덱스를 스캔해야 한다.
오래된 데이터 삭제 및 분리 보관하기
데이터 개수가 늘어나면 늘어날수록 쿼리 실행시간은 증가한다. 따라서 로그인 시도 내역 같은 데이터는 장기간 보관할 필요가 없기 때문에 삭제한다. 혹은 분리하여 데이터를 보관한다.
트랜잭션 고려
모든 코드가 항상 정상적으로 동작하는 것은 아니기 때문에 비정상 상황에서의 트랜잭션 처리를 반드시 고민해야 한다. 트랜잭션을 고민하지 않고 코드를 작성하면 데이터 일관성에 문제가 생길 수 있다. 자주 발생하는 실수 중 하나가 트랜잭션 없이 여러 데이터를 수정하는 것이다.
@Transactional
public void join(JoinRequest join) {
...
memberDao.insert(member); // DB에 데이터 추가
mailClient.sendMail(...); // 메일 발송
}
메일 서버에 일시적인 문제가 있어 sendMail()에서 런타임 예외가 발생했다. 스프링의 @Transactional은 예외가 발생하면 해당 메서드에서 전체 롤백한다. 따라서 DB에 회원 데이터를 정상적으로 추가했더라도 메일 발송 중 예외가 발생하면 회원가입 전체가 실패하게 된다. 하지만 운영에서 이런걸 원하지는 않을 것이다.
@Transactional
public void join(JoinRequest join) {
...
memberDao.insert(member); // DB에 데이터 추가
try {
mailClient.sendMail(...); // 메일 발송
} catch (Exception e) {
// 메일 발송 오류 무시
// 로그로 기록해 모니터링
}
}
위와 같이 따로 에러처리를 수행할 수 있다. 외부 API 연동과 DB 작업이 섞이면 트랜잭션 처리가 복잡해진다. 자, 다시 정리해보자.
재시도
재시도를 통해 연동 실패를 줄일 수 있지만, 항상 재시도를 할 수 있는 것은 아니다. 연동 API를 다시 호출해도 되는 조건인지 확인해야 한다.
- 단순 조회 기능
- 연결 타임아웃
- 멱등성을 가진 변경 기능
또한 재시도를 무한정 할 수는 없기 때문에 1~2번 정도의 재시도가 적당하다. 단점도 존재하는데 연동 서비스에는 더 큰 부하를 줄 수 있다. 연동 서비스의 성능이 느려져서 타임아웃이 발생한다면 같은 요청을 두배로 받게되기 때문에 상당히 느려질 것이다.
동기 비동기
일반적으로 로그인에 성공하면 포인트를 지급하는 기능을 개발해야 하는 경우 동기적으로 순서대로 코드를 작성하여 작동하게 만들 수 있다. 하지만 포인트를 지급하는 것과 같은 외부 서비스의 응답시간이 길어질수록 전체 응답시간이 느려진다. 심한 경우 외부 연동 서비스로 인해 전체 서비스가 먹통이 되기도 한다. 이 경우에는 비동기 방식으로 연동하는 것을 고려해볼 필요가 있다. 생각보다 많은 연동에서 비동기 방식을 사용해도 된다.
- 쇼핑몰에서 주문이 들어오면 판매자에게 푸시 보내기 (푸시 서비스 연동)
- 학습을 완료하면 학생에게 포인트 지급 (포인트 서비스 연동)
- 컨텐츠를 등록할 때 검색 서비스에도 등록 (검색 서비스 연동)
- 인증 번호를 요청하면 SMS로 메시지 발송 (SMS 발송 서비스 연동)
이 예시들은 몇가지 공통점이 있다.
- 연동에 약간의 시차가 생겨도 문제가 되지 않는다.
- 쇼핑몰에서 주문이 완료된 후 1분 뒤에 판매자에게 푸시가 나가도 판매에 지장이 없다.
- 일부 기능은 실패했을 때 재시도가 가능하다.
- 푸시 발송에 실패했을 경우 재시도를 통해 푸시가 발송될 수 있다.
- 연동에 실패했을 때 나중에 수동으로 처리할 수 있는 기능도 있다.
- 연동에 실패했을 때 무시해도 되는 기능도 있다.
- 주문이 들어왔을 때 판매자에게 푸시가 발송되지 않더라도 판매에는 문제가 생기지 않는다.
비동기 연동 방식 5가지
- 별도 스레드로 실행
- 메시지 시스템 이용
- 트랜잭션 아웃박스 패턴 사용
- 배치로 연동
- CDC 이용
동시성
경쟁상태
여러 쓰레드가 동시에 공유자원에 접근할 때, 접근 순서에 따라 결과가 달라지는 상황을 경쟁상태(race condition)라 한다. 경쟁상태가 발생하면 예상하지 못한 결과가 발생할 수 있다. 쓰레드 말고도 static 변수에 변화를 주면 동시성 이슈, 즉 경쟁상태가 발생한다. 그래서 멀티쓰레드 환경에서는 항상 동시성 문제를 생각해야 한다.
동시성 문제 해결
- 잠금(lock)을 이용한 접근 제어
- 락을 획득함
- 공유 자원에 접근 (임계영역)
- 락 해제
임계영역은 동시에 둘 이상의 스레드나 프로세스가 접근하면 안되는 공유자원에 접근하는 코드 영역을 말한다.
뮤텍스와 세마포어
뮤텍스는 mutual exclusion의 약자로 뮤텍스를 다른 말로 잠금(lock)이라고도 한다. 자바에서는 Lock 타입을 사용하고, Go에서는 Mutex인 타입을 사용한다.
세마포어(Semaphore)는 동시에 실행할 수 있는 쓰레드 수를 제한한다. 자원에 대한 접근을 일정 수준으로 제한하고 싶을 때 세마포어를 사용할 수 있다. 예를들면, 외부 서비스에 대한 동시 요청을 최대 5개로 제한하고 싶을 때 세마포어를 사용한다.
동시성 지원 컬렉션
스레드에 안전하지 않은 컬렉션을 여러 쓰레드가 공유하면 동시성 문제가 발생할 수 있다. 자바에서 제공하는 HashMap, HashSet 같은 컬렉션을 여러 쓰레드가 동시에 변경하면 데이터가 깨진다. 이를 해결하기 위해 나온 것이 동기화된 컬렉션을 사용하는 것이다. 즉, 데이터를 변경하는 모든 연산에 락을 적용해서 한번에 한 스레드만 접근할 수 있도록 제한하는 것이다.
동시성 문제를 피하기 위한 또 다른 방법은 불변값을 사용하는 것이다. 값이 바뀌지 않기 때문에 동시에 여러 스레드가 접근해도 문제가 발생하지 않는다.
Comments