Now Loading ...
-
스프링 부트 핵심 원리와 활용
스프링 부트의 필요성
스프링은 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% -> 심각
사용 전략
하나만 써야한다면 핀포인트 추천
스타트업에서 가장 먼저 할 일은 핀포인트 까는 것
설계가 탄탄하고 대용량 트래픽도 잘 버팀
그 다음 대시보드 구축 진행
그 다음 로그 구축
-
자바 애노테이션
애노테이션 (Annotation)
프로그램 실행 중에 읽어서 사용할 수 있는 특별한 주석
내부에서 리플렉션 같은 기술 등을 활용
e.g. Class ,Method ,Field ,Constructor 클래스는 getAnnotation() 제공
참고: 본래 주석은 코드가 아니므로 컴파일 시점에 모두 제거됨
코드에 메모를 달아 놓는 것처럼 코드에 대한 메타데이터를 표현
프로그램 코드가 아니어서 애노테이션이 달린 메서드를 호출해도 영향을 주지 않음
애노테이션 정의 규칙
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
public @interface AnnoElement {
String value();
int count() default 0;
String[] tags() default {};
Class<? extends MyLogger> annoData() default MyLogger.class;
//MyLogger data(); // 다른 타입은 적용X
}
정의: @interface 키워드
속성: 애노테이션은 속성을 가질 수 있음
요소 이름
메서드 형태로 정의
괄호()를 포함하되 매개변수는 없어야 함
데이터 타입
기본 타입 (int, float, boolean 등)
String
Class (메타데이터) 또는 인터페이스 (직접 정의한 것이 아닌 Class에 대한 정보)
enum
다른 애노테이션 타입
위의 타입들의 배열
앞서 설명한 타입 외에는 정의 불가 (즉, 일반적인 클래스를 사용할 수 없음)
예) Member , User , MyLogger
default 값
요소에 default 값을 지정 가능
예: String value() default "기본 값을 적용합니다.";
반환 값
void 반환 타입 사용 불가
예외
예외 선언 불가
메타 애노테이션: 애노테이션을 정의하는데 사용하는 특별한 애노테이션
@Retention
애노테이션의 생존 기간을 지정
종류: RetentionPolicy enum
RetentionPolicy.SOURCE (특별한 경우 사용)
소스코드에서만 생존 -> 컴파일 시점에 제거
RetentionPolicy.CLASS (기본값, 그러나 거의 사용 X)
컴파일 후 .class 파일까지 생존 -> 자바 실행 시점에 제거
RetentionPolicy.RUNTIME (대부분 사용)
자바 실행 중에도 생존
런타임에 리플렉션으로 읽을 수 있어 자주 사용됨
@Target
애노테이션을 적용할 수 있는 위치 지정
지정하지 않은 곳에 애노테이션을 적용하면 컴파일 오류 발생
종류: ElementType enum
주로 TYPE, FIELD, METHOD 사용
e.g. 배열로 여러 위치도 적용 가능
@Target({ElementType.METHOD, ElementType.TYPE})
@Documented (보통 함께 사용)
자바 API 문서를 만들 때, 해당 애노테이션이 문서에 포함되어 표현되도록 지정
@Inherited
자식 클래스가 애노테이션을 상속 받을 수 있게 함
애노테이션 사용법
기본
@AnnoElement(value = "data", count = 10, tags = {"t1", "t2"})
public class ElementData1 {
}
배열 항목이 하나인 경우 {} 생략 가능 & default 항목은 생략 가능
@AnnoElement(value = "data", tags = "t1")
public class ElementData2 {
}
입력 요소가 하나인 경우 value 키 생략 가능 (value = "data" 와 동일)
@AnnoElement("data")
public class ElementData3 {
}
애노테이션과 상속
public interface Annotation {
boolean equals(Object obj);
int hashCode();
String toString();
Class<? extends Annotation> annotationType(); //애노테이션 타입 반환
}
자바에서 애노테이션은 특별한 형태의 인터페이스로 간주
모든 애노테이션은 java.lang.annotation.Annotation 인터페이스를 묵시적으로 상속 받음
@interface로 정의하면 자바 컴파일러가 자동으로 Annotation 인터페이스를 확장
애노테이션 정의
public @interface MyCustomAnnotation {}
자바가 자동으로 처리
public interface MyCustomAnnotation extends java.lang.annotation.Annotation {}
다만, 애노테이션 사이에는 상속이라는 개념이 존재 X
애노테이션은 오직 Annotation 인터페이스만 상속
애노테이션은 다른 애노테이션이나 인터페이스를 직접 상속할 수 없음
@Inherited
애노테이션을 적용한 클래스의 자식 클래스도 해당 애노테이션을 부여 받을 수 있음
클래스 상속에서만 작동 (인터페이스 구현에는 적용 X)
자바 기본 애노테이션
@Override
메서드 재정의가 정확하게 잘 되었는지 컴파일러가 체크하는데 사용
@Deprecated
더 이상 사용되지 않는다는 뜻을 표현하며, 적용된 기능은 사용을 권장하지 않음
컴파일 시점에 경고를 나타내지만, 프로그램은 작동
옵션
since : 더 이상 사용하지 않게된 버전 정보
forRemoval : 미래 버전에 코드가 제거될 예정 (더더욱 강력한 경고)
예제
@Deprecated -> 진짜 쓰지마
@Deprecated(since = "2.4", forRemoval = true) -> 진짜진짜 쓰지마
@SuppressWarnings
자바 컴파일러가 문제를 경고하지만, 개발자가 문제를 잘 알고 있으니 경고하지 말라고 지시
왠만하면 사용 X (제네릭 쓰다보면 개발자가 책임지겠다고 쓸 때 정도 있음)
옵션
all: 모든 경고 억제
deprecation: deprecated 코드를 사용할 때 발생하는 경고 억제
unchecked: 제네릭 타입과 관련된 unchecked 경고 억제
serial: Serializable 인터페이스를 구현할 때 serialVersionUID 필드를 선언하지 않은 경우 발생하는 경고 억제
rawtypes: 제네릭 타입이 명시되지 않은(raw) 타입을 사용할 때 발생하는 경고 억제
unused: 사용되지 않는 변수, 메서드, 필드 등을 선언했을 때 발생하는 경고 억제
참고: 애노테이션 기반 검증기 활용 예제
public class Validator {
public static void validate(Object obj) throws Exception {
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
// @NotEmpty 어노테이션 검사
if (field.isAnnotationPresent(NotEmpty.class)) {
String value = (String) field.get(obj);
NotEmpty annotation = field.getAnnotation(NotEmpty.class);
if (value == null || value.isEmpty()) {
throw new RuntimeException(annotation.message());
}
}
// @Range 어노테이션 검사
if (field.isAnnotationPresent(Range.class)) {
long value = field.getLong(obj);
Range annotation = field.getAnnotation(Range.class);
if (value < annotation.min() || value > annotation.max()) {
throw new RuntimeException(annotation.message());
}
}
}
}
}
Reference
김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션
-
자바 리플렉션
리플렉션
클래스가 제공하는 다양한 정보(메타 데이터)를 런타임에 동적으로 분석하고 사용하는 기능
e.g. 스프링 프레임워크가 내가 만든 클래스를 대신 생성해주는 경우
메타데이터 종류
클래스
e.g. 클래스 이름, 접근 제어자, 부모 클래스, 구현한 인터페이스
필드
e.g. 필드 이름, 타입, 접근 제어자
런타임에 동적으로 해당 필드 값을 읽거나 수정 가능
메서드
e.g. 메서드 이름, 반환 타입, 매개변수 정보
런타임에 동적으로 메서드 조회 및 호출 가능
생성자
e.g. 매개변수 타입 및 개수
런타임에 동적으로 생성자 조회 및 객체 생성 가능
주의점
리플렉션 코드는 특별한 상황에서 사용
공통 문제를 해결하는 유틸리티, 프레임워크, 라이브러리 개발
테스트 등
일반적인 애플리케이션은 권장 X
무분별한 리플렉션 사용은 코드의 가독성과 안정성이 크게 저하
e.g.
private 직접 접근은 객체 지향 원칙을 위반 (캡슐화 및 유지보수성 저하)
클래스 내부 구조나 구현 세부사항이 변경되면 쉽게 깨지거나 버그를 초래
리플렉션은 문자를 활용하므로, 필드 및 메서드 이름 변경 시 컴파일러가 놓침
클래스 메타데이터
클래스의 메타데이터는 Class 클래스로 표현
Class 조회 방법
클래스에서 찾기
클래스명.class
e.g. Class<BasicData> basicDataClass1 = BasicData.class;
인스턴스에서 찾기
인스턴스.getClass()
e.g.
BasicData basicInstance = new BasicData();
Class<? extends BasicData> basicDataClass2 = basicInstance.getClass();
문자로 찾기
Class.forName(패키지명문자열)
e.g.
String className = "reflection.data.BasicData";
Class<?> basicDataClass3 = Class.forName(className);
기본 정보 탐색
클래스 이름
경로 포함 이름: basicData.getName() //reflection.data.BasicData
클래스 이름: basicData.getSimpleName() //BasicData
패키지
basicData.getPackage() //package reflection.data
부모 클래스
basicData.getSuperclass() //class java.lang.Object
구현한 인터페이스
basicData.getInterfaces() //[]
조건 판별
basicData.isInterface() //false
basicData.isEnum() //false
basicData.isAnnotation() //false
수정자 정보 (규칙있는 숫자로 리턴)
basicData.getModifiers() //1
참고: 수정자는 접근제어자와 비접근제어자(기타 수정자)로 분류
접근 제어자: public , protected , default ( package-private ), private
비 접근 제어자: static , final , abstract , synchronized , volatile 등
메서드 메타데이터
Method 클래스로 표현 (클래스 메타데이터를 통해 획득 가능)
메서드 메타데이터 조회
getMethod(메서드이름, 매개변수타입)
해당 클래스와 상위 클래스에서 상속된 모든 public 메서드 중 지정 메서드 조회
e.g.
String methodName = "hello";
Method method = helloClass.getMethod(methodName, String.class);
getDeclaredMethod(메서드이름, 매개변수타입)
해당 클래스에서 선언된 모든 메서드 중 지정 메서드 조회
e.g.
String methodName = "hello";
Method method = helloClass.getMethod(methodName, String.class);
getMethods()
해당 클래스와 상위 클래스에서 상속된 모든 public 메서드를 반환
e.g.
Class<BasicData> helloClass = BasicData.class;
Method[] methods = helloClass.getMethods();
getDeclaredMethods()
해당 클래스에서 선언된 모든 메서드를 반환
접근 제어자에 관계 X, 상속된 메서드 포함 X
e.g.
Class<BasicData> helloClass = BasicData.class;
Method[] declaredMethods = helloClass.getDeclaredMethods();
동적 메서드 호출
메서드 이름을 입력 받으면, 호출 대상 메서드를 동적으로 조회해 호출 가능
getMethod(), getDeclaredMethod()로 메서드 동적 조회
Method 객체의 invoke(인스턴스, 인자1, ...) 로 메서드 호출
e.g.
Class<? extends BasicData> helloClass = helloInstance.getClass();
String methodName = "hello";
Method method = helloClass.getDeclaredMethod(methodName, String.class);
Object returnValue = method.invoke(helloInstance, "hi");
필드 메타데이터
Field 클래스로 표현 (클래스 메타데이터를 통해 획득 가능)
필드 조회
getField(필드이름)
해당 클래스와 상위 클래스에서 상속된 모든 public 필드 중 지정 필드 조회
e.g.
Field nameField = aClass.getField("name");
getDeclaredField(필드이름)
해당 클래스에서 선언된 모든 필드 중 지정 필드 조회
e.g.
Field nameField = aClass.getDeclaredField("name");
getFields()
해당 클래스와 상위 클래스에서 상속된 모든 public 필드를 반환
e.g.
Class<BasicData> helloClass = BasicData.class;
Field[] fields = helloClass.getFields();
getDeclaredFields()
해당 클래스에서 선언된 모든 필드를 반환
접근 제어자에 관계 X, 상속된 필드 포함 X
e.g.
Class<BasicData> helloClass = BasicData.class;
Field[] declaredFields = helloClass.getDeclaredFields();
필드 값 변경
setAccessible(true)
private 필드에 직접 접근해 변경할 수 있는 기능
e.g. nameField.setAccessible(true)
참고: private 메서드, 생성자에서도 사용 가능 (Method, Constructor)
set(인스턴스, 변경값)
필드 값 변경 메서드
e.g. nameField.set(user, "userB")
생성자 메타데이터
Constructor 클래스로 표현 (클래스 메타데이터를 통해 획득 가능)
생성자 조회
getConstructor(매개변수타입)
해당 클래스와 상위 클래스에서 상속된 모든 public 생성자 중 지정 생성자 조회
e.g.
Constructor<?> constructor = aClass.getConstructor(String.class);
getDeclaredConstructor(매개변수타입)
해당 클래스에서 선언된 모든 생성자 중 지정 생성자 조회
e.g.
Constructor<?> constructor = aClass.getDeclaredConstructor(String.class);
getConstructors()
해당 클래스와 상위 클래스에서 상속된 모든 public 생성자를 반환
e.g.
helloClass.getConstructors();
getDeclaredConstructors()
해당 클래스에서 선언된 모든 생성자를 반환
접근 제어자에 관계 X, 상속된 생성자 포함 X
e.g.
helloClass.getDeclaredConstructors();
동적 인스턴스 생성
setAccessible(true)
private 생성자에 직접 접근해 호출할 수 있는 기능
e.g. constructor.setAccessible(true)
newInstance(인자)
생성자를 호출해 동적으로 객체 생성
e.g. Object instance = constructor.newInstance("hello")
Reference
김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션
-
자바 I/O & 네트워크
컴퓨터 데이터
개발하면서 다루는 데이터는 2가지
바이너리 데이터 (byte 기반 - e.g. 010101)
텍스트 데이터 (문자 기반 - e.g. “ABC”)
컴퓨터 메모리
컴퓨터 메모리는 반도체로 만들어짐 (e.g. RAM)
반도체: 트랜지스터의 모임 (수 많은 전구들이 모여 있는 것)
트랜지스터: 아주 작은 전자 스위치 (전구 하나)
전기가 흐르거나 흐르지 않는 두 가지 상태 가짐 -> 0 & 1 이진수 표현
메모리는 단순히 전구를 켜고 끄는 방식으로 작동 -> 컴퓨터는 전구의 상태만 변경 혹은 확인
컴퓨터는 전구들을 켜고 끄는 방식으로 데이터를 기록하고 읽음
현대 컴퓨터 메모리는 초당 수십억 번의 데이터 접근으로 매우 빠름
컴퓨터는 데이터 처리 시 2진수로 변환해 저장
10진수 숫자 -> 간단한 공식 -> 2진수
e.g. 10진수 100 -> 2진수 1100100
문자 -> 문자 집합(Character Set) -> 10진수 -> 간단한 공식 -> 2진수
e.g. “A” -> 65 -> 1000001
단위
1비트(bit): 2가지 상태 표현
1바이트(byte) = 8bit : 256가지 표현 (정보를 처리하는 기본 단위)
음수 표현시 앞의 1비트를 사용 (e.g. 자바의 숫자 타입들)
문자 집합 (Character Set)
사용 전략: 사실상 표준인 UTF-8을 사용하자
문제: 문자는 2진수로 나타낼 수 없음
해결책: 문자 집합 - 컴퓨터 과학자들이 문자에 숫자를 연결시키는 방법을 고안
문자 인코딩: 문자 -> 문자 집합(Character Set) -> 10진수 -> 간단한 공식 -> 2진수
문자 디코딩: 2진수 -> 간단한 공식 -> 10진수 -> 문자 집합(Character Set) -> 문자
문자 집합 종류와 역사
ASCII (American Standard Code for Information Interchange, 1960년도)
각 컴퓨터 회사 간 호환성 문제 해결을 위해 개발
7비트로 128가지 문자 표현
영문 알파벳, 숫자, 키보드 특수문자, 스페이스, 엔터
ISO_8859_1 (= LATIN1 = ISO-LATIN-1, 1980년도)
서유럽 문자를 표현하는 문자 집합
8비트(1byte)로 256가지 문자 표현
ASCII 128가지 + 서유럽 문자, 추가 특수 문자
기존 ASCII와 호환 가능
한글 문자 집합
특징
한글을 표현할 수 있는 문자 집합
16비트(2byte)로 65536가지 문자 표현
기존 ASCII와 호환 가능
ASCII는 1바이트, 한글은 2바이트로 메모리에 저장
한글은 글자가 많아서 1바이트로 표현 불가
EUC-KR (1980년도)
자주 사용하는 한글 표현
ASCII + 자주 사용하는 한글 2350개 + 한국에서 자주 사용하는 기타 글자
MS949 (1990년도)
마이크로소프트가 EUC-KR을 확장해, 한글 11,172자를 모두 표현
e.g. “쀍”, “삡” 등 모든 초성, 중성, 종성 조합 표현 가능
EUC-KR과 호환 가능하고 윈도우 시스템에서 계속 사용됨
전세계 문자 집합 (유니코드)
특징
전세계 문자를 대부분 표현할 수 있는 문자 집합
국제적 호환성을 위해 개발
특정 언어를 위한 문자 집합이 PC에 설치되지 않으면 글자가 깨짐
한 문서 안에 여러 나라 언어 저장 시에도 문제가 됨
UTF-16 (1990년도) - 초반에 인기
16비트(2byte) 기반
기본 다국어는 2byte로 표현 (영어, 유럽, 한국어, 중국어, 일본어)
그 외는 4byte로 표현 (고대문자, 이모지, 중국어 확장 한자)
큰 단점
ASCII 호환 불가
무조건 2바이트로 읽어서 ASCII 영문을 못 읽음 (ASCII 문서가 안열림)
영문의 경우 다른 문자 집합에 비해 2배 메모리 더 사용
웹 문서 80% 이상이 영문 문서라 비효율적
UTF-8 (1990년도)
현대의 사실상 표준 인코딩 기술
8비트(1byte) 기반, 가변 길이 인코딩
1byte: ASCII, 영문, 기본 라틴 문자
2byte: 그리스어, 히브리어 라틴 확장 문자
3byte: 한글, 한자, 일본어
4byte: 이모지, 고대문자등
단점: 일부 언어에서 더 많은 용량 사용
큰 장점
ASCII 호환
저장 공간 및 네트워크 효율성 (ASCII 문자를 1바이트로 사용)
한글이 깨지는 가장 큰 이유 2가지
EUC-KR(MS949)와 UTF-8이 서로 호환되지 않아서
윈도우에서 저장한 것을 UTF-8로 불러오거나 역인 경우
EUC-KR(MS949) 혹은 UTF-8로 인코딩한 한글을 ISO-8891-1로 디코딩할 때
개발 툴 같은 곳에서 ISO-8891-1로 설정되어 있으면, 한글을 저장 및 읽을 때 깨짐
코드 예시
Charset : 문자 집합 클래스
StandardCharsets : 자주 사용하는 문자 집합을 상수로 지정해둠
e.g. StandardCharsets.UTF_8, StandardCharsets.UTF_16BE
참고: UTF-16의 경우, UTF-16BE 사용하자
UTF-16BE & UTF-16LE는 바이트의 순서 차이
String.getBytes(Charset) : 지정한 문자 집합으로 문자 인코딩
참고: 자바의 바이트는 첫 비트로 음양을 표현
예를 들어, EUC-KR의 ‘가’를 2진수로 표현하면 -> (10110000, 10100001)
기본 십진 수 표현 : [176, 161]
자바 바이트로 십진수 표현 : [-80, -95]
즉, 십진수 표현만 다를 뿐 실제 메모리에 저장되는 값은 동일
문자 인코딩 및 디코딩 시 문자 집합이 생략된 경우, 시스템 기본 문자 집합 사용 (보통 UTF-8)
I/O (Input/Output)
데이터를 주고 받는 것
현대 컴퓨터는 대부분 byte 단위로 주고 받음 (bit 단위는 너무 작기 때문에)
자바 프로세스는 파일, 네트워크(소켓), 콘솔 등과 byte 단위로 데이터를 주고 받음
스트림(Stream)
데이터를 주고 받는 방식(I/O)을 추상화한 것
파일이든 소켓을 통한 네트워크든 일관된 방식으로 데이터를 주고 받을 수 있음
덕분에 기억할 메서드가 단순화
읽기: read(), readAllBytes()
쓰기: write()
자원해제: close()
분류
입출력
입력 스트림 : 외부 데이터를 자바 프로세스 내부로 가져옴
출력 스트림 : 자바 프로세스 내부 데이터를 외부로 보냄
독립성
기본 스트림
단독 사용 가능한 스트림
File, 메모리, 콘솔등에 직접 접근하는 스트림
e.g. FileInputStream, FileOutputStream, FileReader, FileWriter, ByteArrayInputStream, ByteArrayOutputStream
보조 스트림
단독 사용 불가능한 스트림 (대상 스트림 필수 필요)
기본 스트림에 보조 기능을 제공하는 스트림
e.g. BufferedInputStream, BufferedOutputStream, PrintStream, InputStreamReader, OutputStreamWriter, DataOutputStream, DataInputStream
스트림 유의할 개념
스트림의 모든 데이터는 byte 단위를 사용
문자 역시 byte로 변환이 필요
코드에서 바이트를 표현할 때 10진수로 사용하자
개발자는 코드에서 문자, 문자집합, 10진수까지만 다루면 됨
e.g. A를 바이트로 표현하고 싶으면 65로 쓰자 (2진수 1000001 사용 X)
참고: write()와 read()가 int를 입력 및 반환하는 이유
자바 byte는 부호 있는 8비트(-128~127)라 EOF(End of File) 표현이 어려움
int를 반환하면 0~255로 표현하고 -1을 EOF로 사용할 수 있음
ByteArrayStream은 거의 사용되지 않는다!
메모리에 데이터를 저장하고 읽을 때는 컬렉션이나 배열을 사용
버퍼(Buffer) : 데이터를 모아서 전달하거나 모아서 전달 받는 용도로 사용하는 것
e.g. byte[] buffer = new byte[BUFFER_SIZE];
버퍼의 크기는 보통 4KB or 8KB 정도 잡는 것이 효율적 (최근엔 16KB도 가끔 보임)
디스크나 파일 시스템의 데이터 기본 읽기 쓰기 단위가 보통 4KB, 8KB이기 때문
즉, 버퍼 크기가 더 커져도 속도가 계속 향상되지 않음
플러시(flush()) : 버퍼가 다 차지 않아도 버퍼에 남아있는 데이터를 전달하는 것
참고: BufferedStream close() 호출 시
내부에서 flush()를 먼저 호출한 후 연결된 스트림의 close() 호출
컴퓨터 간 데이터 교환 형식
사용 전략
JSON을 사용하자 (대부분 충분)
성능 최적화가 매우 중요하다면, Protobuf와 Avro 등을 고려하자
발전 과정
자바 객체 직렬화(Serialization) - 거의 사용하지 않음
메모리에 있는 객체 인스턴스를 바이트 스트림으로 변환해 파일에 저장하거나 네트워크로 전송할 수 있도록 하는 기능
역직렬화(Deserialization)을 통해 원래 객체로 복원 가능
직렬화하려는 클래스는 Serialization 인터페이스를 구현해야 함
장점: 편의성으로 인해 초기 분산 시스템에서 활용
단점: 장애날 확률 높음
호환성 문제 (버전 관리 어려움, 자바 플랫폼 종속성으로 타언어와 호환 불편)
성능 느림, 상대적으로 큰 용량…
XML
장점: 텍스트이므로 플랫폼 간 호환성 해결
단점: 복잡성, 무거움
JSON
가볍고 간결, 좋은 호환성
2000년대 후반, 웹 API와 RESTful 서비스가 대중화되며 사실상 표준이 됨
Protobuf, Avro
장점: 더 적은 용량, 더 빠른 성능 (Byte 기반으로 용량과 성능 최적화됨)
XML, JSON은 텍스트 기반이라 용량이 상대적으로 큼
숫자도 텍스트로 표현되어 바이트를 더 잡아 먹음
단점: 호환성이 떨어지고, byte 기반이라 사람이 직접 읽기 어려움
스트림 종류
Byte Stream (byte를 다루는 스트림)
특징
바이트로 스트림 입출력 지원
BufferdInputStream, BufferedOutputStream (보조 스트림)
내부에서 단순히 버퍼(byte[] buf) 기능 제공 - 대상 Stream이 필요
byte[] buf가 가득차면 대상 스트림의 write(byte[]) 호출 후 버퍼 비움
byte[] buf가 비어 있으면 버퍼 크기만큼 대상 스트림의 read(byte[]) 호출 후 버퍼에서 읽음
close() 호출 시, 내부에서 플러시하고 연결된 스트림의 close()까지 호출됨
장점: 단순한 코드 유지 가능
단점: 기본 read(), write()에 직접 버퍼 사용 보단 느림 (동기화 락 때문)
PrintStream (보조 스트림)
System.out의 실체, 데이터 출력 기능 제공
추가 기능인 println() 제공 (콘솔 출력)
콘솔에 출력하듯 파일이나 다른 스트림에 문자, 숫자, boolean 등 출력 가능
e.g. FileOutputStream과 조합하면 콘솔에 출력하듯 파일에 출력 가능
DataInputStream, DataOutputStream (보조 스트림)
자바 데이터 형을 편리하게 입출력 가능
e.g. String, int, double, boolean…
데이터 형에 따라 알맞은 메서드를 사용
e.g. writeUTF(), writeInt(), writeDouble(), writeBoolean()…
데이터를 정확하게 읽을 수 있는 이유
String의 경우 저장 시 2byte를 사용해 문자의 길이도 함께 저장해 둠
2byte -> 65535 길이까지만 가능
e.g. dos.writeUTF("id1");
-> 3id1(2byte(문자 길이) + 3byte(실제 문자 데이터))
-> dis.readUTF()가 글자 길이를 확인하고 해당 길이만큼 읽음
Int는 단순히 4byte를 사용하므로, 4byte로 저장하고 4byte로 읽음
e.g. dos.writeInt(20) -> dis.readInt()
e.g. FileOutputStream 조합 -> 파일에 자바 데이터 형을 편리하게 저장 가능
주의점: 저장한 순서대로 읽어야 함
writeUTF(), writeInt()였다면, readUTF(), readInt() 순으로
각 타입마다 그에 맞는 byte 단위로 저장되기 때문
e.g. 문자는 UTF-8 형식 저장, 자바 int는 4byte로 묶어 저장…
ObjectInputStream, ObjectOutputStream (보조 스트림, 거의 사용 X)
자바 객체 직렬화 및 역직렬화를 지원
자바 객체 직렬화는 버그를 많이 일으켜서, 거의 사용하지 않음
Character Stream (문자를 다루는 스트림)
특징
문자로 스트림 입출력 지원
내부에서 문자 <-> byte 인코딩 및 디코딩을 대신 처리
따라서, 문자 집합 전달 필수
InputStreamReader, OutputStreamWriter (보조 스트림)
InputStreamReader은 반환타입이 int -> char형으로 캐스팅해 사용
EOF인 -1 표현을 위해 int로 반환
FileReader, FileWriter
내부에서 스스로 FileOutputStream, FileInputStream을 생성해 사용
나머지는 InputStreamReader, OutputStreamWriter과 동일
BufferedReader, BufferedWriter (보조 스트림)
버퍼 보조 기능 제공 (Reader, Writer를 생성자에서 전달)
BufferedReader는 한 줄 단위로 문자 읽는 기능도 추가 제공 (readLine())
한 줄 단위로 문자를 읽고 String 반환, EOF에 도달하면 null 반환
코드 예시
FileStream 예시 (메모리, 콘솔도 유사하게 사용)
출력
생성: FileOutputStream fos = new FileOutputStream("temp/hello.dat");
1바이트 쓰기: fos.write(65);
여러 바이트 한 번에 쓰기: fos.write({65, 66, 67});
입력
생성: FileInputStream fis = new FileInputStream("temp/hello.dat");
1바이트 읽기: fis.read();
여러 바이트 한 번에 읽기 (버퍼 읽기)
byte[] buffer = new byte[10];
int readCount = fis.read(buffer, 0, 10);
모든 바이트 한 번에 읽기
byte[] readBytes = fis.readAllBytes();
파일 및 버퍼 사이즈 설정 예시
public static final int FILE_SIZE = 10 * 1024 * 1024; // 10MB
public static final int BUFFER_SIZE = 8192; // 8KB
Buffered 스트림 사용 예시 (보조 스트림들은 이와 비슷)
출력
FileOutputStream fos = new FileOutputStream(FILE_NAME);
BufferedOutputStream bos = new BufferedOutputStream(fos, BUFFER_SIZE);
for (int i = 0; i < FILE_SIZE; i++) {
bos.write(1);
}
입력
FileInputStream fis = new FileInputStream(FILE_NAME);
BufferedInputStream bis = new BufferedInputStream(fis, BUFFER_SIZE);
while ((data = bis.read()) != -1) {
fileSize++;
}
BufferedReader, BufferedWriter 사용 예시
// 파일에 쓰기
FileWriter fw = new FileWriter(FILE_NAME, UTF_8);
BufferedWriter bw = new BufferedWriter(fw, BUFFER_SIZE);
bw.write(writeString);
bw.close();
// 파일에서 읽기
StringBuilder content = new StringBuilder();
FileReader fr = new FileReader(FILE_NAME, UTF_8);
BufferedReader br = new BufferedReader(fr, BUFFER_SIZE);
String line;
while ((line = br.readLine()) != null) {
content.append(line).append("\n");
}
br.close();
PrintStream 사용 예시
FileOutputStream fos = new FileOutputStream("temp/print.txt");
PrintStream printStream = new PrintStream(fos);
printStream.println("hello java!");
printStream.println(10);
printStream.println(true);
printStream.close();
DataInputStream, DataOutputStream 사용 예시
FileOutputStream fos = new FileOutputStream("temp/data.dat");
DataOutputStream dos = new DataOutputStream(fos);
dos.writeUTF("회원A");
dos.writeInt(20);
dos.writeDouble(10.5);
dos.writeBoolean(true);
dos.close();
FileInputStream fis = new FileInputStream("temp/data.dat");
DataInputStream dis = new DataInputStream(fis);
System.out.println(dis.readUTF());
System.out.println(dis.readInt());
System.out.println(dis.readDouble());
System.out.println(dis.readBoolean());
dis.close();
FileInputStream, FileOutputStream은 디렉토리 지정시 해당 디렉토리를 미리 생성해두자. 그렇지 않으면 FileNotFoundException이 발생한다.
스트림 입출력 성능 최적화
핵심 전략
적당한 크기 파일이라면, 한 번에 처리하자 (수십 MB 정도가 안전 범위)
대용량 파일이라면, 버퍼로 처리하자
일반적인 상황에서는 Buffered 스트림으로 처리
성능이 중요하다면 버퍼를 직접 다루자 (read(byte[]), write(byte[]))
버퍼의 이점
버퍼를 사용 -> OS 시스템 콜 & HDD, SSD 읽기 쓰기 작업 횟수 감소 -> 큰 속도 향상
write(), read()는 호출할 때마다 OS 시스템 콜을 통해 입출력 명령을 전달
OS 시스템 콜과 디스크 읽기/쓰기 -> 무거운 작업
하나씩 입출력 VS 버퍼 입출력 VS 한 번에 전체 입출력
하나씩 입출력
e.g. 1Byte씩 10MB 파일(약 1000만번 호출) -> 쓰기: 약 14초 / 읽기: 약 5초
자바 최적화로 인해 실제로는 배치로 나가서 그나마 이정도
버퍼 입출력 -> 대용량 파일 처리에 유리
e.g. 8192Byte(8KB)씩 10MB 파일 -> 쓰기: 약 14ms / 읽기: 5ms
속도 1000배 향상
편리하게 BuffedStream 사용도 가능 -> 쓰기: 약 102ms / 읽기: 약 94ms
쓰기 속도 140배, 읽기 속도 50배 향상
-> 버퍼 직접 사용보단 느림 (동기화 락 때문)
한 번에 전체 입출력 -> 작은 파일 처리에 유리
e.g. -> 쓰기: 약 15ms / 읽기: 약 3ms
버퍼 입출력 예제와 성능이 비슷
한 번에 쓴다고 무작정 빨라지지 않음
디스크나 파일 시스템의 데이터 읽기 쓰기 단위가 보통 4KB, 8KB이기 때문
부분 읽기 VS 전체 읽기 (둘 다 필요)
부분 읽기(버퍼 읽기)
메모리 사용량 제어 가능 -> 대용량 파일 처리에 유리
e.g. read(byte[], offset, lentgh)
전체 읽기
한 번의 호출로 모든 데이터를 읽을 수 있어 편리 -> 작은 파일 처리에 유리
한 번에 많은 메모리 사용으로 OutOfMemoryError 발생을 조심해야 함
e.g. readAllBytes()
File, Files
자바에서 파일, 디렉토리를 다룰 때 사용
핵심 전략
Files + Path를 사용하자
성능도 좋고 사용도 편리
File 뿐만아니라 파일 관련 스트림 사용도 Files부터 찾아보고 결정할 것
기본 사용법
예전 방식: File (자바 1.0, 레거시에 많음)
public class OldFileMain {
public static void main(String[] args) throws IOException {
File file = new File("temp/example.txt");
File directory = new File("temp/exampleDir");
// 1. exists(): 파일이나 디렉토리의 존재 여부를 확인
System.out.println("File exists: " + file.exists());
// 2. createNewFile(): 새 파일을 생성
boolean created = file.createNewFile();
System.out.println("File created: " + created);
// 3. mkdir(): 새 디렉토리를 생성
boolean dirCreated = directory.mkdir();
System.out.println("Directory created: " + dirCreated);
// 4. delete(): 파일이나 디렉토리를 삭제
//boolean deleted = file.delete();
//System.out.println("File deleted: " + deleted);
// 5. isFile(): 파일인지 확인
System.out.println("Is file: " + file.isFile());
// 6. isDirectory(): 디렉토리인지 확인
System.out.println("Is directory: " + directory.isDirectory());
// 7. getName(): 파일이나 디렉토리의 이름을 반환
System.out.println("File name: " + file.getName());
// 8. length(): 파일의 크기를 바이트 단위로 반환
System.out.println("File size: " + file.length() + " bytes");
// 9. renameTo(File dest): 파일의 이름을 변경하거나 이동
File newFile = new File("temp/newExample.txt");
boolean renamed = file.renameTo(newFile);
System.out.println("File renamed: " + renamed);
// 10. lastModified(): 마지막으로 수정된 시간을 반환
long lastModified = newFile.lastModified();
System.out.println("Last modified: " + new Date(lastModified));
}
}
대체 방식: Files + Path (자바 1.7)
public class NewFilesMain {
public static void main(String[] args) throws IOException {
Path file = Path.of("temp/example.txt");
Path directory = Path.of("temp/exampleDir");
// 1. exists(): 파일이나 디렉토리의 존재 여부 확인
System.out.println("File exists: " + Files.exists(file));
// 2. createFile(): 새 파일 생성
try {
Files.createFile(file);
System.out.println("File created");
} catch (FileAlreadyExistsException e) {
System.out.println(file + " File already exists");
}
// 3. createDirectory(): 새 디렉토리 생성
try {
Files.createDirectory(directory);
System.out.println("Directory created");
} catch (FileAlreadyExistsException e) {
System.out.println(directory + " Directory already exists");
}
// 4. delete(): 파일이나 디렉토리 삭제 (주석 해제 시 실행됨)
// Files.delete(file);
// System.out.println("File deleted");
// 5. isRegularFile(): 일반 파일인지 확인
System.out.println("Is regular file: " + Files.isRegularFile(file));
// 6. isDirectory(): 디렉토리인지 확인
System.out.println("Is directory: " + Files.isDirectory(directory));
// 7. getFileName(): 파일이나 디렉토리의 이름 반환
System.out.println("File name: " + file.getFileName());
// 8. size(): 파일의 크기를 바이트 단위로 반환
System.out.println("File size: " + Files.size(file) + " bytes");
// 9. move(): 파일 이름 변경 또는 이동
Path newFile = Paths.get("temp/newExample.txt"); // Path.of(...)가 더 좋은 방식
Files.move(file, newFile, StandardCopyOption.REPLACE_EXISTING);
System.out.println("File moved/renamed");
// 10. getLastModifiedTime(): 마지막 수정 시간 반환
System.out.println("Last modified: " + Files.getLastModifiedTime(newFile));
// 추가: readAttributes(): 파일의 기본 속성 읽기
BasicFileAttributes attrs = Files.readAttributes(newFile, BasicFileAttributes.class);
System.out.println("===== Attributes =====");
System.out.println("Creation time: " + attrs.creationTime());
System.out.println("Is directory: " + attrs.isDirectory());
System.out.println("Is regular file: " + attrs.isRegularFile());
System.out.println("Is symbolic link: " + attrs.isSymbolicLink());
System.out.println("Size: " + attrs.size());
}
}
파일이나 디렉토리 경로는 Path 활용
static 메서드를 활용해 기능 제공
경로 표시 방법
절대 경로(Absolute path)
PC 내 루트 디렉토리부터 시작하는 전체 경로
e.g. 정규 경로와 대비해 둘 다 가능
/Users/yh/study/inflearn/java/java-adv2
/Users/yh/study/inflearn/java/java-adv2/temp/..
정규 경로(Canonical path)
절대 경로 + 경로 계산이 완료된 것
e.g. 단 하나만 존재
/Users/yh/study/inflearn/java/java-adv2
상대 경로(Relative path)
현재 작업 디렉토리를 기준으로 하는 경로
e.g. 경로 앞에 아무것도 없을 때는 현재 자바 프로젝트 디렉토리부터 시작
java/java-adv2
File에서 사용하기
File file = new File("temp/..");
상대 경로: file.getPath()
절대 경로: file.getAbsolutePath()
정규 경로: file.getCanonicalPath()
현재 경로에 있는 모든 파일 및 디렉토리 반환: file.listFiles()
Files에서 사용하기
Path path = Path.of("temp/..");
상대 경로: path
절대 경로: path.toAbsolutePath()
정규 경로: path.toRealPath()
현재 경로에 있는 모든 파일 및 디렉토리 반환: Files.list(path)
문자 파일 읽기 (Files)
FileReader, FileWriter 스트림 클래스의 기능을 단순한 코드로 대체 가능
메서드
Files.writeString()
파일에 쓰기
e.g. Files.writeString("temp/hello.txt", "abc", UTF_8);
Files.readString()
파일에서 모든 문자 읽기
e.g. Files.readString("temp/hello.txt", UTF_8);
Files.readAllLines(path)
파일을 한 번에 다 읽고, 라인 단위로 List 에 나누어 저장하고 반환
e.g. Files.readAllLines("temp/hello.txt", UTF_8);
Files.lines(path)
파일을 한 줄 단위로 나누어 읽음 (메모리 사용량 최적화 가능)
e.g.
1000MB 파일이라면, 1MB 한 줄 불러와 처리하고 다음 줄 호출 후 기존 1MB 데이터를 GC
try(Stream<String> lineStream = Files.lines(path, UTF_8)){
lineStream.forEach(line -> System.out.println(line));
}
파일 복사 최적화 (Files.copy())
Path source = Path.of("temp/copy.dat");
Path target = Path.of("temp/copy_new.dat");
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
자바에 파일 데이터를 불러오지 않고, 운영체제의 파일 복사 기능 사용해 가장 빠름
파일 스트림 사용: 파일(copy.dat) -> 자바(byte) -> 파일(copy_new.dat)
Files.copy(): 파일(copy.dat) -> 파일(copy_new.dat) - 한 단계 생략
네트워크 프로그래밍 - 소켓 (Socket)
조각 개념
localhost
현재 사용 중인 컴퓨터 자체를 가리키는 특별한 호스트 이름
루프백 주소라 지칭하는 127.0.0.1 이라는 IP로 매핑됨
127.0.0.1은 컴퓨터가 네트워크 패킷을 네트워크 인터페이스를 통해 외부로 나가지 않고, 자신에게 직접 보낼 수 있도록 함
DNS 탐색 과정
TCP/IP 통신에서는 통신 대상 서버를 찾을 때, 호스트 이름이 아니라 IP 주소가 필요
호스트 이름이 주어졌을 경우, IP 주소를 자동으로 찾음
과정 (InetAddress)
자바는 InetAddress.getByName("호스트명") 메서드 사용
이 과정에서 시스템의 호스트 파일을 먼저 확인
/etc/hosts (리눅스, mac)
C:\Windows\System32\drivers\etc\hosts (윈도우,Windows)
호스트 파일에 정의되어 있지 않다면, DNS 서버에 요청해서 IP 주소를 얻음
호스트 파일 예시
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost
Socket 클래스
클라이언트와 서버의 연결에 사용하는 클래스 (TCP 연결, 소켓 객체로 서버와 통신)
Socket socket = new Socket("localhost", PORT)
InetAddress로 IP 찾기
해당 IP와 포트로 TCP 연결 시도 (성공하면 Socket 객체 반환)
클라이언트와 서버 간의 데이터 통신은 Socket이 제공하는 스트림 사용
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
서버는 서버 소켓(ServerSocket)을 사용해 포트를 열어두어야 함 (TCP 연결)
ServerSocket serverSocket = new ServerSocket(PORT);
TCP 연결만 지원하는 특별한 소켓
포트를 지정해 서버 소켓을 생성하면, 클라이언트가 포트를 지정해 접속 가능
Socket socket = serverSocket.accept();
실제 데이터를 주고 받기 위한 Socket 객체 반환
클라이언트의 TCP 연결이 있으면 반환
없으면 연결 정보가 도착할 때까지 대기 (블로킹)
서버는 소켓(Socket) 객체 없이 서버 소켓(ServerSocket)만으로도 TCP 연결이 완료됨
연결 이후에 메시지를 주고 받으려면 Socket 객체 필요
참고: 연결 정보가 있는데 accept() 호출이 없어 서버에는 Socket 객체가 없을 때
클라이언트가 데이터를 보내면 OS TCP 수신 버퍼에서 대기
클라이언트와 서버의 연결 과정
서버가 12345 포트로 서버 소켓을 열어둠 (클라이언트는 이제 12345 포트로 서버 접속 가능)
클라이언트가 12345 포트에 연결 시도
클라이언트 자신의 포트는 보통 생략하고, 이 경우 남아있는 포트 중에 랜덤 할당됨
OS 계층에서 TCP 3 way handshake 발생하고 TCP 연결 완료
서버는 OS backlog queue에 TCP 연결 정보 보관 (자바가 아닌 OS 수준)
연결 정보에는 클라이언트의 IP 및 PORT, 서버의 IP 및 PORT가 모두 있음
서버가 serverSocket.accept()를 호출하면, backlog queue에서 TCP 연결 정보 조회
만약 연결 정보가 없다면, 연결 정보가 생성될 때까지 대기 (블로킹)
해당 정보를 기반으로 Socket 객체 생성
사용한 TCP 연결 정보는 backlog queue에서 제거
여러 클라이언트 접속을 위한 멀티스레드 (Session)
서버 및 네트워크의 기본 베이스이자 거의 다라고 봐도 무방
핵심: 2개의 블로킹의 작업을 해결하기 위해 별도의 스레드를 사용하자 (역할의 분리)
main 스레드
작업: accept() (클라이언트와 서버의 연결을 위해 대기)
새로운 연결이 있을 때마다 Session 객체와 별도 스레드 생성 및 실행하는 역할
Session 담당 스레드
작업: readXxx() (클라이언트의 메시지를 받아 처리하기 위해 대기)
자신의 소켓이 연결된 클라이언트와 메시지를 반복해서 주고 받는 역할
한 세션이 하나의 클라이언트 담당
과정
main 스레드는 서버 소켓을 생성하고, 서버 소켓의 accept()를 반복 호출
클라이언트가 서버에 접속하면, accept()가 Socket을 반환
main 스레드는 이 정보를 기반으로 Runnable을 구현한 Session 객체를 만들고, 새 스레드에서 Session 객체를 실행
Session 객체와 Thread-0는 연결된 클라이언트와 메시지를 주고 받음
네트워크 프로그래밍 - 자원 정리
자원 정리 예외 처리 기본
자원 정리 시 try~catch~finally 구문의 문제
2가지 핵심 문제
close() 시점에 실수로 예외를 던지면, 이후 다른 자원을 닫을 수 없는 문제 발생
finally 블럭 안에서 자원을 닫을 때 예외가 발생하면, 핵심 예외가 finally 에서 발생한 부가 예외로 바뀌어 버림 (핵심 예외가 사라짐)
해결책 1: try~catch~finally + finally 내 자원 정리 코드 try~catch
2가지 핵심 문제 해결
자원 정리에서 발생한 예외는 로그만 남기고 넘어감
4가지 부가 문제 잔존
resource 변수를 선언하면서 동시에 할당할 수 없음( try , finally 코드 블록과 변수 스코프가 다른 문제)
catch 이후에 finally 호출, 자원 정리가 조금 늦어짐
개발자가 실수로 close() 를 호출하지 않을 가능성
개발자의 close() 호출순서 실수 (보통 자원을 생성한 순서와 반대로 닫아야 함)
해결책 2: Try with resources
2가지 핵심 문제 + 4가지 부가 문제 모두 해결
리소스 누수 방지: 모든 리소스가 제대로 닫히도록 보장
finally 블록 누락이나 finally 내 자원 해제 코드 누락 문제 예방
코드 간결성 및 가독성 향상 명시적인 close() 호출이 필요 없음
스코프 범위 한정: 코드 유지보수 향상
리소스 변수의 스코프가 try 블럭으로 한정
조금 더 빠른 자원 해제: try 블럭이 끝나면 즉시 close() 를 호출
기존에는 try~catch~finally에서 catch 이후에 자원을 반납
자원 정리 순서: 먼저 선언한 자원을 나중에 정리
핵심 예외 반환 및 부가 예외 포함:
try-with-resources 는 핵심 예외를 반환
부가 예외는 핵심 예외안에 Suppressed 로 담아서 반환
개발자는 자원 정리 중 발생한 부가 예외를 e.getSuppressed() 로 활용
e.addSuppressed(ex) : 예외 안에 참고할 예외를 담음
네트워크 클라이언트와 서버에서의 자원 정리
문제: 클라이언트 프로세스 종료 시, OS 단에서 TCP 연결 종료 및 정리 발생
TCP 연결 종료로 인해 서버도 예외가 발생하는데, 이 때 자원 정리 없이 종료되면 문제
서버는 프로세스가 계속 살아 실행되어야 하므로, 외부 자원은 즉각 정리되어야 함
클라이언트는 종료 후 다시 실행해도 되고, 컴퓨터를 자주 재부팅해도 돼서 괜찮음
해결 전략
자원의 사용과 해제를 함께 묶어 처리하는 경우 -> Try with resources
Try with resources 적용이 어려운 경우 (자원 해제가 여러 곳에서 진행되는 경우)
-> try~catch~finally + finally 내 자원 정리 코드 try~catch
e.g. 세션 자원 정리는 클라이언트 종료 시점, 서버 종료 시점 모두 이뤄져야 함
서버의 안정적인 종료 처리 (feat. 셧다운 훅)
서버는 종료할 때 사용하는 세션들도 함께 종료해야 함
필요 작업
모든 세션이 사용하는 자원 닫기 (Socket, InputStream, OutputStream)
서버 소켓(ServerSocket) 닫기
셧다운 훅(Shutdown Hook)
자바는 프로세스 종료 시, 자원 정리나 로그 기록 같은 종료 작업을 마무리하는 기능 제공
shutdown 스레드가 개발자가 만든 shutdownHook 실행
e.g. 서버 종료 시, shutdown 스레드가 모든 세션의 자원을 닫고 서버 소켓 닫음
정상 종료는 셧다운 훅 작동 but, 강제 종료는 셧다운 훅 작동 X
정상 종료
모든 non 데몬 스레드의 실행 완료로 자바 프로세스 정상 종료
사용자가 Ctrl+C를 눌러서 프로그램을 중단
kill 명령 전달 (kill -9 제외)
IntelliJ의 stop 버튼
강제 종료
운영체제에서 프로세스를 더 이상 유지할 수 없다고 판단할 때 사용
리눅스/유닉스의 kill -9 나 Windows의 taskkill /F
서버 적용 과정
세션에 자원 정리 기능 추가
클라이언트 연결 종료 및 서버 종료 2곳에서 사용 예정
예외처리: try~catch~finally + finally 내 자원 정리 코드 try~catch
동시성 처리를 적용한 세션 매니저 개발 (SessionManager)
세션 매니저: 생성한 세션을 보관하고 관리하는 객체
동시성 처리 이유: 2곳에서 호출될 수 있음
클라이언트와 연결이 종료됐을 때
서버를 종료할 때
ShutdownHook 클래스를 Runnable을 구현해 개발
주요 코드
sessionManager.closeAll();
serverSocket.close();
자바 종료시 호출되는 셧다운 훅을 등록
ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));
타임아웃(Timeout)
핵심: 외부 서버와 통신하는 경우, 반드시 연결 타임아웃과 소켓 타임아웃을 지정하자
타임아웃
서버에서 응답이 없을 때 제한 시간을 설정하는 것 (타임아웃 시간이 지나면 예외 발생)
종류
TCP 연결 타임아웃
네트워크 연결(TCP 연결) 시도 시, 서버에서 응답이 없을 때 제한 시간을 설정
연결이 안되면 고객에게 빠르게 현재 연결에 문제가 있다고 알려주는 것이 더 나은 방법
설정 방법
기본 설정: OS 연결 대기 타임아웃 (서비스 관점에서 너무 김)
Windows: 약 21초
Linux: 약 75초에서 180초 사이
예외: java.net.ConnectException: Operation timed out
직접 설정
Socket socket = new Socket();
Socket 객체는 생성 시 IP, PORT를 전달하면 생성자에서 TCP 연결
IP, PORT를 빼고 생성하면, 추가 설정을 한 다음 TCP 연결 시도 가능
socket.connect(new InetSocketAddress("192.168.1.250", 45678), 1000);
타임아웃 설정 후 TCP 연결 시도
예외: java.net.SocketTimeoutException: Connect timed out
Read 타임아웃 (소켓 타임아웃)
연결(TCP 연결)이 잘 된 이후, 클라이언트 요청에 서버 응답이 없을 때 제한 시간 설정
서버에 사용자가 폭주해 느려지는 상황 등
설정 방법
Socket socket = new Socket("localhost", 12345);
socket.setSoTimeout(3000);
예외: java.net.SocketTimeoutException: Read timed out
TCP 연결 종료
핵심: 기본적으로 정상 종료, 강제 종료 모두 자원 정리하고 닫도록 설계
IOException 발생 시 자원을 정리 (네트워크 예외가 많아서 부모 예외로 한 번에 처리)
-1, null, EOFException, SocketException 등을 한 번에 처리
정상 종료
TCP 연결 종료 규칙: 서로 FIN 메시지를 보내야 함 (4-way-handshake)
socket.close() 호출 시, FIN 패킷을 상대방에게 전달
FIN 패킷을 받은 상대도 항상 socket.close()를 호출해야 함 (지켜야하는 규칙)
흐름
서버가 클라이언트에게 FIN 패킷 보냄 (socket.close())
패킷을 받으면 클라이언트의 OS에서 FIN에 대한 ACK 패킷 전달 (자동)
클라이언트도 서버에게 FIN 패킷 보냄 (socket.close())
패킷을 받으면 서버의 OS에서 FIN에 대한 ACK 패킷 전달 (자동)
강제 종료
TCP 연결 중에 문제가 발생하면 RST 패킷이 발생
처음 연결이 거부 당할 때
연결 후 통신 중에 상대가 연결을 끊었을 때
방화벽 같은 곳에서 연결을 강제로 종료할 때
…
RST(Reset) 패킷
TCP 연결에 문제가 있다는 뜻
연결 상태를 초기화(리셋)해서 더 이상 현재의 연결을 유지하지 않겠다는 의미
“현재의 세션을 강제로 종료하고, 연결을 무효화하라”
이 패킷을 받은 대상은 바로 연결을 해제해야 함
흐름
서버가 클라이언트에게 FIN 패킷 보냄 (socket.close())
패킷을 받으면 클라이언트의 OS에서 FIN에 대한 ACK 패킷 전달 (자동)
클라이언트가 종료하지 않고, output.write(1) 를 통해 서버에 메시지를 전달
데이터를 전송하는 PUSH 패킷을 서버에 전달
서버는 기대값인 FIN 패킷이 오지 않아, RST 패킷 전송 (TCP 연결에 문제가 있다 판단)
RST 패킷을 받은 클라이언트가 다음 행동을 하면 예외 발생
클라이언트가 read() 시, java.net.SocketException: Connection reset 발생
클라이언트가 write() 시, java.net.SocketException: Broken pipe 발생
주요 네트워크 예외 정리
RST 패킷 예외
java.net.ConnectException: Connection refused
클라이언트가 해당 IP의 서버에 접속은 했으나 연결이 거절됨
서버는 OS 단에서 RST 패킷을 보냄
클라이언트는 연결 시도 중 RST 패킷을 받고 해당 예외를 발생시킴
다음 경우들에서 발생
해당 IP의 서버는 켜져 있지만, 포트가 없을 때 주로 발생
네트워크 방화벽 등에서 무단 연결로 인지하고 연결을 막을 때
java.net.SocketException: Connection reset
RST 패킷을 받은 클라이언트가 연결을 바로 종료하지 않고 read() 시 발생
java.net.SocketException: Broken pipe
RST 패킷을 받은 클라이언트가 연결을 바로 종료하지 않고 write() 시 발생
java.net.SocketException: Socket is closed
자기 자신의 소켓을 닫은 이후에 read(), write()를 호출할 때 발생
연결 타임아웃 예외: 네트워크 연결을 하기 위해 서버 IP에 연결 패킷을 전달했지만 응답이 없는 경우
java.net.ConnectException: Operation timed out
OS 기본 설정에 의한 예외
java.net.SocketTimeoutException: Connect timed out
직접 설정 시 발생하는 예외
다음 경우들에서 발생
IP를 사용하는 서버가 없어서 응답이 없는 경우
해당 서버가 너무 바쁘거나 문제가 있어서 연결 응답 패킷을 보내지 못하는 경우
Read 타임아웃 예외
java.net.SocketTimeoutException: Read timed out
연결이 된 이후, 클라이언트 요청에 서버 응답이 없는 경우
서버에 사용자가 폭주해 느려지는 상황 등
java.net.BindException: Address already in use
지정한 포트를 다른 프로세스가 이미 사용하고 있을 때 발생
해당 프로세스를 종료하면 해결
java.net.UnknownHostException
호스트를 알 수 없음 (존재하지 않는 IP, 도메인 이름)
e.g. Socket socket = new Socket("999.999.999.999", 80);
e.g. Socket socket = new Socket("google.gogo", 80);
커맨드 패턴 & Null Object 패턴
public class CommandManagerImpl implements CommandManager {
public static final String DELIMITER = "\\|";
private final Map<String, Command> commands;
private final Command defaultCommand = new DefaultCommand();
public CommandManagerV4(SessionManager sessionManager) {
commands = new HashMap<>();
commands.put("/join", new JoinCommand(sessionManager));
commands.put("/message", new MessageCommand(sessionManager));
commands.put("/change", new ChangeCommand(sessionManager));
commands.put("/users", new UsersCommand(sessionManager));
commands.put("/exit", new ExitCommand());
}
@Override
public void execute(String totalMessage, Session session) throws
IOException {
String[] args = totalMessage.split(DELIMITER);
String key = args[0];
// NullObject Pattern
Command command = commands.getOrDefault(key, defaultCommand);
command.execute(args, session);
}
}
불필요한 조건문이 많다면 유용한 디자인 패턴
적용 전략
기능이 어느정도 있는데 향후 확장까지 고려해야 한다면 커맨드 패턴을 도입하자
단순한 if 문 몇 개로 해결된다면, 도입 X (굳이 복잡성을 높이지 말자)
Command Pattern
요청을 독립적인 객체로 변환해서 처리하는 방법
장점
분리: 작업을 호출하는 객체와 작업을 수행하는 객체가 분리되어 있어 명확
확장성: 기존 코드 변경 없이 새로운 명령 추가 가능
단점
복잡성 증가: 간단한 작업이어도 여러 클래스를 생성해야 함
Null Object Pattern
null인 상황을 객체(Object)로 만들어 처리하는 방법 (객체의 기본 동작을 정의)
null 체크를 없애 코드의 간결성을 높임
캐리지 리턴(\r) & 라인 피드(\n)
캐리지 리턴은 옛 타자기의 동작을 표현한 것이다. (커서를 맨 앞으로)
윈도우는 엔터를 표현할 때, 캐리지 리턴 + 라인 피드(\r\n) 로 채택했다.
맥, 리눅스는 엔터를 표현할 때, 라인 피드(\n) 만으로 표현했다.
HTTP 공식 스펙에서는 다음 라인을 \r\n로 표현하나 \n만 사용해도 대부분의 웹 브라우저는 문제없이 작동한다.
HTTP 서버
HTTP 서버와 서비스 개발을 위한 로직은 명확하게 분리 가능
HTTP 서버는 재사용 가능
개발자는 새로운 HTTP 서비스에 필요한 서블릿만 구현
WAS (Web Application Server)
웹(HTTP)를 기반으로 작동하면서 프로그램의 코드도 실행할 수 있는 서버
웹 서버 역할 + 애플리케이션 프로그램 코드 수행
웹 서버 역할 = 복잡한 네트워크, 멀티스레드, HTTP 메시지 파싱 등을 모두 해결
프로그램 코드 = 서블릿 구현체들
자바 진영에서는 보통 서블릿 기능을 포함하는 서버를 의미
서블릿 (Servlet, 1990년대)
public interface Servlet {
void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException;
...
}
HTTP 서버에서 실행되는 작은 자바 프로그램 (Server + Applet)
WAS 개발에 대한 자바 진영의 표준
많은 회사가 WAS를 개발하는데, 각각의 서버 간 호환성이 전혀 없어서 등장
A사 HTTP 서버를 사용하다 느려서 B사로 바꾸려면, 인터페이스가 달라 수정이 많음
HTTP 서버를 만드는 회사들은 모두 서블릿을 기반으로 기능 제공
Apache Tomcat, Jetty, Undertow, IBM WebSphere…
장점
표준화 덕에 개발자는 jakarta.servlet.Servlet 인터페이스만 구현하면 됨
WAS를 변경해도 구현했던 서블릿을 그대로 사용 가능
참고: URL 인코딩
HTTP 메시지 시작 라인과 헤더의 이름은 항상 ASCII를 사용해야 한다
초기 인터넷 설계 시기에는 ASCII를 사용했음
HTTP 스펙은 보수적으로 호환성을 가장 중요시함 (많은 레거시 시스템과의 호환)
URL에 ASCII로 표현할 수 없는 문자가 있다면, 퍼센트 인코딩해 ASCII로 표현
퍼센트(%)인코딩
UTF-8 16진수로 표현한 각각의 바이트 문자 앞에 %(퍼센트)를 붙이는 인코딩
e.g. ‘가’ -> UTF-8 16 진수로 표현
-> [EA, B0, 80] (3byte) -> 퍼센트 삽입
-> %EA%B0%80
서블릿에서 URL 파싱할 때도 적용됨
String encode = URLEncoder.encode("가", UTF_8) //%EA%B0%80
String decode = URLDecoder.decode(encode, UTF_8) //가
데이터 크기로는 비효율적이지만 URL, 헤더 정도는 호환성을 위해 감당 가능
큰 용량은 메시지 바디에서 UTF-8로 처리 가능
웹 애플리케이션 서버 제작 과정
멀티스레드 적용
main 스레드는 소켓 연결만 담당
클라이언트와 요청 처리 작업은 ExecutorService 스레드 풀에 전달
HttpRequest, HttpResponse 객체 적용
HTTP 메시지 파싱 및 생성 역할을 담당
퍼센트 인코딩도 처리
커맨드 패턴 서블릿
if문으로 URL을 처리하고 스태틱 메서드로 서비스 로직을 처리하던 것을 리팩토링
URL : 서블릿 구현체 쌍으로 Map<String, HttpServlet> servletMap 관리
HTTP 서버와 서비스 개발을 위한 로직이 명확하게 분리
분리 예시
HTTP 서버와 관련된 부분
HttpServer, HttpRequestHandler, HttpRequest, HttpResponse
HttpServlet, HttpServletManager
공용 서블릿
InternalErrorServlet, NotFoundServlet, DiscardServlet
서비스 개발을 위한 로직
HomeServlet
Site1Servlet, Site2Servlet, SearchServlet
HTTP 서버는 재사용 가능
서블릿에는 요청을 처리하는 서비스 로직만 구현
Request, Response 객체는 HTTP 메시지 파싱 및 생성 담당하고 서블릿에게 전달
문제점
기능마다 서블릿 클래스가 너무 많아짐
새로 만든 클래스를 URL 경로와 항상 매핑해야 하는 불편함
메타 프로그래밍(리플렉션, 애노테이션)을 통한 극대화 - 보일러플레이트 코드 크게 감소
리플렉션 서블릿
서비스 로직은 새로운 컨트롤러 클래스들에 메서드 단위로 위치하도록 리팩토링
URL과 메서드 이름을 동일하게 함
리플렉션 서블릿 하나를 구현해 기본 서블릿으로 사용
요청이 오면 모든 컨트롤러를 순회
요청 URL 경로와 같은 이름의 컨트롤러 메서드를 리플렉션으로 읽고 호출
method.invoke(controller, request, response);
존재하는 서블릿
ReflectionServlet, HomeServlet, DiscardServlet…
장점
하나의 클래스 내에서 메서드로 기능 처리 가능 (관련 기능 별로 클래스 분류)
URL 매핑 작업 제거 (URL 경로의 이름과 같은 이름의 메서드를 찾아 호출)
문제점
요청 URL과 메서드 이름을 다르게 할 수 없음
자바 메서드 이름으로 처리가 어려운 URL 존재
/, /favicon.ico, /add-member
애노테이션 서블릿
컨트롤러에 URL 정보가 담긴 애노테이션 추가 (e.g. @Mapping("/"))
기본 서블릿이 리플렉션으로 애노테이션을 읽도록 리팩토링
요청이 오면 모든 컨트롤러를 순회
요청 URL과 애노테이션 속성값이 같은 메서드를 리플렉션으로 읽고 호출
장점
어떤 요청 URL이든 컨트롤러에서 다른 메서드 이름으로 처리 가능
-> 스프링 프레임워크는 스프링 MVC를 통해 이 과정을 더욱 최적화해 기능을 제공
동적 파리미터 바인딩 (HttpServletRequest, HttpServletRequest…)
요청마다 모든 컨트롤러 조회 -> 처음 서블릿 생성 시점에 PathMap 초기화
…
Reference
김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션
-
QueryDSL Dive
QueryDSL
소개
JPQL 빌더
장점
문자인 JPQL을 코드로 작성해 컴파일 오류 발생 가능
JPQL과 달리 파라미터 바인딩을 자동 처리
라이브러리 종류
querydsl-apt: Querydsl 관련 코드 생성 기능 제공 (Q 클래스 빌드)
querydsl-jpa: Querydsl 라이브러리
기본 문법
JPAQueryFactory
쿼리 작성의 기본 토대
EntityManager를 전달해 생성
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
필드에 두어도 동시성 문제 걱정 없음
EntityManager가 동시성 문제 걱정이 없기 때문에 마찬가지다
Q-Type
전략
기본 인스턴스 사용 방법을 static import해 사용하자
같은 테이블을 조인해야 하는 경우에만 별칭 직접 지정 방법을 사용하자
Q 클래스 인스턴스 사용 방법 2가지
방법 1: 별칭 직접 지정
QMember qMember = new QMember("m");
별칭 = JPQL 별칭
e.g. “select m from Member m” - m이 별칭
같은 테이블을 조인해야할 때만 사용 (다른 때는 쓸 일 없음)
방법 2: 기본 인스턴스 사용
QMember qMember = QMember.member;
select와 from
select, from
selectFrom (축약 버전)
검색 조건 쿼리 (where)
AND, OR 조건
where 조건에 ,로 파라미터를 추가하면 AND 조건 형성
-> null 값은 무시 -> 메서드 추출을 활용해 깔끔한 동적 쿼리 작성 가능
e.g. .where(member.username.eq("member1"), member.age.eq(10))
.and(), .or()로 메서드 체이닝 가능
검색 조건 예시
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.eq("member1").not() // username != 'member1'
member.username.isNotNull() //이름이 is not null
member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30
member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30
member.username.like("member%") //like 검색
member.username.contains("member") // like ‘%member%’ 검색
member.username.startsWith("member") //like ‘member%’ 검색
결과 조회
fetch() : 리스트 조회
데이터 없으면 : 빈 리스트
fetchOne() : 단건 조회
결과가 없으면 : null
결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException
fetchFirst() : 처음 한 건 조회 (= limit(1).fetchOne())
fetchResults() -> deprecated : 페이징 정보 포함 + total count 쿼리 추가 실행
fetchCount() -> deprecated : count 쿼리로 변경해서 count 수 조회
정렬 (orderBy)
desc(), asc() : 일반 정렬
nullsLast(), nullsFirst() : null 데이터 순서 부여
사용 예시
.orderBy(member.age.desc(), member.username.asc().nullsLast())
1번 순서: 회원 나이 내림차순(desc)
2번 순서: 회원 이름 올림차순(asc)
단, 2에서 회원 이름이 없으면 마지막에 출력(nulls last)
참고: 스프링 데이터 JPA의 Sort 객체를 함께 사용할 수 있을까?
스프링 데이터 JPA는 Sort를 QueryDSL의 OrderSpecifier로 변경하는 기능 제공
다만, 정렬은 조금만 복잡해도 Sort 기능 사용이 어려우므로 파라미터로 받아 직접 처리 권장
페이징
SQL 오프셋, 리미트: offset, limit
스프링 부트 3.x(2.6 이상) 유의점 (QueryDSL 5.0)
PageableExecutionUtils 패키지 변경
신규: org.springframework.data.support.PageableExecutionUtils
fetchResults() , fetchCount() => Deprecated
fetchCount() 대체 사용 예제 - count 쿼리 예제 (fetchOne())
Long totalCount = queryFactory
//.select(Wildcard.count) //select count(*)
.select(member.count()) //select count(member.id)
.from(member)
.fetchOne();
fetchResults() 대체 사용 예제
import org.springframework.data.support.PageableExecutionUtils; //패키지 변경
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(member.count()) //count 쿼리
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
// ).fetchOne();
);
// return new PageImpl<>(content, pageable, total);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); //fetchOne() 사용
}
두 쿼리를 각각 메서드로 추출해도 좋음!
반환 전략 (스프링 데이터와 함께 사용하기)
기본: Page 구현체 반환 (PageImpl)
e.g. return new PageImpl<>(content, pageable, total);
CountQuery 최적화 (PageableExecutionUtils 사용)
count 쿼리가 생략 가능한 경우 생략해서 처리 (스프링 데이터 라이브러리 제공)
페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
마지막 페이지이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
집합
집합 함수
List<Tuple> result = queryFactory
.select(member.count(),
member.age.sum(),
member.age.avg(),
member.age.max(),
member.age.min())
.from(member)
.fetch();
Tuple tuple = result.get(0);
tuple.get(member.count()); //회원수
tuple.get(member.age.sum()); //나이 합
tuple.get(member.age.avg()); //평균 나이
tuple.get(member.age.max());
tuple.get(member.age.min());
groupBy(), having()
.groupBy(item.price)
.having(item.price.gt(1000))
조인
기본 조인
연관관계로 조인
문법: join(조인 대상, 별칭으로 사용할 Q타입)
e.g.
queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
종류: join(), innerJoin(), leftJoin(), rightJoin()
세타 조인
연관관계가 없는 필드로 조인
e.g.
queryFactory
.select(member)
.from(member, team)
.where(member.username.eq(team.name))
.fetch();
원리
카타시안 조인을 해버린 후 where절로 필터링 (cross join 후 where 필터링)
DB가 성능 최적화함
단점: 외부조인이 불가능하므로 외부조인 필요시 on 절을 사용해야 함
on 절 활용 조인
조인 대상 필터링
외부조인에 필터링이 필요한 경우에만 사용하자 (내부 조인이면 where 절로 해결)
결과적으로 left join에만 on 절 활용이 의미있는 결과를 만듦
내부조인(inner join)을 사용하면, where 절에서 필터링하는 것과 기능이 동일
e.g.
queryFactory
.select(member, team)
.from(member)
.leftJoin(member.team, team)
.on(team.name.eq("teamA"))
.fetch();
연관관계 없는 엔터티 외부 조인 - 보통 이 이유로 많이 쓰임
문법 차이: leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어감
일반조인: leftJoin(member.team, team) - SQL on절에 id값 매칭 O
on조인: from(member).leftJoin(team).on(xxx) - SQL on절 id값 매칭 X
참고) 내부조인도 가능
e.g.
queryFactory
.select(member, team)
.from(member)
.leftJoin(team)
.on(member.username.eq(team.name))
.fetch();
페치 조인 (fetchJoin())
join(), leftJoin() 등 조인 기능 뒤에 fetchJoin() 추가
e.g.
queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.where(member.username.eq("member1"))
.fetchOne();
distinct
select 절 뒤에 distinct() 추가 (JPQL distinct와 동일)
e.g.
queryFactory
.select(member.username).distinct()
.from(member)
.fetch();
서브 쿼리 (JPAExpressions) - static import 활용하면 코드가 더욱 깔끔해짐
서브쿼리 지원
where 절 서브 쿼리 지원
select 절 서브 쿼리 지원 (하이버네이트 사용 시 지원)
from 절 서브 쿼리(인라인 뷰) 지원 X (JPA, JPQL이 지원 X)
해결책
서브 쿼리를 join으로 변경하기 (높은 확률로 가능)
애플리케이션에서 쿼리를 2번 분리해서 실행하기
nativeSQL을 사용하기
e.g.
queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
기타
Case 문 (거의 사용 X)
select 절, 조건절(where), order by에서 사용 가능
e.g. 단순한 조건
select(member.age
.when(10).then("열살")
.when(20).then("스무살")
.otherwise("기타"))
e.g. 복잡한 조건
select(new CaseBuilder()
.when(member.age.between(0, 20)).then("0~20살")
.when(member.age.between(21, 30)).then("21~30살")
.otherwise("기타"))
e.g. 임의의 순서로 출력하기
NumberExpression<Integer> rankPath = new CaseBuilder()
.when(member.age.between(0, 20)).then(2)
.when(member.age.between(21, 30)).then(1)
.otherwise(3);
List<Tuple> result = queryFactory
.select(member.username, member.age, rankPath)
.from(member)
.orderBy(rankPath.desc())
.fetch();
상수 (거의 사용 X)
Expressions.constant(xxx) 사용
e.g. select(member.username, Expressions.constant("A"))
문자 더하기 (concat)
e.g. select(member.username.concat("_").concat(member.age.stringValue()))
참고: 문자가 아닌 타입들은 stringValue() 로 문자 변환 가능 (ENUM 처리에도 자주 사용)
복잡한 쿼리에 대한 제언
SQL이 화면을 맞추기위해 너무 복잡할 필요는 없다. (from… from… from…)
따라서, DB는 데이터를 퍼올리는 용도로만 사용하자. (필터링, 그룹핑 등 데이터를 최소화해 가져오는 역할)
그리고 뷰 로직은 애플리케이션의 프레젠테이션 계층에서 처리하자.
결과적으로, 서브 쿼리와 복잡한 쿼리가 감소할 것이다.
중급 문법
프로젝션 (select 대상 지정)
프로젝션 대상이 하나
타입을 명확하게 지정
e.g. select(member.username)
프로젝션 대상이 둘 이상
튜플 조회 (Tuple)
List<Tuple> result = queryFactory
.select(member.username, member.age)
.from(member)
.fetch();
for (Tuple tuple : result) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
DTO 조회 (4가지 방법) => 실용적 관점에서는 @QueryProjection이 편리하나 답은 없음
프로퍼티 접근 (Setter)
이름(별칭)을 보고 매칭
e.g. Projections.bean()
select(Projections.bean(MemberDto.class,
member.username,
member.age)
)
필드 직접 접근
getter, setter는 무시하고 리플렉션 등의 방법으로 필드에 직접 값을 꽂음
이름(별칭)을 보고 매칭
e.g. Projections.fields()
select(Projections.fields(MemberDto.class,
member.username,
member.age)
)
생성자 사용
타입을 보고 매칭
e.g. Projections.constructor()
select(Projections.constructor(MemberDto.class,
member.username,
member.age)
)
@QueryProjection (생성자 활용)
사용법
DTO 설정
@Data
public class MemberDto {
private String username;
private int age;
public MemberDto() {}
@QueryProjection
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
빌드 후 DTO의 Q 클래스 생성 확인
사용
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.username, member.age))
.from(member)
.fetch();
장점: 컴파일러 타입 체크가 가능해 가장 안전
단점
DTO가 QueryDSL 애노테이션을 의존
DTO까지 Q 파일을 생성해야 함
유의점: 프로퍼티 or 필드 직접 접근 방식에서 이름이 다를 때
Q 클래스의 필드 이름과 DTO의 필드 이름이 다르면 별칭으로 맞춰줘야 함
별칭 적용 방법
ExpressionUtils.as(source,alias) : 필드나 서브 쿼리에 별칭 적용
username.as("memberName") : 필드에 별칭 적용
e.g.
queryFactory
.select(Projections.fields(UserDto.class,
member.username.as("name"),
ExpressionUtils.as(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub), "age")
)
).from(member)
.fetch();
동적 쿼리
BooleanBuilder
사용 예시
private List<Member> searchMember1(String usernameCond, Integer ageCond) {
BooleanBuilder builder = new BooleanBuilder();
if (usernameCond != null) {
builder.and(member.username.eq(usernameCond));
}
if (ageCond != null) {
builder.and(member.age.eq(ageCond));
}
return queryFactory
.selectFrom(member)
.where(builder)
.fetch();
}
Where 다중 파라미터 사용 (권장, 가장 깔끔)
where 조건에 null 값은 무시
검색조건의 반환결과는 Predicate보다 BooleanExpression 이 좋음 (and, or 조립 가능)
e.g. private BooleanExpression usernameEq(String usernameCond)
장점
메서드를 다른 쿼리에서도 재활용 가능
쿼리 자체의 가독성 상승
조합을 사용하면 반복적으로 쓰이는 코드를 묶어 더 직관적인 코드로 재사용 가능
null 체크는 조금 더 신경써야함 (e.g. null.and(null))
e.g.1
광고 상태를 나타내는 isServiceable() = isValid() + 날짜 IN
e.g.2
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
return usernameEq(usernameCond).and(ageEq(ageCond));
}
사용 예시
private List<Member> searchMember(String usernameCond, Integer ageCond) {
return queryFactory
.selectFrom(member)
.where(usernameEq(usernameCond), ageEq(ageCond))
.fetch();
}
private BooleanExpression usernameEq(String usernameCond) {
return usernameCond != null ? member.username.eq(usernameCond) : null;
}
private BooleanExpression ageEq(Integer ageCond) {
return ageCond != null ? member.age.eq(ageCond) : null;
}
수정 및 삭제 벌크 연산 (execute())
유의점: JPQL과 마찬가지로 배치 쿼리 후에는 영속성 컨텍스트 초기화가 안전 (em.clear())
대량 데이터 수정
기본 수정
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute();
기존 숫자에 1 더하기 (빼고 싶을 때는 -1 전달)
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1))
.execute();
곱하기: .multiply(x)
대량 데이터 삭제
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
SQL function 호출하기
JPA와 같이 Dialect에 등록된 내용만 호출 가능
e.g. “member”를 “M”으로 변경하는 replace 함수 사용
String result = queryFactory
.select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})", member.username, "member", "M"))
.from(member)
.fetchFirst();
ANSI 표준 함수들은 QueryDSL이 상당 부분 내장
e.g. lower()
.where(member.username.eq(member.username.lower()))
순수 JPA + QueryDSL 조합 활용
기본 사용법: 동일 리포지토리 사용
@Repository
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public MemberJpaRepository(EntityManager em) {
this.em = em;
this.queryFactory = new JPAQueryFactory(em);
}
...
}
JPAQueryFactory도 스프링 빈으로 주입해 사용해도 된다 (선택 사항)
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
스프링 데이터 JPA와 QueryDSL
기본 사용법: 스프링 데이터 JPA의 사용자 정의 리포지토리 기능 활용
이 경우, MemberRepositoryImpl은 MemberRepositoryCustomImpl 도 가능 (권장)
순서
사용자 정의 인터페이스 작성
public interface MemberRepositoryCustom {
List<MemberTeamDto> search(MemberSearchCondition condition);
}
사용자 정의 인터페이스 구현
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
public MemberRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
//회원명, 팀명, 나이(ageGoe, ageLoe)
public List<MemberTeamDto> search(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberTeamDto(
member.id,
member.username,
member.age,
team.id,
team.name))
.from(member)
.leftJoin(member.team, team)
.where(usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.fetch();
}
private BooleanExpression usernameEq(String username) {
return isEmpty(username) ? null : member.username.eq(username);
}
private BooleanExpression teamNameEq(String teamName) {
return isEmpty(teamName) ? null : team.name.eq(teamName);
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe == null ? null : member.age.goe(ageGoe);
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe == null ? null : member.age.loe(ageLoe);
}
}
스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
List<Member> findByUsername(String username);
}
기타 (실무 사용 어려움)
인터페이스 지원 - QuerydslPredicateExecutor
QueryDSL의 Predicate을 파라미터로 넘길 수 있음
단점
클라이언트가 QueryDSL 의존
Left Join 불가
Querydsl Web 지원
컨트롤러가 QueryDSL 의존
리포지토리 지원 - QuerydslRepositorySupport
getQuerydsl().applyPagination()
스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능
단점
QueryDSL 3.x 버전 대상
QueryFactory 를 제공하지 않음
Sort 기능이 정상 동작하지 않음
Querydsl 지원 클래스 직접 만들기
Reference
실전! Querydsl
-
Spring Data JPA Dive
스프링 데이터와 스프링 데이터 JPA
스프링 데이터 프로젝트
기본 데이터 저장소의 특수성을 유지하면서 익숙하고 일관된 Spring 기반 데이터 액세스 제공을 목표
스프링 데이터 몽고, 스프링 데이터 레디스, 스프링 데이터 JPA 등이 포함
패키지 구조
spring-data-commons 패키지: Spring-Data 프로젝트(몽고, 레디스, JPA) 모두가 공유
e.g.
Repository(마커 인터페이스)
CrudRepository, PagingAndSortingRepository
spring-data-jpa 패키지: JPA를 위한 스프링 데이터 저장소 지원
e.g. JpaRepository 인터페이스, SimpleJpaRepository 클래스
장점: 유사한 인터페이스로 편하게 개발 가능
DB 변경은 큰 작업이라 거의 일어나지 않으므로, 구현체 교체의 편리함은 장점이 아님
공통 인터페이스
기본 사용법
임의의 설정 클래스에 @EnableJpaRepositories 적용 - 스프링 부트 사용시 생략 가능
@Configuration
@EnableJpaRepositories(basePackages = "jpabook.jpashop.repository")
public class AppConfig {}
만약 적용하고자 하는 패키지가 다르다면, @EnableJpaRepositories를 적용하자
JpaRepository(혹은 부모 인터페이스)를 상속한 인터페이스 만들기
@Repository도 생략 가능 - 스프링 데이터 JPA가 자동처리 (기본 구현체에 이미 적용)
기본 원리
애플리케이션 로딩 시 클래스 스캔 진행
JpaRepository~Repository를 상속한 인터페이스를 모두 찾음
org.springframework.data.repository.Repository를 상속한 인터페이스를 찾음
스프링 데이터 JPA가 구현 클래스 생성 (프록시 구현체)
이후 필요한 곳에 주입
JpaRepository 인터페이스
대부분의 공통 CRUD 제공
제네릭은 <엔티티 타입, 식별자 타입(PK)> 설정
주요 메서드 (상속한 인터페이스 포함)
save(S) : 새로운 엔티티는 저장하고 이미 있는 엔티티는 병합
delete(T) : 엔티티 하나를 삭제 (내부에서 EntityManager.remove() 호출)
findById(ID) : 엔티티 하나를 조회 (내부에서 EntityManager.find() 호출)
getOne(ID) : 엔티티를 프록시로 조회 (내부에서 EntityManager.getReference() 호출)
findAll(...) : 모든 엔티티를 조회
정렬 및 페이징 조건을 파라미터로 제공 (Sort, Pageable)
existsById(ID)
SimpleJpaRepository (기본 구현체)
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> ...{
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
...
}
@Repository 적용됨
컴포넌트 스캔 처리
JPA 예외를 스프링이 추상화한 예외로 변환
@Transactional 적용됨
JPA의 모든 변경은 트랜잭션 안에서 동작
트랜잭션이 이미 리포지토리 계층에 걸려있음
서비스 계층에서 트랜잭션을 시작하지 않으면 리포지토리에서 트랜잭션 시작
서비스 계층에서 트랜잭션을 시작하면 리포지토리는 해당 트랜잭션을 전파 받아 씀
=> 스프링 Data JPA의 변경이 가능했던 이유
@Transactional(readOnly = true)
데이터 단순 조회 트랜잭션에서 플러시를 생략해 약간의 성능 향상
즉, 트랜잭션 종료 시 플러시 작업 제외 (변경 감지 X, DB에 SQL 전달 X)
save 메서드 최적화 필요 상황 (중요)
괜찮은 상황: @GenerateValue면 save() 호출 시점에 식별자가 없어 persist() 호출
문제 상황: 식별자를 @Id만 사용해 직접 할당하는 경우
식별자 값이 있는 상태로 save()를 호출해, merge()가 호출됨
새로운 엔터티가 아닐 경우 merge()를 진행하는데, merge()는 비효율적이므로 지양해야함
merge(): DB에 이미 있는 엔터티라면, SELECT를 실행
새로운 엔터티를 판단하는 기본 전략
식별자가 객체일 때 null 로 판단
식별자가 자바 기본타입일 때 0 으로 판단
해결책: Persistable 인터페이스를 구현
public interface Persistable<ID> {
ID getId();
boolean isNew();
}
등록시간(@CreatedDate)을 조합해 사용하면, 새로운 엔티티 여부 편리하게 확인 가능
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {
@Id
private String id;
@CreatedDate
private LocalDateTime createdDate;
public Item(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
@Override
public boolean isNew() {
return createdDate == null;
}
}
@CreatedDate에 값이 없으면 새로운 엔티티로 판단
쿼리 메서드
전략
2개 정도 파라미터까지만 메서드 이름으로 쿼리 생성해 해결하자
더 길어지면 @Query로 JPQL 직접 정의해 풀자
스프링 데이터 JPA의 쿼리 메서드 탐색 전략
도메인 클래스 + .(점) + 메서드 이름으로 Named Query를 찾음
JpaRepository 상속 시 제네릭으로 설정한 도메인 클래스
인터페이스에 정의한 메서드 이름
없으면 메서드 이름으로 쿼리 생성
3가지 방법
메서드 이름으로 쿼리 생성 (기본)
규칙
...은 식별하기 위한 내용(설명)이므로 무엇이 들어가도 상관 없음
e.g. findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 됨
By 뒤에 원하는 속성과 조건을 입력하면 where 절로 간주
SQL에 들어갈 파라미터는 메서드의 파라미터로 받음
기본 제공 기능
조회: find...By, read...By, query...By, get...By
COUNT: count...By 반환타입 long
EXISTS: exists...By 반환타입 boolean
삭제: delete...By, remove...By 반환타입 long
DISTINCT: findDistinct, findMemberDistinctBy
LIMIT: findFirst3, findFirst, findTop, findTop3
장점
엔터티 필드명을 변경하면 메서드 이름도 변경해야 하는데, 컴파일 오류를 통해 인지 가능
메서드 이름으로 JPA NamedQuery 호출 (거의 사용 X)
사용법
엔터티에 정의된 JPA @NamedQuery의 name으로 메서드 이름 설정
장점: 타입 안정성이 높음 (미리 정의된 정적 쿼리를 파싱을 통해 체크)
단점: 엔터티에 쿼리가 있는 것도 좋지 않고, @Query가 훨씬 강력함
@Query 적용 (자주 사용)
인터페이스 메서드에 JPQL 쿼리 직접 정의 가능
사용법
하나의 값 조회
@Query("select m.username from Member m")
List<String> findUsernameList();
DTO 직접 조회
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
"from Member m join m.team t")
List<MemberDto> findMemberDto();
파라미터 바인딩
@Query("select m from Member m where m.username = :name")
Member findMembers(@Param("name") String username);
이름 기반 바인딩을 하자 (위치 기반 바인딩 지양)
e.g. :name <-> @Param("name")
컬렉션 파라미터 바인딩
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") List<String> names);
Collection 타입으로 in절 지원
장점
타입 안정성이 높음
정적 쿼리라서 틀리면 애플리케이션 시작 시점에 컴파일 에러
이름없는 Named 쿼리라 할 수 있다!
반환 타입
스프링 데이터 JPA는 반환 타입에 따라 getSingleResult() 혹은 getResultList() 등을 호출
List<Member> findByUsername(String name); //컬렉션
Member findByUsername(String name); //단건
Optional<Member> findByUsername(String name); //단건 Optional
단건 조회 결과가 있을지 없을지 모르겠다면 Optional 사용하자!!
조회 결과가 많거나 없으면?
컬렉션
결과 없음: 빈 컬렉션 반환
단건 조회
결과없음: null 반환
JPA는 NoResultException 발생, 스프링 데이터 JPA는 try~catch로 감싼 것
결과가 2건 이상: javax.persistence.NonUniqueResultException 예외 발생
결국엔 스프링 예외 IncorrectResultSizeDataAccessException로 변환됨
참고: 단건 조회 결과 Best Practice
자바 8 이전: 단건 조회의 결과가 없는 경우, 예외가 나은지 null이 나은지는 논란
=> 결론: 실무에서는 null이 낫다!
자바 8 이후
=> DB에서 조회했는데 데이터가 있을지 없을지 모르면 그냥 Optional을 써라!!!
페이징과 정렬
파라미터
Sort : 정렬 기능
Pageable : 페이징 기능 (내부에 Sort 포함)
반환 타입
Page : 페이징 (+ 추가 count 쿼리 결과 포함)
실무에서 최적화가 가능하다면 최대한 카운트 쿼리를 분리해 사용 (e.g. 조인 줄이기)
참고: Count 쿼리는 매우 무거움
@Query(value = "select m from Member m",
countQuery = "select count(m.username) from Member m")
Page<Member> findMemberAllCountBy(Pageable pageable);
Slice : 페이징 - 다음 페이지만 확인 가능 (내부적으로 limit + 1 조회), 무한 스크롤 용도
List: 페이징 - 조회 데이터만 반환
예제 1 - 반환 타입 사용법
//count 쿼리 O
Page<Member> findByUsername(String name, Pageable pageable);
//count 쿼리 X
Slice<Member> findByUsername(String name, Pageable pageable);
//count 쿼리 X
List<Member> findByUsername(String name, Pageable pageable);
List<Member> findByUsername(String name, Sort sort);
예제 2 - Pageable, Sort 파라미터 사용법
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> page = memberRepository.findByAge(10, pageRequest);
Pageable의 구현체 PageRequest 객체를 생성해 전달
PageRequest 생성자 파라미터
첫 번째: 현재 페이지 (0부터 시작)
두 번째: 조회할 데이터 수
추가: 정렬 정보 (Sort)
예제 3 - 페이지를 유지하면서 엔터티를 DTO로 변환하기
Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto());
주요 메서드
Page (Slice를 상속 받았으므로, Slice의 메서드도 사용 가능)
getTotalPages();: 전체 페이지 수
getTotalElements();: 전체 데이터 수
map(Function<? super T, ? extends U> converter);: 변환기
Slice
getNumber(): 현재 페이지
getSize(): 페이지 크기
getNumberOfElements(): 현재 페이지에 나올 데이터 수
getContent(): 조회된 데이터
hasContent(): 조회된 데이터 존재 여부
getSort(): 정렬 정보
isFirst(): 현재 페이지가 첫 페이지 인지 여부
isLast(): 현재 페이지가 마지막 페이지 인지 여부
hasNext(): 다음 페이지 여부
hasPrevious(): 이전 페이지 여부
getPageable(): 페이지 요청 정보
nextPageable(): 다음 페이지 객체
previousPageable(): 이전 페이지 객체
map(Function<? super T, ? extends U> converter): 변환기
벌크성 수정, 삭제 쿼리 (@Modifying)
JPA의 executeUpdate() 를 대신 실행 (벌크성 수정 및 삭제)
@Modifying이 있으면 executeUpdate()를 실행
없으면, getSingleResult() 혹은 getResultList() 등을 실행
@Modifying(clearAutomaically = true) - 기본값은 false
벌크성 쿼리 실행 후, 영속성 컨텍스트 자동 초기화
벌크 연산 이후에는 조회 상황을 대비해, 영속성 컨텍스트 초기화 권장
예제
@Modifying
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
이 경우, @Modifying이 없다면 다음 예외 발생
org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations
엔터티 그래프 (Entity Graph)
페치 조인의 간편 버전
예제
//공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"}) List<Member> findAll();
//JPQL + 엔티티 그래프
@EntityGraph(attributePaths = {"team"}) @Query("select m from Member m") List<Member> findMemberEntityGraph();
//메서드 이름으로 쿼리에서 특히 편리하다.
@EntityGraph(attributePaths = {"team"})
List<Member> findByUsername(String username)
NamedEntityGraph (거의 안씀)
엔터티에 엔터티 그래프를 미리 등록해두고 불러와 쓰는 방법
@NamedEntityGraph(name = "Member.all", attributeNodes =
@NamedAttributeNode("team"))
@Entity
public class Member {}
@EntityGraph("Member.all")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
전략
간단한 쿼리는 @EntityGraph로 처리
복잡한 쿼리는 JPQL로 페치조인 처리
e.g. @Query("select m from Member m left join fetch m.team")
JPA Hint
JPA 쿼리 힌트 (SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)
쿼리 힌트는 readOnly 정도 말고는 잘 안씀
사실, readOnly도 잘 안씀
정말 트래픽이 많을 때 쓸 해결책이 아니다
성능 테스트를 해서 정말 중요하고 트래픽 많은 API 몇 개에만 적용 고려
예제 1 - ReadOnly
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
readOnly - 하이버네이트 종속 기능 (JPA X)
변경이 없다고 생각하고 1차캐시에 스냅샷을 만들지 않도록 최적화 (-> 변경감지 없음)
조회용이라면 스냅샷이 필요없음
변경 감지는 메모리를 더 사용해 비용이 큼 (원본 객체 + 복제본 스냅샷 객체)
예제 2 - Count 쿼리 힌트 추가
@QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly",
value = "true")},
forCounting = true)
Page<Member> findByUsername(String name, Pageable pageable);
forCounting (기본값: true)
Page 반환 시, 페이징을 위한 카운트 쿼리에도 동일한 쿼리 힌트를 적용할지 여부 선택
Lock
예제 - 비관적 락 (Pessimistic Lock = select ... for update)
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findByUsername(String name);
실시간 트래픽이 많은 서비스는 가급적 락을 거는 것을 지양하자
Optimistic Lock으로 해결하거나 락을 안걸고 다른 방법으로 해결하는 쪽을 권장
Pessimistic Lock은 실시간 트래픽보다 정확도가 중요한 서비스에서 좋은 방법
e.g. 돈을 맞춰야 하는 서비스
사용자 정의 리포지토리 (매우 중요)
인터페이스에 메서드를 직접 구현하고 싶을 때 사용
인터페이스 구현체 직접 구현 시 문제
스프링 데이터 JPA는 인터페이스만 정의 후 구현체가 자동 생성
직접 구현체 생성하기에는 오버라이드해야 할 메서드가 너무 많음
사용자 정의 리포지토리 사용 이유
스프링 JDBC Template 사용, MyBatis 사용
JPA 직접 사용(EntityManager), 데이터베이스 커넥션 직접 사용 등등…
Querydsl 사용
사용자 정의 리포지토리를 사용하지 않고 쿼리용 리포지토리를 따로 나누는 것도 좋은 전략! (CQRS)
사용 방법
규칙
방법 1: 리포지토리 인터페이스 명 + Impl
방법 2: 사용자 정의 인터페이스 명 + Impl (권장, 스프링 데이터 2.X~)
=> 스프링 데이터 JPA가 인식해서 스프링 빈으로 등록
사용자 정의 인터페이스 작성
public interface MemberRepositoryCustom {
List<Member> findMemberCustom();
}
사용자 정의 인터페이스 구현 클래스 작성
방법 1: MemberRepository + Impl
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m")
.getResultList();
}
}
방법 2: MemberRepositoryCustom + Impl (권장)
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final EntityManager em;
@Override
public List<Member> findMemberCustom() {
return em.createQuery("select m from Member m")
.getResultList();
}
}
사용자 정의 인터페이스 상속
public interface MemberRepository
extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}
Auditing (실무 자주 사용)
실무 케이스
실무에서 등록일, 수정일은 DB 모든 테이블에 깔고 감
관리자가 있다면 로그인한 ID를 기준으로 등록자, 수정자도 필요한 테이블에 둠
순수 JPA 구현
@MappedSuperclass
@Getter
public class JpaBaseEntity {
@Column(updatable = false)
private LocalDateTime createdDate;
private LocalDateTime updatedDate;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
createdDate = now;
updatedDate = now;
}
@PreUpdate
public void preUpdate() {
updatedDate = LocalDateTime.now();
}
}
public class Member extends JpaBaseEntity {}
JPA 주요 이벤트 어노테이션
@PrePersist, @PostPersist
@PreUpdate, @PostUpdate
스프링 데이터 JPA
설정
@EnableJpaAuditing -> 스프링 부트 설정 클래스에 적용해야 함
@EntityListeners(AuditingEntityListener.class) -> 엔터티에 적용
AuditorAware 스프링 빈 등록 (등록자, 수정자 처리)
@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaApplication.class, args);
}
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.of(UUID.randomUUID().toString());
}
}
실무에서는 세션 정보나, 스프링 시큐리티 로그인 정보에서 ID를 받음
엔터티 적용
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
}
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity extends BaseTimeEntity {
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String lastModifiedBy;
}
public class Member extends BaseEntity {}
적용 애노테이션
@CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy
Base 타입을 분리하고, 원하는 타입을 선택해서 상속
실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요
등록자, 수정자는 필요한 곳도 있고 아닌 곳도 있음
저장시점에는 등록일-수정일, 등록자-수정자에 같은 데이터 저장 (유지보수 관점에서 편리)
선택사항) @EntityListeners(AuditingEntityListener.class) 생략하기
스프링 데이터 JPA가 제공하는 이벤트를 엔티티 전체에 적용
META_INF / orm.xml
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd"
version="2.2">
<persistence-unit-metadata>
<persistence-unit-defaults>
<entity-listeners>
<entity-listener class="org.springframework.data.jpa.domain.support.AuditingEntityListener"/>
</entity-listeners>
</persistence-unit-defaults>
</persistence-unit-metadata>
</entity-mappings>
Web 확장
페이징과 정렬
@GetMapping("/members")
public Page<Member> list(Pageable pageable) {...}
파라미터로 Pageable, 반환 타입으로 Page 사용 가능
파라미터로 구현체인 PageRequest가 생성되어 전달됨
요청 예시: /members?page=0&size=3&sort=id,desc&sort=username,desc
page: 현재 페이지, 0부터 시작
size: 한 페이지에 노출할 데이터 건수
sort: 정렬 조건 정의 예) 정렬 속성,정렬 속성…(ASC
DESC), 정렬 방향을 변경하고 싶으면 sort 파라 미터 추가 ( asc 생략 가능)
기본값 설정하기
글로벌 설정 (스프링 부트)
spring.data.web.pageable.default-page-size=20 # 기본 페이지 사이즈
spring.data.web.pageable.max-page-size=2000 # 최대 페이지 사이즈
개별 설정 (@PageableDefault)
public String list(@PageableDefault(size = 12, sort = "username", direction = Sort.Direction.DESC) Pageable pageable)
둘 이상의 페이징 정보는 접두사로 구분 가능
@Qualifier 에 접두사명 추가 “{접두사명}_xxx”
예제: /members?member_page=0&order_page=1
@Qualifier("member") Pageable memberPageable,
@Qualifier("order") Pageable orderPageable,
Page 내용을 DTO로 변환하기 (API 스펙에 엔터티 노출하지 않기)
public Page<MemberDto> list(Pageable pageable) {
return memberRepository.findAll(pageable).map(MemberDto::new);
}
Page.map() 으로 변환 가능
참고: Page는 변경 없이 0부터 시작하자
Page를 1부터 시작하는 방법 (불편)
방법 1: 직접 클래스를 만들어서 처리
Pageable, Page 파리미터 및 응답 값 사용 X
직접 PageRequest 생성해 리포지토리에 전달, 응답값도 직접 작성
방법 2: spring.data.web.pageable.one-indexed-parameters = true
한계: content만 잘나오고, 나머지는 원래 0 인덱스대로 나옴
도메인 클래스 컨버터 (실무 사용 거의 없음)
@GetMapping("/members/{id}")
public String findMember(@PathVariable("id") Member member) {...}
HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩
자동으로 리포지토리 사용해 엔터티 찾음
간단한 쿼리에만 적용 가능 (트랜잭션이 없으므로 변경이 불가, 단순 조회용)
기타 기능들 - 실무 거의 사용 X
Specifications (명세) -> QueryDSL 사용하자!
JPA Criteria 활용해 다양한 검색 조건 조합 기능 지원
Query By Example -> **QueryDSL 사용하자!
실제 도메인 객체를 활용해 동적 쿼리 처리 (Probe, ExampleMatcher)
실무에 사용하기에는 매칭 조건이 너무 단순하고, Left Join이 안됨
Projections -> 단순할 때만 사용하고, 조금만 복잡해지면 **QueryDSL 사용하자!
프로젝션 대상이 root 엔터티면 유용
인터페이스 기반 Closed Projections
프로퍼티 형식(getter)의 인터페이스를 제공하면, 구현체는 스프링 데이터 JPA가 제공
public interface UsernameOnly {
String getUsername();
}
public interface MemberRepository ... {
List<UsernameOnly> findProjectionsByUsername(String username);
}
클래스 기반 Projections
public class UsernameOnlyDto {
private final String username;
public UsernameOnlyDto(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
인터페이스 기반 Open Projections, 동적 Projections, 중첩구조처리…
네이티브 쿼리 (99% 사용 X)
예제
@Query(value = "select * from member where username = ?", nativeQuery = true)
Member findByNativeQuery(String username);
권장 해결책
복잡한 통계 쿼리도 QueryDSL로 해결
네이티브 쿼리 DTO 조회는 별도 리포지토리 파서 JDBC template or MyBatis 사용 권장
Reference
실전! 스프링 데이터 JPA
-
JPA 활용 팁 2
요청과 응답 관련 유의 사항
요청 및 응답은 API 스펙에 맞추어 별도의 DTO로 전달하자 (엔터티 노출 X)
엔터티를 요청과 응답에 사용하면 프레젠테이션 계층과 엔터티가 결합되어 오염됨 (@NotEmpty 등…)
e.g. @RequestBody CreateMemberRequest request
롬복은 DTO에 적극적으로 사용하자 (Entity에는 getter 정도 이외에는 사용 X)
CQS 개발 스타일 적용하면 유지보수성이 크게 향상됨!
Update 메서드는 반환없이 끝내거나 ID 값 정도만 반환
Update가 엔터티 객체를 반환하면, 업데이트하면서 조회하는 꼴
Update 후 조회가 필요하다면, PK로 하나 조회하자
특별히 트래픽 많은 API가 아니면 큰 이슈 X
e.g.
memberService.update(id, request.getName());
Member findMember = memberService.findOne(id);
API 응답은 처음부터 Object로 반환하자 (Array X)
추후 Count를 넣어달라는 요청 등으로 언제든 요구사항이 변할 수 있음 (확장성을 위해)
지연로딩과 조회 성능 최적화
항상 지연로딩을 기본으로 하고, 성능 최적화가 필요한 경우 페치 조인 사용하자!
즉시 로딩은 연관관계가 필요 없는 경우에도 데이터를 항상 조회해 성능 문제 유발
JPQL 실행 후 응답을 받을 때 연관 관계에 즉시 로딩이 있으면,
영속성 컨텍스트에서 지연 로딩처럼 하나하나 단건 쿼리 날려 다 조회해 가져옴 (N + 1)
참고: 지연로딩은 N + 1을 만들지만, 영속성 컨텍스트에서 조회하므로 운좋게 이미 조회된 객체는 쿼리 생략
DTO 직접 조회 방식은 후순위 선택지
DTO 직접 조회는 페치 조인 없이도 한 번에 쿼리가 나감
장점
SELECT 절에 원하는 데이터 직접 선택 -> 애플리케이션 네트워크 용량 최적화 (생각보다 미비)
최근 네트워크 대역폭이 매우 좋음
대부분 성능 문제는 join에서 걸리거나 where 문이 인덱스를 잘 안탈 때 생김
SELECT 절이 문제가 될 때는 하필 필드 데이터 사이즈가 정말로 컸을 때
e.g. 필드가 10~30개 정도 되면 트래픽이 정말 많은 API는 영향 받을 수도 있음
단점
리포지토리가 API(화면)을 의존 -> API 스펙이 바뀌면 수정
-> 물리적 계층은 나뉘었지만 논리적 계층은 깨져있음
리포지토리 재사용성 감소
컬렉션 조회 최적화
컬렉션 조회시 페치 조인의 한계
컬렉션 페치 조인은 페이징 불가능
결과값은 올바르게 페이징해줄 수도 있으나, 메모리에서 진행되므로 메모리 터질 가능성 매우 큼
1 : N : M 같은 둘 이상의 연쇄적 컬렉션 패치 조인은 사용해서는 안됨
JPA 입장에서 어떤 엔터티를 기준으로 정리할지 모르게 될 수 있음
페이징 + 컬렉션 조회 전략 (default_batch_fetch_size)
전략
ToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인하기 (=쿼리수 최대한 줄이기)
컬렉션은 아래 최적화 적용하고 지연 로딩으로 조회 (=N + 1 문제 완화)
hibernate.default_batch_fetch_size: 글로벌 설정 (이것만으로도 충분)
@BatchSize: 개별 최적화
-> 컬렉션 및 프록시 객체를 설정한 size만큼 한꺼번에 IN 쿼리로 조회
장점
페이징 가능
페치 조인 방식 보다 쿼리 호출 수는 약간 증가하지만, DB 데이터 전송량이 감소
적절한 배치 사이즈
전략
WAS, DB가 버틸 수 있으면 1000으로 설정
WAS, DB가 걱정된다면 100으로 설정하고 점점 늘리기
애매하면 500으로 설정 (100~500 두면 큰 문제 없이 사용 가능)
DB 및 애플리케이션이 순간 부하를 어느정도로 견딜 수 있는지로 결정
DB에 따라 IN 절 파라미터를 1000으로 제한하는 경우도 있음
1000으로 잡으면 DB 및 WAS의 순간 부하 증가 (CPU 및 리소스)
100이면 시간은 더 걸리겠지만 순간 부하는 덜할 것
WAS 메모리 사용량은 100이든 1000이든 동일
distinct
하이버네이트 6 버전 부터는 컬렉션 조회 시 distinct 없이도 애플리케이션 단에서 자동으로 중복을 거른다.
6 이전에는 2가지 기능을 함께 수행했다.
SQL에 distinct 추가
같은 엔터티가 조회되면 애플리케이션 단에서 중복 거르기
네트워크 호출 횟수와 데이터 전송량
네트워크 호출 횟수와 데이터 전송량 사이에는 성능 트레이드 오프가 존재한다.
모두 조인해서 가져오면, 한 번의 호출로 가져오지만 데이터 양이 많을 경우 성능이 저하된다.
여러 쿼리로 나눠 가져오면, 호출 수는 많아지지만 각각 최적화된 데이터 양으로 가져올 수 있어 더 나은 성능을 보일 수도 있다.
예를 들어, 한 쿼리로 1000개 데이터를 퍼올리는 상황이라면 여러 쿼리로 나누는게 나을 수 있다.
쿼리 방식 권장 선택 순서
기본: 엔터티 조회 후 DTO 변환 방식 (대부분의 성능 이슈 해결 가능)
ToOne 관계 조회
페치 조인으로 쿼리 수 최적화
OneToMany 관계 조회 (컬렉션 조회)
페이징 필요 O
ToOne인 부분은 최대한 페치 조인해서 가져옴
컬렉션은 hibernate.default_batch_fetch_size, @BatchSize 로
최적화 후 지연로딩
페이징 필요 X
페치 조인 최적화
e.g.
페이징이 없는 엑셀 다운로드 같은 기능은 페치 조인으로 조회
다만, 용량이 너무 많으면 앞의 default_batch_fetch_size 방법 이용
차선책: DTO 직접 조회 방법 사용
ToOne 관계 조회
단순 조인으로 한 번에 쿼리
OneToMany 관계 조회 (컬렉션 조회)
단건
One을 조회 -> One의 식별자로 Many를 조회 -> 서로 매핑
다건
분할 쿼리 (with IN 쿼리) - 권장
One을 조회 -> 해당 One의 식별자를 모아 Many를 IN 쿼리 -> Map 활용해 매핑
플랫 쿼리
한방 쿼리로 가져온 후 매핑 (페이징이 불가해 실무 비현실성, 성능 차이도 미비)
유지보수 방법
복잡한 통계 API 용으로 QueryService, QueryRepository 파서 DTO 직접 조회 사용
일반 리포지토리는 기본 엔터티 조회용으로 사용
-> 둘 구분으로 유지보수성 향상
최후의 방법: JPA 제공 네이티브 SQL 혹은 스프링 JDBC Template으로 직접 SQL 사용
이런 경우가 거의 없지만 DB 네이티브한 복잡한 기능 필요시 사용
참고
엔터티 조회 방식(페치 조인, BatchSize)으로 해결이 안되는 수준의 상황
서비스 트래픽이 정말 많은 상황이라 DTO 조회 방식으로도 해결이 안 될 가능성이 높음
캐시(레디스, 로컬 메모리 캐시) 사용이나 다른 방식으로 해결해야 함
엔터티 조회 방식은 코드 수정 거의 없이 옵션 변경만으로 성능 최적화하는
반면, DTO 직접 조회 방식은 성능 최적화 시 코드 변경이 많음
개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다.
OSIV (Open Session In View) 전략
유래
Open Session In View: 하이버네이트
Open EntityManager In View: JPA
관례상 OSIV라고 함
하이버네이트가 JPA보다 먼저 나왔기 때문에, 이름 차이 발생
과거 하이버네이트 Session = JPA EntityManager
사용 전략
고객 서비스의 실시간 API는 OSIV 끄기
ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV 켜기
ADMIN은 해봤자 20~30명이 쓰는 서비스
한 프로젝트여도 멀티 모듈 사용해 분리 -> 고객 서비스와 ADMIN 서비스는 배포 군이 다름
spring.jpa.open-in-view = true (기본값)
최초 DB 커넥션 시작 시점부터 API 응답 종료까지 영속성 컨텍스트와 DB 커넥션을 유지
과정
JPA는 @Transactional 로 트랜잭션 시작시점에 커넥션을 가져옴
API 혹은 뷰 템플릿 렌더링이 끝나고 응답이 완전히 나가면
물고 있던 커넥션 반환
영속성 컨텍스트 종료
장점
API 컨트롤러 & View Template에서도 지연 로딩을 가능하게 함
영속성 컨텍스트는 기본적으로 DB 커넥션 유지
단점 (치명적)
너무 오랜시간 동안 DB 커넥션 리소스를 사용 (e.g. 컨트롤러에서 외부 API 호출)
실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자라 장애 유발
일반적인 애플리케이션이라면 트랜잭션 종료 시 커넥션도 반환하는게 자연스러움
spring.jpa.open-in-view = false
트랜잭션을 종료할 때, 영속성 컨텍스트를 닫고 DB 커넥션 반환
장점: 커넥션 리소스 낭비 X
단점: 모든 지연 로딩은 트랜잭션안에서 처리해야 함
부분적 해결책: OSIV 켜기 / 트랜잭션 내에서 지연로딩 모두 처리 / 페치조인
영속성 컨텍스트 종료 후 바깥에서 지연 로딩 시 다음 예외 발생
LazyInitializationException: could not initialize proxy
Command와 Query를 분리(CQS)하면 OSIV 끈 상태에서도 복잡성 관리가 편리
보통 성능 이슈는 조회에서 발생
핵심 비즈니스 로직과 조회 로직은 라이프사이클이 다름
뷰는 자주 변함
한 곳에 모았는데 핵심 비즈니스 로직 4~5개, 조회 로직 30개면 유지보수성 급격히 감소
패키지 구조
service.order.OrderService: 핵심 비즈니스 로직
service.order.query.OrderQueryService: 뷰 (주로 읽기 전용 트랜잭션 사용)
쿼리 서비스 용 패키지를 따로 두는게 좋음
엔터티를 뷰 용 DTO로 변환하는 작업을 QueryService에서 처리
Reference
실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화
-
스프링 핵심 원리 - 고급편
로그 추적기 도입 과정
목표: 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)
-
자바 멀티스레드와 동시성
멀티태스킹 & 멀티프로세싱
프로그램 실행
프로그램을 구성하는 코드를 순서대로 CPU(=프로세서)에서 연산하는 일
초창기 컴퓨터
하나의 CPU 코어에서 한 프로그램 코드를 모두 수행 후 다른 프로그램 코드 실행
e.g. 사용자는 음악 프로그램 끝난 후에야 워드 프로그램 실행 가능해 불편
멀티태스킹 (소프트웨어 관점 - 운영체제)
단일 CPU(단일 CPU 코어)가 여러 작업을 동시에 수행하는 것처럼 보이게 하는 것
e.g. 현대 운영체제에서 여러 애플리케이션이 동시에 실행되는 환경
CPU가 매우 빠르게 두 프로그램의 코드를 번갈아 수행한다면, 사람은 동시에 실행되는 것처럼 느낄 것
현대 CPU는 초당 수십억 번 이상의 연산 수행
대략 0.01초(10ms) 동안 한 프로그램을 수십만 번 연산
하나의 CPU 코어 -> 프로그램 A 코드 수행 (약 10ms) -> 프로그램 B 코드 수행 (약 10ms) -> 프로그램 A의 이전 실행 중인 코드부터 다시 수행 (약 10ms) -> …
멀티프로세싱 (하드웨어 관점)
여러 CPU(여러 CPU 코어)를 사용하여 여러 작업을 동시에 수행하는 것
e.g. 멀티코어 프로세서를 사용하는 현대 컴퓨터 시스템
여러 개의 CPU 코어에서 여러 프로그램이 물리적으로 동시에 실행
코어가 2개여도 2개보다 많은 프로그램 실행 가능
하나의 CPU 코어만 사용하는 시스템보다 동시에 더 많은 작업을 처리
e.g. CPU 코어 2개에서 프로그램 A, B, C 처리
CPU 코어 2개에서 물리적으로 동시에 2개의 프로그램 처리
A, B 실행 (약 10ms)
B, C 실행 (약 10ms)
…
멀티 태스킹과 멀티프로세싱은 함께 일어날 수 있는 개념
CPU 코어
최근의 일반적인 컴퓨터는 하나의 CPU 안에 여러 개의 코어를 가지는 멀티코어 프로세서를 가진다.
코어는 CPU 안의 실제 연산을 처리하는 장치를 말한다.
과거에는 하나의 CPU 안에 하나의 코어만 들어있었다.
프로세스와 스레드
프로세스
운영체제 안에서 실행 중인 프로그램
실행 환경과 자원을 제공하는 컨테이너 역할
자바 언어와 비유하면 클래스는 프로그램(=코드뭉치, 파일), 인스턴스는 프로세스
메모리 구성
각 프로세스는 독립적인 메모리 공간을 가짐
서로의 메모리에 직접 접근 불가
특정 프로세스에 심각한 문제가 발생해도 다른 프로세스에 영향 X (해당 프로세스만 종료)
구성
코드 섹션: 실행할 프로그램의 코드가 저장되는 부분
데이터 섹션: 전역 변수 및 정적 변수가 저장되는 부분 (위 그림의 기타에 포함)
힙: 동적으로 할당되는 메모리 영역
스택: 메서드(함수) 호출 시 생성되는 지역 변수와 반환 주소의 저장 영역 (스레드에 포함)
하나 이상의 스레드를 반드시 포함
스레드
프로세스 내에서 실행되는 작업 단위
CPU를 사용해 코드를 하나하나 실행
메모리 구성
공유 메모리
한 프로세스 내 여러 스레드들은 프로세스가 제공하는 메모리 공간을 공유
e.g. 코드 섹션, 데이터 섹션, 힙, 스택을 프로세스 안 모든 스레드가 공유
개별 스택
각 스레드는 자신의 스택을 가짐
프로세스보다 생성 및 관리가 단순하고 가벼움
멀티스레드가 필요한 이유
하나의 프로그램도 그 안에서 동시에 여러 작업이 필요하다
e.g.
워드 프로그램 - 프로세스A
스레드1: 문서 편집
스레드2: 자동 저장
스레드3: 맞춤법 검사
유튜브 - 프로세스B
스레드1: 영상 재생
스레드2: 댓글
멀티스레드도 단일 코어 스케줄링 & 멀티 코어 스케줄링 모두 발생 가능
프로그램 실행
프로그램을 실행하면 운영체제는 먼저 디스크에 있는 파일 덩어리인 프로그램을 메모리로 불러와 프로세스를 만든다. 프로그램이 실행된다는 것은 사실 프로세스 안에 있는 코드가 한 줄씩 실행되는 것이다.
코드는 보통 main()부터 시작해서 스레드가 하나씩 순서대로 내려가면서 실행한다.
한 프로세스 안에는 최소 하나의 스레드가 존재한다. 그래야 프로그램이 실행될 수 있다.
CPU 스케줄링
운영체제가 CPU에 어떤 프로그램을 얼마만큼 실행할지 결정하는 것
CPU를 최대한 활용할 수 있는 다양한 우선순위와 최적화 기법 사용
e.g. 시분할 기법 (Time Sharing, 시간 공유)
각 프로그램의 실행 시간을 분할해서 마치 동시에 실행되는 것처럼 하는 기법
운영체제는 내부에 스케줄링 큐를 가지고, 각각의 스레드는 스케줄링 큐에서 대기
스레드들이 운영체제한테 내가 실행되어야 한다고 알리면 운영체제는 해당 스레드들을 큐에 넣음
운영체제는 큐에서 대기중인 스레드를 하나씩 꺼내 CPU를 통해 실행
스레드는 CPU 연산을 통해 프로그램 코드를 수행
운영체제는 10ms 정도 후 작업 중인 스레드를 잠시 멈추고 다시 스케줄링 큐에 넣음
스케줄링 큐에서 다음 스레드를 꺼내 CPU를 통해 실행
반복…
단일 스레드: 한 프로세스 내에 하나의 스레드만 존재
멀티 스레드: 한 프로세스 내에 여러 스레드가 존재
컨텍스트 스위칭 (Context Switching)
CPU는 컴퓨터에 있는 여러 Process, 여러 Thread 들을 돌아가면서 실행함
컨텍스트 (Context)
Process나 Thread가 중단 됐다가 다시 실행될 때 필요한 정보
컨텍스트 스위칭 (Context Switching)
현재 실행 중인 Context를 잠시 중단 및 저장하고 새로운 Context를 로딩 및 실행하는 것
멈춰지는 스레드는 수행 위치와 CPU에서 사용하던 변수 값들을 메모리에 저장
실행하는 스레드는 수행 위치와 CPU에서 사용하던 변수 값들을 메모리에서 CPU로 불러옴
컨텍스트 스위칭 발생 시 CPU Cache가 초기화됨
다른 코드 수행을 위해 Cache를 비우고 새로 메모리를 읽어 Caching함
값을 저장하고 불러오는 과정은 약간의 비용을 발생시킴
실제로 컨텍스트 스위칭 시간은 짧지만, 스레드가 매우 많다면 비용이 커질 수 있음
유력한 발생 시점
Sleep
Lock
I/O 작업 (Network I/O, File I/O, Console 출력)
시스템 API 호출
혹은 큰 단위 계산을 할 때 컨텍스트 스위칭 발생 가능성 높음
멀티스레드는 대부분 효율적이지만, 컨텍스트 스위칭 과정이 필요하므로 항상 효율적이진 않음
90% 경우는 효율적, 1~3% 경우는 비효율적
예시
CPU 코어가 2개이고 스레드 2개 만들어 연산
2배 빠르게 처리 가능 (효율적)
CPU 코어가 1개인데 스레드 2개 만들어 연산
연산 시간 + 컨텍스트 스위칭 시간 (비효율적)
단일 스레드로 연산하는 것이 오히려 효율적
스레드 숫자 최적화 전략
CPU 개수와 스레드 개수
CPU 4개, 스레드 2개
CPU 100% 활용 X, 컨텍스트 스위칭 비용은 감소
컨텍스트 스위칭이 일어날 수는 있지만 거의 없을 것
CPU 4개, 스레드 100개
CPU 100% 활용 O, 컨텍스트 스위칭 비용 증가
CPU 4개, 스레드 4개
최적 상태 (CPU 100% 활용 O, 컨텍스트 스위칭 비용 거의 X)
스레드 개수로 CPU 코어 개수 + 1개가 이상적 (특정 스레드 대기 시 남은 스레드 활용 가능)
스레드 작업 유형
CPU 바운드 작업
CPU 연산 능력을 많이 요구하는 작업
e.g. 복잡한 수학 연산, 데이터 분석, 비디오 인코딩, 과학적 시뮬레이션…
I/O 바운드 작업
입출력(I/O) 작업을 많이 요구하는 작업 (대기 시간으로 인해 CPU 유휴 상태 빈번)
e.g. DB 쿼리 처리, 파일 읽기/쓰기, 네트워크 통신, 사용자 입력 처리…
실무 전략
스레드 숫자는 작업 유형에 따라 다르게 설정해야 한다!
CPU 바운드 작업: CPU 코어 수 + 1개
CPU를 거의 100% 사용하는 작업이므로 스레드를 CPU 숫자에 최적화
I/O 바운드 작업: CPU 코어 수 보다 많은 스레드 생성
성능 테스트 통해 CPU를 최대한 활용하는 최적의 스레드 개수 찾을 것!
너무 많은 스레드는 컨텍스트 스위칭 비용 증가
웹 애플리케이션 서버 실무는 I/O 바운드 작업이 많음 -> CPU 코어 수 보다 많은 스레드 생성할 것!
사용자 요청 1개 처리 -> 스레드 1개 필요 (CPU 1%)
I/O 작업(DB 쿼리 대기 등)을 생각하면 스레드는 CPU를 거의 사용하지 않고 대기
이 경우 CPU 코어가 4개 있다고 해서 스레드도 4개만 만들면 안됨
동시에 4명의 사용자 요청만 처리 -> CPU 4% 사용 -> CPU가 심하게 놀고 있음!
단순 생각해도 100개 스레드 생성 가능 (CPU 100%)
스레드 개수만 늘리면 되는데, 서버 장비를 늘리는 비효율적인 사태가 벌어지기도…
웹 애플리케이션 서버도 상황에 따라 CPU 바운드 작업이 많을 수 있음
이 때는 CPU 코어 수 + 1개 고려
스레드 생성 및 실행
스레드 생성과 메모리
자바는 실행 시점에 main이라는 이름의 스레드를 만들고, 프로그램의 시작점인 main() 메서드 실행
새로운 스레드를 생성 및 시작하면 자바는 스레드를 위한 실행 스택을 할당
start() 메서드
새로운 스레드를 실행
main 스레드는 다른 스레드에게 일을 시작하라고 지시만 하고 바로 빠져나옴
스레드에 이름을 주지 않으면 임의의 이름 부여 (Thread-0, Thread-1…)
메서드를 실행하면 스택 위에 스택 프레임이 쌓임
main 스레드는 main() 메서드 스택 프레임 올리며 시작
새로 만든 스레드는 run() 메서드 스택 프레임 올리며 시작
유의점
반드시 run() 메서드가 아닌 start() 메서드 호출해야 함
start() 호출 O -> 실행 스택 생성되고 별도의 스레드로 작동
start() 호출 X -> run() 호출은 단순 함수 실행, 생성한 스레드도 단순한 객체일 뿐
일반적인 메서드 호출 (main 스레드의 실행 스택 위에서 실행)
스레드 간 실행 순서를 보장하지 않음 -> 이것이 멀티스레드!
스레드는 동시에 실행되므로 스레드 간 실행 순서는 얼마든지 달라질 수 있음
CPU 코어가 2개여서 물리적으로 정말 동시에 실행될 수도 있고
하나의 CPU 코어에 시간을 나누어 실행할 수도 있음
생성 방법
Runnable 인터페이스 구현 (권장)
정의
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
실행
Thread thread = new Thread(new HelloRunnable());
thread.start()
더 유연하고 유지보수하기 좋은 방식
상속이 자유로움 (Thread 상속 방식은 다른 상속이 불가능)
스레드와 작업 코드가 서로 분리되어 가독성 상승
여러 스레드가 동일한 Runnable 객체를 공유할 수 있어 자원 관리가 효율적
Thread 클래스 상속
정의
public class HelloThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
실행
HelloThread thread = new HelloThread();
thread.start()
자바는 스레드도 객체로 다룸
스레드가 실행할 코드를 run() 메서드에 재정의
Thread 주요 메서드
Thread.currentThread(): 해당 코드를 실행하는 스레드 객체 조회 가능
Thread.currentThread().getName(): 실행 중인 스레드의 이름을 조회
Runnable 인터페이스와 체크 예외
자식 클래스가 부모보다 더 넓은 범위의 예외를 던지면, 일관성을 해치고 예상치 못한 런타임 오류를 초래할 수 있다.
따라서, 자바에서는 메서드 재정의 시 다음과 같은 예외 관련 규칙을 적용한다.
부모 메서드가 체크 예외를 던지지 않는 경우, 자식 재정의 메서드도 던질 수 없다.
자식 메서드는 부모 메서드가 던질 수 있는 체크 예외의 하위 타입만 던질 수 있다.
언체크 예외는 강제하지 않으므로 상관없이 던질 수 있다.
Runnable 인터페이스의 run() 메서드는 어떤 예외도 던지지 않기 때문에, 개발자는 run() 메서드 재정의시 반드시 try-catch 블록 내에서 체크 예외를 처리해야 한다.
예를 들어, 유틸리티 메서드를 하나 만들어, 내부에서 try-catch로 체크 예외를 잡고 언체크 예외로 변경해 재발생시키는 방법도 있다.
this와 스레드
this는 호출된 인스턴스 메서드가 소속된 객체를 가리키는 참조이며, 스택 프레임 내부에 저장된다.
메서드를 호출하는 것은 정확히는 특정 스레드가 어떤 메서드를 호출하는 것이다. 스레드는 메서드 호출을 관리하기 위해 메서드 단위로 스택 프레임을 만든다. 이 때 인스턴스 메서드를 호출하면 어떤 인스턴스 메서드를 호출했는지 기억하기 위해 해당 인스턴스의 참조값을 스택 프레임 내부에 저장해두는데, 이것이 this다.
따라서, 특정 메서드 안에서 this를 호출하면 스택프레임 내의 this 값을 불러서 사용하고 필드 접근시 this를 생략하면 자동으로 this를 참고해 필드에 접근한다.
참고로 인스턴스 메서드는 this가 있지만 클래스 메서드는 this가 없다.
데몬 스레드 (Daemon Thread)
스레드는 2가지 종류로 구분
사용자 스레드
프로그램의 주요 작업 수행
모든 사용자 스레드가 종료되면 JVM도 종료
Main 뿐만 아니라 다른 사용자 스레드까지 모두 종료되어야 자바 종료 (중간에 작업 끊김 X)
데몬 스레드
백그라운드에서 보조적인 작업 수행
모든 사용자 스레드가 종료되면 JVM이 종료되고 데몬 스레드도 자동 종료 (작업 끊김)
데몬 스레드 실행 방법
thread.setDaemon(true) // 데몬 스레드로 설정 (기본값은 false, user 스레드가 기본)
thread.start() // 데몬 스레드 여부는 start() 실행 이후에는 변경되지 않음
데몬
컴퓨터 과학에서는 사용자에게 보이지 않으면서 시스템의 백그라운드에서 작업을 수행하는 것을 데몬 스레드, 데몬 프로세스라고 한다. 예를 들어, 사용하지 않는 파일이나 메모리를 정리하는 작업들이 있다.
스레드 기본 정보
스레드 이름 부여
Thread myThread = new Thread(new HelloRunnable(), "myThread");
스레드 이름이 “myThread”
디버깅, 로깅 목적으로 유용
Thread 클래스 메서드
threadId(): 스레드 고유 식별자 반환 (JVM 내 각 스레드에 대해 유일)
getName(): 스레드 이름 반환 (스레드 이름은 중복 가능)
getPriority(): 스레드의 우선순위 반환 (1: 가장 낮음 ~ 10: 가장 높음, 기본값: 5)
setPriority()로 변경 가능하지만, 실제 실행 순서는 운영체제에 달려있음
getThreadGroup(): 스레드가 속한 그룹을 반환
getState(): 스레드의 현재 상태를 반환 (Thread.State 열거형에 정의된 상수)
NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
부모 스레드
새로운 스레드를 생성하는 스레드를 의미한다.
스레드는 기본적으로 다른 스레드에 의해 생성된다. (main 스레드는 제외)
스레드 그룹
직접적으로 잘 사용하지 않는다. 스레드를 그룹화하여 관리할 수 있는 기능을 제공한다.
스레드 그룹에는 특정 작업을 일괄적으로 적용할 수 있다. (e.g. 일괄 종료, 우선순위 설정)
모든 스레드는 부모 스레드와 동일한 스레드 그룹에 속한다.
main 스레드는 기본으로 제공되는 main 스레드 그룹에 속한다.
스레드의 생명 주기
NEW
스레드가 생성되었으나 아직 시작되지 않은 상태
Thread 객체는 생성되었지만 start() 메서드가 호출되지 않음
RUNNABLE
스레드가 실행 중이거나 실행될 준비가 된 상태 (=CPU에서 실행될 수 있음)
start() 메서드 호출 후 상태
RUNNABLE 상태의 모든 스레드가 동시 실행되지는 않음 (운영체제 스케줄러가 CPU 할당하기 때문)
자바에서는 운영체제 스케줄러에 있든 CPU에서 실제 실행되고 있든 모두 RUNNABLE 상태 (구분 X)
TERMINATED
스레드가 실행을 마친 상태 (run() 메서드 정상 종료 혹은 예외 발생 종료)
스레드는 한 번 종료되면 다시 시작할 수 없음 (새로 만들어서 실행해야 함)
일시 중지 상태
BLOCKED
스레드가 동기화 락을 기다리는 상태
synchronized 블록 진입 위해 락 획득 대기할 때
WAITING
스레드가 다른 스레드의 특정 작업 완료를 무기한 기다리는 상태
e.g. wait(), join() 혹은 LockSupport.park() 호출 시
다른 스레드가 notify(), notifyAll() 호출하거나 join()이 완료될 때까지 기다림
TIMED_WAITING
스레드가 다른 스레드의 특정 작업 완료를 일정 시간 동안 기다리는 상태
e.g. sleep(long millis), wait(long timeout), join(long millis)
혹은 LockSupport.parkNanos(ns) 호출 시
주어진 시간이 경과하거나 다른 스레드가 해당 스레드를 깨우면 이 상태를 벗어남
다른 스레드의 작업 기다리기 - join()
다른 스레드의 작업 완료를 기다려하는 상황에 사용
join(): 무한정 기다릴 때 사용 (WAITING)
join(ms): 특정 시간만 기다릴 때 사용 (TIMED_WAITING)
진행 과정
호출 스레드는 WAITING 상태가 됨
대상 스레드가 TERMINATED 상태가 될 때까지 대기
대상 스레드가 TERMINATED 상태가 되면 RUNNABLE 상태가 되어 다음 코드 수행
대상 스레드가 이미 TERMINATED 상태라면 바로 빠져나옴
e.g. 연산을 두 개의 스레드로 나누어 진행하고 완료된 후 결과를 합쳐 사용
thread-1: 1 ~ 50까지 더하기
thread-2: 51 ~ 100까지 더하기
main: 두 스레드의 계산 결과를 받아 합치기
스레드 작업을 중간에 중단하기
다른 스레드의 작업을 중간에 중단하기
인터럽트 (권장)
대기 상태의 스레드(WAITING, TIMED_WAITING…)를 직접 깨워, RUNNABLE 상태로 변경
작업 중단 지시 후, 거의 즉각적으로 인터럽트 발생
인터럽트 상태가 되면 InterruptedException 예외 발생
상태 변화
인터럽트 상태(true) -> InterruptedException -> 인터럽트 상태(false)
InterruptedException을 던지는 메서드를 호출하거나 호출 중일 때만 예외가 발생
e.g. Thread.sleep(), join()
일반 코드에서는 예외가 발생하지 않음
관련 메서드
interrupt(): 특정 스레드에 인터럽트 걸기
isInterrupted(): 인터럽트 상태 단순 확인 (인터럽트 상태 변경 X)
interrupted(): 인터럽트 상태를 확인 및 상태 변경
스레드가 인터럽트 상태면 true를 반환 및 인터럽트 상태 false로 변경
스레드가 인터럽트 상태가 아니면 false를 반환 (상태 변경 X)
인터럽트 직접 체크 시, interrupted() 사용할 것! (with interrupt())
InterruptedException을 던지는 메서드가 없을 때도 인터럽트 사용 가능
isInterrupted()는 인터럽트 상태가 true로 남겨진 채 유지됨
다른 곳에서도 계속 인터럽트가 발생할 수 있어 위험
방법 예시
Task 주요 코드 (Runnable)
while (!Thread.interrupted()) {...}
main 스레드가 thread.interrupt() 실행해 work 스레드에 인터럽트 지시
변수 사용하기
방법 예시
Task 주요 코드 (Runnable)
volatile boolean runFlag = true;
while (runFlag) {...}
main 스레드가 runFlag = false;를 실행해 work 스레드에 작업 중단 지시
문제점
work 스레드가 작업 중단 지시에 바로 반응 불가 (반응성이 느림)
while 조건문을 읽을 때에서야 인지하므로 루프 내 작업이 길다면, 반응이 더 느려짐
스레드 스스로 작업을 중간에 중단하기 (yield, 양보하기)
현재 스레드가 크게 바쁘지 않다면, 스케줄링 큐에 대기 중인 다른 스레드에게 CPU 실행 기회를 양보
현재 스레드는 다시 스케줄링 큐로 돌아감 (RUNNABLE 상태 유지)
CPU 코어 수보다 많은 스레드가 있을 때 의미가 있음
yield는 스케줄러에게 힌트만 줄 뿐, 실행 순서 강제 X
굳이 양보할 필요 없는 상황이면 본인 스레드 계속 실행 (운영체자가 최적화)
구현 예시
while (!Thread.interrupted()) {
if (jobQueue.isEmpty()) {
Thread.yield(); // 추가
continue;
}
...
}
최대한 실시간으로 확인 원할 시 yield()가 효율적
만일 좀 더 오래 기다려도 될 것 같아 CPU 사용을 최대한 줄이고 싶다면 sleep()도 괜찮음
참고: sleep()의 단점
복잡한 상태 변화 과정 (RUNNABLE -> TIMED_WAITING -> RUNNABLE)
특정 시간만큼 스레드가 실행되지 않음 (양보할 상황이 아닌데도 휴식)
메모리 가시성 (Memory Visibility)
멀티 스레딩 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제
다른 스레드는 캐시에서Stale Data(오래된 데이터) 읽을 수 있음
CPU와 캐시 메모리
CPU는 처리 성능을 개선하기 위해 캐시 메모리를 사용 (L1, L2, L3 캐시…)
현대 CPU는 코어 단위로 캐시 메모리를 보유
각 스레드가 각자의 캐시 메모리를 바라보고 작업해 서로 값 변경을 감지하지 못함
스레드가 특정 변수 값 사용 시, 점유하는 코어의 캐시 메모리로 값을 불러옴 (From 메인 메모리)
값 변경 시, 캐시 메모리의 값만 변경 (메인 메모리에 즉시 반영 X)
메인 메모리 반영 및 읽기 시점은 알 수 없음!
주로 컨택스트 스위칭이 있을 때 캐시 메모리 함께 갱신 (sleep(), 콘솔 출력…)
그러나 환경마다 다르고 갱신이 일어나지 않을 수도 있음
volatile
성능을 약간 포기하는 대신에, 값 읽기 및 쓰기를 모두 메인 메모리에 직접 접근해 진행
사용 상황
여러 스레드에서 같은 시점에 정확히 같은 데이터를 보는게 중요할 때 사용
캐시 메모리보다 성능이 떨어지므로 꼭 필요한 곳에만 사용! (약 5배 차이, 환경에 따라 다름)
Memory Wall
메모리 액세스 속도보다 CPU 처리 속도가 훨씬 빠르기 때문에 발생하는 문제를 말한다.
일반적으로 CPU에 캐시를 두어 속도를 개선한다. (L1, L2, L3)
자바 메모리 모델 (Java Memory Model)
자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지 규정한다. 특히, 멀티스레드 프로그래밍에서 여러 스레드들의 작업 순서를 보장하는 happens-before 관계를 정의한다.
만일 A happens before B 관계가 성립한다면, A 작업의 모든 메모리 변경 사항은 B 작업 시작 전에 메인 메모리에 반영되어 B 작업에서 볼 수 있다.
즉, 다른 스레드 작업의 최신 상태를 참조하는 것(메모리 가시성)을 보장한다. 이 규칙을 따르면, 멀티스레드 프로그래밍 시 예상치 못한 동작을 피할 수 있다.
스레드 시작 및 종료, 인터럽트, 객체 생성 등의 규칙이 있지만 핵심은 volatile 혹은 동기화 기법(synchronized, ReentrantLock)을 사용하면 메모리 가시성 문제가 발생하지 않는다는 점이다.
동시성 문제와 동기화
동시성 문제
멀티 스레드 상황에서 공유 자원에 여러 스레드가 동시에 접근할 때 발생
e.g. 두 개 스레드가 계좌 출금 로직을 실행할 때, 둘 다 검증 로직을 통과해 잔액 없는데도 출금
근본 원인
공유자원에 대한 변경이 있는 연산을 여러 단계로 나누어 사용하는 것
e.g. 출금은 검증 단계와 계산 단계로 나뉘어 있어 원자적이지 않은 연산
여러 단계 없이 원자적인 변경이라면 문제 없음
변경 없이 읽기만 한다면, 이것 역시 문제될 것이 없음
멀티 스레드에서는 공유 자원에 대한 접근을 적절하게 동기화해서 동시성 문제를 예방하는게 중요
동시성 문제가 있다면 메모리 가시성을 해결해도 문제가 지속됨
임계 영역 (Critical Section)
공유 자원 접근 및 수정으로 인해 여러 스레드가 동시에 작업할 때 문제가 생기는 코드
e.g. 임계영역 = 검증 단계 + 계산 단계
동기화 (Synchronization)
공유 자원에 대해 일관성 있고 안전한 접근을 보장하기 위한 메커니즘
임계 영역은 한 번에 하나의 스레드만 접근할 수 있도록 보호해야 함
멀티 스레드 상황에서의 동시성 문제를 해결하기 위해 사용
경합 조건(Race Condition) 해결
두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제
데이터 정합성이 깨짐
데이터 일관성 해결
여러 스레드가 동시에 읽고 쓰는 데이터의 일관성 유지
e.g. 입출금 예제 (1000원 잔액에서 두 개 스레드가 800원 출금 시도)
순차 실행 결과로 -600은 데이터 일관성이 있음 (숫자는 맞으니까)
완전 동시 실행 결과로 200은 데이터 일관성이 깨짐 (아얘 800원 증발)
멀티스레드 환경에서 필수적인 기능이지만, 성능저하 예방을 위해 꼭 필요한 곳에 사용해야 함
동기화 기법
synchronized
모니터 락을 사용해 동기화하는 방법
모니터 락(monitor lock)
모든 객체(인스턴스)가 내부에 가지고 있는 자신만의 락
자바 기본 제공
스레드가 synchronized 메서드에 진입하려면 반드시 모니터 락을 얻어야 함
적용 범위는 인스턴스 단위
한 스레드가 withdraw() 실행 중일 때,
다른 스레드는 withdraw()와 getBalance() 모두 호출 불가
장점
프로그래밍 언어 문법으로 제공 (자바 1.0)
단순하고 편리한 사용
단점
무한정 대기 문제
BLOCKED 상태 스레드는 락 획득까지 무한정 대기 - 타임아웃 or 인터럽트 불가능
e.g. 웹의 경우 요청한 고객의 화면에 계속 요청 중만 뜨고 응답 X
공정성 문제
BLOCKED 상태 스레드들의 락 획득 순서는 보장 X (자바 표준에 정의 X)
최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있음
예시 코드
메서드 동기화
public class BankAccountImpl implements BankAccount {
private int balance;
...
@Override
public synchronized boolean withdraw(int amount) {
...
}
@Override
public synchronized int getBalance() {
...
}
}
클래스 내 모든 메서드에 일일이 키워드 적용하는게 일반적
블록 동기화 (권장)
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
synchronized (this) {
...
}
log("거래 종료");
return true;
}
동기화 구간은 꼭 필요한 코드 블럭만 최소한으로 한정해 설정해야 함 (최적화)
동기화는 여러 스레드가 동시에 실행하지 못하므로 성능이 떨어짐
동시 처리 구간을 늘려서 전체적인 성능을 더 높일 수 있음
괄호 () 안에 들어가는 값은 락을 획득할 인스턴스의 참조
ReentrantLock
자바는 더 유연하고 세밀한 제어를 위한 동시성 문제 해결 라이브러리 패키지 지원
(java.util.concurrent, 자바 1.5)
Lock 인터페이스와 ReentrantLock 구현체 지원 (LockSupport 활용)
모니터 락이 아닌 자체적으로 구현한 락 사용
synchronized의 단점 극복
무한 대기 문제 -> LockSupport 이용해 해결
공정성 문제 -> ReentrantLock 공정 모드 옵션으로 해결
비공정 모드 (Non-fair mode) - 디폴트
private final Lock nonFairLock = new ReentrantLock();
성능 우선: 락을 획득하는 속도가 빠름
선점 가능: 새 스레드가 대기 스레드보다 먼저 락 획득할 수도 있음
기아 현상 가능: 특정 스레드가 계속해서 락 획득 못할 수 있음
비공정 모드도 내부는 큐로 구현되어 있음
대부분의 경우 오래된 스레드 먼저 실행
Race Condition 정말 심할 때 가끔 새치기 스레드 나올 수 있음
공정 모드 (Fair mode)
서비스에서 로직상 반드시 순서가 지켜져야 할 때 사용 (e.g. 선착순)
private final Lock fairLock = new ReentrantLock(true);
공정성 보장: 먼저 대기한 스레드가 락을 먼저 획득
기아 현상 방지: 모든 스레드가 언젠가 락 획득할 수 있도록 보장
성능 저하: 락 획득 속도 느려짐
LockSupport
스레드를 WAITING 상태로 변경 (BLOCKED X, 무한 대기 문제 해결)
unpark()로 깨울 수 있음
타임아웃 및 인터럽트도 가능해짐!
주요 기능
park(): 스레드를 WAITING 상태로 변경
parkNanos(nanos): 스레드를 TIMED_WAITING 상태로 변경 (지정 나노초)
unpark(thread): 스레드를 WAITING, TIME_WAITING -> RUNNABLE 변경
대기 상태의 스레드는 외부 스레드의 도움을 받아야 깨어 날 수 있음 (파라미터)
LockSupport 활용은 무한 대기하지 않는 락 기능 개발의 토대
if (!lock.tryLock(10초)) { // 내부에서 parkNanos() 사용
log("[진입 실패] 너무 오래 대기했습니다.");
return false;
}
//임계 영역 시작
...
//임계 영역 종료
lock.unlock() // 내부에서 unpark() 사용
락(lock) 클래스를 만들어 락 획득 및 반납에 따라 스레드 상태 변경
다만, 구현을 위해서는 대기 스레드를 위한 자료구조 및
스레드를 깨우는 우선순위 알고리즘도 필요하므로 복잡
자바는 저수준의 LockSupport를 활용하는 고수준의 ReentrantLock 구현해둠
Lock 인터페이스
public interface Lock {
//락 획득 시도, 락 없을 시 WAITING, 인터럽트 반응 X
//lock()은 인터럽트 시 잠깐 RUNNABLE 됐다가 강제로 WAITING 상태로 되돌림
void lock();
void lockInterruptibly() throws InterruptedException;//인터럽트O
boolean tryLock(); //락 획득 시도, 성공 여부 즉시 반환
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //주어진 시간 동안 락 획득 시도, 이후 성공 여부 반환
void unlock(); //락 반납, 락을 획득한 스레드가 호출해야 함
//락과 결합해 사용하는 Condition 객체 생성 및 반환
//스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 함
Condition newCondition();
}
예시 코드 1 - 무한정 대기 (lock.lock())
public class BankAccountImpl implements BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
...
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
lock.lock(); // ReentrantLock 이용하여 lock을 걸기
try {
...
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock(); // ReentrantLock 이용하여 lock 걸기 try {
...
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
}
}
스레드가 락을 획득하지 못하면 WAITING 상태가 되고, 대기 큐에서 관리
(내부에서 LockSupport.park() 호출)
락 반납 시, 대기 큐의 스레드를 하나 깨움 (내부에서 LockSupport.unpark() 호출)
대기 큐에 스레드가 없을 시, 깨우지 않음
깨어난 스레드는 락 획득을 시도
락을 획득하면 대기 큐에서 제거
락을 획득하지 못하면 다시 대기 상태가 되면서 대기 큐에 유지
비공정 모드
락 획득을 시도하는 잠깐 사이에 새 스레드가 락을 먼저 가져갈 수 있음
경쟁: 새로 락을 호출하는 스레드 VS 대기 큐에 있는 스레드
공정 모드
대기 큐에 먼저 대기한 스레드가 락을 가져감
예시 코드 2 - 대기 빠져나오기 (lock.tryLock())
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
// 대기 없이 획득 여부 바로 판단
if (!lock.tryLock()) {
log("[진입 실패] 이미 처리중인 작업이 있습니다.");
return false;
}
// 특정 시간만큼 대기
/**
try {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
log("[진입 실패] 이미 처리중인 작업이 있습니다.");
return false;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
**/
try {
...
} finally {
lock.unlock(); // ReentrantLock 이용하여 lock 해제
}
log("거래 종료"); return true;
}
공유 자원
여러 스레드가 접근하는 자원을 말한다. (e.g. 인스턴스 변수, 클래스 변수, 인스턴스 자체)
사실, 공유 자원은 원자적이지 않은 변경이 문제가 되는 것이므로, final 키워드가 붙은 공유자원은 멀티스레드 상황에 안전한 공유자원이다. 어떤 스레드도 값을 변경할 수 없기 때문이다.
참고로, 지역 변수는 공유 자원이 아니므로 동시성 문제를 전혀 고민하지 않아도 된다.
지역 변수는 각각의 스레드가 가지는 별도의 스택 공간에 저장되어서 다른 스레드와 공유하지 않기 때문이다.
BLOCKED VS WAITING (WAITING & TIMED_WAITING)
두 상태 모두 스레드가 실행 스케줄링에 들어가지 않고 대기한다는 점에서 비슷한 상태이다. (CPU가 실행 X)
다만, BLOCKED 상태는 synchronized에서만 사용되며 타임 아웃이나 인터럽트가 불가능하다.
반면에, WAITING 상태는 범용적으로 사용되며 타임아웃이나 인터럽트를 통해 대기 상태를 빠져나올 수 있다.
생산자 소비자 문제
생산자 소비자 문제(producer-consumer problem)
여러 스레드가 동시에 특정 자원을 함께 생산하고 소비하는 상황
멀티스레드에서 자주 등장하는 동시성 문제
= 한정된 버퍼 문제(bounded-buffer problem)
기본 개념
생산자(Producer)
데이터를 생성하는 역할
e.g. 파일에서 데이터를 읽어오거나 네트워크에서 데이터를 받아오는 스레드
소비자(Consumer)
데이터를 사용하는 역할
e.g. 데이터를 처리하거나 저장하는 스레드
버퍼(Buffer)
생산자가 생성한 데이터를 일시적으로 저장하는 공간
e.g. 큐
생산자 소비자 모두 여럿일 수 있음
문제 상황
생산자가 너무 빠를 때
생산자가 데이터를 빠르게 생성해 버퍼가 가득차면, 버퍼에 빈 공간이 생길 때까지 기다려야 함
소비자가 너무 빠를 때
소비자가 데이터를 빠르게 소비해 버퍼가 비면, 버퍼에 새 데이터가 들어올 때까지 기다려야 함
해결책
스레드를 제어할 수 있는 특별한 자료구조 사용
예제: 스레드를 제어하는 큐 만들기
기본 가정: 소비자 스레드와 생산자 스레드는 지속적으로 발생함 (생산자 소비자 구조는 계속 실행)
BoundedQueueV1
특징
동기화한 큐 사용 (take(), put() 메서드를 synchronized)
생산자가 자원을 생산할 때, 큐가 가득찼다면 데이터를 버림
소비자가 자원을 소비할 때, 큐가 비었다면 아무일도 안하고 null 반환
문제
생산자가 데이터를 버리는 것이 비효율적 (기다림 X)
소비자가 데이터를 기다리지 않는 것이 비효율적 (기다림 X)
BoundedQueueV2
목표: 생산자 혹은 소비자 스레드가 기다리도록 하기
특징
생산자는 큐에 빈 공간이 생길 때까지 기다림
put() 메서드 -> while (queue.size() == max)
생산자 스레드는 반복문을 통해 큐에 빈공간이 생기는지 주기적으로 체크
소비자는 큐에 데이터가 추가될 때까지 기다림
take() 메서드 -> while (queue.isEmpty())
소비자 스레드는 반복문을 통해 큐에 데이터가 추가되는지 주기적으로 체크
문제
생산자나 소비자 스레드가 락을 가지고 대기하면, 다른 스레드들은 BLOCKED 됨
(synchronized)
BoundedQueueV3
목표
임계 영역 안에서 락을 가지고 기다리는 스레드가 락을 다른 스레드에게 양보하도록 하기
Object 클래스를 통한 해결 (wait(), notify(), notifyAll())
synchronized 에서 비롯된 락 획득 후 임계영역 내 무한 대기 문제 해결
모든 객체가 사용 가능 (자바는 멀티스레드를 고려하며 탄생한 언어)
주요 메서드
유의점
모두 synchronized 메서드 및 블록 내에서 호출되어야 함
대기하는 스레드는 스레드 대기 집합에서 대기
Object.wait()
현재 스레드가 가진 락을 반납하고 대기 (WAITING, 스레드 대기 집합)
다른 스레드가 notify(), notifyAll()을 호출할 때까지 대기 유지
Object.notify()
스레드 대기 집합에서 대기 중인 스레드 중 하나를 깨움
대기 집합에서 어떤 스레드가 깨어날지는 예측 불가능
(JVM 스펙 명시 X)
락을 다시 획득할 기회 얻음
깨어난 스레드는 WAITING -> BLOCKED 상태가 됨
깨어난 스레드는 임계 영역 내에 있음
임계 영역 내 코드를 실행하려면 락이 필요
락 획득을 위해 BLOCKED 상태로 대기
Object.notifyAll()
스레드 대기 집합에서 대기 중인 모든 스레드를 깨움
모두 락 획득 기회를 얻음
깨어난 스레드는 WAITING -> BLOCKED 상태가 됨
깨어난 스레드는 임계 영역 내에 있음
임계 영역 내 코드를 실행하려면 락이 필요
락 획득을 위해 BLOCKED 상태로 대기
특징
생산자 - put()
반복문 내에서 wait()으로 락을 반납하고 큐의 빈 공간을 기다림 (WAITING)
자원 생산에 성공하면 notify()로 대기 스레드를 깨우고 종료
소비자 - take()
반복문 내에서 wait()으로 락을 반납하고 큐의 데이터 추가를 기다림 (WAITING)
자원 소비에 성공하면 notify()로 대기 스레드를 깨우고 종료
문제
생산자 소비자 모두 데이터를 정상 생산하고 정상 소비하나…
1) 스레드 대기 집합 하나에 생산자, 소비자 스레드를 함께 관리
2) 깨울 스레드 선택이 불가능 (notify())
같은 종류의 스레드를 깨울 때 비효율 발생
큐에 데이터가 없는데 소비자가 소비자를 깨우거나
큐가 가득 찼는데 생산자가 생산자를 깨우는 케이스 존재
깨어난 스레드가 CPU 자원만 소모하고 바로 다시 대기 집합에 들어가 비효율
스레드 기아 상태 (thread starvation) 발생
최악의 경우 특정 스레드만 영원히 깨어나지 못할 수 있음
notify()가 어떤 스레드를 깨우는지 자바 스펙에 명기 X
물론 보통은 오래 기다린 스레드가 깨어나도록 구현됨
큐에 데이터가 없는데 소비자 스레드만 계속 깨우거나
큐가 가득 찼는데 생산자 스레드만 계속 깨울 수 있음
notifyAll()을 사용하면 스레드 기아 상태를 막을 수 있으나 비효율은 지속
BoundedQueueV4
목표: 구현을 synchronized에서 ReentrantLock으로 변경
특징
Lock 인터페이스와 ReentrantLock 구현체 사용
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
ReentrantLock 을 사용하는 스레드가 대기하는 스레드 대기 공간
Lock(ReentrantLock)을 사용하면 스레드 대기 공간을 직접 만들어야 함
변경 포인트
synchronized -> lock.lock()
wait() -> condition.await()
지정 condition에 현재 스레드를 대기(WAITING) 상태로 보관
notify() -> condition.signal()
지정 condition에서 대기 중인 스레드를 하나 깨움
Condition은 Queue 구조를 사용하므로 FIFO 순서로 깨움
BoundedQueueV5
목표: 서로 다른 종류의 스레드를 꺠우도록 생산자용, 소비자용으로 스레드 대기 집합을 분리
특징
생산자와 소비자 스레드 대기 집합 분리 (condition)
private final Lock lock = new ReentrantLock();
private final Condition producerCond = lock.newCondition();
private final Condition consumerCond = lock.newCondition();
생산자는 소비자를 깨우고 소비자는 생산자를 깨움
생산자 - put()
큐가 가득 찬 경우: producerCond.await()
데이터 저장한 경우: consumerCond.signal()
소비자 - take()
큐가 빈 경우: consumerCond.await()
데이터를 소비한 경우: producerCond.signal()
BlockingQueue
자바는 생산자 소비자 문제 해결을 위해 BlockingQueue 인터페이스와 구현체를 제공
큐가 특정 조건을 만족할 때까지 스레드를 차단할 수 있는 큐 (큐가 가득차거나 비어 있을 때)
실무 멀티스레드는 응답성이 중요하므로, 인터럽트나 타임아웃을 받을 수 있게 설계됨
e.g.
생산자 스레드(서버에 상품을 주문하는 고객)가 고객의 요청을 큐에 넣고
소비자 스레드는 큐에서 주문 요청을 꺼내 처리
선착순 할인 이벤트가 크게 성공해 주문이 폭주하면, 소비가 생산을 따라가지 못하고 큐가 가득 차게 될 수 있음
수 많은 생산자 스레드가 큐 앞에서 대기 (고객도 응답 없이 무한 대기)
너무 오래 기다리지 않고 데이터 추가 포기 및 고객에게 나중에 다시 시도해달라고 응답 보내는게 나은 선택
큐가 가득 찼을 때 생각할 수 있는 4가지 선택
대기 없이 예외 던지기 (Throws Exception)
대기 없이 즉시 false 반환 (Special Value)
대기 (Blocks) - 인터럽트 제공
특정 시간 만큼 대기 (Times Out) - 인터럽트 제공
synchronized와 ReentrantLock의 유사성
생산자 소비자 문제는 5, 60년대 해결된 개념이므로 synchronized와 ReentrantLock은 유사한 모습을 보인다. ReentrantLock이 조금 더 편하게 쓸 수 있게 나왔을 뿐이다.
스레드 대기 집합 (wait set) & 락 대기 집합
자바의 모든 객체 인스턴스는 멀티스레드와 임계 영역을 다루기 위해 모니터 락, 락 대기집합, 스레드 대기 집합 3가지 기본 요소를 가지고 있다. (synchronized 적용 상황)
synchronized에서 스레드의 대기는 wait() 대기, 락 획득 대기 2단계가 존재하며, 스레드 대기 집합은 2차 대기소, 락 대기 집합은 1차 대기소라 볼 수 있다.
만일 임의의 스레드들이 동시에 실행되면, 하나의 스레드가 락을 획득하고 나머지 스레드는 1차 대기소에 들어간다.
또한, 특정 스레드가 wait()을 호출하면 2차 대기소로 들어가고 notify()가 호출되면 2차 대기소에서 나와 락 획득을 시도하며, 락이 없을 경우 1차 대기소로 간다. 즉, 2차 대기소에 있는 스레드는 2차, 1차 대기소를 모두 빠져 나와야 임계 영역을 수행할 수 있다.
스레드 대기 집합은 대기 상태에 들어간 스레드를 관리하는 것이다.
예를 들어, synchronized 임계 영역 안에서 Object.wait()을 호출하면, 스레드는 대기(WAITING) 상태에 들어가고 대기 집합 내에서 관리된다. 이후, 다른 스레드가 Object.notify()를 호출하면 대기 집합에서 빠져나간다.
(참고로, wait() 호출은 앞에 this를 생략할 수 있다. this는 해당 인스턴스를 뜻한다.)
락 대기 집합은 락을 기다리는 BLOCKED 상태의 스레드들을 관리한다. synchronized를 시작할 때, 락이 없으면 BLOCKED 상태로 락 대기 집합에서 대기한다.
ReentrantLock도 마찬가지로 2단계 대기 상태로 동작한다.
다만, 다음의 차이가 있다.
독립적으로 구현된 락, 락 대기 큐, condition(스레드 대기 공간)으로 구성
락 획득 대기 시 WAITING 상태로 대기
condtion.await() 호출 시 스레드 대기 공간에서 대기 (WAITING)
다른 스레드가 condition.signal() 호출 시 스레드 대기 공간 빠져나옴
Doug Lea
동시성 프로그래밍, 멀티스레딩, 병렬 컴퓨팅, 알고리즘 및 데이터 구조 등의 분야에서 많은 업적을 만들었다. 특히, java.util.concurrent 패키지의 주요 설계 및 구현을 주도했다.
java.util.concurrent 패키지가 제공하는 동시성 라이브러리는 견고함 및 성능 최적화에 더불어 개발자가 쉽고 편리하게 동시성 문제를 다룰 수 있게 해준다.
이러한 기여는 자바 동시성 프로그래밍을 크게 발전시키고 현대 자바 프로그래밍의 핵심적 부분이 되었다.
이외에도 Queue, Deque 같은 자료구조에서 Doug Lea의 이름을 찾을 수 있다.
동기화와 원자적 연산 (CAS)
원자적 연산
해당 연산이 더 이상 나눌 수 없는 단위로 수행되는 것
멀티스레드 상황에서 다른 스레드의 간섭 없이 안전하게 처리되는 연산
원자적 연산은 멀티스레드 상황에서 전혀 문제가 없음
원자적 연산이 아닌 경우, synchronized나 Lock 등을 사용해 안전한 임계 영역 만들어야 함
e.g.
i = 1은 원자적 연산 O (대입 연산)
i = i + 1, i++은 원자적 연산 X (3단계: i 값 읽기, 더하기 연산, 대입 연산)
원자적 연산 제공 클래스
자바는 각 타입 별로 멀티스레드 상황에 안전하면서 다양한 값 증가, 감소 연산을 제공
AtomicInteger, AtomicLong, AtomicBoolean, AtomicXxx…
원자적 연산 구현 성능 비교
상황: 1000개 스레드를 사용해 값을 0에서 1000으로 증가시키기
성능 비교
BasicInteger (result: 950, 39ms)
CPU 캐시를 적극 사용하므로 가장 빠름
멀티스레드에서 사용 불가하지만 단일 스레드 사용 시 효율적
VolatileInteger (result: 961, 455ms)
CPU 캐시를 사용하지 않고 메인 메모리 사용해 느려짐
멀티스레드에서 사용 불가
SyncInteger (result: 1000, 625ms)
멀티스레드 상황에서 안전 (synchronized)
MyAtomicInteger보다 느림
MyAtomicInteger (result: 1000, 367ms)
멀티스레드 상황에서 안전 (incrementAndGet(), CAS)
synchronized, Lock(ReentrantLock) 보다 1.5~2배 빠름
CAS 연산 (Compare-And-Swap, Compare-And-Set)
락을 걸지 않고 원자적인 연산 수행 (락 프리(lock-free) 기법)
CPU 하드웨어 차원에서 내리는 특별한 명령
CPU는 잠깐 다른 스레드가 메모리에 write하는 것을 막음
너무 찰나의 시간이므로 락이라 부르진 않음 (성능에 큰 영향 X)
원자적이지 않은 두 과정을 묶어 하나의 원자적 명령으로 만듦 (중간에 다른 스레드 개입 X)
주 메모리에서 값 읽기
기대하는 값이 맞다면 읽은 값을 변경하기 (아니라면 변경 X)
대부분의 현대 CPU가 CAS 연산 명령어 제공
JAVA가 CAS 연산 요청 시 운영체제는 현재 컴퓨터 CPU 종류 확인
(인텔, AMD, MAC…)
그 후 그에 맞는 CAS 연산을 CPU 코어에 명령
자바는 AtomicXxx 클래스에서 CAS 연산 메서드 제공 (compareAndSet())
e.g. compareAndSet(0, 1)
주 메모리 현재 값이 0이라면 1로 변경하고 true 반환
주 메모리 현재 값이 0이 아니라면 변경없이 false 반환
e.g. incrementAndGet() 내부 구현 예시 (CAS 활용)
private static int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
getValue = atomicInteger.get();
log("getValue: " + getValue);
result = atomicInteger.compareAndSet(getValue, getValue + 1);
log("result: " + result);
} while (!result);
return getValue + 1;
}
스레드 충돌이 발생해도 CAS 연산이 성공할 때까지 반복 재시도
덕분에 락 없이 안전한 데이터 변경 가능
작은 단위의 일부 영역에 적용 가능 (락 완전히 대체 X)
락 기반 방식의 문제점 (synchronized, Lock(ReentrantLock))
락 기반 방식은 직관적이지만 무거움
스레드의 상태 변경으로 CPU 스케줄러에 들어갔다 나왔다 하는 무거운 과정 동반
락 획득 및 반납에 시간 소요
락 획득 및 반납하는 과정의 반복
스핀 락(Spin-Lock) - CAS 활용 락 구현
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
log("락 획득 시도");
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
log("락 획득 완료");
}
public void unlock() {
lock.set(false);
log("락 반납 완료");
}
}
스핀 락
락을 획득하기 위해 자원을 소모하면서 반복적으로 확인하는 락 메커니즘
CAS를 사용해서 구현
CAS는 단순한 연산 뿐만 아니라, 가벼운 락 구현에도 사용 가능
락 획득은 원자적이지 않은 임계 영역
락 사용 여부 확인
락의 값 변경
synchronized, Lock 등으로 동기화할 수도 있지만, CAS를 사용하면 원자적 연산 가능
장점
무거운 동기화 작업(락) 없이 아주 가벼운 락을 만들 수 있음
(RUNNABLE 상태를 유지, 빠른 성능 동작)
단점
반복문으로 CPU 자원을 계속 사용하면서 락을 대기 (스핀 대기, 바쁜 대기)
사용 방향
아주 짧은 CPU 연산 수행 시에만 사용해야 효율적
나노 초 단위에서 사용해야 함
e.g. 숫자 값 증가, 자료 구조 데이터 추가
I/O 작업 같이 오래 기다리는 작업에서는 최악
CPU를 계속 사용하며 기다림
e.g. DB 쿼리, 다른 서버 응답 기다리기
보통 I/O 작업은 0.X초 ~ X초까지 걸릴 수 있음 (최소 10ms 이상)
이 경우 일반적인 락을 사용해야 함
동기화 락 방식 VS 락 프리 방식(CAS 활용)
두 방식 모두 안정적인 데이터 변경 보장
동기화 락 방식
비관적 접근법 (pessimistic, 가정: “스레드 충돌이 반드시 일어날 것이다”)
항상 락 획득하고 데이터 접근
다른 스레드의 접근을 막음
스레드를 하나씩 순서대로 돌림
멀티스레드에서 순간적으로 싱글 스레드로 바뀜
장점
하나의 스레드만 리소스에 접근할 수 있으므로 충돌 발생 X
락을 대기하는 스레드는 CPU를 거의 사용 X
단점
락 획득을 위한 대기 시간이 길어질 수 있음
스레드 상태 변경으로 인한 컨텍스트 스위칭 오버헤드
락 프리 방식(CAS 활용)
낙관적 접근법 (optimistic, 가정: “대부분의 경우 충돌이 없을 것이다”)
락을 사용하지 않고 데이터에 바로 접근
어떤 스레드도 멈추지 않음 (10개 스레드면 모두 돌아감)
충돌이 발생하면 그 때 재시도
장점
충돌이 적은 환경에서 높은 성능 발휘
스레드가 블로킹되지 않아(RUNNABLE) 병렬 처리가 더 효율적일 수 있음
단점
충돌이 빈번한 환경이라면 대기 시에도 CPU 자원 계속 소모해 비효율적
실무 사용 전략
기본은 동기화 락을 사용하고 특별한 경우에 CAS를 적용하여 최적화
임계 영역이 필요한 매우 간단한 CPU 연산에만 CAS 연산 사용이 효과적
간단한 CPU 연산은 매우 빨리 처리되므로 충돌이 자주 발생 X
나노 초 단위에서 간단한 연산에서 사용해야 함
e.g. 숫자 값 증가, 자료 구조 데이터 추가
I/O 작업 혹은 몇 초씩 걸리는 복잡한 비즈니스 로직이라면 락 사용
충돌이 어마어마하게 날 것이므로
보통 I/O 작업은 0.X초 ~ X초까지 걸릴 수 있음 (최소 10ms 이상)
e.g. DB 쿼리, 다른 서버 응답 기다리기
실무에서 대부분의 애플리케이션은 공유 자원 사용시, 생각보다 충돌하지 않을 가능성이 훨씬 높음
e.g. 주문 수 실시간 카운트
특정 피크 시간에 주문이 100만건 들어오는 서비스
(1시간 100만건이면 우리나라 탑 서비스)
1,000,000 / 60분 = 1분에 16,666건, 1초에 277건
1초 CPU 연산수 고려하면, 100만 건 중 충돌 나는 경우는 넉넉 잡아도 몇 십건 이하일 것
주문 수 증가 같은 단순한 연산은 AtomicInteger 같은 CAS가 더 나은 성능 보임
CAS 연산과 라이브러리
복잡한 동시성 라이브러리들은 CAS 연산을 사용하지만, 개발자가 직접 사용하는 경우는 거의 없다.
CAS 연산을 사용하는 라이브러리를 잘 사용하는 정도면 충분하다. (AtomicInteger…)
스레드 충돌
두 스레드가 동시에 실행되면서 문제가 발생하는 상황을 말한다.
스레드 세이프
여러 스레드가 동시에 접근해도 괜찮은 경우를 말한다.
동시성 컬렉션
실무 전략
멀티스레드 환경에서 필요한 동시성 컬렉션을 잘 선택해 사용할 수 있으면 충분
단일 스레드에는 일반 컬렉션 사용, 멀티스레드에는 동시성 컬렉션 사용 (성능 트레이드 오프)
기존 컬렉션 프레임워크 (java.util)는 스레드 세이프 X
원자적 연산 제공 X -> 동시성 문제 및 버그 발생
e.g. ArrayList, LinkedList, HashSet, HashMap…
다만, 성능 트레이드 오프로 인해 처음부터 모든 자료구조에 동기화를 해둘 수는 없음
단일 스레드 환경에서 불필요한 동기화는 성능 저하 발생
e.g. java.util.Vector는 현재 거의 사용 X
대안 1: 기존 컬렉션 프레임워크에 synchronized, Lock을 적용해 임계 영역 만들기
컬렉션을 모두 복사해서 동기화 용으로 새로 구현해야하는데 비효율적
구현 변경 시 2곳에서 변경해야 함
대안 2: 프록시가 대신 동기화 기능 처리 (프록시 패턴)
자료구조의 인터페이스를 구현한 프록시 클래스를 만들어 synchronized 적용해 target 호출
클라이언트 -> SyncProxyList (인터페이스 구현 및 synchronized 적용) -> BasicList
자바는 기본 컬렉션을 스레드 세이프하게 만드는 동기화 프록시 기능 제공
Collections를 통해 다양한 synchronized 동기화 메서드 지원
synchronizedList()
synchronizedCollection()
synchronizedMap()
synchronizedSet()
synchronizedNavigableMap()
synchronizedNavigableSet()
synchronizedSortedMap()
synchronizedSortedSet()
장점
기존 코드를 그대로 사용하면서 synchronized만 살짝 추가 가능
예를 들어, SimpleList 인터페이스를 구현한 모든 구현체에 적용 가능
단점
대상 컬렉션 전체에 동기화가 이뤄져 잠금 범위가 넓어짐
동기화 필요 없는 메서드에도 synchronized 적용해야 함
메서드 내 특정 부분에만 정교한 동기화 불가능 (최적화 불가)
대안 3: 자바는 스레드 세이프한 동시성 컬렉션을 제공 (java.util.concurrent, 자바 1.5)
유연하고 성능 최적화된 동기화 전략 사용
일부 메서드에 대해서만 동기화 적용
더욱 정교한 잠금을 통해 성능 최적화
e.g. synchronized , Lock(ReentrantLock), CAS , 분할 잠금 기술(segment lock)
분할 잠금 기술
e.g. 해시맵 -> 버킷마다 락을 분산
다른 버킷에 접근한 스레드는 락 획득을 위해 경쟁하지 않음
같은 버킷 접근해 충돌 시 락 혹은 CAS 기법 적용
종류 (ConcurrentHashMap 가장 많이 사용, 다른 것은 자주 사용 X)
List
CopyOnWriteArrayList : ArrayList 의 대안
Set
CopyOnWriteArraySet : HashSet 의 대안
ConcurrentSkipListSet : TreeSet의 대안
(정렬된 순서 유지, Comparator 사용 가능)
Map
ConcurrentHashMap : HashMap 의 대안
ConcurrentSkipListMap : TreeMap 의 대안
(정렬된 순서 유지, Comparator 사용 가능)
Queue
ConcurrentLinkedQueue : 동시성 큐, 비 차단(non-blocking) 큐
BlockingQueue: 동시성 큐, 스레드 차단(blocking) 큐
ArrayBlockingQueue
크기가 고정된 블로킹 큐
공정(fair) 모드를 사용 가능 (사용 시 성능이 저하될 수 있음)
LinkedBlockingQueue
크기가 무한하거나 고정된 블로킹 큐
PriorityBlockingQueue
우선순위가 높은 요소를 먼저 처리하는 블로킹 큐
SynchronousQueue
데이터를 저장하지 않는 블로킹 큐
생산자가 데이터를 추가하면 소비자가 그 데이터를 받을 때까지 대기
중간에 큐 없이 생산자, 소비자가 직접 거래
생산자-소비자 간의 직접적인 핸드오프(hand-off) 메커니즘을 제공
DelayQueue
지연된 요소를 처리하는 블로킹 큐 (지정된 지연 시간이 지난 후 소비)
일정 시간이 지난 후 작업을 처리해야 하는 스케줄링 작업에 사용
Deque
ConcurrentLinkedDeque : 동시성 덱, 비 차단(non-blocking) 큐
LinkedHashSet, LinkedHashMap의 동시성 컬렉션은 제공 X
필요하다면 Collections.synchronizedXxx() 사용할 것
스레드 풀 (Thread Pool)
스레드 직접 사용의 문제점
스레드 생성 비용으로 인한 성능 문제
스레드 생성 = 스레드 객체 생성 (new Thread()) + 스레드 시작 (thread.start())
new Thread는 단순히 자바 객체만 생성하는 것
thread.start() 호출 시 실제 스레드 생성 (메모리 할당, 시스템 콜, 스케줄링…)
스레드 생성은 매우 무거운 작업 (스레드 하나는 보통 1MB 이상의 메모리 사용)
메모리 할당: 스레드 생성 시 호출 스택을 위한 메모리 공간을 할당해야 함
운영체제 자원 사용: 운영체제 커널 수준에서 시스템 콜을 통해 처리 (CPU와 메모리 소모)
운영체제 스케줄러 설정: 새 스레드를 관리하고 실행 순서 조정
스레드를 재사용하면 효율적일 것
스레드 생성은 단순 자바 객체 생성보다 비교할 수 없을 정도로 큰 작업
아주 가벼운 작업이라면, 작업 실행 시간보다 스레드 생성 시간이 더 오래 걸릴 수 있음
스레드를 재사용하면 처음 생성 후에는 생성 시간 없이 아주 빠르게 작업 수행 가능
스레드 관리 문제
시스템이 버틸 수 있는 최대 스레드 수까지만 스레드를 생성할 수 있게 관리해야 함
서버의 CPU, 메모리 자원이 한정되어 있으므로, 스레드 무한 생성 불가
애플리케이션 종료 시에도 스레드 관리 필요
실행 중 스레드가 남은 작업을 모두 수행한 후 프로그램 종료하도록 관리
급하게 종료해야할 때는 인터럽트를 통해 바로 스레드를 종료하도록 관리
Runnable 인터페이스의 불편함
반환 값이 없음
run() 메서드에 반환 값 X -> 스레드 실행 결과를 직접 받을 수 없음
e.g. 스레드 실행 결과를 멤버 변수에 넣어두고 join()으로 기다린 후 보관 값을 사용
예외 처리
체크 예외를 던질 수 없어 메서드 내부에서 반드시 처리해야 함
스레드 풀
스레드를 생성하고 관리하는 풀
단순히 컬렉션에 스레드를 보관하고 재사용하는 것이지만, 구현은 복잡
스레드 상태 관리 (WAITING, RUNNABLE)
생산자 소비자 문제 (스레드 풀의 스레드가 소비자)
Executor 프레임워크 사용시 편리하게 사용 가능
작업 흐름
스레드를 필요한만큼 미리 생성
작업 요청이 오면 이미 만들어진 스레드를 조회해 작업 처리
작업 완료 후, 스레드를 재사용할 수 있도록 스레드 풀에 다시 반납
스레드 풀을 사용하면 스레드 생성 및 관리 문제 해결
재사용을 통해 스레드 생성 시간을 절약
필요한 만큼만 스레드를 만들고 관리
Executor 프레임워크 (스레드 사용 시 실무 권장)
자바 멀티스레드를 쉽고 편리하게 사용하도록 돕는 프레임워크
작업 실행 관리, 스레드 풀 관리, 스레드 상태 관리, Runnable 한계, 생산자 소비자 문제…
개발자가 직접 스레드 생성 및 관리하는 복잡함을 줄임
Future 패턴(Callable)은 마치 싱글 스레드 방식으로 개발하는 느낌
스레드 생성이나 join()으로 제어하는 코드가 없음
단순히 ExecutorService 에 필요한 작업을 요청하고 결과를 받아서 쓰면 된다!
주요 구성 요소 1
최상위 Executor 인터페이스
public interface Executor {
void execute(Runnable command);
}
ExecutorService 인터페이스 (주로 사용)
public interface ExecutorService extends Executor, AutoCloseable {
<T> Future<T> submit(Callable<T> task);
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException
@Override
default void close(){...}
...
}
주요 메서드로 작업 제출과 제어 기능 추가 제공
작업 단건 처리 - submit(), Future.get()
작업 컬렉션 처리 - invokeAll(), invokeAny()
여러 작업을 한 번에 요청하고 처리하는 메서드 제공
invokeAll()
List<CallableTask> tasks = List.of(taskA, taskB, taskC);
List<Future<Integer>> futures = es.invokeAll(tasks); //이 코드에서 메인스레드가 블로킹됨
for (Future<Integer> future : futures) {
Integer value = future.get();
log("value = " + value);
}
모든 Callable 작업이 완료될 때까지 기다림
타임아웃 설정도 가능
invokeAny()
List<CallableTask> tasks = List.of(taskA, taskB, taskC);
Integer value = es.invokeAny(tasks); //이 코드에서 메인 스레드가 블로킹됨
하나의 Callable 작업이 완료될 때까지 기다리고, 가장 먼저 완료된 작업 결과 반환
완료되지 않은 나머지 작업은 인터럽트를 통해 취소함
타임아웃 설정도 가능
ThreadPoolExecutor (ExecutorService의 기본 구현체)
크게 스레드풀 + 블로킹 큐로 구성
기본 사용 예시
ExecutorService es = new ThreadPoolExecutor(2,2,0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
//ExecutorService es = Executors.newFixedThreadPool(2); //편의 코드
es.execute(new RunnableTask("taskA"));
es.execute(new RunnableTask("taskB"));
es.close()
생산자 (main 스레드)
es.execute(작업) 호출 시, 작업 인스턴스를 내부 BlockingQueue에 보관
소비자 (스레드 풀에 있는 스레드)
소비자 중 하나가 BlockingQueue에 들어 있는 작업을 받아 처리
작업 과정
ThreadPoolExecutor 생성 시점에는 스레드 풀에 스레드를 미리 만들지 않음
es.execute(작업) 호출로 작업이 올 때마다 corePoolSize 까지 스레드 생성
생산자 스레드는 작업만 전달하고 다음 코드 수행 (Non-Blocking)
corePoolSize 까지 생성하고 나면, 이후 스레드 재사용
작업이 완료되면 스레드 풀에 스레드 반납 (= 스레드 상태 변경)
= 스레드가 WAITING 상태로 스레드 풀에서 대기
반납된 스레드는 재사용
close() 호출 시, ThreadPoolExecutor 종료
스레드 풀에 대기하는 스레드도 함께 제거
생성자 사용 속성
corePoolSize
스레드 풀에서 관리되는 기본 스레드의 수
요청이 들어올 때마다 하나씩 생성
maximumPoolSize
스레드 풀에서 관리되는 최대 스레드 수
요청이 너무 많거나 급한 경우 최대 수만큼 초과 스레드 생성해 사용
급한 경우: 큐까지 가득찼는데 새로운 작업 요청이 오는 경우
keepAliveTime , TimeUnit unit
기본 스레드 수를 초과해서 만들어진 스레드가 생존할 수 있는 대기 시간
이 시간 동안 초과 스레드가 처리할 작업이 없다면 초과 스레드는 제거
BlockingQueue workQueue : 작업을 보관할 블로킹 큐 (생산자 소비자 문제 해결)
스레드 풀 상태 확인 메서드
getPoolSize(); //스레드 풀에서 관리되는 스레드의 숫자
getActiveCount(); //작업을 수행하는 스레드의 숫자
getQueue().size(); //큐에 대기중인 작업의 숫자
getCompletedTaskCount(); //완료된 작업의 숫자
주요 구성 요소 2 - Runnable 사용의 불편함 해소
Callable 인터페이스 - java.util.concurrent
public interface Callable<V> {
V call() throws Exception;
}
ExecutorService의 submit() 메서드를 통해 작업으로 전달
Runnable을 대신해 작업 정의 가능
call() 메서드는 값 반환 가능 (제네릭 V 반환 타입)
throws Exception 예외가 선언되어 있어 체크 예외를 던질 수 있음
Future 인터페이스
public interface Future<V> {
//아직 완료되지 않은 작업 취소하고 Future를 취소 상태로 변경 (CACELLED)
//작업이 큐에 아직 있다면 취소 상태로 변경하는 것만으로도 작업이 수행되지 않음
//cancel(true): 작업이 실행 중이면 Thread.interrupt() 호출해 작업 중단
//cancel(false): 이미 실행 중인 작업은 중단하지 않음
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled(); //작업이 취소되었는지 여부 확인
boolean isDone(); //작업 완료 여부 확인 (작업완료: 정상완료, 취소, 예외종료)
//작업 완료까지 대기(Blocking), 완료되면 결과 반환
V get() throws InterruptedException, ExecutionException;
//get()과 동일, 시간 초과되면 예외 발생시킴
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
enum State {
RUNNING,
SUCCESS,
FAILED,
CANCELLED
}
default State state() {} //Future의 상태 반환
}
전달한 작업의 미래 결과를 받을 수 있는 객체
ExecutorService의 submit() 메서드 반환 타입
Future 객체는 내부에 3가지를 보관
작업(Callable 인스턴스)
작업의 완료 여부 (완료 상태)
작업의 결과값 (call() 메서드가 반환할 결과)
필요성
전달한 작업의 결과는 즉시 받을 수 없음
Callable을 submit()하면 미래 어떤 시점에 스레드풀의 스레드가 실행할 것
따라서, 언제 실행이 완료되어 결과를 반환할지 알 수 없음
Future 반환 덕분에 요청 스레드는 블로킹 되지 않고 필요한 작업 수행 가능
즉, 필요한 여러 작업을 ExecutorService에 계속 요청 가능 (동시작업 가능)
모든 작업 요청이 끝난 후, 필요할 때 get()을 호출해서 최종 결과 받으면 됨
반면에, 직접 결과값 반환 설계는 요청스레드가 블로킹됨
즉, 한 작업을 요청하면 블로킹되어 다른 작업을 이어 요청할 수 없음
FutureTask
Future의 실제 구현체
Future 인터페이스 뿐만 아니라 Runnable 인터페이스도 함께 구현
run() 메서드가 작업의 call() 메서드를 호출하고 그 결과를 받아 처리
기본 사용 예시
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService es = Executors.newFixedThreadPool(1); //편의 메서드
Future<Integer> future = es.submit(new MyCallable());
Integer result = future.get();
es.close();
}
static class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
int value = new Random().nextInt(10);
return value;
}
}
요청 스레드가 submit(작업) 호출하면 즉시 Future 객체 반환
ExecutorService는 Future 객체(FutureTask)를 생성
생성된 Future를 블로킹 큐에 전달 - 나중에 스레드 풀의 스레드가 처리
요청 스레드에게 Future 객체를 반환
스레드 풀의 스레드가 큐에 Future 객체를 꺼내서 작업 수행
FutureTask.run() 호출 -> MyCallable.call() 호출
요청 스레드는 본인이 필요할 때 future.get()을 호출한다. 이 때,
Future가 완료 상태
요청스레드는 대기 없이 바로 결과값 반환 받음
Future가 미완료 상태
요청 스레드가 결과 받기 위해 블로킹 상태로 대기 (RUNNABLE -> WAITING)
스레드 풀의 소비자 스레드는 작업이 완료되면
Future에 작업 결과값 담음
Future 상태를 완료로 변경
요청 스레드를 깨움 (WAITING -> RUNNABLE)
Future가 어떤 요청 스레드가 대기하는지 알고 있음
요청 스레드가 완료 상태 Future에서 결과를 반환 받음
작업 완료 소비자 스레드는 스레드 풀로 반환 (RUNNABLE -> WAITING)
Future 요청 예시
바른 예시 - 수행 시간 2초
Future<Integer> future1 = es.submit(task1); // non-blocking
Future<Integer> future2 = es.submit(task2); // non-blocking
Integer sum1 = future1.get(); // blocking, 2초 대기
Integer sum2 = future2.get(); // blocking, 즉시 반환
잘못된 예시 1 - 수행 시간 4초
Future<Integer> future1 = es.submit(task1); // non-blocking
Integer sum1 = future1.get(); // blocking, 2초 대기
Future<Integer> future2 = es.submit(task2); // non-blocking
Integer sum2 = future2.get(); // blocking, 2초 대기
잘못된 예시 2 - 수행 시간 4초
Integer sum1 = es.submit(task1).get(); // get()에서 블로킹
Integer sum2 = es.submit(task2).get(); // get()에서 블로킹
close() VS shutdown()
close()는 자바 19부터 지원되는 메서드다. 19 미만 버전을 사용한다면 shutdown()을 호출해야 한다.
블로킹 메서드
어떤 스레드가 결과를 얻기 위해 대기하는 것을 블로킹(Blocking)이라고 한다. 이 때, 스레드의 상태는 BLOCKED, WAITING에 해당한다.
그리고 Thread.join(), Future.get() 같이 다른 작업이 완료될 때까지 호출한 스레드를 대기하게 하는 메서드를 블로킹 메서드라고 한다.
우아한 종료 (Graceful Shutdown) - ExecutorService
실무 전략
기본적으로 우아한 종료를 선택 (보통 60초로 우아한 종료 시간 지정)
시간안에 우아한 종료가 되지 않으면 다음으로 강제 종료 시도
우아한 종료 (Graceful Shutdown)
문제 없이 안정적으로 종료하는 방식
e.g. 서버 재시작 시 새로운 요청은 막고, 이미 진행중인 요청은 모두 완료한 후 재시작하는게 이상적
ExecutorService 종료 메서드
서비스 종료 -> 풀의 스레드 자원 정리
shutdown()
새로운 작업을 받지 않고, 이미 제출된 작업을 완료한 후 종료
이미 제출된 작업 = 처리중 작업 + 큐에 남아있는 작업
서비스 정상 종료 시도 (이상적)
Non-Blocking 메서드
List<Runnable> shutdownNow()
인터럽트를 통해 실행 중인 작업을 중단하고, 대기 중인 작업을 반환하며 즉시 종료
새로운 요청 거절 + 실행중 작업 중단 + 큐에 남아있는 작업은 반환
FutureTask 반환 (FutureTask는 Runnable을 구현한 것)
서비스 강제 종료 시도
Non-Blocking 메서드
close()
자바 19부터 지원, shutdown()과 동일
shutdown() 호출 후, 하루를 기다려도 작업이 미완료면 shutdownNow() 호출
실용적인 방식이지만, 하루는 너무 김
호출 스레드에 인터럽트가 발생해도 shutdownNow() 호출
서비스 상태 확인
boolean isShutdown()
서비스 종료 여부 확인
boolean isTerminated()
shutdown() , shutdownNow() 호출 후, 모든 작업이 완료되었는지 확인
작업 완료 대기
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException
서비스 종료 시 모든 작업이 완료될 때까지 대기 (지정 시간까지만 대기)
shutdown() 류의 서비스 종료와 함께 사용
Blocking 메서드
실무 구현 (shutdownAndAwaitTermination()) - ExecutorService 공식 API 문서 제안 방식
public static void main(String[] args) throws InterruptedException {
ExecutorService es = Executors.newFixedThreadPool(2);
es.execute(new RunnableTask("taskA"));
es.execute(new RunnableTask("taskB"));
es.execute(new RunnableTask("taskC"));
es.execute(new RunnableTask("longTask", 100_000)); // 100초 대기
log("== shutdown 시작 ==");
shutdownAndAwaitTermination(es);
log("== shutdown 완료 ==");
}
static void shutdownAndAwaitTermination(ExecutorService es) {
// non-blocking, 새로운 작업을 받지 않는다.
// 처리 중이거나, 큐에 이미 대기중인 작업은 처리한다. 이후에 풀의 스레드를 종료한다.
es.shutdown();
try {
// 이미 대기중인 작업들을 모두 완료할 때 까지 10초 기다린다.
log("서비스 정상 종료 시도");
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
// 정상 종료가 너무 오래 걸리면...
log("서비스 정상 종료 실패 -> 강제 종료 시도");
es.shutdownNow();
// 작업이 취소될 때 까지 대기한다. 인터럽트 이후 자원정리 등이 존재할 수 있음
if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
//이 구간이 되면 자바를 강제종료 해야함
//최악의 경우 스레드가 인터럽트를 받을 수 없는 코드 수행 중일 수 있음
//이런 스레드는 자바를 강제 종료해야 제거할 수 있음
//e.g. while(true) {...}
//로그를 남겨두고 추후 문제 코드 수정
log("서비스가 종료되지 않았습니다.");
}
}
} catch (InterruptedException ex) {
// awaitTermination()으로 대기중인 현재 스레드가 인터럽트 될 수 있다.
es.shutdownNow();
}
}
우아한 종료가 이상적이지만 서비스가 너무 늦게 종료되거나 종료되지 않는 문제 발생 가능
갑자기 많은 요청으로 큐에 대기중인 작업이 많아 작업 완료가 어려움
작업이 너무 오래 걸림
버그 발생으로 특정 작업이 안끝남
보통 60초까지 우아하게 종료하는 시간을 정하고, 넘어가면 작업 강제 종료 시도
Executor 프레임워크의 스레드 풀 관리
대량의 요청을 별도의 스레드에서 어떻게 처리해야하는지에 대한 기본기
스레드 풀 관리 사이클
corePoolSize 크기까지는 작업 요청이 올 때마다 스레드를 생성하고 바로 작업 실행
corePoolSize를 초과하면 큐에 작업을 넣음
큐를 초과하면 maximumPoolSize 크기까지만 요청이 올 때마다 초과 스레드를 생성하고 작업 실행
maximumPoolSize를 초과하면 요청이 거절되고 예외 발생 (RejectedExecutionException)
즉, 큐도 가득차고 풀 최대 생성 가능한 스레드 수도 가득 차서 작업을 받을 수 없음
초과스레드는 지정 시간까지 작업 없이 대기하면 제거됨
긴급한 작업들이 끝난 것
shutdown() 진행 시 풀의 스레드가 모두 제거됨
예시 코드
public class PoolSizeMain {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
ExecutorService es = new ThreadPoolExecutor(2, 4, 3000,
TimeUnit.MILLISECONDS, workQueue);
printState(es);
es.execute(new RunnableTask("task1"));
printState(es, "task1");
es.execute(new RunnableTask("task2"));
printState(es, "task2");
es.execute(new RunnableTask("task3"));
printState(es, "task3");
es.execute(new RunnableTask("task4"));
printState(es, "task4");
es.execute(new RunnableTask("task5"));
printState(es, "task5");
es.execute(new RunnableTask("task6"));
printState(es, "task6");
try {
es.execute(new RunnableTask("task7"));
} catch (RejectedExecutionException e) {
log("task7 실행 거절 예외 발생: " + e);
}
sleep(3000);
log("== 작업 수행 완료 ==");
printState(es);
sleep(3000);
log("== maximumPoolSize 대기 시간 초과 ==");
printState(es);
es.close();
log("== shutdown 완료 ==");
printState(es);
}
}
//실행 결과
11:36:23.260 [main] [pool=0, active=0, queuedTasks=0, completedTasks=0] 11:36:23.263 [pool-1-thread-1] task1 시작
11:36:23.267 [main] task1 -> [pool=1, active=1, queuedTasks=0, completedTasks=0]
11:36:23.267 [main] task2 -> [pool=2, active=2, queuedTasks=0, completedTasks=0]
11:36:23.267 [pool-1-thread-2] task2 시작
11:36:23.267 [main] task3 -> [pool=2, active=2, queuedTasks=1, completedTasks=0]
11:36:23.268 [main] task4 -> [pool=2, active=2, queuedTasks=2,
completedTasks=0]
11:36:23.268 [main] task5 -> [pool=3, active=3, queuedTasks=2,
completedTasks=0]
11:36:23.268 [pool-1-thread-3] task5 시작
11:36:23.268 [main] task6 -> [pool=4, active=4, queuedTasks=2, completedTasks=0]
11:36:23.268 [pool-1-thread-4] task6 시작
11:36:23.268 [main] task7 실행 거절 예외 발생: java.util.concurrent.RejectedExecutionException: Task thread.executor.RunnableTask@3abbfa04 rejected from java.util.concurrent.ThreadPoolExecutor@7f690630[Running, pool size = 4, active threads = 4, queued tasks = 2, completed tasks = 0]
11:36:24.268 [pool-1-thread-1] task1 완료
11:36:24.268 [pool-1-thread-1] task3 시작
11:36:24.269 [pool-1-thread-3] task5 완료
11:36:24.269 [pool-1-thread-3] task4 시작
11:36:24.269 [pool-1-thread-2] task2 완료
11:36:24.269 [pool-1-thread-4] task6 완료
11:36:25.273 [pool-1-thread-1] task3 완료
11:36:25.273 [pool-1-thread-3] task4 완료
11:36:26.273 [main] ==작업수행완료==
11:36:26.273 [main] [pool=4, active=0, queuedTasks=0, completedTasks=6]
11:36:29.276 [main] == maximumPoolSize 대기 시간 초과 ==
11:36:29.277 [main] [pool=2, active=0, queuedTasks=0, completedTasks=6]
11:36:29.278 [main] == shutdown 완료 ==
11:36:29.278 [main] [pool=0, active=0, queuedTasks=0, completedTasks=6]
스레드 미리 생성하기
서버는 고객의 첫 요청을 받기 전에 스레드 풀에 스레드를 미리 생성해두길 권장
처음 요청시 스레드 생성시간을 줄여 응답시간 빨라짐
처음 서버 올릴 때 CPU가 치고 올라오므로 미리 스레드 생성해두는게 좋음
ThreadPoolExecutor.prestartAllCoreThreads()
기본 스레드 미리 생성
ExecutorService는 해당 메서드 제공 X
스레드 풀 관리 전략
실무 전략 선택 - 개발자의 시간 아끼기
일반적인 상황이라면 고정 스레드 풀 전략이나 캐시 스레드 풀 전략 선택으로 충분
한 번에 처리할 수 있는 수를 제한해 안정적으로 처리 - 고정 풀 전략
돈이 많다면 풀의 수를 많이 늘리는 전략도 가능 (안정 + 사용자 요청 빠르게 대응)
사용자 요청에 빠르게 대응 - 캐시 스레드 풀 전략
일반 상황을 벗어날 정도로 서비스가 잘 운영되면, 그 때 최적화 시도 (사용자 정의 풀 전략)
각 전략의 특징
고정 스레드 풀 전략: 트래픽이 일정하고, 시스템 안전성이 가장 중요한 서비스
캐시 스레드 풀 전략: 일반적인 성장하는 서비스
사용자 정의 풀 전략: 다양한 상황에 대응
단일 스레드 풀 전략 (newSingleThreadPool())
스레드 풀에 기본 스레드 1개만 사용
큐 사이즈 제한 X (LinkedBlockingQueue)
간단한 사용 및 테스트 용도
고정 스레드 풀 전략 (newFixedThreadPool(nThreads))
스레드 풀에 nThreads 만큼의 기본 스레드 생성 (초과 스레드는 생성 X)
큐 사이즈 제한 X (LinkedBlockingQueue)
스레드 수가 고정되어 있어 CPU, 메모리 리소스가 어느정도 예측 가능한 안정적인 방식
장점
일반적인 상황에서 가장 안정적으로 서비스를 운영할 수 있음
단점
서버 자원(CPU, 메모리)이 여유가 있음에도 사용자가 증가하면 응답이 느려짐
실행되는 스레드 수가 고정되어 있어 요청 처리 시간 보다 큐에 쌓이는 시간이 빠름
e.g. 큐에 10000건 쌓여 있고, 고정 스레드 수가 10, 작업 처리 시간 1초
모든 작업 처리 시 1000초 걸림
서비스 초기 사용자 적을 때는 문제 없지만 사용자가 많아지면 문제
점진적 사용자 확대 시 서비스 응답이 점점 느려짐
갑작스런 요청 증가 시 고객이 응답을 받지 못함
캐시 스레드 풀 전략 (newCachedThreadPool())
기본 스레드를 사용하지 않고 60초 생존 주기를 가진 초과 스레드만 사용 (스레드 수 제한 X)
corePoolSize가 0, SynchronousQueue는 작업 넣기 불가, maxPoolSize는 무한
큐에 작업을 저장하지 않음 (SynchronousQueue, 저장 공간이 0인 특별한 큐)
생산자의 요청을스레드 풀의 소비자 스레드가 직접 받아서 바로 처리
모든 작업이 대기 없이 작업 수 만큼 초과 스레드가 생기면서 바로 실행
중간에 버퍼를 두지 않는 스레드 간 직거래
빠른 처리 O
장점
서버 자원을 최대로 사용할 수 있어 매우 빠름 (초과 스레드 수 제한 X)
작업 요청 수에 따라 스레드가 증감되어 유연한 처리 (생존 주기 내 스레드 적절히 재사용)
점진적 사용자 확대 시 크게 문제 되지 않음
사용자 증가 -> 스레드 사용량 증가 -> CPU, 메모리 사용량 증가
서버 자원에 한계를 고려해 적절한 시점에 시스템 증설 필요
단점
갑작스런 요청 증가 시 서버 자원의 임계점을 넘는 순간 시스템이 다운될 수 있음
사용자 급증 -> 스레드 수 급증 -> CPU, 메모리 사용량 급증 -> 시스템 전체 느려짐
너무 많은 스레드에 시스템이 잠식되어 장애 발생
수 천개 스레드가 처리하는 속도 보다 더 많은 작업 들어옴
수 천개 스레드로 메모리도 가득참 (1개 스레드는 1MB 이상)
사용자 정의 스레드 풀 관리 전략
목표
점진적인 사용자 확대 상황 처리
갑작스런 요청 증가 상황 처리
어떤 경우도 서버가 다운되어서는 안됨
전략
일반: 고정 크기 스레드로 서비스를 안정적으로 운영 (CPU, 메모리 예측 가능)
긴급: 사용자 요청 급증 시 초과 스레드 추가 투입
긴급 상황 때는 스레드 수가 늘어나므로 작업 처리 속도도 더 빨라짐
시스템 자원을 고려해 적정한 maxPoolSize를 설정해야함
거절: 긴급 대응도 어렵다면 추가되는 사용자 요청 거절
즉, 큐가 가득차고 초과 스레드도 모두 사용 중인데 작업이 더 들어오는 상황
= 처리 속도가 높아졌음에도 작업이 빠르게 소모되지 않는 상황
= 시스템이 감당하기 어려운 많은 요청이 들어오고 있는 것
구현 예시
ExecutorService es = new ThreadPoolExecutor(100, 200, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000));
100개 기본 스레드
긴급 대응 초과 스레드 100개(60초 생존)
1000개 작업 가능한 큐
하나의 작업은 1초 걸림
일반 상황: static final int TASK_SIZE = 1100;
100개 기본 스레드로 처리
작업 처리 시간: 1100 / 100 = 11초
긴급 상황: static final int TASK_SIZE = 1200;
100개 기본 스레드 + 100개 초과 스레드로 처리
작업 처리 시간: 1200 / 200 = 6초
긴급 투입 스레드 덕분에 풀의 스레드 수가 2배가 되어 작업 2배 빠르게 처리
거절 상황: static final int TASK_SIZE = 1201;
100개 기본 스레드 + 100개 초과 스레드로 처리
1201번째 작업은 거절 (예외 발생)
실무 주의사항
new ThreadPoolExecutor(100, 200, 60, TimeUnit.SECONDS, new LinkedBlockingQueue());
큐 사이즈: 무한대
큐 사이즈를 무한대로 해서는 절대로 안됨!
큐가 가득차야 긴급 상황으로 인지 가능
큐 사이즈가 무한대면 큐가 가득찰 수 없음
기본 스레드 100개만으로 무한대 작업을 처리하는 문제 발생
가장 좋은 최적화는 최적화하지 않는 것
예측 불가능한 먼 미래보다는 현재 상황에 맞는 최적화가 필요하다. 발생하지 않을 일에 최적화 시간을 쏟다가 버리는 경우가 많기 때문이다.
제일 비싼 자원은 개발자의 인건비다. 최적화에 쏟는 시간보다 간단히 서버 증설하는게 저렴할 수도 있다.
따라서, 중요한 것은 모니터링 환경을 잘 구축하는 것이다.
성장하는 서비스를 포함해 대부분의 서비스는 트래픽이 어느정도 예측 가능하다.
서비스 운영이 정말 잘되어 특출나게 유의미한 트래픽 상승이 예상된다면 그 때 최적화하자.
다만, 어떤 경우에도 절대 시스템이 다운되지 않도록 해야한다!
Executor 거절 정책
ThreadPoolExecutor는 소비자가 처리할 수 없을 정도로 생산 요청이 가득 차면 후속 작업을 거절함
ThreadPoolExecutor는 작업을 거절하는 다양한 정책 제공
설정 방법
ThreadPoolExecutor 생성자 마지막에 원하는 정책을 인자로 전달
RejectedExecutionHandler 구현체 전달
ThreadPoolExecutor는 거절 상황이 발생 시 rejectedExecution() 호출
e.g.
ExecutorService executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new SynchronousQueue<>(), ThreadPoolExecutor.AbortPolicy());
정책 종류 (RejectedExecutionHandler 구현체)
AbortPolicy (Default)
추가 작업 거절시 RejectedExecutionException 예외 발생시킴
개발자는 예외를 잡아서 작업을 포기하거나, 사용자에게 알리거나, 다시 시도 등 구현 가능
DiscardPolicy
추가 작업을 조용히 버림
CallerRunsPolicy
거절하지 않고 추가 작업을 제출하는 스레드가 대신해서 작업을 실행
= 소비자 스레드가 없어서 생산자 스레드가 작업을 대신 처리
해당 생산자 스레드의 속도가 저하될 수 있음 (Blocking)
= 작업 생산 속도가 너무 빠를 때, 작업의 생산 속도를 늦출 수 있음
사용자 정의
개발자가 직접 정의한 거절 정책 사용 가능 (RejectedExecutionHandler 구현)
e.g.
static class MyRejectedExecutionHandler implements RejectedExecutionHandler {
static AtomicInteger count = new AtomicInteger(0);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
int i = count.incrementAndGet();
log("[경고] 거절된 누적 작업 수: " + i);
}
}
Reference
김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성
Backend 멀티쓰레드 이해하고 통찰력 키우기
-
자바 Collection Framework
자바 컬렉션 프레임 워크
자바는 컬렉션 프레임워크를 통해 다양한 자료구조를 인터페이스, 구현, 알고리즘으로 지원
데이터 컬렉션을 효율적으로 저장하고 처리하기 위한 통합 아키텍처 제공 (컬렉션 = 자료를 모아둔 것)
핵심 인터페이스
Collection
단일 루트 인터페이스로 모든 컬렉션 클래스가 상속 받음
필요성
가장 기본적인 인터페이스로 다양한 컬렉션 타입이 공통적으로 따라야하는 기본 규약 정의
이같은 설계는 일관성, 재사용성, 확장성 향상시키고 다형성 이점 제공
주요 메서드
add(E e) : 컬렉션에 요소를 추가
remove(Object o) : 주어진 객체를 컬렉션에서 제거
size() : 컬렉션에 포함된 요소의 수를 반환
isEmpty() : 컬렉션이 비어 있는지 확인
contains(Object o) : 컬렉션이 특정 요소를 포함하고 있는지 확인
iterator() : 컬렉션의 요소에 접근하기 위한 반복자를 반환
clear() : 컬렉션의 모든 요소를 제거
List
순서가 있는 컬렉션
중복 O
인덱스 통한 요소 접근 O
구현
ArrayList(주로 사용): 내부적으로 배열 사용
LinkedList: 내부적으로 연결 리스트 사용
Set
중복을 허용하지 않는 컬렉션
인덱스 통한 요소 접근 X
구현
HashSet(주로 사용): 내부적으로 해시 테이블 사용
LinkedHashSet: 내부적으로 해시 테이블과 연결리스트 사용
TreeSet: 내부적으로 레드-블랙 트리 사용
Queue
요소 처리 전 보관하는 컬렉션
구현
ArrayDeque(주로 사용): 내부적으로 배열 기반 원형 큐 사용 (대부분의 경우 빠름)
LinkedList: 내부적으로 연결리스트 사용
PriorityQueue
Map (Collection 상속 X)
키와 값 쌍으로 요소를 저장하는 객체
구현
HashMap(주로 사용): 내부적으로 해시 테이블 사용
LinkedHashMap: 내부적으로 해시 테이블과 연결리스트 사용
TreeMap: 내부적으로 레드-블랙 트리 사용
알고리즘
컬렉션 프레임워크는 데이터 처리 및 조작 알고리즘 제공 (정렬, 검색, 순환, 변환 등)
제공 방법
자료구조 자체적으로 기능 제공
Collections 와 Arrays 클래스에 정적 메서드 형태로 구현
실무 선택 전략
순서가 중요 O, 중복 허용 O 경우: List 인터페이스를 사용
ArrayList 선택 (주로 사용)
추가/삭제 작업이 앞쪽에서 빈번하다면 LinkedList (성능상 더 좋은 선택)
중복 허용 X 경우: Set 인터페이스 사용
순서가 중요하지 않다면 HashSet (주로 사용)
순서를 유지해야 하면 LinkedHashSet
정렬된 순서가 필요하면 TreeSet
요소를 키-값 쌍으로 저장하려는 경우: Map 인터페이스를 사용
순서가 중요하지 않다면 HashMap (주로 사용)
순서를 유지해야 한다면 LinkedHashMap
정렬된 순서가 필요하면 TreeMap
요소를 처리하기 전에 보관해야 하는 경우: Queue , Deque 인터페이스를 사용
ArrayDeque 선택 (주로 사용, 스택/큐 구조 모두에서 가장 빠름)
우선순위에 따라 요소를 처리해야 한다면 PriorityQueue
배열 (Array)
순서가 있고 중복을 허용하면서 크기가 정적으로 고정된 자료구조
가장 기본적인 자료구조
특징
데이터가 메모리 상에 순서대로 붙어서 존재
검색은 배열의 데이터를 하나하나 확인해야해서 한번에 찾을 수 없음
장점
인덱스 사용 시 최고의 효율
데이터가 아무리 많아도 인덱스는 한번의 계산으로 빠르게 자료 위치 찾음
공식: 배열의 시작 참조 + (자료의 크기 * 인덱스 위치)
arr[0]: x100 + (4byte * 0): x100
arr[1]: x100 + (4byte * 1): x104
arr[2]: x100 + (4byte * 2): x108
단점
배열의 크기가 생성하는 시점에 정적으로 정해짐
처음부터 많이 확보하면 메모리 낭비
데이터 추가가 불편
기존 데이터가 오른쪽으로 한 칸씩 이동해야 함
시간 복잡도
데이터 추가
앞, 중간에 추가: O(N)
마지막에 추가: O(1)
데이터 삭제
앞, 중간에 삭제: O(N)
마지막에 삭제: O(1)
인덱스 조회, 입력, 변경: O(1)
데이터 검색: O(N)
리스트 (List)
순서가 있고 중복을 허용하면서 크기가 동적으로 변하는 자료구조
주요 메서드
add(E e): 리스트의 끝에 지정된 요소 추가
add(int index, E element): 리스트의 지정된 위치에 요소를 삽입
addAll(Collection<? extends E> c): 지정된 컬렉션의 모든 요소를 리스트의 끝에 추가
addAll(int index, Collection<? extends E> c): 지정된 컬렉션의 모든 요소를 리스트의 지정된 위치에 추가
get(int index): 리스트에서 지정된 위치의 요소를 반환
set(int index, E element): 지정한 위치의 요소를 변경하고, 이전 요소를 반환
remove(int index): 리스트에서 지정된 위치의 요소를 제거하고 그 요소를 반환
remove(Object o): 리스트에서 지정된 첫 번째 요소를 제거
clear(): 리스트에서 모든 요소를 제거
indexOf(Object o): 리스트에서 지정된 요소의 첫 번째 인덱스를 반환
lastIndexOf(Object o): 리스트에서 지정된 요소의 마지막 인덱스를 반환
contains(Object o): 리스트가 지정된 요소를 포함하고 있는지 여부를 반환
sort(Comparator<? super E> c): 리스트의 요소를 지정된 비교자에 따라 정렬
subList(int fromIndex, int toIndex): 리스트의 일부분의 뷰를 반환
size(): 리스트의 요소 수를 반환
isEmpty(): 리스트가 비어있는지 여부를 반환
iterator(): 리스트의 요소에 대한 반복자를 반환
toArray(): 리스트의 모든 요소를 배열로 반환
toArray(T[] a): 리스트의 모든 요소를 지정된 배열로 반환
실무 선택 전략
배열 리스트를 실무 기본 사용 (대부분의 경우 성능상 유리)
앞쪽에서 데이터 추가/삭제가 빈번하다면 연결 리스트 사용 고려
몇 천, 몇 만, 몇 십만 건 수준에서 유의미
몇 십, 몇 백 건 정도면 배열 리스트 사용
배열 리스트와 연결 리스트 실제 성능 비교 - 대부분 배열 리스트 유리
직접 구현한 구현체 비교
자바 구현체 비교
평균 추가는 이론적으로 연결 리스트가 빠를 수 있으나 실제로는 배열 리스트가 빠를 때가 많음
실제 성능은 현대 컴퓨터 시스템 환경의 다양한 요소에 의해 영향 받음
e.g. 요소의 순차적 접근 속도, 메모리 할당 및 해제 비용, CPU 캐시 활용도 등
배열 리스트는 CPU 캐시 효율과 메모리 접근 속도 좋음
요소들이 메모리에 연속적으로 위치
위치가 연속적이면 다음 데이터를 메모리에 미리 올릴 수 있음
CAPACITY 초과에 따른 배열 복사 과정은 드물기 때문에 성능 영향 X
이론과 실무는 차이가 있음!
자바의 배열 리스트는 앞, 중간 쪽 데이터 추가가 훨씬 빠르게 최적화됨 (메모리 고속 복사)
자바의 연결 리스트는 뒤 쪽에 데이터 추가하는 속도가 빠름 (이중 연결 리스트)
예시 구현
public interface MyList<E> {
int size();
void add(E e);
void add(int index, E e);
E get(int index);
E set(int index, E element);
E remove(int index);
int indexOf(E o);
}
배열 리스트 (ArrayList)
데이터를 내부의 배열에 보관하는 리스트 구현체
특징
데이터 추가시 배열의 크기를 초과할 때마다 더 큰 크기의 배열을 새로 생성해 값 복사 후 사용
복사 전 기존 배열은 GC 대상
보통 50% 증가 사용
추가할 때마다 만들면 배열 복사 연산이 너무 많음
배열의 크기를 너무 크게 증가하면 메모리 낭비 발생
추가/삭제 시 인덱스로 위치 조회는 빠르지만 추가/삭제 작업 자체는 느림
인덱스로 위치 찾기: O(1)
추가/삭제 작업: O(N) - 데이터 이동 때문에
자바 배열 리스트 특징
기본 CAPACITY 는 10이고 넘어가면 50%씩 증가
메모리 고속 복사 사용해 최적화 (System.arraycopy())
시스템 레벨에서 최적화된 메모리 고속 복사 연산을 사용
배열 요소 이동을 루프가 아니라 시스템 레벨에서 한 번에 빠르게 복사 (수 배 이상 빠름)
데이터가 많으면 고속 복사도 소용 없음
장점
조회가 빠름
끝 부분에 데이터 추가 및 삭제 작업 빠름
단점
앞, 중간 부분 데이터 추가 및 삭제 작업 느림 (데이터 이동으로 인한 성능 저하)
배열 뒷 부분에 낭비되는 메모리가 존재
시간 복잡도
데이터 추가: O(N)
앞, 중간에 추가: O(N)
마지막에 추가: O(1)
데이터 삭제: O(N)
앞, 중간에 삭제: O(N)
마지막에 삭제: O(1)
인덱스 조회: O(1)
데이터 검색: O(N)
예시 구현
public class MyArrayList<E> implements MyList<E> {
private static final int DEFAULT_CAPACITY = 5;
private Object[] elementData;
private int size = 0;
public MyArrayList() {
elementData = new Object[DEFAULT_CAPACITY];
}
public MyArrayList(int initialCapacity) {
elementData = new Object[initialCapacity];
}
@Override
public int size() {
return size;
}
@Override
public void add(E e) {
if (size == elementData.length) {
grow();
}
elementData[size] = e;
size++;
}
@Override
public void add(int index, E e) {
if (size == elementData.length) {
grow();
}
shiftRightFrom(index);
elementData[index] = e;
size++;
}
//요소의 마지막부터 index까지 오른쪽으로 밀기
private void shiftRightFrom(int index) {
for (int i = size; i > index; i--) {
elementData[i] = elementData[i - 1];
}
}
@Override
@SuppressWarnings("unchecked")
public E get(int index) {
return (E) elementData[index];
}
@Override
public E set(int index, E element) {
E oldValue = get(index);
elementData[index] = element;
return oldValue;
}
@Override
public E remove(int index) {
E oldValue = get(index);
shiftLeftFrom(index);
size--;
elementData[size] = null;
return oldValue;
}
//요소의 index부터 마지막까지 왼쪽으로 밀기
private void shiftLeftFrom(int index) {
for (int i = index; i < size - 1; i++) {
elementData[i] = elementData[i + 1];
}
}
@Override
public int indexOf(E o) {
for (int i = 0; i < size; i++) {
if (o.equals(elementData[i])) {
return i;
}
}
return -1;
}
private void grow() {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity * 2;
elementData = Arrays.copyOf(elementData, newCapacity);
}
@Override
public String toString() {
return Arrays.toString(Arrays.copyOf(elementData, size)) + " size=" +
size + ", capacity=" + elementData.length;
}
}
연결 리스트 (LinkedList)
노드를 만들어 각 노드끼리 서로 연결하는 리스트 구현체
특징
노드와 링크로 구성
추가/삭제 시 인덱스로 위치 조회는 느리지만 추가/삭제 작업 자체는 빠름
인덱스로 위치 찾기: O(N) - 데이터 탐색 때문
추가/삭제 작업: O(1) - 필요한 노드끼리 참조만 변경하면 끝
자바 연결 리스트 특징
class Node {
E item;
Node next;
Node prev;
}
class LinkedList {
Node first; //첫 번째 노드 참조
Node last; //마지막 노드 참조
int size;
}
이중 연결 리스트 구조 & 첫 번째 노드와 마지막 노드 둘 다 참조
데이터를 끝에 추가하는 경우도 O(1)
역방향 조회 가능 -> 인덱스 조회 성능 최적화 (size 절반을 기준으로 조회 시작 위치 최적화)
장점
앞 부분 데이터 추가 및 삭제 작업 빠름
필요한만큼만 동적으로 노드를 생성 및 연결하므로 메모리 낭비 X
다만 크게 봤을 때 배열에 비해 메모리가 엄청 절약 X (연결 유지 위한 추가 메모리 사용, next)
단점
중간, 끝 부분 데이터 추가 및 삭제 작업 느림
시간 복잡도
데이터 추가: O(N)
앞에 추가: O(1)
중간, 마지막에 추가: O(N)
데이터 삭제: O(N)
앞에 삭제: O(1)
중간, 마지막에 삭제: O(N)
인덱스 조회: O(N)
데이터 검색: O(N)
예시 구현
public class MyLinkedList<E> implements MyList<E> {
private Node<E> first;
private int size = 0;
@Override
public void add(E e) {
Node<E> newNode = new Node<>(e);
if (first == null) {
first = newNode;
} else {
Node<E> lastNode = getLastNode();
lastNode.next = newNode;
}
size++;
}
private Node<E> getLastNode() {
Node<E> x = first;
while (x.next != null) {
x = x.next;
}
return x;
}
@Override
public void add(int index, E e) {
Node<E> newNode = new Node<>(e);
if (index == 0) {
newNode.next = first;
first = newNode;
} else {
Node<E> prev = getNode(index - 1);
newNode.next = prev.next;
prev.next = newNode;
}
size++;
}
@Override
public E set(int index, E element) {
Node<E> x = getNode(index);
E oldValue = x.item;
x.item = element;
return oldValue;
}
@Override
public E remove(int index) {
Node<E> removeNode = getNode(index);
E removedItem = removeNode.item;
if (index == 0) {
first = removeNode.next;
} else {
Node<E> prev = getNode(index - 1);
prev.next = removeNode.next;
}
removeNode.item = null;
removeNode.next = null;
size--;
return removedItem;
}
@Override
public E get(int index) {
Node<E> node = getNode(index);
return node.item;
}
private Node<E> getNode(int index) {
Node<E> x = first;
for (int i = 0; i < index; i++) {
x = x.next;
}
return x;
}
@Override
public int indexOf(E o) {
int index = 0;
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item))
return index;
index++;
}
return -1;
}
@Override
public int size() {
return size;
}
@Override
public String toString() {
return "MyLinkedList{" +
"first=" + first +
", size=" + size +
'}';
}
private static class Node<E> {
E item;
Node<E> next;
public Node(E item) {
this.item = item;
}
@Override // 가독성 위해 직접 구현 e.g. [A->B->C]
public String toString() {
StringBuilder sb = new StringBuilder();
Node<E> temp = this;
sb.append("[");
while (temp != null) {
sb.append(temp.item);
if (temp.next != null) {
sb.append("->");
}
temp = temp.next;
}
sb.append("]");
return sb.toString();
}
}
}
자료구조와 제네릭
일반적으로 하나의 자료구조에는 같은 데이터 타입을 보관하고 관리한다. 숫자와 문자처럼 관계 없는 여러 데이터 타입을 섞어 보관하는 일은 거의 없다.
따라서, 자료구조에 제네릭을 사용하면 타입 안정성이 높은 자료구조를 만들 수 있어 매우 어울린다.
만약 배열을 사용하는 경우, 제네릭을 적용해도 내부 배열의 타입은 Object[] elementData을 사용할 것이다. 문제는 생성자 코드에서 배열을 생성할 때이다.
new E[DEFAULT_CAPACITY]
제네릭은 타입 매개변수에 의한 new를 허용하지 않는다. 또한, 런타임에 타입 정보가 필요한 생성자에서 타입 매개변수를 사용할 수 없다. 이런 제네릭의 한계로 인해 Object[] 타입 배열을 적용하고 생성자에서 다음 코드를 사용해야 한다.
new Object[DEFAULT_CAPACITY]
Object[] 타입 적용은 결국 자료구조 내부에서 다운캐스팅을 사용하게되는데, 큰 문제는 없다.
Object 자체는 모든 데이터를 담을 수 있어 신경쓸게 없으니, 조회하는 부분에 초점을 맞춰보자.
자료를 입력하는 add(E e) 메서드에서 E 타입만 보관하는 덕분에, get() 메서드에서 데이터를 조회 후 (E)로 다운캐스팅해 반환해도 전혀 문제가 없다.
이중 연결 리스트
노드 앞뒤로 연결하는 이중 연결 리스트는 성능을 더 개선할 수 있다.
특히, 자바가 제공하는 연결 리스트도 이중 연결 리스트다. 마지막 노드를 참조하는 변수를 가지고 있어서, 뒤에 추가하거나 삭제하는 경우에도 O(1) 성능을 제공한다.
재사용성 높이기
프로그래밍 세계에서는 결정을 나중으로 미루면 재사용성이 높아진다.
e.g. 함수 매개변수, 제네릭 타입, 추상화 의존 & 구체적 구현 미루기
이론과 실무의 차이
자료구조를 배울 때 변경 작업이 많으면 LinkedList를 사용하라고 배우지만, 실제로는 ArrayList가 훨씬 빠르다. 이론과 실무의 차이를 유의해야 한다.
해시 알고리즘 (Hash)
주의점: 해시 자료구조 사용 시, 직접정의 객체는 hashCode()와 equals() 반드시 재정의해야 함 (IDE)
동등성을 확보해야 함
hashCode(): 참조 값 기반이 아닌 내부 값 기반으로 해시 코드 생성
equals(): contains() 메서드 실행 시 버킷 내 각각의 값 비교할 때 필요
직접 오버라이딩 하지 않을 시, Object 기본 동일성 비교 구현 실행되어서 문제
hashCode() 구현 X, equals() 구현 X 경우
참조값 기반으로 해시코드가 생성되어 실행 때마다 값이 다름
논리적으로 같은 데이터가 다른 메모리 위치에 중복 저장
다른 위치에서 데이터 조회해 검색 실패
hashCode() 구현 O, equals() 구현 X 경우
같은 해시코드가 생성되어 같은 해시 인덱스 메모리 위치에 저장
equals()가 동일성 비교 수행해, 논리적으로 같은 데이터 중복 저장
해시 인덱스는 정확히 찾으나, 동일성 비교로 인해 검색 실패
hashCode() 구현 O, equals() 구현 O 경우
논리적으로 같은 데이터는 중복 저장 X
해시 인덱스도 정확히 찾고, 동등성 비교로 검색도 성공
기본 아이디어
나머지 연산을 사용해 데이터 값 자체를 배열의 인덱스로 사용하자
배열의 크기만 적절히 확보하면 데이터가 고루 분산
입력 데이터 수와 비교해 배열의 크기가 클수록 충돌 확률은 낮아짐
통계적으로 입력 데이터 수가 배열 크기의 75%를 넘지 않으면 해시 충돌이 자주 발생 X
결과: 데이터 검색 성능 비약적 향상
기존 순차 데이터 검색: O(N)
해시 알고리즘: 해시 충돌이 적도록 제어하면 대부분 O(1)
해결 과정
1단계: 배열 인덱스 사용
데이터 검색 성능이 O(N) 문제 -> O(1) 개선
2단계: 해시 인덱스를 배열의 인덱스로 사용 (feat. 나머지 연산)
입력 값 범위가 크면 그만큼 큰 배열을 사용해서 메모리가 낭비되는 문제 해결
참고로 int 범위 만큼의 큰 배열은 약 17기가 바이트 소모
해시 인덱스: 배열의 인덱스로 사용할 수 있도록 원래 값을 계산한 인덱스
e.g. CAPACITY=10일 때, 14의 해시 인덱스는 4, 99의 해시 인덱스는 9
해시 인덱스 생성 O(1) + 해시 인덱스를 사용해 배열에서 값 조회 O(1) => O(1)
3단계: 해시 충돌 가능성을 인정하고 배열 내 배열 혹은 배열 내 리스트를 이중 사용해 실제 값 보관
최악의 경우 O(N)이지만 확률적으로 어느정도 넓게 퍼지므로 대부분 O(1) 성능일 것
e.g. 9, 19, 29, 99
해시 충돌이 일어나면 해당 인덱스의 배열에서 모든 값을 비교해 검색 - O(N)
해시 충돌 가끔 발생해도 내부에서 값 몇 번만 비교하는 수준이므로 대부분 매우 빠른 조회
해시 충돌: 다른 값을 입력했지만 같은 해시 코드가 나오는 것
e.g. CAPACITY=10일 때, 9와 99의 해시 인덱스는 모두 9로 겹침
예시 코드
public class HashStart {
static final int CAPACITY = 10;
public static void main(String[] args) {
//{1, 2, 5, 8, 14, 99 ,9}
LinkedList<Integer>[] buckets = new LinkedList[CAPACITY];
for (int i = 0; i < CAPACITY; i++) {
buckets[i] = new LinkedList<>();
}
add(buckets, 1);
...
add(buckets, 99);
add(buckets, 9); //중복
//검색
int searchValue = 9;
boolean contains = contains(buckets, searchValue); // true
}
private static void add(LinkedList<Integer>[] buckets, int value) {
int hashIndex = hashIndex(value);
LinkedList<Integer> bucket = buckets[hashIndex]; // O(1)
if (!bucket.contains(value)) { // O(n)
bucket.add(value);
}
}
private static boolean contains(LinkedList<Integer>[] buckets, int
searchValue) {
int hashIndex = hashIndex(searchValue);
LinkedList<Integer> bucket = buckets[hashIndex]; // O(1)
return bucket.contains(searchValue); // O(n)
}
static int hashIndex(int value) {
return value % CAPACITY;
}
}
해시 용어
해시 함수
임의의 길이의 데이터를 입력 받아 고정된 길이의 해시 값(해시 코드)을 출력하는 함수
고정된 길이는 저장 공간의 크기를 의미 e.g. int 형 1, 100은 둘다 4byte
같은 데이터를 입력하면 항상 같은 해시 코드 출력
다른 데이터를 입력해도 같은 해시코드가 출력될 수 있음 (해시 충돌)
해시 함수는 해시 코드가 최대한 충돌하지 않도록 설계해야 함
해시 충돌은 결과적으로 성능 하락
좋은 해시 함수는 해시 코드를 균일하게 분산시키는 것 -> 해시 인덱스도 분산
자바 해시 함수는 내부에 복잡한 연산으로 다양한 범위의 해시 코드 생성 -> 성능 최적화
해시 코드 (해시 함수를 통해 생성)
데이터를 대표하는 값
모든 문자 데이터는 고유한 숫자로 표현 가능 (ASCII 코드)
컴퓨터는 문자를 이해하지 못해 각 문자에 고유한 숫자를 할당해 인식 및 저장
char -> int형 캐스팅으로 확인 가능
char charA = 'A';
(int) charA // 65
연속된 문자는 각 문자의 고유 숫자 합으로 표현 가능 e.g. “AB” -> 65 + 66 = 131
물론, 자바 해시 함수는 더 복잡한 연산 수행
어떤 객체든 정수 해시 코드만 정의하면 해시 인덱스 사용 가능
Object.hashCode()
모든 객체가 자신만의 해시 코드를 표현할 수 있는 기능
보통 오버라이딩해 내부 값 기반으로 동등성 확보하도록 사용 (IDE 이용)
기본 구현: 객체의 참조값을 기반으로 해시 코드 생성
인스턴스가 다르면 해시코드 다름
자바 기본 클래스는 이미 재정의해 둠
값이 같으면 같은 해시 코드
hashCode() 결과는 음수가 나올 수 있는데 절대 값 사용
새 객체 정의 시 직접 오버라이딩
e.g. Member 객체 정의할 때 id 기반으로 해시 코드 생성
@Override
public int hashCode() {
return Objects.hash(id);
}
해시 인덱스(해시 코드를 사용해 생성)
데이터의 저장 위치를 결정하는 값
보통 해시 인덱스 = 해시 코드 % 배열의 크기
셋 (Set)
순서가 없고 중복을 허용하지 않는 자료구조
특징
요소의 유무(=중복 데이터 체크)를 빠르게 확인 가능 (contains())
해시 알고리즘을 통해 데이터 검색 성능 향상
O(N) -> O(1)
데이터 추가/삭제 시 반드시 필요한 중복 체크에도 유용
덕분에 검색, 추가, 삭제 모두 O(N) -> O(1) 개선
주요 메서드
add(E e): 지정된 요소를 셋에 추가 (이미 존재하는 경우 추가하지 않음)
addAll(Collection<? extends E> c): 지정된 컬렉션의 모든 요소를 셋에 추가
contains(Object o): 셋이 지정된 요소를 포함하고 있는지 여부를 반환
containsAll(Collection<?> c): 셋이 지정 컬렉션의 모든 요소를 포함하고 있는지 여부 반환
remove(Object o): 지정된 요소를셋에서 제거
removeAll(Collection<?> c): 지정된 컬렉션에 포함된 요소를 셋에서 모두 제거
retainAll(Collection<?> c): 지정 컬렉션에 포함된 요소만 유지, 나머지 요소는 셋에서 제거
clear(): 셋에서 모든 요소를 제거
size(): 셋에 있는 요소의 수를 반환
isEmpty(): 셋이 비어 있는지 여부를 반환
iterator(): 셋의 요소에 대한 반복자를 반환
toArray(): 셋의 모든 요소를 배열로 반환
toArray(T[] a): 셋의 모든 요소를 지정된 배열로 반환
실무 선택 전략
HashSet 권장
입력 순서 유지, 값 정렬의 필요에 따라서 LinkedHashSet, TreeSet 고려
코드 예시 - 합집합, 교집합, 차집합
public class SetOperationsTest {
public static void main(String[] args) {
Set<Integer> set1 = new HashSet<>(List.of(1, 2, 3, 4, 5));
Set<Integer> set2 = new HashSet<>(List.of(3, 4, 5, 6, 7));
Set<Integer> union = new HashSet<>(set1);
union.addAll(set2);
Set<Integer> intersection = new HashSet<>(set1);
intersection.retainAll(set2);
Set<Integer> difference = new HashSet<>(set1);
difference.removeAll(set2);
System.out.println("합집합: " + union);
System.out.println("교집합: " + intersection);
System.out.println("차집합: " + difference);
}
}
HashSet
배열에 해시 알고리즘을 적용해 구현
요소의 순서 보장 X
자바 HashSet 특징
재해싱 (rehashing) 최적화
배열 크기의 75%를 넘어가면 배열 크기를 2배로 늘리고 모든 요소에 해시 인덱스를 다시 적용
재적용 시간은 걸리지만, 해시 충돌을 줄이고 O(N) 성능 문제를 예방
키만 저장하는 특수한 형태의 해시 테이블
해시 테이블: 해시를 사용해서 키와 값을 저장하는 자료 구조 (HashMap)
자바는 해시 테이블의 원리를 이용하나 Value만 비워두고 사용 (HashMap 구현 활용)
시간 복잡도
데이터 추가: O(1)
데이터 삭제: O(1)
데이터 검색: O(1)
예시 코드 (HashSet)
public class MyHashSet<E> implements MySet<E> {
static final int DEFAULT_INITIAL_CAPACITY = 16;
private LinkedList<E>[] buckets;
private int size = 0;
private int capacity = DEFAULT_INITIAL_CAPACITY;
public MyHashSet() {
initBuckets();
}
public MyHashSet(int capacity) {
this.capacity = capacity;
initBuckets();
}
private void initBuckets() {
buckets = new LinkedList[capacity];
for (int i = 0; i < capacity; i++) {
buckets[i] = new LinkedList<>();
}
}
@Override
public boolean add(E value) {
int hashIndex = hashIndex(value);
LinkedList<E> bucket = buckets[hashIndex];
if (bucket.contains(value)) {
return false;
}
bucket.add(value);
size++;
return true;
}
@Override
public boolean contains(E searchValue) {
int hashIndex = hashIndex(searchValue);
LinkedList<E> bucket = buckets[hashIndex];
return bucket.contains(searchValue);
}
@Override
public boolean remove(E value) {
int hashIndex = hashIndex(value);
LinkedList<E> bucket = buckets[hashIndex];
boolean result = bucket.remove(value);
if (result) {
size--;
return true;
} else {
return false;
}
}
private int hashIndex(Object value) {
//hashCode의 결과로 음수가 나올 수 있다. abs()를 사용해서 마이너스를 제거한다.
return Math.abs(value.hashCode()) % capacity;
}
public int getSize() {
return size;
}
@Override
public String toString() {
return "MyHashSet{" +
"buckets=" + Arrays.toString(buckets) +
", size=" + size +
", capacity=" + capacity +
'}';
}
}
LinkedHashSet
HashSet에 연결 리스트를 추가해 구현
요소의 입력된 순서 보장 O
연결 링크 유지로 인해 HashSet 보다 조금 더 무거움
연결 링크는 데이터를 입력한 순서대로 연결 (양방향 연결)
first 부터 따라가면 입력 순서대로 데이터 순회 가능
시간 복잡도
데이터 추가: O(1)
데이터 삭제: O(1)
데이터 검색: O(1)
TreeSet
이진 탐색 트리를 개선한 레드-블랙 트리를 내부에서 사용해 구현
이진 트리: 자식이 2개까지 올 수 있는 트리
이진 탐색 트리: 부모 노드의 값과 비교해 왼쪽 자식이 더 작은 값, 오른쪽 자식이 더 큰 값 가지는 트리
이진 탐색 트리 개선
이진 탐색 트리는 데이터 균형이 맞지 않으면 최악의 경우 O(N)
해결 방안: 동적으로 균형 다시 맞추기
균형 맞추기 알고리즘 사용 (AVL 트리, 레드-블랙 트리)
자바 TreeSet은 레드-블랙 트리를 사용해 최악의 경우에도 O(log N) 성능 제공
데이터 값의 순서 보장 O
데이터를 값 기준 정렬된 순서로 저장 (값 기준은 Comparator 비교자 이용)
중위 순회를 통해 데이터를 오름차순으로 순회 가능
e.g. 3, 1, 2 입력해도 1, 2, 3 순서로 출력
시간 복잡도 - HashSet 보다 느리지만 데이터 검색 시 한 번의 계산에 절반을 날리는 특징
데이터 추가: O(log N)
데이터 삭제: O(log N)
데이터 검색: O(log N)
맵(Map)
키-값 쌍을 저장하는 자료구조
주의점
HashMap, LinkedHashMap: Key로 쓰이는 객체는 hashCode(), equals() 반드시 구현할 것
특징
키는 중복 X, 값은 중복 O, 순서 유지 X
Collection 상속 X
내부에서 Entry(인터페이스)를 통해 키 값을 묶어 저장
맵의 모든 것은 Key를 중심으로 동작하고 Key는 Set과 같은 구조
Map과 Set은 거의 같음 (단순히 Value가 있는지 없는지 차이)
Key 옆에 Value만 단순히 추가해주면 Map
따라서, Map과 Set의 구현체도 거의 동일
실제로 자바는 HashSet 구현에 HashMap의 구현을 가져다 씀
Map에서 Value만 비워두면 Set으로 사용 가능
키를 통해 빠르게 검색 가능 - O(1)
주요 메서드
put(K key, V value): 지정된 키와 값을 맵에 저장 (같은 키가 있으면 값을 변경)
putIfAbsent(K key, V value): 지정된 키가 없는 경우에 키와 값을 맵에 저장
putAll(Map<? extends K,? extends V> m): 지정된 맵의 모든 매핑을 현재 맵에 복사
get(Object key): 지정된 키에 연결된 값을 반환
getOrDefault(Object key, V defaultValue): 지정된 키에 연결된 값을 반환, 키가 없는 경우 defaultValue 로 지정한 값을 대신 반환
remove(Object key): 지정된 키와 그에 연결된 값을 맵에서 제거
clear(): 맵에서 모든 키와 값을 제거
containsKey(Object key): 맵이 지정된 키를 포함하고 있는지 여부를 반환 - O(1)
containsValue(Object value): 맵에 값이 있는지 여부 반환 - O(N), 다 뒤져봐야 함
keySet(): 맵의 키들을 Set 형태로 반환 - 키가 중복을 허용하지 않으므로 Set 반환
values(): 맵의 값들을 Collection 형태로 반환 - List, Set이 애매하므로 단순히 값의 모음이라는 의미의 Collection 반환 (맵의 값들은 중복 O, 순서 보장 X)
entrySet(): 맵의 키-값 쌍을 Set<Map.Entry<K,V>> 형태로 환한다.
size(): 맵에 있는 키-값 쌍의 개수를 반환
isEmpty(): 맵이 비어 있는지 여부를 반환
실무 선택 전략
HashMap 권장
순서 유지, 정렬의 필요에 따라서 LinkedHashMap, TreeMap 고려
코드 예시 - keySet(), entrySet(), values() 활용
Map<String, Integer> studentMap = new HashMap<>();
System.out.println("keySet 활용");
Set<String> keySet = studentMap.keySet();
for (String key : keySet) {
Integer value = studentMap.get(key);
System.out.println("key=" + key + ", value=" + value);
}
System.out.println("entrySet 활용");
Set<Map.Entry<String, Integer>> entries = studentMap.entrySet();
for (Map.Entry<String, Integer> entry : entries) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("key=" + key + ", value=" + value);
}
System.out.println("values 활용");
Collection<Integer> values = studentMap.values();
for (Integer value : values) {
System.out.println("value = " + value);
}
코드 예시 - 단어 수 세기
public class WordFrequencyTest {
public static void main(String[] args) {
String text = "orange banana apple apple banana apple";
Map<String, Integer> map = new HashMap<>();
String[] words = text.split(" ");
for (String word : words) {
map.put(word, map.getOrDefault(word, 0) + 1);
}
System.out.println(map);
}
}
HashMap
해시를 사용해 키와 값을 저장하는 자료구조 (=해시 테이블 =딕셔너리)
HashSet과 동작 원리 동일
Key 값을 사용해 해시 코드 생성
다만, Entry 사용해 Key, Value 묶어 저장
순서 보장 X
시간 복잡도
데이터 추가: O(1)
데이터 삭제: O(1)
데이터 검색: O(1)
LinkedHashMap
HashMap에 연결 리스트를 추가해 구현
요소의 입력된 순서 보장 O - 입력 순서대로 데이터 순회 가능
연결 링크 유지로 인해 HashMap 보다 조금 더 무거움
시간 복잡도
데이터 추가: O(1)
데이터 삭제: O(1)
데이터 검색: O(1)
TreeMap
레드-블랙 트리를 내부에서 사용해 구현
키 자체 데이터 값 기준으로 정렬된 순서 보장 O (값 기준은 Comparator 비교자 이용)
시간 복잡도
데이터 추가: O(log N)
데이터 삭제: O(log N)
데이터 검색: O(log N)
Stack, Queue, Deque
실무 선택 전략
스택, 큐 모두 deque의 ArrayDeque 구현체 사용 권장 (성능이 빠름)
큐 사용 시
단순히 큐 기능만 필요하면 Queue 인터페이스 사용
더 많은 기능이 필요하면 Deque 인터페이스 사용
스택 (Stack)
후입선출 (LIFO, Last In First Out) 자료구조
전통적으로 값을 넣는 것을 push, 값을 꺼내는 것을 pop 이라고 함
Deque을 사용해 구현해야 함
Stack 구현체 클래스는 내부에서 Vector 사용하는데 하위호환을 존재하므로 사용 권장 X
큐 (Queue)
선입선출 (FIFO, First In First Out) 자료구조
전통적으로 값을 넣는 것을 offer, 값을 꺼내는 것을 poll 이라고 함
덱 (Deque, Double Ended Queue)
양쪽 끝에서 요소를 추가하거나 제거 가능
offerFirst() : 앞에 추가
offerLast() : 뒤에 추가
pollFirst() : 앞에서 꺼냄
pollLast() : 뒤에서 꺼냄
일반적인 큐(Queue)와 스택(Stack)의 기능을 모두 포함하고 있어, 매우 유연한 자료 구조
스택 사용 위한 메서드 제공
push(): 앞에서 입력
pop(): 앞에서 꺼냄
큐 사용 위한 메서드 제공
offer(): 뒤에서 입력
poll(): 앞에서 꺼냄
참고로 둘 다 다음 데이터 단순 확인용으로 peek() 사용 가능 (앞에서 확인)
구현체: ArrayDeque, LinkedList
ArrayDeque 이 모든 면에서 가장 빠름
배열 사용은 현대 컴퓨터 시스템에서 더 나은 성능 발휘할 때가 많음
ArrayList vs LinkedList 차이와 비슷
추가로 원형 큐 자료 구조 사용해 앞, 뒤 입력 모두 O(1)
물론 자바 LinkedList도 앞, 뒤 입력이 O(1)
성능 비교
100만 건 입력 (앞, 뒤 평균)
ArrayDeque : 110ms
LinkedList : 480ms
100만 건 조회 (앞, 뒤 평균)
ArrayDeque : 9ms
LinkedList : 20ms
Iterable, Iterator - Iterator 디자인 패턴
순회
자료구조에 들어 있는 데이터를 차례대로 접근해서 처리하는 것
Iterator(반복자) 디자인 패턴
객체 지향 프로그래밍에서 컬렉션의 요소들을 순회할 때 사용되는 디자인 패턴
컬렉션의 구현과 독립적으로 요소들을 탐색할 수 있는 방법 제공
코드 복잡성 감소, 재사용성 상승
문제: 자료 구조마다 데이터를 접근하는 방법이 모두 다름
e.g. 배열 리스트는 인덱스, 연결 리스트는 노드 순회
개발자가 각 자료구조 내부구조와 순회 방법을 배워야 함
해결책: Iterable, Iterator 인터페이스 (자바 제공)
자료구조 구현과 관계 없이 모든 자료 구조를 일관성 있는 동일한 방법으로 순회 가능
추상화한 순회 과정
반복 과정: 다음 요소가 있는지 물어보기 & 있으면 다음 요소 꺼내기
다음 요소가 없으면 종료
자바 컬렉션 프레임워크는 Iterable 인터페이스와 각 구현체에 맞는 Iterator 구현해 제공
Collection 인터페이스 상위에 Iterable 존재 -> 모든 컬렉션이 순회 가능
Map은 Iterable이 없어 바로 순회 불가
keySet(), values(), entrySet()으로 Set이나 Collection 받아 순회
Iterable을 구현한 자료구조는 iterator를 반환하고 for-each 문이 작동한다는 의미
개발자는 hasNext(), next() 페어 혹은 for-each 문으로 쉽게 자료구조 순회 가능
Iterable
public interface Iterable<T> {
Iterator<T> iterator();
}
단순히 Iterator 반복자를 반환
Iterator
public interface Iterator<E> {
boolean hasNext();
E next();
}
hasNext() : 다음 요소가 있는지 확인, 다음 요소가 없으면 false 를 반환
next() : 다음 요소를 반환, 내부에 있는 위치를 다음으로 이동
Enhanced For Loop와 Iterable
자바는 Iterable 인터페이스를 구현한 객체에 대해서 향상된 for 문을 사용 지원
자바는 컴파일 시점에 다음과 같이 코드 변경
변경 전
for (int value : myArray) {
System.out.println("value = " + value);
}
변경 후
while (iterator.hasNext()) {
Integer value = iterator.next();
System.out.println("value = " + value);
}
코드 예시
public class MyArrayIterator implements Iterator<Integer> {
private int currentIndex = -1;
private int[] targetArr;
public MyArrayIterator(int[] targetArr) {
this.targetArr = targetArr;
}
@Override
public boolean hasNext() {
return currentIndex < targetArr.length - 1;
}
@Override
public Integer next() {
return targetArr[++currentIndex];
}
}
public class MyArray implements Iterable<Integer> {
private int[] numbers;
public MyArray(int[] numbers) {
this.numbers = numbers;
}
@Override
public Iterator<Integer> iterator() {
return new MyArrayIterator(numbers);
}
}
Comparable, Comparator
실무 사용법
객체 기본 정렬 방법은 객체에 Comparable 구현해 정의
기본 정렬 외 다른 정렬을 사용해야 하는 경우 Comparator 구현해 정렬 메서드에 전달
이 경우 전달한 Comparator가 항상 우선권 가짐
정렬 비교 기준 설정 (추상화를 통해 정렬 기준만 간단히 변경 가능)
e.g.
배열 정렬 - Arrays.sort() (비교자 전달 가능)
List 정렬 - Collections.sort(list), list.sort(null) (비교자 전달 가능)
Tree 구조 정렬
저장부터 정렬 필요하므로 TreeSet, TreeMap은 Comparable, Comparator 필수
new TreeSet<>() - 객체의 Comparable로 정렬
new TreeSet<>(new IdComparator()) - 인자로 넘긴 Comparator로 정렬
정렬 시 Comparable, Comparator 둘 다 없으면 런타임 오류
java.lang.ClassCastException: class collection.compare.MyUser cannot be cast to class java.lang.Comparable
Comparable 없어도 Comparator 주면 괜찮음!
Comparator 인터페이스 (비교자)
public interface Comparator<T> {
int compare(T o1, T o2);
}
두 값을 비교할 때 비교 기준을 제공
compare(): 두 인수를 비교해 결과값 반환
첫 번째 인수가 더 작으면 음수 e.g. -1
두 값이 같으면 제로 e.g. 0
첫 번째 인수가 더 크면 양수 e.g. 1
Comparable 인터페이스
public interface Comparable<T> {
public int compareTo(T o);
}
사용자 정의 객체에 정렬 비교 기준 설정 (비교 기능 추가)
Comparable 통해 구현한 순서를 자연 순서(Natural Ordering)라고 함
compareTo(): 자기 자신과 인수로 넘어온 객체 비교해 결과값 반환
현재 객체가 인수로 주어진 객체보다 더 작으면 음수 e.g. -1
두 객체의 크기가 같으면 제로 e.g. 0
현재 객체가 인수로 주어진 객체보다 더 크면 양수 e.g. 1
예시 코드 - 정렬 (Comparator 전달)
public class SortMain {
public static void main(String[] args) {
Integer[] array = {3, 2, 1};
System.out.println("Comparator 비교");
Arrays.sort(array, new AscComparator()); // 1, 2, 3
Arrays.sort(array, new DescComparator()); // 3, 2, 1
//DescComparator와 같다.
Arrays.sort(array, new AscComparator().reversed()); // 3, 2, 1
}
static class AscComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return (o1 < o2) ? -1 : ((o1 == o2) ? 0 : 1);
}
}
static class DescComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return (o1 < o2) ? -1 : ((o1 == o2) ? 0 : 1) * -1;
}
}
}
예시 코드 - 객체 Comparable 정의
public class MyUser implements Comparable<MyUser> {
private String id;
private int age;
...
//나이 오름차순으로 구현
@Override
public int compareTo(MyUser o) {
return this.age < o.age ? -1 : (this.age == o.age ? 0 : 1);
}
}
Arrays 유틸
Arrays.toString()
배열을 문자열로 보기 좋게 정제해 반환
Arrays.copyOf(기존배열, 새로운 길이)
새로운 길이로 배열을 생성하고 기존 배열 값을 새로운 배열에 복사
Arrays.sort()
배열에 들어있는 데이터를 순서대로 정렬
시간 복잡도: O(N log N)
자바 구현 알고리즘
초기: 퀵소트
현재:
데이터가 적을 때(32개 이하) 듀얼 피벗 퀵소트(Dual-Pivot QuickSort) 사용
데이터가 많을 때 팀소트(Tim Sort) 사용
종류
Arrays.sort(배열)
자연 순서 기준으로 정렬 (Comparable 기준)
Arrays.sort(배열, Comparator)
Comparator 기준으로 정렬
Comparator 전달 시 객체 Comparable 보다 우선순위 가짐
컬렉션 유틸
컬렉션을 편리하게 다룰 수 있는 다양한 기능 제공
Collections 정렬 관련 메서드
max : 정렬 기준으로 최대 값을 찾아서 반환
min : 정렬 기준으로 최소 값을 찾아서 반환
shuffle : 컬렉션을 랜덤하게 섞음
sort : 정렬 기준으로 컬렉션을 정렬
reverse : 정렬 기준의 반대로 컬렉션을 정렬
편리한 컬렉션 생성
불변 컬렉션 생성 (of(), 사용 권장)
생성한 객체는 불변 (add(), put(), remove() 불가)
변경 시도 시 UnsupportedOperationException 예외 발생
불변을 위한 다른 구현체 사용
e.g.
class java.util.ImmutableCollections$ListN
List 인터페이스에 불변을 위한 다른 구현체 제공
List, Set, Map 모두 of() 지원
List<Integer> list = List.of(1, 2, 3);
Set<Integer> set = Set.of(1, 2, 3);
Map<String, Integer> map = Map.of("A", 1, "B", 2, "C", 3);
배열을 리스트로 변환하기도 지원
Integer[] inputArr = {30, 20, 20, 10, 10};
List<Integer> list = Arrays.asList(inputArr);
List<Integer> list = List.of(inputArr);
참고: 생성자 전달 방법
Set은 생성자에 List를 받을 수 있음 (배열은 못 받음)
Integer[] inputArr = {30, 20, 20, 10, 10};
Set<Integer> set = new LinkedHashSet<>(List.of(inputArr));
가변 컬렉션으로 전환
불변 -> 가변 (new XxxXxx<>(list))
List<Integer> list = List.of(1, 2, 3);// 불변 리스트 생성
ArrayList<Integer> mutableList = new ArrayList<>(list);// 가변
가변 -> 불변 (Collections.unmodifiableXxx())
List<Integer> unmodifiableList = Collections.unmodifiableList(mutableList); //java.util.Collections$UnmodifiableRandomAccessList
빈 컬렉션 생성
빈 가변 리스트 생성: 구현체 직접 생성 e.g. new ArrayList<>();
빈 불변 리스트 생성
List.of() (자바 9, 권장)
Collections.emptyList() (자바 5)
멀티스레드 동기화 컬렉션 변환
일반 리스트를 동기화된 리스트로 변경 가능
일반 리스트 보다 성능 느림
e.g.
ArrayList<Integer> list = new ArrayList<>();
List<Integer> synchronizedList = Collections.synchronizedList(list);
List.of() VS Arrays.asList()
두 메서드 모두 리스트를 생성할 수 있다.
List<Integer> list = Arrays.asList(1, 2, 3);
List<Integer> list = List.of(1, 2, 3);
일반적으로 자바 9 이상은 List.of()를 권장한다. 혹시나 하위 호환성을 위함이거나 내부 요소를 변경해야 하는 경우 Arrays.asList() 선택할 수 있다.
Array.asList()는 고정된 크기를 가지지만, 내부 요소를 변경할 수 있다.
(set()은 가능하지만 add(), remove() 불가)
즉, 고정도 가변도 아닌 애매한 리스트여서 거의 사용하지 않는다.
Reference
김영한의 실전 자바 - 중급 2편
-
자바 제네릭
제네릭(Generic)
제네릭 실무
이미 만들어진 코드의 제네릭을 읽고 이해하는 정도면 충분
실무에서 제네릭 사용은 거의 드물고, 적용한다면 단순하게 사용
제네릭 타입
public class GenericBox<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
타입 매개변수를 사용하는 클래스나 인터페이스
핵심: 타입 결정을 생성시점으로 미룸
타입 매개변수 선언 e.g. T
생성 시점에 타입 인자 전달 e.g. Integer, String…
컴파일 과정에서 타입 정보 반영
유의점
지정할 수 있는 타입은 참조형만 가능 (기본형 X, 래퍼 클래스 O)
반환 타입에 void를 사용해야할 경우 Void를 사용하고 null 리턴할 것
한번에 여러 타입 매개변수 선언 가능
class Data<K, V> {}
Raw 타입은 사용하지 말아야 함
원시 타입(Raw Type): 제네릭 타입 생성시 타입 지정을 하지 않는 것
GenericBox integerBox = new GenericBox();
내부에서 타입 매개변수에 Object 사용
원시 타입은 과거 코드와 하위 호환을 위해 존재하므로 사용해서는 안됨
Object 타입을 사용해야 하는 경우, 타입 인자로 전달하자
GenericBox<Object> integerBox = new GenericBox<>();
static 메서드에 타입 매개변수를 사용 불가
class Box<T> {
T instanceMethod(T t) {} //가능
static T staticMethod1(T t) {} //제네릭 타입의 T 사용 불가능
}
제네릭 타입은 객체 생성시점에 타입이 정해짐
static 메서드는 클래스 단위로 작동하므로 인스턴스 생성과 무관
static 메서드에 제네릭을 도입하려면 제네릭 메서드를 사용해야 함
선언 방법
class GenericBox<T>
클래스 명 오른쪽에 <>(다이아몬드)를 사용해 타입 매개변수(T) 선언
클래스 내부에 T 타입이 필요한 곳에 T 적용
생성 방법
GenericBox<Integer> integerBox = new GenericBox<Integer>();
생성 시점에 원하는 타입 인자 지정
GenericBox<Integer> integerBox = new GenericBox<>();
자바 컴파일러의 타입 추론을 사용해 생성 부분에 타입 정보 생략도 가능
컴파일러가 대신 타입 인자 전달
제네릭 용어 정리
제네릭 (Generic)
컴파일러가 다운 캐스팅 코드를 대신 처리해주는 것
제네릭 타입 (Generic Type)
타입 매개변수를 사용하는 클래스나 인터페이스 (제네릭 클래스, 제네릭 인터페이스)
e.g. GenericBox<T>
타입 매개변수 (Type Parameter)
제네릭 타입이나 메서드에서 사용되는 변수
e.g. T, E, K, V
타입 인자 (Type Argument)
제네릭 타입을 사용할 때 전달되는 실제 타입
e.g. Integer, String…
타입 매개변수 명명 관례
일반적으로 용도에 맞는 단어의 첫글자를 대문자로 사용
종류
E - Element
K - Key
N - Number
T - Type
V - Value
S, U, V etc. - 2nd, 3rd, 4th types
코드 재사용성 상승시키기
프로그래밍 세계에서 무언가에 대한 결정을 정의 시점이 아니라 사용 시점으로 미루면 재사용성이 크게 상승한다. 즉, 매개변수를 활용해 실행 시점에 인자를 전달하면 재사용이 크게 상승한다. (e.g. 제네릭, 메서드…)
제네릭의 필요성
제네릭은 코드 재사용성과 타입 안정성을 동시에 잡으면서 중복 제거 가능
중복 제거 시도 과정
문제 상황: 데이터 보관 및 꺼내는 객체 만들기
서로 다른 타입의 데이터를 어떻게 다룰지 문제
해결 1: 각 타입 별 클래스 정의 (IntegerBox, StringBox)
코드 재사용X, 타입 안정성 O
XxxBox 클래스 수십개 만드는 것은 비효율적
해결 2: 다형성을 활용한 하나의 클래스 정의 (ObjectBox)
코드 재사용 O, 타입 안정성 X
반환 타입이 Object라 사용 시 위험한 다운 캐스팅 필요
실수로 잘못된 타입의 인수가 전달될 수 있는 문제 (입력 제약이 느슨)
e.g.
integerBox.set("문자100");
Integer result = (Integer) integerBox.get();
-> ClassCastException 발생
해결 3: 제네릭 클래스 사용하기
코드 재사용 O, 타입 안정성 O
타입 매개변수 상한
타입 매개변수 상한은 제네릭의 타입 안정성을 더욱 견고히 지킴
e.g. <T extends Animal>
타입 매개변수를 Animal과 그 자식만 받을 수 있도록 제한
덕분에 자바 컴파일러가 타입 매개변수에 입력될 수 있는 값 범위를 예측 가능
제네릭의 타입 안정성 개선 과정
문제 상황: 개 병원은 개만 받을 수 있고, 고양이 병원은 고양이만 받을 수 있어야 함
해결 1: 각 타입 별 클래스 정의 (DogHospital, CatHospital)
코드 재사용X, 타입 안정성 O
해결 2: 다형성을 활용한 하나의 클래스 정의 (AnimalHospital)
코드 재사용 O, 타입 안정성 X
반환 타입이 Animal라 사용 시 위험한 다운 캐스팅 필요
실수로 잘못된 타입의 인수가 전달될 수 있는 문제 (입력 제약이 느슨)
해결 3: 제네릭 도입
여전히 타입 안정성이 불안
원치 않는 타입의 인수 전달되는 문제
타입 매개변수(T)가 Object 타입으로 취급되어 Animal의 기능 사용 불가
해결 4: 타입 매개변수 상한
코드 재사용 O, 타입 안정성 O
올바른 타입의 인수 전달이 가능해져 타입 안정성 향상
상위 타입의 원하는 기능 사용 가능
제네릭 메서드
public class GenericMethod {
public static Object objMethod(Object obj) {
System.out.println("object print: " + obj);
return obj;
}
public static <T> T genericMethod(T t) {
System.out.println("generic print: " + t);
return t;
}
public static <T extends Number> T numberMethod(T t) {
System.out.println("bound print: " + t);
return t;
}
}
타입 매개변수를 사용하는 메서드
클래스 전체가 아니라 특정 메서드 단위로 제네릭을 도입할 때 사용
핵심: 타입 결정을 메서드 호출 시점으로 미룸
선언 방법
public static <T> T genericMethod(T t) {...}
반환타입 왼쪽에 타입 매개변수 선언
호출 방법
GenericMethod.<Integer>genericMethod(10)
호출 시점에 원하는 타입 인자 지정
Integer integerValue = GenericMethod.numberMethod(10);
보통 타입 추론을 통해 생략해 사용
자바 컴파일러는 전달되는 인자 타입과 반환 타입을 보고 타입 추론
컴파일러가 대신 타입 인자 전달
타입 매개변수 상한 가능
public static <T extends Number> T numberMethod(T t) {}
유의점
인스턴스 메서드, static 메서드 모두 적용 가능 (제네릭 타입은 static 메서드에 사용 불가)
class Box<T> { //제네릭 타입
static <V> V staticMethod(V t) {} //static 메서드에 제네릭 메서드 도입
<Z> Z instanceMethod(Z z) {} //인스턴스 메서드에 제네릭 메서드 도입 가능
}
제네릭 타입과 제네릭 메서드의 타입 매개변수 이름은 다르게 하자! (모호함 X)
인스턴스 메서드 동시 적용에서 제네릭 메서드가 우선순위 가지지만 모호한 것은 좋지 않다!
public class ComplexBox<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public <T> T printAndReturn(T t) {
System.out.println("animal.className: " + animal.getClass().getName());
System.out.println("t.className: " + t.getClass().getName());
// 호출 불가! 메서드는 <T> 타입이다. <T extends Animal> 타입이 아니다.
// t.getName();
return t;
}
}
와일드 카드 (Wild Card)
public class WildcardEx {
//이것은 제네릭 메서드이다.
//Box<Dog> dogBox를 전달한다. 타입 추론에 의해 타입 T가 Dog가 된다.
static <T> void printGenericV1(Box<T> box) {
System.out.println("T = " + box.get());
}
//이것은 제네릭 메서드가 아니다. 일반적인 메서드이다.
//Box<Dog> dogBox를 전달한다. 와일드카드 ?는 모든 타입을 받을 수 있다.
static void printWildcardV1(Box<?> box) {
System.out.println("? = " + box.get());
}
static <T extends Animal> void printGenericV2(Box<T> box) {
T t = box.get();
System.out.println("이름 = " + t.getName());
}
static void printWildcardV2(Box<? extends Animal> box) {
Animal animal = box.get();
System.out.println("이름 = " + animal.getName());
}
static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
T t = box.get();
System.out.println("이름 = " + t.getName());
return t;
}
static Animal printAndReturnWildcard(Box<? extends Animal> box) {
Animal animal = box.get();
System.out.println("이름 = " + animal.getName());
return animal;
}
}
이미 타입이 정해진 제네릭 타입을 편리하게 전달 받을 수 있도록 하는 키워드 (*, ?)
와일드 카드는 제네릭 타입, 제네릭 메서드를 선언하는 것이 아님
단순히 일반 메서드에 제네릭 타입을 받을 수 있는 매개변수가 하나 있는 것
따라서, 일반적인 메서드에 사용 가능
e.g. Box<Dog>, Box<Cat> 등의 제네릭 타입을 전달 받음
유의점
제네릭 타입, 제네릭 메서드와 달리, 호출 시에 타입을 지정할 필요가 없음
비제한 와일드카드 (?)
모든 타입을 다 받을 수 있음
상한 와일드 카드 & 하한 와일드카드 사용 가능
상한 와일드 카드
static void printWildcardV2(Box<? extends Animal> box) {...}
Animal 타입 + Animal 타입의 하위 타입만 입력 가능
하한 와일드 카드 (제네릭 타입, 제네릭 메서드에는 없음)
static void writeBox(Box<? super Animal> box) {...}
Animal 타입 + Animal 타입의 상위 타입만 입력 가능 (e.g. Box<Dog> 전달 불가)
사용 전략
보통의 경우 더 단순한 와일드 카드 사용 권장
제네릭 메서드처럼 타입을 전달해 결정하는 것은 내부 과정이 복잡
꼭 필요한 상황에만 제네릭 타입/제네릭 메서드로 정의
전달할 타입을 명확하게 반환해야 하는 경우 제네릭 타입, 제네릭 메서드 사용
Dog dog = WildcardEx.printAndReturnGeneric(dogBox)
전달할 타입을 명확하게 반환하지 않아도 되는 경우 와일드 카드 사용
Animal animal = WildcardEx.printAndReturnWildcard(dogBox)
타입 이레이저 (Type Eraser)
제네릭 정보가 자바 컴파일 단계에서만 사용되고, 컴파일 이후에는 삭제되는 것
컴파일 전(소스코드, .java): 제네릭 타입 매개변수 존재 O
컴파일 후(바이트코드, .class): 제네릭 타입 매개변수 존재 X (<T>…)
결국, 제네릭은 컴파일러가 다운 캐스팅 코드를 대신 처리해주는 작업
자바는 컴파일 시점 제네릭 코드를 완벽히 검증하고 다운 캐스팅 코드 삽입
하위호환 유지를 위해 컴파일러가 조금 더 고생 (제네릭은 중간에 도입됨)
따라서, 런타임 코드는 옛날 자바 코드와 똑같이 실행됨
타입 이레이저 한계
런타임에 타입을 활용하는 코드 작성 불가
T는 런타임에 모두 Object가 됨
e.g.
param instanceof T; //오류 -> param instanceof Object;
자바는 타입 매개변수에 instanceof 허용 X -> Object는 항상 참이므로
new T(); //오류 -> new Object();
자바는 타입 매개변수에 new 허용 X -> 항상 Object가 생성되므로
대략적인 작동 방식
컴파일 시점
소스코드에서 제네릭 타입(GenericBox) 선언하고 타입 인자(Integer) 전달
public class GenericBox<T> {...}
GenericBox<Integer> box = new GenericBox<Integer>();
컴파일러는 제네릭 정보를 활용해 new GenericBox<Integer>() 에 대해 다음과 같이 이해
public class GenericBox<Integer> {
private Integer value;
public void set(Integer value) {
this.value = value;
}
public Integer get() {
return value;
}
}
컴파일 후
컴파일이 모두 끝나면 자바는 제네릭 정보를 삭제하고 다음과 같은 .class 정보 생성
상한 제한 없이 선언한 타입 매개변수 T는 Object로 변환
public class GenericBox {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
상한 제한 있는 타입 매개변수(T)는 제한한 타입(Animal)으로 변환
소스코드
AnimalHospital<Dog> hospital = new AnimalHospital<>();
컴파일 전
public class AnimalHospital<T extends Animal> {...}
컴파일 후
public class AnimalHospital {
private Animal animal;
public void set(Animal animal) {...}
public void checkup() {...}
public Animal getBigger(Animal target) {...}
}
필요한 곳에 컴파일러가 다운 캐스팅 코드 삽입
e.g.1
GenericBox box = new GenericBox();
box.set(10);
Integer result = (Integer) box.get(); //컴파일러가 캐스팅 추가
e.g.2
Dog dog = (Dog) animalHospital.getBigger(new Dog());
자바는 컴파일 시점에 제네릭 코드를 완벽히 검증하므로 다운 캐스팅 추가에 문제 X
Reference
김영한의 실전 자바 - 중급 2편
-
자바 예외 기본
예외 계층
Object: 모든 객체의 최상위 부모
Throwable: 최상위 예외, 잡으면 안됨 (Error까지 잡히므로)
Error
애플리케이션에서 복구 불가능한 시스템 예외 (메모리 부족, 심각한 시스템 오류…)
애플리케이션 개발자는 이 예외를 잡지 않아야 함
언체크 예외
Exception: 체크 예외 (런타임 예외 제외), 애플리케이션에서 개발자가 잡아야 할 실질적최상위 예외
RuntimeException: 언체크 예외 (=런타임 예외)
체크예외 VS 언체크 예외
핵심
언체크 예외는 throws 선언하지 않고 생략 가능 (자동 예외 던지기)
나머지는 동일
체크 예외
컴파일러가 체크하는 예외
체크 예외의 장단점
예외를 누락하지 않도록 컴파일러가 안전 장치 역할 (누락 시 컴파일 오류)
크게 신경쓰지 않고 싶은 예외까지 모두 반드시 잡거나 던져야 함
언체크 예외
컴파일러가 체크하지 않는 예외
중요 예외의 경우 throws를 선언해두면 IDE를 통해 개발자가 편리하게 인지 가능 (보통은 생략)
언체크 예외의 장단점
신경쓰고 싶지 않은 언체크 예외 무시 가능
개발자가 실수로 예외 누락 가능
예외 처리 기본
기본 규칙
예외는 잡아서 처리하거나 던져야 한다
예외를 잡는 코드: catch
예외를 던지는 코드: throws
예외를 잡거나 던질 때 지정한 예외 뿐만 아니라 그 예외의 자식들도 함께 처리된다
기본적으로 언체크(런타임) 예외를 사용하자
체크 예외들은 바깥으로 던져야 하는데 이 과정에서 의존 관계 문제 발생
실무에서 발생하는 대부분의 예외는 복구 불가능한 시스템 예외 (애플리케이션 단에서 처리 불가)
의존 관계 문제
컨트롤러, 서비스는 본인이 처리할 수 없어도 throws를 선언해 예외를 던져야 함
컨트롤러, 서비스가 해당 체크 예외에 의존하게 되어 구현 기술 변경 시 OCP 위반
예를 들어, DB 접근 기술을 변경한다면 예외를 포함한 컨트롤러, 서비스 코드를 수정
수 많은 체크 예외를 일일이 명시해 던지는 것도 부담
런타임 예외를 사용하면 처리할 수 없는 예외를 별도 선언 없이 그냥 두면 됨
의존성 발생 X -> 기술 변경이 있어도 컨트롤러, 서비스 코드 변경 X -> OCP 준수
대부분의 최근 라이브러리는 런타임 예외를 기본으로 제공 (스프링, JPA…)
런타임 예외는 놓칠 수 있기 때문에 문서화가 중요 (혹은 명시적으로 코드에 throws 남기기)
처리할 수 없는 예외들은 한 곳에서 공통처리
서블릿 오류 페이지, 스프링 MVC ControllerAdvice 예외 공통 처리
고객: 오류 페이지
내부 개발자: 별도 로그, 슬랙, 문자, 메일을 통해 개발자가 빠르게 인지
API는 상태코드 500 응답
예시 코드
public class Main {
public static void main(String[] args) {
NetworkService networkService = new NetworkService();
try {
networkService.sendMessage();
} catch (Exception e) { // 모든 예외를 잡아서 처리
exceptionHandler(e);
}
}
//공통 예외 처리
private static void exceptionHandler(Exception e) {
//공통 처리
System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");
System.out.println("==개발자용 디버깅 메시지==");
e.printStackTrace(System.out); // 스택 트레이스 출력
//e.printStackTrace(); // System.err에 스택 트레이스 출력
//실무에서는 보통 Slf4j 사용해 로그를 콘솔 출력 + 파일로 저장
//e.printStackTrace는 콘솔에만 출력하므로 사용 X
//필요하면 예외 별로 별도의 추가 처리 가능
if (e instanceof SendException sendEx) {
System.out.println("[전송 오류] 전송 데이터: " + sendEx.getSendData());
}
}
}
체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용
매우 심각한 문제는 개발자가 실수로 예외를 놓치면 안된다고 판단할 수 있음
체크 예외 예시 (무조건 해야하는 건 아님)
계좌 이체 실패 예외
결제시 포인트 부족 예외
로그인 ID, PW 불일치 예외
Exception을 던지지 말자
코드가 깔끔해지는 것 같지만, 모든 체크 예외를 다 던져 버려서 중요한 체크 예외를 놓침
꼭 필요한 경우가 아니면 Exception 자체를 밖으로 던지는 것은 좋은 방법이 아님
스택 트레이스를 남기자
로그 남기기
log.info("예외 처리, message={}", e.getMessage(), e);
로그의 마지막 인수에 예외 객체 전달하면 로그에 스택 트레이스를 출력
예외를 전환할 때는 반드시 기존 예외를 포함하자
throw new RuntimeSQLException(e); - 기존 예외 e 포함
덕분에 기존 예외와 스택 트레이스까지 확인 가능
포함하지 않으면 실제 DB에서 발생한 근본적인 원인을 확인할 수 없는 심각한 문제 발생
예외를 계속 던지면 벌어지는 상황
자바 main() 쓰레드의 경우 예외 로그를 출력하면서 시스템이 종료
웹 애플리케이션의 경우 WAS가 예외를 받아 처리하고 개발자가 지정한 오류 페이지 보여줌
(예외 하나로 시스템이 종료되면 안됨)
사용자 예외 만들기
Exception을 상속 받으면 체크 예외
RuntimeException을 상속 받으면 언체크 예외
오류 메시지 보관하기
생성자를 통해 오류 메시지를 보관할 것 (예외가 제공하는 기본 기능)
super(message)로 전달한 메시지는 Throwable의 detailMessage에 보관됨
getMessage()로 조회 가능
public class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
적당한 예외 계층화하기 (너무 많아도 문제)
상속을 사용해 예외를 계층화하면 보다 세밀한 예외 처리 가능
e.g.
NetworkClientException (부모)
ConnectException (자식, 내부 연결 시도 address 보관)
SendException (자식, 내부 전송 시도 데이터 sendData 보관)
각각의 하위 예외에 고유 기능을 만들어 활용 가능
e.g. e.getAddress(), e.getSendData()
부모 예외를 잡아 자식까지 한 번에 처리하거나 특정 하위 예외만 잡아 처리 가능
중요한 특정 하위 예외만 메시지를 명확히 남기고 나머지는 공통 처리 가능
e.g.
[연결 오류] 주소: ... (하위 예외)
[네트워크 오류] 메시지: ... (부모 예외)
[알 수 없는 오류] 메시지: ... (그 외 예외 공통 처리)
예외 처리 발전 과정 예시 (e.g. NetworkClient)
반환 값(문자열)으로 예외 처리
분기 처리 및 return으로 네트워크 연결 및 해제, 데이터 전송 관리 등이 가능
가장 중요한 정상 흐름이 한눈에 들어오지 않음 (정상 흐름과 예외 흐름 분리 X, 가독성 감소)
예외 처리 메커니즘 사용 (try ~ catch ~ finally)
성공 여부를 반환값이 아닌 메서드 정상 종료 여부로 판단
정상 흐름과 예외 흐름을 명확히 분리해 가독성 상승 (try 블록, catch 블록)
반드시 실행되어야 하는 코드를 안전하게 호출하도록 보장 (finally)
finally가 없으면 catch에서 잡지 못한 예외가 발생할 때 문제가 생김
외부 자원 해제 등에 편리
finally
finally 블록은 어떤 경우라도 반드시 호출된다. 주로 try에서 사용한 자원을 해제할 때 사용한다.
예외를 직접 잡을 일이 없다면, try ~ finally만 사용하는 것도 가능하다.
정상 흐름 (try) -> finally
예외 잡음 (catch) -> finally
예외 던짐 -> finally (finally 블록 끝난 이후 예외가 밖으로 던져짐)
자원 해제
외부 리소스는 사용 후 반드시 연결을 해제하고 자원을 반납해야 메모리 고갈을 피할 수 있다. (네트워크 연결 자원, DB 연결 자원…)
자바는 GC로 JVM 메모리 상 인스턴스들을 자동으로 해제하지만, 외부 연결 같은 자바 외부 자원은 자동으로 해제되지 않는다.
여러 예외 한 번에 잡는 Syntax
catch 블록에서 | 키워드를 사용해 예외를 나열할 수 있다.
다만, 이 경우 각 예외들의 공통 부모 기능만 사용할 수 있다.
e.g. catch (ConnectException | SendException e) {...}
// 이 경우 공통 부모인 NetworkClientException의 기능만 사용 가능
try-with-resources 구문
finally 없이 편리한 외부 자원 해제 지원
사용 방법
외부 자원 클래스에 AutoCloseable 인터페이스를 구현 (implements AutoCloseable)
close() 메서드를 오버라이드해 자원 반납 방법 정의
메서드가 예외를 던지지 않으면 인터페이스의 throws Exception은 생략
try-with-resources 구문 사용
try (Resource resource = new Resource()) {
// 리소스를 사용하는 코드
}
try 괄호 안에 사용할 자원을 명시
e.g. try (NetworkClient client = new NetworkClient(address)) {...}
try 블록이 끝나면 자동으로 AutoCloseable.close() 호출해 자원 해제
try 블록만 단독으로도 사용 가능 (catch, finally 없이 사용 가능)
장점
리소스 누수 방지
실수로 finally 블록 혹은 그 내부에 자원 해제 코드를 누락하는 문제 예방
코드 간결성 및 가독성 향상
리소스 스코프 범위 한정
리소스 객체 변수의 스코프를 try 블록으로 한정해 코드 유지보수성 향상
조금 더 빠른 자원 해제
기존에는 catch 이후에 자원을 반납 (try -> catch -> finally)
try with resources는 try 블록이 끝나면 즉시 close() 호출
Reference
김영한의 실전 자바 - 중급 1편
-
자바 중첩 클래스
중첩 클래스의 분류
클래스를 정의하는 위치에 따라 총 4가지, 크게 2가지로 분류 (변수 선언 위치와 동일)
정적 중첩 클래스 (정적 변수와 같은 위치)
내부 클래스
내부 클래스 (인스턴스 변수와 같은 위치)
지역 클래스 (지역 변수와 같은 위치)
익명 클래스 (지역 클래스의 특별 버전)
중첩 클래스 사용 상황 => 패키지 내 꼭 필요한 클래스들만 노출시켜 개발자의 혼란을 줄임
특정 클래스가 다른 하나의 클래스 안에서만 사용됨 (다른 클래스에서 사용시 중첩 클래스 사용 X)
둘이 아주 긴밀하게 연결되어 있는 경우
장점
논리적 그룹화
캡슐화
다른 곳에서 사용될 필요 없는 중첩 클래스가 외부에 노출 X
중첩 클래스는 바깥 클래스의 private 멤버에 접근 가능
불필요한 public 제거 및 긴밀한 연결
정적 중첩 클래스 VS 내부 클래스 사용 상황
바깥 클래스의 인스턴스 상태에 의존하고 인스턴스 변수를 사용할 것 같다면 내부 클래스 사용
아닐 것 같다면 정적 중첩 클래스 사용
정적 중첩 클래스 (Nested)
public class NestedOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
static class Nested {
private int nestedInstanceValue = 1;
public void print() {
// 자신의 멤버에 접근
System.out.println(nestedInstanceValue);
// 바깥 클래스의 인스턴스 멤버에는 접근할 수 없다.
// System.out.println(outInstanceValue);
// 바깥 클래스의 클래스 멤버에는 접근할 수 있다. private도 접근 가능
System.out.println(NestedOuter.outClassValue);
System.out.println(outClassValue);
}
}
}
public class NestedOuterMain {
public static void main(String[] args) {
//단독 생성 가능
NestedOuter.Nested nested = new NestedOuter.Nested();
nested.print();
}
}
static 키워드 O
바깥 클래스의 인스턴스에 소속 X
바깥 클래스의 내부에 있지만, 바깥 클래스와 관계 없는 전혀 다른 클래스 (내 것이 아닌 것)
단지 구조상 중첩해뒀을 뿐 클래스 2개와 큰 차이 없음
class NestedOuter {
}
class Nested {
}
바깥 클래스의 인스턴스 변수 접근 불가 (클래스 변수는 접근 가능)
바깥 인스턴스의 참조값이 없기 때문
private 접근 제어자 관점
바깥 클래스와 중첩 클래스는 서로의 private 접근 제어자에 접근 가능
둘은 접근제어자 관점에서 한 식구
생성 방법
바깥 클래스의 바깥에서 접근 및 생성
접근: 바깥클래스.중첩클래스
생성: new 바깥클래스.중첩클래스()
바깥 클래스 생성과 상관없이 단독 생성 가능
바깥 클래스의 내부에서 접근 및 생성 (권장)
접근: 중첩클래스
생성: new 중첩클래스()
정적 중첩 클래스는 private으로 두고 바깥 접근 및 생성을 막는게 옳음
중첩 클래스의 용도는 소속된 바깥 클래스의 내부에서 사용되는 것이므로
public class Network {
public void sendMessage(String text) {
NetworkMessage networkMessage = new NetworkMessage(text);
}
private static class NetworkMessage {
...
}
}
내부 클래스 (Inner)
내부 클래스 (공통 개념)
public class InnerOuter {
private static int outClassValue = 3;
private int outInstanceValue = 2;
class Inner {
private int innerInstanceValue = 1;
public void print() {
// 자신의 멤버에 접근
System.out.println(innerInstanceValue);
// 외부 클래스의 인스턴스 멤버에 접근 가능, private도 접근 가능
System.out.println(outInstanceValue);
// 외부 클래스의 클래스 멤버에 접근 가능. private도 접근 가능
System.out.println(InnerOuter.outClassValue);
}
}
}
public class InnerOuterMain {
public static void main(String[] args) {
InnerOuter outer = new InnerOuter();
InnerOuter.Inner inner = outer.new Inner();
inner.print();
}
}
static 키워드 X
바깥 클래스의 인스턴스에 소속 O
바깥 클래스의 내부에 있으면서, 바깥 클래스를 구성하는 요소 (나를 구성하는 요소)
개념상으로는 바깥 인스턴스 안에 내부 인스턴스가 생성
실제로는 내부 인스턴스가 바깥 인스턴스의 참조값 보관
바깥 클래스의 인스턴스 변수 접근 가능 (클래스 변수도 접근 가능)
참조값을 가짐
private 접근 제어자 관점
바깥 클래스와 내부 클래스는 서로의 private 접근 제어자에 접근 가능
생성 방법
바깥 클래스의 바깥에서 생성
생성: 바깥클래스의 인스턴스 참조.new 내부클래스()
바깥 클래스의 인스턴스를 먼저 생성해야 내부 클래스의 인스턴스 생성 가능
바깥 클래스의 내부에서 생성 (권장)
생성: new 내부 클래스()
내부 클래스의 인스턴스는 자신을 생성한 바깥 클래스의 인스턴스를 자동으로 참조
public class Car {
private String model;
private int chargeLevel;
private Engine engine;
public Car(String model, int chargeLevel) {
this.model = model;
this.chargeLevel = chargeLevel;
this.engine = new Engine();
}
public void start() {
engine.start();
System.out.println(model + " 시작 완료");
}
private class Engine {
public void start() {
System.out.println("충전 레벨 확인: " + chargeLevel);
System.out.println(model + "의 엔진을 구동합니다."); }
}
}
}
종류
내부 클래스: 바깥 클래스의 인스턴스 멤버에 접근
지역 클래스: 내부 클래스의 특징 + 지역 변수에 접근
익명 클래스: 지역 클래스의 특징 + 클래스 이름 X
지역 클래스
class Outer {
public void process() {
//지역 변수
int localVar = 0;
//지역 클래스
class Local {...}
Local local = new Local();
}
}
내부 클래스의 종류 중 하나로 지역 변수와 같이 코드 블럭 안에서 정의
지역 변수에 접근 가능 (접근하는 지역 변수는 final이거나 사실상 final이어야 함)
접근 제어자 사용 불가 (Like 지역 변수)
사용 상황
특정 메서드 내 간단히 사용할 목적
지역 변수 캡처
public class LocalOuter {
private int outInstanceVar = 3;
public Printer process(int paramVar) {
int localVar = 1; //지역 변수는 스택 프레임이 종료되는 순간 함께 제거된다.
class LocalPrinter implements Printer {
int value = 0;
@Override
public void print() {
System.out.println("value=" + value);
//인스턴스는 지역 변수보다 더 오래 살아남는다.
System.out.println("localVar=" + localVar);
System.out.println("paramVar=" + paramVar);
System.out.println("outInstanceVar=" + outInstanceVar);
}
}
Printer printer = new LocalPrinter();
// 지역클래스가 접근하는 지역 변수는 final이거나 사실상 final이어야 한다.
// localVar = 10; // 컴파일 오류
// paramVar = 20; // 컴파일 오류
//printer.print()를 여기서 실행하지 않고 Printer 인스턴스만 반환한다.
return printer;
}
public static void main(String[] args) {
LocalOuter localOuter = new LocalOuter();
Printer printer = localOuter.process(2);
//printer.print()를 나중에 실행한다. process()의 스택 프레임이 사라진 이후에 실행
printer.print();
//추가
System.out.println("필드 확인");
Field[] fields = printer.getClass().getDeclaredFields();
for (Field field : fields) {
System.out.println("field = " + field);
}
}
}
//실행결과
//value=0
//localVar=1
//paramVar=2
//outInstanceVar=3
//필드 확인
//인스턴스 변수
//field = int nested.local.LocalOuter$1LocalPrinter.value
//캡처 변수
//field = final int nested.local.LocalOuter$1LocalPrinter.val$localVar
//field = final int nested.local.LocalOuter$1LocalPrinter.val$paramVar
//바깥 클래스 참조
//field = final nested.local.LocalOuter nested.local.LocalOuter$1LocalPrinter.this$0
지역 클래스 인스턴스 생성 시점에 접근이 필요한 지역 변수는 복사해서 인스턴스에 보관하는 것
지역 클래스 인스턴스에서 지역 변수에 접근하면, 실제로는 인스턴스에 캡처한 변수로 접근
힙 영역의 인스턴스가 스택 영역의 지역 변수에 접근하는 것은 복잡한 상황을 동반하기 때문
변수 생명 주기 차이 문제
process() 메서드 종료 후, 생존 중인 LocalPrinter 인스턴스의 print() 메서드 호출
변수 생명주기를 고려하면
지역변수(localVar, paramVar)는 print() 메서드 호출 시점 전 이미 소멸
process()의 스택 프레임이 사라지므로 지역 변수도 함께 소멸
그러나 실행 결과는 지역 변수들 값까지 모두 정상 출력
자바의 해결책: 지역 변수 캡처
LocalPrinter 인스턴스 생성 시점에 지역 클래스가 접근하는 지역 변수 확인
해당 지역 변수들을 복사해 인스턴스에 포함하여 생성 (paramVar, localVar)
print() 메서드에서 paramVar, localVar에 접근 시 인스턴스에 있는 캡처 변수에 접근
캡처한 paramVar , localVar 의 생명주기 = LocalPrinter 인스턴스의 생명주기
변수 생명 주기 차이 문제 해결
실제로 LocalPrinter 인스턴스 내에서 캡쳐 변수 확인 가능 (자바가 내부 생성 및 사용)
유의점: 지역 클래스가 접근하는 지역 변수 값은 변경하면 안됨
지역 클래스 접근 지역 변수는 final로 선언하거나 사실상 final이어야 함\
지역 클래스 생성 시점에 지역 변수를 캡처하므로 생성 이후에는 값을 변경해서는 안됨
스택 영역 지역 변수 값과 인스턴스 캡처 변수 값이 서로 달라지는 동기화 문제 예방
두 변수를 동기화할 시 디버깅이 어렵고 멀티스레드 상황에서도 복잡하고 성능 저하 존재
동기화 문제가 일어나지 않게 지역 변수 변경을 원천 차단하는게 깔끔
자바 문법 규칙이고 어길시 컴파일 오류 발생
사실상 final(effectively final)
final을 사용하지 않았지만, 중간에 값을 변경하지 않는 것
final을 사용해도 동일하게 작동해야 함
만일 캡처된 변수를 바꿔야 한다면, 새로 선언하면 됨 (굳이 사이드이펙트 만들 필요 X)
e.g.
int x = localVar;
x++
익명 클래스
지역 클래스의 종류 중 하나
클래스 이름을 생략하고 지역 클래스의 상속과 구현, 선언과 생성을 한번에 처리
지역 클래스의 선언과 생성
//선언
class LocalPrinter implements Printer{
//body
}
//생성
Printer printer = new LocalPrinter();
익명 클래스
Printer printer = new Printer(){
//body
}
new 다음에 상속 혹은 구현할 부모 타입 입력
부모 타입이 인터페이스일 시, 해당 인터페이스를 구현하는 익명 클래스를 생성
사용 상황
인스턴스를 한 번만 생성할 수 있어 일회성 사용에 좋고 코드가 간결해짐
복잡하거나 재사용이 필요한 경우 별도의 클래스를 정의하는 것이 나음
특징
반드시 부모 클래스를 상속 받거나 인터페이스를 구현해야 함
생성자를 가질 수 없고, 기본 생성자만 사용됨
자바 내부에서 바깥 클래스 이름 + $ + 숫자로 정의 (e.g. AnonymousOuter$1)
익명 클래스가 여러개면 숫자가 증가하며 구분
메서드의 재사용성을 높이는 팁
메서드(함수)의 재사용성을 높이기 위해 변하는 부분과 변하지 않는 부분을 분리하고 변하는 부분은 외부에서 전달 받자. (데이터 혹은 코드 조각을 파라미터로 전달)
중복을 제거하고 좋은 코드를 유지할 수 있다.
외부에서 코드 조각을 전달하는 방법
인스턴스를 전달
메서드에 코드 조각을 담아두고 실행
다만, 코드 조각을 전달하기 위해 클래스를 정의하고 인스턴스를 생성하는 것이 번거로움
e.g.
지역 클래스로 생성해 전달
익명 클래스로 생성해 전달
람다(Lambda)로 전달
인수의 타입이 되는 인터페이스가 메서드를 1개만 가지면 사용 가능
자바 8 이전: 메서드 인수로 기본형 타입과 참조형 타입만 전달할 수 있었음
자바 8 이후: 메서드 인수로 함수를 전달 가능
람다가 매우 편리 (람다가 없을 때는 코드 조각을 항상 익명 클래스로 전달했음…)
섀도잉 (Shadowing)
// 내부 클래스 예시
public class ShadowingMain {
public int value = 1;
class Inner {
public int value = 2;
void go() {
int value = 3;
System.out.println("value = " + value); // 3
System.out.println("this.value = " + this.value); // 2
System.out.println("ShadowingMain.value = " + ShadowingMain.this.value); // 1
}
}
public static void main(String[] args) {
ShadowingMain main = new ShadowingMain();
Inner inner = main.new Inner();
inner.go();
}
}
바깥 클래스의 인스턴스 변수 이름과 내부 클래스의 인스턴스 변수 이름이 같다면?
변수 이름이 같을 때 프로그래밍에서는 대부분 더 가깝거나 구체적인 것이 우선권을 가짐
새도잉: 다른 변수들을 가려서 보이지 않게 하는 것
value = 3
this.value = 2
ShadowingMain.value = 1
다른 변수를 가리더라도 인스턴스 참조를 사용해 외부 변수 접근 가능
내부 클래스 인스턴스 접근: this
바깥 클래스 인스턴스 접근: 바깥클래스이름.this
물론, 이름이 같은 경우 처음부터 이름을 서로 다르게 지어서 명확하게 구분하는 것이 더 나은 방법
public class LocalOuter {
private int outInstanceVar = 3;
public void process(int paramVar) {
int localVar = 1;
class LocalPrinter {
int value = 0;
public void printData() {
System.out.println("value=" + value); //0
System.out.println("localVar=" + localVar); //1
System.out.println("paramVar=" + paramVar); //2
System.out.println("outInstanceVar=" + outInstanceVar); //3
}
}
LocalPrinter printer = new LocalPrinter();
printer.printData();
}
public static void main(String[] args) {
LocalOuter localOuter = new LocalOuterV1();
localOuter.process(2);
}
}
Reference
김영한의 실전 자바 - 중급 1편
-
자바 날짜 시간 라이브러리
날짜 시간 라이브러리의 필요성
날짜 라이브러리는 복잡한 계산을 추상화해 제공하므로, 안정적이고 정확한 개발 가능
자바 8에서 java.time 패키지(JSR-310)를 표준 API(기능의 모음)로 도입
외부 라이브러리였던 Joda-Time의 개발자를 데려와 새로운 자바 표준 API를 함께 정의
이전 문제가 많던 API를 크게 개선 (사용성, 성능, 스레드 안정성, 타임존 처리, 불변 객체 설계 등)
날짜 계산이 어려운 이유
각 달은 28~31일로 다르게 분포
윤년 (Leap Year)
지구가 태양을 한 바퀴 도는 평균 시간은 약 365.2425일 (약 365일 5시간 48분 45초)
우리가 사용하는 그레고리력은 1년이 365일
윤년은 둘의 간극을 매우기 위한 해결책으로 4년마다 하루를 추가 (2월 29일)
100년 단위는 윤년이 아니며 400년 단위는 다시 윤년
일광 절약 시간 (Daylight Saving Time, DST) - 썸머타임
보통 3월 중순~11월 초 태양이 일찍 뜨는 것에 맞춰 1시간 앞당기는 제도
국가나 지역에 따라 적용 여부 및 시작 종료 날짜가 다름
타임존 계산
각각의 타임존은 UTC(세계 협정시)로부터의 시간 차이로 정의
London / UTC / GMT는 세계 시간의 기준이 되는 00:00 시간대
GMT (그리니치 평균시, Greenwich Mean Time)
처음 세계 시간은 영국 런던 그리니치 천문대를 기준으로 만듦
UTC (협정 세계시, Universal Time Coordinated)
GMT를 대체하기 위해 도입 (둘은 실질적으로 같은 시간대)
다만, UTC는 원자 시계를 사용해 측정해 보다 정확한 시간 유지
타임존 예시
Europe/London
GMT
UTC
America/New_York -05:00
Asia/Seoul +09:00
자바 날짜 시간 라이브러리 (time) - 클래스 분류표
유의점
모든 날짜 클래스는 불변 -> 변경이 발생하는 경우 새로운 객체를 생성해 반환
초는 나노초 정밀도로 캡처 가능
Year, Month, YearMonth, MonthDay: 자주 사용 X
DayOfWeek: 월, 화, 수, 목, 금, 토, 일을 나타내는 Enum (ChronoField)
날짜와 시간 핵심 인터페이스
TemporalAccessor 인터페이스
날짜와 시간을 읽기 위한 기본 인터페이스
날짜와 시간의 2가지 개념 (특정 시점의 시간 & 시간의 간격)
Temporal 인터페이스 - 특정시점의 시간
날짜와 시간을 조작하기 위한 기능 추가 제공
상위 인터페이스 덕분에 읽기와 쓰기 모두 지원
구현체
LocalDateTime, LocalDate, LocalTime
ZonedDateTime, OffsetDateTime
Instant
TemporalAmount 인터페이스 - 시간의 간격
특정 날짜 시간 객체에 일정 기간을 더하거나 빼는데 사용
구현체
Period , Duration
시간의 단위와 필드
- 단독 사용 X, 날짜 시간 조회나 조작에 사용
TemporalUnit 인터페이스 - 시간의 단위
날짜와 시간을 측정하는 단위
구현체(Enum): ChronoUnit
시간: NANOS, MICROS, MILLIS, SECONDS, MINUTES, HOURS
날짜: DAYS, WEEKS, MONTHS, YEARS, DECADES, CENTURIES, MILLENNIA
기타: ERAS, FOREVER
주요 메서드
between(Temporal, Temporal)
두 Temporal 객체 사이의 시간을 현재 ChronoUnit 단위로 측정하여 반환
e.g.
LocalTime lt1 = LocalTime.of(1, 10, 0);
LocalTime lt2 = LocalTime.of(1, 20, 0);
long secondsBetween = ChronoUnit.SECONDS.between(lt1, lt2);
long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);
getDuration()
현재 ChronoUnit의 기간을 Duration 객체로 반환
e.g.
ChronoUnit.HOURS.getDuration().getSeconds() //3600
ChronoUnit.DAYS.getDuration().getSeconds() //86400
TemporalField 인터페이스 - 시간의 각 필드
날짜와 시간의 특정 부분을 나타냄 (연도, 월, 일, 시간, 분)
예를 들어, 일(day)은 31보다 클 수 없는 것처럼 범위가 생김
구현체(Enum): ChronoField
연도: ERA, YEAR_OF_ERA, YEAR, EPOCH_DAY
월
MONTH_OF_YEAR: 월 (1월 = 1)
주 및 일
DAY_OF_MONTH: 월의 일 (1일 = 1)
DAY_OF_WEEK: 요일 (월요일 = 1)
DAY_OF_YEAR: 연의 일 (1월 1일 = 1)
시간
HOUR_OF_DAY: 시간 (0-23)
HOUR_OF_AMPM: 오전/오후 시간 (0-11)
CLOCK_HOUR_OF_DAY: 시계 시간 (1-24)
CLOCK_HOUR_OF_AMPM: 오전/오후 시계 시간 (1-12)
MINUTE_OF_HOUR: 분 (0-59)
SECOND_OF_MINUTE: 초 (0-59)
MILLI_OF_SECOND: 초의 밀리초 (0-999)
MICRO_OF_SECOND: 초의 마이크로초 (0-999,999)
NANO_OF_SECOND: 초의 나노초 (0-999,999,999)
기타
AMPM_OF_DAY: 하루의 AM/PM 부분
주요 메서드
range()
필드 값의 유효 범위를 ValueRange 객체로 반환 (최소값과 최대값을 제공)
e.g.
ChronoField.MONTH_OF_YEAR.range() //1 - 12
ChronoField.DAY_OF_MONTH.range() //1 - 28/31
Temporal - 특정 시점의 시간
기본 날짜 시간 표현 (LocalXxx)
특정 지역의 날짜와 시간만 고려할 때 사용 (타임존 적용 X, 시간대 고려 X)
국내 서비스만 고려할 때 권장
종류
LocalDate: 날짜만 표현 (년, 월, 일)
예) 2013-11-21
LocalTime: 시간만 표현 (시, 분, 초)
예) 08:20:30.213
밀리초, 나노초 단위도 포함 가능
LocalDateTime: LocalDate + LocalTime
예) 2013-11-21T08:20:30.213
클래스 내부에 LocalDate와 LocalTime을 필드로 가지고 있음
public class LocalDateTime {
private final LocalDate date;
private final LocalTime time;
...
}
주요 메서드
공통 메서드
생성
now(): 현재 시간 기준으로 생성
of(...): 특정 날짜를 기준으로 생성
계산
dt.plusXxx(): 특정 날짜 시간 단위를 더함
e.g. plusYears(1), plusDays(10), plusSeconds(30)
LocalDatetime
날짜와 시간 분리
dt.toLocalDate(): 주어진 LocalDateTime에서 날짜만 반환
dt.toLocalTime(): 주어진 LocalDateTime에서 시간만 반환
날짜와 시간 합체
of(...): 날짜와 시간을 묶어서 LocalDateTime으로 만들기
e.g. LocalDateTime.of(localDate, localTime)
비교
dt.isBefore(): 현재 날짜시간이 지정 날짜시간보다 이전이라면 true 를 반환
dt.isAfter(): 현재 날짜시간이 지정 날짜시간보다 이후라면 true 를 반환
dt.isEqual(): 현재 날짜시간과 지정 날짜시간 시간적으로 동일하면 true 를 반환
isEqual()
객체가 다르고 타임존이 달라도 시간적으로 같으면 true
e.g. 서울의 9시와 UTC의 0시는 시간적으로 동일
equals()
객체 타입, 타임존 등등 내부 데이터의 모든 구성요소가 같아야 true
e.g. 서울의 9시와 UTC의 0시는 타임존이 다르므로 false
시간대 적용 날짜 시간 표현 (ZonedDateTime, OffsetDateTime)
글로벌 서비스 개발 시에만 사용 (그러지 않으면 거의 사용 X)
용어
타임존(Time Zone)
오프셋과 일광 절약 시간제에 대한 정보 담김
-> 타임존을 알면 일광 절약 시간제를 알 수 있음
예) Asia/Seoul
오프셋(Offset)
UTC로 부터의 시간대 차이
예) +9:00
종류
ZoneId
자바가 제공하는 타임존 클래스
내부에 오프셋과 일광 절약 시간제 정보 포함
ZonedDateTime
시간대를 표현하는 타임존이 포함 (LocalDateTime + ZoneId)
일광 절약 시간제 적용
실제 사용 날짜와 시간 정보 표현에 적합 (비행기 시간, 회의 시간, 일상 시간 표현…)
예) 2013-11-21T08:20:30.213+9:00[Asia/Seoul]
클래스 내부에 LocalDatetime, ZoneId, ZoneOffset을 필드로 가지고 있음
public class ZonedDateTime {
private final LocalDateTime dateTime;
private final ZoneOffset offset;
private final ZoneId zone;
...
}
OffsetDateTime
타임존은 없고, 고정된 오프셋만 포함 (LocalDateTime + ZoneOffset)
일광 절약 시간제 적용 X
시간대 변환 없이 로그를 기록하고 처리할 때 적합
로그는 순차적으로 쌓여야 함, 썸머타임 적용으로 1시간 당겨지는 상황 있으면 안됨
예) 2013-11-21T08:20:30.213+9:00
클래스 내부에 LocalDatetime, ZoneOffset을 필드로 가지고 있음
public class OffsetDateTime {
private final LocalDateTime dateTime;
private final ZoneOffset offset;
...
}
주요 메서드
공통 메서드
생성
now(): 현재 시간 기준으로 생성 (ZoneId는 현재 시스템을 따름)
of(...): 특정 날짜를 기준으로 생성
ZonedDatetime
of(...) 사용법
단순 생성
ZonedDateTime zdt = ZonedDateTime.of(2030, 1, 1, 13, 30, 50, 0, ZoneId.of("Asia/Seoul"));
LocalDatetime + ZoneId로 생성하기
LocalDateTime ldt = LocalDateTime.of(2030, 1, 1, 13, 30, 50);
ZonedDateTime zdt = ZonedDateTime.of(ldt, ZoneId.of("Asia/Seoul"));
타임존 변경하기
zdt.withZoneSameInstant(): 입력한 타임존으로 변경
e.g. zdt.withZoneSameInstant(ZoneId.of("UTC"))
OffsetDatetime
of(...) 사용법
LocalDatetime + ZoneOffset로 생성하기
LocalDateTime ldt = LocalDateTime.of(2030, 1, 1, 13, 30, 50);
OffsetDateTime odt = OffsetDateTime.of(ldt, ZoneOffset.of("+01:00"));
이외
ZoneId
getAvailableZoneIds(): 이용 가능한 모든 ZoneId 반환
systemDefault(): 시스템이 사용하는 기본 ZoneId 반환
of(...): 타임존을 직접 제공해서 ZoneId로 변환
e.g. ZoneId.of("Asia/Seoul")
기계 중심의 시간 (Instant)
UTC를 기준으로 하는 시간의 한 지점
1970년 1월 1일 0시 0분 0초(UTC)를 기준으로 경과한 시간으로 계산 (초 데이터)
클래스 내부에 초 데이터를 필드로 가짐 (나노초 정밀도)
public class Instant {
private final long seconds;
private final int nanos;
...
}
일반적으로 LocalDateTime , ZonedDateTime를 사용하고 Instant는 특별한 경우에 사용
기준점이 명확하나(UTC), 사람이 읽기 어렵고 초 단위 간단한 연산만 가능
사용 예
로그 기록, 트랜잭션 타임스탬프, 서버 간 시간 동기화 등 전 세계적으로 일관된 시점 표현 시
지속 시간 계산 등 시간대 변화 없는 순수한 시간 흐름만을 다룰 때
DB에 날짜 시간 저장하거나 다른 시스템과 날짜 시간 정보를 교환할 때
주요 메서드
생성
now()
UTC를 기준 현재 시간의 Instant 를 생성
from()
다른 타입의 날짜와 시간을 기준으로 Instant 를 생성
LocalDateTime 사용 불가 (Instant 는 UTC 기준이어서 시간대 정보가 필요)
e.g.
ZonedDateTime zdt = ZonedDateTime.now();
Instant from = Instant.from(zdt);
ofEpochSecond()
에포크 시간을 기준으로 Instant 를 생성
ofEpochSecond(0) -> 에포크 시간인 1970년 1월 1일 0시 0분 0초로 생성
ofEpochSecond(30) -> 1970/1/1/0/0/30
계산
plusSeconds() : 초, 밀리초, 나노초 정도만 더하는 간단한 메서드
조회
getEpochSecond() : UTC 1970년 1월 1일 0시 0분 0초를 기준으로 흐른 초를 반환
Epoch 시간
Epoch time(에포크 시간) 또는 Unix timestamp는 컴퓨터 시스템에서 시간을 나타내는 방법 중 하나이다. 1970년 1월 1일 00:00:00 UTC부터 현재까지 경과된 시간을 초 단위로 표현한 것이다. 즉, 시간대에 영향을 받지 않는 절대적인 시간 표현 방식이다.
Instant는 Epoch 시간을 다루는 클래스이다.
TemporalAmount - 시간의 간격 (기간, 시간의 양, amount of time)
년, 월, 일 단위 표현 (Period)
클래스 내부에 년, 월, 일을 필드로 가짐
public class Period {
private final int years;
private final int months;
private final int days;
}
주요 메서드
생성
of() : 특정 기간을 지정해서 Period 를 생성
of(년, 월, 일)
ofDays()
ofMonths()
ofYears()
계산
더하기
특정 날짜 인스턴스의 plus() 메서드를 사용해 기간을 더할 수 있음
e.g.
LocalDate currentDate = LocalDate.of(2030, 1, 1);
Period period = Period.ofDays(10);
LocalDate plusDate = currentDate.plus(period);
between(): 기간 차이 구하기 (Period 반환)
LocalDate startDate = LocalDate.of(2023, 1, 1);
LocalDate endDate = LocalDate.of(2023, 4, 2);
Period between = Period.between(startDate, endDate); //Period 반환
조회
getYears(), getMonths(), getDays()
시, 분, 초(나노초) 단위 표현 (Duration)
클래스 내부에 초 데이터만 필드로 가짐
내부에서 초를 기반으로 시, 분, 초를 계산해서 사용
public class Duration {
private final long seconds;
private final int nanos;
}
주요 메서드
생성
of() : 특정 시간을 지정해서 Duration 를 생성
of(지정)
ofSeconds()
ofMinutes()
ofHours()
계산
더하기
특정 시간 인스턴스의 plus() 메서드를 사용해 시간을 더할 수 있음
e.g.
LocalTime lt = LocalTime.of(1, 0);
Duration duration = Duration.ofMinutes(30);
LocalTime plusTime = lt.plus(duration);
between(): 시간 차이 구하기 (Duration 반환)
LocalTime start = LocalTime.of(9, 0);
LocalTime end = LocalTime.of(10, 0);
Duration between = Duration.between(start, end); //Duration 반환
조회
get은 바로 가져오는 느낌, to는 계산을 하는 느낌 (Duration은 내부에 초 데이터만 보유)
toHours(), toMinutes()
getSeconds(), getNano()
일반적인 x시간 x분을 출력할 때는 toHoursPart() + toMinutesPart() 조합 사용
toHoursPart(), toMinutesPart(), toSecondsPart()
날짜와 시간 조회 및 조작
일관성 있는 시간 조회 및 조작 기능 제공 (인터페이스 설계가 잘되어 있음)
불변 객체이므로 메서드 체이닝 가능
기본 규칙
조회 방법
편의 메서드 사용 (가독성을 위해 권장)
자주 사용하는 조회 필드는 간단한 편의 메서드 제공
getYear(), getMonthValue(), getDayOfMonth(), getHour(), getMinute(), getSecond(), getDayOfWeek()
TemporalAccessor.get(TemporalField field)
ChronoField 인수로 전달해, 날짜 시간 객체에서 원하는 단위로 조회 가능
get(ChronoField.YEAR), get(ChronoField.MONTH_OF_YEAR), get(ChronoField.DAY_OF_MONTH), get(ChronoField.HOUR_OF_DAY), get(ChronoField.MINUTE_OF_HOUR), get(ChronoField.SECOND_OF_MINUTE), get(ChronoField.DAY_OF_WEEK)
편의 메서드에 없는 경우 사용
조작 방법
편의 메서드 사용
자주 사용하는 메서드는 간단한 편의 메서드 제공
plus -> plusXxx, minus -> minusXxx
Temporal plus(TemporalAmount amount)
Period, Duration 인수로 전달해 조작 가능
Temporal plus(long amountToAdd, TemporalUnit unit)
시간의 양과 ChronoUnit 인수로 전달해, 특정 시점의 시간을 조작 가능
isSupported() - TemporalAccessor & Temporal 인터페이스
현재 타입에서 특정 시간 단위나 필드를 사용할 수 있는지 확인
e.g. LocalDate에는 시, 분, 초 단위 관련 조회 및 조작을 할 수 없음
LocalDate now = LocalDate.now();
boolean supported = now.isSupported(ChronoField.SECOND_OF_MINUTE);//false
if (supported) {
int minute = now.get(ChronoField.SECOND_OF_MINUTE);
}
기간 차이 구하기
남은 기간
Period, Duration의 between()
e.g.
Period period = Period.between(startDate, endDate);
년: period.getYears() / 월: period.getMonths() / 일: period.getDays()
디데이
ChronoUnit의 between(Temporal, Temporal)
e.g. long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
with()
복잡한 날짜 계산에 적합
날짜와 시간의 특정 필드 값만 변경하는 것이 가능
방법
편의 메서드
자주 사용하는 메서드는 간단한 편의 메서드 제공
dt.with(ChronoField.YEAR, 2020) -> dt.withYear(2020)
TemporalAdjusters 사용
TemporalAdjuster 인터페이스의 구현체 묶음 (자바가 만들어 둠)
더욱 복잡한 날짜 계산 가능
e.g
dt.with(TemporalAdjusters.next(DayOfWeek.FRIDAY))
다음주 금요일 구하기
dt.with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY))
이번 달의 마지막 일요일 구하기
Temporal with(TemporalField field, long newValue)
단순한 날짜만 변경 가능
e.g. dt.with(ChronoField.YEAR, 2020)
날짜와 시간 문자열 파싱과 포멧팅
포멧팅과 파싱
포멧팅: Date -> String
파싱: String -> Date
DateTimeFormatter
날짜와 시간 포멧팅 및 파싱에 사용
포멧팅: ofPattern()
LocalDate date = LocalDate.of(2024, 12, 31);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd 일");
String formattedDate = date.format(formatter); //2024년 12월 31일
파싱: 특정 날짜 객체의 parse()
LocalDate date = LocalDate.of(2024, 12, 31);
String input = "2030년 01월 01일";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd 일");
LocalDate parsedDate = LocalDate.parse(input, formatter);
자주 쓰이는 패턴
y: 연대의 연도
M: 연중 월
d: 월의 일수
H: 24시간제 시(0-23)
m: 분
s: 초
패턴 예시
“yyyy년 MM월 dd 일”
“yyyy-MM-dd HH:mm:ss”
ISO 8601
날짜와 시간의 표준 출력.
Reference
김영한의 실전 자바 - 중급 1편
-
자바 lang 패키지
java.lang 패키지
자바 언어를 이루는 가장 기본이 되는 클래스들을 보관하는 패키지
모든 자바 애플리케이션에서 자동으로 import됨 (import 생략 가능)
대표 클래스
Object: 모든 자바 객체의 부모 클래스
String: 문자열
Integer, Long, Double: 래퍼타입, 기본형 데이터 타입을 객체로 만든 것
Class: 클래스 메타 정보
System: 시스템과 관련된 기본 기능들 제공
Object 클래스
자바에서 모든 클래스의 최상위 부모 클래스
클래스에 상속 받을 부모 클래스가 없으면 묵시적으로 Object 클래스를 상속 받음
public class Parent {...} == public class Parent extends Object {...}
묵시적 상속으로 인해 Object는 메모리에도 함께 생성됨
Object 클래스가 최상위 부모 클래스인 이유
공통 기능 제공
모든 객체에 필요한 기본 기능을 구현
모든 개발자들이 직접 만들 필요 없이 일관성 있게 사용 & 프로그래밍이 단순화
Object가 없다면 수많은 개발자들이 유사한 공통 부모 클래스를 구현해 일관성 X…
다형성의 기본 구현 (한계 존재)
다형성의 올바른 활용 = 다형적 참조 + 메서드 오버라이딩
장점: 다형적 참조 가능
모든 객체를 다 담을 수 있으므로, 다양한 타입의 객체를 통합적으로 처리 가능
한계: 메서드 오버라이딩 불가
자식 클래스의 기능을 사용하려면 다운캐스팅을 해야만 함
toString 같은 Object가 보유한 메서드는 당연히 오버라이딩 가능
제공 메서드
toString(): 객체의 정보를 문자열 형태로 제공
기본 로직: 패키지 포함 객체의 이름 + 16진수화된 객체의 참조값 (해시 코드)
IDE를 통해 재정의하면 편리
참고) System.out.println() 메서드 내부에서 호출됨
public void println(Object x) {...}
-> String.valueOf(x) -> (obj == null) ? "null" : obj.toString()
다형성을 활용한 OCP의 좋은 예
Object를 인자로 받아 다형적 참조
Open: toString을 오버라이딩해 기능 확장
Closed: 클라이언트 코드인 println()은 변경 X
-> 덕분에 세상 모든 객체의 정보를 편리하게 출력 가능
equals(): 객체의 같음을 비교 (동등성 비교)
“두 객체가 같다”는 2가지 의미
동일성(Identity)
두 객체가 참조값이 같은 동일한 객체인지 확인 (==)
자바 머신 기준, 메모리 참조, 물리적
동등성(Equality)
두 객체가 논리적으로 동등한지 확인 (equals())
사람 기준, 논리적
기본 로직: == 동일성 비교 제공
동등성 개념은 각각의 클래스마다 다르기 때문에, 동등성이 필요한 경우 재정의 (IDE 활용)
getClass(): 객체의 클래스 정보를 제공
hashCode()
clone(): 객체 복사 (잘 사용 X)
notify(), notifyAll(), wait(): 멀티 쓰레드용 메서드
…
객체의 참조값 출력
toString()의 기본 사용 이외에 다음 코드를 사용하면 객체의 참조값을 직접 출력할 수 있다.
String refValue = Integer.toHexString(System.identityHashCode(dog1));
System.out.println("refValue = " + refValue);
출력값: refValue = 72ea2f77
equals() 메서드 구현 시 지켜야할 규칙 (중요 X)
반사성(Reflexivity): 객체는 자기 자신과 동등해야 한다. ( x.equals(x) 는 항상 true ).
대칭성(Symmetry): 두 객체가 서로에 대해 동일하다고 판단하면, 이는 양방향으로 동일해야 한다. (x.equals(y) 가 true 이면 y.equals(x) 도 true ).
추이성(Transitivity): 만약 한 객체가 두 번째 객체와 동일하고, 두 번째 객체가 세 번째 객체와 동일하다면, 첫 번째 객체는 세 번째 객체와도 동일해야 한다.
일관성(Consistency): 두 객체의 상태가 변경되지 않는 한, equals() 메소드는 항상 동일한 값을 반환해야 한다.
null에 대한 비교: 모든 객체는 null 과 비교했을 때 false 를 반환해야 한다.
불변 객체
핵심: 불변이라는 단순한 제약을 사용해 사이드 이펙트라는 큰 문제 막을 수 있음
참조형 객체 공유의 문제
기본형과 참조형의 공유
기본형(Primitive Type): 하나의 값을 여러 변수에서 절대로 공유하지 않음 (값 복사 후 대입)
참조형(Reference Type): 하나의 객체를 참조값을 통해 여러 변수에서 공유 가능
따라서, 참조형은 사이드 이펙트 발생 가능성이 높음
사이드 이펙트: 특정 변경이 의도치 않게 다른 부분에 영향을 미침
디버깅이 어려움 & 코드 안정성 저하
또한, 여러 변수가 하나의 객체를 공유하는 것을 막을 방법 X
객체 공유는 개발자가 변수마다 인스턴스를 생성하여 방지 가능
하지만 자바 문법상 참조형 변수 대입은 문제 없기 때문에, 객체 공유를 완벽히 막을 방법은 없음
참조형 변수 대입 (Address b = a) -> 여러 변수가 하나의 객체 공유
사실 근본 원인은 객체를 공유한 것이 아니라 공유될 수 있는 객체의 값을 변경한 것이 문제
결론: 객체의 값을 변경하지 못하게 설계하면 사이드 이펙트 원천 차단 가능
불변 객체 (Immutable Object)
객체의 상태가 변하지 않는 객체 (객체 내부의 값, 필드, 멤버 변수)
설계 전략: 생성자를 통해서만 값을 설정하고, 이후 값 변경 막기
내부 필드를 final로 선언
setXxx() 메서드 제거
개발자는 컴파일 오류 변경 메서드가 없다는 사실 인지 -> 어쩔 수 없이 새 인스턴스 생성
값 변경 필요 시 계산 결과를 새로운 객체로 만들어 반환
기존 값은 변경 X, 계산 결과를 바탕으로 새로운 객체 만들어 반환
(불변 유지 + 새로운 결과)
불변 객체의 변경 관련 메서드들은 보통 새 객체를 만들어 반환하므로 반환 값을 받아야 함
불변 객체 예시 코드
public class ImmutableAddress {
private final String value;
public ImmutableAddress(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@Override
public String toString() {
return "Address{" +
"value='" + value + '\'' +
'}';
}
}
불변 객체 값 변경 예시 코드
public class ImmutableObj {
private final int value;
public ImmutableObj(int value) {
this.value = value;
}
public ImmutableObj add(int addValue) {
int result = value + addValue;
return new ImmutableObj(result);
}
public int getValue() {
return value;
}
}
불변 객체의 의의
자바가 기본으로 제공하는 수많은 클래스들은 불변으로 설계되어 있음
가변 클래스가 더 일반적이고, 불변 클래스는 값을 변경하면 안되는 특별한 경우에 만들어서 사용
같은 기능의 클래스를 하나는 불변, 하나는 가변으로 각각 만드는 경우도 있음
활용 예시
캐시 안정성
멀티 쓰레드 안정성
엔터티의 값 타입 설정에 유용
가변 객체 vs 불변 객체
가변 객체 (Mutable): 처음 만든 이후로 상태가 변할 수 있는 객체
불변 객체 (Mutable): 처음 만든 이후로 상태가 변하지 않는 객체
withXxx() 네이밍 컨벤션
불변 객체에서 값을 변경하는 경우, 메서드 이름이 “with”로 시작하는 경우가 많다.
이는 원래의 상태를 변경하여 새로운 변형을 만든다는 의미를 함유한다. (= coffee with sugar)
즉, 원본 객체의 상태가 그대로 유지됨을 강조하면서 변경사항을 새 복사본에 포함하는 과정을 간결하게 표현하는 것이고, 불변 객체의 변경 메서드 내용은 이와 잘 어울린다.
String 클래스
문자열을 편리하게 다룰 수 있도록 기능 제공 (char[]로 여러 문자를 직접 다루는 불편함을 해소)
클래스이므로 참조형 문자열 객체 생성
String 클래스를 통한 문자열 생성 방법
쌍따옴표 사용: "hello"
객체 생성: new String("hello");
문자열은 매우 자주 다루어지므로, 편의상 "", + 등의 연산을 제공해 문자열 처리
문자열 비교
public class StringEqualsMain1 {
public static void main(String[] args) {
String str1 = new String("hello");
String str2 = new String("hello");
System.out.println("new String() == 비교: " + (str1 == str2));
System.out.println("new String() equals 비교: " + (str1.equals(str2)));
String str3 = "hello";
String str4 = "hello";
System.out.println("리터럴 == 비교: " + (str3 == str4));
System.out.println("리터럴 equals 비교: " + (str3.equals(str4)));
}
}
// 실행 결과
// new String() == 비교: false
// new String() equals 비교: true
// 리터럴 == 비교: true
// 리터럴 equals 비교: true
결론: 항상 equals() 동등성 비교해야 함
String 인스턴스는 new String() 혹은 문자열 리터럴로 만들어질 수 있음
메서드를 사용할 때 String 타입 인자로 둘 중 무엇이 들어올지 알 수 없기 때문
new String() 끼리 비교 시: 동일성 비교 실패 & 동등성 비교 성공
서로 다른 인스턴스이므로 동일성 비교 실패
String 클래스는 동등성 비교를 할 수 있도록 equals() 메서드를 재정의해둠
문자열 리터럴 끼리 비교 시: 동일성 비교 성공 & 동등성 비교 성공
문자열 리터럴을 사용하는 경우, 자바는 메모리 효율성과 성능 최적화를 위해 문자열 풀을 사용
문자열 풀은 힙 영역을 사용하며 메모리 사용과 문자를 만드는 시간을 줄임
자바는로딩 시점에 클래스들을 읽어들이면서
클래스에 문자열 리터럴이 있으면 문자열 풀에 String 인스턴스를 미리 생성해둠
이 때, 같은 문자열이 있으면 만들지 않음
실행 시점에 문자열 리터럴을 사용하면, 문자열 풀에서 String 인스턴스를 찾음
해시 알고리즘을 사용해 매우 빠르게 인스턴스를 찾음
String str3 = "hello", String str4 = "hello"은 같은 참조값 사용
-> 동일성 비교 성공
String은 불변 객체로 설계됨
생성 이후 내부 문자열 값을 변경 불가 & 변경 관련 메서드도 새로운 String 객체를 만들어 반환
불변으로 설계된 이유
사이드 이펙트를 막기 위해
문자열 풀에 있는 String 인스턴스 값 변경 -> 같은 문자열을 참조하는 다른 변수도 함께 변경
구조
public final class String {
//문자열 보관
private final char[] value; // 자바 9 이전
private final byte[] value; // 자바 9 이후
//여러 메서드
public String concat(String str) {...}
public int length() {...}
...
}
문자열 보관
Java 9 이후에는 메모리를 더 효율적으로 사용하기 위해 문자열 보관에 byte[] 사용
char는 문자 하나당 무조건 2byte를 차지
다만, 영어, 숫자는 보통 1byte 표현 가능하고 나머지는 2byte UTF-16 인코딩 사용 가능
주요 메서드
length() : 문자열의 길이를 반환
charAt(int index) : 특정 인덱스의 문자를 반환
indexOf(String str) : 특정 문자열이 시작되는 인덱스를 반환
substring(int beginIndex, int endIndex) : 문자열의 부분 문자열을 반환
contains(CharSequence s) : 문자열이 특정 문자열을 포함하고 있는지 확인
toLowerCase() , toUpperCase() : 문자열을 소문자 또는 대문자로 변환
trim() : 문자열 양 끝의 공백을 제거
concat(String str) : 문자열을 더함 (+ 연산도 concat 사용)
valueOf(Object obj) : 다양한 타입을 문자열로 변환 (숫자, 불리언, 객체…)
format(String format, Object... args
e.g.1 String.format("num: %d, bool: %b, str: %s", num, bool, str);
e.g.2 String.format("숫자: %.2f", 10.1234); //10.12
e.g.3 System.out.printf("숫자: %.2f\n", 10.1234); //10.12
split(String regex) : 문자열을 정규 표현식을 기준으로 분할
join(CharSequence delimiter, CharSequence... elements) : 주어진 구분자로 여러 문자열을 결합
e.g.1
String.join("-", "A", "B", "C"); //A-B-C
e.g.2
String[] splitStr = str.split(",");
String.join("-", splitStr);
자바의 String 최적화
불변 String 클래스의 단점
String str = "A" + "B" + "C" + "D";
String str = String("A") + String("B") + String("C") + String("D");
String str = new String("AB") + String("C") + String("D");
String str = new String("ABC") + String("D");
String str = new String("ABCD");
문자를 더하거나 변경할 때 마다 계속해서 새로운 객체를 생성
new String("AB"), new String("ABC") 는 제대로 사용되지도 않고, GC 대상
많은 CPU, 메모리 자원 소모
StringBuilder는 성능과 메모리면에서 효율적 (가변 String)
StringBuilder 는 내부에 final 이 아닌 변경할 수 있는 byte[] 을 가짐
가변은 사이드 이펙트에 유의해 사용해야 함
문자열을 변경하는 동안만 사용하다가 변경이 끝나면 안전한(불변) String 으로 변환할 것
예시 코드 1
public class StringBuilderMain {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append("A");
sb.append("B");
sb.append("C");
sb.append("D");
System.out.println("sb = " + sb); //ABCD
sb.insert(4, "Java");
System.out.println("insert = " + sb); //ABCDJava
sb.delete(4, 8);
System.out.println("delete = " + sb); //ABCD
sb.reverse();
System.out.println("reverse = " + sb); //DCBA
//StringBuilder -> String
String string = sb.toString();
System.out.println("string = " + string); //DCBA
}
}
예시 코드 2 (메서드 체이닝)
public class StringBuilderMain1_2 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
String string = sb.append("A").append("B").append("C").append("D")
.insert(4, "Java")
.delete(4, 8)
.reverse()
.toString();
System.out.println("string = " + string);
}
}
실무 사용 전략
대부분의 경우 최적화가 되므로 + 연산 사용
문자열 리터럴 최적화
자바 컴파일러는 문자열 리터럴 더하기를 자동으로 합쳐줌
컴파일 전: String helloWorld = "Hello, " + "World!";
컴파일 후: String helloWorld = "Hello, World!";
런타임에 별도 문자열 결합 연산을 수행하지 않으므로 성능 향상
String 변수 최적화
문자열 변수의 경우 안에 어떤 값이 들어있는지 컴파일 시점에는 알 수 없음
따라서 컴파일러가 StringBuilder()를 사용해 자동으로 최적화
String result = str1 + str2;
-> String result = new StringBuilder().append(str1).append(str2).toString();
최적화가 어려운 경우에만 StringBuilder 사용
루프 안에서 문자열을 더하는 경우, 최적화가 이루어지지 않음
String result = "";
for (int i = 0; i < 100000; i++) {
result += "Hello Java ";
}
//의도와 다르게 최적화되는 코드
//String result = "";
//for (int i = 0; i < 100000; i++) {
// result = new StringBuilder().append(result).append("Hello Java").toString();
//}
컴파일러가 반복을 예측할 수 없음
따라서, 최적화에 실패하고 대략 10만번 문자열 객체를 생성할 것 (2490ms)
이런 경우, 직접 StringBuilder 사용할 것 (3ms)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append("Hello Java ");
}
String result = sb.toString();
최적화가 어려운 경우
반복문에서 반복해서 문자 연결 (1000번 넘게 간다 싶을 때 빌더 사용)
조건문을 통해 동적으로 문자열 조합
복잡한 문자열의 특정 부분 변경
매우 긴 대용량 문자열 다루기
CharSequence
CharSequence는 String, StringBuilder의 상위 타입이다. 문자열을 처리하는 다양한 객체를 받을 수 있다.
StringBuilder VS StringBuffer
StringBuffer는 StringBuilder와 똑같은 기능을 수행한다.
차이점은 StringBuffer는 내부에 동기화가 되어 있어서, 멀티쓰레드 상황에 안전하다. 물론, 동기화 오버헤드로 인해 성능은 느리다.
메서드 체이닝 (Method Chaining)
메서드 호출의 결과로 자기 자신의 참조값을 반환하도록 설계하는 것이다. (return this;)
이 경우, 반환된 참조값을 사용해서 .을 찍고 메서드 호출을 계속 이어갈 수 있다.
StringBuilder를 포함해 자바의 라이브러리와 오픈 소스들이 종종 사용한다.
메서드 체이닝은 코드를 간결하고 읽기 쉽게 만들어주는 효과가 있다.
문자열 뒤집기
StringBuilder의 reverse()를 사용하면 편리하게 문자열을 역순으로 얻을 수 있다.
String reversed = new StringBuilder(str).reverse().toString();
래퍼 클래스 (Wrapper Class)
기본형을 감싸서 만드는 클래스 (=기본형의 객체 버전)
기본형(Primitive Type)이 객체가 아니어서 발생하는 한계
객체 지향의 장점을 살릴 수 없음
메서드 제공 X, 객체 참조가 필요한 컬렉션 프레임워크 사용 불가, 제네릭 사용 불가
null 값을 가질 수 없음
데이터가 없음이라는 상태도 필요성이 있는데 불가능
래퍼 클래스는 기본형의 한계를 해결
자바는 기본형에 대응하는 래퍼 클래스를 기본 제공
특징
불변 객체로 설계됨
equals()로 비교해야 함 (==는 참조형이라 안맞음)
equals()와 toString()은 재정의 되어 있음
박싱(Boxing)
기본형을 래퍼 클래스로 변경하는 것
e.g. Integer.valueOf(10)
valueOf(...) 사용 권장
성능 최적화 기능 존재
Integer의 경우 개발자들이 일반적으로 자주 사용하는 -128 ~ 127 범위
해당 범위의 Integer 객체를 미리 생성해두고 조회시 미리 생성된 값 반환 (캐싱)
해당 범위가 아닌 값을 조회시 new Integer()를 호출
valueOf는 내부에서 new Integer(...)을 사용해 객체를 생성하고 돌려줌
new Integer(10) 방식은 향후 자바에서 제거될 예정이므로, 직접 사용 X
언박싱(Unboxing)
래퍼 클래스에 들어있는 기본형 값을 다시 꺼내는 것
intValue(), longValue() 등의 메서드 사용
오토 박싱(Auto-boxing)
컴파일러가 개발자 대신 valueOf, xxxValue() 등의 코드를 추가해주는 기능 (컴파일 단계)
기본형과 래퍼형의 편리한 변환 가능
자바는 1.5부터 오토박싱, 오토 언박싱 지원
예시
int value = 7;
Integer boxedValue = value; // 오토 박싱(Auto-boxing)
int unboxedValue = boxedValue; // 오토 언박싱(Auto-Unboxing)
주요 메서드
valueOf() : 래퍼 타입을 반환 (숫자, 문자열을 모두 지원)
parseInt() : 기본형 반환 (문자열 전용, parseXxx)
compareTo() : 내 값과 인수로 넘어온 값을 비교 (내 값이 크면 1 ,같으면 0 , 작으면 -1 을 반환)
Integer.sum() , Integer.min() , Integer.max() : static 메서드
래퍼 클래스 실무 사용 전략
CPU 연산을 많이 수행하는 특수한 경우에만 기본형 사용해 최적화 (수만~수십만 이상 연속한 연산)
이외에는 코드 유지보수에 더 나은 방향 선택
최신 컴퓨터는 매우 빠르므로 적은 연산 차이는 실질적 도움 X
성능 최적화는 대부분 더 많은 복잡한 코드 요구
특히 웹 애플리케이션의 경우 네트워크 호출을 한 번 줄이는 게 더 효과적
메모리 내 연산 하나보다 네트워크 호출 한 번이 많게는 수십만배 더 오래 걸림
개발 이후 성능 테스트 해보고 정말 문제가 되는 부분을 찾아 최적화
기본형과 래퍼 클래스의 성능 차이 (Integer 기준)
속도
10억번 누적합을 구하는 시나리오
기본형 연산은 래퍼 클래스 연산보다 5배 빠름 (318ms VS 1454ms)
메모리 차이
기본형: 4byte
래퍼 클래스: 4byte + 8~16byte (내부 필드 기본형 값 + 객체 메타데이터)
Class 클래스
클래스의 정보(메타데이터)를 다루는데 사용
런타임에 필요한 클래스의 속성과 메서드 정보를 조회하고 조작 가능
주요 기능
타입 정보 얻기: 클래스의 이름, 슈퍼클래스, 인터페이스, 접근 제한자 등과 같은 정보를 조회
리플렉션: 클래스에 정의된 메서드, 필드, 생성자 등을 조회하고, 이들을 통해 객체 인스턴스를 생성하거나 메서드 를 호출하는 등의 작업 가능
동적 로딩과 생성: Class.forName() 메서드를 사용하여 클래스를 동적으로 로드하고, newInstance() 메서드를 통해 새로운 인스턴스를 생성
애노테이션 처리: 클래스에 적용된 애노테이션(annotation)을 조회하고 처리하는 기능을 제공
주요 메서드
클래스 객체 조회
Class clazz = String.class; // 1.클래스에서 조회
Class clazz = new String().getClass(); // 2.인스턴스에서 조회
Class clazz = Class.forName("java.lang.String"); // 3.문자열로 조회
클래스 객체 메서드
getDeclaredFields(): 클래스의 모든 필드를 조회
getDeclaredMethods(): 클래스의 모든 메서드를 조회
getSuperclass(): 클래스의 부모 클래스를 조회
getInterfaces(): 클래스의 인터페이스들을 조회
리플렉션 예시: 클래스 메타 정보 기반 인스턴스 생성하기
Class helloClass = Hello.class;
Hello hello = (Hello) helloClass.getDeclaredConstructor().newInstance();
class VS clazz
class는 자바의 예약어이므로, 패키지명 및 변수명으로 사용할 수 없다.
자바 개발자들은 이를 대신하여 clazz를 관행으로 사용한다.
System 클래스
시스템과 관련된 기본 기능들 제공
주요 기능
System.in , System.out , System.err: 표준 입력, 표준 출력, 오류 스트림
System.currentTimeMillis(), System.nanoTime(): 밀리초, 나노초 단위 현재 시간 제공
System.getenv(): OS에서 설정한 환경 변수의 값 제공
System.getProperties(): 현재 모든 시스템 속성 제공 (자바에서 사용하는 설정 값)
System.getProperty(String key): 특정 시스템 속성 제공
System.exit(int status): 프로그램 종료 및 OS에 프로그램 종료의 상태 코드 전달 (사용 지양)
상태코드 0: 정상종료
상태 코드 0 이 아님: 오류나 예외적인 종료
System.arraycopy: 배열 고속 복사
시스템 레벨에서 최적화된 메모리 복사 연산 사용
직접 반복문 을 사용해서 배열을 복사할 때 보다 수 배 이상 빠른 성능을 제공
e.g. System.arraycopy(originalArray, 0, copiedArray, 0, originalArray.length);
Math, Random 클래스
Math 클래스
다양한 수학 문제를 해결해주는 클래스
주요 메서드
abs(x) : 절대값
max(a, b) : 최대값
min(a, b) : 최소값
exp(x) : e^x 계산
log(x) : 자연 로그
log10(x) : 로그 10
pow(a, b) : a의 b제곱
ceil(x) : 올림
floor(x) : 내림
round(x) : 반올림
sqrt(x) : 제곱근
cbrt(x) : 세제곱근
random() : 0.0과 1.0 사이의 무작위 값 생성 (double 값)
Random 클래스 (java.util 패키지)
Math.random() 보다 다양한 랜덤값을 구할 수 있도록 기능 제공
Math.random()도 내부에서는 Random 클래스 사용
Random 객체 생성 방법
기본 생성자
Random random = new Random();
생성자를 비워두면 씨드값을 자동 생성해 사용 (매 반복마다 결과가 달라짐)
System.nanoTime() + 여러가지 복잡한 알고리즘 => 씨드값을 생성
생성자의 Seed 전달
Random random = new Random(1);
랜덤은 내부에서 씨드값을 사용해 랜덤값을 구함
씨드값이 같으면 항상 같은 결과를 출력
주요 메서드
random.nextInt() : 랜덤 int 값을 반환
nextInt(int bound) : 0 ~ bound 미만의 숫자를 랜덤으로 반환
예를 들어서 3을 입력하면 0, 1, 2 를 반환한다.
활용: 1 ~ 10 까지 반환하기
random.nextInt(10) + 1
nextDouble() : 0.0d ~ 1.0d 사이의 랜덤 double 값을 반환
nextBoolean() : 랜덤 boolean 값을 반환
정밀 계산에는 BigDecimal을 활용하자.
열거형 - Enum
단순 문자열 처리는 오타나 유효하지 않은 값이 입력될 수 있어 타입 안정성이 떨어짐 (컴파일 오류 감지 X)
e.g. 회원 등급 별 할인 - DIAMOND, GOLD, BASIC
특정 범위로 값 제한 필요
해결 과정 단계
1단계: 문자열 상수 처리
e.g. public static final String BASIC = "BASIC"
장점: 문자열 상수를 사용하면 유효하지 않은 값에 대해 컴파일 오류 발생
단점: 개발자가 실수로 정의해둔 문자열 상수를 사용하지 않으면, 여전히 직접 문자열 입력 가능
public int discount(String grade, int price) {}
위 코드를 보면 개발자는 당연히 모든 문자열을 입력할 수 있다고 생각하게 됨
2단계: 타입 안전 열거형 패턴 (Type-Safe Enum Pattern)
public class ClassGrade {
public static final ClassGrade BASIC = new ClassGrade();
public static final ClassGrade GOLD = new ClassGrade();
public static final ClassGrade DIAMOND = new ClassGrade();
private ClassGrade() {}
}
핵심: 나열한 항목만 사용할 수 있게 제한
애플리케이션 로딩 시점에(static) 각각의 상수에 별도 인스턴스를 생성해 구분
외부에서 new ClassGrade() 생성 및 전달을 막기 위해 private 생성자를 둠
장점
타입 안정성 및 데이터 일관성 향상 (컴파일 오류 체크 가능)
public int discount(ClassGrade classGrade, int price) {}
사전에 정의해둔 인스턴스만 사용할 수 있음
== 동일성 비교 가능 (문자열 처리 시 equals()를 사용해야 했음)
단점: 많은 코드 작성 & private 생성자 추가 유의점
3단계: 열거형 (Enum Type)
타입 안전 열거형 패턴을 쉽게 사용할 수 있도록 프로그래밍 언어에서 지원
예상 가능한 집합을 표현하는 데 사용
Enumeration(in 프로그래밍): 상수들을 사용하여 코드 내에서 미리 정의된 값들의 집합
타입 안정성 및 코드 가독성 향상
static import 사용 시 가독성 더욱 향상
기본 사용법
public enum Grade {
BASIC, GOLD, DIAMOND
}
열거형도 (제약이 추가된) 클래스 (class 대신 enum 키워드를 사용할 뿐)
열거형은 자동으로 java.lang.Enum을 상속 받음 (extends Enum, 추가 상속 불가)
외부 임의 생성 불가 (private 생성자)
인터페이스 구현이 가능
열거형에 추상 메서드 선언 및 구현 가능 (익명 클래스와 같은 방식 사용)
주요 메서드
values(): 모든 ENUM 상수를 포함하는 배열을 반환
Enum 상수
valueOf(String name): 주어진 이름과 일치하는 ENUM 상수를 반환
name(): ENUM 상수의 이름을 문자열로 반환
ordinal(): ENUM 상수의 선언 순서(0부터 시작)를 반환 (사용 지양)
중간에 상수 선언 위치가 변경되면 전체 상수 위치가 모두 변경됨
toString():
ENUM 상수의 이름을 문자열로 반환
name() 메서드와 유사하지만, toString() 은 직접 오버라이드 가능
객체 지향적 예시코드
public enum Grade {
BASIC(10), GOLD(20), DIAMOND(30);
private final int discountPercent;
Grade(int discountPercent) {
this.discountPercent = discountPercent;
}
public int getDiscountPercent() {
return discountPercent;
}
public int discount(int price) {
return price * discountPercent / 100;
}
}
할인율은 등급에 의해 정해짐 (캡슐화 필요)
Grade 클래스 내 필드 추가하고 생성자를 통해 필드 값 저장
열거형은 접근제어자 선언을 막아두었기 때문에, 생성자 선언은 private이 적용
상수 끝에 생성자에 맞는 인수를 전달하면 적절한 생성자가 호출됨 (BASIC(10))
열거형도 클래스이므로 메서드 추가 가능 (getDiscountPercent())
Reference
김영한의 실전 자바 - 중급 1편
-
스프링 파일 업로드
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편 - 백엔드 웹 개발 활용 기술
-
스프링 타입 컨버터
스프링 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편 - 백엔드 웹 개발 활용 기술
-
스프링 예외 처리
서블릿 예외 처리
순수 서블릿 컨테이너는 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편 - 백엔드 웹 개발 활용 기술
-
스프링 쿠키, 세션 로그인 기본
로그인 기본 비즈니스 로직
로그인 컨트롤러
로그인 서비스 로직 호출
호출 성공하면 정상 리턴
실패 시, 글로벌 오류 생성
로그인 서비스 로직
회원을 조회
파라미터로 넘어온 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편 - 백엔드 웹 개발 활용 기술
-
스프링 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 의 차이점 및 사용법
-
스프링 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편 - 백엔드 웹 개발 활용 기술
-
스프링 데이터 접근 활용 기술
데이터 관련 테크닉
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를 통한 트랜잭션 시작 시점에만 적용된다.
-
JPQL Dive
JPQL 개요
단순한 조회 방법
EntityManager.find()
객체 그래프 탐색 - a.getB(), b.getC()
검색조건이 포함된 SQL의 필요성
단순 조회는 문제 없지만 애플리케이션이 필요한 데이터만 DB에서 불러오려면 결국 검색 SQL이 필요
(모든 DB 데이터를 객체로 변환해 검색하는 것은 불가능)
JPQL
엔터티 객체를 대상으로 검색하는 객체 지향 SQL (JPA 제공)
반면에, SQL은 데이터베이스 테이블을 대상으로 쿼리
SQL을 추상화해서 특정 DB SQL에 의존 X
JPQL은 현재 설정 Dialect와 합쳐져 현재 DB에 맞는 적절한 SQL을 생성하고 전달
DB를 바꿔서 Dialect가 바뀌었더라도 JPQL 자체를 바꿀 필요는 없음
JPQL 빌더 - QueryDSL 권장
문자가 아닌 자바코드로 JPQL을 작성할 수 있음
Criteria
JPA 공식 기능
너무 복잡하고 실용성이 없음
QueryDSL
컴파일 시점에 문법 오류를 찾을 수 있음
편리한 동적쿼리 작성
단순하고 쉬움
네이티브 쿼리 - 네이티브 쿼리가 필요할 때는 JdbcTemplate을 사용하는게 낫다
JPQL로 해결할 수 없는 특정 DB 의존적인 기능 해결
e.g. 오라클 CONNECT BY, …
네이티브 SQL
JPA에서 SQL을 직접 사용하는 기능
em.createNativeQuery(sql, 클래스)
JDBC, JdbcTemplate, MyBatis 사용
주의점: 영속성 컨텍스트를 적절한 시점에 강제로 플러시 필요
JPA로 Persist만 해둔 데이터는 JdbcTemplate으로 커넥션을 얻어 SQL 조회시 조회 X
조회 직전 flush() 호출 필요
기본 조회
select m from Member as m where m.age > 18
테이블 이름이 아닌 엔터티 이름 사용 (Member)
별칭은 필수 (m, as는 생략 가능)
엔터티와 속성은 대소문자 구분 O
JPQL 키워드는 대소문자 구분 X
em.createQuery 반환 타입
TypedQuery
반환 타입이 명확할 때 사용
보통 엔터티 클래스를 넘김
TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
Query
반환 타입이 명확하지 않을 때 사용
Query query = em.createQuery("SELECT m.username, m.age from Member m");
결과 조회 API
query.getResultList()
결과가 하나 이상일 때, 리스트 반환
결과가 없으면 빈 리스트 반환
query.getSingleResult()
결과가 정확히 하나, 단일 객체 반환
이외의 결과는 예외 일으킴
결과가 없으면 javax.persistence.NoResultException
둘 이상이면 javax.persistence.NonUniqueResultException
파라미터 바인딩
이름 기준
SELECT m FROM Member m where m.username=:username
query.setParameter("username", usernameParam);
위치 기준 - 버그나기 쉬우므로 사용하지 말 것!
SELECT m FROM Member m where m.username=?1
query.setParameter(1, usernameParam);
프로젝션
SELECT 절에 조회할 대상을 지정하는 것
DISTINCT로 중복 제거
엔터티 프로젝션
SELECT m FROM Member m
조회된 엔터티는 영속성 컨텍스트가 관리
SELECT m.team FROM Member m
이 경우 조인 쿼리가 나가는데 예측이 어려우므로 이 형태로 사용하지 말 것
조인쿼리는 직접 조인 쿼리로 작성하자
임베디드 타입 프로젝션
SELECT m.address FROM Member m
스칼라 타입 프로젝션
SELECT m.username, m.age FROM Member m
위와 같이 여러 값을 조회할 시 3가지 방법 존재
Query 타입으로 조회
TypedQuery에서 Object[] 타입으로 조회
DTO로 바로 조회
SELECT new jpabook.jpql.UserDTO(m.username, m.age) FROM Member m
TypedQuery에서 UserDTO 타입으로 조회
패키지 명을 포함한 전체 클래스 명 입력 (문자 SQL이라 적어줘야 함)
순서와 타입이 일치하는 생성자 필요
페이징 API
각각의 DB Dialect에 맞게 JPA가 추상화
setFirstResult(int startPosition): 조회 시작 위치 (0부터 시작)
setMaxResults(int maxResult): 조회할 데이터 수
//페이징 쿼리
String jpql = "select m from Member m order by m.name desc";
List<Member> resultList = em.createQuery(jpql, Member.class)
.setFirstResult(10)
.setMaxResults(20)
.getResultList();
조인
내부 조인
SELECT m FROM Member m [INNER] JOIN m.team t
외부 조인
SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
세타 조인
연관 관계가 없는 테이블끼리 조인 (카테시안 곱 발생)
select count(m) from Member m, Team t where m.username = t.name
ON 절 (JPA 2.1부터 지원)
조인 대상 필터링
JPQL
SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'A'
SQL
SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.TEAM_ID=t.id and t.name='A'
연관관계 없는 엔터티 외부 조인
JPQL
SELECT m, t FROM Member m LEFT JOIN Team t on m.username = t.name
SQL
SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name
서브 쿼리
JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능
하이버네이트는 SELECT, FROM 절에서 서브 쿼리 가능하도록 지원 (FROM은 6부터 지원)
서브 쿼리 지원 함수
EXISTS (subquery): 서브쿼리에 결과가 존재하면 참
ALL (subquery): 모두 만족하면 참
ANY, SOME (subquery): 하나라도 만족하면 참
IN (subquery): 하나라도 같은 것이 있으면 참
JPQL 타입 표현
문자: ‘HELLO’, ‘She’’s’
숫자: 10L(Long), 10D(Double), 10F(Float)
Boolean: TRUE, FALSE
ENUM: jpabook.MemberType.Admin (패키지명 포함)
파라미터 바인딩으로 풀면 패키지명 안 쓸 수 있음
엔티티 타입: TYPE(m) = Member (상속 관계에서 사용)
em.createQuery(“select i from Item i where type(i) = Book”, Item.class)
where 절에 DTYPE = ‘Book’ 으로 쿼리가 나감
조건식
CASE 식
기본 CASE 식
select
case when m.age <= 10 then '학생요금'
when m.age >= 60 then '경로요금'
else '일반요금'
end
from Member m
단순 CASE 식
select
case t.name
when '팀A' then '인센티브110%'
when '팀B' then '인센티브120%'
else '인센티브105%'
end
from Team t
COALESCE
조건식을 하나씩 차례로 조회해서 null이 아닌 조건식 반환
select coalesce(m.username,'이름 없는 회원') from Member m
NULLIF
두 값이 같으면 null 반환, 다르면 첫 번째 값 반환
select NULLIF(m.username, '관리자') from Member m
JPQL 기본 함수 및 사용자 정의 함수
JPQL 기본 함수
CONCAT
SUBSTRING
TRIM
LOWER, UPPER
LENGTH
LOCATE: 문자 위치 찾기 (locate('de', 'abcdegf'))
ABS, SQRT, MOD
SIZE (JPA 용도): 컬렉션의 크기를 리턴 (select size(t.members) from Team t)
사용자 정의 함수 호출
등록 방법
Hibernate 6는 FunctionContributer 구현체를 만들어야 함
Hibernate 6 이전에는 방언을 상속받고 사용자 정의 함수 등록했음
src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor
위 파일을 생성해 구현체 등록 (custom.CustomFunctionContributor)
package custom;
import org.hibernate.boot.model.FunctionContributions;
import org.hibernate.boot.model.FunctionContributor;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;
public class CustomFunctionContributor implements FunctionContributor {
@Override
public void contributeFunctions(FunctionContributions functionContributions) {
functionContributions.getFunctionRegistry()
.register("group_concat", new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
}
}
호출 방법
select function('group_concat', i.name) from Item i
경로표현식
.을 찍어 객체 그래프를 탐색하는 것
경로표현식에 의한 묵시적 조인은 쓰지 말자
최대한 JPQL과 실제 SQL의 모양을 맞춰 예측가능하게 만들어야 함
조인은 쿼리 튜닝에 중요 포인트이기 때문
유의사항: 묵시적 조인은 항상 내부 조인
명시적 조인 사용하자! (별칭으로 상세 탐색 가능)
select m.username from Team t join t.members m
용어
상태 필드 (state field)
단순히 값을 저장하기 위한 필드
경로 탐색의 끝 (탐색 X)
m.username
연관 필드 (association field)
연관 관계를 위한 필드
단일 값 연관 필드
대상이 엔터티 (@ManyToOne, @OneToOne)
묵시적 내부 조인 발생 (탐색 O)
select m.team from Member m (e.g.m.team)
컬렉션 값 연관 필드
대상이 컬렉션 (@OneToMany, @ManyToMany)
묵시적 내부 조인 발생 (탐색 X)
select t.members from Team t (e.g. t.members)
페치 조인 (fetch join) - join fetch
JPQL에서 성능 최적화를 위해 연관된 엔터티나 컬렉션을 SQL 한 번에 함께 조회하는 기능
페치 조인으로 가져온 연관 엔터티는 프록시가 아닌 실제 엔터티
지정한 엔터티를 즉시 로딩으로 가져오므로, N + 1 문제를 해결
사용 전략
글로벌 로딩 전략은 모두 지연로딩으로 가져가고 최적화가 필요한 곳에 페치 조인 적용
조인 사용 전략
페치 조인으로 가져오기 (객체 그래프를 유지할 때 사용하면 효과적)
페치 조인으로 가져오고 애플리케이션 단에서 알맞는 DTO로 전환해 사용
일반 조인으로 필요한 데이터들만 조회해 DTO로 프로젝션 반환
여러 테이블을 조인해 원래의 엔터티 모양과 전혀 다른 결과를 내야 한다면 일반조인 사용
페치 조인과 일반 조인과의 차이
페치 조인은 회원을 조회하면 연관된 팀도 함께 조회 (SELECT T.*, M.*)
[JPQL] select t from Team t join fetch t.members
[SQL] SELECT T.*, M.* FROM TEAM T INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
일반 조인은 연관된 엔터티를 함께 조회 X (SELECT T.*)
[JPQL] select t from Team t join t.members m
[SQL] SELECT T.* FROM TEAM T INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
컬렉션 페치 조인
일대다 관계에서 실행하는 페치 조인
하이버네이트 6 이후
JPQL DISTINCT 없이도 애플리케이션에서 자동으로 중복 제거 적용
하이버네이트 6 이전
조인 시 데이터 중복 발생
DB와 객체의 차이 때문에 같은 엔터티 중복 발생
실제 팀은 1개인데 멤버와의 조인으로 발생한 행 수에 의해 2개의 팀이 반환
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
JPQL DISTINCT
컬렉션 페치 조인 데이터 중복 방지를 위해 적용
JPQL DISTINCT는 2가지 기능을 제공
SQL에 DISTINCT 추가
실제 SQL에 적용되지만, SQL 결과에서는 중복 제거할 것이 없음
애플리케이션에서 엔터티 중복 제거
애플리케이션 단에서 같은 식별자를 가진 엔터티 제거
DISTINCT 적용시 결과
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
유의 사항
여러 엔터티 다중 페치 조인 시에만 대상에 별칭을 쓰자
하이버네이트에서는 페치 조인 대상에 별칭 사용 가능 (가급적 사용 X)
페치 조인은 연관된 엔터티를 몇 개 걸러서 가져와서는 안됨 (정합성 이슈)
페치 조인은 연관된 엔터티를 항상 모두 가져와야 함
e.g. 팀 조회 시, 팀에 연관된 멤버가 5명일 때 멤버 3명만 가져와서는 안됨
이는 누락을 동반하는 매우 위험한 조작 (Cascade etc…)
JPA 설계 사상은 연관된 모든 것을 다 조회하는 것을 전제하므로 위반 조심
둘 이상의 컬렉션은 페치 조인하지 말자
페치 조인은 컬렉션을 딱 하나만 지정하자
예상치 못하게 데이터 중복이 늘어날 수 있음
e.g Team.members, Team.orders를 한꺼번에 페치 조인해서는 안됨
컬렉션을 페치 조인하면 페이징 API 사용 불가 (메모리 페이징 문제로 매우 위험)
페이징 API: setFIrstResult, setMaxResults
문제
데이터 중복
1:M 컬렉션 페치 조인은 단순히 DB 상 조인 쿼리를 생성
DB에서 조인된 테이블의 로우는 M개 (1 기준으로는 중복된 row가 많은 상황)
따라서, 생성된 DB 쿼리 상 1 기준 페이징이 불가능
데이터 누락 문제가 JPA의 객체 그래프 사상을 위반
e.g. 팀 A에 멤버가 2명 있을 때, 이를 페치 조인해 pageSize를 1로 페이징
페이징은 철저히 DB 중심 -> 팀 & 멤버 조인 테이블에서 1개 row를 가져옴 (멤버1)
DB 결과에 따라 JPA는 팀 A에 멤버가 1명 있다고 생각해 문제 발생 (멤버2 누락)
데이터 중복 및 누락을 피하기 위해 메모리 페이징 발생 (장애 유발 가능성 높음)
하이버네이트는 경고 로그를 남기고 강제로 메모리에서 페이징 (매우 위험)
실제로 조인 쿼리만 날리고 DB에서 페이징하지 않고 메모리에서 페이징
e.g. 100만 건 데이터를 모두 메모리에 올리고 메모리에서 페이징…
해결 방법
일대다 쿼리를 다대일 쿼리로 바꿔 실행 (권장)
일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 당연히 페이징 가능
쿼리 한 번으로 해결되므로 일반적으로 성능이 좋음
팀과 멤버 (OneToMany)에서 멤버 테이블로 페이징 쿼리 후 팀 기준 group by
batchSize 적용
페치 조인을 하지 않고 지연 로딩 활용 (팀에 페이징 쿼리하고 멤버를지연로딩)
배치 사이즈는 N + 1 쿼리를 막고 설정한 단위 기준으로 in-query 진행
기본적으로 글로벌 배치 사이즈 깔고 모든 작업 진행
1000 이하의 적절한 수 지정 (보통 DB의 in 절 개수의 한계가 1000)
persistence.xml
<property name="hibernate.default_batch_fetch_size" value="100" />
application.properties
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
e.g. 팀이 150개고 멤버가 레이지 로딩될 때, batchSize가 100개인 상황
원래는 150개의 N + 1 쿼리가 발생하지만 이를 예방
100개 & 50개 뭉치로 총 2번 in-query해 가져옴
DTO 쿼리
다형성 쿼리
상속 관계 매핑에서 사용
type
조회 대상을 특정 자식으로 한정 (=DTYPE where 절 자동 적용)
e.g. Item 중 Book, Movie 조회하기
[JPQL] select i from Item i where type(i) IN (Book, Movie)
[SQL] select i from i where i.DTYPE in (‘B’, ‘M’)
treat (JPA 2.1)
부모 타입을 특정 자식 타입으로 다룸
타입 캐스팅과 유사
FROM, WHERE, SELECT(하이버네이트) 절에서 사용 가능
e.g. 부모인 Item과 자식 Book이 있을 때, 자식 속성으로 where절 걸고 싶은 경우
[JPQL] select i from Item i where treat(i as Book).author = ‘kim’
[SQL] select i.* from Item i where i.DTYPE = ‘B’ and i.author = ‘kim’
엔터티 직접 사용
JPQL에서 엔터티를 직접 사용하면 SQL에서 해당 엔터티의 기본키 값 사용
[JPQL]
select count(m.id) from Member m - 엔티티의 아이디를 사용
select count(m) from Member m - 엔티티를 직접 사용
[SQL]
select count(m.id) as cnt from Member m - JPQL 둘 다 같은 SQL 실행
연관된 엔터티를 직접 사용하면 외래키 값 사용
[JPQL]
select m from Member m where m.team = :team
select m from Member m where m.team.id = :teamId
[SQL]
select m.* from Member m where m.team_id=? - JPQL 둘 다 같은 SQL 실행
Named 쿼리
미리 정의해서 이름을 부여해두고 사용하는 JPQL (=정적 쿼리)
에노테이션, XML에 정의
XML 정의가 항상 우선권을 가짐
애플리케이션 운영 환경에 따라 다른 XML 배포 가능
애플리케이션 로딩 시점에 초기화 후 재사용 - JPQL을 SQL로 미리 파싱 후 캐싱
약간의 속도 이점
애플리케이션 로딩 시점에 미리 쿼리의 예외를 검증하는 이점
에노테이션 정의 사용 예
@Entity
@NamedQuery(
name = "Member.findByUsername",
query="select m from Member m where m.username = :username")
public class Member {
...
}
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
XML 정의 사용 예
[META-INF/persistence.xml]
<persistence-unit name="jpabook" >
<mapping-file>META-INF/ormMember.xml</mapping-file>
[META-INF/ormMember.xml]
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1">
...
<named-query name="Member.findByUsername">
<query><![CDATA[
select m
from Member m
where m.username = :username
]]></query>
</named-query>
<named-query name="Member.count">
<query>select count(m) from Member m</query>
</named-query>
</entity-mappings>
벌크 연산
여러 개의 데이터에 대한 갱신 쿼리
벌크연산은 주로 JPQL로 진행
JPA 자체는 실시간 단건성 작업에 적합
JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL 실행
e.g. 100건의 엔터티라면 100번의 UPDATE SQL 실행
executeUpdate()
영향 받은 엔터티 수 반환
쿼리 한 번으로 여러 테이블 로우 변경
UPDATE, DELETE 지원
String qlString = "update Product p " +
"set p.price = p.price * 1.1 " +
"where p.stockAmount < :stockAmount";
int resultCount = em.createQuery(qlString)
.setParameter("stockAmount", 10)
.executeUpdate();
insert into .. select
하이버네이트가 INSERT 지원
벌크 연산 사용 전략
JPQL은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리하므로 벌크 연산 사용 맥락이 중요
사용 전략 1: 벌크 연산을 먼저 실행
영속성 컨텍스트에 아무 것도 하지 않고 벌크 연산만 실행
영속성 컨텍스트가 비어 있으니 벌크 연산으로 새로 조회가 발생해도 문제 없음
DB에서 최신 데이터 가져와 1차캐시에 반영할 것이므로
사용 전략 2: 벌크 연산 수행 후 영속성 컨텍스트 초기화 (em.clear())
어떤 엔터티가 미리 조회되어 있는 상황에서 벌크 연산을 진행
JPQL 호출로 플러시 자동 발생
벌크 연산 후 영속성 컨텍스트는 DB에 비해 Old한 상태가 됨
e.g. 처음 조회한 회원 엔터티의 연봉이 5000만원
이후 수행한 벌크 연산에서 연봉이 6000만원이 되어 DB에 플러시됨
이 경우 애플리케이션에서는 여전히 회원 연봉이 5000만원임
따라서, 영속성 컨텍스트를 비워주어 깨끗한 상태에서 다시 조회될 수 있도록 해야 함
Reference
자바 ORM 표준 JPA 프로그래밍 - 기본편
-
JPA Dive
JPA 개요
SQL 중심적인 개발의 문제점
반복적인 자바 객체 매핑 작업 (자바 객체 -> SQL, SQL -> 자바 객체)
SQL 유지보수의 어려움
테이블 필드 추가 시 모든 SQL에 개발자가 직접 필드를 추가해야 함
실수 시 기능 이상 발생
정형화된 쿼리 반복 (INSERT, UPDATE, SELECT, DELETE)
패러다임의 불일치 (객체 지향 & 관계형 DB)
객체 지향 & 관계형 DB의 차이
상속
객체 상속 VS Table 슈퍼타입 서브타입 관계 (One-to-Many)
여러 테이블을 삽입하고 조회하게 되어 객체 변환 과정이 번거로움
연관관계 (e.g Team, Member)
객체는 참조(Reference) VS Table은 Foreign Key
객체를 테이블에 맞추어 모델링하게 됨 (teamId)
객체 다운 모델링을 하면 객체 변환 과정이 번거로움(Team)
객체 그래프 탐색
객체는 자유롭게 객체 그래프 탐색 VS 실행하는 SQL에 따라 탐색 범위 결정
계층형 아키텍처에서 진정한 의미의 계층 분할이 어려움 (엔터티 신뢰 문제)
즉, 물리적으로는 계층이 분할되었지만, 논리적으로는 계층이 분할되어 있지 않음
계층형 아키텍처는 다음 계층을 믿고 쓸 수 있어야 함
만약, 서비스 계층 개발 중에 다른 개발자가 만든 DAO find를 쓸 때
조회된 엔터티의 getTeam, getOrder 나아가 getDelivery가 가능한지는
DAO 내부의 SQL 쿼리를 까봐야 알 수 있음
즉, 다음 계층에 대한 신뢰가 없음
데이터 식별 방법 (==)
같은 ID를 2번의 조회로 데이터 가져온 상황에서
SQL로 조회한 2개 데이터는 서로 다르다
컬렉션에서 같은 ID로 찾은 객체는 항상 같음
객체 다운 모델링을 할수록 매핑 작업이 무수히 늘어남
객체를 자바 컬렉션에 저장하듯이 DB에 저장할 수는 없을까?
JPA (Java Persistence API)
자바 진영의 ORM 기술 표준
JPA 표준 명세로 인터페이스의 모음
JPA 2.1 표준 명세를 구현한 3가지 구현체 (하이버네이트, EclipseLink, DataNucleus)
2.0에서 대부분의 ORM 기능을 포함
객체는 객체대로 RDB는 RDB대로 설계하고 ORM 프레임워크가 중간에서 매핑
JVM 내 JAVA 애플리케이션과 JDBC API 사이에서 동작
패러다임 불일치를 중간에서 해결 (SQL 생성, 객체 매핑)
SQL 중심적인 개발에서 벗어나 객체 중심으로 개발해 생산성 및 유지보수 향상
필드 추가 시, JPA가 알아서 SQL을 동적 생성
자바 컬렉션에 저장하듯이 코드를 작성하여 패러다임 불일치를 해결 (객체 매핑 자동화)
JPA 설정하기
JPA 설정 파일 (persistence.xml)
경로: /META-INF/persistence.xml
이름 지정: persistence-unit name
설정 값 분류
JPA 표준 속성: jakarta.persistence.~
하이버네이트 전용 속성: hibernate.~
스프링 부트를 쓴다면 생성할 필요 없음
대신 application.properties 사용
spring.jpa.properties 하위에 똑같은 속성 추가
Dialect (방언)
SQL 표준을 지키지 않는 특정 DB만의 고유한 기능
각각 DB가 제공하는 SQL 문법 및 함수가 조금씩 다름
페이징: MySQL-LIMIT, Oracle-ROWNUM
JPA는 특정 DB에 종속되지 않지만 Dialect 설정은 필요
hibernate.dialect 속성 값 지정 (하이버네이트는 40가지 이상의 Dialect 지원)
H2: H2Dialect
Oracle: Oracle10gDialect
MySQL: MySQL5InnoDBDialect
데이터베이스 스키마 자동생성 (DDL)
애플리케이션 실행 시점에 DDL 자동 생성
설정한 Dialect에 맞춰서 적절한 DDL 생성
설정값 (hibernate.hbm2ddl.auto)
create: 기존 테이블 삭제 후 다시 생성 (DROP + CREATE)
create-drop
create + 종료 시점에 테이블 삭제 (DROP + CREATE + DROP)
테스트 사용 시 마지막에 깔끔히 날리고 싶을 때 사용
update
변경분만 반영 (ALTER)
컬럼 추가는 가능하지만 지우기는 안됨
운영에서 사용하면 안됨 X
validate: 엔터티와 테이블이 정상 매핑되었는지만 확인
none: 사용하지 않음 (주석처리하는 것과 똑같음)
유의사항
개발, 스테이지, 운영 서버는 반드시 validate 혹은 none만 사용!!!! (스크립트 권장)
개발초기 단계 혹은 로컬에서만 create 혹은 update 사용
DDL 생성 기능
JPA의 DDL 생성 기능(@Table uniqueConstraints, @Column nullable 등)은 DB에만 영향을 주고 런타임에 영향을 주지 않는다.
즉, 애플리케이션 시작 시점에 제약 추가 같은 DDL 자동 생성에만 사용하고, 실제 INSERT, SELECT 등의 JPA 실행 로직에는 큰 영향을 주지 않는다.
JPA 동작 원리
주요 객체
EntityManagerFactory
하나만 생성해서 애플리케이션 전체에서 공유
EntityManager
한 요청 당 1회 사용하고 버림 (쓰레드 간 공유 X)
JPA의 모든 데이터 변경은 트랜잭션 안에서 실행
EntityTransaction transaction = em.getTransaction();
transaction.begin();
...
transaction.commit();
동작 순서
Persistence(클래스)가 persistence.xml 설정 정보 조회
Persistence가 EntityManagerFactory 생성
EntityManagerFactory가 EntityManager 생성
영속성 컨텍스트
애플리케이션과 DB(JDBC API) 사이에서 엔터티를 관리하는 논리적인 영역
엔터티를 영구 저장하는 환경
눈에 보이지 않는 논리적인 개념
엔터티 매니저와 영속성 컨텍스트는 1:1 관계 (엔터티 매니저를 통해 접근)
엔터티의 생명주기
비영속 (new/transient)
영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
e.g. 새로운 객체 생성
영속 (managed)
영속성 컨텍스트에 관리되는 상태
e.g. em.persist(member);
준영속 (detached)
영속성 컨텍스트에 저장되었다가 분리된 상태
영속성 컨텍스트가 제공하는 기능을 사용하지 못함 (더티 체킹 등…)
방법
em.detach(member): 특정 엔터티만 준영속상태로 전환
em.clear(): 영속성 컨텍스트를 완전히 초기화
em.close(): 영속성 컨텍스트를 종료
삭제 (removed)
실제 DB에 삭제를 요청하는 상태 (DELETE SQL 생성)
e.g. em.remove(member);
영속성 컨텍스트의 이점 - JPA 성능 최적화 기능
애플리케이션과 DB 사이에 영속성 컨텍스트라는 계층이 생기면서 Buffering, Cacheing 등의 이점 얻음
1차 캐시
ID(PK)가 Key, Entity가 value인 Map (메모리 내 영속성 컨택스트 안에 위치)
동작
엔터티가 1차 캐시에 있으면 1차 캐시에서 조회
1차 캐시에 없으면 DB에서 조회한 후 1차 캐시에 저장 (=DB 조회가 엔터티를 영속 상태로 만듦)
이점
조회 성능 향상
같은 트랜잭션 안에서는 1차 캐시를 조회해 같은 엔티티를 반환
다만, 큰 성능 향상은 없음
조회가 DB까지 가지 않아서 약간의 성능 향상
하지만, 서비스 전체적으로 봤을 때 이점을 얻는 순간이 매우 짧고 효과가 적음
한 비즈니스 로직 당 하나의 영속성 컨텍스트를 사용해서 이점 순간이 짧음
고객 10명이 와도 모두 별도의 1차 캐시를 가지므로 효과가 적음
같은 것을 여러 번 조회할 정도로 비즈니스 로직이 매우 복잡한 경우 도움이 될 때가 있을 것
동일성 보장
같은 트랜잭션 내에서 영속 엔터티는 여러 번 조회해도 동일성이 보장됨
애플리케이션 차원에서 Repeatable Read 트랜잭션 격리 수준 보장
예를 들어, 트랜잭션 격리수준이 Read Committed여도 보장
트랜잭션을 지원하는 쓰기 지연 (transactional write-behind)
쓰기 지연
트랜잭션 커밋 순간 쓰기 지연 SQL 저장소에 쌓아둔 SQL을 한 번에 DB에 전달하고 바로 커밋
INSERT SQL을 버퍼에 모아두었다 트랜잭션 커밋 시 한 번에 DB에 보냄
UPDATE, DELETE도 트랜잭션 커밋 시 한 번에 보내서 락(Lock) 시간을 최소화
JDBC BATCH SQL 이용
성능 상 이점 (일반 상황 & 배치 작업) - 큰 성능향상은 아님
변경 감지 (Dirty Checking)
엔터티의 조회 순간 1차 캐시에 엔터티와 스냅샷을 함께 보관
변경 감지 과정
transaction.commit() 호출 -> flush() 메서드 호출
현재 엔터티와 스냅샷을 비교
변경사항이 있으면 UPDATE SQL을 생성해 쓰기 지연 SQL 저장소에 적재
적재된 SQL을 한 번에 DB로 보냄 (실제 flush)
실제 DB 커밋 발생
지연 로딩 (Lazy Loading) & 즉시 로딩 (Eager Loading)
지연 로딩: 객체가 실제 사용될 때 로딩
즉시 로딩: JOIN SQL로 한번에 연관된 객체까지 미리 조회
지연 로딩으로 개발하다가 성능 최적화가 필요한 부분은 즉시 로딩을 적용해 해결
기술 사이에 계층이 생길 때
중간에 기술이 껴서 계층이 생긴다면 항상 2가지의 성능 최적화가 가능하다.
캐시
Buffer로 Write 가능 (모아서 보내기 가능)
플러시 (Flush)
영속성 컨텍스트의 변경내용을 DB에 반영하는 것 (=동기화)
쓰기 지연 SQL 저장소에 쌓아둔 쿼리를 DB에 날리는 작업
영속성 컨텍스트를 비우지는 않음
트랜잭션이 있기 때문에 플러시 개념이 존재할 수 있음
플러시는 SQL 전달 타이밍만 조절
결국 커밋 직전에만 동기화하면 됨
플러시 방법
em.flush() - 직접 호출
테스트 이외에 직접 사용할 일은 거의 없음
쿼리를 직접 확인하고 싶거나 커밋 전에 SQL을 미리 반영하고 싶을 때
트랜잭션 커밋 - 플러시 자동 호출
변경 감지가 먼저 발생
쓰기 지연 SQL 저장소의 쿼리(등록, 수정, 삭제)를 DB에 전송
JPQL 쿼리 실행 - 플러시 자동 호출
em.persist(memberA);
em.persist(memberB);
em.persist(memberC);
//중간에 JPQL 실행
query = em.createQuery("select m from Member m", Member.class);
List<Member> members= query.getResultList();
JPQL은 1차 캐시를 거치지 않고 SQL로 번역되어 바로 실행되므로 항상 플러시를 자동 호출
영속성 컨텍스트에 새로 생성된 엔터티가 아직 DB에 반영되지 않았기 떄문
em.setFlushMode로 조절할 수 있으나 굳이 이 옵션을 사용할 일은 없음
Entity 매핑
객체 & 테이블 매핑
@Entity
JPA가 관리하는 객체 (=엔터티)
기본 생성자 필수 (public 또는 protected)
final 클래스, final 필드, enum, interface, inner 클래스 사용 X
name 속성: JPA에서 사용할 엔터티 이름 지정 (기본값: 클래스 이름, 가급적 기본값 사용)
@Table
엔터티와 매핑할 테이블 지정
속성
name
매핑할 테이블 이름 지정
기본값: 엔터티 이름
지정DB 이름이 ORDERS면 name="ORDERS" 지정)
uniqueConstraints(DDL): DDL 생성 시 유니크 제약 조건 생성
catalog: DB catalog 매핑
schema: DB schema 매핑
필드 & 컬럼 매핑
@Column (컬럼 매핑)
name: 매핑할 컬럼 이름
nullable(DDL): null 값 허용 여부 설정
length(DDL): 문자 길이 제약조건 설정 (String 타입에만 사용, 기본값 255)
precision, scale(DDL): BigDecimal 혹은 BigInteger에서 사용
insertable, updatable: DB는 못막지만 애플리케이션 단에서 등록 및 변경을 막거나 허용
unique: 유니크 제약 적용 (제약이름이 랜덤 생성되어 보통 @Table의 속성으로 유니크 적용)
columnDefinition: DB 컬럼 정보 적용 (특정 DB 종속적인 옵션 적용 가능)
@Enumerated (enum 타입 매핑)
EnumType.String을 반드시 적용할 것! (DB에 VARCHAR(255)로 삽입)
EnumType.ORDINAL는 값이 순서를 기준으로 숫자(Integer)로 DB에 삽입됨
따라서, EnumType.ORDINAL는 새로운 Enum 값 추가 시 매우 위험!
@Lob (BLOB, CLOB 타입 매핑)
필드 타입에 따라 매핑이 달라짐
String, char[]: DB 타입 CLOB 매핑
byte[]: DB 타입 BLOB 매핑
@Transient
메모리 상에서만 임시로 어떤 값을 보관하고 싶을 때 사용 (메모리 임시 계산값, 캐시 데이터…)
해당 컬럼은 메모리에서만 쓰고 DB에서 쓰지 않음
@Temporal (날짜 타입 매핑)
@Temporal은 생략하고 LocalDate, LocalDateTime 타입을 사용하자!
JAVA 8부터 하이버네이트가 애노테이션 없이 타입만으로 컬럼 매핑
기본키 매핑 (Primary Key)
권장 식별자 전략
Long 형 + 인조키 + 키 생성전략 사용 (auto-increment 혹은 sequence 전략 사용)
때에 따라 UUID나 회사 내 룰에 따른 랜덤값 사용
@Id(직접 할당)
@Id만 사용 시 PK를 사용자가 직접 할당
@GeneratedValue (자동 생성)
DB가 PK 자동 생성
generator 속성
@SequeceGenerator의 name 혹은 @TableGenerator의 name을 등록
strategy 속성
IDENTITY
기본 키 생성을 데이터베이스에 위임
ID 값을 NULL로 주고 INSERT 쿼리 진행하면 DB가 자동 생성
em.persist() 시점에 즉시 INSERT SQL 실행해 DB에서 식별자 조회
DB 접근 없이는 PK 값을 알 수 없어, 영속성 컨텍스트 관리가 불가
INSERT 후 JDBC API 반환값으로 1차 캐시에 ID 및 엔터티 등록
MySQL, PostgreSQL, SQL Server, DB2 (MySQL AUTO_INCREMENT)
SEQUENCE
DB 시퀀스 오브젝트 사용 (유일한 값을 순서대로 생성하는 DB 오브젝트)
트랜잭션 커밋 시점에 실제 INSERT SQL 실행
em.persist() 시점에 DB에 접근해 현재 DB 시퀀스 값 조회
Hibernate: call next value for MEMBER_SEQ
메모리에 조회 시퀀스 값을 올려두고 1차 캐시에 ID 및 엔터티 등록
Oracle, PostgreSQL, DB2, H2
@SequenceGenerator: 테이블마다 시퀀스를 따로 관리하고 싶을 때 사용
name: 식별자 생성기 이름
sequenceName
매핑할 DB 시퀀스 오브젝트 이름
기본값: hibernate_sequence
initialValue: 처음 시작하는 수 지정 (기본값: 1)
allocationSize
시퀀스 한 번 호출에 증가하는 수
SELECT 네트워크 호출을 줄여서 성능 최적화를 시키는 방법
기본값: 50 (50~100정도가 적당)
DB에 미리 50개를 올려두고 메모리에서 그 개수만큼 1씩 사용
즉, 50개마다 call next 호출
웹 서버가 여러 개여도 동시성 문제 X
시퀀스 사이에 구멍이 생길 뿐
웹서버를 껐다키면 메모리의 시퀀스 정보가 날라가므로
구멍이 문제는 없지만 낭비 최소화 위해 사이즈 너무 크게 하지 말 것
catalog, schema: DB catalog, schema 이름
TABLE
키 생성용 테이블을 사용해 마치 시퀀스처럼 동작시키는 전략
모든 DB에서 사용 가능하지만 성능이 안좋음
@TableGenerator: 키 생성기
name: 식별자 생성기 이름
table: 키 생성 테이블 명
pkColumnValue: 키로 사용할 값 이름 (기본값: 엔터티 이름)
allocationSize: 시퀀스 한 번 호출에 증가하는 수 (성능 최적화)
…
AUTO (기본값): 방언에 따라 자동 지정 (IDENTITY, SEQUENCE, TABLE 중 하나 선택)
연관관계 매핑
객체 지향 모델링의 필요성
객체는 참조를 사용해 연관된 객체를 찾아야 함
엔터티 서로를 참조하는 단방향 연관관계 2개를 만들어야 함 (=양방향 연관관계)
테이블 중심 설계 지양 (=외래키를 그대로 엔터티에 가져오는 설계)
외래키 하나로 양방향 연관관계 맺음 (조인을 통해 서로 조회)
이는 객체 지향적 X, 객체 간 협력 관계를 만들 수 없음
연관관계 방향
단방향 연관관계
한 쪽 엔터티만 다른 쪽 엔터티를 참조 (참조가 1군데)
@JoinColumn, @ManyToOne
양방향 연관관계
엔터티가 서로를 참조 (참조가 2군데)
외래키를 관리하지 않는 엔터티 쪽에도 단방향 연관관계 추가 (mappedBy)
@OneToMany(mappedBy = "team") (멤버 엔터티의 팀 변수를 mappedBy에 지정)
연관관계 주인
양방향 매핑에서 외래키를 관리하는 참조
@JoinColumn 위치한 곳이 연관관계 주인
연관관계 주인을 통해서만 외래키 설정 가능 (양방향 매핑 시 주의점)
주인이 아닌 쪽은 외래키에 영향을 주지 않고 읽기만 가능
mappedBy 위치한 곳
참조 추가가 DB에 영향을 주지 않음
다중성
다대일 (N:1, @ManyToOne)
연관관계 주인이 N쪽 (외래키가 있는 쪽에 @JoinColumn)
사용 지향 (가장 많이 사용)
객체지향적으로 조금 손해 보더라도 DB에 맞춰 ORM 관리하면 운영이 편해짐
객체지향적 손해 예: Member에서 Team으로 갈 일이 없는데 참조를 만들어야 할 때
일대다 (1:N, @OneToMany)
연관관계 주인이 1쪽 (외래키가 없는 쪽에 @JoinColumn)
사용 지양
일대다 양방향은 공식적으로 존재하지 않아서 읽기 전용 필드로 우회해 구현
@JoinColumn(insertable=false, updatable=false)
양쪽 엔터티에 모두 @JoinColumn이 있고 N쪽이 읽기전용 컬럼
일대일 (1:1, @OneToOne)
주 테이블과 대상 테이블 중 외래키 위치 선택 가능
주테이블: 주로 많이 액세스하는 테이블
먼 미래 보지 않고 주 테이블 쪽에 위치시키는 것이 괜찮다
외래키가 있는 곳이 마찬가지로 연관관계의 주인 (@JoinColumn), 반대편은 mappedBy
제약 조건없이 애플리케이션 단에서 일대일이 가능하지만 세심한 관리 필요
DB 입장에서는 외래키에 UNIQUE 제약조건이 추가된게 일대일 관계
다대다 (N:M, @ManyToMany)
다대다는 연결 테이블을 추가해 일대다, 다대일 관계로 풀어내야 함
관계형 DB는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없음
다만, @ManyToMany 사용은 지양
@ManyToMany는 자동으로 연결 테이블을 생성하지만 다른 데이터 필드 추가가 불가
쿼리가 생각하지 못한 방향으로 나갈 수 있음
@OneToMany, @ManyToOne 사용!
연결 테이블을 엔터티로 승격시키자
연결 테이블 PK는 Compound Key (FK + FK)보다 하나의 인조키 만드는게 낫다
운영하다보면 종속되지 않은 아이디 값이 매우 유용!
지향할 연관관계 매핑 전략
최대한 단방향 매핑으로만 설계 한 후, 애플리케이션 개발 시 고민하며 양방향 매핑 추가하자
단방향 매핑만으로도 이미 연관관계 매핑은 완료, 양방향 매핑은 조회 추가일 뿐
객체 입장에서는 양방향이 큰 메리트가 없으므로, 필요한 곳에만 추가하는 것이 더 좋음
다만 실무에서 JPQL 짜다보면 결국 양방향 매핑을 많이 쓰게되긴 함
연관관계의 주인은 DB 테이블 상 외래키가 있는 곳으로 정하자
반대로 주인을 정하면
직관적이지 않은 쿼리로 테이블이 헷갈림
Team에 멤버를 추가했는데 Member Table로 쿼리가 나가 헷갈림
성능 문제가 생김 (크진 않아도 손해는 손해)
Team과 Member를 추가할 때 INSERT 2번 UPDATE 1번 실행
Team은 자신의 엔터티의 외래키가 없으므로 Member에 외래키 업데이트 실행
양방향 매핑시 연관관계 편의 메서드를 생성하자
JPA 기능적으로는 연관관계 주인에만 값을 세팅하면 동작
다만, 객체지향 관점에서 항상 양쪽 모두 값을 입력하는 것이 옳다!
주인만 값 세팅하면 커밋 전까지 1차 캐시에만 있어서 주인이 아닌 쪽 접근 시 실패
테스트 시에도 순수한 자바코드를 사용하므로 양쪽 다 입력하는 것이 문제를 예방
메서드 네이밍 시 setXxx는 지양 (e.g. changeTeam)
주인 쪽, 주인이 아닌 쪽 중 한 곳에만 연관관계 편의 메서드 작성해야 함
모두 작성하면 무한 루프 발생 확률 높음
Lombok toString 만드는 것도 왠만하면 쓰지 말 것!
컨트롤러에 엔터티 절대 반환하지 말 것! (DTO로 변환 반환, API 스펙 변경 X)
상황마다 좋은 쪽이 다름 (특정 객체를 기준으로 풀고 싶을 때 해당 객체에 위치시킴)
일대일 관계에서는 주 테이블에 외래키 위치시키자 (너무 먼 미래 고려하지 말고!)
주 테이블에 외래키
객체지향 개발자가 선호 (JPA 매핑 편리)
장점: 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능 (프록시 객체)
단점: 값이 없으면 외래키에 null 허용
대상 테이블에 외래키 (양방향만 가능)
전통적인 데이터베이스 개발자 선호
장점: 일대다 관계로 변경시 테이블 구조가 유지되어 편리 (변경 포인트가 적음)
프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩됨
주 객체의 대상 객체 참조 여부를 판단하려면, 대상 테이블에 쿼리를 날려 외래키 존재 여부를 확인해야 하므로 즉시로딩 진행 (지연로딩 세팅이 의미가 없음)
상속 관계 매핑
DB의 슈퍼타입-서브타입 관계 논리 모델링 기법을 객체 상속을 활용해 매핑
지향 전략
기본은 조인 전략
서비스가 단순할 때는 단일 테이블 전략으로 진행 (복잡하게 에너지 쓰지 않기)
일부 컬럼을 JSON으로 저장하는 방식으로 대체하기도 함
테이블 상속 전략은 대규모 서비스에서 복잡도가 높을 수 있음
상황 맞게 선택!
주요 어노테이션
@Inheritance(strategy=InheritanceType.XXX)
슈퍼타입-서브타입 관계에 대해 물리 모델 구현 방법 지정
부모 클래스에 적용
부모 클래스는 의도상 사용하지 않으므로 abstract class 지향
부모만 단독으로 저장할 일이 있다면 일반 클래스로 사용
TABLE_PER_CLASS는 반드시 abstract class를 사용 (부모 테이블 생성 막음)
테이블 상속 전략 종류 (InheritanceType)
JOINED (Identity = One to One Type = 조인 전략)
조인 전략이 정석!!
장점
테이블 정규화
객체랑 잘맞고 설계 관점에서 매우 깔끔
단점
조회시 쿼리가 복잡하고 조인을 많이 사용 (조인은 잘 맞추면 성능 매우 잘나옴)
데이터 저장 시 INSERT 쿼리 2번 호출
@DiscriminatorColumn 필요성
항상 @DiscriminatorColumn 적용하자 (운영에 유리)
DTYPE이 없어도 기능상 문제는 없음
SINGLE_TABLE (Rollup = Single Type = 단일 테이블 전략)
장점
조인이 없어 조회 성능이 빠르고 쿼리가 단순함
단점
자식 엔터티 매핑 컬럼은 모두 NULL 허용 (데이터 무결성 관점에서 치명적)
단일 테이블에 모든 것 저장하므로 테이블이 커지고 상황에 따라 조회 성능 감소
@DiscriminatorColumn 필요성
@DiscriminatorColumn 생략해도 DTYPE 컬럼 생성
DTYPE이 반드시 필요하므로
TABLE_PER_CLASS (Rolldown = Plus Type = 구현 클래스마다 테이블 전략)
DB 설계 관점 및 객체 ORM 관점 모두에서 지양 (사용 X)
장점
서브 타입을 명확히 구분해 처리할 때 효과적
단점
여러 자식 테이블을 함께 조회할 때 성능이 느림 (UNION SQL 필요)
ID로 조회해도 3개 테이블을 다 찔러봐야 알 수 있음
변경에도 유연하지 못한 설계
@DiscriminatorColumn 필요성
@DiscriminatorColumn 필요 없음
@DiscriminatorColumn
DTYPE 컬럼 생성
부모 클래스에 적용
name 속성으로 컬럼 이름 지정 (기본값: DTYPE)
@DiscriminatorValue("XXX")
DTYPE에 들어갈 Value 지정
자식 클래스에 적용
기본값: 자식 엔터티의 이름
공통 정보 매핑
@MappedSuperclass
공통 매핑 정보가 필요할 때 사용
부모를 상속 받는 자식 클래스에 매핑 정보만 제공
등록일, 수정일, 등록자, 수정자 등 (id, createdAt, createdBy…)
부모 클래스에 적용 (abstract class 권장)
BaseEntity를 하나 만들고 다른 엔터티가 이를 상속
상속관계 매핑 X, 엔터티 X, 테이블과 매핑 X
조회, 검색 불가 (em.find(BaseEntity) 불가)
JPA에서의 상속
JPA에서는 상속관계 매핑 혹은 공통 정보 매핑만 상속 가능하다.
즉, @Entity 클래스는 @Entity나 @MappedSuperclass로 지정한 클래스만 상속 가능
JPA 프록시 객체
실제 객체의 참조를 보관하는 객체
사용자 입장에서는 진짜인지 프록시인지 구분하지 않고 사용
프록시 객체를 호출하면 프록시는 실제 객체의 메서드 호출
실제 클래스를 상속 받아서 만들어짐
실제 객체에 값만 빈 껍데기 생성
target(실제 객체 주소)만 추가됨
관련 메서드
em.find(): DB에서 실제 엔터티 객체 조회
em.getReference(): DB 조회를 미루는 프록시(가짜) 엔터티 객체 조회
emf.getPersistenceUnitUtil().isLoaded(entity): 프록시 인스턴스의 초기화 여부
entity.getClass(): 프록시 클래스 확인
org.hibernate.Hibernate.initialize(entity): 프록시 강제 초기화 (JPA 표준 X)
프록시 객체의 초기화
프록시 객체에서처음 getXxx 호출 시 한 번만 초기화 진행 (=실제 객체 사용 시)
ID는 클라이언트에서 이미 알고 있는 정보이므로, getId 호출 시에는 초기화 진행 X
이 때, 프록시 객체의 target이 null이므로 영속성 컨텍스트에 초기화 요청
영속성 컨텍스트는 DB에 쿼리를 날려 실제 엔터티 객체를 만들어 프록시의 target과 연결
주의사항
타입 체크 시 == 대신 instanceOf를 사용해야 한다
언제 프록시가 반환될지, 실제 엔터티가 반환될지 예측 힘듦
영속성 컨텍스트에 엔터티가 이미 있다면 getReference()가 실제 엔터티 반환
getReference()로 프록시를 먼저 조회했다면, 이후 find()는 쿼리로 실제 엔터티를 생성했음에도 프록시를 반환
이는 JPA 동일성 보장을 지키기 위함
JPA는 한 영속성 컨텍스트 내라면 PK 값이 동일한 객체에 대해 동일성이 보장됨
즉, 실제 엔터티든 프록시 객체든 pk 값이 같을 때는 == 비교 결과가 true여야함
준영속 상태일 때, 프록시를 초기화하면 예외 발생
프록시는 영속성 컨텍스트를 이용해 초기화를 시도하므로 em.detach(), em.close(), em.clear()를 호출한 준영속 상태 엔터티는 세션이 없거나 끝났다는 예외 발생
하이버네이트 예외: LazyInitializationException
실무에서는 보통 트랜잭션 끝나고 나서 프록시를 조회할 때 노세션 예외를 자주 만남
보통 트랜잭션 시작 및 끝을 영속성 컨텍스트 시작 및 끝과 맞추므로
즉시 로딩 & 지연 로딩
지연 로딩 (FetchType.LAZY)
처음 로딩 시 연관 객체는 직접 조회하지 않고 프록시로 조회
연관 객체는 실제로 사용하는 시점에 초기화
즉시 로딩 (FetchType.EAGER)
처음 로딩 시 한 번에 DB 쿼리를 날려 연관 객체의 실제 엔터티를 가져옴
조인을 사용해 가능한 SQL 한 번에 함께 조회
글로벌 패치 전략
모든 연관관계를 지연 로딩으로 사용하고 필요할 때만 패치조인으로 한 번에 가져오기
즉시 로딩은 예상치 못한 SQL 발생
즉시 로딩은 JPQL에서 N + 1 문제 일으킴
em.find()는 JPA가 최적화해 적어도 하나의 조인 쿼리로 가져오므로 위험도가 덜 함
문제는 JPQL인데, JPQL은 SQL로 바로 번역되어 쿼리를 날림
만일, 멤버 전체를 조회하는 쿼리를 날리면 전체 멤버를 가져옴
이 때, 즉시 로딩이라면 팀 값을 반드시 채워야 함
멤버 조회 후 바로 팀에 대한 쿼리를 멤버 각각에 대해 날려 N + 1 개 쿼리가 발생
기본값 설정 유의사항
@ManyToOne, @OneToOne: 기본이 즉시 로딩이므로 반드시 LAZY로 설정
@OneToMany, @ManyToMany: 기본이 지연 로딩
영속성 전이와 고아 객체
영속성 전이 (CASCADE)
엔터티를 영속화할 때, 연관된 엔터티까지 함께 영속화 (단순히 편리성 제공)
따로 Child 까지 영속화하지 않아도 Parent의 Childs 컬렉션에 등록된 모든 Child를 함께 영속화
사용 조건
단일 엔터티에 완전히 종속적일 때
하나의 부모가 자식들을 관리
자식들은 다른 엔터티와 연관이 없음 (소유자가 하나)
부모와 자식의 라이프 사이클이 동일
보통 CascadeType.ALL, CascadeType.PERSIST 정도만 사용
고아 객체 제거 (orphanRemoval = true)
부모 엔터티와 연관관계가 끊어진 자식 엔터티를 자동으로 삭제
자식 엔터티를 부모 컬렉션에서 제거하면 자동으로 DELETE 쿼리가 나감 (참조가 끊어짐)
Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remove(0);
=> DELETE FROM CHILD WHERE ID = ?
반대로 부모를 제거할 때도 자식 함께 제거
개념적으로 부모를 제거하면 자식은 고아
마치 CascadeType.ALL, CascadeType.REMOVE 처럼 동작
사용 조건
단일 엔터티에 완전히 종속적일 때 (@OneToOne, @OneToMany만 가능)
CascadeType.ALL + orphanRemoval = true
부모 엔터티를 통해 자식의 생명주기를 관리할 수 있음
부모 엔터티에 적용
DDD Aggregate Root 구현에 용이
값 타입
값타입은 엔터티와 혼동하지 않고 정말 값 타입이라 판단될 때만 사용
XY 좌표 수준 말고 실무에서 거의 없음
식별자가 필요하고 지속해서 값을 추적해야한다면 엔터티
JPA 데이터 타입 분류
엔터티 타입
@Entity 정의한 객체
데이터 변경이 있어도 식별자로 지속해서 추적 가능
생명 주기 관리
공유 O
값 타입
단순히 값으로 사용하는 자바 기본 타입 혹은 객체 (int, Integer, String)
식별자 없이 값만 있으므로 변경시 추적 불가
생명주기를 엔터티에 의존 (회원을 삭제하면 이름, 나이 필드도 함께 삭제)
값타입은 공유되어서는 안됨
e.g. 회원 이름 변경시 다른 회원의 이름도 함께 변경되면 안됨
항상 값을 복사해서 사용 (Side effect를 예방)
불변 객체로 설계하는 것이 안전
타입 별 공유 예방 방법
기본 타입(primitive type)은 항상 값을 복사 (int, double)
래퍼 클래스나 특수 클래스(String)는 참조를 막을 수 없어서 값 변경 자체를 막음
임베디드 타입은 불변 객체로 설계해야함
생성자로만 값을 설정하고 수정자(Setter)를 모두 없애기
혹은 수정자를 private으로 만들면 같은 효과
값 타입은 인스턴스가 달라도 내부 값이 같으면 같은 것으로 봐야함
값 타입은 동등성 비교 필요
동등성(equivalence) 비교: 인스턴스의 값을 비교, equals()
동일성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용
값 타입의 equals() 메서드를 적절하게 재정의해야 함
equals()는 기본이 == 비교이므로, 동등성 비교를 하도록 재정의 필요
IntelliJ 자동 생성 권장 (hashcode도 같이 만들것)
Use getters when available 옵션 사용
getter를 사용하지 않으면 바로 필드에 접근 -> 프록시일 때 필드 접근 불가
분류
기본값 타입
e.g. 자바 기본 타입 (int, double), 래퍼 클래스 (Integer, Long), String
임베디드 타입 (복합 값 타입)
주로 기본 값 타입을 모아서 새로운 값 타입을 직접 정의
e.g. XY 좌표, Address(city, street, zipcode), Period(startDate, endDate)
주요 애노테이션
@Embeddable: 값 타입을 정의하는 곳에 표시 (기본 생성자 필수)
@Embedded: 값 타입을 사용하는 곳에 표시
둘 중 하나만 사용해도 동작하지만 둘 다 명시적으로 사용하는 방향 지향
장점
재사용 가능, 높은 응집도
객체와 테이블을 세밀하게 매핑하여 설계시 개념적으로 유의미
Period.isWork()처럼 해당 값 타입만의 유의미한 메서드를 만들 수 있음
유의 사항
적용 전 후 DB 테이블이 달라지는 것은 없음
임베디드 타입 내에도 엔터티를 가질 수 있음
한 엔터티 내에서 같은 값 타입을 재사용 가능
@AttributeOverrides를 사용해 DB 컬럼 이름 매핑
임베디드 타입 값이 null이면 매핑한 컬럼 값도 모두 null
값 타입 컬렉션
값 타입을 하나 이상 저장할 때 사용 (별도의 테이블 생성)
@ElementCollection, @CollectionTable
부모의 라이프 사이클의 의존 (영속성 전이 + 고아 객체 제거 기능 자동 내포)
수정이라는 개념이 없고, 컬렉션에서 값 타입 데이터를 찾아 제거하고 새로 추가
임베디드 값 타입이라면 해당 객체와 값이 똑같은 객체를 새로 생성해 remove
이 때, 해시코드가 중요 (해시코드를 정의하지 않았다면 컬렉션에서 안지워짐)
식별자 개념이 없어 변경 시 추적이 어려움
업데이트 시 테이블 데이터 전부 제거하고 다시 새로 INSERT
PK는 값 타입의 모든 컬럼이 묶여 구성됨
유의 사항
값 타입 컬렉션은 정말 단순할 때만 사용
셀렉트 박스 (치킨, 피자, etc…) 수준의 단순한 비즈니스 로직
추적할 필요 없고 값이 바뀌어도 업데이트 칠 필요 없을 때
이외에는 상황에 따라 일대다 관계로 풀 것
일대다 관계를 위한 엔터티를 만들고, 그 안에서 값 타입을 사용
영속성 전이 + 고아 객체 제거를 사용해 값타입 컬렉션처럼 사용
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = “MEMBER_ID”)
private List<AddressEntity> addressHistory = new ArrayList<>();
INSERT시 UPDATE 쿼리 한 번 더 나가지만 쿼리 최적화 등이 편리
다대일 일대다 양방향 매핑하면 UPDATE 쿼리 제거 가능
잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음
Reference
자바 ORM 표준 JPA 프로그래밍 - 기본편
-
스프링 데이터 접근 핵심 원리
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편 - 데이터 접근 핵심 원리
-
스프링 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는 어떻게 동작할까?
-
스프링 로깅 기본
로깅 (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편 - 백엔드 웹 개발 핵심 기술
-
JPA 활용 팁 1
JPA 테이블 설계 Tips
주문 테이블은 orders로 주로 사용 (예약어 order by 때문에)
테이블 이름은 소문자 + _ 스타일 사용
실무에서 @ManyToMany는 사용하지 말자
중간 테이블에 컬럼을 추가할 수 없고 세밀한 쿼리가 어려움
@ManyToOne, @OneToMany로 풀어내서 사용
연관 관계에서 외래 키가 있는 곳을 연관 관계의 주인으로 정하기 (One-to-Many에서는 Many가 주인)
Getter, Setter는 모두 제공하지 않고, 꼭 필요한 별도 메서드만 제공하는게 가장 이상적이지만 실무는 다름
Getter는 모두 열어놓으면 실무상 편리
엔티티 변경은 Setter를 모두 열어두기 보다 비즈니스 메서드를 별도 제공해 변경 지점이 명확하도록 함
엔티티의 식별자는 id로 쓰더라도 PK 컬럼명은 테이블명_id로 사용하자
Foreign key와 이름을 맞출 수 있는 장점
DBA들도 선호
Cascade=ALL
엔티티를 persist하면 다른 연관관계 엔티티까지 persist를 전파
Delete할 때는 모두 같이 지워짐
값 타입(임베디드 타입)은 변경 불가능하게 설계
@Setter를 제거하고 생성자에서 초기화 강제
기본 생성자를 protected로 두어 안전 향상
JPA 스펙 상 엔티티 및 임베디드 타입은 기본 생성자를 public 혹은 protected로 두어야 함
JPA가 객체 생성시 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문
실무에서는 검증 로직이 있어도 멀티 쓰레드 상황을 고려해 변경 컬럼에 유니크 제약 조건 추가하는 것이 안전
엔티티 설계 시 주의점
모든 연관관계는 지연로딩(Lazy)으로 설정
즉시로딩(Eager)은 예측이 어렵고 N + 1 문제가 자주 발생
연관 관계 엔티티 로딩 시 fetch join 혹은 엔티티 그래프 기능 사용
@XToOne 관계는 기본이 즉시로딩이므로 직접 지연로딩 설정을 해야 함
컬렉션은 필드에서 초기화
null 문제에서 안전
Hibernate은 엔티티 영속화 시 컬렉션을 감싸서 Hibernate이 제공하는 내장 컬렉션으로 변경 (PersistentBag) - 필드 초기화가 내부 매커니즘을 안전하게 지켜줌
테이블, 컬럼명 생성 전략
기본 전략
하이버네이트 기존 구현
엔티티의 필드명을 그대로 테이블 컬럼명으로 사용
SpringPhysicalNamingStrategy
스프링 부트 신규 설정
Camel case -> Snake case
. -> _
추가 전략
명시적으로 컬럼, 테이블명을 적으면 실제 테이블에 물리명 적용 (physical-strategy)
적지 않은 경우 논리명 적용 (implicit-strategy)
애노테이션 Tips
@PersistenceContext
엔티티 매니저(EntityManger) 주입
Lombok 생성자 주입 사용시 애노테이션 생략 가능
@Transactional
readOnly=true
플러시를 하지 않으므로 약간의 성능 향상
디폴트는 readOnly=false이므로, 큰 스코프에서 readOnly=true를 설정하고 커맨드성 작업에 @Transactional을 붙이는 방식으로 사용하면 편리
테스트에 붙으면, 테스트 종료 후 자동으로 트랜잭션 롤백
테스트를 환경을 위한 설정 파일
테스트 케이스에는 메모리 DB 사용이 효율적
데이터 소스나 JPA 관련 별도 추가 설정을 하지 않아도 됨
스프링 부트는 datasource 설정이 없으면 기본적으로 메모리 DB 사용
스프링 부트는 jpa 설정이 없으면 ddl-auto: create-drop 모드로 동작
설정 파일 읽기 전략
테스트에서 스프링을 실행하면, test/resources/application.yml을 읽음
해당 위치에 없을 경우, src/resources/application.yml 읽음
도메인 모델 패턴 VS 트랜잭션 스크립트 패턴
도메인 모델 패턴
엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 패턴 (서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할)
트랜잭션 스크립트 패턴
엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 패턴
변경 감지(Dirty Checking) & 병합(merge)
준영속 엔티티
영속성 컨텍스트가 더이상 관리하지 않는 엔티티
이전에 DB에 한 번 저장되어서 식별자가 존재하나 JPA가 현재 추적하고 있지 않는 객체
준영속 엔티티를 수정하는 2가지 방법
변경 감지(Dirty Checking) - Recommendation
동작
식별자로 엔티티를 조회(find)한 후 데이터 수정
컨텍스트가 종료되면서 트랜잭션 커밋 시점에 변경 감지가 동작하고, 데이터베이스에 UPDATE SQL 실행
병합(merge)
동작
준영속 엔티티의 식별자 값으로 영속 엔티티를 조회
영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체 (병합)
트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터 베이스에 UPDATE SQL 실행
병합은 모든 필드를 변경해버리고 데이터가 없으면 null로 업데이트하므로 위험
Best Practice: 엔티티 변경시 항상 변경 감지 사용하기
컨트롤러에서 엔티티 생성하지 말기
서비스 계층에 식별자(id)와 변경할 데이터를 명확히 전달 (파라미터 or DTO)
서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경
트랜잭션 커밋 시점에 변경 감지 자동 실행
-
스프링 부트 프로젝트 세팅
스프링 프로젝트 세팅 방법
프로젝트 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
-
스프링 핵심원리 - 기본편
스프링의 탄생
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
스프링 핵심 원리 - 기본편
-
스프링 시작하기
빌드 및 실행 방법
./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 접근 기술
-
자바 객체 지향 설계
클래스가 필요한 이유
String[] studentNames = {"학생1", "학생3", "학생4", "학생5"};
int[] studentAges = {15, 17, 10, 16};
int[] studentGrades = {90, 100, 80, 50};
학생이라는 개념을 다룰 때, 배열과 반복문으로 데이터를 처리해야 하므로 데이터 변경 시 실수할 가능성이 높다. 따라서 사람이 관리하기 좋은 코드를 만들기 위해 학생이라는 개념을 하나의 클래스로 묶어야 한다.
클래스 특징
클래스를 통해 마음껏 사용자 정의 타입을 만들 수 있다. (설계도)
클래스에 정의한 변수들 = 멤버 변수(Member variable) = 필드(Field)
실제 메모리에 만들어진 실체를 객체 혹은 인스턴스라 한다.
클래스 타입 변수는 객체를 생성하면 해당 객체의 참조값을 담는다.
System.out.println(student);
// 출력값
// (패키지 + 클래스 정보 @ 16진수 참조값)
class1.Student@7a81197d
클래스 & 인스턴스 & 객체
클래스
객체 생성을 위한 ‘틀’ 또는 ‘설계도’
객체가 가져야 할 속성(변수)과 기능(메서드)를 정의한다.
인스턴스
클래스로부터 생성된 객체
인스턴스 = 객체
어떤 클래스에 속해 있는지 강조 (관계에 초점)
객체
클래스의 속성과 기능을 가진 실체
세상 모든 사물을 단순하게 추상화해보면 속성과 기능 2가지만 남는다.
변수의 값 초기화
멤버 변수: 자동 초기화
인스턴스 생성시 자동 초기화 (new로 만드는 객체들의 멤버 변수들은 모두 자동 초기화된다.)
int = 0, boolean = fasle, 참조형 = null
직접 초기화 지정 가능
지역 변수: 수동 초기화
null
참조형 변수에서 아직 가리키는 대상이 없다면 null을 넣어둘 수 있다.
Data data = null;
아무도 참조하지 않는 인스턴스 (feat. GC)
참조형 변수에 null을 할당하면 해당 참조 데이터가 메모리에 남아 있다가 GC(Garbage Collector)에 의해 제거된다.
메소드가 종료되어 지역변수가 사라질 때, 지역변수가 참조하고 있던 인스턴스 역시 메모리에 남아 있다가 GC에 의해 제거된다.
NullPointerException
null에 .을 찍을 때 발생하는 에러이므로 디버깅시 유의하자.
this
인스턴스 자신의 참조값을 가리킨다. 생성자에서 지역변수 이름이 겹친다면 this를 통해 멤버변수에 접근할 수 있다.
this는 생략이 가능하다.
과거에는 명시적으로 보이지 않아 멤버 변수 접근시 항상 this를 사용하는 코딩 스타일이 존재했다.
그러나 최근엔 IDE의 발달 덕분에 멤버변수와 지역변수 구분이 잘되기 때문에, 꼭 필요한 경우에만 사용하고 생략하는게 권장된다.
변수 탐색
변수를 찾을 때 가까운 지역변수(매개변수 포함)를 먼저 찾고 없으면 그 다음으로 멤버변수를 찾는다. 멤버변수도 없으면 오류가 발생한다.
생성자
규칙
생성자의 이름은 클래스 이름과 같아야 한다.
반환타입이 없으므로 비워둬야 한다.
나머지는 메서드와 동일
인스턴스 생성 후 즉시 호출된다.
new 키워드 이후 ()는 생성자 호출을 의미한다.
생성자 덕분에 자동 초기화로 인한 더미 데이터 생성을 방지하여 초기화를 강제할 수 있다.
기본 생성자
public class MemberInit {
// 기본 생성자
public MemberInit() {
}
}
매개 변수가 없는 생성자
따로 정의한 생성자가 없는 경우 자바 컴파일러가 매개변수와 코드가 없는 기본생성자를 자동으로 만들어 준다.
생성자 오버로딩
생성자도 메서드 오버로딩처럼 여러 생성자 제공 가능
public class MemberConstruct {
String name;
int age;
int grade;
//추가
MemberConstruct(String name, int age) {
this.name = name;
this.age = age;
this.grade = 50;
}
MemberConstruct(String name, int age, int grade) {
this.name = name;
this.age = age;
this.grade = grade;
}
}
this()
생성자 내부에서 자신의 생성자를 호출할 수 있다. (중복 제거를 위해)
단, this()는 생성자 코드 첫줄에만 작성할 수 있다. (아니면 컴파일 오류 발생)
public class MemberConstruct {
String name;
int age;
int grade;
MemberConstruct(String name, int age) {
this(name, age, 50); //변경
}
MemberConstruct(String name, int age, int grade) {
this.name = name;
this.age = age;
this.grade = grade;
}
}
절차 지향 프로그래밍 VS 객체 지향 프로그래밍
절차 지향 프로그래밍
프로그램의 흐름을 순차적으로 따르며 처리하는 방식
데이터와 기능이 분리되어 있다.
데이터와 기능의 분리는 유지보수 관점에서 관리 포인트가 2곳으로 늘어난다.
객체 지향 프로그래밍
객체들 간의 상호작용을 중심으로 프로그래밍하는 방식 (실제 세계의 사물이나 사건을 단순하게 추상화)
속성과 기능(메서드)이 객체 안에 함께 포함되어 있다. (캡슐화)
장점
객체 사용자의 입장에서 코드가 보다 친숙하고 가독성이 높다.
유연하고 변경이 용이하다. (OCP 원칙을 지키는 확장 가능한 설계)
실세계를 역할(인터페이스)과 구현(구현한 클래스 혹은 객체)으로 구분 (다형성)
클라이언트 코드를 변경하지 않고 서버의 구현 기능을 변경할 수 있다.
(= 클라이언트는 인터페이스만 알면 내부 구조를 몰라도 되고 내부 구조를 변경해도 영향을 받지 않는다.)
한계
인터페이스가 변하면 클라이언트, 서버 모두 큰 변경이 발생한다.
따라서 인터페이스를 안정적으로 잘 설계하는 것이 중요하다.
캡슐화(Encapsulation)
속성과 기능을 하나로 묶어서 꼭 필요한 기능만 메서드를 통해 외부에 제공하고 나머지는 모두 내부로 숨기는 것
속성과 기능 묶기 + 접근 제어자를 통해 실현
좋은 캡슐화
속성은 반드시 숨기자.
객체의 데이터는 객체가 제공하는 기능인 메서드를 통해서 접근해야 한다.
데이터를 외부에 열어두면 클래스 내 데이터를 다루는 로직을 무시하고 데이터를 변경할 수 있음
꼭 필요한 기능만 노출하자.
클래스 내부에서만 사용하는 기능들은 모두 감추는 것 좋다.
사용하는 개발자 입장에서 필요한 기능만 정리되어 복잡도가 낮아진다.
음악 플레이어 예제
메소드 추출 팁
자신이 가진 데이터로 계산한다면, 일반적으로 자기자신이 메서드로 계산하는게 좋다.
나중에 수정이 생기거나 변경이 생길 때 본인만 바꾸면 되므로 관리가 편하다.
접근 제어자
해당 클래스 외부에서 특정 필드나 메서드에 접근하는 것을 허용하거나 제한할 수 있다.
필드, 메서드, 생성자에 사용된다.
지역변수는 스코프 내에서만 사용하므로 접근제어자를 사용하는 의미가 없고 사용할 수도 없다.
클래스에는 일부만 사용가능하다. (public, default)
public 클래스는 반드시 파일명과 이름이 같아야 한다.
하나의 자바 파일에 public 클래스는 하나만, default 클래스는 무한정 만들 수 있다.
종류
private: 모든 외부 호출을 막는다.
default(package-private): 같은 패키지안에서 호출은 허용한다.
protected: default + 다른 패키지여도 상속 관계의 호출은 허용한다.
public: 모든 외부 호출을 허용한다.
상속(Inheritance)
extends
기존 부모 클래스의 필드와 메서드를 새로운 자식 클래스에서 재사용하는 것
중복을 줄이고 편리하게 확장할 수 있음
단일 상속만 할 수 있다. (다중 상속은 불가능)
만일, 두 부모를 상속받았는데 둘 다 move()라는 메서드를 가지고 있다면 어떤 메서드를 실행해야할지 애매하다. (다이아몬드 문제)
클래스 계층구조가 매우 복잡해질 수 있다.
메서드 오버라이딩
상속 받은 기능을 자식이 재정의하는 것
멤버변수는 오버라이딩되지 않는다.
@Override
메모리 구조
상속관계 객체 생성 시 그 내부에 부모와 자식이 모두 생성된다. (하나의 참조값에 두 클래스 정보가 공존)
상속관계 호출시 대원칙 (3개)
상속관계 객체 호출 시, 호출자의 타입을 기준으로 먼저 찾는다.
현재 타입에서 기능을 찾지 못하면 상위 부모 타입으로 기능을 찾아서 실행한다. (끝까지 올라가도 없으면 컴파일 오류 발생)
자식 클래스에 오버라이딩된 메서드가 있다면 항상 우선하여 호출된다.
Car와 ElectricCar 예제
super
상속관계에서 부모와 자식의 필드 이름과 메서드 이름이 같은 경우, 부모를 참조하고 싶을 때 super를 통해 부모 클래스로 접근한다.
생성자
상속관계를 사용하면 자식 클래스의 생성자와 부모 클래스의 생성자를 반드시 호출해야 한다.
상속 시 생성자 첫 줄에 super()를 사용해 부모 클래스 생성자를 호출해야 한다.
예외로 첫 줄에 this()(=나말고 다른 생성자를 호출해줘)를 사용할 수 있다.
그러나 자식 생성자 내에서 언젠간 super()가 호출되어야 한다.
부모 클래스의 생성자가 기본생성자라면 super()를 생략할 수 있다.
결과적으로 상속관계 생성자 호출은 부모에서 자식 순으로 실행된다.
다형성(Polymorphism)
다른 타입의 객체를 하나인 것처럼 처리해 주는 것 (아래 두가지 특성 덕분에 실현됨)
(= 한 객체가 여러 타입의 객체로 취급될 수 있는 것)
다형성의 본질은 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다는 것
다형적 참조
부모는 자식을 품을 수 있다. (부모 타입의 변수가 다양한 자식 인스턴스를 참조할 수 있다.)
Parent poly = new Child()
= 업캐스팅 (업캐스팅은 생략이 가능하고 권장된다.)
업캐스팅은 메모리상에 인스턴스가 항상 존재하므로 안전하다.
반면에, 자식은 부모를 품을 수 없다.
Child child = poly // 컴파일 에러
만약 부모 클래스에서 자식 클래스의 메서드를 호출하고 싶다면 다운캐스팅 해야한다.
Child child = (Child) poly
((Child) poly).childMehtod() (일시적 다운 캐스팅도 가능)
다만, 다운캐스팅은 자식 타입이 메모리상에 존재하지 않을 경우 ClassCastException 런타임 에러를 발생시키므로 매우 주의가 필요하다.
다운 캐스팅 시 instance of를 사용하면 안전하다.
오른쪽에 있는 타입에 왼쪽에 있는 인스턴스 타입이 들어갈 수 있으면 true, 아니면 false
new Parent() instanceof Parent // true
new Child() instanceof Parent // true
new Parent() instanceof Child // false
자바 16부터는 instanceof와 동시에 변수 선언도 가능하다.
if (parent instanceof Child child) {...}
다형적 참조 덕분에 자식 인스턴스들을 함수의 부모 타입 매개변수로 참조하거나, 배열의 타입을 부모 타입으로 가져가 자식 인스턴스들을 참조할 수 있다. (중복 제거 및 반복 가능)
메서드 오버라이딩
오버라이딩된 메서드는 항상 우선권을 가진다.
자식에서도 오버라이딩하고 손자에서도 오버라이딩했다면, 손자의 오버라이딩 메서드가 우선권을 가진다.
만일 메서드 오버라이딩이 없다면 항상 부모 타입의 메서드를 호출했을 것이다.
다형성 덕분에 IoC, OCP, DIP, 전략 패턴 등이 가능해짐
다형성이 매우 중요하다.
OCP 원칙
좋은 객체 지향 설계 원칙 중 하나
Open for extension, Closed for modification (확장에는 열려있고 변경에는 닫혀 있다)
기존의 코드 수정 없이 새로운 기능을 추가할 수 있다는 의미
다형성을 보완하는 추상 클래스
추상 클래스는 다형성만으로 생기는 두 가지 문제를 해결한다.
부모 클래스를 인스턴스로 생성할 수 있는 문제 (추상적인 개념이 실제로 존재하는 것은 이상함)
부모 클래스를 상속 받는 자식 클래스가 메서드 오버라이딩을 하지 않을 가능성 (개발자의 실수)
추상 클래스
부모 클래스는 제공하지만 실제 생성되면 안되는 클래스
추상적인 개념을 제공하며 부모 클래스 역할로서 상속 목적으로 사용
인스턴스를 생성할 수 없음 (제약 1)
abstract class AbstractAnimal {...}
추상 메서드
자식 클래스가 반드시 오버라이딩해야 하는 메서드 (제약 2)
메서드 바디가 없음
추상 메서드가 하나라도 있는 클래스는 추상 클래스로 선언해야 한다.
public abstract void sound()
인터페이스 - 순수 추상 클래스를 지원
인터페이스 등장 배경
추상 클래스는 여전히 자신의 메서드를 가질 수 있다.
반면에, 순수 추상 클래스는 추상 클래스를 실행 로직이 전혀 없는 추상 메서드로만 구성한 것을 의미한다.
이는 다형성을 위한 규격, 마치 USB 인터페이스 같은 느낌을 준다.
자바는 이러한 순수 추상 클래스를 편리하게 사용할 수 있도록 인터페이스를 지원한다.
특징
interface 키워드, 구현시 implements 키워드 사용
인터페이스의 메서드는 모두 public abstract이다. (직접 쓸 수도 있지만 생략 권장)
인터페이스의 멤버 변수는 public static final이다. (마찬가지로 생략 권장)
구현이라는 용어 사용
상속은 부모의 기능을 물려 받는 것이지만, 인터페이스는 모든 메서드가 추상 메서드이므로 물려받을 기능이 없고 오히려 자식이 오버라이딩해서 메서드를 구현해야 한다.
다만, 자바 입장에서는 상속이나 구현이나 동일하게 동작한다.
클래스 & 추상 클래스 & 인터페이스는 코드와 메모리 구조상 모두 동일하다.
다중 구현을 지원
유용한 이유
제약
인터페이스의 메서드를 반드시 구현하라는 규약을 준다.
순수 추상 클래스를 지향해도 추상 클래스는 다른 개발자가 미래에 메서드를 추가할 수 있기 때문에, 인터페이스는 이를 예방한다.
다중 구현
클래스의 상속이 하나의 부모만 지정할 수 있는 것과 달리, 인터페이스는 여러 부모를 둘 수 있다.
인터페이스는 자신이 구현을 가지지 않고, 자식이 메서드를 구현한다. 또한 어차피 오버라이딩으로 인해 자식의 메서드가 호출된다. 따라서, 다이아몬드 문제가 발생하지 않는다.
실무적 장단점
인터페이스는 기획이나 사용 기술이 구체화되지 않았을 때, 구현을 미룰 수 있다. (장점)
어떤 DB를 사용할지 미정이라면, 인터페이스만 구현 후 메모리 레포지토리를 사용
할인 정책이 미정이라면, 인터페이스만 구현 후 0원 할인으로 미리 개발 가능
인터페이스는 추상화라는 비용을 발생시킨다. (단점)
개발자가 코드를 읽을 때 인터페이스를 항상 본 후 구현체를 보게 되어 읽는 시간이 증가한다.
대부분 모든 곳에 인터페이스를 먼저 구현하는 것이 이상적이지만, 기능을 확장할 가능성이 없다면 구체 클래스를 직접 사용하고 향후 꼭 필요할 때 리팩토링해서 인터페이스를 도입하는 것도 좋다.
의존 관련 용어 정리
A -> B (UML)
= A가 B를 안다.
= A가 B를 의존한다.
= A가 B를 상속받았다. (A가 자식이고 B가 부모다)
= A가 B를 사용한다.
좋은 객체 지향 설계의 5가지 원칙 (SOLID)
클린 코드 저자 Robert Martin(로버트 마틴)은 좋은 객체 지향 설계의 5가지 원칙을 제시한다.
단일 책임 원칙(SRP, Single responsibility principle)
한 클래스는 하나의 책임만 가져야 한다.
책임의 추상적인 표현이지만, 변경을 기준으로 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것
개방-폐쇄 원칙(OCP, Open/closed principle)
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
다형성을 활용해 기존 코드는 변경하지 않고 새로운 기능들을 추가할 수 있다.
리스코프 치환 원칙(LSP, Liskov substitution principle)
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
다형성의 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것
다형성에 신뢰성을 부여하고 지원하는 원칙
예시
자동차 인터페이스의 엑셀은 앞으로 가야하는 기능인데, 뒤로 가게 구현하면 LSP 원칙 위반
느리게 가더라도 앞으로 가야한다.
인터페이스 분리 원칙(ISP, Interface segregation principle)
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
인터페이스가 명확해지고 대체 가능성이 높아진다.
예시
자동차 인터페이스 -> 운전 인터페이스 & 정비 인터페이스로 분리
사용자 클라이언트 -> 운전자 클라이언트 & 정비사 클라이언트로 분리
정비 인터페이스가 변해도 운전자 클라이언트에 영향을 주지 않음
의존 관계 역전 원칙(DIP, Dependency inversion principle)
프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다.
즉, 클라이언트 코드가 구현 클래스에 의존하지 말고 인터페이스에 의존하라는 의미
다만, 다형성만으로는 OCP, DIP 원칙을 지킬 수 없다.
OCP, DIP를 지키려고 하다보면 결국 스프링 프레임워크를 만들게 된다.
Reference
김영한의 실전 자바 - 기본편
스프링 핵심 원리 - 기본편
-
자바 메모리 구조와 변수, 메서드 종류
자바 메모리 구조
메서드 영역
프로그램을 실행하는데 필요한 공통 데이터를 관리
프로그램의 모든 영역에서 공유됨
구성
클래스 정보: 클래스 실행 코드 (바이트 코드) - 필드, 메서드, 생성자 코드 등
static 영역: static 변수, 메서드, 클래스들을 보관 (프로그램 시작부터 끝까지 메모리 할당)
런타임 상수 풀: 프로그램을 최적화하기 위해 공통 리터럴 상수를 보관
스택 영역
실제 프로그램이 실행되는 영역
실행 스택을 생성하고 메서드가 호출될 때마다 스택에 스택 프레임을 쌓는다. 메서드가 종료되면 스택프레임을 제거한다.
지역변수, 중간 연산 결과, 메서드 호출 정보 등이 스택 프레임에 포함된다.
처음 자바를 실행하면 main()을 실행하기 위해 실행 스택에 main() 스택 프레임을 하나 생성한다.
힙 영역
인스턴스가 생성되는 영역 (new 명령어를 사용하면 여기를 사용)
가비지 컬렉션이 이루어지는 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다.
메서드 코드의 위치
객체가 생성될 때, 인스턴스 내부 변수 값은 각각 힙 영역에 할당되어 독립적으로 존재하지만, 메서드는 새로운 메모리 할당없이 공통된 코드를 공유한다. 따라서, 인스턴스 메서드를 호출하면 실제로는 메서드 영역에 있는 코드를 힙 영역으로 불러와서 수행한다.
단, static 메서드는 메서드 영역의 클래스 정보 코드를 사용하겠지만, 실행도 메서드 영역의 클래스 정보에서 한다.
멤버 변수의 종류
인스턴스 변수 (static이 붙지 않음)
각각의 인스턴스에 독립적으로 소속되어 있는 변수
static 변수 (static이 붙음)
클래스 자체에 소속되어 공용으로 함께 사용할 수 있는 변수
static 변수 = 정적 변수 = 클래스 변수
메서드 영역 (static 영역)에서 관리
클래스명 + . 으로 접근 (자신의 클래스에 있는 정적 변수라면 클래스명 생략 가능)
처음 자바가 로딩될 때 하나만 생성
일반적으로 자바 프로그램이 실행되고 JVM이 처음 뜰 때, 클래스 정보를 메소드 영역에 모두 불러 들이고, static이 붙은 변수들은 메모리(static 영역)에 할당해버린다. 이런 static 변수들은 이 때 딱 하나 만들어지고 Java가 끝날 때까지 계속 쓸 수 있다.
변수의 생명주기
지역변수(매개변수 포함): 스택 영역의 스택 프레임에 존재 (메서드 종료 시 소멸)
인스턴스 변수: 힙 영역에 존재 (GC 발동 시 소멸)
클래스 변수: 메서드 영역의 static 영역에 존재 (JVM 종료 시 소멸)
지역변수 < 인스턴스 변수 < 클래스 변수
지역 변수가 제일 짧고, 클래스 변수가 제일 길다.
static이 정적인 이유
힙 영역에 생성되는 인스턴스 변수는 런타임에서 동적으로 생성되고 제거되지만, static 변수는 프로그램 시작 시점에 만들어지고 프로그램 종료 시점에 제거되므로 상대적으로 매우 정적이다.
멤버 메서드의 종류
인스턴스 메서드 (static이 붙지 않음)
인스턴스에 소속되어 인스턴스를 생성해야 사용할 수 있는 메서드
static 메서드 (static이 붙음)
클래스에 소속되어 클래스에 바로 접근해 사용할 수 있는 메서드
static 메서드 = 정적 메서드 = 클래스 메서드
인스턴스 변수를 필요로 하지 않는 단순 기능만 제공하는 경우 사용 (유틸리티성 메서드)
static 메서드는 static만 사용할 수 있다.(정적변수나 정적 메서드)
main()가 대표적 정적 메서드 (main()이 같은 클래스에서 호출하는 메서드도 정적 메서드)
자주 호출해야 한다면 static import를 통해 클래스 명을 생략하고 메서드를 호출할 수 있다.
final
변수에 final 키워드가 붙으면 더는 값을 변경할 수 없다.
특정 변수의 값을 할당한 이후 변경하지 않아야 한다면 사용하자. (고객 id 같은 부분)
의미 있는 경우
static final 필드(클래스 멤버 변수)를 필드 초기화 하는 것 (메모리 중복 없음)
상수도 static final을 지정한다.
생성자를 이용해서 final 필드(인스턴스 멤버 변수)를 초기화 하는 것
의미 없는 경우
final 필드(인스턴스 멤버 변수)를 필드 초기화 하는 것 (인스턴스마다 값이 중복되어 메모리 낭비)
클래스 final
상속의 끝을 의미, final로 선언된 클래스는 상속할 수 없다.
메서드 final
오버라이딩의 끝을 의미, final로 선언된 메서드는 오버라이드 될 수 없다.
Reference
김영한의 실전 자바 - 기본편
-
-
자바 주요 syntax 정리
대원칙
자바는 항상 변수의 값을 복사해서 대입한다.
변수 선언과 초기화
변수 선언
메모리 공간을 확보해서 데이터를 저장할 그릇을 만드는 것
변수 이름을 통해 해당 메모리공간에 접근한다.
변수 초기화
선언한 변수에 처음으로 값을 저장하는 것
초기화하지 않고 사용할 경우 컴파일 에러
변수를 선언하면 어떤 메모리 공간을 차지하지만, 해당 공간은 다른 프로그램이 사용하고 종료되어 남겨진 알 수 없는 값이 있을 수 있다.
따라서, 초기화하지 않으면 이상한 값이 출력될 수 있으므로, 자바는 문제를 예방하기 위해 변수 접근 전에 초기화를 강제
선언 및 초기화 방식 예제
// 1. 변수 선언, 초기화 각각 따로
int a;
a = 1;
System.out.println(a);
// 2. 변수 선언과 초기화를 한번에
int b = 2;
System.out.println(b);
// 3. 여러 변수 선언과 초기화를 한번에
int c = 3, d = 4;
System.out.println(c);
System.out.println(d);
// 4. 여러 변수 선언을 한번에
int e, f;
컴파일 에러 & 런타임 에러
컴파일 에러는 문법에 맞지 않았을 때 발생하는 에러로 오류를 빠르고 명확하게 찾을 수 있어서 좋은 에러이다.
반면에, 런타임 에러는 프로그램 실행 시 발생하는 에러로 미리 예방이 어려워 나쁜 에러이다. 예를 들어, 고객이 계좌이체를 했는데 내 돈은 나가고 상대방에게 돈이 안들어간 경우 돈이 증발되는 비극이 런타임에서 발생할 수 있다.
주로 사용하는 변수 타입
정수
int (기본)
long (리터럴 값이 20억이 넘을 것 같을 때 사용)
실수
double
불린형
boolean
문자열
String
문자 하나든 문자열이든 모두 String을 사용하는 것이 편리하다.
문자 길이에 따라 메모리 공간이 동적으로 달라짐
예외: 파일 다루기
byte (파일은 바이트 단위로 다루므로 파일 전송, 파일 복사 등에 주로 사용)
변수 명명 핵심 관례
클래스는 대문자로 시작, 나머지는 소문자로 시작
예외
상수는 모두 대문자를 사용하고 언더바로 구분
패키지는 모두 소문자로 사용
전위 & 후위 증감 연산자
전위 증감 연산자
증감 연산이 먼저 수행된 후 나머지 연산 수행
후위 증감 연산자
다른 연산이 먼저 수행된 후 증감 연산 수행
예제
public class OperatorAdd {
public static void main(String[] args) {
// 전위 증감연산자
int a = 1;
int b = 0;
b = ++a;
System.out.println("a = " + a + ", b = " + b); // a = 2, b = 2
// 후위 증감연산자
a = 1;
b = 0;
b = a++;
System.out.println("a = " + a + ", b = " + b); // a = 2, b = 1
}
}
새로운 switch syntax
자바 14부터 조금 더 깔끔한 switch 문법이 도입되었다.
public class Switch {
public static void main(String[] args) {
int grade = 2;
int coupon = switch (grade) {
case 1 -> 1000;
case 2 -> 2000;
case 3 -> 3000;
default -> 500;
};
System.out.println("발급받은 쿠폰 " + coupon);
}
}
삼항 연산자 syntax 예제
String coffee = (time < 3) ? "caffeine" : "decaffeine";
do-while문
조건에 상관없이 무조건 한 번은 코드를 실행한다.
public class DoWhile {
public static void main(String[] args) {
int i = 5;
do {
System.out.println(i);
i++;
} while (i < 3);
}
}
무한 루프 For문
For문은 초기식, 조건식, 증감식의 생략이 가능하고 모두 생략할 경우 무한 루프 반복문이 된다.
(= while true)
public class ForLoopInfinite {
public static void main(String[] args) {
int i = 1;
for (;;) {
if (i >= 10) {
System.out.println("Exit on 10");
break;
}
System.out.println(i);
i++;
}
}
}
다음과 같이 특정식만 생략하는 경우도 가능하다. 결과는 위와 동일하다.
public class ForLoopInfinite {
public static void main(String[] args) {
for (int i = 1; ; i++) {
if (i >= 5) {
System.out.println("Exit on 5");
break;
}
System.out.println(i);
}
}
}
지역 변수와 스코프
지역 변수(Local Variable)
특정 지역에서만 사용할 수 있는 변수
지역은 코드 블록({})을 의미
자신이 선언된 코드 블록 안에서만 생존하고, 블록을 벗어나면 제거된다.
스코프(Scope)
변수의 접근 가능한 범위
for문의 초기식은 for문 내 scope에서만 접근 가능하고 바깥에서는 사용할 수 없다.
변수의 스코프를 꼭 필요한 곳에 한정해 사용해야 메모리를 효율적으로 사용하고 더 유지보수하기 좋은 코드가 된다.
main() 코드 스코프의 변수는 프로그램이 종료될 때까지 메모리에 유지되어 비효율적이다. (비효율적인 메모리 사용)
필요한 곳 바깥에서부터 변수를 선언하면 생각해야 할 변수가 늘어 복잡하다. (코드 복잡성 증가)
연산 시 주요 핵심
같은 타입끼리의 연산 결과는 타입이 동일하다. (int + int는 int다)
서로 다른 타입의 계산은 큰 범위로 자동 형변환이 일어난다. (int + long은 long + long)
자동 형변환과 명시적 형변환
자동 형변환
작은 범위에서 큰 범위로 대입은 허용한다.
int < long < double
큰 범위에서 작은 범위는 문제가 발생
소수점 버림
오버플로우
다른 타입끼리의 연산 시 큰 범위로 자동 형변환이 발생한다.
명시적 형변환
위험을 감수하고 데이터 타입을 강제로 변경하는 것
자바는 기본적으로 큰 범위에서 작은 범위의 대입에 대해 컴파일 에러를 발생시킨다.
은행 이자를 계산하는데 타입 문제로 (double -> int) 이자가 날아가버리는 등의 큰 문제를 방지하기 위해
따라서, 큰 범위에서 작은 범위 대입은 명시적 형변환이 필요하다.
(int) 3.0
형변환 예제
public class Casting {
public static void main(String[] args) {
int div1 = 3 / 2;
System.out.println("div1 = " + div1); //1
double div2 = 3 / 2;
System.out.println("div2 = " + div2); //1.0
double div3 = 3.0 / 2;
System.out.println("div3 = " + div3); //1.5
double div4 = (double) 3 / 2;
System.out.println("div4 = " + div4); //1.5
int a = 3;
int b = 2;
double result = (double) a / b;
System.out.println("result = " + result); //1.5
}
}
데이터 타입 분류
기본형(Primitive type)
int, long, double, boolean 처럼 데이터 값을 변수에 직접 저장할 수 있는 데이터 타입
데이터 사이즈가 정해져 있음 (정적)
더 빠르고 효율적인 메모리 처리
참조형(Reference Type)
데이터에 접근하기 위한 참조(주소)를 저장하는 데이터 타입 (배열, 객체)
생성 시점에서 동적 메모리 할당
유연성 있고 더 복잡한 데이터 구조를 다룰 수 있음
배열
따로 초기화 하지 않는 경우, 배열 생성시 내부 값이 자동으로 초기화된다.
숫자는 0, 불린형은 false, 문자열은 null
배열 변수는 실제 배열이 존재하는 메모리 공간에 대한 주소(참조값)를 담는다.
배열 초기화 예제
```java
public class ArrayInitialization {
public static void main(String[] args) {
// 기본 선언 및 배열 생성
int[] students1;
students1 = new int[5];
// 선언 & 초기화
int[] students2 = new int[]{1, 2, 3, 4, 5};
// 선언 & 더 간단한 초기화
int[] students3 = {1, 2, 3, 4, 5};
// 오류 케이스 // int[] students4; // students4 = {1, 2, 3, 4, 5};
} }
```
For-Each문(Enhanced For Loop)
컬렉션(배열, set etc…)의 요소를 탐색할 때 조금 더 편리한 기능을 제공하는 문법이다.
컬렉션 요소들을 처음부터 끝까지 탐색한다.
public class EnhancedForLoop {
public static void main(String[] args) {
int[] numbers = {1, 2, 3, 4, 5};
for (int number : numbers) {
System.out.println(number);
}
}
}
메서드(Method) 유의점
자바는 항상 변수의 값을 복사해서 대입한다. (인자가 파라미터로 전달될 때도 마찬가지다.)
메서드 호출이 끝나면 메서드 내 파라미터 변수, 로컬 변수들이 모두 메모리에서 제거된다.
메서드 호출 시에도 인자의 타입이 메서드 파라미터의 타입과 똑같아야 하므로, 명시적 형변환이 필요하거나 자동 형변환이 일어날 수 있다.
메서드 오버로딩(Method Overloading)
이름이 같고 매개변수가 다른 메서드를 여러개 정의하는 것을 의미한다.
메서드 시그니처(method signature)
자바에서 메서드를 구분할 수 있는 고유한 식별자
메서드 시그니처 = 메서드 이름 + 매개변수 타입(순서)
반환 타입은 시그니처에 포함되지 않는다.
규칙
메서드의 이름이 같아도 매개변수의 타입 및 순서가 다르면 오버로딩할 수 있다. (=메서드 시그니처가 달라서 가능)
반환 타입은 인정하지 않는다.
여러 개의 메서드를 오버로딩했을 때, 자바는 먼저 본인의 타입에 최대한 맞는 메서드를 찾아 실행하고, 그래도 없으면 형변환 가능한 타입의 메서드를 찾아 실행한다.
메서드 오버로딩 유의 케이스
아래 두 가지는 메서드 오버로딩 하지 못한다.
메서드 시그니처가 같기 때문!
int add(int a, int b)
int add(int c, int d)
Reference
김영한의 자바 입문 - 코드로 시작하는 자바 첫걸음
-
Java 기본 특징
자바 표준 스펙
자바는 표준 스펙이 존재하고 여러 회사가 자신에 입맞에 맞게 이를 구현한다.
자바 표준 스펙
자바의 설계도 문서
자바 커뮤니티 프로세스(JCP)를 통해 관리
구현
자바 표준 스펙에 맞춰 여러 회사가 각자에 최적화된 자바 프로그램을 개발
오라클 Open JDK, Adoptium Eclipse Temurin, Amazon Corretto etc…
오라클 Open JDK 사용하다가 Amazon Corretto 사용해도 대부분 큰 문제 없음
각 회사들은 다양한 OS(Mac, Windows, 리눅스)에 맞는 자바도 함께 제공
컴파일과 실행
소스코드(Source code)
개발자가 .java 확장자의 자바 소스코드를 작성한다.
컴파일(Compile) 단계
자바가 제공하는 javac 를 사용해, .java -> .class 파일 생성
command: javac Hello.java
즉, 자바 컴파일러가 소스코드를 바이트 코드로 변환
자바 가상 머신에서 더 빠르게 실행될 수 있게 최적화하고 syntax error 검출
실행(Runtime) 단계
java 프로그램을 사용해 자바를 띄우고 바이트코드인 .class 파일을 실행하면, JVM(실제 자바 프로그램 = 자바 가상 머신)이 띄워지면서 바이트코드를 읽고 프로그램을 실행한다.
command: java Hello (Hello.class 의 .class 확장자를 빼고 입력)
운영체제 독립성
일반적인 프로그램은 다른 운영체제에서 실행할 수 없다.
Windows 프로그램은 Windows OS가 사용하는 명령어들로 구성되어 있어서, 다른 OS와 호환되지 않음.
반면에, 자바 프로그램은 자바가 설치된 모든 OS에서 실행 가능 (호환성)
각 OS에 맞게 설치된 자바는 해당 OS의 명령어들로 컴파일된 .class 바이트코드를 실행
덕분에 개발할 때와 서버 실행 시 환경에 맞춰 다른 자바를 사용할 수 있다.
개발: Mac, Windows
서버: AWS Linux (Amazon Corretto 자바 설치)
Reference
김영한의 자바 입문 - 코드로 시작하는 자바 첫걸음
Touch background to close