[TIL] 230528 <Spring> My Select Shop
- -
[회원기능 구현]
myselectshop
API 구현
▼ 회원 DB에 매핑되는 @Entity 클래스 구현
entity>User
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
public User(String username, String password, String email, UserRoleEnum role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
}
entity>UserRoleEnum
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
controller>UserController
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserController {
private final UserService userService;
@GetMapping("/user/login-page")
public String loginPage() {
return "login";
}
@GetMapping("/user/signup")
public String signupPage() {
return "signup";
}
@PostMapping("/user/signup")
public String signup(@Valid SignupRequestDto requestDto, BindingResult bindingResult) {
// Validation 예외처리
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
if(fieldErrors.size() > 0) {
for (FieldError fieldError : bindingResult.getFieldErrors()) {
log.error(fieldError.getField() + " 필드 : " + fieldError.getDefaultMessage());
}
return "redirect:/api/user/signup";
}
userService.signup(requestDto);
return "redirect:/api/user/login-page";
}
// 회원 관련 정보 받기
@GetMapping("/user-info")
@ResponseBody
public UserInfoDto getUserInfo(@AuthenticationPrincipal UserDetailsImpl userDetails) {
String username = userDetails.getUser().getUsername();
UserRoleEnum role = userDetails.getUser().getRole();
boolean isAdmin = (role == UserRoleEnum.ADMIN);
return new UserInfoDto(username, isAdmin);
}
}
dto>SignupRequestDto
@Getter
@Setter
public class SignupRequestDto {
@NotBlank
private String username;
@NotBlank
private String password;
@Email
@NotBlank
private String email;
private boolean admin = false;
private String adminToken = "";
}
dto>UserInfoDto
@Getter
@AllArgsConstructor
public class UserInfoDto {
String username;
boolean isAdmin;
}
service>UserService
package com.sparta.myselectshop.service;
import com.sparta.myselectshop.dto.SignupRequestDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
// ADMIN_TOKEN
private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
public void signup(SignupRequestDto requestDto) {
String username = requestDto.getUsername();
String password = passwordEncoder.encode(requestDto.getPassword());
// 회원 중복 확인
Optional<User> checkUsername = userRepository.findByUsername(username);
if (checkUsername.isPresent()) {
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
}
// email 중복확인
String email = requestDto.getEmail();
Optional<User> checkEmail = userRepository.findByEmail(email);
if (checkEmail.isPresent()) {
throw new IllegalArgumentException("중복된 Email 입니다.");
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (requestDto.isAdmin()) {
if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
// 사용자 등록
User user = new User(username, password, email, role);
userRepository.save(user);
}
}
repository>UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
}
build.gradle의 dependencies에서 security 주석 해제
- 회원 기능 및 JWT 인증 방식의 변경으로 인한 Client 코드 변경
basic.js
const host = 'http://' + window.location.host;
let targetId;
$(document).ready(function () {
const auth = getToken();
if (auth !== undefined && auth !== '') {
$.ajaxPrefilter(function (options, originalOptions, jqXHR) {
jqXHR.setRequestHeader('Authorization', auth);
});
} else {
window.location.href = host + '/api/user/login-page';
return;
}
$.ajax({
type: 'GET',
url: `/api/user-info`,
contentType: 'application/json',
})
.done(function (res, status, xhr) {
const username = res.username;
const isAdmin = !!res.admin;
if (!username) {
window.location.href = '/api/user/login-page';
return;
}
$('#username').text(username);
if (isAdmin) {
$('#admin').text(true);
showProduct(true);
} else {
showProduct();
}
})
.fail(function (jqXHR, textStatus) {
logout();
});
// id 가 query 인 녀석 위에서 엔터를 누르면 execSearch() 함수를 실행하라는 뜻입니다.
$('#query').on('keypress', function (e) {
if (e.key == 'Enter') {
execSearch();
}
});
$('#close').on('click', function () {
$('#container').removeClass('active');
})
$('#close2').on('click', function () {
$('#container2').removeClass('active');
})
$('.nav div.nav-see').on('click', function () {
$('div.nav-see').addClass('active');
$('div.nav-search').removeClass('active');
$('#see-area').show();
$('#search-area').hide();
})
$('.nav div.nav-search').on('click', function () {
$('div.nav-see').removeClass('active');
$('div.nav-search').addClass('active');
$('#see-area').hide();
$('#search-area').show();
})
$('#see-area').show();
$('#search-area').hide();
})
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function execSearch() {
/**
* 검색어 input id: query
* 검색결과 목록: #search-result-box
* 검색결과 HTML 만드는 함수: addHTML
*/
// 1. 검색창의 입력값을 가져온다.
let query = $('#query').val();
// 2. 검색창 입력값을 검사하고, 입력하지 않았을 경우 focus.
if (query == '') {
alert('검색어를 입력해주세요');
$('#query').focus();
return;
}
// 3. GET /api/search?query=${query} 요청
$.ajax({
type: 'GET',
url: `/api/search?query=${query}`,
success: function (response) {
$('#search-result-box').empty();
// 4. for 문마다 itemDto를 꺼내서 HTML 만들고 검색결과 목록에 붙이기!
for (let i = 0; i < response.length; i++) {
let itemDto = response[i];
let tempHtml = addHTML(itemDto);
$('#search-result-box').append(tempHtml);
}
},
error(error, status, request) {
logout();
}
})
}
function addHTML(itemDto) {
/**
* class="search-itemDto" 인 녀석에서
* image, title, lprice, addProduct 활용하기
* 참고) onclick='addProduct(${JSON.stringify(itemDto)})'
*/
return `<div class="search-itemDto">
<div class="search-itemDto-left">
<img src="${itemDto.image}" alt="">
</div>
<div class="search-itemDto-center">
<div>${itemDto.title}</div>
<div class="price">
${numberWithCommas(itemDto.lprice)}
<span class="unit">원</span>
</div>
</div>
<div class="search-itemDto-right">
<img src="../images/icon-save.png" alt="" onclick='addProduct(${JSON.stringify(itemDto)})'>
</div>
</div>`
}
function addProduct(itemDto) {
/**
* modal 뜨게 하는 법: $('#container').addClass('active');
* data를 ajax로 전달할 때는 두 가지가 매우 중요
* 1. contentType: "application/json",
* 2. data: JSON.stringify(itemDto),
*/
// 1. POST /api/products 에 관심 상품 생성 요청
$.ajax({
type: 'POST',
url: '/api/products',
contentType: 'application/json',
data: JSON.stringify(itemDto),
success: function (response) {
// 2. 응답 함수에서 modal을 뜨게 하고, targetId 를 reponse.id 로 설정
$('#container').addClass('active');
targetId = response.id;
},
error(error, status, request) {
logout();
}
});
}
function showProduct(isAdmin = false) {
/**
* 관심상품 목록: #product-container
* 검색결과 목록: #search-result-box
* 관심상품 HTML 만드는 함수: addProductItem
*/
let dataSource = null;
// admin 계정
if (isAdmin) {
dataSource = `/api/admin/products`;
} else {
dataSource = `/api/products`;
}
$.ajax({
type: 'GET',
url: dataSource,
contentType: 'application/json',
success: function (response) {
$('#product-container').empty();
for (let i = 0; i < response.length; i++) {
let product = response[i];
let tempHtml = addProductItem(product);
$('#product-container').append(tempHtml);
}
},
error(error, status, request) {
if (error.status === 403) {
$('html').html(error.responseText);
return;
}
logout();
}
});
}
function addProductItem(product) {
console.log(product)
return `<div class="product-card">
<div onclick="window.location.href='${product.link}'">
<div class="card-header">
<img src="${product.image}"
alt="">
</div>
<div class="card-body">
<div class="title">
${product.title}
</div>
<div class="lprice">
<span>${numberWithCommas(product.lprice)}</span>원
</div>
<div class="isgood ${product.lprice > product.myprice ? 'none' : ''}">
최저가
</div>
</div>
</div>
</div>`;
}
function setMyprice() {
/**
* 1. id가 myprice 인 input 태그에서 값을 가져온다.
* 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
* 3. PUT /api/product/${targetId} 에 data를 전달한다.
* 주의) contentType: "application/json",
* data: JSON.stringify({myprice: myprice}),
* 빠뜨리지 말 것!
* 4. 모달을 종료한다. $('#container').removeClass('active');
* 5, 성공적으로 등록되었음을 알리는 alert를 띄운다.
* 6. 창을 새로고침한다. window.location.reload();
*/
// 1. id가 myprice 인 input 태그에서 값을 가져온다.
let myprice = $('#myprice').val();
// 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
if (myprice == '') {
alert('올바른 가격을 입력해주세요');
return;
}
// 3. PUT /api/product/${targetId} 에 data를 전달한다.
$.ajax({
type: 'PUT',
url: `/api/products/${targetId}`,
contentType: 'application/json',
data: JSON.stringify({myprice: myprice}),
success: function (response) {
// 4. 모달을 종료한다. $('#container').removeClass('active');
$('#container').removeClass('active');
// 5. 성공적으로 등록되었음을 알리는 alert를 띄운다.
alert('성공적으로 등록되었습니다.');
// 6. 창을 새로고침한다. window.location.reload();
window.location.reload();
},
error(error, status, request) {
logout();
}
})
}
function logout() {
// 토큰 삭제
Cookies.remove('Authorization', {path: '/'});
window.location.href = host + '/api/user/login-page';
}
function getToken() {
let auth = Cookies.get('Authorization');
if (auth === undefined) {
return '';
}
return auth;
}
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta property="og:title" content="00만의 셀렉샵">
<meta property="og:description" content="관심상품을 선택하고, 최저가 알림을 확인해보세요!">
<meta property="og:image" content="images/og_selectshop.png">
<link rel="stylesheet" href="/css/style.css">
<script src="https://code.jquery.com/jquery-3.7.0.min.js"
integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
<script src="/js/basic.js"></script>
<title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
<!--headr-->
<div id="header-title-login-user">
<span id="username"></span> 님의
</div>
<div id="header-title-select-shop">
Select Shop
</div>
<a id="login-text" href="javascript:logout()">
로그아웃
</a>
<!--/headr-->
</div>
<div class="nav">
<div class="nav-see active">
모아보기
</div>
<div class="nav-search">
탐색하기
</div>
</div>
<div id="see-area">
<div id="product-container">
</div>
</div>
<div id="search-area">
<div>
<input type="text" id="query">
</div>
<div id="search-result-box">
</div>
<div id="container" class="popup-container">
<div class="popup">
<button id="close" class="close">
X
</button>
<h1>⏰최저가 설정하기</h1>
<p>최저가를 설정해두면 선택하신 상품의 최저가가 떴을 때<br/> 표시해드려요!</p>
<div>
<input type="text" id="myprice" placeholder="200,000">원
</div>
<button class="cta" onclick="setMyprice()">설정하기</button>
</div>
</div>
</div>
<input type="hidden" id="admin"/>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" type="text/css" href="/css/style.css">
<script src="https://code.jquery.com/jquery-3.7.0.min.js"
integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<div id="login-form">
<div id="login-title">Log into Select Shop</div>
<br>
<button id="login-id-btn" onclick="location.href='/api/user/signup'">
회원 가입하기
</button>
<div>
<div class="login-id-label">아이디</div>
<input type="text" name="username" id="username" class="login-input-box">
<div class="login-id-label">비밀번호</div>
<input type="password" name="password" id="password" class="login-input-box">
<button id="login-id-submit" onclick="onLogin()">로그인</button>
</div>
<div id="login-failed" style="display:none" class="alert alert-danger" role="alert">로그인에 실패하였습니다.</div>
</div>
</body>
<script>
$(document).ready(function () {
// 토큰 삭제
Cookies.remove('Authorization', {path: '/'});
});
const href = location.href;
const queryString = href.substring(href.indexOf("?") + 1)
if (queryString === 'error') {
const errorDiv = document.getElementById('login-failed');
errorDiv.style.display = 'block';
}
const host = 'http://' + window.location.host;
function onLogin() {
let username = $('#username').val();
let password = $('#password').val();
$.ajax({
type: "POST",
url: `/api/user/login`,
contentType: "application/json",
data: JSON.stringify({username: username, password: password}),
})
.done(function (res, status, xhr) {
const token = xhr.getResponseHeader('Authorization');
Cookies.set('Authorization', token, {path: '/'})
$.ajaxPrefilter(function (options, originalOptions, jqXHR) {
jqXHR.setRequestHeader('Authorization', token);
});
window.location.href = host;
})
.fail(function (jqXHR, textStatus) {
alert("Login Fail");
window.location.href = host + '/api/user/login-page?error'
});
}
</script>
</html>
signup.html
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" type="text/css" href="/css/style.css">
<meta charset="UTF-8">
<title>회원가입 페이지</title>
<script>
function onclickAdmin() {
// Get the checkbox
var checkBox = document.getElementById("admin-check");
// Get the output text
var box = document.getElementById("admin-token");
// If the checkbox is checked, display the output text
if (checkBox.checked == true){
box.style.display = "block";
} else {
box.style.display = "none";
}
}
</script>
</head>
<body>
<div id="login-form">
<div id="login-title">Sign up Select Shop</div>
<form action="/api/user/signup" method="post">
<div class="login-id-label">Username</div>
<input type="text" name="username" placeholder="Username" class="login-input-box">
<div class="login-id-label">Password</div>
<input type="password" name="password" class="login-input-box">
<div class="login-id-label">E-mail</div>
<input type="text" name="email" placeholder="E-mail" class="login-input-box">
<div>
<input id="admin-check" type="checkbox" name="admin" onclick="onclickAdmin()" style="margin-top: 40px;">관리자
<input id="admin-token" type="password" name="adminToken" placeholder="관리자 암호" class="login-input-box" style="display:none">
</div>
<button id="login-id-submit">회원 가입</button>
</form>
</div>
</body>
</html>
JWT 로그인 인증 처리 (Filter)
Client와 Server가 JWT를 주고받는 방식은 개발자가 선택함
<이전 강의에서 배웠던 방식>
1. JWT를 생성한 서버에서 쿠키를 직접 생성해 Client로 전달했었음
ㄴ 응답 Header에 Set-Cookie로 값이 전달하여 Client에서 쿠키를 직접 쿠키 저장소에 저장하지 않아도 자동으로 해당 값이 저장됨
2. Client에서 따로 Header에 JWT를 직접 담아서 보내지 않고 서버에서 Request에 담긴 쿠키 값들 중 JWT에 해당하는 값을 가져와 사용
이번에는 Client와 Server 모두 JWT를 직접 HTTP Header에 담아서 전달받는 방식으로 구현!
JWT 사용 흐름
1. Client가 username, password 로 로그인 성공 시
(1) 서버에서 "로그인 정보" → JWT 로 암호화 (Secret Key 사용)
(2) JWT 를 Client 응답 Header에 전달
Authorization: Bearer <JWT>
ex)
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzcGFydGEiLCJVU0VSTkFNRSI6IuultO2DhOydtCIsIlVTRVJfUk9MRSI6IlJPTEVfVVNFUiIsIkVYUCI6MTYxODU1Mzg5OH0.9WTrWxCWx3YvaKZG14khp21fjkU1VjZV4e9VEf05Hok
(3) Client 에서 JWT 저장 (쿠키)
2. Client에서 JWT 통해 인증방법
(1) JWT를 API 요청 시마다 Header에 포함
예) HTTP Headers
Content-Type: application/json
Authorization: Bearer <JWT>
...
(2) Server
- Client 가 전달한 JWT 위조 여부 검증 (Secret Key 사용)
- JWT 유효기간이 지나지 않았는지 검증
- 검증 성공시, JWT → 에서 사용자 정보를 가져와 확인
ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회
jwt>JwtUtil (header 에서 JWT 가져오기 : public String getJwtFromHeader(HttpServletRequest request))
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
// header 에서 JWT 가져오기
public String getJwtFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
Spring Security 활성화
security>UserDetailsImpl
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
security>UserDetailsServiceImpl
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
dto>LoginRequestDto
@Setter
@Getter
public class LoginRequestDto {
private String username;
private String password;
}
security>JwtAuthenticationFilter : 로그인 진행 및 JWT 생성
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) {
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
}
security>JwtAuthorizationFilter : API에 전달되는 JWT 유효성 검증 및 인가 처리
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getJwtFromHeader(req);
if (StringUtils.hasText(tokenValue)) {
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
config>WebSecurityConfig : 'Spring Security' 설정 및 인증/인가 필터 등록
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/").permitAll() // 메인 페이지 요청 허가
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
[회원별 상품 API 구현]
상품과 회원의 관계
1. 상품 등록 시 누구의 상품인지 등록이 필요
- 관심 상품 등록 시, 등록을 요청한 "회원 정보" 추가가 필요
2. 상품과 회원은 다대일 관계 (상품 : 회원 = N : 1)
- 즉, 한명의 회원은 다수의 상품을 가질 수 있음
- My 셀렉샵의 상품은 검색 API를 사용하여 검색한 상품을 관심 상품으로 등록하는 것이기 때문에
만약 같은 상품이 검색되어 여러 회원에게 등록 되더라도 서비스 상에서는 다른 상품으로 인지됨
- My 셀렉샵의 상품은 검색 API를 사용하여 검색한 상품을 관심 상품으로 등록하는 것이기 때문에
3. 연관관계의 방향을 선택
회원 객체에서 상품 객체를 조회하는 경우가 없기 때문에 상품과 회원을 N : 1 단방향 연관관계로 설정
entity>Product
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
상품 등록 및 조회 구현
controller>ProductController (Controller에서 로그인 회원 정보를 받아 Service 로 전달)
// 관심 상품 등록하기
@PostMapping("/products")
public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
// 응답 보내기
return productService.createProduct(requestDto, userDetails.getUser());
}
// 관심 상품 조회하기
@GetMapping("/products")
public List<ProductResponseDto> getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
// 응답 보내기
return productService.getProducts(userDetails.getUser());
}
service>ProductService (Service에서 회원별 상품 등록 및 조회 구현)
entity>Product
service>ProductService
repository>ProductRepository (Repository에 회원별 상품을 조회하는 메서드 추가)
controller>ProductController (Admin 계정 모든 상품 조회 기능 추가)
// 관리자 조회
@GetMapping("/admin/products")
public List<ProductResponseDto> getAllProducts() {
return productService.getAllProducts();
}
service>ProductService
public List<ProductResponseDto> getAllProducts() {
List<Product> productList = productRepository.findAll();
List<ProductResponseDto> responseDtoList = new ArrayList<>();
for (Product product : productList) {
responseDtoList.add(new ProductResponseDto(product));
}
return responseDtoList;
}
[상품 페이징 및 정렬]
페이징 및 정렬 설계
- 상품 조회 API 수정 필요 (GET /api/products)
- Client → Server
- 페이징
- page : 조회할 페이지 번호 (1부터 시작)
- size : 한 페이지에 보여줄 상품 개수 (10개로 고정!)
- 정렬
- sortBy (정렬 항목)
- id : Product 테이블의 id
- title : 상품명
- lprice : 최저가
- isAsc (오름차순?)
- true: 오름차순 (asc)
- false : 내림차순 (desc)
- sortBy (정렬 항목)
- Server → Client
- number : 조회된 페이지 번호 (0부터 시작)
- content : 조회된 상품 정보 (배열)
- size : 한 페이지에 보여줄 상품 개수
- numberOfElements : 실제 조회된 상품 개수
- totalElements : 전체 상품 개수 (회원이 등록한 모든 상품의 개수)
- totalPages : 전체 페이지 수
totalPages = totalElement / size 결과를 소수점 올림
1 / 10 = 0.1 => 총 1 페이지
9 / 10 = 0.9 => 총 1페이지
10 / 10 = 1 => 총 1페이지
11 / 10 => 1.1 => 총 2페이지
- first: 첫 페이지인지? (boolean)
- last: 마지막 페이지인지? (boolean)
▶ Spring Data 페이징, 정렬 기능
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<Product> products = productRepository.findAllByUser(user, pageable);
- Pageable은 손쉽게 페이징, 정렬 처리를 하기위해 제공되는 인터페이스이며 PageRequest는 해당 인터페이스의 구현체
- 파라미터로 (현재 페이지(0시작), 데이터 노출 개수, 정렬 방법(ASC, DESC))를 전달하여
생성된 Pageable 구현 객체를 Spring Data JPA의 Query Method 파라미터에 함께 전달하면 페이징 및 정렬 처리가 완료된 데이터를 Page 타입으로 반환 - Page 타입에는 Client에 전달해야할 데이터인 totalPages, totalElements등의 데이터를 함께 포함함
페이징 및 정렬 구현
▼ 페이징 및 정렬 기능 추가로 인한 Client 코드 변경
basic.js
const host = 'http://' + window.location.host;
let targetId;
$(document).ready(function () {
const auth = getToken();
if (auth !== undefined && auth !== '') {
$.ajaxPrefilter(function (options, originalOptions, jqXHR) {
jqXHR.setRequestHeader('Authorization', auth);
});
} else {
window.location.href = host + '/api/user/login-page';
return;
}
$.ajax({
type: 'GET',
url: `/api/user-info`,
contentType: 'application/json',
})
.done(function (res, status, xhr) {
const username = res.username;
const isAdmin = !!res.admin;
if (!username) {
window.location.href = '/api/user/login-page';
return;
}
$('#username').text(username);
if (isAdmin) {
$('#admin').text(true);
showProduct();
} else {
showProduct();
}
})
.fail(function (jqXHR, textStatus) {
logout();
});
// id 가 query 인 녀석 위에서 엔터를 누르면 execSearch() 함수를 실행하라는 뜻입니다.
$('#query').on('keypress', function (e) {
if (e.key == 'Enter') {
execSearch();
}
});
$('#close').on('click', function () {
$('#container').removeClass('active');
})
$('#close2').on('click', function () {
$('#container2').removeClass('active');
})
$('.nav div.nav-see').on('click', function () {
$('div.nav-see').addClass('active');
$('div.nav-search').removeClass('active');
$('#see-area').show();
$('#search-area').hide();
})
$('.nav div.nav-search').on('click', function () {
$('div.nav-see').removeClass('active');
$('div.nav-search').addClass('active');
$('#see-area').hide();
$('#search-area').show();
})
$('#see-area').show();
$('#search-area').hide();
})
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function execSearch() {
/**
* 검색어 input id: query
* 검색결과 목록: #search-result-box
* 검색결과 HTML 만드는 함수: addHTML
*/
// 1. 검색창의 입력값을 가져온다.
let query = $('#query').val();
// 2. 검색창 입력값을 검사하고, 입력하지 않았을 경우 focus.
if (query == '') {
alert('검색어를 입력해주세요');
$('#query').focus();
return;
}
// 3. GET /api/search?query=${query} 요청
$.ajax({
type: 'GET',
url: `/api/search?query=${query}`,
success: function (response) {
$('#search-result-box').empty();
// 4. for 문마다 itemDto를 꺼내서 HTML 만들고 검색결과 목록에 붙이기!
for (let i = 0; i < response.length; i++) {
let itemDto = response[i];
let tempHtml = addHTML(itemDto);
$('#search-result-box').append(tempHtml);
}
},
error(error, status, request) {
logout();
}
})
}
function addHTML(itemDto) {
/**
* class="search-itemDto" 인 녀석에서
* image, title, lprice, addProduct 활용하기
* 참고) onclick='addProduct(${JSON.stringify(itemDto)})'
*/
return `<div class="search-itemDto">
<div class="search-itemDto-left">
<img src="${itemDto.image}" alt="">
</div>
<div class="search-itemDto-center">
<div>${itemDto.title}</div>
<div class="price">
${numberWithCommas(itemDto.lprice)}
<span class="unit">원</span>
</div>
</div>
<div class="search-itemDto-right">
<img src="../images/icon-save.png" alt="" onclick='addProduct(${JSON.stringify(itemDto)})'>
</div>
</div>`
}
function addProduct(itemDto) {
/**
* modal 뜨게 하는 법: $('#container').addClass('active');
* data를 ajax로 전달할 때는 두 가지가 매우 중요
* 1. contentType: "application/json",
* 2. data: JSON.stringify(itemDto),
*/
// 1. POST /api/products 에 관심 상품 생성 요청
$.ajax({
type: 'POST',
url: '/api/products',
contentType: 'application/json',
data: JSON.stringify(itemDto),
success: function (response) {
// 2. 응답 함수에서 modal을 뜨게 하고, targetId 를 reponse.id 로 설정
$('#container').addClass('active');
targetId = response.id;
},
error(error, status, request) {
logout();
}
});
}
function showProduct() {
/**
* 관심상품 목록: #product-container
* 검색결과 목록: #search-result-box
* 관심상품 HTML 만드는 함수: addProductItem
*/
let dataSource = null;
var sorting = $("#sorting option:selected").val();
var isAsc = $(':radio[name="isAsc"]:checked').val();
dataSource = `/api/products?sortBy=${sorting}&isAsc=${isAsc}`;
$('#product-container').empty();
$('#search-result-box').empty();
$('#pagination').pagination({
dataSource,
locator: 'content',
alias: {
pageNumber: 'page',
pageSize: 'size'
},
totalNumberLocator: (response) => {
return response.totalElements;
},
pageSize: 10,
showPrevious: true,
showNext: true,
ajax: {
error(error, status, request) {
if (error.status === 403) {
$('html').html(error.responseText);
return;
}
logout();
}
},
callback: function(response, pagination) {
$('#product-container').empty();
for (let i = 0; i < response.length; i++) {
let product = response[i];
let tempHtml = addProductItem(product);
$('#product-container').append(tempHtml);
}
}
});
}
function addProductItem(product) {
console.log(product)
return `<div class="product-card">
<div onclick="window.location.href='${product.link}'">
<div class="card-header">
<img src="${product.image}"
alt="">
</div>
<div class="card-body">
<div class="title">
${product.title}
</div>
<div class="lprice">
<span>${numberWithCommas(product.lprice)}</span>원
</div>
<div class="isgood ${product.lprice > product.myprice ? 'none' : ''}">
최저가
</div>
</div>
</div>
</div>`;
}
function setMyprice() {
/**
* 1. id가 myprice 인 input 태그에서 값을 가져온다.
* 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
* 3. PUT /api/product/${targetId} 에 data를 전달한다.
* 주의) contentType: "application/json",
* data: JSON.stringify({myprice: myprice}),
* 빠뜨리지 말 것!
* 4. 모달을 종료한다. $('#container').removeClass('active');
* 5, 성공적으로 등록되었음을 알리는 alert를 띄운다.
* 6. 창을 새로고침한다. window.location.reload();
*/
// 1. id가 myprice 인 input 태그에서 값을 가져온다.
let myprice = $('#myprice').val();
// 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
if (myprice == '') {
alert('올바른 가격을 입력해주세요');
return;
}
// 3. PUT /api/product/${targetId} 에 data를 전달한다.
$.ajax({
type: 'PUT',
url: `/api/products/${targetId}`,
contentType: 'application/json',
data: JSON.stringify({myprice: myprice}),
success: function (response) {
// 4. 모달을 종료한다. $('#container').removeClass('active');
$('#container').removeClass('active');
// 5. 성공적으로 등록되었음을 알리는 alert를 띄운다.
alert('성공적으로 등록되었습니다.');
// 6. 창을 새로고침한다. window.location.reload();
window.location.reload();
},
error(error, status, request) {
logout();
}
})
}
function logout() {
// 토큰 삭제
Cookies.remove('Authorization', {path: '/'});
window.location.href = host + '/api/user/login-page';
}
function getToken() {
let auth = Cookies.get('Authorization');
if(auth === undefined) {
return '';
}
return auth;
}
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">
<script src="https://code.jquery.com/jquery-3.7.0.min.js"
integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.css"/>
<link rel="stylesheet" href="/css/style.css">
<script src="/js/basic.js"></script>
<title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
<!--headr-->
<div id="header-title-login-user">
<span id="username"></span> 님의
</div>
<div id="header-title-select-shop">
Select Shop
</div>
<a id="login-text" href="javascript:logout()">
로그아웃
</a>
<!--/headr-->
</div>
<div class="nav">
<div class="nav-see active">
모아보기
</div>
<div class="nav-search">
탐색하기
</div>
</div>
<div id="see-area">
<div class="pagination">
정렬:
<select id="sorting" onchange="showProduct()">
<option value="id">ID</option>
<option value="title">상품명</option>
<option value="lprice">최저가</option>
</select>
<input type="radio" name="isAsc" value="true" onchange="showProduct()" checked/> 오름차순
<input type="radio" name="isAsc" value="false" onchange="showProduct()"/> 내림차순
</div>
<div id="pagination" class="pagination"></div>
<br>
<div id="product-container">
</div>
</div>
<div id="search-area">
<div>
<input type="text" id="query">
</div>
<div id="search-result-box">
</div>
<div id="container" class="popup-container">
<div class="popup">
<button id="close" class="close">
X
</button>
<h1>⏰최저가 설정하기</h1>
<p>최저가를 설정해두면 선택하신 상품의 최저가가 떴을 때<br/> 표시해드려요!</p>
<div>
<input type="text" id="myprice" placeholder="200,000">원
</div>
<button class="cta" onclick="setMyprice()">설정하기</button>
</div>
</div>
</div>
<input type="hidden" id="admin"/>
</body>
</html>
▼ 페이징 및 정렬 기능 추가로 인한 Server 코드 변경
controller>ProductController
// 관심 상품 조회하기
@GetMapping("/products")
public Page<ProductResponseDto> getProducts(
@RequestParam("page") int page,
@RequestParam("size") int size,
@RequestParam("sortBy") String sortBy,
@RequestParam("isAsc") boolean isAsc,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
// 응답 보내기
return productService.getProducts(userDetails.getUser(), page-1, size, sortBy, isAsc);
}
service>ProductService
public Page<ProductResponseDto> getProducts(User user,
int page, int size, String sortBy, boolean isAsc) {
// 페이징 처리
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
// 사용자 권한 가져와서 ADMIN 이면 전체 조회, USER 면 본인이 추가한 부분 조회
UserRoleEnum userRoleEnum = user.getRole();
Page<Product> productList;
if (userRoleEnum == UserRoleEnum.USER) {
// 사용자 권한이 USER 일 경우
productList = productRepository.findAllByUser(user, pageable);
} else {
productList = productRepository.findAll(pageable);
}
return productList.map(ProductResponseDto::new);
}
repository>ProductRepository
테스트 데이터 생성
util>TestDataRunner
@Component
public class TestDataRunner implements ApplicationRunner {
@Autowired
UserService userService;
@Autowired
ProductRepository productRepository;
@Autowired
UserRepository userRepository;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
NaverApiService naverApiService;
@Override
public void run(ApplicationArguments args) {
// 테스트 User 생성
User testUser = new User("Robbie", passwordEncoder.encode("1234"), "robbie@sparta.com", UserRoleEnum.USER);
testUser = userRepository.save(testUser);
// 테스트 User 의 관심상품 등록
// 검색어 당 관심상품 10개 등록
createTestData(testUser, "신발");
createTestData(testUser, "과자");
createTestData(testUser, "키보드");
createTestData(testUser, "휴지");
createTestData(testUser, "휴대폰");
createTestData(testUser, "앨범");
createTestData(testUser, "헤드폰");
createTestData(testUser, "이어폰");
createTestData(testUser, "노트북");
createTestData(testUser, "무선 이어폰");
createTestData(testUser, "모니터");
}
private void createTestData(User user, String searchWord) {
// 네이버 쇼핑 API 통해 상품 검색
List<ItemDto> itemDtoList = naverApiService.searchItems(searchWord);
List<Product> productList = new ArrayList<>();
for (ItemDto itemDto : itemDtoList) {
Product product = new Product();
// 관심상품 저장 사용자
product.setUser(user);
// 관심상품 정보
product.setTitle(itemDto.getTitle());
product.setLink(itemDto.getLink());
product.setImage(itemDto.getImage());
product.setLprice(itemDto.getLprice());
// 희망 최저가 랜덤값 생성
// 최저 (100원) ~ 최대 (상품의 현재 최저가 + 10000원)
int myPrice = getRandomNumber(MIN_MY_PRICE, itemDto.getLprice() + 10000);
product.setMyprice(myPrice);
productList.add(product);
}
productRepository.saveAll(productList);
}
public int getRandomNumber(int min, int max) {
return (int) ((Math.random() * (max - min)) + min);
}
}
[상품 폴더 설계]
요구사항 분석
1) 배경
페이지네이션 기능만으로는 원하는 관심상품을 쉽게 찾기 어려워서 폴더별로 관심상품을 저장/관리할 수 있는 기능을 추가!
2) 요구사항
1. 폴더 생성
- 회원별 폴더를 추가
- 폴더를 추가할 때 1개~N개를 한번에 추가가능
2. 관심상품에 폴더 설정
- 관심상품에 폴더는 N개 설정 가능
- 관심상품이 등록되는 시점에는 어느 폴더에도 저장되지 않음
- 관심상품별로 1번에서 생성한 폴더를 선택하여 추가가능
3. 폴더별 조회
- 회원은 폴더별로 관심상품 조회 가능
- 조회 방법
- '전체' 클릭 시: 폴더와 상관 없이 회원이 저장한 전체 관심상품들을 조회 가능
- '폴더명' 클릭 시: 폴더별 저장된 관심상품들을 조회 가능
▶ 폴더 기능 추가로 인한 Client 코드 변경
basic.js
const host = 'http://' + window.location.host;
let targetId;
let folderTargetId;
$(document).ready(function () {
const auth = getToken();
if (auth !== undefined && auth !== '') {
$.ajaxPrefilter(function (options, originalOptions, jqXHR) {
jqXHR.setRequestHeader('Authorization', auth);
});
} else {
window.location.href = host + '/api/user/login-page';
return;
}
$.ajax({
type: 'GET',
url: `/api/user-info`,
contentType: 'application/json',
})
.done(function (res, status, xhr) {
const username = res.username;
const isAdmin = !!res.admin;
if (!username) {
window.location.href = '/api/user/login-page';
return;
}
$('#username').text(username);
if (isAdmin) {
$('#admin').text(true);
showProduct();
} else {
showProduct();
}
// 로그인한 유저의 폴더
$.ajax({
type: 'GET',
url: `/api/user-folder`,
error(error) {
logout();
}
}).done(function (fragment) {
$('#fragment').replaceWith(fragment);
});
})
.fail(function (jqXHR, textStatus) {
logout();
});
// id 가 query 인 녀석 위에서 엔터를 누르면 execSearch() 함수를 실행하라는 뜻입니다.
$('#query').on('keypress', function (e) {
if (e.key == 'Enter') {
execSearch();
}
});
$('#close').on('click', function () {
$('#container').removeClass('active');
})
$('#close2').on('click', function () {
$('#container2').removeClass('active');
})
$('.nav div.nav-see').on('click', function () {
$('div.nav-see').addClass('active');
$('div.nav-search').removeClass('active');
$('#see-area').show();
$('#search-area').hide();
})
$('.nav div.nav-search').on('click', function () {
$('div.nav-see').removeClass('active');
$('div.nav-search').addClass('active');
$('#see-area').hide();
$('#search-area').show();
})
$('#see-area').show();
$('#search-area').hide();
})
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function execSearch() {
/**
* 검색어 input id: query
* 검색결과 목록: #search-result-box
* 검색결과 HTML 만드는 함수: addHTML
*/
// 1. 검색창의 입력값을 가져온다.
let query = $('#query').val();
// 2. 검색창 입력값을 검사하고, 입력하지 않았을 경우 focus.
if (query == '') {
alert('검색어를 입력해주세요');
$('#query').focus();
return;
}
// 3. GET /api/search?query=${query} 요청
$.ajax({
type: 'GET',
url: `/api/search?query=${query}`,
success: function (response) {
$('#search-result-box').empty();
// 4. for 문마다 itemDto를 꺼내서 HTML 만들고 검색결과 목록에 붙이기!
for (let i = 0; i < response.length; i++) {
let itemDto = response[i];
let tempHtml = addHTML(itemDto);
$('#search-result-box').append(tempHtml);
}
},
error(error, status, request) {
logout();
}
})
}
function addHTML(itemDto) {
/**
* class="search-itemDto" 인 녀석에서
* image, title, lprice, addProduct 활용하기
* 참고) onclick='addProduct(${JSON.stringify(itemDto)})'
*/
return `<div class="search-itemDto">
<div class="search-itemDto-left">
<img src="${itemDto.image}" alt="">
</div>
<div class="search-itemDto-center">
<div>${itemDto.title}</div>
<div class="price">
${numberWithCommas(itemDto.lprice)}
<span class="unit">원</span>
</div>
</div>
<div class="search-itemDto-right">
<img src="../images/icon-save.png" alt="" onclick='addProduct(${JSON.stringify(itemDto)})'>
</div>
</div>`
}
function addProduct(itemDto) {
/**
* modal 뜨게 하는 법: $('#container').addClass('active');
* data를 ajax로 전달할 때는 두 가지가 매우 중요
* 1. contentType: "application/json",
* 2. data: JSON.stringify(itemDto),
*/
// 1. POST /api/products 에 관심 상품 생성 요청
$.ajax({
type: 'POST',
url: '/api/products',
contentType: 'application/json',
data: JSON.stringify(itemDto),
success: function (response) {
// 2. 응답 함수에서 modal을 뜨게 하고, targetId 를 reponse.id 로 설정
$('#container').addClass('active');
targetId = response.id;
},
error(error, status, request) {
logout();
}
});
}
function showProduct(folderId = null) {
/**
* 관심상품 목록: #product-container
* 검색결과 목록: #search-result-box
* 관심상품 HTML 만드는 함수: addProductItem
*/
let dataSource = null;
var sorting = $("#sorting option:selected").val();
var isAsc = $(':radio[name="isAsc"]:checked').val();
if (folderId) {
dataSource = `/api/folders/${folderId}/products?sortBy=${sorting}&isAsc=${isAsc}`;
} else if(folderTargetId === undefined) {
dataSource = `/api/products?sortBy=${sorting}&isAsc=${isAsc}&folderId=${folderId}`;
} else {
dataSource = `/api/folders/${folderTargetId}/products?sortBy=${sorting}&isAsc=${isAsc}`;
}
$('#product-container').empty();
$('#search-result-box').empty();
$('#pagination').pagination({
dataSource,
locator: 'content',
alias: {
pageNumber: 'page',
pageSize: 'size'
},
totalNumberLocator: (response) => {
return response.totalElements;
},
pageSize: 10,
showPrevious: true,
showNext: true,
ajax: {
beforeSend: function () {
$('#product-container').html('상품 불러오는 중...');
},
error(error, status, request) {
if (error.status === 403) {
$('html').html(error.responseText);
return;
}
logout();
}
},
callback: function (response, pagination) {
$('#product-container').empty();
for (let i = 0; i < response.length; i++) {
let product = response[i];
let tempHtml = addProductItem(product);
$('#product-container').append(tempHtml);
}
}
});
}
// Folder 관련 기능
function openFolder(folderId) {
folderTargetId = folderId;
$("button.product-folder").removeClass("folder-active");
if (!folderId) {
$("button#folder-all").addClass('folder-active');
} else {
$(`button[value='${folderId}']`).addClass('folder-active');
}
showProduct(folderId);
}
// 폴더 추가 팝업
function openAddFolderPopup() {
$('#container2').addClass('active');
}
// 폴더 Input 추가
function addFolderInput() {
$('#folders-input').append(
`<input type="text" class="folderToAdd" placeholder="추가할 폴더명">
<span onclick="closeFolderInput(this)" style="margin-right:5px">
<svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="red" class="bi bi-x-circle-fill" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
</span>
`
);
}
function closeFolderInput(folder) {
$(folder).prev().remove();
$(folder).next().remove();
$(folder).remove();
}
function addFolder() {
const folderNames = $('.folderToAdd').toArray().map(input => input.value);
try {
folderNames.forEach(name => {
if (name === '') {
alert('올바른 폴더명을 입력해주세요');
throw new Error("stop loop");
}
});
} catch (e) {
console.log(e);
return;
}
$.ajax({
type: "POST",
url: `/api/folders`,
contentType: "application/json",
data: JSON.stringify({
folderNames
})
}).done(function (data, textStatus, xhr) {
if(data !== '') {
alert("중복된 폴더입니다.");
return;
}
$('#container2').removeClass('active');
alert('성공적으로 등록되었습니다.');
window.location.reload();
})
.fail(function(xhr, textStatus, errorThrown) {
alert("중복된 폴더입니다.");
});
}
function addProductItem(product) {
const folders = product.productFolderList.map(folder =>
`
<span onclick="openFolder(${folder.id})">
#${folder.name}
</span>
`
);
return `<div class="product-card">
<div onclick="window.location.href='${product.link}'">
<div class="card-header">
<img src="${product.image}"
alt="">
</div>
<div class="card-body">
<div class="title">
${product.title}
</div>
<div class="lprice">
<span>${numberWithCommas(product.lprice)}</span>원
</div>
<div class="isgood ${product.lprice > product.myprice ? 'none' : ''}">
최저가
</div>
</div>
</div>
<div class="product-tags" style="margin-bottom: 20px;">
${folders}
<span onclick="addInputForProductToFolder(${product.id}, this)">
<svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="currentColor" class="bi bi-folder-plus" viewBox="0 0 16 16">
<path d="M.5 3l.04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.684.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/>
<path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/>
</svg>
</span>
</div>
</div>`;
}
function addInputForProductToFolder(productId, button) {
$.ajax({
type: 'GET',
url: `/api/folders`,
success: function (folders) {
const options = folders.map(folder => `<option value="${folder.id}">${folder.name}</option>`)
const form = `
<span>
<form id="folder-select" method="post" autocomplete="off" action="/api/products/${productId}/folder">
<select name="folderId" form="folder-select">
${options}
</select>
<input type="submit" value="추가" style="padding: 5px; font-size: 12px; margin-left: 5px;">
</form>
</span>
`;
$(form).insertBefore(button);
$(button).remove();
$("#folder-select").on('submit', function (e) {
e.preventDefault();
$.ajax({
type: $(this).prop('method'),
url: $(this).prop('action'),
data: $(this).serialize(),
}).done(function (data, textStatus, xhr) {
if(data !== '') {
alert("중복된 폴더입니다.");
return;
}
alert('성공적으로 등록되었습니다.');
window.location.reload();
})
.fail(function(xhr, textStatus, errorThrown) {
alert("중복된 폴더입니다.");
});
});
},
error(error, status, request) {
logout();
}
});
}
function setMyprice() {
/**
* 1. id가 myprice 인 input 태그에서 값을 가져온다.
* 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
* 3. PUT /api/product/${targetId} 에 data를 전달한다.
* 주의) contentType: "application/json",
* data: JSON.stringify({myprice: myprice}),
* 빠뜨리지 말 것!
* 4. 모달을 종료한다. $('#container').removeClass('active');
* 5, 성공적으로 등록되었음을 알리는 alert를 띄운다.
* 6. 창을 새로고침한다. window.location.reload();
*/
// 1. id가 myprice 인 input 태그에서 값을 가져온다.
let myprice = $('#myprice').val();
// 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
if (myprice == '') {
alert('올바른 가격을 입력해주세요');
return;
}
// 3. PUT /api/product/${targetId} 에 data를 전달한다.
$.ajax({
type: 'PUT',
url: `/api/products/${targetId}`,
contentType: 'application/json',
data: JSON.stringify({myprice: myprice}),
success: function (response) {
// 4. 모달을 종료한다. $('#container').removeClass('active');
$('#container').removeClass('active');
// 5. 성공적으로 등록되었음을 알리는 alert를 띄운다.
alert('성공적으로 등록되었습니다.');
// 6. 창을 새로고침한다. window.location.reload();
window.location.reload();
},
error(error, status, request) {
logout();
}
})
}
function logout() {
// 토큰 삭제
Cookies.remove('Authorization', {path: '/'});
window.location.href = host + '/api/user/login-page';
}
function getToken() {
let auth = Cookies.get('Authorization');
if(auth === undefined) {
return '';
}
return auth;
}
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="preconnect" href="https://fonts.gstatic.com">
<script src="https://code.jquery.com/jquery-3.7.0.min.js"
integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paginationjs/2.1.4/pagination.css"/>
<link rel="stylesheet" href="/css/style.css">
<script src="/js/basic.js"></script>
<title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
<!--headr-->
<div id="header-title-login-user">
<span id="username"></span> 님의
</div>
<div id="header-title-select-shop">
Select Shop
</div>
<a id="login-text" href="javascript:logout()">
로그아웃
</a>
<!--/headr-->
</div>
<div class="nav">
<div class="nav-see active">
모아보기
</div>
<div class="nav-search">
탐색하기
</div>
</div>
<div id="see-area">
<div class="folder-bar folder-black">
<div>
<button id="folder-all" class="folder-bar-item folder-button product-folder folder-active" onclick="openFolder()">전체</button>
</div>
<div id="fragment">
<div th:each="folder : ${folders}">
<button class="folder-bar-item folder-button product-folder"
th:value="${folder.id}"
th:utext="${folder.name}"
th:attr="onclick=|openFolder(${folder.id})|">
</button>
</div>
</div>
<div>
<button id="folder-add" class="folder-bar-item folder-button product-folder" onclick="openAddFolderPopup()">
<svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="currentColor" class="bi bi-folder-plus" viewBox="0 0 16 16">
<path d="M.5 3l.04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.684.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/>
<path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
</div>
</div>
<div class="pagination">
정렬:
<select id="sorting" onchange="showProduct()">
<option value="id">ID</option>
<option value="title">상품명</option>
<option value="lprice">최저가</option>
</select>
<input type="radio" name="isAsc" value="true" onchange="showProduct()" checked/> 오름차순
<input type="radio" name="isAsc" value="false" onchange="showProduct()"/> 내림차순
</div>
<div id="pagination" class="pagination"></div>
<br>
<div id="product-container">
</div>
<div id="container2" class="popup-container">
<div class="popup" style="width:410px; height:auto">
<button id="close2" class="close">
X
</button>
<h1>🗂 폴더 추가하기</h1>
<p>폴더를 추가해서 관심상품을 관리해보세요!</p>
<div id="folders-input">
<input type="text" class="folderToAdd" placeholder="추가할 폴더명">
<!-- <div class="control" data-v-79d84978="" style="width:20px"><a class="button is-primary is-outlined" data-v-79d84978=""><span class="icon" data-v-79d84978=""><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512" class="svg-inline--fa fa-times fa-w-11" data-v-79d84978=""><path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z" data-v-79d84978="" class=""></path></svg></span></a></div>-->
</div>
<div onclick="addFolderInput()">
<svg xmlns="http://www.w3.org/2000/svg" width="30px" fill="currentColor" class="bi bi-folder-plus" viewBox="0 0 16 16">
<path d="M.5 3l.04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.684.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z"/>
<path d="M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z"/>
</svg>
</div>
<button id="add-folder-btn" class="cta2" onclick="addFolder()">추가하기</button>
</div>
</div>
</div>
<div id="search-area">
<div>
<input type="text" id="query">
</div>
<div id="search-result-box">
</div>
<div id="container" class="popup-container">
<div class="popup">
<button id="close" class="close">
X
</button>
<h1>⏰최저가 설정하기</h1>
<p>최저가를 설정해두면 선택하신 상품의 최저가가 떴을 때<br/> 표시해드려요!</p>
<div>
<input type="text" id="myprice" placeholder="200,000">원
</div>
<button class="cta" onclick="setMyprice()">설정하기</button>
</div>
</div>
</div>
<input type="hidden" id="admin"/>
</body>
</html>
폴더 테이블 설계 및 폴더와 회원의 관계
- 폴더 테이블에 필요한 정보
- 폴더명(name): 회원이 등록한 폴더 이름을 저장
- 회원ID(user): 폴더를 등록한 회원의 ID 를 저장
- A 회원이 생성한 폴더는 A 회원에게만 보여야 합니다.
- 폴더와 회원의 관계 : '상품과 회원'의 관계와 동일 (폴더 : 회원 = N : 1, 다대일 관계)
- 회원 객체에서 폴더 객체를 조회하는 경우가 없기 때문에 폴더와 회원을 N : 1 단방향 연관관계로 설정
entity > Folder
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "folder")
public class Folder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
public Folder(String name, User user) {
this.name = name;
this.user = user;
}
}
repository > FolderRepository
public interface FolderRepository extends JpaRepository<Folder,Long> {
}
상품과 폴더의 관계
상품과 폴더는 N : M 다대다 관계
- @ManyToMany 애너테이션을 사용하여 다대다 관계를 풀 수도 있지만 상품_폴더 중간 테이블을 직접 만들어 풀어볼 것임
- 상품 : 상품_폴더 = 1 : N
- 폴더 : 상품_폴더 = 1 : N
- 연관관계의 방향 설정
- 관심 상품을 조회할 때 포함된 폴더들의 정보가 필요 (상품 객체가 폴더 객체 조회 필요!)
- 상품 객체를 기준으로 해당 폴더에 포함된 상품들을 조회해야함 (상품 객체가 폴더 객체 조회 필요!)
- 따라서 상품과 상품_폴더는 양방향 연관관계로 설정
- 폴더 객체에서 상품 객체를 조회하지 않을 예정이기 때문에 폴더는 상품_폴더와 관계를 맺지 않겠음
entity > ProductFolder
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "product_folder")
public class ProductFolder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "folder_id", nullable = false)
private Folder folder;
public ProductFolder(Product product, Folder folder) {
this.product = product;
this.folder = folder;
}
}
repository>ProductFolderRepository
public interface ProductFolderRepository extends JpaRepository<ProductFolder,Long> {
}
entity>Product
@OneToMany(mappedBy = "product")
private List<ProductFolder> productFolderList = new ArrayList<>();
[폴더 생성 및 조회 구현]
요구사항
1. 회원별 폴더를 추가할 수 있음
2. 폴더를 추가할 때 1개~N개를 한번에 추가할 수 있음
3. 회원별 저장한 폴더들이 조회 되어야 함
API 설계 및 구현
1. 회원의 폴더 생성
controller>FolderController
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class FolderController {
private final FolderService folderService;
@PostMapping("/folders")
public void addFolders(@RequestBody FolderRequestDto folderRequestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
List<String> folderNames = folderRequestDto.getFolderNames();
folderService.addFolders(folderNames, userDetails.getUser());
}
}
dto>FolderRequestDto
@Getter
public class FolderRequestDto {
List<String> folderNames;
}
service>FolderService
@Service
@RequiredArgsConstructor
public class FolderService {
private final FolderRepository folderRepository;
// 로그인한 회원에 폴더들 등록
public void addFolders(List<String> folderNames, User user) {
// 입력으로 들어온 폴더 이름을 기준으로, 회원이 이미 생성한 폴더들을 조회합니다.
List<Folder> existFolderList = folderRepository.findAllByUserAndNameIn(user, folderNames);
List<Folder> folderList = new ArrayList<>();
for (String folderName : folderNames) {
// 이미 생성한 폴더가 아닌 경우만 폴더 생성
if (!isExistFolderName(folderName, existFolderList)) {
Folder folder = new Folder(folderName, user);
folderList.add(folder);
} else {
throw new IllegalArgumentException("폴더명이 중복되었습니다.");
}
}
folderRepository.saveAll(folderList);
}
private Boolean isExistFolderName(String folderName, List<Folder> existFolderList) {
// 기존 폴더 리스트에서 folder name 이 있는지?
for (Folder existFolder : existFolderList) {
if(folderName.equals(existFolder.getName())) {
return true;
}
}
return false;
}
}
repository>FolderRepository
public interface FolderRepository extends JpaRepository<Folder,Long> {
List<Folder> findAllByUserAndNameIn(User user, List<String> folderNames);
// select * from folder where user_id = 1 and name in ('1','2','3');
}
2. 회원이 저장한 폴더 조회
controller>UserController
// 로그인 한 유저가 메인 페이지를 요청할 때 가지고있는 폴더를 반환
@GetMapping("/user-folder")
public String getUserInfo(Model model, @AuthenticationPrincipal UserDetailsImpl userDetails) {
model.addAttribute("folders", folderService.getFolders(userDetails.getUser()));
return "index :: #fragment";
}
service>FolderService
// 로그인한 회원이 등록된 모든 폴더 조회
public List<FolderResponseDto> getFolders(User user) {
List<Folder> folderList = folderRepository.findAllByUser(user);
List<FolderResponseDto> responseDtoList = new ArrayList<>();
for (Folder folder : folderList) {
responseDtoList.add(new FolderResponseDto(folder));
}
return responseDtoList;
}
dto>FolderResponseDto
@Getter
public class FolderResponseDto {
private Long id;
private String name;
public FolderResponseDto(Folder folder) {
this.id = folder.getId();
this.name = folder.getName();
}
}
repository>FolderRepository
public interface FolderRepository extends JpaRepository<Folder,Long> {
List<Folder> findAllByUserAndNameIn(User user, List<String> folderNames);
// select * from folder where user_id = 1 and name in ('1','2','3');
List<Folder> findAllByUser(User user);
}
dto>ProductResponseDto
service>ProductService
[관심 상품에 폴더 추가 구현]
요구사항
- 관심상품에 폴더를 0개 ~ N개 설정가능
- 관심상품이 등록되는 시점에는 어느 폴더에도 저장되지 않음
- 관심상품 별로 기 생성 했던 폴더를 선택하여 추가가능
- 폴더 전체 조회 및 선택
API 설계 및 구현
1. 폴더 전체 조회
controller>FolderController
// 회원이 등록한 모든 폴더 조회
@GetMapping("/folders")
public List<FolderResponseDto> getFolders(@AuthenticationPrincipal UserDetailsImpl userDetails) {
return folderService.getFolders(userDetails.getUser());
}
2. 관심 상품에 폴더 추가
controller>ProductController
// 상품에 폴더 추가
@PostMapping("/products/{productId}/folder")
public void addFolder(
@PathVariable Long productId,
@RequestParam Long folderId,
@AuthenticationPrincipal UserDetailsImpl userDetails
) {
productService.addFolder(productId, folderId, userDetails.getUser());
}
service>ProductService
public void addFolder(Long productId, Long folderId, User user) {
// 1) 상품을 조회합니다.
Product product = productRepository.findById(productId).orElseThrow(() ->
new NullPointerException("해당 상품이 존재하지 않습니다.")
);
// 2) 폴더를 조회합니다.
Folder folder = folderRepository.findById(folderId).orElseThrow(
() -> new NullPointerException("해당 폴더가 존재하지 않습니다.")
);
// 3) 조회한 폴더와 상품이 모두 로그인한 회원의 소유인지 확인합니다.
if (!product.getUser().getId().equals(user.getId())
|| !folder.getUser().getId().equals(user.getId())) {
throw new IllegalArgumentException("회원님의 관심상품이 아니거나, 회원님의 폴더가 아닙니다.");
}
// 중복확인
Optional<ProductFolder> overlapFolder = productFolderRepository.findByProductAndFolder(product, folder);
if (overlapFolder.isPresent()) {
throw new IllegalArgumentException("중복된 폴더입니다.");
}
// 4) 상품에 폴더를 추가합니다.
productFolderRepository.save(new ProductFolder(product, folder));
}
repository>ProductFolderRepository
public interface ProductFolderRepository extends JpaRepository<ProductFolder,Long> {
Optional<ProductFolder> findByProductAndFolder(Product product, Folder folder);
}
[폴더 별 관심상품 조회 구현]
요구사항
1. 회원은 폴더 별로 관심상품 조회가 가능
2. 조회방법
- '폴더별': 폴더별 저장된 관심상품들을 조회 가능
- '전체': 폴더와 상관 없이 회원이 저장한 전체 관심상품들을 조회 가능
API 설계 및 구현
1. 폴더별 관심상품 조회
controller>ProductController
// 회원이 등록한 폴더 내 모든 상품 조회
@GetMapping("/folders/{folderId}/products")
public Page<ProductResponseDto> getProductsInFolder(
@PathVariable Long folderId,
@RequestParam int page,
@RequestParam int size,
@RequestParam String sortBy,
@RequestParam boolean isAsc,
@AuthenticationPrincipal UserDetailsImpl userDetails
) {
return productService.getProductsInFolder(
folderId,
page-1,
size,
sortBy,
isAsc,
userDetails.getUser()
);
}
service>ProductService
@Transactional(readOnly = true)
public Page<ProductResponseDto> getProductsInFolder(Long folderId, int page, int size, String sortBy, boolean isAsc, User user) {
// 페이징 처리
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
// 해당 폴더에 등록된 상품을 가져옵니다.
Page<Product> products = productRepository.findAllByUserAndProductFolderList_FolderId(user, folderId, pageable);
Page<ProductResponseDto> responseDtoList = products.map(ProductResponseDto::new);
return responseDtoList;
}
repository>ProductRepository
select
p.id,
p.title as product_title,
pf.product_id as product_id,
pf.folder_id as folder_id
from
product p left join product_folder pf
on p.id = pf.product_id
where p.user_id=1
and
pf.folder_id=3
order by p.id
limit 20, 10;
'TIL' 카테고리의 다른 글
[TIL] 230530 <Spring> 회원가입, 로그인 기능이 있는 투두앱 백엔드 서버 만들기 (2) (0) | 2024.05.30 |
---|---|
[TIL] 230529 <Spring> 회원가입, 로그인 기능이 있는 투두앱 백엔드 서버 만들기 (1) (0) | 2024.05.29 |
[TIL] 230527 <Spring> 사용자 관리하기, 데이터 검증하기 (0) | 2024.05.27 |
[TIL] 230524 <Spring> 인증과 인가, 사용자 관리하기 (0) | 2024.05.24 |
[TIL] 230523 <Spring> My Select Shop (0) | 2024.05.24 |
소중한 공감 감사합니다