이번 시간에는, 열심히 학습한 코드 리팩토링 기법들을 활용해서 내 프로젝트 코드를 개선했던 경험에 대해 포스팅해보도록 하겠다.
Auth Domain
AuthController
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtService jwtService;
private final MemberRepository memberRepository;
@GetMapping
public DataResponse<UserInfoDto> getUserInfo(
@AuthenticationPrincipal TodomonOAuth2User todomonOAuth2User
) {
Member member = memberRepository.findById(todomonOAuth2User.getId())
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
UserInfoDto userInfo = UserInfoDto.from(member);
return DataResponse.of("유저 정보 반환", userInfo);
}
...
}
- 처음에는 '어차피 단순히 멤버 조회만 할건데 굳이 서비스 계층을 거쳐야할까?'라는 생각에 이렇게 설계했음..
- 프레젠테이션 계층에서 영속성 계층을 직접 참조하고 있음
- 읽기 전용 트랜잭션 적용하기 껄끄러움 (트랜잭션 로직이 컨트롤러 계층에 생기게됨)
- 에러 처리 관련 로직이 노출됨
- Member라는 도메인 엔터티가 그대로 노출됨
- 추후 도메인 영역인 Member의 변경이 있을 때 영향이 있음
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtService jwtService;
private final MemberService memberService; // 추가
@GetMapping
public DataResponse<UserInfoDto> getUserInfo(
@AuthenticationPrincipal TodomonOAuth2User todomonOAuth2User
) {
UserInfoDto userInfo = memberService.getMemberInfo(todomonOAuth2User.getId());
return DataResponse.of("유저 정보 반환", userInfo);
}
...
}
- MemberService(중간 객체)를 참조하도록 변경하고, 로직을 이 객체 내부로 옮김
- 도메인 관련 부분을 모두 숨길 수 있게 되었음 (에러 처리, 읽기 전용 트랜잭션, Member 엔터티)
JwtService
@Service
@RequiredArgsConstructor
@Transactional
public class JwtService {
private final MemberService memberService;
private final JwtProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;
public TodomonOAuth2User getPrincipal(String accessToken) {
Long id = jwtProvider.getId(accessToken);
String username = jwtProvider.getUsername(accessToken);
String email = jwtProvider.getEmail(accessToken);
String role = jwtProvider.getRole(accessToken);
String provider = jwtProvider.getProvider(accessToken);
MemberDTO dto = MemberDTO.builder()
.id(id)
.username(username)
.email(email)
.role(role)
.provider(provider)
.build();
return TodomonOAuth2User.from(dto);
}
public void logout(String bearerRefreshToken) {
String refreshToken = jwtProvider.getTokenFromBearer(bearerRefreshToken);
String email = jwtProvider.getPayload(refreshToken).getSubject();
refreshTokenRepository.deleteAllByEmail(email);
}
public void saveRefreshToken(TodomonOAuth2User todomonOAuth2User, TokenDto tokenDto) {
Optional<RefreshToken> refreshToken = refreshTokenRepository.findByEmail(todomonOAuth2User.getEmail());
refreshToken.ifPresentOrElse(
// 있다면 새토큰 발급후 업데이트
token -> {
token.updatePayload(tokenDto.getRefreshToken());
},
// 없다면 새로 만들고 DB에 저장
() -> {
RefreshToken newToken =
new RefreshToken(tokenDto.getRefreshToken(), todomonOAuth2User.getEmail());
refreshTokenRepository.save(newToken);
});
}
public TokenDto refresh(String bearerRefreshToken, HttpServletResponse response) {
String refreshToken = jwtProvider.getTokenFromBearer(bearerRefreshToken);
jwtProvider.validate(refreshToken);
RefreshToken findRefreshToken = refreshTokenRepository.findByPayload(refreshToken)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_REFRESH_TOKEN));
Member findMember = memberService.findMemberByEmail(findRefreshToken.getEmail());
MemberDTO dto = MemberDTO.from(findMember);
// access token 과 refresh token 모두를 재발급
TodomonOAuth2User todomonOAuth2User = TodomonOAuth2User.from(dto);
String newAccessToken = jwtProvider.generateAccessToken(todomonOAuth2User, new Date());
String newRefreshToken = jwtProvider.generateRefreshToken(todomonOAuth2User.getEmail(), new Date());
TokenDto tokenDto = TokenDto.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.build();
this.saveRefreshToken(todomonOAuth2User, tokenDto);
jwtProvider.setHeader(response, tokenDto);
return tokenDto;
}
}
getPrincipal
메서드의 경우, jwtProvider의 getXXX 함수를 여러번 호출하고 있음- 불필요하게 jwt 데이터나 MemberDTO 필드와 같은 내부 정보를 노출
- 이러한 데이터를 변경 시 JwtService도 영향 -> JwtService를 만든 이유가 애매해짐..
- = 강한 결합도, 낮은 응집도
- => JwtProvider 내부에 메서드를 만들어 이를 호출하도록 변경하자. 변할 수 있는 부분은 JwtProvider 내부 구현으로 감추자! => "함수 추출하기" + "함수 옮기기"
logout
메서드에서 email을 얻어오는 부분이 불필요하게 메서드 체이닝을 통해 구현을 드러내고 있다 => "위임 숨기기" 리팩토링을 통해 추상화하자saveRefreshToken
의 경우, TodomonOAuth2User의 email만 사용하고 있다.- email만 받도록 시그니처를 변경하자
- 추가로 upsert로직을 사용하므로 이름을
saveRefreshToken
가 아니라upsertRefreshToken
로 변경해주자
refresh
- 함수 이름을 바꾸자. 무엇을 refresh 한다는 것인지 모르겠다 ->
tokenRefresh
- 함수가 너무 뚱뚱하다.. => 단계 쪼개기 & 함수 추출하기 & 함수 옮기기 등 사용하자
- 'TodomonOAuth2User 추출하는 부분'과 'TodomonOAuth2User를 통해 JWT 토큰 재발급하는 부분'이 너무 길어지는 것 같다.
- 'TodomonOAuth2User 추출하는 부분'은 별도의 메서드로 추출하고 이름을 통해 의도를 드러내자
- JWT 토큰을 재발급하는 부분은 사실상 토큰을 생성하는 로직이다. 이는 JwtProvider에 이미 존재하므로 이를 사용호도록 변경하여 중복 코드를 제거하자
- 또한, 리프레시 토큰을 저장하고 이를 헤더에 세팅해주는 작업이 항상 같이 다니기 때문에 실수를 줄이기 위해
saveRefreshTokenAndSetHeader
라는 함수로 제공하자
- TodomonOAuth2User만 있다면, 이를 통해 Token들(accessToken, refreshToken)을 생성하고, 이를 저장한 후, 헤더에 설정하는 과정은 하나로 묶일 수 있는 과정이고 다른
곳에서 재사용 가능하다.- 이를
doTokenGenerationProcess
라는 메서드로 제공하자
- 이를
- 함수 이름을 바꾸자. 무엇을 refresh 한다는 것인지 모르겠다 ->
@Service
@RequiredArgsConstructor
public class JwtService {
private final MemberService memberService;
private final JwtProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;
public TodomonOAuth2User getPrincipal(String accessToken) {
return TodomonOAuth2User.from(jwtProvider.extractMemberDTOFromAccessToken(accessToken));
}
@Transactional
public void logout(String bearerRefreshToken) {
String email = this.extractEmailFromRefreshToken(bearerRefreshToken);
refreshTokenRepository.deleteAllByEmail(email);
}
private String extractEmailFromRefreshToken(String bearerRefreshToken) {
String refreshToken = jwtProvider.getTokenFromBearer(bearerRefreshToken);
return jwtProvider.getEmail(refreshToken);
}
@Transactional
public TokenDto tokenRefresh(String bearerRefreshToken, HttpServletResponse response) {
String refreshToken = jwtProvider.getTokenFromBearer(bearerRefreshToken);
jwtProvider.validate(refreshToken);
TodomonOAuth2User todomonOAuth2User = this.extractTodomonOAuth2User(refreshToken);
TokenDto tokenDto = jwtProvider.createJwt(todomonOAuth2User);
this.saveRefreshTokenAndSetHeader(response, todomonOAuth2User, tokenDto);
return tokenDto;
}
public void saveRefreshTokenAndSetHeader(HttpServletResponse response, TodomonOAuth2User todomonOAuth2User, TokenDto tokenDto) {
this.upsertRefreshToken(todomonOAuth2User.getEmail(), tokenDto);
this.setHeader(response, tokenDto);
}
private void upsertRefreshToken(String email, TokenDto tokenDto) {
Optional<RefreshToken> refreshToken = refreshTokenRepository.findByEmail(email);
refreshToken.ifPresentOrElse(
// 있다면 새토큰 발급후 업데이트
token -> {
token.updatePayload(tokenDto.getRefreshToken());
},
// 없다면 새로 만들고 DB에 저장
() -> {
RefreshToken newToken =
new RefreshToken(tokenDto.getRefreshToken(), email);
refreshTokenRepository.save(newToken);
});
}
private void setHeader(HttpServletResponse response, TokenDto tokenDto) {
response.addHeader(ACCESS_TOKEN_HEADER, BEARER_PREFIX + tokenDto.getAccessToken());
response.addHeader(REFRESH_TOKEN_HEADER, BEARER_PREFIX + tokenDto.getRefreshToken());
}
private TodomonOAuth2User extractTodomonOAuth2User(String refreshToken) {
RefreshToken findRefreshToken = refreshTokenRepository.findByPayload(refreshToken)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_REFRESH_TOKEN));
Member findMember = memberService.findMemberByEmail(findRefreshToken.getEmail());
MemberDTO dto = MemberDTO.from(findMember);
return TodomonOAuth2User.from(dto);
}
}
하지만, 더 생각해보니 saveRefreshTokenAndSetHeader
메서드의 경우도 결국엔 TodomonOAuth2User의 email 필드만을 사용하고 있다.
이를 고려해서 TodomonOAuth2User이 아니라 email만 전달하는 방식으로 변경하려고 헀으나, 로직에서 유저의 email과 tokenDto가 항상 같이 다닌다는 느낌을 받았다..
(email을 통해 토큰 정보를 조회하는 로직때문!)
=> TokenDto 내부에 email을 포함하자! (물론 중간 객체를 만드는 것이 더 좋아보이나, 여기서는 굳이 싶어서 이렇게 진행)
최종 결과는 다음과 같다.
@Service
@RequiredArgsConstructor
public class JwtService {
private final MemberService memberService;
private final JwtProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;
public TodomonOAuth2User getPrincipal(String accessToken) {
return TodomonOAuth2User.from(jwtProvider.extractMemberDTOFromAccessToken(accessToken));
}
@Transactional
public void logout(String bearerRefreshToken) {
String email = this.extractEmailFromRefreshToken(bearerRefreshToken);
refreshTokenRepository.deleteAllByEmail(email);
}
private String extractEmailFromRefreshToken(String bearerRefreshToken) {
String refreshToken = jwtProvider.getTokenFromBearer(bearerRefreshToken);
return jwtProvider.getEmail(refreshToken);
}
@Transactional
public TokenDto tokenRefresh(String bearerRefreshToken, HttpServletResponse response) {
String refreshToken = jwtProvider.getTokenFromBearer(bearerRefreshToken);
this.validate(refreshToken);
TodomonOAuth2User todomonOAuth2User = this.extractTodomonOAuth2User(refreshToken);
return this.doTokenGenerationProcess(response, todomonOAuth2User);
}
private TodomonOAuth2User extractTodomonOAuth2User(String refreshToken) {
RefreshToken findRefreshToken = refreshTokenRepository.findByPayload(refreshToken)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_REFRESH_TOKEN));
Member findMember = memberService.findMemberByEmail(findRefreshToken.getEmail());
MemberDTO dto = MemberDTO.from(findMember);
return TodomonOAuth2User.from(dto);
}
@Transactional
public TokenDto doTokenGenerationProcess(HttpServletResponse response, TodomonOAuth2User principal) {
TokenDto tokenDto = jwtProvider.createJwt(principal);
this.saveRefreshTokenAndSetHeader(response, tokenDto);
return tokenDto;
}
private void saveRefreshTokenAndSetHeader(HttpServletResponse response, TokenDto tokenDto) {
this.upsertRefreshToken(tokenDto);
this.setHeader(response, tokenDto);
}
private void upsertRefreshToken(TokenDto tokenDto) {
Optional<RefreshToken> refreshToken = refreshTokenRepository.findByEmail(tokenDto.getEmail());
refreshToken.ifPresentOrElse(
// 있다면 새토큰 발급후 업데이트
token -> {
token.updatePayload(tokenDto.getRefreshToken());
},
// 없다면 새로 만들고 DB에 저장
() -> {
RefreshToken newToken =
new RefreshToken(tokenDto.getRefreshToken(), tokenDto.getEmail());
refreshTokenRepository.save(newToken);
});
}
private void setHeader(HttpServletResponse response, TokenDto tokenDto) {
response.addHeader(ACCESS_TOKEN_HEADER, BEARER_PREFIX + tokenDto.getAccessToken());
response.addHeader(REFRESH_TOKEN_HEADER, BEARER_PREFIX + tokenDto.getRefreshToken());
}
public String resolveAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader(ACCESS_TOKEN_HEADER);
return jwtProvider.getTokenFromBearer(bearerToken);
}
public void validate(String token) {
try {
jwtProvider.getPayload(token);
} catch (SecurityException e) {
throw new UnauthorizedException(ErrorCode.INVALID_TOKEN, "검증 정보가 올바르지 않습니다.");
} catch (MalformedJwtException e) {
throw new UnauthorizedException(ErrorCode.INVALID_TOKEN, "유효하지 않은 토큰입니다.");
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(ErrorCode.INVALID_TOKEN, "기한이 만료된 토큰입니다.");
} catch (UnsupportedJwtException e) {
throw new UnauthorizedException(ErrorCode.INVALID_TOKEN, "지원되지 않는 토큰입니다.");
}
}
}
- 추가적으로 JwtProvider에 validate를 JwtService로 옮겨주었다. 에러 처리 담당은 서비스 레이어에서 담당하는게 맞다고 생각했다.
- 또한,
jwtProvider.resolveAccessToken
메서드도 JwtService로 옮겨주었다. - 이와 같은 작업을 통해 다른 객체에서 JwtService와 JwtProvider에 동시에 의존하는 상태를 없앨 수 있었다.
코드가 충분히 간결해진 것 같다!
TodomonOAuth2UserService(OAuth2UserService 구현체)
@Service
@RequiredArgsConstructor
public class TodomonOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberService memberService;
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
ClientRegistration clientRegistration = userRequest.getClientRegistration();
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest); // 인가 서버와 통신해서 실제 사용자 정보 조회
OAuth2ProviderUser oAuth2ProviderUser = this.getOAuth2ProviderUser(clientRegistration, oAuth2User);
// 회원가입
Member member = memberService.getOrCreate(oAuth2ProviderUser);
MemberDTO dto = MemberDTO.from(member);
return TodomonOAuth2User.of(dto, oAuth2ProviderUser);
}
private OAuth2ProviderUser getOAuth2ProviderUser(ClientRegistration clientRegistration, OAuth2User oAuth2User) {
String registrationId = clientRegistration.getRegistrationId();
Map<String, Object> attributes = oAuth2User.getAttributes();
switch (registrationId) {
case "google":
return new GoogleUser(attributes, registrationId);
case "naver":
return new NaverUser(attributes, registrationId);
case "kakao":
return new KakaoUser(attributes, registrationId);
default:
throw new BadRequestException(ErrorCode.BAD_REQUEST, "일치하는 제공자가 없습니다.");
}
}
}
getOAuth2ProviderUser
내부의 switch 문을 보면 registrationId에 따라 다른 구현체를 생성하고 반환하고 있다. 또 다른 등록자가 추가되면 코드를 수정해야 할 것이다..OAuth2ProviderUser
는 기본적으로 추상 클래스와 상속을 통한 다형성을 잘 활용하고 있다. 하지만, 이를 다형성에 따른 인스턴스 생성 롤직을 클라이언트 코드에 그대로 노출해서 아쉽다.- =>
OAuth2ProviderUser
내부에 팩토리 메서드를 만들어서 생성 로직을 옮기자!
public abstract class OAuth2ProviderUser {
private String provider;
private final Map<String, Object> attributes;
protected OAuth2ProviderUser(Map<String, Object> attributes, String registrationId) {
this.attributes = attributes;
this.provider = registrationId;
}
// 팩토리 메서드 추가
public static OAuth2ProviderUser create(Map<String, Object> attributes, String registrationId) {
switch (registrationId) {
case "google":
return new GoogleUser(attributes, registrationId);
case "naver":
return new NaverUser(attributes, registrationId);
case "kakao":
return new KakaoUser(attributes, registrationId);
default:
throw new BadRequestException(ErrorCode.BAD_REQUEST, "일치하는 제공자가 없습니다.");
}
}
public abstract String getEmail();
public abstract String getUsername();
public abstract String getProviderId();
public abstract String getProfileImageUrl();
public String getProvider() {
return provider;
}
public Map<String, Object> getAttributes() {
return attributes;
}
}
---
@Service
@RequiredArgsConstructor
public class TodomonOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
...
private OAuth2ProviderUser getOAuth2ProviderUser(ClientRegistration clientRegistration, OAuth2User oAuth2User) {
String registrationId = clientRegistration.getRegistrationId();
Map<String, Object> attributes = oAuth2User.getAttributes();
return OAuth2ProviderUser.create(attributes, registrationId); // 팩토리 메서드 호출
}
}
Member Domain
MemberService
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final MemberQueryRepository memberQueryRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final FileService fileService;
@Transactional(readOnly = true)
public UserInfoDto getMemberInfo(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
return UserInfoDto.from(member);
}
@Transactional(readOnly = true)
public Member findMemberByEmail(String email) {
return memberRepository.findByEmail(email)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
public Member getOrCreate(OAuth2ProviderUser oAuth2ProviderUser) {
return memberRepository.findByEmail(oAuth2ProviderUser.getEmail())
.orElseGet(() -> this.registerByOAuth2(oAuth2ProviderUser));
}
private Member registerByOAuth2(OAuth2ProviderUser oAuth2ProviderUser) {
Role role = oAuth2ProviderUser.getEmail().equals("maruhan1016@gmail.com")
? Role.ROLE_ADMIN
: Role.ROLE_USER;
Member member = Member.builder()
.username(oAuth2ProviderUser.getUsername())
.email(oAuth2ProviderUser.getEmail())
.provider(OAuth2Provider.valueOf(oAuth2ProviderUser.getProvider().toUpperCase()))
.providerId(oAuth2ProviderUser.getProviderId())
.profileImageUrl(oAuth2ProviderUser.getProfileImageUrl())
.role(role)
.build();
member.initDiligence();
memberRepository.save(member);
return member;
}
@Transactional(readOnly = true)
public ProfileDto getProfile(Long loginMemberId, Long memberId) {
return memberQueryRepository.getMemberProfileById(loginMemberId, memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
@IsMeOrAdmin
public void updateProfile(Long memberId, UpdateMemberProfileReq req) {
validateUpdateProfileRequest(req);
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
String newProfileImageUrl = null;
if (req.getProfileImage() != null) {
newProfileImageUrl = fileService.storeOneFile(req.getProfileImage());
deleteProfileImageOfFindMember(findMember);
}
findMember.updateProfile(
req.getUsername(),
newProfileImageUrl
);
}
private void validateUpdateProfileRequest(UpdateMemberProfileReq updateMemberProfileReq) {
if (
updateMemberProfileReq.getUsername() == null
&& updateMemberProfileReq.getProfileImage() == null) {
throw new BadRequestException(ErrorCode.VALIDATION_ERROR, "수정할 데이터를 전달해야 합니다.");
}
}
private void deleteProfileImageOfFindMember(Member findMember) {
String profileImageUrl = findMember.getProfileImageUrl();
if (profileImageUrl.startsWith("http") || profileImageUrl.startsWith("https")) return;
fileService.deleteFile(profileImageUrl);
}
@IsMeOrAdmin
public void withdraw(Long memberId) {
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
deleteProfileImageOfFindMember(findMember);
memberRepository.delete(findMember);
refreshTokenRepository.deleteAllByEmail(findMember.getEmail());
}
public List<SearchDto> searchMemberByKey(String key) {
return memberQueryRepository.findMemberByKey(key);
}
}
registerByOAuth2
- 어드민 여부 확인하는 부분을 메서드로 추출하여 의도를 드러내자
- 내부에 Member 생성 시 빌더 패턴을 그대로 사용하여 필드를 노출.. => 생성 메서드를 따로 만들자
updateProfile
- 수정 요청 데이터에 프로필 이미지가 있을 경우, 이를 처리하는 로직을 따로 함수로 추출해서 의미를 부여하자
- Member 엔터티 내부의
updateProfile
에서 값이 존재하는 경우에만 수정하도록 처리하고 있는데, 내부를 확인하지 않는 이상 어떤 일이 발생하는지 어렵다는 단점.. - updateUsername, updateProfileImageUrl 이라는 2개의 메서드로 분리하자. 또한, 값이 존재하는지 확인하는 로직을 MemberService에서 맡도록 하여 Member의 책임을 줄이자
- 이러한 수정 로직을 재사용할 가능성이 높고 모두 공통적인 검증을 해야한다면 Member 안에 있는 것이 맞겠지만 이번의 경우에는 아니므로..
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
...
private Member registerByOAuth2(OAuth2ProviderUser oAuth2ProviderUser) {
Role role = isAdmin(oAuth2ProviderUser)
? Role.ROLE_ADMIN
: Role.ROLE_USER;
Member member = Member.of(oAuth2ProviderUser, role);
memberRepository.save(member);
return member;
}
private boolean isAdmin(OAuth2ProviderUser oAuth2ProviderUser) {
return oAuth2ProviderUser.getEmail().equals(ADMIN_EMAIL);
}
...
@IsMeOrAdmin
public void updateProfile(Long memberId, UpdateMemberProfileReq req) {
this.validateUpdateProfileRequest(req);
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
if (StringUtils.hasText(req.getUsername())) findMember.updateUsername(req.getUsername());
if (req.getProfileImage() != null) this.updateProfileImage(req.getProfileImage(), findMember);
}
private void updateProfileImage(MultipartFile profileImage, Member findMember) {
this.deleteProfileImageOfFindMember(findMember.getProfileImageUrl());
String newProfileImageUrl = fileService.storeOneFile(profileImage);
findMember.updateProfileImageUrl(newProfileImageUrl);
}
private void validateUpdateProfileRequest(UpdateMemberProfileReq updateMemberProfileReq) {
if (updateMemberProfileReq.getUsername() == null
&& updateMemberProfileReq.getProfileImage() == null)
throw new BadRequestException(ErrorCode.VALIDATION_ERROR, "수정할 데이터를 전달해야 합니다.");
}
private void deleteProfileImageOfFindMember(String profileImageUrl) {
if (profileImageUrl.startsWith("http") || profileImageUrl.startsWith("https")) return;
fileService.deleteFile(profileImageUrl);
}
...
}
Todo Domain
TodoService - create
@Service
@Transactional
@RequiredArgsConstructor
public class TodoService {
private final MemberRepository memberRepository;
private final TodoRepository todoRepository;
private final RepeatInfoRepository repeatInfoRepository;
private final TodoInstanceRepository todoInstanceRepository;
/**
* Todo를 생성한다.
*
* @param memberId
* @param req
*/
public void create(Long memberId, CreateTodoReq req) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Todo todo = req.toEntity(member);
RepeatInfo repeatInfo = null;
if (req.getRepeatInfoReqItem() != null) {
repeatInfo = this.createAndSetRepeatInfo(req.getRepeatInfoReqItem(), todo);
}
todoRepository.save(todo);
if (repeatInfo != null) {
this.createTodoInstances(todo);
}
}
private RepeatInfo createAndSetRepeatInfo(RepeatInfoReqItem repeatInfoReqItem, Todo todo) {
RepeatInfo repeatInfo = repeatInfoReqItem.toEntity();
todo.setRepeatInfo(repeatInfo);
repeatInfoRepository.save(repeatInfo);
return repeatInfo;
}
private void createTodoInstances(Todo todo) {
List<TodoInstance> instances = this.generateInstances(todo);
if (instances.size() > 1) {
todo.setTodoInstances(instances);
todoInstanceRepository.saveAll(instances);
this.updateTodoDateRange(todo, instances);
}
}
// 공통 로직을 함수로 분리하여 코드 중복 제거
private void updateTodoDateRange(Todo todo, List<TodoInstance> instances) {
LocalDateTime repeatStartAt = instances.get(0).getStartAt();
LocalDateTime repeatEndAt = instances.get(instances.size() - 1).getEndAt();
todo.update(UpdateTodoReq.builder()
.startAt(repeatStartAt)
.endAt(repeatEndAt)
.build());
}
private List<TodoInstance> generateInstances(Todo todo) {
RepeatInfo repeatInfo = todo.getRepeatInfo();
LocalDateTime startAt = todo.getStartAt();
LocalDateTime endAt = todo.getEndAt();
switch (repeatInfo.getFrequency()) {
case DAILY:
return this.generateDailyInstances(todo, startAt, endAt, repeatInfo);
case WEEKLY:
return this.generateWeeklyInstances(todo, startAt, endAt, repeatInfo);
case MONTHLY:
return this.generateMonthlyInstances(todo, startAt, endAt, repeatInfo);
default:
return Collections.emptyList();
}
}
private List<TodoInstance> generateMonthlyInstances(Todo todo, LocalDateTime startAt, LocalDateTime endAt, RepeatInfo repeatInfo) {
List<TodoInstance> instances = new ArrayList<>();
LocalDateTime currentStart = this.adjustDayOfMonth(startAt, repeatInfo.getByMonthDay(), repeatInfo.getInterval());
LocalDateTime currentEnd = this.adjustDayOfMonth(endAt, repeatInfo.getByMonthDay(), repeatInfo.getInterval());
while (this.shouldGenerateMoreInstances(currentStart, repeatInfo, instances.size())) {
currentStart = this.adjustDayOfMonth(currentStart, repeatInfo.getByMonthDay(), repeatInfo.getInterval());
currentEnd = this.adjustDayOfMonth(currentEnd, repeatInfo.getByMonthDay(), repeatInfo.getInterval());
instances.add(TodoInstance.of(todo, currentStart, currentEnd));
currentStart = currentStart.plusMonths(repeatInfo.getInterval());
currentEnd = currentEnd.plusMonths(repeatInfo.getInterval());
}
return instances;
}
// 불필요한 조건문 제거 및 메소드 간소화
private LocalDateTime adjustDayOfMonth(LocalDateTime dateTime, int dayOfMonth, int interval) {
int maxDayOfMonth = dateTime.getMonth().length(dateTime.toLocalDate().isLeapYear());
if (dayOfMonth > maxDayOfMonth) {
return dateTime.plusMonths(interval).withDayOfMonth(dayOfMonth);
}
return dateTime.withDayOfMonth(dayOfMonth);
}
// 주 반복 인스턴스 생성 로직 개선
private List<TodoInstance> generateWeeklyInstances(Todo todo, LocalDateTime startAt, LocalDateTime endAt, RepeatInfo repeatInfo) {
List<TodoInstance> instances = new ArrayList<>();
LocalDateTime currentStart = startAt;
LocalDateTime currentEnd = endAt;
List<DayOfWeek> byDays = this.convertToDayOfWeeks(repeatInfo.getByDay());
while (this.shouldGenerateMoreInstances(currentStart, repeatInfo, instances.size())) {
if (byDays.contains(currentStart.getDayOfWeek())) {
instances.add(TodoInstance.of(todo, currentStart, currentEnd));
}
currentStart = currentStart.plusDays(1);
currentEnd = currentEnd.plusDays(1);
if (currentStart.getDayOfWeek() == DayOfWeek.MONDAY) {
currentStart = currentStart.plusWeeks(repeatInfo.getInterval() - 1);
currentEnd = currentEnd.plusWeeks(repeatInfo.getInterval() - 1);
}
}
return instances;
}
// 문자열을 DayOfWeek 리스트로 변환하는 로직을 분리
private List<DayOfWeek> convertToDayOfWeeks(String byDay) {
return Arrays.stream(byDay.split(",")).map(TimeUtil::convertToDayOfWeek).toList();
}
// 일 반복 인스턴스 생성 로직 개선 및 리팩터링
private List<TodoInstance> generateDailyInstances(Todo todo, LocalDateTime startAt, LocalDateTime endAt, RepeatInfo repeatInfo) {
List<TodoInstance> instances = new ArrayList<>();
LocalDateTime currentStart = startAt;
LocalDateTime currentEnd = endAt;
while (this.shouldGenerateMoreInstances(currentStart, repeatInfo, instances.size())) {
instances.add(TodoInstance.of(todo, currentStart, currentEnd));
currentStart = currentStart.plusDays(repeatInfo.getInterval());
currentEnd = currentEnd.plusDays(repeatInfo.getInterval());
}
return instances;
}
// 조건문 간소화
private boolean shouldGenerateMoreInstances(LocalDateTime currentStart, RepeatInfo repeatInfo, int size) {
return (repeatInfo.getUntil() == null || !currentStart.toLocalDate().isAfter(repeatInfo.getUntil()))
&& (repeatInfo.getCount() == null || size < repeatInfo.getCount());
}
...
}
위는 Todo 및 TodoInstance를 생성하는 로직 부분이다. 요청 데이터에 반복 정보 유무에 따라 로직이 달라지고, 반복 정보가 있다면 RepeatInfo를 생성하고, 반복 정보에 맞는 올바른 TodoInstance를 생성해주어야 하기에 switch 문을 통해 유사하지만 다른 방식으로 TodoInstance를 생성하고 있다.
처음에 코드를 작성했을 때에는 이것보다 더 심했어서.. 지금도 나름 잘 작성했다고 생각했었다. 하지만, 리팩토링과 오브젝트를 공부하고 나니 지금의 코드는 변경에 너무나도 취약하고 중복 코드가 많다는 것을 느꼈다.
또한, TodoService가 생성 책임을 모두 맡고 있기에 조금의 생성 알고리즘이 변경된다면 이 클래스 자체가 변경되어야 했다.
즉, TodoService가 너무 많은 책임을 갖고 있다.. 이를 개선해보자!
먼저 RepeatInfo를 생성하는 부분은 RepeatInfoService에 두고, TodoInstance를 생성하는 부분은 TodoInstanceService에 둔다고 가정하고 다음과 같이 코드를 변경하자.
TodoService.create
public void create(Long memberId, CreateTodoReq req) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Todo todo = req.toEntity(member);
if (req.getRepeatInfoReqItem() != null) {
RepeatInfo repeatInfo = repeatInfoService.createRepeatInfo(req.getRepeatInfoReqItem());
todo.setRepeatInfo(repeatInfo); // Cascade.PERSIST에 의해 함께 저장됨
}
todoRepository.save(todo);
if (todo.getRepeatInfo() != null) {
todoInstanceService.generateAndSaveInstances(todo);
}
}
=> 이제 repeatInfo를 생성하는 로직이나 todoInstance를 생성하는 로직이 변경되더라도 TodoService에는 영향을 주지 않는다!
이제 실제 로직들을 안에 몰아넣자.
RepeatInfoService
@Service
@RequiredArgsConstructor
public class RepeatInfoService {
private final RepeatInfoRepository repeatInfoRepository;
public RepeatInfo createRepeatInfo(RepeatInfoReqItem repeatInfoReqItem) {
RepeatInfo repeatInfo = repeatInfoReqItem.toEntity();
repeatInfoRepository.save(repeatInfo);
return repeatInfo;
}
}
TodoInstanceService
@Service
@RequiredArgsConstructor
public class TodoInstanceService {
private final TodoInstanceRepository todoInstanceRepository;
private final RepeatInfoStrategyFactory strategyFactory;
public void generateAndSaveInstances(Todo todo) {
RepeatInfo repeatInfo = todo.getRepeatInfo();
RepeatInfoStrategy strategy = strategyFactory.getStrategy(repeatInfo.getFrequency());
List<TodoInstance> instances = strategy.generateInstances(todo);
if (!instances.isEmpty()) {
todo.setTodoInstances(instances);
todoInstanceRepository.saveAll(instances);
this.updateTodoDateRange(todo, instances);
}
}
private void updateTodoDateRange(Todo todo, List<TodoInstance> instances) {
LocalDateTime repeatStartAt = instances.get(0).getStartAt();
LocalDateTime repeatEndAt = instances.get(instances.size() - 1).getEndAt();
todo.update(UpdateTodoReq.builder()
.startAt(repeatStartAt)
.endAt(repeatEndAt)
.build());
}
}
generateAndSaveInstances
메서드는 TodoService에 있었던 기존의createTodoInstances
메서드와generateInstances
메서드를 합친 것이다.RepeatInfoStrategyFactory
라는 새로운 클래스를 사용하는 것을 볼 수 있는데, 이는 전략 패턴을 사용하는 것이다.- 기존의 switch 문을 통해 Frequency 값에 따라 다르게 로직을 처리하던 부분을 다형성을 이용한 전략 패턴을 통해 개선해보았다.
RepeatInfoStrategy
public interface RepeatInfoStrategy {
List<TodoInstance> generateInstances(Todo todo);
Frequency getFrequency();
default boolean shouldGenerateMoreInstances(LocalDateTime currentStart, RepeatInfo repeatInfo, int size) {
return (repeatInfo.getUntil() == null || !currentStart.toLocalDate().isAfter(repeatInfo.getUntil()))
&& (repeatInfo.getCount() == null || size < repeatInfo.getCount());
}
}
---
@Component
public class MonthlyRepeatInfoStrategy implements RepeatInfoStrategy {
@Override
public List<TodoInstance> generateInstances(Todo todo) {
List<TodoInstance> instances = new ArrayList<>();
RepeatInfo repeatInfo = todo.getRepeatInfo();
LocalDateTime currentStart = this.adjustDayOfMonth(todo.getStartAt(), repeatInfo.getByMonthDay(), repeatInfo.getInterval());
LocalDateTime currentEnd = this.adjustDayOfMonth(todo.getEndAt(), repeatInfo.getByMonthDay(), repeatInfo.getInterval());
while (this.shouldGenerateMoreInstances(currentStart, repeatInfo, instances.size())) {
currentStart = this.adjustDayOfMonth(currentStart, repeatInfo.getByMonthDay(), repeatInfo.getInterval());
currentEnd = this.adjustDayOfMonth(currentEnd, repeatInfo.getByMonthDay(), repeatInfo.getInterval());
instances.add(TodoInstance.of(todo, currentStart, currentEnd));
currentStart = currentStart.plusMonths(repeatInfo.getInterval());
currentEnd = currentEnd.plusMonths(repeatInfo.getInterval());
}
return instances;
}
@Override
public Frequency getFrequency() {
return Frequency.MONTHLY;
}
// 불필요한 조건문 제거 및 메소드 간소화
private LocalDateTime adjustDayOfMonth(LocalDateTime dateTime, int dayOfMonth, int interval) {
int maxDayOfMonth = dateTime.getMonth().length(dateTime.toLocalDate().isLeapYear());
if (dayOfMonth > maxDayOfMonth) {
return dateTime.plusMonths(interval).withDayOfMonth(dayOfMonth);
}
return dateTime.withDayOfMonth(dayOfMonth);
}
}
---
@Component
public class WeeklyRepeatInfoStrategy implements RepeatInfoStrategy {
@Override
public List<TodoInstance> generateInstances(Todo todo) {
List<TodoInstance> instances = new ArrayList<>();
RepeatInfo repeatInfo = todo.getRepeatInfo();
LocalDateTime currentStart = todo.getStartAt();
LocalDateTime currentEnd = todo.getEndAt();
List<DayOfWeek> byDays = this.convertToDayOfWeeks(repeatInfo.getByDay());
while (this.shouldGenerateMoreInstances(currentStart, repeatInfo, instances.size())) {
if (byDays.contains(currentStart.getDayOfWeek())) {
instances.add(TodoInstance.of(todo, currentStart, currentEnd));
}
currentStart = currentStart.plusDays(1);
currentEnd = currentEnd.plusDays(1);
if (currentStart.getDayOfWeek() == DayOfWeek.MONDAY) {
currentStart = currentStart.plusWeeks(repeatInfo.getInterval() - 1);
currentEnd = currentEnd.plusWeeks(repeatInfo.getInterval() - 1);
}
}
return instances;
}
@Override
public Frequency getFrequency() {
return Frequency.WEEKLY;
}
// 문자열을 DayOfWeek 리스트로 변환하는 로직을 분리
private List<DayOfWeek> convertToDayOfWeeks(String byDay) {
return Arrays.stream(byDay.split(",")).map(TimeUtil::convertToDayOfWeek).toList();
}
}
---
@Component
public class DailyRepeatInfoStrategy implements RepeatInfoStrategy {
@Override
public List<TodoInstance> generateInstances(Todo todo) {
List<TodoInstance> instances = new ArrayList<>();
LocalDateTime currentStart = todo.getStartAt();
LocalDateTime currentEnd = todo.getEndAt();
RepeatInfo repeatInfo = todo.getRepeatInfo();
while (this.shouldGenerateMoreInstances(currentStart, repeatInfo, instances.size())) {
instances.add(TodoInstance.of(todo, currentStart, currentEnd));
currentStart = currentStart.plusDays(repeatInfo.getInterval());
currentEnd = currentEnd.plusDays(repeatInfo.getInterval());
}
return instances;
}
@Override
public Frequency getFrequency() {
return Frequency.DAILY;
}
}
generateInstances
: 실질적으로 각 Frequency에 맞게 TodoInstnace들을 생성하는 로직들이 담긴다.- 파라미터 수가 기존에 비해 크게 감소했다. 기존의 파라미터는 모두 todo에서 얻을 수 있는 파생 매개변수였기 때문에 이를 제거할 수 있었다.
shouldGenerateMoreInstances
:generateInstances
구현부에서 3가지 전략 모두가 동일한 로직을 사용하고 있기에 Java 8에 등장한 default 메서드를 활용해보았다.- 비즈니스 로직이 공유되고 있기에 추상 클래스와 상속을 이용했으면 더 나았을 것 같다
RepeatInfoStrategyFactory
@Component
public class RepeatInfoStrategyFactory {
private final Map<Frequency, RepeatInfoStrategy> strategies;
public RepeatInfoStrategyFactory(List<RepeatInfoStrategy> strategyList) {
this.strategies = strategyList.stream()
.collect(Collectors.toMap(RepeatInfoStrategy::getFrequency, strategy -> strategy));
}
public RepeatInfoStrategy getStrategy(Frequency frequency) {
return strategies.get(frequency);
}
}
TodoService - update
@IsTodayTodoOrAdmin
@IsMyTodoOrAdmin
public void update(Long objectId, UpdateAndDeleteTodoQueryParams params, UpdateTodoReq req) {
validateUpdateReq(req);
if (params.getIsInstance()) {
TodoInstance todoInstance = todoInstanceRepository.findTodoInstanceWithTodo(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
switch (params.getTargetType()) {
case THIS_TASK -> {
// 반복 정보 수정은 ALL_TASKS 타입만 가능
if (req.getRepeatInfoReqItem() != null) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "전체 인스턴스에 대해서만 반복 정보 수정이 가능합니다.");
}
if (req.getEndAt() == null && todoInstance.getEndAt().isBefore(req.getStartAt())
|| req.getStartAt() == null && todoInstance.getStartAt().isAfter(req.getEndAt())
) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "시작 시각은 종료 시각보다 이전이어야 합니다.");
}
todoInstance.update(req);
}
case ALL_TASKS -> {
// 시간 정보 수정은 THIS_TASK 타입만 가능
if (req.getStartAt() != null || req.getEndAt() != null) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "시간 정보 수정은 단일 인스턴스에 대해서만 수정 가능합니다.");
}
Todo todo = todoInstance.getTodo();
todo.update(req); // todo 먼저 업데이트
todoInstance.update(req);
if (req.getRepeatInfoReqItem() != null) {
RepeatInfo oldRepeatInfo = todo.getRepeatInfo();
if (oldRepeatInfo != null) {
todo.setRepeatInfo(null);
todo.setTodoInstances(null);
repeatInfoService.deleteRepeatInfo(oldRepeatInfo);
todoInstanceRepository.deleteAllByTodo_Id(todo.getId());
}
todo.updateEndAtTemporally();
RepeatInfo repeatInfo = repeatInfoService.createRepeatInfo(req.getRepeatInfoReqItem());
todo.setRepeatInfo(repeatInfo);
todoInstanceService.generateAndSaveInstances(todo);
} else {
List<TodoInstance> todoInstances = todoInstanceRepository.findAllByTodo_Id(todoInstance.getTodo().getId());
todoInstances.forEach(instance -> instance.update(req));
}
}
}
} else {
Todo findTodo = todoRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
findTodo.update(req);
if (req.getRepeatInfoReqItem() != null) {
RepeatInfo repeatInfo = repeatInfoService.createRepeatInfo(req.getRepeatInfoReqItem());
findTodo.setRepeatInfo(repeatInfo);
todoInstanceService.generateAndSaveInstances(findTodo);
}
}
}
private static void validateUpdateReq(UpdateTodoReq req) {
if (req.getContent() == null && req.getIsAllDay() == null && req.getRepeatInfoReqItem() == null) {
throw new BadRequestException(ErrorCode.VALIDATION_ERROR, "수정할 데이터를 넘겨주세요");
}
}
update 로직이다. 해야할 일이 많아 쭉 적어내려가다보니 하나의 메서드각 너무 많은 일을 하는 형태가 되어버렸다. SRP에 위배되고 복잡성이 높아진 것 같다.
메서드를 적절히 분리해보자!
@IsTodayTodoOrAdmin
@IsMyTodoOrAdmin
public void update(Long objectId, UpdateAndDeleteTodoQueryParams params, UpdateTodoReq req) {
validateUpdateReq(req);
if (params.getIsInstance()) {
this.updateTodoInstance(objectId, params, req);
} else {
this.updateTodo(objectId, req);
}
}
일단 단순히 각 if/else 내부의 로직을 메서드로 추출해보았다. 속이 후련하다.. 이어서 추출한 각 메서드를 계속 리팩토링 해보자.
updateTodo
private void updateTodo(Long objectId, UpdateTodoReq req) {
Todo findTodo = todoRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
findTodo.update(req);
if (req.getRepeatInfoReqItem() != null) {
this.updateRepeatInfo(req, findTodo);
}
}
private void updateRepeatInfo(UpdateTodoReq req, Todo findTodo) {
RepeatInfo repeatInfo = repeatInfoService.createRepeatInfo(req.getRepeatInfoReqItem());
findTodo.setRepeatInfo(repeatInfo);
todoInstanceService.generateAndSaveInstances(findTodo);
}
repeatInfo를 업데이트 하는 부분이 다른 부분에서도 공통으로 사용되어 별도의 메서드로 추출하였다.
updateTodoInstance
private void updateTodoInstance(Long objectId, UpdateAndDeleteTodoQueryParams params, UpdateTodoReq req) {
TodoInstance todoInstance = todoInstanceRepository.findTodoInstanceWithTodo(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
switch (params.getTargetType()) {
case THIS_TASK -> this.updateSingleInstance(req, todoInstance);
case ALL_TASKS -> this.updateAllInstances(req, todoInstance);
default -> throw new BadRequestException(ErrorCode.BAD_REQUEST, "잘못된 type입니다.");
}
}
updateTodoInstance 역시 내부가 switch 문으로 인해 너무 뚱뚱해져서 각 로직을 메서드로 추출해주었다.
updateSingleInstance
private void updateSingleInstance(UpdateTodoReq req, TodoInstance todoInstance) {
// 반복 정보 수정은 ALL_TASKS 타입만 가능
if (req.getRepeatInfoReqItem() != null)
throw new BadRequestException(ErrorCode.BAD_REQUEST, "전체 인스턴스에 대해서만 반복 정보 수정이 가능합니다.");
this.validateInstanceTime(req, todoInstance);
todoInstance.update(req);
}
private void validateInstanceTime(UpdateTodoReq req, TodoInstance todoInstance) {
if (req.getEndAt() == null && todoInstance.getEndAt().isBefore(req.getStartAt())
|| req.getStartAt() == null && todoInstance.getStartAt().isAfter(req.getEndAt())
) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "시작 시각은 종료 시각보다 이전이어야 합니다.");
}
}
단일 인스턴스 수정의 경우, TodoInstance의 시작 시각 혹은 종료 시각을 변경할 수 있다.
그러나, 이 때의 검증 로직 부분이 읽기 어려워 별도의 함수로 추출하여 의미를 부여해주었다.
updateAllInstances
private void updateAllInstances(UpdateTodoReq req, TodoInstance todoInstance) {
if (req.getStartAt() != null || req.getEndAt() != null) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "시간 정보 수정은 단일 인스턴스에 대해서만 수정 가능합니다.");
}
Todo todo = todoInstance.getTodo();
todo.update(req); // todo 먼저 업데이트
todoInstance.update(req);
if (req.getRepeatInfoReqItem() != null) {
this.updateAllWithNewRepeatInfo(req, todo);
} else {
this.updateAllWithoutNewRepeatInfo(req, todoInstance);
}
}
private void updateAllWithoutNewRepeatInfo(UpdateTodoReq req, TodoInstance todoInstance) {
List<TodoInstance> todoInstances = todoInstanceRepository.findAllByTodo_Id(todoInstance.getTodo().getId());
todoInstances.forEach(instance -> instance.update(req));
}
private void updateAllWithNewRepeatInfo(UpdateTodoReq req, Todo todo) {
this.removeOldRepeatInfo(todo);
todo.updateEndAtTemporally();
this.updateRepeatInfo(req, todo);
}
private void removeOldRepeatInfo(Todo todo) {
RepeatInfo oldRepeatInfo = todo.getRepeatInfo();
if (oldRepeatInfo != null) {
todo.setRepeatInfo(null);
todo.setTodoInstances(null);
repeatInfoService.deleteRepeatInfo(oldRepeatInfo);
todoInstanceRepository.deleteAllByTodo_Id(todo.getId());
}
}
마찬가지로 if/else문 안의 구현부를 별도의 메서드로 추출하여 의미(의도)를 부여해주었다.
메서드 추출하기만 잘 해도 훨씬 깔끔한 느낌이다. 하지만, 이 무수히 많아진 메서드들이 TodoService 안에 모여있다는 점이 불편하다..
적절한 위치로 메서드를 옮겨주자.
TodoService
@IsTodayTodoOrAdmin
@IsMyTodoOrAdmin
public void update(Long objectId, UpdateAndDeleteTodoQueryParams params, UpdateTodoReq req) {
validateUpdateReq(req);
if (params.getIsInstance()) {
todoInstanceService.updateTodoInstance(objectId, params, req);
} else {
this.updateTodo(objectId, req);
}
}
private static void validateUpdateReq(UpdateTodoReq req) {
if (req.getContent() == null && req.getIsAllDay() == null && req.getRepeatInfoReqItem() == null) {
throw new BadRequestException(ErrorCode.VALIDATION_ERROR, "수정할 데이터를 넘겨주세요");
}
}
private void updateTodo(Long objectId, UpdateTodoReq req) {
Todo findTodo = todoRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
findTodo.update(req);
if (req.getRepeatInfoReqItem() != null) {
repeatInfoService.updateRepeatInfo(req, findTodo);
todoInstanceService.generateAndSaveInstances(findTodo);
}
}
RepeatInfoService
public void updateRepeatInfo(UpdateTodoReq req, Todo findTodo) {
RepeatInfo repeatInfo = this.createRepeatInfo(req.getRepeatInfoReqItem());
findTodo.setRepeatInfo(repeatInfo);
}
public void removeOldRepeatInfo(Todo todo) {
RepeatInfo oldRepeatInfo = todo.getRepeatInfo();
if (oldRepeatInfo != null) {
todo.setRepeatInfo(null);
todo.setTodoInstances(null);
this.deleteRepeatInfo(oldRepeatInfo);
}
}
private void deleteRepeatInfo(RepeatInfo repeatInfo) {
repeatInfoRepository.delete(repeatInfo);
}
TodoInstanceService
public void updateTodoInstance(Long objectId, UpdateAndDeleteTodoQueryParams params, UpdateTodoReq req) {
TodoInstance todoInstance = todoInstanceRepository.findTodoInstanceWithTodo(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
switch (params.getTargetType()) {
case THIS_TASK -> this.updateSingleInstance(req, todoInstance);
case ALL_TASKS -> this.updateAllInstances(req, todoInstance);
default -> throw new BadRequestException(ErrorCode.BAD_REQUEST, "잘못된 type입니다.");
}
}
// 시간 정보 수정은 THIS_TASK 타입만 가능
private void updateSingleInstance(UpdateTodoReq req, TodoInstance todoInstance) {
// 반복 정보 수정은 ALL_TASKS 타입만 가능
if (req.getRepeatInfoReqItem() != null)
throw new BadRequestException(ErrorCode.BAD_REQUEST, "전체 인스턴스에 대해서만 반복 정보 수정이 가능합니다.");
this.validateInstanceTime(req, todoInstance);
todoInstance.update(req);
}
private void validateInstanceTime(UpdateTodoReq req, TodoInstance todoInstance) {
if (req.getEndAt() == null && todoInstance.getEndAt().isBefore(req.getStartAt())
|| req.getStartAt() == null && todoInstance.getStartAt().isAfter(req.getEndAt())
) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "시작 시각은 종료 시각보다 이전이어야 합니다.");
}
}
private void updateAllInstances(UpdateTodoReq req, TodoInstance todoInstance) {
if (req.getStartAt() != null || req.getEndAt() != null) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "시간 정보 수정은 단일 인스턴스에 대해서만 수정 가능합니다.");
}
Todo todo = todoInstance.getTodo();
todo.update(req); // todo 먼저 업데이트
todoInstance.update(req);
if (req.getRepeatInfoReqItem() != null) {
this.updateAllWithNewRepeatInfo(req, todo);
} else {
this.updateAllWithoutNewRepeatInfo(req, todoInstance);
}
}
private void updateAllWithNewRepeatInfo(UpdateTodoReq req, Todo todo) {
repeatInfoService.removeOldRepeatInfo(todo);
todoInstanceRepository.deleteAllByTodo_Id(todo.getId()); // 삭제할 대상이 없어도 예외 X
todo.updateEndAtTemporally();
repeatInfoService.updateRepeatInfo(req, todo);
this.generateAndSaveInstances(todo);
}
private void updateAllWithoutNewRepeatInfo(UpdateTodoReq req, TodoInstance todoInstance) {
List<TodoInstance> todoInstances = todoInstanceRepository.findAllByTodo_Id(todoInstance.getTodo().getId());
todoInstances.forEach(instance -> instance.update(req));
}
기존 메서드 내부에서도 책임을 명확히 하기 위해 약간의 코드를 이동시켜주었다.
ex) repeatInfoService.removeOldRepeatInfo
메서드 내부에 있던 todoInstanceRepository.deleteAllByTodo_Id
메서드는 TodoInstanceService 쪽에 있는 것이 맞다고 생각하여 옮겨줌
TodoService - updateStatusAndReward
@IsTodayTodoOrAdmin
@IsMyTodoOrAdmin
public void updateStatusAndReward(Long objectId, boolean isInstance, Long memberId, UpdateTodoStatusReq req) {
Member findMember = memberRepository.findMemberWithDiligenceUsingLock(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
if (isInstance) {
TodoInstance todoInstance = todoInstanceRepository.findTodoInstanceWithTodo(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
if (req.getIsDone()) {
if (!todoInstance.isDone()) {
todoInstance.updateIsDone(true);
rewardForInstance(todoInstance, findMember);
}
} else {
if (todoInstance.isDone()) {
withdrawRewardForInstance(todoInstance, findMember);
todoInstance.updateIsDone(false);
}
}
} else {
Todo findTodo = todoRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
// 반복 정보가 없는 단일 todo의 경우 -> 단순 상태 업데이트 및 보상 지급
if (!findTodo.isDone() && req.getIsDone()) {
findTodo.updateIsDone(true);
reward(findMember, 1);
} else if (findTodo.isDone() && !req.getIsDone()) {
findTodo.updateIsDone(false);
withdrawReward(findMember, 1);
}
}
}
private void withdrawRewardForInstance(TodoInstance todoInstance, Member member) {
withdrawReward(member, 1);
List<TodoInstance> todoInstances = todoInstanceRepository.findAllByTodo_Id(todoInstance.getTodo().getId());
if (checkAlreadyRewardedForAllCompleted(todoInstances)) { // 이미 모든 인스턴스가 완료되어 보상을 받았는지 확인
todoInstance.getTodo().updateIsDone(false);
withdrawReward(member, todoInstances.size());
}
}
private boolean checkAlreadyRewardedForAllCompleted(List<TodoInstance> todoInstances) {
for (TodoInstance todoInstance : todoInstances) {
if (!todoInstance.isDone()) {
return false;
}
}
return true;
}
private void withdrawReward(Member member, int leverage) {
member.addDailyAchievementCnt(-1);
member.getDiligence().decreaseGauge(GAUGE_INCREASE_RATE * leverage);
member.subtractScheduledReward((long) (REWARD_UNIT * leverage * REWARD_LEVERAGE_RATE));
}
// 단일 일정에 대한 보상 로직
private void reward(Member member, int leverage) {
// 일간 달성 수 1 증가
if (leverage == 1) member.addDailyAchievementCnt(1);
// 유저 일관성 게이지 업데이트
member.getDiligence().increaseGauge(GAUGE_INCREASE_RATE * leverage);
// 보상 지급
member.addScheduledReward((long) (REWARD_UNIT * leverage * REWARD_LEVERAGE_RATE));
}
// 반복 일정에 대한 보상 로직
private void rewardForInstance(TodoInstance todoInstance, Member member) {
// 각 인스턴스가 수행되면 보상 지급
reward(member, 1);
if (todoInstance.getEndAt().equals(todoInstance.getTodo().getEndAt())) { // 마지막 인스턴스를 수행 완료 시
List<TodoInstance> todoInstances = todoInstanceRepository.findAllByTodo_Id(todoInstance.getTodo().getId());
// 모든 인스턴스 수행 완료 여부 확인
if (checkIfAllRepeatsCompleted(todoInstances)) {
todoInstance.getTodo().updateIsDone(true);
reward(member, todoInstances.size());
}
}
}
// 반복 종료일까지 설정한 모든 todo를 수행했는지 체크
private boolean checkIfAllRepeatsCompleted(List<TodoInstance> todoInstances) {
int notCompletedInstancesSize = todoInstances.stream()
.filter(instance -> !instance.isDone())
.toList().size();
return notCompletedInstancesSize == 0;
}
위 코드도 개선해보자!
먼저 if/else문 내부 로직을 각각 메서드로 추출하여 의미를 부여하자
@IsTodayTodoOrAdmin
@IsMyTodoOrAdmin
public void updateStatusAndReward(Long objectId, boolean isInstance, Long memberId, UpdateTodoStatusReq req) {
Member findMember = memberRepository.findMemberWithDiligenceUsingLock(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
if (isInstance) {
todoInstanceService.updateStatusLogicForTodoInstance(objectId, req, findMember);
} else {
this.updateStatusLogicForTodo(objectId, req, findMember);
}
}
private void updateStatusLogicForTodo(Long objectId, UpdateTodoStatusReq req, Member findMember) {
Todo findTodo = todoRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
// 반복 정보가 없는 단일 todo의 경우 -> 단순 상태 업데이트 및 보상 지급
if (!findTodo.isDone() && req.getIsDone()) {
findTodo.updateIsDone(true);
rewardService.reward(findMember, 1);
} else if (findTodo.isDone() && !req.getIsDone()) {
findTodo.updateIsDone(false);
rewardService.withdrawReward(findMember, 1);
}
}
겸사겸사 인스턴스를 다루는 로직은 TodoInstanceService 내부로 옮겨 책임 이동도 해주었다.
또한, 투두를 성공한 경우 보상을 주는 로직은 모두 RewardService라는 클래스를 만들어 내부로 옮겨주었다.
update와 reward라는 2개의 서로 다른 책임을 분리한 것이다. 옮기다 보니 대부분의 메서드가 reward와 관련된 메서드였다..
TodoInstanceService
public void updateStatusLogicForTodoInstance(Long objectId, UpdateTodoStatusReq req, Member findMember) {
TodoInstance todoInstance = todoInstanceRepository.findTodoInstanceWithTodo(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
if (req.getIsDone()) {
if (!todoInstance.isDone()) {
todoInstance.updateIsDone(true);
rewardService.rewardForInstance(todoInstance, findMember);
}
} else {
if (todoInstance.isDone()) {
rewardService.withdrawRewardForInstance(todoInstance, findMember);
todoInstance.updateIsDone(false);
}
}
}
RewardService
@Service
@RequiredArgsConstructor
public class RewardService {
private final TodoInstanceRepository todoInstanceRepository;
// 단일 일정에 대한 보상 로직
public void reward(Member member, int leverage) {
// 일간 달성 수 1 증가
if (leverage == 1) member.addDailyAchievementCnt(1);
// 유저 일관성 게이지 업데이트
member.getDiligence().increaseGauge(GAUGE_INCREASE_RATE * leverage);
// 보상 지급
member.addScheduledReward((long) (REWARD_UNIT * leverage * REWARD_LEVERAGE_RATE));
}
public void withdrawReward(Member member, int leverage) {
member.addDailyAchievementCnt(-1);
member.getDiligence().decreaseGauge(GAUGE_INCREASE_RATE * leverage);
member.subtractScheduledReward((long) (REWARD_UNIT * leverage * REWARD_LEVERAGE_RATE));
}
// 반복 일정에 대한 보상 로직
public void rewardForInstance(TodoInstance todoInstance, Member member) {
// 각 인스턴스가 수행되면 보상 지급
this.reward(member, 1);
if (todoInstance.getEndAt().equals(todoInstance.getTodo().getEndAt())) { // 마지막 인스턴스를 수행 완료 시
List<TodoInstance> todoInstances = todoInstanceRepository.findAllByTodo_Id(todoInstance.getTodo().getId());
// 모든 인스턴스 수행 완료 여부 확인
if (checkIfAllRepeatsCompleted(todoInstances)) {
todoInstance.getTodo().updateIsDone(true);
this.reward(member, todoInstances.size());
}
}
}
// 반복 종료일까지 설정한 모든 todo를 수행했는지 체크
private boolean checkIfAllRepeatsCompleted(List<TodoInstance> todoInstances) {
int notCompletedInstancesSize = todoInstances.stream()
.filter(instance -> !instance.isDone())
.toList().size();
return notCompletedInstancesSize == 0;
}
public void withdrawRewardForInstance(TodoInstance todoInstance, Member member) {
this.withdrawReward(member, 1);
List<TodoInstance> todoInstances = todoInstanceRepository.findAllByTodo_Id(todoInstance.getTodo().getId());
if (this.checkAlreadyRewardedForAllCompleted(todoInstances)) { // 이미 모든 인스턴스가 완료되어 보상을 받았는지 확인
todoInstance.getTodo().updateIsDone(false);
this.withdrawReward(member, todoInstances.size());
}
}
private boolean checkAlreadyRewardedForAllCompleted(List<TodoInstance> todoInstances) {
for (TodoInstance todoInstance : todoInstances) {
if (!todoInstance.isDone()) {
return false;
}
}
return true;
}
}
TodoService - deleteTodo
@IsMyTodoOrAdmin
public void deleteTodo(Long objectId, UpdateAndDeleteTodoQueryParams params) {
if (params.getIsInstance()) {
TodoInstance todoInstance = todoInstanceRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
Todo todo = todoInstance.getTodo();
List<TodoInstance> todoInstances = todo.getTodoInstances();
switch (params.getTargetType()) {
case THIS_TASK -> {
todoInstanceRepository.delete(todoInstance);
todoInstances.remove(todoInstance);
}
case ALL_TASKS -> {
todoInstanceRepository.deleteAllByTodo_Id(todo.getId());
todoInstances.clear();
}
}
if (todoInstances.isEmpty()) {
todoRepository.delete(todo);
}
} else {
Todo findTodo = todoRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
todoRepository.delete(findTodo);
}
}
위에서 한 것과 똑같이 if/else 내부를 별도의 메서드로 추출한 후, 책임을 이동시켜 주자
TodoService
@IsMyTodoOrAdmin
public void deleteTodo(Long objectId, UpdateAndDeleteTodoQueryParams params) {
if (params.getIsInstance()) {
Todo parentOfInstance = todoInstanceService.deleteTodoInstancesAndReturnParent(objectId, params);
if (parentOfInstance.getTodoInstances().isEmpty()) todoRepository.delete(parentOfInstance);
} else {
this.deleteSingleTodo(objectId); // CASCADE로 인스턴스도 같이 삭제
}
}
private void deleteSingleTodo(Long objectId) {
Todo findTodo = todoRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
todoRepository.delete(findTodo);
}
TodoInstanceService
public Todo deleteTodoInstancesAndReturnParent(Long objectId, UpdateAndDeleteTodoQueryParams params) {
TodoInstance todoInstance = todoInstanceRepository.findById(objectId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_TODO));
Todo todo = todoInstance.getTodo();
List<TodoInstance> todoInstances = todo.getTodoInstances();
switch (params.getTargetType()) {
case THIS_TASK -> {
todoInstanceRepository.delete(todoInstance);
todoInstances.remove(todoInstance);
}
case ALL_TASKS -> {
todoInstanceRepository.deleteAllByTodo_Id(todo.getId());
todoInstances.clear();
}
}
return todo;
}
Implement Layer의 도입
현재 서비스 레이어의 문제점
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class PurchaseService {
private final MemberRepository memberRepository;
private final OrderRepository orderRepository;
private final StarPointPaymentHistoryRepository starPointPaymentHistoryRepository;
private final ItemService itemService;
private final PurchaseStrategyFactory purchaseStrategyFactory;
public void preparePayment(Long memberId, PreparePaymentReq req) {
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Item findItem = itemService.getItem(req.getItemId());
if (findItem.getIsPremium() && !findMember.isSubscribed()) {
throw new ForbiddenException(ErrorCode.NOT_SUBSCRIPTION);
}
Order order = Order.of(findItem, findMember, req.getQuantity(), req.getMerchant_uid());
orderRepository.save(order);
// 아이템 재화 타입에 맞는 구매 전략 선택
PurchaseStrategy purchaseStrategy = purchaseStrategyFactory.getStrategy(findItem.getMoneyType());
try {
purchaseStrategy.preValidate(findMember, findItem, req);
} catch (Exception e) {
log.error("사전 검증 실패 = {}", e);
order.updateStatus(OrderStatus.FAILED);
throw new BadRequestException(ErrorCode.PREPARE_PAYMENT_ERROR, e.getMessage());
}
}
public PaymentResourceDTO verifyPayment(Long memberId, PaymentReq req) {
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Order findOrder = orderRepository.findByMerchantUid(req.getMerchant_uid())
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_ORDER));
PurchaseStrategy purchaseStrategy = purchaseStrategyFactory.getStrategy(findOrder.getItem().getMoneyType());
try {
purchaseStrategy.postValidate(findMember, findOrder, req);
} catch (Exception e) {
log.error("사후 검증 실패 = {}", e);
findOrder.updateStatus(OrderStatus.FAILED);
throw new BadRequestException(ErrorCode.POST_VALIDATE_PAYMENT_ERROR, e.getMessage());
}
findOrder.updateStatus(OrderStatus.OK); // order 상태 변경
itemService.postPurchase(findMember, findOrder);
log.info("결제 성공! 멤버 아이디: {}, 주문 아이디: {}", memberId, findOrder.getId());
return PaymentResourceDTO.builder()
.email(findMember.getEmail())
.itemName(findOrder.getItem().getName())
.quantity(findOrder.getQuantity())
.totalPrice(findOrder.getTotalPrice())
.build();
}
public void requestToPurchaseStarpointItem(Long memberId, PurchaseStarPointItemReq req) {
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
StarPointPaymentHistory starPointPaymentHistory = req.toEntity(findMember);
starPointPaymentHistoryRepository.save(starPointPaymentHistory);
}
public PaymentResourceDTO cancelPayment(Long memberId, Long orderId) {
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
Order findOrder = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_ORDER));
PurchaseStrategy purchaseStrategy = purchaseStrategyFactory.getStrategy(findOrder.getItem().getMoneyType());
try {
purchaseStrategy.refund(findMember, findOrder);
} catch (Exception e) {
log.error("결제 취소 실패 = {}", e);
findOrder.updateStatus(OrderStatus.CANCELED);
throw new BadRequestException(ErrorCode.CANCEL_PAYMENT_ERROR, e.getMessage());
}
findOrder.updateStatus(OrderStatus.CANCELED);
return PaymentResourceDTO.builder()
.email(findMember.getEmail())
.itemName(findOrder.getItem().getName())
.quantity(findOrder.getQuantity())
.totalPrice(findOrder.getTotalPrice())
.build();
}
}
- 어떤건 Service를 어떤건 Repository를 참조하고 있다
- 표쥰이 없이 막 사용하다보니 잠재적인 순환참조 문제도 있음
- 너무 많은 서비스 & 레포지토리를 주입받는다
- 너무 많은 책임..
- 중복되는 코드가 발생 => 재사용성의 문제
- 코드를 읽으면서 내려가기 부담스럽다.. 서비스 레이어는 비즈니스 로직을 담고 있어야 하는데, 비즈니스 로직을 이해하기 위해서는 코드를 이해해야 한다
- 코드를 처음 보는 사람은 읽는 데에 시간이 많이 소요된다
- 결국 이 코드는 구현 코드와 다를 바가 없다..
- 상세 구현 로직을 드러내지 않고 흐름을 이해할 수 있도록 하고 싶다..
=> 서비스 레이어에서는 인터페이스만 드러내보자
=> 서비스 레이어와 영속성 레이어 사이에 추상 레이어를 하나 더 쌓고 상세한 구현부를 몰아넣자
레퍼런스를 찾다가 제미니님의 '지속 성장 가능한 소프트웨어를 만들어가는 방법'라는 글을 읽게되었다.
너무나도 좋은 내용들을 포함하고 있고, 내가 처한 상황과 정확히 일치했기에 적극적으로 내 프로젝트에 도입해보고자 한다.
Layer
- Presentation Layer
- 외부 변화에 민감한, 외부 의존성이 높은 영역
- 외부 영역에 대한 처리를 담당하는 코드나 요청, 응답 클래스들도 이 레이어에 속함
- Business Layer
- 비즈니스 로직을 투영하는 레이어
- 만약 코드가 계속 성장하여 비즈니스 로직이 너무 많아지거나 결합이 되어야 하는 경우 당연히 상위 레이어를 더 쌓아올려야 함
- Implement Layer
- 비즈니스 로직이 최대한 인터페이스만을 드러낼 수 있도록 하기 위해 도구로써 상세 구현 로직을 갖고 있는 클래스들이 존재하는 레이어
- 가장 많은 클래스들이 존재
- 상세 구현 로직을 담당하여 재사용성도 높은 핵심 레이어
- Data Access Layer
- 상세 구현 로직이 다양한 자원에 접근할 수 있는 기능을 제공하는 레이어
- 기술 의존성을 격리하여 구현 로직에게 순수한 인터페이스를 제공해야 함
- 일반적으로 별도의 모듈로 구성되어 제공
- But, 우리 프로젝트에서는 이렇게까지 추상화를 시키지는 않을 것.. JPA 외의 다른 기술을 사용하지 않을 예정
규칙
- 레이어들은 위에서 아래로 순방향 참조만 가능해야 함
- 레이어의 참조 방향이 역류되지 않아야 함
- 레이어의 참조가 하위 레이어를 건너 뛰지 않아야 함
- 예를 들어, Service가 Repository를 직접 참조 하지 않는 형태여야 함
- Business Layer가 상세 구현 로직과 구현 기술에 대해 너무 자세히 알고 있는 형태가 될 수 있음
- Business Layer는 Implement Layer만을 참조하여 비즈니스 로직 전개
- 동일 레이어 간에는 서로 참조하지 않아야 함 (단, Implement Layer는 예외적으로 서로 참조 가능)
- Implement Layer 클래스들의 재사용성을 늘리고, 협력이 가능한 높은 완결성의 도구 클래스들을 더 많이 만들게 하기 위함
- 또한, 비즈니스 로직의 오염을 막기 위한 규칙이기도 함
Implement Layer 적용
코드가 많이 달라도 이해부탁드립니다 ㅠㅠ 글을 정리했던 시점과 글을 작성하는 시점에 차이가 좀 있어서 그 사이에 여러 로직 및 구조를 손보다보니 많이 달라졌습니다.. 예시용으로 참고부탁드립니다.
위에서 정한 규칙을 토대로 개선한 코드를 몇개 첨부하겠다!
PaymetService
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {
private final MemberReader memberReader;
private final ItemReader itemReader;
private final OrderReader orderReader;
private final OrderWriter orderWriter;
private final RollbackManager rollbackManager;
private final PaymentProvider paymentProvider;
private final PurchaseManager purchaseManager;
private final RefundManager refundManager;
private final PaidOrderProducer paidOrderProducer;
private void checkIsPremiumItemAndMemberSubscription(Item item, Member member) {
if (item.getIsPremium() && !member.isSubscribed()) {
throw new ForbiddenException(ErrorCode.NOT_SUBSCRIPTION);
} else if (item.getName().equals("유료 플랜 구독권") && member.isSubscribed()) {
throw new BadRequestException(ErrorCode.ALREADY_SUBSCRIPTION);
}
}
@Transactional
public void preparePayment(Long memberId, PreparePaymentReq req) {
log.info("결제 요청 === 멤버 아이디: {}, 주문 아이디: {}", memberId, req.getMerchant_uid());
Member member = memberReader.findById(memberId);
Item item = itemReader.findItemById(req.getItemId());
this.checkIsPremiumItemAndMemberSubscription(item, member);
try {
paymentProvider.prepare(req.getMerchant_uid(), req.getAmount());
orderWriter.create(item, member, req);
log.info("사전 검증 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, req.getMerchant_uid());
} catch (Exception e) {
log.error("사전 검증 실패 === 멤버 아이디: {}, 주문 아이디: {}, 이유: {}", memberId, req.getMerchant_uid(), e.getMessage());
rollbackManager.prepareStageRollback(memberId, req);
log.error("사전 검증 실패 로직 수행 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, req.getMerchant_uid());
throw new InternalServerException(ErrorCode.PREPARE_PAYMENT_ERROR, e.getMessage());
}
}
private void validateMember(Long memberId, Member member) {
if (!Objects.equals(member.getId(), memberId))
throw new BadRequestException(ErrorCode.UNAUTHORIZED);
}
@Transactional
public void completePayment(WebhookPayload req) {
log.info("웹훅 수신 === {}", req);
if (!"paid".equals(req.getStatus())) return;
log.info("결제 완료 이벤트 처리 요청 === 주문 아이디: {}, 결제 아이디: {}", req.getMerchant_uid(), req.getImp_uid());
Order order = orderReader.findByMerchantUid(req.getMerchant_uid());
Member member = order.getMember();
Item item = order.getItem();
this.checkIsPremiumItemAndMemberSubscription(item, member);
TodomonPayment todomonPayment = TodomonPayment.of(order, req.getImp_uid());
order.setPayment(todomonPayment);
try {
paymentProvider.complete(req.getImp_uid(), BigDecimal.valueOf(order.getTotalPrice()));
order.updateStatus(OrderStatus.PAID);
log.info("사후 검증 성공 === 멤버 아이디: {}, 주문 아이디: {}", order.getMember().getId(), order.getMerchantUid());
paidOrderProducer.send(member.getId(), req.getMerchant_uid());
} catch (Exception e) {
log.error("사후 검증 실패 === 멤버 아이디: {}, 주문 아이디: {}, 이유: {}", member.getId(), req.getMerchant_uid(), e.getMessage());
rollbackManager.completeStageRollback(todomonPayment, req);
log.error("사후 검증 실패 로직 수행 성공 === 멤버 아이디: {}, 주문 아이디: {}", member.getId(), req.getMerchant_uid());
throw new InternalServerException(ErrorCode.POST_VALIDATE_PAYMENT_ERROR, e.getMessage());
}
}
@Transactional
public PaymentResourceDTO purchaseItem(Long memberId, String merchantUid) {
log.info("프리미엄 아이템 구매 처리 요청 수신 === 유저 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
Order order = orderReader.findByMerchantUid(merchantUid);
if (!order.getOrderStatus().equals(OrderStatus.PAID)) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "결제가 완료되지 않은 주문 건입니다.");
}
try {
purchaseManager.purchase(order.getMember(), order.getItem(), order.getQuantity());
order.updateStatus(OK);
log.info("프리미엄 아이템 구매 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
return this.createPaymentResourceDTO(order);
} catch (Exception e) {
log.error("프리미엄 아이템 구매 실패 === 멤버 아이디: {}, 주문 아이디: {}, 이유: {}", memberId, merchantUid, e.getMessage());
rollbackManager.purchaseStageRollback(merchantUid);
log.error("프리미엄 아이템 구매 실패 로직 수행 성공 === 멤버 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
throw new InternalServerException(ErrorCode.PURCHASE_ERROR, e.getMessage());
}
}
@Transactional
public PaymentResourceDTO cancelPayment(Long memberId, String merchantUid) {
log.info("환불 처리 요청 수신 === 유저 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
Order order = orderReader.findByMerchantUid(merchantUid);
Member member = order.getMember();
this.validateMember(memberId, member);
if (order.getItem().getName().equals("유료 플랜 구독권")) {
log.info("구독 취소 === 유저 아이디: {}, 주문 아이디: {}", memberId, merchantUid);
member.updateIsSubscribed(false);
}
refundManager.refund(order);
TodomonPayment todomonPayment = order.getPayment();
order.updateStatus(OrderStatus.CANCELED);
todomonPayment.updateStatus(PaymentStatus.REFUNDED);
log.info("환불 성공 === 멤버 아이디: {}, 주문 아이디: {}", order.getMember().getId(), order.getMerchantUid());
return this.createPaymentResourceDTO(order);
}
private PaymentResourceDTO createPaymentResourceDTO(Order order) {
return PaymentResourceDTO.builder()
.email(order.getMember().getEmail())
.itemName(order.getItem().getName())
.quantity(order.getQuantity())
.totalPrice(order.getTotalPrice())
.build();
}
}
MemberService
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberReader memberReader;
private final MemberWriter memberWriter;
@Transactional(readOnly = true)
public List<MemberSearchRes> searchMemberByKey(String key) {
log.debug("유저 검색 === 키: {}", key);
return memberReader.search(key);
}
@Transactional(readOnly = true)
public UserInfo getMemberInfo(Long memberId) {
log.debug("로그인 유저 정보 조회: 유저 아이디: {}", memberId);
return UserInfo.from(memberReader.findById(memberId));
}
@Transactional(readOnly = true)
public ProfileRes getProfile(Long loginMemberId, Long targetMemberId) {
log.debug("유저 프로필 조회: 로그인 유저 아이디: {}, 대상 유저 아이디: {}", loginMemberId, targetMemberId);
return memberReader.getProfile(loginMemberId, targetMemberId);
}
@Transactional
@IsMeOrAdmin
public void updateProfile(Long memberId, UpdateMemberProfileReq req) {
log.info("유저 정보 수정 === 유저 아이디: {}, 요청 정보: {}", memberId, req);
Member member = memberReader.findById(memberId);
memberWriter.modify(member, req);
}
@Transactional
@IsMeOrAdmin
public void withdraw(Long memberId) {
Member member = memberReader.findById(memberId);
log.info("회원 탈퇴 === 유저 아이디: {}, 유저명: {}, 유저 이메일: {}", member.getId(), member.getUsername(), member.getEmail());
memberWriter.withdraw(member);
}
}
MemberReader
@Component
@RequiredArgsConstructor
public class MemberReader {
private final MemberRepository memberRepository;
private final MemberQueryRepository memberQueryRepository;
public List<MemberSearchRes> search(String key) {
return memberQueryRepository.findMemberByNameKey(key);
}
public Member findById(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
public Member findById(Long id, String errMessage) {
return memberRepository.findById(id)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER, errMessage));
}
public Optional<Member> findOptionalByEmail(String email) {
return memberRepository.findByEmail(email);
}
public ProfileRes getProfile(Long loginMemberId, Long targetMemberId) {
return memberQueryRepository.getMemberProfileById(loginMemberId, targetMemberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
public Member findMemberWithPetsById(Long id) {
return memberRepository.findMemberWithPets(id)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
public Member findMemberWithRepresentPet(Long id) {
return memberRepository.findMemberWithRepresentPet(id)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
public Member findMemberWithDiligenceUsingLock(Long memberId) {
return memberRepository.findMemberWithDiligenceUsingLock(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
public Member findByUsername(String username) {
return memberRepository.findByUsername(username)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
}
public boolean checkExistUsername(String username) {
return memberRepository.existsByUsername(username);
}
}
MemberWriter
@Component
@RequiredArgsConstructor
public class MemberWriter {
private static final String ADMIN_EMAIL = "maruhan1016@gmail.com";
private final MemberReader memberReader;
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final FileService fileService;
public Member registerByOAuth2(OAuth2ProviderUser oAuth2ProviderUser) {
Role role = this.isAdmin(oAuth2ProviderUser)
? Role.ROLE_ADMIN
: Role.ROLE_USER;
String providerUsername = oAuth2ProviderUser.getUsername();
boolean isExistingUsername = memberReader.checkExistUsername(providerUsername);
Member member = oAuth2ProviderUser.toMember(role);
if (isExistingUsername) {
member.updateUsername(oAuth2ProviderUser.getProviderId());
}
memberRepository.save(member);
return member;
}
private boolean isAdmin(OAuth2ProviderUser oAuth2ProviderUser) {
return oAuth2ProviderUser.getEmail().equals(ADMIN_EMAIL);
}
public void modify(Member member, UpdateMemberProfileReq req) {
if (StringUtils.hasText(req.getUsername())) member.updateUsername(req.getUsername());
if (req.getProfileImage() != null) this.updateProfileImage(req.getProfileImage(), member);
}
private void updateProfileImage(MultipartFile profileImage, Member member) {
fileService.deleteProfileImage(member.getProfileImageUrl());
String newProfileImageUrl = fileService.storeOneFile(profileImage);
member.updateProfileImageUrl(newProfileImageUrl);
}
public void withdraw(Member member) {
fileService.deleteFile(member.getProfileImageUrl());
memberRepository.delete(member);
refreshTokenRepository.deleteAllByUsername(member.getUsername());
}
}
'Project' 카테고리의 다른 글
[TODOMON] EP.17 배포 후 쿠키 공유 문제 해결 with 도메인 연결 및 SSL 인증서 (0) | 2025.02.09 |
---|---|
[TODOMON] EP.15 로그관리 with ELK (0) | 2025.02.07 |
[TODOMON] EP.14 중복 쿼리 제거하기 with OSIV, AOP, JWT (0) | 2025.02.07 |
[TODOMON] EP.13 OneToOne 양방향 관계 조회 시 지연 로딩이 적용되지 않는 문제 (0) | 2025.02.07 |
[TODOMON] EP.12 아무것도 모르는 나의 동시성 문제 해결기 (0) | 2025.02.05 |