🐣 프로젝트 개요
- 🛵 DeliSpring : Spring Boot 기반의 배달 서비스 백엔드 프로젝트입니다.
- 기간 : 2025.04.22 ~ 2025.04.29
- 인원 : 1인 개발 (개인 프로젝트)
- 목표
- JPA를 이용한 DB 연동과 CRUD 구현, AOP 기반 권한 검증 기능을 적용하였습니다.
- JWT를 이용해 로그인, 인증, 인가 기능을 구현하고, Access/Refresh Token 관리를 설계하였습니다.
- JUnit과 Mockito로 예외 상황을 포함한 테스트 코드를 작성하여 서비스의 안정성과 신뢰성을 높였습니다
- 기술 스택
- Backend : Java, Spring Boot 3, Spring Security, JWT
- DB : MySQL
- ORM : JPA, Hibernate
- AOP : Spring AOP
- TEST : JUnit5, Mockito, SpringBootTest
- API Development Tool : Postman
💡 주요 서비스 소개
1. 회원가입 / 로그인 / 탈퇴 / 정보 수정
- 이메일 기반 회원가입 및 JWT 로그인 구현
- 비밀번호는 Bcrypt로 암호화하여 보안 강화
- 사용자 권한을 일반 사용자(USER)와 사장님(OWNER)으로 분리하여 서비스 제공
- 탈퇴 시 비밀번호 확인 후, Soft Delete 방식으로 처리
2. 가게 관리 (사장님만 접근 가능)
- 사장님은 가게를 최대 3개까지 등록 가능
- 가게는 오픈/마감 시간, 최소 주문 금액 등의 정보를 포함
- 폐업 시 상태만 변경되며, 이후 다시 가게 등록 가능
3. 메뉴 관리
- 메뉴 생성, 수정, 삭제는 사장님만 가능
- 메뉴 삭제는 실제 데이터를 삭제하는 것이 아니라, Soft Delete 방식으로 처리
- 메뉴는 가게 단건 조회 시에만 함께 조회됨
4. 주문
- 고객은 메뉴를 선택하여 주문당 메뉴 하나씩만 주문 가능
- 주문 시 최소 주문 금액 및 영업 시간 체크
- 사장님은 주문 상태를 순차적으로 변경 가능 (`REQUESTED` → `ACCEPTED` → `COOKING` → `DELIVERING` → `COMPLETED`)
- 주문 상태 변경 시 OrderLog 엔티티에 자동으로 로그 기록 (AOP 적용)
5. 리뷰
- 고객은 배달 완료된 주문에 대해서만 리뷰 작성 가능
- 리뷰는 별점(1~5점) 및 코멘트로 구성
- 리뷰는 가게 기준으로 최신순 정렬 및 별점 필터링 조회 가능
- 리뷰 작성시 별점 1~5점 가능하지만, 조회는 3~5점만 필터링 가능
💫 주요 개발 포인트
1. 인증 및 권한 관리 (JWT)
- Spring Security와 JWT 기반으로 회원 인증 및 인가(Authorization)를 구현했습니다.
- 모든 API 요청은 `JwtAuthenticationFilter`를 통해 인증 과정을 처리합니다.
- `UserDetails`를 커스터마이징하여 인증된 사용자 정보에서 `userId`를 직접 꺼낼 수 있도록 개선했으며,
이로 인해 서비스 계층에서 매번 Repository를 조회할 필요 없이 효율적으로 사용자 정보를 활용할 수 있습니다. - AccessToken/RefreshToken 발급 및 재발급 기능을 통해 토큰 기반 세션 관리를 설계했습니다.
2. 공통 로직 분리 (AOP)
- 반복되는 권한 체크, 주문 상태 변경 기록의 로직을 `@Aspect` 기반 AOP로 분리했습니다.
- 사용자 권한(USER, OWNER)에 따라 API 접근을 분기 처리하여 보안을 강화했습니다.
3. 주문 이력 관리
- 주문 생성 및 상태 변경 시마다 `OrderLog` 엔티티를 통해 변경 이력을 저장하였습니다.
- 추후에 주문의 흐름만 히스토리 기반으로 추적할 수 있어서 관리에 용이합니다.
4. 예외 처리의 일관성
- 인증 실패, 권한 오류, 데이터 유효성 실패 등 다양한 예외 상황을 일관된 포맷으로 처리하였습니다.
5. 테스트 코드 작성 (TDD 지향)
- `JUnit5`와 `Mockito`를 활용하여 단위 테스트를 작성했습니다.
🚀 트러블슈팅
1. .env 환경설정 문제

🔍 원인
`.env` 파일을 통해 깃에 올리기 민감한 정보들을 보호하고자
`application.properties` 파일에서 DB 연결 정보를 `${DB_URL}` 형태로 적어서 환경변수 설정함
하지만 Spring Boot는 `.env` 파일을 자동으로 읽진 않기 때문에,
`${DB_URL}`이 실제 값으로 치환되지 않아서 DB 연결 실패함
🤗 해결 과정
`applicatoin.properties` 상단에 아래 코드 한줄 추가함
spring.config.import=optional:file:.env[.properties]
🪬결과

해당 설정을 통해 Spring Boot에서도 `.env` 파일의 환경 변수를 인식하게 되어 DB가 정상적으로 연결됨
JS는 해당 코드 없이도 `.env`가 정상 작동했는데, Spring Boot에서는 추가 설정이 필요한게 인상적이었음
2. 리팩토링 : 커스텀필터로 UserDetails를 사용하여 db를 2번 들리던 구조 개선
🔍 원인
기존에 `UserDetails.getUsername()` 메서드에서 `userId(String)`을 꺼낸 후, `repository.findById()`로 한번 더 유저를 조회했음
이미 토큰 안에 userId가 있는데도, DB에서 또 유저 정보를 조회하기 위해 쿼리를 날리는 불필요한 DB I/O 발생함
🤗 해결 과정
- `CustomUserDetails` 클래스를 새로 정의하여 userId를 String이 아닌 Long 타입으로 직접 보관하도록 구현
- `JwtProvider.getAuthentication()` 에서 토큰 파싱 시점에 `userId`, `email`, `authorities`를 이용한 `CustomUserDetails` 객체를 생성하여 등록
- 인증이 완료된 이후에는 `SecurityContextHolder`를 통해 해당 정보를 바로 꺼내어 사용
public class CustomUserDetails implements UserDetails {
private final Long userId;
private final String email;
private final Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(Long userId, String email, Collection<? extends GrantedAuthority> authorities) {
this.userId = userId;
this.email = email;
this.authorities = authorities;
}
public Long getUserId() {
return userId;
}
}
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
Long userId = Long.valueOf(claims.getSubject());
String email = userService.getEmailById(userId);
List<GrantedAuthority> authorities = ...;
UserDetails principal = new CustomUserDetails(userId, email, authorities); // 이렇게 추가!
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
🪬결과
@PostMapping("/logout")
public ResponseEntity<Void> logout(@AuthenticationPrincipal CustomUserDetails userDetails) {
// 리팩토링 전
Long userId = Long.parseLong(userDetails.getUsername());
User user = userRepository.findById(userId);
// 리팩토링 후
Long userId = userDetails.getUserId(); // repository 없이 바로 userId 사용
...
}
- 최초 인증 시 커스텀 필터(`JwtProvider`)를 통해 유저 정보가 `CustomUserDetails`로 세팅됨
- 이후 컨트롤러에서는 `@AuthenticationPrincipal`을 통해 별도 DB 조회 없이 바로 `userId`를 꺼내어 사용 가능해짐
- 로그아웃처럼 유저 인증 정보가 필요한 상황에서 DB 접근을 줄이고 성능을 개선함
3. BUILD 컴파일에러

🔍 원인
`OrderServiceImpl`에서 `Order.builder()`를 사용하려고 Order 엔티티에 `@Builder` 어노테이션 추가하였으나 컴파일 에러 발생
`@Builder`는 내부적으로 모든 필드를 받는 생성자(AllArgsConstructor)를 필요로 하는데,
`@NoArgsConstructor`는 JPA에 필요한 정도의 파라미터가 없는 기본 생성자만 생성되므로,
`@Builder`가 필요한 생성자를 찾지 못하여 에러 발생
🤗 해결 과정
`@AllArgsConstructor` 어노테이션을 추가하여, 모든 필드를 파라미터로 받는 생성자를 생성함
import lombok.AllArgsConstructor; // 추가!
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor // 추가!
@Builder
public class Order extends BaseEntity {
...
🪬결과
- @AllArgsConstructor가 추가되면서, @Builder가 필요한 모든 필드를 받는 생성자를 인식할 수 있게 됨
- Order.builder()를 통해 정상적으로 객체가 생성되고, 컴파일 에러가 해결됨
💫 빌더를 사용하려면 `@AllArgsConstructor`가 꼭 필요하다...
💫 @NoArgsConstructor는 JPA가 요구하는 기본 생성자... @AllArgsConstructor는 빌더가 요구하는 생성자...
4. postman에서 리프레시 토큰 재발급 실패
문제상황 1) 500 Internal Server Error

🔍 원인
`JwtAuthenticationFilter`는 들어오는 요청의 JWT 토큰은 검증해서 인증정보를 `SecurityContext`에 세팅하는 역할.
그런데 `/auth/reissue`가 `JwtAuthenticationFilter`의 WHITELIST에 포함되어 있어서
필터가 토큰 검증도 하지 않고, `SecurityContext`에 인증 정보도 넣지 않고 통과시켜줌 (`chain.doFilter()`로 그냥 넘김)
하지만 `AuthService.reissue()` 메서드는 전달받은 refreshToken을 디코딩하여 사용자 검증을 시도하는데
Claims claims = jwtProvider.getClaims(refreshToken); // 디코딩
claims.get("auth") // <- 이게 없으면 예외!
이때 RefreshToken에는 auth 클레임이 없기 때문에 `JwtProvider.getAuthentication()` 에서 예외 발생함
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
권한 정보가 없는 토큰을 디코딩하여 `claims.get("auth")`를 호출하였는데 `null`이라 예외 발생 한 것.
🤗 해결 과정
- 필터를 거치도록, `JwtAuthenticationFilter`의 WHITELIST에서 `/auth/reissue`를 제거
- accessToken이 만료된 상황에서도 접근해야 하므로, `SecurityConfig`의 `.permitAll()` 설정은 그대로 유지✨
- `JwtAuthenticationFilter` 내부에서 refreshToken인 경우 인증 객체를 만들지 않도록, 예외 처리 추가
if ("ref".equals(claims.get("add"))) {
chain.doFilter(request, response); // refreshToken은 인증 처리 없이 통과
return;
}
🪬결과
500 에러는 해결되었지만, 로그인 상태에서도 403 Forbidden 에러 발생.
문제상황 2) 403 Forbidden

Postman에서 Bearer Token을 정상적으로 넣고 /auth/reissue 요청했음에도 403 발생
🔍 원인
앞선 문제상황1에서 JwtAuthenticationFilter는 refreshToken 여부를 확인하고,
refreshToken일 경우 인증 객체 생성을 건너뛰도록 처리하였음
그러나 Spring Security에서는 SecurityContext에 Authentication이 없으므로 인가 처리 실패함
🤗 해결 과정
if (token != null && jwtTokenProvider.validateToken(token)) {
Claims claims = jwtTokenProvider.getClaims(token);
if ("ref".equals(claims.get("add"))) {
// RefreshToken이면 인증 처리하지 않고 통과
chain.doFilter(request, response);
return;
}
// AccessToken인 경우 인증 객체 생성
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
refreshToken 식별용 클레임("add" 또는 "ref")을 확인하여
refreshToken이면 SecurityContext에 인증 객체 세팅을 생략하고 필터 패스함
따라서 인증 안 해도 되는 reissue 로직에서 403 발생 방지하게 됨
🪬결과
- refreshToken 요청(/auth/reissue)은 권한 없이도 정상 작동
- accessToken은 여전히 SecurityContext에 인증 객체가 세팅되어 권한 필요한 요청도 가능

5. order 엔티티 이름이 인식되지 않는 SQL syntax error (Postman 요청 에러)

🔍 원인

2025-04-30T03:31:37.604+09:00 ERROR 23896 --- [DeliSpring] [nio-8080-exec-3] o.h.engine.jdbc.spi.SqlExceptionHelper : You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'order (created_at,menu_id,price,status,store_id,updated_at,user_id) values ('202' at line 1
order라는 테이블명이 MySQL 예약어(reserved keyword)이기 때문에 syntax 에러 발생
🤗 해결 과정
- Order 엔티티 클래스 위에 `@Table(name = "orders")`을 추가하여, 테이블 이름을 "orders"로 명시적으로 지정.
- 기존에 DB에 있는 `order`테이블은 삭제
🪬결과

SQL 예약어와 다른 이름의 테이블명을 사용하여 에러 해결
Postman에서도 요청이 정상적으로 처리됨
💫 order, user, index 등은 MySQL에서 예약된 키워드로, SQL 쿼리에서 그대로 사용 시 문법 에러가 발생함...
🔖 회고
이번 프로젝트에서는 혼자서 JWT를 적용하고 AOP 기반 권한 검증을 구현하는 등, 전반적인 백엔드 구조를 익히는 데 중점을 두었습니다. 그리고 테스트 코드를 사용하는 것에 초점을 두고 진행하려고 노력했습니다. 혼자 진행한만큼 전체 흐름을 직접 설계하고 점검해볼 수 있어서 Spring Boot의 동작구조를 익히는데에 많은 도움이 되었습니다. 다만 개인으로 진행하다보니 모든 코드에 대하여 테스트코드를 작성하지 못한 점은 아쉬웠습니다. 또한 구현 당시에는 문제 없어 보였던 로직들도 Postman으로 테스트하면서 여러 에러를 마주하였습니다. 눈으로 로직을 보기에는 문제가 없어보여도 실제로 서버를 다양한 상황에서 동작시켜보면 예상치 못한 에러를 발견할 수 있다는 점을 체감할 수 있었습니다.
'👩🏻💻Project' 카테고리의 다른 글
| [JWT] 커스텀 필터와 Resolver 기반의 인증 구조를 Spring Security로 리팩토링하기! (0) | 2025.05.11 |
|---|---|
| [JPA] JPQL을 QueryDSL로 리팩토링하기! (0) | 2025.05.10 |
| [JPA] ⏰타이머 과제 (1) | 2025.04.21 |
| [Java/Spring] postory 프로젝트 트러블슈팅🚀 및 회고 (1) | 2025.04.11 |
| 🗓️ JPA를 활용한 일정 관리 앱 만들기 (0) | 2025.04.04 |