이래저래 자잘한 기능들을 구현했다.
P_user 테이블과 P_store 테이블 사이에 P_favStore 테이블을 두고 즐겨찾기 추가, 삭제를 구현하였다.
@Entity(name="P_FAV_STORE")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
public class P_favStore extends BaseEntity {
@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
@Column(updatable = false, nullable = false)
private UUID favId;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private P_user user;
@ManyToOne
@JoinColumn(name = "store_id", nullable = false)
private P_store store;
}
@ManyToOne
그렇다면 favStore 테이블과 User 테이블은 어떻게 매핑될까?
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Like> likes;
}
@OneToMany
하지만 나는 User 테이블에 따로 favStore 필드를 설정해주진 않았다.
User 정보를 조회할 때 마다 즐겨찾기한 가게 목록이 필요하지는 않고, 중간 테이블 FavStore을 두고 favStoreRepository를 통해 필요한 데이터를 끌어오는 것이 더 효율적이라고 생각했기 때문이다.
User 엔티티에서는 불필요한 필드 없이 사용자 정보에 집중할 수 있고,
favStoreRepository의 메서드를 이용해 필요할 때만 가게 목록을 조회하기 때문에 메모리 사용을 줄이고 성능을 높일 수 있다.
// StoreService
// 가게 즐겨찾기 추가/삭제
public ResponseDto addFavStore(AddFavStoreRequestDto requestDto, HttpServletRequest req) {
// 사용자 검증
P_user user = userService.validateTokenAndGetUser(req).orElse(null);
if(user == null) {
return new ResponseDto<>(-1, "유효하지 않은 토큰이거나 존재하지 않는 사용자입니다", null);
}
// 가게 검증
P_store store = findById(requestDto.getStoreId()).orElse(null);
if(store == null) {
return new ResponseDto<>(-1, "존재하지 않는 가게입니다", null);
}
// 즐겨찾기 여부 확인
Optional<P_favStore> existingFavStore = favStoreRepository.findByUserAndStore(user, store);
if (existingFavStore.isPresent()) {
// 이미 즐겨찾기에 있다면 삭제
favStoreRepository.delete(existingFavStore.get());
return new ResponseDto<>(1, "즐겨찾기에서 삭제되었습니다", null);
} else {
// 즐겨찾기에 없다면 추가
P_favStore favStore = P_favStore.builder()
.user(user)
.store(store)
.build();
favStoreRepository.save(favStore);
return new ResponseDto<>(1, "즐겨찾기에 추가되었습니다", null);
}
}
즐겨찾기 추가, 취소 로직은 기본적으로 FavStore을 조회해서 이미 있다면 삭제(취소), 없다면 추가하는 방식이다.
가게 목록도 이렇게 조회할 수 있었다.
// StoreService
// 즐겨찾기 가게 목록 조회
public ResponseDto getFavStoreList(HttpServletRequest req) {
// 사용자 검증
P_user user = userService.validateTokenAndGetUser(req).orElse(null);
if(user == null) {
return new ResponseDto<>(-1, "유효하지 않은 토큰이거나 존재하지 않는 사용자입니다", null);
}
// 즐겨찾기 가게 목록 조회
List<P_store> favStores = favStoreRepository.findByUser(user);
List<FavStoreListResponseDto> stores = favStores.stream()
.map(FavStoreListResponseDto::new)
.toList();
return new ResponseDto(1, "즐겨찾기 가게 목록 조회 성공", stores);
}
Coupon은 권한 있는 사람에 의해 발행되고, user 테이블과 owner(소유자), issuer(발행자)로 연결된다.
@Entity(name = "P_COUPON")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
public class P_coupon extends BaseEntity {
@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
@Column(updatable = false, nullable = false)
private UUID id;
@NotBlank
private String name;
@NotNull
@Min(value = 0, message = "가격은 0 이상이어야 합니다.")
private int discountAmount;
@NotNull
private Boolean status;
@Past
private LocalDateTime usedAt;
@NotNull
private LocalDate expiredAt;
@ManyToOne
@JoinColumn(name = "owner_id", nullable = false) // 소유자
private P_user owner;
@ManyToOne
@JoinColumn(name = "issuer_id", nullable = false) // 발행자
private P_user issuer;
@OneToOne
@JoinColumn(name = "order_id", unique = true) // 하나의 주문과만 연결
private P_order order;
}
@ManyToOne 적용: 여러 쿠폰이 같은 issuer, owner를 가질 수 있기 때문에.
Coupon 입장에서 여러 개의 쿠폰이 하나의 사용자와 관계를 맺는 구조
// 쿠폰 발행
public ResponseDto createCoupon(CouponRequestDto couponRequestDto, HttpServletRequest req) {
// 사용자 검증
P_user issuer = userService.validateTokenAndGetUser(req).orElse(null);
if (issuer == null) {
return new ResponseDto<>(-1, "유효하지 않은 토큰이거나 존재하지 않는 사용자입니다", null);
}
// 권한 검증
if(issuer.getRole() == UserRoleEnum.CUSTOMER || issuer.getRole() == UserRoleEnum.OWNER) {
return new ResponseDto<>(-1, "권한이 없는 사용자입니다", null);
}
// 수신자 검증
P_user owner = userService.getUserById(couponRequestDto.getOwner());
if (owner == null) {
return new ResponseDto<>(-1, "유효하지 않은 수신자입니다", null);
}
// 만료 기한(6개월)
LocalDate expiredDate = LocalDate.now().plusMonths(6);
// 쿠폰 정보 생성
int discountAmount = couponRequestDto.getDiscountAmount();
String name = couponRequestDto.getName();
int quantity = couponRequestDto.getQuantity();
for (int i = 0; i < quantity; i++) {
P_coupon coupon = P_coupon.builder()
.discountAmount(discountAmount)
.name(name)
.expiredAt(expiredDate)
.issuer(issuer)
.owner(owner)
.status(true)
.build();
// 쿠폰을 DB에 저장
couponRepository.save(coupon);
}
return new ResponseDto<>(1, "쿠폰 생성 완료", null);
}
기본적인 검증을 거쳐 요청에 따라 쿠폰을 발행하는 기능이다.
for문을 이용해 쿠폰을 생성, 저장했는데 지금보니 이왕 저장하는거 한번에 저장하게끔 하는 게 더 나을 것 같다.
// 쿠폰 리스트 생성
List<P_coupon> coupons = new ArrayList<>();
for (int i = 0; i < quantity; i++) {
P_coupon coupon = P_coupon.builder()
.discountAmount(discountAmount)
.name(name)
.expiredAt(expiredDate)
.issuer(issuer)
.owner(owner)
.status(true)
.build();
coupons.add(coupon);
}
// 쿠폰 리스트를 한 번에 저장
couponRepository.saveAll(coupons);
이렇게 하면 save 호출을 줄여서 성능에 더 도움이 되지 않을까 한다.
다만 중간에 에러가 발생한다면 저장하려던 모든 쿠폰이 날아간다는 문제가 발생할 수도 있다.
// 쿠폰 리스트 생성
List<P_coupon> coupons = new ArrayList<>();
for (int i = 0; i < quantity; i++) {
P_coupon coupon = P_coupon.builder()
.discountAmount(discountAmount)
.name(name)
.expiredAt(expiredDate)
.issuer(issuer)
.owner(owner)
.status(true)
.build();
coupons.add(coupon); // 리스트에 쿠폰 추가
}
try {
couponRepository.saveAll(coupons);
} catch (Exception e) {
// 에러 로그 기록 및 실패 응답 반환
return new ResponseDto<>(-1, "일부 쿠폰 저장 실패: " + e.getMessage(), null);
}
이렇게 하면 실패한 항목을 기록해서 따로 처리할 수 있을 것 같다.
현재는 쿠키에 Authorization 이라는 이름으로 넣어서 사용자 검증을 하고 있다.
모르고 무작정 구현하다 보니 쿠키에 대해서 제대로 이해하지 못하고 쿠키에도 Bearer를 갖다붙이고.. 공백도 냅다 넣어놓곤 유효하지 않은 쿠키 에러가 발생하고...
일단은 굴러가게 만들어 뒀지만 좀 더 학습하면서 업그레이드 시킬 필요가 있다.
필터: 요청이 컨트롤러에 도달하기 전이나 응답이 사용자에게 전달되기 전에 사전, 사후 처리를 할 수 있는 기능 제공
로깅용 필터 추가
모든 HTTP 요청과 응답의 전후 처리를 로그로 기록하기 위한 필터
@Slf4j(topic = "LoggingFilter")
@Component
@Order(1) // 필터 순서
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
@Slf4j(topic = "LoggingFilter")
@Component: 해당 클래스가 Spring bean으로 자동 등록되게 함
@Order(1): 필터의 실행 순서 지정. 작을수록 우선 실행됨
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
-> 요청 객체를 HttpServletRequest 타입으로 캐스팅하여 HTTP 요청의 세부 정보를 사용할 수 있게 함
String url = httpServletRequest.getRequestURI();
-> 요청 URI를 가져옴
ex)"http://localhost:8080/api/users" -> "/api/users" 반환
log.info(url)
-> 요청 URI를 로그로 기록하여 어떤 URL로 요청이 들어왔는지 확인할 수 있음
chain.doFilter(request, response);
-> doFilter 메서드를 호출하여 요청을 다음 필터나 최종 목적지(서블릿 또는 컨트롤러)로 전달
log.info("비즈니스 로직 완료");
-> 요청이 최종 리소스에서 처리된 후 다시 LoggingFilter로 돌아와 찍히는 로그
인증용 필터 추가
@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
public class AuthFilter implements Filter {
private final UserService userService;
private final com.sparta.temueats.user.Util.JwtUtil jwtUtil;
public AuthFilter(UserService userService, com.sparta.temueats.user.Util.JwtUtil jwtUtil) {
this.userService = userService;
this.jwtUtil = jwtUtil;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) &&
(url.equals("/api/members/auth/signup") || url.equals("/api/members/auth/login"))
) {
// 회원가입, 로그인 요청 제외
chain.doFilter(request, response);
} else {
// 토큰 확인
String tokenValue = jwtUtil.getTokenFromCookies(httpServletRequest);
if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
// 사용자 ID 추출
Claims claims = jwtUtil.getUserInfoFromToken(token);
Long userId = Long.parseLong(claims.getSubject());
// 사용자 조회
P_user user = userService.findUserById(userId);
if (user == null) {
throw new IllegalArgumentException("Not Found User");
}
request.setAttribute("user", user);
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
}
이전에는 각자 맡은 부분에서 토큰을 검증하고, 정보를 조회하도록 진행했다. 하지만 필터를 거쳐 요청을 처리하게 되면서 인증, 인가 로직과 DB에 직접 접근하는 로직을 분리할 수 있게 되었다.
특히 내가 맡은 User 에서는 다음과 같은 메서드로 인증 후 사용자 정보를 반환하도록 구현해두었었는데, 이 부분을 분리하는 리팩토링을 진행해야 할 것 같다.
AuthFilter는 인증과 사용자 설정에 집중하고,UserService는 사용자 데이터와 관련된 작업만 처리하도록 말이다.
// UserService
public Optional<P_user> validateTokenAndGetUser(HttpServletRequest req) {
String token = jwtUtil.getTokenFromCookies(req);
token = jwtUtil.substringToken(token);
// 토큰 값 검증
if (!jwtUtil.validateToken(token)) {
logger.error("유효하지 않은 토큰");
return Optional.empty();
}
// 토큰이 유효하지 않으면 Optional.empty() 반환
if (!StringUtils.hasText(token) || !jwtUtil.validateToken(token)) {
logger.error("유효하지 않은 토큰");
return Optional.empty();
}
try {
// 사용자 ID 추출
Claims claims = jwtUtil.getUserInfoFromToken(token);
Long userId = Long.parseLong(claims.getSubject());
// 사용자 조회 및 반환
return userRepository.findById(userId);
} catch (NumberFormatException e) {
logger.error("잘못된 사용자 ID", e);
return Optional.empty();
} catch (Exception e) {
logger.error("사용자 검증 중 오류 발생", e);
return Optional.empty();
}
}
근데 막상 필터 다 만들고 나니까 필터가 적용되지 않는 문제를 발견했다.
왜일까..?
해결을 위해 해 본 것들
yml 파일에 로깅 설정 추가해서 INFO 레벨 이상의 로그가 뜨게 함
-> 여전히 안됨
필터 수동 등록하기
-> 원래 @Component 어노테이션 만으로도 스프링 컨텍스트에 등록되어 필터가 인식되어야 하는데 그게 안된다면 수동으로 등록하는 Configuration 클래스를 만들어 등록하는 방법이 있다.
하지만 안되는 이유를 찾고싶은거지 어거지로 이 필터를 적용시키고 싶은 마음은..
다른 방법 찾기
-> Spring Security를 이용하면 인증 및 권한 관리 기능을 Spring Security의 기본 기능으로 구현할 수 있다고 한다.
채택!
configure(HttpSecurity http) 메서드를 오버라이드하여 요청별로 인증, 권한 설정, 로그인 페이지 경로 지정, 세션 관리 등 다양한 보안 설정을 커스터마이징 가능@EnableWebSecurity와 함께 WebSecurityConfigurerAdapter를 상속하여 configure(HttpSecurity http)를 오버라이드하여 보안 설정을 정의한다Security Configuration 설정
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers("/api/members/auth/signup", "/api/members/auth/login").permitAll() // 회원가입, 로그인 요청 제외
.anyRequest().authenticated() // 그 외 모든 요청에 대해 인증처리
);
// 로그인 사용
http.formLogin(Customizer.withDefaults());
return http.build();
}
}
@Configuration: 해당 클래스를 Spring의 설정 클래스임을 명시@EnableWebSecurity: Spring Security를 활성화해 보안 기능을 적용SecurityFilterChain: Spring Security에서 모든 HTTP 요청이 거치는 보안 필터 체인. 각 요청에 대해 보안 규칙을 설정http.csrf((csrf) -> csrf.disable());: CSRF 보호 기능을 비활성화. 일반적으로 REST API에서는 CSRF 토큰이 필요하지 않음authorizeHttpRequests: 각 요청에 대해 접근 권한 설정requestMatchers().permitAll(): 매칭되는 요청에 대해 접근 해제http.formLogin(Customizer.withDefaults());: Spring Security가 제공하는 기본 로그인 페이지를 사용