안녕하딤니카? 재영입니다
지금 프로젝트 방향은 기존의 자체 회원가입 폼을 이용해서 가입한 사용자 (이하 일반회원, Member) 과
구글, 네이버 로그인 API 를 사용해서 로그인한 사용자 (이하 소셜회원, SocialMember) 이 쇼핑몰의 모든 기능을 사용할 수 있도록 수정하는 과정을 진행하고 있습니다.
왜냐면 장바구니, 주문 등 로그인이 필요한 서비스의 경우, 일반회원을 기준으로 개발했고,
이 기능의 개발이 다 끝난 다음에 소셜회원 로그인 기능을 추가했기 때문에, 각 기능마다 코드를 수정해줘야 했습니다.
코드의 유지보수성 향상과 중복 코드가 늘어나는 것을 방지하기 위해, 다른 유형의 사용자 엔티티를 분리해서 관리하던 것을
User 인터페이스와 AbstractUser 엔티티를 생성하여 Member와 SocialMember 엔티티가 AbstractUser 를 상속하도록 엔티티 설계 구조를 변경했습니다.
과정을 아래에 찬찬히 복기하면서 어떻게 변경했는지 기록하겠습니다~ 시~~~작~~~
1. 엔티티 구조 설계 변경의 목적
위에서 간략하게 말했다시피, 엔티티 구조 설계의 변경 목적은 아래와 같습니다.
1) 유형이 다른 회원이 쇼핑몰의 모든 서비스를 이용할 수 있도록 하기 위해서
2) 추후 다른 회원 (ex. 기업회원 등...) 의 유형이 추가될 경우, 서비스 계층의 코드 변경 없이 자식 엔티티 추가 후 부모 엔티티를 상속받게 하여 코드의 유지보수성을 향상하기 위해서
3) 현재 분리된 엔티티로 관리되고 있어서, 동일한 기능을 다른 유형의 회원이 사용할 수 있도록 서비스 계층의 코드를 추가할 때 중복 코드가 다량 발생하는 점을 방지하기 위해서
처음에는 단순히 메소드마다 그냥 소셜회원 처리 코드를 추가하면 되겠지~ 라는 안일한 생각을 하고
먼저 oauth2 를 사용해서 소셜회원 로그인 기능을 구현하는 것이 먼저다 생각하고
기존 Member 엔티티는 유지한 채, SocialMember 라는 새로운 엔티티를 만들어서 두 유형의 사용자의 데이터를 분리해서 저장했습니다.
그러나, 메소드마다 각 유형별 사용자의 처리 코드를 추가하는 것은... 상상 그 이상으로 중복 코드가 많이 발생하는걸 알아버린 저는 결심했습니다. 회원 엔티티의 부모 엔티티를 만들어서 관리하자고...
2. 프로젝트의 ERD

기존에는 Member 와 SocialMember 를 상속 관계 설정 없이 그냥 생성했는데요, 이제는 AbstractUser 엔티티가 Member, SocialMember 의 공통 필드를 가지고 있고, Member 와 SocialMember 엔티티가 가지는 고유한 필드만 각 엔티티에 추가하는 방식으로 다시 구조를 설계했습니다.
그리고 이전에는 Member 와 SocialMember 가 중복된 ID 값을 가지지 않도록 SocialMember 엔티티의 ID 생성 전략을 TABLE 로 가져갔는데, 이제는 AbstractUser 의 ID 생성전략만 신경쓰면 되서 TABLE 전략의 성능 저하 이슈도 걱정하지 않게 되었습니다.
3. 각 엔티티의 구조 변경
3-1. User 인터페이스 생성
먼저, User 인터페이스를 생성해서 모든 사용자 유형이 공통으로 준수해야 할 메서드를 정의합니다. User 인터페이스 생성 및 구현은 필수 사항은 아니지만, 클래스 설계 의도를 명확하게 하기 위해서입니다. 저는 아래와 같이 User 인터페이스를 구현했습니다.
public interface User {
Long getId();
String getName();
String getEmail();
String getAddress();
Role getRole();
void setName(String name);
void setEmail(String email);
void setAddress(String address);
void setRole(Role role);
}
User 인터페이스를 사용함으로서 얻을 수 있는 이점은 아래와 같습니다.
1) 일관성
User 인터페이스를 통해 모든 사용자 유형이 공통으로 준수해야 할 메서드를 정의함으로써, 다양한 사용자 클래스들 간에 일관된 방식으로 작동하도록 보장합니다. 이는 시스템 전체의 코드를 관리하고 이해하는 데 도움이 됩니다.
2) 유연성
다형성을 활용하여 User 인터페이스 타입으로 다양한 하위 타입의 객체를 참조할 수 있습니다. 이는 코드를 더 유연하게 만들고, 확장성을 향상시키며, 특히 다른 타입의 사용자 객체들을 동일한 방식으로 처리할 수 있게 합니다.
3) 유지보수성
시스템을 업데이트하거나 확장할 때 인터페이스를 구현하는 방식을 통해, 새로운 유형의 사용자 클래스를 추가하더라도 기존 시스템과의 호환성을 유지하면서도 쉽게 확장할 수 있습니다. 이는 장기적인 프로젝트 유지보수에 큰 장점을 제공합니다.
3-2. AbstractUser 추상 클래스 구현
다음으로는, AbstractUser 클래스를 만들어서 이 클래스가 User 인터페이스를 구현하도록 합니다.
AbstractUser 는 추상 클래스로 구현하고, 대신 Member 와 SocialMember 가 가지는 공통 데이터 (ex. id, 이름, 이메일...) 필드를 이쪽에 만들어 일관성을 유지하도록 합니다. 저는 아래와 같이 AbstractUser 를 구현했습니다.
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "type")
@Table(name = "abstract_user")
public abstract class AbstractUser extends BaseEntity implements User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "id")
private Long id;
private String name;
@Column(unique = true)
private String email;
private String address;
@Enumerated(EnumType.STRING)
private Role role;
@Override
public Long getId() {
return id;
}
@Override
public String getName() {
return name;
}
@Override
public String getEmail() {
return email;
}
@Override
public String getAddress() {
return address;
}
@Override
public Role getRole() {
return role;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public void setEmail(String email) {
this.email = email;
}
@Override
public void setAddress(String address) {
this.address = address;
}
@Override
public void setRole(Role role) {
this.role = role;
}
}
AbstractUser 클래스는 추상 클래스로 구현하는 대신, 사용자의 실제 데이터를 데이터베이스에 저장해야하기 때문에 @Entity 및 기타 JPA 어노테이션을 사용해서 엔티티를 설계해줍니다.
그리고, Member 와 SocialMember 엔티티에서 가지고 있던 공통 필드를 AbstractUser 에 위치시켜 다른 기능에서 이 엔티티를 사용해서 데이터를 저장하고, 조회하도록 설계했습니다.
그리고, AbstractUser 를 추상 클래스로 구현했는데, 그 이유는 아래와 같습니다.
1) 인스턴스화 방지
추상 클래스는 직접 인스턴스화할 수 없습니다. AbstractUser가 추상 클래스인 이유는 이 엔티티 자체가 구체적인 사용자 유형을 대표하지 않기 때문입니다. 대신, 이 클래스는 일반적인 사용자의 속성과 행동을 정의하고, 실제 사용자 유형은 이를 상속받아 구체화하는 Member나 SocialMember와 같은 하위 클래스에서 표현됩니다.
2) 공통 기능의 정의
AbstractUser 클래스는 모든 사용자 유형(일반회원, 소셜회원 등)이 공통적으로 가지고 있어야 할 기본적인 속성(이름, 이메일 등)과 메소드를 정의합니다. 이를 통해 코드 중복을 줄이고, 모든 하위 클래스에서 이러한 기능들을 재정의하지 않고도 사용할 수 있게 합니다.
3) 계약의 강제
AbstractUser가 User 인터페이스를 구현함으로써, 이 추상 클래스를 상속받는 모든 클래스는 인터페이스에 정의된 메소드를 구현해야만 합니다. 이는 개발자가 새로운 사용자 유형을 시스템에 추가하고자 할 때, 반드시 특정 메소드들을 구현하도록 강제하는 효과가 있어, 일관성과 완전성을 보장합니다.
4) 다형성 활용
추상 클래스를 사용함으로써 다형성을 활용할 수 있습니다.
예를 들어, 시스템의 다른 부분에서 User 인터페이스 타입의 참조를 사용하여 실제로는 Member 또는 SocialMember 객체를 처리할 수 있습니다. 이는 코드의 유연성을 향상시키고, 변경에 대한 민감성을 감소시키는데 기여합니다.
5) 디자인의 명확성
추상 클래스를 사용하면 해당 클래스가 직접 사용되기보다는 상속을 통해 확장될 것임을 명확하게 표현할 수 있습니다. 이는 다른 개발자가 시스템의 설계 의도를 더 쉽게 이해할 수 있게 하고, 설계의 의도에 맞게 클래스를 활용할 수 있도록 돕습니다.
그리고 AbstractUser 엔티티는 @Inheritance 와 @DiscriminatorColumn 어노테이션을 사용해서 상속 관계 전략을 지정했습니다.
1) @Inheritance
@Inheritance 어노테이션은 상속 관계의 전략을 지정하는 데 사용됩니다. JPA에서는 크게 세 가지 상속 전략을 제공합니다.
- SINGLE_TABLE: 모든 클래스의 필드가 하나의 테이블에 저장됩니다. 성능은 좋지만, 데이터베이스에 중복된 컬럼이 많아질 수 있습니다. DiscriminatorColumn을 사용하여 각 행이 어떤 타입인지 구별합니다.
- TABLE_PER_CLASS: 각 클래스마다 별도의 테이블을 생성합니다. 중복 데이터는 없지만, 조인을 사용하지 않아 상속 구조가 데이터베이스에 명확하게 매핑되지 않습니다.
- JOINED: 각 클래스마다 테이블을 생성하고, 조인을 사용하여 연결합니다. 이 방식은 데이터 중복을 최소화하면서도 각 엔티티의 구조를 명확하게 표현할 수 있습니다.
여기서 사용된 InheritanceType.JOINED는 각 상속받는 클래스에 대해 별도의 테이블을 만들고, 조인을 통해 관련 데이터를 조회할 수 있도록 하는 전략입니다. 이는 객체 지향적인 데이터 모델링을 데이터베이스 설계에 효과적으로 반영할 수 있게 해줍니다.
2) @DiscriminatorColumn
@DiscriminatorColumn은 SINGLE_TABLE과 JOINED 전략에서 사용됩니다.
이 어노테이션은 하나의 테이블에 여러 타입의 엔티티 데이터가 저장될 때, 각 데이터 행이 어떤 엔티티 타입에 속하는지 구별하기 위한 컬럼을 지정합니다.
- name: 데이터베이스 테이블에서 사용할 구별 컬럼의 이름을 지정합니다. 예를 들어, name = "type"이라고 지정하면, type 컬럼이 엔티티의 타입을 구별하는 데 사용됩니다.
이 구별 컬럼(DiscriminatorColumn)은 각 행이 어떤 자식 클래스의 인스턴스인지 알려주는 역할을 합니다. 따라서 데이터를 조회할 때 해당 컬럼의 값에 따라 적절한 엔티티 타입으로 객체를 생성할 수 있습니다.
이런 방식으로 기존의 Member, SocialMember, 그리고 추후 추가할 수도 있는 다른 타입의 사용자 엔티티의 공통 필드를 관리하는 AbstractUser 를 사용해서 시스템의 다양한 요구사항을 유연하게 처리할 수 있는 구조를 만들었습니다.
3-3. 기존 엔티티 수정 (Member, SocialMember)
이제 각자 움직이던 Member 와 SocialMember 엔티티는 AbstractUser 엔티티를 상속하고, 각 엔티티만이 가지는 특수한 필드는 각 엔티티에 추가하도록 하겠습니다.
@Entity
@Getter
@Setter
@Table(name = "member")
public class Member extends AbstractUser {
private String password;
// Member 생성 메소드를 만들어서 관리
public static Member createMember(MemberFormDto memberFormDto, PasswordEncoder passwordEncoder) {
Member member = new Member();
member.setName(memberFormDto.getName());
member.setEmail(memberFormDto.getEmail());
member.setAddress(memberFormDto.getAddress());
// BCryptPasswordEncoder Bean 을 파라미터로 넘겨서 패스워드 암호화
String password = passwordEncoder.encode(memberFormDto.getPassword());
member.setPassword(password);
member.setRole(Role.ROLE_USER);
return member;
}
}
@Entity
@Getter
@Setter
@Table(name = "social_member")
public class SocialMember extends AbstractUser {
private String username;
}
Member 엔티티에서만 가지는 password 필드와 createMember 메소드는 남겨두고, AbstractUser 에 정의한 공통 필드는 모두 삭제해줍니다. 그리고 마찬가지로 SocialMember 엔티티에서만 가지는 username (SNS 로그인 API 에서 받는 사용자를 특정하는 값) 필드만을 남겨두고 모두 삭제해줍니다.
또한, 원래는 Member 엔티티와 SocialMember 가 각자 BaseEntity 를 상속받아 생성자, 수정자, 생성일, 수정일을 데이터베이스에 기록했는데, 이 부분도 공통 필드이기때문에 AbstractUser 쪽으로 상속받도록 옮깁니다.
이제 엔티티 설계 수정은 끝났고, 원래는 일반회원은 Member 엔티티에 데이터가 저장되고, 소셜회원은 SocialMember 엔티티에 데이터가 저장되었는데, 이제는 각 엔티티의만이 가지는 필드를 제외하고 공통 필드는 AbstractUser 엔티티에 저장됩니다.
3-4. Spring Data JPA 사용을 위한 Repository 생성
일반회원과 소셜회원이 사용하는 Repository 가 있기 때문에 그 인터페이스는 남겨두고, 이제 일반회원과 소셜회원이 모두 사용할 수 있는 기능에 적용할 리포지토리 인터페이스를 생성해줍니다.
public interface UserRepository extends JpaRepository<AbstractUser, Long> {
AbstractUser findByEmail(String email);
}
인터페이스명은 UserRepository 로 정했고, JpaRepository 를 상속받아 Spring Data JPA 를 사용할 수 있도록 설정해줍니다.
지금은 이메일 조회만 사용하기 때문에 findByEmail 메소드만 만들어두었습니다.
4. 각 기능별 서비스계층 코드 수정
이제 프로젝트의 로그인 시 사용할 수 있는 기능을 일반회원과 소셜회원 구분 없이 사용하도록 각 기능별 서비스계층의 코드를 수정해줘야 합니다.
이 작업은 이전에 고려하던 각 회원별 분기처리를 통해 구분 후 메소드 수정이라는 중복코드가 어마무시하게 생기는 상황보다는 상당히 깔끔하게 코드를 수정할 수 있습니다.
아래 예시는 장바구니 기능 중, 장바구니를 처음 사용하는 유저의 장바구니 생성을 담당하는 addCart 메소드입니다.
public Long addCart(CartItemDto cartItemDto, String email) {
Item item = itemRepository.findById(cartItemDto.getItemId()).orElseThrow(EntityNotFoundException::new);
AbstractUser user = userRepository.findByEmail(email); // Member 에서 AbstractUser 로 변경
Cart cart = cartRepository.findByUserId(user.getId());
if (cart == null) {
cart = Cart.createCart(user);
cartRepository.save(cart);
}
CartItem savedCartItem = cartItemRepository.findByCartIdAndItemId(cart.getId(), item.getId());
if (savedCartItem != null) {
savedCartItem.addCount(cartItemDto.getCount());
return savedCartItem.getId();
} else {
CartItem cartItem = CartItem.createCartItem(cart, item, cartItemDto.getCount());
cartItemRepository.save(cartItem);
return cartItem.getId();
}
}
보시면, Member 의 이메일을 조회하는 변수를 AbstractUser 의 이메일을 조회하는 변수로 바꿔주기만 하면 끝납니다...
이거 아니었으면 if 로 구구절절 적었어야 했을텐데... 한 줄 수정하고 끝나서 정말 다행입니다.
(물론 이 서비스 계층의 다른 메소드들과, 다른 기능의 서비스 계층 코드도 이런 방식으로 수정해주면 됩니다.)
끝맺음을 어떻게 해야하지...
암튼 저처럼 따로 관리하던 엔티티를 다시 설계해서 프로젝트의 유지보수성과 효율성을 높이는... 경험을 해보십쇼
최고네요!
진짜 끝을 어떻게 지어야하지... 그럼 20000
🍀
좋아하는 것을 계속 좋아하세요!
반드시 행복해집니다
[Github] https://github.com/chujaeyeong
[E-mail] chujy1224@gmail.com
'Project' 카테고리의 다른 글
| Swagger 도구를 사용한 API 문서 작성 방법과 사용 시 이점 - 쇼핑몰 프로젝트 06 (1) | 2024.06.09 |
|---|---|
| JPA CascadeType 설정으로 인한 리뷰 삭제 문제 해결하기 - 쇼핑몰 프로젝트 05 (2) | 2024.06.08 |
| Principal 객체를 활용하여 인증된 사용자의 정보를 반환하기 - 쇼핑몰 프로젝트 04 (3) | 2024.05.27 |
| Spring Boot 3.x + Spring Security 6 + OAuth2 소셜 로그인의 흐름 - 쇼핑몰 개인 프로젝트 02 (1) | 2024.05.16 |
| Spring Boot 3.x + Spring Security 6 + OAuth2 활용한 소셜 로그인 기능 구현하기 - 쇼핑몰 개인 프로젝트 01 (2) | 2024.05.15 |