프로젝트 작업일지 : 회원가입 (스프링 - 리액트 회원가입)

작업일지에 들어가며

 

이전 프로젝트의 경우 멤버 테이블을 하나만 운영해서 프론트에서 데이터를 받아오고 DB에 저장하는데 크게 어려움이 없었다.

 

이번에는 멤버 테이블은 회원의 중요 정보를 담는 Main과 세부 정보를 담는 Detail로 나눠서 일대일 관계 설정이 되어있고,

 

또 Main 테이블은 회원 주소에 따라 위치 정보를 담는 location_roads 테이블과 다시 일대일 관계로 설정되어있다.

 

시작도 전에 난항이 생길 것 같은 기분이다.  아무튼 최선을 다해보자!


회원가입 로직 구현 과정

1. 테이블 구조 확인

members_main

members_detail

location_roads

 

members_main을 기준으로 detail과 roads가 일대일 관계로 설정 되어있는 모습이다.

 

2. Entity

데이터베이스 테이블에 맞게 Entity를 생성해준다.

MembersMain

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MembersMain {
	
	@Id
	@Column(name = "no")
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer no;
	
	@Column(name = "email")
	private String email;
	
	@Column(name = "password")
	private String password;
	
	@Column(name = "sns_connected")
	private Integer snsConnected;
	
	@Column(name = "name")
	private String name;
	
	@Column(name = "postcode")
	private String postcode;
	
	@Column(name = "road_address")
	private String roadAddress;
	
	@Column(name = "detail_address")
	private String detailAddress;
	
	@Column(name = "updated_at")
	private LocalDateTime updatedAt;
	
	@Column(name = "road_no")
    private Integer roadNo;
}

MembersDetail

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MembersDetail {
	
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer no;
	
	@Column(name = "birth_date")
	private String birthDate;
	
	@Column(name = "gender")
	private String gender;
	
	@Column(name = "phone")
	private String phone;
	
	@Column(name = "email_verified")
	private Boolean emailVerified;
	
	@Column(name = "status")
	private String status;
	
	@Column(name = "created_at")
	private LocalDateTime createdAt;
	
	@Column(name = "terms")
	private Boolean terms;
	
	@Column(name = "member_no")
    private Integer memberNo;
}

LocationRoads

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LocationRoads {
	
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer no;
	
	@Column(name = "name")
	private String name;
	
	@Column(name = "location_no")
	private Integer locationNo;
}

 

💡 JPA를 사용 중이므로 클래스 네임과 컬럼명은 카멜 케이스로 작성해준다.

 

3. Entity 관계 매핑

외래키가 설정된 테이블을 주체로 설정하였다.

💡 주체와 비주체의 구분 
주체(Owner): 외래 키를 실제로 관리하고 데이터베이스에서 연관 관계를 설정하는 역할.
비주체(Inverse): 관계를 주체 쪽의 매핑을 통해 참조할 뿐, 외래 키를 직접 관리하지 않음.

💡 @OneToOne(mappedBy) 설명
mappedBy는 관계의 주체가 어떤 속성을 기준으로 비주체에 매핑되는지를 명시
mappedBy가 선언된 엔티티는 외래 키를 직접 소유하지 않는다. 즉, 데이터베이스에 외래 키가 존재하지 않고, 주체가 외래 키를 관리

MembersMain

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MembersMain {

	... 생략
	
	@Column(name = "road_no")
	private Integer roadNo;
	
	@OneToOne(mappedBy = "membersMain", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
	private MembersDetail membersDetail; // MembersDetail과의 관계
	
	@OneToOne
	@JoinColumn(name = "road_no", referencedColumnName = "no")
	private LocationRoads locationRoads; // LocationRoads와의 관계
	
	// MembersDetail 설정 메소드
	public void setMembersDetail(MembersDetail membersDetail) {
        this.membersDetail = membersDetail;
        membersDetail.setMembersMain(this);
    }
}

MembersDetail

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MembersDetail {
	... 생략
	
	@Column(name = "member_no")
	private Integer memberNo;
	
	@OneToOne
	@JoinColumn(name = "member_no", referencedColumnName = "no")
	private MembersMain membersMain; // MembersMain과의 관계
}

LocationRoads

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LocationRoads {
	... 생략
    
	@Column(name = "location_no")
	private Integer locationNo;
	
	@OneToOne(mappedBy = "locationRoads", fetch = FetchType.LAZY)
	private MembersMain membersMain; // MembersMain과의 관계

	// MembersMain 설정 메소드
	public void setMembersmain(MembersMain membersMain) {
        this.membersMain = membersMain;
        membersMain.setLocationRoads(this);
    }
}

 

4. DTO

사용자가 입력한 회원가입 데이터를 받아오고 응답하는 DTO를 생성해준다.

RegisterRequestDto

@Getter
@Setter
@NoArgsConstructor
public class RegisterRequestDto {
	
	@NotBlank
	@Email
	private String email;
	
	@NotBlank
	@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*(),.?\":{}|<>])[A-Za-z\\d!@#$%^&*(),.?\":{}|<>]{8,20}$") // 최소 8자 이상 최대 20자 이하, 숫자, 특수문자, 영문자가 포함
	private String password;
	
	@NotBlank
	@Pattern(regexp = "^[a-zA-Z가-힣]{2,20}$") // 2자 이상 20자 이하, 한글과 영어만 입력
	private String name;
	
	@NotBlank
	@Pattern(regexp = "^\\d{8}$") // 숫자 8자리로 입력
	private String birthDate;
	
	@NotBlank
	@Pattern(regexp ="^[0-9]{11}$") // 숫자 11자리만 허용
	private String phone;
	
	@NotBlank
	private String postcode;
	
	@NotBlank
	private String roadAddress;
	
	@NotBlank
	private String detailAddress;
	
	@NotNull
	private Boolean terms = true;
	
	@NotNull
	private Boolean emailVerified = true;
	
	@NotBlank
	private String status = "Active";
	
	@NotBlank
	private Integer snsConnected = 0;
	
	@NotBlank
	private LocalDateTime updatedAt = LocalDateTime.now();
	
	@NotBlank
	private LocalDateTime createdAt = LocalDateTime.now();
}
💡 유효성 검증
validation 어노테이션(@NotBlank @Email @NotNull @Pattern)을 사용하여 유효성을 검증해준다. 
@NotNull의 경우 Boolean 타입에만 적용 가능 프론트에 적용한 유효성 검증 패턴을 백 코드에도 적용해준다.

RegisterResponseDto

@Getter
public class RegisterResponseDto extends ResponseDto {

	private RegisterResponseDto() {
		super(ResponseCode.SUCCESS, ResponseMessage.SUCCESS);
	}

	// 회원가입 성공 시 응답 메소드
	public static ResponseEntity<RegisterResponseDto> success() {
		RegisterResponseDto result = new RegisterResponseDto();
		return ResponseEntity.status(HttpStatus.OK).body(result);
	}

	// 닉네임 중복 시 응답 메소드
	public static ResponseEntity<ResponseDto> duplicateId() {
		ResponseDto result = new ResponseDto(ResponseCode.DUPLICATE_NICKNAME, ResponseMessage.DUPLICATE_NICKNAME);
		return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
	}

	// 이메일 중복 시 응답 메소드
	public static ResponseEntity<ResponseDto> duplicateEmail() {
		ResponseDto result = new ResponseDto(ResponseCode.DUPLICATE_EMAIL, ResponseMessage.DUPLICATE_EMAIL);
		return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
	}

	// 전화번호 중복 시 응답 메소드
	public static ResponseEntity<ResponseDto> duplicatePhone() {
		ResponseDto result = new ResponseDto(ResponseCode.DUPLICATE_PHONE, ResponseMessage.DUPLICATE_PHONE);
		return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
	}
}

ResponseDto

@Getter
@AllArgsConstructor
public class ResponseDto {

	private String code;
	private String message;

	public static ResponseEntity<ResponseDto> databaseError() {
		ResponseDto responseBody = new ResponseDto(ResponseCode.DATABASE_ERROR, ResponseMessage.DATABASE_ERROR);
		return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(responseBody);
	}
}

 

💡 HTTP Status의 응답 코드와 메세지를 담는 인터페이스를 따로 만들어둔다.
프론트에서 응답 코드와 메세지를 그대로 받아 출력하는 것도 가능해진다.

ResponseCode : HTTP Status 응답 코드 인터페이스

// HTTP Status 응답 코드 인터페이스
public interface ResponseCode {

	// HTTP Status 200
	String SUCCESS = "SU";

	// HTTP Status 400
	String VALIDATION_FAILED = "VF";
	String DUPLICATE_NICKNAME = "DN";
	String DUPLICATE_EMAIL = "DM";
	String DUPLICATE_PHONE = "DP";
	String NOT_EXISTED_USER = "NU";
	String NOT_EXISTED_BOARD = "NB";

	// HTTP Status 401
	String SIGN_IN_FAIL = "SF";
	String AUTHORIZATION_FAIL = "AF";

	// HTTP status 403
	String NO_PERMISSION = "NP";

	// HTTP status 500
	String DATABASE_ERROR = "DBE";
}

ResponseMessage : HTTP Status 응답 메세지 인터페이스

public interface ResponseMessage {

	// HTTP Status 200
	String SUCCESS = "Success.";

	// HTTP Status 400
	String VALIDATION_FAILED = "Validation failed.";
	String DUPLICATE_NICKNAME = "Duplicate nickname.";
	String DUPLICATE_EMAIL = "Duplicate email.";
	String DUPLICATE_PHONE = "Duplicate phone.";
	String NOT_EXISTED_USER = "This user does not exist.";
	String NOT_EXISTED_BOARD = "this board does not exist.";

	// HTTP Status 401
	String SIGN_IN_FAIL = "Login information mismatch.";
	String AUTHORIZATION_FAIL = "Authorization Failed.";

	// HTTP status 403
	String NO_PERMISSION = "Do not have permission.";

	// HTTP status 500
	String DATABASE_ERROR = "Database error.";
}

 

5. toEntity 메소드 작성

회원가입 시 클라이언트로부터 받아온 데이터를 Entity로 저장하는 메소드를 작성해준다.

MembersMain

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MembersMain {
	... 생략
	
	// 회원가입 시 클라이언트로부터 받아온 데이터를 엔티티에 저장하는 메소드
	public static MembersMain registerToEntity(RegisterRequestDto dto) {
		return MembersMain.builder()
				.email(dto.getEmail())
				.password(dto.getPassword())
				.name(dto.getName())
				.postcode(dto.getPostcode())
				.roadAddress(dto.getRoadAddress())
				.detailAddress(dto.getDetailAddress())
				.updatedAt(dto.getUpdatedAt())
				.snsConnected(dto.getSnsConnected())
				.build();
	}
}

MembersDetail

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MembersDetail {
	... 생략
    
	// 회원가입 시 클라이언트로부터 받아온 데이터를 엔티티에 저장하는 메소드
	public static MembersDetail registerToEntity(RegisterRequestDto dto) {
		return MembersDetail.builder()
				.birthDate(dto.getBirthDate())
				.phone(dto.getPhone())
				.emailVerified(dto.getEmailVerified())
				.status(dto.getStatus())
				.createdAt(dto.getCreatedAt())
				.terms(dto.getTerms())
				.build();
	}
}

 

 

5. Repository 

MembersMainRepository

public interface MembersMainRepository extends JpaRepository<MembersMain, Integer> {
	
	// 이메일에 해당하는 멤버 정보 찾기
	MembersMain findByEmail(String email);
	
	// 이메일 중복 검사
	boolean existsByEmail(String email);
}

MembersDetailRepository

public interface MembersDetailRepository extends JpaRepository<MembersDetail, Integer> {
	
	// 휴대전화 중복 검사
	boolean existsByPhone(String phone);
}

LocationRoadsRepository

public interface LocationRoadsRepository extends JpaRepository<LocationRoads, Integer> {
	
	// 도로명주소 찾기
	Optional<LocationRoads> findByName(String name);
}

 

 

6. Service

AuthProcess

public interface AuthProcess {
	
	ResponseEntity<? super RegisterResponseDto> register(RegisterRequestDto dto);
}

AuthProcessImpl

@Service
@RequiredArgsConstructor
public class AuthProcessImpl implements AuthProcess {

	private final MembersMainRepository membersMainRepository;
	private final MembersDetailRepository membersDetailRepository;
	private final LocationRoadsRepository locationRoadsRepository;

	// 비밀번호 암호화를 위한 인코더
	private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
	
	// 회원가입
	@Override
	public ResponseEntity<? super RegisterResponseDto> register(RegisterRequestDto dto) {

		try {
			
			// 이메일 중복 검사
			String email = dto.getEmail();
			boolean existedEmail = membersMainRepository.existsByEmail(email);
			if (existedEmail)
				return RegisterResponseDto.duplicateEmail();
			
			// 휴대전화 중복 검사
			String phone = dto.getPhone();
			boolean existedPhone = membersDetailRepository.existsByPhone(phone);
			if (existedPhone)
				return RegisterResponseDto.duplicatePhone();

			String password = dto.getPassword();
			String encodedPassword = passwordEncoder.encode(password);
			dto.setPassword(encodedPassword);
			
			// 입력받은 도로명 주소 매칭
			LocationRoads roadName = locationRoadsRepository.findByName(dto.getRoadAddress())
					.orElseThrow(() -> new IllegalArgumentException("해당 도로명 주소를 찾을 수 없습니다."));

			// MembersMain과 MembersDetail 객체 생성
			MembersMain members = MembersMain.registerToEntity(dto);
			MembersDetail membersDetail = MembersDetail.registerToEntity(dto);

			// 테이블 연관 관계에 따른 설정
			membersDetail.setMemberNo(members.getNo());
			members.setLocationRoads(roadName);
			members.setMembersDetail(membersDetail);
			membersDetail.setMembersMain(members);

			// 데이터베이스에 저장
			membersMainRepository.save(members);

		} catch (Exception e) {
			e.printStackTrace();
			return ResponseDto.databaseError();
		}

		return RegisterResponseDto.success();
	}
}

 

7. Controller

AuthController

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

	private final AuthProcess authProcess;

	// 회원가입
	@PostMapping("/register")
	public ResponseEntity<? super RegisterResponseDto> register(@RequestBody @Valid RegisterRequestDto requestBody) {
		// 회원가입 처리 및 응답 반환
		ResponseEntity<? super RegisterResponseDto> response = authProcess.register(requestBody);
		return response;
	}
}

 

8. 회원가입 시도

8-1 에러

🚨 Caused by: org.hibernate.MappingException: Column 'member_no' is duplicated in mapping for entity 'com.acorn.entity.MembersDetail' (use '@Column(insertable=false, updatable=false)' when mapping multiple properties to the same column)

MembersDetail 엔티티에서 memberNo 필드가 데이터베이스의 member_no 컬럼과 중복되어 매핑되고 있다는 오류가 생겼다. 위의 Entity 코드를 보게되면 @OneToOne 관계 설정에서 이미 MembersMain의 no와 연결된 member_no 컬럼을 참조하고 있기 때문에 발생한 것 같다. 이 문제를 해결하기 위해 memberNo 필드를 @Column(insertable = false, updatable = false)로 설정하여 Hibernate가 이 필드를 데이터베이스에 삽입하거나 업데이트하지 않도록하여 오류를 해결해보자!

@Column(name = "member_no")
private Integer memberNo;

@Column(name = "road_no")
private Integer roadNo;

@Column(name = "location_no")
private Integer locationNo;

🔽

@Column(name = "member_no", insertable = false, updatable = false)
private Integer memberNo;

@Column(name = "road_no", insertable = false, updatable = false)
private Integer roadNo;

@Column(name = "location_no", insertable = false, updatable = false)
private Integer locationNo;

 

9. 회원가입 재시도

 

회원가입 성공!


작업일지를 마치며

✨ 나의 생각

간단하게 끝날 것 같던 회원가입 로직을 구현하는데 꽤나 많은 시간을 사용했다.

엔티티 연관 관계 매핑은 학원에서도 배우고 블로그에 따로 정리를 해놨음에도 불구하고

관계 매핑시키는데 사소한 에러들이 자꾸 발생하여 애를 먹었다.

그렇지만 이번에 엔티티 관계 매핑에 대해서 전보다는 확실히 더 깨닫고 배워서 뿌듯하다.

프로젝트 진행이 원활하게 되어 가고 있는 것 같아 더 힘이 난다.

Reference

 

🙏