트러블 슈팅(제출용)

제이 용·2025년 12월 30일

트러블 슈팅(1)


날짜 검색에서 LocalDate vs LocalDateTime

왜 LocalDate로 입력을 받았을까?

  • 사용자는 날짜만 신경 쓰지, 시:분:초까지 직접 입력할 필요가 없음

  • UI/UX 관점에서 날짜 입력이 훨씬 직관적임


그런데 응답은 왜 LocalDateTime일까?

  • DB에는 생성 시점이 LocalDateTime으로 저장됨

  • 응답 DTO는 정확한 생성 시각을 보여주는 게 맞음

입력은 단순하게(LocalDate), 출력은 정확하게(LocalDateTime)

역할에 맞게 타입을 분리하는 것이 설계적으로 더 좋다.


@DateTimeFormat + atStartOfDay / LocalTime.MAX를 쓰는 이유

  • @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
  • LocalDate startDate;

왜 이런 변환 로직이 필요한가?

  • 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);
}

null 반환인데 왜 검색 조건이 적용될까?

  • QueryDSL의 where()는 null 조건을 자동으로 무시한다.

  • 조건이 있으면 AND로 결합하고, 없으면 제외한다고 한다.

query.where(
    titleContains(keyword),
    createdAtBetween(start, end)
);

null-safe 동적 쿼리를 위한 의도된 설계

  • 장점

    • if-else 지옥 방지

    • 조건 조합 폭발 방지

    • 가독성, 유지보수성 ↑


fetchOne() vs fetch() 차이 (강의내용 중복)

왜 fetchOne을 함부로 쓰면 안 될까?

fetchOne()
  • 결과가 1개일 거라 확신할 때만

  • 2개 이상이면 예외 발생

fetch()
  • 리스트 조회

  • 결과 개수와 무관

“단건 조회가 보장될 때만 fetchOne”


Optional을 쓰는 이유 & orElseThrow 자동완성

Optional을 왜 쓰는가?

  • null 반환 → 실수로 NPE 발생

  • Optional → “없을 수도 있음”을 타입으로 강제

userRepository.findById(id)
    .orElseThrow(...)

Optional 안 쓰면 orElseThrow 자동완성이 안 뜬 이유?

  • orElseThrow()는 Optional 전용 메서드

  • 엔티티 자체에는 존재하지 않음

Optional은 단순 편의가 아니라

null 가능성을 코드 레벨에서 드러내는 장치


N+1 문제가 발생한 이유와 해결 방식

왜 N+1이 발생했을까?

  • 연관 엔티티가 LAZY 로딩

  • 반복 접근 시 쿼리가 추가로 실행됨

해결 방법은?

fetch join
  • QueryDSL에서 join + fetch

  • 필요한 필드만 Projections로 조회

“조회용 쿼리는 엔티티 조회가 아니라 DTO 조회가 더 적합한 경우가 많다”


SecurityConfig에 permitAll이 있는데 JWT 필터에서 또 체크하는 이유

SecurityConfig면 끝 아닌가?

.anyRequest().authenticated()
  • 이건 인가(Authorization) 단계

  • JwtFilter는 그보다 앞단의 인증(Authentication) 필터

그래서 필터에서도 분기 처리가 필요한 이유

  • 필터는 무조건 실행됨

  • /auth/** 요청에서도 JWT 검사하면 오류 발생

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    return request.getRequestURI().startsWith("/auth");
}

필터 책임: 인증 정보 생성

SecurityConfig 책임: 접근 허용/차단


UsernamePasswordAuthenticationFilter 앞에 JWT 필터를 두는 이유

이 필터는 뭐 하는 놈인가?

  • 폼 로그인 기반

  • ID/PW 인증 처리

왜 JWT 필터가 앞에 있어야 할까?

  • JWT는 이미 인증된 사용자

  • UsernamePasswordAuthenticationFilter까지 갈 필요 없음

JWT 인증이 먼저 끝나야

SecurityContext가 채워지고

이후 인가 로직이 정상 동작


정리

“Spring / JPA / Security에서의 설정과 패턴은

‘될 때까지 맞추는 코드’가 아니라

왜 이 레이어에서 이 책임을 가지는지를 이해하는 게 중요하다!!!”


트러블 슈팅(2)

궁금궁금(feat.QueryDsl)

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 쿼리 → 개수 계산

    • JPA & QueryDSL에서 권장되는 패턴

왜 fetchOne()을 쓰는가?

.select(todo.id.count())
  • count()는 결과가 항상 1행

  • 리스트가 아니라 단일 값

    • 그래서:
fetch()(List<Long>)

fetchOne()(Long)

왜 keyword, start, end 조건을 똑같이 넣어야 할까?

“검색 조건이 다르면 total이 의미 없어짐”

  • 목록 조회 결과: 5개

  • total: 전체 100개 (이러면 안됨)

목록 쿼리와 count 쿼리는 조건이 반드시 동일해야 함!!


요약

페이징 처리에서는 현재 페이지의 데이터 조회와

전체 데이터 개수 조회를 분리해야 하며,

이를 위해 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 쿼리: 단순한 구조 → 전체 개수 계산용

페이징에서 정확한 total 값을 얻기 위한 필수 구조


왜 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은 성능 저하만 유발

의도적으로 단순한 count 쿼리 작성 = 성능 최적화

요약

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는 기술적 관심사(로깅) 담당

즉, 역할이 명확하게 분리되어 있음

이 구조가 “맞는 이유”를 한 줄씩 뜯어보면

책임 분리가 명확하다 (SRP)

  • 서비스 : 책임
  • ManagerService : 매니저 등록 비즈니스 규칙
  • LogService : 요청 기록을 DB에 안전하게 남김

ManagerService가 로그 테이블 직접 만지면 책임이 섞임


트랜잭션 경계를 분리하기 위함

  • 핵심 목적은 이거 👇

“매니저 등록이 실패해도 로그는 반드시 저장”

  • 이건
@Transactional(REQUIRES_NEW)
  • 메서드 단위 트랜잭션 분리가 필요하고,

  • 클래스 안에서는 불가능

  • Spring AOP 프록시 특성

그래서 별도 서비스로 분리 + DI 가 올바른 구조.

중요한 포인트 (많이 놓친다고함.)

@Transactional(propagation = REQUIRES_NEW)
public void saveLog() { ... }
  • 이걸 같은 서비스 클래스에서 호출하면 적용 안 됨

  • 왜냐하면:

    • Spring의 @Transactional은 프록시 기반

    • 자기 자신 메서드 호출(self-invocation)은 프록시를 안 탐

그래서 무조건 다른 Bean이어야 함

LogService 분리는 필수


생각보다 실무에서 흔한 패턴이라고 한다!(feat.튜터님!)

실무 예시들:

OrderServicePaymentLogService

UserServiceLoginHistoryService

ReservationServiceAuditLogService

“비즈니스 서비스 → 로그/이력 서비스”

매우 정상적인 구조


그럼 언제 서비스 → 서비스 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 오류

이 과정에서 “로컬과 서버는 완전히 다른 환경”이라는 걸 명확히 인식하게 됐다.


health 403에러 문제 해결 과정

  • 문제 1: EC2연결 후 RDS 연동까지 성공했음에도 health를 연결하였을 때 403 에러가 나는 것을 식별, 코드 수정을 했음에도 불구하고 403에러가 나는 것을 식별

  • 원인

    • 어플리케이션 내 코드에 /health 경로를 필터에 통과시키도록 설정해주지 않았음.
    • 코드를 수정한 후에 수정된 jar파일을 EC2에 재빌딩을 해주지 않았음.
  • 해결

    • 애플리케이션 코드에 JwtFilter와 SecurityConfig에 /health 경로를 예외처리 해주었음.
    • 수정된 코드를 다시 jar파일로 빌드 후 EC2에 다시 재빌딩 해주었더니 정상 작동 되었음.

JWT 관련 문제 해결 과정

  • 문제 2: EC2에서 서버가 아예 안 뜸
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}

JPA / RDS 연결 문제 해결

  • 문제 3: Hibernate Dialect 오류
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}

EC2 ↔ RDS 네트워크 & 보안 그룹 이해

  • 핵심 구조
[인터넷]
   ↓ 8080
[EC2 - Spring Boot]
   ↓ 3306
[RDS - MySQL]

사용한 보안 그룹

  • EC2 보안 그룹 (launch-wizard-1)

    • 22 (SSH)

    • 8080 (Spring Boot API)

    • 외부에서 서버 접근 허용

  • RDS 보안 그룹 (rds-ec2-1)

    • 3306 포트

    • 소스: EC2 보안 그룹

    • IP가 아닌 보안 그룹 기반 연결

이 설정 덕분에 EC2 → RDS 연결 성공


JAR 재빌드 & 배포 과정

  • 로컬에서
./gradlew clean bootJar
  • EC2로 전송
scp -i key.pem build/libs/expert-0.0.1-SNAPSHOT.jar ubuntu@EC2_IP:/home/ubuntu
  • EC2 실행
java -jar expert-0.0.1-SNAPSHOT.jar
  • 정상 실행 로그
Tomcat started on port 8080
Started ExpertApplication

Health Check 최종 확인

  • 브라우저에서 접속:
http://EC2_PUBLIC_IP:8080/health
  • 응답:
OK
  • 서버 실행 확인
  • 인증 없이 접근 가능
  • 과제 요구사항 충족

얻은 것

  • AWS 배포에서 가장 중요한 건 코드보다 환경

  • 보안 그룹은 “열어두는 설정”이 아니라 누구에게 열어두는지가 핵심

  • JWT / DB / 서버는 모두 환경 변수 기반으로 관리해야 안전하다!!

  • “된다”보다 “왜 되는지 설명할 수 있는 상태”가 중요함....

정리 한 줄

Spring Boot 서버를 AWS EC2에 배포하고,

RDS와 보안 그룹 기반으로 연동한 뒤

인증 없이 접근 가능한 Health Check API를 통해

서버의 Live 상태를 확인할 수 있도록 구성했다.


0개의 댓글