기능을 구현 하면서 정신 차리고 보니 중복된 코드가 너무 많아서 코드 리팩토링을 진행하기로 했습니다.
여러 사람이 작업하면서 PR 날릴 때 코드 리뷰를 진행하지 않은 결과입니다.
리뷰를 진행하지 않으면 어떤 나비효과를 불러오게 되었는지 요즘 엄청 잘 느끼고 있습니다.😅
대표 서비스 클래스인 DiaryService만 코드 리팩토링 과정을 나타내 보려고 합니다.
미리보는 리팩토링 결과
- 리팩토링 전 341줄로 이루어져있던 코드는 333줄의 코드로 줄었습니다.
- 코드 줄로만 판단했을 때는 많이 줄은 것은 아니나,
전반적인 중복 로직에 대한 코드를 메서드로 분리하여 코드의 양이 줄었고, 메서드 내 가독성이 향상되었습니다. - 신경쓰이는 네이밍도 조금씩 손보아서 코드의 가독성과 명확성이 향상되었습니다.
- 메서드가 많아짐에 따라 어떤 순서로 해당 메서드가 진행되는지 한번에 이해하지 못하는 경우가 있을 것 같아 주석으로 어떤 로직인지 설명을 추가하면 더욱 이해하기 쉬울 것 같습니다.
리팩토링 진행된 순서
이 게시글의 목차는 다음과 같습니다.
- 메서드 내 기능 분리
- 새로 생성한 메서드 중 중복된 부분이 있는지 확인
- 새로 생성한 메서드라도 기능 분리가 필요한 곳이 있는지 확인
- 팀원 분의 코드리뷰
1. 메서드 내 기능 분리하기
기존 DiaryService 코드
너무 많은 기능이 한 메서드 안에 담겨있어서 읽기도 힘든 것을 볼 수 있습니다.
코드 전체를 가져와서 보여드리는 것보다는 save 기능을하는 메서드를 예시로 가져와 보았습니다.
한 메서드가 8-90줄 가량 되는 것도 있는데 이게 말이 안 되죠...😅
@Service
@RequiredArgsConstructor
public class DiaryService {
@Transactional
public DiarySaveResponseDTO save(Long id, DiarySaveRequestDTO requestDto, MultipartFile image) {
// TODO: 기능별 메서드 분리
// 1. 일기 작성하는 사용자 검색
Member member = memberDAO.findById(id)
.orElseThrow(() -> new MemberNotFoundException("해당 사용자가 존재하지 않습니다. id = " + id));
// 2. 다이어리 저장
Diary diary = diaryDAO.save(requestDto.toEntity(member));
// 3. 해시태그가 있다면 해시태그 저장
if (requestDto.getHashtags() != null) {
for (String hashtag : requestDto.getHashtags()) {
TagList tagList = tagListDAO.findByTag(hashtag)
.orElseGet(() -> tagListDAO.save(new TagList(hashtag)));
tagList.incrementCount();
MemberTags memberTags = memberTagsDAO.findByMemberIdAndTagList(member.getId(), tagList)
.orElseGet(() -> memberTagsDAO.save(new MemberTags(member, tagList)));
memberTags.incrementCount();
diaryTagDAO.save(new DiaryTag(diary, memberTags));
}
}
// 4. 리워드 포인트 랜덤 생성 및 저장
long seed = System.currentTimeMillis();
Random random = new Random(seed);
int rewardPoint = random.nextInt(10) + 1;
member.addRandomPoint(rewardPoint);
// 5. 리워드 출처 저장
RewardHistory rewardHistory = rewardHistoryDAO
.save(new RewardHistory(member, RewardType.PLUS_DIARY_WRITE, rewardPoint));
if (rewardHistory.getId() == null) {
throw new IllegalArgumentException("일기 히스토리 저장 오류");
}
// 6. 일기 내 이미지가 있다면 이미지 저장 후 이미지 경로 전송, 없다면 String("") 값 전송
if (requestDto.getHasImage()) {
String imagePath = diaryImageService.uploadDiaryImage(image, FileFolder.PERSONAL_DIARY, diary);
return new DiarySaveResponseDTO(diary.getId(), true, imagePath);
}
return new DiarySaveResponseDTO(diary.getId(), false, "");
}
}
1차적으로 수정된 전체 코드
전체적으로 기능별로 메서드를 만들어서 불러오도록 수정 했습니다.
전반적인 가독성이 향상 된 것을 볼 수 있습니다.
@Service
@RequiredArgsConstructor
public class DiaryService {
@Transactional
public DiarySaveResponseDTO save(Long id, DiarySaveRequestDTO requestDto, MultipartFile image) {
Member member = getMemberById(id);
Diary diary = saveDiary(requestDto, member);
if (requestDto.getHashtags() != null) {
saveTags(requestDto, member, diary);
}
int rewardPoint = makeRewardPoint();
saveRewardPointAndHistory(member, rewardPoint);
if (hasImage(requestDto.getHasImage(), image)) {
//TODO: 만약 hasImage가 true인데 image가 비었다면 FE에게 에러 메시지 보낼 것
String imagePath = diaryImageService.uploadDiaryImage(image, FileFolder.PERSONAL_DIARY, diary);
return new DiarySaveResponseDTO(diary.getId(), true, imagePath);
}
return new DiarySaveResponseDTO(diary.getId(), false, "");
}
private Member getMemberById(Long id) {
return memberDAO.findById(id)
.orElseThrow(() -> new MemberNotFoundException("해당 사용자가 존재하지 않습니다. id = " + id));
}
private Diary saveDiary(DiarySaveRequestDTO requestDto, Member member) {
return diaryDAO.save(requestDto.toEntity(member));
}
private void saveTags(DiarySaveRequestDTO requestDto, Member member, Diary diary) {
for (String hashtag : requestDto.getHashtags()) {
TagList tagList = tagListDAO.findByTag(hashtag)
.orElseGet(() -> tagListDAO.save(new TagList(hashtag)));
tagList.incrementCount();
MemberTags memberTags = memberTagsDAO.findByMemberIdAndTagList(member.getId(), tagList)
.orElseGet(() -> memberTagsDAO.save(new MemberTags(member, tagList)));
memberTags.incrementCount();
diaryTagDAO.save(new DiaryTag(diary, memberTags));
}
}
private int makeRewardPoint() {
long seed = System.currentTimeMillis();
Random random = new Random(seed);
return random.nextInt(10) + 1;
}
private void saveRewardPointAndHistory(Member member, int rewardPoint) {
member.addRandomPoint(rewardPoint);
RewardHistory rewardHistory = rewardHistoryDAO
.save(new RewardHistory(member, RewardType.PLUS_DIARY_WRITE, rewardPoint));
if (rewardHistory.getId() == null) {
throw new IllegalArgumentException("일기 히스토리 저장 오류");
}
}
}
2. 새로 생성된 메서들 중 중복 제거
Tag 관련 메서드 중복 제거
리팩토링 전
// 새로운 태그 저장
private void saveTags(DiarySaveRequestDTO requestDto, Member member, Diary diary) {
for (String hashtag : requestDto.getHashtags()) {
TagList tagList = tagListDAO.findByTag(hashtag)
.orElseGet(() -> tagListDAO.save(new TagList(hashtag)));
tagList.incrementCount();
MemberTags memberTags = memberTagsDAO.findByMemberIdAndTagList(member.getId(), tagList)
.orElseGet(() -> memberTagsDAO.save(new MemberTags(member, tagList)));
memberTags.incrementCount();
diaryTagDAO.save(new DiaryTag(diary, memberTags));
}
}
// 일기 업데이트시 사용하는 새로운 태그 저장
private void saveNewTags(DiaryUpdateRequestDTO requestDto, Diary diary) {
for (String hashtag : requestDto.getHashtags()) {
TagList newTagList = tagListDAO.findByTag(hashtag)
.orElseGet(() -> tagListDAO.save(new TagList(hashtag)));
newTagList.incrementCount();
MemberTags newMemberTags = memberTagsDAO.findByMemberIdAndTagList(diary.getMember().getId(), newTagList)
.orElseGet(() -> memberTagsDAO.save(new MemberTags(diary.getMember(), newTagList)));
newMemberTags.incrementCount();
diaryTagDAO.save(new DiaryTag(diary, newMemberTags));
}
}
알고보니 같은 로직이 들어 가있어서 이 부분을 지우고 본문에 들어있던 tag 관련 로직을 한 곳으로 모아 주었습니다.
리팩토링 후
private void saveTags(List<String> hashtags, Member member, Diary diary) {
if (hashtags != null) {
hashtags.forEach(hashtag -> saveTag(hashtag, member, diary));
}
}
private void saveTag(String hashtag, Member member, Diary diary) {
TagList tagList = tagListDAO.findByTag(hashtag)
.orElseGet(() -> tagListDAO.save(new TagList(hashtag)));
tagList.incrementCount();
MemberTags memberTags = memberTagsDAO.findByMemberIdAndTagList(member.getId(), tagList)
.orElseGet(() -> memberTagsDAO.save(new MemberTags(member, tagList)));
memberTags.incrementCount();
diaryTagDAO.save(new DiaryTag(diary, memberTags));
}
private void updateTags(Diary diary, List<String> newHashtags) {
removeExistingTags(diary);
saveTags(newHashtags, diary.getMember(), diary);
}
@Transactional
public DiarySaveResponseDTO update(Long id, DiaryUpdateRequestDTO requestDto, MultipartFile image) {
Diary diary = getDiaryById(id);
validateUpdateDate(diary);
updateTags(diary, requestDto.getHashtags()); // 변경된 로직
/* 기존 로직
removeExistingTags(diary);
if (requestDto.getHashtags() != null) {
saveNewTags(requestDto, diary);
}
*/
boolean currentHasImage = requestDto.getHasImage();
String imagePath = updateDiaryImage(currentHasImage, image, diary);
return updateDiary(requestDto, diary, currentHasImage, imagePath);
}
덕분에 코드의 중복된 부분을 줄일 수 있었습니다.
3. 기능 분리가 필요한 부분 재확인
서비스 기본 로직과 이미지 관련 로직을 별도의 메서드로 분리하여 가독성을 향상 시켰습니다.
리팩토링 전
// save 메서드 내 이미지가 있을 때 이미지를 저장하는 부분
if (hasImage(requestDto.getHasImage(), image)) {
String imagePath = diaryImageService.uploadDiaryImage(image, FileFolder.PERSONAL_DIARY, diary);
return new DiarySaveResponseDTO(diary.getId(), true, imagePath);
}
return new DiarySaveResponseDTO(diary.getId(), false, "");
//
private String updateDiaryImage(boolean currentHasImage, MultipartFile image, Diary diary) {
boolean originalHasImage = diary.getHasImage() != null && diary.getHasImage();
String imagePath = "";
if (currentHasImage) {
imagePath = diaryImageService.updateImage(originalHasImage, image, FileFolder.PERSONAL_DIARY, diary);
}
if (originalHasImage && !currentHasImage) {
diaryImageService.deleteImage(diary);
}
return imagePath;
}
리팩토링 후
// 변경된 save 메서드 내 이미지가 있을 때 이미지를 저장하는 부분
// 가독성이 향상된 것 뿐만 아니라 이미지 로직이 분리 되었다.
if (hasImage(requestDto.getHasImage(), image)) {
return sendSaveResponseWithImage(diary, image);
}
return new DiarySaveResponseDTO(diary.getId(), false, "");
//
private DiarySaveResponseDTO sendSaveResponseWithImage(Diary diary, MultipartFile image) {
String imagePath = diaryImageService.uploadDiaryImage(image, FileFolder.PERSONAL_DIARY, diary);
return new DiarySaveResponseDTO(diary.getId(), true, imagePath);
}
private String updateDiaryImage(boolean currentHasImage, MultipartFile image, Diary diary) {
boolean originalHasImage = Boolean.TRUE.equals(diary.getHasImage());
if (currentHasImage) {
return diaryImageService.updateImage(originalHasImage, image, FileFolder.PERSONAL_DIARY, diary);
}
if (originalHasImage) {
diaryImageService.deleteImage(diary);
}
return "";
}
해당 리팩토링 하면서 `Boolean.TRUE.equals(diary.getHasImage())` 이 부분도 알게 되었는데 ...
- Boolean.TRUE.equals(null)은 false를 반환합니다.
- Boolean.TRUE.equals(Boolean.TRUE)는 true를 반환합니다.
- Boolean.TRUE.equals(Boolean.FALSE)는 false를 반환합니다.
Boolean의 해당 메서드는 null 값이 들어와도 false로 반환해주는 편리한 메서드였습니다.
이 부분도 다른 로직에 많이 사용했어서 교체 해주면 좋을 것 같은데 ... 일단은 보류해두고 다음 리팩토링 때 진행 해야겠습니다. 😢
처음에는 별거 아니라고 생각을 했었는데 리팩토링 하는 데도 많은 에너지가 쓰이고 생각보다 시간이 많이 걸리는 걸 깨닫게 된 경험이었습니다. 😂
4. PR 날린 후 팀원 분의 코드리뷰
- DiaryDTO의 경우 이름을 DiaryResponseDTO로 하는 것은 어떨까요?
- 클래스의 이름을 명확하게하여 사용하는 곳을 알 수 있도록 하는 것이 좋았습니다.
- 이번에 이미지 기능이 업데이트 되면서 일기들을 모아 보는 페이지에서는 이미지 정보를 보내주지 않고, 세부 일기를 확인 했을 때만 추가 해주자는 논의가 진행되고 있어(확정되지 않음) 추후 확장될 기능을 위해서 `DiaryDetailDTO`를 건의 드렸습니다.
- 이미지 기능이 추가되면서 FE에서 이미지가 존재할 때 `hasImage`라는 boolean 값을 사용 중인데, 해당 변수가 꼭 필요할까요?
- 처음에 저는 이미지 기능은 사용자가 선택 가능한 데다, default가 false라고 생각을 했어서 그 값을 명시해주기 위해 필요하다 생각했습니다. 하지만 해당 리뷰를 읽고 고민을 해보다 `hasImage` 변수 값을 받지 않기로 결정했습니다.
- hasImage 값이 있다면 물론 명시적으로 표현이 가능하지만 hasImage값이 False였을 때 이미지 데이터가 없었을 경우를 위해 따로 예외처리가 필요합니다.(서로 값이 배치되는 경우를 고려)
- 기타 예외를 확인 해야할 곳이 많은 것입니다.
Boolean 값 사용자의 이미지 삽입 유무 FE 전송 값 BE 받은 값 hasImage(True) O O O 정상 O O X 예외 발생 X O O 예외 발생 hasImage(False) O . . 예외 발생 ... 기타 예외 발생 가능 - 하지만 hasImage 변수가 존재하지 않았을 때 image 데이터 자체 값의 유무만 판단하면 되기 때문에 불필요한 로직을 줄일 수 있다고 판단 했습니다.
- 처음에 저는 이미지 기능은 사용자가 선택 가능한 데다, default가 false라고 생각을 했어서 그 값을 명시해주기 위해 필요하다 생각했습니다. 하지만 해당 리뷰를 읽고 고민을 해보다 `hasImage` 변수 값을 받지 않기로 결정했습니다.
'프로젝트 > 프로젝트 과정' 카테고리의 다른 글
깃허브 Organigation 레포지토리 복구하기 (2) | 2024.07.23 |
---|---|
[잔디일기] 에러 핸들링 하기 (0) | 2024.05.24 |
[잔디일기] 패키지 구조에 대한 고민 - 적용결과 (0) | 2024.05.21 |
[잔디일기] 패키지 구조에 대한 고민 (0) | 2024.05.10 |
[토이프로젝트] 잔디일기: 개발하면서 고민했던 부분들 (0) | 2024.04.13 |