티스토리 뷰

 

API를 개발하다보면 클라이언트로부터 String 타입의 날짜 데이터를 전달받는 경우가 있다. 요청받은 날짜 데이터를 처리하기 위해서는 문자열 데이터를 LocalDate 또는 LocalDateTime과 같은 날짜 관련 타입으로 역직렬화해주어야 한다. 또는 LocalDateTime 타입의 객체를 클라이언트가 원하는 패턴으로 직렬화하여 전달해주어야 할 수도 있다. 스프링에서는 날짜 관련 클래스를 요청 및 응답으로 주고 받을 수 있도록 다양한 방법을 지원하며 크게 4가지 방법이 있다. 각각에 대해 자세히 알아보고 자신의 상황에 맞게 적절히 선택해보자. 

 

 

앞으로의 예제에서 사용할 아래 요청 클래스는 스케줄을 생성하는데 사용되는 DTO로, 요청을 처리하기 위해서는 사용자가 보낸 "2023-11-22 14:00:00" 문자열을 LocalDateTime 타입의 객체로 변환하는 로직이 필요하다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleRequestDto {

    private LocalDateTime startDateTime;
    private LocalDateTime endDateTime;
    private String content;
    private Boolean isDone;
}

 

아래는 스케줄의 생성 일시를 반환하는 응답 DTO로, 아래와 같은 JSON 형태로 반환되는 것이 요구사항이다.

@Getter
public class ScheduleResponseDto {
    
    private LocalDateTime createdDate = LocalDateTime.now();
}
{
    "createdDate": "23/12/23 13:00:00"
}

 

 

사실 String 타입의 요청을 역직렬화하여 객체로 받을 수 있다면, 그 반대 과정은 쉽게 진행할 수 있다. 따라서 문자열의 날짜 데이터를 어떻게 LocalDateTime 등의 날짜 타입의 객체로 변환하여 받을 수 있는지를 중심으로 살펴볼 것이다. 스프링에서는 아래와 같은 4가지 방법이 존재한다. 그럼 해당 방법을 하나씩 알아보자.

 

 

1. String 타입으로 받은 후 직접 변환 

2. "yyyy-MM-ddTHH:mm:ss" 패턴으로 요청

3. @JsonFormat, @DateTimeFormat 어노테이션 사용

3. 커스텀 어노테이션과 커스텀 Deserializer 사용

 

 

 

String 타입으로 받은 후 직접 변환

사실 이 방법은 스프링의 기능을 백분 활용하지 못하는 방법으로, 여러가지 이유로 별로 추천하지 않느다. 그럼에도 불구하고 방법을 소개하자면, 다음과 같이 startDateTime, endDateTime 필드를 LocalDateTime 타입이 아닌 String 타입으로 받은 후 Controller에서 DateTimeFormatter를 이용해 직접 변환해주는 것이다. 참고로 DateTimeFormatter는 자바에서 제공하는 클래스이다.

 

구현

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleRequestDto {

    private String startDateTime;
    private String endDateTime;
    private String content;
    private Boolean isDone;
}
@RequestMapping("/schedules")
@RestController
public class ScheduleController {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    @PostMapping
    public void createSchedule(@RequestBody ScheduleRequestDto requestDto) {
        LocalDateTime startDateTime = LocalDateTime.parse(requestDto.getStartDateTime(), formatter);
        LocalDateTime endDateTime = LocalDateTime.parse(requestDto.getEndDateTime(), formatter);
        //...
    }
}

요청 JSON 데이터

{
    "startDateTime": "2023-11-22 14:00:00",
    "endDateTime": "2023-11-22 16:00:00",
    "content": "코딩 테스트",
    "idDone": false
}

 

 

문제점

이 경우 발생할 수 있는 문제점은 먼저 날짜 데이터를 처리하는 모든 Controller 내 메소드 마다 이러한 변환 로직이 추가된다는 것이다. 만약 이러한 변환 로직에 변경사항이 생기면 다시 모든 코드를 수정해주어야 하며, 만약 하나라도 수정해주지 않으면 예상치 못한 오류 또한 발생할 수 있다.

 

또 다른 문제는 이러한 파싱 과정에서 DateTimeParseException 예외가 발생할 수 있는데, 스프링에서는 Controller의 메소드 인자를 전달해주는 과정에서 문제가 생기면 일반적으로 MethodArgument...Exception 예외를 뱉고 400(BAD_REQUEST) 상태 코드로 응답한다. 날짜 데이터가 파싱이 되지 않은 것도 클라이언트가 잘못된 요청 데이터를 전달했기 때문이므로 400 상태 코드로 응답해주어야 하지만, 이 경우에는 기본적으로 500(INTERNAL_SERVER_ERROR)로 응답하게 된다. 즉 DateTimeParseException 예외에 대한 별도의 처리(ex. @ExceptionHandler)가 필요하거나 또는 Controller 내부에서 직접 DateTimeParseException 예외를 try-catch하여 다시 MethodArgument...Exception 관련 예외를 던져주는 등 아주 불필요하고도 수고스런 작업을 해주어야 한다. 

 

이러한 이유로 해당 방법은 권하지 않으며 아래 남은 3가지 방법을 적용하는 것을 추천한다. 

 

 

 

"yyyy-MM-ddTHH:mm:ss" 패턴으로 요청

Spring Boot 2.0 이후부터는 jsr310 의존성이 기본적으로 포함되어 있어 아래와 같은 의존성을 build.gradle에 직접 추가해줄 필요가 없다. 

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

 

 

따라서 스프링에서는 기본적으로 별도의 설정 없이도 날짜 데이터에 대한 역직렬화, 직렬화, 포맷팅이 자동으로 수행된다. 다만 요청 데이터, 응답 데이터에 대한 패턴이 고정되어 있어 항상 "yyyy-MM-ddTHH:mm:ss" 형식으로 요청해야만 LocalDateTime 객체로 역직렬화되며, LocalDateTime 객체를 응답으로 전달했을 때 또한 항상 "yyyy-MM-ddTHH:mm:ss " 패턴이 적용된다. 즉 별도의 설정 없이도 편리하게 문자열 날짜 데이터를 자동으로 변환해주지만 날짜 패턴이 고정되어 있어 이 패턴을 따르지 않는 요청에 대해서는 BAD_REQUEST를 날리며 데이터 변환에 실패한다.  

 

 

참고로 중단점을 찍어가면서 확인해본 결과 @ReqeustBody, @ResponseBody를 통해 JSON 데이터 ↔ 객체로 변환하는 경우에는 Jackson 라이브러리에서 제공하는 역직렬화, 직렬화 로직이 수행되는데, 이때 기본적으로 JSR310DateTimeDeserializerBase, JSR310FormattedSerializerBase 인터페이스의 구현체인 LocalDateTimeDeserializer, LocalDateTimeSerializer가 각각 동작한다. 즉 마법처럼 문자열 데이터가 LocalDateTime 객체로 또는 LocalDateTime 객체가 문자열 데이터로 변환되는 것이 아닌 Jackson 라이브러리에서 기본적으로 해당 로직을 제공해주기 때문이다. 

참고로 Jackson의 JavaTimeModule 클래스 내에서 LocalDateTime 외에도 다양한 날짜 타입에 대한 역직렬화, 직렬화를 수행해주는 클래스들을 기본적으로 등록해주는 것을 확인할 수 있다. 

 

JavaTimeModule 내의 날짜 관련 기본 deserializer, serializer 등록

 

 

반면 @RequestParam, @ModelAttribute 등을 통해 요청 파라미터 데이터를 객체로 변환하거나, Model의 attribute 내의 객체를 View Template에서 문자열로 변환하는 경우에는 Formatter, 즉 각각 ParserPrinter가 동작한다. (즉 이 경우에는 Jackson과 아무 관련이 없다.)

 

실제 확인해본 결과 문자열 데이터를 LocalDateTime 객체로 변환하는 경우에는 TemporalAccessorParser가, LocalDateTime 객체를 문자열로 변환하는 경우에는 TemporalAccessorPrinter가 동작한다. 참고로 이 ParserPrinter는 앞의 Deserializer, Serializer와 달리 스프링에서 제공하는 클래스이다. 즉 스프링의 기본 Formatter를 통해 날짜 데이터가 변환되는 것으로 DateTimeFormatterRegistrar 클래스에서 LocalDateTime 타입 외에도 다양한 날짜 클래스를 포맷팅해주는 Formatter(Parser, Printer)를 등록해주는 것을 확인할 수 있다. 

 

DateTimeFormatterRegistrar 내의 날짜 관련 기본 Parser, Printer 등록

 

 

 

이러한 Deserializer, Serializer, Parser, Printer는 결국 내부적으로 자바의 DateTimeFormatter를 이용해 날짜 데이터를 변환하고 이 과정에서 기본 정의된 날짜 패턴을 따른다. 다음은 기본적으로 정의된 날짜 패턴으로, 요청 문자열이 다음 패턴을 따른다면 별도의 설정 없이도 LocalDateTime, LocalDate 등의 날짜 타입 객체로 자동 변환해준다.

 

자바의 DateTimeFormatter 내 정의된 패턴

 

 

구현

이 방법의 경우 별다른 설정 없이도 문자열 형태의 날짜 데이터를 LocalDateTime 객체로 잘 받아올 수 있으며, LocalDateTime 객체를 응답으로 전달한 경우에도 자동으로 문자열 형태로 변환된다. 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleRequestDto {

    private LocalDateTime startDateTime;
    private LocalDateTime endDateTime;
    private String content;
    private Boolean isDone;
}
@Getter
public class ScheduleResponseDto {
    
    private LocalDateTime createdDate = LocalDateTime.now();
}
@RequestMapping("/schedules")
@RestController
public class ScheduleController {

    @PostMapping
    public ScheduleResponseDto createSchedule(@RequestBody ScheduleRequestDto requestDto) {
        //...
        return new ScheduleResponseDto();
    }
}

요청 JSON 데이터

startDateTimeendDateTime 데이터의 중간에 T가 꼭 추가되어야 한다.

{
    "startDateTime": "2023-11-22T14:00:00",
    "endDateTime": "2023-11-22T16:00:00",
    "content": "코딩 테스트",
    "idDone": false
}

응답 JSON 데이터

{
    "createdDateTime": "2023-11-21T10:37:52"
}

 

 

문제점

그러나 현재 문제점은 응답 JSON 데이터에서 createdDate 필드값이 요구사항을 만족하지 못하고 있다. 즉 클라이언트에서 원하는 패턴은 "23/11/21 10:37:52" 인데, 이 경우 항상 "2023-11-21T10:37:52" 으로 응답 데이터가 전달된다. 이렇게 날짜 데이터에 대한 응답 패턴이나 요청 패턴을 변경하고자 하는 경우에는 아래에서 살펴볼 @JsonFormat 또는 @DateTimeFormat 어노테이션을 사용해 원하는 패턴을 지정해줄 수 있다.

 

 

 

@JsonFormat, @DateTimeFormat 어노테이션 사용 

사실 이 경우에도 동일하게 Jackson의 Deserializer, Serializer가, 스프링의 Parser, Printer가 동작하나 데이터를 변환하는 과정에서 사용되는 날짜 패턴을 개발자가 직접 지정해줄 수 있다는 차이점이 있다. 이 외에도 locale, timezone 설정도 가능하다.

@JsonFormat은 Jackson 라이브러리에서, @DateTimeFormat은 스프링에서 제공해주는 것으로 이에 따라 사용 용도에도 차이를 보인다. 즉 @RequestBody 또는 @ResponseBody 어노테이션이 선언되는 DTO 클래스 내에서는 @JsonFormat 어노테이션을, @RequestParam 또는 @ModelAttribute 어노테이션이 선언되는 경우에는 @DateTimeFormat 어노테이션을 사용하면 된다.

 

구현

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleRequestDto {

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime startDateTime;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime endDateTime;
    
    private String content;
    
    private Boolean isDone;
}
@Getter
public class ScheduleResponseDto {
    
    @JsonFormat(shape = Shape.STRING, pattern = "yy/MM/dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createdDate = LocalDateTime.now();
}
@RequestMapping("/schedules")
@RestController
public class ScheduleController {

    @PostMapping
    public ScheduleResponseDto createSchedule(@RequestBody ScheduleRequestDto requestDto) {
        //...
        return new ScheduleResponseDto();
    }
}

요청 JSON 데이터

{
    "startDateTime": "2023-11-22 14:00:00",
    "endDateTime": "2023-11-22 16:00:00",
    "content": "코딩 테스트",
    "idDone": false
}

응답 JSON 데이터

ScheduleResponseDto에서 @JsonFormat 어노테이션을 통해 응답 pattern을 지정해주었기 때문에 클라이언트가 원하는 형태로 응답 데이터를 전달해줄 수 있게 되었다.

{
    "createdDateTime": "23/11/21 10:37:52"
}

 

참고로 이 경우 @JsonFormat 대신 @DateTimeFormat을 사용하면 지정한 패턴이 적용되지 않는다. (정확하진 않지만 Spring Boot 2.0에서는 @RequestBody DTO 내에서도 @DateTimeFormat 어노테이션 적용이 가능했던 것 같은데, 현재 버전인 3.0에서는 잘 동작하지 않는다.)

 

 

반면 요청 파라미터로 전달되는 날짜 데이터에 대한 패턴은 다음과 같이 @DateTimeFormat 어노테이션을 통해 지정할 수 있다. 

@RequestMapping("/schedules")
@RestController
public class ScheduleController {

    @GetMapping
    public void getSchedule(@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime dateTime) {
        //...
    }
}

 

 

문제점

만약 클라이언트 쪽에서 "yyyy-MM-ss HH:mm:ss" 패턴 뿐만 아니라 "yyyy/MM/ss HH:mm:ss" 패턴의 요청 데이터도 전달할 수 있도록 해달라는 요구사항이 존재하면 어떻게 해야 할까? 사실 @JsonFormat 어노테이션 내에는 @Repeatable 메타 어노테이션이 존재하지 않아 다음과 같이 여러 패턴을 지정해줄 수 없다. 

@JsonFormat(shape = Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
@JsonFormat(shape = Shape.STRING, pattern = "yyyy/MM/dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime startDateTime;

 

그럼 어쩔 수 없이 첫번째 방법을 사용하여 날짜 데이터를 String으로 받아 직접 변환해주어야 하나? 다행히도 마지막 남은 방법을 통해 이 문제를 해결할 수 있다. 바로 직접 Deserializer를 구현해 사용하는 것이다. 그럼 마지막 방법에 대해 알아보자. 

 

 

 

커스텀 어노테이션과 커스텀 Deserializer 사용

Jackson 라이브러리에서 기본적으로 제공해주었던 LocalDateTimeDeserializer를 통해 다양한 패턴의 날짜 데이터를 LocalDateTime으로 변환할 수 있다. 일단 코드를 먼저 보자.

 

구현

먼저 역직렬화의 대상이 되는 날짜 타입의 필드에 선언할 커스텀 어노테이션을 정의해준다. @JacksonAnnotationsInside 어노테이션을 사용하여 Jackson 관련 커스텀 애노테이션을 정의할 수 있다. 정의한 @CustomLocalDateTimeFormat 어노테이션은 문자열 날짜 데이터를 LocalDateTime 객체로 역직렬화할 때 CustomLocalDateTimeDeserializer를 사용하도록 지정하고 있다. 

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
public @interface CustomLocalDateTimeFormat {

}

 

 

다양한 날짜 패턴에 대해 LocalDateTime 객체로 역직렬화를 수행해주는 CustomLocalDateTimeDeserializer 클래스는 다음과 같다. 사용 가능한 여러 패턴에 대해 for문을 돌며 역직렬화를 수행하는 것을 확인할 수 있다. 만약 첫번째 패턴에서 실패하면 성공할 때까지 역직렬화를 수행하며, 만약 지정한 모든 패턴에 대해 역직렬화에 실패하면 예외를 던진다. 

public class CustomLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {

    private static final String HYPHEN_PATTERN = "yyyy-MM-dd HH:mm:ss";
    private static final String SLASH_PATTERN = "yyyy/MM/dd HH:mm:ss";
    private static final String DOT_PATTERN = "yyyy.MM.dd HH:mm:ss";
    private static final List<LocalDateTimeDeserializer> deserializers = List.of(
        new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(HYPHEN_PATTERN)),
        new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(SLASH_PATTERN)),
        new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DOT_PATTERN)));

    @Override
    public LocalDateTime deserialize(
        JsonParser parser, DeserializationContext context) throws IOException {
        for (LocalDateTimeDeserializer deserializer : deserializers) {
            try {
                return deserializer.deserialize(parser, context);
            } catch (Exception e) {
            }
        }
        throw new JsonParseException(parser, "Unable to parse date: [" + parser.getValueAsString()
            + "]. Supported formats: " + List.of(HYPHEN_PATTERN, SLASH_PATTERN, DOT_PATTERN));
    }
}

 

 

이제 요청 DTO에 @CustomLocalDateTimeFormat 어노테이션을 선언해주면 다음과 같은 다양한 날짜 패턴 요청에 대해서도 LocalDateTime 객체로의 역직렬화가 정상적으로 수행되는 것을 확인할 수 있다. 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleRequestDto {

    @CustomLocalDateTimeFormat
    private LocalDateTime startDateTime;
    
    @CustomLocalDateTimeFormat
    private LocalDateTime endDateTime;
    
    private String content;
    private Boolean isDone;
}

요청 JSON 데이터

{
    "startDateTime": "2023/11/22 14:00:00",
    "endDateTime": "2023.11.22 16:00:00",
    "content": "코딩 테스트",
    "idDone": false
}

 

 

만약 CustomLocalDateTimeDeserializer 클래스 내에 가능한 날짜 패턴만 추가해주면, 예를 들어 일(day) 단위의 스케줄링, 시(hour) 단위의 스케줄링, 분(minute) 단위의 스케줄링 요청을 모두 하나의 DTO로 받을 수도 있다. 즉 아래와 같은 요청 데이터도 위에서 정의한 ScheduleRequestDto로 받을 수 있게 된다는 것이다. 

{
    "startDateTime": "2023/11/22",
    "endDateTime": "2023.11.25",
    "content": "해커톤",
    "idDone": false
}

 

 

문제점

앞서 말했듯이 Deserializer@RequestBody 어노테이션이 붙은 DTO를 역직렬화할 때 사용되는 것으로, @CustomLocalDateTimeFormat 어노테이션을 요청 파라미터에 선언해봤자 날짜 데이터가 정상적으로 변환되지 않는다. 따라서 이 경우에는 위의 Deserializer 처럼 별도의 Formatter를 동일하게 정의해주어야 한다.

 

또한 List<LocalDateTime>과 같은 Collection 타입에 대해 @CustomLocalDateTimeFormat 어노테이션을 사용하면 날짜 데이터가 정상적으로 역직렬화되지 않아 Resolved [org.springframework.http.converter.HttpMessageNotReadableException] 예외가 발생한다. 즉 Collection 타입에 대한 별도의 Deserializer 정의가 또 필요하다는 것이다. 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CollectionRequestDto {

    @CustomLocalDateTimeFormat
    private List<LocalDateTime> dateTimeList;
}

 

 

그러나 @CustomLocalDateTimeFormat 어노테이션을 사용하여 커스텀 Deserializer가 동작하도록 지정한 방법은 지역적인 설정으로, 만약 모든 LocalDateTime 객체로의 역직렬화 과정에 커스텀 Deserializer가 동작하도록 전역적인 설정을 해주면, Collection 타입에 대한 별도의 Deserializer 정의 없이도 날짜 데이터를 변환할 수 있다.

 

전역적으로 동작하는 경우에는 @CustomLocalDateTimeFormat 어노테이션이 필요하지 않으며(자동으로 CustomLocalDateTimeDeserializer가 동작하기 때문) List, Map 등의 Collection 타입의 필드에 대해서도 역직렬화가 잘 수행된다. (실제로는 CollectionDeserializer라는 기본 Deserializer가 동작하며 CollectionDeserializer 내부적으로 전역 등록된 CustomLocalDateTimeDeserializer가 사용된다.)

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CollectionRequestDto {

    //@CustomLocalDateTimeFormat 선언X
    private List<LocalDateTime> dateTimeList;
}
@Configuration
public class WebConfig {

    @Bean
    public Module module() {
        SimpleModule simpleModule = new SimpleModule(); //Jackson 라이브러리의 모듈
        //커스텀 deserializer 전역 등록 (LocalDateTime으로 역직렬화하는 모든 경우에 해당 Deserializer 사용)
        simpleModule.addDeserializer(LocalDateTime.class, new CustomLocalDateTimeDeserializer());
        return simpleModule;
    }
}

 

 

추가

좀더 찾아본 결과 아래와 같은 형태로 @JsonFormat 어노테이션을 통해서도 다양한 패턴을 지정할 수 있다. 다만 이 경우에는 해당 어노테이션이 선언된 필드에 대해서만 적용된다. 만약 LocalDateTime 등 특정 클래스 타입으로 역직렬화되는 모든 경우에 다양한 패턴이 적용되길 원한다면 앞서 설명한 것처럼 Deserializer를 전역적으로 등록하여 사용하면 된다.  

@JsonFormat(pattern = "[yyyy/MM/dd HH:mm:ss][yyyy.MM.dd HH:mm:ss][yyyy-MM-dd HH:mm:ss]")

 

참고로 @DateTimeFormat 어노테이션의 경우 fallbackPatterns 속성이 있어 여기에 다른 패턴들을 지정해줄 수 있다. 

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss", fallbackPatterns = {"yyyy/MM/dd HH:mm:ss", "yyyy.MM.dd HH:mm:ss"})

 

 

 

마무리

커스텀 Deserializer를 사용하는 경우 클라이언트 입장에서 요청 패턴의 자율성이 높아지며, 요청 패턴에 따라 요청 DTO를 각각 정의해줄 필요가 없어 프로젝트의 복잡도가 낮아진다는 장점도 있지만, 매번 모든 가능한 패턴으로 일일이 파싱하기 때문에 성능 저하의 가능성이 아주 조금? 있는 듯하다. 아무튼 정말 필요한 경우가 아니라면 그냥 기본적으로 제공되는 @JsonFormat, @DateTimeFormat 어노테이션을 사용하는 것을 추천한다.

 

 

이처럼 스프링은 어노테이션을 사용해 커스텀하게 설정할 수 있는 지점이 많다. 예를 들어 요청 DTO 검증 시 커스텀 어노테이션을 선언하여 특정 커스텀 Validator가 동작하도록 지정할 수도 있다. 다음 글에서는 이러한 커스텀 어노테이션과 스프링 AOP를 활용하여 공통 응답 DTO 클래스를 매번 Controller 메소드에서 생성하지 않고 효율적으로 생성하는 방법에 대해 알아보자.

 

 

 

끝.

 

 

 

참고 

https://stackoverflow.com/questions/69471508/deserializing-multiple-localdatetime-formats-in-springboot

https://www.baeldung.com/jackson-annotations

https://jojoldu.tistory.com/361

https://stackoverflow.com/questions/53480525/how-to-provide-a-custom-deserializer-with-jackson-and-spring-boot

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함