-
Notifications
You must be signed in to change notification settings - Fork 8
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
Conversation
studentCodeRepository.deleteByMember(member); | ||
studentRepository.flush(); | ||
studentCodeRepository.save(new StudentCode(code, school, member, request.username())); |
There was a problem hiding this comment.
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 쿼리가 호출됩니다. 참고 블로그 링크
따라서 이전의 데이터가 삭제되기 전 insert문이 실행되면서, Student의 UNIQUE 제약조건때문에 에러가 발생합니다.
이 문제를 해결하기 위해, delete -> flush -> insert 순서로 메서드를 호출했습니다.
There was a problem hiding this comment.
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하는 방식으로 바뀌었지만, 그래도 이 코멘트 삭제안하고 그대로 첨부할게용
public boolean canReissue(LocalDateTime currentTime) { | ||
return SECONDS.between(issuedAt, currentTime) > MIN_REQUEST_TERM_SECONDS; | ||
} |
There was a problem hiding this comment.
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 칼럼을 추가했습니다!
이에 대한 의견 남겨주세요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StudentCode가 상속하고 있는 BaseTimeEntity
를 제외해도 될 것 같네요!
추가적으로 BaseTimeEntity
를 제외한다면 왜 해당 엔티티는 제외되었는지 문서화가 필요한 것 같기도 해요.
추후 왜 이 엔티티만 BaseTimeEntity
가 없지? 실수로 누락된건가? 라고 생각할 수 있을 것 같거든요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2df5dc3 반영 완료!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이렇게 해결해도 되고
BaseTimeEntity protected로 쓰는건 어떨까요? 전 그런 방법도 괜찮을꺼라 여겨서요.
제가 생각못한 문제점 있으시면 아무나 추가 의견 달아주세요!!
There was a problem hiding this comment.
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로 여는 것에 대한 사이드이펙트는 저도 생각나는게 없네요!!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
그러네용 도메인적으로 쓸꺼면 따로 만드는게 좋다고 생각되네요
@ExtendWith(MockitoExtension.class) | ||
@DisplayNameGeneration(ReplaceUnderscores.class) | ||
@SuppressWarnings("NonAsciiCharacters") | ||
class StudentServiceTest { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test 클래스 자동 생성했는데 생성되길래, 이전 테스트 코드가 없는 줄 알고 새롭게 작성했는데...
패키지 구조 옮기면서 추적이 안되는것이었네요 🥲
테스트 코드 패키지 구조도 하루빨리 재정비해야할 것 같습니다...
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); |
There was a problem hiding this comment.
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의 문법을 따랐습니다🥲
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
감동적인 SetupMockito 입니다
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BDDMockito 문법을 활용하기 위해 SetUpMockito 클래스를 만들어줬습니다!
BDDMockito 문법 적용으로 가독성이 높아졌고, 개별 테스트 메서드에서 복붙하기 편해졌습니다. ㅎㅎ
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
와 lenient 이거 완전 힙합인데요?
There was a problem hiding this 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("너무 잦은 요청입니다. 잠시 후 다시 시도해주세요."), |
There was a problem hiding this comment.
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을 만들어서 처리해도 좋을 것 같네요!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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과 메시지로 커버해줘도 안될 이유도 없다.. 솔직히 큰 차이 없다고 느껴서 다른 분들에게 더 명확한 근거가 있다면 그 쪽에 따르거나 다수결에 합류하는 방식으로 갈 것 같아요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저도 429 는 원인이 명확하다 생각해서 써도 괜찮을 것 같아요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
429로 땅땅땅 👨🏻⚖️
studentCodeRepository.deleteByMember(member); | ||
studentRepository.flush(); | ||
studentCodeRepository.save(new StudentCode(code, school, member, request.username())); |
There was a problem hiding this comment.
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("너무 잦은 요청입니다. 잠시 후 다시 시도해주세요."), |
There was a problem hiding this comment.
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과 메시지로 커버해줘도 안될 이유도 없다.. 솔직히 큰 차이 없다고 느껴서 다른 분들에게 더 명확한 근거가 있다면 그 쪽에 따르거나 다수결에 합류하는 방식으로 갈 것 같아요.
public boolean canReissue(LocalDateTime currentTime) { | ||
return SECONDS.between(issuedAt, currentTime) > MIN_REQUEST_TERM_SECONDS; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이렇게 해결해도 되고
BaseTimeEntity protected로 쓰는건 어떨까요? 전 그런 방법도 괜찮을꺼라 여겨서요.
제가 생각못한 문제점 있으시면 아무나 추가 의견 달아주세요!!
There was a problem hiding this 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("너무 잦은 요청입니다. 잠시 후 다시 시도해주세요."), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저도 429 는 원인이 명확하다 생각해서 써도 괜찮을 것 같아요
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
와 lenient 이거 완전 힙합인데요?
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
감동적인 SetupMockito 입니다
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; |
There was a problem hiding this comment.
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;
요거 하나만 적어줘도 외래키에 대한 보조 인덱스 생성하면서 유니크 제약 조건까지 걸 수 있을 것 같아요 !!
There was a problem hiding this comment.
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가 존재하지 않아 예외가 펑! 터져버려서 ㅠㅠ
터짐 방지용으로
- temp_index 생성
- 기존 FK index 드랍
- UNIQUE index 생성
- temp_index 드랍
이 순서로 진행했었습니다!!
그런데 푸우가 제안해주신 방식으로 하면 그럴 필요가 없어지겠네요!
다만, member_id에 대한 index가 FK용 하나, unique용 하나 이렇게 2개가 될 수도 있을 것 같은데
이 부분은 확인 후 다시 코멘트 달겠습니다!
There was a problem hiding this comment.
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 존재유무를 확인해봐야할 것 같아요!
# 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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
qpflrnt
* 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 수정
* 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 수정
📌 관련 이슈
✨ PR 세부 내용
"학생 인증 메일 전송 요청"을 이전 요청의 30초 이내에 요청하면 예외가 발생하도록 했습니다.
해당 기능을 구현하게 된 이유는 이슈 본문을 참고해주세요!
추가적으로, 따닥 문제를 방지하고 데이터 정합성을 위해 unique 제약조건을 추가했습니다.
🚨 dev merge시 에러가 나지 않도록, 기존 dev DB 데이터를 검토하는 작업을 수행해야합니다!!