7주 차 스터디에서는 User 도메인에 있는 API들에 대해서 코드 리팩토링과 쿼리 최적화를 진행했다.
집콕 서비스는 카카오 로그인을 하기 때문에 내부적으로 아이디/비번을 다루지 않는다. 카카오에서 인증을 마치고 이메일과 회원가입 형식을 입력하면 JWT token을 발급해 주는 형식으로 이루어져 있다.
User 도메인에 있는 API는 총 10개로, 다음과 같다.
프로젝트를 한차례 진행해보고 나니 API를 이렇게 많이 만들 필요가 없었다는 것을 이제 느낀다.
당시에 만들었던 API 들을 보면, 마이페이지 API, 마이페이지 수정 페이지 API 등 (둘 다 user의 정보를 불러오는 API이다.) 이렇게 비슷한 기능을 하는 API를 분리시킬 필요가 없었다. 둘 다 프론트 경험이 없다 보니 정보를 캐싱해 두고 쓴다는 개념을 몰랐다. 그래서 페이지마다 필요한 정보를 제공하는 API를 전부 만들었던 것이다.
막상 프로젝트를 진행하니, 프론트에서는 초기 로그인할 때 User 정보를 가져오는 API를 하나 호출해서 저장해 두고 계속 가져다 쓰는 방식으로 진행했다. 그래서 우리가 여러 개로 분리시킨 API들이 전부 쓰이지 않았다. 이래서 프론트와의 소통이 중요하다고들 하는 것 같다. 우리가 API를 설계할 때는 그냥 백엔드끼리 피그마의 페이지들만 보고 API를 정해서 통보하는 식으로 했기 때문이다.
그러니 프론트에서도 이해가 되지 않는 API 질문이 많았고, 우리도 쓰이지 않을 API를 만들어서 생산성이 좀 떨어졌던 것 같다. 한 번 몸소 깨달았으니 다신 그러지 말자.
반성은 이쯤하고 다시 코드 리팩토링으로 돌아가자.
0️⃣ Controller 예외처리 리팩토링
개선 전
코드 before refactoring
@Operation(summary = "온보딩정보 입력 API", description = "회원가입 후, 온보딩 정보를 입력하는 API입니다.")
@PatchMapping("")
public BaseResponse<Object> onBoarding(@Parameter(hidden=true) @PreAuthorize long userId, @Validated @RequestBody PatchOnBoardingRequest patchOnBoardingRequest, BindingResult bindingResult){
log.info("{UserController.onBoarding}");
System.out.println(patchOnBoardingRequest.toString());
if(bindingResult.hasFieldErrors("address")){
throw new OnBoardingBadRequestException(ADDRESS_OVER_LENGTH);
}
if(bindingResult.hasFieldErrors("latitude") || bindingResult.hasFieldErrors("longitude")){
throw new OnBoardingBadRequestException(INVALID_LAT_LNG);
}
if(bindingResult.hasFieldErrors("mpriceMin") ||
bindingResult.hasFieldErrors("mdepositMin") ||
bindingResult.hasFieldErrors("ydepositMin") ||
bindingResult.hasFieldErrors("purchaseMin")){
throw new OnBoardingBadRequestException(INVALID_MIN_PRICE);
}
if(bindingResult.hasFieldErrors("mpriceMax") ||
bindingResult.hasFieldErrors("mdepositMax") ||
bindingResult.hasFieldErrors("ydepositMax") ||
bindingResult.hasFieldErrors("purchaseMax")){
throw new OnBoardingBadRequestException(INVALID_MAX_PRICE);
}
if(bindingResult.hasFieldErrors("realEstateType")){
throw new OnBoardingBadRequestException(INVALID_INTEREST_TYPE);
}
if(bindingResult.hasFieldErrors("transactionType")){
throw new OnBoardingBadRequestException(INVALID_TRANSACTION_TYPE);
}
if(bindingResult.hasFieldErrors("isSmallerthanMax")){
throw new OnBoardingBadRequestException(MIN_IS_BIGGER_THAN_MAX);
}
if(bindingResult.hasErrors()){
throw new OnBoardingBadRequestException(BAD_REQUEST, getErrorMessages(bindingResult));
}
return new BaseResponse(MEMBER_INFO_UPDATE_SUCCESS, this.userService.setOnBoarding(patchOnBoardingRequest, userId));
}
기존 Controller 코드를 보면 Spring Validation을 사용하고 있다. @Validated 어노테이션으로 검증을 하고 결과를 BindingResult에 저장해 둔다. 당시에 우리는 유효성 검사를 하기 위해서 Controller 내부에서 BindingResult에 hasFieldError 메서드를 사용해 조건문으로 어떤 필드에서 오류가 발생했는지를 판단하고 Exception을 throw 시켰다. 이런 방식은 Controller를 더럽게 할뿐더러 많은 중복 코드를 가진다.
그래서 이번에 주언이 형이 리팩토링한 방법을 소개하려고 한다.
개선 후
코드 after refactoring
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ValidationErrorResponse handle_UserValidationException(MethodArgumentNotValidException e) {
log.error("[handle_UserValidationException]", e);
List<FieldErrorDetail> fieldErrors = processFieldErrors(e);
fieldErrors.forEach(fieldErrorDetail -> {
log.info("[fieldName, message, code] {} {} {}", fieldErrorDetail.getField(), fieldErrorDetail.getCode(), fieldErrorDetail.getMessage());
});
return new ValidationErrorResponse(INVALID_FIELD_FORMAT, fieldErrors);
}
private List<FieldErrorDetail> processFieldErrors(MethodArgumentNotValidException e) {
List<FieldErrorDetail> fieldErrors = new ArrayList<>();
e.getBindingResult().getFieldErrors().forEach(fieldError -> {
String fieldName = fieldError.getField();
log.info("[processFieldName] {}", fieldName);
BaseExceptionResponseStatus errorStatus = FIELD_ERROR_MAP.getOrDefault(fieldName, INVALID_FIELD_FORMAT);
fieldErrors.add(new FieldErrorDetail(fieldName, errorStatus.getCode(), errorStatus.getMessage()));
});
return fieldErrors;
}
@Validated로 검증한 필드에서 오류가 터지면 MethodArgumentNotValidException이 발생한다고 한다. 즉 우리가 따로 exception을 던질 필요가 없다는 것이다. Exception Handler에서 이 exception에 대해서 잡아주기만 하면 된다. MethodArgumentNotValidException에는 어떤 필드에서 오류가 발생했는지를 알 수 있고, 우리는 기존에 {필드명, ErrorCode}로 정의해 놓은 map에서 mapping 해서 넘겨주면 되는 것이다. 이것 덕분에 controller가 전부 깔끔해졌고 책임도 하나만 가질 수 있게 되었다.
1️⃣ User 생성 (POST User)
User를 생성하는 API이다.
카카오 서버로부터 인증을 받고 이메일을 받아오면, 서버에서 회원 유무를 판단해서 응답해 준다. 이때 회원이 아니라면 회원가입 페이지로 넘어가게 되는데 여기서 생성할 회원 정보를 입력하고 요청하는 API이다.
개선 전
코드 before refactoring
@Transactional
public AuthTokens signUp(PostSignUpRequest postSignUpRequest) {
log.info("{UserService.signUp}");
String email = postSignUpRequest.getEmail();
OAuthProvider oAuthProvider = postSignUpRequest.getOauthProvider();
String nickname = postSignUpRequest.getNickname();
Gender gender = postSignUpRequest.getGender();
String birthday = postSignUpRequest.getBirthday();
User user = new User(email, oAuthProvider, nickname, gender,birthday);
//user 생성하면서 연관된 table 열도 생성
user.setDesireResidence(new DesireResidence(user));
user.setTransactionPriceConfig(new TransactionPriceConfig(user));
user.setHighlights(this.makeDefaultHighlights(user));
user.setOptions(this.makeDefaultOptions(user));
user.setImpressions(this.makeDefaultImpressions(user));
this.userRepository.save(user);
long userId = this.userRepository.findByEmail(email).getUserId();
//token 발행
AuthTokens authTokens = jwtProvider.createToken(email, userId);
redisService.setValues(email, authTokens.getRefreshToken(), Duration.ofMillis(jwtProvider.getREFRESH_TOKEN_EXPIRED_IN()));
return authTokens;
}
이 코드에서는 메소드의 책임 단일화가 잘 되지 않았다. signUp이라는 메서드에서 user 도 생성하고, token 도 발행하면서 redis에 token을 저장하고 있다. 이는 signUp이라는 메서드에 여러 책임이 들어가게 된 것이라고 볼 수 있다.
그리고 user 를 생성하는 데 있어서 dto에서 값을 하나하나 들고 와서 user의 필드에 넣어주는 것이 좀 보기 좋지 않다. 이를 DTO 내부에 메서드로 빼서 책임을 넘기는 것이 좋을 것 같다.
그리고 user 마다 초기에 세팅되는 값들이 필요한데, (kok을 만들 때 보일 option들) makeDefaultHighlights, makeDefaultOptions, makeDefaultImpressions를 사용해서 default 값들을 생성해주고 있다. 이 메서드들은 UserService 클래스 내부에 별도의 메서드로 분리되어 있는데 UserService라는 클래스 내부에 있기에는 역할이 좀 다르다. 이것도 각 Entity 클래스의 내부 static 메서드로 분리시키는 것이 좋을 것 같다.
쿼리 관련해서 개선 시킬 사항이 많아 보인다. makeDefault~~ 메서드들을 실행할 때 많은 option 항목들을 생성해 주는데 이때 각 요소마다 (table의 tuple 하나마다) INSERT 쿼리가 발생해서 효율이 좋지 않았다. 현재는 유저를 한 번 생성할 때마다 112개의 INSERT 쿼리가 발생한다. 😩
개선 후
코드 after refactoring
@Transactional
public AuthTokens signUp(PostSignUpRequest postSignUpRequest){
log.info("{UserService.signUp}");
User user = createUser(postSignUpRequest);
AuthTokens authTokens = makeJwtToken(user);
addRedisEntry(user, authTokens);
return authTokens;
}
private User createUser(PostSignUpRequest postSignUpRequest) {
log.info("{UserService.createUser}");
User user = postSignUpRequest.toEntity();
userRepository.save(user);
DesireResidence desireResidence = new DesireResidence(user);
TransactionPriceConfig transactionPriceConfig = new TransactionPriceConfig(user);
desireResidenceRepository.save(desireResidence);
transactionPriceConfigRepository.save(transactionPriceConfig);
makeDefaultUserInfo(user);
return user;
}
private void makeDefaultUserInfo(User user) {
List<Highlight> highlights = Highlight.makeDefaultHighlights(user).stream().toList();
highlightBulkJdbcRepository.saveAll(highlights);
List<Option> options = Option.makeDefaultOptions(user);
optionBulkJdbcRepository.saveAll(options);
List<Impression> impressions = Impression.makeDefaultImpressions(user).stream().toList();
impressionBulkJdbcRepository.saveAll(impressions);
List<Option> savedOptions = optionRepository.findAllByUserId(user.getUserId());
List<DetailOption> detailOptions = DetailOption.makeDefaultDetailOptions(savedOptions);
detailOptionBulkJdbcRepository.saveAll(detailOptions);
}
private AuthTokens makeJwtToken(User user) {
return jwtProvider.createToken(JwtUserDetails.from(user));
}
private void addRedisEntry(User user, AuthTokens authTokens){
redisService.setValues(user.getEmail(), authTokens.getRefreshToken(), Duration.ofMillis(jwtProvider.getREFRESH_TOKEN_EXPIRED_IN()));
}
우선 메소드들을 분리시켰다. createUser 메서드에서는 유저를 생성하는 책임을 맡는다. User를 생성해 두고 Default로 생성해야 하는 부분에 대해서는 또 makeDefaultUserInfo라는 메서드로 분리시켰다.
token 관련은 makeJwtToken이라는 메서드로, redis는 addRedisEntry라는 메서드로 분리시켰다.
그리고 makeDefault~~ 함수들의 위치를 UserService 클래스에서 각 Entity 클래스로 이동시켜서 각 메서드가 하는 역할에 맞게 해당 클래스를 접근해서 호출되도록 리팩터링 했다.
쿼리 관련해서는 Bulk Insert 를 사용했다. Bulk Insert를 사용하면 INSERT 쿼리가 각 tuple 마다 발생되는 것이 아니라 한 번의 쿼리로 발생된다. (이 내용에 관해서는 따로 포스팅을 하는 것이 좋겠다.) 아무튼 우리는 @GeneratedValue(strategy = GenerationType.IDENTITY) 이 방식을 사용하기 때문에 Hibernate에서 제공하는 bulk insert가 제공되지 않는다. @Sequence 나 @Table 어노테이션을 사용하면 가능하지만 @Sequence는 MySQL에서 지원하지 않고, @Table은 키 생성을 위한 table이 생길 수밖에 없다는 점에서 맞지 않았다.
그래서 Hibernate에서 제공하는 bulk insert는 사용할 수가 없었고 JDBCTemplate의 BatchUpdate 메서드를 사용했다. (여기에 대한 자세한 내용은 따로 포스팅에서 다루는 것이 좋을 것 같다.)
근데 문제가 하나 있었다. Bulk INSERT를 사용하면 생성된 Entity에 대한 PK 값을 바로 알 수 없다는 것이다. 우리는 Option들을 생성해주고, 해당 Option의 PK를 FK로 가지는 DetailOption을 또 생성해주어야 했다. 여기서 DB에 생성된 Option의 PK 값을 어떻게 알 수 있을까?
재연이 형은 Option을 생성할 때 PK 값을 임의로 지정해주었다. Option의 필드 값들을 조합해서 PK를 생성하는 방식으로 했다. 나랑 주언이 형은 Option을 한 번 조회하는 쿼리를 한 번 더 발생시켰다. 나랑 주언이 형은 쿼리 발생이 어쩔 수 없는 것으로 보았고, 재연이 형은 어떻게든 해결해보려고 한 것 같다.
하지만 PK 생성 방식이 Unique 한 PK를 생성하는지도 의문이었고, Option만 다른 방식으로 PK를 생성하는 예외를 두고 싶지 않아서 결국 bulk insert로 생성한 Option들을 한 번 더 조회하는 쿼리를 한 번 더 발생시켰다.
TPS
위의 사진은 refactoring 전이고 아래는 refactoring 후이다.
TPS가 279 -> 368 로 향상되었다.
2️⃣ User 수정 (PATCH User)
회원가입 후 user에 대한 온보딩 정보를 입력받는다. 이때 이 정보들을 저장하기 위한 API이다.
온보딩에서 입력받는 정보가 굉장히 많은데 이 정보들을 User Table에 다 저장하는 것이 아니라 다른 Table로 분리 시켰다. User와 일대일 관계를 가지는 DesireResidence와 TransactionPriceConfig이다. 각각 희망거주정보, 희망거래정보를 나타낸다.
개선 전
코드 before refactoring
@Transactional
public Objects setOnBoarding(PatchOnBoardingRequest patchOnBoardingRequest, long userId) {
log.info("{UserService.setOnBoarding}");
String address = patchOnBoardingRequest.getAddress();
double latitude = patchOnBoardingRequest.getLatitude();
double longitude = patchOnBoardingRequest.getLongitude();
RealEstateType realEstateType = patchOnBoardingRequest.getRealEstateType();
TransactionType transactionType = patchOnBoardingRequest.getTransactionType();
long mpriceMin = patchOnBoardingRequest.getMpriceMin();
long mpriceMax = patchOnBoardingRequest.getMpriceMax();
long mdepositMin = patchOnBoardingRequest.getMdepositMin();
long mdepositMax = patchOnBoardingRequest.getMdepositMax();
long ydepositMin = patchOnBoardingRequest.getYdepositMin();
long ydepositMax = patchOnBoardingRequest.getYdepositMax();
long purchaseMin = patchOnBoardingRequest.getPurchaseMin();
long purchaseMax = patchOnBoardingRequest.getPurchaseMax();
//User table에 realEstateType 수정
User user = this.userRepository.findByUserId(userId);
user.setRealEstateType(realEstateType);
user.setTransactionType(transactionType);
this.userRepository.save(user);
//희망 거주지 table 수정
DesireResidence desireResidence = this.desireResidenceRepository.findByUser(user);
desireResidence.setAddress(address);
desireResidence.setLatitude(latitude);
desireResidence.setLongitude(longitude);
this.desireResidenceRepository.save(desireResidence);
//거래가격설정 table 수정
TransactionPriceConfig transactionPriceConfig = this.transactionPriceConfigRepository.findByUser(user);
transactionPriceConfig.setMPriceMin(mpriceMin);
transactionPriceConfig.setMPriceMax(mpriceMax);
transactionPriceConfig.setMDepositMin(mdepositMin);
transactionPriceConfig.setMDepositMax(mdepositMax);
transactionPriceConfig.setYDepositMin(ydepositMin);
transactionPriceConfig.setYDepositMax(ydepositMax);
transactionPriceConfig.setPurchaseMin(purchaseMin);
transactionPriceConfig.setPurchaseMax(purchaseMax);
this.transactionPriceConfigRepository.save(transactionPriceConfig);
return null;
}
지금 코드를 보면, DTO에서 정보들을 하나씩 뽑아서 로컬 변수에 저장한 뒤에, Entity에 하나하나 매핑하는 작업을 하고 있다.
각 클래스의 필드값 매핑해주는 작업을 각 클래스의 내부 메서드로 리팩터링 해주면 좋을 것 같다.
쿼리는 현재 6개가 발생한다. User 를 조회할 때 하나가 발생하고, DesireResidence와 TransactionPriceConfig가 일대일 매핑이라 EAGER 로딩에 의해 2개가 발생하고, 각 table 별로 Update 쿼리가 하나씩 발생해서 총 6개의 쿼리가 생긴다. Update 쿼리는 어쩔 수 없지만 각 table을 조회하는 쿼리는 fetch join으로 한 번에 들고 올 수 있을 것 같다.
개선 후
코드 after refactoring
@Transactional
public Objects setOnBoarding(PatchOnBoardingRequest patchOnBoardingRequest, JwtUserDetails jwtUserDetails) {
log.info("{UserService.setOnBoarding}");
User user = userRepository.findByUserIdWithDesireResidenceAndTransactionPriceConfig(jwtUserDetails.getUserId());
user.setOnBoardingInfo(patchOnBoardingRequest);
userRepository.save(user);
return null;
}
User entity의 setOnBoardingInfo 라는 메서드를 통해 User의 필드 값 수정 책임을 넘겨주었고, setOnBoardingInfo 메서드 안에서도 DesireResidence와. TransactionPriceConfig의 필드 값을 수정하는 setDesireResidenceInfo와 setTransactionPriceConfig 메서드를 호출해 준다. 2개의 메서드 역시 각각 DesireResidence와 TransactionPriceConfig 클래스 내부에 정의된 메서드이다.
쿼리도 fetch join을 사용해서 각 table을 조회하는 쿼리가 3개에서 1개로 줄었다. User를 조회할 때마다 fetch join 해야하는 table들이 달라서 메서드들을 분리시켰다. 여기서는 findByUserIdWithDesireResidenceAndTransactionPriceConfig라는 메서드로 정의했다. 메서드 명이 너무 길긴 하다..
TPS
249 -> 352 로 향상되었다.
3️⃣ User 조회 (GET User 마이페이지)
마이페이지를 클릭했을 때 보여질 정보들을 제공하는 API이다.
앞서 언급했듯이 이 API는 굳이 필요하지 않다. (어차피 프론트에서는 user 정보를 캐싱해서 쓰기 때문) 그럼에도 공부도 할 겸, 만들었던 API에 애정도 남아있고 해서 리팩토링 해봤다.
개선 전
코드 before refactoring
public GetMyPageResponse myPageLoad(long userId) {
log.info("{UserService.myPageLoad}");
//repository로부터 객체 가져오기
User user = this.userRepository.findByUserId(userId);
TransactionPriceConfig transactionPriceConfig = this.transactionPriceConfigRepository.findByUser(user);
//return 할 dto 선언
GetMyPageResponse getMyPageResponse = new GetMyPageResponse();
//dto field 값 set
getMyPageResponse.setNickname(user.getNickname());
getMyPageResponse.setImageUrl(user.getProfileImgUrl());
getMyPageResponse.setRealEstateType(user.getRealEstateType() == null ? null : user.getRealEstateType().toString());
getMyPageResponse.setAddress(this.desireResidenceRepository.findByUser(user).getAddress());
String transactionType = null;
//관심매물유형에 따라 dto field 값 set 작업 분기처리
if(user.getTransactionType() == null){
getMyPageResponse.setTransactionType(null);
getMyPageResponse.setPriceMax(null);
getMyPageResponse.setPriceMin(null);
getMyPageResponse.setDepositMax(null);
getMyPageResponse.setDepositMin(null);
}
else{
getMyPageResponse.setTransactionType(user.getTransactionType().toString());
transactionType = user.getTransactionType().getDescription();
if(transactionType.equals("월세")){
getMyPageResponse.setPriceMax(transactionPriceConfig.getMPriceMax());
getMyPageResponse.setPriceMin(transactionPriceConfig.getMPriceMin());
getMyPageResponse.setDepositMax(transactionPriceConfig.getMDepositMax());
getMyPageResponse.setDepositMin(transactionPriceConfig.getMDepositMin());
}
else if(transactionType.equals("전세")){
getMyPageResponse.setDepositMax(transactionPriceConfig.getYDepositMax());
getMyPageResponse.setDepositMin(transactionPriceConfig.getYDepositMin());
}
else if(transactionType.equals("매매")){
getMyPageResponse.setPriceMax(transactionPriceConfig.getPurchaseMax());
getMyPageResponse.setPriceMin(transactionPriceConfig.getPurchaseMin());
}
}
return getMyPageResponse;
}
당시 코드는 User와 TransactionPriceConfig, DesireResidence 를 각각 조회해서 DTO에 필드 값을 매핑하는 식으로 코드가 작성되어 있다. 즉 myPageLoad라는 메서드에 책임이 너무 많다. DTO 매핑하는 작업을 DTO 내부 메서드로 빼내줄 생각이다.
User, DesireResidence, TransactionPriceConfig를 각각 조회하기 때문에 쿼리가 3개 발생한다. 이전 API를 위해 만들어두었던 findByUserIdWithDesireResidenceAndTransactionPriceConfig 메서드를 쓰면 쿼리 하나로 줄일 수 있을 것 같다.
개선 후
코드 after refactoring
public GetMyPageResponse myPageLoad(JwtUserDetails jwtUserDetails) {
log.info("{UserService.myPageLoad}");
User user = userRepository.findByUserIdWithDesireResidenceAndTransactionPriceConfig(jwtUserDetails.getUserId());
return GetMyPageResponse.from(user);
}
분석한 내용 그대로 리팩토링을 진행했다. 코드가 굉장히 간결해진 것을 확인할 수 있다.
TPS
251 -> 371 로 향상되었다.
4️⃣ User 조회 (GET User 마이페이지 수정페이지)
마이페이지 수정하기 버튼을 눌렀을 때 보여질 정보들을 위한 API이다.
위의 마이페이지 API와는 달리 더 많은 유저 정보를 제공한다. 프론트에서는 위의 API를 쓰는 게 아니라 이 API를 로그인할 때 사용해서 정보를 저장해 두고 계속 쓰는 것 같다. 리팩토링 해보자.
개선 전
코드 before refactoring
public GetMyPageDetailResponse myPageDetailLoad(long userId) {
log.info("{UserService.myPageDetailLoad}");
User user = this.userRepository.findByUserId(userId);
TransactionPriceConfig transactionPriceConfig = this.transactionPriceConfigRepository.findByUser(user);
GetMyPageDetailResponse getMyPageDetailResponse = new GetMyPageDetailResponse();
getMyPageDetailResponse.setImageUrl(user.getProfileImgUrl());
getMyPageDetailResponse.setNickname(user.getNickname());
getMyPageDetailResponse.setBirthday(user.getBirthday());
getMyPageDetailResponse.setGender(user.getGender());
getMyPageDetailResponse.setAddress(this.desireResidenceRepository.findByUser(user).getAddress());
getMyPageDetailResponse.setRealEstateType(user.getRealEstateType() == null ? null : user.getRealEstateType().toString());
if (user.getTransactionType() == null) {
getMyPageDetailResponse.setTransactionType(null);
} else {
getMyPageDetailResponse.setTransactionType(user.getTransactionType().toString());
}
getMyPageDetailResponse.setMpriceMin(transactionPriceConfig.getMPriceMin());
getMyPageDetailResponse.setMpriceMax(transactionPriceConfig.getMPriceMax());
getMyPageDetailResponse.setMdepositMin(transactionPriceConfig.getMDepositMin());
getMyPageDetailResponse.setMdepositMax(transactionPriceConfig.getMDepositMax());
getMyPageDetailResponse.setYdepositMin(transactionPriceConfig.getYDepositMin());
getMyPageDetailResponse.setYdepositMax(transactionPriceConfig.getYDepositMax());
getMyPageDetailResponse.setPriceMin(transactionPriceConfig.getPurchaseMin());
getMyPageDetailResponse.setPriceMax(transactionPriceConfig.getPurchaseMax());
getMyPageDetailResponse.setLatitude(user.getDesireResidence().getLatitude() == null ? null :user.getDesireResidence().getLatitude());
getMyPageDetailResponse.setLongitude(user.getDesireResidence().getLongitude()== null ? null :user.getDesireResidence().getLongitude());
return getMyPageDetailResponse;
}
위의 API 와 개선사항이 똑같다. DTO 매핑 로직을 DTO 내부의 from 메서드로 만들고, 테이블을 조회할 때 fetch join으로 한 번에 가져온다.
개선 후
코드 after refactoring
public GetMyPageDetailResponse myPageDetailLoad(JwtUserDetails jwtUserDetails) {
log.info("{UserService.myPageDetailLoad}");
User user = userRepository.findByUserIdWithDesireResidenceAndTransactionPriceConfig(jwtUserDetails.getUserId());
return GetMyPageDetailResponse.from(user);
}
쿼리가 3개에서 1개로 줄었고, 코드도 간결해졌다.
TPS
1491 -> 2236 으로 향상되었다. 이렇게까지 향상될 일인가..??
5️⃣ User 수정 (PUT User 마이페이지 수정)
마이페이지 수정페이지에서 수정된 정보를 저장하는 API이다.
개선 전
코드 before refactoring
@Transactional
public Object updateMyInfo(long userId, MultipartFile file, PutUpdateMyInfoRequest putUpdateMyInfoRequest) {
log.info("{UserService.updateMyInfo}");
User user = this.userRepository.findByUserId(userId);
if(file != null) {
String url = this.fileUploadUtils.uploadFile(user.getUserId().toString() + "/profile", file);
if(url == null){
throw new FileUploadException(CANNOT_SAVE_FILE);
}
user.setProfileImgUrl(url);
}
user.setNickname(putUpdateMyInfoRequest.getNickname());
user.setBirthday(putUpdateMyInfoRequest.getBirthday());
user.setGender(putUpdateMyInfoRequest.getGender());
user.setRealEstateType(putUpdateMyInfoRequest.getRealEstateType());
user.setTransactionType(putUpdateMyInfoRequest.getTransactionType());
DesireResidence desireResidence = user.getDesireResidence();
desireResidence.setAddress(putUpdateMyInfoRequest.getAddress());
desireResidence.setLatitude(putUpdateMyInfoRequest.getLatitude());
desireResidence.setLongitude(putUpdateMyInfoRequest.getLongitude());
TransactionPriceConfig transactionPriceConfig = user.getTransactionPriceConfig();
transactionPriceConfig.setMPriceMin(putUpdateMyInfoRequest.getMpriceMin());
transactionPriceConfig.setMPriceMax(putUpdateMyInfoRequest.getMpriceMax());
transactionPriceConfig.setMDepositMin(putUpdateMyInfoRequest.getMdepositMin());
transactionPriceConfig.setMDepositMax(putUpdateMyInfoRequest.getMdepositMax());
transactionPriceConfig.setYDepositMin(putUpdateMyInfoRequest.getYdepositMin());
transactionPriceConfig.setYDepositMax(putUpdateMyInfoRequest.getYdepositMax());
transactionPriceConfig.setPurchaseMin(putUpdateMyInfoRequest.getPurchaseMin());
transactionPriceConfig.setPurchaseMax(putUpdateMyInfoRequest.getPurchaseMax());
this.userRepository.save(user);
return null;
}
당시에 코드는 updateMyInfo 메서드에 많은 책임이 가중되어 있다. 각 Entity의 필드 값도 수정하고 이미지 파일 여부에 따라 S3 수정도 하고 있다.
쿼리는 User, DesireResidence, TransactionPriceConfig 를 조회하고 수정하는데 총 6번이 발생했다.
개선 후
코드 after refactoring
@Transactional
public Object updateMyInfo(JwtUserDetails jwtUserDetails, MultipartFile file, PutUpdateMyInfoRequest putUpdateMyInfoRequest) {
log.info("{UserService.updateMyInfo}");
User user = this.userRepository.findByUserIdWithDesireResidenceAndTransactionPriceConfig(jwtUserDetails.getUserId());
String imageUrl = settingProfileImage(jwtUserDetails.getUserId(), file);
user.setUpdateUserInfo(imageUrl, putUpdateMyInfoRequest);
this.userRepository.save(user);
return null;
}
함수 책임 단일화를 진행했다. 이미지 판단을 settingProfileImage라는 메서드로 분리했고, 각 Entity 필드 값을 수정하는 로직도 각 클래스 내부 메서드로 옮겼다.
쿼리는 User를 조회하면서 DesireResidence와 TransacitionPriceConfig를 fetch join 하는 함수를 사용했다.
TPS
245 -> 388로 향상되었다.
6️⃣ User 조회 (GET User가 설정한 Kok 정보 불러오기)
마이페이지에서 Kok 설정 정보를 수정하는 옵션이 있는데 이 버튼을 클릭하면 Kok 설정 정보를 수정할 수 있는 페이지로 이동한다.
이때 기존 설정 정보를 불러오기 위한 API이다.
개선 전
코드 before refactoring
@Transactional
public GetKokOptionLoadResponse loadKokOption(long userId) {
log.info("{UserService.kokOptionLoad}");
//model 객체 호출
User user = this.userRepository.findByUserId(userId);
List<Highlight> highlightList = this.highlightRepository.findAllByUser(user);
List<Option> optionList = this.optionRepository.findAllByUser(user);
//exception 처리
if(highlightList == null || optionList == null){
throw new KokOptionLoadException(MEMBER_LIST_ITEM_QUERY_FAILURE);
}
GetKokOptionLoadResponse getKokOptionLoadResponse = new GetKokOptionLoadResponse();
//dto에 highlight 정보 삽입
for(Highlight highlight : highlightList){
getKokOptionLoadResponse.addHighlight(highlight.getTitle());
}
//dto에 option 정보 삽입
List<GetKokOptionLoadResponse.Option> outerOptions = new ArrayList<>();
List<GetKokOptionLoadResponse.Option> innerOptions = new ArrayList<>();
List<GetKokOptionLoadResponse.Option> contractOptions = new ArrayList<>();
for(Option option : optionList){
//dto에 detailOption 정보 삽입
List<DetailOption> detailOptionList = this.detailOptionRepository.findAllByOption(option);
List<GetKokOptionLoadResponse.DetailOption> detailOptionList1 = new ArrayList<>();
for(DetailOption detailOption : detailOptionList){
detailOptionList1.add(new GetKokOptionLoadResponse.DetailOption(detailOption.getDetailOptionId(), detailOption.getName(), detailOption.isVisible()));
}
//for문 해당 option이 outerOption 인지, innerOption 인지, contractOption 인지 판단 --> dto에 삽입
if(option.getCategory().equals(OptionCategory.OUTER)){
outerOptions.add(new GetKokOptionLoadResponse.Option(option.getOptionId(), option.getName(), option.getOrderNum(), option.isVisible(), detailOptionList1));
}
else if(option.getCategory().equals(OptionCategory.INNER)){
innerOptions.add(new GetKokOptionLoadResponse.Option(option.getOptionId(), option.getName(), option.getOrderNum(), option.isVisible(), detailOptionList1));
}
else if(option.getCategory().equals(OptionCategory.CONTRACT)){
contractOptions.add(new GetKokOptionLoadResponse.Option(option.getOptionId(), option.getName(), option.getOrderNum(), option.isVisible(), detailOptionList1));
}
}
getKokOptionLoadResponse.setOuterOptions(outerOptions);
getKokOptionLoadResponse.setInnerOptions(innerOptions);
getKokOptionLoadResponse.setContractOptions(contractOptions);
return getKokOptionLoadResponse;
}
DTO에 매핑하는 로직을 분리시킬 필요가 있을 것 같다.
그리고 Option list를 반복문으로 돌면서 각 Option 에 대해서 DetailOption을 조회하는 로직이 있어서 불필요한 쿼리가 많이 발생되었다.
개선 후
코드 after refactoring
@Transactional
public GetKokOptionLoadResponse loadKokOption(JwtUserDetails jwtUserDetails) {
log.info("{UserService.loadKokOption}");
List<Highlight> highlightList = highlightRepository.findAllByUserId(jwtUserDetails.getUserId());
List<Option> optionList = optionRepository.findAllByUserIdWithDetailOption(jwtUserDetails.getUserId());
return GetKokOptionLoadResponse.of(highlightList, optionList);
}
우선 DTO 매핑을 DTO 내부에 of 메서드로 만들어서 해결했다.
그리고 DTO에 필요한 정보를 가져오기 위해서 Highlight와 Option, DetailOption을 DB를 조회해야 했는데 여기서 스터디 원들 간 방법의 차이가 조금 있었다.
우선 나는 User를 조회하면서 Highlight와 Option을 fetch join 했다. 그랬더니 DetailOption 정보를 가져올 때 Option을 반복문으로 돌지 않고 한 번에 가져오기 위해 좀 힘들었다. 로직이 좀 복잡해졌다.
반면에 재연이 형은 위 코드와 같이 Highlight와 Option을 따로 조회하되, Option을 조회할 때 DetailOption을 fetch join 하도록 했다. 그랬더니 코드도 간결해지고 한눈에 알아보기 쉬워서 재연이 형 방법을 택했다.
DetailOption을 fetch join으로 들고 오기 때문에 쿼리가 많이 줄었다. 기존에는 Option의 개수만큼 DetailOption을 조회하는 쿼리가 발생되었는데 지금은 쿼리가 2개만 발생한다.
TPS
635 -> 1406 으로 2배 이상 향상되었다.
이건 쿼리를 많이 줄였기 때문에 이정도의 향상이 합리적으로 보인다.
7️⃣ User 수정 (PUT User가 설정한 Kok 정보 수정하기)
User 설정 Kok 리스트 수정 페이지에서 수정된 정보를 저장하는 API이다.
개선 전
코드 before refactoring
@Transactional
public Object updateKokOption(long userId, PostUpdateKokOptionRequest postUpdateKokOptionRequest) {
log.info("{UserService.updateKokOption}");
//model 객체 호출
User user = this.userRepository.findByUserId(userId);
List<Highlight> newHighlightList = this.highlightRepository.findAllByUser(user);
//exception 처리
if(newHighlightList == null){
throw new KokOptionLoadException(MEMBER_LIST_ITEM_UPDATE_FAILURE);
}
//기존 Highlight 객체 삭제=========================================================================
this.highlightRepository.deleteAll(newHighlightList);
//새로운 highlight list 생성하기
newHighlightList.clear();
for(String highlightTitle : postUpdateKokOptionRequest.getHighlights()){
newHighlightList.add(new Highlight(highlightTitle, user));
}
//user 객체에 highlight list 바꾸기
this.highlightRepository.saveAllAndFlush(newHighlightList);
//option, detailOption 객체 수정 (outer) ====================================================================
for(PostUpdateKokOptionRequest.Option requestOption : postUpdateKokOptionRequest.getOuterOptions()){
Option option = this.optionRepository.findByOptionId(requestOption.getOptionId());
option.setOrderNum(requestOption.getOrderNumber());
option.setVisible(requestOption.isVisible());
for(PostUpdateKokOptionRequest.DetailOption requestDetailOption : requestOption.getDetailOptions()){
DetailOption detailOption = this.detailOptionRepository.findByDetailOptionId(requestDetailOption.getDetailOptionId());
detailOption.setVisible(requestDetailOption.isDetailOptionIsVisible());
this.detailOptionRepository.save(detailOption);
}
this.optionRepository.save(option);
}
//option, detailOption 객체 수정 (inner) ====================================================================
for(PostUpdateKokOptionRequest.Option requestOption : postUpdateKokOptionRequest.getInnerOptions()){
Option option = this.optionRepository.findByOptionId(requestOption.getOptionId());
option.setOrderNum(requestOption.getOrderNumber());
option.setVisible(requestOption.isVisible());
for(PostUpdateKokOptionRequest.DetailOption requestDetailOption : requestOption.getDetailOptions()){
DetailOption detailOption = this.detailOptionRepository.findByDetailOptionId(requestDetailOption.getDetailOptionId());
detailOption.setVisible(requestDetailOption.isDetailOptionIsVisible());
this.detailOptionRepository.save(detailOption);
}
this.optionRepository.save(option);
}
//option, detailOption 객체 수정 (contract) ====================================================================
for(PostUpdateKokOptionRequest.Option requestOption : postUpdateKokOptionRequest.getContractOptions()){
Option option = this.optionRepository.findByOptionId(requestOption.getOptionId());
option.setOrderNum(requestOption.getOrderNumber());
option.setVisible(requestOption.isVisible());
for(PostUpdateKokOptionRequest.DetailOption requestDetailOption : requestOption.getDetailOptions()){
DetailOption detailOption = this.detailOptionRepository.findByDetailOptionId(requestDetailOption.getDetailOptionId());
detailOption.setVisible(requestDetailOption.isDetailOptionIsVisible());
this.detailOptionRepository.save(detailOption);
}
this.optionRepository.save(option);
}
return null;
}
딱 봐도 중복이 많다. 근데 각 Option, DetailOption은 PK가 각자 다르기 때문에 굳이 카테고리(outer, innner, contract) 별로 구분할 필요가 없다. 이걸 하나의 메서드로 합칠 수 있을 것 같다.
그리고 지금 update, delete 쿼리가 너무 많이 발생하고 있다. Highlight 같은 경우엔 기존 것을 삭제하고 새로 들어온 태그들로 저장하는데 이때 delete, insert 쿼리가 Highlight 개수만큼 발생한다. 그리고 Option과 DetailOption도 각자 update 쿼리가 발생해서 많은 쿼리가 발생한다. 아까 쓴 bulk 쿼리를 사용하자.
개선 후
코드 after refactoring
@Transactional
public Object updateKokOption(JwtUserDetails jwtUserDetails, PostUpdateKokOptionRequest postUpdateKokOptionRequest) {
log.info("{UserService.updateKokOption}");
updateHighlightList(jwtUserDetails.getUserId(), postUpdateKokOptionRequest);
updateOption(jwtUserDetails.getUserId(), postUpdateKokOptionRequest);
return null;
}
private void updateHighlightList(Long userId, PostUpdateKokOptionRequest postUpdateKokOptionRequest) {
highlightBulkJdbcRepository.deleteAll(userId);
User user = userRepository.findByUserId(userId);
List<Highlight> highlightList = postUpdateKokOptionRequest.getHighlights().stream()
.map(title -> Highlight.of(title, user))
.toList();
highlightBulkJdbcRepository.saveAll(highlightList);
}
private void updateOption(Long userId, PostUpdateKokOptionRequest postUpdateKokOptionRequest) {
List<PostUpdateKokOptionRequest.Option> requestOptions = Stream.of(
postUpdateKokOptionRequest.getOuterOptions(),
postUpdateKokOptionRequest.getInnerOptions(),
postUpdateKokOptionRequest.getContractOptions()
)
.flatMap(Collection::stream)
.toList();
optionBulkJdbcRepository.updateAll(requestOptions);
updateDetailOption(requestOptions);
}
private void updateDetailOption(List<PostUpdateKokOptionRequest.Option> requestOptionIds) {
List<PostUpdateKokOptionRequest.DetailOption> requestDetailOptionList =
requestOptionIds.stream()
.map(PostUpdateKokOptionRequest.Option::getDetailOptions)
.flatMap(Collection::stream)
.toList();
detailOptionBulkJdbcRepository.updateAll(requestDetailOptionList);
}
메서드를 분리해서 책임을 단일화 했다.
그리고 Option, DetailOption에 대한 작업을 카테고리 별로 분류해서 저장하지 않고 flatmap을 사용해서 하나의 stream으로 만들어서 저장을 한 번에 해주었다.
insert, update, delete 쿼리도 JDBCTemplate의 bulkUpdate 메서드를 사용했다. bulk update에 대해서는 추후 다른 포스팅으로 정리할 예정이다.
TPS
197 -> 1375 정도로 6배 정도 향상되었다.
엄청 향상되었는데 bulk query를 발생하면서 확실히 많은 양의 쿼리를 줄였다. 그 때문에 이 정도 변화는 합리적이다.
결론
User 도메인에 해당하는 API 들을 리팩토링 했다.
저 코드를 처음 작성할 당시에는 쿼리 최적화의 중요성을 모르기도 했고 builder 패턴 등을 몰라서 코드가 굉장히 냄새났었다. 리팩토링을 하면서 많은 부분을 수정했고 스터디 원들끼리 각자가 리팩토링 한 부분을 공유하면서 새로운 부분들을 많이 알아냈다.
JDBCTemplate 에서 BULK 쿼리를 발생하는 것도 알았고, Validated 어노테이션에서 생기는 exception을 handler에서 잡아주면 bindingResult 로 분기 처리 할 필요가 없다는 것도 새로이 알았다.
나중에 포스팅으로 Hibernate 에서 PK 생성 전략이 IDENTITY 일 때 BULK 가 안되는 이유도 포스팅 할 예정이다.
그리고 지금은 포스팅이 늦어서 여름방학이 끝난 상태인데, 방학 동안 security 적용이랑 test code도 작성했다. 그 부분도 차근차근 포스팅 할 예정이다.
리팩토링은 학기가 시작되어도 계속 이어갈 예정이다.
'백엔드' 카테고리의 다른 글
[ToHero 백엔드 개발일지] Blue-Green 무중단 배포 with Docker (2) | 2024.12.26 |
---|---|
우당탕탕 Bulk INSERT 도입기 (1) | 2024.09.14 |
Kok 도메인 API 코드 개선 및 쿼리 최적화 - 집콕 서버 리팩토링(2) (0) | 2024.08.18 |
RealEstate 도메인 API 코드 개선 및 쿼리 최적화 - 집콕 서버 리팩토링(1) (0) | 2024.07.28 |
집콕 서버 리팩토링 (0) | 2024.07.27 |