Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[3주차 세미나] 구현 과제 #13

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open

[3주차 세미나] 구현 과제 #13

wants to merge 30 commits into from

Conversation

choyeongju
Copy link
Member

@choyeongju choyeongju commented Nov 6, 2024

⭐ TODO

[필수]

  • 회원가입
  • 일기 작성 (카테고리, 공개하기 기능 추가)
  • 전체 일기 목록 조회 (카테고리, 글자순 정렬 기능 추가)
  • 내 일기 목록 조회 (카테고리, 글자순 정렬 기능 추가)
  • 일기 상세 조회
  • 일기 수정
  • 일기 삭제

[선택]

  • 일기 목록 조회 시 페이징 처리

+++
[2주차 선택 과제 연장선]

  • 5분에 하나만 작성 및 응답형식
  • 30자 넘길 시 응답코드 고민
  • 일기 수정 일일 2회 제한 기능 추가
  • 중복되는 제목 없게

 

초기 기획서 회의 결론

AND-SOPT-SERVER/forum#16

  • API Path 정할 시, h2 에서는 ‘user’가 예약어기 때문에 ‘users’ 쓰기
  • 💖 유저 식별자로는 그냥 pk인 row id 를 넘겨주기
  • 💖 DiaryResponseDto에 diaryId, userId 까지 같이 보내기
  • 회원가입, 로그인 API url 에는 /auth 넣자
  • 헤더값에 대한 key 통일

 

✅ Service 클래스의 역할 별 분리

image

DiaryService 클래스에서 역할 별로 분리한 4개의 클래스들을 필드로 가져와 이 클래스들을 통해 필요한 함수를 호출하도록 했습니다.

  • DiaryRemover : DB에서 엔티티를 ‘삭제’하는 로직만 모아놓음
  • DiaryRetriever : DB에서 엔티티를 조회하여 ‘찾아오는’ 로직만 모아놓음
  • DiarySaver : DB에 엔티티를 ‘저장’하는 로직만 모아놓음
  • DiaryUpdater : DB의 엔티티를 ‘업데이트’ 하는 로직만 모아놓음

⏩ 이를 통해 각 클래스별로 책임을 명확하게 분리했습니다.
⏩ 가독성 또한 향상시킬 수 있습니다.

 

✅ Entity

▶️ @Enumerated

    public class Diary {
    ...
	    @Enumerated(EnumType.STRING)
	    @Column(name = "category", nullable = false)
	    private Category category;
	  ...
	  }

EnumType에는 enum 이름 값을 DB에 저장하는 EnumType.STRING

enum 순서(숫자)값을 DB에 저장하는 EnumType.ORDINAL 이 있지만,

⇒ 안정성을 위해 @Enumerated(EnumType.STRING) 을 사용했습니다.

▶️ cascade=CascadeType.REMOVE

    @OneToMany(mappedBy="user", fetch = FetchType.LAZY, cascade=CascadeType.REMOVE)
    public List<Diary> diaries;

cascade=CascadeType.ALL 로 지정할 경우, 불필요한 연산이 발생될 수 있으며, 가끔 User 삭제 시 관련된 Diary 가 함께 삭제되지 않는 문제가 발생되기도 합니다.(실제 앱잼에서의 경험담) 따라서 REMOVE 로 지정해 주었습니다.

▶️ @Setter 지양

엔티티에서의 setter 사용을 통한 외부에서의 위험한 변경을 막기 위해 불필요한 @Setter 어노테이션을 삭제했습니다. 그에 따라서 public 으로 선언되었던 필드들 모두 private 로 변경해 주었습니다. → 별도의 메서드를 선언해서 setter 를 대신하여 값을 변경해주도록 하자!

 

✅ 회원가입

▶️ @NotNull vs. @NotBlank

@Builder
public record UserJoinDto(
        @NotBlank(message = "Username 은 필수로 입력해야 합니다.")
        String username,
        @NotBlank(message = "Password 는 필수로 입력해야 합니다.")
        String password,
        @NotBlank(message = "Nickname 은 필수로 입력해야 합니다.")
        String nickname
) {
}
  • 단순히 값이 있어야 한다면 @NotNull
  • 의미 있는 값이 입력되어야 한다면 @NotBlank

▶️ ResponseEntity.created

https://00h0.tistory.com/88
무의식적으로 사용하던 것에 대해 제대로 알고자 하여…

▶️ 회원가입 Response 에는 어떤 정보를 담을 것인가?

package org.sopt.diary.dto.response;

import jakarta.validation.constraints.NotBlank;
import lombok.Builder;

@Builder
public record UserResponse(
        @NotBlank
        String username,
        @NotBlank
        String nickname
) {
}

회원가입 후, ‘비밀번호’는 클라이언트에게 불필요한 정보라고 생각되어 반환하지 않았습니다.

 

✅ 일기 수정

User가 일기 수정 화면에 접속했더라도, 일기를 수정하지 않고 그대로 다시 <저장> 버튼을 누르는 상황이 있을 것이라 생각했습니다.

그래서 DiaryUpdateDto 의 모든 값이 null 일 때도 처리가 가능하도록 구현했습니다.

public class DiaryUpdater {

    public void updateDiary(
            final Diary diary,
            final DiaryUpdateDto diaryUpdateDto
    ) {
        String content = diaryUpdateDto.content() != null ? diaryUpdateDto.content() : diary.getContent();
        Category category = diaryUpdateDto.category() != null ? diaryUpdateDto.category() : diary.getCategory();
        boolean visible = diaryUpdateDto.visible() != null ? diaryUpdateDto.visible() : diary.isVisible();

        diary.updateDiary(content, category, visible);
    }
}

DiaryUpdateDto 의 필드 값들을 검사 후, null 이면 기존 일기의 값을 유지하고, 수정이 발생하면 그 수정값을 적용하도록 구현했습니다.

이 때,

public record DiaryUpdateDto(
        @Size(max = 30, message = "일기의 내용은 30자 이내여야 합니다.")
        String content,
        Category category,
        Boolean visible
) {
}

DiaryUpdateDto 에서 visible 변수는 boolean이 아니라 Boolean 타입(Wrapper 타입)을 사용하여 null 값을 허용하도록 해주었습니다. → 사용자가 visible 값을 수정하지 않을 경우에도 null 값을 가질 수 있도록 합니다.

 

일기 수정 일일 2회 제한 기능 추가

    public void updateDiary(String content, Category category, boolean visible) {
        LocalDate today = LocalDate.now();

        if (lastModifiedDate == null || !lastModifiedDate.isEqual(today)) {
            this.modifiedCount = 1;
            this.lastModifiedDate = today;
        } else {
            if (modifiedCount >= 2) {
                throw new DiaryUpdatesLimitException(ErrorCode.DIARY_UPDATE_NOT_ALLOWED);
            }
            modifiedCount++;
        }
        this.content = content;
        this.category = category;
        this.visible = visible;
    }

Diary 엔티티 내부에 일일 수정 횟수를 검증하고 업데이트하는 로직을 두었습니다.

 

💖 페이징 처리(선택과제)

‘전체 일기 목록 조회’ 와 ‘내 일기 조회’ 의 로직은 비슷하므로 전체 일기 목록 조회 를 예시로 모든 구현 과정을 설명하도록 하겠습니다!

✅ DiaryController _ getDiaryList()

    @GetMapping("/diary")
    public ResponseEntity<DiaryListResponse> getDiaryList(
            @RequestParam(name = "category", required = false) final Category category,
            @RequestParam(name = "sort", required = false) final SortOption sortOption,
            @RequestParam(defaultValue = "0") final int page,
            @RequestParam(defaultValue = "10") final int size
            ){
        Category categoryContent = null;
        if (category != null) {
            categoryContent = Category.fromContent(category.getContent());
        }

        SortOption sortOptionContent = null;
        if (sortOption != null) {
            sortOptionContent = SortOption.fromContent(sortOption.getContent());
        }
        return ResponseEntity.ok(diaryService.getDiaryList(categoryContent, sortOptionContent, page, size));
    }

▶️ 카테고리별, 정렬기준별 API를 따로 구현해야 할까..?

카테고리, 정렬기준은 user 가 꼭 선택하지 않아도 되는 값이기 때문에 항목 선택에 따른 API를 굳이 분리하여 만들지 않고 required = false 로 Param 값을 처리할 수 있도록 했습니다.

▶️ 카테고리, 정렬기준에 대한 검증

  • Category와 SoptOption Enum 클래스 안에 fromContent 메서드를 정의하였습니다. 이를 통해서 클라이언트 측에서 입력한 문자열 값을 해당 enum으로 변환하고, 파라미터 값에 제가 정의한 올바른 enum 상수의 content 값에 해당하는지 확인하는 역할을 하게끔 했습니다.

  • 잘못된 값이 들어왔을 경우에는, 서비스까지 가서 데이터를 처리할 필요가 없다고 판단하였기에, 컨트롤러 단에서 검증을 수행하도록 하였습니다.

  • Category

    @Getter
    @AllArgsConstructor
    public enum Category {
        FOOD("음식"),
        SCHOOL("학교"),
        MOVIE("영화"),
        EXERCISE("운동");
    
        private final String content;
    
        public static Category fromContent(String content) {
            for (Category category : Category.values()) {
                if (category.getContent().equals(content)) {
                    return category;
                }
            }
            throw new IllegalArgumentException(ErrorCode.INVALID_ARGUMENTS);
        }
    }

     

    ✅ Pageable

    페이지 번호와 페이지 크기를 전달하면, 내부적으로 오프셋 기반 페이지네이션을 수행하는 Pageable 를 사용하여 페이징 처리를 구현했습니다.

    해당 서비스에서는 무한 스크롤과 같이 이전 커서의 위치가 중요한 것이 아니라, ‘페이지’에 집중하기 때문에 오프셋 기반 페이징을 사용하는 것이 적절한 것 같다고 판단했습니다.

    ▶️ DiaryService_getDiaryList()

    public DiaryListResponse getDiaryList(
                final Category category,
                final SortOption sortOption,
                final int page,
                final int size
        ){
            final Pageable pageable = PageRequest.of(page, size);
            Page<Diary> diaryPage;
    
            SortOption defaultSortOption = (sortOption != null) ? sortOption : SortOption.RECENT_DATE;
    
            if (category != null) { // 카테고리 선택 O
                diaryPage = defaultSortOption == SortOption.CONTENT_LENGTH
                        ? diaryRetriever.findByCategoryOrderByContentLengthDesc(category, pageable) // 내용 길이 순 정렬
                        : diaryRetriever.findByCategoryOrderByCreatedAtDesc(category, pageable); // 최신순 정렬
            } else { // 카테고리 선택 X
                diaryPage = defaultSortOption == SortOption.CONTENT_LENGTH
                        ? diaryRetriever.findAllOrderByContentLengthDesc(pageable) // 내용 길이 순 정렬
                        : diaryRetriever.findAllByOrderByCreatedAtDesc(pageable); // 최신순 정렬 (기본값)
            }
    
            List<DiaryListResponse.DiaryDto> diaryItems = diaryPage.getContent()
                    .stream().map(
                            diary -> DiaryListResponse.DiaryDto.builder()
                                    .id(diary.getId())
                                    .userId(diary.getUser().getId())
                                    .nickname(diary.getUser().getNickname())
                                    .title(diary.getTitle())
                                    .build()
                    ).toList();
    
            return DiaryListResponse.builder()
                    .diaryLists(diaryItems)
                    .build();
        }

 

case 별 분기문

<category 를 선택했을 경우, 하지 않았을 경우> 와 <sortOption을 선택했을 경우, 하지 않았을 경우> 를 나누어 2 x 2 = 4 가지 경우의 수를 만들었습니다.

@choyeongju choyeongju self-assigned this Nov 6, 2024
@choyeongju choyeongju changed the title Seminar2 [3주차 세미나] 구현 과제 Nov 6, 2024
@choyeongju choyeongju assigned airoca and unassigned choyeongju Nov 6, 2024
// 일기 작성
@PostMapping("/diary")
public ResponseEntity<Void> createDiary(
@RequestHeader("Authorization") final Long userId,

Choose a reason for hiding this comment

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

@RequestHeader의 required 가 default 로 true 이므로,
해당 API 의 요청은 무조건 Authorization header 가 필요함. (없으면 별도의 예외를 발생할거임 <- 이건 찾아보시오)

  • 따라서 값이 들어온다면 long 으로 보장이 될거야

그런데 일반적으로는 @RequestHeader를 잘 사용하지 않는 편이야
왜냐하면

  • Authorization 이라는 헤더의 value 가 String 이 들어오면 어떻게 처리할건지?
    • ex. request 의 header 가 Authorization : "abc" 이렇게왔는데, Long 에 대한 타입 불일치가 발생해서
      • String 이여서 NumberFormatException 날수도? 아니면 Spring level 에서 뭔가 파라미터 타입 불일치라는 예외를 발생시킬듯?
    • @RequestHeader 를 잘 사용하려면 이런 예외들 (required : true 일 때 헤더가 없는 경우, 헤더는 있으나 타입이 다를 경우) 다 대응을 해줘야한다

Choose a reason for hiding this comment

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

일반적으로는

  • ArgumentResolver 로 특정 어노테이션 (ex. @AuthorizationHeader )을 만들어서 값을 가져오도록
    • ArgumentResolver 내부의 로직에서 위에 말한 예외들에 대한 처리가능
      • try { ~ } catch (Exception e) {~}

아니면 만약에 Spring Security 를 쓴다면 @AuthenticationPrincipal 이런거 쓰던지~


// 일기 작성 기능
@Transactional
public Diary createDiary(final Long userId, DiaryCreateDto diaryCreateDto){

Choose a reason for hiding this comment

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

Long -> long

@Transactional(readOnly = true)
public class DiaryService {

private final UserService userService;

Choose a reason for hiding this comment

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

Service 간 호출

// 일기 작성 기능
@Transactional
public Diary createDiary(final Long userId, DiaryCreateDto diaryCreateDto){
User user = userService.findById(userId);

Choose a reason for hiding this comment

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

final User user = userService.findById(userId);


@Getter
@RequiredArgsConstructor
public class IllegalArgumentException extends RuntimeException{

Choose a reason for hiding this comment

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

네이밍을 고치면 좋을것같습니다

  • IllegalArgumentException 이미 존재하는 Exception
  • 얘는 어디서 발생할지 몰라
    • java library 내부 코드를 따라가면 알수있긴한데,

@Getter
@Table(name = "users") // user 는 예약어 이므로 users 로 명시적 지정
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

Choose a reason for hiding this comment

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

UserEntity 이런 네이밍이 좀 더 명확해보입니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants