프로젝트에서 거리순 정렬이 필요했는데 기존에는 그냥 QueryDSL로 Order By 유클리드 거리를 하고 있었다.
너무 비효율적이라는 생각이 들어서 더 좋은 방법이 없나 찾아보다가 공간 인덱스를 발견했다.
https://www.baeldung.com/hibernate-spatial
결론부터 말하자면 적용 후 성능이 151ms 에서 63ms 으로 58.28% 개선되었다
Hibernate-Spatial 개념 정리 → 링크
나에게 필요한 것은 특정한 좌표들을 비교하는 일이니까 Point를 선택해서 썼다
우선 관련된 설정을 해준다
latitude와 longitude를 Point로 바꿔줬다
⚠️주의⚠️
경도 longitude → x
위도 latitude → y
@Column
private Double latitude;
@Column
private Double longitude;
→
import org.locationtech.jts.geom.Point;
@Column
private Point location;
.longitude(post.getLongitude())
.latitude(post.getLatitude())
→
.longitude(post.getLocation().getX())
.latitude(post.getLocation().getY())
Repository 단은 기존에 이렇게 생겼었다
✨개선 1. Spatial Index 사용
@Override
public List<Post> getPostsByDistance(int page, User user) {
less
Copy code
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory.selectFrom(post)
.orderBy(((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
List<Post> closestPosts = results.getResults();
return closestPosts;
}
→
String jpql = "SELECT p FROM Post p " +
"WHERE p.location IS NOT NULL " +
"ORDER BY distance(p.location, :userLocation) ASC";
TypedQuery<Post> query = entityManager.createQuery(jpql.toString(), Post.class);
query.setFirstResult(offset);
query.setMaxResults(pagesize);
return query.getResultList();
✨개선 2. 과도하게 오버로딩된 메서드들을 하나로 통합
@RequiredArgsConstructor
@Repository
public class PostCustomRepositoryImpl implements PostCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
@PersistenceContext
private EntityManager entityManager;
private final int pagesize = 5;
@Override
public List<Post> getPostsByDistance(int page, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
String jpql = "SELECT p FROM Post p " +
"WHERE p.location IS NOT NULL " +
"ORDER BY distance(p.location, :userLocation) ASC";
TypedQuery<Post> query = entityManager.createQuery(jpql, Post.class);
query.setParameter("userLocation", user.getLocation());
query.setFirstResult(offset);
query.setMaxResults(pagesize);
return query.getResultList();
}
@Override
public List<Post> getPostsByStatus(int page, PostStatusEnum status, User user) {
if (user.getLongitude() == null || user.getLatitude() == null) {
return getPostsByStatusWithoutAddress(page, status);
} else {
return getPostsByStatusWithAddress(page, status, user);
}
}
@Override
public List<Post> getPostsByCategory(int page, User user, CategoryEnum category) {
List<Post> posts;
if (user.getLongitude() == null || user.getLatitude() == null) {
posts = getPostsByCategoryWithoutAddress(page, category);
} else {
posts = getPostsByCategoryWithAddress(page, category, user);
}
return posts;
}
@Override
public List<Post> getPostsByKeyword(int page, User user, String keyword) {
List<Post> posts;
if (user.getLongitude() == null || user.getLatitude() == null) {
posts = getPostsByKeywordWithoutAddress(page, keyword);
} else {
posts = getPostsByKeywordWithAddress(page, keyword, user);
}
return posts;
}
@Override
public List<Post> getPostsByStatusAndCategory(int page, PostStatusEnum status,
CategoryEnum category, User user) {
if (user.getLongitude() == null || user.getLatitude() == null) {
return getPostsByStatusAndCategoryWithoutAddress(page, status, category);
} else {
return getPostsByStatusAndCategoryWithAddress(page, status, category, user);
}
}
@Override
public List<Post> getPostsByStatusAndKeyword(int page, PostStatusEnum statusEnum,
String keyword, User user) {
if (user.getLatitude() == null || user.getLongitude() == null) {
return getPostsByStatusAndKeywordWithoutAddress(page, statusEnum, keyword);
} else {
return getPostsByStatusAndKeywordWithAddress(page, statusEnum, keyword, user);
}
}
@Override
public List<Post> getPostsByCuisine(int page, User user, String cuisine) {
if (user.getLongitude() == null || user.getLatitude() == null) {
return getPostsByCuisineWithoutAddress(page, cuisine);
} else {
return getPostsByCuisineWithAddress(page, cuisine, user);
}
}
@Override
public List<Post> getPostsByStatusAndCuisine(int page, PostStatusEnum statusEnum,
String cuisine, User user) {
if (user.getLongitude() == null || user.getLatitude() == null) {
return getPostsByStatusAndCuisineWithoutAddress(page, statusEnum, cuisine);
} else {
return getPostsByStatusAndCuisineWithAddress(page, statusEnum, cuisine, user);
}
}
private List<Post> getPostsByStatusWithoutAddress(int page, PostStatusEnum status) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.postStatus.eq(status))
.orderBy(post.deadline.desc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByStatusWithAddress(int page, PostStatusEnum status, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.postStatus.eq(status))
.orderBy(((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByStatusAndCategoryWithoutAddress(int page, PostStatusEnum status,
CategoryEnum category) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.postStatus.eq(status)
.and(post.category.eq(category)))
.orderBy(post.deadline.desc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByStatusAndCategoryWithAddress(int page, PostStatusEnum status,
CategoryEnum category, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(
post.postStatus.eq(status)
.and(post.category.eq(category)))
.orderBy(((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByCategoryWithoutAddress(int page, CategoryEnum category) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory.selectFrom(post)
.where(post.category.eq(category))
.orderBy(post.deadline.desc())
.offset(offset)
.limit(pagesize)
.fetchResults();
List<Post> closestPosts = results.getResults();
return closestPosts;
}
private List<Post> getPostsByCategoryWithAddress(int page, CategoryEnum category, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory.selectFrom(post)
.where(post.category.eq(category))
.orderBy(
((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
List<Post> closestPosts = results.getResults();
return closestPosts;
}
private List<Post> getPostsByStatusAndKeywordWithoutAddress(int page, PostStatusEnum statusEnum,
String keyword) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.store.contains(keyword).and(post.postStatus.eq(statusEnum)))
.orderBy(post.deadline.desc())
.offset(offset)
.limit(pagesize)
.fetchResults();
List<Post> closestPosts = results.getResults();
return closestPosts;
}
private List<Post> getPostsByStatusAndKeywordWithAddress(int page, PostStatusEnum statusEnum,
String keyword, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.store.contains(keyword).and(post.postStatus.eq(statusEnum)))
.orderBy(((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
List<Post> closestPosts = results.getResults();
return closestPosts;
}
private List<Post> getPostsByCuisineWithoutAddress(int page, String cuisine) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory.selectFrom(post)
.where(post.cuisine.eq(cuisine))
.orderBy(post.deadline.desc())
.offset(offset)
.limit(pagesize)
.fetchResults();
List<Post> closestPosts = results.getResults();
return closestPosts;
}
private List<Post> getPostsByCuisineWithAddress(int page, String cuisine, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory.selectFrom(post)
.where(post.cuisine.eq(cuisine))
.orderBy(
((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByStatusAndCuisineWithoutAddress(int page, PostStatusEnum status,
String cuisine) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.postStatus.eq(status)
.and(post.cuisine.eq(cuisine)))
.orderBy(post.deadline.desc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByStatusAndCuisineWithAddress(int page, PostStatusEnum status,
String cuisine, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.postStatus.eq(status)
.and(post.cuisine.eq(cuisine)))
.orderBy(((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByKeywordWithoutAddress(int page, String keyword) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.store.contains(keyword))
.orderBy(post.deadline.desc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
private List<Post> getPostsByKeywordWithAddress(int page, String keyword, User user) {
QPost post = QPost.post;
int offset = page * pagesize;
QueryResults<Post> results = jpaQueryFactory
.selectFrom(post)
.where(post.store.contains(keyword))
.orderBy(((post.latitude.subtract(user.getLatitude()))
.multiply((post.latitude.subtract(user.getLatitude())))
.add((post.longitude.subtract(user.getLongitude()))
.multiply((post.longitude.subtract(user.getLongitude())))))
.asc())
.offset(offset)
.limit(pagesize)
.fetchResults();
return results.getResults();
}
}
→
package com.moayo.moayoeats.backend.domain.post.repository;
import com.moayo.moayoeats.backend.domain.post.entity.CategoryEnum;
import com.moayo.moayoeats.backend.domain.post.entity.Post;
import com.moayo.moayoeats.backend.domain.post.entity.PostStatusEnum;
import com.moayo.moayoeats.backend.domain.user.entity.User;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import lombok.RequiredArgsConstructor;
import org.locationtech.jts.geom.Point;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
@RequiredArgsConstructor
@Repository
public class PostCustomRepositoryImpl implements PostCustomRepository {
@PersistenceContext
private final EntityManager entityManager;
private final int pagesize = 5;
@Override
public List<Post> getPosts(int page, User user, PostStatusEnum status, CategoryEnum category, String keyword, String cuisine) {
int offset = page * pagesize;
Point userLocation = user != null ? user.getLocation() : null;
StringBuilder jpql = new StringBuilder("SELECT p FROM Post p WHERE 1=1");
if (status != null) {
jpql.append(" AND p.postStatus = :status");
}
if (category != null) {
jpql.append(" AND p.category = :category");
}
if (keyword != null && !keyword.trim().isEmpty()) {
jpql.append(" AND p.store LIKE :keyword");
}
if (cuisine != null && !cuisine.trim().isEmpty()) {
jpql.append(" AND p.cuisine = :cuisine");
}
if (userLocation != null) {
jpql.append(" AND p.location IS NOT NULL ORDER BY distance(p.location, :userLocation) ASC, p.deadline DESC");
} else {
jpql.append(" ORDER BY p.deadline DESC");
}
TypedQuery<Post> query = entityManager.createQuery(jpql.toString(), Post.class);
if (status != null) {
query.setParameter("status", status);
}
if (category != null) {
query.setParameter("category", category);
}
if (keyword != null && !keyword.trim().isEmpty()) {
query.setParameter("keyword", "%" + keyword + "%");
}
if (cuisine != null && !cuisine.trim().isEmpty()) {
query.setParameter("cuisine", cuisine);
}
if (userLocation != null) {
query.setParameter("userLocation", userLocation);
}
query.setFirstResult(offset);
query.setMaxResults(pagesize);
return query.getResultList();
}
}
→
✨개선 4. Builder Pattern 적용
이렇게 하고 나서 뭔가 Query를 만들때 Builder 패턴을 적용하고 싶어서 이렇게 해봤다
public class PostQueryBuilder {
private StringBuilder jpql;
@PersistenceContext
private EntityManager entityManager;
private TypedQuery<Post> query;
private PostStatusEnum status;
private CategoryEnum category;
private String keyword;
private String cuisine;
private Point userLocation;
public PostQueryBuilder(EntityManager entityManager) {
this.entityManager = entityManager;
this.jpql = new StringBuilder("SELECT p FROM Post p WHERE 1=1");
}
public PostQueryBuilder withStatus(PostStatusEnum status) {
this.status = status;
if (status != null) {
jpql.append(" AND p.postStatus = :status");
}
return this;
}
public PostQueryBuilder withCategory(CategoryEnum category) {
this.category = category;
if (category != null) {
jpql.append(" AND p.category = :category");
}
return this;
}
public PostQueryBuilder withKeyword(String keyword) {
this.keyword = keyword;
if (keyword != null && !keyword.trim().isEmpty()) {
jpql.append(" AND p.store LIKE :keyword");
}
return this;
}
public PostQueryBuilder withCuisine(String cuisine) {
this.cuisine = cuisine;
if (cuisine != null && !cuisine.trim().isEmpty()) {
jpql.append(" AND p.cuisine = :cuisine");
}
return this;
}
public PostQueryBuilder orderByDistance(User user) {
this.userLocation = user.getLocation();
if (userLocation != null) {
jpql.append(" AND p.location IS NOT NULL ORDER BY distance(p.location, :userLocation) ASC");
return AndOrderByDeadline();
}
return orderByDeadline();
}
public PostQueryBuilder AndOrderByDeadline() {
jpql.append(", p.deadline DESC");
return this;
}
public PostQueryBuilder orderByDeadline() {
jpql.append(" ORDER BY p.deadline DESC");
return this;
}
public TypedQuery<Post> build() {
query = entityManager.createQuery(jpql.toString(), Post.class);
if (status != null) {
query.setParameter("status", status);
}
if (category != null) {
query.setParameter("category", category);
}
if (keyword != null && !keyword.trim().isEmpty()) {
query.setParameter("keyword", "%" + keyword + "%");
}
if (cuisine != null && !cuisine.trim().isEmpty()) {
query.setParameter("cuisine", cuisine);
}
if (userLocation != null) {
query.setParameter("userLocation", userLocation);
}
return query;
}
}
✨개선 4. 공통 Super class 를 사용해서 다른 자료형들을 하나의 자료구조에 가변 용량으로 저장
Object 를 Map에다 넣으면 하드코딩을 줄일 수 있을 것 같아서 적용해 보았다.
하드코딩도 줄어들고 필요한 parameter만 Map에 들어가기 때문에 더 효율적이 된다
package com.moayo.moayoeats.backend.domain.post.repository.builder;
import com.moayo.moayoeats.backend.domain.post.entity.CategoryEnum;
import com.moayo.moayoeats.backend.domain.post.entity.Post;
import com.moayo.moayoeats.backend.domain.post.entity.PostStatusEnum;
import com.moayo.moayoeats.backend.domain.user.entity.User;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import org.locationtech.jts.geom.Point;
import java.util.HashMap;
import java.util.Map;
public class PostQueryBuilder {
private final StringBuilder jpql;
@PersistenceContext
private final EntityManager entityManager;
private final Map<String, Object> parameters = new HashMap<>();
public PostQueryBuilder(EntityManager entityManager) {
this.entityManager = entityManager;
this.jpql = new StringBuilder("SELECT p FROM Post p WHERE 1=1");
}
public PostQueryBuilder withStatus(PostStatusEnum status) {
if (status != null) {
jpql.append(" AND p.postStatus = :status");
parameters.put("status", status);
}
return this;
}
public PostQueryBuilder withCategory(CategoryEnum category) {
if (category != null) {
jpql.append(" AND p.category = :category");
parameters.put("category", category);
}
return this;
}
public PostQueryBuilder withKeyword(String keyword) {
if (keyword != null && !keyword.trim().isEmpty()) {
jpql.append(" AND p.store LIKE :keyword");
parameters.put("keyword", "%" + keyword + "%");
}
return this;
}
public PostQueryBuilder withCuisine(String cuisine) {
if (cuisine != null && !cuisine.trim().isEmpty()) {
jpql.append(" AND p.cuisine = :cuisine");
parameters.put("cuisine", cuisine);
}
return this;
}
public PostQueryBuilder orderByDistance(User user) {
Point userLocation = user.getLocation();
if (userLocation != null) {
jpql.append(" AND p.location IS NOT NULL ORDER BY distance(p.location, :userLocation) ASC");
parameters.put("userLocation", userLocation);
return andOrderByDeadline();
}
return orderByDeadline();
}
public PostQueryBuilder andOrderByDeadline() {
jpql.append(", p.deadline DESC");
return this;
}
public PostQueryBuilder orderByDeadline() {
jpql.append(" ORDER BY p.deadline DESC");
return this;
}
public TypedQuery<Post> build() {
TypedQuery<Post> query = entityManager.createQuery(jpql.toString(), Post.class);
parameters.forEach(query::setParameter);
return query;
}
}
사실 아래처럼 QueryDsl 쓰고싶었는데
특정 범위 내인지 판단하는 메서드 등은 있지만
두 지점간의 거리를 구하고 싶을때는 아직 JPQL을 사용해야 하는 것 같다
성능 개선 결과
Original
package com.moayo.moayoeats.backend.domain.post;
import com.moayo.moayoeats.backend.domain.post.dto.request.PostRequest;
import com.moayo.moayoeats.backend.domain.post.service.impl.PostCreateServiceImpl;
import com.moayo.moayoeats.backend.domain.user.entity.User;
import com.moayo.moayoeats.backend.domain.user.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.stream.IntStream;
@SpringBootTest
public class PostServiceTest {
private static final String[] CITY_NAMES = {"Seoul", "Busan", "Incheon", "Daegu", "Daejeon"};
private static final String[] CITY_COORDINATES = {"(lat:37.5665,lng:126.9780)", // Seoul
"(lat:37.4563,lng:126.7052)", // Incheon
"(lat:35.8722,lng:128.6011)", // Daegu
"(lat:36.3504,lng:127.3845)", // Daejeon
"(lat:35.1796,lng:129.0756)" // Busan
};
@Autowired
private PostCreateServiceImpl postCreateService;
@Autowired
private UserRepository userRepository;
private User user;
@BeforeEach
public void setUp() {
user = userRepository.findById(1L).orElseThrow();
}
public void createMultiplePosts(int numberOfRequests, User user) {
IntStream.range(0, numberOfRequests).forEach(i -> {
PostRequest postRequest = new PostRequest(
CITY_COORDINATES[i % CITY_COORDINATES.length],
CITY_NAMES[i % CITY_NAMES.length] + " Store",
"10000",
"3000",
"30",
"12",
"KOREAN");
postCreateService.createPost(postRequest, user);
});
}
@Test
public void testCreateMultiplePosts() {
int numberOfRequests = 500;
createMultiplePosts(numberOfRequests, user);
}
}
151ms
Spatial Index
package com.moayo.moayoeats.backend.domain.post.service;
import com.moayo.moayoeats.backend.domain.post.dto.request.PostRequest;
import com.moayo.moayoeats.backend.domain.post.service.impl.PostCreateServiceImpl;
import com.moayo.moayoeats.backend.domain.user.entity.User;
import com.moayo.moayoeats.backend.domain.user.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.stream.IntStream;
@SpringBootTest
public class PostServiceTest {
private static final String[] CITY_NAMES = {"Seoul", "Busan", "Incheon", "Daegu", "Daejeon"};
private static final String[] CITY_COORDINATES = {"(lat:37.5665,lng:126.9780)", // Seoul
"(lat:37.4563,lng:126.7052)", // Incheon
"(lat:35.8722,lng:128.6011)", // Daegu
"(lat:36.3504,lng:127.3845)", // Daejeon
"(lat:35.1796,lng:129.0756)" // Busan
};
@Autowired
private PostCreateServiceImpl postCreateService;
@Autowired
private UserRepository userRepository;
private User user;
@BeforeEach
public void setUp() {
user = userRepository.findById(1L).orElseThrow();
}
public void createMultiplePosts(int numberOfRequests, User user) {
IntStream.range(0, numberOfRequests).forEach(i -> {
PostRequest postRequest = new PostRequest(CITY_COORDINATES[i % CITY_COORDINATES.length], CITY_NAMES[i % CITY_NAMES.length] + " Store", "10000", "3000", "30", "12", "KOREAN");
postCreateService.createPost(postRequest, user);
});
}
@Test
public void testCreateMultiplePosts() {
int numberOfRequests = 500;
createMultiplePosts(numberOfRequests, user);
}
}
63ms
- 성능 개선된 차이: 151ms - 63ms = 88ms
- 성능 개선율: (151ms88ms)×100≈58.28%
'Journal' 카테고리의 다른 글
@EnableJpaAuditing 이 한 곳에서만 정의되어야 하는 이유 (0) | 2024.09.05 |
---|---|
QueryDSL 설정 (0) | 2024.09.05 |
한 시간 후 만료되는 초대 링크 개발기 : DB 구성에 대한 고민 (0) | 2024.06.20 |
Service에서 다른 Service 를 의존하게 하기 (0) | 2024.06.20 |
깃허브 프로필 꾸며보기!! (0) | 2024.02.09 |