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

[BE] feat: 학생 인증 메일 전송 잦은 재요청시 예외처리 (#466) #467

Merged
merged 18 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5df569c
feat: StudentCode unique 제약조건 추가
xxeol2 Sep 26, 2023
10ec56e
refactor: 불필요한 setUp 메서드 제거
xxeol2 Sep 26, 2023
d781540
feat: StudnetCode 발행 일자 필드 추가 및 재발행 가능 여부 확인 기능 구현
xxeol2 Sep 26, 2023
e078907
feat: 인증번호 재전송 가능 확인 기능 구현
xxeol2 Sep 26, 2023
1356bbc
fix: duplicated key exception으로 insert가 안되는 버그 수정
xxeol2 Sep 26, 2023
af0550c
refactor: 기존 테스트와 새로운 테스트 병합
xxeol2 Sep 26, 2023
e7460e8
feat: SetUpMockito 클래스 생성 및 적용
xxeol2 Sep 26, 2023
2087790
refactor: studentCode unique 제약조건 수정
xxeol2 Sep 27, 2023
2df5dc3
refactor: StudentCode에서 BaseTimeEntity 상속받지 않도록 수정
xxeol2 Sep 27, 2023
b906f9b
refactor: username 파라미터로 전달
xxeol2 Sep 27, 2023
0556b14
refactor: studentCode issued_at not null 제약조건 추가
xxeol2 Sep 27, 2023
7d56a08
refactor: 학생 인증 코드 재발급 로직 update를 활용하도록 수정
xxeol2 Sep 27, 2023
8a9bd43
refactor: 429 Too Many Request 적용
xxeol2 Oct 2, 2023
71c574f
Merge remote-tracking branch 'origin/dev' into feat/#466
xxeol2 Oct 5, 2023
2986630
Merge remote-tracking branch 'origin/dev' into feat/#466
xxeol2 Oct 6, 2023
f896daf
refactor: flyway version 수정
xxeol2 Oct 6, 2023
5d463db
refactor: StudentCode BaseTimeEntity 상속받지 않도록 수정
xxeol2 Oct 6, 2023
d022838
refactor: student_code UNIQUE 제약조건 추가 flyway script 수정
xxeol2 Oct 6, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public enum ErrorCode {
DUPLICATE_STUDENT_EMAIL("이미 인증된 이메일입니다."),
TICKET_CANNOT_RESERVE_STAGE_START("공연의 시작 시간 이후로 예매할 수 없습니다."),
INVALID_STUDENT_VERIFICATION_CODE("올바르지 않은 학생 인증 코드입니다."),
TOO_FREQUENT_REQUESTS("너무 잦은 요청입니다. 잠시 후 다시 시도해주세요."),
Copy link
Collaborator

Choose a reason for hiding this comment

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

HTTP 응답에는 다음과 같은 코드가 있어요!
https://developer.mozilla.org/ko/docs/Web/HTTP/Status/429
새로운 Exception을 만들어서 처리해도 좋을 것 같네요!

Copy link
Member Author

Choose a reason for hiding this comment

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

오... 이런 상태코드가 있었군요 없는게없는...

그런데 이전에 409 Conflict 상태코드를 사용할 것이냐 말 것이냐 논의가 길게 이어졌던 것 같은데
결론은 그냥 400 BadRequest로 에러를 정형화하자. 로 결론이 났던 것 같아요!
그래서 저는 이번 케이스도 별도의 Exception으로 처리하지 않고 정형화된 Exception을 활용하는 것도 나쁘지 않아보여요!

별개의 Exception을 만들어 429 보내기 vs 기존 BadReqeustException 써서 400 보내기
어떤게 좋아보이시나요?
@seokjin8678 @BGuga @carsago

Copy link
Collaborator

Choose a reason for hiding this comment

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

저는 409 conflict를 쓰냐 마냐 상황에서는, 400을 보내는것에 기울었던걸로 기억해요. 왜냐하면 409라는 스펙이 정의가 되어있음에도, 거의 사용되어 있지 않기에(저번에 이 부분이 궁금해서 토스 API 명세를 봤을때도 존재하지 않더군요. 물론 이걸 선호하는 서비스도 존재할 거에요) 오히려 bad Request와 메시지를 통하는게 클라이언트 입장에서 더 혼란을 줄이고 명확할 수 있다는 점이였습니다.

다만 저는 too many request는 은근 쓰이고, 그 네이밍 자체로 명확해서 괜찮다는 의견도 있으면서, 동시에 400과 메시지로 커버해줘도 안될 이유도 없다.. 솔직히 큰 차이 없다고 느껴서 다른 분들에게 더 명확한 근거가 있다면 그 쪽에 따르거나 다수결에 합류하는 방식으로 갈 것 같아요.

Copy link
Member

Choose a reason for hiding this comment

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

저도 429 는 원인이 명확하다 생각해서 써도 괜찮을 것 같아요

Copy link
Member Author

Choose a reason for hiding this comment

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

429로 땅땅땅 👨🏻‍⚖️



// 401
EXPIRED_AUTH_TOKEN("만료된 로그인 토큰입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import com.festago.student.dto.StudentVerificateRequest;
import com.festago.student.repository.StudentCodeRepository;
import com.festago.student.repository.StudentRepository;
import java.time.Clock;
import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -28,29 +30,54 @@ public class StudentService {
private final SchoolRepository schoolRepository;
private final MemberRepository memberRepository;
private final StudentRepository studentRepository;
private final Clock clock;

public StudentService(MailClient mailClient, VerificationCodeProvider codeProvider,
StudentCodeRepository studentCodeRepository, SchoolRepository schoolRepository,
MemberRepository memberRepository, StudentRepository studentRepository) {
MemberRepository memberRepository, StudentRepository studentRepository, Clock clock) {
this.mailClient = mailClient;
this.codeProvider = codeProvider;
this.studentCodeRepository = studentCodeRepository;
this.schoolRepository = schoolRepository;
this.memberRepository = memberRepository;
this.studentRepository = studentRepository;
this.clock = clock;
}

public void sendVerificationMail(Long memberId, StudentSendMailRequest request) {
validateStudent(memberId);
validateDuplicateEmail(request);
Member member = findMember(memberId);
School school = findSchool(request.schoolId());
validate(memberId, request);
VerificationCode code = codeProvider.provide();
studentCodeRepository.deleteByMember(member);
studentCodeRepository.save(new StudentCode(code, school, member, request.username()));
saveStudentCode(code, member, school, request.username());
mailClient.send(new VerificationMailPayload(code, request.username(), school.getDomain()));
}

private Member findMember(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
}

private School findSchool(Long schoolId) {
return schoolRepository.findById(schoolId)
.orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND));
}

private void validate(Long memberId, StudentSendMailRequest request) {
validateFrequentRequest(memberId);
validateStudent(memberId);
validateDuplicateEmail(request);
}

private void validateFrequentRequest(Long memberId) {
studentCodeRepository.findByMemberId(memberId)
.ifPresent(code -> {
if (!code.canReissue(LocalDateTime.now(clock))) {
throw new BadRequestException(ErrorCode.TOO_FREQUENT_REQUESTS);
}
});
}

private void validateStudent(Long memberId) {
if (studentRepository.existsByMemberId(memberId)) {
throw new BadRequestException(ErrorCode.ALREADY_STUDENT_VERIFIED);
Expand All @@ -63,20 +90,19 @@ private void validateDuplicateEmail(StudentSendMailRequest request) {
}
}

private Member findMember(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND));
}

private School findSchool(Long schoolId) {
return schoolRepository.findById(schoolId)
.orElseThrow(() -> new NotFoundException(ErrorCode.SCHOOL_NOT_FOUND));
private void saveStudentCode(VerificationCode code, Member member, School school, String username) {
studentCodeRepository.findByMemberId(member.getId())
.ifPresentOrElse(
studentCode -> studentCode.reissue(code, school, username),
() -> studentCodeRepository.save(new StudentCode(code, school, member, username))
);
}

public void verificate(Long memberId, StudentVerificateRequest request) {
validateStudent(memberId);
Member member = findMember(memberId);
StudentCode studentCode = studentCodeRepository.findByCodeAndMember(new VerificationCode(request.code()), member)
StudentCode studentCode = studentCodeRepository.findByCodeAndMember(new VerificationCode(request.code()),
member)
.orElseThrow(() -> new BadRequestException(ErrorCode.INVALID_STUDENT_VERIFICATION_CODE));
studentRepository.save(new Student(member, studentCode.getSchool(), studentCode.getUsername()));
studentCodeRepository.deleteByMember(member);
Expand Down
40 changes: 36 additions & 4 deletions backend/src/main/java/com/festago/student/domain/StudentCode.java
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
package com.festago.student.domain;

import com.festago.common.domain.BaseTimeEntity;
import static java.time.temporal.ChronoUnit.SECONDS;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
import com.festago.member.domain.Member;
import com.festago.school.domain.School;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import org.springframework.util.StringUtils;


@Entity
public class StudentCode extends BaseTimeEntity {
@EntityListeners(AuditingEntityListener.class)
public class StudentCode {

private static final int MIN_REQUEST_TERM_SECONDS = 30;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand All @@ -29,24 +40,31 @@ public class StudentCode extends BaseTimeEntity {
private School school;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(unique = true)
private Member member;

private String username;

@NotNull
@LastModifiedDate
private LocalDateTime issuedAt;

protected StudentCode() {
}

public StudentCode(VerificationCode code, School school, Member member, String username) {
this(null, code, school, member, username);
this(null, code, school, member, username, null);
}

public StudentCode(Long id, VerificationCode code, School school, Member member, String username) {
public StudentCode(Long id, VerificationCode code, School school, Member member, String username,
LocalDateTime issuedAt) {
validate(username);
this.id = id;
this.code = code;
this.school = school;
this.member = member;
this.username = username;
this.issuedAt = issuedAt;
}

private void validate(String username) {
Expand All @@ -59,6 +77,16 @@ private void validate(String username) {
}
}

public boolean canReissue(LocalDateTime currentTime) {
return SECONDS.between(issuedAt, currentTime) > MIN_REQUEST_TERM_SECONDS;
}
Comment on lines +79 to +81
Copy link
Member Author

Choose a reason for hiding this comment

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

createdAt으로 비교를 하려 했었는데,
createdAt이 부모 클래스(BaseEntity)의 private 필드라 외부에서 설정해줄 수 없어 테스트에 어려움을 겪었습니다 🥲

issuedAt은 createdAt과 동일한 값을 가지는데, 해당 필드가 비즈니스 로직에 사용되기도 하고, 이정도의 중복은 괜찮다고 생각해 issuedAt 칼럼을 추가했습니다!

이에 대한 의견 남겨주세요!

Copy link
Collaborator

Choose a reason for hiding this comment

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

StudentCode가 상속하고 있는 BaseTimeEntity를 제외해도 될 것 같네요!
추가적으로 BaseTimeEntity를 제외한다면 왜 해당 엔티티는 제외되었는지 문서화가 필요한 것 같기도 해요.
추후 왜 이 엔티티만 BaseTimeEntity가 없지? 실수로 누락된건가? 라고 생각할 수 있을 것 같거든요!

Copy link
Member Author

Choose a reason for hiding this comment

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

2df5dc3 반영 완료!

Copy link
Collaborator

Choose a reason for hiding this comment

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

이렇게 해결해도 되고
BaseTimeEntity protected로 쓰는건 어떨까요? 전 그런 방법도 괜찮을꺼라 여겨서요.
제가 생각못한 문제점 있으시면 아무나 추가 의견 달아주세요!!

Copy link
Member Author

Choose a reason for hiding this comment

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

delete-and-insert 대신 update를 채택하면서 isseudAt 칼럼이 생성일 기준이 아닌 수정일 기준으로 바뀌었어요! (7d56a08)

저는 이번 경우에는 issuedAt을 활용하는 것이 더 좋아보여요.
재발급 가능 여부를 결정짓는 중요한 필드이기 때문입니다!
재발급 가능 여부 확인 메서드에서, updatedAt으로 현재시간과 비교를 하면 어색할 것 같아요. 서비스 코드를 봐야 이해할 수 있는 도메인로직이라는 생각이 들어요!

이번과 별개로 BaseTimeEntity를 protected로 여는 것에 대한 사이드이펙트는 저도 생각나는게 없네요!!

Copy link
Collaborator

Choose a reason for hiding this comment

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

그러네용 도메인적으로 쓸꺼면 따로 만드는게 좋다고 생각되네요


public void reissue(VerificationCode code, School school, String username) {
this.code = code;
this.school = school;
this.username = username;
}

public Long getId() {
return id;
}
Expand All @@ -78,4 +106,8 @@ public Member getMember() {
public String getUsername() {
return username;
}

public LocalDateTime getIssuedAt() {
return issuedAt;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public interface StudentCodeRepository extends JpaRepository<StudentCode, Long>
void deleteByMember(Member member);

Optional<StudentCode> findByCodeAndMember(VerificationCode code, Member member);

Optional<StudentCode> findByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- issued_at 칼럼 추가 (NOT NULL)
alter table student_code
add column issued_at datetime(6) not null
default '1999-12-31 00:00:00';

alter table student_code
alter column issued_at drop default;

-- 기존 created_at updated_at 삭제
alter table student_code
drop column created_at;

alter table student_code
drop column updated_at;

-- StudentCode의 member_id UNIQUE 제약조건 추가
create index temp_member_index
on student_code (member_id);

drop index fk_student_code__member on student_code;

create unique index fk_student_code__member
on student_code (member_id);

drop index temp_member_index on student_code;
Copy link
Member

Choose a reason for hiding this comment

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

요거 실행 시켜보면 fk_student_code__member 가 없다고 뜨는 것 같아요!!
보니까 같은 컬럼에 대한 index 를 temp_member_index 로 만드니 덮어쓰기 되는 것 같아요!!
그래서 이 4개의 쿼리를

ALTER TABLE student_code
    MODIFY COLUMN member_id bigint UNIQUE;

요거 하나만 적어줘도 외래키에 대한 보조 인덱스 생성하면서 유니크 제약 조건까지 걸 수 있을 것 같아요 !!

Copy link
Member Author

@xxeol2 xxeol2 Oct 1, 2023

Choose a reason for hiding this comment

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

완전 HIP 한데요?

아래는 V1.sql 중 일부인데

alter table student_code
    add constraint fk_student_code__member
        foreign key (member_id)
            references member (id);

요러코롬 StudentCode 테이블에 fk_student_code__member로 외래키 제약조건을 추가했었어요!

이번에 해당 인덱스를 UNIQUE를 추가하려했는데
이게 기존 index에 UNIQUE를 추가하는 방법은 안보이고, 인덱스를 drop하고 새롭게 create해줘야 하더라구요?
근데 이게 drop -> create 문을 순차적으로 실행시키니까 중간 과정에는 FK에 대한 index가 존재하지 않아 예외가 펑! 터져버려서 ㅠㅠ
터짐 방지용으로

  1. temp_index 생성
  2. 기존 FK index 드랍
  3. UNIQUE index 생성
  4. temp_index 드랍

이 순서로 진행했었습니다!!

그런데 푸우가 제안해주신 방식으로 하면 그럴 필요가 없어지겠네요!
다만, member_id에 대한 index가 FK용 하나, unique용 하나 이렇게 2개가 될 수도 있을 것 같은데
이 부분은 확인 후 다시 코멘트 달겠습니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

요거 실행 시켜보면 fk_student_code__member 가 없다고 뜨는 것 같아요!!

이 부분은 제 로컬 DB에선 잘 실행되었고, 푸우 DB에선 실행되지 않은 걸 보니
캠퍼스 돌아가서 dev DB의 index 존재유무를 확인해봐야할 것 같아요!

Loading
Loading