https://github.com/VSFe/Tech-Interview
면접 대비 질문 정리 깃허브를 참고해서 CS를 대비해 보려고 한다.
스터디원 4명과 함께 질문들을 각자 정리하고, 파트를 맡아 발표를 진행했다.
오늘의 주제는 Java Spring
#1 ~ 10
까지는 Java 언어
에 대해,
#11 ~ 18
까지는 Spring
의 이론적인 부분에 대한 질문을 정리했다.
JRE
(Java Runtime Environment) 의 핵심 구성 요소.
Java bytecode
가 다른 플랫폼에서 실행되는 것을 가능하게 해주는 녀석.
Java application
↔ 사용 중인 현재 OS, 하드웨어
사이에서 abstraction layer
역할을 한다.
abstraction layer
로써의 역할이 자바 언어가 플랫폼 독립적
(JVM과 함께라면 어떤 시스템에서도 실행될 수 있는) 인 성격을 가지게 하는 것임.JVM 의 내부 동작 과정과 특징
궁금증) 그럼,
JVM
이랑Java Interpreter
랑 같은 것인가??
아니다. 다른 개념임.
JVM 을 자바 bytecode 를 실행시킬 수 있는 하나의 큰 환경으로 보고, 그 과정에서 (bytecode를 로드하고 검증하고 실행하고) 쓸 수 있는 많은 기법 중에 하나를 interpretation
으로 봐야 함.
따라서 Java Interpreter
를 쓰는 방법도 있다는 것이지 둘이 같이 개념이 절대 아님.
** 최근의 JVM은 interpretation
방법 보다 bytecode 변환과 실행에 Just-In-Time (JIT) compilation
기법을 많이 사용한다고 함.
런타임에 동적으로 bytecode → native machine code 변환시킬 수 있는 최적화 기법이기 때문에 성능 향상에 많이 도움이 된다고 한다.
JVM 에 파이썬, C++ 와 같은 언어를 올리는 것도 가능하다.
단, language-specific implementations 또는 translator 의 도움이 필요하다.
GraalVM Native Image
라는 기술을 사용하여 JVM 에서 돌 수 있는 코드로 컴파일 시켜준다.LLVM C++ Backend
기술을 사용해서 C++ code를 LLVM Intermediate Representation code
으로 만들고, 후에 LLVM JIT compiler
로 최적화하는 과정을 거친다.어떻게 이런게 가능한가?
JVM의 유연성(flexibility) 과 extensibility(확장성) 덕에 가능한 것임.
원래 JVM은 bytecode를 실행하기 위해서만 만들어졌지만, 아키텍쳐 자체는 다른 언어들도 이 매커니즘을 이용해서 사용할 수 있도록 설계되었다.
예를 들어) JVM 은 stack-based bytecode 형식 위에서 돌아가기 때문에 무슨 언어든 간에 Java bytecode 로만 변환될 수 있다면 JVM에서 실행시킬 수 있다.
그렇다면 JVM 계열 언어를 일반적인 방법(JVM을 사용하지 않고) 으로 컴파일 할 수 있나?
물론이다.
JVM 계열의 언어들은 그저 Write once, Run anywhere
원칙을 지키기 위해 bytecode로 변환된 코드를 JVM으로 실행하는 것 뿐이지 컴파일 기술이 달라질 수 있다.
예를 들어)
Ahead-of-Time (AOT) Compilation
Virtual Machine 을 사용하면 얻을 수 있는 장단점은 무엇이 있나
Java와 함께 가상 머신(JVM)을 사용하면 얻을 수 있는 장점:
(앞서 계속 언급됐다싶이) 플랫폼 독립성: Java 코드를 바이트코드로 컴파일하면 호환 가능한 JVM이 있는 모든 플랫폼에서 실행할 수 있음.
Write once, Run anywhere
원칙 자바 언어를 유연하게 만듦.GC를 통한 메모리 관리
보안
JIT(Just-In-Time) 컴파일
단점:
Overhead 비용 발생
메모리 사용량:
시작 시간의 약간의 지연
네이티브 코드와의 Interaction 제한
Reverse Engineering의 위험성
JVM ↔ 내부 실행 프로그램 의 관계를 부모 ↔ 자식 프로세스 관계로 비유 가능?
어느 정도 프로세스간의 관계로 비유될 수는 있겠지만, 완전히 정확한 비유는 아니다.
비유의 괜찮은 부분:
비유에서 JVM은 부모 프로세스 역할. JVM은 실행 환경을 제공하고 Java 프로그램 실행에 필요한 리소스를 관리하고 담당한다.
그리고 이러한 JVM 내에서 실행되는 Java 프로그램은 자식 프로세스에 비유되는데, Java 프로그램은 JVM의 문맥에서 실행되며, 실행에 필요한 서비스와 기능은 JVM이 제공한다.
그러나 한계점:
독립성:
부모-자식 프로세스 관계에서 프로세스끼리는 서로 독립적으로 실행되는 별개의 개체다.
각 자식 프로세스는 고유한 메모리 공간을 갖고 다른 자식 프로세스와 독립적으로 실행되는데, JVM과 Java 프로그램은 긴밀하게 상호작용 한다. Java 프로그램은 JVM 없이는 존재하거나 실행할 수 없으며, 심지어 같은 메모리 공간과 런타임 환경을 공유하기 때문에 이런 점은 맞지 않음.
직접 제어권:
부모-자식 프로세스에서 부모 프로세스는 자식 프로세스를 제어하고 통신 메커니즘을 통해 상호 작용할 수 있음. 하지만 JVM에서 실행되는 Java 프로그램 누가 누구를 직접 제어하는 관계가 아니고 통신 메커니즘을 통해 JVM과 통신을 할 수 없음.
final
키워드의 쓰임:
상수를 선언, 변수를 변경 불가능하게 만들거나 메서드 재정의를 방지하는 데 사용된다.
final
키워드를 사용하는 장점은 다양한 보장을 제공한다는 점이 아닐까 싶음.
상수: final
키워드로 변수를 선언하면 값이 초기화된 후에는 변경할 수 없는 상수가 됨.
즉, 프로그램 실행 중에 수정되지 않아야 하는 값을 정의하는 데 유용함.
final int MY_CONSTANT = 10;
불변성: final
키워드로 객체 참조 변수를 선언하면 초기화 된 후에는 해당 참조를 다른 객체를 가리키도록 변경할 수 없다.
이렇게 함으로써 변경할 수 없는 객체를 생성하는 데 도움이 되고 따라서 스레드 안전성과 코드 이해의 용이성과 같은 여러 이점이 있음.
final String myString = "Hello";
메서드 재정의 방지: final
키워드로 메서드를 선언하면 하위 클래스에서 재정의 되는 것을 방지한다.
즉, 메서드의 동작이 모든 하위 클래스에서 일관되게 유지되도록 보장하고자 할 때 유용함.
public class Parent {
public final void doSomething() {
// 구현
}
}
public class Child extends Parent {
// 컴파일 오류: Parent의 final 메서드 재정의 불가!
// public void doSomething() { ... }
}
정리하자면 final
키워드를 적절하게 사용함으로써,
그렇담 당연히 컴파일 과정에서도 차이가 있을 것 같다
맞다. 컴파일러에게 추가적인 정보를 제공하여 특정 최적화를 수행하고 특정 규칙을 강제할 수 있게 함.
상수 보장:
final
키워드로 변수를 선언하면 컴파일러는 이를 상수로 취급해서, 상수 접기(constant folding
)라는 최적화를 수행하여 컴파일러가 표현식을 평가하고 코드 전체에서 변수를 해당 값(ex. 10)으로 대체함.
이러한 최적화를 통해 중복된 조회를 제거하고 성능을 개선할 수 있습니다.
final int MY_CONSTANT = 10;
int result = MY_CONSTANT * 5; // 컴파일러가 MY_CONSTANT를 값 (10)으로 대체함.
값의 불변 보장:
final
키워드로 선언된 참조 변수는 단 한 번만 값을 할당할 수 있도록 컴파일러가 보장함. 만약에 변수에 다시 값을 할당하려고 하면 컴파일 오류가 발생.
final String myString = "Hello";
// 컴파일 오류: 불변 변수 'myString'에 값을 할당할 수 없음!
myString = "World";
컴파일러는 메서드 디스패치를 최적화하고 하위 클래스에서 재정의를 확인하지 않고도 상위 클래스의 메서드가 항상 호출되도록
할 수 있음.
정리하자면,
이러한 컴파일 점검과 최적화는 컴파일러가 잠재적인 문제를 빨리 잡아내고
코드 품질을 향상
시키며 더 효율적인 바이트코드를 생성
할 수 있도록 도와준다고 할 수 있겠다.
인터페이스와 추상 클래스 모두 클래스에 대한 계약을 정의
하고 하위 클래스에서 구현해야 할 메서드 집합을 지정
하는 데 사용된다.
하지만 차이점을 아는게 중요.
static
또는 final
로 선언하지 않아도 된다.정리하면,
인터페이스는 계약을 정의하는 데 더 중점을 둔 반면에, 추상 클래스는 부분적인 구현을 제공하는 데 중점을 둔다.
특정 기본 클래스에 강하게 결합되지 않고
여러 클래스가 공통 동작
을 공유해야 하는 상황에서는 인터페이스가 선호되고
하위 클래스가 특정 메서드를 구현하도록 강제
하면서 기본 구현을 제공
하고자 하는 경우에는 추상 클래스가 더 적합합니다.
** 그런데 Java 8 이후부터는 인터페이스에도 구현 메서드 쓸 수 있지 않나?
맞다. 수업 시간에도 배웠듯이 ,Default 메서드
가 추가됨으로써 구현부 있는 메서드 정의가 가능해짐.
public interface MyInterface {
// Abstract method
void doSomething();
// Default method 직접 구현부
default void doSomethingElse() {
System.out.println("스터디 준비 왤케 오래 걸림");
}
}
따라서, 이미 해당 인터페이스를 구현하고 있는 클래스들의 구현을 바꿀 필요 없이 새로운 기능을 추가한 것.
(이미 구현한 클래스들은 default 메서드를 @Override
한 것. 새롭게 구현하는 클래스들은 재정의 할 필요가 없음)
그런데 자바에서 클래스는 단일 상속, 인터페이스는 다중 상속으로 한 이유가 있을까?
정답은 자바의 설계 목표와 언어 철학이라고 생각하는게 맞다.
자바의 핵심 설계 원칙 중 하나는 언어를 simple, clean, easy to learn
하게 유지하는 것이라고 함.
추상 클래스에 다중 상속을 허용하면 클래스가 어떤 부모의 기능을 상속하는지와 같은 복잡한 상황이 발생할 수 있으니 예방 차원에서 컷. **(”다이아몬드 문제” 라고 함)
**다이아몬드 문제: 클래스가 같은 메서드 이름을 가진 여러 클래스로부터 상속받을 때 모호성이 발생하는 상황.
인터페이스는 주로 클래스가 따라야 할 계약을 정의하는 데 사용됨. 그렇기 때문에 이 경우엔 다중 상속을 허용해도 모호하거나 복잡한 상황이 발생하지 않음.
자바의 클래스에 대해 단일 상속을 강제하고 인터페이스에 대해 다중 상속을 허용하는 결정은,
간결성, 유연성, 유지보수 가능성 사이의 균형을 유지하며,
개발자들이 견고하고 관리 가능한 코드 구조를 구축하도록 장려한다고 함.
프로그램 실행 중에 클래스의 구조를 분석
하고, 클래스의 멤버 변수, 메서드, 생성자 등을 동적으로 조사, 검색하고 호출
할 수 있는 기능을 말합니다.
쉽게 말하면, 리플렉션은 프로그램이 자기 자신을 조사하고 수정하는 능력을 제공하는 기술입니다.
이런 기능을 어디에 주로 사용하나?
동적 클래스 로딩: 실행 중에 동적으로 클래스를 로딩하여 객체를 생성할 수 있기 때문에,
런타임에 어떤 것을 사용할지 결정되는 클래스나 패키지를 사용할 수 있게 해준다.
객체의 메타데이터 접근: 클래스의 이름, 필드, 메서드, 상위 클래스, 인터페이스 등과 같은 메타데이터에 접근하여 클래스에 대한 정보를 얻을 수 있으며.
프레임워크 개발: 리플렉션은 자바 프레임워크와 라이브러리의 구현에서 많이 사용된다고 한다.
예를 들어, Spring 프레임워크는 리플렉션을 이용하여 클래스를 검색하고, 의존성 주입(DI)을 처리하며, 다양한 설정 정보를 분석한다고 함.
참고) 리플렉션을 사용하려면 java.lang.reflect
패키지에 있는 Class
, Field
, Method
등의 클래스를 활용. 이러한 클래스들을 사용해서 클래스의 구조를 조사하고, 필드 값을 가져오거나 설정하며, 메서드를 호출할 수 있음.
보안상의 이슈가 있을 가능성이 높아 보이는데 실제로 그런가?
만약 그렇다면, 방지할 수 있는 방법에는 어떤게 있을까?
Yes. 주로 다음과 같은 이유로 보안상 이슈가 발생함.
리플렉션을 사용하면 일반적인 접근 제어자(private, protected)를 우회하여 private 멤버에 접근하거나, protected 멤버를 하위 클래스가 아닌 다른 클래스에서 접근하는 것이 가능해진다.
이로 인해 객체의 상태를 예기치 않게 변경하거나, 보안 취약점이 발생할 수 있음.
자바의 보안 기능 중 일부는 리플렉션을 통해 우회될 수 있습니다.
예를 들어, 클래스 로딩 시 보안 검사를 우회하여 안전하지 않은 클래스를 로드하는 것이 가능합니다.
리플렉션을 사용하여 클래스의 메타데이터에 접근하면, 민감한 정보(예: 비밀번호, 암호화 키 등)가 노출될 수 있습니다.
그럼 방지 방법은?
아예 사용을 막거나 권한을 주거나:
보안 상 중요한 클래스나 메서드에 대해서는 리플렉션을 사용하지 못하도록 하거나, 특정 권한이 있는 사용자만 리플렉션을 사용할 수 있도록 제한할 수 있다.
추가 보안 검사 SecurityManager
사용:
SecurityManager를 이용하여 보안 검사를 추가로 수행하면 리플렉션 기능의 사용을 제한할 수 있다고 함.
재검증:
리플렉션으로 동적으로 클래스를 로딩하기 전에, 클래스를 검증하여 신뢰할 수 있는지 확인.
방지 방법이 애초에 덜 사용하던가, 사용할 때 조심하던가 정도이기 때문에 사용할 때는 신중히 결정하는게 필요해 보임.
Static Class:
static class
는 중첩 클래스(inner class)의 한 종류.
Inner class는 다른 클래스 내부에 정의되는 클래스로, 해당 외부 클래스의 멤버와 메서드에 쉽게 접근할 수 있음.
static class
는 외부 클래스의 인스턴스와 상관없이 존재할 수 있으며, 주로 외부 클래스와 강하게 연관된 로직을 구현할 때 사용한다.
public class OuterClass {
// static inner class
public static class StaticInnerClass {
// ...
}
// 인스턴스 멤버 변수와 메서드
private int x;
// ...
}
static inner class는 외부 클래스의 static 멤버에 접근할 수 있지만, 외부 클래스의 instance 에는 직접 접근할 수 없음.
따라서 static inner class의 인스턴스를 생성하려면 외부 클래스의 인스턴스 없이 아래와 같이 생성한다.
// 외부 클래스의 인스턴스 없이 static inner class 인스턴스 생성
OuterClass.StaticInnerClass innerObj = new OuterClass.StaticInnerClass();
Static Method:
static method
는 클래스의 인스턴스 생성 없이 클래스 이름을 통해 직접 호출할 수 있는 메서드이다.
일반적인 메서드와는 달리, static method
에서는 인스턴스 멤버(변수, 메서드)에 직접 접근할 수 없다.
그 이유는 static method
는 특정 인스턴스와 연관되지 않고 클래스 레벨에서 독립적으로 동작해야 하기 때문.
public class MyClass {
private static int staticVariable;
private int instanceVariable;
public static void staticMethod() {
// static method에서는 instanceVariable에 접근할 수 없음
// this.instanceVariable; // 오류 발생
System.out.println("This is a static method.");
}
public void instanceMethod() {
// 인스턴스 메서드에서는 staticVariable에 접근할 수 있음
System.out.println("스터디 준비 시간 너무 오래걸림: " + staticVariable);
System.out.println("하하하: " + this.instanceVariable);
}
}
요약하면, static class
는 다른 클래스 내에 중첩된 클래스로, 인스턴스 생성 없이도 사용할 수 있으며 주로 외부 클래스와 강하게 연관된 구조를 표현할 때 사용.
반면에 static method
는 클래스 레벨에서 독립적으로 동작해야 하는 기능을 구현할 때 사용되며, static class와 마찬가지로 클래스 인스턴스 생성 없이 클래스 이름으로 직접 호출할 수 있음.
Static 키워드를 사용하면 얻을 수 있는 장점, 주의해야 할 점
클래스 레벨에서 접근 가능:
해당 클래스의 인스턴스 생성 없이도 클래스 이름을 통해 직접 접근할 수 있기 때문에 클래스 레벨에서 독립적인 기능을 제공할 수 있음.
메모리 공간 절약:
static
멤버는 프로그램이 실행될 때 메모리에 한 번만 할당되며, 클래스의 인스턴스마다 중복되어 생성되지 않음.
유틸리티 메서드 구현:
수학 연산 등과 같이 특정 인스턴스와 관련되지 않고 독립적으로 동작해야 하는 유틸리티 메서드를 구현할 때 유용하다고 함.
주의해야 할 제약점과 단점:
상태 공유 문제:
static
멤버는 클래스의 모든 인스턴스에서 공유되기 때문에 여러 곳에서 수정하는 경우, 의도치 않은 상태 변경이 발생해서 디버깅 지옥에 빠질 수 있음.
Thread Safety 문제:
static
멤버에 여러 스레드가 동시에 접근하는 경우 sync(동기화) 문제가 발생할 수 있음.
상속 안됨:
static
멤버는 상속에 영향을 주지 않고, 서브클래스에서 static
메서드를 오버라이딩할 수 없음.
컴파일 과정에서 static 은 어떻게 처리될까?
프로그램 실행 중에 발생하는 예기치 않은 상황을 나타내는 객체.
이러한 상황은 프로그램이 올바르게 처리되지 않거나 예상치 못한 동작(프로그램 중단 등)을 수행할 수 있다.
자바에서 예외는 두 가지 주요 유형
으로 나뉨.
Checked Exception (확인된 예외):
컴파일러에 의해 확인되고 처리되어야 하는 예외.
Exception 클래스의 하위 클래스로 나타나며, 주로 외부 리소스 접근이나 파일 입출력(IOException) 같은 작업에서 발생할 수 있음.
개발자가 이러한 예외들을 처리하는 코드를 작성해야만 컴파일이 가능.
Unchecked Exception (미확인 예외 또는 런타임 예외):
컴파일러가 확인하지 않으며, 개발자가 명시적으로 처리를 요구하지 않는 상황.
RuntimeException 클래스의 하위 클래스로 분류되며, 배열의 범위를 초과하는 경우(IndexOutOfRange), 0으로 나누는 경우와 같이 프로그래머의 실수로 인해 발생할 수 있는 예외들.
대표적으로 NullPointerException, ArithmeticException 등.
예외가 발생하면 자바는 해당 예외를 처리하는 코드를 찾아가며 예외 발생 지점에서부터 호출된 메서드들을 거슬러 올라가면서 catch 블록
에 해당하는 예외 처리 코드를 찾는다.
만약 catch 블록이 없으면 예외는 프로그램을 종료시키는데, 이 때 예외 정보와 함께 오류 메시지가 출력됨.
예외 처리 방법은 크게 3가지가 있음.
첫번째, try-catch 블록
사용.
try 블록에는 예외가 발생할 수 있는 코드를 작성하고, catch 블록에는 예외를 처리하는 코드를 작성.
finally 블록은 선택적으로 사용할 수 있으며, 예외가 발생하든 아니든 반드시 실행되어야 하는 코드를 작성.
ex)
public class ExceptionExample {
public static void main(String[] args) {
try {
int result = divide(10, 0);
System.out.println("리절트: " + result);
} catch (ArithmeticException e) {
System.out.println("에러: Cannot divide by zero.");
} finally {
System.out.println("항상 실행되는 블록");
}
}
public static int divide(int num1, int num2) {
return num1 / num2;
}
}
이 예시 에서는 0으로 나누는 경우에 ArithmeticException
이 발생할 수 있다.
catch 블록에서 이를 처리했고, finally 블록은 항상 실행.
두번째, throws 키워드
사용.
throws 키워드는 메서드 선언부에 사용되며, 메서드에서 예외를 직접 처리하지 않고 해당 예외를 메서드를 호출한 곳으로 던진다.
던져진 예외는 해당 메서드를 호출한 상위 메서드에서 처리해야 함.
ex)
public void someMethod() throws SomeException {
// 여기에 예외가 발생할 수 있는 코드
// 발생하면 직접 처리하지 않고 SomeException 으로 던짐.
}
// 메서드를 호출한 곳에서 예외를 처리해야 함
try {
someMethod();
} catch (SomeException e) {
// 던져진 예외 처리
}
세번째, Custom Exception (사용자 정의 예외)
기본적으로 제공되는 예외 외에도 개발자가 자신만의 예외 클래스를 만들어서 사용할 수 있음.
사용자 정의 예외를 만들 때는 Exception 또는 RuntimeException 클래스를 상속받아 새로운 예외 클래스를 정의하면 된다.
ex)
public class MyCustomException extends Exception {
// custom exception 정의
}
if (someCondition) {
// custom exception 으로 예외 던짐
throw new MyCustomException("새롭게 정의된 예외처리 클래스로 던져");
}
try {
// 예외가 발생할 수 있는 코드
} catch (MyCustomException e) {
// MyCustomException 예외를 처리하는 코드
}
이러한 예외처리. 성능에 부담이 갈까?
간다!
예외가 발생하는 상황은 정상적인 프로그램 실행 흐름에서 벗어나는 경우이므로, 예외 처리는 추가적인 오버헤드를 발생시킬 수 있다.
그러나 대부분의 경우 예외 처리의 성능 영향은 미미하며, 코드의 가독성과 유지보수성을 향상 시키는데 더 큰 가치가 있기 때문에 사용한다. 또 예외 처리가 없을 때 발생하는 심각한 버그와 불안정성을 생각하면 쓰는게 맞다.
하지만 그렇다고 해서 예외 처리를 무시하거나 최적화하지 않아서는 안됨.
특히 예외 처리가 반복적으로 많은 수의 예외를 발생시키거나, 빈번하게 실행되는 루프 안에 있는 경우에는 확실히 성능에 영향을 줄 수 있다.
예외 처리 성능을 개선하는 방법:
예외를 피하도록 미리 체크하고 코드를 짜자 : 맞는 말이지만 너무 당연한 말이라 패스.
Checked Exception 사용 최소화:
Checked Exception은 예외를 처리하는 것이 강제되기 때문에 성능에 영향을 줄 수 있다.
성능에 민감한 비즈니스 로직에서는 Checked Exception의 사용을 최소화하고, 대신 Unchecked Exception을 사용하는 것을 고려할 수 있음
→ (예외처리가 필요하지 않은데 하라고 하는 경우 또는 다른 방법으로 오류를 처리하는게 좋은 경우)
캐싱:
반복적인 예외를 처리해야 하는 상황이 있다면, 예외를 캐싱하여 재사용하는 방법 고려.
예외 처리 위치 최적화:
예외 처리 코드를 적절한 위치에 두어 성능을 개선할 수 있다.
예를 들어, 반복문 안에서 발생하는 예외는 반복문 밖으로 이동시키는 등의 최적화를 고려.
멀티스레드 환경에서 공유 데이터에 대한 동기화를 제공하는 방법 중 하나.
멀티스레드 환경에서 여러 스레드가 공유 데이터에 접근할 때, 동시에 데이터를 수정하면 예기치 않은 결과가 발생할 수 있음.
이런 문제를 해결하기 위해 스레드 간의 동기화가 필요하며, 이를 위해 synchronized
키워드를 사용한다.
synchronized
키워드를 사용하면 두 가지 주요 목적을 달성할 수 있음.
synchronized
키워드를 추가하여 해당 메소드의 모든 코드 블록에 대한 동기화를 제공. 이렇게 하면 여러 스레드가 해당 메소드를 호출할 때 동시에 접근하지 않고, 순차적으로 실행될 수 있다.public synchronized void synchronizedMethod() {
// Critical section
// 이 메소드 내의 코드는 한 번에 하나의 스레드만 실행.
}
synchronized
키워드를 사용하여 해당 블록에 대한 동기화를 제공할 수도 있다. 이 방법은 메소드 전체가 아닌 특정 부분만 동기화 해야 할 때 유용.public void someMethod() {
// Non-critical section
// 동기화가 필요하지 않은 코드
synchronized (this) {
// Critical section
// 동기화가 필요한 코드
}
// Non-critical section
// 동기화가 필요하지 않은 코드
}
정리하자면,
synchronized
키워드를 사용하면 잠재적으로 발생할 수 있는 경쟁 상태나 데이터 불일치 문제를 막을 수 있지만, 락을 얻고 해제하는 overhead 가 있으므로 필요한 부분만 동기화하고 과도하게 사용하지 않는 것이 중요.
또한 자바 5부터는 java.util.concurrent
패키지에 있는 더 효율적인 동기화 기능들을 사용하는 것이 권장된다고 함.
예를 들어 ReentrantLock
과 ReadWriteLock
등을 활용하여 세밀한 컨트롤이 가능한 동기화를 구현할 수 있음.
synchronized
키워드의 사용 위치에 따른 의미 변화
메소드와 블록에 적용되는 경우를 알아보자.
메소드 선언부에 synchronized
키워드를 사용하면 해당 메소드 전체가 동기화된다.
이 경우 해당 메소드를 호출하는 모든 스레드는 메소드의 코드 블록을 순차적으로 실행해야 하고,
이로 인해 여러 스레드가 동시에 메소드를 실행하지 않으므로, 공유 데이터의 일관성을 보장할 수 있다.
하지만 이 방식은 메소드 전체에 락이 걸리기 때문에 성능 저하가 발생할 수 있는 것이 단점.
public synchronized void synchronizedMethod() {
// 여러 스레드가 이 부분에 접근하면 순차적으로 실행됨.
}
synchronized
:synchronized
키워드를 특정 코드 블록에 적용하면 해당 블록에 대한 동기화를 수행한다.
이렇게 하면 블록 내부의 코드만 동기화되며, 나머지 부분은 동기화의 영향을 받지 않습니다.
따라서 성능상의 이점이 있습니다.
public void someMethod() {
// Non-critical section
// 동기화가 필요하지 않은 코드
synchronized (this) {
// 여러 스레드가 이 부분에 접근하면 순차적으로 실행됨.
}
// Non-critical section
// 동기화가 필요하지 않은 코드
}
이러한 블록 동기화는 메소드 전체를 동기화하는 것보다 세밀한 컨트롤이 가능.
예를 들어, 특정 조건이 충족되었을 때만 동기화가 필요한 경우, 블록 동기화를 사용하여 해당 부분만 동기화할 수 있다.
효율적인 코드 작성 측면에서
Synchronized
키워드를 사용해도 될까?
우선 키워드를 쓰는 목적이자 장점)
1. 스레드 안전성을 보장:
synchronized
를 사용하면 여러 스레드 간의 데이터 경합(데이터 불일치) 문제를 방지할 수 있다.
이를 통해 스레드 안전성을 보장하여 동시에 여러 스레드가 접근해도 데이터의 일관성을 유지할 수 있음.
이어서 단점)
2. 동기화 오버헤드 발생:
락을 획득하고 해제하는 과정에 오버헤드가 발생, 이로 인해 성능 저하가 발생할 수 있음.
특히, 여러 스레드가 동시에 접근하는 경우 락을 얻기 위해 대기해야 하므로 처리 속도가 저하될 수 있다.
**대안 존재: synchronized
가 블록 또는 메소드에 대한 기본적인 동기화 메커니즘으로 유용하지만, 자바 5부터는 java.util.concurrent
패키지에서 더 효율적인 동시성 유틸리티들을 제공.
ReentrantLock
, ReadWriteLock
, ConcurrentHashMap
등을 활용하면 더 세밀한 동기화가 가능하고 성능도 향상될 수 있음.
ReentrantLock
:ReentrantLock
은 synchronized
와 비슷한 동기화 메커니즘을 제공하는데, 더 세밀한 컨트롤이 가능. ReentrantLock
은 락을 획득하고 해제하는데 명시적인 방법(lock 객체 생성)을 사용하며, 락을 획득할 수 없을 때 대기하는 기능도 제공함.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
Lock lock = new ReentrantLock();
public void someMethod() {
lock.lock();
try {
// 동기화가 필요한 코드
} finally {
lock.unlock();
}
}
ReadWriteLock
:ReadWriteLock
은 읽기와 쓰기 동작에 대한 동기화를 구분하여 제공.
읽기 연산은 여러 스레드가 동시에 수행할 수 있지만, 쓰기 연산은 단 하나의 스레드만 허용한다.
따라서 읽기 연산이 빈번하고 쓰기 연산이 상대적으로 적은 경우에 ReadWriteLock
을 사용하면 성능을 향상시킬 수 있다.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
public void readMethod() {
readWriteLock.readLock().lock();
try {
// 읽기 동기화가 필요한 코드
} finally {
readWriteLock.readLock().unlock();
}
}
public void writeMethod() {
readWriteLock.writeLock().lock();
try {
// 쓰기 동기화가 필요한 코드
} finally {
readWriteLock.writeLock().unlock();
}
}
Semaphore
:Semaphore
은 특정 리소스에 대한 접근을 제한하는데 사용되는 동기화 기법입.
Semaphore
은 정해진 개수의 허용 스레드 수를 가지고 있으며, 허용 스레드가 더 이상 없는 경우 대기하도록 하여 주로 리소스 풀 관리 등에 사용된다고 함.
import java.util.concurrent.Semaphore;
Semaphore semaphore = new Semaphore(5); // 5개의 허용 스레드 수
public void someMethod() {
try {
semaphore.acquire(); // 락 획득, 허용 스레드가 없는 경우 대기
// 동기화가 필요한 코드
} catch (InterruptedException e) {
// 예외 처리
} finally {
semaphore.release(); // 락 해제
}
}
CountDownLatch
:CountDownLatch
는 특정 스레드가 특정 작업을 완료하기 전에 다른 스레드가 기다리도록 할 때 사용된다.
특정 횟수의 작업 완료 시까지 대기하다가 모든 작업이 완료되면 스레드가 실행되도록 함.
import java.util.concurrent.CountDownLatch;
CountDownLatch latch = new CountDownLatch(3); // 3개의 작업 완료 대기
public void someMethod() {
try {
// 동기화가 필요한 코드
} finally {
latch.countDown(); // 작업이 완료될 때마다 카운트 다운
}
}
// 메인 스레드에서 작업 완료 대기
try {
latch.await();
// 모든 작업이 완료되면 이 부분이 실행됨
} catch (InterruptedException e) {
// 예외 처리
}
정리하자면,
synchronized
는 단순한 동기화에 유용하며, 더 세밀한 동기화가 필요한 경우에는 대안을 찾아보는 것이 좋다.
Thread Local
에 대해 알아보자.
Thread Local은 자바에서 스레드 간에 데이터를 공유하지 않고, 각 스레드 별로 독립적으로 유지하고자 할 때 사용하는 기능.
즉, 동일한 데이터를 여러 스레드가 공유하지 않고 스레드마다 독립적으로 가지도록 하는 메커니즘이다.
스레드 간에 공유되는 전역 변수나 인스턴스 변수를 사용하면 스레드 안전성을 위해 위와 같은 방법으로 동기화를 해야하고, 동기화를 하면 성능 저하가 발생할 수 있다.
이런 상황에서 Thread Local을 사용하면 각 스레드 별로 자신만의 독립적인 변수를 사용할 수 있으며, 스레드 안전성을 보장하면서 동기화 오버헤드를 피할 수 있다.
Thread Local
의 )
ThreadLocal<String> threadLocalVariable = new ThreadLocal<>();
threadLocalVariable.set("Value for 7번 문제");
String value = threadLocalVariable.get();
threadLocalVariable.remove();
예를 들어, 웹 애플리케이션에서 사용자 인증 정보를 Thread Local을 이용해 저장하면, 각각의 요청을 처리하는 스레드가 독립적으로 사용자 인증 정보를 가지고 처리할 수 있음.
이렇게 되면 스레드 간의 정보 누수나 인증 정보가 다른 스레드와 공유되는 상황을 방지할 수 있다.
주의점)
사용 후에는 데이터를 명시적으로 삭제해야 함.
또한, 과도한 사용은 메모리 누수로 이어질 수 있으므로, 적절하게 사용하는 것이 중요.
자바 8에서 소개된 기능으로, 컬렉션(List, Map …)의 요소들을 처리하고 조작하는 기능을 제공하는 API.
Stream은 데이터의 흐름을 나타내며, 간결한 함수형 프로그래밍 스타일을 지원하여 컬렉션 처리를 더욱 쉽고 간결하게 만들어준다.
기존의 컬렉션 처리 방식은 주로 반복문을 사용하는 것이었는데, Stream은 이를 대체하고 데이터의 흐름을 다루는데 적합한 선언적인 방식으로 작성할 수 있도록 해준다.
→ 코드를 더욱 간결하고 가독성 있게 만들 수 있다.
Stream의 특징, 동작 방식)
Stream은 사실 컬렉션 뿐 아니라 배열, I/O 자원, 함수 등 다양한 데이터 소스로부터 생성할 수 있다.
중간 연산과 최종 연산:
Stream은 중간 연산과 최종 연산으로 구분된다.
중간 연산은 Stream을 반환하며, 연속적으로 체인을 이루어 연산을 수행.
최종 연산은 Stream을 닫고, 실제 연산이 수행되는 단계.
지연 실행:
지연 실행(lazy evaluation)을 지원.
최종 연산이 호출될 때까지 중간 연산은 실제로 수행되지 않는다. 이를 통해 불필요한 연산을 피할 수 있음.
병렬 처리:
Stream은 병렬 처리를 자동으로 지원함.
데이터가 충분히 크고 병렬 처리가 가능한 경우, Stream API를 사용하여 멀티코어 프로세서의 성능을 최대한 활용할 수 있다.
**병렬 처리에 대해서 더 알아보자)
병렬 처리는 멀티코어 프로세서를 활용하여 작업을 여러 개의 스레드로 나누어 동시에 처리하는 것을 의미한다.
이로 인해 대용량 데이터를 더 빠르게 처리할 수 있음.
Stream은 병렬 처리를 사용하기 위해 parallel()
메서드를 제공.
parallel()
메서드를 호출하면 스트림의 데이터 처리가 병렬적으로 수행되고 성능이 향상된다.
ex)
import java.util.Arrays;
import java.util.List;
public class ParallelStreamExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 일반 Stream 사용
int sumOfSquares = numbers.stream()
.map(n -> n * n) // 각 요소를 제곱
.reduce(0, Integer::sum); // 합계 구하기
System.out.println("일반 스트림 사용: " + sumOfSquares);
// -------------------------------------------------------------------------------------------------------------------------------------------------------------
// 병렬 Stream 사용
int sumOfSquaresParallel = numbers.parallelStream()
.map(n -> n * n) // 각 요소를 제곱 (병렬 처리)
.reduce(0, Integer::sum); // 합계 구하기
System.out.println("병렬 스트림 사용: " + sumOfSquaresParallel);
}
}
하지만, 병렬 처리를 사용할 때의 주의점)
데이터가 적다면 오히려 병렬 처리 오버헤드 때문에 성능을 저하시킬 수 있으므로, 대용량 데이터에 사용하는 것이 적합.
병렬 처리를 수행할 때 스레드 안전성을 유지해야 함.
즉, 공유 자원에 접근할 때 적절한 동기화를 고려해야 한다.
병렬 처리는 각 요소의 순서에 영향을 주지 않으므로, 순서가 중요한 작업에는 주의해야 함.
Stream의 일반적인 사용 예시 ex)
import java.util.Arrays;
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sumOfEvenNumbers = numbers.stream()
.filter(n -> n % 2 == 0) // 중간 연산: 짝수만 필터링
.mapToInt(Integer::intValue) // 중간 연산: Integer를 int로 변환
.sum(); // 최종 연산: 합을 계산
System.out.println("Sum of even numbers: " + sumOfEvenNumbers);
}
}
코드 설명) numbers 리스트 → Stream으로 변환한 후 → 중간 연산인 filter와 mapToInt를 이용해 필요한 처리를 수행하고 → 최종 연산인 sum()을 호출하여 짝수의 합을 계산.
Stream과 for loop(반복문) 의 성능 비교
일반적으로,
Stream은 코드를 간결하고 가독성 있게 만들어주지만, 반복문에 비해 일부 상황에서는 성능이 더 낮다.
Stream의 성능이 더 느린 이유)
객체 생성 + 메서드 호출 오버헤드:
Stream API를 사용하면 중간 연산과 최종 연산에 대해 많은 객체 생성과 메서드 호출이 발생.
지연 실행:
중간 연산이 최종 연산이 호출될 때까지 실제로 수행되지 않음.
(실제로 호출되는 시점에서 중간연산을 진행하기 때문에 더 이득일 것 같았지만) 만약, 반복되는 코드로 인해 여러 번 같은 연산이 호출되는 상황이라면 반복문보다 성능이 떨어질 수 있다.
병렬 처리 오버헤드:
병렬 처리를 위한 추가적인 오버헤드가 발생할 수 있음.
하지만 대부분의 경우 병렬 처리가 필요한 대용량 데이터를 다루는 상황에서는 Stream의 성능이 훨씬 우수.
Stream
에서 사용할 수 있는 함수형 인터페이스에 대해 알아보자.
Stream에서 사용할 수 있는 함수형 인터페이스는 주로 중간 연산과 최종 연산에서 사용된다.
함수형 인터페이스는 하나의 추상 메서드만을 가지고 있으므로 람다 표현식이나 메서드 참조를 사용하여 간결하게 함수를 정의할 수 있다.
Predicate
매개변수로 받은 객체를 조건에 따라 평가하여 참(true) 또는 거짓(false)을 반환하는 함수형 인터페이스.
주로 filter() 메서드와 함께 사용하여 요소를 걸러내는데 활용.
Function<T, R>
주로 map() 메서드와 함께 사용하여 요소를 변환하는데 사용하는 함수형 인터페이스.
Consumer
입력값을 받아서 소비하는 함수형 인터페이스.
주로 forEach() 메서드와 함께 사용하여 각 요소를 소비하는데 사용됨.
Supplier
아무런 입력값을 받지 않고 결과값을 제공하는 함수형 인터페이스.
주로 generate() 메서드와 함께 사용하여 스트림의 요소를 생성하는데 사용.
**이런 함수형 인터페이스들은 자바 8부터 람다 표현식과 메서드 참조와 함께 소개되었다고 함.
외부 변수를 사용할 때,
final
키워드를 붙여서 사용하는 상황에 대하여
외부 변수를 람다 표현식이나 익명 클래스에서 사용할 때, 해당 변수는 사실상 final 상수로 취급된다.
이는 자바 8 이전의 익명 클래스에서도 적용되었던 규칙으로, 자바 8에서는 람다 표현식에서도 같은 규칙이 적용되는 것.
왜 final 상수로 취급되는가?
람다 표현식은 외부 범위의 변수를 가져와 사용할 수 있다.
하지만 람다 표현식은 무명의 클래스로 컴파일되고, 이 클래스의 인스턴스는 그 외부 변수의 복사본을 저장하게 된다.
따라서 람다 표현식에서 사용되는 외부 변수는 상수로 취급되어야만 하는 것. 왜냐하면 람다 표현식을 사용하는 동안 외부 변수의 값이 변경되면 람다가 가져온 변수의 값과 일치하지 않을 수 있기 때문이다.
이를 방지하기 위해 람다 표현식에서는 외부 변수가 변경되지 않도록 final 키워드를 붙여서 "읽기 전용"으로 만드는 것이다.
// 그래서 이 코드는 컴파일 에러
class Apple {
public static void main(String[] args) {
int temp;
IntStream.rangeClosed(1, 5).forEach(i -> {
temp = i; // 람다식에서 final 로 취급되는 temp 변수를 update 하려는 시도
System.out.println("value of i is = " + i);
});
}
}
그러나 자바 8에서는 final 키워드를 생략해도 되도록 바뀌었음. (컴파일러가 똑똑해졌기 때문)
자바 컴파일러가 변수의 수정 가능 여부를 추론하여 final 키워드를 암묵적으로 적용하기 때문이다.
그렇지만 명시적으로 final 키워드를 붙여 사용하는 것은 가독성과 코드의 의도를 명확하게 전달하는 데 도움이 될 수 있기 때문에 필수적인 요구사항은 아니지만, 권장되는 습관으로 간주되는 정도라고 알아두면 될 것 같다.
자바의 Garbage Collection (GC)는 자동 메모리 관리 기법 중 하나로, 개발자가 명시적으로 메모리를 할당하거나 해제하는 대신,
JVM(Java Virtual Machine)이 자동으로 더 이상 사용되지 않는 객체를 감지하고 자동으로 해당 메모리를 회수. 이를 통해 개발자는 메모리 관리에 대한 부담을 덜 수 있으며, 메모리 누수와 같은 일반적인 프로그래밍 실수를 최소화할 수 있다.
GC의 주요 개념과 원리)
참조 카운팅(Reference Counting)
이는 객체가 참조되는 횟수를 계산하는 기법으로, 참조되는 횟수가 0이 되면 해당 객체는 더 이상 사용되지 않는 것으로 간주하고 메모리를 해제한다.
하지만 자바는 실제로 이 기법을 사용하지 않는다. 왜냐하면 순환 참조 같은 문제로 인해 객체가 실제로 참조되지 않지만 참조 카운트가 0이 되지 않는 상황이 발생할 수 있기 때문.
Reachability (도달 가능성)
자바의 GC는 도달 가능성(reachability) 기반으로 동작한다.
이는 어떤 객체가 루트(root)에서부터 접근 가능한지를 판단하여 살아있는 객체와 쓸모 없어진 객체를 구분합니다.
루트는 주로 스레드의 스택 프레임, 정적(static) 변수, JNI(Java Native Interface) 등을 포함한다.
Mark and Sweep (마킹 & 스윕 알고리즘)
가장 일반적인 GC 알고리즘으로, 루트에서부터 접근 가능한 객체들을 모두 마킹(mark)한 다음, 마킹되지 않은 객체들은 쓸모 없는 것으로 판단하고 메모리를 해제(sweep) 하는 방식이다.
Generational GC (세대 기반 알고리즘) → (수업 시간에 쌤이 잠깐 알려주신 알고리즘)
대부분의 자바 GC는 세대(generation) 기반의 알고리즘을 사용.
새로운 객체들을 Eden 영역에 할당하고, 오래된 객체들을 더 오래 살아남은 영역(Old 영역)에 할당하는 방식.
대부분의 객체는 금방 쓸모 없어지기 때문에 새로운 객체들이 많이 수집되는 반면, 오래된 객체들은 잘 유지된다.
Stop-the-world (일시적 중지)
일반적인 GC 실행 도중에는 GC를 위해 JVM의 모든 스레드가 일시적으로 중지되는 것을 뜻함.
이러한 중지 시간을 최소화하기 위해 GC 알고리즘들은 계속 개선되고 있다.
finalize()
를 수동으로 호출하는 것이 문제가 될 수 있나요?
Java에서 finalize()
메서드는 객체가 GC에 의해 수집되기 전에 자동으로 호출되는 메서드이다.
이 메서드를 오버라이딩하여 객체가 소멸되기 전에 정리 작업을 수행할 수는 있는데, finalize()
메서드를 수동으로 호출하는 것은 일반적으로 문제가 될 수 있고 권장되지 않는다.
왜 권장되지 않을까?)
finalize()
메서드를 수동으로 호출한다는 것은 명시적으로 GC를 실행하려는 시도를 의미하는데,
Java에서는 공식적으로 개발자가 직접 GC를 호출하는 것을 지원하지 않는다. (GC는 JVM에 의해 자동으로 관리되는 것)
그렇기 때문에 System.gc()
또는 Runtime.getRuntime().gc()
와 같은 메서드를 사용해서 GC를 요청해도 실제로 GC가 실행될지는 보장되지 않는다.
JVM 의 GC 알고리즘 방해 가능성
JVM은 자체적으로 적절한 시기에 GC를 수행하고, 일반적으로는 적절한 메모리 압력이나 상황이 발생할 때에만 GC를 실행하는데, 수동적 GC 호출은 JVM의 GC 알고리즘을 방해하고 성능 저하를 초래할 수 있다.
finalize()의 불확실성
finalize()
메서드의 호출 시점은 보장되지 않기 때문에 GC에 의해 수동으로 호출하는 객체가 아직 수집되지 않았을 수도 있다.
대신 try-with-resources 문
을 사용하여 자원을 명시적으로 정리하거나 AutoCloseable
인터페이스를 구현하여 자원을 관리하는 것이 더 안전하고 예측 가능한 대체 방법이다.
성능 저하: finalize()
메서드를 오버라이딩하고 수동 호출하면서 객체를 GC의 관리 대상에서 제외하도록 처리하려고 할 수 있습니다. 이러한 접근 방식은 오히려 메모리 누수를 유발할 수 있으며, 더 많은 메모리 사용과 GC의 빈번한 실행으로 성능 저하를 초래할 수 있습니다.
어떤 변수의 값이
null
→ 이 값은 GC가 되나요?
그렇다.
Java에서 GC는 도달 불가능한 객체들(root에서부터 접근이 불가능한 객체들)을 수집하여 메모리를 회수하는 프로세스이기 때문에 만약 어떤 변수에 null을 할당하면, 해당 변수가 이전에 참조하던 객체와의 연결이 끊어지게 되는 상황이다.
따라서 해당 객체는 더 이상 도달 가능하지 않으며, GC의 대상이 될 가능성이 매우 높다고 할 수 있다.
ex)
public class Example {
public static void main(String[] args) {
// 객체를 생성하고 변수 obj가 이를 참조.
MyClass obj = new MyClass();
// obj 변수에 null을 할당하여 객체와의 연결을 끊는다.
obj = null;
// 이 시점에서 obj가 이전에 참조하던 객체는 도달 불가능한 상태.
// 따라서 해당 객체는 GC의 대상이 될 수 있다.
}
}
자바에서 equals()
와 hashCode()
는
객체 동등성 비교와 해시 기반 컬렉션에 사용되는 두 가지 메서드.
equals()
메서드
두 개의 객체가 동등하다고 판단하는 로직을 구현하는데 사용된다.
즉, 두 객체의 내용이 동일한지를 확인하는 메서드.
자바에서 모든 클래스는 기본적으로 equals()
를 오버라이딩하지 않으면 Object
클래스의 equals()
메서드를 상속받는데, 그 메서드는 객체의 레퍼런스 비교를 수행하기 때문에 두 객체가 메모리에서 같은 위치를 가리키는지를 비교함.
하지만 대부분의 경우에 우리는 객체의 내용을 기반으로 동등성을 판단하고 싶을 때가 많기 때문에 이럴 때는 반드시 오버라이딩 해줘야 한다.
equals()
오버라이딩 시 주의할 점)
null
체크: equals()
메서드는 항상 null
과 비교되는지 확인하여 NullPointerException
을 방지해야 함.equals()
메서드는 객체가 자기 자신과 비교될 때 true
를 반환해야 함.equals()
메서드는 다른 클래스의 인스턴스와 비교되지 않도록 첫 번째로 타입을 체크해야 함.ex)
public class Person {
private String name;
private int age;
@Override
public boolean equals(Object o) {
if (this == o) return true; // 자기 자신과 비교
// null 체크, 클래스 타입 체크
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name); //내용 비교
}
}
hashCode()
메서드
hashCode()
메서드는 객체의 해시 코드를 반환하는데 사용.
해시 코드는 해시 기반 컬렉션(HashMap, HashSet ..)에서 객체를 저장하고 검색하기 위한 인덱스로 사용된다.
자바에서 hashCode()
메서드를 오버라이딩하지 않으면 Object
클래스의 기본 hashCode()
를 상속받는데, 그 메서드는 객체의 메모리 주소에 기반하여 해시 코드를 생성한다.
hashCode()
메서드를 오버라이딩할 때는 equals()
와 일관성을 유지해야 하는게 포인트.
즉, 두 객체가 equals()
메서드를 통해 동등하다면, hashCode()
메서드도 같은 값을 반환해야 한다. 그렇지 않으면 해시 기반 컬렉션에서 객체를 올바르게 저장하고 검색할 수 없다.
일반적으로 hashCode()
를 구현할 때는 객체 내의 필드들을 기반으로 해시 코드를 계산한다.
또한, hashCode()
를 오버라이딩할 때에는 equals()
와 마찬가지로 null
값을 다루는 등의 주의사항을 지켜야 한다.
ex)
public class Person {
private String name;
private int age;
@Override
public boolean equals(Object o) {
// equals 메서드 구현 생략
}
@Override
public int hashCode() {
return Objects.hash(name, age); // 필드를 기반으로 해시코드 계산
}
}
본인이
equals()
,hashcode()
를 재정의한다면, 어떤 점을 염두해 두고 구현할 것 같으신가요?
앞서 설명하긴 했지만 다시 정리하면 다음과 같다.
equals()
메서드를 재정의할 때에는,
NullPointerException
을 발생시키지 않도록 null
값을 처리.null
이 아닌 다른 객체와 비교할 때에는 먼저 클래스 타입을 확인.false
를 반환.equals()
메서드를 재정의할 때는 상위 클래스의 equals()
메서드를 호출할 수도 있음.equals()
메서드를 구현.hashCode()
와 일관성을 유지.equals()
메서드를 오버라이딩하면 hashCode()
메서드도 함께 재정의해야 함.hashcode()
를 오버라이딩 할 때에는,
같은 객체들은 같은 해시 코드를 반환해야 함.
equals()
메서드를 통해 동등하다고 판단된 두 객체는 반드시 같은 해시 코드를 반환해야 함.a.equals(b)
가 true
라면, a.hashCode()
와 b.hashCode()
는 같아야 한다.다른 객체들은 다른 해시 코드를 반환해야 함.
null
값을 다루는 방법을 결정해야 함.
예를 들어, hashcode 에 넘기는 필드 중 하나가 null
일 경우 해시 코드를 어떻게 생성할 것인지 고려해야 한다.
해시 충돌 고려.
제어의 역전(IoC)
:프레임워크가 대신 처리해준다! 객체지향 프로그래밍에서의 제어 흐름을 개발자가 정하는 것이 아니라, 프레임워크 또는 컨테이너의 제 3자가 제어 흐름을 주도한다는 개념. 즉 한마디로, 개발자가 프로그램의 제어 흐름을 결정하지 않고, 프레임워크에 의해 제어 흐름이 결정되는 것. 이렇게 하면 애플리케이션의 결합도가 낮아지고 유연성과 확장성이 향상된다.반대라서 뭐..?
**IoC를 구현하는 방법 2가지)
Dependency Lookup
애플리케이션의 객체를 생성하거나 검색하기 위해 컨테이너에 의존한다.
주로 서비스 로케이터 패턴을 사용.
**서비스 로케이터 패턴?)
Service Locator는 애플리케이션에 필요한 모든 서비스에 접근하는 방법을 알고 있다.
따라서 다른 모든 서비스들은 해당 컴포넌트만 알면 된다.
MovieFinder finder = ServiceLocator.movieFinder();
// class ServiceLocator...
이런 식으로 ServiceLocator
를 싱글턴 레지스트리로 만들어서 직접 접근하는 식으로 인스턴스를 만들면 된다.
Dependency Injection(DI)
객체가 필요로 하는 의존성을 외부에서 주입받도록 한다.
**DI를 사용하는 방법의 세 가지 유형)
public class UserService {
private UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// Methods using userRepository...
}
public interface MessageService {
void sendMessage(String message);
}
// MessageService 를 구현하는 두 클래스
public class EmailService implements MessageService {
@Override
public void sendMessage(String message) {
// Logic to send an email
System.out.println("Sending an email: " + message);
}
}
public class SMSService implements MessageService {
@Override
public void sendMessage(String message) {
// Logic to send an SMS
System.out.println("Sending an SMS: " + message);
}
}
// MessageService 가 필요한 클래스
public class MessageProcessor {
private MessageService messageService;
// Setter method
public void setMessageService(MessageService messageService) {
this.messageService = messageService;
}
public void processMessage(String message) {
// Delegate the message sending to the MessageService implementation
messageService.sendMessage(message);
}
}
// NotificationService.java
@Service
public class NotificationService {
private final MessageService messageService;
@Autowired
public NotificationService(MessageService messageService) {
this.messageService = messageService;
}
public void sendNotification(String message) {
messageService.sendMessage(message);
}
}
// MainApplication.java
public class MainApplication {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
NotificationService notificationService = context.getBean(NotificationService.class);
notificationService.sendNotification("Hello, Spring Interface Injection!");
}
}
정리하자면,
IoC와 DI는 객체지향 프로그래밍에서 애플리케이션의 제어 흐름과 의존성을 관리하는 방법을 제시하는 중요한 개념이다.
특정 기능을 하는 클래스가 딱 한 개라도, Spring 에서는 Bean을 사용한다. 왜?
애플리케이션이 성장하고 복잡해질 수 있기 때문에, 향후 기능 확장이나 변경을 고려해서 코드를 유연하게 설계하는 것.
⇒ Bean을 사용하면 인터페이스를 통해 의존성을 주입하고, DI를 통해 구현 클래스를 변경하거나 새로운 클래스를 손쉽게 추가할 수 있다.
테스트 하기 좋다
⇒ Bean을 사용하면 의존성을 주입하므로 테스트에서 모의 객체(Mock)를 사용하여 단위 테스트를 수행할 수 있다. 따라서 테스트의 격리성과 정확성을 높이는 데 도움이 됨.
결합도 낮추기
구체적인 클래스를 하나만 사용하더라도 이를 직접적으로 참조하는 것은 객체간의 결합도를 높인다.
⇒ Bean을 사용하면 인터페이스를 통해 결합도를 낮추고, 클래스 간의 의존성을 줄일 수 있다.
Bean 생명 주기 관리(초기화, 소멸 시점 설정)를 통해 리소스 누수를 방지하고, 애플리케이션의 성능을 최적화할 수 있다.
AOP(Aspect-Oriented Programming) 지원
⇒ Spring은 AOP를 통해 핵심 비즈니스 로직과 부가적인 기능(로깅, 보안, 트랜잭션 …)을 분리하여 개발할 수 있도록 지원한다. 이로 인해 코드의 가독성과 유지 보수성이 향상된다.
따라서)
확정성, 유지 보수, 테스트 용이성, 여러가지 편한 기능 제공 등의 이유 때문에 스프링은 Bean을 사용한다고 보면 된다.
Spring의
Bean 생성주기
에 대하여
InitializingBean
인터페이스를 구현하거나, @PostConstruct
어노테이션이 지정된 메서드를 포함하면 해당 메서드가 호출된다.DisposableBean
인터페이스를 구현하거나, @PreDestroy
어노테이션이 지정된 메서드를 포함하면 해당 메서드가 호출되어 마무리 작업을 수행한다.위와 같은 빈의 생성 주기를 관리하기 위해 Spring은 빈 팩토리(Bean Factory) 또는 애플리케이션 컨텍스트(Application Context)를 사용한다.
**애플리케이션 컨텍스트는 빈 팩토리를 확장한 기능이며, 빈의 생성, 의존성 주입, 초기화, 소멸과 같은 빈의 생명 주기를 관리하는 책임을 갖고 있다.
애플리케이션 컨텍스트는 일반적으로 XML, 어노테이션 등을 사용하여 빈의 구성 정보를 제공받는다.
프로토타입 빈 (Prototype Bean)?
프로토타입 빈은 Spring Framework에서 제공하는 빈의 스코프(scope)
중 하나이다.
빈의 스코프는 해당 빈이 생성되고 존재하는 범위를 정의하는 것을 의미.
프로토타입 빈은 요청할 때마다 매번 새로운 인스턴스가 생성되는 스코프 이다.
Spring의 빈은 기본적으로 싱글톤(Singleton) 스코프 로 생성된다.
싱글톤 스코프는 하나의 빈 인스턴스만을 생성하여 컨테이너에서 공유하기 때문에 동일한 빈을 여러 번 요청하더라도 매번 같은 인스턴스를 반환한다.
하지만 프로토타입 빈은 매번 요청할 때마다 새로운 인스턴스를 생성하여 반환한다.
즉, 매 요청마다 독립적인 상태를 유지해야 하는 경우 유용한데,
예를 들어 웹 애플리케이션에서 요청마다 새로운 폼 객체를 생성하거나, 상태 정보를 담은 객체를 사용해야 할 때 프로토타입 빈을 사용할 수 있다.
프로토타입 빈은 요청 시점에서 생성되고, 해당 빈이 더 이상 사용되지 않을 때 소멸된다.
이러한 동작은 역시 애플리케이션 컨텍스트 또는 빈 팩토리에 의해 관리된다.
AOP는 애플리케이션의 핵심 비즈니스 로직과 부가적인 기능을 분리하여 개발하는 방법을 말한다.
AOP는 애플리케이션의 여러 모듈에 걸쳐 공통적으로 적용되는 관심사를 분리하여 중복 코드를 최소화하고 코드의 재사용성과 유지보수성을 향상시키는데 사용된다.
공통되는 관심사는 예를 들면 이런 것들이 있을 수 있다.
AOP는 이런 관심사를 모듈화하여 핵심 관심사와 분리시킨다.
예를 들어서, 취뽀를 하고 회사에 들어갔는데 팀장님이 서비스에 문제가 있는 것 같다고 하심.
→ 나한테 어디서 느려지는 것 같냐고 물어보심;;
→ 나는 모든 메서드 시작과 끝에 코드를 넣어서 함수 호출마다 몇 milisecond 걸리는지 찍어보기로 함.
→ ㅎㅎ…
(여기서 AOP!)
이를 위해 어드바이스(Advice)
, 포인트컷(Pointcut)
, 어드바이저(Advisor)
의 개념이 등장한다.
**AspectJ와 같은 외부 라이브러리를 이용하여 AOP를 사용할 수도 있지만,
Spring Framework에서는 자체적인 AOP 기능을 제공한다.
Spring AOP는 프록시(Proxy) 패턴을 기반으로 동작하며, 런타임 시에 관심사를 핵심 비즈니스 로직에 적용한다.
⭐️⭐️ 더 조사할 것) 스프링 AOP 특징? 프록시 패턴, 블로그 링크 참조
정리하자면,
AOP를 사용하면 핵심 비즈니스 로직과 공통 관심사를 명확하게 분리할 수 있으며, 코드의 재사용성, 유지보수성, 가독성을 향상시킬 수 있다.
@Aspect
어노테이션은 무엇이고 그 기능은?
AOP 기능을 활용하여 공통 관심사를 구현하는 클래스를 표시하는 역할을 합니다.
Aspect
는 Advice(어드바이스)와 Pointcut(포인트컷)을 함께 포함하며,
@Aspect
어노테이션을 사용하여 Spring에게 해당 클래스가 Aspect로서 동작하도록 지정한다.
어떤 동작을 하나?)
스캐닝
Spring은 컨테이너를 초기화하는 과정에서 @Aspect
어노테이션이 적용된 클래스를 우선 스캔한다.
Pointcut 정의
Aspect 클래스 내부에서 @Pointcut
어노테이션을 사용하여 특정 메서드를 포인트컷으로 정의한다.
포인트컷은 어떤 메서드에 Advice를 적용할지를 선택하는 기준을 정의합니다.
Advice 정의
Aspect 클래스 내부에서 @Before
, @After
, @Around
, @AfterReturning
, @AfterThrowing
등의 어노테이션을 사용하여 Advice를 정의한다.
Advice는 횡단 관심사를 구현한 코드로, 언제(When)와 어떻게(How) 핵심 관심사에 적용할지를 정의한다.
Advisor 생성
Aspect 클래스 내부에서 @Pointcut
과 @Before
, @After
, @Around
, @AfterReturning
, @AfterThrowing
등의 어노테이션을 조합하여 Advisor를 생성한다.
Advisor는 Advice와 Pointcut의 결합으로, 어떤 메서드에 어떤 Advice를 적용할지를 지정한다.
프록시 생성
Spring은 Aspect에 정의된 Advisor를 기반으로 프록시를 생성한다.
프록시는 핵심 비즈니스 로직의 메서드를 감싸는 래퍼(wrapper) 역할을 합니다.
AOP 적용
프록시를 이용하여 공통 관심사를 핵심 비즈니스 로직에 적용한다.
이를 통해 핵심 비즈니스 로직에 대한 수정 없이 공통 관심사를 추가할 수 있다.
정리하자면,
@Aspect
어노테이션을 사용하면 AOP 기능을 구현하는 데 매우 편리하고 가독성이 좋은 코드를 작성할 수 있다.
Aspect 클래스의 메서드는 Advice로서 공통 관심사를 구현하며, @Pointcut
어노테이션을 사용하여 메서드가 적용될 포인트컷을 정의한다.
Spring은 이러한 Aspect 클래스를 스캔하여 자동으로 AOP를 적용하며, 애플리케이션에 공통 관심사를 쉽게 추가할 수 있게 된다.
둘 다 웹 애플리케이션에서 요청과 응답을 가로채고 처리하는 기능을 제공하는데 사용되지만,
각각은 서로 다른 기술과 용도를 가지고 있다.
Controller 에서 요청을 가로채고 처리하는 인터페이스이다.
Spring의 `핸들러 인터셉터(HandlerInterceptor)` 인터페이스를 구현하여 생성하며,
일반적으로 전역적으로 또는 특정 URL 패턴에 대해 적용된다.
**Interceptor를 사용해서 할 수 있는 작업)
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final StringLOG_ID= "logId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
//@RequestMapping: HandlerMethod
//정적 리소스: ResourceHttpRequestHandler
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;//호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("postHandle [{}]", modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String logId = (String) request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", logId, requestURI, handler);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
@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("/**")
.excludePathPatterns("/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error");
}
}
웹 애플리케이션의 모든 요청과 응답에 대해 가로채고 처리하는 클래스이다.
`javax.servlet.Filter` 인터페이스를 구현하여 생성하며,
웹 애플리케이션의 web.xml 파일에 등록하여 동작시킨다.
**Servlet Filter를 사용해서 할 수 있는 작업)
@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
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);
}
}
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
근데 뭐가 다른거임?
Interceptor와 Servlet Filter는 둘 다 공통 관심사를 처리하는 데 사용되지만, 사용 시기와 적용 범위에 차이가 있다.
Interceptor
는 주로 Spring MVC에서 사용되며, 컨트롤러 레벨에서 요청 처리를 가로채는 데 사용된다.
Servlet Filter
는 Java Servlet 스펙에 의존하므로 Spring 이외의 웹 프레임워크에서도 사용할 수 있으며, 모든 요청과 응답에 대해 가로채는 범용적인 기능을 가지고 있다.
좀더 자세하게 알아보자.
인터셉터를 사용하는 경우는 다음과 같다.
필터를 사용하는 경우:
⭐️⭐️ 순서에 대해서 정리 (뭐가 더 앞단에 있는지?)
스프링 시큐리티도 필터다.
Spring MVC에서 핵심적인 컨트롤러 역할을 담당하는 클래스이다.
웹 애플리케이션에 들어오는 모든 클라이언트 요청을 중앙 집중적으로 처리하고, 해당 요청을 적절한 컨트롤러에게 전달하여 처리 결과를 반환하는 역할을 수행한다.
DispatcherServlet의 주요 역할)
정리하자면,
DispatcherServlet은 Spring MVC의 핵심이며, 전체 요청-응답 주기를 관리하여 클라이언트 요청에 적절한 핸들러를 호출하고, 해당 핸들러의 처리 결과를 클라이언트에게 반환한다.
이를 통해 웹 애플리케이션의 요청 처리 로직을 효과적으로 분리하여 구성할 수 있고, MVC 디자인 패턴을 쉽게 적용할 수 있다.
한번에 여러 요청을 모두 받을 수 있을까?
DispatcherServlet은 기본적으로 Spring의 기본 동작 방식을 따라 싱글톤(Singleton)으로 생성되어서 한 번에 하나의 요청만을 처리할 수 있다.
싱글톤으로 생성된 DispatcherServlet은 웹 애플리케이션의 시작 시점에 초기화되고,
요청이 들어올 때마다 새로운 스레드를 생성하지 않는 대신, 한 번 생성된 DispatcherServlet 인스턴스가 모든 요청을 순차적으로 처리한다.
만약 여러 요청을 동시에 처리하고 싶다면,
서블릿 컨테이너의 설정 에 따라 다중 스레드가 처리 가능한 방식으로 설정하거나,
로드 밸런싱을 통해 여러 인스턴스의 DispatcherServlet을 운영하는 등의 방법을 사용할 수 있다.
@Controller
여러 개를 DispatcherServlet 은 어떻게 구분하나요?
수많은 @Controller
를 구분하고 요청을 적절한 핸들러(컨트롤러)에게 라우팅하기 위해 핸들러 매핑(HandlerMapping)을 사용한다.
핸들러 매핑은 DispatcherServlet이 요청을 받아서 처리하는 과정에서 가장 먼저 실행되며,
요청의 URL이나 다른 속성을 기준으로 적절한 핸들러를 찾아주는 역할을 하는 기법이다.
일반적으로 사용되는 핸들러 매핑)
@RequestMapping
어노테이션을 기반으로 핸들러를 매핑하는 가장 일반적인 핸들러 매핑.@RequestMapping
어노테이션이 선언된 메서드 또는 클래스에 매핑되는 것을 찾아 매핑한다.기본적으로 Spring은 RequestMappingHandlerMapping
을 사용하여 @RequestMapping
어노테이션과 URL을 기반으로 핸들러를 찾아주는 매핑을 수행한다.
@RequestMapping
어노테이션은 메서드 또는 클래스에 적용되며, 요청의 URL과 매핑되는 핸들러를 선택한다.
따라서 @Controller
를 사용하여 정의된 컨트롤러들이 요청을 처리하도록 한다.
JPA(Java Persistence API)
와 같은 ORM(Object-Relational Mapping)
을 사용하는 이유)
이러한 이유로 ORM을 사용하여 개발하면 데이터베이스와의 상호작용이 간소화되고,
객체 지향 프로그래밍의 장점을 최대한 활용할 수 있으며,
유지보수성과 개발 생산성을 향상시킬 수 있다.
영속성(Persistence)
의 기능은? 성능 향상에 도움이 되는가?
영속성은 데이터를 오래 지속시키는 기능을 말하고, 주로 데이터베이스에 객체의 상태를 저장하고, 필요에 따라 수정, 조회, 삭제 등의 작업을 가능하게 한다.
영속성을 통해 객체는 애플리케이션의 실행과 무관하게 데이터베이스에 저장되며, 애플리케이션을 종료하더라도 데이터는 보존된다.
ORM(Object-Relational Mapping) 기술을 사용하여 영속성을 구현하면, 객체와 데이터베이스 간의 매핑 관계를 자동으로 처리할 수 있다.
즉, 쿼리와 테이블을 자동으로 생성하고 관리하는 작업을 대신 처리해주기 때문에 개발자는 데이터베이스와 직접 상호작용하는 SQL 코드를 작성하지 않아도 된다.
영속성의 성능 향상 기여)
캐시(Cache) 활용
영속성 계층은 자주 사용되는 데이터를 캐시에 저장하여 데이터베이스 접근을 줄인다.
캐시를 사용하면 동일한 데이터를 반복적으로 데이터베이스에서 불러올 필요가 없어져서 I/O 비용을 절감할 수 있다.
지연 로딩(Lazy Loading)
영속성을 통해 연관된 객체를 필요할 때 로딩하는 지연 로딩을 사용하면, 실제로 필요한 데이터만 로딩하여 불필요한 데이터베이스 쿼리를 줄일 수 있다.
트랜잭션 관리
영속성은 트랜잭션 관리를 지원하여 ACID 특성을 보장한다.
따라서 데이터베이스의 데이터 무결성과 일관성을 유지할 수 있다.
데이터베이스 독립성
영속성 계층을 사용하여 데이터베이스와의 종속성을 줄일 수 있다.
이로 인해 다양한 데이터베이스를 지원하는 환경에서도 애플리케이션을 실행할 수 있다.
N+1 Problem
N+1
문제는 ORM(Object-Relational Mapping)을 사용하는 애플리케이션에서 발생하는 성능 이슈 중 하나이다.
→ 쿼리 실행 횟수가 증가하여 데이터베이스에 대한 부하가 증가하는 상황이다.
일반적으로 N+1
문제가 나타나는 상황)
N
개 있을 경우를 가정.N
번의 추가 쿼리를 실행한다.1 + N
이 된다.예를 들어보자.
고양이와 고양이 집사의 관계 코드가 있다.
/**
* @author Incheol Jung
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Owner {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
// 집사:고양이 = 1:N 관계
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
private Set<Cat> cats = new LinkedHashSet<>();
...
}
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Cat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@ManyToOne // 고양이:집사 = N:1
private Owner owner;
public Cat(String name) {
this.name = name;
}
}
테스트 시나리오)
고양이를 10마리 생성, 집사를 10명 생성.
집사는 10마리씩 고양이를 키우고 있다.
고양이 집사를 조회해보자.
@Test
void exampleTest() {
Set<Cat> cats = new LinkedHashSet<>();
for(int i = 0; i < 10; i++){
cats.add(new Cat("cat" + i));
}
catRepository.saveAll(cats); // 고양이 저장하고
List<Owner> owners = new ArrayList<>();
for(int i = 0; i < 10; i++){
Owner owner = new Owner("owner" + i);
owner.setCats(cats); // 연관관계가 있는 집사에 고양이를 set 해주었다.
owners.add(owner);
}
ownerRepository.saveAll(owners);
entityManager.clear();
System.out.println("-------------------------------------------------------------------------------");
List<Owner> everyOwners = ownerRepository.findAll(); // 집사를 조회하는 코드 실행
assertFalse(everyOwners.isEmpty());
}
결과는?
고양이 집사 조회하는 쿼리를 호출했는데,
고양이를 조회하는 쿼리가 집사를 조회한 행만큼 쿼리가 더 호출한 것을 확인할 수 있다.
왜??)
N+1
문제는 관련 객체들이 필요할 때 연관되어 있는 객체가 모두 로딩되기 때문에,
사용할 때마다 필요한 객체들을 추가로 가져오는 쿼리가 실행되면서 N+1
문제가 발생하는 것이다.
N+1
문제를 해결하는 방법)
가장 일반적인 방법은 데이터 fetch 방식을 최적화하는 것이다다.
fetch join 을 사용하여 관련 데이터를 한 번에 모두 조회하는 방법 등을 활용하여 쿼리 실행 횟수를 줄일 수 있다.
또 @BatchSize
같은 ORM의 특별한 기능을 사용하여 N+1
문제를 개선할 수도 있다.
이렇게 하면 데이터베이스에 대한 부하를 최소화하고 성능을 향상시킬 수 있다.
Spring Framework에서 제공하는 어노테이션으로, 트랜잭션을 관리하는 데 사용된다.
데이터베이스 용어인 트랜잭션
은 데이터베이스 작업을 여러 단계로 나누어 실행할 때, 모든 단계가 성공적으로 완료되면 데이터를 커밋하고, 하나라도 실패하면 이전 상태로 롤백하는 작업 단위를 말한다.
@Transactional
어노테이션의 기능)
트랜잭션의 시작과 종료
메서드 또는 클래스에 @Transactional
어노테이션을 적용하면 해당 메서드 또는 클래스에서 실행되는 모든 데이터베이스 관련 작업들은 하나의 트랜잭션으로 묶이게 된다.
메서드가 실행되면 트랜잭션을 시작하고, 메서드의 모든 작업이 성공적으로 완료되면 트랜잭션을 커밋(commit)하고, 하나라도 실패하면 트랜잭션을 롤백(rollback)한다.
트랜잭션 범위 지정
@Transactional
어노테이션에는 propagation
(전파) 속성을 사용하여 트랜잭션의 범위를 지정할 수 있다.
예를 들어, PROPAGATION_REQUIRED
로 설정하면 이미 진행 중인 트랜잭션이 있으면 해당 트랜잭션에 참여하고, 없으면 새로운 트랜잭션을 시작합니다.
PROPAGATION_NEW
로 설정하면 이미 진행 중인 트랜잭션이 있으면 정지시키고 새로운 트랜잭션을 시작한다. 만약 실패하면, roll back 후에 정지시킨 트랜잭션을 재개한다.
예외 처리와 롤백
메서드 내에서 예외가 발생하면, @Transactional
어노테이션이 적용된 메서드의 트랜잭션은 롤백된다.
즉, 예외가 발생하면 이전 상태로 데이터베이스 작업이 롤백됨.
트랜잭션 속성 설정
isolation
, readOnly
, timeout
, rollbackFor
, noRollbackFor
등 다양한 속성을 사용하여 트랜잭션의 동작을 세밀하게 제어할 수 있다.
즉, @Transactional
어노테이션을 사용하여 트랜잭션을 관리하면 개발자는 별도로 트랜잭션을 시작하고 커밋 또는 롤백하는 등의 작업을 하지 않아도 된다.
@Transactional(readonly = true)
은 무슨 기능인가요?
Spring Framework에서 제공하는 @Transactional
어노테이션의 속성 중 트랜잭션을 읽기 전용(Read-Only)으로 설정하는 기능을 하는 것이다.
readonly=true
로 설정된 어노테이션을 사용하면 해당 트랜잭션이 읽기 작업만 수행하고, 데이터를 변경하는 쓰기 작업은 수행하지 않는다는 것을 나타낸다. 즉, 메서드가 실행되는 동안 데이터베이스의 상태를 변경하는 쿼리 (INSERT, UPDATE, DELETE)가 수행되지 않는다.
사용시 이점)
성능 향상
읽기 전용 트랜잭션은 데이터베이스에 변경 작업이 없기 때문에 커밋하는 시간이나 롤백하는 시간이 절약된다.
더 나은 동시성 제어
트랜잭션 범위가 읽기 전용인 경우에는 데이터베이스 레벨에서 더 높은 동시성을 제공할 수 있다.
(여러 개의 읽기 전용 트랜잭션들이 동시에 실행될 수 있으므로)
트랜잭션 오버헤드 감소
쓰기 작업이 없는 읽기 전용 트랜잭션은 트랜잭션 오버헤드가 줄어든다.
트랜잭션의 시작과 종료, 롤백 등의 작업이 필요하지 않기 때문.
**하지만 주의할 점은, 해당 메서드 내에서 쓰기 작업을 수행하는 코드가 있다면 readonly=true
속성은 무시되고 트랜잭션이 읽기-쓰기로 전환되어 쓰기 작업도 수행될 수 있다고 한다.
읽기만 하려는 메서드에 트랜젝션을 꼭 걸어야 하는가?
붙이지 않아도 됨.
Spring은 트랜잭션을 명시적으로 시작하지 않은 경우, 메서드가 실행될 때 트랜잭션을 자동으로 시작하지 않기 때문에, 읽기 작업만 수행하는 메서드는 별도의 @Transactional
어노테이션을 붙이지 않으면 트랜잭션을 생성하지 않는다.
하지만 특정 상황에서 읽기 작업에도 트랜잭션을 걸어야 하는 상황이 있다)
일관성을 유지해야 하는 경우
일관성 있는 데이터를 보장하기 위해 읽기 작업에도 트랜잭션을 걸어야 할 수 있다.
특히, 다른 쓰기 작업과 함께 일어날 수 있는 경우 읽기 작업도 트랜잭션으로 보호하여 데이터 일관성을 유지한다.
동시성 문제를 해결해야 하는 경우
여러 사용자가 동시에 읽기 작업을 수행할 때, 데이터의 불일치 등 동시성 문제가 발생할 수 있다.
이러한 경우 트랜잭션을 사용하여 동시에 읽기 작업을 수행하도록 제어할 수 있다.
특정 데이터베이스 설정이 필요한 경우
일부 데이터베이스는 읽기 작업에도 트랜잭션 설정이 필요한 경우가 있다.
정리하자면,
일반적으로 읽기 작업은 트랜잭션 범위가 크지 않고 빠르게 수행되는 경향이 있기 때문에 트랜잭션을 걸지 않아도 문제가 발생하지 않는 경우가 많다.
그러나 읽기 위해 접근하는 데이터가 다른 트랜잭션에 의해 변경될 수 있는 상황이라면, 읽기 작업에도 트랜잭션을 사용하는 것이 맞아 보인다.
Java에서 Annotation(어노테이션)
은 코드에 메타데이터를 추가하는 기능을 한다.
메타데이터란 코드 자체의 기능을 변경하지 않으면서 코드에 대한 정보를 제공하는 데이터를 의미.
Annotation은 주로 컴파일러와 런타임에 사용된다.
Annotation은 @
기호를 사용하여 표현하며, 클래스, 메서드, 필드 등에 붙여 사용할 수 있다.
어노테이션의 주요 기능)
컴파일러 지시
Annotation은 컴파일러에게 특정 작업을 수행하도록 지시하는 역할을 한다.
예를 들어, @Override
어노테이션은 메서드가 상위 클래스의 메서드를 오버라이드(재정의)하는 것을 표시하여 컴파일러에게 검증하도록 한다.
코드 문서화
주석과 비슷하게 코드에 대한 문서화 역할도 힌다.
@Deprecated
어노테이션은 해당 요소(클래스, 메서드, 필드 등)가 더 이상 권장되지 않는 것임을 표시하여 개발자에게 알리는 역할을 한다.
컴파일 타임 체크
컴파일 타임에 코드의 일관성과 문제를 체크하는 데 사용되기도 한다.
예를 들어, @SuppressWarnings
어노테이션은 경고 메시지를 무시하도록 컴파일러에게 지시한다.
Annotation은 주로 코드의 가독성을 높이고 잠재적인 버그를 방지하는 등 다양한 기능을 제공한다고 보면 된다.
근데 Spring 에서의
@
어노테이션은 훨씬 많은 일을 하는 것 같던데…
그건 Spring이 Annotation을 강력하게 활용하여 다양한 기능과 설정을 더 지원하기 때문이다.
Spring은 자바 언어와 Reflection API를 활용하여 Annotation을 동적으로 분석하고 이를 기반으로 애플리케이션을 구성하고 제어한다.
Spring Annotation 이 다양한 기능을 제공할 수 있는 이유)
—위에서 계속 나온 @
어노테이션(Aspect, Transactional, Controller, Bean 등등) 의 기능들 다시 리마인드
Dependency Injection(DI)과 관련된 설정
Spring은 @Autowired
, @Component
, @Service
, @Repository
등의 Annotation을 활용하여 의존성 주입을 자동으로 수행한다. 이를 통해 객체들 간의 의존성을 쉽게 설정하고 제어할 수 있다.
Aspect-Oriented Programming(AOP) 지원
Spring은 @Aspect
와 다른 AOP 관련 Annotation을 사용하여 공통 관심 사항을 분리하고 구현한다.
AOP를 통해 로깅, 보안, 트랜잭션 처리 등과 같은 부가적인 기능을 간결하게 추가할 수 있다.
트랜잭션 관리
@Transactional
과 같은 Annotation을 사용하여 트랜잭션 설정을 간단하게 제어할 수 있다.
트랜잭션 범위, 롤백 규칙, 격리 수준 등을 Annotation으로 지정하여 트랜잭션을 관리한다.
웹 개발 지원
Spring MVC에서는 @Controller
, @RequestMapping
, @RequestBody
, @ResponseBody
등의 Annotation을 활용하여 웹 요청과 응답을 처리하는 컨트롤러를 간편하게 구현할 수 있다.
데이터베이스 지원
Spring Data JPA에서는 @Entity
, @Repository
, @Query
등의 Annotation을 사용하여 데이터베이스와 연동하는 리포지토리를 쉽게 작성할 수 있다.
Bean 생성과 설정
@Bean
, @Configuration
, @Profile
등의 Annotation을 사용하여 Spring의 Bean 생성과 설정을 쉽게 정의할 수 있다.
Spring에서 Annotation은 설정 파일을 대체하여 애플리케이션을 더 간단하고 직관적으로 구성하고 관리할 수 있도록 도와준다.
또한 Annotation을 활용하면 코드의 가독성이 향상되고 개발자의 생산성이 증가하는 장점도 있다.
Lombok 에서의
@Data
어노테이션을 잘 사용하지 않는 이유는?
Lombok의 @Data
어노테이션은 클래스의 기본적인 메서드들을 자동으로 생성해주는 기능을 제공합니다.
@Data
를 사용하면 equals()
, hashCode()
, toString()
, Getter
, Setter
등의 메서드를 간단하게 생성할 수 있다.
이로 인해 코드의 중복을 줄이고 간결한 클래스를 작성할 수 있다.
하지만 @Data
를 잘 사용하지는 않는다. 왜일까?
불필요한 메서드가 많이 생성됨
@Data
는 클래스의 모든 필드에 대해 equals()
, hashCode()
, toString()
, Getter
, Setter
를 자동으로 생성하는데, 클래스에 필요하지 않은 메서드를 생성하는 경우도 있을 수 있다.
예를 들어, 실제로 equals()
와 hashCode()
메서드가 필요 없는 클래스인데 이를 자동으로 생성하게 된다면, 불필요한 리소스를 소비할 수 있다.
상황에 맞지 않는 equals()
와 hashCode()
재정의
Lombok의 @Data
는 모든 필드를 기반으로 equals()
와 hashCode()
를 생성하는데, 이는 모든 필드를 비교하므로 클래스에 적합하지 않은 경우가 있을 수 있다.
동등성 비교가 특정 필드로 제한되어야 하는 경우 직접 equals()
와 hashCode()
를 구현해야 한다.
Getter, Setter의 상세한 제어 불가
@Data
는 클래스의 모든 필드에 대해 Getter와 Setter를 생성하는데, 일부 필드에 대해서만 Getter와 Setter를 허용하거나, Getter와 Setter에 특정 로직을 추가해야 하는 경우에는 직접 Getter와 Setter를 정의해야 한다.
부모 클래스오 자식 클래스 메서드 이름 충돌
상속 관계에서 @Data
를 사용하면 메서드 이름 충돌이 발생할 수 있다.
부모 클래스와 자식 클래스가 모두 @Data
를 사용하면 상속된 메서드와 재정의된 메서드가 충돌할 수 있다.
Tomcat
은 Apache Software Foundation에서 개발한 오픈 소스 웹 애플리케이션 서버(WAS)다.
웹 애플리케이션 서버는 클라이언트로부터 요청을 받아 웹 애플리케이션을 실행하고, 그 결과를 클라이언트에게 반환하는 역할을 수행한다.
Tomcat 주요 역할)
웹 애플리케이션 호스팅
Tomcat은 웹 애플리케이션을 호스팅하고 실행하는 기능을 제공한다.
웹 애플리케이션은 HTML, CSS, JavaScript 같은 정적 파일과 Java, Python, PHP 등의 동적 컨텐츠를 포함한다.
Java EE 지원
Tomcat은 Java EE(Java Platform, Enterprise Edition) 사양의 일부를 지원하기 때문에 Java EE 애플리케이션을 실행할 수 있다.
**Java EE는 기업환경에서 대규모 웹 애플리케이션을 개발하기 위한 스펙
HTTP 프로토콜 지원
Tomcat은 HTTP 프로토콜을 기반으로 클라이언트와 통신.
HTTP 요청을 받아들이고 처리한 후, HTTP 응답을 생성하여 클라이언트로 반환한다.
Tomcat은 간단한 정적 웹 사이트부터 복잡한 Java 웹 애플리케이션까지 다양한 종류의 웹 애플리케이션을 지원한다. 많은 웹 애플리케이션 프레임워크들이 Tomcat을 기반으로 동작하며, 대중적인 웹 서비스 환경에서 널리 사용되고 있다고 한다.
Netty
?
Netty는 비동기 이벤트 기반 네트워크 프레임워크로서, 주로 네트워크 애플리케이션을 개발하기 위해 사용된다.
이러한 프레임워크는 Java로 개발되었으며, 높은 성능과 확장성을 제공하는 것으로 유명하다.
Netty를 사용하는 이유)
좋은 성능
비동기 이벤트 기반 아키텍처를 사용해서 높은 처리량과 낮은 지연 시간을 제공한다.
I/O 작업을 블로킹하지 않고 비동기 방식으로 처리하기 때문에, 많은 클라이언트의 요청도 효율적으로 처리할 수 있다.
확장성이 좋음
다양한 네트워크 프로토콜을 지원하고, 커스터마이징이 용이해서 다양한 요구사항에 쉽게 대응할 수 있음.
멀티플랫폼 지원 (Java 언어의 장점 가져감)
Java 기반으로 개발되어 JVM 위에서 동작하기 때문에 멀티플랫폼에서 쉽게 사용할 수 있으며, 다양한 운영 체제와 호환된다.
**Netty는 웹 서버, 프록시 서버, 채팅 서버, 게임 서버 등 다양한 유형의 네트워크 애플리케이션에서 사용되고 있, 고성능과 확장성이 필요한 경우에 자주 사용되는 프레임워크라고 한다.