스프링 프로젝트 중 공통적인 처리에 대한 방법 및 코드 개선에 대한 내용에 대한 정리입니다.

알림

해당 글은 Spring으로 사내 프로젝트를 하면서 느낀 코드 개선 및 경험에 대한 내용입니다.
여러 개발 블로그의 경험과 노하우가 담긴 글을 참조하며, 추가한 내용입니다.
글의 내용이 정답이 아닐 수 있으며, 개인적인 생각으로 가볍게 읽어주세요. :D..

프로젝트 코드 공통 처리 및 여러 Tips

참조링크 : Spring Guide - Directory

최근 프로젝트의 구조는 아래와 같습니다.

Alt text

위와 같이 패키지를 domain과 global로 나누어 환경을 구축했습니다.

domain에서는 front의 화면과 유사하게 package를 설정했습니다.
하지만 프로젝트의 시나리오가 자주 변경되고 각 시스템에서 공통적인 로직들이 많아지면서 중복 코드가 발생하게 되면서 구조를 조금 변경했습니다.

backend의 경우 시스템 저장소가 7개 정도가 되다보니 아무래도 중복 코드가 많이 발생했습니다.

그래서 global은 모든 프로젝트(각각의 repository)를 동일하게 맞춘 후 multi module 또는 git submodule을 활용하여 관리하고 코드를 수정합니다.

기존 global에 있는 시스템마다 조금 씩 다른 Spring Security 설정 Swaager 설정은 global에서 domain package로 이동 시켰습니다.

또한, 하나의 Class 안에 시스템별 Inner Class가 있었던 부분이 있는데 코드를 유지관리하기가 힘들어서 전부 분리했습니다.

domain에서는 시스템 별 API 구조 포멧이 동일한 경우 front의 component개념과 동일하게 다른 backend 저장소에서도 해당 package를 복사하여도 import 경로 충돌이 없도록 구조를 맞추어 작업을 하게 되니 조금 더 공수가 줄어드는 효과가 있습니다.

Response Template에 대한 처리

참조링크: Response를 객체에 담아 반환하기
참조링크: Spring Guide - Exception 전략

Front에 제공하는 API의 통일성을 위하여 API 제공 시에 일관된 구조로 제공하기 위해 처음 의도는 정상적으로 API의 템플릿과 오류 및 유효성 처리에 대한 템플릿을 구분하여 처리합니다.

  • 정상 템플릿
1
2
3
"success": "",
"message": "",
"data": ""
  • 에러 템플릿
1
2
3
4
"success": "",
"message": "",
"error_code": "",
"error_list": ""

위와 같은 템플릿으로 backend에서는 정상인 경우 controller에서 return 해버리고 비정상적인 경우 @valid에서 죽거나, error를 throw합니다.

이렇게 구현하면 아래와 같이 controller에서 바로 로직을 처리할 수 있습니다.

1
2
3
4
@PostMapping
public SingleResult<TempManageResponse> getTempList(@Valid @RequestBody TempRequest request) {
    return responseService.getSingleResult(tempManageService.getTempList(request), TEMP_SELECT_SUCCESS);
}

에러의 경우 처리는 아래와 같습니다. 이후 ExceptionHandler로 빠진다음 여기서 error template 함수를 통해 에러 메시지를 반환합니다.

1
2
Temp temp = tempRepository.findBySnoAndUseYn(request.getTempSno(), SEARCH_USE_YN)
                .orElseThrow(() -> new EntityNotFoundException(TEMP_NOT_FOUND))

이렇게 하면 에러에 대한 처리를 핸들링 할 수 있으니 필요에 의해 맞출 수 있습니다.

entity에서 enum을 써야할 경우

참조링크: Legacy DB의 JPA Entity Mapping (Enum Converter 편)

enum 처리는 약간 고민이 드는 점이 있지만 enum의 경우도 정말 잘 사용한다면 여러 상태를 직관적이고 쉽게 처리하고 화면과 상태를 한번에 처리할 수 있기에 장점이 있습니다.

단점이라면 조금 학습을 해야하며, 종종 enum을 이용한 entity를 처리를 하면 다른 시스템에서 비슷한 시스템이지만 entity 정의가 달라서 코드 로직이 다른 경우입니다.

예를 들어 아래의 entity가 있다고 하면,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Convert(converter = AlarmConverter.class)
@Comment("100:알림-일반, 200:알림-정상, 300:알림-경고, 400:알림-심각")
@Column(name = "alarm_code")
private AlarmStatus alarm;

...

@Getter
public enum AlarmStatus implements EnumType {

    ALARM(100, "알림", 0),
    NORMAL(200, "정상", 0),
    WARNING(300, "경고", 1),
    FATAL(400, "심각", 2),
    ;

    private final int code;
    private final String level;
    private final int levelCode; 
    ...
}
1
2
3
@Comment("100:알림-일반, 200:알림-정상, 300:알림-경고, 400:알림-심각")
@Column(name = "alarm_code")
private int alarm;

이렇게 다른 시스템에서 정의한 entity가 다른 경우 관련된 로직도 달라집니다.

간단한 예로 해당 entity의 상태값을 조회할 때도 entity에 enum을 정의한 경우 아래와 같이

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@QueryProjection
public NotificationsData(Long notiSno, NotificationsStatus status, String equip, LocalDateTime dateTime, String value,
                            NotificationsConfirmStatus confirmationStatus) {
    this.notiSno = notiSno;
    this.level = status.getLevel();
    this.equip = equip;
    this.dateTime = dateTime;
    this.value = value;
    this.confirmationStatus = confirmationStatus.getDesc();
}

enum이 상태를 알기에 세부적으로 지정한 정보를 바로 가져올 수 있습니다.

entity의 단일 타입으로 설정하면 enum에서 정의하지 못한 값을 상수로 정의 후 조건문으로 확인하는 로직이 필요합니다.

하지만 이것도 정답이 있는 것이 아니라 프로젝트의 복잡도와 어떻게 하는게 더 쉽고 효율적인지에 따라 상황에 맞게 팀내에서 잘 조율해서 해야 된다고 생각합니다.

Dto 및 Optional은 잘 사용하기

관령링크: Optional에 대해

backend의 controller의 역할은 frontend에서 들어온 값의 유효성 처리와 service 로직에 필요한 값을 제공하거나, 반대로 frontend에 데이터를 제공하는 경우라 생각합니다.

여러 블로그를 보면 dto는 controller와 service까지만 관여하는게 좋다고도 하는데 QueryDSL을 이용하므로 dto는 repository 까지 들어가는 경우도 종종 있을 수 있다고 생각합니다.

이러한 부분은 유연하게 가면 될 것이라 생각됩니다.

관리할 수 없는 코드는 삭제

처음 Backend를 구성할 때는 swagger2를 적용하고 front 외주 개발자 분과 API에 대한 기능을 명세했었습니다.

swagger2에서는 실시간으로 backend api를 확인할 수 있지만, 그 때에 이슈 및 요청사항 관리를 어떻게 할지 고민중에
어떤 API가 변경됐는지를 swagger안에 html을 만들어서 명세 했던적이 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private ApiInfo apiInfo() {

    return new ApiInfoBuilder()
            .title("title")
            .version("1.01")
            .description("설명")
            .license("변경이력 ")
            .licenseUrl("/version-history.html")
            .build();
}

위와 같이 설정 후 static에 github style을 적용 후 html에 변경 이력을 관리했습니다.

문제는 회의 후 수정사항이 증가하면 위의 변경이력 또한 갱신해줘야 하는데 일을 하기 위해 일을 만드는 거라 느껴졌습니다.

지금은 Gitlab에서 CI/CD와 각 시스템마다 Issue를 처리하고 있지만, 이슈에 대해 이슈 트래킹 도구(Jira or Trello or Gitlab or Notion 등)를 미리 이용했다면 더 좋았을 거라 생각됩니다.

클래스 역할

코드는 각 클래스의 역할에 맞게 코드를 분리하면 좋다고 생각합니다.

예를 들어, 회원가입을 하는 경우 controller에서는 입력값에 대한 유효성 처리, service에서는 db와 연계된 예외사항 및 로직 처리, repository는 실제 저장할 쿼리 정도로 기준을 잡아서 처리합니다.

Dto에서 유효성을 처리하는 방법은 아래와 같으며, 컨트롤러에서는 아래와 같이 처리하면 좋습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Controller

public CommonResult updateUser(@Valid @RequestBody UserUpdateRequest request) {
    userManageService.updateUser(request);
    return responseService.getSuccessResult(CommonMessage.USER_UPDATE_SUCCESS);
}

Request Dto

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@ApiModel(value = "사용자 수정 요청 정보")
public class UserUpdateRequest {

    @NotBlank(message = "이름을 입력하세요.")
    private String name;

    @Email(message = "유효한 이메일 주소를 입력하세요.")
    private String email;

}

위와 같이 지정하면 위에서 지정한 Global Exception으로 해당 valid가 빠지면서 에러 템플릿에서 지정한 포맷으로 에러 사유를 볼 수 있습니다.

이 밖에도 필요에 따라 페이징 처리, 다국어 처리, DB 이중화 등도 잘 설정해 놓으면 쉽게 이용하여 쓸 수 있을 것이라 생각합니다.