Java & Spring boot, Thread Safety 하지 않은 Singleton 객체(JSONParser)

Spring boot에서 com.googlecode.json-simple:json-simple 라이브러리를 사용할 때 싱글톤으로 만들면 안되는 이유
김주혁's avatar
Nov 01, 2024
Java & Spring boot, Thread Safety 하지 않은 Singleton 객체(JSONParser)
 

 

문제 상황

 
String result = restTemplate.getForObject(BASE_URL + url, String.class); return (JSONObject) jsonParser.parse(result);
 
프로젝트에서 JSON 파싱 중 다음과 같은 다양한 에러들이 발생했습니다.
 
  • Unexpected character (") at position
  • ClassCastException: JSONObject cannot be cast to String
  • Unexpected token END OF FILE at position
  • Unexpected token VALUE(errorCode) at position 2048
  • Unexpected token LEFT BRACE({) at position
  • NullPointerException: Cannot read field "type" because "token" is null
 
서버 로그로 확인해본 결과, 확실히 문제가 발생하는 부분은 (JSONObject) jsonParser.parse(result); 로 특정됐습니다.
 
다른 프로젝트와 달리 JSONParser를 싱글턴으로 만들어서 사용중이란걸 확인하였고, 요청마다 JSONParser를 만드는 것으로 수정한 뒤 문제가 해결됐습니다.
 

Thread Safety 문제

 
왜 문제가 발생했을 까요? JSONParser의 Parse 코드를 살펴보면 JSON Parser는 내부적으로 파싱 상태를 파싱 상태를 유지하는 변수들을 가지고 있습니다.
Parse Code
public Object parse(Reader in, ContainerFactory containerFactory) throws IOException, ParseException { this.reset(in); LinkedList statusStack = new LinkedList(); LinkedList valueStack = new LinkedList(); try { do { this.nextToken(); Map parent; List newArray; String key; label67: switch (this.status) { case -1: throw new ParseException(this.getPosition(), 1, this.token); case 0: switch (this.token.type) { case 0: this.status = 1; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(this.token.value); break label67; case 1: this.status = 2; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(this.createObjectContainer(containerFactory)); break label67; case 2: default: this.status = -1; break label67; case 3: this.status = 3; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(this.createArrayContainer(containerFactory)); break label67; } case 1: if (this.token.type == -1) { return valueStack.removeFirst(); } throw new ParseException(this.getPosition(), 1, this.token); case 2: switch (this.token.type) { case 0: if (this.token.value instanceof String) { key = (String)this.token.value; valueStack.addFirst(key); this.status = 4; statusStack.addFirst(new Integer(this.status)); } else { this.status = -1; } break label67; case 2: if (valueStack.size() > 1) { statusStack.removeFirst(); valueStack.removeFirst(); this.status = this.peekStatus(statusStack); } else { this.status = 1; } case 5: break label67; default: this.status = -1; break label67; } case 3: List val; switch (this.token.type) { case 0: val = (List)valueStack.getFirst(); val.add(this.token.value); break label67; case 1: val = (List)valueStack.getFirst(); parent = this.createObjectContainer(containerFactory); val.add(parent); this.status = 2; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(parent); break label67; case 2: default: this.status = -1; break label67; case 3: val = (List)valueStack.getFirst(); newArray = this.createArrayContainer(containerFactory); val.add(newArray); this.status = 3; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(newArray); break label67; case 4: if (valueStack.size() > 1) { statusStack.removeFirst(); valueStack.removeFirst(); this.status = this.peekStatus(statusStack); } else { this.status = 1; } case 5: break label67; } case 4: switch (this.token.type) { case 0: statusStack.removeFirst(); key = (String)valueStack.removeFirst(); parent = (Map)valueStack.getFirst(); parent.put(key, this.token.value); this.status = this.peekStatus(statusStack); break; case 1: statusStack.removeFirst(); key = (String)valueStack.removeFirst(); parent = (Map)valueStack.getFirst(); Map newObject = this.createObjectContainer(containerFactory); parent.put(key, newObject); this.status = 2; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(newObject); break; case 2: case 4: case 5: default: this.status = -1; break; case 3: statusStack.removeFirst(); key = (String)valueStack.removeFirst(); parent = (Map)valueStack.getFirst(); newArray = this.createArrayContainer(containerFactory); parent.put(key, newArray); this.status = 3; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(newArray); case 6: } } if (this.status == -1) { throw new ParseException(this.getPosition(), 1, this.token); } } while(this.token.type != -1); } catch (IOException var9) { IOException ie = var9; throw ie; } throw new ParseException(this.getPosition(), 1, this.token); }
 
private LinkedList statusStack; // 파싱 상태를 추적하는 스택 private LinkedList valueStack; // 값을 저장하는 스택 private int status; // 현재 파싱 상태 private Token token; // 현재 처리 중인 토큰 // this.status = 1; statusStack.addFirst(new Integer(this.status)); valueStack.addFirst(this.token.value);
파싱 상태를 유지하는 각 변수들은 final 값이 아니며 계속해도 변동하는 값입니다.
 
만약 멀티 스레드로 Application이 돌아가는 상태에서 싱글톤 파서에 동시 다발적인 요청이 들어오면 상태 스택이 오염됩니다.
// Thread A statusStack.addFirst(new Integer(2)); // Object 파싱 시작 // Thread B가 끼어들어옴 statusStack.addFirst(new Integer(3)); // Array 파싱 시작 // Thread A가 다시 실행 // → 두 스레드가 경쟁 상태에 들어가며 Unexpected Token Exception 에러 발생
 
그리고 valueStack이 충돌하게 됩니다.
// Thread A valueStack.addFirst("key1"); // Object의 key 추가 // Thread B가 끼어들어옴 valueStack.addFirst(newArray); // Array 추가 // Thread A가 다시 실행 parent.put(key, this.token.value); // 잘못된 타입의 값 처리 시도 // → ClassCastException 발생 가능
 
따라서 문제의 원인는 싱글톤으로 Parser를 사용했을 때 Thread Safety 않은 것이 원인이었습니다.
 
Thread Safety하지 않다는 것은, 멀티 스레드 프로그래밍에서 함수나 변수 혹은 객체가 여러 스레드로부터 동시 접근이 이루어져 프로그램의 실행에 문제가 발생하는 것을 의미합니다.
 

JVM Generation과 Garbage Collector

 
JSON 파서를 싱글톤으로 사용하면, 파싱 과정에서 생성된 많은 임시 객체가 메모리에 남아 불필요하게 쌓이면서 메모리 관리 문제를 초래할 수 있습니다. JVM의 힙 메모리 구조와 GC 과정을 통해 살펴보면,
  • Young Generation
    • 역할: 새로운 객체가 생성되는 공간으로, 여기서 생성된 객체는 대부분 Eden 영역으로 할당됩니다.
    • 구성: Eden과 2개의 Survivor 영역으로 나뉩니다.
      • Eden 영역: 새로운 객체가 처음 할당되는 곳으로, Eden이 가득 차면 Minor GC가 발생하여 불필요한 객체들을 정리합니다.
      • Survivor 영역: Minor GC 후 Eden에서 살아남은 객체가 이동되는 곳입니다. 두 개의 Survivor 영역이 번갈아 사용되며, 한 Survivor 영역이 가득 차면 그 안의 객체는 다른 Survivor 영역으로 옮겨지고 가득 찬 영역은 비워집니다.
      • Old Generation 이동: Minor GC 후 여러 번 살아남아 특정 임계점을 초과한 객체는 Old Generation으로 이동됩니다.
    • Minor GC: Young Generation에서 발생하며, 모든 스레드가 멈추는 Stop-the-World 이벤트입니다.
  • 2. Old Generation
    • 역할: Young Generation을 여러 차례 통과해 살아남은 장기 객체가 저장되는 공간입니다.
    • Major GC: Old Generation이 가득 차게 되면 Major GC가 발생하여, 더 이상 사용되지 않는 객체를 정리합니다. Major GC 역시 Stop-the-World 이벤트이기 때문에 시스템 성능에 큰 영향을 미칩니다.
  • 3. Permanent Generation
    • 역할: 애플리케이션에서 사용되는 클래스 메타데이터, Jakarta 라이브러리 클래스와 함수 등을 저장합니다. 런타임 시 JVM에 의해 할당되며, 메모리 부족이 발생하면 GC를 통해 관리됩니다.
 
싱글톤 JSON 파서를 사용하는 경우, JSON 파싱 작업 중에 생성된 임시 객체들이 Young Generation에 남아 있게 됩니다. 이러한 객체가 Minor GC에서 제거되지 않으면 Old Generation으로 이동하게 되고, 이로 인해 Old Generation의 사용량이 증가합니다. 결과적으로, Old Generation이 가득 차면서 Full GC 빈도가 증가하고, 이는 애플리케이션 성능 저하를 유발합니다.
 

 

검증

 
싱글톤 Parser의 경쟁상태에서 동작 여부를 살펴보면,
 
스레드의 경쟁상태 코드
import org.json.simple.parser.JSONParser; import org.springframework.boot.autoconfigure.SpringBootApplication; import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; @SpringBootApplication public class DemoApplication { private static final JSONParser parser = new JSONParser(); private static final AtomicInteger errorCount = new AtomicInteger(0); private static final AtomicInteger successCount = new AtomicInteger(0); // 서로 다른 복잡도의 JSON 문자열들 private static final String[] TEST_JSONS = { "{\"key1\":\"value1\", \"key2\":{\"nested\":\"value\"}}", "[1,2,{\"array\":\"test\"}]", "{\"array\":[1,2,3],\"object\":{\"nested\":\"value\"}}", "{\"key\":\"value\"}" }; public static void main(String[] args) throws InterruptedException { // 스레드 풀 생성 ExecutorService executorService = Executors.newFixedThreadPool(10); CountDownLatch latch = new CountDownLatch(100); List<Future<ParsingResult>> futures = new ArrayList<>(); // 100개의 동시 파싱 작업 실행 for (int i = 0; i < 100; i++) { final int index = i % TEST_JSONS.length; Future<ParsingResult> future = executorService.submit(() -> { try { // 의도적으로 지연을 주어 경쟁 상태 발생 가능성 증가 Thread.sleep(ThreadLocalRandom.current().nextInt(10)); // JSON 파싱 시도 Object result = parser.parse(TEST_JSONS[index]); successCount.incrementAndGet(); return new ParsingResult(true, null); } catch (Exception e) { errorCount.incrementAndGet(); return new ParsingResult(false, e); } finally { latch.countDown(); } }); futures.add(future); } // 모든 작업이 완료될 때까지 대기 latch.await(); executorService.shutdown(); // 결과 분석 및 출력 System.out.println("테스트 완료!"); System.out.println("성공 횟수: " + successCount.get()); System.out.println("실패 횟수: " + errorCount.get()); System.out.println("\n발생한 에러들:"); for (Future<ParsingResult> future : futures) { try { ParsingResult result = future.get(); if (!result.success && result.error != null) { System.out.println("에러 타입: " + result.error.getClass().getSimpleName()); System.out.println("에러 메시지: " + result.error.getMessage()); System.out.println("--------------------"); } } catch (Exception e) { System.out.println("Future 처리 중 에러: " + e.getMessage()); } } } // 파싱 결과를 담는 클래스 private static class ParsingResult { final boolean success; final Exception error; ParsingResult(boolean success, Exception error) { this.success = success; this.error = error; } } // 더 정밀한 테스트를 위한 메소드 public static void stressTest() { // 파서의 상태를 의도적으로 조작하여 경쟁 상태 유발 ExecutorService executor = Executors.newFixedThreadPool(2); // 첫 번째 스레드: 긴 JSON 파싱 executor.submit(() -> { try { String complexJson = "{\"level1\":{\"level2\":{\"level3\":{\"data\":\"value\"}}}}"; parser.parse(complexJson); } catch (org.json.simple.parser.ParseException e) { throw new RuntimeException(e); } }); // 두 번째 스레드: 파싱 도중 간섭 executor.submit(() -> { try { // 첫 번째 스레드가 파싱을 시작하도록 잠시 대기 Thread.sleep(5); String simpleJson = "{\"key\":\"value\"}"; parser.parse(simpleJson); } catch (Exception e) { System.out.println("Thread 2 Error: " + e.getMessage()); } }); executor.shutdown(); } } // 실제 경쟁 상태를 더 잘 관찰하기 위한 모니터링 래퍼 class MonitoredJSONParser extends JSONParser { private final AtomicInteger parsingCount = new AtomicInteger(0); @Override public Object parse(String json) throws org.json.simple.parser.ParseException { int currentCount = parsingCount.incrementAndGet(); try { System.out.println("Parsing #" + currentCount + " started by thread: " + Thread.currentThread().getName()); Object result = super.parse(json); System.out.println("Parsing #" + currentCount + " completed successfully"); return result; } catch (org.json.simple.parser.ParseException e) { System.out.println("Parsing #" + currentCount + " failed with error: " + e.getMessage()); throw e; } finally { parsingCount.decrementAndGet(); } } }
 
위 코드를 5회 실행시키면 파싱 중 아래와 같은 결과를 확인할 수 있습니다.
성공
실패
1
53
47
2
49
46
3
50
46
4
50
47
5
54
41
실행 중 발생했던 에러는 다음과 같습니다.
  • ParseException
  • ArrayIndexOutOfBoundsException
  • NoSuchElementException
  • NullPointerException
 
parser를 향상된 모니터링을 위한 MonitoredJSONParser의 Parser로 변경하면
 
향상된 모니터링 스레드 파싱 경쟁상태 결과
Parsing #1 started by thread: pool-1-thread-3 Parsing #3 started by thread: pool-1-thread-10 Parsing #2 started by thread: pool-1-thread-4 Parsing #3 failed with error: null Parsing #2 failed with error: null Parsing #2 started by thread: pool-1-thread-10 Parsing #3 started by thread: pool-1-thread-9 Parsing #3 failed with error: null Parsing #2 failed with error: null Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-2 Parsing #2 started by thread: pool-1-thread-6 Parsing #2 failed with error: null Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #2 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-5 Parsing #2 started by thread: pool-1-thread-7 Parsing #1 started by thread: pool-1-thread-9 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-2 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #2 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-10 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-10 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-4 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-3 Parsing #2 started by thread: pool-1-thread-10 Parsing #2 failed with error: null Parsing #1 started by thread: pool-1-thread-6 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #2 started by thread: pool-1-thread-5 Parsing #1 completed successfully Parsing #3 started by thread: pool-1-thread-3 Parsing #3 started by thread: pool-1-thread-8 Parsing #3 completed successfully Parsing #2 completed successfully Parsing #3 failed with error: null Parsing #1 started by thread: pool-1-thread-6 Parsing #2 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-10 Parsing #2 started by thread: pool-1-thread-9 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #3 started by thread: pool-1-thread-8 Parsing #3 completed successfully Parsing #1 started by thread: pool-1-thread-6 Parsing #2 started by thread: pool-1-thread-7 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-4 Parsing #1 completed successfully Parsing #2 started by thread: pool-1-thread-2 Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-3 Parsing #2 started by thread: pool-1-thread-5 Parsing #2 failed with error: null Parsing #1 failed with error: null Parsing #1 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-7 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-4 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-7 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #2 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-7 Parsing #2 started by thread: pool-1-thread-2 Parsing #2 completed successfully Parsing #3 started by thread: pool-1-thread-10 Parsing #1 completed successfully Parsing #3 completed successfully Parsing #1 started by thread: pool-1-thread-6 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-6 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-2 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-5 Parsing #1 completed successfully Parsing #2 started by thread: pool-1-thread-1 Parsing #1 started by thread: pool-1-thread-4 Parsing #2 completed successfully Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #2 started by thread: pool-1-thread-10 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-6 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-2 Parsing #2 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #2 started by thread: pool-1-thread-4 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-5 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-7 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #2 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #2 started by thread: pool-1-thread-9 Parsing #2 completed successfully Parsing #2 failed with error: null Parsing #1 started by thread: pool-1-thread-10 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #2 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-10 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-2 Parsing #1 completed successfully Parsing #2 started by thread: pool-1-thread-6 Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-9 Parsing #2 started by thread: pool-1-thread-5 Parsing #1 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-4 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-7 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-1 Parsing #1 completed successfully Parsing #2 started by thread: pool-1-thread-5 Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-8 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-3 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-2 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-10 Parsing #2 started by thread: pool-1-thread-5 Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-4 Parsing #1 completed successfully Parsing #2 started by thread: pool-1-thread-1 Parsing #3 started by thread: pool-1-thread-9 Parsing #3 completed successfully Parsing #2 completed successfully Parsing #1 started by thread: pool-1-thread-6 Parsing #1 completed successfully Parsing #1 started by thread: pool-1-thread-7 Parsing #1 completed successfully 테스트 완료! 성공 횟수: 86 실패 횟수: 14
 
위와 같이 멀티 스레드가 하나의 파서에 들어가면서 경쟁상태가 유발되어 에러가 발생하는 것을 확인할 수 있습니다.
 

해결

 
  • Thread Local 방식
    •  
      public class ThreadSafeJsonParser { private static final ThreadLocal<JSONParser> parser = ThreadLocal.withInitial(() -> new JSONParser()); public static JSONObject parse(String json) { try { return (JSONObject) parser.get().parse(json); } catch (ParseException e) { throw new RuntimeException("JSON parsing failed", e); } } }
      스레드가 접근할 수 있는 스레드 만의 저장소 개념입니다. 스레드 단위로 로컬 변수를 사용할 수 있기 때문에 특정 스레드는 마치 전역변수처럼 여러 메서드에서 로컬 변수를 사용할 수 있습니다. ThreadLocal을 사용하여 각 스레드마다 독립적인 JSONParser 인스턴스를 생성합니다.
       
    • 장점
      • JSONParser 객체를 한 번만 생성해 성능이 좋습니다.
      • 각 스레드에 독립적인 인스턴스를 할당하여 경쟁 상태를 원천차단 합니다.
    • 단점
      • ThreadLocal에 저장된 객체는 현재 스레드가 종료될 때 까지 유지됩니다. 스레드 풀을 사용할 경우, 스레드가 종료되지 않고 풀에서 관리 되기 때문에 로컬 값이 유지되어 메모리 누수가 발생하기 때문에 메모리 관리가 필요합니다.
      • 개발자의 실수로 스레드 간에 ThreadLocal 데이터를 공유하는 경우 치명적인 사이드 이펙트가 발생할 수 있습니다. 예를 들어, 인증 토큰을 저장하고 있는 ThreadLocal 데이터에 다른 스레드가 접근하여 얘기치 못한 결과를 발생시킨다면 보안 문제를 발생시킬 수 있습니다.
 
  • Parser Pool 사용
    •  
      public class JsonParserPool { private final ObjectPool<JSONParser> pool; public JsonParserPool(int maxSize) { pool = new GenericObjectPool<>(new BasePooledObjectFactory<>() { @Override public JSONParser create() { return new JSONParser(); } @Override public PooledObject<JSONParser> wrap(JSONParser parser) { return new DefaultPooledObject<>(parser); } }, maxSize); } public JSONObject parse(String json) { JSONParser parser = null; try { parser = pool.borrowObject(); return (JSONObject) parser.parse(json); } catch (ParseException e) { throw new RuntimeException("JSON parsing failed", e); } finally { if (parser != null) { pool.returnObject(parser); } } } }
       
      JSONParser Pool을 만들어서 제한된 파서 인스턴스를 생성하고, 필요할 때 사용하는 구조입니다. borrowObject() 를 통해 JSONParser를 풀에서 가져오고 파싱 작업이 완료되면 returnObject()로 메서드를 반환하여 재사용합니다.
       
    • 장점
      • JSONParser를 재사용하여 불필요한 인스턴스 생성을 방지하고 메모리를 절약합니다.
      • 제한된 수의 Parser 인스턴스를 공유하므로 성능을 향상시킬 수 있습니다.
    • 단점
      • Parser Pool의 크기를 초과하는 동시 비동기 요청이 들어오게 되면 기다려야 하며, 높은 요청 빈도를 처리할 때 성능이 저하될 수 있습니다.
      • 복잡도가 올라가 유지보수가 어려워 질 수 있습니다.
      • 객체가 반환되지 않는 엣지 케이스가 발생할 시, 요청이 지연될 수 있습니다.
 
  • 매 요청마다 JSONParser 생성
    •  
      public class JsonParserFactory { public static JSONObject parse(String json) { JSONParser parser = new JSONParser(); try { return (JSONObject) parser.parse(json); } catch (ParseException e) { throw new RuntimeException("JSON parsing failed", e); } } }
       
      가장 직관적인 방식이면서 가장 문제가 생기지 않습니다. Parser가 단순 객치이기 때문에 생성 비용이 크지 않아, 멀티 스레드 환경에서도 성능 저하가 일어나기 어렵습니다.
       
    • 장점
      • 매 요청마다 새 인스턴스를 생성하므로 동시성 이슈가 발생하지 않으며, 코드가 간단하고 직관적입니다.
      • 각 요청마다 독립적인 인스턴스를 사용하므로 스레드 안전성 문제가 전혀 없습니다.
    • 단점
      • 매번 새로운 객체를 생성하므로 빈번한 파싱이 필요한 환경에서는 성능이 저하될 수 있습니다.
      • 인스턴스를 매번 새로 생성하므로, GC가 더 자주 발생할 가능성이 있습니다.
 

 

결론

 
세 가지 중에서 가장 추천하는 방식은 매 요청마다 새로운 Parser를 생성하는 방식입니다. 구현이 간단하고 직관적이며, Parser 객체 생성 비용이 크지 않아 실제 성능에 미치는 영향이 크지 않습니다. 특히 복잡한 동시성 이슈나 메모리 누수 걱정 없이 안전하게 사용할 수 있다는 장점이 있습니다.
 
더 높은 성능이 필요한 경우에는 ThreadLocal 방식을 고려해볼 수 있으나, 메모리 누수 방지를 위한 추가적인 관리가 필요하여 Application의 복잡도를 올리게 되고, 치명적인 사이드 이펙트가 발생할 수 있어 주의 깊게 사용해야 합니다.
 
발생하는 에러가 제각각이라 구체적인 문제를 파악하기 어려웠습니다만, 자바 Application을 개발하면서 멀티 스레드와 객체의 동작 방식에 대해 한 번 더 고민을 깊게 할 수 있던 과정이라고 생각합니다.
 
Share article

vlogue