๐Ÿ“Œ Spring ์‹ฌํ™” ๊ณผ์ œ ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ…

euphonyยท2025๋…„ 2์›” 27์ผ
0

๋‚ด์ผ๋ฐฐ์›€์บ ํ”„

๋ชฉ๋ก ๋ณด๊ธฐ
57/66

Spring ์‹ฌํ™” ์ฃผ์ฐจ ๊ฐœ์ธ ๊ณผ์ œ๋ฅผ ์ง„ํ–‰ํ–ˆ๋‹ค. ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง, N+1 ๋ฌธ์ œ ํ•ด๊ฒฐ, API ๋กœ๊น…, ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ ๋“ฑ์˜ ๊ณผ์ œ๋ฅผ ์ˆ˜ํ–‰ํ–ˆ๋‹ค. ๊ทธ ๊ณผ์ •์—์„œ์˜ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ…๊ณผ ๊ณ ๋ฏผํ–ˆ๋˜ ์ , ๋ฐฐ์šด ์  ๋“ฑ์„ ๊ธฐ๋กํ•˜๊ณ ์ž ํ•œ๋‹ค.

Main ์‹คํ–‰ ์‹œ ์—๋Ÿฌ ๋ฐœ์ƒ

์ฒ˜์Œ ํ”„๋กœ์ ํŠธ๋ฅผ ๋ฐ›์•„ ์‹คํ–‰ํ•˜๋‹ˆ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—๋Ÿฌ๊ฐ€ ๋‚˜์™”๋‹ค.

์—๋Ÿฌ ๋ฉ”์„ธ์ง€๊ฐ€ ๊ธธ๊ธด ํ•˜์ง€๋งŒ ์ž˜ ์ฝ์–ด๋ณด๋ฉด JWT ์‹œํฌ๋ฆฟ ํ‚ค์— ๋Œ€ํ•œ ๋ฌธ์ œ์ธ ๊ฒƒ์„ ์ง์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค. application.properties ํŒŒ์ผ์— ํ‚ค๋ฅผ ๋„ฃ์œผ๋ ค๊ณ  ํ–ˆ๋Š”๋ฐ, ์•„์˜ˆ resourse ํด๋”๊ฐ€ ์—†์—ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋จผ์ € ํ‚ค๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ–ˆ๋‹ค.

java.lang.IllegalArgumentException: Could not resolve placeholder 'jwt.secret.key' in value "${jwt.secret.key}"
org.springframework.beans.factory.BeanCreationException: 
Error creating bean with name 'jwtUtil': Injection of autowired dependencies failed
Unsatisfied dependency expressed through constructor parameter 0: 
Error creating bean with name 'jwtUtil': Injection of autowired dependencies failed

ํ„ฐ๋ฏธ๋„์— ์•„๋ž˜์™€ ๊ฐ™์ด ์ž…๋ ฅํ•ด ํ‚ค๋ฅผ ๋งŒ๋“ค์—ˆ๋‹ค. ํ‚ค๋ฅผ ์ฒ˜์Œ ๋งŒ๋“ค์–ด ๋ณด์ง€๋งŒ ๊ฒ€์ƒ‰ํ•˜๋‹ˆ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์ด ๋ฐ”๋กœ ๋‚˜์™”๋‹ค. ํ„ฐ๋ฏธ๋„๋กœ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ• ์™ธ์—๋„ ํ‚ค๋ฅผ ๋งŒ๋“œ๋Š” ์‚ฌ์ดํŠธ๋ฅผ ํ™œ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•๋„ ์žˆ์—ˆ๋‹ค.

openssl rand -hex 64

๊ฒฐ๊ณผ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์•„์ฃผ ๊ธด ์‹œํฌ๋ฆฟ ํ‚ค๊ฐ€ ๋‚˜์™”๋‹ค.

22b8c9ae9a1b397ef0c8a2d142f91d1d00b7c0dc0dae01d2999d197f111f3e01117c15a4477f63732fa2aa6fe8fbf313874b681fa31cef4049aa50707b7f8156

๊ทธ๋ฆฌ๊ณ  resourse ํด๋”๋ฅผ ์ƒ์„ฑ ํ›„, application.properties ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ‚ค๋ฅผ ๋„ฃ์—ˆ๋‹ค.

jwt.secret.key="JWT_์‹œํฌ๋ฆฟ_ํ‚ค"

์—ฌ๊ธฐ์„œ ์ค‘์š”ํ•œ ์ ์€ JwtUtil ํด๋ž˜์Šค์—์„œ${jwt.secret.key} ๋กœ ์ ์—ˆ๋‹ค๋ฉด, jwt.secret.key ๋กœ ๋˜‘๊ฐ™์ด ์ ์–ด์•ผ ํ•œ๋‹ค๋Š” ์ ์ด๋‹ค.

๊ทธ๋Ÿฐ๋ฐ ๋˜ ์—๋Ÿฌ๊ฐ€ ๋‚ฌ๋‹ค. ์™œ์ผ๊นŒ? ์ž˜ ์‚ดํŽด๋ณด๋‹ˆ ์ƒˆ๋กœ์šด ์—๋Ÿฌ๊ฐ€ ๋“ฑ์žฅํ–ˆ๋‹ค.

java.lang.IllegalArgumentException: Illegal base64 character 22

ํ•ด๋‹น ์—๋Ÿฌ์— ๋Œ€ํ•œ ๋‚ด์šฉ์„ ๊ฒ€์ƒ‰ํ•˜๋‹ค๊ฐ€ JWT๋ฅผ ์‚ฌ์šฉํ•œ ๋‹ค๋ฅธ ์‚ฌ๋žŒ์˜ ์Šคํฌ๋ฆฐ์ƒท์„ ๋ณด๊ณ  ๋‚˜์˜ ์‹ค์ˆ˜๋ฅผ ์•Œ์•„์ฐจ๋ ธ๋‹ค. ๋ฐ”๋กœ ์Œ๋”ฐ์˜ดํ‘œ๋กœ ํ‚ค๋ฅผ ๊ฐ์‹ผ ๊ฒƒ์ด๋‹ค.

์ด๋ ‡๊ฒŒ ์ž‘์„ฑํ•˜์ง€ ๋ง๊ณ ,

jwt.secret.key="JWT_์‹œํฌ๋ฆฟ_ํ‚ค"

์ด๋ ‡๊ฒŒ ์Œ๋”ฐ์˜ดํ‘œ ์—†์ด ์ž‘์„ฑํ•ด์•ผ ํ•œ๋‹ค. ์ œ๋Œ€๋กœ ๊ณ ์นœ ํ›„ ์‹คํ–‰ํ•˜๋‹ˆ ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ–ˆ๋‹ค.

jwt.secret.key=JWT_์‹œํฌ๋ฆฟ_ํ‚ค

+) ์ถ”๊ฐ€ ๊ณต๋ถ€ ๐Ÿ‘€

์ฒ˜์Œ์—๋Š” ์‹œํฌ๋ฆฟ ํ‚ค ๊ฐ’์„ ๋ฐ”๊พผ ๊ฒƒ ๋•Œ๋ฌธ์— ์ •์ƒ ์ž‘๋™ํ•˜๋Š” ์ค„ ์•Œ์•˜์ง€๋งŒ ์•„๋‹ˆ์—ˆ๋‹ค. ํ‚ค ๊ฐ’์ด ๋ฌธ์ œ๋Š” ์•„๋‹ˆ์—ˆ์ง€๋งŒ, ๋‚ด๊ฐ€ ์œ„์—์„œ ๋งŒ๋“  ํ‚ค ๊ฐ’์€ ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์—์„œ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋Š” ์•Œ๊ณ ๋ฆฌ์ฆ˜์— ์ ํ•ฉํ•˜์ง€ ์•Š๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๊ฒŒ ๋˜์—ˆ๋‹ค.

ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์—์„œ ์ ์šฉํ•œ ์•Œ๊ณ ๋ฆฌ์ฆ˜์€ HS256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์ด๋‹ค.

private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

์‚ฌ์šฉํ•˜๋Š” ์•Œ๊ณ ๋ฆฌ์ฆ˜์— ๋”ฐ๋ผ ํ‚ค๋ฅผ ๋‹ค๋ฅด๊ฒŒ ์ƒ์„ฑํ•ด์•ผ ํ•œ๋‹ค. ๋‚ด๊ฐ€ ์œ„์—์„œ ํ‚ค๋ฅผ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉํ•œ openssl rand -hex 64 ๋Š” HS512(512๋น„ํŠธ) ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์‚ฌ์šฉํ•  ๋•Œ ์ ํ•ฉํ•˜๋‹ค.

๋”ฐ๋ผ์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์กฐ๊ธˆ ๋‹ค๋ฅธ ๋ช…๋ น์–ด๋ฅผ gitbash์— ์น˜๋‹ˆ ๋” ์งง์€ ํ‚ค๊ฐ€ ๋‚˜์™”๋‹ค.

openssl rand -base64 32

๊ฒฐ๊ณผ๋ฅผ ๋ณด๋‹ˆ ํ™•์‹คํžˆ ์œ„์—์„œ ์–ป์€ ํ‚ค์™€ ๋‹ค๋ฅด๋‹ค.

7yD9Da2chXAX5qCI2WrIzdiIifDZI8WPtcqJ+rjUJRA=

๐Ÿ’ก-hex 64 vs -base64 32 ์ฐจ์ด

1) -hex 64 (HEX)

  • HS512(512๋น„ํŠธ) ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์‚ฌ์šฉํ•  ๋•Œ ์ ํ•ฉ
  • ๋‹ค๋งŒ, HEX ๋ฌธ์ž์—ด์€ Base64์™€ ๋‹ค๋ฅด๊ฒŒ ๋ฐ”๋กœ Secret Key๋กœ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋ฐ”์ดํŠธ ๋ณ€ํ™˜์ด ํ•„์š”ํ•จ
  • ์˜ˆ์ œ ๋ณ€ํ™˜ ์ฝ”๋“œ
    byte[] keyBytes = new BigInteger(secretKey, 16).toByteArray();

2) -base64 32 (Base64)

  • HS256(256๋น„ํŠธ) ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์‚ฌ์šฉํ•  ๋•Œ ์ ํ•ฉ
  • Base64.getDecoder().decode(secretKey)๋ฅผ ํ†ตํ•ด ๋ฐ”๋กœ ๋ฐ”์ดํŠธ ๋ฐฐ์—ด๋กœ ๋ณ€ํ™˜ ๊ฐ€๋Šฅ
  • Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey))์™€ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

๊ทธ๋Ÿฐ๋ฐ, openssl rand -hex 64 ๋ฅผ ์ด์šฉํ•ด ์ƒ์„ฑํ•œ ํ‚ค๋„ ์ •์ƒ์ ์œผ๋กœ ๋Œ์•„๊ฐ€์„œ ์˜๋ฌธ์ด ๋“ค์—ˆ๋‹ค. HS256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์— ์ ํ•ฉํ•œ ํ‚ค๋Š” openssl rand -base64 32 ๋กœ ์ƒ์„ฑํ•œ ํ‚ค๋ผ๊ณ  ํ–ˆ๋Š”๋ฐ, ์™œ ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ•˜๋Š” ๊ฑธ๊นŒ? ์ด ๋ถ€๋ถ„์€ GPT์—๊ฒŒ ๋ฌผ์–ด๋ดค๋‹ค.๐Ÿค”

๊ทธ ์ด์œ ๋Š” Spring Security ๋ฐ JJWT ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์—์„œ ๋‚ด๋ถ€์ ์œผ๋กœ HEX ๋ฌธ์ž์—ด์„ ๋ฐ”์ดํŠธ ๋ฐฐ์—ด๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ฒ˜๋ฆฌํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋ผ๊ณ  ํ•œ๋‹ค.

  • JJWT (Java JWT, JSON Web Token for Java) : Java ๊ธฐ๋ฐ˜์˜ JWT(Json Web Token) ์ƒ์„ฑ ๋ฐ ๊ฒ€์ฆ์„ ์œ„ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ, JWT๋ฅผ ์‰ฝ๊ฒŒ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ๋„๋ก ๋„์™€์ค€๋‹ค.

JJWT์—์„œ Keys.hmacShaKeyFor() ๋ฉ”์„œ๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋™์ž‘ํ•œ๋‹ค. byte[] keyBytes๋ฅผ ์ž…๋ ฅ ๋ฐ›์•„ SecretKey ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•˜๋Š”๋ฐ, ์—ฌ๊ธฐ์„œ ์ž…๋ ฅ๊ฐ’์ด 256๋น„ํŠธ๋ณด๋‹ค ๊ธธ๋ฉด ์ž๋™์œผ๋กœ ์ ์ ˆํ•œ ๊ธธ์ด๋กœ ์ž˜๋ผ์„œ ์‚ฌ์šฉํ•œ๋‹ค.

public static SecretKey hmacShaKeyFor(byte[] keyBytes) {
    return new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
}

๋”ฐ๋ผ์„œ, ํ‚ค๊ฐ€ ๊ธธ์–ด๋„ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. ํ•˜์ง€๋งŒ ๋ถˆํ•„์š”ํ•˜๊ฒŒ ๊ธด ๊ฐ’์„ ์‚ฌ์šฉํ•˜๊ธฐ๋ณด๋‹จ Base64 ํ‚ค๋ฅผ ์‚ฌ์šฉํ•ด ๋ณ€ํ™˜ ๊ณผ์ •์„ ์—†์• ๋Š” ๊ฒƒ์ด ๋” ํšจ์œจ์ ์ผ ๊ฒƒ์ด๋‹ค.

@Pattern์„ ๋‚˜๋ˆ ์•ผ ํ• ๊นŒ?

์ฝ”๋“œ ๊ฐœ์„  ๋ฌธ์ œ ์ค‘ ์•„๋ž˜ ์ฝ”๋“œ ๋ถ€๋ถ„์„ ํ•ด๋‹น API์˜ ์š”์ฒญ DTO์—์„œ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ๊ฐœ์„ ํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค.

if (userChangePasswordRequest.getNewPassword().length() < 8 ||
        !userChangePasswordRequest.getNewPassword().matches(".*\\d.*") ||
        !userChangePasswordRequest.getNewPassword().matches(".*[A-Z].*")) {
    throw new InvalidRequestException("์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•˜๊ณ , ์ˆซ์ž์™€ ๋Œ€๋ฌธ์ž๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.");
}

๊ทธ๋™์•ˆ ๊ณผ์ œ์—์„œ ํ–ˆ๋˜ ๋‚ด์šฉ์ด์–ด์„œ ๋ฐ”๋กœ @size ์™€ @Pattern์„ ์‚ฌ์šฉํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ž‘์„ฑํ–ˆ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ @Pattern์„ ํ•˜๋‚˜๋กœ ํ•ฉ์น˜๋Š”๊ฒŒ ๋‚˜์„ ๊ฒƒ ๊ฐ™์•˜๋‹ค. ํ•ฉ์น˜์ง€ ์•Š์œผ๋ฉด ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ˆซ์ž์™€ ๋Œ€๋ฌธ์ž๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ผ๋Š” ๋ฉ”์„ธ์ง€๋„ ์–ด๋””์— ๋„ฃ์–ด์•ผ ํ• ์ง€ ์• ๋งคํ–ˆ๋‹ค.

@NotBlank
@Size(min =  8, message = "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
@Pattern(regexp = ".*\\d.*")
@Pattern(regexp = ".*[A-Z].*")
private String newPassword;

๊ทธ๋ž˜์„œ ์ •๊ทœํ‘œํ˜„์‹์„ ๊ฒ€์ƒ‰ํ•ด ์ˆ˜์ • ํ›„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ•œ ๋ฒˆ์— ๋„ฃ๊ณ  ๋ฉ”์„ธ์ง€๋„ ๊ฐ™์ด ๋„ฃ์—ˆ๋‹ค.

@NotBlank
@Size(min =  8, message = "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
@Pattern(regexp = "^(?=.*\\d)(?=.*[A-Z]).*$", message = "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ˆซ์ž์™€ ๋Œ€๋ฌธ์ž๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
private String newPassword;

์ด ๋ฌธ์ œ์—์„œ ๋‚ด๊ฐ€ ๊ณ ๋ฏผํ•œ ์ ์€ @Pattern์„ ํ•˜๋‚˜์˜ ์ •๊ทœ ํ‘œํ˜„์‹์œผ๋กœ ์ ๊ธฐ vs ๋”ฐ๋กœ ๋‚˜๋ˆ ์„œ ์ ๊ธฐ ์˜€๋‹ค.

  • ํ•˜๋‚˜์˜ ์ •๊ทœ ํ‘œํ˜„์‹์œผ๋กœ ์ ๋Š”๋‹ค๋ฉด?
    • ๋‚˜๋ˆ ์„œ ์ ๋Š” ๊ฒƒ๋ณด๋‹ค ๊น”๋”ํ•ด๋ณด์ธ๋‹ค.
    • ์ง€๊ธˆ๊นŒ์ง€ ๋”ฐ๋กœ ๋‚˜๋ˆˆ ์ ์ด ์—†์–ด์„œ ํ•˜๋‚˜๋กœ ํ•˜๋Š” ๊ฒŒ ๋งž๋Š” ๊ฒƒ ๊ฐ™๋‹ค.
  • ๋”ฐ๋กœ ๋‚˜๋ˆ ์„œ ์ ๋Š”๋‹ค๋ฉด?
    • ๋‚˜์ค‘์— ์œ ์ง€๋ณด์ˆ˜ํ•˜๊ธฐ ์‰ฌ์šธ ๊ฒƒ ๊ฐ™๋‹ค.
    • ์˜ค๋ฅ˜ ๋ฉ”์„ธ์ง€๋ฅผ ๋”ฐ๋กœ ๋ณด์—ฌ์ค€๋‹ค๋ฉด ๋‹ค๋ฅธ ๊ฐœ๋ฐœ์ž์ž…์žฅ์—์„œ ๋” ํŽธ๋ฆฌํ•  ๊ฒƒ ๊ฐ™๋‹ค.

์™€ ๊ฐ™์€ ์ƒ๊ฐ์„ ํ•˜๋‹ค๊ฐ€ ํŠœํ„ฐ๋‹˜๊ป˜ ์ฐพ์•„๊ฐ€ ์—ฌ์ญค๋ดค๋‹ค. ์šฐ์„  ๊ฒฐ๋ก ๋ถ€ํ„ฐ ๋งํ•˜์ž๋ฉด, ๊ฐœ๋ฐœ์ž๋งˆ๋‹ค ๋‹ค๋ฅด์ง€๋งŒ ์ง€๊ธˆ ์ƒํ™ฉ(๋น„๋ฐ€๋ฒˆํ˜ธ์— ์ˆซ์ž์™€ ๋Œ€๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜์–ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ)์€ ํŠน์ดํ•œ ์กฐ๊ฑด์ด ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์— ํ•˜๋‚˜๋กœ ํ•ฉ์ณ๋„ ์ข‹์„ ๊ฒƒ ๊ฐ™๋‹ค๊ณ  ๋ง์”€ํ•ด์ฃผ์…จ๋‹ค.

๊ฐ์ž์˜ ์Šคํƒ€์ผ์— ๋”ฐ๋ผ ๋‹ค๋ฅด์ง€๋งŒ, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ƒํ™ฉ์„ ์ œ์‹œํ•ด์ฃผ์…”์„œ ์ดํ•ด๊ฐ€ ์ž˜ ๋๋‹ค.

  • ๋ณด์•ˆ์„ ๊ฐ•์กฐํ•˜๋Š” ์ƒํ™ฉ์—์„œ ์กฐ๊ฑด์ด ๊ณ„์† ์ถ”๊ฐ€๋œ๋‹ค๋ฉด?
    • ํŠœํ„ฐ๋‹˜์€ ์š”๊ตฌ ์กฐ๊ฑด์˜ ๋ณ€๊ฒฝ์ด ์žฆ์€ ํ”„๋กœ์ ํŠธ ์ฐธ์—ฌ ๊ฒฝํ—˜์ด ๋งŽ์œผ์…”์„œ ํŒจํ„ด์„ ๋”ฐ๋กœ ๋‚˜๋ˆ„๋Š” ํŽธ์ด๋ผ๊ณ  ํ•˜์…จ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์š”๊ตฌ ์กฐ๊ฑด์— ๋งž๊ฒŒ ์ฃผ์„์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.
  • ํŒจํ„ด์„ ๋Š˜๋ฆฌ๊ฑฐ๋‚˜ ์ค„์—ฌ์•ผ ํ•  ๊ฒฝ์šฐ๋ผ๋ฉด?
    • ์ •๊ทœ์‹ ํŒจํ„ด์„ ๋ชจ๋‘ ๋ฌถ์–ด์„œ ์ฒ˜๋ฆฌํ•˜๋ฉด ๊ฐ€๋…์„ฑ์ด ๋–จ์–ด์งˆ ์ˆ˜ ์žˆ๋‹ค.
    • ์ •๊ทœ ํ‘œํ˜„์‹์ด ์ต์ˆ™ํ•˜์ง€ ์•Š์€ ๊ฐœ๋ฐœ์ž๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ๋‹ค.

๐Ÿ’ญ ํ•˜๋‚˜์˜ ๋ฐฉ๋ฒ•์„ ์ •๋‹ต์œผ๋กœ ๊ณ ์ˆ˜ํ•˜๊ธฐ ๋ณด๋‹ค๋Š”, ์ƒํ™ฉ์— ๋งž์ถฐ์„œ ์œ ์—ฐํ•˜๊ฒŒ ์ฝ”๋“œ ์Šคํƒ€์ผ์„ ์ ์šฉํ•˜๋Š” ๊ฒƒ๋„ ๊ฐœ๋ฐœ์ž์˜ ์—ญ๋Ÿ‰์ด๋ผ๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ ์‹œ UnnecessaryStubbingException ๋ฐœ์ƒ

ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๋Š”๋ฐ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

์ฐพ์•„๋ณด๋‹ˆ mockito-core ๋ฒ„์ „์ด 1.x์ผ ๋•Œ ์—†์—ˆ๋˜ Strictness(ํ…Œ์ŠคํŠธ์ฝ”๋“œ์˜ ์—„๊ฒฉ์„ฑ)์„ ๊ทœ์ •ํ•˜๊ธฐ ์œ„ํ•ด ์ƒ๊ธด ์—๋Ÿฌ๋ผ๊ณ  ํ•œ๋‹ค. ๋ธ”๋กœ๊ทธ๋“ค์„ ์ญ‰ ๋ณด๋‹ˆ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์€ 3๊ฐ€์ง€ ์ •๋„๊ฐ€ ์žˆ์—ˆ๋‹ค.

  • ํ•„์š”์—†๋Š” Stubbing ์ œ๊ฑฐํ•˜๊ธฐ
  • Lenient() ๋ฉ”์„œ๋“œ๋ฅผ ์•ž์— ์ถ”๊ฐ€ํ•˜๊ธฐ
  • Mockito-core๋ฒ„์ „์„ 1.x๋‚˜ 2.x๋กœ ๋‚ด๋ฆฌ๊ธฐ

๋ญ”๊ฐ€๋ฅผ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ๋ฒ„์ „์„ ๋‚ด๋ฆฌ๋Š” ๊ฒƒ์€ ์กฐ๊ธˆ ๋‘๋ ค์›Œ์„œ(...) ํ•„์š”์—†๋Š” Stubbing์„ ์ œ๊ฑฐํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์„ ํƒํ–ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ค‘์š”ํ•œ ์ ์€ Stubbing์ด ์ •ํ™•ํžˆ ๋ฌด์—‡์ธ์ง€ ๋ชจ๋ฅธ๋‹ค๋Š” ์ ์ด๋‹ค.๐Ÿคจ

  • Stubbing : Mock ๊ฐ์ฒด๊ฐ€ ํŠน์ • ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ–ˆ์„ ๋•Œ ๋ฏธ๋ฆฌ ์ •ํ•ด์ง„ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์„ค์ •ํ•˜๋Š” ๊ฒƒ์œผ๋กœ, ํ…Œ์ŠคํŠธ์—์„œ DB๋‚˜ ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ์ œ๊ฑฐํ•˜๊ณ , ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์„ ์ œ์–ดํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉํ•œ๋‹ค.
  • ๋‹ค์Œ๊ณผ ๊ฐ™์ด findById(todoId)๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ํ•ญ์ƒ Optional.of(todo)๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. ์—ฌ๊ธฐ์„œ todoRepository๋Š” ์‹ค์ œ DB๋ฅผ ์กฐํšŒํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ๋ฏธ๋ฆฌ ์ง€์ •ํ•œ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฐ€์งœ(Mock) ๊ฐ์ฒด๋กœ ๋™์ž‘ํ•œ๋‹ค. ์‹ค์ œ DB์— ์ ‘๊ทผํ•˜์ง€ ์•Š๊ณ  ํ…Œ์ŠคํŠธํ•˜๋ฉด ๋” ๋น ๋ฅด๊ณ  ๋…๋ฆฝ์ ์ธ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— Stubbing์„ ์‚ฌ์šฉํ•œ๋‹ค.
   given(todoRepository.findById(todoId)).willReturn(Optional.of(todo));

์ด์ œ ๋‹ค์‹œ ์—๋Ÿฌ ์ฝ”๋“œ๋ฅผ ์‚ดํŽด๋ณด๋ฉด, UnnecessaryStubbingException, ์ฆ‰ ํ•„์š”ํ•˜์ง€ ์•Š์€ Stubbing์ด ์กด์žฌํ•œ๋‹ค๋Š” ๋œป์ธ ๊ฒƒ ๊ฐ™๋‹ค. ํ•„์š”ํ•˜์ง€ ์•Š์€ Stubbing์ด๋ผ๋Š” ๊ฒƒ์€ ์‹ค์ œ ๋Œ“๊ธ€ ๋ชฉ๋ก์„ ์กฐํšŒํ•˜๋Š” CommentService ํด๋ž˜์Šค์˜ getComments() ๋ฉ”์„œ๋“œ์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” ๋ถ€๋ถ„์„ ๋งํ•˜๋Š” ๊ฒƒ์ด๋‹ค.

์—๋Ÿฌ๊ฐ€ ๋‚œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์™€ getComments() ๋ฉ”์„œ๋“œ๋ฅผ ๋ณด๋ฉด ์„œ๋น„์Šค ์ฝ”๋“œ์—์„œ๋Š” ํ˜ธ์ถœํ•˜์ง€ ์•Š์ง€๋งŒ, ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ๋‚ด์—์„œ๋Š” ํ˜ธ์ถœํ•˜๊ณ  ์žˆ๋Š” ๋ถ€๋ถ„์ด ์žˆ๋‹ค.

@Test
void comment_๋ชฉ๋ก_์กฐํšŒ์—_์„ฑ๊ณตํ•œ๋‹ค() {
    // given
    long userId = 1L;
    long todoId = 1L;

    User user = new User("user1@example.com", "password", UserRole.USER);
    ReflectionTestUtils.setField(user, "id", userId);

    Todo todo = new Todo("Title", "Contents", "Sunny", user);
    ReflectionTestUtils.setField(todo, "id", todoId);

    Comment comment = new Comment("comment", user, todo);
    List<Comment> commentList = List.of(comment);

    given(todoRepository.findById(todoId)).willReturn(Optional.of(todo));
    given(commentRepository.findByTodoIdWithUser(todoId)).willReturn(commentList);

    // when
    List<CommentResponse> commentResponses = commentService.getComments(todoId);

    // then
    assertEquals(1, commentResponses.size());
    assertEquals(comment.getId(), commentResponses.get(0).getId());
    assertEquals(comment.getUser().getEmail(), commentResponses.get(0).getUser().getEmail());
}
@Transactional(readOnly = true)
public List<CommentResponse> getComments(long todoId) {
    List<Comment> commentList = commentRepository.findByTodoIdWithUser(todoId);

    return commentList.stream()
            .map(comment -> new CommentResponse(
                    comment.getId(),
                    comment.getContents(),
                    new UserResponse(comment.getUser().getId(), comment.getUser().getEmail())
            ))
            .collect(Collectors.toList());
}

๋ฐ”๋กœ ttodoRepository.findById(todoId)๋ฅผ ํ˜ธ์ถœํ•˜๊ณ  ์žˆ๋Š” ๋ถ€๋ถ„์ด๋‹ค. ์‹ค์ œ ์„œ๋น„์Šค ๋กœ์ง์—์„œ๋Š” ํ˜ธ์ถœํ•˜๊ณ  ์žˆ์ง€ ์•Š๋‹ค. ๋”ฐ๋ผ์„œ ๋‹ค์Œ given()์„ ์ œ๊ฑฐํ–ˆ๋‹ค.

given(todoRepository.findById(todoId)).willReturn(Optional.of(todo)); // ์ œ๊ฑฐ

๊ทธ๋ฆฌ๊ณ ๋‚˜์„œ ๋‹ค์‹œ ์‹คํ–‰ํ•˜๋‹ˆ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ์ž˜ ์ž‘๋™ํ•˜๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์—ˆ๋‹ค. ํ•„์š”ํ•  ๊ฒƒ ๊ฐ™๋‹ค๊ณ  ๋ฌด์ž‘์ • ๋Œ์–ด์“ฐ๋‹ค๊ฐ€ ์ด๋Ÿฐ ์—๋Ÿฌ๋ฅผ ๋งˆ์ฃผํ•˜๋‹ˆ ์„œ๋น„์Šค ๋กœ์ง์„ ์ž˜ ์‚ดํŽด๋ณด๊ณ  ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์งœ์•ผํ•œ๋‹ค๋Š” ๊ฒƒ์„ ๊นจ๋‹ฌ์•˜๋‹ค.

N+1 ๋ฌธ์ œ ํ•ด๊ฒฐํ•˜๊ธฐ

๋‹ค์Œ getTodos() ๋ฉ”์„œ๋“œ์—์„œ ๋ฐœ์ƒํ•˜๊ณ  ์žˆ๋Š” N+1 ๋ฌธ์ œ๋ฅผ @EntityGraph๋ฅผ ์‚ฌ์šฉํ•ด ํ•ด๊ฒฐํ•ด์•ผ ํ•œ๋‹ค.

public Page<TodoResponse> getTodos(int page, int size) {
    Pageable pageable = PageRequest.of(page - 1, size);

    Page<Todo> todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable);

    return todos.map(todo -> new TodoResponse(
            todo.getId(),
            todo.getTitle(),
            todo.getContents(),
            todo.getWeather(),
            new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
            todo.getCreatedAt(),
            todo.getModifiedAt()
    ));
}

์œ„ ์ฝ”๋“œ์—์„œ ํ• ์ผ์„ ๋ชจ๋‘ ๋ถˆ๋Ÿฌ์˜จ ํ›„, ๊ทธ ์•„๋ž˜ ์œ ์ €๋ฅผ ๋˜ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๊ณผ์ •์—์„œ N+1 ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์•„๋ณด์ธ๋‹ค.

Todo ์—”ํ‹ฐํ‹ฐ์˜ ์ผ๋ถ€๋ฅผ ์‚ดํŽด๋ณด๋ฉด User์™€ @ManyToOne ๋‹ค๋Œ€์ผ ์—ฐ๊ด€๊ด€๊ณ„๋ฅผ ๋งบ๊ณ  ์žˆ๊ณ , ์ง€์—ฐ ๋กœ๋”ฉ(FetchType.LAZY)์œผ๋กœ ์„ค์ •๋˜์–ด ์žˆ๋‹ค. ์ง€์—ฐ ๋กœ๋”ฉ์œผ๋กœ ์„ค์ •ํ•˜๋ฉด Todo๋ฅผ ์กฐํšŒํ•  ๋•Œ User๋ฅผ ๋ฐ”๋กœ ๊ฐ€์ ธ์˜ค๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ํ”„๋ก์‹œ ๊ฐ์ฒด๋กœ ์žˆ๋‹ค๊ฐ€ ์‹ค์ œ ์‚ฌ์šฉ๋  ๋•Œ SELECT ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋˜๋Š” ๊ฒƒ์ด๋‹ค.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

๋จผ์ € findAllByOrderByModifiedAtDesc() ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ชจ๋“  todo๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ณ  ์žˆ๋‹ค.

Page<Todo> todos = todoRepository.findAllByOrderByModifiedAtDesc(pageable);

์ด๋•Œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋  ๊ฒƒ์ด๋‹ค.

SELECT * FROM todo ORDER BY modified_at DESC LIMIT ?, ?

๊ทธ๋ฆฌ๊ณ  ๋‹ค์Œ ์ฝ”๋“œ์—์„œ todo.getUser()๋ฅผ ํ•˜๋ฉด User์— ๋Œ€ํ•œ SELECT ์ถ”๊ฐ€ ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋œ๋‹ค. ์ฆ‰ todo๊ฐ€ 100๊ฐœ๋ผ๋ฉด ์œ„์—์„œ 1๊ฐœ์˜ ์ฟผ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•˜๊ณ , ์•„๋ž˜์—์„œ 100๊ฐœ์˜ ์ฟผ๋ฆฌ๊ฐ€ ๋˜ ๋ฐœ์ƒํ•˜๊ฒŒ ๋˜๋Š” ๊ฒƒ์ด๋‹ค.

todos.map(todo -> new TodoResponse(
        todo.getId(),
        todo.getTitle(),
        todo.getContents(),
        todo.getWeather(),
        new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()), // N+1 ๋ฌธ์ œ ๋ฐœ์ƒ ๊ฐ€๋Šฅ
        todo.getCreatedAt(),
        todo.getModifiedAt()
));

Repository์—์„œ๋Š” ์ด๋ฏธ ๋‹ค์Œ๊ณผ ๊ฐ™์ด fetch join์„ ์‚ฌ์šฉํ•ด N+1 ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ–ˆ๋‹ค.

@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

์—ฌ๊ธฐ์„œ @EntityGraph๋ผ๋Š” ์–ด๋…ธํ…Œ์ด์…˜์„ ํ™œ์šฉํ•ด ํ•ด๊ฒฐํ•ด๋ณด์ž. @EntityGraph(attributePaths = {"user"})์™€ ๊ฐ™์ด ์ ์–ด์ฃผ๋ฉด User๋„ ํ•จ๊ป˜ ์กฐํšŒํ•˜๋„๋ก ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

@EntityGraph(attributePaths = {"user"})
Page<Todo> findAllByOrderByModifiedAtDesc(Pageable pageable);

ํŒ€์›๋ถ„๊ป˜์„œ N+1 ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜์ง€ ์•Š์•˜์„ ๋•Œ์˜ ๊ฒฐ๊ณผ์™€ ํ•ด๊ฒฐํ–ˆ์„ ๋•Œ์˜ ๊ฒฐ๊ณผ๋ฅผ ์ง์ ‘ ๋ณด์—ฌ์ฃผ์…จ๋‹ค. ๊ทธ๋ž˜์„œ ๋‚˜๋„ ๋”ฐ๋ผ์„œ ํ…Œ์ŠคํŠธ ํ•ด๋ณด๋‹ˆ ํ›จ์”ฌ ์ดํ•ด๊ฐ€ ์ž˜๋๋‹ค. ๋ฏธ๋ฆฌ ์œ ์ € 2๋ช…์„ ์ƒ์„ฑํ•˜๊ณ , ์ผ์ •๋„ ๊ฐ ์œ ์ €๋งˆ๋‹ค 1๊ฐœ์”ฉ ๋งŒ๋“ค์–ด ํ…Œ์ŠคํŠธ ํ–ˆ๋”๋‹ˆ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™”๋‹ค.

@EntityGraph ์ ์šฉ ์ „@EntityGraph ์ ์šฉ ํ›„

N+1 ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์ „์—๋Š” ์ผ์ •์„ ์กฐํšŒํ•˜๊ณ , ๊ฐ ์œ ์ €๋งˆ๋‹ค ํ•œ ๋ฒˆ์”ฉ ๋” ์กฐํšŒํ•˜๊ณ  ์žˆ๋‹ค. ์œ ์ €์˜ ์ˆ˜(2๋ช…)๋งŒํผ ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋œ ๊ฒƒ์ด๋‹ค. N+1 ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•œ ํ›„์—๋Š” ์œ ์ €๋ฅผ ์ถ”๊ฐ€๋กœ ์กฐํšŒํ•˜๋Š” ๊ฒƒ ์—†์ด ์ฟผ๋ฆฌ๊ฐ€ ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰๋˜๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

0๊ฐœ์˜ ๋Œ“๊ธ€

๊ด€๋ จ ์ฑ„์šฉ ์ •๋ณด