Spring Adapter-Handler를 흉내낸, 확장 가능성을 고려한 전략 패턴 기반 회원가입 컴포넌트 설계

2025. 2. 13. 20:44·백엔드

프로젝트를 진행하며 회원가입 컴포넌트에 대해 고민했던 부분과 해결 방법에 대해 공유를 해보겠습니다


코드 공유

링크

문제 상황

제가 받은 요구사항에서는 다양한 유저 권한이 존재했으며, 이들 각각은 다른 엔티티와의 관계 및 특정 필드를 필요로 했습니다.

  • ADMIN
  • HUB_MANAGER
  • HUB_DELIVERY_USER
  • COMPANY_DELIVERY_USER
  • COMPANY_MANAGER

그러나 프로젝트 초기에는 이러한 관계와 필드에 대한 구체적인 요구사항이 명확하지 않아, 추후 권한 추가 및 회원 가입 로직 확장 가능성을 염두에 두어야 했습니다. 실제로 기획이 진행되면서 새롭게 추가된 권한에 맞춰 회원가입 로직을 확장해야 하는 상황도 발생했습니다.


해결 방법

이 문제를 해결하기 위해 User 엔티티를 기본으로 하고, 권한별로 하위 엔티티를 두어 User 엔티티를 상속받는 구조로 설계하였습니다. 또한 회원가입 로직은 인터페이스로 분리하여 권한별로 다르게 구현하여, 서비스 로직은 역할(권한)에만 의존하도록 하였습니다. 이렇게 함으로써 권한별로 서로 다른 회원가입 로직을 구현하면서도 캡슐화와 단일 책임 원칙을 지킬 수 있었습니다.

각 권한별로 회원가입 로직을 따로 구현한 후, 이 구현체들을 Spring Bean으로 등록하였으며, 회원 가입 시 유저의 권한에 따라 해당 로직을 처리할 수 있도록 하였습니다.

User 하위 엔티티

  • User 엔티티에서는 공통으로 사용되는 필드와 메서드를 관리하며 메서드를 추상화합니다.
  • @Entity(name = "p_users")
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @Getter
    @NoArgsConstructor
    @DiscriminatorColumn(name = "role")
    public abstract class User extends AuditRecord{
       @Id
       @GeneratedValue(strategy = GenerationType.IDENTITY)
       private Long id;
    ​
       @Column(nullable = false, unique = true)
       private String username;
    ​
       @Column(nullable = false)
       private String password;
    ​
       @Column
       private String slackId;
    ​
       @Column(nullable = true)
       private Boolean isActivated = false;
    ​
       @Transient // DB에 저장되지 않지만 엔티티에 추가
       public String getRole() {
           return this.getClass().getAnnotation(DiscriminatorValue.class).value();
      }
    ​
       protected User(String username, String password, String slackId) {
           this.username = username;
           this.password = password;
           this.slackId = slackId;
      }
    ​
       public void changeSlackId(String slackId) {
           this.slackId = slackId;
           isActivated = false;
      }
    ​
       public abstract void updateHubs(UUID hubId);
    ​
       public abstract UUID getHubId();
    ​
       public abstract UUID getCompanyId();
    ​
       public void deleteUser(User user) {
           delete(user);
      }
    ​
       public void activateUser() {
           isActivated = true;
      }
    }
    ​
  • User 하위 엔티티(role에 따른 user)에서는 각각 고유의 필드와 메서드를 관리하며 메서드를 구현합니다.
  • @Entity
    @DiscriminatorValue("HUB_DELIVERY_USER")
    @NoArgsConstructor
    @Getter
    public class HubDeliveryUser extends User{
    ​
       private HubDeliveryUser(String username, String password, String slackId) {
           super(username, password, slackId);
      }
    ​
       @Override
       public void updateHubs(UUID hubId) {
    ​
      }
    ​
       @Override
       public UUID getHubId() {
           return null;
      }
    ​
       public static HubDeliveryUser signUp(String username, String password, String slackId) {
           return new HubDeliveryUser(username, password, slackId);
    ​
      }
    ​
       @Override
       public UUID getCompanyId() {
           return null;
      }
    }
    ​

Adapter - Handler 구조

회원 가입 컴포넌트 개발 시 고민한 점은, 엔티티에서 단순히 메서드 상속을 통한 추상화 방식으로 해결할지, 아니면 다형성을 활용한 Handler 패턴을 사용할지를 고민했습니다. 결국, 향후 확장성을 고려하여 다형성 기반의 Handler 패턴을 선택하였습니다.

  1. UserSignUpInterface라는 인터페이스를 정의하고, 권한별로 SignUpHandler를 구현하였습니다.
  2. 각 Handler는 동일한 인터페이스를 구현하지만, 각기 다른 권한에 맞춰 회원가입 로직을 처리하도록 개발되었습니다.
  3. HandlerAdapter를 통해 유저의 권한에 맞는 Handler를 찾아서 호출하고, 이를 통해 회원가입 로직을 수행하도록 설계했습니다.

1. UserSignUpInterface

  • signUp : 권한에 따른 회원 가입을 처리하는 메서드
  • getPermitUserRole() : 특정 핸들러(구현체)가 처리할 수 있는 유저 권한을 반환하는 메서드
public interface SignUp {
    User signUp(String username, String password, String slackId, UUID hubId, UUID companyId);
​
    UserRole getPerimitUserRole();
}

2. UserSignUpHandler

  • signUp : 권한에 따른 회원 가입을 처리하는 메서드를 구현
  • getPermitUserRole : 특정 핸들러(구현체)가 처리할 수 있는 유저 권한을 반환하는 메서드를 구현
    • 아래 예시에서는 HubManagerSignUpHandler 이므로 UserRole.HUB_MANAGER 가 반환
public class HubManagerSignUp implements SignUp{
​
    private final HubClientService hubClientService;
​
    @Override
    public User signUp(String username, String password, String slackId, UUID hubId, UUID companyId) {
​
        hubClientService.verifyExistsHub(hubId);
​
        return HubManager.signUp(username, password, slackId, hubId);
    }
​
    @Override
    public UserRole getPerimitUserRole() {
        return UserRole.HUB_MANAGER;
    }
}

3. SignUpAdapter

SignUpAdapter는 UserRole을 입력 받아, 해당 role에 맞는 handler를 찾아 호출하는 역할을 수행합니다.

  • signUpHandlerMap :
    • UserRole.class를 바탕으로 생성된 EnumMap입니다.
    • key: userRole, value: signUpHandler
    • 스프링 빈으로 등록된 모든 SignUpHandler를 list로 주입 받아, signUpHandler의 getPermitUserRole()을 통해 userRole을 key값으로 지정하고, 해당 value에 signUpHandler를 넣어주었습니다.
  • getSignUpHandler :
    • userRole을 전달 받아 해당 Role을 처리할 수 있는 signUpHandler를 호출하는 역할을 합니다. 만약 특정 UserRole에 해당하는 signUpHandler를 찾을 수 없다면 Exception을 발생시킵니다.
  • supports :
    • userRole을 전달 받아 해당 userRole을 Adapter가 지원하는를 검사합니다.
@Component
public class SignUpAdapter {
​
    protected static final Map<UserRole, SignUp> signUpHandlerMap = new EnumMap<>(UserRole.class);
​
    @Autowired
    public SignUpAdapter(List<SignUp> signUpHandlerList) {
        for (SignUp signUpHandler : signUpHandlerList) {
            signUpHandlerMap.put(signUpHandler.getPerimitUserRole(), signUpHandler);
        }
    }
​
    public SignUp getSignUpHandler(UserRole userRole) {
        if (!supports(userRole)) {
            throw new RoleNotExistsException();
        }
        return signUpHandlerMap.get(userRole);
    }
​
    private boolean supports(UserRole userRole) {
        return signUpHandlerMap.containsKey(userRole);
    }
}

UserService (사용)

위와 같은 설계를 통하여 다음과 같은 중요한 장점을 확인할 수 있었습니다

  1. 확장 용이
    • 향후 UserRole이 추가될 경우 해당 Role에 대한 SignUpHandler만 추가되면 됩니다.
    • 특정 UserRole의 SignUp 로직이 변경될 경우 해당 SignUpHandler의 로직만 변경되면 됩니다.

실제 서비스 중간 UserRole이 새롭게 추가 되었는데, 해당 Role에 대한 SignUpHandler만 추가해주며 간편하게 구현할 수 있었습니다.

  1. 캡슐화와 다형성
    • UserService에서 SignUp을 사용할 때 userRole만 넘겨주며 사용하면 되기에 signUp 로직을 확인 후 직접 필요한 로직을 찾아 호출할 필요가 없습니다.
    • @Transactional
      public SignUpResponseDto signUp(String username, String password, String confirmPassword, UserRole userRole, String slackId, UUID hubId, UUID companyId) {
      ​
         verifySignupException(username, password, confirmPassword, slackId);
      ​
         SignUp signUpHandler = signUpAdapter.getSignUpHandler(userRole);
      ​
         User user = signUpHandler.signUp(username, bCryptPasswordEncoder.encode(password), slackId, hubId, companyId);
         User savedUser = userRepository.save(user);
      ​
         return new SignUpResponseDto(savedUser.getId(), savedUser.getUsername(), savedUser.getRole(), slackLink);
      }
    • UserService에서는 signUpAdapter에게 userRole을 넘겨주면 signUpAdapter가 사용가능한 signUpHandler를 직접 찾아 반환합니다.
    • 이후 반환 받은 signUpHandler가 어떤 handler인지를 userService에서는 알 필요 없이 바로 회원가입을 처리할 수 있습니다.

결론

요구 사항 명세가 정확히 주어지지 않아 오히려 확장 가능성과 유지 보수가 편한 Component를 개발할 수 있는 경험을 가지게 되었습니다.

실제 Spring에서 Adapter를 통해 Handler를 대신 찾아 호출하는 부분에서 영감을 받아 비슷하게 구현해보았습니다. 

최종적으로는 adapter - handler 구조를 통해 Component를 개발했고, 이후 이것을 호출할 때는 권한에 따라 회원 가입 구현이 달라지는 전략패턴을 적용했습니다.

'백엔드' 카테고리의 다른 글

Redis + Ip 기반 조회 수 어뷰징 방지  (0) 2025.03.16
목록 조회 시 페이징 쿼리 최적화를 통한 최소 10배 성능 개선  (0) 2025.03.12
github packages를 통한 공통 모듈 개발기  (2) 2025.02.13
CustomArgumentResolver를 통해 Pageable 입력 받기  (0) 2025.02.13
대용량 트래픽에서 동시성 문제를 고려한 선착순 쿠폰 발급 처리  (0) 2025.02.13
'백엔드' 카테고리의 다른 글
  • 목록 조회 시 페이징 쿼리 최적화를 통한 최소 10배 성능 개선
  • github packages를 통한 공통 모듈 개발기
  • CustomArgumentResolver를 통해 Pageable 입력 받기
  • 대용량 트래픽에서 동시성 문제를 고려한 선착순 쿠폰 발급 처리
shoon95
shoon95
shoon95 님의 블로그 입니다.
  • shoon95
    shoon95 님의 블로그
    shoon95
  • 전체
    오늘
    어제
    • 분류 전체보기 (18)
      • 백엔드 (10)
      • 프로젝트 (1)
      • 트러블 슈팅 (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
    • 학습 정리
  • 공지사항

  • 인기 글

  • 태그

    동시성
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
shoon95
Spring Adapter-Handler를 흉내낸, 확장 가능성을 고려한 전략 패턴 기반 회원가입 컴포넌트 설계
상단으로

티스토리툴바