JPA
엔티티 연관관계 정리
Auth 파트
•
AuthService
package org.example.backendproject.Auth.service;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.Auth.dto.LoginRequestDTO;
import org.example.backendproject.Auth.dto.SignUpRequestDTO;
import org.example.backendproject.user.dto.UserDTO;
import org.example.backendproject.user.dto.UserProfileDTO;
import org.example.backendproject.user.entity.User;
import org.example.backendproject.user.entity.UserProfile;
import org.example.backendproject.user.repository.UserRepository;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class AuthService {
private final UserRepository userRepository;
@Transactional // 해당 어노테이션 선언해야 저장이 된다.
public void signUp(SignUpRequestDTO dto) {
// 사용자 조회 여부 확인, null값 체크
if (userRepository.findByUserid(dto.getUserid()).isPresent()) {
throw new RuntimeException("사용자가 이미 존재합니다.");
}
User user = new User();
user.setUserid(dto.getUserid());
user.setPassword(dto.getPassword());
UserProfile profile = new UserProfile();
profile.setUsername(dto.getUsername());
profile.setEmail(dto.getEmail());
profile.setPhone(dto.getPhone());
profile.setAddress(dto.getAddress());
/** 연관관계 설정 **/
profile.setUser(user);
user.setUserProfile(profile);
userRepository.save(user);
}
public UserDTO login(LoginRequestDTO loginRequestDTO) {
User user = userRepository.findByUserid(loginRequestDTO.getUserid())
.orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
if (!loginRequestDTO.getPassword().equals(user.getPassword())) {
throw new RuntimeException("비밀번호를 찾을 수 없습니다.");
}
UserDTO userDTO = new UserDTO();
userDTO.setId(user.getId());
userDTO.setUserid(user.getUserid());
// 유저 프로필
UserProfileDTO profileDTO = new UserProfileDTO();
profileDTO.setUsername(user.getUserProfile().getUsername());
profileDTO.setEmail(user.getUserProfile().getEmail());
profileDTO.setPhone(user.getUserProfile().getPhone());
profileDTO.setAddress(user.getUserProfile().getAddress());
return userDTO;
}
}
Java
복사
User 파트
•
UserProfileDTO 생성
package org.example.backendproject.user.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserProfileDTO {
private String username;
private String email;
private String phone;
private String address;
}
Java
복사
•
User 엔티티에 해당 내용을 추가
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Board> boards = new ArrayList<>();
Java
복사
•
BaseTime 엔티티 생성 - 공통 적용
package org.example.backendproject.user.entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Getter
@Setter
// JPA 엔티티의 생성/수정 시점을 자동으로 기록하도록 이벤트 리스너로 동작하도록 하는 어노테이션
@EntityListeners(AuditingEntityListener.class)
// 이 클래스를 상속받는 엔티티들은 이 클래스의 필드를 컬럼으로 포함시키라는 어노테이션
@MappedSuperclass
public abstract class BaseTime {
// 엔티티가 저장될 때 자동으로 시간을 기록
@CreatedDate
private LocalDateTime created_date;
// 엔티티가 수정될 때 자동으로 시간 기록
@LastModifiedDate
private LocalDateTime updated_date;
}
Java
복사
•
@EnableJpaAuditing 적용 - BaseTime에 맞춰서
package org.example.backendproject;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@EnableJpaAuditing
public class BackendProjectApplication {
public static void main(String[] args) {
SpringApplication.run(BackendProjectApplication.class, args);
}
}
Java
복사
•
UserDTO에 profile 추가
package org.example.backendproject.user.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class UserDTO {
private Long id;
private String userid;
private UserProfileDTO profile; // 추가
}
Java
복사
•
UserProfilerRepository 추가
package org.example.backendproject.user.repository;
import org.example.backendproject.user.entity.UserProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserProfilerRepository extends JpaRepository<UserProfile, Long> {
}
Java
복사
•
UserService
package org.example.backendproject.user.service;
import jakarta.persistence.EntityManager;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.user.dto.UserDTO;
import org.example.backendproject.user.dto.UserProfileDTO;
import org.example.backendproject.user.entity.User;
import org.example.backendproject.user.entity.UserProfile;
import org.example.backendproject.user.repository.UserProfilerRepository;
import org.example.backendproject.user.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final UserProfilerRepository userProfilerRepository;
private final EntityManager em;
/** 내 정보 조회 **/
@Transactional(readOnly = true)
public UserDTO getMyInfo(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다."));
UserDTO dto = new UserDTO();
dto.setId(user.getId());
dto.setUserid(user.getUserid());
UserProfile profile = user.getUserProfile();
UserProfileDTO profileDTO = new UserProfileDTO();
profileDTO.setUsername(profile.getUsername());
profileDTO.setEmail(profile.getEmail());
profileDTO.setPhone(profileDTO.getPhone());
profileDTO.setAddress(profileDTO.getAddress());
dto.setProfile(profileDTO);
return dto;
}
/** 유저 정보 수정 **/
@Transactional
public UserDTO updateUser(Long id, UserDTO userDTO) {
// 유저 레포지토리를 통해서 유저를 가져옴
User user = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("토큰이 존재하지 않습니다."));
// 프로필 객체를 만들어서 기존에 변경되기 전 프로필을 넣어줌
UserProfile profile = user.getUserProfile();
// 수정하려는 프로필이 있는지 체크, 수정하려는 프로필 정보가 있는지 체크
if (profile != null && userDTO.getProfile() != null) {
UserProfileDTO dtoProfile = userDTO.getProfile();
// 프로필을 수정하기 위해 전달받은 데이터로 변강한다.
if (dtoProfile.getUsername() != null) profile.setUsername(dtoProfile.getUsername());
if (dtoProfile.getEmail() != null) profile.setEmail(dtoProfile.getEmail());
if (dtoProfile.getPhone() != null) profile.setPhone(dtoProfile.getPhone());
if (dtoProfile.getAddress() != null) profile.setAddress(dtoProfile.getAddress());
}
/**
* JPA에서 findById()로 가져온 엔티티는 영속 상태임.
* 필드 값을 바꾸면 JPA가 트랜잭션 커밋할 때 자동으로 update 쿼리를 날림
*/
// 아래는 변경된 내용을 프론트에 던져주기 위해 생성한다.
UserDTO dto = new UserDTO();
dto.setId(user.getId());
dto.setUserid(user.getUserid());
UserProfileDTO profileDTO = new UserProfileDTO();
profileDTO.setUsername(profile.getUsername());
profileDTO.setEmail(profile.getEmail());
profileDTO.setPhone(profile.getPhone());
profileDTO.setAddress(profile.getAddress());
dto.setProfile(profileDTO);
return dto;
}
//아래는 순환참조가 되는 예제
public User getProfile2(Long profileId) {
UserProfile profile = userProfilerRepository.findById(profileId)
.orElseThrow(()->new RuntimeException("프로필 없음"));
return profile.getUser();
}
//dto로 순환참조 방지
public UserDTO getProfile(Long profileId) {
UserProfile profile = userProfilerRepository.findById(profileId)
.orElseThrow(()->new RuntimeException("프로필 없음"));
User user =profile.getUser();
if (user==null) throw new RuntimeException("연결된 유저 없음");
UserProfileDTO profileDTO = new UserProfileDTO(
profile.getUsername(),
profile.getEmail(),
profile.getPhone(),
profile.getAddress()
);
UserDTO userDTO = new UserDTO(
user.getId(),
user.getUserid(),
profileDTO
);
return userDTO;
}
@Transactional
public void saveAllUsers(List<User> users) {
long start = System.currentTimeMillis();
for (int i = 0; i < users.size(); i++) {
em.persist(users.get(i));
if (i % 1000 == 0){
em.flush();
em.clear();
}
}
long end = System.currentTimeMillis();
System.out.println("JPA saveAll 저장 소요 시간(ms): " + (end - start));
}
}
Java
복사
•
UserController 수정
package org.example.backendproject.user.controller;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.user.dto.UserDTO;
import org.example.backendproject.user.entity.User;
import org.example.backendproject.user.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {
// @Value("${PROJECT_NAME:web Server}")
// private String instanceName;
//
// @GetMapping
// public String test() {
// return instanceName;
// }
private final UserService userService;
/** 내 정보 보기 **/
@GetMapping("/me/{id}")
public ResponseEntity<UserDTO> getMyInfo(@PathVariable("id") Long userId){
return ResponseEntity.ok(userService.getMyInfo(userId));
}
/** 유저 정보 수정 **/
@PutMapping("/me/{id}")
public ResponseEntity<UserDTO> updateUser(@PathVariable("id") Long userId, @RequestBody UserDTO dto) {
UserDTO updated = userService.updateUser(userId, dto);
return ResponseEntity.ok(updated);
}
// dto로 순환참조 방지
@GetMapping("/profile/{profileId}")
public UserDTO getProfile(@PathVariable Long profileId) {
return userService.getProfile(profileId);
}
@PostMapping("/jpaSaveAll")
public String saveAll(@RequestBody List<User> users) {
userService.saveAllUsers(users);
return "ok";
}
}
Java
복사
•
회원 정보 확인
•
Postman에서 수정
•
localhost:8080 에 접속하여 수정
•
DB에서 수정 내용 확인
Board 파트
•
Board 엔티티
package org.example.backendproject.board.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.example.backendproject.comment.entity.Comment;
import org.example.backendproject.user.entity.BaseTime;
import org.example.backendproject.user.entity.User;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class Board extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
private String batchKey;
// 아래는 글을 작성한 유저 정보
// 연관관계 매핑
// 다대일
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
// 일대다
@OneToMany(mappedBy = "board")
private List<Comment> comment = new ArrayList<>();
}
Java
복사
•
dto 생성
package org.example.backendproject.board.dto;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class BoardDTO {
private Long id;
private String title;
private String content;
private String username;
private Long user_id;
private LocalDateTime created_date;
private LocalDateTime updated_date;
private String batchKey;
public BoardDTO(Long id, String title, String content, String username, Long user_id,
LocalDateTime created_date, LocalDateTime updated_date) {
this.id = id;
this.title = title;
this.content = content;
this.username = username;
this.user_id = user_id;
this.created_date = created_date;
this.updated_date = updated_date;
}
}
Java
복사
•
BoardRepository
package org.example.backendproject.board.repository;
import java.util.List;
import org.example.backendproject.board.dto.BoardDTO;
import org.example.backendproject.board.entity.Board;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface BoardRepository extends JpaRepository<Board, Long> {
// 특정 User의 게시글만 조회
//@Query(value = "SELECT * FROM board WHERE user_id = :userId", nativeQuery = true) //이거 사용안해도 되는데 예시로 네이티브쿼라로 작성한거임
List<Board> findByUserId(Long userId);
//보드 엔티티를 기준으로 조회하되
//Board 엔티티 전체를 반환하는게 아니라 원하는 값만 보드dto 생성자에 넣어서 리스트로 반환합니다
//대소문자 구분없이 검색하는 옵션
//title에 해당 키워드가 포함되어 있거나 content에 키워드가 포함되어잇는거 출력
/** 페이징 적용 전 **/
/** 검색기능 **/
// 제목 또는 내용에 키워드가 포함된 글 검색 (대소문자 구분 없음)
@Query("SELECT new org.example.backendproject.board.dto.BoardDTO(" +
"b.id, b.title, b.content,b.user.userProfile.username, b.user.id, b.created_date, b.updated_date" +
") " +
"FROM Board b " +
"WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " +
"OR LOWER(b.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<BoardDTO> searchKeyword(@Param("keyword") String keyword);
/** 페이징 적용 후 **/
//페이징 전체 목록
@Query("SELECT new org.example.backendproject.board.dto.BoardDTO(" +
"b.id, b.title, b.content,b.user.userProfile.username, b.user.id,b.created_date, b.updated_date) " +
"FROM Board b ORDER BY b.title DESC")
// + "ORDER BY b.title DESC") //쿼리로 정렬
Page<BoardDTO> findAllPaging(Pageable pageable);
//페이징 처리 결과를 담는 페이징 객체입니다.
//전체 페이지수, 현재 페이지 번호,전체 아이템 겟수 등 페이징 관련 모든 정보들을 반환합니다.
//Pageable은 jpa에서 제공하는 페이징 정보를 담은 객체입니다.
//page번호, 한페이지당 데이터 갯수 ,정렬 기준 등 파라미터를 받아 원하는 조건으로 페이징 및 정렬 쿼리를 생성할 수 있습니다.
//페이징 검색 목록
@Query("SELECT new org.example.backendproject.board.dto.BoardDTO(" +
"b.id, b.title, b.content,b.user.userProfile.username, b.user.id, b.created_date, b.updated_date) " +
"FROM Board b " +
"WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :keyword, '%')) " +
"OR LOWER(b.content) LIKE LOWER(CONCAT('%', :keyword, '%')) ORDER BY b.title DESC")
// + "ORDER BY b.title DESC")// 쿼리로 정렬
Page<BoardDTO> searchKeywordPaging(@Param("keyword") String keyword, Pageable pageable);
}
Java
복사
•
BatchRepository - 일괄 등록 (배치 작업)
package org.example.backendproject.board.repository;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.board.dto.BoardDTO;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class BatchRepository {
private final JdbcTemplate jdbcTemplate;
public void batchInsert(List<BoardDTO> boardDTO) {
// 각각의 변수들은 Database에 있는 변수의 명칭대로 입력해줘야 정상적으로 동작됨
String sql = "INSERT INTO board (title, content, user_id, created_date, updated_date, batch_key) "
+ "VALUES (?, ?, ?, ?, ?, ?) ";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
BoardDTO dto = boardDTO.get(i);
ps.setString(1, dto.getTitle());
ps.setString(2, dto.getContent());
ps.setLong(3, dto.getUser_id());
ps.setString(4, String.valueOf(dto.getCreated_date()));
ps.setString(5, String.valueOf(dto.getUpdated_date()));
ps.setString(6, dto.getBatchKey());
}
@Override
public int getBatchSize() {
return boardDTO.size();
}
});
}
}
Java
복사
•
BoardService
package org.example.backendproject.board.service;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.backendproject.board.dto.BoardDTO;
import org.example.backendproject.board.entity.Board;
import org.example.backendproject.board.repository.BatchRepository;
import org.example.backendproject.board.repository.BoardRepository;
import org.example.backendproject.user.entity.User;
import org.example.backendproject.user.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
private final UserRepository userRepository;
private final BatchRepository batchRepository;
private final EntityManager em;
/** 글 등록 **/
@Transactional
public BoardDTO createBoard(BoardDTO boardDTO) {
// userId(PK)를 이용해서 User 조회
if (boardDTO.getUser_id() == null) {
throw new IllegalArgumentException("userId(PK)가 필요합니다!");
}
// 연관관계 매핑!
// 작성자 User 엔티티 조회 (userId 필요)
User user = userRepository.findById(boardDTO.getUser_id())
.orElseThrow(() -> new IllegalArgumentException("작성자 정보가 올바르지 않습니다."));
// mysql 저장
Board board = new Board();
board.setTitle(boardDTO.getTitle());
board.setContent(boardDTO.getContent());
// 연관관계 매핑
board.setUser(user);
Board saved = boardRepository.save(board);
return toDTO(saved);
}
/** 게시글 상세 조회 **/
@Transactional(readOnly = true)
public BoardDTO getBoardDetail(Long boardId) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("게시글 없음: " + boardId));
return toDTO(board);
}
/** 게시글 수정 **/
@Transactional
public BoardDTO updateBoard(Long boardId, BoardDTO dto) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new IllegalArgumentException("게시글 없음: " + boardId));
board.setTitle(dto.getTitle());
board.setContent(dto.getContent());
boardRepository.save(board);
return toDTO(board);
}
/** 게시글 삭제 **/
@Transactional
public void deleteBoard(Long boardId) {
if (!boardRepository.existsById(boardId)) {
throw new IllegalArgumentException("게시글 없음: " + boardId);
}
boardRepository.deleteById(boardId);
}
/** 페이지 적용 전 **/
// 게시글 전체 목록
@Transactional(readOnly = true)
public List<BoardDTO> getBoardList() {
return boardRepository.findAll().stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
// 게시글 검색 페이징 아님
public List<BoardDTO> searchBoards(String keyword) {
return boardRepository.searchKeyword(keyword);
}
/** 페이징 적용 후 **/
//페이징 전체 목록
public Page<BoardDTO> getBoards(int page, int size) {
return boardRepository.findAllPaging(PageRequest.of(page, size)); //페이저블에 페이징에대한 정보를 담아서 레포지토리에 전달하는 역할
// return boardRepository.findAllWithDto(PageRequest.of(page, size, Sort.by("id").ascending())); //함수로 정렬
}
//페이징 검색 목록
public Page<BoardDTO> searchBoardsPage(String keyword, int page, int size) {
return boardRepository.searchKeywordPaging(keyword, PageRequest.of(page, size));
}
/** 배치 작업 (JdbcTemplate) **/
@Transactional
public void batchSaveBoard(List<BoardDTO> boardDTOList) {
Long start = System.currentTimeMillis();
int batchsize = 1000; // 한번에 처리할 배치 크기
for (int i = 0; i < boardDTOList.size(); i += batchsize) { // i는 1000씩 증가
// 전체 데이터를 1000개씩 잘라서 배치리스트에 담는다.
int end = Math.min(boardDTOList.size(), i + batchsize); // 두 개의 숫자 중 작은 수를 반환
List<BoardDTO> batchList = boardDTOList.subList(i, end);
// 전체 데이터에서 1000씩 작업을 하는데 마지막 데이터가 1000개가 안 될수도 있으니
// Math.min()으로 전체 크기를 넘지 않게 마지막 인덱스를 계산해서 작업한다.
// 내가 넣은 데이터만 엘라스틱서치에 동기화하기 위해 uuid 생성
String batchKey = UUID.randomUUID().toString();
for (BoardDTO dto : batchList) {
dto.setBatchKey(batchKey);
}
// 1. MySQL로 INSERT
batchRepository.batchInsert(batchList);
}
Long end = System.currentTimeMillis();
log.info("[BOARD][BATCH] 전체 저장 소요 시간(ms): {}", (end - start));
}
// Entity → DTO 변환
private BoardDTO toDTO(Board board) {
BoardDTO dto = new BoardDTO();
dto.setId(board.getId());
dto.setTitle(board.getTitle());
dto.setContent(board.getContent());
dto.setUser_id(board.getUser().getId());
dto.setUsername(board.getUser() != null ? board.getUser().getUserProfile().getUsername() : null); // ★ username!
dto.setCreated_date(board.getCreated_date());
dto.setUpdated_date(board.getUpdated_date());
return dto;
}
@Transactional
public void boardSaveAll(List<Board> boardList) {
long start = System.currentTimeMillis();
for (int i = 0; i < boardList.size(); i++) {
em.persist(boardList.get(i));
if (i % 1000 == 0) {
em.flush();
em.clear();
}
}
long end = System.currentTimeMillis();
System.out.println("JPA Board SaveAll 저장 소요 시간(ms): " + (end - start));
}
}
Java
복사
대량 배치 삽입 - 소요 시간 비교
•
JPA (1000개 기준)
◦
소요 시간: 788ms
•
JdbcTemplate (1000개 기준)
◦
소요 시간: 79ms
•
JPA (10000개 기준)
◦
소요 시간 - 6.81s
•
JdbcTemplate 대량 배치 삽입
◦
소요 시간: 432ms
오늘 푸시한 커밋 리스트
날짜 | 커밋 메시지 |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 | |
2025-06-24 |