๐ ํ๋ก์ ํธ ๊ฐ์
“๋ชจ์ฌ๋ผ PAW PAW” ๋ ๋ฐ๋ ค๋๋ฌผ ์๋ ์ปค๋ฎค๋ํฐ์ ๋๋ค.
ํ์ ๊ฐ์
ํ ์ด์ฉํ ์ ์์ผ๋ฉฐ, ๋ฐ๋ ค๋๋ฌผ์ ์ฌ์ง๊ณผ ํ๋กํ์ ๋ฑ๋กํด ๋ค๋ฅธ ์ฌ๋๊ณผ ๊ณต์ ํ ์ ์์ต๋๋ค.
์ข์ํ๋ ๋จ์ด, ์ซ์ดํ๋ ๋จ์ด, ํ์ค ์๊ฐ ๋ฑ์ ํตํด ์ฌ์ฉ์์ ๋ฐ๋ ค๋๋ฌผ ๊ฐ์ ๊ต๊ฐ์ ํํํฉ๋๋ค. ์ฌ์ฉ์๋ ๋ฑ๋ก๋ ๋ฐ๋ ค๋๋ฌผ ํ๋กํ์ ๋ณด๋ฉฐ ์ข์์๋ฅผ ๋จ๊ธธ ์ ์์ผ๋ฉฐ, ์ด๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ธ๊ธฐ ๋ญํน์ด ๊ตฌ์ฑ๋ฉ๋๋ค.
๋ฐ๋ ค๋๋ฌผ ์ด๋ฏธ์ง ๋ฑ๋ก ์ AI๋ฅผ ํตํ ๋ฐ๋ ค๋๋ฌผ ํ๋ณ ๊ธฐ๋ฅ์ ์ ์ฉํ์ฌ, ๋ฑ๋ก๋ ์ด๋ฏธ์ง๊ฐ ์ค์ ๋ฐ๋ ค๋๋ฌผ์ธ์ง ์ฌ์ ์ ๊ฒ์ฆํ ์ ์๋๋ก ํ์ต๋๋ค.
Github Repo : https://github.com/HAN-SEOHYUN/wedlessInvite/issues
โ ๋ชฉํ
- ์ํคํ ์ฒ ๋ถ๋ฆฌ, ์์ธ ์ฒ๋ฆฌ ๋ฑ์ ๊ตฌ์กฐ์ ์ธ ์ค๊ณ
- ์ค์ ๋ถํ ํ ์คํธ ๊ฒฝํ
- ๊ณต๋ถ์ค์ธ ๋์์ธ ํจํด ์ ์ฉ
- Docker, AWS ๊ธฐ์ ํ์ต ๋ฐ ์ ์ฉ
๐ ์ฌ์ฉ ๊ธฐ์ ์คํ
Spring Boot ์๋ฒ์ Thymeleaf ํ ํ๋ฆฟ ์์ง์ผ๋ก ๊ตฌ์ฑ๋ ๋ชจ๋๋ฆฌํฑ ์ํคํ ์ฒ ๊ธฐ๋ฐ์ ์น ์ ํ๋ฆฌ์ผ์ด์ ์ ๋๋ค.
Spring MVC ์ํคํ ์ฒ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ปจํธ๋กค๋ฌ, ์๋น์ค, ๋ ํฌ์งํ ๋ฆฌ ๊ณ์ธต์ ๋ช ํํ๊ฒ ๋ถ๋ฆฌํ๊ณ , Model-View-Controller ํจํด์ ๋ฐ๋ผ ํด๋ผ์ด์ธํธ ์์ฒญ์ ์ฒ๋ฆฌํฉ๋๋ค.
- Database: MySQL 8.0
- Infra: AWS EC2, S3, Docker
- Language: Java 23
- Framework: Spring Boot
- ORM: JPA (Hibernate)
- Database: MySQL
- Template Engine: Thymeleaf
- View Layer: HTML + CSS + JS
- AI Service: PyTorch + Flask (ResNet ๊ธฐ๋ฐ ์ด๋ฏธ์ง ํ๋ณ)
- ๊ธฐํ: Intellij, Github
- TEST : k6, Postman
K6 ๋ถํ ํ ์คํธ ์ํคํ ์ณ
๐ ์ฃผ์ ์ค๊ณ ํฌ์ธํธ
1. ๋ฐ๋ ค๋๋ฌผ ์ด๋ฏธ์ง ํ๋ณ AI ๊ธฐ๋ฅ
ImageController๋ ์ด๋ฏธ์ง๋ฅผ MultipartFile๋ก ๋ฐ์์ Flask ๊ธฐ๋ฐ AI ์๋ฒ๋ก ์ ์กํ๊ณ , ํด๋น ์ด๋ฏธ์ง๊ฐ ๊ฐ์์ง/๊ณ ์์ด์ธ์ง ํ๋ณํฉ๋๋ค.
Flask ์๋ฒ๋ PyTorch์ ResNet-18 ๋ชจ๋ธ์ ํ์ฉํ๋ฉฐ, ์๋ฒ์์ ๋ถ๋ฅ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ์, ์ ํจํ ๋ฐ๋ ค๋๋ฌผ์ธ์ง ์ฒดํฌ ํ ์ ์ฅํฉ๋๋ค.
2. ์ผ๊ด๋ ์์ธ ์ฒ๋ฆฌ (Exception Handling)
๋ฐ๋ณต์ ์ผ๋ก ์ฌ์ฉ๋๋ ์์ธ ์ํฉ์ ๊ฐ๊ฒฐํ๊ณ ๋ช ํํ๊ฒ ์ฒ๋ฆฌํ๊ธฐ ์ํด CustomException๊ณผ ErrorCode๋ฅผ ์ค๊ณํ์ต๋๋ค.
@Getter
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.getErrorMsg());
this.errorCode = errorCode;
}
}
์๋์ ๊ฐ์ด ์ฌ์ฉ์์ ์ ์ง๊ด์ ์ด๊ณ ๊ฐ๋จํ๊ฒ ์์ธ์ฒ๋ฆฌ๋ฅผ ํ ์ ์์ต๋๋ค.
public void validateFileSize(MultipartFile file) {
if (file.getSize() > MAX_FILE_SIZE) {
throw new CustomException(EXCEED_MAX_FILE_SIZE);
}
}
3. DTO ๊ธฐ๋ฐ ๋ฐ์ดํฐ ์ฒ๋ฆฌ
๋ชจ๋ ๋๋ฉ์ธ์์ ์์ฒญ๊ณผ ์๋ต ๋ฐ์ดํฐ๋ฅผ DTO(Data Transfer Object)๋ก ๋ถ๋ฆฌํด ์ฒ๋ฆฌํ์ต๋๋ค.
๋ํ DTO ํด๋์ค๋ Lombok์ @Builder ํจํด์ ์ ์ฉํด ๋ถ๋ณ์ฑ์ ์ ์งํ๋ฉด์๋ ๊ฐ๋ ์ฑ์ ๋์์ต๋๋ค.
์๋ฅผ ๋ค์ด ์ด๋ฏธ์ง ๋ชฉ๋ก ์๋ต์ ์ํ DTO๋ ๋ค์๊ณผ ๊ฐ์ด ๊ตฌ์ฑ๋ฉ๋๋ค:
@Getter
@Setter
public class ImageListResponseDto {
private String orgFileName;
private String s3Url;
private Long fileSize;
private LocalDateTime modTime;
@Builder
public ImageListResponseDto(String orgFileName, String s3Url, Long fileSize, LocalDateTime modTime) {
this.orgFileName = orgFileName;
this.s3Url = s3Url;
this.fileSize = fileSize;
this.modTime = modTime;
}
}
4. Response<T>๋ก ์๋ต ์ผ๊ด์ฑ ํ๋ณด
ํด๋ผ์ด์ธํธ์์ ํต์ ์์ ์ผ๊ด๋ ์๋ต ํฌ๋งท์ ์ ์งํ๊ธฐ ์ํด AbstractResponse, SuccessResponse<T> ๊ตฌ์กฐ๋ฅผ ์ค๊ณํ์ต๋๋ค.
์๋ต์ ๋ค์๊ณผ ๊ฐ์ JSON ํํ๋ก ์ ๊ณต๋๋ฉฐ, ์ฑ๊ณต๊ณผ ์คํจ ์ฌ๋ถ์ ๊ด๊ณ์์ด ๋์ผํ ๊ตฌ์กฐ๋ฅผ ์ ์งํฉ๋๋ค.
{
"success": true,
"statusCode": "OK",
"message": "์์ฒญ์ด ์ ์์ ์ผ๋ก ์ฒ๋ฆฌ๋์์ต๋๋ค.",
"data": { ... }
}
๊ณตํต ๋ถ๋ชจ ํด๋์ค์ธ AbstractResponse๋ ์ฑ๊ณต ์ฌ๋ถ, ์ํ ์ฝ๋, ๋ฉ์์ง๋ฅผ ์ ์ํ๊ณ ์์ผ๋ฉฐ, ์ด๋ฅผ ์์ํ๋ SuccessResponse<T>์ FailureResponse๋ ๊ฐ๊ฐ ์ฑ๊ณต๊ณผ ์คํจ์ ๋ฐ๋ฅธ ๋ฐ์ดํฐ๋ฅผ ํฌํจํฉ๋๋ค. SuccessResponse<T>๋ ์ ๋ค๋ฆญ ํ์ T๋ฅผ ํตํด ๋ค์ํ ํํ์ ๋ฐ์ดํฐ๋ฅผ ์ ์ฐํ๊ฒ ๋ด์ ์ ์๋๋ก ์ค๊ณ๋์ด ์์ต๋๋ค.
public class SuccessResponse<T> extends AbstractResponse {
private final T data;
public SuccessResponse(HttpStatus statusCode, String message, T data) {
super(statusCode, message, true); // success ๊ฐ์ ํญ์ true
this.data = data;
}
public T getData() {
return data;
}
}
์๋ฅผ ๋ค์ด, ์ข์์ ๋ญํน ๋ฐ์ดํฐ๋ฅผ ๋ฐํํ๋ API๋ ๋ค์๊ณผ ๊ฐ์ด ์์ฑ๋ฉ๋๋ค.
@GetMapping("/rank")
public ResponseEntity<SuccessResponse<List<PetLikeRankingResponseDto>>> getPetLike() {
List<PetLikeRankingResponseDto> dto = likeService.getTop3MostLikedInvitations();
SuccessResponse<List<PetLikeRankingResponseDto>> successResponse = new SuccessResponse<>(
HttpStatus.OK,
LIKE_LIST_FETCH_SUCCESS_MESSAGE,
dto
);
return ResponseEntity.status(HttpStatus.OK).body(successResponse);
}
5. ๋ก๊ทธ์ธ ๊ฒ์ฌ ์ธํฐ์ ํฐ (LoginCheckInterceptor)
๋นํ์์ ์ ๊ทผ์ ์ ํํ๊ธฐ ์ํด LoginCheckInterceptor๋ฅผ ๊ตฌํํ์ต๋๋ค. ์ธ์ฆ์ด ํ์ํ ์์ฒญ์ ๋ํด ์ธ์ ์ ์ฌ์ฉ์ ์ ๋ณด๊ฐ ์กด์ฌํ์ง ์์ผ๋ฉด ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ๋๋ฉฐ, ํน์ ๊ณต๊ฐ URI๋ ์์ธ๋ก ์ฒ๋ฆฌํ ์ ์๋๋ก ์ค๊ณํ์ต๋๋ค. ๋ก๊ทธ์ธ ํํฐ๋ WebConfig ์ค์ ์ ํตํด ์ปจํธ๋กค๋ฌ ๊ฒฝ๋ก์ ์ ์ฉ๋๋ฉฐ, ๋ก๊ทธ์ธ ์ํ๊ฐ ์๋ ๊ฒฝ์ฐ์๋ ์๋์ผ๋ก /login ๊ฒฝ๋ก๋ก ์ด๋์์ผ ์ฌ์ฉ์ ์ธ์ฆ์ ์ ๋ํฉ๋๋ค. ํน์ API๋ ์์ธ ๊ฒฝ๋ก๋ก ๋ฑ๋กํ ์ ์์ต๋๋ค.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("userMaster") == null) {
response.sendRedirect("/login");
return false;
}
return true;
}
โ ๋ก๊ทธ ์ถ์ ํ ํ๋ฆฟ (AbstractLogTraceTemplate)
๊ฐ ์์ฒญ๋ณ ์คํ ํ๋ฆ์ ์ถ์ ํ๊ณ , ์์ธ ๋ฐ์ ์ ์์ธ์ ์ ํํ ํ์ ํ ์ ์๋๋ก ํ๋ LogTrace ๊ธฐ๋ฅ์ ๊ฐ๋ฐํ์ต๋๋ค.
๊ณตํต์ ์ธ ๋ถ๊ฐ๊ธฐ๋ฅ์ ํ ํด๋์ค์ ๋ชจ์ ์ ์๋๋ก ํ ํ๋ฆฟ ๋ฉ์๋ ํจํด์ ์ฌ์ฉํ์ต๋๋ค.
์์ธํ ๋ด์ฉ์ ๋งํฌ์์ ํ์ธํ ์ ์์ต๋๋ค. ๊ฐ ์์ฒญ๋ณ ์คํํ๋ฆ์ ๋ํ Log ๋ ์๋์ ๊ฐ์ต๋๋ค.
[71701732] OrderController.createInvitation()
[71701732] |-->InvitationService.saveInvitationMaster
[71701732] |<--InvitationService.saveInvitationMaster time=34ms
[71701732] OrderController.createInvitation() time=40ms
๐ ์งํํ๋ฉฐ ๋ง์ฃผํ ์ด์๋ ํ์ต ๊ฒฝํ์ ์ ๋ฆฌํ ๊ธ๋ค์ ์๋์ ๊ณต์ ํฉ๋๋ค
- ๋์ปค ์ด๋ฏธ์ง ์ํคํ ์ณ ์ค๋ฅ
- ๋ฐฐํฌ ์ ๋ถํํ ์คํธ๋ก ์ฑ๋ฅ ๋ณ๋ชฉ ํด๊ฒฐ - TPS 2๋ฐฐ ํฅ์
- ํ ํ๋ฆฟ๋ฉ์๋ ๋์์ธ ํจํด์ ํ์ฉํ LogTrace ๊ฐ๋ฐ
- EC2 ์๋ฒ ๋ฉ๋ชจ๋ฆฌ ๋ถ์กฑ ์ด์