[TIL] 241112 즐겨찾기 기능 구현, Spring Security

MONA·2024년 11월 12일

나혼공

목록 보기
27/92

이래저래 자잘한 기능들을 구현했다.

즐겨찾기 기능 구현

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

  • JPA에서 엔티티 간의 다대일 관계를 정의할 때 사용하는 어노테이션
  • 다대일 관계: 여러 엔티티가 하나의 엔티티와 연관될 때 사용
    ex) 여러 개의 주문(order)이 하나의 사용자(user)와 연관되는 상황

그렇다면 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

  • JPA에서 일대다 관계를 정의할 때 사용하는 어노테이션
  • 일대다 관계: 하나의 엔티티가 여러 다른 엔티티와 연관될 때 사용
    ex) 한 명의 사용자(User)가 여러 개의 주문(order)을 가질 수 있는 상황
  • 외래 키는 기본적으로 관계의 반대쪽(ManyToOne이 설정된 쪽)에 설정됨

하지만 나는 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")

    • Lombok의 어노테이션. log라는 이름의 Logger를 자동 생성.
    • 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에 있는 인증 후 User 객체를 반환하는 메서드
// 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();
        }
    }
  • request.setAttribute("user", user)
    -> 조회한 사용자 정보를 요청의 속성에 저장하여 이후 요청에서 사용자 정보를 접근할 수 있도록 설정
    ex) P_user user = (P_user) request.getAttribute("user");

근데 막상 필터 다 만들고 나니까 필터가 적용되지 않는 문제를 발견했다.
왜일까..?

해결을 위해 해 본 것들

  1. yml 파일에 로깅 설정 추가해서 INFO 레벨 이상의 로그가 뜨게 함
    -> 여전히 안됨

  2. 필터 수동 등록하기
    -> 원래 @Component 어노테이션 만으로도 스프링 컨텍스트에 등록되어 필터가 인식되어야 하는데 그게 안된다면 수동으로 등록하는 Configuration 클래스를 만들어 등록하는 방법이 있다.
    하지만 안되는 이유를 찾고싶은거지 어거지로 이 필터를 적용시키고 싶은 마음은..

  3. 다른 방법 찾기
    -> Spring Security를 이용하면 인증 및 권한 관리 기능을 Spring Security의 기본 기능으로 구현할 수 있다고 한다.
    채택!


Spring Security

  • 웹 애플리케이션 및 REST API의 보안을 책임지는 Spring 프레임워크의 하위 프로젝트
  • 인증과 권한 관리를 쉽게 설정할 수 있도록 지원하는 강력한 보안 프레임워크

주요 기능

  1. 인증 (Authentication)
  • 애플리케이션에 접근하는 사용자가 누구인지 확인하는 과정
  • 다양한 인증 방식을 제공함. 가장 일반적인 방식은 폼 로그인과 Basic Auth 방식
  • JWT, OAuth2 등 외부 인증 방식과 통합할 수 있음
  • 커스텀 인증 필터를 작성하여 다른 인증 방식을 구현할 수도 있음
  1. 권한 부여 (Authorization)
  • 인증된 사용자가 애플리케이션에서 어떤 자원에 접근할 수 있는지를 결정
  • 접근 제어 리스트 (ACL), URL 패턴 매칭, 메서드 레벨 보안(@PreAuthorize, @Secured 등)과 같은 다양한 권한 부여 방식을 지원
  1. 보안 설정의 유연성
  • HttpSecurity를 통해 각종 보안 설정을 유연하게 구성할 수 있음(특정 URL에 대한 접근을 제한하거나, 사용자 역할에 따라 접근 권한을 부여할 수 있음)
  • configure(HttpSecurity http) 메서드를 오버라이드하여 요청별로 인증, 권한 설정, 로그인 페이지 경로 지정, 세션 관리 등 다양한 보안 설정을 커스터마이징 가능
  1. 자동화된 보안 필터 체인
  • 보안 필터 체인을 통해 각 요청에 대해 인증 및 권한 부여 검사를 자동화함
  • 여러 개의 기본 필터들로 로그인 처리, 로그아웃 처리, 세션 관리, 예외 처리, 요청의 인증 상태 확인 등을 담당함
  • 커스텀 필터로 변경도 가능
  1. 세션 관리 및 CSRF 보호
  • 세션 관리 기능을 통해 사용자의 로그인 상태를 유지하고 관리
    • 다양한 세션 관리 전략을 제공하며, 세션 고정 보호, 세션 타임아웃 등의 기능도 포함
  • CSRF (Cross-Site Request Forgery) 보호를 기본으로 제공
  1. OAuth2 및 SSO (Single Sign-On)
  • OAuth2 및 OpenID Connect 프로토콜을 통해 소셜 로그인(Google, Facebook 등) 및 Single Sign-On을 쉽게 구현할 수 있는 기능을 제공

기본 사용 흐름

  1. Security Configuration 설정
    @EnableWebSecurity와 함께 WebSecurityConfigurerAdapter를 상속하여 configure(HttpSecurity http)를 오버라이드하여 보안 설정을 정의한다
  2. 인증 처리
    사용자 인증에 필요한 로직을 설정하고, 커스텀 인증 필터나 UserDetailsService 등을 구현하여 사용자 정보를 로드한다
  3. 권한 부여
    URL이나 메서드에 접근 권한을 설정하여 인증된 사용자가 접근할 수 있는 범위를 지정한다
  4. 보안 필터 체인 적용
    모든 요청이 필터 체인을 거쳐 인증 및 권한 부여 검사를 받도록 설정한다

구현!

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가 제공하는 기본 로그인 페이지를 사용
profile
고민고민고민

0개의 댓글