안녕!
우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다.
사장모드: stampcrush.site/admin
고객모드: stampcrush.site
💋 인트로
혼자서 해당 내용을 개발하겠다고 맡았는데,
중간에 큰 문제가 발생해서 전 팀원이 매달릴 정도로 좀 어려운 상황이 발생해서 정리해보려고 한다.
💋 만들려고 하는 기능의 플로우 소개
✔ 이 기능이 필요한 이유
우리 서비스는 전화번호를 통한 간편한 적립을 우리 서비스의 핵심 가치로 생각하고 있다.
따라서 이전에 스탬프크러쉬 서비스에 가입한 적이 없던 고객도 전화번호만 눌러서 간편하게 적립을 할 수 있는 등 서비스를 제한적으로 사용할 수 있고, 이후에 직접 웹사이트로 들어와 서비스를 이용하려면 간편한 회원가입을 통해 등록 회원이 되어야 한다.
또한 이 때, 간편 회원가입을 하게 되면, 기존에 임시 회원(비회원)일 때 쌓아두었던 데이터를 모두 연동해야 한다.
✔ 기능을 추가한 서비스의 플로우
우리 서비스는 사장모드의 [스탬프 적립] 기능에서 고객이 전화번호를 입력하면, 적립이 되는 시스템이다.
카페에서 전화번호를 입력했을 때, 입력한 전화번호에 해당하는 고객이 데이터베이스에 없으면 임시 회원으로 가입할 수 있다.
하지만, 고객모드 웹사이트에서 직접 자신이 가진 쿠폰을 확인하려면 회원가입/로그인 절차가 필요하다. (현재는 소셜 로그인만 지원하는 상황이다.)
아쉽게도 카카오 소셜 로그인 이후에 전화번호를 제공받을 수 없어서, 회원가입 직후에 우리의 Customer 테이블에는 전화번호가 존재하지 않는다.
따라서 우리 서비스는 회원가입 이후에 직접 전화번호를 입력받는 페이지를 통해 전화번호를 입력받고 있다.
(프론트엔드에서 지속적으로 전화번호가 데이터베이스에 존재하는지를 확인해서 존재하지 않는다면, 해당 페이지를 탈출하더라도 계속해서 전화번호를 입력하라고 요청한다.)
이때, 전화번호를 입력받았다면 세 가지 플로우가 가능하다.
- 해당 전화번호를 사용하는 가입 고객(Register Customer)이 이미 존재할 수 있다.
- 해당 전화번호를 사용하는 임시 고객(Temporary Customer)이 이미 존재할 수 있다.
- 해당 전화번호를 사용하는 고객이 존재하지 않을 수 있다.
3번의 경우는 간단하게 해당 전화번호를 phone_number 칼럼에 저장하는 API를 호출하면 된다.
나머지 경우에 어떻게 할 것인지에 대해서는, 아래와 같이 정리해 보았다.
- 회원가입 (절차에서 전화번호 입력 없음.)
- 회원가입 이후에 등록할 전화번호 입력
- 입력받은 전화번호가 이미 존재하는지 전화번호로 고객 조회 API 호출
- 조회 결과에 따른 대응
- 가입 회원 중에서 이미 해당 번호가 존재하는 경우 ⇒ 전화번호 등록 거부 (재입력 요구)
- 임시 회원 중 이미 해당 번호가 존재하는 경우
- 프론트엔드에서 데이터를 연동할 것인지 물어봄
- 연동하기 선택 ⇒ 데이터 연동 API 호출
- 거부 선택 ⇒ 전화번호 등록 거부 (재입력 요구)
- 프론트엔드에서 데이터를 연동할 것인지 물어봄
- 회원 중에 해당 번호가 존재하지 않는 경우 ⇒ 전화번호 저장 API 호출
💋 구현 내용 소개
✔ API 명세
✔ 테이블 구조: Diagram
회원 데이터와 관련된 우리 테이블의 구조를 Diagram으로 나타내면 아래와 같다.
우리 테이블의 describe문을 찍어보면 아래와 같다.
✔ 테이블의 특징: 상속 관계 매핑 조인(Join) 전략
- JPA에서 지원하는 상속 구조를 사용하고 있다.
- 초기 구현 당시에 null을 피하고, 테이블을 정규화하고 싶어서 상속 관계 매핑 전략 중 조인 전략을 사용했다.
💋 구체적인 구현 방법
✔ 데이터 연동 전 레코드의 상태
레코드 주소는 DB 상에 보이지는 않지만, 설명의 편의를 위해 추가했다.
- 이전에 가입했던, Customer Table 내 임시 회원의 레코드
customer 테이블
temporary_customer 테이블
별다른 정보 없음
- 시간이 흘러, 회원가입을 했을 경우에 추가된 가입 회원의 레코드
customer 테이블
register_customer 테이블
✔ 데이터 연동 후 원하는 결과
데이터가 연동되었다면, 다른 테이블에서 이미 foreign key로 사용하고 있는 id 2를 유지하면서, dtype, oauth과 관련된 칼럼을 update해야 할 것이다.
- 2번 아이디를 사용하도록 함.
- 가입 회원의 nickname, dtype, oauth_provider, oauth_id를 사용하도록 함.
💋 여러 가지 시도
✔ Try 1: 새로운 레코드의 id 변경 [실패]
1004 레코드를 삭제한 후, 2004 레코드의 id를 2로 변경하고, phone_number를 update한다.
- 우리 프로젝트에서 사용하는 MySQL의 InnoDB는 클러스터링 인덱스를 지원하는 스토리지 엔진이다.
- 1004 레코드를 삭제한다면, pk를 기준으로 모든 레코드의 위치가 재구성될 가능성이 있다. (극한의 비효율)
- 2004 레코드의 id를 2로 바꾼다면 해당 레코드의 저장 위치까지 1004로 바꿔야 한다는 문제점이 있다.
🚨 실패 이유
- 1004 레코드는 다른 테이블과 fk로 엮여 있어서, 삭제가 불가했음.
1004 레코드를 그대로 두고, 해당 레코드에 update하는 것이 맞다고 생각하게 됨.
✔ Try 2: 기존 레코드에 데이터 업데이트 [실패]
1004 레코드의 dtype , nickname, dtype, oauth_provider, oauth_id를 update하고, 2004 레코드를 삭제한다.
- 2004 레코드 이후에 더 많은 레코드가 쌓였을 지라도, 1004 뒤에 비해서는 덜 쌓였을 테니, 레코드 위치 재구성이 덜 치명적일 것으로 예상한다.
🚨 실패 이유
- 기존 레코드는 temporary_customer 테이블에 저장되어 있기 때문에, oauth_provider, oauth_id 칼럼이 아예 존재하지 않아서 업데이트할 수가 없었음.
✔ Try 3: 두 레코드를 모두 삭제하고, id가 2인 레코드를 새로 INSERT [실패]
🚨 실패 이유
- 1004 레코드는 다른 테이블과 fk로 엮여 있어서, 삭제가 불가했음.
✔ Try 4: 상속 관계 매핑을 조인 전략에서 단일 테이블 전략으로 변경 [실패]
아래와 같은 테이블 구조로 변경해서, 1004 레코드의 dtype , nickname, dtype, oauth_provider, oauth_id를 update하고, 2004 레코드를 삭제한다.
Try2에서 실패한 이유를 보완할 수 있었음.
🚨 실패 이유
- JPA에서 dtype을 사용해서 register_customer, temporary_customer 중 어떤 테이블에서 찾을지에 대해 결정하기 때문에 fk 제약조건을 풀어 1004 레코드를 강제로 삭제하더라도, id:2로 검색한다면 새로 생긴 레코드를 찾아오지 못하고 null을 반환해버림.
✔ Try 5: Try 4 + 네이티브 쿼리로 dtype 변경 [반쪽짜리 성공]
JPA가 데이터를 찾아올 때, dtype을 사용해서 찾아오기 때문에, jdbcTemplate를 사용해서 직접 바꿔주면 되는게 아닌가?
실제 데이터베이스에서 위와 같이 실행하니 정말로 dtype이 변했다.
@Test
void 데이터_연동_테스트() {
TemporaryCustomer temporaryCustomer = temporaryCustomerRepository.save(TemporaryCustomer.from("01038626099"));
Long id2 = temporaryCustomer.getId();
RegisterCustomer registerCustomer = registerCustomerRepository.save(
RegisterCustomer.builder()
.nickname("레오")
.oAuthId(12334512L)
.oAuthProvider(OAuthProvider.KAKAO)
.build()
);
registerCustomer.registerPhoneNumber("01038626099");
Long id300 = registerCustomer.getId();
em.flush();
jdbcTemplate.execute("update customer set dtype='register' where customer_id=" + id2);
em.flush();
em.clear();
Optional<TemporaryCustomer> find = temporaryCustomerRepository.findById(id2);
assertThat(find).isEmpty();
}
🚨 실패 이유
하지만, dtype을 바꾸는 것은 굉장히 억지스러웠는데....
네, `@DiscriminatorValue("register")`와 같은 어노테이션을 사용하여 dtype 값을 설정한 경우에는 직접 dtype 필드를 업데이트할 수 없습니다. 이는 JPA에서 상속 구조 조인 전략을 사용할 때의 제약 사항 중 하나입니다. dtype 값을 jpql 쿼리로 직접 변경하는 것은 일반적으로 권장되지 않습니다. dtype 값은 JPA가 엔티티를 올바른 테이블에서 가져오기 위해 사용하는 중요한 정보이기 때문에, 임의로 변경하면 JPA의 내부 동작을 망칠 수 있습니다. 만약 특정 엔티티의 dtype 값을 변경하고 싶다면, 다른 방법을 고려해야 합니다. 예를 들어, 해당 엔티티의 인스턴스를 새로 생성하거나, 상속 구조 조인 전략을 사용하지 않는 다른 전략을 선택하는 것이 좋습니다. 따라서, dtype 값을 jpql 쿼리로 직접 변경하는 것은 권장되지 않는 방법입니다. 상황에 따라 적절한 대안을 찾아 사용해야 합니다.
또한, 구구 코치도 반대했다.
- 현재 구조로 인해서 여기까지 한계를 느낀 것 같다고 생각한다.
- Native Query로 dtype 바꾸는건 신입 개발자가 절대 알아볼 수 없는 코드다.
- 데이터 정합성이 망가지기 때문에, 유지보수가 최악이다.
✔ Try 6: 엔티티 구조를 완전히 바꾼다. [진행중]
구구와의 상담을 통해 아래와 같이 결심할 수 있었다...
- dtype을 제거하고 enum을 통해서 REGISTER, TEMPORARY 여부를 구분하도록 변경한다.
- 상속 관계 매핑을 제거한다.
결과적으로는 이런 식으로 사용하게 될 것이다.
어떻게 흘러갈 지는 다음 포스팅에서 계속...
💋 여기까지 느낀 점
우리가 이 구조를 처음 설계하던 당시에, JPA 공부를 처음으로 하던 당시라, 여러 가지 매핑을 적용해 보고 싶었다.
물론 실무에서는 fk를 아예 설정하지 않고 사용한다던가 상속관계 매핑과 같은 ORM 기술만의 독특한 매핑의 경우에는 실제와 약간의 괴리가 존재할 수 있기 때문에 특히 조심해서 사용해야 한다는 것도 알았다.
현재 결정적인 문제점은 상속 관계 매핑에서 발생한다.
공부했다고 신나서 기술이나 구조를 무지성으로 도입하는 것을 조심해야 겠다고 생각했다.
💋 앞으로의 프로젝트 계획
앞으로의 프로젝트 흐름은...
- 엔티티 구조 완전히 변경
- 데이터 연동 API 만들기
- 데이터베이스 구조 변경 + 기존 데이터 마이그레이션
- 변경된 자바 코드 배포
이렇게 흘러갈 예정인데, 두 가지 어려운 점이 있다.
- 데이터 마이그레이션에 대한 것
- 3번, 4번 과정을 함께 서비스에 장애가 발생하지 않게 하면서 변경 사항을 반영하는 것
아직까지는 해결책을 찾지 못했다.
💋 참고자료
도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
'PROJECT > Stamp Crush' 카테고리의 다른 글
[우테코] Flyway를 사용한 데이터베이스 스키마 형상 관리 (0) | 2023.09.13 |
---|---|
[우테코] 임시 회원 ↔ 가입회원 데이터 연동기(2): 테이블 구조 대공사, 데이터 연동 API 구현! (0) | 2023.09.11 |
[우테코] 스탬프크러쉬 서비스 API 설계 (복잡한 요청은 여러 개의 요청으로 나눠서, 조회 API 예외 상황에 대한 정의, API의 재사용성을 통해 10시간을 절약했다) (0) | 2023.08.01 |
[우테코] 스탬프크러쉬 팀의 서비스 기능 목록을 공유합니다! (0) | 2023.07.24 |
[우테코] 스탬프크러쉬 팀의 배포 자동화: EC2 환경에서 Docker, Jenkins를 사용한 CI, CD (feat. Java 17) (0) | 2023.07.23 |