0. 들어가며

2026.01.04 - [개발관련] - [MySQL] 복합 인덱스 적용 및 성능테스트 트러블 슈팅

 

지난 포스팅에서 MySQL 복합 인덱스 적용으로 조회 성능을 개선했습니다.

 

성능테스트를 진행하기 전, 다양한 최적화 방안을 고민했습니다. 그중 하나가 애플리케이션 레벨에서의 쿼리 전송 방식을 점검하는 것이었습니다.

 

현재 프로젝트는 JPA(Hibernate)를 사용하고 있습니다. 이에 JPA가 내부적으로 어떤 방식을 사용하여 DB와 통신하는지, 그리고 그것이 대용량 트래픽 처리에 적합한 방식인지 검증하기 위해 JDBC 인터페이스(Statement, PreparedStatement, CallableStatement) 개념들을 다시 한번 정리해 보았습니다.

 

java.sql.Statement        (부모: 기본 쿼리 실행)
   └── java.sql.PreparedStatement  (자식: + 캐싱, 파라미터 바인딩)
           └── java.sql.CallableStatement  (손자: + 프로시저 호출, Output 파라미터)

1. Statement

가장 기본적인 JDBC 인터페이스로 SQL문을 단순 문자열 그대로 DB에 전송합니다.

 

  • 특징: 주로 정적 SQL을 만들 때 사용합니다. 여기서 정적 SQL이란 매개변수가 없어 쿼리 문장 자체가 변하지 않는 상태를 의미합니다.
  • 동작 방식: 문자열로 완성된 쿼리를 전달하기 때문에, 값이 바뀔 때마다 쿼리 문장 전체를 새로 생성해야 합니다.
  • 문제점
    • 성능 저하: 값이 바뀔 때마다 DB는 매번 새로운 쿼리로 인식하여 파싱과 컴파일을 반복합니다.
    • 보안 취약: 문자열 연결 방식(+ 또는 String.format)을 사용하므로 SQL Injection 공격에 매우 취약합니다.

*SQL Injection: 악의적인 사용자가 입력창에 SQL 구문을 주입하여, 데이터베이스를 조작하거나 비정상적으로 데이터를 유출시키는 공격 기법입니다.

// 1. 매개변수가 없는 순수 정적 쿼리
String sql = "INSERT INTO tblAddress VALUES (seq.nextVal, 'Sopia', 21, 'f', '서울시', default)";

// 2. 값을 외부에서 받는 경우 (문자열 결합 방식 -> Injection 위험)
String name = "Sopia"; 
int age = 21;
String sql = String.format("INSERT INTO tblAddress VALUES (seqAddress.nextVal, '%s', %s, ...)", name, age);

Statement stmt = conn.createStatement();
stmt.executeUpdate(sql);

2. PreparedStatement (JPA의 선택)

Statement를 상속받은 인터페이스로 동적 SQL을 처리하는 데 최적화되어 있습니다.

  • 특징: 매개변수(?)가 포함된 동적 쿼리를 실행할 때 사용합니다. 쿼리의 뼈대는 고정하되 데이터만 실시간으로 갈아 끼우는 방식입니다.
  • 동작 방식: 쿼리를 미리 컴파일해두고, 실행 시에는 바인딩된 매개변수 값만 전달합니다.
  • 장점
    • 성능 최적화: DB가 실행 계획을 재사용하므로 반복 요청 시 매우 빠릅니다.
    • 보안성: 매개변수로 전달된 값을 단순 텍스트로 처리하기 때문에 SQL Injection을 원천 차단합니다.
// 매개변수(?)를 사용하는 동적 쿼리 틀 정의
String sql = "INSERT INTO tblAddress VALUES (seqAddress.nextVal, ?, ?, ?, ?, default)";
PreparedStatement pstmt = conn.prepareStatement(sql);

// 실행 시점에 동적으로 값 바인딩 (안전함)
pstmt.setString(1, "Sopia");
pstmt.setInt(2, 21);
pstmt.executeUpdate();

3. CallableStatement

DB 내부에 정의된 스토어드 프로시저를 호출하기 위한 인터페이스입니다. PreparedStatement를 상속받아 파라미터 바인딩 기능을 그대로 사용하면서, 프로시저만의 Output 파라미터 처리 기능을 추가로 가집니다.

  • 특징: 복잡한 로직을 DB 서버 내부 함수(프로시저)로 정의해두고 Java에서는 호출만 합니다.
    • *스토어드 프로시저: 일련의 쿼리문들을 하나의 함수처럼 선언하여 데이터베이스에 저장해 둔 것입니다.
  • 장점(네트워크 비용 감소): 여러 번의 쿼리 요청을 한 번의 호출로 처리하므로 네트워크 왕복 시간을 줄여 응답 속도를 단축할 수 있습니다.
  • 단점 및 고려 대상 제외 이유 (Trade-off)
    • DB CPU 사용률 급증: 로직 수행 주체가 WAS에서 DB 서버로 넘어갑니다. WAS는 확장이 쉽지만 DB는 리소스 확장이 어렵고 비용이 비쌉니다. 연산 부하가 DB에 집중되면 CPU 사용률이 급증하여 전체 시스템의 병목이 될 위험이 있습니다.
    • 유지보수성 저하: 로직이 DB 내부에 숨어 있어 형상 관리와 디버깅이 까다롭습니다.
// DB에 저장된 프로시저 호출 (?는 파라미터)
String runProcedure = "{call get_stock_history(?)}";
CallableStatement cstmt = conn.prepareCall(runProcedure);

cstmt.setString(1, "005930");
cstmt.execute();

 

4. 요약 및 결론

구분 Statement PreparedStatement CallableStatement
주요용도 정적 SQL (단순 실행) 동적 SQL (파라미터 중심) 스토어드 프로시저 호출
쿼리해석 실행 시마다 매번 파싱 최초 1회만 파싱 (캐싱) DB에 이미 저장됨
보안성 SQL Injection에 취약 매우 안전 안전
리소스부하 DB 파싱 부하 발생 WAS/DB 리소스 균형 DB CPU 부하 집중

 

JPA(Hibernate)는 내부적으로 PreparedStatement를 기본 채택하고 있습니다.

결국 성능 최적화는 단순히 실행 시간을 줄이는 것뿐만 아니라 시스템 전체 리소스(CPU, 메모리, 네트워크)의 균형을 맞춰 고려해야하는 과정임을 확인했습니다.

단순히 프레임워크의 기능을 사용하는 것에 그치지 않고 동작 원리와 리소스 사용량 사이의 Trade-off를 고민할 수 있었습니다.

 

참고

https://www.datacadamia.com/lang/java/jdbc/callable_statement

 

JDBC - Callable Statement (Stored Procedure)

The CallableStatement objects interface adds methods to the statement interface for retrieving output parameter values returned from stored procedures. See java/sql/CallableStatementCallableStatement objects java/sql/CallableStatementCallableStatement obje

www.datacadamia.com

 

'개발관련' 카테고리의 다른 글

[MySQL] 복합 인덱스 적용 및 성능테스트 트러블 슈팅  (2) 2026.01.04
WebSocket  (2) 2025.11.10
[Spring Boot] Authentication & Authorization  (2) 2025.09.08
Session, JWT, OAuth  (0) 2025.08.12
static, Thread-safe  (4) 2025.08.04

0. 들어가며

종목별 과거 주가 조회 API의 응답 지연 문제가 있었고 변동성이 낮은 데이터라는 특성을 고려해 Redis 캐싱을 적용해 응답 속도를 약 30% 개선했었습니다.

 

하지만, 외부 인프라에 의존하기에 앞서 데이터베이스(RDBMS) 자체의 성능을 어느 수준까지 끌어올릴 수 있을지를 확인해보고 싶었습니다. 단순히 데이터를 메모리에 올려 반환하는 방식보다 병목의 원인이 되는 쿼리 실행 계획을 분석하고 개선하는 것이 보다 기본적인 접근이라고 생각했습니다.

 

도메인별로 서버가 분리된 MSA 환경에서 주식 서비스를 독립된 환경으로 격리하여 테스트를 진행한 기록입니다. 병목을 재현한 뒤 추가적인 인프라 도입 없이 인덱스 설계 변경만으로 조회 성능을 31초에서 0.009초까지 개선한 과정입니다.

1. 문제 상황: 500만 건 데이터의 정렬 지연

사용자가 가장 빈번하게 조회하는 '종목별 과거 시세 조회' 기능에서 병목이 발견되었습니다.

 

1-1.  대상 데이터

문제가 발생한 곳은 일별 주가 정보를 저장하는 테이블이었습니다.

 

프로젝트 초기의 누적 데이터는 50만 건 수준이었으나, 매일 전 종목의 시세가 쌓이는 구조입니다. 이에 현재 데이터 양에 안주하지 않고, 향후 데이터가 수백만 건 이상으로 폭증했을 때도 서비스가 안정적일지 검증하고 싶었습니다.

 

따라서 테스트 환경에 데이터를 약 10배 증폭시켜 부하 상황을 시뮬레이션했습니다. 특히 병목이 예상되는 특정 종목(삼성전자)에는 약 100만 건의 데이터를 적재하여 추후 분봉/틱 단위 차트로 고도화될 경우까지 대비한 선제적인 스트레스 테스트를 수행했습니다.

 

1-2. 병목 쿼리

SELECT * FROM daily_stock_history
WHERE stock_code = '005930'  -- 삼성전자 (종목코드)
ORDER BY daily_stock_history_date DESC
LIMIT 90;

 

1-3. 장애 현상

부하 테스트(k6) 결과, 동시 접속자가 50명을 넘어서자 응답 속도가 기하급수적으로 느려지며 시스템 장애로 이어졌습니다.

  • p95 기준 응답 속도: 31.75s
  • 처리량(TPS) : 약 4.5 TPS
    • 150명의 가상 유저(VUser)가 요청을 보내고 있음에도 서버는 초당 4건 정도밖에 처리하지 못했습니다. Filesort 작업이 DB 자원을 독점하면서 병목이 발생했기 때문입니다.
  • 에러율: 1.13% (타임아웃 발생)
  • 로그: 쿼리 처리가 지연되면서 DB 커넥션 풀(HikariCP)이 고갈되어 SQLTransientConnectionException 발생

30초 대기 후 타임아웃 발생

2. 테스트 환경

정확한 원인 분석을 위해 MacBook Pro와 Mac Mini를 1:1로 직접 연결하여 하드웨어 자원을 완벽히 격리한 로컬 테스트 환경을 구축했습니다.

 

2-1. 물리적 구성

  • Target Server: MacBook Pro (M2 Chip)
    • 역할: 실제 서비스 트래픽 처리
    • 실행 프로세스: WAS, MySQL, Scouter Host / Java Agent
    • 요청 처리에만 자원을 집중하도록 구성
  • Load Generator & Monitor: Mac Mini
    • 역할: 부하 생성 및 데이터 수집 / 관제
    • 실행 프로세스: k6 (부하 테스트 스크립트), Scouter Server(Collector), Scouter Client
    • 부하 발생과 로그 수집에 필요한 리소스가 Target Server의 CPU를 점유하지 않도록 분리

 

2-2. 네트워크 구성

  • 순수한 DB 쿼리 성능과 네트워크 I/O만을 측정하기 위하여 L2 스위치로 LAN을 구성했습니다.

 

2-3. 테스트 시나리오

실제 사용자 유입 패턴을 고려하여 k6로 계단식 부하 시나리오를 설계했습니다.

  • 목표: 시스템이 버틸 수 있는 최대 처리량과 임계점 확인
  • 성능 기준: p95 응답 속도 500ms 이하, 에러율 1% 미만

[테스트 단계 구성]

  • Warm-up (10명)
  • Load (50명): 평시 트래픽 상황 가정
  • Stress (150명): 트래픽 폭증 상황 가정 → 이 구간에서 DB CPU가 포화되고 병목이 발생하는지 관측
export const options = {
  // [Step Load 패턴 적용]
  stages: [
    { duration: '30s', target: 10 },  // 1. Warm-up: 10명으로 가볍게 시작
    { duration: '1m', target: 50 },   // 2. Load: 50명까지 증가 (평시 트래픽)
    { duration: '1m', target: 150 },  // 3. Stress: 150명으로 폭증 (병목 재현 구간)
    { duration: '30s', target: 0 },   // 4. Cool-down: 종료
  ],
  // [성능 목표 설정: SLA]
  thresholds: {
    // "95%의 요청이 500ms 안에 들어와야 성공" -> 인덱스 미적용 시 이 기준을 초과하여 Fail 발생
    http_req_duration: ['p(95)<500'], 
    // "에러율은 1% 미만이어야 함"
    http_req_failed: ['rate<0.01'],   
  },
};

 

3. 원인 분석: 실행 계획 점검

장애 재현 직후, 쿼리의 실행 계획을 확인해보았습니다.

 

3-1. 대용량 데이터의 정렬 부하

실행 계획의 Sort 단계에서 약 100만건의 데이터가 처리되고 있음이 확인되었습니다.

  • 상황: stock_code 인덱스를 통해 '삼성전자' 데이터를 찾았지만, 정렬 기준인 날짜(date)에 대한 인덱스는 없었습니다.
  • 동작: DB는 추출된 100만 건을 정렬하기 위해 메모리(Sort Buffer)를 할당했으나 용량을 초과하여 디스크에 임시 파일을 생성해 정렬하는 Filesort 작업을 수행했습니다.
  • 문제: 메모리 처리보다 느린 디스크 I/O가 발생하면서 쿼리 응답 시간이 30초 이상으로 치솟았고 과도한 CPU 자원을 소모했습니다.

3-2. 비효율적인 데이터 스캔

이 쿼리의 목적은 최신 데이터 90건을 조회하는 것입니다. 하지만 실행 계획을 보면 1,010,000건(Rows)을 읽고 있습니다.

  • Read Rows: 약 1,010,000건 (대상 종목 전체 스캔)
  • Result Rows: 90건 (상위 90개 추출)

단 90개의 결과를 얻기 위해 100만 개를 읽고 정렬한 뒤, 나머지 99.9%는 버려버리는 비효율적인 방식으로 동작하고 있었습니다. 이것이 CPU를 점유하고 DB 커넥션을 고갈시킨 원인이었습니다.

4. 해결 방법: 복합 인덱스

  • WHERE 절의 조회 조건(stock_code)과 ORDER BY 절의 정렬 조건(daily_stock_history_date)을 결합한 복합 인덱스를 생성했습니다.
  • 원리: 인덱스는 데이터가 정렬된 상태로 저장됩니다. 따라서 복합 인덱스를 활용하면 DB가 런타임에 별도의 정렬을 수행할 필요 없이, 이미 정렬된 데이터를 뒤에서부터 순서대로 읽기만 하면(Backward Index Scan) 됩니다.
CREATE INDEX idx_stock_history_code_date
ON daily_stock_history (stock_code, daily_stock_history_date);

 

 

4-1. 적용 시 고려사항 (Trade-off)

물론 인덱스 추가가 무조건적인 정답은 아닙니다. 인덱스를 적용할 때 두 가지 단점을 고려해야 했습니다.

  • 쓰기 성능 저하: 데이터가 INSERT, UPDATE, DELETE 될 때마다 인덱스도 재정렬해야 하므로 쓰기 작업 부하가 증가.
  • 저장 공간 증가: 인덱스 저장을 위한 별도의 디스크 공간을 차지.

실제 테이블 용량 분석 결과

 

전체 용량 대비 인덱스가 차지하는 비중은 약 12% 수준이었습니다. 주가 데이터는 장 마감 후 한 번 적재되면 수정이 거의 발생하지 않고 조회 트래픽이 압도적으로 높습니다. 따라서 12%의 저장 공간을 투자하여 조회 속도를 3,500배 개선하는 것이 비용 대비 효과 측면에서 합리적이라고 생각했습니다.

5. 결과

인덱스 적용 후, 동일한 격리 환경에서 성능을 측정한 결과입니다.

 

5-1. 정량적 결과

기존 단일 인덱스 환경과 복합 인덱스 적용 후의 성능을 비교했을 때, 약 3,500배의 성능 향상을 확인했습니다.

  • 응답 속도: 31.75s -> 0.009s (p95 기준)
  • 처리량(TPS): 4.5TPS -> 약 100TPS (20배 이상 증가)
    • 기존에는 DB 병목으로 인해 요청을 처리하지 못하고 타임아웃이 발생했으나 개선 후에는 유입되는 트래픽을 지연 없이 안정적으로 처리

5-2. 실행 계획의 변화

개선 전
개선 후

  • 개선 전: 인덱스에 정렬 정보가 없어 DB가 별도로 정렬을 수행하며 부하 발생.
  • 개선 후: 인덱스가 이미 정렬되어 있어별도 정렬 없이 순서대로 읽기만 하여 부하 해소.

 

6. 마치며

이번에 성능 테스트를 하면서 단순히 기능만 구현하는 게 다가 아니라는 걸 느꼈습니다. 특히 옵티마이저(Optimizer)의 동작 원리나 Statement, PreparedStatement, CallableStatement 같은 기본 개념들도 다시 확실하게 정리해봐야겠다는 생각이 들었습니다.

0-1. 웹소켓이란?

  • WebSocket 프로토콜은 접속 확립에 HTTP를 사용하지만, 그 후의 통신은 WebSocket 독자의 프로토콜로 이루어진다.
  • header가 상당히 작아 overhead가 적은 특징이 있다.
  • 실시간 양방향 데이터 통신이 필요한 경우, 많은 수의 동시 접속자를 수용해야 하는 경우, 브라우저에서 TCP 기반의 통신으로 확장해야 하는 경우 등.
  • 응용 계층(OSI 7계층) 수준의 프로토콜
  • 주로 사용되는 경우
    • 동시 접속자 수 많고 빠른 반응이 필요한 서비스
    • 실시간 채팅, 알림, 주식 시세 등

0-2. HTTP vs 웹 소켓 차이점?

결정적인 차이는 프로토콜에 있다. HTTP는 질문하면 대답하는 통신, WebSocket은 서로 실시간 대화하는 통신

구분 HTTP WebSocket
통신 방식 요청/응답 기반 (Request-Response) 양방향 (Full-duplex)
연결 유지 매 요청마다 새 연결 (Stateless) 한 번 연결 후 계속 유지 (Stateful)
프로토콜 HTTP/1.1, HTTP/2 WebSocket(ws://, wss://)
헤더 크기 큼 (메타데이터 포함) 작음 (오버헤드 최소화)
서버 → 클라이언트 통신 불가능 (단, Polling, SSE 등으로 우회) 가능 (서버가 직접 Push 가능)
사용 목적 문서/리소스 전송 중심 실시간 데이터 교환 중심

1. WebSocketHandler

  • Spring Framework에서 웹소켓 통신을 처리하기 위한 기본 핸들러 클래스
  • WebSocket 연결 수립, 메시지 송수신, 연결 종료 등 이벤트 기반 콜백 메서드를 제공
  • 주로 TextWebSocketHandler를 상속받아 사용
  • 직접 세션 관리하고, 메시지 라우팅 에러처리 등을 구현해야 함
  • 텍스트 기반 통신을 기본
  • 단순한 실시간 알림, 주가 정보 등 가볍고 빠른 실시간 통신에 적합

2. STOMP (Simple Text Oriented Messaging Protocol)

  • WebSocket 위에서 동작하는 메시징 프로토콜
    • WebSocket이 단순 통로라면, STOMP는 그 통로 안의 대화 규칙(프로토콜)
  • 텍스트 기반 프로토콜
  • SEND, SUBSCRIBE, MESSAGE, CONNECT 등의 명령으로 메시지를 주고받음
  • Pub/Sub (발행 - 구독) 구조를 지원하며, 클라이언트가 토픽을 구독하면 서버가 해당 주제로 브로드캐스트 가능
  • Spring WebSocket + STOMP 구조
    • /app : 클라이언트 → 서버 메시지 경로
    • /topic : 서버 → 클라이언트 브로드캐스트
  • 메시지 브로커와 연동하여 확장성 있는 구조도 구축 가능
  • 장점
    • 메시징 구조가 표준화되어 관리/확장 용이
    • 브로커 연동으로 대규모 서비스 구현 가능
    • 구독/발행 구조로 다중 사용자 실시간 알림에 적합
  • 단점
    • 텍스트 기반으로 오버헤드가 존재
    • 구조가 복잡하고 저지연 고빈도 스트림에는 부적합

3. Socket.io

  • WebSocket을 우선 사용하지만, 브라우저가 지원하지 않거나 네트워크 제약이 있으면 fallback을 사용한다.
  • 즉, WebSocket 지원되면 WebSocket으로, 지원이 안 되면 HTTP Long polling으로 흉내낸다.
  • 이 방식 덕분에 모든 환경에서 ‘실시간 통신처럼’ 작동할 수 있다.
  • 이벤트 기반 아키텍처를 사용한다.
  • 클라이언트와 서버가 특정 이벤트 이름을 기준으로 메시지를 송수신한다.

4. 장단점

항목 WebSocketHandler STOMP Socket.io
기반 기술 WebSocket WebSocket + STOMP 프로토콜 WebSocket + fallback(HTTP)
언어/환경 Java(Spring) Java(Spring) Node.js
메시징 구조 직접 구현 Pub/Sub 구조 이벤트 기반
확장성 낮음 높음 (브로커 연동) 중간
데이터 형태 텍스트 / 바이너리 텍스트 텍스트 / 바이너리
주 사용처 실시간 알림, 주가 서비스 채팅, 알림, 방송형 서비스 실시간 협업, 채팅
특징 요약 단순·고성능 구조적·확장성 호환성·유연성

5. 정리

  • WebSocketHandler → WebSocket을 직접 다루는 Spring 구현체
  • STOMP → WebSocket 위에서 메시징 규칙을 추가한 프로토콜
  • Socket.io → WebSocket을 확장한 Node.js 라이브러리 (이벤트 기반 + fallback 지원)

결국 세 가지 모두 WebSocket을 기반으로 실시간 통신을 구현하는 방식

모종의 이유로 다시 내용을 훑고있는데, 모든 내용이 다 … 유기적으로 연관이 되어있다는 걸 알고 흐름에 따라 정리해보기로 함.

JVM 메모리 구조 → 멀티스레드와 공유 자원 → 경쟁 상태 문제 발생 → 동기화(뮤텍스, 세마포어)로 문제 해결 → GC와 static의 관계

JVM

자바 바이트코드(.claass 파일)를 운영체제가 이해할 수 있는 언어로 번역하고 실행하는 소프트웨어입니다. 특정 운영체제에 종속되지 않고 자바 프로그램을 실행할 수 있게 해주는 가상 컴퓨터입니다.

  • 플랫폼 독립성, 메모리 관리, 런타임 환경 제공

JVM 메모리 구조

자바 프로그램을 실행하면 하나의 프로세스가 생성되고, 그 안에 JVM이 동작합니다.
JVM은 프로그램을 실행하는 데 필요한 메모리를 여러 영역으로 나누어 관리하는데, 크게 스레드가 공유하는 영역과 스레드마다 독립적인 영역으로 나뉩니다.

  • 스레드가 공유하는 영역
    • JVM 내의 모든 스레드가 함께 사용합니다. 여러 스레드가 동시 접근이 가능하기 때문에 동기화 문제 (Race Condition)가 발생할 수 있습니다.
    • 메서드 영역: 클래스 파일의 바이트 코드, 정적(static) 변수, 상수, 필드 정보, 메서드 정보 등이 저장됩니다. JVM이 프로그램을 시작할 때 로드되며, 프로그램이 종료될 때까지 남아 있습니다.
    • 힙 영역: new 연산자로 생성된 객체와 배열이 저장되는 공간입니다. 대부분의 프로그램 데이터가 이 곳에 저장되며, GC의 주요 관리 대상입니다.
  • 스레드마다 독립적인 영역
    • 스레드가 자신만의 고유한 공간을 가지며, 다른 스레드와 공유하지 않으므로 동기화 문제가 발생하지 않습니다.
    • 스택 영역: 메서드 호출 시 사용되는 지역 변수, 매개변수, 반호나 값 등이 임시로 저장됩니다. 메서드 호출이 끝나면 자동으로 소멸됩니다.
    • PC(Program Counter) 레지스터: 스레드가 실행할 다음 명령어의 주소를 저장합니다. 스레드마다 실행 순서가 다르므로 각각 독립적인 PC 레지스터를 가집니다.

프로세스와 스레드

JVM과 메모리 구조를 이해하려면? → 둘의 관계를 명확히 알아야 합니다.

  • 프로세스
    • 운영체제로부터 메모리, CPU 등 자원을 할당받아 실행되는 프로그램의 단위
    • 각 프로세스는 독립적인 메모리 공간을 가지며 하나의 JVM이 하나의 프로세스입니다.
  • 스레드
    • 한 프로세스 내에서 실행되는 작업의 흐름(실행 단위)
    • 한 프로세스는 여러 스레드를 가질 수 있으며, 이 스레드들은 프로세스의 메모리 공간을 공유합니다.
    • 자바의 멀티스레드 프로그래밍은 이 스레드들이 공유하는 힙과 메서드 영역의 데이터를 안전하게 다루는 것이 핵심입니다.

멀티스레드와 공유 자원

JVM 프로세스는 여러 개의 스레드를 생성하여 작업을 병렬로 처리할 수 있습니다. 각 스레드는 독립적인 실행 흐름을 가지지만, 힙과 메서드 영역을 공유합니다.

이러한 공유 자원에 여러 스레드가 동시에 접근하여 값을 읽거나 수정할 때 문제가 발생합니다.

(예를 들어 → 여러 스레드가 동시에 static 변수의 값을 증가시키는 작업을 한다고 가정.)

  • 각 스레드는 변수의 현재 값을 읽고, 1을 더한 다음 다시 쓰는 과정을 거친다.
  • 만약 한 스레드가 값을 읽은 직후 다른 스레드가 먼저 값을 수정해 버리면? → 첫 번째 스레드는 낡은 값으로 계산하게 되어 최종 결과가 예상과 달라진다.

이처럼 여러 스레드가 공유 자원에 동시에 접근하면서 발생하는 데이터 불일치 문제를 경쟁 상태(Race Condition)이라고 합니다.

경쟁 상태 문제 발생

방금 설명했듯, JVM의 힙과 메서드 영역은 모든 스레드가 공유하는 공간입니다.
여러 스레드가 이러한 공유 자원에 동시에 접근하여 값을 읽거나 수정할 때 경쟁 상태라는 문제가 발생합니다.

경쟁 상태는 여러 스레드의 실행 순서가 예측 불가능하여, 그 결과가 달라지는 상황을 말합니다.
이는 데이터 불일치나 논리적 오류를 초래합니다.

(간단한 예시 → static 변수 counter를 두 스레드가 1씩 증가시키는 상황)

  • 스레드 A가 counter의 현재 값(0)을 읽습니다.
  • 동시에 스레드 B도 counter의 현재 값(0)을 읽습니다.
  • 스레드 A는 읽은 값에 1을 더해(0 + 1 = 1) counter에 다시 씁니다.
  • 스레드 B는 자신이 읽었던 값에 1을 더해 (0 + 1 = 1) counter에 다시 씁니다.

결과 적으로 counter의 최종 값은 2가 되어야 하지만, 잘못된 순서로 인해 1이 됩니다.

이처럼 공유 자원에 접근하는 코드 영역을 임계 구역(Critical Section)이라고 부르며, 이 구역에 대한 접근은 반드시 제어해야 합니다.

동기화

경쟁 상태 문제를 해결하기 위한 방법이 바로 동기화입니다.

동기화는 여러 스레드가 공유 자원(임계 구역)에 동시에 접근하는 것을 막고, 한 번에 한 스레드만 접근하도록 순서를 제어하는 것을 말합니다.

이러한 동기화를 구현하는 대표적인 도구에 뮤텍스와 세마포어가 있습니다.

  • 뮤텍스(Mutex)
    • 상호 배제(Mutuql Exclusion)의 약자로 단 하나의 스레드만 임계 구역에 접근할 수 있도록 하는 잠금(Lock)메커니즘입니다.
    • 잠금(Lock): 공유 자원을 사용하기 전에 먼저 잠금을 걸어 다른 스레드가 접근하지 못하도록 합니다.
    • 해제(Unlock): 작업을 마친 후에는 잠금을 풀어 다른 스레드가 사용할 수 있게 합니다.
    • 소유권: 잠금을 건 스레드만 잠금을 해제할 수 있습니다.

뮤텍스는 한 명의 사용자만 사용할 수 있는 화장실에 비유할 수 있습니다. 사용자가 화장실에 들어가면 문을 잠그고, 나오면서 문을 열어줍니다. 다른 사람은 문이 열릴 때까지 기다려야 합니다.

  • 세마포어(Semaphore)
    • 공유 자원에 대한 접근을 제어하는 동구로 정수형 변수 하나와 P연산 V연산으로 구성됩니다. 뮤텍스와 달리 여러 개의 스레드가 동시에 접근할 수 있습니다.
    • 카운팅(Counting): 세마포어의 정수 값은 공유 자원에 접근 가능한 스레드 수를 의미합니다.
    • P연산(Waiting): 자원을 사용하려는 스레드는 P연산을 수행하여 세마포어 값을 감소시킵니다. 만약 값이 음수가 되면 해당 스레드는 대기 상태로 전환됩니다.
    • V연산(Signal): 자원 사용을 마친 스레드는 V연산을 수행하며 세마포어 값을 증가시킵니다. 대기 중인 스레드가 있다면 하나를 깨워 자원을 사용하게 합니다.

세마포어는 여러 개의 좌석이 있는 영화관에 비유할 수 있습니다. 좌석이 남아있다면(세마포어 값 > 0) 여러 명이 동시에 입장 가능하고, 좌석이 모두 차면(세마포어 값 <= 0) 다음 손님은 좌석이 생길 때까지 기다려야 합니다.

뮤텍스는 세마포어의 특수한 형태로 볼 수 있습니다. 세마포어의 값이 1인 이진 세마포어(Binary Semaphore)는 뮤텍스와 동일한 역할을 합니다. 하지만 일반적으로는 단일 자원에는 뮤텍스, 여러 개의 자원에는 세마포어를 사용합니다.

GC와 static 변수의 관계

static 변수는 JVM의 메서드 영역에 할당됩니다. 이 영역은 프로그램이 시작될 때 메모리에 로드되어 프로그램이 종료될 때까지 메모리에 남아있습니다. 이러한 static 변수는 애플리케이션의 생명주기와 같기 때문에, GC의 관리 대상이 아닙니다.

반면 new키워드로 힙 영역에 생성된 객체는 해당 객체에 대한 유효한 참조가 사라지면 GC가 이를 감지하여 메모리를 회수합니다.

이러한 특성 때문에 static 변수를 남발하면 다음과 같은 문제가 발생할 수 있습니다.

  • 메모리 누수
    • static 변수가 힙 영역의 객체를 참조하고 있는 한, 해당 객체는 애플리케이션이 종료될 때까지 GC의 대상이 되지 않습니다. 만약 이 객체가 불필요한 데이터를 계속 축적하면 메모리가 점진적으로 부족해지는 메모리 누수가 발생할 수 있습니다.
  • 공유 자원 문제
    • static 변수는 모든 스레드가 공유하는 자원으로, 멀티스레드 환경에서 동기화 처리를 제대로 하지 않으면 경쟁 상태에 빠질 수 있습니다.

따라서 static 변수는 프로그램 전반에 걸쳐 공유되어야 하는 상수나 단일 객체(싱글턴)를 정의할 때만 신중하게 사용하는 것이 좋습니다.

'CS 및 면접복기' 카테고리의 다른 글

20250829  (2) 2025.08.29
20250820 - 디자인패턴(2)  (2) 2025.08.20
20250818 - 디자인 패턴  (2) 2025.08.18
20250811 - Network  (2) 2025.08.11
20250808  (3) 2025.08.08

이전에 Spring Boot 프로젝트를 진행하면서는 HTTP 헤더의 Authorization 토큰을 직접 파싱해 사용자 정보를 가져오곤 했습니다.

Spring Security를 사용한다면 컨트롤러 메서드에 Authentication 객체를 파라미터로 주입받아 사용할 수 있습니다.

1. Authorization헤더 vs. Authentication 파라미터

두 방식의 가장 큰 차이점은 인증 정보 처리의 책임이 어디에 있느냐입니다.

// 직접 Authorization 헤더를 다루는 방식
@RestController
@RequestMapping("/api/friends")
public class FriendControllerV1 {
    // ... (의존성 주입)

    @GetMapping("/search")
    public ResponseEntity<List<UserSearchResponse>> searchUsers(
        @RequestParam String keyword,
        @RequestHeader("Authorization") String token) {

        // 1. 토큰 유효성 검증 로직 (만료, 변조 등)
        if (!isValidToken(token)) {
            return ResponseEntity.status(401).build();
        }

        // 2. 토큰에서 사용자 ID 추출
        Long currentUserId = extractUserIdFromToken(token);

        // 3. 비즈니스 로직 호출
        List<User> users = friendService.searchUsers(keyword, currentUserId);

        // ...
    }

}
  • 컨트롤러가 인증과 비즈니스 로직이라는 두 가지 역할을 동시에 수행하게됩니다. 토큰 처리 로직이 모든 컨트롤러 메서드에 반복되거나, 별도의 유틸리티 클래스로 분리해야 하는 번거로움이 있습니다.
// Spring Security의 Authentication 파라미터를 사용하는 방식
@RestController
@RequestMapping("/api/friends")
public class FriendControllerV2 {

    // ... (의존성 주입)

    @GetMapping("/search")
    public ResponseEntity<List<UserSearchResponse>> searchUsers(
        @RequestParam String keyword,
        Authentication authentication) { // Spring이 인증된 사용자 정보를 자동으로 주입

        // 1. 인증된 사용자 ID를 바로 사용
        Long currentUserId = (Long) authentication.getPrincipal();

        // 2. 비즈니스 로직 호출 (깔끔해짐)
        List<User> users = friendService.searchUsers(keyword, currentUserId);

        // ...
    }
}
  • 컨트롤러는 이미 인증이 완료되었다는 것을 신뢰하고 비즈니스 로직에만 집중할 수 있습니다. 토큰 처리, 유효성 검증 같은 부가적인 관심사가 완전히 분리되어 코드가 간결해지고 유지보수성이 높아집니다.

결론적으로, Spring Security 환경에서는 Authentication 파라미터를 사용하는 것이 객체지향 원칙과 관심사 분리 측면에서 훨씬 더 좋은 설계라고 생각합니다.

'개발관련' 카테고리의 다른 글

[MySQL] 복합 인덱스 적용 및 성능테스트 트러블 슈팅  (2) 2026.01.04
WebSocket  (2) 2025.11.10
Session, JWT, OAuth  (0) 2025.08.12
static, Thread-safe  (4) 2025.08.04
[Spring Boot] OAuth 2.0 소셜 로그인 구현 흐름  (0) 2025.07.03
  • DDD에서 aggregate란?
    • Aggregate는 도메인 주도 설계에서 핵심적인 개념 중 하나로, 연관된 도메인 객체들을 하나의 단위로 묶는 일관성 경계를 의미합니다.
    • (예를 들어 ‘주문’ 도메인을 모델링할 때 주문 자체와 그에 속한 주문 항목들을 함께 다루는 경우가 많습니다. 이럴 때 ‘주문’이 Aggregate Root가 되고, ‘주문 항목들’은 그 내부의 구성요소가 됩니다.
    • 중요한 점은 외부에서는 반드시 Aggregate Root를 통해서만 내부 객체에 접근하거나 조작해야 한다는 것입니다. 이렇게 함으로써 도메인의 불변 조건을 보장하고, 트랜잭션의 범위를 Aggregate 단위로 제한할 수 있어 시스템의 일관성을 유지할 수 있습니다.)
    • 즉, 복잡한 도메인 모델을 관리 가능하게 만들고, 도메인의 무결성을 보장하는 데 중요한 역할을 합니다.
  • msa랑 모놀리식 설명
    • 모놀리식은 모든 기능이 하나의 통합된 애플리케이션으로 구성된 구조입니다.
    • 장점은 초기 개발과 배포가 단순하고 컴포넌트 간 호출이 내부 메서드 호출로 빠르다는 점입니다. 그러나 규모가 커질수록 코드가 복잡해지고, 일부 기능 수정이나 배포가 전체 시스템에 영향을 주는 문제가 있습니다.
    • MSA는 시스템을 작고 독립적인 서비스들로 나누어 개발하는 방식입니다. 각 서비스는 독립적으로 배포되고 확장 가능하며, 자체 DB와 로직을 가집니다.
    • 장점은 서비스 단위의 빠른 배포, 장애 격리, 기술 스택의 다양성 등 여러 장점이 있지만, 반대로 서비스 간 통신 비용이 증가하고, 분산 환경에서 데이터 정합성이나 트랜잭션 관리가 어려워질 수 있습니다.
  • msa서비스 구분 어떤식으로 했나요?
    • 서비스 구분 기준을 ‘도메인’ 기반으로 했고, 구체적으로는 사용자에게 제공되는 핵심 기능 단위로 나눴습니다.
    • 구체적으로는 멀티 게임 보드, 싱글 게임 모드, 회원 관리, 랭킹 관리 등 사용자가 실제로 접하는 핵심 기능 단위로 서비스를 나눴습니다.
    • 장점은 각 기능의 요구사항이 명확하게 구분되기 때문에, 서비스 간 책임이 모호해지지 않고, 각각의 서비스가 독립적으로 개발 및 배포될 수 있다는 점입니다.
    • 각 서비스는 자체 DB를 가지며, 통신은 RESTful API 기반으로 이뤄졌습니다.
  • 데이터 정합성 어떻게 관리했나요?
    • (MSA 도입 처음이었고, 일정상 기능 구현과 MVP 완성이 우선 과제였기에 체계적으로 대응하지는 못했다.)
    • 서비스 간 데이터 중복이나 비동기 처리 시점에서 발생할 수 있는 정합성 문제는 인지하고 있었으나, 기능 단위 완성과 배포에 집중했습니다.
    • (이후에 이 부분 개선을 위해 CQRS 패턴과 이벤트 기반 아키텍처에 대해 찾아보았다.
    • 예를 들어, 사용자 점수 변경이나 랭킹 갱신의 경우 이벤트 발행과 소비를 통해 eventual consistency를 확보하는 방안을 고려해볼 수 있었음.)
  • 왜 kafka를 선택했나요? rabbitmq나 redis pub/sub도 있는데
    • Kafka를 선택한 이유는 사용자의 데이터 안정성 확보가 가장 중요한 고려사항이었기 때문입니다.
    • RabbitMQ는 기본적으로 온메모리 방식으로 동작하기에 장애나 재시작 시 메시지 휘발 가능성이 존재합니다. (durable 옵션을 설정하면 디스크 기반으로도 사용가능하지만, 처리량과 지연 측면에서 불리한 부분도 있음.)
    • Kafka는 기본적으로 디스크 기반 구조로 동작하며, 메시지를 일정 기간 동안 보존할 수 있고, 컨슈머 그룹 기반 메시지 처리 모델을 통해 대용량 처리나 재처리에도 유리합니다.
    • (실제로 게임 결과 데이터를 안정적으로 수집하고, 이를 기반으로 랭킹 업데이트나 사용자 기록 저장을 하기 위해 Kafka 기반의 비동기 이벤트 처리 아키텍처로 전환하게 되었습니다.)
  • 다형성이란?
    • 객체지향의 핵심 개념 중 하나로, 같은 메시지를 서로 다른 객체가 다르게 응답할 수 있는 특징을 말합니다.
    • Animal이라는 부모 클래스에 speak() 라는 케서드가 있을 때, Dog, Cat 같은 자식 클래스들이 이를 각각 ‘멍멍’, ‘야옹’으로 구현하면, 동일한 speak() 호출이 객체 타입에 따라 다르게 동작합니다.
    • 코드의 확장성과 유연성이 높아져서, 새로운 동물 클래스가 추가되더라도 기존 코드를 수정하지 않고 사용할 수 있습니다.
  • 응집도, 결합도 → 스프링 개발시 어떤 상황에 적용되는 거 같은지 편하게 설명해보세요.
    • 응집도는 하나의 클래스나 모듈이 얼마나 하나의 책임에 집중하고 있는지를 나타내는 지표
    • 결합도는 다른 클래스나 모듈에 얼마나 강하게 의존하는지를 나타내는 지표
    • 응집도는 기능 단위로 책임을 분리하고 각 모듈이 자신의 역할만 명확히 하도록 구성, 결합도는 di나 인터페이스 추상화를 통해 낮추는 방향으로 구조를 설계.
    • 유지보수성과 테스트 효율이 높아지고 기능 변경 시 영향 범위를 최소화할 수 있다는 장점이 있습니다.
  • nginx왜 썼나요? apache도 있는데?
    • (Nginx는 비동기 이벤트 구조를 가지고 있어, 동시에 많은 요청을 처리하는 데 Apache보다 효율적입니다. Apache는 쓰레드 기반이라 연결 수가 많아질 수록 리소스 사용이 급증하는 반면, Nginx는 가벼운 구조 덕분에 고성능, 고병렬 처리에 유리했습니다.
    • 트래픽 처리 효율이 중요했기 때문에, 보다 나은 성능과 낮은 리소스 소비, 그리고 설정의 유연성 측면에서 Nginx를 선택했습니다.)
  • nginx로 어떤 역할을 처리했나요?
    • 단순한 웹서버가 아닌 여러 기능을 통합 처리하는 게이트웨이 레벨을 역할로 활용
    • 리버스 프록시 → 클라이언트 요청을 내부 API 서버로 전달
    • 서버 분산을 위해 로드밸런싱 기능도 적용
    • 정적 리소스는 Nginx가 직접 서빙하면서 캐싱 처리를 통해 응답 속도 개선
    • 로그인 이후 특정 API 접근 제어를 위해 Lua 스크립트로 인증, 인가 로직을 구현.
  • GC란?
    • JVM에서 더 이상 사용되지 않는 객체를 자동으로 탐지하고 메모리를 관리하는 것.
    • Java는 명시적으로 메모리를 해제하지 않아도 되는데, 그 이유가 바로 GC 덕분입니다. GC는 힙 메모리 영역에서 참조되지 않는 객체들을 찾아 제거함으로써, 메모리 누수나 OutOfMemoryError를 방지하는 역할을 합니다.
  • http vs https ?
    • HTTP와 HTTPS의 가장 큰 차이는 보안 계층 (SSL/TLS)의 적용 여부입니다.
    • HTTP는 데이터를 암호화하지 않고 평문으로 전송하기 때문에, 중간에 네트워크를 가로채면 요청 내용이 그대로 노출될 수 있습니다.
    • 반면 HTTPS는 TLS(Transport Layer Security)를 통해 클라이언트와 서버 간 데이터를 암호화하여 전송하기 때문에, 도청, 위조, 변조에 강합니다.
    • 또한 HTTPS는 서버의 디지털 인증서 (SSL 인증서)를 통해 사용자가 접속한 서버가 신뢰할 수 있는지 검증하는 과정도 포함되어 있어, 피싱 사이트 예방에도 효과적입니다.
  • https://www.naver.com접속하면 일어나는 일
    • 크게 네 단계로 나누어 설명
    • DNS 조회
      • 사용자가 https://www.naver.com을 입력하면, 브라우저는 먼저 로컬 캐시를 확인하고, 없다면 순차적으로 Root → TLD → Authoritative DNS 서버를 통해 IP 주소를 얻습니다.
    • TCP 연결 및 TLS/SSL Handshake
      • IP를 확보한 뒤 서버와 TCP 3-way Handshake로 연결을 맺고, 이어 TLS Handshake를 통해 서버 인증서 검증 및 세션 키 교환을 통해 보안 채널을 생성합니다.
    • HTTP 요청 및 응답
      • 통신 채널이 만들어지면 브라우저는 서버에 HTTP GET 요청을 보냅니다.
      • 이 요청에는 브라우저 정보, 쿠키, 그리고 원하는 리소스 정보 등이 담겨 있습니다. 서버는 요청을 받아 처리한 후, 200 OK와 같은 상태 코드를 포함한 HTTP 응답으로 HTML, CSS, JavaScript 파일 등을 클라이언트에게 전송합니다.
    • 브라우저 랜더링
      • 받은 HTML은 DOM 트리로, CSS는 CSSOM 트리로 파싱되며, 이를 통해 렌더 트리를 만든 뒤 실제 픽셀을 화면에 그려서 화면에 그려집니다.
    • (OSI 7계층 위주의 설명으로도 정리해두기)
  • gc안쓰는 프로그래밍 적용 산업은 왜 그런 언어를 굳이 사용하는지? 왜 그렇게 생각하는지? (방산이나 뭐 로켓 등 산업에서)
    • (GC 없는 언어(C/C++)를 사용하는 산업은 실시간성, 예측 가능한 메모리 제어, 성능 보장이 중요한 분야입니다.
    • 예를 들어 방산, 항공, 로켓 산업처럼 한 치의 지연이나 오작동도 허용되지 않는 시스템에서는 GC에 의한 예기치 않은 성능 저하조차 위험 요소가 될 수 있습니다.
    • 그래서 수동으로 메모리를 제어할 수 있는 언어를 선택하는 것이고, 안정성과 신뢰성이 최우선인 분야 특성에 부합한다고 생각합니다.)

(etc)

  • WS, WAS
    • Web Server, Web Application Server
    • WS는 정적 리소스, 예를 들면 HTML, CSS, 이미지 등을 클라이언트에 전달하는 역할을 하고
    • WAS는 동적인 처리가 필요한 요청, 예를 들어 DB 조회, 로직 실행과 같은 서버 사이드 처리를 담당합니다.
    • 보통 Nginx나 Apache 같은 WS가 클라이언트 요청을 먼저 받고, 내부적으로 WAS로 요청을 전달해 처리한 뒤 응답을 반환하는 구조로 함게 사용합니다.
  • 객체지향 5원칙 중 2개 설명
    • SOLID
    • 단일 책임 원칙(SRP) : 하나의 클래스는 하나의 책임만 가져야한다는 원칙입니다. 즉, 클래스가 하나의 변경 이유만 가져야 한다는 뜻으로, 유지보수성과 응집도를 높이는 데 기여합니다.
    • 개방-폐쇄 원칙(OCP): 소프트웨어 구성 요소는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 원칙입니다. 기능을 추가할 땐 기존 코드를 수정하지 않고, 새로운 클래스를 추가하거나 기존 인터페이스를 구현해서 유연하게 확장할 수 있어야 한다는 의미입니다.
  • abstract vs interface
    • 공통적으로 추상화를 위해 사용된다.
    • abstract 클래스는 공통된 상태와 기본 구현을 함께 제공할 수 있다. 일부 메서드는 구현하고 일부는 추상 메서드로 넘겨 하위 클래스가 구현하게 할 수 있다.
    • interface는 행위 중심의 계약을 정의할 때 사용하며 원래는 메서드 선언만 가능했지만, (java 8 이후로는 default 메서드나 static 메서드도 정의할 수 있게 되었다.)
    • 가장 큰 차이는 상속 구조입니다. 자바는 클래스는 단일 상속만 가능하지만, 인터페이스는 다중 구현이 가능하기 때문에, 여러 역할을 조합해야 할 때는 인터페이스가 더 유리합니다.
    • (인터페이스는 기능에 대한 계약만 정의하고, 실제 구현은 전적으로 구현 클래스에서 맡는 구조입니다. 반면 추상 클래스는 공통된 로직과 상태를 구현해두고, 하위 클래스는 필요한 부분만 오버라이딩해서 쓰는 방식입니다.)

 

지난주부터 갑자기 폭풍전야 같은 시간을 보내서 블로그 글 쓸 시간도 없었고 프로젝트 개발도 멈춰있었는데 ...
이제 좀 숨 돌릴틈이 생겨서 바로 복기 !!!!

 

각 회사가 도메인이나 성격이 너무 다르기도 했지만 .. 일단 !
경험 기반으로 질문 들어왔던 회사 제외, 기술 질문 복기 기록만 올려봅니다.

 

진짜 언제 어디서 기회가 생길지 모른다 .. 부족한 건 인지하고 꾸준히 준비하자 🚀

'CS 및 면접복기' 카테고리의 다른 글

[OS] JVM부터 동시성까지  (2) 2025.09.12
20250820 - 디자인패턴(2)  (2) 2025.08.20
20250818 - 디자인 패턴  (2) 2025.08.18
20250811 - Network  (2) 2025.08.11
20250808  (3) 2025.08.08

+ Recent posts