트러블 슈팅(1)
날짜 검색에서 LocalDate vs LocalDateTime
왜 LocalDate로 입력을 받았을까?
사용자는 날짜만 신경 쓰지, 시:분:초까지 직접 입력할 필요가 없음
UI/UX 관점에서 날짜 입력이 훨씬 직관적임
DB에는 생성 시점이 LocalDateTime으로 저장됨
응답 DTO는 정확한 생성 시각을 보여주는 게 맞음
@DateTimeFormat + atStartOfDay / LocalTime.MAX를 쓰는 이유
LocalDate는 시간 정보가 없음
DB 컬럼은 LocalDateTime
그래서:
시작일 → 00:00:00
종료일 → 23:59:59.999999999
이 방식이 의도가 가장 명확하다고 함.
QueryDSL/JPA에서 시간 경계 버그를 가장 확실히 방지
QueryDSL에서 BooleanExpression이 null인데도 동작하는 이유
private BooleanExpression titleContains(String keyword) {
return keyword == null ? null : todo.title.contains(keyword);
}
QueryDSL의 where()는 null 조건을 자동으로 무시한다.
조건이 있으면 AND로 결합하고, 없으면 제외한다고 한다.
query.where(
titleContains(keyword),
createdAtBetween(start, end)
);
장점
if-else 지옥 방지
조건 조합 폭발 방지
가독성, 유지보수성 ↑
fetchOne() vs fetch() 차이 (강의내용 중복)
fetchOne()
결과가 1개일 거라 확신할 때만
2개 이상이면 예외 발생
fetch()
리스트 조회
결과 개수와 무관
Optional을 쓰는 이유 & orElseThrow 자동완성
null 반환 → 실수로 NPE 발생
Optional → “없을 수도 있음”을 타입으로 강제
userRepository.findById(id)
.orElseThrow(...)
orElseThrow()는 Optional 전용 메서드
엔티티 자체에는 존재하지 않음
N+1 문제가 발생한 이유와 해결 방식
연관 엔티티가 LAZY 로딩
반복 접근 시 쿼리가 추가로 실행됨
fetch join
QueryDSL에서 join + fetch
필요한 필드만 Projections로 조회
SecurityConfig에 permitAll이 있는데 JWT 필터에서 또 체크하는 이유
.anyRequest().authenticated()
이건 인가(Authorization) 단계
JwtFilter는 그보다 앞단의 인증(Authentication) 필터
필터는 무조건 실행됨
/auth/** 요청에서도 JWT 검사하면 오류 발생
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getRequestURI().startsWith("/auth");
}
UsernamePasswordAuthenticationFilter 앞에 JWT 필터를 두는 이유
폼 로그인 기반
ID/PW 인증 처리
JWT는 이미 인증된 사용자
UsernamePasswordAuthenticationFilter까지 갈 필요 없음
정리
“Spring / JPA / Security에서의 설정과 패턴은
‘될 때까지 맞추는 코드’가 아니라
왜 이 레이어에서 이 책임을 가지는지를 이해하는 게 중요하다!!!”
트러블 슈팅(2)
Long total = queryFactory .select(todo.id.count()) .from(todo) .where( todo.title.contains(keyword), todo.createdAt.between(start, end) ) .fetchOne();
왜 이 코드가 추가로 필요할까?
페이징 응답에는 보통 이 정보들이 필요합니다
현재 페이지의 데이터 목록 (content)
전체 데이터 개수 (totalElements)
전체 페이지 수 (totalPages)
현재 페이지 번호
그런데 !!!
limit, offset이 들어간 조회 쿼리로는 전체 개수를 알 수 없다.
목록 조회 쿼리만 있으면 생기는 문제
List<TodoSearchResponse> content = queryFactory
.select(...)
.from(todo)
.where(...)
.orderBy(todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
“현재 페이지 데이터”만 조회
DB에는 총 몇 건이 있는지 모름!!
프론트 입장에서는
그래서 count 쿼리가 필요함
select count(todo.id) from todo ...
페이징 조건 ❌ (limit/offset 없음)
검색 조건만 동일하게 적용
전체 검색 결과 개수만 조회
PageImpl<>(content, pageable, total)
왜 목록 조회랑 쿼리를 분리했을까?
count는 필요한 컬럼이 id 하나뿐
projection / join / orderBy 필요 없음
조회 쿼리 → 데이터 가져오기
count 쿼리 → 개수 계산
왜 fetchOne()을 쓰는가?
.select(todo.id.count())
count()는 결과가 항상 1행
리스트가 아니라 단일 값
fetch() ❌ (List<Long>)
fetchOne() ✅ (Long)
왜 keyword, start, end 조건을 똑같이 넣어야 할까?
목록 조회 결과: 5개
total: 전체 100개 (이러면 안됨)
요약
페이징 처리에서는 현재 페이지의 데이터 조회와
전체 데이터 개수 조회를 분리해야 하며,
이를 위해 QueryDSL에서 count 전용 쿼리를 추가로 작성한다.
// 실제 목록 조회 쿼리 (페이징 대상)
List<TodoSearchResponse> content = queryFactory
// TodoSearchResponse DTO로 바로 매핑하기 위해 Projections.constructor 사용
// 엔티티 전체가 아니라 "필요한 필드만" 조회해서 성능 최적화
.select(Projections.constructor(
TodoSearchResponse.class,
// 일정 제목
todo.title,
// 해당 일정의 담당자 수
// JOIN으로 인해 중복 row가 생길 수 있으므로 countDistinct 사용
manager.id.countDistinct(),
// 해당 일정의 댓글 수
// 댓글도 JOIN으로 중복될 수 있으므로 countDistinct 사용
comment.id.countDistinct()
))
// 기준 테이블은 todo
.from(todo)
// 일정과 담당자 관계 LEFT JOIN
// 담당자가 없는 일정도 검색 결과에 포함시키기 위해 LEFT JOIN
.leftJoin(todo.managers, manager)
// 담당자와 유저 JOIN (닉네임 검색을 위함)
.leftJoin(manager.user, user)
// 일정과 댓글 LEFT JOIN
// 댓글이 없는 일정도 검색 결과에 포함
.leftJoin(todo.comments, comment)
// 검색 조건
.where(
// 제목 키워드 부분 일치 검색
todo.title.contains(keyword),
// 담당자 닉네임 부분 일치 검색
manager.user.nickName.contains(managerNickname),
// 일정 생성일 범위 검색
todo.createdAt.between(start, end)
)
// todo 기준으로 집계해야 하므로 groupBy 필수
// count(), countDistinct()를 사용했기 때문
.groupBy(todo.id)
// 최신 일정이 먼저 나오도록 정렬
.orderBy(todo.createdAt.desc())
// 한 페이지에 가져올 데이터 개수 제한
.limit(pageable.getPageSize())
// 실제 데이터 조회
.fetch();
// 전체 데이터 개수 조회 쿼리 (count 쿼리)
Long count = queryFactory
// 전체 검색 결과 개수를 구하기 위한 count 쿼리
.select(todo.id.count())
// 기준 테이블은 동일하게 todo
.from(todo)
// 목록 조회와 "동일한 검색 조건"을 사용해야 함
// 그래야 페이지 수(totalPages)가 정확해짐
.where(
todo.title.contains(keyword),
todo.createdAt.between(start, end)
)
// count는 항상 단일 결과이므로 fetchOne 사용
.fetchOne();
이 코드에서 꼭 이해해야 할 핵심 포인트
목록 조회 쿼리와 count 쿼리를 분리한 이유
목록 조회: limit, groupBy, join → 데이터 표현용
count 쿼리: 단순한 구조 → 전체 개수 계산용
왜 countDistinct를 쓰는가?
manager.id.countDistinct()
comment.id.countDistinct()
JOIN이 들어가면 하나의 todo가 여러 row로 늘어남
그냥 count() 쓰면 중복 카운트 발생
countDistinct()로 실제 개수만 집계
왜 groupBy(todo.id)가 필요한가?
count / countDistinct는 집계 함수
집계 대상이 todo 기준이므로 groupBy(todo.id) 필수
없으면 SQL 에러 발생
왜 count 쿼리에는 JOIN이 없는가?
전체 개수만 알면 됨
JOIN은 성능 저하만 유발
요약
QueryDSL 페이징에서는
목록 조회 쿼리와 전체 개수(count) 쿼리를 분리하고,
목록 조회는 Projection + groupBy로 최적화하며
count 쿼리는 조건만 동일하게 유지한 채 최대한 단순하게 작성한다.
package org.example.expert.domain.todo.searchRepository;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.example.expert.domain.todo.dto.response.TodoSearchResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.time.LocalDateTime;
import java.util.List;
import static org.example.expert.domain.comment.entity.QComment.comment;
import static org.example.expert.domain.manager.entity.QManager.manager;
import static org.example.expert.domain.todo.entity.QTodo.todo;
import static org.example.expert.domain.user.entity.QUser.user;
@RequiredArgsConstructor
public class SearchRepositoryImpl implements SearchRepository {
private final JPAQueryFactory queryFactory;
@Override
public Page<TodoSearchResponse> search(
String keyword,
String managerNickname,
LocalDateTime start,
LocalDateTime end,
Pageable pageable
) {
List<TodoSearchResponse> content = queryFactory
.select(Projections.constructor(
TodoSearchResponse.class,
todo.title,
manager.id.countDistinct(),
comment.id.countDistinct()
))
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(manager.user, user)
.leftJoin(todo.comments, comment)
.where(
todo.title.contains(keyword),
manager.user.nickName.contains(managerNickname),
todo.createdAt.between(start, end)
)
.groupBy(todo.id)
.orderBy(todo.createdAt.desc())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(todo.id.count())
.from(todo)
.where(
todo.title.contains(keyword),
todo.createdAt.between(start, end)
)
.fetchOne();
return new PageImpl<>(content, pageable, total == null ? 0 : total);
}
“서비스 → 서비스 DI”는 별로인가?(feat.Transactional의 옵션)
보통 사람들이 꺼리는 이유:
❌ 안 좋은 경우
서비스끼리 순환 의존성
한 서비스가 다른 서비스의 비즈니스 로직을 대신 수행
ServiceA -> ServiceB
ServiceB -> ServiceC
ServiceC -> ServiceA ❌
괜찮은 경우는?
핵심 차이
ManagerService는 비즈니스 로직 담당
LogService는 기술적 관심사(로깅) 담당
이 구조가 “맞는 이유”를 한 줄씩 뜯어보면
트랜잭션 경계를 분리하기 위함
@Transactional(REQUIRES_NEW)
메서드 단위 트랜잭션 분리가 필요하고,
클래스 안에서는 불가능
Spring AOP 프록시 특성
@Transactional(propagation = REQUIRES_NEW)
public void saveLog() { ... }
이걸 같은 서비스 클래스에서 호출하면 적용 안 됨
왜냐하면:
Spring의 @Transactional은 프록시 기반
자기 자신 메서드 호출(self-invocation)은 프록시를 안 탐
생각보다 실무에서 흔한 패턴이라고 한다!(feat.튜터님!)
실무 예시들:
OrderService → PaymentLogService
UserService → LoginHistoryService
ReservationService → AuditLogService
그럼 언제 서비스 → 서비스 DI가 안 좋을까?
피해야 할 경우 체크리스트
서로가 서로를 호출한다 (순환 참조)
A 서비스가 B 서비스의 도메인 규칙을 대신 처리
“편해서” 그냥 갖다 쓴 경우
공통 로직이라고 다 서비스로 빼버린 경우
요약
이 구조는 비즈니스 로직과 기술적 관심사를 분리하고,
트랜잭션 전파 옵션(REQUIRES_NEW)을 적용하기 위한 의도적인 서비스 분리로
Spring AOP 특성을 고려한 올바른 설계였다!
요약2
서비스 간 DI는 무조건 나쁜 설계가 아니라,
책임이 명확히 분리되고 트랜잭션/기술적 관심사를 분리하기 위한 경우에는 권장되는 구조다.
특히 @Transactional(REQUIRES_NEW)는 자기 자신 호출로는 동작하지 않기 때문에
별도의 서비스 분리가 필수적이다.
트러블 슈팅(3)
Spring Boot 서버를 AWS EC2 + RDS로 배포하고 Health Check API까지 연결하기
Spring Boot 애플리케이션을 AWS EC2에 배포한다
RDS(MySQL) 와 연동한다
서버가 살아있는지 확인할 수 있는 Health Check API를 만든다
Health API는 누구나 접근 가능해야 한다
배포 과정에서 발생한 문제를 직접 해결하며 AWS 네트워크 / 보안 흐름을 이해한다
처음에는 IntelliJ에서 실행하면 잘 되는데, EC2에 배포한 JAR를 실행하면 다음과 같은 문제가 발생했다.
403 Forbidden
JWT 관련 에러
jar 파일 최신화 오류
DB 연결 실패
Hibernate Dialect 오류
문제 1: EC2연결 후 RDS 연동까지 성공했음에도 health를 연결하였을 때 403 에러가 나는 것을 식별, 코드 수정을 했음에도 불구하고 403에러가 나는 것을 식별
원인
해결
WeakKeyException: key byte array is not secure enough
원인
JWT Secret Key 길이가 256bit 미만
Base64 디코딩을 시도했지만, 실제로는 Base64 문자열이 아니었음
해결
Base64 인코딩된 256bit 이상 키 사용
EC2 환경 변수로 주입
export JWT_KEY=Base64로_인코딩된_충분히_긴_키
jwt:
secret:
key: ${JWT_KEY}
Unable to determine Dialect without JDBC metadata
원인
EC2에 DB 환경 변수가 없었음
Spring Boot가 DB 정보를 아예 못 읽음
확인
env | grep DB
export DB_URL=jdbc:mysql://RDS엔드포인트:3306/mydb
export DB_USERNAME=admin
export DB_PASSWORD=비밀번호
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
[인터넷]
↓ 8080
[EC2 - Spring Boot]
↓ 3306
[RDS - MySQL]
사용한 보안 그룹
EC2 보안 그룹 (launch-wizard-1)
22 (SSH)
8080 (Spring Boot API)
외부에서 서버 접근 허용
RDS 보안 그룹 (rds-ec2-1)
3306 포트
소스: EC2 보안 그룹
IP가 아닌 보안 그룹 기반 연결
./gradlew clean bootJar
scp -i key.pem build/libs/expert-0.0.1-SNAPSHOT.jar ubuntu@EC2_IP:/home/ubuntu
java -jar expert-0.0.1-SNAPSHOT.jar
Tomcat started on port 8080
Started ExpertApplication
http://EC2_PUBLIC_IP:8080/health
OK
AWS 배포에서 가장 중요한 건 코드보다 환경
보안 그룹은 “열어두는 설정”이 아니라 누구에게 열어두는지가 핵심
JWT / DB / 서버는 모두 환경 변수 기반으로 관리해야 안전하다!!
“된다”보다 “왜 되는지 설명할 수 있는 상태”가 중요함....
정리 한 줄
Spring Boot 서버를 AWS EC2에 배포하고,
RDS와 보안 그룹 기반으로 연동한 뒤
인증 없이 접근 가능한 Health Check API를 통해
서버의 Live 상태를 확인할 수 있도록 구성했다.