반응형
으어어어 스터디 시작이다!
힘내자!
[Q]
- 자바는 왜 컴파일러가 기계어를 만들어내는 대신 JVM 을 통해서 중간 형태(.class)의 명령어들을 실행할까?
- 객체지향의 4대 원리를 조사해봅시다.
- 힙영역과 스택영역의 차이는 무엇일까요? 스택 영역이라는 이름은 어디에서 유래한걸까요?
- 초기화가 되지 않은 String 타입의 변수에서 값을 읽어오면 어떻게 될까요? NullPointerException 은 어떤 상황에 발생할까요?
- 연산자들의 우선순위에 대해서 알아봅시다.
[A]
1. 자바는 왜 컴파일러가 기계어를 만들어내는 대신 JVM 을 통해서 중간 형태(.class)의 명령어들을 실행할까?
플랫폼 독립성 (Write Once, Run Anywhere)
- 자바의 주요 설계 목표는 한 번 작성한 코드를 여러 플랫폼에서 실행할 수 있다는 것 이다! (자바의 큰 장점!)
- 컴파일러가 생성하는 바이트코드는 특정 운영 체제나 하드웨어가 아니라, JVM이라는 가상 머신에서 실행되도록 설계되어있다!
- 이로 인해 자바 애플리케이션은 다양한 플랫폼에서 추가적인 수정 없이 실행 될 수 있다! JVM만 설치되어 있다면 바이트코드를 (바이트코드를 기계어로 변환 하는 JIT 컴파일로) 실행할 수 있음!
이식성(Protability)
- JVM은 각 운영 체제와 하드웨어에 맞게 구현된다.
- 자바 프로그램은 기계어로 번역되지 않고, 하드웨어에 의존하지 않는 바이트코드로 컴파일된다. JVM이 바이트코드를 해당 플랫폼의 기계어로 변환해 실행하기 때문에 이식성이 뛰어나다!
보안(Security)
- JVM은 프로그램 실행 전에 바이트코드를 검증(Bytecode Verification)한다. 이를 통해 악의적인 코드 실행을 방지하고, 메모리 접근 오류와 같은 문제를 줄일 수 있다.
- 직접 기계어로 컴파일된 프로그램은 실행 시 이런 검증 단계를 거치지 않기 때문에 보안 취약점이 더 많을 수 있다.
가상 머신의 추상화(Abstraction)
- JVM은 하드웨어나 운영 체제의 세부 사항을 숨겨준다. 개발자는 특정 플랫폼의 세부 사항을 신경 쓰지 않고, 표준화된 환경에서 개발할 수 있다.
- 이는 다양한 플랫폼 간의 차이를 고려하지 않아도 되는 장점을 제공한다.
동적 최적화(Dynamic Optimization)
- JVM은 실행 중에 프로그램의 성능을 최적화 하는 JIT(Just-In-Time) 컴파일러를 사용한다. JIT는 바이트 코드를 기계어로 변환하고, 자주 실행되는 코드를 더 빠르게 실행되도록 최적화 한다.
- 이로 인해 실행 속도가 인터프리터 기반 언어(컴파일 대신 한줄씩 해석(interpret) 하면서 실행하는 언어. Python, JavaScript 등)에 비해 훨씬 빠르며, 정적 컴파일 언어와 비슷한 수준의 성능을 제공한다.
멀티플랫폼 개발 생태계 지원
- JVM이 표준화된 인터페이스를 제공하기 때문에, 자바뿐만 아니라 Kotlin, Scala, Groovy 등 JVM 기반 언어들이 같은 플랫폼에서 실행될 수 있다.
- 다양한 언어와 툴이 조화롭게 동작하는 환경을 제공한다.
깊게 생각해보기
- 바이트코드 검증 말고 다른 검증은 없을까? 그리고 왜 다른 검증은 선택하지 않았을까?
- 소스코드 검증 - 소스코드를 직접 검증하는 방식도 가능하지만, 문제점이 있음
- 다양한 언어 처리 문제: JVM은 자바 외에도 Kotlin, Scala 등 다양한 언어를 지원한다.
- 소스코드 검증은 각 언어에 맞게 별도 로직이 필요하다.
- 느린 속도: 소스코드 검증은 복잡하고 시간이 많이 소요될 수 있다.
- 런타임 검증 - 프로그램 실행 중에 실시간으로 검증하는 방식도 있지만 단점이 큼
- 실행 중에 오류가 발생하면 이미 시스템에 영향을 줄 수 있어 위험하다.
- 런타임에 추가적인 검증 비용이 들기 때문에 성능이 저하될 수 있다.
- 네이티브 코드 검증 - 바이트코드가 아닌 네이티브 기계어를 직접 검증하는 방식
- CPU 아키텍처마다 명령어 구조가 다르기 때문에 검증이 복잡해진다.
- 플랫폼 독립성을 잃게 된다.
- 소스코드 검증 - 소스코드를 직접 검증하는 방식도 가능하지만, 문제점이 있음
- JVM이 다른 플랫폼에서 실행 될때 바이트코드 검증을 통해 어떤 방식으로 오류를 방지할 수 있는 것 일까?
- 로컬 변수에 접근하기 전에 올바르게 초기화 되었는지 확인.
- 스택의 상태(데이터의 타입, 크기 등)를 추척해 적절하게 사용되고 있는지 확인.
- 허용되지 않은 메서드 호출이나 필드 접근을 차단.
- 배열 범위를 벗어난 접근을 방지.
- JVM이 숨겨주는 세부사항은 어떤 것들이 있을까? 그리고 이를 어떻게 알고 숨겨주는 것 일까?
- 숨기는 부분
- 메모리 관리
- 메모리 할당 및 해제 방식(C언어처럼 수동으로 메모리를 관리하지 않아도 됨).
- 스택과 힙 같은 메모리 구조를 운영 체제에 따라 적절히 설정.
- 숨기는 방법
- JVM은 힙과 스택 영역을 자동으로 관리한다.
- 가비지 컬렉션을 통해 더이상 사용하지 않는 메모리를 자동으로 해제한다.
- 스레드 관리
- 운영 체제마다 스레드의 생성, 스케줄링, 동기화 방식이 다름.
- 숨기는 방법
- 자바는 Thread 클래스와 synchronized 키워드를 통해 스레드 작업을 단순화 한다.
- JVM은 운영 체제의 네이티브 스레드 API를 내부적으로 호출해 자바의 표준 스레드 인터페이스를 구현한다.
- 파일 시스템 접근
- 운영 체제별 파일 경로 형식,파일 처리 방식의 차이.
- 숨기는 방법
- 자바의 File 클래스나 Paths API는 운영 체제의 파일 경로 형식을 추상화 한다.
- 내부적으로 JVM이 적절한 OS 호출을 통해 파일을 읽고 쓴다.
- 네트워크 통신
- 네트워크 소켓 생성 및 통신 방식(OS마다 다른 네이티브 구현).
- 숨기는 방법
- 자바는 Socket 클래스와 같은 고수준 API를 제공.
- JVM은 내부적으로 TCP/IP 소켓을 처리하고, 운영 체제와 직접 통신한다.
- 기계어 명령 처리
- CPU 아키텍쳐와 명령어 세트 차이.
- 숨기는 방법
- JVM 바이트코드는 하드웨어에 독립적인 명령어로 작성된다.
- 실생 시 JIT 컴파일러가 이를 해당 플랫폼의 기계어로 변환한다.
- 운영 체제별 차이점
- 운영 체제마다 다른 API 호출 방식, 스케줄링 정책, 리소스 제한.
- 숨기는 방법
- JVM 구현체가 운영 체제별로 커스터마이징 된다.
- 메모리 관리
- JVM은 어떻게 알고 숨겨줄까?
- 바이트코드의 추상화
- 자바 프로그램은 .java 파일에서 바이트코드(.class)로 컴파일 된다.
- 이 바이트코드는 JVM이 이해할 수 있는 하드웨어 독립적인 명령어 집합으로, 하드웨어나 운영 체제의 세부 사항이 포함되지 않습니다.
- 플랫폼별 JVM 구현
- Oracle, OpenJDK 등의 JVM 구현체는 각 플랫폼의 세부 사항을 처리하는 코드를 포함한다.
- 예를 들어, Window에서 JVM은 Window API를 호출해 파일 작업을 처리하고, Linux에서는 POSIX API를 호출하도록 설계 된다.
- 표준화된 자바 클래스 라이브러리
- java.* 패키지는 개발자가 플랫폼에 독립적인 코드를 작성할 수 있도록 표준화된 인터페이스를 제공한다.
- JVM은 이 표준 API와 네이티브 운영 체제 호출 사이를 매핑한다.
- 바이트코드의 추상화
- 결론
- JVM은 바이트코드의 추상화와 플랫폼에 특화된 구현을 조합해 하드웨어와 OS의 차이를 숨긴다.
- 자바 개발자는 운영체제의 복잡한 세부사항을 몰라도 된다.
- 숨기는 부분
- JVM은 다양한 플랫폼에서 동작하도록 설계 되었는데 단점은 어떤 것 들이 있을까?
- JVM은 운영 체제에 독립적이지만, 이로 인해 플랫폼의 특화된 기능(GPU 계산 최적화 등)을 효율적으로 활용하지 못할 수 있다.
- 추가적인 추상화 계층(JVM)이 존재하기 때문에, 네이티브 코드에 비해 실행 초기 속도가 느리고, 메모리 사용량도 많아질 수 있다.
- JIT는 각각의 플랫폼에서 어떤 기준으로 최적화를 하는 것 일까?
- 프로파일링 데이터(실행 통계)를 기반으로 최적화를 적용한다. 자주 호출되거나 반복적으로 실행되는 코드를 핫스팟(Hotspot)으로 식별하여 네이티브 코드로 컴파일하거나 인라이닝(함수를 코드에 직접 삽입) 같은 최적화를 수행한다.
모르는 개념 정리
- JIT(Just-In-Time) 컴파일은 무엇인가?
- 핵심
- 바이트코드를 실행 도중에 즉시(JIT) 기계어로 번역한다.
- 번역된 기계어 코드는 캐싱되므로 같은 코드를 반복 실행할 때 더 빠르게 실행된다.
- 장점
- 빠른 실행 속도 - 코드 번경 후 곧바로 실행 가능하므로 디버깅이나 테스트에 유리하다.
- 인터프리터(프로그래밍 언어의 코드를 한 줄씩 읽고, 해석하여 즉시 샐행하는 프로그램) 방식만 사용했을 때보다 훨씬 빠르다.
- 인터프리터 vs 컴파일러
- 컴파일러는 코드를 한 번에 번역하여 실행 파일(기계어)을 생성한 뒤 실행한다.
- 반면, 인터프리터는 번역과 실행을 동시에 수행한다.
- 인터프리터 vs 컴파일러
- 반복적으로 실행되는 코드를 기계어로 변환해 실행 시간을 절약한다.
- 인터프리터(프로그래밍 언어의 코드를 한 줄씩 읽고, 해석하여 즉시 샐행하는 프로그램) 방식만 사용했을 때보다 훨씬 빠르다.
- 동적 최적화
- 실행 도중에 코드의 성능을 분석해 최적화를 수행한다.
- 예: 불필요한 연산 제거, 루프 최적화 등.
- 플랫폼 독립성 유지
- JIT JVM 안에서 동작하기 때문에 Java의 플랫폼 독립성 특징을 해지치 않는다.
- 빠른 실행 속도 - 코드 번경 후 곧바로 실행 가능하므로 디버깅이나 테스트에 유리하다.
- 동작 방식
- 초기 실행
- JVM은 처음엔 바이트코드를 인터프리터로 실행한다.
- 한 줄씩 해석하고 실행하기 때문에 처음엔 속도가 느릴 수 있다.
- 성능 최적화 단계
- JIT 컴파일러가 프로그램의 핫스팟(Hotspot)을 찾아낸다.
- 핫스팟: 자주 실행되는 코드(반복문, 주요 함수 등).
- 이런 코드만 기계어로 번환하여 실행 속도를 높인다.
- JIT 컴파일러가 프로그램의 핫스팟(Hotspot)을 찾아낸다.
- 캐싱
- 변환된 기계어 코드는 저장되어 이후 실행 시 재사용 된다.
- 이렇게 하면 반복적으로 실행되는 코드의 성능이 대폭 향상 된다.
- 초기 실행
- 핵심
2. 객체지향의 4대 원리를 조사해봅시다.
추상화
- 정의
- 중요한 정보만 남기고, 불필요한 세부 사항은 감추는 것을 말한다.
- 장점
- 시스템의 복잡성을 줄이고 이해하기 쉽게 만든다.
- 유지보수와 확장이 용이해진다.
캡슐화
- 정의
- 데이터와 메서드를 하나의 객체로 묶고, 외부에서 접근을 제한하여 데이터를 보호하는 것을 의미한다.
- 접근 제어자(private, protected, public)를 사용하여 필드와 메서드의 접근 범위를 제어한다.
- 데이터를 직접 접근하지 않고, Getter/Setter 메서드를 통해 간접적으로 접근하게 한다.
- 장점
- 데이터의 무결성을 유지할 수 있다.
- 코드의 재사용성과 유지보수성이 높아진다.
상속
- 정의
- 기존 클래스(부모 클래스)의 속성와 메서드를 다른 클래스(자식 클래스)에게 물려주는 것을 말한다.
- 코드의 재사용성을 높이고 계층 구조를 형성한다.
- 장점
- 코드의 중복을 줄이고 재사용성을 높인다.
- 계층 구조를 통해 논리적으로 설계를 정리할 수 있다.
다형성
- 정의
- 하나의 객체가 여러가지 형태를 가질 수 있는 성질을 말한다.
- 동일한 메서드나 연산이 다양한 객체에서 다르게 동장할 수 있다.
- 종류
- 컴파일타임 다형성(오버로딩) - 같은 이름의 메서드가 다양한 매게변수로 동작하도록 하는 것.
- 런타임 다형성(오버라이딩) - 부모 클래스의 메서드를 자식 클래스가 재정의하여 실행 시점에 다른 동작을 수행하도록 하는 것.
- 장점
- 코드의 유연성과 확장성을 높인다.
- 객체간의 관계를 보다 유연하게 설계할 수 있다.
깊게 생각해보기
- 추상화로 인해서 클래스파일 자체가 많아지는 것은 최종적으로 프로그램에 큰 영향이 없을까?
- 단기적으로 복잡해 보일 수 있지만, 적절하게 관리된다면 프로그램의 유지보수성, 확장성, 안정성 측면에서 긍정적인 효과를 가져온다.
- 클래스 파일이 많아지는 것이 최종적으로 미치는 영향
- 긍정적인 영향
- 모듈화 및 재사용성
- 추상화를 통해 기능이 명확히 분리된 클래스는 독립적으로 개발, 테스트, 재사용할 수 있다.
- 새로운 요구사항이 생길 때 기존 코드를 수정하지 않고 기능 추가가 가능하다.
- 유지보수성 향상
- 클래스가 적절히 분리되어 있다면, 특정 클래스의 변경이 다른 부분에 미치는 영향을 최소화할 수 있다.
- 코드가 분리되어 있으면 버그 수정과 확장이 용이하다.
- 확장성
- 추상 클래스를 기반으로 새로운 구현체를 추가하기 쉬워지므로, 프로그램 확장이 용이하다.
- 인터페이스 기반 설계를 통해 다양한 구현을 사용할 수 있는 유연성을 확보할 수 있다.
- 모듈화 및 재사용성
- 부정적인 영향(잘못된 설계의 경우)
- 복잡성 증가
- 클래스가 불필요하게 세분화되면 코드가 지나치게 복잡해지고, 개발자 간 협업시 혼란을 초래할 수 있다.
- 성능 영향
- 클래스가 많아지면서 JVM의 클래스 로딩 시간이나 메모리 사용량이 증가할 수 있다.
- 하지만 대부분의 경우 이는 무시할 수 있을 정도로 작은 영향이며, JIT 컴파일러와 클래스 로더가 최적화를 수행한다.
- 관리의 어려움
- 잘못 설계된 시스템에서는 관련 없는 클래스 간의 종속성이 늘어나고, 코드를 추적하기 어려워질 수 있다.
- 복잡성 증가
- 긍정적인 영향
3. 힙영역과 스택영역의 차이는 무엇일까요? 스택 영역이라는 이름은 어디에서 유래한걸까요?
힙 영역(Heap)
- 특징
- 동적 메모리 할당: 프로그램 실행 중 개발자가 필요에 따라 메모리를 할당하고 해제하는 공간이다.
- 크기: 힙은 상대적으로 크고, 고정된 크기가 아닌 시스템의 가용 메모리를 활용한다.
- 데이터 저장: 객체, 배열과 같은 동적 데이터가 저장된다.
- 관리 방식: 힙은 개발자가 직접 메모리를 할당(new 사용) 하고 필요 없으면 해제해야 하지만, Java 같은 언어는 가비지 컬렉터(Garbage Collector)가 자동으로 해제해 준다.
- 접근 속도: 스택보다 느리다. 메모리 할당과 해제가 더 복잡하기 때문이다.
- 생존 기간: 힙에 할당된 메모리는 프로그램이 끝날 때까지 유지 되거나, 가비지 컬렉터가 수거할 때까지 남아 있다.
- 예제
- arr 배열은 힙에 생성된다!
class Main {
public static void main(String[] args) {
int[] arr = new int[10]; // 힙에 메모리 할당
}
}
스택 영역(Stack)
- 특징
- 정적 메모리 할당: 함수 호출 시점에 자동으로 할당되고, 함수가 끝나면 자동으로 해제된다.
- 크기: 스택은 고정된 크기로 운영체제에서 관리하며, 보통 크기가 작다.
- 데이터 저장: 메서드의 지역 변수, 매개변수, 함수 호출 스택 프레임(Stack Frame)이 저장된다.
- 관리 방식: 개발자가 메모리를 직접 관리하지 않아도 된다. 함수 호출 시점에서 자동으로 메모리를 할당하고 해제 한다.
- 접근 속도: 힙보다 빠르다. 메모리가 단순한 구조로 관리되지 때문이다.
- 생존 기간: 스택에 저장된 변수는 함수가 종료되면 메모리에서 해제 된다.
- 예제
- x, y, z는 모두 스택에 저장된다!
class Main {
public static void main(String[] args) {
int x = 10; // 스택에 저장
method(x);
}
static void method(int y) {
int z = y + 1; // y와 z는 스택에 저장
}
}
힙과 스택의 비교
구분 | 힙(Heap) | 스택(Stack) |
메모리 할당 방식 | 동적 메모리 할당(런타임) | 정적 할당(컴파일 시점 결정) |
관리 주체 | 개발자 또는 가비지 컬렉터 | 자동으로 관리 |
속도 | 느림(메모리 탐색 및 할당 오버헤드 존재) | 빠름 (LIFO 구조로 접근) |
데이터 저장 | 동적으로 생성된 객체 및 데이터 | 지역 변수, 매개변수, 함수 호출 정보 |
생명 주기 | 개발자가 할당/해제(new/delete)하거나 가비지 컬렉터가 처리 | 함수 종료 시 자동 해제 |
구조 | 비 선형적(메모리 관리자가 빈 공간에 할당) | 선형적(Last-In-First-Out, LIFO) |
가비지 컬렉션 | 있음 (Java나 Python 등에서 불필요한 메모리를 정리) | 없음 (자동으로 해제됨) |
스택이라는 이름의 유래
스택(Stack)이라는 이름은 데이터가 쌓이는 방식 (LIFO: Last In, First Out)에서 유래 되었다.
- 스택 영역은 함수 호출 시 스택 프레임이라는 구조체가 스택에 쌓이고, 함수 종료 시 하나씩 제거된다. 이 동작 방식이 자료구조 스택과 유사하기 때문에 스택 영역이라 부른다.
- 함수가 호출되면 스택에 프레임이 추가(Push) 된다.
- 함수 실행이 끝나면 해당 프레임이 스택에서 제거(Pop) 된다.
4-1. 초기화가 되지 않은 String 타입의 변수에서 값을 읽어오면 어떻게 될까요?
- 지역 변수 (Local Variable)
- 반드시 초기화해야 하며, 초기화 하지 않고 값을 읽으려고 하면 컴파일 에러가 발생한다.
public class Example {
public static void main(String[] args) {
String localVar; // 초기화하지 않음
System.out.println(localVar); // 컴파일 에러 발생
}
}
- 인스턴스 변수(Instance Variable)
- 초기화 하지 않으면 기본값으로 null이 자동으로 설정 된다.
public class Test {
String str; // 초기화하지 않음, 기본값은 null
public static void main(String[] args) {
Test test = new Test();
System.out.println(test.str); // 출력: null
}
}
4-2. NullPointerException 은 어떤 상황에 발생할까요?
- NullPointerException은 null 값을 참조하려는 잘못된 접근이 이루어질 때 발생한다.
- 메서드 호출 시
- null 값을 가진 객체로 메서드를 호출하려고 할 때.
String str = null;
System.out.println(str.length()); // NullPointerException 발생
- 필드 접근시
- null 객체의 맴버 필드에 접근하려고 할 때.
Example obj = null;
System.out.println(obj.instanceVar); // NullPointerException 발생
- 배열 접근시
- null 참조를 가진 배열에 접근하려고 할 때.
int[] arr = null;
System.out.println(arr.length); // NullPointerException 발생
- Wrapper 클래스에서 언박싱 시
- null 값을 가진 Wrapper 객체를 기본형으로 변환하려고 할 때.
Integer num = null;
int value = num; // NullPointerException 발생
NullPointerException 예방 방법
- 초기화
- 변수를 선언할 때 항상 적절한 값으로 초기화 한다.
- null 체크
- 메서드 호출이나 필드 접근 전에 null 인지 확인한다.
- Optional 사용 (Java 8이상)
- Optional 클래스를 사용하여 null 가능성을 안전하게 처리한다.
5. 연산자들의 우선순위에 대해서 알아봅시다.
- 가장 높은 우선순위(묶음 연산자)
- 괄호 ()로 묶인 부분은 무조건 먼저 계산된다.
int result = (3 + 5) * 2; // 괄호 안을 먼저 계산: 8 * 2 = 16
- 단항 연산자
- 변수나 값을 하나만 다루는 연산자들.
- 증감 연산자: ++, --
- 부호 연산자: +, -
- 논리 부정: !
- 변수나 값을 하나만 다루는 연산자들.
int a = 5;
int b = ++a + 2; // ++a 먼저 실행: a=6, b=8
- 산술 연산자
- 덧셈, 뺄셈보다 곱셈, 나눗셈, 나머지가 우선순위가 높다.
- 곱셈/나눗셈/나머지: *, /, %
- 덧셈/뺄셈: +, -
- 덧셈, 뺄셈보다 곱셈, 나눗셈, 나머지가 우선순위가 높다.
int result = 10 + 2 * 5; // 곱셈 먼저: 10 + 10 = 20
- 비교 연산자
- 대소 비교: <, >, <=, >=
- 동등 비교: ==, !=
boolean isGreater = 5 > 3; // true
boolean isEqual = 5 == 3; // false
- 논리 연산자
- 논리 AND: && (둘 다 참일 때만 참)
- 논리 OR: || (둘 중 하나만 참이면 참)
boolean result = (5 > 3) && (2 < 4); // true && true = true
- 조건 연산자 (삼항 연산자)
- 조건 ? 값1 : 값2 (조건이 참이면 값1, 거짓이면 값2)
int result = (5 > 3) ? 10 : 20; // 5 > 3이 참이므로 result = 10
- 대입 연산자
- 값을 변수에 할당하는 연산: =, +=, -=, *=, /=
int a = 5; // a에 5를 대입
a += 3; // a = a + 3 (a는 8)
예시
int a = 10, b = 5, c = 2;
int result = a - b + c * 3 > 10 ? a : b;
계산 순서:
- c * 3 → 2 * 3 = 6
- b + 6 → 5 + 6 = 11
- a - 11 → 10 - 11 = -1
- -1 > 10 → false
- 삼항 연산 → 결과는 b
결과: result = 5
[스터디 후 느낀점 및 반성]
흠... 정말 부끄럽게도 공부 + 블로그에 정리해놓았던 개념들도 어버버 하면서 "모르겠습니다!"의 반복이였다.....
멘붕이였다... 그래서 나도 모르게 "죄송합니다!" 라고 해버렸따.... 내스스로한테 죄송해야하는데 퓨ㅠㅠ 황금같은 시간을 좀더 활용하기 위해서 좀 제대로 공부를 해서 가야겠다는 생각을 했다...
근데 공부를 해도 까먹는것은 어떻게 해야하는 걸까? 너무 슬프다
반응형
'Study > Study Alone' 카테고리의 다른 글
[나혼자공부] 4주차 복습-2 (0) | 2024.12.26 |
---|---|
[나혼자공부] 4주차 복습-1 (2) | 2024.12.25 |
[나혼자공부] 3주차 복습-2 (1) | 2024.12.20 |
[나혼자공부] 3주차 복습-1 (0) | 2024.12.19 |
[STUDY] 2주차 (0) | 2024.12.16 |
댓글