Lucian Log
Blog
Computer Science
Algorithm
DB
Network
OS
General
AI
Blockchain
Concurrency
ETC
Git
Infrastructure
AWS
Docker
Java-Ecosystem
JPA
Java
Spring
JavaScript-Ecosystem
JavaScript
Next.js
React
TypeScript
Python-Ecosystem
Django
FastAPI
Python
SQLAlchemy
Software Engineering
Architecture
Culture
Test
Home
Contact
Copyright © 2024 |
Yankos
Home
>
Java-Ecosystem
> Spring
Now Loading ...
Spring
스프링 부트 핵심 원리와 활용
스프링 부트의 필요성 스프링은 2013년까지 크게 성장해왔지만, 프로젝트 시작 시 필요한 설정이 점점 늘어나 어려워짐 스프링 부트 (2014~) 스프링을 편리하게 사용할 수 있도록 지원하는 도구 스프링 부트가 프로젝트 시작을 위한 복잡한 설정 과정을 해결 -> 개발 시간 단축 핵심 기능 WAS Tomcat 같은 웹서버를 내장 (별도 웹 서버 설치 필요 X) 라이브러리 관리 손쉬운 스타터 종속성 제공, 스프링과 외부 라이브러리의 버전 호환을 자동 관리 자동 구성 프로젝트 시작에 필요한 스프링과 외부 라이브러리의 빈을 자동 등록 외부 설정 공통화 프로덕션 준비 모니터링을 위한 메트릭, 상태 확인 기능 제공 웹 서버와 서블릿 컨테이너 JAR & WAR JAR (Java Archive) 여러 클래스와 리소스를 묶어서 만든 압축 파일 JVM 위에서 직접 실행되거나 다른 곳에서 사용하는 라이브러리로 제공 가능 직접 실행: 직접 자신의 main 메서드로 실행 가능 (e.g. java -jar abc.jar) MANIFEST.MF 파일에 실행할 메인 메서드가 있는 클래스를 지정해야 함 라이브러리: 다른 곳에서 import 될 수 있음 WAR (Web Application Archive) WAS에 배포할 때 사용하는 파일 WAS 위에서 실행됨 (JVM 위에서 WAS가 실행되고 WAS 위에서 WAR가 실행됨) WAS 위에서 실행되기 위해 WAR의 복잡한 구조를 지켜야함 WEB-INF classes : 실행 클래스 모음 lib : 라이브러리 모음 web.xml : 웹 서버 배치 설정 파일(생략 가능) index.html : 정적 리소스 자바 웹 애플리케이션 개발 방식 외장 서버 방식 (전통적인 방식) WAS 기반 위에 애플리케이션 코드를 빌드한 war 파일을 심어 배포하는 방식 단점 WAS를 별도로 설치해야 하고, 버전 변경시에도 재설치해야 함 개발 환경 설정과 배포 과정이 복잡 방법 먼저 서버에 WAS(e.g. 톰캣)를 설치 서블릿 스펙에 맞춰 코드를 작성하고 WAR 형식으로 빌드 직접 초기화 방법 서블릿 컨테이너 초기화 및 애플리케이션 초기화 코드 작성 ServletContainerInitializer, @HandlesTypes… 스프링 사용 시 애플리케이션 초기화 코드에 관련 코드 작성 스프링 컨테이너 생성 및 빈 등록 디스패처 서블릿 생성 후 스프링 컨테이너와 연결 디스패처 서블릿을 서블릿 컨테이너에 등록 … … 스프링 MVC 지원 방법 (서블릿 컨테이너 초기화는 자동으로 해줌) 애플리케이션 초기화만 작성 (WebApplicationInitializer 상속) 스프링 컨테이너 생성 및 디스패처 서블릿 연결 등 빌드한 war 파일을 WAS의 특정 위치에 전달해 배포 내장 서버 방식 (최근 방식) 애플리케이션 코드 안에 WAS가 라이브러리로서 내장 스프링 부트가 내장 톰캣을 포함 (tomcat-embed-core) 톰캣 생성, 서블릿 컨테이너 초기화 및 애플리케이션 초기화를 모두 자동화 e.g. 스프링 컨테이너 생성, 디스패처 서블릿 등록… public class MySpringApplication { public static void run(Class<?> configClass, String[] args) { System.out.println("MySpringBootApplication.run args=" + List.of(args)); // 톰캣 설정 Tomcat tomcat = new Tomcat(); Connector connector = new Connector(); connector.setPort(8080); tomcat.setConnector(connector); // 스프링 컨테이너 생성 AnnotationConfigWebApplicationContext appContext = new AnnotationConfigWebApplicationContext(); appContext.register(configClass); // 스프링 MVC 디스패처 서블릿 생성 및 스프링 컨테이너 연결 DispatcherServlet dispatcher = new DispatcherServlet(appContext); // 디스패처 서블릿 등록 Context context = tomcat.addContext("", "/"); tomcat.addServlet("", "dispatcher", dispatcher); context.addServletMappingDecoded("/", "dispatcher"); try { tomcat.start(); } catch (Exception e) { throw new RuntimeException(e); } } } 방법 개발자는 코드를 작성하고 JAR로 빌드한 후 원하는 위치에서 실행 개발자가 main() 메서드만 실행하면 WAS는 함께 실행됨 핵심: 내장 서버 방식과 외장 서버 방식과의 차이 초기화 코드는 거의 똑같음 (서블릿 컨테이너 초기화, 애플리케이션 초기화) 차이는 시작점 개발자가 main() 메서드를 직접 실행하는가(JAR) 서블릿 컨테이너가 제공하는 초기화 메서드를 통해 실행하는가(WAR) Jar & FatJar & 실행 가능 Jar (feat. 내장 톰캣 라이브러리 포함을 위한 서사) Jar가 요구하는 자바 표준 (Gradle이 자동화) META-INF/MANIFEST.MF 파일에 실행할 main() 메서드의 클래스 지정 Jar 스펙의 한계 Jar 파일은 Jar 파일을 포함할 수 없음 -> 내부 라이브러리 역할의 Jar 파일 포함 불가 Fat Jar 라이브러리 Jar의 압축을 풀고 class들을 뽑아 새로 만드는 Jar에 포함하는 방식 용량이 더 큼 장점 하나의 Jar 파일에 여러 라이브러리를 내장 가능 (내장 톰캣) 단점 어떤 라이브러리가 포함되어 있는지 확인이 어려움 (모두 class로 풀려있음) 클래스나 리소스 파일명 중복 시 해결 불가 실행 가능한 Jar (Executable Jar) Jar 내부에 Jar를 포함할 수 있는 특별한 구조의 Jar 스프링 부트가 빌드하면 결과로 나오는 Jar 스프링 부트가 새로 정의 (자바 표준 X) Fat Jar의 단점을 모두 해결 내부 구조 META-INF MANIFEST.MF (자바 표준) org/springframework/boot/loader (스프링 부트 로더: 실행 가능 Jar를 실제 구동 시키는 클래스들이 포함) JarLauncher.class 스프링 부트 main() 실행 클래스 BOOT-INF classes : 우리가 개발한 class 파일과 리소스 파일 lib : 외부 라이브러리 classpath.idx : 외부 라이브러리 모음 layers.idx : 스프링 부트 구조 정보 실행 과정 java -jar xxx.jar 를 실행 META-INF/MANIFEST.MF 파일 탐색 스프링 부트는 빌드 시 JarLauncher를 넣어주고 Main-Class에 지정 Main-Class 를 읽어서 JarLauncher의 main() 메서드를 실행 JarLauncher가 몇몇 기능을 처리 Jar 내부 Jar를 읽는 기능을 처리 BOOT-INF/lib/ 인식 특별한 구조에 맞게 클래스 정보 읽어들임 BOOT-INF/classes/ 인식 JarLauncher가 MANIFEST.MF의 Start-Class에 지정된 main() 호출 실제 프로젝트의 main() 호출 결국 스프링 부트란? 앞의 프로젝트 시작을 위한 복잡한 설정 과정을 라이브러리로 만든 것 주요 코드 @SpringBootApplication public class BootApplication { public static void main(String[] args) { SpringApplication.run(BootApplication.class, args); } } main() 메서드에서 코드 한 줄로 시작 SpringApplication.run(BootApplication.class, args); BootApplication 클래스를 설정 정보로 사용하겠다는 의미로 전달 **SpringApplication.run() 복잡한 설정 과정 처리 핵심: WAS(내장 톰켓) 생성 + 스프링 컨테이너 생성 톰캣 설정, 스프링 컨테이너 생성, 디스패처 서블릿 생성 및 스프링 컨테이너와 연결, 서블릿 컨테이너에 디스패처 서블릿 등록… @SpringBootApplication 컴포넌트 스캔 시작점 지정 (내부에 @ComponentScan 기능 붙어 있음) main() 메서드가 있는 시작점 클래스에 추가 기본 대상: 애노테이션이 붙은 클래스의 현재 패키지부터 그 하위 패키지 스프링 부트가 제공하는 라이브러리 관리 기능 외부 라이브러리 버전 관리 개발자는 원하는 라이브러리만 고르고 버전은 생략 스프링 부트가 부트 버전에 맞춘 최적화된 라이브러리 버전을 선택해줌 (호환성 테스트 완료) plugins { id 'org.springframework.boot' version '3.0.2' id 'io.spring.dependency-management' version '1.1.0' //추가 id 'java' } dependency-management 플러그인 사용 spring-boot-dependencies의 bom 정보를 참고 bom(Bill of Materials, 부품 목록) 현재 프로젝트에 지정한 스프링 부트 버전에 맞는 라이브러리 버전 명시 spring-boot-dependencies는 gradle 플러그인에서 사용하므로 눈에 보이진 않음 스프링 부트 스타터 제공 Best Practice 라이브러리 뭉치를 제공 덕분에 일반적으로 많이 사용하는 대중적인 라이브러리로 간단하게 프로젝트 시작 가능 이름 패턴 공식: spring-boot-starter-* 비공식: thirdpartyproject-spring-boot-starter (스프링이 공식적 제공 X) e.g. mybatis-spring-boot-starter 자주 사용하는 스타터 spring-boot-starter : 핵심 스타터, 자동 구성, 로깅, YAML (다른 스타터 사용 시 보통 포함됨) spring-boot-starter-jdbc : JDBC, HikariCP 커넥션풀 spring-boot-starter-data-jpa : 스프링 데이터 JPA, 하이버네이트 spring-boot-starter-data-mongodb : 스프링 데이터 몽고 spring-boot-starter-data-redis : 스프링 데이터 Redis, Lettuce 클라이언트 spring-boot-starter-thymeleaf : 타임리프 뷰와 웹 MVC spring-boot-starter-web : 웹 구축을 위한 스타터, RESTful, 스프링 MVC, 내장 톰캣 spring-boot-starter-validation : 자바 빈 검증기(하이버네이트 Validator) spring-boot-starter-batch : 스프링 배치를 위한 스타터 참고: 스프링 부트가 관리하지 않는 외부 라이브러리 사용하기 버전 없이 적용해보고 안되면 버전 명시 e.g. implementation 'org.yaml:snakeyaml:1.30' 참고: 스프링 부트가 관리하는 라이브러리의 버전 변경 방법 e.g. ext['tomcat.version'] = '10.1.4' 거의 변경할 일이 없지만, 혹시나 버그 때문에 버전을 바꿔야 한다면 사용 tomcat.version 같은 속성값은 스프링 부트 docs에서 확인하자 자동 구성 (Auto Configuration) 스프링 부트가 일반적으로 자주 사용하는 빈들을 자동으로 등록해주는 기능 개발자의 반복적이고 복잡한 빈 등록 및 설정을 최소화 보통 라이브러리를 만들어 제공할 때 사용 (이외는 잘 없음) 스프링 부트 기본 사용 라이브러리: spring-boot-autoconfigure 주요 애노테이션 @AutoConfiguration 자동 구성 적용 내부에 @Configuration이 있어서 자바 설정 파일로 사용 가능 옵션 after : 자동 구성이 실행되는 순서 지정 가능 @Conditional 특정 조건에 맞을 때 설정이 동작하도록 함 (If 문과 유사) @Conditional 기본 동작 Condition 인터페이스를 구현해 사용 public interface Condition { boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata); } 구현 클래스 @Slf4j public class MemoryCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { String memory = context.getEnvironment().getProperty("memory"); log.info("memory={}", memory); return "on".equals(memory); } } matches() 메서드가 true 를 반환하면 동작, false 를 반환하면 동작 X 사용 (설정 클래스에 추가) @Configuration @Conditional(MemoryCondition.class) //추가 public class MemoryConfig { @Bean public MemoryController memoryController() { return new MemoryController(memoryFinder()); } @Bean public MemoryFinder memoryFinder() { return new MemoryFinder(); } } @Conditional(구현클래스.class) 스프링이 제공하는 Conditional 기본 구현체 특징 @ConditionalOnXxx 형태 스프링 부트 자동 구성에 사용 @Conditional은 스프링의 기능 -> @ConditionalOnXxx로 스프링 부트가 확장 종류 @ConditionalOnClass , @ConditionalOnMissingClass 클래스가 있는 경우 동작, 나머지는 그 반대 @ConditionalOnBean , @ConditionalOnMissingBean 빈이 등록되어 있는 경우 동작, 나머지는 그 반대 @ConditionalOnProperty 환경 정보가 있는 경우 동작 @ConditionalOnResource 리소스가 있는 경우 동작 @ConditionalOnWebApplication , @ConditionalOnNotWebApplication 웹 애플리케이션인 경우 동작 @ConditionalOnExpression SpEL 표현식에 만족하는 경우 동작 @AutoConfiguration 이해하기 자동 구성 라이브러리 만들기 순수 라이브러리 방식을 그대로 하되, 라이브러리 내에 자동 구성 설정 파일 추가 e.g. @AutoConfiguration, @ConditionalOnXxx 추가 @AutoConfiguration @ConditionalOnProperty(name = "memory", havingValue = "on") public class MemoryAutoConfig { @Bean public MemoryController memoryController() { return new MemoryController(memoryFinder()); } @Bean public MemoryFinder memoryFinder() { return new MemoryFinder(); } } 자동 구성 대상 지정 (필수) src/main/resources/META-INF/spring/ 디렉토리에 다음 파일 추가 org.springframework.boot.autoconfigure.AutoConfiguration.imports 만든 자동 구성을 패키지를 포함해 지정 e.g. memory.MemoryAutoConfig 스프링 부트의 동작 스프링 부트는 시작 시점에 libs 폴더 내 모든 라이브러리의 resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일을 읽어서 자동 구성 클래스를 인식하고 @Conditional 조건에 맞으면 빈으로 등록 과정: @SpringBootApplication -> @EnableAutoConfiguration -> @Import(AutoConfigurationImportSelector.class) -> resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일을 열어 설정 정보 선택 -> 스프링 컨테이너에 설정 정보 등록 @SpringBootApplication : 스프링 부트 시작점 @SpringBootApplication public class AutoConfigApplication { public static void main(String[] args) { SpringApplication.run(AutoConfigApplication.class, args); } } run()에 설정정보로 사용할 클래스를 전달 (AutoConfigApplication) @EnableAutoConfiguration : 자동 구성 활성화 기능 제공 @Import(AutoConfigurationImportSelector.class) @Import : 스프링 설정 정보(@Configuration) 추가 정적 방법 : @Import(클래스) 동적 방법: @Import(ImportSelector) - 설정 대상을 동적 선택 public interface ImportSelector { String[] selectImports(AnnotationMetadata importingClassMetadata); //... } AutoConfigurationImportSelector ImportSelector의 구현체로 설정 정보를 동적으로 선택 모든 라이브러리에 있는 특정 경로를 확인해 설정 정보 선택 순수 라이브러리 만들기 다른 곳에서 사용할 순수 라이브러리 Jar를 만들어야 하므로, 실행 가능 Jar가 되지 않도록 스프링 부트 플러그인은 사용하지 않는다. (옵션을 넣으면 스프링 부트 플러그인을 써도 가능할 수 있음) 코드가 완성되면 빌드(./gradlew clean build)를 진행한다. 실제 라이브러리 추가는 다음과 같이 진행한다. 원하는 프로젝트에 libs 디렉토리를 생성하고 빌드 결과물 Jar 파일을 해당 디렉토리에 복사한다. 그 후, build.gradle의 dependencies에 다음 코드를 추가하면 된다. implementation files('libs/결과.jar') //추가 외부 설정과 프로필 사용 전략 설정 데이터를 기본으로 사용 일부 속성을 변경할 필요가 있다면, 자바 시스템 속성 or 커맨드 라인 옵션 인수 사용 우선 순위가 높으므로 설정 데이터를 덮어씀 외부 설정 애플리케이션 빌드는 한번만 하고 각 환경에 맞추어 실행 시점에 외부 설정값 주입 장점 모든 환경에 똑같은 빌드 결과를 사용할 수 있어서 신뢰성 향상 손쉽게 새로운 환경 추가도 가능 일반적인 방법 OS 환경 변수 OS에서 지원하는 외부 설정 해당 OS를 사용하는 모든 프로세스에서 사용 (사용 범위가 가장 넓음) 자바 시스템 속성 자바에서 지원하는 외부 설정 e.g. -D vm 옵션을 통해서 전달 java -Durl=dev -jar app.jar => url=dev 속성 추가됨 순서에 주의 (-D 옵션이 -jar 보다 앞에 있음) 해당 JVM 안에서 사용 자바 커맨드 라인 인수 기본 커맨드 라인에서 전달하는 외부 설정 e.g. 필요한 데이터를 마지막 위치에 스페이스로 구분해서 전달 java -jar app.jar dataA dataB 실행 시 main(args) 메서드에서 사용 커맨드 라인 옵션 인수 (스프링만의 표준 방식 지원) 커맨드 라인 인수를 key=value 형식으로 사용할 수 있도록 표준 정의 ApplicationArguments 인터페이스, DefaultApplicationArguments 구현체 사용 전달 형식: --key=value e.g. --url=devdb --username=dev_user --password=dev_pw mode=on => 스프링 부트는 ApplicationArguments 를 스프링 빈으로 등록해둠 커맨드 라인을 포함해 커맨드 라인 옵션 인수의 입력을 저장 해당 빈을 주입 받으면 어디서든 사용가능 외부 파일(설정 데이터) 프로그램에서 외부 파일을 직접 읽어서 사용 (애플리케이션 로딩 시점) application.properties, application.yml YAML 사용 권장 (application.yml) 사람이 읽기 좋은 데이터 구조 Jar 파일 안에 설정 파일을 포함시키고 프로필로 관리 방법 프로필마다 파일을 나눠 관리 파일 이름 : application-{프로필}.properties 실행 (spring.profiles.active=프로필) --spring.profiles.active=프로필 (커맨드 라인 옵션 인수) -Dspring.profiles.active=프로필 (자바 시스템 속성) 하나의 파일로 관리 (권장) 파일 내 논리적으로 영역 구분 application.properties -> #--- or !--- application.yml -> --- 프로필 지정 spring.config.activate.on-profile=프로필 실행 (spring.profiles.active=프로필) --spring.profiles.active=프로필 (커맨드 라인 옵션 인수) -Dspring.profiles.active=프로필 (자바 시스템 속성) 프로필 유의점 프로필 지정 없이 실행할 시, 스프링은 default 프로필 사용 설정 파일에 프로필 지정 없이 쓴 설정들은 프로필과 무관하게 모두 적용 보통 기본값을 처음에 두고 그 후 프로필이 필요한 논리 문서들을 둠 프로필을 한번에 둘 이상 설정도 가능 실행: --spring.profiles.active=dev,prod 문서 읽기 내부 동작 스프링은 단순하게 문서를 순서대로 읽으면서 값 설정 기존 데이터가 있으면 덮어쓰기 진행 논리 문서에 spring.config.activate.on-profile 옵션이 있으면 해당 프로필을 사용할 때만 적용 YAML과 프로필 예시 my: datasource: url: local.db.com username: local_user password: local_pw etc: maxConnection: 2 timeout: 60s options: LOCAL, CACHE --- spring: config: activate: on-profile: dev my: datasource: url: dev.db.com username: dev_user password: dev_pw etc: maxConnection: 10 timeout: 60s options: DEV, CACHE --- spring: config: activate: on-profile: prod my: datasource: url: prod.db.com username: prod_user password: prod_pw etc: maxConnection: 50 timeout: 10s options: PROD, CACHE @Profile(프로필) 해당 프로필이 활성화된 경우에만 빈을 등록 설정값 정도를 넘어서 각 환경마다 다른 빈을 등록해야 하는 경우 사용 내부에서는 @Conditional 사용 @Conditional(ProfileCondition.class) e.g. 결제 기능 @Slf4j @Configuration public class PayConfig { @Bean @Profile("default") public LocalPayClient localPayClient() { log.info("LocalPayClient 빈 등록"); return new LocalPayClient(); } @Bean @Profile("prod") public ProdPayClient prodPayClient() { log.info("ProdPayClient 빈 등록"); return new ProdPayClient(); } } 로컬 환경은 가짜 결제 기능 빈 등록 (@Profile("default")) 운영 환경은 실제 결제 기능 빈 등록 (@Profile("prod")) Environment & PropertySource 추상화 (외부 설정 통합) 스프링은 key=value 형태로 사용하는 외부 설정을 추상화 모두 통합 (OS 환경변수, 자바 시스템 속성, 커맨드 라인 옵션 인수, 설정 데이터) Environment 를 통해 외부 설정 조회 값 조회: environment.getProperty(key) 같은 외부 설정 값이 있다면, 내부의 미리 정해진 우선순위에 따라 조회 과정 스프링은 로딩 시점에 PropertySource들을 생성 PropertySource 추상 클래스 -> XxxPropertySource 구현체 생성 후, Environment에서 사용할 수 있게 연결 스프링 부트 외부 설정 우선순위 (위에서부터 아래로) @TestPropertySource (테스트에서 사용) 커맨드 라인 옵션 인수 자바 시스템 속성 OS 환경변수 설정 데이터(application.properties) jar 외부 프로필 적용 파일 application-{profile}.properties jar 외부 application.properties jar 내부 프로필 적용 파일 application-{profile}.properties jar 내부 application.properties 외부 설정 조회 방법 (스프링 지원, 모두 Environment 활용) Environment 조회: Environment.getProperty(key, Type) 타입 정보를 주면 해당 타입으로 변환 (스프링 내부 변환기 작동) e.g. int maxConnection = env.getProperty("my.datasource.etc.max-connection", Integer.class); @Value 외부 설정 값을 주입하는 방법 ${} 를 사용해서 외부 설정의 키값을 주면 원하는 값을 주입받을 수 있음 조회: @Value("${my.datasource.url}") private String url; 필드, 파라미터 모두 사용 가능 기본값 사용 가능 (:) e.g. @Value("${my.datasource.etc.max-connection:1}") @ConfigurationProperties (편리하여 권장) 외부 설정의 묶음 정보를 객체로 변환하는 기능 (타입 안전한 설정 속성) 자바 빈 검증기 적용 가능 (spring-boot-starter-validation) 생성자를 이용해 작성하자 (Setter를 통할 수도 있지만 없는게 안전) 조회: @ConfigurationProperties("외부 설정 KEY의 묶음 시작점") @Getter @ConfigurationProperties("my.datasource") @Validated public class MyDataSourcePropertiesV3 { @NotEmpty private String url; @NotEmpty private String username; @NotEmpty private String password; private Etc etc; public MyDataSourcePropertiesV3(String url, String username, String password, Etc etc) { this.url = url; this.username = username; this.password = password; this.etc = etc; } @Getter public static class Etc { @Min(1) @Max(999) private int maxConnection; @DurationMin(seconds = 1) @DurationMax(seconds = 60) private Duration timeout; private List<String> options; public Etc(int maxConnection, Duration timeout, List<String> options) { this.maxConnection = maxConnection; this.timeout = timeout; this.options = options; } } } 사용 @Slf4j @EnableConfigurationProperties(MyDataSourcePropertiesV3.class) public class MyDataSourceConfigV3 { private final MyDataSourcePropertiesV3 properties; public MyDataSourceConfigV3(MyDataSourcePropertiesV3 properties) { this.properties = properties; } @Bean public MyDataSource dataSource() { return new MyDataSource( properties.getUrl(), properties.getUsername(), properties.getPassword(), properties.getEtc().getMaxConnection(), properties.getEtc().getTimeout(), properties.getEtc().getOptions()); } } 캐밥 표기법 소문자와 -를 사용하는 표기법이다. 스프링은 설정 데이터에 캐밥 표기법을 권장한다. e.g. my.datasource.etc.max-connection=1 프로덕션 레디 기능 장애는 언제든 발생할 수 있지만, 지표를 심어 감시하고 모니터링하는 것은 반드시 필요 애플리케이션이 살아 있는지, 로그 정보는 정상 설정인지, 커넥션 풀은 얼마나 사용되는지… 프로덕션 레디 기능 프로덕션을 운영에 배포할 때 준비해야 하는 비기능적 요소들 종류 지표(metric), 추적(trace), 감사(auditing) 메트릭은 대략적인 값과 추세를 확인하는 것이 주 목적 (중간중간 누락될 수 있음) 메트릭(지표)의 분류 게이지(Gauge) 임의로 오르내릴 수 있는 값 e.g. CPU 사용량, 메모리 사용량, 사용 중인 커넥션 카운터(Counter) 단순하게 증가하는 단일 누적 값 e.g. HTTP 요청 수, 로그 발생 수 모니터링 액츄에이터 (스프링 부트 제공) 프로덕션 레디 기능을 편리하게 사용하도록 지원 모니터링 시스템(마이크로미터, 프로메테우스, 그라파나)과의 연동 지원 기본 사용법 build.gradle implementation 'org.springframework.boot:spring-boot-starter-actuator' /actuator 경로로 기능 제공 액츄에이터 기능(=엔드포인트) 웹 노출 (application.yml) management: endpoints: web: exposure: include: "*" 액츄에이터 엔드포인트들은 내부에서만 접근 가능한 내부망을 사용하자 (보안 주의) 액츄에이터 기능을 애플리케이션 서버와 다른 포트에서 실행 e.g. management.server.port=9292 참고: 포트 분리가 어렵고 불가피하게 외부 접근 허용하는 상황 서블릿 필터 혹은 스프링 시큐리티 통한 인증 추가 개발 필요 마이크로미터 (라이브러리) 애플리케이션의 메트릭(측정 지표)을 마이크로미터가 정한 표준 방법으로 모아 제공 마이크로미터가 추상화를 통해 모니터링 툴에 맞는 구현체를 갈아 끼울 수 있도록 함 모니터링 툴: CloudWatch, Datadog, JMX, New Relic, Prometheus, … 모니터링 툴이 변경되어도 애플리케이션 코드는 그대로 유지 가능 “애플리케이션 메트릭 파사드”라고도 부름 스프링 부트 액츄에이터는 마이크로미터를 기본 내장해 사용 사용법 경로: /actuator/metrics/{name} Tag 필터: Tag를 기반으로 정보 필터링 가능 쿼리 파라미터에 tag=KEY:VALUE 형식으로 적용 e.g. /actuator/metrics/jvm.memory.used?tag=area:heap 톰캣 메트릭은 모두 사용하려면 다음 옵션을 켜야함 server: tomcat: mbeanregistry: enabled: true 프로메테우스 지속해서 수집한 메트릭을 저장하는 DB 참고: 마이크로미터는 그 순간의 메트릭만 확인 가능 설정 방법 애플리케이션 설정 (build.gradle) implementation 'io.micrometer:micrometer-registry-prometheus 스프링 부트와 액츄에이터가 자동으로 마이크로미터 프로메테우스 구현체 등록 프로메테우스 메트릭 수집 엔트포인트 자동 추가 (/actuator/prometheus) 프로메테우스 설정 프로메테우스 폴더에 있는 prometheus.yml 파일을 수정 ```yaml … scrape_configs: - job_name: “prometheus” static_configs: - targets: [“localhost:9090”] #추가 job_name: “spring-actuator” metrics_path: ‘/actuator/prometheus’ scrape_interval: 1s static_configs: targets: [‘localhost:8080’] ``` job_name : 수집하는 이름 (임의의 이름을 사용) metrics_path : 수집할 경로를 지정 scrape_interval : 수집할 주기를 설정 (10s~1m 권장, 기본값은 1m) targets : 수집할 서버의 IP, PORT를 지정 실행 ./prometheus 사용법 Label 필터 마이크로미터의 Tag를 프로메테우스에서는 Label이라고 함 {} 사용해 필터링 레이블 일치 연산자 = 제공된 문자열과 정확히 동일한 레이블 선택 != 제공된 문자열과 같지 않은 레이블 선택 =~ 제공된 문자열과 정규식 일치하는 레이블 선택 !~ 제공된 문자열과 정규식 일치하지 않는 레이블 선택 e.g.uri=/log , method=GET 조건으로 필터 http_server_requests_seconds_count{uri="/log", method="GET"} 연산자 및 함수 지원 +, -, *, /, % sum e.g. sum(http_server_requests_seconds_count) sum by : SQL group by와 유사 e.g. sum by(method, status)(http_server_requests_seconds_count) count e.g. count(http_server_requests_seconds_count) topk e.g. topk(3, http_server_requests_seconds_count) 오프셋 수정자 http_server_requests_seconds_count offset 10m 범위 벡터 선택기 http_server_requests_seconds_count[1m] 카운터 지표를 위한 함수 (계속 증가하는 그래프를 보정) increase() 지정한 시간 단위별로 증가를 확인 e.g. increase(http_server_requests_seconds_count{uri="/log"}[1m]) rate() 범위 백터에서 초당 평균 증가율을 계산 초당 얼마나 증가하는지 나타내는 지표로 보자 e.g. rate(data[1m]) irate() 범위 벡터에서 초당 순간 증가율을 계산 급격하게 증가한 내용을 확인하기 좋음 (순간적으로 많이 증가했구나 판단!) 그라파나 프로메테우스(DB)에 있는 데이터를 불러와 그래프 대시보드로 보여주는 툴 실행 bin 디렉토리 내에서 ./grafana-server http://localhost:3000 접속 관리자 계정 접속 Email or Username: admin Password: admin 그 다음 Skip 선택 설정 방법 대시보드 설정에서 데이터 소스 추가 (프로메테우스 추가) 왼쪽 하단 설정(Configuration) 버튼에서 Data sources 선택 Add data source Prometheus 선택 URL: http://localhost:9090 Save & test 대시보드 가져다 쓰기 https://grafana.com/grafana/dashboards 접속 Copy Id to clipboard 그라파나 접속 Dashboards -> New -> Import 불러올 대시보드 아이디를 입력하고 Load Prometheus 데이터 소스 선택하고 Import 실무 모니터링 환경 구성 팁 모니터링 3단계 대시보드 큰 뷰를 보는데 좋음 e.g. 마이크로미터, 프로메테우스, 그라파나 모니터링 대상 시스템 메트릭(CPU, 메모리) 애플리케이션 메트릭(톰캣 쓰레드 풀, DB 커넥션 풀, 애플리케이션 호출 수) 비즈니스 메트릭(주문수, 취소수) 애플리케이션 추적 (핀포인트) 세부 추적에 좋음 각각의 HTTP 요청 추적 마이크로서비스 환경에서 분산 추적 마이크로서비스에서 한 요청이 전체 서버를 어떻게 흘러갔는지 확인 가능 e.g. 핀포인트(오픈소스), 스카우트(오픈소스), 와탭(상용), 제니퍼(상용) 로그 대시보드, 애플리케이션 추적으로 안잡히는 문제들을 잡기 위해 사용 같은 HTTP 요청을 묶어서 확인할 수 있는 방법이 중요 (MDC 적용) MDC: 컨텍스트에 맞춰 고객 요청이 오고 갈 때 같은 로그 아이디를 심어주는 기능 e.g. 한 HTTP 요청임을 구분할 수 있게, 로그에 UUID도 같이 남김 팁 파일로 직접 로그를 남기는 경우 일반 로그와 에러 로그는 파일을 구분해 남기자 클라우드에 로그를 저장하는 경우 (데이터독, AWS 제품…) 검색이 잘 되도록 구분 알람 모니터링 툴에서 일정 이상 수치가 넘어가면 슬랙, 문자 등을 연동 경고 및 심각으로 2가지 종류 구분할 것 경고: 하루 1번 정도 사람이 직접 들어가서 확인해도 되는 수준 심각: 즉시 확인해야 함 -> 슬랙 알림, 문자, 전화 (슬랙 추천! 경고 알람방, 심각 알람방) e.g. 경고와 심각을 잘 나누어 업무와 삶에 방해되지 않도록 함 디스크 사용량 70% -> 경고 디스크 사용량 80% -> 심각 CPU 사용량 40% -> 경고 CPU 사용량 50% -> 심각 사용 전략 하나만 써야한다면 핀포인트 추천 스타트업에서 가장 먼저 할 일은 핀포인트 까는 것 설계가 탄탄하고 대용량 트래픽도 잘 버팀 그 다음 대시보드 구축 진행 그 다음 로그 구축
Java-Ecosystem
· 2025-03-31
스프링 핵심 원리 - 고급편
로그 추적기 도입 과정 목표: Controller, Service, Repository에 변경을 최소화하여 로그 추적기 적용하기 로그 추적기 기본 구현 TraceId public class TraceId { private String id; private int level; public TraceId() { this.id = createId(); this.level = 0; } private TraceId(String id, int level) { this.id = id; this.level = level; } private String createId() { return UUID.randomUUID().toString().substring(0, 8); } public TraceId createNextId() { return new TraceId(id, level + 1); } public TraceId createPreviousId() { return new TraceId(id, level - 1); } public boolean isFirstLevel() { return level == 0; } public String getId() { return id; } public int getLevel() { return level; } } TraceStatus public class TraceStatus { private TraceId traceId; private Long startTimeMs; private String message; public TraceStatus(TraceId traceId, Long startTimeMs, String message) { this.traceId = traceId; this.startTimeMs = startTimeMs; this.message = message; } public Long getStartTimeMs() { return startTimeMs; } public String getMessage() { return message; } public TraceId getTraceId() { return traceId; } } Trace - 실제 로그 생성 및 처리 @Slf4j @Component public class Trace { private static final String START_PREFIX = "-->"; private static final String COMPLETE_PREFIX = "<--"; private static final String EX_PREFIX = "<X-"; public TraceStatus begin(String message) { TraceId traceId = new TraceId(); Long startTimeMs = System.currentTimeMillis(); log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message); return new TraceStatus(traceId, startTimeMs, message); } public void end(TraceStatus status) { complete(status, null); } public void exception(TraceStatus status, Exception e) { complete(status, e); } private void complete(TraceStatus status, Exception e) { Long stopTimeMs = System.currentTimeMillis(); long resultTimeMs = stopTimeMs - status.getStartTimeMs(); TraceId traceId = status.getTraceId(); if (e == null) { log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs); } else { log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs, e.toString()); } } private static String addSpace(String prefix, int level) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < level; i++) { sb.append( (i == level - 1) ? "|" + prefix : "| "); } return sb.toString(); } } 주요 public 메서드 begin() end() exception() 1단계: 단순 적용 @GetMapping("/v1/request") public String request(String itemId) { TraceStatus status = null; try { status = trace.begin("OrderController.request()"); orderService.orderItem(itemId); trace.end(status); return "ok"; } catch (Exception e) { trace.exception(status, e); throw e; //예외를 꼭 다시 던져주어야 한다. } } 해결 해야할 문제 공통 로직 처리 중복 문제 (부가 기능 코드가 너무 많음) 모든 컨트롤러, 서비스, 레포지토리 핵심 로직 앞 뒤로 로그 코드를 넣어야 함 (수작업) begin(), end(), exception(), try~catch 문 로그 때문에 예외가 사라지지 않도록 예외를 다시 던져주어야 함 로그에 대한 문맥 정보 전달 문제: 직전 로그 깊이와 트랜잭션 ID 전달 필요 (TraceId) HTTP 요청 구분 필요 (같은 HTTP 요청이면 같은 트랜잭션 ID 남겨야 함) 메서드 호출 깊이 표현 필요 (Level) 2단계: 파라미터 이용한 동기화 개발 문맥 정보 전달 문제 해결 Trace 클래스에 beginSync 메서드 추가 public TraceStatus beginSync(TraceId beforeTraceId, String message) { TraceId nextId = beforeTraceId.createNextId(); Long startTimeMs = System.currentTimeMillis(); log.info("[" + nextId.getId() + "] " + addSpace(START_PREFIX, nextId.getLevel()) + message); return new TraceStatus(nextId, startTimeMs, message); } TraceId를 서비스, 레포지토리 메서드 파라미터에 추가 public void orderItem(TraceId traceId, String itemId) {} public void save(TraceId traceId, String itemId) {} 각각 TraceId 전달해 beginSync 호출 해결해야할 문제 공통 로직 처리 중복 문제 (부가 기능 코드가 너무 많음) 모든 컨트롤러, 서비스, 레포지토리 핵심 로직 앞 뒤로 로그 코드를 넣어야 함 (수작업) begin(), end(), exception(), try~catch 문 로그 때문에 예외가 사라지지 않도록 예외를 다시 던져주어야 함 TraceId 동기화를 위해 모든 관련 메서드 파라미터를 수정해야함 (수작업) 3단계: 필드를 이용한 동기화 모든 관련 메서드 파라미터 수정 문제 해결 traceIdHolder 필드로 TraceId 동기화하는 LogTrace 구현체 개발 @Slf4j public class FieldLogTrace implements LogTrace { private static final String START_PREFIX = "-->"; private static final String COMPLETE_PREFIX = "<--"; private static final String EX_PREFIX = "<X-"; private TraceId traceIdHolder; //traceId 동기화, 동시성 이슈 발생 @Override public TraceStatus begin(String message) { syncTraceId(); TraceId traceId = traceIdHolder; ... return new TraceStatus(traceId, startTimeMs, message); } ... private void complete(TraceStatus status, Exception e) { ... releaseTraceId(); } private void syncTraceId() { if (traceIdHolder == null) { traceIdHolder = new TraceId(); } else { traceIdHolder = traceIdHolder.createNextId(); } } private void releaseTraceId() { if (traceIdHolder.isFirstLevel()) { traceIdHolder = null; //destroy } else { traceIdHolder = traceIdHolder.createPreviousId(); } } ... } 구현체 스프링 빈 등록하면, 파라미터 전달 코드 필요 X 해결해야 할 문제 공통 로직 처리 중복 문제 (부가 기능 코드가 너무 많음) 모든 컨트롤러, 서비스, 레포지토리 핵심 로직 앞 뒤로 로그 코드를 넣어야 함 (수작업) begin(), end(), exception(), try~catch 문 로그 때문에 예외가 사라지지 않도록 예외를 다시 던져주어야 함 동시성 문제: 여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제 싱글톤 스프링 빈 FieldLogTrace 인스턴스는 애플리케이션에 딱 1개 존재 동시에 여러 사용자가 요청하면, 여러 스레드가 traceIdHolder 필드에 동시 접근 트래픽이 적은 상황에서는 확률상 잘 나타나지 않고, 트래픽이 많아질수록 자주 발생 4단계: 필드 동기화 - 스레드 로컬(ThreadLocal) 적용 싱글톤 객체 필드를 사용할 때 동시성 문제 해결 traceIdHolder 필드가 스레드 로컬을 사용하도록 변경 TraceId traceIdHolder -> ThreadLocal<TraceId> traceIdHolder private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>(); 값을 저장할 때는 set(...), 조회할 때는 get() 사용 traceIdHolder.set(new TraceId()); TraceId traceId = traceIdHolder.get(); 호출 추적 로그 완료 시, 반드시 remove() 호출 (스레드 전용 보관소 내 값 제거) 해결해야 할 문제 공통 로직 처리 중복 문제 (부가 기능 코드가 너무 많음) 모든 컨트롤러, 서비스, 레포지토리 핵심 로직 앞 뒤로 로그 코드를 넣어야 함 (수작업) begin(), end(), exception(), try~catch 문 로그 때문에 예외가 사라지지 않도록 예외를 다시 던져주어야 함 5단계: 템플릿 메서드 패턴 적용 공통 로직 처리 중복 문제 해결 변하지 않는 부가 기능 로직을 템플릿 코드로 분리 public abstract class AbstractTemplate<T> { private final LogTrace trace; public AbstractTemplate(LogTrace trace) { this.trace = trace; } public T execute(String message) { TraceStatus status = null; try { status = trace.begin(message); //로직 호출 T result = call(); trace.end(status); return result; } catch (Exception e) { trace.exception(status, e); throw e; } } protected abstract T call(); } 변하는 핵심 로직을 자식 클래스로 분리 (컨트롤러, 서비스, 레포지토리) @GetMapping("/v4/request") public String request(String itemId) { AbstractTemplate<String> template = new AbstractTemplate<>(trace) { @Override protected String call() { orderService.orderItem(itemId); return "ok"; } }; return template.execute("OrderController.request()"); } 결과적으로, 변경 지점을 하나로 모아 변경에 쉽게 대처할 수 있는 구조 만듦 로그를 남기는 부분에 단일 책임 원칙(SRP)을 지킴 해결해야 할 문제 상속에서 오는 문제 (자식과 부모의 강결합, 자식 클래스 매 번 만들고 오버라이딩하는 복잡함) 6단계: 템플릿 콜백 패턴 적용 상속에서 오는 문제 해결 TraceCallback 인터페이스 - 콜백 public interface TraceCallback<T> { T call(); } TraceTemplate - 템플릿 public class TraceTemplate { private final LogTrace trace; public TraceTemplate(LogTrace trace) { this.trace = trace; } public <T> T execute(String message, TraceCallback<T> callback) { TraceStatus status = null; try { status = trace.begin(message); //로직 호출 T result = callback.call(); trace.end(status); return result; } catch (Exception e) { trace.exception(status, e); throw e; } } } 컨트롤러, 서비스, 레포지토리에 템플릿 실행 코드 적용 @RestController public class OrderControllerV5 { private final OrderServiceV5 orderService; private final TraceTemplate template; public OrderControllerV5(OrderServiceV5 orderService, LogTrace trace) { this.orderService = orderService; this.template = new TraceTemplate(trace); } // 람다로도 전달 가능 @GetMapping("/v5/request") public String request(String itemId) { return template.execute("OrderController.request()", new TraceCallback<>() { @Override public String call() { orderService.orderItem(itemId); return "ok"; } }); } } this.template = new TraceTemplate(trace) 생성자에서 trace 의존관계 주입을 받음과 동시에 Template 생성 장점: 테스트 시 한 번에 목으로 대체할 수 있어 간단 Template 류 테스트에 적합 테스트 시 스프링 빈 등록할 때 다 만들어서 진행하는게 더 불편 물론 처음부터 TraceTemplate을 스프링 빈으로 등록하고 주입 받을 수도 있음! 또한, 모든 컨트롤러, 서비스, 레포지토리에 하더라도 이 정도 객체 생성은 낭비 아님 해결해야 할 문제 로그 추적기 도입 위해 결국 원본 코드(컨트롤러, 서비스, 레포지토리) 수정해야 하는 문제 코드로 최적화할 수 있는건 최대치로 완료! 6.5단계: 프록시 도입 예정 (데코레이터 패턴) 원본 코드 수정 문제 해결 (프록시 + DI) 해결해야 할 문제: 너무 많은 프록시 클래스를 만들어야 함 7단계: 동적 프록시 도입 (JDK 동적 프록시, 인터페이스가 있으므로) 원본 코드 수정 및 프록시 클래스 다량 수작업 문제 해결 + 메서드 마다 선택적 적용 기능 추가 LogTraceBasicHandler - InvocationHandler 상속 public class LogTraceBasicHandler implements InvocationHandler { private final Object target; private final LogTrace logTrace; private final String[] patterns; //패턴을 통한 적용 필터링 public LogTraceBasicHandler(Object target, LogTrace logTrace) { this.target = target; this.logTrace = logTrace; this.patterns = patterns; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { //메서드 이름 필터 String methodName = method.getName(); if (!PatternMatchUtils.simpleMatch(patterns, methodName)) { return method.invoke(target, args); } TraceStatus status = null; try { String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()"; status = logTrace.begin(message); //로직 호출 Object result = method.invoke(target, args); logTrace.end(status); return result; } catch (Exception e) { logTrace.exception(status, e); throw e; } } } 동적 프록시 스프링빈 등록 @Configuration public class DynamicProxyBasicConfig { private static final String[] PATTERNS = {"request*", "order*", "save*"}; // 메서드 이름 필터링 패턴 @Bean public OrderControllerV1 orderControllerV1(LogTrace logTrace) { OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace)); OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(), new Class[]{OrderControllerV1.class}, new LogTraceBasicHandler(orderController, logTrace, PATTERNS) ); return proxy; } @Bean public OrderServiceV1 orderServiceV1(LogTrace logTrace) { OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace)); OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(OrderServiceV1.class.getClassLoader(), new Class[]{OrderServiceV1.class}, new LogTraceBasicHandler(orderService, logTrace, PATTERNS) ); return proxy; } @Bean public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) { OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl(); OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(), new Class[]{OrderRepositoryV1.class}, new LogTraceBasicHandler(orderRepository, logTrace, PATTERNS) ); return proxy; } } 해결해야 할 문제 인터페이스 없이 클래스만 있는 경우 동적 프록시 적용 불가 메서드 마다 부가기능 선택적 적용 기능 자동화 8단계: 프록시 팩토리 적용 인터페이스 유무 상관없이 동적 프록시 생성 Advice 정의 @Slf4j public class LogTraceAdvice implements MethodInterceptor { private final LogTrace logTrace; public LogTraceAdvice(LogTrace logTrace) { this.logTrace = logTrace; } @Override public Object invoke(MethodInvocation invocation) throws Throwable { TraceStatus status = null; try { Method method = invocation.getMethod(); String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()"; status = logTrace.begin(message); //로직 호출 Object result = invocation.proceed(); logTrace.end(status); return result; } catch (Exception e) { logTrace.exception(status, e); throw e; } } } 프록시 팩토리 사용해 프록시 생성 후 스프링 빈 등록 @Slf4j @Configuration public class ProxyFactoryConfigV1 { @Bean public OrderControllerV1 orderControllerV1(LogTrace logTrace) { OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace)); ProxyFactory factory = new ProxyFactory(orderController); factory.addAdvisor(getAdvisor(logTrace)); OrderControllerV1 proxy = (OrderControllerV1) factory.getProxy(); return proxy; } @Bean public OrderServiceV1 orderServiceV1(LogTrace logTrace) { OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace)); ProxyFactory factory = new ProxyFactory(orderService); factory.addAdvisor(getAdvisor(logTrace)); OrderServiceV1 proxy = (OrderServiceV1) factory.getProxy(); return proxy; } @Bean public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) { OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl(); ProxyFactory factory = new ProxyFactory(orderRepository); factory.addAdvisor(getAdvisor(logTrace)); OrderRepositoryV1 proxy = (OrderRepositoryV1) factory.getProxy(); return proxy; } private Advisor getAdvisor(LogTrace logTrace) { //pointcut NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); pointcut.setMappedNames("request*", "order*", "save*"); //advice LogTraceAdvice advice = new LogTraceAdvice(logTrace); //advisor = pointcut + advice return new DefaultPointcutAdvisor(pointcut, advice); } } 어디에 부가기능을 적용할지는 포인트컷으로 조정 NameMatchMethodPointcut의 심플 매칭 기능 활용해 * 패턴 사용 어드바이저 = 포인트컷(NameMatchMethodPointcut) + 어드바이스(LogTraceAdvice) 인터페이스가 있기 때문에 프록시 팩토리가 JDK 동적 프록시를 적용 물론, 구체 클래스만 있을 때는 프록시 팩토리가 CGLIB을 적용 해결해야 할 문제 스프링 빈 수동 등록 시 너무 많은 설정 (설정 지옥) 발생 프록시 팩토리로 프록시 생성하는 코드를 포함해 설정 파일 및 코드가 너무 많음 컴포넌트 스캔 시 현재 방법으로는 프록시 적용 불가 컴포넌트 스캔 시 실제 객체는 스프링 컨테이너 스프링 빈으로 이미 등록을 다 해버린 상태 9단계: 빈 후처리기 적용 컴포넌트 스캔 포함 모든 스프링 빈 등록에 프록시 적용 + 설정 파일 프록시 생성 코드 반복 해결 프록시 변환을 위한 빈후처리기 (PackageLogTraceProxyPostProcessor) @Slf4j public class PackageLogTraceProxyPostProcessor implements BeanPostProcessor { private final String basePackage; private final Advisor advisor; public PackageLogTraceProxyPostProcessor(String basePackage, Advisor advisor) { this.basePackage = basePackage; this.advisor = advisor; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { //프록시 적용 대상 여부 체크 //프록시 적용 대상이 아니면 원본을 그대로 반환 String packageName = bean.getClass().getPackageName(); if (!packageName.startsWith(basePackage)) { return bean; } //프록시 대상이면 프록시를 만들어서 반환 ProxyFactory proxyFactory = new ProxyFactory(bean); proxyFactory.addAdvisor(advisor); Object proxy = proxyFactory.getProxy(); return proxy; } } 특정 패키지와 그 하위에 위치한 스프링 빈들만 프록시를 적용 즉, 스프링 부트의 수많은 기본 등록 빈들을 제외하고 필요한 빈만 프록시 적용 스프링 부트 제공 빈은 final 클래스 등 프록시 만들 수 없는 빈이 있음 빈후처리기 스프링 빈 등록 @Slf4j @Configuration @Import({AppV1Config.class, AppV2Config.class}) public class BeanPostProcessorConfig { @Bean public PackageLogTraceProxyPostProcessor logTraceProxyPostProcessor(LogTrace logTrace) { return new PackageLogTraceProxyPostProcessor("hello.proxy.app", getAdvisor(logTrace)); } private Advisor getAdvisor(LogTrace logTrace) { //pointcut NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); pointcut.setMappedNames("request*", "order*", "save*"); //advice LogTraceAdvice advice = new LogTraceAdvice(logTrace); //advisor = pointcut + advice return new DefaultPointcutAdvisor(pointcut, advice); } } 프록시 적용 결과 v1: 인터페이스가 있으므로 JDK 동적 프록시가 적용 v2: 구체 클래스만 있으므로 CGLIB 프록시가 적용 v3: 구체 클래스만 있으므로 CGLIB 프록시가 적용 (컴포넌트 스캔) 해결해야 할 문제 빈 후처리기가 자주 쓰이므로, 대표적인 AOP 구현체를 사용하는게 좋음 10단계: 스프링 AOP 적용하기 스프링 AOP로 편리하게 횡단관심사 적용하기 내부에서 스프링 제공 빈후처리기 사용 (AnnotationAwareAspectJAutoProxyCreator) LogTraceAspect @Slf4j @Aspect public class LogTraceAspect { private final LogTrace logTrace; public LogTraceAspect(LogTrace logTrace) { this.logTrace = logTrace; } @Around("execution(* hello.proxy.app..*(..))") public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { TraceStatus status = null; //log.info("target={}", joinPoint.getTarget());//실제호출대상 //log.info("getArgs={}", joinPoint.getArgs()); //전달인자 //log.info("{}", joinPoint.getSignature()); //시그니처 try { String message = joinPoint.getSignature().toShortString(); status = logTrace.begin(message); //로직 호출, 실제 대상(target) 호출 Object result = joinPoint.proceed(); logTrace.end(status); return result; } catch (Exception e) { logTrace.exception(status, e); throw e; } } } ProceedingJoinPoint joinPoint 내부에 실제 호출 대상, 전달 인자, 어떤 객체와 어떤 메서드 호출되었는지 정보 포함 스프링 빈 등록 (@Import나 컴포넌트 스캔으로 등록해도 괜찮음) @Configuration @Import({AppV1Config.class, AppV2Config.class}) public class AopConfig { @Bean public LogTraceAspect logTraceAspect(LogTrace logTrace) { return new LogTraceAspect(logTrace); } } 활용 단계: 스프링 AOP 활용 예제 로그 추적 AOP @Trace 애노테이션 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Trace { } TraceAspect @Slf4j @Aspect public class TraceAspect { @Before("@annotation(hello.aop.exam.annotation.Trace)") public void doTrace(JoinPoint joinPoint) { Object[] args = joinPoint.getArgs(); log.info("[trace] {} args={}", joinPoint.getSignature(), args); } } @Trace가 붙은 메서드에 어드바이스를 적용 재시도 AOP @Retry 애노테이션 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Retry { int value() default 3; // 재시도 횟수 } RetryAspect @Slf4j @Aspect public class RetryAspect { @Around("@annotation(retry)") public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable { log.info("[retry] {} retry={}", joinPoint.getSignature(), retry); int maxRetry = retry.value(); Exception exceptionHolder = null; for (int retryCount = 1; retryCount <= maxRetry; retryCount++) { try { log.info("[retry] try count={}/{}", retryCount, maxRetry); return joinPoint.proceed(); } catch (Exception e) { exceptionHolder = e; } } throw exceptionHolder; } } 예외가 발생했을 때 다시 시도해서 문제를 복구 retry.value()을 통해 애노테이션에 지정한 값만큼 재시도 쓸만한 실무 예제 케이스 특정 시간 이상 실행되거나 예외가 터졌을 때 로그를 남기는 Trace 100ms 이상 걸린 요청에는 로그 남기기 e.g. 1초이상 걸리면 INFO로 남기고, 5~10초 걸리면 WARNING 남기는 식 스레드 로컬(ThreadLocal) 일반적인 공유 변수 필드 (문제) 여러 스레드가 같은 인스턴스의 필드에 접근하면 처음 스레드가 보관한 데이터가 사라질 수 있음 스레드 로컬 필드 (해결) 각 스레드마다 제공되는 별도의 내부 저장소 (본인 스레드만 접근 가능) 여러 스레드가 같은 인스턴스의 스레드 로컬 필드에 접근해도 문제 X 정말 완전히 동시에 들어와도 구분 가능 각각의 스레드 객체는 자신만의 ThreadLocalMap 을 가짐 (전용 보관소) 키: ThreadLocal 인스턴스 참조 (e.g. nameStore) / 값: 데이터 (e.g. userA) 참고로 스레드 로컬 저장소와 이에 보관된 데이터들은 힙 영역에 저장됨 스프링 빈 같은 싱글톤 객체 필드를 사용하면서도 동시성 문제 해결 가능 일반적으로 Controller, Service 싱글톤 빈들에는 상태값 필드를 두지 않음 (동시성 문제 예방) 상태값을 저장해야 하는 경우에만 스레드 로컬로 해결 java.lang.ThreadLocal 클래스 (자바 지원) 변수 정의: private ThreadLocal<String> nameStore = new ThreadLocal<>(); 저장: nameStore.set(name); 조회: nameStore.get() 제거: nameStore.remove() 그림 시나리오 thread-A가 userA 값 저장 시 스레드 로컬은 thread-A 전용 보관소에 데이터 보관 thread-B가 userB 값 저장 시 스레드 로컬은 thread-B 전용 보관소에 데이터 보관 thread-A가 조회 시 스레드 로컬은 thread-A 전용 보관소에서 userA 데이터 반환 thread-B가 조회 시 스레드 로컬은 thread-B 전용 보관소에서 userB 데이터 반환 유의사항 스레드는 스레드 로컬 사용완료 후 스레드 로컬에 저장된 값을 항상 제거해야 함 (remove()) 스레드 전용 보관소가 아니라 스레드 전용 보관소 내 값 제거 즉, 요청이 끝날 때 필터나 인터셉터에서 clear하거나 최소한 ThreadLocal.remove() 반드시 호출할 것 제거하지 않을 경우 문제 발생 스레드 풀 없는 상황에서는 가비지 컬렉터가 회수할 수 없어 메모리 누수 발생 가능 WAS(톰캣)처럼 스레드 풀 사용하는 경우 문제 발생! thread-A가 풀에 반환될 때, thread-A 전용 보관소에 데이터 남아있음 스레드 풀 스레드는 재사용되므로, 사용자 B 요청도 thread-A 할당 받을 수 있음 결과적으로, 사용자B가 사용자A의 데이터를 확인하게 되는 심각한 문제가 발생 따라서, 사용자A의 요청이 끝날 때 remove() 필요 템플릿 메서드 패턴 다형성(상속)을 사용해서 변하는 부분과 변하지 않는 부분을 분리하는 방법 변하지 않는 템플릿 코드를 부모 클래스에, 변하는 부분은 자식 클래스에 두고 상속과 오버라이딩으로 처리 GOF 디자인패턴 정의: “작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기합니다” 장점 변경 지점을 하나로 모아 변경에 쉽게 대처할 수 있는 구조 (SRP, 단일 책임 원칙 지킴) 단점 (From 상속) 부모의 기능을 전혀 사용하지 않는데도 자식이 부모를 상속해 강결합됨 (잘못된 의존관계 설계) 부모 클래스 수정 시, 자식 클래스도 영향 받음 핵심 로직 추가 시 자식 클래스(익명 내부 클래스)를 계속 만들고 오버라이딩해야 하는 복잡함 예시 코드 AbstractTemplate @Slf4j public abstract class AbstractTemplate { public void execute() { long startTime = System.currentTimeMillis(); //비즈니스 로직 실행 call(); //상속 //비즈니스 로직 종료 long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("resultTime={}", resultTime); } protected abstract void call(); } SubClassLogic1 @Slf4j public class SubClassLogic1 extends AbstractTemplate { @Override protected void call() { log.info("비즈니스 로직1 실행"); } } SubClassLogic2 @Slf4j public class SubClassLogic2 extends AbstractTemplate { @Override protected void call() { log.info("비즈니스 로직2 실행"); } } 실행 코드 1 AbstractTemplate template1 = new SubClassLogic1(); template1.execute(); AbstractTemplate template2 = new SubClassLogic2(); template2.execute(); 실행 코드 2 - 익명 내부 클래스 사용하기 AbstractTemplate template1 = new AbstractTemplate() { @Override protected void call() { log.info("비즈니스 로직1 실행"); } }; template1.execute(); AbstractTemplate template2 = new AbstractTemplate() { @Override protected void call() { log.info("비즈니스 로직2 실행"); } }; template2.execute(); 핵심 기능: 해당 객체가 제공하는 고유 기능 e.g. 주문 로직 부가 기능: 핵심 기능을 보조하기 위해 제공되는 기능 (단독 사용 X) e.g. 로그 추적 기능, 트랜잭션 기능 전략 패턴 다형성(위임)을 통해 변하는 코드와 변하지 않는 코드를 분리 변하지 않는 부분을 Context 라는 곳에 두고, 변하는 부분은 Strategy 인터페이스를 구현해 처리 Context 는 변하지 않는 템플릿 역할 Strategy 는 변하는 알고리즘 역할 GOF 디자인 패턴 정의 “알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자.” “전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.” 전략(Strategy) 전달 방법 전략을 생성자로 받아 내부 필드로 저장하기 Context 안에 내부 필드에 원하는 전략을 주입해 조립 완료 후 실행 (Setter 두지 않음) 선 조립, 후 실행 방법에 적합 전략 신경쓰지 않고 단순히 실행만 하면 됨 (Context 실행 시점에는 이미 조립이 끝남) 전략을 execute 메서드의 파라미터로 받기 - 로그 추적기 구현에 적합 실행할 때 마다 전략을 유연하게 변경 가능 단점은 실행할 때마다 신경써야 하는 번거로움 장점 (템플릿 메서드 패턴 상위 호환) 템플릿 메서드 패턴의 상속이 가져오는 단점 제거 템플릿 메서드 패턴: 부모 클래스가 변경되면 자식들이 영향 받음 전략 패턴: Context 코드가 변경되어도 전략들에 영향 X Context는 Strategy 인터페이스에만 의존해, 구현체를 변경 및 생성해도 Context에 영향 없음 예시 코드 Strategy public interface Strategy { void call(); } StrategyLogic1 @Slf4j public class StrategyLogic1 implements Strategy { @Override public void call() { log.info("비즈니스 로직1 실행"); } } StrategyLogic2 @Slf4j public class StrategyLogic2 implements Strategy { @Override public void call() { log.info("비즈니스 로직2 실행"); } } Context - 전략 내부 필드 보관 @Slf4j public class Context { private Strategy strategy; // 필드에 전략을 보관 public Context(Strategy strategy) { this.strategy = strategy; } public void execute() { long startTime = System.currentTimeMillis(); //비즈니스 로직 실행 strategy.call(); //위임 //비즈니스 로직 종료 long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("resultTime={}", resultTime); } } 실행 코드 1 Strategy strategyLogic1 = new StrategyLogic1(); ContextV1 context1 = new ContextV1(strategyLogic1); context1.execute(); Strategy strategyLogic2 = new StrategyLogic2(); ContextV1 context2 = new ContextV1(strategyLogic2); context2.execute(); 실행 코드 2 - 익명 내부 클래스 사용하기 Strategy strategyLogic1 = new Strategy() { @Override public void call() { log.info("비즈니스 로직1 실행"); } }; Context context1 = new Context(strategyLogic1); context1.execute(); Strategy strategyLogic2 = new Strategy() { @Override public void call() { log.info("비즈니스 로직2 실행"); } }; Context context2 = new Context(strategyLogic2); context2.execute(); 실행 코드 3 - 람다 사용하기 Context context1 = new Context(() -> log.info("비즈니스 로직1 실행")); context1.execute(); Context context2 = new Context(() -> log.info("비즈니스 로직2 실행")); context2.execute(); ContextV2 - 전략 파라미터 전달 @Slf4j public class ContextV2 { public void execute(Strategy strategy) { long startTime = System.currentTimeMillis(); //비즈니스 로직 실행 strategy.call(); //위임 //비즈니스 로직 종료 long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("resultTime={}", resultTime); } } 실행 코드 4 - 파라미터 전달 버전 ContextV2 실행 ContextV2 context = new ContextV2(); context.execute(new StrategyLogic1()); context.execute(new StrategyLogic2()); 템플릿 메서드 패턴과 전략 패턴 두 패턴 모두 동일한 문제를 다룬다. (변하는 부분과 변하지 않는 부분을 분리하기) 또한, 두 패턴은 코드 조각(변하는 부분) 전달하기를 동일한 목적으로 둔다. 다음과 같이 정리할 수 있다. 코드 조각 전달하기 패턴 생성자 주입하기 (전략 패턴) 파라미터로 전달하기 (전략 패턴) 상속 활용하기 (템플릿 메서드 패턴) 코드 조각 전달하기 방법 클래스 정의 후 생성 (new) 익명 클래스로 전달 람다로 전달 다만, 디자인 패턴은 모양보다는 의도가 중요하다. 예를 들어, 전략 패턴이라는 의도를 담고 있으면 생성자 주입으로도 파라미터 주입으로도 구현할 수 있다. 템플릿 콜백 패턴 콜백(Callback) 다른 코드의 인수로서 넘겨주는 실행 가능한 코드 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 나중에 실행할 수도 있음 즉, 코드가 호출(call)은 되는데 코드를 넘겨준 곳의 뒤(back)에서 실행된다는 뜻 ContextV2 예제에서 콜백은 Strategy 클라이언트에서 직접 Strategy 를 실행하는 것이 아니라, 클라이언트가 ContextV2.execute(..) 를 실행할 때 Strategy 를 넘겨주고, ContextV2 뒤에서 Strategy 가 실행됨 스프링에서는 파라미터 전달 방식의 전략 패턴을 템플릿 콜백 패턴이라 지칭 Context 는 템플릿, Strategy 는 콜백 GOF 패턴 X, 스프링 내부에서 자주 사용되는 패턴이어서 스프링에서만 이렇게 부름 스프링 내 XxxTemplate 은 템플릿 콜백 패턴으로 만들어진 것 e.g. JdbcTemplate , RestTemplate , TransactionTemplate , RedisTemplate … 예시 코드 파라미터 전달 전략 패턴(ContextV2)과 동일하고 이름만 다름 Context -> Template Strategy -> Callback Callback public interface Callback { void call(); } Template @Slf4j public class TimeLogTemplate { public void execute(Callback callback) { long startTime = System.currentTimeMillis(); //비즈니스 로직 실행 callback.call(); //위임 //비즈니스 로직 종료 long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("resultTime={}", resultTime); } } 실행 코드 1 - 익명 내부 클래스 사용하기 TimeLogTemplate template = new TimeLogTemplate(); template.execute(new Callback() { @Override public void call() { log.info("비즈니스 로직1 실행"); } }); template.execute(new Callback() { @Override public void call() { log.info("비즈니스 로직2 실행"); } }); 실행 코드 2 - 람다 사용하기 TimeLogTemplate template = new TimeLogTemplate(); template.execute(() -> log.info("비즈니스 로직1 실행")); template.execute(() -> log.info("비즈니스 로직2 실행")); 자바 언어에서 콜백 자바 언어에서 실행 가능한 코드를 인수로 넘기려면 객체가 필요하다. 자바8부터는 람다를 사용할 수 있다. 자바 8 이전에는 보통 하나의 메서드를 가진 인터페이스를 구현하고, 주로 익명 내부 클래스를 사용했다. 최근에는 주로 람다를 사용한다. 프록시 (Proxy) 클라이언트가 간접적으로 서버에 요청할 때 중간에서 역할하는 대리자(Proxy) 클라이언트 -> 서버 (직접 호출) 클라이언트 -> 프록시 -> 서버 (간접 호출) 프록시 개념은 클라이언트-서버라는 큰 개념 아래서 폭넓게 사용 (규모 차이) e.g. 객체 개념의 프록시, 웹 서버 개념의 프록시 특징 서버와 프록시는 같은 인터페이스 사용 (DI를 통한 대체 가능) 실제 객체(서버) 코드와 클라이언트 코드 변경 없이 유연하게 서버 대신 프록시 주입 가능 클라이언트는 서버에게 요청한 것인지 프록시에게 요청한 것인지 모름 프록시 객체는 내부에 실제 객체의 참조값을 가짐 (최종적으로 실제 객체 호출해야하므로) 프록시 패턴에서의 실제 객체 명칭: target 데코레이터 패턴에서의 실제 객체 명칭: component 프록시 체인 가능 클라이언트는 요청 후 여러 프록시가 여러 번 호출되어도 모름 중간 프록시 객체의 이점 접근 제어: 권한에 따른 접근 차단, 캐싱, 지연 로딩 부가 기능 추가: e.g. 요청 값/응답 값을 중간에 변형, 실행 시간 측정 로그 추가 프록시 패턴 & 데코레이터 패턴 모두 프록시를 사용하는 GOF 디자인 패턴이다. 둘은 의도에 따라 구분한다. 프록시 패턴: 접근 제어가 목적 데코레이터 패턴: 부가 기능 추가가 목적 프록시 패턴 (Proxy Pattern) 접근 제어를 목적으로 프록시를 사용하는 패턴 e.g. 권한에 따른 접근 차단, 캐싱, 지연 로딩 핵심: 실제 객체 코드와 클라이언트 코드를 전혀 변경하지 않고 프록시 도입만으로 접근 제어함 예시 코드 Subject 인터페이스 public interface Subject { String operation(); } ProxyPatternClient public class ProxyPatternClient { private Subject subject; public ProxyPatternClient(Subject subject) { this.subject = subject; } public void execute() { subject.operation(); } } RealSubject - target (e.g. 호출할 때마다 시스템에 큰 부하를 주는 데이터 조회) @Slf4j public class RealSubject implements Subject { @Override public String operation() { log.info("실제 객체 호출"); sleep(1000); //1초 걸림 return "data"; } private void sleep(int millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { e.printStackTrace(); } } } CacheProxy - Proxy (캐싱 통한 조회 성능 향상) @Slf4j public class CacheProxy implements Subject { private Subject target; // 실제 객체 private String cacheValue; // 캐시값 public CacheProxy(Subject target) { this.target = target; } @Override public String operation() { log.info("프록시 호출"); if (cacheValue == null) { cacheValue = target.operation(); } return cacheValue; } } ProxyPatternTest public class ProxyPatternTest { @Test void noProxyTest() { RealSubject realSubject = new RealSubject(); ProxyPatternClient client = new ProxyPatternClient(realSubject); client.execute(); // 1초 client.execute(); // 1초 client.execute(); // 1초 } @Test void cacheProxyTest() { Subject realSubject = new RealSubject(); Subject cacheProxy = new CacheProxy(realSubject); ProxyPatternClient client = new ProxyPatternClient(cacheProxy); client.execute(); // 1초 client.execute(); // 0초 client.execute(); // 0초 } } 캐싱: : 처음 조회 결과값(cacheValue)을 보관해 다음 조회를 매우 빠르게 만드는 성능 향상 기법 데코레이터 패턴 (Decorator Pattern) 부가 기능 추가를 목적으로 프록시를 사용하는 패턴 e.g. 요청 값/응답 값을 중간에 변형, 실행 시간 측정 로그 추가 핵심: 실제 객체 코드와 클라이언트 코드를 전혀 변경하지 않고 프록시 도입만으로 부가 기능 추가 참고: GOF 데코레이터 패턴 기본예제 GOF에서는 Decorator 추상 클래스를 통해 내부 component 중복까지 해결 데코레이터들이 내부에 호출 대상인 component를 가지고 항상 호출하는 부분이 계속 중복 따라서, component 속성을 가지고 있는 Decorator 추상 클래스 도입 효과: 내부 중복 해결 + 클래스 다이어그램에서 실제 컴포넌트와 데코레이터 구분 가능 예시 코드 Component 인터페이스 public interface Component { String operation(); } DecoratorPatternClient @Slf4j public class DecoratorPatternClient { private Component component; public DecoratorPatternClient(Component component) { this.component = component; } public void execute() { String result = component.operation(); log.info("result={}", result); } } RealComponent - component (실제 객체) @Slf4j public class RealComponent implements Component { @Override public String operation() { log.info("RealComponent 실행"); return "data"; } } MessageDecorator - Proxy (부가 기능 추가, 응답값 변형) @Slf4j public class MessageDecorator implements Component { private Component component; public MessageDecorator(Component component) { this.component = component; } @Override public String operation() { log.info("MessageDecorator 실행"); String result = component.operation(); String decoResult = "*****" + result + "*****"; log.info("MessageDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult); return decoResult; } } TimeDecorator - Proxy (부가 기능 추가, 호출 시간 측정) @Slf4j public class TimeDecorator implements Component { private Component component; public TimeDecorator(Component component) { this.component = component; } @Override public String operation() { log.info("TimeDecorator 실행"); long startTime = System.currentTimeMillis(); String result = component.operation(); long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("TimeDecorator 종료 resultTime={}ms", resultTime); return result; } } DecoratorPatternTest public class DecoratorPatternTest { @Test void decorator() { Component realComponent = new RealComponent(); Component messageDecorator = new MessageDecorator(realComponent); Component timeDecorator = new TimeDecorator(messageDecorator); DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator); client.execute(); } } 상황에 따른 로그 추적기 프록시 적용법 결론 프록시 적용은 인터페이스가 있든 없든 모두 대응할 수 있어야 함 인터페이스가 있는 편이 상속 제약에서 벗어나 프록시 적용하기 편리 다만, 실용적인 관점에서 인터페이스를 안 만드는 경우도 있고 이에 대응할 수 있어야 함 동적 프록시를 적용해야 함 만들어야 할 프록시 수가 너무 많음 똑같은 로직 적용인데 대상 클래스마다 프록시를 만들어야 함 상황 1: 인터페이스 있는 구체 클래스 - 스프링 빈 수동 등록 프록시는 인터페이스를 구현 프록시에서 로그 추적기 메서드 코드 실행하고 target 호출 프록시를 스프링 빈으로 등록 (프록시만 스프링 컨테이너에서 관리, 실제 객체는 프록시에 주입) 상황 2: 인터페이스 없는 구체 클래스 - 스프링 빈 수동 등록 프록시는 구체 클래스를 상속 프록시에서 로그 추적기 메서드 코드 실행하고 target 호출 프록시를 스프링 빈으로 등록 (프록시만 스프링 컨테이너에서 관리, 실제 객체는 프록시에 주입) 상속으로 인한 약간의 불편함 존재 기본 생성자 없을 시 부모 클래스 생성자 호출해야 함 클래스나 메서드에 final이 있을 시, 상속 혹은 오버라이딩 불가 상황 3: 스프링 빈 자동 등록 (컴포넌트 스캔) 리플렉션 (Reflection) @Slf4j public class ReflectionTest { // 어려운 공통화 (메서드 호출 부분 동적 처리가 어려움) @Test void reflection0() { Hello target = new Hello(); //공통 로직1 시작 log.info("start"); String result1 = target.callA(); //호출하는 메서드가 다름 log.info("result={}", result1); //공통 로직1 종료 //공통 로직2 시작 log.info("start"); String result2 = target.callB(); //호출하는 메서드가 다름 log.info("result={}", result2); //공통 로직2 종료 } // 리플렉션 활용 메서드 동적 호출 @Test void reflection() throws Exception { Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello"); Hello target = new Hello(); Method methodCallA = classHello.getMethod("callA"); dynamicCall(methodCallA, target); Method methodCallB = classHello.getMethod("callB"); dynamicCall(methodCallB, target); } private void dynamicCall(Method method, Object target) throws Exception { log.info("start"); Object result = method.invoke(target); log.info("result={}", result); } @Slf4j static class Hello { public String callA() { log.info("callA"); return "A"; } public String callB() { log.info("callB"); return "B"; } } } 사용 전략 일반적으로 사용하면 안됨 프레임워크 개발이나 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해 사용해야함 프록시의 경우 프록시 클래스 100개, 1000개를 없앨 수 있으니 이럴 때는 사용할만 함 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드를 동적으로 호출하는 기능 주요 메서드 Class.forName("클래스 경로 포함 이름") : 클래스 메타정보 획득 classHello.getMethod("메서드이름") 클래스의 메서드 메타정보 획득 소스코드로 박혀있던 메서드를 Method 클래스로 추상화해 동적으로 사용 가능 methodCallA.invoke(target) : 획득한 메서드 메타정보로 실제 인스턴스의 메서드를 호출 장점: 애플리케이션을 동적으로 유연하게 만들 수 있음 (e.g. 동적 호출 통해 공통 로직 뽑아내고 재사용) 단점: 런타임에 동작하므로, 컴파일 시점에 오류를 잡을 수 없음 인자로 사용하는 것이 문자열이고, 실수 여지가 높음 (타입 안정성 낮음) 컴파일 오류라는 발전을 역행하는 방식이므로 일반적으로 사용 X 동적 프록시 동적 프록시 프록시 객체를 동적으로 런타임에 생성하는 기술 덕분에 부가 기능 로직을 하나만 개발해 공통으로 적용 가능 프록시 클래스를 대상 클래스마다 수작업으로 만드는 문제 해결 단일 책임 원칙 지킴 (하나의 클래스에 부가 기능 로직 모음) JDK 동적 프록시 (자바 기본 제공) 인터페이스 기반으로 동적 프록시 생성 (대상 객체는 인터페이스 필수로 있어야 함) 개발자는 InvocationHandler만 개발 (프록시 클래스 개발 X) 사용 방법 InvocationHandler 인터페이스를 구현해 원하는 로직 적용 InvocationHandler 인터페이스 (JDK 동적 프록시 제공) public interface InvocationHandler { public Object invoke(Object proxy, Method method, Object[] args) throws Throwable; } Object proxy : 프록시 자신 Method method : 호출한 메서드 Object[] args : 메서드를 호출할 때 전달한 인수 구현 예시 @Slf4j public class TimeInvocationHandler implements InvocationHandler { private final Object target; //실제 객체 public TimeInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { log.info("TimeProxy 실행"); long startTime = System.currentTimeMillis(); Object result = method.invoke(target, args); long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("TimeProxy 종료 resultTime={}", resultTime); return result; } } 프록시 실행 //java.lang.reflect.Proxy @Slf4j public class JdkDynamicProxyTest { @Test void dynamicA() { AInterface target = new AImpl(); TimeInvocationHandler handler = new TimeInvocationHandler(target); AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[] {AInterface.class}, handler); proxy.call(); //targetClass=hello.proxy.jdkdynamic.code.AImpl //proxyClass=com.sun.proxy.$Proxy1 } } new TimeInvocationHandler(target): 동적 프록시에 적용할 핸들러 로직 Proxy.newProxyInstance(...): 동적 프록시 생성 생성된 프록시는 전달 받은 InvocationHandler 구현체의 로직을 실행 실제 실행 순서 클라이언트는 JDK 동적 프록시의 call() 실행 JDK 동적 프록시는 InvocationHandler.invoke() 를 호출 구현체인 TimeInvocationHandler 내부 로직을 수행 method.invoke(target, args) 호출해 target인 실제 객체(AImpl) 호출 AImpl 인스턴스의 call() 실행 AImpl 인스턴스의 call() 실행 끝나면 TimeInvocationHandler로 응답이 돌아옴 시간 로그를 출력하고 결과를 반환 CGLIB 동적 프록시 인터페이스 없어도 구체 클래스를 상속해 동적 프록시 생성 가능 (인터페이스 기반도 가능) 개발자는 MethodInterceptor만 개발 (프록시 클래스 개발 X) 제약 부모 클래스에 기본 생성자가 있어야 함 (동적 생성 위해) 클래스나 메서드에 final 붙으면 상속 및 오버라이드 불가 -> 프록시에서 예외 혹은 동작 불가 사용 방법 MethodInterceptor 인터페이스를 구현해 원하는 로직 적용 MethodInterceptor 인터페이스 (CGLIB 제공) public interface MethodInterceptor extends Callback { Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable; } obj : CGLIB가 적용된 객체 method : 호출된 메서드 args : 메서드를 호출하면서 전달된 인수 proxy : 메서드 호출에 사용 구현 예시 @Slf4j public class TimeMethodInterceptor implements MethodInterceptor { private final Object target; public TimeMethodInterceptor(Object target) { this.target = target; } @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { log.info("TimeProxy 실행"); long startTime = System.currentTimeMillis(); //참고로 Method 사용도 되지만 CGLIB은 성능상 MethodProxy 권장 Object result = proxy.invoke(target, args); long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("TimeProxy 종료 resultTime={}", resultTime); return result; } } 프록시 실행 @Slf4j public class CglibTest { @Test void cglib() { ConcreteService target = new ConcreteService(); Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(ConcreteService.class); enhancer.setCallback(new TimeMethodInterceptor(target)); ConcreteService proxy = (ConcreteService) enhancer.create(); proxy.call(); // targetClass=hello.proxy.common.service.ConcreteService // proxyClass=hello.proxy.common.service.ConcreteService$ $EnhancerByCGLIB$$25d6b0e3 } } Enhancer : CGLIB는 Enhancer 를 사용해서 프록시를 생성 enhancer.setSuperclass(ConcreteService.class) CGLIB는 구체 클래스를 상속 받아서 프록시 생성할 수 있음 (구체 클래스 지정) enhancer.setCallback(new TimeMethodInterceptor(target)) 프록시에 적용할 실행 로직을 할당 enhancer.create() : 프록시를 생성 클래스 이름 규칙: 대상클래스$$EnhancerByCGLIB$$임의코드 CGLIB (Code Generator Library) 바이트코드를 조작해 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리다. 본래 외부 라이브러리이지만, 스프링 내부 소스 코드에 포함되어 있다. 따라서, 스프링을 사용하면 별도 설정이 필요 없다. 또한, 개발자가 CGLIB을 직접 사용할 일은 거의 없기 때문에, 너무 깊게 갈 필요도 없다. 스프링 지원 프록시 - ProxyFactory 스프링이 지원하는 동적 프록시를 편리하게 만들어주는 기능 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존 X 인터페이스가 있으면 JDK 동적 프록시, 없으면 CGLIB을 사용 가능 (변경 가능, proxyTargetClass) 스프링은 Advice, Pointcut 개념 도입 개발자는 부가기능 로직으로 Advice만 개발 Advice는 프록시에 적용하는 부가 기능 로직 InvocationHandler, MethodInterceptor를 개념적으로 추상화 덕분에 개발자는 InvocationHandler, MethodInterceptor 중복 관리 필요 X 프록시 팩토리는 내부에서 JDK 동적 프록시인 경우 InvocationHandler가 Advice 호출하도록 개발 CGLIB인 경우 MethodInterceptor가 Advice 호출하도록 개발 Pointcut을 사용해 특정 조건에 따라 프록시 로직 적용 여부 컨트롤 proxyTargetClass 옵션 proxyTargetClass=true (스프링 부트 AOP 적용 디폴트) 인터페이스가 있어도 여부 상관없이 CGLIB 사용 - 구체 클래스 기반 프록시 proxyTargetClass=false 인터페이스가 있으면 JDK 동적 프록시 - 인터페이스 기반 프록시 인터페이스가 없으면 CGLIB 사용 - 구체 클래스 기반 프록시 기본 사용 방법 스프링 제공 MethodInterceptor 구현해 Advice 만들기 MethodInterceptor public interface MethodInterceptor extends Interceptor { Object invoke(MethodInvocation invocation) throws Throwable; } 스프링 AOP 모듈(spring-aop) 내 org.aopalliance.intercept 패키지 소속 상속 관계 MethodInterceptor는 Interceptor 상속 Interceptor는 Advice 인터페이스 상속 MethodInvocation invocation target 정보, 현재 프록시 객체 인스턴스, args, 메서드 정보 등 포함 구현 예시 @Slf4j public class TimeAdvice implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) throws Throwable { log.info("TimeProxy 실행"); long startTime = System.currentTimeMillis(); Object result = invocation.proceed(); //target 호출 long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("TimeProxy 종료 resultTime={}ms", resultTime); return result; } } 프록시 실행 @Slf4j public class ProxyFactoryTest { @Test @DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용") void interfaceProxy() { ServiceInterface target = new ServiceImpl(); ProxyFactory proxyFactory = new ProxyFactory(target); proxyFactory.addAdvice(new TimeAdvice()); ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); proxy.save(); assertThat(AopUtils.isAopProxy(proxy)).isTrue();//true assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();//true assertThat(AopUtils.isCglibProxy(proxy)).isFalse();//false } @Test @DisplayName("구체 클래스만 있으면 CGLIB 사용") void concreteProxy() { ConcreteService target = new ConcreteService(); ProxyFactory proxyFactory = new ProxyFactory(target); proxyFactory.addAdvice(new TimeAdvice()); ConcreteService proxy = (ConcreteService) proxyFactory.getProxy(); proxy.call(); assertThat(AopUtils.isAopProxy(proxy)).isTrue();//true assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();//false assertThat(AopUtils.isCglibProxy(proxy)).isTrue();//true } @Test @DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고, 클래스 기반 프록시 사용") void proxyTargetClass() { ServiceInterface target = new ServiceImpl(); ProxyFactory proxyFactory = new ProxyFactory(target); proxyFactory.setProxyTargetClass(true); //중요 proxyFactory.addAdvice(new TimeAdvice()); ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); proxy.save(); assertThat(AopUtils.isAopProxy(proxy)).isTrue();//true assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();//false assertThat(AopUtils.isCglibProxy(proxy)).isTrue();//true } } new ProxyFactory(target) 프록시 팩토리를 생성 시, 생성자에 target 객체 전달 프록시 팩토리는 target 인스턴스 정보 기반으로 프록시 생성 만약 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시 생성 proxyFactory.addAdvice(new TimeAdvice()) 생성할 프록시가 사용할 부가 기능 로직을 설정 (Advice) JDK 동적 프록시가 제공하는 InvocationHandler 와 CGLIB가 제공하는 MethodInterceptor 의 개념과 유사 proxyFactory.getProxy() : 프록시 객체를 생성하고 반환 포인트컷, 어드바이스, 어드바이저 포인트컷(Pointcut) public interface Pointcut { ClassFilter getClassFilter(); MethodMatcher getMethodMatcher(); } public interface ClassFilter { boolean matches(Class<?> clazz); } public interface MethodMatcher { boolean matches(Method method, Class<?> targetClass); //.. } 부가 기능 적용 여부를 판단하는 필터링 로직 (어디에?) 주로 클래스와 메서드 이름으로 필터링 포인트 컷은 크게 ClassFilter와 MethodMatcher로 구성 클래스가 맞는지, 메서드가 맞는지 확인하고 둘 다 true일 경우 어드바이스 적용 직접 만들진 않고 보통 스프링 구현체 사용 AspectJExpressionPointcut : aspectJ 표현식으로 매칭 (실무 단독 사용) NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭 (PatternMatchUtils 사용) JdkRegexpMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭 TruePointcut : 항상 참을 반환 AnnotationMatchingPointcut : 애노테이션으로 매칭 어드바이스(Advice) 프록시가 호출하는 부가 기능 (어떤 로직?) 어드바이저(Advisor) 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것 (포인트컷1 + 어드바이스1) 프록시 팩토리 사용 시 어드바이저 제공 가능 유의점: 하나의 target에 여러 AOP 적용 시 스프링 AOP는 target 마다 하나의 프록시만 생성 (여러 프록시 X, 성능 최적화) 하나의 프록시에 여러 어드바이저를 적용 예시 코드 1 - 기본 사용 @Slf4j public class AdvisorTest { @Test void advisorTest1() { ServiceInterface target = new ServiceImpl(); ProxyFactory proxyFactory = new ProxyFactory(target); DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice()); proxyFactory.addAdvisor(advisor); ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); proxy.save(); proxy.find(); } @Test @DisplayName("직접 만든 포인트컷") void advisorTest2() { ServiceImpl target = new ServiceImpl(); ProxyFactory proxyFactory = new ProxyFactory(target); DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice()); proxyFactory.addAdvisor(advisor); ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); proxy.save(); proxy.find(); } @Test @DisplayName("스프링이 제공하는 포인트컷") void advisorTest3() { ServiceImpl target = new ServiceImpl(); ProxyFactory proxyFactory = new ProxyFactory(target); NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); pointcut.setMappedNames("save"); DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice()); proxyFactory.addAdvisor(advisor); ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); proxy.save(); proxy.find(); } static class MyPointcut implements Pointcut { @Override public ClassFilter getClassFilter() { return ClassFilter.TRUE; } @Override public MethodMatcher getMethodMatcher() { return new MyMethodMatcher(); } } static class MyMethodMatcher implements MethodMatcher { private String matchName = "save"; @Override public boolean matches(Method method, Class<?> targetClass) { boolean result = method.getName().equals(matchName); return result; } //false인 경우 클래스의 정적 정보만 사용, 스프링이 내부에서 캐싱 통해 성능 향상 가능 //true인 경우 매개변수가 동적으로 변경된다고 가정, 캐싱 X @Override public boolean isRuntime() { return false; } @Override public boolean matches(Method method, Class<?> targetClass, Object... args) { throw new UnsupportedOperationException(); } } } new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice()); Advisor 인터페이스의 가장 일반적인 구현체 생성자에 포인트컷과 어드바이스 전달 proxyFactory.addAdvisor(advisor) 프록시 팩토리에 적용할 어드바이저를 지정 프록시 팩토리를 사용할 때 어드바이저는 필수 예시 코드 2 - 여러 어드바이저 적용 프록시 여러 개 만들기 (해결책 X, 프록시 수가 계속 늘어남) public class MultiAdvisorTest { @Test @DisplayName("여러 프록시") void multiAdvisorTest1() { //client -> proxy2(advisor2) -> proxy1(advisor1) -> target //프록시1 생성 ServiceInterface target = new ServiceImpl(); ProxyFactory proxyFactory1 = new ProxyFactory(target); DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1()); proxyFactory1.addAdvisor(advisor1); ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy(); //프록시2 생성, target -> proxy1 입력 ProxyFactory proxyFactory2 = new ProxyFactory(proxy1); DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2()); proxyFactory2.addAdvisor(advisor2); ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy(); //실행 proxy2.save(); //결과 //MultiAdvisorTest$Advice2 - advice2 호출 //MultiAdvisorTest$Advice1 - advice1 호출 //ServiceImpl - save 호출 } @Slf4j static class Advice1 implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) throws Throwable { log.info("advice1 호출"); return invocation.proceed(); } } @Slf4j static class Advice2 implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) throws Throwable { log.info("advice2 호출"); return invocation.proceed(); } } } 프록시 하나에 여러 어드바이저를 적용 (해결책 O) @Test @DisplayName("하나의 프록시, 여러 어드바이저") void multiAdvisorTest2() { //proxy -> advisor2 -> advisor1 -> target DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2()); DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1()); ServiceInterface target = new ServiceImpl(); ProxyFactory proxyFactory1 = new ProxyFactory(target); proxyFactory1.addAdvisor(advisor2); //추가 proxyFactory1.addAdvisor(advisor1); //추가 ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy(); //실행 proxy.save(); //결과 //MultiAdvisorTest$Advice2 - advice2 호출 //MultiAdvisorTest$Advice1 - advice1 호출 //ServiceImpl - save 호출 } 프록시 팩토리에 원하는 만큼 addAdvisor() 호출로 어드바이저 등록 등록하는 순서대로 advisor 가 호출 (여기서는 advisor2 , advisor1 순서) 여러 프록시 사용과 결과는 같고, 성능은 더 좋음 빈 후처리기 (BeanPostProcessor) 스프링 빈 등록 위해 생성한 객체를 빈 저장소 등록 직전에 조작하는 기능 (후킹 포인트, Hooking) 객체 조작 (setXxx…) 완전히 다른 객체로 바꿔치기 모든 빈 등록 후킹 가능 (수동 빈 등록 & 컴포넌트 스캔) -> 컴포넌트 스캔 빈도 프록시 적용 가능 BeanPostProcessor 인터페이스 - 스프링 제공 public interface BeanPostProcessor { Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException } 인터페이스를 구현하고 스프링 빈으로 등록하면 스프링 컨테이너가 빈 후처리기로 인식하고 동작 postProcessBeforeInitialization 객체 생성 이후 @PostConstruct 같은 초기화가 발생하기 전에 호출되는 포스트 프로세서 postProcessAfterInitialization 객체 생성 이후 @PostConstruct 같은 초기화가 발생한 다음에 호출되는 포스트 프로세서 빈 등록 과정 (feat. 빈 후처리기) 생성: 스프링 빈 대상이 되는 객체를 생성 (@Bean , 컴포넌트 스캔 모두 포함) 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달 후 처리 작업: 빈 후처리기는 전달된 스프링 빈 객체를 조작하거나 다른 객체로 바뀌치기 등록: 빈 후처리기는 빈을 반환 (반환된 빈이 빈 저장소에 등록됨) 예시 코드 public class BeanPostProcessorTest { @Test void postProcessor() { ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class); //beanA 이름으로 B 객체가 빈으로 등록된다. B b = applicationContext.getBean("beanA", B.class); b.helloB(); //A는 빈으로 등록되지 않는다. Assertions.assertThrows(NoSuchBeanDefinitionException.class, () -> applicationContext.getBean(A.class)); } @Slf4j @Configuration static class BeanPostProcessorConfig { @Bean(name = "beanA") public A a() { return new A(); } @Bean public AToBPostProcessor helloPostProcessor() { return new AToBPostProcessor(); } } @Slf4j static class A { public void helloA() { log.info("hello A"); } } @Slf4j static class B { public void helloB() { log.info("hello B"); } } @Slf4j static class AToBPostProcessor implements BeanPostProcessor { @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof A) { return new B(); } return bean; } } } @PostConstruct와 빈 후처리기 @PostConstruct는 빈 생성 이후 빈 초기화 역할을 하는데, 이 역시도 빈 후처리기와 함께 동작된다. 사실, 스프링은 CommonAnnotationBeanPostProcessor 라는 빈 후처리기를 자동으로 등록하는데, 여기에서 @PostConstruct 애노테이션이 붙은 메서드를 호출한다. 즉, 스프링 스스로도 스프링 내부의 기능을 확장하기 위해 빈 후처리기를 사용한다. 스프링 제공 빈 후처리기 핵심: 개발자는 Advisor만 스프링 빈으로 등록하면 됨 AnnotationAwareAspectJAutoProxyCreator - 자동 프록시 생성기 자동으로 프록시를 생성해주는 빈 후처리기 크게 2가지 기능 @Aspect를 모두 찾아서 Advisor로 변환해 저장 (AnnotationAware인 이유) 스프링 빈으로 등록된 Advisor들을 찾아서 필요한 곳에 프록시 적용 스프링 부트가 스프링 빈 자동 등록 라이브러리 추가 필요 implementation 'org.springframework.boot:spring-boot-starter-aop' aspectjweaver 등록 (aspectJ 관련 라이브러리) 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록 과거에 @EnableAspectJAutoProxy 직접 사용하던 작업을 대신 자동 처리 작동 과정 - 자동 프록시 생성기 (빈 후처리기) @Aspect를 어드바이저로 변환해 저장 실행: 스프링 애플리케이션 로딩 시점에 자동 프록시 생성기를 호출 모든 @Aspect 빈 조회 자동 프록시 생성기는 스프링 컨테이너에서 @Aspect 붙은 스프링 빈 모두 조회 어드바이저 생성 @Aspect 어드바이저 빌더 통해 @Aspect 애노테이션 정보 기반으로 어드바이저 생성 어드바이저 저장: 생성한 어드바이저를 @Aspect 어드바이저 빌더 내부에 저장 참고: @Aspect 어드바이저 빌더 (BeanFactoryAspectJAdvisorsBuilder) @Aspect 의 정보를 기반으로 포인트컷, 어드바이스, 어드바이저를 생성하고 보관 생성한 어드바이저는 빌더 내부 저장소에 캐시 (보관) 어드바이저 기반으로 프록시 생성 생성: 스프링이 스프링 빈 대상이 되는 객체를 생성 (@Bean , 컴포넌트 스캔 모두 포함) 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달 모든 Advisor 조회 모든 Advisor 빈 조회 빈 후처리기는 스프링 컨테이너에서 모든 Advisor 빈 조회 모든 @Aspect 기반 Advisor 조회 빈 후처리기는 @Aspect 어드바이저 빌더 내부에 저장된 모든 Advisor를 조회 프록시 적용 대상 체크 조회한 Advisor 내 포인트컷을 사용해 해당 객체가 프록시를 적용할 대상인지 아닌지 판단 객체의 클래스 정보와 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 매칭 모든 메서드를 비교해 조건이 하나라도 만족하면 프록시 적용 대상 e.g. 10개의 메서드 중에 하나만 포인트컷 조건에 만족해도 프록시 적용 대상 만약 Advisor가 여러개고 포인트컷 조건을 다 만족해도 프록시는 단 하나만 생성 프록시 팩토리가 생성하는 프록시는 내부에 여러 Advisor를 포함 가능하므로! e.g. advisor1 의 포인트컷만 만족 -> 프록시 1개 생성, 프록시에 advisor1 만 포함 advisor1 , advisor2 의 포인트컷 모두 만족 -> 프록시 1개 생성, 프록시에 advisor1 , advisor2 모두 포함 advisor1 , advisor2 의 포인트컷 모두 만족 X -> 프록시 생성 X 프록시 생성 프록시 적용 대상이면 프록시를 생성하고 반환해 프록시를 스프링 빈으로 등록 프록시 적용 대상이 아니라면 원본 객체를 반환해 원본 객체를 스프링 빈으로 등록 빈 등록: 반환된 객체는 스프링 빈으로 등록 참고: 실제 포인트컷의 역할은 2가지 프록시 적용 여부 판단 - 생성 단계 (빈 후처리기에 쓰임) 해당 빈이 프록시를 생성할 필요가 있는지 없는지 체크 클래스 + 메서드 조건을 모두 비교, 모든 메서드를 포인트컷 조건에 하나하나 매칭 조건에 맞는 것이 하나라도 있으면 프록시 생성 O 조건에 맞는 것이 하나도 없으면 프록시 생성 X e.g. orderControllerV1 내 request() , noLog() 메서드 존재 request()가 조건에 만족하므로 프록시 생성 어드바이스 적용 여부 판단 - 사용 단계 프록시가 호출되었을 때 부가 기능인 어드바이스를 적용할지 말지 판단 e.g. orderControllerV1은 이미 프록시가 걸려있음 request()는 포인트컷 조건 만족, 프록시는 어드바이스 먼저 호출 후 target 호출 noLog()는 포인트컷 조건 만족 X, 프록시는 바로 target만 호출 @Aspect 어드바이저 생성을 편리하게 지원 애노테이션 기반 프록시 적용에 필요 관점 지향 프로그래밍을 지원하는 AspectJ 프로젝트에서 제공하는 애노테이션 스프링은 이를 차용해 프록시를 통한 AOP 지원 횡단 관심사(cross-cutting concerns) 해결에 초점 e.g. 로그 추적기 @Around @Around의 메서드는 어드바이스가 됨 @Around의 값은 포인트컷이 됨 (AspectJ 표현식 사용) @Aspect 클래스 하나에 @Around 메서드 2개 -> Advisor 2개가 만들어짐! 예시 코드 LogTraceAspect @Slf4j @Aspect public class LogTraceAspect { private final LogTrace logTrace; public LogTraceAspect(LogTrace logTrace) { this.logTrace = logTrace; } @Around("execution(* hello.proxy.app..*(..))") public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { TraceStatus status = null; //log.info("target={}", joinPoint.getTarget()); //실제 호출 대상 //log.info("getArgs={}", joinPoint.getArgs()); //전달인자 //log.info("{}", joinPoint.getSignature()); //시그니처 try { String message = joinPoint.getSignature().toShortString(); status = logTrace.begin(message); //로직 호출 Object result = joinPoint.proceed(); //실제 대상(target) 호출 logTrace.end(status); return result; } catch (Exception e) { logTrace.exception(status, e); throw e; } } } ProceedingJoinPoint joinPoint 내부에 실제 호출 대상, 전달 인자, 어떤 객체와 어떤 메서드 호출되었는지 정보 포함 스프링 빈 등록 (@Import나 컴포넌트 스캔으로 등록해도 괜찮음) @Configuration @Import({AppV1Config.class, AppV2Config.class}) public class AopConfig { @Bean public LogTraceAspect logTraceAspect(LogTrace logTrace) { return new LogTraceAspect(logTrace); } } 관점 지향 프로그래밍 (AOP, Aspect-Oriented Programming) 애플리케이션 로직 분류 핵심 기능: 해당 객체가 제공하는 고유 기능 e.g. OrderService의 핵심 기능은 주문 로직 부가 기능: 핵심 기능을 보조하기 위해 제공하는 기능 e.g. 로그 추적 로직, 트랜잭션 기능 문제 부가 기능은 횡단 관심사 (cross-cutting concerns) - 여러 클래스에서 동일하게 사용됨 부가 기능을 애플리케이션 전반에 적용하는 문제는 일반적인 OOP로 해결이 어려움 변경 지점 모듈화 불가능 부가기능 적용 시 문제 100개 클래스라면 100개에 다 적용해야함 유틸리티 클래스를 만들어도 유틸리티 호출 코드가 필요 try~catch~finally 구조가 필요하면 더욱 복잡 부가기능 수정 시 문제 100개 클래스라면 100개를 다 수정해야 함 해결책: 관점 지향 프로그래밍 (AOP) 애스펙트(관점)를 사용한 프로그래밍 방식 애플리케이션을 바라보는 관점을 개별 기능에서 횡단 관심사 관점으로 달리 보는 것 OOP의 부족한 부분(횡단 관심사 처리)을 보조하는 목적으로 개발됨 애스펙트(Aspect) 부가 기능을 핵심 기능에서 분리해 한 곳에서 관리하고 어디에 적용할지 정의 구현 예: @Aspect, Advisor AOP 구현 예: AspectJ 프레임워크, 스프링 AOP AspectJ 프레임워크 자바 프로그래밍 언어에 대한 완벽한 관점 지향 확장 횡단 관심사의 깔끔한 모듈화 오류 검사 및 처리, 동기화, 성능 최적화(캐싱), 모니터링 및 로깅 스프링 AOP AspectJ 직접 사용 X 대부분 AspectJ의 문법 차용 AspectJ가 제공하는 기능 중 실무에 쓸만한 일부 기능만 취해서 제공 AOP 적용 방식 (AspectJ 사용 시 가능한 방법들) 컴파일 시점 (위빙) - 잘 사용 X 컴파일 시점에 애스펙트 관련 호출 코드 실제 삽입 (.class 디컴파일 시 확인 가능) AspectJ가 제공하는 특별한 컴파일러 사용 컴파일러는 Aspect를 확인해 해당 클래스가 적용 대상인지 확인 후 부가 기능 로직 적용 단점 특별한 컴파일러가 필요하고 복잡 AspectJ 직접 사용해야 함 클래스 로딩 시점 (로드 타임 위빙) - 잘 사용 X 클래스 로딩 시점에 바이트 코드를 수정해 애스펙트 관련 호출 코드 실제 삽입 자바를 실행하면 자바 언어는 .class 파일을 JVM 내부의 클래스 로더에 보관 JVM에 저장 전에 .class를 조작할 수 있는 기능 활용 (Java Instrumentation) 수 많은 자바 모니터링 툴들이 이 방식을 사용 단점 자바 실행 시 옵션 설정이 필요해 번거롭고 운영하기 어려움 특별한 옵션(java -javaagent)을 통해 클래스 로더 조작기를 지정 필요 AspectJ 직접 사용해야 함 런타임 시점 (런타임 위빙, 프록시) - 스프링 AOP 방식 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 시점 스프링 컨테이너, 프록시와 DI, 빈 포스트 프로세서 등을 통해 애스펙트 적용 (코드 삽입 X) 장점 스프링만 있으면 얼마든지 AOP를 적용 가능 특별한 컴파일러 필요 X, 옵션 필요 X, AspectJ 필요 X AspectJ는 러닝커브가 높아서 스프링 AOP 만으로 대부분 충분 단점 프록시를 사용하므로 메서드 실행 지점에만 AOP 적용 가능 (조인 포인트 제한) final 클래스나 메서드에 AOP 적용 불가 생성자나 static 메서드, 필드 값 접근에 대해서 AOP 적용 불가 스프링 컨테이너에서 관리할 수 있는 스프링 빈에만 AOP 적용 가능 AOP 용어 정리 조인 포인트(Join point) AOP를 적용할 수 있는 모든 지점 (추상적인 개념) 어드바이스가 적용될 수 있는 위치 e.g. 메서드 실행, 생성자 호출, 필드 값 접근, static 메서드 접근 (프로그램 실행 중 지점) 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메서드 실행 지점으로 제한 AspectJ는 바이트 코드 조작이 가능해 모든 지점에 AOP 적용 가능 포인트컷(Pointcut) 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능 주로 AspectJ 표현식을 사용해 지정 프록시를 사용하는 스프링 AOP는 메서드 실행 지점만 포인트컷으로 선별 가능 타겟(Target) 어드바이스를 받는 실제 객체 어드바이스(Advice) 부가 기능 특정 조인 포인트에서 Aspect에 의해 취해지는 조치 Around(주변), Before(전), After(후)와 같은 다양한 종류의 어드바이스가 있음 애스펙트(Aspect) 어드바이스 + 포인트컷을 모듈화 한 것 @Aspect, Advisor 하나의 Aspect에 여러 어드바이스와 포인트 컷이 함께 존재 가능 어드바이저(Advisor) 하나의 어드바이스와 하나의 포인트 컷으로 구성 스프링 AOP에서만 사용되는 특별한 용어 위빙(Weaving) 원본 로직에 부가 기능 로직 추가하는 것 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 것 핵심 기능 코드에 영향 주지 않고 부가 기능 추가 가능 시점에 따른 종류 컴파일 타임 (AspectJ compiler) 로드 타임 런타임 - 스프링 AOP는 런타임, 프록시 방식 AOP 프록시 AOP 기능을 구현하기 위해 만든 프록시 객체 스프링 AOP 프록시 JDK 동적 프록시 CGLIB 프록시 AspectJ와 스프링 AOP spring-boot-starter-aop 라이브러리를 사용하면, AspectJ의 인터페이스 및 껍데기 등을 차용해 사용하지만, 프레임워크 자체는 사용하지 않는다. 실제로 @Aspect를 포함한 org.aspectj 패키지 관련 기능은 aspectjweaver.jar 라이브러리가 제공하는 것이지만, 스프링 AOP와 함께 사용할 수 있게 의존관계에 포함된다. 하지만, AspectJ가 제공하는 애노테이션이나 관련 인터페이스만 사용하고 컴파일, 로드타임 위버 등은 사용하지 않는다. 스프링 AOP는 독립적으로 프록시 방식 AOP를 사용한다. 스프링 AOP 사용법 기본 사용법 @Slf4j @Aspect public class AspectV1 { //hello.aop.order 패키지와 하위 패키지 @Around("execution(* hello.aop.order..*(..))") public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { //join point 시그니처 log.info("[log] {}", joinPoint.getSignature()); return joinPoint.proceed(); } } 활용법 1 - 포인트 컷 분리하기 @Slf4j @Aspect public class AspectV2 { //hello.aop.order 패키지와 하위 패키지 @Pointcut("execution(* hello.aop.order..*(..))") private void allOrder(){} //클래스 이름 패턴이 *Service @Pointcut("execution(* *..*Service.*(..))") private void allService(){} @Around("allOrder()") public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { log.info("[log] {}", joinPoint.getSignature()); return joinPoint.proceed(); } //hello.aop.order 패키지와 하위 패키지 이면서 클래스 이름 패턴이 *Service @Around("allOrder() && allService()") public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable try { log.info("[트랜잭션 시작] {}", joinPoint.getSignature()); Object result = joinPoint.proceed(); log.info("[트랜잭션 커밋] {}", joinPoint.getSignature()); return result; } catch (Exception e) { log.info("[트랜잭션 롤백] {}", joinPoint.getSignature()); throw e; } finally { log.info("[리소스 릴리즈] {}", joinPoint.getSignature()); } } } @Pointcut 포인트컷 시그니처 (signature): 메서드 이름과 파라미터를 합친 것 e.g. allOrder() 메서드의 반환 타입은 void 여야 하고 코드 내용은 비워둬야 함 @Around 어드바이스에서는 포인트컷 시그니처도 사용 가능 && (AND), || (OR), ! (NOT) 3가지로 포인트컷 조합 가능 장점 하나의 포인트컷 표현식을 여러 어드바이스에서 함께 사용 가능 다른 클래스에 있는 외부 어드바이스에서도 포인트컷을 함께 사용 가능 활용법 2 - 포인트컷 공용 클래스 만들기 포인트컷 공용 클래스 public class Pointcuts { //hello.springaop.app 패키지와 하위 패키지 @Pointcut("execution(* hello.aop.order..*(..))") public void allOrder(){} //타입 패턴이 *Service @Pointcut("execution(* *..*Service.*(..))") public void allService(){} //allOrder && allService @Pointcut("allOrder() && allService()") public void orderAndService(){} } orderAndService(): allOrder()와 allService() 포인트컷 조합 가능 Aspect @Slf4j @Aspect public class AspectV4Pointcut { @Around("hello.aop.order.aop.Pointcuts.allOrder()") public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { log.info("[log] {}", joinPoint.getSignature()); return joinPoint.proceed(); } @Around("hello.aop.order.aop.Pointcuts.orderAndService()") public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable { try { log.info("[트랜잭션 시작] {}", joinPoint.getSignature()); Object result = joinPoint.proceed(); log.info("[트랜잭션 커밋] {}", joinPoint.getSignature()); return result; } catch (Exception e) { log.info("[트랜잭션 롤백] {}", joinPoint.getSignature()); throw e; } finally { log.info("[리소스 릴리즈] {}", joinPoint.getSignature()); } } } 사용법: 패키지명을 포함한 클래스 이름과 포인트컷 시그니처를 모두 지정 활용법 3 - 어드바이스 적용 순서 조정하기 @Slf4j public class AspectV5Order { @Aspect @Order(2) public static class LogAspect { @Around("hello.aop.order.aop.Pointcuts.allOrder()") public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { log.info("[log] {}", joinPoint.getSignature()); return joinPoint.proceed(); } } @Aspect @Order(1) public static class TxAspect { @Around("hello.aop.order.aop.Pointcuts.orderAndService()") public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable { try { log.info("[트랜잭션 시작] {}", joinPoint.getSignature()); Object result = joinPoint.proceed(); log.info("[트랜잭션 커밋] {}", joinPoint.getSignature()); return result; } catch (Exception e) { log.info("[트랜잭션 롤백] {}", joinPoint.getSignature()); throw e; } finally { log.info("[리소스 릴리즈] {}", joinPoint.getSignature()); } } } } @Aspect 단위로 @Order 애노테이션 적용 (클래스 단위) 하나의 애스펙트에 여러 어드바이스가 있으면 순서 보장 X (분리 필요) e.g. LogAspect , TxAspect 애스펙트로 각각 분리 숫자가 작을수록 먼저 실행 (@Order) e.g TxAspect가 먼저 실행되고 LogAspect 실행 활용법 4 - 다양한 어드바이스 종류 활용하기 @Slf4j @Aspect public class AspectV6Advice { @Around("hello.aop.order.aop.Pointcuts.orderAndService()") public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable { try { //@Before log.info("[around][트랜잭션 시작] {}", joinPoint.getSignature()); Object result = joinPoint.proceed(); //@AfterReturning log.info("[around][트랜잭션 커밋] {}", joinPoint.getSignature()); return result; } catch (Exception e) { //@AfterThrowing log.info("[around][트랜잭션 롤백] {}", joinPoint.getSignature()); throw e; } finally { //@After log.info("[around][리소스 릴리즈] {}", joinPoint.getSignature()); } } @Before("hello.aop.order.aop.Pointcuts.orderAndService()") public void doBefore(JoinPoint joinPoint) { log.info("[before] {}", joinPoint.getSignature()); } @AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()", returning = "result") public void doReturn(JoinPoint joinPoint, Object result) { log.info("[return] {} return={}", joinPoint.getSignature(), result); } @AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex") public void doThrowing(JoinPoint joinPoint, Exception ex) { log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage()); } @After(value = "hello.aop.order.aop.Pointcuts.orderAndService()") public void doAfter(JoinPoint joinPoint) { log.info("[after] {}", joinPoint.getSignature()); } } @Around 이외의 어드바이스가 존재하는 이유 @Before, @After 등은 실수할 가능성이 적고 코드 작성 의도가 명확히 드러남 좋은 설계는 제약이 있는 것 (실수를 미연에 방지) @Around는 실수할 가능성 존재 실수로 joinPoint.proceed() 호출하지 않을 가능성 O 타겟 호출 X -> 치명적 버그 발생 어드바이스 종류 @Around 메서드 호출 전후에 수행 조인포인트 실행여부 선택, 전달값 및 반환값 변환, 예외변환, try 구문처리 가능 가장 강력한 어드바이스, 모든 기능 사용 가능 @Before : 조인 포인트 실행 이전에 실행 @AfterReturning 조인 포인트 정상 완료 후 실행 returning 속성값 = 어드바이스 메서드의 매개변수 이름 returning 절에 지정된 타입의 값을 반환하는 메서드만 대상으로 실행 e.g. 지정타입이 String이면 void를 리턴하는 서비스는 종료 후 doReturn 호출 X String을 반환하는 레포지토리는 종료 후 doReturn 호출 O void 메서드이므로 반환되는 객체 변경 불가능 (조작은 가능, setter) @AfterThrowing 메서드가 예외를 던지는 경우 실행 throwing 속성값 = 어드바이스 메서드의 매개변수 이름 throwing 절에 지정된 타입과 맞는 예외를 대상으로 실행 @After 조인 포인트의 정상 또는 예외에 관계없이 실행 (finally) 일반적으로 리소스를 해제하는데 사용 ProceedingJoinPoint 인터페이스 JoinPoint의 하위 타입 주요 기능: getArgs(), getThis(), getTarget(), getSignature() … 주요 기능 proceed() : 다음 어드바이스나 타겟 호출 @Around는 필수, 다른 어드바이스는 생략 가능 실행 순서 동일한 Aspect 안에서 동일한 조인포인트에 대해 실행 우선순위 적용 (스프링 5.2.7) 물론, @Aspect 내 동일한 종류의 어드바이스가 2개 있으면 순서 보장 X (분리 필요) 실행순서: @Around, @Before, @After, @AfterReturning, @AfterThrowing 스프링 AOP 포인트컷 사용법 AspectJ포인트컷을 편리하게 표현하기 위한 포인트컷 표현식을 제공 공통 문법 ?: 생략 가능 *: 어떤 값이 들어와도 가능 패키지 .: 정확하게 해당 위치의 패키지 ..: 해당 위치의 패키지와 그 하위 패키지도 포함 메서드 파라미터 (String) : 정확하게 String 타입 파라미터 하나 () : 파라미터가 없어야 함 (*) : 정확히 하나의 파라미터, 단 모든 타입 허용 (*, *) : 정확히 두 개의 파라미터, 단 모든 타입 허용 (..) : 숫자와 무관하게 모든 파라미터, 모든 타입 허용 (파라미터가 없어도 허용) e.g. (), (Xxx), (Xxx, Xxx) (String, ..) : String 타입으로 시작, 이후 숫자와 무관하게 모든 파라미터, 모든 타입 허용 e.g. (String) , (String, Xxx) , (String, Xxx, Xxx) 허용 포인트컷 지시자 (Pointcut Designator, PCD) execution (가장 많이 사용, 나머지는 자주 사용 X) 메서드 실행 조인 포인트를 매칭 Syntax execution(접근제어자? 반환타입 선언타입?메서드이름(파라미터) 예외?) 선언타입 = 패키지 + 타입 + 메서드 이름 e.g. hello.aop.member.*(1).*(2) - (1): 타입 (2): 메서드 이름 e.g. 가장 세밀한 포인트컷 "execution(public String hello.aop.member.MemberServiceImpl.hello(String))" 가장 많이 생략한 포인트컷 "execution(* *(..))" //접근제어자, 선언타입, 예외 생략 메서드 이름 매칭 포인트컷 "execution(* *el*(..))" 패키지 매칭 포인트컷 "execution(* hello.aop.member.*.*(..))" "execution(* hello.aop.member..*.*(..))" "execution(* hello.aop..*.*(..))" 실패 케이스 - "execution(* hello.aop.*.*(..))" //지정 패키지에는 없음 타입 매칭 포인트컷 "execution(* hello.aop.member.MemberServiceImpl.*(..))" "execution(* hello.aop.member.MemberService.*(..))" 부모타입 지정 가능 주의점: 부모 타입에서 선언한 메서드가 자식 타입에 있어야 매칭에 성공 파라미터 매칭 포인트컷 "execution(* *(String))" "execution(* *())" // 파라미터 없는 메서드 매칭 "execution(* *(*))" // 정확히 하나의 파라미터 허용, 모든 타입 가능 "execution(* *(..))" // 개수 무관 모든 파라미터 및 타입 허용 "execution(* *(String, ..))" // String 타입으로 시작, 모두 허용 within (거의 사용 X) 특정 타입 내의 조인 포인트를 매칭 (execution의 타입 부분) 타입을 매칭해서 성공하면 그 안의 메서드(조인 포인트)들을 자동으로 모두 매칭 표현식에 부모 타입 지정 불가 (execution과의 차이) e.g. "within(hello.aop.member.MemberServiceImpl)" "within(hello.aop.member.*Service*)" "within(hello.aop..*)" args (단독 사용 X, 파라미터 바인딩에서 주로 사용) 주어진 타입의 인스턴스에 인자가 매칭되면 조인 포인트 매칭 (execution의 파라미터 부분) 실제 넘어온 파라미터 객체 인스턴스를 보고 판단 (동적, 부모 타입 허용) execution은 딱 일치해야 함 (정적, 부모 타입 허용 X) e.g. "args(String)", "args(Object)", "args(java.io.Serializable)" "args()", "args(*)" "args(..)", "args(String, ..)" this (거의 사용 X, 이해 안되도 괜찮음) 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트 * 등의 패턴 말고 정확한 타입 하나를 지정해야 함 (부모 타입 허용) e.g. this(hello.aop.member.MemberService) 유의점: JDK 동적 프록시가 대상일 경우, 표현식에 구체 클래스를 지정하면 AOP 적용에 실패 target (거의 사용 X, 이해 안되도 괜찮음) Target 객체(스프링 AOP 프록시가 가리키는 실제 대상)를 대상으로 하는 조인 포인트 * 등의 패턴 말고 정확한 타입 하나를 지정해야 함 (부모 타입 허용) e.g. target(hello.aop.member.MemberService) @target (단독 사용 X, 파라미터 바인딩에서 주로 사용) 주어진 타입의 애노테이션이 있는 타입을 찾아 매칭 부모 클래스 메서드를 포함해 인스턴스의 모든 메서드에 어드바이스 적용 e.g. "execution(* hello.aop..*(..)) && @target(hello.aop.member.annotation.ClassAop)" // @ClassAop @within (단독 사용 X, 파라미터 바인딩에서 주로 사용) 주어진 타입의 애노테이션이 있는 타입을 찾아 매칭 자기 자신 클래스에 정의된 메서드에만 어드바이스 적용 e.g. "execution(* hello.aop..*(..)) && @within(hello.aop.member.annotation.ClassAop)" // @ClassAop @annotation 주어진 애노테이션을 가지고 있는 메서드를 찾아 매칭 e.g. "@annotation(hello.aop.member.annotation.MethodAop)" //@MethodAop @args (거의 사용 X) 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트 e.g. @args(test.Check) 전달된 인수의 런타임 타입에 @Check 애노테이션이 있는 경우에 매칭 bean (거의 사용 X) 스프링 전용 포인트컷 지시자, 빈 이름으로 포인트컷을 지정 e.g. "bean(orderService) || bean(*Repository)" 매개변수 전달 포인트컷 표현식을 사용하면 여러 정보를 어드바이스에 매개변수로 전달 가능 물론, 이 방법 말고 단순히 ProceedingJoinPoint로 접근 가능한 정보도 많음 규칙 포인트컷의 이름과 매개변수의 이름을 맞추어야 함 타입은 메서드에 지정한 타입으로 제한 e.g. this, target, args, @target, @within,@annotation, @args @Slf4j @Import(ParameterTest.ParameterAspect.class) @SpringBootTest public class ParameterTest { @Autowired MemberService memberService; @Test void success() { log.info("memberService Proxy={}", memberService.getClass()); memberService.hello("helloA"); } @Slf4j @Aspect static class ParameterAspect { @Pointcut("execution(* hello.aop.member..*.*(..))") private void allMember() {} @Around("allMember()") public Object logArgs1(ProceedingJoinPoint joinPoint) throws Throwable { Object arg1 = joinPoint.getArgs()[0]; log.info("[logArgs1]{}, arg={}", joinPoint.getSignature(), arg1); return joinPoint.proceed(); } //logArgs1과 동일 @Around("allMember() && args(arg,..)") public Object logArgs2(ProceedingJoinPoint joinPoint, Object arg) throws Throwable { log.info("[logArgs2]{}, arg={}", joinPoint.getSignature(), arg); return joinPoint.proceed(); } //매개변수 타입을 String으로 제한 @Before("allMember() && args(arg,..)") public void logArgs3(String arg) { log.info("[logArgs3] arg={}", arg); } //프록시 객체를 전달받음 @Before("allMember() && this(obj)") public void thisArgs(JoinPoint joinPoint, MemberService obj) { log.info("[this]{}, obj={}", joinPoint.getSignature(), obj.getClass()); } //실제 대상 객체를 전달받음 @Before("allMember() && target(obj)") public void targetArgs(JoinPoint joinPoint, MemberService obj) { log.info("[target]{}, obj={}", joinPoint.getSignature(), obj.getClass()); } //타입의 애노테이션을 전달받음 @Before("allMember() && @target(annotation)") public void atTarget(JoinPoint joinPoint, ClassAop annotation) { log.info("[@target]{}, obj={}", joinPoint.getSignature(), annotation); } //타입의 애노테이션을 전달받음 @Before("allMember() && @within(annotation)") public void atWithin(JoinPoint joinPoint, ClassAop annotation) { log.info("[@within]{}, obj={}", joinPoint.getSignature(), annotation); } //메서드의 애노테이션을 전달 받음 @Before("allMember() && @annotation(annotation)") public void atAnnotation(JoinPoint joinPoint, MethodAop annotation) { log.info("[@annotation]{}, annotationValue={}", joinPoint.getSignature(), annotation.value()); } } } 포인트컷 지시자 this & target 유의점 프록시 생성 방식에 따라 케이스가 나뉘어짐 (JDK 동적 프록시 VS CGLIB) 핵심: JDK 동적 프록시 대상일 때, this에 구체 클래스 지정 시 AOP 적용이 실패함 JDK 동적 프록시가 포인트컷 대상일 경우 포인트컷에 MemberService 인터페이스 지정 this(hello.aop.member.MemberService) proxy 객체를 보고 판단 this 는 부모 타입을 허용하기 때문에 AOP 적용 target(hello.aop.member.MemberService) target 객체를 보고 판단 target 은 부모 타입을 허용하기 때문에 AOP 적용 포인트컷에 MemberServiceImpl 구체 클래스 지정 this(hello.aop.member.MemberServiceImpl) proxy 객체를 보고 판단 AOP 적용 실패 JDK 동적 프록시 객체는 인터페이스 기반으로 구현 MemberServiceImpl를 전혀 알지 못함 target(hello.aop.member.MemberServiceImpl) target 객체를 보고 판단 target 객체가 MemberServiceImpl 타입이므로 AOP 적용 CGLIB 프록시가 포인트컷 대상일 경우 포인트컷에 MemberService 인터페이스 지정 this(hello.aop.member.MemberService) proxy 객체를 보고 판단 this 는 부모 타입을 허용하기 때문에 AOP 적용 target(hello.aop.member.MemberService) target 객체를 보고 판단 target 은 부모 타입을 허용하기 때문에 AOP 적용 포인트컷에 MemberServiceImpl 구체 클래스 지정 this(hello.aop.member.MemberServiceImpl) proxy 객체를 보고 판단 CGLIB proxy 객체는 MemberServiceImpl 상속 받으므로 AOP 적용 target(hello.aop.member.MemberServiceImpl) target 객체를 보고 판단 target 객체가 MemberServiceImpl 타입이므로 AOP 적용 args, @args, @target… 위와 같은 표현식은 최대한 프록시 적용 대상을 축소하는 표현식과 함께 사용해야 한다. (단독 사용 X) 위 표현식은 동적으로 실제 객체 인스턴스가 생성되고 실행될 때 어드바이스 적용 여부를 확인할 수 있다. 포인트컷 적용은 프록시가 있어야 가능한데, 단독으로 사용하면 생성 시점에도 모든 스프링 빈에 AOP 프록시 적용을 시도한다. 스프링 내부 빈들은 final 빈도 있기 때문에 오류가 발생할 가능성이 높다. 스프링 AOP 실무 주의사항 프록시와 내부 호출 문제 (AOP가 잘 적용되지 않을 때 우선 의심 사항) @Slf4j @Component public class CallServiceV0 { public void external() { log.info("call external"); internal(); // 내부 메서드 호출(this.internal()) } public void internal() { log.info("call internal"); } } 스프링의 프록시 방식 AOP는 일반적으로 대상 객체(target)를 직접 호출할 일 X 항상 프록시를 거치므로 AOP가 적용되고 어드바이스가 호출될 수 있음 문제: 대상 객체 내 내부 메서드 호출에는 AOP 적용 불가 내부 메서드 호출은 프록시 거치지 않고 대상 객체를 직접 호출 (내부 메서드는 AOP 적용 X) 참고 AOP는 큰 단위 기능에 적용하는 것이 원칙 (public 메서드) e.g. 트랜잭션, 주요 컴포넌트 로그 출력 private처럼 작은 단위 메서드 수준에는 적용 X (잘못된 설계 예방) 따라서, 내부 호출 문제는 큰 기능끼리 서로 호출하는 경우를 다룸 (public이 public 호출) AspectJ로 컴파일, 로드 타임 위빙 시 내부 호출에도 AOP 적용 가능 (But, 복잡하니 지양) 해결책 1: 자기 자신을 의존관계 주입 받기 // 참고: 생성자 주입은 순환 사이클을 만들기 때문에 실패한다. @Slf4j @Component public class CallServiceV1 { private CallServiceV1 callServiceV1; @Autowired public void setCallServiceV1(CallServiceV1 callServiceV1) { this.callServiceV1 = callServiceV1; } public void external() { log.info("call external"); callServiceV1.internal(); //외부 메서드 호출 } public void internal() { log.info("call internal"); } } 수정자를 통해 프록시 객체를 주입 받아 호출 (실제 자신 X) - AOP 적용 가능 순환 사이클 문제 생성자 주입은 생성 시 순환 사이클이 만들어져 오류 발생 수정자 주입은 생성 이후 주입 가능 하지만, 스프링 부트 2.6부터 순환 참조 금지 정책 적용 예제 위한 옵션 필요 (spring.main.allow-circular-references=true) 해결책 2: 스프링 빈 지연 조회 // ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회 @Slf4j @Component @RequiredArgsConstructor public class CallServiceV2 { // private final ApplicationContext applicationContext; private final ObjectProvider<CallServiceV2> callServiceProvider; public void external() { log.info("call external"); // CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class); CallServiceV2 callServiceV2 = callServiceProvider.getObject(); callServiceV2.internal(); //외부 메서드 호출 } public void internal() { log.info("call internal"); } } ObjectProvider 스프링 컨테이너 객체 조회를 빈 생성 시점이 아닌 실제 객체 사용 시점으로 지연 가능 callServiceProvider.getObject() 호출 시점에 스프링 컨테이너에서 빈을 조회 ApplicationContext 사용도 가능하지만 너무 많은 기능을 제공 해결책 3: 구조 변경 (권장) 내부 호출이 발생하지 않도록 구조를 변경하는 것이 가장 좋음! 여러 방법 가능 분리하기 // 구조를 변경(분리) @Slf4j @Component @RequiredArgsConstructor public class CallServiceV3 { private final InternalService internalService; public void external() { log.info("call external"); internalService.internal(); //외부 메서드 호출 } } @Slf4j @Component public class InternalService { public void internal() { log.info("call internal"); } } 클라이언트에서 둘 다 호출하는 구조로 변경하기 클라이언트 -> external() 클라이언트 -> internal() 즉, external()에서 internal()을 내부 호출하지 않도록 코드 변경 JDK 동적 프록시와 의존관계 주입 문제 (구체 클래스 캐스팅 실패) 잘 설계된 애플리케이션은 인터페이스로 주입하므로 문제가 잘생기지 않음 다만, 테스트 혹은 다른 이유로 구체 클래스로 주입받아야 할 경우 존재 문제 (JDK 동적 프록시) 타입 캐스팅 문제 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능 (ClassCastException) 인터페이스를 기반으로 프록시를 생성했기 때문에 구체 클래스를 아얘 모름 의존관계 주입 문제 //JDK 동적 프록시 OK, CGLIB OK @Autowired MemberService memberService; //JDK 동적 프록시 X, CGLIB OK @Autowired MemberServiceImpl memberServiceImpl; JDK 동적 프록시는 구체 클래스 타입에 의존관계 주입 불가능 (타입 캐스팅 불가) 트레이드 오프 JDK 동적 프록시: 구체 클래스 타입 주입 문제 CGLIB 프록시: 대상 클래스에 기본 생성자 필수, 생성자 2번 호출 문제 CGLIB의 단점 (구체 클래스 상속에서 비롯된 문제점) 대상 클래스에 기본 생성자 필수 상속에 기반하므로, 생성자에서 대상 클래스의 기본 생성자 호출 (자바 규약) 따라서 대상 클래스에 기본 생성자가 필수 생성자 2번 호출 문제 2번 호출 상황 실제 target의 객체를 생성할 때 프록시 객체를 생성할 때 부모클래스의 생성자 호출 해서는 안되지만 만약 생성자에 과금 로직이 있었다면 2번 과금 실행 로그가 2번 남는 경우도 존재 가능 final 키워드 붙은 클래스, 메서드 사용 불가 (큰 문제 아님) 프록시 생성은 상속에 기반하므로 문제 생김 다만, 보통 웹 애플리케이션 개발에 final 키워드는 잘 사용 X e.g. AOP 적용 대상에 final 잘 사용 X 스프링 해결책: CGLIB 프록시 사용 스프링 3.2, CGLIB을 스프링 내부에 함께 패키징 (별도 라이브러리 추가 필요 없음) 기본 생성자 필수 문제 해결 스프링 4.0, objenesis 라이브러리를 사용으로 기본 생성자 없이 객체 생성 가능 생성자 2번 호출 문제 해결 스프링 4.0, objenesis 라이브러리를 사용으로 생성자가 1번만 호출됨 타겟 객체는 개발자가 생성, 프록시 객체는 CGLIB이 기본 생성자 없이 생성해냄 2번 호출 문제 자동 해결 스프링 부트 2.0, CGLIB 기본 사용 AOP 적용 기본 설정: proxyTargetClass=true 인터페이스 유무 상관없이 항상 CGLIB 사용해 구체클래스 기반으로 프록시 생성 CGLIB의 가장 큰 장점은 어떤 경우에도 타입 문제 없이 의존관계 주입 가능하다는 점 JDK 동적 프록시의 문제였던 구체 클래스 주입이 가능 개발자 입장에서는 문제 없이 편리하게 개발할 수 있는 것이 중요 프록시 기술 종류를 몰라도 잘 동작하는게 좋음 objenesis 라이브러리 생성자 호출 없이 객체를 생성할 수 있게 해준다. Reference 스프링 핵심 원리 - 고급편 스레드 로컬 (Thread Local)
Java-Ecosystem
· 2024-11-08
스프링 파일 업로드
HTML Form 전송 방식 차이 application/x-www-form-urlencoded HTML 폼 기본 전송 방식 폼 태그에 enctype 옵션을 주지 않을 시 자동 지정 multipart/form-data 여러 데이터 형식을 함께 보내기 위한 Form 데이터 전송 방식 (HTTP 제공) 파일은 문자가 아닌 바이너리 타입으로 전송 필요 각각의 항목을 구분해 한번에 전송 e.g. 폼 데이터 전송 시 문자와 바이너리를 동시 전송 문자: 이름, 나이… 파일: 첨부파일 폼 태그 -> enctype="multipart/form-data" 서블릿 파일 업로드 HttpServletRequest request.getParameter(...) 요청 파라미터 접근 request.getParts() multipart/form-data 전송 방식에서 각각 나누어진 부분을 받아서 확인 개별 Part 메서드 part.getSubmittedFileName() : 클라이언트가 전달한 파일명 part.getInputStream(): Part의 전송 데이터를 읽기 (Body) part.write(fullPath): Part를 통해 전송된 데이터를 지정 경로에 저장 스프링 파일 업로드 업로드하는 HTML Form의 name에 맞추어 @RequestParam 을 적용하면 됨 @RequestParam String itemName @RequestParam MultipartFile file MultipartFile 인터페이스 제공 메서드 file.getOriginalFilename() : 업로드 파일 명 file.transferTo(...) : 파일 저장 서블릿에 비해 HttpServletRequest를 사용 X 파일 부분만 구분하기도 편리 예시 코드 @Slf4j @Controller @RequestMapping("/spring") public class SpringUploadController { @Value("${file.dir}") private String fileDir; @GetMapping("/upload") public String newFile() { return "upload-form"; } @PostMapping("/upload") public String saveFile(@RequestParam String itemName, @RequestParam MultipartFile file, HttpServletRequest request) throws IOException { log.info("request={}", request); log.info("itemName={}", itemName); log.info("multipartFile={}", file); if (!file.isEmpty()) { String fullPath = fileDir + file.getOriginalFilename(); log.info("파일 저장 fullPath={}", fullPath); file.transferTo(new File(fullPath)); } return "upload-form"; } } 멀티파트 관련 사용 옵션 (application.properties) 실제 파일 저장 경로 지정 file.dir=파일 업로드 경로 e.g. /Users/lucian/study/file/ 해당 경로에 반드시 실제 폴더 만들어두기 지정 파일 경로는 컨트롤러의 멤버 변수에 주입해 사용 가능 //application.properties에서 설정한 파일 경로 주입 @Value("${file.dir}") private String fileDir; 업로드 사이즈 제한 //파일 하나의 최대 사이즈 (기본 1MB) spring.servlet.multipart.max-file-size=1MB //멀티 파트 요청 하나의 여러 파일 전체 합 (기본 10MB) spring.servlet.multipart.max-request-size=10MB 큰 파일 무제한 업로드를 예방하고 업로드 사이즈 제한 가능 사이즈를 넘길 시 예외 발생 (SizeLimitExceededException) 서블릿 컨테이너 멀티파트 관련 처리 옵션 spring.servlet.multipart.enabled (기본 true) false: 멀티파트 처리 안하기 결과 request=org.apache.catalina.connector.RequestFacade@xxx itemName=null parts=[] true: 멀티파트 처리하기 결과 request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest itemName=Spring parts=[ApplicationPart1, ApplicationPart2] true일 시, 스프링 DispatcherServlet의 MultipartResolver를 실행 MultipartResolver는 멀티파트 요청이 온 경우 HttpServletRequest -> MultipartHttpServletRequest 변환 스프링 기본 멀티파트 리졸버는 StandardMultipartHttpServletRequest 반환 MultipartHttpServletRequest HttpServletRequest의 자식 인터페이스 멀티파트 관련 추가 기능 제공 StandardMultipartHttpServletRequest MultipartHttpServletRequest 인터페이스 구현체 실제 파일 업로드 구현 시 주의사항 고객이 업로드한 파일명과 서버 내부 관리 파일명은 다르게 할 것 서로 다른 고객이 같은 파일 이름을 업로드하면 기존 파일과 충돌 발생 예시 구현 업로드 파일 정보 @Data public class UploadFile { private String uploadFileName; private String storeFileName; public UploadFile(String uploadFileName, String storeFileName) { this.uploadFileName = uploadFileName; this.storeFileName = storeFileName; } } 상품 도메인 객체 @Data public class Item { private Long id; private String itemName; private UploadFile attachFile; private List<UploadFile> imageFiles; } 파일 저장 객체 구현하기 멀티파트 파일을 서버에 저장하는 역할 파일은 보통 로컬 스토리지나 S3에 저장하고 DB에는 해당 경로만 저장 (DB에 파일 자체 저장 X) 예시 구현 @Component public class FileStore { @Value("${file.dir}") private String fileDir; public String getFullPath(String filename) { return fileDir + filename; } public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException { List<UploadFile> storeFileResult = new ArrayList<>(); for (MultipartFile multipartFile : multipartFiles) { if (!multipartFile.isEmpty()) { storeFileResult.add(storeFile(multipartFile)); } } return storeFileResult; } public UploadFile storeFile(MultipartFile multipartFile) throws IOException { if (multipartFile.isEmpty()) { return null; } String originalFilename = multipartFile.getOriginalFilename(); String storeFileName = createStoreFileName(originalFilename); multipartFile.transferTo(new File(getFullPath(storeFileName))); return new UploadFile(originalFilename, storeFileName); } //서버 내부 관리 파일명 생성 (UUID 사용해 충돌 방지) private String createStoreFileName(String originalFilename) { String ext = extractExt(originalFilename); String uuid = UUID.randomUUID().toString(); return uuid + "." + ext; } //확장자 추출 함수 private String extractExt(String originalFilename) { int pos = originalFilename.lastIndexOf("."); return originalFilename.substring(pos + 1); } } 파일 저장 폼 전송 객체 예시 @Data public class ItemForm { private Long itemId; private String itemName; private List<MultipartFile> imageFiles; //이미지 다중 업로드 private MultipartFile attachFile; } 파일 저장 뷰 예시 다중 파일 업로드는 <input> 태그에 multiple="multiple" 옵션 지정 ItemForm의 List<MultipartFile> imageFiles을 통해 여러 이미지 파일 받을 수 있음 <!DOCTYPE HTML> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="utf-8"> </head> <body> <div class="container"> <div class="py-5 text-center"> <h2>상품 등록</h2> </div> <form th:action method="post" enctype="multipart/form-data"> <ul> <li>상품명 <input type="text" name="itemName"></li> <li>첨부파일<input type="file" name="attachFile" ></li> <li>이미지 파일들<input type="file" multiple="multiple" name="imageFiles" ></li> </ul> <input type="submit"/> </form> </div> <!-- /container --> </body> </html> 파일 조회 및 다운로드 예시 이미지 조회 UrlResource로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리 반환 파일 다운로드 Content-Disposition 헤더에 attachment; filename="업로드 파일명" 주기 파일 다운로드 시에는 고객이 업로드한 파일명으로 다운로드하는게 좋음 (UTF_8 인코딩) UrlResource로 파일을 읽어서 헤더와 바디를 ResponseEntity<Resource> 반환 예시 구현 @Slf4j @Controller @RequiredArgsConstructor public class ItemController { private final ItemRepository itemRepository; private final FileStore fileStore; @GetMapping("/items/new") public String newItem(@ModelAttribute ItemForm form) { return "item-form"; } @PostMapping("/items/new") public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException { UploadFile attachFile = fileStore.storeFile(form.getAttachFile()); List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles()); //데이터베이스에 저장 Item item = new Item(); item.setItemName(form.getItemName()); item.setAttachFile(attachFile); item.setImageFiles(storeImageFiles); itemRepository.save(item); redirectAttributes.addAttribute("itemId", item.getId()); return "redirect:/items/{itemId}"; } @GetMapping("/items/{id}") public String items(@PathVariable Long id, Model model) { Item item = itemRepository.findById(id); model.addAttribute("item", item); return "item-view"; } @ResponseBody @GetMapping("/images/{filename}") public Resource downloadImage(@PathVariable String filename) throws MalformedURLException { return new UrlResource("file:" + fileStore.getFullPath(filename)); } @GetMapping("/attach/{itemId}") public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException { Item item = itemRepository.findById(itemId); String storeFileName = item.getAttachFile().getStoreFileName(); String uploadFileName = item.getAttachFile().getUploadFileName(); UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName)); log.info("uploadFileName={}", uploadFileName); String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8); String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\""; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) .body(resource); } } Reference 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
Java-Ecosystem
· 2024-07-28
스프링 타입 컨버터
스프링 Converter HTTP 요청 데이터는 문자로 처리됨 다만, 파라미터를 원하는 타입으로 지정하면 스프링이 자동으로 타입 변환 개발자는 직접 타입 변환할 필요 없이 원하는 타입으로 편리하게 전달 받아 사용 예시 @RequestParam, @ModelAttribute, @PathVariable … (스프링 MVC 요청 파라미터) @Value 등으로 YML 정보 읽기 XML에 넣은 스프링 빈 정보 변환 뷰 렌더링 시 컨버터 인터페이스 package org.springframework.core.convert.converter; public interface Converter<S, T> { T convert(S source); } 스프링은 확장 가능한 컨버터 인터페이스 지원 org.springframework.core.convert.converter.Converter 개발자는 스프링에 추가적인 타입 변환이 필요하면 인터페이스를 구현해 등록 Converter와 PropertyEditor 과거에는 PropertyEditor로 타입 변환했으나 동시성 문제가 있어서 잘 사용하지 않는다. 지금은 기능 확장 시 Converter를 사용한다. 스프링이 제공하는 다양한 컨버터 Converter: 기본 타입 컨버터 ConverterFactory: 전체 클래스 계층 구조가 필요할 때 GenericConverter: 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능 ConditionalGenericConverter: 특정 조건이 참인 경우에만 실행 스프링은 문자, 숫자, 불린, Enum 등 일반적인 타입에 대한 대부분의 인터페이스 구현체들을 제공한다. ConversionService 스프링은 개별 컨버터를 모아서 묶어두고 편리하게 사용할 수 있도록 제공 스프링 내부에서도 ConversionService를 사용해 타입 변환 e.g @RequestParam RequestParamMethodArgumentResolver에서 ConversionService 사용해 타입 변환 뷰 템플릿도 컨버젼 서비스 적용 가능 (타임 리프 문법: ${{...}}) ConversionService 인터페이스 public interface ConversionService { boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType); boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType); <T> T convert(@Nullable Object source, Class<T> targetType); Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType); } canConvert: 컨버팅이 가능한지 체크 convert: 실제 컨버팅 수행 ConversionService는 2가지 이점 제공 등록과 사용 분리 컨버터 등록 입장: 타입 컨버터를 명확히 알아야 함 컨버터 사용 입장: 컨버전 서비스 인터페이스만 의존, 구체적인 타입 컨버터 몰라도 됨 인터페이스 분리 원칙 적용 (ISP) DefaultConversionService 구현체는 두 인터페이스를 구현 ConversionService: 컨버터 사용에 초점 ConverterRegistry: 컨버터 등록에 초점 ConversionService 인터페이스를 사용하는 클라이언트는 꼭 필요한 메서드만 알게 됨 메시지 컨버터와 컨버전 서비스 HttpMessageConverter에는 컨버전 서비스가 적용되지 않는다. 메시지 컨버터는 Jackson 라이브러리를 사용해 HTTP 메시지 바디 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력한다. 따라서, JSON 결과로 만들어지는 숫자나 날짜 포멧을 변경하고 싶으면, Jackson 라이브러리가 제공하는 설정을 통해 지정해야 한다. (Jackson Data Format 같은 키워드로 검색 필요) 참고로, 컨버전 서비스는 @RequestParam , @ModelAttribute , @PathVariable , 뷰 템플릿 등에서 사용할 수 있다. Formatter 일반적인 웹 애플리케이션 환경에서의 타입 변환 (개발자) 문자 -> 객체 (다른 타입) 객체 (다른 타입) -> 문자 Formatter는 문자에 특화한 Converter의 특별한 버전 Formatter: 문자 특화 + 현지화(Locale) Converter: 범용 (객체 -> 객체) Formatter 인터페이스 public interface Printer<T> { String print(T object, Locale locale); } public interface Parser<T> { T parse(String text, Locale locale) throws ParseException; } public interface Formatter<T> extends Printer<T>, Parser<T> { } String print(T object, Locale locale) : 객체를 문자로 변경 T parse(String text, Locale locale) : 문자를 객체로 변경 스프링 Formatter 스프링은 용도에 따라 다양한 방식의 포멧터를 제공한다. Formatter: 포멧터 AnnotationFormatterFactory: 필드 타입 혹은 애노테이션 정보를 활용할 수 있는 포멧터 FormattingConversionService 포멧터를 지원하는 컨버전 서비스 포멧터도 특별한 컨버터일 뿐 내부에서 어댑터 패턴을 사용해 Formatter가 Converter처럼 동작하도록 지원 컨버터 & 포멧터를 모두 등록 가능 FormattingConversionService 는 ConversionService 관련 기능을 상속 받음 DefaultFormattingConversionService 구현체: 기본적인 통화, 숫자 기본 포멧터를 추가해 제공 커스텀Converter 및 Formatter 등록 @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { //컨버터 추가 registry.addConverter(new StringToIpPortConverter()); registry.addConverter(new IpPortToStringConverter()); //포멧터 추가 registry.addFormatter(new MyNumberFormatter()); } } 등록 방법은 다르지만 컨버전 서비스를 통해 컨버터와 포멧터를 일관성 있게 사용 가능 addFormatters() 오버라이딩 -> 스프링 내부 ConversionService에 컨버터 자동 등록 addConverter(): 컨버터 추가 addFormatter(): 포멧터 추가 우선 순위 스프링 기본 컨버터보다 추가한 컨버터가 높은 우선순위 포멧터보다 컨버터가 높은 우선 순위 스프링 기본 포멧터 스프링은 자바 기본 타입들에 대한 수많은 포멧터를 기본 제공 애노테이션 기반 포멧터도 제공 덕분에 객체에 각 필드마다 다른 형식으로 포멧 지정 가능 종류 @NumberFormat 숫자 관련 형식 지정 NumberFormatAnnotationFormatterFactory @DateTimeFormat 날짜 관련 형식 지정 Jsr310DateTimeFormatAnnotationFormatterFactory 예시 코드 @Controller public class FormatterController { @GetMapping("/formatter/edit") public String formatterForm(Model model) { Form form = new Form(); form.setNumber(10000); form.setLocalDateTime(LocalDateTime.now()); model.addAttribute("form", form); return "formatter-form"; } @PostMapping("/formatter/edit") public String formatterEdit(@ModelAttribute Form form) { return "formatter-view"; } @Data static class Form { @NumberFormat(pattern = "###,###") private Integer number; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime localDateTime; } } Reference 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
Java-Ecosystem
· 2024-07-27
스프링 예외 처리
서블릿 예외 처리 순수 서블릿 컨테이너는 2가지 방식으로 예외 처리 지원 Exception WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생) 웹 애플리케이션은 서블릿 컨테이너 안에서 실행 애플리케이션 내에서 예외를 잡지 못하고 서블릿 밖으로 WAS까지 예외가 전달되는 상황 WAS(톰캣)가 500 상태코드로 처리 (서버에서 처리할 수 없는 오류로 판단) 스프링 부트 사용시 스프링 부트 기본 예외 페이지 보여줌 아닐 시 tomcat 기본 제공 오류 화면 참고) 없는 자원 접근 시 스프링 부트 혹은 tomcat의 404 예외 페이지 보여줌 response.sendError(HTTP 상태 코드, 오류 메시지) WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 (response.sendError()) 호출 시 response 내부에 오류가 발생했다는 상태를 저장 서블릿 컨테이너는 응답 전에 해당 상태를 보고 오류 코드에 맞는 기본 오류 페이지 제공 HttpServletResponse 메서드 호출 시 서블릿 컨테이너에게 오류가 발생했다고 전달 실제로 예외가 발생하지는 않고 정상 리턴으로 WAS까지 전달 서블릿 오류 페이지 서블릿 오류 페이지 등록 (스프링 부트를 통한 커스터마이징) ErrorPage 설정 @Component public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> { @Override public void customize(ConfigurableWebServerFactory factory) { ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"); ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500"); ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500"); factory.addErrorPages(errorPage404, errorPage500, errorPageEx); } } sendError, Exception 발생 상황에 따라 설정한 컨트롤러 URL 호출 오류 페이지 컨트롤러 @Slf4j @Controller public class ErrorPageController { @RequestMapping("/error-page/404") public String errorPage404(HttpServletRequest request, HttpServletResponse response) { log.info("errorPage 404"); return "error-page/404"; } @RequestMapping("/error-page/500") public String errorPage500(HttpServletRequest request, HttpServletResponse response) { log.info("errorPage 500"); return "error-page/500"; } } 컨트롤러가 개발자가 만든 오류 페이지 뷰 리턴 오류 페이지 작동 원리 예외 발생과 오류 페이지 요청 흐름 WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생) WAS /error-page/500 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/1) -> View 정상 요청, 예외 발생과 오류 페이지 요청 흐름 (Ver. 필터, 인터셉터 중복 호출 제거) WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생) WAS 오류 페이지 확인 WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러(/error-page/500) -> View 고객(클라이언트)은 한 번 요청하지만, 서버 내부적으로 컨트롤러 2번 호출 재호출 시 WAS는 오류 정보를 request 객체에 담아 보내줌 (setAttribute()) 오류정보는 다음 키에 대응 javax.servlet.error.exception : 예외 javax.servlet.error.exception_type : 예외 타입 javax.servlet.error.message : 오류 메시지 javax.servlet.error.request_uri : 클라이언트 요청 URI javax.servlet.error.servlet_name : 오류가 발생한 서블릿 이름 javax.servlet.error.status_code : HTTP 상태 코드 오류 페이지 출력을 위한 내부 요청은 보통 필터, 인터셉터 중복 호출을 피하는게 효율적 필터는 DispatcherType 통해 중복 호출 제거 (dispatchType=REQUEST) 서블릿은 요청 종류를 구분할 수 있도록 DispatcherType 제공 (HttpServletRequest에 포함) DispatcherType.REQUEST: 클라이언트 요청 (필터의 기본값) DispatcherType.ERROR: 오류 요청 DispatcherType.FORWARD: 서블릿에서 다른 서블릿이나 JSP를 호출할 때 DispatcherType.INCLUDE: 서블릿에서 다른 서블릿이나 JSP의 결과 포함할 때 DispatcherType.ASYNC: 서블릿 비동기 호출 필터 호출 여부 등록 예시 filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR); 이 경우, 클라이언트 요청과 오류 페이지 요청 모두 필터 호출 인터셉터는 경로 정보로 중복 호출 제거 (excludePathPatterns("/error-page/**")) 인터셉터는 서블릿이 아니라 스프링이 제공하는 기능 -> DispatcherType과 무관 따라서, excludePathPatterns 에 오류 페이지 경로를 추가하는 방식 사용 자바 메인 메서드에서 Exception 발생 상황 자바는 메인 메서드 직접 실행 시 main()이라는 이름의 쓰레드가 실행된다. 만일, main() 메서드 넘어 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드가 종료된다. 스프링 부트 오류 페이지 스프링 부트는 서블릿의 복잡한 오류 페이지 작동 과정을 기본으로 제공 /error 기본 경로로 기본 오류 페이지 설정 (ErrorPage 자동 등록) ErrorMvcAutoConfiguration 클래스가 오류 페이지 자동 등록 BasicErrorController 스프링 컨트롤러를 자동 등록 ErrorPage에서 등록한 /error 경로를 매핑해 처리 개발자는 오류 페이지만 등록하면 됨 규칙에 따른 경로 위치에 HTTP 상태 코드 이름의 뷰 파일을 넣어두면 됨 BasicErrorController가 구현된 기본 로직으로 처리 뷰 선택 우선순위 (구체적인게 더 우선순위 높음) 뷰 템플릿 - 1 순위 resources/templates/error/500.html resources/templates/error/5xx.html 정적 리소스( static , public ) - 2 순위 resources/static/error/400.html resources/static/error/404.html resources/static/error/4xx.html 적용 대상이 없을 때 뷰 이름(error) - 3 순위 resources/templates/error.html BasicErrorController는 오류 관련 정보를 model에 담아 뷰로 전달 가능 다만, 오류 정보 model 포함 여부는 선택 가능 실무에서는 노출 X 고객에게 오류 관련 내부 정보를 노출하는 것은 좋지 않음 고객에겐 정제된 오류화면 보이고, 서버에 오류를 로그로 남길 것 application.properties server.error.include-exception=true //exception 포함여부 server.error.include-message=on_param //message 포함여부 server.error.include-stacktrace=on_param //trace 포함여부 server.error.include-binding-errors=on_param //errors 포함여부 true/false가 아닌 값은 다음 3가지 옵션 사용 가능 (기본값: never) never : 사용하지 않음 always :항상 사용 on_param : 파라미터가 있을 때 사용 참고로 다음 정보를 뷰 템플릿에서 활용 가능 timestamp: Fri Feb 05 00:00:00 KST 2021 status: 400 error: Bad Request exception: org.springframework.validation.BindException trace: 예외 trace message: Validation failed for object=’data’. Error count: 1 errors: Errors(BindingResult) path: 클라이언트 요청 경로 (/hello) 스프링 부트 오류 관련 옵션 (application.properties) server.error.whitelabel.enabled=true: 스프링 whitelabel 오류 페이지 적용 여부 server.error.path=/error: 오류 페이지 자동 등록 경로 (사용할 일 거의 없음) 확장 포인트 에러 공통 처리 컨트롤러의 기능 변경을 원할 때, ErrorController 인터페이스를 상속 받아 구현하거나 BasicErrorController 를 상속 받아 기능을 추가할 수 있다. 사용할 일은 거의 없다. API 예외 처리 API는 예외 처리는 조금 더 복잡함 각 오류 상황에 맞는 오류 응답 스펙을 정의 JSON으로 데이터 응답 @ExceptionHandler (실무 사용 권장) BasicErrorController 사용이나 HandlerExceptionResolver 직접 구현은 API 예외를 다루기 어려움 어려운 점 HandlerExceptionResolver에서 ModelAndView 반환 - API 응답에 필요 없음 API 응답을 위해 HttpServletResponse로 직접 응답 데이터 넣음 - 서블릿 시절 회귀 컨트롤러마다 예외를 별도 처리하기 어려움 - 같은 RuntimeException을 다르게 처리 어려움 스프링이 만들어둔 구현체 ExceptionHandlerExceptionResolver 사용 사용 방법 애노테이션과 함께 해당 컨트롤러에서 처리하고 싶은 예외를 메서드에 지정 해당 컨트롤러에서 예외가 발생하면 메서드 호출 예외는 자기자신과 그 자식까지 포함 예외 생략 @ExceptionHandler public ResponseEntity<ErrorResult> userExHandle(UserException e) {} @ExceptionHandler에 예외를 생략하면 메서드 파라미터의 예외를 자동 지정 상속관계 예외에 대한 @ExceptionHandler 우선순위 @ExceptionHandler(부모예외.class) < @ExceptionHandler(자식예외.class) 자세한 것이 우선권 다양한 예외 한번에 처리 @ExceptionHandler({AException.class, BException.class}) public String ex(Exception e) { log.info("exception e", e); } ModelAndView를 리턴해 오류 화면 응답도 가능하지만 잘 사용하지 않음 @ExceptionHandler(ViewException.class) public ModelAndView ex(ViewException e) { log.info("exception e", e); return new ModelAndView("error"); } 예시 코드 @Slf4j @RestController public class ApiExceptionV2Controller { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResult illegalExHandle(IllegalArgumentException e) { log.error("[exceptionHandle] ex", e); return new ErrorResult("BAD", e.getMessage()); } @ExceptionHandler public ResponseEntity<ErrorResult> userExHandle(UserException e) { log.error("[exceptionHandle] ex", e); ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage()); return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST); } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler public ErrorResult exHandle(Exception e) { log.error("[exceptionHandle] ex", e); return new ErrorResult("EX", "내부 오류"); } @GetMapping("/api2/members/{id}") public MemberDto getMember(@PathVariable("id") String id) { if (id.equals("ex")) { throw new RuntimeException("잘못된 사용자"); } if (id.equals("bad")) { throw new IllegalArgumentException("잘못된 입력 값"); } if (id.equals("user-ex")) { throw new UserException("사용자 오류"); } return new MemberDto(id, "hello " + id); } @Data @AllArgsConstructor static class MemberDto { private String memberId; private String name; } } @ControllerAdvice / @RestControllerAdvice (@ControllerAdvice + @ResponseBody) 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능 부여 컨트롤러 지정 방법 대상 지정 X -> 모든 컨트롤러 적용 (글로벌 적용) 애노테이션, 패키지, 클래스 등으로 지정 가능 (보통 패키지까지는 일반적으로 지정해줌) // Target all Controllers annotated with @RestController @ControllerAdvice(annotations = RestController.class) public class ExampleAdvice1 {} // Target all Controllers within specific packages @ControllerAdvice("org.example.controllers") public class ExampleAdvice2 {} // Target all Controllers assignable to specific classes @ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class}) public class ExampleAdvice3 {} 정상 코드와 예외 처리 코드 분리 가능 예시 코드 @Slf4j @RestControllerAdvice public class ExControllerAdvice { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResult illegalExHandle(IllegalArgumentException e) { log.error("[exceptionHandle] ex", e); return new ErrorResult("BAD", e.getMessage()); } @ExceptionHandler public ResponseEntity<ErrorResult> userExHandle(UserException e) { log.error("[exceptionHandle] ex", e); ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage()); return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST); } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler public ErrorResult exHandle(Exception e) { log.error("[exceptionHandle] ex", e); return new ErrorResult("EX", "내부 오류"); } } 여러가지 방법 HandlerExceptionResolver (줄여서 ExceptionResolver라고 부름) 스프링 MVC 제공 인터페이스 public interface HandlerExceptionResolver { ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex); } 목표 발생하는 예외마다 다른 상태코드로 처리 API 마다 오류 메시지, 형식 등을 다르게 처리 API 예외 처리에 유용 예외처리가 깔끔해짐 스프링 MVC 내에서 예외 처리를 끝내고 정상 흐름으로 돌림 기존의 복잡한 내부 재호출 과정을 없앰 기존 예외 처리 과정: 예외 발생 -> WAS, WAS -> /error 다시 호출 컨트롤러에서 예외가 발생했을 때, ExceptionResolver 에서 예외 처리 마무리 서블릿 컨테이너까지 예외가 올라가지 않음 WAS 입장에서는 정상 처리된 것 ExceptionResolver 한 곳에서 예외를 모두 처리 예외 처리 흐름 ExceptionResolver 적용 전 ExceptionResolver 적용 후 ExceptionResolver로 예외를 해결해도 postHandle()은 호출 X 스프링 기본 제공 구현체 HandlerExceptionResolverComposite 에 다음 순서로 등록 ExceptionHandlerExceptionResolver (우선순위 가장 높음) @ExceptionHandler 처리 담당 ResponseStatusExceptionResolver 예외에 따라 HTTP 상태 코드 지정 담당 처리 @ResponseStatus 적용된 예외 @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류") public class BadRequestException extends RuntimeException {} 애노테이션 확인 후 오류 코드 변경 및 메시지 담아 전달 response.sendError() 호출 후 WAS 오류 페이지 내부 요청 reason -> MessageSource에서 찾기 가능 e.g. reason = "error.bad" 개발자가 변경할 수 없는 예외에 적용 불가능 ResponseStatusException 예외 @GetMapping("/api/response-status") public String responseStatusEx2() { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException()); } DefaultHandlerExceptionResolver (우선순위 가장 낮음) 스프링 내부 기본 예외 처리 e.g. 파라미터 바인딩 실패 시 TypeMismatchException 그냥 두면 서블릿 컨테이너까지 올라가 500 에러 발생 다만, TypeMismatchException는 클라이언트 실수인 400이 맞음 따라서, 스프링이 400 오류로 변경 처리 DefaultHandlerExceptionResolver.handleTypeMismatch() response.sendError() (400) WAS 오류 페이지 내부 요청 구현 예시 코드 구현 @Slf4j public class UserHandlerExceptionResolver implements HandlerExceptionResolver { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try { if (ex instanceof UserException) { log.info("UserException resolver to 400"); String acceptHeader = request.getHeader("accept"); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); if ("application/json".equals(acceptHeader)) { Map<String, Object> errorResult = new HashMap<>(); errorResult.put("ex", ex.getClass()); errorResult.put("message", ex.getMessage()); String result = objectMapper.writeValueAsString(errorResult); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().write(result); return new ModelAndView(); } else { //TEXT/HTML return new ModelAndView("error/400"); } } } catch (IOException e) { log.error("resolver ex", e); } return null; } } ACCEPT 헤더 값이 application/json 이면 JSON으로 오류를 내려 줌 그 외 경우에는 error/ 500에 있는 HTML 오류 페이지를 보여줌 등록 (WebMvcConfigurer) @Override public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) { resolvers.add(new UserHandlerExceptionResolver()); } configureHandlerExceptionResolvers는 스프링 기본 등록 ExceptionResolver를 제거하므로 사용 지양 extendHandlerExceptionResolvers 사용 지향 HandlerExceptionResolver 반환 값에 따른 DispatcherServlet 동작 방식 빈 ModelAndView: 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿이 리턴 ModelAndView: View, Model 등을 지정 후 반환해 뷰를 렌더링 null 다음 ExceptionResolver 찾아 실행 만일 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고 서블릿 밖으로 던짐 ExceptionResolver 활용 예외 상태 코드 변환 예외를 response.sendError(xxx)로 변경해 WAS 내부 호출로 위임 뷰 템플릿 처리 새로운 오류 화면 뷰를 렌더링 API 응답 처리 response.getWriter()로 JSON 응답 처리 가능 BasicErrorController 기본 처리 (HTML 오류 페이지 제공에 대해서만 사용) @RequestMapping(produces = MediaType.TEXT_HTML_VALUE) public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {} @RequestMapping public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {} 스프링 부트 제공 기본 오류 방식 동일한 경로(/error)에 클라이언트 Accept 요청 헤더에 따라 다르게 응답 헤더 값이 text/html인 경우 errorHtml() 메서드 호출해 뷰 반환 그 외 경우, error() 메서드 호출해 HTTP Body로 JSON 반환 문제 API 응답은 각각의 컨트롤러, 예외마다 서로 다른 응답 결과를 전달해야 할 수 있음 회원 API 예외 응답과 상품 API 예외 응답이 다른 것처럼 세밀하고 복잡 BasicErrorController는 확장해야 세밀한 응답 변경 가능 Reference 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
Java-Ecosystem
· 2024-07-20
스프링 쿠키, 세션 로그인 기본
로그인 기본 비즈니스 로직 로그인 컨트롤러 로그인 서비스 로직 호출 호출 성공하면 정상 리턴 실패 시, 글로벌 오류 생성 로그인 서비스 로직 회원을 조회 파라미터로 넘어온 password와 비교 password가 같으면 회원을 반환 다르면 null 을 반환 로그인 유지 방법 e.g. 로그인 성공한 고객의 이름을 보여주기 쿠키 방식 서버에서 로그인 성공 시 HTTP 응답에 쿠키를 담아 브라우저에 전달 (Set-Cookie) 브라우저는 앞으로 모든 요청에 쿠키 정보 자동 포함 쿠키 종류 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시까지만 유지 쿠키 생성 로직 //쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료) Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId())); response.addCookie(idCookie); //response: HttpServletResponse 쿠키 조회 파라미터 @CookieValue(name = "memberId", required = false) Long memberId required = false: 로그인 하지 않은 사용자도 접근 허용 쿠키 로그아웃 로직 Cookie cookie = new Cookie(cookieName, null); cookie.setMaxAge(0); response.addCookie(cookie); //response: HttpServletResponse 응답 쿠키를 Max-Age=0로 생성해, 기존 쿠키를 덮어 씌우고 즉시 종료되게끔 함 보안 문제 쿠키 값은 임의로 변경 가능 클라이언트에서 마음대로 변경 가능 e.g. Cookie: memberId=1 Cookie: memberId=2 (다른사용자의 이름이 보임) 쿠키에 보관된 정보는 훔칠 수 있음 쿠키에 개인 정보나, 신용카드 정보가 있다면… 해당 정보가 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있음 해커가 쿠키를 한 번 훔쳐가면 평생 사용 가능 해커가 악의적인 요청 계속 시도 가능 보안 문제 대안 쿠키에 예측 불가능한 임의의 토큰(랜덤 값)을 노출 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 함 클라이언트와 서버의 연결은 추정 불가능한 임의의 식별자 값으로 연결해야 함 서버에서 토큰을 관리 중요한 값을 서버에 보관하고 노출하지 않음 서버에서 토큰과 사용자 id를 매핑해서 인식 서버에서 해당 토큰의 만료시간을 짧게 유지 (예: 30분) 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없음 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거 세션 방식 서버에 중요한 정보를 보관하고 연결을 유지하는 방법 단순히 쿠키를 사용하지만, 서버에서 데이터를 유지하는 방법일 뿐 서버는 세션 저장소를 가짐 단순하게는 Map<sessionId, value> 형태 서블릿의 세션 저장소는 Map<JSESSIONID, Map<String, Object>> 형태 세션 ID는 추정 불가능 해야 하므로 UUID를 주로 사용 세션 방식은 단순 쿠키 방식의 보안 문제들을 해결 예상 불가능한 세션 ID를 사용해 쿠키값 변조를 예방 쿠키 정보(세션 ID)가 털려도 여기에는 중요한 정보가 없음 (서버에 있음) 세션 만료시간을 짧게 유지하고, 해킹 의심시 서버에서 해당 세션 강제 제거 동작 방식 로그인 사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인 서버는 세션 ID를 생성하고, 보관할 값과 함께 세션 저장소에 보관 서버는 클라이언트에게 세션 ID만 쿠키로 전달 (Set-Cookie) 중요한 정보는 서버에 있음 추정불가능한 세션 ID만 클라이언트에 전달되므로 사용자 추정 불가 클라이언트는 쿠키 저장소에 세션 ID가 담긴 쿠키를 저장 로그인 이후 클라이언트는 요청시 항상 서버로 쿠키를 전달 서버는 전달 받은 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용 기능 정리 세션 생성 sessionId 생성 (UUID) 세션 저장소에 sessionId 및 보관 값 저장 응답 쿠키에 sessionId를 담아 전달 세션 조회 요청 쿠키의 sessionId 값을 사용해, 세션 저장소에서 보관 값 조회 세션 만료 요청 쿠키의 sessionId 값을 사용해, 세션 저장소에 보관된 sessionId와 보관 값 제거 세션 타임아웃 대부분의 사용자는 로그아웃 없이 브라우저를 종료 -> 서버는 사용자 이탈 여부를 알 수 없음 그렇다고 세션을 무한정 보관해서는 안됨 세션과 관련된 쿠키를 탈취 당했을 경우, 지속적인 악의적 요청 가능 메모리의 한계가 있으므로, 세션은 꼭 필요한 경우만 사용해야 함 사용자가 서버에 최근 요청한 시간을 기준으로 30분 정도 세션 유지 권장 세션 생성 시점 기준 30분을 잡으면, 로그인 연장이 안되어 고객이 불편 흐름 LastAccessedTime 통해 최근 세션 접근 시간을 확인하고 갱신 타임아웃 설정 시간만큼 세션을 추가로 사용 LastAccessedTime 이후로 타임아웃 되면, WAS가 내부에서 해당 세션을 제거 세션에는 적당한 유지 시간을 설정하고 최소한의 데이터만 보관 사용자 수가 급증하면 메모리가 급증해 장애 발생 가능 세션 메모리 사용량 = 보관한 데이터 용량 * 사용자 수 실무에서는 멤버 아이디만 혹은 로그인 용 멤버 객체를 따로 만들어 최소 정보만 보관 세션의 시간을 너무 길게 가져가도 메모리 누적으로 장애 위험 기본 30분을 기준으로 고민 세션 방식 구현 예 서블릿 제공 @SessionAttribute HttpSession과 같은 기능을 제공 세션을 찾고 세션에 들어 있는 데이터를 조회하는 과정을 스프링이 간편하게 처리 사용 방법 @SessionAttribute(name = "loginMember", required = false) Member loginMember 이미 로그인된 사용자 찾음 세션을 생성하는 기능은 없음 HttpSession 직접 구현 기능에 더해 일정 시간 사용하지 않으면 해당 세션을 삭제하는 기능도 제공 쿠키 이름은 JSESSIONID로 추정 불가능한 랜덤 값 생성 Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05 세션 저장소의 형태: Map<JSESSIONID, Map<String, Object>> 세션 저장소는 하나만 있고, 그 안에 여러 HttpSession이 보관됨 HttpSession은 내부에 데이터를 Map 형식으로 보관 사용 방법 HttpSession session = request.getSession() //HttpServeletRequest 세션 생성과 조회 쿠키의 JSESSIONID로 세션 보관소에서 세션을 가져옴 create 옵션 (기본값: true) request.getSession(true) 세션이 있으면 기존 세션 반환 세션이 없으면 새로운 세션을 생성 및 반환 request.getSession(false) 세션이 있으면 기존 세션 반환 세션이 없으면 null 반환 session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember); 세션에 로그인 정보 보관 하나의 세션에 여러 값 보관 가능 SessionConst.LOGIN_MEMBER의 값을 키로 사용해 loginMember 데이터 저장 session.getAttribute(SessionConst.LOGIN_MEMBER) 세션에서 특정 키 값으로 데이터 조회 session.invalidate() 세션 제거 타임아웃 설정 스프링 부트 글로벌 설정 (application.properties) server.servlet.session.timeout=60 : 60초, 기본은 1800(30분) 특정 세션 단위로 시간 설정 session.setMaxInactiveInterval(1800); //1800초 보안상 더 중요한 부분에 대하여 적용할 수 있는 장점 존재 제공 속성 sessionId: 세션 ID, JSESSIONID 값 maxInactiveInterval: 세션의 유효 시간 creationTime: 세션 생성일시 lastAccessedTime: 최근에 sessionID(JSESSIONID)를 전달하는 요청을 보낸 시간 isNew: 새로 생성된 세션인지 여부 직접 구현 @Component public class SessionManager { public static final String SESSION_COOKIE_NAME = "mySessionId"; private Map<String, Object> sessionStore = new ConcurrentHashMap<>(); /** * 세션 생성 */ public void createSession(Object value, HttpServletResponse response) { //세션 id를 생성하고, 값을 세션에 저장 String sessionId = UUID.randomUUID().toString(); sessionStore.put(sessionId, value); //쿠키 생성 Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId); response.addCookie(mySessionCookie); } /** * 세션 조회 */ public Object getSession(HttpServletRequest request) { Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME); if (sessionCookie == null) { return null; } return sessionStore.get(sessionCookie.getValue()); } /** * 세션 만료 */ public void expire(HttpServletRequest request) { Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME); if (sessionCookie != null) { sessionStore.remove(sessionCookie.getValue()); } } private Cookie findCookie(HttpServletRequest request, String cookieName) { if (request.getCookies() == null) { return null; } return Arrays.stream(request.getCookies()) .filter(cookie -> cookie.getName().equals(cookieName)) .findAny() .orElse(null); } } 상수 관리 로그인 등을 위한 상수는 인터페이스 혹은 추상 클래스로 만들어두는 게 좋다. 상수를 위한 클래스는 인스턴스를 생성할 일이 따로 없기 때문이다. TrackingModes 로그인 처음 시도시 URL에 다음과 같이 jsessionid가 포함되어 있다. http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872 이는 웹 브라우저가 쿠키를 지원하지 않을 경우 쿠키 대신 URL을 통해 세션을 유지할 수 있게 돕는 방식이다. 타임리프 같은 템플릿을 사용해 자동으로 URL에 해당 값이 계속 포함되도록 구현해야 한다. 다만, URL 전달 방식은 잘 사용하지 않으므로 application.properties를 설정해 기능을 꺼두는게 좋다. 스프링 URL 매핑 전략 변경으로 URL 전달 방식에 대해 404 에러가 나타날 때도 tracking-modes를 끄는게 권장 방법이다. server.servlet.session.tracking-modes=cookie 만약 반드시 URL에 jsessionid가 필요하다면 application.properties에 다음 옵션을 추가하자. spring.mvc.pathmatch.matching-strategy=ant_path_matcher 서블릿 필터와 스프링 인터셉터 서블릿 필터와 스프링 인터셉터는 웹과 관련된 공통 관심사를 편리하게 관리 가능 모든 컨트롤러에 공통으로 적용되는 로직을 관리 공통 관심사(cross-cutting concern)는 한 곳에서 관리해 변경 포인트를 줄여야 함 e.g. 로그인 여부 체크 (인증), 모든 고객의 요청 로그 남기기 공통 관심사는 AOP로도 해결하지만, 웹 관련 공통 관심사는 HTTP 헤더나 URL 정보들 필요 서블릿 필터와 스프링 인터셉터는 HttpServletRequest 제공 같은 리퀘스트에 대응하는 모든 로그에 같은 식별자(UUID)를 자동으로 남기고 싶은 경우 -> logback mdc 참고해 사용 서블릿 필터 필터 인터페이스 public interface Filter { public default void init(FilterConfig filterConfig) throws ServletException {} public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; public default void destroy() {} } 필터 인터페이스를 구현하고 등록하면, 서블릿 컨테이너가 필터를 싱글톤 객체로 생성 및 관리 메서드 init(): 필터 초기화 메서드 (서블릿 컨테이너 생성 시 호출) doFilter(): 요청이 올 때 마다 호출됨, 원하는 필터 로직을 여기에 구현 destroy(): 필터 종료 메서드 (서블릿 컨테이너 종료 시 호출) 필터 흐름 필터 정상 흐름 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 필터가 호출된 후 서블릿 호출 스프링을 사용하는 경우, 서블릿은 디스패처 서블릿을 의미 필터 제한 HTTP 요청 -> WAS -> 필터 -> X 필터는 적절하지 않은 요청이라 판단되면 서블릿 호출 X e.g. 비 로그인 사용자 필터 체인 HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러 필터는 체인으로 구성되고, 중간에 필터를 자유롭게 추가 가능 e.g. 로그 남기는 필터 적용 후, 로그인 여부 체크 필터 적용 특정 URL 패턴에만 적용 가능 /*: 모든 요청에 필터 적용 서블릿 URL 패턴과 동일하므로 참고 서블릿 필터 고유 특징 (거의 사용 X) 다음 필터 또는 서블릿을 호출시, request, response를 다른 객체로 바꿔서 넘길 수 있음 chain.doFilter(request, response) ServletRequest , ServletResponse 를 구현한 다른 객체를 만들어서 넘기면, 해당 객체를 다음 필터 또는 서블릿에서 사용 스프링 인터셉터에서는 제공 X 로그인 필터 구현 예시 로그인 필터 @Slf4j public class LoginCheckFilter implements Filter { private static final String[] whitelist = {"/", "/members/add", "/login", "/logout","/css/*"}; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String requestURI = httpRequest.getRequestURI(); HttpServletResponse httpResponse = (HttpServletResponse) response; try { log.info("인증 체크 필터 시작 {}", requestURI); if (isLoginCheckPath(requestURI)) { log.info("인증 체크 로직 실행 {}", requestURI); HttpSession session = httpRequest.getSession(false); if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) { log.info("미인증 사용자 요청 {}", requestURI); //로그인으로 redirect //로그인 후 요청 경로로 돌아가기 위해 redirectURL 전달 httpResponse.sendRedirect("/login?redirectURL=" + requestURI); return; //중요, 미인증 사용자는 다음으로 진행하지 않고 끝! } } // 중요! // 다음 필터 있으면 호출, 없으면 서블릿 호출 // 호출하지 않으면 다음단계로 진행되지 않음 chain.doFilter(request, response); } catch (Exception e) { throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함 } finally { log.info("인증 체크 필터 종료 {}", requestURI); } } /** * 화이트 리스트의 경우 인증 체크X */ private boolean isLoginCheckPath(String requestURI) { return !PatternMatchUtils.simpleMatch(whitelist, requestURI); } } 필터 등록 @Configuration public class WebConfig { @Bean public FilterRegistrationBean loginCheckFilter() { FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>(); //스프링 부트 사용시 가능 filterRegistrationBean.setFilter(new LoginCheckFilter()); //필터 체인 순서, 낮을수록 먼저 동작 filterRegistrationBean.setOrder(2); //필터를 적용할 URL 패턴 filterRegistrationBean.addUrlPatterns("/*"); return filterRegistrationBean; } } @ServletComponentScan @WebFilter(filterName = "logFilter", urlPatterns = "/*") 로 필터 등록이 가능하지만 필터 순서 조절이 안된다. 따라서 FilterRegistrationBean 을 사용하자. 스프링 인터셉터 특징 스프링 MVC 제공 서블릿 필터보다 편리하고 정교한 다양한 기능 제공 스프링 MVC를 사용하고 필터를 꼭 사용해야하는 상황이 아니라면 인터셉터 사용 권장 인터셉터 인터페이스 public interface HandlerInterceptor { default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {} default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {} default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {} } 인터셉터 인터페이스를 구현하고 등록 WebMvcConfigurer가 제공하는 addInterceptors()를 사용해 등록 인터셉터는 컨트롤러 호출 전과 후, 요청 완료 이후로 단계가 세분화 되어 있음 어떤 컨트롤러(handler)가 호출되는지 그리고 어떤 modelAndView가 반환되는지 호출 정보와 응답 정보를 받을 수 있음 서블릿 필터는 단순히 request, response만 제공했음 인터셉터 흐름 인터셉터 정상 흐름 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 스프링 MVC의 시작점이 디스패처 서블릿이므로 인터셉터는 이후 호출 인터셉터 제한 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> X 적절하지 않은 요청이라 판단되면 컨트롤러 호출 X e.g. 비 로그인 사용자 인터셉터 체인 HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러 인터셉터는 체인으로 구성되고, 중간에 인터셉터를 자유롭게 추가 가능 e.g. 로그 남기는 인터셉터 적용 후, 로그인 여부 체크 인터셉터 적용 디스패처 서블릿 내 호출 흐름 preHandle(): 컨트롤러 호출 전에 호출 (핸들러 어댑터 호출 전) preHandle() 응답값이 true이면 다음으로 진행 false이면 더 이상 진행 X (나머지 인터셉터, 핸들러 어댑터 모두 호출 X) postHandle(): 컨트롤러 호출 후에 호출 (핸들러 어댑터 호출 후) afterCompletion(): 뷰가 렌더링 된 이후에 호출 디스패처 서블릿 내 예외 흐름 preHandle(): 컨트롤러 호출 전에 호출 postHandle(): 컨트롤러에서 예외가 발생하면 호출되지 않음 afterCompletion() 예외 발생해도 항상 호출 예외와 무관하게 공통 처리 시 사용 예외를 파라미터로 받아서 어떤 예외 발생했는지 로그 출력 가능 특정 URL 패턴에만 적용 가능 서블릿 URL 패턴과 다름 (스프링 URL 패턴) 매우 정밀하게 설정 가능 (addPathPatterns, excludePathPatterns) 로그인 인터셉터 구현 예시 로그인 인터셉터 @Slf4j public class LoginCheckInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); log.info("인증 체크 인터셉터 실행 {}", requestURI); HttpSession session = request.getSession(false); if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) { log.info("미인증 사용자 요청"); //로그인으로 redirect response.sendRedirect("/login?redirectURL=" + requestURI); return false; // 다음 호출 진행 X } return true; // 다음 호출 진행 여부를 위해 반환 } } 인터셉터 등록 @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LogInterceptor()) .order(1) .addPathPatterns("/**") .excludePathPatterns("/css/**", "/*.ico", "/error"); registry.addInterceptor(new LoginCheckInterceptor()) .order(2) .addPathPatterns("/**") // 적용할 URL 패턴 .excludePathPatterns( "/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error" ); // 제외할 URL 패턴 } } Intercepter 구현 시 유의사항 스프링 인터셉터 구현체 역시 싱글톤처럼 관리되므로 멤버 변수를 사용할 때는 주의해야 한다. 나의 요청 중에 다른 쓰레드가 사용하면 멤버 변수값이 바꿔치기 될 수 있어서 위험하다. e.g. 로그 남길 시 UUID를 멤버 변수로 생성하면 위험 스프링 URL 패턴 스프링이 제공하는 URL 패턴은 서블릿의 패턴과 완전히 다르다. 더 자세하고 세밀한 설정이 가능한 장점이 있다. ? 한 문자 일치 경로(/) 안에서 0개 이상의 문자 일치 ** 경로 끝까지 0개 이상의 경로(/) 일치 {spring} 경로(/)와 일치하고 spring이라는 변수로 캡처 {spring:[a-z]+} matches the regexp [a-z]+ as a path variable named “spring” {spring:[a-z]+} regexp [a-z]+ 와 일치하고, “spring” 경로 변수로 캡처 {*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처 /pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/toast.html /resources/.png — matches all .png files in the resources directory /resources/** — matches all files underneath the /resources/ path, including /resources/image.png and /resources/css/spring.css /resources/{path} — matches all files underneath the /resources/ path and captures their relative path in a variable named “path”; /resources/image.png will match with “path” → “/image.png”, and /resources/css/spring.css will match with “path” → “/css/spring.css” /resources/{filename:\w+}.dat will match /resources/spring.dat and assign the value “spring” to the filename variable Spring PathPattern 공식문서 ArgumentResolver 활용 공통 관심사를 ArgumentResolver로 구현해 등록 가능 핸들러의 파라미터에 필요한 객체를 전달하는 것에 초점 컨트롤러를 더욱 편리하게 사용 가능 로그인 처리에 활용 시 인터셉터와의 차이점 (역할과 시점이 다름) 인터셉터에서는 전역적으로 세션을 체크하여 요청의 인가 및 인증 여부를 확인 ArgumentResolver에서는 특정 요청 중에 세션 정보가 필요할 시 가져오는 역할을 수행 e.g. 구현 예시: 로그인 회원 편리하게 가져오기 흐름 @Login 애노테이션이 있으면 직접 만든 ArgumentResolver가 동작 자동으로 세션에 있는 로그인 회원 찾아주고, 없으면 null 반환 컨트롤러 @GetMapping("/") public String homeLoginArgumentResolver(@Login Member loginMember, Model model) { //세션에 회원 데이터가 없으면 home if (loginMember == null) { return "home"; } //세션이 유지되면 로그인으로 이동 model.addAttribute("member", loginMember); return "loginHome"; } @Login 애노테이션 생성 package hello.login.web.argumentresolver; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface Login { } HandlerMethodArgumentResolver 구현 @Slf4j public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { log.info("supportsParameter 실행"); boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); return hasLoginAnnotation && hasMemberType; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { log.info("resolveArgument 실행"); HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); HttpSession session = request.getSession(false); if (session == null) { return null; } return session.getAttribute(SessionConst.LOGIN_MEMBER); } } supportsParameter() @Login 애노테이션이 있으면서 Member 타입이면, 해당 ArgumentResolver 사용 resolveArgument() 컨트롤러 호출 직전에 호출되어 필요한 파라미터 정보를 생성 여기서는 세션에 있는 로그인 회원 정보인 member 객체를 찾아 반환 이후 컨트롤러의 파라미터에 member 객체를 전달 ArgumentResolver 등록 @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new LoginMemberArgumentResolver()); } //... } Reference 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
Java-Ecosystem
· 2024-07-10
스프링 Validation
Validation HTTP 요청이 정상인지 검증하는 것은 컨트롤러의 중요한 역할 스프링 제공 방법 Bean Validation (Bean Validation 2.0 (JSR-380)) + BindingResult 검증 로직을 공통화 및 표준화해 객체에 애노테이션으로 검증 적용 객체에 검증 애노테이션 적용 (e.g. @NotNull, @Range…) 파라미터에 @Valid, @Validated 적용하면 검증 실행 기술 표준으로서 검증 애노테이션 및 여러 인터페이스의 모음 구현체는 일반적으로 하이버네이트 Validator를 사용 (ORM과 관련 없음) 스프링 MVC가 Bean Validator를 사용하는 과정 spring-boot-starter-validation를 라이브러리로 등록 스프링 부트가 자동으로 Bean Validator를 인지하고 스프링에 통합 스프링 부트는 LocalValidatorFactoryBean을 글로벌 Validator로 등록 애노테이션을 보고 검증을 수행하는 검증기 (e.g. @NotNull) @Valid, @Validated 적용으로 파라미터 검증 실행 검증 오류 발생 시 FieldError, ObjectError 생성해 BindingResult 담음 검증 순서 타입에 맞춰 각각의 필드 바인딩 시도 실패시 typeMismatch로 FieldError 추가 바인딩에 성공한 필드만 Bean Validation 적용 비즈니스 로직 적용 방법 컨트롤러 용도에 따라 검증 전용 객체 분리하기 (도메인 객체는 순수하게 유지) 장점: 검증 중복이 없고 복잡도 낮음 단점: 컨트롤러에서 전송 받은 데이터를 도메인 객체 생성 및 변환 과정 추가 검증 전용 객체 이름은 일관성만 있게자유로이 명명 ItemSave, ItemSaveForm , ItemSaveRequest ,ItemSaveDto… 동일한 도메인 객체 사용 + Bean Validation의 groups 속성으로 분류 - 권장 X 장점: 중간에 도메인 객체 생성 과정 없이 컨트롤러부터 리포지토리까지 전달 가능 단점: 간단한 경우에만 가능 검증할 기능을 등록 및 수정 등 각각의 그룹으로 나누어 적용 가능 저장용 groups 생성 package hello.itemservice.domain.item; public interface SaveCheck { } 수정용 groups 생성 package hello.itemservice.domain.item; public interface UpdateCheck { } groups 적용 @Data public class Item { @NotNull(groups = UpdateCheck.class) //수정시에만 적용 private Long id; @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) private String itemName; @NotNull(groups = {SaveCheck.class, UpdateCheck.class}) @Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class}) private Integer price; @NotNull(groups = {SaveCheck.class, UpdateCheck.class}) @Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용 private Integer quantity; public Item() { } public Item(String itemName, Integer price, Integer quantity) { this.itemName = itemName; this.price = price; this.quantity = quantity; } } @Validated에 groups 적용 (@Valid는 groups 기능이 없음) @Validated(SaveCheck.class) @Validated(UpdateCheck.class) ObjectError 처리 방법 글로벌 오류는 자바 코드로 직접 작성해 처리 권장 (메서드 추출) public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { //특정 필드 예외가 아닌 전체 예외 if (item.getPrice() != null && item.getQuantity() != null) { int resultPrice = item.getPrice() * item.getQuantity(); if (resultPrice < 10000) { bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null); } } ... } @ScriptAssert() - 권장 X @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000") public class Item { //... } 생성되는 메시지 코드 ScriptAssert.item ScriptAssert 제약이 많고 복잡하여 권장하지 않음 API 적용 시 고려할 점 (HTTP 메시지 컨버터) @Valid, @Validated -> HttpMessageConverter(@RequestBody)에 적용 가능 public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) API는 3가지 경우 고려 필요 성공 요청 실패 요청 HttpMessageConverter 에서 요청 JSON을 객체로 생성하는데 실패 컨트롤러 자체가 호출되지 않고 예외가 발생 검증 적용(Validator) X 검증 오류 요청 JSON 객체 생성은 성공했으나 이후 검증 실패 HttpMessageConverter 는 성공하지만 검증(Validator)에서 오류가 발생 필요한 의존관계 패키지 implementation 'org.springframework.boot:spring-boot-starter-validation jakarta.validation-api: Bean Validation 인터페이스 hibernate-validator: 구현체 검증 애노테이션 @NotBlank: null 과 "" 과 " " 모두 허용하지 않음 @NotEmpty: null 과 ""을 허용하지 않음 @NotNull: null을 허용하지 않음 @Range(min = 1000, max = 1000000): 범위 안의 값만 허용 @Max(9999): 최대 지정 값까지만 허용 테스트에서 Bean Validation 사용하기 ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); Validator validator = factory.getValidator(); Set<ConstraintViolation<Item>> violations = validator.validate(item); Bean Validation 오류 코드 Bean Validation이 오류 메시지를 찾는 순서 MessageResolver 생성 메시지 코드대로 messageSource 찾기 (errors.properties) 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}") 라이브러리가 제공하는 기본 값 사용 기본은 애노테이션 이름으로 오류코드를 등록 e.g. @NotBlank NotBlank.item.itemName NotBlank.itemName NotBlank.java.lang.String NotBlank 생성이 예상되는 적절한 오류 코드로 errors.properties에 원하는 메시지 등록 가능 BindingResult 스프링이 제공하는 검증 오류를 보관하는 객체 (Model에 자동 포함) 실제로는 인터페이스이고 Errors 인터페이스를 상속받고 있음 실제 넘어오는 구현체는 BeanPropertyBindingResult 타입으로 Errors를 사용해도 되지만 BindingResult는 더 추가적인 기능 제공 관례상으로도 Errors보다 BindingResult 많이 사용 반드시 @ModelAttribute 파라미터의 바로 뒤에 위치해야 함 e.g. @ModelAttribute Item item, BindingResult bindingResult 타임리프가 통합 기능도 제공 (#fields, th:errors, th:errorclass) 검증 오류 적용 방법 스프링 자동 적용 @ModelAttribute에 데이터 바인딩 오류가 발생 시 자동 처리 e.g. 주로 타입 오류 BindingResult가 없으면 컨트롤러 호출 X, 400 오류 페이지 이동 BindingResult가 있으면 스프링이 new FieldError() 실행 생성한 필드 에러 객체를 BindingResult에 자동으로 담음 이후 컨트롤러 정상 호출 개발자가 직접 넣기 rejectValue(), reject()를 호출하는 방법 target(검증 대상 모델)을 BindingResult가 이미 앎 (깔끔한 코드) 내부에서 MessageCodesResolver를 사용 FieldError, ObjectError 생성 후 오류 코드들을 보관 즉, MessageCodesResolver가 생성한 오류들을 가지고 처리 필드 에러 처리 (rejectValue()) bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null); rejectValue() 파라미터 field: 오류 필드명 errorCode messageCodesResolver를 위한 오류 코드 필드명, 오브젝트명, 오류코드를 조합한 키로 메시지 가져옴 errorArgs: 메시지에서 사용하는 인자 defaultMessage: 오류 메시지 찾을 수 없을 때 기본 메시지 글로벌 에러 처리 (reject()) bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null); reject() 파라미터 errorCode errorArgs defaultMessage 참고 ValidationUtils: Empty, 공백 등의 조건까지 한 줄로 처리 가능 ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required"); FieldError, ObjectError 직접 생성을 통한 방법 (addError()) 필드 에러 처리 (FieldError) FieldError 객체를 생성해 bindingResult에 담음 FieldError는 ObjectError의 자식 bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다.")); FieldError 파라미터 (생성자 2개) objectName: @ModelAttribute 이름 field: 오류가 발생한 필드 이름 rejectedValue: 사용자가 입력한 값 (거절된 값) bindingFailure: 바인딩 실패인지, 검증 실패인지 구분 값 codes: 메시지 코드 지정 (errors.properties) 배열로 여러 값을 전달 가능 순서대로 매칭해 처음 매칭되는 메시지 사용 (없으면 예외 발생) e.g. new String[] {"max.item.quantity"} arguments: 메시지에서 사용하는 인자 {0}, {1}… 순서대로 치환 값 전달 e.g. new Object[] {9999} defaultMessage: 오류 기본 메시지 글로벌 에러 처리 (ObjectError - 특정 필드를 넘어서는 오류) ObjectError 객체를 생성해 bindingResult에 담음 bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); ObjectError 파라미터 (생성자 2개) objectName: @ModelAttribute 이름 codes: 메시지 코드 arguments: 메시지에서 사용하는 인자 defaultMessage: 오류 기본 메시지 Validator 사용하기 스프링이 제공하는 Validator 인터페이스를 상속해 검증 로직을 담기 가능 컨트롤러에서 검증 로직을 분리하고 재사용할 수 있음 구현할 메서드 supports 해당 검증기 지원 여부 확인 @Override public boolean supports(Class<?> clazz) { return Item.class.isAssignableFrom(clazz); } validate(Object target, Errors errors) 검증 대상 객체와 BindingResult 전달 적용 방법 WebDataBinder 에 검증기 추가 + @Validated 파라미터 적용 @InitBinder public void init(WebDataBinder dataBinder) { log.info("init binder {}", dataBinder); dataBinder.addValidators(itemValidator); } WebDataBinder (컨트롤러에 추가) 스프링 파라미터 바인딩 역할 및 검증 기능 수행 해당 컨트롤러가 호출될 때마다 검증기 적용 즉, 요청이 올 때마다 새로 생성해 검증 글로벌 설정도 가능하지만 사용할 일 거의 없음 (권장 X) @SpringBootApplication public class ItemServiceApplication implements WebMvcConfigurer { public static void main(String[] args) { SpringApplication.run(ItemServiceApplication.class, args); } @Override public Validator getValidator() { return new ItemValidator(); } } 글로벌 설정 시 BeanValidator가 자동 등록되지 않음 @Validated 검증 실행을 원하는 파라미터에 적용 supports를 통해 등록된 검증기들 중 실행해야 할 것을 구분 검증기 직접 호출도 가능하지만 불편 개발자 직접 처리 중복 처리가 많아짐 타입 오류 처리가 안됨 Integer 타입 파라미터에 문자가 들어오면 오류 스프링 MVC에서 컨트롤러 호출되기 전부터 400 예외 발생 특히, 타입 오류의 경우 검증 전 고객의 입력 데이터를 보존하지 못함 (UX에 중요한 부분) 클라이언트 검증 & 서버 검증 클라이언트 검증만 사용하면 조작이 가능해 보안에 취약하고, 서버 검증만 있다면 즉각적인 고객 사용성이 부족해진다. 따라서, 클라이언트 검증과 서버 검증은 둘 다 적절히 섞어 사용하되, 최종적으로 서버 검증을 필수로 진행한다. @Validated와 @Valid 검증 시 @Validated, @Valid 둘 다 사용 가능하다. @Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다. 다만, @Valid는 다음 의존관계 추가가 필요하다. `implementation ‘org.springframework.boot:spring-boot-starter-validation’ javax.validation VS org.hibernate.validator javax.validation으로 시작하면 표준 인터페이스, org.hibernate.validator로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증이다. 다만, 실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 된다. BeanValidation - @ModelAttribute VS @RequestBody @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도(타입이 맞지 않는 오류) 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다. @RequestBody 는 객체 단위로 바인딩이 적용된다. HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다. MessageCodesResolver 검증 오류 코드로 메시지 코드 후보들을 생성 MessageCodesResolver는 인터페이스이고 DefaultMessageCodesResolver가 기본 구현체 보통 이렇게 생성된 메시지 코드를 기반으로 MessageSource에서 메시지를 찾음 기본 메시지 코드 생성 규칙 객체 오류 규칙 code + “.” + object name code e.g. 오류 코드: required, object name: item required.item required 필드 오류 규칙 code + “.” + object name + “.” + field code + “.” + field code + “.” + field type code 예) 오류 코드: typeMismatch, object name: "user", field: "age", field type: int typeMismatch.user.age typeMismatch.age typeMismatch.int typeMismatch 메시지 처리 전략 예시 (errors.properties) #==ObjectError== #Level1 totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1} #Level2 totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1} #==FieldError== #Level1 required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. #Level2 - 생략 #Level3 required.java.lang.String = 필수 문자입니다. required.java.lang.Integer = 필수 숫자입니다. min.java.lang.String = {0} 이상의 문자를 입력해주세요. min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요. range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요. range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요. max.java.lang.String = {0} 까지의 문자를 허용합니다. max.java.lang.Integer = {0} 까지의 숫자를 허용합니다. #Level4 required = 필수 값 입니다. min= {0} 이상이어야 합니다. range= {0} ~ {1} 범위를 허용합니다. max= {0} 까지 허용합니다. 메시지 처리 기본 범용 메시지를 두고, 세밀하게 작성해야 하는 경우에 세밀한 메시지를 적용하도록 메시지 단계를 두자 세밀한 메시지가 범용 메시지보다 우선순위 가진다. 예를 들어, required라는 메시지만 있으면 해당 메시지를 기본으로 사용하고, required.item.itemName 같이 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용한다. MessageCodesResolver는 메시지 관련 공통 전략을 편리하게 적용할 수 있게 지원한다. 스프링 타입 오류 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 자동으로 사용한다. 이 경우 MessageCodesResolver를 통해 4가지 메시지 코드가 발생할텐데, errors.properties에 해당 코드가 없다면 스프링이 생성한 기본 메시지가 출력된다. Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "A" 기본 출력을 임의로 바꾸고 싶다면, errors.properties에 다음과 같은 코드를 적절하게 추가하면 된다. typeMismatch.java.lang.Integer=숫자를 입력해주세요. typeMismatch=타입 오류입니다. Reference 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 @NotNull, @NotEmpty, @NotBlank 의 차이점 및 사용법
Java-Ecosystem
· 2024-06-30
스프링 MVC 메시지와 국제화
메시지 다양한 메시지를 한 곳에서 관리하도록 하는 기능 스프링 부트는 messages.properties를 기본 메시지 파일로 인식하고 관리 경로: /resources/messages.properties 국제화 메시지 파일을 각 나라 언어별로 별도 관리해 서비스를 국제화 베이스파일명_언어 형식으로 메시지 파일을 만들어두면 자동으로 인식 e.g. messages_en.properties, messages_ok.properties 기본은 HTTP accept-language 헤더 값을 보고 판단 스프링 부트 기본 LocaleResolver: AcceptHeaderLocaleResolver 혹은 사용자가 직접 언어를 선택하도록 하고, 쿠키나 세션 기반 처리도 가능 LocaleResolver 인터페이스의 구현체 변경 public interface LocaleResolver { Locale resolveLocale(HttpServletRequest request); void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale); } 찾을 수 있는 국제화 파일이 없는 경우, 언어정보 없는 디폴트 파일 기본 사용 (messages.properties) MessageSource 인터페이스 public interface MessageSource { String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale); String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException; 스프링은 메시지 관리 기능을 MessageSource 인터페이스를 통해 제공 getMessage: 파라미터만 다르고 메시지를 읽어오는 기능 수행 메시지가 없는 경우 NoSuchMessageException 발생 code: 메시지 파일에서 지정한 키 args: 매개변수를 전달하는 배열 메시지 파일의 {0} 부분 치환 hello.name=안녕 {0} Object[] 배열로 넘겨야 함 ms.getMessage("hello.name", new Object[]{"Spring"}, null); defaultMessage: 메시지가 없을 때 기본 메시지 지정 (예외 발생 예방) locale: 국제화 파일 선택 e.g. Locale.KOREA, Locale.ENGLISH 정보가 없을 시 (null) Locale.getDefault() 호출해, 시스템 기본 로케일 사용 시스템 기본 로케일 조회 실패 시 기본 이름 메시지 파일 조회 (message.properties) Locale이 en_US인 경우 messages_en_US / messages_en / messages 순서로 찾음 스프링 부트는 자동으로 ResourceBundleMessageSource 구현체를 스프링 빈으로 등록 메시지 파일 임의 지정 방법 (application.properties) e.g. spring.messages.basename=messages,config.i18n.messages 기본값: spring.messages.basename=messages 직접 등록 방법 @Bean public MessageSource messageSource() { ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); messageSource.setBasenames("messages", "errors"); messageSource.setDefaultEncoding("utf-8"); return messageSource; basenames: 설정 파일 이름 지정 여러 파일을 한 번에 지정 가능 (messages, errors) messages.properties, errors.properties 파일을 읽음 defaultEncoding: 인코딩 정보 지정 (utf-8 사용하면 됨) Reference 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
Java-Ecosystem
· 2024-06-21
스프링 데이터 접근 활용 기술
데이터 관련 테크닉 DTO (Data Transfer Object) 기능은 없고 데이터를 전달만 하는 용도로 사용하는 객체 기능이 있어도 주 기능이 데이터 전달이면 DTO라 할 수 있음 네이밍 컨벤션은 자유여서 DTO를 꼭 붙이지 않아도 됨 DTO는 최종 호출되는 곳이 소유자이므로, 소유자가 있는 패키지에 위치하는 것이 맞다! 보통은 리포지토리 패키지에 위치할 것이고, 만일 서비스에서 사용이 끝난다면 서비스 패키지에 둠 이를 지키지 않으면 순환참조가 발생할 수 있음 별칭 (as) 별칭을 사용하면 DB 컬럼 이름과 객체의 이름의 표기법이 불일치하거나 이름이 아얘 다른 문제 해결 가능 관례적으로 DB는 snake case를 쓰고 자바는 camel case를 써서 문제 발생 BeanProperty 관련 기능을 쓸 때, select item_name 쿼리의 경우 setItem_name()이라는 메서드가 없으므로 문제가 생기는데 select item_name as itemName은 불일치 문제를 해결 보통 DB 관련 기능들은 표기법 자동 변환을 지원 NamedParameterJdbcTemplate의 BeanPropertyRowMapper() 등 컬럼 이름과 객체 이름이 완전히 다를 때만 SQL에서 별칭을 사용하면 됨 테스트 원칙과 방법 테스트는 다른 환경과 철저히 분리해야 함 메모리 모드 (=임베디드 모드) DB를 애플리케이션에 내장해 함께 실행 (애플리케이션 실행 시 DB를 JVM 메모리에 포함) 애플리케이션이 종료되면 임베디드 모드 DB도 함께 종료되고 데이터도 모두 사라짐 DB 초기화 SQL 스크립트 필요 (테이블 생성 etc…) 스프링 부트가 SQL 스크립트를 실행해 애플리케이션 로딩 시점에 DB 초기화 제공 위치와 파일 이름 일치 필요 src/test/resources/schema.sql SQL 실행 로그 확인 방법 (application.properties) logging.level.org.springframework.jdbc=debug 방법 별 다른 DB 설정 안하기 스프링 부트는 별 다른 DB 설정이 없으면 메모리 DB로 접속 src/test/resources/application.properties의 DB 접근 정보 지우기 테스트 프로필로 메모리 DB 접속하는 dataSource 빈 등록하기 jdbc:h2:mem:db 등의 메모리 주소로 데이터 소스 생성 및 사용 테스트 전용 DB 사용하기 다른 이름의 데이터베이스를 하나 생성하고 테스트 시에는 해당 주소에 접속 테스트는 다른 테스트와 격리해야 하고 반복 실행할 수 있어야 함 트랜잭션 & 롤백 전략 각각의 테스트를 시작할 때 트랜잭션을 열고 끝날 때 해당 트랜잭션을 롤백 방법 @Transactional 기본은 로직이 성공적으로 수행되면 커밋 테스트에서 사용하면, 테스트를 각각 트랜잭션 안에서 실행하고 끝나면 자동 롤백 트랜잭션 전파로 서비스, 리포지토리도 테스트에서 시작한 같은 트랜잭션에 참여 클래스, 메서드 단위 적용 가능 간혹 테스트에서 실제 저장을 하고 싶을 때는 @Commit을 테스트에 함께 붙이면 됨 @BeforeEach, @AfterEach에서 트랜잭션 열고 롤백 transactionManager.getTransaction(...); transactionManager.rollback(); 테스트 유의점 테스트시 application.properties는 src/test/resources에 있는 것이 우선순위 실행 @SpringBootTest: @SpringBootApplication을 찾아 설정으로 사용 SQL Mapper 종류 선택기준 ORM 기술 스택을 사용하면, 네이티브 SQL을 사용할 때 JdbcTemplate 수준에서 거의 해결될 것 따라서, 단순한 쿼리가 많다면 JdbcTemplate 사용 반면에, 프로젝트에 복잡한 쿼리가 많다면 MyBatis 사용 둘 다 사용해도 되지만, MyBatis를 선택했다면 그것으로 충분할 것 JdbcTemplate 장점 설정의 편리함 spring-jdbc 라이브러리에 포함되어 있어 간단하게 바로 사용 가능 spring-jdbc는 스프링으로 JDBC 사용할 때 기본으로 사용되는 라이브러리 반복 문제 해결 템플릿 콜백 패턴으로 JDBC를 직접 사용할 때 발생하는 반복 작업을 대신 처리 단점 동적 SQL 해결이 어려움 SQL을 자바 코드로 작성하기 때문에, SQL 라인이 넘어갈 때 마다 문자 더하기를 해주어야 함 패키지 설정 implementation 'org.springframework.boot:spring-boot-starter-jdbc' 추가 설정(application.properties) SQL 로그: logging.level.org.springframework.jdbc=debug main, test 모두 추가 주요 기능 JdbcTemplate 순서 기반 파라미터 바인딩 지원 객체 생성 private final JdbcTemplate template template = new JdbcTemplate(dataSource); 단건 조회 template.queryForObject(sql, memberRowMapper(), memberId); 결과 로우가 하나일 때 사용 결과가 없으면 EmpytyResultDataAccessException 발생 결과가 둘 이상이면 IncorrectResultSizeDataAccessException 발생 리스트 조회 template.query(sql, memberRowMapper(), param.toArray()); 결과가 하나 이상일 때 사용 결과 없으면 빈 컬렉션을 반환 갱신 template.update(sql, money, memberId); KeyHolder DB가 Identity (auto increment) 전략을 사용할 때, Insert 완료 후 PK 값을 채움 임의의 SQL (DDL 등) jdbcTemplate.execute("create table mytable (id integer, name varchar(100))"); 응답 결과 매핑 RowMapper 데이터베이스의 반환 결과인 ResultSet을 객체로 변환 private RowMapper<Member> memberRowMapper() { return (rs, rowNum) -> { Member member = new Member(); member.setMemberId(rs.getString("member_id")); member.setMoney(rs.getInt("money")); return member; }; } NamedParameterJdbcTemplate (권장) 이름 기반 파라미터 바인딩 지원 객체 생성 private final NamedParameterJdbcTemplate template; template = new NamedParameterJdbcTemplate(dataSource); 자주 사용하는 파라미터 종류 Map Map<String, Object> param = Map.of("id", id); Item item = template.queryForObject(sql, param, itemRowMapper()); MapSqlParameterSource SqlParameterSource param = new MapSqlParameterSource() .addValue("itemName", updateParam.getItemName()) .addValue("price", updateParam.getPrice()) .addValue("quantity", updateParam.getQuantity()) .addValue("id", itemId); template.update(sql, param); BeanPropertySqlParameterSource 자바빈 프로퍼티 규약을 통해 자동으로 파라미터 객체 생성 전달하는 객체에 getXxx 메소드가 있어야 함 SqlParameterSource param = new BeanPropertySqlParameterSource(item); KeyHolder keyHolder = new GeneratedKeyHolder(); template.update(sql, param, keyHolder); 응답 결과 매핑 BeanPropertyRowMapper ResultSet의 결과를 받아서 자바빈 규약에 맞추어 데이터를 변환 private RowMapper<Item> itemRowMapper() { return BeanPropertyRowMapper.newInstance(Item.class); //camel 변환 지원 } SimpleJdbcInsert INSERT SQL을 직접 작성하지 않아도 되도록 지원 생성 시점에 DB 테이블 메타 데이터 조회, 어떤 칼럼이 있는지 확인 (usingColumns 생략 가능) 객체 생성 private final SimpleJdbcInsert jdbcInsert; jdbcInsert = new SimpleJdbcInsert(dataSource) ` .withTableName(“item”)` ` .usingGeneratedKeyColumns(“id”);` ` // .usingColumns(“item_name”, “price”, “quantity”); //생략 가능` 삽입 SqlParameterSource param = new BeanPropertySqlParameterSource(item); Number key = jdbcInsert.executeAndReturnKey(param); //생성된 키 자동 조회 SimpleJdbcCall 스토어드 프로시저를 편리하게 호출 가능 MyBatis JdbcTemplate의 대부분의 기능 및 추가 기능 제공 장점 SQL을 XML에 편리하게 작성 가능 (문자 더하기 불편 X) 동적 쿼리를 편리하게 작성 가능 단점 약간의 설정이 필요 패키지 설정 implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0 스프링 부트가 관리해주는 공식 라이브러리가 아니므로 뒤에 버전 정보를 붙여야 함 스프링 부트 3.0 이상 -> mybatis-spring-boot-starter 3.0.3 사용 추가 설정 (application.properties) mybatis.type-aliases-package=hello.itemservice.domain //패키지 이름 mybatis.configuration.map-underscore-to-camel-case=true logging.level.hello.itemservice.repository.mybatis=trace mybatis.type-aliases-package 마이바티스에서 타입 정보 사용할 때 패키지 이름을 적어야 하는데, 여기 명시 시 생략 가능 지정 패키지 및 하위 패키지까지 자동 인식 여러 위치 지정 시 ,, ;로 구분 mybatis.configuration.map-underscore-to-camel-case 언더바 -> 카멜 케이스 자동 변경 기능 활성화 DB 컬럼 이름과 자바 객체 이름 사이의 불일치 해결 logging.level.hello.itemservice.repository.mybatis=trace 쿼리 로그 확인 main, test 모두 추가 주요 기능 매퍼 인터페이스 @Mapper 마이바티스 매핑 XML을 호출해주는 매퍼 인터페이스에 @Mapper 애노테이션을 적용 매퍼 인터페이스의 메서드를 호출하면 연결된 XML의 SQL을 실행하고 결과를 반환 원리 애플리케이션 로딩 시점에 MyBatis 스프링 연동 모듈이 @Mapper 인터페이스 조회 동적 프록시 기술을 사용해 조회된 해당 인터페이스들의 구현체를 생성 생성한 구현체를 스프링 빈으로 등록 매퍼 구현체 MyBatis 스프링 연동 모듈이 생성한 구현체 덕에 인터페이스만으로 깔끔하게 사용 가능 MyBatis 예외를 스프링 예외 추상화인 DataAccessException에 맞게 변환해 반환 XML 매핑 파일 <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="hello.item.repository.mybatis.ItemMapper"> <insert id="save" useGeneratedKeys="true" keyProperty="id"> insert into item (item_name, price, quantity) values (#{itemName}, #{price}, #{quantity}) </insert> <update id="update"> update item set item_name=#{updateParam.itemName}, price=#{updateParam.price}, quantity=#{updateParam.quantity} where id = #{id} </update> <select id="findById" resultType="Item"> select id, item_name, price, quantity from item where id = #{id} </select> <select id="findAll" resultType="Item"> select id, item_name, price, quantity from item <where> <if test="itemName != null and itemName != ''"> and item_name like concat('%',#{itemName},'%') </if> <if test="maxPrice != null"> and price <= #{maxPrice} </if> </where> </select> </mapper> src/main/resources 하위에 매핑 인터페이스와 패키지 위치를 똑같이 맞추어 생성 src/main/resources/hello/item/repository/mybatis/ItemMapper.xml namespace 매퍼 인터페이스를 지정 XML 파일 경로 수정 (`application.properties) mybatis.mapper-locations=classpath:mapper/**/*.xml resources/mapper를 포함한 하위 폴더의 XML을 매핑 파일로 인식 main, test 모두 적용 기본 쿼리 <insert> <insert id="save" useGeneratedKeys="true" keyProperty="id"> insert into item (item_name, price, quantity) values (#{itemName}, #{price}, #{quantity}) </insert> id: 매퍼 인터페이스에 설정한 메서드 이름 지정 #{}: 파라미터 바인딩 useGeneratedKeys: IDENTITY 전략일 때 사용 keyProperty: 생성되는 키 이름 지정 <update> import org.apache.ibatis.annotations.Param; void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateParam); <update id="update"> update item set item_name=#{updateParam.itemName}, price=#{updateParam.price}, quantity=#{updateParam.quantity} where id = #{id} </update> @Param: 파라미터가 2개 이상이면 애노테이션으로 이름을 지정해 구분해야 함 <select> <select id="findById" resultType="Item"> select id, item_name, price, quantity from item where id = #{id} </select> resultType: 반환 타입 명시 SQL 결과를 편리하게 객체로 변환 <select> 동적 쿼리 <select id="findAll" resultType="Item"> select id, item_name, price, quantity from item <where> <if test="itemName != null and itemName != ''"> and item_name like concat('%',#{itemName},'%') </if> <if test="maxPrice != null"> and price <= #{maxPrice} </if> </where> </select> <if>: 모두 실패해면 where를 만들지 않고, 하나라도 성공하면 where, and 자동 지원 XML 특수문자 XML은 태그 사용으로 인해 특수문자 사용이 제한 되어 다음과 같은 연산 키워드 지원 < (<), > (>), & (&) CDATA CDATA 구문 내에서는 특수문자 사용 가능 <![CDATA[ and price <= #{maxPrice} ]]> 다른 동적 쿼리 형태 <choose>, <when>, <otherwise>: switch 구문과 유사 <foreach>: 컬렉션 반복 처리 시 사용 애노테이션 SQL 작성 인터페이스 메서드에 적용 @Select, @Insert, @Update, @Delete @Select("select id, item_name, price from item where id=#{id}") MyBatis의 장점은 XML에 있으므로 가끔 간단한 쿼리 정도에만 사용하고 거의 사용 X 동적 SQL도 어려움 <sql> SQL 코드를 재사용 가능 <sql id="userColumns"> ${alias}.id,${alias}.username,${alias}.password </sql> <include>로 sql 조각을 찾아 사용 <include refid="userColumns"><property name="alias" value="t1"/></include> <resultMap> DB 컬럼 이름과 객체 이름 불일치 문제를 별칭 사용 대신 사용자 지정 매핑으로 해결 스프링 트랜잭션 AOP와 예외 정책 스프링 트랜잭션 예외 발생 기본 정책 언체크 예외(RuntimeException, Error)는 롤백 애플리케이션에서 복구 불가능한 예외로 가정 체크 예외(Exception)는 커밋 비즈니스적으로 의미가 있을 때 사용할 것으로 가정 시스템은 정상 동작했지만 비즈니스 상황에서 문제 존재 즉, 예외를 발생시켜 알림 예외를 마치 리턴 값처럼 사용하는 상황 비즈니스 예외는 매우 중요하고 반드시 처리해야 하는 경우가 많음 e.g. 결제 잔고 부족 시 필요한 후속작업 주문 데이터를 저장하고, 결제 상태를 대기로 처리 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내 NotEnoughMoneyException 커스텀 체크 예외 발생시킴 (비즈니스 예외) 예외 전략 비즈니스 상황에서 체크 예외 던지기 전략 (권장 - 스프링 기본 정책) 비즈니스 상황에 따라 체크 예외도 롤백하고 싶은 경우 rollbackFor 옵션으로 롤백 가능 비즈니스 상황에서 리턴 값 사용하기 전략 비즈니스 상황은 체크 예외 던지지 않기 (시스템 예외만 예외로 가정) 일관성 있는 리턴 값을 통해 다음 프로세스를 태움 (ENUM…) @Transactional 옵션 value, transactionManager 스프링 빈에 등록된 트랜잭션 매니저 중 어떤 것을 사용할지 지정 값 생략 시 기본 등록 트랜잭션 매니저 사용 스프링 부트가 라이브러리 보고 판단해 등록한 트랜잭션 매니저 보통 생략해서 사용 사용하는 트랜잭션 매니저가 둘 이상이라면, 이 값을 지정해 구분 애노테이션 속성이 하나인 경우 value 생략하고 바로 값 넣기 가능 public class TxService { @Transactional("memberTxManager") public void member() {...} @Transactional("orderTxManager") public void order() {...} } rollbackFor 스프링 트랜잭션 예외 발생 기본 정책에 추가로 롤백할 예외 지정 @Transactional(rollbackFor = Exception.class) 이 경우, 체크 예외인 Exception이 발생해도 롤백 noRollbackFor 스프링 트랜잭션 예외 발생 기본 정책에 추가로 롤백을 하지 않을 예외 지정 propagation 트랜잭션 전파 옵션 isolation 트랜잭션 격리 수준 지정 보통 DB 기본값을 따르고 애플리케이션 개발자가 직접 지정하는 경우는 적음 DEFAULT: DB 설정 격리 수준 따름 READ_UNCOMMITTED, READ_COMMITED, REPEATABLE_READ, SERIALIZABLE timeout 트랜잭션 수행 시간 타임아웃 지정 (초 단위) 운영 환경에 따라 동작하지 않을 수도 있기 때문에 동작 확인 필요 label @Transactional에 어떠한 태깅을 붙여 직접 읽고 싶을 때 사용 예를 들어, A라 적히면 A DB에서 B라 적히면 B DB 에서 조회 일반적으로 잘 사용하지 않음 readOnly 읽기 전용 트랜잭션 생성 읽기 전용 트랜잭션은 등록, 수정, 삭제가 안되고 읽기만 동작 (환경에 따라 비정상 동작 가능) 기본값: false @Transactional == @Transactional(readOnly=false) readOnly는 보통 생략 일반적으로 트랜잭션은 읽기 쓰기가 모두 가능한 형태로 생성됨 읽기에 대한다양한 성능 최적화 진행 프레임워크 JdbcTemplate은 읽기 전용 트랜잭션 안에서 변경을 실행하면 예외를 던짐 JPA는 읽기 전용 트랜잭션의 경우 커밋 시점에 플러시를 호출하지 않음 변경 감지를 위한 스냅샷 객체도 생성하지 않고 메모리를 절약 JDBC 드라이버 읽기 전용 트랜잭션 안에서 변경 쿼리가 발생하면 예외 던짐 읽기, 쓰기 (마스터, 슬레이브) DB를 구분해서 요청 (리플리케이션) 데이터베이스 읽기 트랜잭션은 읽기만 하면 되므로, 내부 성능 최적화 발생 읽기 트랜잭션이라면 일반적으로 true를 주는 것이 좋음 보통 JPA를 사용할 경우 성능 최적화가 더 큼 Jdbc에 대해서는 크게 일어나지 않거나 오히려 성능이 저하되는 경우도 있긴 함 스프링 트랜잭션 전파 (Propagation) 트랜잭션 전파 트랜잭션이 이미 진행 중인데 추가로 트랜잭션 수행 시 어떻게 동작할 지 결정하는 것 (스프링) 여러 클라이언트가 서비스 혹은 레포지토리를 호출하는 상황에서 다양한 트랜잭션 요구사항 해결 용어 외부 트랜잭션 상대적으로 바깥에 있는 트랜잭션 (처음 시작된 트랜잭션) 내부 트랜잭션 마치 내부에 있는 것처럼 보이는 트랜잭션 (외부 트랜잭션 수행 도중 호출된 트랜잭션) 물리 트랜잭션 실제 DB 트랜잭션을 의미 논리 트랜잭션 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위 REQUIRED 옵션 사용하고 추가 내부 트랜잭션이 있을 때만 적용하는 개념 옵션 사용 전략 대부분 REQUIRED 옵션 사용 아주 가끔 REQUIRES_NEW 옵션 사용 나머지는 거의 사용 X REQUIRED (기본 설정) 기존 트랜잭션이 없으면 생성하고 있으면 참여 e.g. 회원 등록 시 로그도 무조건 함께 남김 한 물리 트랜잭션으로 묶는 것은 데이터 정합성 문제 예방 효과 있음 특징 여러 개의 논리 트랜잭션을 하나의 물리 트랜잭션으로 묶음 = 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 물리 트랜잭션으로 만들어 줌 = 내부 트랜잭션이 외부 트랜잭션에 참여한다고도 표현 = 외부 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다고도 표현 외부 트랜잭션이 실제 물리 트랜잭션을 관리 물리 트랜잭션의 시작과 실제 커밋 및 롤백을 담당 코드 상 논리 트랜잭션마다 커밋 호출이 중복되는데, 실제 커밋은 외부 트랜잭션 커밋 시 발생 원칙 모든 논리 트랜잭션(=트랜잭션 매니저)이 커밋되어야 물리 트랜잭션이 커밋 하나의 논리 트랜잭션(=트랜잭션 매니저)이라도 롤백되면 물리 트랜잭션은 롤백 시나리오 별 동작 트랜잭션 매니저 커밋 / 롤백 기본 로직 신규 트랜잭션(=외부 트랜잭션)인지 확인 (isNewTransaction) 신규 트랜잭션인 경우 물리 커밋 / 롤백 호출 신규 트랜잭션이 아닌 경우 커밋인 경우 아무 것도 안함 롤백인 경우 트랜잭션 동기화 매니저에 rollbackOnly=true만 설정 물리 커밋 호출 시에는 rollbackOnly 설정 확인 false인 경우 그대로 커밋 true인 경우 물리 롤백 진행 및 예외 던짐 UnexpectedRollbackException.class 예외 발생 e.g. 내부 트랜잭션 롤백 호출 e.g. 레포지토리에서 런타임 예외 발생했는데, 서비스에서 예외 처리한 경우 등록 로그 저장 실패해도 회원 등록은 문제 없게 하려는 상황 AOP에서 물리 커밋 호출하다가 rollbackOnly=true 확인하여 발생 개발자는 정상 흐름으로 복구해 커밋을 의도했지만, 결과는 모두 롤백 이럴 땐 REQUIRES_NEW도 함께 사용해야 함 (실무에서 많이 하는 실수) 내부 트랜잭션 로직에서 런타임 예외가 발생하는 경우 물리 롤백 호출 AOP 역시 발생한 예외를 그대로 밖으로 던짐 상황 1: 모든 논리 트랜잭션 정상 커밋 신규 트랜잭션(=외부 트랜잭션)인 경우만 실제 물리 커밋 및 롤백 관리 (isNewTransaction) 결과: 물리 트랜잭션 커밋 상황 2: 외부 트랜잭션 롤백, 내부 트랜잭션 커밋 외부 트랜잭션이 실제 롤백 실행 결과: 물리 트랜잭션 롤백 상황 3: 외부 트랜잭션 커밋, 내부 트랜잭션 롤백 내부 트랜잭션 롤백 때, 기존 트랜잭션을 롤백 전용(rollback-only)으로 표시 트랜잭션 동기화 매니저에 rollbackOnly=true 표시 Participating transaction failed - marking existing transaction as rollback-only 외부 트랜잭션 커밋 때, UnexpectedRollbackException.class 예외 발생 커밋 호출 시 트랜잭션 동기화 매니저에 rollbackOnly=true 확인 후 동작 Global transaction is marked as rollback-only 결과: 물리 트랜잭션 롤백 + 예외 발생 REQUIRES_NEW 항상 새로운 트랜잭션을 생성 e.g. 회원 등록 로그를 남기되, 로그 저장 실패해도 회원 등록은 문제 없게 할 때 유용 로그는 콘솔에 로그로 남겨두고 나중에 복구해도 큰 문제 없음 LogRepository - save() @Transactional(propagation = Propagation.REQUIRES_NEW) public void save(Log logMessage) 커넥션이 2개 물리므로 성능이 중요한 곳에서는 주의해서 사용해야 함 REQUIRES_NEW를 안쓰고 단순한 해결 방법이 있다면 더 좋다! e.g. 구조 변경으로 해결 MemberFacade라는 계층을 하나 더 두고 MemberService와 LogRepository 호출 특징 외부 트랜잭션과 내부 트랜잭션을 완전히 분리해 각각 별도의 물리 트랜잭션으로 사용 별도의 물리 트랜잭션을 가진다는 뜻은 DB 커넥션을 따로 사용한다는 뜻 따라서, 내부 트랜잭션의 커밋 / 롤백이 외부 트랜잭션의 커밋 / 롤백에 영향을 미치지 않음 데이터베이스 커넥션을 동시에 2개 사용하므로 성능이 중요한 곳에서는 주의해서 사용 1번의 HTTP 요청을 담당한 쓰레드가 두 개의 커넥션을 동시에 물고 있으면 커넥션 풀의 커넥션이 쉽게 고갈될 수 있음 빠르게 처리되면 괜찮지만, 하필 해당 로직이 느리다면 커넥션 2개가 말려들어가 위험 내부 트랜잭션 시작 시 REQUIRES_NEW 옵션 적용 내부 트랜잭션(con2)이 완료될 때까지 기존 트랜잭션의 커넥션(con1)은 잠시 보류됨 트랜잭션 매니저 커밋 / 롤백 기본 로직은 똑같이 적용됨 SUPPORT 기존 트랜잭션이 없으면, 트랜잭션 없이 진행 기존 트랜잭션이 있으면, 기존 트랜잭션에 참여 NOT_SUPPORT 기존 트랜잭션이 없으면, 트랜잭션 없이 진행 기존 트랜잭션이 있다면, 보류하고 트랜잭션 없이 진행 MANDATORY 기존 트랜잭션이 없으면, IllegalTransactionStateException 예외 발생 기존 트랜잭션이 있으면, 기존 트랜잭션에 참여 NEVER 기존 트랜잭션이 없으면, 트랜잭션 없이 진행 기존 트랜잭션이 있으면, IllegalTransactionStateException 예외 발생 NESTED 기존 트랜잭션이 없으면, 새로운 트랜잭션 생성 기존 트랜잭션이 있으면, 중첩 트랜잭션을 만듦 중첩 트랜잭션은 외부 트랜잭션에 영향을 받기만 하고 영향을 주지는 않음 중첩 트랜잭션이 롤백되어도 외부 트랜잭션은 커밋 가능 외부 트랜잭션이 롤백 되면 중첩 트랜잭션도 함께 롤백됨 JDBC savepoint 기능 사용 (DB 드라이버 기능 지원 확인 필수) JPA에서는 사용 불가능 REQUIRES_NEW 옵션에서 외부 트랜잭션과 내부 트랜잭션 외부 트랜잭션과 내부 트랜잭션이 별개의 물리 트랜잭션을 사용한다. 하지만, 애플리케이션 로직에서 외부 트랜잭션이 닫히면 내부 트랜잭션에 접근할 수 없다. 내부 트랜잭션의 커밋 / 롤백 후 외부 트랜잭션의 커밋 / 롤백은 가능한 반면, 외부 트랜잭션의 커밋 / 롤백 후 내부 트랜잭션의 커밋 / 롤백은 불가능하다. 트랜잭션 전파와 옵션 isolation, timeout, readOnly 옵션은 트랜잭션이 처음 시작될 때만 적용되고 참여할 때는 적용되지 않는다. 즉, REQUIRED를 통한 트랜잭션 시작, REQUIRES_NEW를 통한 트랜잭션 시작 시점에만 적용된다.
Java-Ecosystem
· 2024-06-16
스프링 데이터 접근 핵심 원리
DB 접근 기술 의존 관계 @Transactional -> PlatformTransactionManager 인터페이스 -> JDBC 활용 기술 (SQLMapper, ORM)에 따른 구현체 (DataSourceTransactionManager, JpaTransactionManager, EtcTransactionManager) -> DataSource 인터페이스 -> Connection Pool의 DataSource 구현체 사용 -> DriverManager -> JDBC Connection 인터페이스 (DB Driver) 초기화 과정으로 커넥션들을 미리 생성 -> DriverManagerDataSource DataSource 구현체 사용 -> DriverManager -> JDBC Connection 인터페이스 (DB Driver) 항상 새 커넥션 생성 데이터베이스 변경 문제 일반적인 애플리케이션 서버와 DB 사용법 커넥션 연결 (TCP/IP) SQL 전달 (with 커넥션) 결과 응답 문제는 데이터베이스마다 사용법이 모두 다름 (커넥션 연결, SQL 전달, 결과 응답 방법) 데이터베이스 변경시 애플리케이션의 DB 사용코드도 함께 변경해야 하고, 개발자의 학습량이 늘어남 JDBC 표준 인터페이스(Java Database Connectivity) 자바에서 여러 데이터베이스에 편리하게 접속할 수 있도록 도와주는 3가지 표준 인터페이스 java.sql.Connection (커넥션 연결) java.sql.Statement (SQL을 담은 내용) java.sql.ResultSet (결과 응답) JDBC 드라이버 각각의 DB 벤더들이 JDBC 인터페이스를 자신의 DB에 맞도록 구현한 라이브러리 예시: MySQL JDBC 드라이버, Oracle JDBC 드라이버 etc… 장점 애플리케이션 로직이 JDBC 표준 인터페이스에만 의존하므로, DB 변경시 애플리케이션 코드를 그대로 유지하고 JDBC 구현 라이브러리만 변경 가능 개발자는 JDBC 표준 인터페이스 사용법만 학습하면, 모든 DB 연결 가능 한계 DB마다 SQL 역시 사용법의 차이가 있어, DB 변경 시 여전히 SQL은 그에 맞도록 변경해야 함 다만, JPA를 사용하면 이 역시도 많은 부분 해결됨 JDBC 활용 기술 JDBC(1997)는 오래된 기술이고 사용 방법도 복잡 직접 사용하기보다는 이를 편리하게 사용할 수 있는 다른 기술들을 활용 (내부에서 JDBC 사용) SQL Mapper 장점 SQL 응답 결과를 객체로 편리하게 변환 JDBC 반복 코드를 제거 낮은 러닝커브 (SQL만 작성할 줄 알면 금방 배워 사용 가능) 단점 개발자가 직접 SQL 작성 Spring JdbcTemplate, MyBatis ORM 장점 SQL 직접 작성하지 않아 개발 생산성 크게 상승 (SQL을 동적으로 생성 및 실행) 데이터베이스마다 다른 SQL을 사용하는 문제를 중간에서 해결 단점 러닝커브가 높음 JPA (하이버네이트, 이클립스 링크…) JDBC DriverManager DriverManager (JDBC가 제공) 라이브러리에 등록된 DB 드라이버들을 관리 커넥션 획득 기능 제공 (JDBC 표준 인터페이스 Connection) 커넥션 획득 과정 Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD); DriverManager는 라이브러리에 등록된 DB 드라이버 목록을 자동으로 인식 드라이버들에게 순서대로 다음 정보를 넘겨서 커넥션 획득 가능 여부 확인 접속에 필요한 정보: URL, 이름, 비밀번호… 커넥션 획득 가능한 드라이버는 바로 실제 DB에 연결해 커넥션 구현체 반환 (URL 등을 통해 판단) 커넥션 획득 불가능한 드라이버는 다음 드라이버에게 순서를 넘김 커넥션 사용 쿼리 준비 PreparedStatement(pstmt) 주로 사용 (Statement의 자식 인터페이스) 전달할 SQL과 파라미터로 전달할 데이터를 바인딩 파라미터 바인딩 방식은 SQL Injection 예방 코드 pstmt = con.prepareStatement(sql) pstmt.setString(1, member.getMemberId) pstmt.setInt(2, member.getMoney()) 쿼리 실행 조회 executeQuery() SELECT 쿼리 조회 후 ResultSet 반환 rs = pstmt.executeQuery() ResultSet (JDBC 표준 인터페이스) executeQuery()의 반환 타입 select 쿼리 결과가 순서대로 들어가 있음 내부의커서(Cursor)를 이동해 다음 데이터 조회 (rs.next()) 최초 커서는 데이터를 가리키고 있지 않아서, 한 번 rs.next() 호출해야 조회 가능 rs.next() 결과가 true면 데이터 있음, false면 데이터 없음 원하는 커서 위치에서 키 값으로 데이터 획득 (rs.getString, rs.getInt…) 갱신 executeUpdate() 갱신 쿼리 실행 후 영향 받은 Row 수 반환 pstmt.executeUpdate() 커넥션 풀 (Connection Pool) 애플리케이션 시작 시점에 필요한 만큼 커넥션을 미리 생성해 풀에 보관 DriverManager 사용 애플리케이션 실행 속도에 영향을 주지 않기 위해 별도의 쓰레드로 커넥션 생성 (“poolName” connection adder) 풀 내에 커넥션은 모두 TCP/IP로 DB와 연결되어 즉시 SQL 전달 가능 애플리케이션 로직은 커넥션 풀을 통해 이미 생성된 커넥션 획득 및 반환 애플리케이션 로직은 DB 드라이버를 통하지 않음 커넥션을 단순히 객체 참조로 가져다 쓰면 됨 다 사용한 커넥션은 종료하지 않고 그대로 커넥션 풀에 반환 적절한 커넥션 풀 숫자 스펙에 따라 다르기 떄문에 성능 테스트를 통해 정해야 함 (기본값은 보통 10개) 서버 당 최대 커넥션 수를 제한 가능 (무한정 커넥션 생성을 막아 DB 보호) hikariCP 주로 사용 커넥션 획득 시 내부 관리를 위해 실제 커넥션을 참조하는 히카리 프록시 커넥션을 생성해 반환 e.g. 트랜잭션 2개가 순차적으로 커넥션 획득 및 반환을 진행할 시 프록시 커넥션은 다르지만 물리 커넥션(conn0)은 동일 트랜잭션1: Acquired Connection [HikariProxyConnection@1000000 wrapping conn0] 트랜잭션2: Acquired Connection [HikariProxyConnection@2000000 wrapping conn0] 커넥션 풀 없는 DB 커넥션 획득 과정 애플리케이션 로직이 DB 드라이버 통해 커넥션 조회 (TCP/IP 연결, 3 way handshake) 연결 후 드라이버는 ID, PW 및 기타 부가정보를 DB에 전달 DB는 ID, PW로 내부 인증을 하고, 내부에 DB 세션을 생성 후 커넥션 생성 완료 응답 DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환 -> 통신에 리소스가 많이 들고, 커넥션 생성 시간이 매번 추가되어 사용자 경험이 안좋아짐 그래서 커넥션 풀은 이 과정을 애플리케이션 시작 시점에 미리 진행 커넥션 생성 시간 커넥션 생성시간은 MySQL 계열 DB에서는 수 ms 정도로 매우 빨리 커넥션을 확보한다. 반면에 수십 ms 이상 걸리는 DB들도 있다. DataSource 인터페이스 문제 커넥션을 획득하는 다양한 방법 존재 JDBC DriverManager 직접 사용 (신규 커넥션 생성) DBCP2 커넥션 풀 HikariCP 커넥션 풀 커넥션 획득방법을 변경하면 애플리케이션 코드도 함께 변경해야 함 DataSource (해결책) 커넥션을 획득하는 방법을 추상화한 인터페이스 핵심 기능은 커넥션 조회 (getConnection -> Connection) 애플리케이션 코드는 DataSource에 의존 각각의 커넥션풀의 DataSource 구현체를 갈아끼우기 (HikariDataSource) DriverManager의 경우 DataSource 구현체로 DriverManagerDataSource 사용 인터페이스에 의존하므로, 커넥션 획득 방법 변경해도 애플리케이션 코드 변경 X (DI + OCP) JdbcUtils를 사용하면 커넥션도 편리하게 닫을 수 있음 DataSource와 DriverManager의 차이 DriverManager는 커넥션 획득마다 매 번 설정정보(URL, USERNAME, PASSWORD…)를 넘겨줘야 한다. 반면에, DataSource는 객체 생성시 한 번만 설정정보를 전달하고 이후 커넥션 획득은 dataSource.getConnection만 호출한다. 이렇게 설정과 사용을 분리하면 설정 정보를 한 곳에 모아두고 이에 대한 의존성을 없앨 수 있다. (예를 들어, Repository는 DataSource만 의존하고 설정정보를 몰라도 된다.) DB 연결구조와 DB 세션 사용자는 WAS, DB 접근 툴 같은 클라이언트를 사용해 DB 서버에 접근 DB 서버에 연결을 요청하고 커넥션을 맺음 DB 서버는 내부에 커넥션에 대응하는 세션을 생성 해당 커넥션을 통한 모든 요청은 세션이 실행 (SQL 실행 및 트랜잭션 제어) 커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 생성 사용자가 커넥션을 닫거나 DBA가 세션을 강제 종료하면 세션 종료 애플리케이션 구조와 트랜잭션 3계층 아키텍처 (가장 단순하면서 많이 사용) 프레젠테이션 계층 UI 관련 처리 웹 요청과 응답, 사용자 요청 Validation 사용 기술: 서블릿, 스프링 MVC 서비스 계층 비즈니스 로직 사용 기술: 가급적 특정 기술 의존 없이 순수 자바 코드 시간이 흘러서 UI(웹), 데이터 저장 기술을 다른 기술로 변경해도 비즈니스 로직은 최대한 변경 없이 유지해야 함 덕분에 비즈니스 로직의 유지보수와 테스트가 쉬움 데이터 접근 계층 실제 데이터베이스에 접근하는 코드 사용 기술: JDBC, JPA, File, Redis, Mongo… 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 함 서비스 계층에서 커넥션 생성 및 종료 비즈니스 로직이 잘못되면 다같이 롤백 이를 위해, 트랜잭션 추상화가 필요 추상화 없는 경우, 같은 커넥션을 유지하기 위해 커넥션을 파라미터로 전달하는 단순한 방법 사용 그 결과 DB 접근 기술인 트랜잭션으로 인해 순수한 서비스 계층에 의존성 발생 트랜잭션 추상화 public interface TxManager { begin(); commit(); rollback(); } 트랜잭션 추상화 인터페이스를 의존하도록 하면, 순수한 서비스 계층을 만들 수 있음 DB 접근 기술을 변경할 때, 해당 기술에 맞는 구현체를 만들면 됨 (DI + OCP) JdbcTxManager, JpaTxManager 스프링 트랜잭션 추상화 PlatformTransactionManager 인터페이스 (=트랜잭션 매니저) 스프링이 제공하는 트랜잭션 추상화 트랜잭션 추상화 역할 데이터 접근 기술에 따른 트랜잭션 구현체도 스프링이 제공 스프링 5.3부터 JDBC 트랜잭션 관리 시, JdbcTransactionManager 제공 DataSourceTransactionManager를 상속 받아 약간의 기능 확장이 있지만, 같은 것으로 보기 메소드 getTransaction() 트랜잭션을 시작 기존에 이미 진행 중인 트랜잭션이 있는 경우, 해당 트랜잭션에 참여 commit() rollback() 리소스 동기화 역할 스프링은 트랜잭션 동기화 매니저를 제공 트랜잭션 유지를 위해, 트랜잭션의 시작부터 끝까지 같은 커넥션을 동기화(유지)하도록 도움 트랜잭션 매니저 내부에서 트랜잭션 동기화 매니저를 사용 쓰레드 로컬 (ThreadLocal)을 사용해 커넥션 동기화 멀티스레드 상황에도 커넥션을 문제 없이 안전하게 보관해주는 것 쓰레드마다 별도의 저장소가 부여되어, 해당 쓰레드만 해당 데이터에 접근 가능 코드가 지저분해지는 단순한 커넥션 파라미터 전달 방법을 피할 수 있음 트랜잭션 동기화 매니저 유용한 메서드 (TransactionSynchronizationManager) .isActualTransactionActive() 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인하기 .isCurrentTransactionReadOnly() 현재 쓰레드의 트랜잭션이 읽기 전용인지 확인 커넥션 획득 및 종료 과정 트랜잭션 매니저는 데이터 소스를 통해 커넥션을 생성하고 트랜잭션 시작 트랜잭션 매니저는 해당 커넥션을 트랜잭션 동기화 매니저에 보관 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션으로 트랜잭션을 종료 이어서 커넥션을 닫음 동작 서비스 코드 private final PlatformTransactionManager transactionManager; TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); 내부에서 데이터소스를 사용해 커넥션을 생성 후, 수동 커밋 모드로 트랜잭션 시작 커넥션을 트랜잭션 동기화 매니저에 보관 (쓰레드 로컬에 보관) transactionManager.commit(status); 트랜잭션 동기화 매니저를 통해 동기화된 커넥션 획득 해당 커넥션을 트랜잭션을 커밋 리소스 정리 트랜잭션 동기화 매니저 정리 (쓰레드 로컬은 사용 후 꼭 정리해야 함) con.setAutoCommit(true)로 되돌리기 (커넥션 풀 고려) con.close()로 커넥션 종료 (커넥션 풀인 경우 반환) transactionManager.rollback(status); 커밋과 마찬가지 과정 진행 리포지토리 코드 DataSourceUtils를 사용해 트랜잭션 동기화 매니저를 거쳐 커넥션 동기화 시도 Connection con = DataSourceUtils.getConnection(dataSource); 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션 반환 없으면 새로운 커넥션 생성 (=서비스 계층에서 트랜잭션 없이 돌리는 경우) DataSourceUtils.releaseConnection(con, dataSource); 동기화된 커넥션을 닫지 않고 그대로 유지 트랜잭션 동기화 매니저가 관리하는 커넥션이 아니라면 해당 커넥션을 닫음 (=리포지토리에서 생성된 커넥션이므로 닫음) TransactionTemplate (TransactionTemplate) 템플릿 콜백 패턴을 활용해 트랜잭션 시작 및 커밋, 롤백 코드 반복을 제거 언체크 예외가 발생하면 롤백, 그 외 경우는 커밋 코드 new TransactionTemplate(transactionManager) execute(): 응답 값이 있을 때 사용 executeWithoutResult(): 응답 값이 없을 때 사용 다만, 여전히 서비스 계층은 핵심 기능과 부가 기능이 섞여 있어 유지보수가 어려움 핵심 기능: 비즈니스 로직 부가 기능: 트랜잭션 처리 로직 순수한 비즈니스 로직만 남긴다는 목표를 달성하지 못함 트랜잭션 AOP (@Transactional) 스프링은 트랜잭션 AOP를 제공 (@Transactional) 스프링 AOP 프록시는 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 객체를 명확히 분리 @Transactional은 클래스, 메서드 모두 적용 가능 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어 스프링 컨테이너에 등록 클래스 레벨에 적용할 경우 public 메서드만 적용 대상 의도하지 않은 곳까지 과도하게 트랜잭션이 걸리는 것을 지양 spring 3.0부터는 protected, default에도 트랜잭션이 적용됨 인터페이스에서 사용은 지양 (AOP 적용이 안될 수 있음) @Transactional 적용 규칙 우선순위 규칙 스프링에서 항상 더 구체적인 것이 높은 우선순위 @Transactional이 클래스와 메서드에 붙어 있다면 메서드가 높은 우선순위 @Transactional이 인터페이스와 클래스에 붙어 있다면 클래스가 높은 우선순위 클래스 메서드 > 클래스 타입 > 인터페이스 메서드 > 인터페이스 타입 클래스에 적용 시 메서드는 자동 적용 스프링 부트가 트랜잭션 AOP 처리를 위한 스프링 빈들도 자동으로 등록 어드바이저, 포인트컷, 어드바이스 트랜잭션 매니저와 데이터소스도 자동 등록 스프링 빈 이름: dataSource application.properties 정보로 HikariDataSource 기본 생성 spring.datasource.url spring.datasource.username spring.datasource.password spring.datasource.url이 없으면 메모리 DB 생성 시도 스프링 빈 이름: transactionManager PlatformTransactionManager에 해당하는 적절한 트랜잭션 매니저를 자동 등록 현재 등록된 라이브러리를 보고 판단 JdbcTemplate 혹은 MyBatis이면 DataSourceTransactionManager JPA면 JpaTransactionManager 둘 다 사용하면 JpaTransactionManager (DataSourceTransactionManager 기능 대부분을 지원하므로) 개발자가 직접 스프링 빈으로 등록할 경우 스프링 부트가 자동 등록하지 않음 선언적 트랜잭션 관리 VS 프로그래밍 방식 트랜잭션 관리 선언적 트랜잭션 관리 선언 하나로 실용적이고 편리하게 트랜잭션 적용하는 것 (@Transactional, 과거 XML 설정) 실무에서 주로 사용 프로그래밍 방식 트랜잭션 관리 트랜잭션 매니저, 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것 트랜잭션 AOP 주의사항 트랜잭션이 적용되지 않는 문제 @Slf4j @SpringBootTest public class InternalCallV1Test { @Autowired CallService callService; @Test void printProxy() { log.info("callService class={}", callService.getClass()); } @Test void internalCall() { callService.internal(); } @Test void externalCall() { callService.external(); } @TestConfiguration static class InternalCallV1Config { @Bean CallService callService() { return new CallService(); } } @Slf4j static class CallService { public void external() { log.info("call external"); printTxInfo(); internal(); } @Transactional public void internal() { log.info("call internal"); printTxInfo(); } private void printTxInfo() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info("tx active={}", txActive); } } } internalCall 테스트에 internal() 호출 시, 예상대로 프록시를 거쳐 트랜잭션이 시작됨 externalCall 테스트에 external() 호출 시, internal() 호출의 트랜잭션이 시작되지 않음 대상 객체의 내부에서 메서드 호출 발생할 시 AOP 프록시를 거치지 않고 대상 객체를 직접 호출 자바에서 메서드 앞에 별도 참조가 없으면, this (자기 자신 인스턴스) 사용 따라서, this.internal() 실행 (프록시를 통하지 않고 직접 객체 호출) 해결책: 별도의 클래스로 분리하기 @SpringBootTest public class InternalCallV2Test { @Autowired CallService callService; @Test void externalCallV2() { callService.external(); } @TestConfiguration static class InternalCallV2Config { @Bean CallService callService() { return new CallService(innerService()); } @Bean InternalService innerService() { return new InternalService(); } } @Slf4j @RequiredArgsConstructor static class CallService { private final InternalService internalService; public void external() { log.info("call external"); printTxInfo(); internalService.internal(); } private void printTxInfo() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info("tx active={}", txActive); } } @Slf4j static class InternalService { @Transactional public void internal() { log.info("call internal"); printTxInfo(); } private void printTxInfo() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info("tx active={}", txActive); } } } 메서드 내부 호출을 외부 호출로 변경 externalCallV2 테스트를 실행하면 callService 객체를 직접 호출해 external()을 호출 callService는 주입 받은 internalService 트랜잭션 프록시 객체를 통해 처리 internal()에 @Transactional이 있으므로 트랜잭션을 시작하고 호출 초기화 시점 @PostConstruct + @Transactional 빈 초기화 메서드가 먼저 호출되고 트랜잭션 AOP가 적용됨 따라서, 트랜잭션이 적용되지 않음 @EventListener(value = ApplicationReadyEvent.class) + @Transactional 트랜잭션 AOP를 포함해 스프링 컨테이너가 완전히 생성된 후, 메서드를 호출 올바르게 트랜잭션 적용됨 스프링 데이터 접근 예외 추상화 예외 처리 의존성 제거 과정 서비스 계층을 순수하게 유지하기 위해 리포지토리에서 체크 예외를 런타임 예외로 전환해 던지기 SQLException을 런타임 에러로 전환해 던짐 리포지토리에서 넘어오는 특정한 예외만 복구하고 싶을 때는 DB 에러 코드에 따라 다른 런타임 예외 던지고 서비스에서 처리하기 SQLException에 DB가 제공하는 errorCode가 들어 있음 커스텀 DB 예외를 부모로 에러코드에 맞게 런타임 예외를 생성해 상속 (의미 있는 DB 관련 예외 계층을 만들 수 있음) 여전히 남은 문제 DB마다 SQL ErrorCode가 다르므로 DB 변경 시 에러코드도 모두 변경해야 함 (OCP 위반) 수많은 에러 코드에 맞춰 런타임 예외를 만들기 어려움 스프링 데이터 접근 예외 추상화 스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리하여 일관된 예외 계층 추상화를 제공 각각의 예외가 특정 기술에 종속되어 있지 않아, 서비스 계층에서도 사용 가능 스프링 예외 변환기 기술 변경에도 서비스 코드 변경 X -> OCP 준수 계층 DataAccessException: 런타임 예외를 상속, 최상위 데이터 접근 예외 TransientDataAccessException 일시적 (동일한 SQL을 다시 시도할 때 성공할 가능성 있음) 쿼리 타임아웃, 락 관련 오류 NonTransientDataAccessException 일시적이지 않음 (동일한 SQL을 다시 시도하면 실패) SQL 문법 오류(BadSqlGrammarException), DB 제약조건 위배 스프링 메뉴얼에 모든 예외가 정리되어 있진 않아서, 코드를 직접 열어 확인하는 것이 필요 스프링 예외 변환기 DB 발생 오류 코드를 적절한 스프링 데이터 접근 예외로 자동 변환 데이터 접근 기술(JDBC, JPA)에서 발생하는 예외를 적절한 스프링 데이터 접근 예외로 변환 sql-error-codes.xml 참고해 각각 DB의 에러코드에 대응하는 스프링 예외로 변환 코드 SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); DataAccessException resultEx = exTranslator.translate("select", sql, e); 첫 번째 파라미터는 읽을 수 있는 설명 두 번째는 실행한 SQL 마지막은 발생한 SQLException 스프링이 예외를 추상화해 준 덕분에 특정 구현 기술에 종속적이지 않은 순수한 서비스 계층을 유지 가능 필요한 경우 서비스 계층에서 특정한 스프링 예외를 잡아 복구 가능 JdbcTemplate 스프링은 템플릿 콜백 패턴을 사용하는 JdbcTemplate을 제공해 JDBC 반복 문제를 해결 코드 반복 제거 (PreparedStatement 생성, 파라미터 바인딩, 쿼리 실행, 결과 바인딩 in 리포지토리) 트랜잭션을 위한 커넥션 조회 및 동기화 자동 처리 (DataSourceUtils) 스프링 예외 변환기 자동 실행 리소스 자동 종료 코드 private final JdbcTemplate template template = new JdbcTemplate(dataSource); 조회: template.queryForObject(sql, memberRowMapper(), memberId); 갱신: template.update(sql, money, memberId); RowMapper 데이터베이스의 반환 결과인 ResultSet을 객체로 변환 private RowMapper<Member> memberRowMapper() { return (rs, rowNum) -> { Member member = new Member(); member.setMemberId(rs.getString("member_id")); member.setMoney(rs.getInt("money")); return member; }; } 각 계층의 결과 서비스 계층의 순수성 트랜잭션 추상화 + 트랜잭션 AOP 서비스 계층의 순수성을 유지하면서 트랜잭션 이용 가능 스프링 예외 추상화 및 예외 변환기 데이터 접근 기술이 변경되어도 서비스 계층의 순수성을 유지하면서 예외도 사용 가능 서비스 계층이 리포지토리 인터페이스에 의존 리포지토리가 다른 구현 기술로 변경되어도 서비스 계층 순수성 유지 가능 리포지토리 JdbcTemplate 사용으로 반복 코드가 대부분 제거 Reference 스프링 DB 1편 - 데이터 접근 핵심 원리
Java-Ecosystem
· 2024-05-12
스프링 MVC 원리
Web Server & Web Application Server Web Server (HTTP) 정적 리소스 제공 + 기타 부가기능 동적인 처리(애플리케이션 로직 등)가 필요하면 WAS에 요청 위임 예시) NGINX, APACHE Web Application Server (HTTP) 애플리케이션 로직 수행 (프로그램 코드 실행) + 웹 서버 기능 (정적 리소스 제공) 동적 HTML, HTTP API(JSON), 서블릿, JSP, Spring MVC API 서버만 제공할 경우 WAS만으로 서버 구축해도 괜찮음 (회사끼리 데이터 주고 받을 때) 예시) Tomcat, Jetty, Undertow WAS는 애플리케이션 코드 실행에 더 특화되어 있다! 웹서버와 WAS는 서로가 서로의 기능을 가지고 있긴 해서 경계가 모호 서블릿 컨테이너 기능 제공하면 WAS라 보기도 함 (서블릿 사용안하는 프레임워크도 있지만…) 공존 이유 효율적인 리소스 관리 WAS가 너무 많은 역할을 담당하여 서버 과부하 우려 애플리케이션 로직은 값어치가 높으므로 값이 낮은 정적 리소스 때문에 과부하되면 안됨 역할 분리 정적 리소스 사용이 많을 때는 Web Server 증설 애플리케이션 리소스 사용이 많을 때는 WAS 증설 지속적인 오류 화면 제공 WAS는 잘 죽는 반면, Web Server는 잘 안 죽음 WAS 및 DB 장애시 Web Server가 오류화면 제공 가능 Servlet 메시지 수신, 파싱, 응답 메시지 생성 및 송신 등 HTTP 스펙의편리한 사용을 지원하는 자바 클래스 서블릿을 지원하는 WAS를 사용하면, 의미있는 비즈니스 로직에만 집중 가능 사용 방법 메인 함수가 실행되는 클래스에 @ServletComponentScan 추가 HttpServlet을 상속받고 @WebServlet 애노테이션에 name과 urlPatterns를 지정 protected의 service 코드를 오버라이딩해 비즈니스 로직 작성 HttpServletRequest와 HttpServletResponse 타입 파라미터로 요청 및 응답 정보 사용 가능 부가 기능 임시 저장소 기능: HTTP 요청의 시작과 끝까지 유지, View에 데이터 전달하는 Model 역할도 수행 세션 관리 기능: request.getSession(create: true) 흐름 HTTP 요청시 WAS가 Request, Response 객체를 생성해서 서블릿 객체 호출 서비스 로직에서 Request 객체의 HTTP 요청 정보를 이용하고 Response 객체에 응답 정보 입력 WAS는 Response 객체에 담긴 내용으로 HTTP 응답 정보 생성 Servlet Container(서블릿 컨테이너) 서블릿을 지원하는 WAS (톰캣) 서블릿 객체의 생명주기 관리 (생성, 초기화, 호출, 종료) 서블릿 객체를 싱글톤으로 관리 최초 로딩 시점에 서블릿 객체를 미리 만들어두고 재활용 공유 변수 사용에는 주의해야 함 동시 요청을 위한 멀티 쓰레드 처리 지원 덕분에 개발자가 멀티 쓰레드를 신경쓰지 않고 마치 싱글 쓰레드 프로그래밍 하듯이 편리하게 개발 (WAS가 개발 생산성을 가장 높여주는 부분) 멀티 쓰레드 환경이므로 싱글톤 객체(서블릿, 스프링 빈)는 주의해서 사용 (공유변수, 멤버변수 조심) 동시요청 (멀티 쓰레드) 쓰레드 애플리케이션 코드를 하나하나 순차적으로 실행하는 것 (한번에 하나의 코드 라인만 수행) 자바 메인 메서드를 처음 실행하면 main이라는 이름의 쓰레드가 실행 동시 처리가 필요하면 쓰레드를 추가로 생성 서블릿 객체는 쓰레드가 호출 멀티 쓰레드는 동시요청 처리 가능 (단일 쓰레드로는 처리가 어려움) 요청마다 쓰레드 생성 장점 동시 요청 처리 가능 하나의 쓰레드가 지연 되어도, 나머지 쓰레드는 정상 동작 단점 쓰레드 생성 비용은 매우 비쌈 (요청마다 쓰레드 생성하면 응답 속도도 늦어짐) 컨텍스트 스위칭 비용 발생 (하나의 CPU 코어에 2개 이상의 쓰레드를 돌리면 발생) 쓰레드 생성에 제한 없음 (요청이 너무 많으면, CPU와 메모리 임계점을 넘어 서버가 죽음) 쓰레드 풀 설정한 최대치 만큼 쓰레드를 미리 생성해 풀에 보관하고 관리 (톰캣 기본설정: 최대 200개) 요청이 들어오면 쓰레드 풀에서 쓰레드를 할당하고 다 쓰면 반납 (재사용) 풀에 남은 쓰레드가 부족하면 대기 중인 요청은 거절하거나 특정 숫자만큼만 대기하도록 설정 가능 장점 쓰레드를 미리 생성하므로, 쓰레드 생성 비용(CPU)이 절약되고 응답이 빠름 쓰레드 풀 최대치가 있으므로 너무 많은 요청이 들어와도 기존 요청은 안전하게 처리 가능 WAS의 주요 튜닝 포인트는 최대 쓰레드 수(max thread) 동시 요청이 많은 상황에서 너무 낮게 설정 시: 서버 리소스는 여유롭지만, 금방 클라이언트 응답 지연이 발생 100개 요청이 왔는데 최대 쓰레드가 10개면 동시에 10개 요청만 처리 그런데 사실 CPU는 5% 밖에 사용안함 너무 높게 설정 시: CPU, 메모리 임계점 초과로 서버 다운 발생 10000개의 요청이 오면 10000개를 모두 받아들이다가 서버가 죽음 장애 발생시 클라우드면 일단 서버부터 늘리고 이후 튜닝 클라우드가 아니면 바로 튜닝 적정 쓰레드 풀 숫자는 성능 테스트를 통해 찾아야 함 애플리케이션 로직 복잡도, CPU & 메모리 & IO 리소스 상황에 따라 모두 다름 최대한 실제 서비스와 유사하게 성능 테스트 시도 아파치 ab, 제이미터, nGrinder 백엔드가 고려할 3가지 HTTP 통신 정적 리소스 어떻게 제공할지 동적 HTML 페이지 어떻게 제공할지 (View Template) API 어떻게 제공할지 (JSON) MVC 패턴 배경 비즈니스 로직과 뷰는 변경의 라이프 사이클이 다르므로, 분리하는 것이 좋은 설계 역할을 나누면 유지보수성이 향상되고 각각의 기능을 특화할 수 있음 컨트롤러 (Controller) HTTP 요청을 받아서 파라미터를 검증하고 비즈니스 로직 및 오케스트레이션 작업 실행 오케스트레이션: 데이터 접근 및 모델 담기 등의 작업 지금은 더 고도화 되어서 다음 두 가지 패턴을 띔 서비스 계층: 비즈니스 로직 + 오케스트레이션 서비스 계층: 오케스트레이션 / 도메인 모델: 비즈니스 로직 모델 (Model) 뷰에 출력할 데이터를 담아둠 뷰 (View) 화면을 렌더링하는 일에 집중 HTML 생성에 더하여 XML, Excel, JSON 생성 등도 포괄 SSR & CSR 서버 사이드 렌더링 (SSR) HTML 최종 결과를 서버에서 만들어서 웹브라우저에 전달 JSP, Thymeleaf (백엔드 기술) 클라이언트 사이드 렌더링 (CSR) HTML 최종 결과를 JS를 이용해 웹 브라우저에서 동적으로 생성해 적용 필요한 부분만 부분부분 변경 React, Vue.js (프론트엔드 기술) CSR + SSR 동시 지원하는 프론트 기술도 존재하므로 칼같이 나눌 필요 X (Next.js) SSR도 JS 이용해 화면 일부를 동적으로 변경 가능 자바 웹기술 역사 Servlet(1997) HTML 생성이 어려움 (동적 HTML을 생성할 수 있으나 자바코드로 일일히 HTML 만들어야 해서 불편) JSP(1999) HTML 생성이 편리해 JSP로 모두 개발 비즈니스 로직과 뷰 로직이 결합되어 코드라인이 너무 많아지고 유지보수 저하 Servlet + JSP MVC 패턴 모델, 뷰, 컨트롤러로 역할을 나눠 비즈니스 로직과 화면 렌더링 부분을 나눔 한계점: 공통 기능 처리가 어려움 dispatcher.forward() 같은 View로 이동하는 코드 중복 /WEB-INF/views 와 .jsp 같은 ViewPath 중복 (JSP 의존성도 증가) HttpServletResponse response는 파라미터로 항상 존재하지만 사용 X 공통 기능을 메서드로 뽑는 방안도 여전히 호출 중복이 존재하며 호출을 강제하지는 못함 프론트 컨트롤러 패턴의 등장 배경 MVC 프레임워크 춘추 전국 시대 (2000년 초 ~ 2010년 초) 반복되는 MVC 패턴을 자동화하기 위해 여러 프레임워크 등장 스트럿츠, 웹워크, 스프링 MVC(과거 버전) 당시에는 스트럿츠 + 스프링 코어(MVC 제외한 service, DAO, repository) 형태를 주로 사용 FrontController 패턴 적용 프론트 컨트롤러 서블릿 하나로 클라이언트 요청을 받음 (나머지 컨트롤러는 서블릿 사용 X) 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아 호출 공통 처리 담당 스프링 MVC 핵심도 프론트 컨트롤러 패턴 애노테이션 기반의 스프링 MVC MVC 프레임워크 혼돈 시대 정리 @RequestMapping 기반의 애노테이션 컨트롤러 등장으로, 스프링은 MVC 부분에서도 완승 스프링 부트 (Spring Boot) 빌드 결과(Jar)에 WAS 서버(Tomcat)를 포함하여 빌드 배포를 단순화 빌드된 Jar 파일을 아무 서버에 넣고 말아서 실행하면 됨 과거에는 서버에 WAS(Tomcat)를 직접 설치하고 Jar 파일을 모아 War 파일을 만들어서 배포를 위한 특정 폴더에 집어 넣어 배포해야 했음 최신 기술 - 스프링 웹 기술의 분화 Web Servlet - Spring MVC 서블릿 위에 Spring MVC를 올려서 동작 Web Reactive - Spring WebFlux 비동기 Non-Blocking 처리 최소 쓰레드로 최대 성능 (컨텍스트 스위칭 비용 효율화) CPU 코어가 4개 있으면 쓰레드 개수를 4 혹은 +1(5개) 정도로 맞춤 고효율로 CPU 개수에 딱 맞췄기 때문에 쓰레드가 계속 돌아가고 컨텍스트 스위칭 비용이 거의 안듦 함수형 스타일로 개발 - 동시처리 코드 효율화 WAS에서 상품 조회, 주문 서버 조회 등 여러 개의 서버에 여러 개 API를 동시에 찔러서 데이터를 가져와 조합해야 할 때 매우 효율적 Java 코드는 깔끔하지 않지만 함수형 스타일 코드는 매우 깔끔 (Netty) 서블릿 사용 X 단점 기술적 난이도 매우 높음 RDB 지원 부족 NoSQL(Redis, Elastic Search, DynamoDB, MongoDB)은 지원 잘 됨 일반 MVC 쓰레드 모델도 충분히 빠름 좋은 장비 띄워서 쓰레드 1000개 넣고 돌려도 잘 돌아감 실무에서 아직 많이 사용 X 자바 뷰 템플릿 역사 JSP 느린 속도, 부족한 기능 Freemarker, Velocity 빠른 속도 (Thymeleaf 보다 빠름) Thymeleaf (권장) 네추럴 템플릿 HTML 태그 속성을 이용하므로 HTML의 모양을 유지하면서 뷰 템플릿 적용 가능 스프링 MVC와 강한 기능 통합 스프링 MVC 핵심 구조와 원리 구조 DispatcherServlet 프론트 컨트롤러 (스프링 MVC의 핵심) 부모 클래스로부터 HttpServlet을 상속 받아, 서블릿으로서 동작 스프링 부트는 DispatcherServlet을 서블릿으로 자동 등록하면서, 모든 경로(urlPatterns="/")에 대해서 매핑 흐름 서블릿이 호출되면 DispatcherServlet의 부모인 FrameworkServlet에서 오버라이드한 HttpServlet에서 제공하는 service() 메서드가 호출됨 이후 여러 메서드가 호출되다가 DispatcherServlet.doDispatch()를 호출 HandlerMapping 요청 URL과 핸들러(컨트롤러)의 매핑 핸들러 (Handler) 컨트롤러를 포괄 꼭 컨트롤러 개념이 아니더라도 어떠한 것이든 어댑터가 지원하면 처리 가능 HandlerAdapter (in 핸들러 어댑터 목록) 다양한 버전의 규격이 다른 핸들러들을 호환 가능하게 도움 프레임워크를 유연하고 확장성 있게 설계 가능 어댑터 패턴 덕분에 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리 가능 핵심 메서드 boolean supports(Object handler) 어댑터가 해당 컨트롤러를 처리할 수 있는지 판단 ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) 실제 컨트롤러를 호출하고 ModelAndView 반환 컨트롤러가 ModelAndView를 반환하지 못하면, 어댑터가 직접 생성해서라도 반환 ModelAndView 논리 뷰 이름을 가짐 뷰 렌더링에 필요한 모델 객체 포함 ViewResolver (물리 뷰 경로 반환기) 논리 뷰 이름을 실제 물리 뷰 경로로 변경 e.g. return new View("/WEB-INF/views/" + viewName + ".jsp"); View 물리 뷰 경로를 가짐 모델 정보와 함께 render() 메서드를 호출 (해당 물리명 주소로 servlet의 forward 함수 호출) 동작 순서 핸들러 조회: 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러)를 조회 자동 등록된 HandlerMapping들을 순서대로 실행해 핸들러 탐색 스프링 부트가 자동 등록하는 핸들러 매핑 종류 (우선순위 내림차순) RequestMappingHandlerMapping: 애노테이션 기반 컨트롤러에 사용 (@RequestMapping) BeanNameUrlHandlerMapping: 스프링 빈의 이름으로 핸들러 탐색 (@Component("...")) 핸들러 어댑터 조회: 핸들러를 실행할 수 있는 핸들러 어댑터 조회 자동 등록된 HandlerAdapter들의 supports()를 순서대로 호출 스프링 부트가 자동 등록하는 핸들러 어댑터 종류 (우선순위 내림차순) RequestMappingHandlerAdapter: 애노테이션 기반 컨트롤러에 사용 (@RequestMapping) HttpRequestHandlerAdapter: HttpRequestHandler 인터페이스 처리 (서블릿 유사) SimpleControllerHandlerAdapter: Controller 인터페이스 처리 (과거) 핸들러 어댑터 실행 핸들러 실행: 핸들러 어댑터가 실제 핸들러 실행 ModelAndView 반환: 핸들러 어댑터는 핸들러의 반환 결과를 ModelAndView로 변환해 반환 @ResponseBody, HttpEntity(ResponseEntity) 있는 경우 ViewResolver 호출하지 않고 이대로 종료 ViewResolver 호출: 뷰 리졸버를 찾고 실행 주어진 논리 뷰 이름으로 자동 등록된 viewResolver들을 순서대로 호출 스프링 부트가 자동 등록하는 뷰 리졸버 BeanNameViewResolver: 빈 이름으로 뷰를 찾아서 반환 (엑셀 파일 생성에 사용) InternalResourceViewResolver: JSP를 처리할 수 있는 뷰를 반환 application.properties 파일에 prefix와 suffix 등록 (권장) spring.mvc.view.prefix=/WEB-INF/views/ spring.mvc.view.suffix=.jsp View 반환: 뷰 리졸버는 뷰의 논리 이름을 물리 이름으로 바꾸고, 뷰 객체 반환 JSP의 경우 InternalResourceView(JstlView)를 반환 (내부에 forward() 로직 존재) 다른 뷰 템플릿들은 forward() 과정 없이 바로 렌더링 뷰 렌더링: 뷰 객체의 render() 메서드 호출 스프링 MVC 기본 기능 Controller 관련 기능 컨트롤러 애노테이션 @Controller 스프링이 자동으로 컨트롤러로 인식해 스프링 빈으로 등록 반환 값이 String이면 뷰 이름으로 인식하여, 뷰를 찾고 렌더링 @RestController 스프링이 자동으로 컨트롤러로 인식해 스프링 빈으로 등록 반환 값으로 HTTP 메시지 바디에 바로 입력 (@Controller + @ResponseBody) @RequestMapping 요청 정보 URL 매핑 대부분의 속성을 배열로 제공하므로 다중 설정 가능 {"/hello-basic", "/hello-go"} HTTP 메서드를 설정하지 않으면 메서드 모두 허용 설정하려면 아래와 같이 적용해야 해서 불편함 @RequestMapping(value = "/", method = RequestMethod.GET) HTTP 메서드 축약 애노테이션 제공 @GetMapping/@PostMapping/@PutMapping/@PatchMapping/@DeleteMapping @RequestMapping 내포 클래스 레벨 + 메서드 레벨 조합 적용 (효율적 URL 매핑 적용) @Controller @RequestMapping("/springmvc/members") public class SpringMemberController { @GetMapping("/new-form") // @RequestMapping도 가능 public ModelAndView newForm() { ... } @PostMapping("/save") // @RequestMapping도 가능 public ModelAndView save() { ... } @GetMapping // @RequestMapping도 가능 public ModelAndView members() { ... } } 경로 변수 조회 기본 사용법 @PathVariable("userId") String userId 경로변수 이름과 변수명이 같으면 생략 가능 @PathVariable String userId HTTP 헤더 조회 모든 헤더 조회 @RequestHeader MultiValueMap<String, String> headerMap 하나의 키에 여러 값을 받는 HTTP header, 쿼리 파라미터를 처리 가능 특정 헤더 조회 @RequestHeader("host") String host 속성: required (필수 값 여부), defaultValue (기본값) 특정 쿠키 조회 @CookieValue(value = "myCookie", required = false) 속성: required (필수 값 여부), defaultValue (기본값) 서블릿 조회 HttpServletRequest request HttpServletResponse response 특수 조회 Locale locale HttpMethod httpMethod … HTTP 요청 파라미터 조회 (GET 쿼리 파라미터, POST HTML Form) @RequestParam("요청 파라미터 이름") request.getParameter("파라미터 이름")와 유사 요청 파라미터와 변수명이 같으면 생략 가능 Primitive 타입이면 @RequestParam도 생략가능 required=false 자동 적용 완전 생략은 과한 측면도 있으니 유의 속성: required (필수 값 여부), defaultValue (기본값) Map으로 조회하기 @RequestParam Map<String, Object> paramMap @RequestParam MultiValueMap<String, Object> paramMap 파라미터의 값이 1개가 확실하다면 Map을 사용하지만, 아니라면 MultiValueMap을 사용 서블릿 조회 HttpServletRequest의 request.getParameter() HTTP 요청 메시지 바디 조회 @RequestBody HttpMessageConverter 사용 (요청이 content-type: application/json일 때) 헤더 정보가 필요할 땐, HttpEntity 혹은 @RequestHeader 사용할 것 생략 불가능 단순 Text (StringHttpMessageConverter) @RequestBody String messageBody JSON (MappingJackson2HttpMessageConverter) @RequestBody HelloData data 직접 만든 객체 지정 HttpEntity 조회 HttpMessageConverter 사용 (요청이 content-type: application/json일 때) 단순 Text (StringHttpMessageConverter) HttpEntity<String> httpEntity String messageBody = httpEntity.getBody(); JSON (MappingJackson2HttpMessageConverter) HttpEntity<HelloData> httpEntity RequestEntity HttpEntity를 상속 받음 HttpMethod, URL 정보 추가 가능 서블릿 조회 단순 Text HttpServletRequest request ServletInputStream inputStream = request.getInputStream(); String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); InputStream inputStream String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); JSON 단순 Text와 유사하나 다음 코드가 추가됨 private ObjectMapper objectMapper = new ObjectMapper(); Jackson 라이브러리 ObjectMapper를 사용 (JSON to 자바 객체) MappingJackson2HttpMessageConverter가 하는 작업을 수동으로 진행 HelloData data = objectMapper.readValue(messageBody, HelloData.class); 조건 매핑 파라미터 특정 파라미터 조건 매핑 (params) @GetMapping(value = "/mapping-param", params = "mode=debug") 특정 요청 파라미터 포함한 요청만 받음 http://localhost:8080/mapping-param?mode=debug 특정 헤더 조건 매핑 (headers) @GetMapping(value = "/mapping-header", headers = "mode=debug") 미디어 타입 조건 매핑 HTTP 요청 헤더 Content-Type (consume) @PostMapping(value = "/mapping-consume", consumes = "application/json") 만약 맞지 않으면 상태코드 415 Unsupported Media Type 반환 사용 예시 consumes = "text/plain" consumes = {"text/plain", "application/*"} consumes = MediaType.TEXT_PLAIN_VALUE HTTP 요청 헤더 Accept (produce) @PostMapping(value = "/mapping-produce", produces = "text/html") 만약 맞지 않으면 상태코드 406 Not Acceptable 반환 사용예시 produces = "text/plain" produces = {"text/plain", "application/*"} produces = MediaType.TEXT_PLAIN_VALUE produces = "text/plain;charset=UTF-8" required & defaultValue 속성 required 속성 required의 기본값은 true 주의사항 파라미터 이름만 사용하는 요청의 경우 @RequestParam(required = true) String username 요청: /request-param-required?username= -> 빈 문자열로 통과 요청: /request-param-required -> 400 예외 발생 Primitive 타입에 null 입력하는 경우 @RequestParam(required = false) int age 요청: /request-param -> 500 예외 발생 (null을 int에 입력 불가능) 해결 방법 @RequestParam(required = false) Integer age 요청: /request-param -> null 입력 통과 @RequestParam(required = false, defaultValue = "-1") int age 요청: /request-param -> 기본값이 있으므로 required가 무의미 defaultValue 속성 파라미터에 값이 없는 경우 지정한 기본값 적용 기본 값이 있기 때문에 required는 의미 없어짐 빈 문자의 경우도 기본값 적용 (요청: /request-param-default?username=) 클라이언트 to 서버 데이터 전달 방법 3가지 쿼리 파라미터 (GET) HTML Form (POST, 메시지 바디에 쿼리 파라미터 형식으로 전달) HTTP message body (POST, PUT, PATCH) 요청 파라미터 VS HTTP 메시지 바디 요청 파라미터 조회: @RequestParam, @ModelAttribute (생략 가능) HTTP 메시지 바디 조회: @RequestBody (생략 불가능, 생략하면 @ModelAttribute로 기능) 스프링 부트 3.2: 파라미터 이름 생략시 발생하는 예외 (@PathVariable, @RequestParam) java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified, and parameter name information not found in class file either. 해결방법 1. 파라미터 이름을 생략하지 않고 항상 적기 해결방법 2. 컴파일 시점에 -parameters 옵션 추가 (File -> Settings -> Build, Execution, Deployment → Compiler → Java Compiler -> Additional command line parameters) 해결방법 3. Gradle을 사용해서 빌드하고 실행 (권장) View 관련 기능 정적 리소스 (HTML, CSS, JS 제공) 스프링 부트는 기본 정적 리소스 경로 제공 src/main/resources/static 실제 서비스에서도 공개되기 때문에 공개할 필요없는 HTML을 두는 것을 조심할 것! 접근 요청: http://localhost:8080/basic/hello-form.html 제공: src/main/resources/static/basic/hello-form.html 뷰 템플릿 (동적 HTML 제공) 스프링 부트는 기본 뷰 템플릿 경로 제공 src/main/resources/templates 사용 방법 String 반환 ViewName 직접 반환 (뷰의 논리 이름을 리턴) @ResponseBody가 없으면 뷰 리졸버를 실행 String 반환: 리다이렉트 지원 (RedirectView) redirect:/ return "redirect:/basic/items/{itemId}"; RedirectAttributes 함께 사용 권장 URL에 ID 값을 넣을 때 URL 인코딩이 안되어 위험 URL 인코딩 + 경로변수, 쿼리 파라미터 처리 @PostMapping("/add") public String addItem(Item item, RedirectAttributes redirectAttributes) { Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/basic/items/{itemId}"; } ModelAndView 생성 및 반환 (권장 X) ModelAndView mv = new ModelAndView("뷰 논리경로") mv.addObject("객체 이름", 실제 객체): 모델에 데이터 추가 void 반환 (권장 X) 요청 URL을 참고해 논리 뷰 이름으로 사용 /response/hello(요청) -> templates/response/hello.html (실행) 실행 조건 @Controller O HTTP 메시지 바디 처리 파라미터 X (HttpServletResponse, OutputStream) 접근 반환: response/hello 실행: templates/response/hello.html HTTP 응답 메시지 바디 직접 입력 (API 방식, 정적 리소스나 뷰 템플릿 거치치 않음) @ResponseBody HttpMessageConverter 사용 단순 Text (StringHttpMessageConverter) -> String 리턴 JSON (MappingJackson2HttpMessageConverter) -> Java 객체 리턴 상태코드 입력: @ResponseStatus @ResponseStatus(HttpStatus.OK) 클레스 레벨 및 메서드 레벨 모두 적용 가능 HttpEntity HttpMessageConverter 사용 단순 Text (StringHttpMessageConverter) -> String 리턴 JSON (MappingJackson2HttpMessageConverter) -> Java 객체 리턴 return new HttpEntity<>("ok"); ResponseEntity HttpEntity를 상속 받음 HTTP Status Code 추가 가능 단순 Text 반환 타입 선언: ResponseEntity<String> return new ResponseEntity<>("Hello World", responseHeaders, HttpStatus.CREATED) JSON 반환 타입 선언: ResponseEntity<HelloData> return new ResponseEntity<>(helloData, HttpStatus.OK) 서블릿 응답 HttpServletResponse response response.getWriter().write("ok"); Writer responseWriter responseWriter.write("ok"); Model 관련 기능 @Controller @RequestMapping("/springmvc/v3/members") public class SpringMemberControllerV3 { private MemberRepository memberRepository = MemberRepository.getInstance(); @GetMapping("/new-form") public String newForm() { return "new-form"; } @PostMapping("/save") public String save( @RequestParam("username") String username, @RequestParam("age") int age, Model model) { Member member = new Member(username, age); memberRepository.save(member); model.addAttribute("member", member); return "save-result"; } @GetMapping public String members(Model model) { List<Member> members = memberRepository.findAll(); model.addAttribute("members", members); return "members"; } } Model model 파라미터 선언으로 편리하게 모델 사용 가능 model.addAttribute("객체 이름", 실제 객체): 모델에 데이터 추가 @ModelAttribute 요청 파라미터를 받아 객체에 바인딩하는 과정을 자동화 Primitive 이외 타입은 @ModelAttribute 생략가능 (argument resolver 지정타입 외) 이름을 생략하면 클래스 명의 첫글자만 소문자로 바꿔서 모델에 등록 (HelloData -> helloData) @ModelAttribute HelloData helloData 실행 과정 HelloData 객체 생성 요청 파라미터 이름으로 HelloData 객체의 프로퍼티 찾고 setter를 호출해 바인딩 @Data public class HelloData { private String username; private int age; } // 롬복 @Data = @Getter + @Setter + @ToString + // @EqualsAndHashCode + @RequiredArgsConstructor // 위험하기 때문에 사용 주의! DTO는 괜찮지만 핵심 도메인 모델엔 사용 X @ModelAttribute - 컨트롤러 레벨 적용 @ModelAttribute("regions") public Map<String, String> regions() { Map<String, String> regions = new LinkedHashMap<>(); regions.put("SEOUL", "서울"); regions.put("BUSAN", "부산"); regions.put("JEJU", "제주"); return regions; } 컨트롤러 클래스 내에 별도의 메서드로서 @ModelAttribute를 적용 가능 해당 클래스 내 모든 컨트롤러는 호출 시 미리 정의한 모델이 자동으로 담김 (반복 데이터 처리에 유리) HTTP 메시지 컨버터 @ResponseBody 사용시 반환값을 HTTP Body에 직접 입력 viewResolver 대신 HttpMessaveConverter 동작 HTTP Accept 헤더와 컨트롤러의 반환 타입 정보를 조합해 적절한 HttpMessageConverter 선택 HttpMessageConverter 인터페이스로서 HTTP 요청 및 응답에 모두 사용 주요 메서드 canRead(), canWrite(): 메시지 컨버터가 해당 클래스, 미디어타입을 지원하는지 체크 read(), write(): 메시지 컨버터를 통해 메시지를 읽고 쓰는 기능 동작 클래스 타입을 먼저 바라보고 컨버터 종류 채택 후, 미디어 타입 지원 확인 HTTP 요청 데이터 읽기 요청이 오고 컨트롤러는 @RequestBody, HttpEntity(RequestEntity) 사용 canRead() 호출 (메시지 컨버터가 메시지 읽을 수 있는 지 확인) 대상 클래스 타입을 지원하는가 (@RequestBody의 대상 클래스) HTTP 요청의 Content-Type 헤더의 미디어 타입을 지원하는가 조건을 만족하면 read() 호출해, 객체 생성 및 반환 HTTP 응답 데이터 생성 컨트롤러에서 @ResponseBody, HttpEntity(ResponseEntity)로 값을 반환 canWrite() 호출 (메시지 컨버터가 메시지를 쓸 수 있는지 확인) 대상 클래스 타입을 지원하는가 (return의 대상 클래스) 미디어 타입을 지원하는가 @RequestMapping의 produces가 세팅되어 있으면 이 값을 기준으로 처리 아니라면 HTTP 요청의 Accept 헤더의 미디어 타입을 지원하는지 여부 확인 조건을 만족하면 write() 호출해, HTTP 응답 메시지 바디에 데이터 생성 스프링 부트 기본 메시지 컨버터 (우선순위 순서로) ByteArrayHttpMessageConverter 기본이 바이트 배열로 오므로 변환 없이 그대로 받이들이는 것 클래스 타입: byte[], 미디어 타입: */* 요청 예 @RequestBody byte[] data 응답 예 @ResponseBody return byte[] 쓰기 미디어 타입: application/octet-stream StringHttpMessageConverter 바이트로 오는 데이터를 문자열로 처리 클래스 타입: String, 미디어타입: */* 요청 예 @RequestBody String data 응답 예 @ResponseBody return "ok" 쓰기 미디어타입: text/plain MappingJackson2HttpMessageConverter 바이트로 오는 데이터를 객체 또는 HashMap으로 처리 클래스 타입: 객체 또는 HashMap, 미디어 타입: application/json 요청 예 @RequestBody HelloData data 응답 예 @ResponseBody return helloData 쓰기 미디어타입: application/json HTTP 메시지 컨버터의 위치 HTTP 메시지 컨버터는 RequestMappingHandlerAdapter에서 실제로 사용 (애노테이션 기반) RequestMappingHandlerAdapter 동작 방식 ArgumentResolver 호출 정확히는 HandlerMethodArgumentResolver 핸들러의 파라미터, 애노테이션 정보를 기반으로 핸들러가 필요로 하는 요청 데이터 생성 (HttpServletRequest, Model, @RequestParam, @ModelAttribute, @RequestBody, HttpEntity) 과정 ArgumentResolver 구현체들을 탐색하며 (InvocableHandlerMethod) supportsParameter() 호출 (해당 파라미터 지원 여부 체크) 지원 가능하면 resolveArgument() 호출 (실제 객체 생성) HTTP 메시지 컨버터 사용해 데이터 처리 후 리턴 (canRead(), read()) 핸들러 호출 (with 생성된 요청 데이터) ReturnValueHandler 정확히는 HandlerMethodReturnValueHandler 컨트롤러의 반환 값을 변환해 응답 데이터 생성 (ModelAndView, @ResponseBody, HttpEntity) 과정 ReturnValueHandler 구현체들을 탐색하며 (ServletInvocableHandlerMethod) supportsReturnType() 호출 (해당 리턴 타입 지원 여부 체크) 지원 가능하면 handleReturnValue() 호출 HTTP 메시지 컨버터 사용해 데이터 처리 (canWrite(), write()) HTTP 메시지 컨버터 적용 경우 스프링 MVC는 다음 상황에서 HTTP 메시지 컨버터를 적용한다. HTTP 요청: @RequestBody, HttpEntity(RequestEntity) HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity) 스프링 MVC 주요 ArgumentResolver & ReturnValueHandler @RequestBody, @ResponseBody 존재: RequestResponseBodyMethodProcessor() 사용 HttpEntity 존재: HttpEntityMethodProcessor() 사용 기능 확장 기능 확장은 WebMvcConfigurer 상속 및 스프링 빈 등록을 통해 구현한다. 스프링은 다음을 모두 인터페이스로 제공하므로, 언제든 커스터마이징 기능 확장이 가능하다. HandlerMethodArgumentResolver HandlerMethodReturnValueHandler HttpMessageConverter @Bean public WebMvcConfigurer webMvcConfigurer() { return new WebMvcConfigurer() { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { //... } @Override public void extendMessageConverters(List<HttpMessageConverter<?>> converters) { //... } }; } Reference 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 View, ViewResolver (SERVLET) @RequestBody는 어떻게 동작할까?
Java-Ecosystem
· 2024-04-21
스프링 로깅 기본
로깅 (Logging) 스프링 부트 로깅 라이브러리(spring-boot-starter-logging)에서 다음 로깅 라이브러리 사용 SLF4J 라이브러리: 로그 라이브러리를 통합해서 인터페이스로 제공 (Logback, Log4J, Log4J2…) Logback 라이브러리: 실무에서 구현체 로그 라이브러리로 주로 사용 (스프링 부트 기본 제공) 로그 선언 기본 사용법 private static final Logger log = LoggerFactory.getLogger(getClass()); 롬복 사용 @Slf4j 다음 코드를 자동 생성 private static final Logger log = LoggerFactory.getLogger(Xxx.class); 올바른 로그 사용법 올바른 사용법: log.debug("data={}", data) 잘못된 사용법: log.debug("String concat log=" + name) 로그 출력 레벨을 info로 설정하면 로그 남지 않으나, 더하기 연산은 무조건 수행되어 비효율 로그 출력 포멧 시간 / 로그 레벨 / 프로세스 ID / 쓰레드 명 / 클래스명 / 로그 메시지 로그 레벨 수준 (정보가 많은 순서로) TRACE > DEBUG > INFO > WARN > ERROR DEBUG: 개발 서버 적합 INFO: 운영 서버 적합 로그 레벨별 호출 방법 //@Slf4j @RestController public class LogTestController { private final Logger log = LoggerFactory.getLogger(getClass()); @RequestMapping("/log-test") public String logTest() { String name = "Spring"; log.trace("trace log={}", name); log.debug("debug log={}", name); log.info(" info log={}", name); log.warn(" warn log={}", name); log.error("error log={}", name); return "ok"; } } 로그 레벨 전역 설정 (application.properties) 전체 로그 레벨 설정 (디폴트: info) logging.level.root=info 특정 패키지 및 하위 패키지 로그 레벨 설정 logging.level.hello.springmvc=debug 장점 쓰레드 정보, 클래스 이름 같은 부가 정보 확인 가능 출력 포멧 조절 가능 로그 레벨 설정으로 상황에 맞게 조절 가능 파일, 네트워크 등 콘솔 뿐만 아니라 별도의 위치에 남길 수 있음 (파일의 경우 일별, 용량별 분할 가능) System.out 보다 성능도 좋음 Reference 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술
Java-Ecosystem
· 2024-04-14
스프링 부트 프로젝트 세팅
스프링 프로젝트 세팅 방법 프로젝트 GENERATE: https://start.spring.io Spring Boot Version은 SNAPSHOT, M2가 들어가지 않은 것이 정식 버전 Package name은 -가 안들어가도록 주의 ADD DEPENDENCIES: Spring WEB, Thymeleaf, Lombok, Validation, JPA, H2… Settings Lombok Plugins - Lombok 설치 Annotation Processing - Enable annotation processing Gradle - Build and run using, Run tests using - IntelliJ IDEA 변경 Main 함수 실행 - White label page 확인 H2 Database 세팅 방법 설치 - H2 Database 데이터베이스 파일 생성 (첫 진입) jdbc:h2:~/jpashop 다음 파일 생성 확인: ~/jpashop.mv.db 이후부터 TCP 연결 jdbc:h2:tcp://localhost/~/jpashop JPA 및 DB 설정 main/resources/application.yml spring: datasource: url: jdbc:h2:tcp://localhost/~/jpashop username: sa password: driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: create properties: hibernate: # show_sql: true # System.out을 통해 SQL 남김 (지양) format_sql: true logging.level: org.hibernate.SQL: debug org.hibernate.orm.jdbc.bind: trace ddl-auto create: 애플리케이션 실행 시점에 테이블을 drop하고 다시 생성 none: 테이블을 생성하지 않음 format_sql SQL이 포멧팅되어 조금 더 보기 좋게 나오게 함 org.hibernate.SQL (권장, 로그로 남기는게 좋음) logger를 통해 SQL 남김 org.hibernate.orm.jdbc.bind: trace SQL 실행 파라미터(쿼리 파라미터)를 로그로 남김 외부 라이브러리 (가독성 높은 쿼리 파라미터 로그) implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' 커넥션 정보, 가독성 높은 쿼리 파라미터 등 상세 정보 제공 시스템 자원을 잡아 먹으므로 운영 시스템에 적용하려면 반드시 성능 테스트 필요 (개발 단계 자유 사용) QueryDSL 설정 방법 (스프링 부트 3.0 이상) JDK 17 이상, 빌드 옵션으로는 Gradle 선택하기 (IntelliJ X) Preferences - Annotation Processors - Enable annotation processing 체크 build.gradle에 아래 설정 추가 //Querydsl 추가 implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" 예제 엔터티(@Entity) 만들기 (Hello.class) Gradle - Tasks - build: build 작업 진행 생성된 build 폴더를 삭제하고 다시 하고 싶을 때는 clean 작업 진행 build - generated - sources - annotationProcessor - … - Q파일 생성 확인 (디렉토리) Test 설정 파일 spring: logging.level: org.hibernate.SQL: debug 경로: test/resources/application.yml 유용한 명령어 의존관계 확인 (Tree view) 프로젝트 디렉토리 - ./gradlew dependencies -configuration compileClasspath 서버 재시작 없이 View 파일 변경하기 spring-boot-devtools 라이브러리 추가 html 파일만 컴파일 (build - Recompile) 초기 데이터 생성 @Slf4j @RequiredArgsConstructor public class TestDataInit { private final ItemRepository itemRepository; /** * 확인용 초기 데이터 추가 */ @EventListener(ApplicationReadyEvent.class) public void initData() { log.info("test data init"); itemRepository.save(new Item("itemA", 10000, 10)); itemRepository.save(new Item("itemB", 20000, 20)); } } @EventListener(ApplicationReadyEvent.class) 스프링 컨테이너가 완전히 초기화를 끝내고, 실행 준비가 되었을 때 발생하는 이벤트 스프링 컨테이너가 AOP를 포함해 완전히 초기화된 시점 @PostConstruct의 경우, AOP 같은 부분이 다 처리되지 않은 시점에 호출될 수 있음 예를 들어, @Transactional 관련 AOP가 적용되지 않고 호출될 수 있어 문제 스프링은 이 시점에, initData()를 호출 프로필 (Profile) 프로필은 로컬, 운영 환경, 테스트 실행 등 다양한 환경에 따라 다른 설정을 할 때 사용하는 정보 로컬에서는 로컬 DB, 운영 환경에서는 운영 DB에 접근 환경에 따라 다른 스프링 빈 등록 스프링은 로딩 시점에 spring.profiles.active 사용 (spring.profiles.active=local) main 프로필: src/main/resources 하위 application.properties test 프로필: src/test/resources 하위 **`application.properties 프로필을 지정하지 않으면 "default" 프로필로 동작 설정파일(Config) 및 프로필 적용하기 @Import(MemoryConfig.class) @SpringBootApplication(scanBasePackages = "hello.itemservice.web") public class ItemServiceApplication { public static void main(String[] args) { SpringApplication.run(ItemServiceApplication.class, args); } @Bean @Profile("local") public TestDataInit testDataInit(ItemRepository itemRepository) { return new TestDataInit(itemRepository); } } @Import(MemoryConfig.class) 원하는 설정파일 적용 @Profile("local") 특정 프로필의 경우에만 해당 스프링 빈 등록 주요한application.properties 설정 트랜잭션 프록시가 호출하는 트랜잭션의 시작 및 종료 로그 확인 가능 logging.level.org.springframework.transaction.interceptor=TRACE logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG JPA 커밋 롤백 로그 확인 logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG logging.level.org.hibernate.resource.transaction=DEBUG JPA SQL 로그 확인 logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.orm.jdbc.bind=TRACE HTTP 요청 메시지 확인하기 logging.level.org.apache.coyote.http11=trace
Java-Ecosystem
· 2024-03-15
스프링 핵심원리 - 기본편
스프링의 탄생 EJB (Enterprise Java Beans) 지옥 과거 EJB는 자바 진영의 표준이었다. 분산 처리를 편리하게 도와주고 자체 ORM(Entity Bean)을 제공 그러나 EJB 의존적 개발은 코드 관리가 심히 어려웠고 Entity Bean은 join이 잘 안될 정도로 기술력이 떨어짐 스프링의 탄생 Rod Johnson(로드 존슨)이 J2EE Design and Development 책 발행 (2002) J2EE(=EJB)에 고통 받던 로드 존슨은 EJB 없이 고품질의 확장 가능한 애플리케이션 개발을 보여줌 (3만 줄 이상의 예제코드) 현재의 스프링 핵심 개념 제시 (BeanFactory, ApplicationContext, POJO, IoC, DI etc…) 개발자들은 열광했다. Juergen Hoeller(유겐 휠러), Yann Caroff(얀 카로프)가 책이 아깝다며 오픈소스 프로젝트 제안 스프링의 시작과 현재 (EJB의 겨울을 넘어 새로운 시작이라는 뜻) 현재도 유겐 휠러가 스프링 핵심코드의 상당수를 개발 중 JPA의 탄생 Gavin King(개빈 킹)이 EJB 엔터티 빈을 대체하는 Hibernate을 개발 (많은 개발자가 사용하게 됨) 추후 자바 표준을 새로 만들 때, 개빈 킹을 영입해 JPA 표준을 만듦 그 후 Hibernate이 다시 JPA 표준 구현체가 됨 현재 JPA 구현체들 중 가장 많이 사용하는 것이 Hibernate이다. 스프링 생태계 필수 스프링 프레임워크 핵심 가치: 객체 지향 언어의 강점을 잘 살릴 수 있게 도와주는 프레임워크 핵심기술: 스프링 DI 컨테이너, AOP, 이벤트, 기타 웹 기술: 스프링 MVC, 스프링 WebFlux 데이터 접근 기술: 트랜잭션, JDBC, ORM 지원 기술 통합: 캐시, 이메일, 원격접근, 스케줄링 테스트: 스프링 기반 테스트 지원 언어: 코틀린, 그루비 스프링 부트 스프링을 편리하게 사용할 수 있도록 지원 쉬운 스프링 애플리케이션 생성 Tomcat 웹서버 설치 필요 없이 내장 손쉬운 빌드 구성을 위한 starter 및 서드 파티 라이브러리 자동 구성 모니터링 기능 제공 선택 스프링 데이터 스프링 세션 스프링 시큐리티 스프링 Rest Docs 스프링 배치 스프링 클라우드 객체 지향과 스프링 객체 지향의 핵심은 다형성이지만, 다형성만으로는 SOLID의 OCP, DIP 원칙을 지킬 수 없다. MemberRepository m = new MemoryMemberRepository(); 다형성 + OCP, DIP를 지키려다보면 결국 스프링 프레임워크를 만들게 된다. 의존관계 주입 (DI, Dependency Injection) 런타임에 외부에서 구현 객체를 생성하고 클라이언트에 전달해서 실제 의존관계가 연결되는 것 스프링 이전: AppConfig 설정 클래스를 따로 만들고 구현 객체 생성, 연결 책임을 할당 애플리케이션이 크게 사용 영역과 구성 영역으로 분리 관심사의 분리, SRP 준수 구현 객체 변경시 사용 영역(클라이언트 코드) 변경 없이 구성 영역만 영향 받음 다형성 + DIP가 잘 지켜지면 OCP 준수까지 이어짐 스프링 이후: 스프링은 DI 컨테이너(=IoC 컨테이너)를 통해 DI를 지원 (구성 영역) 스프링 컨테이너 = AppConfig + @Configuration (+ @Bean) 구성 영역 덕분에 클라이언트 코드 변경 없이 부품 갈아 끼우듯 런타임 기능 확장이 가능 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 의존관계를 쉽게 변경 가능 제어의 역전 (IoC, Inversion of Control) 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것 구성영역(AppConfig 혹은 DI 컨테이너)이 프로그램의 제어흐름을 가져가면서 발생 “역할에 따른 구현이 보인다”의 의미 역할과 구현 클래스가 한눈에 들어오는 것을 말한다. 메소드 이름, 리턴 타입(역할), 리턴하는 객체(구현)가 명확하게 보이는 코드가 좋다. 프레임워크 VS 라이브러리 프레임워크: 내가 작성하는 코드를 제어하고 대신 실행함 (JUnit) 라이브러리: 내가 작성한 코드가 직접 제어의 흐름을 담당 의존관계 분류 클래스 의존관계(정적): 애플리케이션 실행 없이 import 코드만으로 파악하는 의존관계 (클래스 다이어그램) 객체 의존관계(동적): 애플리케이션 실행 시점(런타임)에 실제 생성되는 객체 인스턴스 간 의존관계 (객체 다이어그램) 스프링 컨테이너 (DI 컨테이너, IoC 컨테이너) 스프링 컨테이너 (AppConfig + @Configuration = ApplicationContext) 스프링에서 의존관계 주입을 지원해주는 구성 영역 ApplicationContext 혹은 BeanFactory를 지칭 구조 BeanFactory (스프링 컨테이너 최상위 인터페이스) 스프링 빈을 관리하고 조회하는 역할 getBean() 제공 ApplicationContext(인터페이스, 주로 사용) 빈 관리 및 조회 기능 (BeanFactory 상속 받음) 부가 기능 제공 국제화 기능, 환경변수 (로컬, 개발, 운영 구분), 애플리케이션 이벤트 (이벤트 발행 구독 모델 지원), 리소스 조회 ApplicationContext 구현체 (다양한 형식의 설정 정보) 종류 AnnotationConfigApplicationContext (애노테이션 기반 자바 코드 설정) GenericXmlApplicationContext (XML 설정) XxxApplicationContext… BeanDefinition 빈 설정 메타정보 @Bean 당 각각 하나씩 메타정보가 생성됨 스프링 컨테이너는 BeanDefinition 인터페이스만 알고 해당 메타정보 기반으로 빈 생성 다양한 형식의 설정 정보는 실제로 BeanDefinitionReader가 읽고 BeanDefinition을 생성 AnnotatedBeanDefinitionReader XmlBeanDefinitionReader XxxBeanDefinitionReader… 스프링 빈(@Bean) 스프링 컨테이너에 등록된 객체 스프링 컨테이너 조회 메서드 대원칙: 부모 타입을 조회하면, 자식 타입도 함께 조회한다. Object로 조회시 모든 스프링 빈 조회 유의점 구체 타입 조회(특정 하위 타입 조회 등)는 유연성이 감소되므로 지양 개발시에는 굳이 컨테이너에 직접 빈을 조회할 일이 없음 기본 조회 ac.getBean(빈이름, 타입) ac.getBean(타입) 예외 NoSuchBeanDefinitionException: No bean named ... 조회 대상 빈이 없을 때 NoUniqueBeanDefinitionException: No bean named ... 타입으로 조회시 같은 타입의 스프링 빈이 둘 이상일 때 (빈 이름 지정하면 해결) 해당 타입의 모든 빈을 조회 ac.getBeansOfType(타입) 컨테이너에 등록된 모든 빈 이름 조회 ac.getBeanDefinitionNames() Bean Definition 조회 ac.getBeanDefinition(데피니션 네임) 빈 역할 조회 beanDefinition.getRole() ROLE_APPLICATION: 일반적으로 사용자가 정의한 빈 ROLE_INFRASTRUCTURE: 스프링이 내부에서 사용하는 빈 Class 내부의 static Class의 의미 해당 클래스를 현재 상위 클래스의 스코프 내에서만 사용하겠다는 의미 Spring Bean 생성을 위한 두 가지 일반적인 설정 정보 등록 방법 직접 Spring Bean 등록 (=xml 방식) BeanDefinition에 클래스 정보가 자세히 기록되어 있음 beanDefinition = Generic bean: class [hello.core.member.MemberServiceImpl] Factory method를 통해 등록 (=Java config를 통해 등록하는 방법) FactoryBean(=AppConfig) & Factory Method(=memberService 메서드) BeanDefinition에 factoryBeanName=appConfig; factoryMethodName=memberService 식으로 등록되어 있음 스프링 컨테이너 생성 과정 스프링 컨테이너 생성 단계 구성 정보(AppConfig.class)와 함께 컨테이너 객체 생성 new AnnotationConfigApplicationContext(AppConfig.class) 스프링 빈 생성 및 등록 단계 스프링 컨테이너는 설정 클래스 정보를 확인하면서 @Bean이 붙은 메서드를 모두 호출하고 메서드의 이름 Key, 메서드 반환 객체를 Value로 스프링 빈 저장소에 등록 메서드 호출로 빈 객체 생성시 의존관계 주입이 필요한 객체에 한해서 이 시점에 DI가 발생 빈 이름 = 메서드 명 빈 이름 직접 부여 가능 - @Bean(name="memberServiceNewNamed") 빈 이름은 항상 다른 이름을 부여해야 함 (다른 빈 무시 혹은 기존 빈 덮는 등의 오류) 스프링 빈 의존관계 설정 단계 스프링 컨테이너는 설정 정보를 참고해서 의존관계 주입 (DI) 스프링 컨테이너 특징: 싱글톤 패턴 싱글톤 패턴: 클래스의 인스턴스가 단 1개만 생성되는 것을 보장하는 디자인 패턴 public class SingletonService { //static 영역에 객체를 단 1개만 생성 private static final SingletonService instance = new SingletonService(); //객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회 허용 public static SingletonService getInstance() { return instance; } //private 생성자로 new 키워드를 사용한 객체 외부 생성 방지 private SingletonService() { } public void logic() { System.out.println("싱글톤 객체 로직 호출"); } } 장점 클라이언트 요청이 올 때마다 이미 만들어진 객체를 공유해 메모리 낭비 없이 효율적으로 처리할 수 있음 단점 구현 코드가 많고 DIP, OCP 위반 가능성을 높임 memberService.getInstance()로만 접근 가능하므로 테스트하기 어렵고 유연성이 떨어짐 독립적인 단위테스트를 하려면 매번 공유된 인스턴스 초기화 필요 안티패턴으로 불리기도 함 싱글톤 컨테이너 스프링 컨테이너는 싱글톤 패턴의 단점을 해결하면서 스프링 빈을 싱글톤으로 관리 (싱글톤 레지스트리) Bean을 단 한 번만 생성하고 클라이언트 요청이 올 때마다 생성한 Bean을 공유 싱글톤 패턴을 위한 지저분한 코드가 없어도 됨 DIP, OCP, 테스트, private 생성자로부터 자유롭게 싱글톤 사용 가능 주의점: 스프링 빈은 항상 Stateless 설계할 것 싱글톤은 여러 클라이언트가 하나의 객체 인스턴스를 공유하므로 무상태(Stateless)로 설계해야 함 공유 필드가 큰 장애를 유발 가급적 읽기에만 사용 @Configuration과 바이트 코드 조작 라이브러리 결론적으로 항상 @Configuration을 사용해야 한다. 설정 정보에서 new를 사용해 여러 번 빈을 생성하는 자바코드를 볼 수 있다. 그러나 실제로 빈은 한 번만 생성된다. 이는 설정정보 클래스에 @Configuration 애노테이션이 붙어 있어 가능하다. CGLIB이라는 바이트코드 조작 라이브러리가 설정 정보 클래스를 상속받아 임의의 다른 클래스를 만들고 이를 스프링 빈으로 등록한다. 이 클래스는 싱글톤 기능을 보장해준다. bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$bd479d70 구체적으로 @Bean이 붙은 메서드마다 빈이 이미 존재하면 해당 빈을 반환하고, 없으면 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어진다. 만일 @Configuration이 없다면, 매번 빈이 생성 및 등록되는 비효율적인 상황이 발생한다. 즉, @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다. (이 경우 @autowired를 사용하여 의존관계 자동 주입으로 싱글톤 동작을 유도하는 방법이 있긴 하다.) 컴포넌트 스캔 설정 정보 없이 자동으로 스프링 빈을 등록하는 기능 애노테이션 @ComponentScan 설정 정보 기반이 될 클래스에 애노테이션 추가 (@Bean 붙은 메서드 없어도 상관없음) @Component가 붙은 모든 클래스를 스프링 빈으로 등록 @Component 스프링 빈으로 등록할 클래스에 애노테이션 추가 네이밍 전략 빈이름은 클래스명(첫글자만 소문자로)을 그대로 따라감 직접 빈 이름 지정: @Component("memberServiceNewName") @Autowired 의존관계 자동 주입이 필요한 클래스에 애노테이션 추가 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입 조회 전략 기본은 타입이 같은 빈을 찾아 주입 - 같은 타입이 여러개 있으면 충돌 생성자가 하나라면 애노테이션이 생략되어도 자동으로 조회 @Autowired(required = false): 주입 대상이 없어도 동작 기본 스캔 대상 @Component 및 @Component 붙은 대상은 모두 스캔 @Component를 포함하고 있는 애노테이션 @Controller MVC 컨트롤러로 인식 @Service 특별한 처리 X, just 개발자의 비즈니스 계층 인식용 @Repository 데이터 접근 계층 인식, 데이터 계층 예외를 스프링 예외로 변환 @Configuration 스프링 설정 정보 인식 및 싱글톤 적용 useDefaultFilters이 기본적으로 켜져 있고, 옵션을 끄면 기본 스캔 대상들 제외도 가능 스캔 대상 필터 (@ComponentScan 파라미터) includeFilters: 컴포넌트 스캔 대상을 추가로 지정 excludeFilters: 컴포넌트 스캔에서 제외할 대상 지정 빈 중복 등록으로 인한 스캔 충돌 기본적으로 중복 등록을 하지 말자 (스프링 부트에서 에러 던짐) 케이스 자동 빈 등록 & 자동 빈 등록 ConflictingBeanDefinitionException 스프링 예외 발생 수동 빈 등록 & 자동 빈 등록 수동 빈 등록이 우선권 (수동 빈이 자동 빈을 오버라이딩) 의존관계 자동 주입 의존 관계 주입 방법 생성자 주입 (Recommandation - Spring & Other DI Frameworks) 생성자 호출 시점에 단 1번 호출되는 것이 보장 불변, 필수 의존관계에 사용 대부분의 의존관계 주입은 애플리케이션 종료까지 변하면 안됨 (불변) 생성자 주입 + final을 사용하면 필수적 주입 데이터 누락은 컴파일 오류 (필수) 다른 주입방법은 final 사용 불가 실제 주요 사용 예 생성자를 1개 두고 @Autowired 생략 lombok @RequiredArgsConstructor 적용 final 필드를 모아 컴파일 시점에 생성자 코드 자동 생성 수정자 주입 (Setter 주입, 필요 시 사용) 선택, 변경 가능성이 있는 의존관계에 사용 필드 주입 (Don’t use) 외부 변경이 불가해 테스트가 힘듦 예를 들어, 테스트에 실제 DB를 사용하지 않는 가짜 레포지토리 사용이 불가 이를 위해 Setter를 만들면 세터 주입과 동일 결국, DI 프레임워크 없이는 순수한 단위 테스트 불가 테스트 코드, @Configuration 같은 특별한 용도에서만 사용 일반 메서드 주입 (Don’t use) 한 번에 여러 필드를 주입 받을 수 있다는 점이 수정자 주입과 차이점 옵셔널 처리 (주입할 스프링 빈이 없어도 동작해야 할 때) @Autowired(required=false) 자동 주입 대상이 없다면, 메서드 자체를 호출 x @Nullable 자동 주입 대상이 없다면, null 입력 의존관계 파라미터의 타입 Optional<> 자동 주입 대상이 없다면, Optional.empty 입력 의존관계 파라미터의 타입 조회 빈이 2개 이상일 때 @Autowired는 타입으로 조회하므로 조회 빈이 여러 개일 때 충돌 발생 해결책 @Primary (Recommandation) 우선이 되는 빈에 적용 @Qualifier (Optional) 추가 구분자(@Qualifier(”임의의 구분자 이름”))를 빈과 실제 주입이 필요한 곳에 각각 적용 우선권은 @Primary보다 높음 실제 사용시에는 커스텀 애노테이션으로 만들면 컴파일 시점에 타입 체크 가능 @Autowired 필드 명 매칭 (Don’t use) 빈이 여러 개일 때, 필드명 혹은 파라미터명으로 추가 매칭 메인 데이터베이스 커넥션 획득 빈에 @Primary를 적용하고, 서브 데이터베이스 커넥션 획득 빈에 @Quliafier를 적용하는 케이스에 유용. 조회한 빈이 모두 필요할 때 List, Map으로 자동 의존관계 주입을 받으면 모든 빈 조회 가능 전략 패턴 구현 용이 스프링 컨테이너 생성하며 스프링 빈 등록하기 new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class) 자동 및 수동에 대한 실무 운영 기준 애플리케이션 = 업무 로직 빈 + 기술 지원 빈 업무 로직 빈 컨트롤러, 서비스, 레포지토리 등 비즈니스 요구사항 개발 시 추가 및 변경되는 부분 기술 지원 빈 데이터베이스 연결, 로깅 등의 기술적인 문제나 AOP를 처리하는 부분 전략 자동 기능(컴포넌트 스캔, 의존관계 자동 주입)을 기본으로 사용 (업무 로직) 수동 빈 등록 상황 (명시적이어서 유지 보수에 좋음) 기술 지원 로직 비즈니스 로직 중 다형성을 적극 활용할 때 (DiscountPoilicy) 종류를 한 눈에 파악하기 위해 수동 빈으로 등록 (권장) 자동으로 하는 경우 특정 패키지에 같이 묶어두기 빈 생명주기 콜백 DB 커넥션, 네트워크 소켓은 애플리케이션 시작 및 종료 시, 객체 초기화 & 종료 작업 필요 스프링 빈의 이벤트 라이프사이클 스프링 컨테이너 생성 스프링 빈 생성 Constructor Injection이 일어날 수 있음 생성자 주입은 자동 의존관계 주입이지만 객체 생성이 어쩔 수 없이 되어야할 경우 스프링 빈 생성 시점에 의존관계 주입이 일어남 의존관계 주입 Setter or Field Injection 초기화 콜백 빈이 생성되고 의존관계 주입이 완료된 후 호출 사용 소멸전 콜백 빈이 소멸되기 직전에 호출 스프링 종료 방법 @PostConstruct, @PreDestroy (Recommandation) 빈으로 등록될 실제 객체에 적용 장점 자바 표준(JSR-250)이라 스프링이 아닌 다른 컨테이너에서도 동작 스프링 권장 방법 단점 외부 라이브러리 적용 불가능 빈 등록 초기화, 종료 메서드 지정 (외부 라이브러리 초기화 및 종료 시 사용) @Bean(initMethod = "init", destroyMethod = "close") 빈으로 등록될 실제 객체에 init, close 메서드 만들고 지정 destroyMethod 속성은 지정하지 않아도 종료 메서드를 자동 추론해 호출 장점 메서드 이름 자유롭게 지정 가능 스프링 코드에 의존 X 외부 라이브러리 적용 가능 인터페이스 InitializingBean, DisposableBean (Don’t use) afterPropertiesSet, destroy 메서드 오버라이딩으로 지원 스프링 인터페이스라서 스프링에 의존하게 됨 단점 외부 라이브러리 적용 불가능 빈 스코프 빈이 존재할 수 있는 범위 애노테이션: @Scope(“지정 범위”) (자동 등록 및 수동 등록 모두 적용 가능) 종류 싱글톤 스프링 컨테이너의 시작과 종료까지 유지 (기본) 프로토타입 스프링 컨테이너에 요청할 때 마다 프로토타입 빈을 생성하고 의존관계 주입 및 초기화 메서드 실행 후 클라이언트에 반환 이후 빈 관리 책임이 클라이언트에 있으므로 @PreDestroy같은 종료 메서드가 호출되지 않음 필요시 클라이언트가 종료 메서드를 호출해야 함 웹 관련 스코프 (웹 환경에서만 동작) request HTTP 요청 하나가 들어오고 나갈 때 까지 유지되는 스코프 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 관리 session HTTP Session과 동일한 생명주기를 가지는 스코프 application 서블릿 컨텍스트와 동일한 생명주기를 가지는 스코프 websocket 웹 소켓과 동일한 생명주기를 가지는 스코프 싱글톤 빈 + 프로토타입 빈 사용 실무 문제는 대부분 싱글톤 빈으로 해결할 수 있어서 프로토타입 빈을 직접 사용하는 경우가 드물다! 원 시나리오 싱글톤 빈과 프로토타입 빈을 함께 사용 싱글톤 빈이 프로토타입 빈을 사용할 때마다 항상 새로운 프로토타입 빈 생성 문제 싱글톤 빈에 프로토타입 빈 의존관계 주입 시 프로토타입 빈이 미리 생성 싱글톤 빈이 요청할 때마다 같은 프로토타입 빈을 반환 해결책 의존관계 조회(DL, Dependency Lookup) 외부에서 의존관계를 주입받지 않고, 컨테이너에서 직접 의존관계를 찾는 것 스프링 컨테이너로 직접 조회하므로 항상 새로운 프로토타입 빈 생성 방법 (편한 방법 선택) ObjectProvider (편리) ObjetProvider의 getObject()를 호출하면 내부에서 스프링 컨테이너를 통해 해당 빈을 찾아서 반환 (DL) 장점 기능이 단순해서 단위테스트 및 mock 코드 만들기 쉬움 단점 스프링 의존 Provider(JSR-330) jakarta.inject.Provider Provider의 get()을 호출하면 DL 실행 장점 자바 표준 (다른 컨테이너에서도 사용 가능) 기능이 단순해서 단위테스트 및 mock 코드 만들기 쉬움 단점 별도 라이브러리를 추가해야 함 jakarta.inject:jakarta.inject-api:2.0.1 Provider, ObjectProvider는 프로토타입 뿐만 아니라 DL이 필요한 어떤 경우에도 사용 가능 싱글톤 빈 + 리퀘스트 스코프 빈 원 시나리오 컨트롤러 및 서비스 빈에 HTTP 요청마다 로거 객체를 DI 문제 컨트롤러 및 서비스는 싱글톤 빈이므로 스프링 앱 실행 시점에 생성 및 필요한 DI가 일어남 그러나, 로거 객체는 request scope를 가져서 이 시점에 생성이 될 수 없어 에러 발생 해결책 (실제 빈 객체 조회를 필요한 시점까지 지연하기, 편한 방법 선택) Provider 방식 Provider, ObjectProvider 프록시 방식 @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS) 적용 대상이 클래스면 TARGET_CLASS 적용 대상이 인터페이스면 INTERFACES 가짜 프록시 클래스를 만들어두고 HTTP request 상관없이 미리 주입해둘 수 있음 가짜 프록시 객체는 request scope와 상관 없이 싱글톤처럼 동작 CGLIB 라이브러리로 내 클래스를 상속받은 가짜 프록시 객체 주입 프록시 객체는 요청이 오면 내부에서 진짜 빈을 요청하는 위임 로직 실행 프록시는 진짜 빈을 찾는 방법을 알고 있음 클라이언트 메서드 호출 시 가짜 프록시 객체의 메서드를 호출한 것이고 프록시 객체는 request scope의 진짜 객체를 호출 클라이언트는 원본인지 아닌지 모르게 동일하게 사용 (다형성) 표준과 프레임워크 기능 사이에서의 선택 JPA의 경우 JPA 자체가 표준이기에 표준을 사용하면 된다. 스프링의 경우 스프링이 더 기능이 좋다 싶으면 스프링 기능을 사용하고 유용성이 비슷하다 싶으면 자바 표준을 사용하면 좋을 것 같다. (자바 표준이 있어도 사실상 스프링 이외의 컨테이너를 쓸 일이 없기 때문) 흔치 않지만, 스프링이 아닌 다른 컨테이너에서도 사용할 수 있어야 하면 표준을 사용해야 한다. spring-boot-starter-web 웹 라이브러리가 없으면 스프링 부트는 AnnotationConfigApplicationContext 기반으로 애플리케이션을 구동한다. 반면에, 웹 라이브러리가 있으면 AnnotationConfigServletWebServerApplicationContext를 기반으로 구동한다. (웹 관련 추가 설정 및 환경이 필요하므로) Reference 스프링 핵심 원리 - 기본편
Java-Ecosystem
· 2024-03-11
스프링 시작하기
빌드 및 실행 방법 ./gradlew build cd build/libs java -jar hello-spring-0.0.1-SNAPSHOT.jar 빌드가 잘 안될 때는 ./gradlew clean build 후 다시 빌드 (빌드 폴더 삭제) 주요 라이브러리 의존관계 스프링 부트 라이브러리 spring-boot-starter-web spring-boot-starter-tomcat: 톰캣 (웹서버) spring-webmvc: 스프링 웹 MVC spring-boot-starter-thymeleaf: 타임리프 템플릿 엔진(View) spring-boot-starter(공통): 스프링 부트 + 스프링 코어 + 로깅 spring-boot spring-core spring-boot-starter-logging logback, slf4j 테스트 라이브러리 spring-boot-starter-test junit: 테스트 프레임워크 mockito: 목 라이브러리 assertj: 테스트 코드를 좀 더 편하게 작성하게 도와주는 라이브러리 spring-test: 스프링 통합 테스트 지원 웹 개발 유형 변화 정적 컨텐츠 서버가 HTML 파일을 브라우저에게 그대로 넘겨주는 방식 과거에는 뷰와 컨트롤러 분리 없이 뷰로 모든 것을 다 하는 모델 원 방식을 사용 static/index.html 해당 경로로 index.html을 생성해두면 Welcome page가 된다. 예시 요청: localhost:8080/hello-static.html 톰켓 서버가 스프링 컨테이너에 요청 전달 요청에 대한 컨트롤러가 없다면, resources/static/hello-static.html을 찾아 브라우저에 반환 MVC와 템플릿 엔진 컨트롤러에서 리턴 값으로 문자를 반환하면 뷰 리졸버(viewResolver)가 화면을 찾아 처리한다. 템플릿 엔진 viewName 매핑 resources/templates 하위에 있는 {viewName}.html 파일을 찾아 템플릿 엔진 처리 후 브라우저에 반환 예시 요청: localhost:8080/hello-mvc 톰켓 서버가 스프링 컨테이너에 요청 전달 요청에 대한 컨트롤러를 찾음 컨트롤러는 hello-template이라는 문자열을 반환 viewResolver는 viewName 매핑으로 resources/templates/hello-template.html을 찾음 viewResolver가 Thymeleaf 템플릿 엔진에게 처리를 넘김 템플릿 엔진이 렌더링해서 변환한 HTML을 브라우저에게 반환 API @ResponseBody를 붙인 컨트롤러는 viewResolver를 사용하지 않는다. 결과 값을 JSON 형태로 HTTP Body에 담아 반환한다. viewResolver 대신 HttpMessageConverter가 동작 HTTP Accept 헤더와 컨트롤러 반환 타입 정보를 조합해 HttpMessageConverter가 선택됨 StringHttpMessageConverter (기본 문자처리) MappingJackson2HttpMessageConverter (기본 객체처리) xmlHttpMessageConverter (accept header에 xml로 요청) 예시 요청: localhost:8080/hello-api 톰켓 서버가 스프링 컨테이너에 요청 전달 요청에 대한 컨트롤러를 찾음 @ResponseBody가 붙은 컨트롤러이므로 반환값이 HttpMessageConverter에 전달됨 HttpMessageConverter가 데이터를 직렬화 후 HTTP Body에 담아 반환 Spring Bean 스프링 빈은 각 객체들 간의 의존관계를 저장하여 사용하기 편리하게 지원한다. 의존관계 등록방법 자동 의존관계 설정 (Component Scan) 정형화된 컨트롤러, 서비스, 레포지토리 같은 코드에 컴포넌트 스캔을 사용한다. 컴포넌트 등록 @Component 애노테이션이 있으면 스프링 빈으로 자동 등록된다. 다음 어노테이션들은 @Component를 포함하고 있어 역시 자동 등록된다. @Controller @Service @Repository @Autowired 해당 어노테이션이 붙은 메소드에게 스프링이 자동으로 연관 객체를 DI(의존성 주입)한다. 생성자에 사용시 객체 생성시점에 의존성 주입한다. 생성자가 하나라면 @Autowired 생략 가능 수동 의존관계 설정 (SpringConfig.java) 정형화되지 않거나 상황에 따라 구현 클래스를 변경해야 한다면 직접 설정으로 등록한다. 레포지토리를 다양하게 변경해야 하는 상황이라면 관리 포인트가 하나가 되어 편리 등록 방법 @Service, @Repository, @Autowired 등 현 상황에 불필요한 어노테이션은 제거 현재 앱 디렉토리에 SpringConfig.java 클래스 생성 클래스에 @Configuration, 메서드에 @Bean 어노테이션을 설정 메서드마다 필요한 인스턴스를 반환 @Configuration public class SpringConfig { @Bean public MemberService memberService() { return new MemberService(memberRepository()); } @Bean public MemberRepository memberRepository() { return new MemoryMemberRepository(); } } 특징 스프링은 스프링 빈에 등록할 때, 객체를 싱글톤으로 등록한다. 즉, 같은 스프링 빈이면 모두 같은 인스턴스여서 메모리가 절약된다. (설정으로 변경은 가능) 예시 주문 컨트롤러에서 멤버 서비스, 멤버 레포지토리를 요청하면 똑같은 인스턴스를 넣어 줌 스프링 통합 테스트 테스트 클래스에 다음 어노테이션을 추가한다. @SpringBootTest 스프링 컨테이너와 테스트를 함께 실행한다. @Transactional 테스트 케이스에 해당 어노테이션을 추가하면, 테스트 시작 전에 트랜잭션을 시작하고 테스트 완료 후 항상 롤백한다. 덕분에 DB에 데이터가 남지 않아 각각의 테스트가 서로 영향을 주지 않는다. 스프링 DB 접근 기술 순수 JDBC 관련 코드들이 매우 장황하고 반복이 많다. JdbcTemplate SQL은 여전히 직접 작성하지만, JDBC의 반복 코드를 대부분 제거해준다. JPA ORM의 개념으로 넘어왔다. 데이터 중심 설계에서 객체 중심 설계 패러다임으로 전환할 수 있고 생산성을 크게 높인다. jdbc 관련 라이브러리가 포함되어 있다. 항상 @Transactional을 사용해서 데이터 변경을 트랜잭션 안에서 실행 시켜야 한다. 스프링 데이터 JPA 인터페이스만으로 개발 가능 메서드 이름만으로 조회 기능 제공 페이징 기능 자동 제공 단순 반복 코드가 크게 줄어드는 덕분에 개발자는 비즈니스 로직에 집중할 수 있다. Querydsl 복잡한 동적 쿼리 작업 AOP Aspect of Programming 관점 지향 프로그래밍 어떤 로직을 핵심 관심 사항, 공통 관심 사항으로 나누고 그 관점을 바탕으로 각각 모듈화 핵심 관심 사항 (core concern) 비즈니스 로직 공통 관심 사항 (cross-cutting concern) DB 연결, 로깅, 파일 입출력, API 계층별 응답 시간 측정 etc… @Aspect, @Around AOP in Spring 스프링은 프록시 방식의 AOP를 사용 스프링은 AOP가 있으면 서버가 올라올 때 컨테이너에서 스프링 빈에 등록하면서 가짜 스프링 빈을 앞에 세우고 그것이 끝나면 진짜 스프링 빈을 호출하도록 동작한다. (가짜 서비스 후 진짜 서비스 호출) 컨트롤러에서 서비스를 호출하면 서비스 코드는 스프링 빈을 통해 가짜 프록시 서비스를 의존성 주입 받고 해당 프록시 서비스가 끝나면 다시 실제 서비스 코드가 의존성 주입을 받아 실행된다. 이 방식은 스프링이 DI가 가능하기 떄문에 할 수 있는 기술 Reference 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술
Java-Ecosystem
· 2024-02-08
<
>
Touch background to close