Pinstinct Software Engineer

개월 수 구하기(Duration vs Period vs ChronoUnit)

문제 상황

오늘부터 입력받은 날짜까지의 개월 수를 구하는 레거시 코드에서 버그가 발견됐다. 해당 로직은 SimpleDateFormat과 Calender 라이브러리를 사용중이었다. 오늘날짜를 제대로 가져오지 못하고, 개월 수 계산도 제대로 이루어지지 않는 상태였다.

Duration vs Period vs ChronoUnit

  • Duration: 두 시간 사이의 초, 나노 초 단위 간격을 나타낸다.
LocalTime start = LocalTime.of(10, 35, 40);
LocalTime end = LocalTime.of(10, 36, 50, 800);

Duration duration = Duration.between(start, end);

System.out.println("Seconds: " + duration.getSeconds());  // Seconds: 70
System.out.println("Nano Seconds: " + duration.getNano());  // Nano Seconds: 800
  • Period: 두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다.
LocalDate startDate = LocalDate.of(1939, 9, 1);
LocalDate endDate = LocalDate.of(1945, 9, 2);

Period period = Period.between(startDate, endDate);

System.out.println("Years: " + period.getYears());  // Years: 6
System.out.println("Months: " + period.getMonths());  // Months: 0
System.out.println("Days: " + period.getDays());  // Days: 1
  • ChronoUnit: 객체를 생성하지 않고 간편하게 간격을 나타낸다.
LocalDate startDate = LocalDate.of(1939, 9, 1);
LocalDate endDate = LocalDate.of(1945, 9, 2);

long months = ChronoUnit.MONTHS.between(startDate, endDate);
long weeks = ChronoUnit.WEEKS.between(startDate, endDate);
long days = ChronoUnit.DAYS.between(startDate, endDate);

System.out.println("Months: " + months);  // Months: 72
System.out.println("Weeks: " + weeks);  // Weeks: 313
System.out.println("Days: " + days);  // Days: 2193

LocalTime startTime = LocalTime.of(10, 35, 40);
LocalTime endTime = LocalTime.of(10, 36, 50, 800);

long hours = ChronoUnit.HOURS.between(startTime, endTime);
long minutes = ChronoUnit.MINUTES.between(startTime, endTime);
long seconds = ChronoUnit.SECONDS.between(startTime, endTime);

System.out.println("Hours: " + hours);  // Hours: 0
System.out.println("Minutes: " + minutes);  // Minutes: 1
System.out.println("Seconds: " + seconds);  // Seconds: 70

[Java8 Time API] Duration과 Period 사용법 (+ChronoUnit)

해결 방법

Period 이용한 방법

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate requestDate = LocalDate.parse("2022-02-02", formatter);
ZonedDateTime kst = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));

Period period = Period.between(requestDate, kst.toLocalDate());
int result = period.getYears() * 12 + period.getMonths();

ChronoUnit 이용한 방법

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate requestDate = LocalDate.parse("2022-02-02", formatter);
ZonedDateTime kst = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));

long result = ChronoUnit.MONTHS.between(requestDate, kst.toLocalDate());

ChronoUnit이 더 효율적이기 때문에 ChronoUnit을 사용한다.

Duration.between vs ChronoUnit.between

Java Nested ENUM 클래스 구성하기

문제 사항

배너의 타입이 있고, 배너의 타입들은 다음과 같이 구성되어 있습니다.

  • 이벤트1
  • 이벤트2
  • 이벤트3
  • 이벤트4
  • 이벤트5
  • 광고1
  • 광고2
  • 광고3
  • 광고4
  • 광고5

여기에 하위 타입이 추가되며, 이벤트 타입 배너에만 하위 타입을 추가해야 합니다.

해결 방법

기존에 배너 타입을 별도로 정의하지 않아, ENUM 클래스로 추가했습니다. 또한 배너 타입을 ‘이벤트’, ‘광고’ 등으로 묶기 위해 *Group ENUM 클래스를 추가했습니다.

BannerType Eunm

@Getter
public enum BannerType {
    EVENT_1("이벤트1"),
    EVENT_2("이벤트2"),
    EVENT_3("이벤트3"),
    EVENT_4("이벤트4"),
    EVENT_5("이벤트5"),
    AD_1("광고1"),
    AD_2("광고2"),
    AD_3("광고3"),
    AD_4("광고4"),
    AD_5("광고5"),
    ;

    private String type;

    BannerType(String type) {
        this.type = type;
    }
    
    public static boolean isBannerType(String type) {
        return Arrays.stream(BannerType.values())
                .anyMatch(bannerType -> bannerType.getType().equals(type));
    }
    
    public static BannerType getBannerType(String type) {
        return Arrays.stream(BannerType.values())
                .filter(bannerType -> bannerType.getType().equals(type))
                .findAny()
                .orElse(null);
    }

}

BannerTypeGroup Enum

@Getter
public enum BannerTypeGroup {
    EVENT("이벤트", Arrays.asList(
            BannerType.EVENT_1,
            BannerType.EVENT_2,
            BannerType.EVENT_3,
            BannerType.EVENT_4,
            BannerType.EVENT_5
            )),
    AD("광고", Arrays.asList(
            BannerType.AD_1,
            BannerType.AD_2,
            BannerType.AD_3,
            BannerType.AD_4,
            BannerType.AD_5
            ));

    private String type;
    private List<BannerType> banners;

    BannerTypeGroup(String type, List<BannerType> banners) {
        this.type = type;
        this.banners = banners;
    }

    public boolean isBannerType(BannerType bannerType) {
        return banners.stream()
                .anyMatch(banner -> banner == bannerType);
    }

    public static BannerTypeGroup findByBannerType(BannerType bannerType) {
        return Arrays.stream(BannerTypeGroup.values())
                .filter(bannerTypeGroup -> bannerTypeGroup.isBannerType(bannerType))
                .findAny()
                .orElse(null);
    }
}

서비스에서 사용

BannerType bannerType = BannerType.getBannerType(banner_type);
BannerTypeGroup requestBannerType = BannerTypeGroup.findByBannerType(bannerType);
String eventTypeName = BannerTypeGroup.EVENT.name();
if (requestBannerType.name().equals(eventTypeName)) {
    // 이벤트 배너인 경우만 하위 타입 저장
}

Enum 사용법 및 예제

Java Enum 활용기-우아한형제들

DCI 패턴 테스트 코드

DCI 패턴(Describe 방식)

보통 테스트 코드 작성할 때 given, when, then 방식(Behavior 방식)을 많이 사용한다. 하지만 주석을 사용하기 때문에 강제성이 없다. 때문에 테스트 코드에 DCI 패턴을 도입했다.

  • Describe: 테스트 대상을 명시 (클래스, 메소드 이름 명시)
  • Context: 테스트 대상이 놓인 상황을 설명 (파라미터 설명)
    • with, when으로 시작하도록 한다.
  • It: 테스트 대상의 행동을 설명 (무엇을 리턴하는지 설명)

이어서 읽었을 때 비문이 아닌 하나의 좋은 문장이 되도록 작성하는 것이 중요하다.

JUnit5로 계층 구조의 테스트 코드 작성하기

테스트 코드를 왜 그리고 어떻게 작성해야 할까?

적용 예시

controller 테스트

import com.limhm.MainTests;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

public class BannerControllerTest extends MainTests {
    String BASE_URL = "/banner";

    // multipart는 POST로만 전송가능하기 때문에 builder를 별도로 생성
    MockMultipartHttpServletRequestBuilder multipartPutBuilder(String url) {
        MockMultipartHttpServletRequestBuilder builder = multipart(url);
        builder.with(request -> {
           request.setMethod(HttpMethod.PUT.name());
           return request;
        });
        return builder;
    }

    abstract class BannerRequest {
        private String getDateTime(int days) {
            LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
            LocalDateTime result = now.plusDays(days);
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            return result.format(formatter);
        }

        MultiValueMap<String, String> getObject(int start, int end) {
            MultiValueMap<String, String> request = new LinkedMultiValueMap<>();
            request.add("banner_type", "event");
            request.add("banner_subject", "hello");
            request.add("banner_start_date", getDateTime(start));
            request.add("banner_end_date", getDateTime(end));
            return request;
        }

        MockMultipartFile getFile() {
            return new MockMultipartFile(
                    "banner_img",
                    "test.jpg",
                    MediaType.MULTIPART_FORM_DATA_VALUE,
                    "image".getBytes()
            );
        }
    }

    @DisplayName("배너 생성 테스트")
    @Nested
    class DescribeBannerRegistration {
        @DisplayName("헤더 인증정보가 없다면")
        @Nested
        class ContextWithoutAuth extends BannerRequest {
            @DisplayName("401을 리턴한다")
            @Test
            void itReturns401() throws Exception {
                mockMvc.perform(multipart(BASE_URL)
                        .file(getFile())
                        .params(getObject(0, 1)))
                        .andExpect(MockMvcResultMatchers.status().isUnauthorized())
                        .andDo(print());
            }
        }

        @DisplayName("모든 파라미터가 주어지면")
        @Nested
        class ContextWithAllParameters extends BannerRequest {
            @DisplayName("200을 리턴한다")
            @Test
            void itReturns200() throws Exception {
                mockMvc.perform(multipart(BASE_URL)
                        .file(getFile())
                        .header("JWT", "abc")
                        .params(getObject(1, 2)))
                        .andExpect(MockMvcResultMatchers.status().isOk())
                        .andDo(print());
            }
        }
    }

    @DisplayName("배너 수정 테스트")
    @Nested
    class DescribeBannerModify {
        @DisplayName("헤더 인증정보가 없다면")
        @Nested
        class ContextWithoutAuth extends BannerRequest {
            @DisplayName("401을 리턴한다")
            @Test
            void itReturns401() throws Exception {
                mockMvc.perform(multipartPutBuilder(BASE_URL)
                        .file(getFile())
                        .params(getObject(2, 3)))
                        .andExpect(MockMvcResultMatchers.status().isUnauthorized())
                        .andDo(print());
            }
        }

        @DisplayName("모든 파라미터가 주어지면")
        @Nested
        class ContextWithAllParameters extends BannerRequest {
            @DisplayName("200을 리턴한다")
            @Test
            void itReturns200() throws Exception {
                MultiValueMap<String, String> params = getObject(3, 4);
                params.add("banner_key", "1");
                mockMvc.perform(multipartPutBuilder(BASE_URL)
                        .file(getFile())
                        .header("JWT", "abc")
                        .params(params))
                        .andExpect(MockMvcResultMatchers.status().isOk())
                        .andDo(print());
            }
        }
    }
}

service 테스트

import com.limhm.MainTests;

import com.limhm.dto.BannerRegistModifyDto;
import com.limhm.service.BannerService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.time.LocalDateTime;
import java.time.ZoneId;


import static org.junit.Assert.assertThat;

public class BannerServiceTest extends AdminTests {
    @Autowired
    BannerService service;

    abstract class BannerDto {
        private LocalDateTime getDateTime(int days) {
            LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
            return now.plusDays(days);
        }

        BannerRegistModifyDto getObject(int start, int end) {
            return new BannerRegistModifyDto(
                    "event",
                    "hello",
                    getDateTime(1),
                    getDateTime(2)
            );
        }
    }

    @DisplayName("배너 등록")
    @Nested
    class DescribeBannerInsert {
        @DisplayName("모든 파라미터가 주어지면")
        @Nested
        class ContextWithAllParameter extends BannerDto {
            @DisplayName("배너를 저장한다")
            @Test
            void itSavesBanner() throws Exception {
                int result = service.insertBanner(getObject(1, 2));
                assertThat(result).isEqualTo(1);
            }
        }
    }
}

두개의 기간이 겹치는지 체크하는 로직

문제 상황

기존에 등록된 배너들과 새로운 배너들의 기간이 겹치지 않도록 추가/수정하고 싶다는 요구사항이 발생했다.

위의 요구사항을 해결하기 위해 기간 A와 기간 B가 주어졌을 때, 겹치는지 확인하는 로직이 필요했다.

두 기간 비교 방법

기간 B가 기간 A보다 앞서는 경우 (startA > endB)

  • |--- B ---| |--- A ---|

기간 B가 기간 A보다 뒤에 있는 경우 (endA < startB)

  • |--- A ---| |--- B ---|

위의 두 경우를 제외한 모든 경우가 겹치는 기간이다.

여기에 드모르간 법칙을 이용하면,

  • not (A or B) <-> not A and not B

다음과 같이 겹치는 기간을 표현할 수 있다.

  • startA <= endB and EndA >= startB

Determine Whether Two Date Ranges Overlap

해결 방법

위의 원리에 따라 자바의 LocalDateTime을 사용해 함수를 작성했다.

public boolean isOverlap(LocalDateTime startA, LocalDateTime endA, LocalDateTime startB, LocalDateTime endB) {
  return ((startA.isBefore(endB) || startA.isEqual(endB))) && ((startB.isBefore(endA)) || (startB.isEqual(endA)));
}

SimpleDateFormat vs DateTimeFormatter

SimpleDateFormat

SimpleDateFormat: 날짜를 파싱(text -> date) 또는 포매팅(date -> text)할 때 사용하는 locale-sesitive 클래스이다.

하지만 멀티스레드 환경에서 Thread-safe를 보장하지 않는다. 내부적으로 Calender 클래스를 인스턴스화해서 사용하는데 멀티쓰레드 충돌을 방지해주는 장치가 없기 때문이다.

new 연산자를 통해 새로운 인스턴스를 생성해 해결할 수 있다. 하지만 SimpleDateFormat은 비싼 객체로 CPU 자원을 많이 사용한다.

[Java]SimpleDateFormat을 쓰면 안된다고? (feat.Thread-Safe)

DateTimeFormatter

JDK8 부터 DateTimeFormatter 사용을 권장한다.

  • LocalDate: yyyy.MM.dd
  • LocalTime: HH.mm.ss
  • LocalDateTime: yyyy.MM.dd HH.mm.ss (+나노 초 가능)
// 날짜 생성
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

LocalDateTime today = LocalDateTime.now();
String today2 = today.format(formatter);

// 날짜 연산
LocalDateTime afterHour = today.plusDays(1);

// 날짜 비교
boolean a = today.isBefore(afterHour);
boolean b = today.isAfter(afterHour);
boolean c = today.isEqual(afterHour);

// String -> LocalDateTime
String dateTime = "2023-08-21";
LocalDateTime localDateTime = LocalDateTime.parse(dateTime);

DateTimeFormatter 기본 사용법