🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [42]일차
🚀42일차에는 Spring JDBC, MyBatis, JPA 의 표준 사용을 도와주는 Spring Data JDBC에 대해서 배울 수 있었다.
이전 회고에서 Spring JDBC와 Spring Data JDBC를 비교할때에는 어려워보이는 개념들이었는데
개념을 먼저 알고 실습을 함으로써 Spring Data JDBC에 대해 이해할 수 있게 되었다.
스트림 API
데이터 처리 메소드
filter()
- Predicate를 받아들이며 filter의 조건을 boolean형으로 판단하고 Stream으로 반환
- 이 후 collect() 메소드 사용으로 중간연산인 filter()로 반환된 Stream을 toList()를 통해 리스트로 바꿔서 반환
▶️실습 - 글자수 5개 이상인 것 필터링 + 중복 제거 + 새로운 리스트 얻어내기
스트림 사용 X
List<String> words = Arrays.asList("Apple", "Banana", "Cherry", "Apple", "Cherry", "Date");
// 1. 스트림 사용 X
// 1-1) HashSet 사용으로 중복제거
Set<String> distinctList = new HashSet<>(words);
List<String> result = new ArrayList<>();
// 1-1) 다른방법. contains()메소드 사용
for(String word : words){
if(word.length() >= 5 && !result.contains(word)){
result.add(word);
}
}
// 1-2) 조건 필터링
for(String word : distinctList){
if(word.length() >= 5){
result.add(word);
}
}
// 1-3) 새로운 리스트 출력
System.out.println(result);
- 스트림을 사용하지 않을때는
HashSet 컬렉션 프레임워크 사용이나 contains()메소드를 통해 구현할 수 있다. - HashSet 컬렉션 프레임워크 사용 방법 : 중복을 먼저 제거한 후 조건 필터링하는 방법
- contains() 메소드를 사용하는 방법 : 조건 필터링과 리스트에 값이 존재하는지의 체크를 동시에 수행하는 방법
- >> 출력 [Apple, Banana, Cherry]
스트림 사용 O
// 2. 스트림 사용 O
List<String> streamResult = words.stream()
.filter(w -> (w.length() >= 5))
.distinct()
.collect(Collectors.toList());
System.out.println(streamResult);
- Stream을 사용하면 코드를 간결하게 표현할 수 있다.
- distinct()는 내부적으로 equals()메소드를 기반으로 요소들의 동등성을 판단하고 있다.
❓ collect(Collectors.toList()) vs toList()
.collect() 사용 시 새로운 리스트를 반환하기때문에 그 리스트에 add()로 새 값을 넣는 것이 가능 (=가변리스트)
반면 .toList() 사용 시 리스트로 반환하긴 하지만 add()를 사용하고자하면 오류가 발생. (=불변리스트)
데이터 변환 메소드
- map()
➡️각 요소를 다른 형태로 변환
Function 인터페이스를 인자로 받으므로 이 함수는 각 요소를 다른 형태로 매핑하는 역할을 수행
ex. stream.map(element → element.toUpperCase())
➡️각 스트림의 각 문자열 요소를 대문자로 변환하는 등에 사용 - flatMap()
➡️리스트 안에 리스트가 들어있는것과같은 형태에서 그 스트림들을 하나의 스트림으로 합침으로써
중첩된 구조를 평탄화하는데 사용
ex. stream.flatMap(list → list.stream())
➡️각 요소(리스트 등)를 스트림으로 변환하고 이러한 스트림들을 하나의 스트림으로 합침
map()
List<String> words = Arrays.asList("Apple", "Banana", "Cherry", "Apple", "Cherry", "Date");
words.stream()
.map(w -> w.toLowerCase())
.distinct()
.forEach(System.out::println);
- map()으로 모든 데이터를 소문자로 변환
- distinct()로 중복을 제거 후 forEach()로 출력 (System.out::println 메소드 참조 사용
- >> 출력 apple, banana, cherry, date
map() - 변환된 연산 값 반환
// map() : 각 요소 연산 결과 반환
int[] aptNumber = {2, 4, 5, 7, 33, 14, 22, 31, 100};
Arrays.stream(aptNumber)
.map(n -> n + 1000)
.forEach(System.out::println);
❓만약 stream()을 통해 가공된 데이터를 다시 배열로 바꾸고자할때
.toArray(Integer[]::new);
➡️.toArray()를 사용
이때 Object배열로 리턴되므로 기존의 int형으로 리턴하기 위해서는 Integer로 바꿔줘야함
flatMap()
List<List<String>> nestedList = Arrays.asList(
Arrays.asList("Apple", "Banana"),
Arrays.asList("Cherry", "Date")
);
List<String> flattenedList = nestedList.stream()
.flatMap(List<String>::stream)
.collect(Collectors.toList());
System.out.println(flattenedList);
- >> 출력 [Apple, Banana, Cherry, Date]
- 중첩된 스트림이 단일 스트림으로 합쳐져 출력되는 것을 확인할 수 있다.
데이터 정렬 메소드
sorted() : 오름차순 (기본)
// sorted() : 정렬
Arrays.stream(aptNumber)
.sorted()
.forEach(System.out::println);
- 오름차순 정렬이 (기본정렬)이다.
- >> 출력 [2, 4, 5, 7… 순] 으로 출력
sorted() : 내림차순
// sorted() : 내림차순
Arrays.stream(aptNumber)
.boxed()
.sorted(Comparator.reverseOrder())
.forEach(System.out::println);
- .boxed()
➡️boxed() 사용으로 int → Integer 형으로 박싱해주어야
Comparator클래스의 reverseOrder()메소드를 사용할 수 있다.
데이터 순회 메소드
- forEach(), peek()
- peek() : 각 스트림의 요소에 대해 작업을 수행하지만 스트림 자체는 변화시키지 않고
peek()는 중간연산에 해당되어 디버깅이나 요소에 대한 임시 처리를 위해 사용
활용 : 스트림의 흐름을 중단하지 않고 중간에 값을 확인하고 싶을때 사용
peek()
int[] arr = {2, 100, 5, 7, 33, 14, 22, 31, 17};
Arrays.stream(arr)
.sorted()
.peek(n -> System.out.println("[peek()] : " + n))
.map(n -> n + 1000)
.forEach(n -> System.out.println("[forEach()] : " + n));
- forEach()가 데이터흐름을 중단하여 출력하는 것과 달리
peek()는 중간에 데이터흐름을 중단하지 않고 임시 처리를 담당 - 활용 : 스트림의 중간 상태를 확인하면서 디버깅하는데 도움 (중간연산이 어떻게 진행되고 있는지 등)
매칭, 검색, 집계 연산 메소드
조건 매칭 - allMatch(), anyMatch(), noneMatch()
- 주어진 조건에 맞는 요소가 있는지 확인하는데 사용하고 데이터 집합이 특정 조건을 만족하는지 효과적으로 검증가능
- allMatch()
➡️스트림의 모든 요소가 주어진 조건을 만족하는지 검사
ex. 모든 요소가 양수인지 확인하는 경우 사용 → 따라서 데이터 전체가 특정 조건을 충족해야함 - anyMatch()
➡️어느 하나의 요소라도 조건을 만족하는지 검사 - noneMatch()
➡️모든 요소가 조건을 만족하지 않는지 검사
▶️실습 - 조건 매칭 메소드
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// allMatch() : 모든 조건이 양수인지 검사
System.out.println("[allMatch - 모든 조건이 양수입니까?] : " + numbers.stream().allMatch(n -> n > 0));
// anyMatch() : 어떤 조건이라도 음수의 조건을 만족하는지 검사
System.out.println("[anyMatch - 어느 요소라도 음수를 만족합니까?] : " + numbers.stream().anyMatch(n -> n < 0));
// noneMatch() : 모든 조건이 10 이상이 아닌지 검사
System.out.println("[noneMatch - 모든 조건이 10 이상입니까?] : " + numbers.stream().noneMatch(n -> n > 10));
집계 연산 - count(), max(), min(), average(), sum()
- 특정 기준에 따라 데이터를 요약하고 분석하는데 사용
- 대규모 데이터셋에서 유용한 정보를 추출가능
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// count() : 개수 집계
long count = numbers.stream().count();
System.out.println(count);
// max()
int max = numbers.stream().max(Integer::compareTo).orElse(0);
System.out.println("최대값 결과 : " + max);
// min()
int min = numbers.stream().min(Integer::compareTo).orElse(0);
System.out.println("최소값 결과 : " + min);
- max() 메소드는 반환값으로 Optional을 리턴
- 이어서 orElse를 통해 리스트가 비어있을 경우에 0을 넣도록 설정 가능
average()
- 평균을 구할때는 Object나 Optional을 평균낼순 없을 것
- map을 통해 데이터의 형태를 바꿔준 후 진행해주어야함
// average()
double average = numbers.stream().mapToInt(Integer::intValue).average().orElse(0.0);
System.out.println("평균값 결과 : " + average);
- mapToInt(Integer::intValue)
mapToInt()를 통해 정수형으로 바꿔주고 intValue()를 통해 int 값으로 넣게됨
mapToInt()는 언박싱에 사용되는 메소드
- 예를 들어 Integer의 valueOf()로 값을 넣을 수 있고 이 과정을 컴파일러가 대신해주지만
intValue()의 값으로는 int형을 받아들이는 것을 확인 가능
- average()메소드는 반환값으로 max() 메소드와 마찬가지로 Optional을 리턴하기때문에
orElse(0.0)이나 getAsDouble() 등의 Optional이 제공하는 메소드를 사용해야함
sum()
// sum()
int sum = numbers.stream().mapToInt(Integer::intValue).sum();
System.out.println("총 합 결과 : " + sum);
- sum()또한 average()처럼 바로 사용할 순 없고 mapToInt()로 Integer → int 로 바꿔줘야함
- sum() 메소드는 반환값으로 max(), average() 메소드와 달리 Optional이 아닌 int로 바로 리턴해주므로
orElse()와 같은 Optional 메소드를 사용하지 않아도됨
데이터 축소 메소드
- 리듀싱 연산 (reduce())
- 스트림 내의 여러 데이터를 특정 기준에 따라 하나의 결과로 결합하는 과정
(=스트림 내 요소들을 순차적으로나 병렬적으로 처리하여 하나의 결론을 도출)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
- 0은 초기값이고 a, b → a + b 람다식으로 두 요소를 더해서 누적
스트림 결과 수집
- Collectors 클래스를 활용
- 스트림의 요소들을 다양한 형태의 “결과로 수집”하는데 사용하는 메소드 제공
- 자바 8에서 도입된 스트림 API중요한 클래스
- 데이터를 그룹화, 정리, 요약하는 과정을 단순화
복잡한 데이터 처리
- 예를 들어 Path 사용
- Files의 list() 메소드는 Stream을 반환하고, 예외처리가 필요함 (IO이므로)
▶️실습 - Path로 복잡한 데이터 처리
public static void main(String[] args) throws IOException {
Path path = Paths.get("src/day18/stream/");
Stream<Path> stream = Files.list(path);
stream.forEach(p -> System.out.println(p.getFileName()));
stream.close();
}
- throws Exception으로 예외처리를 컴파일러에게 넘김
- Files.list(path)
➡️Stream을 반환할때 타입은 Path로 받음 - stream.forEach(p -> System.out.println(p.getFileName()));
➡️forEach()로써 getFileName()을 출력하도록 구현 - stream.close();
➡️File이라는 IO 객체를 사용했으므로 close()로써 IO사용을 닫아주어야함 - >> 출력 : stream패키지 안의 파일이름들이 출력 (StreamExam02.java, StreamExam03.java ...)
▶️실습 - Path로 복잡한 데이터 처리 2
Stream<String> stream2 = Files.lines(Paths.get("src/day18/stream/StreamExam03.java"));
stream2.forEach(System.out::println);
- >> 출력 : StreamExam03.java의 파일내용을 Stream으로 읽어와서 로그창에 그대로 출력
이처럼 스트림 API 사용으로 데이터 처리작업 간소화, 코드가독성 향상, 복잡한 데이터 처리 간소화 등이 가능
스트림 연산의 순서
- map()후 filter()하는 것보다는 filter()후 map()을 하는 것이 권장
➡️조건에 맞게 미리 필터링하게되면
매핑 연산을 하기 전에 처리해야할 데이터의 양을 줄일 수 있게되어 성능이 향상
❓순차스트림 vs 병렬스트림
➡️병렬스트림은 주로 데이터 처리량이 많은 작업에서 유리하기떄문에
데이터 크기에 작은 작업량에 따라서는 순차 스트림이 더 효율적일 수 있다.
무한 스트림
➡️Stream.generate()나 Stream.iterate() 사용 시 종료 조건을 명시해야 스트림이 무한정으로 늘어나는 것을 방지 가능
🚀실습 - 모든 요소의 짝수 검사
람다식 사용 O + 스트림 사용 X
List<Integer> numbers = Arrays.asList(2, 4, 6, 8, 10);
// 1. 람다식을 사용해 모든 요소 검사
Predicate<Integer> isEven = n -> n % 2 == 0;
boolean allEven = true;
for(Integer number : numbers){
if(!isEven.test(number)){
allEven = false;
break;
}
}
System.out.println("[람다식] 모든 요소가 짝수입니까? : " + allEven);
람다식 사용 O + 스트림 사용 O
// 2. 스트림을 사용한 모든 요소 검사
boolean allEvenStream = numbers.stream().allMatch(n -> n % 2 == 0);
System.out.println("[스트림] 모든 요소가 짝수입니까? : " + allEvenStream);
Collectors.groupingBy()
Map<Category, List<Product>> groupedByCategory =
products.stream().collect(Collectors.groupingBy(Product::getCategory));
- products.stream()
products는 List<Product> 타입이므로 stream()을 호출 시 products 리스트가 스트림으로 변환
스트림으로 변환으로 Product 객체들이 연속적인 흐름을 처리할 수 있도록함 - collect(Collectors.groupingBy(Product::getCategory))
➡️.collect() : 스트림의 요소들을 수집(collect)해서 원하는 자료구조로 변환해줌
특정 기준(getCategory()의 반환 값)에 따라 그룹핑함 - groupingBy()
➡️이 메소드를 그룹핑할때의 Key로 사용(=기준으로 사용)
각 Product의 getCategory() 값을 기준으로 Product들을 묶어서 맵(Map)으로 변환한다는 것
🚀실습 - 과일 데이터 groupingBy() 수행
과일 이름 | 과일 색깔 |
Apple | Red |
Tomato | Red |
Banana | Yellow |
Lemon | Yellow |
여기서 Map<Color, List<Fruit>> 수행
- 1. 과일 리스트의 각 요소(Fruit 객체)에 대해 getColor() 호출
- 2. 해당 Color를 Key로 하는 Map 생성
- 3. 같은 Color의 Key를 가진 Fruit들을 List<Fruit> 형태로 묶음.
{
Red: [Apple, Tomato],
Yellow: [Banana, Lemon]
}
- 과일 리스트를 getColor() 값(색깔) 기준으로 묶어서
➡️"색깔 → 해당 색깔인 과일들" 형태의 Map을 만들어낸 것
스트림의 병렬처리
parallelStream()
List<Integer> largeList = // 큰 데이터 세트
largeList.parallelStream()
.filter(...) // CPU 집약적인 연산
.collect(Collectors.toList());
- largeList.parallelStream()
.parallelStream() 호출 시병렬 스트림 생성
➡️병렬 스트림 : 여러 개의 CPU 코어를 활용해 스트림 연산을 병렬로 실행할 수 있도록 도와줌
정리하자면
.stream()을 사용하면 단일 스레드(순차 처리)
.parallelStream()을 사용하면 멀티 스레드(병렬 처리)
❓병렬 스트림
➡️병렬 스트림이 항상 더 빠른 것은 아님.
작은 데이터에서는 오히려 오버헤드 발생으로 성능이 낮아질 수 있음
큰 데이터나 CPU 부하가 높은 연산에서 병렬 스트림 사용이 효율적일 것
⚠️병렬 처리 시 공유 자원을 변경하면 race condition(경쟁 상태, 레이스컨디션)이 발생할 수 있음
값의 정렬 순서를 보장하지 않기때문에 .sequential()로 변환하여 순서를 보장하도록 해야할 수 있음
Spring Data JDBC
- Spring JDBC에서는 private JdbcTemplate jdbcTemplate; 처럼 직접 접근해서 사용했던 것을
Spring Data JDBC에서는 직접 접근해서 사용하지 않을 것 - 대신 Spring Data Repository Inteface를 제공 (=Repository interface)
➡️Spring Data JDBC의 일부로, 데이터 접근 계층을 단순화하는 패턴 중 하나 - 이 인터페이스를 사용 시 개발자는 DB 연산을 위한 기본적인 메소드들을 자동으로 사용 가능
(=필요한 메소드들을 선언만해놓으면 자동으로 연산을 수행한다는 것)
➡️실습 - CommandLineRunner
기존 - CommandLineRunner
@SpringBootApplication
public class MainApplication implements CommandLineRunner {
public static void main(String[] args) {
SpringApplication.run(MainApplication.class);
}
@Override
public void run(String... args) throws Exception {
// 테스트할 코드 입력
}
}
- 기존에 이처럼 implements CommandLineRunner로 사용하던 CommandLineRunner를 @Bean으로 등록할 수 있다.
@Bean 등록 - CommandLineRunner
@Bean
public CommandLineRunner demo(UserDao userDao) {
return args -> {
try {
// Create
userDao.insertUser(new User(null, "han", "han@example.com"));
// Read
List<User> users = userDao.findAllUsers();
users.forEach(user -> System.out.println(user.getName() + " - " + user.getEmail()));
// Update
userDao.updateUserEmail("han", "new.han@example.com");
// Delete
// userDao.deleteUser("Esther");
} catch (UserNotFoundException e) {
System.out.println("Error: " + e.getMessage());
} catch (Exception e) {
System.out.println("General error occurred: " + e.getMessage());
}
};
}
- CommandLineRunner를 @Bean으로 등록하면
@springBootApplication이 이 Bean을 자동으로 찾아서
테스트할 코드를 자동으로 동작하게끔 할 것이다 - public CommandLineRunner demo(…)
CommandLineRunner 사용 시 demo라는 이름으로 메소드 수행
@Bean
public Book book(){
return new Book();
}
- Book객체를 리턴하는 방식으로 Bean을 구현했던 것처럼
return args -> { ... };
- CommandLineRunner 또한 return부를 구현할 수 있다.
@Bean - CommandLineRunner (람다식 사용)
@Bean
public CommandLineRunner demo(){
// 1. 람다식 사용 X
// return new CommandLineRunner(){
// @Override
// public void run(String... args) throws Exception{
// ...
// }
// };
// 2. 위의 코드를 람다식으로
return args -> { ... };
정리하자면
1. implements CommandLineRunner 방법
2. @Bean등록 CommandLineRunner 방법 모두 사용 가능
Repository 인터페이스 사용
- DTO : Data Transfer Object : 단순히 값만 담아서 전달되는 객체
- Repository 인터페이스에서는 DTO와 유사하게 Entity를 제공
- Entity : DTO의 역할도 하지만 데이터베이스와 객체간의 관계를 알려주는 역할도 한다.
@Table 어노테이션을 사용 가능
@Table("users")
public class User {
@Id
private Long id;
private String name;
private String email;
}
- @Table("users")
➡️데이터베이스의 사용하고자하는 테이블의 이름 명시
>> DB테이블 명과 정확히 일치해야함 - @Id
➡️테이블에는 Primary Key도 사용하므로 @Id를 붙여줌으로써 Primary Key를 명시
그외로 @Getter… 등 필요한 어노테이션도 붙여준다.
public User(String name, String email) {
this.name = name;
this.email = email;
}
또한 생성자 중에 Primary Key를 뺀 두 필드만 받는 생성자도 만듦
▶️실습 - UserRepository 인터페이스
- 실제 수행할 메소드들을 정의
- extends CrudRepository를 상속
- CrudRepository의 인자로
첫번째 인자 : 사용할 엔티티
두번째 인자 : @Id로 사용할 필드의 타입
- 상속받는 CrudRepository 인터페이스에 들어가보면
- CRUD 연산에 대해 이미 구현이 되어있다.
MainApplication 클래스
@SpringBootApplication
public class MainApplication {
public static void main(String[] args) {
SpringApplication.run(MainApplication.class);
}
@Bean
public CommandLineRunner demo(UserRepository userRepository){
return args -> {
userRepository.save(new User("Harvey", "Barnes@premier.com"));
};
}
}
- save() 메소드에 데이터를 넣고 테스트
- MySQL Workbench로 확인해보면 값이 제대로 들어와있다.
findById()
User findUser = userRepository.findById(12L).get(); // 방금 넣은 값의 id가 12이기때문에 테스트
System.out.println(findUser);
- findById() 메소드는 반환값으로 Optional을 리턴하기때문에
값이 없었을 경우에 처리할 메소드 orElse() 등을 사용할 수 있다.
- 로그 창으로 출력해보면 가져와지는 것을 확인 가능
count()
- 전체 몇건이 있는지 테스트
System.out.println("현재 DB에 저장된 데이터 개수 : " + userRepository.count()); // 5 출력
Spring Data JPA - 직접 메소드 정의
- UserRepository에 사용자가 직접 메소드 정의
public interface UserRepository extends CrudRepository<User, Long> {
List<User> findByName(String name);
}
- 원하는 타입과 메소드에 필요한 인자들을 선언만해주면
기존에 findById()로 Id로써 검색하던 것처럼 내부적으로 메소드 이름을 분석하여 자동으로 메소드를 구현해줌
▶️실습 - 메소드 직접 정의
userRepository.findByName("Harvey").stream()
.forEach(System.out::println);
- findByName("Harvey")
위에서 직접 정의한 findByName은 반환타입으로 리스트 타입을 반환하므로 - stream() 메소드를 바로 붙여서 사용 가능
- forEach() 최종연산 메소드로 바로 결과까지 출력
커스텀 쿼리 메소드
- Spring Data JDBC 가 대신 구현해줬던 메소드를 직접 구현하여 사용 가능하다는 것
- 위에서 정의했었던 findByName은 Spring Data JPA의 Repository에서 제공하는 “쿼리 메소드”의 예시
➡️findByName(String name) 같은 쿼리 메소드(Query Method) 는
Spring Data JPA에서 자동으로 구현해주는 기능이며
Spring Data JDBC에서는 이런 기능이 자동으로 동작하지 않는다.
Spring Data JDBC에서는 @Query 또는 직접 작성한 SQL을 사용해야한다는 특징이 있다. - 쿼리 메소드 : 메소드의 “이름을 분석하여 해당 이름에 기반한 SQL쿼리를 자동으로 생성하고 실행하는 기능을 제공”
➡️개발자가 SQL을 직접 작성하지 않아도 DB에 대한 조회, 저장, 삭제 등의 작업을 수행할 수 있게 해줌
커스텀 쿼리 메소드의 이름 규칙
- DB에 어떤 쿼리를 수행할지 이름으로 예측 가능
- 접두어 : 일반적으로 find…By, read…By, query…By, count…By, get…By 등이 사용 (쿼리의 시작점)
- 조건 표현 : By이후에는 쿼리의 조건을 표현 (ex. FindByName 은 Name필드를 기준으로 데이터 조회)
- 조건 연결 : And나 Or로 연결 (findByNameAndEmail)
- 조건 상세화 및 정렬, 리턴타입 등도 존재
🚀 추가로 학습한 스트림 API에 대해 정리하면서 편리하지만 그만큼 기능도 많아 알아야할 메소드가 많다는 것을 느꼈다.
Spring Data JDBC에 대한 것을 배우면서 Spring JDBC보다 더 편리한 기능을 제공함으로써
자동으로 CRUD연산을 수행하는 것을 회고를 진행하면서 더 자세히 알 수 있었다.
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_43일차_"친구목록 페이지" (1) | 2025.02.07 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_42일차_"SQL 기반 페이징 기법" (0) | 2025.02.06 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_41일차_"람다식 / 스트림 API" (0) | 2025.02.05 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_40일차_"Spring JDBC" (1) | 2025.02.04 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_39일차_"쿠키 / 세션" (1) | 2025.02.03 |