카테고리 없음

전략 패턴

minus43 2025. 1. 16. 12:43

프로젝트를 시작할 당시의 코드이다
로그인의 비즈니스 로직을 담당하는 서비스 계층의 코드인데..

package com.buysellgo.userservice.user.service;

import com.buysellgo.userservice.common.auth.JwtTokenProvider;
import com.buysellgo.userservice.common.entity.Role;
import com.buysellgo.userservice.user.controller.dto.JwtCreateReq;
import com.buysellgo.userservice.user.controller.dto.KeepLogin;
import com.buysellgo.userservice.user.domain.seller.Seller;
import com.buysellgo.userservice.user.domain.user.User;
import com.buysellgo.userservice.user.repository.SellerRepository;
import com.buysellgo.userservice.user.repository.UserRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {
    private static final String ACCESS_TOKEN = "accessToken";
    private static final String USERNAME = "username";
    private static final String COMPANY_NAME = "companyName";
    private static final String FAILURE = "failure";
    private static final long DEFAULT_HOURS = 10L;  // 기본 10시간
    private static final long KEEP_LOGIN_HOURS = 168L;  // 로그인 유지 7일 (7 * 24 = 168시간)
    
    private final UserRepository userRepository;
    private final SellerRepository sellerRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, Object> userTemplate;
    private final RedisTemplate<String, Object> sellerTemplate;
    private final RedisTemplate<String, Object> adminTemplate;

    public Map<String, Object> createJwt(JwtCreateReq req) {
        Map<String, Object> map = new HashMap<>();
        long expirationHours = req.keepLogin() == KeepLogin.ACTIVE ? KEEP_LOGIN_HOURS : DEFAULT_HOURS;

        if(req.role().equals(Role.USER)) {
            User user = userRepository.findByEmail(req.email()).orElseThrow();
            if(passwordEncoder.matches(req.password(), user.getPassword())){
                String accessToken = jwtTokenProvider.createToken(req.email(), req.role().toString());
                String refreshToken = jwtTokenProvider.createRefreshToken(req.email(), req.role().toString());
                userTemplate.opsForValue().set(user.getEmail(), refreshToken, expirationHours, TimeUnit.HOURS);
                map.put(ACCESS_TOKEN, accessToken);
                map.put(USERNAME, user.getUsername());
                return map;
            }
        } else if (req.role().equals(Role.SELLER)) {
            Seller seller = sellerRepository.findByEmail(req.email()).orElseThrow();
            if(passwordEncoder.matches(req.password(), seller.getPassword())){
                String accessToken = jwtTokenProvider.createToken(req.email(), req.role().toString());
                String refreshToken = jwtTokenProvider.createRefreshToken(req.email(), req.role().toString());
                sellerTemplate.opsForValue().set(seller.getEmail(), refreshToken, expirationHours, TimeUnit.HOURS);
                map.put(ACCESS_TOKEN, accessToken);
                map.put(COMPANY_NAME, seller.getCompanyName());
                return map;
            }
        } else if (req.role().equals(Role.ADMIN)) {
            User user = userRepository.findByEmail(req.email()).orElseThrow();
            if(passwordEncoder.matches(req.password(), user.getPassword())){
                String accessToken = jwtTokenProvider.createToken(req.email(), req.role().toString());
                String refreshToken = jwtTokenProvider.createRefreshToken(req.email(), req.role().toString());
                adminTemplate.opsForValue().set(user.getEmail(), refreshToken, expirationHours, TimeUnit.HOURS);
                map.put(ACCESS_TOKEN, accessToken);
                map.put(USERNAME, user.getUsername());
                return map;
            }
        }
        map.put(FAILURE, "login fail");
        return map;
    }
}

이프엘스이프엘스이프엘스이프엘스이프....
사용자의 구분이 회원, 판매자, 관리자가 되기 때문에
내 생각에는 어..롤값 받으면 그거 보고 이렇게 해주면 되지 않을까 했다.
멘토링을 받으면서 이 부분을 감사하게도 리뷰를 해주셨다.
그러면서 소개시켜 주신게 전략 패턴(Strategy pattern)이다.

전략 패턴이란

 

전략 패턴(Strategy Pattern)은 객체의 행위를 정의하고, 이를 캡슐화하여 필요에 따라 행위를 동적으로 교체할 수 있도록 설계된 행위 디자인 패턴 중 하나입니다. 이 패턴은 알고리즘, 정책 또는 행위를 각각 독립적인 클래스로 분리하여 관리하고, 런타임에 이를 교체할 수 있도록 유연성을 제공합니다.
-채찍비티 왈

뭔 말인지 잘 모르겠다.

위의 예시를 가지고 곰곰히 생각해보자
위의 예시는 단순히 로그인이다
로그인은 정말 간단하다.


먼저 이메일로 해당 유저가 DB에 저장되어 있는지 확인한다.
해당 유저가 존재한다면 비밀번호도 맞는지 검증한다.
비밀번호도 맞다면 해당 유저의 이메일과 역할을 가지고 토큰을 생성한다.

자, 3단계를 거치니까 참 간단한데, 역할이 3가지면?
바로 3배의 코드량이 된다
문제는 이 방식을 어떻게 하느냐이다.

위 방법처럼 한 곳에 때려박아도 된다. 

 

문제는....

가독성이 일단 좋지 않다.
로그인이 있으면 토큰 갱신도 있을 거고 로그아웃도 있을 거다
그러면 지금 저 이프엘스이프엘스이프를 3번을 더 반복한게 한 파일에 있을 거다.
나라도 이해는 해도 그닥 자세히 보진 않을 거 같기는 하다.

그리고, 중복되는 게 너무 많다.
이프엘스이프엘스이프를 보면 들어가는 값은 비슷한데 과정은 너무 비슷한게 많다.
그럼 함수 추출 딱대를 실천하면 되지 않을까.
근데 각 역할마다 과정이 살짝 다를 수도 있다.

그리고 하나 더 있다.
과연 한 메소드 안에서 3명의 역할에 대한 로그인을 수행하는게 합당한가?
이 메소드에 오류가 있으면 어..끔찍한 일이 벌어지는 것이다.

그럼 발상을 달리 해보는 것이다.
객체지향에는 뭐가 있는가, 캡상추다(캡슐화,상속,추상화,다형성)이 있다.
이걸 활용하면 해결되지 않을까?

조금 다른 고민을 해야 한다.
과정이 비슷하다? 그럼 적당히 추상화를 할 수 있다는 것이 아닌가
그러면 이제 인터페이스를 활용하면 뭔가를 할 수 있지 않을까?

전략 패턴은 그런 해답을 제공하려고 한다.
전략 패턴은 크게 전략 인터페이스(Strategy Interface), 전략 컨텍스트(Strategy Context), 구체적 전략(Concrete Strategy)로 구성할 것을 제안한다.

전략 인터페이스에 공통적으로 구현할 기능들을 추상화 시켜 놓고
어떤 전략으로 가야하는지를 전략 컨텍스트에서 명시하고
구체적 전략은 전략 인터페이스를 구현한다.

 이런 흐름이다.

그럼 이를 어떻게 처음 코드에 적용할 수 있는가
역할이 바로 그 전략이 되었다.
로그인이라는 과정은 앞서 말한 확인하고 비밀번호 검증하고 토큰 발급하는 과정을 거친다.
그럼 이 비슷한 기능들을 추상화하여서 전략 인터페이스를 만든다.

package com.buysellgo.userservice.strategy.auth.common;

import com.buysellgo.userservice.common.entity.Role;
import com.buysellgo.userservice.strategy.auth.dto.AuthDto;
import java.util.Map;

/**
 * 인증 전략을 정의하는 인터페이스입니다.
 * 각 구현체는 JWT 생성, 갱신, 삭제 및 역할 지원 여부를 처리해야 합니다.
 *
 * @param <T> AuthResult에 반환되는 데이터의 타입입니다.
 */
public interface AuthStrategy<T extends Map<String, Object>> {

    /**
     * 주어진 인증 정보를 사용하여 JWT를 생성합니다.
     *
     * @param dto 인증 정보를 포함하는 AuthDto입니다.
     * @return JWT 생성 결과를 포함하는 AuthResult입니다.
     */
    AuthResult<T> createJwt(AuthDto dto);

    /**
     * 주어진 토큰을 사용하여 JWT를 갱신합니다.
     *
     * @param token 갱신할 JWT 토큰입니다.
     * @return JWT 갱신 결과를 포함하는 AuthResult입니다.
     */
    AuthResult<T> updateJwt(String token);

    /**
     * 주어진 토큰을 사용하여 JWT를 삭제합니다.
     *
     * @param token 삭제할 JWT 토큰입니다.
     * @return JWT 삭제 결과를 포함하는 AuthResult입니다.
     */
    AuthResult<T> deleteToken(String token);

    /**
     * 주어진 이메일과 프로바이더를 사용하여 소셜 로그인을 처리합니다.
     * jwt 토큰을 생성하고 사용자 정보를 반환합니다.
     *
     * @param email 사용자의 이메일입니다.
     * @return 소셜 로그인 결과를 포함하는 AuthResult입니다.
     */
    AuthResult<T> socialSignIn(String email);
    /**
     * 주어진 역할을 이 전략이 지원하는지 여부를 결정합니다.
     *
     * @param role 확인할 역할입니다.
     * @return 전략이 역할을 지원하면 true, 그렇지 않으면 false입니다.
     */
    boolean supports(Role role);
}


(제네릭이 남발한건 sonaqube라는 정적 분석 툴 때문이다. 사용하면 워...음 생각하는게 좀 달라지긴 한다.)
위에 있는 것들이 각 역할별로 공통적으로 구현할 것들이다.
그리고 가장 밑의 supports가 보이는가? 참고하라.

이제 컨텍스트를 보자

package com.buysellgo.userservice.strategy.auth.common;

import com.buysellgo.userservice.common.entity.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;



@Component
@RequiredArgsConstructor
public class AuthContext {
    private final List<AuthStrategy<Map<String, Object>>> strategies;

    public AuthStrategy<Map<String, Object>> getStrategy(Role role) {
        return strategies.stream()
            .filter(strategy -> strategy.supports(role))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("Unsupported role: " + role));
    }
}

먼저 불러오는 것은 분명 인터페이스의 리스트를 불러오는 것 같다.
어떻게? 라고 생각하면 다음에 나올 구체적 전략을 보면 이해가 갈 것이다.
어쨋든 전략을 선택하는 getStrategy를 보면 역할을 받는다 그런 다음에 전략 리스트를 순회하면서 
각 전략의 아까 참고하라 했던 supports를 돌면서 역할과 구체적 전략이 매칭되는지 검사한다.
그리고 찾으면 그 구체적 전략을 반환하는 것이다.

자 이제 구체적 전략이다.(회원 전략이다, 아마 전략패턴 적용안하면 크고 아름다운 서비스가 만들어졌겠지)

package com.buysellgo.userservice.strategy.auth.impl;

import com.buysellgo.userservice.common.auth.JwtTokenProvider;
import com.buysellgo.userservice.common.auth.TokenUserInfo;
import com.buysellgo.userservice.common.entity.Role;
import com.buysellgo.userservice.domain.user.Profile;
import com.buysellgo.userservice.domain.user.User;
import com.buysellgo.userservice.repository.UserRepository;
import com.buysellgo.userservice.strategy.auth.common.AuthResult;
import com.buysellgo.userservice.strategy.auth.common.AuthStrategy;
import com.buysellgo.userservice.strategy.auth.dto.AuthDto;
import jakarta.transaction.Transactional;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import java.util.Map;
import java.util.HashMap;
import java.util.Optional;
import java.util.concurrent.TimeUnit;       
import com.buysellgo.userservice.repository.ProfileRepository;

import static com.buysellgo.userservice.common.util.CommonConstant.*;

@Transactional
@Component
@RequiredArgsConstructor
public class UserAuthStrategy implements AuthStrategy<Map<String, Object>> {
    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final RedisTemplate<String, Object> userTemplate;
    private final ProfileRepository profileRepository;
    
    @Override
    public AuthResult<Map<String, Object>> createJwt(AuthDto dto) {
        Optional<User> userOptional = userRepository.findByEmail(dto.email());
        
        if (userOptional.isEmpty()) {
            return AuthResult.fail(USER_NOT_FOUND.getValue());
        }
        
        User user = userOptional.get();
        Profile profile = profileRepository.findByUser(user).orElseThrow();
        Map<String, Object> data = new HashMap<>();
        data.put(USER_VO.getValue(), user.toVo());
        data.put(PROFILE_VO.getValue(), profile.toVo());

        if (!passwordEncoder.matches(dto.password(), user.getPassword())){
            return AuthResult.fail(PASSWORD_NOT_MATCHED.getValue());
        }
        
        String accessToken = jwtTokenProvider.createToken(dto.email(),dto.role().toString(),user.getUserId());
        String refreshToken =jwtTokenProvider.createRefreshToken(dto.email(),dto.role().toString());

        long expirationHours = dto.keepLogin().isKeepLogin() ? KEEP_LOGIN_HOURS.getValue() : DEFAULT_HOURS.getValue();
        userTemplate.opsForValue().set(
                user.getEmail(),
                refreshToken,
                expirationHours,
                TimeUnit.HOURS
        );

        return AuthResult.success(accessToken, data);
    }

    @Override
    public AuthResult<Map<String, Object>> updateJwt(String token) {
        TokenUserInfo userInfo = jwtTokenProvider.validateAndGetTokenUserInfo(token);
        Optional<User> userOptional = userRepository.findByEmail(userInfo.getEmail());
        if (userOptional.isEmpty()) {
            return AuthResult.fail(USER_NOT_FOUND.getValue());
        }

        User user = userOptional.get();
        Map<String, Object> data = new HashMap<>();
        data.put(USER_VO.getValue(), user.toVo());

        if(ObjectUtils.anyNull(userTemplate.opsForValue().get(user.getEmail()))){
            return AuthResult.fail(TOKEN_OR_USER_NOT_FOUND.getValue());
        }
        String accessToken = jwtTokenProvider.createToken(user.getEmail(),Role.USER.toString(),user.getUserId());


        return AuthResult.success(accessToken, data);
    }

    @Override
    public AuthResult<Map<String, Object>> deleteToken(String token) {
        TokenUserInfo userInfo = jwtTokenProvider.validateAndGetTokenUserInfo(token);
        Optional<User> userOptional = userRepository.findByEmail(userInfo.getEmail());
        if (userOptional.isEmpty()) {
            return AuthResult.fail(USER_NOT_FOUND.getValue());
        }
        User user = userOptional.get();
        Map<String, Object> data = new HashMap<>();
        data.put(USER_VO.getValue(), user.toVo());

        if(ObjectUtils.anyNull(userTemplate.opsForValue().get(user.getEmail()))){
            return AuthResult.success(TOKEN_OR_USER_NOT_FOUND.getValue(),data);
        }

        userTemplate.delete(user.getEmail());

        return AuthResult.success(REFRESH_TOKEN_DELETED.getValue(),data);
    }

    @Override
    public AuthResult<Map<String, Object>> socialSignIn(String email) {
        Optional<User> userOptional = userRepository.findByEmail(email);
        if (userOptional.isEmpty()) {
            return AuthResult.fail(USER_NOT_FOUND.getValue());
        }
        User user = userOptional.get();
        Profile profile = profileRepository.findByUser(user).orElseThrow();
        Map<String, Object> data = new HashMap<>();
        data.put(USER_VO.getValue(), user.toVo());
        data.put(PROFILE_VO.getValue(), profile.toVo());

        String accessToken = jwtTokenProvider.createToken(user.getEmail(), user.getRole().toString(), user.getUserId());
        data.put("access_token(개발 중에만 표시)", BEARER_PREFIX.getValue() + accessToken);
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), user.getRole().toString());

        userTemplate.opsForValue().set(user.getEmail(), refreshToken, KEEP_LOGIN_HOURS.getValue(), TimeUnit.HOURS);

        
        return AuthResult.success(accessToken, data);
    }

    @Override
    public boolean supports(Role role) {
        return Role.USER.equals(role);
    }
}


아까 어떻게 인터페이스의 리스트를 가져오라 하던 컨텍스트가 구체적 전략을 찾을 수 있는지는 
구체적 전략의 위에 표시한 @Component 덕분이다.
프로그램을 실행하면서 자동적으로 컴포넌트 스캔이 이루어지기 때문에 이미 이 구체적 전략이 인터페이스의 구현체인게 이미 알려져 있는 것이다.
또한 다형성 덕분에 가져오는 것이기도 하다.

전략 패턴을 적용함으로서 
각 역할별로 어떻게 보면 독립적으로 각 엔티티의 리포지토리에 접근할 수 있게 되었다.
명확하게 회원의 토큰작업은 회원의 토큰 관련 전략이
판매자의 토큰작업은 판매자의 토큰 관련 전략이
관리자의 토큰작업은 관리자의 토큰 관련 전략이 전담하게 된 것이다.

만약 공통적으로 추가할 기능이 있다면 인터페이스에 추가하고 각 역할별 전략에서 구체적으로 커스텀하게 구현하면 되고,
역할이 추가된다면 구체적 전략만 하나 더 만들면 된다.

전에는 한 서비스에서 전략 추가되면 메서드 수정하고 기능 추가되면 다른 역할들끌어오면서 했다면
이 전략 패턴은 기존 코드를 수정하기 보다 확장한다는 SOLID의 OCP에 적합하다고 볼 수 있다.

물론, 코드량 많아지긴 하더라...?
구현을 각 역할별로 전담해있어서..ㅠ
근데 캡상추다의 캡추다를 경험할 수 있다는 점에서..나는 객체지향스러운 뭔가를 했다고 할 수 있겠다.

이게 스노우볼이 되어서 계층 아키텍처에도 영향을 주더라..그건 차후 소개하겠다.
(이제 채팅 서비스 만들러 가야지)