Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2단계 - 장바구니 기능] 제이미(임정수) 미션 제출합니다. #352

Merged
merged 91 commits into from
May 19, 2023

Conversation

JJ503
Copy link
Member

@JJ503 JJ503 commented May 7, 2023

안녕하세요 춘식!
1단계 미션에 이어 2단계 미션도 PR 리뷰 요청합니다.
고민과 잘 안 되는 부분들이 많아 생각보다 리뷰 요청이 늦어졌습니다 😥

의문이 든 사항에 대해선 코드에 코멘트를 작성해 두었습니다.
의문 사항 외에도 여러 피드백과 고민해 보면 좋을 것들에 대해 마구 던져주시면 감사하겠습니다!

또한, 1단계에서 주신 피드백에 대한 답변 및 추가 의문 사항에 대해 1단계 PR 코멘트로 달아두었습니다.
이에 대해서도 확인해 주시면 감사하겠습니다! (1단계 PR 바로가기)

이번 리뷰도 잘 부탁드립니다!
감사합니다.

JJ503 added 30 commits May 4, 2023 09:41
ItemViewController -> ViewController
- CartResponse 반환 dto 생성
- AuthInfo 계정 요청 dto 생성
- Service에서 이메일이 아닌 AuthInfo를 받도록 수정
Comment on lines 44 to 52
public List<CartData> findAll(final Long userId) {
final String sql = "SELECT carts.id, items.name, items.image_url, items.price " +
"FROM items JOIN carts " +
"ON items.id = carts.item_id " +
"WHERE carts.user_id = :user_id";
MapSqlParameterSource param = new MapSqlParameterSource("user_id", userId);
return namedParameterJdbcTemplate.query(sql, param, cartDataRowMapper);
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CartDao의 findAll()를 확인해 보면 현재 서비스에서 필요한 정보(장바구니의 id, 상품의 이름, 이미지 url, 가격)만 가져오는 것을 확인할 수 있습니다.
그런데 추후 요구사항이 변경되어 다른 정보들을 가져오는 경우가 발생할 수도 있습니다.
이런 경우를 대비하여 carts, items(혹은 users도)의 테이블을 조인한 모든 정보를 가져오는 것이 좋을지 궁금합니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요구사항을 미리 대비하여 쿼리를 크게 짜놓을 필요는 없습니다. 오히려 성능이 느려질 수도 있고요. 객체의 경우는 유지보수가 어렵기 때문에 어느 정도의 확장성을 가지고 가는게 좋지만 쿼리는 확장의 굳이 그럴 필요가 없어서 요구사항에만 충실해도 될 것 같네요 🙂

Copy link
Member Author

@JJ503 JJ503 May 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쿼리의 경우는 확장까지 굳이 생각하지 않는군요..!
쿼리의 경우는 비용이 들거나 불필요한 데이터 혹은 객체가 생기기 때문에 그런 것일까요?

Comment on lines +24 to +32
private final CartDao cartDao;
private final UserDao userDao;
private final ItemDao itemDao;

public CartService(CartDao cartDao, UserDao userDao, ItemDao itemDao) {
this.cartDao = cartDao;
this.userDao = userDao;
this.itemDao = itemDao;
}
Copy link
Member Author

@JJ503 JJ503 May 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 Service 클래스와 다르게 CartService 클래스에서는 세 가지 dao를 모두 사용하고 있습니다.
하지만 UserDaod와 ItemDao는 각각의 서비스에서 사용되고 있는 상태입니다.
그래서 CartService에서도 다시 사용하는 것이 괜찮은지 궁금합니다.
혹은 서비스들을 합치는 것이 더 나을까요?

현재 제가 서비스를 나눈 기준은 테이블 별로 나눈 상태입니다.
춘식의 경우 서비스를 나누는 기준이 어떻게 되는지 궁금합니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한 서비스에서 쓰이는 dao를 다른 서비스에서 사용하는 건 괜찮습니다. 완전히 같은 기능이라면 서비스에서 다른 서비스를 호출해 사용해도 되지만 이 경우 순환참조가 일어나지 않도록 조심해야 합니다. find~ 처럼 핵심 기능이 아닌 검증의 용도라면 재사용을 하기도 합니다.

서비스는 프로젝트 크기에 따라 나누는 기준을 다르게 잡아도 됩니다. 지금은 도메인/엔티티 단위로 서비스를 작성하지만 서비스가 작다면 하나로 합쳐도 되고, 크다면 더 작게 쪼개도 됩니다.

저희 팀 같은 경우는 작은 도메인은 하나의 서비스를 두고 서비스 내에서 큰 분량을 가진 주제는 다른 서비스로 갈라지기도 합니다. OrderSearchService(조회), OrderService(저장, 업데이트, 삭제) 같은 식으로요. 테이블은 dao에서 조인될 때가 많아서 테이블보다는 도메인으로 나누는 편입니다 🙂

Copy link
Member Author

@JJ503 JJ503 May 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

답변 감사합니다!
해당 미션은 작은 서비스라 엔티티 단위로 쪼개도 괜찮지만 다른 미션에서 join을 사용하게 된다면 도메인별로 분리하는 연습도 해봐야겠군요!
cartDao에 join한 데이터를 넘기는 것과 cart 테이블의 한 행 자체를 반환하는 rowMapper가 존재해 서로 다른 도메인을 반환하는데 이 정도는 괜찮을까요? (cartDao 바로 가기)

public ResponseEntity<ItemResponse> loadItem(@PathVariable final Long itemId) {
ItemResponse item = itemService.loadItem(itemId);
return ResponseEntity.status(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(item);
}

@PutMapping("/{itemId}")
@PutMapping("{itemId}")
Copy link
Member Author

@JJ503 JJ503 May 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 시 사용할 수 있는 메서드는 Put과 Patch가 있는 것으로 알고 있습니다.
일단 원래 사용한 put을 사용했는데, 춘식의 경우 둘 중 어떤 메서드를 더 선호하시는지 궁금합니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put과 patch는 용도가 비슷하지만 엄연히 따지자면 구분이 되기 때문에 선호 여부를 떠나 적절한 http 메서드를 적용합니다. 이참에 put 과 patch의 차이를 한번 정리해보는건 어떨까요? 😎

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put의 경우는 수정할 데이터가 존재한다면 수정을, 없다면 해당 데이터를 추가하는 방식이군요. 그렇기에 put으로 요청할 경우 수정하고 싶은 데이터만이 아니라 생성 시와 동일한 형식으로 정보를 넘겨주어야 합니다.
반면, patch의 경우 부분적인 수정을 적용하기 위한 메서드로 모든 정보가 아닌 수정하려는 정보만 전달해 주면 됩니다. 그래서 제 경우 존재하는 데이터의 특정 부분만 수정만 하길 원하는 것이므로 patch가 더 잘 어울리겠네요!

그런데 아직 js에서 수정된 값만 보내주는 방법을 잘 모르겠네요… 🥲
또한, patch를 통해 값의 일부만 수정하는 것과 한 행의 모든 데이터를 덮어 씌우는 것이 많이 차이가 나는지도 궁금합니다.
결국 객체를 수정해 이를 dao에 넘겨주는 것으로 보이는데 이는 결국 한 행을 덮어 씌우는 것을 의미합니다.
그렇다면, 일부만 수정하다 null이 실수로 저장되는 것보다 put을 통해 모든 데이터를 가져와 수정하는 방법이 더 안전하지 않을까?라는 생각이 드는데 이는 크게 상관없는지, 그래도 개념적으로 통용해야 하므로 patch가 더 좋은 것인지 궁금합니다!

@JJ503 JJ503 changed the title 2단계 - 장바구니 기능] 제이미(임정수) 미션 제출합니다. [2단계 - 장바구니 기능] 제이미(임정수) 미션 제출합니다. May 8, 2023
Copy link

@bimppap bimppap left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 제이미, 춘식입니다!
2단계로 오면서 코드가 많이 발전했네요! 꼼꼼히 짠 코드에 감탄하고 갑니다ㅎㅎ 👍
질문에 대한 답과 코멘트 남겼으니 확인 부탁드립니다. 1단계에 추가적으로 남긴 질문에도 답을 달았으니 참고해주세요 🙂
궁금하거나 이해가 안 되는 점은 편하게 DM 주세요.

Comment on lines +11 to +13
public AuthorizationException(String message) {
super(message);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 사용하지 않고 있군요?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 에러가 나면 ControllerAdvice에서 핸들링을 어떻게 할까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 에러가 발생하게 되면 ControllerAdvice에서 핸들링하지 않고 있기에 500에러가 발생하는군요…!
인증과 관련된 문제는 입력과 동일하게 허용되는 예외 상황이라고 생각해 log.info로 동일하게 처리했습니다.

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String accessToken = request.getHeader("Authorization");
if (accessToken == null) {
throw new AuthorizationException();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서만 쓰이는 거 같은데 따로 메세지를 안 보내줘도 되는 걸까요? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외 메시지를 설정하는 것이 더 좋겠네요!
수정했습니다!


import javax.servlet.http.HttpServletRequest;

public class BasicAuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 컴포넌트로 등록하지 않아도 될까요?

Copy link
Member Author

@JJ503 JJ503 May 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 경우 BasicAuthenticationPrincipalArgumentResolver를 매번 객체를 새로 생성해서 사용했는데 빈에 등록하면 그럴 필요 없이 생성된 객체를 가져다 사용할 수 있겠네요!
해당 부분 적용해 보았습니다.

또한, LoginInterceptorBasicAuthorizationExtractor도 동일하게 처리해 주었습니다.
제가 잘 이해한 것인지 확인해 주시면 감사하겠습니다. (로직 바로 가기)

private String email;
private String password;

public AuthInfo(String email, String password) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthInfoDto라고 dto임을 명시해도 되지 않을까요? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entity 혹은 도메인과 헷갈리지 않기 위해선 명시하는 것이 좋겠네요!

String header = request.getHeader(AUTHORIZATION);

if (header == null) {
return null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인증이 안 된 경우인데 에러를 던져야 하는 거 아닐까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다..! 예외를 던져주도록 수정했습니다!

Comment on lines +46 to +53
private void validate(Builder builder) {
if (builder.email == null) {
throw new IllegalArgumentException("이메일은 빈 값일 수 없습니다.");
}
if (builder.password == null) {
throw new IllegalArgumentException("비밀번호는 빈 값일 수 없습니다.");
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검증도 꼼꼼하게 👍👍


import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@Transactional
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transactional 어노테이션을 붙이면 어떤 장점이 있을까요? readOnly=true를 붙이면 또 어떤 차이가 있을까요ㅎㅎ

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service에 Transactional 어노테이션 붙이게 되면 RuntmeException 혹은 오류가 발생한 경우 롤백을 다른 예외가 없을 경우 커밋을 수행해 준다고 알고 있습니다.

readOnly=true에 대한 부분은 공식 문서를 한번 살펴보았습니다.
공식 문서에는 아래와 같이 작성되어 있었습니다.

This just serves as a hint for the actual transaction subsystem; it will not necessarily cause failure of write access attempts. A transaction manager which cannot interpret the read-only hint will not throw an exception when asked for a read-only transaction but rather silently ignore the hint.

즉, 실제로 readOnly여서 조회 외에는 불가능하게 만드는 것은 아니만, 해당 메서드의 동작에 대한 힌트를 주는 것입니다.
그래서 클래스에 @Transactional (readOnly=true)를 붙여주어도 모든 메서드는 수행이 되긴 합니다.

하지만, 읽기에 최적화하므로 변경 여부에 대해 신경을 쓰지 않아 오류가 발생해도 롤백되지 않고 넘어가게 될 수도 있습니다.
대신 읽기에 있어선 트랜잭션 범위 최소화와 같인 불필요한 작업을 줄이기에 보다 성능이 향상된다는 장점이 있습니다.
또한, readOnly=true를 붙임으로써 해당 메서드의 역할의 힌트를 주어 좀 더 직관적으로 알 수 있다는 장점이 있습니다.

그런데 조사하다 보니 조회 외에는 아무 작업도 하지 수행하지 못한다는 이야기가 계속해 나오는데 제가 잘못 이해한 것인지 궁금합니다..!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DBSM별로 트랜잭션 어노테이션이 반영되는 방식이 다를 수 있기 때문에 똑같은 어노테이션이어도 다른 결과가 나올 수 있습니다 🙂
관련해서 참고하기 좋은 글이 있으니 한번 읽어보면 좋을 것 같네요!

<img src="" alt=""/> <!-- 상품 이미지 -->
<div class="cart-item-name"></div> <!-- 상품 이름 -->
<div class="cart-item-price"></div> <!-- 상품 가격 -->
<img src="${cartItem.imageUrl}" alt=""/> <!-- 상품 이미지 -->
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것만 지우면 완벽한 주석 킬러가 되겠네요 😎

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class LoginInterceptor extends HandlerInterceptorAdapter {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 ArgumentResolver가 LoginInterceptor의 기능도 하고 있다고 보는데 어떻게 생각하시나요? 따로 둔 이유가 있을까요?

Copy link
Member Author

@JJ503 JJ503 May 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 기능에 대한 제 생각에 대해 간단하게 말씀드리면 우선 Interceptor는 특정 url을 요청했을 때 가로채 인증을 수행합니다.
그래서 ArgumetResolver를 사용하지 않더라도 해당 페이지를 요청했을 때 header에 있는 Authorization으로 인증되어야 합니다.

반면, ArgumentResolver는 요청에 대해서 헤더의 Authorization 값과 같은 정보를 AuthInfoDto로 바인딩하는 것 기능을 수행합니다.
그래서 ArgumentResolver의 경우는 바인딩을 위해 header에 특정 데이터가 존재하는지만 확인하고 인증은 진행하지 않았습니다.

그런데 제 경우 Interceptor에서 실제 계정이 존재하는지에 대한 과정을 수행하지 않았기 때문에 두 기능이 비슷하다고 보였을 수 있겠네요!
Interceptor에 계정이 존재하는지에 대한 기능을 추가해 두었습니다! (로직 바로 가기)

제가 춘식의 질문을 제대로 이해한 것이 맞는지 궁금합니다.


이와 관련하여 추가적으로 여쭤보고 싶은 부분이 있습니다.
현재 LoginInterceptor에서는 해당 계정이 실제 있는지 확인해 줍니다.
그리고 CartService에서 장바구니 추가 및 제거, 조회를 수행할 때 다시 해당 계정이 존재하는지 확인하는 작업을 수행해 줍니다. (로직 바로 가기)
이때, 2차 검증을 수행해 주는 것에 대한 춘식의 의견이 궁금합니다!

현재 일단은 2차 검증을 수행하는 과정으로 두었습니다.
왜냐하면 웹으로 요청을 보내는 경우 Interceptor를 통해 인증을 수행할 것이지만, Service를 웹 요청이 아닌 다른 방식으로 사용하게 된다면 검증이 꼭 이루어져야 하기 때문입니다.

그런데 이러한 일이 일어날 수 있음을 가정하는 것이 맞을까요?
혹은 웹 서비스이기에 웹 요청에 대해서만 생각하고 Interceptor를 믿고 이후에는 굳이 검증을 하지 않아도 괜찮은 것일까요?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ArgumentResolver는 어떠한 요청이 컨트롤러에 들어왔을 때, 요청에 들어온 값으로부터 원하는 객체를 만들어내는 일을 간접적으로 도와주는 기능입니다. 꼭 헤더에서만 가져오는 건 아니에요!

Interceptor 가 인증을 위해 사용되는 것도 맞습니다. 다만 저는 핸들링하는 도메인이 같다면 ArgumentResolver 에서 객체를 만드는 과정에서 검증을 거치고 인터셉터는 생략을 해도 된다고 봐요. ArgumentResolver 가 잘못된 값을 줘서는 안 된다고 생각해서요 🤔 하지만 확장성을 고려하면 역할에 맞게 분리하는 게 좋긴 합니다ㅎㅎ

2차 검증에 대해서는 없는 것보단 있는 게 낫다라는 입장입니다! 다른 방식이 어떤 의미인지는 모르겠지만 이상적으로 검증 로직은 꼼꼼할 수록 좋습니다. 하지만 테스트 코드로 검증 로직 없이도 잘 돌아가는 걸 확신할 수 있다면 제외해도 좋습니다. 신뢰는 꼼꼼한 검증 로직으로도 얻을 수 있지만 꼼꼼한 테스트로도 얻을 수 있으니까요 👍

import static org.mockito.Mockito.*;

@WebMvcTest(CartController.class)
class CartControllerTest {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

125개... 멋집니다 👍

Comment on lines +20 to +30
private final RowMapper<Cart> cartRowMapper = (resultSet, rowNumber) -> new Cart.Builder()
.id(resultSet.getLong("id"))
.userId(resultSet.getLong("user_id"))
.itemId(resultSet.getLong("item_id"))
.build();
private final RowMapper<CartData> cartDataRowMapper = (resultSet, rowNumber) -> new CartData(
resultSet.getLong("id"),
new Name(resultSet.getString("name")),
new ImageUrl(resultSet.getString("image_url")),
new Price(resultSet.getInt("price"))
);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dao에서 서로 다른 도메인을 반환하는 경우
리뷰 답변 참고

Comment on lines +16 to +23
private final LoginInterceptor loginInterceptor;
private final BasicAuthenticationPrincipalArgumentResolver basicAuthenticationPrincipalArgumentResolver;

public WebMvcConfiguration(LoginInterceptor loginInterceptor,
BasicAuthenticationPrincipalArgumentResolver basicAuthenticationPrincipalArgumentResolver) {
this.loginInterceptor = loginInterceptor;
this.basicAuthenticationPrincipalArgumentResolver = basicAuthenticationPrincipalArgumentResolver;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빈 등록을 통한 의존성 주입
리뷰 답변 참고

Comment on lines +42 to +53
private void isValidBase64(String authHeaderValue) {
String exceptionMessage = "잘못된 인증 정보입니다.";
if (authHeaderValue.split(" ").length > 1) {
throw new AuthorizationException(exceptionMessage);
}
if (!authHeaderValue.matches(BASE64_REGEX)) {
throw new AuthorizationException(exceptionMessage);
}
if (authHeaderValue.length() % 3 != 0) {
throw new AuthorizationException(exceptionMessage);
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Base64 인코딩 값 유효성 검사
리뷰 답변 참고

Comment on lines +25 to +26
AuthInfoDto authInfoDto = basicAuthorizationExtractor.extract(request);
userService.isExistUser(authInfoDto);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interceptor

  • 계정이 존재하는지에 대한 검증 추가

리뷰 답변 참고

Comment on lines +35 to +41
Long userId = findUserIdByEmail(authInfoDto);
validateExistItem(itemId);
Cart cart = new Cart.Builder()
.userId(userId)
.itemId(itemId)
.build();
return cartDao.save(cart);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CartService

  • 사용자, 아이템 존재 여부에 대한 2차 검증

리뷰 답변 참고

@JJ503
Copy link
Member Author

JJ503 commented May 16, 2023

안녕하세요 춘식!
2단계 리뷰에 대한 피드백 적용 후 리뷰 재요청드립니다.

생각보다 리뷰 요청이 더 늦어져 죄송할 따름이네요... 😥
편하실 때 리뷰 해주시면 감사하겠습니다..!

또한, 춘식이 남겨준 질문들과 추가적인 의문점들에 대해선 코멘트로 작성해 두었습니다.
확인해 주시면 감사하겠습니다.

이번 리뷰도 잘 부탁드립니다 😊

Copy link

@bimppap bimppap left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 제이미, 춘식입니다!
제가 일이 많아서 답이 늦었네요ㅠㅠ리팩토링 깔끔하게 잘 해주셨어요!
피드백에 대해 많이 찾아보고 고민한 게 느껴져서 저도 공부가 된 것 같네요ㅎㅎ
장바구니 미션은 여기서 마무리하도록 하겠습니다. 고생하셨습니다!
질문에 대한 답에 코멘트들 짧게 달아놨으니 확인 부탁드립니다.
남은 미션들도 파이팅입니다! 👍

@bimppap bimppap merged commit 0768d3f into woowacourse:jj503 May 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants