QueryDSL, Cache, Redis, 인덱스, 쿼리 튜닝, 동시성, AOP, 테스트 영역에서 나올 수 있는 면접 질문과 핵심 답변을 정리했습니다.
A.
QueryDSL에서는 검색 조건을 BooleanExpression 메서드로 분리해서 동적 쿼리를 만들 수 있습니다.
예를 들어 검색어, 카테고리, 상태값이 있을 때 값이 존재하면 조건을 반환하고, 값이 없으면 null을 반환합니다.
private BooleanExpression keywordContains(String keyword) {
if (keyword == null || keyword.isBlank()) {
return null;
}
return product.name.contains(keyword);
}
그리고 where()에 여러 조건을 넣으면 필요한 조건만 적용됩니다.
.where(
keywordContains(keyword),
categoryEq(category),
statusEq(status)
)
이 방식은 조건이 많아져도 코드가 깔끔하고, 각 조건을 메서드로 분리할 수 있어 유지보수에 좋습니다.
QueryDSL의 where()는 여러 조건 중 null인 조건을 무시합니다.
그래서 검색 조건이 없을 때 억지로 1=1 같은 조건을 만들 필요가 없습니다.
즉, 조건이 있으면 적용되고 없으면 제외되기 때문에 동적 쿼리를 안전하고 간결하게 작성할 수 있습니다.
JPA Specification도 동적 쿼리를 만들 수 있지만, QueryDSL은 타입 안정성이 좋고 코드 가독성이 더 좋다고 느꼈습니다.
특히 복잡한 검색 조건, 정렬, 페이징, 조인 쿼리를 작성할 때 QueryDSL은 Java 코드처럼 읽히기 때문에 유지보수에 유리합니다.
또한 QClass를 기반으로 필드명을 컴파일 시점에 확인할 수 있어 문자열 기반 쿼리보다 실수를 줄일 수 있습니다.
A.
페이징을 할 때는 실제 데이터를 가져오는 content 쿼리와 전체 개수를 구하는 count 쿼리가 필요합니다.
하지만 content 쿼리에 필요한 join이 count 쿼리에는 필요하지 않은 경우가 많습니다.
그래서 count 쿼리를 따로 작성하면 불필요한 join을 줄여 성능을 개선할 수 있습니다.
content query: 실제 목록 조회
count query: 전체 개수 조회
데이터가 많아질수록 count 쿼리 비용도 커지기 때문에, 별도로 최적화하는 것이 중요합니다.
join이 많으면 DB가 여러 테이블을 결합하고 조건을 비교해야 합니다.
하지만 count 쿼리는 보통 “조건에 맞는 데이터 개수”만 필요하기 때문에, 응답 DTO를 만들기 위한 join이나 fetch join은 필요 없는 경우가 많습니다.
예를 들어 상품 목록 조회에서 판매자 이름을 보여주기 위해 seller를 join했다면, 전체 상품 개수를 세는 count 쿼리에서는 seller join이 필요 없을 수 있습니다.
이런 join을 제거하면 DB가 읽어야 하는 데이터와 연산량이 줄어 성능이 개선됩니다.
Offset 페이징은 offset만큼 데이터를 건너뛴 뒤 필요한 데이터를 가져옵니다.
예를 들어 100만 번째 데이터부터 20개를 가져오려면 DB는 앞의 100만 개를 건너뛰는 작업을 해야 합니다.
반면 No-Offset 페이징은 마지막으로 조회한 ID나 기준값 이후의 데이터를 조회합니다.
Offset 방식:
LIMIT 20 OFFSET 1000000
No-Offset 방식:
WHERE id < 마지막으로 조회한 id
LIMIT 20
데이터가 많아질수록 Offset 방식은 건너뛰는 비용이 커지고, No-Offset 방식은 인덱스를 활용해 다음 데이터를 바로 찾을 수 있어 더 효율적입니다.
A.
캐시 키는 같은 요청이면 같은 캐시를 사용하고, 다른 요청이면 다른 캐시를 사용하도록 설계해야 합니다.
예를 들어 상품 검색 결과를 캐싱한다면 검색어, 카테고리, 정렬 조건, 페이지 번호, 페이지 크기 등을 키에 포함해야 합니다.
@Cacheable(
value = "productSearch",
key = "#keyword + ':' + #category + ':' + #page + ':' + #size"
)
캐시 키를 너무 단순하게 만들면 서로 다른 검색 조건이 같은 캐시를 공유하는 문제가 생길 수 있습니다.
반대로 너무 복잡하게 만들면 캐시 재사용률이 떨어질 수 있습니다.
Spring의 @Cacheable은 프록시 기반 AOP로 동작합니다.
외부에서 프록시 객체를 통해 메서드를 호출해야 캐시 로직이 적용됩니다.
하지만 같은 클래스 내부에서 this.method()처럼 호출하면 프록시를 거치지 않기 때문에 @Cacheable이 동작하지 않을 수 있습니다.
이것을 Self-invocation 문제라고 합니다.
@CachePut은 DB를 갱신한 뒤 캐시도 즉시 최신 값으로 갱신할 때 사용합니다.
@CacheEvict는 DB 갱신 후 기존 캐시를 삭제하고, 다음 조회 시 다시 캐싱하게 할 때 사용합니다.
| 방식 | 사용 상황 |
|---|---|
@CachePut | 갱신 결과가 명확하고 캐시에 바로 반영하고 싶을 때 |
@CacheEvict | 여러 캐시 키에 영향이 있거나 갱신 범위가 넓을 때 |
검색 결과 캐시처럼 하나의 데이터 변경이 여러 페이지, 여러 검색 조건에 영향을 주는 경우에는 캐시를 직접 갱신하기보다 무효화하는 방식이 더 안전합니다.
A.
Redis TTL은 데이터의 변경 빈도와 실시간성이 얼마나 중요한지 기준으로 결정합니다.
자주 바뀌지 않고 조회가 많은 데이터는 TTL을 길게 가져가고, 자주 바뀌는 데이터는 짧게 가져갑니다.
예를 들어 다음과 같이 설정할 수 있습니다.
| 데이터 | TTL 기준 |
|---|---|
| 상품 상세 | 비교적 길게 |
| 인기 검색어 | 짧게 |
| 재고 정보 | 매우 짧게 또는 캐시 제외 |
| 이벤트 상품 목록 | 이벤트 기간과 변경 빈도에 맞게 |
특히 재고처럼 정확성이 중요한 데이터는 캐시보다 DB나 Redis 원자 연산을 기준으로 처리하는 것이 안전합니다.
여러 캐시가 동시에 만료되고 요청이 몰리면 많은 요청이 한꺼번에 DB로 향할 수 있습니다.
이것을 Cache Stampede 또는 Cache Avalanche라고 합니다.
완화 방법은 다음과 같습니다.
예를 들어 상품 가격을 DB에서 수정했는데 기존 상품 상세 캐시를 삭제하지 않으면 문제가 생길 수 있습니다.
DB에는 10,000원으로 수정되었지만 Redis에는 이전 가격인 12,000원이 남아 있을 수 있습니다.
이 상태에서 사용자가 상품을 조회하면 오래된 가격을 보게 됩니다.
이런 문제를 줄이려면 DB 수정 후 관련 캐시를 무효화하거나 TTL을 짧게 설정해야 합니다.
A.
복합 인덱스는 자주 사용되는 조회 조건과 정렬 조건을 기준으로 설계합니다.
일반적으로 다음 기준을 고려합니다.
예를 들어 프로모션 상품을 상태와 시작 시간 기준으로 조회한다면 다음과 같은 인덱스를 고려할 수 있습니다.
(status, start_at, id)
단, 무조건 컬럼을 많이 넣는 것이 좋은 것은 아니고, 실제 실행 계획을 보고 결정해야 합니다.
복합 인덱스는 왼쪽부터 순서대로 사용됩니다.
예를 들어 (status, category_id, created_at) 인덱스가 있을 때 category_id만 조건에 사용하면 인덱스를 제대로 활용하기 어렵습니다.
-- 인덱스 활용에 유리
WHERE status = 'OPEN' AND category_id = 1
-- 선행 컬럼이 빠져 인덱스 활용이 어려울 수 있음
WHERE category_id = 1
그래서 복합 인덱스를 만들 때는 실제 쿼리에서 자주 함께 사용되는 조건 순서를 고려해야 합니다.
카디널리티가 낮다는 것은 값의 종류가 적다는 뜻입니다.
예를 들어 성별, 판매 상태처럼 값의 종류가 적은 컬럼은 인덱스를 타도 많은 행을 읽어야 할 수 있습니다.
이 경우 DB 입장에서는 인덱스를 사용하는 것보다 테이블 전체를 읽는 것이 더 빠를 수도 있습니다.
그래서 카디널리티가 낮은 컬럼은 단독 인덱스보다 다른 조건과 함께 복합 인덱스로 설계하는 경우가 많습니다.
A.
type이 ALL로 표시된다는 것은 Full Table Scan을 의미합니다.
즉, 조건에 맞는 데이터를 찾기 위해 테이블 전체를 읽는다는 뜻입니다.
개선할 때는 다음 순서로 접근합니다.
WHERE, ORDER BY, JOIN 조건 확인EXPLAIN으로 실행 계획 확인단순히 인덱스를 추가하는 것이 아니라, 실제 쿼리 패턴과 실행 계획을 함께 봐야 합니다.
인덱스가 있어도 옵티마이저가 Full Scan이 더 빠르다고 판단하면 인덱스를 사용하지 않을 수 있습니다.
대표적인 경우는 다음과 같습니다.
%keyword처럼 앞에 와일드카드로 시작할 때인덱스는 조회 성능을 높일 수 있지만, 쓰기 작업에는 비용이 추가됩니다.
데이터를 INSERT, UPDATE, DELETE할 때 테이블뿐만 아니라 인덱스도 함께 갱신해야 하기 때문입니다.
따라서 인덱스가 많아질수록 저장 공간도 늘어나고 쓰기 성능은 떨어질 수 있습니다.
그래서 인덱스는 “조회에 자주 사용되는 쿼리”를 기준으로 필요한 만큼만 추가해야 합니다.
A.
재고 차감처럼 초과 판매가 발생하면 안 되는 로직에서는 비관적 락을 선택할 수 있습니다.
비관적 락은 데이터를 수정하기 전에 먼저 락을 걸어서 다른 트랜잭션이 동시에 수정하지 못하게 막습니다.
재고가 적고 요청이 몰리는 선착순 이벤트 상황에서는 충돌 가능성이 높기 때문에, 낙관적 락보다 비관적 락이 더 단순하고 안전할 수 있습니다.
다만 비관적 락은 락 대기 시간이 길어지면 성능 저하가 발생할 수 있으므로 트랜잭션 범위를 짧게 가져가야 합니다.
낙관적 락은 version 컬럼을 기준으로 충돌을 감지합니다.
동시에 여러 요청이 같은 재고를 수정하면 먼저 커밋한 요청만 성공하고, 나중 요청은 버전 불일치로 예외가 발생합니다.
이때 재시도 로직을 둘 수 있습니다.
1. 재고 조회
2. 재고 차감 시도
3. OptimisticLockException 발생
4. 짧은 대기 후 재시도
5. 최대 재시도 횟수 초과 시 실패 처리
재시도 횟수는 무한정으로 두면 안 되고, 예를 들어 3회처럼 제한을 두는 것이 안전합니다.
데드락은 두 트랜잭션이 서로가 가진 락을 기다리면서 멈추는 상황입니다.
예를 들어 주문 A는 상품 1번을 먼저 잠그고 상품 2번을 기다리고, 주문 B는 상품 2번을 먼저 잠그고 상품 1번을 기다리면 데드락이 발생할 수 있습니다.
이를 줄이려면 여러 row에 락을 걸 때 항상 같은 순서로 접근해야 합니다.
예를 들어 상품 ID 오름차순으로 정렬한 뒤 락을 획득하는 방식이 있습니다.
A.
Redis 분산 락은 보통 TTL을 함께 설정합니다.
락을 획득한 서버가 갑자기 죽더라도 TTL이 지나면 락이 자동으로 만료되어 다른 서버가 다시 락을 획득할 수 있습니다.
만약 TTL 없이 락을 잡으면 서버 장애 시 락이 영원히 풀리지 않는 문제가 생길 수 있습니다.
그래서 분산 락에서는 다음이 중요합니다.
Lettuce로 직접 분산 락을 구현하면 보통 락을 얻을 때까지 Redis에 반복 요청하는 스핀 락 방식이 됩니다.
요청이 적으면 단순해서 사용할 수 있지만, 요청이 많으면 Redis에 부담이 커질 수 있습니다.
Redisson은 pub/sub 방식으로 락 해제 이벤트를 기다릴 수 있어 불필요한 반복 요청을 줄일 수 있습니다.
따라서 트래픽이 많고 락 경합이 자주 발생하는 상황에서는 Redisson이 더 적합할 수 있습니다.
분산 락을 AOP로 분리하면 비즈니스 로직에서 락 획득과 해제 코드를 제거할 수 있습니다.
장점은 다음과 같습니다.
주의할 점은 다음과 같습니다.
finally에서 수행되어야 함A.
@Transactional도 Spring AOP 기반으로 동작하고, 커스텀 AOP도 프록시 기반으로 동작합니다.
둘 다 같은 메서드에 적용되면 @Order 설정이나 우선순위에 따라 실행 순서가 달라질 수 있습니다.
예를 들어 분산 락 AOP와 트랜잭션이 함께 있다면 보통 다음 순서를 의도합니다.
락 획득
→ 트랜잭션 시작
→ 비즈니스 로직 실행
→ 트랜잭션 커밋
→ 락 해제
이렇게 해야 트랜잭션이 끝나기 전에 락이 먼저 풀려서 다른 요청이 들어오는 문제를 줄일 수 있습니다.
Self-invocation은 같은 클래스 내부에서 자기 자신의 메서드를 직접 호출할 때 발생합니다.
Spring AOP는 프록시 객체를 거쳐야 동작하는데, 내부 호출은 프록시를 거치지 않고 this.method()로 호출됩니다.
그래서 @Transactional, @Cacheable, 커스텀 AOP 모두 같은 문제가 발생할 수 있습니다.
해결 방법은 다음과 같습니다.
트랜잭션 안에서 예외가 발생하고 해당 예외가 밖으로 전파되면 롤백됩니다.
기본적으로 Spring은 RuntimeException과 Error에 대해 롤백합니다.
다만 AOP 어드바이스에서 예외를 잡고 삼켜버리면 트랜잭션은 정상 처리로 판단할 수 있습니다.
그래서 롤백이 필요한 예외는 반드시 다시 던지거나, rollbackFor 옵션을 명확히 설정해야 합니다.
A.
동시성 테스트는 ExecutorService와 CountDownLatch를 사용해 여러 스레드가 동시에 같은 로직을 실행하도록 작성할 수 있습니다.
흐름은 다음과 같습니다.
CountDownLatch로 동시에 시작하도록 맞춥니다.ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
try {
stockService.decreaseStock(productId);
} finally {
latch.countDown();
}
});
}
latch.await();
이 테스트를 통해 동시에 요청이 들어와도 재고가 음수가 되지 않는지 확인할 수 있습니다.
이 방식은 동시성 문제를 재현하는 데 도움이 되지만 실제 운영 환경을 완전히 대체할 수는 없습니다.
한계는 다음과 같습니다.
그래서 단위 테스트뿐만 아니라 통합 테스트, 부하 테스트도 함께 고려해야 합니다.
운영 환경은 로컬보다 훨씬 많은 변수가 있습니다.
대표적인 원인은 다음과 같습니다.
따라서 운영 환경을 고려한 동시성 제어는 단순히 로컬 테스트 통과만으로 끝내면 안 되고, 락 범위, 트랜잭션 범위, 커넥션 풀, 장애 복구까지 함께 검토해야 합니다.
CH5 플러스 스프링 면접에서는 단순 CRUD보다 성능 최적화와 동시성 제어를 얼마나 이해하고 있는지 확인할 가능성이 높습니다.
특히 다음 키워드는 꼭 연결해서 설명할 수 있어야 합니다.
면접에서는 “기능을 구현했다”보다
왜 이 방식을 선택했는지, 어떤 문제가 생길 수 있는지, 그 문제를 어떻게 완화했는지까지 함께 말하는 것이 중요합니다.
출처: 내일배움 스파르타 클럽