JPA ์ฟผ๋ฆฌ ์คํ ๊ฒ์ฆ ๋ฐ ์ฑ๋ฅ ํ ์คํธ๋ฅผ ์ํ ์ด๋ ธํ ์ด์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
QueryKeeper๋ Spring Boot
+ JPA
ํ
์คํธ ์ฝ๋์์ ์คํ๋๋ SQL ์ฟผ๋ฆฌ ์, ์คํ ์๊ฐ, DB ์ ๊ทผ ์ฌ๋ถ ๋ฑ์ ์ด๋
ธํ
์ด์
๊ธฐ๋ฐ์ผ๋ก ๊ฒ์ฆํ๋ ํ
์คํธ ์ ์ฉ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์
๋๋ค.
์ธ๋ถ APM์ด๋ JDBC ํ๋ก์ ์์ด, ์์ Java ์ฝ๋๋ก ๊ตฌํ๋์์ต๋๋ค. ํต์ฌ JDBC ๊ตฌ์ฑ ์์(PreparedStatement
, Connection
, DataSource
)๋ฅผ ์ง์ ๊ฐ์ธ ๋ฎ์ ์์ค์์ ์ฟผ๋ฆฌ๋ฅผ ์ถ์ ํฉ๋๋ค.
โ๏ธ Java 8 ~ 17+, Spring Boot 2.7 ~ 3.2+, Hibernate 5.6 ~ 6.3+, JUnit 5.8+ ํ๊ฒฝ์ ์ง์ํฉ๋๋ค.
โ ๋ณ๋ ์ค์ ์์ด ๋ฐ๋ก ์ฌ์ฉ ๊ฐ๋ฅ. ํ ์คํธ ํด๋์ค์
@EnableQueryKeeper
๋ง ์ถ๊ฐ
โ ์ฟผ๋ฆฌ ์ฑ๋ฅ ํ๊ท๋ฅผ ํ ์คํธ ๋จ๊ณ์์ ๊ฐ์ง
โ@ExpectQuery
,@ExpectDetachedAccess
,@ExpectTime
,@ExpectDuplicateQuery
๊ฐ์ ์ง๊ด์ ์ธ ์ด๋ ธํ ์ด์ ์ผ๋ก ๊ตฌํ
โN+1 ๋ฌธ์
,๋ถํ์ํ DB ํธ์ถ
,๋๋ฆฐ ์ฟผ๋ฆฌ
๋ฅผ ํ ์คํธ ์ค ํ์ง
โPreparedStatement
,Connection
๋ฐDataSource
๋ฅผ ์ง์ ๋ํ
์ด๋ ธํ ์ด์ | ์ค๋ช |
---|---|
@EnableQueryKeeper |
๋ชจ๋ QueryKeeper ๊ธฐ๋ฅ์ ํ์ฑํ |
@ExpectQuery |
ํ ์คํธ ์ค ์ค์ ์ํ๋ ์ถ์ ์ ๊ฐ์๋ฅผ ๋ก๊น ๋ฐ ๊ฒ์ฌ |
@ExpectDuplicateQuery |
๋์ผํ SQL ์ฟผ๋ฆฌ(ํ๋ผ๋ฏธํฐ ํฌํจ)๊ฐ ๋ฐ๋ณต ์คํ๋ ๊ฒฝ์ฐ ํ ์คํธ๋ฅผ ์คํจ ์ฒ๋ฆฌ |
@ExpectDetachedAccess |
ํธ๋์ญ์
์ด ์ข
๋ฃ๋ ํ LAZY ํ๋์ ์ ๊ทผํ์ฌ ๋ฐ์ํ๋ LazyInitializationException ๋ฅผ ๊ฐ์ง |
@ExpectTime |
ํ ์คํธ ์คํ ์๊ฐ ์ ํ (ms) |
@ExpectNoDb |
ํ ์คํธ ์ค DB ์ ๊ทผ์ด ์์ด์ผ ํต๊ณผ |
@ExpectNoTx |
ํ ์คํธ ์ค ํธ๋์ญ์ ์ด ํ์ฑํ๋์ด ์์ผ๋ฉด ์คํจ (strict = true์ผ ๊ฒฝ์ฐ, ์ฝ๊ธฐ ์ ์ฉ๋ ์คํจ) |
๐ ์ด๋ ธํ ์ด์ ๋ณ ์์ธ ์ค๋ช (ํด๋ฆญํ์ฌ ํผ์น๊ธฐ)
ํ ์คํธ ์ค ์คํ๋ SQL ์ฟผ๋ฆฌ ์๋ฅผ ๊ธฐ๋กํ๊ณ ๊ฒ์ฆํฉ๋๋ค.
-
ํ๋ผ๋ฏธํฐ:
select
(๊ธฐ๋ณธ๊ฐ: -1) โ ์์ SELECT ์ฟผ๋ฆฌ ์insert
(๊ธฐ๋ณธ๊ฐ: -1) โ ์์ INSERT ์ฟผ๋ฆฌ ์update
(๊ธฐ๋ณธ๊ฐ: -1) โ ์์ UPDATE ์ฟผ๋ฆฌ ์delete
(๊ธฐ๋ณธ๊ฐ: -1) โ ์์ DELETE ์ฟผ๋ฆฌ ์
-
๋์ ๋ฐฉ์: ๊ธฐ๋ณธ์ ์ผ๋ก ์ด ์ด๋ ธํ ์ด์ ์ ์คํ๋ ๋ชจ๋ SQL ์ฟผ๋ฆฌ๋ฅผ ํ๋ผ๋ฏธํฐ๋ฅผ ํฌํจํ ์์ ํ ํํ๋ก ์ถ๋ ฅํ๋ฉฐ, ์คํ ์๊ฐ๊ณผ ํธ์ถ ์์น๋ ํจ๊ป ๋ก๊น ํฉ๋๋ค. ๋ง์ฝ
select
,insert
๋ฑ์ ๊ธฐ๋ ํ์(0 ์ด์)๊ฐ ์ง์ ๋ ๊ฒฝ์ฐ, ์ค์ ์คํ๋ ์ฟผ๋ฆฌ ์์ ์ผ์นํ์ง ์์ผ๋ฉด ํ ์คํธ๋ ์คํจ ์ฒ๋ฆฌ๋ฉ๋๋ค. ๊ธฐ๋๊ฐ์ด ์ค์ ๋์ง ์๋๋ผ๋ ๋ชจ๋ ํ ์คํธ์์ ์ฟผ๋ฆฌ ๋ชฉ๋ก์ ํญ์ ๋์ผํ ํ์์ผ๋ก ์ถ๋ ฅ๋ฉ๋๋ค. ์ถ๋ ฅ ๋ด์ฉ์ ์ฟผ๋ฆฌ ์ ํ, ์คํ ์๊ฐ(ms), ํ๋ผ๋ฏธํฐ๊ฐ ํฌํจ๋ ์ค์ SQL๋ฌธ, ํธ์ถ ์์น(ํด๋์ค๋ช #๋ฉ์๋:๋ผ์ธ ๋ฒํธ) ๋ฑ์ ํฌํจํ๋ฉฐ ๋๋ฒ๊น ์ด๋ ์ฑ๋ฅ ๋ถ์์ ๋งค์ฐ ์ ์ฉํฉ๋๋ค.
๋์ผํ SQL ์ฟผ๋ฆฌ(ํ๋ผ๋ฏธํฐ ํฌํจ)๊ฐ ์ฌ๋ฌ ๋ฒ ์คํ๋ ๊ฒฝ์ฐ ํ ์คํธ๋ฅผ ์คํจ ์ฒ๋ฆฌํฉ๋๋ค.
-
ํ๋ผ๋ฏธํฐ:
max
(์ ํ, ๊ธฐ๋ณธ๊ฐ: 0) โ ํ์ฉ๋๋ ์ค๋ณต ์ฟผ๋ฆฌ์ ์ต๋ ๊ฐ์
-
์๋ ๋ฐฉ์:
ํ ์คํธ ์คํ ์ค ๋ฐ์ํ ๋ชจ๋ SQL ์ฟผ๋ฆฌ์ ๊ทธ ํ๋ผ๋ฏธํฐ๋ฅผ ์ถ์ ํ์ฌ,
๋์ผํ ์ฟผ๋ฆฌ(๋ฌธ์์ด ๋ฐ ํ๋ผ๋ฏธํฐ ์กฐํฉ)๊ฐ ๋ฐ๋ณต ์คํ๋ ๊ฒฝ์ฐ ์ค๋ณต์ผ๋ก ํ๋จํฉ๋๋ค.
์ด ์ค๋ณต ์ฟผ๋ฆฌ ์๊ฐmax
๊ฐ์ ์ด๊ณผํ๋ฉด ํ ์คํธ๋ ์คํจํ๊ฒ ๋ฉ๋๋ค.
๋ฃจํ ๋ด ๋์ผ SELECT ๋ฐ๋ณต, ์ค์๋ก ๋ฐ์ํ N+1 ๋ฌธ์ ๋ฑ์ ์กฐ๊ธฐ์ ๊ฐ์งํ๋ ๋ฐ ์ ์ฉํฉ๋๋ค.
ํธ๋์ญ์ ์ด ์ข ๋ฃ๋ ์ํ์์ ์ง์ฐ ๋ก๋ฉ ํ๋์ ์๋ชป ์ ๊ทผํ ๊ฒฝ์ฐ ๋ฐ์ํ๋ LazyInitializationException ์ ๊ฐ์งํฉ๋๋ค. ์ฆ, JPA ์ํฐํฐ๊ฐ detached ์ํ์ผ ๋ ๋ฐ์ํ๋ ์๋ชป๋ Lazy ํ๋ ์ ๊ทผ์ ํ ์คํธ ์ค ์กฐ๊ธฐ์ ํ์ธํ ์ ์์ต๋๋ค.
-
ํ๋ผ๋ฏธํฐ: ์์
-
๋์ ๋ฐฉ์: ํ ์คํธ ์คํ ์ค ๋ฐ์ํ
LazyInitializationException
์ AOP๋ก ๊ฐ๋ก์ฑ์ด, ์ด๋ค ์ํฐํฐ์ ์ด๋ค ํ๋๊ฐ ์๋ชป ์ ๊ทผ๋์๋์ง ๊ธฐ๋กํฉ๋๋ค. ์ด๋ฅผ ํตํด ํ ์คํธ์์ ์์์น ๋ชปํ Lazy ์ ๊ทผ์ ๋น ๋ฅด๊ฒ ๊ฐ์งํ ์ ์์ต๋๋ค.
โ ๏ธ ํธ๋์ญ์ ์ธ๋ถ์์์ ๋น์ ์์ ์ธ Lazy ์ ๊ทผ(LazyInitializationException) ๋ง ํ์งํฉ๋๋ค.
ํ ์คํธ๊ฐ ์ง์ ๋ ์๊ฐ ๋ด์ ์๋ฃ๋์ด์ผ ํฉ๋๋ค.
-
ํ๋ผ๋ฏธํฐ:
value
(ํ์) โ ํ์ฉ๋๋ ์ต๋ ํ ์คํธ ์คํ ์๊ฐ (ms ๋จ์)
-
๋์ ๋ฐฉ์: ํ ์คํธ ์คํ ์ ์ฒด ์๊ฐ(์ค์ , DB ์ฟผ๋ฆฌ ๋ฑ ํฌํจ)์ ์ธก์ ํ๋ฉฐ, ์ค์ ํ ์๊ฐ ์ด์ ์์๋๋ฉด ์คํจํฉ๋๋ค.
ํ ์คํธ ์ค ์ด๋ค ํํ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ ๊ทผ๋ ์์ด์ผ ํฉ๋๋ค.
-
ํ๋ผ๋ฏธํฐ: ์์
-
๋์ ๋ฐฉ์: SELECT, INSERT, UPDATE, DELETE ๋ฑ ๋ชจ๋ ์ฟผ๋ฆฌ ์คํ์ ๊ฐ์งํ๋ฉฐ, ๋จ ํ๋๋ผ๋ ๋ฐ์ํ๋ฉด ์คํจํฉ๋๋ค. ์์ ๋ก์ง ๋๋ ์บ์ ๋จ์ ํ ์คํธ์ ์ ์ฉํฉ๋๋ค.
ํ ์คํธ๊ฐ ํธ๋์ญ์ ์ธ๋ถ์์ ์คํ๋์ด์ผ ํจ์ ๊ฒ์ฆํฉ๋๋ค.
-
ํ๋ผ๋ฏธํฐ:
strict
(๊ธฐ๋ณธ๊ฐ: true) โreadOnly
ํธ๋์ญ์ ๊น์ง ๊ธ์งํ ์ง ์ฌ๋ถ
-
๋์ ๋ฐฉ์: ํ ์คํธ ์คํ ์ค ํ์ฑ ํธ๋์ญ์ ์ด ์กด์ฌํ๋์ง ํ์ธํฉ๋๋ค.
strict=true
์ธ ๊ฒฝ์ฐ,@Transactional(readOnly = true)
๋ ์คํจ ์ฒ๋ฆฌ๋ฉ๋๋ค.
์๋ ์์๋ ์ผ๋ถ ํ ์คํธ๊ฐ ์คํจํ๋๋ก ์ค๊ณ๋์ด ์์ต๋๋ค.
@SpringBootTest
@EnableQueryKeeper // โ
๊ธฐ๋ฅํ์ฑํํ
class UserRepositoryTest {
....
@Test
@ExpectQuery(select = 1, insert = 1) // โ ์คํจ
@ExpectTime(500) // โ
์ฑ๊ณต
@ExpectNoTx(strict = false) // โ
์ฑ๊ณต
@ExpectNoDb // โ ์คํจ
@ExpectDuplicateQuery // โ ์คํจ
void testCombinedAssertions() {
User user = new User("Alice", "alice@example.com");
user.addRole(new Role("ADMIN"));
user.addRole(new Role("USER"));
userRepository.save(user);
userRepository.findAll();
entityManager.clear();
List<User> users = userRepository.findAll();
users.get(0).getRoles().size();
int sum = 0;
for (int i = 0; i < 1000; i++)
sum += i;
assertThat(sum).isGreaterThan(0);
}
@Test
@ExpectDetachedAccess // โ ์คํจ
void testDetachedAccess() {
userService.triggerDetachedAccess();
}
}
UserRepositoryTest > testDetachedAccess() STANDARD_OUT
2025-01-01T12:00:00.000+00:00 INFO 7475 --- [ Test worker] c.q.junit.QueryKeeperExtension :
[QueryKeeper] โถ ExpectDetachedAccess X FAILED - Entity: Role
โข Field: roles
โข Access Path: User.roles
โข Root Entity: User
UserRepositoryTest > testCombinedAssertions() STANDARD_OUT
2025-06-16 19:39:14.450 INFO 2484 --- [ Test worker] c.q.junit.QueryKeeperExtension :
[QueryKeeper] โถ ExpectNoTx โ PASSED - No transaction in testCombinedAssertions()
[QueryKeeper] โถ ExpectTime โ PASSED - testCombinedAssertions took 11ms (expected <= 500ms)
[QueryKeeper] โถ ExpectQuery X FAILED
--------------------------------------------------------
Expected - (SELECT: 1, INSERT: 1), Actual - (SELECT: 2, INSERT: 3)
--------------------------------------------------------
Total Queries: 8
--------------------------------------------------------
1. [OTHER] (0 ms)
SQL : call next value for hibernate_sequence
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:48
--------------------------------------------------------
2. [OTHER] (0 ms)
SQL : call next value for hibernate_sequence
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:48
--------------------------------------------------------
3. [OTHER] (0 ms)
SQL : call next value for hibernate_sequence
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:48
--------------------------------------------------------
4. [INSERT] (0 ms)
SQL : insert into users (email, name, id) values ('alice@example.com', 'Alice', 3)
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:48
--------------------------------------------------------
5. [INSERT] (0 ms)
SQL : insert into roles (name, user_id, id) values ('ADMIN', 3, 4)
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:48
--------------------------------------------------------
6. [INSERT] (0 ms)
SQL : insert into roles (name, user_id, id) values ('USER', 3, 5)
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:48
--------------------------------------------------------
7. [SELECT] (0 ms)
SQL : select user0_.id as id1_1_, user0_.email as email2_1_, user0_.name as name3_1_ from users user0_
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:49
--------------------------------------------------------
8. [SELECT] (0 ms)
SQL : select user0_.id as id1_1_, user0_.email as email2_1_, user0_.name as name3_1_ from users user0_
Caller : com.example.demo.UserRepositoryTest#testCombinedAssertions:52
--------------------------------------------------------
[QueryKeeper] โถ ExpectNoDb X FAILED - 8 DB queries were executed in testCombinedAssertions()
[QueryKeeper] โถ ExpectDuplicateQuery X FAILED - Found 1 duplicate queries (allowed: 0)
โข Duplicate [2x] โ select user0_.id as id1_1_, user0_.email as email2_1_, user0_.name as name3_1_ from users user0_
Querykeeper์ ๋ก๊ทธ ์ถ๋ ฅ์ ์ํด SLF4J๋ฅผ ์ฌ์ฉํฉ๋๋ค.
Spring Boot๋ฅผ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ ๋ณ๋ ์ค์ ์ด ํ์ํ์ง ์์ต๋๋ค (spring-boot-starter-logging
์ ํฌํจ)
Spring Boot๊ฐ ์๋ ํ๊ฒฝ์์๋ ๋ค์ ์์กด์ฑ์ ์ถ๊ฐ:
runtimeOnly 'ch.qos.logback:logback-classic:1.4.14'
make publish
dependencies {
testImplementation 'com.querykeeper:querykeeper:1.1.0'
}
testImplementation files('libs/querykeeper-1.1.0.jar')
test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
showStandardStreams = true
}
}
- Java 8 ~ Java 17+
- Spring Boot 2.7.x ~ 3.2+
- Hibernate 5.6.x ~ 6.3+
- JUnit Jupiter 5.8+
์ด ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ Spring Boot + JPA ํ๊ฒฝ์ ์ ์ ๋ก ์๋ ์์กด์ฑ์ด ํจ๊ป ์์ด์ผ ์ ์์ ์ผ๋ก ๋์ํฉ๋๋ค
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.querykeeper:querykeeper:1.1.0'
}