배경: 기업 하나에 클러스터 하나
- 폐쇄망에서 여러 기업이 각자 블록체인 네트워크를 쓰는 구조
- 격리 단위는 기업 1개 = kind 클러스터 1개 — 한 서버 위에 클러스터가 여러 개 뜬다
- 클러스터마다 호스트 포트가 필요하다(API 서버, Ingress, 대시보드, RPC…)
- 그래서 기업마다 포트 범위를 하나씩 나눠주는 함수가 있다
이 포트 할당 경로에서 성격이 완전히 다른 버그가 네 번 났다. 하루가 안 되는 사이에. 앞의 셋은 같은 함수였고, 마지막 하나는 다른 클래스였다 — 그게 마지막 하나가 늦게 발견된 이유이기도 하다.
① 소프트 삭제된 기업을 빼고 있었다
- 증상 — 새 기업의 kind 클러스터 생성이 실패
- 원인 — 사용 중인 포트를 셀 때
del_yn = true인 기업을 제외
-List<Agency> existingAgencies = agencyRepository.findAllByDelYnFalseAndClusterStatusNot(
- ClusterStatus.INACTIVE);
+List<Agency> existingAgencies = agencyRepository.findAllByClusterStatusNot(
+ ClusterStatus.INACTIVE);
- 소프트 삭제(행을 지우지 않고
del_yn = true표시만 하는 삭제)의 함정 - DB에서는 안 보이는데, 실제 kind 클러스터는 살아서 포트를 물고 있다
- 애플리케이션이 “지웠다”고 판단하는 시점과 실물이 사라지는 시점이 다르다
여기서 배웠어야 할 것: 포트는 DB의 소유가 아니라 OS의 소유다. DB 기준으로 세면 틀린다.
② 7분 뒤, 같은 줄을 또 고쳤다
①의 수정은 del_yn 필터만 걷어냈다. 그런데 ClusterStatus.INACTIVE는 여전히 빼고 있었다. 7분 만에 드러났다.
-List<Agency> existingAgencies = agencyRepository.findAllByClusterStatusNot(ClusterStatus.INACTIVE);
+List<Agency> existingAgencies = agencyRepository.findAll();
같은 커밋에서 설정도 바꿨다.
port-range-start: 10000
port-range-size: 10 # 1000 이었다
- 기업당 1000개씩 예약해뒀는데 스크립트가 실제 쓰는 건 10개였다
- 1000개씩이면 65535 안에서 기업 55개가 한계 — 실사용과 무관하게 예약만으로 고갈된다
그리고 여기서 방향을 바꿨다. 조건을 계속 다듬는 대신 재사용을 아예 포기했다.
- 삭제됐든 INACTIVE든 전부 포함해서
max(마지막 포트) + 1부터 할당 - 충돌 가능성이 근본적으로 사라진다 — 한 번 나간 번호는 다시 안 나간다
③ 같은 날 저녁, 쿼리를 분리했다
findAll()은 지나치게 솔직한 쿼리다. 다른 화면의 소프트 삭제 필터 작업과 얽히면서, 포트 할당 전용 쿼리로 떼어냈다.
-List<Agency> existingAgencies = agencyRepository.findAll();
+List<Agency> existingAgencies = agencyRepository.findAllForPortRangeAllocation();
@Query("SELECT a FROM Agency a")
List<Agency> findAllForPortRangeAllocation();
- 하는 일은
findAll()과 같다. 다른 건 이름이 의도를 말한다는 점뿐 - 누군가 나중에 “삭제된 기업이 왜 여기 나와?” 하고 필터를 붙이는 사고를 막는다
- 이름 자체가 “여기서는 삭제된 것도 포함해야 한다”는 주석 역할
성능도 동작도 안 바뀌는 커밋이지만, ①이 왜 났는지 생각하면 이게 진짜 수정이다.
④ 다음날, 층위가 다른 누락이 나왔다
①~③은 전부 기업과 기업 사이의 충돌이었다. 이번 건 한 기업 안에서의 충돌이다.
기업의 포트 범위 안에는 서비스마다 자리가 미리 정해져 있다. 괄호 안 숫자는 범위 시작점으로부터의 오프셋이다 — 범위가 10000부터면 Grafana는 10004다.
DASHBOARD(3, ...), GRAFANA(4, ...), PROMETHEUS(5, ...),
BESU_RPC(6, ...), BESU_WS(7, ...),
BLOCKSCOUT(8, ...), FABRIC_EXPLORER(9, ...);
- 그런데 “사용 중인 포트”를 셀 때 DB 엔티티에 저장된 포트만 셌다
- 위 고정 오프셋들은 집계에서 빠져 있었다 → 이미 쓰는 자리를 또 내줄 수 있다
if (agency.isClusterActive()) {
for (ServicePortType type : ServicePortType.values()) {
usedPorts.add(type.getPort(agency.getPortRangeStart()));
}
}
이 누락은 포트를 세는 두 곳, allocatePort()와 getUsedPorts()에 똑같이 있었다. 양쪽에 다 넣었는데 — 넣은 내용이 서로 달랐다.
-
getUsedPorts(): 오프셋 0·1·2(K8s API, Ingress HTTP/HTTPS)를 손으로 먼저 넣고, 그다음 위 enum을 돈다 → 0~9 전부 -
allocatePort(): enum만 돈다 → 3~9만
즉 실제로 포트를 내주는 쪽인 allocatePort()는 오프셋 0~2를 지금도 비어 있다고 본다. 클러스터가 떠 있고 아직 Fabric/Besu 엔티티 포트가 없는 상태라면, 할당 루프가 범위 시작점부터 훑다가 K8s API 서버 포트를 그대로 내준다.
글을 쓰며 코드를 다시 읽다가 발견했다. ④를 고칠 때 나는 “양쪽 다 고쳤다”고 생각했는데, 실제로는 한쪽에만 인프라 포트를 넣었다.
지식이 두 곳에 흩어져 있으면 한 곳만 고치고 끝난다 — ④의 교훈으로 이렇게 적으려 했는데, 정작 그 수정 자체가 그 교훈의 사례가 됐다.
지금 이 코드에 남아 있는 것
글을 쓰며 다시 읽어보니, 고친 것보다 안 고친 것이 더 눈에 띈다.
포트 회수가 반쪽만 있다.
- 클러스터를 지우면
portRangeStart/End를null로 되돌리긴 한다 - 그런데 할당은
max(마지막 포트) + 1— 지워진 게 마침 제일 높은 범위일 때만 재사용된다 - 10000, 10010, 10020 중 가운데를 지워도 다음 할당은 10030이다. 구멍은 안 메워진다
- 생성·삭제를 반복하면 실사용량과 무관하게 번호가 단조 증가한다
여유 포트가 0개다.
- 고정 오프셋이 0~9로 정확히 10개, 범위 크기도 10
- 즉 기업당 자유롭게 쓸 포트가 남지 않는다
- ②에서 1000 → 10으로 줄인 것이 만든 새 제약이다. 하나를 고치면서 다른 하나를 좁혔다
여기서 바로 위의 비대칭이 겹친다. 여유가 없으면 깔끔하게 “고갈”로 실패하는 게 맞는데, allocatePort()는 0~2를 비었다고 보므로 예외 대신 인프라 포트를 내준다. 실패가 실패처럼 안 생겼다는 점에서 고갈보다 나쁘다.
이름이 거짓말하는 메서드가 있다.
-
releasePort()라는 이름의 메서드 두 개가 실제로는 로그만 찍는다 - 주석에 “실제 포트는 엔티티 삭제 시 자동 해제됨”이라 적혀 있다
- 동작은 맞을지 몰라도, 이름은 호출하는 쪽에 거짓 약속을 한다
한계 체크는 두 단계로 있다 — 범위 할당 시 65535 초과 검사, 범위 내 개별 포트 소진 검사. 고갈을 감지는 하지만 회수는 안 한다.
남는 교훈
같은 함수에서 버그가 반복되면 조건이 아니라 접근이 틀린 것이다.
- ①과 ②는 둘 다 “어떤 기업을 셀 것인가”를 다듬는 수정이었다. 7분 간격으로 두 번
- 세 번째로 조건을 다듬는 대신 재사용을 포기하자 그 계열의 버그가 멈췄다
- 조건을 정교하게 만드는 대신 조건이 필요 없는 구조로 가는 게 나을 때가 있다
정확한 자원 관리와 단순한 자원 관리는 다른 목표다.
- 정확한 쪽: 빈 구멍을 추적해 재사용 — 포트를 아끼지만 상태가 복잡하고 위 4개 같은 버그가 산다
- 단순한 쪽: 번호를 계속 늘린다 — 포트를 낭비하지만 틀릴 여지가 거의 없다
- 지금 규모에선 후자가 맞다. 다만 그건 규모가 작아서지 설계가 옳아서가 아니다. 이 구분을 흐리면 나중에 이유 없이 그 구조를 지키게 된다
“어디까지가 내 관할인가”를 착각하면 첫 버그가 난다.
- 포트는 DB가 아니라 OS의 자원이다. DB 상태로 세는 순간 실물과 어긋난다
- 소프트 삭제는 애플리케이션 안에서만 유효한 개념인데, 그 밖의 자원에까지 적용하려 한 게 ①이었다