Java Spring을 사용하면서 드는 가장 큰 고민은 역시나 어떻게 하면 잘 쓸 수 있을까 이다. 이번 프로젝트는 규모가 크지는 않더라도 제대로 끝내보자고 생각했다! 구현 도중 Entity간 연관관계는 어떻게 줄지, 쿼리는 어떻게 작성할지(어디에서 어떻게 조회할지), 데이터가 없는 경우 Null 처리는 어떻게 할지, 코드는 어떻게 보기 쉽게 작성할지,, 등등 고민을 많이 하게 되었고 다음 프로젝트에서는 이 고민들을 더욱더 반영해보고 싶다
접근제어자, 생성자 사용
클래스와 멤버의 접근 권한을 최소화하자 는 것이 접근제어자의 핵심이다. 접근제어자를 공부하면서 생성자와 static, final 등의 변수도 함께 공부하는 것이 도움이 많이 되었다.
접근수준
- private : 멤버를 선언한 top level 클래스에서만 접근 가능하다.
- package-private : 멤버가 소속된 패키지 안의 모든 클래스에서 접근 가능하다. -> 현업에선 잘 쓰이지 않는다.
- protected : 이 멤버를 선언한 클래스 하위 클래스에서도 접근 가능하다.
- public : 모든 곳에서 접근 가능하다.
생성자 관련 어노테이션
- @NoArgsConstructor 파라미터가 없는 기본 생성자 만듦
- @AllArgsConstructor 모든 필드 값을 파라미터로 받는 생성자 만듦
- @RequiredArgsConstructor final이나 @NonNull인 필드 값만 파라미터로 받는 생성자 만듦
✨ 그럼 내가 아래 코드에서 왜 protected/private/public을 썼는지 알아보자.
@Getter @Setter
@Entity @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class History {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int solvedBaekjoonWeek;
private int writtenTistoryWeek;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name="student_id")
private Student student;
@ManyToOne
@JoinColumn(name="study_id")
private Study study;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name="week_id")
private Week week;
public History(Student student, Study study, Week week){
this.student = student;
this.study = study;
this.week = week;
this.solvedBaekjoonWeek = 0;
this.writtenTistoryWeek = 0;
}
public void addTistoryWeek(int writtenTistoryWeek ){
this.writtenTistoryWeek+=writtenTistoryWeek;
}
}
@NoArgsConstructor로 작성한다면 외부에서 언제든지 new History()와 같이 객체를 생성할 수 있다. 즉 파라미터 없는 생성자를 무분별하게 만드는 이슈를 줄이기 위해서 접근 권한을 protected로 주었다. 객체의 필수적인 초기화 작업이 아래 생성자로 존재하기 때문에 생성자를 통해 아래에서 지정하지 않은 필드로 생성하는 방식 차단하고 싶었다.
이 때 왜 굳이 protected로 해? private 같은 걸로 아예 막아버리면 안되나 하는 생각이 들텐데 접근 권한을 Private로 하면 프록시 객체 생성에 문제가 생기게 된다. 관련 내용은 인터넷에 잘 나와있다.
아니 그럼 아예 쓰지를 않으면 안돼? -> 안돼요!!!!!
@Entity,@Embeddable 어노테이션을 사용하면 이 클래스는 테이블과 매핑할 클래스라는 것을 명시해주기 위해 기본 생성자(no-arg constructor)가 필수적이다.
static vs final
- static - '고정된', 즉 메모리에 딱 한 번만 할당된다. 객체마다 생성될 필요가 없다.
- final - '최종적인', 즉 수정 불가능하다. 오직 한 번만 할당할 수 있다.
🤔 그렇다면 private vs private final 은 언제 쓰는 것이 좋을까?
객체 생성 이후에 변경될 필요가 없는 경우, 즉 필드의 속성이 변경되지 않아야 하는 경우에는 private final을 사용하여 불변성을 확보하고, setter 등으로 필드의 속성이 변경될 수 있다면 private 만을 사용하자.
private, static, final 에 대해 이해했다면 아래 차이를 이해할 수 있을 것이다.
private static final을 선언한 변수를 사용하면 재할당하지 못하며, 메모리에 한 번 올라가면 같은 값을 클래스 내부의 전체 필드, 메서드에서 공유한다.
private final을 선언한 변수를 사용하면 재할당하지 못하며, 해당 필드, 메서드 별로 호출할 때마다 새로이 값이 할당(인스턴스화)한다.
private 을 선언한 변수를 사용하면 재할당 가능하며, 해당 필드, 메서드 별로 호출할 때마다 새로이 값이 할당(인스턴스화)한다.
✨ 내가 설계한 아래 DTO에 대해 살펴보자. 학생이 푼 백준 리스트를 주르륵 가져오는 코드이다. 해당 리스트를 잘못해서 service 로직 등에서 다른 협업 개발자가 또는 내가 삭제하거나 수정하면 안된다 !! 따라서 오직 한 번만 할당되도록 final 로 지정해주었다.
Transactional
Transactional 관련 공부 및 구현 내용은 이전 글에서도 잘 알아봤었다. SELECT 쿼리를 주로 가져오는 서비스로직 에는 @Transactional에 readOnly 속성을 추가하는 것이 좋다. ( 읽기 전용 쿼리의 성능 최적화 )
이 때, 'get'으로 시작하는 함수명에 낚여(?) 잘못 속성을 추가했다가 낭패를 봤다.. 정말 다시 한 번 느낀 클린코드와 기능분리의 중요성이다,,, 부끄럽다 그래도 빨리 찾아 해결할 수 있었다.
DTO 작성 시 직렬화/역직렬화
위의 Transactional 함수와 비슷한 이슈로 에러를 맞이한 경험이 있다.. 아래 DTO는 명칭은 Res로 끝나는데 Lambda 입장에서 Res지만 java 서버로 들어오는 Request 로 설계되어 혼동이 왔다. 하..부끄럽다 언릉 고쳐야지 클린 코드가 아니라도 주석이라도 달았어야 한다고 생각한다.
@Data
@Getter
//@AllArgsConstructor
//@NoArgsConstructor -> 생성자 코드 중 하나가 있어야 한다 !!
public class CrawlingRes {
private Long studyId;
private Long studentId;
private Long WeekId;
private final List<Integer> solvedBaekjoon;
}
이 오류를 자세히 알아보자. DTO 운반 시 @RequestBody와 @ResponseBody를 사용한다. 위의 dto는 (이름은 res로 끝나지만) request이므로 역직렬화(Json->Java) 과정이 필요하다. 이런 ObjectMapper (@RequestBody, @ResponseBody) 를 통해 역직렬화를 할 때에는 기본 생성자(@NoArgsConstructor)또는 전체인자 생성자(@AllArgsConstructor)가 반드시 필요하다. ObjectMapper가 DTO를 생성할 때 기본 생성자를 사용하기 때문인데, Reflection을 사용해 기본 생성자를 찾아 객체를 생성한 후, public 범위의 Getter, 모든 범위의 Setter, 그리고 public 범위의 Field를 찾아 데이터를 바인딩 한다는 특징이 있다 !
직렬화와 역직렬화에 대한 개념은 한 번 짚고 가는 것은 좋다 ! 하지만 굳이 깊이 팔 필요는 없는 것 같다. DTO는 목적 자체가 어떤 로직이 있다기 보다는, 단순히 데이터를 전달하는 것이기 때문에 Getter, Setter, @AllArgsConstructor @NoArgsConstructor를 자유롭게 할당해서 사용해도 문제는 없다고 한다.
✨ 따라서 나는 아래와 같이 이용했다.
기본적으로 @AllArgsConstructor, @NoArgsConstructor를 모두 할당해주고, final 변수가 있는 DTO에서는 @AllArgsConstructor 로 가져온다. (모든 필드의 값은 전부 가져온다고 가정하였다.)
@Data
@Getter
@AllArgsConstructor //final 변수로 인해 @NoArgsConstructor를 사용할 수 없다.
public class CrawlingRes {
private Long studyId;
private Long studentId;
private Long WeekId;
private final List<Integer> solvedBaekjoon;
}
@Data @Getter
@AllArgsConstructor @NoArgsConstructor
public class CrawlingReq {
private Long studyId;
private Long studentId;
private String studentName;
private String studentBaekjoonName;
private Long WeekId;
}
@ToString
추가로 MVC 패턴을 이용해 JSP를 사용하는 과정에서 @Data 어노테이션을 활용하였다. @Data 어노테이션 속 @ToString을 이용하여 객체가 가지고 있는 정보나 값들을 문자열로 만들어 리턴할 수 있었다. DTO객체를 @ToString 값 없이 들고오면 아래와 같은 형식으로 출력된다. ( 해당 객체의 클래스 이름과 객체의 해시 코드)
SemesterRes@2c7b84de
아래는 나의 JSP 화면단을 책임지는 ViewController 이다. /list 경로로 들어오면 SemesterRes 리스트를 가져오게 되어있는데 이 때 이 값들을 화면단에 표현하려면 ToString 이 반드시 필요하다.
참고로 @Data 어노테이션은 아래 어노테이션의 묶음집(?)이다. 주의해서 사용할 필요는 있다.
@Data = @NoArgsConstructor + @Getter + @Setter + @ToString + @EqualsAndHashCode
제네릭을 활용한 ResponseTemplate 생성
제네릭이란 클래스 내부에서 지정하는 것이 아닌 외부에서 사용자에 의해 지정되는 것을 의미한다. ResponseTemplate을 만들 때 제네릭을 활용하면 매우 간편하게 코드를 짤 수 있다. api 호출 결과(success, error) 에 따른 response body 구조를 다르게 가져가기 위해서 두 가지 ResponseTemplate 클래스를 추가하여 오버로딩하였다.
1. 클래스 및 인터페이스 선언 방식
타입 파라미터 T 를 이용해 data를 가져온다. T에 어떤 타입을 전달하느냐에 따라서 해당 클래스의 인스턴스가 그 타입의 데이터를 담을 수 있게 된다. T는 String이 될 수도, StudentRes 와 같은 DTO 형식이 될 수도 있다.
@Getter
@AllArgsConstructor
@JsonPropertyOrder({"isSuccess", "code", "message", "data"})
public class ResponseTemplate<T> {
@JsonProperty("isSuccess")
private final Boolean isSuccess;
private final String message; //메시지 전달
private final int code; //내부 코드
@JsonInclude(JsonInclude.Include.NON_NULL)
private T data;
//요청 성공 시
public ResponseTemplate(T data) {
this.isSuccess = SUCCESS.isSuccess();
this.message = SUCCESS.getMessage();
this.code = SUCCESS.getCode();
this.data = data;
}
//요청 실패시
public ResponseTemplate(ResponseTemplateStatus status) {
this.isSuccess = status.isSuccess();
this.message = status.getMessage();
this.code = status.getCode();
}
}
만약 요청에 성공한다면 @JsonProperty 를 이용해 JSON으로 직렬화될 때 isSuccess: true(또는 false)라는 JSON 값이 추가된다. data에 아무 것도 담기지 않는 경우를 대비해 data 값이 null이면 해당 필드를 생략하는 @JsonInclude 옵션을 추가해 사용하였다.
2. 제네릭 클래스 활용 방식
아래와 같이 ResponseTemplate에 내가 원하는 형식을 넣으면 알아서 그 타입으로 변경되어 JSON 형태로 보여지게 된다.
@ResponseBody
@GetMapping("/study")
@Operation(summary = "스터디 참가 조정 페이지에서 스터디를 가져오는 API")
public ResponseTemplate<List<StudyRes>> getStudyList(@RequestParam("semesterId") Long semesterId){
try{
List<StudyRes> studyList = studyService.getStudyList(semesterId);
return new ResponseTemplate<List<StudyRes>>(studyList);
} catch (ResponseException e){
return new ResponseTemplate<>((e.getStatus()));
}
}
Entity 설계
연관관계 설정
연관관계를 설정하는 과정에서 이번에 1, 2번에 해당하는 설계를 잘 해볼 수 있었다. 추가로 다음번에는 3, 4번에 해당하는 것들을 잘 생각해서 설계해보면 좋을 것 같다.
- 단방향 매핑 vs 양방향 매핑, 1:1, 1:N, N:M 매핑
- 연관관계의 주인은 누구로 할 것인가?
- 도메인 제약조건에 위배되는 데이터가 들어오는 가능성에 대해 생각해보았는가?
- 고유성을 보장하지 않을 가능성을 생각해보았는가? (나는 유일성을 만족했다고 생각했는데, select 문 과정에서 여러 개의 데이터들을 가져올 수 있을 것이다.)
NULL 처리
기존에는 isPresent, isEmpty 코드를 남발했다면, orElseGet / orElse 메서드를 사용해 null값 체크와 null값일 경우 어떤 값으로 대체할 것인지를 한번에 처리하는 로직으로 바꾸었다. 기준은 간단히 아래와 같다.
1. isPresent()-get() 대신 orElse() / orElseGet() / orElseThrow()
2. orElse(new ...) 대신 orElseGet(() -> new ...)
orElseGet(Supplier)에서 Supplier는 Optional에 값이 없을 때만 실행된다.
3. 컬렉션일 때는 Optional 사용 X
아래 코드와 같이 null 이 발생할 경우 그대로 ResponseException으로 예외처리하도록 구현하여 불필요한 Optional 을 없앤다.
@Transactional
public void removeStudyFromStudyList(List<Long> studentIdList, Long studyId) throws ResponseException {
for (Long studentId : studentIdList) {
Student student = studentRepository.findById(studentId)
.orElseThrow(() -> new ResponseException(STUDENT_NOT_FOUND));
List<Student_Study> studentStudies = studentStudyRepository.findStudentStudiesByStudentId(student.getId());
for (Student_Study studentStudy : studentStudies) {
studentStudyRepository.delete(studentStudy);
}
}
}
추가로, NULL 에러가 빈번하게 발생하는 코드가 있었다.
DB 상에서 전 주 데이터를 조회했을 때 -> 데이터가 있을 경우 전주 대비 이번주에 푼 문제 갯수를 조회하는 데이터에서 Null 오류가 빈번하게 발생하였고, 이 코드를 깔끔하게 처리하고자 하였다.
✨기존 코드이다. 진짜 너무 보기 싫.. 거의 의식의 흐름대로 짰다.
public List<HistoriesRes> getHistoryList(Long studyId) throws ResponseException {
List<HistoriesRes> historyResList = new ArrayList<>();
List<StudentRes> studentResList = studentService.getStudentListByStudyId(studyId);
List<Week> weekList = weekService.getWeekListByStudyId(studyId);
System.out.println("weekList"+weekList.toString());
for (StudentRes studentRes : studentResList) {
for (int weekNum = 0; weekNum < weekList.size(); weekNum++) {
Optional<History> history = getHistory(studyId, weekList.get(weekNum).getId(), studentRes.getId());
if (history.isPresent()) {
System.out.println(history.get().getWeek().getWeekNumber());
if (weekNum > 0) {
Optional<History> beforeHistory = getHistory(studyId, weekList.get(weekNum - 1).getId(), studentRes.getId());
//일단 개수로 판별하여 집어넣는다.
int beforeBaekjoonNum = 0;
int nowBaekjoonNum = 0;
if (beforeHistory.isPresent()) {
Optional<BaekjoonHistory> beforeBaekjoonHistory = baekjoonHistoryRepository.findById(beforeHistory.get().getId());
Optional<BaekjoonHistory> nowBaekjoonHistory = baekjoonHistoryRepository.findById(history.get().getId());
if (beforeBaekjoonHistory.isPresent()){
beforeBaekjoonNum = beforeBaekjoonHistory.get().getSolvedBaekjoon().size();
System.out.println("beforeBaekjoonNum" + beforeBaekjoonNum);
System.out.println(beforeBaekjoonHistory.get().getId());
}
if (nowBaekjoonHistory.isPresent()){
nowBaekjoonNum = nowBaekjoonHistory.get().getSolvedBaekjoon().size();
System.out.println("nowBaekjoonNum" + nowBaekjoonNum);
System.out.println(nowBaekjoonHistory.get().getId());
}
history.get().setSolvedBaekjoonWeek(nowBaekjoonNum - beforeBaekjoonNum);
}
}
historyRepository.save(history.get());
HistoriesRes historyListRes = new HistoriesRes(studentRes.getName(), weekList.get(weekNum).getWeekNumber(), max(0, history.get().getSolvedBaekjoonWeek()), history.get().getWrittenTistoryWeek());
historyResList.add(historyListRes);
}
}
}
return historyResList;
}
Optional을 잘 사용하는 방법 과 관련한 글을 참고하였다. Optional을 어떻게 쓰면 좋다는 가이드라인이 아주 잘 나와있다.
public List<HistoriesRes> getHistoryList(Long studyId) throws ResponseException {
List<HistoriesRes> historyResList = new ArrayList<>();
List<StudentRes> studentResList = studentService.getStudentListByStudyId(studyId);
List<Week> weekList = weekService.getWeekListByStudyId(studyId);
System.out.println("weekList" + weekList.toString());
for (StudentRes studentRes : studentResList) {
for (int weekNum = 0; weekNum < weekList.size(); weekNum++) {
int currentWeekNum = weekNum;
getHistory(studyId, weekList.get(weekNum).getId(), studentRes.getId())
.ifPresent(history -> {
int solvedBaekjoonWeekDiff = compareWithPreviousHistory(studyId, history, currentWeekNum, studentRes.getId(), weekList);
history.setSolvedBaekjoonWeek(solvedBaekjoonWeekDiff);
historyRepository.save(history);
HistoriesRes historyListRes = new HistoriesRes(studentRes.getName(), weekList.get(currentWeekNum).getWeekNumber(),
Math.max(0, solvedBaekjoonWeekDiff), history.getWrittenTistoryWeek());
historyResList.add(historyListRes);
});
}
}
return historyResList;
}
private int compareWithPreviousHistory(Long studyId, History history, int currentWeekNum, Long studentId, List<Week> weekList) {
if (currentWeekNum > 0) {
return getHistory(studyId, weekList.get(currentWeekNum - 1).getId(), studentId)
.map(beforeHistory -> {
int beforeBaekjoonNum = baekjoonHistoryRepository.findById(beforeHistory.getId())
.map(beforeBaekjoonHistory -> beforeBaekjoonHistory.getSolvedBaekjoon().size())
.orElse(0);
int nowBaekjoonNum = baekjoonHistoryRepository.findById(history.getId())
.map(nowBaekjoonHistory -> nowBaekjoonHistory.getSolvedBaekjoon().size())
.orElse(0);
return nowBaekjoonNum - beforeBaekjoonNum;
})
.orElse(0);
}
return 0;
}
.map 과 같은 stream 은 Java 8부터 지원되기 시작한 기능으로, 코드에 무분별하게 사용하는 것은 별로인 것 같고 가장 기초적이면서도 많이 쓰이는 Filter, Map, Sorted, collect 함수 정도는 필요에 따라 응용할 수 있도록 노력하고 있다.
implements vs extends 알고 쓰기 - JPARepository 상속
- implements는 주로 interface를 상속받으며, 부모 클래스(인터페이스)를 가져다 쓸 때 세부 구현 내용을 재정의해야 한다.
구현해야 해서 이름이 구현?!반면, extends는 부모의 메서드를 그대로 가져다 사용할 수 있다. class 끼리는 extends를 통한 다중 상속이 안된다.- extends는 일반 클래스와 abstract 클래스 상속에 사용되고, implement는 interface 상속에 사용된다.
- class가 class를 상속받을 땐 extends를 사용하고, interface가 interface를 상속 받을 땐 extends를 사용한다.
- 언제 쓰이는가?
둘 모두 상속에서 사용되는 용어이다. 상속이란, 자식클래스가 부모 클래스의 기능을 가져다 쓰는 것을 의미한다. - 그럼 아래를 살펴보자.
우리가 본능적으로 자주 구현하는 JpaRepository를 뜯어보면 아래와 같이 이미 여러 함수들이 만들어져 있다. 따라서 우리가 굳이 구현하지 않은 save() 함수 등도 척척! 만들어주는 것을 볼 수 있다. JpaRepository 자체는 여러 interface들을 extends로 다중상속하고 있다.
interface가 interface를 상속받을 땐 extends를 쓰고, interface는 다중상속이 가능하다 !
✨ 따라서 나는 JpaRepository 인터페이스를 확장시켜 기본 CRUD 처리는 굳이 작성하지 않고 가져다 사용하였고 (ex. weekRepository.save(week)) , 제공해주지 않는 다른 정적 쿼리들은 @Query 어노테이션으로 따로 직접 구현해주었다.
JPA를 사용해보면서 좀 더 생각해본 점은 우선, 쿼리를 작성해두지 않음으로 인한 개발자들 간 혼동이 있을 수 있다는 것이다. 쿼리를 모두 작성하는 쪽으로 구현해서 쓰이는 쿼리 정보들이 모두 보이는 것도 큰 장점이 있을 것이다.
그리고 위에서도 언급했지만 JPARepository는 기본적으로 findById 등에서 Optional<> 어노테이션이 붙어 return 하게 된다. 이럴 경우 ofNullable 등을 사용함에 있어서 제한이 있어 jpa 사용 시 이 점도 고려해볼 사항이다.
@Async를 활용해 응답시간 줄이기
Java Spring의 경우에는 기본적으로 Sync Blocking 형태로 구성되어 있어 MVC 패턴인 내 프로젝트의 경우
Controller -> Service -> Repository -> DB -> return -> return -> return 하는 과정을 모두 거쳐서 응답이 전부 처리될 때까지 한없이 기다려야 한다.
나는 Nonblocking 형태로 일단 응답을 던지고 받는 순서로 구현하고 싶었다.
일단 프로젝트 상위에 @EnableSync로 @Async 어노테이션을 사용할 수 있도록 설정해준다.
이후 내가 원하는 Controller에서 불러오는 saveHistoryList라는 Service 단 로직에 @Async 어노테이션을 붙여주었다.
이렇게 10s에서 0.1초 대로 api/v1/weekdiff 경로의 api 호출 시간을 단축시킬 수 있었다 !
중간중간 설명이 많이 생략되긴 했는데 결론적으로는 여러 자잘자잘한 고민들부터 큰 고민들까지 변경 가능성이 있는 프로젝트에서 역시 내가 할 수 있는 최선은 설계를 제대로 하는 것이라고 생각한다. DB 설계든 코드 설계이든 좀 더 좋은 방향성으로 나아갈 수 있도록 의식적으로 노력해야 겠다 !!
'BackEnd > JAVA\SPRING' 카테고리의 다른 글
[Java/Spring] 템플릿 콜백 패턴으로 JDBC 템플릿 구현해보기 (0) | 2024.04.02 |
---|---|
[SpringSecurity] Koala 프로젝트 간단한 인증처리 (0) | 2024.02.13 |
[WAS/WS|Docker] Springboot war 파일 외장 tomcat 배포 (2) | 2024.01.10 |
[SPRING] 서비스 추상화 (0) | 2023.10.19 |
[SPRING] AOP (0) | 2023.10.16 |