카카오테크캠퍼스 STEP2 2주차 회고
이번 2주차의 주제는 카카오톡 선물하기 기능 중 위시 리스트 기능 구현이다.
1주차에서는 JdbcTemplate와 H2 Database를 이용하여 각 상품들을 CRUD 할 수 있는 관리자 페이지를 구현하였다.
2주차의 기능 요구사항은 여기에 덧붙혀 유효성 검사,회원가입 및 로그인,위시 리스트 기능을 추가하는 것이다.
1단계
상품을 추가하거나 수정하는 경우, 클라이언트로부터 잘못된 값이 전달될 수 있기 때문에 유효성 검사를 통하여 어떤 부분이 잘못되었는지 알려주는 기능을 추가해준다.
유효성 검사는 다음과 같은 조건을 따른다.
- 상품 이름은 공백을 포함하여 최대 15자까지 입력할 수 있다.
- 특수 문자
- 가능: ( ), [ ], +, -, &, /, _
- 그 외 특수 문자 사용 불가
- "카카오"가 포함된 문구는 담당 MD와 협의한 경우에만 사용할 수 있다.
유효성 검사를 위해 validation 의존성을 추가해줬다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
이 validation 의존성을 통해 엔티티 객체에서 @Size , @Pattern과 같은 유효성 검사를 명시해주고 컨트롤러 메서드 파라미터에 @Valid를 통해 유효성 검사를 적용할 수 있다.
public class GiftRequest {
@Size(max = 15)
@Pattern(regexp = "[\\s\\(\\)\\[\\]\\+\\-&/_a-zA-Z0-9\uAC00-\uD7AF]*", message = "특수문자 오류")
private String name;
private int price;
private String imageUrl;
//...이하 get/set
}
@Valid 애노테이션을 통해 GiftRequest에 대한 유효성 검사를 적용한 컨트롤러 메서드
@PostMapping("/admin/gift/create")
public String giftCreate(@Valid @ModelAttribute GiftRequest giftRequest) {
giftService.addGift(giftRequest);
return "redirect:/admin";
}
"카카오"가 포함된 문구는 담당 MD와 협의한 경우에만 사용할 수 있다. 라는 조건은 처음에 GiftRequest에 같이 명시해주었으나, 멘토님의 피드백으로 입력 데이터에 대한 유효성 검증과 비즈니스 정책에 따른 유효성 검증으로 나뉠 수 있다는 걸 알게되었다.
public class Gift {
private Long id;
private String name;
private int price;
private String imageUrl;
public Gift() {
}
public Gift(Long id, String name, int price, String imageUrl) {
if (!isValidName(name)) {
throw new IllegalArgumentException("카카오 문구는 MD와 협의 후 사용가능합니다.");
}
this.id = id;
this.name = name;
this.price = price;
this.imageUrl = imageUrl;
}
private boolean isValidName(String name) {
return name != null && !name.contains("카카오");
}
//..이하 get/set
}
그래서 위와 같이 Gift 엔티티 객체에 생성자를 통해 생성할 때 유효성 검증을 해주는 방식으로 수정하여 비즈니스 유효성검증을 분리해주었다.
2단계
2단계의 요구사항
- 회원은 이메일과 비밀번호를 입력하여 가입한다.
- 토큰을 받으려면 이메일과 비밀번호를 보내야 하며, 가입한 이메일과 비밀번호가 일치하면 토큰이 발급된다.
- 토큰을 생성하는 방법에는 여러 가지가 있다. 방법 중 하나를 선택한다
POST /members/register HTTP/1.1
content-type: application/json
host: localhost:8080
{
"email": "admin@email.com",
"password": "password"
}
HTTP/1.1 200
Content-Type: application/json
{
"token": ""
}
위와 같이 회원가입/로그인을 진행하면 토큰이라는 것을 발행해주는 방식으로 구현을 해야했다.
그래서 먼저 토큰이라는게 뭔지 토큰이라는 건 어떻게 발행해줄지, 토큰은 어떤 종류가 있는지에 대해 학습해야했고 그 과정에서 JWT 토큰이라는 것을 알게되었다.
세션,쿠키와 토큰에 대하여
완벽 정리! 쿠키, 세션, 토큰, 캐시 그리고 CDN
웹 서핑을 하면서 어떤 사이트에 들어가면 쿠키를 설정하라는 문구를 본 적이 있을 거예요. 이 쿠키 때문에 쇼핑 사이트에 로그인하지 않아도 장바구니에 물건을 담아두거나 검색 기록에서 이
52.78.238.255
JWT 토큰이란?
🌐 JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)
Cookie / Session / Token 인증 방식 종류 보통 서버가 클라이언트 인증을 확인하는 방식은 대표적으로 쿠키, 세션, 토큰 3가지 방식이 있다. JWT를 배우기 앞서 우선 쿠키와 세션의 통신 방식을 복습해
inpa.tistory.com
Header,payload,signature 로 구성되어 있는 JWT 토큰을 이용하게 되면 다음과 같은 이점을 얻을 수 있다.
- 로컬 저장방식을 이용하기 때문에 서버 용량과 관계 X
- Header,Payload를 가지고 Signature를 생성하기 때문에 데이터의 위변조를 막을수 있다.
- 클라이언트 인증 정보를 저장하는 세션과 달리, 서버가 Stateless 하게 운용된다.
jwt토큰 기능을 이용하기 위해 다음과 같은 의존성을 추가한다.
implementation 'io.jsonwebtoken:jjwt:0.9.1'
@Component
public class JwtUtil {
@Value("${jwt.secretKey}")
private String secretKey;
public String getUserEmail(String token)
{
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
String email = claims.getSubject();
return email;
}
public boolean checkValidateToken(String token){
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
public String generateJWT(User user) {
long expirationTime = System.currentTimeMillis() + 3600000;
Date expirationDate = new Date(expirationTime);
String token = Jwts.builder()
.setSubject(user.getEmail())
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
return token;
}
}
jwt 토큰을 발행할 수 있는 유틸 클래스인 JwtUtil을 만들어주었다.
JwtUtil은 다음과 같이 구성되어있다.
- jwt토큰을 생성해주는 generateJWT()
- jwt 토큰에서 파싱하여 유저 email 값을 꺼내올 수 있는 getUserEmail()
- 파라미터로 전달된 토큰이 유효한 토큰인지 검사해주는 checkValidateToken()
이걸 이용하여 로그인 시에 jwt 토큰을 JSON타입으로 반환시켜주는 기능을 추가하였다.
@PostMapping("/login")
@ResponseBody
public ResponseEntity<Map<String, String>> login(@RequestBody UserRequest userRequest) {
Optional<String> token = userService.login(userRequest.getEmail(), userRequest.getPassword());
return token.map(t -> ResponseEntity.ok(Map.of("accessToken", t)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "이메일 혹은 패스워드가 틀렸습니다.")));
}
로그인을 하게되면 위와 같이 jwt 토큰이 JSON타입으로 반환된다.
3단계
3단계의 요구사항
이전 단계에서 로그인 후 받은 토큰을 사용하여 사용자별 위시 리스트 기능을 구현한다.
- 위시 리스트에 등록된 상품 목록을 조회할 수 있다.
- 위시 리스트에 상품을 추가할 수 있다.
- 위시 리스트에 담긴 상품을 삭제할 수 있다.
사용자 정보는 요청 헤더의 Authorization 필드를 사용한다.
- Authorization: <유형> <자격증명>
Authorization: Bearer token
3단계는 장바구니,찜목록과 같은 느낌의 기능인 위시 리스트를 구현하는 것이다.
위시 리스트를 CRUD 하는 기능에 토큰을 이용하여 사용자별 위시리스트를 따로 구분할 수 있게 해야하는게 핵심인 것 같다.
CREATE TABLE user_gifts (
`user_id` BIGINT NOT NULL,
`gift_id` BIGINT NOT NULL,
`quantity` INT NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, gift_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (gift_id) REFERENCES gift(id)
);
위시리스트 테이블 스키마는 위와 같이 유저id와 상품id를 참조하는 테이블을 생성하여 사용하였다.
@Repository
public class WishDao {
private JdbcTemplate jdbcTemplate;
@Autowired
public WishDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void addGiftToUser(Long userId, Long giftId, int quantity) {
String query = "INSERT INTO user_gifts (user_id, gift_id, quantity) VALUES (?, ?, ?)";
jdbcTemplate.update(query, userId, giftId, quantity);
}
public void removeGiftFromUser(Long userId, Long giftId) {
String query = "DELETE FROM user_gifts WHERE user_id = ? AND gift_id = ?";
jdbcTemplate.update(query, userId, giftId);
}
public List<UserGift> getGiftsForUser(Long userId) {
String query = "SELECT * FROM user_gifts WHERE user_id = ?";
return jdbcTemplate.query(query, new Object[]{userId}, new BeanPropertyRowMapper<>(UserGift.class));
}
}
@Service
public class WishService {
private WishDao wishDao;
@Autowired
public WishService(WishDao wishDao) {
this.wishDao = wishDao;
}
public void addGiftToUser(Long userId, Long giftId, int quantity) {
wishDao.addGiftToUser(userId, giftId, quantity);
}
public void removeGiftFromUser(Long userId, Long giftId) {
wishDao.removeGiftFromUser(userId, giftId);
}
public List<UserGift> getGiftsForUser(Long userId) {
return wishDao.getGiftsForUser(userId);
}
}
WishDao와 WishService를 만들어 위시리스트에 상품을 CRUD 할 수 있는 기능을 추가하였다.
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final UserService userService;
@Autowired
public AuthInterceptor(UserService userService) {
this.userService = userService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
Optional<User> user = getUserFromToken(authHeader);
User resolvedUser = user.orElse(null);
if (resolvedUser != null) {
request.setAttribute("user", resolvedUser);
return true;
}
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
public Optional<User> getUserFromToken(String authHeader) {
String token = authHeader.replace("Bearer ", "").trim();
if(userService.validateToken(token)) {
return userService.getUserByToken(token);
}
return Optional.empty();
}
}
인터셉터라는 기능을 적용하여 유효한 사용자인지 컨트롤러 핸들러 실행 전에 체크할 수 있게하였다.
스프링 인터셉터란?
https://popo015.tistory.com/115
[Spring] 스프링 인터셉터(Interceptor)란 ?
목표 Interceptor 란 무엇인지 알아본다. Interceptor 를 직접 구현해본다. 순서 1. 인터셉터(Interceptor) 1.1 인터셉터란? 1.2 왜 사용하는가? 1.3 구현수단 1.4 어떤 메서드를 가지고 있는가? 2. 인터셉터 동작
popo015.tistory.com
@RestController
public class WishListController {
private final WishService wishService;
private final GiftService giftService;
@Autowired
public WishListController(WishService wishService, GiftService giftService) {
this.wishService = wishService;
this.giftService = giftService;
}
@GetMapping("/wish")
public ResponseEntity<?> getGiftList(@RequestAttribute("user") User user) {
if (user != null) {
List<GiftResponse> gifts = giftService.getAllGifts();
return ResponseEntity.ok(gifts);
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token");
}
@PostMapping("/wish/{giftId}")
public ResponseEntity<String> addGiftToCart(
@RequestAttribute("user") User user,
@PathVariable Long giftId,
@RequestParam(required = false, defaultValue = "1") int quantity) {
if (user != null) {
wishService.addGiftToUser(user.getId(), giftId, quantity);
return ResponseEntity.ok("위시리스트에 상품이 추가되었습니다.");
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token");
}
@DeleteMapping("/wish/{giftId}")
public ResponseEntity<String> removeGiftFromCart(
@RequestAttribute("user") User user,
@PathVariable Long giftId) {
if (user != null) {
wishService.removeGiftFromUser(user.getId(), giftId);
return ResponseEntity.ok("카트에서 상품이 삭제되었습니다.");
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token");
}
@GetMapping("/mywish")
public ResponseEntity<List<WishResponse>> getUserGifts(@RequestAttribute("user") User user) {
if (user != null) {
List<UserGift> userGifts = wishService.getGiftsForUser(user.getId());
List<WishResponse> wishResponses = userGifts.stream()
.map(userGift -> new WishResponse(userGift.getGiftId(),
giftService.getGift(userGift.getGiftId()).getName(),
giftService.getGift(userGift.getGiftId()).getPrice(),
userGift.getQuantity()))
.collect(Collectors.toList());
return ResponseEntity.ok(wishResponses);
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
}
}
WishController를 통해 적절한 CRUD api 기능을 구현해주었다.
처음엔 @RequestAttribute("user") User user 부분을 HttpServletRequest request 로 받아왔었는데 멘토님의 피드백을 통해 @RequestAttribute를 사용하는 방법이 있다는 것을 알게되었다. 이 방법이 가독성이나 명시성 측면에서 더 좋은 느낌.
이렇게 헤더에 Authorization : Bearer <jwt토큰> 을 포함한 상태로 2번 상품을 추가하면 상품이 잘 추가되는걸 볼 수 있다.
2주차 총평
사용자 인증과 관련된 코드를 구현해보는 것도 처음이고 제대로된 웹 개발 경험이 있는 상태도 아니었기 때문에 토큰과 같은 개념조차 생소하였다.그래서 이번 주차는 전 주차보다 시간이 훨씬 더 걸리게 되었는데 최대한 기능구현에만 초점을 두고 코드를 작성해서 전체적인 구조나 리팩토링 상태가 엉망인 것 같았다.그래서 멘토님 피드백을 통해 내가 놓쳤던 부분들, 마치 신경쓰지 못한 부분들을 캐치할 수 있어 좋았다. 그래도 이번주차를 통해 인증 방식, 토큰 뿐만 아니라 인터셉터와 같은 스프링 기능에 대해서 학습할 수 있어 알아가는게 많았던 주차였던 것 같다.
'KakaoTechCampus' 카테고리의 다른 글
[Spring] 페이징 처리 시 JPA N + 1 문제를 @EntityGraph로 해결해보자 (0) | 2024.11.09 |
---|---|
[Spring] 카카오페이 api 이용해서 테스트 결제 기능 구현하기 (1) | 2024.11.09 |
[카카오테크캠퍼스] STEP2 3주차 회고 (1) | 2024.07.21 |