헷갈렸던 기술들을 복습한다.

1. 배열 vs 연결리스트

연산별 비교

연산 배열 연결리스트
인덱스 접근 O(1) O(n)
값 탐색 O(n) O(n)
맨 앞 삽입/삭제 O(n) O(1)
중간 삽입/삭제 (위치를 이미 참조 중) O(n) O(1)
중간 삽입/삭제 (인덱스만 아는 경우) O(n) O(n)

마지막 줄이 자주 잘못 이야기되는 부분이다. 연결리스트의 중간 삽입이 O(1)인 것은 해당 노드의 참조를 이미 손에 들고 있을 때뿐이다. list.add(5000, x)처럼 인덱스만 아는 상태라면 head부터 5000번 따라가야 하므로 결국 O(n)이다. 순회 중 이터레이터로 삭제하는 상황이 아니라면 이 이점은 잘 발생하지 않는다.

진짜 차이는 메모리 배치

시간복잡도 표만으로는 왜 실무에서 배열 기반 자료구조를 기본으로 쓰는지 설명되지 않는다. 이유는 캐시다.

CPU는 메모리를 바이트 단위가 아니라 캐시 라인 단위(보통 64바이트) 로 읽어온다. 4바이트 int 배열을 순회하면 한 번의 메모리 접근으로 16개 원소가 캐시에 올라오고, 나머지 15번은 캐시 히트다. 게다가 하드웨어 프리페처가 순차 접근 패턴을 감지해 다음 라인을 미리 당겨온다.

연결리스트는 노드가 힙 여기저기에 흩어져 있다. 노드를 하나 따라갈 때마다 새로운 캐시 라인을 읽어야 하고, 주소가 예측 불가능하니 프리페처도 도움이 되지 않는다. 포인터 추적(pointer chasing) 은 매 단계가 메모리 지연(수십~수백 사이클)에 그대로 노출된다.

공간 오버헤드도 있다. Java LinkedList의 노드는 값 참조 외에 prev/next 참조와 객체 헤더를 함께 들고 있어, 원소 하나당 수십 바이트가 추가로 든다. 담는 데이터가 작을수록 이 비율은 커진다.

그래서


2. 이진 탐색 트리와 B-tree

최악의 경우가 O(n)인 이유

BST 탐색 비용은 정확히는 O(n)이나 O(log n)이 아니라 O(h), 즉 트리 높이에 비례한다. 문제는 h가 삽입 순서에 따라 달라진다는 점이다.

1, 2, 3, 4, 5를 순서대로 삽입하면 새 값은 항상 현재 노드보다 커서 오른쪽 자식으로만 붙는다. 결과는 한쪽으로 완전히 치우친(skewed) 트리이고, 이는 사실상 연결리스트다. h = n이 되어 탐색이 O(n)이 된다.

O(log n)이 나오는 전제는 “매 비교마다 후보가 절반씩 줄어든다”는 것이고, 그 전제는 좌우 서브트리 크기가 비슷할 때만 성립한다. 정렬된 데이터를 그대로 넣는 것은 실무에서 드물지 않기 때문에(예: 자동 증가 ID, 타임스탬프) 이건 이론적인 걱정이 아니다.

균형 트리

DB 인덱스가 B-tree인 이유

메모리 자료구조와 디스크 자료구조는 최적화 대상이 다르다. 디스크(혹은 SSD)에서는 비교 횟수가 아니라 페이지 I/O 횟수가 비용을 지배한다.

이진 트리는 노드마다 자식이 둘뿐이라 원소가 100만 개면 높이가 20이고, 최악의 경우 20번의 랜덤 I/O가 필요하다. B-tree는 노드 하나를 디스크 페이지 크기(InnoDB 기본 16KB) 에 맞춰 키를 수백 개씩 담는다. 팬아웃이 수백이 되면 같은 100만 건도 높이가 3 정도로 떨어진다. 게다가 상위 레벨 노드는 버퍼 풀에 캐시되어 있어 실제 디스크 접근은 리프 한 번 수준이다.

B+tree는 여기서 한 걸음 더 나간다.

인덱스 설계에서 정렬과 범위 조건이 왜 그렇게 잘 맞아떨어지는지가 여기서 나온다.


3. 유니크 인덱스의 실제 비용

풀스캔이 아니다

유니크 제약은 내부적으로 유니크 인덱스(B-tree) 로 구현된다. 삽입 시 중복 확인은 그 인덱스를 타는 탐색이므로 O(log n) 이지, 테이블 전체를 훑는 것이 아니다.

그럼 실제 비용은 어디서 나오나

  1. 인덱스 유지 비용: 쓰기마다 B-tree에 키를 넣고, 페이지가 꽉 차면 분할(split)이 일어난다. 랜덤한 값(UUID 등)을 인덱스로 잡으면 분할과 페이지 파편화가 잦아진다.
  2. 체인지 버퍼를 못 쓴다: InnoDB는 세컨더리 인덱스 갱신을 메모리에 모아뒀다 나중에 반영하는 change buffer 최적화를 갖고 있는데, 유니크 인덱스에는 적용되지 않는다. 중복 여부를 지금 당장 확인해야 하므로 해당 페이지를 반드시 디스크에서 읽어와야 하기 때문이다. 쓰기가 많은 테이블에서 유니크 인덱스가 비싼 진짜 이유가 이것이다.
  3. 잠금: 중복 체크 과정에서 인덱스 레코드에 락이 잡힌다. 동시에 같은 키로 들어온 트랜잭션은 대기하고, 격리 수준에 따라 갭 락이 얽히면 데드락이 생길 수도 있다.

그래도 거는 게 맞는 경우

선착순 쿠폰 발급처럼 중복·초과 발급이 곧 사고인 도메인에서는 (coupon_id, member_id)에 UNIQUE를 걸어 최종 방어선으로 둔다.

애플리케이션에서 “조회해보고 없으면 INSERT”하는 방식은 조회와 삽입 사이에 다른 트랜잭션이 끼어들 수 있어 동시성 상황에서 언제든 뚫린다. Redis 카운터나 분산 락으로 앞단을 막더라도 그것들은 장애·재시도·타임아웃 상황에서 완벽하지 않다. DB 유니크 제약은 그 모든 게 실패해도 뚫리지 않는 마지막 계층이다.

중복 시 예외를 던지게 두고 애플리케이션에서 “이미 발급됨”으로 변환하거나, INSERT ... ON DUPLICATE KEY UPDATE로 흡수하는 식으로 설계한다. 성능 주장이 갈릴 때는 결국 실측한 숫자가 근거다.


4. 뮤텍스 vs 세마포어

개념

이진 세마포어와 뮤텍스는 왜 다른가

N=1인 세마포어는 겉보기 동작이 뮤텍스와 같아 보이지만 소유권 개념이 없다. 이 차이가 실제로 만드는 결과는 다음과 같다.

용도 구분


5. 대칭키 vs 비대칭키

두 방식

또 하나 실무적인 차이로, 비대칭키는 한 번에 다룰 수 있는 데이터 크기가 키 길이에 묶여 있다. 대용량 데이터를 통째로 비대칭키로 암호화하는 방식 자체가 성립하지 않는다.

TLS는 둘을 어떻게 조합하나

핵심은 비대칭키를 데이터 암호화가 아니라 인증과 키 합의에만 쓰고, 실제 데이터는 대칭키로 처리한다는 것이다.

TLS 1.3 기준 흐름은 이렇다.

  1. 클라이언트와 서버가 ECDHE(타원곡선 Diffie-Hellman) 로 각자의 임시 공개값을 교환하고, 각자 자기 개인값과 상대 공개값을 조합해 같은 비밀값을 도출한다. 이 비밀값 자체는 네트워크에 흐르지 않는다.
  2. 서버는 인증서와, 핸드셰이크 내용에 대한 개인키 서명을 보낸다. 클라이언트는 CA 체인으로 인증서를 검증하고 서명을 확인해 “지금 이 서버가 그 인증서의 개인키를 실제로 갖고 있다”는 사실을 확인한다.
  3. 도출한 비밀값에서 대칭키를 파생시키고, 이후 모든 데이터는 AES-GCM 같은 대칭 암호로 주고받는다.

여기서 두 가지를 짚어둘 만하다.


6. Kafka 컨슈머 장애 복구

리밸런싱

컨슈머 그룹은 브로커 쪽 그룹 코디네이터가 관리한다. 각 컨슈머는 백그라운드 스레드로 주기적인 하트비트를 보내고, session.timeout.ms 안에 하트비트가 끊기면 코디네이터가 그 컨슈머를 죽은 것으로 판단해 리밸런싱을 트리거한다. 담당하던 파티션은 살아 있는 다른 컨슈머에게 재할당된다.

프로세스가 살아 있어도 쫓겨날 수 있다. 한 번의 poll() 이후 다음 poll()까지 max.poll.interval.ms를 넘기면(=메시지 처리가 너무 오래 걸리면) 코디네이터는 그 컨슈머가 진행 불가 상태라고 보고 그룹에서 제외한다. 처리 시간이 긴 컨슈머에서 “멀쩡한데 계속 리밸런싱이 돈다”면 대개 이쪽이다. max.poll.records를 줄이거나 인터벌을 늘려 맞춘다.

리밸런싱 자체도 비용이다. 기본(eager) 방식은 모든 컨슈머가 일단 할당을 전부 반납한 뒤 다시 나눠 갖는 stop-the-world 형태라 그동안 소비가 멈춘다. 이를 줄이려면 바뀐 파티션만 옮기는 cooperative sticky 할당 전략을 쓰거나, 재배포처럼 잠깐 나갔다 돌아오는 경우에 대비해 group.instance.id를 부여하는 정적 멤버십으로 불필요한 리밸런싱을 막는다.

어디부터 다시 읽는가

이어받은 컨슈머는 __consumer_offsets 토픽에 커밋된 오프셋부터 다시 읽는다. 그래서 커밋 시점이 전달 보장을 결정한다.

기본값인 enable.auto.commit=true는 poll 시점에 이전 배치를 자동 커밋하기 때문에 경계가 모호하다. 정확한 제어가 필요하면 수동 커밋으로 바꾼다.

실무에서는 at-least-once를 택하고 컨슈머 로직을 멱등하게 만드는 쪽이 일반적이다. 메시지에 고유 키를 넣어 처리 이력을 확인하거나, DB 유니크 제약으로 중복 삽입을 흡수하거나, UPSERT로 몇 번 처리해도 같은 결과가 되게 한다.

Druid 인제스천

Druid는 컨슈머를 직접 관리하지 않고 슈퍼바이저가 인제스천 태스크의 수명주기를 관리한다. 태스크가 실패하면 슈퍼바이저가 감지해 재시작하고, 태스크가 어디까지 읽었는지는 메타데이터 저장소에 기록된 오프셋으로 판단해 그 지점부터 이어서 소비한다. 세그먼트 커밋과 오프셋 기록을 하나의 트랜잭션으로 묶기 때문에 재시작 후에도 중복 적재가 생기지 않는다.


7. 정합성 사전 모니터링

장애를 “고객 문의로 알게 되는 것”과 “시스템이 먼저 알려주는 것”의 차이를 만드는 게 대사 배치다.

대사(reconciliation) 배치

주기적으로 두 소스의 값을 비교한다. 선착순 쿠폰이라면 Redis 카운터의 발급 수 vs DB 발급 레코드 수다. 설계할 때 챙길 것들:

알림과 지표


8. 헥사고날 vs 레이어드

차이는 의존성 방향

포트는 방향에 따라 나뉜다. 바깥에서 도메인을 호출하는 인바운드 포트(유스케이스 인터페이스)와, 도메인이 바깥을 호출하는 아웃바운드 포트(저장소·알림 인터페이스)다. 후자에서 의존성 역전이 일어나 도메인이 인프라를 향하지 않게 된다.

얻는 것과 치르는 것

얻는 것은 테스트 용이성과 교체 가능성이다. 아웃바운드 포트를 인메모리 페이크로 갈아끼우면 DB 없이 도메인 로직을 테스트할 수 있고, 저장소를 바꿔도 어댑터만 새로 쓰면 된다.

치르는 것은 간접 계층이다. 도메인 모델과 영속화 엔티티를 분리하면 둘 사이를 오가는 매핑 코드가 계속 생기고, 인터페이스와 구현이 매번 쌍으로 늘어난다.

도메인 로직이 얇으면 이 비용이 이득보다 크다. CRUD에 가까운 기능에서는 격리할 도메인 자체가 별로 없어서, 포트와 어댑터가 사실상 Repository 인터페이스를 한 번 더 감싼 것에 그친다. 아키텍처 선택은 좋고 나쁨이 아니라 도메인 복잡도에 대한 트레이드오프다.


9. RAG 리랭킹

bi-encoder의 한계

벡터 검색은 bi-encoder 구조다. 질의와 문서를 각각 독립적으로 벡터로 인코딩하고, 코사인 유사도 같은 거리로 비교한다. 문서 벡터는 미리 계산해 인덱싱해둘 수 있어서 수백만 건에서도 밀리초 단위로 후보를 뽑는다.

대신 정밀도에 한계가 있다. 질의와 문서가 서로를 보지 못한 채 각자 하나의 벡터로 압축되기 때문에, 어느 단어가 어느 문장과 대응하는지 같은 세밀한 관계가 표현되지 않는다. 결과적으로 “주제는 비슷한데 정작 질문에 답하지 않는 문서”가 상위에 올라오곤 한다.

cross-encoder로 재정렬

cross-encoder는 질의와 문서를 하나의 입력으로 이어 붙여 모델에 넣고 관련도 점수를 직접 출력한다. 어텐션이 질의 토큰과 문서 토큰 사이를 오가므로 훨씬 정확하다.

문제는 미리 계산해둘 수 없다는 것이다. 질의가 정해져야 점수가 나오므로 (질의, 문서) 쌍마다 모델을 한 번씩 돌려야 한다. 전체 코퍼스에 적용하는 건 불가능하다.

그래서 두 단계로 나눈다.

  1. 검색: bi-encoder(+BM25 같은 키워드 검색)로 top-k 후보를 넉넉히 뽑는다. k는 보통 50~100.
  2. 리랭킹: cross-encoder로 그 k개만 재채점해 상위 5~10개를 남긴다.
  3. 생성: 남은 문서만 LLM 컨텍스트에 넣는다.

느린 모델을 후보 k개에만 적용하는 비용과 정확도의 절충이다. k를 키우면 재현율은 오르지만 리랭킹 지연이 선형으로 늘어나므로, 지연 예산을 보고 정한다.

리랭킹은 컨텍스트 길이 측면에서도 의미가 있다. LLM에 문서를 많이 넣을수록 비용이 늘고, 관련 없는 문서가 섞이면 오히려 답변 품질이 떨어진다. 상위 몇 개만 정확하게 골라 넣는 편이 낫다.