
"이거 DTO로 만들까요, VO로 만들까요?" - 신입 개발자 시절 누구나 한 번쯤 해본 질문
Spring Boot로 개발하다 보면 VO, DTO, Entity라는 용어를 자주 마주칩니다. 비슷해 보이지만 각각의 목적과 사용처가 명확히 다릅니다. 이번 글에서는 개념부터 실전 활용까지, 더 이상 헷갈리지 않도록 정리해보겠습니다.
1. 한눈에 보는 핵심 차이
| 구분 | VO | DTO | Entity |
|---|---|---|---|
| 한 줄 요약 | 값 그 자체 | 데이터 택배 상자 | DB 테이블의 Java 버전 |
| 목적 | 값 표현 | 계층 간 데이터 전달 | DB 테이블 매핑 |
| 동등성 | 값으로 비교 | 보통 비교 안 함 | ID로 비교 |
| 불변성 | 불변 | 가변 | 가변 |
| 비즈니스 로직 | 가질 수 있음 | 없음 | 가질 수 있음 |
비유로 이해하기
- VO: 만원짜리 지폐 (어떤 만원이든 가치는 같음)
- DTO: 택배 상자 (물건을 A에서 B로 옮기는 역할)
- Entity: 주민등록증 (고유 번호로 개인을 식별)
2. 먼저 알아야 할 것: 불변 vs 가변
불변 객체 (Immutable)
한 번 생성되면 상태를 변경할 수 없는 객체입니다.
@Getter
@RequiredArgsConstructor // final 필드 생성자 자동 생성
public class Money {
private final int amount; // final = 변경 불가
private final String currency; // final = 변경 불가
// 값 변경이 필요하면 새 객체 반환
public Money add(Money other) {
return new Money(this.amount + other.amount, this.currency);
}
}
Money money1 = new Money(1000, "KRW");
// money1.setAmount(2000); // 컴파일 에러! Setter 없음
Money money2 = money1.add(new Money(500, "KRW"));
log.info("money1: {}", money1.getAmount()); // 1000 (변경 안 됨)
log.info("money2: {}", money2.getAmount()); // 11500 (새 객체)
| 장점 | 설명 |
|---|---|
| 스레드 안전 | 여러 스레드가 동시에 접근해도 안전 |
| 예측 가능 | 값이 변하지 않아 디버깅 용이 |
| 사이드 이펙트 방지 | 다른 코드에서 값을 변경할 수 없음 |
Java의 대표적인 불변 객체:
| 클래스 | 설명 | 값 변경 시 |
|---|---|---|
String |
문자열 | 새 문자열 객체 생성 |
Integer, Long |
래퍼 클래스 | 새 래퍼 객체 생성 |
LocalDate, LocalDateTime |
날짜/시간 (Java 8+) | 새 날짜 객체 반환 |
BigDecimal |
정밀한 숫자 계산 | 새 BigDecimal 반환 |
// String - 불변
String str = "Hello";
str.concat(" World"); // 원본 변경 안 됨
log.info("str: {}", str); // "Hello" (그대로)
String newStr = str.concat(" World"); // 새 객체 반환
log.info("newStr: {}", newStr); // "Hello World"
// LocalDate - 불변
LocalDate today = LocalDate.now();
today.plusDays(1); // 원본 변경 안 됨
log.info("today: {}", today); // 오늘 날짜 (그대로)
LocalDate tomorrow = today.plusDays(1); // 새 객체 반환
log.info("tomorrow: {}", tomorrow); // 내일 날짜
// BigDecimal - 불변 (금융 계산에 필수)
BigDecimal price = new BigDecimal("10000");
price.add(new BigDecimal("500")); // 원본 변경 안 됨
BigDecimal newPrice = price.add(new BigDecimal("500")); // 새 객체 반환
log.info("newPrice: {}", newPrice); // 10500
주의:
Date,Calendar는 가변 객체입니다. Java 8 이후에는 불변인LocalDate,LocalDateTime을 사용하세요.
가변 객체 (Mutable)
생성 후에도 상태를 변경할 수 있는 객체입니다.
@Getter @Setter // Setter 있음 = 가변
public class UserDTO {
private String name;
private String email;
}
UserDTO user = new UserDTO();
user.setName("홍길동");
user.setName("김철수"); // 얼마든지 변경 가능
언제 뭘 쓰나?
| 상황 | 선택 | 이유 |
|---|---|---|
| 금액, 주소 같은 값 | 불변 | 값이 변하면 안 됨 |
| 멀티스레드 환경 | 불변 | 동기화 불필요 |
| API 요청/응답 DTO | 가변 | 값 설정이 편리 |
| DB Entity | 가변 | 상태 변경 필요 |
3. VO (Value Object)
개념
"값 그 자체"를 표현하는 객체입니다. 핵심은 값으로 동등성을 판단한다는 것입니다.
제 지갑의 만원과 여러분 지갑의 만원은 "같은" 만원입니다. 지폐 일련번호가 달라도 가치는 동일하니까요.
코드 예시
@Getter
@EqualsAndHashCode // 모든 필드로 동등성 비교
@RequiredArgsConstructor
public class Money {
private final int amount;
private final String currency;
// 유효성 검증이 필요하면 정적 팩토리 메서드 사용
public static Money of(int amount, String currency) {
if (amount < 0) {
throw new IllegalArgumentException("금액은 0 이상이어야 합니다");
}
return new Money(amount, currency);
}
// 비즈니스 로직 포함 가능
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("통화가 다릅니다");
}
return new Money(this.amount + other.amount, this.currency);
}
}
@EqualsAndHashCode란?
equals()와hashCode()메서드를 자동 생성해주는 Lombok 어노테이션입니다.
메서드 역할 equals()두 객체가 같은지 비교 (필드 값 기준) hashCode()객체의 해시값 반환 (HashMap, HashSet에서 사용) 왜 필요한가요?
// 센서에서 읽은 좌표 두 개
Coordinate sensorA = new Coordinate(37.5665, 126.9780); // 서울시청
Coordinate sensorB = new Coordinate(37.5665, 126.9780); // 또 다른 센서도 서울시청
// @EqualsAndHashCode 없으면 → false (메모리 주소가 다르니까)
// @EqualsAndHashCode 있으면 → true (좌표 값이 같으니까)
sensorA.equals(sensorB);
// 실무 상황 1: 이 위치에 이미 매장이 있는지 확인
List<Coordinate> storeLocations = getStoreLocations();
if (storeLocations.contains(newLocation)) {
throw new IllegalArgumentException("이미 해당 위치에 매장이 존재합니다");
}
// 실무 상황 2: 중복 좌표 제거 (여러 센서가 같은 위치를 보고할 때)
Set<Coordinate> uniqueLocations = new HashSet<>(allSensorData);
log.info("중복 제거 후 위치 수: {}", uniqueLocations.size());
// 실무 상황 3: 좌표별 날씨 정보 캐싱
Map<Coordinate, WeatherInfo> weatherCache = new HashMap<>();
weatherCache.put(seoul, seoulWeather);
weatherCache.get(anotherSeoulCoord); // 같은 좌표면 캐시 히트
// @EqualsAndHashCode가 자동 생성하는 코드
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money money = (Money) o;
return amount == money.amount &&
Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
이 어노테이션 덕분에 위 코드를 직접 작성하지 않아도 됩니다.
Money price1 = new Money(1000, "KRW");
Money price2 = new Money(1000, "KRW");
// @EqualsAndHashCode 덕분에 값으로 비교 가능
log.info("price1 equals price2: {}", price1.equals(price2)); // true (같은 값)
도메인별 VO 활용 사례
VO는 단순한 금액뿐 아니라 GIS, IoT, 물류, 의료 등 다양한 도메인에서 핵심적으로 사용됩니다.
GIS/지도 시스템
@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA용
@AllArgsConstructor
public class Coordinate {
private double latitude;
private double longitude;
// 유효성 검증이 필요하면 정적 팩토리 메서드
public static Coordinate of(double latitude, double longitude) {
if (latitude < -90 || latitude > 90)
throw new IllegalArgumentException("위도는 -90 ~ 90 사이");
if (longitude < -180 || longitude > 180)
throw new IllegalArgumentException("경도는 -180 ~ 180 사이");
return new Coordinate(latitude, longitude);
}
// 두 좌표 간 거리 계산 (Haversine 공식)
public double distanceKmTo(Coordinate other) {
double R = 6371;
double dLat = Math.toRadians(other.latitude - this.latitude);
double dLon = Math.toRadians(other.longitude - this.longitude);
double a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(Math.toRadians(this.latitude)) *
Math.cos(Math.toRadians(other.latitude)) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
public boolean isWithinRadius(Coordinate center, double radiusKm) {
return distanceKmTo(center) <= radiusKm;
}
}
활용:
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Store {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Embedded
private Coordinate location;
@Builder
public Store(String name, Coordinate location) {
this.name = name;
this.location = location;
}
}
// 반경 3km 내 매장 검색
List<Store> nearbyStores = stores.stream()
.filter(s -> s.getLocation().isWithinRadius(userLocation, 3.0))
.toList();
IoT/센서 시스템
@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public class Temperature {
private final double value;
private final TemperatureUnit unit;
public Temperature toCelsius() {
return switch (unit) {
case CELSIUS -> this;
case FAHRENHEIT -> new Temperature((value - 32) * 5/9, TemperatureUnit.CELSIUS);
case KELVIN -> new Temperature(value - 273.15, TemperatureUnit.CELSIUS);
};
}
public boolean isOverheat(double thresholdCelsius) {
return toCelsius().value > thresholdCelsius;
}
public boolean isFreezing() {
return toCelsius().value <= 0;
}
}
@Getter
@EqualsAndHashCode
public class BatteryLevel {
private final int percentage;
// 유효성 검증 필요 시 생성자 직접 작성
public BatteryLevel(int percentage) {
if (percentage < 0 || percentage > 100)
throw new IllegalArgumentException("0~100 사이여야 합니다");
this.percentage = percentage;
}
public boolean isLow() { return percentage <= 20; }
public boolean isCritical() { return percentage <= 5; }
}
활용:
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {}) // 연관관계 있으면 제외
public class IoTDevice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Coordinate location;
@Embedded
@AttributeOverride(name = "percentage", column = @Column(name = "battery_level"))
private BatteryLevel battery;
private Instant lastSeen;
@Builder
public IoTDevice(Coordinate location, BatteryLevel battery) {
this.location = location;
this.battery = battery;
this.lastSeen = Instant.now();
}
public boolean needsMaintenance() {
return battery.isLow() ||
Duration.between(lastSeen, Instant.now()).toHours() > 24;
}
public void updateLastSeen() {
this.lastSeen = Instant.now();
}
}
도메인별 VO 요약
| 도메인 | VO | 비즈니스 로직 |
|---|---|---|
| GIS | Coordinate, BoundingBox | 거리 계산, 영역 포함 여부 |
| IoT | Temperature, BatteryLevel | 단위 변환, 임계값 체크 |
| 물류 | Weight, Dimension | 배송비 계산, 부피 계산 |
| 의료 | BloodPressure, HeartRate | 정상 범위 판단 |
| 금융 | Money, ExchangeRate | 환율 계산 |
VO를 쓰면 좋은 점:
- 타입 안정성:
int대신Temperature로 의미 명확 - 비즈니스 로직 캡슐화:
temperature.isOverheat()처럼 메서드로 제공 - 유효성 검증 내장: 생성자에서 검증하여 잘못된 값 원천 차단
4. DTO (Data Transfer Object)
개념
계층 간 데이터를 전달하는 "택배 상자"입니다. 로직 없이 순수하게 데이터만 담습니다.
전통적인 방식
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserRequestDTO {
@NotBlank(message = "이름은 필수입니다")
private String name;
@Email(message = "올바른 이메일 형식이 아닙니다")
private String email;
private int age;
}
@Getter @Setter
public class UserResponseDTO {
private Long id;
private String name;
private String email;
private LocalDateTime createdAt;
public static UserResponseDTO from(User user) {
UserResponseDTO dto = new UserResponseDTO();
dto.setId(user.getId());
dto.setName(user.getName());
dto.setEmail(user.getEmail());
dto.setCreatedAt(user.getCreatedAt());
return dto;
}
}
Java Record로 간결하게 (Java 14+)
public record UserRequestDTO(
@NotBlank String name,
@Email String email,
int age
) {}
public record UserResponseDTO(
Long id,
String name,
String email,
LocalDateTime createdAt
) {
public static UserResponseDTO from(User user) {
return new UserResponseDTO(
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedAt()
);
}
}
Record는 불변이고, equals(), hashCode(), toString()이 자동 생성됩니다.
DTO 네이밍 컨벤션
// 요청용
UserCreateRequestDTO
UserUpdateRequestDTO
// 응답용
UserResponseDTO
UserListResponseDTO
UserSimpleResponseDTO
5. Entity
개념
DB 테이블과 1:1로 매핑되는 객체입니다. 고유 식별자(ID)로 구분됩니다.
이름이 같은 "홍길동"이 100명 있어도 주민등록번호로 각각을 구분하듯, Entity도 ID로 구분합니다.
코드 예시
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@CreatedDate
private LocalDateTime createdAt;
@Builder
public User(String name, String email) {
this.name = name;
this.email = email;
}
// ID로 동등성 비교
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(id, user.id); // ID만 비교!
}
@Override
public int hashCode() {
return Objects.hash(id);
}
// 비즈니스 로직 (Setter 대신)
public void changeEmail(String newEmail) {
if (newEmail == null || newEmail.isBlank()) {
throw new IllegalArgumentException("이메일은 필수입니다");
}
this.email = newEmail;
}
}
Setter를 지양하는 이유
// Bad: Setter 남발
user.setName("홍길동");
user.setEmail("test@test.com");
// 어디서든 마음대로 변경 가능 → 의도 불명확
// Good: 의미 있는 메서드
user.changeEmail("new@test.com"); // "이메일 변경"이라는 의도가 명확
Entity vs VO 동등성 비교
// Entity: ID로 비교
User user1 = userRepository.findById(1L);
User user2 = userRepository.findById(1L);
user1.equals(user2); // true (같은 ID)
// VO: 값으로 비교
Money m1 = new Money(1000, "KRW");
Money m2 = new Money(1000, "KRW");
m1.equals(m2); // true (같은 값)
6. 실전! 함께 사용하는 방법
전체 흐름
Controller Service Repository DB
│ │ │ │
RequestDTO ────→ Entity 변환 ────→ Entity ────→ 저장
│ │ │ │
ResponseDTO ←──── DTO 변환 ←──── Entity ←──── 조회실전 코드
Entity (Order.java)
@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"user"}) // 연관관계 제외
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Embedded
private Money totalAmount; // VO
@Embedded
private Address shippingAddress; // VO
@Enumerated(EnumType.STRING)
private OrderStatus status;
@Builder
public Order(User user, Money totalAmount, Address shippingAddress) {
this.user = user;
this.totalAmount = totalAmount;
this.shippingAddress = shippingAddress;
this.status = OrderStatus.PENDING;
}
// Setter 대신 의미 있는 메서드
public void complete() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("대기 상태에서만 완료 가능");
}
this.status = OrderStatus.COMPLETED;
}
}
DTO
public record OrderCreateRequest(
Long userId,
int amount,
String currency,
String city,
String detail
) {
public Money toMoney() {
return new Money(amount, currency);
}
public Address toAddress() {
return new Address(city, detail);
}
}
public record OrderResponse(
Long orderId,
String userName,
int totalAmount,
String status
) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
order.getUser().getName(),
order.getTotalAmount().getAmount(),
order.getStatus().name()
);
}
}
Controller
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody OrderCreateRequest request) {
Order order = orderService.createOrder(request);
return ResponseEntity.ok(OrderResponse.from(order));
}
}
Service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
@Transactional
public Order createOrder(OrderCreateRequest request) {
User user = userRepository.findById(request.userId())
.orElseThrow(() -> new IllegalArgumentException("사용자 없음"));
Order order = Order.builder()
.user(user)
.totalAmount(request.toMoney())
.shippingAddress(request.toAddress())
.build();
return orderRepository.save(order);
}
}
7. 자주 하는 질문
Q1. DTO에 비즈니스 로직 넣어도 되나요?
No. DTO는 데이터 전달만. 변환 로직(from())은 OK, 비즈니스 로직은 Service나 Entity에.
Q2. Entity를 Controller에서 직접 반환해도 되나요?
권장하지 않습니다.
- 순환 참조 문제 (JSON 직렬화 시 무한 루프)
- 민감한 필드 노출 위험
- Entity 변경이 API 스펙에 영향
Q3. Record vs 일반 클래스?
| 상황 | 추천 |
|---|---|
| 응답 DTO | Record |
| 요청 DTO | 둘 다 OK |
| 복잡한 변환 | 클래스 (Builder 필요 시) |
Q4. VO를 왜 써야 하나요?
// Bad: 원시 타입
private int amount; // 금액? 수량?
private String currency; // amount와 연관?
// Good: VO
private Money totalAmount; // 명확!
8. Lombok 어노테이션 가이드
Lombok을 사용할 때 VO, DTO, Entity 각각에 맞는 어노테이션을 써야 합니다. 잘못 쓰면 불변 객체가 가변이 되거나, Entity에서 순환 참조가 발생할 수 있습니다.
VO - 불변을 지켜라
| 어노테이션 | 사용 여부 | 이유 |
|---|---|---|
@Getter |
O | 값 조회 필요 |
@EqualsAndHashCode |
O | 값으로 동등성 비교 |
@ToString |
O | 디버깅용 |
@RequiredArgsConstructor |
O | final 필드 생성자 |
@Value |
O | 위 4개 + 불변 한방에 해결 |
@Setter |
X | 불변 객체에 Setter 금지 |
@Data |
X | Setter 포함되어 있음 |
// 방법 1: 개별 어노테이션
@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public class Money {
private final int amount;
private final String currency;
public Money add(Money other) {
return new Money(this.amount + other.amount, this.currency);
}
}
// 방법 2: @Value (한방에 불변 객체)
@Value
public class Money {
int amount; // 자동으로 private final
String currency;
public Money add(Money other) {
return new Money(this.amount + other.amount, this.currency);
}
}
주의:
@EmbeddableVO는 JPA가 기본 생성자를 요구하므로@NoArgsConstructor(access = AccessLevel.PROTECTED)를 추가해야 합니다.
@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA용
@AllArgsConstructor
public class Coordinate {
private double latitude;
private double longitude;
}
DTO - 상황에 따라 선택
| 어노테이션 | 사용 여부 | 이유 |
|---|---|---|
@Data |
O | 가변 DTO에 편리 (Getter+Setter+ToString+EqualsAndHashCode) |
@Getter @Setter |
O | @Data 대신 명시적으로 |
@NoArgsConstructor |
O | Jackson 역직렬화에 필요 |
@AllArgsConstructor |
O | 모든 필드 초기화 |
@Builder |
O | 복잡한 DTO 생성 시 |
// 방법 1: @Data (가변 DTO)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserRequestDTO {
@NotBlank
private String name;
@Email
private String email;
private int age;
}
// 방법 2: Record (불변 DTO, Lombok 불필요)
public record UserRequestDTO(
@NotBlank String name,
@Email String email,
int age
) {}
Record vs @Data 선택 기준:
| 상황 | 추천 |
|---|---|
| Java 14+ & 불변 원할 때 | Record |
| MapStruct 사용 | @Data (Setter 필요) |
| 복잡한 변환 로직 | @Data + @Builder |
Entity - Setter 절대 금지
| 어노테이션 | 사용 여부 | 이유 |
|---|---|---|
@Getter |
O | 조회 필요 |
@NoArgsConstructor(access = PROTECTED) |
O | JPA 필수, 외부 생성 방지 |
@Builder |
O | 생성자 대신 빌더로 객체 생성 |
@ToString(exclude = "연관관계") |
O | 순환 참조 방지 |
@EqualsAndHashCode(of = "id") |
△ | ID만 비교 (또는 직접 구현) |
@Setter |
X | 무분별한 변경 방지 |
@Data |
X | Setter + EqualsAndHashCode 문제 |
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(exclude = {"orders"}) // 연관관계 필드 제외
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@OneToMany(mappedBy = "user")
private List<Order> orders = new ArrayList<>();
@Builder
public User(String name, String email) {
this.name = name;
this.email = email;
}
// Setter 대신 의미 있는 메서드
public void changeEmail(String newEmail) {
this.email = newEmail;
}
// ID로 동등성 비교 (직접 구현 권장)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return id != null && id.equals(user.id);
}
@Override
public int hashCode() {
return getClass().hashCode(); // 프록시 고려
}
}
한눈에 보는 Lombok 정리
| 구분 | 권장 | 금지 |
|---|---|---|
| VO | @Value 또는 @Getter @EqualsAndHashCode @RequiredArgsConstructor |
@Setter, @Data |
| DTO | @Data 또는 Record |
- |
| Entity | @Getter @NoArgsConstructor(access=PROTECTED) @Builder |
@Setter, @Data |
9. 정리
결정 가이드
이 객체가 어디서 쓰이나요?
├── DB에 저장? → Entity
├── API 요청/응답? → DTO
└── 값 자체가 의미? (금액, 좌표) → VO사용 위치
| 위치 | 사용 |
|---|---|
| Controller | RequestDTO, ResponseDTO |
| Service | Entity, VO |
| Repository | Entity |
참고 자료
'Spring' 카테고리의 다른 글
| Deprecated: 코드의 정중한 은퇴 통보 (0) | 2025.11.12 |
|---|---|
| JPA @MappedSuperclass 애노테이션 완벽 이해하기 (0) | 2025.07.24 |
| [@Transactional은 만능이 아니다!] Checked Exception과 @Transactional: 초보 스프링 개발자를 위한 가이드 (0) | 2025.01.23 |
| MSA(마이크로서비스 아키텍처) vs 모놀리식 아키텍처 비교 (0) | 2025.01.09 |
| [Spring; 스프링] 직렬화와 역직렬화: Jackson ObjectMapper와 스프링부트에서의 활용 (0) | 2024.09.23 |