쓴다는 것과 안다는 것 사이의 한 겹

 ・ 8

photo by Jeremy Bishop(https://unsplash.com/@jeremybishop?utm_source=templater_proxy&utm_medium=referral) on Unsplash

"Spring 써봤어요", "React 써봤어요", "Kubernetes 위에서 운영해봤어요" — 이력서엔 한 줄이면 충분한 말들이에요. 그런데 그 한 줄이 가리키는 깊이는 사람마다 천차만별이죠.

도구를 쓰는 건 누구나 해요. 어려운 건 도구가 자기 일을 멈추는 지점, 그러니까 추상화 한 겹 아래를 들여다보는 일이에요.

"써봤다"와 "안다" 사이엔 한 겹의 박스가 있어요. 그 박스를 열어볼 수 있느냐가 차이를 만들어요.

이 글은 매일 쓰는 도구들의 박스를 한 겹씩 열어보는 거예요. 다섯 영역에서 "기본 사용법" 한 단계 아래를 짚어볼게요. 모범 답안이 아니라, 자기가 쓰는 스택을 한 번 더 짚어보는 체크리스트로 봐주세요.

Spring을 쓰는 것과 Spring을 아는 것#

Spring을 "그냥 쓴다"는 건 @Service, @Repository, @Autowired 같은 어노테이션을 익혀 컴파일이 도는 코드를 만드는 거예요. 알기 시작하는 건 그 어노테이션이 사라지면 무슨 일이 벌어지는지 설명할 수 있을 때부터예요.

핵심은 DI(Dependency Injection) 한 단어로 모여요. 객체가 자기 의존성을 직접 만들지 않고 외부에서 받게 하는 것 — 이게 결합도를 낮추고 테스트를 가능하게 만들어요.

주입 방식은 세 가지지만, 결이 달라요.

방식 특징 추천 여부
필드 주입 짧음. 그러나 컨테이너 없이 못 만듦 비권장
세터 주입 선택적 의존성 표현 가능 특정 케이스만
생성자 주입 불변, 순환 참조 즉시 발견, 테스트↑ 기본

Spring이 생성자 주입을 권장하는 이유는 단순하지 않아요.

  1. 순환 참조를 기동 시점에 잡아내요 — 필드/세터 주입은 객체부터 만들고 나중에 채우니까 런타임에야 드러나요
  2. 불변성final로 선언 가능, 멀티스레드 안전
  3. 테스트 가능성new OrderService(mockRepo)로 컨테이너 없이 단위 테스트가 돼요

세 번째가 TDD에서 결정적이에요. 테스트를 먼저 쓰려면 테스트 가능한 설계가 전제거든요. 생성자 시그니처에 의존성이 다 드러나면 가짜 객체를 어디에 끼울지 명확하고, 의존성이 너무 늘면 자연스럽게 클래스를 쪼개게 돼요. SRP가 자동으로 따라와요.

@Service
@RequiredArgsConstructor // Lombok이 final 필드로 생성자 생성
public class OrderService {
    private final PaymentClient paymentClient;
    private final OrderRepository orderRepository;
}

자바를 쓰는 것과 자바를 아는 것#

람다 한 줄, stream().map() 한 줄은 누구나 써요. 그 안에서 무슨 일이 벌어지는지가 박스 안쪽이에요.

익명 객체와 람다 - 같은 듯 다른#

Runnable a = new Runnable() { public void run() { ... } }; // 익명 클래스
Runnable b = () -> { ... };                                // 람다

겉보기엔 비슷한데 내부는 달라요.

  • 익명 클래스는 컴파일러가 별도 .class 파일을 만들어요 (Outer$1.class). 캡처되는 외부 변수는 익명 클래스의 필드로 들어가요. 그래서 effectively final 제약이 붙어요
  • 람다.class를 만들지 않고 invokedynamic 명령으로 런타임에 LambdaMetafactory가 인스턴스를 만들어요. 메모리 부담이 적고 JIT 친화적이에요

"람다는 익명 클래스의 syntactic sugar 아냐?"라는 흔한 오해를 풀어주는 지점이에요.

Stream - 지연 평가의 파이프라인#

Stream의 본질은 세 가지예요.

  • 지연 평가filter, map은 즉시 실행되지 않고, toList 같은 종료 연산을 만나야 흐름이 시작돼요
  • 불변성 — 원본 컬렉션을 건드리지 않아요
  • 합성 가능성 — 작은 연산을 조합해서 큰 변환을 만들어요

Java 21에서는 Stream Gatherers(JEP 461)로 사용자 정의 중간 연산까지 만들 수 있어요. 슬라이딩 윈도우, 묶음 처리 같은 것들이 표준 stream에 들어왔어요.

가상 스레드 - 자바의 새 동시성#

Java 21 LTS의 가장 큰 변화는 Virtual Threads(JEP 444)예요. OS 스레드를 거의 안 잡고도 수만 개의 블로킹 작업을 동시에 띄울 수 있어요.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    urls.forEach(url -> executor.submit(() -> fetch(url)));
}

비동기 코드를 동기 스타일로 쓸 수 있게 됐다는 게 핵심이에요. CompletableFuture로 콜백을 비비는 시대가 끝나가는 신호죠.

Exception - Checked vs Unchecked#

  • Checked (Exception을 상속, RuntimeException 제외) — 컴파일러가 처리를 강제
  • Unchecked (RuntimeException 계열) — 강제 없음

실무는 거의 다 Unchecked + 도메인 예외 계층으로 가요. Checked는 람다/스트림과 안 맞고 호출자에 부담을 강요하거든요. Spring 자체 예외 계층(DataAccessException 등)도 전부 Unchecked로 설계됐어요.

public abstract class BusinessException extends RuntimeException { }
public class OrderNotFoundException extends BusinessException { }

@RestControllerAdvice로 일괄 변환해서 일관된 응답을 내려주는 게 표준이에요.


React를 쓰는 것과 React를 아는 것#

React는 "JSX를 쓰면 화면이 나온다"가 입구예요. 그 너머에 알아야 할 게 몇 겹 더 있어요.

JSX와 가상 DOM이 진짜로 하는 일#

JSX는 결국 React.createElement(...) 호출로 컴파일돼요. 그 호출이 만들어내는 객체 트리가 가상 DOM이고, React는 이전 트리와 비교(diffing)해서 실제 DOM에 최소한의 변경만 반영해요.

이걸 알면 "왜 key가 중요한가", "왜 같은 위치의 컴포넌트가 unmount되지 않는가" 같은 질문이 풀려요.

Hooks의 핵심#

Hook 역할
useState 컴포넌트 로컬 상태
useEffect 외부 시스템 동기화 (구독, fetch 등)
useMemo 비싼 계산 결과 캐싱
useCallback 함수 참조 안정화
useRef 렌더링과 무관한 가변 값
useContext 가까운 Provider의 값 구독

React 19에서는 useActionState, useOptimistic, use 같은 훅이 추가됐고, 컴파일러(React Compiler) 가 메모이제이션을 자동화해서 useMemo/useCallback을 손으로 까는 일이 줄었어요.

컴포넌트 합성 - 데이터 흐름의 골격#

React의 데이터 흐름은 한 방향이에요. 부모 → 자식은 props, 자식 → 부모는 콜백 props.

<Child title={title} onSave={(data) => handleSave(data)} />

문제는 할아버지 컴포넌트로 상태를 보내야 할 때예요. props를 한 단계씩 거슬러 올리는 prop drilling은 깊어질수록 끔찍해져요.

선택지는 세 가지예요.

  1. Context — 트리의 어느 깊이에서든 같은 값에 접근. 자주 바뀌는 상태엔 부적합 (리렌더 비용)
  2. 상태 관리 라이브러리 — Zustand, Jotai, Redux Toolkit. 전역 store를 두고 어디서든 구독
  3. 상태를 끌어올리기(lifting state up) — 공통 부모로 옮기기. 가장 React스러운 방법이지만 부모가 비대해지면 한계

스타일링과 격리#

CSS의 전역성은 컴포넌트 모델과 충돌해요. 그래서 CSS Modules(Button.module.css), Tailwind(클래스 조합), CSS-in-JS 같은 도구가 격리를 책임져요.

요즘은 Tailwind + shadcn/ui 조합이 사실상 표준에 가깝고, Next.js App Router에선 서버 컴포넌트와의 호환성 때문에 런타임 CSS-in-JS는 입지가 줄었어요.


캐시와 분산을 쓰는 것과 아는 것#

데이터를 다루는 영역은 깊이 가 가장 빨리 드러나요.

Cache Stampede - 만료 직후의 함정#

"@Cacheable 붙였어요"가 쓰는 거라면, 캐시가 만료된 순간 동시에 들어온 요청이 전부 DB로 몰려가는 현상을 설명할 수 있어야 아는 거예요. 이걸 Cache Stampede(또는 thundering herd)라고 불러요.

대응법

  • @Cacheable(sync = true) — 같은 키의 동시 호출 중 하나만 원본으로 내려감
  • 분산 락 — Redisson RLock으로 재생성 권한을 한 요청에만 부여
  • Stale-While-Revalidate — 만료된 값을 일단 돌려주고, 백그라운드에서 갱신
  • TTL 지터 — 같은 키들이 동시에 만료되지 않게 무작위 오프셋

Redis Persistence와 그 너머#

Redis가 죽으면 캐시만 날아가는 게 아니에요. 세션, 분산 락, 큐가 같이 흔들려요.

  • RDB — 특정 시점 스냅샷. 빠른 복구, 일부 데이터 유실 가능
  • AOF — 모든 쓰기 명령 로그. 유실 적음, 파일 큼

실무는 둘 다 켜는 하이브리드 모드(aof-use-rdb-preamble yes)가 표준이에요. 거기에 Sentinel(고가용성) 또는 Cluster(샤딩)을 얹고, 애플리케이션 단에서 Circuit Breaker(Resilience4j)로 폴백 경로를 열어둬요.

2024년 Redis 라이선스 변경 이후 OSS 진영은 Valkey(Linux Foundation 포크)로 빠르게 이동했고, AWS ElastiCache/MemoryDB도 Valkey를 기본 옵션으로 제공해요.

MSA - 무엇을 얻고 무엇을 잃는가#

서비스를 잘게 쪼개면 얻는 것

  • 독립 배포, 기술 스택 자유, 장애 격리, 팀 자율성

잃는 것

  • 네트워크 지연/트래픽, 분산 트랜잭션, 통합 테스트 복잡도, 운영 부담

분산 트랜잭션은 Saga나 Outbox 패턴으로 풀어요. 통합 테스트는 Consumer-Driven Contract Testing(Pact)이나 Testcontainers로 대응하구요.

대용량 스토리지 - 키 설계가 전부#

HBase 같은 와이드 컬럼 스토어에서 가장 중요한 건 Row Key 설계예요. 키는 사전순으로 정렬돼 Region이 나뉘는데, 타임스탬프를 앞에 두면 최신 데이터가 한 Region에만 쏠려요 (핫스팟).

해결책

  • 솔트(salt) — 키 앞에 해시 일부
  • 리버스 타임스탬프Long.MAX_VALUE - ts
  • 복합 키userId#reverseTimestamp

DynamoDB, BigTable도 본질은 같아요. 파티션 키와 정렬 키 설계가 곧 시스템 성능이에요.

분산 코디네이션 - ZooKeeper의 마스터 선정#

여러 노드 중 하나가 마스터가 돼야 할 때, ZooKeeper는 임시 순차(ephemeral sequential) 노드로 풀어요.

  1. 각 노드가 ZK에 순차 노드 생성
  2. 가장 작은 번호가 마스터
  3. 마스터의 세션이 끊기면 임시 노드 자동 삭제
  4. Watch를 걸어둔 다음 후보가 알림 받음

세션 유지는 주기적인 ping이 책임지고, 정해진 timeout 안에 ping이 없으면 ZK가 세션을 만료시켜요.

Kafka는 KRaft 모드로 ZK 의존을 끊었고, 새 프로젝트라면 etcd(Kubernetes도 쓰는)가 더 흔한 선택이에요. 패턴은 같아요.


컨테이너 위에서 돌리는 것과 운영하는 것#

docker run 한 줄, kubectl apply 한 번은 시작에 불과해요.

자원 관리 - requests와 limits#

resources:
  requests:
    memory: '256Mi'
    cpu: '250m'
  limits:
    memory: '512Mi'
    cpu: '500m'
  • requests — 스케줄링 기준. 노드가 이만큼 보장
  • limits — 사용 상한. CPU는 throttling, 메모리는 OOMKilled

JVM 컨테이너에서는 메모리 인식이 중요해요. Java 10+ 부터 cgroup을 인식해 힙을 정하지만, -XX:MaxRAMPercentage=75.0처럼 명시적으로 잡아두는 게 안전해요. limits를 전부 힙으로 쓰면 메타스페이스/스택이 모자라 OOM이 나거든요.

자동 조정은 HPA(수평), VPA(수직)가 표준이에요.

릴리스에서 배포까지 - 단계로 보기#

개발자가 코드를 쓰고 운영까지 가는 일반적인 흐름이에요.

  1. 커밋 & 푸시 — Git
  2. CI — 빌드, 테스트, 정적 분석 (GitHub Actions, GitLab CI)
  3. 이미지 빌드 — Dockerfile, 멀티 스테이지로 용량 최소화
  4. 레지스트리 푸시 — Docker Hub, ECR, GHCR. 의미 있는 태그(v1.2.3)
  5. CD — ArgoCD/Flux 같은 GitOps 도구가 매니페스트 변경 감지, 또는 Helm/Kustomize
  6. 롤링 업데이트 — Deployment의 이미지가 갱신되면 새 ReplicaSet을 띄우고 순차 교체
  7. 헬스체크와 트래픽 전환 — readiness probe 통과 시 Service가 새 Pod로 트래픽 전환
  8. 관측과 롤백 — Prometheus/Grafana + OpenTelemetry. 문제 시 kubectl rollout undo

배포 전략은 Rolling이 기본, 무중단이 중요하면 Blue-Green, 점진 검증이 필요하면 Canary로 가요.

장애 대응 - 무엇부터 보는가#

순서가 중요해요.

  1. 영향 범위 — 어떤 사용자/서비스가 영향받고 있는지
  2. 최근 변경 — 직전 배포, 설정/시크릿 변경, 의존성 변경
  3. 메트릭 — CPU, 메모리, 에러율, 지연 (Grafana)
  4. 로그ERROR, Exception, Caused by, 5xx, timeout, connection refused
  5. 분산 트레이싱 — OpenTelemetry로 서비스 간 흐름 따라가기
  6. 롤백 결정 — 원인 분석보다 영향 차단이 우선일 때가 많아요

"원인을 못 찾았으니까 못 돌린다"보다 "일단 돌리고 분석하자"가 운영의 기본기예요.


박스 한 겹을 여는 습관#

다섯 영역을 짚어봤는데, 결국 같은 이야기로 돌아와요.

프레임워크의 마법이 멈추는 지점에서, 진짜 이해가 시작돼요.

@Autowired가 없을 때 어떻게 객체가 만들어질까. useState가 없을 때 어떻게 화면이 갱신될까. @Cacheable이 없을 때 캐시 미스 폭주는 어떻게 막을까. kubectl apply 한 줄 뒤에서 스케줄러는 무엇을 보고 어디로 Pod를 보낼까.

각각 한 단계만 들여다보면 도구를 보는 눈이 달라져요. 같은 코드를 짜더라도 왜 이걸 골랐고, 망가지면 어떻게 되는지를 설명할 수 있게 돼요.

오늘 자기 코드에서 어노테이션 하나, 훅 하나, 매니페스트 한 줄을 골라서 "이걸 없애도 같은 동작을 직접 구현할 수 있을까?" 를 자문해보세요. 답이 막히는 그 지점이, 다음에 열어야 할 박스예요.


As we risk ourselves, we grow. Each new experience is a risk.

— Fran Watson


다른 글
백엔드 시스템을 끝까지 그려볼 수 있나요 커버 이미지
 ・ 6

백엔드 시스템을 끝까지 그려볼 수 있나요

AR 글래스는 차세대 스마트폰이 될까 커버 이미지
 ・ 15

AR 글래스는 차세대 스마트폰이 될까

백엔드 면접에서 자주 나오는 질문 38가지 정리 커버 이미지
 ・ 12

백엔드 면접에서 자주 나오는 질문 38가지 정리