프로그램이 실행되는 동안 사용하는 CPU, 메모리, 파일 핸들, 네트워크 소켓 등을 말함.
제한된 자원을 효율적으로 사용해야 비용 절감과 성능 최적화 가능.
자원 관리의 중요성:
자원의 불필요한 낭비를 줄이고, 프로그램의 성능과 안정성을 보장.
자원 누수(Resource Leak)를 방지하여 시스템의 안정성을 유지.
2. 싱글턴 패턴 (Singleton Pattern)
설명:
디자인 패턴의 하나로, 클래스의 단 하나의 인스턴스만 생성되도록 보장.
모든 클라이언트가 이 인스턴스를 공유하여 자원을 절약.
주로 전역 상태 관리, 공통된 작업 수행에 사용.
싱글턴 패턴의 장점:
메모리 효율성: 인스턴스를 하나만 생성하여 메모리 낭비 방지.
글로벌 접근성: 어디서든 같은 인스턴스를 참조 가능.
싱글턴 패턴의 주의점:
상태 관리: 싱글턴 객체가 상태를 가진다면, 다중 쓰레드 환경에서 데이터 무결성을 보장해야 함.
의존성 주입 사용 권장: 테스트와 확장성을 고려해, 직접 싱글턴을 참조하기보다 DI(Dependency Injection)를 사용하는 것이 좋음.
싱글턴 패턴 사용 예시:
데이터베이스 연결 관리: 하나의 연결 객체로 다수의 요청 처리.
로깅: 애플리케이션 전역에서 동일한 로깅 객체 사용.
설정 관리: 환경 설정 값을 하나의 객체로 관리.
Kotlin의 싱글턴 구현:
object Singleton { fun showMessage() { println("Hello from Singleton!") } } fun main() { Singleton.showMessage() }
3. Companion Object
설명:
Kotlin에서 클래스 내부에 포함된 특별한 객체.
클래스의 인스턴스 없이 멤버에 접근 가능.
자바의 static 키워드와 비슷한 역할.
사용 목적:
클래스 레벨에서 공통적으로 사용되는 함수나 변수 관리.
팩토리 메서드 구현 등.
예제:
data class UserDto(val username: String, val password: String) { companion object { fun encodePassword(password: String): String { return password.reversed() // 단순한 비밀번호 암호화 } } } fun main() { val encodedPassword = UserDto.encodePassword("12345") println("Encoded Password: $encodedPassword") }
4. 지연 초기화 (Lazy Initialization)
설명:
필요한 시점에 객체를 초기화하여 메모리와 성능을 최적화하는 기법.
Kotlin에서는 lazy 키워드를 사용하여 구현.
장점:
성능 개선: 객체가 실제로 필요할 때만 초기화하여 초기 로딩 속도 개선.
메모리 효율성: 불필요한 메모리 사용 방지.
예제:
class Image(val imageFile: String) { val thumbnail: String by lazy { println("Generating thumbnail...") "Thumbnail for $imageFile" } } fun main() { val image = Image("photo.jpg") println("Image loaded") println(image.thumbnail) // 이 시점에서 thumbnail 초기화 }
5. 인라인 함수 (Inline Function)
설명:
함수 호출 시, 함수 본문을 호출 지점에 직접 삽입하는 방식.
람다나 고차 함수에서 객체 생성을 피하기 위해 메모리 효율적으로 사용.
장점:
함수 호출 오버헤드 감소.
고차 함수에서 불필요한 객체 생성 제거.
단점:
함수 크기가 클 경우, 인라인 사용 시 컴파일된 바이트코드가 커져 프로그램 크기 증가.
예제:
inline fun compute(a: Int, action: () -> Unit): Int { action() return a * 2 } fun main() { val result = compute(10) { println("Action executed!") } println("Result: $result") }
6. 추가 학습이 필요한 주제
자원 관리 기법:
try-with-resources(Java)나 use(Kotlin) 같은 자원 자동 해제 기법.
파일, 네트워크 소켓, 데이터베이스 연결의 효율적인 관리.
멀티쓰레드 환경에서 자원 관리:
동기화(Synchronization)와 Lock, Semaphore 등의 동시성 제어 기법.
객체 풀(Object Pool):
자원을 효율적으로 재사용하기 위해 객체를 풀(Pool)로 관리하는 기법.
Dependency Injection (DI):
싱글턴 객체와 함께 사용하면 코드의 확장성과 유지보수성을 높일 수 있음.
성능 측정 도구:
자원 사용량을 분석하고, 최적화 가능성을 식별하는 도구(Ex: Android Profiler, JConsole).
7. 정리
기술
설명
장점
싱글턴 패턴
하나의 인스턴스만 생성하여 전역적으로 접근 가능.
메모리 절약, 전역 상태 관리 용이.
Companion Object
클래스의 정적 멤버를 관리하는 특별한 객체.
코드 간결화, 정적 함수/변수 관리.
지연 초기화
객체를 필요할 때 초기화하여 메모리와 성능 최적화.
초기 로딩 속도 개선, 불필요한 메모리 사용 방지.
인라인 함수
호출 시 함수 본문을 호출 지점에 삽입.
함수 호출 오버헤드 제거, 고차 함수 효율화.
결론
자원 관리는 프로그램의 효율성과 안정성을 높이는 핵심 요소입니다. 싱글턴, Companion Object, Lazy Initialization, Inline Function과 같은 기술들은 자원의 효율적 사용뿐 아니라 코드의 가독성과 유지보수성을 높이는 데 기여합니다. 추가 학습을 통해 동시성과 멀티쓰레드 환경에서 자원을 관리하는 방법을 익히면 더욱 견고한 시스템을 구축할 수 있습니다.
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
delay(1000)
println("CoroutineScope 코루틴")
}
5. 디스패처 (Dispatcher)
코루틴이 실행될 쓰레드를 결정.
Dispatchers.Main:
UI와 상호작용하거나 사용자 이벤트 처리에 적합.
Android 개발에서 많이 사용.
Dispatchers.IO:
네트워크 통신, 파일 입출력 등 I/O 작업에 최적화.
Dispatchers.Default:
CPU 집약적인 작업(데이터 처리, 복잡한 계산 등)에 적합.
Dispatchers.Unconfined:
제한되지 않은 디스패처. 실행 환경에 따라 동적으로 실행.
6. 쓰레드와 코루틴의 차이
쓰레드(Thread)
코루틴(Coroutine)
경량성
무겁고, 수십 개의 쓰레드 생성 시 성능 저하.
매우 가벼움. 하나의 쓰레드에서 수백만 개의 코루틴 생성 가능.
메모리 사용
각 쓰레드는 독립적인 스택 메모리를 가짐.
코루틴은 힙 메모리를 공유하며 필요 시만 메모리 사용.
교체 방식
운영체제가 컨텍스트 스위칭을 관리.
개발자가 소스 코드에서 작업 교체 시점을 명시적으로 설정.
사용 용도
주로 동시 작업을 처리하는 저수준 구현.
동시성과 비동기 작업을 처리하는 고수준 프로그래밍.
성능
다수의 쓰레드를 실행하면 메모리와 CPU 오버헤드 증가.
효율적. CPU 자원을 최소화하며 동시성 작업 처리.
오버헤드
쓰레드 생성과 종료, 컨텍스트 스위칭 비용 큼.
오버헤드가 적고, 효율적으로 스케줄링.
7. 예시 코드
1) 간단한 코루틴 실행
fun main() = runBlocking {
launch {
delay(1000)
println("첫 번째 코루틴")
}
println("메인 함수")
}
2) 여러 코루틴 실행
fun main() = runBlocking {
val job1 = async {
delay(2000)
"작업 1 완료"
}
val job2 = async {
delay(1000)
"작업 2 완료"
}
println(job1.await())
println(job2.await())
}
8. 추가 학습이 필요한 주제
코루틴 컨텍스트와 스코프:
CoroutineScope와 SupervisorJob의 역할 및 사용 방법.
에러 처리:
코루틴 내 예외 처리 방법 (try-catch, supervisorScope).
코루틴 채널 (Channels):
코루틴 간 데이터를 주고받는 통신 방식.
Flow:
Kotlin의 비동기 데이터 스트림 처리 도구.
테스트:
runBlockingTest와 같은 코루틴 기반 테스트 도구 활용.
9. 결론
코루틴은 효율적인 비동기 프로그래밍과 동시성 처리를 위한 강력한 도구입니다. Google에서 Android 개발에 적극 권장하며, 현대적인 비동기 처리를 위해 필수적인 기술로 자리 잡고 있습니다. 학습을 통해 코루틴의 다양한 사용법과 최적화 기법을 익히는 것이 중요합니다. 😊
**시간이 오래 걸리는 작업(예: 파일 다운로드, 네트워크 요청)**이 진행되는 동안 앱이나 프로그램이 멈추지 않고 다른 작업을 계속 실행.
동기적 프로그래밍과의 차이점:
**동기적 프로그래밍(Synchronous Programming)**은 작업을 순차적으로 실행하며, 이전 작업이 완료될 때까지 기다려야 다음 작업을 시작.
반면, 비동기 프로그래밍은 작업의 완료 여부와 관계없이 다른 작업을 병렬적으로 실행.
2. 비동기 프로그래밍의 필요성
효율적인 자원 사용:
I/O 작업(네트워크, 파일 시스템 등)에서 작업 완료를 기다리지 않고, 유휴 상태인 CPU를 활용 가능.
사용자 경험 개선:
긴 작업이 진행되는 동안 애플리케이션이 멈추거나 응답하지 않는 문제를 방지.
동시성 지원:
여러 작업이 동시에 실행되도록 설계하여 멀티태스킹 구현 가능.
3. 동기와 비동기의 차이
동기 프로그래밍
비동기 프로그래밍
작업 방식
작업이 순차적으로 실행됨.
작업이 병렬적으로 실행 가능.
작업 흐름
이전 작업이 끝나야 다음 작업이 실행됨.
작업 완료 여부와 관계없이 다른 작업을 실행 가능.
장점
구현이 단순하고 이해하기 쉬움.
응답성이 좋고, 자원 활용이 효율적.
단점
시간이 오래 걸리는 작업이 전체 흐름을 막을 수 있음.
구현 복잡도가 높아질 수 있음(콜백 지옥, 오류 처리 등).
사용 사례
계산, 순차적 처리 로직.
네트워크 요청, 대규모 데이터 처리, 사용자 인터페이스 응답.
4. 비동기 프로그래밍의 예시
예시 1: 동기 프로그래밍
// 동기 방식: 모든 작업이 순서대로 실행
System.out.println("1. 5GB 영상 다운로드 중...");
Thread.sleep(5000); // 5초 동안 대기
System.out.println("2. 메일 전송 중...");
Thread.sleep(2000); // 2초 동안 대기
System.out.println("3. 알림 전송 완료!");
출력:
1. 5GB 영상 다운로드 중...
2. 메일 전송 중...
3. 알림 전송 완료!
동기 방식에서는 앞선 작업이 끝날 때까지 다음 작업이 대기.
예시 2: 비동기 프로그래밍
// 비동기 방식: 각 작업이 병렬적으로 실행
System.out.println("1. 5GB 영상 다운로드 요청!");
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(5000); // 5초 동안 대기 (다운로드)
System.out.println("2. 영상 다운로드 완료!");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("3. 메일 전송 중...");
Thread.sleep(2000); // 2초 동안 대기
System.out.println("4. 알림 전송 완료!");
출력:
1. 5GB 영상 다운로드 요청!
3. 메일 전송 중...
4. 알림 전송 완료!
2. 영상 다운로드 완료!
작업이 병렬로 실행되어 응답성이 향상.
5. 비동기 프로그래밍을 활용한 생활 속 사례
웹 브라우저:
웹 페이지를 로드하는 동안 이미지나 추가 데이터를 백그라운드에서 다운로드.
모바일 앱:
사용자가 앱을 탐색하는 동안, 새로운 메시지나 알림을 비동기로 가져옴.
게임:
게임 플레이 중, 백그라운드에서 리소스 로드 또는 업데이트.
파일 업로드:
사용자가 다른 작업을 계속할 수 있도록 대용량 파일을 비동기적으로 업로드.
6. 비동기 프로그래밍의 장점과 단점
장점
단점
사용자 경험(UX) 개선: 응답성 향상 및 애플리케이션 정지 방지.
복잡한 구현: 콜백 지옥, 오류 처리 어려움.
리소스 효율성: CPU와 메모리 자원을 효과적으로 사용 가능.
디버깅 및 유지보수 어려움.
대규모 작업 처리: I/O 작업이나 네트워크 요청을 효율적으로 처리 가능.
동기 작업보다 설계 및 코드 작성에 더 많은 시간 필요.
7. 비동기 프로그래밍 구현 방식
콜백 (Callback):
특정 작업 완료 후 실행할 동작을 미리 정의.
단점: "콜백 지옥"으로 인해 코드 가독성이 떨어질 수 있음.
Promise/Future:
작업 완료 후 반환값을 처리하는 객체.
단점: 여러 Promise를 연결하면 복잡해질 수 있음.
Async/Await:
비동기 작업을 동기 코드처럼 읽기 쉽게 작성.
단점: 예외 처리를 적절히 구현해야 함.
8. 비동기 프로그래밍이 적합한 상황
네트워크 요청:
외부 API 호출, 데이터베이스 쿼리 등.
대용량 작업 처리:
파일 입출력, 대규모 데이터 처리.
사용자 인터페이스 응답성 개선:
UI 작업 중 애플리케이션 멈춤 방지.
결론
비동기 프로그래밍은 애플리케이션의 성능과 응답성을 높이는 데 매우 중요한 프로그래밍 기법입니다. 동기적 흐름에서 발생할 수 있는 병목 현상을 해결하고, 사용자가 더 나은 경험을 할 수 있도록 돕습니다. 하지만 구현의 복잡성이 증가할 수 있으므로, **상황에 맞는 패턴(콜백, Promise, Async/Await)**을 사용해 관리해야 합니다.
하나의 프로그램(프로세스)은 기본적으로 하나의 메인 쓰레드(fun main(0 메인함수)에서 실행되며, 이를 통해 모든 작업이 처리됨.
추가 쓰레드를 생성하면 여러 작업을 동시에 처리할 수 있어 **동시성(Concurrency)**을 구현할 수 있음.
2. 쓰레드의 기본 개념
프로세스(Process):
실행 중인 프로그램으로, 운영체제(OS)에서 메모리와 CPU를 할당받아 독립적으로 실행됨.
각 프로세스는 독립된 메모리 공간(코드, 데이터, 힙, 스택)을 가짐.
쓰레드(Thread):
프로세스 내에서 실행되는 작은 작업 단위.
스택 메모리를 독립적으로 사용하지만, 힙 메모리(공유 자원)는 다른 쓰레드와 공유.
쓰레드의 메모리 구조:
스택(Stack): 쓰레드마다 독립된 실행 컨텍스트를 저장.
힙(Heap): 모든 쓰레드가 공유하는 메모리 공간.
코드/데이터 영역: 프로그램의 실행 코드와 상수 저장.
3. 쓰레드의 특징
특징
설명
병렬 실행
여러 쓰레드가 동시에 실행 가능하여, 작업 처리 속도와 효율성을 높임.
스택 독립성
각 쓰레드는 독립된 스택 메모리를 가지므로, 실행 컨텍스트를 개별적으로 관리.
공유 자원 관리
쓰레드는 힙 메모리를 공유하므로, 공유 자원 접근 시 동기화(Synchronization)가 필요.
컨텍스트 스위칭
CPU가 여러 쓰레드 사이를 전환하며 실행. 전환 비용(오버헤드)이 발생.
4. 쓰레드의 필요성
동시성(Concurrency) 구현:
하나의 작업을 여러 단위로 나누어 동시에 처리.
예: UI 처리 중 파일 다운로드.
대기 시간 최소화:
시간이 오래 걸리는 작업(네트워크 요청, 파일 입출력 등) 동안 다른 작업을 중단 없이 실행.
프로그램 효율성 향상:
멀티코어 CPU에서 여러 쓰레드를 실행하면 자원을 효율적으로 활용 가능.
5. 쓰레드를 사용하는 주요 사례
게임:
캐릭터의 움직임, 효과음, 사용자 입력 처리를 동시에 수행.
멀티미디어 애플리케이션:
비디오 재생 중 오디오 출력과 자막 표시를 병렬로 실행.
네트워크 서버:
여러 클라이언트 요청을 동시에 처리.
경쟁 환경:
경마 프로그램처럼 여러 개체가 동시에 움직이고 결과를 경쟁.
6. 쓰레드의 구현
1) Kotlin에서 쓰레드 생성
fun main() {
thread(start = true) {
println("Thread 1: 실행 중")
}
thread(start = true) {
println("Thread 2: 실행 중")
}
}
2) 쓰레드 경쟁 예제
fun main() {
thread(start = true) {
for (i in 1..10) {
println("Thread 1: $i")
Thread.sleep(500) // 0.5초 대기
}
}
thread(start = true) {
for (i in 50..60) {
println("Thread 2: $i")
Thread.sleep(500) // 0.5초 대기
}
}
}
3) 쓰레드 동기화 문제 해결
여러 쓰레드가 공유 자원을 동시에 접근할 경우, 데이터 충돌 문제 발생 가능.
해결 방법: synchronized 키워드로 동기화 처리.
val lock = Any()
var sharedResource = 0
fun main() {
thread(start = true) {
synchronized(lock) {
for (i in 1..5) {
sharedResource++
println("Thread 1: $sharedResource")
}
}
}
thread(start = true) {
synchronized(lock) {
for (i in 1..5) {
sharedResource++
println("Thread 2: $sharedResource")
}
}
}
}
7. 쓰레드의 장단점
장점
단점
병렬 처리로 작업 처리 속도가 향상.
컨텍스트 스위칭으로 인한 성능 오버헤드 발생.
대기 시간이 긴 작업(I/O 작업)을 처리하면서 다른 작업을 실행 가능.
동기화 문제로 인해 데이터 충돌 및 데드락 발생 가능.
멀티코어 CPU 환경에서 성능 효율성을 극대화.
쓰레드 수가 많아지면 메모리 사용량 증가.
8. 추가 학습이 필요한 주제
코루틴 (Coroutine):
쓰레드보다 가볍고 효율적인 비동기 프로그래밍 방식.
Kotlin의 대표적인 비동기 처리 도구.
동기화(Synchronization):
공유 자원 접근 시 발생하는 문제를 해결하기 위한 기술.
Mutex, Semaphore, Volatile 등.
컨텍스트 스위칭(Context Switching):
쓰레드 간 전환 시 발생하는 비용과 효율성 최적화 방법.
멀티쓰레드 디버깅:
복잡한 쓰레드 환경에서의 오류 해결 방법.
Reactive Programming:
비동기 데이터를 처리하는 프로그래밍 패러다임. (Ex: RxJava, Project Reactor)
9. 결론
쓰레드는 동시성과 병렬성을 구현하기 위한 강력한 도구입니다. 하지만 쓰레드를 잘못 설계하면 데이터 충돌, 데드락 등 복잡한 문제가 발생할 수 있습니다. 쓰레드의 한계를 보완하기 위해 코루틴 같은 최신 기술도 함께 학습하면 더욱 효율적인 프로그래밍이 가능합니다.
📌 애플리케이션 전반에 걸쳐 여러 모듈이나 레이어에서 공통적으로 필요하지만, 특정 비즈니스 로직과 직접적으로 관련이 없는 기능을 의미합니다.
이러한 기능은 여러 곳에서 반복적으로 사용되며, 코드 중복을 초래하거나 모듈 간의 결합도를 높이는 원인이 될 수 있습니다.
같은 말로 횡단 관심사 라고 하며 여러 위치에서 공통적으로 사용되는 부가 기능이고 Filter가 나오게된 이유는 공통 관심사(Cross Cutting Concern)의 처리 때문이다.
요구사항 : 로그인 한 유저만 특정 API를 사용할 수 있어야 한다.
해결방법
언제나 핵심은 수정에 있다!
화면에서 로그인 하지 않으면 API를 사용하지 못하도록 막는다.
유저가 HTTP 요청을 마음대로 조작할 수 있다.
Controller에서 로그인 여부를 체크하는 Logic을 작성한다.
실제로는 인증이 필요한 모든 컨트롤러에 공통으로 로그인 여부를 체크해야 한다.
로그인 로직이 변경될 때 마다 로그인 여부를 체크하는 Logic 또한 변경될 가능성이 높다.
@RestController
@RequestMapping("/post")
public class PostController {
@PostMapping
public PostResponseDto create(PostCreateRequestDto request) {
// 로그인 여부 확인 로직
// 생성 로직
}
@PutMapping("/{postId}")
public void update(
@PathVariable Long postId,
PostUpdateRequestDto request
) {
// 로그인 여부 확인 로직
// 수정 로직
}
@DeleteMapping("/{postId}")
public void delete(@PathVariable Long postId) {
// 로그인 여부 확인 로직
// 삭제 로직
}
}
위와같이 여러가지 로직에서 공통으로 관심이 있는 부분을 공통 관심사 라고 한다.
로그인 여부 확인 로직 -> 공통 관심사 (인증 : 로그인)
공통 관심사는 로그인뿐만 아니라 더 큰범위를 의미한다.
Spring AOP를 활용할 수 있다.
Web과 관련된 공통 관심사는 Servlet Filter나 Spring Intercepter를 사용한다.
HttpServletRequest 객체를 제공하기 때문에 HTTP 정보나 URL 정보에 접근하기 쉽다.
ex) HTTP Header Cookie → 인증
ex ) 특정 URL의 요청은 인증을 할것이다. → URL 정보 필요
Spring AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)
📌 로깅, 보안, 트랜잭션 관리 등과 같은 횡단 관심사를 개별적인 Aspect(관점)로 모듈화할 수 있도록 합니다.
이를 통해 핵심 비즈니스 로직과 부가적인 로직을 분리하여 코드의 가독성과 유지보수성을 높일 수 있습니다.
반복되는 공통 작업(예: 로깅, 보안, 트랜잭션 관리)을 한 곳에 모아서 관리할 수 있게 도와준다. 예를 들어 모든 메서드 실행 전에 로그를 냄기는 경우, 각 메서드에 System.out.println()을 넣는 대신, AOP로 한번만 설정하면 알아서 모든 메서드에 적용된다.
Spring AOP의 주요 개념
Aspect(관점)
횡단 관심사를 정의한 모듈입니다. 예: 로그 기록, 트랜잭션 관리.
Join Point(조인 포인트)
애플리케이션 실행 과정에서 Aspect를 적용할 수 있는 지점입니다. 예: 메서드 호출, 예외 발생.
Advice(어드바이스)
Join Point에서 실행되는 작업(Aspect의 구체적인 동작)입니다. Advice는 다음과 같이 구분됩니다:
Before: 메서드 실행 전에 실행.
After: 메서드 실행 후에 실행.
Around: 메서드 실행 전후에 실행.
After Returning: 메서드가 정상적으로 반환된 후 실행.
After Throwing: 메서드 실행 중 예외가 발생한 후 실행.
Pointcut(포인트컷)
Advice가 적용될 Join Point를 정의하는 표현식입니다.
Weaving(위빙)
Aspect를 애플리케이션의 대상 객체에 적용하는 과정입니다. Spring AOP에서는 런타임 위빙이 일반적으로 사용됩니다.
Spring AOP 특징
Spring AOP는 프록시 기반으로 동작합니다.
런타임 기반 AOP를 지원하며, Java 동적 프록시나 CGLIB를 사용합니다.
메서드 호출 수준의 AOP를 제공합니다. (클래스 내부의 필드 변경 등에는 적용되지 않음)
Spring AOP 사용 방법
1. 의존성 추가 Spring AOP를 사용하려면 Maven 또는 Gradle에 관련 의존성을 추가합니다:
📌 Java Servlet에서 HTTP 요청과 응답을 중간에서 처리, 이를 기반으로 다양한 처리 작업을 수행하는 데 사용되는 Interface이다.
클라이언트 → 요청 → 필터 → 서블릿/JSP → 응답 → 필터 → 클라이언트
Filter Interface의 사용 사례
인증(Authentication) 및 권한 부여(Authorization):
특정 페이지에 접근하려는 클라이언트를 필터에서 확인하고, 인증되지 않은 사용자라면 로그인 페이지로 리디렉션.
로깅(Logging):
요청의 정보를 기록(예: 요청 URL, 클라이언트 IP, 요청 시간 등).
데이터 유효성 검사:
클라이언트의 요청 데이터를 확인하고, 잘못된 값이 있으면 필터 단계에서 차단.
응답 데이터 변환:
서버의 응답 데이터를 필터에서 압축하거나 특정 포맷(예: JSON)으로 변환.
보안 처리:
HTTP 요청의 헤더를 검사해 보안 위협(예: XSS, CSRF)을 탐지 및 차단.
jakarta.servlet.Filter
Filter Interface를 Implements하여 구현하고 Bean으로 등록하여 사용한다.
Servlet Container가 Filter를 Singleton 객체로 생성 및 관리한다.
주요 메서드
init()
Filter를 초기화하는 메서드이다.
Servlet Container가 생성될 때 호출된다.
default ****method이기 때문에 implements 후 구현하지 않아도 된다.
doFilter()
Client에서 요청이 올 때 마다 doFilter() 메서드가 호출된다.
doFilter() 내부에 필터 로직(공통 관심사 로직)을 구현하면 된다.
WAS에서 doFilter() 를 호출해주고 하나의 필터의 doFilter()가 통과된다면
Filter Chain에 따라서 순서대로 doFilter() 를 호출한다.
더이상 doFilter() 를 호출할 Filter가 없으면 Servlet이 호출된다.
destroy()
필터를 종료하는 메서드이다.
Servlet Container가 종료될 때 호출된다.
default method이기 때문에 implements 후 구현하지 않아도 된다.
Servlet Filter 구현
Filter 구현체
요청 URL을 Log로 출력하는 Filter
@Slf4j
public class CustomFilter implements Filter {
@Override
public void doFilter(
ServletRequest request,
ServletResponse response,
FilterChain chain
) throws IOException, ServletException {
// Filter에서 수행할 Logic
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
log.info("request URI={}", requestURI);
// chain 이 없으면 Servlet을 바로 호출
chain.doFilter(request, response);
}
}
doFilter() 는 더 이상 호출할 Filter가 없다면 Servlet을 호출한다.
ServletRequest 는 기능이 별로 없어서 대부분 기능이 많은 HttpServletRequest 를 다운 캐스팅 하여 사용한다.
Filter 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean customFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
// Filter 등록
filterRegistrationBean.setFilter(new CustomFilter());
// Filter 순서 설정
filterRegistrationBean.setOrder(1);
// 전체 URL에 Filter 적용
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
setFilter()
등록할 필터를 파라미터로 전달하면 된다.
setOrder()
Filter는 Chain 형태로 동작한다.
즉, 실행될 Filter들의 순서가 필요하다.
파라미터로 전달될 숫자에 따라 우선순위가 정해진다.
숫자가 낮을수록 우선순위가 높다.
addUrlPatterns()
필터를 적용할 URL 패턴을 지정한다.
여러개 URL 패턴을 한번에 지정할 수 있다.
규칙은 Servlet URL Pattern과 같다.
filterRegistrationBean.addUrlPatterns("/*")
모든 Request는 Custom Filter를 항상 지나간다.
Spring이 제공하는 URL Pattern은 Servlet과 다르게 더욱 세세하게 설정할 수 있다.
Servlet Filter 정리
Filter를 사용하려면 Filter Interface를 Implements 하여 구현한다.
구현한 Filter를 Bean으로 등록 한다.
HTTP 요청이 오면 doFilter() 메서드가 호출된다.
ServletRequest는 기능이 별로 없어서 HttpServletRequest로 다운 캐스팅 해야한다.
@Getter
public class User {
// 식별자
private Long id;
// 이름
private String name;
// 나이
private Integer age;
// 로그인 ID
private String userName;
// 비밀번호
private String password;
public User(Long id, String name, Integer age, String userName, String password) {
this.id = id;
this.name = name;
this.age = age;
this.userName = userName;
this.password = password;
}
}
User 클래스 설계
로그인 요청 DTO
// 필드 전체를 매개변수로 가진 생성자가 있어야 @ModelAttribute가 동작한다.
@Getter
@AllArgsConstructor
public class LoginRequestDto {
// 사용자가 입력한 아이디
@NotBlank
private final String userName;
// 사용자가 입력한 비밀번호
@NotNull
private final String password;
}
일반적으로 DTO는 클라이언트의 요청혹은 서버의 응답이기 때문에 변경되면 안된다.
final을 사용하여 불변 객체로 관리한다.
Java17 버전에 나온 record를 사용할 수 있다.
로그인 응답 DTO
@Getter
public class LoginResponseDto {
private final Long id;
// 이외 응답에 필요한 데이터들을 필드로 구성하면 된다.
// 필요한 생성자
public LoginResponseDto(Long id) {
this.id = id;
}
}
유저 조회 응답 DTO
@Getter
public class UserResponseDto {
// 유저 식별자
private final Long id;
// 유저 이름
private final String name;
public UserResponseDto(Long id, String name) {
this.id = id;
this.name = name;
}
}
HomeController
@Controller
@RequiredArgsConstructor
public class HomeController {
private final UserService userService;
@GetMapping("/home")
public String home(
// @CookieValue(required = true) 로 필수값(default) 설정
// required = false 이면 필수값 아님.
@CookieValue(name = "userId", required = false) Long userId, // String->Long 자동 타입컨버팅
Model model
) {
// 쿠키에 값이 없으면 로그인 페이지로 이동 -> 로그인 X
if(userId == null) {
return "login";
}
// 실제 DB에 데이터 조회 후 결과가 없으면 로그인 페이지로 이동 -> 일치하는 회원정보 X
UserResponseDto loginUser = userService.findById(userId);
if(loginUser == null) {
return "login";
}
// 정상적으로 로그인 된 사람이라면 View에서 사용할 데이터를 model 객체에 데이터 임시 저장
model.addAttribute("loginUser", loginUser);
// home 화면으로 이동
return "home";
}
}
View에서는 model 객체에 담겨있는 loginUser 를 활용하여 변수로 사용할 수 있다.
UserController
@Controller
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/login")
public String login(
@Valid @ModelAttribute LoginRequestDto request,
HttpServletResponse response // 쿠키값 세팅에 필요
) {
// 로그인 유저 조회
LoginResponseDto responseDto = userService.login(request.getUserName(), request.getPassword());
if (responseDto.getId() == null) {
// 로그인 실패 예외처리
return "login";
}
// 로그인 성공 처리
// 쿠키 생성, Value는 문자열로 변환하여야 한다.
Cookie cookie = new Cookie("userId", String.valueOf(responseDto.getId()));
// 쿠키에 값 세팅 (expire 시간을 주지 않으면 세션쿠키가 됨, 브라우저 종료시 로그아웃)
// Response Set-Cookie: userId=1 형태로 전달된다.
response.addCookie(cookie);
// home 페이지로 리다이렉트
return "redirect:/home";
}
@PostMapping("/logout")
public String logout(
HttpServletResponse response
) {
Cookie cookie = new Cookie("userId", null);
// 0초로 쿠키를 세팅하여 사라지게 만듬
cookie.setMaxAge(0);
response.addCookie(cookie);
// home 페이지로 리다이렉트
return "redirect:/home";
}
}
Cookie 이름(Key)은 userId , 값(Value)은 회원 index 값을 담아둔다.
Set-Cookie: userId=1
만료 시간을 지정하지 않으면 세션 쿠키로 만들어진다.
브라우저 종료 전까지 userId 가 모든 요청 헤더의 Cookie에 담겨서 전달된다.
로그아웃 기능
새로운 Cookie를 userId = null로 생성한다.
setMaxAge(0) 설정으로 만료시킨다.
응답에 만료된 쿠키를 담아 보낸다.
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public LoginResponseDto login(String userName, String password) {
// 입력받은 userName, password와 일치하는 Database 조회
Long index = userRepository.findIdByUserNameAndPassword(userName, password);
return new LoginResponseDto(index);
}
public UserResponseDto findById(Long id) {
return userRepository.findById(id);
}
}
UserRepository
@Repository
public class UserRepository {
private static final User USER1 = new User(1L, "wonuk", 100, "wonuk", "1234");
private static final User USER2 = new User(2L, "wonuk2", 200, "wonuk2", "2345");
private static final List<User> USERS = Arrays.asList(USER1, USER2);
public Long findIdByUserNameAndPassword(String userName, String password) {
return USERS.stream()
.filter(user -> user.getUserName().equals(userName) && user.getPassword().equals(password))
.map(User::getId)
.findFirst()
.orElse(null);
}
public UserResponseDto findById(Long id) {
return USERS.stream()
.filter(user -> Objects.equals(user.getId(), id))
.map(user -> new UserResponseDto(user.getId(), user.getName()))
.findFirst()
.orElse(null);
}
}
📌 동적인 웹 페이지를 생성하기 위해 사용되는 도구이며 템플릿을 기반으로 정적인 부분과 동적인 데이터를 결합하여 HTML, XML 등 의 문서를 생성하는 역할을 수행한다.
우리가 흔히 말하는 UI(User Interface)를 만들며, SSR(Server Side Rendering)에 사용된다.
정적 출력은 [안내 메시지]와 같은 모든 사용자에게 동일하게 보이는 데이터를 말하고 동적 데이터는 [사용자 이름]과 같은 사용자, 상황마다 다른 결과를 표현하는 데이터다.
아래와 같이 서버에서 보내는 데이터를 html쪽에서 정의하는 방식으로 접근이 편리하게 만들어준다.
<!-- 정적 출력 -->
<h1>안녕하세요, 방문해주셔서 감사합니다!</h1>
<!-- 동적 출력 -->
<p>안녕하세요, <span th:text="${name}">이름</span>님!</p>
[실제 출력]
<!-- 정적 출력 -->
<h1>안녕하세요, 방문해주셔서 감사합니다!</h1>
<!-- 동적 출력 -->
<p>안녕하세요, <span>철수</span>님!</p>
템플릿 엔진이 나온 이유
자바 코드로 HTML을 만들어 내는 것이 아닌 HTML 문서에 동적으로 변경해야 하는 부분만 자바 코드를 넣을 수 있다면 더 편리하다.
대표적인 템플릿 엔진
Thymeleaf
Spring과 통합이 잘 되어있다.
다양한 기능을 포함하고 있다.
JSP(Java Server Pages)
예전엔 많이 사용했으나, 현재 안 쓰는 추세
FreeMarker
Velocity
Mustache
MVC 패턴 개요
📌 Servlet이나 JSP만으로 비지니스 로직과 View Rendering 까지 모두 처리하면 너무 많은 역할을 하게 되고 유지보수가 굉장히 어려워져서(책임이 너무 많음) 고안된 패턴이다. Web Application은 일반적으로 MVC(Model View Controller) 패턴을 사용한다.
Servlet 문제점
화면을 그리는 View 영역과 비지니스 로직이 Servlet 하나에 모두 섞여있다.
책임을 너무 많이 가지고 있다.
Servlet 동작 순서
사용자가 Client(브라우저)를 통해 서버에 HTTP Request 즉, API 요청을 한다.
요청을 받은 Servlet 컨테이너는 HttpServletRequest, HttpServletResponse객체를 생성한다.
설정된 정보(URL, HTTP Method)를 통해 어떠한 Servlet에 대한 요청인지 찾는다.
해당 Servlet에서 service 메서드를 호출한 뒤 브라우저의 요청 Method에 따라 doGet() 혹은doPost() 등의 메서드를 호출한다.
서버에서 응답을 생성한 뒤 HttpServletResponse객체에 응답을 담아 Client(브라우저)에 반환한다.
위의 내용을 설명하면, 클라에서 서버에 계속해서 같은 내용을 반복 요청한다는 것, 그런데 그걸 받는 컨트롤러가 하나에만 배치되면 되는데 다 각각 배치된다는 것.. (카페 알바에게 아메리카노 3잔시켰는데 다른 주문도 많은데 알바 3명이서 1개씩 만들고 있다...)
HttpServletResponse 객체를 사용하는 경우가 적다.(JSP에서 모두 해결하기 때문)
HttpServletRequest와 HttpServletResponse는 Test 코드를 작성하기도 매우 힘들다.
공통 기능이 추가될수록 Controller에서 처리해야 하는 부분들이 많아진다.
공통 기능 처리
모든 컨트롤러에서 공통으로 적용되는 기능을 뜻한다.
ex) Log 출력, 인증, 인가 등
공통 기능을 Method로 분리하여 각각의 컨트롤러에서 사용하면 되는것 아닌가요? 라고 생각이 가능하지만, 공통 기능으로 만들어놓은 Method 또한 항상 중복적으로 호출이 필요합니다. 또한, 사람인 개발자가 작업하다보면 Method를 호출하는 일을 깜빡 할수도 있고 Method가 많아지면 많아질수록 Controller의 책임이 점점 커진다. 이를 위해 아래의 대안이 나왔다.
프론트 컨트롤러 패턴
📌 Servlet(Controller)이 호출되기 전에 공통 기능을 하나의 Servlet에서 처리해주는 패턴이다. 프론트 컨트롤러(Servlet) 하나에 모든 클라이언트측 요청이 들어온다.
프론트 컨트롤러의 역할
모든 요청을 하나의 프론트 컨트롤러가 받는다.
공통 기능을 처리한다.
요청을 처리할 수 있는 Controller를 찾아서 호출한다.(Controller Mapping)
프론트 컨트롤러를 제외한 나머지 컨트롤러는 Servlet을 사용하지 않아도 된다.
일반 Controller들은 HttpServlet을 상속( HTTP 프로토콜 기반의 요청과 응답을 처리하기 위해 제공되는 클래스 )받거나, @WebServlet(URL 매핑 여기서는 url과 servlet과의 매핑을 말한다.)을 사용하지 않아도 된다. = 프론트 컨트롤러가 처리함
@WebServlet의 설명이 이해가 안될 수도 있는데, url은 말 그대로 주소 식별자다. url에 보내는 요청을 보고 어느 servlet에서 처리를 할지 정하고 해당 servlet에 배치(매핑)하는 과정은 완전별개의 문제이다.
프론트 컨트롤러 의문점
프론트 컨트롤러를 사용하면 모든 컨트롤러에서 같은 형태의 응답을 해야하는가?
위 그림처럼 공통 처리 로직에 모든 컨트롤러가 연결되기 위해서는 모든 컨트롤러가 return 하는 결과의 형태가 동일해야 한다.
하지만, Controller 마다 로직이나 응답해야하는 결과는 당연히 다를테고 응답을 동일하게 맞추려고 한다면 해당 애플리케이션은 확장성, 유지보수성을 잃는다.
공통 로직에서 응답별로 퍼즐을 다시 하나하나 처리할 수 있으나 공통 부분의 책임이 너무 커지게된다. 또한, 컨트롤러에서 반환되는 결과가 달라지면 공통처리 부분의 변경또한 불가피하다.
그래서 아래의 대안이 또 나왔다.
어댑터 패턴
📌 어댑터 패턴은 호환되지 않는 인터페이스를 가진 클래스들이 함께 동작할 수 있도록 중간에서 변환을 도와주는 디자인 패턴입니다. 주로, 이미 존재하는 코드와 새로운 코드 사이의 호환성 문제를 해결하는 데 사용됩니다.
컨트롤러들은 동일한 인터페이스를 구현하도록 하고 해당 인터페이스와 공통 로직 사이에 어댑터를 두어 유연하게 만든다. 서로 다른 인터페이스를 갖는 두 클래스를 연결해주는 패턴이다.