🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [47]일차
🚀47일차에는 JPA에서 테이블 간의 관계 매핑을 어떻게 구현할 수 있는지 배울 수 있었다.
@OneToMany, @ManyToOne 등 테이블 간 1:N (일 대 다) 관계를 정의해낼 수 있는 것을 실습해보았다.
학습 목표 : 테이블 간 관계매핑을 JPA를 통해 구현해낼 수 있어야함, 테이블의 제약조건을 클래스로써 정의해야함
학습 과정 : 회고를 통해 작성
EntityManagerFactory
- 🚀EntityManagerFactory에 대한 이해를 하기 위해 추가적으로 회고 정리를 하였다.
UserDAO
// 필드 선언
private EntityManagerFactory emf;
- EntityManagerFactory 인스턴스는 EntityManager를 생성하는 공장 역할을 하는데
- 만들어진 EntityManager는 엔티티의 생명주기를 관리하게 된다.
- 이 EntityManagerFactory 인스턴스는 비용이 많이드므로 애플리케이션에서 하나만 생성하여 사용
➡️ex. UserDAO 인스턴스를 2개 이상 만들거나 다른 DAO에서도 EntityManagerFactory를 사용한다면
⭐이를 관리하는 “싱글턴 패턴”으로 만들어진 Factory가 있는 것이 좋음 - EntityManagerFactory 인스턴스는 스레드에 안전하고 애플리케이션 전반에 걸쳐
여러 스레드에 걸쳐 공유되지만, EntityManager 인스턴스는 스레드에 안전하지 않음
public UserDAO(){
emf = Persistence.createEntityManagerFactory("UserPU");
}
- Persistence클래스에서 createEntityManagerFactory 메소드를 호출하여 EntityManagerFactory를 초기화함
- UserDAO클래스에서는 이처럼 DAO객체의 생성시 (=생성자를 이용해) EntityManagerFactory를 생성하도록 함.
➡️스레드 안전성 관점에서 안전 - 이렇게 생성된 EntityManagerFactory는 여러 스레드에 걸쳐 EntityManagr 인스턴스를 생성하는데 사용됨
❓이유 : 자원이 많이 소모되는 EntityManagerFactory이므로 “한번 생성”하고 필요시마다
재사용하여 EntityManager 인스턴스를 생성함으로써 효율적으로 사용하는 것
UserDAO - 데이터 조회 메소드 분석
public void createUser(User user) {
EntityManager em = emf.createEntityManager();
try {
em.getTransaction().begin();
em.persist(user);
em.getTransaction().commit();
} finally {
em.close();
}
- 클래스의 각 메소드는 메소드 범위내에서 정확하게 EntityManager를 생성하고 닫아야함
➡️EntityManager가 스레드에 안전하지 않기 때문임
➡️ 각 트랜잭션이 독립적으로 처리되도록 보장하는 것.
➡️EntityManager를 스레드 간 공유하지 않아야한다는 요구사항을 준수하게됨 - persist(user)
➡️User엔티티를 영속성 컨텍스트에 젖아하여, 트랜잭션이 커밋될때 데이터베이스에 반영되도록 함 - em.close()
➡️finally블록에서는 예외발생여부와 상관없이 EntityManager를 항상 닫아 자원을 정리하도록 함
JPAUtil
- Java 애플리케이션에서 JPA를 사용하여 DB작업을 수해하기 위해 필요한 EntityManagerFactory
- 이 EntityManagerFactory를 관리하는 유틸리티 클래스 JPAUtil
- 싱글턴 패턴 : 생성자가 private해야함
❓싱글턴 패턴에서 생성자가 private한 이유
➡️하나의 클래스가 오직 하나의 인스턴스만 가지도록 보장하는 디자인 패턴이므로 생성자를 private으로 선언
➡️생성자가 private하면 외부에서 해당 클래스의 객체를 직접 생성할 수 없습니다.
➡️이를 통해 클래스 내부에서만 인스턴스를 생성하고 관리할 수 있습니다.
JPAUtil
public class JPAUtil {
private static final EntityManagerFactory emfInstance =
Persistence.createEntityManagerFactory("UserPU");
// Java 어플리케이션이 종료될 때 자동으로 close()메소드가 호출되도록 합니다.
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if (emfInstance != null) {
System.out.println("---- emf close ---");
emfInstance.close();
}
}));
}
private JPAUtil() {}
public static EntityManagerFactory getEntityManagerFactory() {
return emfInstance;
}
}
- Runtime 클래스를 통해 getRuntime()메소드로 Runtime을 얻어오고, JVM이 종료될때 (=애플리케이션이 종료될때)
addShutdownHook()메소드를 호출하여 emfInstance.close() 처럼 EntityManagerFactory 인스턴스 사용을 닫아줌
JPAUtil - Log 사용
@Slf4j
public class JPAUtil {
//...
static {
//...
log.info("---- emf close ----");
emfInstance.close();
}
//...
private static final EntityManagerFactory emfInstance = …
static { ... }
public static EntityManagerFactory getEntityManagerFactory() { return emfInstance; }
- private JPAUtil() {} 처럼 생성자를 private으로 선언함으로써 JPAUtil()생성자가 호출된
- static으로 지정된 static관련 필드와 메소드가 실행이되고 getEntityManagerFactory() 메소드로부터는
emfInstacne를 반환받음
➡️EntityManagerFactory 인스턴스를 final로 선언함으로써 하나만 만들어질수 있도록 한다.
▶️실습 - UserDAO 리팩토링 - JPAUtil 사용
변경 전 UserDAO
- entityManagerFactory를 직접 생성
// User엔티티를 받아서 생성
public void createUser(User user){
EntityManager entityManager = entityManagerFactory.createEntityManager();
try{
entityManager.getTransaction().begin();
// 엔티티를 영속성 컨텍스트에서 관리할 수 있도록 받아들인 user엔티티를 넣음
entityManager.persist(user);
entityManager.getTransaction().commit();
}finally{
entityManager.close();
}
}
JPAUtil 적용 후 UserDAO
public void createUser(User user) {
EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
try {
em.getTransaction().begin();
em.persist(user);
em.getTransaction().commit();
} finally {
em.close();
}
}
- JPAUtil.getEntityManagerFactory().createEntityManager();
➡️JPAUtil을 통해 EntityManagerFactory를 get() 해오고, 이를 통해 EntityManager를 생성
❓JPAUtil 사용의 이유
➡️EntityManagerFactory가 각기 다른 DAO마다 매번 생성(=공장이 매번 만들어짐)되면 안되기때문에
JPAUtil을 이용한 방법을 사용
ex. A 디자인의 옷이 필요한데 5명이 A디자인 옷을 구입하려할때
하나의 공장에서 A디자인 옷 5개를 만들면 되지만
5개의 공장에서 A디자인 옷을 각각 만드는 것은 적합하지 않음
emfInstance.close()를 통해서 모든 옷이 만들어지면 공장을 닫아주는 것을 구현
➡️JPAUtil 클래스 안에서 구현 (Runtime클래스 활용)
⭐즉 EntityManagerFactory를 하나 만들어서 EntityManager를 여러개 만드는 것이 효율적
▶️실습 - UserDAO 리팩토링 - Logger 사용
// updateUser() 메소드
//...
entityManager.merge(user);
log.info("[수정] user : {}", user.getName());
log.info("[수정] user : {}", user.getEmail());
// deleteUser() 메소드
//...
entityManager.remove(entityManager.contains(user)?user : entityManager.merge(user));
log.info("[삭제] user : {}", user.getName());
log.info("[삭제] user : {}", user.getEmail());
엔티티 Entity
- JPA에서 가장 핵심적인 개념 중 하나
- 엔티티 : 데이터베이스 테이블의 행에 대응되는 객체지향모델
- {id = 2, email = sample@exam.com, name = Rush} 과 같은 한 행(row)이 엔티티라고 볼 수 있다.
엔티티에서 hashCode, equals()메소드의 필요성
- 자바에서 객체의 동등성을 결정하는 중요한 메소드
- 이 메소드들이 JPA에서 엔티티 객체 사용 시 잘 구현되어있는지 확인해야함
➡️엔티티의 동등성이 제대로 처리되어야하기때문 - 일반적으로 식별자 필드 (ex. id)를 기준으로 구현됨, 엔티티의 id는 DB의 저장된 레코드를 대표하기때문
영속성 컨텍스트 Persistence Context
- 엔티티를 영구저장하는 환경을 의미
- 엔티티의 생명주기와 상태를 관리
- 엔티티매니저에서를 통해 엔티티가 영속성 컨텍스트에 저장될때 ( persist() )
JPA는 엔티티의 상태를 관리하고 DB와의 동기화를 담당 - 주요 기능
➡️1차 캐시 : 엔티티의 1차 캐시 역할을 수행하여 한 트랜잭션 내에서의 반복된 DB호출을 최소화해줌
➡️엔티티의 생명주기 관리
➡️변경 감지 (Dirty Checking) :
트랜잭션 커밋 시 영속성 컨텍스트 안의 엔티티들을 검사하여 변경된 엔티티가 있다면 자동으로 DB에 반영
지연 실행 Write Behind
- JPA는 DB의 INSERT 및 UPDATE SQL작업을 트랜잭션이 커밋될떄까지 지연시킴
- “지연 실행 캐싱” or “지연 쓰기” 커밋 시점에 SQL실행을 지연함으로써 DB로의 왕복횟수를 줄일 수 있음
➡️많은 엔티티 저장시나 업데이트 시 성능을 크게 향상 가능
➡️SQL작업이 올바른 순서로 실행되도록 보장하여 참조 무결성 유지 - 지연 실행 동작 과정 :
1. JPA에서 엔티티를 조회하고 영속성 컨텍스트에 로드하는 시점에 초기 상태의 스냅샷을 생성
2. 트랜잭션이 커밋될때 이 스냅샷과 현재 엔티티의 상태를 비교하여 변경감지를 수행
3. 이 과정으로 변경된 필드가 있을 경우에만 그 필드에 대해서만 UPDATE SQL이 실행
생명주기전환 메소드
- persist(entity) : 엔티티를 영속 상태로 만듦
- merge(entity) : 분리된 상태의 엔티티를 다시 영속 상태로 복구
- remove(entity) : 엔티티를 삭제 상태로 전환
- detach(entity) : 엔티티를 분리 상태로 만듦
엔티티 매핑
- 기본적인 엔티티 매핑 - @Entity, @Id, @GeneratedValue
@Entity의 속성
- name : 엔티티에 이름을 지정할 수 있음 (@Entity(name = “Sample”))
➡️이 이름은 JPQL(Java Persistence Query Language) 쿼리에서 엔티티 참조 시 사용 명시하지 않을 경우
클래스의 이름이 기본값으로 적용됨
ex. JPQL에서는 Sample로 엔티티를 참조할 수 있음
JPQL : SQL은 테이블을 대상으로 쿼리를 작성하지만 JPQL은 SQL과 유사하면서 엔티티를 대상으로 쿼리를 작성 - schema : 엔티티가 속할 데이터베이스 스키마를 지정 (@Entity(schema = “sales”))
특정 데이터베이스 스키마 내에서 엔티티를 그룹화하는데 사용
스키마 = “데이터베이스”와 유사 개념
그 외 엔티티 매핑
- table : 엔티티에 매핑될 데이터베이스 테이블의 이름을 지정 (@Table(name = “users”))
- readOnly : 엔티티가 읽기전용인지의 여부 설정
- inheritance : 엔티티의 상속전략을 지정
ex. @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
➡️싱글 테이블, 조인드, 테이블 퍼 클래스 전략 등을 지정 가능 - @Id : 클래스의 필드를 테이블의 기본 키(primary key)로 지정
- @GeneratedValue : 기본 키의 값을 자동으로 생성할 방법을 명시
- @Column : 엔티티의 필드가 데이터베이스 테이블의 어떤 열에 매핑될 것인지 (어떤 컬럼에 매핑될것인지)를 정의 insertable 속성 : 이 필드가 DB에 삽입될 수 있는지의 여부 설정 (true = 삽입 가능)
updateable 속성 : 이 필드가 데이터베이스에 업데이트 될 수 있는지의 여부 설정 (true = 수정 가능)
엔티티 관계 매핑
@OneToMany
- 엔티티 간의 일대다 관계를 매핑할때 사용하는 어노테이션
- 한 엔티티가 다른 엔티티 여러 개와 관계를 가질 수 있음
- 주로 List, Set, Map과 같은 컬렉션 타입을 사용 mappedBy 속성을 사용하여 소유가 아닌 쪽을 지정
해당 엔티티를 참조하는 필드의 이름을 지정
➡️이 속성이 설정되면 현재 엔티티가 관계의 주인이 아니라는 것을 나타냄 - 기본 속성 : mappedBy, fetch, cascade (CascadeType.ALL 등)을 지정하여 영속성 상태 변화를
관련 엔티티에 자동으로 전파하도록 할 수 있음
➡️CascadeType.ALL : 상위 엔티티를 저장, 수정, 삭제 시 하위 엔티티도 같이 처리됨
▶️실습 - 엔티티 관계 매핑 (School, Student)
School 엔티티 = DB의 schools 테이블과 연결
@Table(name = "schools")
@Entity
@Getter@Setter@ToString
@NoArgsConstructor
public class School {
@Id@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 255, nullable = false)
private String name;
public School(String name) {
this.name = name;
}
}
- @NoArgsConstructor : 기본생성자는 꼭 필요하므로 명시
- public School (String name) : ➡️ID를 제외한 필드 생성자 선언
- @ID와 @GeneratedValue를 붙여 사용할 수도 있다. (@Getter@Setter@ToString 도 마찬가지)
- @Column의 length = 255는 255가 기본값이므로 선택적으로 사용 가능
Student 엔티티 = DB의 students 테이블과 연결
@Table(name = "students")
@Entity
@Getter@Setter@ToString
@NoArgsConstructor
public class Student {
@Id@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@ManyToOne
@JoinColumn(name = "school_id")
private School school;
public Student(String name, School school) {
this.name = name;
this.school = school;
}
}
- @ManyToOne
@JoinColumn(name = "school_id")
private School school;
➡️ school_id는 School과 관련된 필드이므로 먼저 인스턴스를 선언
Student가 School 인스턴스를 가지도록 명시해줌으로써 관계를 자동매핑
ex. 학생 = Many, 학교 = One (일 대 다 관계) - @JoinColumn : 어떤 컬럼으로 해당 엔티티에 접근 중인지를 명시 (name = "school_id")
JoinColumn은 따라서 Student엔티티에서 students테이블의 school_id 컬럼을 가리키고 있도록 지정
School 엔티티에서 Student 얻어오는 방법
private List<Student> students = new ArrayList<>();
- 이때 School 엔티티에 Student 엔티티와의 관계를 명시해줄 필요가 있다.
@Id@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Student> students = new ArrayList<>();
public School(String name) {
this.name = name;
}
- @OneToMany 어노테이션으로 mappedBy를 지정하여 Student테이블과의 관계를 명시
- mappedBy : @OneToMany 어노테이션 기준 mappedBy 속성은 반드시 명시
➡️이는 Student 엔티티에서 선언되어있는 인스턴스인 private School school; 에 따라
school로부터 정보를 얻어올 수 있도록 하여 두 엔티티 간의 연관관계를 매핑하는 것 - private School school;
➡️School 타입 인스턴스 이름 (school)과 mappedBy의 “school” 이름이 같아야함
mappedBy는 mappedBy를 사용한 엔티티가 “연관관계에 있어서 주된 엔티티”가 아니다”라는 의미 - ❓주된 엔티티 : 일대다관계에서 (다)에 해당하는 엔티티가 가짐 (여기서는 Student엔티티가 연관관계의 주인)
- 정리하자면
mappedBy로 연관관계를 Student 엔티티로부터 가져와서 그 school에서 가져온 값들을 토대로
List<Student> students를 채워주는 것
따라서 학생이 추가되면 자동으로 이 School 엔티티의 students 리스트에 채워진다. - 👀관계설정 시 양방향 설정보다 (School ↔ Student)
단방향으로 설정 권장 (School ← Student)
- cascade = CascadeType.ALL
➡️cascade : 연관된 것들에 대해 어떻게 처리할지를 정의
ex. School 엔티티가 persist()를 통해 영속상태가 되면 이 엔티티에 속한 Student 엔티티들 또한 영속상태로 따라감
➡️CascadeType.ALL : 생성, 수정, 삭제에 대한 모든(ALL) 것을 따라가도록 지정
- orphanRemoval = true/false
➡️고아 객체가 생겼을때 어떻게 처리할 것인지를 지정
(=부모객체가 사라졌을때 자식객체를어떻게 처리할 것인지를 지정)
ex. School 엔티티 삭제 시 그 School에 다니는 Student 엔티티도 삭제됨
➡️orphanRemoval = true
true : 부모객체 삭제 시 자식객체도 자동삭제할 것이라는 옵션
▶️테스트 - StudentMain
@Slf4j
public class SchoolMain {
public static void main(String[] args) {
EntityManager entityManager = JPAUtil.getEntityManagerFactory().createEntityManager();
entityManager.getTransaction().begin();
try{
School school = entityManager.find(School.class, 1L);
String findSchool = school.getName();
log.info("ID [1] School Name : {}", findSchool);
for(Student student : school.getStudents()){
log.info("{}'s Student Name : {}", findSchool, student.getName());
}
}finally{
entityManager.close();
}
}
}
- ID [1] School Name : Greenwood High School
➡️첫 log.info()를 통해 id=1번에 해당하는 학교 이름인 Greenwood High School 을 가져옴 - Greenwood High School's Student Name : Alice
➡️ 두번째 리스트를 순회하며 얻은 학생들의 log.info() 또한 잘 가져와짐 - students 리스트가 채워지는 시점은 find(School.class, 1L)을 통해 가져오는 시점
➡️find()메소드 호출 시 students 리스트도 채워지지만
- 실제로는 find()메소드를 통해 Student 엔티티가 Select되지 않고 기다림 (지연 로딩(=가져오는것))
- school.getStudents()을 통해 실제 students 리스트 값이 쓰일때에서야 Select가 실행되는 것을 확인 가능
- 정리하자면 처음에 모두 SELECT하는 것이 아닌 기다리고 있다가
(=지연) 쿼리가 필요할때 그때에서야 실행 (=지연 로딩)
▶️테스트 - StudentMain - 메소드 분리
데이터 생성 - create()
// 데이터 생성
private static void create(){
EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
em.getTransaction().begin();
try{
// School 생성
School school = new School("Tiger School");
// Student 생성
school.getStudents().add(new Student("Matz", school));
school.getStudents().add(new Student("Alex", school));
em.persist(school); // 생성한 school을 영속상태로 만들어줌
em.getTransaction().commit(); // 모든 변경사항 적용
String createSchool = school.getName();
log.info("[NEW] School Name : {}", createSchool);
for(Student student : school.getStudents()){
log.info("{}'s Student Name : {}", createSchool, student.getName());
}
}finally{
em.close();
}
}
- main에서 모든 기능을 수행하지 않고 create()메소드를 static으로 두어 프로그램 실행 시 미리 담길 수 있도록한다.
- 그 후 main메소드에서 find()메소드 호출 시 구현했던 기능들이 실행될 수 있도록 한다.
데이터 수정 - update()
// 데이터 수정
private static void update(){
EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
em.getTransaction().begin();
try{
// School 변경
School school = em.find(School.class, 4L);
String beforeSchool = school.getName();
school.setName("Jaguar School"); // Tiger School 변경 -> Jaguar School 변경
em.getTransaction().commit(); // 모든 변경사항 적용
String updatedSchool = school.getName();
log.info("[UPDATE] School Name : {} -> {}", beforeSchool, updatedSchool);
}finally{
em.close();
}
}
데이터 삭제 - delete()
// 데이터 삭제
private static void delete(){
EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
em.getTransaction().begin();
try{
// 이전 정보를 출력하기위한 find()
School school = em.find(School.class, 3L);
String deletedSchool = school.getName();
// School 삭제
em.remove(school);
em.getTransaction().commit(); // 모든 변경사항 적용
log.info("[DELETE] School Name : {} -> [삭제완료]", deletedSchool);
}finally{
em.close();
}
}
- id = 3번 Lion School 이 삭제된 것을 확인 가능
🚀실습 - 엔티티 관계 매핑 (Author, Book)
Author 엔티티
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
Book 엔티티
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
- 이처럼 제약조건과 관계 매핑 부분을 구현해줌
데이터 조회 - find()
EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
em.getTransaction().begin();
try{
Author author = em.find(Author.class, 2L);
String findAuthor = author.getName();
log.info("[SELECT] [ID=2] Author : {}", findAuthor);
for(Book book : author.getBooks()){
log.info("Book [{}]'s Author : [{}]", book.getTitle(), findAuthor);
}
}finally{
em.close();
}
데이터 생성 - create()
// 1. Author 생성
Author author = new Author("김소월");
// 2. Books 생성
author.getBooks().add(new Book("진달래꽃", author));
author.getBooks().add(new Book("가는 길", author));
em.persist(author);
em.getTransaction().commit();
데이터 수정 - update()
Author author = em.find(Author.class, 2L);
author.setName("Mark Zuckerberg");
em.persist(author);
em.getTransaction().commit();
데이터 삭제 - delete()
Author author = em.find(Author.class, 4L);
em.remove(author);
em.persist(author);
em.getTransaction().commit();
- id=4인 Author를 삭제함과 동시에 Author의 Book들도 함께 삭제 되는 것을 확인 가능
➡️Author 엔티티의 cascade = CascadeType.ALL 의 영향
🚀회고 결과 :
이번 회고에서는 JPA에서의 두 테이블 간 관계 매핑에 대해 회고를 진행하였다.
cascade 속성으로 테이블 간 연관관계 매핑, @OneToMany, @ManyToOne 등
어느 엔티티에 지정해주어야할지 등에 대해서는 회고를 통해 추가 공부할 수 있었다.
- cascade 속성 사용
- orphanRemoval 사용으로 인한 고아 객체 처리 방법
- find()에서의 commit()이 필수적인 것은 아님
- log.info() 사용으로 DB에 저장, 수정, 삭제되는 과정 확인 가능
- persist() 사용의 중요성
- @Column 속성 지정
느낀 점 :
School <-> Student 테이블과 Author <-> Book 테이블 매핑으로 이전에 진행한 프로젝트에도 댓글 기능 구현이 가능할 수 있을 것 같았다. 하나의 게시글에 여러 개의 댓글이 담길 수 있는 관계로써 매핑할 수 있다는 것을 생각할 수 있었다.
엔티티 매핑 관련 어노테이션으로 테이블 생성 시 걸어주었던 제약조건을 클래스레벨에서 구현해낼 수 있다는 것 또한
이해하기 쉽게 배울 수 있었다.
향후 계획 :
- 댓글 기능 구현 고려
- 다른 연관관계의 테이블로써 추가 실습 (Apartment <-> Resident 등)
- JPAUtil을 사용한 EntityManager 관리 추가 실습
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_49일차_"JPA 상속 관계 매핑" (1) | 2025.02.18 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_48일차_"JPA 관계형 테이블" (1) | 2025.02.14 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_46일차_"JPA" (0) | 2025.02.12 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_44일차_"페이징 처리" (0) | 2025.02.10 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_43일차_"친구목록 페이지" (1) | 2025.02.07 |