PROJECT/Stamp Crush

[우테코] 임시 회원 ↔ 가입회원 데이터 연동기(2): 테이블 구조 대공사, 데이터 연동 API 구현!

깃짱 2023. 9. 11. 23:30
반응형

 

 

 

 

안녕! 

우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다. 

 

스탬프크러쉬 서비스의 소스 코드 바로가기

사장모드: stampcrush.site/admin

고객모드: stampcrush.site

💋 인트로

지난 포스팅에서 우리 팀의 회원 데이터 연동 과정에 대해서 설명했고, 어떻게 구현할 지에 대해서도 여러 가지 시행착오에 대해 소개했다. 

 

지난 포스팅: https://engineerinsight.tistory.com/193

 

[우테코] 임시 회원 ↔ 가입회원 데이터 연동기(1): 6가지 시도와 실패한 이유(JPA 상속 관계 매핑

안녕! 우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다. 스탬프크러쉬 서비스의 소스 코드 바로가기 사장모드: stampcrush.site/admin 고객모드: stampcrush.site 💋 인트로 혼자서 해당 내용을 개

engineerinsight.tistory.com

 

결국 우리 팀은 더 늦기 전에 테이블 구조를 변경하기로 했고, 오늘은 그 과정에 대해서 간략히 소개하려고 한다. 

 

💋 테이블 구조

✔ 결정한 테이블 구조

지난 포스팅에서 나왔던 여러 가지 시도 끝에 우리 팀은 최종적으로 6번째에 소개했던 방법을 채택했다. 

 

우리 팀이 어려움을 겪는 그 악의 근원은 바로 JPA 상속 관계 매핑이었다.

우리팀의 기존 테이블 구조

 

어려움의 이유는, 상속 관계 매핑은 JPA에서 dtype을 기준으로 조회하게 되는데, 우리가 원하는 임시 회원 데이터를 연동해서 가입 회원으로 바꾸는 과정은 dtype을 TEMPORARY에서 REGISTER로 바꾸는 과정이 필요했기 때문이다. 처음에는 테이블에 있기에 그대로 update할 수 있을 것이라고 생각했었다. 

 

하지만, JPA에서 dtype을 인위적으로 바꿀 수 있는 방법은 없었다. 

바꾸려면 해당 레코드를 삭제하고 다른 하위 타입의 레코드를 INSERT 해야만 했다.

하지만 이 과정은 우리 팀의 작업과 맞지 않았다. 우리는 새로운 레코드를 원하는 것이 아니라, 기존 레코드의 dtype을 포함한 몇몇 속성들을 수정해서 그대로 사용하고 싶었던 것이었기 때문이다. 

 

Before

 

After

 

위의 내용만 보면 잘 감이 안올 수 있는데, 그래서 바뀐 코드를 아래에서 설명하겠다! 

 

✔  JPA 엔티티 코드 

우리 팀 레오가 이 부분을 맡아서 수정해줬다. 

과정에서 빌더 패턴 등등 좀 세련되게 바꾸어줘서 이후에 코드 작업이 매우 재밌어졌다.

관련 내용은 레오가 올렸던 블로그 글PR 링크 참고!

 

🌟 Before

  • Customer Table
    • 상속 관계 매핑의 조상 클래스 
    • dtype은 테이블에는 존재하지만, 코드 상에서 직접 수정할 수는 없음. 
    • 모든 customer가 비즈니스 로직 사용을 위해서는 필수로 가져야 할 phone number를 가짐. 
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
@Getter
@Entity
@DiscriminatorColumn(name = "dtype")
@Inheritance(strategy = JOINED)
public abstract class Customer {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "customer_id")
    private Long id;
    private String nickname;

    @Column(unique = true)
    private String phoneNumber;

    public Customer(String nickname, String phoneNumber) {
        this(null, nickname, phoneNumber);
    }

    public void registerPhoneNumber(String phoneNumber) {
        this.phoneNumber = phoneNumber;
    }

    public abstract boolean isRegistered();
}

 

  • Register Customer Table
    • 상속 관계 매핑된 자손 클래스
    • email, oauth 관련 정보 등 정식으로 가입된 회원만이 가지고 있는 정보들을 포함함. 
@NoArgsConstructor(access = PROTECTED)
@Getter
@DiscriminatorValue("register")
@Entity
public class RegisterCustomer extends Customer {

    private String loginId;
    private String encryptedPassword;

    private String email;

    @Enumerated(EnumType.STRING)
    @Column(name = "oauth_provider")
    private OAuthProvider oAuthProvider;

    @Column(name = "oauth_id")
    private Long oAuthId;

	// ...
}

 

  • Temporary Customer Table
    • 특별히 다른 정보를 포함하지는 않지만, 가입 회원과 type 자체를 구분하기 위해 별도로 생성한 엔티티
    • 이 포스팅에는 추가하지는 않았지만, 자체 닉네임 생성 등등 해당 객체만의 메서드들을 가지고 있었음. 
@NoArgsConstructor(access = PROTECTED)
@DiscriminatorValue("temporary")
@Entity
public class TemporaryCustomer extends Customer {

	// ...
}

 

🌟 After

  • Customer Table (딱 1개)
    • 기존에 가입 회원과 임시 회원을 나누기 위해 사용하던 Dtype 대신에, 직접 수정해줄 수 있는 CustomerType enum을 필드로 사용하도록 함. 
    • 가입 회원은 가지고 있지만, 임시 회원에게는 없는 필드가 대다수이기 때문에 null을 허용함. 
@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "customer_id")
    private Long id;
    private String nickname;
    private String phoneNumber;
    private String loginId;
    private String encryptedPassword;
    private String email;

    @Enumerated(STRING)
    @Column(name = "oauth_provider")
    private OAuthProvider oAuthProvider;

    @Column(name = "oauth_id")
    private Long oAuthId;

    @Enumerated(STRING)
    private CustomerType customerType;
    
    // ...
}

 

  • CustomerType (enum)
    • 가입 회원과 임시 회원을 구분할 수 있는 enum
public enum CustomerType {

    TEMPORARY("register"),
    REGISTERED("temporary");

    private final String customerType;

    CustomerType(String customerType) {
        this.customerType = customerType;
    }

    public String getCustomerType() {
        return customerType;
    }
}

 

💋 데이터 연동 API 구현

✔  관련 설명

 

이 부분은 내가 맡아서 했다. 

 

좀 조심해야 했던건, 

 

  1. 현재 엔티티 코드만 변경되어 있고 개발 서버에 실제 데이터베이스 스키마를 변경한 것은 아니라서, 머지 후에 자동 배포 과정을 중지시켰다. 
  2. 테스트를 정말 꼼꼼히 해야만 했다. (테스트를 위한 API를 추가로 만들기도 했다.)

 

✔  API 명세

이번 명세는 시간이 많지 않아서, 그냥 나 혼자 짜고 프론트엔드는 검토만 하는 식으로 진행했다. 

 

✔  구현 방법

  • 기존에 가입했던 임시 회원 레코드의 customerType을 REGISTERED로 변경한다.
  • 기존에 가입했던 임시 회원 레코드의 nickname, ... oAuthProvider, oAuthId를 현재 로그인 되어있는 가입 회원의 값으로 update한다. 
  • 현재 로그인 되어있는 가입 회원의 레코드를 customer 테이블에서 삭제한다.

위 세 과정을 거치면 데이터 연동이 된다고 생각했다. 

 

✔  구현 소스 코드

관련 PR 링크: https://github.com/woowacourse-teams/2023-stamp-crush/pull/644

 

[BE] feat: 회원 데이터 연동 API by eunkeeee · Pull Request #644 · woowacourse-teams/2023-stamp-crush

주요 변경사항 회원 데이터 연동 API 작성했습니다. 테스트 용도로 임시 회원가입 API도 생성했습니다. 구체적인 플로우가 약간 어려우면, 제가 엄청 자세히 적어놓은 블로그 글 있으니 참고해주

github.com

 

  • Controller
    @PostMapping("/link-data")
    public ResponseEntity<Void> linkData(
            CustomerAuth customer,
            @RequestBody VisitorProfilesLinkDataRequest request
    ) {
        VisitorProfilesLinkDataDto dto = request.toDto();
        visitorProfilesCommandService.linkData(customer.getId(), dto);
        return ResponseEntity.ok().build();
    }
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class VisitorProfilesLinkDataRequest {

    private Long id;

    public VisitorProfilesLinkDataDto toDto() {
        return new VisitorProfilesLinkDataDto(id);
    }
}
  • Controller Test Code
    @Test
    void 데이터_연동에_성공하면_200_상태코드를_반환한다() throws Exception {
        doNothing()
                .when(visitorProfilesCommandService)
                .linkData(eq(null), any());

        VisitorProfilesLinkDataRequest request = new VisitorProfilesLinkDataRequest(1L);

        mockMvc.perform(
                        post("/api/profiles/link-data")
                                .contentType(APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(request))
                )
                .andExpect(status().isOk());
    }

 

  • Service
    • 반환값이 없어서, 서비스만의 테스트는 효율이 떨어진다고 생각해 인수 테스트를 꼼꼼히 짜는 것으로 대체했음. 
    public void linkData(Long customerId, VisitorProfilesLinkDataDto dto) {
    	// 현재 로그인된 가입 회원
        Customer registerCustomer = findExistingCustomer(customerId);
        
        // 이전에 가입했던 임시 회원
        Customer temporaryCustomer = findExistingCustomer(dto.getPreviousTemporaryCustomerId());

        String nickname = registerCustomer.getNickname();
        String loginId = registerCustomer.getLoginId();
        String encryptedPassword = registerCustomer.getEncryptedPassword();
        String email = registerCustomer.getEmail();
        OAuthProvider oAuthProvider = registerCustomer.getOAuthProvider();
        Long oAuthId = registerCustomer.getOAuthId();

		// 기존 임시 회원의 CustomerType을 REGISTERED로 변경
        temporaryCustomer.toRegisterCustomer(); // 엔티티 내에서는 setter임
        
        // 기존 임시 회원의 columns를 현재 로그인된 가입 회원의 데이터로 update
        temporaryCustomer.setNickname(nickname);
        temporaryCustomer.setLoginId(loginId);
        temporaryCustomer.setEncryptedPassword(encryptedPassword);
        temporaryCustomer.setEmail(email);
        temporaryCustomer.setoAuthProvider(oAuthProvider);
        temporaryCustomer.setoAuthId(oAuthId);

		// 현재 로그인 된 가입 회원의 레코드 삭제
        customerRepository.delete(registerCustomer);
    }

 

✔  인수테스트 코드

  • 인수테스트만을 위해서 TEMPORARY CUSTOMER 가입 API, REGISTER CUSTOMER 가입 API를 테스트만을 위한 용도로 별도로 만들었다. 
  • 모든 과정은 step으로 넣어서 최대한 인수테스트 코드 상에서는 API call에 대한 부분은 은닉했다. 
public class VisitorLinkDataAcceptanceTest extends AcceptanceTest {

    private static final OAuthRegisterCustomerCreateRequest O_AUTH_REGISTER_CUSTOMER_CREATE_REQUEST = new OAuthRegisterCustomerCreateRequest(
            "깃짱",
            "gitchan@gmail.com",
            OAuthProvider.KAKAO,
            1235436L
    );

    @Autowired
    private CustomerRepository customerRepository;

    @Autowired
    private AuthTokensGenerator authTokensGenerator;

    @Test
    void 기존_임시회원의_데이터와_연동한다() {
        Long temporaryCustomerId = 임시_회원_가입_요청하고_아이디_반환("01038626099");
        Customer temporaryCustomer = customerRepository.findById(temporaryCustomerId).get();

        OAuthRegisterCustomerCreateRequest registerCustomerCreateRequest = O_AUTH_REGISTER_CUSTOMER_CREATE_REQUEST;
        String accessToken = 회원_가입_요청하고_액세스_토큰_반환(registerCustomerCreateRequest);
        Long registerCustomerId = authTokensGenerator.extractMemberId(accessToken);

        ExtractableResponse<Response> response = 회원_데이터_연동_요청(accessToken, new VisitorProfilesLinkDataRequest(temporaryCustomer.getId()));

        Customer linkedCustomer = customerRepository.findById(temporaryCustomerId).get();

        assertAll(
                () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()),
                () -> assertThat(linkedCustomer.getNickname()).isEqualTo(registerCustomerCreateRequest.getNickname()),
                () -> assertThat(linkedCustomer.getOAuthProvider()).isEqualTo(registerCustomerCreateRequest.getoAuthProvider()),
                () -> assertThat(linkedCustomer.getOAuthId()).isEqualTo(registerCustomerCreateRequest.getoAuthId()),
                () -> assertThat(customerRepository.findById(registerCustomerId)).isEmpty()
        );
    }
}

 

  • 데이터 연동 RestAssured 관련 step
public class VisitorLinkDataStep {

    public static ExtractableResponse<Response> 회원_데이터_연동_요청(String accessToken, VisitorProfilesLinkDataRequest request) {
        return RestAssured.given()
                .log().all()
                .contentType(JSON)
                .auth().preemptive()
                .oauth2(accessToken)
                .body(request)

                .when()
                .post("/api/profiles/link-data")

                .then()
                .log().all()
                .extract();
    }
}

 

 

이후에 해당 테이블 스키마 변경을 위해서 데이터 스키마 변경 및 형상 관리에 대한 부분도 좀 공을 들여야 할 것 같다. 

 

💋 참고자료

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟

 

 

 

반응형