실무에서 1:N 관계의 테이블 구조를 한 번쯤은 경험해보셨을 겁니다.
게시판과 댓글, 주문과 주문상세, 유저와 활동 로그 등 다양한 도메인에서 자주 등장하죠.
이럴 때 고전적인 방법은, 먼저 부모 테이블을 조회한 뒤, 반복문을 돌면서 자식 테이블을 다시 조회하는 방식입니다.
이 구조는 구현이 쉽고 명확하지만, 데이터가 많아지면 성능 이슈나 I/O 비용 증가로 이어질 수 있습니다.
PostgreSQL에서는 이 문제를 string_agg 함수로 간결하게 해결할 수 있습니다.
오늘은 게시판의 게시글과 댓글 관계를 예시로, 1:N 테이블 구조에서 string_agg를 어떻게 활용해 성능을 개선할 수 있는지 소개드리겠습니다.
1. 기존 방식: N+1 문제 발생
기존 방식은 다음과 같은 형태로 동작합니다.
public ResponseEntity<ResponseVO> getPost() {
List<BoardDetailDto> allPosts = boardDAO.getAllPost();
for (BoardDetailDto post : allPosts) {
List<CommentDetailDto> allComments = boardDAO.getCommentByPostId(post.getPostId());
post.setComments(allComments);
post.setCommentCount(allComments.size());
}
return ofSuccess(Map.of("post", allPosts));
}
• 게시글이 10개면 댓글 조회 쿼리도 10번 발생합니다.
• 결국 게시글 수(N) + 댓글 쿼리 수(N) = 총 1 + N 쿼리 → N+1 문제 발생.
• 단순하면서 직관적이지만, 대용량 데이터에선 성능 병목이 심해집니다.
2. 개선 방식: string_agg로 한 번에 조회
DB에서는 댓글들을 string_agg로 묶어서 하나의 문자열로 내려주고,
Java에서는 이를 파싱해서 List로 변환하는 방식입니다.
PostgreSQL 쿼리 예시
SELECT
p.post_id,
p.title,
u.username AS author,
p.contents,
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') AS created_at,
COUNT(c.comment_id) AS comment_count,
string_agg(
c.comment_id || ':' || cu.username || ':' || c.content || ':' || to_char(c.created_at, 'YYYY-MM-DD HH24:MI:SS'),
'|'
ORDER BY c.created_at
) AS string_agg
FROM post p
JOIN users u ON p.user_id = u.user_id
LEFT JOIN comment c ON p.post_id = c.post_id
LEFT JOIN users cu ON c.user_id = cu.user_id
GROUP BY p.post_id, p.title, author, p.contents, p.created_at
ORDER BY p.created_at DESC;
댓글이 중복될 가능성이 있다면 string_agg(DISTINCT ...)처럼 DISTINCT를 쓰고 싶어질 수 있는데,
PostgreSQL에서 DISTINCT는 내부적으로 정렬(Sort)을 수행하고, 중복 제거까지 추가 비용이 발생해 성능이 떨어질 수 있습니다.
이럴 때는 GROUP BY를 통해 결과 단위를 명확하게 나눠주는 것이 더 좋습니다.
위 쿼리처럼 GROUP BY post_id 기준으로 묶으면 각 게시글마다 하나의 줄만 나오고, string_agg 대상도 명확해져서 성능상 더 효율적이에요.
BoardDetailDto의 stringAgg 파싱 예시
public void setStringAgg(String stringAgg) {
if (comments == null) {
comments = new ArrayList<>();
}
String[] split = stringAgg.split("\\|");
for (String entry : split) {
String[] data = entry.split(":", 4);
CommentDetailDto build = CommentDetailDto.builder()
.commentId(Long.parseLong(data[0]))
.commenter(data[1])
.comment(data[2])
.createdAt(data[3])
.build();
comments.add(build);
}
}
비교
비교 | 기존 방식 | String_agg |
소요시간 | 55ms | 46ms |
쿼리횟수 | 게시글수 + 1 | 1 |
구현 복잡도 | 낮음 | 파싱 로직 필요 |
적합한 상황 | 소규모 환경 | 대용량, 댓글 많은 환경 |
기존 방식은 쿼리 호출 횟수가 많아질수록 성능 저하가 발생하는 반면
string_agg 방식은 단일 쿼리로 필요한 데이터를 한 번에 조회하므로 I/O를 줄이고, 응답 속도 향상에 유리합니다.
결과 예시
{
"post": [
{
"postId": 1,
"title": "Post about PostgreSQL",
"author": "alice",
"contents": " postgreSql 이란 ? PostgreSQL은 오픈 소스 객체-관계형 데이터베이스 시스템(ORDBMS)으로, Enterprise급 DBMS의 기능과 차세대 DBMS에서나 볼 수 있을 법한 기능들을 제공 ",
"createdAt": "2025-04-11 12:04:06",
"commentCount": 3,
"comments": [
{
"commentId": 1,
"commenter": "bob",
"comment": "Thanks for the info!",
"createdAt": "2025-04-11 12:04:15"
},
{
"commentId": 2,
"commenter": "charlie",
"comment": "Very helpful.",
"createdAt": "2025-04-11 12:04:15"
},
{
"commentId": 3,
"commenter": "bob",
"comment": "Can you explain more about indexes?",
"createdAt": "2025-04-11 12:04:15"
}
]
},
{
"postId": 2,
"title": "Understanding JOINs",
"author": "bob",
"contents": "A JOIN is a SQL instruction in the FROM clause of your query that is used to identify the tables you are querying and how they should be combined.",
"createdAt": "2025-04-11 12:04:06",
"commentCount": 1,
"comments": [
{
"commentId": 4,
"commenter": "alice",
"comment": "Nice summary of joins.",
"createdAt": "2025-04-11 12:04:15"
}
]
},
{
"postId": 3,
"title": "Tips for Writing Clean Code",
"author": "alice",
"contents": " Effectiveness, Efficiency and Simplicity · Format and Syntax · Naming · Conciseness vs Clarity. ",
"createdAt": "2025-04-11 12:04:06",
"commentCount": 2,
"comments": [
{
"commentId": 5,
"commenter": "charlie",
"comment": "Great tips!",
"createdAt": "2025-04-11 12:04:15"
},
{
"commentId": 6,
"commenter": "bob",
"comment": "I agree, clean code is essential.",
"createdAt": "2025-04-11 12:04:15"
}
]
}
]
}
'DBMS > PostgreSql' 카테고리의 다른 글
SQL 쿼리 최적화 - CTE로 복잡한 조건 정리하기 (0) | 2025.04.25 |
---|---|
PostgreSQL 메타 정보 조회 (0) | 2024.10.01 |