데일리 CS 학습
- Q. 인덱스(Index)를 너무 많이 만들면 안되는 이유는 무엇인가요?
- A. 인덱스 조회는 성능을 높여주지만, 삽입, 수정, 삭제 시마다 인덱스 정보도 함께 갱신해야 하므로 쓰기 성능이 저하됩니다. 또한 인덱스 자체가 별도의 저장 공간을 차지하므로, 과도한 인덱스는 DB의 전체적인 효율성을 떨어뜨리고 관리 복잡도를 높입니다.
- Q. JVM이란 무엇이고 어떤 역할을 하나요?
- A. Java Virtual Machine의 약자로 자바 바이트코드를 운영체제에 맞게 실행해주는 가상 머신입니다. 플랫폼 독립성을 제공하며, 클래스 로더, 실행 엔진, 가비지 컬렉터 등으로 구성됩니다.
- Q. 객체지향 프로그래밍(OOP)의 4가지 특징을 설명해 주세요.
- A. 캡슐화(데이터와 메서드를 하나로 묶고 은닉), 상속(부모 클래스의 속성, 기능을 자식이 물려받음), 다형성(같은 이름의 메서드가 다르게 동작), 추상화(불필요한 내용을 숨기고 핵심만 표현)입니다.
- Q. 인터페이스와 추상 클래스의 차이점은 무엇인가요?
- A. 인터페이스는 다중 구현이 가능하며 모든 메서드가 기본적으로 추상 메서드입니다. 추상 클래스는 단일 상속만 가능하고, 일반 메서드와 필드를 포함할 수 있습니다. 공통 행동 정의에는 추상 클래스, 다중 역할 부여에는 인터페이스를 씁니다.
- Q. 오버로딩과 오버라이딩의 차이를 설명해주세요.
- A. 오버로딩은 같은 클래스 내에서 메서드 이름은 같고 매개변수가 다른 것, 오버라이딩은 부모 클래스의 메서드를 자식 클래스에서 재정의하는 것입니다.
- Q. Java에서 스택과 힙 메모리의 차이를 설명해 주세요
- A. 스택은 메서드 호출 시 생성되는 지역 변수, 매개변수 등을 저장하며 메서드 종료 시 자동 해제됩니다. 힙은 new 키워드로 생성된 객체가 저장되며, GC가 관리힙니다.
트랜잭션 전파(Propagation) &
격리 수준(Isolation Level)
REQUIRED vs REQUIRES_NEW, Dirty Read / Phantom Read, @Transactional 내부 AOP 동작까지
01 트랜잭션이란?
트랜잭션은 하나의 논리적 작업 단위로, 내부 연산이 모두 성공하거나 모두 실패해야 합니다. 이를 ACID 속성이라고 합니다.
| 속성 | 의미 | 예시 |
|---|---|---|
| Atomicity 원자성 | 전부 성공 또는 전부 실패 | 계좌 이체 — 출금만 되고 입금 안 되면 안 됨 |
| Consistency 일관성 | 트랜잭션 전후 DB 무결성 유지 | 잔액이 음수가 되는 상태로 커밋 불가 |
| Isolation 격리성 | 동시 실행 트랜잭션은 서로 독립 | A가 수정 중인 데이터를 B가 못 봄 |
| Durability 지속성 | 커밋된 데이터는 영구 저장 | 장애 후 재시작해도 커밋 데이터 보존 |
02 @Transactional 내부 동작 (AOP 프록시)
프록시 생성 과정
Spring은 @Transactional이 붙은 빈을 CGLIB 프록시(바이트코드 조작)로 감싸서 스프링 컨테이너에 등록합니다. 실제 메서드 호출 전후로 트랜잭션 시작/커밋/롤백 로직을 끼워 넣는 방식입니다.
Controller 등
트랜잭션 시작
비즈니스 로직
커밋 or 롤백
// Spring이 내부적으로 생성하는 프록시 구조 (개념적 표현)
public class OrderService$$SpringCGLIB extends OrderService {
@Override
public void placeOrder(OrderRequest request) {
TransactionStatus status = txManager.getTransaction(txDef); // BEGIN
try {
super.placeOrder(request); // 실제 비즈니스 로직 실행
txManager.commit(status); // COMMIT
} catch (RuntimeException e) {
txManager.rollback(status); // ROLLBACK
throw e;
}
}
}Java
@Transactional은 기본적으로 RuntimeException과 Error에서만 롤백합니다. CheckedException (예: IOException)은 롤백하지 않습니다. 체크 예외도 롤백하려면 @Transactional(rollbackFor = Exception.class)을 명시해야 합니다.self-invocation 함정
가장 많이 실수하는 부분입니다. 같은 클래스 내에서 @Transactional 메서드를 직접 호출하면 프록시를 거치지 않으므로 트랜잭션이 적용되지 않습니다.
@Service
public class OrderService {
// ❌ 잘못된 코드 — self-invocation
public void createOrder(OrderRequest req) {
saveOrder(req); // this.saveOrder() → 프록시 우회! 트랜잭션 없음
}
@Transactional
public void saveOrder(OrderRequest req) { ... }
}
// ✅ 해결법 1 — 별도 클래스로 분리 (가장 권장)
@Service
public class OrderSaveService {
@Transactional
public void saveOrder(OrderRequest req) { ... }
}
// ✅ 해결법 2 — ApplicationContext에서 self 주입 (권장하지 않음)
@Service
public class OrderService {
@Autowired
private OrderService self; // 프록시 빈을 주입받음
public void createOrder(OrderRequest req) {
self.saveOrder(req); // 프록시를 통해 호출 → 트랜잭션 적용됨
}
}Java
CGLIB 프록시는 메서드를 오버라이딩해서 동작하는데,
private 메서드는 오버라이딩이 불가능하기 때문입니다. @Transactional은 반드시 public 메서드에만 붙이세요.readOnly 최적화
@Transactional(readOnly = true) // 조회 전용 트랜잭션
public List<OrderDto> getOrders(Long userId) {
return orderRepository.findByUserId(userId);
}Java
readOnly = true로 설정하면 세 가지 최적화가 이루어집니다.
- Hibernate flush 모드가 MANUAL로 변경 — 변경 감지(dirty checking) 자체를 수행하지 않아 스냅샷 비교 비용이 사라집니다.
- DB 드라이버/커넥션 풀 최적화 — read-only 힌트를 DB에 전달해 DB가 리드 레플리카로 라우팅할 수 있습니다.
- 불필요한 락 방지 — 일부 DB에서 읽기 전용 트랜잭션에 S-Lock 대신 무락(no-lock) 읽기를 수행합니다.
03 트랜잭션 전파 (Propagation)
메서드 A가 메서드 B를 호출할 때, B가 기존 트랜잭션에 합류할지 새로 만들지를 결정하는 것이 전파 옵션입니다.
REQUIRED vs REQUIRES_NEW — 핵심 차이
REQUIRED (기본값) — 기존 트랜잭션에 합류
@Transactional(propagation = Propagation.REQUIRED) // 생략해도 동일 (기본값)
public void outer() {
inner(); // inner는 outer의 트랜잭션에 참여
}
@Transactional(propagation = Propagation.REQUIRED)
public void inner() {
// outer와 같은 물리적 트랜잭션 공유
// inner에서 예외 발생 → 전체(outer 포함) 롤백
}Java
REQUIRES_NEW — 무조건 새 트랜잭션
@Transactional(propagation = Propagation.REQUIRED)
public void placeOrder() {
orderRepository.save(order); // TX-A에서 실행
auditService.log("주문 생성"); // TX-B (새 트랜잭션) — 독립적으로 커밋
throw new RuntimeException(); // TX-A 롤백, TX-B는 이미 커밋됨
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String msg) {
// 별도의 물리적 트랜잭션 TX-B 시작
// outer가 롤백돼도 이 로그는 DB에 남음
auditLogRepository.save(new AuditLog(msg));
}Java
REQUIRES_NEW는 외부 트랜잭션을 일시 중단하고 새 커넥션을 가져옵니다. 동시 요청이 많으면 커넥션 풀이 고갈될 수 있습니다. HikariCP 기본값(10개)에서 5개의 동시 요청만 있어도 10개의 커넥션이 필요해 데드락이 발생할 수 있습니다.나머지 전파 옵션
REQUIRED
기존 트랜잭션이 있으면 참여, 없으면 새로 생성. 가장 일반적인 케이스.
REQUIRES_NEW
기존 트랜잭션을 일시 중단하고 새 트랜잭션 시작. 감사 로그, 알림 발송 등 독립적 커밋이 필요한 경우.
SUPPORTS
기존 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행. 조회 로직에 유용.
NOT_SUPPORTED
기존 트랜잭션을 일시 중단하고 트랜잭션 없이 실행. 성능이 중요한 단순 조회에 사용.
MANDATORY
기존 트랜잭션이 반드시 있어야 함. 없으면 IllegalTransactionStateException 발생.
NEVER
트랜잭션이 있으면 예외 발생. 트랜잭션 없이만 실행돼야 하는 로직에 사용.
전파 관련 실수 패턴
// ❌ 문제 — inner에서 예외를 잡았는데 outer도 롤백됨
@Transactional
public void outer() {
try {
inner(); // REQUIRED → 같은 TX 공유
} catch (Exception e) {
// 예외는 잡았지만 TX에 이미 rollback-only 마크가 찍힘!
log.warn("inner 실패, 무시");
}
outerLogic(); // 여기서 UnexpectedRollbackException 발생
}
@Transactional // REQUIRED — outer와 같은 TX
public void inner() {
throw new RuntimeException(); // TX에 rollback-only 마킹
}
// ✅ 해결 — inner를 REQUIRES_NEW로 독립시키거나 예외를 outer로 전파
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { ... } // 이제 별도 TX → outer가 catch해도 무방Java
REQUIRED로 합류한 inner에서 RuntimeException이 발생하면 Spring은 공유 트랜잭션에 rollback-only 플래그를 세웁니다. outer에서 예외를 catch해도 이 플래그는 지워지지 않아, 커밋 시점에 UnexpectedRollbackException이 발생합니다.04 격리 수준 (Isolation Level)
격리 수준은 동시에 실행되는 트랜잭션들이 서로 얼마나 영향을 주고받는가를 결정합니다. 격리 수준이 높을수록 정합성은 올라가지만 성능은 낮아집니다.
동시성 문제 3가지
1. Dirty Read — 커밋되지 않은 데이터를 읽음
2. Non-Repeatable Read — 같은 쿼리가 다른 결과를 반환
3. Phantom Read — 같은 조건의 쿼리에서 행 개수가 달라짐
4가지 격리 수준 비교
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 성능 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 발생 | 발생 | 발생 | 가장 빠름 |
| READ_COMMITTED | 방지 | 발생 | 발생 | 빠름 |
| REPEATABLE_READ | 방지 | 방지 | 발생 (이론상) | 보통 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 가장 느림 |
대부분의 DB는
READ_COMMITTED를 기본값으로 사용합니다. MySQL InnoDB는 예외적으로 REPEATABLE_READ가 기본값이며, MVCC 덕분에 Phantom Read도 대부분 방지됩니다. Spring의 @Transactional은 DEFAULT로 설정 시 사용 중인 DB 드라이버의 기본 격리 수준을 따릅니다.Spring에서 격리 수준 지정
// 특정 메서드에만 격리 수준 적용
@Transactional(isolation = Isolation.REPEATABLE_READ)
public OrderSummary getOrderSummary(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
List<Item> items = itemRepository.findByOrderId(orderId);
// 두 쿼리 사이에 다른 TX가 수정해도 같은 스냅샷 데이터 보장
return new OrderSummary(order, items);
}
// 격리 수준 옵션 목록
// Isolation.DEFAULT — DB 드라이버 기본값
// Isolation.READ_UNCOMMITTED — 거의 사용 안 함
// Isolation.READ_COMMITTED — Oracle, PostgreSQL 기본
// Isolation.REPEATABLE_READ — MySQL InnoDB 기본
// Isolation.SERIALIZABLE — 완전 직렬화, 성능 매우 낮음Java
MySQL InnoDB의 MVCC
InnoDB는 MVCC(Multi-Version Concurrency Control)로 락 없이 동시성을 확보합니다. 각 트랜잭션은 시작 시점의 스냅샷(Undo Log 기반)을 읽습니다.
InnoDB는 모든 행에
trx_id(마지막 변경 트랜잭션 ID)와 roll_ptr(이전 버전 포인터)을 숨겨진 컬럼으로 관리합니다. REPEATABLE_READ에서는 트랜잭션 시작 시 read_view를 생성하고, 자신보다 나중에 커밋된 변경은 Undo Log를 통해 이전 버전을 읽습니다. 이 덕분에 SELECT는 락을 걸지 않아도 일관된 데이터를 읽을 수 있습니다.-- REPEATABLE_READ에서 Phantom Read가 방지되는 이유
-- TX-A 시작 (read_view = {min_trx_id: 100, max_trx_id: 110})
SELECT * FROM member WHERE age > 20; -- 3건
-- TX-B가 새 회원 INSERT + COMMIT (trx_id = 115)
-- TX-A 재조회: trx_id 115는 max_trx_id(110)보다 크므로 무시
SELECT * FROM member WHERE age > 20; -- 여전히 3건 (MVCC 덕분에 Phantom 없음)
-- 단, SELECT FOR UPDATE (잠금 읽기)는 최신 데이터를 읽으므로 Phantom 발생 가능
SELECT * FROM member WHERE age > 20 FOR UPDATE; -- 4건 (갭 락으로 방지 가능)SQL
05 비교 정리 및 선택 기준
전파 옵션 선택 기준
| 상황 | 권장 전파 옵션 | 이유 |
|---|---|---|
| 일반 비즈니스 로직 (주문, 결제) | REQUIRED | 하나의 작업이 하나의 트랜잭션으로 묶여야 원자성 보장 |
| 감사 로그, 알림 발송 | REQUIRES_NEW | 본 작업 실패 시에도 로그는 남아야 함 (독립 커밋) |
| 단순 조회 (트랜잭션 불필요) | SUPPORTS 또는 NOT_SUPPORTED | 불필요한 트랜잭션 오버헤드 제거 |
| 반드시 트랜잭션 내에서만 실행 | MANDATORY | 실수로 트랜잭션 없이 호출하는 것을 예외로 방지 |
격리 수준 선택 기준
| 상황 | 권장 격리 수준 | 이유 |
|---|---|---|
| 일반 웹 서비스 (기본) | READ_COMMITTED | Dirty Read만 방지해도 충분, 성능 우수 |
| 한 트랜잭션 내 반복 조회 필요 | REPEATABLE_READ | 같은 데이터를 여러 번 읽는 집계/정산 작업 |
| 금융, 재고 — 완벽한 정합성 | SERIALIZABLE | 완전한 직렬 실행 보장, 성능 희생 |
| 통계, 근사값 조회 | READ_UNCOMMITTED | 약간의 오차 허용, 성능 최우선 |
핵심 요약
- @Transactional은 CGLIB 프록시 기반 AOP로 동작 →
public메서드에만,self-invocation주의 - REQUIRED는 기존 트랜잭션에 합류 → inner 예외 시 전체 롤백 (rollback-only 마킹)
- REQUIRES_NEW는 독립 트랜잭션 → 커넥션 2개 소모, 커넥션 풀 고갈 주의
- Dirty Read → READ_COMMITTED 이상으로 방지 / Non-Repeatable → REPEATABLE_READ 이상 / Phantom → SERIALIZABLE (InnoDB는 MVCC로 REPEATABLE_READ에서도 대부분 방지)
- MySQL InnoDB 기본값은 REPEATABLE_READ + MVCC → SELECT는 스냅샷 읽기, SELECT FOR UPDATE는 최신 읽기
06 면접 예상 질문
Q1. @Transactional이 동작하지 않는 경우를 설명하세요.
RuntimeException이므로 IOException 등은 rollbackFor를 별도 지정해야 합니다.Q2. REQUIRED와 REQUIRES_NEW의 차이 및 사용 시 주의점은?
REQUIRED는 기존 트랜잭션에 합류해 물리적 커넥션을 공유합니다. inner에서 예외 발생 시 공유 트랜잭션 전체에 rollback-only가 마킹되어 outer에서 catch해도 UnexpectedRollbackException이 발생합니다. REQUIRES_NEW는 새 커넥션을 가져와 독립적으로 커밋/롤백하지만, 동시에 커넥션을 2개 이상 점유하므로 커넥션 풀 크기를 고려해야 합니다.Q3. Dirty Read, Non-Repeatable Read, Phantom Read의 차이는?
Q4. MySQL InnoDB의 기본 격리 수준이 REPEATABLE_READ인데 왜 Phantom Read가 잘 발생하지 않나요?
read_view(스냅샷)를 생성합니다. 일반 SELECT는 이 스냅샷을 기준으로 Undo Log를 통해 이전 버전을 읽기 때문에, 이후에 다른 트랜잭션이 INSERT하고 커밋해도 해당 행이 read_view 범위를 벗어나 보이지 않습니다. 단, SELECT FOR UPDATE 같은 잠금 읽기는 최신 데이터를 읽으므로 Phantom이 발생할 수 있고, 이 경우 갭 락(Gap Lock)으로 방어합니다.Q5. readOnly = true는 왜 성능에 유리한가요?
MANUAL로 바뀌어 dirty checking(엔티티 스냅샷 비교)을 수행하지 않습니다. 조회만 하는데도 JPA가 1차 캐시에 엔티티를 스냅샷으로 복사해두는 비용이 사라집니다. 추가로 Spring의 LazyConnectionDataSourceProxy와 함께 사용하면 실제 쿼리가 발생할 때까지 커넥션 획득을 지연하거나 read replica로 라우팅하는 것도 가능합니다.'스프링' 카테고리의 다른 글
| @Transactional 동작 원리AOP 프록시 · self-invocation · readOnly 최적화 (0) | 2026.04.15 |
|---|---|
| JPA N+1 문제발생 원인과 3가지 해결 전략 (0) | 2026.04.13 |
| 스프링 인증/인가..! JWt..? (0) | 2024.09.10 |
| Spring 인증 및 관리 시스템 (0) | 2024.09.06 |
| Spring JPA 뭐하는 키워드지..? (1) | 2024.08.24 |