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

Conversation

xxeol2
Copy link
Member

@xxeol2 xxeol2 commented Sep 26, 2023

📌 관련 이슈

✨ PR 세부 내용

"학생 인증 메일 전송 요청"을 이전 요청의 30초 이내에 요청하면 예외가 발생하도록 했습니다.
해당 기능을 구현하게 된 이유는 이슈 본문을 참고해주세요!

추가적으로, 따닥 문제를 방지하고 데이터 정합성을 위해 unique 제약조건을 추가했습니다.

🚨 dev merge시 에러가 나지 않도록, 기존 dev DB 데이터를 검토하는 작업을 수행해야합니다!!

@xxeol2 xxeol2 added the BE 백엔드에 관련된 작업 label Sep 26, 2023
@xxeol2 xxeol2 self-assigned this Sep 26, 2023
@github-actions
Copy link

github-actions bot commented Sep 26, 2023

Unit Test Results

  72 files    72 suites   14s ⏱️
199 tests 199 ✔️ 0 💤 0
202 runs  202 ✔️ 0 💤 0

Results for commit d022838.

♻️ This comment has been updated with latest results.

Comment on lines 94 to 96
studentCodeRepository.deleteByMember(member);
studentRepository.flush();
studentCodeRepository.save(new StudentCode(code, school, member, request.username()));
Copy link
Member Author

@xxeol2 xxeol2 Sep 26, 2023

Choose a reason for hiding this comment

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

flush를 호출해준 이유는,
Hibernate에서 SQL 동작 순서가 정해져있어 항상 insert -> delete 순으로 SQL 쿼리가 호출됩니다. 참고 블로그 링크

image
image

따라서 이전의 데이터가 삭제되기 전 insert문이 실행되면서, Student의 UNIQUE 제약조건때문에 에러가 발생합니다.

이 문제를 해결하기 위해, delete -> flush -> insert 순서로 메서드를 호출했습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

deleteByMember에 @Modifying 을 활용하는 건 어떨까요?
지금은 그냥 flush해도 위에 메소드만 호출된다는게 분명하지만 리스키하게 flush를 하는 것보다, 그냥 deleteByMember만 바로 쿼리를 날려주는게 전 더 괜찮지않을까? 라는 생각이 들어요.


9 / 28
update하는 방식으로 바뀌었지만, 그래도 이 코멘트 삭제안하고 그대로 첨부할게용

Comment on lines +83 to +85
public boolean canReissue(LocalDateTime currentTime) {
return SECONDS.between(issuedAt, currentTime) > MIN_REQUEST_TERM_SECONDS;
}
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.

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

@xxeol2 xxeol2 marked this pull request as draft September 26, 2023 15:37
Comment on lines 39 to 43
@ExtendWith(MockitoExtension.class)
@DisplayNameGeneration(ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
class StudentServiceTest {

Copy link
Member Author

@xxeol2 xxeol2 Sep 26, 2023

Choose a reason for hiding this comment

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

Test 클래스 자동 생성했는데 생성되길래, 이전 테스트 코드가 없는 줄 알고 새롭게 작성했는데...
패키지 구조 옮기면서 추적이 안되는것이었네요 🥲

테스트 코드 패키지 구조도 하루빨리 재정비해야할 것 같습니다...

@xxeol2 xxeol2 marked this pull request as ready for review September 26, 2023 15:41
Comment on lines 78 to 96
request = new StudentSendMailRequest("ash", 1L);
Member member = MemberFixture.member().id(1L).build();
School school = SchoolFixture.school().id(1L).build();

lenient()
.when(memberRepository.findById(anyLong()))
.thenReturn(Optional.of(member));
lenient()
.when(schoolRepository.findById(anyLong()))
.thenReturn(Optional.of(school));
lenient()
.when(studentCodeRepository.findByMemberId(anyLong()))
.thenReturn(Optional.empty());
lenient()
.when(studentRepository.existsByMemberId(anyLong()))
.thenReturn(false);
lenient()
.when(studentRepository.existsByUsernameAndSchoolId(anyString(), anyLong()))
.thenReturn(false);
Copy link
Member Author

Choose a reason for hiding this comment

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

테스트 메서드마다 성공 조건 given절이 덕지덕지 있어서, 무엇을 위한 테스트인지 한 눈에 파악하기 어렵다고 느꼈습니다.

그래서 성공 조건을 @BeforeEach setUp 메서드에서 전부 설정하고, 각 테스트 메서드에서는 해당 테스트 조건만 재정의 하는 방식으로 구현해봤습니다.

그런데 사용되지 않는 given절이 있으면 예외가 발생하고, 예외를 막기 위해서는 lenient() 키워드를 붙여야한다고 합니다.
그런데 이 lenient()는 유감스럽게도 BDDMockito 문법은 지원되지 않습니다...
그래서 어쩔 수 없이 Mockito의 문법을 따랐습니다🥲

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

감동적인 SetupMockito 입니다

Comment on lines +82 to +96
SetUpMockito
.given(memberRepository.findById(anyLong()))
.willReturn(Optional.of(member));
SetUpMockito
.given(schoolRepository.findById(anyLong()))
.willReturn(Optional.of(school));
SetUpMockito
.given(studentCodeRepository.findByMemberId(anyLong()))
.willReturn(Optional.empty());
SetUpMockito
.given(studentRepository.existsByMemberId(anyLong()))
.willReturn(false);
SetUpMockito
.given(studentRepository.existsByUsernameAndSchoolId(anyString(), anyLong()))
.willReturn(false);
Copy link
Member Author

@xxeol2 xxeol2 Sep 26, 2023

Choose a reason for hiding this comment

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

BDDMockito 문법을 활용하기 위해 SetUpMockito 클래스를 만들어줬습니다!
BDDMockito 문법 적용으로 가독성이 높아졌고, 개별 테스트 메서드에서 복붙하기 편해졌습니다. ㅎㅎ

Copy link
Member

Choose a reason for hiding this comment

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

와 lenient 이거 완전 힙합인데요?

@xxeol2 xxeol2 added the ☢️ DB 데이터베이스에 변경이 있는 작업 label Sep 27, 2023
Copy link
Collaborator

@seokjin8678 seokjin8678 left a comment

Choose a reason for hiding this comment

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

BaseTimeEntity 상속 해제하신것 확인했습니다!
추가로 고려해야할 사항에 대해 코멘트 남겼어요!

@@ -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로 땅땅땅 👨🏻‍⚖️

Comment on lines 94 to 96
studentCodeRepository.deleteByMember(member);
studentRepository.flush();
studentCodeRepository.save(new StudentCode(code, school, member, request.username()));
Copy link
Collaborator

Choose a reason for hiding this comment

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

deleteByMember에 @Modifying 을 활용하는 건 어떨까요?
지금은 그냥 flush해도 위에 메소드만 호출된다는게 분명하지만 리스키하게 flush를 하는 것보다, 그냥 deleteByMember만 바로 쿼리를 날려주는게 전 더 괜찮지않을까? 라는 생각이 들어요.


9 / 28
update하는 방식으로 바뀌었지만, 그래도 이 코멘트 삭제안하고 그대로 첨부할게용

@@ -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.

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

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

Comment on lines +83 to +85
public boolean canReissue(LocalDateTime currentTime) {
return SECONDS.between(issuedAt, currentTime) > MIN_REQUEST_TERM_SECONDS;
}
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

@BGuga BGuga left a comment

Choose a reason for hiding this comment

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

수고 많으셨습니다 애쉬 👍
감동적인 예외처리네요

@@ -25,6 +25,8 @@ public enum ErrorCode {
DUPLICATE_STUDENT_EMAIL("이미 인증된 이메일입니다."),
TICKET_CANNOT_RESERVE_STAGE_START("공연의 시작 시간 이후로 예매할 수 없습니다."),
INVALID_STUDENT_VERIFICATION_CODE("올바르지 않은 학생 인증 코드입니다."),
TOO_FREQUENT_REQUESTS("너무 잦은 요청입니다. 잠시 후 다시 시도해주세요."),
Copy link
Member

Choose a reason for hiding this comment

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

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

Comment on lines +82 to +96
SetUpMockito
.given(memberRepository.findById(anyLong()))
.willReturn(Optional.of(member));
SetUpMockito
.given(schoolRepository.findById(anyLong()))
.willReturn(Optional.of(school));
SetUpMockito
.given(studentCodeRepository.findByMemberId(anyLong()))
.willReturn(Optional.empty());
SetUpMockito
.given(studentRepository.existsByMemberId(anyLong()))
.willReturn(false);
SetUpMockito
.given(studentRepository.existsByUsernameAndSchoolId(anyString(), anyLong()))
.willReturn(false);
Copy link
Member

Choose a reason for hiding this comment

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

와 lenient 이거 완전 힙합인데요?

Comment on lines 78 to 96
request = new StudentSendMailRequest("ash", 1L);
Member member = MemberFixture.member().id(1L).build();
School school = SchoolFixture.school().id(1L).build();

lenient()
.when(memberRepository.findById(anyLong()))
.thenReturn(Optional.of(member));
lenient()
.when(schoolRepository.findById(anyLong()))
.thenReturn(Optional.of(school));
lenient()
.when(studentCodeRepository.findByMemberId(anyLong()))
.thenReturn(Optional.empty());
lenient()
.when(studentRepository.existsByMemberId(anyLong()))
.thenReturn(false);
lenient()
.when(studentRepository.existsByUsernameAndSchoolId(anyString(), anyLong()))
.thenReturn(false);
Copy link
Member

Choose a reason for hiding this comment

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

감동적인 SetupMockito 입니다

Comment on lines 17 to 25
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 존재유무를 확인해봐야할 것 같아요!

xxeol2 added 5 commits October 5, 2023 11:05
# Conflicts:
#	backend/src/main/java/com/festago/student/application/StudentService.java
#	backend/src/main/java/com/festago/student/domain/StudentCode.java
#	backend/src/test/java/com/festago/student/application/StudentServiceTest.java
Copy link
Collaborator

@seokjin8678 seokjin8678 left a comment

Choose a reason for hiding this comment

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

고생하셨습니다!

Copy link
Collaborator

@carsago carsago left a comment

Choose a reason for hiding this comment

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

qpflrnt

@xxeol2 xxeol2 merged commit 610915a into dev Oct 6, 2023
3 checks passed
@xxeol2 xxeol2 deleted the feat/#466 branch October 6, 2023 05:50
BGuga pushed a commit that referenced this pull request Oct 17, 2023
* feat: StudentCode unique 제약조건 추가

* refactor: 불필요한 setUp 메서드 제거

* feat: StudnetCode 발행 일자 필드 추가 및 재발행 가능 여부 확인 기능 구현

* feat: 인증번호 재전송 가능 확인 기능 구현

* fix: duplicated key exception으로 insert가 안되는 버그 수정

* refactor: 기존 테스트와 새로운 테스트 병합

* feat: SetUpMockito 클래스 생성 및 적용

* refactor: studentCode unique 제약조건 수정

* refactor: StudentCode에서 BaseTimeEntity 상속받지 않도록 수정

* refactor: username 파라미터로 전달

* refactor: studentCode issued_at not null 제약조건 추가

* refactor: 학생 인증 코드 재발급 로직 update를 활용하도록 수정

* refactor: 429 Too Many Request 적용

* refactor: flyway version 수정

* refactor: StudentCode BaseTimeEntity 상속받지 않도록 수정

* refactor: student_code UNIQUE 제약조건 추가 flyway script 수정
BGuga pushed a commit that referenced this pull request Oct 17, 2023
* feat: StudentCode unique 제약조건 추가

* refactor: 불필요한 setUp 메서드 제거

* feat: StudnetCode 발행 일자 필드 추가 및 재발행 가능 여부 확인 기능 구현

* feat: 인증번호 재전송 가능 확인 기능 구현

* fix: duplicated key exception으로 insert가 안되는 버그 수정

* refactor: 기존 테스트와 새로운 테스트 병합

* feat: SetUpMockito 클래스 생성 및 적용

* refactor: studentCode unique 제약조건 수정

* refactor: StudentCode에서 BaseTimeEntity 상속받지 않도록 수정

* refactor: username 파라미터로 전달

* refactor: studentCode issued_at not null 제약조건 추가

* refactor: 학생 인증 코드 재발급 로직 update를 활용하도록 수정

* refactor: 429 Too Many Request 적용

* refactor: flyway version 수정

* refactor: StudentCode BaseTimeEntity 상속받지 않도록 수정

* refactor: student_code UNIQUE 제약조건 추가 flyway script 수정
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
BE 백엔드에 관련된 작업 ☢️ DB 데이터베이스에 변경이 있는 작업
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[BE] 학생 인증 메일 전송 요청이 동시에 여러건 들어오는 것을 막는다.
4 participants