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, 네트워크, 리플렉션
-
도커(Docker)Dive - 주요 명령어와 지시어
도커 명령어
기본 양식: docker (Management Command) Command
Management Command는 생략 가능 (생략이 가능하면 생략을 권장)
정보
docker version : Client, Server의 버전 및 상태 확인
docker info : 플러그인, 호스트 OS의 시스템 상세 정보 확인
docker --help : 메뉴얼 확인
e.g.
docker --help
docker container --help
docker container run --help
docker ps : 실행 중인 컨테이너 리스트 조회
-a : 종료된 컨테이너 포함 모든 컨테이너 조회
docker logs (컨테이너 명) : 실행 중인 컨테이너의 로그 조회
-f : 실시간 로그 조회
이미지 레지스트리
docker pull 이미지명 : 로컬 스토리지로 이미지 다운로드 (이미지 네이밍 규칙 준수)
docker tag 기존이미지명 추가할이미지명 : 로컬 스토리지에 이미지명 추가
실제 파일은 하나 (즉, 하나에 이미지에 여러 개의 이름 추가 가능)
같은 파일이어도 이름에 따라 어디에 업로드 될 지가 달라짐
e.g. docker tag devwikirepo/simple-web:1.0 veluga29/my-simple-web:0.1
docker push 이미지명 : 이미지 레지스트리에 이미지 업로드
docker login : 로컬 스토리지 특정 공간에 이미지 레지스트리 인증 정보 생성
생성 디렉터리: ~/.docker/config.json
docker logout : 이미지 레지스트리 인증 정보 삭제
Management Command - container
docker run (실행 옵션) 이미지명 (실행명령) : 컨테이너 실행
-d : 백그라운드 실행 (데몬 프로그램 실행에 적합)
--name {컨테이너명} : 컨테이너의 이름 지정
-it : 커맨드 창을 통해 실행할 컨테이너와 직접 상호작용
shell 명령 bin/bash 추가 필요
--network 네트워크명 : 원하는 네트워크 지정
-p HostOS의포트:컨테이너의포트 : 포트포워딩 옵션
-v 도커의볼륨명:컨테이너의내부경로 : 볼륨 마운트
e.g.
-v volume1:/var/lib/postgresql/data
-v volume1:/etc/postgresql -v volume2:/var/lib/postgresql/data
-v 사용자지정HostOS디렉토리:컨테이너의내부경로 : 볼륨 바인드 마운트 (디버깅용)
e.g. -v volume1:/var/lib/postgresql/data
--cpus={CPUcore수} : 컨테이너가 사용할 최대 CPU 코어 수 (소수점도 가능)
--memory={메모리용량} : 컨테이너가 사용할 최대 메모리 정의 (b, k, m, g 단위)
e.g. docker run --cpus=1 --memory=8g
e.g.
docker run 이미지명 (실행명령) : 컨테이너 실행 시 메타데이터의 cmd 덮어쓰기
docker run --env KEY=VALUE 이미지명 : 컨테이너 실행 시 메타데이터의 env 덮어쓰기
docker run -it --name 컨테이너명 이미지명 bin/bash : 컨테이너 실행과 동시에 터미널 접속 (shell) - 이미지 내부 파일 시스템 확인 혹은 디버깅 용도
docker run -it --network second-bridge --name ubuntuC devwikirepo/pingbuntu bin/bash : 원하는 네트워크 지정해 컨테이너 실행
docker run -d --name my-postgres -e POSTGRES_PASSWORD=password -v mydata:/var/lib/postgresql/data postgres:13 : 볼륨 지정해 DB 실행
docker rm 컨테이너명/ID : 컨테이너 삭제
-f : 실행 중인 컨테이너 삭제 (단순 rm은 실행 중인 컨테이너 삭제 불가)
e.g.
docker rm -f multi1 multi2 multi3 : 여러 컨테이너 한번에 삭제
docker cp 원본위치 복사위치 : 컨테이너와 호스트 머신 간 파일 복사
docker cp 컨테이너명:원본위치 복사위치 : 컨테이너 -> 호스트머신으로 파일 복사
docker cp 원본위치 컨테이너명:복사위치 : 호스트머신 -> 컨테이너로 파일 복사
docer container inspect 컨테이너명 : 컨테이너의 메타 데이터 조회
결과 예시
[{
{
...
"NetworkSettings": {
...
"Networks": {
"bridge": { //브릿지 네트워크명
...
"Gateway": "172.17.0.1", //도커브릿지 가상 IP
"IPAddress": "172.17.0.2", //컨테이너 가상 IP
...
}
}
}
}
}]
docker stats (컨테이너명/ID) : 컨테이너의 리소스 사용량 조회
docker events : Host OS에서 발생하는 컨테이너 관련 이벤트 로그 조회
Management Command - image
docker image ls (이미지명) : 다운로드된 이미지 조회
docker image inspect 이미지명 : 이미지의 메타 데이터 조회
docker image rm 이미지명 : 로컬 스토리지의 이미지 삭제
docker image history 이미지명 : 이미지의 레이어 이력 조회
도커 커밋
docker commit -m 커밋명 실행중인컨테이너명 생성할이미지명 : 실행 중인 컨테이너를 이미지로 생성
도커 빌드
docker build -t 이미지명 Dockerfile경로 : 도커파일을 통해 이미지 빌드
Dockfile경로 = 빌드 컨텍스트 지정
도커 파일이 있는 경로로 가서 실행하자! (Dockerfile경로=.)
옵션
-t 이미지명 : 결과 이미지의 이름 지정
-f 도커파일명
도커파일명이 Dockerfile이 아닌 경우 별도 지정
케이스 별로 다른 도커파일이 필요한 경우
--no-cache : 캐시를 사용하지 않고 빌드
e.g. docker build -t leafy:2.0.0 . --no-cache
Management Command - network
docker network ls : 네트워크 리스트 조회
docker network inspect 네트워크명 : 네트워크 상세 정보 조회
docker network create 네트워크명 : 네트워크 생성
e.g. docker network create --driver bridge --subnet 10.0.0.0/24 --gateway 10.0.0.1 second-bridge
docker network rm 네트워크명 : 네트워크 삭제
Management Command - volume
docker volume ls : 볼륨 리스트 조회
docker volume inspect 볼륨명 : 볼륨 상세 정보 조회
e.g.
[
{
"CreatedAt": "2025-02-05T04:38:44Z",
"Driver": "local", //local = 실제 데이터가 호스트 OS에 저장됨
"Labels": {},
//경로는 리눅스에서 관찰 가능, MacOS 등은 관찰 불가
//도커가 가상 머신 형태로 실행되기 때문
"Mountpoint": "/var/lib/docker/volumes/mydata/_data",
"Name": "mydata",
"Options": {},
"Scope": "local"
}
]
docker volume create 볼륨명 : 볼륨 생성
docker volume rm 볼륨명 : 볼륨 삭제
Management Command - compose
docker compose up -d : YAML 파일에 정의된 서비스 생성 및 시작
--build : 로컬에 동일 이름 이미지가 있으면 제거하고 새 이미지로 다시 빌드
소스코드 변경이 있어야 적용됨
docker compose ps : 현재 실행 중인 서비스 상태 표시
docker compose build : 현재 실행 중인 서비스의 이미지만 빌드
docker compose logs : 실행 중인 서비스의 로그 표시
docker compose down : YAML 파일에 정의된 서비스 종료 및 제거
-v : 볼륨까지 함께 제거 (옵션이 없으면 기본적으로 볼륨은 남아있음)
Dockerfile 지시어
기본 양식: 지시어 지시어의옵션
유의사항
일반적으로 파일 시스템 변 경이 있는 명령어는 레이어 추가 O
메타데이터에만 영향 주는 명령어는 레이어 추가 X
기본 지시어
FROM 이미지명 : 베이스 이미지를 지정 (필수)
COPY 빌드컨텍스트내파일경로 복사할레이어경로 : 파일을 레이어에 복사 (새로운 레이어 추가)
--from : 파일을 가져올 다른 스테이지 지정 (멀티 스테이지 빌드)
즉, 빌드 컨텍스트가 아니라 다른 스테이지 이미지에서 파일을 가져옴
e.g. --from=build
시스템 관련 지시어
WORKDIR 디렉터리명 : 작업 디렉터리를 지정 (새로운 레이어 추가, cd)
다음에 나오는 지시어들은 지정된 디렉터리 기준으로 수행됨
가능한 초반에 FROM 다음 바로 작성하는 것이 좋음
USER 유저명 : 명령을 실행할 사용자 변경 (새로운 레이어 추가, su)
기본은 루트 사용자로 실행
보안을 위해 컨테이너가 필요 이상의 권한을 가지지 않도록 조절
EXPOSE 포트번호 : 컨테이너가 사용할 포트를 명시
보통은 소스 코드안에 애플리케이션이 사용할 포트가 지정되어 있음
따라서, 필수는 아니지만 공유 문서 기재 용도 큼 (도커파일만 보고도 포트 확인 가능)
환경변수 설정
ARG 변수명 변수값 : 이미지 빌드 시점에만 사용할 환경 변수 설정
docker build --build-arg 변수명=변수값 : 덮어쓰기
ENV 변수명 변수값 (권장) : 이미지 빌드 및 컨테이너 실행 시점까지 계속 유지될 환경 변수 설정
docker run -e 변수명=변수값 : 덮어쓰기
프로세스 실행
ENTRYPOINT ["명령어"] : 자주 쓰이는 고정된 명령어를 지정
의도치 않은 명령어 접근 1차적 방지 (완벽 X)
e.g. ENTRYPOINT ["npm"]
CMD ["명령어"] : 컨테이너 실행 시 실행 명령어 지정 (메타 데이터 CMD 필드에 저장됨)
e.g. CMD ["start"]
RUN 명령어 : 명령어 실행 (새로운 레이어 추가)
Docker Compose 지시어
예시 1 - 애플리케이션 & Redis
version: '3'
services:
hitchecker:
build: ./app
image: hitchecker:1.0.0
ports:
- "8080:5000"
redis:
image: "redis:alpine"
예시 2 - 이중화 DB
version: '3'
x-environment: &common_environment
POSTGRESQL_POSTGRES_PASSWORD: adminpassword
POSTGRESQL_USERNAME: myuser
POSTGRESQL_PASSWORD: mypassword
POSTGRESQL_DATABASE: mydb
REPMGR_PASSWORD: repmgrpassword
REPMGR_PRIMARY_HOST: postgres-primary-0
REPMGR_PRIMARY_PORT: 5432
REPMGR_PORT_NUMBER: 5432
services:
postgres-primary-0:
image: bitnami/postgresql-repmgr:15
volumes:
- postgres_primary_data:/bitnami/postgresql
environment:
<<: *common_environment
REPMGR_PARTNER_NODES: postgres-primary-0,postgres-standby-1:5432
REPMGR_NODE_NAME: postgres-primary-0
REPMGR_NODE_NETWORK_NAME: postgres-primary-0
postgres-standby-1:
image: bitnami/postgresql-repmgr:15
volumes:
- postgres_standby_data:/bitnami/postgresql
environment:
<<: *common_environment
REPMGR_PARTNER_NODES: postgres-primary-0,postgres-standby-1:5432
REPMGR_NODE_NAME: postgres-standby-1
REPMGR_NODE_NETWORK_NAME: postgres-standby-1
volumes:
postgres_primary_data:
postgres_standby_data:
예시 3 - Leafy
version: '3'
services:
leafy-postgres:
build: ./leafy-postgresql
image: leafy-postgres:5.0.0-compose
volumes:
- mydata:/var/lib/postgresql/data
deploy:
resources:
limits:
cpus: '1'
memory: 256M
restart: always
leafy-backend:
build: ./leafy-backend
image: leafy-backend:5.0.0-compose
environment:
- DB_URL=leafy-postgres
depends_on:
- leafy-postgres
deploy:
resources:
limits:
cpus: '1.5'
memory: 512M
restart: on-failure
leafy-front:
build: ./leafy-frontend
image: leafy-front:5.0.0-compose
environment:
- BACKEND_HOST=leafy-backend
ports:
- 80:80
depends_on:
- leafy-backend
deploy:
resources:
limits:
cpus: '0.5'
memory: 64M
restart: on-failure
volumes:
mydata:
version : 도커 컴포즈의 버전 정의
services : 실제로 실행할 컨테이너들의 리스트
컨테이너 이름
build : 이미지 빌드가 필요한 경우 지정 (도커파일 경로 지정)
image : 원하는 이미지 지정
기존 이미지가 있는 경우 그대로 사용 (e.g. hitchecker:1.0.0)
없거나 --build 옵션 적용할 땐 build 경로의 Dockerfile 사용해 이미지 빌드
e.g. docker build -t hitchecker:1.0.0 ./app
이미지 재빌드 : 이미지 태그를 바꾸기 or --build 옵션 사용
기존 이미지도 없고 build 경로도 없는 경우, 외부 이미지 다운
ports : -p 옵션과 동일 (포트 포워딩)
volumes : 마운트할 볼륨 지정
볼륨명 : 컨테이너내부경로
environment : 환경변수 지정
키 : 밸류 : 기본방식
<<: *common_environment : x-environment의 공통 환경변수 주입
depends-on : 특정 컨테이너가 실행될 때까지 컨테이너 실행 보류
없으면 모든 컨테이너가 병렬 실행
다만, 이렇게 지정해도 프로세스 실행 속도 차이로 문제 발생 가능
-> 대신 물리적으로 일정 시간을 정해두는 방법이 좋을 수도 있음
volumes : 생성할 볼륨의 리스트
x-environment: &common_environment : 공통 환경변수 지정 (도커 컴포즈 버전 3 이상)
Reference
개발자를 위한 쉬운 도커
-
도커(Docker)Dive - 실무 적용 팁
레이어 관리
이미지의 크기를 줄이면 네트워크 비용을 감소시키고 빌드 속도를 향상시킬 수 있음
RUN 지시어 관리 (불필요한 레이어를 줄이기)
레이어 쌓는 지시어 하나 당 레이어가 추가됨
&&를 활용해 최대한 레이어 하나로 처리하자 -> 불필요한 레이어 감소
e.g. RUN을 5번 사용하면 레이어가 5개 쌓이는데 비해, 레이어를 1개만 쌓이게 할 수 있음
RUN apt-get update && \
apt-get install -y curl && \
apt-get install -y xz-utils && \
apt-get install -y git && \
apt-get clean
애플리케이션의 크기를 작게 관리하기
불필요한 기능 줄이기
큰 모듈을 여러 모듈로 분리하기
가능한 작은 크기의 베이스 이미지 사용하기
가능한 alpine OS 사용 (e.g. 우분투 이미지 70MB, 알파인 이미지 8MB)
FROM alpine:latest
RUN apk update && \
apk add --no-cache curl && \
apk add --no-cache xz && \ apk add --no-cache git
극단적으로 줄이고 싶다면 스크래치 이미지 활용 (FROM scratch)
스크래치 이미지
모든 이미지의 뿌리가 되는 이미지
이미지 빌드를 위한 최소한의 파일만 포함
스크래치 이미지 위에서 필요한 것만 패키징 -> 보안 향상, 이미지 크기 감소
e.g.
# 빌드 스테이지
FROM golang:alpine AS builder
WORKDIR /app
COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o helloworld main.go
# 운영 스테이지
FROM scratch
COPY --from=builder /app/helloworld .
EXPOSE 8080
ENTRYPOINT ["./helloworld"]
빌드한 GO 언어 프로그램은 아무것도 없는 스크래치 이미지에서도 실행 가능
-> 정적 바이너리 파일 (리눅스용 바이너리 파일)
GO 언어는 이미지 크기를 작게 구성하는데 매우 좋은 방법!
MSA에서 하나의 컨테이너 크기를 줄이는 것이 중요한 미션 -> GO 언어의 장점
.dockerignore 로 이미지에 불필요한 파일이 섞이지 않게 관리하기
빌드 컨텍스트로 이동할 파일을 관리
e.g. COPY . . 명령어 등으로 디렉터리 전체 복사할 경우 유용
캐싱을 활용한 빌드
도커는 Dockerfile 각 지시어 단계의 결과 레이어마다 캐시 처리
다음 빌드에서 동일한 지시어 및 처리 내용을 사용하면, 캐시된 레이어 그대로 사용 (새 레이어 생성 X)
e.g. 동일한 지시어인데 처리 내용이 다른 경우
동일한 지시어 COPY . . -> 빌드 컨텍스트의 소스코드 변경 O -> 새로 레이어 생성
레이어 변경이 있다면 해당 레이어와 그 이후의 모든 레이어는 새로 레이어 생성 (캐시 사용 X)
전략: 변경되지 않는 레이어들을 아래에 배치해 캐시 빈도 높이자 (e.g. 라이브러리 설치 레이어)
3-Tier 아키텍처 구성
문제: 백엔드 API는 프론트만 접근하고 클라이언트에 노출되면 안됨
해결책: Nginx 프록시 기술을 활용해 보안이 뛰어난 3-Tier 아키텍처 구성 가능
즉, 클라이언트는 웹서버만 접근 가능하고 백엔드 애플리케이션 접근은 불가능
Nginx 프록시는 특정 경로로 온 요청을 지정한 서버로 전달 (by Nginx 서버 설정)
보안 향상, 부하 관리, API 응답 캐시 등 가능
e.g. /api/ 경로로 온 요청을 애플리케이션 서버로 전달하도록 Nginx 서버 설정한 경우
http://localhost/index.html -> 웹서버의 정적 파일을 응답
http://localhost/api/~ -> 애플리케이션으로 요청 전달 (데이터 접근)
DB 이중화
DB 서버의 고가용성을 위해 적용
방법
동시에 같은 볼륨 사용하기
구성이 간단하지만 불륨의 문제가 생기면 대처하기 어려움
볼륨 성능에 부하가 발생할 수 있음
컨테이너마다 별도의 볼륨 사용하기
데이터 동기화 처리를 별도로 해야 함
동기화 방법 (DB 서버가 제공)
Primary-Standby Replication
하나의 Primary 서버에 여러 Standby 서버를 연결
Primary 서버는 읽기/쓰기, Standby 서버는 읽기만 가능
쓰기가 실행되면 데이터는 즉시 Standby 서버로 복제됨
Primary-Primary Replication
모든 DB 서버는 Primary 서버
모든 서버가 읽기/쓰기 가능
여러 서버에서 쓰기가 일어나므로, 동기화 구성 작업이 조금 더 복잡
컨테이너 애플리케이션 리소스 최적화
도커는 가상화 기술이므로 컨테이너마다 사용 가능 리소스를 제한 가능
적정 리소스량은 운영 경험과 테스트를 통해 결정
사용량 초과 시
CPU limit 초과 -> CPU 스로틀링 발생 -> 애플리케이션의 성능 저하 발생
CPU 스로틀링: 시스템이 애플리케이션의 CPU 사용을 제한
Memory limit 초과 -> OOM(Out of Memory) Killer 프로세스 실행 -> 컨테이너 강제 종료
자바 가상 머신 (JVM) 튜닝
JVM의 메모리 중 힙 메모리는 애플리케이션 사용량 증감에 가장 큰 영향을 받음
보통 전체 서버 메모리의 50~80%로 설정 (자바 실행시 설정)
e.g. java -jar -Xmx=4G app.jar (힙 메모리 최대값을 4G로 지정)
자바 힙 메모리 자동 설정
# JVM 튜닝을 위한 환경변수 추가
ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap"
컨테이너 메모리 변경에 맞게 애플리케이션 실행 시 자바 최대 힙 메모리를 자동 조정
자바 기능
자바 10버전 이상은 기본 활성화
애플리케이션 시작 시 -Xmx 옵션을 지정하면 자동조정은 없음
자바 10버전 미만일 경우 도커파일에 해당 옵션 지정
컨테이너 내 IDE 개발환경 구성하기
컨테이너 내부에 IDE 개발환경을 구성하는 것의 장점
로컬 PC에 라이브러리나 런타임 설치 없이 깔끔하게 유지 가능
개발자 한 명이 여러 프로젝트에 참여할 때, 개발 PC를 도커만 설치된 상태로 깔끔하게 관리
개발자들의 개발 환경을 일관적으로 유지하고 표준화 가능
같은 프로젝트를 개발하는 팀원끼리 설정 차이로 발생하는 문제를 예방
VSCode
컨테이너 내부에서 VSCode를 실행해 사용 가능
개발용 컨테이너 내 소스코드는 볼륨을 사용해 Host OS의 실제 소스코드를 마운트
방법
익스텐션 설치하기
Docker (MicroSoft)
Dev Containers (MicroSoft)
.devcontainer 디렉터리 생성
devcontainer.json : VSCode가 새로운 개발환경을 만들 때 사용하는 파일
name : 개발 환경의 이름
dockerFile : 개발 환경 구성에 필요한 도커 파일 이름
forwardPorts : docker run 의 -p 옵션과 동일
customizations : 개발 환경 내 VSCode 실행 시 적용할 extension, 세팅 정보 등을 설정
postCreateCommand : 컨테이너 생성 후 실행할 커맨드 입력 (도커파일 CMD)
remoteUser : 컨테이너 안에서 사용할 기본 사용자 지정
Dockerfile : 개발을 수행할 컨테이너 정의
명령어 팔레트에서 Dev Containers: Open Folder in Container 실행
IntelliJ (유료 버전만 가능)
VSCode와 달리 로컬 PC에서 IntelliJ를 실행 (애플리케이션 실행 및 디버깅에만 컨테이너 활용)
소스코드와 도커파일을 사용해 자바 실행 이미지를 빌드하고 실행
JDK 버전 별 사용이 편리하기 때문에, 완전한 컨테이너 환경 내 개발이 아니어도 보완이 됨
로컬 PC가 완전히 클린하진 않지만, 개발자들의 개발 환경 일관성 유지가 가능
Run/Debug Configuration 을 생성해서 컨테이너 내 개발환경 구성
실행 버튼
자동으로 docker build로 이미지를 빌드하고 docker run으로 컨테이너를 실행
디버그 모드
JDK는 기본적으로 디버깅 기능을 제공하나
컨테이너에서 실행중인 애플리케이션을 디버깅하려면 원격 디버깅 기능 사용해야 함
방법
IntelliJ IDEA - Settings - Plugins -> 검색으로 Docker 확장 설치
상단 Edit configurations - Run/Debug Configurations 진입
실행 환경 추가
Add New Configurations -> Dockerfile 선택해 설정 생성
IntelliJ와 연동할 도커 데몬 선택 : Server 옆 ... -> Name 및 Docker for Mac 지정
Dockerfile : 이미지 빌드에 사용할 도커 파일 경로 지정 (기본값으로 두기)
Image tag : 빌드될 이미지의 태그 지정 (e.g. dev)
Container name : 빌드된 이미지를 사용해 실행할 컨테이너의 이름 지정
Add Run Options로 옵션 추가 가능
e.g. Port Binding = -p -> 8080:8080
e.g. Environment variables = -e -> DB_URL=postgres
e.g. Run Options -> --network leafy-network
Debug 환경 추가 (기본 5005번 포트로 자바가 원격 디버깅)
Add New Configurations -> Remote JVM Debug 선택해 설정 생성
Name 지정 (e.g. LocalDevContainerDebug)
Before Launch - + - Run Another Configuration - 앞서 만든 컨테이너 실행 환경 지정
앞서 만든 컨테이너 실행 환경에 Add Run Options 추가
포트 포워딩 5005:5005 추가
Command - -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar leafy.jar 추가 (자바 애플리케이션을 디버깅 용으로 시작하는 옵션)
DevOps & CI/CD & Github Actions
DevOps의 목표는 개발 환경과 운영 환경의 차이를 줄여 서비스의 퀄리티를 높이고자 함
컨테이너, CI/CD, 자동화, MSA, IaC 등이 DevOps가 지향하는 기술
CI/CD 파이프라인
소스코드에서 배포 환경 관리까지의 모든 프로세스를 자동화하는 것 (소스코드가 물처럼 흘러감)
파이프라인이 없을 경우의 단점
사람이 직접 빌드 및 배포 수행하여 휴먼 에러 및 표준화의 어려움이 발생
자동화 이전에는 각각의 단계를 개발자, 운영자, QA, 테스터가 따로 진행했음
배포 시간이 매우 길어지고 복잡
CI(Continuous Integration): 지속적 통합, 배포가능한 아티팩트(Jar/Image)를 빌드하는 단계
e.g. 컨테이너 환경이라면 이미지를 빌드하고 푸시하는 단계의 자동화
CD(Continuous Deployment) : 지속적 배포, 실제 환경에 아티팩트를 배포하는 단계
GitHub Actions
파이프라인을 구성하고 자동화할 수 있는 GitHub 제공 기술
빌드용 서버를 빌려주므로 별도의 서버 없이 쉽게 파이프라인을 실행할 수 있음
방법
GitHub에 소스코드를 푸시하면 GitHub Actions에서 CI/CD 자동 실행
.github/workflows의 yml 파일을 GitHub이 자동으로 인식해서 파이프라인 실행
용어
러너(Runner) : 파이프라인(워크플로우)이 실제로 실행되는 서버
워크플로우(Workflow)
서버에서 실행되는 파이프라인의 실제 작업들
워크플로우 = 파이프라인 = .github/workflows 내 파일 1개
관계
하나의 워크플로우는 여러 개의 작업(Jobs)으로 이루어짐
하나의 작업은 여러 개의 스탭(Steps=Action)으로 이루어짐
트리거를 통해 워크플로우 자동 실행 가능
트리거(Trigger) : 조건을 설정해 충족하면 워크플로우를 자동 실행
e.g. 소스코드 푸시, 특정 시간(매일 8시)…
기본 문법 (YAML 형식)
기본 템플릿
runs-on : Runner 지정
특별한 경우가 아니면 ubuntu-latest 지정
작업마다 러너를 다르게 지정 가능
트리거 문법
스텝 문법
러너에 소스코드를 다운하기 (소스코드가 필요한 작업의 경우 사용)
도커 buildx 활성화 (도커 제공 스탭)
기본 러너에는 도커는 설치되어 있지만, buildx 기능은 비활성화되어 있음
buildx를 활성화하면 멀티플랫폼 빌드, 캐싱 등의 기능 제공
도커 로그인 정보 생성 스탭
러너에 도커 허브에 접속할 수 있는 로그인 정보 파일 생성
깃허브 시크릿에 키와 값 형태로 저장해 적용
Github의 프로필의 Settings - Developer Settings - Personal access tokens - Tokens (classic) - Generate new token (classic) - scope (repo, workflow 선택) - Generate token
Repo의 Settings - Secrets and variables - Actions - New Repository Secret에 키-밸류 쌍 지정
DockerHub의 Account Settings - Personal access tokens - New Access Token - permission 선택 (Read, Write, Delete 혹은 적합한 것) - Generate Token - 토큰을 복사해 도커 허브에 로그인하기 위한 토큰으로 사용
도커 빌드 푸시 액션스 (소스코드를 사용해 이미지를 빌드하고 레지스트리에 푸시)
이미지는 CPU 아키텍처가 다르면 실행 불가능
buildx를 통해 멀티플랫폼 빌드를 활성화하면, MacOS 이미지도 리눅스에서 사용 가능
Reference
개발자를 위한 쉬운 도커
-
도커(Docker)Dive - 멀티 컨테이너 관리
클라우드 네이티브 애플리케이션 (Cloud Native Application)
클라우드
다른 회사의 서버를 빌려서 운영
퍼블릭 클라우드: 누구나 사용할 수 있는 클라우드 서비스 (e.g. Azure, AWS, GCP)
프라이빗 클라우드: 특정 조직만 사용할 수 있는 클라우드 서비스 (e.g. 조직 내 계열사)
보안과 비용이 보다 효율적
특징
요청 즉시 서버를 생성 (Provisioning)
스토리지 저장소에 가상화 기술을 활용
사용자가 서버를 구매할 때마다 스펙에 맞는 VM을 만들어 제공
사용 시간만 비용 지불 (직접 서버를 운영음 비용이 매우 큼)
현대 애플리케이션의 다양한 문제를 해결
확장성 (Scalability) : 트래픽 증가에 유연하고 빠르게 대처
복원력 (Resilience) : 장애 발생 시 빠르게 복구
e.g. 서울 데이터 센터에 장애가 났을 때 부산 데이터 센터의 서버로 트래픽 전환 (Disaster Recovery, 복구에 사용하는 서버)
효율적인 운영 비용 처리 : 전문 아키텍트의 적절한 서버 구성과 끊임없는 최적화 필요
-> 클라우드는 시작점이고 애플리케이션이 클라우드에 적합하지 않으면 큰 의미 없을 수 있음
클라우드 네이티브 애플리케이션
클라우드 환경을 더 잘 활용할 수 있는 애플리케이션 구조
필요 사항
MSA: 트래픽 증가에 빠르게 대처하기 위해선 애플리케이션이 MSA 구조로 개발되어야 함
컨테이너: 컨테이너를 활용해 실행 환경에 종속되지 않는 동작을 보장해야 함
무상태 (Stateless): 상태를 가지지 않는 애플리케이션 서버는 어디에나 즉시 배포 가능
DevOps 및 CI/CD: 배포가 자동화되어야 하고 릴리즈가 빠르게 수행되어야 함
모놀리식과 MSA
잘 활용하면 클라우드 네이티브 애플리케이션으로서의 장점이 커서 MSA로 많이 전환하는 추세
모놀리식
모든 기능을 하나의 애플리케이션에 구성
수직 확장(Scale-Up) 주로 사용
장점
초반 개발 속도가 빠르다가 코드 베이스가 커질수록 느려짐
복잡성이 낮음
단점
트래픽 대처 능력 감소
하나의 애플리케이션 크기가 큼
애플리케이션 실행 시간이 오래 걸림
개발에 들어가는 빌드 시간 및 배포 시간도 오래 걸림
서버 확장 시 비효율적인 리소스 사용
실제 트래픽은 주문 기능에서만 나는데 상품관리, 회원관리도 함께 확장됨
MSA
도메인이나 기능별로 모듈을 분리해 서버 배포
수평 확장(Scale-Out) 주로 사용
장점
각각의 모듈의 크기가 감소해 서버 스케일 아웃 시간 빠름
스케일 아웃 시 효율적인 리소스 사용
트래픽이 늘어난 모듈만 서버 스케일 아웃 가능
모듈별로 완전히 독립됨
각각 다른 언어로 개발 가능
기능 장애 발생해도 다른 모듈에 영향 방지 가능
단점
초기 구성이 복잡하고 오래 걸림
복잡성이 높음
네트워크 기본
전세계가 물리적 케이블을 통해 전기 신호로 정보를 주고 받음 (먼 해외는 해저 케이블 사용)
IP
전 세계 인터넷상에서 유일한 주소 (동적 IP는 시간에 따라 변하지만 여전히 유일)
회선 당 하나 배정하므로 한 회선(공인 IP)을 사설 IP로 나눠 여러 기기가 동시에 IP 사용 가능
공인 IP: 집주소, 전세계에서 유일
사설 IP: 방번호, 소속된 네트워크 장비(공유기) 내에서 유일
사설 IP 주소 대역 (아래 범위 해당하는 주소는 모두 사설 IP)
10.0.0.0 — 10.255.255.255 (Class A)
172.16.0.0 - 172.31.255.255 (Class B)
192.168.0.0 — 192.168.255.255 (Class C)
공유기: 공인 IP를 사설 IP로 분리하는 장치
e.g. IP Time
WAN 포트: 공인 IP를 받는 인터넷 선을 꽂는 부분
LAN 포트: 각각의 기기를 연결해서 사설 IP를 분배하는 곳
기업 네트워크 (아래보다 더 복잡)
공인망: 공인 IP 끼리 사용하는 네트워크 통신망
사설망: 라우터를 사용해 만들어진 내부 네트워크 통신망 (사설 IP 사용, 실제 서버에게 할당)
라우터: 공유기와 비슷한 역할
네트워크 인터페이스
인터넷에 연결하기 위해 컴퓨터에 장착하는 부품 중 하나
네트워크 인터페이스마다 IP를 할당 (사설 IP)
하나의 기기에는 한 개 이상의 네트워크 인터페이스 장착 가능
e.g. 노트북 (유선 인터페이스, 무선 인터페이스)
포트
서버에 있는 프로세스들 중 어떤 프로세스에 데이터를 전달할지 지정
IP 뒤에 : 붙여 지정 (e.g. 192.168.0.5:8080)
물리적 존재 X, 서버 안에서 정의해 사용
네트워크를 사용하는 프로그램은 실행될 때 자신이 사용할 포트를 지정
Well-Known 포트: 사전에 약속되어 있는 포트
e.g. 웹서버 통신(80, 443), SSH(22), FTP(21)
네트워크 통신
아웃바운드 : 자신의 서버에서 출발하는 통신
인바운드 : 외부 서버에서 자신의 서버로 오는 통신
NAT (Network Address Translation) - 아웃바운드 통신에 사용
매핑 테이블 활용해 공인 IP와 사설 IP를 매핑해주는 기술
공인 IP의 랜덤한 포트를 여러 개 지정해두고 각각의 포트에 사설 IP 정보 매칭
사설망을 구성하는 라우터가 항상 가지는 기능 (자동 설정)
동적으로 정보를 관리하므로 외부에서 서버로 접근할 때는 혼란이 생김 (인바운드 통신)
NAT 테이블 예시
아웃바운드 통신 발생 시 해당 사설 IP 주소 및 포트와 공인 IP 주소 및 포트를 저장
공인포트번호는 랜덤 지정
외부 서버는 공인 IP 주소 124.111.46.91:10001을 출발지로 알고 응답을 보냄
라우터는 돌아온 정보를 NAT 테이블에서 192.168.0.4:80으로 응답을 보냄
포트 포워딩 (Port Forwarding) - 인바운드 통신에 사용
외부에서 사설망으로 접근할 때 사용하는 NAT 테이블 같은 매핑 정보 관리 기술
외부에서 공인 IP에 접근하면 포트 포워딩 룰에 맞는 사설 IP로 변환해 트래픽 전달
사용자가 직접 지정
포트포워딩 예시
외부 서버에서 공인 IP 주소 124.111.46.91:80으로 요청 보냄
포트 포워딩 룰에 따라 요청은 사설 IP 192.168.0.4:80으로 보내짐
DNS (Domain Name System)
도메인 주소와 공인 IP를 매핑해주는 기술 (도메인명 <-> IP 주소)
일반적인 엔터프라이즈 환경에서는 사내망에서 별도로 DNS 서버를 운영하기도 함
사내 서버들끼리 도메인으로 통신할 수 있도록 정보 제공
DNS 서버 주소도 내부 DNS 서버의 IP로 지정
DNS 예시
devwiki-docker.com으로 요청을 보냄
IP 주소가 8.8.8.8인 DNS 서버에 질문을 던져 도메인 주소에 해당하는 IP 주소 받음
요청은 124.111.46.91로 전달됨
도커 가상 네트워크
가상 네트워크: 한 대의 서버 내에서 논리적으로 여러 네트워크를 구성하는 기술
물리적 네트워크 망에는 네트워크 인터페이스로 공인 IP 혹은 사설 IP가 할당된 PC(서버)가 존재
해당 서버 내에 가상 네트워크 구성
SDN(Software Defined Network)라고도 함
도커는 가상 네트워크 기술을 활용해 컨테이너 네트워크를 구성
e.g. 외부와 컨테이너의 통신, 컨테이너와 컨테이너의 통신
가상 네트워크 생성 과정
도커 설치 및 실행 시 다음 2가지 생성
브릿지(docker0): 가상 공유기
가상의 IP 주소를 할당 받음 (일반적으로 172.17.0.1)
가상의 IP 주소: 서버 내 논리적으로 정의된 IP (실제 존재 X)
브릿지 네트워크: 가상 네트워크
컨테이너 실행 시
브릿지 네트워크의 IP 주소 범위 내에서 가상의 IP 주소 할당
e.g. 172.17.0.2, 172.17.0.3, 172.17.0.4, 172.17.0.5
마치 공유기를 통해 사설 IP 할당되는 것과 비슷
같은 브릿지에서 생성된 컨테이너는 브릿지를 통해 서로 통신 가능
도커는 여러개의 브릿지 네트워크 구성도 가능
네트워크 신호 전달 과정
서버(PC)는 물리 네트워크 인터페이스에 인터넷 선을 연결해 공인 IP 혹은 사설 IP를 할당받음
해당 서버 내 도커 설치 후 실행 시
도커는 호스트 OS에 가상 인터페이스 1개 생성 (docker0)
컨테이너 실행 시
호스트 OS에 각각 컨테이너에 해당하는 가상 인터페이스들 생성 (Veth고유번호)
전달: 물리 인터페이스 -> 호스트 OS -> 컨테이너 가상 인터페이스 -> 컨테이너
가상 인터페이스 간 통신은 iptables를 활용해 규칙을 정의하고 소프트웨어적으로 패킷 전달
iptables
Linux OS의 패킷 필터링 시스템
내부 네트워크 트래픽 제어 및 라우팅 규칙 정의
특정 IP 주소로 네트워크를 보냈을 때 어떤 인터페이스로 전달할지에 대한 규칙 설정
e.g. 규칙: 172.17.0.3으로 향하는 요청은 Veth2 인터페이스로 전달하자
물리 네트워크 였다면, 네트워크 장치들이 알아서 해줌
참고: 호스트 OS -> 물리적 인터페이스 1개 : 가상 인터페이스 여러개 = 하드웨어 : 소프트웨어
가상 네트워크와 외부 통신 (포트포워딩)
아웃바운드 통신은 가상 네트워크가 알아서 NAT 사용
인바운드 통신은 요청이 원하는 컨테이너의 포트로 전달되도록 직접 포트포워딩 옵션 지정
HOST OS의 포트는 아무거나 지정해도 상관 X
이미 등록된 포트는 중복 불가
의도적으로 포트포워딩을 하지 않으면, 컨테이너 간 통신만 허용
e.g. DB 서버는 포트포워딩 없이 컨테이너 간 통신만 허용하여 외부 통신을 막음
도커 DNS
직접 생성한 브릿지 내 컨테이너가 사용할 수 있는 기본 DNS 서버 제공 (기본 브릿지 제외)
컨테이너 이름이 도메인으로 자동 저장됨
e.g. containerA - 10.0.02, containerB - 10.0.03
컨테이너 간의 통신에 중요! -> 컨테이너 IP는 컨테이너 재시작 시 계속 바뀔 수 있어 불편
외부 DNS 서버와 연동되어 있어, 컨테이너 외부 도메인으로도 접근 가능 (e.g. 구글)
도커 네트워크 드라이버
브릿지 네트워크 (Bridge)
도커 브릿지를 활용해 컨테이너 간 통신 지원
NAT 및 포트포워딩 기술을 활용해 외부 통신 지원
호스트 네트워크 (Host)
호스트 네트워크를 공유해 모든 컨테이너가 호스트 머신과 동일한 IP 사용하도록 지원
호스트 네트워크와 포트 중복 불가능
오버레이 네트워크 (Overlay)
호스트 머신이 다수일 때 하나의 네트워크처럼 사용하도록 지원 (Kubernetes에서 사용)
Macvlan 네트워크
컨테이너에 MAC 주소를 할당해, 물리 네트워크 인터페이스에 직접 연결
스토리지와 볼륨
컨테이너의 중요한 속성: Stateless (무상태)
컨테이너는 상태가 없음
컨테이너 실행 후 모든 변경 사항은 컨테이너 레이어에만 존재
불변성: 모든 상태는 이미지에 기록되고 이미지는 한 번 지정된 후 변경되지 않음
컨테이너 상태 변경(e.g. 소프트웨어 버전 변경)이 필요하면 새 버전의 이미지 제작 후 배포
장점
여러 컨테이너를 다른 여러 환경에서 빠르게 배포 가능
트래픽이나 장애에도 컨테이너를 쉽게 생성해 빠르게 대처
제약
상태가 없으므로 데이터는 무조건 외부에 저장해야 함
데이터 영구 저장을 위해서는 DB 서버 사용이 필수
사용자 세션 정보나 캐시 같은 정보는 캐시 서버나 쿠키를 통해 관리
e.g. 사용자 로그인 정보, 장바구니 상품 리스트
동일한 요청은 항상 동일한 결과를 제공해야 함
같은 이미지로 생성한 모든 서버에서 같은 응답을 제공해야 함
컨테이너 실행 시점에 설정을 외부에서 주입할 수 있어야 함
환경 변수나 구성 파일을 통해 다양한 환경에서 컨테이너 이미지를 활용 가능
도커 볼륨 (Docker Volume)
데이터를 보관하기 위해 도커가 관리하는 외부 공유 저장소
호스트 OS의 서버 특정 공간에 저장 (e.g. /volumes/volume1)
볼륨 저장 경로에 사용자가 직접 접근하기는 어려움
경로는 리눅스에서는 관찰 가능, MacOS 등은 관찰 불가
도커가 가상머신 형태로 실행되어 경로를 자동 관리하고 가상머신 안에 저장하기 때문
컨테이너가 삭제되도 볼륨은 남아있음
볼륨 마운트
컨테이너들은 컨테이너의 특정 경로를 도커 볼륨에 마운트해서 사용
즉, 컨테이너의 특정 폴더는 공유용 폴더
e.g. PostgreSQL
PostgreSQL은 /var/lib/postgresql/data 경로에 실제 DB 데이터 저장
해당 경로를 도커 볼륨에 마운트
해당 경로에 저장하는 파일들은 컨테이너 레이어가 아니라 외부 볼륨에 저장
여러 컨테이너는 1개의 볼륨을 공유해 동일한 데이터를 제공할 수 있음
컨테이너가 삭제되거나 새 컨테이너가 생성되어도 데이터 영속성 보장
볼륨과 컨테이너의 관계
여러 컨테이너에 하나의 볼륨 마운트 가능
하나의 컨테이너에 여러 개의 볼륨 마운트 가능
바인드 마운트 (Bind Mount)
도커가 자동 관리에서 벗어나 Host OS에서 데이터를 직접 관찰 가능 (볼륨 X)
방법: -v 옵션에서 볼륨 이름 대신 사용자 지정 경로를 전달
디버깅에 유용
볼륨은 마운트한 컨테이너가 없을 때만 삭제 가능
컨테이너의 무상태와 서버 관리 방법론
Pet 방식 (전통적 서버 방법론)
서버 한 대 한 대를 소중히 직접 관리하는 방식
서버 에러 및 종료를 서비스 장애로 간주
서버가 상태를 가져서 교체가 어려움
e.g. Monolithic, OnPremise
Cattle 방식
컨테이너를 활용한 서버 방법론 (서버는 소모품)
서버 에러 및 종료가 충분히 일어난다고 가정
문제 서버 삭제 후 빠르게 새 서버를 생성해 대체하는 방식으로 해결
서버의 상태를 최대한 제거해 빠르게 교체 가능하도록 함
e.g. MSA, WEBAPP
마운트
컴퓨터의 특정 디렉토리를 외부 저장소와 연결하는 것을 말한다.
NFS(Network File System)는 PC의 특정 폴더 혹은 드라이브 단위를 NFS에 마운트 시킬 수 있고, 여러 컴퓨터가 접근할 수 있습니다.
도커 컴포즈 (Docker Compose)
여러 개의 도커 컨테이너를 편리하게 관리하는 도구
여러 개의 컨테이너를 하나의 파일에 정의해 복잡한 애플리케이션 구조를 관리
e.g. 서비스 간 의존성, 도커 네트워크
특징
YAML 문법으로 IaC 적용 (docker-compose.yml)
도커 데스크탑 설치 시 기본 제공
도커 컴포즈의 서비스 = 컨테이너
디폴트로 네트워크를 생성해 컴포즈 파일 내 컨테이너들을 포함시킴
e.g. --network leafy-network
장점
여러 개의 컨테이너를 한 번의 명령어로 실행 혹은 종료 가능 (docker compose up)
로컬 개발 환경에서 활용이 편리 (실제 운영과 비슷한 환경을 빠르게 구성 가능)
YAML 문법 (YAML Ain’t Markup Language, YAML은 마크업 언어가 아니다)
JSON 같은 데이터 표현 형식 중 하나다.
상대적으로 최근에 나온 포멧으로 가독성에 더 초점을 맞춰 간결하다.
JSON이 {} 와 "" 와 , 등이 필수인 반면, YAML은 띄어쓰기 기반으로 정보를 구분한다.
예시로, 띄어쓰기와 -로 리스트와 객체를 표현할 수 있다.
정의서 작성과 같은 사용자가 직접 파일을 작성하는 방식에 많이 쓰인다. (Docker, Kubernetes)
Reference
개발자를 위한 쉬운 도커
-
2024년 회고록
머리말: 2024년 1월 20일
1월 20일. JVM 생태계로 옮겨가자고 마음 먹었던 날이다.
2년차 개발자로서 그에 맞는 기본기를 갖추길 강하게 바라서 정했다.
더 알고 싶다. 프로페셔널하게 잘하고 싶다는 열망이 강했다. 선배 개발자들이 쌓아 놓은 길이 풍부하니, 차근차근 밟아 가다보면 많은 것이 채워지리라 생각했다.
이에 더해, 전체적인 삶도 더 건강하게 채워보기로 노력했다.
언제나처럼 한 해가 빠르게 지나갔다.
지난 시간의 결과들을 되돌아보고 올 해 목표를 새로 설정한다.
정형화된 것보단 내가 쓰기 편한 형태의 1년 회고를 남긴다.
생활 습관
2024년을 시작할 때 꼭 지켰으면 했던 몇 가지를 정했다. 어떤 것은 초과 달성했고 어떤 것은 조금 더 잘했으면 하는 아쉬움이 있다.
책 21권
24년 목표: 20권
24년 결과: 21권
25년 목표: 30권
1권 초과 달성으로 마무리했다. 아주 많다고 할 수는 없지만 달성한 보람이 크다.
접근성을 최대한 높여 한 번이라도 책을 더보게끔 유도했다. 독서용으로 구매한 아이패드 미니가 충분한 역할을 했다. 휴대성 덕분에 버스나 지하철안에서 한 번이라도 더 보게 되고, 자기전에도 한 번 더 손을 뻗고 읽게 된다.
먼지 타면 손이 잘안가서 최대한 전자책을 지향했는데 이것도 나한텐 효과적이었던 것 같다.
그리고 노션으로 서재를 만들어 정리하는 습관을 들였다. 시각적으로 내 서재를 볼 수 있으니 달성감이 생기고 짧게라도 정리하니 기억에 더 남는다.
내 서재
상반기에 비해 하반기에 독서 속도가 많이 느려졌던게 아쉬웠다. 특히, 기술 서적 읽을 때는 아무래도 시간이 걸리면서 상대적으로 텐션이 떨어지는데, 25년에는 더 전략적으로 시간을 분배해봐야겠다.
C-Level 분들의 연평균 독서량이 30권 이상이라는 기사가 있었다. 1달에 2권만 읽어도 24권인데 대단하다고 생각이 든다.
2025년에는 30권을 목표로 한다.
물론 단순 물량보다 실질적인 것이 중요하다.
잘 모르는 영역이 많은데, 25년에는 생활 법률이나 우주 카테고리에 대해서도 좀 더 관심을 가지려 한다. 특히, 투자 쪽은 작년보다 조금 더 깊게 공부할 계획이다.
주 3~4회 운동
24년 목표: 체지방률 15%
24년 결과: 체지방률 18.8%
25년 목표: 체지방률 15%
운동을 시작한지 1년 6개월이 지났다.
체지방률 26%에서 시작했고 근육량 2kg 증가, 체지방 5.5kg 감소시켜 체지방률 18%대에 진입했다.
주 3~4회씩 꾸준히 운동했던 점은 뿌듯한데, 목표에 3%가량 못미친 결과는 아쉬움이 남는다.
좋았던 기억은 애정하는 몇 가지 운동들의 최대 기록들이다.
턱걸이 횟수는 10개를 넘어갔다. 최대 16개를 했는데, 한 번도 못했던 옛날을 생각하면 정말 큰 발전이다.
벤치프레스 70kg을 찍은 것도 정말 기뻤다. 1RM이어서 아슬아슬했지만, 큰 성취감을 느꼈다.
기록을 위해 몇가지 최대 기록을 남겨둔다.
운동
최대 무게(횟수)
벤치프레스
70kg
풀업
16회
레그프레스
160kg
스쿼트
70kg
밀리터리 숄더프레스
40kg
어려웠던 점은 정체기다.
처음 3개월 PT로 배운 후 혼자 운동해나갔다.
1년 정도 철저한 식단과 함께 주 3~5회 운동을 했는데, 벌크업과 살찜 사이(?)의 균형을 찾는게 어려웠다.
지금은 무리한 섭취보다 체지방 조절을 가장 우선하고 있고, 체지방률이 감소하는 걸 보며 보다 건강한 느낌을 받고 있다.
사이사이 어깨 부상도 힘들었다. 잘못된 숄더프레스 및 벤치프레스 자세로 오른쪽 어깨가 반복적으로 문제가 생겼다. 처음엔 원인도 몰라서 관련될 법한 운동 영상, 의학 영상을 모조리 찾아본 기억이 난다. 시행착오 끝에 올바른 운동 자세를 찾으니 부상이 더이상 없더라. 신기한 경험이었는데, 운동은 자세가 정말 중요함을 체감했다.
어느덧 2년차도 넘어가니 여러 생각이 든다. 무리하지 않는 건강하고 꾸준한 운동이 제일인 것 같다.
25년 목표는 한번 더 체지방률 15% 달성이다.
다시 온 정체기를 뚫는게 올 해 목표가 될 것이다.
새벽 기상과 공부 패턴
새벽 기상이 일상에 많이 스며 들었다. 최소 7시간 수면 확보를 기준으로 일찍자고 일어났다.
요즘은 5시 50분으로 정착했다. 4시 50분 / 5시 50분 / 6시 30분 등 몇 가지 기상 패턴들이 다양하게 있었는데, 결과적으로 일찍 일어났을 때 조용한 환경으로 인해 집중도와 작업 진척도가 더 늘어나는 효과가 있었다.
모든 날을 완벽히 보내진 못했다. 4달은 새벽기상, 2달은 보다 늦은 기상, 3달은 다시 새벽기상 식의 반복이 있었다. 이런 부분은 크게 스트레스 받지 않으려 한다. 몸이 피곤하다는 신호가 있을 때는 충분히 자는게 건강에 이로운 것 같다.
올 해 기상 패턴도 5시 50분으로 늦춤 없이 그대로 유지해보려 한다.
추가로 습관 추적을 위해 하루 공부량을 측정하는데, 나무 심기가 재미를 준다.
개인적으로 Forest 앱을 좋아하는데, 매일 집중한 시간만큼 자신이 좋아하는 나무를 심을 수 있다.
나도 몰랐던 나무 취향(?)도 알게 됐다.
습관 추적은 많은 자기계발서에서 추천하는 방법이다. 습관 추적을 지원하는 다양한 앱 중 성숙한 서비스를 제공해서 추천한다.
포레스트 (Forest)
개발 공부
사실 한 해 동안 공부했던 모든 것들이 너무 유익했다. FastAPI 생태계에 있을 때는 어려웠던 레퍼런스 천국을 자바 생태계에서 경험했다.
선별한 강의와 책을 보면서 예제 코드를 백문이 불여일타하고 이론은 옵시디언을 활용해 학습기록용 블로그에 정리하고 있다.
회독법으로 접근하고 있다. 결국 4~5회독은 해야 장기 기억으로 완전히 남을 것이다.
지금까지 봤던 강의와 책은 최소 2회독한 상태이고, 새로운 것들을 계속 공부하면서 회독도 지속적으로 병행할 계획이다.
한 해 공부했던 책, 강의, 자격증 기록을 남겨본다.
기술 서적
24년에 읽은 기술 서적들이다. 사실 읽고 싶은 책이 더 많았는데 시간이 참 빠르게 지나간다.
기술 서적은 확실히 읽는 속도가 오래 걸린다. 두께가 있는 책들은 3~4주는 잡아야 2회독하는 패턴을 겪었다.
물론 충분히 필요한 절대적 시간량들이라 생각한다.
다만, 오래걸려서 텐션이 떨어지는 구간들을 좀 더 리듬감 있게 가져가도록 신경쓰려고 한다.
올 해는 아래 책들은 반드시 읽기로 계획했고 다른 책들은 상황에 맞게 필요를 조정하려 한다.
“Real MySQL” / “개발자를 위한 레디스” / “아파치 카프카 애플리케이션 프로그래밍 with 자바”
강의
영한님 강의는 최대한 다 듣고 싶었는데 2개 강의가 아직 남았다 (실전 자바 고급 2편, 스프링 부트 핵심 원리)
솔직히 정말 좋았다. 실무를 한 번 겪고 왔기 때문에 그동안 풀리지 않았던 고민들과 가려웠던 부분들이 많이 해소됐다. 교육 비용은 아끼지 말자.
해야할 것들이 많으니 우선순위를 잘 지정해서 남은 강의도 올 해 적절한 시점에 마무리해야겠다.
돌아보면 한 해 동안 인프런 이용을 참 많이했다.
큰돌님 CS는 분량이 정말 어마어마했는데, 그만큼 CS 대비를 풍부하게 할 수 있어 좋았다. 아직 내재화해야할게 많아서 핵심을 다시 한 번 추려서 회독해야겠다.
동시성 강의들도 좋았다. 멀티스레드 디자인패턴이나 레디스 분산 락 등 이론과 더불어 다양한 동시성 제어 전략을 알 수 있어 폭을 넓힐 수 있었다.
DB 설계 강의도 테이블 설계 전략을 머릿속에 일관성 있게 정립할 수 있어 도움이 많이 됐다.
자격증
24년에는 SQLD와 정보처리기사 2개를 합격했다.
시간이 길어지는만큼 기본적인 것들은 이럴 때 최소한의 시간으로 그냥 가져가자고 목표했다.
정보처리기사는 실기 90점으로 나름 고득점 합격했던게 소소한 즐거움이었다. 점수는 의미가 없지만 잠깐의 기쁨은 동기부여에 도움이 된다.
올 해는 AWS Associate 솔루션 아키텍트를 치를 계획이다. 하다보면 또 보이는 것이 있을거라 생각해 자격증 관련해서도 유도리 있게 한 해 목표를 수정해야겠다.
맺음말
생산성에 대한 생각을 많이 한다. 방대한 세상을 어떻게 체계적으로 정리하며 살아갈까에 대한 고민이다. 한 해 동안 개발에 관해서도 삶에 관해서도 건강해지기 위해 노력했다. 그리고 향상된 부분을 가시화하기 위해 신경썼다.
지난 1년을 거치며 보다 건강한 상태가 됐다는 점에 칭찬한다. 새로운 동기부여가 되는 지점이다.
2024년은 혼자의 시간이지만 프로페셔널함을 생각하며 보냈다. 엔지니어로서는 직업 윤리로서 기술적 탁월함을 추구했고 한 개인으로서는 삶의 전반적인 토대를 다시 다졌다.
2025년 회고 때는 과정을 발판 삼아 가치 있는 결과물을 남기고 기록하길 기도한다.
-
도커(Docker)Dive - 기본 개념
서버 운영
서버
하드웨어에서 실행 중인 소프트웨어 (문맥에 따라 하드웨어 지칭할 수도 있음)
어떤 소프트웨어가 실행 중인지에 따라 다양한 서버로 분류
e.g. 파일 서버 (파일 업로드/다운로드), DB 서버, 웹서버, 웹애플리케이션서버
엔터프라이즈 환경에서는 아주 많은 양의 서버를 운영해야 함
서버 운영 방법
베어메탈(Baremetal)- 비효율적
서버를 하나 구입하고 OS 설치 후 여러 개의 소프트웨어 실행
단점
하나의 소프트웨어에 문제가 생기면 다른 소프트웨어에게 영향을 미침 (에러, 사용량 급증)
하이퍼바이저(Hypervisor) - 전통적 가상화 기술
컨테이너(Container) - 최신 가상화 기술
큰 서버를 효율적으로 나눠서 사용하기 위해 가상화 기술이 필요
가상화 기술
물리적 컴퓨팅 환경 내부에서 논리적 컴퓨팅 환경을 만들 수 있는 기술
실제로 존재하는 컴퓨터가 아니지만, 마치 존재하는 것처럼 만듦
e.g. 하나의 OS안에서 4개의 추가 OS 만듦
8 Core/64 GB RAM -> OS(1 Core/8GB RAM) + 프로그램(1 Core/8GB RAM) X 4
장점
마치 여러 대의 컴퓨터를 사용하는 것처럼, 안전하게 소프트웨어 운영 가능
가상 컴퓨터에는 사용자가 직접 리소스를 분배할 수 있음 (리소스 최대값 지정)
OS가 많아져 총 리소스 사용량은 증가하겠지만, 논리적으로 격리되어 한 프로그램의 문제가 다른 프로그램에 영향을 미치지 않음
물리적 컴퓨터 한 대만 사용할 수 있어 경제적
기업 입장에서는 낮은 사양 컴퓨터 여러대보다 높은 사양의 컴퓨터 한 대가 효율적
하이퍼바이저 가상화 (전통적 가상화 기술)
가상 환경 운영 프로그램을 설치해 관리하는 방식
e.g. VMWare, VirtualBox, Red Hat의 하이퍼바이저 제품
과정
호스트 OS(물리, 기본 OS)에 하이퍼바이저를 설치
격리된 환경으로 게스트 OS(논리, 가상머신) 실행하고 프로세스 운영
게스트 OS 커널의 시스템 콜을 호스트 OS 커널에게 전달할 때 중간에서 번역
시스템 콜: 커널에 하드웨어 자원을 요청하기 위한 표준
서버가 한 대라서 게스트 OS가 물리적 자원을 쓰려면 호스트 OS를 거쳐야만 함
각 OS 커널의 언어가 다르므로 번역 필요
핵심 특징
각각의 게스트 OS가 독립적인 커널을 가질 수 있음
장점
커널을 독립적으로 가지고 있어 보안면에서는 더 나을 수도 있음
단점: 무겁고 느림
하나의 게스트 OS가 차지하는 오버헤드가 큼
독립적인 커널로 인해 부팅 시간이 매우 느림
컨테이너 가상화
커널의 자체 기능(LXC)을 활용한 가상화 방식
e.g. Docker
LXC(Linux Containers)
리눅스 커널이 제공하는 자체 격리 기술
커널 자체 기능만 사용해 격리된 공간(컨테이너) 생성 가능
네임스페이스: 리소스를 나누는 기준 역할
e.g. 프로세스, HDD, Network, 사용자, 호스트네임…
Cgroups: 리소스의 사용량을 배분하는 기술
e.g. 프로세스가 사용하는 메모리, CPU, HDD, Network, BandWidth
컨테이너
LXC 기술을 사용해 만들어진 격리된 공간
컨테이너를 생성하면 완전히 격리된 CPU, Disk, Network, Memory 공간을 차지
내부에서 프로세스를 띄우면 완전히 격리된 공간에서 띄우는 것
여러 개의 컨테이너를 실행시키면 각각의 컨테이너는 격리된 공간에서 안전하게 운영됨
핵심 특징
모든 컨테이너는 HostOS의 커널을 공유해 사용
장점: 가볍고 빠르다!!!
오버헤드가 적어 빠름
하이퍼바이저와 달리 번역을 거치는 중간 단계가 없어 빠름
부팅이 매우 빠름
자체적인 커널 없이 호스트 OS의 커널을 공유하므로 커널 실행 시간 자체가 없음
-> 모던 애플리케이션 요구사항에 적합
빠르게 변화하는 사용자 니즈에 맞춰 변경 사항을 빠르게 적용 가능
e.g. 가벼운 웹서버 올리기
하이퍼바이저: 60초
컨테이너: 3초
단점: 호스트 OS의 커널을 공유하므로, 호스트 OS와 다른 종류의 OS는 실행할 수 없음
유의: 어떤 컨테이너 플랫폼 사용할지 어떤 컨테이너 런타임을 사용할지 선택 가능
도커 (오픈소스, 2013~)
커널의 컨테이너 가상화 기술을 쉽게 사용하기 위한 소프트웨어 (컨테이너 플랫폼)
하이퍼바이저와 달리 실제 격리 수행 주체는 커널 자체
목적: 컨테이너 내에서 소프트웨어(서버)를 빠르고 가볍게 운영하기 위해 사용
가장 점유율이 높은 컨테이너 플랫폼
컨테이너 플랫폼 예시 - Docker, Podman, Containerd…
컨테이너 플랫폼 구조
컨테이너 엔진
사용자의 요청을 받아 컨테이너를 관리해주는 역할
도커 아키텍처 (클라이언트-서버 모델)
Docker CLI - 클라이언트
사용자가 입력한 명령어를 서버 API 양식에 맞게 변환해 대신 전달
덕분에 사용자는 도커 데몬의 API와 쉽게 통신 가능
Docker Daemon (=dockerd) - 서버
호스트 OS에서 지속적으로 실행되면서 클라이언트 요청에 따라 컨테이너 관리
클라이언트를 위한 API 제공
컨테이너 런타임을 통해서 컨테이너를 조작하고 결과를 CLI에게 전달
컨테이너 런타임
직접 커널과 통신하면서 실제로 격리된 공간을 만드는 역할
인터페이스: CRI(Container Runtime Interface) - OCI가 규정한 표준
구현: RUNC (도커 지원 기본 컨테이너 런타임)
이미지와 컨테이너
서버에서 소프트웨어 실행을위해 필요한 것들
하드웨어
OS
프로그램 실행 위한 구성 요소 (패키지, 라이브러리, 런타임 언어)
소프트웨어 (실행 시킬 프로그램)
이미지 = 실제 압축 파일 + 메타 데이터
컨테이너 실행 시 실제 압축 파일과 메타 데이터가 격리된 공간에 복사되어 프로세스로 실행
이미지 (Image)
특정 시점의 파일시스템(디렉터리)을 저장한 압축 파일
이미지 = OS + 구성 요소 + 소프트웨어 => 실행 준비가 완료된 상태 자체를 압축해 공유
Windows 백업 기능, 가상 머신의 스냅샷과 비슷
백업이나 스냅샷보다 압축 사이즈가 매우 작아 인터넷을 통한 저장과 공유가 수월함
이미지는 다른 사람이 만든 것을 사용하거나 직접 만들 수 있음
이미지 : 컨테이너 = 프로그램 : 프로세스 (1개의 이미지로 여러 컨테이너 실행 가능)
이미지는 파일 시스템 (압축 파일 형태로 호스트 머신 특정 경로에 위치)
컨테이너는 실행 상태의 이미지
이미지 메타데이터 (Metadata)
이미지에 대한 정보를 기술하는 데이터
이미지 이름, 사이즈
Env: 소프트웨어가 실행 시 참조할 환경설정 정보 (키-값 쌍)
소프트웨어 버전, 실행을 위해 필요한 파일 경로 등이 있음 (바뀌면 동작도 달라짐)
e.g. VERSION=1.23.2,PATH=/usr/..
Cmd: 컨테이너 실행 시 프로세스 실행 명령어 지정 (리눅스 명령어)
e.g. nginx -g daemon off;
컨테이너 실행 시 다른 값으로 덮어쓰기도 가능 (e.g. CMD 명령어 변경 등)
같은 이미지도 전혀 다른 역할을 수행하는 컨테이너로 만들 수 있음
보통 이미지를 디버깅할 때 주로 사용
컨테이너 라이프사이클
생성 단계 (Created) - docker create
컨테이너를 실행하기 위한 격리된 공간이 만들어지는 상태
네트워크, 스토리지, 환경 변수 등 모든 리소스를 격리
실행 단계 (Running) - docker start
컨테이너의 메타 데이터 CMD 값을 사용해 컨테이너를 실행
실제 프로세스가 실행되어 CPU와 메모리 사용
일시정지 단계 (Paused) - docker pause, docker unpause
컨테이너에서 실행 중인 모든 프로세스가 일시 중지된 상태
현재 상태를 모두 메모리에 저장 (CPU X, 메모리 O)
저장된 상태에서부터 재시작
정지 단계 (Stopped = Exited) - docker stop, docker start
컨테이너에서 실행 중인 프로세스를 완전히 중단
CPU와 메모리 사용 모두 중단 (재시작시 프로세스를 처음부터 다시 실행)
삭제 단계 (Deleted) - docker rm, docker rm -f
컨테이너가 삭제된 상태 (격리된 공간 삭제)
참고
컨테이너의 상태는 대부분 컨테이너 내에서 실행되는 프로세스 상태와 일치
프로세스를 잘 설계하고 다루는 것 => 컨테이너를 잘 사용하는 것
docker run = docker create + docker start
docker restart: 프로세스를 재시작
실행 중 프로세스에 종료나 재시작 신호를 보내면 10초 뒤 반응
이미지 레지스트리
도커 이미지를 저장하기 위한 저장소
e.g. Docker Hub (대표적)
개인 및 팀이 필요한 이미지를 서로 공유하고 다운로드 (GitHub과 유사)
GitHub이 소스 코드만 보관 <-> Docker Hub는 이미지 보관 (소스 코드 + 실행 환경)
이미지명만 서로 알면 실행 환경이 일치하는 애플리케이션 공유 가능
새 서버 구성 시간 및 서버 운영 비용 크게 감소
공통 제공 기능
이미지 공유, 이미지 검색, 이미지 버전 관리, 보안, 파이프라인 (DevOps 배포)
이미지 저장 공간 종류
호스트 머신의 로컬 스토리지 (특정 디렉터리)
온라인 저장소
퍼블릭 레지스트리 (e.g. Docker Hub)
프라이빗 레지스트리
보안 상 사내망, 내부망에서만 사용 가능한 레지스트리
방법
설치형 레지스트리
로컬 서버에 직접 설치
e.g. Harbor, Docker 프라이빗 레지스트리
퍼블릭 클라우드 서비스
시간 당 사용 요금 지불
e.g. Amazon ECR, Azure Container Registry (ACR)
=> docker run 실행 시
이미지가 로컬 스토리지에 있으면 바로 실행
없으면 온라인 레지스트리에서 로컬 스토리지로 이미지를 다운 후 실행
이미지 네이밍 규칙
이미지 네이밍: 레지스트리주소/프로젝트명/이미지명:이미지태그
레지스트리주소 (기본값: 도커에서는 Docker Hub 주소 docker.io)
어떤 레지스트리에서 이미지를 다운로드/업로드할 지 지정
프로젝트명 (기본값: library)
이미지를 보관하는 폴더 같은 개념 (Docker Hub에서는 사용자의 계정명)
library: 도커사가 직접 검증한 오피셜 이미지를 관리하는 프로젝트
이미지명: 다운로드 받을 이미지의 이름
이미지태그 (기본값: 최신 버전을 의미하는 latest)
이미지의 버전 (숫자, 영문 모두 사용 가능)
stable: 안정적 버전
alpine, perl…: 베이스 이미지로 사용했던 OS 버전
slim: 프로그램 실행에 정말 필요한 것들만 남겨놓음
이미지 전송 시간은 크게 단축하나 디버깅이나 사용이 불편할 수 있음
e.g.
devwiki.com/myProject/myNginx:2.1.0-alpine
nginx -> docker.io/library/nginx:latest (오피셜 이미지)
참고: 이미지 : 이미지 명 = 실제파일 : 참조 링크
이미지 빌드
이미지 레이어
이미지는 레이어드 파일 시스템으로 구성됨
레이어가 모여 하나의 이미지 구성
e.g. 이미지 다운 시, pull이 여러차례 걸쳐 일어남
한 줄이 하나의 레이어
레이어(Layer)
이전 레이어에서 변경된 내용을 저장 (소스코드 커밋, 푸시와 유사)
특징
레이어는 순차적으로 쌓임
여러 이미지 간 공유 가능 (재사용)
Copy-on-Write 전략 사용
다음 레이어에서 이전 레이어의 특정 파일을 수정할 때, 해당 파일의 복사본을 만들어 변경 사항을 적용
e.g. 컨테이너 레이어는 파일수정 시 이전 레이어의 파일을 복사해와 수정
원래 레이어는 수정되지 않고 그대로 유지됨
불변 레이어 (Immutable Layer): 레이어는 한 번 생성되면 변경되지 않음
이미지의 일관성을 유지
동일한 이미지를 사용하는 컨테이너는 동일한 파일 시스템 상태 사용을 보장
캐싱
레이어를 캐시해두고 이미 빌드된 레이어를 재사용할 수 있음
이미지 빌드 시간이 크게 향상 (같은 레이어 사용하는 여러 이미지에서 효율적)
장점
중복 데이터를 최소화해 효율적인 저장소 사용 가능
재사용에 유리한 구조 (각 레이어가 서로 영향 X)
=> 이미지 저장 및 전송 시 스토리지와 네트워크 사용량 절약
빌드 속도 상승
e.g.
레이어 1: OS 파일 시스템
레이어 2: Nginx 설치 파일 (추가 파일만 저장)
레이어 3: Nginx 설정 파일 (추가 파일만 저장)
레이어 4: index.html 파일 수정
레이어 구분
이미지 레이어 : 컨테이너 레이어 = 건축 도면 : 실제 건물
이미지 레이어
읽기 전용 레이어
컨테이너 실행을 위한 세이브 포인트 역할
각각의 레이어는 고유한 해시값을 가짐
컨테이너 레이어
모든 컨테이너가 가지는 자신만의 읽기/쓰기 레이어
컨테이너 실행 시, 이미지 레이어 위에 새로 추가
컨테이너 실행 후 프로세스가 변경하는 내용을 기록
장점: 같은 이미지로 여러 컨테이너를 만들어도 하나의 이미지 레이어를 공유
=> 컨테이너 생성 속도 향상 및 공간 절약
이미지를 만드는 방법
이미지 커밋
실행 중인 컨테이너의 상태를 그대로 이미지로 저장
새로운 이미지 = 기존 이미지 레이어 + 컨테이너 레이어
단점
휴먼 에러 가능성이 높음
이미지를 만들 때마다 컨테이너를 실행해 직접 커밋 명령을 수행해야 함
레이어 쌓을 때마다 컨테이너 실행 및 커밋 반복이 번거로움
이미지 빌드 (주로 사용)
IaC 방식을 활용해 이미지를 저장 (Dockerfile)
원하는 이미지 상태를 소스 코드로 작성하면 컨테이너 생성 및 커밋 작업을 도커가 대신 수행
Dockerfile 지시어마다 레이어를 쌓는지 여부가 다름
레이어를 쌓는 지시어 하나 당 레이어 1개 추가
예를 들어, CMD는 레이어를 쌓지 않음
과정 (docker build) -> 커밋 과정을 자동 반복
임시 컨테이너 생성
변경 사항 적용 후 커밋 (새로운 레이어 생성)
임시 컨테이너 삭제
빌드 컨텍스트 (Build Context)
도커 데몬이 이미지를 빌드할 때 전달되는 폴더
도커 데몬은 빌드 컨텍스트에 있는 파일만 카피 명령으로 복사할 수 있음
폴더 내에 도커 파일과 빌드에 사용되는 파일들이 담겨야 함
빌드 컨텍스트가 너무 크면 전송 시간이 길어지거나 문제 발생
C 드라이브 전체 사용 등은 절대 안되고, 따로 폴더로 관리할 것!
.dockerignore : 빌드 컨텍스트로 전달될 파일 관리
멀티 스테이지 빌드 (Multi-Stage Build)
빌드 스테이지와 실행 스테이지 2개로 나누어 빌드하는 방식
장점: 애플리케이션 실행용 이미지의 크기가 크게 감소
문제
빌드 과정에서 사용하는 파일들은 용량을 많이 차지 (e.g. 메이븐 빌드 도구)
실행용 이미지의 사이즈가 커지면 이미지 전송 및 다운로드 시간이 더 걸림
해결책: 빌드에 사용하는 이미지와 실행에 사용하는 이미지 나누기
e.g.
빌드 이미지: 메이븐 도구, 소스코드
실행 이미지: 자바 런타임 및 빌드된 jar 파일
참고: 두 개의 FROM -> 도커가 두 개의 컨테이너를 동시 실행 (메이븐 컨테이너, JDK 컨테이너)
IaC(Infrastructure as Code)
인프라를 코드를 통해서 관리하는 것을 말한다.
사람이 화면이나 CLI를 통해 관리하는 기존 방식은 인수인계도 어렵고 휴먼 에러를 일으킬 확률이 높다.
반면에, IaC는 사람이 코드로 인프라 상세 작업을 기재한다. 그러면 프로그램이 코드를 읽어 대신 인프라 관리를 수행하므로, 더 빠르고 안전하다.
또한, 이러한 코드 명세서를 GitHub에 올리면 인프라 상태도 소스코드처럼 버전 관리를 할 수 있다.
애플리케이션 빌드
애플리케이션 빌드는 필요한 라이브러리들을 설치하고 소스코드를 실행 가능한 프로그램으로 만드는 것을 말한다. (소스코드 -> 애플리케이션)
빌드의 결과물은 애플리케이션 프로그램(Program) 혹은 아티팩트(Artifact)라고 부른다.
예를 들어, 자바로 개발한 소프트웨어는 소스 코드를 실행 가능한 아티팩트로 빌드할 수 있다. (소스코드 + OS, Java Runtime, 빌드 도구(mvn), 라이브러리 -> jar 혹은 war 파일)
개발한 소스 코드를 이미지로 빌드하는 과정에는 일반적으로 이러한 애플리케이션 빌드 과정을 직접 포함시켜야 한다.
Reference
개발자를 위한 쉬운 도커
-
DB 설계 규칙 정리
DB 네이밍 규칙
테이블명, 컬럼명은 소문자로 작성하고 snake_case를 사용한다.
테이블명은 복수형을 사용하자. (선택사항)
여러 개 데이터를 가지고 있음을 표현
회사마다 규칙이 갈리므로, 복수형이든 단수형이든 일관성 있게만 사용하면 됨
축약어를 사용하지 않는다.
SQL문은 예약어만 대문자로 작성하자. (SELECT, FROM, WHERE…)
DB 설계 핵심 원칙
핵심 원칙: 중복 없애기 (정규화)
데이터 간 모순(이상 현상)의 근본적 원인은 데이터 중복이고, 정규화는 데이터 중복을 제거
e.g. 두 테이블에 작성자 컬럼이 있는데, 모두 수정하다가 한 데이터를 빼먹으면 갱신 이상 발생
DB 설계 과정
저장해야 하는 데이터 파악하기
대략적인 UI 디자인 기획을 보고 필요한 데이터 파악
가끔 글로만 정리된 요구사항이 있을 수 있지만, 놓치는 부분이 생길 수 있어 좋지 않은 방식
기준: 나중에 조회해서 쓸 일이 있겠다 싶은 데이터를 전부 저장
그룹핑해서 분류하기
관련 있는 것들끼리 묶어 상위 개념을 찾음 (= 엔터티 = 테이블)
따로 법칙이 있다기보다 인간이 모두 가지고 있는 보편적인 분류 능력을 따르자
e.g.
아이디, 패스워드, 이름, 이메일 → 사용자 (엔터티)
영화 이름, 영화에 출연한 배우, 영화 상영 시간 → 영화 (엔터티)
게시글 제목, 게시글 내용, 게시글 생성 시간 → 게시글 (엔터티)
6가지 규칙을 적용하며 테이블 분리해나가기
유의점
데이터 예시를 입력하며 파악하자
규칙 1 + 규칙 2의 결과는 규칙 3의 결과 동일 (무얼 선택해도 괜찮음!)
처음에 완벽한 설계가 안돼도 추후 데이터 중복을 발견해 수정 가능하니 불안해하지 말자
연관 관계 파악 관련 규칙
규칙 1: 한 칸에 한 가지 정보만 들어가야 한다 (제1정규형)
한 칸에 두 가지 정보가 있을 시, 테이블을 분리하고 FK를 적용
FK 적용이 규칙 1을 어길 경우, 반대쪽 테이블에도 FK를 적용해보자
‘한 가지 정보’의 기준은 절대적이지 않아서, 자신의 서비스에 맞게 판단해야 함
e.g. 손/흥민 or 손흥민, 01012341234 or 010/1234/1234
규칙 2: 어떤 테이블에 FK를 넣어도 ‘규칙 1’을 못 지킬 때는 중간 테이블을 하나 더 만들자
양쪽 테이블의 FK를 중간 테이블로 옮기기
중간 테이블의 이름은 동사를활용하면 좋음 (직관성, 가독성 상승)
e.g.
students & courses
-> course_registrations {id(PK), student_id(FK), course_id(FK)}
movies & actors
-> casting {id(PK), movie_id(FK), actor_id(FK)}
규칙 3: 헷갈릴 땐 연관 관계를 파악해보자 (1:1, 1:N, N:M)
판단 방법
엔터티 간에 어울리는 동사를 찾기 (A가 B를 ___.)
e.g.
사용자가 이메일을 소유한다.
이메일은 사용자에 의해 소유되어진다.
적절한 단어 찾기 (하나의 or 여러개의)
e.g. ‘하나의’를 고정해서 따져보기
하나의 사용자는 ___ 이메일을 소유한다.
하나의 이메일은 ___ 사용자에 의해 소유되어진다.
관계 파악하기 (1:1, 1:N, N:M)
e.g. 사용자와 이메일은 1: N 관계
1:N 관계
N 쪽 테이블에 FK 들어가야 함
N:M 관계
중간 테이블이 있어야 하고, 중간 테이블에 두 테이블의 FK가 들어가야 함 (1:N으로 해소)
1:1 관계
아무 테이블에 FK를 넣어도 됨
합쳐도 되는지 고려해보자 (왠만하면 1:1 관계로 분리하지 않는 걸 추천)
통쨰로 조회하는 일이 많다면 합치는게 좋음
만약 각각 따로 조회할 일이 많다면 분리하는게 좋음
중복 없애기 관련 규칙
규칙 4: 데이터 중복이 발생하는 컬럼이 있는지 확인하자
임의의 데이터를 넣고 시뮬레이션해서 데이터 중복 발생 여부 파악
e.g. 게시물 테이블의 작성자 데이터가 중복됨
중복이 있다면 테이블 분리하고 FK 적용
수정 포인트가 하나가 되므로, 갱신 이상 여지가 사라짐
규칙 5: 가짜 중복과 진짜 중복 구별하기
실제 서비스에서 A 데이터 값을 수정하면, B 데이터 값도 같이 수정되어야 하는가?
e.g. id 1 게시물의 작성자를 수정하면, id 2 게시물의 작성자도 수정해야 하나?
같이 수정되어야 하면 진짜 중복, 아니라면 가짜 중복
진짜 중복인 경우 테이블 분리해야 함
규칙 6: 숨어있는 중복을 찾아라
숨어 있는 중복
겉으로는 중복이 아닌 것 같지만, 무언가 수정해야할 때 같이 수정해야 하는 경우
e.g. users & posts -> likes (중간 테이블)
좋아요를 제거할 때, 게시글의 좋아요 수도 같이 수정해야 함
주로 통계치(합계, 평균, 최대값 등)를 낼 때 발생
해결 방법: 테이블에서 통계치 칼럼을 없애야 함
e.g. 좋아요 수 없애기
좋아요 수를 알고 싶다면, 좋아요 테이블에서 필터링하여 개수 카운팅하자
DB 설계 반영 지침
최대한 정규화를 지켜서 DB 설계하자
정규화 적용하지 않은 케이스의 의견들
정규화를 지키면 테이블 개수가 많아져서 관리가 불편해요.
→ 데이터 중복으로 인해 발생하는 단점들이 훨씬 크고 관리하기가 더 어렵다.
JOIN을 많이 하면 할수록 성능이 안 좋아져서 정규화를 안 했어요.
→ 실제로 JOIN을 많이 했을 때 성능이 안 좋아지는 경우도 존재한다. 하지만 이렇게 얘기하는 사람치고 실제로 성능 측정해본 사람이 아무도 없다. (추측만 했을 뿐)
즉, 이상 현상 방지에 가장 초점을 두자
데이터 중복으로 인해 실수로 데이터가 잘못 관리되는 문제가 더 큼
성능 개선은 느려지기 전까지 최대한 안 건드리는 게 Best!
필요성을 느낀다면 반드시 ‘측정’을 기반으로 해야 함
역정규화는 정말 구조로 인한 성능 문제가 클 때만 사용하라
ERD 표기 지침
DB 설계 시 ERD 그리는 게 필수가 아니어서, 보고 해석하는 방법 정도만 알아도 충분
홀로할 때는 편하고 빠른 방법으로 진행 (엑셀, A4지…)
협업할 때는 툴 사용 (ERD Clould, dbdiagram…)
1:1 관계인지 1:N 관계인지만 파악할 수 있으면 됨 (그 외 표기는 잘 안쓰임)
데이터 타입 실전 활용 지침 (MySQL 기준)
주요 사용 타입
정수를 저장해야 하는 경우 : INT
10억이 넘어가는 정수를 저장해야 될 수도 있는 경우 : BIGINT
실수를 저장하고 싶은 경우 : DECIMAL
문자를 저장해야 하는 경우 : VARCHAR(글자수)
6만이 넘어가는 문자를 저장해야 하는 경우 : LONGTEXT
TimeZone을 고려하지 않고 날짜/시간 데이터만 저장하면 되는 경우 : DATETIME
ex) 국내 서비스
TimeZone을 고려하면서 날짜/시간 데이터를 저장해야 하는 경우 : TIMESTAMP
ex) 글로벌 서비스
True, False의 형태를 저장하고 싶은 경우 : TINYINT(1)
참고: 숫자는 숫자로 저장할 수도 있고 문자로 저장할 수도 있음
DB 관점에서는 “계산에서 쓰는 값인지 안 쓰는 값인지”를 기준으로 선택
휴대폰 번호는 문자로 저장 (숫자를 더해서 사용하지 않고 단순히 고유의 값으로 사용할 뿐)
주민등록번호도 마찬가지로 문자로 저장
현업에서 잘 사용하지 않는 데이터 타입 : CHAR, FLOAT, DOUBLE, TEXT 등
Reference
비전공자도 이해할 수 있는 DB 설계 입문/실전
-
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
-
선착순 이벤트 시스템 개발하기
선착순 이벤트 시스템 개발하기
발생할 수 있는 문제
쿠폰을 100개만 발급해야 하는데, 쿠폰이 100개보다 많이 발급되었다
트래픽이 급증해 이벤트 페이지 접속이 안된다
이벤트랑 전혀 상관없는 페이지들도 느려졌다
해결책
트래픽이 몰렸을 때 대처하는 방법 적용
Redis를 사용해 쿠폰 발급 개수를 보장하기
Kafka를 활용해 다른 페이지에 미치는 영향을 줄이기
Redis로 Race Condition 해결하기
기존 락 활용의 문제
선착순 쿠폰 발행은 쿠폰 개수에 대한 정합성을 요구함
락 활용은 요구사항의 임계영역이 길어서 성능 불이익 발생
발급된 쿠폰 개수를 가져오는 것부터 쿠폰 생성까지 락을 걸면 임계영역이 길어져서 성능 불이익
해결책: 레디스는 싱글 스레드로 동작해 Race Condition 해결
애플리케이션의 모든 스레드는 언제나 최신 값을 가지게 됨
레디스 incr 명령어 활용
key의 value를 1씩 증가시킴
성능이 매우 빠른 명령어
남은 문제점
쿠폰 발급 개수가 많아질수록 RDB에 부담을 주어 서비스 지연 및 오류 발생
짧은 시간 내 많은 요청 -> DB 서버 리소스 과부하
쿠폰 전용 DB가 아니라면 다른 서비스에도 영향
e.g. MySQL이 1분에 100개 Insert가 가능하다고 가정
10:00
쿠폰 생성 10000개 요청
10:01
주문 생성 요청
10:02
회원가입 요청
-> 쿠폰 생성으로 인해 100분 이후에 주문 및 회원가입 요청이 처리됨
-> 심지어 보통은 타임아웃이 있으므로, 쿠폰 생성 일부분과 주문 및 회원가입 처리 실패
Kafka로 처리량 조절하기
데이터 정합성은 Redis로 이미 확인했으므로, 쿠폰 생성만 처리
Kafka
분산 이벤트 스트리밍 플랫폼
이벤트 스트리밍: 소스에서 목적지까지 이벤트를 실시간으로 스트리밍하는 것
Producer(소스) - Topic(큐) - Consumer(목적지)
장점
API에서 직접 생성하는 것에 비해 처리량 조절이 가능 -> DB 부하 감소
큐를 사용하므로 이벤트가 하나가 끝난 후 다음 이벤트가 처리되어 DB에 한 번에 쏠리지 않음
단점
이벤트 생산과 이벤트 처리는 약간의 텀이 발생
Producer의 이벤트 생산은 매우 빠르지만, Consumer는 이벤트를 처리하느라 시간차 발생
부록: 쿠폰 1인당 1개로 제한하기
DB 레벨 제한: Unique key 사용하기
userId, couponType에 유니크 제약 조건 걸기
문제점: 보통 서비스는 한 유저가 같은 타입의 쿠폰을 여러개 가질 수 있으므로, 실용적이지 않음
락 범위 넓혀서 쿠폰 발급 여부 조회해 판단하는 로직 추가하기
쿠폰 발급 여부 판단 로직
쿠폰 발급 여부 조회: select * from coupon where userId = ?
쿠폰이 이미 있다면 발급하지않고, 미지급일 때만 발급
락 범위: 쿠폰 발급 여부 조회 ~ Redis 동시성 체크 ~ 카프카 이벤트 생산
문제점
생산자와 소비자의 시간차 때문에 쿠폰이 여러 개 발급될 수 있음
소비자에서 아직 발급 중인데 유저의 쿠폰 발급 요청이 한 번 더 온다면?
API에서 쿠폰 생성까지 하더라도 락 범위가 너무 넓어 성능 저하 발생
Set 자료구조 사용하기 (권장)
userId를 Set에 저장하면 쿠폰 발급 여부를 바로 알 수 있음
Redis도 Set을 지원하므로 활용
부록: Consumer 예외 처리하기
문제: Consumer에서 예외가 발생하면, 발급된 쿠폰 개수는 올라갔는데 쿠폰은 발급되지 않은 상황 발생
즉, 100개보다 적은 쿠폰이 발생하는 상황 발생 가능
해결책: Consumer에서 예외 발생 시, 백업 데이터(FailedEvent 테이블)와 로그 남기기
추후 배치 프로그램으로 주기적으로 실패한 이벤트를 다시 처리해 쿠폰 발급
-
동시성 문제 해결 방법
동시성 문제 해결방법
멀티스레드 작업을 하다보면, 공유 자원에 대한 Race Condition으로 인해 동시성 이슈가 발생한다
이에 대한 다양한 해결방법을 정리해보자
최종 선택 기준
synchronized는 사용 X
비용적 여유가 없고 적정한 트래픽이라면, MySQL로 처리
비용적 여유가 있고 트래픽이 많다면 Redis로 처리
MySQL VS Redis
MySQL
이미 사용 중이라면 별도 비용 없이 사용 가능
Redis 보다 성능이 떨어짐 (어느정도 트래픽까지는 문제 없이 사용 가능)
Redis
사용 중인 Redis가 없다면, 인프라 구축 및 관리 비용 발생
MySQL보다 성능이 좋음
Synchronized (거의 사용 X)
데이터에 1개의 스레드만 접근 가능하도록 하기
문제점
여러 프로세스 동작 시, 여전히 Race Condition 발생
synchronized는 하나의 프로세스 안에서만 1개의 스레드 접근 보장
다른 프로세스의 스레드가 접근하면, 여전히 여러 스레드가 접근 가능해짐
서버가 1대일 때는 괜찮지만, 2~3대부터는 데이터 접근을 여러 곳에서 할 수 있음
실제 운영 중인 서비스는 대부분 2대 이상의 서버를 사용 -> synchronized는 거의 사용 X
추가로, @Transactional 사용 시 synchronized 적용이 어려움
@Transactional은 스프링 AOP 사용으로 트랜잭션 프록시 객체를 생성
내부 동작
startTransaction();
stockService.decrease(id, quantity); (target 객체 호출)
endTransaction();
실제 DB 업데이트(endTransaction()) 전에 다른 스레드가 decrease() 메서드 호출할 수 있음
이렇게 되면, 다른 스레드는 갱신되기 전 값을 가져가 여전히 동시성 문제 발생
즉, 서비스 객체 메서드가 아닌, AOP 객체 메서드에 synchronized를 걸어야 하는데 어려움
MySQL이 지원해주는 방법
선택 기준
충돌이 빈번하게 일어날 것 같다면 Pessimistic Lock
충돌이 별로 없을 것 같다면 Optimistic Lock
Pessimistic Lock
실제 데이터에 락을 걸어서 정합성을 맞추는 방법 (for update)
Exclusive Lock을 걸게되며, 다른 트랜잭션에서는 락이 해제되기전에 데이터를 가져갈 수 없음
e.g. 서버 1이 락을 가져가면, 다른 서버(2, 3, 4…)는 락 획득 대기
데드락을 주의해야 함
장점
충돌이 빈번하게 발생한다면, Optimistic Lock 보다 성능이 좋을 수 있음
락 덕분에 데이터 정합성 보장
단점
락 자체로 인한 성능 감소 발생
Optimistic Lock
버전을 이용해 정합성을 맞추는 방법
데이터를 읽은 후 update 쿼리를 수행할 때, 현재도 내가 읽은 버전이 맞는지 확인하며 업데이트
e.g.
서버 1과 2가 버전 1인 데이터를 읽고, 업데이트 쿼리를 날림
서버 1이 업데이트 쿼리를 수행하면 해당 데이터의 버전이 2가 됨
서버 2는 읽은 버전이 1이므로, 현재 데이터와 버전(버전 2)이 달라 쿼리 실패
내가 읽은 버전에서 수정사항이 생겼을 경우, application에서 다시 읽은 후에 작업을 수행
장점
별도의 락을 걸지 않으므로Pessimistic Lock 보다 성능이 좋음
단점
업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해야 함
Named Lock
이름을 가진 Metadata Locking
이름을 가진 락을 획득한 후 해제할 때까지 다른 세션은 이 락을 획득할 수 없음
MySQL: select get_lock('1', 1000), select release_lock(‘1’)
락 해제는 별도의 명령어로 수행하거나 선점시간이 끝나야됨
트랙잭션이 종료될 때 락이 자동으로 해제되지 않음
장점
분산 락 구현에 적합
Pessimistic Lock 보다 타임아웃을 쉽게 구현할 수 있음
삽입 시 데이터 정합성 맞출 때도 좋음
단점
트랜잭션 종료 시 락 해제, 세션 관리를 잘해주어야 함
실제 사용 시 구현 방법이 복잡
참고: 실무에서는 데이터 소스를 분리해서 사용할 것 (커넥션 풀 고갈 예방)
Pessimistic Lock과의 차이점
Pessimistic Lock은 행, 테이블 단위로 락을 걸음 (e.g. Stock에 락을 걸음)
Named Lock은 메타 데이터에 락을 걸음 (e.g. Stock이 아닌 별도의 공간에 락을 걸음)
Redis를 이용한 방법
선택 기준
재시도가 필요하지 않은 락은 Lettuce 활용
재시도가 필요한 락은 Redisson 활용
Lettuce
setnx 명령어(set if not exist)를 활용해 분산 락 구현
데이터 set (=락 획득), 데이터 del (=락 해제)
MySQL의 Named Lock과 유사
스핀락(Spin Lock) 방식
장점
구현이 간단
세션 관리도 신경쓸 필요 X
별도 라이브러리 필요 X
spring-data-redis를 사용하면 Lettuce가 기본
단점
재시도 로직을 개발자가 직접 작성
스핀 락이므로 충돌이 잦으면 Redis 부하 상승
-> 실패 시 재시도 시간에 텀을 두어 보완 (Thread.sleep(100))
Redisson
Pub-Sub 기반으로 분산 락 제공
채널을 하나 만들어 락 점유 중인 스레드가 해제를 알리면 락 획득 대기 스레드는 락 획득 시도
장점
락 획득 재시도를 기본으로 제공
Pub-Sub 기반이므로 Lettuce에 비해 Redis 부하 적음
락 해제 후 알림으로 락 획득 시도는 1번 혹은 몇 번 정도만 진행함
단점
구현이 조금 복잡함
별도의 라이브러리 필요 O
-
소프트웨어 장인
감사하다. 경험이 풍부한 그리고 소프트웨어 장인 정신을 실천하기 위해 노력했던 선배 개발자의 이야기를 들을 수 있는 책이었다. 추상적이던 애자일, XP, 소프트웨어 장인정신의 역사를 알 수 있었던 점도 좋았다. 2015년에 나온 책이지만, 대다수의 이야기들이 여전히 공감된다.
재밌는 부분은 과거에 문제였던 것들이 오늘 날에도 여전히 반복된다는 점이다. 선배들의 시행착오에서 어떤 개발자들은 나아갔고 어떤 개발자들은 나아가지 못했다.
개발자는 여러 경험을 쌓을 수 있지만, 소프트웨어의 품질 나아가 소프트웨어 산업 전반을 향상시키는 태도와 노력은 스스로 함양하고 실천해야 한다. 애자일 방법론, XP 실행 관례는 좋은 품질의 소프트웨어를 만들기 위해 과거 시행착오로부터 나온 증명된 노하우다. 우리는 선배들이 앞서 쌓아왔던 좋은 가치를 지향하고 그 위에 새로움과 변화를 쌓을 직업 윤리와 책임이 있다.
어려운 일이지만, 개발자라는 직업을 더욱 전문적이고 가치 있게 하는 이유이기도 하다.
애자일 (Agile)
서로 다른 여러 맥락에 따른 방법론과 테크닉의 조합 (단일 개념 X)
소프트웨어 프로젝트의 기본속성인 변화에 개발팀과 기업이 잘 적응할 수 있도록 도움
모든 애자일 방법론은
빠르고 짧은 피드백 루프에 대한 것 -> 피드백이 빠르고 짧을수록 애자일해짐
기술적 탁월함이 전제되어 있음 -> 기술적 수준이 개선되어야 한다는 것
애자일 매니페스토 창안 (2001년 2월)
소프트웨어 업계에 영향력이 있는 17명이 유타 주 스키 리조트에서 모임
켄트 백, 알리스테어 콕번, 워드 커닝햄, 마틴 파울러, 로버트 C. 마틴…
서로의 경험과 기술, 방법론을 공유하며 더 나은 소프트웨어 프로젝트 수행 방법 모색
여러 방법론과 테크닉을 발표
익스트림 프로그래밍(eXtreme Programming: XP)
스크럼
실용주의 프로그래밍
피처-드리븐 개발(FDD)
동적 시스템 개발 모델(Dynamic System Development Model: DSDM), 적응형 소프트웨어 개발, 크리스탈…
애자일 원칙
절차적 관점: 올바른 목표를 향해 진행 중인지 확인
회의 방식, 구성원들의 역할, 요구사항 파악 방법, 작업 진척 속도 파악 방법, 피드백 방식…
기술적 관점: 목표한 것을 올바르게 실행하고 있는지에 대해 안심할 수 있음
TDD, 페어 프로그래밍, 지속적인 통합, 단순한 디자인 원칙…
12가지 원칙
애자일 방식으로 일하기 위해
개발자는 비즈니스와 고객 가치 창출에 직접 관여해야함 (개발팀은 수평적이 되어감)
이렇게 일하기 위해서는 소프트웨어 프로페셔널이 되어야 한다
코드를 잘 작성하는 것은 최소 요건
테스트, 분석, 비즈니스에 대한 이해, 커뮤니케이션 능력, 보다 외향적인 성격 등이 요구됨
문제: 많은 애자일 전환이 기술적 개선 없이 절차와 도구에만 집중하다 실패
애자일은 절차와 기술적 탁월함이 모두 필요
스크럼 마스터, 애자일 코치가 절차에만 집중하고 사람들에 대한 기술적 훈련에는 관심이 없음
즉, 절차에만 집중하고 XP 실행 관례를 활용하는 경우는 드물다 (가르칠 역량도 없음)
프로젝트를 이끄는 상급자들이 기술 이해가 떨어지는 경우, 의사 결정이 프로젝트를 재앙으로 이끔
해결책: 완전한 애자일 전환을 위해서는 기업과 개발팀의 소프트웨어 장인정신이 필요
자기 목소리를 내는 프로페셔널한 개발자들이 필요하다
프로페셔널한 개발자: 기술적 실행 관례, 기술적 전문성, 관련 도구들을 마스터한 개발자
익스트림 프로그래밍 (XP)
XP의 기원
켄트 백(Kent Beck)은 여러 실행 관례들의 묶음을 발표, 1996
이후 크라이슬러 사 급여 지급 시스템 (C3) 프로젝트 리더로 일하며 일부분 수정
C3 팀에는 론 제프리스, 마틴 파울러, 돈 웰스 등의 애자일 지지자들이 있었음
C3 프로젝트는 성공 -> XP 실행 관례 도입 후,
버그가 1/3로 감소, 테스트 커버리지 상승, 디버깅 시간 제로 근접, 생산성 10배 상승
실행 관례는 매일 같이 습관처럼 해야 하는 것 (내재화)
제공 가치 (증명됨)
빠른 피드백 루프, 요구사항과 비용에 대한 더 나은 이해, 지식 공유, 버그 감소, 자동화, 빠른 릴리즈
실행 관례를 거부하는 사람들에게
의사 결정에 대해서 책임감만 가지면 된다.
거부하는 사람들의 이유와 이야기에서도 듣고 배울 것이 있을 것이다.
다만, 물어보자
XP가 제공하는 가치와 동등한 가치를 만들어내기 위해 무엇을 하고 있나요?
더 나은 방법은 있나요?
미래에 더 훌륭한 실행 관례가 나타난다면 비교해보고 또 따르자
절대적인 것은 없음, 개방적인 사고방식 필요
아래만 비교하면 된다
프로젝트에 어떤 가치를 주는지?
피드백 루프가 얼마나 긴지?
XP 실행 관례
테스트 주도 개발(TDD)
테스트가 코딩 방향을 주도하면 코드 설계가 간단해진다는 방법론 (복잡하기 어려움)
정확히 요구사항만 만족시키게 됨
코드가 복잡해지는 것을 방지 (복잡하면 테스트 자체가 어렵기 때문)
피드백이 빠르고 코드가 살아있는 문서 역할을 함
‘테스트 코드를 먼저 작성한다’의 진화 버전
페어 프로그래밍
실시간 코드 리뷰 효과
전체 시스템 이해도 및 개발자 스킬이 팀 차원에서 누적되고 향상
코딩 표준도 정의 및 유지 가능
같은 페어끼리 너무 오래 있지 않도록 하루 이틀 단위로 교체 필요
리펙토링
지속적인 코드 리펙토링 필요
전체 시스템을 한꺼번에 새로 작성하고 싶은 욕구를 조심하고, 한정해서 리팩토링에 집중
자주 변경 되는 부분을 대상으로 시작 (몇 년 동안 안바뀐 부분은 바꿀 필요가 없음)
단순한 설계
공동 오너십
지속적인 통합
버그 예방 협업을 위해 코드를 배포할 때마다 전체 테스트 스위트가 실행되고 실패하면 알림
몇 분 정도의 빠른 피드백 루프로 완료
TDD + 지속적인 통합은 QA 팀의 부담이 줄거나 팀 자체가 필요하지 않을 수 있음
…
소프트웨어 장인정신 (Software Craftsmanship)
소프트웨어 개발의 프로페셔널리즘
소프트웨어 개발자로서 일을 더 잘하기 위해 품는 이념이자 삶의 철학
즉, 책임감, 프로페셔널리즘, 실용주의, 소프트웨어 개발자로서의 자부심
스스로 커리어에 책임감을 가지고, 지속적으로 새로운 도구와 기술을 익히며 발전하겠다는 태도
탁월함에 헌신하고 탁월함을 추구함
소프트웨어 장인정신 운동의 사명
프로페셔널리즘으로 소프트웨어 개발이라는 업의 수준을 기술적, 사회적으로 높이는 것
직업 윤리
역량 미달의 수동적인 노동자가 아니라 프로로서 수준을 높여 일하는 개발자 지향 (일평생 정진)
전문가들은 당연히 스스로에게 돈과 시간을 투자한다 (교육이 회사의 의무는 아님)
배움과 훈련이 멈추는 순간 우리의 커리어도 멈춰버린다
최선이라고 알려진 몇몇 조합들에 대해서 완전하게 마스터하고 있어야 한다
고객이 바라는 바를 가장 효율적으로 만족시킬 것 (실용주의와 밀접)
경험이 적은 소프트웨어 장인과 지식을 나누는 것
다음 세대 장인을 키우는데 사회적 윤리적 의무감을 느껴야 함
불가능한 일정에 대하여 모든 위험을 공유하여 아니오라고 말하고 대안을 제시할 것
자신이 떠나고 난 자리가 부끄럽지 않도록할 것
역사
“실용주의 프로그래머: 수련자에서 마스터로”, 1999 (의미 있는 시작)
“소프트웨어 장인정신: 새로운 요구상” - 피트 맥브린, 2001
소프트웨어 도제 토론 모임 - 켄 아우어, 2002
애자일 콘퍼런스 & “클린 코드: 애자일 소프트웨어 장인정신을 위한 핸드북” - 로버트 마틴, 2008
애자일의 절차 중심적 상업화에 대한 걱정으로 소프트웨어 장인정신 정의와 대중화에 관심
소프트웨어 장인 매니페스토, 2009
핵심은 프로페셔널 소프트웨어 개발의 수준을 높인다(부제)에 있다.
내용
소프트웨어 장인을 열망하는 우리는, 스스로의 기술을 연마하고, 다른 사람들이 기술을 배울 수 있도록 도움으로써 프로페셔널 소프트웨어 개발의 수준을 높인다.
이러한 일을 하는 과정에서 우리는 다음과 같은 가치들을 추구한다.
동작하는 SW뿐만 아니라, 정교하고 솜씨 있게 만들어진 작품을,
-> 개발자가 쉽게 이해할 수 있는 SW (테스트, 비즈니스 용어 코드, 명료, …)
변화에 대응하는 것뿐만 아니라, 계속해서 가치를 더하는 것을,
-> 테스트, 확장 가능한 구조, 쉬운 유지보수
개별적으로 협력하는 것뿐만 아니라, 프로페셔널 커뮤니티를 조성하는 것을,
-> 멘토링과 공유
고객과 협업하는 것뿐만 아니라, 생산적인 동반자 관계를,
파트너십과 프로페셔널한 행동을 계약관계보다 상위에 둔다
적극적으로 프로젝트 성공에 기여해야 함
평판 관리 + 고객(협업할 기업)을 선별하는 능력도 요구됨
이 왼쪽의 항목들을 추구하는 과정에서, 오른쪽 항목들이 꼭 필요함을 의미한다.
소프트웨어 장인 컨퍼런스 & 소프트웨어 장인 커뮤니티(LSCC), 2009~
사용되는 비유
장인 (개발자) & 공예품 (소프트웨어)
도제 시스템 (다른 개발자들에게 기술을 공유하고 가르친다는 관점)
개발자와 기업들이 일을 올바르게 수행하도록 도움
여러 기술적 실행 관례를 활용
애자일과 소프트웨어 장인 정신은 상호 보완적
익스트림 프로그래밍(XP) 실행 관례도 적극적으로 활용
정교하고 솜씨 있게 짠 코드의 중요성을 강조
코드를 넘어 고객의 더 많은 부분을 도울 것을 강조
소프트웨어 장인은 애자일 원칙과 XP 실행 관례를 습관화하고 있음
익숙하지 않아 업무 속도가 느릴 수는 있지만, 원칙과 관례로 인해 업무 속도가 느려질리는 없음
추천하는 노력 과정
끊임 없는 자기계발
독서, 블로그, 기술 웹사이트, 리더 그룹 팔로우(트위터)
블로그는 나 자신을 위한 기록이 가장 우선, 다른 사람 생각은 너무 걱정 말자
끊임 없는 훈련
카타, 펫 프로젝트, 오픈소스, 페어 프로그래밍
다양한 경험
소프트웨어 개발은 다양성이 상당히 높은 전문 분야
다양한 기술과 도구를 접하면 프로페셔널해지고 생각지 못했던 커리어 선택지도 생김
탁월함을 위한 조언
커리어
형편없는 코드를 남기지 말자
커리어 패스는 내가 열정이 있는 것, 진정 즐겁게 할 수 있는 것을 따라야 한다 (개발자의 즐거움)
고참은 일시적이고 상대적인 것이다
거쳐 가는 모든 직장, 프로젝트들 하나하나가 미래 목표를 위한 투자다. 직장은 단순히 돈 버는 곳이 아니다.
지식노동자를 움직이는 것은 자율성, 통달, 목적의식이다. 소프트웨어 장인은 이를 따라 일할 곳을 선택한다.
소프트웨어 장인은 일자리를 잃는 것에 걱정이 없다. 자신의 커리어 방향이 일치하는 경우에만 수용한다.
회사 내 커리어보다 개인의 커리어를 항상 우선해야 한다.
테스트
자동화할 수 있는 버그를 QA가 발견하는 것은 개발자로서 대단히 수치스러운 일이다
QA의 역할은 인간의 예측할 수 없는 행동을 반영해 개발자가 예상하지 못한 문제를 찾아내는 것
테스트 코드를 쓰지 않았다면, 코드 작성을 완료했다고 할 수 없다.
자신이 짠 코드를 알고 있으니 테스트 코드를 안만들어도 된다는 개발자는 대단히 이기적인 사람이다.
리팩토링
레거시 코드를 볼 때 짠 사람을 너무 욕하지말고 즐거운 도전 과제로 생각하자.
남이 작성한 코드를 엉망이라고 말하기는 쉽지만, ‘나라면 더 잘 만들 수 있는가?’를 스스로 물어보자.
레거시 코드는 경계부터 점진적으로 테스트 코드를 작성하면서 이해도를 높이고 리팩토링해나간다
설계
가장 훌륭한 코드는 작성할 필요가 없는 코드다. 더 적게 작성할수록 더 좋다.
켄트 백이 제시한 ‘단순한 설계를 위한 4가지 원칙’
모든 테스트를 통과해야 한다
명료하고, 충분히 표현되고, 일관되어야 한다
동작이나 설정에 중복이 있어서는 안된다
메서드, 클래스, 모듈의 수는 가능한 적어야 한다
-> 요약하면 중복의 최소화, 명료성의 최대화
디자인 패턴은 범용적이어서 오버 엔지니어링과 복잡함을 유도하므로 리팩토링이 필요할 때 적용하자.
TDD 기반 애자일과 XP 실행 관례는 당장 필요를 충족시키는 단순한 코드를 유도한다.
예를 들어, 기능 추가로 인해 실제 필요한 상황에만 추상화를 도입하자. (실용적)
채용
자신보다 훌륭한 사람과 함께 일하기를 원하고 최소 자신과 비슷한 역량의 사람이 채용되길 희망하자.
다른 개발자를 추천하는 것 자체가 스스로의 평판을 시험대에 올리는 행위임을 이해하자.
항상 새로운 것을 시도하고, 배우고, 지식을 공유하고, 커뮤니티 활동에 적극적인 열정 있는 사람이 중요하다.
특정 기술에 대한 지식, 경력년수, 학위는 훌륭한 개발자를 놓치게 하는 요소다.
GitHub 계정, 블로그, 오픈 소스 활동, 커뮤니티 활동 내역, 펫 프로젝트, 트위터 계정, 좋아하는 기술 서적 목록, 참석했던 컨퍼런스 등이 열정 있는 사람을 채용하기 위한 이력서 요소다.
열정적인 개발자는 성장하기 위해 개인 시간을 기꺼이 투자한다.
회사는 시급히 채용할 상황을 절대 만들어서는 안된다. 잘못된 채용은 프로젝트를 망친다.
채용은 파트너십이다. 재능있는 개발자의 의견을 중요시 하고 일 방식 개선에서 도움을 받겠다는 것이다.
면접에서 질문을 많이 하는 사람은 좋은 파트너십을 맺을 가능성이 높다.
새로운 프로젝트의 개발자 채용은 열정과 소프트웨어 개발 기초 역량 외에도 프로젝트 성공 경험이 필요하다.
고객의 문제에 대응하고 비즈니스적 압박을 견뎌내는 노련한 개발자가 필요
면접관은 지원자를 프로페셔널로 대하고 건강한 기술 토론이 되도록 이끌어야 한다.
지원자를 무시하거나 바보로 만들면 안된다.
문화
소프트웨어 장인은 주변 사람들에게 영감을 불어 넣기 위해 모든 노력을 아끼지 않는다.
배움의 문화를 만들면 회사에 열정을 주입할 수 있다.
북클럽, 테크 런치, 그룹 토론회, 업무 교환, 그룹 코드 리뷰, 그룹 코드 카타, 회사 시간 내 펫프로젝트 시간 허용, 외부 기술 커뮤니티와 교류하기…
배움의 문화는 강제하지 말고 관심 있는 사람에게 집중하자.
Reference
소프트웨어 장인
-
도메인 주도 개발 시작하기
DDD (Domain Driven Design)
도메인이 중심이 되는 개발 방식
DDD는 추상적인 설계 철학이고 여러 답이 나올 수 있다는 점에서 예술이다
기존 Model Driven Design을 한 단계 발전시킴
기존 Model Driven Design은 생산성 향상에 초점을 맞춘 기술 중심적 접근 방식
DDD는 MDD에 비즈니스 중심의 접근 방식과 전략적 설계 개념을 추가
핵심 목표: Loose Coupling, High Cohesion
지속적으로 진화하는 모델을 만들면서 복잡한 어플리케이션을 쉽게 만들어 가는 것
전략적 설계 & 전술적 설계
전략적 설계 (Strategic Design)
도메인 문제를 문제 공간에서 해결 공간으로 가져가는 과정 (도메인 전문가와 기술팀이 함께 회의)
문제 공간(Problem Space): 도메인 추출 및 하위 도메인 분류 (도메인 전문가가 주요 역할)
해결 공간(Solution Space): 바운디드 컨텍스트 및 컨텍스트 맵 정의 (개발자가 주요 역할)
범위: 전반적
핵심 개념: Ubiquitous Language (보편 언어)
핵심은 유비쿼터스 언어에 기반한 팀 간 커뮤니케이션
유용한 도구
사용 사례(유스케이스) 분석
이벤트 스토밍 (Event Stroming)
Business Model 분석
…
전술적 설계 (Tactical Design)
전략적 설계에서 도출된 도메인 모델과 컨텍스트 맵을 이용해 실제 구현 진행
e.g. 바운디드 컨텍스트마다 도메인에 알맞은 아키텍처 사용
핵심 Bounded Context는 Model-Driven
지원 Bounded Context는 CRUD (서비스-DAO)
일반 Bounded Context는 Model-Driven, 3rd-party…
e.g. 컨텍스트 내 혼합도 가능 (CQRS)
상태 변경 관련 기능은 Model-Driven
조회 기능은 CRUD (서비스-DAO)
e.g. 바운디드 컨텍스트마다 서로 다른 구현 기술 사용도 가능
바운디드 컨텍스트 1: 스프링 MVC + JPA
바운디드 컨텍스트 2: Netty + Mybatis
바운디드 컨텍스트 3: 스프링 MVC + 몽고 DB
범위: 특정 Bounded Context
핵심 개념: Model Driven Design (모델 주도 설계)
도메인 모델을 중심으로 패턴 적용
유용한 패턴
계층형 아키텍처
Entity, Value Object
Aggregate
Factory
Repository
Domain Event
콘웨이의 법칙
소프트웨어의 구조는 해당 소프트웨어를 개발하는 조직의 구조를 따라간다.
역콘웨이의 전략
개발하는 조직의 구조를 소프트웨어의 구조에 맞춘다. 바운디드 컨텍스트 별로 팀을 구성하는 것도 좋다.
DDD 주요 개념
도메인
소프트웨어로 해결하고자 하는 현실 세계의 문제 영역
e.g. 온라인 서점
세부적 분류
비즈니스 도메인: 전체 문제 영역
문제 도메인: 전체 문제 영역 중 IT로 해결하고자 하는 문제 영역
도서 기업의 경우 문제 도메인은 전체 비즈니스 도메인의 일부분
IT 기업의 경우 비즈니스 도메인 전부가 문제 도메인
하위 도메인
한 도메인은 여러 하위 도메인으로 나눌 수 있음
e.g. 카탈로그, 주문, 혜택, 배송 …
3가지 유형으로 분류 가능
핵심 하위 도메인: 가장 중요한 문제
지원 하위 도메인: 핵심 도메인을 지원하는 문제
일반 하위 도메인: 대부분의 소프트웨어가 가지고 있는 문제 (e.g. 회원)
하위 도메인들은 서로 연동하여 완전한 기능을 제공
도메인의 특정 기능은 외부 시스템이나 수작업을 활용하기도 함
e.g. 배송 업체, 결제 대행 업체, 소규모 업체의 정산 수작업 엑셀 처리
도메인 전문가
온라인 홍보, 정산, 배송 등 각 영역에는 전문가 존재
요구사항을 제대로 이해할수록 도메인 전문가가 원하는 제품으로 향할 가능성 높음
개발자와 전문가는 직접 대화해야 함
중간에서 발생하는 정보의 왜곡과 손실을 방지
개발자는 전문가가 진짜 원하는 것을 찾아야 함
도메인 전문가 스스로도 요구사항을 정확히 표현 못할 수 있음
도메인 모델
특정 도메인을 개념적으로 표현한 것
e.g. 객체 기반 모델링 (기능과 데이터), 상태 다이어그램 기반 모델링 (상태 전이) - UML 예시
도메인을 이해하는데 도움이 된다면 표현 방식은 어느 것이든 괜찮음 (중요한 내용만 담음)
관계가 중요한 도메인은 그래프, 계산 규칙이 중요하면 수학 공식 이용
도메인 모델 (개념) VS 구현 모델 (구현 기술)
각 하위 도메인마다 별도로 모델을 만들어야 함 (올바른 방법)
같은 용어라도 하위 도메인마다 의미가 달라질 수 있음
e.g. 카탈로그 상품 (이미지, 상품명, 가격 위주), 재고 관리 상품 (실존 개별 객체 추적 목적)
도메인 모델은 엔터티와 밸류 타입으로 구분 가능
모델링 방법
요구사항을 바탕으로 도메인 모델을 구성하는 핵심 구성요소(엔터티, 속성), 규칙, 기능 찾기
상위 수준에서 정리한 문서화가 매우 큰 도움이 됨 (e.g. 화이트 보드, 위키 등)
바운디드 컨텍스트 (Bounded Context)
특정 도메인 모델을 구분하는 경계를 정의
구조: 도메인 모델 + 표현 영역, 응용 서비스, 도메인, 인프라스트럭처, DBMS를 모두 포함
바운디드 컨텍스트는 용어를 기준으로 구분 (컨택스트 내에서 동일한 유비쿼터스 언어 사용)
e.g. 같은 상품도 카탈로그 B.C의 Product와 재고 B.C의 Product는 각 B.C에 맞는 모델 가짐
바운디드 컨텍스트가 하위 도메인과 1 : 1 관계를 가지면 이상적이지만, 현실은 그렇지 않을 때가 많음
이상적 목표: 바운디드 컨텍스트 1 : 하위 도메인 1 : 도메인 모델 1
현실
e.g. 팀 조직 구조에 따라 결정
주문 바운디드 컨텍스트 + 결제 금액 계산 바운디드 컨텍스트 = 주문 하위 도메인
e.g. 용어를 명확히 구분 못해 두 하위 도메인을 하나의 바운디드 컨텍스트에서 구현
상품 바운디드 컨텍스트 = 카탈로그 하위 도메인 + 재고 하위 도메인
e.g. 규모가 작은 기업은 전체 시스템을 한 개팀에서 구현
소규모 쇼핑몰을 1개의 웹 애플리케이션으로 제공
1개 바운디드 컨텍스트 = 회원 + 카탈로그 + 재고 + 구매 + 결제 하위 도메인
여러 하위 도메인을 하나의 바운디드 컨텍스트에서 개발할 때, 하위 도메인 모델이 섞이지 않도록 하자
물리 바운디드 컨텍스트가 1개여도 논리 바운디드 컨텍스트 생성하자 (내부에서 패키지 활용)
바운디드 컨텍스트 간 통합
e.g. 카탈로그 하위 도메인에서 카탈로그 B.C와 추천 B.C 개발
카탈로그 B.C와 추천 B.C는 서로 다른 도메인 모델 가짐
직접 통합 방식 (e.g. REST API)
클라이언트로 호출한 후 응답 받은 데이터를 현재 도메인에 맞는 모델로 변환
e.g.
카탈로그 시스템은 카탈로그 도메인 모델에 기반한 도메인 서비스로 상품 추천 기능 표현
ProductRecommendationService (도메인 서비스) - RecSystemClient (Infra)
RecSystemClient
externalRecClient로 추천 시스템의 REST API 호출
응답받은 추천 데이터를 카탈로그 도메인에 맞는 상품 모델로 변환
변환이 복잡하면 별도의 Translator 클래스를 만들어 처리해도 됨
간접 통합 방식 (e.g. 메시지 큐)
출판/구독 모델을 이용해 바운디드 컨텍스트끼리 연동
한 바운디드 컨텍스트가 메시지 큐에 필요한 데이터를 저장
다른 바운디드 컨텍스트들은 메시지 큐에서 데이터 수신
서로 메시지 형식을 맞춰야 함
보통 큐를 제공하는 주체에 기반해 데이터 구조 결정
e.g.
추천 시스템은 사용자 활동 이력이 필요 (조회 상품 이력이나 구매 이력 등)
메시지 교환
카탈로그 시스템은 메시지 큐에 사용자 활동 이력 추가
추천 시스템은 메시지 큐에서 메시지를 읽어와 사용
카탈로그 시스템에서 큐를 제공하면 메시지 형식은 카탈로그 도메인 모델에 기반함
메시지 형식은 어떤 도메인 관점을 쓸지에 따라 달라짐
카탈로그 도메인 관점 (ViewLog, OrderLog-OrderLineLog)
카탈로그 도메인 모델 기준의 데이터를 메시지 큐에 저장
추천 도메인 관점 (ActiveLog)
추천 도메인 모델 기준의 데이터로 변환해 메시지 큐에 저장
만일, 큐를 추천 시스템에서 제공하면 REST API와 비슷해짐
물론 카탈로그 시스템이 비동기로 전달한다는 차이는 있음
바운디드 컨텍스트 간 관계
API 호출 방식 (e.g. REST API, 프로토콜 버퍼) - 메시지 큐 방식도 포함될 듯함!
단점은 하류 컴포넌트(Downstream)는 상류 컴포넌트(Upstream)에 의존
즉, API를 사용하는 바운디드 컨텍스트는 API를 제공하는 바운디드 컨텍스트에 의존
상류 컴포넌트는 상류 B.C의 도메인 모델을 따름
공개 호스트 서비스 (Open Host Service)
상류 팀이 여러 하류팀의 요구사항을 수용할 수 있는 API를 만들어 제공하는 서비스
e.g. 검색 B.C (상류 컴포넌트) : 블로그, 카페, 게시판 B.C (하류 컴포넌트)
안티코럽션 계층 (Anticorruption Layer)
하류 서비스는 상류 서비스 모델이 자신의 모델에 영향을 주지 않도록 완충지대 구성
e.g. 앞선 RecSystemClient는 모델 변환 처리와 안티코럽션 계층 역할을 함
공유 커널 방식 (Shared Kernel)
두 바운디드 컨텍스트가 같은 모델을 공유
공유 커널: 함께 공유하는 모델
e.g. 운영자를 위한 주문 관리 도구 개발 팀 VS 고객을 위한 주문 서비스 개발 팀
주문을 표현하는 모델을 서로 공유해 주문과 관련된 중복 설계 방지
중복을 줄여주는 장점 (동일한 모델을 두 번 개발하는 것을 방지)
공유 커널을 사용하는 두 팀은 반드시 밀접한 관계를 유지해야 함
독립 방식 (Separate Way)
서로 통합하지 않는 방식
두 바운디드 컨텍스트 간 통합이 필요하다면 수동으로 진행
e.g. 온라인 쇼핑몰 판매 정보를 운영자가 직접 ERP 시스템에 입력
규모가 커지면 결국 두 바운디드 컨텍스트를 통합해야 함 (수동 통합은 한계가 있음)
컨텍스트 맵 (Context Map)
바운디드 컨텍스트 간의 관계를 표시한 지도
전체 비즈니스 조망 가능 (시스템의 전체 구조)
규칙은 크게 없음
해결 공간의 대표적 산출물
매핑 관계 용어
유비쿼터스 언어 (Ubiquitous Language, 전략적 설계의 핵심)
전문가, 관계자, 개발자가 공유하는 도메인과 관련된 공통 언어 (도메인에서 사용하는 용어)
바운디드 컨텍스트 내에서 동일한 유비쿼터스 언어 공유
대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에 반영해야 한다
소통 과정에서 용어의 모호함이 감소
개발자는 도메인과 코드 사이에서 불필요한 의미 해석 과정 감소
알맞은 영어 단어 찾는 시간을 아끼지 말고, 코드와 문서는 변화를 바로 반영해 최신 상태를 유지
모델 주도 설계 (Model Driven Design, 전술적 설계의 핵심)
비즈니스 도메인의 핵심 개념과 규칙을 반영한 모델을 기반으로 설계하는 방법론
DDD를 위한 패턴들을 사용해 모델이 생명력을 잃지 않도록 지속적으로 관리
e.g. 계층형 아키텍처, Entity, Value Object, Aggregate, Factory, Repository…
객체 기반 도메인 모델링
상태 다이어그램 기반 도메인 모델링
도메인 모델, DTO와 get/set 메서드
도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
특히 set 메서드는 필드값만 변경하고 끝나기 때문에 상태 변경과 관련된 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다. 또한, set 메서드를 열어두는 것은 도메인 객체를 불완전하게 생성하도록 허용한다.
따라서, 도메인 객체는 생성자를 통해 필요한 데이터를 모두 받도록 설계해야 한다. (이 경우, private한 set을 만들어 생성자에서 사용할 수 있음)
또한, 불변 밸류 타입을 사용하면 자연스럽게 set 메서드 사용이 사라진다.
DTO는 도메인 로직이 없어 get/set 메서드를 사용해도 데이터 일관성에 영향을 덜 주지만, 프레임워크의 private 필드 직접 할당 기능을 최대한 사용하면 불변 객체의 장점을 DTO까지 확장할 수 있어 권장한다.
그린 필드 & 브라운 필드
그린필드: 소프트웨어의 초기 개발 시점 (코드가 깨끗함)
브라운 필드: 소프트웨어가 장기간 개발되어 복잡해진 시점
계층 구조 아키텍처 구성 - 도메인 모델 패턴
아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴
마틴파울러,
계층 구조는 상위 계층에서 하위 계층으로만 의존하는 특성을 가짐 (지름길은 허용)
DIP 적용 -> 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존하는 구조로 변화
인프라스트럭처의 클래스가 도메인이나 응용 영역에 정의된 인터페이스를 상속
DIP를 항상 적용할 필요 X -> DIP 장점(변경에 유연, 테스트가 쉬움) VS 구현의 편리함 적절히 고려
DIP의 장점을 해치지 않는 범위라면 응용 영역과 도메인 영역이 구현 기술을 의존해도 괜찮다
응용 영역의 트랜잭션 처리 의존 정도는 괜찮음 (@Transactional)
리포지터리와 도메인 모델은 구현기술이 거의 바뀌지 않아 타협도 좋음 (테스트 문제도 X)
e.g.1 JPA 애너테이션이 적용된 도메인 모델 (@Entity, @Table)
e.g.2 스프링 데이터 JPA를 상속하는 리포지터리 인터페이스
구조
표현 계층 (Presentation, UI)
사용자의 요청을 처리하고 정보를 보여줌
데이터 변환 역할
요청 데이터를 응용 서비스가 요구하는 알맞은 형태로 변환해 전달
실행 결과를 사용자에게 알맞은 형식으로 응답
응용 계층 (Application)
도메인 계층을 조합해서 사용자가 요청한 기능을 실행
주로 오케스트레이션을 하는 계층이어서 단순한 형태를 가짐
도메인 기능 예시
리포지터리에서 애그리거트를 구한다
애그리거트의 도메인 기능을 실행한다
결과를 리턴한다
새 애그리거트 생성 예시
데이터가 유효한지 검사한다 (데이터 중복 등)
애그리거트를 생성한다
리포지터리에 애그리거트를 저장한다
결과를 리턴한다
트랜잭션 처리, 접근 제어, 이벤트 처리 등을 담당
도메인 계층 (Domain)
도메인 모델에 도메인 핵심 규칙을 구현
인프라스트럭처 계층 (Infrastructure)
외부 시스템과의 연동 처리 (e.g. DB, SMTP, REST, 메시징 시스템)
구현 전략
인증 및 인가 전략
URL 이용 인증 및 인가는 서블릿 필터가 좋은 위치 (스프링 시큐리티도 유사하게 동작)
URL만으로 어려운 경우 응용 서비스의 메서드 단위로 인증 및 인가 수행 (@PreAuthorize)
개별 도메인 객체 단위로 필요한 경우, 직접 권한 로직 구현 (심화: 스프링 시큐리티 확장)
e.g. 게시글 삭제는 본인 또는 관리자 역할을 가진 사용자만 할 수 있다
게시글 애그리거트를 로딩해야 권한 검사할 수 있음 (도메인 서비스에 구현)
permissionService.checkDeletePermission(userId, article);
응용 계층 전략
한 도메인과 관련된 기능은 각각 별도의 서비스 클래스로 구현하자.
각 클래스 별로 필요한 의존 객체만 포함하므로 코드 품질 유지와 이해에 도움이 됨
클래스의 개수가 많아지고 단순 코드 중복은 문제
필요 시, 한 응용 서비스 클래스에서 1개 내지 2~3개 기능 정도를 가지도록 허용
코드 중복이 신경쓰인다면 별도의 헬퍼 클래스를 둬서 해결
// 각 응용 서비스에서 공통되는 로직을 별도 클래스로 구현
public final class MemberServiceHelper {
public static Member findExistingMember(MemberRepository repo, String memberId) {
Member member = repo.findById(memberId);
if (member == null)
throw new NoMemberException(memberId);
return member;
}
}
// 공통 로직을 제공하는 메서드를 응용 서비스에서 사용
import static com.myshop.member.application.MemberServiceHelper.*;
public class ChangePasswordService {
private MemberRepository memberRepository;
public void changePassword(String memberId, String curPw, String newPw) {
Member member = findExistingMember(memberRepository, memberId);
member.changePassword(curPw, newPw);
}
// ...
}
응용 계층 전달 데이터가 2개 이상이면 DTO 객체를 사용해 표현 계층에서 자동 변환하면 편리
e.g. 스프링 MVC 웹 요청 파라미터 자바 객체 변환 기능
응용 서비스는 표현 영역에서 필요한 데이터만 리턴하자
도메인 객체 리턴하면 도메인 로직을 표현 영역에서 실행할 가능성이 생김
요청 값에 대한 검증은 표현 계층 보다 응용 서비스에서 처리하자 (응용서비스 완성도 상승)
여러 검증 정보가 한 번에 필요하면, 서비스에서 에러 코드를 모아 1개 예외로 발생시키자
List<ValidationError> errors = new ArrayList<>;
if (!errors.isEmpty()) throw new ValidationErrorException(errors);
표현 영역에서 예외 잡아 변환 - bindingResult.rejectValue()
조회 전용 기능의 경우 서비스 없이 표현 영역에서 바로 사용해도 괜찮음
요청 처리 흐름
패키지 구조
한 패키지는 가능한 10~15개 미만으로 타입 개수 유지 (코드 찾기 불편하지 않을 정도)
기본 패키지 구성
도메인이 클 때는 하위 도메인마다 별도로 패키지 구성하자
각 하위 도메인의 응용 영역과 도메인 영역은 애그리거트 기준으로 나누어 재구성 가능
애그리거트, 모델, 리포지터리는 같은 도메인 모듈에 위치
도메인이 크면 도메인 모델과 도메인 서비스를 별도 패키지로 구분 가능
order.domain.order, order.domain.service
도메인 영역의 주요 구성요소
엔터티와 밸류 (Entity & Value type)
엔터티와 밸류의 구분법: 고유 식별자를 갖는지 확인
DB 테이블 갖는다고 엔터티는 아님
엔터티 식별자와 DB PK 식별자는 다른 것
e.g. Article - ArticleContent
ArticleContent는 밸류이며, DB에 PK가 있지만 도메인에서의 식별자는 아님
참고: 식별자 구현 위치
식별자 생성규칙(도메인 규칙)이 있으면, 도메인 영역에 위치 (e.g. 도메인 서비스, 리포지토리)
엔터티
고유의 식별자를 갖는 객체
식별자가 같으면 두 엔터티는 같다 (equals() 및 hashCode() 로 구현)
자신의 라이프 사이클을 가짐
도메인 모델 엔터티와 DB 관계형 모델 엔터티는 서로 다름
도메인 모델 엔터티는 데이터와 함께 도메인 기능을 제공
도메인 모델 엔터티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류로 표현 가능
RDBMS는 밸류 타입이 표현이 어려움
개별 데이터로 저장 -> 개념이 드러나지 않음
테이블을 분리해 저장 -> 테이블의 엔터티에 가깝고 밸류라는 의미가 드러나지 않음
밸류
고유의 식별자를 갖지 않는 객체
모든 속성이 같으면 두 밸류 객체는 같다 (equals() 및 hashCode() 로 구현)
주로 개념적으로 하나인 값을 표현하고 싶거나 의미를 명확하게 표현하고 싶을 때 사용
ex 1. Receiver = receiverName + receiverPhoneNumber
ex 2. Address = shippingAddress1 + shippingAddress2 + shipppingZipcode
ex 3. ShippingInfo = Receiver + Address
ex 4. Money - ‘돈’을 의미하도록 하여 코드 이해에 도움을 줌
ex 5. OrderNo - 주문 엔터티의 식별자로 밸류를 사용해 코드 이해에 도움을 줌
엔터티의 속성 혹은 다른 밸류 타입의 속성으로 사용됨
기능 추가가 가능하다는 장점이 있음
불변(immutable) 으로 설계해야 함
데이터 변경 기능 제공 X
변경할 때는 새로 밸류 객체를 생성해 반환
JPA 관련 테크닉
JPA가 강제하는 엔터티와 벨류 정의 시 필요한 기본 생성자는 protected로 선언하자
값이 없는 온전치 못한 객체 생성 예방
@Access(AccessType.FIELD): 메서드 매핑을 완전히 방지하고 필드 매핑 강제
- 원래 JPA는 메서드에도 컬럼 매핑(AccessType.PROPERTY)이 가능하므로 아얘 막자
밸류 매핑
@Embedded, @Embeddable: 값 객체 지정 애노테이션
@EmbeddedId: 식별자 자체를 밸류 타입으로 지정 (식별자 의미 강조 위해)
JPA는 식별자 타입이 Serializable 이어야 하므로, 밸류 타입은 상속 필요
기능 추가 가능한 장점 (e.g. 주문 번호 세대 구분)
@SecondaryTable: 밸류를 별도 테이블로 매핑
조회 성능이 안좋음 (두 테이블을 조인해서 가져옴) -> 조회 전용 DAO 사용 필수!
지연 로딩을 위해 밸류를 엔터티로 매핑할 수도 있지만, 밸류 정체성을 잃어버려서 안좋음
@AttributeOverrides: 값 객체의 칼럼 이름이 다른 값 객체의 그것과 서로 다를 때 사용
AttributeConverter: 2개 이상의 프로퍼티를 가진 밸류 타입을 1개 컬럼에 매핑 가능
AttributeConverter 인터페이스를 상속해 convertToDatabaseColumn(), convertToEntityAttribute() 구현한 후, 해당 클래스에 @Converter(autoApply = true) 적용
autoApply = false인 경우 필요한 곳에 @Convert(converter = ...) 적용
e.g. Length 클래스(int value, String unit) -> DB 컬럼 width
밸류 컬렉션 매핑
@ElementCollection, @CollectionTable: 밸류 컬렉션을 별도 테이블로 매핑
e.g. Order - OrderLines
AttributeConverter: 밸류 컬렉션을 한 개 컬럼에 매핑할 때도 사용
e.g. Email 주소 목록 Set (EmailSet) -> 1개 DB 칼럼에 콤마로 구분해 저장
기술적 혹은 팀 표준적 한계로 인해, 밸류를 @Entity로 구현해야 할 수도 있음
e.g. 밸류 타입인데 상속 매핑이 필요한 경우
상태 변경 메서드 제거 & cascade + orphanRemoval=true 적용
다만, images 리스트의 clear() 호출하면 쿼리가 비효율적이어서, @Embeddable로 단일 클래스 구현하고 기능은 타입에 따라 if-else로 구현하는 것도 방법이다!
@SecondaryTable 예제
@Entity
@Table(name = "article")
@SecondaryTable(
name = "article_content",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@AttributeOverrides({
@AttributeOverride(
name = "content",
column = @Column(table = "article_content", name = "content")
),
@AttributeOverride(
name = "contentType",
column = @Column(table = "article_content", name = "content_type")
)
})
@Embedded
private ArticleContent content;
}
애그리거트 (Aggregate)
연관된 엔터티와 밸류 객체를 개념적으로 하나로 묶은 군집
개념상 완전한 1개의 도메인 모델 표현
e.g. 주문 애그리거트(상위 개념) = Order 엔터티, OrderLine 밸류, Orderer 밸류(하위)
보통 대다수의 애그리거트는 1개의 엔터티 객체만으로 구성되며 2개 이상은 드물다
루트 엔터티 이외의 엔터티는 실제 엔터티가 맞는지, 맞다면 다른 애그리거트는 아닌지 의심
애그리거트를 나누는 기준: 도메인 규칙에 따라 함께 생성 혹은 변경되는 구성 요소인가? (라이프사이클)
‘A가 B를 갖는다’ 라는 요구사항이 있어도 A와 B가 다른 애그리거트일 수 있음
e.g. 상품과 리뷰는 다른 애그리거트 - 변경 주체가 다르고 함께 생성하거나 함께 변경하지 않음
특징
커져서 복잡해진 도메인 모델을 상위 수준에서 애그리거트 간 관계로 파악 및 관리 가능
애그리거트는 도메인 규칙과 요구사항에 따라 경계를 가짐
한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않음
애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 가짐 (대부분 함께 생성 및 제거)
일관성을 관리하는 기준이 됨
복잡한 도메인 모델을 단순한 구조로 만들어 도메인 기능 확장 및 변경 비용 감소
루트 엔터티
애그리거트에 속한 전체 객체를 관리하는 엔터티
핵심 역할: 애그리거트의 일관성이 깨지지 않도록 하는 것
도메인 규칙을 지켜 애그리거트 내 모든 객체가 항상 정상 상태를 유지하도록 함
e.g. OrderLine 밸류 변경 시, Order 엔터티의 주문 총 금액을 함께 변경
e.g. changeShippingInfo()는 배송 시작 전에만 배송지를 변경 가능하도록 구현
애그리거트의 유일한 진입점 (애그리거트 단위로 내부 구현 캡슐화)
애그리거트의 도메인 기능은 루트 엔터티를 통해서만 실행 가능
애그리거트 내 엔터티 및 밸류 객체는 루트 엔터티를 통해서만 간접적으로 접근 가능
필요한 구현 습관
단순한 필드 변경 public set 메서드는 지양하자
set 메서드는 도메인 의도를 표현하지 못함
공개 set만 줄여도 cancel, change 등 의미가 드러나는 메서드 구현 빈도 상승
밸류 타입은 불변으로 구현하기
덕분에 애그리거트 외부에서 밸류 객체에 접근해도 상태 변경은 불가능
e.g.
루트 엔터티는 내부 다른 객체를 조합(참조, 기능 실행 위임)해 기능 구현 완성
Order - calculateTotalAmounts()
OrderLines - ChangeOrderLines()
혹시 Order가 getOrderLines()를 제공해도 OrderLines가 불변이면 애그리거트 외부에서 변경이 불가능해 안전하다
혹시 불변 구현이 불가능한 경우, 변경 기능을 default(package-private), protected로 제한하면 애그리거트 외부 상태 변경을 방지할 수 있음
애그리거트에 영속성 전파 적용하자 (@OneToMany, @OneToOne)
애그리거트의 모든 객체는 함께 저장되고 함께 삭제되어야 함
cascade = {CascadeType.PERSIST, CascadeType.REMOVE}
orphanRemoval = true
@Embeddable은 기본적으로 함께 저장 및 삭제되므로 cascade 속성 필요 X
애그리거트 간 참조
= 루트 엔터티가 다른 루트 엔터티를 참조하는 것
다른 애그리거트를 참조할 때는 ID 참조하자
애그리거트 경계가 명확해지고 응집도가 높아짐
여러 애그리거트를 읽어야할 때, 조회 속도 문제는 조회 전용 쿼리로 해결 (DAO)
여러 애그리거트를 읽어야할 때, 조회 속도 문제 발생할 수 있음 (N + 1)
e.g. 주문 개수가 10개 - 주문 쿼리 1, 상품 쿼리 10
DAO에서 조인을 이용해 1번의 쿼리로 데이터 로딩하자 (e.g. OrderViewDao, JPQL)
만약 애그리거트마다 다른 저장소를 사용한다면, 캐시 적용 혹은 조회 전용 저장소 이용
코드는 복잡해져도 시스템 처리량 상승
객체 참조는 구현은 편리하지만 단점이 많음
다른 애그리거트의 상태를 쉽게 변경할 수 있다는 단점
지연로딩/즉시로딩 고민 필요
확장 시에도 하위 도메인마다 기술이 달라질 수 있는데 JPA에 종속되어버림
애그리거트를 팩토리로 사용하기
한 애그리거트의 상태를 보고 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드 추가
e.g. Store의 상태를 보고 Product를 생성한다면, Store에 createProduct() 추가
정보가 많다면 Store - createProduct() - ProductFactory.create()도 가능
도메인의 응집도가 높아짐
리포지터리 (Repository)
애그리거트 단위로 도메인 객체를 조회하고 저장하는 기능 정의 (도메인 모델의 영속성 처리)
구현을 위한 도메인 모델 (엔터티와 밸류는 요구사항에서 도출되는 도메인 모델)
애그리거트는 개념적으로 하나이므로 저장할 때도 전체를 저장하고, 불러올 때도 전체를 불러와야 함
원자적 변경 구현: RDBMS는 트랜잭션 사용, 몽고 DB는 한 애그리거트를 한 문서에 저장
응용 서비스 계층이 사용 주체
리포지터리 인터페이스는 도메인 영역, 구현 클래스는 인프라스트럭처 영역에 속함
기본 정의 메서드 (애그리거트 저장 및 조회)
save(Some some)
findById(SomeId id)
필요에 따라 다양한 조건의 검색이나 delete(id)나 count() 추가
리포지터리와 DAO
리포지터리와 DAO는 데이터를 DB로 부터 가져온다. 둘은 목적이 같지만 의미에서 차이가 있다.
CQRS에서 명령 모델에서 사용할 때는 리포지터리라고 지칭하고, 조회 모델에서 사용할 때는 DAO라고 지칭한다.
스프링 데이터 JPA Specification
DAO 구현 시 다양한 조건 검색에는 Specification을 사용이 도움이 된다.
하이버네이트 @Subselect
쿼리 결과를 @Entity로 매핑할 수 있는 기능으로, 마치 뷰를 사용하는 것 처럼 쿼리 실행 결과를 매핑할 테이블처럼 사용할 수 있다. @Immutable, @Synchronize를 함께 사용하자.
도메인 서비스 (Domain Service)
한 애그리거트만으로는 구현이 불가능한 특정 엔터티에 속하지 않는 도메인 로직을 처리
여러 애그리거트가 필요한 기능을 억지로 한 애그리거트에 넣으면 안된다
코드가 복잡하고 외부 의존이 높아져 수정이 어려움
애그리거트의 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어 명시적으로 드러나지 않게 됨
계산 로직 & 외부 시스템 연동이 필요한 도메인 로직에 사용
계산 로직 (e.g. 실제 결제 금액 계산)
총 주문 금액은 주문 애그리거트에서 가능
But, 할인 금액 계산은 상품, 쿠폰, 회원 등급, 구매 금액 등이 필요
나아가 2개의 할인 쿠폰 적용은 단일 할인 쿠폰 애그리거트로 처리 불가
public class DiscountCalculationService {...}
public Money calculateDiscountAmounts(List<OrderLines>, List<Coupon> coupons, MemberGrade grade)
외부 시스템 연동 도메인 로직 (e.g. 설문 조사 시스템이 외부 역할 관리 시스템과 연동해야 할 때)
설문 조사 생성 권한이 있는지 확인하는 것은 도메인 규칙
외부 연동 보다는 도메인 로직 관점에서 인터페이스 작성 - 응용 서비스에서 사용
public interface SurveyPermissionChecker
boolean hasUserCreationPermission(String userId)
인터페이스는 도메인 영역, 구현 클래스는 인프라스트럭처 영역에 위치
구현 및 사용 방법
상태 없이 로직만 구현 (엔터티, 밸류 등과의 차이)
사용 주체는 애그리거트 혹은 응용 서비스 둘 다 가능
e.g. 결제 금액 계산: Order - calculateAmounts(DiscountCalculationService disCalSvc, MemberGrade grade)
e.g. 계좌 이체: TransferService (도메인 서비스) - transfer(Account fromAcc, Account toAcc, Money amounts)
도메인 서비스는 도메인 영역에 위치
e.g. 실제 계산 금액 도메인 서비스는 주문 애그리거트와 같은 패키지에 위치
domain.model, domain.service, domain.repository로 분할해도 괜찮음
도메인 로직이 외부 시스템을 이용해 구현될 때는 인터페이스와 클래스를 분리하자
도메인 서비스 인터페이스 (도메인 영역) - 도메인 서비스 구현 클래스 (인프라스트럭처 영역)
애그리거트와 트랜잭션
트랜잭션 구현 전략
1개의 트랜잭션에서는 1개의 애그리거트만 수정하자
트랜잭션 범위는 작을수록 좋다 (1개 테이블 한 행 잠금이 3개 테이블 잠금보다 처리량 높음)
2개 이상의 애그리거트 수정 -> 트랜잭션 충돌 가능성 상승 -> 처리량 감소
부득이하게 1개 트랜잭션으로 2개 이상의 애그리거트 수정이 필요할 경우 응용 서비스에서 수정
다음 상황에서만 허용
기술적으로 도메인 이벤트를 사용할 수 없거나 팀 표준인 경우
UI 구현의 편리 - 운영자의 편의를 위해 여러 주문의 상태를 한 번에 변경하고 싶을 때
도메인 이벤트 사용 -> 1 트랜잭션 1 애그리거트 수정한 후 다른 애그리거트 수정 가능 (동기, 비동기)
애그리거트를 위한 추가적인 트랜잭션 처리 기법 (잠금)
애그리거트 간 동시성 문제 제어를 위해 필요
e.g. 운영자와 고객이 동시에 논리적으로 같은 애그리거트에 접근하지만 물리적으로 다른 애그리거트 객체를 사용하게 되어 동시성 문제 발생
종류
선점 잠금 (Pessimistic Lock)
한 스레드의 애그리거트 사용이 끝날 때까지 다른 스레드의 해당 애그리거트 수정을 막음
e.g. 운영자가 배송지 정보를 조회하고 상태를 변경하는 동안, 고객의 애그리거트 수정을 막는다
구현: for update 쿼리 (DBMS 지원 행단위 잠금/특정 레코드에 한 커넥션만 접근 가능)
JPA (하이버네이트)
EntityManger의 find() 메서드에 LockModeType.PESSIMISTIC_WRITE 인자 전달
e.g. entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE)
스프링 데이터 JPA
@Lock(LockModeType.PESSIMISTIC_WRITE) 지정
주의점: 교착 상태 예방을 위해 최대 대기 시간 지정 필요 (DBMS마다 지원 여부 다름)
JPA
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(
Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints
);
스프링 데이터 JPA
@QueryHints({ @QueryHint(name = "javax.persistence.lock.timeout", value = "2000") })
비선점 잠금 (Optimistic Lock)
버전 값을 사용해서 변경 가능 여부를 실제 DBMS 변경 반영 시점에 확인하는 방법
e.g. 운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 후 수정하도록 한다
선점 잠금으로 해결할 수 없는 경우를 해결
사용자가 버전 값을 응답 받고 다음 요청에 버전 값을 함께 보내는 방식
덕분에 여러 트랜잭션이나 시간에 걸쳐 락 확장 가능
구현: 쿼리에서 애그리거트의 버전이 동일한 경우에만 수정하고 성공하면 버전값도 올리기
쿼리
UPDATE aggtable SET version = version + 1, colx = ?, coly = ? WHERE aggid = ? and version = 현재버전
JPA
@Version private long version (필드에 애너테이션 적용)
트랜잭션 충돌 시 OptimisticLockingFailureException 발생
주의점: 강제 버전 증가 잠금 모드 필요 (for 애그리거트 일관성 유지)
애그리거트에서 일부 구성요소의 값만 바뀌어도 루트 엔터티 버전 값을 증가시키자
JPA
EntityManger의 find() 메서드에 LockModeType.OPTIMISTIC_FORCE_INCREMENT 인자 전달
엔터티 상태 변경 여부와 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리
스프링 데이터 JPA
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) 지정
오프라인 선점 잠금 (Offline Pessimistic Lock)
선점 잠금과 달리 여러 트랜잭션에 걸쳐 동시 변경을 막는 방식
e.g. 누군가 수정 화면을 보고 있을 때, 수정 화면 자체를 실행하지 못하게 막음
구현: 락 직접 구현
애플리케이션 단 락 구현 (LockManger)
DB 단 락 구현 (테이블)
주의점
락을 얻은 사용자가 영원히 반납하지 않는 경우를 고려해 잠금 유효 시간 필요
락을 얻은 사용자는 일정 주기로 유효 시간을 증가시켜야 UX 불편 없이 수정 가능
수정 폼에서 1분 단위로 Ajax 호출해 1분씩 유효 시간 증가시키기
이벤트 (Event)
문제점: 바운디드 컨텍스트 간에는 강결합이 발생한다
e.g. 주문 B.C와 결제 B.C 간의 강결합 (환불 기능의 강결합)
환불 기능 구현 2가지 방법
Order 엔터티의 cancel()에 RefundService 도메인 서비스를 파라미터 넘겨 실행
응용 서비스에서 Order 엔터티의 cancel()과 RefundService의 refund() 실행
환불은 외부 결제 API에 의존, 도메인 서비스가 필요
외부 의존으로 생기는 문제점
트랜잭션 처리 문제
외부결제시스템이 비정상이라 예외가 발생하면, 트랜잭션을 롤백할지 커밋할지 애매
환불 실패시 롤백하는 것이 맞아보이지만, 반드시 롤백해야 하는 것은 아님
주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식도 가능
성능 문제
외부 결제 시스템의 응답 시간이 길어지면 대기 시간도 길어져 성능 문제 발생
설계상 문제점: 도메인 객체에 도메인 서비스를 전달할 때 도메인 로직이 뒤섞임 (강결합)
변경의 이유가 증가
주문 도메인에 결제 도메인의 환불 관련 로직이 뒤섞임
주문 도메인 객체의 코드가 결제 도메인 때문에 변경될 수 있음
기능 추가 시 로직이 더욱 섞임
주문취소 및 환불에 통지 기능을 추가하면, NotiService도 파라미터로 넘김
로직이 더욱 섞여 트랜잭션 처리가 복잡해지고 외부 서비스가 2개로 증가
이벤트 (해결책)
과거에 벌어진 어떤 것 => 상태 변경
e.g. 암호를 변경했음, 주문을 취소했음
“~할 때”, “~가 발생하면”, “만약 ~하면” 같은 요구사항 -> 이벤트 구현 가능
도메인의 상태 변경과 관련되는 경우가 많음
e.g. 주문을 취소할 때, 이메일을 보낸다. (앞: 이벤트, 뒤: 후속 처리)
효과
도메인 로직이 섞이지 않아 바운디드 컨텍스트 간 강결합을 크게 해소 (변경 및 기능 확장 용이)
e.g. 주문 도메인의 결제 도메인 의존을 제거
e.g. 구매 취소 시 이메일 보내기 기능을 추가하더라도 구매 취소 로직은 수정할 필요 X
용도
트리거
도메인의 상태가 바뀔 때 후처리 실행
e.g. 주문 취소 이벤트 -> 환불 처리, 예매 완료 이벤트 -> SMS 발송
시스템 간의 데이터 동기화
e.g. 배송지 변경 이벤트 -> 핸들러는 외부 배송 서비스와 배송지 정보 동기화
관련 구성요소 (도메인 모델에 이벤트 도입 시)
이벤트
발생한 이벤트에 대한 정보를 담음
클래스 이름으로 이벤트 종류 표현
이벤트 발생 시간
이벤트와 관련된 추가 데이터 (e.g. 주문 번호, 신규 배송지 정보)
구현
일반 클래스 사용 (과거시제로 명명 e.g. ShippingInfoChangedEvent)
공통 프로퍼티가 있다면 상위 이벤트 클래스를 만들어도 괜찮음 (e.g. 발생 시간)
이벤트 처리에 필요한 최소한의 정보만 포함
이벤트 생성 주체
도메인 객체(엔터티, 밸류, 도메인 서비스)가 주체
도메인 로직 실행으로 상태가 변경되면 관련 이벤트를 발생시킴
구현
Events: 이벤트 발행 래퍼 클래스 구현 (ApplicationEventPublisher를 이용)
public class Events {
private static ApplicationEventPublisher publisher;
static void setPublisher(ApplicationEventPublisher publisher) {
Events.publisher = publisher;
}
public static void raise(Object event) {
if (publisher != null) {
publisher.publishEvent(event);
}
}
}
Events.raise(...): 이벤트를 디스패처에 전달
설정 컴포넌트에서 파라미터 전달하고 빈 등록
이벤트 디스패처 (퍼블리셔)
이벤트 생성 주체와 이벤트 핸들러를 연결
이벤트 생성 주체는 이벤트를 생성해서 디스패처에 전달
디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파
이벤트 생성과 처리는 디스패처 구현 방식에 따라 동기 혹은 비동기로 실행됨
구현
ApplicationEventPublisher 이용 (스프링 제공)
이벤트 핸들러 (구독자)
발생한 이벤트에 반응
이벤트를 전달받고 담긴 데이터를 이용해서 원하는 기능을 실행
구현
@EventListener(이벤트.class): 이벤트 수신
이벤트 발생 시 해당 애너테이션이 붙은 메서드를 모두 찾아 실행
이벤트 처리 방식
동기 이벤트 처리
응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러 실행 (순차적)
도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행
문제점: 외부 서비스 영향 문제
이벤트를 사용해 강결합 문제는 해결했지만, 외부 서비스 영향 문제는 여전히 남아 있음
= 트랜잭션 처리 문제 및 성능 문제
2가지 해결책: 비동기 이벤트 처리 or 이벤트와 트랜잭션 연계
e.g.
외부 환불 시스템이 실패했다고 반드시 트랜잭션을 롤백해야 할까?
구매 취소는 처리하고 환불만 재처리 혹은 수동 처리하는 것도 가능한 방법!
즉, 주문 취소 후 수십초 내에 결제 취소가 이루어지면 됨!
마찬가지로, 회원 가입 신청 후 몇 초 뒤에 이메일이 도착해도 문제 없음!
실패 시 사용자는 이메일 재전송 요청을 이용해 수동으로 인증 메일 다시 받음
비동기 이벤트 처리
요구사항: ‘A 하면 이어서 B 하라’ -> ‘A하면 최대 언제까지 B하라‘인 경우가 많음
즉, 대부분 일정 시간 안에만 후속 조치 처리하면 되는 경우가 많음
B를 실패하면 일정 간격으로 재시도하거나 수동 처리해도 상관 없음
‘A하면 최대 언제까지 B하라‘로 바꿀 수 있는 요구사항은 비동기 이벤트 처리로 구현 가능
방법: A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러 실행
구현 방법
로컬 핸들러를 비동기로 실행하기
main 함수가 있는 클래스에 @EnableAsync 적용해 비동기 기능 활성화
이벤트 핸들러 메서드에 @Async 적용 (=이벤트 핸들러를 별도 스레드로 비동기 실행)
메시지 큐 사용하기 (Kafka or RabbitMQ)
보통 이벤트 생성 주체와 이벤트 핸들러는 별도 프로세스에서 동작
이벤트 발생 JVM과 이벤트 처리 JVM이 다름
동일 JVM에서 비동기를 위해 메시지 큐를 사용할 수 있지만 시스템만 복잡해짐
과정 (별도의 스레드나 프로세스로 처리)
이벤트가 발생하면 이벤트 디스패처는 이벤트를 메시지 큐로 보냄
메시지 큐는 이벤트를 메시지 리스너에 전달
메시지 리스너는 알맞은 이벤트 핸들러를 이용해 이벤트 처리
글로벌 트랜잭션 (Optional)
필요시 도메인 기능과 메시지 큐 이벤트 저장 절차를 한 트랜잭션으로 묶을 수 있음
e.g. 같은 트랜잭션 범위: 도메인 상태변화 DB 반영 & 이벤트 메시지 큐 저장
장점: 이벤트를 안전하게 메시지 큐에 전달 가능
단점: 전체 성능 감소, 글로벌 트랜잭션을 지원하지 않는 메시지 시스템 존재
RabbitMQ: 글로벌 트랜잭션 지원 O, 안정적 메시지 전달 (클러스터 및 고가용성 지원)
Kafka: 글로벌 트랜잭션 지원 X, 높은 성능
이벤트 저장소(Event Store)를 이용한 비동기 처리 (DB)
이벤트를 DB에 저장한 뒤 별도 프로그램을 이용해 이벤트 핸들러에 전달
동일한 DB에서 도메인의 상태와 이벤트를 저장
도메인 상태 변화와 이벤트 저장이 로컬 트랜잭션으로 처리됨 (한 트랜잭션)
트랜잭션에 성공하면 이벤트가 저장소에 보관되는 것을 보장
장점
물리적 저장소에 이벤트를 저장하므로, 이벤트 핸들러가 처리에 실패해도 다시 저장소에서 읽어와 실행 가능
구현
이벤트 저장소와 이벤트 포워더 사용하기
포워더가 주기적으로 이벤트 저장소에서 이벤트를 가져와 이벤트 핸들러 실행
포워더는 별도의 스레드 이용
@Scheduled 로 주기적으로 이벤트를 읽고 전달
이벤트를 어디까지 처리했는지 이벤트 포워더가 추적해야 함
DB 테이블이나 로컬 파일에 마지막 offset 값 보관
이벤트 저장소와 이벤트 제공 API 사용하기
외부 핸들러가 API 서버를 통해 이벤트 목록을 가져감
어디까지 처리했는지 이벤트 목록을 요구하는 외부 핸들러가 기억해야 함
lastOffset: 마지막에 처리한 데이터의 offset을 기억
이벤트 적용 추가 고려사항
이벤트 발생 주체를 EventEntry에 추가할지 여부
e.g “Order가 발생시킨 이벤트만 조회하기” 기능이 필요하다면 이벤트 발생 주체 정보를 추가
포워더에서 전송 실패를 얼마나 허용할 것인지
특정 이벤트에서 계속 전송에 실패하면, 해당 이벤트 때문에 나머지 이벤트를 전송할 수 없게됨
실패한 이벤트의 재전송 횟수 제한을 두자
e.g. 동일 이벤트 전송 3회 실패 시, 해당 이벤트를 생략하고 다음 이벤트로 넘어가는 정책
처리 실패 이벤트를 별도 실패용 DB나 메시지 큐에 저장하는 것도 좋음
물리적 저장소에 남겨두면 실패 이유 분석이나 후처리에 도움이 됨
이벤트 손실
이벤트 저장소 사용 방식은 이벤트 저장을 보장 (도메인 로직과 한 트랜잭션에서 실행됨)
반면, 로컬 핸들러 방식은 이벤트 처리에 실패하면 이벤트를 유실하게 됨
이벤트 순서
이벤트 저장소: 이벤트 발생 순서대로 처리 가능
메시징 시스템: 기술에 따라 이벤트 발생 순서와 메시지 전달 순서가 다를 수 있음
(동일한) 이벤트 재처리
마지막으로 처리한 이벤트의 순번을 기억하고 이미 처리한 순번의 이벤트가 도착하면 무시하기
e.g.
회원 가입 신청 이벤트가 처음 도착하면 이메일을 발송
동일 순번 이벤트가 다시 들어오면 이메일 발송 X
이벤트를 멱등으로 처리
동일 이벤트를 한 번 적용하나 여러 번 적용하나 시스템이 같은 상태가 되도록 핸들러 구현
시스템 장애로 동일 이벤트 중복 발생이나 처리가 발생해도 부담이 감소
e.g.
배송지 정보 변경 이벤트를 받아서 주소를 변경하는 핸들러
DB 트랜잭션 실패와 이벤트 처리 실패
동기든 비동기든 트랜잭션 실패와 이벤트 처리는 함께 고려해야 함
다만, 경우의 수가 복잡하다
e.g. 주문 취소 상태 변경과 결제 외부 API 환불 처리 실패 시나리오 예시
동기: 결제 취소 API 호출이 완료됐는데, DB 트랜잭션에 실패해 취소 상태가 안됨
비동기: 주문은 취소 상태로 DB 반영 됐는데, 결제는 취소되지 않음
경우의 수 줄이기: 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하도록 처리
트랜잭션 실패 경우의 수가 줄어 이벤트 처리 실패만 고민하면 됨
이벤트 핸들러를 실행했는데, 트랜잭션이 롤백되는 상황은 없어짐!
방법
@TransactionalEventListener 적용
스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 해줌
@TransactionalEventListener(classes = OrderCanceledEvent.class, phase = TransactionPhase.AFTER_COMMIT) (TransactionPhase.AFTER_COMMIT)
스프링은 트랜잭션 커밋에 성공한 뒤, 핸들러 메서드를 실행
중간에 예외 발생으로 롤백 시, 핸들러 메서드 실행 X
이벤트 저장소로 DB를 사용하는 것도 동일한 효과 발생
트랜잭션 성공할 때만 이벤트가 DB에 저장되므로
CQRS (Command Query Responsibility Segregation)
명령을 위한 모델과 조회를 위한 모델을 분리하는 패턴
명령(Command): 상태를 변경
주로 한 애그리거트의 상태를 변경
e.g. 새 주문 생성, 배송지 정보 변경, 회원 암호 변경
ORM이 적합: 객체 지향으로 도메인 모델을 구현하기가 편리
조회(Query): 상태를 제공
2개 이상의 애그리거트가 필요할 때가 많음
e.g. 주문 상세 내역 보기, 게시글 목록 보기, 회원 정보 보기, 판매 통계 보기
ORM이 부적합: 애그리거트들이 분리되어 있어, 성능 고려 필요
응용 서비스 계층 없이 컨트롤러에서 바로 DAO 실행해도 무방
장점
명령 모델 구현 시 도메인 자체에 집중 가능 (조회 성능 위한 코드 분리)
복잡한 도메인에 적합
단일 모델로 처리하면, 조회 기능의 로딩 속도를 위해 도메인 모델이 매우 복잡해짐
조회 성능 향상에 유리 (자유롭게 성능 향상 시도 가능)
각 모델에 맞는 구현 기술 선택 가능
e.g.1 명령 모델은 JPA, 조회 모델은 MyBatis…
e.g.2 명령 모델은 트랜잭션 지원 RDBMS, 조회 모델은 조회 성능이 좋은 메모리 기반 NoSQL
대규모 웹 서비스는 조회 성능 개선을 위해 암묵적으로 이미 CQRS를 적용
웹 서비스는 상태 변경 요청보다 조회 요청이 월등히 많음
쿼리 최적화, 캐싱, 조회 전용 저장소 사용 등의 기법을 적용하게 됨
다만, 명시적으로도 CQRS 적용하는 것이 좋음
단점
구현 코드가 더 많아짐
도메인이 복잡하거나 대규모 트래픽이 발생하는 서비스 -> 유지보수에 유리
도메인이 단순하거나 트래픽이 많지 않은 서비스 -> 비용 상승, 이점이 적음
더 많은 구현 기술 필요
다른 구현 기술, 다른 저장소, 데이터 동기화를 위한 메시징 시스템 도입 등
중요: 명령 모델과 조회 모델 간 데이터 동기화는 이벤트를 활용해 처리하자
명령 모델에서 상태 변경 후 이벤트 발생 -> 조회 모델에 이벤트를 전달해 변경 내역 반영
DB 저장소가 다른 경우, 동기화 시점에 따라 구현이 달라짐
실시간 동기화: 동기 이벤트 or 글로벌 트랜잭션 사용 (성능은 떨어지는 단점)
특정 시간 안에만 동기화: 비동기로 데이터 전송
Reference
도메인 주도 개발 시작하기
NHN FORWARD 22 - DDD 뭣이 중헌디? 🧐
Domain Driven Design – 1부 (Strategic Design)
DDD 이야기 part1
-
만들면서 배우는 클린 아키텍처
주요 도메인 중심 아키텍처 용어 기원
아키텍처 선택 가이드: 도메인 코드가 애플리케이션에서 가장 중요하면 사용하자
종류
클린 아키텍처 - 로버트 마틴 (Robert C. Martin)
도메인 중심 아키텍처들에 적용되는 원칙을 제시 (추상적)
도메인 중심의 아키텍처들은 DDD의 조력자
육각형 아키텍처 (Hexagonal Architecture) - 알리스테어 콕번 (Alistair Cockburn) (구체적)
도메인 주도 설계 (DDD, Domain Driven Design)- 에릭 에반스 (Eric Evans)
전통적인 계층형 아키텍처의 문제
일반적인 3계층 아키텍처
올바르게 계층을 구축하고 추가 아키텍처 강제 규칙 적용하면 쉽게 기능 추가 및 유지보수 가능
웹계층이나 영속성 계층에 독립적으로 도메인 로직 작성 가능
아래 문제점만 극복할 수 있다면, 좋은 코드 유지 가능 (강제가 없어 어려울 뿐)
문제점: 장기적으로 나쁜 방향의 코드를 쉽게 허용
의존성 방향으로 인해 데이터베이스 주도 설계를 유도
비즈니스 관점에서 도메인 로직을 먼저 만들어야하지만, 영속성 계층을 먼저 구현하게 됨
도메인 계층이 영속성 계층의 ORM 엔터티를 사용해 강한 결합 발생
강제가 적어 지름길을 택하기 쉬워짐
전통 계층형 아키텍처의 유일한 규칙: 같은 계층 혹은 아래 계층에만 의존 가능
필요한 상위 컴포넌트를 계속 아래로 내리기 쉬움 (e.g. 영속성 계층에 몰리는 헬퍼, 유틸리티)
아키텍처 규칙 강제 필요성 (빌드 실패 수준으로 관리)
테스트가 어려워짐
계층 건너뛰기(웹 계층 -> 영속성 계층) 시, 종종 웹 계층으로 도메인 로직 책임이 전파
웹 계층 테스트에서 영속성 계층까지 모킹해야 해서, 테스트 복잡도 상승
유스케이스를 숨김
도메인 로직이 여러 계층에 흩어짐 -> 새로운 기능을 추가할 위치 찾기가 어려움 (수직적 측면)
여러 개 유스케이스 담당하는 넓은 서비스 허용 -> 작업할 서비스 찾기 어려움 (수평적 측면)
동시 작업 지원 아키텍처는 아님 (병합 충돌)
영속성 -> 도메인 -> 웹 순으로 개발하므로, 특정 기능은 동시에 1명의 개발자만 작업 가능
현재 넓은 서비스라면, 다른 유스케이스 작업도 같은 서비스에서 동시에 하게 됨
클린 아키텍처의 핵심 토대
단일 책임 원칙(SRP)
일반적 정의: 하나의 컴포넌트는 한 가지 일만 해야 한다.
실질적 정의: 컴포넌트를 변경하는 이유는 오직 하나뿐이어야 한다. (책임 = 변경할 이유)
컴포넌트를 변경할 이유가 한 가지라면, 다른 곳 수정 시 해당 컴포넌트를 신경쓸 필요가 없음
의존성 역전 원칙(DIP)
정의: 코드상의 어떤 의존성이든 그 방향을 바꿀 수 있다. (서드파티 라이브러리 제외)
상위 계층의 변경할 이유를 줄임
e.g. 영속성 계층에 대한 도메인 계층의 의존성
클린 아키텍처 (Clean Architecture)
핵심
의존성 규칙: 계층 간의 모든 의존성이 도메인 코드로 향해야 한다.
의존성 역전 -> 영속성과 UI 문제로부터 도메인 로직의 결합 제거
-> 변경 이유의 수 감소
-> 유지보수성 향상
외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있음
구조
애플리케이션 코어
도메인 엔터티: 비즈니스 규칙에 집중
유스케이스: 도메인 엔터티에 접근 가능
비즈니스 규칙 지원 컴포넌트 (컨트롤러, 게이트웨이, 프레젠터 등)
바깥쪽 계층: 다른 서드파티 컴포넌트에 어댑터 제공
대가
엔터티에 대한 모델을 각 계층에서 따로 유지보수해야 함 (통신할 때는 매핑 작업 필요)
하지만, 결합이 제거되어 바람직한 상태
e.g. ORM 엔터티는 기본생성자를 강제하지만 도메인 모델에는 필요없음
유의사항
의존성 역전은 실제로 유스케이스와 영속성 어댑터 간 적용 (의존성 방향이 코어로 향하도록)
육각형 아키텍처 (Hexagonal Architecture)
클린 아키텍처 원칙들에 부합하는 구체적 아키텍처 중 하나
포트와 어댑터(ports-and-adapters) 아키텍처로도 불림
핵심
클린 아키텍처의 의존성 규칙 그대로 적용 (모든 의존성은 코어로 향한다.)
의존성 역전 -> 영속성과 UI 문제로부터 도메인 로직의 결합 제거
-> 변경 이유의 수 감소
-> 유지보수성 향상
외부의 영향을 받지 않고 도메인 코드를 자유롭게 발전시킬 수 있음
구조
애플리케이션 코어
도메인 엔터티
유스케이스: 도메인 엔터티와 상호작용
어댑터
애플리케이션과 다른 시스템 간의 번역을 담당
e.g. 웹 어댑터, 영속성 어댑터, 외부 시스템 어댑터
분류
주도하는 어댑터(driving adapter) = 인커밍 어댑터 = 왼쪽 어댑터
애플리케이션 코어를 호출 (in)
주도되는 어댑터(driven adapter) = 아웃고잉 어댑터 = 오른쪽 어댑터
애플리케이션 코어가 호출 (out)
포트
애플리케이션 코어와 어댑터들 간의 통신을 위한 인터페이스
분류
입력 포트(in) = 인커밍 포트
주도하는 어댑터가 호출하는 인터페이스
구현체: 코어 내 유스케이스 클래스
출력 포트(out) = 아웃고잉 포트
애플리케이션 코어가 호출하는 인터페이스
구현체: 어댑터 클래스
계층 분류
어댑터 계층: 어댑터
애플리케이션 계층: 포트 + 유스케이스 구체 클래스(Service)
도메인 계층: 도메인 엔터티
유의사항
의존성 역전은 실제로 유스케이스와 주도되는 어댑터 간에 적용됨 -> 의존성 방향이 코어로 향하도록
주도하는 어댑터는 원래 의존성 방향이 코어로 향함 -> 인터페이스는 단순 진입점 구분 역할
표현력 있는 패키지 구조
표현력 있는 패키지 구조는 각 요소들을 패키지 하나씩에 직접 매핑
아키텍처-코드 갭을 완화시킴
아키텍처에 대한 적극적인 사고를 촉진
-> 의사소통, 개발, 유지보수 모두 조금 더 수월해짐
분류
엔터티: domain - Account, Activity
유스케이스: application - SendMoneyService
인커밍 포트: application - port - SendMoneyUseCase
아웃고잉 포트: application - port - LoadAccountPort, UpdateAccountStatePort
인커밍 어댑터: adapter - in - web - AccountController
아웃고잉 어댑터: adapter - out - persistence - AcountPersistenceAdapter
고려사항
접근 제한자로 계층 사이 불필요한 의존성 예방 가능 (e.g. 도메인의 영속성 계층 의존)
port만 public 두기
나머지는 모두 package-private
DDD 개념과 직접적 대응 가능
상위 레벨 패키지를 바운디드 컨텍스트로 활용 가능 (e.g. account)
책임을 좁히는 유스케이스명을 사용하자 (로버트 마틴, 소리치는 아키텍처)
AccountService 보다 SendMoneyService 인터페이스명이 좋음 (송금하기 유스케이스)
모든 계층에 의존성을 가진 중립적인 컴포넌트를 도입해 의존성 주입하자
아키텍처를 구성하는 대부분의 클래스를 초기화해 인스턴스 주입
e.g. AccountController, SendMoneyService, AccountPersistenceAdapter
유스케이스 구현하기
입력 유효성 검증 & 비즈니스 규칙 검증
두 검증 모두 비즈니스 규칙으로 다루는게 옳을 수도 있으나 구분하면 유지보수성이 향상됨
구분 방법
도메인 모델의 현재 상태에 접근하는가?
O -> 비즈니스 규칙 검증
e.g. 출금 계좌는 초과 출금되어서는 안된다.
X -> 입력 유효성 검증
e.g. 송금되는 금액은 0보다 커야 한다.
전략
유스케이스 -> 비즈니스 규칙 검증 책임 O, 입력 유효성 검증 책임 X -> 도메인 엔터티 내에 두기
애플리케이션 계층 -> 입력 유효성 검증 책임 O -> 입력 모델에서 다루자!
유효하지 않은 입력값이 코어로 향해 모델의 상태를 해치는 것을 막아야 함
유스케이스 구현체 주위에 잘못된 입력에 대한 보호막 형성 (소위 오류 방지 계층)
e.g.
SendMoneyCommand 생성자에서 처리 (final 필드 사용하면 생성 후 변경 막음)
규칙 예: 모든 파라미터는 null이 아니어야함
규칙 예: 송금액은 0보다 커야함
Bean Validation API 사용도 좋음
없는 것만 직접 구현 (requireGreaterThan())
비즈니스 규칙을 도메인 엔터티 내에서 다루기 애매할 때는 유스케이스 코드에서 진행
검증 실패 시 유효성 검증 전용 예외 던지기
유스케이스마다 각각 입력 모델, 출력 모델 두기
유스케이스가 보다 명확해짐
다른 유스케이스와의 결합 방지 -> 유지보수성 향상
읽기는 전용 쿼리 서비스 두기
읽기 작업은 유스케이스가 아니고 간단한 데이터 쿼리
따라서, 쿼리를 위한 전용 인커밍 포트를 만들고 쿼리 서비스(query service)에 구현하기 (CQS)
e.g.
GetAccountBalanceQuery 인터페이스 (인커밍 포트)
GetAccountBalanceService 구현체, getAccountBalance() 메서드
풍부한 도메인 모델 VS 빈약한 도메인 모델
도메인 로직이 도메인 엔터티에 많다면 DDD 철학을 따르는 풍부한 도메인 모델이 된다.
반면에, 유스케이스에 도메인 로직이 몰리면 빈약한 도메인 모델이 된다.
물론, 스타일에 따라 선택이 가능한 부분이다.
웹 어댑터 구현하기
인커밍 포트의 필요성 (feat. 의존성 방향이 올바름에도 사용하는 이유)
계층 진입점의 명세가 되어 유지보수 정보가 됨
웹 소켓 시나리오의 경우 반드시 포트가 필요
인커밍 포트이자 아웃고잉 포트가 되므로
유스케이스 입력 모델과 다른 구조와 의미를 가지는 유효성 검증 필요
웹 어댑터의 입력 모델이 유스케이스의 입력 모델로 변환할 수 있다는 것을 검증
컨트롤러 나누기
컨트롤러는 가능한 좁고 너무 많은게 낫다! (기본적으로 클래스 코드는 적을수록 좋음!)
각 연산에 대해 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식이 좋음
e.g. AccountController로 모두 묶는 것 보다 SendMoneyController가 나음
메서드와 클래스 명은 유스케이스를 최대한 반영해서 짓기
e.g. CreateAccount 보다 RegisterAccount가 더 나을 수 있음
각 컨트롤러가 별도의 입출력 모델을 가지는게 좋음!
e.g. CreateAccountResource, UpdateAccountResource
컨트롤러 나누기는 동식 작업 시 병합 충돌 예방에도 도움이 됨
영속성 어댑터 구현하기
입출력 모델은 애플리케이션 코어에 위치한 도메인 엔터티나 DTO (포트에 지정)
입출력 모델이 애플리케이션 코어에 위치 -> 어댑터 내부 변경이 코어에 영향을 주지 않음
다만, 어댑터에서 매핑 작업이 필요
입력 모델 -> JPA 엔터티
JPA 엔터티 -> 출력 모델
좁은 포트 인터페이스 지향하기
일반적인 방법은 엔터티를 기준으로 모든 연산을 하나의 리포지토리에 모아두는 것
기본은 도메인 클래스 혹은 DDD 애그리거트 당 하나의 영속성 어댑터 구현하기
추가적으로, JPA 어댑터 & SQL 어댑터 구성도 가능 (성능 개선 위해)
그렇다면, 인터페이스 분리 원칙(ISP)에 따라 좁은 포트 인터페이스 지향하자!
항상은 아니더라도 가능한 각 서비스가 자신에게 필요한 메서드만 알면 되도록 하자
장점: 코드 가독성 향상, 테스트 편리
e.g. AccountRepository -> LoadAccountPort, UpdateAccountStatePort, CreateAccountPort
테스트 성공의 기준
테스트는 얼마나 마음 편하게 소프트웨어를 배포할 수 있는냐를 성공 기준으로 삼으면 된다.
초기 몇 번의 프로덕션 배포 시 버그가 나온다면, 해당 케이스를 추가해서 개선하면 된다.
시간이 걸릴지라도 결국은 유지보수를 위한 옳은 길이 될 것이다.
경계 간 매핑하기
매핑에 대한 찬성과 반대
찬성
두 계층 간에 매핑이 없으면 서로 같은 모델을 사용하게 되어, 두 계층이 강하게 결합된다.
반대
매핑이 있으면 모델이 각 계층에 있어 보일러플레이트 코드가 많아진다.
e.g. 간단한 CRUD 유스케이스
적절한 사용 원칙
유스케이스마다 적절한 전략을 선택하거나 섞어 써야 한다! (한 전략을 철칙으로 여겨선 안됨!)
좁은 포트를 사용하면 유스케이스마다 다른 매핑 전략 사용 가능
어려운 방법이지만, 정확히 해야하는 일만 수행하면서도 유지보수하기 쉬운 코드가 될 것
간단한 전략으로 시작해서 복잡한 전략으로 갈아타는 것도 좋은 방법
많은 유스케이스들은 간단한 CRUD에서 점차 풍부한 비즈니스 유스케이스로 변화
어떤 매핑 전략을 선택했더라도 나중에 언제든 바꿀 수 있음!
팀 내 합의할 수 있는 가이드라인을 정해둬야 함
가이드 라인 예시
변경 유스케이스
웹 계층 & 애플리케이션 계층 사이
1순위 전략: 완전 매핑
유효성 검증 규칙이 명확해짐
애플리케이션 계층 & 영속성 계층 사이
1순위 전략: 매핑하지 않기
매핑 오버헤드 줄이고 빠르게 코드 짜기 위해
2순위 전략: 양방향 매핑
애플리케이션 계층에서 영속성 문제를 다루면 결합 제거가 우선
쿼리 작업
웹 계층 & 애플리케이션 계층 사이 + 애플리케이션 계층 & 영속성 계층 사이
1순위 전략: 매핑하지 않기
매핑 오버헤드 줄이고 빠르게 코드 짜기 위해
2순위 전략: 양방향 매핑
애플리케이션 계층에서 웹 문제, 영속성 문제를 다루면 결합 제거가 우선
4가지 전략
매핑하지 않기 (No Mapping)
모든 계층이 도메인 모델을 입출력 모델로 사용
장점
간단한 CRUD 유스케이스에는 유용 (모든 계층이 정확히 같은 구조와 정보 띄는 상황)
단점: 단일 책임 원칙 위반
도메인과 애플리케이션 계층이 웹이나 영속성 관련 특수 요구사항에 오염됨
웹이나 영속성 관련 이유로 변경될 가능성 생김
애플리케이션 및 도메인 계층이 웹과 영속성 문제를 다루면 바로 다른 전략을 취해야 함
양방향 매핑 (Two-Way)
각 어댑터가 전용 모델을 사용하고 포트 전달 전에 계층 내에서 매핑 실행
웹 계층에서는 웹 모델 -> 도메인 모델, 도메인 모델 -> 웹 모델 매핑 진행
영속성 계층도 마찬가지
장점: 단일 책임 원칙 준수
한 계층의 전용 모델을 변경하더라도 다른 계층에는 영향이 없음 (깨끗한 도메인 모델)
단점
보일러플레이트 코드가 많아짐
아주 간단한 CRUD 유스케이스에선 개발을 더디게 함
인커밍 포트와 아웃커밍 포트에서 도메인 모델이 계층 경계를 넘어 통신에 사용됨
도메인 모델이 바깥쪽 계층의 요구에 따른 변경에 취약해짐
완전 매핑 (Full)
각 계층이 전용 모델을 가짐
입력 모델은 command, request 등 네이밍 사용)
웹 계층은 입력을 커맨드 객체로 매핑할 책임을 가짐
애플리케이션 계층은 커맨드 객체를 도메인 모델로 매핑할 책임을 가짐
전역 패턴으로는 비추천
웹 계층(혹은 인커밍 어댑터)과 애플리케이션 계층 사이를 추천
애플리케이션 계층과 영속성 계층 사이에서는 매핑 오버헤드로 비추천
장점: 유지보수하기가 훨씬 쉬움
단점: 보일러플레이트 코드가 많아짐
단방향 매핑 (One-Way)
모든 계층의 모델들이 같은 인터페이스를 구현 (몇몇 getter 메서드를 제공하는 인터페이스)
도메인 모델은 풍부한 행동을 구현
도메인 객체는 매핑 없이 바깥 계층으로 전달 가능
바깥 계층에서는 상태 인터페이스를 이용할지, 전용 모델로 매핑할지 결정
DDD의 팩터리(factory) 개념과 어울림
장점: 계층 간 모델이 비슷할 때 효과적
읽기 전용 연산은 전용 모델 매핑 없이 상태 인터페이스만으로 충분
단점: 매핑이 계층을 넘나들며 퍼져 있어, 개념적으로 어려움
애플리케이션 조립하기
설정 컴포넌트
코드 의존성이 올바른 방향을 가리키게 하기 위해서 필요
책임
모든 클래스에 대한 의존성을 가지고 객체 인스턴스를 생성할 책임을 가짐
런타임에 애플리케이션 조립에 대한 책임을 가짐
의존성 주입 메커니즘으로 런타임에 필요한 곳에 객체 주입
설정 파일, 커맨드라인 파라미터 등에도 접근
장점
단일 책임 원칙을 위반하지만, 덕분에 애플리케이션의 나머지 부분을 깔끔하게 유지 가능
테스트가 쉬워짐
@Component를 포함한 커스텀 애노테이션
컴포넌트 스캔 사용 시, @PersistenceAdapter, @WebAdapter 등의 커스텀 애노테이션을 만들어 적용하면 아키텍처 구조를 더욱 쉽게 파악할 수 있다.
아키텍처 경계 강제하기
‘경계를 강제한다’
의존성이 올바른 방향을 향하도록 강제하는 것
시간이 지나면서 아키텍처가 서서히 무너지는 것을 방지 -> 유지보수하기 좋은 코드
아래 3가지 경계 강제 방법을 조합해 사용
방법 1: 접근 제한자
전략
도메인 엔터티 및 포트만 public으로 열고, 나머지 모두 package-private으로 진행
컴포넌트 스캐닝만 가능 (빈 수동 등록은 public 열지 않으면 불가)
package-private
자바 패키지를 통해 클래스들을 모듈로 만들어주므로 중요
모듈의 진입점으로 활용할 클래스만 골라서 public으로 만들면 됨
의존성 규칙 위반 위험이 감소
단점
모듈 내 클래스가 많아지면 하위 패키지를 만들어야 함
-> 계층 내 의존이 불가해져 public 열어야 함
-> 아키텍처 의존성 규칙이 깨질 환경 조성됨
방법 2: 컴파일 후 체크 (post-compile check)
컴파일러로는 경계 강제 불가할 경우, 런타임에 체크 시도
ArchUnit 사용하기
의존성 방향이 예상대로 설정됐는지 체크할 수 있는 API
계층 간 의존성 확인 테스트를 추가해 확인
단점
타입 세이프하지 않음 (오타나 패키지명 리팩터링에 취약)
항상 코드와 함께 유지보수해야 함
방법 3: 빌드 아티팩트를 분리하기
각 모듈 혹은 계층에 대해 각각 빌드 모듈(JAR 파일)을 만들어 계층 간 의존성 강제 가능
빌드 스크립트에 아키텍처에서 허용하는 의존성만 지정
장점
순환 의존성 방지 보증
다른 모듈을 고려하지 않고 특정 모듈 코드만 격리한 채로 변경 가능
새로운 의존성 추가 시 항상 의식적으로 행동하게 됨
의식적으로 지름길 사용하기
깨진 창문 이론
인간의 뇌는 망가져 있는 것을 보면 더 망가뜨려도 된다고 생각한다.
지름길을 거의 사용하지 않고 깨끗하게 프로젝트를 시작하고 유지하는 것이 중요
지름길을 취하는 것이 가끔 실용적일 때가 있음
프로젝트 전체에서 중요하지 않은 부분, 프로토타이핑, 경제적 이유…
의도적인 지름길은 세심하게 기록해두자! -> 팀원이 이를 인지하면 깨진 창문 이론이 덜할 것
유스케이스가 단순한 CRUD 상태에서 벗어나는 시점을 잘 파악해 리팩토링하자!
단순한 CRUD 상태에서 더이상 벗어나지 않는 유스케이스는 그대로 유지하는게 더 경제적
지름길 발생 예시
유스케이스 간 입출력 모델 공유 (지양)
특정 요구사항을 공유할 때만 괜찮음 -> 다만 둘이 독립적이라면 분리하는게 맞음
도메인 엔터티를 입출력 모델로 사용하기 (지양)
인커밍 포트 건너뛰기 (지양)
인커밍 포트는 진입점 식별에 중요
애플리케이션 서비스 건너뛰기 (지양)
간단한 CRUD에서는 고려해볼 수 있음
아웃고잉 어댑터 클래스가 애플리케이션 서비스의 인커밍 포트 구현
도메인 모델을 입력 모델로 사용
도메인 로직이 생기면 바로 애플리케이션 서비스 계층 사용할 것
Reference
만들면서 배우는 클린 아키텍처
-
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 개발과 성능 최적화
-
단위 테스트 (Unit Testing) - 가치 있는 테스트 작성하기
단위 테스트 스타일과 함수형 아키텍처
단위 테스트 스타일 종류
출력 기반 테스트 (output-based testing, 함수형)
SUT에 입력을 넣고 출력을 점검하는 방식
사이드 이펙트 X, 반환 값만 검증
e.g.
decimal discount = sut.CalculateDiscount(product1, product2)
Assert.Equal(0.02m, discount)
상태 기반 테스트 (state-based testing)
작업이 완료된 후 시스템 상태를 확인하는 방식
상태: SUT, 협력자, 프로세스 외부 의존성의 상태 (DB, 파일 시스템)
e.g.
sut.AddProduct(product)
Assert.Equal(1, sut.Products.Count)
통신 기반 테스트 (communication based testing)
목을 사용해 SUT와 협력자 간의 통신을 검증
e.g
emailGatewayMock.Verify(x => x.SendGreetingsEmail(), Times.Once)
단위 테스트 4대 요소 통한 비교
결론: 항상 출력 기반 테스트를 선호하자
객체 지향에 적용이 어렵지만, 테스트를 출력기반 스타일로 변경하는 기법 사용하면 됨
리팩토링 내성
출력 기반 테스트가 가장 우수
테스트가 테스트 대상 메서드에만 결합해 거짓 양성 방지 탁월
상태 기반 테스트, 통신 기반 테스트는 취약
테스트가 구현 세부 사항에 결합될 가능성 높음
유지 보수성
출력 기반 테스트가 가장 우수
거의 항상 짧고 간결하므로 유지보수가 쉬움
전역 상태나 내부 상태를 변경할리 없으므로, 프로세스 외부 의존성 X
상태 기반 테스트, 통신 기반 테스트는 취약
상태 기반 테스트, 통신 기반 테스트는 검증부가 커짐
보완 1: 검증부 헬퍼 메서드 -> 메서드 유지와 재사용에 대한 명분이 드물 것
보완 2: 검증 대상 클래스에 동등 멤버 정의 -> 코드 오염 (Code Pollution)
회귀 방지와 빠른 피드백은 단위 테스트 스타일과 관련이 적음
함수형 프로그래밍
사이드 이펙트가 없는 순수 함수 코드를 강조하는 프로그래밍 방식
숨은 입출력이 없어야 함
사이드 이펙트(인스턴스 상태 변경, 파일 I/O), 예외, 내외부 상태 참조(비공개 속성, DB 조회)
지향점
비즈니스 로직 코드와 사이드 이펙트 발생 코드를 분리
어떤 사이드 이펙트도 일으키지 않는 애플리케이션은 불가능
함수형 아키텍처
사이트 이펙트 코드를 최소화하고 순수 함수 방식 코드를 극대화하는 방식
구성
함수형 코어 (functional core, immutable core)
결정을 내리는 코드
수학적 함수로 작성
가변 셸 (mutable shell)
해당 결정에 따라 작용하는 코드 (실행)
수학적 함수에 의해 이뤄진 모든 결정을 가시적으로 변환 (DB 변경, 메시지 버스 전송)
협력 과정
가변 셸이 모든 입력 수집
-> 함수형 코어는 결정을 생성
-> 가변 셸은 결정을 사이드 이펙트로 변환
헥사고날 아키텍처과의 공통점 및 차이점
헥사고날 아키택처 ⊃ 함수형 아키텍처 (극단적으로 함수형 아키텍처 = 헥사고날 아키텍처)
공통점
관심사 분리 측면
도메인 : 애플리케이션 서비스 = 함수형 코어 : 가변 셸
단방향 의존성 흐름
차이점
사이드 이펙트 처리
헥사고날 아키텍처는 도메인 계층 내라면 사이드 이펙트 허용
함수형 아키텍처는 모든 사이드 이펙트를 함수형 코어 밖 가장자리로 밀어냄
단점
적용 불가 상황 존재
의사 결정 절차 중간에 프로세스 외부 의존성을 조회 시, 출력 기반 테스트 적용 불가
e.g.
DB에 있는 방문자의 접근 레벨을 중간에 조회해야할 때
public FileUpdate AddRecord(..., IDatabase database) {...}
도메인은 절대로 DB에 의존해서는 안됨
해결책
애플리케이션 서비스 전면부에서 방문자 접근 레벨도 수집
접근 레벨이 필요 없어도 DB 조회하므로 성능 저하
그럼에도 사이드 이펙트 분리 유지 가능한 장점
AuditManager에 IsAccessLevelCheckRequired() 메서드 두기
애플리케이션 서비스에서 AddRecord() 전에 호출
true 반환 시 AddRecord()에 접근 레벨 전달
분리를 다소 완화하고 성능 향상 (필요할 때만 DB 조회)
성능 감소
함수형 아키텍처를 지키다보면 시스템이 외부 의존성을 더 많이 호출
e.g. 초기 버전과 목 버전과 달리 최종 버전은 디렉토리에서 모든 파일을 읽음
결론: 성능이 영향이 미미하다면 유지보수성을 택하는게 나음
코드베이스 크기 증가
함수형 아키텍처는 코드 복잡도가 낮아지고 유지보수성이 향상되지만 초기 코딩이 증가
복잡도가 낮은 간단한 프로젝트는 초기 투자가 타당 X
단위 테스트 스타일 선택 전략
최대한 출력 기반 테스트 지향
함수형 코어는 출력 기반 테스트로, 가변 셸은 훨씬 더 적은 수의 통합 테스트로 다루기
함수형 프로그래밍을 활용해 기반 코드가 함수형 아키텍처 지향하도록 재구성
출력 기반 스타일 변환
사이드 이펙트를 비즈니스 연산 끝으로 몰아서 비즈니스 로직을 사이드 이펙트와 분리
e.g. 파일 I/O가 섞인 코드 (AuditManger)
초기
도메인 객체 AuditManger는 파일 I/O 코드를 품고 있음
테스트도 파일 I/O로 검증 (단위 테스트 X, 통합 테스트 O)
방법 1: 파일 I/O를 목으로 대체해 주입하기 (목 사용 테스트)
방법 2: I/O 담당 클래스로 따로 만들어 외부로 빼기 (출력 기반 테스트)
= 사이드 이펙트 외부 추출
AuditManager (함수형 코어) - Persister (가변 셸)
AuditManager는 new FileUpdate() 식으로 업데이트 명령 반환
추가할만한 사항
삭제 유스케이스가 있다면 FileAction & ActionType Enum 처리
오류처리 필요시 예외 클래스를 만들어 반환
간헐적으로 상태 기반 테스트, 통신 기반 테스트 사용
객체 지향은 모든 테스트를 출력 기반 전환 불가
e.g. User 클래스의 email, type 속성 변경
상태 기반 테스트지만 사이드 이펙트가 메모리에 남아 있어 테스트 용이성 향상
최대한 출력 기반 테스트로 전환하되 비용에 따라 상태, 통신 기반 테스트를 적절히 섞자
스타일과 단위 테스트 분파
두 분파는 출력 기반 테스트를 사용
고전파는 상태 기반 테스트 선호, 런던파는 통신 기반 테스트 선호
코드 오염 (Code Pollution)
단위 테스트를 가능하게 하거나 단순화하기 위한 목적만으로 제품 코드베이스를 오염시키는 것을 말한다.
가치 있는 테스트를 위한 리팩토링
제품 코드의 4가지 유형
분류 기준
코드 복잡도: 코드 내 의사 결정 분기 수
도메인 유의성: 코드가 프로젝트 문제 도메인에 얼마나 의미가 있는지
협력자 수: 클래스나 메서드 내에 가변 의존성 또는 프로세스 외부 의존성 수
유형
도메인 모델 및 알고리즘 (중요)
복잡한 코드와 도메인 유의성을 갖는 코드가 단위테스트에서 가장 이로움
협력자가 없어 유지비가 낮고 회귀 방지 탁월
참고: 복잡도와 도메인 유의성은 서로 독립적 (도메인 코드가 안복잡할 수 있음)
간단한 코드 (테스트 필요 X)
컨트롤러
도메인 클래스나 외부 애플리케이션 같은 다른 구성 요소의 작업 조정
협력자가 많은 코드는 테스트 비용이 많이 듦 (유지 보수성 감소)
통합 테스트로 간단히 테스트
지나치게 복잡한 코드
알고리즘과 컨트롤러로 나누어 리팩토링하자
이상적으로 여기 속하는 코드는 없어야 함
e.g. 여러 책임을 가지고 있는 덩치 큰 컨트롤러
분리 불가능한 경우도 존재하지만 분리를 지향하면 지나치게 복잡한 코드는 아닐 것!!
컨트롤러에 비즈니스 로직이 있을 수도 있음
도메인 클래스에 협력자가 하나, 둘, 심지어 셋 있을 수도 있음
그래도 프로세스 외부 의존성 및 목 사용은 지양
지나치게 복잡한 코드 분할하기
험블 객체 패턴 (Humble Object)
험블 객체(험블 래퍼)를 두고 이곳에서 중요 로직과 테스트가 어려운 의존성을 붙이는 패턴
프레임워크 의존성과 결합되어 있는 코드는 테스트가 어려움
e.g. 비동기, 멀티스레딩, 사용자 인터페이스, 프로세스 외부 의존성 통신
방법
테스트 가능한 로직을 따로 추출
험블 객체를 통해 테스트 로직과 테스트 어려운 의존성을 각각 호출
험블 객체는 오케스트레이션을 할 뿐 자체적인 로직이 없으므로 테스트할 필요 X
예시
헥사고날 아키텍처, 함수형 아키텍처와 완전히 일치!
로직: 도메인 계층, 함수형 코어
테스트하기 어려운 의존성: 애플리케이션 서비스 계층, 가변 셸
단일 책임 원칙(SRP) 관점과도 일치
비즈니스 로직과 오케스트레이션 분리
MVC(Model-View-Controller), MVP(Model-View-Presenter) 패턴
컨트롤러와 프레젠터는 험블 객체로서 모델과 뷰를 붙임
DDD의 집계 패턴 (Aggregate Pattern)
클래스를 클러스터로 묶으면 코드 베이스의 총 통신 수가 줄고 테스트 용이성 향상
지나치게 복잡한 코드 리팩토링 단계
1단계: 암시적 의존성을 명시적 의존성으로 만들기
도메인 객체 내 프로세스 외부 의존성은 인터페이스를 두어 주입 (목 방식)
e.g. 데이터베이스, 메시지 버스
통합 테스트에도 중요
그러나 도메인 모델은 프로세스 외부 의존성에 의존하지 않는 것이 깔끔
2단계: 애플리케이션 서비스 계층 도입
험블 컨트롤러로 오케스트레이션 책임을 위임
도메인 모델이 외부 시스템과 직접 통신하는 문제 극복
도메인 모델은 잘 분리되었지만 컨트롤러는 아직 복잡한 상태
3단계: 애플리케이션 서비스 복잡도 낮추기
객체 매핑 작업 추출하기
ORM 사용
원시 데이터베이스 사용 시 데이터 매핑을 위한 팩토리 클래스 작성 (in 도메인 모델)
방법
별도의 클래스 (권장)
간단한 경우, 기존 도메인 클래스의 정적 메서드
애플리케이션 서비스에서 조정
object[] userData = _database.GetUserById(userId);
User user = UserFactory.create(userData);
테스트해볼 만함
언어 혹은 프레임워크 내 숨은 분기 존재
데이터 요소 접근이나 타입 캐스팅 예외 등
오케스트레이션 처리 절충하기
비즈니스 로직과 오케스트레이션 분리는 다음 패턴에서 가장 효율적
외부 읽기 - 비즈니스 로직 실행 - 외부 쓰기
중간 결과를 바탕으로 프로세스 외부 의존성을 추가로 조회해야할 경우 존재
외부 읽기 - 비즈니스 로직 실행 - 외부 읽기 - 비즈니스 로직 실행 - 외부 쓰기
대처 방법
모든 대처 방법은 위 3가지 특성 중 2가지만 가질 수 있으므로 선택 필요
도메인 모델 테스트 유의성: 도메인 클래스 내 협력자 수와 유형 영향
컨트롤러 단순성: 분기 수 영향
성능: 프로세스 외부 의존성 호출 수
종류
의사 결정 프로세스 단계를 더 세분화하기
지나치게 복잡한 컨트롤러를 만들지만 완화 방법 사용으로 절충
CanExecute/Execute 패턴 사용
도메인 클래스 내 CanExecute() 메서드에 두기
모든 유효성 검사 진행 메서드
Execute() 및컨트롤러 둘 모두에서 호출!
비즈니스 로직이 컨트롤러로 유출되는 것을 방지 (캡슐화)
도메인 계층의 모든 결정 통합
e.g. User에 CanChangeEmail() 메서드 두기
모든 유효성 검사를 CanChangeEmail()에 두기
ChangeEmail()은 CanChangeEmail() 호출
컨트롤러도 CanChangeEmail() 호출
외부 통신 여부 결정, 성공하면 외부 통신
CanExecute/Execute 패턴 적용 불가능한 경우도 존재
파편화 로직을 컨트롤러에 넣고 통합테스트로 처리해야 함
e.g.
이메일 고유성 검증
프로세스 외부 의존성에 따른 도메인 로직 의사 결정
모든 외부 읽기 쓰기를 가장자리로 밀어내기
대부분 프로젝트에서 성능은 매우 중요하므로 고려 X
도메인 모델에 프로세스 외부 의존성 주입(내부에서 외부 읽기쓰기 결정)
비즈니스 로직과 외부 통신이 결합되므로 테스트와 유지보수 어렵
도메인 이벤트를 사용해 도메인 모델 변경 사항 추적하기
도메인 이벤트는 도메인 모델의 중요 변경 사항을 추적하고 외부에 알리는데 사용됨
e.g. 메시지 버스에 메시지를 보내서 외부에 변경 알리기
비즈니스 로직 파편화 예방
의사 결정 책임을 도메인 모델에 유지
컨트롤러로 도메인 로직이 유출되는 것을 방지
e.g. 이메일 변경이 안되었다면 이벤트만으로 이메일 메시지 전송 안할 수 있음
DB는 이메일 변경이 안되어도 매번 저장해도 됨
식별할 수 있는 동작 X, 상대적으로 성능 차이 미미
ORM 사용 시 상태 변경 없으면 DB I/O가 없어 더욱 용이
이메일 메시지 전송은 식별할 수 있는 동작이어서 조정 필요
도메인 이벤트 구현
도메인 이벤트 클래스
public class EmailChangedEvent
{
public int UserId { get; }
public string NewEmail { get; }
}
외부 시스템에 통보하는데 필요한 데이터가 포함된 클래스
e.g. 사용자 ID, Email
과거 시제 명명 (이미 일어난 일들을 나타내므로)
값 객체 (불변)
DomainEvent를 공통 클래스로 뽑아도 좋음
도메인 클래스
이벤트 컬렉션 보유 e.g. List<DomainEvent>
컨트롤러 끝에서 외부로 이벤트 발행하거나 이벤트 디스패처 사용 가능
e.g.
User 도메인 클래스 ChangeEmail() 메서드
EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
컨트롤러 끝단에서 도메인 이벤트 처리
foreach (var ev in user.EmailChangedEvents) {
_messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
}
4단계: 책임 명확히 위임하기
잘못 둔 책임은 새로운 클래스에 두어 리팩토링
e.g. Company 클래스 - ChangeNumberOfEmployees(), IsEmailCorporate()
5단계: 테스트 적용
외부 클라이언트 입장에서 식별할 수 있는 동작을 파악해 계층적으로 테스트하자!
고객(클라이언트) 입장에서 컨트롤러의 ChangeEmail() 및 메시지 버스 호출
컨트롤러(클라이언트) 입장에서 User의 ChangeEmail()
User(클라이언트) 입장에서 Company의 ChangeNumberOfEmployees(), IsEmailCorporate()
즉, 외부 계층의 관점에서 각 계층을 테스트하고, 기저 계층과의 통신(구현)은 무시
단위 테스트
User의 ChangeEmail() 테스트
Changing_email_from_non_corporate_to_corporate()
Assert.Equal(2, company.NumberOfEmployees)
Assert.Equal("new@mycop.com, sut.Email)
Assert.Equal(UserType.Employee, sut.Type)
Changing_email_from_corporate_to_non_corporate()
sut.Email.Should().Be("new@gmail.com");
sut.Type.Should().Be(UserType.Customer);
sut.EmailChangedEvents.Should().Equal(new EmailChangedEvent(1, "new@gmail.com"));
도메인 이벤트 검증
Changing_email_without_changing_user_type()
Changing_email_to_the_same_one()
Company 테스트
도메인 유의성이 있는 모든 전제 조건은 테스트 O
ChangeNumberOfEmployees() -> 전제조건: 직원수는 음수 X
도메인 유의성이 없는 전제 조건은 테스트 X
UserFactory의 Create() -> 전제조건: data.Length >= 3
User와 Company 생성자 테스트 -> 필요 X
통합 테스트
UserController의 ChangeEmail() 테스트
액티브 레코드 패턴 (Active Record pattern)
도메인 클래스가 스스로 데이터베이스를 검색하고 저장하는 방식을 말한다.
단순하고 단기적인 프로젝트에는 잘 작동하지만, 코드베이스가 커지면 확장하기 어렵다.
CanExecute/Execute 패턴 예시
도메인 클래스 내 유효성 검사를 담당하는 CanExcute()는 Execute()와 컨트롤러에서 모두 호출한다.
User 도메인 클래스
public string CanChangeEmail()
{
if (IsEmailConfirmed)
return "Can't change a confirmed email";
return null;
}
public void ChangeEmail(string newEmail, Company company)
{
Precondition.Requires(CanChangeEmail() == null);
...
}
컨트롤러
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
...
}
통합 테스트
통합 테스트: 단위 테스트가 아닌 모든 테스트
단위 테스트의 3가지 요구 사항을 하나라도 충족하지 않으면 통합테스트
단일 동작 단위를 검증
빠르게 수행
다른 테스트와 별도로 처리
통합 테스트는 시스템이 전체적으로 잘 작동하는지 확신하기 위해 사용
각 부분이 외부 시스템(DB, 메시지 버스)과 어떻게 통합되는지 확인 필요
모든 테스트는 도메인 모델과 컨트롤러에만 초점을 맞춰야 한다!
단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황 확인
통합 테스트는 주요 흐름(happy path)과 단위 테스트가 못 다루는 기타 예외 상황(edge case) 확인
비즈니스 시나리오 당 1~2개 -> 시스템 전체의 정확도 보장
통합 테스트 전략
가장 긴 주요 흐름(happy path)을 선택해 프로세스 외부 의존성과의 상호작용을 모두 확인
1개 테스트로 어렵다면 외부 통신을 모두 확인할 수 있도록 통합 테스트 추가 작성
컨트롤러에서 빠른 실패 원칙에 해당하는 예외는 통합 테스트로 다루지 말기
e.g. CanChangeEmail()는 통합 테스트 가치가 적음
애플리케이션 초반부에서 버그를 내어 데이터 손상으로 이어지지 않음
오히려 단위 테스트에서 확인하기 좋음
관리 의존성은 실제 인스턴스 사용하고, 비관리 의존성은 목으로 대체하자
프로세스 외부 의존성 유형
관리 의존성
애플리케이션을 통해서만 접근할 수 있는 의존성 ex. DB
구현 세부사항 (하위 호환 고려 X)
비관리 의존성
외부에서도 접근할 수 있는 의존성 ex. SMTP, 메시지버스
식별할 수 있는 동작 (하위 호환 유지 필요)
특이 케이스) 관리 의존성이면서 비관리 의존성인 경우
e.g. 다른 애플리케이션에서 접근할 수 있는 DB (특정 테이블 접근 권한 열어둠)
일시적 대응: 공유된 테이블을 비관리 의존성 취급하자
사실상 메시지 버스, 목 대체 필요
다만, 시스템 간 결합도와 복잡도가 증가하므로 지양
API, 메시지 버스 통신이 더 나음
실제 데이터베이스를 사용할 수 없는 경우, 통합 테스트 작성하지 말고 도메인 모델 단위 테스트에 집중
보안 혹은 비용 문제로 실제 DB를 사용할 수 없는 경우 존재
관리 의존성을 목으로 대체하면 회귀 방지에서 단위 테스트와 차이 X (리팩터링 내성도 저하)
엔드 투 엔드 테스트는 대부분의 경우 생략 가능
통합 테스트 보호 수준이 엔드 투 엔드와 비슷함 (관리 의존성 포함 및 비관리 의존성 목 대체)
배포 후 1~2개 정도의 중요한 엔드 투 엔드 테스트 작성 가능
엔드 투 엔드 테스트는 프로세스 외부 의존성을 모두 실제 인스턴스 사용해야 해 느림
검증: 메시지 버스를 직접 확인, 데이터베이스 상태는 애플리케이션을 통해 간접 확인
테스트 예시 (CRM 프로젝트)
가장 긴 주요 흐름 (Changing_email_from_corporate_to_non_corporate())
기업 이메일에서 일반 이메일로 변경하는 것
사이드 이펙트 가장 많음 (DB update, 메시지버스)
단위 테스트로 어려운 예외 상황 (이메일을 변경할 수 없는 시나리오)
테스트 필요 X, 빠른 실패 케이스
로깅(Logging) 기능 테스트?
//User 도메인 클래스
public void ChangeEmail(string newEmail, Company company)
{
_logger.Info(
$"Changing email for user {UserId} to {newEmail}"); //진단 로깅 (지양)
Precondition.Requires(CanChangeEmail() == null);
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
AddDomainEvent(
new UserTypeChangedEvent(
UserId, Type, newType)); //DomainLogger 대신 도메인 이벤트 사용
}
Email = newEmail;
Type = newType;
AddDomainEvent(new EmailChangedEvent(UserId, newEmail));
_logger.Info($"Email is changed for user {UserId}"); //진단 로깅 (지양)
}
로깅은 횡단 관심사 (코드베이스 어느 부분에서나 필요함)
로깅은 프로세스 외부 의존성에 사이드 이펙트를 초래 (텍스트 파일, DB)
사이드 이펙트를 개발자 이외 사람(API 클라이언트, 고객)이 보는 경우 -> 반드시 테스트!
지원 로깅 (support logging)
식별할 수 있는 동작
e.g. 비즈니스 요구사항이므로 명시적으로 래퍼 클래스 만들기
(IDomainLogger, DomainLogger)
public class DomainLogger : IDomainLogger
{
private readonly ILogger _logger;
public DomainLogger(ILogger logger)
{
_logger = logger;
}
public void UserTypeHasChanged(
int userId, UserType oldType, UserType newType)
{
_logger.Info(
$"User {userId} changed type " +
$"from {oldType} to {newType}");
}
}
사이드 이펙트를 개발자만 보는 경우 -> 테스트 X
진단 로깅 (diagnostic logging)
구현 세부 사항
e.g. 로그 라이브러리 그대로 사용 (ILogger)
과도한 사용 지양
도메인 모델에서 절대 사용하지 말자
컨트롤러에서 무언가를 디버깅해야 할 때만 일시적으로 사용하고 제거하자
실제 테스트
지원 로깅은
도메인 클래스에서 필요할 때 도메인 이벤트 사용해 분리
프로세스 외부 의존성(로그 저장소)이 있으므로
컨트롤러에서 필요할 때 로그 라이브러리 그대로 사용
프로세스 외부 의존성을 조정하는 곳이므로
단위 테스트는 User에서 UserTypeChangedEvent 확인
통합 테스트는 목을 사용해 DomainLogger와의 상호 작용 확인
목 사용 가치 최대화 모범 전략
비관리 의존성만 목으로 대체하기
비관리 의존성 검증에 필요한 정확도에 따라 어느 지점을 목으로 대체할지 결정해야 함
메시지 버스는 발행되는 메시지 구조가 중요하므로 통신하는 시스템 끝 마지막 타입 검증이 유리
지원 로깅은 로그의 구조가 중요하진 않으므로 IDomainLogger만 목으로 처리해도 충분
e.g. 메시지 버스 예시
IMessageBus(도메인 관련 메시지 정의 래퍼 클래스) & IBus(메시지 버스 SDK 래퍼)
public interface IMessageBus
{
void SendEmailChangedMessage(int userId, string newEmail);
}
public class MessageBus : IMessageBus
{
private readonly IBus _bus;
public void SendEmailChangedMessage(
int userId, string newEmail)
{
_bus.Send("Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}");
}
}
public interface IBus
{
void Send(string message);
}
IBus를 목으로 처리하면 회귀방지, 리팩터링 내성 극대화 가능
IBus가 비관리 의존성과 통신하는 마지막 타입
구현 세부 사항이 아닌 실제 사이드 이펙트 검증 가능
테스트 - 목 버전
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var busMock = new Mock<IBus>(); // IBus를 목으로 대체
var messageBus = new MessageBus(busMock.Object);//구체클래스
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);
/* ... */
busMock.Verify(
x => x.Send(
"Type: USER EMAIL CHANGED; " +
$"Id: {user.UserId}; " +
$"NewEmail: new@gmail.com"),
Times.Once);
}
IMessageBus 인터페이스를 삭제하고 MessageBus로 대체 가능
목 대체 목적이 사라진 IMessageBus는 구현이 하나뿐인 인터페이스
테스트 - 스파이 버전
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);
/* ... */
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
}
시스템 끝에 있는 클래스는 스파이가 목보다 나음 (스파이: 직접 작성한 목)
public interface IBus
{
void Send(string message);
}
public class BusSpy : IBus
{
private List<string> _sentMessages = new List<string>();
public void Send(string message)
{
_sentMessages.Add(message);
}
public BusSpy ShouldSendNumberOfMessages(int number)
{
Assert.Equal(number, _sentMessages.Count);
return this;
}
public BusSpy WithEmailChangedMessage(int userId, string newEmail)
{
string message = "Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}";
Assert.Contains(
_sentMessages, x => x == message);
return this;
}
}
검증 단계에서 코드를 재사용해 테스트 크기 감소
간결한 영어 문장의 플루언트 인터페이스로 가독성 향상
통합 테스트에서만 목 사용하기 (단위 테스트 X)
항상 목 호출 수 확인하기
비관리 의존성 통신에서 확인해야할 것
예상하는 호출이 있는가?
예상치 못한 호출은 없는가?
e.g. Times.Once (정확히 한 번만 전송되는지 확인하기)
e.g. messageBusMock.VerifyNoOtherCalls(); (목 라이브러리 지원)
보유 타입만 목으로 처리하기
서드파티 라이브러리 위에 항상 어댑터를 작성하고, 해당 어댑터를 목으로 처리해야 함
손상 방지 계층으로서 서드파티 라이브러리의 복잡성을 추상화하고 필요한 기능만 노출
마찬가지로 비관리 의존성에만 적용
e.g. IBus
데이터베이스 테스트 (관리 의존성)
테스트 전제 조건
형상 관리 시스템에 데이터베이스를 유지하자
데이터베이스 스키마를 일반 코드 취급해 형상 관리 시스템에 저장 (Git)
SQL 스크립트 형태 (테이블, 뷰, 인덱스, 저장 프로시저)
참조 데이터의 경우 SQL INSERT 문 형태로 함께 저장 (e.g. UserType 테이블)
모델 데이터베이스 사용은 안티패턴
데이베이스 스키마를 과거 특정 시점으로 되돌릴 수 없음 (추적 불가)
모든 개발자는 로컬에서 별도의 데이터베이스 인스턴스 사용하자
마이그레이션 기반 데이터베이스 배포 지향하자
마이그레이션 방식은 초기에는 구현과 유지보수가 어렵지만 효과적
상태 기반 방식
배포 중에 비교 도구가 스크립트를 자동 생성해 모델 DB에 맞게 운영 DB 업데이트
스크립트는 형상 관리로 저장
마이그레이션 방식
업그레이드 스크립트를 직접 작성해 형상 관리로 저장
SQL 스크립트 혹은 SQL로 변환할 수 있는 DSL 언어 사용
통합 테스트 트랜잭션 관리
대부분의 ORM은 Unit of Work 패턴을 구현
작업 단위
비즈니스 작업에서 하나의 트랜잭션으로 묶이는 데이터 변경 작업의 집합
e.g. JPA 영속성 컨텍스트
통합 테스트에서는 적어도 3개의 작업 단위를 사용하자! (준비, 실행, 검증 구절 당 하나씩)
통합 테스트는 가능한 운영 환경과 비슷해야함
같은 테스트에서 작업 단위 재사용은 운영 환경과 다른 환경을 만들어서 문제
e.g. 동일 테스트 내에서 DB에 바로 업데이트 쿼리를 날림
조회는 ORM의 1차 캐시에서 진행해서 업데이트 반영 X
검증부에서 업데이트가 안되어 테스트가 실패할 수 있음
공유 데이터베이스에서 각 통합 테스트 격리하기
통합 테스트를 순차적으로 실행하기
순차적 테스트가 병렬 테스트보다 실용적 (성능 향상 이점보다 복잡함이 큼)
대부분의 단위 테스트 프레임워크에서 기능 지원
두 가지 테스트군 만들기 (단위 테스트 & 통합 테스트)
통합테스트군은 테스트 병렬처리 비활성화하기
테스트 실행 간에 남은 데이터 제거하기
테스트 시작 시점에 데이터 정리하기 (Unit Testing 책의 권장 전략)
정리 단계를 실수로 건너 뛰지 않고 빠른 동작과 일관성을 제공
모든 통합 테스트에 기초 클래스 두고 삭제 스크립트 작성
public abstract class IntegrationTests
{
private const string ConnectionString = "...";
protected IntegrationTests()
{
ClearDatabase();
}
private void ClearDatabase()
{
string query =
"DELETE FROM dbo.[User];" +
"DELETE FROM dbo.Company;";
using (var connection = new SqlConnection(ConnectionString))
{
var command = new SqlCommand(query, connection)
{
CommandType = CommandType.Text
};
connection.Open();
command.ExecuteNonQuery();
}
}
}
데이터베이스 트랜잭션에 각 테스트를 래핑하고 커밋하지 않기 (애매)
변경 내용이 자동으로 롤백되어 정리 단계 생략 문제를 해결하고 편리
운영 환경과 다른 환경을 만듦
ReadUncommited 격리 레벨이 아닌 이상 트랜잭션 하나에서 준비, 실행, 검증 구절을 진행 -> 1차 캐시로 인한 테스트 변질 발생 가능성
테스트 종료 시점에 데이터 정리하기
빠르지만 정리 단계를 건너뛰기 쉬움
테스트 도중 중단하면 데이터가 DB에 남아 있어 이후 테스트에 영향을 줌
각 테스트 전 데이터베이스 백업 복원하기 (지양)
가장 느림
컨테이너를 사용해도 컨테이터 인스턴스 제거 및 새 컨테이너 생성에 몇 초 걸림
인메모리 데이터베이스 피하기
테스트용 DB로 SQLite 같은 인메모리 DB를 사용할 수 있음
테스트 데이터를 제거할 필요 X
빠름
테스트 실행할 때마다 인스턴스화 가능
공유 의존성 X (단위 테스트화)
일반 DB와 기능적 일관성이 없음 (운영 환경과 테스트 환경 불일치)
거짓 양성, 나아가 거짓 음성 다량 발생
테스트에서도 운영환경과 같은 DBMS를 사용하자 (버전은 달라도 괜찮, 공급업체는 같음)
테스트 구절에서 코드 재사용하기 (통합 테스트 크기 줄이기)
비즈니스와 관련 없는 기술적인 부분을 비공개 메서드 혹은 헬퍼 클래스로 추출 (재사용)
헬퍼 메서드로 작업 단위(트랜잭션 수)가 더욱 늘어날 수 있지만 유지보수성 위해 절충
준비 구절
전략
기본적으로 테스트와 동일한 클래스에 팩토리 메서드 배치
기초 클래스에 두지 말자
모든 테스트에서 실행하는 코드만 둬야 함
e.g. 데이터 정리
코드 반복 있을 시, 헬퍼 클래스 생성 및 배치
오브젝트 마더 패턴
private User CreateUser(
string email = "user@mycorp.com",
UserType type = UserType.Employee,
bool isEmailConfirmed = false)
{
using (var context = new CrmContext(ConnectionString))
{
var user = new User(0, email, type, isEmailConfirmed);
var repository = new UserRepository(context);
repository.SaveUser(user);
context.SaveChanges();
return user;
}
}
오브젝트 마더 (Object Mother) - 지향
테스트 픽스처(테스트 실행 대상)를 만드는데 도움이 되는 클래스 또는 메서드
준비 구절에서 코드 재사용 용이
테스트 데이터 빌더 패턴 - 지양
User user = new UserBuilder().WithEmail(..).WithType(..).Build();
플루언트 인터페이스 제공 (약간의 가독성 향상)
마찬가지로 준비 구절에서 코드 재사용 용이
상용구가 너무 많이 필요하므로 불편
실행 구절
private string Execute(
Func<UserController, string> func,
MessageBus messageBus,
IDomainLogger logger)
{
using (var context = new CrmContext(ConnectionString))
{
var controller = new UserController(
context, messageBus, logger);
return func(controller);
}
}
컨트롤러 정보를 받아 실행하는 헬퍼 메서드 도입 (실행 구절 줄이기)
프로세스 외부 의존성도 한 번에 전달
검증 구절
public static class UserExtensions
{
public static User ShouldExist(this User user)
{
Assert.NotNull(user);
return user;
}
public static User WithEmail(this User user, string email)
{
Assert.Equal(email, user.Email);
return user;
}
}
// Example usage
User userFromDb = QueryUser(user.UserId);
userFromDb
.ShouldExist()
.WithEmail("new@gmail.com")
.WithType(UserType.Customer);
Company companyFromDb = QueryCompany();
companyFromDb
.ShouldExist()
.WithNumberOfEmployees(0);
헬퍼 메서드 두기 (+플루언트 인터페이스)
읽기 테스트를 해야 하는가?
가장 복잡하거나 중요한 읽기 작업만 테스트하고 나머지는 무시 (할 경우 통합 테스트로 진행)
읽기 버그는 해로운 문제가 없음
성능면에서 일반 SQL 사용하는 것이 좋음!
도메인 모델도 필요 X
ORM의 불필요한 추상화 계층 피할 수 있음
쓰기를 철저히 테스트하는 것이 매우 중요
위험성이 높기 때문에 매우 가치 있음
리포지토리 테스트를 해야 하는가?
마찬가지로 직접 테스트하지말고 통합 테스트의 일부로서만 다루기
컨트롤러 사분면에 소속 -> 통합테스트가 필요한데 이점이 적음
유지비가 높음 (외부 통신 존재)
그에 비해 회귀 방지 이점이 적음 (복잡도가 거의 없음)
복잡도가 있는 부분은 별도 알고리즘으로 추출해 테스트
e.g. 객체 매핑 작업 (UserFactory, CompanyFactory)
EventDispatcher도 별도로 테스트하지 말자
유지비가 높지만 회귀 방지 이점이 적음
인터페이스의 사용이유 2가지
느슨한 결합
구체 클래스가 2개 이상일 때 추상화를 위해 사용
구체 클래스가 1개일 때 인터페이스 도입은 YAGNI 위배 (You aren’t gonna need it)
목 사용
구체 클래스가 1개일 경우에도 인터페이스를 사용하는 이유
인터페이스가 없으면 테스트 대역을 만들 수 없음
의존성을 목으로 처리할 필요가 있을 때만, 프로세스 외부 의존성에 인터페이스 두자
= 비관리 의존성에만 인터페이스를 쓰자
e.g.
private readonly Database _database;
private readonly ImessageBus _messageBus;
Unit Testing 책 권장 백엔드 시스템 계층
간접 계층은 많은 애플리케이션 문제를 해결하지만, 가능한 간접 계층을 적게 사용하자.
도메인 모델 (도메인 로직)
애플리케이션 서비스 계층 = 컨트롤러 (외부 클라이언트에 대한 진입점 제공 및 오케스트레이션)
인프라 계층 (데이터베이스 저장소, ORM 매핑, SMTP 게이트웨이)
순환 의존성 제거하기
순환 의존성이란 둘 이상의 클래스가 제대로 작동하고자 직간접적으로 서로 의존하는 것을 말한다.
순환 의존성은 코드를 읽을 때 주변 클래스 그래프를 파악해야 하는 부담이 존재하며 테스트를 방해한다.
따라서, 순환 의존성은 최대한 제거하자.
실행 구절이 여러 개인 다단계 테스트
여러 개 실행 구절을 가지는 테스트는 프로세스 외부 의존성을 관리하기 어려운 경우에 발생한다.
따라서, 다단계 테스트는 거의 항상 엔드 투 엔드 테스트 범주에서만 허용된다. (통합테스트도 드묾)
단위 테스트는 절대로 실행 구절이 여러 개 있어서는 안된다.
식별할 수 있는 동작 기준
식별할 수 있는 동작은 다음 2가지 기준 중 하나를 충족해야 한다.
클라이언트의 목표 중 하나에 직접적 연관이 있음
외부에서 접근할 수 있는 프로세스 외부 의존성에서 사이드 이펙트가 발생함
권장 의존성 주입
모든 의존성은 항상 생성자 혹은 메서드를 통해 명시적으로 주입하자.
의존성을 내부로 숨기는 Ambient Context는 안티패턴이다.
이는 의존성이 숨어있어 변경이 어렵고 테스트가 더 어려워진다.
단위 테스트 안티 패턴
안티 패턴 1: 비공개 메서드 단위 테스트
식별할 수 있는 동작으로 간접적으로 비공개 메서드를 테스트해야 함
즉, 최대한 하지 말아야 한다!
식별할 수 있는 동작으로 테스트 해도 비공개 메서드가 매우 복잡해 커버리지가 낮은 경우
해당 비공개 메서드는 죽은 코드이거나 (삭제 필요)
추상화가 누락된 징후 (별도 클래스로 도출 필요)
e.g.
복잡한 비공개 메서드
public class Order
{
private Customer _customer;
private List<Product> _products;
public string GenerateDescription()
{
return $"Customer name: {_customer.Name}, " +
$"total number of products: {_products.Count}, " +
$"total price: {GetPrice()}";
}
private decimal GetPrice()
{
decimal basePrice = /* _products에 기반한 계산 */;
decimal discounts = /* _customer에 기반한 계산 */;
decimal taxes = /* _products에 기반한 계산 */;
return basePrice - discounts + taxes;
}
}
추상화 적용 코드
public class Order
{
private Customer _customer;
private List<Product> _products;
public string GenerateDescription()
{
var calc = new PriceCalculator();
return $"Customer name: {_customer.Name}, " +
$"total number of products: {_products.Count}, " +
$"total price: {calc.Calculate(_customer, _products)}";
}
}
public class PriceCalculator
{
public decimal Calculate(Customer customer, List<Product> products)
{
decimal basePrice = /* _products에 기반한 계산 */;
decimal discounts = /* _customer에 기반한 계산 */;
decimal taxes = /* _products에 기반한 계산 */;
return basePrice - discounts + taxes;
}
}
비공개 메서드 테스트가 타당한 예외도 존재
신용 조회 관리 시스템 (Inquiry 클래스의 비공개 생성자 내 승인 로직)
승인 로직은 중요하므로 단위테스트를 거쳐야 함 -> public 허용
안티 패턴 2: 단위 테스트 목적으로 비공개 상태 노출하기
비공개 상태 -> 식별할 수 없는 동작
비공개 상태를 바꾸는 메서드 테스트 -> 단일 메서드 보다 식별할 수 있는 동작 관점에서 테스트하자
추후에 비즈니스 요구 사항으로 공개 상태로 바뀌면, 그 때는 상태를 검증하면 좋다!
안티 패턴 3: 테스트로 유출된 도메인 지식
public class CalculatorTests
{
[Theory]
[InlineData(1, 3)]
[InlineData(11, 33)]
[InlineData(100, 500)]
public void Adding_two_numbers(int value1, int value2)
{
int expected = value1 + value2; // 유출
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}
테스트가 제품 코드에서 알고리즘 구현을 복사한 상황 (value1 + value2)
복잡한 알고리즘 다루는 테스트에서 주로 발생
구현 세부사항과 결합되는 테스트 (리팩토링 내성 0점)
테스트 작성 시 결과를 하드코딩하자!
public class CalculatorTests
{
[Theory]
[InlineData(1, 3, 4)]
[InlineData(11, 33, 44)]
[InlineData(100, 500, 600)]
public void Adding_two_numbers(int value1, int value2, int expected)
{
int actual = Calculator.Add(value1, value2);
Assert.Equal(expected, actual);
}
}
하드코딩 예상 결과값은 도메인 전문가의 도움을 받아 SUT가 아닌 다른 것으로 미리 계산
레거시 코드 리팩토링 시 레거시 코드로 결과를 생성하고 예상 결과 값으로 사용 가능
안티 패턴 4: 코드 오염
테스트에만 필요한 코드를 제품 코드에 추가하는 것
테스트 코드와 제품 코드가 혼재되면 유지비 증가
e.g. private readonly bool _isTestEnvironment
테스트 코드를 제품 코드 베이스와 반드시 분리하자! (운영용 진짜 구현체 & 테스트용 가짜 구현체)
e.g. ILogger 인터페이스 -> Logger (운영용), FakeLogger (테스트용)
이 상황의 인터페이스도 일종의 코드 오염이지만, 오염도가 낮고 다루기 쉬움
안티 패턴 5: 구체 클래스를 목으로 처리하기
인터페이스가 아닌 구체 클래스로 목으로 처리할 경우 단일 책임 원칙 위배되는 시나리오일 가능성
여러 책임이 합쳐진 클래스일 가능성을 의심하고 분리하자!
안티 패턴 6: 앰비언트 컨텍스트로서의 시간 처리
시간을 정적 메서드 혹은 필드로 참조하는 것 -> 테스트가 더 어려움 (공유 의존성 발생)
더 나은 방안: 시간을 명시적 의존성으로 주입하기
컨트롤러에는 시간 관련 인스턴스를 전달 (클래스는 메서드에서 시간 반환)
도메인 클래스에는 시간을 값으로 전달
Reference
단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
-
단위 테스트 (Unit Testing) - 가치 있는 테스트 식별하기
좋은 단위 테스트의 4대 요소
좋은 단위 테스트의 4가지 특성
회귀 방지 (=소프트웨어 버그 방지)
중요 지표: 테스트로 실행되는 코드의 양, 코드 복잡도, 코드의 도메인 유의성
복잡도와 도메인 유의성이 높은 코드에 대한 테스트가 많을수록 회귀 방지가 탁월
리팩터링 내성
테스트 실패없이 애플리케이션 코드 리펙토링 가능한지에 대한 척도
중요지표: 거짓 양성 발생량 (적을수록 좋음)
거짓 양성: 리팩토링 후 기능이 의도대로 작동해도 테스트가 실패하는 상황 (허위 경보)
회귀 발생 시 조기 경고를 제공 X (잘못된 것이므로 개발자가 무시)
리팩토링에 대한 능력과 의지 감소 (테스트 스위트에 대한 신뢰가 부족)
거짓 양성의 원인: SUT의 구현 세부 사항과 결합된 테스트 (분리 필요)
해결책: 테스트에서 구현 세부사항이 아닌 최종 결과를검증하기
결합도를 낮추면 리팩토링 내성 상승
거짓 양성 발생량이 크게 감소
거짓 양성에 대한 올바른 대응은 테스트 스위트의 안정성을 높이는 것
빠른 피드백
중요 지표: 테스트 실행 속도
빠른 테스트는 버그 수정 비용이 대폭 감소 (더 많은 테스트를 자주 실행할 수 있음)
느린 테스트는 버그 수정 비용이 상승 (뒤늦게 버그를 발견, 시간 낭비)
유지 보수성
중요 지표: 유지비 (테스트 이해 난이도, 테스트 실행 난이도)
테스트 이해 난이도: 테스트의 크기를 의미 (코드라인이 적을수록 읽기 쉬움)
테스트 실행 난이도: 테스트가 프로세스 외부 종속성으로 작동하면, 의존성 운영 비용 고려 필요
회귀 방지 & 리팩터링 내성 간 관계
올바른 추론: 올바르게 작동해 테스트가 통과 & 기능이 고장나 테스트가 실패
회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도 극대화를 목표로하는 특성
테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수)
거짓 양성, 거짓 음성 발생 확률 줄이기 -> 테스트 정확도 상승
회귀 방지가 훌륭한 테스트는 거짓 음성 수를 최소화
리팩터링 내성이 훌륭한 테스트는 거짓 양성 수를 최소화
중대형 프로젝트는 거짓 음성과 거짓 양성에 똑같이 주의를 기울여야 함
프로젝트 초반은 리팩토링이 많지 않아 거짓 양성은 무시할만 함
프로젝트 중후반으로 갈수록 리팩토링이 중요한데, 거짓 양성이 잦으면 문제가 커짐
테스트 전략
테스트의 가치 = 회귀 방지 X 리팩터링 내성 X 빠른 피드백 X 유지 보수성
하나라도 0이면 전체가 0 (모두 1도 불가능)
유지보수성은 다른 특성과 독립적 (엔드 투 엔드 테스트에서만 회귀 방지와 연관됨)
회귀 방지, 리팩토링 내성, 빠른 피드백은 상호 배타적 -> 하나를 희생해야 둘이 최대 가능
회귀 방지 희생 -> 너무 간단한 테스트
리팩토링 내성 희생 -> 구현에 결합된 깨지기 쉬운 테스트
빠른 피드백 희생 -> 엔드 투 엔드 테스트
각 요소에 높은 임계치를 두고 이를 충족하는 테스트만 테스트 스위트에 남기기
소수의 매우 가치 있는 테스트가 프로젝트의 지속적 성장에 효과적
전략적 절충
리팩토링 내성은 최대화 필요 (리팩토링 내성은 대부분 있거나 없거나 둘 중 하나이므로…)
회귀 방지와 빠른 피드백 사이에서 조절하자
테스트 피라미드 관점 전략
테스트 유형 간 비율은 피라미드 형태를 유지할 것 (팀, 프로젝트 마다 비율 차이 O)
모든 테스트 계층은 가능한 거짓 양성 최소화 목표 (리팩토링 내성 최대화)
피라미드 내 테스트 유형에 따라 회귀 방지와 빠른 피드백 사이에서 선택함
엔드 투 엔드 테스트는 매우 중요한 기능에만 적용
빠른 피드백과 유지보수성 결여 -> 숫자가 가장 적은 이유
예외 케이스
복잡도가 거의 없는 기본 CRUD 프로젝트
통합 테스트 수가 단위 테스트 수와 같거나 많고 엔드 투 엔드 테스트가 없음
단위 테스트는 복잡도 없는 환경에서 유용성 감소
통합 테스트는 여전히 시스템 간 통합 동작 확인에 가치 있음
프로세스 외부 의존성 하나만 연결하는 API (e.g. DB)
엔드 투 엔드 테스트를 더 많이 두는 것이 적합 (환경 상 통합 테스트와 구분 불가)
속도가 상당히 빠를 것이고 유지비도 적음
블랙 박스 테스트 & 화이트 박스 테스트 전략
둘을 조합하되 테스트 작성 시 블랙 박스 테스트 선택하자
화이트 박스 테스트는 구현에 결합 -> 리팩토링 내성 포기할 수는 없음!
테스트 분석 시 화이트 박스 테스트 사용! (e.g. 코드 커버리지 도구)
목과 테스트 취약성
테스트 대역(test double)
모든 유형의 비운영용 가짜 의존성
e.g. 더미, 스텁, 스파이, 목, 페이크
사용 의도에 따라 목과 스텁으로 나뉨 (Mock 프레임워크로 똑같이 인스턴스를 생성)
목(mock) - 목, 스파이
외부로 나가는 상호 작용을 모방하고 검사
상태 변경을 위해 의존성을 호출하는 것 (사이드 이펙트 O)
e.g. SMTP 서버로 이메일 발송 작업
CQS 관점에서 명령을 대체 (보통 반환값 X)
구현
목: 목 프레임워크의 도움 받아 생성
스파이: 수동으로 작성한 목
스텁(stub) - 스텁, 더미, 페이크
내부로 들어오는 상호 작용을 모방만 함
입력 데이터를 얻기 위해 의존성을 호출하는 것 (사이드 이펙트 X)
e.g. DB로 부터 데이터 검색
CQS 관점에서 조회를 대체 (보통 반환값 O)
구현
더미: 단순 하드코딩 값 (null, 가짜 문자열)
스텁: 더 정교하게 시나리오마다 다른 값 반환하는 의존성
페이크: 스텁과 같지만, 아직 존재하지 않는 의존성을 대체하고자 구현
무분별한 목 사용 지양하기 (feat. 리팩토링 내성 감소)
API를 잘 설계하면 단위테스트도 자동으로 좋아짐
식별할 수 있는 동작만 공개하고 구현 세부사항을 비공개함으로써 리팩토링 내성 상승
스텁의 상호작용은 검증하지 말자! (안티패턴)
입력을 제공할 뿐이지 SUT의 최종 결과가 아님
스텁의 상호작용 검증은 내부 구현 세부사항과 결합(overspecifiation) -> 리팩토링 내성 감소
목의 상호작용 검증은 최종 결과 검증
e.g.
mock.Verify(x => x.SendGreetingsEmail("user@email.com")) -> O
stub.Verify(x => x.GetNumberOfUsers(), Times.Once) -> X
사이드 이펙트가 있는 시스템 간 통신은 목으로 테스트하자! (외부 애플리케이션 통신)
클래스 간 통신에도 목을 쓰는 것은 런던파의 단점
가치 있는 목 테스트
var mock = new Mock<IEmailGateway>()
mock.Verify(x => x.SendReceipt("..@x.com","egg",5), Times.Once)
클라이언트 목표 달성에 도움이 되는 연산
잘못된 목 테스트
var storeMock = new Mock<IStore>()
storeMock.Verify(x => x.RemoveInventory("egg", 5), Time.Once)
시스템 내 통신(도메인 간 통신)은 클라이언트 목표로 가는 중간 단계 (구현 세부 사항)
애플리케이션을 통해서만 접근할 수 있는 프로세스 외부 의존성은 목 대체 X
모든 공유 의존성을 목으로 대체하는 것은 고전파의 단점
외부 클라이언트 관점에서 접근 불가한 시스템은 구현 세부 사항
e.g. 데이터베이스
식별할 수 있는 동작과 공개 API
모든 제품 코드는 2차원으로 분류할 수 있다.
공개 API (public) & 비공개 API (private)
식별할 수 있는 동작과 구현 세부 사항
식별할 수 있는 동작은 클라이언트가 목표를 달성하는데 도움이 되는 연산(Operation)과 상태(State)를 최소한으로 노출한다. (연산은 계산 수행 혹은 사이드 이펙트를 초래하는 메서드를 의미)
구현 세부사항은 두 가지 중 어떤 것도 하지 않는다.
잘 설계된 API는 식별할 수 있는 동작은 공개 API와 일치하고, 모든 구현 세부 사항은 비공개 API 뒤에 숨어 있다. 만일, 식별할 수 있는 동작을 달성하고자 할 때 클래스에서 호출해야 하는 연산 수가 1보다 크면 해당 클래스는 구현 세부 사항을 유출했을 가능성이 크다.
또한, API를 잘 설계하면 단위테스트도 자동으로 좋아진다. (리팩토링 내성 상승)
장기적으로 캡슐화는 증가하는 복잡성에 대응하고 소프트웨어의 지속적 성장을 가능하게 하는 유일한 방법이다.
헥사고날 아키텍처(Hexagonal Architecture, Alistair Cockburn)
- 애플리케이션 서비스 + 도메인
- 도메인 계층 (도메인 지식)
- 비즈니스 로직 책임
- 애플리케이션 서비스 계층 (유스케이스)
- 외부 환경과의 통신을 조정 (SMTP, 메시지 버스, 서드파티…)
- 잘 설계된 API는 프랙탈 특성 존재
- 서로 다른 계층의 테스트도 동일한 동작을 서로 다른 수준으로 검증하는 프랙탈 특성 존재
- 목표(유스 케이스) - 하위 목표 - …
Reference
단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
-
단위 테스트 (Unit Testing) - 단위 테스트의 목표와 구조
단위 테스트의 목표
단위 테스트의 목표: 소프트웨어 프로젝트의 지속 가능한 성장
버그 없이 주기적으로 리팩토링하고 새로운 기능을 추가할 수 있도록 지원
테스트 없는 프로젝트
초기 개발 속도가 빠름 -> 시간이 갈수록 엔트로피(시스템 내 무질서도) 증가 및 개발 속도 감소
테스트 있는 프로젝트
초반에 상당한 노력이 들어감 -> 프로젝트 후반에도 안정적으로 잘 성장
단위 테스트 적용은 필수이고 논쟁거리가 아님
테스트는 코드베이스의 일부
애플리케이션의 정확성을 보장하는 책임을 가진 코드
현재의 논쟁: 좋은 단위테스트는 어떤 것인가?
개발 주기에 통합되어 있는 것
매 배포 전 테스트 실행
코드베이스에 가장 중요한 부분을 대상으로 하는 것
핵심인 도메인 모델을 다른 것과 구분해 테스트
최소한의 유지비로 최대 가치를 끌어내는 것
고품질 테스트는 동작의 단위를 검증하는 것 (비즈니스 로직 테스트)
식별할 수 있는 동작은 테스트하고 구현 세부사항은 테스트 X
필요 사항
가치 있는 테스트 식별하기
가치 있는 테스트 작성하기
테스트 스위트 품질 측정 방법
커버리지 지표
테스트 스위트가 소스 코드를 얼마나 실행하는지 백분율로 표현
중요한 피드백을 줄 순 있지만 테스트 스위트 품질 측정에 부적합
커버리지가 낮으면 테스트가 충분치 않다는 좋은 증거
시스템의 핵심 부분은 커버리지를 높게 두는게 좋음
하지만 커버리지가 높다고 품질을 보장하지는 못함
모든 결과의 검증을 보장하지 못함 (100%여도 빠져나가는 케이스들이 있음)
외부 라이브러리는 경로 검증 불가
지표로만 보고 목표로 삼아서는 안됨!!
특정 커버리지를 목표로하면 개발자들은 시스템을 속일 방법을 궁리하는 부작용 발생
종류
코드 커버리지(code coverage, test coverage)
코드 커버리지 = 실행 코드 라인 수 / 전체 라인 수
커버리지 숫자는 조작이 가능,,,
e.g. return input.Length() > 5 //input='abc', 100% 커버리지
분기 커버리지(branch coverage)
분기 커버리지 = 통과 분기 / 전체 분기수
코드 커버리지를 조금은 보완
e.g. return input.Length() > 5 //input='abc', 50% 커버리지
회귀(=소프트웨어 버그)
코드 수정 후 기능이 의도한 대로 작동하지 않는 경우를 의미한다.
단위 테스트란?
단위 테스트의 속성
작은 코드 조각(Unit)을 검증
빠르게 수행
격리된 방식으로 처리하는 자동화된 테스트 (쟁점)
격리가 무엇인지에 대한 의견 차이가 근본적으로 고전파와 런던파를 가름
단위 테스트 접근 방식에 대한 분파
고전파 (Classical School, Detroit) - 지향
원론적인 접근 추구
상향식 TDD (도메인 모델부터 시작)
상태를 검증 (e.g. 고객이 상점을 통해 구매하면 상점의 재고 차감 여부를 검증)
격리 방식에 대한 관점: 단위 테스트끼리 격리
테스트는 적합한 순서(순차적 or 병렬적)로 실행 가능하며 서로의 결과에 영향 X
공유 의존성에 대해서만 테스트 대역 사용
단위 테스트 간 공유되는 의존성은 테스트 대역으로 교체
싱글톤 객체 같은 경우 각 테스트마다 생성하면 테스트 간 공유 X
설정 파일도 각 테스트마다 생성자 주입하는 방식으로 가능
공유 의존성 = 프로세스 외부 의존성
실무에서 예외 케이스가 거의 없음!
예외 상황: 읽기 전용 외부 API (불변 의존성이므로 공유 의존성 X)
테스트 속도와 안정성을 위해 테스트 대역으로 교체 권장
빠르고 안정적이라면 원칙에 맞춰 그대로 사용해도 괜찮
교체 시 테스트 속도 상승 (단위 테스트의 2번째 요건 충족)
공유 의존성은 대부분 프로세스 외부에 있어 호출이 느림
외부 공유 의존성 테스트는 보통 통합 테스트의 영역
코드 조각 범위 (Unit): 공유 의존성만 없으면 여러 클래스 묶어 테스트 가능
통합 테스트: 단위 테스트 정의를 충족하지 않는 테스트
고전파 관점의 단위 테스트 정의
단일 동작 단위를 검증하고
빠르게 수행하고
다른 테스트와 별도로 처리한다.
e.g. 둘 이상의 동작 단위 검증 테스트, 다른 팀 개발 코드와 통합해 검증하는 테스트, 프로세스 외부 의존성, 공유 의존성
장점
단위 테스트 목표 달성에 적합 -> 동작의 단위 검증에 유용
단점
SUT가 올바르게 동작하더라도 협력자에 버그에 있는 경우 테스트 실패
런던파 (London School, Mockist)
런던의 프로그래밍 커뮤니티에서 시작
하향식 TDD (상위 레벨 테스트부터 시작)
상호작용을 검증 (e.g. 목 객체의 메서드가 올바르게 호출되었는지, 호출 횟수는 맞는지…)
격리 방식에 대한 관점: 테스트 대상 시스템에서 협력자를 격리
한 클래스의 의존성을 모두 테스트 대역(test double)으로 대체 (불변 의존성은 그대로)
e.g. 인터페이스를 통해 목 객체를 만들고 SUT에 인자로 넘김
코드 조각 범위 (Unit): 단일 클래스 혹은 해당 클래스 내 메서드
통합 테스트: 실제 협력자 객체를 사용하는 모든 테스트
장점
테스트가 실패하면 테스트 대상 시스템이 고장난 것이 확실해짐 (테스트가 세밀)
중요성 떨어짐 (정기적으로 테스트 실행하면 고장난 부분 좁히기 쉬움)
테스트 준비 시 복잡한 의존성 조립을 피하고 대역 하나로 대체 가능
e.g. 한 번에 한 클래스만 테스트 -> 전체 단위 테스트 구조 간단해짐
중요성 떨어짐 (복잡한 의존성은 잘못된 설계이므로 설계를 바꿔야 함)
단점
목을 다루는 것은 불안정함 내포
테스트가 SUT의 구현 세부 사항에 빈번히 결합
테스트 대상 시스템(SUT, System Under Test) & 협력자(Collaborator)
SUT는 현재 테스트에 대상이 되는 시스템을 의미한다. 테스트 대상 메서드는 MUT(Method Under Test)라고도 한다.
반면에 협력자는 불변 의존성(값 객체)을 제외한 시스템에 엮여 있는 모든 의존성들을 의미한다.
즉, 일반적인 클래스는 협력자와 값 객체로 2가지 유형의 의존성으로 동작할 수 있다.
의존성 종류
공유 의존성(shared dependency)
테스트 간 공유되고 서로의 결과에 영향을 미칠 수 있는 의존성
e.g. 정적 가변 필드, 데이터베이스
비공개 의존성(private dependency)
테스트 간 공유하지 않는 의존성
프로세스 외부 의존성(out-of-process dependency)
애플리케이션 프로세스 외부에서 실행되는 의존성. 대부분 공유 의존성이지만 아닌 경우도 있다.
e.g. 데이터베이스는 외부 의존성이면서 공유 의존성인 반면,
테스트 실행 전 도커 컨테이너로 시작한 데이터베이스는 외부 의존성이면서 비공개 의존성
읽기 전용 API처럼 프로세스 외부 의존성이지만 불변 의존성이어서 공유 의존성이 아님
엔드 투 엔드 테스트 (end-to-end test)
시스템을 최종 사용자 관점에서 검증하는 것을 의미한다. (동의어: UI 테스트, GUI 테스트, 기능 테스트)
엔드 투 엔드 테스트는 통합 테스트의 일부다.
둘 모두 코드가 프로세스 외부 의존성과 함께 어떻게 작동하는지 검증한다. 다만, 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다.
(통합 테스트가 프로세스 외부 의존성이 1~2개, 엔드 투 엔드 테스트는 전부 혹은 대다수)
예를 들어, DB, 파일 시스템, 결제 게이트 웨이라는 3가지 프로세스 외부 의존성이 있다면, 보통의 통합 테스트는 완전히 제어 가능하지 않은 결제 게이트 웨이만 테스트 대역으로 대체하는 반면에, 엔드 투 엔드 테스트는 전부 테스트에 포함한다.
통합 테스트와 엔드 투 엔드 테스트의 뚜렷한 경계는 없다. 테스트 버전이 없거나 자동으로 가져오는 것이 불가능한 프로세스 외부 의존성의 경우 엔드 투 엔드 테스트 역시 테스트 대역을 사용해야 한다.
또한, 엔드 투 엔드 테스트는 유지보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트가 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다.
단위 테스트 구조
AAA 패턴 (Arrage, Act, Assert)
준비, 실행, 검증패턴으로 테스트하는 일반적인 방식
단순하고 균일한 구조를 만들어 가독성과 유지보수성이 향상
Given, When, Then은 비기술자에게 조금 더 읽기 쉬운 점말고 AAA와 차이가 없음
단위 테스트 구조에 대한 지침
한 테스트에 여러 개의 준비, 실행, 검증 구절 -> 여러 테스트로 나눠라!
여러 구절 = 테스트가 여러 개의 동작 단위를 한 번에 검증 = 통합 테스트
테스트 내 if 문 피하자 -> 여러 테스트로 나눠라!
분기가 있다는 것 역시 한 번에 너무 많은 것을 검증한다는 표시
단위 테스트 든 통합 테스트 든 테스트에 분기가 있어서 얻는 이점은 없음 (가독성만 감소)
각 구절의 적정 크기
준비 구절: 세 구절 중 가장 큼
많이 크다면 테스트 내 비공개 메서드, 별도 팩토리 클래스 추출해 재사용
실행 구절: 한 줄 (이상적)
두 줄 이상인 경우 SUT API 설계 문제 의심 -> 캡슐화 지키기
e.g. 테스트 내 Purchase(), RemoveInventory() 각각 실행-> 캡슐화가 깨짐
단, 유틸리티나 인프라 코드는 덜 적용되므로, 절대 두줄 이상 두지 말라고 할 수는 없음!
검증 구절: 여러 검증이 있을 수 있지만 너무 커지는 것을 경계
equals() 정의해 객체 끼리 Assert문 한번에 검증하는 것이 좋음
참고) 종료 구절: 리소스 정리 목적으로 통합테스트에 주로 쓰임 (메서드 추출해 재사용)
SUT 구분하기
SUT는 동작에 대한 유일한 진입점
테스트 내 SUT 이름을 sut로 명명해 구분하자
구절 주석 지침
AAA 패턴 따르고 주석 없이 빈 줄로 각각 구절을 3등분해 구분
각 구절 내에 빈 줄이 있다면, 주석 추가하기 (//arrage, //act, //assert)
테스트 픽스처 재사용하기
테스트 픽스처: 단위 테스트를 수행할 때 필요한 초기 상태나 설정
e.g. 계좌 잔고 확인 테스트를 위한 초기 입금 설정 작업
준비 구절 코드 재사용은 좋은 방법
픽스처 재사용 방법
권장: 테스트 클래스에 비공개 팩토리 메서드 두자 (가독성, 재사용성 향상)
CustomerTests -> CreateStoreWithInventory(), CreateCustomer()
안티 패턴: 테스트 클래스 생성자에서 픽스처 초기화
e.g. _store.AddInventory(Product.Shampoo, 10)
테스트 간 결합도 상승 및 가독성 감소
테스트마다 재고 변경을 다르게 설정 하고 싶어도 결합되어 어려움 (공유 상태)
생성자 재사용의 유일한 예외: 테스트 대부분에 사용되는 픽스처
기초 클래스를 두고 생성자에서 초기화한 후 개별 테스트 클래스에서 상속해 재사용
e.g. 통합 테스트 시 DB 커넥션 초기화
CustomerTests가 DB 커넥션이 있는 IntegrationTests를 상속받음
단위 테스트 명명법
Best Practice
표현력이 있는 간단하고 쉬운 영어 구문 (엄격하지 않게 표현의 자유 허용)
장황한 표현 지양 e.g. considered X
사실만 서술하고 소망이나 욕구 지양 e.g. should be X -> is O
기초 영문법 지키기 e.g. 관사
도메인에 익숙한 비개발자에게 설명하듯이 이름 짓기 (도메인 전문가, 비즈니스 분석가)
동작으로 이름 짓기
테스트 이름에 메서드 이름 넣지 말기
예외: 유틸리티 코드는 메서드 이름 사용해도 괜찮 (비즈니스 로직 X)
테스트 클래스 이름 지정: [클래스명]Tests
동작 단위 검증의 진입점 역할
해당 클래스만 검증한다는 것이 아님 -> 여러 클래스 걸쳐도 동작을 검증
_ 로 단어 구분 (가독성 향상)
e.g
Sum_of_two_numbers()
Delivery_with_a_past_date_is_invalid
안티 패턴: [테스트 대상 메서드]_[시나리오]_[예상 결과]
타인이 읽기 난해하고 구현 세부사항에 묶임
e.g.
Sum_TwoNumbers_ReturnsSum()
IsDeliveryValid_InvalidDate_ReturnsFalse()
매개변수화된 테스트 리팩토링 하기 (Parameterized Test)
유사한 테스트를 묶을 수 있는 기능 제공
하나의 동작은 여러 테스트가 필요하고 복잡하면 테스트 수가 급증하므로 관리에 용이
사용 지침
입력 매개변수만으로 테스트케이스 판단이 가능하면, 하나의 테스트 메서드 사용
매개변수만으로 판단이 어렵다면 긍정 테스트 케이스와 부정 테스트 케이스 나누기
동작이 너무 복잡하면 매개변수화된 테스트 사용말고 모두 개별 테스트로 두기
e.g. 가장 빠른 배송일이 오늘부터 이틀 후가 되도록 작동하는 배송 기능
4가지(어제, 오늘, 내일, 모레) 테스트가 필요하지만 유일한 차이점은 배송 날짜
Parameterized Test로 묶기
하나의 테스트 메서드 두기
Can_detect_an_invalid_delivery_date()
[(-1, false), (0, false), (1, false), (2, true)]
긍정 테스트와 부정 테스트 나누기 (boolean 매개변수 제거 효과)
긍정: The_soonest_delivery_date_is_two_days_from_now()
2
부정: Detects_an_invalid_delivery_date()
[-1, 0, 1]
검증문 라이브러리 사용하기
쉬운 영어로 구성된 이야기 패턴으로 테스트 가독성 향상
유일한 단점은 프로젝트에 의존성 추가하는 것
Assert.equal(30, result) -> result.Should().Be(30)
Reference
단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
-
스프링 핵심 원리 - 고급편
로그 추적기 도입 과정
목표: 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)
-
정보처리기사 92점 합격수기 - 인프런
주말 코딩님 덕분에 정말 “효율적”으로 실기 합격했습니다!
가채점 점수 92점, 발표일에 합격인증까지 올리겠습니다~😀
10월 20일에 있었던 3회차 정보처리기사 실기를 막 마치고 얻은 점수입니다.
약 1주일 간 주말코딩님 인프런 강의와 함께하며 정보처리기사를 준비했구요 (대략 10일)
가장 큰 목표인 “최소비용”, “효율” 중시로 시험을 준비했던 과정을 공유해보려고 합니다.
우리 모두 바쁘자나요~
고득점 목표는 정말 전혀 없었는데 주말코딩님과 핵심만 집중하다보니 덤으로 얻었다고 생각해요
1. 학습 목표
저한테 가장 중요했던 것은 “최소비용 및 시간으로 안정적인 합격하기” 였어요.
시중 책들이나 강의가 불필요하게 깊게 파고 비싸서 시간과 비용이 아깝다고 느꼈어요
(몇 백페이지 어떻게 다보나요… 필기도 CBT로 기출만 풀고 넘어왔습니다)
자격증 공부는 실질적인 개발 공부와 다른 측면이 있으니 자격증 준비는 정말 최소한의 비용으로 해야겠다고 마음 먹었습니다.
부가적으로 C언어 메모리 관련 지식과 CS 큰그림 정리만 얻자는 마음으로 준비했어요
그런 점에서 주말코딩 님 강의는 저의 목표와 매우 적합했습니다!
주말코딩 님도 수강생들에게 핵심만 집중적으로 공략해서 빠르게 합격하길 원하셨거든요.
모두 다른 할 일들이 많으니까요!!
(주말코딩 정처기 인프런 강의: https://u.inf.run/3Bu7c2O)
2. 강사님 성향 & 실제 체감한 시험 경향
강사님 기본 전제는 100점 중 60점 넘으면 통과이므로
전체 5~60% 비중인 코딩 영역은 최대한 다 맞추자 (1개정도의 킬러문제는 그냥 틀리자)
이론 영역은 찍기도 가능하니 1~2개만 맞추자
를 강조하십니다.
납득이 되고 매우 합리적인 전략이에요.
이론 영역에 대해 조금 더 생각해볼 부분은 강사님께서 조금 보수적으로 잡고 말씀하신 부분이 있고, 실제 공부하다보면 이론은 1~2개보다 충분히 더 맞출 수 있습니다.
강사님이 중요한 부분만 정리한 총 1~2시간 정도 강의와 20페이지 정도되는 이론 요약집 제공해주세요. 항상 빈출되는 5~10가지 유형만 확실히 정리하고 가도 안정적으로 점수 추가 가능하다고 느꼈어요.
이외 나머지는 대차게 틀리면 됩니다! 다 맞출 필요가 없으니까요 🤣
사람이니까 코딩영역 실수해서 조금 더 틀려도 합격권 넉넉할거라고 느껴요.
코딩 영역은 주말 코딩님 이전 명성도 있고 실제로 강의를 워낙 잘해주셔서 정말 킬러 문항 1개만 틀리고 다 맞을 수 있습니다! 비전공자지만 독학한 베이스가 있긴해서 운좋게 킬러 문제도 건졌어요
실제로 시험을 봐보니 최근 시험 경향이 코딩 난이도를 높이고 이론은 너무 어렵게 안내는 느낌이 들었습니다.
이론이 최근 기출 포함해 항상 나오는 문제 주제로 5~10개 풀 정해놓고 돌려서 나오는 경향이라 그 부분만 확실해도 얘기했던 기존 목표인 이론 1~2개 맞추기보다 더 맞출 수 있을 것 같습니다.
결과적으로 코딩 문제는 다 맞추고, 이론에서 8점 깎였어요 (한 문제 틀리고, 한 문제 부분점수)
“코딩만은 확실히”를 지향하는 주말 코딩님의 방식은 매우 타당했습니다
3. 공부 방법
당시 10일 정도 남았었고 2~3일 보통 숨고르기 시간으로 날리잖아요~ 하하하
그래서 저는 공부 전략을 다음과 같이 잡았습니다.
[강의 중요 부분 수강 + 강사님 이론 요약집 외우기 + 이론만 잽싸게 기출 보기]
[강의 중요 부분 수강]
강의는시간 관계상 부가적인 부분만 제끼고 최대한 들었습니다. (75프로 수강했네요)
비전공자지만 개발경험은 있어서 앞에 언어 공통 문법 부분이랑 뒤에 고난도 코드영역의 정렬만 제꼈습니다. (정렬 문제는 개념몰라도 주요 강의 내용만으로 코드 풀 수 있어요)
코딩 기출문제 풀이 강의는 무조건 하루 한개씩 들었고
다만 시간 관계상 강사님 C, Java, Python 변형문제 강의는 못들었어요 (기출 강의 중간중간에도 변형 문제는 소개해주셔서 다행히 괜찮았던 것 같아요)
고난도 코드영역에 SQL 기출문제는 꼭 챙겨봤습니다.
이렇게만 공부해도 코딩 + DB 영역 50점은 먹고 가요 (주말코딩 님 그는 정말…)
시험 3~4일전 강사님 이론 강의를 살살 듣기 시작했는데요 운영체제 페이지 교체 부분부터 정리했어요
기출보니 요즘 자주 나오더라구요! 요 영역이 조금 빡세보여도 강의듣고 하면 풀만해서 5점 가져가는 것 같아요
그리고 결국 시험 기간 이틀 전에서야 빡세게 이론 강의 완강하고 그 후 이론 요약집만 달렸어요 (머릿 속 이상적 계획과의 괴리…)
[강사님 이론 요약집 외우기]
이론은 강사님 강조해주시는 영역 몇가지 있어요
주요 포인트
결합도/응집도, 테스트 스텁 및 드라이버, 테스트 종류와 방식 (블랙/화이트), 라우팅 프로토콜(RIP, OSPF…), 데이터베이스 이론(로킹, 상호배제 조건 등)…
시간 날 때 나머지
보안용어와 암호화 기법, OSI 7계층
주요 포인트만 확실히 하고 간다 생각했고 (결합도/응집도, 테스트 종류 방식 진짜 맨날 나와요)
추가로 디자인 패턴만 설명보고 용어 쓸 수 있게 준비했습니다. (+위에서 강의로 봤던 페이지 교체 부분도요!)
시간 날 때 나머지 부분도 요약집 내에서만 준비했어요
OSI 7계층에 용어들만 키워드 위주 암기 미리했고 (계층 이름, ARP, RARP, ICMP, IGMP 정도),
보안용어 암호화 기법은 시험가기 전 30분정도만 봤습니다. (운에 맞기고 틀려요 그냥~)
그래도 필기 때 한번 봤던 내용들이라 익숙함은 있더라구요
[이론만 잽싸게 기출 보기]
코딩 영역은 강의 기출 풀이로 거의 충분해서
이론 공부 병행하면서 이론 기출만 23년~24년도 빠르게 확인해봤습니다.
강의나 요약집에서 이미 봤던 기출도 있고 해서 이쯤이면 금방 빠르게 볼 수 있어요.
뉴비티 사이트가 필기 공부할 때 썼던 CBT 처럼 잘되어 있어서 공부하는 동안 잘 활용했어요.
(뉴비티, https://newbt.kr/%EC%8B%9C%ED%97%98/%EC%A0%95%EB%B3%B4%EC%B2%98%EB%A6%AC%EA%B8%B0%EC%82%AC+%EC%8B%A4%EA%B8%B0))
4. 마무리
처음에는 실기 준비 어떻게 공부할지 고민했습니다. 아는 지인은 시중에 수제X 책 사서 했다더라구요.
제 성향에는 비효율적인 방법이었어요 컴팩트한 시간을 매우 중요시하는데 핵심을 벗어나 너무 폭넓게 공부해야 하니까요.
유튜브 검색을 통해 어쩌다 주말 코딩 님을 알게 되었는데, 강사님의 효율적인 학습 지향점을 듣고 바로 납득하고 강의 수강을 정했습니다.
플랫폼도 개발 컨텐츠에 친화적인 인프런이니까 수강기간 걱정없이 들을 수 있는 점이 신뢰와 안정감을 줬구요.
덕분에 처음 목표인 안정적 합격도 이뤘지만 보다 과하게 93점을 받았는데, 주말 코딩님 지향점의 장점 덕분이라 생각합니다.
언제나 느끼지만 핵심이 중요하다고 생각해요.
제 시험 준비 기록이 정보처리기사 준비하시는 다른 분들의 시간 절약 및 정신적 건강에 조금이나마 도움이 되었으면 좋겠습니다. (기록에는 시간을 아끼지 않았거든요 하핳)
준비하시는 모든 분들 파이팅하시고 쾌속 합격하시길 바랍니다!
Reference
[인프런 주말코딩] 일주일만에 합격하는 정보처리기사 실기
[뉴비티] 실기 기출 풀이 플랫폼
-
정보처리기사 실기 요점 정리
이론 준비 체크리스트
필수 - 모르면 시험 들어가면 안됨
테스트 종류와 방식 (블랙/화이트)
라우팅 프로토콜
데이터베이스 이론 중 약술형 나올만한 것 (로킹, 상호배제 조건 등)
결합도/응집도
테스트 스텁, 드라이버
나머지
보안용어와 암호화 기법, OSI 7계층
운영체제
메모리 관리
배치 전략: 프로세스를 통으로 메모리에 넣음
최초 적합(First-fit): 가장 앞부분부터 탐색하다가 처음 만난 곳에 넣음
최적 적합(Best-fit): 최대한 낭비 없이 넣음 (남는 공간이 가장 적은 공간에 할당)
최악 적합(Worst-fit): 제일 낭비되는 곳에 넣음 (가장 큰 공간에 할당)
가상 메모리 관리 (페이징 기법): 프로세스를 페이지로 나누어 가상 메모리로 올리고 교체
페이징 교체 알고리즘
OPT(Optimal): 향후에 가장 안쓰는 것을 빼기(미래를 알고있다 가정)
FIFO(First-in First-out): 가장 먼저 온 것을 먼저 빼기
LRU(Least Recently Used): 사용한지 가장 오래된 것 빼기
LFU(Least Frequently Used): 참조 횟수가 가장 적은 페이지 교체 (참조횟수 같을 때는 가장 오래된것 빼는걸 기본으로 하자)
MFU(Most Frequently Used): 참조 횟수가 가장 많은 페이지 교체 (참조횟수 같을 때는 가장 오래된것 빼는걸 기본으로 하자)
NUR(Not Used Recently): 최근에 사용되지 않은 페이지 교체
CPU 스케줄링 알고리즘: 여러 프로세스를 CPU가 수행할 수 있게 연산 시간을 분배하는 것
선점형: 프로세스가 CPU 사용 중일 때, 더 높은 우선순위의 프로세스가 CPU 연산 빼앗아갈 수 있음
라운드로빈(Round-Robin): 모든 프로세스에 공평하게 시간 할당, 할당시간 실행 후 가장 뒤로 세움
SRT(Shortest Remaining Time First): 남은 시간이 가장 적은 프로세스를 우선 순위로 연산, 실행 중에 남은 시간이 더 적은 프로세스가 들어오면 그 프로세스를 먼저 해결
비선점형: 한 프로세스가 CPU를 차지하면, 끝날 때까지 다른 프로세스가 점유 불가
FCFS(First come, First Served): 먼저 온 프로세스 순으로 처리 (선착순)
SJF(Shortest Job First): 실행 시간이 가장 짧은 프로세스를 먼저 처리
우선순위(Priority): 프로세스가 가지고 있는 우선순위 별로 먼저 처리
운영체제 목적
처리능력(Throughput) 향상
반환시간 단축
사용 가능도 향상
신뢰도 향상
페이지 VS 프레임
페이지: 프로세스를 자른 것
프레임: 물리 메모리를 자른 것
페이지 폴트(page fault): 프로그램이 참조하려는 페이지가 현재 메모리에 없는 상황
스레싱(Thrashing): 프로세스 처리 시간보다 페이지 교체 시간이 더 많아지는 현상
e.g. 프로세스 처리시간 1초, 페이지 교체 시간 2초
프로세스 상태 종류: 생성, 준비, 실행, 대기, 완료, 끝
교착상태: 여러 개의 프로세스가 특정 자원 할당을 계속 대기하면서 서로 소비 못하는 상황
조건: 상호배제, 점유와 대기, 비선점, 환형대기
해결방법: 예방, 회피, 발견, 복구
뮤텍스: 하나의 프로세스가 공유자원에 접근하는 동안, 다른 프로세스가 해당 자원에 접근 못하게 막는 도구 (락을 걸어줌)
세마포어: 운영체제에서 여러 프로세스가 공유 자원을 사용할 때 문제를 해결하는 동기화 도구 (보통 정수값으로 관리)
데이터 입출력 구현
데이터 모델링 순서
데이터베이스 계획 -> 요구사항 분석
-> 개념적 데이터 모델링: 개체타입, 속성 등 명시해서 현실 세계 반영
-> 논리적 데이터 모델링: 개념적 구조를 정규화하고 규칙과 관계 완성 (엔터티, 속성, 관계 구조적 정의)
-> 물리적 데이터 모델링: 레코드 양식 순서, 경로 인덱싱, 클러스터링, 해싱
이상(Anomaly): DB 조작 시 비정상적으로 동작하는 현상 (삽입 이상, 갱신 이상, 삭제 이상)
정규화
제1 정규형 (1NF): 모든 속성이 원자값
제2 정규형 (2NF): 복합키일 때, 부분 함수 종속성 없어야 함
제3 정규형 (3NF): 이행 함수 종속성 없어야 함
보이스-코드 정규형 (BCNF): 모든 결정자가 후보키 되도록 해, 모든 함수적 종속성이 후보키에 의해 결정
제4 정규형 (4NF): 다치 종속성 없어야 함 (다치종속: 1개 속성에 여러 속성이 매핑)
제5 정규형 (5NF): 조인 종속성이 없어야 함 (조인종속: 여러 테이블 조합했을 때, 현재 결과 구성 가능)
샤딩: 대규모 데이터베이스 시스템에서 여러 개 독립적인 부분으로 분할하여 성능을 향상시키는 기술
인덱스: 추가적인 저장 공간을 활용해, 테이블의 검색 속도를 향상시키기 위한 자료구조
시스템 카탈로그: 데이터베이스에 저장되어 있는 데이터 개체들에 대한 정보가 수록되어 있는 시스템
분산 데이터베이스의 목표: 위치 투명성, 중복 투명성, 병행 투명성, 장애 투명성
데이터베이스 회복기법: 즉시갱신, 지연갱신, 검사시점, 그림자 페이징, 미디어 회복기법
서버 프로그램/인터페이스 구현
서버의 종류: 웹 서버, 웹 애플리케이션 서버, 데이터베이스 서버, 파일 서버
웹 서버: 정적 컨텐츠 처리, HTTP 요청 및 응답 처리
웹 애플리케이션 서버(WAS): 동적 컨텐츠, DB 연결 처리
응집도와 결합도 (매우 중요)
응집도 (강할수록 좋음) - 모듈 내부 코드들 간
기순교절시논우 (강한순서대로)
결합도 (강할수록 안좋음) - 모듈 간
내공외제스자 (강한순서대로)
공통 모듈 구현 절차
DTO/VO -> SQL -> DAO -> Service -> Controller -> View
매우 큰 소프트웨어의 분석
FAN-IN: 특정 모듈이 호출하는 모듈 (내가 부르는 것, 기준 모듈의 하위 모듈)
FAN-OUT: 특정 모듈을 호출하는 모듈 (나를 부르는 것, 기준 모듈의 상위 모듈)
미들웨어: 서로 다른 소프트웨어 사이를 연결하는 중간다리 소프트웨어
e.g. JDBC, RabbitMQ, Apache Tomcat
인터페이스 설계: 데이터 주고 받을 때 노드 구성 방법
EAI(Enterprise Application Integration) - 큰 규모 회사
Point-to-Point
Hub-and-Spoke
Message Bus
Hybrid
ESB(Enterprise Service Bus)
서비스 지향 아키텍처(SOA)를 지원하는 기업 애플리케이션 통합을 위한 아키텍처 패턴
EAI 보완
화면설계/애플리케이션 테스트
UI 유형: CLI, GUI, NUI(Natural, 말이나 행동), OUI (Organic, 모든 사물)
UI 설계원칙: 직관성, 유효성, 학습성, 유연성
UML 다이어그램 종류
구조적 다이어그램 (대표: 클래스 다이어그램, 객체 다이어그램)
행위적 다이어그램 (대표: 유스케이스 다이어그램, 순차 다이어그램, 상태 다이어그램)
애플리케이션 테스트
정적 테스트
동적 테스트 (중요)
화이트 박스 테스트: 코드를 오픈한 상태에서 논리적인 모든 경로 테스트
기초 경로 검사(Base Path Test): 모든 독립적인 실행 경로를 테스트하는 방법
제어 구조 검사: 조건 검사, 루프 검사, 데이터 흐름 검사
블랙 박스 테스트: 사용자 요구사항 명세서 보면서 동작 원리는 모르고 기능 작동해보며 테스트
동등 분할 검사 (Equivalence Partitioning)
입력 데이터를 유사한 특성을 가진 그룹으로 나누고, 각 그룹에서 대표값 선택해 테스트
경계값 분석 (Boundary Value Analysis)
입력 값의 경계 영역을 집중적으로 테스트
결정 테이블 테스트 (Decision Table Testing)
도표, 테이블을 만들어 입력에 따라 상태 변화 체크
상태 전이 테스트 (State Transition Testing)
입력에 따라 상태가 어떻게 변하는지 테스트
유스 케이스 테스트 (Use Case Testing)
사용자의 특정 행위 (유스 케이스) 따른 시스템 동작 테스트
오류 추정 (Error Guessing)
테스터의 경험과 직관을 바탕으로 오류 추정 및 테스트
테스트 평가 지표
구문 커버리지: 프로그램의 모든 구문이 한 번씩 실행될 수 있게 테스트 데이터 선정
결정 커버리지: 전체 결정문(조건문)을 테스트 하는 방법
조건 커버리지: 조건문 내에 참, 거짓을 적어도 한 번씩 결과가 나오도록 수행
조건/결정 커버리지: 전체 조건식과 개별 조건식이 참/거짓 한 번씩 나오게 (모든 결과 테스트)
변경조건/결정 커버리지: 각 개별 조건식이 독립적으로 영향주도록 테스트
다중조건/결정 커버리지: 모든 개별 조건식 모든 조합 다 커버리지
애플리케이션 테스트 기본원리
파레토 법칙: 애플리케이션의 20% 코드에서 전체 80% 결함이 발견됨
살충제 패러독스: 동일한 테스트 케이스 반복은 더이상 다른 결함 발견 못함
오류-부재의 궤변: 오류와 결함이 없더라도 요구사항을 만족하지 않으면 소프트웨어 품질은 낮은 것
완벽한 테스트 불가능: 테스트는 결함을 완전히 없애는 것이 아니라, 결함을 발견하는데 의의가 있음
테스트 하네스: 테스트 환경의 일부분으로 테스트를 지원하기 위해 생성된 코드나 데이터
테스트 드라이버
테스트 대상 모듈을 호출하는 더미 프로그램 (상향식 테스트 시, 임시로 만든 상위 모듈)
테스트 스텁
테스트 대상 모듈이 호출하는 프로그램 (하향식 테스트 시, 임시로 만든 하위 모듈)
관계대수
관계대수는 수학적 이론이고 이것을 구현해낸 것이 현재 컴퓨터과학의 데이터베이스 (RDBMS, SQL)
주로 나올 SQL
SELECT, UPDATE, DELETE, INSERT INTO
CREATE, ALTER, DROP
CREATE INDEX, DROP INDEX
관계 대수 기호
프로젝션은 중복값을 제거하고 릴레이션 만듦
합집합도 중복값 제거하고 릴레이션 만듦
합집합: SQL의 UNION
교집합: SQL의 INTERSECT
제약조건 키워드
PRIMARY KEY, FOREIGN KEY(+REFERENCES)
UNIQUE, NOT NULL, DEFAULT, CHECK, AUTOINCREMENT
ON DELETE CASCADE, ON UPDATE CASCADE
DCL
GRANT 권한 ON 테이블 TO 유저
REVOKE 권한 ON 테이블 FROM 유저
조인
세타조인
조인에 참여하는 두 릴레이션의 속성 값을 비교하고, 조건을 만족하는 튜플만 반환
조건의 종류: =, ≠, ≤, ≥, <, >
동등조인
세타조인에서 = (는) 연산자를 사용한 조인
가장 일반적으로 통용되는 “조인연산”
자연조인
동등 조인에서 조인에 참여한 속성이 두 번 나오지 않도록, 두 번째 속성을 제거한 결과를 반환
즉, 중복된 속성을 제거
세미조인
자연조인을 한 후에, 두 릴레이션 중에 한쪽 릴레이션의 결과만 반환
왼쪽과 오른쪽 중에 제거할 속성 쪽을 열어두는 형식으로 기호 작성
프로그래밍 코드 영역 외울 것
아스키 코드
A - 65
a- 97
문자 “0” - 48
완전수 (1~100 사이)
6, 28
문자열 상수풀
리터럴을 사용했을 때는 상수풀에 넣고 재사용 (같은 리터럴은 같은 참조객체를 쓴다)
new String()은 참조값이 다른 아얘 새로운 객체 생성, 힙 영역에 저장
Integer 캐싱 (Double 같은 자료형은 캐싱이 없음)
자바는 -128~127 범위안에 정수는 캐싱해 재사용 (참조값 동일) e.g. Integer num = 100
만일, int와 Integer를 == 비교하면, 언박싱으로 인해 true 나옴
new Integer(100)은 역시 참조값이 아얘 다른 새로운 객체 생성, 힙 영역 저장
(Boolean 등등…도 new 하면 참조값 아얘 다른 새로운 객체)
비트 연산자 종류
&(and), |(or), ^(xor), ~(not)
비트연산 XOR
XOR은 비트가 서로 다르면 1, 같으면 0
같은 XOR 연산을 3번하면 두 변수 값이 SWAP됨
e.g. a = a ^ b; b = a ^ b; a = a ^ b
비트연산 ~
양수에 not을 취하는 경우 결과값: -(해당 양수 + 1)
Reference
(2024) 일주일만에 합격하는 정보처리기사 실기
-
2024 당근 테크 밋업 후기
2024 당근 테크 밋업 후기
올 해 운은 2024 당근 테크 밋업 당첨에 몰아서 다 쓴 모양이다.
당첨이 정말 어렵다고들 하는데 운 좋게도 생애 첫 밋업의 기회를 얻었다. 덕분에 기다리는 순간부터 오늘까지 설레고 유쾌한 시간의 연속이었다.
나이스한 당근 엔지니어 분들과의 만남, 만족스러운 럭키 드로우 등 흥미로운 일들에 대한 경험담을 살짝 남긴다.
Frontend, Server, Data/ML, Platform 4가지로 파트가 나누어져 있었는데, 나는 서버 파트로 참여했다.
코엑스 컨퍼런스룸 3층으로 가니 예쁜 서체와 함께 행사장이 꾸며져 있었다. 입구부터 당근의 느낌(?)이 물씬 풍기는게 맘에 들었다.
입장할 때는 스태프분들이 팔에 당당한 밋업 참가자의 징표를 휘감아주었다. 뿌듯한 한 컷이다.
여담이지만, 행사장의 당근 관계자 분들은 다들 매우 친절하고 단합력이 좋았다.
서버 파트 강연 진행은 308호 공간에서 진행했는데, 도착하자마자 자리 잡고 강연을 듣는 형태였다.
다만, 이번 당근 밋업의 조금 특별한 점으로 당근 엔지니어가 주도하는 네트워킹 모임이 있었다.
규모는 4~8명으로 모임마다 다양하다. 밋업 몇 주 전부터 선착순으로 약 70개 넘는 주제로 네트워킹 참여 모집을 했는데, 실제로 행사 당일에 관련 주제를 가지고 서로의 경험과 대화를 나누는 네트워킹을 진행했다. (덕분에 당근 어플 모임 기능과 익숙해졌다.)
나는 주문 서비스에서 일한 경험이 있어, 당근페이 머니서비스 팀 네트워킹에 참여했다.
주문 서비스는 안정성과 데이터 정합성이 매우 중요한데, 당근페이 같은 큰 규모에서는 어떻게 관리하고 있는지 궁금했다.
모임을 주도하는 머니서비스팀 엔지니어 윈터, 윌리엄은 매우 나이스한 분들이었다. 당근페이에서 일했던 경험들을 자연스럽게 나누며 어떻게 생각하는지 참여자들과 서로 묻고 답했다. 함께 모인 다양한 개발자 및 기획자 분들도 유익한 질문들을 많이 던져주시더라.
이런 형태의 네트워킹이 처음이라 긴장을 많이 했는데, 화기애애한 분위기 속에서 머니서비스 팀이 극복했던 이슈와 앞으로의 목표를 진솔하게 들을 수 있었다.
잠시지만 좋은 분들과의 만남에 감사함을 가졌다.
첫 밋업이어서 이후에는 강연에 집중했다. 아직은 이해가 잘 되지 않는 내용도 많았지만 이런 이런 키워드들이 있구나를 알게된 것도 도움이 되었다.
“빠르게 변하는 도메인에서 살아남는 코드”라는 주제도 재밌었다. 당근 운영개발팀은 루비 레거시의 압박 속에서 확장성과 설정 가능성을 목표로 최대한 OCP를 지키는 리팩터링을 했는데, 과정 속에서 메타 프로그래밍으로 접근한 점이 흥미로웠다. 리플렉션 같은 느낌으로 런타임에 코드 자체를 동적으로 변경하며 기능을 확장했는데, ‘현업에서 이렇게 적용할 수도 있겠구나’ 고민해보며 생각을 확장할 수 있었다.
마지막 세션인 “당근의 회원 시스템을 마이크로서비스로 분리하기”에서는 당근 Identity Service 팀이 회원 서비스를 안전하게 분리하기 위해 진행한 디테일한 테스트와 도구들을 소개했다. 안정성을 위해 굉장히 디테일한 부분까지 신경쓰는 점이 인상 깊었고, 덕분에 대규모 서비스에서는 얼마나 신중하고 단계적으로 접근해야하는지 느끼는 시간이었다.
큰 규모에서는 생각하는 각도가 더욱 중요하겠구나 싶다.
기쁘게도 젯브레인 에코백이 내게 왔다.
강연 중간중간마다도 이벤트가 있었는데, 마지막에는 설문조사에 참여한 모든 참여자에게 럭키 드로우 찬스가 주어졌다. 품목으로는 젯브레인 배지, 키캡, 스티커, 에코백 등의 경품이 있었다. 키캡이 매우 인기 있었지만, 개인적으로 개발자스러운 패션 굿즈로서 에코백이 가장 갖고 싶었다.
신기하게도 단번에 뽑았는데, 돌아봐도 당첨 운이 참 좋은 밋업 기간이었다.
밋업과 연관은 없지만 마지막엔 코엑스 내 클로리스 티 룸에 들려 밀크티 프로즌을 마셨다.
예전부터 좋아하는 곳인데 밀크티 음료 조합이 독특하고 맛이 참 좋다. 코엑스에 갈 일이 있다면 강력 추천한다.
2024 당근 밋업은 생애 첫 밋업이어서 더욱 기억에 남는다. 약 1000명에 가까운 IT 업계 종사자들이 한데 모이는 모습이 신기하면서 기분 좋은 자극이 되었다.
또한, 당근 엔지니어 분들의 기술에 대한 열정, 동료들과의 화목함을 보며 당근의 분위기가 참 좋다고 느꼈다.
앞으로 어떤 곳에서 일할지 모르지만, 당근 같은 건강한 분위기에서 또 다시 개발하길 다짐한다.
-
자바 멀티스레드와 동시성
멀티태스킹 & 멀티프로세싱
프로그램 실행
프로그램을 구성하는 코드를 순서대로 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 멀티쓰레드 이해하고 통찰력 키우기
-
멀티 스레드와 디자인 패턴
Process & Thread 차이
Thread는 서로 메모리 공유 O
문제
모든 Thread가 하나의 자료구조(e.g. queue)를 공유하면 자료구조가 망가질 것
해결책: 배타제어 (=동기화)
Concurrent Class (동시성 컬렉션)
Lock
특정 코드 구간을 반드시 한 Thread만 실행하도록 막음 (크리티컬 섹션)
Lock을 건 코드 구간의 실행시간이 길수록 성능저하가 발생
최악의 경우 Single Thread가 차라리 나음
One Process, One Thread Architecture가 나온 이유
Redis도 처음에 이 아키텍처를 따름에도 매우 빨라서 인기 얻음
Lock Free
Lock을 사용하지 않고 배타제어
관련 키워드: interlocked.increment(), Atomic Operation, Lock-Free 알고리즘, Non-Blocking 알고리즘, CAS(compare and set)
Thread Safe하게 일반 Class 사용하기
Write는 한 Thread에서만, Read는 여러 Thread에서 진행하면 유용
Process는 서로 메모리 공유 X
문제
Process끼리는 메모리 공유가 안되기 때문에, 통신이 필요 (HTTP, TCP…)
MSA를 지향하는 현대 사회에서는 Process간 통신 필수
MSA = Multi Process
Multi Process 필요성
서버 머신 한 대 성능에는 한계, Scale Out 필수!
서버 Architecture 구상하는 입장에서는 Process 하나가 작은 기능을 담는 것이 훨씬 유리 (One Process, One Thread가 설득력 얻는 부분)
언어가 다른 Process끼리는 서로 패킷 주고 받는게 스트레스
해결책: Multi Process 간 통신 방법
서버 간 통신 방법
Google Protobuf, Apache Avro (Good)
IDL 파일에 모델을 정의해두면 Java, C++, JS, C# 등 여러 언어에서 사용 가능
JSON (Bad)
필드 추가시 상대방에게 알려주기 어려움
오타로 인한 디버깅 Cost
데이터를 어딘가에 올려놓고 필요한 서버가 알아서 가져가게 하는 방법
Redis Pub/Sub
특정 key에 데이터를 넣고 Pub/Sub
Queue 이용하기 (AWS SQS)
Queue에 넣고 데이터가 추가됐을 때, 특정 Topic으로 Event 받기
제 3 스토리지를 이용하는 것이므로 상대적으로 느림
빠르게 통신할 필요가 없는 경우 이용
웹서비스는 느리다는 느낌은 안듦
TCP 실시간 통신 서비스는 느리다 느낄 수 있음
Thread
Thread란?
흐르는 시냇물 위에 띄워놓은 돛단배
스레드 스타트 이후 계속 원하는 작업들이 진행될 것이고 내 손을 떠나도 계속 돌아감
Entrypoint (진입점)
public static void Main(String[] args) {}
Process가 맨 처음 실행하는 함수, 함수가 종료되면 Process도 종료
Main Thread에서 실행
쓰레드 사용하기
var thread = new Thread(Func);
스레드 생성
thread.Start();
Thread 생성자에 넣어준 함수를 별도의 스레드에서 실행
thread.Join();
스레드가 종료될 때까지 대기함 (Blocking)
Blocking & Non-Blocking
Blocking
함수를 실행하고 모든 코드가 완료된 후 리턴
Non-Blocking
실행한 함수의 코드가 완료되지 않고 리턴
Non-Blocking 함수의 실행과 완료를 아는 방법
Polling
주기적으로 확인하기
어떤 스레드에서 isFinish에 true 값을 넣으면 스레드 실행의 완료를 파악
while(true) {
if (isFinish == true) {
Break;
}
sleep(1000); //CPU 100%되지 않게
}
e.g. HTTP 통신
Event
Event가 발생했을 때 내가 원하는 함수를 호출해줌
setTimeout(callback, 1000); //1초 후 callback 함수 실행
콜백 지옥 유의 (요즘은 async & await 사용)
async & await 장점은 무엇인가요?
멀티스레드 프로그래밍(비동기 실행)을 하지만 Blocking 방식으로 진행해서 편함
**콜백지옥 피할 수 있음 **
public async function Task<string> GetString() {
...
}
string result = await GetString();
Console.Write(result);
getString() 함수는 다른 스레드에서 실행되지만 Blocking 방식으로 호출
= 비동기로 실행하지만 Blocking 방식
Server Thread Model
웹 서버, TCP 서버 등 서버 구현에 일반적으로 사용되는 스레드 모델
생산자 소비자 문제와 일치
생산자: I/O 스레드 (혹은 Worker 스레드라 부르기도 함)
네트워크 카드가 요청 데이터를 읽으면, I/O 스레드에서 해당 데이터를 Job Queue로 넘김
네트워크 카드 메모리가 매우 작으므로, 패킷이 가득차지 않게 작업만 빠르게 넘김
웹 서버나 프레임워크가 생산을 처리해 줌
Buffer: Job Queue
Job Queue는 메인 메모리에 위치
e.g. 웹이라면 request들이 담김
소비자: Worker Thread (혹은 Logic 스레드라 부르기도 함)
Worker Thread가 Job Queue에 작업들을 읽어서 처리
무거운 작업들 실행 (DB 접속, Redis 통신)
무거운 작업이라 오래 걸리지만, 최대한 빨리 실행되도록 해야 함
빨리 동작하지 않으면 Job Queue에 데이터가 차서 서비스 응답이 느려짐
일반적으로 개발자가 짠 로직은 Worker 스레드에서 돌아가는 코드를 짠 것
IOCP, EPoll
OS에서 제공하는 비동기 I/O 작업을 하기 위한 기술이다.
즉, I/O 요청을 하면 비동기로 처리해주고 결과도 비동기로 받게 된다. Windows에는 IOCP, Linux에는 Epoll이라는 기능이 이에 해당한다.
Guarded Suspension 패턴
할 일이 없는 Thread는 대기열에 넣고 할 일이 생기면 대기열에서 빼서 실행해주는 패턴
작업이 있으면 깨우고 없으면 쉼
Balking 패턴
내가 해야될 작업이 있는지 주기적으로 확인 (반복문)
작업이 있으면 하고 없으면 무시 (RUNNABLE)
스레드가 계속 동작하므로 작업이 없을 때 해야할 동작을 지정할 수도 있음
Read-Write Lock 패턴
Read 락과 Write 락을 따로 두는 락 메커니즘
한 스레드가 Write할 때는 다른 스레드가 Read 및 Write 모두 불가능
한 스레드가 Read할 때는 다른 스레드도 Read 가능
Read 스레드가 많고 Write 스레드가 좀 적다면, Read 성능 효율이 향상
Read 할 때는 Write를 하는지 안하는지만 판단
Read를 더 편하고 자유롭게 할 수 있음
만일, 사용한다면 각 언어에 구현된 클래스 찾아 사용할 것
Thread-Per-Message 패턴
하나의 작업 당 하나의 Thread가 실행하도록 위임
스레드 개수가 너무 많아지면 컨텍스트 스위칭 오버헤드가 높아져 성능 저하
Future 패턴
Main 스레드가 다른 스레드에 작업을 위임하고 본인 스스로도 다른 작업을 할 수 있게 하는 패턴
Thread-Specific Storage 패턴
스레드 마다 별도의 저장 공간을 가지게 하는 패턴
= 스레드 로컬: 각 스레드 별로 사용할 수 있는 변수
Reference
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편 - 데이터 접근 핵심 원리
-
데이터베이스 첫걸음
데이터 베이스가 갖춰야 할 기본 기능
데이터의 검색과 갱신
데이터베이스는 주소록에서 시작
데이터의 조회 및 등록, 수정, 삭제가 가능해야 함
데이터 포멧 및 처리 성능에 대한 고려가 필요
동시성 제어 (= 배타 제어)
데이터베이스는 동시에 복수의 사용자로부터 검색 및 갱신 처리를 받음
갱신의 무결성이 중요 (갱신 상황에 대한 제어가 필요)
만일 두 명의 사용자가 같은 파일에 접근해 수정하려 한다면 다음 동작 중 하나가 시나리오가 될 것
한 사람이 파일을 열고 있을 때, 다른 사람은 열 수 없음
한 사람이 파일을 열고 있을 때, 다른 사람은 읽기 전용(ReadOnly)으로만 파일을 열 수 있음
어떤 사람도 문제없이 파일을 열 수 있고, 나중에 수행된 쪽의 갱신이 반영
이를 더티 쓰기(Dirty Write)라고 하며, 선착순으로 갱신을 반영하는 상황
데이터 무결성 관점에서 지양
장애 대응
데이터베이스는 장애에 강해야 한다.
데이터 보호와 장애 대책에 최대한으로 예민해져야 함
데이터 소실은 큰 사회문제와 손해배상청구 유발
데이터 소실 문제에 대한 대책
데이터 다중화: 데이터를 복수의 장소에 분산해서 유지 (예방책)
백업: 데이터 소실이 발생했을 때 데이터를 복원하는 방법 (사후대책)
보안
데이터베이스에 보존된 데이터를 어떻게 숨길 것인가
데이터베이스는 사용자가 서버를 의식하지 못하도록 설계되고 있음
데이터베이스의 종류
계층형 데이터베이스
데이터를 계층구조로 관리 (조직도, 전체 구조도)
최초의 현대적 데이터베이스
관계형 데이터베이스
2차원 표 형식으로 데이터를 관리 (현재 가장 주류)
프로그래밍 언어를 사용하지 않아도 데이터를 조작 가능 (SQL)
SQL이란 관계형 데이터베이스가 데이터를 조작하기 위해 준비한 언어
객체지향 데이터베이스 & XML 데이터베이스
객체와 XML 형식으로 데이터를 관리 (비주류)
NoSQL 데이터베이스
Not only SQL (SQL뿐만 아니라 다른 것이 더 있다)
관계형 데이터베이스의 기능 일부를 버리고 성능(처리속도)을 높임
대량의 데이터를 고속으로 처리해야 하는 웹서비스와 잘 맞음
데이터베이스의 구성
DBMS와 데이터베이스
Database
데이터 저장소를 뜻하는 추상화된 개념
DBMS(Database Management System)
데이터베이스의 기능을 제공하는 소프트웨어
데이터베이스 추상화를 구현한 제품
MySQL, PostgreSQL, Oracle…
시스템과 데이터베이스
시스템은 여러가지 소프트웨어를 조합해 만들어야 하며, 이 작업을 SI(System Integration)라고 함
사용되는 소프트웨어는 크게 3가지로 구분하며 계층성을 띔
애플리케이션
비즈니스 로직을 자동화한 소프트웨어
사용자는 애플리케이션을 매개로 데이터베이스에 접근
미들웨어
중간 소프트웨어
DBMS가 해당하는 위치
운영체제
시스템을 동작하게 하기 위한 토대가 되는 기능을 제공하는 소프트웨어
적합한 조합은 예산, 제품 기능, 엔지니어 리소스를 고려해 선택
제품의 비즈니스적 관점도 고려 필요
현시점에서 최고라고 생각해 선택한 조합이 수년 후에는 불가능해지는 곳이 IT 세계
HP-UX + Oracle 조합은 두 기업의 친밀한 관계로 지속되었으나, 추후 서로 소송으로 얽힘
데이터베이스와 비용
시스템 혹은 서비스를 새로 만드는 목적은 돈벌이
관공서나 지방공공단체는 직접적인 이익추구를 하지 않지만, 사회 전체의 이익을 높이기 위해 시스템을 사용
비용감각이 있는 엔지니어가 되어야 함
시스템 전체 비용 내역
초기비용
서비스 이용시 최초로 지급하는 비용
하드웨어 구매비용, 엔지니어 급여…
운영비용
서비스 이용 기간에 지속적으로 지급하는 비용
유지보수 비용 (장애 대응, 프로그램 수정)
데이터베이스 관점의 비용
초기비용
소프트웨어 라이센스 요금 (사용허가료)
시스템 규모가 클수록 라이센스료가 증가 (CPU, 사용자 수는 규모의 척도)
종류
프로세서 라이센스(Processor License)
하드웨어(DB 서버) CPU 성능에 따라 가격 결정
어느 정도 규모를 가진 상용 시스템에서 채택
사용자 라이센스(User License)
사용자 수에 따라 가격 결정
사용자 수 파악이 쉬운 소규모 환경에서 채택
에디션과 옵션
종류
스탠다드 에디션
중소규모 시스템용
엔터프라이즈 에디션
대규모 시스템용
신뢰성, 성능, 보안 등의 추가 기능 지원 제공
익스프레스 에디션
시험판
평범한 기능 및 동작 확인용
운영비용
기술지원 비용
버그에 대한 기술적 Q&A, 긴급 수정 프로그램 배포 등의 지원
데이터베이스는 복잡한 로직으로 구현되어 있으므로, 해당 데이터베이스 개발자의 도움 필요
기술 지원 없는 소프트웨어 사용은 생명줄 없이 등산하는 것
너무 오래된 버전의 제품은 기술지원을 받을 수 있는 기간이 짧음
EOSL(End of Service LIfe)
제품이 배포되고 오랜 시간이 지나 기술 지원이 종료되는 시점
EOSL 가까우면 서비스 채택을 지양하거나 새로운 서비스로 마이그레이션해야 함
반대로 최신 버전은 버그의 집합체로 안정성과 신뢰성에 결함이 있는 경우가 많음
현실적인 데이터베이스 비용 선택지
벤더 제품의 데이터베이스
초기비용 있음 + 운영비용 있음
고기능이 더 많음
반영구적 거주 가능
오픈소스 데이터베이스
초기비용 없음 + 운영비용 있음
기술지원료만 유상 혹은 구독 요금제
초기비용 없이 간단히 시작할 수 있어 의사결정 비용이 낮음
초기비용이 낮으면 전체비용이 높은 경우가 많으니 전체 비용을 확실히 계산 후 냉정한 판단 필요
구독 요금 (Subscription)
무기한 사용의 라이센스 형태와 달리 기한을 정한 사용 허가
운영비용만 존재
기술 지원이 포함
데이터베이스와 아키텍처 구성
아키텍처 역사
아키텍처
시스템을 만들기 위한 물리 레벨의 조합
시스템의 목적과 기능을 표현 (아키텍처를 보고 그 시스템의 용도와 목적을 추측할 수 있음)
폭넓은 지식이 필요 (데이터베이스, 서버, OS, 미들웨어, 저장소, 로드밸런서, 방화벽…)
초반 아키텍처 설계가 프로젝트의 비용과 성패를 결정
역사
Stand-alone (1980)
데이터베이스가 동작하는 머신(DB서버)이 네트워크 없이 독립적으로 동작
물리적으로 서버 앞에 앉아서 사용해야함
클라이언트/서버 (1990~2000)
네트워크를 통해 데이터베이스 서버 1대에 복수 사용자의 단말이 접속하는 구성
보안상 이유로 주로 기업이나 조직 내 닫힌 네트워크(LAN)에 이용
네이티브 애플리케이션을 사용해 비즈니스 로직이 클라이언트에 존재
Web 3계층 (2000~)
Web Server, WAS, DB 서버로 이루어진 구성
클라이언트(브라우저)와 DB 서버 사이에 웹 서버와 웹 애플리케이션 서버를 둠
네트워크를 이용해도 애플리케이션 계층과 DB 계층의 보안성을 높일 수 있음
클라이언트의 비즈니스로직을 애플리케이션 계층으로 옮겨서 애플리케이션 관리 비용을 낮춤
다중화 관련 용어
다중화(=고가용성)
서비스 정지를 막기 위해 서버를 여러개 두어 1대가 고장나도 나머지가 동작하도록 구성하는 설계
클러스터링(Clustering)
동일한 기능의 컴포넌트를 병렬화하는 것
클러스터링으로 가용성을 높인다 = 여유도(Redundancy)를 확보한다 = 다중화
가용성
사용자 입장에서 시스템을 어느정도 사용할 수 있는지
신뢰성
컴포넌트(하드웨어, 소프트웨어)가 고장나는 빈도나 고장 기간을 나타내는 개념
가용성을 높이는 2가지 전략
심장전략: 시스템 내 각 컴포넌트의 신뢰성을 높이기
신장전략: 컴포넌트를 병렬화하기 (물량작전)
신뢰성이 낮은 컴포넌트를 사용하더라도 다중화(클러스터링)한다면 시스템 전체 가용성 상승
가동률(=가용률)
시스템이 무고장으로 동작할 확률
100% - 장애 발생률(서버)
100%는 원리적으로 불가능 (비용을 들여도 달성 불가능)
서버가 늘어날수록 가동률이 증가하나 증가 폭은 크게 적어짐
시스템 세계에서는 가용률 99%도 낮은 수치 (= 1년 중 3일 15시간 36분 서비스 다운)
유지보수 등의 계획정지를 포함하지 않는 경우 실제 가용률이란 표현을 사용하는 경우도 존재
단일 장애점(SPFO, Single Point of Failure)
다중화되어 있지 않아서 전체 서비스의 계속성에 영향을 주는 컴포넌트
단일 장애점의 신뢰성이 시스템 전체의 가용성을 결정
단일 장애점을 없애기 위해 대부분 이중화 노력
DB 서버의 다중화
DB 서버는 데이터를 보존하는 영속 계층이기 때문에, 오랫동안 클러스터링이 어려운 컴포넌트로 인식
웹서버, WAS는 데이터를 일시적으로 처리하므로 다중화가 간편
데이터는 항상 갱신되므로 DB 서버 다중화는 데이터 정합성이 중요
DB 서버 아키텍처 = DB 서버 + 저장소
대량의 데이터를 영구적으로 보존해야하고 그에 따른 성능도 요구됨
DB 아키텍처 패턴
클러스터링(Clustering)
Shared Disk (기본 다중화)
DB 서버 다중화 + 1개 저장소
저장소가 1개라 데이터 정합성은 신경 쓸 필요 없음
종류 (DB 서버 동시 동작 여부에 따라)
Active-Active
컴포넌트를 동시에 가동
Oracle RAC, DB2 pureScale 말고는 없음
장점
시스템 다운 시간이 짧음 (한 대가 다운되어도 남은 서버가 계속 처리)
좋은 처리 성능 (DB 서버 대수가 증가하면 동시 가동 CPU 및 메모리도 증가)
단점
저장소가 병목지점이므로 생각만큼 성능 향상이 없는 경우도 존재
저장소가 파괴될 경우 데이터 유실
Active-Standby
컴포넌트 중 실제 가동하는 것은 Active, 남은 것은 대기(Standby)
Standby 상태 DB 서버는 Active DB 서버에 장애가 날 때만 사용
Heartbeat: Standby DB 서버는 일정 간격으로 Active DB에 이상 없는지 체크 (수 초~수십 초)
신호가 끊기면 장애 발생으로 판단하고 Standby DB 서버 종작
전환시간만큼의 시스템 다운 발생 (수십초~수분)
종류
Cold-Standby
평소에는 Standby DB 작동 X, Active DB가 다운된 시점에 작동
Hot-Standby
평소에도 Standby DB 작동
전환시간이 더 짧지만, 라이센스료가 더 비쌈
사치스러운 구성이지만, 그럼에도 Active-Active보다 저렴
가용성과 성능이 좋은 순서(= 라이센스 가격순)
Active-Active
Active-Standby(Hot-Standby)
Active-Standby(Cold-Standby)
Shared Nothing (성능 추구를 위한 다중화)
네트워크 이외의 자원을 모두 분리하는 방식
서버, 저장소, 데이터를 한 세트 단위로 해서 여러 세트로 분리
구글이 개발한 구조를 Sharding이라고도 부름 (구글이 극적인 방식으로 유효성 증명)
장점
구조가 간단하고 저장소 병목 방지
서버와 저장소 세트를 늘리면 병렬처리 때문에 선형적으로 성능(처리율)이 향상
단점
각각의 DB 서버가 동일한 1개 데이터에 엑세스할 수 없음
시 단위 DB 서버 + 저장소 세트
고양시 데이터는 고양시 데이터를 가진 DB 서버만 엑세스 가능
경기도 인구 계산할 때는 각 시별 세트로부터 데이터를 집계 정리하는 서버 필요
DB 서버 다운 대책으로 커버링(Covering) 구성 필요
한 DB 서버가 다운되면 다른 DB 서버가 이어받아 계속 처리
리플리케이션 (Replication)
DB 서버와 저장소 세트를 복수로 준비하는 구성
종류
마스터 슬레이브 (주로 사용)
멀티 마스터 (복잡해서 흔치 않음)
성능과 갱신주기 사이에 트레이드 오프 고려 필요
Active 세트(마스터)와 Standby 세트(슬레이브)가 나뉘어 있음
주기적으로 Standby측 저장소를 최신 데이터로 동기화해야 함 (데이터 정합성 유지)
장점
원격지 리플리케이션 덕분에 가용성이 매우 높음
DB 서버와 저장소가 모두 사용 불능이어도 다른 1세트가 살아있다면 서비스 지속
자연재해로 서울 데이터센터가 파괴돼도 부산 데이터 센터가 무사하면 계속 처리 가능
피라미드형 리플리케이션 구성을 하면 부하 분산도 가능
오래된 데이터를 사용해도 되는 기능은 손자나 증손자 세트에 분산
커넥션과 세션
커넥션 (Connection)
로그인 후 사용자와 데이터베이스가 연결된 상태
커넥션이 유지되는 한 사용자는 데이터베이스와 무언가를 주고 받을 수 있음
데이터베이스는 동시에 여러 개의 커넥션 유지 가능 (=동시에 복수의 사용자 연결 병행처리)
전화 이미지와 유사
전화번호 입력 - 전화 걸기 - 상대방이 전화를 받음
사용자 정보 입력 - 로그인 실행 - 커넥션 연결 완료
세션 (Session)
커넥션의 시작과 종료 사이에서 교환의 시작과 종료까지의 단위
커넥션과 매우 유사하지만 실제로 커넥션 확립 후 세션 생성
기본적으로 커넥션과 세션은 1:1 대응
커넥션이 성립되면 동시에 암묵적으로 세션도 시작, 세션을 끊으면 커넥션도 끊어지는 경우가 대다수
관계형 데이터베이스의 계층
데이터베이스 내부의 테이블은 몇 개의 그룹으로 나뉘어 관리 (디렉토리와 유사)
4 계층 트리구조 (ANSI 표준 SQL)
1계층: 인스턴스 (Instance)
물리적 개념으로 DBMS 동작 단위
프로세스, 서버라 부름
멀티 인스턴스가 가능하지만, 거의 사용하지 않음
2계층: 데이터베이스 (Database)
3계층: 스키마 (Schema)
데이터베이스의 디렉토리에 해당하는 것
사용자가 스키마를 자유롭게 만들어 용도별 분류 혹은 권한 관리 등을 할 수 있음
4계층: 오브젝트 (Object)
테이블(Table), 인덱스 (Index), 저장 프로시저 (Stored Procedure) 등을 총칭
실제 RDBMS의 계층 분류
3계층 RDBMS: MySQL, Oracle
MySQL은 데이터베이스와 스키마를 동일한 것으로 간주
Oracle은 인스턴스 아래에 데이터베이스를 한 개만 만들 수 있다는 독자적 제약 (실질적 3계층)
4계층 RDBMS: PostgreSQL, SQL Server, DB2
트랜잭션과 동시성 제어
트랜잭션 (Transaction)
DB의 상태를 변경시키기 위해 복수의 SQL 쿼리를 한 작업 단위로 묶은 것
데이터를 파일에 저장하지 않고 데이터베이스를 이용하는 이유 중 하나
특성 (ACID)
Atomic(원자성)
트랜잭션 내 작업들이 전부 성공하거나 전부 실패하는 것을 보증
전부 성공하면 COMMIT, 하나라도 실패하면 ROLLBACK
Consistency(일관성)
허용된 방식으로만 데이터를 변경할 수 있도록 보증
트랜잭션은 데이터 변경 시 무결성 제약을 지킴 (유니크 제약 등)
Isolation(격리성, 고립성)
트랜잭션을 복수 사용자가 동시에 실행해도 각각의 처리가 모순없이 실행되는 것을 보증
모순 없음: 복수의 트랜잭션이 순서대로 실행되는 경우와 같은 결과를 얻을 수 있는 상태
= Serializable (직렬화 가능)
다만 격리성은 동시성 관련 성능 이슈로 인해 트랜잭션 격리 수준을 선택 가능
Serializable은 격리성을 온전히 반영하지만 성능면에서 실용적이지 않음
Durability(지속성)
트랜잭션이 커밋되면 영구적이 되어 그 결과를 잃지 않는 것
시스템 장애도 견딜 수 있음(데이터베이스나 OS의 비정상적 종료)
트랜잭션을 하드 디스크에 로그로도 기록하므로, 성공한 트랜잭션들은 복구
잠금 (Lock)
갱신시 락을 걸어서 후속처리를 블록하는 방법
갱신 중일 때 조회는 블록하지 않음
조회는 락을 얻지 않음
결과적으로 트랜잭션 수행 동안 하나의 로우를 동시에 수정하는 것은 안됨
잠금단위: 테이블 전체, 블록, 행
락 획득 경우
한 트랜잭션이 갱신을 시도하면 락을 얻음 (INSERT, UPDATE, DELETE)
SELECT ~ FOR UPDATE로 조회하면, 조회 시점부터 해당 트랜잭션이 끝날 때까지 락을 얻음
애플리케이션에서 금액 조회 후 해당 금액 관련 계산시 필요
락 타임아웃
갱신과 갱신이 부딪히는 경우 나중에 온 트랜잭션이 잠금 대기상태가 됨
설정한 락 타임아웃 대기시간을 넘어가면 다음 중 하나의 롤백 진행
오류가 발생한 쿼리만 롤백 (MySQL 기본 설정)
트랜잭션 롤백을 원할 때는 타임아웃 후 명시적 ROLLBACK 실행
트랜잭션 전체 롤백
innodb_rollback_on_timeout 설정
교착 상태 (Dead Lock)
트랜잭션끼리 각자가 점유하고 있는 락을 교차해 얻으려고 하여, 상황이 바뀌지 않는 상태
DBMS는 교착상태를 자동으로 검출해 상태를 보고
일반적인 데이터베이스에서 발생할 가능성이 있고 모든 것을 없앨 수는 없음
애플리케이션에서 트랜잭션을 항상 재실행할 수 있는 구조로 만들어야 함
대책
트랜잭션을 작은 단위로 자주 커밋
항상 각각의 테이블 액세스 순서 정하기
이유 없는 SELECT ~ FOR UPDATE 피하기
잠금 범위를 줄이기 (행 단위), 다만 동시성이 많은 경우 테이블 단위가 유리할 때도 있음
트랜잭션 격리 수준 (Transaction Isolation level, ANSI 표준)
실용적인 성능을 위해 Serializable로 부터 격리 수준을 완화해 자신이 아닌 다른 트랜잭션의 영향받는 것을 허용하는 4가지 단계
조회하는 사람 관점에서 생각하기
종류
Read Uncommitted (가장 완화)
다른 트랜잭션이 커밋하지 않은 데이터까지 조회
이상현상: Dirty Read, NonRepeatable Read, Phantom Read
갱신시 데이터 정합성 문제가 발생할 수 있어서 유의 (Dirty Read)
Read Committed - 일반적으로 가장 많이 사용
커밋 완료된 데이터만 조회 (최신 쿼리 실행 시점 커밋 데이터 읽음)
이상현상: NonRepeatable Read, Phantom Read
Repeatable Read
커밋 완료된 데이터만 조회하며, 하나의 트랜잭션에서 반복해 행을 조회하더라도 똑같은 행을 보장
이상현상: Phantom Read
Serializable (가장 엄격)
커밋 완료된 데이터만 조회하며, 트랜잭션을 순차적으로 진행시킴
이상현상이 없으나 성능이 낮음
이상현상
격리 수준을 완화하면 직렬화 가능에서 발생하지 않았던 현상 발생
Dirty Read
다른 트랜잭션이 아직 커밋하지 않은 데이터까지 읽음
사용자 A가 값을 변경하고 아직 커밋을 안해도 사용자 B가 변경 값을 읽음
NonRepeatable Read
한 트랜잭션 내에서 같은 행을 다시 조회할 때 값이 다름
행에 초점
사용자 A가 값을 읽고 사용자 B가 해당 값을 변경했을 때, 사용자 A가 다시 조회하면 최초 값이 아닌 변경된 값을 읽음
Phantom Read
한 트랜잭션 내에서두 번이상 범위 조회(count, 범위 검색 등)를 할 때, 데이터가 나타나거나 사라짐
전체 데이터에 초점
사용자 A가 범위 검색을 해 3행을 얻었는데 사용자 B가 범위 검색 조건에 해당하는 데이터 행을 하나 추가하고 커밋하면, 사용자 A가 범위 검색을 재실행했을 때 4행을 얻음
MySQL 테이블 종류
MyISAM형 테이블: 트랜잭션 사용이 불가능한 단순한 구조
InnoDB형 테이블: 트랜잭션 사용 가능 (MVCC 구조, multi versioning concurrency control)
MVCC에 따른 MySQL의 특성 (Multi Versioning Concurrency Control)
갱신중이라도 읽기는 블록되지 않음 (읽기와 읽기도 서로 블록하지 않음)
갱신 시 락을 얻음 (락은 기본적으로 행 단위로 얻으며 트랜잭션 종료시까지 유지)
갱신과 갱신 상황에서 나중에 온 트랜잭션의락 획득 시도를 블록 (락 타임아웃만큼 대기)
갱신 시 갱신 전 데이터를 UNDO 로그로 롤백 세그먼트 영역에 유지 (트랜잭션 롤백, 격리수준 대응 용)
Read Uncommitted 사용이 드문 이유
과거에는 MVCC가 주류가 아니어서, 시점에 따라 읽기가 블록되는 경우가 있었다. 해당 시기에는 값의 부정확함을 담보하더라도 읽기를 블록하지 않고 싶어 Read Uncommitted를 편리하게 사용했었는데, 현재는 MVCC가 읽기를 블록하지 않아서 필요성이 크게 줄었다. DBMS 차원에서 지원하지 않는 경우도 있다. (PostgreSQL, Firebird)
오토커밋(AutoCommit)
명시적 트랜잭션 개시: BEGIN TRANSACTION, START TRANSACTION, SET TRANSACTION
트랜잭션 개시가 명시적으로 지정되지 않았을 때, 오토커밋 모드에 여부에 따라 다음과 같이 트랜잭션을 구별
오토커밋 모드
하나의 SQL 문이 하나의 트랜잭션 (쿼리 실행 직후 자동 커밋)
보통 DBMS의 기본 설정(디폴트)
수동커밋 (오토커밋 모드 비활성)
COMMIT 혹은 ROLLBACK 실행할 때까지가 하나의 트랜잭션
트랜잭션 기능을 제대로 수행하기 위한 조건
수동커밋을 설정하는 것을 트랜잭션을 시작한다고 표현
설정한 오토커밋 옵션은 해당 세션 내에서 계속 유지 (중간에 변경 가능)
DDL은 실행시 자동으로 암묵적 커밋이 발생
DDL, DML, DCL
SQL 명령은 DDL, DML, DCL로 구분되며, SQL문 대부분은 DML이다.
데이터 정의 언어 (DDL, Data Definition Language)
스키마 혹은 테이블 등의 데이터를 저장하는 그릇을 작성하거나 제거
CREATE, DROP, ALTER
데이터 조작 언어 (DML, Data Manipulation Language)
테이블의 행의 검색 및 변경에 사용
SELECT, INSERT, UPDATE, DELETE
데이터 제어 언어 (DCL, Data Control Language)
데이터베이스에서 실행한 변경을 확정하거나 취소하는 데 사용
COMMIT, ROLLBACK
테이블 설계의 기초
관계형 데이터베이스가 표준이 된 이유는 데이터 정합성을 높이기 위한 설계 노하우가 매우 발달했기 때문
테이블은 집합이자 함수다!
테이블의 개념
테이블 명은 반드시 복수형이나 집합명사로 표현해야 함 (=공통 속성을 가진 것의 집합)
테이블은 현실세계를 반영
집합과 사물의 계층을 지켜야 함
가장 상위의 개념집합으로 정리해야 함
중복 행을 허용하지 않음 (기본키의 중요성)
등록 후 변경이 전혀 없는 과거 이력 데이터 같은 경우는 기본키 관리가 엄격하지 않아도 됨
거래, 병력, 급여 명세 등
다만, 기본적으로 기본키는 자연키보다 대리키(surrogate key)를 권장 (자연키는 변화함)
테이블은 클래스와 비슷하지만 메소드를 가지지 않아서 액션이 없고 데이터 조작만 받는 수동적인 존재
함수 종속성
테이블은 함수다 (=함수 종속성)
함수는 입력 값과 출력 값의 대응표
테이블은 기본키를 특정하면 어떤 레코드의 열 값 전체가 고유하게 특정됨
제2정규형과 제3정규형은 함수 종속성을 정리해 가는 과정
정규화
기본적인 테이블 정의 이론
설계 감각이 없는 사람이라도 어느정도 기계적으로 정답에 도달할 수 있도록 고안된 절차
테이블 설계는 90%(제1,2,3정규형 충족) + 10%(성능을 고려한 반정규화)
정규형을 지키지 않으면 갱신이상이 발생할 수 있음
갱신이상: 갱신 시 데이터부정합
종류
제1정규형(1NF)
스칼라 값만 존재하는 테이블 (테이블 셀에 배열 같은 복합적인 값을 포함하지 않음)
관계형 데이터베이스 테이블은 전부 제1정규형을 자동으로 만족 (기술적으로 위반 불가능)
제2정규형(2NF)
부분함수 종속성이 없는 테이블
기본키가 1개 열이라면, 자동으로 제2정규형 만족
부분함수 종속성
기본키가 복합키일 때, 기본키를 구성하는 열의 일부에만 함수 종속성이 있는 것
부분함수 종속성이 있는 테이블
열: [“고객기업 ID”, “주문번호”, “주문접수일”, “고객기업명”, “고객기업 규모”]
기본키가 {“고객기업 ID”, “주문번호”}일 때
테이블에 “고객기업명”, “고객기업 규모” 열은 “고객기업 ID”만으로 특정됨
두 열에 대해서 “주문번호”는 쓸데없는 정보
부분함수 종속이 존재하면, 해당 키와 종속하는 열만 다른 테이블로 만들어 외부로 꺼내야 함
불만족시 갱신이상
고객기업 정보를 어느정도 알지 못하면 주문을 등록할 수 없음
고객기업 규모를 모르면 값을 넣기 어려움 (NULL, 더미값 등록은 권장 X)
같은 고객기업 행이 복수 행 존재 (잘못 등록될 위험)
제3정규형(3NF)
추이함수 종속이 없는 테이블
추이함수 종속
기본키 이외의 키 간에 발생하는 함수 종속
추이함수 종속이 있는 테이블
열: [“고객기업 ID”, “고객기업명”, “고객기업 규모”, “업계코드”, “업계명”]
{업계코드} -> {업계명}의 함수 종속이 존재
추이함수 종속이 존재하면, 마찬가지로 테이블을 나누어 외부로 꺼내야 함
불만족시 갱신이상
기업 단위의 집합을 반영하는 테이블
업계 데이터만 추가하고 싶을 때, 기업과 실제 거래하지 않으면 새 레코드 추가 불가능
ER 다이어그램 (Entity-Relationship Diagram)
테이블 간의 관계를 그래픽으로 이해하기 쉽게 도와주는 기술
정규화로 인해 테이블이 많아지면, 시각적 표기가 중요해짐
IE(Information Engineering) 표기법이 널리 쓰임)
백업과 복구
데이터베이스는 크래시(비정상적 시스템 장애)가 일어날 때, 해당 시점까지 최신 커밋된 상태를 복구
지속성(Durability)을 지키며 성능을 높이기 위해 데이터베이스는 다음 구조를 가짐
DBMS의 데이터 보존 기억장치는 하드디스크 (느림)
로그 선행 쓰기 (WAL, Write Ahead Log)
우선 로그로 변경 내용을 기술하고 로그 레코드를 써서 하드디스크와 동기화 (MySQL InnoDB 로그)
디스크에 쓰는 횟수를 줄일 수 있어 성능이 좋음
크래시가 일어나면 메모리(버퍼) 상 데이터는 잃지만, WAL과 체크포인트를 참고해 복구 (=롤포워드)
백업
크래시 복원도 논리적 파괴(DDL 테이블 파기)나 물리적 파손(디스크 장치 고장)은 대응 불가능
정상 동작할 때 주기적으로 백업 중요
PITR (Point-in-time Recovery)
백업 이후 시점 실행된 갱신을 기록한 로그(archive)를 보존해서 복원한 DB에 순차 반영
백업으로 복원은 단순히 백업 시점으로 되돌리는 것이라 이후 수행한 갱신 반영 위해 필요
WAL로 쓰인 로그를 아카이브 지정해 PITR 용으로 보존할 수 있음
3가지 관점에 따른 백업 형태
데이터베이스 가동 여부
핫 백업 (=온라인 백업)
데이터베이스를 가동한채로 백업
데이터베이스 기능으로 백업 (mysqldump)
콜드 백업 (=오프라인 백업)
데이터베이스를 정지한 상태에서 백업
OS 기능으로 백업 (데이터 디렉토리의 모든 파일을 전부 복사)
백업 데이터 형식 구분
논리 백업
SQL 기반의 텍스트 형식으로 백업 데이터 기록
오픈 소스 DBMS 위주
물리 백업
데이터 영역을 그대로 덤프 (바이너리 형식)
벤더 DBMS 위주
백업 데이터 양
풀 백업
전체 데이터를 매일 백업
부분 백업
우선 풀 백업한 후, 갱신된 데이터 따로 백업
차등(Differential) 백업
풀 백업에서 차등만 백업
최초 풀 백업과 최후의 차등 백업으로 복원
증분(Incremental) 백업
전일로부터 증분만 백업
최초 풀 백업과 모든 증분으로 복원
백업 파일들은 떨어진 곳에 각각 보관하는 것이 중요
데이터 베이스와 백업 데이터를 다른 디스크 장치로 나눠 보관
장치를 지리적으로 떨어진 장소에 두면, 자연 재해로부터 데이터 보호도 가능
데이터베이스 장애는 일정 비율로 항상 일어나므로, 이를 고려해 대책을 세우고 비율을 줄이는 노력 필요
성능과 데이터베이스
성능 측정의 2가지 지표
처리시간 (Processing Time) = 응답시간 (Response Time)
어떤 특정 처리의 시작부터 종료까지 걸린 시간
처리율 (Throughput)
특정 처리를 단위 시간에 몇 건 처리 가능한가
50 TPS (트랜잭션을 초당 50건 처리), 50 PV/S (웹페이지를 초당 50회 열람)
시스템의 자원 용량을 결정하는 요인
처리율(동시 실행된 처리)에 비례해 필요한 자원 양 증가
시스템 자원 중 하나에서 병목 지점이 생기면, 한계점을 넘어 시스템 성능이 급격히 떨어짐
사이징(Sizing), 캐퍼시티 플랜(Capacity Plan)
한계점을 고려해 미리 자원을 확보해야 함
데이터베이스는 병목 되기 쉬운 포인트
취급하는 데이터 양이 가장 많음
스케일 아웃으로 해결이 어려움
데이터베이스 병목지점은 CPU, 메모리가 아닌 하드디스크
스케일 아웃은 Shared Nothing 정도만 해당
스케일 업을 통한 해결은 인메모리 데이터베이스로 실현
튜닝
애플리케이션을 효율화해 같은 양의 자원이라도 성능을 향상하게 하는 기술
= 어떻게 하면 SQL을 빠르게 할 수 있을까
데이터베이스는 성능 향상 제약 상황으로 인해 전통적으로 튜닝 기술이 발달
인덱스로 해결할 수 있는지 검사하는 것이 제1 선택
데이터베이스의 SQL 처리 과정
파스 (Parse)
SQL 문이 문법적으로 잘못된 부분이 없는지 검사
파서(Parser)가 담당
실행계획 (Execution Plan)
SQL 문에 필요한 데이터를 어떤 경로로 접근할지 계획
옵티마이저(Optimizer)가 담당
수많은 경로 중 가장 효율적인 경로를 선택 (풀 스캔, 레인지 스캔 판단 등…)
통계정보(Statistics)
옵티마이저가 실행계획을 세울 때 입력값으로 사용하는 정보
테이블 데이터를 샘플링 추출해 계산한 것으로 정확한 정보는 아니지만 속도적 이점을 줌
테이블 행열수, 각 열의 길이 및 데이터형, 테이블 크기, 기본키 및 NOT NULL 정보, 열 값의 분산 및 편향 등
DBMS가 자동으로 수집 (대체로 대량의 데이터가 변경될 때)
올바른 통계정보 수집이 중요
결과 정보 갱신 설정이 ON이 되게 해야함 (혹시 안되어 있다면 낡은 통계 정보 쓰게됨)
정기 갱신 형태에서 급격히 데이터 양이 변화하면, 실행계획이 비효율적일 수 있음
실행계획 평가
데이터 액세스
테이블 액세스 방법
실행 계획의 type 열
종류
풀 스캔 (All)
테이블에 포함된 레코드를 처음부터 끝까지 전부 읽어 들이는 방법
레인지 스캔 (range, ref…)
인덱스를 사용해 테이블의 일부 레코드에만 액세스하는 방법
인덱스 (Index)
책의 목차, 색인
구조 (B-tree)
핵심: 데이터를 반드시 정렬된 형태로 유지
균형 트리 구조 (Balanced-tree)
루트부터 리프까지의 거리가 일정한 트리구조
트리 중에서도 성능이 안정화
성능적이점
어떤 값에 대해서도 같은 시간에 결과를 얻음 (균일성)
이진 탐색
일반적으로 B-tree의 계층은 3~4 정도로 조절됨
어떤 값을 찾아도 2~4회 노드 액세스로 탐색 완료
데이터 양이 증가할 수록 성능 개선 효과 우수 (Log N)
1GB, 100만행도 소~중규모 데이터
인덱스의 큰 개선 효과는 더 큰 데이터에서 나옴
내부적으로 정렬을 사용하는 SQL의 정렬을 생략해 고속화
GROUP BY, 집계 함수, 집합 연산 (UNION, INTERSECT, EXCEPT)…
키로 지정된 열에 인덱스가 존재하면, 이미 정렬된 데이터를 바탕으로 정렬 생략
DB마다 차이는 있음
갱신 빈도가 높은 테이블은 정기적으로 인덱스 재구성으로 트리의 균형을 되찾아야 함
갱신이 반복되면 트리의 균형이 깨져가고, 인덱스 성능도 악화됨
어느정도 자동으로 균형 회복 기능 있지만, 수동 재구성도 필요
무분별한 인덱스 생성의 역효과
인덱스 갱신의 오버헤드로 성능 감소
통상 1행 레코드의 인덱스 갱신은 매우 빠름
몇천 몇만 몇억행 갱신이 모이면 인덱스 갱신 시간을 얕볼 수 없음
의도한 것과 다른 인덱스 사용되기도 함
인덱스 생성 기준
크기가 큰 테이블만 만들기
크기가 작은 테이블은 풀 스캔과 레인지 스캔의 차이가 없음
기본키 제약, 유일성 제약이 부여된 열에는 불필요
암묵적으로 이미 인덱스가 작성되어 있음
값의 중복 체크를 위한 데이터 정렬에 인덱스를 사용하면 편리하기 때문
Cardinality가 높은 열에 만들기
Cardinality: 값의 분산도
카디널리티가 높아야 인덱스 혜택을 받을 수 있음
예시
운전면허증 번호 »> 넘을 수 없는 벽 »> 한국 시도 행정구역 > 성별
Reference
데이터베이스 첫걸음
DB 격리 수준과 이상현상
스프링 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편 - 백엔드 웹 개발 핵심 기술
-
MySQL 주요 명령
MySQL 주요 관리명령
show status
MySQL의 상태에 대한 여러 정보 확인
Threads_connected
커넥션의 상태 및 수
Uptime
서버 가동 후 경과 시간
Queries
실행한 SQL의 수
show databases
데이터베이스 목록 보기
show tables
테이블 목록 보기
show table status
통계정보 보기
show create table 테이블명\G
테이블 정의 보기
explain SQL문
해당 SQL문의 실행계획 취득
show index from 테이블명
인덱스를 표시
desc 테이블명
테이블 열 정보 보기
use 데이터베이스 이름
특정 데이터베이스 사용하기
quit
로그오프
MySQL 주요 SQL
CREATE TABLE 테이블명 1 LIKE 테이블 2;
테이블 구조만 복제해 생성하기
INSERT INTO 테이블명 VALUES (..., ..., ...), (..., ..., ...), (..., ..., ...);
복수 행 입력 (Multi row insert)
INSERT 문 처리를 1번으로 정리해서 기존 INSERT 문 복수 회 실행 보다 처리시간이 더 짧음
다른 DBMS도 구현되어 있는 경우가 있음 (PostgreSQL, SQLServer, DB2…)
-
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
스프링 핵심 원리 - 기본편
-
HTTP URI 및 Status Code 설계 방법
HTTP 통신 유스 케이스
데이터 전송 방식 분류
쿼리 파라미터 전송 (검색어를 포함한 정렬 필터)
GET
메시지 바디 전송
POST, PUT, PATCH
유스케이스
정적 데이터 조회
이미지, 정적 텍스트 문서
리소스 경로로 단순 조회
동적 데이터 조회
검색어 포함 필터 및 정렬 적용
쿼리 파라미터 조회
HTML Form을 통한 데이터 전송
GET, POST만 지원
GET 전송
form 내용을 쿼리 파라미터 형식으로 전달
POST 전송
Content-Type: application/x-www-form-urlencoded (default)
form 내용을 메시지 바디 통해서 전송 (key=value 형태)
전송 데이터를 url encoding 처리
한글 같은 것이 들어오면 자동으로 인코딩 됨
abc김 -> abc%EA%B9%80
Content-Type: multipart/form-data
form 내용 및 다른 종류의 여러 파일을 메시지 바디 통해서 전송 (boundary로 타입마다 나눔)
파일 업로드 같은 바이너리 데이터 전송시 사용
API를 통한 데이터 전송
AJAX, Axios 등을 통한 자바스크립트 통신
Content-Type: application/json (JSON 데이터로 소통)
서버 to 서버, 웹 혹은 앱 클라이언트
URI 설계 단위
문서(Document)
단일 개념 (파일 하나, 객체 인스턴스, 데이터베이스 row)
members/1, /files/star.jpg
컬렉션(Collection)
서버가 관리하는 리소스 디렉토리
POST 기반 등록
서버가 리소스 URI를 결정
/members
스토어(Store)
클라이언트가 관리하는 리소스 디렉토리
PUT 기반 등록 (없으면 생성, 있으면 수정)
클라이언트가 리소스 URI를 결정
파일 시스템, 게시판 등에 적용
/files
컨트롤러(Controller), 컨트롤 URI
일반적인 HTTP 메서드만으로 해결하기 애매한 경우 사용
문서, 컬렉션, 스토어로 해결하기 어려운 추가 프로세스 실행
동사로 된 리소스 경로 사용
/members/{id}/delete
HTTP API 설계 예시
HTTP API - 컬렉션
회원 관리 시스템 예시
회원 목록: GET /members
회원 등록: POST /members
회원 조회: GET /members/{id}
회원 수정: PATCH, PUT, POST /members/{id}
실무에서는 엔터티의 속성이 매우 많으므로 PATCH를 쓰는게 제일 좋음
PUT은 하나라도 누락되면 데이터가 날아가버릴 위험 (게시판 게시글 수정 정도 OK)
둘 다 애매한 경우는 POST 사용
회원 삭제: DELETE /members/{id}
HTTP API - 스토어
파일 관리 시스템 예시
파일 목록: GET /files
파일 조회: GET /files/{filename}
파일 등록: PUT /files/{filename}
파일 삭제: DELETE /files/{filename}
파일 대량 등록: POST /files
HTML Form
순수 HTML, HTML Form만을 사용해야 할 때의 시나리오
GET, POST만 지원
메서드 제약을 컨트롤 URI로 해결
회원 관리 시스템 예시
회원 목록: GET /members
회원 등록 폼: GET /members/new
회원 등록: POST /members/new (혹은 /members)
회원 조회: GET /members/{id}
회원 수정 폼: GET /members/{id}/edit
회원 수정: POST /members/{id}/edit (혹은 /members/{id})
회원 삭제: POST /members/{id}/delete
List 형식의 쿼리 파라미터
쿼리 파라미터에서 같은 키 값에 대해 복수의 value를 보낼 수도 있음
id=1&id=2&id=3&id=4
HTTP 상태코드
클라이언트는 상위 상태코드로 해석해 처리하므로 미래에 새 상태코드가 추가되어도 클라이언트는 변경 X
2xx (Successful)
200 OK
201 Created
요청 성공해서 새로운 리소스가 생성됨
응답의 Location 헤더 필드로 생성된 리소스 식별 (Location: /members/1)
혹은 응답 메시지 바디에 id를 리턴해 생성된 리소스 식별
202 Accepted
요청이 접수되었으나 처리가 완료되지 않았음
배치 처리 (요청 접수 1시간 후 배치 프로세스 시작)
204 No Content
서버가 요청을 성공적으로 수행했지만, 응답 페이로드 본문에 보낼 데이터가 없음
웹 문서 편집기 save 버튼
3xx (Redirection)
요청을 완료하기 위해 유저 에이전트의 추가 조치 필요
웹브라우저는 3xx 응답 결과에 Location 헤더가 있으면, Location 위치로 자동 이동
영구 리다이렉션 (거의 사용 X)
리소스 URI가 영구적으로 이동
원래의 URL을 사용하지 않고 검색엔진에서도 변경을 인지
301 Moved Permanently
리다이렉트시 요청 메서드가 GET으로 변하고, 본문(메시지 바디)이 제거될 수 있음
308 Permanent Redirect
301과 같은 기능
리다이렉트시 요청 메서드와 본문 유지 (POST로 보내면 리다이렉트도 POST)
일시 리다이렉션
리소스 URI가 일시적으로 변경
검색엔진에서 기존 URI 유지
처음 302의 의도는 메서드 유지였으나 애매한 스펙 기재로 웹브라우저들이 GET으로 변경하도록 구현되었고 결국 명확한 스펙의 307, 303이 등장 함 (301 대응의 308도 마찬가지)
302 Found (현실적으로 이미 많은 라이브러리가 디폴트로 사용하므로 302만 써도 무방)
리다이렉트 요청 메서드가 GET으로 변하고, 본문이 제거될 수 있음
307 Temporary Redirect
302와 같은 기능
리다이렉트시 요청 메서드와 본문 유지 (POST로 보내면 리다이렉트도 POST)
303 See Other
302와 같은 기능
리다이렉트시 요청 메서드가 GET으로 변경
PRG (Post/Redirect/Get) (자주 사용)
POST 주문 후 새로고침하면 재요청으로 인해 중복 주문이 될 수 있음
따라서, POST 주문 후에 주문 결과 화면을 GET 메서드로 리다이렉트
특수 리다이렉션
304 Not Modified
클라이언트에게 서버 리소스가 수정되지 않았음을 알려줌
클라이언트는 로컬 캐시로 리다이렉트 (캐시 재사용)
응답 메시지 바디 X
조건부 GET, HEAD 요청시 사용
4xx (Client Error) - 오류의 원인이 클라이언트에 있으므로, 재시도가 항상 실패
400 Bad Request
클라이언트가 잘못된 요청을 해서 서버가 요청을 처리할 수 없음
요청 파라미터가 잘못되거나, API 스펙이 맞지 않을 때 (백엔드는 철저히 validation해야 함)
401 Unauthorized
클라이언트가 해당 리소스에 대한 인증이 필요함 (인증 실패)
응답에 WWW-Authenticate 헤더와 함께 인증 방법 설명
403 Forbidden
서버가 요청을 이해했지만 승인을 거부함 (인가 실패, 접근 권한 불충분)
로그인한 어드민 등급이 아닌 사용자가, 어드민 등급 리소스에 접근하는 경우
404 Not Found
요청 리소스가 서버에 없음
혹은 권한이 부족한 클라이언트에게 해당 리소스를 완전히 숨기고 싶을 때 (403도 안내고 완전히 숨기고 싶을 때)
5xx (Server Error) - 오류의 원인 서버에 있으므로, 재시도가 성공할 수도 있음
500 Internal Server Error
서버 내부 문제로 오류 발생
애매하면 500
503 Service Unavailable
서비스 이용 불가
서버가 일시적인 과부하 혹은 예정된 작업으로 잠시 요청을 처리할 수 없음
Retry-After 헤더 필드로 얼마뒤에 복구되는지 보낼 수 있음
서버는 왠만하면 500대 에러를 내서는 안됨. 항상 200대 혹은 400대 에러로 해결할 것
Reference
모든 개발자를 위한 HTTP 웹 기본 지식
-
HTTP 헤더 종류
HTTP header
HTTP 전송에 필요한 모든 부가정보
History
RFC2616 (폐기)
Header를 General header, Request header, Response header, Entity header로 분류
Entity body(실제 데이터)는 Message body에 담음
Entity header는 Entity body 해석을 위한 정보 제공 (Content-Type, Content-Length)
RFC723x
Entity => Representation(표현)
회원이라는 리소스를 특정 데이터 형식(HTML, JSON, XML)으로 표현해 전달하겠다는 의미
Representation = Representation Metadata + Representation Data
Representation Data는 Payload(=Message body)에 담음
일반 HTTP 헤더
표현 헤더
Content-Type
미디어 타입, 문자 인코딩
text/html; charset=utf-8, application/json (디폴트 인코딩: utf-8), image/png
Content-Encoding
표현 데이터의 압축 정보 (전달자가 헤더 추가)
gzip, deflate, identity(=압축 X)
Content-Language
자연 언어
ko, en, en-US
Content-Length
바이트 단위
Transfer-Encoding 사용 시에는 필요 없음
협상 헤더 (Content Negotiation)
클라이언트가 선호하는 표현을 서버에 요청하고 서버는 최대한 클라이언트 선호에 맞춰 응답
요청시에만 사용하는 헤더
종류
Accept (미디어 타입)
Accept-Charset (문자 인코딩)
Accept-Encoding (압축 정보)
Accept-Language (자연 언어)
협상 우선순위
Quality Values(q)
0~1: 클수록 높은 우선순위
생략 시 1
Accpet-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
구체적인 것이 우선
Accept: text/*, text/plain, text/plain;format=flowed, */*
text/plain;format=flowed > text/plain > text/* > */*
전송방식 관련 헤더
단순 전송
Content-Length 헤더와 함께 한번에 데이터 전송
압축 전송
Content-Length + Content-Encoding 헤더와 함께 압축된 데이터를 전송
분할 전송
Transfer-Encoding: chunked 헤더와 함께 데이터를 일정한 단위로 쪼개어 보냄
Content-Length 헤더는 보내면 안됨
큰 용량의 데이터를 한 번에 보내느라 기다리는 상황이 생기지 않도록, 분할된 데이터가 오는대로 바로바로 보여주는 방식
서버에서 5byte가 만들어지면 클라이언트에 먼저 보내고, 또 만들어지면 또 보내서 마지막에 0바이트 \r\n을 보내고 끝을 표현
범위 전송
Range(요청 헤더), Content-Range(응답 헤더)와 함께 범위를 지정해 데이터를 전송
데이터를 절반정도 받다가 연결이 끊겼을 때, 못받은 범위만큼만 재요청하면 효율적
일반 정보 헤더
요청 헤더
From
유저 에이전트의 이메일 정보
거의 사용되지 않지만 검색 엔진 같은 곳에서 주로 사용 (크롤링 그만해달라는 요청을 할 수 있는 연락 수단)
Referer
이전 웹 페이지 주소
유입 경로 분석에 사용
User-Agent
클라이언트의 애플리케이션 정보 (웹브라우저 정보)
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/ 537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36
통계 정보 혹은 특정 브라우저의 장애에 대한 파악에 이용
응답 헤더
Server
ORIGIN 서버의 소프트웨어 정보
ORIGIN 서버: 여러 프록시 서버, 캐시 서버를 제외하고 정말로 요청을 처리해 응답하는 서버
Server: Apache/2.2.22 (Debian)
server: nginx
Date
메시지가 발생한 날짜와 시간
최신 스펙에서 응답에만 사용하도록 명시
특별 정보 헤더
요청 헤더
Host (필수)
요청한 호스트 정보 (도메인)
클라이언트가 DNS를 거쳐 얻은 IP로 가상호스팅 중인 서버에 패킷을 보냈을 때, 어떤 도메인으로 전달해야 할지 판단하는 것에 구분점이 됨
가상호스팅: 하나의 IP 주소에 여러 도메인이 적용되어 있는 상황 (도메인이 다른 여러 애플리케이션 구동)
응답 헤더
Location
페이지 리다이렉션
201: 요청에 의해 생성된 리소스 URI
3xx: 요청을 자동으로 리다이렉션할 리소스 URI
Allow
해당 Path에서 허용 가능한 HTTP 메서드를 확인해 서버에서 보냄
405 (Method Not Allowed)에는 반드시 포함
실제로 구현되어 있는 곳은 별로 없음
Retry-After
유저 에이전트가 다음 요청을 하기까지 기다려야 하는 시간
503 (Service Unavailable) 응답 시 서비스가 언제까지 불능인지 알려줌
날짜표기 혹은 초단위 표기
인증 헤더
Authorization (요청 헤더)
클라이언트 인증 정보를 서버에 전달
Authorization: Basic xxxxxxxxx
Authorization: Bearer xxxxxxxxx
WWW-Authenticate (응답 헤더)
리소스 접근시 필요한 인증 방법 정의
정의해준 방법으로 다시 제대로 인증 정보를 생성해서 인증하라는 의미
401 (Unauthorized)와 함께 사용
WWW-Authenticate: Newauth realm="apps", type=1,
title="Login to \"apps\"", Basic realm="simple"
쿠키 헤더
특징
HTTP는 Stateless 프로토콜이므로 상태가 요구되는 상황에서는 쿠키로 저장
사용자 로그인 세션 관리
광고 정보 트래킹
GDPR(General Data Protection Regulation, EU 개인정보보호 법령)로 인해 EU 회원국의 웹사이트들은 유저들로부터 쿠키 수집 동의를 받아야 함 (필수쿠키, 기능쿠키, 성능쿠키, 마케팅쿠키 등에 대해 각각 선택도 가능)
쿠키는 항상 서버에 전송되므로 네트워크 트래픽이 유발되기 때문에, 최소한의 정보만 사용해야 함 (세션 id, 인증 토큰)
보안에 민감한 데이터는 저장하면 안됨 (주민번호, 신용카드 번호)
생명주기
세션 쿠키: 만료 날짜가 생략된 쿠키는 브라우저 종료시까지만 유지
영속 쿠키: 만료 날짜가 입력된 쿠키는 해당 날짜까지 유지
Cookie (요청 헤더)
서버에서 받은 쿠키를 클라이언트가 HTTP 요청시 전달
Set-Cookie (응답 헤더)
서버에서 클라이언트로 쿠키 전달
Field Value
expires
만료일이 되면 쿠키 삭제
max-age
0이나 음수를 지정하면 쿠키 삭제
domain
쿠키를 전송받는 서버 도메인의 범위 제한
예시) domain=example.com
명시
기준 도메인 + 서브 도메인 적용
example.com&dev.example.com까지 쿠키 접근 가능(=쿠키 전송)
생략
기준 도메인만 적용
example.com만 쿠키 접근 가능
path
해당 경로를 포함해 하위 경로 페이지까지만 쿠키 접근 가능
일반적으로 path=/ 루트로 지정
Secure
https인 경우에만 쿠키 전송
HttpOnly
자바스크립트로 쿠키 접근 불가, http 전송에만 사용 가능
XSS 공격 방지
SameSite
쿠키를 전송하는 요청 도메인(=현재 접속해 있는 페이지)의 범위 제한
요청 도메인이 쿠키에 설정된 도메인과 같은 경우에만 쿠키 전송
XSRF 공격 방지
속성
Strict: 같은 도메인에서만 접근 가능
퍼스트 파티 쿠키 only
Lax: <a>, <link>, <form method="GET">통한 이동은 다른 도메인이어도 cookie 전송
Chrome 80 default
퍼스트 파티 쿠키 + 일부 서드 파티 쿠키
None: cross-site에서도 쿠키 전송 가능 (단, Secure 옵션 추가필수)
퍼스트 파티 쿠키 + 모든 서드 파티 쿠키
캐시와 조건부 요청 HTTP 헤더
(캐시 제어 헤더) + (검증 헤더 & 조건부 요청 헤더 한 쌍) 캐시 조합 권장
cache-control: max-age=... + Last-Modified
cache-control: max-age=... + ETag (Recommendation)
캐시 기본 동작
첫 번째 요청시응답에서 특정 캐시 헤더 및 바디 데이터를 브라우저 캐시에 저장
cache-control: max-age=60
Last-Modified: 2023-04-23...
ETag: "aaaaaaaaa"
두 번째 요청시 캐시 유효시간(max-age 값) 검증
유효: 캐시에서 조회
유효 X
서버로 요청
조건부 요청 헤더 추가
검증 헤더에 따라 If-Modified-Since 혹은 If-None-Match
서버 검증
기존 데이터 변경 X
304 Not Modified (HTTP Body X) 응답
캐시에서 조회 (재사용)
브라우저 캐시갱신 (응답 캐시 헤더)
기존 데이터 변경
200 OK, 변경된 데이터 응답
브라우저 캐시 갱신 (응답 캐시 헤더 + 바디)
헤더 종류
캐시 제어 헤더
Cache-Control (캐시 제어)
max-age
캐시 유효 시간, 초 단위
no-cache
데이터를 캐시해도 되지만, 항상 원 서버(Origin Server)에 검증하고 사용
no-store
데이터에 민감한 정보가 있으므로 저장하면 안됨 (메모리에서 사용하고 최대한 빨리 삭제)
Pragma (캐시 제어, HTTP 1.0 하위호환)
no-cache (위와 동일)
Expires (Cache-Control: max-age 하위호환, 함께 사용시 Expires는 무시됨)
캐시 만료일을 정확한 날짜로 지정
검증 헤더 (Validator)
캐시 데이터와 서버 데이터가 같은지 검증하는 데이터
Last-Modified
데이터가 마지막으로 수정된 시간
1초 미만 단위의 캐시 조정이 불가능
ETag (Entity Tag)
캐시용 데이터에 임의의 고유한 버전 이름(Hash)을 붙이고 데이터 변경시 Hash 재생성
ETag가 같으면 캐시유지, 다르면 변경된 데이터 전송
서버에서 별도 캐시 로직을 관리하고 싶은 경우 사용
데이터 수정 날짜가 다르지만 A -> B -> A로 수정해 데이터 결과가 똑같은 경우
스페이스나 주석 같이 크게 영향 없는 변경 무시
애플리케이션 배포 주기에 맞추어 ETag 모두 갱신
조건부 요청 헤더
검증 헤더를 통해 브라우저 캐시에 저장된 값으로 조건에 따른 분기 요청
If-Modified-Since: Last-modified 값 사용
If-Unmodified-Since: Last-modified 값 사용
If-None-Match: ETag 값 사용
If-Match: ETag 값 사용
장점
비싼 네트워크 사용량을 줄일 수 있음 (캐시 유효시간동안 네트워크 이용은 용량이 적은 헤더 전달뿐)
브라우저 로딩 속도가 매우 빨라져서 사용자 경험이 좋아짐
프록시 캐시
원 서버가 멀리 있는 경우 중간에 프록시 캐시 서버(CDN 서비스)를 두어 속도적 이점을 얻음
클라이언트(한국) - (0.5초) - 원 서버(미국)
클라이언트(한국) - (0.1초) - 프록시 캐시 서버(한국 어딘가) - (0.4초) - 원 서버(미국)
보편적 캐시 방법
첫 번째 접근이 오래걸리고 두 번째 이후부터는 다운이 이미 받아져 빨라짐
유튜브의 인기 있는 영상은 로딩이 빠르고 인기 없는 영상은 로딩이 느림
원 서버에서 캐시 서버로 데이터를 밀어 넣는 경우도 있음
관련 캐시 응답 헤더
Cache-Control: public
응답이 public 캐시에 저장되어도 됨 (=중간 프록시 캐시 서버에 저장되어도 됨)
Cache-Control: private
응답이 private 캐시에 저장되어야 함 (기본값)
Cache-Control: s-maxage
프록시 캐시에 적용되는 max-age
Cache-Control: must-revalidate
캐시 만료 후 최초 조회시 원 서버에 검증해야 함
원 서버 접근 실패시 반드시 오류가 발생해야 함 (504 Gateway Timeout)
캐시 시간이 유효하다면 캐시 사용
Age: 60
원 서버에서 응답 후 프록시 캐시 내에 머문 시간(초)
확실한 캐시 무효화 응답
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
기본적으로 웹브라우저 임의로 캐시를 할 수 있기 때문에 완전한 캐시 무효를 위해 사용
네트워크 단절 등으로 인한 원 서버 접근 불가 시 must-revalidate이 필요
no-cache의 경우 캐시 서버 설정에 따라 원 서버에 접근할 수 없는 경우, 캐시 데이터를 반환할 수 있음 (오류보다는 오래된 데이터라도 보여주기, 200 OK)
must-revalidate은 원서버에 접근할 수 없는 경우, 항상 오류 발생시킴 (매우 중요한 돈과 관련된 결과들에 필수, 504 Gateway Timeout)
용어
원 서버 (Origin Server): 실제 요청을 처리하는 서버
public 캐시: 프록시 캐시 서버
private 캐시: 각각의 브라우저의 로컬 캐시
Reference
모든 개발자를 위한 HTTP 웹 기본 지식
마케터를 위한 웹사이트 쿠키 동의 환경의 이해
What are the security differences between cookies with Domain vs SameSite strict?
SameSite란? None, Lax, Stricts
-
HTTP 기본 구조
기본 용어
IP (Internet Protocol)
패킷(Packet)을 단위로 특정 주소(IP Address)에 데이터를 전달할 수 있는 프로토콜
IP 패킷 (보내려는 메시지 + 출발지 IP, 도착지 IP…)
한계
비연결성
패킷을 받을 대상이 없거나 상대 서버가 불능 상태여도 전송
비신뢰성
중간에 패킷이 누락되거나 순서대로 오지 않는 경우 존재
프로그램 구분
같은 IP인데 통신하는 애플리케이션이 2개 이상인 경우 구분 불가
전송계층(Transport Layer)
네트워크 4계층에서 TCP 혹은 UDP 추가 정보로 IP 패킷을 보완하는 단계
TCP (Transmission Control Protocol)
앞선 IP의 문제점을 해결 (전송제어 정보를 패킷에 추가)
TCP/IP 패킷 (IP 패킷 + 출발지 PORT, 목적지 PORT, 전송제어, 순서, 검증정보…)
특징
연결지향 (3 way handshake)
SYN, SYN+ACK, ACK 3단계로 연결을 확인하고 그 후 데이터를 보냄
최근엔 최적화되어 세 번째 단계 ACK에서 데이터를 함께 보내는 것이 가능
데이터 전달 보증
서버는 데이터를 잘 받았다는 응답을 클라이언트에게 줌
순서 보장
기본적으로는 패킷 1, 3, 2 순서로 왔다면 2부터 다시 보낼 것을 클라이언트에 요청
서버 최적화에 따라 다시 보내달라는 요청 없이 내부적으로 처리하기도 할 것
UDP (User Datagram Protocol)
IP와 비슷할 정도로 기능이 거의 없음 (하얀 도화지)
PORT, 체크섬 정도만 추가
TCP의 연결지향, 데이터 전달 보증, 순서 보장 등이 없다.
덕분에 단순하고 빠름
TCP는 3 way handshake와 패킷의 추가정보들로 인해 데이터가 크고 속도가 느림
따라서, 속도 최적화는 UDP 이용
HTTP3 스펙에서도 UDP를 활용하며 최근 각광
PORT
같은 IP(내 서버) 내에 여러 프로세스가 통신 중일 때, 응답 패킷이 어느 애플리케이션의 패킷인지 구분
IP가 아파트면 PORT는 동호수를 표현
0~65535 할당 가능
0~1023은 잘 알려진 포트로 사용하지 않는 것이 좋음
HTTP - 80
HTTPS - 443
DNS (Domain Name System)
전화번호부 같은 서버를 제공하여 도메인명을 IP 주소로 변환하는 역할 수행
IP는 기억하기 어렵고 가변적이어서 DNS가 이를 해결
URI (Uniform Resource Identifier)
자원을 식별하는 방법을 총칭
URL(Uniform Resource Locator) + URN(Uniform Resource Name)
URL: https://www.inflearn.com/course/lecture
URN: urn:isbn:01270712
URN은 보편화 되지 않아서 URI = URL로 생각해도 무방하다.
URL 문법
Syntax: scheme://[userinfo@]host[:port][/path][?query][#fragment]
예시: https://www.google.com:443/search?q=hello&hl=ko
scheme
주로 프로토콜 사용 (어떤 방식으로 자원에 접근할 것인가에 대한 약속)
http, https, ftp
port
http 80 포트, https 443 포트 등 보편적인 경우 생략 가능
userinfo
URL에 사용자 정보를 포함해서 인증하는 경우 사용하지만 거의 쓰이지 않음
host
도메인명 또는 IP 주소를 직접 사용 가능
path
계층적 구조의 리소스 경로
query
key-value 형태
?로 시작, &로 추가
서버로 요청시 모두 문자로 넘어감
= query parameter = query string
fragment
html 내부 북마크에 사용
서버 전송 정보가 아님
브라우저 요청 흐름
클라이언트
애플리케이션 계층
웹 브라우저에 요청: https://www.google.com:443/search?q=hello&hl=ko
웹 브라우저가 DNS 조회 및 PORT 정보 파악
웹 브라우저가 HTTP 요청 메시지 생성
SOCKET 라이브러리
파악한 IP 및 PORT 정보로 구글 서버와 3 way handshake로 연결 맺기
OS로 데이터 전달
OS 계층 (TCP/UDP & IP 계층)
TCP/IP 패킷 생성 (HTTP 메시지 포함)
네트워크 인터페이스
패킷에 이더넷 프레임을 씌워 인터넷망으로 던짐
인터넷 망
수많은 인터넷 노드를 거쳐 목적지 구글 서버에 패킷 전달
구글 서버
구글 서버는 반대 과정으로 tcp/ip 패킷을 까서 http 메시지를 해석
구글 서버는 요청에 맞는 http 응답 메시지를 생성하고 TCP/IP 패킷을 씌워 클라이언트에 다시 보냄
인터넷 망
수많은 인터넷 노드를 거쳐 클라이언트 웹브라우저에 응답 패킷 전달
클라이언트
클라이언트는 응답 패킷을 까서 http 메시지를 해석
메시지 내 데이터를 웹 브라우저가 렌더링하여 화면에 출력
HTTP (HyperText Transfer Protocol)
모든 형태의 데이터를 HTTP 메시지로 전송 가능
처음엔 HTML 같은 HyperText 문서 전송 용도로 시작
HTTP/1.1 (1997)
가장 많이 사용되는 중요한 버전
주요 기능이 이미 모두 포함됨
RFC7230~7235(2014)이 최신 개정판
HTTP/2, HTTP/3은 성능 개선에 초점
TCP 이용
HTTP/1.1, HTTP/2
UDP 이용
HTTP/3
특징
클라이언트-서버 구조
클라이언트(UI, 사용성) & 서버(비즈니스 로직, 데이터) 분리로 각각이 독립적 진화 가능
무상태 프로토콜(Stateless)
서버가 클라이언트의 상태를 보존하지 않음
서버 Scale Out(수평 확장)에 유리
무상태는 응답 서버를 쉽게 바꿀 수 있으므로 무한한 서버 증설 가능
갑자기 클라이언트 요청(고객)이 증가해도 서버(점원)를 대거 투입할 수 있음
한계
무상태로 설계할 수 없는 경우도 있음
쿠키 세션 로그인
요청 데이터가 많음
최대한 무상태로 설계하고 어쩔 수 없는 경우에만 상태 유지
정말 같은 시간에 딱 맞추어 발생하는 대용량 트래픽 감당을 위한 필수 설계
선착순 1000명 이벤트는 수만명 동시 요청 발생
첫 페이지에 로그인도 필요 없는 정적 페이지 하나를 두면 조금 분산이 됨
비연결성(Connectionless)
요청 및 응답할 때만 연결하고 바로 끊음
서버의 자원을 매우 효율적으로 사용할 수 있음
HTTP는 초 단위 이하의 빠른 속도로 응답
1시간 동안 수천명이 서비스를 이용해도 서버에서 실제 동시에 처리하는 요청은 수십개 이하로 작음 (1초에 몇 명 되지도 않을 것)
HTTP 지속 연결(Persistent Connections) 기본으로 사용해 연결 시간을 어느정도 최적화
TCP/IP 연결(3 way handshake) 시간이 사용자에게 매번 추가되는 상황이 비효율적
js파일, html 파일, css 파일을 각각 다운 받을 때마다 연결을 맺음 (0.9초)
HTTP 지속 연결로 해결
HTML 페이지 하나가 전부 다운 받아질 때까지 TCP 연결을 유지하고 해제함 (0.5초)
HTTP 메시지 구조
구조
시작 라인(start-line)
요청과 응답 기본 형태는 start-line만 다름
request-line (요청 메시지 경우)
(HTTP 메서드) (SP=공백) (request-target=absolute path) (SP) (HTTP version) (CRLF=엔터)
ex) GET /search?q=hello&hl=ko HTTP/1.1
status-line (응답 메시지 경우)
(HTTP version) (SP) (status-code) (SP) (reason-phrase) (CRLF)
ex) HTTP/1.1 200 OK
헤더(header)
HTTP 전송에 필요한 모든 메타 정보 담김
수많은 표준 헤더가 존재 & 임의의 헤더 추가 가능
구조 (header-field)
(field-name) (:) (OWS=띄어쓰기 허용) (field-value) (OWS)
field-name은 대소문자 구분 X, field-value는 대소문자 구분 O
request example
Host: www.google.com
response example
Content-Type: text/html;charset=UTF-8
Content-Length: 3432
공백 라인(empty line) - Required
메시지 바디(message body) - Optional
실제 전송할 데이터 담김
byte로 표현할 수 있는 모든 데이터 가능
HTML, 이미지, 영상, JSON etc…
HTTP 메서드
API URI 설계 표준
리소스 식별 (명사)
리소스: 회원
계층 구조 상 상위 => 컬렉션 => 복수 명사(/members)
계층 구조 상 하위 => 도큐먼트 => 식별자 구분 (/members/{id})
행위는 HTTP 메서드로 분리 (동사)
행위: 조회, 등록, 삭제, 변경
주요 HTTP 메서드 종류
GET
리소스 조회
쿼리 파라미터로 데이터 전달
최신 스펙에서 메시지 바디로 데이터 전달이 가능하지만, 지원하지 않는 곳이 있어 권장 X
POST
요청 데이터 처리
리소스마다 요청 데이터를 어떻게 처리할지 따로 정해야 함
신규 리소스 등록
회원가입, 게시판 글쓰기…
프로세스 처리
단순한 데이터 생성 및 변경을 넘어서 엮여있는 프로세스들을 처리해야 하는 경우
POST의 결과로 새 리소스가 생성되지 않을 수 있음
주문에서 결제완료 -> 배달시작 -> 배달완료 같은 큰 작업들이 엮인 상태변경
POST /orders/{orderId}/start-delivery (보통 POST에서 컨트롤 URI 사용)
다른 메서드로 처리하기 애매한 경우
JSON으로 조회 데이터 넘겨야 하는데, GET 메서드 사용하기 어려운 경우
한 문서 끝에 내용 추가
즉, 서버에서 큰 변화가 일어나는 것은 POST 사용
PUT
리소스 대체 & 해당 리소스가 없을시 생성 (=덮어쓰기)
요청에서 데이터가 누락되면 그대로 삭제됨 (위험성 존재)
클라이언트가 리소스를 식별 (URI)
PATCH
리소스 부분 변경
실무 엔터티들은 데이터가 많기 때문에 변경에 주로 PATCH를 사용
PATCH를 못받아들이는 서버가 있다면 POST를 부분 변경에 사용한다.
DELETE
리소스 삭제
HTTP 메서드의 속성
안전(Safe Methods)
호출해도 리소스를 변경하지 않음
안전한 메서드: GET
멱등(Idempotent Methods)
여러 번 호출해도 결과가 똑같음
서버에 문제가 있을 때, 클라이언트가 같은 요청을 다시 해도 되는가의 판단 근거
멱등하지 않은 메서드: POST, PATCH
캐시가능(Cacheable Methods)
응답 결과 리소스를 캐시해서 사용 가능
큰 용량의 데이터를 로컬 PC 웹 브라우저 내부에 저장하고 있을 수 있는지 여부
캐시 가능 메서드: GET, POST, PATCH
POST, PATCH는 메시지 바디까지 캐시 키로 고려해야 해서 구현이 어려움
실제로 GET 정도만 캐시로 사용
Reference
모든 개발자를 위한 HTTP 웹 기본 지식
-
스프링 시작하기
빌드 및 실행 방법
./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
김영한의 자바 입문 - 코드로 시작하는 자바 첫걸음
-
-
데이터베이스 기본 용어
Database
전자적으로 저장되고 사용되는 관련있는 데이터들의 조직화된 집합
Electronically, Related, Organized collection
DBMS (Database Management System)
사용자에게 DB를 정의 및 관리하는 기능을 제공하는 소프트웨어 시스템
PostgreSQL, MySQL, Oracle Database, SQL Server
Metadata(=Catalog, Data about Data)
DB를 설명하는 데이터 (Descriptive)
DBMS를 통해 관리됨
e.g. 데이터 유형, 구조, 제약 조건, 보안, 저장, 인덱스, 사용자 그룹 etc…
Database System
Database + DBMS + 연관된 applications
포괄적으로 database라고 부르기도 함
Data Model
DB의 구조를 추상화해 설명하는 모형 (DB 구조: 데이터 유형, 데이터 관계, 제약 사항 etc…)
DB 기본 Operations(CRUD)를 포함
종류
Conceptual Data Model (=high-level)
일반 사용자들이 쉽게 이해할 수 있게 DB를 구조화 (추상화 수준이 가장 높음)
비즈니스 요구사항 기술에 사용
Logical Data Model (=representational)
특정 DBMS에 종속되지 않는 수준에서 디테일하고 이해하기 쉽게 DB를 구조화
실제 DB 설계를 할 수 있는 수준
종류 (DBMS가 채택)
relational data model (MySQL, Oracle DB, SQL Server)
object data model
object-relational data model (PostgreSQL)
Physical Data Model (=low-level)
컴퓨터에 데이터가 어떻게 파일 형태로 저장되는지를 기술
data format, data orderings, access path(e.g. index…)
Database Schema
Data Model을 바탕으로 database의 구조를 기술한 것
Database State (=Snapshot =현재 instances의 집합)
특정 시점에 database에 있는 실제 데이터
Three-Schema Architecture
User Application으로 부터 물리적인 database를 분리
각 레벨을 독립시켜 어느 레벨의 변화가 상위 레벨에 영향을 주지 않게 함 (안정적인 데이터베이스 운영)
Database system을 구축하는 architecture 중 하나로 가장 많이 사용됨
대부분의 DBMS가 어느정도 따르나 three level을 완벽하게 나누지는 않음
Conceptual 변화는 External Level에 영향을 안 미치는 것이 상대적으로 힘들기 때문
각각의 Schema는 DB 구조를 표현만 함. 데이터가 실제 존재하는 곳은 internal level
분류
external schemas at external level (user view)
특정 유저들이 필요로 하는 데이터만 표현 (그 외 데이터는 숨김)
logical data model을 통해 표현
conceptual schemas at conceptual level
internal schema를 추상화해서 물리적인 저장 구조 내용은 숨기고 전체 DB 구조를 기술
entities, data types, relationships, user operations, constraints에 집중
logical data model을 통해 표현
internal schemas at internal level
물리적으로 데이터가 어떻게 저장되는지 physical data model을 통해 표현
data storage, data structure, access path 등 실체가 있는 내용 기술
Database Language
오늘날 DBMS는 DML, VDL, DDL이 따로 존재하기 보다는 통합된 언어로 존재 (e.g. SQL)
언어 종류
DDL (Data Definition Language)
Conceptual Schema를 정의하기 위해 사용하는 언어
SDL (Storage Definition Language)
Internal Schema를 정의하기 위해 사용하는 언어
최근엔 파라미터 설정으로 대체 (SDL은 거의 없음)
VDL (View Definition Language)
External Schema를 정의하기 위해 사용하는 언어
대부분의 DBMS에서 DDL이 VDL 역할까지 수행
DML (Data Manipulation Language)
Database에 있는 실제 data를 활용하기 위한 언어 (CRUD)
-
파이썬으로 살펴보는 아키텍처 패턴: TDD, DDD, EDM 적용하기
Part 1 목표 아키텍처
Chapter 0 Big Ball of Mud에 대한 접근
Big Ball of Mud 안티패턴
처음에 깔끔한 작성을 목표로 시작한 소프트웨어 시스템도 시간이 지나면서 모든 요소 (도메인 지식, 비즈니스 로직, 로깅, 이메일 보내기 etc…)들이 서로 Coupling(결합)되어 시스템의 일부를 바꾸는 것도 힘들어지는 상황
Big Ball of Mud를 피하기 위한 일반적인 접근
1. Abstraction(추상화) & Encapsulation(캡슐화)
행동을 캡슐화하여 추상화로 사용하는 것은 코드의 표현력을 높이고 테스트와 유지보수를 더 쉽게 만든다.
2. Layering(계층화)
Layering Architecture는 복잡한 의존성들을 해결한다. 코드의 역할을 구분하고 범주(category)를 나눠 어떤 코드 범주가 특정 코드 범주를 호출할 수 있는지 규칙을 정한다. 도메인 모델(Domain Model)로 비즈니스 계층을 만들고, 모든 비즈니스 로직을 이곳에 모아야 한다.
3-Layer Architecture:
표현 계층 (UI or API or CLI…)
————|————
비즈니스 로직 (Business Rules & Workflows)
————|————
데이터베이스 계층 (Data Read & Write)
3. DIP (Dependency Inversion Principle, 의존성 역전 원칙)
비즈니스 코드는 기술적인 세부 사항에 의존해서는 안된다. 서로 추상화를 사용해 강한 의존성을 해소하여 각자가 독립적으로 변경될 수 있는 환경을 만들어야 한다.
예를 들어, 인프라를 바꿔야 하는 필요성이 있을 때 비즈니스 계층을 변경하지 않고도 인프라 세부 사항을 바꿀 수 있어야 한다.
DIP의 정의
고수준 모듈은 저수준 모듈에 의존해서는 안된다. 두 모듈 모두 추상화에 의존해야 한다.
추상화는 세부 사항에 의존해서는 안된다. 반대로 세부 사항은 추상화에 의존해야 한다.
Chapter 1. 도메인 모델링
도메인 모델
Domain(도메인): 해결해야 할 문제
Model(모델): 어떤 프로세스나 현상을 설명하기 위해 그것의 특성을 관찰하고 정리한 일종의 Mind Map
DDD(Domain-Driven Design)는 도메인 모델링의 개념을 널리 알렸고, 소프트웨어에서 가장 중요한 요소는 문제에 대해 유용한 모델을 제공하는 것이라고 주장한다.
도메인 모델링 자체는 DDD보다도 일찍 시작된 개념 (1980~)
비즈니스 전문가는 이미 그들의 도메인의 비즈니스 용어가 있으므로 개발자는 이를 공부하고 소프트웨어에 녹여내야 한다.
도메인 모델의 용어와 규칙은 비즈니스 전문가와 Ubiquitous Language(유비쿼터스 언어=비즈니스 전문용어)로 표현해야 한다.
Value Object, Entity
Value Object
데이터는 있지만 유일한 식별자가 없는 비즈니스 개념, 내부 데이터에 의해 개체 식별
값이 같으면 동일하다. (Structural Equality,구조적 동등성)
10파운드를 말할 때 10파운드라는 값(가치)이 중요하지, 어떤 지폐인지는 중요하지 않다.
수명이 없고 항상 Entity에 속한다.
불변(immutable) 속성
dataclass의 @frozen=True 로 해시 설정
Entity
고유한 식별자로 구분되는 개념
식별자가 같으면 동일하다. (Identifier Equality)
같은 이름, 같은 성별의 군인도 다른 군번(id)을 가진 동명이인일 수 있다.
수명이 있다.
가변(mutable) 속성
__eq__를 식별자로 비교하도록 구현
__hash__를 None으로 설정해서 집합등에 사용할 수 없게 구현
엔티티(entity)와 값객체(value-object)에 대해서
__hash__는 객체를 집합에 추가하거나 딕셔너리의 키로 사용할 때 동작을 제어하는 magic method
Domain Service Function
동사로 표현되는 부분은 (도메인과 관련된 비즈니스 로직)을 함수로 구현한다.
Domain Exception(예외)을 통해서도 도메인 개념을 표현할 수 있다
Ex) OutOfStock
이러한 동사 하나하나가 단위 테스트가 된다.
Chapter 2. 저장소 패턴
앱과 도메인이 복잡한 경우 Repository Pattern을 통해 저장소 계층을 하나 추가하는 방향을 생각해 볼 수 있다.
영속성과 분리된 모델(Persistence-Igorant Model) - 도메인 모델과 ORM의 분리
도메인 모델은 그 어떤 의존성도 없어야 한다. 즉, 인프라와 관련된 문제가 도메인 모델에 영향을 끼쳐 단위테스트를 느리게 하고 도메인 모델 변경을 어렵게 해서는 안된다.
따라서, 모델(비즈니스 로직)을 내부에 있도록 하여 의존성이 내부로 들어오게 해야 한다. (Onion Architecture)
이를 위해 도메인 모델과 ORM을 분리하여 도메인 모델이 항상 순수한 상태를 유지하고 인프라에 신경쓰지 않도록 한다. SQLAlchemy의 Classical Mapper를 사용하면 이를 구현할 수 있다. 이러한 구조에서는 비즈니스 로직에 영향을 주지 않고 SQLAlchemy를 제거하여 다른 ORM 혹은 전혀 다른 영속화 시스템을 채택해 갈아 끼울 수 있다.
Repository Pattern (저장소 패턴)
데이터 저장소를 간단히 추상화하는 것으로 데이터 계층을 분리할 수 있다.
추상화한 Repository는 마치 모든 데이터가 메모리 상에 존재하는 것처럼 가정해 데이터 접근과 관련된 세부 사항을 감춘다. 일반적으로 get(), add()를 통해 데이터를 가져오고 조작한다.
저장소에 대한 테스트는 모든 모델이 할 필요는 없다. 한 모델 클래스에 대해 생성/변경/저장을 모두 테스트했다면, 새로 추가되는 비슷한 패턴의 클래스는 최소한의 호출 응답만 확인하거나 테스트를 전혀 진행하지 않을 수도 있다.
Pros & Cons
장점
Repository와 Domain Model사이의 인터페이스를 간단하게 유지할 수 있다.
모델과 인프라를 완전히 분리했기 때문에 도메인이 복잡해도 비즈니스 로직 변경과 인프라 변경이 쉽다.
영속성을 생각하기 전에 도메인 모델을 작성하면, 처리해야 할 비즈니스 문제에 더 잘 집중할 수 있다.
Fake Repository를 만드는 식으로 단위 테스트를 위한 가짜 저장소를 쉽게 만들 수 있다.
단점
ORM mapping 변경 및 유지 보수 작업에 공수가 더 든다. (모델, ORM 둘 다 손봐야 하기 때문에)
저장소 계층에 대한 러닝커브가 발생한다.
Chapter 3. 결합과 추상화
Cohesion(응집)과 Coupling(결합)
응집: 한 컴포넌트가 다른 컴포넌트를 지원하며 서로 맞물려 잘 돌아가는 상황 (지역적인 결합)
결합: B 컴포넌트가 깨지는게 두려워서 A 컴포넌트를 변경할 수 없는 경우 (전역적인 결합)
Abstraction(추상화)
추상화를 통해 세부사항을 감추면 시스템 내 결합 정도를 줄일 수 있다.
또한, 추상화는 테스트를 더 쉽게 해준다.
Fake Object VS Mock
Fake Object
대치하려는 대상을 동작할 수 있게 구현한 존재, 테스트를 위한 구현만 제공 (고전 스타일 TDD)
의존성 주입을 하는 함수를 만들면 Test 시 Fake Object를 만들어 주입하기 쉬움
I/O의 경우 의존성 주입해 Fake를 뜨면 편함
def synchronise_dirs(reader, **filesystem**, source_root, dest_root):
Mock
대상이 어떻게 쓰이는지 검증할 때 사용 (런던 학파 TDD)
목을 너무 많이 사용하는 테스트는 설정 코드가 많아서 정작 신경을 써야 하는 이야기가 드러나지 않는 단점이 있다.
Chapter 4. 서비스 계층 (유스 케이스)
Use Case(유스 케이스)
사용자의 행동 요청 시나리오에 따라 시스템이 수행하는 작업 과정
Orchestration(오케스트레이션)
저장소에서 여러 데이터를 가져오고, 데이터베이스 상태에 따라 입력을 검증하며 오류 처리하고, 성공적인 경우 데이터를 데이터베이스에 커밋하는 일련의 작업들을 의미한다.
이러한 로직은 웹 API 엔드포인트와 관련이 없고 엔드포인트를 무겁고 장황하게 만드므로, 따로 서비스 계층에 분리하는 것이 타당하다.
Service Layer
유스 케이스를 정의하고 워크 플로를 조정하는 Orchestration(오케스트레이션) 로직을 담는다.
(서비스 계층=오케스트레이션 계층=유스 케이스 계층)
전형적인 서비스 계층 함수들은 다음과 비슷한 단계를 거친다.
저장소에서 어떤 객체들을 가져온다.
현재 세계를 바탕으로 요청을 검사하거나 어서션으로 검증한다.
도메인 서비스(비즈니스 로직)를 호출한다.
모든 단계가 정상적으로 실행됐다면 변경한 상태를 저장하거나 업데이트한다.
서비스 계층 추가 시 다음과 같은 장점이 있다.
엔드포인트가 아주 얇아지고 작성하기 쉬워진다. 엔드포인트는 JSON 파싱이나 웹 기능만 담당한다.
테스트의 상당 부분을 빠른 단위 테스트와 최소화된 E2E 및 통합 테스트로 만들어, 테스트 피라미드를 높은 기어비(High Gear)로 적절히 구성할 수 있다.
Chapter 5. 높은 기어비와 낮은 기어비의 TDD
결합과 설계 피드백 사이의 트레이드 오프
API 테스트(High Gear)로 갈수록 세부 설계 피드백은 적어지지만, 더 넓은 커버리지의 테스트를 제공하므로 데이터베이스 스키마 변경 등의 대규모 변경에 대하여 코드가 망가지지 않는다는 자신감을 제공한다.
반대로, 도메인 모델 테스트(Low Gear)는 도메인 언어로 작성되므로 모델의 살아있는 문서 역할을 한다. 다만, 특정 구현과 긴밀하게 결합되어 있어서 전체가 깨질 수 있는 불안함을 포함해 로직 변경시 Cost가 크다
Service Layer 추가 후 지향할 테스트 방향
도메인 모델에 집중되어 있던 단위 테스트를 모두 서비스 계층 함수에 대해 테스트하도록 리팩토링할 필요가 있다.
즉, E2E 테스트는 호출과 응답에 관련한 Happy Path, Unhappy Path만 테스트하고 비즈니스 로직 관련 테스트는 Service Layer 함수들에 대한 단위테스트로 진행한다.
도메인 모델에 대한 테스트가 너무 많으면 코드베이스를 바꿀 때마다 수십 개에서 수백 개의 테스트를 변경해야 하는 문제가 생긴다.
서비스 계층 테스트는 더 낮은 결합(Coupling)을 제공하고 커버리지, 실행 시간, 효율 사이를 잘 절충할 수 있게 도와줘서 도메인 모델 테스트 보다 이점이 있다.
또한, 서비스 계층 테스트에 집중하면 커버리지가 더 높으므로, 도메인 모델을 리팩토링할 때 변경해야 하는 코드의 양을 크게 줄일 수 있다.
서비스 계층 테스트를 도메인으로부터 완전히 분리하기
서비스 함수 파라미터는 도메인 객체를 받지 않고 원시 타입으로 받도록 선언하자.
def allocate(line: OrderLine, repo: AbstractRepository, session) -> str
보다는
def allocate(orderid: str, sku: str, qty: int, repo: abstractRepository, session) -> str:
으로 사용하자.
서비스 테스트의 모든 도메인 의존성을 한 곳에 모으자.
픽스처 함수에 팩토리 함수를 넣어 도메인 의존성을 모으는 방법이 있다.
개인적으로 가장 좋은 것은 모델 객체를 추가하는 서비스 함수를 하나 작성해두면, 도메인 의존성 없이 테스트에 지속적으로 사용할 수 있어 편리하다. 덕분에 서비스 계층이 오직 서비스 계층에만 의존한다.
def test_add_batch():
repo, session = FakeRepository([]), FakeSession()
services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session)
assert repo.get("b1") is not None
assert session.committed
다만, 단순히 테스트 의존성 제거 만을 위해 새 서비스를 작성할 필요는 없다. 미래에 필요성을 고려해 도입한다.
엔드 투 엔드 테스트
API 테스트 역시 API 테스트에만 의존하도록 하는 것은 괜찮은 방법이다.
또한, Happy Path를 위한 하나의 E2E & 모든 Unhappy Path를 위한 하나의 E2E를 작성해 관리하자.
정리
Chapter 6. 작업 단위 패턴 (Unit of Work)
작업 단위 패턴은 원자적 연산(Atomic Operation)에 대한 추상화다.
어떤 객체가 메모리에 적재됐고 어떤 객체가 최종 상태인지를 기억한다.
장점
UoW는 영속적 저장소에 대한 단일 진입점으로 기능하여 엔드포인트와 서비스 계층을 데이터 계층과 완전히 분리할 수 있다. (서비스 함수 자체와 엔드포인트(Flask, FastAPI)가 데이터베이스와 직접 대화하지 않는다.) 데이터베이스에 접근하는 코드가 여기저기 흩어지지 않게 하나로 모으고, 각 컴포넌트가 자신에게 반드시 필요한 것들만 갖게 하는 것이 좋다.
원자적 연산을 표현하는 좋은 추상화가 생기고, 파이썬 콘텍스트 관리자를 사용하면 원자적 한 그룹으로 묶여야 하는 코드 블록을 시각적으로 쉽게 알아볼 수 있다.
트랜잭션 시작과 끝을 명시적으로 제어할 수 있고, 애플리케이션이 실패하면 기본적으로 안전한 방식의 트랜잭션 처리를 할 수 있다.
UoW는 세션을 단순화해 핵심 부분만 사용하도록 해준다. 세션 API는 풍부한 기능과 도메인에 불필요한 연산을 제공하므로, 코드를 Session 인터페이스와 결합하는 것은 SQLAlchemy의 모든 복잡성을 결합하기로 결정하는 것이다.
단점
ORM이 이미 원자적 연산에 대한 좋은 추상화를 제공할 수 있다.
(롤백, 다중 스레딩이 담긴) 복잡한 트랜잭션을 처리하는 코드의 경우 매우 신중하게 생각해야 한다.
-
Empty 자료형의 type annotation에 관하여
Intro
Mypy와 친하게(?) 지내다보면 문득 어떤 type annotation를 줘야할지 모호한 경우가 왕왕 발생합니다. (안 발생한다면 mypy랑 베프인 분들 :thumbsup:) 그 중 재밌었던 부분은 빈 리스트 []는 어떤 typing을 주어야할지에 대한 고민입니다. 보통 비어있다고 생각하면 typing 모듈의 Optional을 생각하게 되는데, 사실 약간 불편한 느낌이 있습니다. 예를 들어, Optional[str]은 Union[str, None]과 동일하며 그 의미는 “None을 허용한다”이므로, list[Optional[str]]는 리스트의 element로 str이 오거나 None이 올 수 있다는 말이 됩니다. 즉, [None]도 허용한다는 의미가 포함되게 됩니다.
그렇다면 [None]은 제외하고 순수하게 empty list []만 허용하고 싶을 때는 어떻게 해야 할까요?
결론만 이야기하면, list[str]으로 충분합니다. 굳이 Optional을 사용해 [None]의 경우까지 허용시킬 필요가 없습니다.
실제로 이것이 맞는지 PEP와 Mypy로 함께 확인해봅시다 :smiley:
Empty list의 타입
기본적인 list 타입은 위와 같이 선언할 수 있습니다. list[str]는 str 타입의 element로 구성된 리스트를 허용한다는 의미죠. Mypy로 체킹해봐도 dogs: list[str] = ["Welsh Corgi", "Golden Retriever", "Bulldog"]가 문제없이 허용됩니다.
[]도 허용하고 싶을 때는 어떻게 해야할까요?
list[Optional[str]]은 ["Welsh Corgi", "Golden Retriever", "Bulldog"], [None], [] 3가지 경우를 허용합니다. 보통 우리는 [None]에 대한 허용을 필요로 하지 않죠.
따라서, 통상적인 의미의 빈 리스트를 허용 type annotation은 단순히 list[str]을 사용하면 됩니다. list[str]은 ["Welsh Corgi", "Golden Retriever", "Bulldog"], [] 2가지 경우를 허용합니다.
list[int], list[float], list[bool] 역시 동일하게 []를 허용합니다.
Data type에 따른 분류
기본 자료형
기본 자료형의 경우는 Optional을 사용해주는 것이 본래 의도와 맞을 것입니다.
Optional[int] example: 2, None
Optional[float] example: 3.14, None
Optional[bool] example: True, None
컬렉션 자료형
컬렉션 자료형의 경우, Optional 없이 본래의 타입을 사용하는 것이 의도에 맞을 것입니다.
list[str] example: [Welsh Corgi, 'Poodle'], []
dict[int, str] example: {1: "Barking", 2: "Running"}, {}
set[int] example: {1, 2}, set()
다만 튜플은 길이가 고정되는 자료형이기 때문에, 빈 튜플을 표현하거나 튜플의 길이를 가변적으로 표현하고 싶다면 다른 방법을 사용해야합니다.
tuple[int] example: (4,)
tuple[()] example: () (=empty tuple)
Union[tuple[()], tuple[int]] example: (), (4,)
tuple[int, ...] example: (), (4,), (3, 4, 5) (=Arbitrary-length homogeneous tuple)
PEP 484 & Mypy docs
빈 자료형을 어떤 타입으로 표현해야 하는지만을 따로 설명한 챕터는 없습니다. 다만, 이에 대해 신빙성있게 명시된 부분들은 PEP 484 – Type Hints와 Mypy docs - Type inference and type annotations에서 직간접적으로 찾아볼 수 있습니다.
PEP 484의 type comments 설명을 보면, empty list를 어떤 타입으로 명시할 수 있는지가 간접적으로 드러나 있습니다.
PEP 484의 The typing Module 챕터에서는 empty tuple은 tuple[()], arbitrary-length homogeneous tuple은 tuple[int, ...]를 사용하라고 명확히 설명해주었네요.
Mypy docs에서도 collection 자료형의 타입에 관하여 명시된 부분이 있습니다. 이에 따르면, empty list는 list[int], empty dict는 dict[str, int], empty set은 set[int] 등으로 표현 가능합니다.
Outro
Empty 자료형에 대해 온전히 설명하는 PEP가 있다면 좀 더 좋았을텐데라는 생각이 들지만, 한편으로는 여러 reference에서 이에 대한 증거들을 찾아가는 과정도 꽤 흥미로웠습니다.
Empty 자료형은 type annotation을 조금 헷갈리게 할 수 있습니다. 하지만 충분히 직관적으로 타입을 표현할 수 있으니, 이를 염두해서 type annotation을 사용하면 좋을 것 같습니다 :)
P.S. Tuple의 type annotation은 직관적인가…? 자료형에 특성에 따른 예외이니까 kindly하게 받아들여야겠다…!
Reference
PEP 484 - Type hinting #type-comments
PEP 484 - Type hinting #the-typing-module
Mypy docs - Explicit types for collections
-
안전한 JWT 발급에 유의해야할 점들
Intro
현재 회사 프로젝트에서 JWT를 사용하면서, JWT를 쿠키로 안전하게 보내기 위해 겪었던 혼란들을 기록해보고자 합니다.
HttpOnly 옵션
기본적으로, access token과 refresh token은 서버에서 HttpOnly 옵션을 사용해 쿠키로 발급해주는 것이 바람직합니다. 이는 클라이언트에서 JS를 통해 쿠키로 접근하는 것을 막아주는 옵션이며, JS 코드를 심어 악의적인 명령을 실행하는 XSS(Cross Site Scripting) 공격을 예방할 수 있습니다. (글로벌 변수인 document를 사용해 document.cookie로 접근하는 것을 막아줍니다.)
Secure 옵션
서버에서 Secure 옵션을 사용해 JWT를 보냅시다. Secure는 쿠키가 HTTPS 프로토콜에서만 보내지도록 합니다. HTTP에서는 전송 중간에 쿠키가 탈취될 위험이 있기 때문에, 안전하게 HTTPS에서만 보내질 수 있도록 설정합니다.
SameSite 옵션
무언가 JWT가 잘 발급되지 않는다 싶으면, SameSite 옵션을 꼭 의심해봅니다. 서버에서 SameSite=None을 설정해 JWT를 보냅시다. 특히, Chrome 브라우저는 SameSite의 default 값이 lax로 되어 있는데 이로인해 cross-site 간의 request에 cookie가 보내지지 않을 수 있습니다.
allow-origins, allow-credentials
서버에서 CORS관련 설정들을 잘 세팅합시다. allow-origins에는 CORS를 허락할 클라이언트의 주소를 꼭 입력해줍니다. allow-credentials 옵션도 True로 설정해, 쿠키가 잘 보내질 수 있도록 합니다.
클라이언트가 HttpOnly 쿠키를 사용하는 방법
클라이언트가 HttpOnly 쿠키를 서버로부터 전달 받았다면, 이후 해당 HttpOnly 쿠키는 클라이언트가 어떠한 request를 보낼 때마다 자동으로 쿠키에 담겨 보내집니다. 이 때, 클라이언트에서 withCredentials=True를 설정하고 request를 보내야 쿠키가 올바르게 전송 됩니다. Access token과 refresh token이 자동으로 담겨지는 점이 편리하죠!
Outro
직접 주어진 실무 문제는 아니었지만, 문제 해결에 함께 참여하고 개인 프로젝트에서 잘 되지 않았던 점들을 되짚어 보면서 많은 공부가 되었습니다. HttpOnly 쿠키로 JWT를 보낼 때, 여러가지 조건이 갖춰져야 비로소 올바르게 전송됩니다. 생각보다 장애물이 많은데, 잘 기록해둬야 나중에 같은 문제를 마주했을 때 덜 헤맬 것 같습니다 :)
-
Poetry typed package를 mypy가 인식하려면? feat. py.typed
Intro
최근 몇 주간은 회사 업무 중 트러블 슈팅이 특히 많았습니다. 그 중 가장 기억에 남았던 것은 import한 poetry 패키지의 타입을 mypy가 제대로 인식하지 못하는 문제였습니다.
해당 문제는 MSA로 개발 중인 프로젝트에서 발생했는데, 각 서비스에서 공통으로 쓰이는 class들을 하나의 패키지에 담는 과정에서 나타났습니다. 이는 py.typed 파일을 추가함으로써 생각보다 간단히(?) 해결할 수 있었는데, 그 과정을 남겨보고자 합니다.
Problem
Poetry로 빌드된 패키지를 개발 중인 서비스로 import 하고 mypy로 type checking하니, 위와 같이 무수한 type error가 발생했습니다. :(
우선, 관련 error는 mypy extension package 중 하나인 sqlalchemy2-stubs의 적용이 제대로 이루어지지 않아 발생한 에러였습니다. 서비스 내에는 잘 install 되어 있었기 때문에, 처음엔 패키지 내에서도 sqlalchemy2-stubs를 설치해야 하나 고민했습니다. 하지만, 패키지 내의 dependecy 설정으로도 에러는 해결되지 않았습니다.
결국 sqlalchemy2-stubs 자체보다는 타입 인식 자체가 잘 안되는 이유를 찾아야 했습니다.
Solution
실제로 문제의 해결은 py.typed 파일의 존재 유무에 있었습니다.
문제가 되었던 패키지의 디렉토리 구조는 다음과 같았습니다.
|- project-core
|- dist
|- project_core
|- __init__.py
|- package_content...
|- __init__.py
|- pyproject.toml
그리고 실제 패키지 내용에 해당하는 디렉토리 내의 최상단에 내용이 비어있는 py.typed 파일을 수동으로 생성해주면, mypy가 패키지 코드의 type annotation을 인식하기 시작합니다.
|- project-core
|- dist
|- project_core
|- py.typed
|- __init__.py
|- package_content...
|- __init__.py
|- pyproject.toml
패키지와 관련된 type checking 수단을 제안하는 PEP-561에도 py.typed에 대한 내용이 명시되어 있습니다. (poetry 뿐만 아니라 범용적으로 적용됩니다.)
우선 지금 문제 상황은 3번에 해당할 것입니다. 즉, package maintainer(패키지 관리자)가 자신의 패키지 코드에 외부의 stub file이 적용되길 원하는 경우입니다. (여기서 stub은 type information만이 담긴 파일을 의미합니다.)
이에 따라, 현재 서비스의 sqlalchemy2-stubs가 패키지에도 적용되길 원합니다.
PEP-581은 이를 위해 패키지 관리자가 package의 top-level에 py.typed라는 marker file을 생성해야 함을 전달합니다. (MUST)
사실 문제를 해결하는 다른 방법도 존재하겠지만(MYPYPATH에 site-packages를 추가하는 방법 등…), 간단하고 편한 방법이 있으니 굳이 사용하지 않을 이유가 없을 것 같습니다.
Outro
아직 package를 만들어 본 경험이 없었는데, 덕분에 package 빌드 방법에 조금 더 익숙해진 것 같습니다. 이와 더불어 package를 올바르게 배포하기 위해 다양한 요소들이 필요함을 느꼈습니다. 익숙함이 쌓이다보면 언젠가 작은 오픈소스를 배포하는 날도 오지 않을까 기대되네요 :)
결론입니다. Typed package에는 항상 py.typed를 추가해주세요!!
Reference
Don’t forget py.typed for your typed Python package
PEP-581 Packaging Type Information
-
NFT overview
NFT란?
Non-Fungible Token의 약자로 다른 것과 대체 불가능한 토큰을 뜻합니다. 예를 들어, 돈의 경우 내가 가진 1000원은 다른 사람이 가진 1000원과 동일한 가치를 지니므로 대체 가능합니다. 반면, 나의 강아지와 다른 사람의 강아지는 동일한 경제적, 정서적 가치를 가지고 대체될 수 없습니다. 즉, NFT는 디지털 컨텐츠의 고유성 및 원본임을 증명해주는데 주 목적을 둡니다.
또한, NFT는 블록체인에 저장되어 있어서 누가 언제 해당 토큰을 소유했는지 전부 기록됩니다. 따라서, 이전에는 불가능했던 디지털 자산의 소유권을 입증하는 것도 가능해집니다.
NFT의 특징
NFT를 사는 것은 컨텐츠를 사는 것이 아니라 컨텐츠로 연결된 데이터를 사는 것입니다.
NFT는 소유자가 아니더라도 누구나 열람 가능합니다.
NFT는 소유자가 아니더라도 누구나 저장할 수 있습니다.
구매한 NFT는 재판매할 수 있습니다.
NFT의 장점과 단점
Pros
자신의 명망과 취향을 자랑하고 싶은 사람들의 Ego를 충족시킬 수 있습니다.
트위터 역시 NFT를 자랑할 수 있는 탭을 만들 예정입니다.
프로필 이미지도 NFT로 만들고 블록체인 검증 체크 마크를 보여줘서, 절대 유일한 프로필 사진을 가질 수 있게 해줄 계획입니다.
Provenance(프로비넌스)
해당 예술작품을 소장한 오너들의 기록을 말합니다.
명망있는 사람이 소유한 적이 있다면, 그 사실 자체가 예술작품의 가치 상승에 반영되기도 합니다.
NFT 덕분에 소유자의 기록이 모두 기록되어 누구나 확인이 가능합니다.
Cons
가치가 크게 변동되는 시장으로 인해, 투기꾼들이 몰리다보니 이미지 자체도 하락합니다.
막대한 전기를 소모하여 환경 오염을 촉발시킵니다.
NFT에 Contents를 삽입하는 방법
NFT 토큰을 만드는 큰 그림
두 가지 기능을 가진 Smart contract 만들기
돈을 받는 기능 (이더나 달러를 받으면)
토큰을 전송하는 기능 (1개의 토큰을 줄게)
해당 토큰은 1개의 유일한 토큰이 됩니다. 그리고 1개의 유일한 토큰에 이미지, 영상, 전세 계약 등을 심으면 NFT가 됩니다.
용어
ERC721: NFT의 스탠다드. ERC 20에 토큰 ID, 메타데이터 JSON 파일이 추가된 형태
토큰 ID: NFT에 붙는 개별 식별 번호
메타데이터 JSON: NFT에 넣을 정보 및 컨텐츠가 담기는 그릇
IPFS: 위변조가 불가능한 어찌보면 블록체인과 비슷한 분산 저장소 (모든 정보를 블록체인에 담기는 비싸므로 IPFS를 항상 함께 사용함)
실제 과정
원하는 컨텐츠(이미지, 영상 등)을 IPFS 올리고 hash 값을 받습니다.
메타데이터 JSON에 hash 값을 삽입합니다.
해당 메타데이터 JSON을 IPFS에 올리고 hash 값을 받습니다.
ERC721 민터 코드를 디플로이합니다. (코인을 내고 블록체인에 올립니다)
NFT를 민트합니다. (코인을 내고 NFT를 발행합니다, json hash값 주소와 wallet 주소도 적용)
NFT 전송 과정
NFT를 manually하게 구매 및 판매할 때
지갑을 설치합니다. (Ethereum 기준으로 MetaMask wallet을 설치합니다.)
MetaMask Mobile app을 엽니다. (NFT 거래는 아직 모바일에서만 가능합니다.)
NFT 탭에서 보내길 원하는 NFT를 고릅니다.
상대방의 Ethereum 주소를 입력합니다.
Transaction (gas) fee를 지불합니다.
Etherscan을 통해 Ethereum 블록체인에서 해당 transaction을 등록합니다.
NFT를 manually하게 구매할 때
판매자에게 MetaMask wallet 주소를 보냅니다.
판매자가 NFT를 전송했다면, transaction ID 전달도 함께 요청합니다.
Etherscan과 transaction ID를 통해 해당 transaction이 정상적으로 확인되면, 정식적으로 구매한 NFT의 소유주가 됩니다.
다만, NFT 마켓플레이스를 이용하는 것이 보다 안전하게 구매하는 방법일 것입니다.
Opensea에서 판매자는 입력폼에 컨텐츠를 첨부하고 가격을 정한 후, 트랜잭션 수수료를 내면 해당 컨텐츠를 NFT로 만들어 판매할 수 있습니다.
OpenSea에서 구매자는 원하는 NFT를 골라 MetaMask로 NFT 가격과 gas fee 지불하면 해당 NFT를 구매할 수 있습니다.
Reference
How to Transfer an NFT: Step by Step Guide to Do it Right
NFT 광풍? 혁신일까, 마케팅일까? 개발자가 정리해드림.
-
서버에서 JWT를 안전하게 발급하는 방법은 무엇일까?
Intro
이전 개인 프로젝트에서 JWT로 로그인을 구현할 때, access token을 response body에 담아 보낸 기억이 있습니다. 사실 httpOnly 쿠키로 보내려고 했지만, 그 때는 서버에서 전달받은 쿠키를 프론트에서 어떻게 사용해야 할지 방법을 찾지 못해 불가피하게 이용한 방법이었습니다. (여러 JWT 인증 예제에서 request body로 access token을 보내는 경우가 심심치 않게 보인 점도 한 몫했습니다.)
JWT의 access token은 서버에서 httpOnly로 보내는게 무조건 옳은 것일까? 프론트에서는 httpOnly로 받은 access token을 어떻게 처리할까? Response body로 보냈을 때 생기는 문제는 무엇일까? 해결하지 못한 고민들은 계속 남아 맴돌기에, 이번 기회에 가볍게 정리해보고자 합니다.
XSS(=CSS), CSRF(=XSRF)
먼저, JWT에서 주요하게 이슈가되는 기본적인 보안 문제는 XSS와 CSRF 공격입니다. 따라서, 단순하게는 XSS와 CSRF를 막는 방식으로 접근하는 편이 바람직해보입니다.
XSS(=Cross Site Scripting)
해커가 JS같은 스크립트 코드를 URL 혹은 Input에 악의적으로 삽입해 피해자의 웹브라우저에서 실행시키는 공격을 말합니다.
피해자의 브라우저에 저장된 중요한 정보들을 빼내올 수 있습니다.
CSS가 이미 약자로 있기 때문에, XSS라고 더 많이 불리는 것 같습니다.
보통 중요한 데이터를 전송할 때 httpOnly 쿠키를 사용하면, XSS 공격을 막을 수 있습니다.
CSRF(=Cross Site Request Forgery)
해커가 정상적인 request를 가로채 피해자인척하고 변조된 request를 서버에 보내, 서버에서 악의적인 동작을 수행하도록 만드는 공격을 말합니다.
피해자의 개인정보가 수정 및 유출, 원치 않는 광고성 포스팅 작성 등의 피해가 있을 수 있습니다.
프론트와 httpOnly 쿠키 옵션
서버에서 쿠키를 설정할 때 (set_cookie) httpOnly 옵션을 줄 수 있습니다. 서버에서 httpOnly를 적용해 쿠키로 보낸 값들은 클라이언트에서 직접 접근이 불가능합니다. (document.cookie로 접근 불가능) 다만, 이후 request를 할 때마다 해당 쿠키가 자동으로 쿠키 헤더에 담겨 request와 함께 보내집니다.
httpOnly는 JS로 쿠키에 접근할 수 없으므로, XSS 공격을 막을 수 있습니다. 반면에, 매 request마다 자동으로 쿠키 헤더에 담겨 보내지는 특징 때문에 CSRF 공격에 취약점을 가질 수 있습니다.
Secure 쿠키 옵션
Secure은 클라이언트 혹은 서버에서 https에서만 쿠키를 전송할 수 있도록 허용하는 옵션입니다. httpOnly는 클라이언트에서 JS를 통한 탈취 문제는 해결할 수 있지만, 네트워크를 직접 감청하여 쿠키를 가로채는 공격을 막을 수 없습니다. 특히, http에서는 데이터가 암호화되지 않고 전달되기 때문에, request나 response가 중간에 탈취당하면 그대로 데이터를 노출하게 됩니다.
따라서, 데이터가 암호화되어 보내지는 https에서만 통신 가능하도록 secure 옵션을 설정할 필요가 있습니다.
JWT를 발급하는 경우의 수
경우의 수는 refresh token과 access token을 모두 사용하는 것을 기준으로 고려합니다.
Case 1 - refresh token, access token을 모두 httpOnly 쿠키로 보내기
access token을 httpOnly 쿠키 헤더로 보내면, XSS 공격을 충분히 막을 수 있습니다.
httpOnly이기 때문에, 프론트에서 JS를 통해 쿠키에 접근할 수 없고 해커도 이를 이용할 수 없습니다.
refresh token도 마찬가지입니다.
반면 CSRF에 취약합니다.
Request에 access token이 항상 자동으로 담겨 보내지므로, request를 위조하는 CSRF를 막기 어렵습니다.
refresh token도 마찬가지입니다.
Case 2 - refresh token은 httpOnly 쿠키로, access token은 response body로 보내기
access token은 프론트에서 클로저 등을 통해 private variable로 저장하고 관리합니다.
이 때, XSS, CSRF 문제는 없어집니다.
혹시나 https 이외의 통신이라면 response body의 중간 탈취 위험은 있을 수 있지만, refresh token은 탈취되지 않기 때문에 유효 기간이 짧은 access token만 탈취되고 이후 갱신은 어려울 것입니다.
다만, 새로고침이 일어날 때마다 access token이 휘발성으로 사라지기 때문에, refresh token으로 새로운 access token을 발급받아야 합니다. (access token 유효기간의 의미가 사라지는 것 같기도…)
refresh token의 경우
httpOnly이므로 XSS 공격 문제가 없습니다.
Request에 refresh token이 항상 자동으로 담겨 보내지지만, CSRF를 시도해도 해커는 access token을 알 수 없습니다.
해커가 refresh token을 사용해 새로운 access token을 서버에 요청할 수는 있어도, response body로 날라오는 access token은 해커가 아닌 사용자에게로 갈 뿐입니다.
Case 3 - refresh token, access token을 모두 response body로 보내기
access token과 refresh token을 프론트에서 클로저 등을 통해 private variable로 저장하고 관리합니다.
새로고침이 일어날 때마다 refresh token과 access token이 사라지므로, 로그인 유지가 되지 않습니다.
즉, XSS, CSRF 공격 위험과 멀어지지만 로그인 기능과도 멀어(?)집니다.
만일 https 이외의 통신이라면, access token 뿐만 아니라 refresh token까지 탈취당하여 더 오랜 기간동안 위험할 수 있습니다.
Outro
결론적으로 위 Case 중에서는 Case 2가 보안상으로 가장 best한 방법으로 생각됩니다. 다만, 새로고침 시 access token이 유지되지 않는 점에서 다시 cookie의 필요성이 생각나는 무언가 아쉬운 부분이 느껴집니다.
이 포스팅은 더 좋은 방법을 알게될 때마다 계속 업데이트해 나가야 할 것 같습니다 :)
Reference
JWT는 어디에 저장해야할까? - localStorage vs cookie
프론트에서 안전하게 로그인 처리하기 (ft. React)
01. 시큐리티 - HTTP Only 와 Secure Cookie
-
WSL2로 Windows에서 Linux 사용하기
Intro
Windows는 멋진 OS입니다. Windows 덕분에 개발의 첫 발자국을 뗄 수 있던 사람은 아주 많을 것입니다!
다만, Windows 환경에서 개발을 진행하다보면, 생각치 못한 에러를 마주칠 때가 참 많습니다. 특히 Mac OS 환경에서는 자연스럽게 넘어가던 일들이 왕왕 막힐 때는, 고구마 5개가 식도에 함께하는 기분을 느끼게 됩니다(?)
이러한 참사를 막기 위해, Windows 위에서 리눅스를 매끄럽게 사용할 수 있게 도와주는 WSL2가 존재합니다.
WSL2란?
WSL은 Windows Subsystem for Linux 2의 줄임말로, 윈도우의 가상화 기능을 활용해서 윈도우 위에서 리눅스를 사용할 수 있게해줍니다. 단순히 가상머신으로 리눅스를 사용할 수 있는 것이 아니라, 윈도우 시스템과 통합되어 마치 하나의 머신처럼 자연스럽게 리눅스를 활용하는 것이 가능합니다.
- LainyZine: 프로그래머 가이드
Requirements
Windows 10 버전 요구사항: 20H1 이상
Windows 사양 확인
Windows + S 키로 검색 탭을 열어 PC 정보를 검색합니다.
PC 정보의 아래 쪽에 Windows 사양 부분에서 버전을 확인합니다. 현재 20H1, 20H2, 21H1 등에서 WSL 사용이 지원됩니다.
WSL2 활성화 및 Ubuntu 설치
WSL2 설치를 위해 가상 터미널을 이용합니다. 이 때, 가상 터미널로 Windows Terminal을 설치해 사용하면 이후 WSL 사용도 편리해집니다. 없을 시엔 Windows PowerShell을 사용합시다.
Windows + S 키로 Windows Terminal이나 PowerShell을 검색한 후, 우 클릭하여 ‘관리자 권한으로 실행’을 클릭합니다.
다음 명령어를 실행해 WSL 기능을 활성화합니다.
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
Microsoft Store에 들어가 원하는 버전의 Ubuntu를 설치합니다.
활성화 적용을 위해 컴퓨터를 재시작합니다.
다운받은 Ubuntu를 실행하고 설치 완료 메시지까지 약간 기다립니다.
계정 정보 입력 메시지가 뜨면, 새로운 Ubuntu OS에 대한 새로운 계정을 만듭니다. (기존 Windows 정보와 전혀 상관없이 새 계정을 만들면 됩니다.)
이후, 다음 명령어를 사용해 활성화 되어 있는 WSL을 WSL2로 업데이트합니다. (관리자 권한 실행)
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
컴퓨터를 재시작합니다.
다음 명령어를 사용해 WSL2를 기본 버전으로 설정합니다. (관리자 권한 실행)
wsl --set-default-version 2
만일 커널 구성요소를 다운로드하라는 메시지가 나오면, 해당 링크로 가서 커널 업데이트 패키지를 다운로드 받아 install하고 다시 wsl --set-default-version 2 명령어를 실행합니다.
다음 명령어를 사용해, WSL에게 Ubuntu에 WSL2를 사용할 것이라는 것을 알려줍니다.
wsl --list --verbose를 통해 현재 설치된 ubuntu의 버전을 확인할 수 있습니다.
wsl -l -v로 현재 설치된 리눅스를 확인해볼 수 있습니다.
wsl --set-version Ubuntu-18.04 2식으로 명령을 실행합니다.
혹시 BIOS에서 가상화가 사용가능하도록 설정하라는 메시지가 뜨면, 구글 검색을 통해 가상화 설정을 진행하고 다시 명령어를 실행합시다.
Customizing Linux Shell
WSL2을 통한 Ubuntu의 초기 리눅스 쉘 상태는 굉장히 ugly합니다. 따라서, 몇 가지 기본세팅이나 UI 적용을 통해 보다 깔끔한 터미널을 만드는 것도 매우 좋을 것입니다.
다음 링크에서 원하는 customizing을 참고하시길 바랍니다.
Nomad Coder WSL Setup
Outro
Windows 환경에서 개발함에 있어 WSL2는 단비 같은 툴입니다. 개발에만 집중하기도 모자른 시간을 환경적 에러에서 소모할 필요는 없습니다. 그렇지만 Windows라고 개발에서 배제(?)될 필요도 없습니다. 다만, Windows를 쓰시는 개발자라면 WSL2로 초기 환경을 세팅하고 개발하시길 권합니다 :)
Reference
Nomad Coder WSL Setup
LainyZine: 프로그래머 가이드
-
SQLAlchemy 기본
SQLAlchemy
동기 지원 모듈: sqlalchemy
create_engine (데이터베이스 엔진)
Session (세션)
sessionmaker (세션 팩토리)
ORM Setting 기본 단계
DB engine 생성 및 접속
세션 정의 및 생성
테이블 초기 생성
Session을 만드는 2가지 방법
Session 객체를 직접 생성
사용 코드
def get_db():
db = Session(bind=engine)
try:
yield db
finally:
db.close()
FastAPI의 Depends(get_db)를 통해 의존성 주입하면 편리
Session 팩토리
사용 코드
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
db.close() - 사용 후에는 직접 끊어줘야 함
sessionmaker 옵션
autocommit
세션 작업 후 자동으로 커밋되도록 활성화
False로 두고 명시적으로 커밋하는게 좋음
autoflush
트랜잭션 안에서 바로바로 데이터 반영 시킬지 여부
예를 들어, DB에 100개의 데이터가 있는데 현재 트랜잭션 내에서 insert 쿼리 후 count 쿼리를 날리면, autoflush가 true일 때 101개 결과를 반환
과거 방식이기도 하고, False가 바람직
테이블 초기 생성
Base.metadata.create_all(bind=engine)
조회 Syntax
모든 컬럼 조회
db.query("TableObjectName")
= SELECT * FROM TableName
e.g. db.query(User)
특정 컬럼 조회
db.query("TableObjectName.columnname")
= SELECT columnname FROM TableName
e.g. db.query(User.email)
WHERE절
filter
e.g. filter(User.nickname == 'veluga')
filter_by
e.g. filter_by(nickname="john")
AND & OR
AND
e.g. filter("조건").filter("조건")
e.g. filter("조건", "조건")
OR
or_을 임포트해 사용
from sqlalchemy import or_
e.g. filter(or_(User.username == "veluga", User.id == 1))
정렬
오름차순 정렬
order_by(User.id)
내림차순 정렬
from sqlalchemy import desc
order_by(desc(User.id))
조회 실행 (쿼리 실행)
단건 조회
first()
결과가 여러 개면 그 중 첫 번째 리턴
없을 경우 None 반환
one()
결과가 여러 개거나 없을 경우 에러
scalar()
결과가 여러 개일 경우 에러
없을 경우 None 반환
복수 리스트 조회
all()
scalars()
조회 결과의 개수 반환
count()
그룹화 및 집계 함수 사용 패턴
func에서 원하는 집계함수 사용 (count, sum, max, min…)
from sqlalchemy import func
db.query(func.count(User.id).label('total')).group_by(User.id).all()
삭제 Syntax
db.delete("조회한 모델 객체")
db.commit()
Reference
2.0 style query 결과 가져오기 총 정리 (한 개 또는 여러 개)
SQLAlchemy 1.x 와 2.0의 Query 스타일 비교
-
Python zoneinfo - UTC 시간대를 더욱 쉽게 적용합시다!
이전에 python에서 UTC 시간대를 적용할 때는 pytz 라이브러리가 주로 사용되었습니다.
특히 aware 타입과 naive 타입을 비교하기 어렵기 때문에, pytz를 사용해 datetime 객체를 aware 타입으로 바꾸고 비교하는 것은 매우 유용했습니다. (aware 타입은 timezone 정보가 포함된 datetime이고 naive 타입은 timezone 정보가 포함되지 않은 datetime입니다.)
그러나 pytz는 2018년 서울과 평양시간을 UTC+9 시간이 아닌 UTC+08:30으로 표현하는 버그, 실수를 유발할 수 있는 사용 방법 등 이슈도 공존했습니다. 이를 보완하기 위해, Python은 3.9 버전부터 표준 라이브러리로 zoneinfo 모듈을 제공합니다. 덕분에, 따로 pytz를 인스톨하지 않고도 datetime에 쉽게 원하는 시간대를 적용할 수 있습니다.
Python official document - zoneinfo
https://docs.python.org/ko/3/library/zoneinfo.html
ZoneInfo 클래스
ZoneInfo(key: str)
ZoneInfo는 key를 생성자의 인자로 받는 클래스입니다. 예를 들어, “America/New_York”, “Europe/London”를 key로 던지면, 해당 시간대 정보를 가지는 인스턴스를 생성합니다. 시간대 적용은 이 인스턴스를 활용합니다.
현재 시간에 ZoneInfo 적용하기
from zoneinfo import ZoneInfo
from datetime import datetime
dt = datetime.now(ZoneInfo('UTC'))
# datetime.now(tz=ZoneInfo('UTC'))와 동일
print(dt)
# 2022-01-09 11:05:40.133971+00:00
zoneinfo는 기존 datetime 객체에 그대로 적용할 수 있습니다. UTC 시간대를 적용한 현재 시간을 알고 싶다면, datetime.now(ZoneInfo('UTC'))을 사용합니다.(datetime.now(tz=ZoneInfo('UTC'))와 동일합니다.) 반환된 dt는 aware 타입 객체가 될 것입니다.
dt = datetime.now(ZoneInfo('Asia/Seoul'))
print(dt)
# 2022-01-09 20:05:40.133971+09:00
서울의 시간대로 현재 시간을 알고 싶다면, ZoneInfo의 인자로 ‘Asia/Seoul’ key를 적용합니다.
임의의 datetime에 ZoneInfo 적용하기
from zoneinfo import ZoneInfo
from datetime import datetime, timedelta
dt = datetime(2020, 10, 31, 12, tzinfo=ZoneInfo("America/Los_Angeles"))
print(dt)
# 2020-10-31 12:00:00-07:00
원하는 시간에 ZoneInfo를 적용하고 싶다면, datetime의 tzinfo에 ZoneInfo 정보를 줍시다.
dt_add = dt + timedelta(days=1)
print(dt_add)
# 2020-11-01 12:00:00-08:00
datetime끼리의 연산 역시 summer time을 고려해 알아서 계산됩니다.
Windows와 tzdata
zoneinfo는 Python의 표준 라이브러리에 포함되기 때문에, 따로 인스톨없이 사용할 수 있습니다.
다만, 윈도우의 경우 zoneinfo 모듈을 사용할 때 다음과 같은 에러가 발생할 수 있습니다.
ModuleNotFoundError: No module named ‘tzdata’
zoneinfo는 기본적으로 시스템의 시간대 데이터를 사용합니다. 하지만, 윈도우는 시간대를 다루는 시스템이 다른 OS와 조금 달라서, zoneinfo와 호환되지 않는다고 합니다. (PEP 615)
However, not all systems ship a publicly accessible time zone database — notably Windows uses a different system for managing time zones — and so if available zoneinfo falls back to an installable first-party package, tzdata, available on PyPI. [d] If no system zoneinfo files are found but tzdata is installed, the primary ZoneInfo constructor will use tzdata as the time zone source. - Sources for time zone data (PEP 615)
이 때는, CPython 핵심 개발자가 유지 보수하는 first-party 패키지인 tzdata를 인스톨합시다. (pip install tzdata)
zoneinfo는 참고할 수 있는 시간대 데이터가 없을 시 자동으로 tzdata를 시간대 데이터로 사용하므로, 인스톨 시 문제가 해결됩니다.
개발 시 최대한 신뢰할 수 있는 라이브러리를 사용하고 이외의 라이브러리에 대한 의존성을 줄일 필요가 있습니다. 고마웠던 pytz지만, 가능하다면 표준 라이브러리에 포함된 zoneinfo 사용을 지향해봐야겠습니다.
Reference
Python 3.10 document - zoneinfo
PEP 615 - Support for the IANA Time Zone Database in the Standard Library
PYTHON 3.9에 등장한 상큼한 8가지 FEATURES
평양 및 서울의 timezone관련 pytz 이슈
-
TypeScript basic - Advanced Object
Interface
타입스크립트에서 타입을 정의하는 방법은 다양합니다.
type Mail = {
postagePrice: number;
address: string;
}
const catalog: Mail = ...
기존처럼 type을 사용해 정의할 수도 있습니다.
interface Mail {
postagePrice: number;
address: string;
}
const catalog: Mail = ...
그런데 타입스크립트에서 객체의 타입을 정의하는데 자주 사용되는 또 하나의 방법은 interface를 사용하는 것입니다. type과 interface는 문법적인 측면에서 = 사용의 차이가 있지만, 타입을 강제하는 기능은 동일합니다.
그렇다면 interface는 어디에 사용하는 것일까요?
type은 object 뿐만 아니라 primitive 타입을 포함한 모든 타입을 정의하는데 사용할 수 있는 반면, interface는 object 타입 정의에만 사용할 수 있습니다. 마치 설계도와 같은 느낌이 녹아 있는 interface는 제약이 있다는 점에서 코드를 일관성 있게 작성하도록 도와주기 때문에, 객체 지향 프로그램을 작성할 때는 interface를 주로 사용합니다.
Interfaces and class
Interface와 class는 궁합이 잘 맞습니다. Interface는 object의 타입을 정의하는 키워드이고 class는 object로 프로그래밍하는 방법이기 때문입니다.
interface Robot {
identify: (id: number) => void;
}
class OneSeries implements Robot {
identify(id: number) {
console.log(`beep! I'm ${id.toFixed(2)}.`);
}
answerQuestion() {
console.log('42!');
}
}
interface는 class / object에 타입을 적용할 수 있습니다. 특히, class에 타입을 적용할 때에는 implements 키워드를 사용합니다.
위 코드는 OneSeries 클래스에 implements 키워드를 사용해 Robot 타입을 적용하는 과정입니다. Robot 타입이 적용된 OneSeries는 인터페이스에 명시된 대로 identify 메서드를 가져야 하며, 명시된 것만 지켰다면 이외로 추가적인 answerQuestion 메서드를 가지는 것도 가능합니다.
Deep nested type
class OneSeries implements Robot {
about;
constructor(props: { general: { id: number; name: string; } }) {
this.about = props;
}
getRobotId() {
return `ID: ${this.about.general.id}`;
}
}
Class OneSeries는 nested된 object 타입을 가지는 about 프로퍼티와 getRobotId 메서드를 가집니다. 이러한 nested된 object 타입을 표현하고 싶다면, interface Robot은 다음과 같이 작성하면 됩니다.
interface Robot {
about: {
general: {
id: number;
name: string;
};
};
getRobotId: () => string;
}
타입스크립트는 무한히 nested된 object 타입을 표현할 수 있습니다!
타입 구성 분리하기
interface About {
general: {
id: number;
name: string;
version: {
versionNumber: number;
}
}
}
위와 같이 더욱 깊게 nested되는 object 타입일수록 가독성은 떨어집니다. 또한, About 타입에서도 version만 필요한 상황이 있을 수 있습니다. 따라서, 일정한 정도로 각각 따로 interface를 만들어 함께 사용하는 것이 효과적일 수 있습니다.
interface About {
general: General;
}
interface General {
id: number;
name: string;
version: Version;
}
interface Version {
versionNumber: number;
}
앞선 복잡했던 interface 코드를 가독성 높은 재사용가능한 코드로 변형했습니다. 코드는 조금 길어졌지만, 더욱 큰 프로그램에서는 이러한 형태로 코드를 작성하는 것이 훨씬 유리합니다.
Extending interface
때때로 어떤 타입의 모든 프로퍼티와 메서드들을 복사해서 다른 타입에 가져와야 할 때도 있습니다. 이 때 extends가 유용합니다.
interface Shape {
color: string;
}
interface Square extends Shape {
sideLength: number;
}
const mySquare: Square = { sideLength: 10, color: 'blue' };
Square는 extends 키워드를 사용해 Shape의 모든 프로퍼티를 복사해서 가져옵니다. 실제로 mySquare에서는 sideLength 프로퍼티 뿐만 아니라 color 프로퍼티를 가져도 에러가 나지 않습니다.
Index signature
외부의 API나 소스로부터 데이터를 받아오는 경우, 특정 객체의 프로퍼티 이름이 정확히 무엇인지 알 수 없는 상황이 생깁니다. 이 때, 해당 프로퍼티들을 받는 변수를 임의의 이름으로 하나 설정해 처리할 수 있습니다. 이를 index signature라고 합니다.
{
'40.712776': true;
'41.203323': true;
'40.417286': false;
}
예를 들어, 위와 같은 데이터를 map API query에 대한 response로 받았다고 가정해봅시다. String 타입으로 이루어진 각각의 프로퍼티들은 위도를 나타냅니다. 다만, 이러한 프로퍼티들은 개발자 입장에서 정확히 이름을 알기가 어렵습니다.
interface SolarEclipse {
[latitude: string]: boolean;
}
따라서, 위와 같이 [latitude: string]라는 index signature를 정의해주면, response로 받는 데이터에 존재하는 모든 프로퍼티들의 타입을 하나로 정의할 수 있습니다. 위의 경우 모든 프로퍼티의 이름은 string 타입으로, 그 값은 boolean 타입으로 정의됩니다. latitude는 개발자가 임의로 설정한 이름임을 유의합니다.
Optional type member
어떤 함수나 클래스를 만들 때, optional argument 설정은 자유롭습니다. 이는 interface로 타입을 정의할 때도 마찬가지입니다. interface에서도 타입 멤버들에 대해 optional 속성을 설정해 줄 수 있습니다.
interface OptionsType {
name: string;
size?: string;
}
function listFile(options: OptionsType) {
let fileName = options.name;
if (options.size) {
fileName = `${fileName}: ${options.size}`;
}
return fileName;
}
위의 size 프로퍼티는 optional한 프로퍼티입니다. 프로퍼티 이름과 :사이에 ?가 존재한다면, 해당 프로퍼티는 optional 프로퍼티로 간주됩니다. 따라서, 위 코드에서는 size 프로퍼티를 사용하기 전에 if (options.size) 조건문을 사용해 size 프로퍼티의 존재 여부를 먼저 확인하고 사용해야 합니다.
listFile({ name: 'readme.txt' })
size 프로퍼티가 optional하기 때문에, 위와 같이 size 프로퍼티가 없는 객체를 인자로 사용해도 에러를 일으키지 않습니다.
Reference
Codecademy - TypeScript
-
TypeScript basic - Type Narrowing
Type narrowing
타입스크립트는 자신의 소스코드를 자바스크립트 코드로 컴파일하는 단계에서, 타입 체크를 하여 개발자에게 알림을 줍니다. 이러한 컴파일 단계에서의 타입 체크는 매우 유용합니다. 그러나 타입스크립트는 더 많은 것을 제공해줄 능력이 있습니다.
타입스크립트는 코드의 주변 맥락을 확인하여 런타임시 어떻게 동작할지 파악하고, 이에 따라 변수의 구체적인 타입을 추론하여 알려줍니다! 이를 type narrowing이라고 합니다. 특히, 변수가 union을 통해 다양한 타입의 가능성을 내재하고 있을 때, type narrowing은 빛을 발합니다.
function formatDate(date: string | number) {
// date can be a number or string here
if (typeof date === 'string') {
// date must be a string here
}
}
위와 같은 코드에서 date 인자는 string 타입도 number 타입도 가능합니다. 이 때, if (typeof date === 'string')같은 type guard를 사용해 각각의 타입마다 따로 로직을 만들어 type narrowing 할 수 있습니다. 이는 타입스크립트의 런타임 코드 실행 맥락 파악을 통한 타입 추론 능력 덕분입니다!
Type guard
function formatDate(date: string | number) {
// date can be a number or string here
if (typeof date === 'string') {
// date must be a string type
}
}
타입스크립트의 type narrowing은 type guard를 통해 진행됩니다. Type guard는 변수의 구체적인 타입을 체크하는 표현식을 가리킵니다. 일반적으로 typeof가 많이 활용됩니다. 위에서 if (typeof date === 'string') 부분이 type guard에 해당됩니다.
in as type guard
때때로 특정 프로퍼티 혹은 메서드가 해당 타입에 존재하는지 확인하고 싶은 경우가 있습니다. 이 때, in operator를 사용할 수 있습니다. in은 특정 프로퍼티가 객체 자체에 혹은 해당 객체의 프로토타입 체인 내에 존재하는지 확인해줍니다.
type Tennis = {
serve: () => void;
}
type Soccer = {
kick: () => void;
}
function play(sport: Tennis | Soccer) {
if ('serve' in sport) {
return sport.serve();
}
if ('kick' in sport) {
return sport.kick();
}
}
그리고 in은 type guard로서 사용할 수 있습니다. 위의 if ('serve' in sport)에서는 특정 프로퍼티에 존재 여부가 타입스크립트에게 단서를 주어 type narrowing이 이루어집니다. 위 코드의 경우, 만일 'serve'가 sport에 존재한다면, sport는 Tennis 타입일 것이 확정되기 때문에 if ('serve' in sport) 구문 내에서는 sport를 Tennis 타입 변수로 간주하고 코드를 짜도 무방합니다. 즉, 타입스크립트가 에러를 띄우지 않습니다.
Narrowing with else
만일 if 조건문이 type guard로 쓰였다면, 이에 대응하는 else 문은 if 문과 정확히 반대되는 type guard로서 기능합니다. 즉, if 문에서 체크한 타입 이외의 가능한 타입들은 모두 else 문에서 고려하게 됩니다.
function formatPadding(padding: string | number) {
if (typeof padding === 'string') {
return padding.toLowerCase();
} else {
return `${padding}px`;
}
}
예를 들어, 위 if 문에서 string 타입에 대한 로직을 작성했기 때문에, else 문은 number 타입에 대한 로직을 자동으로 담당하게 됩니다.
Narrowing After a Type Guard
사실 else 문을 사용하지 않아도 else 문과 똑같은 type narrowing을 사용할 수 있습니다. Type guard인 if 문이 끝난 이후 나오는 코드들은 나머지 가능한 타입들에 대한 코드로 자동으로 상정됩니다.
type Tea = {
steep: () => string;
}
type Coffee = {
pourOver: () => string;
}
function brew(beverage: Coffee | Tea) {
if ('steep' in beverage) {
return beverage.steep();
}
return beverage.pourOver();
}
예를 들어, 위의 if 문 내에서는 beverage가 Tea 타입을 가질 것입니다. 반면, if 문이 끝나고 나온 return beverage.pourOver(); 코드에서는 beverage가 당연히 Coffee 타입일 것이기 때문에, 타입스크립트는 오류를 내지 않습니다.
Reference
Codecademy - TypeScript
-
TypeScript basic - Union
Union
타입스크립트는 변수마다 다른 단계의 타입 구체성을 부여할 수 있습니다. 예를 들어, 변수에 string 타입을 강제하면 해당 변수는 string 타입으로 매우 제한적인 타이핑을 가지게 됩니다. 반면에, any를 부여하면 해당 변수는 특정 타입에 제한되지 않는 매우 자유로운 타이핑을 가지게 됩니다.
Union 타입은 이러한 두 극단의 타이핑에서 중간을 찾아가는 방법입니다. union은 서로 다른 타입들을 원하는대로 조합하여 만든 것을 의미합니다. 예를 들어, 회사원의 ID를 저장할 때, ID는 string 혹은 number가 모두 올 수 있습니다. 다만, 이를 any로 받기에는 너무 광범위하기 때문에, union을 사용해 원하는 타이핑 범위를 조절하는 것이 효과적입니다.
Union 정의
Union은 |을 사용해 원하는 type 멤버들을 하나하나 함께 정의합니다.
let ID: string | number;
// number
ID = 1;
// or string
ID = '001';
console.log(`The ID is ${ID}.`);
위 코드에서 ID는 string 혹은 number 값의 할당이 모두 허용됩니다. 이러한 union 타입은 함수의 파라미터를 포함해 어디서든 사용할 수 있습니다.
function getMarginLeft(margin: string | number) {
return { 'marginLeft': margin };
}
예를 들어, 함수의 파라미터에서는 위와 같이 union을 정의해주면 됩니다.
Type narrowing with type guard
Union을 사용하다보면, 코드의 특정 지점에서 union으로 type annotation된 변수의 타입이 모호해지는 경우가 발생합니다.
function getMarginLeft(margin: string | number) {
// ...
}
예를 들어, 함수 내에서 margin은 string과 number를 동시에 가지기 때문에, string의 메서드를 분별없이 사용하면 타입스크립트 트랜스파일러가 오류를 띄웁니다. 따라서, 다음과 같이 type guard를 사용하여, 해당 지점에서 변수가 string인지 number인지 명확히 표시해주어야 합니다.
function getMarginLeft(margin: string | number) {
// margin may be a string or number here
if (typeof margin === 'string') {
// margin must be a string here
return margin.toLowerCase();
}
}
위의 if 조건문은 type guard라고 부릅니다. 조건문 내에서라면 margin은 반드시 string 타입임이 보장되므로, toLowerCase()와 같은 string 메서드를 써도 에러가 나지 않습니다.
이렇게 type guard를 사용하여 코드 내에서 type을 명확히 하는 것을 type narrowing이라고 합니다. Union을 사용할 때는 type narrowing으로 각각의 타입에 맞는 로직을 분리해 사용하는 것이 필요합니다.
Inffered union return type
만일 경우마다 다양한 타입의 값을 리턴하는 함수가 있다면, 타입스크립트는 해당 함수의 return type을 union으로서 판단합니다.
function getBook() {
try {
return getBookFromServer();
} catch (error) {
return `Something went wrong: ${error}`;
}
}
예를 들어, 위 코드에서 getBookFromServer()의 리턴 값의 타입이 Book이라고 합시다. 그러면 함수 getBook은 Book 혹은 string 타입의 값을 리턴할 것입니다. 따라서, 타입스크립트는 getBook의 리턴 타입을 union Book | string으로 추론합니다.
Union with array
Union 타입은 array와 함께 할 때 더욱 강력해집니다.
const dateNumber = new Date().getTime(); // returns a number
const dateString = new Date().toString(); // returns a string
const timesList: (string | number)[] = [dateNumber, dateString];
예를 들어, 날짜의 타입으로 number 혹은 string이 올 수있습니다. 이러한 날짜 데이터를 array에 담고 싶다면, 위와 같이 union을 사용해 const timesList: (string | number)[] = [dateNumber, dateString];로 type annotation 해주면 됩니다.
이를 활용하면, 다양한 multiple type을 annotation하여 유연하게 배열을 사용할 수 있습니다.
Union with literal type
type Color = 'green' | 'yellow' | 'red';
function changeLight(color: Color) {
// ...
}
프로그램에서 어떠한 구체적으로 구별되는 상태를 만들길 원할 때, literal type을 union을 사용해 만들 수 있습니다. 위와 같이 'green', 'yellow', 'red'라는 리터럴을 사용해 union 타입을 만들면, 'purple'과 같은 인자는 타입스크립트에 의해 validation 됩니다.
Reference
Codecademy - TypeScript
-
Next.js basic - 개념 조각 모음
Next.js 주요한 특징들
Static Generation VS Sever-side Rendering VS Client-side Rendering
Static Generation
HTML이 build time에 pre-rendering되는 방식입니다. 즉, 외부에서 가져오는 데이터들도 build time에 요청하기 때문에 최신 데이터보다는 잘 변하지 않는 데이터들을 처리하기에 적합합니다.
미리 HTML을 생성하기 때문에 SEO에 강점이 있습니다. 또한 미리 한 번 생성된 HTML을 재사용하는 것과 더불어 Sever-side Rendering과 달리 CDN에 캐시되는 덕분에 셋 중에 속도가 가장 빠르며, Next.js에서 가장 권장되는 방법입니다.
Sever-side Rendering
HTML이 유저로부터 request가 있을 때마다 pre-rendering되는 방식입니다. 즉, 외부에서 가져오는 데이터들이 request 시점에 요청된 데이터들이기 때문에, 최신 데이터들을 사용하기 용이하다는 장점이 있습니다.
HTML이 pre-rendering되기 때문에, SEO에 강점이 있으며, Static Generation보다는 느리지만 Client-side Rendering 보다는 빠릅니다.
다만, 사용자 측면에서는 페이지 이동마다 화면이 깜빡거리며 새로고침이 발생하게 됩니다.
Client-side Rendering
HTML의 pre-rendering 및 외부 데이터 API 요청을 하지 않고, 클라이언트 측에서 자바스크립트 코드로 모든 것을 처리하는 방식입니다. 사용자가 요청한 페이지만 불러온 후, 사용자의 행동에 따라 필요한 부분만 다시 읽어 들이는 single page application 방식으로 동작하게 됩니다. 따라서, 사용자 측면에서 리로딩없이 필요한 부분만 빠르게 인터랙션할 수 있습니다.
다만, 초기 구동 속도가 느리고 SEO가 어렵다는 단점이 있습니다. (구글에서는 Client-side Rendering도 SEO를 잘 할 수 있다고 이야기하지만, Client-side Rendering은 SEO가 잘 안된다는 것이 정설입니다.)
위의 렌더링 방식들은 페이지마다 다르게 적용할 수 있고, 한 페이지 안에서도 부분마다 다르게 적용할 수 있습니다.
예를 들어, 보통 SEO가 가장 잘되어야 하는 부분은 상품 정보 페이지이므로 해당 페이지는 Static Generation이나 Sever-side Rendering으로 처리하는 것이 좋습니다. 또한, 상품 정보 페이지 내에서 title 같은 정보는 잘 변하지 않으므로 Static Generation을 사용하는 것이 좋습니다. 반면에, description이나 keyword 같은 부분들은 A/B Test 등으로 자주 변화를 시도해 볼 수 있기 때문에, Sever-side Rendering을 사용하는 것이 적합합니다. 이외의 데이터와 상관없는 navigation bar나 메뉴 같은 부분들은 Client-side Rendering을 적용해 보다 나은 인터랙션을 제공할 수 있습니다.
Static file serving
Next.js는 static 파일을 public 디렉토리에서 처리합니다. 그리고 public 폴더 안에 있는 static file들은 base URL을 /로 사용할 수 있습니다. 예를 들어, /public/me.jpg는 /me.jpg로 사용하면 됩니다.
Public 디렉토리에 있는 파일들은 빌드 타임에만 서빙되므로, 런타임에 저장되는 파일들은 AWS S3 같은 다른 서드 파티 서비스를 사용해 처리하길 권장합니다.
Reference
Next.js Document
-
TypeScript basic - Complex Types
1. Array
Array의 타입을 정하는 것은 앞서 진행한 primitive types와는 조금 다릅니다. 자바스크립트의 array는 다양한 타입의 데이터가 array의 요소로서 공존할 수 있기 때문입니다. 따라서, array의 타입을 알아낸다는 것은 각각의 element의 타입을 추적한다는 의미가 됩니다.
기존 자바스크립트에서는 array의 타입을 추적하는 작업이 상당히 번거롭지만, 타입스크립트는 이를 간편하게 해결해줍니다.
Array type annotation
Array의 타입을 type annotation으로 미리 정할 수 있습니다. Array type annotation 방법은 두 가지 존재합니다.
let names: string[] = ['Danny', 'Samantha'];
먼저, element 타입을 기존의 type annotation 방법처럼 지정하고 []를 바로 뒤에 붙여주는 방식입니다. 위의 경우, element의 타입이 string인 array가 만들어집니다.
let names: Array<string> = ['Danny', 'Samantha'];
또 다른 방법은 Array<T> 문법을 사용하는 것입니다. 이 역시 앞선 코드와 동일하게 array의 element 타입을 string으로 지정합니다.
이렇게 type annotation이된 array는 array를 생성할 때의 element가 지정한 타입과 다르거나 array에 새로 추가하는 element의 타입이 지정한 타입과 다를 때, 타입 에러를 보여줍니다.
Multi-dimensional array
다차원 array를 다룰 때는 차원수에 대응하여 []를 type annotation으로 더 붙여주면됩니다.
let arr: string[][] = [['str1', 'str2'], ['more', 'strings']];
예를 들어, string[][]의 경우 (string[])[]을 요약한 것입니다. 즉, 모든 element가 string[] 타입을 가지는 array라고 해석하면 됩니다.
let names: string[] = []; // No type errors.
let numbers: number[] = []; // No type errors.
names.push('Isabella');
numbers.push(30);
이 때, 빈 array []는 어떤 array 타입의 값으로 할당되어도 문제없이 실행됨을 유의합니다.
Tuple
자바스크립트의 array는 앞서 말했듯 다양한 타입의 요소들이 섞여서 구성될 수 있습니다. 타입스크립트에서는 이렇게 다양한 타입의 element로 구성된 array를 tuple이라고 부르며, 새로운 자료형으로서 다룹니다.
let ourTuple: [string, number, string, boolean] = ['Is', 7 , 'our favorite number?' , false];
Tuple은 위와 같이 []안에 원하는 순서대로 데이터 타입을 나열함으로써 구현합니다. 이로 인해, tuple은 tuple 내 element 타입의 순서와 tuple의 길이가 미리 고정되게 됩니다.
let tup: [string, string] = ['hi', 'bye'];
let arr: string[] = ['there','there'];
tup = ['there', 'there']; // No Errors.
tup = arr; // Type Error! An array cannot be assigned to a tuple.
자바스크립트에서는 array나 tuple이 모두 동일하게 간주됩니다. 하지만, 타입스크립트에서는 두 자료형이 다르게 취급되며, 심지어 element들이 동일한 타입을 가졌을지라도 tuple 변수에 array를 할당하는 것이 불가능합니다.
Array type inference
타입스크립트는 변수에 초기화된 value 혹은 return statement를 보고 해당 변수의 타입을 추론합니다. 이는 array가 value로 주어져도 마찬가지입니다.
let examAnswers= [true, false, false];
다만, 이때 examAnswers의 타입이 boolean[]인 array인지 [boolean, boolean, boolean]인 tuple인지 헷갈립니다. 타입스크립트는 이에 대해 항상 boolean[] array로서 타입을 추론합니다. 길이 및 타입 순서의 고정 등 제약이 많은 tuple보다 array가 더 자유로운 타입이기 때문입니다.
let tup: [number, number, number] = [1,2,3];
let concatResult = tup.concat([4,5,6]); // concatResult has the value [1,2,3,4,5,6].
위과 같이, tuple과 array를 concatenation하는 상황에서도 concatResult는 array 타입으로 추론됩니다.
따라서, 타입스크립트의 type inference에서는 tuple로 추론되는 경우가 없습니다. Tuple을 사용하고 싶다면, 앞서 확인한 type annotation을 사용해야만 합니다.
Rest parameters type annotation
function addPower(p: number, ...numsToAdd: number[]): number{
/* rest of function */
}
Rest parameters는 고정되지 않은 다수의 인자를 array로서 받습니다. 따라서, array 타입으로서 인자에 기존 방식대로 type annotation을 줄 수 있습니다.
Spread syntax with tuple
자바스크립트의 spread syntax는 tuple과 매우 궁합이 잘 맞습니다.
function gpsNavigate(startLatitudeDegrees:number, startLatitudeMinutes:number, startNorthOrSouth:string, startLongitudeDegrees: number, startLongitudeMinutes: number, startEastOrWest:string, endLatitudeDegrees:number, endLatitudeMinutes:number , endNorthOrSouth:string, endLongitudeDegrees: number, endLongitudeMinutes: number, endEastOrWest:string) {
/* navigation subroutine here */
}
예를 들어, 위의 함수를 두 가지 위치 정보를 받아 경로를 찾는 함수라고 생각해봅시다. 많은 수의 인자를 받는 함수이기 때문에 gpsNavigate(40, 43.2, 'N', 73, 59.8, 'W', 25, 0, 'N', 71, 0, 'W')와 같이 호출하게 됩니다. 이는 가독성이 떨어지기에, 다음과 같이 두 가지 tuple 변수로 나눠봅시다.
let codecademyCoordinates: [number, number, string, number, number, string] = [40, 43.2, 'N', 73, 59.8, 'W'];
let bermudaTCoordinates: [number, number, string, number, number, string] = [25, 0 , 'N' , 71, 0, 'W'];
그리고 spread syntax를 사용해 조금 더 가독성을 높여 호출해봅시다.
gpsNavigate(...codecademyCoordinates, ...bermudaTCoordinates);
// And by the way, this makes the return trip really convenient to compute too:
gpsNavigate(...bermudaTCoordinates, ...codecademyCoordinates);
// If there is a return trip . . .
2. Complex type
Enum
관련있는 상수들의 집합을 열거형(Enum)이라고 합니다. 앞서 살펴본 타입들은 해당 타입의 값들이 다양하게 무한한 경우의 수로 존재할 수 있지만, enums는 값의 경우의 수가 제한됩니다. 즉, 내가 원하는 경우의 수 값들로만 타입을 구성하고 enumerate하고 싶을 때 enum이 적합합니다.
enum Direction {
North,
South,
East,
West
}
위와 같이 동서남북을 정의하고 싶을 때, enum을 사용할 수 있습니다. Enum에서 각각의 enum variable(∝key)들에는 number 타입을 가지는 숫자 값(∝value)이 순서대로 자동 대응되게 됩니다. 즉, Direction.North, Direction.South, Direction.East, Direction.West는 각각 0, 1, 2, 3의 값들이 대응됩니다.
let whichWayToArcticOcean: Direction;
whichWayToArcticOcean = Direction.North; // No type error.
whichWayToArcticOcean = Direction.Southeast; // Type error: Southeast is not a valid value for the Direction enum.
whichWayToArcticOcean = West; // Wrong syntax, we must use Direction.West instead.
Enum으로 만든 custom type은 기존 방식처럼 type annotation을 그대로 사용할 수 있습니다. 그리고 enum으로 type annotation한 변수는 위와 같이 Direction enum에서 .을 통해 접근가능한 값들만 변수에 할당할 수 있게 됩니다.
let whichWayToAntarctica: Direction;
whichWayToAntarctica = 1; // Valid TypeScript code.
whichWayToAntarctica = Direction.South; // Valid, equivalent to the above line.
만일 enum value가 number type이라면, 위의 whichWayToAntarctica = 1; 같이 임의의 enum value를 직접 할당하는 것도 가능합니다. (Enum의 value로는 number 혹은 string 타입 두 가지 경우가 가능합니다. 뒤에서 더 설명하겠습니다.)
enum Direction {
North = 7,
South,
East,
West
}
위와 같이 North가 7부터 시작하는 enum도 만들 수 있습니다. 이 때, South, East, West는 각각 8, 9, 10으로 1씩 자동으로 증가하며 대응됩니다.
enum Direction {
North = 8,
South = 2,
East = 6,
West = 4
}
만일 모든 값에 각기 다른 값을 대응시키고 싶다면, 위 코드처럼 빠짐없이 명시해주면 됩니다.
String Enum VS Numeric Enum
Enum은 number 혹은 string 타입에 한해서 자신의 value를 가질 수 있습니다. 앞서 살펴본 enum은 number 타입의 enum value를 가지는 numeric enum이었습니다. 이와 대조적으로, string 타입의 enum value를 가지는 string enum을 살펴봅시다.
enum DirectionNumber { North, South, East, West }
enum DirectionString { North = 'NORTH', South = 'SOUTH', East = 'EAST', West = 'WEST' }
자동으로 number 타입 값이 할당되던 numeric enum과 달리, string enum은 직접 string 값을 명시해줘야 합니다. String 타입 enum value는 어떤 것이든 올 수 있지만, 관례적으로 enum variable의 대문자 형태를 사용하는 것이 일반적입니다. 덕분에 에러메시지나 로그에 정보가 담기기 때문입니다. 이러한 정보적 측면에서, enum은 항상 string enum으로 사용할 것이 권장됩니다.
let whichWayToAntarctica: DirectionString;
whichWayToAntarctica = '\ (•◡•) / Arbitrary String \ (•◡•) /'; // Type error!
whichWayToAntarctica = 'SOUTH'; // STILL a type error!
whichWayToAntarctica = DirectionString.South; // The only allowable way to do this.
String enum이 권장되는 또 하나의 이유는 enum 정의 이후에는 임의의 string 값 할당이 불가능함에 있습니다. 위와 같이 enum으로 type annotation된 변수에 string 값을 할당하면, enum에 이미 존재하는 enum value임에도 type error가 발생합니다. 즉, whichWayToAntarctica = DirectionString.South;처럼 오직 .을 통해 접근한 값만 할당 가능합니다. 임의의 값을 마음대로 할당가능한 numeric enum가 대조되는 부분입니다.
let whichWayToAntarctica: DirectionNumber;
whichWayToAntarctica = 1; // Valid TypeScript code.
whichWayToAntarctica = DirectionNumber.South; // Valid, equivalent to the above line.
whichWayToAntarctica = 943205; // Also, valid TypeScript code!!
Object
let aPerson: {name: string, age: number};
Object 역시 type annotation으로 사용할 수 있습니다. 위의 코드는 object type annotation하는 syntax의 예시입니다. Object 리터럴과 매우 비슷해 보이지만, 각 property의 value 위치에 type이 명시되어 있습니다.
aPerson = {name: 'Aisle Nevertell', age: "wouldn't you like to know"}; // Type error: age property has the wrong type.
aPerson = {name: 'Kushim', yearsOld: 5000}; // Type error: no age property.
aPerson = {name: 'User McCodecad', age: 22}; // Valid code.
그리고 실제로 각각의 property의 이름과 type이 동일한 object가 아니라면 타입 에러를 띄웁니다.
let aCompany: {
companyName: string,
boss: {name: string, age: number},
employees: {name: string, age: number}[],
employeeOfTheMonth: {name: string, age: number},
moneyEarned: number
};
Object의 큰 장점은 property에 type 제한이 없다는 점입니다. Object의 property에는 enum, array 혹은 또 다른 object까지 다양하고 자유롭게 type을 명시할 수 있습니다.
Type alias
만일 object나 tuple 타입 같은 복잡한 타입이 동일하게 자주 반복된다면, 해당 타입에 따로 임의의 이름을 정해 효율을 높일 수 있습니다. type <alias name> = <type> 형태로 원하는 타입에 별칭을 부여합니다.
let aCompany: {
companyName: string,
boss: { name: string, age: number },
employees: { name: string, age: number }[],
employeeOfTheMonth: { name: string, age: number },
moneyEarned: number
};
위와 같이 반복적으로 동일한 타입이 자주 나오는 경우, 다음과 같이 type alias를 사용해 반복을 줄입니다.
type Person = { name: string, age: number };
let aCompany: {
companyName: string,
boss: Person,
employees: Person[],
employeeOfTheMonth: Person,
moneyEarned: number
};
Type alias에서 유의할 점은 type alias는 단순히 별칭일 뿐, 그 자체로는 타입이 아니라는 것입니다. 따라서 아래와 같은 코드는 type alias의 이름이 달라도 내부의 타입은 동일하기 때문에, 타입 에러를 일으키지 않습니다.
type MyString = string;
type MyOtherString = string;
let firstString: MyString = 'test';
let secondString: MyOtherString = firstString; // Valid code.
Function type
자바스크립트의 함수는 변수에 담길 수 있습니다. 그리고 타입스크립트는 함수가 담기는 변수에 함수 타입을 적용할 수 있습니다.
type StringsToNumberFunction = (arg0: string, arg1: string) => number;
위 코드는 마치 arrow function과 유사하지만, 함수가 아니라 함수 타입을 정의한 것입니다. 위의 문법을 사용해 각각의 파라미터에 타입을 지정할 수 있고, => 뒤에는 return 타입을 지정합니다.
참고로 type StringsToNumberFunction는 type alias에 해당합니다.
let myFunc: StringsToNumberFunction;
myFunc = function(firstName: string, lastName: string) {
return firstName.length + lastName.length;
};
myFunc = function(whatever: string, blah: string) {
return whatever.length - blah.length;
};
// Neither of these assignments results in a type error.
앞서 정의한 함수 타입을 myFunc 변수에 적용했습니다. 이후 myFunc에 할당된 함수들은 모두 StringsToNumberFunction 타입에 어긋나지 않기 때문에, 타입에러가 일어나지 않습니다. 이 때, 함수 타입에 설정한 arg0, arg1과 실제 파라미터의 이름은 달라도 괜찮습니다.
type StringToNumberFunction = (string)=>number; // NO
type StringToNumberFunction = arg: string=>number; // NO NO NO NO
함수 타입에서 유의할 점은 파라미터의 이름을 안 쓴다거나 파라미터를 둘러싸는 ()를 빼먹어서는 안되는 것입니다. 함수 타입에서는 파라미터가 하나여도 ()를 반드시 써줘야 합니다.
type OperatorFunction = (arg0: number, arg1: number) => number;
// Math Tutor Function That Accepts a Callback
function func(operationCallback: OperatorFunction) {
operationCallback();
}
특히, 콜백 함수를 인자로 받을 때 이러한 함수 타입들은 더욱 유용할 것입니다.
Generic type
제네릭(Generic)은 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미합니다. 예를 들어, 클래스를 정의 할 데이터 타입을 확정하지 않고 인스턴스를 생성할 때 데이터 타입을 지정하는 것은 제네릭에 한 예입니다. 앞서 봤던, array element에 타입을 적용하는 Array<T> 문법도 제네릭의 예시에 해당합니다.
type Family<T> = {
parents: [T, T], mate: T, children: T[]
};
Object에도 위와 같이 제네릭을 적용할 수 있습니다. Family<T> 자체로는 아직 type annotation을 할 수 없습니다. T라는 타입 변수 자리에 원하는 타입을 대체했을 때, 비로소 type annotation으로 기능할 수 있습니다. 이 때, 식별자 T는 관습적으로 쓰이는 것이므로 임의로 바꿔도 괜찮습니다.
let aStringFamily: Family<string> = {
parents: ['stern string', 'nice string'],
mate: 'string next door',
children: ['stringy', 'stringo', 'stringina', 'stringolio']
};
위와 같이 타입 변수 T를 string으로 대체하면, 타입 내 원래의 T 자리는 모두 string으로 대체되기 때문에, 위 코드는 결과적으로 에러 없이 잘 동작합니다.
Generic function
함수의 리턴 타입을 설정할 때도 제네릭은 유용하게 사용됩니다.
function getFilledArray<T>(value: T, n: number): T[] {
return Array(n).fill(value);
}
위와 같이 array를 생성하는 함수는 array에 삽입되는 요소의 타입이 value의 값에 따라 달라지기 때문에, 리턴되는 array의 타입을 정의하기가 어렵습니다.
따라서, 제네릭을 적용하여 T로 리턴 타입 정의를 용이하게 합니다.
getFilledArray<string>('cheese', 3)
제네릭 함수의 호출은 위와 같이 원하는 타입을 <> 안에 명시하여 실행합니다.
getFilledArray<string>의 경우는 결과적으로 type annotation (value: string, n: number): string[]와 동일해집니다. 즉, T가 함수 내부의 type annotation에서 적용 가능해집니다.
Reference
Codecademy - TypeScript
-
Next.js basic - Pre-rendering
Pre-rendering
Pre-rendering은 Next.js의 중요한 특징 중 하나입니다. Next.js는 클라이언트에서 HTML 생성을 모두 처리하기 보다는, 처음에 모든 페이지에 대한 HTML을 미리 생성하는데, 이것을 pre-rendering이라고 합니다. Pre-rendering 덕분에 Next.js는 SEO와 더불어 좋은 성능을 보입니다.
Pre-rendering 이후에는 hydration이라는 과정을 거칩니다. Hydration이란 브라우저가 페이지를 로딩할 때, 해당 페이지를 로딩하기 위해 필요한 최소한의 자바스크립트 코드만을 가져와 실행시켜서 미리 생성되어 있는 HTML을 interactive하게 만드는 과정을 말합니다.
만일 순수 리액트 코드로 작성된 애플리케이션의 경우, pre-rendering이 없기 때문에 페이지들의 HTML을 미리 생성하지 않습니다. 그래서 만일 순수 리액트 코드로 이루어진 애플리케이션의 자바스크립트 코드를 disabled 상태로 만든다면, 페이지 자체가 보이지 않게 됩니다. 반면, Next.js가 적용된 애플리케이션은 static HTML이 미리 생성된 덕분에 자바스크립트 기능을 제외한 페이지 자체는 보이게 됩니다.
Two forms of pre-rendering
Next.js의 pre-rendering은 Static Generation과 Server-side Rendering이라는 두 가지 형태가 존재합니다. 두 형태의 차이점은 언제 페이지에 대한 HTML이 생성되는가에 있습니다.
먼저, Static Generation은 build-time에 HTML을 생성하는 pre-rendering method입니다. 즉, 클라이언트의 request 이전에 HTML이 생성됩니다. 이렇게 pre-rendering된 HTML은 각각의 request에 요청될 때마다 재사용됩니다. Static Generation은 request에 상관없이 내용이 자주 바뀌지 않는 marketing page, blog post, E-commerce product listing, documentation 등에 유용합니다
이와 달리, Server-side Rendering은 각각의 request가 올 때마다 HTML을 생성하는 pre-rendering method입니다. 즉, 클라이언트의 request 후에 HTML이 생성되며, 생성된 HTML은 재사용되지 않습니다. Server-side Rendering은 빈번히 update되는 데이터 혹은 request마다 content가 바뀜으로 인해, request 이전에 pre-render하기 어려운 상황에서 유용합니다.
참고로, 개발자 모드로 서버를 실행했을 때는 모든 페이지가 Server-side Rendering으로 pre-rendering됩니다. 심지어 Static Generation을 사용하는 페이지라고 하더라도 마찬가지입니다.
Next.js는 각각의 페이지마다 위의 두 가지 형태 중 어떤 pre-rendering을 사용할지 선택할 수 있습니다. 따라서, Static Generation과 Server-side Rendering 방식이 혼합된 Next.js 애플리케이션을 만들 수 있습니다.
다만, 대부분의 경우에서는 Static Generation이 권장됩니다. 매 request 마다 HTML을 생성해야 하는 Server-side rendering에 비해, 한 번 HTML을 생성하고 재사용하는 Static Generation이 훨씬 빠르기 때문입니다. 만일 항상 최신 상태를 유지해야 하는 데이터를 처리할 경우, 느림을 감안하고서라도 Server-side Rendering을 사용하거나 pre-rendering을 생략하고 Client-side Rendering을 사용하는 것이 적합합니다.
Static Generation with and without data
Static Generation은 외부 데이터가 필요할 때 혹은 필요하지 않을 때 모두 사용할 수 있습니다. 외부적으로 데이터를 가져오지 않아도 되는 페이지들은 자동으로 Static Generation될 것입니다. 이와 달리, 처음에 반드시 데이터를 가져와야 하는 페이지의 경우, 빌드 시간에 파일 시스템에 접근하거나 외부 API 혹은 데이터베이스 등에 request를 해야만 합니다. 이러한 요청은 getStaticProps을 사용해 진행합니다.
export default function Home(props) { ... }
export async function getStaticProps() {
// Get external data from the file system, API, DB, etc.
const data = ...
// The value of the `props` key will be
// passed to the `Home` component
return {
props: ...
}
}
비동기 함수 getStaticProps 안에서 실행하는 모든 것들은 빌드 타임에 진행되고, 요청을 통해 응답받은 데이터는 props의 형태로 데이터가 필요한 페이지 컴포넌트에 전달할 수 있습니다. 또한, getStaticProps 함수 내에 원하는 로직을 완성했다면, 페이지 컴포넌트를 export한 것과 마찬가지로 getStaticProps 함수도 export해주는 것을 유의해야 합니다.
getStaticProps에 대한 몇 가지 유의할 점
getStaticProps 함수는 항상 server-side에서 실행됩니다. 즉, 브라우저에서 실행될 염려가 없기 때문에, 필요한 데이터를 가져오기 위해 데이터베이스에 쿼리를 날리는 것 역시 문제가 되지 않습니다.
개발자 모드로 서버를 실행했다면, getStaticProps는 request가 있을 때마다 실행되는 Sever-side Rendering 방식으로 동작합니다. (npm run dev, yarn dev) 반면에, production용으로 서버가 실행되었다면, getStaticProps 함수는 원래 의도대로 빌드 시간에 실행됩니다.
getStaticProps 함수는 항상 page 파일에서 export되어야 합니다. Non-page 파일에서 export 되어서는 안됩니다.
Server-side Rendering
만일 Server-side Rendering을 하고 싶다면, getServerProps를 사용합니다. CDN에 캐시되지 않아 getStaticProps보다는 느리겠지만, 최신의 정보를 request 때마다 가져올 수 있습니다.
export async function getServerSideProps(context) {
return {
props: {
// props for your component
}
}
}
context 매개변수를 사용하면, request와 관련된 매개변수들을 다룰 수 있습니다.
Client-side Rendering
만일 데이터 pre-rendering을 생략하고 싶다면, Client-side Rendering을 사용합니다. 페이지에서 외부적으로 데이터를 필요로하지 않는 부분만 Static Generation하고, 페이지의 나머지 부분은 client에서 자바스크립트를 사용해 데이터를 가져오면서 로딩할 수 있습니다. (Static Generation without data + Fetch data on the Client-Side)
만일 client-side에서 데이터를 fetching하고 싶다면, Next.js에서 제공하는 리액트 훅 SWR을 사용할 것을 권장합니다. 다음은 SWR의 예시입니다.
import useSWR from 'swr'
function Profile() {
const { data, error } = useSWR('/api/user', fetch)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
Reference
Next.js Document
-
TypeScript basic - Function
Parameter type annotation
변수에 type annotation을 했던 것처럼, 타입스크립트는 함수의 파라미터에 type annotation을 하여 파라미터가 원하는 데이터 타입을 가지도록 할 수 있습니다.
물론, 기존 자바스크립트에서도 파라미터의 타입을 validation할 수 있는 방법이 있습니다.
function printLengthOfText(text) {
if (typeof text !== 'string') {
throw new Error('Argument is not a string!');
}
console.log(text.length);
}
printLengthOfText(3); // Error: Argument is not a string!
다만 조건문을 만들고 error를 일으키는 작업이 조금 번거롭습니다.
타입스크립트는 이러한 불편함을 type annotation을 사용해 다음과 같이 간단히 해결합니다.
function printKeyValue(key: string, value) {
console.log(`${key}: ${value}`);
}
printKeyValue('Courage', 1337); // Prints: Courage: 1337
printKeyValue('Mood', 'scared'); // Prints: Mood: scared
이로 인해, key는 string 타입을 가져야 하며, annotation이 없는 value는 any 타입을 부여받게 됩니다.
Optional parameter
function greet(name: string) {
console.log(`Hello, ${name || 'Anonymous'}!`);
}
greet('Anders'); // Prints: Hello, Anders!
greet(); // TypeScript Error: Expected 1 arguments, but got 0.
JavaScript는 인자 없이 greet()을 실행했을 때, name은 undefined가 되고 이는 falsy value로 인식되어 결국 ‘Hello, Anonymous`가 콘솔에 출력될 것입니다. 그러나 타입스크립트는 optional을 따로 지정해주지 않으면 이에 대하여 오류를 일으킵니다.
따라서 optional 파라미터를 사용하고 싶다면, 다음과 같이 파라미터 뒤에 ?를 사용해 해당 파라미터가 optional 함을 선언해줍니다.
function greet(name?: string) {
console.log(`Hello, ${name|| 'Anonymous'}!`);
}
greet(); // Prints: Hello, Anonymous!
Default parameter
파리미터의 기본값을 지정해주면 해당 파리미터는 optional해지며 동시에 기본값의 타입과 동일한 타입의 데이터가 인자로 올 것이 전제됩니다.
function greet(name = 'Anonymous') {
console.log(`Hello, ${name}!`);
}
위 코드에 대해 인자없이 greet()을 실행하면, ‘Hello, Anonymous!’를 출력합니다. 반면에, greet(3)과 같이 number 값을 인자로 전달하면 타입 에러를 야기합니다. 이는 name의 인자로 string 혹은 undefined 값이 올 것이라고 파라미터의 default 값으로 인해 설정되었기 때문입니다.
Inferring return type
타입스크립트는 함수의 리턴 값의 타입 역시 추론합니다.
function ouncesToCups(ounces: number) {
return `${ounces / 16} cups`;
}
const liquidAmount: number = ouncesToCups(3);
// Type 'string' is not assignable to type 'number'.
예를 들어, ouncesToCups 함수는 return statement의 값이 string이므로, string 값을 반환할 것이 분명히 예측됩니다. 따라서 liquidAmount 역시 string 값이 되어야 하는데 number로 변수를 선언했으므로 타입 에러가 나타납니다.
Return type annotation
또한, type annotation을 사용하면 함수의 리턴 값에 대해서도 더 분명하게 타입 선언을 해줄 수 있습니다.
function createGreeting(name?: string): string {
if (name) {
return `Hello, ${name}!`;
}
return undefined;
//Typescript Error: Type 'undefined' is not assignable to type 'string'.
};
함수의 () 바로 뒤에 : type을 설정하면, 함수의 반환 값의 타입을 지정해줄 수 있습니다.
뿐만 아니라 Arrow function에도 마찬가지로 리턴 값에 대한 타입을 지정해줄 수 있습니다.
const createArrowGreeting = (name?: string): string => {
if (name) {
return `Hello, ${name}!`;
}
return undefined;
// Typescript Error: Type 'undefined' is not assignable to type 'string'.
};
Void return type
함수에 특별한 이유가 없는 한, return type을 type annotation으로 명시해주는 것이 좋은 습관입니다. 다만, 따로 리턴하는 것이 없는 함수에 대해서는 void를 사용해 type annotation을 해주는 것이 적절합니다.
function logGreeting(name:string): void{
console.log(`Hello, ${name}!`)
}
Documentation comments
/**
* This is a documentation comment
*/
함수에 대한 설명을 등록하고 마우스 호버 등을 통해 이를 확인하고 싶다면 documentation comments 기능을 활용합니다.
/**
* Returns the sum of two numbers.
*
* @param x - The first input number
* @param y - The second input number
* @returns The sum of `x` and `y`
*
*/
function getSum(x: number, y: number): number {
return x + y;
}
}
위와 같이, 원하는 함수 위에 documentation comment를 등록하면 함수에 대한 설명을 입력할 수 있습니다. 또한, @param, @returns 등의 special tags를 활용하면, 함수의 특정 요소를 강조하는 comment를 입력할 수 있습니다.
Reference
Codecademy - TypeScript
-
Next.js basic - Asset, Metadata and CSS
CSS, assets and metadata
Next.js에서는 CSS를 어떻게 적용하여 스타일링할 수 있을까요? 그리고 이미지와 같은 정적 파일들과 <title>과 같은 페이지 내 메타 데이터들은 Next.js에서 어떻게 다뤄야 할까요?
Asset with <Image> and image optimization
이미지와 같은 정적 파일들은 public 디렉토리에 위치시킵니다. Next.js는 public에 있는 파일들을 자동으로 참조합니다. 이미지를 저장하기 위해 public 디렉토리에 images 디렉토리를 생성하고, 그 안에 원하는 이미지를 저장하세요. (예를 들어, 프로필 사진을 사용하기 위해 profile.jpg를 저장해보세요.)
import Image from 'next/image'
const YourComponent = () => (
<Image
src="/images/profile.jpg" // Route of the image file
height={144} // Desired size with correct aspect ratio
width={144} // Desired size with correct aspect ratio
alt="Your Name"
/>
)
저장한 이미지는 Image 컴포넌트를 next/image에서 임포트해 사용합니다. height와 width 속성을 사용해 이미지의 렌더링 사이즈를 지정해주고, src로 이미지의 위치를 설정해줍니다.
기존 HTML <img> 태그는 브라우저의 화면 크기가 바뀔 때마다 변화에 대한 이미지의 resizing을 지원하지 않습니다. 반면에, Next.js의 <Image> 컴포넌트를 사용하면, 해당 이미지의 resizing을 자동으로 지원해줍니다.
또한, <Image> 컴포넌트는 이미지의 포멧도 브라우저에서 WepP와 더 나은 이미지 포멧을 지원한다면, 자동으로 포멧을 변환해서 이미지 파일을 optimization해줍니다. 뿐만 아니라, 애플리케이션의 빌드 타임에서 이미지를 로딩하는 대신, 이미지가 viewport에 나올 때 비로소 lazy-loading하여, 페이지 전체 로딩 시간을 원활히 합니다.
Metadata
페이지의 메타 데이터를 변경하고 싶다면, Next.js의 <Head> 컴포넌트를 사용합니다. HTML <head>와는 달리, <Head>는 리액트 컴포넌트입니다.
import Head from 'next/head'
<Head> 컴포넌트는 'next/head'에서 임포트합니다.
export default function FirstPost() {
return (
<>
<Head>
<title>First Post</title>
</Head>
<h1>First Post</h1>
<h2>
<Link href="/">
<a>Back to home</a>
</Link>
</h2>
</>
)
}
그리고 원하는 메타 데이터를 <Head> 컴포넌트 안에서 설정해줍니다. 위 코드는 페이지의 <title> 속성을 변경했습니다. 개발자 도구에서 해당 페이지의 HTML 문서를 확인해보면, 실제로 <head>에 <title> 태그가 추가되어 있는 것을 볼 수 있습니다.
CSS styling
<style jsx>{`
…
`}</style>
Next.js에서 CSS는 <style jsx> 태그에 작성하면 됩니다. <style jsx>는 styled-jsx 라이브러리를 사용해 지원되는 것이며, Next.js는 built-in으로 제공됩니다. CSS와 Sass 역시 마찬가지로 built-in으로 지원됩니다.
Layout component & CSS module
CSS 스타일을 적용하기 위해, Layout 컴포넌트와 CSS module을 사용해봅시다.
먼저 최상위 디렉토리에 components 디렉토리를 하나 생성합니다.
export default function Layout({ children }) {
return <div>{children}</div>
}
그리고 components/layout.js를 생성하여 위와 같은 Layout 컴포넌트를 작성합니다. 이 Layout 컴포넌트는 모든 페이지에 걸쳐 사용될 것입니다.
import Head from 'next/head'
import Link from 'next/link'
import Layout from '../../components/layout'
export default function FirstPost() {
return (
<Layout>
<Head>
<title>First Post</title>
</Head>
<h1>First Post</h1>
<h2>
<Link href="/">
<a>Back to home</a>
</Link>
</h2>
</Layout>
)
}
그리고 CSS를 추가하고 싶은 페이지에 <Layout> 컴포넌트를 감싸서 적용해줍니다.
.container {
max-width: 36rem;
padding: 0 1rem;
margin: 3rem auto 6rem;
}
<Layout>에 적용해줄 CSS는 CSS Module을 사용해 생성합니다. CSS Module은 CSS 파일을 임포트해 리액트 컴포넌트에서 사용하는 것을 도와줄 것입니다. components/layout.module.css 파일을 생성하여, 위와 같이 원하는 CSS 코드를 작성합니다. 특히, CSS Modules를 사용하기 위해서는 생성한 CSS 파일의 이름이 반드시 .module.css로 끝나야함을 유의합니다.
import styles from './layout.module.css'
export default function Layout({ children }) {
return <div className={styles.container}>{children}</div>
}
끝으로, Layout 컴포넌트에 CSS를 적용합니다. layout.module.css 파일을 임의의 이름에 임포트해 사용합니다. 여기서는 styles를 사용합니다. 그리고 Layout 내에서 className 속성을 사용해 styles.container를 적용합니다. 이 후, http://localhost:3000/posts/first-post 페이지에 들어가보면, CSS가 잘 적용된 것을 확인할 수 있습니다.
Unique class name의 자동 생성
CSS가 적용된 해당 페이지에서 개발자 도구를 열어 HTML 문서를 확인해보면, Layout 컴포넌트로 인해 렌더링된 다음과 같은 class name으로 새로운 <div>가 생성되어 있는 것을 볼 수 있습니다.
<div class="layout_container__2t4v2">
이는 CSS Module이 자동으로 생성한 고유한 class name입니다. 뒷 부분의 고유 문자열 덕분에 class name이 충돌할 여지는 없습니다.
또한, Next.js의 code splitting은 CSS Module에서도 적용되어, 현재 페이지가 로딩될 때 필요한 최소한의 CSS만 함께 로딩되게 됩니다.
Global CSS
만일 모든 페이지에서 항상 적용 및 로딩되는 CSS를 원한다면, pages/_app.js 파일을 생성하고 _app.js 파일 내부에서 해당 CSS 파일을 임포트하면 됩니다.
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />
}
먼저, pages/_app.js 파일을 생성하고 파일 내부에 위 컴포넌트를 작성합니다. App 컴포넌트는 가장 최상위 컴포넌트로서 모든 페이지에 영향을 줍니다. 특히, 페이지들 간의 이동이 있을 때, App 컴포넌트에 state을 저장해두면 유용합니다.
그리고 npm run dev로 서버를 다시 실행해줍니다. _app.js를 추가했을 때는 항상 서버를 다시 실행해줘야 변경사항이 저장됨을 유의합니다!
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
line-height: 1.6;
font-size: 18px;
}
* {
box-sizing: border-box;
}
a {
color: #0070f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
display: block;
}
그리고 최상위 디렉토리 밑에 styles 디렉토리를 하나 만들어, 위와 같이 원하는 CSS 코드를 styles/global.css로 파일을 생성해 저장합니다.
import '../styles/global.css'
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />
}
그리고 pages/_app.js에서 global.css를 임포트해주면, 페이지를 이동해도 global.css의 내용이 항상 적용되는 것을 확인할 수 있습니다.
여기서 주의할 점은 global.css는 항상 _app.js 내에서 임포트해줘야 한다는 것입니다. global.css는 항상 모든 페이지에 영향을 주어야 하기 때문입니다.
Reference
Next.js Document
-
TypeScript basic - Type
TypeScript
타입스크립트는 2012년 마이크로소프트가 발표한 기존 자바스크립트에 정적 타입 문법을 추가한 프로그래밍 언어입니다. 자바스크립트의 슈퍼셋(Superset)이기 때문에 타입스크립트 컴파일러 혹은 바벨(Babel)을 이용해 자바스크립트 코드로 변환되어 실행됩니다.
동적 타입의 인터프리터 언어인 자바스크립트와 달리, 타입스크립트는 정적 타입의 컴파일 언어이며 미리 타입을 결정하기 때문에 실행 속도가 매우 빠릅니다. 다만, 매 코드 작성시 타입을 설정하는 번거로움과 더불어, 늘어가는 코드량으로 인해 컴파일 속도는 오래걸린다는 단점이 함께 합니다.
그러나 타입스크립트의 가장 큰 장점은 코드 작성단계에서 타입을 체크해 에러를 사전에 방지할 수 있다는 점입니다. 또한, IDE의 코드 자동 완성을 지원하기 때문에 개발 생산성을 크게 향상시키는 이점도 있습니다.
타입 추론(Type inference)
타입 추론(Type inference)은 타입스크립트가 변수의 데이터 타입을 정할 때 처음 정의할 때 할당한 값을 분석하여 타입을 추론해 지정하는 방식입니다.
let order = 'first';
order = 1;
따라서 위와 같이 처음 order를 정의할 때 String 값으로 정의했다면, order에는 1과 같은 Number 타입의 값이 재할당될 수 없습니다.
"MY".toLowercase();
// Property 'toLowercase' does not exist on type '"MY"'.
// Did you mean 'toLowerCase'?
또한, 타입스크립트는 유추한 해당 타입의 shape 역시 확인하여 위와 같이 메서드 이름 오타로 인한 버그도 쉽게 잡아낼 수 있습니다.
Any
만일 변수에 값을 할당하지 않고 선언만 한다면, 해당 변수는 any 타입을 가집니다.
let onOrOff;
onOrOff = 1;
onOrOff = false;
위의 onOrOff는 선언만 되었기 때문에, any 타입을 가집니다. 이 경우, 변수의 값이 기존과 다른 타입의 값으로 재할당되어도 타입스크립트는 오류를 일으키지 않습니다.
Type annotation
변수에 값을 할당하지 않고 선언만 했을 때, 해당 변수가 any 타입이 아니라 특정 타입을 명확히 가지길 원할 수 있습니다. 이 때, type annotation을 사용합니다.
let mustBeAString : string;
mustBeAString = 'Catdog';
mustBeAString = 1337;
// Error: Type 'number' is not assignable to type 'string'
위와 같이, let mustBeAString : string;으로 String 타입을 명확히 지정해두면, String 이외 타입의 원치 않는 데이터 할당을 막을 수 있습니다.
Reference
Codecademy - TypeScript
타입스크립트 핸드북
활용도가 높아지는 웹 프론트엔드 언어, 타입스크립트(TypeScript)
-
Next.js basic - Pages
페이지 이동 구현 방법
Next.js를 사용해 여러 개의 페이지를 만들고 이동하는 방법을 소개합니다. Next.js는 code splitting, client-side navigation, prefetching 등을 통해, 자동으로 애플리케이션의 성능을 best performance로 최적화합니다.
페이지 만들기
먼저 새로운 페이지를 만들어봅시다. Next.js에는 pages 디렉토리가 존재합니다. 해당 디렉토리에 원하는 URL로 js 파일을 생성하면, 쉽게 새로운 페이지를 만들 수 있습니다. 예를 들어, pages/posts/first-post.js라는 경로로 새로운 페이지를 만들었다면, 해당 페이지의 URL은 /posts/first-post이 됩니다.
export default function FirstPost() {
return <h1>First Post</h1>
}
그리고 위와 같이 컴포넌트를 만들고 서버를 실행하면, http://localhost:3000/posts/first-post에 해당 페이지가 뜨게 됩니다. 이 때, 컴포넌트는 항상 default export가 되어야 함을 유의합니다.
이러한 방식은 HTML과 PHP를 사용하여 웹사이트를 구축하는 방식과 비슷하지만, HTML 대신에 JSX와 React component를 사용했다는 점이 다릅니다. 이제 남은 것은 홈페이지에 새로운 페이지로 가는 링크만 걸어주는 것입니다!
Link component
Next.js에서 페이지의 링크를 걸어주는 것은 <Link> 컴포넌트를 사용해서 수행합니다.
import Link from 'next/link'
이를 위해, 먼저 'next/link'로부터 Link 컴포넌트를 import합니다.
<h1 className="title">
Read{' '}
<Link href="/posts/first-post">
<a>this page!</a>
</Link>
</h1>
그리고 index.js에서 위와 같이 코드를 작성하면, 새로 만든 페이지의 URL /posts/first-post로 이동하는 링크를 만들 수 있습니다. 여기서 <Link>가 <a> 태그를 감쌌다는 점, href 속성은 <Link> 태그에 주었다는 점을 유의합니다.
참고로, {' '}은 multiple line text를 나누기 위해 사용됩니다.
import Link from 'next/link'
export default function FirstPost() {
return (
<>
<h1>First Post</h1>
<h2>
<Link href="/">
<a>Back to home</a>
</Link>
</h2>
</>
)
}
앞선 pages/posts/first-post.js에도 위와 같이 홈으로 돌아가는 링크를 만들면, 페이지끼리 서로 이동할 수 있게 됩니다.
Link and client-side navigation
<Link>의 사용은 client-side navigation을 가능하게 합니다. 즉, 페이지 전환이 클라이언트 측에서 자바스크립트를 이용해 일어나기 때문에, 페이지의 모든 부분을 서버에서부터 새로 가져와 로딩하는 브라우저 기본 navigation 방식보다 훨씬 빠르게 동작합니다.
만일 <Link>가 아닌 <a> 태그를 사용했다면, 브라우저는 해당 링크에 접근할 때마다 페이지 전체를 refresh할 것입니다.
Code splitting
Next.js에서는 code splitting이 자동적으로 일어나므로, 페이지의 로딩도 해당 페이지에 반드시 필요한 것들만 로딩됩니다. 예를 들어, 홈페이지가 렌더링될 때는 다른 페이지들은 로딩되지 않습니다. 특히, 애플리케이션에 수 많은 페이지가 있을 때, 유저는 자신이 요청한 페이지를 보다 빠르게 볼 수 있게 됩니다. 즉, 페이지들의 코드는 각각 분리되어 있고, 어떤 특정 페이지가 오류를 일으켜도 애플리케이션의 나머지 부분은 문제없이 동작합니다.
Prefetching
브라우저의 viewport(메뉴 바, 탭 영역을 제외한 브라우저의 순수 화면 영역)에 <Link> 컴포넌트가 있을 때마다, Next.js는 <Link>에 연결된 페이지들을 자동으로 미리 로딩해둡니다. 이를 prefetching이라고 하며, 이러한 페이지들은 유저가 링크를 누를 때 background에서 이미 로딩되어 있어서 매우 빠르게 페이지가 전환됩니다.
Reference
Next.js Document
-
React - Advanced tips
Programming patterns
리액트는 자주 사용되는 프로그래밍 패턴이 존재합니다.
Scene 1 - Stateful components to stateless components
Stateful component가 자신의 state setter 함수를 props로 child component에 전달하면, child component의 어떠한 event에 의해 해당 함수가 호출되어 parent component의 state를 변경합니다. 그리고 parent component는 변경된 state를 props로 또 다른 child component(=sibling component)에게 전달해 해당 child component에서 화면에 표시합니다.
Scene 2 - Separating container components from presentational components
State를 가지거나 calculation 등의 functional part를 담당하는 component는 container component로, 렌더링을 담당하는 component는 presentational component로 분리해야 합니다. 분리된 presentational component는 항상 container component에 의해서 렌더링되어야 합니다.
Style Name Syntax
일반적인 JavaScript에서 style의 name은 hyphenated-lowercase로 이루어져 있습니다.
const styles = {
'margin-top': '20px',
'background-color': 'green'
};
반면에, 리액트는 style name이 camelCase로 이루어져 있습니다.
const styles = {
marginTop: '20px',
backgroundColor: 'green'
};
Style Value Syntax
일반적인 JavaScript에서는 "450px", "20%" 처럼 숫자와 단위를 함께 적어 string 형태로 style value를 사용해야 합니다. 하지만, 리액트에서는 px에 한해서 생략이 가능하고, 이 경우 숫자도 string이 아닌 number 그대로 사용하는 것이 가능합니다. 물론 기존의 string 형태도 그대로 사용 가능합니다.
{ fontSize: 30 }
다만, 다른 단위를 사용하고 싶을 때는 기존의 string 형태로 사용합니다.
{ fontSize: "2em" }
propTypes
propTypes는 리액트에서 자주 사용되는 특징입니다. Prop이 전달될 것이 예상되는 component에 올바른 prop이 전달되었는지에 대한 validation을 도와주고, documentation을 통해 component의 상황을 한눈에 파악할 수 있도록 도와줍니다.
import PropTypes from 'prop-types';
propTypes를 사용하기 위해선 'prop-types' 라이브러리를 import해야 합니다.
import React from 'react';
import PropTypes from 'prop-types';
export class MessageDisplayer extends React.Component {
render() {
return <h1>{this.props.message}</h1>;
}
}
// This propTypes object should have
// one property for each expected prop:
MessageDisplayer.propTypes = {
message: PropTypes.string
};
그리고 미리 정의된 component에 위와 같이 property를 추가하는 방식으로 propTypes를 정의할 수 있습니다. 이 때, propTypes의 value는 object 형태여야 함을 유의합니다. 그리고 해당 object의 각각의 property는 component에 전달될 것이 기대되는 prop의 이름으로 설정합니다.
Runner.propTypes = {
message: PropTypes.string.isRequired,
style: PropTypes.object.isRequired,
isMetric: PropTypes.bool.isRequired,
miles: PropTypes.number.isRequired,
milesToKM: PropTypes.func.isRequired,
races: PropTypes.array.isRequired
};
PropTypes를 통해 설정할 수 있는 data type의 이름은 위와 같습니다. isRequired의 경우, prop이 잘 전달되는지 확인해서 만일 잘 전달되지 않으면 console에 warning을 띄어주는 역할을 합니다.
const Example = (props) => {
return <h1>{props.message}</h1>;
}
Example.propTypes = {
message: PropTypes.string.isRequired
};
만일 function component에 propTypes를 추가하고 싶다면, 위와 같이 function component 자체의 property로 propTypes를 지정합니다.
React forms
import React from 'react';
import ReactDOM from 'react-dom';
export class Input extends React.Component {
constructor(props) {
super(props);
this.state = { userInput: '' };
this.handleUserInput = this.handleUserInput.bind(this);
}
handleUserInput(e) {
this.setState({userInput: e.target.value});
}
render() {
return (
<div>
<input type="text" value={this.state.userInput} onChange={this.handleUserInput} />
<h1>{this.state.userInput}</h1>
</div>
);
}
}
ReactDOM.render(
<Input />,
document.getElementById('app')
);
일반적인 form은 유저가 input field에 계속 타이핑하더라도 submit 버튼을 누르기전까지는 서버에서 그 사실을 알지 못합니다. 즉, submit 이전까지 프론트가 알고 있는 input 정보와 서버가 알고 있는 input 정보 사이에 불일치가 존재합니다.
그러나 이러한 불일치는 웹사이트의 third part에서 해당 정보를 필요로 할 때, 프론트냐 서버냐에 따라 다른 결과를 내어 문제가 발생할 수 있습니다. 이를 해결하기 위해, 리액트 form은 모든 new character와 deletion에 대한 프론트 및 서버의 동기화를 지원하여 application의 모든 요소가 일관성 있게 동작하도록 합니다. 특히, 일반적인 <form> tag를 굳이 사용하지 않고 위 코드처럼 <input> tag만으로 이를 구현할 수 있습니다.
Uncontrolled vs Controlled component
Uncontrolled component란 스스로 state를 가지고 그 값을 기억하는 component를 말합니다. 반면에 controlled component는 스스로 state를 가지지 않고 다른 component에 의해 관리되어지는 component를 말합니다.
리액트에는 주로 controlled component가 많고 이러한 component는 스스로에 대한 정보를 props를 통해 얻게 됩니다.
Reference
Learn React - Codecademy
-
React - Hook
Functional components
지금까지 JavaScript의 클래스를 사용해서 정의한 리액트의 component들은 함수를 사용해서 정의할 수도 있습니다. 이를 function component라고 합니다. Function component는 간단하고 직관적이라는 장점이 있습니다.
// A component class written in the usual way:
class MyComponentClass extends React.Component {
render() {
return <h1>Hello world</h1>;
}
}
// The same component class, written as a stateless functional component:
const MyComponentClass = () => {
return <h1>Hello world</h1>;
}
// Works the same either way:
ReactDOM.render(
<MyComponentClass />,
document.getElementById('app')
);
Function component는 위와 같이 함수 형태로 작성하며, render() 메서드를 사용하지 않고 JSX expression을 바로 리턴하는 방식으로 작성합니다.
Function component는 props 역시 전달받을 수 있습니다.
function WelshCorgi (props) {
return (
<div>
<p>{props.prompt}</p>
</div>
);
}
ReactDOM.render(
<WelshCorgi feed="High quality dog feed" />,
document.getElementById('app');
);
props는 parameter로 정의해 전달받고, props.propertyName 형식으로 접근합니다.
Hook
Hook은 function component에서 component의 state와 이후의 렌더링 관련 side effects를 관리하도록 도와주는 함수들입니다. 클래스에서는 작동되지 않지만, function component에서 lifecycle적인 특징들도 관리할 수 있도록 도와줍니다.
State hook - useState
import React, { useState } from "react";
function Toggle() {
const [toggle, setToggle] = useState('off');
return (
<div>
<p>The toggle is {toggle}</p>
<button onClick={() => setToggle("On")}>On</button>
<button onClick={() => setToggle("Off")}>Off</button>
</div>
);
}
useState는 리액트 라이브러리에서 제공하는 JavaScript 함수로, 호출 시 두 가지 value가 담긴 array를 리턴합니다.
current state - the current value of this state
state setter - a function that we can use to update the value of this state
State에 대한 초깃값은 useState에 인자로 넣어진 값으로 설정할 수 있습니다. 초깃값이 중요하지 않은 경우, 인자를 넣지 않고 초깃값을 undefined 상태로 두어도 상관없으나 null 값이라도 넘겨주는 것이 가독성을 높이는 방법이 될 수 있습니다.
useState를 사용해서 임의의 value를 인자로 state setter 함수를 호출하면, 현재 state를 새로운 state로 update할 수 있습니다. 특히 state setter 함수가 호출되면 리액트는 자동으로 해당 component를 다시 렌더링하므로 변경한 새로운 state value가 바로 반영됩니다.
import React, { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(prevCount => prevCount + 1);
return (
<div>
<p>Wow, you've clicked that button: {count} times</p>
<button onClick={increment}>Click here!</button>
</div>
);
}
만일 기존의 state를 활용해 계산한 값으로 state를 update하고 싶다면, state setter 함수에 콜백 함수를 인자로 전달하면 됩니다. 위와 같이 기존 state count를 활용해 prevCount + 1 값으로 state를 update하고 싶다면, setCount(prevCount => prevCount + 1)처럼 콜백 함수를 state setter 함수의 인자로 넣어줍니다. 특정한 상황에서는 setCount(count +1) 같이 바로 값을 update할 수도 있지만, 콜백 함수를 사용하는 방법이 모든 상황에서 더 안전하다는 점을 유의합니다.
import React, { useState } from "react";
const options = ["Bell Pepper", "Sausage", "Pepperoni", "Pineapple"];
export default function PersonalPizza() {
const [selected, setSelected] = useState([]);
const toggleTopping = ({target}) => {
const clickedTopping = target.value;
setSelected((prev) => {
// check if clicked topping is already selected
if (prev.includes(clickedTopping)) {
// filter the clicked topping out of state
return prev.filter(t => t !== clickedTopping);
} else {
// add the clicked topping to our state
return [clickedTopping, ...prev];
}
});
};
return (
<div>
{options.map(option => (
<button value={option} onClick={toggleTopping} key={option}>
{selected.includes(option) ? "Remove " : "Add "}
{option}
</button>
))}
<p>Order a {selected.join(", ")} pizza</p>
</div>
);
}
만일 state의 값이 Array 타입인 경우, state를 update할 때 이전 state의 Array를 그대로 변경하지말고 새로운 Array로 변경 내역을 copy해서 state에 할당해야 함을 유의합니다. 위에서도 return prev.filter(t => t !== clickedTopping); 혹은 return [clickedTopping, ...prev];으로 새로운 Array를 만들어 리턴합니다.
export default function Login() {
const [formState, setFormState] = useState({});
const handleChange = ({ target }) => {
const { name, value } = target;
setFormState((prev) => ({
...prev,
[name]: value
}));
};
return (
<form>
<input
value={formState.firstName}
onChange={handleChange}
name="firstName"
type="text"
/>
<input
value={formState.password}
onChange={handleChange}
type="password"
name="password"
/>
</form>
);
}
State의 타입이 Object인 경우에도 update할 state 값은 변경된 내역을 새로 copy한 Object가 되어야 합니다. 또 Object를 arrow function에서 return할 때는 {}가 겹치는 문제가 발생할 수 있기 때문에, 반환할 Object를 ()로 감싸줄 필요가 있습니다.
Separate Hooks for Separate States
function Subject() {
const [state, setState] = useState({
currentGrade: 'B',
classmates: ['Hasan', 'Sam', 'Emma'],
classDetails: {topic: 'Math', teacher: 'Ms. Barry', room: 201};
exams: [{unit: 1, score: 91}, {unit: 2, score: 88}]);
});
State와 같은 dynamic data를 다루기 위해서는 state 변수마다 각각 hook을 지정해 관리하는 것이 편합니다. 위와 같이 하나의 복잡한 Object를 state로 하여 하나의 hook으로 관리한다면, 복잡한 state들을 각각 copy할 때 매우 불편해집니다.
function Subject() {
const [currentGrade, setGrade] = useState('B');
const [classmates, setClassmates] = useState(['Hasan', 'Sam', 'Emma']);
const [classDetails, setClassDetails] = useState({topic: 'Math', teacher: 'Ms. Barry', room: 201});
const [exams, setExams] = useState([{unit: 1, score: 91}, {unit: 2, score: 88}]);
// ...
}
따라서, 위와 같이 state 변수마다 hook을 만들어 관리한다면 훨씬 간단하고 쉽게 state를 관리할 수 있습니다.
Effect hook
Effect hook은 렌더링 이후의 side effects를 관리하는 함수입니다. fetch API를 통해 백엔드로부터 데이터를 받아오거나 DOM을 읽고 변화를 주는 등의 side effect를 발생시키는 작업들을 관리하며, 보통 다음 3가지 상황에서 사용합니다.
Component가 DOM에 mount되어 렌더링될 때
State 혹은 props가 변화하여 component가 다시 렌더링 될 때
Component가 DOM에서 unmount되어 렌더링될 때
Effect hook - useEffect
import React, { useState, useEffect } from 'react';
function PageTitle() {
const [name, setName] = useState('');
useEffect(() => {
document.title = `Hi, ${name}`;
});
return (
<div>
<p>Use the input field below to rename this page!</p>
<input onChange={({target}) => setName(target.value)} value={name} type='text' />
</div>
);
}
useEffect는 component를 렌더링할 때마다 다른 함수를 호출하기 위해 사용합니다. 이로 인해, useEffect는 첫 번째 인자로 렌더링 후 호출할 목적의 콜백 함수를 받습니다. 그리고 이러한 콜백 함수를 effect라고도 부릅니다. 예를 들어, 위 코드에서는 () => { document.title = name; }가 effect입니다.
Effect는 현재 state에도 접근할 수 있습니다. 다만 component 렌더링이 일어난 다음 DOM이 update되면 그 후 effect가 호출되므로, state도 update가 완료된 상태에서 접근하게 됩니다.
Clean Up Effects
어떠한 effect들은 메모리 누수를 피하기 위하여 항상 제거하는 작업을 동반해주어야 합니다. 예를 들어, effect를 사용해 직접 DOM 내의 element에 event listener를 추가하는 경우, 원하는 작업이 끝나면 해당 event listener를 반드시 다시 제거해주어야 합니다. 그렇지 않으면 렌더링될 때마다 호출되는 effect hook의 특성으로 인해, 이후 발생하는 수많은 렌더링 상황마다 event listener가 의도치 않게 끊임없이 추가되어 메모리가 터지는 상황이 생길 수 있습니다. 따라서 다음과 같이 useEffect의 effect 내에서 event listener를 제거하는 함수를 반환하여, 추가했던 event listener를 제거해줍니다.
useEffect(()=>{
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
})
Effect가 반환하는 함수는 useEffect가 항상 clean up 함수로 간주하므로, 리액트는 effect 작업이 끝나면 자동적으로 이를 호출합니다.
Dependency array
Effect는 기본적으로 매 렌더링이 일어나는 상황마다 호출됩니다. 그러나 dependency array를 사용하면, effect를 원하는 때에만 호출하도록 설정할 수 있습니다. Dependency array는 useEffect의 두 번째 인자로 넣는 array를 말합니다.
만일 component가 mount되어 첫 번째 렌더링을 할 때만 effect hook을 호출하고 최종 렌더링에서 clean up하고 싶다면, 빈 array []를 useEffect()의 두 번째 인자로 넣어줍니다.
반면에, dependency array에 특정 변수를 요소로 넣는다면, 해당 변수의 값이 변할 때만 effect가 호출됩니다.
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if the value stored by count changes
Hook을 사용하는 규칙
더욱 복잡한 React 앱에서 혼란을 피하기 위해, hook은 다음과 같은 규칙을 지키며 사용합시다.
Hook을 항상 top level에서만 사용합시다.
리액트는 function component 내에서 정의한 순서에 따라 hook과 함께 관리되는 data와 function들을 인식합니다. 따라서, conditions, loops, nested functions 안에서 hook을 사용하지 말아야 합니다.
if (userName !== '') {
useEffect(() => {
localStorage.setItem('savedUserName', userName);
});
}
조건문을 쓰고 싶다면 위와 같이 쓰지 말고, 다음과 같이 effect 내에서 사용해 동일한 결과를 얻을 수 있습니다.
useEffect(() => {
if (userName !== '') {
localStorage.setItem('savedUserName', userName);
}
});
Hook은 react function component 내에서만 사용합시다.
Function component이외에 hook을 사용할 수 있는 곳은 custom hook을 제외하고 존재하지 않습니다. Class component나 일반적인 JavaScript 함수 내에서 hook을 사용하지 맙시다.
Separate Hooks for Separate States
// Handle menuItems with one useEffect hook.
const [menuItems, setMenuItems] = useState(null);
useEffect(() => {
get('/menu').then((response) => setMenuItems(response.data));
}, []);
// Handle position with a separate useEffect hook.
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (event) =>
setPosition({ x: event.clientX, y: event.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
Effect hook 역시 모든 로직을 한 곳에 모아두면 가독성이 떨어지고 복잡해집니다. 따라서 위와 같이 effect 마다 따로 hook을 만드는 것을 지향합니다.
Reference
Learn React - Codecademy
-
React - Component Lifecycle Methods
Component lifecycle methods
리액트의 수많은 component들은 각각 자신의 lifecycle을 가집니다. 보통 component의 lifecycle 다음과 같이 구성됩니다.
Mounting, when the component is being initialized and put into the DOM for the first time
Updating, when the component updates as a result of changed state or changed props
Unmounting, when the component is being removed from the DOM
그리고 이러한 lifecycle 각각을 제어하기 위해 개발자들이 사용할 수 있는 lifecycle method들이 존재합니다. 대표적으로 constructor()와 render() 역시 lifecycle method에 해당됩니다! constructor()는 mounting phase에 첫 번째로 호출되는 메서드로, render()는 mounting과 updating phase에 자주 등장하는 메서드로 분류할 수 있습니다.
componentDidMount()
componentDidMount() 메서드는 mounting phase에서 마지막으로 호출되는 메서드입니다. Mounting phase 안에서 메서드들은 다음과 같은 순서로 호출됩니다.
The constructor
render()
componentDidMount()
componentDidMount()를 활용하면 1초씩 현 시각을 계속 알려주는 시계를 만들 수 있습니다.
import React from 'react';
import ReactDOM from 'react-dom';
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = { date: new Date() };
}
render() {
return <div>{this.state.date.toLocaleTimeString()}</div>;
}
componentDidMount() {
// Paste your code here.
const oneSecond = 1000;
setInterval(() => {
this.setState({ date: new Date() });
}, oneSecond);
}
}
ReactDOM.render(<Clock />, document.getElementById('app'));
componentWillUnmount
import React from 'react';
export class Clock extends React.Component {
constructor(props) {
super(props);
this.state = { date: new Date() };
}
render() {
return <div>{this.state.date.toLocaleTimeString()}</div>;
}
componentDidMount() {
const oneSecond = 1000;
this.intervalID = setInterval(() => {
this.setState({ date: new Date() });
}, oneSecond);
}
componentWillUnmount() {
clearInterval(this.intervalID);
}
}
componentWillUnmount 메서드는 unmounting phase에서 사용됩니다. Component가 완전히 없어지기 전에 호출되기 때문에, side-effect를 발생시키는 불필요한 비동기 함수를 종료하기 적합한 시기입니다. 위와 같이 시간을 지속적으로 업데이트하는 시계의 setInterval() 함수를 멈추려면, componentWillUnmount() 메서드에서 clearInterval()을 사용합니다. intervalID를 clearInterval()의 인자로 전달해주면 해당 setInterval() 함수를 종료시킵니다.
componentDidUpdate
Updating phase에서 주로 사용하는 메서드는 render(), componentDidUpdate입니다. Update는 props와 state의 변화가 일어날 때 발생하는 작업으로, update 관련한 로직은 componentDidUpdate에서 사용하는 것이 유용합니다.
Reference
Learn React - Codecademy
this interactive diagram
-
React - Component Interacting
Component interacting
React application은 몇 십에서 몇 백 개까지 component를 가질 수 있습니다. 각각의 작은 component들은 자신의 역할을 담당하면서 거대한 app을 구성하고 서로 상호작용함으로써 app을 동작시킵니다.
Component 간 상호 작용 유형
Component가 다른 component를 렌더링하는 것
Component가 다른 component에게 정보를 전달하는 것
A component in a render function
class WelshCorgi extends React.Component {
render() {
return <h1>Welsh Corgi wooooow!</h1>;
}
}
class Dog extends React.Component {
render() {
return <WelshCorgi />;
}
}
Component 클래스의 render() 메서드는 HTML-like JSX expression 뿐만 아니라 component instance 형태의 JSX expression도 리턴할 수 있습니다.
import React from 'react';
import ReactDOM from 'react-dom';
class WelshCorgi extends React.Component {
render() {
return <h1>Welsh Corgi wooooow!</h1>;
}
}
class ProfilePage extends React.Component {
render() {
return (
<div>
<h1>All About Me!</h1>
<p>I like Welsh Corgi!!!</p>
<WelshCorgi />
</div>
);
}
}
이러한 속성을 활용하면 하나의 component 안에서 다른 여러 개의 component를 함께 렌더링할 수 있습니다. 위의 <WelshCorgi /> component 인스턴스는 ProfilePage component가 생성되면 그 안에서 렌더링됩니다. 따라서, ReactDOM.render()를 통해 ProfilePage 인스턴스 하나만 렌더링하면 내부에 있는 component들은 자동으로 함께 렌더링되게 됩니다.
Component 안에 다른 component가 포함되어 렌더링될 수 있다는 특징은 리액트의 강력한 장점입니다!
Props
부모 component가 자식 component에가 전달하는 정보가 담긴 객체를 props라고 합니다. 모든 component들은 자신의 props를 가지고 있으며, 이를 통해 부모 component로부터 전달받은 정보를 확인할 수 있습니다.
특정 component의 props를 보고 싶다면, this.props를 사용해 확인할 수 있습니다.
<Greeting name="Frarthur" town="Flundon" age={2} haunted={false} myInfo={["top", "secret", "lol"]} />
Component에 prop을 추가하고 싶다면, 생성한 인스턴스에 속성으로 추가해주면 됩니다. 위의 코드는 <Greeting /> component 인스턴스에 name, town, age, haunted, myInfo 등의 props를 부여한 것입니다.
이 때 만일 string이 아닌 정보를 주고 싶다면, {}로 정보를 감싸서 속성을 부여해야 한다는 점을 유의합시다.
이렇게 추가한 속성들은 this.props.속성이름을 통해 접근할 수 있습니다.
Event handler as prop
함수 역시 props로 넘길 수 있는데, 보통 event handler 함수가 이러한 특징을 활용해 prop으로 자주 전달됩니다.
import React from 'react';
import ReactDOM from 'react-dom';
class Button extends React.Component {
render() {
return (
<button onClick={this.props.talk}>
Click me!
</button>
);
}
}
class Talker extends React.Component {
talk() {
let speech = '';
for (let i = 0; i < 10000; i++) {
speech += 'blah ';
}
alert(speech);
}
render() {
return <Button talk={this.talk} />;
}
}
ReactDOM.render(
<Talker />,
document.getElementById('app')
);
Event handler는 render() 메서드와 비슷한 방식으로 임의의 이름의 메서드를 정의하고 필요한 component에 prop으로서 전달합니다.
Naming convention of event handler
Event handler를 prop으로 전달할 때, 임의로 naming해야 할 부분이 두 군데 생깁니다. 이 때, 반드시 따를 필요는 없지만 통용되는 naming convention이 존재합니다.
첫 번째는 event handler 메서드를 정의할 때인데, 이벤트의 타입에 따라 handleClick, handleHover 등으로 사용합니다.
두 번째는 prop name인데, 이벤트 타입에 따라 onClick, onHover 등으로 정의합니다.
class MyClass extends React.Component {
handleHover() {
alert('I am an event handler.');
alert('I will listen for a "hover" event.');
}
render() {
return <Child onHover={this.handleHover} />;
}
}
this.props.children
모든 component들은 props 객체 내에 children property를 가집니다. 앞서 self-closing tag로 만들었던 component들은 사실 <MyComponentClass></MyComponentClass>로 나뉘어 쓰이는 것 역시 가능합니다. 이 경우, this.props.children은 나뉘어 쓰이는 태그 사이에 존재하는 모든 것을 리턴합니다.
import React from 'react';
import ReactDOM from 'react-dom';
class List extends React.Component {
render() {
let titleText = `Favorite ${this.props.type}`;
if (this.props.children instanceof Array) {
titleText += 's';
}
return (
<div>
<h1>{titleText}</h1>
<ul>{this.props.children}</ul>
</div>
);
}
}
class App extends React.Component {
render() {
return (
<div>
<List type='Dog'>
<li>Welsh Corgi</li>
<li>Dachshund</li>
</List>
<List type='Cat'>
<li>Road cat</li>
</List>
</div>
);
}
}
ReactDOM.render(
<App />,
document.getElementById('app')
);
위 코드의 경우, this.props.children은 각각의 List component 사이에 있는 모든 <li> element들을 리턴합니다.
<List type='Dog'>의 경우
// return elements
<li>Welsh Corgi</li>
<li>Dachshund</li>
<List type='Cat'>의 경우
// return element
<li>Road cat</li>
defaultProps
class Example extends React.Component {
render() {
return <h1>{this.props.text}</h1>;
}
}
Example.defaultProps = { text: 'Welsh Corgi' };
만일 component 인스턴스에 요구되어지는 prop이 전달되지 않았을 때, 해당 prop은 화면에 아무것도 출력하지 않을 것입니다. 이 때, default 값을 prop에 지정해 화면에 항상 무언가가 출력될 수 있게 할 수 있습니다.
이를 위해 component 클래스의 defaultProps property를 사용합니다. defaultProps property에 원하는 props의 기본값들을 설정한 object를 지정하여 기본값 설정을 완료합니다.
state
축구 경기에서 각 팀의 스코어 정보처럼 변할 수 있는 정보를 dynamic information이라고 합니다. 리액트 component는 이러한 dynamic information을 다뤄야 할 때, props와 state를 사용합니다. 그 중, state란 각각의 component가 가지고 있는 상태를 저장한 것을 뜻하며, component 내부에서 관리됩니다. 렌더링 결과물에 영향을 주는 정보를 갖고 있다는 부분에서도 props와 공통점이 있습니다.
class Example extends React.Component {
constructor(props) {
super(props);
this.state = { mood: 'decent' };
}
render() {
return <div></div>;
}
}
<Example />
초기의 state는 props와 달리 component 클래스의 constructor에서 state property를 지정해 설정합니다. 또한, 각각의 component들은 스스로의 state를 가집니다.
모든 component는 super()를 통해 항상 초기화 시켜야 합니다. 이후, state에 적절한 객체를 할당해 initial state를 설정합니다.
class TodayImFeeling extends React.Component {
constructor(props) {
super(props);
this.state = { mood: 'decent' };
}
render() {
return (
<h1>
I'm feeling {this.state.mood}!
</h1>
);
}
}
Component 클래스 내에서 state에 접근하고 싶다면 this.state.name-of-property 형태로 접근합니다. 위의 this.state.mood는 ‘decent’ 값에 접근합니다.
this.setState()
Component의 현재 state를 바꾸고 싶다면, this.setState() 메서드를 사용합니다. setState()는 변경 요소가 담긴 객체를 첫 번째 인자로 받아 사용합니다.
{
mood: 'great',
hungry: false
}
현재 state의 상황이 위와 같다고 가정해봅시다.
this.setState({ hungry: true });
그리고 setState()를 사용해 hungry 상태를 변경합니다.
{
mood: 'great',
hungry: true
}
그 결과 위와 같이 hungry 상태만 true로 변경되었습니다. setState는 기본적으로 인자로 받은 객체에 담긴 요소들만 접근해 값을 변경하고 다른 원래의 요소들은 그대로 둡니다.
setState()와 render()
setState() 메서드에서 유의할 점은 이 메서드가 state를 변경한 후 자동적으로 .render() 메서드까지 호출한다는 부분입니다. 즉, setState()를 사용하면 state를 변경한 부분이 바로 화면에 반영됩니다. 따라서, setState()는 render() 메서드 안에서 호출되면 안됩니다. 이를 지키지 않으면 서로 끊임없이 호출하는 무한 루프에 빠지게 됩니다.
Reference
Learn React - Codecademy
-
React - Component
Component of React
Component란 하나의 작업을 수행하는 재사용할 수 있는 작은 코드 뭉치를 의미합니다. 여기서 하나의 작업이란 대체로 HTML 코드를 렌더링하는 것을 말합니다.
Necessary import
Component를 사용하기 위해서는 React 객체를 import 해두어야 합니다. React 객체에는 리액트 라이브러리를 사용하기 위한 필수적인 메서드들이 담겨있습니다. JSX expression을 사용하는데도 React 객체가 반드시 필요하므로, 첫 줄은 항상 다음 코드로 시작하도록 합니다!
import React from 'react';
또한, component 사용을 위해 ReactDOM 객체도 import합니다. ReactDOM 객체는 React 객체와 마찬가지로 React와 관련된 메서드들을 가지고 있습니다. 그러나 React에는 순수하게 React만을 위한 메서드가 담겨있는 반면, ReactDOM은 React와 DOM의 상호작용을 돕는 메서드들이 담겨 있다는 차이점이 있습니다. 따라서, 다음 코드 역시 함께 사용합니다.
import ReactDOM from 'react-dom';
클래스를 활용한 Component 생성
리액트 component는 자바스크립트의 클래스 혹은 함수를 통해 생성할 수 있습니다. 여기서는 클래스 component에 초점을 맞추겠습니다.
클래스 component는 리액트 라이브러리의 Component 클래스를 상속받아서 정의합니다. 클래스를 사용하면 원하는 만큼 인스턴스로 component를 만들어 렌더링할 수 있다는 이점이 생깁니다.
import React from 'react';
import ReactDOM from 'react-dom';
class MyComponentClass extends React.Component {
render() {
return <h1>Hello component</h1>;
}
}
ReactDOM.render(
<MyComponentClass />,
document.getElementById('app')
);
위와 같이 React.Component를 상속받으면 새로운 component 클래스를 만들어 customizing할 수 있습니다. 여기서 React.Component는 React 객체의 property이며, Component는 클래스입니다.
여기서 또 하나 유의할 점은 새로 정의한 component 클래스 body에는 반드시 render() 메서드를, render() 메서드 내에는 주로 JSX expression을 반환하는 return statement를 정의해야 한다는 부분입니다. 해당 클래스에는 어떤 component를 만들 것인지 instruction을 제시해줘야 하기 때문에, 이를 위한 render() 메서드와 return statement를 필수적으로 정의합니다.
그리고 이렇게 만들어진 클래스를 활용해 component를 자유롭게 생성할 수 있습니다. 앞서 JSX element를 사용했듯이, 클래스의 이름을 사용해 <MyComponentClass /> 코드를 쓰면 component 인스턴스가 생성됩니다!
이렇게 생성한 component 인스턴스를 ReactDOM.render()에 인자로 던져주면, 해당 component를 화면에 렌더링할 수 있습니다. Component는 클래스에서 정의한 render() 메서드를 가지고 있기 때문에, ReactDOM.render()는 인자로 받은 component의 render() 메서드를 자동으로 호출하게끔 하여 JSX expression을 반환받고 화면에 렌더링합니다.
Class component의 naming convention
새로 정의한 클래스 component의 이름은 첫 글자부터 대문자를 사용하는 UpperCamelCase를 따릅니다. 이것은 Java의 naming convention에서 차용되었으며, 원래의 JavaScript 클래스를 만들 때도 마찬가지의 convention을 따릅니다.
UpperCamelCase를 사용하는 또 다른 이유는 리액트 자체적으로도 찾을 수 있습니다. JSX element는 HTML-like인 경우와 component인 경우로 나뉩니다. 이 때, UpperCamelCase로 쓰인 JSX element가 있다면, 해당 element가 component instance임을 쉽게 파악할 수 있습니다.
ex) ShinyBrownHairOfWelshCorgi
ex) <WelshCorgiLegComponent />
render() 메서드에 정의할 수 있는 것
class Random extends React.Component {
render() {
// First, some logic that must happen
// before rendering:
const n = Math.floor(Math.random() * 10 + 1);
// Next, a return statement
// using that logic:
return <h1>The number of Welsh Corgi is {n}!</h1>;
}
}
Component의 render() 메서드에는 항상 return statement가 와야 합니다. 다만 이에 더하여, 렌더링 직전의 간단한 계산 역시 둘 수 있는 위치입니다.
class Random extends React.Component {
// This should be in the render function:
const n = Math.floor(Math.random() * 10 + 1);
render() {
return <h1>The number of Welsh Corgi is {n}!</h1>;
}
};
그러나 위와 같이 render() 메서드 바깥에 변수를 정의하는 것은 syntax error를 유발하니, 메서드 안쪽에서 정의할 것을 유의해야 합니다.
Event listener in a component
class MyClass extends React.Component {
myFunc() {
alert('Stop it. Stop hovering my Welsh Corgi.');
}
render() {
return (
<div onHover={this.myFunc}>
</div>
);
}
}
위와 같이 component 클래스의 메서드로 정의한 event handler 함수를 사용하여, event listener를 component에 정의할 수 있습니다. Event listener 속성에 this를 사용해 메서드를 부여하는 것으로 적용 가능합니다.
Reference
Learn React - Codecademy
-
React - JSX
React basic
React.js는 Facebook 엔지니어들이 개발한 UI 개발 목적의 JavaScript 라이브러리입니다. 리액트의 컴포넌트 기반 개발은 Single Page Application을 비롯한 프론트 개발에 큰 변화를 이끌었으며, 근 5~6년간 자바스크립트 생태계의 가장 중요한 존재 중 하나로 자리해 왔습니다. 최근에는 더 효율적인 프론트 개발 라이브러리들이 많이 등장했지만, 리액트의 영향력은 여전히 직간접적으로 느껴집니다.
JSX
const h1 = <h1>Welsh Corgi!!</h1>;
JSX는 리액트에 사용되기 위해 쓰여진 JavaScript의 syntax extension입니다. 보통 JavaScript 파일 속에 JavaScript 코드와 HTML 코드들이 혼용되어 쓰여진 것들로 통용되므로, JSX 코드에는 HTML같은 코드가 포함되지만 실제로 HTML은 아닙니다.
특히, JSX는 웹 브라우저가 바로 읽을 수 없습니다. 그러므로 JSX가 포함된 JavaScript 파일을 통상적으로 사용하려면, JSX compiler를 통해 일반적인 JavaScript 코드로 컴파일해야 합니다.
JSX element
<h1>Hello world</h1>
또한, JavaScript 파일 속에 HTML과 똑같이 생긴 위와 같은 코드들을 JSX element라고 부릅니다. JSX element는 JavaScript 코드로 간주되어, 변수에 저장되거나 함수의 인자로 입력되는 등 일반적인 모든 프로그래밍에 문제 없이 사용됩니다.
const welshCorgi = <img src='images/welsh.jpg' alt='welsh corgi' width='600px' height='600px' />;
JSX element에는 HTML 때와 마찬가지로 attribute 역시 적용할 수 있습니다.
const welshCorgi = (
<a href="https://www.shinybrownhair.com">
<h1>
Bow wow!
</h1>
</a>
);
Nested한 형태도 기존 HTML처럼 사용할 수 있습니다. 다만, multi-line이 될 경우 ()로 감싸주어야 오류 없이 프로그래밍할 수 있음을 유의합시다.
const dogs = (
<p>I am a Poodle.</p>
<p>I am a Welsh Corgi. Nice to meet you!</p>
);
다만, JSX expression은 하나의 같은 element 단위가 되어야 하기 때문에, 위와 같이 두 개의 element를 한 번에 사용하는 것은 불가능합니다. 만일 위와 같이 쓰고 싶다면, 위 코드를 하나의 <div></div> 태그로 감싸서 코드가 올바르게 동작하도록 만드는 방법을 권장합니다.
Rendering
렌더링(Rendering)이란 코드를 해석해서 화면에 띄우는 작업을 의미합니다. 렌더링은 보통 리액트와 관련된 메서드들을 모아둔 ReactDom 라이브러리의 ReactDOM.render() 메서드를 사용해 진행합니다.
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(<h1>Hello world</h1>, document.getElementById('app'));
ReactDOM.render() 메서드에는 첫 번째 인자로 화면에 띄울 JSX expression을 사용합니다. 그리고 두 번째 인자로 해당 JSX expression을 띄울 container가 될 HTML 태그를 찾아 넘깁니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/styles.css">
<title>Learn ReactJS</title>
</head>
<body>
<main id="app"></main>
</body>
</html>
예를 들어 위와 같은 index.html 문서가 있다면, <main id="app"></main> 태그 속에 첫 번째 인자로 넘긴 JSX expression이 위치해 화면에 렌더링됩니다.
Virtual DOM
const dog = <h1>Welsh Corgi</h1>;
// This will add "Welsh Corgi" to the screen:
ReactDOM.render(dog, document.getElementById('app'));
// This won't do anything at all:
ReactDOM.render(dog, document.getElementById('app'));
ReactDOM.render()의 장점은 변경이 있는 DOM elements만 update한다는 점입니다. 수많은 DOM elements가 있을 때, 변경된 것들만 update하는 것은 React의 큰 이점입니다. React는 virtual DOM을 통해 이를 실현합니다.
Virtual DOM이란 리액트에서 실제 DOM object와 대응되는 가벼운 카피 버전의 가상 DOM object를 말합니다. Virtual DOM은 실제 DOM과 같은 property들을 가지지만, DOM의 변화를 화면에 직접 띄우는 기능은 없기 때문에, 일반 DOM 조작보다 빠르다는 장점이 있습니다.
따라서, 리액트는 다음과 같은 방식으로 DOM을 update합니다.
전체 virtual DOM을 업데이트합니다.
Update한 virtual DOM과 이전 virtual DOM의 snapshot을 비교하여 변화된 부분들을 확인합니다.
변화된 부분만 실제 DOM object에서 update합니다.
실제 DOM의 변화가 화면에 반영됩니다.
DOM manipulation의 단점
과거 일반적인 자바스크립트 라이브러리들은 DOM manipulation을 할 때, DOM element 하나가 변경되면 모든 element들을 다시 update해야 해서 비효율적이었습니다. 덕분에 DOM이 커질수록 cost가 더욱 늘어났는데, 리액트의 virtual DOM 도입은 cost 문제를 혁신적으로 해결했습니다. 변경된 특정 DOM element만 update하는 virtual DOM의 특징이 DOM manipulation 속도를 혁신적으로 향상 됐습니다.
Advanced syntax of JSX
JSX의 문법은 대게 HTML과 동일하지만 미묘하게 다른 부분들이 존재하므로 유의해야 합니다.
className
<h1 className="dog">Welsh Corgi</h1>
HTML에서 사용되는 class 속성은 JSX에서 className으로 사용합니다. 이는 JavaScript가 class를 예약어로 갖고 있어서 JSX를 JavaScript로 변압할 때 키워드가 겹치는 문제가 발생하기 때문입니다. 대신 className은 JSX가 렌더링될 때, class 속성으로서 자동으로 인식됩니다.
self-closing tag
Fine in HTML with a slash:
<br />
Also fine, without the slash:
<br>
HTML에서는 <img> 태그나 <input> 태그 같은 요소들의 끝 부분 > 앞에 /를 쓰는 것이 선택적입니다.
Fine in JSX:
<br />
NOT FINE AT ALL in JSX:
<br>
하지만, JSX에서는 self-closing tag에 /를 반드시 써줘야 합니다. (그렇지 않으면, 에러가 발생합니다.)
JavaScript in JSX in JavaScript
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(
<h1>{2 + 3}</h1>,
document.getElementById('app')
);
// Output on monitor: 5
JSX expression 안에 일반적인 JavaScript 코드를 사용하고 싶다면, {}를 사용합니다. {} 안에 위치한 코드들은 JSX expression 안쪽이라도 JavaScript 코드로 인식됩니다. 여기서 {}는 JSX나 JavaScript가 아니라, JavaScript injection into JSX의 시작과 끝을 나타내는 marker입니다.
Event Listener
function myFunc() {
alert('Welsh Corgi!!!!');
}
<img onClick={myFunc} />
JSX에서도 HTML과 같이 event listener를 사용할 수 있습니다. on을 접두어로 하는 속성들을 사용하면 event listener를 적용할 수 있는데, 해당 속성들의 값은 반드시 함수가 되어야 합니다.
또한, HTML에서 event listener의 이름들은 모두 소문자로 쓰이지만, JSX에서는 camelCase로 사용해야 합니다.
Conditional statement
JSX에는 if 구문을 삽입할 수 없습니다. 하지만, 이를 해결할 몇 가지 방법도 존재합니다.
const sound = 'Bow wow!';
if (sound === 'Bow wow!') {
message = (
<h1>
Hey, good dog!
</h1>
);
} else {
message = (
<h1>
I like a lot of animal!
</h1>
);
}
먼저, JSX 바깥에서 if를 사용해 원하는 조건문을 만들 수 있습니다.
const sound = 'Bow wow!';
const message = (
<h1>
{ sound === 'Bow wow!' ? 'Hey, good dog!' : 'I like a lot of animal!' }
</h1>
);
혹은 삼항연산자(ternary operator)를 사용하면 JSX 내부에서도 조건문을 사용할 수 있습니다. React에서는 상당히 자주 사용되는 방법입니다.
const tasty = (
<ul>
<li>Dog feed</li>
{ !puppy && <li>Dog gum</li> }
{ age > 1 && <li>bone</li> }
{ age > 5 && <li>Dog ade</li> }
{ age > 7 && <li>Dog cookie</li> }
</ul>
);
만일 어떤 조건에서만 action을 취하고 다른 때는 아무 것도 하지 않는 경우라면, && 연산자를 활용하는 것도 적합합니다. 즉, && 연산자의 왼쪽 expression이 true일 경우에만, && 연산자의 오른쪽 expression이 렌더링될 것입니다. 이러한 형태의 조건문도 React에서 자주 쓰이는 방식입니다.
map()
const dogs = ['Welsh Corgi', 'Poodle', 'Dachshund'];
const listDogs = dogs.map(dog => <li>{dog}</li>);
<ul>{listDogs}</ul>
만일 JSX element의 array를 만들고 싶다면, .map()을 사용하는 것이 유용합니다. React에서 자주 사용되는 방식이므로 기억해두면 좋습니다.
// This is fine in JSX, not in an explicit array:
<ul>
<li>dog 1</li>
<li>dog 2</li>
<li>dog 3</li>
</ul>
// This is also fine!
const liArray = [
<li>dog 1</li>,
<li>dog 2</li>,
<li>dog 3</li>
];
<ul>{liArray}</ul>
또한, <li> JSX element들이 담긴 array는 위의 {liArray} 같이 곧바로 <ul>과 함께 사용하는 것이 가능합니다.
key 속성
<ul>
<li key="li-01">Dog 1</li>
<li key="li-02">Dog 2</li>
<li key="li-03">Dog 3</li>
</ul>
<li> 태그들은 때때로 key 속성을 필요로 할 때가 있습니다. 특정 상황에서 key를 설정해두지 않으면 잘못된 순서로 list-item들이 나타날 수 있으므로, 다음과 같은 상황에서는 key 속성을 설정합니다.
각각의 list-item이 memory를 가질 경우 (to-do list와 같이 항목의 체크 여부를 기억해야 할 때)
list-item이 섞일 가능성이 있을 때
key 속성을 설정할 때, key 속성의 값은 unique해야 합니다.
React.createElement()
React 코드를 JSX expression을 쓰지 않고도 사용할 수 있는 방법이 있습니다.
const h1 = <h1>Welsh Corgi</h1>;
위의 JSX expression으로 표현하던 기존의 코드는 다음과 같이 새로 쓰일 수 있습니다.
const h1 = React.createElement(
"h1",
null,
"Welsh Corgi"
);
React.createElement()을 사용하면 JSX expression을 쓰지 않고도 같은 기능을 하는 React 코드를 만들 수 있습니다.
사실 JSX element가 컴파일 될 때, 컴파일러는 내부적으로 해당 JSX element를 React.createElement() 메서드로 변형하여 호출합니다. 즉, JSX expression을 사용하기 전에는 항상 import React from 'react';로 React 객체를 import해야 하는데, 그 이유는 내부적으로 항상 React.createElement() 메서드가 사용 가능해야 하기 때문입니다.
Reference
Learn React - Codecademy
Event Listener List - React.js
-
JavaScript - Async/Await
Async-Await
async, await을 사용하는 구문은 ES8에서 소개된 JavaScript의 비동기 처리를 위한 syntactic sugar입니다. 비동기 처리하는 과정이나 결과는 이전 callback 함수를 통해 구현하는 방식이나 혹은 ES6에서부터 사용하는 promise 객체를 사용해 구현하는 방식과 동일하지만, 문법적으로 조금 더 편리하게 비동기 처리를 할 수 있도록 제공됩니다.
async keyword
async function myFunc() {
// Function body here
};
myFunc();
비동기 함수를 만들기 위해 사용하는 키워드입니다. 구현한 비동기 처리 로직은 위와 같이 async로 선언된 함수로 감싸서 의도대로 실행할 수 있습니다.
const myFunc = async () => {
// Function body here
};
myFunc();
또한, async 함수는 함수 선언식 뿐만 아니라 함수 표현식으로도 사용할 수 있습니다.
async 함수의 리턴 값
async 함수는 항상 promise 객체를 리턴합니다. 덕분에, 원래의 promise 비동기 처리 방식대로 .then(), .catch() 등을 그대로 사용할 수 있습니다. 다만, 리턴할 때 3가지 상황에 따라 다른 promise 객체를 내어줍니다.
명시적으로 리턴하는 값이 없을 때: undefined를 resolved value로 사용하는 promise 객체를 리턴합니다.
명시적으로 promise 객체가 아닌 값을 리턴할 때: 해당 리턴 값을 resolved value로 사용하는 promise 객체를 리턴합니다.
명시적으로 promise 객체를 리턴할 때: 해당 promise 객체를 그대로 리턴합니다.
await keyword
async 키워드 만으로는 비동기 처리를 제대로 할 수 없기 때문에, async 함수 안에서는 보통 await을 함께 사용합니다.
await은 지정한 함수에서 promise 객체가 리턴 및 resolve될 때까지 async 함수 실행 자체를 멈추었다가, promise의 resolved value를 받으면 해당 값을 리턴하고 async 함수의 남은 코드를 다시 실행하는 키워드입니다. 즉, promise를 객체를 받아 해당 promise 객체를 pending 상태에서 resolved 상태까지 실행하여 resolved value를 리턴하는 전 과정을 포괄합니다. 이러한 특이성으로 인해, await은 주로 라이브러리에서 가져온 promise를 리턴하는 함수와 함께 사용하는 것이 일반적입니다.
async function asyncFuncExample(){
let resolvedValue = await myPromise();
console.log(resolvedValue);
}
asyncFuncExample(); // Prints: I am resolved now!
위 코드에서 myPromise()는 "I am resolved now!"라는 string을 resolve할 promise를 리턴하는 함수입니다. 이렇게 promise의 로직을 인지하며 await을 사용하면, 비동기적인 코드가 순차적인 코드 흐름으로 읽히도록 구현할 수 있습니다.
Error handling with try... catch
async function usingTryCatch() {
try {
let resolveValue = await asyncFunction('thing that will fail');
let secondValue = await secondAsyncFunction(resolveValue);
} catch (err) {
// Catches any errors in the try block
console.log(err);
}
}
usingTryCatch();
기존의 promise 객체 비동기 처리 방식에서 chain이 길어질 때, .catch를 사용해도 어떤 순서에서 error가 발생한 것인지 파악하기 어려웠습니다. 반면에, async... await에서는 try... catch를 사용해 쉽게 error handling을 진행할 수 있습니다.
async 함수에서 try... catch는 동기적인 코드와 같은 방식으로 error handling을 할 수 있으면서 동시에, 동기 및 비동기 error 모두를 잡아낼 수 있기 때문에, 쉬운 디버깅을 가능하게 한다는 큰 이점이 있습니다.
async function usingPromiseCatch() {
let resolveValue = await asyncFunction('thing that will fail');
}
let rejectedPromise = usingPromiseCatch();
rejectedPromise.catch((rejectValue) => {
console.log(rejectValue);
})
물론 async 함수도 promise 객체의 .catch 메서드를 종종 사용할 때가 있습니다. 위와 같이, 복잡한 코드의 마지막 에러만 잡아내고 싶을 경우 global scope에서 사용하는 것이 하나의 예입니다.
독립적인 promise들을 다루는 방법
다수의 promise 객체들이 서로 의존하고 있을 때는 promise마다 await을 사용하여 명확한 순서로 비동기 처리를 하는 것이 효율적입니다. 반면에, promise 객체들이 서로 독립적일 때는 순서에 상관없이 모든 promise가 동시에 실행되는 것이 보다 효율적입니다.
async 함수에서 앞서 이야기한 concurrent 실행을 진행하는 방법을 크게 2가지 소개하겠습니다.
await in one line
/* 원래의 모습
async function waiting() {
const firstValue = await firstAsyncThing();
const secondValue = await secondAsyncThing();
console.log(firstValue, secondValue);
}
*/
// concurrent 실행
async function concurrent() {
const firstPromise = firstAsyncThing();
const secondPromise = secondAsyncThing();
console.log(await firstPromise, await secondPromise);
}
use Promise.all
async function asyncPromAll() {
const resultArray = await Promise.all([asyncTask1(), asyncTask2(), asyncTask3(), asyncTask4()]);
for (let i = 0; i<resultArray.length; i++){
console.log(resultArray[i]);
}
}
Reference
Codecademy - introduction to javascript
-
JavaScript - Browser compatibility and transpilation
Browser Compatibility & Transpilation
우리는 정기적으로 web browser의 update 알림을 받습니다. 주기적인 update가 필요한 이유는 보통 보안상 취약점을 처리하고 HTML, CSS 혹은 JavaScript의 새로운 syntax 버전을 지원하기 위해서입니다.
특히, JavaScript의 표준을 관리하는 기관, Ecma International이 2015년에 발표한 ECMAScript2015(흔히, ES6로 불리우는)가 등장했을 때, 많은 개발자들은 장점이 많은 ES6를 바로 채택하고 사용했지만 대부분의 브라우저에서 ES6가 지원되지 않아 브라우저 호환성(browser compatibility) 문제가 발생했습니다.
이 챕터에서는 새로운 syntax 버전과의 gap으로 인해 발생하는 이러한 브라우저 호환성 이슈를 개발자들이 어떤식으로 처리하는지에 초점을 맞추려고 합니다.
caniuse.com
caniuser.com은 브라우저 호환성 정보를 쉽게 찾아볼 수 있는 사이트입니다. 어떤 브라우저의 몇 번째 버전이 특정 라이브러리를 지원하는지 여부를 간단히 체크할 수 있습니다.
이 곳에서 검색해보면, ES5는 여전히 대다수의 브라우저에서 지원됩니다. 이와 달리, ES6는 점진적인 적용을 감안해야 합니다. 그 결과 대부분의 최신 버전 브라우저에서는 ES6가 원활히 지원되는 반면, ES6 module과 같은 ES6의 특정 feature들은 지원되지 않는 브라우저가 아직 대다수입니다.
ES6의 장점과 Transpilation의 필요성
ES6는 이전 버전인 ES5에 비해 상당한 이점들이 있습니다.
Readability and economy of code
Addresses sources of ES5 bugs
A similarity to other programming languages
이러한 장점들은 많은 web developer들이 ES6를 곧바로 채택하도록 만들었습니다.
다만, ECMA는 이로 인해 발생할 호환성 문제를 미리 예상해, ES6가 ES5 코드로 mapping될 수 있게끔 만들었습니다. 예를 들어, const나 let 키워드를 var로 mapping하거나, interpolation을 concatenation으로 mapping하는 방식입니다.
이러한 mapping은 충분히 규칙적이기 때문에, 개발자들은 ES6가 자동으로 ES5으로 변환되도록 하는 Babel이라는 JS library를 만들었습니다. 즉, 하나의 프로그래밍 언어를 다른 언어로 변환하는 과정을 transpilation이라고 하며, Babel은 ES6를 ES5로 transpile합니다.
Babel
Babel은 ES6를 ES5로 손쉽게 transpile해주는 library입니다. 먼저, Babel을 사용하기 위해 ES6의 파일(main.js)의 위치를 ./src/main.js에 둡니다.
project
|_ src
|___ main.js // ES6 file
그리고 Babel을 설치하기 전에 npm을 사용할 수 있게끔 프로젝트를 setup해야 합니다. npm은 node project manager의 약자로 node package에 대한 접근과 관리를 위해 사용됩니다. npm을 사용하면 작업의 반복과 버그를 줄일 수 있습니다.
터미널에서 npm을 init합니다. (Node가 설치되어 있어야 합니다!)
npm init
이 때, metadata에 관한 사항을 적어달라는 prompt가 나오는데, title과 description정도만 입력하고 전부 무시해도 좋습니다. (title, description 역시 선택사항입니다.)
Init 이후에, root 디렉토리에는 package.json 파일이 생성됩니다. package.json 파일은 해당 프로젝트의 metadata와 프로젝트를 실행하기 위해 필요한 node package 목록, command line scripts에 해당하는 key-value pair 등을 저장합니다.
Babel은 터미널 창에서 다음과 같이 사용합니다.
Babel package 설치하기 (2개 모두)
for CLI tool
npm install babel-cli -D
for mapping information
npm install babel-preset-env -D
실행이 완료되면 Babel 패키지 및 관련된 모든 dependency들이 node_modules 디렉토리에 저장되어 있는 것을 확인할 수 있습니다.
-D 옵션
-D는 해당 패키지를 package.json의 devDependencies라는 property에 추가하는 옵션입니다. 일단 devDependencies에 추가된 패키지들은 다른 개발자들이 현재 프로젝트를 실행할 때 각각의 패키지를 install할 필요없이 npm install 커맨드 한 번으로 모두 설치되는 이점을 가집니다.
Source가 되는 JS version 설정하기
Root 디렉토리에서 .babelrc 파일을 생성합니다.
touch .babelrc
.babelrc 내에 source가 될 js 파일의 버전을 명시합니다. 아래와 같은 object를 파일에 저장하면, Babel은 ES6+에 대한 코드들을 목표 언어로 변환할 것입니다.
```
{
“presets”: [“env”]
}
package.json에 Babel 실행을 위한 script 기재하기
package.json에 script property에 가보면 다음과 같은 객체가 존재함을 확인할 수 있습니다.
...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}, ...
이 객체의 "test" property 밑에, 다음과 같이 Babel을 실행하기 위한 script를 하나 추가합니다.
...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "babel src -d lib"
}
추가한 코드는 각각 다음과 같은 의미를 가지고 있습니다.
babel — The Babel command call responsible for transpiling code.
src — Instructs Babel to transpile all JavaScript code inside the src directory.
-d — Instructs Babel to write the transpiled code to a directory.
lib — Babel writes the transpiled code to a directory called lib.
Babel 실행하기 (root directory)
npm run build
작업이 완료되면 ./lib 디렉토리에 변환된 ES5 코드가 담긴 파일을 확인할 수 있습니다. 파일명은 본래의 ES6 파일명과 동일하게 생성됩니다.
최종적인 디렉토리 구조는 다음과 같습니다.
project
|_ lib
|___ main.js
|_ node_modules
|___ .bin
|___ ...
|_ src
|___ main.js
|_ .babelrc
|_ package.json
Reference
Codecademy - introduction to javascript
-
JavaScript - Class
Class
Javascript는 OOP(object-oriented programming) language입니다. 따라서, 실제 세계를 모델로 class와 instance를 만들어 object들을 사용할 수 있습니다.
Syntax example
class Dog {
constructor(name) {
this._name = name;
this._behavior = 0;
}
get name() {
return this._name;
}
get behavior() {
return this._behavior;
}
incrementBehavior() {
this._behavior ++;
}
}
const halley = new Dog('Halley');
console.log(halley.name); // Print name value to console
console.log(halley.behavior); // Print behavior value to console
halley.incrementBehavior(); // Add one to behavior
console.log(halley.name); // Print name value to console
console.log(halley.behavior); // Print behavior value to console
class
Class를 생성하기 위해 필요한 키워드 입니다.
constructor
Class가 object와 가장 구분되는 지점은 constructor 메서드의 유무입니다. constructor는 새로운 instance가 만들어질 때마다 호출되는 class의 중요한 메서드이며, instance를 초기화하는 역할을 합니다.
this
Class의 맥락에서 this는 해당 class의 instance를 의미합니다.
new
Class의 instance를 생성하기 위해 사용하는 키워드입니다. new는 class 내의 constructor() 메서드를 호출하고 새로운 instance를 반환합니다.
상속(Inheritance)
class Cat {
constructor(name, usesLitter) {
this._name = name;
this._usesLitter = usesLitter;
this._behavior = 0;
}
get name() {
return this._name;
}
get behavior() {
return this._behavior;
}
get usesLitter() {
return this._usesLitter;
}
incrementBehavior() {
this._behavior++;
}
}
앞선 class의 예제에서 Dog class를 만들었던 것처럼, Cat class도 이와 유사하게 만들 수 있습니다. 여기선 Cat의 경우 모든 것이 Dog와 동일하지만 배변기 사용 가능 여부를 나타내는 usesLitter property만 하나 더 가집니다.
이렇게 여러 class가 동일한 부분을 가질 경우, 코드의 반복을 피하기 위해 부모 class를 만들어 자식 class가 이를 상속(inheritance)받게끔 설계하는 것이 효율적입니다. 상속은 부모 class가 가지는 property와 method를 동일하게 사용할 수 있게끔 물려받는 것을 의미합니다. 상속을 사용하면 코드의 가독성이 높아지고 유지보수가 매우 쉬워집니다.
class Animal {
constructor(name) {
this._name = name;
this._behavior = 0;
}
get name() {
return this._name;
}
get behavior() {
return this._behavior;
}
incrementBehavior() {
this._behavior++;
}
}
따라서, 위와 같이 Animal class를 만들어 Dog와 Cat의 공통된 부분을 모은 후, 이를 각자 상속받도록 설계하는 것이 보다 나은 코드를 만드는 방향이 될 것입니다.
Animal을 상속받은 Cat의 코드는 다음과 같습니다.
class Cat extends Animal {
constructor(name, usesLitter) {
super(name);
this._usesLitter = usesLitter;
}
get usesLitter() {
return this._usesLitter;
}
}
extends
Class를 다른 class의 자식 class로 만들기 위해 사용하는 키워드입니다. 부모 class의 method들을 자식 class가 사용할 수 있게 됩니다.
super
부모 class의 constructor 메서드를 호출하는 키워드입니다. 부모 class의 property 상속과 관련이 깊습니다.
또한, 자식 class에서 this를 사용하기 위해, 자식 class 내의 constructor 메서드 첫 번째 줄에 반드시 호출해주어야 합니다. (그렇지 않으면, reference error가 발생합니다!)
const bryceCat = new Cat('Bryce', false);
console.log(bryceCat._name); // output: Bryce
console.log(bryceCat.name); // output: Bryce
끝으로, Animal class를 상속받은 Cat은 위와 같이 instance를 만들어 사용할 수 있습니다.
Static method
Static method는 class에 직접적으로 접근해 사용하는 메서드를 말합니다. 해당 class의 instance를 통해서는 사용할 수 없다는 특징이 있습니다. 예를 들어, Date class는 instance를 만들 수 있으면서 .now() 같은 static method를 사용할 수 있습니다.
다음은 Animal class에 static method generateName을 추가한 코드입니다.
class Animal {
constructor(name) {
this._name = name;
this._behavior = 0;
}
static generateName() {
const names = ['Angel', 'Spike', 'Buffy', 'Willow', 'Tara'];
const randomNumber = Math.floor(Math.random()*5);
return names[randomNumber];
}
}
console.log(Animal.generateName()); // returns a name
static
Static method를 선언하는 키워드입니다. static이 사용된 메서드는 instance를 통해 사용할 수 없고, class에서 직접적으로 접근해야 호출 가능합니다. Instance를 통해 호출할 경우, error를 일으킵니다.
Reference
Codecademy - introduction to javascript
-
JavaScript - Object
Object
Javascript의 data type은 6개의 primitive data type(string, number, boolean, null, undefined, symbol)과 1개의 object data type으로 구성되어 있습니다. Javascript는 객체지향 언어이고 6개의 primitive data type도 객체와 같이 동작하는 특징이 있습니다. 또한, object는 mutable(변경가능한) 속성을 가집니다.
Syntax
Object는 {}를 통해 구현됩니다. {} 안에는 unordered data를 key-value pair로 삽입합니다. value의 경우 어떤 data type이 와도 괜찮습니다. 반면에, key의 타입은 string이어야 합니다. 다만, key의 경우 특별한 특수문자를 집어넣는 것이 아니라면 quotation mark 없이 사용해도 string으로 자동 인식됩니다.
// An object literal with two key-value pairs
let spaceship = {
'Fuel Type': 'diesel',
color: 'silver'
};
Property
Object에 저장된 함수가 아닌 data는 property라고 부릅니다. Property에 접근할 때는 .이 사용됩니다. 만일 object 내에 없는 property에 접근한 경우에는 undefined가 반환됩니다.
let spaceship = {
homePlanet: 'Earth',
color: 'silver'
};
spaceship.homePlanet; // Returns 'Earth',
spaceship.color; // Returns 'silver',
또 다른 방법은 []을 사용하는 것입니다. 원하는 key를 []안에 넣으면 object에서 해당하는 property에 접근합니다. []는 특수문자가 포함된 key string에 특히 유용합니다.
let spaceship = {
'Fuel Type': 'Turbo Fuel',
'Active Duty': true,
homePlanet: 'Earth',
numCrew: 5
};
spaceship['Active Duty']; // Returns true
spaceship['Fuel Type']; // Returns 'Turbo Fuel'
spaceship['numCrew']; // Returns 5
spaceship['!!!!!!!!!!!!!!!']; // Returns undefined
Add, update and delete
[], .와 =를 사용하면, object에 새로운 property를 추가하거나 기존 property를 수정할 수 있습니다. 또한, const 변수에 담긴 object여도 해당 object 안의 property를 추가하거나 수정할 수 있습니다.
const spaceship = {type: 'shuttle'};
spaceship = {type: 'alien'}; // TypeError: Assignment to constant variable.
spaceship.type = 'alien'; // Changes the value of the type property
spaceship.speed = 'Mach 5'; // Creates a new key of 'speed' with a value of 'Mach 5'
Object 내의 property를 삭제하는 방법은 delete 키워드를 사용하는 것입니다. 역시 const 변수에 담긴 object여도 내부의 property 삭제가 가능합니다.
const spaceship = {
'Fuel Type': 'Turbo Fuel',
homePlanet: 'Earth',
mission: 'Explore the universe'
};
delete spaceship.mission; // Removes the mission property
Method
Object 내에 저장된 데이터가 함수라면, 해당 데이터는 method라고 부릅니다. Method는 key에 method 이름을, value에 익명 함수를 저장함으로써 구현합니다.
const alienShip = {
invade: function () {
console.log('Hello! We have come to dominate your planet. Instead of Earth, it shall be called New Xaculon.')
}
};
ES6에서 새로이 소개된 method 문법에서는 :과 function 키워드 없이도 정의할 수 있습니다.
const alienShip = {
invade () {
console.log('Hello! We have come to dominate your planet. Instead of Earth, it shall be called New Xaculon.')
}
};
Method는 ., ()를 사용해 호출합니다.
alienShip.invade(); // Prints 'Hello! We have come to dominate your planet. Instead of Earth, it shall be called New Xaculon.'
Pass by reference
Javascript에서 object는 pass by reference로 동작합니다. Object를 담는 변수는 실제로는 해당 객체가 담겨 있는 메모리 주소를 담기 때문에, object가 함수에 인자로 전달되어 변형이 일어나면 함수 밖의 실제 object도 영향을 받아 변형됩니다.
const spaceship = {
homePlanet : 'Earth',
color : 'silver'
};
let paintIt = obj => {
obj.color = 'glorious gold'
};
paintIt(spaceship);
spaceship.color // Returns 'glorious gold'
함수 내에서 object를 재할당하는 경우
let spaceship = {
homePlanet : 'Earth',
color : 'red'
};
let tryReassignment = obj => {
obj = {
identified : false,
'transport type' : 'flying'
}
console.log(obj) // Prints {'identified': false, 'transport type': 'flying'}
};
tryReassignment(spaceship) // The attempt at reassignment does not work.
spaceship // Still returns {homePlanet : 'Earth', color : 'red'};
spaceship = {
identified : false,
'transport type': 'flying'
}; // Regular reassignment still works.
함수의 인자로 object를 받을 때, 함수 내에서 새로운 object를 재할당을 하는 것은 기존 object에 영향을 미치지 않습니다.
위 예에서 obj 파라미터는 함수내에 생성되는 로컬 변수입니다. tryReassignment 함수의 흐름은 파라미터 obj에 인자로 들어온 object의 메모리 주소가 담기고, 이에 대해 새로운 object를 할당하여 새 object의 메모리 주소가 다시 obj에 담기게끔 이어집니다. 하지만, 함수가 종료되면 로컬 변수였던 obj 역시 사라지기 때문에, 기존 spaceship에 담긴 object는 변형 없이 그대로 남아 있게 됩니다.
for … in
Array의 경우 index를 통해 looping할 수 있지만, object는 key를 사용하기 때문에 다른 looping 수단이 필요합니다. 따라서, object looping에 대해서는 for ... in 구문을 사용합니다.
let spaceship = {
crew: {
captain: {
name: 'Lily',
degree: 'Computer Engineering',
cheerTeam() { console.log('You got this!') }
},
'chief officer': {
name: 'Dan',
degree: 'Aerospace Engineering',
agree() { console.log('I agree, captain!') }
},
medic: {
name: 'Clementine',
degree: 'Physics',
announce() { console.log(`Jets on!`) } },
translator: {
name: 'Shauna',
degree: 'Conservation Science',
powerFuel() { console.log('The tank is full!') }
}
}
};
// for...in
for (let crewMember in spaceship.crew) {
console.log(`${crewMember}: ${spaceship.crew[crewMember].name}`);
}
this keyword
this 키워드는 calling object를 나타내며, object의 method 내에서 property에 접근할 때는 this 키워드를 사용합니다. 여기서 calling object란 해당 method를 호출하는 객체를 말합니다.
const goat = {
dietType: 'herbivore',
makeSound() {
console.log('baaa');
},
diet() {
console.log(this.dietType);
}
};
goat.diet();
// Output: herbivore
예를 들어, diet() method에서 dietType property에 접근하기 위해서는 반드시 this 키워드가 필요합니다. diet() 내에서 dietType에 접근할 경우 scope가 diet() 안쪽으로 설정되기 때문에 reference error가 발생합니다. 따라서, dietype property에 접근하려면 this 키워드로 calling object인 goat를 불러와 접근해야 합니다.
Arrow function과 this
const goat = {
dietType: 'herbivore',
makeSound() {
console.log('baaa');
},
diet: () => {
console.log(this.dietType);
}
};
goat.diet(); // Prints undefined
객체에 method를 정의할 때, arrow function 사용은 지양해야 합니다. 위와 같은 경우 this가 가리키는 calling object는 global object입니다. this가 diet scope에 존재하지 않기 때문에, 상위 스코프를 탐색하게 되고 global object가 this가 됩니다. 따라서, global object에는 dietType property가 없기 때문에, this.dietType은 undefined를 가집니다.
Privacy of object
Product를 만들다보면, 어떠한 object 내 property에 아무나 접근하지 못하게끔 막아야 하는 상황이 발생합니다. 특정 프로그래밍 언어들에서는 이러한 경우를 제어할 수 있는 privacy와 관련된 built-in 키워드를 제공합니다. 하지만 Javascript의 경우 이러한 제어 방법이 없기 때문에, 네이밍 컨벤션을 통해 다른 개발자들에게 해당 property를 어떻게 써야할 지 알려줍니다.
대표적으로 property의 식별자 앞에 _를 붙이는 것은 해당 property가 변형되어서는 안된다는 의미입니다.
const robot = {
_energyLevel: 100,
recharge(){
this._energyLevel += 30;
console.log(`Recharged! Energy is currently at ${this._energyLevel}%.`)
}
};
robot['_energyLevel'] = 'high';
robot.recharge();
// Output: Recharged! Energy is currently at high30%.
예를 들어, 위 코드의 경우 _energyLevel은 robot['_energyLevel'] = 'high';과 같이 실제로 변형이 가능합니다. 하지만, 개발자의 의도에 맞지 않게 string 값으로 변형함으로 인해 high30%와 같은 어색한 결과가 발생했습니다.
이처럼 _가 붙은 property는 원치않는 결과가 나올 수 있으니 직접적으로 접근하여 변형시키면 안된다는 의미를 내포합니다.
Getters & Setters
Getters method
const person = {
_firstName: 'John',
_lastName: 'Doe',
get fullName() {
if (this._firstName && this._lastName){
return `${this._firstName} ${this._lastName}`;
} else {
return 'Missing a first name or a last name.';
}
}
}
// To call the getter method:
person.fullName; // 'John Doe'
Getters는 객체 내부에서 property를 가져와 반환해주는 method입니다. Method 앞에 get를 사용해 구현하며, this를 통해 객체 내의 property를 조작합니다. Getters를 호출할 때는 마치 property에 접근하는 것 같이, () 없이 .만으로 호출합니다.
Getters를 사용하면, property에 접근할 때 원하는 action을 임의로 추가할 수 있고, 다른 개발자들이 이해하기 쉽도록 코드를 짤 수 있습니다.
Setters method
const person = {
_age: 37,
set age(newAge){
if (typeof newAge === 'number'){
this._age = newAge;
} else {
console.log('You must assign a number to age');
}
}
};
person.age = 40;
console.log(person._age); // Logs: 40
person.age = '40'; // Logs: You must assign a number to age
객체 내 property에 대한 접근을 도와주는 getters와 달리, setters는 객체 내 존재하는 property의 value를 재할당할 수 있게 도와주는 method입니다. Method 앞에 set을 사용해 구현하며, 마찬가지로 this를 사용해 객체 내 property를 조작합니다. Setters를 호출할 때도 마치 property에 값을 할당하는 것 같이 .만 사용하여 호출합니다.
Setters도 input checking, easier readability 등의 이점을 가집니다.
Naming of getters, setters
Getters와 setters의 이름은 객체 내의 property들의 이름과 겹쳐서는 안됩니다. 만일 겹칠 경우, 끝없는 call stack error에 빠지게 됩니다. 이를 피하기 위해, property 이름 앞에 _를 붙여주는 것은 좋은 방법이 됩니다.
Factory function
const monsterFactory = (name, age, energySource, catchPhrase) => {
return {
name: name,
age: age,
energySource: energySource,
scare() {
console.log(catchPhrase);
}
}
};
const ghost = monsterFactory('Ghouly', 251, 'ectoplasm', 'BOO!');
ghost.scare(); // 'BOO!'
하나하나의 object를 직접 만드는 것은 손이 많이 가고 비효율적입니다. 따라서, 몇 가지 parameter를 받아서 customized된 object를 반환하는 함수를 만들면 다수의 object를 효율적으로 생성할 수 있습니다. 이러한 함수를 factory function이라고 합니다.
Property value shorthand
const monsterFactory = (name, age) => {
return {
name: name,
age: age
}
};
기존에는 객체에 property를 저장하기 위해 위 코드와 같이 key-value pair 방식을 사용했습니다. 다만, ES6에서는 factory function을 사용할 때와 같이 parameter의 이름과 property의 이름이 같은 경우에 대해 코드 중복을 줄일 수 있도록 property value shorthand 문법을 제공합니다.
따라서 위 코드는 다음과 같이 수정될 수 있습니다.
const monsterFactory = (name, age) => {
return {
name,
age
}
};
Destructured assignment
객체의 key를 통해 value를 가져와 변수에 저장하던 일반적인 방식에 대해, 조금 더 간략한 destructured assignment 방식이 존재합니다.
const vampire = {
name: 'Dracula',
residence: 'Transylvania',
preferences: {
day: 'stay inside',
night: 'satisfy appetite'
}
};
위와 같은 vampire 객체에서 residence property를 가져와 변수에 저장하고 싶다면, 두 가지 방식을 사용할 수 있습니다.
const residence = vampire.residence;
console.log(residence); // Prints 'Transylvania'
먼저 일반적인 방식으로 key를 통해 가져올 수 있습니다.
const { residence } = vampire;
console.log(residence); // Prints 'Transylvania'
그런데 만일 key의 이름과 같은 이름으로 변수를 생성한다면, 위와 같이 {}를 통해 보다 간결히 property를 가져와 변수에 저장할 수 있습니다.
Reference
Codecademy - introduction to javascript
-
JavaScript - Iterator
Iterator
Looping을 더욱 쉽게 만들어주는 JavaScript의 built-in array methods를 iteration method(=iterator)라고 합니다. Iterator는 array가 element들을 조작하고 value를 반환하기 위해 호출하는 메서드로서 도움을 줍니다.
forEach()
forEach()는 특정 함수를 array 각각의 element들에 적용하는 iterator입니다. 보통 iterator의 인자로 함수를 받은 후, element들 각각을 인자로 사용해 해당 함수를 호출합니다. (이렇게 다른 함수의 인자로 사용되는 함수를 callback 함수라고 부릅니다.)
forEach()는 기존의 array를 변경하지 않으며, undefined를 return합니다.
groceries.forEach(groceryItem => console.log(groceryItem));
또한, arrow function을 인자로 사용해 iterator를 호출할 수도 있습니다. 이처럼, iterator의 인자로 사용되는 함수의 syntax는 임의로 자유롭게 사용할 수 있습니다.
map()
const numbers = [1, 2, 3, 4, 5];
const bigNumbers = numbers.map(number => {
return number * 10;
});
console.log(numbers); // Output: [1, 2, 3, 4, 5]
console.log(bigNumbers); // Output: [10, 20, 30, 40, 50]
map() 역시 forEach()와 비슷하게 동작합니다. 인자로 callback 함수를 받아, array 각각의 element를 callback 함수의 인자로 사용합니다. 다만, map()은 함수를 적용한 새로운 값들을 array에 담아서 반환한다는 점이 특징입니다.
filter()
const words = ['chair', 'music', 'pillow', 'brick', 'pen', 'door'];
const shortWords = words.filter(word => {
return word.length < 6;
});
console.log(words); // Output: ['chair', 'music', 'pillow', 'brick', 'pen', 'door'];
console.log(shortWords); // Output: ['chair', 'music', 'brick', 'pen', 'door']
filter()는 원래의 array에서 특정 조건에 만족하는 element들만 골라내어 새로운 array에 담아 반환합니다. 따라서, filter()에 인자로 사용되는 callback 함수는 반드시 boolean 값을 리턴하는 함수여야 합니다. 이 때, callback 함수가 true를 반환하게 하는 element들이 새로운 array에 담깁니다.
findIndex()
const jumbledNums = [123, 25, 78, 5, 9];
const lessThanTen = jumbledNums.findIndex(num => {
return num < 10;
});
console.log(lessThanTen); // Output: 3
console.log(jumbledNums[3]); // Output: 5
findIndex()는 특정 element의 위치를 알고 싶을 때 사용하는 iterator입니다. Callback 함수가 true를 반환하는 첫 번째 element의 index를 return합니다. 만일, callback 함수의 조건을 충족하는 element가 없다면 findIndex()는 -1을 반환합니다.
reduce()
const numbers = [1, 2, 4, 10];
const summedNums = numbers.reduce((accumulator, currentValue) => {
return accumulator + currentValue
})
console.log(summedNums) // Output: 17
Iteration
accumulator
currentValue
return value
First
1
2
3
Second
3
4
7
Third
7
10
17
reduce()는 말그대로 array을 감소시켜 하나의 값으로 만드는 iterator입니다. Callback 함수에 따라 array의 각 element를 accumulator에 대해 계산해, 최종적으로 하나의 계산 값을 반환합니다.
const numbers = [1, 2, 4, 10];
const summedNums = numbers.reduce((accumulator, currentValue) => {
return accumulator + currentValue
}, 100) // <- Second argument for .reduce()
console.log(summedNums); // Output: 117
Iteration #
accumulator
currentValue
return value
First
100
1
101
Second
101
2
103
Third
103
4
107
Fourth
107
10
117
또한, reduce()는 optional한 두 번째 parameter를 받을 수 있으며, 이 때 해당 parameter는 accumulator로서 사용됩니다.
Reference
Codecademy - introduction to javascript
-
JavaScript - Array
Array
Javascript의 array는 어떤 data type도 저장할 수 있으며, 저장된 data마다 순서(index)를 지닙니다.
Syntax
기본적인 문법은 []을 중심으로 이뤄지며, array 내부에 다양한 type의 data들이 함께 저장될 수 있습니다.
Indexing
Array는 []에 index를 사용하여 원하는 element에 접근할 수 있습니다. Indexing의 시작은 0부터 진행되며, 만일 element의 총 개수를 넘어가는 index로 접근할 경우 undefined가 반환됩니다.
Indexing to String
const hello = 'Hello World';
console.log(hello[6]);
// Output: W
또한, Indexing은 String type의 data에도 적용됨을 유의합니다.
Update with index
let seasons = ['Winter', 'Spring', 'Summer', 'Fall'];
seasons[3] = 'Autumn';
console.log(seasons);
//Output: ['Winter', 'Spring', 'Summer', 'Autumn']
Indexing을 사용하면 접근한 data를 원하는 값으로 update하는 것도 가능합니다.
let & const in array
let condiments = ['Ketchup', 'Mustard', 'Soy Sauce', 'Sriracha'];
const utensils = ['Fork', 'Knife', 'Chopsticks', 'Spork'];
condiments[0] = 'Mayo';
console.log(condiments); // [ 'Mayo', 'Mustard', 'Soy Sauce', 'Sriracha' ]
condiments = ['Mayo'];
console.log(condiments); // [ 'Mayo' ]
utensils[3] = 'Spoon';
console.log(utensils); // [ 'Fork', 'Knife', 'Chopsticks', 'Spoon' ]
const 변수에 저장한 array라도 해당 array 내부의 요소는 여전히 변경가능(mutable)합니다. 다만, 새로운 array 혹은 값을 변수에 재할당할 수는 없습니다.
Useful property & method
length: array 내의 존재하는 element의 개수를 반환합니다.
push(): array의 맨 끝에 element를 추가합니다. (이 때, 인자를 여러 개 받을 수 있습니다.)
const itemTracker = ['item 0', 'item 1', 'item 2'];
itemTracker.push('item 3', 'item 4');
console.log(itemTracker);
// Output: ['item 0', 'item 1', 'item 2', 'item 3', 'item 4'];
pop(): array의 맨 끝의 element를 제거하고 그 값을 반환합니다. (인자를 받지 않습니다.)
shift(): array 맨 앞의 element를 제거하고 반환합니다. (인자를 받지 않습니다.)
unshift(): array의 맨 앞에 element를 추가합니다. (이 때, 인자를 여러 개 받을 수 있습니다.)
slice(): 설정한 index대로 slicing한 결과를 반환합니다.
const animals = ['ant', 'bison', 'camel', 'duck', 'elephant'];
console.log(animals.slice(2));
// expected output: Array ["camel", "duck", "elephant"]
console.log(animals.slice(2, 4));
// expected output: Array ["camel", "duck"]
console.log(animals.slice(1, 5));
// expected output: Array ["bison", "camel", "duck", "elephant"]
console.log(animals.slice(-2));
// expected output: Array ["duck", "elephant"]
console.log(animals.slice(2, -1));
// expected output: Array ["camel", "duck"]
indexOf(): 인자로 오는 값이 array에서 몇 번째 index인지 찾아 반환합니다.
Reference
Codecademy - introduction to javascript
-
JavaScript - Function
Function of Javascript
Syntax
Syntax of declaring parameter and calling with argument
Parameter를 함수에 선언하는 문법은 다음과 같습니다.
그리고 다음과 같이 인자를 전달해 해당 함수를 호출합니다.
Default parameter
Javascript에서 default parameter는 ES6 버전에서 소개되었습니다. 함수에 default parameter를 설정해두면, 인자가 전달되지 않거나 data type이 undefined인 인자가 전달될 때 argument의 값으로 default parameter에 설정된 값이 오게 됩니다.
function greeting (dog = 'stranger dog') {
console.log(`Hello, ${dog}!`)
}
greeting('Welsh Corgi') // Output: Hello, Welsh Corgi!
greeting() // Output: Hello, stranger dog!
Return
보통의 언어들처럼 return 키워드를 사용해 함수의 결과를 반환합니다. 만일 어떤 값을 리턴하지 않으면, 기본적으로 undefined 값이 반환됩니다.
function greeting(name) {
let text = 'Bow wow, hello ' + name + '!';
}
console.log(greeting('lucian')) // Prints undefined
function greeting(name) {
let text = 'Bow wow, hello ' + name + '!';
return;
}
console.log(greeting('lucian')) // Prints undefined
Hoisting
Javascript는 함수가 선언된 곳 이전에서도 해당 함수를 호출할 수 있습니다. 이러한 Javascript의 특징을 hoisting이라고 부릅니다. 다만, 함수 선언 이전에 호출하는 것은 일반적으로 좋은 방법이 아니기 때문에, hoisting의 사용은 지양하는 것이 좋습니다.
greeting(); // Output: Hello, World!
function greeting() {
console.log('Hello, bow wow!');
}
함수 표현식 (Function Expression)
함수를 정의하는 또 다른 방법으로 함수 표현식이 있습니다. 보통의 함수 선언식과 달리 함수 표현식은 익명함수를 변수에 저장하는 방식으로 구현합니다.(ES6부터 보통 const 변수에 담습니다.) 익명함수는 식별자(이름)가 없는 함수를 말합니다. 함수 표현식의 기본 문법 예제는 다음과 같습니다.
함수 표현식으로 만든 함수는 변수의 이름을 사용해서 호출합니다.
variableName(argument1, argument2)
함수 표현식에서 또 한 가지 유의할 점은 hoisting이 적용되지 않는다는 것입니다. 함수 표현식은 항상 함수를 호출하기 전에 위치해야 합니다.
Arrow function
함수를 짧게 정의하도록 돕는 또 하나의 방법입니다. function 키워드를 쓰는 대신 =>를 써서 함수를 선언합니다. 다음은 arrow function의 syntax 예제입니다.
const greeting = (name) => {
let text = `Hi, ${name}. Bow wow!`
return text;
};
console.log(greeting('Lucian')); // Hi, Lucian. Bow wow!
Concise arrow function
Arrow function은 몇 가지 조건 하에서 더욱 간결해질 수 있습니다.
먼저, 함수의 parameter가 한 개라면, () 없이 parameter를 선언할 수 있습니다.
함수의 body가 single-line block일 경우, {}은 생략할 수 있습니다. {}이 없는 경우, 해당 body의 결과는 return 키워드에 상관없이 자동으로 반환됩니다.
Reference
Codecademy - introduction to javascript
함수 표현식 VS 함수 선언식
-
JavaScript - First step
First step of Javascript
출력
console 객체의 log 메서드를 사용해 콘솔에 출력합니다.
console.log("print out something");
주석 처리
Single line comment
// something comment
Multi-line comment
/* something comment */
Fundamental data types
Number: Any number, including numbers with decimals: 4, 8, 1516, 23.42.
String: Any grouping of characters on your keyboard (letters, numbers, spaces, symbols, etc.) surrounded by single quotes: ' ... ' or double quotes " ... ". Though we prefer single quotes. Some people like to think of string as a fancy word for text.
Boolean: This data type only has two possible values— either true or false (without quotes). It’s helpful to think of booleans as on and off switches or as the answers to a “yes” or “no” question.
Null: This data type represents the intentional absence of a value, and is represented by the keyword null (without quotes).
Undefined: This data type is denoted by the keyword undefined (without quotes). It also represents the absence of a value though it has a different use than null.
Symbol: A newer feature to the language, symbols are unique identifiers, useful in more complex coding. No need to worry about these for now.
Object: Collections of related data.
Object를 제외한 나머지 6개의 data types는 Primitive data type이라고 부릅니다.
Operator
Javascript에는 다음과 같은 산술 연산자들이 존재합니다.
Add: + (복합 대입 연산자는 +=)
Minus: - (복합 대입 연산자는 -=)
Multiply: * (복합 대입 연산자는 *=)
Divide: / (복합 대입 연산자는 /=)
Modulo: % (복합 대입 연산자는 %=)
Increment operator: ++ (+1을 함과 동시에 할당까지 진행)
Decrement operator: -- (-1을 함과 동시에 할당까지 진행)
비교 연산자는 ===를 제외하고는 다른 언어들과 비슷한 양상을 보입니다. 비교 대상은 Number 뿐만 아니라 String도 포함됩니다.
Less than: <
Greater than: >
Less than or equal to: <=
Greater than or equal to: >=
Is equal to: ===
Is not equal to: !==
논리 연산자는 다음과 같이 사용합니다.
And: &&
Or: ||
Not: !
변수 (Variable)
Javascript에서는 camel case가 변수명 convention으로 사용됩니다.
favoriteFood, numOfSlices, etc…
또한, 변수는 값을 꼭 할당할 필요 없이 선언만 할 수도 있습니다. 이렇게 선언만 한 경우, 해당 변수에는 자동적으로 undefined 값이 정의됩니다.
let price;
console.log(price); // Output: undefined
price = 350;
console.log(price); // Output: 350
Javascript에서는 변수를 선언하는 키워드의 종류로 var, let, const가 있습니다.
var: 새로운 변수를 생성할 수 있게 해주는 기본 키워드입니다. 2015년 등장한 ES6 버전 이전에 가장 많이 쓰였습니다.
let: 변수에 다른 값이 재할당될 수 있음을 의미하는 키워드입니다. ES6 버전에서 처음 등장했습니다.
let dog = 'Welsh Corgi';
console.log(dog); // Output: Welsh Corgi
dog = 'Poodle';
console.log(dog); // Output: Poodle
const: 변수에 다른 값이 재할당될 수 없음을 의마하는 키워드입니다. 실제로 다른 값을 재할당하면 TypeError가 발생합니다. 또한, 변수는 선언함과 동시에 값이 할당되어야 합니다. 선언만 할 경우 SyntaxError가 발생합니다. let과 마찬가지로 ES6 버전에서 처음 등장했습니다.
String concatenation
Javascript에서도 +를 사용해 string 간의 concatenation을 수행할 수 있습니다.
const favoriteAnimal = 'Welsh Corgi';
console.log('My favorite animal: ' + favoriteAnimal); // My favorite animal: Welsh Corgi
만일 data type이 String이 아닌 data와 concatenation을 할 경우, String type으로 auto converting 되어 정상적으로 concatenation됩니다.
const count = 3;
console.log('There are ' + count + ' Welsh Corgies!'); // There are 3 Welsh Corgies!
String interpolation
ES6 버전에서는 template literal을 사용해 변수를 string에 삽입하는 interpolation을 수행할 수 있습니다. Interpolation은 `` 를 사용해 표현하며, placeholder ${변수명}`를 사용해 변수를 삽입합니다. 이렇게 만든 template literal은 문자열로서 취급됩니다.
const myPet = 'Welsh Corgi';
console.log(`I own a pet ${myPet}.`); // I own a pet Welsh Corgi.
Interpolation은 코드의 가독성을 높이므로, 만들어질 string의 모습을 누구나 쉽게 알 수 있다는 장점이 있습니다.
Conditional statement
다음은 Javascript에 존재하는 몇 가지 조건문들의 문법입니다.
Syntax of If statement
if (condtion) {
codeblock
} else if (condition) {
codeblock
} else if (condition) {
codeblock
} else {
codeblock
}
Syntax of ternary operator
(condition) ? (codeblock when true) : (codeblock when false);
Syntax example of switch statement
let dog = 'Welsh Corgi';
switch (dog) {
case 'Golden Retriever':
console.log('Golden Retriever, bow wow!');
break;
case 'Dachshund':
console.log('Dachshund, bow wow!');
break;
case 'Welsh Corgi':
console.log('Welsh Corgi, bow wow!');
break;
default:
console.log('No correct dog but bow wow!');
break;
}
Reference
Codecademy - introduction to javascript
-
Secret key를 숨기는 통상적 방법
Github 같은 public한 장소에 프로젝트를 배포할 때, secret key같은 private한 정보들은 숨겨서 배포해야 합니다. 이를 위한 통상적인 방법은 환경변수를 이용하는 것입니다. 하나의 파일에 private한 정보들을 몰아놓으면, 프로젝트를 실행하기 전마다 해당 파일을 사용해 환경변수를 등록해둘 수 있고 아무일 없었듯이 프로젝트를 실행할 수 있습니다.
대표적으로 secret key를 숨기기 위해서는 secret key를 담을 secret_bash 파일, 등록된 secret key 환경변수를 가져올 settings.py파일, .gitignore 파일 총 3가지가 필요합니다. (secret_bash와 settings.py의 이름은 임의로 변경 가능합니다.)
아래는 임의의 Python 프로젝트 구조의 예시입니다.
my_super_project - app - main.py
| |_ settings.py
|_ .gitignore
|_ secret_bash
과정
해당 과정은 리눅스 기반에서 진행합니다. 사용되는 secret key는 PostgreSQL과 관련있는 예시입니다.
1. secret_bash 생성 및 설정
프로젝트의 최상위 디렉토리 밑에 secret_bash 파일을 생성하고 secret key 정보를 담습니다. 파일 내에 export 명령어를 사용하는 이유는 프로젝트를 실행하기 전마다 해당 파일을 실행해 secret key를 환경 변수로 등록할 수 있도록 하기 위함입니다.
2. settings.py 생성 및 설정
secret_bash에서 환경 변수를 export하면, 해당 환경 변수를 프로젝트로 가져올 수 있게 설정합니다. 이 과정에서 Python의 표준 라이브러리인 os 모듈을 사용합니다. os는 개발자가 간편하게 시스템적 접근을 할 수 있도록 도와주는 라이브러리로, os 라이브러리의 getenv를 사용해 등록된 환경변수를 가져옵니다. 이 때 해당 환경 변수가 존재하지 않는다면 getenv는 None을 반환하므로, 혹시나 오류가 나지 않게끔 ''(빈 문자열)을 기본값으로 지정해 반환하도록 만듭니다.
3. main,py에서 환경 변수 사용하기
현재 디렉토리에 존재하는 settings.py에서 환경 변수 값을 담았던 변수들을 import해 secret key가 필요한 곳에 사용합니다. 여기서는 PostgreSQL의 URL을 구성하기 위해 username이나 password 등을 환경 변수를 사용했습니다.
4. secret_bash 파일을 활용해 환경 변수 등록하기
앞 과정을 다 수행했다면 secret key가 잘 동작하는지 프로젝트를 실행해봐야 합니다.
프로젝트 실행 전, 터미널에서 source 명령을 사용해 secret_bash 파일의 설정 내용을 시스템에 적용합니다.
source secret_bash
5. .gitignore에 secret_bash를 등록하고 github에 배포하기
.gitignore는 github에 올리지 않고 싶은 것들을 설정해두는 파일입니다. .gitignore에 secret_bash를 등록해서 프로젝트를 배포할 때 secret_bash가 무시되도록 만듭니다. (.gitignore의 상세한 문법이 존재하나 여기서는 생략하겠습니다.)
그리고 Github에 프로젝트를 배포하면, secret key가 숨겨진 상태로 프로젝트가 배포됨을 확인할 수 있습니다.
-
-
데이터베이스
SQL Overview
SQL(Structured Query Language)은 데이터베이스에서 데이터를 저장, 조작 및 조회하기 위한 standard language입니다. Oracle, MySQL, Postgres 등의 다양한 데이터베이스에서 표준으로서 사용됩니다.
본 포스팅은 자세히보다는 가볍게 SQL 용법들을 정리하려고 합니다.
Demo DB
예시로 사용하는 DB는 Northwind 데이터베이스입니다. 해당 데이터베이스에는 여러 table이 존재하는데, 그중 Customers 데이터베이스는 다음 표와 같은 모습을 가집니다.
SQL basic
SQL 작성 순서
SELECT
FROM
WHERE
GROUP BY
HAVING
ORDER BY
SELECT
SELECT 구문은 데이터 조회를 위해 사용합니다.
Syntax
SELECT column1, column2, ...
FROM table_name;
만일, Customers테이블에서 CustomerName과 City column만 조회하고 싶다면 다음과 같이 쿼리를 만듭니다.
SELECT CustomerName, City FROM Customers;
특정 column이 아닌 전체 데이터를 조회하고 싶다면 *을 사용합니다.
SELECT * FROM table_name;
DISTINCT
SELECT DISTINCT를 사용하면 중복되는 데이터를 unique하게 조회할 수 있습니다.
Syntax
SELECT DISTINCT column1, column2, ...
FROM table_name;
만일, 고객들이 어떤 국적을 갖고 있는지만 파악하고 싶다면, SELECT DISTINCT로 City를 조회합니다.
SELECT DISTINCT Country FROM Customers;
위와 동일한 SQL 결과를 GROUP BY로도 만들 수 있습니다.
SELECT Country FROM Customers GROUP BY Country;
WHERE
Records를 특정 조건식으로 필터링하는데 사용합니다. SELECT 뿐만 아니라 UPDATE, DELETE 등의 명령어에서도 사용합니다.
Syntax
SELECT column1, column2, ...
FROM table_name
WHERE condition;
예를 들어, France 국적 고객들만 조회하고 싶다면 다음과 같이 사용합니다.
SELECT * FROM Customers WHERE Country='France';
WHERE의 조건식에는 AND, OR, NOT 논리 연산자와 더불어 다음과 같은 연산자들이 사용됩니다.
LIKE/NOT LIKE
또한, WHERE내에서는 column에서 Pattern을 찾아내는 LIKE 연산자도 사용할 수 있습니다.
LIKE 연산자와 함께 쓰이는 wildcard는 데이터베이스 종류마다 다를 수 있지만, 다음은 SQL Server에서 자주 쓰이는 대표적 wildcard의 예시입니다.
%: 0개 이상의 characters를 의미하는 기호입니다.
_: 1개의 character를 의미하는 기호입니다.
다음은 LIKE의 사용 예시들입니다.
IN/NOT IN
IN은 특정 값들에 해당하는 record만 남겨주는 연산자입니다. OR을 여러번 사용한 것과 동일한 기능을 할 수 있습니다.
Syntax 1
SELECT column_name(s)
FROM table_name
WHERE column_name IN (value1, value2, ...);
Syntax 2
SELECT column_name(s)
FROM table_name
WHERE column_name IN (SELECT STATEMENT);
BETWEEN/NOT BETWEEN
BETWEEN은 특정 범위 내의 값들을 선택합니다. 범위로 사용할 수 있는 값으로 숫자, 문자, 날짜 자료형이 있습니다. 또한, 설정한 시작 값과 끝 값은 모두 범위 내에 포함됩니다.
Syntax
SELECT column_name(s)
FROM table_name
WHERE column_name BETWEEN value1 AND value2;
ORDER BY
ORDER BY는 특정 column을 기준으로 정렬을 수행합니다. Default는 오름차순 정렬이고, 내림차순으로 정렬하고 싶다면 DESC 키워드를 뒤에 붙여줍니다.
Aggregate Function 사용도 가능합니다.
Syntax
SELECT column1, column2, ...
FROM table_name
ORDER BY column1, column2, ... ASC|DESC;
Country column을 기준으로 내림차순 정렬을 하고 싶다면 다음과 같이 쿼리를 만듭니다.
SELECT * FROM Customers ORDER BY Country DESC;
INSERT INTO
INSERT INTO는 새로운 records를 table에 추가합니다.
Syntax 1 - column을 특정지어 추가할 때 (value가 빈 column에는 null 값이 삽입됩니다.)
INSERT INTO table_name (column1, column2, column3, ...)
VALUES (value1, value2, value3, ...);
Syntax 2 - 모든 column에 value를 추가할 때 (value를 추가하는 순서는 해당 테이블의 column 이름 순서를 따릅니다.)
INSERT INTO table_name
VALUES (value1, value2, value3, ...);
CustomerID의 경우 record가 생성될 때 자동으로 입력되어지므로 신경쓰지 않아도 됩니다. 모든 열에 대해서 생략은 기본값을 사용한다는 의미로 해석됩니다.
혹은 명시적으로 DEFAULT를 값으로 넣어주면 지정한 기본값을 사용해 생성합니다.
IS NULL
NULL 값은 =, <, <> 같은 비교 연산자로 처리할 수 없습니다. 대신에, NULL 값은 IS NULL과 IS NOT NULL을 사용해 비교합니다.
UPDATE
UPDATE는 기존의 records를 수정할 때 사용합니다. 수정되는 record의 수는 WHERE의 조건식을 통해 정해집니다. 만일 WHERE가 빠지면 table의 데이터가 모두 갱신되므로, 유의해야 합니다.
Syntax
UPDATE table_name
SET column1 = value1, column2 = value2, ...
WHERE condition;
예를 들어, Customers의 테이블의 CustomerID가 1인 record에서 ContactName을 ‘Lucian’, City를 ‘Seoul’로 바꾸고 싶다면 다음의 쿼리를 만들면 됩니다.
UPDATE Customers
SET ContactName = 'Lucian', City= 'Seoul'
WHERE CustomerID = 1;
DELETE
기존의 records를 삭제할 때는 DELETE를 사용합니다. 삭제되는 record 수는 WHERE을 통해 정해집니다. 만일 WHERE가 빠지면 table의 데이터가 모두 삭제되므로, 유의해서 삭제해야 합니다.
Syntax
DELETE FROM table_name WHERE condition;
만일, CustomerName이 ‘Alfreds Futterkiste’인 record를 삭제하고 싶다면 다음과 같이 쿼리를 만듭니다.
DELETE FROM Customers WHERE CustomerName='Alfreds Futterkiste';
부분 조회
데이터베이스의 퍼포먼스를 위해 테이블의 records를 전부 조회하지 않고 일정 부분만 따로 조회하는 방법도 존재한다. 이를 위한 문법은 데이터베이스들마다 상이한데, MySQL은 LIMIT, SQL Server/MS Access는 SELECT TOP, Oracle은 FETCH를 사용한다.
Aggregate functions
다음 함수들은 특정 Column의 values를 원하는 목적으로 계산하여 return합니다.
(기본적으로 NULL을 제외하고 집계하며, COUNT 함수만 NULL을 포함한 전체 행 집계)
COUNT() Syntax
SELECT COUNT(column_name)
FROM table_name
WHERE condition;
MAX() Syntax
SELECT MAX(column_name)
FROM table_name
WHERE condition;
MIN() Syntax
SELECT MIN(column_name)
FROM table_name
WHERE condition;
AVG() Syntax
SELECT AVG(column_name)
FROM table_name
WHERE condition;
SUM() Syntax
SELECT SUM(column_name)
FROM table_name
WHERE condition;
Aliases
Table이나 column에 임의적으로 이름을 지어줄 수 있습니다. Alias는 해당 쿼리에 한해서만 유효합니다.
Column Syntax
SELECT column_name AS alias_name
FROM table_name;
Table Syntax
SELECT column_name(s)
FROM table_name AS alias_name;
만일 alias가 띄어쓰기를 포함한다면, double quotation(““)이나 square brackets([])를 사용해 감싸줍니다.
SELECT CustomerName AS Customer, ContactName AS [Contact Person]
FROM Customers;
다음과 같이 여러 개의 column을 합쳐 만든 새로운 column에 alias를 사용할 수도 있습니다.
SELECT CustomerName, Address + ', ' + PostalCode + ' ' + City + ', ' + Country AS Address
FROM Customers;
혹은, 길어지는 SQL 쿼리를 조금 더 짧게 쓰기 위해 table alias를 사용할 수도 있습니다.
SELECT o.OrderID, o.OrderDate, c.CustomerName
FROM Customers AS c, Orders AS o
WHERE c.CustomerName='Around the Horn' AND c.CustomerID=o.CustomerID;
JOIN
관련된 columns을 기준으로 두 개 이상의 table의 records를 합칩니다.
INNER JOIN: 대상 tables에서 ON의 조건에 match되는 모든 records를 가져옵니다.
FULL OUTER JOIN, FULL JOIN: 대상 tables에서 ON의 조건에 match되는 모든 records를 가져오고, 대상 tables에 남아있는 match되지 않은 records를 모두 가져옵니다. (이때, 빈 field는 NULL 값으로 채워서 가져옵니다)
LEFT JOIN: 왼쪽 table의 모든 records를 가져오고, 오른쪽 table에서 ON의 조건에 match되는 records를 붙입니다. (이 때, 빈 field는 NULL 값으로 채웁니다.)
RIGHT JOIN: 오른쪽 table의 모든 records를 가져오고, 왼쪽 table에서 ON의 조건에 match되는 records를 붙입니다. (이 때, 빈 field는 NULL 값으로 채웁니다.)
UNION
2개 이상의 SELECT 쿼리의 결과를 하나로 합쳐서 내어줍니다. UNION 사용 시, 모든 SELECT문들은 동일한 개수의 column을 동일한 순서대로 가져야 하며, 각 column의 데이터 타입도 비슷해야 합니다.
UNION syntax - unique values
SELECT column_name(s) FROM table1
UNION
SELECT column_name(s) FROM table2;
UNION ALL syntax - allow duplicated data
SELECT column_name(s) FROM table1
UNION ALL
SELECT column_name(s) FROM table2;
GROUP BY
데이터를 특정 칼럼을 기준으로 그룹화하여 그룹별로 구분할 때 사용합니다. GROUP BY는 aggregate functions와 함께 자주 쓰입니다.
Syntax
SELECT column_name(s)
FROM table_name
WHERE condition
GROUP BY column_name(s)
ORDER BY column_name(s);
HAVING
Aggregate function을 사용해 조건식을 사용하고 싶을 때 HAVING을 사용합니다. WHERE 구문에서는 aggregate function을 사용할 수 없기 때문에 보통 GROUP BY 함께 사용됩니다.
Syntax
SELECT column_name(s)
FROM table_name
WHERE condition
GROUP BY column_name(s)
HAVING condition
ORDER BY column_name(s);
EXISTS
서브 쿼리가 반환하는 records가 1개 이상이면 True를 0개면 False를 반환합니다.
Syntax
SELECT column_name(s)
FROM table_name
WHERE EXISTS
(SELECT column_name FROM table_name WHERE condition);
만일, suppliers 중 가격이 20 미만인 product를 팔고 있는 사람이 누구인지 파악하고 싶다면 다음과 같이 사용합니다.
SELECT SupplierName
FROM Suppliers
WHERE EXISTS (SELECT ProductName FROM Products WHERE Products.SupplierID = Suppliers.supplierID AND Price < 20);
EXISTS, IN, JOIN의 속도 차이
EXIST는 데이터의 존재 여부만 파악한 후, 더이상 수행되지 않습니다. 하지만, IN은 실제로 존재하는 데이터들의 모든 값까지 비교하기 때문에 EXISTS보다 느린 경우가 많습니다. JOIN은 일반적으로 EXISTS보다 빠르지만, 중복된 값이 많을 경우 EXISTS가 더 빠르다고 알려져 있습니다.
ANY, ALL
ANY는 서브 쿼리에 주로 쓰이며, 서브 쿼리의 값 중 하나라도 조건식에 맞는다면 True를 return합니다.
Syntax
SELECT column_name(s)
FROM table_name
WHERE column_name operator ANY
(SELECT column_name
FROM table_name
WHERE condition);
반면에 ALL의 경우, 서브 쿼리의 값들이 모두 조건식에 만족되어야만 True를 return합니다. 보통, SELECT, WHERE 혹은 HAVING과 함께 많이 쓰입니다.
Syntax
SELECT column_name(s)
FROM table_name
WHERE column_name operator ALL
(SELECT column_name
FROM table_name
WHERE condition);
Operator used in ALL, ANY
ALL, ANY syntax에 나오는 operator는 =, <>, !=, >, >=, <, <= 등의 비교 연산자입니다.
SELECT INTO
한 table의 데이터를 새로운 table에 복사하여 저장합니다.
Syntax
SELECT column1, column2, column3, ...
INTO newtable [IN externaldb]
FROM oldtable
WHERE condition;
만일, 데이터 없이 table의 schema만 복사해 가져오고 싶다면 다음과 같이 쓸 수도 있습니다.
SELECT * INTO newtable
FROM oldtable
WHERE 1 = 0;
INSERT INTO SELECT
특정 table의 데이터를 복사해 다른 table에 삽입합니다. 복사한 데이터의 타입은 삽입할 테이블 내 column의 데이터 타입과 일치해야 합니다.
Syntax - All columns
INSERT INTO table2
SELECT * FROM table1
WHERE condition;
Syntax - Specify columns
INSERT INTO table2 (column1, column2, column3, ...)
SELECT column1, column2, column3, ...
FROM table1
WHERE condition;
CASE
If… Else… 구문처럼, SQL에서도 조건에 따라 값을 return할 수 있습니다. 조건식이 true인 경우를 만나면 이후 조건은 읽지 않고 값을 return하며, true인 조건이 없으면 ELSE의 값을 return합니다. 만일 조건이 모두 false인데 ELSE가 없다면, NULL 값을 return합니다.
Syntax
CASE
WHEN condition1 THEN result1
WHEN condition2 THEN result2
WHEN conditionN THEN resultN
ELSE result
END;
NULL function
만일 NULL 값이 나와서는 안되는 상황이라면, column의 NULL 값 대신 함수를 통해 대체 값을 return해줄 수 있습니다. 다만, NULL function의 이름은 IFNULL(), ISNULL(), COALESCE(), NVL() 등으로 데이터베이스마다 상이합니다.
Stored Procedure
자주 사용하는 SQL 코드를 stored procedure로 미리 저장해두고 재사용할 수 있습니다. Parameter를 여러개 설정하여 사용할 수도 있습니다.
Syntax - Save
CREATE PROCEDURE procedure_name
AS
sql_statement
GO;
Syntax - Execution
EXEC procedure_name;
Comment
--: single line comment
/*, */: multi line comment
Database관련 SQL
데이터베이스 자체를 조작하는 것과 관련된 SQL 문법에 대해 살펴봅니다.
CREATE
새로운 SQL 데이터베이스를 생성합니다. 다만, 데이터베이스를 생성할 때는 관리자 권한을 얻어야 합니다.
Syntax
CREATE DATABASE databasename;
존재하는 데이터베이스 리스트를 확인하고 싶다면 SHOW DATABASES를 사용합니다.
DROP
기존에 존재하는 SQL 데이터베이스를 삭제합니다. 역시, 데이터베이스를 생성할 때는 관리자 권한을 얻어야 합니다.
Syntax
DROP DATABASE databasename;
BACKUP
기존에 존재하는 SQL 데이터베이스를 다른 위치에 Backup시킵니다. 다음 SQL은 SQL server에서 적용되는 문법입니다.
Full backup syntax
다른 디스크 주소에 데이터베이스를 백업하는 것이 안전합니다.
BACKUP DATABASE databasename
TO DISK = 'filepath';
Differential backup syntax
이전 버전의 backup에서 변화된 부분만 backup합니다. 덕분에, Backup time을 줄일 수 있습니다.
BACKUP DATABASE databasename
TO DISK = 'filepath'
WITH DIFFERENTIAL;
CREATE TABLE
데이터베이스에 새로운 table을 생성합니다.
Syntax
CREATE TABLE table_name (
column1 datatype,
column2 datatype,
column3 datatype,
....
);
Column parameter에는 해당 column에 설정할 이름을, datatype parameter에는 varchar, integer, date 등의 타입을 명시합니다.
다음은 Persons 테이블을 만드는 예시입니다.
CREATE TABLE Persons (
PersonID int,
LastName varchar(255),
FirstName varchar(255),
Address varchar(255),
City varchar(255)
);
CREATE TABLE에 SELECT를 결합해 사용하면 기존의 테이블에서 원하는 부분을 복사하여 새 테이블을 만들 수도 있습니다.
Syntax
CREATE TABLE new_table_name AS
SELECT column1, column2,...
FROM existing_table_name
WHERE ....;
DROP TABLE
기존에 존재하는 table을 삭제합니다.
Syntax
DROP TABLE table_name;
만일 table 자체는 남겨두고 table 내의 데이터만 전부 삭제하고 싶다면 TRUNCATE을 사용합니다.
Syntax
TRUNCATE TABLE table_name;
ALTER TABLE
기존 table에 새로운 column을 추가하거나 이미 존재하는 column을 수정 및 삭제할 수 있습니다.
ADD column syntax
ALTER TABLE table_name
ADD column_name datatype;
DROP column syntax
ALTER TABLE table_name
DROP COLUMN column_name;
ALTER/MODIFY column syntax
Column의 datatype을 수정합니다. SQL Server / MS Access는 ALTER를 사용합니다. MODIFY를 사용하는 데이터베이스도 다수 존재합니다.
ALTER TABLE table_name
ALTER COLUMN column_name datatype;
SQL Constraints
Table에 삽입될 수 있는 데이터에 대해 규칙을 정하는 키워드입니다. Table에 들어갈 데이터의 타입을 제한할 수 있어 데이터의 accuracy와 reliability를 높입니다. Constraint의 적용 범위는 column level 혹은 table level이 될 수 있습니다.
Constraints는 CREATE TABLE로 table을 생성할 시 정해주거나 ALTER TABLE로 변경 및 삭제할 수 있습니다. Syntax는 대체로 밑의 형태를 따르지만 데이터베이스마다 상이합니다.
CREATE TABLE Syntax
CREATE TABLE table_name (
column1 datatype constraint,
column2 datatype constraint,
column3 datatype constraint,
....
);
ALTER TABLE ADD syntax
ALTER TABLE table_name
ADD constraint (column1, column2...);
ALTER TABLE DROP syntax
ALTER TABLE table_name
DROP constraint;
Constraint에 이름을 짓고 여러 columns에 한번에 적용하고 싶다면 다음과 같은 방법도 있습니다. (ALTER TABLE에서도 적용됩니다.)
CREATE TABLE Persons (
ID int NOT NULL,
LastName varchar(255) NOT NULL,
FirstName varchar(255),
Age int,
CONSTRAINT UC_Person UNIQUE (ID,LastName)
);
Constraint 종류
NOT NULL: 특정 column에 NULL 값이 존재하지 않게끔 강제합니다.
UNIQUE: 특정 column에 같은 값이 존재하지 않게끔 강제합니다.
PRIMARY KEY: 각각의 record를 고유하게 구분해줍니다. NOT NULL과 UNIQUE를 자동으로 보장하며 table에 오직 1개만 존재할 수 있습니다.
FOREIGN KEY: Table들 사이의 연결을 유지시켜줍니다. FOREIGN KEY는 table 내에 존재하는 하나의 필드이며, 다른 table에서는 PRIMARY KEY로 사용됩니다. FOREIGN KEY가 있는 table을 child table, FOREIGN KEY가 가리키는 PRIMARY KEY가 위치한 table을 parent table 혹은 referenced table이라고 합니다.
CHECK: 특정 column에 들어갈 value의 범위를 제한합니다. 조건식을 활용해 원하는 constrain을 걸 수 있습니다.
DEFAULT: 특정 column에 대하여 기본값을 지정해줍니다.
INDEX: 특정 column에 index를 부여할 수 있습니다. 처음 index를 부여할 때 table 생성 시간은 조금 걸릴 수 있지만, 이후 빠른 데이터 탐색이 가능합니다. INDEX constrain을 부여하는 문법은 다른 constraint와 조금 상이합니다.
Syntax - allow duplicated data
CREATE INDEX index_name
ON table_name (column1, column2, ...);
Syntax - not allow duplicated data
CREATE UNIQUE INDEX index_name
ON table_name (column1, column2, ...);
AUTO_INCREMENT: 새로운 record가 만들어질 때마다 값이 자동으로 1씩 증가하여 채워지는 필드를 설정합니다. 처음 시작 기본값은 1로 설정되어 있지만 변경 가능합니다.
VIEW
View란 SQL 쿼리 결과를 기반으로 만드는 가상 table을 의미합니다. View의 데이터는 그 자체로 실제 존재하는 것은 아니고 기존의 데이터를 어떻게 보여줄지 정의한 것입니다. 따라서, 여러 테이블로부터 가져온 데이터들을 마치 원래부터 하나의 table이었던 것처럼 보여줄 수 있습니다. 또한 기존의 데이터를 보기 좋게 가져오는 것이기 때문에, view의 데이터는 쿼리할 때마다 최신 데이터로 보여집니다.
장점
복잡한 SELECT 문을 일일이 매번 기술할 필요가 없음
필요한 열과 행만 사용자에게 보여줄 수 있고, 갱신도 뷰 정의에 따른 갱신으로 한정할 수 있음
데이터 저장 없이 실현되고, 뷰를 제거해도 참조 테이블은 영향 받지 않음
Create syntax
CREATE VIEW view_name AS
SELECT column1, column2, ...
FROM table_name
WHERE condition;
Update syntax
CREATE OR REPLACE VIEW view_name AS
SELECT column1, column2, ...
FROM table_name
WHERE condition;
Drop syntax
DROP VIEW view_name;
SQL Injection
만일 유저에게 ID 같은 input을 받아 앞에서 보았던 SQL 쿼리들을 사용한다면, 해커들의 위협에 쉽게 노출될 수 있습니다. 해커들은 교묘하게 SQL 문을 조작할 수 있는 형태로 input을 보내, 데이터베이스의 모든 records를 탈취할 수 있기 때문입니다. 이를 안전하게 처리하기 위해 SQL parameters를 사용할 수 있습니다.
txtNam = getRequestString("CustomerName");
txtAdd = getRequestString("Address");
txtCit = getRequestString("City");
txtSQL = "INSERT INTO Customers (CustomerName,Address,City) Values(@0,@1,@2)";
db.Execute(txtSQL,txtNam,txtAdd,txtCit);
위와 같이 @를 사용한 부분은 parameter가 되어 데이터를 input으로 받을 수 있습니다. Execute로 인자들을 SQL 쿼리로 넘겨 실행하면, 보다 안전하게 쿼리를 처리할 수 있습니다.
NULL
NULL은 불명(Unknown), 적용불가(N/A, Not Aplicable)를 나타내기 위해 사용합니다. DBMS 세계에서는 NULL 사용을 권장하지 않습니다. (NOT NULL) 특히, 다음 2가지 문제로 인해 지양합니다.
SQL 코딩 시 인간의 직감에 반하는 3개의 논리값을 고려하게 된다.
true, false 외에 추가로 NULL 고려하는 것이 불편
사칙연산 또는 SQL 함수 인수에 NULL이 포함되면 NULL 전파가 일어난다.
IS NULL이 아닌 = NULL을 사용하면 원치 않는 결과가 나올 수 있음
Scalar 값
SELECT 문은 일반적으로 열과 행으로 구성된 테이블 형식입니다. 스칼라 값(단일값)은 SELECT 결과의 특수한 형태로 하나의 열과 하나의 행으로 구성된 테이블을 의미합니다.
이러한 스칼라 값은 마치 데이터나 수치처럼 취급해 조건문에 이용할 수 있습니다.
Reference
w3school - SQL
SQL 뷰(view) 소개
SQL에서 연관 서브쿼리 연산자 EXISTS 활용하기
Mysql Exists와 IN절 설명과 차이점
-
블록체인 Overview
블록체인(Blockchain)의 개념
공식적 정의
Blockchain Security Technology라고 하며, 데이터를 담은 블록을 체인 형태로 연결한 다음 동시에 수많은 컴퓨터에 복제와 저장을 하는 분산형 데이터 저장 기술을 말합니다. 중앙 서버에 거래 내역을 저장하지 않고 거래를 할 때마다 수많은 거래 참여자들에게 거래 내역을 공유하기 때문에 위조, 변조가 거의 불가능합니다.
특징
Append(추가): 블록체인에서 블록은 오직 추가만 할 수 있습니다.
Decentralization(탈중앙화): 특정 개인이 블록체인 DB를 관리할 수 없습니다.
쉬운 개념 설명
블록체인은 일종의 데이터베이스로, 각각의 블록은 데이터로 생각할 수 있습니다.
특별한 점은 데이터를 추가(Append)만 할 수 있다는 것입니다. 블록체인 DB에 연결된 블록 데이터는 편집 및 삭제가 불가능합니다. 대학교 학위, 정부 단위에서 운전면허증, 지원금 사용 내역, 혹은 개인의 전세 계약서 등 안전한 보관이 필요한 영역에서 데이터를 기록하는데 활용될 수 있습니다.
또한, 블록체인 참여자 모두가 DB의 복제본을 가지고 있어서(Decentralization) 특정 개인이 DB를 관리할 수 없습니다. 특정 개인이 갑자기 100억원어치 비트코인을 가지고 있다고 DB를 조작해도 다른 사람들의 블록체인 사본과 비교하면 쉽게 거짓이 감별됩니다. 이로 인해, 나아가 정부의 독단적인 감시나 통제에도 공동으로 대응할 수 있습니다. 이 검증이 확실한 동작하는 이유는 너무나 많은 사람들이 비트코인 노드를 돌리고 있어 모든 컴퓨터가 일제히 종료되어 있지 않는한 공동의 감시를 벗어나기 어렵기 때문입니다. 현실적으로 모두의 컴퓨터가 동시에 종료되는 일은 불가능합니다.
블록체인 생성 과정
블록의 형태
블록은 정보를 DB에 추가하는 방법입니다. 그리고 하나의 블록은 block hash, 이전 블록의 block hash, block data로 구성됩니다.
먼저 block data에는 해당 영역의 data가 담깁니다. 비트코인의 경우 거래 내역(transaction) 데이터가 해당됩니다. 그리고 현재 블록의 block hash에는 이전 block의 hash 값과 지금의 데이터를 함께 해시한 값이 담깁니다. 이렇게 해시를 통해 블록을 쌓아가면, 블록체인에 어떤 작은 조작만 가해도 결과로 나올 해시값이 크게 달라지기 때문에 보안이 안전하게 유지됩니다.
해시(Hash)란?
어떤 데이터를 특정 알고리즘을 사용해 고정된 길이를 가진 기묘한 모습의 데이터로 변환시킨 값을 말합니다. 해시를 사용하면 Input 데이터로 Output 데이터를 만들 수 있지만, 만들어진 Output data를 Input data로 되돌릴 수는 없습니다.
채굴자(Miner)의 역할
비트코인의 경우에는 10분마다 블록이 생성되어 블록체인에 추가됩니다. 그렇다면 블록에는 어떠한 데이터가 담겨야 하고, 이 블록을 생성하는 사람은 누구일까요?
우선 블록체인에 추가될 데이터는 상황에 따라 누구든지 올릴 수 있게 하기도, 아무나 올릴 수 없게 하기도 합니다. 아무나 올릴 수 없게 하는 경우는 안전과 정확함이 중요한 금융 관련 데이터, 정부 관련 데이터 등이 해당될 것입니다. 이러한 경우, 데이터에게 요구되는 속성은 진실성(Truth)입니다. 블록체인에 추가될 데이터는 거짓 없는 진실이어야 합니다. 그리고 데이터의 진실성 검증은 작업 증명(Proof of Work)을 통해 이뤄집니다. 작업 증명은 전 세계 채굴자(Miner)들에 의해 진행됩니다.
채굴(Mining)이란 주어진 데이터를 작업 증명하여 블록을 생성 및 추가한 후, 이에 대한 보상으로 해당 거래에 대한 수수료와 암호화폐(Cryptocurrency)를 받아가는 작업을 말합니다. 조금 더 자세히 얘기하면, 작업 증명이란 네트워크가 내는 어떠한 문제를 해결하는 것이고 이를 완료하면 이에 대한 보상이 지급됩니다. 채굴 작업은 전 세계 누구든지 참여할 수 있으나 해결해야 할 문제의 난이도는 시간이 지날수록 더욱 높아집니다.
채굴자의 작업은 단순합니다. 채굴자는 오직 Nonce라는 파라미터만 조작할 수 있고, 문제에서 원하는 해시값을 생성해내는 Nonce를 찾아 네트워크에게 알려주면 됩니다. 예를 들어, 해시값이 3개의 0으로 시작하기 위해서 어떠한 Nonce를 사용해야 하는지가 문제로 제시되면, 채굴자는 Nonce 값을 일일이 넣어보면서 해시값을 생성하고 비교해 완전 탐색으로 Nonce 값을 찾아냅니다.
문제를 푼 채굴자들은 블록을 생성해 해당 블록체인에 추가하고, 작업 증명에 대한 보상으로 해당 거래에 대한 수수료와 코인을 받아갑니다.
암호화폐(Cryptocurrency)
암호화폐는 채굴자들의 작업 증명 보상으로서 주어집니다. 이 때, 암호화폐는 채굴자의 작업 증명과 함께 새로 생성됩니다. 예를 들어 비트코인은 채굴자가 작업 증명을 함과 동시에 생성되어 채굴자의 지갑으로 지급됩니다.
암호화폐들은 생산량이 제한 되어 있는 경우에 가치가 상승합니다. 비트코인의 경우, 2100만개로 생산량이 한정되어 있어 희소성을 가집니다. 다만, 한정된 생산량에 너무 빨리 도달하지 않게끔 하기 위해 생산이 너무 빨리 진행되면 문제의 난이도를 올리는 등의 조정을 취합니다. (현재 난이도는 19이며, 생성하는 해시값이 19개의 0으로 시작해야 합니다.) 특히, 4년마다 한 번씩 반감기라는 것을 가지면서, 처음엔 작업 증명으로 50개씩 발급되었던 비트코인이 25, 12.5를 거쳐 현재는 6.25개씩 지급되고 있습니다.
그래픽카드(Graphic Card)
최근 그래픽 카드의 값이 급등하는 이유는 채굴 작업과 관련이 깊습니다. 그래픽 카드는 Nonce 탐색을 매우 빠르게 진행하기 때문입니다. 대략 6천만 Nonce를 1초만에 계산한다고 하니, 채굴자들은 그래픽 카드를 가능한한 총동원해 코인 채굴에 사용하길 원합니다.
Smart Contract
블록체인의 공유 네트워크를 활용해서 개인의 코드를 모두가 공유, 검증, 실행하지만 수정은 할 수 없는 백엔드에 올리는 작업을 말합니다. 예를 들어, Airbnb같이 집을 중개해주는 서비스 도움 없이 개인이 직접 도어록 같은 센서에 기반한 코드(봇)를 만들고, 이 코드를 통해 서로 금액을 교환할 수 있습니다. 덕분에 개인과 개인 사이에 중개인이 필요없어지고 정부와 단체에 조종당할 염려가 사라집니다. 다만, 네트워크 외부의 IoT 센서 혹은 무언가에게 의존해야하는 신뢰기반 거래가 형성되어 버려서, 센서가 해킹당했을 때의 위험성이 단점으로 존재합니다. 스마트 컨트랙트를 지원하는 블록체인은 Ethereum, Polcadot, Cardano, Kusama 등 다양합니다.
대체 불가능한 토큰 (NFT, Non Fungible Token)
Non Fungible이란 땅, 포켓몬 카드, 한정판 신발 등 대체 불가능한 것들을 말합니다. 즉, NFT는 어떠한 블록체인 기술을 기반으로 이미지, 영상 등의 콘텐츠에 고유한 표식을 부여하는 디지털 자산입니다. 이러한 표식은 해당 자산이 원본임을 증명해줍니다. 이로 인해, 디지털 재산권 개념과 함께 디지털 자산에 대한 소유권 주장이 가능해집니다.
DeFi
탈중앙화된 금융 서비스를 의미하며, 중간 거래자나 서드파티없이 스마트 컨트랙트로 모든 거래가 오고 가는 형태를 띕니다. 사람의 손을 전혀 거치지 않고 100% 코드로 돌아가기 때문에, 중간에서 중개 수수료가 지불되거나 관리자에 의해 계좌가 동결될 일이 없습니다.
Reference
블록체인. 개발자가 쉽게 설명해드림. - 노마드 코더
BlockChain 기초 개념 - 블록, 채굴, 작업증명, 난이도, 보상
미국 기자들 ‘교과서’ “암호화폐 ‘크립토’로 줄이지 말라”
-
9-2. 가상 메모리
Page replacement - 다양한 캐싱 환경
캐싱 기법
한정된 빠른 공간(=캐쉬)에 요청된 데이터를 저장해 두었다가 후속 요청시 캐쉬로부터 직접 서비스하는 방식이다. 즉, 한 번 썼던 데이터는 빠른 접근이 가능한 캐쉬 메모리에 저장해두었다가 가까운 시기에 해당 데이터에 대한 접근이 요청되면 빠르게 제공해준다. Paging system과 더불어 cache memory, buffer caching, Web caching 등 다양한 분야에서 사용되는 방식이다.
캐싱 기법의 운영상 시간 제약
다만, 이러한 캐싱 기법은 운영상 시간 제약이 존재한다. 교체 알고리즘이 삭제할 항목을 결정하는 일에 지나치게 많은 시간을 소요하지 않아야 한다. 예를 들어, Buffer caching이나 Web caching의 경우 시간 복잡도가 O(1) ~ O(logN) 정도까지 허용한다.
반면, Paging system에서는 기존의 LRU, LFU 등의 삭제 항목 결정 알고리즘이 실제로 사용되기는 어렵다. Paging system의 경우 page fault가 생길 때만 OS가 관여하기 때문에, 페이지가 이미 메모리에 존재하는 상황에서의 참조 시각 정보는 OS가 알 수 없다. 즉, 특정 상황의 참조 시각과 참조 빈도 등을 알 수 없으므로, 앞에서 살펴봤던 LRU, LFU 등의 알고리즘은 실제 시스템에서는 사용되기 어렵다.
Clock Algorithm (=Second Chance Algorithm)
캐싱 제약을 극복하기 위해, paging system에서는 일반적으로 Clock Algorithm이 쓰인다. 이 알고리즘에서는 각각의 page table entry에 최근에 참조함을 나타내는 reference bit을 둔다. 그리고 이미 메모리에 올라와 있는 페이지에 대해 참조가 일어날 경우, 하드웨어가 reference bit을 1로 바꿔 최근에 참조함을 기록한다. 그리고 메모리에 새로운 page를 올려할 상황이라 OS가 내쫒을 page를 결정할 때에는 위와 같은 과정으로 reference bit을 참고해 오래된 page를 내쫒는다.
또한, 조금 더 개선된 성능을 위해 modified bit을 둬서 page에 write가 일어났는지 여부를 기록한다. 만일, modified bit이 1인 page가 있다면 해당 페이지는 메모리에 올라와서 최근에 내용이 변경된 것이기 때문에, backing storage로 쫒아낼 때 변경된 내용을 반영하고 쫒아내야 한다.
Page Frame의 Allocation
지금까지는 페이지가 어떤 프로세스에 속하느냐를 구체적으로 고려하지 않고 작업을 수행했다. 하지만, 각 프로세스마다 얼마만큼의 page frame을 할당한 것인가는 중요한 문제이다. 메모리 참조 명령어 수행시 명령어, 데이터 등 여러 페이지를 동시에 참조하게 되는데, 이 명령어 수행을 위해 최소한 할당되어야 하는 frame 수가 있기 때문이다. 예를 들어, 반복문을 구성하는 page가 3개라고 한다면, 3개가 한번에 할당되는 것이 좋다. 2개가 할당된다면, 매 loop마다 page fault가 일어나 원활한 수행에 방해가 된다.
3가지 Allocation 방법 (∝ Local repacement)
Equal Allocation: 모든 프로세스에 똑같은 갯수 할당
Proportional Allocation: 프로세스 크기에 비례하여 할당
Priority Allocation: 프로세스의 priority에 따라 다르게 할당
Global replacement VS Local replacement
Global replacement는 따로 프로세스마다 할당되어야할 frame 개수를 정해놓지 않더라도 알고리즘을 수행하다보면 알아서 필요한 프로세스에 page가 더 많이 할당되는 것을 말한다. 반면에, Local replacement는 프로세스마다 할당할 page 개수를 정해둔 것을 말한다.
Thrashing
프로세스의 원활한 수행에 필요한 최소한의 page frame 수를 할당받지 못한 경우 발생한다. 위 그래프와 같이, 메모리에 동시에 올라온 프로세스 개수가 많아질수록, 특정 순간에 CPU 이용률이 급감해버리는 thrashing 현상이 발생한다. 보통 위와 같은 과정을 거쳐 thrashing으로 이어진다. 이를 해결하기 위해 두 가지 알고리즘을 소개한다.
Working-Set Algorithm VS PFF (Page-Fault Frequency) Scheme (∝ Global repacement)
Reference
운영체제, 이화여대 반효경 교수님
-
9-1. 가상 메모리
이번 챕터부터는 메모리 관리 기법 중 paging 기법을 사용하는 것을 가정한다. 실제로도 대부분의 시스템은 paging 기법을 채택하고 있다.
Demand Paging
실제로 특정 page에 대한 요청이 있을 때 해당 page를 메모리에 올리는 것을 말한다. 프로그램에는 안정적인 실행을 위해 방어적으로 넣은 자주 쓰이지 않는 코드 영역들이 매우 많이 존재한다. 그렇기에 실제로 쓰이는 코드들만 메모리에 올리면, I/O 양과 메모리 사용량을 크게 감소시킬 수 있고 더 많은 사용자들이 멀티 프로세싱할 수 있는 환경이 만들어진다.
Demand Paging에서 페이지 테이블의 entry에 존재하는 valid/invalid bit의 역할을 살펴보자. Invalid는 주소 영역에서 사용되지 않는 부분이나 페이지가 물리적 메모리에 올라와 있지 않은 상황을 의미한다. 처음에는 모든 page entry가 invalid로 초기화되어 있다. 그리고 주소 변환시 해당 페이지가 invalid로 세팅되어 있다면, page fault를 일으킴과 동시에 trap을 걸어 운영체제에게 CPU를 넘기고 page fault가 난 페이지를 메모리에 올리게끔 한다. Page fault에 대한 처리 루틴은 운영체제에 정의되어 있으며, 구체적으로는 위 그림과 같은 과정을 거친다.
Backing storage에서 메모리에 페이지를 올리는 디스크 I/O는 시간이 오래걸리기 때문에, page fault가 얼마나 나느냐에 따라 메모리 접근 시간에 차이가 날 수 있다. 위의 Effective Access Time에서 p는 보통 굉장히 작아서 대부분의 경우 page fault가 나지 않는다. 하지만, 적은 확률로 page fault가 나는 상황에서는 위의 붉은 글씨의 요인들처럼 큰 시간적 오버헤드가 발생함을 유의한다.
Page replacement
메모리에 여유 공간이 필요할 때, 운영체제가 어떤 frame을 빼앗아서 page를 쫒아낼지 결정하는 것을 Page replacement라고 한다. 이것을 구현하는 알고리즘을 Replacement Algorithm이라고 하는데, page fault rate을 최소화하는 방향으로 page를 쫒아내도록 알고리즘을 잘 설정해야 한다.
Optimal Algorithm (실제로 쓰이진 않음)
Optimal Algorithm은 가장 먼 미래에 참조되는 page를 replace하는 방식으로 Page fault를 최소화하는 알고리즘이다. 위 예시처럼, 미래의 page 참조를 전부 안다고 가정하고 진행하기 때문에 실제로 시스템에서 쓰이진 않지만, 가장 최고의 성능을 나타내는 지표로서 다른 알고리즘들의 성능에 대한 upper bound를 제공한다.
FIFO Algorithm (실제로 쓰임)
간단하게 먼저 올라온 page를 먼저 쫒아내는 방식이다. 특이한 점은 frame 수를 늘리면 성능이 좋아져야 할 것 같지만, FIFO 알고리즘에서는 오히려 성능이 떨어지는 현상이 발생하는데, 이를 FIFO Anomaly 혹은 Belady’s Anomaly라고 부른다.
LRU (Least Recently Used) Algorithm (실제로 쓰임)
LRU(Least Recently Used) 알고리즘은 참조의 측면에서 가장 오래 전에 참조된 page를 쫒아내는 방법이다. 얼핏보면 FIFO와 비슷하지만, FIFO보다 효율적으로 동작하여 더 많이 쓰이는 알고리즘이다.
LFU (Least Frequently Used) Algorithm (실제로 쓰임)
LFU(Least Frequently Used) 알고리즘은 참조 횟수가 가장 적은 page를 쫒아내는 방법이다. 동일한 참조 횟수를 기록 중인 page가 여럿 있을 때는 일반적으로 큰 의미를 두지 않고 알고리즘이 임의로 쫒아낸다. 다만, 그 중에서도 가장 오래 전에 참조된 page를 쫒아내도록 알고리즘을 설계하는 것이 성능 향상에 이로울 수 있다.
LRU VS LFU
LRU는 참조 시점의 최근성을 반영한다. 반면에 LFU는 장기적인 측면에서 page의 인기도를 더 정확히 반영하는 장점이 있다. 다만, LFU는 LRU보다 구현이 복잡하다.
LRU의 경우는 시간에 따라 일렬로 줄 세우고 가장 최근에 참조했던 페이지를 내쫒으면 된다. 따라서, Linked List 자료구조로 구현하여, 페이지를 내쫒는 작업을 O(1) 시간 복잡도로 수행하게끔 한다. 반면에, LFU는 페이지의 참조 빈도가 계속 바뀌기 때문에, heap 자료구조를 사용하여 지속적으로 정렬하는 방법을 사용한다. 이 경우 페이지를 내쫒는 작업은 O(log n)의 시간 복잡도로 수행된다.
Reference
운영체제, 이화여대 반효경 교수님
-
8-2. 메모리 관리
Allocation of Physical Memory
메모리는 일반적으로 Interrupt vector와 함께 낮은 주소 영역을 사용하는 OS 상주 영역과 높은 주소 영역을 사용하는 사용자 프로세스 영역 둘로 나뉜다.
사용자 프로세스 영역의 할당 방법
1. Contiguous allocation (연속 할당)
각각의 프로세스가 메모리의 연속적인 공간에 적재되도록 하는 것이다. 연속 할당 방식은 두 가지가 존재한다.
고정 분할 방식
프로그램이 들어갈 사용자 메모리 영역을 미리 파티션(partition)으로 나눠놓는 것을 말한다. 이 경우, 동시에 메모리에 load되는 프로그램의 수가 제한되고 최대 수행 가능 프로그램 크기도 제한된다. 위 그림을 예시로 보면 메모리 영역은 이미 고정되어 나뉘어져 있고 프로그램 A와 B는 각각 자신의 크기에 맞는 파티션을 찾아 그 위에서 실행된다. 이 과정에서 프로그램을 담을만큼 충분한 용량을 가지지 못해 남겨진 메모리 영역을 의미하는 외부 조각과 파티션에서 프로그램이 실행되고 남은 메모리 영역을 의미하는 내부 조각이 발생한다.
가변 분할 방식
사용자 메모리 영역을 미리 나눠놓지 않는 방법을 말한다. 가변 분할 방식은 프로그램의 크기를 고려해 프로그램들을 차곡차곡 메모리 영역에 할당한다. 이 때, 앞서 실행된 프로그램이 종료되거나 새로운 프로그램이 실행됨에 따라 남겨져버는 메모리 영역, 즉 외부조각이 발생할 수 있다. 이 가용 메모리 공간을 Hole이라고 하는데, 운영체제는 할당 공간과 흩어져 있는 가용 공간(hole)을 잘 고려해서 프로그램의 실행을 매끄럽게 도와야 한다. 한편, 가변 분할 방식에서는 미리 정해진 파티션이 없기 때문에 내부 조각은 발생하지 않는다.
Dynamic Storage Allocation Problem
가변 분할 방식에서 size n인 요청을 만족하는 가장 적절한 hole을 찾는 문제를 말한다. First-fit과 Best-fit이 Worst-fit보다 속도와 공간 이용률 측면에서 더 효과적인 것으로 알려져 있다.
First-fit
Size가 n이상인 것 중에 최초로 찾아지는 hole에 할당하는 방법이다.
Best-fit
Size가 n이상인 가장 작은 hole을 찾아서 할당하는 방법이다. 많은 수의 아주 작은 hole들이 생성되며, hole들의 리스트가 크기순으로 정렬되지 않은 경우 모든 hole의 리스트를 탐색해야 한다.
Worst-fit
가장 큰 hole에 할당하는 방법이다. 이 역시 hole들의 리스트가 크기순으로 정렬되어 있지 않으면, 모든 리스트를 탐색해야 하고, Best-fit과는 달리 상대적으로 아주 큰 hole들이 생성된다.
Compaction
사용 중인 메모리 영역을 한 군데로 몰고 hole들을 다른 한 곳으로 몰아 큰 block을 만듦으로써 외부조각 문제를 해결하는 방법이다. 다만, Run time binding이 지원되어야 수행 가능하고, 최소한의 메모리 이동을 고려하는 복잡한 문제를 해결해야 하기 때문에 비용이 매우 많이 든다는 단점이 있다.
2. Noncontiguous allocation
하나의 프로세스가 메모리의 여러 영역에 분산되어 올라갈 수 있는 방법을 말한다.
Paging 기법
프로세스의 virtual memory를 동일한 사이즈의 page로 나누는 방법이다. 따라서 virtual memory의 내용이 page 단위로 비연속적으로 저장되며, 일부는 backing storage에, 일부는 physical memory에 저장된다.
Paging 기법을 사용하기 위해서 physical memory를 동일한 크기의 frame으로, logical memory를 동일한 크기의 page로(frame과 같은 크기) 나눠야 한다. 그리고 기존과 달리 page table을 사용해서 logical address를 physical address로 주소 변환한다. 이 기법을 사용하면 가장 마지막 페이지로 인해 발생하는 내부 조각은 존재할 수 있지만, 마지막 page를 제외한 모든 page와 frame의 크기가 동일하기 때문에 외부 조각은 발생하지 않는다.
위 그림으로 조금 더 자세히 살펴보자. CPU가 어떤 논리적 주소를 주면, 논리적 주소의 앞 부분 p는 페이지 번호가 되고, 뒷 부분 d는 페이지 번호의 주소에서 얼마나 떨어져 있는지 알려주는 offset이 된다. 따라서, p를 page table의 entry(=index)로 사용하면, 페이지 번호에 해당하는 frame 번호 f를 구할 수 있고 논리적 주소를 물리적 주소로 변환할 수 있게 된다.
그렇다면 위의 page table의 구현은 어떻게 이루어질까? 앞서 살펴본 기존의 연속 할당 방식에서는 MMU를 이용한 2개의 레지스터(base register, limit register)만으로 주소변환을 충분히 할 수 있었다. 하지만 불연속 할당 방식을 사용하는 paging 기법에서는 page table을 따로 두고 기존과 다르게 처리한다.
일단, Paging 기법에서 프로세스는 주로 4KB의 크기의 수많은 페이지로 나뉘어진다. 그래서 상당히 많은 entry 정보를 저장해야 하는 page table은 그 용량을 감당하기 위해 physical memory에 상주하게 된다. 즉, CPU의 논리적 주소를 주소 변환하기 위해서는 총 2번(page table 접근 한 번, 실제 data/instruction 접근 한 번) physical memory에 접근하게 된다.
Page table 운용에 사용되는 Register의 경우에는 page table을 가리키는 Page-table base register(PTBR)과 테이블 크기를 보관하는 Page-table length register(PTLR)이라는 2개의 register를 사용한다. 또한, 속도를 높이기 위한 하드웨어 측면의 방책으로 associative register나 translation look-aside buffer(TLB)라는 고속 lookup hardware cache를 사용한다.
TLB에 대하여 그림으로 살펴보자. 위 그림처럼 paging 기법에서 주소 변환을 수행하려면 두 번의 메모리 접근을 해야 하기 때문에, TLB라는 하드웨어의 지원을 통해 속도를 더 빠르게 가져갈 필요가 있다. TLB는 실제 캐쉬 메모리와는 다르지만 주소 변환만을 위한 일종의 캐쉬 메모리 역할을 하는데, page table에서 자주 쓰이는 일부 entry들을 TLB에 저장해두고 메모리보다 조금 윗단에서 entry를 빠르게 가져다 쓸 수 있게 해주는 역할을 한다. 즉, CPU가 주는 논리적 주소를 주소 변환할 때 먼저 TLB를 살펴보고, 만약에 TLB에 해당 entry가 있다면 한 번의 메모리 접근을, TLB에 entry가 없다면 원래대로 두 번의 메모리 접근을 한다.
유의할 점은 page table의 경우 page number를 index로 바로 frame number를 알 수 있는 반면, TLB는 page number와 frame number가 쌍으로 이루어져 있어서 frame number를 알고 싶다면 전체 TLB의 원소를 모두 다 검색해봐야 검색 유무를 판단할 수 있다는 것이다. 따라서, 이 검색을 원활히 진행시키기 위해 associative register들로 parallel search가 가능하도록 해 단번에 frame number를 알 수 있도록 만든다.
또한, page table은 각 프로세스마다 다르게 존재하므로, 이에 대응하기 위해 context switch가 일어날 때마다 TLB는 flush되어야 한다.
앞서 살펴본 것을 토대로 메모리 접근 시간을 파악해보면 위와 같다. 결론적으로 1보다 작은 값 입실론과 1에 아주 가까운 알파 값으로 인해 EAT(Effective Access Time)는 2보다 작아지게 되어, 적어도 메모리에 두 번 접근하는 것보다 나은 방법이 된다는 것이 증명된다.
Two-Level Page Table (2단계 페이지 테이블)
Two-Level Page Table은 위와 같이 바깥 page table과 안쪽 page table 두 개를 활용하는 방법이다. 본래의 Page Table에서는 공간적 낭비가 발생하기 때문에, 이를 막고자 나타난 것이 Two-Level Page Table이다. 현대 컴퓨터는 address space가 매우 큰 프로그램도 잘 지원할 수 있는데, 용량이 큰 프로세스라고 할지라도 대부분의 프로그램은 자신의 주소 공간의 매우 일부분만 사용한다. 이 경우, 기존의 page table은 배열이기 때문에 논리적 주소의 일부분만 사용되어 빈공간이 생기더라도 전체의 주소 공간을 저장할 수 있게끔 생성된다. 즉, 이 과정에서 생기는 빈공간들이 공간적 비효율성을 야기한다.
사실 바깥 page table과 안쪽 page table 두 가지를 사용하니까 시간적으로나 공간적으로나 더 낭비가 클 것 같지만 실제로는 충분한 이점이 있다. 앞서 말햇듯이 프로세스의 주소 공간 중 거의 쓰이지 않는 부분이 훨씬 많기 때문에, 바깥 page table에서 해당 부분들을 Null로 처리해버리면 Null로 처리된 곳에는 안쪽 page table이 생성되지 않아 공간적인 낭비가 감소하는 효과가 있다.
Two-Level Page Table은 위와 같이 바깥 page table 속의 entry마다 안쪽 page table을 둬서 이 page table들을 두 번 거친 후에 물리적 메모리 주소에 도달하게 한다. 이 때, 안쪽 page table 각각의 크기는 4KB로 본래의 page의 크기와 동일하게 된다.
Two-Level Page Table의 주소 공간에 대한 bit 수 분배는 위의 예시와 같으니 참고하도록 하자.
Multi-Level Paging
프로세스의 주소 공간이 더 커지면, 다단계 페이지 테이블이 효율적이다. 페이지 테이블이 더 많아져 메모리 접근 횟수 역시 더 많아질 수 있지만, 공간 낭비를 더욱 줄일 수 있다. 또한, TLB를 사용하면 메모리 접근 횟수 및 총 소요 시간도 크게 줄일 수 있다.
예를 들어, 4단계 페이지 테이블을 이용하는 경우만 해도 위와 같이 메모리 접근 시간이 크게 소요되지 않음을 알 수 있다.
Paging 기법에 관한 몇 가지 Issue
페이지 테이블의 Valid / Invalid bit
페이지 테이블에는 해당 페이지가 실제로 사용되느냐 안되느냐를 표현하는 valid-invaild bit이 존재한다. Valid는 해당 주소의 frame에 프로세스를 구성하는 유효한 내용이 있어 접근을 허용함을 뜻하고, invalid는 해당 주소의 frame에 유효한 내용이 없어 접근을 허용하지 않음을 뜻한다. Invalid에서 해당 주소 frame에 유효한 내용이 없다는 것은 프로세스가 해당 주소 부분을 사용하지 않는 경우 혹은 해당 페이지가 메모리에 올라와 있지 않고 swap area에 있는 경우를 말한다.
만일 프로세스의 주소 공간에서 거의 쓰이지 않는 영역에 해당하는 페이지라면 invalid임을 표시해 구분하는 것이 유용하다. Frame number를 0으로 두는 것만으로는 그것이 0번 frame을 의미하는 것인지 메모리에 올라와 있지 않다는 것을 말하는지 분별할 수 없기 때문이다.
페이지 테이블의 Protection bit
페이지 테이블에는 또 하나의 bit이 존재한다. Protection bit이라고 불리는 이 bit은 해당 page의 연산(read/write/read-only)에 대한 권한을 부여한다. 프로세스에는 code, stack, data 영역이 있는데, code 부분에 해당하는 page의 경우 내용이 바뀌면 안되기 때문에 read only 연산만 가능하게 설정하고 다른 영역은 read, write 모두 가능하게 설정한다.
Inverted Page Table
기존 page table의 큰 공간 낭비 문제를 해결하기 위한 또 하나의 방법이다. 기존 page table이 page number에 따라 page table entry를 만드는 것과 달리, inverted page table은 frame number에 따라 page table entry를 만든다. 그렇기에 page table도 프로세스마다 존재하는 것이 아니라 시스템에 단 하나 존재한다. 그리고 이를 보완하기 위해 page table 각각의 entry에 프로세스 ID를 추가로 넣어줘 어떤 프로세스의 page인지 구분할 수 있도록 한다.
Inverted 방식의 page table은 한 개만 존재함으로써 공간 낭비를 극적으로 줄일 수 있다. 다만, 주소 변환을 하기 위한 시간적 overhead는 커지기 때문에, associative register를 활용해 병렬적으로 page table 검색을 하게끔하는 방식을 보완해 사용한다.
Shared Page
Shared page는 shared code가 page로 나뉠 때 사용되는 용어이다. Shared Code(=Re-entrant Code =Pure code)는 프로세스마다 동일한 프로그램을 실행함으로 인해 같은 코드가 쓰이는 경우에 read-only 상태로 공유하고 메모리에 올리는 하나의 코드를 말한다. 예를 들어, Text editor나 compiler, window systems 같은 프로그램들은 굳이 코드를 여러번 중복할 필요가 없기 때문에, shared code로 공유한다. 이러한 shared code는 모든 프로세스의 논리적 주소 공간에서 동일한 위치에 있어야 하며, 각 프로세스의 독립적인 private code와 data는 프로세스의 논리적 주소 공간 어디에 위치해도 상관없다.
Segmentation 기법
이제 또 다른 대표적인 불연속 할당 방식으로 Segmentation 기법을 알아보자. Segmentation은 프로그램을 의미 단위로 구성된 여러개의 segment로 나누어 할당하는 방식이다. Segment는 크게는 프로그램 전체, 작게는 함수 하나하나로 정의 될 수 있는데, 일반적으로 code, data, stack 영역이 하나씩 segment로 분류된다.
Segmentation에서 논리적 주소는 segment-number와 offset으로 구성된다. 또한 Paging 기법과 비슷하지만 다르게 사용되는 segment table이 존재하며, 테이블 내 각각의 entry에는 segment의 물리적 주소 시작점을 담는 base와 segment의 길이를 담는 limit이 존재한다. 또한, 물리적 메모리에서 segment table의 위치를 담는 Segment-table base register(STBR)와 프로그램이 사용하는 segment의 수를 기록하는 Segment-table length register(STLR)가 존재한다.
위의 그림에서 CPU가 논리적 주소를 주게 되면, segment table에서 논리적 주소의 segment 번호 s에 해당하는 entry를 찾게 된다. 그리고 entry에서의 base 값과 논리적 주소의 offset d를 이용해 물리적 주소에 접근한다. 또한, 물리적 메모리에 접근하기 전에 해당 entry에서 limit 값을 확인하여, 논리적 주소의 offset 값이 프로그램의 주소 범위를 벗어나지 않았는지 파악한다. Paging 기법과 달리 각각의 Segement는 길이가 다르기 때문에, entry에 존재하는 limit 값을 통해 segment의 길이를 결정짓는 것이 중요하고, 이를 활용해 프로그램의 범위를 벗어나는 악의적인 접근에 대해 trap을 건다.
Segmentation은 segment 각각의 길이가 동일하지 않으므로 외부조각이 발생하는 문제가 있다. 하지만, read/write/execution 등의 권한을 부여하는 protection 작업이나 각각의 프로세스가 동일한 코드를 공유하는 sharing 작업에서는 의미 단위를 강조하는 Segmentation이 매우 효과적이라는 장점도 있다.
위 그림은 Segmentation의 한 예시인데, paging 기법의 크기가 4KB인 수많은 page 개수에 비하면 segment의 개수는 현저히 적음을 알 수 있다. 프로그램이 의미 단위로 큼직큼직하게 쪼개지기 때문에 위 예시에서는 segment의 개수가 5개밖에 되지 않는다. 대신 segment의 용량은 4KB로 크기가 고정되어 있는 page에 비하면 매우 커질 수 있다.
또한, segment의 개수가 적어짐에 따라 segment table의 entry 개수도 적어지므로, page table과 달리 table로 인한 공간 낭비가 현저하게 감소한다.
Paged Segmentation (=Segmentation with Paging)
Paged Segmentation은 Paging 기법과 Segmentation 기법을 혼합하여 Segmentation된 각각의 segment에 paging을 적용하는 방법이다. 이렇게 혼합 방식을 사용하면 Segmentation에서 발생하는 외부 조각 문제를 해결하고 protection과 sharing은 본래의 의미 단위대로 처리할 수 있어 유용하다. 실제로도 순수한 Segmentation만을 사용하는 컴퓨터는 없으며 Segmentation을 사용한다면 이렇게 Paging과 혼합적으로 운용한다.
Paged Segmentation의 과정을 살펴보자. 위 그림에서 CPU가 논리적 주소를 주면 segment 번호 s를 사용해 segment table의 해당 entry에 접근한다. 그리고 offset d가 해당 entry의 limit 값을 넘어가지 않는다면, d에 존재하는 페이지 번호 p를 사용해 해당 segment에 mapping된 page table의 entry에 접근한다.(offset d가 limit 값을 넘어간다면, trap을 건다.) 그 후, entry에 해당하는 프레임 번호 f와 d에 존재하는 offset d’을 더해 물리적 주소로 변환을 완료한다.
Memory Management 챕터에 관하여
메모리 관리 챕터는 물리적 메모리에 관하여 다뤘다. 이 메모리 접근 과정에서 운영체제의 역할은 없었고, 오직 하드웨어의 역할만 있었음을 유의하자.
Reference
운영체제, 이화여대 반효경 교수님
-
8-1. 메모리 관리
Symbolic Address VS Logical Address VS Physical Address
1. Symbolic Address
프로그래머 입장에서 메모리를 다룰 때, 숫자가 아닌 변수명, 함수명 등으로 메모리를 조작하는 상징적 주소
Symbolic Address가 compile되면 숫자로 된 Logical Address가 됨
2. Logical Address (=Virtual Address)
프로세스마다 독립적으로 가지는 주소 공간
각 프로세스마다 0번지부터 시작
CPU가 보는 주소
3. Physical Address
실제 메모리에 올라가는 위치
프로그램이 실행될 때, Logical Address를 Physical Address로 주소 변환 (주소 바인딩)
주소 바인딩 (Address Binding)
어떤 프로그램이 실행되기 위해서는 물리적 주소에 올라가야 하는데, 물리적인 주소 어디로 올라갈지 결정하는 것을 의미한다. 현대 컴퓨터는 어떤 프로그램을 실행할 시 프로그램 내 instruction들을 산발적으로 여러 메모리 상 위치에 나눠 실행하지만, 여기서는 하나의 프로그램을 통째로 메모리 상 균일한 위치에 올린다고 가정하고 진행한다.
1. 주소 바인딩이 실현되는 3가지 시점
Compile time binding
Physical Address가 컴파일 시에 정해져서 Logical Address와 Physical Address와 같음
이 때, 컴파일러가 생성한 코드를 절대 코드(absolute code)라고 지칭
메모리가 많이 비어있을 때도 특정 위치부터 주소를 바인딩하기 때문에 비효율적
시작 위치 변경시 재컴파일해야 함
과거에 쓰이던 방식
Load time binding
프로그램이 실행되는 타이밍에 Loader가 Physical Address를 부여함
정해진 위치가 아닌 비어 있는 메모리 위치에 주소를 바인딩
이 때, 컴파일러가 생성한 코드는 재배치가능 코드(relocatable code)라고 지칭
Execution time binding (=Run time binding)
Physical Address를 부여하는 타이밍과 방식은 Load time binding과 동일
프로그램 실행 중에도 프로세스의 메모리 상 위치가 바뀔 수 있다는 점이 특징
CPU가 주소를 참조할 때마다 binding을 점검
이를 위해서 하드웨어적인 지원이 필요 (ex. MMU)
주소 바인딩이 되더라도 Logical Address는 코드상에 남아 있으므로, CPU는 Physical Address가 아닌 이 Logical Address를 참조하고 요청해 연산을 수행한다.
2. MMU (Memory-Management Unit)
Logical Address를 Physical Address로 mapping해 주는 Hardware device
Execution time binding을 지원
2개의 register를 이용해 주소 변환 지원 (relocation register, limit register)
Relocation register(=base register): 접근할 수 있는 물리적 메모리 주소의 최소값
Limit register: 논리적 주소의 범위
user program은 logical address만 다루며, 실제 physical address는 볼 수 없고 알 필요도 없음
MMU scheme
사용자 프로세스가 CPU에서 수행되며 생성해내는 모든 주소값에 대해 base register 값을 더한다. 아래에 예시를 살펴보자.
위 그림은 process p1이 실행되어 있는 상황에서 CPU가 p1의 한 instruction을 요청하는 과정을 담고 있다. 먼저 왼쪽 하단의 p1 그림은 p1의 논리적 주소를 보여준다. p1은 0~3000번지까지의 논리적 주소를 가진다. 이 때, limit register는 p1의 가장 끝 주소인 3000을 기억한다. 또한, 현재 CPU는 0~3000까지의 논리적 주소 중 346번지에 있는 instruction을 요청한 상황이다.
물리적 주소 입장에서 보면, p1은 실행될 때 14000~17000번지까지의 주소를 부여 받았다. 논리적 주소의 범위인 3000만큼을 물리적 주소도 동일하게 받았다. 이 때, relocation register는 p1의 물리적 주소 시작위치인 14000을 기억한다. 그렇다면 CPU가 요청한 instruction의 물리적 메모리 상 위치는 어떻게 될까? CPU가 요청한 논리적 주소 346번지 instruction은 relocation register에 저장된 물리적 위치 시작 주소 14000에 346을 그대로 더한 14346번지 물리적 주소에 존재한다. 즉, 논리적 주소는 상대적으로 표현한 것이기 때문에 실제 위치에서 상대적으로 계산하면 원하는 instruction의 물리적 주소를 알 수 있다.
한편, limit register는 어떤 프로그램이 악의적으로 프로세스의 메모리 범위를 벗어나는 주소를 요청하는 경우를 막기 위해 존재한다. 예를 들어, 위 그림에서 CPU가 요청한 논리적 주소가 4000이라고 하면 p1의 물리적 주소 범위인 14000~17000을 벗어나 18000의 주소를 요청한 것이기 때문에 limit register가 이를 막는다.
MMU의 지원을 받아 주소 변환을 하는 과정을 일반화하면 위와 같이 도식화할 수 있다. CPU가 어떤 instruction의 logical address를 요청하면 그 주소가 limit register에 저장된 값을 넘지 않는지(논리 주소가 프로그램의 크기를 넘어가지 않는지) 확인한다. 만약에 값을 넘어가면, trap이 걸려 운영체제가 해당 프로그램의 CPU 제어권을 앗아가고 범위를 벗어난 악의적인 시도에 대해 프로그램을 종료시키는 등의 제제를 가한다. 값이 벗어나지 않는다면, 요청한 logical address 값에 relocation register에 저장된 값을 더해 physical address로 주소 변환을 하고, 해당 주소에 존재하는 내용을 CPU에게 전달한다.
Dynamic Loading
프로세스 전체를 메모리에 미리 다 올리는 것이 아니라 해당 루틴이 불려질 때 메모리에 load하는 것을 말한다. 프로그램의 코드는 모든 코드가 항상 일정하게 쓰이는 것이 아니라 오류 처리 루틴같은 상대적으로 덜 쓰이는 부분이 존재한다. Dynamic Loading은 이렇게 가끔씩 사용되는 많은 양의 코드를 다루는 경우에서 메모리의 효율성을 크게 증대시킨다. 다만, 이 개념은 운영체제가 제공하는 라이브러리로 프로그래머가 직접 구현하는 것을 의미하며, 운영체제가 스스로 메모리에 올리고 쫒아내는 것을 관리하는 paging system과는 다른 개념임을 유의해야 한다.
Overlays
메모리에 프로세스의 부분 중 실제 필요한 정보만을 올리는 것을 말한다. Dynamic Loading과 그 의미가 거의 동일하나 초창기 컴퓨터 시스템에서 사용되던 말이다. 작은 공간의 메모리에 큰 프로그램을 실행시키기 위해 프로그래머가 직접 수작업으로 프로그램을 분할해 메모리에 올리던 방법으로, 운영체제의 지원없이 구현했기 때문에 프로그래밍이 매우 복잡했다.
Swapping
Swapping
프로세스를 일시적으로 메모리에서 backing store로 쫒아내는 것을 의미한다. 메모리에서 쫒았다가 다시 올리는 작업이므로, 프로세스가 특정 위치에 반드시 복귀해야 하는 Compile time binding, Load time binding보다는 빈 메모리 영역 아무곳에나 프로세스를 올릴 수 있는 Execution time binding에서 더 적합하다. Swap time은 대부분 transfer time(swap되는 양에 비례하는 시간)에 해당한다.
Backing store (=swap area)
하드 디스크의 일부분으로, 많은 사용자의 프로세스 이미지를 담을 만큼 충분히 빠르고 큰 저장 공간을 말한다.
Swap in / Swap out
프로세스가 메모리에서 쫒겨나 backing store로 내려가는 것을 Swap out이라고 하고, backing store에서 다시 메모리로 올라가는 것을 Swap in이라고 한다. 일반적으로 중기 스케줄러가 메모리에 올라와 있는 프로세스들의 CPU priority를 고려하여 swap out시킬 프로세스를 선정한다.
Dynamic Linking
Linking을 실행 시간(execute time)까지 미루는 기법이다. 본래의 Linking(=Static Linking)은 실행 파일을 만들 때, 라이브러리 실행 코드가 실행 파일 코드에 포함되어 실행 파일의 크기가 커진다. 즉, 같은 라이브러리를 쓰는 프로세스라고 하더라도 각각의 프로세스 주소 공간에 라이브러리 코드가 매 번 들어 있는 실행파일이 생성된다.
반면에, Dynamic Linking은 만들어진 실행 파일 속에 라이브러리 루틴의 위치를 찾기 위한 포인터(stub라고 하는 작은 코드)만 넣어 두고 라이브러리 코드 전체는 포함시키지 않는다. 그리고 실행 파일에서 해당 라이브러리를 호출할 시, 포인터로 라이브러리 파일의 위치를 찾아 해당 라이브러리 코드를 메모리에 올리고 실행한다. 만일, 다른 프로세스가 라이브러리를 호출해 이미 메모리에 올라와 있는 경우, 실행만 한다. 본래의 Linking에 비해 메모리 공간을 덜 잡아먹고 실행 파일의 크기가 줄어든다는 점에서 효율적이다.
Reference
운영체제, 이화여대 반효경 교수님
-
Fast API tutorial - Validation
각각의 Parameters는 인자로 받을 데이터에 대해 여러가지 조건을 걸어 validations(유효성 검사)를 수행할 수 있습니다. 만일 incorrect한 데이터가 감지될 경우 validation에 의해 error가 응답됩니다.
Parameter의 종류를 선언하는 함수
앞에서 살펴봤듯이 parameter는 path parameter, query parameter, request body parameter 등 여러가지 형태의 종류가 존재합니다. 이외에도 cookie parameter, header parameter등 더 다양한 형태가 존재하는데, 이러한 parameter를 조금 더 명시적으로 선언할 수 있게 도와주는 함수가 각각 존재합니다.
from typing import Optional
from fastapi import FastAPI, Path, Query
app = FastAPI()
@app.get("/items/{item_id}")
async def read_items(item_id: Path(...), q: Query(None):
results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
if q:
results.update({"q": q})
return results
만일 parameter의 default 값으로 Path(...)를 설정해주면, 해당 parameter는 required한 path parameter가 됩니다. 혹은 Default 값으로 Query(None)를 사용한다면 해당 parameter는 not required한 query parameter가 됩니다. 이러한 함수들은 함수의 첫 번째 parameter로 default 값을 받습니다.
Path([default 값])
Query([default 값])
etc…
이렇게 각각의 parameters는 자신의 이름을 딴 함수를 갖고 있습니다. fastapi에서 import해오는 Path, Query 등이 그 예입니다. 사실 각각의 함수들은 해당 이름의 클래스에서 인스턴스를 만들어 return하는 기능을 하므로, default parameter로 설정하는 것은 해당 이름의 객체가 됩니다. Parameter에 대한 validation은 이러한 함수들을 사용해 적용합니다.
이러한 클래스들이 비슷한 느낌을 띄는 이유가 있습니다. 해당 클래스들은 모두 Param 클래스의 subclass들입니다. 그래서 이들은 validation과 metadata의 추가를 모두 똑같은 방식으로 적용할 수 있습니다.
Path(), Query(), Body() 함수로 required parameter 만들기
앞에서 Query 함수의 첫번째 parameter로 None을 사용해 optional parameter를 만들었는데, 만일 Query 함수를 사용해 required parameter를 만들고 싶다면 Query의 첫 번째 argument로 ... (Ellipsis)를 사용하면 됩니다. 이는 나중에 사용할 Path, Body 함수와 더불어 같은 맥락의 함수들에 똑같이 적용됩니다.
Query(…)
Path(…)
Body(…)
etc…
String Validations
Additional Information
Fast API는 type hinting과 default parameter를 통해 이에 대한 추가 정보를 인식하고 활용합니다.
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/")
async def read_items(q: Optional[str] = None):
results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
if q:
results.update({"q": q})
return results
위 코드는 async def read_items(q: Optional[str] = None): 부분에 query parameter q에 대한 타입을 명시했습니다. q는 str타입이 단서가 되어 query parameter로 인식됩니다. 또한, = None을 통해 not required한 optional parameter로 인지됩니다.
Additional validation
Parameter에 인자로 받을 데이터에 대한 validation을 걸어줄 수 있습니다. 일례로, query parameter q에 대해 인자로 들어올 str 데이터의 최대 길이가 50이 넘지 않게끔 검사를 수행하는 validation을 만들겠습니다.
from typing import Optional
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items/")
async def read_items(q: Optional[str] = Query(None, max_length=50)):
results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
if q:
results.update({"q": q})
return results
우선 fastapi에서 Query 함수를 import합니다.
from fastapi import FastAPI, Query
그리고 Query 함수를 다음과 같은 형태로 default parameter 자리에 사용합니다.
Query([default 값], [조건식])
q: Optional[str] = Query(None, max_length=50)는 default 값으로 None을 유지한 상태에서 q의 최대 길이를 50으로 지정합니다. 그리고 실제로 전달된 데이터의 길이가 50을 넘어가면, error를 응답합니다.
또한, Query 함수는 다음과 같이 parameter를 더 추가해 여러 개의 validation을 지정할 수 있으며, 정규표현식을 validation으로 지정할 수도 있습니다.
Query(None, min_length=3, max_length=50)
Query(None, min_length=3, max_length=50, regex="^fixedquery$")
이러한 validation 정보들은 Interactive Documentation에도 업데이트됩니다.
Parameter에 Multiple values 받기
from typing import List, Optional
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items/")
async def read_items(q: Optional[List[str]] = Query(None)):
query_items = {"q": q}
return query_items
Parameter를 특정 parameter를 만드는 함수를 사용해 선언한다면, multiple values를 받는 parameter로 만들 수 있습니다. 만일 query parameter를 Query 함수를 사용해 만든다면, multiple values를 받는 query parameter를 만드는 식입니다. 이 경우 query parameter는 반드시 Query 함수와 함께 정의되어야 하는데, 그렇지 않으면 Fast API가 해당 parameter를 request body로 간주할 수 있기 때문입니다. (Singular type이 아닌 type으로 parameter를 선언할 때 나타나는 현상입니다!)
q: Optional[List[str]] = Query(None)
위와 같이 List 타입으로 q를 선언하면, http://localhost:8000/items/?q=foo&q=bar 요청과 같이 URL에 여러 개의 query 값이 전달되어도 리스트로 한 번에 받아 처리할 수 있습니다. Fast API는 자동으로 multiple query를 인식해 리스트에 담아줍니다. 위 URL 요청에 대한 response은 다음과 같습니다.
{
"q": [
"foo",
"bar"
]
}
만일 리스트로 받을 내부 요소들의 타입까지 체크하고 싶진 않다면, 다음과 같이 list로만 타입을 선언하면 됩니다.
q: list = Query([])
Parameter에 metadata 넣기
Parameter의 종류를 선언하는 함수에 인자를 설정해주면, 함수를 적용한 parameter에 metadata를 추가할 수 있습니다. 예를 들어, Query 함수의 parameter를 사용하면 다음과 같이 query parameter에 또 다른 metadata들을 추가할 수 있습니다.
async def read_items(
q: Optional[str] = Query(
None,
title="Query string",
description="Query string for the items to search in the database that have a good match",
min_length=3,
)
):
여기선 title과 description parameter를 추가했는데, 이렇게 추가된 query parameter 정보들은 Interactive Document에도 반영됩니다.
Parameter에 Alias 설정하기
REST하게 URL을 만들고 싶다면, _보다 -를 사용하는 것이 좋습니다. 언더스코어 _는 밑줄이 그어지면 가독성이 떨어지기 때문입니다. 그러나 parameter 이름을 item-query처럼 사용하는 것은 Python 문법에 어긋납니다. 따라서, 이러한 경우에는 parameter에 alias를 item-query로 설정해줍니다. 아래는 query parameter의 예입니다.
q: Optional[str] = Query(None, alias="item-query")
Parameter Deprecating하기
Deprecated는 특정 기능이 아직까지 사용되고는 있지만, 중요도가 떨어져 조만간 사라지게 될 상태를 말합니다. 만일 특정 parameter를 언젠가 제거할 계획이지만 이를 계속 사용하는 클라이언트 개발자들을 위해 한 동안 남겨두려는 상황이라면, 해당 parameter를 deprecating하여 Interactive API Documentation에 해당 parameter가 deprecated 상태임을 명확히 알려줄 수 있습니다. (Documentation은 클라이언트 개발자들과의 소통 창구 역할을 합니다!)
예를 들어, 다음과 같이 Query 함수의 parameter로 deprecated=True를 설정해줍니다.
q: Optional[str] = Query(None, deprecated=True)
Deprecated 상태에서는 parameter의 이용이 여전히 가능하지만, Interactive Documentation에는 해당 parameter의 deprecated 상태가 명확히 반영됩니다.
Numeric Validations
앞에선 String과 관련된 validation을 많이 살펴봤지만, Numeric 형태의 데이터를 다룰 때도 물론 validation을 수행하거나 metadata를 추가해줄 수 있습니다. 이 경우는 Numeric value를 자주 사용하는 path parameter를 주로 사용해서 살펴보겠습니다.
Path 함수는 다음과 같이 import해 사용합니다.
from fastapi import Path
Path Parameter에 metadata 넣기
from typing import Optional
from fastapi import FastAPI, Path, Query
app = FastAPI()
@app.get("/items/{item_id}")
async def read_items(
item_id: int = Path(..., title="The ID of the item to get"),
q: Optional[str] = Query(None, alias="item-query"),
):
results = {"item_id": item_id}
if q:
results.update({"q": q})
return results
Path 함수에 metadata를 넣을 때도, Query와 똑같은 방식으로 사용합니다. item_id path parameter에 title 정보를 넣고 싶다면 다음과 같이 Path 함수에 parameter로 삽입하여 적용합니다.
item_id: int = Path(..., title="The ID of the item to get")
이 때, path parameter는 path의 일부분이기 때문에, 인자가 반드시 존재해야 하는 parameter입니다. 따라서, 첫 번째 파라미터로 ...을 사용해 Fast API에게 required parameter임을 알려줍니다. 사실 ...이외의 None이나 다른 default 값을 사용하더라도 문제 없이 실행되지만 큰 의미는 없으며, 해당 path parameter는 여전히 required parameter로 기능합니다.
Number validations
Parameter에 대하여 몇몇 숫자에 대한 validation을 추가할 수 있습니다.
from fastapi import FastAPI, Path
app = FastAPI()
@app.get("/items/{item_id}")
async def read_items(
*,
item_id: int = Path(..., title="The ID of the item to get", gt=0, le=1000),
q: str,
):
results = {"item_id": item_id}
if q:
results.update({"q": q})
return results
위 코드의 Path 함수에 들어간 gt, le 같은 validation parameter들의 의미는 다음과 같습니다.
gt: greater than
ge: greater than or equal
lt: less than
le: less than or equal
Reference
Fast API 공식 문서 튜토리얼
-
Fast API tutorial - Params
Fast API 튜토리얼 - Parameters of Path, Query, Request body
Path Parameters
Path Parameters의 정의와 형태
Path parameter는 path 내에 들어있는 variable의 value를 전달받은 parameter를 말합니다.
@app.get("/items/{item_id}")
def read_item(item_id):
return {"item_id": item_id}
위의 코드에서, item_id는 path parameter에 해당합니다. HTTP 요청이 들어오면 해당 URL에서 {item_id}에 해당하는 value를 획득하고, 이 value는 read_item함수의 item_id에 인자로 전달됩니다.
위의 코드를 main.py에 추가해 저장한 후, http://127.0.0.1:8000/items/foo에 들어가면 response로 {"item_id":"foo"}이 확인됩니다.
Data conversion and validation
@app.get("/items/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id}
또한, path operation function에서 인자로 사용한 path parameter에 타입 힌트를 줄 수 있습니다. (다른 parameter도 마찬가지로 적용됩니다.) 그리고 이렇게 자료형을 annotate한 parameter는 들어온 인자 값을 annotated된 자료형대로 형 변환해서 parameter에 담습니다. 만일 http://127.0.0.1:8000/items/3으로 요청이 들어온 경우, 원래는 path parameter를 str 타입으로 받아 item_id 값이 ‘3’이 되지만 위 코드에서는 타입 힌트를 보고 int로 형 변환된 3이 담깁니다. 즉, Fast API는 타입 힌트를 통해 자동으로 parsing을 통한 data conversion을 제공합니다.
만일 path parameter에 annotated된 타입과 다른 타입의 값이 요청된다면, 해당 HTTP 요청은 에러를 일으킵니다. 이는 Fast API가 데이터 유효성 검사까지 수행함을 보여줍니다. 실제로 http://127.0.0.1:8000/items/foo에 들어가면 응답에 오류가 발생합니다. Annotated된 int 타입으로 형 변환이 이뤄질 수 없는 foo가 값으로 들어왔기 때문입니다. http://127.0.0.1:8000/items/4.2의 경우도 마찬가지입니다.
타입 힌트로 annotated된 변수는 Interactive API documentation에도 적용됩니다.
http://127.0.0.1:8000/docs에 들어가면 path parameter item_id가 integer로 선언되어 있음을 확인할 수 있습니다.
Fast API에서 이러한 data conversion 및 validation이 가능한 이유는 내부적으로 Pydantic 라이브러리의 도움 덕분입니다.
Pydantic이란?
파이썬 타입 힌트를 사용해 데이터 유효성 검사를 해주는 라이브러리입니다. 만일 어노테이션된 타입과 다른 데이터를 만나면 에러를 띄웁니다. Fast API에서는 Pydantic을 활용하여 간편하게 데이터 유효성 검사를 수행합니다.
Path Operation 정의 순서의 중요성
어떤 path operation들은 정의하는 순서에 따라 예상치 못한 처리를 일으킬 수 있습니다. 예를 들어, 고정된 path를 가진 path operation과 path parameter를 가진 path operation이 모두 정의된 경우를 살펴봅시다.
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/me")
async def read_user_me():
return {"user_id": "the current user"}
@app.get("/users/{user_id}")
async def read_user(user_id: str):
return {"user_id": user_id}
/users/me 코드는 /users/{user_id}보다 앞에 쓰여져야 합니다. 만일 순서가 바뀌면, Fast API는 me를 user_id의 value로 오해하여 본래 의도와 다르게 read_user 함수를 호출할 것입니다.
Path Parameter의 값으로 Path를 받는 경우
때로는 path parameter의 값으로 home/dogs/wealsh와 같은 path가 올 수 있습니다. 만일 path operation의 path가 기존처럼 /files/{file_path}이라면, file_path는 /files/home/dogs/wealsh 요청이 들어왔을 때 이를 온전히 인식하지 못하고 {"detail":"Not Found"}를 응답합니다. 하지만, Starlette에서 제공하는 Path convertor를 사용하면 path parameter의 인자가 path 형태로 들어와도 이를 온전히 인식하게 됩니다. Fast API는 Starlette을 기반으로 만들어졌기 때문에, 특별한 import 없이 다음과 같이 써주면 path convertor가 동작합니다.
/files/{file_path:path}
이를 활용하면 다음과 같이 path operation에 http://127.0.0.1:8000/files/home/dogs/wealsh 형태로 요청을 보내도 온전히 동작합니다.
from fastapi import FastAPI
app = FastAPI()
@app.get("/files/{file_path:path}")
async def read_file(file_path: str):
return {"file_path": file_path}
위의 요청의 경우 files/home/dogs/wealsh 값이 file_path에 담겨 응답됩니다. 만일 /files/home/dogs/wealsh 형태로 앞에 /를 추가하여 file_path에 담고 싶다면 http://127.0.0.1:8000/files//home/dogs/wealsh 형태로 요청을 보내면 됩니다.
Query Parameters
Query Parameters의 정의와 형태
Path operation function에 path parameter가 아닌 다른 parameter를 선언했다면, 해당 parameter들은 자동으로 query parameter로 인식됩니다. Query parameter는 request로 들어오는 query의 값이 담기는 parameter입니다.
Query는 URL의 ?뒤에 오는 key-value pair를 의미하며 각각의 query는 &로 구분됩니다. 다음은 request에 담긴 query의 예시입니다.
http://127.0.0.1:8000/items/?skip=0&limit=10
또한, 다음과 같은 path operation은 이러한 request에 대해 query parameter를 받습니다.
from fastapi import FastAPI
app = FastAPI()
fake_items_db = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"}]
@app.get("/items/")
async def read_item(skip: int = 0, limit: int = 10):
return fake_items_db[skip : skip + limit]
이 경우, query parameter는 skip과 limit이고 각각 0과 10을 인자로 받습니다.
원래대로라면 URL로부터 들어온 str타입의 ‘0’과 ‘10’으로 값을 받았겠지만, skip과 limit의 타입을 int로 선언했기 때문에 형 변환하여 값을 받습니다. 즉, query parameter에도 path parameter에서 적용되던 다음과 같은 프로세스들이 그대로 적용됩니다.
Editor Support (Auto completion, Error check, etc…)
Data conversion
Data validation
Automatic Documentation
Default value & Optional Parameters
from fastapi import FastAPI
app = FastAPI()
fake_items_db = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"}]
@app.get("/items/")
async def read_item(skip: int = 0, limit: int = 10):
return fake_items_db[skip : skip + limit]
Query parameter는 default parameter를 설정할 수 있습니다. 이 경우 skip과 limit의 default 값은 각각 0과 10입니다.
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: str, q: Optional[str] = None):
if q:
return {"item_id": item_id, "q": q}
return {"item_id": item_id}
또한, query parameter에는 typing 모듈을 활용해서 Optional 타입을 선언할 수 있습니다. q: Optional[str] = None은 query parameter q가 str 타입의 value를 인자로 받거나 혹은 인자가 없을 때는 None을 default value로 가진다는 의미입니다. 즉, Fast API는 q를 required하지 않은 parameter로 인식합니다.
이 때, Fast API는 = None부분을 인식해 query parameter q의 required 여부를 구분합니다. 또한, : Optional[str] 부분에서 Fast API는 str 부분만 인식해 data conversion 및 data validation에 사용합니다. 그리고 나머지 Optional 부분은 Fast API가 아닌 Editor의 Auto completion과 Error check를 support하기 위해 사용됩니다.
Required parameter란?
Parameter가 Required하다는 것은 특정 parameter가 필수적으로 인자를 받아야만 함을 말합니다. 보통 특정 parameter에 default값을 설정해두면 not required, default 값을 설정하지 않으면 required 상태로 인식됩니다. 만일 not required한 parameter를 굳이 특정 값이 있지 않아도 되는 Optional parameter로 만들고 싶다면, default 값으로 None을 설정하면 됩니다.
Request Body
Request Body의 정의와 형태
Request body는 클라이언트에서 API로 보내는 data를 의미합니다. 반면에, API가 클라이언트에게 보내는 data는 response body라고 합니다. Response body는 API가 항상 보내야 하는 반면, request body는 클라이언트가 필수적으로 보낼 필요는 없습니다.
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
tax: Optional[float] = None
app = FastAPI()
@app.post("/items/")
async def create_item(item: Item):
return item
Request body는 Pydantic model을 통해 선언합니다. pydantic 라이브러리에서 BaseModel을 import하고, BaseModel을 상속하는 클래스를 생성해 Pydantic 모델을 만듭니다. Model의 attribute들은 query parameter와 같은 방식으로 required 여부를 정할 수 있습니다. 위 경우, name, price는 required한 attribute이고 description, tax는 not required하면서 optional한 attribute입니다.
따라서, 위 모델은 다음과 같은 JSON 객체(혹은 Python dict 객체)를 선언한 것과 같습니다.
{
"name": "Foo",
"description": "An optional description",
"price": 45.2,
"tax": 3.5
}
description과 tax는 optional하기 때문에 다음과 같은 JSON 객체도 request body로 유효하게 전달 받을 수 있습니다.
{
"name": "Foo",
"description": "An optional description",
"price": 45.2,
"tax": 3.5
}
그리고 path operation fucntion의 parameter에 원하는 pydantic model을 타입 선언 해주면, 해당 파라미터는 request body를 전달받는 parameter로 인식됩니다. 위 코드에서는 async def create_item(item: Item):에서 Item pydantic model을 타입으로 선언해 item을 request body parameter로 만들었습니다.
이렇게 선언된 request body parameter는 다음과 같은 특징을 가집니다.
Request body로 들어온 data를 JSON 형식으로 읽어들입니다.
필요할 경우 들어온 data를 선언된 타입에 일치하도록 data conversion합니다.
선언된 타입으로 Data validation을 수행합니다. (Incorrect data에는 error를 띄웁니다!)
Editor support를 지원합니다.
해당 model에 대한 JSON schema를 생성해, Automatic Documentation에 적용합니다.
Request Body로 전달받은 Model 사용법
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
tax: Optional[float] = None
app = FastAPI()
@app.post("/items/")
async def create_item(item: Item):
item_dict = item.dict()
if item.tax:
price_with_tax = item.price + item.tax
item_dict.update({"price_with_tax": price_with_tax})
return item_dict
Request body를 전달받은 item은 클래스의 attribute를 사용하는 것과 똑같은 방식으로 자유롭게 사용할 수 있습니다. 예를 들어, item.tax처럼 tax 속성에 접근해 value를 사용할 수 있습니다. 또한, pydantic model의 .dict() 메서드를 사용해 item.dict()로 해당 model의 데이터를 python dict 형태로 사용할 수도 있습니다.
위 코드는 tax 속성에 인자가 들어왔다면, price_with_tax = item.price + item.tax로 새로운 value를 만들고 item에서 추출한 item_dict에 item_dict.update({"price_with_tax": price_with_tax})로 새로운 key-value를 추가하여 item_dict를 return합니다.
Path + Query + Request Body Parameters
Path, query, request body parameter는 모두 동시에 사용할 수 있습니다. Fast API는 각각의 parameters를 자동으로 구분해냅니다
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
tax: Optional[float] = None
app = FastAPI()
@app.put("/items/{item_id}")
async def create_item(item_id: int, item: Item, q: Optional[str] = None):
result = {"item_id": item_id, **item.dict()}
if q:
result.update({"q": q})
return result
위 경우 item_id는 path parameter, item은 request body parameter, q는 query parameter로 자동 인식됩니다. 기본적으로 parameter 자동 인식은 다음과 같은 기준으로 진행됩니다.
Path 안에도 선언되어 있는 parameter는 path parameter로 인식합니다. (혹은 Path(...)가 선언되어 있는 parameter)
int, float, str, bool 등의 singular type으로 선언된 parameter는 query parameter로 인식합니다. (혹은 Query(...)가 선언되어 있는 parameter)
Pydantic model로 type이 선언된 parameter는 request body parameter로 인식합니다. (혹은 Body(...)가 선언되어 있는 parameter)
Path, Query, Request body Parameters의 순서 문제
Query parameter를 default 값이 없는 required parameter로 만들고, path parameter는 default 값으로 Path 인스턴스를 넣어 not required한 parameter로 만드는 다음과 같은 상황을 가정해보겠습니다.
async def read_items(
item_id: int = Path(..., title="The ID of the item to get"), q: str
):
이 때, Python 문법으로 인해 default 값이 있는 parameter는 default 값이 없는 parameter의 앞에 위치하지 못합니다. 따라서, 위 코드는 오류를 일으킵니다.
하지만, 다음과 같이 순서를 정리하면 오류를 피할 수 있습니다.
async def read_items(
q: str, item_id: int = Path(..., title="The ID of the item to get")
):
Fast API는 parameter의 이름, 타입, default parameter 등의 단서를 통해 parameter의 종류를 인식하므로, 순서에 대한 문제는 Python 문법에서만 고려하면 됩니다.
만일 다음과 같은 약간의 트릭을 사용한다면, default 값 여부에 상관 없이 자유로운 parameter 배열이 가능합니다.
async def read_items(
*, item_id: int = Path(..., title="The ID of the item to get"), q: str
):
*를 함수의 첫 번째 parameter로 사용하면 위와 같이 default 값이 없는 parameter가 뒷 순서로 와도 상관 없습니다. *는 Python 함수의 special parameter 중 하나로, * 뒤에 위치한 parameter들은 모두 키워드 인자만 받도록 강제합니다. Special parameter에 대해 더 자세히 알고 싶다면, Python 공식 튜토리얼 문서의 Special parameters 부분을 읽어 보시길 바랍니다.
Reference
Fast API 공식 문서 튜토리얼
-
7. Deadlock
Deadlock
1. Deadlock이란?
일련의 프로세스들이 서로가 가진 자원을 기다리며 block된 상태를 말한다. 여기서 자원이란 하드웨어와 소프트웨어 등을 모두 포함하는 개념이며, 이러한 자원을 사용하는 절차로는 Request, Allocate, Use, Release가 있다.
2. Deadlock이 발생하는 4가지 조건
Deadlock이 발생하려면 아래의 4가지 조건을 모두 만족해야 한다.
Mutual Exclusion : 매 순간 하나의 프로세스만이 자원을 사용한다.
No Preemption : 프로세스는 자원을 강제로 빼앗기지 않고 스스로 내어 놓는다.
Hold and wait : 자원을 가진 프로세스가 다른 자원을 기다릴 때, 보유 자원을 놓지 않고 계속 가지고 있는다.
Circular wait : 자원을 기다리는 프로세스간에 사이클이 형성된다.
Resource-Allocation Graph (자원할당그래프)
Deadlock의 발생 여부를 판단하기 위해 Resource-Allocation Graph(자원할당그래프)를 사용한다. 위 그래프는 자원할당그래프의 예시이고 동그라미는 프로세스, 네모는 자원을 나타낸다. 네모 안의 검은 점은 자원의 instance, 즉 자원의 개수를 표현한다.
만일 자원할당그래프에 cycle이 없다면, deadlock에 걸리지 않았다고 볼 수 있다. 그러나 자원할당그래프에 cycle이 있을 경우에는 두 가지 경우로 나뉘는데, 먼저 자원의 instance가 하나인 경우 deadlock이 발생했다고 이야기할 수 있는 반면, 자원의 instance가 여러개 있으면 deadlock이 발생할 가능성은 존재하지만 직접 확인해봐야 실제 발생 여부를 파악할 수 있다. 위의 예시 그래프의 경우, 그래프에 cycle이 없기 때문에 deadlock에 걸리지 않았다.
위의 왼쪽 그래프와 오른쪽 그래프는 cycle이 존재해도 instance가 여럿 있는 자원이 있기 때문에 deadlock을 단정지을 수는 없다. 왼쪽 그래프의 경우, 직접 화살표를 따져보면 서로 자원을 양보하지 못하는 상황이어서 deadlock이 발생했음을 확인할 수 있다. 반면, 오른쪽 그래프는 P2와 P4가 자원을 반납하면 얼마든지 cycle이 소멸할 수 있기 때문에 deadlock 상황이 아니라고 판단된다.
Deadlock의 처리 방법
1. Deadlock Prevention (강한 방법)
자원 할당 시 Deadlock의 4가지 필요조건 중 어느 하나가 만족되지 않도록 하는 방법이다. Mutual Exclusion은 지키지 않을 시 아얘 Deadlock 문제에 대한 의미가 없을 뿐더러 지금까지의 논의도 무의미해지므로, 나머지 3가지 조건에 대해 Deadlock Prevention을 진행하는 것이 옳다.
먼저 Hold and Wait를 만족하지 않게 하는 2가지 방법이 있다. 하나는 아얘 처음 프로세스가 시작할 때 필요한 모든 자원을 할당받게 하는 것이다. 이를 통한다면 deadlock을 방지할 수 있지만, 아직 필요한 순서가 아닌 자원까지 보유하게 되어 비효율적인 문제가 있을 수 있다. 또 다른 방법은 자원이 필요한 프로세스가 자원을 기다려야 하는 상황에 처해 있다면, 보유한 자원을 모두 내려놓고 기다리게 하는 것이다. 이렇게 하면 deadlock을 예방하면서 앞 방법보다 조금 더 효율적으로 동작할 수 있다.
다음은 No Preemption을 만족하지 않게 해서 deadlock을 예방하는 방법이다. 이는 자원을 필요로 하는 어떤 프로세스가 이미 자원을 보유하고 있는 프로세스로부터 자원을 빼앗아 올 수 있게 허용하는 것을 의미한다. 이러한 방법은 CPU, Memory같이 state를 쉽게 save하고 restore할 수 있는 자원에 적용하는 것이 효과적이다. 자원을 빼앗겼을 때 진행 상태가 쉽게 엉키고 흩으러지는 자원에는 사용이 곤란하다.
마지막으로 Circular Wait을 만족시키지 않는 것도 deadlock을 예방하는데 효과적이다. 이는 모든 자원에 할당 순서를 매기는 것으로 구현된다. 모든 자원마다 할당 순서를 정하여 정해진 순서에 따라 자원을 할당받으면 프로세스끼리 꼬일 일이 없어진다.
Deadlock Prevention은 deadlock을 원천 차단할 수 있는 장점이 있지만, 자원의 utilization(이용률)이나 throughput(성능)이 낮아지고 starvation 문제를 가져올 수 있다는 단점이 있다.
2. Deadlock Avoidance (강한 방법)
Deadlock Avoidance는 자원 요청에 대한 부가적 정보를 사용해 deadlock의 가능성이 없는 경우에만 자원을 할당한다. 즉, 프로세스가 시작될 때 해당 프로세스가 미래의 쓸 자원의 총량을 미리 예측하고, 만일 deadlock 가능성이 보인다면 자원의 instance가 여러개 있어도 해당 프로세스에게 자원을 내어주지 않는다.
Deadlock Avoidance 방법은 만일 자원의 instance가 한 개일 경우, Resource-Allocation Graph(자원할당그래프)를 사용하여 deadlock을 피한다. 위 그림은 자원의 instance가 하나일 경우인데, 점선은 프로세스가 화살표가 가리키는 방향의 자원을 미래에 획득할 가능성을 표시한다. 여기서 P2 프로세스는 R2 자원을 미래에 획득할 가능성이 있기 때문에, 가운데 그림처럼 P2가 R2 자원을 요청할 수 있지만, deadlock의 가능성이 있기 때문에 실제로 자원을 내어주지는 않는다.
만일 자원의 instance가 여러 개일 경우라면, 위 그림처럼 Banker’s Algorithm을 사용한다. 위 그림에서는 프로세스가 미래의 사용할 자원의 총량을 미리 예측하여 Max라는 테이블로 표시했다. Allocation은 현재 프로세스들이 자원을 확보하고 있는 상태를 나타내고, Need는 Max에서 Allocation을 뺀 값들을 기록한 것으로서 앞으로 프로세스가 얼마만큼의 자원을 더 요청할 것인지 계산한다. 그리고 남아있는 자원을 표시한 Available과 미리 계산한 각 프로세스들의 Need를 비교해 각 프로세스들의 deadlock 가능성을 예측하고, deadlock 가능성이 없는 프로세스에게 자원을 할당한다. 예를 들어, P1의 경우 Available 내에서 미래의 자원(Need)을 공급할 수 있기 때문에 deadlock 가능성이 적은 것으로 판단하여 자원 획득을 허용해준다. 반면, P0는 Available 허용 범위를 벗어나는 자원 수(Need)들을 앞으로 요청할 예정이므로 deadlock 가능성이 높다고 판단하여 자원을 내어주지 않는다. 그리고 만일, 다른 프로세스의 작업이 끝나서 자원이 반납되어 가용 자원의 수가 늘었다면, 자원의 수가 허락되는 선에서 이전에 자원을 받지 못했던 프로세스에게 자원을 내어준다.
3. Deadlock Detection and recovery (약한 방법)
이 방법은 Deadlock 발생을 허용하되 그에 대한 detection 루틴을 두어 deadlock을 발견하면 recover한다.
먼저 deadlock을 detection하는 방법은 Deadlock Avoidance에서 썼던 방법과 유사하게 진행한다. 먼저, 자원의 instance가 하나인 경우, Wait-for 그래프를 사용한다. 위의 오른쪽 그래프는 Wait-for 그래프라고 하는데, 왼쪽의 자원할당그래프에서 자원을 표시하는 네모만 제거하고 단순화한 모형이다. 이 Wait-for 그래프를 보고 프로세스 간 cycle을 발견한다면, deadlock 상황이 발생한 것으로 판단한다.
또한, 자원의 instance가 여러 개인 경우에도 앞선 Deadlock Avoidance와 비슷하게 처리한다. 위와 같은 상황은 가용 가능한 자원이 없어 deadlock에 처한 것처럼 보이지만, Allocation에서 P0가 Request하는 자원이 따로 없기 때문에, 할당된 자원이 반납될 것으로 예상된다. 이를 고려하여 차근차근 프로세스들의 Request들을 처리해나가면 deadlock 상황이 아닌 것으로 판단된다.
Deadlock detection으로 deadlock이 감지되었다면, Recovery 작업이 이어져야 한다. 여기에는 두 가지 방법이 있는데, 먼저 Process termination이 있다. Deadlock과 연루된 프로세스를 한 번에 모두 죽이는 방법이나, deadlock이 풀릴 때까지 한 개씩 프로세스를 죽이는 방법이 존재한다.
두 번째 방법인 Resource Preemption은 deadlock과 연루된 프로세스의 자원을 뺏는 방법이다. 비용을 최소화할 자원을 선택하여 해당 프로세스의 자원을 뺏음으로써 deadlock을 해제한다. 이 때, 비용만 생각하여 자원을 뺏다가 계속 하나의 프로세스만 희생되는 starvation 문제가 발생할 수 있어서, 프로세스들이 균형있게 자원을 배분받을 수 있도록 고려할 필요가 있다.
4. Deadlock Ignorance (약한 방법)
이 방법은 단순하게 시스템이 deadlock을 책임지지 않는다. UNIX를 포함해 대부분의 운영체제가 택하는 방법으로서, deadlock 자체가 매우 드물게 발생하고 이것을 대비하는 것에 대한 overhead가 더 클 수도 있기 때문에 deadlock에 대한 조치를 사용자에게 맡긴다. 만일 deadlock이 발생할 경우, 사용자는 시스템의 비정상적 동작을 느끼게 되고 직접 프로세스를 종료시키는 등의 방법을 수행함으로써 이를 해결한다.
Reference
운영체제, 이화여대 반효경 교수님
-
비동기 프로그래밍을 돕는 asyncio 라이브러리
asyncio(Asynchronous I/O)
파이썬은 인터프리터 언어의 특성과 함께 속도가 느린 언어로 알려져있습니다. 그렇기에 비동기로 처리해 속도를 높이는 방법은 파이썬의 단점을 극복하는 하나의 해답이 됩니다. 다만, 파이썬에는 멀티스레드에서 발생하는 복잡한 문제들을 막기 위한 GIL(Global Interpreter Lock)이 존재하고, 이로 인해 항상 한 번에 하나의 스레드만 작업을 수행할 수 있어 진정한 의미의 멀티스레딩은 실현되기 어렵습니다. GIL은 보다 복잡한 문제를 막기 위한 심플하고 효과적인 방법이지만, 파이썬의 한계점이자 파이썬이 태생적으로는 비동기 프로그래밍에 적합하지 않은 언어임을 보여주죠.
한계가 뚜렷함에도 파일 읽기 및 쓰기, Http 통신 대기와 같은 Blocking I/O 상황에서는 비동기 프로그래밍이 여전히 파이썬에서 위력을 발휘합니다. 이러한 상황의 비동기 프로그래밍을 좀 더 간편하게 하기 위해 나온 모듈이 asyncio입니다. 그리고 asyncio로 인한 변화 덕분에, 파이썬에서도 점점 비동기 프로그래밍 사용이 용이해지고 있습니다.
asyncio는 비동기 프로그래밍을 위한 모듈로, async/await 구문을 사용해 CPU 작업과 I/O 작업을 병렬로 처리할 수 있도록 도와줍니다. asyncio 모듈은 파이썬 3.4에 새로이 추가되었고, 3.5 부터 async def와 await 구문이 지원되었습니다. 그래서 파이썬 3.4 미만에서는 비동기 프로그래밍을 @asyncio.coroutine 데코레이터와 yield from을 사용해 구현해야 합니다. 3.3의 경우 pip install asyncio로 모듈을 설치하고 데코레이터와 yield from을 사용하면 됩니다.
동기(synchronous) 처리
특정 작업이 끝나면 다음 작업을 처리하는 순차처리 방식입니다. (프로그램의 코드가 순차적으로 처리되는 방식의 프로그래밍을 말합니다.) 아래 코드처럼 main 함수의 코드들이 작성된 순서대로 처리되는 경우 동기적으로 처리되었다고 말합니다. 특정 작업을 멈출 때도 비동기 프로그래밍에서 사용하는 asyncio.sleep과 대비되게 time 모듈을 사용합니다.
import time
def main():
print('time')
foo('text')
print('finished')
def foo(text):
print(text)
time.sleep(2)
main()
# 실행 결과
# time
# text
# finished
비동기(asynchronous) 처리
여러 작업을 처리하도록 예약한 뒤 작업이 끝나면 결과를 받는 방식입니다. (프로그램의 코드가 여러 프로세스 여러 스레드로 나뉘어 처리되는 방식을 말합니다.) 코드 아래에서 이어 설명하겠습니다.
비동기 함수, 네이티브 코루틴
코루틴은 필요에 따라 일시정지할 수 있는 함수를 말합니다. 코루틴은 다양한 언어에 존재하고 여러 형태로 구현될 수 있습니다. 특히, 파이썬에서는 제너레이터에 기반한 코루틴과 구분하기 위해, async def로 만든 코루틴을 네이티브 코루틴이라고 부릅니다. 이러한 코루틴 함수를 비동기 함수라고도 부르며, 네이티브 코루틴은 앞서 이야기한 것처럼 파이썬 3.5에서부터 등장합니다.
import asyncio
async def main(): # async def로 네이티브 코루틴을 만듦
print('Hello, world!')
asyncio.run(main()) # main 코루틴 함수를 실행
# 실행 결과
# Hello, world!
간단한 네이티브 코루틴을 구현했습니다. 먼저 async def로 네이티브 코루틴을 만듭니다. 그리고 async def 함수 범위 바깥에서 코루틴 함수를 실행하기 위해, asyncio.run(코루틴 객체)을 사용합니다. 네이티브 코루틴 함수를 호출하면 코루틴 객체를 생성하므로, 이를 asyncio.run()에 넣어주면 됩니다. 이렇게 하면 비동기 함수의 실행이 완료되고 생각했던 출력 결과를 얻습니다. 하지만 아직 코드에는 비동기적인 느낌이 없습니다.
await으로 네이티브 코루틴 실행하기
이번엔 조금 더 비동기적인 느낌을 내어 프로그램을 짜보겠습니다. 이를 위해, await이 필요합니다.
await은 네이티브 코루틴 함수 내에서만 사용할 수 있으며, 두 가지 기능을 수행합니다. 첫 번째 기능은 코루틴 함수를 실행(execute)하는 것입니다. 원래 async def 구문에서는 await이 코루틴 함수를 실행하는 키워드입니다. 하지만 앞서 말했듯 await은 async def 함수 내부에서만 사용이 가능하기 때문에, 코루틴 함수 밖에서 코루틴 함수를 실행할 때는 앞에서 봤던 asyncio.run()을 사용합니다. 두 번째 기능은 await 키워드 의미 그대로 await에 지정된 코루틴 함수가 종료될 때까지 기다리는 것입니다. 실제로 await 뒤에 코루틴 객체, 퓨처 객체, 태스크 객체를 지정할 수 있으며, 해당 객체가 끝날 때까지 기다린 뒤 결과를 반환합니다. (3가지 객체는 코루틴과 관련된 객체들이며 보통 어웨이터블(awaitable) 객체로 불립니다. 여기서는 코루틴 함수를 호출하면 리턴되는 코루틴 객체와 이후 만들 태스크 객체만 다루겠습니다.) 용법은 다음과 같고 변수에 할당하지 않아도 되지만, 할당한다면 해당 코루틴 함수가 return하는 값이 담깁니다.
변수 = await 코루틴객체
변수 = await 퓨처객체
변수 = await 태스크객체
await을 사용해 네이티브 코루틴을 실행해보겠습니다.
import asyncio
async def main():
print('time')
await foo('test')
print('finished')
async def foo(text):
await asyncio.sleep(1)
print(text)
asyncio.run(main())
# 출력 결과
# time
# test
# finished
이 경우 ‘time’이 출력되고 1초 후 ‘ test’와 ‘finished’가 출력되면서 네이티브 코루틴이 잘 실행됨을 확인할 수 있습니다.
다만, 실제 비동기 프로그래밍이라면 프로그램의 코드가 여러 스레드로 동시에 작업을 수행하기 때문에, foo 함수에서 1초를 기다리는 동작은 수행하더라도, 실제로 1초도 되기전에 ‘time’, ‘test’, ‘finished’가 모두 출력되며 프로그램이 마무리될 것입니다. (foo 함수에서 1초 기다리는 코드 asyncio.sleep(1)이 완료되기 전에 main 함수가 먼저 종료되기 때문에 foo 함수 종료 이전에 프로그램 자체가 먼저 종료될 것입니다!) 그래서 실제 비동기 실행을 위해서는 task 객체를 사용해야 합니다.
Task 객체를 생성해 비동기 실행하기
Task를 사용하면 실제로 비동기 실행을 할 수 있습니다.
import asyncio
async def main():
print('time')
asyncio.create_task(foo('test'))
print('finished')
async def foo(text):
asyncio.create_task(asyncio.sleep(1))
print(text)
asyncio.run(main())
# 출력 결과
# time
# finished
# test
await은 코루틴을 실행하는 역할과 해당 코루틴 함수가 종료될 때까지 기다리는 역할을 수행합니다. 그러나 await만으로는 여러 스레드에서 동시에 프로그램이 실행되는 비동기적인 실행이 되지 않습니다. 이러한 비동기적 실행을 위해서 task 객체를 사용합니다. asyncio.create_task(코루틴 객체)를 사용하면 해당 코루틴 객체에 대한 task 객체를 생성함과 동시에 해당 코루틴 함수를 비동기적으로 실행합니다. 즉, await을 사용하지 않아도 실제 비동기적으로 코루틴 함수를 실행합니다. 따라서, 위 코드는 앞서 이야기한 것처럼 ‘time’을 출력한 후 1초를 기다리지 않고 ‘finished’와 ‘test’가 출력됩니다.
‘finished’가 ‘test’보다 먼저 출력된 이유는 context switch에 의한 것으로 예상됩니다. 정확한 알고리즘은 알 수 없지만, 스레드가 서로 교차하다가 ‘finished’ 출력 스레드가 먼저 완료되고 그 다음 ‘test’ 출력 스레드가 완료되며 함수가 종료되었을 것입니다.
또한, asyncio.create_task(asyncio.sleep(1))에 해당하는 스레드는 아직 수행 중이겠지만, 1초가 지나기 이전에 main 함수가 종료되면서 프로그램은 종료됩니다. 만일 asyncio.create_task(asyncio.sleep(1)) 코드의 수행을 온전히 완료하고 프로그램을 종료하고 싶다면, 다음과 같이 원하는 위치에서 await으로 함수 종료를 기다리면 됩니다.
import asyncio
async def main():
print('time')
await asyncio.create_task(foo('test'))
print('finished')
async def foo(text):
await asyncio.create_task(asyncio.sleep(1))
print(text)
asyncio.run(main())
# 출력 결과
# time
# test
# finished
이렇게 되면, 프로그램은 비동기적으로 실행했지만 원하는 위치에서 임의로 함수의 종료를 기다린 후 다음 동작을 실행하게끔 할 수 있습니다. 위 경우 비동기적으로 함수를 실행했음에도 ‘time’ 출력 후 1초 기다린 다음 ‘test’와 ‘finished’가 출력됨을 확인할 수 있습니다.
조금 더 심화된 비동기 예제 살펴보기
앞의 내용들을 적용하여 조금 더 심화된 비동기 코드를 살펴보겠습니다.
import asyncio
async def main():
print('time')
asyncio.create_task(foo('test'))
await asyncio.sleep(0.5)
print('finished')
await asyncio.sleep(1)
async def foo(text):
await asyncio.sleep(1)
print(text)
await asyncio.sleep(1)
print(text)
asyncio.run(main())
# 출력 결과
# time
# finished
# test
위 코드는 task 객체를 생성해 비동기적으로 foo 함수를 실행합니다. 코드 수행이 여러 스레드로 나뉘어 동시에 이뤄지므로, 위 코드의 경우 main 함수와 foo 함수가 동시에 병렬적으로 실행되는 상황입니다. 시간 단위로 살펴보면 다음과 같습니다.
약 0초: ‘time’이 출력되면서 main과 foo 함수가 분기됩니다.
약 0.5초: main 함수의 await asyncio.sleep(0.5) 코루틴이 종료되고 ‘finished’가 출력됩니다.
약 1초: foo 함수의 await asyncio.sleep(1) 코루틴이 종료되고 ‘test’가 출력됩니다.
약 1.5초: main 함수의 await asyncio.sleep(1) 코루틴이 종료되면서 main 함수 종료와 함께 프로그램이 종료됩니다.
따라서 foo 함수의 마지막 ‘test’는 출력되지 않습니다.
Reference
Python3.8 asyncio, async/await 기초 - 코루틴과 태스크
asyncio 사용하기
Python 3, asyncio와 놀아보기
-
6-2. Process Synchronization 문제
3가지의 고전적인 Synchronization 문제
1. Bounded-Buffer Problem (Producer-Consumer Problem)
Bounded-Buffer Problem이란?
유한한 크기(그림은 circular 형태)를 가진 버퍼(임시로 데이터를 저장하는 공간)의 환경에서 발생하는 문제들을 의미한다. 이 문제는 생산자-소비자 문제(Producer-Consumer Problem)라고도 불리며, 이러한 상황을 가정 할 때는 여러 개의 생산자 프로세스와 여러 개의 소비자 프로세스가 존재한다. 생산자 프로세스들은 데이터를 생성해 빈 공유 버퍼에 삽입한다. 위 그림에서는 주황색으로 칠해져 있는 동그라미가 생산자 프로세스가 데이터를 저장해 둔 공유 버퍼이고, 색이 없는 동그라미가 비어 있는 버퍼이다. 그리고 소비자 프로세스들은 데이터가 존재하는 버퍼에 접근해 데이터를 빼내고 조작한다.
이러한 상황에서 두 가지 문제가 발생할 수 있다. 첫째로, 하나의 버퍼에 둘 이상의 프로세스가 접근했을 때 발생하는 문제이다. 이는 생산자와 소비자 각각의 측면에서 살펴볼 수 있는데, 생산자 측면에서는 하나의 비어있는 버퍼에 두 가지 이상의 생산자 프로세스가 접근해 조작을 시도하면 synchronization 문제가 발생한다. 소비자 측면에서도 데이터가 존재하는 버퍼에 둘 이상의 소비자 프로세스가 접근하면 마찬가지로 synchronization 문제가 발생한다. 이 문제들을 해결하기 위해서는 앞에서 살펴봤듯이 하나의 프로세스가 버퍼를 조작할 때 lock을 걸고, 작업을 완료할 때 lock을 푸는 과정이 필요하다.
둘째로, 전체 버퍼의 유한함으로 인해 발생하는 문제가 있다. 먼저, 생산자 측면에서는 생산자 프로세스들만 계속 접근해 공유 버퍼가 가득 차는 경우가 생길 수 있다. 데이터가 버퍼에 가득 찬 상황에서는 다른 생산자 프로세스가 접근해도 데이터를 생성할 수 없어 데이터가 소비되길 기다려야만 하는 상황이 발생한다. 이런 상황에서는 소비자 프로세스가 와야지만 다음 생산자 프로세스의 작업 수행이 가능해진다. 이 때, 생산자 프로세스 입장에서는 빈 버퍼 공간이 자원이며, 전체 버퍼가 가득찬 상황은 자원을 획득할 수 없는 상황으로 간주된다. 반면에 소비자 측면에서는 소비자 프로세스들만 득세하여 전체 버퍼가 비어 있는 상황이 발생할 수 있다. 이 경우 획득할 수 있는 데이터가 없어 소비자 프로세스들은 다른 생산자 프로세스가 올 때까지 끝없이 기다려야 한다. 이 경우, 소비자 프로세스 입장에서 데이터가 있는 버퍼가 자원이며, 전체 버퍼가 비어있는 상황은 자원을 획득할 수 없는 상황으로 볼 수 있다.
필요한 Semaphore
공유 데이터의 Mutual Exclusion을 위한 Binary semaphore
버퍼의 Resource Count를 위한 Counting semaphore
Bounded-Buffer Problem을 Semaphore를 이용해 수도 코드로 나타내면 위와 같다. 먼저 Semaphore 변수로 lock을 나타내는 mutex와 내용이 들어있는 버퍼의 개수를 나타내는 full, 빈 버퍼의 개수를 나타내는 empty 총 3가지를 가진다. 그리고 이 변수들을 사용한 P, V 연산을 수행해 생산자 프로세스와 소비자 프로세스의 자원을 획득 및 반납하는 과정을 위와 같이 나타낸다.
2. Readers and Writers Problem
Reader and Writers Problem은 데이터를 읽는 것에 대한 고민을 반영한다. 여기서는 주로 DB에서 이러한 문제가 발생하기 때문에 공유데이터를 DB라고 특정지어 얘기한다. 기본적으로 synchronization 문제를 예방하기 위해 한 프로세스가 공유 데이터에 접근 중일 때 lock을 걸고 다른 프로세스가 공유 데이터에 접근하는 것을 막아야 한다. 하지만 Reader and Writers 문제에서는 어떤 한 프로세스가 DB에 write하는 경우를 제외하고는 언제든 다른 여러 프로세스들의 read 접근을 막아야할 이유가 없다. 즉, 한 프로세스가 DB에 write하는 경우에만 모든 접근을 막고, 그 이외의 상황에서는 모든 프로세스들의 read 접근을 허용하는 것을 지향한다.
Reader and Writers Problem은 위와 같은 수도 코드로 구현한다. 공유 데이터로 DB 자체와 접근 중인 reader 프로세스의 수를 세는 readcount를 두고, semaphore 변수로 readcount로의 접근에 대해 lock 걸기 위한 mutex, DB로의 접근에 lock을 걸기 위한 db를 둔다.
Writer 입장에서는 단순히 db 변수로 lock을 걸어 DB 공유 데이터에 접근해 쓰기 작업을 수행하고, 작업이 끝나면 lock을 풀고 나오면 된다. 반면에 Reader 입장에서는 조금 더 복잡해지는데, Reader 프로세스는 DB에 접근하기 전에 먼저 readcount 데이터를 조작하여 자신의 출석을 알린다. 변수 readcount도 공유 데이터이므로 synchronization 문제를 예방하기 위해 mutex 변수로 lock을 걸고 데이터를 조작한다. 이 때, 만일 자신이 첫 번째로 read를 진행하는 프로세스라면(readcount의 값이 1이라면), DB에 lock을 걸고 read를 진행한다. 그리고 mutex lock을 풀고 DB에 대한 읽기를 진행한다. DB에 걸린 lock의 경우 writer 프로세스만 차단하는 것이어서, 이 사이에 CPU가 다른 프로세스들에게 넘어가면서 여러 프로세스들이 DB에 대한 읽기를 진행할 수 있다. Reader 프로세스는 DB 읽기를 마무리하면 다시 mutex로 lock을 걸고 readcount 변수의 값을 감소시켜 자신이 바깥으로 나감을 기록한다. 그리고 mutex lock을 다시 풀고 작업을 마무리한다. 이 때, 가장 마지막에 나가는 Reader 프로세스는(readcount의 값을 0으로 만든 프로세스는) mutex lock을 풀기 전 DB에 대한 lock도 풀어 writer의 접근을 허용시킨 후 작업을 마무리해야 한다.
이러한 구현에서 한 가지 유의할 점은 계속 Reader 프로세스가 들어오면 Writer 프로세스에게는 DB에 접근할 기회가 돌아오지 않는 Starvation 문제가 발생할 수 있다는 것이다.
3. Dining-Philosophers Problem
Dining-Philosophers Problem 역시 synchronization 문제를 표현한다. 이 문제에서 테이블에는 철학자 다섯이 앉아 있고, 철학자는 생각하는 행동과 먹는 행동 두 가지만을 실행한다. 철학자들의 사이사이에는 한 개의 젓가락이 놓여 있으며, 철학자가 음식을 먹으려면 자신의 양쪽에 있는 젓가락을 함께 들어야만 한다.
이러한 문제는 위와 같은 수도 코드로 나타낼 수 있다. Semaphore 배열 chopstick은 5개의 젓가락에 대한 사용 여부를 0과 1값으로 표현한다. 그리고 chopstick[i]는 자신의 왼쪽 젓가락, chopstick[i + 1]은 자신의 오른쪽 젓가락을 나타낸다. 만일 자신의 오른쪽이나 왼쪽에 있는 철학자가 음식을 먹고 있다면, 그 차례가 끝나고 자신이 양쪽 젓가락을 모두 확보할 수 있을 때에서야 비로소 음식을 먹을 수 있다.
그런데 위와 같은 코드는 데드락(Deadlock)이라는 치명적인 결함이 생길 수 있다. 예를 들어, 모든 철학자가 배가 고파 왼쪽의 젓가락을 동시에 집는 경우, 아무도 음식을 먹을 수 없는 상황이 발생하는 것이다.
이러한 상황을 해결하기 위해서 3가지 해결책이 존재하는데, 먼저 4명의 철학자만이 테이블에 앉게 하면 적어도 데드락 문제는 분명히 피할 수 있다. 둘째로 젓가락을 모두 집을 수 있을 때에만 젓가락을 집을 수 있게 허용하는 방법이 있다. 이렇게 하면 젓가락 한 쪽만 집는 상황이 예방되어 데드락을 피할 수 있다. 끝으로 홀수 번째 철학자는 오른쪽 젓가락을, 짝수 번째 철학자는 왼쪽 젓가락을 먼저 집도록 하는 비대칭 전략 역시 데드락을 피하는 좋은 방법이 된다.
위 수도 코드는 Dining-Philosophers Problem의 데드락 문제에 대한 두 번째 해결책을 구현한 것이므로 참고하자.
Monitor
Semaphore는 프로그래머의 코딩 환경에 편의를 제공하지만, 한 번의 실수가 모든 시스템에 치명적인 영향을 주고 그 버그를 잡아내기가 쉽지 않다는 단점을 가진다. 이러한 단점을 보완하기 위해 Monitor가 존재한다. Monitor는 동시 수행중인 프로세스 사이에서 abstract data type의 안전한 공유를 보장하기 위한 high-level synchronization construct이다. 즉, 어떤 공유 데이터를 저장하고 있다면, 미리 정의된 특정 프로시져를 통해서만 이 공유 데이터에 접근하게 하는 것이 Monitor의 주요 기능이다. Semaphore는 공유 데이터에 접근하는 경우 항상 lock을 걸고 풀어야 하는 번거로움이 있는데, Monitor는 정해진 프로시저를 통해 공유 데이터에 접근하면 굳이 lock을 걸지 않아도 알아서 synchronization 문제를 예방해준다는 장점이 있다.
Monitor에는 어떤 조건에 따라 프로세스의 상태를 통제하는 condition variable(위 그림의 x, y)과 프로세스를 condition variable에 줄세우고 잠들게 하는 wait() 연산, condition variable에 잠자고 있는 프로세스를 깨우는 signal() 연산이 존재한다. 예를 들어, x.wait()은 프로세스를 x라는 condition variable에 줄세우고 잠들게하는 작업을 수행하고, x.signal()은 x에 잠들어 있는 프로세스 하나를 깨워주는 작업을 한다. 즉, wait() 연산이 적용된 프로세스는 다른 프로세스가 signal() 연산을 사용하기 전까지 suspend 상태가 된다. 반면에, signal() 연산은 suspend된 프로세스 하나를 다시 동작하게 하는 일을 한다.
Monitor 버전의 코드는 프로그래머 입장에서 Semaphore 코드에 비해 훨씬 직관적으로 이해된다. 그리고 Semaphore를 이용한 코드와 언제든 서로 변환시키기 용이하다. 위의 수도 코드들은 각각 Bounded-Buffer Problem, Dining-Philosophers Problem을 Monitor를 이용한 코드로 변환한 것인데, 공유 데이터에 접근할 때 lock을 걸고 푸는 과정이 없어 이전 semaphore를 사용해 만든 코드보다 더 직관적이고 용이하게 코드가 구성됨을 알 수 있다.
Reference
운영체제, 이화여대 반효경 교수님
-
6-1. Process Synchronization 문제
Process Synchronization 문제
위 그림처럼 S-box(Storage-Box)에 담긴 데이터에 여러 E-box(Execution-Box)가 접근하는 경우, 복수의 E-box(여러 프로세스)들이 동시에 데이터에 접근하는 Race Condition이 발생한다. 이러한 상황에서 데이터의 최종 연산 결과가 마지막에 해당 데이터를 다룬 프로세스에 의해 원치 않는 결과로 이어질 수 있는데, 이처럼 공유 데이터에 동시 접근이 일어나 데이터의 불일치가 생기는 문제를 Process Synchronization이라고 한다.
OS에서 Race Condition이 발생하는 3가지 경우
1. kernel 수행 중 인터럽트 발생 시
Process Synchronization은 인터럽트로 인해 발생하기도 한다. 예를 들어, 어떤 값에 count++와 count–가 수행되면 원래의 값에서 +1을 하고 다시 -1을 연산하는 것이므로 원래의 값으로 돌아올 것이 기대된다. 그런데, count++가 먼저 수행되어 데이터를 읽은 상태에서 인터럽트가 들어와 count–가 데이터를 읽고 연산해 저장하는 상황을 생각해볼 경우, 본래 진행하던 count++ 연산의 결과가 다시 데이터를 덮어 쓰게 되어 원하는 결과를 얻지 못하게 된다.
이러한 문제를 해결하기 위해서, 중요한 변수를 건드리는 kernel 연산은 인터럽트가 들어와도 그 연산이 끝난 후 인터럽트 처리루틴을 실행하는 것이 하나의 방법이 될 수 있다.
2. Process가 system call을 해 kernel mode로 수행 중인데, context switch가 일어나는 경우
Process Synchronization의 또 다른 경우는 context switch로 인해 발생할 수 있다. 만일 프로세스 A가 시스템콜을 하여 커널모드에서 count++ 연산을 수행 중이었는데 CPU 할당 시간이 끝나 프로세스 B에게 CPU가 넘어간 상황을 가정해보자. 프로세스 B에서는 또 다른 count++ 연산을 진행해 커널에 데이터를 저장했고 다시 CPU를 프로세스 A에게 넘겼다. 이 때, 프로세스 A가 본래 진행 중이던 count++ 연산을 마무리해 데이터를 커널에 저장하면, 프로세스 A의 본래 context에서의 연산 결과로만 덮어 쓰게 되어, 프로세스 B의 연산 결과는 사라지게 된다.
이를 해결하기 위해서 어떤 프로세스가 커널 모드에 있다면, CPU 할당 시간이 지나도 CPU를 다른 프로세스에게 preempt 당하지 않도록 하고 커널모드에서 사용자 모드로 돌아가는 순간에 다른 프로세스가 preempt 하게 만들 수 있다. 물론 이 경우 한 프로세스에게 CPU 할당 시간이 더 많이 돌아갈 수 있지만, time sharing 시스템에서 이 정도 할당 시간은 큰 영향을 미치지 않는다.
3. Multiprocessor에서 shared memory 내의 kernel data
CPU가 여럿 있는 경우에서 나타나는 Process Synchronization은 데이터의 접근하는 주체가 각각 다른 것이기 때문에, 단순히 인터럽트를 막는 차원에서 해결되지 않는다.
이를 해결하기 위해 두 가지 방법이 있다. 첫 번째는 이미 접근한 CPU가 있을 시, 커널 전체에 lock을 걸어두는 방법이다. 한 CPU가 커널에 접근하면 커널 자체에 lock을 걸어 다른 CPU의 접근을 막고 작업이 끝났을 때 lock을 풀면 Process Synchronization을 막을 수 있다. 두 번째는 커널 내부의 어떤 공유 데이터에 접근할 시, 해당 데이터에만 lock을 걸고 해제하는 방식이다. 커널 내부의 한 데이터에 lock이 걸려도 다른 데이터에는 다른 CPU가 접근할 수 있다는 점에서, 전자보다 후자의 방법이 더 효율적이라고 볼 수 있다.
Critical section (임계 구역) 문제
1. Critical section (임계 구역)
복수의 프로세스가 공유 데이터를 동시에 사용하길 원하는 상황에서, 각 프로세스의 코드에는 critical section(임계 구역)이라고 불리는 공유 데이터에 접근하는 코드가 존재한다. 만일 하나의 프로세스가 자신의 critical section에 있는 경우, 다른 모든 프로세스는 critical section에 진입하지 않아야 한다.
위 코드는 임의의 프로세스에 대한 코드를 나타내는데, 이에 대하여 어떤 코드든 공유 데이터에 접근하거나(critical section), 접근하지 않는(remainder section) 부분으로 나뉘어질 것이다. 이 때, critical section 진입 전 entry section에서 lock을 걸어 다른 프로세스의 critical section 진입을 막고, exit section에서 lock을 풀어 다른 프로세스들이 critical section에 진입할 수 있게 하는 작업들이 동반되어야 한다.
2. Critical section 문제 해결을 위한 조건
Mutual Exclusion (상호 배제)
한 프로세스가 critical section을 수행 중이라면, 다른 프로세스는 critical section에 진입해서는 안된다.
Progress (진행)
critical section에 진입한 프로세스가 없는 상황에서 critical section 진입을 원하는 프로세스가 있다면, 진입을 허가해주어야 한다. (간혹, 동시 진입을 막으려고 짠 코드가 실수로 모두의 진입을 막는 경우가 있으므로 유의한다.)
Bounded Waiting (유한 대기)
프로세스가 critical section 진입을 요청하면, 그 요청이 허용될 때까지 다른 프로세스들의 critical section 진입 횟수의 제한이 있어야 한다. (다른 프로세스들이 계속 번갈아 critical section에 진입하는 바람에, 새로 진입을 요청한 프로세스가 critical section에 진입하지 못하는 상황이 없어야 한다.)
Critical section 문제 해결 조건을 만족하는 알고리즘
1. Algorithm example 1 (turn을 교대로 넘기기)
위 코드는 P0(프로세스 0)를 위한 코드이다. 그리고 위와 전체적으로 동일하지만 코드의 두 부분이 while (turn != 1), turn = 0로 구성된 P1(프로세스 1)의 코드가 또 하나 존재할 것이다. 또한, 위의 turn 변수는 P0와 P1 중 어떤 프로세스가 critical section에 들어갈 차례인지를 나타낸다.
이 코드의 경우 P0는 자신의 차례가 아닐 때(turn=1), entry section에 해당하는 while 문 안에서 돌아간다. 그 후, P1 쪽의 코드에서 turn = 0가 되어 P0의 차례가 왔을 때 비로소 critical section에 진입한다. Critical section의 코드를 마쳤을 때, P0는 exit section에 진입해 turn = 1을 수행하고 P1에게 차례를 넘기며 나머지 코드를 수행한다. 반대로 P1은 P0의 과정을 P0보다 먼저 겪은 것이고 둘은 서로 번갈아 이 과정을 그대로 반복하며 각자의 작업을 완료해 나간다.
이 예시 코드는 critical section 해결 조건 중 Mutual Exclusion을 만족하지만, Progress 조건은 만족하지 못한다. 만일 P0가 더 빈번히 critical section에 진입하고 싶어하고 P1은 한 번만 critical section에 진입하고 싶어할 때, P1이 최초 critical section 진입 이후에는 더이상 critical section에 진입하지 않아 P0에게 차례를 주지 않는 상황이 발생한다. 이 경우, critical section에 진입해 있는 프로세스가 아무 것도 없음에도 진입 요청이 있는 프로세스에게 차례가 돌아가지 않아 progress 조건을 충족하지 못하게 된다.
2. Algorithm example 2 (flag로 각자의 critical section 진입 의사 밝히기)
두 번째 알고리즘 예시는 프로세스 각자에 flag를 두어 critical section 진입 의사를 각자 나타내게 하는 방법이다. 이 알고리즘도 Mutual Exclusion을 보장한다. 그리고 이 방법은 각자의 critical section 진입 의사를 체크할 수 있어 1번 예시 알고리즘처럼 아무도 critical section에 진입해 있지 않은데 critical section에 못 들어가는 상황을 방지할 수 있다. 하지만 반대로 P0가 자신의 코드에서 flag = true를 수행하자마자 P1에서 CPU를 점유해 자신의 flag를 true로 만들면, 서로 양보하다가 둘 모두 critical section에 진입하지 못하는 상황이 발생할 수 있다. 따라서 이 경우도 Progress 조건을 만족하지 못한다.
3. Peterson’s Algorithm
3 번째 알고리즘은 Peterson’s Algorithm이다. 앞선 두 알고리즘의 예시를 모두 가져와 복합적으로 만든 것인데, 이 알고리즘의 경우 Mutual Exclusion과 Progress 조건을 모두 만족시킨다. 프로세스는 flag로 자신의 critical section 진입 의사를 밝히고 turn을 상대방의 차례로 설정하는 방법을 기본으로 해 서로의 critical section 진입을 체크한다. 위 코드처럼 while문 조건으로 flag와 turn을 모두 사용하면, 앞선 두 알고리즘 예시의 문제들을 모두 해결할 수 있어 Progress 조건까지 포함해 모든 critical section 문제 해결 조건을 충족하는 알고리즘을 만들 수 있게 된다.
하지만 이 알고리즘도 문제는 있는데, 바로 Busy Waiting(=spin lock)이다. 다른 프로세스의 critical section 수행을 기다리는 동안 while문으로 lock에 걸리는 바람에 자신의 CPU 할당 시간이 돌아와도 그 시간 내내 while문 안에서 헛돌며 CPU와 메모리를 낭비하게 된다.
4. Hardware Synchronization
사실 하드웨어적으로 atomic하게 lock을 걸 수 있다면, 앞의 존재했던 문제들은 자연스럽게 해결된다. 앞 알고리즘들의 문제들은 결국 데이터의 읽기와 쓰기가 하나의 instruction 안에서 해결되지 않기 때문에 발생하는 것들이다. 특히 고급 언어로 쓰여진 한 줄의 코드는 여러 instruction들의 묶음일 수 있는데, 이 코드 속 instruction들을 몇 개 실행하는 중간에 다른 프로세스에 CPU를 빼앗겨 프로세스들간의 읽기와 쓰기의 순서가 섞이게 되면, process들 간의 critical section 문제가 발생하는 것이다. 따라서 하드웨어적으로 읽기와 쓰기를 하나의 instruction 속에서 처리하는 Test_and_set() 함수를 사용하면 critical section 문제를 근본적으로 예방할 수 있다.
Test_and_set(x)은 변수 x에 담긴 데이터를 읽어 들이고 그 값을 1로 재설정한다. 특징은 데이터를 읽고 쓰는 이 과정이 앞서 말했듯 하나의 instruction으로 수행된다는 점이다. 이로 인해 새롭게 수정한 위의 코드는 앞의 알고리즘들의 비해 보다 간결해진다. 만일 P0가 처음 CPU를 점유했다면, while문을 실행할 때 Test_and_set(lock)이 false(0)를 읽어와 while문은 그대로 지나가게 되고 lock의 값은 true(1)로 재설정해 lock이 걸리게 된다. 그리고 lock을 건 상태에서 critical section에 진입하게 된다. 이 상태에서는 CPU가 P1에게 넘어가더라도 P1은 이미 걸려있는 lock으로 인해 while문 안에 갇히게 되므로 critical section 문제를 피할 수 있게 된다. 그리고 P0가 다시 CPU를 얻어 critical section 코드를 완료하면 false(0)값을 할당해 lock을 풀며 나머지 코드를 실행하게 되고, P1은 다음 CPU 점유 시 critical section에 진입할 수 있게 된다.
Semaphore 자료형
앞의 방식들은 모두 프로그래머가 직접 코딩해야 하는 번거로움이 있다. 이 과정을 추상화하여 프로그래머의 편리한 코드 작성 환경을 제공하는 방법으로 Semaphore 자료형이 존재한다. Semaphore 자료형은 정수값을 가질 수 있는 자료형이고 P 연산과 V 연산이라는 두 가지 atomic한 Operation이 존재한다. Semaphore의 정수값은 자원의 수(공유 데이터의 수)를 의미한다. P 연산은 이러한 공유 데이터를 획득하는 연산이고 반대로 V 연산은 다 사용한 공유 데이터를 반납하는 연산으로 볼 수 있다. 구체적으로 P 연산은 자원이 0개일 때는 while문을 돌며 기다리다가, 1개 이상의 자원이 생기면 자원을 가져가 변수값을 감소시키는 모습을 보이고, V 연산은 쓰던 자원을 반납하여 변수값을 증가시키는 모습을 띈다. 이러한 이유로 P 연산의 경우는 while문에 갇혀 Busy-Waiting이 발생할 수 있다.
Critical section 문제를 해결하기 위해 사용한 lock을 걸고 푸는 개념도 Semaphore 자료형을 사용하면 쉽게 접근할 수 있다. Lock의 경우 변수 값이 1인 Semaphore를 생각할 수 있으며, P 연산이 lock을 거는 과정, V 연산이 lock을 푸는 과정에 해당한다. 위 그림은 Semaphore 자료형을 critical section 문제에 적용한 pseudo 코드이다. Lock 변수를 Mutual Exclusion을 나타내는 mutex 변수로 사용하고 P 연산과 V 연산을 사용해 lock을 걸고 풀면, 간결하게 critical section 문제를 다룰 수 있다.
Busy Waiting 문제를 해결하는 Block & Wakeup 방식 (=sleep lock)
Semaphore를 이용하여 위와 같이 Busy Waiting 문제를 해결할 수 있다. 이를 Block & Wakeup 방식이라고 하는데, 먼저 semaphore에는 프로세스들의 block과 wakeup 상태를 확인하기 위해 사용하는 변수 value와 프로세스들을 기다리게할 wait queue에 해당하는 L을 정의한다. 그리고 프로세스가 자원을 획득할 수 없는 경우, PCB를 block 상태로 만들고 wait queue에 대기시킨다. 이 때, 다른 프로세스의 자원이 반납되면 wait queue에 block 상태로 있는 프로세스를 wakeup 상태로 만들고 해당 프로세스에게 자원을 내어준다.
구체적으로 살펴보면, Block & Wakeup 방식에서 semaphore의 value 값은 자원의 수라기 보다 wait queue에 block 상태로 존재하는 프로세스가 있는지 없는지 확인하는 역할을 한다. 그리고 P 연산은 프로세스가 자원을 획득하는 연산이다. 일단 semaphore의 변수 value의 값을 감소시키는데, 만일 value 값이 음수가 될 경우, 획득을 요청한 프로세스를 semaphore의 wait queue에 넣고 block 상태로 만든다. V 연산의 경우, semaphore의 value 값을 증가시킴으로써 프로세스의 자원을 반납한다. 만일 값이 증가한 이후에 변수 value의 값이 양수라면 wait queue에서 대기하는 프로세스가 없다는 의미이므로 연산이 마무리된다. 하지만 value 값이 0 이하일 경우, wait queue에 block 상태로 존재하는 다른 프로세스가 있다는 의미이므로, 가장 순서가 먼저인 프로세스를 wakeup 상태로 만드는 작업까지 수행한다.
Busy Wait 방식 VS Block & Wakeup 방식
일반적으로 Block & Wakeup 방식이 더 좋다. 그러나 Critical Section의 길이를 기준으로 생각해보면, 다른 양상을 띌 수 있다. Block 상태와 wakeup 상태 사이를 왔다갔다하는 overhead도 크기 때문에, Critical section의 길이가 짧으면 Busy Wait 방식을 채택하는 것이 나을 수도 있다. 반면에, critical section의 길이가 길면 CPU와 메모리의 무의미한 낭비를 막기 위해 Block & Wakeup 방식을 채택하는 것이 효율적이다.
Semaphore의 두 가지 타입
1. Binary semaphore (=mutex)
자원이 하나라서 0 또는 1값만 가질 수 있는 semaphore다. 주로 lock을 걸어 mutual exclusion을 구현할 때 사용한다.
2. Counting semaphore
주로 자원이 여러 개 있어서 이를 세기 위해 사용되며, 임의의 정수 값을 가지는 semaphore이다.
Deadlock과 Starvation
1. Deadlock
둘 이상의 프로세스가 서로 상대방에 의해서만 충족될 수 있는 event를 무한히 기다리는 현상을 말한다. 예를 들어, 1로 초기화된 두 가지 semaphore S(하드디스크 1에 관한 semaphore)와 Q(하드디스크 2에 관한 semaphore)가 있다고 해보자. (lock & unlock 기능) 이 때, 하드디스크 1에 있는 내용을 읽어와서 하드디스크 2에 이를 쓰는 작업을 수행하려고 한다. 이 작업을 수행하려면, 하나의 프로세스가 홀로 S와 Q 모두를 획득해야만 한다. 그런데 만일 P0(프로세스 0)이 S를 획득한 상태에서 P1(프로세스 1)에게 CPU를 넘겨 준다면, P1은 남아 있는 Q를 획득한다. 이렇게 되면, CPU가 P0에게 다시 넘어가도 P0는 Q를 획득할 수 없어, 진행해야 할 작업을 하지 못하고 Deadlock 상태에 빠지게 된다. 반대로 P1 입장에서도 S를 얻지 못해 작업을 수행하지 못하는 상황에 처한다.
이 경우, 자원을 획득하는 순서를 똑같이 맞춰주면 간단히 deadlock 문제가 해결된다. P0나 P1이나 S부터 획득하고 그 다음 Q를 획득하게 한다면, 한 프로세스가 P를 획득한 상태에서는 다른 프로세스가 P를 획득하지 못해 Q에 대해 획득하려는 시도까지 이어지지 않게 된다.
2. Starvation
특정 프로세스들만 자원을 공유함으로 인해, 다른 프로세스들은 자원을 영원히 획득하지 못하는 상황을 의미한다.
Reference
운영체제, 이화여대 반효경 교수님
-
5-2. CPU 스케줄링
Multi-level queue
우선도가 다른 ready queue
Ready queue를 foreground(interactive)와 background(batch - no human interaction)으로 분리한다. 그리고 foreground에는 RR, background에는 FCFS 등으로 각 큐에 독립적인 스케줄링 알고리즘을 설계한다. 또한 어떤 큐에게 CPU를 줄 지 (그 이후에는 큐에 있는 어떤 프로세스에게 CPU를 줄 지)결정하는 작업이 필요한데, 이를 큐에 대한 스케줄링으로 해결한다. Fixed priority scheduling은 우선도를 최우선으로 하여 우선도가 높은 foreground에게 먼저 scheduling하고 그 다음 background에게 주는 방식이다. 이 방식에서는 starvation이 단점이 될 수 있다. 이에 대한 대안으로 Time Slice가 있는데, 이 스케줄링은 각 큐에 CPU time을 적절한 비율로 할당한다. (ex. foreground에 80% background에 20% CPU time 분배)
Multi-level feedback queue
우선도가 높은 queue여도 상황에 따라 낮은 우선도 queue가 높은 우선도 queue보다 우선될 수 있다. Multi-level queue의 고정된 우선도라는 단점을 극복하기 위한 대안이다. 예를 들어, 들어오는 프로세스를 우선도가 가장 높은 queue에 줄 세우고 RR 방식을 사용하되, 우선도가 낮은 queue일수록 time quantum을 길게 준다. 그래서 time quantum 내에 프로세스가 완료되면 큐에서 내보내고, 완료되지 않았으면 다음으로 우선도가 높은 큐에 해당 프로세스를 줄 세운다. 이렇게 하면 CPU burst가 짧은 프로세스에 우선 순위를 더 많이 주고, CPU burst가 긴 프로세스의 우선도는 더 낮출 수 있다.
특수한 상황에서의 CPU Scheduling
1. Multiple-Processor Scheduling (간략히 다룸)
Homogeneous Processor라면
Queue에 한 줄로 세워서 각 프로세서가 알아서 꺼내가게 할 수 있는가하면 어떤 프로세스는 특정 프로세서에서만 수행되어야 하는 경우가 존재하므로 이를 고려해야 한다.
Load sharing
일부 프로세서에 job이 몰리지 않게 하는 적절한 메커니즘이 필요하다. 모든 CPU가 공동 큐를 사용하는 방법 혹은 각각의 CPU마다 별개의 큐를 사용하는 방법이 있다.
Symmetric Multiprocessing (SMP)
각 프로세스가 각자 알아서 스케줄링을 결정한다.
Asymmetric Multiprocessing
하나의 프로세서가 시스템 데이터의 접근과 공유를 책임지고 나머지 프로세서는 그것에 따른다.
2. Real-Time Scheduling
Time sharing과 달리 미리 스케줄링을 계획하고 데드라인이 보장되도록하는 방식
Hard real-time systems
정해진 시간안에 반드시 끝내도록 스케줄링하는 것
Soft real-time computing (많이 쓰임)
영화 스트리밍과 같이 time sharing 시스템에서 다른 일반적인 프로세스들과 섞여서 실행되지만, 일반 프로세스에 비해 높은 priority를 갖게해 데드라인을 지키도록 지향하는 스케줄링. (조금은 데드라인을 어기는 것이 허용됨)
3. Thread Scheduling
Local Scheduling
User level thread의 경우 운영체제가 thread의 존재를 모르기 때문에, 사용자 수준의 thread library에 의해 어떤 thread를 스케줄할지 결정한다. (운영체제는 CPU를 프로세스에게 전달만 하고 어떤 스레드에 CPU를 줄지는 해당 프로세스 내부에서 결정한다.)
Global Scheduling
Kernel level thread의 경우 운영체제가 thread의 존재를 알고 있기 때문에, 일반적인 프로세스와 마찬가지로 커널의 단기 스케줄러가 어떤 thread를 스케줄할지 결정한다.
Algorithm Evaluation
1. Queueing models
(Server를 CPU로 보자.) 확률분포로 주어지는 arrival rate와 service rate 등을 통해 각종 performance index 값을 계산한다. (이론적 측면에서 많이 사용하는 방법)
2. Implementation (구현) & Mesurement (성능 측정)
실제 시스템에 알고리즘을 구현하여 실제 작업에 대해서 성능을 측정 및 비교한다.
ex) 리눅스 커널에 나의 CPU 스케줄링 알고리즘을 구현해보고, 실제 프로그램을 돌려서 원래의 리눅스 환경과 나의 알고리즘이 적용되어 있는 리눅스 커널의 성능을 비교해본다.)
3. Simulation (모의 실험)
알고리즘을 모의 프로그램으로 작성 후 trace(실제 프로그램으로부터 추출한 input data)를 입력으로 하여 결과를 비교한다.
Reference
운영체제, 이화여대 반효경 교수님
-
5-1. CPU 스케줄링
CPU Burst & I/O Burst
어떤 프로그램이 실행된다는 것은 CPU Burst와 I/O Burst가 번갈아 가며 일어나는 것을 의미한다. 프로그램의 종류에 따라 두 Burst의 빈번함이 다를 수 있는데, ① 사용자 관여가 많은 (키보드 입력, 모니터 출력 등이 잦은) 프로그램(interactive job)은 CPU Burst 시간이 짧아지면서 두 Burst가 번갈아 빈번히 나타나고, ② 과학 계산용 프로그램 같은 연산 시간이 긴 프로그램은 CPU Burst 시간이 길어지면서 I/O 비중이 크게 줄어든다.
위 그래프는 CPU Burst 시간과 그 빈도에 따라 프로그램들을 분류한 것인데, CPU Burst 시간이 짧을수록 프로그램의 CPU Burst 빈도가 잦음을 알 수 있다. 이 같이 CPU를 잡고 계산하는 것보다 I/O에 더 많은 시간을 사용하는 프로그램들을 I/O bound job이라고 하며, 반대로 계산 위주로 구성된 프로세스는 CPU bound job이라고 부른다.
여러 종류의 job(=process)이 섞여 있기 때문에, 그들을 적절한 CPU Scheduling이 필요하다.
→ CPU bound job이 CPU를 너무 오래 사용하면 효율성이 떨어지므로, I/O bound job(=Interactive한 job)에게 우선적으로 CPU를 주도록 지향하는 것이 CPU Scheduling의 주요한 목표이다.
CPU Scheduler & Dispatcher
1. CPU Scheduler
운영체제의 여러 코드 중 CPU schedule 기능을 담당하는 코드를 지칭하는 용어다.
Ready 상태의 프로세스 중 어떤 프로세스에게 CPU를 줄 지 결정한다.
2. Dispatcher
역시 운영체제의 여러 코드 중 특정 코드를 지칭하는 용어다.
CPU 제어권을 CPU scheduler에 의해 선택된 프로세스에게 넘긴다. 이 과정을 문맥 교환(Context Switch)이라고 한다.
CPU Scheduling이 필요한 경우
1, 4의 스케줄링은 nonpreemptive(=자진 반납, 비선점형), 나머지 모든 스케줄링은 preemptive(=강제로 뺏음, 선점형, 대부분의 현대적인 CPU 스케줄링에서 사용)
3의 경우 일반적으로 원래 CPU를 점유하던 프로세스에게 timer가 끝날 때까지 CPU를 다시 쓰게 하지만, 만약 우선순위가 가장 높은 프로세스의 I/O가 완료된 것이었다면 해당 프로세스에게 CPU를 바로 넘기게 된다.
Scheduling Criteria (CPU 스케줄링 성능 척도)
1. 시스템 입장에서의 성능 척도
: CPU 하나로 최대한 일을 많이 시키자!
CPU utilization (이용률) : 전체 시간 중 CPU가 놀지 않고 일한 시간의 비율
Throughput (처리량) : 주어진 시간 동안 완료한 작업(process)의 수
2. 프로그램 입장에서의 성능 척도
: 내가 CPU를 빨리 얻어서 내가 빨리 끝나는 게 중요!
Turnaround Time (소요시간, 반환시간) : CPU를 사용하기 위한 대기시간을 포함해 CPU를 사용완료하고 빠져나갈 때까지 걸린 총 시간 (다른 프로세스와 번갈아 CPU를 사용하게 되어도 그 모든 시간을 합하여 계산한다.)
프로세스가 CPU를 쓰러 대기열에 들어와서 CPU를 사용하고 I/O하러 나갈 때까지의 시간
ex) 중국집 손님이 코스요리를 시켰을 때, 중국집에 들어와서 요리를 기다리고 먹고를 반복하다가 다 먹고 나갈 때까지의 모든 시간
Waiting Time (대기시간) : Ready queue에서 대기하며 걸린 순수한 시간
CPU Burst와 I/O Burst가 번갈아 반복된다면, 그동안 생긴 여러 번의 대기 시간을 모두 합하여 계산하는 것이 아래의 Response Time과의 차이점이다.
ex) 손님이 코스요리 음식을 기다린 모든 시간
Response Time (응답시간) : Ready queue에 들어와서 처음 CPU를 얻기까지 걸린 시간 (∝ time sharing)
ex) 첫 번째 음식이 나올 때까지 기다린 시간
CPU Scheduling Algorithm
1. FCFS (First-Come First-Served) - nonpreemptive (비선점형)
먼저 들어온 프로세스를 먼저 처리한다. 먼저 들어온 프로세스가 CPU bound job일 경우 처리 시간이 길어지므로, 효율적인 스케줄링은 아니다.
ex 1) 0초 대에서 프로세스들이 간발의 차이로 P1, P2, P3 순으로 들어왔을 때
ex 2) 0초 대에서 프로세스들이 간발의 차이로 P2, P3, P1 순으로 들어왔을 때
FCFS는 ex 1과 ex 2의 waiting time 같이 들어온 작업의 순서에 따라 결과 차이가 크게 나타나는 비효율성이 있다. 이처럼 작업 시간이 긴 프로세스에 의해 작업 시간이 짧은 프로세스들이 실행되지 못하는 상황을 Convoy effect(호위 효과)라고 한다.
2. SJF (Shortest-Job-First)
CPU Burst가 짧은 프로세스에게 CPU 제어권을 제일 먼저 스케줄한다. 이 때, 각 프로세스의 다음 번 CPU Burst time을 고려하여 스케줄링에 활용한다.
Nonpreemptive SJF
일단 CPU를 잡으면 해당 프로세스의 CPU Burst가 완료될 때까지 CPU를 선점(preemption)당하지 않는다.
→ 프로세스가 CPU를 다 사용하고 나가는 시점에 CPU 스케줄링을 결정
ex)
Preemptive SJF (SRTF = Shortest-Remaining-Time-First)
현재 수행 중인 프로세스의 남은 burst time보다 더 짧은 CPU burst time을 가지는 새로운 프로세스가 도착하면 CPU를 빼앗는다. 주어진 프로세스들에 대하여 minimum average waiting time을 보장한다. (어떤 알고리즘도 이 waiting time 보다 빠를 수 없다.)
→ 새로운 프로세스가 들어올 때와 프로세스가 빠져 나갈 때, 두 가지 시점에서 CPU 스케줄링이 이뤄진다.
ex)
SJF의 문제점
Starvation (기아 현상) : 우선도가 낮은 프로세스(=CPU burst time이 긴 프로세스)는 영원히 실행되지 못할 수 있다.
CPU burst time의 추정 : CPU burst time은 추정만 가능하기에 실제 정확한 시간을 알고 SJF를 수행하기는 어렵다.
CPU burst time 추정은 과거의 CPU 사용 흔적을 바탕으로 exponetial averaging 기법을 사용해 이뤄진다. 이 기법은 과거의 흔적일수록 덜 반영하고 최근 흔적일수록 많이 반영하는 흐름을 가진다.
3. Priority Scheduling
높은 우선 순위를 가지는 프로세스에게 CPU를 할당한다. 작은 정수가 high priority를 나타낸다. (SJF도 일종의 Priority Scheduling → priority = predicted next CPU burst time)
Nonpreemptive : CPU를 선점한 프로세스에게서 CPU를 빼앗지 않는다.
Preemptive : 우선도에 따라 CPU를 빼앗긴다. (SJF 설명과 유사)
Problem : Starvation (기아 현상)!!!
→ Solution) Aging : 시간이 지남에 따라 우선도가 낮은 프로세스의 우선도를 높인다.
4. Round Robin (RR) - Preemptive (선점형), 현대적 CPU Scheduling
각 프로세스는 동일한 크기의 할당 시간(time quantum)을 가지며 이 할당 시간이 지나면 CPU를 선점당하고 ready queue의 제일 뒤로 가서 다시 줄을 선다.
n개의 프로세스가 ready queue에 있고 할당 시간이 q time unit인 경우 각 프로세스는 최대 q time unit 단위로 CPU 시간의 1/n을 얻는다. (어떤 프로세스도 (n-1)q time unit 이상 기다리지 않는다.)
RR의 특징
Response Time 빨라지는 장점
Waiting Time은 CPU burst time이 긴 프로세스일수록 길고 반대의 경우 짧음
Performance
q large → FCFS
q small → context switch 오버헤드가 커진다.
ex) Time quantum이 20일 때
→ 일반적으로 SJF보다 average turnaround time이나 waiting time은 길어질 수 있지만 response time은 더 짧다. 또한, CPU 실행 시간이 동일한 프로세스들일 경우 RR이 비효율적일 수 있지만, 일반적으로는 CPU 실행 시간이 다르기 때문에 대부분에서 효율적이다.
Reference
운영체제, 이화여대 반효경 교수님
-
4-2. 프로세스 관리
프로세스의 생성, 실행 및 종료에 관한 시스템 콜
프로세스 관리 시스템 콜 정리
1. fork() 시스템콜
위 그림의 두 코드는 부모 프로세스(좌), 자녀 프로세스(우)이다. 처음 부모 프로세스가 코드를 수행하다가 fork 시스템 콜을 만나면, 부모 프로세스를 똑같이 복사해 자녀 프로세스를 만들고 이후 명령을 계속 실행한다. 자녀 프로세스는 부모 프로세스의 Program Counter를 그대로 복제했기 때문에, 부모 프로세스와 마찬가지로 fork의 바로 밑 코드부터 실행한다.
또한, 부모 프로세스는 fork의 return 값으로 양수, 자녀 프로세스는 0을 pid에 취해 서로를 구분한다.
2. exec() 시스템콜
fork로 복사한 프로세스를 다른 프로그램으로 다시 덮어쓰기 위해 exec 시스템콜을 사용한다. 위와 같은 경우는 execlp 함수를 만나면, exec 시스템 콜이 발생해 복사한 자녀 프로세스에 새로 date 파일을 덮어써 실행하게 된다. 따라서, date가 실행되면 위 그림에 보이는 원래의 자녀 프로세스의 코드로는 다시 돌아갈 수 없다.
3. wait() 시스템콜
부모 프로세스가 wait 시스템 콜을 걸면, 부모 프로세스는 자식 프로세스가 종료될 때까지 blocked 상태가 된다. 자식 프로세스가 종료되면 부모 프로세스는 위 그림 처럼 wait 뒤에 있는 S2 코드를 계속 실행한다. (자식이 종료될 때까지 부모가 기다리는 모델에 해당)
ex) 쉘 프롬프트의 커서가 깜빡이는 상태에서 프로그램을 실행 시 자식 프로세스 형태로 실행되고, 쉘 프롬프트 프로그램은 부모 프로세스로서 자식 프로세스가 종료될 때까지 기다렸다가(blocked 상태) 다시 실행된다.
4. exit() 시스템콜
자발적 종료
마지막 statement 수행 후 exit() 시스템 콜을 통해 이루어진다. 프로그램에 명시적으로 적어주지 않아도 main 함수가 리턴되는 위치로 컴파일러가 넣어준다.
비자발적 종료
부모 프로세스가 자식 프로세스를 강제 종료 시킬 때
ex) 자식 프로세스가 한계치를 넘어서는 자원을 요청할 때, 자식에게 할당된 태스크가 더 이상 필요하지 않을 때
부모가 종료될 때 (프로세스는 항상 자식이 먼저 종료되고 부모가 종료됨)
키보드로 kill, break 등을 칠 때
프로세스 간 협력
독립적 프로세스
프로세스는 각자의 주소 공간을 가지고 수행되므로 원칙적으로 하나의 프로세스는 다른 프로세스의 수행에 영향을 미치지 못한다.
협력 프로세스
어떤 경우에는 프로세스 협력 메커니즘을 통해 하나의 프로세스가 다른 프로세스의 수행에 영향을 미치며 서로 정보를 교환하는 것이 효율적일 수 있다.
프로세스 간 협력 메커니즘 (IPC: Interprocess Communication)
massage passing : 커널을 통해 메시지를 전달한다. (프로세스들끼리 직접은 불가능하다.)
Message system : 프로세스 사이에 공유 변수를 일체 사용하지 않고 통신하는 시스템
Direct Communication : 통신하려는 프로세스의 이름을 명시적으로 표시
Indirect Communication : mailbox(혹은 port)를 통해 메시지를 간접 전달 (프로세스 이름을 명시하지 않으므로 다른 프로세스가 열어볼 수 도 있음)
shared memory : (원칙적으로는 안되지만) 서로 다른 프로세스 간에도 일부 주소 공간을 공유하게 하는 메커니즘
Thread는 하나의 프로세스이므로 프로세스 간 협력으로 보기에는 어렵다!
Reference
운영체제, 이화여대 반효경 교수님
-
-
Fast API tutorial - Installation
Fast API 공식 문서의 튜토리얼을 살펴보고 정리합니다. 본 글은 윈도우 환경을 기준으로 작성되었습니다.
Fast API 설치하기
앞에서는 간단히 Fast API와 uvicorn만 설치하여 진행했지만, 이번엔 튜토리얼을 편하게 진행하기 위해 Fast API와 이에 따른 의존 관계가 있는 모듈들을 한꺼번에 설치하겠습니다.
가상환경을 사용한다면 활성화시켜주시고, [all] 옵션을 사용해 Fast API와 관련 모듈들을 한번에 설치합니다.
> pip install fastapi[all]
이 때 uvicorn 서버도 함께 설치되기 때문에, 따로 uvicorn을 설치할 필요없이 프로젝트 디렉토리에 main.py 파일만 만들고 바로 서버를 구동할 수 있습니다.
가장 simplest한 형태의 Fast API 코드를 main.py에 작성해 실행해봅시다. main.py 파일을 프로젝트 폴더에 생성하고 다음 코드를 입력해 저장합니다.
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"message": "Hello World"}
코드 분석
from fastapi import FastAPI: fastapi 모듈에서 파이썬 클래스 FastAPI를 import합니다.
app = FastAPI(): app 변수에 FastAPI 인스턴스를 만들어 담습니다. 해당 변수에 이름에 따라 바로 이어 나올 uvicorn 명령어를 다르게 사용합니다. uvicorn main:[변수이름] --reload처럼 말이죠!
@app.get("/"): path operation decorator를 만듭니다. path란 //를 제외하고 url에서 첫 번째로 만나는 /로부터 시작되는 url의 뒷 부분을 말하며, operation은 HTTP method를 말합니다. 이 코드의 경우, decorator가 장식하는 함수가 ‘GET operation을 사용해 / path로 가라는 요청’을 처리하는 역할을 한다고 FastAPI에게 알려줍니다.
def root():: path operation function을 정의합니다. 이 코드의 경우, FastAPI는 GET operation으로 URL /에 대한 요청이 들어오면 이 함수를 호출합니다.
return {"message": "Hello World"}: content를 리턴합니다. 리턴할 수 있는 객체는 dict, list, int, str, Pydantic model 등 다양합니다.
그리고 Uvicorn 서버를 구동합니다.
> uvicorn main:app --reload
uvicorn main:app --reload의 의미
uvicorn: uvicorn 서버를 실행합니다
main: main.py 파일(모듈)을 의미합니다.
app: main.py 내에서 생성한 FastAPI 클래스의 객체를 의미합니다.
--reload: 코드를 수정한 후 자동으로 서버를 재시작해주는 옵션입니다. 현재 개발 중일 때 사용합니다.
이제 브라우저로 로컬머신에서 작동 중인 앱을 확인해봅시다. http://127.0.0.1:8000 주소에 들어가면, JSON 형태의 응답으로 다음과 같이 새로이 마주하는 Fast API 세상과 인사를 나눌 수 있습니다!
Uvicorn이란?
uvloop와 httptools를 사용하는 초고속 ASGI(Asynchronous Server Gateway Interface) web server입니다. 최근까지 파이썬은 asyncio 프레임 워크를 위한 저수준 서버 / 애플리케이션 인터페이스가 없었는데, uvicorn의 등장으로 Fast API같은 프레임워크의 비동기 처리 성능이 크게 향상됐습니다.
Starlette이란?
Uvicorn 위에서 실행되는 비동기적으로 실행할 수 있는 web application server입니다. FastAPI는 Starlette 위에서 동작하고, Starlette 클래스를 상속받았기 때문에, Starlette의 모든 기능을 사용할 수 있습니다.
Reference
Fast API 공식 문서 튜토리얼
Uvicorn이란?
비동기 Micro API server로 좋은 FastAPI
-
파이썬 데코레이터 (Decorator)
파이썬의 함수는 일급 시민이자 일급 객체
일급 객체(First-class object)란 다음과 같은 몇 가지 조건을 갖춤으로 인해서, 해당 객체를 사용할 때 다른 요소들과 아무런 차별이 없는 객체를 의미합니다. 다음은 Robin Popplestone이 정의한 일급 객체의 일반적인 조건입니다.
모든 일급 객체는 함수의 실질적인 매개변수가 될 수 있다.
모든 일급 객체는 함수의 반환값이 될 수 있다.
모든 일급 객체는 할당의 대상이 될 수 있다. (변수 대입)
모든 일급 객체는 비교 연산(==, equal)을 적용할 수 있다.
일급 객체는 자바스크립트에서 파생된 개념이지만 지금은 대다수 프로그래밍 언어에 적용되는 개념입니다. 파이썬에서는 모든 것이 객체이자 일급객체여서, 함수 역시 위 조건을 만족하는 일급 객체에 해당합니다.
데코레이터란?
데코레이터란 기존 함수를 수정하지 않은 상태에서 새로운 기능을 추가할 때 사용하는 장식자입니다. 함수 위에 @를 붙인 것들이 모두 데코레이터에 해당됩니다.
def basic_latte(func):
def wrapper():
print('Milk')
func()
print('Espresso')
return wrapper
def vanilla():
print('Vanilla Syrup')
def caramel():
print('Caramel Syrup')
vanilla_latte = basic_latte(vanilla)
vanilla_latte()
print()
caramel_latte = basic_latte(caramel)
caramel_latte()
# 출력 결과
# Milk
# Vanilla Syrup
# Espresso
#
# Milk
# Caramel Syrup
# Espresso
데코레이터의 이해를 위해 다양한 시럽을 베이스로 라떼를 제조해보는 예제로 데코레이터의 기본 구조를 살펴보겠습니다. 위의 basic_latte 함수는 우유와 에스프레소를 추가해주는 함수입니다. 특이한 점은 함수를 인자로 받고 내부에서 새로 정의한 함수 wrapper를 리턴하는 부분인데, 이렇게 하면 기존 함수 func을 매개변수로 사용해 추가 기능을 자유롭게 덧입힐 수 있습니다. 위와 같은 경우 vanilla 함수, caramel 함수에 각각 에스프레소와 우유를 덧입혀 출력한 것이죠! 파이썬의 closure의 개념을 알고 있다면, 이 예제 역시 closure의 일종으로 이해할 수 있습니다.
이 같은 구현이 가능한 이유는 파이썬의 함수가 일급 객체이기 때문입니다. 함수를 인자로 받고 리턴하고 변수에 할당하는 것이 가능함으로 인해 앞으로 강력하게 사용될 데코레이터가 탄생할 수 있었던 것이죠.
def basic_latte(func):
def wrapper():
print('Milk')
func()
print('Espresso')
return wrapper
@basic_latte
def vanilla():
print('Vanilla Syrup')
@basic_latte
def caramel():
print('Caramel Syrup')
vanilla()
print()
caramel()
# 출력 결과
# Milk
# Vanilla Syrup
# Espresso
#
# Milk
# Caramel Syrup
# Espresso
데코레이터를 사용하면 위에서 살펴본 라떼 제조를 간단히 실행할 수 있습니다. 단순히 원하는 함수 위에 @추가기능함수이름을 달아주면, 굳이 basic_latte(vanilla)를 하지 않고 vanilla()만 실행해도 원하는 결과를 확인할 수 있습니다.
만일 여러개의 데코레이터를 지정하고 싶다면 다음과 같이 호출하면 됩니다.
def espresso(func):
def wrapper():
func()
print('Espresso')
return wrapper
def milk(func):
def wrapper():
func()
print('Milk')
return wrapper
@espresso
@milk
def vanilla():
print('Vanilla Syrup')
vanilla()
# 출력 결과
# Vanilla Syrup
# Milk
# Espresso
에스프레소와 우유를 각각 덧입혀 바닐라 라떼 제조에 성공했습니다! @를 쓰지 않았을 때의 코드 동작은 espresso(milk(vanilla))() 와 동일합니다.
데코레이터에서 매개변수와 반환값을 처리하기
이번에는 매개변수와 반환값을 처리하는 데코레이터를 만들어 보겠습니다.
def make_latte(func):
def wrapper(espresso, milk):
latte = func(espresso, milk)
print(f'{func.__name__}(espresso={espresso}ml, milk={milk}ml) -> latte={latte}ml')
return latte
return wrapper
@make_latte
def mix(espresso, milk):
return espresso + milk
print(mix(60, 200))
# 출력 결과
# mix(espresso=60ml, milk=200ml) -> latte=260ml
# 260
데코레이터가 매개변수를 처리할 수 있게끔 만드려면, 안쪽 wrapper 함수를 mix와 똑같은 형태로 매개변수를 받을 수 있게 만들어줘야 합니다. (결국 wrapper 함수가 인자를 받아 실행될 것이기 때문이죠!) 그리고 wrapper 함수 안에서 추가하고 싶은 기능을 만들어 줍니다. 여기서는 mix 함수를 실행한 리턴값을 변수로 저장하고 라떼 레시피와 제조 과정을 출력했습니다. 마지막으로 mix 함수는 에스프레소와 우유의 용량을 합친 수를 리턴해야 하므로, wrapper 함수에서 mix 함수의 반환값을 리턴해주도록 합니다. 만일 이를 잊어버리면, mix 함수를 호출해도 리턴값이 나오지 않으므로 유의해야 합니다.
이로써 매개변수와 반환값을 잘 처리하는 라떼 제조 데코레이터 구현에 성공했습니다. 만일 가변 인수 함수에 기능을 추가하고 싶은 상황이라면 데코레이터 안쪽 wrapper 함수에 *arg, **kwarg를 사용해주면 됩니다. 이렇게 만든 가변 인수 데코레이터는 고정 인수를 사용하는 일반적인 함수에도 사용할 수 있습니다.
매개 변수가 있는 데코레이터 만들기
데코레이터의 또 하나 강력한 점은 인자를 받아 동적으로 적용되는 추가 기능을 덧입힐 수 있다는 것입니다.
def make_variation(syrup_name): # 데코레이터의 인자를 추가하는 부분
def make_latte(func): # 실제 데코레이터 부분
def wrapper(espresso, milk):
latte = func(espresso, milk)
print(f'{func.__name__}(espresso={espresso}ml, milk={milk}ml) with {syrup_name} syrup')
print(f'-> {syrup_name}_latte={latte}ml')
return latte
return wrapper
return make_latte # 실제 데코레이터 함수 반환
@make_variation('green_tea')
def mix(espresso, milk):
return espresso + milk
print(mix(60, 200))
# 출력 결과
# mix(espresso=60ml, milk=200ml) with green_tea syrup
# -> green_tea_latte=260ml
# 260
보통 기본 베이직 커피에 무언가를 더 가미해 다양한 맛을 낸 커피를 베리에이션(variation)이라고 하는데, 여기선 데코레이터의 인자로 시럽의 이름을 받아 기본 라떼의 베리에이션을 만들어 보겠습니다.
코드를 보면 기존에 만들었던 데코레이터와 큰 차이 없이, 단순히 데코레이터의 인자를 받을 함수를 하나 더 덧입혀 삼중으로 처리하고 wrapper 함수의 출력문을 조금 바꿨습니다. 그리고 mix 함수 위에는 새로 덧입힌 함수를 데코레이터로 사용하고 인자로 녹차시럽(green_tea)을 받았습니다. 이렇게 하면, 녹차시럽을 가미한 베리에이션으로 녹차 라떼가 완성됩니다. 데코레이터의 인자를 바닐라 시럽이나 카라멜 시럽으로 바꾸면 동적으로 다른 베리에이션을 만드는 것도 가능합니다.
여러 개의 데코레이터를 지정하다가 원래 함수의 이름이 나오지 않을 때
여러 베리에이션을 만들면 다음과 같이 원래 함수의 이름이 나오지 않을 수 있습니다.
# 실제 동작: make_variation('green_tea')(make_variation('vanilla')(mix))(60, 200)
@make_variation('green_tea')
@make_variation('vanilla')
def mix(espresso, milk):
return espresso + milk
print(mix(60, 200))
# 결과 출력
# mix(espresso=60ml, milk=200ml) with vanilla syrup
# -> vanilla_latte=260ml
# wrapper(espresso=60ml, milk=200ml) with green_tea syrup
# -> green_tea_latte=260ml
# 260
참고로 위 함수의 실제 동작은 make_variation('green_tea')(make_variation('vanilla')(mix))(60, 200)으로 실행됩니다. 이 때 원하지 않는 출력 결과로 wrapper 함수의 이름이 나타났는데, 이를 개선하려면 wrapper 함수 위에 functools 모듈의 wraps 데코레이터를 사용해야 합니다.
import functools
def make_variation(syrup_name):
def make_latte(func):
@functools.wraps(func) # @functools.wraps에 func을 인자로 넣은 뒤 wrapper 함수 위에 지정
def wrapper(espresso, milk):
latte = func(espresso, milk)
print(f'{func.__name__}(espresso={espresso}ml, milk={milk}ml) with {syrup_name} syrup')
print(f'-> {syrup_name}_latte={latte}ml')
return latte
return wrapper
return make_latte
@make_variation('green_tea')
@make_variation('vanilla')
def mix(espresso, milk):
return espresso + milk
print(mix(60, 200))
# 결과 출력
# mix(espresso=60ml, milk=200ml) with vanilla syrup
# -> vanilla_latte=260ml
# mix(espresso=60ml, milk=200ml) with green_tea syrup
# -> green_tea_latte=260ml
# 260
@functools.wraps 데코레이터를 사용하면 출력 결과가 원하는대로 나오는 것을 확인할 수 있습니다. @functools.wraps 데코레이터는 원래 함수의 정보를 유지시켜 디버깅을 용이하게 합니다. 따라서 데코레이터를 만들 때 함께 사용하는 것이 유용합니다.
클래스로 데코레이터 만들기
기존에 함수로 만들던 데코레이터는 클래스로도 만들 수 있습니다. 다만, 클래스로 데코레이터를 만들 때는 인스턴스를 함수처럼 호출하게 도와주는 __call__ 매직 메서드를 사용해야 합니다.
class basic_latte:
def __init__(self, func):
self.func = func
def __call__(self):
print('Milk')
self.func()
print('Espresso')
@basic_latte
def vanilla():
print('Vanilla Syrup')
vanilla() # basic_latte(vanilla)() 형태로 동작해 인스턴스가 생성되고, ()로 인해 __call__ 메서드가 호출됨
# 출력 결과
# Milk
# Vanilla Syrup
# Espresso
이렇게 코드를 짜면 기존의 함수로 만든 데코레이터와 동일한 결과를 얻을 수 있습니다. 데코레이터로 인해 basic_latte(vanilla)가 먼저 동작해 basic_latte 클래스의 인스턴스가 생성되고 해당 인스턴스에 ()가 붙어 __call__ 메서드가 수행되어 추가로 구현한 기능이 동작하게 됩니다.
클래스로 만든 데코레이터로 매개변수와 반환값도 처리할 수 있습니다.
class make_latte:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
latte = self.func(*args, **kwargs)
print('{}(espresso={}ml, milk={}ml) -> latte={}ml'.format(self.func.__name__, *args, latte))
return latte
@make_latte
def mix(espresso, milk):
return espresso + milk
print(mix(60, 200))
# 출력 결과
# mix(espresso=60ml, milk=200ml) -> latte=260ml
# 260
__call__ 메서드에 mix 함수가 받을 인자를 똑같이 받도록 만들고 mix 함수의 리턴 값을 __call__메서드에서 반환해주면, 기존의 함수 데코레이터와 동일한 결과를 얻는 데코레이터를 클래스로 만들 수 있습니다.
매개 변수가 있는 데코레이터도 클래스로 구현해보겠습니다.
class make_variation:
def __init__(self, syrup_name):
self.syrup_name = syrup_name
def __call__(self, func):
def wrapper(*args, **kwargs):
latte = func(*args, **kwargs)
print('{}(espresso={}ml, milk={}ml) with {} syrup'.format(func.__name__
, *args, self.syrup_name))
print(f'-> {self.syrup_name}_latte={latte}ml')
return latte
return wrapper
@make_variation('green_tea')
def mix(espresso, milk):
return espresso + milk
print(mix(60, 200))
# 출력 결과
# mix(espresso=60ml, milk=200ml) with green_tea syrup
# -> green_tea_latte=260ml
# 260
__init__ 메서드에서 데코레이터의 인자를 초깃값으로 받으면서, 인스턴스 속성으로 저장합니다. 그리고 __call__ 메서드에서 함수를 인자로 받도록 하고, 메서드 내부에 wrapper 함수를 새로 만들어 호출할 함수와 똑같은 형태로 매개변수를 받을 수 있도록 만들어 줍니다. 추가할 기능 역시 wrapper 함수에 구현하고 __call__ 메서드가 wrapper 함수를 리턴하도록 합니다. 그리고 mix 함수의 반환 값을 wrapper 함수가 리턴하도록 만들면 인자를 받는 데코레이터 구현이 완료됩니다. 똑같이 녹차 라떼가 제조됨을 확인할 수 있죠!
데코레이터의 의의
이로써 파이썬에서 데코레이터를 만드는 다양한 형태와 방법을 살펴봤습니다. 클로저 개념에서 발전되어 등장한 데코레이터는 기존 함수를 변형하지 않고 새로운 기능을 추가하는 목적으로 사용하지만, 디버깅에서도 훌륭한 수단이 됩니다. 함수의 성능 측정이나 함수 실행 전 데이터 확인 같은 다양한 목적으로도 사용되므로, 데코레이터에 익숙해지는 것은 효과적인 프로그래밍에 큰 도움이 될 것입니다.
Reference
python의 함수 decorators 가이드
파이썬 코딩 도장 - 데코레이터
1급 객체(first-class object)란?
-
-
-
-
-
파이썬 클래스 개념 조각 모음
클래스(Class)를 사용하는 이유
관련 있는 데이터를 묶기 위해 배열이, 데이터 묶음 요소마다 의미를 부여하기 위해 딕셔너리가, 의미를 확장해 다양한 정보와 동작들을 한데 묶어 표현하기 위해 클래스가 탄생했다.
보안상의 이슈를 다루기 위해 코드들을 바깥과 분리하여 감싸는 encapsulation 기능이 필요했다.
__main__을 사용하는 이유
C, C++ 같은 언어의 main 함수 영향을 받았다.
프로그램의 중심이 되는 코드들을 한 곳에 정리하기 위한 관리상 요인이 작용했다. (덕분에 프로그래밍의 시작점 파악이 용이)
속성(Attribute)의 종류
인스턴스 속성
인스턴스를 통해 접근할 수 있는 속성 (클래스 바깥에서는 인스턴스.속성, 클래스 내부에서는 self.속성으로 접근)
__init__ 메서드 안에 정의한 속성
인스턴스 별로 독립되어 있는 속성이며, 각 인스턴스가 값을 따로 저장해야 할 때 사용
인스턴스를 생성한 후에도 자유롭게 속성을 추가할 수 있음
인스턴스.속성 = something (방법 1)
클래스 내 메서드에 속성을 정의하고, 인스턴스 생성 후 호출 (방법 2)
__slots__ 메서드로 특정 속성만 추가를 허용하도록 지정 가능
__slots__ = ['속성이름1, '속성이름2'] (속성 이름은 문자열로 지정)
>>> class Person:
... __slots__ = ['name', 'age'] # name, age만 허용(다른 속성은 생성 제한)
...
>>> maria = Person()
>>> maria.name = '마리아' # 허용된 속성
>>> maria.age = 20 # 허용된 속성
>>> maria.address = '서울시 서초구 반포동' # 허용되지 않은 속성은 추가할 때 에러가 발생함
Traceback (most recent call last):
File "<pyshell#32>", line 1, in <module>
maria.address = '서울시 서초구 반포동'
AttributeError: 'Person' object has no attribute 'address'
클래스 속성
클래스에 바로 만든 속성
클래스 내부, 클래스 바깥 모두에서 접근 가능하다. (언더스코어 2개를 사용해 비공개 속성으로도 만들 수 있음)
모든 인스턴스가 공유하는 속성이며, 인스턴스 전체가 사용해야 하는 값을 저장할 때 사용
class Person:
bag = []
def put_bag(self, stuff):
Person.bag.append(stuff) # self.bag.append(stuff)라고 써도 되지만, 클래스 이름을 쓰는 것이 명확
james = Person()
james.put_bag('책')
maria = Person()
maria.put_bag('열쇠')
print(james.bag) # ['책', '열쇠']
print(maria.bag) # ['책', '열쇠']
속성과 메서드 이름을 찾는 순서
파이썬에서 속성, 메서드 이름을 찾을 때, 인스턴스, 클래스 순으로 찾는다. 위 예에서도 마치 인스턴스 속성을 사용한 것 같지만, 인스턴스 속성이 없으면 클래스 속성을 찾게 되므로 실제로 클래스 속성을 리턴한 것이다.
인스턴스나 클래스에서 __dict__ 속성을 출력해보면 현재 인스턴스와 클래스의 속성을 딕셔너리로 확인할 수 있다.
>>> james.__dict__
{}
>>> Person.__dict__
mappingproxy({'__module__': '__main__', 'bag': ['책', '열쇠'], 'put_bag': <function Person.put_bag at 0x028A32B8>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None})
메서드(Method)의 종류
인스턴스 메서드
인스턴스를 통해 접근할 수 있는 메서드
대부분의 일반적인 메서드에 해당되며 첫 번째 파라미터로 self를 지정하는 메서드 (self는 instance 그 자체를 받음)
정적 메서드
인스턴스를 통하지 않고 클래스에서 바로 호출 가능
메서드 위에 @staticmethod를 붙이고 파라미터로 self를 지정하지 않는 메서드
self를 받지 않기 때문에 인스턴스 속성에 접근할 수 없음
그래서 보통 인스턴스 속성, 인스턴스 메서드가 필요없는 메서드, 인스턴스의 상태를 변화시키지 않는 순수함수를 만들 때 사용
class Calc:
@staticmethod
def add(a, b):
print(a + b)
@staticmethod
def mul(a, b):
print(a * b)
Calc.add(10, 20) # 클래스에서 바로 메서드 호출 / 30
Calc.mul(10, 20) # 클래스에서 바로 메서드 호출 / 200
클래스 메서드
인스턴스를 통하지 않고 클래스에서 바로 호출 가능
메서드 위에 @classmethod를 붙이고 첫번째 파라미터로 cls를 지정하는 메서드 (cls는 class 그 자체를 받음)
cls를 받기 때문에 클래스 속성, 클래스 메서드에 접근할 수 있음
메서드 안에서 클래스 속성, 클래스 메서드에 접근하거나 메서드 안에서 현재 클래스의 인스턴스를 만들 때 사용
class Person:
count = 0 # 클래스 속성
def __init__(self):
Person.count += 1 # 인스턴스가 만들어질 때
# 클래스 속성 count에 1을 더함
@classmethod
def print_count(cls):
print('{0}명 생성되었습니다.'.format(cls.count)) # cls로 클래스 속성에 접근
james = Person()
maria = Person()
Person.print_count() # 2명 생성되었습니다.
비공개 속성과 비공개 메서드
비공개 속성 (Private Attribute)
클래스 바깥에서는 접근할 수 없고 클래스 안에서만 사용할 수 있는 속성
클래스 바깥에 드러내고 싶지 않은 값에 사용한다.
__속성으로 사용
class Person:
def __init__(self, name, age, address, wallet):
self.name = name
self.age = age
self.address = address
self.__wallet = wallet # 변수 앞에 __를 붙여서 비공개 속성으로 만듦
maria = Person('마리아', 20, '서울시 서초구 반포동', 10000)
maria.__wallet -= 10000 # 클래스 바깥에서 비공개 속성에 접근하면 에러가 발생함
클래스 내 메서드에서는 접근 가능
class Person:
def __init__(self, name, age, address, wallet):
self.name = name
self.age = age
self.address = address
self.__wallet = wallet # 변수 앞에 __를 붙여서 비공개 속성으로 만듦
def pay(self, amount):
self.__wallet -= amount # 비공개 속성은 클래스 안의 메서드에서만 접근할 수 있음
print('이제 {0}원 남았네요.'.format(self.__wallet))
maria = Person('마리아', 20, '서울시 서초구 반포동', 10000)
maria.pay(3000)
비공개 메서드 (Private Method)
클래스 바깥에서는 접근할 수 없고 클래스 안에서만 사용할 수 있는 메서드
클래스 바깥에 드러내고 싶지 않고 보통 내부에서만 호출되어야 할 때 사용한다.
__메서드로 사용
class Person:
def __greeting(self):
print('Hello')
def hello(self):
self.__greeting() # 클래스 안에서는 비공개 메서드를 호출할 수 있음
james = Person()
james.__greeting() # 에러: 클래스 바깥에서는 비공개 메서드를 호출할 수 없음
파이썬 접근 제어
다른 언어와 달리 파이썬은 접근제어자 키워드가 따로 존재하지 않기 때문에 네이밍(naming)을 통해 접근 제어를 수행한다.
다만, 파이썬에서는 네이밍을 사용해 접근을 제어해도 완벽하게 차단할 수는 없다.
public, protected, private은 상황별로 다음과 같은 양상을 보인다.
public
언더스코어(_)없이 시작하는 속성, 메서드
어디서나 접근 가능
protected
언더스코어 1개로 시작하는 속성, 메서드
어디서나 접근 가능하지만, 암묵적 규칙에 의해 해당 클래스 내부와 파생 클래스에서만 접근해야 함 (파이썬은 protect 기능이 X)
private
언더스코어 2개로 시작하는 속성, 메서드
해당 클래스 내부에서만 접근 가능
주요 Dunder Method (=Magic method)
__repr__
해당 class의 string representation을 설정
객체를 출력하면 미리 설정된 사용자가 이해할 수 있는 문자열을 반환
self 파라미터 하나만 받고, 반드시 문자열을 리턴해야 한다.
class Employee():
def __init__(self, name):
self.name = name
def __repr__(self):
return self.name
argus = Employee("Argus Filch")
print(argus)
# prints "Argus Filch"
__add__
+ 기호에 대응하는 메서드
더하는 메서드로서 self 파라미터와 여기에 더할 인자 하나를 받는다.
class Color:
def __init__(self, red, green, blue):
self.red = red
self.green = green
self.blue = blue
def __repr__(self):
return "Color with RGB = ({red}, {green}, {blue})".format(red=self.red, green=self.green, blue=self.blue)
def __add__(self, other):
"""
Adds two RGB colors together
Maximum value is 255
"""
new_red = min(self.red + other.red, 255)
new_green = min(self.green + other.green, 255)
new_blue = min(self.blue + other.blue, 255)
return Color(new_red, new_green, new_blue)
red = Color(255, 0, 0)
green = Color(0, 255, 0)
blue = Color(0, 0, 255)
# Color with RGB: (255, 0, 255)
magenta = red + blue
# Color with RGB: (0, 255, 255)
cyan = green + blue
# Color with RGB: (255, 255, 0)
yellow = red + green
# Color with RGB: (255, 255, 255)
white = red + green + blue
__len__
len() 함수를 호출했을 때의 결과 값을 임의로 설정해 리턴할 수 있는 메서드
__iter__
iterator 객체를 반환해 반복가능한 객체로 만들어 주는 메서드
__contains__
멤버 연산자 in을 사용할 수 있게 해주는 메서드
클래스 관련 메서드
특정 클래스의 인스턴스인지 확인하기
isinstance(인스턴스, 클래스)
True, False 반환
해당 객체가 특정 속성을 가지고 있는지 여부 확인하기
hasattr(객체, '속성')
True, False 반환
hasattr(attributeless, "fake_attribute")
# returns False
해당 객체에서 특정 속성의 값을 가져오기
getattr(객체, '속성', default)
속성이 있으면 속성의 값 반환, 없으면 디폴트 값 반환
getattr(attributeless, "other_fake_attribute", 800)
# returns 800, the default value
특정 클래스 A가 클래스 B의 subclass인지 확인하기
issubclass(클래스 A, 클래스 B)
True, False 반환
Reference
파이썬 코딩 도장
Codecademy - learning python 3
private, proteted, public 의 차이
인스턴스 메소드의 종류와 용법 (Instance methods): Public, Protected, Private 접근제어자 (Access Modifiers)
-
REST API 이해하기
REST API의 정의
Representational State Transfer의 약자
정보들을 주고 받는 HTTP 요청을 보낼 때, 어떤 URI에 어떤 메서드를 사용할지 개발자들 사이에 널리 지켜지는 약속 (Software Architecture)
REST를 지켰을 때 각 요청이 어떤 동작이나 정보를 위한 것인지를 그 요청의 모습 자체만 봐도 추론 가능해짐
과거의 복잡했던 SOAP 방식을 대체하여 최근에 가장 널리 쓰이는 양식
REST API의 구성
자원(Resource) - URI를 통해 식별 (네트워크 상에 존재하는 자원을 구분하는 식별자)
행위(Verb) - HTTP Method에 따라 자원에 접근
표현(Representations) 혹은 정보(Message) - HTTP 헤더와 바디, 응답 코드를 활용
REST의 특징
Uniform
리소스에 대한 조작이 통일되고 한정적인 인터페이스로 구성된 아키텍처 스타일 (Uniform Interface)
Stateless
작업을 위한 상태정보를 따로 저장, 관리하지 않고 단순히 들어오는 요청만 처리
덕분에 구현이 단순해짐
Cacheable
기존 웹표준을 사용하므로 웹의 기존 인프라를 이용해 캐싱 기능 적용 가능
Self-descriptiveness
REST API 메시지만으로도 무슨 의미인지 쉽게 이해할 수 있는 자체 표현 구조를 가짐
Client - Server 구조
서버와 클라이언트의 구분이 명확
계층형 구조
REST 서버는 다중 계층으로 구성될 수 있으며 보안, 로드 밸런싱, 암호화 계층을 추가해 구조상의 유연성을 둘 수 있음
PROXY, 게이트웨이 같은 네트워크 기반의 중간매체를 사용할 수 있게 함
REST API 디자인 가이드 요약
REST API 중심 규칙
URI는 정보의 자원을 표현해야 합니다.
리소스 명은 동사보다는 명사를 사용
자원에 대한 행위는 HTTP Method(GET, POST, PUT, DELETE)로 표현합니다.
POST: 해당 URI를 요청하면 리소스를 생성합니다.
GET: 해당 리소스를 조회합니다.
PUT: 해당 리소스를 수정합니다.
DELETE: 해당 리소스를 삭제합니다.
URI 설계 시 주의할 점
슬래시 구분자는 계층관계를 나타낼 때 사용합니다.
마지막 문자로 슬래시를 포함하지 않습니다.
하이픈(-)은 URI 가독성을 높이는데 사용합니다.
언더스코어(_)는 가독성을 해치므로 URI에 사용하지 않습니다.
URI 경로에는 소문자가 적합합니다.
파일 확장자(.jpg, .png 등)는 URI에 포함시키지 않습니다.
리소스 간의 관계를 표현하는 방법
REST 간의 연관 관계는 다음과 같이 표현합니다.
/리소스명/리소스 ID/관계가 있는 다른 리소스명
GET : /users/{userid}/books (일반적으로 소유 ‘has’의 관계를 표현할 때)
관계명이 복잡하다면 서브 리소스에 명시적으로 포함할 수 있습니다.
GET : /users/{userid}/likes/books (관계명이 애매하거나 구체적 표현이 필요할 때)
Collection과 Document 개념을 활용한 리소스 표현
Collection: 문서들의 집합, 객체들의 집합
Document: 하나의 문서, 하나의 객체
Collection과 Document로 표현하면 URI 설계가 더욱 용이해집니다.
http://restapi.com/sports/soccer/players/7
sports, players라는 Collection과 soccer, 7이라는 document로 표현
Collection은 복수로 Document는 단수로 표현해주는 것이 좋습니다.
REST API의 정보
HTTP 바디
자원에 대한 정보를 HTTP 바디에 데이터로 담아 전달합니다. 데이터 포멧으로는 최근 JSON이 가장 많이 쓰입니다.
HTTP 헤더
HTTP 바디의 컨텐츠 종류를 명시할 수 있고 인증 권한 정보를 담습니다. 요청 HTTP 헤더는 ‘Accept’ 항목을, 응답 HTTP 헤더는 ‘Content-type’을 담습니다. 다음은 ‘Content-type’의 몇 가지 예입니다.
application/json
application/xml
text/plain
image/jpeg
image/png
HTTP 응답 상태 코드
잘 설계된 REST API는 URI 뿐만 아니라 요청에 대한 응답까지 잘 내어주어야 합니다.
200: 클라이언트의 요청을 정확히 수행함
201: 클라이언트가 어떤 리소스 생성을 요청했고, 해당 리소스가 성공적으로 생성됨 (POST로 리소스 생성 시)
400: 클라이언트의 요청이 부적절함
401: 인증 받지 않은 클라이언트가 인증이 필요한 리소스를 요청함
403: 인증 유무와 관계없이, 응답하고 싶지 않은 리소스를 클라이언트가 요청했을 때 사용
리소스의 존재를 인정하는 것이므로 403 사용을 지양하고 401, 404 사용을 권고
404: 클라이언트가 요청하는 리소스를 찾을 수 없음
405: 클라이언트가 요청한 리소스에서는 사용 불가능한 메서드를 이용함
301: 클라이언트가 요청한 리소스에 대한 URI가 변경됨
Location header에 변경된 URI 적어줄 것
500: 서버에 문제가 있음
Reference
REST API 제대로 알고 사용하기
REST API 이해하기
-
순간 놓치기 쉬운 파이썬 개념들 정리
2진수, 8진수, 16진수로 정수 표현하기
>>> 0b110 # 2진수
6
>>> 0o10 # 8진수
8
>>> 0xF # 16진수
15
보다 정교한 계산으로 부동소수점 오류를 피하는 자료형 Decimal
from decimal import Decimal
cost_of_gum = Deciaml('0.10')
cost_of_gumdrop = Decimal('0.35')
cost_of_transaction = cost_of_gum + cost_of_gumdrop
print(cost_of_transaction)
# Returns 0.45 instead of 0.44999999999999996
빈 변수 만들기
>>> x = None # 다른 언어의 null 값
>>> print(x)
None
del 키워드가 사용되는 경우
변수 삭제, 리스트 요소 삭제, 딕셔너리 요소 삭제
언더스코어 변수( _ )
파이썬 셸에서 코드를 실행했을 때 결과는 _(밑줄 문자) 변수에 저장됩니다. 따라서 _를 사용하면 직전에 실행된 결과를 다시 가져올 수 있습니다.
단락 평가(short-circuit evalution)
단락 평가란 첫 번째 값만으로 결과가 확실할 때 두 번째 값은 확인(평가)하지 않는 방법을 말합니다. 논리 연산에서 단락 평가는 중요합니다. 예를 들어, Fasle and True는 and 앞이 False이기 때문에 뒷 부분을 고려할 필요없이 결과가 False가 됩니다. 실제 연산에서도 False and True는 단락 평가를 진행해 앞 부분만 확인하여 결과를 리턴합니다. 따라서, 복잡한 논리 연산일수록, 전체 결과를 빠르게 판단할 수 있는 식이 있다면 최대한 앞으로 빼서 효율적으로 연산이 동작하게끔 작성해야 합니다.
또한, 파이썬에서 논리 연산자는 마지막으로 단락 평가를 실시한 값을 그대로 반환하는 점을 유의해야 합니다. 논리 연산자는 무조건 불을 반환하지 않습니다.
True and 'Welsh Corgi' # 'Welsh Corgi' 리턴
'Welsh Corgi' and True # True 리턴
'Welsh Corgi' and False # False 리턴
False and 'Welsh Corgi' # False 리턴
0 and 'Welsh Corgi' # 0 리턴
자료형(객체) 구분
1. 시퀀스 자료형
리스트, 튜플, range, 문자열처럼 값이 연속적으로 이어진 자료형을 시퀀스 자료형(sequence types)라고 부릅니다.
수행 가능 연산:
in 연산자
+, * 연산 (range 제외)
len() 함수
인덱싱([] ∝ getitem 메서드) & 슬라이싱(∝ 슬라이스 객체 생성 후 [] 또는 getitem 메서드에 삽입)
인덱스로 값 할당 및 del 삭제 (list만 가능, 다만 범위를 벗어나면 안됨)
슬라이싱으로 값 할당 및 del 삭제 (list만 가능)
슬라이싱으로 값 할당 및 del 삭제
슬라이싱으로 범위를 지정해 값 할당 및 삭제를 진행할 때, 새 리스트를 생성하지 않고 기존 리스트를 변경합니다.
범위와 요소의 개수가 정확히 일치하지 않아도 됩니다.
예 1)
a = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
a[2:5] = ['a'] # 인덱스 2부터 4까지에 값 1개를 할당하여 요소의 개수가 줄어듦
a
[0, 10, 'a', 50, 60, 70, 80, 90]
예 2)
a = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
a[2:5] = ['a', 'b', 'c', 'd', 'e'] # 인덱스 2부터 4까지 값 5개를 할당하여 요소의 개수가 늘어남
a
[0, 10, 'a', 'b', 'c', 'd', 'e', 50, 60, 70, 80, 90]
예 3)
a = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
a[2:5] = ['a', 'b', 'c'] # 인덱스 2부터 4까지 값 할당
a
[0, 10, 'a', 'b', 'c', 50, 60, 70, 80, 90]
범위에 인덱스 증가폭을 설정해서 값을 할당할 수도 있습니다. (다만, 이 때는 범위에 해당하는 요소 개수와 할당할 요소의 개수가 일치해야 합니다.)
예)
a = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
a[2:8:2] = ['a', 'b', 'c'] # 인덱스 2부터 2씩 증가시키면서 인덱스 7까지 값 할당
a
[0, 10, 'a', 30, 'b', 50, 'c', 70, 80, 90]
del을 사용해 일반적으로 값을 삭제할 수 있지만, 인덱스 증가폭을 사용해서 값을 삭제할 수도 있습니다.
예)
a = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
del a[2:8:2] # 인덱스 2부터 2씩 증가시키면서 인덱스 6까지 삭제
a
[0, 10, 30, 50, 70, 80, 90]
2. 반복 가능한(iterable) 객체
문자열, 리스트, 딕셔너리, 세트 같이, 요소가 여러 개 들어있고, 한 번에 하나씩 꺼낼 수 있는 객체입니다. 반복 가능한 객체는 iter 메서드를 포함하고 있으며, 이 메서드를 호출해 이터레이터(iterator)를 생성할 수 있습니다.
3. 변경 가능한(Mutable) 객체
객체의 값이나 요소가 변경 가능한지 아닌지에 따라 나뉘는 기준이다. Mutable한 객체 list, dict, set을 외워두는게 기억하기 편리하다.
참고하기 좋은 표
얕은 복사와 깊은 복사
얕은 복사 (=주소값 복사)
파이썬은 모든 변수에 주소값이 담기므로, 단순히 변수에 할당하는 방식으로는 새로운 객체를 복사하는 것이 아니라 동일한 객체를 가리키게 된다.
a = [0, 0, 0, 0, 0]
b = a
a is b
True
깊은 복사 (1차원)
파이썬 내장함수 copy()
슬라이싱을 통한 복사 ex) a[:]
깊은 복사 (다차원)
copy 모듈의 deepcopy() 메서드 사용
튜플을 사용하는 이유
리스트가 언제든 요소를 추가할 수 있게 하기 위해 실제 데이터보다 큰 메모리를 사용하는데 반해, 튜플은 요소 변경이 없어 고정된 메모리를 사용합니다. 또한, 튜플의 구조는 간단해서 리스트보다 빠른 성능을 보여줍니다. 따라서, 요소 변경이 없는 상황에서는 튜플을 사용하는 것이 메모리를 아끼고 성능을 높이는 방법입니다.
defaultdict를 사용해 기본값이 빈 리스트인 딕셔너리 생성하기
from collections import defaultdict
a = defaultdict(list)
a['x']
a['y']
print(a)
defaultdict(<class 'list'>, {'x': [], 'y': []})
딕셔너리에서 요소를 삭제하는 방법
딕셔너리에서 요소를 삭제하는 방법은 제한적이기 때문에 보통 다음과 같은 방법을 사용한다.
1. 키를 통해 삭제하기
del 예약어, pop(‘키’, 기본값) 메서드 사용합니다. popitem() 메서드를 사용하면, 파이썬 3.6 이상에서는 딕셔너리의 가장 마지막에 있는 키, 값 쌍을 삭제하여 튜플로 반환하고, 3.6 미만에서는 임의의 키, 값 쌍을 삭제하여 튜플로 반환합니다.
2. 값을 통해 삭제하기 (특정 값을 제외하여 새로 딕셔너리를 생성)
딕셔너리 표현식을 사용합니다.
x = {'a': 10, 'b': 20, 'c': 30, 'd': 40}
x = {key: value for key, value in x.items() if value != 20}
x
{'a': 10, 'c': 30, 'd': 40}
파일 객체는 이터레이터입니다!
open을 통해서 가져오는 파일 객체는 이터레이기 때문에, for문에서 반복하거나 언패킹할 수 있습니다.
file = open('welsh.txt', 'r')
a, b, c = file
a, b, c
('안녕하세요.\n', '멍멍!\n', '저는 웰시 코기입니다.\n')
random 모듈에서 자주 사용되는 메서드들
import random
# 0이상 1미만 범위의 난수 생성
random.random() # return: 0 <= x < 1에 해당하는 x 값
# 지정한 범위에 해당하는 정수 하나를 랜덤하게 가져오기
random.randint(1, 16) # return: 1 <= x <= 16에 해당하는 int 타입 x 값
# 지정한 범위에 해당하는 실수 하나를 랜덤하게 가져오기
random.uniform(1, 20) # return: 1.0 <= x < 20.0에 해당하는 float 타입 x 값
# range(start, stop, step) 함수로 만들어지는 정수들 중 랜덤하게 하나를 가져오기
random.randrange(1, 9, 2) # return: 1, 3, 5, 7 중 하나의 값
seq = ['a', 'b', 'c', 'd']
# 시퀀스 객체 내 요소 순서를 무작위로 변경하기
random.shuffle(seq) # seq: ['c', 'b', 'a', 'd']
# 시퀀스 객체에서 요소 하나를 랜덤하게 가져오기
random.choice(seq) # return: 'c', 'b', 'a', 'd' 중 하나의 값
# 시퀀스 객체에서 요소 여러 개를 랜덤하게 가져오기
random.sample(seq, 2) # return: seq 리스트에서 2개의 요소를 뽑아 리스트로 만들어 리턴
datetime 모듈 사용법
from datetime import datetime
# datetime 객체 생성하기
# datetime(년, 월, 일, 시간, 분, 초)
birthday = datetime(1994, 6, 27) # datetime.datetime(1994, 6, 27, 0, 0)
birthday = datetime(1994, 6, 27, 6, 30, 27) # datetime.datetime(1994, 6, 27, 6, 30, 27)
# year, month, day, hour, minute, second 속성에 접근 가능
birthday.year # 1994
birthday.month # 6
# weekday() 메서드를 사용하면 요일을 0(월) ~ 6(일) 인덱스로 반환
birthday.weekday() # 0
# 현재 시간으로 datatime 객체 생성하기
datetime.now() # datetime.datetime(2021, 5, 7, 23, 46, 7, 925228)
# datetime 객체로 두 날짜 사이의 시간 차이 구하기
datetime(2021, 1, 2) - datetime(2020, 1, 1) # datetime.timedelta(days=367)
datetime.now() - datetime(2021, 1, 1) # datetime.timedelta(days=126, seconds=86052, microseconds=468421)
# 문자열로 된 시간을 datetime 객체로 파싱하기
parsed_date = datetime.strptime('Jan 15, 2019', '%b %d, %Y')
parsed_date.month # 1
parsed_date.day # 15
parsed_date.minute # 0
# datetime 객체를 문자열로 만들기
date_string = datetime.strftime(datetime.now(), '%b %d, %Y')
date_string # 'May 08, 2021'
strftime() and strptime() Format Codes 는 다음 링크에서 확인
https://docs.python.org/3/library/datetime.html
함수에서 파라미터의 초깃값을 빈 리스트로 만들고 싶은 경우
함수 파라미터의 초깃값으로는 Immutable한 객체만 사용해야 한다.
만일 Mutable한 객체라면, 여러번 함수를 호출해도 처음에 초깃값으로 생성한 객체를 조작하게 된다.
따라서, 리스트를 인자로 받을 파라미터의 초깃값을 None으로 설정하고 함수 내부에서 if 조건문으로 체크하는 것이 바람직하다.
def add_author(authors_books, current_books=None):
if current_books is None:
current_books = []
current_books.extend(authors_books)
return current_books
Reference
파이썬 코딩 도장
파이썬 del - 제타위키
파이썬 기초
Python, 파이썬 - Call by assignment, mutable, immutable, 파이썬 복사(Python Copy)
-
3-1. 프로세스
프로세스(Process)의 개념
1. 프로세스
실행중인 프로그램을 의미한다.
2. 프로세스의 문맥(Context)
프로세스의 현재 진행 상태를 알려주는 것
time sharing, multitasking 등의 실현은 각 프로세스의 문맥을 정확히 기록해두어야 가능하다!
하드웨어 문맥 : CPU의 수행 상태를 나타냄
ex) Program Counter, 각종 register → CPU 관점에서 파악!
프로세스의 주소 공간 : 어떤 자료구조가 어떤 값을 가지고 있는지, 어떤 함수가 호출되고 return되는지 등을 파악함
ex) code, data, stack → 메모리 관점에서 파악!
프로세스 관련 커널 자료 구조
ex) PCB(Process Control Block), Kernel stack(프로세스마다 다른 커널 스택을 가지기에 개별로 상태 파악 가능) → 운영체제 관점에서 파악! (운영체제가 프로세스를 어떻게 평가하는지)
프로세스의 상태 (Process State)
Running : CPU를 잡고 Instruction을 수행 중인 상태
Ready : 메모리에 올리는 것 등 다른 조건을 모두 만족하고 CPU를 기다리는 상태
Blocked (wait, sleep) : CPU를 주어도 당장 Instruction을 수행할 수 없는 상태
ex) 프로세스 자신이 요청한 event(ex. I/O)가 즉시 만족되지 않아 기다리는 상태, 프로세스 주소 공간 중 필요한 부분이 메모리에 아직 올라와 있지 않을 때 등
현대 컴퓨터에 중기 스케줄러의 등장으로 추가된 상태
Suspended (stopped) : 외부적 이유로 프로세스의 수행이 정지된 상태. 프로세스는 통째로 디스크에 swap out된다.
ex) 메모리에 너무 많은 프로그램이 올라와 있을 때 (by 중기 스케줄러), 사용자가 프로그램을 일시 정지시킨 경우
Blocked : 자신이 요청한 event가 만족되면 Ready
Suspended : 외부에서 resume해 주어야 Active
있을 수도 없을 수도 있는 상태
New : 프로세스가 생성 중인 상태
Terminated : 수행(Execution)이 끝난 상태
프로세스의 상태도
프로세스 상태도 - suspended 상태 추가
위의 프로세스 상태도는 운영체제의 입장에서 프로세스 상태를 명시한 것이다. 따라서, monitor mode에서도 운영체제가 running하고 있다고 말하지 않고, 사용자 프로세스가 Running 상태에 있다고 말한다. 또한, interrupt 혹은 system call을 진행 중일 때, 사용자 프로세스는 (커널모드 혹은 유저모드에서) Running 상태에 있다고 간주한다.
Suspended 상태의 경우, 외부적인 이유로 메모리에서 벗어나 있는 상태로서 inactive하다고 말하고, Blocked에서 벗어났느냐 Ready에서 벗어났느냐에 따라 Suspended Blocked, Suspended Ready로 나뉜다. 또한, Suspended Blocked 상태에서 이전에 요청한 I/O 작업이나 event가 마무리되면 Suspended Blocked이 Suspended Ready로 바뀌기도 한다.
프로세스 진행과 queue
커널 주소 공간의 자료구조 Queue
위 상태도에서 나오는 하드웨어 및 CPU의 Queue들은 머릿 속에서는 모두 흩어져 있는 것으로 분류되지만, 사실은 모두 커널 주소 공간 중 Data 영역에서 queue 자료구조를 만들어 관리하는 것이다.
PCB (Process Control Block)
운영체제가 각 프로세스를 관리하기 위해 프로세스당 유지하는 정보
PCB의 구조
PCB의 구성 요소 (구조체로 유지)
OS가 관리상 사용하는 정보
ex) Process state, Process ID, scheduling information & priority
CPU 수행 관련 하드웨어 값 (프로세스 문맥 정보)
ex) Program Counter, registers
메모리 관련 (프로세스 문맥 정보)
ex) code, data, stack의 위치 정보
파일 관련 (프로세스 문맥 정보)
ex) open file descriptors
문맥 교환 (Context Switch)
CPU를 한 프로세스에서 다른 프로세스로 넘겨주는 과정
문맥 교환 흐름
위 그림의 프로세스 A가 프로세스 B에게 CPU를 넘겨줄 때, 운영체제는 정확히 그 시점부터 프로세스 A가 다시 시작할 수 있게 프로세스 A의 PCB에 레지스터들의 저장된 값, Program Counter 값, 메모리 위치 정보 등을 저장한다. 새롭게 CPU를 얻게 되는 프로세스 역시 운영체제가 해당 프로그램의 PCB에서 상태를 읽어와 저장된 시점부터 다시 작업을 수행한다.
문맥 교환이 일어나는 경우와 아닌 경우
System call이나 Interrupt 발생 시 항상 문맥 교환이 일어나진 않는다. 보통은 위 그림의 (1)의 경우처럼 원래 작업 중이던 프로세스에게 다시 CPU 제어권을 넘겨 timer가 정한 시간에 도달할 때까지 작업을 수행하게 한다. 그러나 timer가 정한 시간이 다 되거나 I/O 요청으로 인해 프로세스가 blocked 상태가 되는 (2)의 경우에는 문맥 교환이 발생한다.
물론 (1)의 경우에도 커널 code를 실행하기 위해 CPU 수행 정보 등 약간의 context를 PCB에 저장해야 되지만 문맥 교환만큼 부담이 크지 않다.
ex) Cache memory flush(캐시 메모리를 비우는 것)는 overhead가 큰데, 문맥 교환 시에는 이러한 캐시 메모리를 비워야 하는 반면, 단순한 커널모드와 유저모드 사이의 변환에서는 캐시 메모리를 비울 필요까지는 없다.
프로세스를 스케줄링하기 위한 큐 (Queue)
Job queue : 현재 시스템 내에 있는 모든 프로세스의 집합 (Ready queue와 Device queue의 프로세스를 포함)
Ready queue : 현재 메모리에 있으면서 CPU를 잡아 실행되기를 기다리는 프로세스의 집합 (혹은 줄)
Device queue : I/O device의 처리를 기다리는 프로세스의 집합 (혹은 줄)
스케줄러
Long-term scheduler (장기 스케줄러 or job scheduler)
시작 프로세스 중 어떤 것에게 memory를 주고 ready queue로 보낼지 결정한다.
degree of Multiprogramming(메모리에 올라가 있는 프로세스의 수)을 제어
메모리에 올라가 있는 프로그램 수가 너무 많아도 너무 적어도 안좋다.
그러나 현대의 대부분 컴퓨터의 time sharing system에서는 사용하지 않는다. (무조건 메모리에 프로세스를 올린다. = ready)
Short-term scheduler (단기 스케줄러 or CPU scheduler)
어떤 프로세스에게 CPU를 주고 running 상태로 만들지 결정한다.
빠른 속도 (millisecond 단위)
Medium-term scheduler (중기 스케줄러 or Swapper)
메모리 여유 공간을 마련하기 위해 메모리에 있는 프로세스를 통째로 디스크로 쫒아낸다.
Long-term scheduler를 대신해 현대 컴퓨터의 degree of Multiprogramming을 제어 (프로그램은 실행 시 무조건 메모리에 올라가므로 어떤 것을 쫒아낼지가 이슈가 된다.)
Reference
운영체제, 이화여대 반효경 교수님
-
2-2. 프로그램의 실행
동기식 입출력과 비동기식 입출력
1. 동기식 입출력 (synchronous I/O)
I/O 요청 후 입출력 작업이 완료된 후에야 CPU 제어권이 사용자 프로그램에게 넘어가는 것을 의미한다.
구현 방법 1
I/O가 끝날 때까지 CPU를 낭비시킨다.
매 시점 하나의 I/O만 일어날 수 있다. (I/O 장치도 낭비)
구현 방법 2
I/O 요청 후 I/O가 완료될 때까지 해당 프로그램에게서 CPU를 빼앗는다.
I/O 처리를 기다리는 줄에 해당 프로그램을 줄 세운다.
ex) A 프로그램 I/O 작업을 줄 세우고 B 프로그램에 CPU를 할당했는데 B도 I/O를 요청하면, B 프로그램 I/O 작업도 줄 세우고 C 프로그램에 CPU를 할당한다. (∴ CPU도 I/O도 끊김 없이 자신의 작업을 계속하게 된다.)
다른 프로그램에게 CPU를 준다.
2. 비동기식 입출력 (asynchronous I/O)
I/O가 시작된 후 입출력 작업이 끝나기를 기다리지 않고 CPU 제어권이 I/O를 요청한 해당 사용자 프로그램으로 다시, 즉시 넘어가는 것을 의미한다.
동기식 / 비동기식 입출력의 흐름
DMA (Direct Memory Access)
빠른 입출력 장치(ex. 1바이트 혹은 2바이트의 키보드 타이핑이 지속적으로 있을 때)를 메모리에 가까운 속도로 처리하기 위해 사용한다.
CPU의 중재 없이 device contorller가 device의 buffer storage 내용을 block(page) 단위로 메모리에 직접 전송한다.
바이트 단위가 아닌 block(혹은 page) 단위로 인터럽트를 발생시킨다.
다시 말해, DMA에 의해 device가 메모리에 직접 내용을 카피해두고, block 단위로 모아서 CPU에 한 번 인터럽트를 걸어 I/O 작업이 끝났음을 알린다.
일반 I/O와 Memory Mapped I/O에서의 입출력 Instruction 차이
일반 I/O (좌) & Memory Mapped I/O (우)
CPU에서 실행할 수 있는 Instruction에는 메모리에 접근하는 것과 I/O device에 접근하는 것이 있다.
일반 I/O : Memory addresses + Device addresses
Memory Mapped I/O : I/O device에도 메모리 주소를 매겨 Memory에 접근하는 Instruction을 사용해 I/O할 수 있다.
저장장치 계층 구조
CPU가 접근 가능한 저장장치를 Primary(Executable) Storage, 접근 가능하지 않은 장치를 Secondary Storage라고 한다. Primary Storage에는 Registers, Cache Memory, Main Memory 등이 있고 Secondary Storage에는 Magnetic Disk, Optical Disk, Magnetic Tape 등이 있다.
캐싱(Caching) : 보다 빠른 저장장치로 당장 필요한 정보를 읽어 들여서 사용하는 것을 말한다. 보다 느린 저장 장치에서 모든 것을 다 읽어 들이진 못하지만, 한 번 읽어 놓으면 다시 사용하기 편리하기 때문에, 캐싱의 주 목적은 보통 재사용성에 둔다. 또한, 빠른 저장장치는 저장 공간에 한계가 있으므로 기존에 있던 것 중 어떤 것을 제외시킬지는 캐싱의 이슈 중 하나이다.
프로그램의 실행 (메모리 load)
프로그램 실행 과정
프로그램은 File system(ex. 하드디스크)에 ‘실행파일‘의 형태로 존재한다. 실행파일을 실행하면 ‘프로세스‘가 되어 물리적 메모리(Physical memory)에 올라간다.
실행파일은 실행되면 곧바로 물리적 메모리로 가지 않고 가상 메모리(Virtual memory)라는 중간 단계를 거친다. 어떤 프로그램이 실행되면 0번지부터 시작되는 그 프로그램만의 독자적인 메모리 주소 공간(Address space)가 형성되는데, 각 주소 공간은 ① CPU에서 실행할 기계어가 담기는 code, ② 변수 혹은 전역변수 등 프로그램이 사용하는 자료구조가 담긴 data, ③ 함수 호출 및 return할 때 어떤 data를 쌓았다가 꺼내가는 용도인 stack 영역이 존재한다. 모든 프로그램은 각자의 주소 공간을 물리적 메모리에 올려 스스로를 실행시킨다.
컴퓨터 부팅 시 커널(운영체제)은 물리적 메모리에 올라가 항상 상주하는 반면, 사용자 프로그램들은 실행 시 주소 공간이 생겼다가 종료 시 사라지는 과정을 가진다. 또한, 프로그램은 실행될 때 해당 주소 공간의 모든 것이 아닌 가장 필요한 부분(ex. A 함수 실행 중이면 그에 필요한 코드)만 물리적 메모리에 올린다. (∵ 메모리 낭비를 피하기 위해서) 또한, 해당 부분이 필요 없게 되면 물리적 메모리에서 제외하지만, 프로그램이 종료 전까지 보관이 필요한 경우라면 물리적 메모리 제외와 동시에 하드 디스크의 Swap area로 보낸다.
각 프로그램들이 가지는 주소 공간은 물리적, 연속적으로 할당된 것이 아닌 머릿속에만 존재하는 개념이라 총칭해 가상 메모리라고 부른다. 실제로 가상 메모리의 주소 공간은 연속적으로 할당되지 않고 어떤 부분은 물리적 메모리에 어떤 부분은 Swap area에 나뉘어 존재한다.
따라서, 가상 메모리는 각 프로그램마다 독자적으로 가지고 있는 메모리 주소 공간을 의미한다. 하지만 경우에 따라, 메인 메모리의 연장 공간으로 하드 디스크를 사용하는 기법(swapping)을 의미하기도 한다.
File system의 하드디스크 내용은 컴퓨터를 꺼도 유지되지만, Swap area의 하드디스크 내용은 메모리의 연장 공간이어서 컴퓨터를 끄면 프로세스 종료와 더불어 메모리 내용이 사라지고 Swap area의 내용도 의미가 없어진다.
가상 메모리의 주소(ex. 1000번지)를 물리적 메모리의 주소(ex. 3000번지)로 변환해주는 과정을 address translation이라고 하는데, 이 과정은 특정 하드웨어의 지원을 받아 이루어진다.
커널 주소 공간의 내용
물리적 메모리의 커널 영역
PCB (Process Code Block) : 메모리에 올라온 프로그램을 관리하기 위한 자료구조 (ex. CPU를 얼마나 썼는지, 다음은 어떤 프로그램에게 얼마나 메모리를 줘야 하는지 등을 결정하는데 이용)
stack : 커널 함수 호출 및 return을 위해 존재하며, 어떤 프로그램이 어떤 함수를 이용하는지 알기 위해 각 프로그램마다 커널 스택을 따로 둔다.
사용자 프로그램이 사용하는 함수
모든 프로그램은 어떤 언어를 사용해서 만들었든 함수로 짜여 있다. low-level, 심지어 컴파일해서 기계어 단위의 instruction으로 가더라도 함수 구조에 대한 내용을 확인할 수 있다.
사용자 프로그램이 사용하는 함수의 종류
컴파일하여 나의 프로그램의 실행 파일을 만들면, 실행 파일에는 사용자 정의 함수든 라이브러리 함수든 모두 코드에 포함되어 있다. 반면, 커널 함수는 내 실행 파일에 커널 함수 코드(정의)가 포함되어 있지 않고 시스템 콜을 통한 호출에 의해 접근해서 사용한다.
프로그램의 실행 (A라는 프로그램의 관점에서)
프로그램의 실행 과정
위와 같이 프로그램은 시작부터 종료까지 user mode와 kernel mode를 반복한다.
Reference
운영체제, 이화여대 반효경 교수님
-
Git 기초
Git 기초
Git 기본 이용 사이클
Git 저장소 선언하기 (Initialization & Repository)
Git 저장소를 흔히 Repo(레포)라고 부르며, Repo는 git으로 관리하는 하나의 메인 저장소를 뜻함
사용자가 변경한 모든 내용 추적 가능
현재 상태, 변경 시점, 변경한 사용자, 설명 텍스트 등
관리할 폴더에서 git init 을 통해 선언
주목할 특징
Git은 이제 Local에서 모든 것을 저장 및 버전 관리 가능하고 나중에 원격 서버에 올려도 상관 없음
Git은 데이터를 추가만 할 수 있음
파일 삭제 == 삭제 기록 추가 (물론 온전한 삭제도 가능하지만, 버전 관리에서는 삭제 기록도 매우 중요)
Git은 파일을 추적하지 않음
파일의 내용 단위로 각 문자와 줄을 추적
빈 디렉토리는 추적하지 않음
파일 추가/수정/삭제
변경 사항 선택
파일 상태
파일은 추적 여부로 구분해 tracked, untracked 파일로 나눌 수 있음
tracked 파일은 Unmodified(수정 없음), Modified(수정 있음), Staged(저장(커밋)을 위해 준비됨) 상태로 구분됨
스테이징(Staging)을 통해 커밋하고 싶은 파일 선택
스테이징이 필요한 이유?
여러 작업 중 일부분만 커밋해야 할 때
커밋 전 상태를 수정 또는 체크할 때 (안전하게 커밋하기 위함)
상태 업데이트
커밋(Commit)을 통해 새로운 버전으로 업로드
커밋을 하면, 각 버전마다 40자리의 숫자 + 알파벳 조합으로 이루어진 해시값이 존재
해시 값은 버전의 주소(Key)
해시값은 내용(파일 구조)을 사용해 생성됨 (파일이 어떤 폴더 밑에 있고, 어떤 내용이다 등의 상태를 40자리로 표현)
Commit Hash 값으로 Checkout하면 버전 이동 가능
Git Branch
소프트웨어 버전 넘버링 상식
보통 세가지 숫자로 표현되며, 위 그림과 같이 각각 의미가 다름
마지막에 알파벳이 들어가는 경우도 있지만, 보통은 모두 숫자로 사용
1.1, 2.1 같이 두 개로 표현된 경우, 1.0.1, 2.0.1을 뜻함
브랜치 (Branch)
버전 관리시 수많은 오류를 개발자들이 각각 따로 처리하는 상황이 발생하는데, 이를 위해 브랜치 개념이 등장
브랜치는 시간의 흐름의 축
Git 명령어
프로젝트 총 관리자 및 시작자 관점
프로젝트 시작 선언
git init
.git 파일이 생성됨 (처음엔 숨김 상태라 안보임)
모든 버전 관리 정보가 담겨있으므로 조심해야 함 (버전을 초기화하고 싶을 땐, 이 폴더를 통째로 삭제하면 됨)
로컬에서 Git 초기화 진행
시작 버전은 master branch에 기록
.gitignore 파일을 생성해 양식(정규표현식)에 맞춰 작성하면, 저장하고 싶지 않은 파일들을 무시할 수 있음
README.md 파일 작성
Repo의 메인 페이지 역할
프로젝트 설명 및 사용방법, 라이센스 등을 기술
유저 이름과 이메일 등록하기 (log에 남기는 용도)
git config --global user.name="[이름]" : 깃 설정 파일을 내 모든 컴퓨터에 적용, 그 중 이름 정보 입력하겠다.
git config --global user.email="[이메일]" : 깃 설정 파일을 내 모든 컴퓨터에 적용, 그 중 이메일 정보 입력하겠다.
파일 스테이지로 올리기
git add [file]
[file]을 스테이지로 올림 (폴더나 전체도 가능)
스테이지에서 내리기
git restore --staged [file]
파일 상태 체크하기 (습관적으로)
git status
git diff
스테이지에 있는 내용 커밋
git commit -m "add README.md"
간단한 설명과 함께 커밋
커밋 기록 살펴보기
git log
조금 더 시각적으로 편하게 살펴보는 방법
git log --all --decorate --graph --oneline
위 방법에 간단한 별명을 붙이는 방법
git config --global alias.adog "log --all --decorate --graph --oneline"
원격 저장소와 연결
git remote add origin [url]
origin이라는 이름으로 [url]과 연결 (origin은 원격 저장소에 관용 표현이므로 변경 가능)
연결 여부를 확인하는 명령어
git remote -v
원격 저장소로 올리기
git push origin master
원격 저장소 master branch에 업데이트
로컬과 원격 저장소가 동기화됨!
버전 이동하기
git log 로 원하는 버전의 해시값 확인
만일 과거로 돌아와 미래 로그가 안보인다면, git log --all
git checkout [40자리 해시값의 앞 7자리]
예시
git checkout a703380
git checkout master
협업하는 개발자 시점
원격 저장소 다운받기
git clone [url]
파일 구조, 로그를 포함한 모든 것이 다운로드됨
기능별로 개발하기
master 브랜치는 배포 버전이므로 함부로 커밋하기 어려움… 필요한 기능은 병렬적으로 가지쳐서 개발하자!
git branch [name]
처음에는 master를 가리키는 것처럼 보이지만 커밋하면 branch를 가리키는 것을 확인할 수 있음
[name] 없이 git branch를 쓰면, 현재 브랜치가 무엇인지 확인 가능
브랜치/버전 이동하기
git checkout [name]
브랜치 합치기
git merge [name]
[name] 브랜치를 현재 브랜치로 합치기
기능 완성 후, master와 합치는데 주로 이용
같은 파일만 건드리지 않았으면 문제 없이 병합 가능!
가지가 복잡한 브랜치 합치기
git rebase master - 자주 사용!!
base(기준점)를 master의 끝 점으로 re-base(재설정)해서 그래프를 한 줄로 만듬
브랜치 지우기
git branch -d [name]
완료했거나 필요가 없어진 브랜치를 삭제
프로젝트 리더 시점
다른 개발자가 원격에 메인 버전을 업데이트 하면, 최신 버전을 다운받아 오고 싶음
원격에서 기록 가져오기
git fetch - 자주 사용!!
원격 저장소와 동기화하지만 merge는 하지 않음
동일 파일을 건드리는 Conflict를 방지하기 위해 미리 체크할 수 있음
원격에서 기록 가져오고 합치기
git pull
원격 저장소와 동기화하고 merge까지 진행
충돌을 일으킨 개발자 시점
같은 파일의 같은 부분을 수정하고 합칠 때는 충돌(Conflict)이 발생함
커밋 되돌리기(reset, revert), 직접 충돌 부분 수정하기 등 다양한 해결법 존재
실수한 커밋을 RESET하기
git reset [option] [commit의 7자리 해시값]
해당 커밋 이후 기록을 없애기 (Hard, Mixed, Soft)
커밋으로 프로젝트가 망하면, 원하는 커밋으로 reset 가능
이미 원격 저장소에 올라가 있는 경우 사용해서는 안됨! 다른 개발자들과 버전이 달라져버린다…
실수한 커밋도 내 커밋, 기록하자!
git revert [commit의 7자리 해시값] - 가장 좋음!!
되돌릴 커밋이 여러개라면 범위를 주어 한번에 되돌릴 수 있음
git revert [commit의 7자리 해시값]..[commit의 7자리 해시값]
선택한 커밋 하나만 되돌리고 다른 커밋 내용은 그대로 둠, 수정한 기록도 남김
협업하는데 커밋 로그를 함부로 지우면 서로 버전이 이상해질 수 있으니 revert로 수정 기록 남기자!
다만, revert 쓰는 것 보다도 가장 좋은건 수정해서 그냥 커밋을 하는 것이 아닐까?
브랜치 바꿔야하는데 커밋은 하기 싫을 때
현재 무언가 작업 중일 때 브랜치를 바꾸면, 작업 중인 내용이 바뀐 브랜치로 따라옴
git stash
현재 작업하고 있는 작업물을 따로 추적하지 않는 저장파일에 저장하기
Reference
티아카데미 Git & GitHub page 블로그 만들기
초보용 git 되돌리기
-
Git의 발전 과정
Git의 발전 과정
Git 탄생 배경
Git은 분산형 버전 관리 시스템 (DVCS, Distributed Version Control System)
처음엔 리눅스 오픈 커뮤니티에서 BitKeeper(회사)의 DVCS를 사용했으나, 이익을 추구하는 기업과 오픈 커뮤니티와의 상충이 발생
리눅스 창시자 Linus Tovalds를 중심으로 2005년 리눅스 오픈 커뮤니티에서 자체 툴로서 제작
버전 관리 시스템 (VCS, Version Control System)
파일의 변경 사항을 저장하고, 원하는 시점의 버전을 다시 꺼내올 수 있는 시스템
CVCS(중앙집중식 VCS) VS DVCS(분산형 VCS)
중앙집중식 버전 관리 시스템 (CVCS, Central Version Control System)
하나의 중앙 서버를 두고, 해당 서버에서 버전을 관리
최신 버전으로 업그레이드하기 위해, 최상단의 버전을 다운받고 수정해 업데이트하는 방식
메인 서버에 접속하지 않으면 로컬에서 개발 불가
메인 서버가 폭파되면 큰일남
효율적이지만, 여전히 협업의 불편함이 있음
분산형 버전 관리 시스템 (DVCS, Distributed Version Control System)
메인 서버와 개발자들의 컴퓨터 각각에 모든 코드와 파일 변경 정보들이 분산되어 버전을 관리
메인 서버에 파일들이 어떻게 수정되었고, 누가 변경했는지 등의 정보가 저장됨
로컬에서 버전 관리하고 메인 서버에 올릴 수 있음
메인 서버가 폭파되어도 버전들 생존 가능
보다 효율적인 협업 가능
Subversion (SVN) - CVCS
CVCS의 대표적인 시스템 중 하나
파일의 모든 변경 사항을 저장
초기에 File A, B, C를 각각 만든다면, 각각의 파일마다 변경 사항 델타를 따로 저장
특정 버전을 다운 받을 때, 초깃값에서 해당 버전까지의 델타들을 합한 값인 파일을 다운받아 관리
Git - DVCS
특정 버전에 해당하는 모든 정보와 파일들을 스냅샷으로 찍어 관리
버전이 수정된 파일은 수정본을 올리고, 수정되지 않은 파일은 해당 파일이 존재하는 버전으로 연결되는 링크를 저장
최신 버전의 스냅샷만 유지하고 이전 버전은 델타로 관리
Reference
티아카데미 Git & GitHub page 블로그 만들기
-
Django 간단한 블로그 만들기
1. 가상환경 설정하기 (Window)
가상환경이란 자신이 원하는 환경을 구축하기 위해 필요한 모듈만 담아 놓는 바구니를 말한다. 프로젝트 기초 전부를 분리해 사용할 수 있기 때문에 유용하다.
Virtualenv를 통한 설정
가상환경 생성하기
먼저, 명령 프롬프트에서 가상환경을 생성할 폴더를 만들고 해당 폴더로 이동한다. 홈 디렉토리(C:\Users\Name)에 생성하면 적당한 선택이다.
mkdir djangogirls
cd djangogirls
그리고 가상 환경을 생성한다. 가상환경을 이름을 설정할 수 있는데 여기서는 myvenv로 생성하기로 한다.
python -m venv myvenv
가상환경 사용하기
myvenv\Scripts\activate
만일 실행이 안될 경우, cmd를 관리자 권한으로 실행한다.
2. 장고 설치하기
pip을 최신 버전으로 업데이트하기
python3 -m pip install --upgrade pip
장고 설치하기
pip install django~=2.0.0
3. 장고 프로젝트 시작하기
생성할 장고 프로젝트의 구조
djangogirls
├───manage.py
└───mysite
settings.py
urls.py
wsgi.py
__init__.py
manage.py: 사이트 관리를 도와주는 파일
settings.py: 웹사이트 설정이 있는 파일
urls.py: urlresolver가 사용하는 패턴 목록을 포함하는 파일
장고 프로젝트 시작하기 명령 (mysite는 프로젝트 이름이므로 변경가능)
현재 디렉토리에서 장고 프로젝트 생성
(myvenv) C:\Users\Name\djangogirls> django-admin.py startproject mysite .
settings.py 설정 변경
정확한 현재 시간 설정 (선택)
TIME_ZONE = 'Asia/Seoul'
정적파일 경로 추가
파일 끝에 STATIC_URL 바로 밑에 STATIC_ROOT 추가
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
호스트 이름 일치시키기
DEBUG가 True이고 ALLOWED_HOSTS가 비어 있으면, 호스트는 [‘localhost’, ‘127.0.0.1’, ‘[::1]’] 에 유효
PythonAnywhere에 앱을 배포한다면 다음과 같이 수정
ALLOWED_HOSTS = ['127.0.0.1', '.pythonanywhere.com']
데이터베이스 설정하기
settings.py 파일 안에 sqlite 데이터베이스가 기본적으로 설치되어 있음 (기본 장고 데이터베이스 어댑터)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
데이터 베이스 생성 명령
(myvenv) ~/djangogirls$ python manage.py migrate
웹 서버 시작하기
(myvenv) ~/djangogirls$ python manage.py runserver
4. 장고 앱 만들기
장고 앱 만들기
프로젝트 내부에 장고 애플리케이션 생성 (blog는 앱 이름이므로 변경 가능)
(myvenv) ~/djangogirls$ python manage.py startapp blog
settings.py 속 INSTALLED_APPS에 새로 생성한 앱 등록 (앱 이름을 끝에 추가)
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'blog',
]
앱 생성 후 프로젝트 구조
djangogirls
├── mysite
| __init__.py
| settings.py
| urls.py
| wsgi.py
├── manage.py
└── blog
├── migrations
| __init__.py
├── __init__.py
├── admin.py
├── models.py
├── tests.py
└── views.py
5. 모델 만들기
블로그 글 모델 객체 만들기
blog/models.py에 Model 객체를 선언해 모델 생성 (파일 내 내용 전부 삭제 후 아래 코드 추가)
from django.conf import settings
from django.db import models
from django.utils import timezone
class Post(models.Model):
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
text = models.TextField()
created_date = models.DateTimeField(
default=timezone.now)
published_date = models.DateTimeField(
blank=True, null=True)
def publish(self):
self.published_date = timezone.now()
self.save()
def __str__(self):
return self.title
데이터베이스에 모델 추가하기
모델에 생긴 변화를 알리기 위해 migration 파일 생성
python manage.py makemigrations blog
데이터베이스에 반영하기
python manage.py migrate blog
6. 장고 관리자
관리자 페이지 언어 변경 (선택)
setting.py의 LANGUAGE_CODE = ‘ko’로 바꿀 것
blog/admin.py 파일 내용 수정
생성한 모델 import 및 모델 등록
```python
from django.contrib import admin
from .models import Post
admin.site.register(Post)
```
관리자 계정 생성
서버를 실행하는 중에 관리자 계정을 생성해야만 한다.
코드 실행 후 유저 네임, 이메일 주소 및 암호 입력
(myvenv) ~/djangogirls$ python manage.py createsuperuser
Username: admin
Email address: admin@admin.com
Password:
Password (again):
Superuser created successfully.
7. 서버 배포하기
PythonAnywhere로 배포
.gitignore 파일 설정 (github에 코드 push 전)
특정 파일들을 .gitignore 파일에 등록하면, git이 해당 파일들의 변화는 무시하고 추적하지 않게끔 할 수 있다.
여기서 db.sqlite3는 로컬 데이터베이스이고 이는 테스트 공간으로만 사용하는 것이 좋으므로, github 저장소에 저장하지 않는다.
에디터를 사용해 아래와 같은 내용으로 .gitignore 파일을 프로젝트 폴더(djangogirls)에 만들자.
*.pyc
*~
__pycache__
myvenv
db.sqlite3
/static
.DS_Store
PythonAnywhere 서버에 Github에서 코드 가져오기
PythonAnywhere 콘솔에 다음 코드 입력 (my-first-blog는 github 저장소 이름)
git clone https://github.com/<your-github-username>/my-first-blog.git
PythonAnywhere에서 가상환경 생성 및 활성화하기
$ cd my-first-blog
$ virtualenv --python=python3.6 myvenv
Running virtualenv with interpreter /usr/bin/python3.6
[...]
Installing setuptools, pip...done.
$ source myvenv/bin/activate
(myvenv) $ pip install django~=2.0
Collecting django
[...]
Successfully installed django-2.0.6
PythonAnywhere에서 데이터베이스 초기화 및 관리자 계정 생성하기
(mvenv) $ python manage.py migrate
Operations to perform:
[...]
Applying sessions.0001_initial... OK
(mvenv) $ python manage.py createsuperuser
web app으로 블로그 배포하기
PythonAnywhere 대시보드에서 Web을 클릭하고 Add a new web app을 선택
도메인 이름 확정 후, 수동설정(munual configuration)을 클릭하고 Python 3.6을 선택한 다음, 다음(Next)을 클릭
가상환경 설정하기
PythonAnywhere 설정 화면의 가상환경(Virtualenv) 섹션에서 가상환경 경로를 입력해주세요(Enter the path to a virtualenv)라고 쓰여 있는 빨간색 글자를 클릭하고 /home/<your-username>/my-first-blog/myvenv/ 라고 입력
이동 경로를 저장하려면, 파란색 박스에 체크 표시하고 클릭
WSGI 파일 설정하기
PythonAnywhere에게 웹 애플리케이션의 위치와 Django 설정 파일명을 알려주는 역할
“WSGI 설정 파일(WSGI configuration file)” 링크(페이지 상단에 있는 “코드(Code)” 섹션 내 /var/www/<your-username>_pythonanywhere_com_wsgi.py 부분)를 클릭
모든 내용을 삭제하고 아래 코드 추가
import os
import sys
path = '/home/<your-PythonAnywhere-username>/my-first-blog' # PythonAnywhere 계정으로 바꾸세요.
if path not in sys.path:
sys.path.append(path)
os.environ['DJANGO_SETTINGS_MODULE'] = 'mysite.settings'
from django.core.wsgi import get_wsgi_application
from django.contrib.staticfiles.handlers import StaticFilesHandler
application = StaticFilesHandler(get_wsgi_application())
저장(Save)을 누르고 웹(Web) 탭 누르기
큰 녹색 다시 불러오기(Reload) 버튼을 누르면, 모든 배포 작업 완료
8. URL 설정 및 뷰(View) 만들기
장고는 URLconf (URL configuration)를 사용
URLconf는 장고에서 URL과 일치하는 뷰를 찾기 위한 패턴들의 집합이다.
mysite/urls.py에서 url 설정
mysite/urls.py의 초기 코드
"""mysite URL Configuration
[...]
"""
from django.contrib import admin
from django.urls import path
urlpatterns = [
path('admin/', admin.site.urls),
]
blog 앱에서 mysite/urls.py로 url들 가져오기
from django.contrib import admin
from django.urls import path, include # include 추가
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('blog.urls')), # blog.urls를 가져오는 코드 추가
]
blog/urls.py 파일 생성 및 코드 추가
from django.urls import path
from . import views
urlpatterns = [
path('', views.post_list, name='post_list'), # post_list 뷰를 루트 url에 할당
]
뷰 만들기
뷰는 애플리케이션의 로직을 넣는 곳으로, 모델에서 필요한 정보를 받아와 템플릿에 전달하는 역할을 한다.
blog/views.py 안에 뷰 만들기
초기 코드
from django.shortcuts import render
# Create your views here.
뷰 만들기
from django.shortcuts import render
# Create your views here.
def post_list(request):
'''
요청(request)을 넘겨받아 render메서드를 호출한다.
이 함수는 render 메서드를 호출하여 받은(return) blog/post_list.html 템플릿을 보여준다.
'''
return render(request, 'blog/post_list.html', {})
9. 템플릿 만들기
템플릿이란 서로 다른 정보를 일정한 형태로 표시하기 위해 재사용 가능한 파일을 말한다. (장고의 템플릿 양식은 HTML)
blog/templates/blog 디렉토리를 만들고, 디렉토리 내부에 html 파일 생성
하위 디렉토리를 만드는 것은 폴더가 구조적으로 복잡해졌을 때 찾기 쉽게 하기 위한 관습적 방법이다!
post_list.html 파일 생성 및 원하는 html 코드 추가
예시
```html
Django Girls blog
Django Girls Blog
published: 14.06.2014, 12:14
My first post
Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.
published: 14.06.2014, 12:14
My second post
Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut f.
```
10. 모델로부터 템플릿에 동적으로 데이터 가져오기
뷰에서 모델 연결하기
blog/views.py 파일 수정
from django.shortcuts import render
from django.utils import timezone # 쿼리셋 동작을 위해 추가
from .models import Post # Post 모델을 사용하기 위해 import
def post_list(request):
# 쿼리셋 추가
posts = Post.objects.filter(published_date__lte=timezone.now()).order_by('published_date')
return render(request, 'blog/post_list.html', {'posts': posts}) # 'posts' 매개변수 추가
템플릿에서 템플릿 태그를 사용해 보여주기
blog/templates/blog/post_list.html 에서 템플릿 태그 사용
<div>
<h1><a href="/">Django Girls Blog</a></h1>
</div>
# 장고 템플릿에서의 루프 테크닉
{% for post in posts %}
<div>
<p>published: {{ post.published_date }}</p>
<h1><a href="">{{ post.title }}</a></h1>
<p>{{ post.text|linebreaksbr }}</p>
</div>
{% endfor %}
11. 간략하게 CSS 다루기
부트스트랩 설치하기
인터넷에 있는 파일을 연결하므로써 진행
html 파일 내 <head>에 아래 링크 추가
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
정적 파일 (static files)
css 파일과 이미지 파일이 해당
앱에 static 폴더를 추가하고 폴더 안에 정적 파일 저장 (장고는 static 폴더를 자동을 찾을 수 있음!)
blog 앱 안에 static 폴더 생성
djangogirls
├── blog
│ ├── migrations
│ ├── static
│ └── templates
└── mysite
static 폴더 내부에 css 폴더를 만들고, css 파일을 생성해 저장
blog/static/css/blog.css 파일 생성
djangogirls
└─── blog
└─── static
└─── css
└─── blog.css
blog.css 파일에 다음과 같은 예시 코드 추가
```css
.page-header {
background-color: #ff9400;
margin-top: 0;
padding: 20px 20px 20px 40px;
}
.page-header h1, .page-header h1 a, .page-header h1 a:visited, .page-header h1 a:active {
color: #ffffff;
font-size: 36pt;
text-decoration: none;
}
.content {
margin-left: 40px;
}
h1, h2, h3, h4 {
font-family: ‘Lobster’, cursive;
}
.date {
color: #828282;
}
.save {
float: right;
}
.post-form textarea, .post-form input {
width: 100%;
}
.top-menu, .top-menu:hover, .top-menu:visited {
color: #ffffff;
float: right;
font-size: 26pt;
margin-right: 20px;
}
.post {
margin-bottom: 70px;
}
.post h1 a, .post h1 a:visited {
color: #000000;
}
```
html 파일에서 정적 파일 로딩 및 css 파일 링크 추가
class를 추가하고 정적 파일을 로딩하여 css파일과 링크한 html 파일 예시
```django
{% raw %}
{% load static %} // 정적 파일 로딩
Django Girls blog
// css 파일 링크 추가
Django Girls Blog
{% for post in posts %}
published: {{ post.published_date }}
{{ post.title }}
{{ post.text|linebreaksbr }}
{% endfor %}
{% endraw %}
```
12. 장고 템플릿 확장하기
템플릿 확장은 웹사이트 안의 서로 다른 페이지에서 HTML의 일부를 동일하게 재사용 할 수 있게 하는 것을 말한다.
기본 템플릿 생성하기
blog/templates/blog/ 에 base.html 파일 생성
block 템플릿 태그를 적절히 삽입한 뼈대 html 코드 추가
post_list.html 파일의 전체 코드 중 <body> 태그 내용만 바꿔 다음과 같이 base.html에 코드 추가
```django
{% raw %}
{% load static %}
Django Girls blog
Django Girls Blog
{% block content %}
{% endblock %}
{% endraw %}
```
기본 템플릿과 확장 템플릿 연결하기
확장할 html 파일에는 블록에 대한 템플릿의 일부만 남김
block 템플릿 태그 추가
확장 태그를 파일 맨 앞에 추가
blog/templates/blog/post_list.html을 다음 코드로 변경
{% raw %}
{% extends 'blog/base.html' %}
{% block content %}
{% for post in posts %}
<div class="post">
<div class="date">
{{ post.published_date }}
</div>
<h1><a href="">{{ post.title }}</a></h1>
<p>{{ post.text|linebreaksbr }}</p>
</div>
{% endfor %}
{% endblock %}
{% endraw %}
Reference
가상환경
장고걸스 튜토리얼
-
2-1. 컴퓨터 시스템 구조
1. 컴퓨터 시스템 구조
1.1. Computer (전문가적 입장에서)
CPU : 매 클럭 사이클마다 Memory에서 Instruction(기계어)을 읽어서 실행한다. Memory 및 I/O device의 local buffer에 접근할 수 있다.
Memory : CPU의 작업 공간이다. 원래는 CPU만 접근 가능한 공간이지만 DMA controller가 있다면 이 역시 접근이 허용된다.
1.2. I/O device
키보드, 마우스 : 입력장치
모니터, 프린터 : 출력장치
디스크 : 보조기억장치이자 입출력장치 (디스크에서 내용을 읽으면 입력장치, 디스크에 내용을 저장하면 출력장치)
2. 컴퓨터 시스템 구조 (더 자세하게)
2.1. I/O device
device controller (장치 제어기) → Hardware!
해당 I/O device를 전담하여 관리하는 일종의 작은 CPU이다. 자신의 local buffer만 접근 가능하다.
control register, status resgister를 가짐 (CPU가 지시하는 제어 정보 및 명령을 관리하고 수행하는 register)
local buffer를 가짐 (실제 data를 저장하고 담는 register)
local buffer : device controller의 작업 공간이다.
device driver (장치 구동기)
다양한 제조사의 디바이스는 각각의 디바이스를 처리하기 위한 개별 인터페이스를 갖고 있는데, OS에 설치하는 프로그램 중 각각의 디바이스에 올바른 인터페이스로 접근할 수 있게 해주는 소프트웨어를 지칭한다. → Software!
2.2. Computer
CPU의 흐름
내가 만든 C 프로그램이 컴파일 되어 A라는 프로그램으로서 CPU를 차지하고 실행되고 있다면 CPU는 메모리상에서 A 프로그램의 Instruction들을 읽으며 작업을 수행한다. CPU는 메모리에 접근하는 Instruction들만 수행하며, 디스크 읽기 및 쓰기(File I/O), 키보드 입력(scanf), 모니터 출력(printf) 등의 I/O device와 관련된 Instruction들은 device controller로 보낸다. Device controller는 local buffer에서 CPU가 요청한 작업을 수행하고, 그동안 CPU는 다시 메모리에 접근해 I/O 관련 결과가 필요 없는 다음 Intruction들을 수행한다. 만일 A 프로그램에서 반드시 device controller에 보낸 I/O Instruction의 결과가 나와야 작업을 수행할 수 있는 상황이 되면, 먼저 작업을 수행할 수 있는 B 프로그램으로 CPU 자원이 옮겨가게 된다.(= time sharing) 결과적으로, CPU는 프로그램이 종료될 때까지 메모리상의 프로그램들 중 자신이 할 수 있는 Instruction을 끊임없이 찾아 수행하며 여러 프로그램들을 동작하게 한다.
register : CPU 안에 있는 Memory보다 더 빠르면서 정보를 저장할 수 있는 작은 공간
mode bit
사용자 프로그램의 잘못된 수행으로 다른 프로그램 및 운영체제에 피해가 가지 않도록 하기 위한 보호장치이다. CPU에서 현재 실행되고 있는 프로그램이 운영체제인지 사용자 프로그램인지 구분해주는 역할을 수행하며 두 가지 모드를 지원한다.
사용자 모드 [1] : 사용자 프로그램 수행 (Only 일반명령만 수행한다.)
메모리에 접근하는 Instruction들만 허용한다.
사용자 프로그램에게 CPU를 넘기기 전에 mode bit을 1로 세팅한다.
모니터 모드 [0] (= 커널 모드, 시스템 모드) : OS 코드 수행 (특권명령까지 포함해 모든 명령이 수행 가능하다.)
I/O 관련 Instruction 같이 보안을 해칠 수 있는 중요한 명령어를 포함해 모든 Instruction이 수행 가능하다.
Interrupt나 Exception 발생 시 하드웨어가 mode bit을 0으로 바꾼다.
Intrerrupt line : 인터럽트 요청을 담는 공간. CPU는 Interrupt line을 확인하고 요청된 인터럽트를 처리한다.
timer : 특정 프로그램이 CPU를 독점하는 것(ex. 무한루프)을 막기 위해 시간을 바탕으로 제어하는 장치
타이머 값은 매 클럭 틱마다 1씩 감소하고 값이 0이 되면 인터럽트가 발생한다.
time sharing 구현에 널리 이용된다.
현재 시간 계산을 위해서도 사용된다.
흐름
처음 컴퓨터 부팅 시에는 운영체제가 CPU를 가지고 있다. 그 후 특정 사용자 프로그램에 CPU를 넘길 때, timer에 최대 사용 시간(ex. 수십 밀리세컨드의 짧은 시간)을 세팅하고 CPU를 넘긴다. timer는 설정된 시간이 되면 interrupt line을 통해 CPU에 interrupt를 건다. CPU는 할당받은 프로그램의 Instruction를 하나 수행하고 Interrupt line을 체크하는 과정을 반복하며, Interrupt line에서 timer의 interrupt를 발견 시 하던 일을 멈추고 운영체제에게 CPU 제어권을 넘긴다. 그리고 운영체제는 다시 timer에 특정 값을 세팅하고 다른 프로그램에게 CPU를 넘기는 흐름을 반복한다.
운영체제는 다른 프로그램에게 자유롭게 CPU를 넘기지만, 마음대로 다시 뺏어 올 수는 없기 때문에 timer의 도움을 받아 다시 권한을 가져온다. 이외에도 프로그램이 종료될 때나 I/O 관련 Instruction을 만났을 때는 timer에 상관없이 CPU가 자동으로 운영체제에 반납된다. 사용자 프로그램은 각종 보안 이슈 등의 이유로 직접 I/O 장치에 대한 접근을 할 수 없기 때문에, 관련 Instruction을 만나면 운영체제에게 CPU를 넘기고 운영체제는 I/O device controller에 작업을 요청한다. 그 후 I/O device controller에 의해 사용자가 버퍼에 결과물(ex. 키보드 입력 데이터)을 남기고 controller가 다시 CPU에 intrrupt를 걸 때까지, 운영체제는 다른 사용자 프로그램에 CPU를 넘긴다. CPU는 다른 프로그램에서 Instruction들을 수행하다가 Interrupt line에서 device controller의 interrupt를 확인하면 우선은 CPU를 운영체제에 넘긴다. 운영체제는 I/O 요청 작업이 완료됨을 인지하고 그 결과물을 I/O 작업을 요청했던 프로그램의 메모리 공간에 복사해둔다. 그리고 당장은 timer에 남은 시간만큼 방금 수행 중이던 프로그램에게 도로 CPU를 넘긴다. 하지만, 그 후에는 CPU를 다시 반납받아 I/O 작업을 요청했던 프로그램에게 CPU를 돌려준다. 그리고 해당 프로그램은 메모리 공간에 복사되어 있는 I/O 작업 결과물을 사용하여 다음 Instruction들을 수행한다.
DMA controller (Direct Memory Access)
CPU에 너무 많은 인터럽트가 걸리는 것을 방지하기 위해, 로컬 버퍼의 데이터를 메모리에 복사하는 작업을 대신 수행하고 완료된 작업들을 일정량 모아뒀다가 한 번에 CPU에 인터럽트를 걸어 CPU의 동작이 효율적으로 운영되도록 도와주는 기능을 한다.
memory controller : CPU와 DMA controller의 동시 접근을 막고 교통정리를 해주는 일종의 조율기 역할을 한다.
3. 입출력(I/O)의 수행
모든 입출력 명령은 특권 명령이다.
시스템 콜(system call) : 사용자 프로그램이 운영체제에게 보내는 I/O 요청
trap을 사용하여 인터럽트 벡터의 특정 위치로 이동
제어권이 인터럽트 벡터가 가리키는 인터럽트 서비스 루틴으로 이동
올바른 I/O 요청인지 확인 후 수행
작업 완료 후 제어권을 시스템 콜 다음 명령으로 옮김
4. 인터럽트 (주로 하드웨어 인터럽트)
인터럽트 당한 시점의 레지스터와 program counter를 save한 후, CPU 제어권을 인터럽트 처리 루틴(해당 인터럽트를 처리하는 커널 함수)에 넘긴다.
Interrupt(하드웨어 인터럽트): 하드웨어가 발생시킨 인터럽트 ex) I/O device controller의 인터럽트, timer의 인터럽트
Trap(소프트웨어 인터럽트)
Exception : 프로그램이 오류를 범한 경우
System call : 사용자 프로그램이 운영체제의 서비스를 받기 위해 커널 함수를 호출하는 것
인터럽트 관련 용어
인터럽트 벡터 : 해당 인터럽트 처리 루틴의 주소(필요한 함수 주소)를 가지고 있다.
인터럽트 처리 루틴 (= interrupt service routine, 인터럽트 핸들러)
해당 인터럽트를 처리하는 커널 함수 (운영체제 내 코드)
키보드 컨트롤러 인터럽트라면 키보드 버퍼 내용을 메모리로 카피하고 키보드 I/O를 요청했던 프로세스에게는 CPU를 얻을 수 있음을 표시한다. 타이머의 인터럽트라면 CPU를 뺐어서 다른 프로그램에게 전달한다.
현대의 운영체제는 인터럽트에 의해 구동된다! (인터럽트가 없을 때는 항상 사용자 프로그램이 CPU를 점유하므로…)
Reference
운영체제, 이화여대 반효경 교수님
-
1. 운영체제 개요
운영체제(Operating System, OS)란?
하드웨어 바로 위에 설치되어 사용자 및 소프트웨어를 하드웨어와 연결시켜주는 시스템 소프트웨어이다.
협의의 운영체제 : 보통 커널을 지칭한다. 커널은 운영체제의 핵심 부분으로 메모리에 상주한다. 전공자 입장에서 주로 이 의미로 많이 쓰인다.
광의의 운영체제 : 컴퓨터 부팅 시, 커널 및 커널과 함께 실행되는 주변 시스템 유틸리티를 모두 총칭하는 개념이다.
운영체제의 목적
1. 컴퓨터 시스템 자원의 효율적 관리
효율성 : 주어진 하드웨어 자원(CPU, 기억장치, 입출력장치 등)을 활용하여 최대한 성능을 내도록 한다.
ex) 실행 중인 프로그램들에게 짧은 시간 간격으로 CPU를 번갈아 할당하거나 메모리 공간을 적절히 분배하는 것
형평성 : 특정 사용자가 차별받지 않도록 사용자 간의 형평성을 고려하여 자원을 분배한다.
소프트웨어 자원(프로세스, 파일, 메시지)을 관리하거나 사용자 및 운영체제 스스로를 보호하기도 한다.
2. 사용자에게 편리한 컴퓨터 시스템 이용 환경 제공
실제로는 하나의 컴퓨터를 이용하는 여러 사용자들이 마치 자신만의 독자적 컴퓨터에서 프로그램을 실행시키는 듯한 느낌을 받게 한다.
또한, 하드웨어를 직접 다루는 복잡한 역할을 대신해준다.
운영체제의 분류
1. 동시 작업 가능 여부
단일 작업(single tasking) : 한 번에 하나의 작업만 처리한다. ex) MS-DOS
다중 작업(multi tasking) : 동시에 두 개 이상의 작업을 처리한다. ex) UNIX, MS Windows
2. 사용자 수
단일 사용자 ex) MS-DOS, MS Windows
다중 사용자 ex) UNIX, NT server
3. 처리 방식
시분할(time sharing)
여러 작업을 수행할 때, 컴퓨터 처리 능력을 일정한 시간 단위로 분할하여 사용하는 방식이다. 우리가 주로 사용하는 현대적 범용 컴퓨터는 대부분 이 방식을 사용한다. 일괄 처리 방식에 비해 짧은 응답시간을 가지지만 사용자의 수에 따라 처리시간이 달라진다. (0.01초의 처리시간이 사람이 많아질수록 0.1초, 1초와 같이 느려진다.) 이로 인해, Interactive한 속성(컴퓨터에 무언가를 입력하면 바로 화면에 결과가 나오는 방식)을 느낄 수 있으며, 실시간 방식과 달리 처리 시간의 제약이 따로 존재하진 않는다.
실시간(Realtime OS)
정해진 Deadline에 어떠한 작업이 무조건 마무리되어야 하는 실시간 시스템을 위해 만들어진 OS이다. 따라서, 한 치의 오차도 발생해서는 안 되는 공장 제어, 미사일 제어, 반도체 공정 등 특수 목적 시스템에 많이 사용된다.
· Hard realtime system(경성 실시간 시스템) : 시간을 어기면 큰 문제가 생기는 시스템 ex) 공정 파이프라인
· Soft realtime system(연성 실시간 시스템) : 약간의 시간 어김이 허용되는 시스템 ex) 영화 스트리밍
영화 스트리밍, 웹서핑 등에 사용하는 보통의 범용 컴퓨터는 시분할 방식의 OS를 사용하지만 내비게이션 앱이나 블랙박스 영상 촬영 등은 잠깐의 시간 어김도 허용되서는 안 된다. 따라서, 범용 컴퓨터의 OS가 Realtime을 요구하는 Application들을 어떻게 지원해줘야 할 지에 대한 연구도 진행되고 있다.
일괄처리(batch processing)
과거의 컴퓨터 처리 방식 중 하나로 현대에는 익숙지 않은 방식이다. 작업 요청을 일정량 모아서 한꺼번에 처리하는 방식으로 interactive하지 않다. 다음 작업을 위해서는 작업이 완전히 종료될 때까지 기다려야 하는 불편함이 있다.
요즈음의 범용 컴퓨터 OS는 다중 작업, 다중 사용자, 시분할 처리 방식의 속성을 가진다.
다중 작업 관련 용어 정리
아래의 모든 용어는 ‘컴퓨터에서 여러 작업이 동시에 수행되는 것’을 의미한다.
Multitasking
Multiprogramming : 여러 프로그램이 메모리에 올라가 있음을 강조한다.
Time sharing : CPU의 시간을 분할하여 사용하는 것을 강조한다.
Multiprocess : process는 실행 중인 프로그램을 뜻하여, 여러 개의 실행 중인 프로그램을 말한다.
Multiprocessor : 하나의 컴퓨터에 여러 CPU(processor)가 붙어 있음을 뜻한다. (하드웨어적으로 강조)
운영체제의 예
1. 유닉스(UNIX)
초기의 대형 컴퓨터(서버)를 위해 만들어진 운영체제로, multitasking과 다중 사용자가 가능하다. 복잡한 어셈블리어로 유닉스를 만든 것에 한계가 있어, 보다 high level에 해당하는 C언어가 탄생했다. 코드의 대부분이 C언어로 작성된 유닉스는 덕분에 기계어 집합이 전혀 다른 컴퓨터에도 이식하는 것이 쉬워져 높은 이식성을 보였다. (C언어 코드를 단순히 컴파일하면 되었다.) 유닉스는 최소한의 핵심적인 커널 구조만 가지며 메모리를 아꼈고, 복잡한 시스템에 맞게 확장이 용이했다. 또한, ‘공개 Software 정신’의 개념 하에 소스 코드를 공개하며 수많은 유닉스 기반의 OS들을 배출했다. System Ⅴ, FreeBSD(버클리 대학교 제작), SunOS, Solaris, Linux 등의 다양한 버전이 그 예이다. 특히, Linux는 개인용 컴퓨터를 비롯해 여러 환경에서 사용 가능한 특징을 보인다.
2. Microsoft 운영체제
단일 작업, 단일 사용자를 상정하며 시작되었다.
DOS(Disk Operating System) : 단일 사용자용 운영체제이며, 640KB의 적은 메모리는 한계점이다. 이러한 한계가 있는 DOS에 새로운 기능이 계속 추가되며 DOS의 코드는 복잡해지고 누더기(?)가 되었다. 그 이후, DOS 위에서 Windows를 실행시키는 것이 가능해지고 점차 Windows가 독자적인 OS로 독립하였다.
MS Windows : 제작된 다중 작업이 가능한 GUI 기반 운영체제이다. 하드웨어를 연결하면 별도의 사용자 조작이나 프로그램 설치 없이 바로 사용 가능한 Plug and Play 지원(그 당시엔 혁신적이었다.), DOS용 응용 프로그램과의 호환성, 풍부한 지원 소프트웨어 등의 특징이 있다.
3. 이외에도 애플 OS(Macintosh OS→Mac OS), 소형 디바이스(Handleheld device)를 위한 OS(PalmOS, Pocket PC(WinCE), Tiny OS) 등이 존재했고, 점차 iOS 같은 스마트 디바이스(Smart device)를 위한 OS 등 여러 형태의 운영체제로 발전하였다.
운영체제의 Issue
운영체제의 구조
CPU 스케줄링 : 빠른 처리 속도를 가진 CPU지만, 작업들을 어떤 순서로 할당하는 게 가장 효율적일지 고민한다.
메모리 관리 : 한정된 메모리를 어떤 작업들에 많게 혹은 적게 배분하고 제외시킬지에 관한 주제이다.
파일 관리 : 디스크에 파일을 어떻게 보관할지에 관한 주제이다. 디스크 헤드의 효율적인 움직임을 고민한다.
입출력 관리 : 다양한 입출력 장치와 컴퓨터 간의 정보 교환을 어떻게 할지 고민한다. 입출력 장치의 느린 처리속도를 극복하기 위해 빠른 처리 속도를 가진 CPU를 순간적으로 멈추는 Interrupt도 이 주제에서 다룬다.
프로세스 관리 : 컴퓨터 소프트웨어(프로그램)들을 어떻게 관리할지에 대한 주제이다.
보호 시스템, 네트워킹, 명령어해석기(Command Line Interpreter) 등의 주제도 존재한다.
내 스스로가 운영체제가 되었다고 생각하며 공부해보자 :)
Reference
운영체제, 이화여대 반효경 교수님
-
-
-
-
투 포인터 (Two Pointers)
투 포인터 (Two Pointers) 알고리즘
투 포인터(Two Pointers) 알고리즘은 리스트에 순차적으로 접근해야 하는 경우에, 두 개의 점의 위치를 기록하면서 처리하는 방식을 말한다. 시작점과 끝점을 사용해 순차적으로 접근할 데이터의 범위를 표현할 수 있다.
투 포인터를 활용하면 유용한 다음 문제를 살펴보자.
위 문제는 전체 수열이 주어지면, 그 수열의 부분 수열 중 특정한 합을 가지는 연속하는 부분 수열을 찾는 문제이다. 이 문제를 해결하는 가장 심플한 방법은 완전탐색으로 각 인덱스마다 해당 인덱스로 시작하는 부분 연속 수열을 모두 찾아보는 방법이다. 다만, 이 완전탐색은 시간 복잡도가 O(N²)이 걸리므로 비효율적이다.
이 때, 투 포인터 알고리즘을 활용하면 선형 시간 복잡도 O(N)으로 이 문제를 처리할 수 있다. 알고리즘의 과정은 다음과 같다.
진행 과정을 구체적으로 살펴보자.
가장 먼저 시작점과 끝점이 첫 번째 원소의 인덱스를 가리키도록 초기화한다. 또한, 문제에서 원하는 부분합 M은 5이다. 현재 부분합은 1이고 M보다 작으므로 무시한다.
이전 부분합이 M보다 작았기 때문에, end를 1 증가시키고 새로운 부분합을 구한다. 구해진 부분합은 3이므로 무시한다.
이전 부분합이 역시 M보다 작았으므로, end를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 6이므로 무시한다.
이전 부분합이 M보다 컸으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 5이므로 카운트를 센다.
이전 부분합이 M과 같았으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 3이므로 무시한다.
이전 부분합이 M보다 작았으므로, end를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 5이므로 카운트를 센다.
이전 부분합이 M과 같았으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 2이므로 무시한다.
이전 부분합이 M보다 작았으므로, end를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 7이므로 무시한다.
이전 부분합이 M보다 컸으므로, start를 1 증가시키고 새로운 부분합을 구한다. 현재 부분합은 5이므로 카운트를 센다. 그리고 알고리즘은 마무리된다.
이러한 투 포인터 알고리즘을 파이썬으로 구현한 코드는 다음과 같다.
n = 5 # 데이터의 개수 N
m = 5 # 찾고자 하는 부분합 M
data = [1, 2, 3, 2, 5] # 전체 수열
count = 0
interval_sum = 0
end = 0
# start를 차례대로 증가시키며 반복
for start in range(n):
# end를 가능한 만큼 이동시키기
while interval_sum < m and end < n:
interval_sum += data[end]
end += 1
# 부분합이 m일 때 카운트 증가
if interval_sum == m:
count += 1
interval_sum -= data[start]
print(count)
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
소수 판별 알고리즘 - 에라토스테네스의 체
소수 (Prime Number) 판별 알고리즘
소수란 1보다 큰 자연수 중 1과 자기자신을 제외한 자연수로는 나누어떨어지지 않는 자연수를 말한다. 코딩 테스트에서는 어떠한 자연수가 소수인지 아닌지 판별해야 하는 문제가 자주 출제되므로 알고리즘을 기억해두면 좋다.
다음은 기본적인 소수 판별 알고리즘을 파이썬으로 구현한 것이다.
# 소수 판별 함수 정의 (2이상의 자연수에 대하여)
def is_prime_number(x):
# 2부터 (x - 1)까지의 모든 수를 확인하며
for i in range(2, x):
# x가 해당 수로 나누어떨어진다면
if x % i == 0:
return False # 소수가 아님
return True # 소수임
print(is_prime_number(4))
print(is_prime_number(7))
기본적인 소수 판별 알고리즘의 시간 복잡도는 O(N)이다. 2부터 N - 1까지의 모든 자연수에 대하여 차례차례로 연산을 수행하기 때문이다. 다만, 자연수의 범위가 10억과 같이 커진다면 연산 수행에 문제가 생기므로 시간복잡도를 개선할 필요성이 있다.
개선된 소수 판별 알고리즘
약수의 성질에서 시간 복잡도 개선의 단서를 찾을 수 있다. 어떤 한 수에 대한 모든 약수는 가운데 약수를 기준으로 곱셈 연산에 대해 대칭을 이룬다. 예를 들어, 16의 약수 1, 2, 4, 8, 16에서 2 X 8 = 16이고 8 X 2 = 16이다. 즉, 특정한 수에 대한 모든 약수를 찾을 때 가운데 약수(제곱근)까지만 확인하면 충분하다.
다음 코드는 이를 활용하여 소수 판별 알고리즘을 개선한 형태이다.
# 소수 판별 함수 (2이상의 자연수에 대하여)
def is_prime_number(x):
# 2부터 x의 제곱근까지의 모든 수를 확인하며
for i in range(2, int(x ** 0.5) + 1):
# x가 해당 수로 나누어 떨어진다면
if x % i == 0:
return False # 소수가 아님
return True # 소수임
print(is_prime_number(4))
print(is_prime_number(7))
이 경우 특정 수의 제곱근까지만 확인하는 과정이므로, 시간 복잡도는 O(√N)이 된다.(루트 N)
에라토스테네스의 체 알고리즘
지금까지 특정 수에 대하여 소수를 판별하는 과정을 살펴보았다. 더 나아가 만일 특정한 수의 범위가 주어지고 그 범위안의 존재하는 모든 소수를 찾아야 한다면 어떻게 해야할까? 이 상황에서는 다수의 자연수에 대하여 소수 여부를 판별하는 대표적 알고리즘인 에라토스테네스의 체를 사용할 수 있다. 에라토스테네스의 체 알고리즘의 동작 과정은 다음과 같다.
2번 단계에서는 남은 수 중에서 아직 처리하지 않은 가장 작은 소수 i(남은 수가 결국 소수)를 찾고, 3번 단계에서 i를 제외한 그 i의 배수를 모두 제거하는 과정을 반복한다.
다음은 N=26인 상황일 때의 동작과정이다.
에라토스테네스의 체 역시 약수의 성질을 적용할 수 있다. 예를 들어, 위 경우는 26의 대략적인 제곱근인 5까지만 확인하면 된다. 6부터는 배수가 5를 넘어갈 수 없고, 이미 앞에서 소수 2, 3, 5의 배수를 제거했기 때문이다. 따라서, √N까지의 자연수만 확인해도 동일한 결과를 얻을 수 있다.
다음은 에라토스테네스의 체 알고리즘을 파이썬 코드로 구현한 것이다.
n = 1000 # 2부터 1000까지의 모든 수에 대하여 소수 판별
# 처음엔 모든 수를 소수(True)인 것으로 초기화(0, 1은 제외)
array = [True for i in range(n + 1)]
# 에라토스테네스의 체 알고리즘 수행
# 2부터 n의 제곱근까지의 모든 수를 확인하며
for i in range(2, int(n ** 0.5) + 1):
if array[i] == True:
# i를 제외한 i의 모든 배수를 지우기
j = 2
while i * j <= n:
array[i * j] = False
j += 1
# 모든 소수 출력
for i in range(2, n + 1):
if array[i]:
print(i, end=' ')
이러한 에라토스테네스의 체 알고리즘의 시간 복잡도는 O(NloglogN) 으로 선형시간에 가까울 정도로 매우 빠르므로, 다수의 소수를 찾는 문제에서 효율적이다. 다만, 각 자연수에 대한 소수 여부를 저장해야 하기 때문에 메모리가 많이 필요하다는 단점이 있다. 예를 들어, N이 10억인 경우 문제 해결이 어렵다.
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
기타 그래프 이론 - 최소 신장 트리 (MST, Minimum Spanning Tree)
신장 트리(Spanning Tree)란?
신장 트리(Spanning Tree)란 원본 그래프의 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프를 뜻한다. 위의 가운데 그림처럼 간선들이 모든 노드를 잇고 있지만, 사이클은 생기지 않는 부분 그래프가 신장 트리의 예시가 된다. 반면, 오른쪽 그림처럼 모든 노드를 잇지도 않고 사이클마저 생기는 것은 신장 트리에 해당되지 않는다. 이 개념을 트리라고 부르는 이유는 모든 노드가 포함되어 서로 연결되면서 사이클이 존재하지 않는다는 조건이 트리의 조건에 해당하기 때문이다. 이러한 트리의 특성으로 인해, 신장 트리가 가지는 총 간선의 개수는 노드의 개수 - 1이 된다.
최소 신장 트리(MST, Minimum Spanning Tree)
최소 신장 트리(MST, Minimum Spanning Tree)란 최소한의 비용으로 구성되는 신장 트리를 의미한다. 최소 신장 트리의 개념은 여러 문제 상황에서 유용할 수 있는데, 만일 N개의 도시가 있고 두 도시 사이에 도로를 놓아 전체 도시가 서로 연결될 수 있게 하는 경우 최소 신장 트리가 사용된다. 위 그림을 예시로 보면, 3개의 도시가 있는 상황에서 모든 도시를 최소 비용으로 연결하는 방법은 오른쪽 그림과 같다.
크루스칼 알고리즘 (Kruskal Algorithm)
크루스칼 알고리즘(Kruskal Algorithm)은 대표적인 최소 신장 트리 알고리즘들 중 하나이다. 그리디 알고리즘으로 분류되며 동작 과정은 다음과 같다.
요약하자면, 모든 간선을 최소 비용 순으로 하나씩 확인하여 사이클을 생성하지 않는 간선들만 최소 신장 트리에 포함시키는 것이다. 구체적인 예시로 더 살펴보자.
위와 같이 원본 그래프가 주어졌을 때, 먼저 간선을 오름차순으로 정렬하고 작업을 수행한다. 위 그림의 테이블은 가독성을 위주로 간선 정보가 나열되어 있기 때문에 혼돈하지 않도록 하자.
처음으로 가장 최소인 비용을 가지는 3, 4번 노드를 잇는 간선을 확인한다. 두 노드는 다른 집합에 속해 있어 사이클 생성이 불가능하므로 Union 함수를 호출해 같은 집합으로 만들어 최소 신장 트리에 포함한다.
다음으로 다음 최소 비용에 해당하는 4, 7번 노드를 잇는 간선을 확인한다. 두 노드 역시 다른 집합에 속해 사이클을 생성하지 않으므로, Union 함수로 최소 신장 트리에 포함한다.
다음 최소 비용에 해당하는 4, 6번 노드를 잇는 간선도 두 노드가 다른 집합에 속해 있으므로 Union 함수를 호출해 최소 신장 트리에 포함시킨다.
다음 최소 비용에 해당하는 6, 7번 노드를 잇는 간선을 확인한다. 6번과 7번 노드의 경우 같은 집합에 속해 있기 때문에, 사이클을 발생시킨다. 따라서, 최소 신장 트리에 해당 간선을 포함시키지 않고 무시한다.
다음 최소 비용인 1번과 2번 노드를 잇는 간선을 확인한다. 두 노드는 다른 집합에 속하므로 Union 함수를 호출하여 같은 집합으로 합쳐 최소 신장 트리에 포함한다.
다음 최소 비용에 해당하는 2번 6번 노드를 연결하는 간선도 서로 다른 집합에 속하므로 최소 신장트리에 포함시킨다.
다음 최소 비용에 해당하는 2번 노드와 3번 노드를 연결하는 간선은 두 노드가 같은 집합에 속하므로 무시한다.
다음 최소 비용에 해당하는 5번과 6번 노드를 잇는 간선은 두 노드가 서로 다른 집합에 속하므로, Union 함수를 호출하여 최소 신장트리에 포함시킨다.
마지막으로 1번과 5번 노드를 잇는 간선은 두 노드가 서로 같은 집합에 속해 있으므로 무시하도록 한다.
연산을 모두 수행하면 최종적으로 위와 같은 최소 신장 트리가 나온다. 이 최소 신장 트리의 모든 간선의 비용을 합하면, 해당 값이 최종 비용이 된다.
위의 과정을 파이썬 코드로 구현하면 다음과 같다.
# input
# 7 9
# 1 2 29
# 1 5 75
# 2 3 35
# 2 6 34
# 3 4 7
# 4 6 23
# 4 7 13
# 5 6 53
# 6 7 25
# 특정 원소가 속한 집합을 찾기 (Find 연산)
def find_parent(parent, x):
# 루트 노드를 찾을 때까지 재귀 호출
if parent[x] != x:
parent[x] = find_parent(parent, parent[x])
return parent[x]
# 두 원소가 속한 집합을 합치기 (Union 연산)
def union_parent(parent, a, b):
a = find_parent(parent, a)
b = find_parent(parent, b)
if a < b:
parent[b] = a
else:
parent[a] = b
# 노드의 개수와 간선(Union 연산)의 개수 입력 받기
v, e = map(int, input().split())
parent = [0] * (v + 1) # 부모 테이블 초기화하기
# 모든 간선을 담을 리스트와 최종 비용을 담을 변수
edges = []
result = 0
# 부모 테이블에서, 부모를 자기 자신으로 초기화
for i in range(1, v + 1):
parent[i] = i
# 모든 간선에 대한 정보를 입력 받기
for _ in range(e):
a, b, cost = map(int, input().split())
# 비용순으로 정렬하기 위해서 튜플의 첫 번째 원소를 비용으로 설정
edges.append((cost, a, b))
# 간선을 비용순으로 정렬
edges.sort()
# 간선을 하나씩 확인하며
for edge in edges:
cost, a, b = edge
# 사이클이 발생하지 않는 경우에만 집합에 포함
if find_parent(parent, a) != find_parent(parent, b):
union_parent(parent, a, b)
result += cost
print(result)
크루스칼 알고리즘의 시간 복잡도는 Elog(E)이다. 이와 같은 시간복잡도를 가지는 이유는 크루스칼 알고리즘에서 가장 시간이 오래 걸리는 부분이 정렬을 수행하는 작업이며, E개의 간선을 정렬하기 때문이다. 내부에서 이뤄지는 서로소 집합 알고리즘의 시간 복잡도는 정렬 알고리즘의 시간 복잡도보다 작기 때문에 무시한다.
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
기타 그래프 이론 - 서로소 집합 (Disjoint Sets)
서로소 집합 (Disjoint Sets)
서로소 집합이란 공통 원소가 없는 두 집합을 의미한다. 예를 들어, {1, 2}, {3, 4}는 서로소 관계이지만, {1, 2}, {2, 3}은 2라는 공통된 원소가 존재하므로 서로소 관계가 아니다.
서로소 집합 자료구조 (Union Find 자료구조)
서로소 집합 자료구조(Union Find 자료구조)는 서로소 부분 집합들로 나누어진 원소들의 데이터를 처리하기 위한 자료구조이다. 서로소 집합 자료구조에는 두 가지 연산이 존재하는데, 두 개의 원소가 포함된 집합을 하나의 집합으로 합치는 합집합(Union) 연산과 특정한 원소가 속한 집합이 어떤 집합인지 알려주는 찾기(Find) 연산이 그것이다.
서로소 집합 자료구조의 동작 과정
1. 기본 동작 과정 (합치기 연산이 여러 개 주어졌을 경우)
합치기 연산이 여러 개 주어졌을 경우, 위와 같은 동작 과정을 거쳐 작업을 수행한다. 이를 구체적으로 살펴보자.
위와 같이 4개의 Union 연산이 주어졌을 상황을 가정해보자. 먼저 노드 개수만큼의 크기를 가지는 부모 노드를 표현하는 테이블을 생성하고, 테이블 내 각 노드의 부모노드를 자기자신으로 초기화한다.
테이블 생성 및 초기화가 끝나면, 첫 번째로 Union(1, 4) 연산을 처리한다. 이를 처리하기 위해 Union 연산의 인자 값으로 주어진 노드 1과 노드 4의 루트 노드를 찾는다. 여기서는 각자 자기자신이 루트 노드에 해당하므로, 1과 4 중 더 큰 번호에 해당하는 노드 4의 부모노드를 1번 노드로 설정한다. 일반적으로, 큰 번호 노드를 작은 번호 노드의 자식 노드로 설정하는 것이 관행이 있어서 이 규칙을 따라 예시를 진행하겠다.
Union(1, 4) 연산이 끝나면, Union(2, 3) 연산을 진행한다. 노드 2와 노드 3에 대하여 루트 노드를 찾는데, 이번에도 자기자신이 루트 노드이고 3이 더 큰 번호 노드이므로 3번 노드의 부모 노드를 2번 노드로 설정한다.
다음으로 Union(2, 4) 연산을 위와 같은 방식으로 또 진행한다. 2번 노드의 루트 노드는 자기 자신이고, 4번 노드의 루트 노드는 1번 노드이다. 2번 노드가 1번 노드보다 큰 번호이므로, 1번 노드를 2번 노드의 부모 노드로 설정한다.
마지막으로 Union(5, 6) 연산을 똑같은 방법으로 수행한다. 각각의 노드의 루트 노드는 자기자신이고 6번 노드가 더 큰 번호이므로, 5번 노드는 6번 노드의 부모 노드로 설정된다.
이와 같은 서로소 집합 자료구조는 각 집합들간의 연결성을 통해 총 몇 개의 집합이 존재하는지를 손쉽게 확인할 수 있다는 장점이 있다. 위의 1, 2, 3, 4번 노드들은 하나의 루트 노드를 가지며 트리 구조 형태를 띈다. 이런 경우 1, 2, 3, 4번 노드들은 원소가 4개인 하나의 집합으로 파악할 수 있다. 또한 5, 6번 노드도 원소가 2개인 또 다른 집합으로서 존재한다. 결론적으로, 위 그래프에서는 총 2개의 집합(1, 2, 3, 4번 노드 집합과 5, 6번 노드 집합)이 존재하고, 그 2개의 집합은 서로소 관계를 가진다.
다만, 기본적인 형태의 서로소 집합 자료구조에서는 루트 노드에 즉시 접근할 수 없다는 단점도 동시에 가지고 있다. 루트 노드를 찾기 위해서는 부모 테이블에서 해당 노드의 부모 노드를 계속 확인하며 거슬러 올라가야만 한다.
위의 과정을 파이썬 코드로 구현하면 다음과 같다.
# input
# 6 4
# 1 4
# 2 3
# 2 4
# 5 6
# 특정 원소가 속한 집합을 찾기 (Find 연산)
def find_parent(parent, x):
# 루트 노드를 찾을 때까지 재귀 호출
if parent[x] != x:
return find_parent(parent, parent[x])
return x
# 두 원소가 속한 집합을 합치기 (Union 연산)
def union_parent(parent, a, b):
a = find_parent(parent, a)
b = find_parent(parent, b)
if a < b:
parent[b] = a
else:
parent[a] = b
# 노드의 개수와 간선(Union 연산)의 개수 입력 받기
v, e = map(int, input().split())
parent = [0] * (v + 1) # 부모 테이블 초기화하기
# 부모 테이블에서, 부모를 자기 자신으로 초기화
for i in range(1, v + 1):
parent[i] = i
# Union 연산을 각각 수행
for i in range(e):
a, b = map(int, input().split())
union_parent(parent, a, b)
# 각 원소가 속한 집합 출력하기
print('각 원소가 속한 집합: ', end='')
for i in range(1, v + 1):
print(find_parent(parent, i), end=' ')
print()
# 부모 테이블 내용 출력하기
print('부모 테이블: ', end='')
for i in range(1, v + 1):
print(parent[i], end=' ')
2. 기본 구현 방법의 개선
위의 기본적인 Union Find 구현 방법은 수행 시간 면에서 문제점이 있다. 합집합(Union) 연산이 편향되게 이루어지는 경우 찾기(Find) 함수가 비효율적으로 동작한다는 점이다.
위는 최악의 경우를 가정한 예시다. 위와 같이 Union 연산이 편향적으로 수행되면, 5번 노드에 대해서 찾기(Find) 함수를 수행할 시 모든 노드를 다 확인하여 1번 노드를 루트 노드로 반환하는 비효율적인 동작을 보인다. 이 때, 시간 복잡도는 O(V)다.
따라서 Find 함수를 개선하기 위해 경로 압축(Path Compression) 기법을 사용한다. 다음은 경로 압축 기법을 구현한 파이썬 코드인데, 이는 기본적인 Find 함수에 약간의 변형만으로 구현된다.
# 특정 원소가 속한 집합을 찾기
def find_parent(parent, x):
# 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출
if parent[x] != x:
parent[x] = find_parent(parent, parent[x])
return parent[x]
경로 압축 기법을 적용하면, 각 노드에 대하여 Find 함수를 호출한 이후에 해당 노드의 루트 노드가 바로 부모 노드가 된다. 위의 파이썬 코드를 사용하면 같은 예시에 대하여 위 그래프와 같이 모드 노드들이 자신의 루트 노드를 부모 노드로 가지는 결과를 보여준다. 시간 복잡도도 개선되는 모습을 보인다.
서로소 집합을 활용한 사이클 판별
서로소 집합은 무방향 그래프에서 사이클을 판별할 때 사용 가능하다. (방향이 있는 그래프에서는 DFS를 사용한다.) 서로소 집합을 사용한 사이클 판별 알고리즘의 과정은 다음과 같다.
이를 더 구체적으로 살펴보자.
처음에는 기존 서로소 집합 자료구조 구현과 같은 초기화 과정을 거친다. 각 노드에 대하여 부모 노드를 자기자신으로 설정한다.
그 다음, 1번 노드와 2번 노드를 연결하는 간선을 확인하여, 어떤 노드가 부모노드가 될 지 판단한다. 1번과 2번 노드의 부모 노드는 각자 자기자신이므로, 더 큰 번호에 해당하는 2번 노드의 부모 노드를 1번 노드로 설정한다.
다음은 1번 노드와 3번 노드를 잇는 간선을 확인한다. 1번 노드와 3번 노드도 각각의 부모 노드가 자기 자신이므로, 더 큰 번호에 해당하는 3번 노드의 부모 노드를 1번 노드로 설정한다.
끝으로 2번 노드와 3번 노드 사이의 간선을 확인한다. 2번 노드와 3번 노드 각각의 루트 노드는 1번 노드이므로, 이미 같은 집합에 속해 있음을 알고 사이클이 발생함을 파악할 수 있다.
서로소 집합을 사용한 사이클 판별 알고리즘의 파이썬 구현은 다음 코드와 같다.
# input
# 3 3
# 1 2
# 1 3
# 2 3
# 특정 원소가 속한 집합을 찾기 (Find 연산)
def find_parent(parent, x):
# 루트 노드를 찾을 때까지 재귀 호출
if parent[x] != x:
parent[x] = find_parent(parent, parent[x])
return parent[x]
# 두 원소가 속한 집합을 합치기 (Union 연산)
def union_parent(parent, a, b):
a = find_parent(parent, a)
b = find_parent(parent, b)
if a < b:
parent[b] = a
else:
parent[a] = b
# 노드의 개수와 간선(Union 연산)의 개수 입력 받기
v, e = map(int, input().split())
parent = [0] * (v + 1) # 부모 테이블 초기화하기
# 부모 테이블에서, 부모를 자기 자신으로 초기화
for i in range(1, v + 1):
parent[i] = i
cycle = False # 사이클 발생 여부
for i in range(e):
a, b = map(int, input().split())
# 사이클이 발생한 경우 종료
if find_parent(parent, a) == find_parent(parent, b):
cycle = True
break
# 사이클이 발생하지 않았다면 합집합(Union) 연산 수행
else:
union_parent(parent, a, b)
if cycle:
print("사이클이 발생했습니다.")
else:
print("사이클이 발생하지 않았습니다.")
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
최단 경로 (Shortest Path) - 벨만 포드 (Bellman-Ford)
벨만 포드 알고리즘 (Bellman-Ford Algorithm)
1. 벨만 포드 알고리즘 개요
벨만 포드 알고리즘(Bellman-Ford Algorithm)은 다익스트라 알고리즘과 거의 유사하다. 다만, 다익스트라 알고리즘과 달리 벨만 포드 알고리즘은 음의 값을 가지는 간선을 포함하여 알고리즘을 수행할 수 있다는 점이 큰 차이점이다.
위와 같이 5번 노드에서 2번 노드로 가는 간선의 비용이 -2인 그래프가 있다. 이 경우, 음의 간선이 존재하지만 얼마든지 오른쪽 테이블처럼 최소 비용을 계산해낼 수 있다.
그러나 음의 간선의 순환이 포함되어 있는 경우 최소 비용을 계산하는데 어려움이 생길 수 있다. 위 그래프는 음의 간선 비용이 -4인데 이 값이 상당히 작기 때문에 ‘3번 노드 -> 5번 노드 -> 2번 노드’ 순으로 순환을 계속하는 것이 최소 비용을 구하는 과정이 되어버리고, 결국 비용을 마이너스 무한대로 무한히 줄이게 되는 상황을 확인할 수 있다. 이러한 상황을 타개하기 위해서는 벨만포드 알고리즘을 적용해야 한다.
일반적으로 최단 경로 문제는 다음과 같은 상황이 존재한다.
모든 간선이 양수인 경우
음수 간선이 포함된 경우
음수 간선의 순환이 있는 경우
음수 간선의 순환이 없는 경우
벨만 포드 알고리즘의 경우 음의 간선이 포함된 상황에서도 사용할 수 있고, 음수 간선의 순환을 감지할 수 있는 덕분에 음의 간선이 포함된 상황에서 최단 경로를 구할 때는 벨만 포드 알고리즘을 사용한다. 다만, 벨만 포드 알고리즘의 시간 복잡도는 O(VE) 로 다익스트라 알고리즘보다 느리기 때문에, 음의 간선이 포함되지 않은 상황이라면 다익스트라 알고리즘을 적용하는 것이 바람직하다.
2. 벨만 포드 알고리즘의 동작 과정
벨만 포드 알고리즘의 동작 과정은 다음과 같다.
전체적인 로직은 다익스트라 알고리즘과 유사하다. 여기서 두 가지 주목해볼 점이 있는데 먼저, 음수 간선의 순환을 체크하는 부분이다. 벨만 포드 알고리즘은 마지막에 3번 과정을 한 번 더 수행하므로써 음수 간선의 순환이 존재하는지 여부를 확인한다. 만일, 이 과정에서 테이블이 또 갱신된다면, 음수 간선의 순환이 존재한다고 판단한다.
또한, 3번의 과정을 N - 1번만큼 수행하는 부분에서 벨만 포드 알고리즘은 전체 간선을 모두 확인하는 반면, 다익스트라 알고리즘은 확인한 노드에 붙어 있는 간선만 체크한다는 점은 다르다. 여기서 알 수 있는 것은 벨만 포드 알고리즘이 다익스트라 알고리즘의 최적의 해(Optimal Solution)를 항상 포함한다는 것이다. 즉, 다익스트라는 간선의 비용이 양수인 상황에서만 적용 가능하지만, 벨만 포드 알고리즘은 다익스트라 알고리즘의 최적의 해를 보장하므로 시간은 오래걸리더라도 모든 상황에 적용 가능하다.
벨만 포드의 파이썬 구현 코드는 다음과 같다.
import sys
input = sys.stdin.readline
INF = int(1e9) # 무한을 의미하는 값으로 10억 설정
def bf(start):
# 시작 노드에 대해서 초기화
dist[start] = 0
# 전체 n번의 라운드(round)를 반복
for i in range(n):
# 매 반복마다 "모든 간선"을 확인
for j in range(m):
cur = edges[j][0]
next_node = edges[j][1]
cost = edges[j][2]
# 현재 간선을 거쳐서 다른 노드로 이동하는 거리가 더 짧은 경우
if dist[cur] != INF and dist[next_node] > dist[cur] + cost:
dist[next_node] = dist[cur] + cost
# n번째 라운드에서도 값이 갱신된다면 음수 순환이 존재
if i == n - 1:
return True
return False
# 노드의 개수, 간선의 개수를 입력받기
n, m = map(int, input().split())
# 모든 간선에 대한 정보를 담는 리스트 만들기
edges = []
# 최단 거리 테이블을 모두 무한으로 초기화
dist = [INF] * (n + 1)
# 모든 간선 정보를 입력받기
for _ in range(m):
a, b, c = map(int, input().split())
edges.append((a, b, c))
# 벨만 포드 알고리즘을 수행
negative_cycle = bf(1)
if negative_cycle:
print(-1)
else:
# 1번 노드를 제외한 다른 모든 노드로 가기 위한 최단 거리 출력
for i in range(2, n + 1):
# 도달할 수 없는 경우, -1을 출력
if dist[i] == INF:
print(-1)
# 도달할 수 있는 경우, 최단 거리 출력
else:
print(dist[i])
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
최단 경로 (Shortest Path) - 플로이드 워셜 (Floyd-Warshall)
플로이드 워셜 알고리즘 (Floyd-Warshall Algorithm)
1. 플로이드 워셜 알고리즘 개요
플로이드 워셜 알고리즘(Floyd-Warshall Algorithm)은 최단 경로를 구하는 또 하나의 대표적 알고리즘이다. 다만, 다익스트라 알고리즘이 ‘한 지점에서 다른 특정 지점까지의 최단 경로를 구하는 경우’에 사용한다면, 플로이드 워셜 알고리즘은 ‘모든 지점에서 다른 모든 지점까지의 최단 경로를 모두 구하는 경우’에 사용한다.
플로이드 워셜은 다익스트라처럼 단계별로 거쳐가는 노드를 기준으로 알고리즘을 수행하지만 매 단계마다 방문하지 않은 노드 중에 최단 거리를 갖는 노드를 찾는 과정이 없다. 그리고 2차원 테이블에 모든 노드의 최단 거리 정보를 저장하며, 이를 점화식을 통해 갱신한다는 점에서 다이나믹 프로그래밍 유형에 속한다. 구현하는 것은 다익스트라 알고리즘에 비해 쉽지만, 시간복잡도가 O(N³)이므로 노드와 간선의 개수가 적은 상황에서만 사용할 수 있다.
플로이드 워셜 알고리즘의 점화식은 위와 같다. 각 단계마다 특정한 노드 k를 거쳐가는 경우를 확인하여, a에서 b로 가는 최단 거리보다 a에서 k를 거쳐 b로 가는 거리가 더 짧은지 검사한다. 그리고 둘 중 짧은 거리를 최단 거리로 갱신한다.
2. 플로이드 워셜 알고리즘 동작 과정
처음엔 각 노드마다 인접한 노드들과의 거리를 확인하여 최단 거리 테이블에 기록한다. 이 때, 최단 거리 테이블의 행은 출발 노드를, 열을 도착 노드를 의미한다.
그 이후 이중 반복문을 이용하여 모든 노드들에 대하여 1번 노드를 거쳐가는(k=1) 경우를 고려해 점화식을 수행한다. 1번 노드를 거쳐가는 케이스이므로 출발, 도착 노드가 1번 노드인 행, 열과 자기 자신으로 향하는 오른쪽 아래 방향 대각선 값들은 이번 단계 알고리즘 수행의 영향을 받지 않는다. k = 1인 단계에서 알고리즘을 수행하면 총 6가지 값이 갱신 대상이 되고 실제로 변경되는 값은 D24, D32이다.
k=2인 단계에서도 k=1인 단계와 마찬가지로 총 6개의 갱신 대상이 존재한다. 이번 단계에서는 실제로 D13만 변경된다.
k = 3 단계도 위 과정과 동일하며, D41, D42의 값이 실제로 변경된다.
끝으로 k = 4인 단계도 마찬가지의 과정을 수행하며, D13만 실제로 값이 변경하는 것을 끝으로 알고리즘이 종료된다.
이러한 알고리즘 수행은 삼중 반복문(k, 행, 열)을 통해 구현이 가능하다.
# input
# 4
# 7
# 1 2 4
# 1 4 6
# 2 1 3
# 2 3 7
# 3 1 5
# 3 4 4
# 4 3 2
# output
# 0 4 8 6
# 3 0 7 9
# 5 9 0 4
# 7 11 2 0
INF = int(1e9) # 무한을 의미하는 값으로 10억 설정
# 노드 및 간선의 개수 입력 받기
n = int(input())
m = int(input())
# 2차원 리스트(인접 행렬 방식)를 생성하고 무한으로 초기화
graph = [[INF] * (n + 1) for _ in range(n + 1)]
# 자기 자신으로 가는 비용은 0으로 초기화
for i in range(1, n + 1):
for j in range(1, n + 1):
if i == j:
graph[i][j] = 0
# 각 간선에 대한 정보를 입력 받아 테이블을 초기화
for _ in range(m):
# A에서 B로 가는 비용은 C라고 설정
a, b, c = map(int, input().split())
graph[a][b] = c
# 점화식에 따라 플로이드 워셜 알고리즘을 수행
for k in range(1, n + 1):
for i in range(1, n + 1):
for j in range(1, n + 1):
graph[i][j] = min(graph[i][j], graph[i][k] + graph[k][j])
# 수행된 결과를 출력
for i in range(1, n + 1):
for j in range(1, n + 1):
# 도달할 수 없는 경우, 무한(INFINITY)으로 출력
if graph[i][j] == INF:
print("INFINITY", end=' ')
# 도달할 수 있는 경우, 거리를 출력
else:
print(graph[i][j], end=' ')
print()
이렇게 구현한 플로이드 워셜 알고리즘은 거쳐가는 노드 k와 테이블 전체를 완전 탐색하는 연산을 고려하여 O(N³)의 시간 복잡도를 가진다. 따라서, 보통 최대 500개 이하의 노드라면 플로이드 워셜 알고리즘 수행이 가능하다고 판단할 수 있다. 500개라 하더라도 500 X 500 X 500은 1억을 넘어가므로 유의할 필요가 있다.
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
최단 경로 (Shortest Path) - 다익스트라 (Dijkstra Algorithm)
최단 경로 알고리즘 (Shortest Path Algorithm)
최단 경로 알고리즘(Shortest Path Algorithm)이란 가장 짧은 경로를 찾는 알고리즘을 말한다. 최단 경로를 찾는 것은 여러가지 상황이 존재할 수 있는데, 대표적으로 (1) 한 지점에서 다른 한 지점까지의 최단 경로를 찾는 상황 (2) 한 지점에서 다른 모든 지점까지의 최단 경로를 찾는 상황 (3) 모든 지점에서 다른 모든 지점까지의 최단 경로를 찾는 상황을 생각해 볼 수 있다.
최단 경로 알고리즘은 일반적으로 그래프 자료구조를 기반으로 해 진행된다. 각 지점은 노드(Node)로 나타내고, 지점 사이를 연결하는 도로는 간선(Edge)으로 표현한다. 예를 들어, 노드는 도시, 마을, 국가 등으로, 간선은 도로, 통로 등으로 표현할 수 있다.
다익스트라 알고리즘 (Dijkstra Algorithm)
1. 다익스트라 알고리즘 개요
다익스트라 알고리즘(Dijkstra Algorithm)은 대표적인 최단 경로 알고리즘 중 하나이다. 에츠허르 다익스트라(Edsger Wybe Dijkstra)가 고안한 알고리즘이어서 알고리즘 명에 창시자 이름을 그대로 사용했다.
다익스트라 알고리즘은 특정한 노드에서 출발해 다른 모든 노드로 가는 최단 경로를 계산한다. 이 때, 알고리즘에 사용되는 그래프는 음의 간선이 없어야 올바른 결과를 얻을 수 있다. 이러한 특징은 다익스트라 알고리즘이 인공위성 같은 실제 GPS 소프트웨어에서 기본 알고리즘으로 채택되는 이유 중 하나이다. 보통 현실 세계의 도로는 음의 간선으로 표현되지 않기 때문에, 다익스트라는 실제 세계에서 실용적으로 사용되기에 적합한 알고리즘이다.
또한, 이 알고리즘은 매 상황에서 가장 비용이 적은 노드를 선택하는 과정을 반복한다는 점에서 그리디 알고리즘으로 분류될 수 있다. 다만, 어떤 지점에서 다른 지점을 경유하여 특정 지점으로 가는 최단 경로는 경유한 지점을 중심으로 하는 또 다른 최단 경로들로 분할할 수 있다는 점에서, 최단 경로 알고리즘은 다이나믹 프로그래밍을 기반으로 한다고도 말할 수 있다.
다익스트라 알고리즘의 동작 과정은 다음과 같다.
1. 출발 노드를 설정한다.
2. 최단 거리 테이블을 초기화한다. (자기자신으로 향하는 비용은 0, 다른 모든 노드로 향하는 비용은 무한(inf)로 설정)
3. 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택한다.
4. 해당 노드를 거쳐 다른 노드로 가는 비용을 계산하여 최단 거리 테이블을 갱신한다.
5. 3번과 4번 과정을 반복한다.
특히, 3번을 통해 매 순간 변하지 않는 최단 경로를 정할 수 있다는 점은 다익스트라 알고리즘이 그리디 알고리즘으로 잘 동작하게 되는 근거가 된다. 그런데, 다익스트라 최단 경로 알고리즘을 동작시키면 각 노드에 대한 최단 경로가 아닌 단순한 최단 거리를 결과로 얻게 된다. 이름은 최단 경로 알고리즘이지만 최단 거리를 넘어서 진정한 최단 경로까지 출력하기 위해서는 별도의 로직을 추가할 필요가 있다. 다만, 코딩테스트 수준에서는 모든 노드에 대한 최단 거리 테이블만 구해도 충분히 문제를 해결할 수 있으므로, 이 글에서는 최단 거리를 구하는 것에 대해서만 살펴보기로 한다.
2. 다익스트라 알고리즘 동작 과정
다익스트라 알고리즘에서 최단 거리 테이블은 각 노드에 대해서 현재까지의 최단 거리 정보를 가지고 있다. 그리고 알고리즘 수행 과정에서 이미 기록된 최단 거리 정보보다 더 짧은 경로를 찾게 되면, 찾은 경로의 거리를 최단 거리 테이블에 갱신한다. 위 그림을 살펴보면, 현재까지 A로 가는 최단 거리가 8로 기록되어 있지만, 다음 탐색에서 B를 경유해 A로 가는 경로가 더 짧음을 확인했다면 해당 경로의 거리인 7을 최단 거리 테이블에 갱신해준다.
(1) 간단한 구현
위 그림과 같이 간선에 방향성이 있는 그래프를 사용해 다익스트라 알고리즘의 더 구체적인 수행 과정을 살펴보자. 먼저, 출발 노드는 임의로 1번 노드로 설정하고 최단 거리 테이블을 초기화한다. 최단 거리 테이블에서 1번은 자기자신으로 향하므로 값을 0으로 설정하고 나머지 모든 노드는 무한 값을 설정해 진행한다.
다음으로, 방문하지 않은 노드 중에서 최단 거리가 가장 짧은 노드를 선택한다. 여기서는 1번 노드의 최단거리가 가장 짧으므로 1번 노드를 선택하여 방문한다. 그리고 해당 노드를 거쳐 다른 노드로 가는 비용을 계산해 최단 거리 테이블에 갱신한다. 이 경우, 1번 노드에 인접한 노드는 2, 3, 4번 노드이고 각각에 대하여 비용을 계산한 값은 0+2, 0+5, 0+1가 되는데, 이는 모두 각 노드의 현재 값인 무한대보다 작으므로 최단 거리 테이블 속 해당 노드들에 이 값들을 기록한다.
그리고 다시 앞 과정을 반복한다. 이번엔 아직 방문하지 않은 노드들 중 가장 최단 거리가 짧은 노드가 4번 노드이므로 이 노드를 선택해 방문한다. 4번 노드에 인접한 노드를 확인해보면 3, 5번 노드가 있다. 3번 노드의 경우 새로 찾은 경로의 비용이 1+3이고 이는 현재 값 5보다 작으므로 최단 거리 테이블의 3번 노드 정보를 갱신한다. 5번 노드의 경우도 새로 찾은 경로의 비용이 1+1이고 이는 현재 값 무한대보다 작으므로 테이블의 정보를 갱신한다.
이번엔 방문하지 않은 노드들 중 최단 거리가 가장 짧은 노드로 2, 5번 노드 두 개가 있다. 일반적으로 숫자가 더 작은 노드를 먼저 처리하는 경향이 있어 여기선 2번 노드를 먼저 처리하기로 한다. 방문한 2번 노드에는 3, 4번 노드가 인접해 있다. 3번 노드의 경우 새로 찾은 경로의 비용이 2+3인데 현재 값이 4이므로 테이블을 갱신하지 않고 넘어간다. 4번 노드 역시 새로운 경로의 비용이 2+2인데 현재 값이 1이므로 테이블을 갱신하지 않는다. 그런데 사실 4번 노드는 이미 방문한 노드이기 때문에 비용의 대소 비교없이 갱신을 무시하고 넘어가는 방법을 사용할 수 있다. 이미 방문한 노드는 그 노드까지 가는 최단 경로가 확실히 정해진 것이어서 변동의 여지가 없기 때문이다.
다음 단계에서는 방문하지 않은 노드 중 5번 노드의 최단 거리가 가장 짧으므로 5번 노드를 방문한다. 5번 노드에 인접한 노드로는 3, 6번 노드가 있는데, 3번노드의 경우 새로 찾은 경로의 비용 2+1이 현재 값 4보다 작으므로 테이블을 갱신하고 6번의 경우도 새로 찾은 경로의 비용이 2+2여서 현재 값 무한대보다 작으므로 테이블을 갱신한다.
이번엔 방문하지 않은 노드 중 최단 거리가 가장 짧은 3번 노드를 방문해 처리한다. 3번 노드의 인접 노드로는 2, 6번 노드가 있다. 2번 노드는 새로 찾은 경로의 비용이 3+3이어서 현재 값 2보다 크고, 이미 방문한 노드여서 최단거리가 확정되어 있으므로 테이블을 갱신할 필요가 없다. 6번 노드도 새로 찾은 경로의 비용 3+5가 현재 값 4보다 크므로 역시 테이블을 갱신하지 않는다.
마지막으로, 방문하지 않은 마지막 6번 노드에 대해 처리한다. 사실, 다익스트라 알고리즘에서는 마지막 남은 하나의 노드에 대해서 처리할 필요가 없다. 이미 다른 모든 노드들에 대해 최단 거리가 확정되어서 기존 과정을 수행할 필요성이 사라지기 때문이다. 심지어 위 그래프의 6번 노드는 인접한 노드도 없어서 알고리즘 과정을 수행하지 않는다. 따라서, 별도의 과정 수행 없이 알고리즘이 종료된다.
다익스트라 알고리즘의 간단한 코드 구현은 아래와 같다.
# input
# 6 11
# 1
# 1 2 2
# 1 3 5
# 1 4 1
# 2 3 3
# 2 4 2
# 3 2 3
# 3 6 5
# 4 3 3
# 4 5 1
# 5 3 1
# 5 6 2
# output
# 0
# 2
# 3
# 1
# 2
# 4
import sys
input = sys.stdin.readline
INF = int(1e9) # 무한을 의미하는 값으로 10억을 설정
# 노드의 개수, 간선의 개수를 입력 받기
n, m = map(int, input().split())
# 시작 노드 번호 입력 받기
start = int(input())
# 각 노드에 인접한 노드 정보를 담는 리스트 만들기
graph = [[] for _ in range(n + 1)]
# 방문 여부를 체크하는 리스트 만들기
visited = [False] * (n + 1)
# 최단 거리 테이블 값을 무한으로 초기화
distance = [INF] * (n + 1)
# 모든 간선 정보를 입력 받기
for _ in range(m):
a, b, c = map(int, input().split())
graph[a].append((b, c)) # a번 노드에서 b번 노드로 가는 비용이 c
# 방문하지 않은 노드 중에서, 가장 최단 거리가 짧은 노드의 번호를 반환
def get_smallest_node():
min_value = INF
index = 0
for i in range(1, n + 1):
if distance[i] < min_value and not visited[i]:
min_value = distance[i]
index = i
return index
def dijkstra(start):
# 시작 노드에 대해 초기화
distance[start] = 0
visited[start] = True
for j in graph[start]:
distance[j[0]] = j[1]
# 시작 노드를 제외한 n - 1개의 노드에 대해 반복
for i in range(n - 1):
# 현재 최단 거리가 가장 짧은 노드를 방문
now = get_smallest_node()
visited[now] = True
# 현재 노드와 인접한 다른 노드들 확인
for j in graph[now]:
cost = distance[now] + j[1]
# 현재 노드를 경유해 다른 노드로 이동하는 거리가 더 짧은 경우
if cost < distance[j[0]]:
distance[j[0]] = cost
# 다익스트라 알고리즘 수행
dijkstra(start)
# 모든 노드에 대해 최단 거리 출력
for i in range(1, n + 1):
# 도달할 수 없는 경우 무한(INFINITY)으로 출력
if distance[i] == INF:
print("INFINITY")
# 도달할 수 있는 경우 거리를 출력
else:
print(distance[i])
위와 같은 간단한 다익스트라 알고리즘 구현은 시간 복잡도가 O(V²)이다. 최단 거리가 가장 짧은 노드를 찾는 선형 탐색을 O(V)번 해야하고, 찾은 노드에 인접한 노드를 매번 확인해야 하기 때문이다. 따라서, 전체 노드 개수가 5000개 이하인 문제에 대해서는 큰 문제 없지만, 10000개를 넘어가는 문제에 대해서는 보다 개선된 다익스트라 알고리즘을 사용해야 한다.
(2) 개선된 다익스트라 알고리즘
다익스트라 알고리즘을 개선하기 위해서는 우선순위 큐를 사용해야 한다. 우선순위 큐는 우선도가 높은 데이터를 먼저 처리하도록 설계된 자료구조이며 이를 구현하기 위해 보통 힙 자료구조를 사용한다. 힙을 사용하면 데이터의 삽입, 삭제에 logN의 시간 복잡도가 소요되며, 데이터를 우선도에 따라 NlogN 시간 복잡도로 정렬할 수 있다.
다익스트라 알고리즘을 개선하기 위해서 ‘단계마다 방문하지 않은 노드 중 최단 거리가 가장 짧은 노드를 선택’하는 과정에 힙 자료구조를 사용한다. 그리고 최단 거리가 가장 짧은 노드를 선택해야하므로 최소 힙을 사용해야 한다.
우선순위 큐로 구현한 다익스트라 알고리즘의 자세한 동작 과정을 살펴보자. 처음엔 원래의 다익스트라 알고리즘 구현과 동일하다. 이번에도 1번 노드를 출발 노드로 설정했으므로 1번 노드까지의 현재 최단 거리 값을 0으로 설정하고, 1번 노드에 대한 정보를 튜플 형태로 우선순위 큐에 넣는다. 이 때, 튜플의 첫 번째 원소를 거리로 설정하면 이 거리를 기준으로 거리가 더 작은 노드가 먼저 나올 수 있도록 큐가 구성된다.
이후 매 단계마다 우선순위 큐에 담긴 원소를 꺼내서 해당 노드에 대한 방문 여부를 확인하고 처리하는 과정을 반복한다. 이번 단계에서 큐로부터 꺼낸 원소는 아직 방문하지 않은 1번 노드이므로, 1번 노드를 방문 처리하고 인접한 노드에 대한 최단 거리 값을 갱신한다. 1번 노드와 인접한 노드는 2, 3, 4번 노드가 존재하고 각각 0+2, 0+5, 0+1의 최단 경로 값을 가지므로, 현재 테이블의 무한 값을 갱신하고 갱신한 노드를 우선순위 큐에 삽입한다. 여기서 큐에 삽입하는 노드는 최단 거리 값이 갱신된 노드만 해당된다는 점을 유의하자.
다음으로 우선순위 큐에서 다시 원소를 꺼내 방문 여부를 파악한다. 큐에서 꺼낸 4번 노드는 아직 방문하지 않은 노드이므로 방문처리한다. 그리고 인접한 3, 5번 노드의 최단 경로 값을 계산해 현재 값과 비교하고 테이블을 갱신한다. 3번 노드는 최단 경로 값이 1+3으로 현재 값 5보다 작고 5번 노드도 1+1로 현재 값 무한대보다 작으므로 두 노드 다 값을 갱신한다. 그 후 갱신한 두 노드를 우선순위 큐에 넣는다. 이 때, 우선순위 큐 속 노드들은 거리를 기준으로 오름차순 정렬되어 알고리즘 수행에 적합하게 재정렬됨을 확인할 수 있다.
이 다음도 위에서 살펴본 바와 마찬가지이다. 우선순위 큐에서 꺼낸 2번 노드를 방문처리하고 인접한 노드의 최단 거리 값을 계산한다. 이 경우는 계산된 최단 거리 값이 현재 값보다 크므로 따로 테이블을 갱신하지 않는다. 또한, 값이 갱신되지 않았기 때문에 우선순위 큐에도 노드들을 삽입하지 않는다.
다음으로 우선순위 큐에서 꺼낸 원소는 5번 노드이고 이를 방문하여 이와 인접한 노드들에 대해 최단 거리를 갱신한다. 이번에는 3, 6번 노드 모두 최단 거리가 갱신되고 우선순위 큐에 삽입된다.
이번에도 앞과 마찬가지로 우선순위 큐에서 원소를 꺼내고 3번 노드를 방문한다. 3번 노드의 인접 노드는 현재 값이 더 작기 때문에 따로 갱신되지 않고 우선순위 큐에 삽입되지 않는다.
다음 우선순위 큐에서 꺼낸 노드는 3번 노드이다. 3번 노드는 이미 방문한 적이 있으므로 처리하지 않고 넘어간다. 이 때, 따로 방문 여부를 체크할 리스트 테이블을 만들지 않고, 단순히 큐에서 꺼낸 노드의 거리 값과 최단 거리 테이블의 거리 값을 대소 비교해 큐에서 꺼낸 노드의 거리가 크면 무시하고 넘어가는 방법을 사용할 수 있다.
같은 방식으로 우선순위 큐에서 원소를 꺼내 확인한다. 나온 원소는 6번 노드이고 아직 방문하지 않았으므로 방문처리한다. 6번 노드는 인접한 노드가 없기 때문에 따로 갱신처리하지 않는다.
마지막으로 우선순위 큐에 남은 하나의 원소를 꺼낸다. 꺼낸 원소는 3번 노드이고 이미 방문한 적이 있기 때문에 다른 처리없이 넘어가며 알고리즘을 종료한다. 이 과정은 다른 시각에서 보면 기존의 테이블에 기록된 최단 거리 3보다 새로 꺼낸 최단 거리 5가 더 크기 때문에 넘어간다고 생각할 수도 있다.
개선된 다익스트라 알고리즘의 소스코드는 다음과 같다.
# input
# 6 11
# 1
# 1 2 2
# 1 3 5
# 1 4 1
# 2 3 3
# 2 4 2
# 3 2 3
# 3 6 5
# 4 3 3
# 4 5 1
# 5 3 1
# 5 6 2
# output
# 0
# 2
# 3
# 1
# 2
# 4
import heapq
import sys
input = sys.stdin.readline
INF = int(1e9) # 무한을 의미하는 값으로 10억을 설정
# 노드의 개수, 간선의 개수를 입력 받기
n, m = map(int, input().split())
# 시작 노드 번호 입력 받기
start = int(input())
# 각 노드에 인접한 노드 정보를 담는 리스트 만들기
graph = [[] for _ in range(n + 1)]
# 최단 거리 테이블 값을 무한으로 초기화
distance = [INF] * (n + 1)
# 모든 간선 정보를 입력 받기
for _ in range(m):
a, b, c = map(int, input().split())
graph[a].append((b, c)) # a번 노드에서 b번 노드로 가는 비용이 c
def dijkstra(start):
q = []
# 시작 노드로 가는 최단 경로는 0으로 설정하고 큐에 삽입
heapq.heappush(q, (0, start))
distance[start] = 0
while q: # 큐가 빌 때까지
# 가장 최단 거리가 짧은 노드에 대한 정보 꺼내기
dist, now = heapq.heappop(q)
# 현재 노드가 이미 처리된 적이 있는 노드라면 무시
if distance[now] < dist:
continue
# 현재 노드와 인접한 노드들을 확인
for i in graph[now]:
cost = dist + i[1]
# 현재 노드를 경유해 다른 노드로 가는 거리가 더 짧은 경우
if cost < distance[i[0]]:
distance[i[0]] = cost
heapq.heappush(q, (cost, i[0]))
# 다익스트라 알고리즘 수행
dijkstra(start)
# 모든 노드에 대해 최단 거리 출력
for i in range(1, n + 1):
# 도달할 수 없는 경우 무한(INFINITY)으로 출력
if distance[i] == INF:
print("INFINITY")
# 도달할 수 있는 경우 거리를 출력
else:
print(distance[i])
위 알고리즘의 시간 복잡도는 O(ElogV) 로 앞의 간단히 구현된 다익스트라 알고리즘보다 훨씬 빠르다. 이러한 시간 복잡도는 직관적으로 와닿지 않을 수 있다. 하지만 잘 생각해보면 이를 이해할 수 있는데, 이미 방문한 노드의 경우 처리하지 않기 때문에 우선순위 큐에서 하나씩 노드를 꺼내 검사하는 반복문은 V이상의 횟수로 반복되지 않는다. 그리고 V번 반복하면서 실제 인접한 노드를 확인하는 작업은 간선의 개수 E만큼 수행된다. 따라서, 개선된 다익스트라 알고리즘은 이 E개의 원소를 우선순위 큐에 넣고 모두 빼내는 작업으로 단순화할 수 있고, 이는 시간 복잡도 O(ElogE)로 표현할 수 있다. 다만, 모든 노드가 다 연결되었다고 했을 때의 간선의 개수는 약 V²개이며 이는 E보다 항상 크다. 이를 log를 씌워서 생각해보면 V²은 log(V²) = 2log(V) = log(V), E는 log(E)가 되고 대소관계는 여전히 유지되어 log(V)는 log(E)보다 항상 크다. 따라서, O(ElogE)는 간단히 O(ElogV)로 표현할 수 있게 된다.
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
모두를 위한 딥러닝 Part 2
딥러닝으로 XOR 문제 풀기
# XOR 문제
XOR 문제는 두 변수 x1, x2가 같다면 False를, 다르다면 True를 반환하는 문제이다. 많은 Neural Network 연구자들이 이 문제를 해결하기 위해 애썼지만, 이 문제는 하나의 유닛으로는 해결할 수 없다는 것이 수학적으로 증명될 정도로 해결하기 어려웠고 ‘Neural Network는 안된다’는 인식이 강해졌다. 이후 여러 개의 유닛을 사용하면 XOR 문제를 해결할 수 있음이 알려졌지만 어떻게 매개변수 w, b를 학습할 수 있을지에 대해 다시 한번 장벽에 부딪혔다. 하지만, 결국 Neural Network는 XOR 문제를 극복해냈는데 지금부터 어떻게 이것이 구현됐는지 살펴보고자 한다.
# Neural Network로 XOR 문제 해결하기
1. Forward Propagation
왼쪽 그림은 세 개의 유닛을 쌓아 만든 간단한 Neural Network 모델이다. 이 모델을 이용하면 XOR 문제를 해결할 수 있는데, 오른쪽 그림처럼 모델 유닛들의 가중치 w와 편향 b가 주어지면 XOR 문제에서 주어진 x1, x2를 모델에 입력하여 결과를 계산할 수 있다. 첫 번째 유닛은 행렬 연산을 수행하면 -8이라는 값이 나오고 이를 시그모이드(sigmoid) 함수에 넣으면 0에 매우 근접한 값 y1을 얻을 수 있다. 두 번째 유닛도 같은 방식으로 계산해 1에 매우 근접한 값 y2를 얻을 수 있으며, 계산을 통해 y1, y2를 세 번째 유닛에 넣고 계산하면 XOR 문제가 요구하는 답인 0을 얻을 수 있다.
나머지 XOR 문제도 같은 Neural Network로 연산하면 XOR 문제를 해결하기 위한 답을 도출할 수 있다. 이렇게 주어진 모델에 input을 넣고 output을 출력해내는 과정을 Forward Propagation이라고 한다.
2. Back Propagation
앞선 예시의 경우 Neural Network 모델의 w, b가 주어진 상태로 연산을 진행했다. 사실 단순한 얕은 Neural Network의 경우 XOR 문제를 해결할 수 있는 w, b를 사람이 직접 찾을 수 있다. 하지만 더욱 깊은 Neural Network의 경우 w, b를 직접 찾는 것은 불가능하다. 그렇다면 w, b가 주어지지 않았을 때는 어떻게 모델을 학습하여 w, b를 자동적으로 계산해낼 수 있을까?
가중치 w, 편향 b를 자동적으로 계산해내기 위해서는 Gradient Descent 알고리즘이 필요하다. 이를 위해 미분을 통해 기울기를 구하는 작업이 동반된다. 그러나 가중치를 조정하기 위해서는 네트워크 각 layer의 input들이 output y에 미치는 영향을 알아야 하는데, 깊은 네트워크일수록 x에 대한 미분을 구하기가 어려워 x가 y에 미치는 영향을 파악하기 힘들다. 심지어 MIT AI Lab의 Marvin Minsky 교수는 Perceptrons(1969)에서 아무도 이것을 학습시킬 수 없다고 말하며 당시 Neural Network의 암흑기가 도래했다.
이러한 학습의 난관을 Paul Werbos가 논문을 통해 Backpropagation 방법을 제시하며 해결했다. 당시에는 Neural Network가 주목받지 못해 Paul의 논문은 조명받지 못했지만, 사장됐던 Backpropagation을 Geoffrey Hinton 교수가 자신의 방법으로 다시 제안하며 Neural Network는 다시 관심을 받게 되었다.
그들이 제시한 Backpropagation은 예측값과 실제값 사이의 오류(Error)를 Cost 함수를 이용해 뒤에서부터 앞으로 보내 미분값을 구하고 w, b를 어떻게 수정할지 계산하는 방법이다. 여기에서는 미분의 공식 중 하나인 chain rule이 강력한 도구로 활용된다. 위 그림 같이 f 함수를 g 함수와 b에 대하여 미분한 값들과 g함수를 w와 x에 대해 미분한 값들을 단순히 계산해내면, chain rule을 통해 f 함수를 w와 x에 대해 미분한 값들을 쉽게 구해낼 수 있게 된다.
네트워크의 layer가 매우 많은 모델에 대해서도 chain rule을 통해 하나하나 미분값을 알아내가면 결국 위 그림처럼 f를 x, y에 대해서 각각 미분한 값들을 알아낼 수 있게 되고, 결국 w, b에 대해서도 미분값을 알아내어 Gradient Descent를 실행할 수 있게 된다. 이러한 Backpropagation은 매우 깊은 네트워크도 충분히 학습 가능하게 하는 딥러닝의 강력한 도구이다.
ReLU 함수
# Backpropagation에서 나타나는 Vanishing gradient 문제
모델의 층을 많이 쌓을수록 보다 다양한 문제를 풀 수 있지만, backpropagation을 하며 input이 output에 미치는 영향을 알아볼 때 chain rule을 통해 얻은 미분값이 너무 작아져 학습이 잘 안되는 문제가 생긴다.
위와 같이 곱셈에 대한 오차역전파를 구할 때 x에 대한 g(x)의 미분 값은 y가 되는데, y는 시그모이드 함수를 통과한 출력 값이라 범위가 0~1 사이로 고정된다. 만일 x = -10이어서 y가 0에 근접한 수를 갖게 된다면, x에 대한 f(x)의 미분값은 g(x)에 대한 f(x)의 미분값 곱하기 y이기 때문에 0에 근접한 값을 갖게 된다. 이러한 과정을 계속 반복해 더욱 0에 가까운 값으로 경사하강법을 수행하면 매개변수 w와 b의 학습이 제대로 이뤄지지 않게 된다. 이러한 문제를 경사도가 사라진다는 의미에서 Vanishing gradient라고 한다.
이 Vanishing gradient 문제로 인해 2~3단계를 넘어가는 층을 가진 Neural Network는 학습시킬 수 없다는 좌절에 빠지게 되고, Neural Net은 1986년~2006년까지 2차 겨울을 맞이 하게 된다.
# ReLU (Rectified Linear Unit) 함수
Geoffrey Hinton 교수는 Vanishing gradient의 원인을 시그모이드 함수에서 찾았다. (We used the wrong type of non-linearity) 시그모이드 함수는 항상 1 미만의 값을 갖는 한계점이 있기 때문에, 입력이 0 이하인 범위에서는 y = 0 함수를 갖고 입력이 0 초과인 범위에서는 y = x 함수를 갖는 ReLU 함수를 시그모이드 함수의 대안으로 제시했다.
Neural Net에서 Activation function으로 단순히 ReLU를 사용하면 해당 network의 학습을 원할하게 진행시킬 수 있다. 즉 층이 2~3 층정도로 적은 모델의 경우 기존의 시그모이드를 사용해도 괜찮지만, 이보다 더 많은 층수를 가진 모델은 ReLU를 사용해야 학습 진행이 가능하다.
실제로 Neural Net에 ReLU 함수를 적용 시, 미동이 없는 시그모이드에 비해 ReLU가 빠르게 Cost 함수를 줄여나가는 것을 관찰할 수 있다.
ReLU 함수에서 더 나아가 Leaky ReLU, tanh, Maxout, ELU 등의 다양한 activation 함수들이 존재하고 상황에 따라 효과적인 함수를 선택해 사용할 수 있다.
위의 activation 함수들을 각각 사용하여 CIFAR-10 이미지 데이터에 대한 정확도를 측정해본 결과, 실제로 시그모이드를 제외한 다른 activation 함수들은 큰 문제없이 높은 정확도를 기록하는 모습을 살펴볼 수 있다.
Weight initialization
# 가중치 초기화 (Weight initialization)
1. Zero initialization
Geoffrey Hinton 교수가 찾아낸 Neural Network가 제대로 동작하지 않는 이유들 중 하나는 가중치 초기화의 문제이다.
예를들어, 극단적으로 가중치를 0으로 초기화하면 backpropagation으로 구한 x에 대한 f(x)의 기울기 값이 0이 되어 기울기가 소실되는 현상이 발생한다. 이 경우 학습이 원활하게 이뤄지지 않기 때문에, 가중치 초기화의 첫번째 유의점은 ‘w를 모두 0으로 설정하지 말자.’이다.
2. RBM을 이용한 initialization
가중치를 어떻게 설정해야할 지에 대한 문제는 깊이 들어갈수록 어려워졌는데, Hinton 교수는 A Fast Learning Algorithm for Deep Belief Nets (2006)이라는 논문을 통해 RBM(Restricted Boatman Machine)으로 Neural Network의 가중치를 초기화하자고 제안했다. RBM을 사용해 가중치를 초기화한 Neural Net을 Deep Belief Nets이라고 한다.
RBM이란 Input을 모델에 넣어 Output을 구하고 (encoder), 구한 Output 값에 다시 가중치를 곱하여 Input 값을 예측해낸 뒤 (decoder), 원래의 Input 값과 예측한 Input 값의 차가 최저가 되도록 (두 값이 거의 동일해지도록) 가중치를 조정해나가며 초기값을 설정하는 방법이다.
전체 네트워크에 대해 RBM을 사용해 가중치를 학습시키는 것을 Pre-Training이라고 한다. 이것을 구현하기 위해 먼저 인접한 첫 번째와 두 번째 레이어에 대해 encoder와 decoder로 값을 비교하여 가중치를 학습시키고 다음 인접한 두 번째, 세 번째 레이어에 대해서도 똑같은 과정을 반복하며 네트워크의 끝까지 모든 레이어에 대해 가중치를 학습시킨다.
Pre-Training을 거쳐 가중치를 초기화한 모델을 원래대로 training data를 주고 학습시키는 것을 특별히 Fine Tunning한다고 말한다. Pre-Training을 통해 이미 가중치들이 생각보다 잘 학습되어서 좋은 초기값을 제공하므로 살짝의 튜닝만 한다는 의미에서 training 과정을 다르게 지칭한다.
3. 더욱 simple한 초기화
RBM을 사용해 가중치를 초기화하는 과정을 하나하나 거쳐가는 것은 복잡하다. 초기화 과정을 더욱 간단하게 진행하기 위해 Xavier initialization, He initialization 등의 초기화가 등장했다. 이 두 방법은 input 값의 개수와 output 값의 개수를 고려하여 초기화를 진행하는 방법인데, 위 식과 같이 초기화를 진행하면 RBM을 사용한 것과 동일한 혹은 더 나은 성능을 보여준다고 알려져 있다. 흥미로운 점은 He initialization이 Xavier initialization의 식에서 단순히 분모 루트 안의 값을 2로 나눠주는 것으로 Xavier initialization보다 더 좋은 성능을 보여준다는 사실이다.
이러한 가중치 초기화에 대한 연구는 아직도 활발히 진행 중이므로 여러가지 초기화 방법에 대해 알아두고 데이터마다 다르게 적용해볼 필요가 있다.
Dropout과 Ensemble
# Overfitting 문제
뉴럴 넷의 가중치 변수를 많이 생성하고, 레이어를 깊게 쌓을수록 해당 모델은 training data에 오버피팅될 가능성이 높다. 오버 피팅을 해결하기 위해서 1. training data를 더욱 늘리거나 2. feature(x 변수)를 줄이거나 (이 방법은 굳이 사용하지 않아도 괜찮다.) 3. Regularization(정규화)를 진행해 모델의 하이퍼플레인(선)을 보다 평탄하게 해주는 방법이 있다. (= 가중치에 너무 큰 값을 배정하지 않게 하자!)
# 드롭아웃 (Dropout)
그러나 뉴럴넷 모델이 복잡해지면 정규화만으로 오버피팅에 대응하기 어려워지는데, 이 때 Dropout 기법이 사용된다. Dropout: A Simple Way to Prevent Neural Networks from Overfitting (Nitish Srivastava et al. 2014) 논문에 따르면 Dropout은 랜덤하게 어떤 뉴런들의 연결을 끊고 해당 뉴런을 비활성화시켜 뉴럴넷을 보다 간소하게 만드는 방법이다. 힘들게 만든 뉴런들을 왜 없애는 지에 대한 강한 의문이 들 수 있지만, 이 방법은 생각보다 큰 성능 상승 효과를 가져온다.
각 뉴런들은 각자 한 분야의 전문가 역할을 한다. 예를 들어, 고양이인지 아닌지 판단하는 뉴럴 넷을 사용한다고 할 때, 어떤 뉴런은 귀를 갖고 있는지 아닌지 판단하는 전문가, 어떤 뉴런은 꼬리를 가지고 있는지 판별하는 전문가, 어떤 뉴런은 털이 있는지 없는지 판단하는 전문가이다. 이런 뉴런들 중 몇몇을 쉬게 하여 학습을 진행하는 것이 Dropout 방법이다.
Dropout을 텐서플로우에서 적용하는 방법은 단순히 활성화 함수까지 있는 레이어에 Dropout 레이어를 하나 추가해주는 것이다. 이 때 dropout_rate를 설정해줘야 하는데, 보통 0.5로 설정하여 매 레이어마다 랜덤하게 학습하는 뉴런을 다르게 한다. Dropout에서 주의할 점은 training할 때만 적용해야 한다는 점이다. 학습시킬 때는 Dropout으로 랜덤하게 뉴런들을 학습시키고 test할 때는 dropout없이 모든 뉴런을 사용해서 예측해야 한다.
# 앙상블 (Ensemble)
모델의 성능을 높이는 방법 중 하나로 앙상블(Ensemble) 기법이 있다. 예를들어, 위 그림과 유사하게 9개의 층을 가진 신경망 모델이 k개 있다고 하면, 이를 각각 모두 학습시킨 후 예측한 결과들을 마지막에 통합하여 최종적인 예측 결과를 도출할 수 있다. 마치 여러 전문가에게 의견을 물은 후 투표를 통해 최종적인 결론을 내리는 것과 유사한 이 방법은 적게는 2%에서 많게는 4~5%까지 모델의 성능을 향상시킨다.
CNN (Convolutional Neural Network)
# CNN (Convolutional Neural Network)
CNN의 시작은 고양이의 시각 인식에 관한 연구에서 비롯됐다. 고양이가 어떤 사진을 인식할 때, 각 뉴런이 각각 자신이 맡은 사진의 특정 부분에만 활성화되는 것이 확인되었고, 컴퓨터의 이미지 인식에도 같은 방식을 적용하는 시도가 이뤄졌다.
CNN은 Convolutional Layer와 ReLU, Pooling 계층의 반복적 조합으로 구성되며 끝에는 Fully Connected Layer로 결과를 분류한다.
실제로 CNN을 동작시킬 때, 행렬의 크기 단위로 설정한 filter를 사용해 전체 이미지를 부분으로 쪼개어 처리한다. 위 그림의 예는 3개의 RGB 컬러로 구성된 32 pixel X 32 pixel 이미지를 5 X 5 X 3의 크기를 가진 filter로 처리하는 모습이다.
Filter는 원래 딥러닝에서 사용하던 Wx + b 회귀식의 가중치 행렬 W로 표현된다. 따라서 이미지를 filter를 통해 처리하는 것은 쪼개어진 이미지를 회귀식을 통해 계산해 하나의 숫자 ouput으로 만드는 과정을 의미한다. 이 과정을 쪼개어진 이미지 모두에 적용하면, 우리는 보다 축소된 크기의 숫자값 행렬을 얻을 수 있다. 이러한 과정을 거쳐 얻은 행렬을 Feature map이라고 하고 이 피쳐맵에 활성화 함수를 적용한 것을 Activation map이라고 한다.
그렇다면 Feature map 하나의 크기는 어떻게 될까? 위 그림의 공식처럼 Output의 행 혹은 열 길이는 (N - F) / stride + 1로 구할 수 있다. 여기서 Stride는 filter를 몇 칸씩 이동하며 적용할지를 나타낸다. 예를들어, 위 그림에서 이미지의 가로, 세로 길이를 N이라 하고 filter의 가로, 세로 길이를 F라 한다면, N = 7, F = 3이다. 따라서 stride를 1로 설정해 filter를 한 칸씩 이동하며 적용하는 경우에는 (7 - 3) / 1 + 1 = 5를 통해 Ouput이 5 X 5 크기의 행렬이 되고, stride를 2로 설정해 filter를 두 칸씩 이동하며 적용하는 경우에는 (7 - 3) / 2 + 1 = 3을 통해 Output이 3 X 3 크기의 행렬이 된다. Stride가 3인 경우에는 공식에서 정수로 나누어 떨어지지 않기 때문에, CNN 적용이 불가능하다.
그러나 filter를 통해 이미지를 축소시키는 것은 다시말해 어떠한 정보를 잃어버리게 된다는 점을 의미한다. 따라서, 일반적으로 CNN을 사용할 때는 이미지 가장자리에 0을 둘러싸는 Zero padding 처리를 한 후 진행한다. Zero padding을 진행하면 1. 이미지의 과도한 축소를 막을 수 있고 2. 이미지 모서리 부분의 위치를 어떤 형태로든 network에 알려줄 수 있다. 위 그림처럼 7 X 7 크기의 이미지에 1 pixel만큼 Zero padding 처리를 해 9 X 9 크기의 행렬을 만들면, 1만큼 stride를 적용해 3 X 3 크기의 filter를 거쳐 나오는 output의 크기는 (9 - 3) / 1 + 1 = 7을 통해 원래대로 7 X 7 행렬이 된다.
지금까지 Convolutional Layer에서 하나의 Feature map을 만드는 과정을 살펴봤다. 이후에는 이 과정을 각 가중치가 서로 다른 여러 개의 filter에 대해 적용해 여러 개의 Activation map(활성화 함수가 적용된 Feature map)을 하나의 Convolutional Layer에서 만들 수 있다. 위와 같이 가중치가 서로 다른 6개의 5 X 5 X 3 filter를 사용한다면, 총 6개의 28 X 28 크기의 Activation map을 만들 수 있다.
앞의 과정을 반복하면 위와 같이 여러 개의 Convolutional layer들을 만들 수 있다. Zero padding 없이 32 X 32 X 3 크기의 이미지를 첫 번째 Convolutional Layer에 넣으면 6개의 5 X 5 X 3 filter에 의해 28 X 28 X 6 Activation Maps가 나오고, 이를 두 번째 Convolutional Layer에 넣으면 10개의 5 X 5 X 6 filter에 의해 24 X 24 X 10 Activation Maps가 나온다.
참고로 각각의 layer에서 사용되는 weight의 개수는 그 layer에 적용되는 필터의 크기와 갯수를 곱한 값과 같다. 즉, 첫 번째 layer의 경우 크기가 5 X 5 X 3인 filter가 6개 존재하므로 weight의 개수는 450개이다.
# Max pooling과 Fully Connnected Layer
Pooling Layer란 하나의 Convolutional Layer에서 1개씩 부분 layer를 뽑아서 sampling을 통해 resize하여 다시 축소된 Convolutional Layer로 재구성한 Layer이다.
Pooling은 filter를 이용해 진행하는데, 보통 Max pooling이 자주 이용된다. 위 그림처럼 stride가 2인 2 X 2 크기의 filter가 있다면, 그 filter에 대상이 된 4개의 값 중 가장 큰 값 하나를 선택해 Pooling Layer의 요소로 사용한다. 따라서, 위 그림의 Pooling Layer 요소는 6, 8, 3, 4로 구성된다.
그리고 CNN 모델의 마지막에는 기존 Neural Network에서 사용하던 모든 뉴런들을 연결해 연산하는 Fully Connected Layer를 위치시킨다. Convolutional Layer의 경우 모든 뉴런을 연결시키지 않고 정보를 전달했는데, CNN의 끝 부분에서는 Softmax를 사용해 이미지를 분류해야 하므로 기존의 Fully Connected된 적당한 깊이의 Neural Network를 사용한다.
RNN (Recurrent Neural Network)
# RNN (Recurrent Neural Network)
출처: https://heung-bae-lee.github.io/2020/01/12/deep_learning_08/
Data는 여러가지 종류가 존재하는데, 그 중 Sequential data의 처리는 기존의 Neural Net이나 CNN으로 해결할 수 없다. Sequential data란 순서에 의미가 있어서 순서가 달리지면 의미가 손상되는 데이터를 말한다. Sequential data에는 음성 인식에서의 소리 신호, 세계 기온 변화 추이와 사례가 있는데, 특히 언어의 경우 문장은 하나의 단어뿐만 아니라 그 앞뒤의 단어들까지 고려해야 이해가 가능하다. 이러한 Sequential data를 처리하는 기법으로 RNN(Recurrent Neural Network)이 사용된다.
RNN에서는 입력으로 계산한 값이 그 다음 것에 영향을 주도록 구조화한다. 위 그림에서 입력 X0를 받아 Cell이라고 부르는 A에서 계산된 상태(state)를 그 다음 입력 X1으로 계산된 A에 전달하는 것이 이에 해당한다. 즉 어떤 시점에서 무언가를 계산할 때, 이전 시점의 것들이 영향을 미친다는 점에서 Sequential data를 처리하는데 적합하다.
새로운 state를 연산하는 방법은 위의 공식과 같다. Input X와 이전 시점의 state에 가중치 W와 관련된 함수를 연산해주면 새로운 state의 값을 구할 수 있다. RNN에는 모든 state에 똑같은 공식이 적용되므로 위 그림의 모델처럼 순환 화살표 표시를한다.
# Vanila RNN
RNN의 가장 기본적인 형태는 위의 공식을 사용한다. 새로운 state를 연산하기 위해 이전 state와 input X에 각각 다른 가중치를 기존 회귀식같이 WX형태로 곱해주고 두 값을 더한다. 그리고 더한 값에 tanh 함수를 활성화함수로 사용해 새로운 state를 구한다. Output Y를 구할 때도 비슷하게 또 다른 가중치 W를 기존 WX 형태로 곱해주면 된다.
이 연산에서 사용하는 가중치 Wh, Wxh, Why는 어떤 state를 구할 때라도 항상 같다는 점을 주의하자.
단순한 신경망은 표현력에 한계가 있지만 RNN을 사용한다면 활용 범위가 크게 넓어진다.
· One to Many: Image Captioning (이미지를 인식하여 여러개의 문자열로 표현하는 분야)
· Many to One: Sentiment Anlaysis (여러 개의 문자열을 입력 받아 하나의 감성을 나타내는 문자열로 분석하는 분야)
· Many to Many: Machine Translation (여러 개의 문자열을 입력 받아 여러 개의 문자열로 번역하는 분야)
· Many to Many: Video Classification on frame level (여러 프레임을 입력 받아 여러 개의 문자열로 표현하는 분야)
이러한 방식으로 RNN도 다양한 형태를 띌 수 있는데, RNN이 보다 깊어지고 복잡해질수록 기존 신경망 모델과 마찬가지로 학습이 어려워지는 현상이 발생한다. 이를 극복하기 위한 Advanced Model로 조경현 교수님이 만든 GRU나 LSTM(Long Short Term Memory)이 존재한다.
# Hello의 예
RNN 모델에 단어 ‘hello’를 학습시키는 예를 살펴 보자. ‘hello’는 철자 단위로 모델을 학습시킬 수 있다. X0에 ‘h’, X1에 ‘e’, X2에 ‘l’, X3에 ‘l’을 입력으로 넣고 입력된 철자 다음으로 나올 철자를 예측하도록 설계할 수 있다. 이 경우 h0는 ‘e’, h1은 ‘l’, h2는 ‘l’, h3은 ‘o’를 output으로 예측한다.
이 때, 위 그림처럼 고정된 값 Whh, Wxh, Why를 가중치로 사용하여 각 input의 state를 연산하고 output Y를 출력한다. Input X의 경우 one-hot 인코딩을 통해 철자를 표현하는게 일반적이고 output은 softmax를 통해 철자 예측을 진행하면 되는데, 모델의 학습 역시 기존의 softmax에 대한 cost 함수를 사용해 진행하면 된다.
본 포스팅은 김성훈 교수님의 강의
‘모두를 위한 딥러닝’을 학습하고 정리한 내용을 담고 있습니다.
-
다이나믹 프로그래밍 (Dynamic Programming)
다이나믹 프로그래밍 (Dynamic Programming)
현대에서 컴퓨터를 사용해도 해결하기 어려운 문제는 최적의 해를 구하는데 매우 많은 시간을 요하거나 메모리 공간을 매우 많이 요구하는 문제들이다. 그런데 어떠한 문제는 메모리 공간을 조금 더 사용하면 연산 속도를 비약적으로 상승시킬 수 있는 방법이 있다. 메모리를 적절히 사용하여 수행 시간 효율을 비약적으로 상승시키는 방법을 다이나믹 프로그래밍(Dynamic Programming)이라고 하며 동적 계획법이라고도 부른다.
다이나믹 프로그래밍은 1. 큰 문제를 작게 나누고, 2. 같은 문제라면 한 번 씩만 풀어 문제를 효율적으로 해결하는 알고리즘이다. 즉, 다이나믹 프로그래밍은 다음의 두 조건을 갖췄을 때만 사용가능하다.
1. 최적 부분 구조 (Optimal Substructure): 큰 문제를 작은 문제로 나눌 수 있다.
2. 중복되는 부분 문제 (Overlapping Subproblem): 작은 문제에서 구한 정답은 그것을 포함하는 큰 문제에서도 동일하다.
다이나믹 프로그래밍으로 해결할 수 있는 대표적인 문제로 피보나치 수열이 있다. 피보나치 수열은 현재의 항을 이전 두 개 항의 합으로 설정해 끊임없이 이어지는 수열을 말한다.
피보나치 수열은 위와 같이 점화식 형태로 만들어 간결하게 표현할 수 있다. 점화식이란 인접한 항들 사이의 관계식을 말한다. 따라서, 위 식은 n번째 피보나치 수는 (n - 1)번째 피보나치 수와 (n - 2)번째 피보나치 수를 합한 것이고 단, 1번째 피보나치 수와 2번째 피보나치 수는 1임을 의미한다.
이러한 피보나치 수열은 단순히 재귀함수만으로도 표현할 수 있다.
def fibonacci(n):
if n == 1 or n == 2:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(4))
하지만 재귀함수로만 구현한 경우, 시간복잡도가 O(2ⁿ)이 되어 n이 커질수록 수행 시간이 기하급수적으로 증가하기 때문에 심각한 문제가 발생한다.
이는 중복되는 부분 문제로 살펴볼 수 있는데, f(6)을 호출하는 예시에서는 f(2)가 총 5번 중복으로 계산되는 것을 알 수 있다. 이렇게 중복되는 부분을 또 다시 계산하는 비효율성을 다이나믹 프로그래밍으로 극복할 수 있다.
다이나믹 프로그래밍은 크게 재귀 함수를 이용하는 탑다운(Top-Down) 방식과 반복문을 이용하는 바텀업(Bottom-Up) 방식으로 나뉜다. 먼저, 메모이제이션(Memoization) 기법(탑다운 방식)을 사용해 피보나치 수열을 해결해보자.
# 한 번 계산된 결과를 메모이제이션(Memoization)하기 위한 리스트 초기화
d = [0] * 100
# 피보나치 함수를 재귀함수로 구현 (탑다운 다이나믹 프로그래밍)
def fibonacci(n):
# 종료조건(1 혹은 2일 때 1을 반환)
if n == 1 or n == 2:
return 1
# 이미 계산한적 있는 문제라면 그대로 반환
if d[n]:
return d[n]
# 아직 계산하지 않은 문제라면 점화식에 따라서 피보나치 결과 반환
d[n] = fibonacci(n - 1) + fibonacci(n - 2)
return d[n]
print(fibonacci(99))
메모이제이션이란 한 번 구한 결과를 메모리 공간에 기록해두고 같은 식을 다시 호출하면 기록한 결과를 그대로 사용하는 기법을 말한다. 캐싱(Caching)이라고도 부르는 이 방법은 특히 탑다운 방식을 이야기할 때 한정해서 쓰인다. (바텀업에서는 사용하지 않는 용어다.) 메모이제이션은 위의 코드처럼 한 번 구한 정보를 리스트에 저장하고 재귀적으로 다이나믹 프로그래밍을 수행하다가 같은 정보가 필요할 때, 구한 정답을 그대로 리스트에서 가져오도록 구현한다. 실제로 위 코드를 실행해보면 단순히 재귀로 구하는 것보다 훨씬 빠르게 답을 도출하는 것을 확인할 수 있다.
다음으로 다이나믹 프로그래밍의 전형적인 형태인 바텀업 방식으로 피보나치 수열을 구현해보자.
# 앞서 계산된 결과를 저장하기 위한 DP 테이블 초기화
d = [0] * 100
# 첫 번째 피보나치 수와 두 번째 피보나치 수는 1
d[1] = 1
d[2] = 1
n = 99
# 피보나치 함수를 반복문으로 구현 (바텀업 다이나믹 프로그래밍)
for i in range(3, n + 1):
d[i] = d[i - 1] + d[i - 2]
print(d[n])
일반적으로 바텀업 방식은 탑다운 방식보다 성능이 좋아 다이나믹 프로그래밍의 전형적인 방법으로 알려져 있다. 바텀업 방식에서 사용되는 결과 저장용 리스트를 DP 테이블이라고 부르며, 이 DP 테이블을 이용해 반복적으로 피보나치 수열을 구현한다.
다이나믹 프로그래밍으로 f(6)을 호출하면 실질적으로 실행하는 것은 f(3), f(4), f(5), f(6)뿐이고 나머지는 기록한 정보를 가져오는 형태여서 큰 수행 속도 향상과 함께 O(N)의 시간 복잡도를 얻을 수 있다.
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
모두를 위한 딥러닝 Part 1
Machine Learning 개요
# 머신러닝이란?
Explicit(=many rules)한 프로그래밍을 지양하고, 프로그램에게 데이터를 보고 스스로 학습할 능력을 부여해서 어떠한 결과를 도출하게끔 하는 연구하는 분야
→ Field of study that gives computers the ability to learn without being explicitly programmed (Arthur Samuel, 1959)
# 학습 방법에 따른 유형
1. Supervised learning
: 컴퓨터에게 정답(label)이 무엇인지 알려주면서 학습시키는 방법 (label이 있는 data로 학습)
Regression
어떠한 연속된 값을 주어진 데이터들의 특징(feature)을 기준으로 예측하는 문제
ex) 시험공부에 투자한 시간에 대한 기말시험 ‘점수’ 예측
Binary Classification
주어진 데이터를 2개의 카테고리로 분류하는 문제
ex) 개와 고양이 구분
Multi-Class(=Multi-Lable) Classification : 주어진 데이터를 3개 이상의 카테고리로 분류하는 문제
ex) 시험공부에 투자한 시간에 대한 기말시험 ‘등급’ 예측
2. Unsupervised learning
: 정답(label)을 알려주지 않고 비슷한 데이터를 군집화하여 미래를 예측하는 학습 방법 (label이 없는 data로 학습)
ex) Google news grouping, Word clustering
Clustering
Dimensionality Reduction
etc…
Linear Regression
# Linear Regression
주어진 학습 데이터를 가장 잘 설명할 수 있는 선을 찾아 분석하는 방법이다. (Regression은 연속하는 값을 가지는 학습 데이터에 한해 사용한다.) 위 그림처럼 주어진 데이터를 그래프에 표현하고 여러가지 선을 긋다보면 파란선이 해당 데이터를 가장 잘 표현함을 알 수 있다. 이러한 선을 H(x) = Wx + b의 형태의 수식으로 찾아내는 것을 Linear Regression이라고 한다. 위 그림의 파란선은 H(x) = x로 나타낼 수 있다.
# Loss & Cost function
Cost function은 예상한 가설(선)이 데이터에 얼마나 잘 맞는지 확인하는 함수이다. 보통 예측값에서 실제값을 뺀 값의 제곱인 (H(x) - y)²을 Loss로 사용하여 Cost function을 구한다.
즉 이렇게 계산한 모든 Loss의 평균을 내면 Cost function을 구할 수 있다. 딥러닝에서는 주로 이 Cost function이 사용되고 이러한 Cost function을 최소화시키는 W, b를 찾는 것이 목표가 된다.
Multi-variable linear regression
# Mulit-variable linear regression
앞에서 공부했던 선형 회귀는 하나의 변수에 대하여 출력을 계산했다. 그러나 위 시험 점수 예측 사례의 퀴즈 1 점수, 퀴즈 2 점수, 중간고사 점수처럼 여러개의 변수를 고려하여 회귀를 진행할 땐 어떻게 해야할까?
기존의 선형 회귀 식은 H(x) = Wx + b였다. 다변량 선형 회귀는 위와 같이 기존 선형 회귀와 유사하게 새로운 가중치 w를 각각의 새로운 변수 x들에 곱해주면 된다.
다변량 선형 회귀의 비용함수 역시 선형 회귀의 비용함수 식을 그대로 가져오되 Hypothesis만 다변량 회귀식으로 적용하여 사용한다.
Hypothesis를 n개의 변수에 대하여 일반화하면 위와 같다. 그러나 n의 값이 커질수록 식이 길어서 이를 표현하기 어려워지는 문제가 생긴다.
식이 길어지는 문제는 행렬(Matrix)을 도입하는 방법(= Vectorization)으로 해결할 수 있다. 변수 x들에 대한 행렬 X와 각각의 변수에 대한 가중치 w들을 표현하는 행렬 W를 사용해 H(X) = XW라는 Hypothesis를 사용할 수 있다. 일반적으로 이론에 사용되는 식에서는 H(x) = Wx 처럼 W를 앞에 사용하지만, 실제로 구현할 때는 XW와 같이 X를 앞에 두고 사용한다. Vectorization은 n개의 변수에 대해 n번이나 수행되어야 하는 계산을 한번으로 줄여 효율적인 계산을 돕는 이점이 있다.
위는 기말 시험 점수를 예측하는 다변량 선형 회귀에 대한 예시이다. 왼쪽 상단의 표에는 3가지 시험 점수 변수와 기말 점수 변수에 대한 데이터가 5개 있다. 이러한 데이터 하나하나를 Instance라고 한다. 행렬로 다변량 선형 회귀를 수행할 때는 그 행과 열에 정보가 담겨 있는데, X의 행은 instance의 개수(data의 개수), 열은 독립변수의 개수를 나타낸다. W의 행은 독립변수의 개수를 나타내며 열은 출력 개수를 나타낸다. 그리고 두 행렬 X와 W를 계산한 결과를 담는 행렬은 행이 instance의 개수, 열이 출력의 개수를 나타낸다.
Logistic Regression
# 이진 분류 (Binary classification)
이진 분류(Binary Classification)는 어떤 문제에 대하여 두 가지 중 하나를 결정하는 문제이다. 메일이 스팸메일인지 아닌지, 페이스북 피드를 보여줄지 말지, 방금 진행한 신용카드 거래가 사기인지 아닌지 판단하는 것이 이진 분류의 예다. 일반적으로 결정해야할 두 가지 결과는 0, 1로 인코딩해 사용한다.
# 로지스틱 회귀 (Logistic Regression)
1. 로지스틱 회귀와 시그모이드(Sigmoid) 함수
이진 분류를 가장 잘 해결할 수 있는 방법으로 로지스틱 회귀(Logistic Regression)가 있다. 기존의 선형 회귀는 시험에 통과할 사람을 정확히 예측하는게 어렵고, 입력값이 커질수록 출력값이 0~1 범위를 크게 벗어나 결과를 두 가지로 분류하기 어렵다. 이러한 출력값을 0~1 범위로 압축하는 함수를 이용해 출력값을 분류하는 것이 로지스틱 회귀이다.
로지스틱 회귀에서는 0~1 범위로 출력값을 압축하는 함수로 시그모이드(Sigmoid) 함수를 사용한다. 시그모이드 함수는 모든 출력값이 0~1 사이에서 나오는 특징이 있다. 이를 통해, 기존 가설인 선형 회귀에 시그모이드 함수를 덧입혀 이진 분류에 적합한 새로운 가설 H(x)를 만들 수 있다.
2. 로지스틱 회귀의 Cost 함수
기존 선형 회귀의 cost 함수는 기울기가 0이 되는 값이 하나여서 쉽게 최솟값을 찾을 수 있었지만, 로지스틱 회귀의 경우 비선형 함수인 sigmoid 함수로 인해 cost 함수가 훨씬 구불구불한 형태를 띄게 된다. 이로 인해, 기울기가 0이 되는 지점이 많아져 시작점에 따라 경사하강법으로 찾는 최솟값의 지점이 달라진다. 즉, cost 함수의 진짜 최솟값을 찾는 것이 어렵다.
이를 극복하기 위해, 로지스틱 회귀에서는 위와 같은 cost 함수를 사용한다. 가장 왼쪽에 있는 그래프는 y = 1일 때의 cost 함수, 그 옆에 있는 그래프는 y = 0일 때의 cost 함수이다. 시그모이드 함수로 인해 생기는 지수함수적 특성을 log 함수를 사용해 중화한 덕분에 전체적으로 포물선과 비슷한 형태를 띈다. 따라서, 최솟값 찾기가 용이하다.
cost 함수 그래프를 살펴보자. y = 1일 때의 그래프에서 H(x)가 1에 가까울수록(예측값이 정답에 가까울수록) cost 함수가 작아지고 H(x)가 0에 가까울수록(예측값이 틀릴수록) cost 함수가 무한대로 커진다. 반대로 y = 0일 때의 그래프에서 H(x)가 1에 가까울수록(예측값이 틀릴수록) cost 함수가 무한대로 커지고 H(x)가 0에 가까울수록(예측값이 정답에 가까울수록) cost 함수가 작아진다. 로지스틱 회귀의 cost 함수가 비용함수의 역할을 정확히 수행함을 확인할 수 있다.
비용함수를 텐서플로우로 실제로 구현할 때는 C(H(x), y) = - ylog(H(x)) - (1 - y)log(1 - H(x)) 식을 사용한다. 위의 y = 1일 때와 y = 0일 때의 비용함수를 똑같이 표현한 같은 식이며 구현의 편의를 위해 사용한다.
Multi-Class Classification - Softmax
# 다중 클래스 분류 (Multi-Class Classification)
앞선 로지스틱 회귀에서는 두 가지 선택지만 결정했다. 만약 세 가지 이상의 클래스를 두고 결정해야 하는 상황이라면 다중 클래스 분류(Multi-Class Classification)를 한다. 위와 같이 시간과 출석 여부라는 두 가지 변수에 대하여 A, B, C 세 가지 성적을 매기는 상황을 가정해보자. 성적 분포의 그래프는 오른쪽 그래프와 같다.
성적이 표현된 그래프를 로지스틱 회귀를 사용한다고 생각하고 A에 대해, B에 대해, C에 대해 각각 이진 분류한다면 위와 같이 3가지 선을 그을 수 있다. A인지 아닌지, B인지 아닌지, C인지 아닌지를 구별하는 세 가지 선을 그은 것이다.
이 3가지 식을 행렬로 표현하면 왼쪽 그림과 같다. 그리고 계산의 편의를 위해 이 식들을 또 하나의 행렬로 통합하면 오른쪽 그림과 같아진다. 오른쪽 그림의 계산식의 3가지 출력이 각각 A, B, C에 대한 H(x) 값이 된다.
A, B, C에 대한 각각의 예측값이 0~1 범위에 있게 하고 모두 합해 1이 나오게 한다면 편리한 계산을 할 수 있다. 이를 위해 위 그림과 같은 Softmax 함수를 사용한다. Softmax 함수를 사용하면 A, B, C에 대한 세 가지 예측값을 확률로서 사용할 수 있게 된다.
Softmax로 도출된 각각의 확률은 One-Hot Encoding을 통해 1 혹은 0으로 분류되고 각각의 예측값이 결론적으로 가리키는 것이 A인지 B인지 C인지를 확인할 수 있게 된다.
※ Softmax의 Cost 함수
Learning rate, Overfitting and Regularization
# Learning rate (학습률)
Gradient Descent를 진행할 때, 각 step마다 어느 정도씩 진행할지 Learning rate(학습률)을 지정하여 설정할 수 있다. (위 그림에서 알파값이 학습률을 나타낸다.)
학습률을 너무 큰 값으로 설정하면 스텝마다 큰 폭으로 학습이 진행되어 왼쪽 그림처럼 w값이 발산해버리는 오버슈팅(Overshooting) 문제가 발생할 수 있다. 반대로 학습률을 너무 작은 값으로 설정하면 스텝마다 작은 폭으로 학습이 진행돼 오른쪽 그림처럼 학습이 더뎌지는 문제가 발생한다. 학습률 설정에 정답은 없지만 처음에 0.01의 학습률을 설정하고 양상에 따라 조절하는 것도 한 방법이 될 수 있다.
# 데이터 전처리 (Preprocessing)
데이터들을 다루다보면 x data에 해당하는 각각의 변수들의 값의 범위가 서로 크게 차이날 수 있다. 이러한 경우 적절한 학습률을 설정해도 오버피팅이나 언더피팅이 발생할 수 있는데, x data를 적절하게 전처리(Preprocessing)해주면 다시 정상적으로 학습시킬 수 있다. 이러한 전처리는 보통 zero-centered를 통해 원래의 데이터를 0을 중심으로 분포하게 만들거나, Normalization을 통해 변수 값의 범위를 특정 범위에 속하게 만드는 방법들이 있다.
이러한 normalize의 대표적인 예 중 하나가 표준화(Standardization)이다. 기존의 data에서 그 평균을 빼고 표준편차로 나눠주면 data는 표준정규분포를 따르게 되어 특정 범위 내에 분포하게 된다. 고등학교에서 통계 과목을 배울 때, 자주 봤던 이 개념을 사용해 data를 표준화시키면 정상적인 학습 진행에 큰 도움을 준다.
# 오버피팅 (Overfitting)
학습시킨 모델이 training data(학습 데이터)에서만 너무 잘 맞아서 test data나 실제 문제에서는 좋은 성능을 발휘하지 못하는 현상을 오버피팅(Overfitting)이라고 한다. 오른쪽 그림은 학습 데이터에서 +와 -를 완벽하게 가르지만 실제 문제를 다룰 때는 +와 -를 나누는 성능이 왼쪽 그림에 비해 더 떨어질 수 있다. 이 경우엔 오버피팅 문제가 없는 왼쪽 모델이 더 성능이 좋으므로 모델을 학습시킬 땐 항상 오버피팅에 대해 경계해야 한다.
오버피팅의 해결책으로는 1. training data를 더 많이 확보하는 것 2. feature의 개수를 줄이는 것(=x변수를 줄이는 것) 3. Regularization시키는 것 등이 있다.
# Regularization
Regularization이란 데이터를 가르는 모델의 구불구불한 선을 조금 더 평탄하게 만드는 것을 의미한다. 보통 가중치 w의 값이 커질수록 모델의 선이 구불구불해지고, w의 값이 작아질수록 모델의 선이 평탄하게 뻗게 된다. 가중치 w 값을 보다 작게 하여 모델의 선을 적당히 평탄하게 만드는 Regularization을 통해 오버피팅을 어느정도 줄일 수 있다.
Regularization은 cost 함수에 위 식을 더해주는 것으로서 구현하고 이를 L2 Regularization이라고 부른다. 맨 왼쪽의 람다 변수는 regularization strength라고 불리는데, 이 값이 0에 가까울수록 Regularization의 영향을 적게 한다는 의미고 이 값이 커질수록 Regularization의 영향력을 크게 한다는 의미이다. 이를 통해 가중치 값을 낮추고 오버피팅을 어느정도 극복할 수 있다.
본 포스팅은 김성훈 교수님의 강의
‘모두를 위한 딥러닝’을 학습하고 정리한 내용을 담고 있습니다.
-
이진 탐색
순차 탐색
일반적으로 자주 사용되는 탐색으로, 앞에서부터 데이터를 하나씩 차례대로 확인하며 리스트 안에 있는 특정 데이터를 찾는 방법이다. 보통 정렬되지 않은 리스트에서 데이터를 찾을 때 사용한다. 충분한 시간이 있다면 데이터가 아무리 많아도 항상 원하는 데이터를 찾을 수 있는 것이 장점이다. 시간 복잡도는 최악의 경우 O(N)을 보장한다.
# 순차 탐색 함수 구현
def sequential_search(target, array):
for i in range(len(array)):
if array[i] == target:
return i + 1 # 현재 위치 반환 (인덱스이므로 1을 더함)
array = [4, 5, 1, 3, 2]
target = 3
print(sequential_search(target, array))
4
이진 탐색
이진 탐색은 탐색 범위를 절반씩 좁혀가며 데이터를 탐색하는 방법이다. 순차 탐색과는 다르게 배열 내부의 데이터가 정렬된 상태여야만 사용 가능하다. 이진 탐색에는 탐색하고자하는 범위의 시작점, 끝점 그리고 중간점을 위치를 나타내는 변수로서 사용한다. 찾으려는 데이터와 중간점 위치에 있는 데이터를 반복적으로 비교해서 원하는 데이터를 찾는 것이 이진 탐색 과정이다. 한 번 확인할 때마다 확인하는 원소의 개수가 대략 절반씩 줄어든다는 점에서 시간 복잡도가 O(logN)이다.
1. 재귀함수를 이용한 이진 탐색
n = 10
target = 7
array = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
def binary_search(array, target, start, end):
if start > end:
return None
mid = (start + end) // 2
if array[mid] == target:
return mid
elif array[mid] > target:
return binary_search(array, target, start, mid - 1)
else:
return binary_search(array, target, mid + 1, end)
result = binary_search(array, target, 0, n - 1)
if result == None:
print("원소가 존재하지 않습니다.")
else:
print(result + 1)
4
2. 반복문을 이용한 이진 탐색
n = 10
target = 7
array = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
def binary_search(array, target, start, end):
while start <= end:
mid = (start + end) // 2
# 찾은 경우 중간점 인덱스 반환
if array[mid] == target:
return mid
# 중간점의 값보다 찾고자 하는 값이 작은 경우 왼쪽 확인
elif array[mid] > target:
end = mid - 1
# 중간점의 값보다 찾고자 하는 값이 큰 경우 오른쪽 확인
else:
start = mid + 1
return None
result = binary_search(array, target, 0, n - 1)
if result == None:
print("원소가 존재하지 않습니다.")
else:
print(result + 1)
4
파이썬 이진 탐색 라이브러리 bisect
- bisect_left(array, x): 정렬된 순서를 유지하면서 배열 array에 x를 삽입할 가장 왼쪽 인덱스를 반환
- bisect_right(array, x): 정렬된 순서를 유지하면서 배열 array에 x를 삽입할 가장 오른쪽 인덱스를 반환
from bisect import bisect_left, bisect_right
a = [1, 2, 4, 4, 8]
x = 4
print(bisect_left(a, x)) # 정렬된 순서를 유지하면서 배열 a에 x를 삽입할 가장 왼쪽 인덱스를 반환
print(bisect_right(a, x)) # 정렬된 순서를 유지하면서 배열 a에 x를 삽입할 가장 오른쪽 인덱스를 반환
2
4
- 값이 특정 범위에 속하는 데이터 개수 구하기
from bisect import bisect_left, bisect_right
# 값이 [left_value, right_value]인 데이터의 개수를 반환하는 함수
def count_by_range(a, left_value, right_value):
right_index = bisect_right(a, right_value)
left_index = bisect_left(a, left_value)
return right_index - left_index
a = [1, 2, 3, 3, 3, 3, 4, 4, 8, 9]
# 값이 4인 데이터 개수 출력
print(count_by_range(a, 4, 4))
# 값이 [-1, 3] 범위에 있는 데이터 개수 출력
print(count_by_range(a, -1, 3))
2
6
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
정렬 알고리즘
정렬(Sorting)이란?
데이터를 특정한 기준에 따라서 순서대로 나열하는 것을 의미한다.
선택 정렬 (Selection Sort)
데이터가 무작위로 여러 개 있을 때, 가장 작은 데이터를 선택해 앞으로 보내는 과정을 반복하는 정렬이다. 가장 작은 데이터를 선택해 맨 앞에 있는 데이터와 바꾸고, 다음으로 작은 데이터를 골라 앞에서 두 번째 데이터와 바꾸는 과정을 끝까지 반복해 데이터를 정렬한다.
선택 정렬을 파이썬으로 구현하면 다음과 같다.
# 배열의 원소를 오름차순으로 정렬
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
for i in range(len(array)):
min_index = i # 가장 작은 원소의 인덱스
for j in range(i+1, len(array)):
if array[min_index] > array[j]:
min_index = j
array[i], array[min_index] = array[min_index], array[i]
print(array)
선택 정렬은 다른 더 빠른 정렬 알고리즘들에 비해 비효율적인 면이 있다. 선택 정렬의 시간 복잡도는 이중 for문을 수행한다는 점에서 직관적으로 O(N²)임을 알 수 있다.
삽입 정렬 (Insertion Sort)
삽입 정렬은 특정 데이터를 적절한 위치에 삽입하여 정렬하는 알고리즘이다. 이는 특정 데이터의 앞까지 데이터들은 정렬되어 있다고 가정하고, 정렬된 데이터들 사이에서 적절한 위치를 골라 해당 데이터를 삽입하는 방식으로 진행된다.
삽입 정렬을 파이썬으로 구현하면 다음과 같다.
array = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
for i in range(1, len(array)):
for j in range(i, 0, -1):
if array[j] < array[j - 1]: # 한 칸씩 왼쪽으로 이동
array[j], array[j - 1] = array[j - 1], array[j]
else: # 자신보다 작은 데이터를 만나면 그 위치에서 멈춤
break
print(array)
삽입 정렬은 선택 정렬에 비해 실행 시간의 측면에서 더 효율적인 알고리즘으로 알려져 있고, 특히 데이터가 거의 정렬되어 있을 때 매우 빠르게 동작하는 특징이 있다.
삽입 정렬의 시간 복잡도는 이중 for문이 사용된 점을 보고 O(N²)임을 알 수 있지만 최선의 경우 O(N)을 가진다. 데이터가 거의 정렬되어 있는 상황이라면, 퀵정렬 알고리즘보다도 빠르게 동작한다.
퀵 정렬 (Quick Sort)
퀵정렬은 일반적으로 가장 많이 사용되는 알고리즘이자 대부분의 프로그래밍 언어 정렬 라이브러리의 근간이 되는 알고리즘이다. 기준 데이터(Pivot, 피벗)를 설정하고 그 기준보다 큰 데이터와 작은 데이터의 위치를 교환한 후, 리스트를 반으로 나누는 방식(분할)을 반복해 정렬을 진행한다.
파이썬으로 이를 구현하면 다음과 같다. 여기서 피벗을 정하는 방식은 리스트의 첫 번째 데이터를 피벗으로 정하는 호어 분할(Hoare Partition)을 바탕으로 한다.
array = [5, 7, 9, 0, 3, 1, 6, 2, 4, 8]
def quick_sort(array, start, end):
if start >= end: # 원소가 1개면 종료
return
pivot = start
left = start + 1
right = end
# 엇갈릴 때까지 반복
while left <= right:
# 피벗보다 큰 데이터를 찾을 때까지 반복
while left <= end and array[left] <= array[pivot]:
left += 1
# 피벗보다 작은 데이터를 찾을 때까지 반복
while right > start and array[right] >= array[pivot]:
right -= 1
if left > right: # 엇갈렸다면 작은 데이터와 피벗을 교체
array[right], array[pivot] = array[pivot], array[right]
else: # 엇갈리지 않았다면 작은 데이터와 큰 데이터를 교체
array[left], array[right] = array[right], array[left]
# 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬 수행
quick_sort(array, start, right - 1)
quick_sort(array, right + 1, end)
quick_sort(array, 0, len(array) - 1)
print(array)
퀵정렬의 평균 시간 복잡도는 O(NlogN)이다. 데이터를 절반씩 분할하며 진행한다고 가정하면, 기하급수적으로 분할 횟수가 감소함을 알 수 있다. 퀵정렬은 데이터가 무작위로 입력되는 경우 빠르게 동작할 가능성이 높지만, 이미 데이터가 정렬되어 있는 경우에는 최악의 경우 O(N²)의 시간 복잡도를 가지며 느리게 동작한다. 하지만, 대부분의 정렬 라이브러리는 피벗값 설정 로직을 추가해 최악의 경우에도 O(NlogN)의 시간 복잡도를 보장하므로 크게 신경쓰지 않아도 된다.
계수 정렬 (Count Sort)
계수 정렬 알고리즘은 특정 조건(모든 데이터가 0을 포함한 양의 정수로 표현될 수 있어야 함)에 부합해야 한다는 제약이 있지만, 조건이 갖춰지면 매우 빠르게 동작하는 정렬 알고리즘이다. 데이터의 모든 범위를 담을 수 있는 크기의 리스트를 선언해, 데이터를 직접 세어 리스트에 기록한 후 정렬한다. 그러므로 가장 큰 데이터와 가장 작은 데이터의 차이가 작을 때(1,000,000을 넘지 않을 때) 효과적으로 사용할 수 있다.
# 모든 원소 값은 0보다 크거나 같음
array = [7, 5, 9, 0, 3, 1, 6, 2, 9, 1, 4, 8, 0, 5, 2]
# 모든 원소 값이 0으로 초기화 된 모든 범위를 포함하는 리스트 생성
count = [0] * (max(array) + 1)
for i in range(len(array)):
count[array[i]] += 1 # 각 데이터에 해당하는 인덱스의 값 증가
# 리스트의 정보를 확인하여 그 값만큼 출력 반복
for i in range(len(count)):
for j in range(count[i]):
print(i, end=' ')
모든 데이터가 양의 정수(0을 포함한)로 표현될 수 있다면, 데이터의 개수가 N, 데이터 중 최댓값이 K일 때, 최악의 경우에도 O(N + K)의 시간 복잡도를 보장한다. 공간 복잡도 역시 O(N + K)이다. 또한, 데이터의 크기가 한정되어 있고 많이 중복되어 있을수록 유리하다.
정렬 알고리즘 비교
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
DFS(Depth-First Search) & BFS(Breadth-First Search)
그래프 탐색
하나의 자료구조로서 그래프(Graph)는 데이터와 데이터 사이의 관계를 잘 표현해주는 자료구조이다. 그래프는 기본적으로 데이터가 담기는 노드(Node)와 데이터 사이를 연결하는 간선(Edge)으로 이루어져있다. 노드는 정점(Vertex)이라고도 불린다.
그래프 탐색은 하나의 노드를 시작으로 다수의 노드를 방문하는 것을 말하며, 간선으로 연결되어 있는 두 노드는 서로 ‘인접’해 있다고 한다.
DFS (Depth-First Search, 깊이 우선 탐색)
DFS는 그래프의 깊은 부분을 우선적으로 탐색하는 알고리즘이다. 특정한 경로로 먼저 최대한 깊숙이 탐색한 후, 다시 돌아와 다른 경로를 탐색한다. DFS는 스택이나 재귀함수를 활용해 구현하며, 기본 순서는 다음과 같다.
탐색 시작 노드를 스택에 삽입하고 방문 처리한다.
스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리한다. 방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다.
2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.
위와 같은 그래프를 DFS로 탐색 시, 방문 순서는 1 - 2 - 7 - 6 - 8 - 3 - 4 - 5 이다. 파이썬으로 이를 구현하면 다음과 같다.
def dfs(graph, v, visited):
# 현재 노드 방문
visited[v] = True
print(v, end=' ')
# 현재 노드의 인접 노드를 재귀적으로 방문
for i in graph[v]:
if not visited[i]:
dfs(graph, i, visited)
# 각 노드가 연결된 정보 표현
graph = [
[],
[2, 3, 8],
[1, 7],
[1, 4, 5],
[3, 5],
[3, 4],
[7],
[6, 8],
[1, 7]
]
# 각 노드의 방문 정보 표현
visited = [False] * 9
# DFS 함수 호출
dfs(graph, 1, visited)
BFS (Breadth-First Search, 너비 우선 탐색)
BFS는 가까운 노드부터 탐색하는 알고리즘이다. BFS는 큐 자료구조를 활용해 구현하는 것이 일반적이며 다음과 같은 절차로 이루어진다.
탐색 시작 노드를 큐에 삽입하고 방문 처리를 한다.
큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다.
2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.
위 그래프를 BFS로 탐색하면 1 - 2 - 3 - 8 - 7 - 4 - 5 - 6 이다. 이를 파이썬으로 구현하면 다음과 같다.
from collections import deque
# BFS 메서드 정의
def bfs(graph, start, visited):
queue = deque([start])
visited[start] = True
# 큐가 빌 때까지 반복
while queue:
# 큐에서 하나의 원소를 뽑아 출력
v = queue.popleft()
print(v, end=' ')
# 아직 방문하지 않은 인접 노드들을 큐에 삽입하고 방문 처리
for i in graph[v]:
if not visited[i]:
queue.append(i)
visited[i] = True
# 각 노드가 연결된 정보 표현
graph = [
[],
[2, 3, 8],
[1, 7],
[1, 4, 5],
[3, 5],
[3, 4],
[7],
[6, 8],
[1, 7]
]
visited = [False] * 9
bfs(graph, 1, visited)
Reference
gimtommang11 자료구조 그래프
3. DFS & BFS
본 포스팅은 ‘안경잡이 개발자’ 나동빈 님의 저서
‘이것이 코딩테스트다’와 그 유튜브 강의를 공부하고 정리한 내용을 담고 있습니다.
-
-
-
-
'이것이 취업을 위한 코딩 테스트다'로 코딩 테스트 시작하기
“코딩 테스트를 떨어졌다!”
최근에 가장 많이 맞닥뜨린 상황이다. 인턴, 정규직 지원은 아니지만 간절히 원하던 교육 프로그램에 지원할 때마다 항상 2차 코테를 넘어서지 못하고 있다… 비전공자의 입장이다 보니 코테에 대한 제대로 된 지식 없이 약간의 문제 풀이 연습으로 도전한 게 화근인 듯싶다. 처음에는 1차 코테를 통과하길래 솔직히 “오? 되나?” 싶었다. 그러나 다른 분들 후기를 보고 나니 코테는 시간 복잡도나 알맞은 자료구조 선택 같이 깊게 생각하고 코드에 녹여야 할 요소가 많았다.
사실, 교육 프로그램 입과를 목표로 했기 때문에 코테 준비사항에서도 큰 요구가 없는 것 같았다. ‘자료구조’나 ‘알고리즘’이 중요한 과목임을 알지만 ‘들어가서 공부하자’ 싶었다. 특히, 비전공자니까 혼자 공부하는 것보단 어딘가에 소속돼서 교육 받아야 한다는 생각이 강했다. (함께하는 동료들도 만나야 시너지가 있으니까!) 하지만, 막상 코테를 접하니 교육 프로그램 입과 문제에서도 (모든 문제가 그렇지는 않았지만) ‘자료구조’, ‘알고리즘’에 대한 이해가 필요했다. 그렇게 입과에 떨어지고 점점 시간이 지나가다 보니, 내실이 부족해져 감을 느꼈다. 이전 동아리 동료들과 프로젝트를 하며 항상 재밌게 공부했던 프로그래밍인데, 시간이 지날수록 발전이 없구나… 서글퍼졌다.
그래서 내린 결론은 ‘나에게 집중하자!’이다. 지금은 공백에 대한 걱정이나 교육 프로그램에 의지하지말고 스스로 부족한 것에 집중하기로 결정했다. 특히나 나이에 관대한 곳이 IT니까 조급하지 말고 길게 보자고 다독였다. 그렇게 차근차근하다 보면 좋은 기회가 오지 않을까? :)
자료구조와 알고리즘에 한해서 현재 나의 상태를 보면, 전공도 부전공도 아니지만 컴퓨터공학과 수업을 통해 C언어로 자료구조 공부를 한 적이 있다. (친절하고 열정적이신 교수님 덕에 높은 집중력으로 A+를 받았었다.) 하지만, 지금 바로 구현할 수 있냐고 묻는다면 그렇지 않다. (특히, 그래프 단원 쪽에서 다익스트라나 벨만포드를 보고 경악했던 기억이 난다.) 그래도 과제를 하며 스택, 큐, 힙, 우선순위 큐 같은 여러 지식들에 익숙해졌으니까, 이제는 책을 보고 문제를 풀며 온전히 내 것으로 만들어 보고자 한다.
어떤 책으로 공부할까? 이전에 SW 마에스트로를 하던 동생에게 알고리즘 책으로 유명한 종만북을 추천받은 적이 있는데, 찾아보니 무지 어려워 보였다. C++을 배워본 적이 없으니까 나의 미천한 C 실력으로는 아직 무리라고 판단했다.
‘종만북’으로 유명한 구종만 님의 알고리즘 문제해결전략
그러던 중, 유튜브에서 자주 영상을 챙겨봤었던 ‘안경잡이 개발자’ 나동빈 님이 최근에 내신 저서 ‘이것이 취업을 위한 코딩 테스트다’를 발견했다. 책을 살 때는 항상 신중히 구매하는 편인데, 이 책은 알찬 콘텐츠 구성과 나동빈 님께 느끼는 신뢰가 있어서인지 큰 고민 없이 바로 구매했다. (이렇게 저항감 없이 구매하는 건 참 오랜만이다…)
나에게는 자료구조, 알고리즘뿐만 아니라 코딩 테스트의 일반적인 지식도 필요했는데, 이 책은 자료구조, 알고리즘에 대한 내용과 더불어 초보자 알고리즘 독학 로드맵, 최근 기업 코테의 동향까지 하나로 모아져 있었다. 또한, 파이썬이 익숙한 편이라 문제 풀이의 주 언어로 파이썬을 사용했다는 점도 매력적이었다. 거기에 유튜브 인강까지 매주 토, 일 진행한다고 하니 망설일 이유가 없었다.
이제 1강 들었는데 강의 길이가 꽤나 길었다. 그래도 꾸준히 공부한 내용을 기록하며 마지막에는 책에 대한 리뷰까지 행복하게 남기고 싶다. 알고리즘 푸는 것이 꽤나 즐겁다고 느끼는 요즈음이다. 부족한 부분 투성이지만 하나하나 나의 장점으로 만들어 가야겠다.
Touch background to close