Spring

🚴🏽 Spring-Security 오픈소스에 기여하기 (진행중)

Issue : ConcurrentSessionFilter breaks permitAll endpoint on session expiration
PR : Add ContinueRequestSessionInformationExpiredStrategy

들어가며

작년 오픈소스 스터디 때 Spring-batch 오픈소스에 기여하기로 컨트리뷰터가 될 수 있었습니다. 그 이후에도 기여를 해야지라고 다짐했지만 결국 두 번째 스터디에 참여해서야 Spring-Security PR을 올리게 되었습니다.

오픈소스 분석 및 해결방안

이슈 자체는 동시접속을 관리하는 ConcurrentSessionFilter에서 session 관리를 하고 있는데, 여기서 session이 만료된 경우에 대해서는 별도 처리없이 early return 처리를 해버리기 때문에 이후의 filter 처리가 이뤄지지 않는다는 문제였습니다. 여기서 문제를 해결하기 위해 메인테이너가 방향성을 제시해 놓은 이슈였기 때문에 쉽게 접근할 수 있었습니다.

static class ContinueRequestSessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
	@Override
	public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws ServletException, IOException {
		event.getFilterChain().doFilter(event.getRequest(), event.getResponse());
	}
}

구현시 고민한 부분은 ContinueRequestSessionInformationExpiredStrategy를 생성해서 default 전략으로 가지고 가야하는 부분인가 아니면 사용자 입맛에 맞는 전략으로 알아서 넣을 수 있게 하느냐의 문제였습니다. 기존의 세션 전략으로는 ResponseBodySessionInformationExpiredStrategy를 사용하고 있는데 단순히 세션 만료를 return하는 전략이었습니다. 일단 PR 자체는 메인테이너 제시 코드를 하나의 클래스로 별도로 만들어서 다른 전략처럼 선택할 수 있게 그리고 그걸로 ConcurrentSessionFilter 세팅하는 방식으로 적용했습니다. default 처리를 할 수도 있었지만 세션 종료 후 이어지는 필터를 수행하는 것이 오히려 불필요한 과정이 될 수도 있을 것이라는 생각이었습니다. 현재 이 부분은 메인테이너와 조율하면서 변경할 가능성이 있습니다.

스터디 회고

저번 스터디는 1달동안 하는거라서 약간 중간중간 나태해졌던 것 같은데 이번에는 1주일 동안 이슈를 정하고 하루 날 잡아서 2시간 안에 PR올리기를 했더니 더 몰입해서 할 수 있었던 점이 좋았습니다. 그리고 이전에 Spring security in Action을 책으로 보면서 공부할 때는 약간 지루할 때도 있었는데 오히려 실제 코드를 보면서 접하니까 더 이해가 빠르게 되었습니다.



🚴🏽 Spring-Batch 오픈소스에 기여하기

Issue : StepBuilder - issue when setting the taskExecutor before faultTolerant()
PR : Add AbstractTaskletStepBuilder copy constructor

들어가며

오픈소스 기여 스터디를 통해 spring-batch 기여에 참여하게 되었습니다. 이슈 선정의 목표는 당시에 처음 오픈소스를 참여하는 것이라서 욕심내지 않고, 쉬우면서 중요한 오픈소스에 기여해보자는 단순한 목표를 가지고 시작했습니다. GDG 오픈소스 스터디에 참여해서 스터디 운영자 분과 다른 스터디 참여하시는 분들의 PR 참여를 노션으로 보면서 기한 내에 PR를 할 수 있었습니다. (아직 merge 반영되지 않았지만 메인테이너가 확인한 상태입니다.)

Spring batch에 대한 간략 설명

Spring은 워낙 유명한 오픈소스이기 때문에 다들 알겠지만 spring-batch의 배치 잡을 사용할 프로젝트가 아닌 이상 사용해보지 않은 사람도 많은 것 같습니다. 저도 알고는 있었지만 오픈소스로 내부 오픈소스를 본 것은 처음이었습니다.

Spring batch는 대량의 데이터를 처리하는 데 사용되는 경량, 포괄적인 배치 프레임워크입니다.
먼저 stepItemReader, ItemProcessor, ItemWriter와 같은 구성요소를 포함하며, 특정 작업을 수행하는 역할을 합니다. 데이터 청크 단위로 처리할 수 있도록 하며 하나 이상의 step으로 job을 구성합니다.
job은 배치 처리의 실행 단위로 JobInstance로 간주되며, JobParameter에 의해 구분됩니다.JobExecution으로 실행에 대한 기록을 담습니다.

stepbuilder1.png

이슈 내용

이슈가 간단하고 테스트코드를 잘 작성해주셔서 한 번에 이해하기 편했습니다.
step을 만들 때, taskExecutor()의 위치에 따라 필드 값이 잘 상속되지 않음을 알 수 있습니다.

TaskletStep step1 = new StepBuilder("step-name", jobRepository)
                .chunk(10, transactionManager)
                .reader(itemReader)
                .processor(itemProcessor)
                .writer(itemWriter)
                .faultTolerant()
                .taskExecutor(taskExecutor)
                .build();
TaskletStep step2 = new StepBuilder("step-name", jobRepository)
                .chunk(10, transactionManager)
                .taskExecutor(taskExecutor)// The task executor is set before faultTolerant()
                .reader(itemReader)
                .processor(itemProcessor)
                .writer(itemWriter)
                .faultTolerant()
                .build();
오픈소스 분석

TaskletStep가 만들어지는 과정은 다음과 같은 구조도로 나타낼 수 있습니다.

StepBuilderHelper
|
|-- AbstractTaskletStepBuilder
    |
    |-- SimpleStepBuilder
        |
        |-- FaultTolerantStepBuilder
            |
            |-- TaskletStep

StepBuilderTaskletStep을 생성할 때, .faultTolerant()FaultTolerantStepBuilder의 영향을 받고, .taskExecutor(taskExecutor)AbstractTaskletStepBuilder의 메소드가 적용됩니다.
faultTolerant()를 적용하므로서 TaskletStep이 생성될 때 청크 지향의 시스템 구현시 실패한 아이템의 재시도(retry)와 스킵(skip) 기능을 포함하게 됩니다.

// FaultTolerantStepBuilder faultTolerant()
public FaultTolerantStepBuilder<I, O> faultTolerant() {
  return new FaultTolerantStepBuilder<>(this);
}

taskExecutor(taskExecutor)AbstractTaskletStepBuilder 속성 중 taskExecutor를 넣어준 매개변수로 변경하고 SimpleStepBuilder 인스턴스를 반환할 수 있게 해줍니다. 여기서 BAbstractTaskletStepBuilder 또는 그 하위 클래스의 타입을 나타냅니다.

// AbstractTaskletStepBuilder taskExecutor(taskExecutor)
public B taskExecutor(TaskExecutor taskExecutor) {
  this.taskExecutor = taskExecutor;
  return self();
}

SimpleStepBuilder의 생성자를 보면 부모 객체의 속성들을 받아서 새로운 객체를 생성하거나 복사 생성자를 만드는 두 가지 방법이 사용된 것을 볼 수 있습니다.

public SimpleStepBuilder(StepBuilderHelper<?> parent) {
	super(parent);
}
protected SimpleStepBuilder(SimpleStepBuilder<I, O> parent) {
    super(parent);
    this.chunkSize = parent.chunkSize;
    this.completionPolicy = parent.completionPolicy;
    this.chunkOperations = parent.chunkOperations;
    this.reader = parent.reader;
    this.writer = parent.writer;
    this.processor = parent.processor;
    this.itemListeners = parent.itemListeners;
    this.readerTransactionalQueue = parent.readerTransactionalQueue;
    this.meterRegistry = parent.meterRegistry;
}

하지만 AbstractTaskletStepBuilder의 복사 생성자가 없고 단순히 부모 객체 속성을 받아오는 super() 처리 생성자만 있는 것을 볼 수 있습니다.

public AbstractTaskletStepBuilder(StepBuilderHelper<?> parent) {
  super(parent);
}
해결방안

AbstractTaskletStepBuilder의 복사 생성자를 추가하므로서 필드 update가 가능하도록 처리했습니다.

public AbstractTaskletStepBuilder(AbstractTaskletStepBuilder<?> parent) {
    super(parent);
    this.chunkListeners = parent.chunkListeners;
    this.stepOperations = parent.stepOperations;
    this.transactionManager = parent.transactionManager;
    this.transactionAttribute = parent.transactionAttribute;
    this.streams.addAll(parent.streams);
    this.exceptionHandler = parent.exceptionHandler;
    this.throttleLimit = parent.throttleLimit;
    this.taskExecutor = parent.taskExecutor;
}

테스트 코드는 다음과 같이 작성했는데, 오픈소스 기여에서 contribute 파일이나 readme 내용을 보고 설정한 내용대로 테스트 코드를 작성하면 됩니다.
@BeforeEach로 공통으로 테스트에 사용될 simpleStepBuilder 만들어 줍니다. 코드 수정은 AbstractTaskletStepBuilder 자체는 추상클래스이므로 비교를 위한 구현체로 simpleStepBuilder를 이용했습니다.
복사 생성자가 잘 작동하는지 확인하는 테스트 코드와 taskExecutor()를 먼저하고 faultTolerant()를 이후에 했을 때 값을 비교하는 테스트 코드를 작성했습니다.

여기서,SimpleStepBuilder의 속성값은 private로 접근이 어렵기 때문에 자바 API인 리플렉션을 이용했습니다. 리플렉션은 런타임에 클래스의 정보를 조회하고, 객체의 필드나 메서드에 접근하거나, 클래스의 객체를 동적으로 생성하는 등의 작업을 가능하게 해주는 자바 API입니다. field.setAccessible(true)와 같은 방식으로 declare 필드의 접근을 조정할 수 있습니다. (accessPrivateField 메서드)

@SpringBatchTest
@SpringJUnitConfig
public class AbstractTaskletStepBuilderTests {
	private final JobRepository jobRepository =  mock(JobRepository.class);
	private final int chunkSize = 10;
	private final ItemReader itemReader = mock(ItemReader.class);
	private final ItemProcessor itemProcessor = mock(ItemProcessor.class);
	private final ItemWriter itemWriter = mock(ItemWriter.class);
	private final SimpleAsyncTaskExecutor taskExecutor  = new SimpleAsyncTaskExecutor();
	SimpleStepBuilder simpleStepBuilder;

	private <T> T accessPrivateField(Object o, String fieldName) throws ReflectiveOperationException {
		Field field = o.getClass().getDeclaredField(fieldName);
		field.setAccessible(true);
		return (T) field.get(o);
	}

	private <T> T accessSuperClassPrivateField(Object o, String fieldName) throws ReflectiveOperationException {
		Field field = o.getClass().getSuperclass().getDeclaredField(fieldName);
		field.setAccessible(true);
		return (T) field.get(o);
	}

   // 공통 사용될 simpleStepBuilder 만들기
	@BeforeEach
	void set(){
		StepBuilderHelper stepBuilderHelper = new StepBuilderHelper("test", jobRepository) {
			@Override
			protected StepBuilderHelper self() {
				return null;
			}
		};
		simpleStepBuilder = new SimpleStepBuilder(stepBuilderHelper);
		simpleStepBuilder.chunk(chunkSize);
		simpleStepBuilder.reader(itemReader);
		simpleStepBuilder.processor(itemProcessor);
		simpleStepBuilder.writer(itemWriter);
	}

    // 복사가 잘되는지 확인
	@Test
	void copyConstractorTest() throws ReflectiveOperationException {
		Constructor<SimpleStepBuilder> constructor = SimpleStepBuilder.class.getDeclaredConstructor(SimpleStepBuilder.class);
		constructor.setAccessible(true);
		SimpleStepBuilder copySimpleStepBuilder = constructor.newInstance(simpleStepBuilder);

		int copyChunkSize = accessPrivateField(copySimpleStepBuilder, "chunkSize");
		ItemReader copyItemReader = accessPrivateField(copySimpleStepBuilder, "reader");
		ItemProcessor copyItemProcessor = accessPrivateField(copySimpleStepBuilder, "processor");
		ItemWriter copyItemWriter = accessPrivateField(copySimpleStepBuilder, "writer");

		assertEquals(chunkSize, copyChunkSize);
		assertEquals(itemReader, copyItemReader);
		assertEquals(itemProcessor, copyItemProcessor);
		assertEquals(itemWriter, copyItemWriter);
	}

   // taskExecutor를 먼저하고 faultTolerant를 이후에 했을 때 값 비교
	@Test
	void faultTolerantMethodTest() throws ReflectiveOperationException {
		simpleStepBuilder.taskExecutor(taskExecutor); // The task executor is set before faultTolerant()
		simpleStepBuilder.faultTolerant();

		int afterChunkSize = accessPrivateField(simpleStepBuilder, "chunkSize");
		ItemReader afterItemReader = accessPrivateField(simpleStepBuilder, "reader");
		ItemProcessor afterItemProcessor = accessPrivateField(simpleStepBuilder, "processor");
		ItemWriter afterItemWriter = accessPrivateField(simpleStepBuilder, "writer");
		TaskExecutor afterTaskExecutor = accessSuperClassPrivateField(simpleStepBuilder, "taskExecutor");

		assertEquals(chunkSize, afterChunkSize);
		assertEquals(itemReader, afterItemReader);
		assertEquals(itemProcessor, afterItemProcessor);
		assertEquals(itemWriter, afterItemWriter);
		assertEquals(taskExecutor, afterTaskExecutor);
	}
}
스터디 회고

실제로 오픈소스에 기여한다는 건 생각만 해보고 시도한 적이 없었는데 이렇게 간단한 오픈소스를 통해 이번을 시작으로 다른 오픈소스에도 참여해보고 싶다는 생각이 들었습니다. 그리고 오픈소스 자체에 대한 심리적 부담감?같은 것이 있었는데 실제로 프로젝트에 참여하듯이 기여를 할 수 있다는 점이 좋았습니다. 스터디가 아니였으면, 다른 사람들과 함께 하지 않았으면 시도가 더 늦어졌을 것 같은데 좋은 기회에 참여할 수 있어서 감사했습니다.

결론으로 spring-batch는 기여할 것이 많은 노다지였다!

스프링 배치 컨트리뷰터!!
그로부터 거의 5개월이 지난 시점의 메인테이너의 피드백을 받게 됐는데 도움을 주신 인제님과 기여부분 수정제안을 해주신 태익님의 도움으로 pr을 다시 올려서 close 시킬 수 있었습니다.
사실 엄청난 코드작성을 한 부분은 아니지만 중간 데이터 소실이라는 문제가 발생하는 부분이기 때문에 core단 버그를 수정했다는 점에서 굉장히 뿌듯했고, 이런 식으로 접근할 수 있구나 이렇게 소통해서 문제를 해결하는구나라는 것을 알게 된 순간이었습니다.

아쉬운 부분이 있다면, 아무리 생각해도 당시에 이슈에서 제안한 한정적 방법 밖에 떠오르지 않아서 테스트를 일단 이슈대로 작성했었습니다. 중간에 test 부분 작성에 대한 방향성을 더 유지보수가 쉽게 하는 방안으로 메인테이너 요청을 만족스럽게 수정하지 못한 부분이었습니다. 기존에는 모든 값들이 잘 복사됐는지 확인하는 방식이었는데, 메인테이너가 아래와 같이 stepOperations 비교를 하는 방식으로 변경해서 테스트를 변경해주신걸 볼 수 있었습니다. (spring-batch에는 private 처리된 것이 많아서 ReflectionTestUtils로 값을 그냥 가지고 와서 비교하는 방식으로 테스트 코드가 실제로 다른 테스트에도 작성된 부분이 많습니다.)

Object stepOperations = ReflectionTestUtils.getField(step, "stepOperations");
assertInstanceOf(TaskExecutorRepeatTemplate.class, stepOperations);



🚴🏽 내 Github action unit-test는 왜 안 통과했을까?

문제발생
로컬에서는 문제가 없었는데 Github action pr test를 통과하지 못하는 상황이 발생했습니다. 당시 pr에 많은 기능의 commit이 있어서 하나씩 살펴봤지만 test가 성공하지 못하는 이유를 알지 못해서 팀원들과 같이 논의하면서 해결하게 되었습니다.

20231003_150719.png


문제원인 파악하기
properties가 잘 매핑되지 않았기 때문에 나는 에러였고 .env, application.yml 등 mapping 파일들을 살펴보게 되었습니다.

DevMingleApplicationTest > contextLoads() FAILED
    java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:143
        Caused by: org.springframework.beans.factory.BeanCreationException at AutowiredAnnotationBeanPostProcessor.java:489
            Caused by: java.lang.IllegalArgumentException at PropertyPlaceholderHelper.java:18***
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2***23-1***-***3T***4:55:***5.188Z  INFO 192*** --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'


해결방안
팀 회의 때 같이 살펴본 결과 test에 있는 application.ymlinclude처리로 다른 mapping 파일을 추가하지 않아 발생한 문제였습니다.
결과적으로 새로 이번에 commit하면서 생성된 valid mapping을 추가하므로서 해결할 수 있었습니다.

spring:
  profiles:
    include: auth, valid


추가 고찰하기
Github action에 test를 통과하는지 살펴보기 위해서는 test 코드 빌드여부를 확인해야 하고, 그 과정에서 속성값 매핑이 제대로 되는지 확인해야 합니다. 알고보니 사실 간단한 솔루션이었지만 이 글을 남기는 이유는 간단해도 기억하지 못하거나 경험하지 않았을 때, 간과할 수 있는 부분이라 생각이 들었기 때문입니다. 그리고 저 혼자 보이지 않던 부분을 팀원들과 해결할 수 있어서 뿌듯했습니다 :)



🚴🏽 @Password 사용자 정의 어노테이션 생성과 yml 속성 mapping error 해결하기

문제발생
유효성 검증을 위해 어노테이션을 생성하고 에러메세지와 pattern을 mapping하는 과정에서 yml 값이 제대로 연결되지 않는 문제가 발생했습니다.

10:35:32.622 [restartedMain] ERROR org.springframework.boot.SpringApplication -- Application run failed java.lang.NullPointerException: Cannot invoke "Object.toString()" because "key" is null at org.springframework.beans.factory.config.YamlProcessor.lambda$asMap$0(YamlProcessor.java:248) at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:721) at org.springframework.beans.factory.config.YamlProcessor.asMap(YamlProcessor.java:239) at org.springframework.beans.factory.config.YamlProcessor.lambda$asMap$0(YamlProcessor.java:241) at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:721) at org.springframework.beans.factory.config.YamlProcessor.asMap(YamlProcessor.java:239) at org.springframework.beans.factory.config.YamlProcessor.lambda$asMap$0(YamlProcessor.java:241) at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:721)

application-valid.yml

valid:  
  password:  
    pattern: ^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@#$%^&+=!])(?=\\S+$).{8,}$
    message:
      null: 비밀번호는 공백일 수 없습니다.  
      err: 비밀번호는 최소 8자 이상, 영문 대소문자, 숫자, 특수 문자 포함해야 합니다.

PasswordValidator.java

package com.example.dm.dto.users;  
  
import jakarta.annotation.PostConstruct;  
import jakarta.validation.ConstraintValidator;  
import jakarta.validation.ConstraintValidatorContext;  
import org.slf4j.Logger;  
import org.slf4j.LoggerFactory;  
import org.springframework.beans.factory.annotation.Value;  
import org.springframework.stereotype.Component;  
  
@Component  
class PasswordValidator implements ConstraintValidator<Password, String> {  
  @Value("${valid.password.pattern}")  
  private String PASSWORD_PATTERN;  
  @Value("${valid.password.message.null}")  
  private String PASSWORD_NULL_MESSAGE;  
  @Value("${valid.password.message.err}")  
  private String PASSWORD_ERR_MESSAGE;
    
  @Override  
  public boolean isValid(String value, ConstraintValidatorContext context) {  
      if(value.isBlank()){  
          context.disableDefaultConstraintViolation();  
          context.buildConstraintViolationWithTemplate(PASSWORD_NULL_MESSAGE)  
          .addConstraintViolation();  
          return false;  
      }else if(!value.matches(PASSWORD_PATTERN)){  
          context.disableDefaultConstraintViolation();  
          context.buildConstraintViolationWithTemplate(PASSWORD_ERR_MESSAGE)  
          .addConstraintViolation();  
          return false;  
      }  
      return true;  
      }  
  }
}


문제원인 파악하기

  1. .yml 값 등록이 바르게 들어오는 것을 확인했습니다.
  2. PasswordValidator 클래스는 @Component 설정된 상태입니다.
  3. 에러 디버깅 YamlProcessor 찍어보기


해결방안
YamlProcessor에서 찍어봤을 때, key 값이 null이고 value는 제대로 들어오는 것을 확인할 수 있었습니다. value 값의 문자열 처리 이후에도 문제가 발생해서 디버깅 해본 결과, yml 파일에 key 값으로 설정할 수 없는 예약어 null이 사용된 것을 알 수 있었습니다. yml에서 사용할 수 없는 예약어로는 y,yes,n,no,true,false,null,~ 등이 있습니다.


추가 고찰하기
추가적으로 @Value 값이 제대로 들어오는지 확인하기 위해서 @PostConstruct로 값을 찍어볼 수 있습니다.
하지만 위에서 문제는 @PostConstruct는 확인이 불가능했는데, 스프링 빈이 생성되고 모든 의존성 주입 완료 후 실행되기 때문입니다. 위에 에러는 설정파일이나 초기화 단계의 문제이기 때문에 빈의 메서드 실행 전이라 애플리케이션 실패로 미리 종료되어 버렸기 때문입니다. 이 경우는 단순하게 yml 파일 설정을 확인할 필요가 있음을 알게 되었습니다.

private static final Logger logger = LoggerFactory.getLogger(PasswordValidator.class);

@PostConstruct  
public void postConstruct() {  
	logger.info("PASSWORD_PATTERN: {}", PASSWORD_PATTERN);  
	logger.info("PASSWORD_NULL_MESSAGE: {}", PASSWORD_NULL_MESSAGE);  
	logger.info("PASSWORD_ERR_MESSAGE: {}", PASSWORD_ERR_MESSAGE);  
}  



🚴🏽 암호화와 authentication 이슈

Spring boot 3.x, Spring security 6, Java 17


이슈사항
Spring security 설정으로 defaultSuccessUrl로 잘 이동하는 것처럼 보였으나 실제로 SecurityContextHolder에 제대로 authentication이 등록되지 않아서 뷰단에서 thymeleaf sec:authorize 설정이 제대로 되지 않는 문제가 발생했습니다. 해결방법은 간단했지만 당시에 별다른 에러 문구가 없어 고민한 부분들을 정리하는 방식으로 작성했습니다.


시도한 방법들
먼저, 의존성 문제가 아닐까 고민했습니다. Spring boot 3.x와 일부 의존성 충돌 가능성에 대한 글을 읽은 기억이 있어서 찾아봤는데 공식 레퍼런스에서도 thymeleaf-extras-springsecurity6와 이슈가 있다는 등의 관련된 별다른 접점은 찾지 못했습니다.
SecurityContextHolder에서 authentication을 제대로 담지 못하고 있는 것이 아닐까 생각해서 debug를 하면서 코드를 찾아봤습니다. 그리고 명시적으로 SecurityContextHolderauthentication을 넣어도 값이 anomyous authentication으로 찍히는 것을 보았습니다. 이 부분은 요청을 수행 후 자동으로 filterChainProxy에서 doFilter에서 this.securityContextHolderStrategy.clearContext();처리해서의 문제가 아니였다는 것을 알게 되었습니다.

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
    if (!clearContext) {
        doFilterInternal(request, response, chain);
        return;
    }
    try {
        request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
        doFilterInternal(request, response, chain);
    }
    catch (Exception ex) {
        Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
        Throwable requestRejectedException = this.throwableAnalyzer
                .getFirstThrowableOfType(RequestRejectedException.class, causeChain);
        if (!(requestRejectedException instanceof RequestRejectedException)) {
            throw ex;
        }
        this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response,
                (RequestRejectedException) requestRejectedException);
    }
    finally {
        this.securityContextHolderStrategy.clearContext();
        request.removeAttribute(FILTER_APPLIED);
    }
}


해결방안
초심으로 돌아가서 중요하게 다시 생각해보게 된 부분이 Spring security의 동작원리였습니다. filter 중심의 Spring security 프로세스에서 인증논리 구조를 보면 UserDetailService로 user 여부를 확인하고, 이 때 인증은 AuthenticationProvider라는 인증 제공자를 통해서 진행됩니다. 이를 위해 SecurityConfig에 빈으로 주입해서 authentication이 바르게 적용된 것을 확인할 수 있었습니다.

@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(){
    DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
    daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
    daoAuthenticationProvider.setUserDetailsService(authService);
    return daoAuthenticationProvider;
}


추가 고찰
이전 프로젝트에서는 Spring security에서 별도의 인증제공자를 빈으로 주입하지 않아도 작동했었는데, 오래된 버전이라서 WebSecurityConfigurerAdapter를 통한 override 방식의 구현이었다는 점이 가장 큰 차이였습니다. version 5.4 이후부터는 component-based 방식으로 접근하기 때문에 달라진 부분이 있습니다. filterChain 방식의 구현과 필요한 논리부를 빈 또는 컴포넌트화해서 사용합니다.
그럼에도 불구하고 이번 이슈는 단순히 WebSecurityConfigurerAdapter가 deprecated 되었기 때문이라고만은 볼 수 없었습니다.

20230706_194429.png

여기서 보면, AuthenticationManager로서 ProviderManagerDaoAuthenticationProvider를 default 값으로 제공하고 있는 것을 알 수 있습니다. 하지만 위 해결방안에서 @Bean 주입 이후 DaoAuthenticationProvider의 authentication 과정이 제대로 작동하는 것을 볼 수 있었습니다.


결과
authenication 과정이 제대로 작동하는 것을 볼 수 있었고, 뷰단에서 securityContext에 따른 sec:authorize가 잘 작동해 로그인한 경우에는 로그아웃 버튼이 보이게 동작하는 것을 확인할 수 있었습니다.



Spring Triangle

IoC, AOP, PSA


의존성 주입과 제어역전, IoC(Inversion of Control)

// 사용할 의존성을 외부에서 만들어주는 개념이 바로 IoC이다.
class OwnerController {
   private OwnerRepository repository = new OwnerRepository();
}

의존성을 주입해주는 일을 외부에서 해주는 것을 IoC 라고 합니다. IoC 컨테이너는 Application ContextBeanFactory 인터페이스를 이용해 빈을 만들고 빈들 사이의 의존성을 제공해줍니다. 빈의 생성은 컨테이너가 만들어질 때 component scanning 과정으로 @Component 이하 값들을 빈으로 등록해줍니다.
여기서 빈(Bean)이란 스프링 IoC 컨테이너가 관리하는 객체를 말합니다. applicationContext.getBean() 메서드를 사용하면 스프링에 등록된 모든 빈의 이름들을 찾을 수 있습니다.
그럼 @Autowired, @Inject 차이점은 무엇일까?
등록된 빈들을 사용하기 위해 사용되며, 서로 대체가 가능합니다. 이 어노테이션들을 사용할 때는 생성자에 사용하는 것이 권장됩니다. @Autowired는 Spring에서 지원하는 프레임워크에 종속적인 어노테이션이고, @Inject는 Java에서 지원하는 것이며, nullable하지 않기 때문에 반드시 주입받을 빈이 필요합니다.


관점 지향 프로그래밍, AOP(Aspect Oriented Programming)
관심사를 중점으로 중복된 코드를 모아서 횡단 관심사의 처리를 하는 프로그래밍 기법을 말합니다. 예를 들어, 메서드별 성능을 재기 위해서 사용하는 org.springframework.util.StopWatch 스프링 프레임워크에서 제공하는 유틸을 사용할 수 있는데, 이런 공통기능들을 모든 메서드 실행 전 수행하고 시간을 측정하는 방식으로 AOP를 구성할 수 있습니다.
AOP를 구현하는 방법은 AspectJ를 이용한 컴파일을 이용하거나 바이트 코드를 조작하거나 프록시 패턴을 이용하는 방법들이 있습니다. 그 중에서도 스프링은 프록시 패턴을 이용해서 AOP를 지원하고 있습니다. 이는 실제 디자인패턴에서 말하는 프록시 패턴과 조금 상이한데, 리플렉션과 바이트코드 조작을 이용해 실제 타겟 기능을 대리 수행하며, 기능을 확장하거나 추가할 수 있는 다이나믹 프록시 객체를 의미합니다.

// 스프링 AOP 처리 예제 코드
@Component
@Aspect
public class LogAspect {
  Logger logger = LoggerFactory.getLogger(LogAspect.class);

  @Around("@annotation(LogExecutionTime)")
  public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable{
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    Object proceed = joinPoint.proceed();

    stopWatch.stop();
    logger.info(stopWatch.prettyPrint());

    return proceed;
  }
}
🔍 AspectJ를 이용한 컴파일과 바이트 코드 조작 방식으로 구현하는 AOP 컴파일 방식
A.java - (AOP) - A.class(AspectJ)
바이트코드 조작
A.java - A.class - (AOP) - 메모리(AspectJ)


추상화 서비스, PSA(Portable Service Abstraction)
잘 만든 인터페이스. 환경 변화와 관계없이 일관된 방식의 기술로의 접근 환경을 제공하는 추상화 구조를 말합니다. Spring에서는 Spring Web MVC, Spring Transaction, Spring Cache 등 다양한 PSA를 제공합니다. Spring MVC를 사용하면 @Controller, @RequestMapping 등의 기능을 기존 코드 변동없이 간단하게 코드 변경이 가능한 것을 알 수 있습니다. 이렇게 일관성 있는 서비스 추상화(Service Abstraction)을 만들 수 있습니다.

  • 프로젝트의 spring-boot-starter-web 의존성 대신 spring-boot-starter-webflux 의존성을 받도록 바꿔주기만 하면 Tomcat이 아닌 netty 기반으로 실행하게 할 수 있습니다.

예제로 배우는 스프링 입문
Inversion of Control Containers and the Dependency Injection pattern Proxy



엔티티 설계시 주의점

여기저기 @Setter 금지
엔티티에서는 가급적 Setter를 사용하지 말고 필요한 부분에는 따로 메서드를 생성해주는 것이 좋습니다. 실제 서비스를 운영할 때는 값을 set할 수 있는 곳이 너무 많으면 유지보수에 어려움을 겪을 수 있기 때문입니다.

지연전략을 사용하기
모든 연관관계는 지연로딩으로 설정해서 한 번 조회에 연관된 테이블들이 모두 조회돼서 메모리에 로드될 수 있기 때문입니다. 이 경우 성능의 저하, 불필요한 데이터 쿼리까지 발생하게 됩니다. 따라서 @ManyToOne 또는 @OneToOne에서는 즉시로딩이 기본값이므로 반드시 fetch 전략을 지연로딩으로 설정하는 것이 좋습니다.
만약에 즉시로딩일 필요한 로직이 생기는 경우 fetch 조인을 이용하거나 객체 그래프 탐색을 사용해서 문제를 해결할 수 있습니다. (객체지향 쿼리 언어 참조)

컬렉션에서 필드 초기화
entity 설정시 연관관계 매핑으로 NPE 방지 차원에서 아래 코드처럼 초기화한 속성들이 있습니다. NPE 외에도 하이버네이트에서 제공하는 내장 컬렉션으로 관리가 들어가기 때문에 임의로 변경하지 않도록 주의해야 합니다.

@OneToMany(mappedBy = "member")
private List<Orders> orders = new ArrayList<>();

하이버네이트에서 컬렉션을 어떻게 관리를 한다는 말인가
강의만 듣고는 이해가 가지 않았는데, 저와 같은 고민을 한 글을 발견했습니다. 하이버네이트가 엔티티를 영속화할 때 내부에 컬렉션이 있으면 하이버네이트가 특별하게 조작한 컬렉션으로 변경한다고 합니다. 그래서 임의 수정시 하이버네이트가 인식을 하지 못해 제대로 동작할 수 없는 문제가 발생한다고 합니다.
그럼 하이버네이트는 컬렉션을 어떻게 관리하는 걸까요?
하이버네이트는 컬렉션을 효율적으로 관리하기 위해 엔티티를 영속 상태로 만들때 원본 컬렉션을 감싸고 있는 내장 컬렉션을 생성해서 내장 컬렉션을 사용하도록 참조를 변경합니다. PersistentBag, PersistentList, PersistentSet 등의 컬렉션 래퍼를 사용합니다. 이러한 컬렉션들은 일반적인 자바 컬렉션과 달리 데이터베이스와의 연동을 위한 추가적인 기능을 제공합니다. 예를 들어, PersistentBag은 중복된 값을 허용하면서도 순서를 유지할 수 있도록 구현되어 있습니다. 또한, PersistentBag은 데이터베이스와의 동기화를 위해 데이터베이스에서 필요한 데이터만 로딩하여 메모리를 효율적으로 사용할 수 있도록 최적화되어 있습니다.

변경감지
변경감지를 이용하면, find()로 entity를 가져오는 과정에서 영속성 컨텍스트에서 관리대상이 되기 때문에 별도의 save() 로직이 없어도 트랜잭션이 끝나는 시점에 Dirty Checking을 진행합니다.

private final EntityManager em;
Member m = em.find(Member.class, 1);
m.setName("John");
m.setAge(30);

merge()로 병합처리를 하는 경우에는 기존 값에서 변경된 값이 있을 때, 구분해서 들어가는 것이 아니라 모두 병합처리되어 null로 받는 값이 있는 경우 null 처리되기 때문에 사용에 유의하는 것이 좋다.

객체지향 설계를 위한 엔티티 연관 메서드 작성
엔티티와 연관된 메서드는 관리를 위해 엔티티와 같이 작성해둡니다. 예를 들어, 엔티티의 생성, 삭제, 엔티티의 속성만을 가지고 계산하는 메서드 등이 해당됩니다. 이는 객체지향 설계 관점에서도 필요하고, 불필요한 엔티티 조작을 방지하 수 있습니다. 예를 들어, Member라는 객체 자체를 외부에서 막 수정할 수 없도록 생성자를 protect처리하고 생성 메서드를 직접 작성하는 것들이 해당됩니다.

protected Stock(){}

public void addStock(int quantity){
    this.stockQuantity += quantity;
}

public void removeStock(int quantity){
    int restStock = this.stockQuantity -= quantity;
    if(restStock<0){
        throw new NotEnoughStockException("need more stock");
    }
    this.stockQuantity = restStock;
}

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화
필드에 있는 컬렉션을 초기화 시키는 이유가 뭔가요?
PersistentBag



@Transactional

트랜잭션 관리를 위해 Spring Framework 2.0 이상의 버전에서 지원되는 어노테이션입니다. 메서드 레벨 또는 클래스 레벨에서 사용할 수 있으며, 해당 메서드 또는 클래스의 모든 public 메서드에 트랜잭션을 적용합니다.


@Transactional의 작동방식
Spring boot 애플리케이션을 실행하는 시점에 proxy 를 생성에 필요한 TransactionAutoConfiguration 등 클래스들이 자동으로 활성화됩니다. 따라서 클라이언트 request를 보내면 @Transactional이 적용된 메서드에 대한 트랜잭션 처리가 가능해집니다.

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(PlatformTransactionManager.class)
@AutoConfigureAfter({ JtaAutoConfiguration.class, HibernateJpaAutoConfiguration.class,
  DataSourceTransactionManagerAutoConfiguration.class, Neo4jDataAutoConfiguration.class })
@EnableConfigurationProperties(TransactionProperties.class)
public class TransactionAutoConfiguration {

   @Configuration(proxyBeanMethods = false)
   @ConditionalOnBean(TransactionManager.class)
   @ConditionalOnMissingBean(AbstractTransactionManagementConfiguration.class)
   public static class EnableTransactionManagementConfiguration {

      @Configuration(proxyBeanMethods = false)
      @EnableTransactionManagement(proxyTargetClass = false)
      @ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "false")
      public static class JdkDynamicAutoProxyConfiguration {}

      @Configuration(proxyBeanMethods = false)
      @EnableTransactionManagement(proxyTargetClass = true)
      @ConditionalOnProperty(prefix = "spring.aop", name = "proxy-target-class", havingValue = "true",
        matchIfMissing = true)
      public static class CglibAutoProxyConfiguration {}

   }
}

위 코드에서 알 수 있듯이 TransactionAutoConfiguration으로 ProxyConfiguration 구성을 활성화하기 위해서는 @ConditionalOnClass에 따른 선행조건으로 PlatformTransactionManager()가 있어야합니다.
일반적으로 spring-jdbcspring-data-jpa 등의 의존성을 포함시키면DataSourceTransactionManage나 JpaTransactionManager 등과 같은 클래스가 PlatformTransactionManager 구현체로 사용됩니다. 이렇게 구성된 TransactionManager는 connection 객체를 생성하고, 트랜잭션의 commit 또는 rollback을 가능하게 합니다.
이렇게 TransactionManager가 선정되면 @EnableTransactionManagement가 선언된 ProxyConfiguration 클래스를 통해 트랜잭션 관리 기능을 활성화합니다. ProxyConfiguration 클래스로 두 가지를 제시하고 있는데 바로 JDK Dynamic Proxy와 CGLIB 방식입니다. JDK Dynamic Proxy는 인터페이스를 기반으로 프록시 객체를 생성하며, CGLIB은 바이트 코드 조작을 통해 원본 객체의 서브클래스를 생성한다는 차이점이 있습니다.
Spring은 AOP 프레임워크를 이용하여 프록시를 생성하고, 특정 메소드 호출을 가로채서 추가 동작을 수행합니다.


@Transactional의 속성

  • propagation(전파방식)
    • REQUIRED, REQUIRES_NEW, NESTED, SUPPORTS, MANDATORY, NOT_SUPPORTED, NEVER
  • isolation(격리수준)
    • READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE
  • readonly(읽기 전용 여부)
  • timeout


사용할 때 이것만큼은 고려하자!

1. 트랜잭션을 적용하려는 메서드는 반드시 public으로 선언되어야 합니다.
프록시 객체로 외부에서 접근 가능한 인터페이스를 제공해야 하기 때문입니다. 만약 해당 메서드가 private이나 protected로 선언되어 있다면, 프록시 객체가 생성될 때 해당 메서드에 접근할 수 없으므로 @Transactional 어노테이션을 사용한 트랜잭션 관리가 불가능합니다.

2. 다른 AOP 기능과의 충돌을 고려해야 합니다. 예를 들어 @Secured를 통해 권한이 있는 사용자 여부를 확인하는데 @Transactional이 먼저 수행된다면 권한 검사가 무의미해집니다.
이를 방지하기 위해서는 @Order를 이용해 적용 순서를 정하거나 적용범위를 조정해서 해결할 수 있습니다. 이외에도 @Transactional proxyTargetClass 속성을 true로 설정하여 강제로 클래스를 대상으로 프록시를 사용하도록 지정할 수도 있습니다. 하지만 성능저하가 발생할 수 있다는 단점이 있습니다.

3. Service 계층에서 사용하자.
Service 계층에서 @Transactional을 사용하므로써 여러 데이터베이스 작업들을 원자적으로 처리할 수 있습니다. 그리고 Spring에서 단일 책임원칙에 따라 Database 계층에서 비즈니스 로직과 관련 없는 역할을 담당해 코드의 유지보수와 확장성이 높이기 위함입니다.
그럼 JPA에서는 왜 SimpleJpaRepository에 @Transactional을 사용할까요?
Spring Data JPA에서 제공하는 SimpleJpaRepository는 일반적인 레포지토리 구현체와는 조금 다릅니다. SimpleJpaRepository는 JPA에서 제공하는 기능들을 활용하여, 일부 비즈니스 로직을 처리합니다. 예를 들면, save() 메소드를 사용하여 엔티티를 저장할 때 새로운 것인지 아니면 이미 저장된 것인지를 판단하여 동작을 수행합니다. 그리고 Mixed Transaction이 발생해도 일반적으로 Service 계층의 @Transactional이 우선순위를 갖습니다.

4. Exception을 고려하자.
트랜잭션은 RuntimeException과 Error에서는 롤백되지만, Checked exceptions에서는 롤백되지 않습니다. Checked exceptions는 예측가능한 에러를 말하는데, 아래와 같이 @Transactional에 rollbackFor 속성을 두어 롤백처리가 되도록 할 수도 있습니다.

@Transactional(rollbackFor={Exception.class})

5. 트랜잭션 Checked exception이 발생했을 때 Java와 Kotlin
Java에서는 롤백되지 않고 Checked exception을 try-catch, throw 방식으로 처리하고 있지만 Kotlin에서는 트랜잭션이 롤백되는 것을 볼 수 있습니다. 하지만 Java에서처럼 롤백이 되지 않도록 @Throws를 사용해서 처리할 수도 있습니다. 따라서 개발자는 어떤 언어를 스프링과 사용하는지 그리고 Custom Exception 설정을 어떻게 하는지에 따라 다양한 Exception 처리방법을 고려해야 합니다.

6. 트랜잭션과 DeadLock
실제로 DeadLock 이슈가 발생해서 리펙토링을 하면서 가장 유심히 본 부분 중 하나입니다. Service 계층에서 설정한 메서드들이 데이터베이스에서 어떤 방향으로 리소스를 점유하는지는 매우 중요합니다.


@Transactional 바르게 알고 사용하기 Spring Transaction Management: @Transactional In-Depth



Lombok

@ToString
클래스의 toString 메서드를 자동으로 생성해주고 싶을 때 사용됩니다. @ToString(of={"ID", "NAME"}, includeFieldNames = false)와 같이 컬럼을 지정하는 방식으로 사용되기도 하고, 각 컬럼 위에 @ToString.Include 또는 @ToString.Exclude를 사용해서 컬럼별로 지정이 가능합니다.

@Entity
@ToString(of={"ID, NAME"})
public class Person {
    long id;
    String name;
    int age;
}

// 다른 방식의 활용
@Entity
@ToString
public class Person {
  @ToString.Include  
  long id;
  @ToString.Include
  String name;
  @ToString.Exclude
  int age;
}

Lombok 기본 사용법 익히기

results matching ""

    No results matching ""