1. 예외의 종류
1-1. Checked Exception (체크 예외)
체크 예외는 Exception 클래스를 상속하지만 RuntimeException은 상속하지 않는 예외를 의미한다.
이름에서 알 수 있듯이 "체크"라는 건 컴파일러가 체크한다는 의미다. 컴파일 시점에 이 예외를 처리했는지 안 했는지를 컴파일러가 확인한다.
만약 체크 예외를 처리하지 않으면 컴파일 자체가 안 된다. 그래서 개발자는 반드시 try-catch 블록으로 예외를 잡아서 처리하거나, 메서드 시그니처에 throws 키워드를 붙여서 "나는 이 예외를 처리 안 하고 위로 던질게요"라고 명시해야 한다.
대표적인 예로 IOException, SQLException, FileNotFoundException 등이 있다. 이런 예외들을 보면 공통점이 있다. 파일을 읽거나, 데이터베이스에 접근하거나, 네트워크 통신을 하는 등 외부 리소스와 상호작용할 때 발생한다는 점이다.
왜 이런 예외들을 체크 예외로 만들었을까?
외부 요인 때문에 발생하는 예외는 개발자가 예측할 수 있고, 적절히 처리하면 복구할 수도 있기 때문이다. 예를 들어 파일이 없으면 새로 만들거나, DB 연결이 끊기면 재연결을 시도하는 식으로 말이다. 그래서 컴파일러가 "이 예외는 발생할 수 있으니 미리 처리 방법을 생각해둬"라고 강제하는 것이다.
// 체크 예외는 반드시 처리해야 한다
public void readFile() throws IOException {
FileReader reader = new FileReader("file.txt");
}
// 또는 try-catch로 처리
public void readFile() {
try {
FileReader reader = new FileReader("file.txt");
} catch (FileNotFoundException e) {
// 예외 처리 로직
System.out.println("파일이 없습니다. 새로 생성합니다.");
}
}
1-2. Unchecked Exception (언체크 예외 = Runtime Exception)
언체크 예외는 RuntimeException 클래스를 상속하는 예외다. "언체크"라는 이름처럼 컴파일러가 체크하지 않는다.
이 예외를 처리하든 안 하든 컴파일은 통과한다. 개발자가 알아서 판단해서 처리하면 된다.
"Runtime Exception"이라고도 부르는데, 이게 중요한 포인트다. 런타임, 그러니까 프로그램이 실행되는 중에 발생하는 예외라는 의미다. 컴파일 시점에는 문법적으로 문제가 없어 보이지만, 실제로 실행해보니 터지는 것이다.
대표적인 예로 NullPointerException, IllegalArgumentException, IndexOutOfBoundsException, ArithmeticException 등이 있다.
이런 예외들의 공통점은 뭘까? 바로 개발자의 실수 때문에 발생한다는 점이다.
NullPointerException은 null 체크를 안 해서 생기고, IndexOutOfBoundsException은 배열 인덱스 범위를 잘못 계산해서 생기고, ArithmeticException은 0으로 나누는 등의 잘못된 연산을 해서 생긴다.
이런 건 외부 요인이 아니라 코드 자체의 버그다. 그래서 컴파일러가 "이건 네가 알아서 해. 내가 체크 안 해줄게"라고 하는 것이다.
// 언체크 예외는 처리를 강제하지 않는다
public void divide(int a, int b) {
int result = a / b; // b가 0이면 ArithmeticException 발생 가능
// 하지만 컴파일은 된다
}
public String getName(User user) {
return user.getName(); // user가 null이면 NullPointerException 발생 가능
// 이것도 컴파일은 된다
}
// 물론 처리하고 싶으면 처리할 수 있다
public void divideWithCheck(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("0으로 나눌 수 없습니다");
}
int result = a / b;
}
2. 트랜잭션 롤백 규칙
2-1. Spring의 기본 롤백 정책
여기서부터가 헷갈리는 부분이다.
Spring Framework에서 @Transactional 어노테이션을 사용할 때, 예외가 발생하면 자동으로 롤백해준다고 알고 있을 것이다.
Spring의 기본 정책은 이렇다. Unchecked Exception(RuntimeException 및 그 하위 클래스)이 발생하면 자동으로 롤백한다. 하지만 Checked Exception이 발생하면 롤백하지 않고 그냥 커밋해버린다.
왜 이렇게 설계했을까? 다시 예외의 본질로 돌아가보자. Checked Exception은 외부 요인으로 발생하고 복구 가능성이 있는 예외다.
예를 들어 이메일 전송이 실패했다고 해서 계좌이체를 취소할 필요는 없다. 그래서 Spring은 "Checked Exception은 복구 가능하니까 트랜잭션은 그대로 유지할게"라고 판단한다.
반면 Unchecked Exception(RuntimeException)은 개발자의 실수나 프로그래밍 오류다. NullPointerException이 발생했다는 건 뭔가 로직이 잘못됐다는 의미다. 이런 경우 데이터가 일부만 저장되면 안 되니까 전부 롤백하는 게 맞다.
그래서 Spring은 "RuntimeException은 버그니까 다 되돌릴게"라고 판단한다.
@Transactional
public void method1() {
userRepository.save(user);
// RuntimeException 발생 시 자동 롤백된다
throw new IllegalArgumentException("잘못된 인자");
}
@Transactional
public void method2() throws IOException {
userRepository.save(user);
// IOException 발생 시 롤백되지 않는다!
// user는 저장된 상태로 커밋된다
throw new IOException("파일 오류");
}
실수 포인트~
"어? @Transactional 붙였는데 왜 롤백이 안 되지?"
Checked Exception이 발생했는데 기본 정책이 롤백 안 하는 것이기 때문
2-2. 롤백 정책 커스터마이징
Spring은 이 정책을 바꿀 수 있는 방법을 제공한다. @Transactional 어노테이션에 rollbackFor 속성을 추가하면 된다.
rollbackFor 속성을 사용하면 "이 예외가 발생하면 롤백해줘"라고 명시할 수 있다.
반대로 noRollbackFor 속성을 사용하면 "이 예외가 발생해도 롤백하지 마"라고 명시할 수 있다.
// 모든 Exception에 대해 롤백하도록 설정
@Transactional(rollbackFor = Exception.class)
public void method1() throws Exception {
// 이제 Checked Exception이 발생해도 롤백된다
}
// 특정 Checked Exception만 롤백하도록 설정
@Transactional(rollbackFor = {SQLException.class, IOException.class})
public void method2() throws Exception {
// SQLException이나 IOException 발생 시에만 롤백된다
}
// 특정 Unchecked Exception은 롤백하지 않도록 설정
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void method3() {
// IllegalArgumentException 발생 시 롤백하지 않는다
// 나머지 RuntimeException은 여전히 롤백된다
}
// 여러 설정을 조합할 수도 있다
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = IllegalArgumentException.class
)
public void method4() throws Exception {
// 모든 Exception은 롤백하지만 IllegalArgumentException만 제외
}
많은 프로젝트에서 rollbackFor = Exception.class를 기본으로 사용한다.
이렇게 하면 모든 예외에 대해 롤백이 보장되니까 안전하다. 그리고 정말 롤백하면 안 되는 특수한 경우에만 noRollbackFor를 추가하는 방식으로 가는 것이다.
2-3. 롤백이 중요한 이유
이게 왜 중요할까?
@Transactional
public void processOrder(Order order) throws PaymentException {
// 1. 재고 차감
inventoryService.decreaseStock(order); // 성공
// 2. 결제 처리
paymentService.process(order); // PaymentException 발생 (Checked Exception)
// 문제: 기본 정책은 Checked Exception에 대해 롤백하지 않는다
// 결과: 재고는 차감되었는데 결제는 실패 → 데이터 불일치!
}
이런 상황을 생각해보자. 재고는 차감했는데 결제가 실패했다.
그런데 @Transactional의 기본 정책 때문에 롤백이 안 된다. 결과적으로 재고는 줄었는데 돈은 안 받은 상황이 된다.
이게 바로 데이터 정합성이 깨지는 순간이다.
올바른 방법은
@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws PaymentException {
inventoryService.decreaseStock(order); // 성공
paymentService.process(order); // PaymentException 발생
// 롤백됨 → 재고 차감도 취소됨 → 데이터 정합성 유지
}
rollbackFor = Exception.class를 추가하면 PaymentException이 발생했을 때 재고 차감도 함께 취소된다.
이게 우리가 원하는 트랜잭션의 원자성이다. 전부 성공하거나, 전부 실패하거나 둘 중 하나여야 한다.
실무에서 이런 실수는 생각보다 자주 발생한다. 특히 외부 API를 호출하는 경우, 대부분의 라이브러리들이 Checked Exception을 던지기 때문이다. 그래서 항상 롤백 정책을 명확히 설정해야 한다.
3. Checked Exception을 Unchecked Exception으로 변환
3-1. 변환 패턴
실무에서는 Checked Exception을 Unchecked Exception으로 감싸서 던지는 패턴을 자주 사용한다.
이게 무슨 말이냐면, Checked Exception을 catch한 다음에 RuntimeException이나 그 하위 클래스로 다시 던지는 것이다.
public void saveUser(User user) {
try {
// Checked Exception 발생 가능
userRepository.save(user);
} catch (SQLException e) {
// Unchecked Exception으로 변환해서 던진다
throw new RuntimeException("사용자 저장 실패", e);
}
}
// 또는 커스텀 예외를 만들어서 사용
public void saveUser(User user) {
try {
userRepository.save(user);
} catch (SQLException e) {
throw new DataAccessException("사용자 저장 실패", e);
}
}
여기서 DataAccessException은 RuntimeException을 상속한 커스텀 예외라고 가정한다.
실제로 Spring의 DataAccessException이 바로 이런 구조다.
3-2. 변환하는 이유
왜 이런 번거로운 짓을 할까? 세 가지 큰 이유가 있다.
첫째, 트랜잭션 롤백을 보장하기 위해서다.
앞에서 배웠듯이 Checked Exception은 기본적으로 롤백되지 않는다.
하지만 RuntimeException으로 변환하면 자동으로 롤백된다. 특히 SQLException 같은 경우, 대부분 복구 불가능한 오류인데 Checked Exception이라서 롤백이 안 되는 문제가 있다.
그래서 이걸 RuntimeException으로 감싸면 자연스럽게 롤백이 된다.
// 변환 전: SQLException은 Checked Exception
@Transactional
public void updateUser(User user) throws SQLException {
userRepository.update(user); // SQLException 발생 시 롤백 안 됨
}
// 변환 후: RuntimeException으로 감싸면 자동 롤백
@Transactional
public void updateUser(User user) {
try {
userRepository.update(user);
} catch (SQLException e) {
throw new DataAccessException(e); // 이제 롤백됨
}
}
둘째, 호출자의 부담을 줄이기 위해서다.
Checked Exception을 사용하면 모든 호출 체인에서 throws를 선언하거나 try-catch로 처리해야 한다.
// Checked Exception을 그대로 사용하는 경우
public void method1() throws SQLException {
method2(); // SQLException을 던지니까 나도 throws 해야 함
}
public void method2() throws SQLException {
method3(); // SQLException을 던지니까 나도 throws 해야 함
}
public void method3() throws SQLException {
// SQLException 발생
connection.execute();
}
이렇게 하면 SQLException이 발생하는 method3부터 시작해서 method2, method1까지 모두 throws SQLException을 붙여야 한다.
만약 호출 체인이 10단계라면? 10개의 메서드 시그니처에 모두 throws를 붙여야 한다. 이게 얼마나 귀찮은 일인지.....
이걸 Unchecked Exception으로 변환하면 어떻게 될까?
// Unchecked Exception으로 변환한 경우
public void method1() {
method2(); // throws 필요 없음
}
public void method2() {
method3(); // throws 필요 없음
}
public void method3() {
try {
connection.execute();
} catch (SQLException e) {
throw new DataAccessException(e); // RuntimeException으로 변환
}
}
method1과 method2는 throws를 붙일 필요가 없다. 메서드가 훨씬 깔끔해진다.
그리고 예외 처리가 필요한 곳에서만 선택적으로 try-catch를 하면 된다.
셋째, 복구 불가능한 예외를 강제로 처리하지 않기 위해서다.
Checked Exception의 원래 의도는 "복구 가능한 예외는 미리 처리하라"는 것이었다. 하지만 실제로는 복구 불가능한 예외도 Checked Exception으로 설계된 경우가 많다.
대표적인 게 SQLException이다. DB 연결이 끊겼거나 테이블이 없거나 권한이 없는 등의 오류는 애플리케이션 레벨에서 복구할 수 없다. 그런데 Checked Exception이기 때문에 어쩔 수 없이 try-catch를 붙여야 한다. 그리고 catch 블록에서 뭘 할까? 대부분 그냥 로그 찍고 끝이다.
// 의미 없는 예외 처리
public void saveUser(User user) {
try {
userRepository.save(user);
} catch (SQLException e) {
// 뭘 할 수 있을까? 복구할 방법이 없다
logger.error("에러 발생", e);
// 그래도 throws를 안 하려면 여기서 처리해야 함
}
}
이런 의미 없는 try-catch를 피하기 위해 RuntimeException으로 변환하는 것이다.
그러면 복구 불가능한 예외는 그냥 위로 전파되고, 최상위 레벨(예: Controller의 ExceptionHandler)에서 일괄적으로 처리하면 된다.
3-3. 실무 적용 예시
실무에서는 보통 계층별로 예외를 변환한다. 각 계층마다 적절한 추상화 수준의 예외로 감싸는 것이다.
// Repository 계층: DB 관련 예외를 DataAccessException으로 변환
public class UserRepository {
public User findById(Long id) {
try {
// JDBC 코드
PreparedStatement ps = connection.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
return mapToUser(rs);
} catch (SQLException e) {
// SQLException을 DataAccessException(RuntimeException)으로 변환
throw new DataAccessException("사용자 조회 실패: " + id, e);
}
}
public void save(User user) {
try {
// JDBC 코드
PreparedStatement ps = connection.prepareStatement("INSERT INTO users ...");
// ...
} catch (SQLException e) {
throw new DataAccessException("사용자 저장 실패", e);
}
}
}
// Service 계층: 비즈니스 로직 수행
@Transactional
public class UserService {
private final UserRepository userRepository;
public void updateUser(Long id, UserDto dto) {
// Repository에서 DataAccessException(RuntimeException)이 발생할 수 있다
User user = userRepository.findById(id);
// 비즈니스 로직
user.update(dto);
// 저장 시에도 DataAccessException이 발생할 수 있다
userRepository.save(user);
// DataAccessException은 RuntimeException이므로 자동으로 롤백된다
}
}
// Controller 계층: HTTP 응답 처리
@RestController
public class UserController {
private final UserService userService;
@PutMapping("/users/{id}")
public ResponseEntity<?> updateUser(
@PathVariable Long id,
@RequestBody UserDto dto) {
try {
userService.updateUser(id, dto);
return ResponseEntity.ok().build();
} catch (DataAccessException e) {
// 필요한 경우에만 여기서 처리
logger.error("사용자 업데이트 실패", e);
return ResponseEntity.internalServerError()
.body("서버 오류가 발생했습니다");
}
}
}
이렇게 계층별로 적절한 예외로 변환하면 코드가 훨씬 깔끔해진다.
Service 계층은 SQLException 같은 저수준 예외를 알 필요가 없고, 그냥 DataAccessException만 신경 쓰면 된다.
이게 바로 추상화의 힘이다.
참고로 Spring을 사용하면 이런 변환을 자동으로 해준다. Spring의 JdbcTemplate이나 JPA를 사용하면 SQLException을 내부적으로 DataAccessException으로 변환해서 던진다. 그래서 우리는 SQLException을 직접 처리할 일이 거의 없다.
3-4. Spring의 PSA (Portable Service Abstraction)
앞에서 "Spring을 사용하면 이런 변환을 자동으로 해준다"고 했는데, 이게 바로 Spring의 PSA(Portable Service Abstraction) 덕분이다. PSA는 Spring이 제공하는 핵심 기능 중 하나로, 기술 구현체를 감싸서 일관된 인터페이스를 제공하는 추상화 계층이다.
PSA란 무엇인가?
PSA는 Portable Service Abstraction의 약자다. 말 그대로 "이식 가능한 서비스 추상화"다. 여기서 핵심은 두 가지다.
첫째, Portable(이식 가능)이라는 건 기술을 바꿔도 코드를 거의 수정하지 않아도 된다는 의미다. JDBC를 쓰다가 JPA로 바꿔도, 또는 Hibernate로 바꿔도 동일한 방식으로 사용할 수 있다.
둘째, Service Abstraction(서비스 추상화)은 구체적인 기술의 세부사항을 감추고 통일된 인터페이스를 제공한다는 의미다. 개발자는 JDBC의 복잡한 API를 직접 다루지 않아도 되고, Spring이 제공하는 간단한 인터페이스만 사용하면 된다.
예외 변환에서의 PSA
가장 대표적인 예가 바로 예외 변환이다. JDBC를 직접 사용하면 SQLException(Checked Exception)을 계속 처리해야 한다. 하지만 Spring의 JdbcTemplate이나 JPA를 사용하면 이 번거로움이 사라진다.
// JDBC를 직접 사용하면 (PSA 없이)
public void saveUser(User user) throws SQLException {
Connection conn = dataSource.getConnection();
try {
PreparedStatement ps = conn.prepareStatement("INSERT INTO users VALUES (?, ?)");
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.executeUpdate();
} finally {
conn.close();
}
// SQLException을 계속 throws 하거나 try-catch 해야 함
}
// Spring JdbcTemplate을 사용하면 (PSA 적용)
public void saveUser(User user) {
jdbcTemplate.update(
"INSERT INTO users VALUES (?, ?)",
user.getName(),
user.getEmail()
);
// SQLException → DataAccessException으로 자동 변환됨
// throws 필요 없음!
}
Spring이 내부적으로 어떻게 동작하는지 보자
- JdbcTemplate 내부에서 JDBC 코드 실행
- SQLException 발생
- Spring이 자동으로 DataAccessException(RuntimeException)으로 변환
- 개발자는 SQLException을 전혀 신경 쓰지 않아도 됨
기술 독립성 - Portable의 의미
PSA의 가장 큰 장점은 기술 독립성이다.
JDBC를 사용하든 JPA를 사용하든, 동일한 DataAccessException 계층 구조를 사용한다.
// JDBC 사용
@Repository
public class UserJdbcRepository {
private final JdbcTemplate jdbcTemplate;
public void save(User user) {
jdbcTemplate.update("INSERT INTO users ...", params);
// DataAccessException 발생 가능
}
}
// JPA로 변경해도
@Repository
public class UserJpaRepository {
private final EntityManager entityManager;
public void save(User user) {
entityManager.persist(user);
// 똑같이 DataAccessException 발생 가능
}
}
// Service 계층의 예외 처리 코드는 동일
@Service
public class UserService {
public void createUser(User user) {
try {
userRepository.save(user); // JDBC든 JPA든 상관없음
} catch (DataAccessException e) {
// 동일한 예외 처리
logger.error("사용자 생성 실패", e);
}
}
}
JDBC를 쓰다가 JPA로 바꿔도 Service 계층의 코드는 전혀 수정할 필요가 없다.
둘 다 DataAccessException을 던지기 때문이다. 이게 바로 "Portable"의 의미다.
통일된 예외 계층 구조
Spring은 데이터 접근 기술마다 다른 예외들을 하나의 계층 구조로 통일했다. DataAccessException을 최상위로 하는 예외 계층을 제공한다.
DataAccessException (RuntimeException)
├── DataIntegrityViolationException // 제약 조건 위반
│ ├── DuplicateKeyException // 중복 키 (PK, Unique)
│ └── DataIntegrityViolationException
├── DataAccessResourceFailureException // DB 연결 실패
├── DeadlockLoserDataAccessException // 데드락
├── OptimisticLockingFailureException // 낙관적 락 실패
├── PessimisticLockingFailureException // 비관적 락 실패
└── ...
JDBC의 SQLException도, JPA의 PersistenceException도, Hibernate의 HibernateException도 모두 이 계층 구조로 변환된다. 그래서 개발자는 구체적인 기술을 몰라도 예외 처리를 할 수 있다.
@Service
public class UserService {
public void registerUser(User user) {
try {
userRepository.save(user);
} catch (DuplicateKeyException e) {
// JDBC든 JPA든, 중복 키 오류는 DuplicateKeyException으로 통일
throw new BusinessException("이미 존재하는 사용자입니다");
} catch (DataAccessException e) {
// 기타 DB 오류
throw new BusinessException("사용자 등록에 실패했습니다");
}
}
}
PSA의 다른 예시들
PSA는 예외 변환뿐만 아니라 Spring 곳곳에서 사용된다.
// 트랜잭션 PSA: JDBC든 JPA든 동일한 @Transactional 사용
@Transactional
public void businessLogic() {
// JDBC TransactionManager든 JPA TransactionManager든 상관없음
}
// 캐시 PSA: EhCache든 Redis든 동일한 @Cacheable 사용
@Cacheable("users")
public User getUser(Long id) {
// 구현체와 무관하게 동일한 어노테이션 사용
}
// 메시징 PSA: JMS든 Kafka든 동일한 방식 사용
@JmsListener(destination = "myQueue")
public void handleMessage(String message) {
// 메시징 기술과 무관하게 동일한 방식
}
이렇게 Spring의 PSA는 구체적인 기술의 세부사항을 감추고, 개발자가 비즈니스 로직에만 집중할 수 있게 해준다.
그리고 나중에 기술을 바꿔야 할 때도 코드 수정을 최소화할 수 있다.
이게 바로 "Spring을 사용하면 SQLException을 직접 처리할 일이 거의 없다"는 말의 의미다.
3-5. 주의사항
예외를 변환할 때 주의할 점이 있다. 원본 예외(cause)를 반드시 포함해야 한다는 것이다.
// 올바른 변환: 원본 예외를 cause로 전달
try {
// ...
} catch (SQLException e) {
throw new DataAccessException("DB 오류", e); // e를 두 번째 인자로 전달
}
// 잘못된 변환: 원본 예외를 버림
try {
// ...
} catch (SQLException e) {
throw new DataAccessException("DB 오류"); // 원본 예외 정보 손실
}
왜 원본 예외를 포함해야 할까? 디버깅 때문이다.
예외가 발생하면 스택 트레이스를 봐야 하는데, 원본 예외를 포함하지 않으면 정확히 어디서 뭐가 잘못됐는지 알 수 없다.
예를 들어 "DB 오류"라는 메시지만 있으면 뭐가 문제인지 모른다. 하지만 원본 SQLException을 포함하면 "테이블이 없습니다" 같은 구체적인 정보를 볼 수 있다. 그래서 항상 원본 예외를 cause로 전달해야 한다.
또 하나 주의할 점은, 무조건 모든 Checked Exception을 변환하는 건 아니라는 것이다. 정말로 복구 가능한 예외라면 Checked Exception으로 남겨두는 게 맞다.
예를 들어 사용자 입력 검증 실패는 복구 가능하다. 잘못된 입력을 받으면 사용자에게 "다시 입력하세요"라고 알려줄 수 있으니까. 이런 경우는 굳이 RuntimeException으로 변환할 필요가 없다.
// 복구 가능한 예외는 Checked Exception으로 유지
public class UserService {
public void registerUser(UserDto dto) throws ValidationException {
if (dto.getAge() < 0) {
// 이건 복구 가능 (사용자가 다시 입력할 수 있음)
throw new ValidationException("나이는 0 이상이어야 합니다");
}
// ...
}
}
// Controller에서 적절히 처리
@PostMapping("/users")
public ResponseEntity<?> registerUser(@RequestBody UserDto dto) {
try {
userService.registerUser(dto);
return ResponseEntity.ok().build();
} catch (ValidationException e) {
// 사용자에게 친절한 메시지 반환
return ResponseEntity.badRequest().body(e.getMessage());
}
}
정리하자면, 복구 불가능한 Checked Exception(주로 인프라 관련)은 RuntimeException으로 변환하고, 복구 가능한 Checked Exception(주로 비즈니스 검증)은 그대로 유지하는 게 좋은 전략이다.
'공부일기.. > Java' 카테고리의 다른 글
| [Java] String 정리 (1) | 2025.12.16 |
|---|---|
| [디자인패턴] 자바 전략 패턴(Strategy Pattern) (0) | 2025.11.25 |
| [java] Enum 추상 메서드 활용과 이해 (0) | 2025.10.03 |
| [코테] JAVA 백준 알고리즘 시작하기 - 입출력 가이드 !!성능최적화!! (0) | 2025.09.29 |
| [java] 자바 파일 처리 기본기 가이드 - 주니어 개발자 필수 FILE/FILES (0) | 2025.09.22 |