SMALL

Spring Security 관련 고민

Spring security에 JWT를 도입하였다. Jwt Authentication Filter 구성 시 SecurityContextHolder에 Member 객체를 전달해야 했다.

 

Jwt는 OncePerRequest로 설정되어 있었기에 모든 요청마다 Member 객체를 ContextHolder에 전달하려다 보니 매 요청마다 토큰 validation과 DB 조회를 통한 사용자 validation이 동시에 발생하였다.

 

첫번째 결론

DB 조회를 최소화 해야 시스템의 안정성과 성능 상의 오버헤드를 줄일 수 있다고 판단했다.

그에 따라 auth filter의 역할을 JWT 자체에 대한 validation으로 한정하고 사용자 validation은 controller 단에서 처리하고, 사용자 validation을 담당하는 component를 구성하여 사용하면 OOP 관점에서 SRP 원칙을 고수할 수 있겠다고 생각했다.

 

component를 사용하는 것보다 AOP를 지향하는 것은 어떨까 생각하였으나 Annotation 기반 설계는 자동화에는 매우 용이하나 method 보다 활용도가 떨어졌다.

 

프로젝트 특성상 엔티티(테이블)의 구조가 계층적이었고, 엔티티에 따라 데이터 사용 권한 validation 방식이 상이했다.

 

결국 Jwt auth filter에서 SecurityContextHolder를 사용할 수 밖에 없고, 이는 내가 생각했던 auth filter의 SRP 원칙에 어긋났다.

 

결론적으로,

1. AuthUtil 컴포넌트로 사용자 validation 역할을 분리했다.

2. JwtAuthenticationFilter에서는 토큰 자체의 Validation만 진행하기로 하고 JWT에 필요한 모든 정보를 포함하기로 했다.

3. SecurityContextHolder에는 실제 Member 객체가 아닌 JWT에 포함된 정보만 갖는 MemberClaim 객체를 사용하기로 했다.

 

두번째 결론

사용자의 데이터 소유권 validation에 DB 조회는 애초에 필요 없다는 것을 깨달았다.

 

JWT는 서버에서 generate 되는데, 그럼 DB 조회 후 생성된 Member 객체의 UUID는 JWT의 subject에 포함될 것이고, JWT verify를 통과했다면 변조 되지 않음이 보장되므로 당연히 유효한 사용자의 UUID일 것이다(로그아웃이나 토큰 Replay와 같은 예외는 별개의 문제)

 

결론적으로,

1. AOP 지향적으로 사용자 validation을 설계하고 현재 인증된 사용자에 대해선 Service 레이어에서 조회를 할 것이므로 새로운 컴포넌트는 불필요하다.

 

세번째 결론

사용자의 아이템 ID의 경우 DB 조회 및 join 등 복잡한 validation 로직이 필요한데 이의 경우는 AOP로 처리하기엔 한계가 있었다.

 

엔티티가 추가됨에 따라 사용자 권한 validation을 위한 aspect 코드와 새로운 annotation을 정의하는 것은 확장성과 유지보수성에 치명적이었다.

계층적인 구조를 띄는 예제 테이블

 

그래서 다소 우아하진 않지만 서비스별로 해당 서비스가 주로 다루는 엔티티에 관한 데이터 소유권 validation을 진행하는 private 메서드를 정의했다. 차후 리팩토링 시, 이들을 모아서 util 컴포넌트로 모아 OOP 원칙을 고수할 수 있도록 해야겠다.

 

결론적으로,

Service 단에서 소유권 Validation 로직을 다뤄야 한다.

네번째 결론

@PreAuthorize, @PostAuthorize, @Secured 어노테이션의 존재를 깨달았다.

@PreAuthorize를 통해 AOP 기반으로 메서드 실행 전 사용자의 해당 데이터에 대한 소유권을 먼저 검증한 다음 로직을 진행할 수 있다.

...
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig { ... }

 

해당 어노테이션을 사용하기 위해선 SecurityConfig에 다음 어노테이션을 추가한다

public abstract class AccessHandler {
    public final boolean ownershipCheck(UUID resourceId) {
        return isResourceOwner(resourceId);
    }

    public final UUID getCurrentMemberId() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        return ((Member) auth.getPrincipal()).getId();
    }
    abstract boolean isResourceOwner(UUID resourceId);
}

 

@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VocabAccessHandler extends AccessHandler {
    private final VocabRepository vocabRepository;

    @Override
    boolean isResourceOwner(UUID resourceId) {
        return vocabRepository.findById(resourceId)
                .map(Vocab::getMember)
                .filter((m) -> getCurrentMemberId().equals(m.getId()))
                .isPresent();
    }
}

AccessHandler abstract를 정의하고 각 Entity에 맞게 검증 로직을 구현한다


    @Transactional(readOnly = true)
    @PreAuthorize("@vocabAccessHandler.ownershipCheck(#vId)")
    public VocabDto getVocabById(UUID vId) {
        Vocab vocab = vocabRepository.findById(vId)
                .orElseThrow(() -> new ApiException(NO_VOCAB));

        return VocabDto.fromEntity(vocab);
    }

데이터에 대한 트랜재션이 2번 중복되어 효율은 떨어질 수 있으나 단 하나의 조회를 늘림으로써 클린코드, 검증과 비즈니스 로직의 역할 분리, AOP 패턴을 사용할 수 있으니 이 정도의 오버헤드는 나쁘지 않은 trade-off 라고 생각된다

LIST
SMALL

내가 알고 있던 테스트에 관하여

기존에 유닛테스트가 중요하다는 것은 전공에서도 배웠고 여기저기서 주워들은 건 있었지만 정작 프로젝트를 진행하다보면 유닛테스트가 정말 의미가 있나 싶은 순간들이 많았다.

 

아래의 회원가입 코드의 경우, 스프링 DI로 부터 주입받은 다른 컴포넌트 로직의 동작을 제외하면 해당 메서드의 온전한 로직은 겨우 파라미터로 변수를 전달하거나 return 값을 던져주는 것 뿐이다.

    @Transactional
    public JoinDto.Response join(@Valid JoinDto.Request request) {
        Member member = Member.builder()
                .email(request.getEmail())
                .nickname(request.getNickname())
                .password(passwordEncoder.encode(request.getPassword()))
                .build();

        return JoinDto.Response.fromEntity(memberRepository.save(member));
    }

실제 로직의 코드에 비해 테스트 코드 사이즈는 아래와 같이 비대해진다.

@ExtendWith(MockitoExtension.class)
class TestTest {
    @Mock
    private MemberRepository memberRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private AuthService authService;

    @Test
    void joinTestSuccess() {
        // given
        JoinDto.Request request = JoinDto.Request.builder()
                .email("test@example.com")
                .nickname("test")
                .password("test123")
                .build();

        String encodedPassword = "encodedPassword123";

        Member savedMember = Member.builder()
                .email(request.getEmail())
                .nickname(request.getNickname())
                .password(encodedPassword)
                .build();

        when(passwordEncoder.encode(request.getPassword()))
                .thenReturn(encodedPassword);

        when(memberRepository.save(any(Member.class)))
                .thenReturn(savedMember);

        // when
        JoinDto.Response response = authService.join(request);

        // then
        assertEquals(request.getEmail(), response.getEmail());
        assertEquals(request.getNickname(), response.getNickname());
    }
}

모든 의존성을 전부 Mocking 하다보면 Java의 OOP 특성이 많이 드러나는 Spring에선 특히 테스트 코드 작성에 들이는 시간적인 오버헤드가 크다.

 

많은 모듈과 컴포넌트가 계층적으로 나눠져 의존성 주입을 통해 많은 것이 동작하는 스프링부트 아키텍처 특성 상 일일이 모든 모듈에 대한 테스트 코드를 작성하는 것은 배보다 배꼽이 더 큰 꼴이었기에 의문이 꽤 많이 들었다.

스프링의 계층적인 아키텍처 구조

Martin Fowler의 유닛 테스트

그래서 검색을 하던 중 ‘마틴 파울러’ 라는 사람의 테스트 피라미드 모델을 보게 되었고, 유닛 테스트에 대한 회의감이 심해지던 와중에 유닛 테스트의 비중이 가장 크다는 그림을 보고 더 혼란스러워졌다.

테스트 피라미드

아무리 봐도 자바는 체계적인 언어여서 통합 테스트만 진행하더라도 탄탄한 예외처리를 통해 충분히 빠른 디버깅이 가능할텐데 유닛 테스트의 중요성을 재차 강조하다니.

 

그러나 마틴 파울러가 말하는 유닛 테스트는 내가 알던 것과 약간 달랐다.

 

마틴 파울러가 말하는 유닛 테스트에는 Sociable(협동) Unit Test, Solitary(단일) Unit Test 두 가지가 존재했는데, 전자가 보통 흔히 생각하는 통합 테스트, 후자가 우리가 익히 배워왔던 고전적인 유닛 테스트였던 것이다.

 

즉, Solitary Unit Test는 mock/stub을 이용하여 해당 컴포넌트의 의존성을 배제하고 로직만을 테스트하는 것, Sociable Unit Test는 한 컴포넌트의 비즈니스 로직에 대해 다른 협력 컴포넌트들과 함께 테스트 한다는 것이다.

그렇다면 Sociable Test와 Integration Test의 차이는 무엇인가?

Sociable Test는 Unit test인 만큼 여전히 특정 컴포넌트나 모듈, 클래스에 대한 테스트라면, 통합 테스트는 아예 다른 시스템과의 상호작용이나, 다른 기술 스택 간의 상호작용, 외부 API, 네트워크 등의 영향을 받는 조건에 대해 모두 테스트 하는 것이다.

 

또한 Unit test 케이스는 개발자가 직접 작성한다면, Integration test 케이스는 개발자가 아닌 타인이 작성한다는 것이 차이점이다.

 

그렇다면 내게 필요한 것은 비즈니스 로직의 핵심적이고 복잡한 모듈에 대해선 Solitary Unit Test가 필요할 것이고 (예를 들자면 JWT 토큰을 생성하거나 Validation 하는 로직), 어느 정도 흐름이 필요한 모듈엔 Sociable Unit Test를 통해 해당 컴포넌트와 협력하는 다른 컴포넌트들과의 흐름을 테스트해야 할 것이다. (특정 Util 컴포넌트를 사용한 사용자 Validation 등)

Solitary(단독) Unit Test

흔히 생각할 수 있는 고전적인 방식의 Unit Test, 테스트더블(Mock, Stub)을 이용해 의존성을 배제하여 다른 컴포넌트의 영향을 받지 않고 오로지 해당 컴포넌트의 로직만을 테스트하는 것

Sociable(협동) Unit Test

특정 컴포넌트를 테스트 한다는 것은 고전적인 Unit Test와 동일하지만 의존성을 배제하지 않고 팀 또는 개발자가 지정한 Unit의 범위에 맞게 다른 컴포넌트와 함께 협동으로 테스트하는 것, 흐름 단위로 Unit의 범위를 지정할 수도 있음

 

통합 테스트와의 차이로는 통합테스트는 다른 시스템과의 상호작용이나, 다른 기술 스택 간의 상호작용, 외부 API, 네트워크 등의 영향을 받는 조건에 대해 모두 테스트 하는 것, 또한 Unit test 케이스는 주로 개발자가 직접 작성한다면, Integration test 케이스는 개발자가 아닌 타인이 작성할 수 있다는 것이 차이점

 

Sociable Unit Test

테스트 하고자 하는 Unit 범위 내에 있는 Bean은 실제 Bean을 사용하고, 범위를 벗어나는 컴포넌트는 Mocking을 하여 Unit Test를 진행할 수 있다

 

아래 코드는 JWT를 적용한 회원가입 성공 케이스에 대한 테스트이다. AuthService의 join() 메서드에 대한 전반적인 로직의 흐름을 테스트 하며 DB 접근에 대한 부분은 Mocking 하였다

 

- @TestPropertySource를 통해 application.properties를 Mocking 한다

- @ContextConfiguration을 통해 해당 테스트 Context 내에서 사용할 Bean의 형태를 지정한다

 --> AuthService.class는 @Service를 통해 정의되었기에 Spring Context를 통해 의존성이 주입되므로 별도로 지정한다

 --> 나머지는 생성자를 통해 객체를 생성할 수 있는 @Component 또는 @Bean 이므로 

- 테스트 클래스 내에 @TestConfiguration 어노테이션을 사용하여 어느 Bean을 Mocking하고 어느 Bean을 실제 Bean으로 사용할 지에 대한 의존성 주입 생성자를 지정한다

 --> @TestConfiguration은 @Configuration과 유사하나 Spring Test 전용으로 사용한다

@ExtendWith(SpringExtension.class)
@TestPropertySource(properties = {
        "jwt.secret=test-secret",
        "jwt.exp=3600"
})
@ContextConfiguration(classes = {AuthService.class, AuthServiceTest.TestConfig.class})
class AuthServiceTest {

    @Autowired
    private AuthService authService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private MemberRepository memberRepository; // mock임

    @Test
    void joinTestSuccess() {
        // given
        String rawPassword = "test1234";
        String encodedPassword = passwordEncoder.encode(rawPassword);

        JoinDto.Request request = JoinDto.Request.builder()
                .email("user@example.com")
                .nickname("tester")
                .password(rawPassword)
                .build();

        Member memberToReturn = Member.builder()
                .email(request.getEmail())
                .nickname(request.getNickname())
                .password(encodedPassword)
                .build();

        // mocking
        Mockito.when(memberRepository.save(Mockito.any(Member.class)))
                .thenReturn(memberToReturn);

        // when
        JoinDto.Response response = authService.join(request);

        // then
        assertEquals(request.getEmail(), response.getEmail());
        assertEquals(request.getNickname(), response.getNickname());
        Mockito.verify(memberRepository).save(Mockito.any(Member.class));
    }

    @TestConfiguration
    static class TestConfig {

        @Bean
        public MemberRepository memberRepository() {
            return Mockito.mock(MemberRepository.class);
        }

        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder(); // 실제 컴포넌트 사용
        }

        @Bean
        public JwtProperties jwtProperties() {
            return new JwtProperties();
        }

        @Bean
        public JwtUtil jwtUtil() {
            return new JwtUtil(jwtProperties());
        }

        @Bean
        public JwtBlacklistService jwtBlacklistService() {
            return Mockito.mock(JwtBlacklistService.class);
        }

        @Bean
        public AuthenticationManager authenticationManager() {
            return Mockito.mock(AuthenticationManager.class);
        }
    }
}

 

LIST
SMALL

이진 트리를 입력받아 전위 순회(preorder traversal), 중위 순회(inorder traversal), 후위 순회(postorder traversal)한 결과를 출력하는 프로그램을 작성하시오.

예를 들어 위와 같은 이진 트리가 입력되면,

  • 전위 순회한 결과 : ABDCEFG // (루트) (왼쪽 자식) (오른쪽 자식)
  • 중위 순회한 결과 : DBAECFG // (왼쪽 자식) (루트) (오른쪽 자식)
  • 후위 순회한 결과 : DBEGFCA // (왼쪽 자식) (오른쪽 자식) (루트)

가 된다.

입력

첫째 줄에는 이진 트리의 노드의 개수 N(1 ≤ N ≤ 26)이 주어진다. 둘째 줄부터 N개의 줄에 걸쳐 각 노드와 그의 왼쪽 자식 노드, 오른쪽 자식 노드가 주어진다. 노드의 이름은 A부터 차례대로 알파벳 대문자로 매겨지며, 항상 A가 루트 노드가 된다. 자식 노드가 없는 경우에는 .으로 표현한다.

출력

첫째 줄에 전위 순회, 둘째 줄에 중위 순회, 셋째 줄에 후위 순회한 결과를 출력한다. 각 줄에 N개의 알파벳을 공백 없이 출력하면 된다.

 

<제출>

import java.util.*;
import java.util.stream.*;

public class Main {
	public static void main(String[] args) {
		Scanner sc = new Scanner(System.in);
		Tree<String> tree = new Tree<>();
		ArrayList<String> lines = new ArrayList<>();
		int n = sc.nextInt(); sc.nextLine();
		
		for(int i=0; i<n; i++) {
			lines.add(sc.nextLine());
		}
		
		sc.close();
		
		for(String line : lines) {
			List<String> datas = Arrays.stream(line.split(" "))
									.collect(Collectors.toList());
			String parentData = datas.get(0);
			if(!datas.get(1).equals(".")) {
				tree.addL(parentData, datas.get(1));
			}
			if(!datas.get(2).equals(".")) {
				tree.addR(parentData, datas.get(2));
			}
		}
		
		for(String s : tree.getPreorder()) {
			System.out.print(s);
		}
		System.out.print("\n");

		for(String s : tree.getInorder()) {
			System.out.print(s);
		}
		System.out.print("\n");
		
		for(String s : tree.getPostorder()) {
			System.out.print(s);
		}
	}
	
	static class Tree<T>{
		Node<T> root;
		
		public void addL(T parentData, T data) {
			Node<T> parentNode = this.findByData(parentData)
					.orElseGet(() -> new Node<T>(parentData));
			Node<T> childNode = this.findByData(data)
					.orElseGet(() -> new Node<T>(data));
			
			if(root == null) {
				root = parentNode;
			}
			
			parentNode.l = childNode;
		}
		
		public void addR(T parentData, T data) {
			Node<T> parentNode = this.findByData(parentData)
					.orElseGet(() -> new Node<T>(parentData));
			Node<T> childNode = this.findByData(data)
					.orElseGet(() -> new Node<T>(data));
			
			if(root == null) {
				root = parentNode;
			}
			
			parentNode.r = childNode;
		}
		
		public List<T> getPreorder(){
			ArrayDeque<Node<T>> stack = new ArrayDeque<>();
			ArrayList<T> result = new ArrayList<>();
			Node<T> it;
			
			stack.push(root);
			while(!stack.isEmpty()) {
				it = stack.pop();
				result.add(it.data);
				if(it.r != null) {
					stack.push(it.r);
				}
				if(it.l != null) {
					stack.push(it.l);
				}
			}
			
			return result;
		}
		
		public List<T> getInorder(){
			ArrayDeque<Node<T>> stack = new ArrayDeque<>();
			ArrayList<T> result = new ArrayList<>();
			Node<T> it = root;
			
			while(it != null || !stack.isEmpty()) {
				if(it != null) {
					stack.push(it);
					it = it.l;
				} else {
					it = stack.pop();
					result.add(it.data);
					it = it.r;
				}
			}
			
			return result;
		}
		
		public List<T> getPostorder(){
			ArrayDeque<Node<T>> stack = new ArrayDeque<>();
			ArrayDeque<Node<T>> resultStack = new ArrayDeque<>();
			ArrayList<T> result = new ArrayList<>();
			Node<T> it;
			
			stack.push(root);
			while(!stack.isEmpty()) {
				it = stack.pop();
				resultStack.push(it);
				if(it.l != null) {
					stack.push(it.l);
				}
				if(it.r != null) {
					stack.push(it.r);
				}
			}
			
			while(!resultStack.isEmpty()) {
				result.add(resultStack.pop().data);
			}
			
			return result;
		}
		
		public Optional<Node<T>> findByData(T data){
			ArrayDeque<Node<T>> stack = new ArrayDeque<>();
			Node<T> it;
			if(root == null) {
				return Optional.empty();
			}
			
			stack.push(root);
			while(!stack.isEmpty()) {
				it = stack.pop();
				if(it.data.equals(data)) {
					return Optional.of(it);
				}
				if(it.r != null) {
					stack.push(it.r);
				}
				if(it.l != null) {
					stack.push(it.l);
				}
			}
			
			return Optional.empty();
		}
	}
	
	static class Node<T>{
		T data;
		Node<T> l;
		Node<T> r;
		
		Node(T data){
			this.data = data;
		}
	}
}

 

재귀가 너무 싫어서 재귀를 대체할 만한 방법이 없을까 고민하다가 함수는 스택 프레임에서 호출되는 구조임을 이용해 구현하기로 했다.

비록 남의 코드를 참고하긴 했지만 이 방법이 익숙해지면 어떤 재귀 문제도 풀어낼 수 있지 않을까?

물론 이 문제는 재귀로 구현하면 이보다 훨씬 간단하다

LIST

'IT > BOJ' 카테고리의 다른 글

[재귀] 11729 - 하노이 탑 이동 순서  (0) 2023.11.08
[재귀] 1629 - 곱셈  (0) 2023.11.08
[BFS/DFS] 2178 - 미로 탐색  (0) 2023.11.08
[BFS/DFS] 1926 - 그림  (0) 2023.11.08

+ Recent posts